From d6ec115348d0581fc2e6729298db7f31c776d1d6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Apr 2026 16:11:31 -0700 Subject: [PATCH 001/159] v0.6.29: login improvements, posthog telemetry (#4026) * feat(posthog): Add tracking on mothership abort (#4023) Co-authored-by: Theodore Li * fix(login): fix captcha headers for manual login (#4025) * fix(signup): fix turnstile key loading * fix(login): fix captcha header passing * Catch user already exists, remove login form captcha --- apps/sim/app/(auth)/signup/signup-form.tsx | 11 +++-------- .../app/workspace/[workspaceId]/home/home.tsx | 12 ++++++++++-- .../w/[workflowId]/components/panel/panel.tsx | 19 ++++++++++++++++++- apps/sim/lib/posthog/events.ts | 5 +++++ 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 55a0508ec1b..afb27cd729a 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -270,10 +270,8 @@ function SignupFormContent({ name: sanitizedName, }, { - fetchOptions: { - headers: { - ...(token ? { 'x-captcha-response': token } : {}), - }, + headers: { + ...(token ? { 'x-captcha-response': token } : {}), }, onError: (ctx) => { logger.error('Signup error:', ctx.error) @@ -282,10 +280,7 @@ function SignupFormContent({ let errorCode = 'unknown' if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) { errorCode = 'user_already_exists' - errorMessage.push( - 'An account with this email already exists. Please sign in instead.' - ) - setEmailError(errorMessage[0]) + setEmailError('An account with this email already exists. Please sign in instead.') } else if ( ctx.error.code?.includes('BAD_REQUEST') || ctx.error.message?.includes('Email and password sign up is not enabled') diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index d76f17ff454..38367339197 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -223,6 +223,14 @@ export function Home({ chatId }: HomeProps = {}) { posthogRef.current = posthog }, [posthog]) + const handleStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'mothership', + }) + stopGeneration() + }, [stopGeneration, workspaceId]) + const handleSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -334,7 +342,7 @@ export function Home({ chatId }: HomeProps = {}) { defaultValue={initialPrompt} onSubmit={handleSubmit} isSending={isSending} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} userId={session?.user?.id} onContextAdd={handleContextAdd} /> @@ -359,7 +367,7 @@ export function Home({ chatId }: HomeProps = {}) { isSending={isSending} isReconnecting={isReconnecting} onSubmit={handleSubmit} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} messageQueue={messageQueue} onRemoveQueuedMessage={removeFromQueue} onSendQueuedMessage={sendNow} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 4d485c763ce..da51910789b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { History, Plus, Square } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' +import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' import { BubbleChatClose, @@ -33,6 +34,7 @@ import { import { Lock, Unlock, Upload } from '@/components/emcn/icons' import { VariableIcon } from '@/components/icons' import { useSession } from '@/lib/auth/auth-client' +import { captureEvent } from '@/lib/posthog/client' import { generateWorkflowJson } from '@/lib/workflows/operations/import-export' import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components' @@ -101,6 +103,9 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const params = useParams() const workspaceId = propWorkspaceId ?? (params.workspaceId as string) + const posthog = usePostHog() + const posthogRef = useRef(posthog) + const panelRef = useRef(null) const fileInputRef = useRef(null) const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore( @@ -264,6 +269,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel loadCopilotChats() }, [loadCopilotChats]) + useEffect(() => { + posthogRef.current = posthog + }, [posthog]) + const handleCopilotSelectChat = useCallback((chat: { id: string; title: string | null }) => { setCopilotChatId(chat.id) setCopilotChatTitle(chat.title) @@ -394,6 +403,14 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel [copilotEditQueuedMessage] ) + const handleCopilotStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'copilot', + }) + copilotStopGeneration() + }, [copilotStopGeneration, workspaceId]) + const handleCopilotSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -833,7 +850,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel isSending={copilotIsSending} isReconnecting={copilotIsReconnecting} onSubmit={handleCopilotSubmit} - onStopGeneration={copilotStopGeneration} + onStopGeneration={handleCopilotStopGeneration} messageQueue={copilotMessageQueue} onRemoveQueuedMessage={copilotRemoveFromQueue} onSendQueuedMessage={copilotSendNow} diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 537a9864282..faf9895bf62 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -378,6 +378,11 @@ export interface PostHogEventMap { workspace_id: string } + task_generation_aborted: { + workspace_id: string + view: 'mothership' | 'copilot' + } + task_message_sent: { workspace_id: string has_attachments: boolean From 98c85677f50e0752b6af336c84f27673776ac859 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 29 Jun 2026 15:18:51 -0700 Subject: [PATCH 002/159] improvement(external-endpoints): v2 versions with clean signatures + updated docs --- .../docs/de/api-reference/getting-started.mdx | 2 +- .../content/docs/de/api-reference/meta.json | 9 +- .../(generated)/execution/meta.json | 3 + .../(generated)/workflows/meta.json | 6 +- .../docs/en/api-reference/getting-started.mdx | 2 +- .../content/docs/en/api-reference/meta.json | 8 +- .../en/platform/enterprise/audit-logs.mdx | 13 +- .../docs/es/api-reference/getting-started.mdx | 2 +- .../content/docs/es/api-reference/meta.json | 11 +- .../docs/fr/api-reference/getting-started.mdx | 2 +- .../content/docs/fr/api-reference/meta.json | 11 +- .../docs/ja/api-reference/getting-started.mdx | 2 +- .../content/docs/ja/api-reference/meta.json | 11 +- .../docs/zh/api-reference/getting-started.mdx | 2 +- .../content/docs/zh/api-reference/meta.json | 11 +- apps/docs/lib/openapi.ts | 80 +- apps/docs/openapi-core.json | 2948 +++++++++++++++++ apps/docs/openapi-v2-files-audit.json | 1125 +++++++ apps/docs/openapi-v2-knowledge.json | 1802 ++++++++++ apps/docs/openapi-v2-logs.json | 1065 ++++++ apps/docs/openapi-v2-tables.json | 2339 +++++++++++++ apps/docs/openapi-v2-workflows.json | 1024 ++++++ apps/sim/app/api/v1/admin/audit-logs/route.ts | 11 +- .../admin/organizations/[id]/billing/route.ts | 3 +- .../[id]/members/[memberId]/route.ts | 3 +- .../api/v1/admin/outbox/[id]/requeue/route.ts | 5 +- apps/sim/app/api/v1/admin/outbox/route.ts | 5 +- .../api/v1/admin/referral-campaigns/route.ts | 3 +- apps/sim/app/api/v1/audit-logs/auth.ts | 70 +- apps/sim/app/api/v1/logs/filters.ts | 12 +- apps/sim/app/api/v1/logs/route.ts | 2 +- apps/sim/app/api/v1/middleware.ts | 73 +- apps/sim/app/api/v2/audit-logs/[id]/route.ts | 76 + apps/sim/app/api/v2/audit-logs/route.ts | 103 + apps/sim/app/api/v2/files/[fileId]/route.ts | 124 + apps/sim/app/api/v2/files/route.ts | 236 ++ .../[id]/documents/[documentId]/route.ts | 209 ++ .../api/v2/knowledge/[id]/documents/route.ts | 306 ++ apps/sim/app/api/v2/knowledge/[id]/route.ts | 193 ++ apps/sim/app/api/v2/knowledge/route.ts | 140 + apps/sim/app/api/v2/knowledge/search/route.ts | 299 ++ apps/sim/app/api/v2/lib/response.ts | 144 + apps/sim/app/api/v2/logs/[id]/route.ts | 109 + .../v2/logs/executions/[executionId]/route.ts | 74 + apps/sim/app/api/v2/logs/route.ts | 168 + .../api/v2/tables/[tableId]/columns/route.ts | 269 ++ apps/sim/app/api/v2/tables/[tableId]/route.ts | 114 + .../v2/tables/[tableId]/rows/[rowId]/route.ts | 226 ++ .../app/api/v2/tables/[tableId]/rows/route.ts | 406 +++ .../v2/tables/[tableId]/rows/upsert/route.ts | 98 + apps/sim/app/api/v2/tables/route.ts | 140 + apps/sim/app/api/v2/tables/utils.ts | 103 + .../app/api/v2/workflows/[id]/deploy/route.ts | 169 + .../api/v2/workflows/[id]/rollback/route.ts | 122 + apps/sim/app/api/v2/workflows/[id]/route.ts | 81 + apps/sim/app/api/v2/workflows/route.ts | 142 + .../api/contracts/v1/admin/organizations.ts | 9 +- apps/sim/lib/api/contracts/v1/audit-logs.ts | 70 +- apps/sim/lib/api/contracts/v1/shared.ts | 44 + apps/sim/lib/api/contracts/v2/audit-logs.ts | 58 + apps/sim/lib/api/contracts/v2/files.ts | 112 + apps/sim/lib/api/contracts/v2/knowledge.ts | 270 ++ apps/sim/lib/api/contracts/v2/logs.ts | 123 + apps/sim/lib/api/contracts/v2/shared.ts | 39 + apps/sim/lib/api/contracts/v2/tables.ts | 321 ++ apps/sim/lib/api/contracts/v2/workflows.ts | 112 + .../orchestration/file-folder-lifecycle.ts | 10 +- bun.lock | 11 +- scripts/check-api-validation-contracts.ts | 4 +- 69 files changed, 15754 insertions(+), 145 deletions(-) create mode 100644 apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json create mode 100644 apps/docs/openapi-core.json create mode 100644 apps/docs/openapi-v2-files-audit.json create mode 100644 apps/docs/openapi-v2-knowledge.json create mode 100644 apps/docs/openapi-v2-logs.json create mode 100644 apps/docs/openapi-v2-tables.json create mode 100644 apps/docs/openapi-v2-workflows.json create mode 100644 apps/sim/app/api/v2/audit-logs/[id]/route.ts create mode 100644 apps/sim/app/api/v2/audit-logs/route.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/route.ts create mode 100644 apps/sim/app/api/v2/files/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/search/route.ts create mode 100644 apps/sim/app/api/v2/lib/response.ts create mode 100644 apps/sim/app/api/v2/logs/[id]/route.ts create mode 100644 apps/sim/app/api/v2/logs/executions/[executionId]/route.ts create mode 100644 apps/sim/app/api/v2/logs/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/columns/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/rows/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts create mode 100644 apps/sim/app/api/v2/tables/route.ts create mode 100644 apps/sim/app/api/v2/tables/utils.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/deploy/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/rollback/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/route.ts create mode 100644 apps/sim/app/api/v2/workflows/route.ts create mode 100644 apps/sim/lib/api/contracts/v1/shared.ts create mode 100644 apps/sim/lib/api/contracts/v2/audit-logs.ts create mode 100644 apps/sim/lib/api/contracts/v2/files.ts create mode 100644 apps/sim/lib/api/contracts/v2/knowledge.ts create mode 100644 apps/sim/lib/api/contracts/v2/logs.ts create mode 100644 apps/sim/lib/api/contracts/v2/shared.ts create mode 100644 apps/sim/lib/api/contracts/v2/tables.ts create mode 100644 apps/sim/lib/api/contracts/v2/workflows.ts 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 25c8cfdbf2e..7e94ab0d7bd 100644 --- a/apps/docs/content/docs/de/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/de/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/de/api-reference/meta.json b/apps/docs/content/docs/de/api-reference/meta.json index d8a1fb142c6..74cedc72725 100644 --- a/apps/docs/content/docs/de/api-reference/meta.json +++ b/apps/docs/content/docs/de/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,9 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", "(generated)/audit-logs", "(generated)/tables", - "(generated)/files" + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json new file mode 100644 index 00000000000..52458d430c3 --- /dev/null +++ b/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json @@ -0,0 +1,3 @@ +{ + "pages": ["executeWorkflow", "getWorkflowExecution", "cancelExecution", "getJobStatus"] +} diff --git a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json index 491129e3cdf..6fb5dc0f8bf 100644 --- a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json +++ b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json @@ -1,13 +1,9 @@ { "pages": [ - "executeWorkflow", - "getWorkflowExecution", - "cancelExecution", "listWorkflows", "getWorkflow", "deployWorkflow", "undeployWorkflow", - "rollbackWorkflow", - "getJobStatus" + "rollbackWorkflow" ] } 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 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/en/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/en/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/en/api-reference/meta.json b/apps/docs/content/docs/en/api-reference/meta.json index c99ab8eb13f..74cedc72725 100644 --- a/apps/docs/content/docs/en/api-reference/meta.json +++ b/apps/docs/content/docs/en/api-reference/meta.json @@ -10,12 +10,14 @@ "typescript", "---Endpoints---", "(generated)/workflows", - "(generated)/human-in-the-loop", "(generated)/logs", - "(generated)/usage", "(generated)/audit-logs", "(generated)/tables", "(generated)/files", - "(generated)/knowledge-bases" + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx b/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx index 9bcf9dfb0ed..b9d039c2a56 100644 --- a/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx @@ -33,7 +33,7 @@ Audit logs are also accessible through the Sim API for integration with external ```http GET /api/v1/audit-logs -Authorization: Bearer +X-API-Key: ``` **Query parameters:** @@ -71,11 +71,18 @@ Authorization: Bearer "createdAt": "2026-04-20T21:16:00.000Z" } ], - "nextCursor": "eyJpZCI6ImFiYzEyMyJ9" + "nextCursor": "eyJpZCI6ImFiYzEyMyJ9", + "limits": { + "workflowExecutionRateLimit": { + "sync": { "requestsPerMinute": 60, "maxBurst": 10, "remaining": 59, "resetAt": "2026-04-20T21:17:00.000Z" }, + "async": { "requestsPerMinute": 30, "maxBurst": 5, "remaining": 30, "resetAt": "2026-04-20T21:17:00.000Z" } + }, + "usage": { "currentPeriodCost": 1.25, "limit": 50, "plan": "enterprise", "isExceeded": false } + } } ``` -Paginate by passing the `nextCursor` value as the `cursor` parameter in the next request. When `nextCursor` is absent, you have reached the last page. +Paginate by passing the `nextCursor` value as the `cursor` parameter in the next request. When `nextCursor` is absent, you have reached the last page. Each entry also includes `actorName`; `metadata` is an arbitrary per-action JSON object. The `limits` object reports your current rate-limit and usage status. The API accepts both personal and workspace-scoped API keys. Rate limits apply — the response includes `X-RateLimit-*` headers with your current limit and remaining quota. 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 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/es/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/es/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/es/api-reference/meta.json b/apps/docs/content/docs/es/api-reference/meta.json index c96dc5d2edc..74cedc72725 100644 --- a/apps/docs/content/docs/es/api-reference/meta.json +++ b/apps/docs/content/docs/es/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/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 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/fr/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/fr/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/fr/api-reference/meta.json b/apps/docs/content/docs/fr/api-reference/meta.json index c96dc5d2edc..74cedc72725 100644 --- a/apps/docs/content/docs/fr/api-reference/meta.json +++ b/apps/docs/content/docs/fr/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/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 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/ja/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/ja/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/ja/api-reference/meta.json b/apps/docs/content/docs/ja/api-reference/meta.json index c96dc5d2edc..74cedc72725 100644 --- a/apps/docs/content/docs/ja/api-reference/meta.json +++ b/apps/docs/content/docs/ja/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/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 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/zh/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/zh/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/zh/api-reference/meta.json b/apps/docs/content/docs/zh/api-reference/meta.json index c96dc5d2edc..74cedc72725 100644 --- a/apps/docs/content/docs/zh/api-reference/meta.json +++ b/apps/docs/content/docs/zh/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/lib/openapi.ts b/apps/docs/lib/openapi.ts index af5f7a2b4c8..41f0687139a 100644 --- a/apps/docs/lib/openapi.ts +++ b/apps/docs/lib/openapi.ts @@ -2,8 +2,17 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { createOpenAPI } from 'fumadocs-openapi/server' +const SPEC_FILES = [ + 'openapi-core.json', + 'openapi-v2-logs.json', + 'openapi-v2-workflows.json', + 'openapi-v2-tables.json', + 'openapi-v2-knowledge.json', + 'openapi-v2-files-audit.json', +] as const + export const openapi = createOpenAPI({ - input: ['./openapi.json'], + input: SPEC_FILES.map((file) => `./${file}`), }) interface OpenAPIOperation { @@ -24,20 +33,34 @@ function resolveRef(ref: string, spec: Record): unknown { return current } -function resolveRefs(obj: unknown, spec: Record, depth = 0): unknown { - if (depth > 10) return obj +function resolveRefs( + obj: unknown, + spec: Record, + seen: Set = new Set(), + depth = 0 +): unknown { + // Generous backstop against pathological fan-out; real schemas nest far shallower. + if (depth > 50) return obj if (Array.isArray(obj)) { - return obj.map((item) => resolveRefs(item, spec, depth + 1)) + return obj.map((item) => resolveRefs(item, spec, seen, depth + 1)) } if (obj && typeof obj === 'object') { const record = obj as Record - if ('$ref' in record && typeof record.$ref === 'string') { - const resolved = resolveRef(record.$ref, spec) - return resolveRefs(resolved, spec, depth + 1) + if (typeof record.$ref === 'string') { + const ref = record.$ref + // Break reference cycles: if this $ref is already being expanded above us, + // leave it untouched instead of recursing forever. + if (seen.has(ref)) return record + const resolved = resolveRef(ref, spec) + if (resolved === undefined) return record + seen.add(ref) + const out = resolveRefs(resolved, spec, seen, depth + 1) + seen.delete(ref) + return out } const result: Record = {} for (const [key, value] of Object.entries(record)) { - result[key] = resolveRefs(value, spec, depth + 1) + result[key] = resolveRefs(value, spec, seen, depth + 1) } return result } @@ -48,14 +71,34 @@ function formatSchema(schema: unknown): string { return JSON.stringify(schema, null, 2) } -let cachedSpec: Record | null = null +let cachedSpecs: Record[] | null = null + +function getSpecs(): Record[] { + if (!cachedSpecs) { + cachedSpecs = SPEC_FILES.map( + (file) => + JSON.parse(readFileSync(join(process.cwd(), file), 'utf8')) as Record + ) + } + return cachedSpecs +} -function getSpec(): Record { - if (!cachedSpec) { - const specPath = join(process.cwd(), 'openapi.json') - cachedSpec = JSON.parse(readFileSync(specPath, 'utf8')) as Record +/** + * Locate an operation by path + method across every rendered spec, returning the + * operation together with the spec that owns it so `$ref`s resolve within the + * correct document (each spec carries its own `components`). + */ +function findOperation( + path: string, + method: string +): { operation: Record; spec: Record } | undefined { + const key = method.toLowerCase() + for (const spec of getSpecs()) { + const pathObj = (spec.paths as Record> | undefined)?.[path] + const operation = pathObj?.[key] as Record | undefined + if (operation) return { operation, spec } } - return cachedSpec + return undefined } export function getApiSpecContent( @@ -63,22 +106,19 @@ export function getApiSpecContent( description: string | undefined, operations: OpenAPIOperation[] ): string { - const spec = getSpec() - if (!operations || operations.length === 0) { return `# ${title}\n\n${description || ''}` } const op = operations[0] const method = op.method.toUpperCase() - const pathObj = (spec.paths as Record>)?.[op.path] - const operation = pathObj?.[op.method.toLowerCase()] as Record | undefined + const found = findOperation(op.path, op.method) - if (!operation) { + if (!found) { return `# ${title}\n\n${description || ''}` } - const resolved = resolveRefs(operation, spec) as Record + const resolved = resolveRefs(found.operation, found.spec) as Record const lines: string[] = [] lines.push(`# ${title}`) diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json new file mode 100644 index 00000000000..53a99c2e866 --- /dev/null +++ b/apps/docs/openapi-core.json @@ -0,0 +1,2948 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API — Execution & Usage", + "description": "Run workflows, poll and cancel executions, resume Human-in-the-Loop pauses, and check usage limits.", + "version": "1.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" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Execution", + "description": "Run workflows, poll execution status, and cancel runs" + }, + { + "name": "Human in the Loop", + "description": "Manage paused workflow executions and resume them with input" + }, + { + "name": "Usage", + "description": "Check rate limits and billing usage" + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/workflows/{id}/execute": { + "post": { + "operationId": "executeWorkflow", + "summary": "Execute Workflow", + "description": "Execute a deployed workflow. Supports synchronous, asynchronous, and streaming modes. For async execution, the response includes a statusUrl you can poll for results.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/workflows/{id}/execute\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"input\": {\n \"key\": \"value\"\n }\n }'" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the deployed workflow to execute.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + } + ], + "requestBody": { + "description": "Execution configuration including input values and execution mode options.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "input": { + "type": "object", + "description": "Key-value pairs matching the workflow's defined input fields. Use the Get Workflow endpoint to discover available input fields.", + "additionalProperties": true + }, + "triggerType": { + "type": "string", + "description": "How this execution was triggered. Defaults to api when called via the REST API. Recorded in execution logs for filtering." + }, + "stream": { + "type": "boolean", + "description": "When true, returns results as Server-Sent Events (SSE) for real-time block-by-block output streaming." + }, + "selectedOutputs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of specific block IDs whose outputs to include in the response. When omitted, all block outputs are returned." + } + } + }, + "example": { + "input": { + "query": "What is the weather in Tokyo?" + } + } + } + } + }, + "responses": { + "200": { + "description": "Synchronous execution completed successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecutionResult" + }, + "example": { + "success": true, + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "output": { + "content": "The weather in Tokyo is sunny, 22°C." + }, + "error": null, + "metadata": { + "startTime": "2026-01-15T10:30:00Z", + "endTime": "2026-01-15T10:30:01Z", + "duration": 1250 + } + } + } + } + }, + "202": { + "description": "Asynchronous execution has been queued. Poll the statusUrl for results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncExecutionResult" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + } + } + }, + "/api/workflows/{id}/executions/{executionId}": { + "get": { + "operationId": "getWorkflowExecution", + "summary": "Get Execution Status", + "description": "Get the current status of a workflow execution. Returns the run's lifecycle state (`running`, `paused`, `completed`, `failed`, etc.), timing, error, and optionally per-block outputs. Designed for polling — works for any execution, including ones that pause and resume.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + }, + { + "id": "curl-with-outputs", + "label": "cURL (with block outputs)", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}?selectedOutputs=blockId,blockId.field&includeOutput=true\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique identifier of the execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "includeOutput", + "in": "query", + "required": false, + "description": "When `true` and the execution has `status: completed`, include the workflow's final output in the response.", + "schema": { + "type": "string", + "enum": ["true", "false"] + } + }, + { + "name": "selectedOutputs", + "in": "query", + "required": false, + "description": "Comma-separated block-output selectors. A bare `blockId` returns that block's full output; a dot-path like `blockId.field` or `blockId.nested.path` returns just that value. Results are returned in the `blockOutputs` map keyed by the selector string.", + "schema": { + "type": "string", + "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf,c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration" + } + } + ], + "responses": { + "200": { + "description": "Execution status returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowExecutionStatus" + }, + "examples": { + "completed": { + "summary": "Completed run", + "value": { + "executionId": "9254f1c9-5a11-4a12-91e3-8065293f3609", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "completed", + "trigger": "api", + "level": "info", + "startedAt": "2026-05-15T19:43:12.189Z", + "endedAt": "2026-05-15T19:45:45.224Z", + "totalDurationMs": 153035, + "paused": null, + "cost": { + "total": 0.005 + }, + "error": null, + "finalOutput": null, + "blockOutputs": null + } + }, + "paused": { + "summary": "Currently paused run", + "value": { + "executionId": "772749f6-ee81-414c-a2c3-671549dd62b8", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "paused", + "trigger": "manual", + "level": "info", + "startedAt": "2026-05-15T22:25:57.178Z", + "endedAt": "2026-05-15T22:25:57.215Z", + "totalDurationMs": 1, + "paused": { + "pausedAt": "2026-05-15T22:25:57.216Z", + "resumeAt": "2026-05-16T18:25:57.200Z", + "pauseKind": "time", + "blockedOnBlockId": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf", + "pausedExecutionId": "438bf05b-bd3c-4011-b78e-b19c112eeb66", + "pausePointCount": 1, + "resumedCount": 0 + }, + "cost": { + "total": 0.005 + }, + "error": null, + "finalOutput": null, + "blockOutputs": null + } + }, + "failed": { + "summary": "Failed run", + "value": { + "executionId": "3ccfdeed-a63c-4e86-98e2-8bec723bca52", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "failed", + "trigger": "api", + "level": "error", + "startedAt": "2026-05-15T22:24:50.991Z", + "endedAt": "2026-05-15T22:24:50.999Z", + "totalDurationMs": 2, + "paused": null, + "cost": { + "total": 0.005 + }, + "error": "Wait 1: Wait time exceeds maximum of 5 minutes; enable async mode to wait up to 30 days", + "finalOutput": null, + "blockOutputs": null + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/executions/{executionId}/cancel": { + "post": { + "operationId": "cancelExecution", + "summary": "Cancel Execution", + "description": "Cancel a running workflow execution. Only effective for executions that are still in progress.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}/cancel\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique identifier of the execution to cancel.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Execution was successfully cancelled.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the cancellation was successful." + }, + "executionId": { + "type": "string", + "description": "The ID of the cancelled execution." + } + } + }, + "example": { + "success": true, + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/jobs/{jobId}": { + "get": { + "operationId": "getJobStatus", + "summary": "Get Job Status", + "description": "Poll the status of an asynchronous workflow execution. Use the jobId returned from the Execute Workflow endpoint when the execution is queued asynchronously.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/jobs/{jobId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "description": "The job identifier returned in the async execution response.", + "schema": { + "type": "string", + "example": "job_4a3b2c1d0e" + } + } + ], + "responses": { + "200": { + "description": "Current status of the job. When completed, includes the execution output.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobStatus" + }, + "example": { + "success": true, + "taskId": "job_abc123", + "status": "completed", + "output": { + "content": "Done" + }, + "metadata": { + "startTime": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/paused": { + "get": { + "operationId": "listPausedExecutions", + "summary": "List Paused Executions", + "description": "List all paused executions for a workflow. Workflows pause at Human in the Loop blocks and wait for input before continuing. Use this endpoint to discover which executions need attention.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused?status=paused\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter paused executions by status.", + "schema": { + "type": "string", + "example": "paused" + } + } + ], + "responses": { + "200": { + "description": "List of paused executions.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pausedExecutions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PausedExecutionSummary" + } + } + } + }, + "example": { + "pausedExecutions": [ + { + "id": "pe_abc123", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "status": "paused", + "totalPauseCount": 1, + "resumedCount": 0, + "pausedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "expiresAt": null, + "metadata": null, + "triggerIds": [], + "pausePoints": [ + { + "contextId": "ctx_xyz789", + "blockId": "block_hitl_1", + "registeredAt": "2026-01-15T10:30:00Z", + "resumeStatus": "paused", + "snapshotReady": true, + "resumeLinks": { + "apiUrl": "https://www.sim.ai/api/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13/ctx_xyz789", + "uiUrl": "https://www.sim.ai/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "contextId": "ctx_xyz789", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "response": { + "displayData": { + "title": "Approval Required", + "message": "Please review this request" + }, + "formFields": [] + } + } + ] + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/paused/{executionId}": { + "get": { + "operationId": "getPausedExecution", + "summary": "Get Paused Execution", + "description": "Get detailed information about a specific paused execution, including its pause points, execution snapshot, and resume queue. Use this to inspect the state before resuming.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Paused execution details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PausedExecutionDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/resume/{workflowId}/{executionId}": { + "get": { + "operationId": "getPausedExecutionByResumePath", + "summary": "Get Paused Execution (Resume Path)", + "description": "Get detailed information about a specific paused execution using the resume URL path. Returns the same data as the workflow paused execution detail endpoint.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Paused execution details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PausedExecutionDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + } + } + } + }, + "/api/resume/{workflowId}/{executionId}/{contextId}": { + "get": { + "operationId": "getPauseContext", + "summary": "Get Pause Context", + "description": "Get detailed information about a specific pause context within a paused execution. Returns the pause point details, resume queue state, and any active resume entry.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "contextId", + "in": "path", + "required": true, + "description": "The pause context ID to retrieve details for.", + "schema": { + "type": "string", + "example": "ctx_xyz789" + } + } + ], + "responses": { + "200": { + "description": "Pause context details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PauseContextDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "operationId": "resumeExecution", + "summary": "Resume Execution", + "description": "Resume a paused workflow execution by providing input for a specific pause context. The execution continues from where it paused, using the provided input. Supports synchronous, asynchronous, and streaming modes (determined by the original execution's configuration).", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"input\": {\n \"approved\": true,\n \"comment\": \"Looks good to me\"\n }\n }'" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "contextId", + "in": "path", + "required": true, + "description": "The pause context ID to resume. Found in the pause point's contextId field or resumeLinks.", + "schema": { + "type": "string", + "example": "ctx_xyz789" + } + } + ], + "requestBody": { + "description": "Input data for the resumed execution. The structure depends on the workflow's Human in the Loop block configuration.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "input": { + "type": "object", + "description": "Key-value pairs to pass as input to the resumed execution. If omitted, the entire request body is used as input.", + "additionalProperties": true + } + } + }, + "example": { + "input": { + "approved": true, + "comment": "Looks good to me" + } + } + } + } + }, + "responses": { + "200": { + "description": "Resume execution completed synchronously, or resume was queued behind another in-progress resume.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResumeResult" + }, + { + "type": "object", + "description": "Resume has been queued behind another in-progress resume.", + "properties": { + "status": { + "type": "string", + "enum": ["queued"], + "description": "Indicates the resume is queued." + }, + "executionId": { + "type": "string", + "description": "The execution ID assigned to this resume." + }, + "queuePosition": { + "type": "integer", + "description": "Position in the resume queue." + }, + "message": { + "type": "string", + "description": "Human-readable status message." + } + } + }, + { + "type": "object", + "description": "Resume execution started (non-API-key callers). The execution runs asynchronously.", + "properties": { + "status": { + "type": "string", + "enum": ["started"], + "description": "Indicates the resume execution has started." + }, + "executionId": { + "type": "string", + "description": "The execution ID for the resumed workflow." + }, + "message": { + "type": "string", + "description": "Human-readable status message." + } + } + } + ] + }, + "examples": { + "sync": { + "summary": "Synchronous completion", + "value": { + "success": true, + "status": "completed", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "output": { + "result": "Approved and processed" + }, + "error": null, + "metadata": { + "duration": 850, + "startTime": "2026-01-15T10:35:00Z", + "endTime": "2026-01-15T10:35:01Z" + } + } + }, + "queued": { + "summary": "Queued behind another resume", + "value": { + "status": "queued", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "queuePosition": 2, + "message": "Resume queued. It will run after current resumes finish." + } + }, + "started": { + "summary": "Execution started (fire and forget)", + "value": { + "status": "started", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "message": "Resume execution started." + } + } + } + } + } + }, + "202": { + "description": "Resume execution has been queued for asynchronous processing. Poll the statusUrl for results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncExecutionResult" + }, + "example": { + "success": true, + "async": true, + "jobId": "job_4a3b2c1d0e", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "message": "Resume execution queued", + "statusUrl": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "503": { + "description": "Failed to queue the resume execution. Retry the request.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error message." + } + } + } + } + } + } + } + } + }, + "/api/users/me/usage-limits": { + "get": { + "operationId": "getUsageLimits", + "summary": "Get Usage Limits", + "description": "Retrieve your current rate limits, usage spending, and storage consumption for the billing period.", + "tags": ["Usage"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/users/me/usage-limits\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "Current rate limits, usage, and storage information.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageLimits" + }, + "example": { + "success": true, + "rateLimit": { + "sync": { + "limit": 100, + "remaining": 95, + "reset": "2026-01-15T11:00:00Z" + }, + "async": { + "limit": 50, + "remaining": 48, + "reset": "2026-01-15T11:00:00Z" + } + }, + "usage": { + "currentPeriodCost": 12.5, + "limit": 100, + "plan": "pro" + }, + "storage": { + "usedBytes": 5242880, + "limitBytes": 1073741824, + "percentUsed": 0.49 + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "parameters": [] + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "TableId": { + "name": "tableId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "description": "The unique identifier of the table." + }, + "RowId": { + "name": "rowId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "description": "The unique identifier of the row." + }, + "WorkspaceId": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "The unique identifier of the workspace." + } + }, + "schemas": { + "ColumnDefinition": { + "type": "object", + "description": "Definition of a table column including its type and constraints.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "description": "Column name. Must start with a letter or underscore.", + "example": "email", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "description": "Whether the column requires a value on insert.", + "default": false + }, + "unique": { + "type": "boolean", + "description": "Whether values in this column must be unique across all rows.", + "default": false + } + } + }, + "Table": { + "type": "object", + "description": "A user-defined table with a typed schema.", + "properties": { + "id": { + "type": "string", + "description": "Unique table identifier.", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "name": { + "type": "string", + "description": "Table name.", + "example": "contacts" + }, + "description": { + "type": "string", + "description": "Optional description of the table.", + "example": "Customer contact records" + }, + "schema": { + "type": "object", + "description": "Table schema definition.", + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ColumnDefinition" + }, + "description": "Array of column definitions for the table." + } + } + }, + "rowCount": { + "type": "integer", + "description": "Current number of rows in the table." + }, + "maxRows": { + "type": "integer", + "description": "Maximum rows allowed by the current billing plan." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was last modified." + } + } + }, + "TableRow": { + "type": "object", + "description": "A single row in a table.", + "properties": { + "id": { + "type": "string", + "description": "Unique row identifier.", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "data": { + "type": "object", + "additionalProperties": true, + "description": "Row data as key-value pairs matching the table schema." + }, + "position": { + "type": "integer", + "description": "Row's position/order in the table." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was last modified." + } + } + }, + "WorkflowSummary": { + "type": "object", + "description": "Summary representation of a workflow returned in list operations.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Human-readable workflow name.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description of what the workflow does.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": "string", + "nullable": true, + "description": "The folder this workflow belongs to. null if at the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + "workspaceId": { + "type": "string", + "description": "The workspace this workflow belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is currently deployed and available for API execution.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent deployment. null if never deployed.", + "example": "2025-06-15T10:30:00Z" + }, + "runCount": { + "type": "integer", + "description": "Total number of times this workflow has been executed.", + "example": 142 + }, + "lastRunAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent execution. null if never run.", + "example": "2025-06-20T14:15:22Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created.", + "example": "2025-01-10T09:00:00Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "WorkflowDetail": { + "type": "object", + "description": "Full workflow representation including input field definitions and configuration.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Human-readable workflow name.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description of what the workflow does.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": "string", + "nullable": true, + "description": "The folder this workflow belongs to. null if at the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + "workspaceId": { + "type": "string", + "description": "The workspace this workflow belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is currently deployed and available for API execution.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent deployment. null if never deployed.", + "example": "2025-06-15T10:30:00Z" + }, + "runCount": { + "type": "integer", + "description": "Total number of times this workflow has been executed.", + "example": 142 + }, + "lastRunAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent execution. null if never run.", + "example": "2025-06-20T14:15:22Z" + }, + "variables": { + "type": "object", + "description": "Workflow-level variables and their current values.", + "example": {} + }, + "inputs": { + "type": "object", + "description": "The workflow's input field definitions. Use these to construct the input object when executing the workflow.", + "properties": { + "fields": { + "type": "object", + "description": "Map of field names to their type definitions and configuration.", + "additionalProperties": true, + "example": {} + } + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created.", + "example": "2025-01-10T09:00:00Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "WorkflowDeployment": { + "type": "object", + "description": "Deployment state of a workflow after a deploy, undeploy, or rollback operation.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is deployed and available for API execution after the operation.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the active deployment. null after an undeploy.", + "example": "2026-06-12T10:30:00Z" + }, + "version": { + "type": "integer", + "description": "The deployment version that is now active. Omitted for undeploy.", + "example": 4 + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal warnings. Present when trigger, schedule, or MCP side-effect sync is still in progress or needs a redeploy." + } + } + }, + "ExecutionResult": { + "type": "object", + "description": "Result of a synchronous workflow execution.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the workflow executed successfully without errors.", + "example": true + }, + "executionId": { + "type": "string", + "description": "Unique identifier for this execution. Use this to query logs or cancel the execution.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "output": { + "type": "object", + "description": "Workflow output keyed by block name and output field. Structure depends on the workflow's block configuration.", + "additionalProperties": true, + "example": { + "result": "Hello, world!" + } + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message if the execution failed. null on success.", + "example": null + }, + "metadata": { + "type": "object", + "description": "Execution timing metadata.", + "properties": { + "duration": { + "type": "integer", + "description": "Total execution duration in milliseconds.", + "example": 1250 + }, + "startTime": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2025-06-20T14:15:22Z" + }, + "endTime": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed.", + "example": "2025-06-20T14:15:23Z" + } + } + } + } + }, + "AsyncExecutionResult": { + "type": "object", + "description": "Response returned when a workflow execution is queued for asynchronous processing.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the execution was successfully queued.", + "example": true + }, + "async": { + "type": "boolean", + "description": "Always true for async executions. Use this to distinguish from synchronous responses.", + "example": true + }, + "jobId": { + "type": "string", + "description": "Internal job queue identifier for tracking the execution.", + "example": "job_4a3b2c1d0e" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier. Use this to query execution status or cancel.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "message": { + "type": "string", + "description": "Human-readable status message (e.g., \"Execution queued\").", + "example": "Execution queued" + }, + "statusUrl": { + "type": "string", + "format": "uri", + "description": "URL to poll for execution status and results. Returns the full execution result once complete.", + "example": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" + } + } + }, + "LogEntry": { + "type": "object", + "description": "Summary of a single workflow execution log entry.", + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": "string", + "description": "The workflow that was executed.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier for this run.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "level": { + "type": "string", + "description": "Log severity. info for successful executions, error for failures.", + "example": "info" + }, + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, manual, webhook, schedule, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2025-06-20T14:15:22Z" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed.", + "example": "2025-06-20T14:15:23Z" + }, + "totalDurationMs": { + "type": "integer", + "description": "Total execution duration in milliseconds.", + "example": 1250 + }, + "cost": { + "type": "object", + "description": "Cost summary for this execution.", + "properties": { + "total": { + "type": "number", + "description": "Total cost of this execution in USD.", + "example": 0.0032 + } + } + }, + "files": { + "type": "object", + "nullable": true, + "description": "File outputs produced during execution. null if no files were generated.", + "example": null + } + } + }, + "LogDetail": { + "type": "object", + "description": "Detailed log entry with full execution data, workflow metadata, and cost breakdown.", + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": "string", + "description": "The workflow that was executed.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier for this run.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "level": { + "type": "string", + "description": "Log severity. info for successful executions, error for failures.", + "example": "info" + }, + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, manual, webhook, schedule, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2025-06-20T14:15:22Z" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed.", + "example": "2025-06-20T14:15:23Z" + }, + "totalDurationMs": { + "type": "integer", + "description": "Total execution duration in milliseconds.", + "example": 1250 + }, + "workflow": { + "type": "object", + "description": "Summary metadata about the workflow at the time of execution.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Workflow name at the time of execution.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Workflow description at the time of execution.", + "example": "Routes incoming support tickets and drafts responses" + } + } + }, + "executionData": { + "type": "object", + "description": "Detailed execution data including block-level traces and final output.", + "properties": { + "traceSpans": { + "type": "array", + "description": "Block-level execution traces with timing, inputs, and outputs for each block that ran.", + "items": { + "type": "object" + } + }, + "finalOutput": { + "type": "object", + "description": "The workflow's final output after all blocks completed." + } + } + }, + "cost": { + "type": "object", + "description": "Detailed cost breakdown for this execution.", + "properties": { + "total": { + "type": "number", + "description": "Total cost of this execution in USD.", + "example": 0.0032 + }, + "tokens": { + "type": "object", + "description": "Aggregate token usage across all AI model calls in this execution.", + "properties": { + "prompt": { + "type": "integer", + "description": "Total prompt (input) tokens consumed.", + "example": 450 + }, + "completion": { + "type": "integer", + "description": "Total completion (output) tokens generated.", + "example": 120 + }, + "total": { + "type": "integer", + "description": "Total tokens (prompt + completion).", + "example": 570 + } + } + }, + "models": { + "type": "object", + "description": "Per-model cost and token breakdown. Keys are model identifiers (e.g., gpt-4o, claude-sonnet-4-20250514).", + "additionalProperties": { + "type": "object", + "description": "Cost and token details for a specific model.", + "properties": { + "input": { + "type": "number", + "description": "Cost of prompt tokens for this model in USD." + }, + "output": { + "type": "number", + "description": "Cost of completion tokens for this model in USD." + }, + "total": { + "type": "number", + "description": "Total cost for this model in USD." + }, + "tokens": { + "type": "object", + "description": "Token usage for this specific model.", + "properties": { + "prompt": { + "type": "integer", + "description": "Prompt tokens consumed by this model." + }, + "completion": { + "type": "integer", + "description": "Completion tokens generated by this model." + }, + "total": { + "type": "integer", + "description": "Total tokens for this model." + } + } + } + } + } + } + } + } + } + }, + "Limits": { + "type": "object", + "description": "Rate limit and usage information included in every API response.", + "properties": { + "workflowExecutionRateLimit": { + "type": "object", + "description": "Current rate limit status for workflow executions.", + "properties": { + "sync": { + "description": "Rate limit bucket for synchronous executions.", + "$ref": "#/components/schemas/RateLimitBucket" + }, + "async": { + "description": "Rate limit bucket for asynchronous executions.", + "$ref": "#/components/schemas/RateLimitBucket" + } + } + }, + "usage": { + "type": "object", + "description": "Current billing period usage and plan limits.", + "properties": { + "currentPeriodCost": { + "type": "number", + "description": "Total spend in the current billing period in USD.", + "example": 1.25 + }, + "limit": { + "type": "number", + "description": "Maximum allowed spend for the current billing period in USD.", + "example": 50 + }, + "plan": { + "type": "string", + "description": "Your current subscription plan (e.g., free, pro, team).", + "example": "pro" + }, + "isExceeded": { + "type": "boolean", + "description": "Whether the usage limit has been exceeded. Executions may be blocked when true.", + "example": false + } + } + } + } + }, + "RateLimitBucket": { + "type": "object", + "description": "Rate limit status for a specific execution type.", + "properties": { + "requestsPerMinute": { + "type": "integer", + "description": "Maximum number of requests allowed per minute.", + "example": 60 + }, + "maxBurst": { + "type": "integer", + "description": "Maximum number of concurrent requests allowed in a burst.", + "example": 10 + }, + "remaining": { + "type": "integer", + "description": "Number of requests remaining in the current rate limit window.", + "example": 59 + }, + "resetAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the rate limit window resets.", + "example": "2025-06-20T14:16:00Z" + } + } + }, + "JobStatus": { + "type": "object", + "description": "Status of an asynchronous job.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the request was successful.", + "example": true + }, + "taskId": { + "type": "string", + "description": "The unique identifier of the job.", + "example": "job_4a3b2c1d0e" + }, + "status": { + "type": "string", + "enum": ["queued", "processing", "completed", "failed"], + "description": "Current status of the job.", + "example": "completed" + }, + "metadata": { + "type": "object", + "description": "Timing metadata for the job.", + "properties": { + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the job started processing.", + "example": "2025-06-20T14:15:22Z" + }, + "completedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the job completed. Present only when status is completed or failed.", + "example": "2025-06-20T14:15:23Z" + }, + "duration": { + "type": "integer", + "description": "Duration of the job in milliseconds. Present only when status is completed or failed.", + "example": 1250 + } + } + }, + "output": { + "description": "The workflow execution output. Present only when status is completed.", + "type": "object", + "example": { + "result": "Hello, world!" + } + }, + "error": { + "description": "Error details. Present only when status is failed.", + "type": "string", + "example": null + }, + "estimatedDuration": { + "type": "integer", + "description": "Estimated duration in milliseconds. Present only when status is queued or processing.", + "example": 2000 + } + } + }, + "WorkflowExecutionStatus": { + "type": "object", + "description": "Current status of a workflow execution.", + "properties": { + "executionId": { + "type": "string", + "description": "The unique identifier of the execution.", + "example": "9254f1c9-5a11-4a12-91e3-8065293f3609" + }, + "workflowId": { + "type": "string", + "description": "The unique identifier of the workflow.", + "example": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7" + }, + "status": { + "type": "string", + "enum": ["pending", "running", "paused", "completed", "failed", "cancelled"], + "description": "Current normalized lifecycle status. `paused` is set when a row exists in pausedExecutions with status `paused` or `partially_resumed`; otherwise the workflowExecutionLogs row's status field is used.", + "example": "completed" + }, + "trigger": { + "type": "string", + "enum": ["api", "manual", "schedule", "webhook", "chat"], + "description": "What triggered the execution.", + "example": "api" + }, + "level": { + "type": "string", + "enum": ["info", "warning", "error"], + "description": "Log level of the execution.", + "example": "info" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-05-15T19:43:12.189Z" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp when execution ended. Null while the run is in flight.", + "example": "2026-05-15T19:45:45.224Z" + }, + "totalDurationMs": { + "type": "integer", + "nullable": true, + "description": "Total duration of the execution in milliseconds. Null while the run is in flight.", + "example": 153035 + }, + "paused": { + "type": "object", + "nullable": true, + "description": "Pause-state details. Present only when status is `paused`.", + "properties": { + "pausedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was paused.", + "example": "2026-05-15T22:25:57.216Z" + }, + "resumeAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Earliest scheduled resume time across active pause points. Null for human-only pauses.", + "example": "2026-05-16T18:25:57.200Z" + }, + "pauseKind": { + "type": "string", + "enum": ["time", "human"], + "nullable": true, + "description": "What kind of pause the workflow is waiting on.", + "example": "time" + }, + "blockedOnBlockId": { + "type": "string", + "nullable": true, + "description": "The block currently blocking resume.", + "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf" + }, + "pausedExecutionId": { + "type": "string", + "description": "ID of the paused-execution row, useful for cross-referencing with the human-in-the-loop endpoints.", + "example": "438bf05b-bd3c-4011-b78e-b19c112eeb66" + }, + "pausePointCount": { + "type": "integer", + "description": "Total number of pause points recorded for this execution.", + "example": 1 + }, + "resumedCount": { + "type": "integer", + "description": "Number of pause points already resumed.", + "example": 0 + } + } + }, + "cost": { + "type": "object", + "nullable": true, + "description": "Cost summary. Detailed token / model breakdown lives on the /v1/logs detail endpoint.", + "properties": { + "total": { + "type": "number", + "description": "Total cost in USD.", + "example": 0.005 + } + } + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message. Present only when status is `failed`.", + "example": null + }, + "finalOutput": { + "type": "object", + "nullable": true, + "description": "The workflow's final output. Returned only when ?includeOutput=true AND status is `completed`.", + "example": null + }, + "blockOutputs": { + "type": "object", + "nullable": true, + "description": "Per-block outputs keyed by the selector string. Returned only when `?selectedOutputs` is set.", + "additionalProperties": true, + "example": { + "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration": 60000, + "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.status": "completed" + } + } + } + }, + "AuditLogEntry": { + "type": "object", + "description": "An enterprise audit log entry recording an action taken in the workspace.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the audit log entry.", + "example": "audit_2c3d4e5f6g" + }, + "workspaceId": { + "type": "string", + "nullable": true, + "description": "The workspace where the action occurred.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "actorId": { + "type": "string", + "nullable": true, + "description": "The user ID of the person who performed the action.", + "example": "user_abc123" + }, + "actorName": { + "type": "string", + "nullable": true, + "description": "Display name of the person who performed the action.", + "example": "Jane Smith" + }, + "actorEmail": { + "type": "string", + "nullable": true, + "description": "Email address of the person who performed the action.", + "example": "jane@example.com" + }, + "action": { + "type": "string", + "description": "The action that was performed (e.g., workflow.created, member.invited).", + "example": "workflow.deployed" + }, + "resourceType": { + "type": "string", + "description": "The type of resource affected (e.g., workflow, workspace, member).", + "example": "workflow" + }, + "resourceId": { + "type": "string", + "nullable": true, + "description": "The unique identifier of the affected resource.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "resourceName": { + "type": "string", + "nullable": true, + "description": "Display name of the affected resource.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Human-readable description of the action.", + "example": "Deployed workflow Customer Support Agent" + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional context about the action.", + "example": null + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the action occurred.", + "example": "2025-06-20T14:15:22Z" + } + } + }, + "UsageLimits": { + "type": "object", + "description": "Current rate limits, usage, and storage information for the authenticated user.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the request was successful." + }, + "rateLimit": { + "type": "object", + "description": "Rate limit status for workflow executions.", + "properties": { + "sync": { + "description": "Rate limit bucket for synchronous executions.", + "allOf": [ + { + "$ref": "#/components/schemas/RateLimitBucket" + }, + { + "type": "object", + "properties": { + "isLimited": { + "type": "boolean", + "description": "Whether the rate limit has been reached." + } + } + } + ] + }, + "async": { + "description": "Rate limit bucket for asynchronous executions.", + "allOf": [ + { + "$ref": "#/components/schemas/RateLimitBucket" + }, + { + "type": "object", + "properties": { + "isLimited": { + "type": "boolean", + "description": "Whether the rate limit has been reached." + } + } + } + ] + }, + "authType": { + "type": "string", + "description": "The authentication type used (api or manual)." + } + } + }, + "usage": { + "type": "object", + "description": "Current billing period usage.", + "properties": { + "currentPeriodCost": { + "type": "number", + "description": "Total spend in the current billing period in USD." + }, + "limit": { + "type": "number", + "description": "Maximum allowed spend for the current billing period in USD." + }, + "plan": { + "type": "string", + "description": "Your current subscription plan (e.g., free, pro, team)." + } + } + }, + "storage": { + "type": "object", + "description": "File storage usage.", + "properties": { + "usedBytes": { + "type": "integer", + "description": "Total storage used in bytes." + }, + "limitBytes": { + "type": "integer", + "description": "Maximum storage allowed in bytes." + }, + "percentUsed": { + "type": "number", + "description": "Percentage of storage used (0-100)." + } + } + } + } + }, + "FileMetadata": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique file identifier.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "name": { + "type": "string", + "description": "Original filename.", + "example": "data.csv" + }, + "size": { + "type": "integer", + "description": "File size in bytes.", + "example": 1024 + }, + "type": { + "type": "string", + "description": "MIME type of the file.", + "example": "text/csv" + }, + "key": { + "type": "string", + "description": "Storage key for the file.", + "example": "workspace/abc-123/1709571234-xyz-data.csv" + }, + "uploadedBy": { + "type": "string", + "description": "User ID of the uploader." + }, + "uploadedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of when the file was uploaded." + } + } + }, + "KnowledgeBase": { + "type": "object", + "description": "A knowledge base for storing and searching document embeddings.", + "properties": { + "id": { + "type": "string", + "description": "Unique knowledge base identifier." + }, + "name": { + "type": "string", + "description": "Knowledge base name." + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description." + }, + "tokenCount": { + "type": "integer", + "description": "Total token count across all documents." + }, + "embeddingModel": { + "type": "string", + "description": "Embedding model used (e.g. text-embedding-3-small)." + }, + "embeddingDimension": { + "type": "integer", + "description": "Embedding vector dimension." + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfig" + }, + "docCount": { + "type": "integer", + "description": "Number of documents in the knowledge base." + }, + "connectorTypes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Types of connectors attached to this knowledge base." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was last modified." + } + } + }, + "ChunkingConfig": { + "type": "object", + "description": "Configuration for how documents are split into chunks for embedding.", + "properties": { + "maxSize": { + "type": "integer", + "minimum": 100, + "maximum": 4000, + "default": 1024, + "description": "Maximum chunk size in tokens." + }, + "minSize": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "default": 100, + "description": "Minimum chunk size in characters." + }, + "overlap": { + "type": "integer", + "minimum": 0, + "maximum": 500, + "default": 200, + "description": "Overlap between chunks in tokens." + } + } + }, + "KnowledgeDocument": { + "type": "object", + "description": "A document in a knowledge base.", + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier." + }, + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base this document belongs to." + }, + "filename": { + "type": "string", + "description": "Original filename." + }, + "fileSize": { + "type": "integer", + "description": "File size in bytes." + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file." + }, + "processingStatus": { + "type": "string", + "enum": ["pending", "processing", "completed", "failed"], + "description": "Current processing status." + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks created from this document." + }, + "tokenCount": { + "type": "integer", + "description": "Total token count." + }, + "characterCount": { + "type": "integer", + "description": "Total character count." + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded." + } + } + }, + "KnowledgeDocumentDetail": { + "type": "object", + "description": "Detailed document information including processing and connector details.", + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier." + }, + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base this document belongs to." + }, + "filename": { + "type": "string", + "description": "Original filename." + }, + "fileSize": { + "type": "integer", + "description": "File size in bytes." + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file." + }, + "processingStatus": { + "type": "string", + "enum": ["pending", "processing", "completed", "failed"], + "description": "Current processing status." + }, + "processingError": { + "type": "string", + "nullable": true, + "description": "Error message if processing failed." + }, + "processingStartedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When processing started." + }, + "processingCompletedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When processing completed." + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks created." + }, + "tokenCount": { + "type": "integer", + "description": "Total token count." + }, + "characterCount": { + "type": "integer", + "description": "Total character count." + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search." + }, + "connectorId": { + "type": "string", + "nullable": true, + "description": "Connector ID if sourced from an external connector." + }, + "connectorType": { + "type": "string", + "nullable": true, + "description": "Connector type (e.g. google-drive, notion)." + }, + "sourceUrl": { + "type": "string", + "nullable": true, + "description": "Original source URL for connector-sourced documents." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded." + } + } + }, + "SearchResult": { + "type": "object", + "description": "A single search result from knowledge base vector search.", + "properties": { + "documentId": { + "type": "string", + "description": "ID of the source document." + }, + "documentName": { + "type": "string", + "description": "Filename of the source document." + }, + "sourceUrl": { + "type": "string", + "nullable": true, + "description": "URL to the original source document for connector-synced documents (e.g., a Confluence page, Google Doc, or Notion page). Null for documents without an external source." + }, + "content": { + "type": "string", + "description": "The matched chunk content." + }, + "chunkIndex": { + "type": "integer", + "description": "Index of the chunk within the document." + }, + "metadata": { + "type": "object", + "description": "Tag metadata associated with the chunk (display names mapped to values)." + }, + "similarity": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Similarity score (0-1, where 1 is most similar)." + } + } + }, + "TagFilter": { + "type": "object", + "description": "A tag-based filter for knowledge base search.", + "required": ["tagName", "value"], + "properties": { + "tagName": { + "type": "string", + "description": "Display name of the tag to filter by." + }, + "fieldType": { + "type": "string", + "enum": ["text", "number", "date", "boolean"], + "default": "text", + "description": "Data type of the tag field." + }, + "operator": { + "type": "string", + "default": "eq", + "description": "Comparison operator (e.g. eq, neq, gt, lt, gte, lte, contains, between)." + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "description": "Value to filter by." + }, + "valueTo": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ], + "description": "Upper bound value for 'between' operator." + } + } + }, + "PausedExecutionSummary": { + "type": "object", + "description": "Summary of a paused workflow execution.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the paused execution record." + }, + "workflowId": { + "type": "string", + "description": "The workflow this execution belongs to." + }, + "executionId": { + "type": "string", + "description": "The execution that was paused." + }, + "status": { + "type": "string", + "description": "Current status of the paused execution.", + "example": "paused" + }, + "totalPauseCount": { + "type": "integer", + "description": "Total number of pause points in this execution." + }, + "resumedCount": { + "type": "integer", + "description": "Number of pause points that have been resumed." + }, + "pausedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution was paused." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the paused execution record was last updated." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the paused execution will expire and be cleaned up." + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional metadata associated with the paused execution.", + "additionalProperties": true + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of triggers that initiated the original execution." + }, + "pausePoints": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PausePoint" + }, + "description": "List of pause points in the execution." + } + } + }, + "PausePoint": { + "type": "object", + "description": "A point in the workflow where execution has been paused awaiting human input.", + "properties": { + "contextId": { + "type": "string", + "description": "Unique identifier for this pause context. Used when resuming execution." + }, + "blockId": { + "type": "string", + "description": "The block ID where execution paused." + }, + "response": { + "description": "Data returned by the block before pausing, including display data and form fields." + }, + "registeredAt": { + "type": "string", + "format": "date-time", + "description": "When this pause point was registered." + }, + "resumeStatus": { + "type": "string", + "enum": ["paused", "resumed", "failed", "queued", "resuming"], + "description": "Current status of this pause point." + }, + "snapshotReady": { + "type": "boolean", + "description": "Whether the execution snapshot is ready for resumption." + }, + "resumeLinks": { + "type": "object", + "description": "Links for resuming this pause point.", + "properties": { + "apiUrl": { + "type": "string", + "format": "uri", + "description": "API endpoint URL to POST resume input to." + }, + "uiUrl": { + "type": "string", + "format": "uri", + "description": "UI URL for a human to review and approve." + }, + "contextId": { + "type": "string", + "description": "The context ID for this pause point." + }, + "executionId": { + "type": "string", + "description": "The execution ID." + }, + "workflowId": { + "type": "string", + "description": "The workflow ID." + } + } + }, + "queuePosition": { + "type": "integer", + "nullable": true, + "description": "Position in the resume queue, if queued." + }, + "latestResumeEntry": { + "$ref": "#/components/schemas/ResumeQueueEntry", + "nullable": true, + "description": "The most recent resume queue entry for this pause point." + }, + "parallelScope": { + "type": "object", + "description": "Scope information when the pause occurs inside a parallel branch.", + "properties": { + "parallelId": { + "type": "string", + "description": "Identifier of the parallel execution group." + }, + "branchIndex": { + "type": "integer", + "description": "Index of the branch within the parallel group." + }, + "branchTotal": { + "type": "integer", + "description": "Total number of branches in the parallel group." + } + } + }, + "loopScope": { + "type": "object", + "description": "Scope information when the pause occurs inside a loop.", + "properties": { + "loopId": { + "type": "string", + "description": "Identifier of the loop." + }, + "iteration": { + "type": "integer", + "description": "Current loop iteration number." + } + } + } + } + }, + "ResumeQueueEntry": { + "type": "object", + "description": "An entry in the resume execution queue.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for this queue entry." + }, + "pausedExecutionId": { + "type": "string", + "description": "The paused execution this entry belongs to." + }, + "parentExecutionId": { + "type": "string", + "description": "The original execution that was paused." + }, + "newExecutionId": { + "type": "string", + "description": "The new execution ID created for the resume." + }, + "contextId": { + "type": "string", + "description": "The pause context ID being resumed." + }, + "resumeInput": { + "description": "The input provided when resuming." + }, + "status": { + "type": "string", + "description": "Status of this queue entry (e.g., pending, claimed, completed, failed)." + }, + "queuedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the entry was added to the queue." + }, + "claimedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When execution started processing this entry." + }, + "completedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When execution completed." + }, + "failureReason": { + "type": "string", + "nullable": true, + "description": "Reason for failure, if the resume failed." + } + } + }, + "PausedExecutionDetail": { + "type": "object", + "description": "Detailed information about a paused execution, including the execution snapshot and resume queue.", + "allOf": [ + { + "$ref": "#/components/schemas/PausedExecutionSummary" + }, + { + "type": "object", + "properties": { + "executionSnapshot": { + "type": "object", + "description": "Serialized execution state for resumption.", + "properties": { + "snapshot": { + "type": "string", + "description": "Serialized execution snapshot data." + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Trigger IDs from the snapshot." + } + } + }, + "queue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResumeQueueEntry" + }, + "description": "Resume queue entries for this execution." + } + } + } + ] + }, + "PauseContextDetail": { + "type": "object", + "description": "Detailed information about a specific pause context within a paused execution.", + "properties": { + "execution": { + "$ref": "#/components/schemas/PausedExecutionSummary", + "description": "Summary of the parent paused execution." + }, + "pausePoint": { + "$ref": "#/components/schemas/PausePoint", + "description": "The specific pause point for this context." + }, + "queue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResumeQueueEntry" + }, + "description": "Resume queue entries for this context." + }, + "activeResumeEntry": { + "$ref": "#/components/schemas/ResumeQueueEntry", + "nullable": true, + "description": "The currently active resume entry, if any." + } + } + }, + "ResumeResult": { + "type": "object", + "description": "Result of a synchronous resume execution.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the resume execution completed successfully." + }, + "status": { + "type": "string", + "description": "Execution status.", + "enum": ["completed", "failed", "paused", "cancelled"], + "example": "completed" + }, + "executionId": { + "type": "string", + "description": "The new execution ID for the resumed workflow." + }, + "output": { + "type": "object", + "description": "Workflow output from the resumed execution.", + "additionalProperties": true + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message if the execution failed." + }, + "metadata": { + "type": "object", + "description": "Execution timing metadata.", + "properties": { + "duration": { + "type": "integer", + "description": "Total execution duration in milliseconds." + }, + "startTime": { + "type": "string", + "format": "date-time", + "description": "When the resume execution started." + }, + "endTime": { + "type": "string", + "format": "date-time", + "description": "When the resume execution completed." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request parameters. Check the details array for specific validation errors.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message describing the validation failure." + }, + "details": { + "type": "array", + "description": "List of specific validation errors with field-level details.", + "items": { + "type": "object" + } + } + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. You do not have permission to access this resource. For audit log endpoints, this requires an Enterprise subscription and organization admin/owner role.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found. Verify the ID is correct and belongs to your workspace.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration specified in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message with rate limit details." + } + } + } + } + } + }, + "RowsUpdated": { + "description": "Rows updated.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Indicates whether the request was successful." + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation message describing how many rows were updated." + }, + "updatedCount": { + "type": "integer", + "description": "Number of rows that were updated." + }, + "updatedRowIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of IDs for each row that was updated." + } + }, + "description": "Response payload." + } + } + }, + "example": { + "success": true, + "data": { + "message": "Rows updated successfully", + "updatedCount": 2, + "updatedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json new file mode 100644 index 00000000000..402866bc262 --- /dev/null +++ b/apps/docs/openapi-v2-files-audit.json @@ -0,0 +1,1125 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Files & Audit Logs", + "description": "Version 2 of the Sim REST API for the Files and Audit Logs surfaces.\n\n## Conventions (v2)\n\nEvery v2 endpoint shares one response family:\n\n- **Single resource:** `{ \"data\": T }`\n- **List:** `{ \"data\": T[], \"nextCursor\": string | null }`\n- **Error:** `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`\n\n### Cursor pagination\n\nLists use an opaque keyset cursor (Stripe/Slack-style): pass `limit` and `cursor` in, receive `data` and `nextCursor` out. Treat `cursor` as opaque — pass back the `nextCursor` from the previous page verbatim. When `nextCursor` is `null` there are no more results. Total counts are not returned on lists.\n\n### Rate limiting\n\nRate-limit state is carried in response headers, not the body: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (an ISO 8601 timestamp). A throttled request returns `429` with a `Retry-After` header (seconds).\n\n### Authentication\n\nAll endpoints authenticate with the `X-API-Key` header (a personal or workspace API key). Files endpoints are workspace-scoped via the required `workspaceId` query parameter. Audit Logs endpoints are organization-scoped enterprise endpoints and require an Enterprise subscription plus an organization admin or owner role.", + "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" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Files", + "description": "Upload, download, list, and archive workspace files (v2). Workspace-scoped via the required workspaceId query parameter." + }, + { + "name": "Audit Logs", + "description": "Query the organization audit trail (v2). Organization-scoped enterprise endpoints requiring an Enterprise subscription and an organization admin or owner role." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/files": { + "get": { + "operationId": "listFiles", + "summary": "List Files", + "description": "List the active files in a workspace with opaque cursor pagination. Results are ordered by upload time. Pass the `nextCursor` from a previous response to fetch the next page; a `null` `nextCursor` means there are no more results.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files?workspaceId=YOUR_WORKSPACE_ID&limit=100\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of files to return per page. Clamped to the range 1–1000. Defaults to 100.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + } + }, + { + "$ref": "#/components/parameters/Cursor" + } + ], + "responses": { + "200": { + "description": "A page of workspace files.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileListResponse" + }, + "example": { + "data": [ + { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z" + } + ], + "nextCursor": "eyJ1cGxvYWRlZEF0IjoiMjAyNi0wMS0xNVQxMDozMDowMFoiLCJpZCI6IndmX1YxU3RHWFI4ejVqZEhpNkJteVQ5MSJ9" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "uploadFile", + "summary": "Upload File", + "description": "Upload a file to a workspace as `multipart/form-data` with a single `file` field. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the request body is buffered. Maximum file size is 100MB. Duplicate filenames within a workspace are rejected. Returns `201 Created`.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/files?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"file=@/path/to/file.csv\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "requestBody": { + "required": true, + "description": "The file to upload, sent as multipart/form-data.", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "The file to upload. Maximum size is 100MB." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The file was uploaded successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileResponse" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "400": { + "description": "The request was malformed: an invalid `workspaceId` query parameter, a body that is not valid multipart form data, or a missing `file` form field.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "file form field is required" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "description": "A file with the same name already exists in this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "A file with this name already exists in the workspace" + } + } + } + } + }, + "413": { + "description": "The upload exceeds the 100MB file size limit, or the workspace storage limit would be exceeded.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "File size exceeds 100MB limit (142.30MB)" + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/{fileId}": { + "get": { + "operationId": "downloadFile", + "summary": "Download File", + "description": "Download the raw bytes of a file. The success response body is the file content itself — there is no JSON envelope. The actual `Content-Type` reflects the stored file's MIME type (shown here as `application/octet-stream`); `Content-Disposition` and `Content-Length` describe the attachment, and rate-limit state is returned in the `X-RateLimit-*` headers. Lookups are workspace-scoped: a file that belongs to another workspace returns `404`. Error responses still use the canonical v2 JSON error envelope.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files/{fileId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -o downloaded-file.csv" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The raw file content as binary data. The `Content-Type` header reflects the file's stored MIME type.", + "headers": { + "Content-Type": { + "description": "MIME type of the file. Varies per file; defaults to application/octet-stream when unknown.", + "schema": { + "type": "string", + "example": "text/csv" + } + }, + "Content-Disposition": { + "description": "Attachment disposition carrying the (sanitized and RFC 5987 encoded) filename.", + "schema": { + "type": "string", + "example": "attachment; filename=\"data.csv\"; filename*=UTF-8''data.csv" + } + }, + "Content-Length": { + "description": "Size of the file in bytes.", + "schema": { + "type": "string", + "example": "1024" + } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteFile", + "summary": "Delete File", + "description": "Archive (soft delete) a file in a workspace. The operation is workspace-scoped and records its own audit entry. Returns the file id and a `deleted` acknowledgement.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/files/{fileId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The file was archived.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2DeleteFileResponse" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "deleted": true + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "description": "The file could not be archived because of a conflicting state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Failed to delete file" + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/audit-logs": { + "get": { + "operationId": "listAuditLogs", + "summary": "List Audit Logs", + "description": "List audit log entries for the authenticated user's organization with opaque cursor pagination. These are organization-scoped (not workspace-scoped) enterprise endpoints: the caller must belong to an organization with an active Enterprise subscription and hold an admin or owner role — otherwise the request returns `403`. The `ipAddress` and `userAgent` fields are intentionally excluded from entries for privacy.", + "tags": ["Audit Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs?limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "action", + "in": "query", + "required": false, + "description": "Filter by action type (e.g., file.uploaded, workflow.deployed, member.invited).", + "schema": { + "type": "string" + } + }, + { + "name": "resourceType", + "in": "query", + "required": false, + "description": "Filter by resource type (e.g., file, workflow, workspace, member).", + "schema": { + "type": "string" + } + }, + { + "name": "resourceId", + "in": "query", + "required": false, + "description": "Filter by a specific resource ID.", + "schema": { + "type": "string" + } + }, + { + "name": "workspaceId", + "in": "query", + "required": false, + "description": "Filter by a workspace within your organization. Must belong to your organization, otherwise the request returns 400.", + "schema": { + "type": "string" + } + }, + { + "name": "actorId", + "in": "query", + "required": false, + "description": "Filter by the user who performed the action. Must be a member of your organization, otherwise the request returns 400.", + "schema": { + "type": "string" + } + }, + { + "name": "startDate", + "in": "query", + "required": false, + "description": "Only return entries at or after this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "required": false, + "description": "Only return entries at or before this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "includeDeparted", + "in": "query", + "required": false, + "description": "When true, include entries from users who have left the organization. Defaults to false.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of entries to return per page. Must be between 1 and 100. Defaults to 50.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "$ref": "#/components/parameters/Cursor" + } + ], + "responses": { + "200": { + "description": "A page of audit log entries.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2AuditLogListResponse" + }, + "example": { + "data": [ + { + "id": "audit_2c3d4e5f6g", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "actorId": "user_abc123", + "actorName": "Jane Smith", + "actorEmail": "jane@example.com", + "action": "file.uploaded", + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "resourceName": "data.csv", + "description": "Uploaded file \"data.csv\" via API", + "metadata": { + "fileSize": 1024, + "fileType": "text/csv" + }, + "createdAt": "2026-01-15T10:30:00Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "description": "The request was malformed: an invalid query parameter, an `actorId` that is not a member of your organization, or a `workspaceId` that does not belong to your organization.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "actorId is not a member of your organization" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/audit-logs/{id}": { + "get": { + "operationId": "getAuditLog", + "summary": "Get Audit Log", + "description": "Retrieve a single audit log entry by ID, scoped to the authenticated user's organization. Organization-scoped (not workspace-scoped): the caller must belong to an organization with an active Enterprise subscription and hold an admin or owner role — otherwise the request returns `403`. An entry outside your organization returns `404` (existence is not leaked). The `ipAddress` and `userAgent` fields are intentionally excluded for privacy.", + "tags": ["Audit Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique audit log entry identifier.", + "schema": { + "type": "string", + "minLength": 1, + "example": "audit_2c3d4e5f6g" + } + } + ], + "responses": { + "200": { + "description": "The audit log entry.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2AuditLogResponse" + }, + "example": { + "data": { + "id": "audit_2c3d4e5f6g", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "actorId": "user_abc123", + "actorName": "Jane Smith", + "actorEmail": "jane@example.com", + "action": "file.uploaded", + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "resourceName": "data.csv", + "description": "Uploaded file \"data.csv\" via API", + "metadata": { + "fileSize": 1024, + "fileType": "text/csv" + }, + "createdAt": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace.", + "schema": { + "type": "string", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + }, + "FileIdPath": { + "name": "fileId", + "in": "path", + "required": true, + "description": "The unique identifier of the file.", + "schema": { + "type": "string", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + } + }, + "Cursor": { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor. Pass the `nextCursor` value from a previous response to fetch the next page.", + "schema": { + "type": "string" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 100 + } + }, + "X-RateLimit-Remaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 95 + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-01-15T11:00:00Z" + } + } + }, + "schemas": { + "V2File": { + "type": "object", + "description": "A workspace file as exposed by the v2 surface.", + "required": ["id", "name", "size", "type", "key", "uploadedBy", "uploadedAt"], + "properties": { + "id": { + "type": "string", + "description": "Unique file identifier.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "name": { + "type": "string", + "description": "Original filename.", + "example": "data.csv" + }, + "size": { + "type": "integer", + "minimum": 0, + "description": "File size in bytes.", + "example": 1024 + }, + "type": { + "type": "string", + "description": "MIME type of the file.", + "example": "text/csv" + }, + "key": { + "type": "string", + "description": "Storage key for the file.", + "example": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv" + }, + "uploadedBy": { + "type": "string", + "description": "User ID of the uploader.", + "example": "user_abc123" + }, + "uploadedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of when the file was uploaded.", + "example": "2026-01-15T10:30:00Z" + } + } + }, + "V2DeleteFileResult": { + "type": "object", + "description": "Acknowledgement returned by a successful archive (soft delete).", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The unique identifier of the archived file.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Always true on a successful archive." + } + } + }, + "V2AuditLogEntry": { + "type": "object", + "description": "A public enterprise audit log entry. The ipAddress and userAgent fields are intentionally excluded for privacy.", + "required": [ + "id", + "workspaceId", + "actorId", + "actorName", + "actorEmail", + "action", + "resourceType", + "resourceId", + "resourceName", + "description", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the audit log entry.", + "example": "audit_2c3d4e5f6g" + }, + "workspaceId": { + "type": ["string", "null"], + "description": "The workspace where the action occurred, or null for organization-level actions.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "actorId": { + "type": ["string", "null"], + "description": "The user ID of the person who performed the action, or null when not attributable.", + "example": "user_abc123" + }, + "actorName": { + "type": ["string", "null"], + "description": "Display name of the person who performed the action.", + "example": "Jane Smith" + }, + "actorEmail": { + "type": ["string", "null"], + "description": "Email address of the person who performed the action.", + "example": "jane@example.com" + }, + "action": { + "type": "string", + "description": "The action that was performed (e.g., file.uploaded, workflow.deployed).", + "example": "file.uploaded" + }, + "resourceType": { + "type": "string", + "description": "The type of resource affected (e.g., file, workflow, workspace, member).", + "example": "file" + }, + "resourceId": { + "type": ["string", "null"], + "description": "The unique identifier of the affected resource.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "resourceName": { + "type": ["string", "null"], + "description": "Display name of the affected resource.", + "example": "data.csv" + }, + "description": { + "type": ["string", "null"], + "description": "Human-readable description of the action.", + "example": "Uploaded file \"data.csv\" via API" + }, + "metadata": { + "description": "Arbitrary per-action metadata as JSON. The shape varies by action type and may be null for some actions.", + "example": { + "fileSize": 1024, + "fileType": "text/csv" + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the action occurred.", + "example": "2026-01-15T10:30:00Z" + } + } + }, + "V2FileListResponse": { + "type": "object", + "description": "A page of files plus the cursor for the next page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The files in this page.", + "items": { + "$ref": "#/components/schemas/V2File" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more results." + } + } + }, + "V2FileResponse": { + "type": "object", + "description": "A single file resource.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2File" + } + } + }, + "V2DeleteFileResponse": { + "type": "object", + "description": "The result of archiving a file.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2DeleteFileResult" + } + } + }, + "V2AuditLogListResponse": { + "type": "object", + "description": "A page of audit log entries plus the cursor for the next page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The audit log entries in this page.", + "items": { + "$ref": "#/components/schemas/V2AuditLogEntry" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more results." + } + } + }, + "V2AuditLogResponse": { + "type": "object", + "description": "A single audit log entry resource.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2AuditLogEntry" + } + } + }, + "V2Error": { + "type": "object", + "description": "The canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code (e.g., BAD_REQUEST, NOT_FOUND, RATE_LIMITED)." + }, + "message": { + "type": "string", + "description": "Human-readable error message." + }, + "details": { + "description": "Optional structured error context. For validation errors this is an array of field-level issues; for rate limiting it carries the reset timestamp." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request. Inspect `error.message` and the optional `error.details` for specifics.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": ["workspaceId"], + "code": "invalid_type", + "message": "Required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. For Files, the API key lacks access to the workspace. For Audit Logs, this requires an Enterprise subscription and an organization admin or owner role.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Active enterprise subscription required" + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found, or it does not belong to the authorized scope.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "File not found" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer", + "example": 30 + } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-15T11:00:00Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred on the server.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json new file mode 100644 index 00000000000..5c43fd27ff7 --- /dev/null +++ b/apps/docs/openapi-v2-knowledge.json @@ -0,0 +1,1802 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Knowledge Bases", + "description": "The v2 Knowledge Bases API lets you create and manage knowledge bases, upload and inspect documents, and run vector and tag search over your indexed content.\n\n## Conventions\n\nAll endpoints live under the `/api/v2` base path and share a single set of conventions:\n\n- **Authentication** — Send your Sim API key in the `X-API-Key` header on every request. Keys are scoped to a workspace (or are personal keys that target a workspace); `workspaceId` is always required so the request can be tenant-scoped and rate-limited.\n- **Single-resource and mutation responses** return `{ \"data\": ... }`.\n- **List responses** use an opaque-cursor envelope: `{ \"data\": [ ... ], \"nextCursor\": string | null }`. Pass the returned `nextCursor` back as the `cursor` query parameter to fetch the next page. When `nextCursor` is `null` there are no more results. Cursors are opaque — do not parse or construct them.\n- **Errors** use a single envelope: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`. The HTTP status code and the stable `code` field move together (for example `404` ⇄ `NOT_FOUND`).\n- **Rate limiting** — Every response carries the current limiter state in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. A throttled request returns `429` with a `Retry-After` header.", + "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" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Knowledge Bases", + "description": "Create and manage knowledge bases, upload and inspect documents, and run vector and tag search (v2 API)." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/knowledge": { + "get": { + "operationId": "listKnowledgeBases", + "summary": "List Knowledge Bases", + "description": "List all knowledge bases in a workspace. The full bounded per-workspace set is returned as a single page, so `nextCursor` is always `null` today; treat the response as a standard cursor list so pagination can be added later without a contract change.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "Knowledge bases for the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The knowledge bases in the workspace.", + "items": { + "$ref": "#/components/schemas/KnowledgeBase" + } + }, + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createKnowledgeBase", + "summary": "Create Knowledge Base", + "description": "Create a new knowledge base in a workspace. The embedding model and dimension are fixed server-side and cannot be supplied. Returns `201` with the created knowledge base.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Product Documentation\",\n \"description\": \"All product docs and guides\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The knowledge base to create.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKnowledgeBaseBody" + } + } + } + }, + "responses": { + "201": { + "description": "The knowledge base was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + } + ], + "get": { + "operationId": "getKnowledgeBase", + "summary": "Get Knowledge Base", + "description": "Retrieve a single knowledge base by ID. A knowledge base that does not exist, belongs to another workspace, or that the caller cannot read is reported as `404` so cross-workspace existence is never leaked.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The knowledge base.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "operationId": "updateKnowledgeBase", + "summary": "Update Knowledge Base", + "description": "Update a knowledge base's name, description, or chunking config. At least one of `name`, `description`, or `chunkingConfig` must be provided. The target workspace is carried in the request body.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\n \"https://www.sim.ai/api/v2/knowledge/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Updated name\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The fields to update. At least one of name, description, or chunkingConfig is required.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateKnowledgeBaseBody" + } + } + } + }, + "responses": { + "200": { + "description": "The updated knowledge base.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteKnowledgeBase", + "summary": "Delete Knowledge Base", + "description": "Delete a knowledge base and all of its documents. Returns a delete acknowledgement with the id of the removed knowledge base.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/knowledge/{id}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The knowledge base was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/search": { + "post": { + "operationId": "searchKnowledge", + "summary": "Search Knowledge", + "description": "Run vector and/or tag search across one or more knowledge bases. Provide a `query` for semantic vector search, `tagFilters` for structured filtering, or both. At least one of `query` or `tagFilters` is required.\n\nNotes and limits:\n- Tag filters are only supported when searching a single knowledge base.\n- When a `query` is supplied, all targeted knowledge bases must use the same embedding model; otherwise the request is rejected. Search such knowledge bases separately.\n- A text query consumes hosted embedding (and optional rerank) usage; tag-only search is free.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge/search\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"knowledgeBaseIds\": [\"KB_ID\"],\n \"query\": \"How do I reset my password?\",\n \"topK\": 10\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The search request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchBody" + } + } + } + }, + "responses": { + "200": { + "description": "Search results.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request. Returned when neither `query` nor `tagFilters` is provided, when tag filters target more than one knowledge base, when the selected knowledge bases use different embedding models, or when a tag name/value is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "crossModel": { + "summary": "Knowledge bases use different embedding models", + "value": { + "error": { + "code": "BAD_REQUEST", + "message": "Selected knowledge bases use different embedding models and cannot be searched together. Search them separately." + } + } + }, + "multiKbTagFilter": { + "summary": "Tag filters across multiple knowledge bases", + "value": { + "error": { + "code": "BAD_REQUEST", + "message": "Tag filters are only supported when searching a single knowledge base" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "One or more of the requested knowledge bases do not exist or are not accessible from this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Knowledge base not found or access denied" + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}/documents": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "get": { + "operationId": "listKnowledgeDocuments", + "summary": "List Documents", + "description": "List documents in a knowledge base. Supports search, enabled-state filtering, sorting, and cursor pagination. Pass the returned `nextCursor` back as `cursor` to fetch the next page; the total document count is available as `docCount` on the parent knowledge base.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of documents to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor from a previous response's `nextCursor`. Omit for the first page.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against document filenames.", + "schema": { + "type": "string" + } + }, + { + "name": "enabledFilter", + "in": "query", + "required": false, + "description": "Filter documents by their enabled state.", + "schema": { + "type": "string", + "enum": ["all", "enabled", "disabled"], + "default": "all" + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": [ + "filename", + "fileSize", + "tokenCount", + "chunkCount", + "uploadedAt", + "processingStatus", + "enabled" + ], + "default": "uploadedAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } + } + ], + "responses": { + "200": { + "description": "Documents in the knowledge base.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The documents on this page.", + "items": { + "$ref": "#/components/schemas/DocumentSummary" + } + }, + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "uploadKnowledgeDocument", + "summary": "Upload Document", + "description": "Upload a single document to a knowledge base as `multipart/form-data`. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the file body is buffered. The maximum file size is 100 MB. Processing is asynchronous: the document is returned with `processingStatus: \"pending\"` and indexing continues in the background — poll the Get Document endpoint to observe progress.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"file=@/path/to/document.pdf\"" + } + ], + "requestBody": { + "required": true, + "description": "The file to upload.", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "The document file to upload (max 100 MB)." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The document was accepted and queued for processing.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentSummaryEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request. Returned when the body is not valid multipart form data or the required `file` field is missing.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "file form field is required" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "description": "The uploaded file exceeds the 100 MB limit, or the workspace storage limit has been reached.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "File size exceeds 100MB limit (123.45MB)" + } + } + } + } + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}/documents/{documentId}": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/DocumentId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "get": { + "operationId": "getKnowledgeDocument", + "summary": "Get Document", + "description": "Retrieve the full detail for a single document, including processing state and connector provenance.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "The document detail.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteKnowledgeDocument", + "summary": "Delete Document", + "description": "Delete a single document from a knowledge base. Returns a delete acknowledgement with the id of the removed document.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "The document was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "KnowledgeBaseId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + } + }, + "DocumentId": { + "name": "documentId", + "in": "path", + "required": true, + "description": "The unique identifier of the document.", + "schema": { + "type": "string", + "minLength": 1, + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + } + }, + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace that scopes the request.", + "schema": { + "type": "string", + "minLength": 1, + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 60 + } + }, + "RateLimitRemaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 59 + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2025-06-20T14:16:00Z" + } + }, + "RetryAfter": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer", + "example": 30 + } + } + }, + "schemas": { + "NextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more results. Pass it back as the `cursor` query parameter. Do not parse or construct cursors.", + "example": null + }, + "ChunkingConfig": { + "type": "object", + "description": "How documents in this knowledge base are split into chunks before embedding.", + "required": ["maxSize", "minSize", "overlap"], + "additionalProperties": true, + "properties": { + "maxSize": { + "type": "integer", + "description": "Maximum chunk size, in tokens.", + "example": 1024 + }, + "minSize": { + "type": "integer", + "description": "Minimum chunk size, in characters.", + "example": 100 + }, + "overlap": { + "type": "integer", + "description": "Number of overlapping characters between adjacent chunks.", + "example": 200 + }, + "strategy": { + "type": "string", + "description": "Chunking strategy applied during processing.", + "enum": ["auto", "text", "regex", "recursive", "sentence", "token"] + } + } + }, + "ChunkingConfigInput": { + "type": "object", + "description": "Chunking configuration for the knowledge base. Defaults are applied when omitted.", + "properties": { + "maxSize": { + "type": "integer", + "description": "Maximum chunk size, in tokens.", + "minimum": 100, + "maximum": 4000, + "default": 1024 + }, + "minSize": { + "type": "integer", + "description": "Minimum chunk size, in characters.", + "minimum": 1, + "maximum": 2000, + "default": 100 + }, + "overlap": { + "type": "integer", + "description": "Number of overlapping characters between adjacent chunks.", + "minimum": 0, + "maximum": 500, + "default": 200 + } + } + }, + "KnowledgeBase": { + "type": "object", + "description": "A knowledge base: a collection of documents indexed for vector and tag search.", + "required": [ + "id", + "name", + "description", + "tokenCount", + "embeddingModel", + "embeddingDimension", + "chunkingConfig", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique knowledge base identifier.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "name": { + "type": "string", + "description": "Human-readable knowledge base name.", + "example": "Product Documentation" + }, + "description": { + "type": ["string", "null"], + "description": "Optional description of the knowledge base. null when not set.", + "example": "All product docs and guides" + }, + "tokenCount": { + "type": "integer", + "description": "Total number of tokens across all indexed documents.", + "example": 48213 + }, + "embeddingModel": { + "type": "string", + "description": "The embedding model used to index documents in this knowledge base.", + "example": "text-embedding-3-small" + }, + "embeddingDimension": { + "type": "integer", + "description": "The dimensionality of the embedding vectors.", + "example": 1536 + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfig" + }, + "docCount": { + "type": "integer", + "description": "Number of documents in the knowledge base.", + "example": 12 + }, + "connectorTypes": { + "type": "array", + "description": "The set of external connector types that have synced documents into this knowledge base.", + "items": { + "type": "string" + }, + "example": ["notion", "google_drive"] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was created.", + "example": "2025-01-10T09:00:00Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was last modified.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "KnowledgeBaseEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["knowledgeBase"], + "properties": { + "knowledgeBase": { + "$ref": "#/components/schemas/KnowledgeBase" + } + } + } + } + }, + "CreateKnowledgeBaseBody": { + "type": "object", + "description": "Request body for creating a knowledge base.", + "required": ["workspaceId", "name"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace the knowledge base belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable knowledge base name.", + "example": "Product Documentation" + }, + "description": { + "type": "string", + "maxLength": 1000, + "description": "Optional description of the knowledge base.", + "example": "All product docs and guides" + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfigInput" + } + } + }, + "UpdateKnowledgeBaseBody": { + "type": "object", + "description": "Request body for updating a knowledge base. At least one of name, description, or chunkingConfig must be provided.", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace the knowledge base belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "New knowledge base name.", + "example": "Updated Product Documentation" + }, + "description": { + "type": "string", + "maxLength": 1000, + "description": "New description of the knowledge base.", + "example": "Refreshed product docs and guides" + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfigInput" + } + } + }, + "DocumentSummary": { + "type": "object", + "description": "Summary representation of a document, returned in list operations and as the upload acknowledgement.", + "required": [ + "id", + "knowledgeBaseId", + "filename", + "fileSize", + "mimeType", + "processingStatus", + "chunkCount", + "tokenCount", + "characterCount", + "enabled", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier.", + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + }, + "knowledgeBaseId": { + "type": "string", + "description": "The knowledge base this document belongs to.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "filename": { + "type": "string", + "description": "Original filename of the uploaded document.", + "example": "getting-started.pdf" + }, + "fileSize": { + "type": "integer", + "description": "Size of the file in bytes.", + "example": 248913 + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file.", + "example": "application/pdf" + }, + "processingStatus": { + "type": "string", + "description": "Current processing state of the document.", + "enum": ["pending", "processing", "completed", "failed"], + "example": "completed" + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks the document was split into. 0 until processing completes.", + "example": 24 + }, + "tokenCount": { + "type": "integer", + "description": "Total number of tokens extracted from the document.", + "example": 8123 + }, + "characterCount": { + "type": "integer", + "description": "Total number of characters extracted from the document.", + "example": 41205 + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search.", + "example": true + }, + "createdAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "DocumentSummaryEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["document"], + "properties": { + "document": { + "$ref": "#/components/schemas/DocumentSummary" + } + } + } + } + }, + "Document": { + "type": "object", + "description": "Full document detail: the summary fields plus processing state and connector provenance.", + "required": [ + "id", + "knowledgeBaseId", + "filename", + "fileSize", + "mimeType", + "processingStatus", + "chunkCount", + "tokenCount", + "characterCount", + "enabled", + "createdAt", + "processingError", + "processingStartedAt", + "processingCompletedAt", + "connectorId", + "connectorType", + "sourceUrl" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier.", + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + }, + "knowledgeBaseId": { + "type": "string", + "description": "The knowledge base this document belongs to.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "filename": { + "type": "string", + "description": "Original filename of the uploaded document.", + "example": "getting-started.pdf" + }, + "fileSize": { + "type": "integer", + "description": "Size of the file in bytes.", + "example": 248913 + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file.", + "example": "application/pdf" + }, + "processingStatus": { + "type": "string", + "description": "Current processing state of the document.", + "enum": ["pending", "processing", "completed", "failed"], + "example": "completed" + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks the document was split into. 0 until processing completes.", + "example": 24 + }, + "tokenCount": { + "type": "integer", + "description": "Total number of tokens extracted from the document.", + "example": 8123 + }, + "characterCount": { + "type": "integer", + "description": "Total number of characters extracted from the document.", + "example": 41205 + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search.", + "example": true + }, + "createdAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded.", + "example": "2025-06-18T16:45:00Z" + }, + "processingError": { + "type": ["string", "null"], + "description": "Error message if processing failed, otherwise null.", + "example": null + }, + "processingStartedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when processing started, or null.", + "example": "2025-06-18T16:45:05Z" + }, + "processingCompletedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when processing completed, or null.", + "example": "2025-06-18T16:45:42Z" + }, + "connectorId": { + "type": ["string", "null"], + "description": "Identifier of the connector that synced this document, or null for direct uploads.", + "example": null + }, + "connectorType": { + "type": ["string", "null"], + "description": "Type of the connector that synced this document, or null for direct uploads.", + "example": null + }, + "sourceUrl": { + "type": ["string", "null"], + "description": "Original source URL of the document for connector-synced documents, or null.", + "example": null + } + } + }, + "DocumentEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["document"], + "properties": { + "document": { + "$ref": "#/components/schemas/Document" + } + } + } + } + }, + "SearchTagFilter": { + "type": "object", + "description": "A structured tag filter applied to search. Tag filters are only supported when searching a single knowledge base.", + "required": ["tagName", "value"], + "properties": { + "tagName": { + "type": "string", + "description": "The display name of the tag to filter on.", + "example": "category" + }, + "fieldType": { + "type": "string", + "description": "The tag's field type.", + "enum": ["text", "number", "date", "boolean"] + }, + "operator": { + "type": "string", + "description": "Comparison operator. Valid operators depend on the field type.", + "default": "eq", + "example": "eq" + }, + "value": { + "description": "The value to compare against.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "example": "billing" + }, + "valueTo": { + "description": "Upper bound for the `between` operator (number or date).", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + } + }, + "SearchBody": { + "type": "object", + "description": "Request body for knowledge search. At least one of `query` or `tagFilters` must be provided.", + "required": ["workspaceId", "knowledgeBaseIds"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the knowledge bases.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "knowledgeBaseIds": { + "description": "A single knowledge base ID or an array of up to 20 IDs to search.", + "oneOf": [ + { + "type": "string", + "minLength": 1, + "description": "A single knowledge base ID." + }, + { + "type": "array", + "description": "An array of knowledge base IDs.", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "maxItems": 20 + } + ], + "example": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + }, + "query": { + "type": "string", + "description": "The natural-language query for semantic vector search. Required if `tagFilters` is omitted.", + "example": "How do I reset my password?" + }, + "topK": { + "type": "integer", + "description": "Maximum number of results to return.", + "minimum": 1, + "maximum": 100, + "default": 10 + }, + "tagFilters": { + "type": "array", + "description": "Structured tag filters. Only supported when searching a single knowledge base. Required if `query` is omitted.", + "items": { + "$ref": "#/components/schemas/SearchTagFilter" + } + } + } + }, + "SearchResult": { + "type": "object", + "description": "A single search hit (a matching document chunk).", + "required": [ + "documentId", + "documentName", + "sourceUrl", + "content", + "chunkIndex", + "metadata", + "similarity" + ], + "properties": { + "documentId": { + "type": "string", + "description": "Identifier of the document the chunk belongs to.", + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + }, + "documentName": { + "type": ["string", "null"], + "description": "Filename of the source document, or null if unavailable.", + "example": "getting-started.pdf" + }, + "sourceUrl": { + "type": ["string", "null"], + "description": "Original source URL of the document, or null for direct uploads.", + "example": null + }, + "content": { + "type": "string", + "description": "The matching chunk's text content.", + "example": "To reset your password, open Settings and choose \"Security\"." + }, + "chunkIndex": { + "type": "integer", + "description": "Zero-based index of the chunk within its document.", + "example": 3 + }, + "metadata": { + "type": "object", + "description": "The document's tag values keyed by tag display name. Values are user-defined and may be strings, numbers, booleans, or dates.", + "additionalProperties": true, + "example": { + "category": "billing", + "priority": 2 + } + }, + "similarity": { + "type": "number", + "description": "Similarity score in the range 0–1 for vector search (higher is more similar). 1 for tag-only matches.", + "example": 0.8423 + } + } + }, + "SearchEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["results", "query", "knowledgeBaseIds", "topK", "totalResults"], + "properties": { + "results": { + "type": "array", + "description": "The matching chunks, ordered by relevance.", + "items": { + "$ref": "#/components/schemas/SearchResult" + } + }, + "query": { + "type": "string", + "description": "The query that was executed (empty string for tag-only search).", + "example": "How do I reset my password?" + }, + "knowledgeBaseIds": { + "type": "array", + "description": "The knowledge base IDs that were searched.", + "items": { + "type": "string" + }, + "example": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + }, + "topK": { + "type": "integer", + "description": "The maximum number of results requested.", + "example": 10 + }, + "totalResults": { + "type": "integer", + "description": "The number of results returned.", + "example": 4 + } + } + } + } + }, + "DeleteEnvelope": { + "type": "object", + "description": "Delete acknowledgement.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The id of the resource that was deleted.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "deleted": { + "type": "boolean", + "description": "Always true.", + "enum": [true], + "example": true + } + } + } + } + }, + "Error": { + "type": "object", + "description": "The canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code.", + "enum": [ + "BAD_REQUEST", + "UNAUTHORIZED", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "USAGE_LIMIT_EXCEEDED", + "LOCKED", + "RATE_LIMITED", + "INTERNAL_ERROR" + ] + }, + "message": { + "type": "string", + "description": "Human-readable description of the error." + }, + "details": { + "description": "Optional structured context for the error, such as field-level validation issues." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": "workspaceId", + "message": "workspaceId query parameter is required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "The API key is missing or invalid. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "The authenticated caller does not have access to the requested workspace or resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } + } + }, + "NotFound": { + "description": "The requested resource does not exist or is not accessible from this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Knowledge base not found" + } + } + } + } + }, + "Conflict": { + "description": "The request conflicts with the current state of the resource (for example, a resource with the same name already exists).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Resource already exists" + } + } + } + } + }, + "UsageLimitExceeded": { + "description": "The workspace has exceeded its usage or billing limits. Upgrade the plan to continue.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "USAGE_LIMIT_EXCEEDED", + "message": "Usage limit exceeded. Please upgrade your plan to continue." + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request payload exceeds the allowed size, or the workspace storage limit has been reached.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Storage limit exceeded" + } + } + } + } + }, + "UnsupportedMediaType": { + "description": "The uploaded file's MIME type or extension is not supported.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNSUPPORTED_MEDIA_TYPE", + "message": "Unsupported file type" + } + } + } + } + }, + "RateLimited": { + "description": "The rate limit has been exceeded. Retry after the period indicated by the Retry-After header.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/RetryAfter" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2025-06-20T14:16:00Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred on the server.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json new file mode 100644 index 00000000000..4631df64376 --- /dev/null +++ b/apps/docs/openapi-v2-logs.json @@ -0,0 +1,1065 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Logs", + "description": "Version 2 of the Sim API for workflow execution logs. v2 standardizes every response on a single envelope: a single resource returns `{ data }`, a list returns `{ data, nextCursor }`, and an error returns `{ error: { code, message, details? } }`. Lists use opaque cursor pagination (`limit` + `cursor` in, `nextCursor` out). Rate-limit state is carried in the `X-RateLimit-*` response headers rather than the body. Authenticate every request with the `X-API-Key` header.", + "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" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "security": [ + { + "apiKey": [] + } + ], + "tags": [ + { + "name": "Logs", + "description": "Query workflow execution logs, retrieve a single log entry, and fetch the full execution state snapshot for a run." + } + ], + "paths": { + "/api/v2/logs": { + "get": { + "operationId": "listLogs", + "summary": "List Logs", + "description": "List workflow execution logs for a workspace with filtering and opaque cursor pagination. Returns `{ data, nextCursor }`. By default (`details=basic`) each entry contains summary fields only; pass `details=full` to include the per-execution `workflow` summary, and additionally `includeFinalOutput=true` / `includeTraceSpans=true` to materialize `finalOutput` / `traceSpans` on each entry.", + "tags": ["Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "workflowIds", + "in": "query", + "description": "Comma-separated list of workflow IDs to filter by. Only logs from these workflows are returned.", + "schema": { + "type": "string" + }, + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36,8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + { + "name": "folderIds", + "in": "query", + "description": "Comma-separated list of folder IDs. Returns logs for all workflows within these folders.", + "schema": { + "type": "string" + } + }, + { + "name": "triggers", + "in": "query", + "description": "Comma-separated trigger types to filter by (e.g. api, webhook, schedule, manual, chat).", + "schema": { + "type": "string" + }, + "example": "api,schedule" + }, + { + "name": "level", + "in": "query", + "description": "Filter logs by severity level. info for successful executions, error for failed ones.", + "schema": { + "type": "string", + "enum": ["info", "error"] + } + }, + { + "name": "startDate", + "in": "query", + "description": "Only return logs started at or after this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "description": "Only return logs started at or before this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "executionId", + "in": "query", + "description": "Filter by an exact execution ID. Useful for looking up a specific run.", + "schema": { + "type": "string" + } + }, + { + "name": "minDurationMs", + "in": "query", + "description": "Only return logs where total execution duration was at least this many milliseconds.", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "maxDurationMs", + "in": "query", + "description": "Only return logs where total execution duration was at most this many milliseconds.", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "minCost", + "in": "query", + "description": "Only return logs where execution cost was at least this amount in USD.", + "schema": { + "type": "number", + "minimum": 0 + } + }, + { + "name": "maxCost", + "in": "query", + "description": "Only return logs where execution cost was at most this amount in USD.", + "schema": { + "type": "number", + "minimum": 0 + } + }, + { + "name": "model", + "in": "query", + "description": "Filter by the AI model used during execution (e.g., gpt-4o, claude-sonnet-4-20250514).", + "schema": { + "type": "string" + } + }, + { + "name": "details", + "in": "query", + "description": "Response detail level. basic returns summary fields only. full additionally includes the per-entry workflow summary and enables the includeFinalOutput / includeTraceSpans materialization flags.", + "schema": { + "type": "string", + "enum": ["basic", "full"], + "default": "basic" + } + }, + { + "name": "includeTraceSpans", + "in": "query", + "description": "When true, includes block-level execution trace spans on each entry. Only applies when details=full.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "includeFinalOutput", + "in": "query", + "description": "When true, includes the workflow's final output on each entry. Only applies when details=full.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of log entries to return per page. Values are clamped to the range 1–1000.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + } + }, + { + "name": "cursor", + "in": "query", + "description": "Opaque pagination cursor returned from a previous request's nextCursor field. Omit to fetch the first page.", + "schema": { + "type": "string" + } + }, + { + "name": "order", + "in": "query", + "description": "Sort order by execution start time. desc returns newest first.", + "schema": { + "type": "string", + "enum": ["desc", "asc"], + "default": "desc" + } + } + ], + "responses": { + "200": { + "description": "A page of execution logs matching the filter criteria.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "Log entries for the current page.", + "items": { + "$ref": "#/components/schemas/LogListItem" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for fetching the next page. null when there are no more results." + } + } + }, + "example": { + "data": [ + { + "id": "log_7x8y9z0a1b", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "deploymentVersionId": "dep_2c4e6a8b0d1f", + "level": "info", + "trigger": "api", + "startedAt": "2026-01-15T10:30:00.000Z", + "endedAt": "2026-01-15T10:30:01.250Z", + "totalDurationMs": 1250, + "cost": { + "total": 0.0032 + }, + "files": null + } + ], + "nextCursor": "eyJzdGFydGVkQXQiOiIyMDI2LTAxLTE1VDEwOjMwOjAwLjAwMFoiLCJpZCI6ImxvZ183eDh5OXowYTFiIn0=" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/logs/{id}": { + "get": { + "operationId": "getLog", + "summary": "Get Log", + "description": "Retrieve a single log entry by its ID, including the workflow metadata captured at execution time, the materialized execution data, and the cost summary. Returns `{ data }`.", + "tags": ["Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the log entry.", + "schema": { + "type": "string", + "example": "log_7x8y9z0a1b" + } + } + ], + "responses": { + "200": { + "description": "The requested log entry with full execution data and cost summary.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/LogDetail" + } + } + }, + "example": { + "data": { + "id": "log_7x8y9z0a1b", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "level": "info", + "trigger": "api", + "startedAt": "2026-01-15T10:30:00.000Z", + "endedAt": "2026-01-15T10:30:01.250Z", + "totalDurationMs": 1250, + "files": null, + "workflow": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": null, + "userId": "usr_1a2b3c4d5e", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "createdAt": "2025-01-10T09:00:00.000Z", + "updatedAt": "2025-06-18T16:45:00.000Z", + "deleted": false + }, + "executionData": { + "traceSpans": [], + "finalOutput": { + "result": "Hello, world!" + } + }, + "cost": { + "total": 0.0032 + }, + "createdAt": "2026-01-15T10:30:00.000Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/logs/executions/{executionId}": { + "get": { + "operationId": "getExecution", + "summary": "Get Execution", + "description": "Retrieve the full execution state snapshot for a run: the workflow state captured at execution time plus execution metadata (trigger, timing, and cost). Returns `{ data }`.", + "tags": ["Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/executions/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique execution identifier.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "The full execution state snapshot with workflow state and metadata.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/Execution" + } + } + }, + "example": { + "data": { + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workflowState": { + "blocks": {}, + "edges": [], + "loops": {}, + "parallels": {} + }, + "executionMetadata": { + "trigger": "api", + "startedAt": "2026-01-15T10:30:00.000Z", + "endedAt": "2026-01-15T10:30:01.250Z", + "totalDurationMs": 1250, + "cost": { + "total": 0.0032 + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "WorkspaceId": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "The unique identifier of the workspace whose logs to query." + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "Maximum number of requests allowed in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "X-RateLimit-Remaining": { + "description": "Number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp when the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "schemas": { + "Cost": { + "type": ["object", "null"], + "description": "Aggregate execution cost in USD. null when no cost was recorded for the run.", + "required": ["total"], + "properties": { + "total": { + "type": "number", + "description": "Total cost of the execution in USD.", + "example": 0.0032 + } + } + }, + "LogWorkflowSummary": { + "type": "object", + "description": "Workflow summary captured at execution time. Present on a list entry only when details=full.", + "required": ["id", "name", "description", "deleted"], + "properties": { + "id": { + "type": ["string", "null"], + "description": "The workflow's identifier. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Workflow name. Falls back to \"Deleted Workflow\" when the workflow no longer exists.", + "example": "Customer Support Agent" + }, + "description": { + "type": ["string", "null"], + "description": "Workflow description, or null if none was set.", + "example": "Routes incoming support tickets and drafts responses" + }, + "deleted": { + "type": "boolean", + "description": "Whether the workflow has since been deleted.", + "example": false + } + } + }, + "LogWorkflowDetail": { + "type": "object", + "description": "Full workflow metadata captured at execution time.", + "required": [ + "id", + "name", + "description", + "folderId", + "userId", + "workspaceId", + "createdAt", + "updatedAt", + "deleted" + ], + "properties": { + "id": { + "type": ["string", "null"], + "description": "The workflow's identifier. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Workflow name. Falls back to \"Deleted Workflow\" when the workflow no longer exists.", + "example": "Customer Support Agent" + }, + "description": { + "type": ["string", "null"], + "description": "Workflow description, or null if none was set.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": ["string", "null"], + "description": "The folder the workflow belongs to. null if at the workspace root or the workflow is gone.", + "example": null + }, + "userId": { + "type": ["string", "null"], + "description": "The user that owns the workflow. null if the workflow is gone.", + "example": "usr_1a2b3c4d5e" + }, + "workspaceId": { + "type": ["string", "null"], + "description": "The workspace the workflow belongs to. null if the workflow is gone.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "createdAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created. null if the workflow is gone.", + "example": "2025-01-10T09:00:00.000Z" + }, + "updatedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified. null if the workflow is gone.", + "example": "2025-06-18T16:45:00.000Z" + }, + "deleted": { + "type": "boolean", + "description": "Whether the workflow has since been deleted.", + "example": false + } + } + }, + "LogListItem": { + "type": "object", + "description": "Summary of a single workflow execution log entry returned by the list endpoint.", + "required": [ + "id", + "workflowId", + "executionId", + "deploymentVersionId", + "level", + "trigger", + "startedAt", + "endedAt", + "totalDurationMs", + "cost", + "files" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": ["string", "null"], + "description": "The workflow that was executed. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier for this run.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "deploymentVersionId": { + "type": ["string", "null"], + "description": "The deployment version that produced this run. null for runs not tied to a deployment.", + "example": "dep_2c4e6a8b0d1f" + }, + "level": { + "type": "string", + "description": "Log severity. info for successful executions, error for failures.", + "example": "info" + }, + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-01-15T10:30:00.000Z" + }, + "endedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.", + "example": "2026-01-15T10:30:01.250Z" + }, + "totalDurationMs": { + "type": ["integer", "null"], + "description": "Total execution duration in milliseconds. null if the run has not finished.", + "example": 1250 + }, + "cost": { + "$ref": "#/components/schemas/Cost" + }, + "files": { + "type": ["array", "null"], + "description": "Attachment metadata for files produced during the run. null when the run produced no files.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "workflow": { + "allOf": [ + { + "$ref": "#/components/schemas/LogWorkflowSummary" + } + ], + "description": "Workflow summary. Present only when details=full." + }, + "finalOutput": { + "type": "object", + "additionalProperties": true, + "description": "The workflow's final output. The shape depends on the workflow. Present only when details=full and includeFinalOutput=true." + }, + "traceSpans": { + "type": "array", + "description": "Block-level execution trace spans with timing, inputs, and outputs. Present only when details=full and includeTraceSpans=true.", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "LogDetail": { + "type": "object", + "description": "Detailed log entry with full workflow metadata, materialized execution data, and cost summary.", + "required": [ + "id", + "workflowId", + "executionId", + "level", + "trigger", + "startedAt", + "endedAt", + "totalDurationMs", + "files", + "workflow", + "executionData", + "cost", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": ["string", "null"], + "description": "The workflow that was executed. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier for this run.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "level": { + "type": "string", + "description": "Log severity. info for successful executions, error for failures.", + "example": "info" + }, + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-01-15T10:30:00.000Z" + }, + "endedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.", + "example": "2026-01-15T10:30:01.250Z" + }, + "totalDurationMs": { + "type": ["integer", "null"], + "description": "Total execution duration in milliseconds. null if the run has not finished.", + "example": 1250 + }, + "files": { + "type": ["array", "null"], + "description": "Attachment metadata for files produced during the run. null when the run produced no files.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "workflow": { + "$ref": "#/components/schemas/LogWorkflowDetail" + }, + "executionData": { + "type": "object", + "additionalProperties": true, + "description": "Materialized execution trace for this run (block states, trace spans, and final output). Large blobs stored externally are resolved inline.", + "properties": { + "traceSpans": { + "type": "array", + "description": "Block-level execution traces with timing, inputs, and outputs.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "finalOutput": { + "type": "object", + "additionalProperties": true, + "description": "The workflow's final output after all blocks completed." + } + } + }, + "cost": { + "$ref": "#/components/schemas/Cost" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the log entry was recorded.", + "example": "2026-01-15T10:30:00.000Z" + } + } + }, + "Execution": { + "type": "object", + "description": "Full execution state snapshot: the workflow state at execution time plus execution metadata.", + "required": ["executionId", "workflowId", "workflowState", "executionMetadata"], + "properties": { + "executionId": { + "type": "string", + "description": "The unique identifier for this execution.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "workflowId": { + "type": ["string", "null"], + "description": "The workflow that was executed. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "workflowState": { + "type": "object", + "additionalProperties": true, + "description": "Snapshot of the workflow configuration at the time of execution.", + "properties": { + "blocks": { + "type": "object", + "additionalProperties": true, + "description": "Map of block IDs to their configuration and state during execution." + }, + "edges": { + "type": "array", + "description": "Connections between blocks defining the execution flow.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "loops": { + "type": "object", + "additionalProperties": true, + "description": "Loop configurations defining iterative execution patterns." + }, + "parallels": { + "type": "object", + "additionalProperties": true, + "description": "Parallel execution group configurations." + } + } + }, + "executionMetadata": { + "type": "object", + "description": "Metadata about the execution including trigger, timing, and cost.", + "required": ["trigger", "startedAt", "endedAt", "totalDurationMs", "cost"], + "properties": { + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-01-15T10:30:00.000Z" + }, + "endedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.", + "example": "2026-01-15T10:30:01.250Z" + }, + "totalDurationMs": { + "type": ["integer", "null"], + "description": "Total execution duration in milliseconds. null if the run has not finished.", + "example": 1250 + }, + "cost": { + "$ref": "#/components/schemas/Cost" + } + } + } + } + }, + "Error": { + "type": "object", + "description": "Canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Machine-readable error code (e.g., BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, RATE_LIMITED, INTERNAL_ERROR).", + "example": "NOT_FOUND" + }, + "message": { + "type": "string", + "description": "Human-readable error message.", + "example": "Log not found" + }, + "details": { + "description": "Optional structured details about the error (e.g., field-level validation issues or rate-limit reset info). Present only on some errors." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request parameters. Inspect error.details for field-level validation issues.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": "workspaceId", + "message": "Workspace ID is required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } + } + } + } + }, + "Forbidden": { + "description": "The API key is authenticated but not authorized for the requested workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "API key is not authorized for this workspace" + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found. An authorization failure on a single resource is also reported as 404 so resource existence is not leaked.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Log not found" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-15T10:31:00.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred while processing the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json new file mode 100644 index 00000000000..fa3daf0cd97 --- /dev/null +++ b/apps/docs/openapi-v2-tables.json @@ -0,0 +1,2339 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim Tables API v2", + "description": "Version 2 of the Sim Tables API for managing tables, their column schemas, and rows of structured data. v2 standardizes every endpoint on a single response family: a single resource is returned as `{ data }`, lists are returned as `{ data, nextCursor }` with opaque cursor pagination, and errors are returned as `{ error: { code, message, details? } }`. Rate-limit state is carried in `X-RateLimit-*` response headers. Authenticate every request with the `X-API-Key` header. Row `data` is always 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" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Tables", + "description": "Manage tables, columns, and rows for structured data storage (v2 API)." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/tables": { + "get": { + "operationId": "listTables", + "summary": "List Tables", + "description": "List all tables in a workspace. Returns the full bounded set of tables for the workspace as a single page, so `nextCursor` is always null.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The tables in the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableListEnvelope" + }, + "example": { + "data": [ + { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "contacts", + "description": "Customer contact records", + "schema": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + }, + { + "id": "col_d4e5f6", + "name": "name", + "type": "string", + "required": true + } + ] + }, + "rowCount": 2, + "maxRows": 100000, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createTable", + "summary": "Create Table", + "description": "Create a new table with a typed column schema. The schema must contain between 1 and 50 columns.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"contacts\",\n \"description\": \"Customer contacts\",\n \"schema\": {\n \"columns\": [\n { \"name\": \"email\", \"type\": \"string\", \"required\": true, \"unique\": true },\n { \"name\": \"name\", \"type\": \"string\", \"required\": true },\n { \"name\": \"age\", \"type\": \"number\" }\n ]\n }\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The table name, optional description, column schema, and target workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTableBody" + } + } + } + }, + "responses": { + "201": { + "description": "The table was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableEnvelope" + }, + "example": { + "data": { + "table": { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "contacts", + "description": "Customer contacts", + "schema": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + }, + { + "id": "col_d4e5f6", + "name": "name", + "type": "string", + "required": true + }, + { + "id": "col_g7h8i9", + "name": "age", + "type": "number", + "required": false, + "unique": false + } + ] + }, + "rowCount": 0, + "maxRows": 100000, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}": { + "get": { + "operationId": "getTable", + "summary": "Get Table", + "description": "Get a single table's metadata and column schema.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The requested table.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTable", + "summary": "Delete Table", + "description": "Archive a table. Returns the id of the archived table.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The table was archived.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteTableEnvelope" + }, + "example": { + "data": { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/columns": { + "post": { + "operationId": "addTableColumn", + "summary": "Add Column", + "description": "Add a column to the table schema. Returns the table's full column list after the change.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"column\": {\n \"name\": \"phone\",\n \"type\": \"string\",\n \"required\": false,\n \"unique\": false\n }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and the column definition to add.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddColumnBody" + } + } + } + }, + "responses": { + "200": { + "description": "The column was added.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnsEnvelope" + }, + "example": { + "data": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + }, + { + "id": "col_d4e5f6", + "name": "name", + "type": "string", + "required": true, + "unique": false + }, + { + "id": "col_x9y8z7", + "name": "phone", + "type": "string", + "required": false, + "unique": false + } + ] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateTableColumn", + "summary": "Update Column", + "description": "Update a column by name — rename it, change its type, or toggle its required/unique constraints. Provide at least one field in `updates`. Returns the table's full column list after the change.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"columnName\": \"phone\",\n \"updates\": {\n \"name\": \"phone_number\",\n \"required\": true\n }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace, the current column name, and the fields to change.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateColumnBody" + } + } + } + }, + "responses": { + "200": { + "description": "The column was updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnsEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableColumn", + "summary": "Delete Column", + "description": "Delete a column from the table schema by name. A table must always keep at least one column. Returns the table's full column list after the change.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"columnName\": \"phone_number\"\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and the name of the column to delete.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteColumnBody" + } + } + } + }, + "responses": { + "200": { + "description": "The column was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnsEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows": { + "get": { + "operationId": "listTableRows", + "summary": "List Rows", + "description": "Query rows from a table with optional filtering, sorting, and cursor pagination. `filter` and `sort` are passed as JSON-encoded query parameters and key on column names. Pagination uses an opaque cursor: pass the `nextCursor` from a previous response to fetch the next page; `nextCursor` is null on the final page. Total row count is available as `rowCount` on the table resource.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/FilterQuery" + }, + { + "$ref": "#/components/parameters/SortQuery" + }, + { + "$ref": "#/components/parameters/LimitQuery" + }, + { + "$ref": "#/components/parameters/CursorQuery" + } + ], + "responses": { + "200": { + "description": "Rows matching the query.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowListEnvelope" + }, + "example": { + "data": [ + { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "email": "jane@example.com", + "name": "Jane Doe", + "age": 30 + }, + "position": 0, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + ], + "nextCursor": "eyJvZmZzZXQiOjUwfQ==" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createTableRows", + "summary": "Create Rows", + "description": "Insert one or many rows. Send a single-row body (`{ data }`) to insert one row, or a batch body (`{ rows }`) to insert up to 1000 rows in one request. The response shape mirrors the request: a single insert returns `{ data: { row } }`, a batch insert returns `{ data: { rows, insertedCount } }`. Row `data` is keyed by column name.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": {\n \"email\": \"user@example.com\",\n \"name\": \"Jane Doe\"\n }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "Either a single-row payload or a batch payload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRowsBody" + }, + "examples": { + "single": { + "summary": "Insert a single row", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "data": { + "email": "user@example.com", + "name": "Jane Doe" + } + } + }, + "batch": { + "summary": "Insert multiple rows", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "rows": [ + { + "email": "a@example.com", + "name": "Ada" + }, + { + "email": "b@example.com", + "name": "Babbage" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The row(s) were inserted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRowsResponse" + }, + "examples": { + "single": { + "summary": "Single insert response", + "value": { + "data": { + "row": { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "email": "user@example.com", + "name": "Jane Doe" + }, + "position": 0, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + } + } + }, + "batch": { + "summary": "Batch insert response", + "value": { + "data": { + "rows": [ + { + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "data": { + "email": "a@example.com", + "name": "Ada" + }, + "position": 0, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + }, + { + "id": "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85", + "data": { + "email": "b@example.com", + "name": "Babbage" + }, + "position": 1, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + ], + "insertedCount": 2 + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "operationId": "updateTableRows", + "summary": "Update Rows by Filter", + "description": "Bulk-update every row matching a filter, applying the same partial `data` patch to each. The filter must contain at least one condition. `updatedRowIds` is always returned (empty when nothing matched).", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"filter\": { \"status\": \"pending\" },\n \"data\": { \"status\": \"active\" }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace, a non-empty filter, the patch data, and an optional row cap.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRowsByFilterBody" + } + } + } + }, + "responses": { + "200": { + "description": "The matching rows were updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRowsEnvelope" + }, + "example": { + "data": { + "updatedCount": 3, + "updatedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85", + "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + ] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableRows", + "summary": "Delete Rows", + "description": "Delete rows in bulk, either by a non-empty filter or by an explicit list of row ids. Provide exactly one of `filter` or `rowIds`. For id-based deletes the response also reports `requestedCount` and any `missingRowIds`; these fields are omitted for filter-based deletes.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"rowIds\": [\"row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93\", \"row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85\"]\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and either a non-empty filter or an explicit list of row ids.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRowsBody" + }, + "examples": { + "byIds": { + "summary": "Delete specific rows by id", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "rowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] + } + }, + "byFilter": { + "summary": "Delete rows matching a filter", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "filter": { + "status": "archived" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The rows were deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRowsEnvelope" + }, + "examples": { + "byIds": { + "summary": "Id-based delete response", + "value": { + "data": { + "deletedCount": 2, + "deletedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ], + "requestedCount": 2, + "missingRowIds": [] + } + } + }, + "byFilter": { + "summary": "Filter-based delete response", + "value": { + "data": { + "deletedCount": 5, + "deletedRowIds": ["row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93"] + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/{rowId}": { + "get": { + "operationId": "getTableRow", + "summary": "Get Row", + "description": "Get a single row by id.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The requested row.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateTableRow", + "summary": "Update Row", + "description": "Partially update a single row by id. The `data` patch is keyed by column name and merges into the existing row.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": { \"name\": \"Updated Name\" }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and the partial row data to apply.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRowBody" + } + } + } + }, + "responses": { + "200": { + "description": "The row was updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableRow", + "summary": "Delete Row", + "description": "Delete a single row by id. Returns `deletedCount` and `deletedRowIds`, mirroring the bulk delete shape.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The row was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRowEnvelope" + }, + "example": { + "data": { + "deletedCount": 1, + "deletedRowIds": ["row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07"] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/upsert": { + "post": { + "operationId": "upsertTableRow", + "summary": "Upsert Row", + "description": "Insert a row, or update the existing row that conflicts on a unique column. When `conflictTarget` is omitted the server resolves the conflict against the table's single unique column. The response reports whether the row was inserted or updated.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/upsert\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": { \"email\": \"user@example.com\", \"name\": \"John\" },\n \"conflictTarget\": \"email\"\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace, the row data, and an optional unique column to resolve the conflict against.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertRowBody" + } + } + } + }, + "responses": { + "200": { + "description": "The row was inserted or updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertRowEnvelope" + }, + "example": { + "data": { + "row": { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "email": "user@example.com", + "name": "John" + }, + "position": 0, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + }, + "operation": "insert" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "TableId": { + "name": "tableId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "description": "The unique identifier of the table." + }, + "RowId": { + "name": "rowId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "description": "The unique identifier of the row." + }, + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "The unique identifier of the workspace that owns the table." + }, + "FilterQuery": { + "name": "filter", + "in": "query", + "required": false, + "description": "JSON-encoded filter object keyed by column name. Supports equality ({\"status\": \"active\"}), comparison operators ({\"age\": {\"$gt\": 18}}), and $and/$or composition.", + "schema": { + "type": "string" + } + }, + "SortQuery": { + "name": "sort", + "in": "query", + "required": false, + "description": "JSON-encoded sort object mapping column name to direction. Example: {\"created_at\": \"desc\"}.", + "schema": { + "type": "string" + } + }, + "LimitQuery": { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum rows to return (1-1000, default 100).", + "schema": { + "type": "integer", + "default": 100, + "minimum": 1, + "maximum": 1000 + } + }, + "CursorQuery": { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor. Pass the `nextCursor` from a previous response to fetch the next page. Omit for the first page.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "Maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "RateLimitRemaining": { + "description": "Number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + "RetryAfter": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "schemas": { + "V2Error": { + "type": "object", + "description": "Canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Machine-readable error code.", + "example": "BAD_REQUEST" + }, + "message": { + "type": "string", + "description": "Human-readable error message." + }, + "details": { + "description": "Optional structured error details, such as per-field validation issues." + } + } + } + } + }, + "Column": { + "type": "object", + "description": "A column definition in a table schema.", + "required": ["name", "type"], + "properties": { + "id": { + "type": "string", + "description": "Stable server-assigned column id. May be absent on legacy columns created before id backfill.", + "example": "col_a1b2c3" + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "email" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + }, + "workflowGroupId": { + "type": "string", + "description": "Set when the column is the output of a workflow group." + } + } + }, + "ColumnInput": { + "type": "object", + "description": "Column definition supplied when creating a table or adding a column.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "email" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + } + } + }, + "Table": { + "type": "object", + "description": "A user-defined table with a typed column schema.", + "required": [ + "id", + "name", + "description", + "schema", + "rowCount", + "maxRows", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique table identifier.", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "name": { + "type": "string", + "description": "Table name.", + "example": "contacts" + }, + "description": { + "type": ["string", "null"], + "description": "Optional description of the table. Null when not set.", + "example": "Customer contact records" + }, + "schema": { + "type": "object", + "description": "Table schema definition.", + "required": ["columns"], + "properties": { + "columns": { + "type": "array", + "description": "Array of column definitions for the table.", + "items": { + "$ref": "#/components/schemas/Column" + } + } + } + }, + "rowCount": { + "type": "integer", + "description": "Current number of rows in the table." + }, + "maxRows": { + "type": "integer", + "description": "Maximum rows allowed by the current billing plan." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was last modified." + } + } + }, + "RowData": { + "type": "object", + "additionalProperties": true, + "description": "Row cells keyed by column name. Each value is typed per its column definition.", + "example": { + "email": "jane@example.com", + "name": "Jane Doe", + "age": 30 + } + }, + "Row": { + "type": "object", + "description": "A single row in a table.", + "required": ["id", "data", "position", "createdAt", "updatedAt"], + "properties": { + "id": { + "type": "string", + "description": "Unique row identifier.", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "position": { + "type": "integer", + "description": "Row's position/order in the table." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was last modified." + } + } + }, + "Filter": { + "type": "object", + "additionalProperties": true, + "minProperties": 1, + "description": "Filter object keyed by column name. Supports equality ({\"status\": \"active\"}), comparison operators ({\"age\": {\"$gt\": 18}}), and $and/$or composition. Must contain at least one condition.", + "example": { + "status": "active" + } + }, + "CreateTableBody": { + "type": "object", + "description": "Payload to create a new table.", + "required": ["workspaceId", "name", "schema"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that will own the table." + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 128, + "description": "Table name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "contacts" + }, + "description": { + "type": "string", + "maxLength": 500, + "description": "Optional description of the table." + }, + "schema": { + "type": "object", + "required": ["columns"], + "description": "The table's column schema.", + "properties": { + "columns": { + "type": "array", + "minItems": 1, + "maxItems": 50, + "description": "Column definitions. A table must have between 1 and 50 columns.", + "items": { + "$ref": "#/components/schemas/ColumnInput" + } + } + } + } + } + }, + "AddColumnBody": { + "type": "object", + "description": "Payload to add a column to a table.", + "required": ["workspaceId", "column"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "column": { + "type": "object", + "description": "The column definition to add.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "phone" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + }, + "position": { + "type": "integer", + "minimum": 0, + "description": "Zero-based insert position in the column order. Appended at the end when omitted." + } + } + } + } + }, + "UpdateColumnBody": { + "type": "object", + "description": "Payload to update an existing column by name.", + "required": ["workspaceId", "columnName", "updates"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "columnName": { + "type": "string", + "description": "The current name of the column to update.", + "example": "phone" + }, + "updates": { + "type": "object", + "description": "Fields to change. Provide at least one.", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "New column name.", + "example": "phone_number" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "New data type for the column." + }, + "required": { + "type": "boolean", + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "description": "Whether values in this column must be unique across all rows." + } + } + } + } + }, + "DeleteColumnBody": { + "type": "object", + "description": "Payload to delete a column by name.", + "required": ["workspaceId", "columnName"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "columnName": { + "type": "string", + "description": "The name of the column to delete.", + "example": "phone_number" + } + } + }, + "CreateRowSingleBody": { + "type": "object", + "description": "Insert a single row.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "afterRowId": { + "type": "string", + "minLength": 1, + "description": "Insert directly after this row id. Mutually exclusive with beforeRowId." + }, + "beforeRowId": { + "type": "string", + "minLength": 1, + "description": "Insert directly before this row id. Mutually exclusive with afterRowId." + } + } + }, + "CreateRowBatchBody": { + "type": "object", + "description": "Insert multiple rows in one request.", + "required": ["workspaceId", "rows"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "rows": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "Rows to insert. Each entry is keyed by column name. Up to 1000 rows per request.", + "items": { + "$ref": "#/components/schemas/RowData" + } + } + } + }, + "CreateRowsBody": { + "description": "Either a single-row payload or a batch payload.", + "oneOf": [ + { + "$ref": "#/components/schemas/CreateRowSingleBody" + }, + { + "$ref": "#/components/schemas/CreateRowBatchBody" + } + ] + }, + "UpdateRowsByFilterBody": { + "type": "object", + "description": "Bulk-update rows matching a filter.", + "required": ["workspaceId", "filter", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "filter": { + "$ref": "#/components/schemas/Filter" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of matching rows to update." + } + } + }, + "DeleteRowsByFilterBody": { + "type": "object", + "description": "Delete rows matching a filter.", + "required": ["workspaceId", "filter"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "filter": { + "$ref": "#/components/schemas/Filter" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of matching rows to delete." + } + } + }, + "DeleteRowsByIdsBody": { + "type": "object", + "description": "Delete an explicit list of rows by id.", + "required": ["workspaceId", "rowIds"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "rowIds": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "Row ids to delete. Up to 1000 ids per request.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of rows to delete." + } + } + }, + "DeleteRowsBody": { + "description": "Provide exactly one of `filter` or `rowIds`.", + "oneOf": [ + { + "$ref": "#/components/schemas/DeleteRowsByFilterBody" + }, + { + "$ref": "#/components/schemas/DeleteRowsByIdsBody" + } + ] + }, + "UpdateRowBody": { + "type": "object", + "description": "Partial update for a single row.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + } + } + }, + "UpsertRowBody": { + "type": "object", + "description": "Insert-or-update a row keyed by a unique column.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "conflictTarget": { + "type": "string", + "minLength": 1, + "description": "Name of the unique column to resolve the conflict against. When omitted, the server uses the table's single unique column." + } + } + }, + "TableEnvelope": { + "type": "object", + "description": "A single table wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["table"], + "properties": { + "table": { + "$ref": "#/components/schemas/Table" + } + } + } + } + }, + "TableListEnvelope": { + "type": "object", + "description": "A page of tables. `nextCursor` is always null because the full bounded workspace set is returned in one page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Table" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more pages." + } + } + }, + "DeleteTableEnvelope": { + "type": "object", + "description": "Confirmation that a table was archived.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "The id of the archived table." + } + } + } + } + }, + "ColumnsEnvelope": { + "type": "object", + "description": "The table's full column list after a column mutation.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["columns"], + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Column" + } + } + } + } + } + }, + "RowEnvelope": { + "type": "object", + "description": "A single row wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["row"], + "properties": { + "row": { + "$ref": "#/components/schemas/Row" + } + } + } + } + }, + "RowListEnvelope": { + "type": "object", + "description": "A cursor-paginated page of rows.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Row" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null on the final page." + } + } + }, + "BatchInsertRowsEnvelope": { + "type": "object", + "description": "Result of a batch row insert.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["rows", "insertedCount"], + "properties": { + "rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Row" + } + }, + "insertedCount": { + "type": "integer", + "description": "Number of rows inserted." + } + } + } + } + }, + "CreateRowsResponse": { + "description": "A single-row insert returns `{ data: { row } }`; a batch insert returns `{ data: { rows, insertedCount } }`.", + "oneOf": [ + { + "$ref": "#/components/schemas/RowEnvelope" + }, + { + "$ref": "#/components/schemas/BatchInsertRowsEnvelope" + } + ] + }, + "UpdateRowsEnvelope": { + "type": "object", + "description": "Result of a bulk update-by-filter.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["updatedCount", "updatedRowIds"], + "properties": { + "updatedCount": { + "type": "integer", + "description": "Number of rows updated." + }, + "updatedRowIds": { + "type": "array", + "description": "Ids of the updated rows. Empty when nothing matched.", + "items": { + "type": "string" + } + } + } + } + } + }, + "DeleteRowsEnvelope": { + "type": "object", + "description": "Result of a bulk delete. `requestedCount` and `missingRowIds` are present only for id-based deletes.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["deletedCount", "deletedRowIds"], + "properties": { + "deletedCount": { + "type": "integer", + "description": "Number of rows deleted." + }, + "deletedRowIds": { + "type": "array", + "description": "Ids of the deleted rows.", + "items": { + "type": "string" + } + }, + "requestedCount": { + "type": "integer", + "description": "Number of row ids requested. Present only for id-based deletes." + }, + "missingRowIds": { + "type": "array", + "description": "Requested ids that did not exist. Present only for id-based deletes.", + "items": { + "type": "string" + } + } + } + } + } + }, + "DeleteRowEnvelope": { + "type": "object", + "description": "Result of a single-row delete.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["deletedCount", "deletedRowIds"], + "properties": { + "deletedCount": { + "type": "integer", + "description": "Always 1 when a row was deleted." + }, + "deletedRowIds": { + "type": "array", + "description": "The id of the deleted row.", + "items": { + "type": "string" + } + } + } + } + } + }, + "UpsertRowEnvelope": { + "type": "object", + "description": "Result of an upsert, including whether the row was inserted or updated.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["row", "operation"], + "properties": { + "row": { + "$ref": "#/components/schemas/Row" + }, + "operation": { + "type": "string", + "enum": ["insert", "update"], + "description": "Whether the row was inserted or updated." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request. The request body, query parameters, or a JSON-encoded filter/sort failed validation. Inspect `error.details` for field-level issues.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": "schema.columns", + "message": "Table must have at least one column" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. The API key cannot access the target workspace, or a plan limit (such as the maximum number of tables) has been reached.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } + } + }, + "NotFound": { + "description": "The requested table or row was not found. Verify the id is correct and belongs to the specified workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Table not found" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/RetryAfter" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-15T10:31:00.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected server error occurred.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json new file mode 100644 index 00000000000..341c14ceb5c --- /dev/null +++ b/apps/docs/openapi-v2-workflows.json @@ -0,0 +1,1024 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Workflows", + "description": "Version 2 of the Sim REST API for listing workflows, inspecting workflow detail, and managing deployments (deploy, undeploy, rollback).\n\nThe v2 surface standardizes on a single response family across every endpoint:\n- Single resource: `{ \"data\": T }`\n- List: `{ \"data\": T[], \"nextCursor\": string | null }`\n- Error: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`\n\nLists use an opaque cursor (Stripe/Slack-style): send `limit` and `cursor`, receive `{ data, nextCursor }`. Cursors are opaque tokens — pass back the `nextCursor` from the previous page verbatim and stop when it is `null`. Total counts are not returned on lists.\n\nRate-limit state is carried in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` response headers (not in the body). A throttled request returns `429` with a `Retry-After` header.\n\nAuthenticate every request with an API key in the `X-API-Key` header.", + "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" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Workflows", + "description": "List workflows, inspect workflow detail, and manage deployments (deploy, undeploy, rollback) on the v2 API." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/workflows": { + "get": { + "operationId": "listWorkflows", + "summary": "List Workflows", + "description": "Retrieve workflows in a workspace using opaque cursor-based pagination. Results are ordered deterministically; follow `nextCursor` to page through the full set, and stop when it is `null`.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "folderId", + "in": "query", + "required": false, + "description": "Filter results to only include workflows within this folder.", + "schema": { + "type": "string", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + } + }, + { + "name": "deployedOnly", + "in": "query", + "required": false, + "description": "When true, only return workflows that are currently deployed. Useful for listing workflows available for API execution.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of workflows to return per page. Must be between 1 and 100.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor returned from a previous request's `nextCursor` field. Omit for the first page.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "A page of workflows.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "Workflows for the current page.", + "items": { + "$ref": "#/components/schemas/WorkflowListItem" + } + }, + "nextCursor": { + "type": "string", + "nullable": true, + "description": "Opaque cursor for fetching the next page. `null` when there are no more results." + } + } + }, + "example": { + "data": [ + { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 142, + "lastRunAt": "2026-06-20T14:15:22.000Z", + "createdAt": "2026-01-10T09:00:00.000Z", + "updatedAt": "2026-06-18T16:45:00.000Z" + } + ], + "nextCursor": "eyJzb3J0T3JkZXIiOjAsImNyZWF0ZWRBdCI6IjIwMjYtMDEtMTBUMDk6MDA6MDAuMDAwWiIsImlkIjoiM2IxZjdjOTIifQ==" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}": { + "get": { + "operationId": "getWorkflow", + "summary": "Get Workflow", + "description": "Retrieve a single workflow, including its workflow-level variables and trigger input field definitions. Returns 404 when the workflow does not exist or you do not have access to it (existence is not leaked).", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "responses": { + "200": { + "description": "The requested workflow.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/WorkflowDetail" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 142, + "lastRunAt": "2026-06-20T14:15:22.000Z", + "variables": { + "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": { + "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60", + "name": "supportEmail", + "type": "string", + "value": "support@example.com" + } + }, + "inputs": [ + { + "name": "ticketBody", + "type": "string", + "description": "The raw text of the incoming support ticket." + } + ], + "createdAt": "2026-01-10T09:00:00.000Z", + "updatedAt": "2026-06-18T16:45:00.000Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/deploy": { + "post": { + "operationId": "deployWorkflow", + "summary": "Deploy Workflow", + "description": "Deploy the workflow's current draft state. Creates a new deployment version, makes it live for API execution, and activates schedules and triggers. Optionally accepts a `name` and `description` for the new version; the request body may be omitted entirely. Returns 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/workflows/{id}/deploy\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\": \"Release 4\", \"description\": \"Fixes the agent prompt\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "requestBody": { + "required": false, + "description": "Optional metadata for the new deployment version. The request body may be omitted entirely.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Optional label for the new deployment version.", + "example": "Release 4" + }, + "description": { + "type": "string", + "maxLength": 50000, + "nullable": true, + "description": "Optional summary of what changed in this version.", + "example": "Fixes the agent prompt" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Workflow deployed successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/DeployResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "version": 4, + "warnings": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "undeployWorkflow", + "summary": "Undeploy Workflow", + "description": "Take the workflow offline. API execution stops and schedules, webhooks, and other deployment side effects are removed. Deployment versions are retained, so the workflow can be deployed again later. Returns 400 when the workflow is not currently deployed, 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/workflows/{id}/deploy\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "responses": { + "200": { + "description": "Workflow undeployed successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/UndeployResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": false, + "deployedAt": null, + "warnings": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/rollback": { + "post": { + "operationId": "rollbackWorkflow", + "summary": "Rollback Workflow", + "description": "Roll the live deployment back to a previous deployment version. The workflow must currently be deployed. By default the version immediately preceding the currently active one is re-activated; pass `version` to target a specific deployment version instead. The workflow's draft state is not modified. Returns 400 when the workflow is not deployed or there is no version to roll back to, 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/workflows/{id}/rollback\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"version\": 3}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "requestBody": { + "required": false, + "description": "Optional rollback target. The request body may be omitted entirely to roll back to the version immediately preceding the active one.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "The deployment version to re-activate. Defaults to the version immediately preceding the active one.", + "example": 3 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Workflow rolled back successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/RollbackResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "version": 3, + "warnings": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "WorkspaceId": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace to list workflows from.", + "schema": { + "type": "string", + "minLength": 1, + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + }, + "WorkflowId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique workflow identifier.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 60 + } + }, + "RateLimitRemaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 59 + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-06-29T21:50:00.000Z" + } + } + }, + "schemas": { + "Error": { + "type": "object", + "description": "Canonical v2 error envelope. Every non-2xx response uses this shape.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code.", + "enum": [ + "BAD_REQUEST", + "UNAUTHORIZED", + "USAGE_LIMIT_EXCEEDED", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "LOCKED", + "RATE_LIMITED", + "INTERNAL_ERROR" + ] + }, + "message": { + "type": "string", + "description": "Human-readable description of what went wrong." + }, + "details": { + "description": "Optional structured detail about the error (e.g. field-level validation issues). Shape varies by error code; absent when there is nothing to add." + } + } + } + } + }, + "WorkflowListItem": { + "type": "object", + "description": "Summary representation of a workflow returned by the list endpoint.", + "required": [ + "id", + "name", + "description", + "folderId", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Human-readable workflow name.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description of what the workflow does. `null` when unset.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": "string", + "nullable": true, + "description": "The folder this workflow belongs to. `null` when at the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + "workspaceId": { + "type": "string", + "description": "The workspace this workflow belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is currently deployed and available for API execution.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent deployment. `null` when never deployed.", + "example": "2026-06-12T10:30:00.000Z" + }, + "runCount": { + "type": "integer", + "description": "Total number of times this workflow has been executed.", + "example": 142 + }, + "lastRunAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent execution. `null` when never run.", + "example": "2026-06-20T14:15:22.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created.", + "example": "2026-01-10T09:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified.", + "example": "2026-06-18T16:45:00.000Z" + } + } + }, + "WorkflowInputField": { + "type": "object", + "description": "A single trigger input field extracted from the workflow's input-definition block. Use these to construct the `input` object when executing the workflow.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "description": "Field name as referenced by the workflow.", + "example": "ticketBody" + }, + "type": { + "type": "string", + "description": "Declared field type (e.g. `string`, `number`, `boolean`, `object`).", + "example": "string" + }, + "description": { + "type": "string", + "description": "Optional human-readable description of the field.", + "example": "The raw text of the incoming support ticket." + } + } + }, + "WorkflowDetail": { + "type": "object", + "description": "Full workflow representation: every list field plus workflow-level variables and trigger input field definitions.", + "required": [ + "id", + "name", + "description", + "folderId", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "variables", + "inputs", + "createdAt", + "updatedAt" + ], + "allOf": [ + { + "$ref": "#/components/schemas/WorkflowListItem" + }, + { + "type": "object", + "required": ["variables", "inputs"], + "properties": { + "variables": { + "type": "object", + "description": "Workflow-scoped variables keyed by variable id. Each value is a structured variable object (`{ id, name, type, value, ... }`); only the inner `value` is user-defined. Empty object when the workflow defines no variables.", + "additionalProperties": true, + "example": { + "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": { + "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60", + "name": "supportEmail", + "type": "string", + "value": "support@example.com" + } + } + }, + "inputs": { + "type": "array", + "description": "The workflow's trigger input field definitions.", + "items": { + "$ref": "#/components/schemas/WorkflowInputField" + } + } + } + } + ] + }, + "DeploymentState": { + "type": "object", + "description": "Base deployment state shared by deploy, undeploy, and rollback results.", + "required": ["id", "isDeployed", "deployedAt", "warnings"], + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is deployed and available for API execution after the operation." + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the active deployment. `null` when the workflow is not deployed.", + "example": "2026-06-12T10:30:00.000Z" + }, + "warnings": { + "type": "array", + "description": "Non-fatal warnings. Present when trigger, schedule, or MCP side-effect sync is still in progress or needs a redeploy. Empty array when there is nothing to report.", + "items": { + "type": "string" + } + } + } + }, + "DeployResult": { + "description": "Deployment state returned after a successful deploy. `isDeployed` is always `true`.", + "allOf": [ + { + "$ref": "#/components/schemas/DeploymentState" + }, + { + "type": "object", + "properties": { + "version": { + "type": "integer", + "description": "The deployment version that is now active. May be omitted when the version number is unavailable.", + "example": 4 + } + } + } + ] + }, + "UndeployResult": { + "description": "Deployment state returned after a successful undeploy. `isDeployed` is always `false`, `deployedAt` is always `null`, and no `version` is included.", + "allOf": [ + { + "$ref": "#/components/schemas/DeploymentState" + } + ] + }, + "RollbackResult": { + "description": "Deployment state returned after a successful rollback. `isDeployed` is always `true` and `version` identifies the re-activated deployment version.", + "allOf": [ + { + "$ref": "#/components/schemas/DeploymentState" + }, + { + "type": "object", + "required": ["version"], + "properties": { + "version": { + "type": "integer", + "description": "The deployment version that was re-activated.", + "example": 3 + } + } + } + ] + } + }, + "responses": { + "BadRequest": { + "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues. Also returned when an operation is not allowed in the current state (e.g. undeploying a workflow that is not deployed).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "workspaceId is required", + "details": [ + { + "path": ["workspaceId"], + "message": "workspaceId is required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the `X-API-Key` header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. You do not have permission to access the requested workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } + } + }, + "NotFound": { + "description": "The workflow does not exist or you do not have access to it. Existence is not leaked, so an access failure is reported as 404.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Workflow not found" + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request body exceeds the maximum allowed size.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } + } + } + } + }, + "Locked": { + "description": "The workflow is locked and cannot be modified. Wait for the in-progress operation to finish, then retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "Workflow is locked and cannot be modified" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration specified in the `Retry-After` header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer", + "example": 30 + } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-06-29T21:50:00.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred while processing the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/sim/app/api/v1/admin/audit-logs/route.ts b/apps/sim/app/api/v1/admin/audit-logs/route.ts index 9610232d357..f3dbc231e69 100644 --- a/apps/sim/app/api/v1/admin/audit-logs/route.ts +++ b/apps/sim/app/api/v1/admin/audit-logs/route.ts @@ -31,21 +31,13 @@ import { internalErrorResponse, listResponse, } from '@/app/api/v1/admin/responses' -import { - type AdminAuditLog, - createPaginationMeta, - parsePaginationParams, - toAdminAuditLog, -} from '@/app/api/v1/admin/types' +import { type AdminAuditLog, createPaginationMeta, toAdminAuditLog } from '@/app/api/v1/admin/types' import { buildFilterConditions } from '@/app/api/v1/audit-logs/query' const logger = createLogger('AdminAuditLogsAPI') export const GET = withRouteHandler( withAdminAuth(async (request) => { - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) - const parsed = await parseRequest( v1AdminListAuditLogsContract, request, @@ -56,6 +48,7 @@ export const GET = withRouteHandler( try { const query = parsed.data.query + const { limit, offset } = query const conditions = buildFilterConditions({ action: query.action, resourceType: query.resourceType, diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts index 69b773accf5..18bc485fbe6 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts @@ -29,6 +29,7 @@ import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, internalErrorResponse, @@ -152,7 +153,7 @@ export const PATCH = withRouteHandler( { params: routeParams }, { validationErrorResponse: adminValidationErrorResponse, - invalidJson: 'throw', + invalidJsonResponse: adminInvalidJsonResponse, } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts index 0f25618fc2c..1c0913cf576 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts @@ -44,6 +44,7 @@ import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, internalErrorResponse, @@ -143,7 +144,7 @@ export const PATCH = withRouteHandler( { params: routeParams }, { validationErrorResponse: adminValidationErrorResponse, - invalidJson: 'throw', + invalidJsonResponse: adminInvalidJsonResponse, } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts index 2b00537059a..bfe0bb747c7 100644 --- a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts +++ b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts @@ -71,7 +71,10 @@ export const POST = withRouteHandler( }) } catch (error) { logger.error('Failed to requeue outbox event', { eventId: id, error: toError(error).message }) - return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + return NextResponse.json( + { success: false, error: 'Failed to requeue outbox event' }, + { status: 500 } + ) } }) ) diff --git a/apps/sim/app/api/v1/admin/outbox/route.ts b/apps/sim/app/api/v1/admin/outbox/route.ts index f88ac55536c..57ce53c49f5 100644 --- a/apps/sim/app/api/v1/admin/outbox/route.ts +++ b/apps/sim/app/api/v1/admin/outbox/route.ts @@ -77,7 +77,10 @@ export const GET = withRouteHandler( }) } catch (error) { logger.error('Failed to list outbox events', { error: toError(error).message }) - return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + return NextResponse.json( + { success: false, error: 'Failed to list outbox events' }, + { status: 500 } + ) } }) ) diff --git a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts index b7f7c162118..1432b46d37b 100644 --- a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts +++ b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts @@ -41,6 +41,7 @@ import { requireStripeClient } from '@/lib/billing/stripe-client' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, internalErrorResponse, @@ -181,7 +182,7 @@ export const POST = withRouteHandler( {}, { validationErrorResponse: adminValidationErrorResponse, - invalidJson: 'throw', + invalidJsonResponse: adminInvalidJsonResponse, } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts index b01f6af9736..9adc8d5c29f 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -35,7 +35,21 @@ type AuthResult = * Returns the organization ID and all member user IDs on success, * or an error response on failure. */ -export async function validateEnterpriseAuditAccess(userId: string): Promise { +/** + * Structured enterprise audit-access result shared by the v1 and v2 surfaces so + * each version can render the failure in its own response envelope. + */ +export type EnterpriseAuditAccessResult = + | { success: true; context: EnterpriseAuditContext } + | { success: false; status: number; message: string } + +/** + * Core enterprise audit-access check (no response rendering). See + * {@link validateEnterpriseAuditAccess} for the policy checks performed. + */ +export async function resolveEnterpriseAuditAccess( + userId: string +): Promise { const [membership] = await db .select({ organizationId: member.organizationId, role: member.role }) .from(member) @@ -43,31 +57,16 @@ export async function validateEnterpriseAuditAccess(userId: string): Promise m.userId) @@ -108,9 +101,26 @@ export async function validateEnterpriseAuditAccess(userId: string): Promise { + const result = await resolveEnterpriseAuditAccess(userId) + if (result.success) return { success: true, context: result.context } + return { + success: false, + response: NextResponse.json({ error: result.message }, { status: result.status }), } } diff --git a/apps/sim/app/api/v1/logs/filters.ts b/apps/sim/app/api/v1/logs/filters.ts index 0e409e4d53f..8e40ca1db51 100644 --- a/apps/sim/app/api/v1/logs/filters.ts +++ b/apps/sim/app/api/v1/logs/filters.ts @@ -1,5 +1,5 @@ import { workflow, workflowExecutionLogs } from '@sim/db/schema' -import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' +import { and, asc, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' export interface LogFilters { workspaceId: string @@ -103,8 +103,14 @@ export function buildLogFilters(filters: LogFilters): SQL { return conditions.length > 0 ? and(...conditions)! : sql`true` } +/** + * Order rows by `(startedAt, id)` so the sort matches the keyset cursor's tuple + * comparison in {@link buildLogFilters}. Without the `id` tie-break, rows that + * share a `startedAt` have an arbitrary order and can be skipped or duplicated + * across pages. + */ export function getOrderBy(order: 'desc' | 'asc' = 'desc') { return order === 'desc' - ? desc(workflowExecutionLogs.startedAt) - : sql`${workflowExecutionLogs.startedAt} ASC` + ? [desc(workflowExecutionLogs.startedAt), desc(workflowExecutionLogs.id)] + : [asc(workflowExecutionLogs.startedAt), asc(workflowExecutionLogs.id)] } diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index bd6a2185dd5..74f992fc207 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -124,7 +124,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const logs = await baseQuery .where(conditions) - .orderBy(orderBy) + .orderBy(...orderBy) .limit(params.limit + 1) const hasMore = logs.length > params.limit diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 51d69070f32..d27385305f0 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -157,36 +157,46 @@ export function createRateLimitResponse(result: RateLimitResult): NextResponse { } /** - * Verify that the API key is allowed to access the requested workspace. - * - * Enforces two policies: + * Structured workspace-access failure shared by the v1 and v2 API surfaces so + * each version can render the failure in its own response envelope. + */ +export interface WorkspaceAccessError { + status: number + code: 'FORBIDDEN' + message: string +} + +/** + * Core workspace-scope check (no response rendering). Enforces two policies: * - A workspace-scoped key may only target its own workspace. * - A personal key is rejected when the workspace has disabled personal API * keys (`allowPersonalApiKeys = false`), matching the workflow-execution * surface in `app/api/workflows/middleware.ts`. */ -export async function checkWorkspaceScope( +export async function resolveWorkspaceScope( rateLimit: RateLimitResult, requestedWorkspaceId: string -): Promise { +): Promise { if ( rateLimit.keyType === 'workspace' && rateLimit.workspaceId && rateLimit.workspaceId !== requestedWorkspaceId ) { - return NextResponse.json( - { error: 'API key is not authorized for this workspace' }, - { status: 403 } - ) + return { + status: 403, + code: 'FORBIDDEN', + message: 'API key is not authorized for this workspace', + } } if (rateLimit.keyType === 'personal') { const settings = await getWorkspaceBillingSettings(requestedWorkspaceId) if (!settings?.allowPersonalApiKeys) { - return NextResponse.json( - { error: 'Personal API keys are not allowed for this workspace' }, - { status: 403 } - ) + return { + status: 403, + code: 'FORBIDDEN', + message: 'Personal API keys are not allowed for this workspace', + } } } @@ -194,21 +204,46 @@ export async function checkWorkspaceScope( } /** - * Validates workspace-scoped API key bounds and the user's workspace permission. - * Returns null on success, NextResponse on failure. + * Core workspace-access check (scope + the user's workspace permission level), + * shared by v1 and v2. Returns a structured failure or null on success. */ -export async function validateWorkspaceAccess( +export async function resolveWorkspaceAccess( rateLimit: RateLimitResult, userId: string, workspaceId: string, level: PermissionType = 'read' -): Promise { - const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) +): Promise { + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (!permissionSatisfies(permission, level)) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + return { status: 403, code: 'FORBIDDEN', message: 'Access denied' } } return null } + +/** + * v1 wrapper: renders {@link resolveWorkspaceScope} as the v1 `{ error }` body. + */ +export async function checkWorkspaceScope( + rateLimit: RateLimitResult, + requestedWorkspaceId: string +): Promise { + const failure = await resolveWorkspaceScope(rateLimit, requestedWorkspaceId) + return failure ? NextResponse.json({ error: failure.message }, { status: failure.status }) : null +} + +/** + * v1 wrapper: renders {@link resolveWorkspaceAccess} as the v1 `{ error }` body. + * Returns null on success, NextResponse on failure. + */ +export async function validateWorkspaceAccess( + rateLimit: RateLimitResult, + userId: string, + workspaceId: string, + level: PermissionType = 'read' +): Promise { + const failure = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, level) + return failure ? NextResponse.json({ error: failure.message }, { status: failure.status }) : null +} diff --git a/apps/sim/app/api/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[id]/route.ts new file mode 100644 index 00000000000..d1fca3d0aa0 --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/[id]/route.ts @@ -0,0 +1,76 @@ +import { db } from '@sim/db' +import { auditLog } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { v2GetAuditLogContract } from '@/lib/api/contracts/v2/audit-logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' +import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2AuditLogDetailAPI') + +export const revalidate = 0 + +/** + * GET /api/v2/audit-logs/[id] + * + * Returns a single audit log entry scoped to the authenticated user's + * organization. Org-scoped (not workspace-scoped). Unlike v1, authorization + * (`checkRateLimit` → `validateEnterpriseAuditAccess`) runs BEFORE the untrusted + * param is parsed, fixing the v1 ordering inconsistency. The org-scope predicate + * is folded into the lookup so a non-org log reads as 404 (existence is not + * leaked). + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'audit-logs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const authResult = await resolveEnterpriseAuditAccess(userId) + if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) + + const parsed = await parseRequest(v2GetAuditLogContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { organizationId, orgMemberIds } = authResult.context + + const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) + const scopeCondition = buildOrgScopeCondition({ + organizationId, + orgWorkspaceIds, + orgMemberIds, + includeDeparted: true, + }) + + const [log] = await db + .select() + .from(auditLog) + .where(and(eq(auditLog.id, id), scopeCondition)) + .limit(1) + + if (!log) return v2Error('NOT_FOUND', 'Audit log not found') + + return v2Data(formatAuditLogEntry(log), { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Audit log detail fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts new file mode 100644 index 00000000000..c785ccaaede --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -0,0 +1,103 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' +import { + buildFilterConditions, + buildOrgScopeCondition, + getOrgWorkspaceIds, + queryAuditLogs, +} from '@/app/api/v1/audit-logs/query' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2AuditLogsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/audit-logs + * + * Lists audit logs scoped to the authenticated user's organization. Org-scoped + * (not workspace-scoped): `resolveWorkspaceAccess` is intentionally NOT used — + * access is gated by enterprise org admin/owner membership. Auth ordering + * matches v1: `checkRateLimit` → `validateEnterpriseAuditAccess` run before the + * untrusted query is parsed. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'audit-logs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const authResult = await resolveEnterpriseAuditAccess(userId) + if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) + + const { organizationId, orgMemberIds } = authResult.context + + const parsed = await parseRequest( + v2ListAuditLogsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + + if (params.actorId && !orgMemberIds.includes(params.actorId)) { + return v2Error('BAD_REQUEST', 'actorId is not a member of your organization') + } + + const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) + + if (params.workspaceId && !orgWorkspaceIds.includes(params.workspaceId)) { + return v2Error('BAD_REQUEST', 'workspaceId does not belong to your organization') + } + + const scopeCondition = buildOrgScopeCondition({ + organizationId, + orgWorkspaceIds, + orgMemberIds, + includeDeparted: params.includeDeparted, + }) + const filterConditions = buildFilterConditions({ + action: params.action, + resourceType: params.resourceType, + resourceId: params.resourceId, + workspaceId: params.workspaceId, + actorId: params.actorId, + startDate: params.startDate, + endDate: params.endDate, + }) + + const { data, nextCursor } = await queryAuditLogs( + [scopeCondition, ...filterConditions], + params.limit, + params.cursor + ) + + return v2CursorList(data.map(formatAuditLogEntry), nextCursor ?? null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Audit logs fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts new file mode 100644 index 00000000000..9d2e6d603b9 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -0,0 +1,124 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2DeleteFileContract, v2DownloadFileContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import type { V2ErrorCode } from '@/app/api/v2/lib/response' +import { + rateLimitHeaders, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FileRouteParams { + params: Promise<{ fileId: string }> +} + +/** + * GET /api/v2/files/[fileId] — Download file content (binary). + * + * The response carries no JSON envelope, so rate-limit state is surfaced via + * `X-RateLimit-*` headers. Errors still render the canonical v2 JSON error body. + * Lookups are workspace-scoped (IDOR-safe): a file in another workspace 404s. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DownloadFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const fileRecord = await getWorkspaceFile(workspaceId, fileId) + if (!fileRecord) return v2Error('NOT_FOUND', 'File not found') + + const buffer = await fetchWorkspaceFileBuffer(fileRecord) + + return new Response(new Uint8Array(buffer), { + status: 200, + headers: { + 'Content-Type': fileRecord.type || 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`, + 'Content-Length': String(buffer.length), + ...rateLimitHeaders(rateLimit), + }, + }) + } catch (error) { + logger.error('Error downloading file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * DELETE /api/v2/files/[fileId] — Archive (soft delete) a file. + * + * Delegates to the shared orchestration, which is workspace-scoped and records + * its own audit entry (the request is forwarded so that entry captures client + * IP / user agent). Orchestration `errorCode`s map to specific v2 codes rather + * than v1's blanket 500. + */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId, + fileIds: [fileId], + request, + }) + + if (!result.success) { + const code: V2ErrorCode = + result.errorCode === 'not_found' + ? 'NOT_FOUND' + : result.errorCode === 'validation' + ? 'BAD_REQUEST' + : result.errorCode === 'conflict' + ? 'CONFLICT' + : 'INTERNAL_ERROR' + return v2Error(code, result.error || 'Failed to delete file') + } + + logger.info(`Archived file ${fileId} from workspace ${workspaceId}`) + + return v2Data({ id: fileId, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error('Error deleting file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts new file mode 100644 index 00000000000..dbc6e982068 --- /dev/null +++ b/apps/sim/app/api/v2/files/route.ts @@ -0,0 +1,236 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + type V2File, + v2ListFilesContract, + v2UploadFileContract, +} from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + FileConflictError, + getWorkspaceFile, + listWorkspaceFiles, + uploadWorkspaceFile, +} from '@/lib/uploads/contexts/workspace' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FilesAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const MAX_FILE_SIZE = 100 * 1024 * 1024 +const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 + +interface FileCursor { + uploadedAt: string + id: string +} + +/** Stable keyset ordering: `uploadedAt` ascending, `id` ascending as the tiebreaker. */ +function compareFiles(a: V2File, b: V2File): number { + if (a.uploadedAt !== b.uploadedAt) return a.uploadedAt < b.uploadedAt ? -1 : 1 + if (a.id !== b.id) return a.id < b.id ? -1 : 1 + return 0 +} + +/** + * GET /api/v2/files — List files in a workspace with cursor pagination. + * + * The shared {@link listWorkspaceFiles} manager returns the full active set + * ordered by `uploadedAt`; v2 applies a bounded keyset slice over that result in + * the route. Pushing `limit`/`cursor` down into the manager query is a follow-up. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListFilesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, limit, cursor } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const files = await listWorkspaceFiles(workspaceId) + + const items: V2File[] = files + .map((f) => ({ + id: f.id, + name: f.name, + size: f.size, + type: f.type, + key: f.key, + uploadedBy: f.uploadedBy, + uploadedAt: + f.uploadedAt instanceof Date ? f.uploadedAt.toISOString() : String(f.uploadedAt), + })) + .sort(compareFiles) + + const decoded = cursor ? decodeCursor(cursor) : null + const afterCursor = decoded + ? items.filter( + (f) => + f.uploadedAt > decoded.uploadedAt || + (f.uploadedAt === decoded.uploadedAt && f.id > decoded.id) + ) + : items + + const hasMore = afterCursor.length > limit + const page = afterCursor.slice(0, limit) + const last = page.at(-1) + const nextCursor = + hasMore && last ? encodeCursor({ uploadedAt: last.uploadedAt, id: last.id }) : null + + return v2CursorList(page, nextCursor, { rateLimit }) + } catch (error) { + logger.error('Error listing files', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * POST /api/v2/files — Upload a file to a workspace. + * + * Authorization runs fully (rate limit → workspace write access) before the + * multipart body is buffered: the workspace is a contract-validated query param, + * so an unauthorized caller never streams a 100 MB body into memory. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2UploadFileContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + let formData: FormData + try { + formData = await readFormDataWithLimit(request, { + maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'workspace file upload body', + }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + return v2Error('BAD_REQUEST', 'Request body must be valid multipart form data') + } + + const rawFile = formData.get('file') + const file = rawFile instanceof File ? rawFile : null + if (!file) { + return v2Error('BAD_REQUEST', 'file form field is required') + } + + if (file.size > MAX_FILE_SIZE) { + return v2Error( + 'PAYLOAD_TOO_LARGE', + `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` + ) + } + + const buffer = await readFileToBufferWithLimit(file, { + maxBytes: MAX_FILE_SIZE, + label: 'workspace upload file', + }) + + const userFile = await uploadWorkspaceFile( + workspaceId, + userId, + buffer, + file.name, + file.type || 'application/octet-stream' + ) + + logger.info(`Uploaded file: ${file.name} to workspace ${workspaceId}`) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: userFile.id, + resourceName: file.name, + description: `Uploaded file "${file.name}" via API`, + metadata: { fileSize: file.size, fileType: file.type || 'application/octet-stream' }, + request, + }) + + const fileRecord = await getWorkspaceFile(workspaceId, userFile.id) + const uploadedAt = + fileRecord?.uploadedAt instanceof Date + ? fileRecord.uploadedAt.toISOString() + : fileRecord?.uploadedAt + ? String(fileRecord.uploadedAt) + : new Date().toISOString() + + const responseFile: V2File = { + id: userFile.id, + name: userFile.name, + size: userFile.size, + type: userFile.type, + key: userFile.key, + uploadedBy: userId, + uploadedAt, + } + + return v2Data(responseFile, { rateLimit, status: 201 }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + + const message = getErrorMessage(error, 'Failed to upload file') + if (error instanceof FileConflictError || message.includes('already exists')) { + return v2Error('CONFLICT', message) + } + if (message.includes('Storage limit') || message.includes('storage limit')) { + return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') + } + + logger.error('Error uploading file', { error: message }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts new file mode 100644 index 00000000000..235c80707eb --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -0,0 +1,209 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { document, knowledgeConnector } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, isNull } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { + type V2KnowledgeDocument, + v2DeleteKnowledgeDocumentContract, + v2GetKnowledgeDocumentContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deleteDocument } from '@/lib/knowledge/documents/service' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDocumentDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface DocumentDetailRouteParams { + params: Promise<{ id: string; documentId: string }> +} + +/** + * Resolves a knowledge base via the shared v1 ownership invariant + * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A + * `404` is always `NOT_FOUND`; a `403` is masked as `NOT_FOUND` on reads and + * surfaced as `FORBIDDEN` on writes. + */ +async function resolveKnowledgeBaseScoped( + id: string, + workspaceId: string, + userId: string, + rateLimit: RateLimitResult, + level: 'read' | 'write' +): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return level === 'read' + ? v2Error('NOT_FOUND', 'Knowledge base not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** GET /api/v2/knowledge/[id]/documents/[documentId] — Get document details. */ +export const GET = withRouteHandler( + async (request: NextRequest, context: DocumentDetailRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetKnowledgeDocumentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id: knowledgeBaseId, documentId } = parsed.data.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + const docs = await db + .select({ + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + filename: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + processingStatus: document.processingStatus, + processingError: document.processingError, + processingStartedAt: document.processingStartedAt, + processingCompletedAt: document.processingCompletedAt, + chunkCount: document.chunkCount, + tokenCount: document.tokenCount, + characterCount: document.characterCount, + enabled: document.enabled, + uploadedAt: document.uploadedAt, + connectorId: document.connectorId, + connectorType: knowledgeConnector.connectorType, + sourceUrl: document.sourceUrl, + }) + .from(document) + .leftJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id)) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(1) + + const doc = docs[0] + if (!doc) return v2Error('NOT_FOUND', 'Document not found') + + const documentDetail: V2KnowledgeDocument = { + id: doc.id, + knowledgeBaseId: doc.knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus as V2KnowledgeDocument['processingStatus'], + processingError: doc.processingError, + processingStartedAt: serializeDate(doc.processingStartedAt), + processingCompletedAt: serializeDate(doc.processingCompletedAt), + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + connectorId: doc.connectorId, + connectorType: doc.connectorType ?? null, + sourceUrl: doc.sourceUrl, + createdAt: serializeDate(doc.uploadedAt), + } + + return v2Data({ document: documentDetail }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error getting document`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +/** DELETE /api/v2/knowledge/[id]/documents/[documentId] — Delete a document. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: DocumentDetailRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteKnowledgeDocumentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id: knowledgeBaseId, documentId } = parsed.data.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + const docs = await db + .select({ id: document.id, filename: document.filename }) + .from(document) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(1) + + const doc = docs[0] + if (!doc) return v2Error('NOT_FOUND', 'Document not found') + + await deleteDocument(documentId, requestId) + + recordAudit({ + workspaceId: parsed.data.query.workspaceId, + actorId: userId, + action: AuditAction.DOCUMENT_DELETED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: documentId, + resourceName: doc.filename, + description: `Deleted document "${doc.filename}" from knowledge base via API`, + metadata: { knowledgeBaseId }, + request, + }) + + return v2Data({ id: documentId, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting document`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server 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 new file mode 100644 index 00000000000..9f2c7b5367a --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -0,0 +1,306 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + type V2KnowledgeDocumentSummary, + v2ListKnowledgeDocumentsContract, + v2UploadKnowledgeDocumentContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { generateRequestId } from '@/lib/core/utils/request' +import { + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createSingleDocument, + type DocumentData, + getDocuments, + processDocumentsWithQueue, +} from '@/lib/knowledge/documents/service' +import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { validateFileType } from '@/lib/uploads/utils/validation' +import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDocumentsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const MAX_FILE_SIZE = 100 * 1024 * 1024 +const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 + +interface DocumentsRouteParams { + params: Promise<{ id: string }> +} + +/** + * Resolves a knowledge base via the shared v1 ownership invariant + * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A + * `404` is always `NOT_FOUND`; a `403` is masked as `NOT_FOUND` on reads and + * surfaced as `FORBIDDEN` on writes. + */ +async function resolveKnowledgeBaseScoped( + id: string, + workspaceId: string, + userId: string, + rateLimit: RateLimitResult, + level: 'read' | 'write' +): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return level === 'read' + ? v2Error('NOT_FOUND', 'Knowledge base not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** GET /api/v2/knowledge/[id]/documents — List documents in a knowledge base. */ +export const GET = withRouteHandler(async (request: NextRequest, context: DocumentsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2ListKnowledgeDocumentsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { workspaceId, limit, cursor, search, enabledFilter, sortBy, sortOrder } = + parsed.data.query + const { id: knowledgeBaseId } = parsed.data.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + // Opaque cursor encodes the underlying offset (upgradeable to keyset later). + const offset = cursor ? (decodeCursor<{ offset: number }>(cursor)?.offset ?? 0) : 0 + + const documentsResult = await getDocuments( + knowledgeBaseId, + { + enabledFilter: enabledFilter === 'all' ? undefined : enabledFilter, + search, + limit, + offset, + sortBy: sortBy as DocumentSortField, + sortOrder: sortOrder as SortOrder, + }, + requestId + ) + + const documents: V2KnowledgeDocumentSummary[] = documentsResult.documents.map((doc) => ({ + id: doc.id, + knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus, + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + createdAt: serializeDate(doc.uploadedAt), + })) + + const nextCursor = documentsResult.pagination.hasMore + ? encodeCursor({ offset: offset + limit }) + : null + return v2CursorList(documents, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing documents`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * POST /api/v2/knowledge/[id]/documents — Upload a document to a knowledge base. + * + * Authorization runs fully before the multipart body is buffered: the workspace + * is a contract-validated query param (not a form field as in v1), so an + * unauthorized caller never streams a file into memory. Order: rate limit → + * KB ownership (write) → usage gate → buffered multipart read. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: DocumentsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UploadKnowledgeDocumentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id: knowledgeBaseId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + // Fast usage gate before the storage write + indexing (the async backstop + // in processDocumentAsync still covers non-HTTP paths). + const usage = await checkActorUsageLimits(userId, workspaceId) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + + let formData: FormData + try { + formData = await readFormDataWithLimit(request, { + maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'knowledge document upload body', + }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + return v2Error('BAD_REQUEST', 'Request body must be valid multipart form data') + } + + const rawFile = formData.get('file') + const file = rawFile instanceof File ? rawFile : null + if (!file) { + return v2Error('BAD_REQUEST', 'file form field is required') + } + + if (file.size > MAX_FILE_SIZE) { + return v2Error( + 'PAYLOAD_TOO_LARGE', + `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` + ) + } + + const fileTypeError = validateFileType(file.name, file.type || '') + if (fileTypeError) { + return v2Error('UNSUPPORTED_MEDIA_TYPE', fileTypeError.message) + } + + const buffer = await readFileToBufferWithLimit(file, { + maxBytes: MAX_FILE_SIZE, + label: 'knowledge document file', + }) + const contentType = file.type || 'application/octet-stream' + + const uploadedFile = await uploadWorkspaceFile( + workspaceId, + userId, + buffer, + file.name, + contentType + ) + + const newDocument = await createSingleDocument( + { + filename: file.name, + fileUrl: uploadedFile.url, + fileSize: file.size, + mimeType: contentType, + }, + knowledgeBaseId, + requestId, + userId + ) + + const documentData: DocumentData = { + documentId: newDocument.id, + filename: file.name, + fileUrl: uploadedFile.url, + fileSize: file.size, + mimeType: contentType, + } + + processDocumentsWithQueue([documentData], knowledgeBaseId, {}, requestId).catch(() => { + // Processing errors are logged internally by the queue. + }) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: newDocument.id, + resourceName: file.name, + description: `Uploaded document "${file.name}" to knowledge base via API`, + metadata: { knowledgeBaseId, fileSize: file.size, mimeType: contentType }, + request, + }) + + const document: V2KnowledgeDocumentSummary = { + id: newDocument.id, + knowledgeBaseId, + filename: newDocument.filename, + fileSize: newDocument.fileSize, + mimeType: newDocument.mimeType, + processingStatus: 'pending', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: newDocument.enabled, + createdAt: serializeDate(newDocument.uploadedAt), + } + + return v2Data({ document }, { rateLimit, status: 201 }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + + if (error instanceof Error) { + if ( + error.message.includes('Storage limit exceeded') || + error.message.includes('storage limit') + ) { + return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') + } + if (error.message.includes('already exists')) { + return v2Error('CONFLICT', 'Resource already exists') + } + } + + logger.error(`[${requestId}] Error uploading document`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts new file mode 100644 index 00000000000..79bb1b4b86c --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -0,0 +1,193 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + v2DeleteKnowledgeBaseContract, + v2GetKnowledgeBaseContract, + v2UpdateKnowledgeBaseContract, +} from '@/lib/api/contracts/v2/knowledge' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { formatKnowledgeBase, resolveKnowledgeBase } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface KnowledgeRouteParams { + params: Promise<{ id: string }> +} + +/** + * Resolves a knowledge base via the shared v1 ownership invariant + * ({@link resolveKnowledgeBase}: workspace access + KB-belongs-to-workspace) and + * renders any failure in the v2 envelope. A `404` (missing KB or workspace + * mismatch) is always `NOT_FOUND`; a `403` (no workspace access) is masked as + * `NOT_FOUND` on reads so cross-workspace KB existence never leaks, and surfaced + * as `FORBIDDEN` on writes. + */ +async function resolveKnowledgeBaseScoped( + id: string, + workspaceId: string, + userId: string, + rateLimit: RateLimitResult, + level: 'read' | 'write' +): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return level === 'read' + ? v2Error('NOT_FOUND', 'Knowledge base not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** GET /api/v2/knowledge/[id] — Get knowledge base details. */ +export const GET = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const result = await resolveKnowledgeBaseScoped( + id, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + return v2Data({ knowledgeBase: formatKnowledgeBase(result.kb) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error getting knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PUT /api/v2/knowledge/[id] — Update a knowledge base. */ +export const PUT = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UpdateKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, name, description, chunkingConfig } = parsed.data.body + + const result = await resolveKnowledgeBaseScoped(id, workspaceId, userId, rateLimit, 'write') + if (result instanceof NextResponse) return result + + const updates: { + name?: string + description?: string + chunkingConfig?: { maxSize: number; minSize: number; overlap: number } + } = {} + if (name !== undefined) updates.name = name + if (description !== undefined) updates.description = description + if (chunkingConfig !== undefined) updates.chunkingConfig = chunkingConfig + + const updatedKb = await updateKnowledgeBase(id, updates, requestId) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: id, + resourceName: updatedKb.name, + description: `Updated knowledge base "${updatedKb.name}" via API`, + metadata: { updatedFields: Object.keys(updates) }, + request, + }) + + return v2Data({ knowledgeBase: formatKnowledgeBase(updatedKb) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + if (error.message.includes('does not have permission')) { + return v2Error('FORBIDDEN', 'Access denied') + } + if (error.message.includes('already exists')) { + return v2Error('CONFLICT', 'Resource already exists') + } + } + + logger.error(`[${requestId}] Error updating knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/knowledge/[id] — Delete a knowledge base. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: KnowledgeRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const result = await resolveKnowledgeBaseScoped( + id, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + await deleteKnowledgeBase(id, requestId) + + recordAudit({ + workspaceId: parsed.data.query.workspaceId, + actorId: userId, + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: id, + resourceName: result.kb.name, + description: `Deleted knowledge base "${result.kb.name}" via API`, + request, + }) + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts new file mode 100644 index 00000000000..d1fb7d5b10d --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -0,0 +1,140 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateKnowledgeBaseContract, + v2ListKnowledgeBasesContract, +} from '@/lib/api/contracts/v2/knowledge' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' +import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' +import { formatKnowledgeBase } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/knowledge — List knowledge bases in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListKnowledgeBasesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const knowledgeBases = await getKnowledgeBases(userId, workspaceId) + const items = knowledgeBases.map(formatKnowledgeBase) + + // `getKnowledgeBases` returns the full bounded workspace set → single page. + return v2CursorList(items, null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing knowledge bases`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/knowledge — Create a new knowledge base. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2CreateKnowledgeBaseContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, name, description, chunkingConfig } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const kb = await createKnowledgeBase( + { + name, + description, + workspaceId, + userId, + embeddingModel: getConfiguredEmbeddingModel(), + embeddingDimension: EMBEDDING_DIMENSIONS, + chunkingConfig: chunkingConfig ?? { maxSize: 1024, minSize: 100, overlap: 200 }, + }, + requestId + ) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.KNOWLEDGE_BASE_CREATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: kb.id, + resourceName: kb.name, + description: `Created knowledge base "${kb.name}" via API`, + metadata: { chunkingConfig }, + request, + }) + + return v2Data({ knowledgeBase: formatKnowledgeBase(kb) }, { rateLimit, status: 201 }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + if (error.message.includes('does not have permission')) { + return v2Error('FORBIDDEN', 'Access denied') + } + if ( + error.message.includes('Storage limit exceeded') || + error.message.includes('storage limit') + ) { + return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') + } + if (error.message.includes('already exists')) { + return v2Error('CONFLICT', 'Resource already exists') + } + } + + logger.error(`[${requestId}] Error creating knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts new file mode 100644 index 00000000000..8f432bf467e --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -0,0 +1,299 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + type V2KnowledgeSearchResult, + v2SearchKnowledgeContract, +} from '@/lib/api/contracts/v2/knowledge' +import { isZodError, parseRequest } from '@/lib/api/server' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' +import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' +import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' +import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' +import type { StructuredFilter } from '@/lib/knowledge/types' +import { + generateSearchEmbedding, + getDocumentMetadataByIds, + getQueryStrategy, + handleTagAndVectorSearch, + handleTagOnlySearch, + handleVectorOnlySearch, + type SearchResult, +} from '@/app/api/knowledge/search/utils' +import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeSearchAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** POST /api/v2/knowledge/search — Vector / tag search across knowledge bases. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-search') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2SearchKnowledgeContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, topK, query, tagFilters } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + // A query incurs hosted embedding (+ optional rerank) cost — gate the actor's + // usage and frozen status before spending. Tag-only search is free, so skip it. + if (query && query.trim().length > 0) { + const usage = await checkActorUsageLimits(userId, workspaceId) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + } + + const knowledgeBaseIds = Array.isArray(parsed.data.body.knowledgeBaseIds) + ? parsed.data.body.knowledgeBaseIds + : [parsed.data.body.knowledgeBaseIds] + + const accessChecks = await Promise.all( + knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId)) + ) + const accessibleKbs = accessChecks + .filter( + (ac): ac is KnowledgeBaseAccessResult => + ac.hasAccess === true && ac.knowledgeBase.workspaceId === workspaceId + ) + .map((ac) => ac.knowledgeBase) + const accessibleKbIds = accessibleKbs.map((kb) => kb.id) + + if (accessibleKbIds.length === 0) { + return v2Error('NOT_FOUND', 'Knowledge base not found or access denied') + } + + const inaccessibleKbIds = knowledgeBaseIds.filter((id) => !accessibleKbIds.includes(id)) + if (inaccessibleKbIds.length > 0) { + return v2Error( + 'NOT_FOUND', + `Knowledge bases not found or access denied: ${inaccessibleKbIds.join(', ')}` + ) + } + + let structuredFilters: StructuredFilter[] = [] + const tagDefsCache = new Map>>() + + if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 1) { + return v2Error( + 'BAD_REQUEST', + 'Tag filters are only supported when searching a single knowledge base' + ) + } + + if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 0) { + const kbId = accessibleKbIds[0] + const tagDefs = await getDocumentTagDefinitions(kbId) + tagDefsCache.set(kbId, tagDefs) + + const displayNameToTagDef: Record = {} + tagDefs.forEach((def) => { + displayNameToTagDef[def.displayName] = { + tagSlot: def.tagSlot, + fieldType: def.fieldType, + } + }) + + const undefinedTags: string[] = [] + const typeErrors: string[] = [] + + for (const filter of tagFilters) { + const tagDef = displayNameToTagDef[filter.tagName] + if (!tagDef) { + undefinedTags.push(filter.tagName) + continue + } + const validationError = validateTagValue( + filter.tagName, + String(filter.value), + tagDef.fieldType + ) + if (validationError) { + typeErrors.push(validationError) + } + } + + if (undefinedTags.length > 0 || typeErrors.length > 0) { + const errorParts: string[] = [] + if (undefinedTags.length > 0) { + errorParts.push(buildUndefinedTagsError(undefinedTags)) + } + if (typeErrors.length > 0) { + errorParts.push(...typeErrors) + } + return v2Error('BAD_REQUEST', errorParts.join('\n')) + } + + structuredFilters = tagFilters.map((filter) => { + const tagDef = displayNameToTagDef[filter.tagName]! + return { + tagSlot: tagDef.tagSlot, + fieldType: tagDef.fieldType, + operator: filter.operator, + value: filter.value, + valueTo: filter.valueTo, + } + }) + } + + const hasQuery = Boolean(query && query.trim().length > 0) + const hasFilters = structuredFilters.length > 0 + + const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel))) + if (hasQuery && embeddingModels.length > 1) { + return v2Error( + 'BAD_REQUEST', + 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.' + ) + } + const queryEmbeddingModel = embeddingModels[0] + + let results: SearchResult[] + let queryEmbeddingIsBYOK: boolean | null = null + + if (!hasQuery && hasFilters) { + results = await handleTagOnlySearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + structuredFilters, + }) + } else if (hasQuery && hasFilters) { + const strategy = getQueryStrategy(accessibleKbIds.length, topK) + const queryEmbeddingResult = await generateSearchEmbedding( + query!, + queryEmbeddingModel, + workspaceId + ) + queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK + const queryVector = JSON.stringify(queryEmbeddingResult.embedding) + results = await handleTagAndVectorSearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + structuredFilters, + queryVector, + distanceThreshold: strategy.distanceThreshold, + }) + } else if (hasQuery) { + const strategy = getQueryStrategy(accessibleKbIds.length, topK) + const queryEmbeddingResult = await generateSearchEmbedding( + query!, + queryEmbeddingModel, + workspaceId + ) + queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK + const queryVector = JSON.stringify(queryEmbeddingResult.embedding) + results = await handleVectorOnlySearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + queryVector, + distanceThreshold: strategy.distanceThreshold, + }) + } else { + return v2Error('BAD_REQUEST', 'Either query or tagFilters must be provided') + } + + if (queryEmbeddingIsBYOK !== null) { + await recordSearchEmbeddingUsage({ + userId, + workspaceId, + embeddingModel: queryEmbeddingModel, + query: query!, + isBYOK: queryEmbeddingIsBYOK, + sourceReference: `v2-kb-search:${requestId}`, + }) + } + + const tagDefsResults = await Promise.all( + accessibleKbIds.map(async (kbId) => { + try { + const tagDefs = tagDefsCache.get(kbId) ?? (await getDocumentTagDefinitions(kbId)) + const map: Record = {} + tagDefs.forEach((def) => { + map[def.tagSlot] = def.displayName + }) + return { kbId, map } + } catch { + return { kbId, map: {} as Record } + } + }) + ) + const tagDefinitionsMap: Record> = {} + tagDefsResults.forEach(({ kbId, map }) => { + tagDefinitionsMap[kbId] = map + }) + + const documentIds = results.map((r) => r.documentId) + const documentMetadataMap = await getDocumentMetadataByIds(documentIds) + + const searchResults: V2KnowledgeSearchResult[] = results.map((result) => { + const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} + const metadata: Record = {} + + ALL_TAG_SLOTS.forEach((slot) => { + const tagValue = result[slot as keyof SearchResult] + if (tagValue !== null && tagValue !== undefined) { + const displayName = kbTagMap[slot] || slot + metadata[displayName] = tagValue + } + }) + + const docMeta = documentMetadataMap[result.documentId] + return { + documentId: result.documentId, + documentName: docMeta?.filename ?? null, + sourceUrl: docMeta?.sourceUrl ?? null, + content: result.content, + chunkIndex: result.chunkIndex, + metadata, + similarity: hasQuery ? 1 - result.distance : 1, + } + }) + + return v2Data( + { + results: searchResults, + query: query || '', + knowledgeBaseIds: accessibleKbIds, + topK, + totalResults: results.length, + }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + logger.error(`[${requestId}] Knowledge search error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts new file mode 100644 index 00000000000..e6c5e3dc5d2 --- /dev/null +++ b/apps/sim/app/api/v2/lib/response.ts @@ -0,0 +1,144 @@ +import { NextResponse } from 'next/server' +import type { ZodError } from 'zod' +import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' +import type { RateLimitResult, WorkspaceAccessError } from '@/app/api/v1/middleware' + +/** + * Runtime response helpers for the v2 API surface. Every v2 route renders its + * output through these so the envelope, error shape, and rate-limit headers stay + * identical across the whole surface. v2 routes reuse the v1 auth/rate-limit + * middleware and the platform domain services — these helpers only standardize + * the HTTP envelope. + */ + +export type V2ErrorCode = + | 'BAD_REQUEST' + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'CONFLICT' + | 'PAYLOAD_TOO_LARGE' + | 'UNSUPPORTED_MEDIA_TYPE' + | 'USAGE_LIMIT_EXCEEDED' + | 'LOCKED' + | 'RATE_LIMITED' + | 'INTERNAL_ERROR' + +const STATUS_BY_CODE: Record = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + USAGE_LIMIT_EXCEEDED: 402, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + PAYLOAD_TOO_LARGE: 413, + UNSUPPORTED_MEDIA_TYPE: 415, + LOCKED: 423, + RATE_LIMITED: 429, + INTERNAL_ERROR: 500, +} + +type RateLimitHeaderSource = Pick + +export function rateLimitHeaders(rateLimit?: RateLimitHeaderSource): Record { + if (!rateLimit) return {} + return { + 'X-RateLimit-Limit': rateLimit.limit.toString(), + 'X-RateLimit-Remaining': rateLimit.remaining.toString(), + 'X-RateLimit-Reset': rateLimit.resetAt.toISOString(), + } +} + +interface V2SuccessOptions { + rateLimit?: RateLimitHeaderSource + status?: number + headers?: Record +} + +function successHeaders(options: V2SuccessOptions): Record { + return { ...rateLimitHeaders(options.rateLimit), ...options.headers } +} + +/** `{ data }` (+ rate-limit headers). */ +export function v2Data(data: T, options: V2SuccessOptions = {}): NextResponse { + return NextResponse.json( + { data }, + { status: options.status ?? 200, headers: successHeaders(options) } + ) +} + +/** `{ 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 + headers?: Record +} + +/** `{ error: { code, message, details? } }`. */ +export function v2Error( + code: V2ErrorCode, + message: string, + options: V2ErrorOptions = {} +): NextResponse { + const error: { code: V2ErrorCode; message: string; details?: unknown } = { code, message } + if (options.details !== undefined) error.details = options.details + return NextResponse.json( + { error }, + { status: options.status ?? STATUS_BY_CODE[code], headers: options.headers } + ) +} + +/** Render a contract `ZodError` as the v2 error envelope. */ +export function v2ValidationError(error: ZodError): NextResponse { + return v2Error('BAD_REQUEST', getValidationErrorMessage(error, 'Invalid request'), { + details: serializeZodIssues(error), + }) +} + +/** 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 + * `Retry-After`. + */ +export function v2RateLimitError(rateLimit: RateLimitResult): NextResponse { + const headers = rateLimitHeaders(rateLimit) + if (rateLimit.error) { + return v2Error('UNAUTHORIZED', rateLimit.error, { headers }) + } + const retryAfterSeconds = rateLimit.retryAfterMs + ? Math.ceil(rateLimit.retryAfterMs / 1000) + : Math.ceil((rateLimit.resetAt.getTime() - Date.now()) / 1000) + return v2Error('RATE_LIMITED', 'API rate limit exceeded', { + headers: { ...headers, 'Retry-After': retryAfterSeconds.toString() }, + details: { retryAfter: rateLimit.resetAt.toISOString() }, + }) +} + +/** Opaque base64-JSON keyset cursor codec shared by all v2 cursor lists. */ +export function encodeCursor(data: Record): string { + return Buffer.from(JSON.stringify(data)).toString('base64') +} + +export function decodeCursor>(cursor: string): T | null { + try { + return JSON.parse(Buffer.from(cursor, 'base64').toString()) as T + } catch { + return null + } +} diff --git a/apps/sim/app/api/v2/logs/[id]/route.ts b/apps/sim/app/api/v2/logs/[id]/route.ts new file mode 100644 index 00000000000..698e59f10ed --- /dev/null +++ b/apps/sim/app/api/v2/logs/[id]/route.ts @@ -0,0 +1,109 @@ +import { db } from '@sim/db' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2LogDetail, v2GetLogContract } from '@/lib/api/contracts/v2/logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2LogDetailAPI') + +export const revalidate = 0 + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'logs-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetLogContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionId: workflowExecutionLogs.executionId, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + costTotal: workflowExecutionLogs.costTotal, + files: workflowExecutionLogs.files, + createdAt: workflowExecutionLogs.createdAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(eq(workflowExecutionLogs.id, id)) + .limit(1) + + const log = rows[0] + if (!log) return v2Error('NOT_FOUND', 'Log not found') + + // Convert an authorization failure into 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, log.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Log not found') + + const executionData = await materializeExecutionData( + log.executionData as Record | null, + { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } + ) + + const detail: V2LogDetail = { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + level: log.level, + trigger: log.trigger, + startedAt: log.startedAt.toISOString(), + endedAt: log.endedAt ? log.endedAt.toISOString() : null, + totalDurationMs: log.totalDurationMs, + files: (log.files as unknown[] | null) ?? null, + workflow: { + id: log.workflowId, + name: log.workflowName || 'Deleted Workflow', + description: log.workflowDescription, + folderId: log.workflowFolderId, + userId: log.workflowUserId, + workspaceId: log.workflowWorkspaceId, + createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null, + updatedAt: log.workflowUpdatedAt ? log.workflowUpdatedAt.toISOString() : null, + deleted: !log.workflowName, + }, + executionData, + cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, + createdAt: log.createdAt.toISOString(), + } + + return v2Data(detail, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Log detail fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts new file mode 100644 index 00000000000..da936577def --- /dev/null +++ b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts @@ -0,0 +1,74 @@ +import { db } from '@sim/db' +import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2Execution, v2GetExecutionContract } from '@/lib/api/contracts/v2/logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2ExecutionAPI') + +export const revalidate = 0 + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => { + try { + const rateLimit = await checkRateLimit(request, 'logs-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetExecutionContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { executionId } = parsed.data.params + + const rows = await db + .select() + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.executionId, executionId)) + .limit(1) + + if (rows.length === 0) return v2Error('NOT_FOUND', 'Workflow execution not found') + + const workflowLog = rows[0] + + // Convert an authorization failure into 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowLog.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow execution not found') + + const [snapshot] = await db + .select() + .from(workflowExecutionSnapshots) + .where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId)) + .limit(1) + + if (!snapshot) return v2Error('NOT_FOUND', 'Workflow state snapshot not found') + + const execution: V2Execution = { + executionId, + workflowId: workflowLog.workflowId, + workflowState: snapshot.stateData, + executionMetadata: { + trigger: workflowLog.trigger, + startedAt: workflowLog.startedAt.toISOString(), + endedAt: workflowLog.endedAt ? workflowLog.endedAt.toISOString() : null, + totalDurationMs: workflowLog.totalDurationMs, + cost: workflowLog.costTotal != null ? { total: Number(workflowLog.costTotal) } : null, + }, + } + + return v2Data(execution, { rateLimit }) + } catch (error) { + logger.error('Error fetching execution data', { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts new file mode 100644 index 00000000000..a4cc3372d37 --- /dev/null +++ b/apps/sim/app/api/v2/logs/route.ts @@ -0,0 +1,168 @@ +import { db } from '@sim/db' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { eq, sql } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2LogListItem, v2ListLogsContract } from '@/lib/api/contracts/v2/logs' +import { parseRequest } from '@/lib/api/server' +import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2LogsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'logs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListLogsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const filters = { + workspaceId: params.workspaceId, + workflowIds: params.workflowIds?.split(',').filter(Boolean), + folderIds: params.folderIds?.split(',').filter(Boolean), + triggers: params.triggers?.split(',').filter(Boolean), + level: params.level, + startDate: params.startDate ? new Date(params.startDate) : undefined, + endDate: params.endDate ? new Date(params.endDate) : undefined, + executionId: params.executionId, + minDurationMs: params.minDurationMs, + maxDurationMs: params.maxDurationMs, + minCost: params.minCost, + maxCost: params.maxCost, + model: params.model, + cursor: params.cursor + ? decodeCursor<{ startedAt: string; id: string }>(params.cursor) || undefined + : undefined, + order: params.order, + } + + const conditions = buildLogFilters(filters) + const orderBy = getOrderBy(params.order) + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionId: workflowExecutionLogs.executionId, + deploymentVersionId: workflowExecutionLogs.deploymentVersionId, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + costTotal: workflowExecutionLogs.costTotal, + files: workflowExecutionLogs.files, + executionData: params.details === 'full' ? workflowExecutionLogs.executionData : sql`null`, + workflowName: workflow.name, + workflowDescription: workflow.description, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(conditions) + .orderBy(...orderBy) + .limit(params.limit + 1) + + const hasMore = rows.length > params.limit + const data = rows.slice(0, params.limit) + + let nextCursor: string | null = null + if (hasMore && data.length > 0) { + const lastLog = data[data.length - 1] + nextCursor = encodeCursor({ startedAt: lastLog.startedAt.toISOString(), id: lastLog.id }) + } + + type LogRow = (typeof data)[number] + const buildItem = (log: LogRow): V2LogListItem => { + const item: V2LogListItem = { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + deploymentVersionId: log.deploymentVersionId, + level: log.level, + trigger: log.trigger, + startedAt: log.startedAt.toISOString(), + endedAt: log.endedAt ? log.endedAt.toISOString() : null, + totalDurationMs: log.totalDurationMs, + cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, + files: (log.files as unknown[] | null) ?? null, + } + if (params.details === 'full') { + item.workflow = { + id: log.workflowId, + name: log.workflowName || 'Deleted Workflow', + description: log.workflowDescription, + deleted: !log.workflowName, + } + } + return item + } + + const needsMaterialize = + params.details === 'full' && (params.includeFinalOutput || params.includeTraceSpans) + + const formattedLogs = needsMaterialize + ? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => { + const item = buildItem(log) + if (log.executionData) { + const execData = (await materializeExecutionData( + log.executionData as Record | null, + { + workspaceId: log.workspaceId, + workflowId: log.workflowId, + executionId: log.executionId, + } + )) as Record + if (params.includeFinalOutput && execData.finalOutput) { + item.finalOutput = execData.finalOutput + } + if (params.includeTraceSpans && execData.traceSpans) { + item.traceSpans = execData.traceSpans + } + } + return item + }) + : data.map(buildItem) + + return v2CursorList(formattedLogs, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Logs fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts new file mode 100644 index 00000000000..48b6726eb35 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts @@ -0,0 +1,269 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2AddTableColumnContract, + v2DeleteTableColumnContract, + v2UpdateTableColumnContract, +} from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + addTableColumn, + deleteColumn, + renameColumn, + updateColumnConstraints, + updateColumnType, +} from '@/lib/table' +import { checkAccess, normalizeColumn } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableColumnsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface ColumnsRouteParams { + params: Promise<{ tableId: string }> +} + +/** POST /api/v2/tables/[tableId]/columns — Add a column to the table schema. */ +export const POST = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-columns') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2AddTableColumnContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const updatedTable = await addTableColumn(tableId, validated.column, requestId) + + recordAudit({ + workspaceId: validated.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Added column "${validated.column.name}" to table "${table.name}"`, + metadata: { column: validated.column }, + request, + }) + + return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + if (error.message.includes('already exists') || error.message.includes('maximum column')) { + return v2Error('BAD_REQUEST', error.message) + } + if (error.message === 'Table not found') { + return v2Error('NOT_FOUND', error.message) + } + } + + logger.error(`[${requestId}] Error adding column to table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/tables/[tableId]/columns — Update a column (rename, type change, constraints). */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-columns') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UpdateTableColumnContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const { updates } = validated + let updatedTable = null + + if (updates.name) { + updatedTable = await renameColumn( + { tableId, oldName: validated.columnName, newName: updates.name }, + requestId + ) + } + + if (updates.type) { + updatedTable = await updateColumnType( + { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, + requestId + ) + } + + if (updates.required !== undefined || updates.unique !== undefined) { + updatedTable = await updateColumnConstraints( + { + tableId, + columnName: updates.name ?? validated.columnName, + ...(updates.required !== undefined ? { required: updates.required } : {}), + ...(updates.unique !== undefined ? { unique: updates.unique } : {}), + }, + requestId + ) + } + + if (!updatedTable) { + return v2Error('BAD_REQUEST', 'No updates specified') + } + + recordAudit({ + workspaceId: validated.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Updated column "${validated.columnName}" in table "${table.name}"`, + metadata: { columnName: validated.columnName, updates }, + request, + }) + + return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + const msg = error.message + if (msg.includes('not found') || msg.includes('Table not found')) { + return v2Error('NOT_FOUND', msg) + } + if ( + msg.includes('already exists') || + msg.includes('Cannot delete the last column') || + msg.includes('Cannot set column') || + msg.includes('Invalid column') || + msg.includes('exceeds maximum') || + msg.includes('incompatible') || + msg.includes('duplicate') + ) { + return v2Error('BAD_REQUEST', msg) + } + } + + logger.error(`[${requestId}] Error updating column in table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/tables/[tableId]/columns — Delete a column from the table schema. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: ColumnsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-columns') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteTableColumnContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const updatedTable = await deleteColumn( + { tableId, columnName: validated.columnName }, + requestId + ) + + recordAudit({ + workspaceId: validated.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Deleted column "${validated.columnName}" from table "${table.name}"`, + metadata: { columnName: validated.columnName }, + request, + }) + + return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + if (error.message.includes('not found') || error.message === 'Table not found') { + return v2Error('NOT_FOUND', error.message) + } + if (error.message.includes('Cannot delete') || error.message.includes('last column')) { + return v2Error('BAD_REQUEST', error.message) + } + } + + logger.error(`[${requestId}] Error deleting column from table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts new file mode 100644 index 00000000000..202ad6a7900 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -0,0 +1,114 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2DeleteTableContract, v2GetTableContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deleteTable } from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiTable, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** GET /api/v2/tables/[tableId] — Get table details. */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetTableContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok) return v2Error('NOT_FOUND', 'Table not found') + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + return v2Data({ table: toApiTable(result.table) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error getting table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/tables/[tableId] — Archive a table. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteTableContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + await deleteTable(tableId, requestId) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: result.table.name, + description: `Archived table "${result.table.name}"`, + request, + }) + + return v2Data({ id: tableId }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) 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 new file mode 100644 index 00000000000..59861c6c0ea --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts @@ -0,0 +1,226 @@ +import { db } from '@sim/db' +import { userTableRows } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { + v2DeleteTableRowContract, + v2GetTableRowContract, + v2UpdateTableRowContract, +} from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { RowData, TableSchema } from '@/lib/table' +import { buildIdByName, buildNameById, rowDataNameToId, updateRow } from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiRow, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRowAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RowRouteParams { + params: Promise<{ tableId: string; rowId: string }> +} + +/** GET /api/v2/tables/[tableId]/rows/[rowId] — Get a single row. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-row-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetTableRowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, rowId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok) return v2Error('NOT_FOUND', 'Table not found') + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const [row] = await db + .select({ + id: userTableRows.id, + data: userTableRows.data, + position: userTableRows.position, + createdAt: userTableRows.createdAt, + updatedAt: userTableRows.updatedAt, + }) + .from(userTableRows) + .where( + and( + eq(userTableRows.id, rowId), + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId) + ) + ) + .limit(1) + + if (!row) return v2Error('NOT_FOUND', 'Row not found') + + const nameById = buildNameById(result.table.schema as TableSchema) + return v2Data( + { + row: toApiRow( + { + id: row.id, + data: row.data as RowData, + position: row.position, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }, + nameById + ), + }, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error getting row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/tables/[tableId]/rows/[rowId] — Partial update a single row. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-row-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UpdateTableRowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, rowId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const nameById = buildNameById(table.schema as TableSchema) + const updatedRow = await updateRow( + { + tableId, + rowId, + data: rowDataNameToId(validated.data as RowData, idByName), + workspaceId: validated.workspaceId, + actorUserId: userId, + }, + table, + requestId + ) + // No `cancellationGuard` is passed, so `updateRow` can't return null here. + // Defensive narrowing for TypeScript. + if (!updatedRow) return v2Error('NOT_FOUND', 'Row not found') + + return v2Data({ row: toApiRow(updatedRow, nameById) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const errorMessage = toError(error).message + if (errorMessage === 'Row not found') return v2Error('NOT_FOUND', errorMessage) + + if ( + errorMessage.includes('Row size exceeds') || + errorMessage.includes('Schema validation') || + errorMessage.includes('must be unique') || + errorMessage.includes('Unique constraint violation') || + errorMessage.includes('Cannot set unique column') + ) { + return v2Error('BAD_REQUEST', errorMessage) + } + + logger.error(`[${requestId}] Error updating row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/tables/[tableId]/rows/[rowId] — Delete a single row. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-row-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteTableRowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, rowId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const [deletedRow] = await db + .delete(userTableRows) + .where( + and( + eq(userTableRows.id, rowId), + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId) + ) + ) + .returning({ id: userTableRows.id }) + + if (!deletedRow) return v2Error('NOT_FOUND', 'Row not found') + + // v2 mirrors the bulk delete shape: always returns `deletedRowIds`. + return v2Data({ deletedCount: 1, deletedRowIds: [deletedRow.id] }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts new file mode 100644 index 00000000000..60957cf8aa9 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts @@ -0,0 +1,406 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest, NextResponse } from 'next/server' +import type { V1BatchInsertTableRowsBody } from '@/lib/api/contracts/v1/tables' +import { + v2CreateTableRowsContract, + v2DeleteTableRowsContract, + v2ListTableRowsContract, + v2UpdateRowsByFilterContract, +} from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { Filter, RowData, TableSchema } from '@/lib/table' +import { + batchInsertRows, + buildIdByName, + buildNameById, + deleteRowsByFilter, + deleteRowsByIds, + filterNamesToIds, + insertRow, + rowDataNameToId, + sortNamesToIds, + updateRowsByFilter, + validateBatchRows, + validateRowData, + validateRowSize, +} from '@/lib/table' +import { queryRows } from '@/lib/table/rows/service' +import { TableQueryValidationError } from '@/lib/table/sql' +import { checkAccess } from '@/app/api/table/utils' +import { + checkRateLimit, + type RateLimitResult, + resolveWorkspaceScope, +} from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { + toApiRow, + v2RowValidationError, + v2RowWriteError, + v2TableAccessError, +} from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRowsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRowsRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * Inserts a validated batch of rows. Authorizes against the table's own + * workspace (IDOR guard) before any write, translates name-keyed row data to + * storage ids, and returns the inserted rows in the canonical v2 envelope. + */ +async function handleBatchInsert( + requestId: string, + tableId: string, + validated: V1BatchInsertTableRowsBody, + userId: string, + rateLimit: RateLimitResult +): Promise { + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return v2TableAccessError(accessResult) + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // External callers key row data by column name; storage keys by id. + const idByName = buildIdByName(table.schema as TableSchema) + const nameById = buildNameById(table.schema as TableSchema) + const rows = (validated.rows as RowData[]).map((r) => rowDataNameToId(r, idByName)) + + const validation = await validateBatchRows({ + rows, + schema: table.schema as TableSchema, + tableId, + }) + if (!validation.valid) return v2RowValidationError(validation.response) + + try { + const insertedRows = await batchInsertRows( + { tableId, rows, workspaceId: validated.workspaceId, userId }, + table, + requestId + ) + + return v2Data( + { + rows: insertedRows.map((r) => toApiRow(r, nameById)), + insertedCount: insertedRows.length, + }, + { rateLimit } + ) + } catch (error) { + const response = v2RowWriteError(error) + if (response) return response + + logger.error(`[${requestId}] Error batch inserting rows`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +} + +/** GET /api/v2/tables/[tableId]/rows — Query rows with filtering, sorting, offset pagination. */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2ListTableRowsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // Translate name-keyed filter/sort fields → column ids; translate rows back. + const idByName = buildIdByName(table.schema as TableSchema) + const nameById = buildNameById(table.schema as TableSchema) + const filter = validated.filter + ? filterNamesToIds(validated.filter as Filter, idByName) + : undefined + const sort = validated.sort ? sortNamesToIds(validated.sort, idByName) : undefined + + // Cursor-uniform v2 pagination: the opaque cursor encodes the underlying + // offset (upgradeable to keyset later without an interface change). Total row + // count is intentionally omitted here — it's available as `rowCount` on the table. + const offset = validated.cursor + ? (decodeCursor<{ offset: number }>(validated.cursor)?.offset ?? 0) + : 0 + + const result = await queryRows( + table, + { + filter, + sort, + limit: validated.limit, + offset, + includeTotal: true, + withExecutions: false, + }, + requestId + ) + + const total = result.totalCount ?? 0 + const hasMore = offset + result.rowCount < total + const nextCursor = hasMore ? encodeCursor({ offset: offset + validated.limit }) : null + + return v2CursorList( + result.rows.map((r) => toApiRow(r, nameById)), + nextCursor, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error querying rows`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/tables/[tableId]/rows — Insert row(s). Supports single or batch. */ +export const POST = withRouteHandler( + async (request: NextRequest, context: TableRowsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2CreateTableRowsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + + if ('rows' in parsed.data.body) { + const batchValidated = parsed.data.body + const scopeError = await resolveWorkspaceScope(rateLimit, batchValidated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + return handleBatchInsert(requestId, tableId, batchValidated, userId, rateLimit) + } + + const validated = parsed.data.body + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return v2TableAccessError(accessResult) + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const nameById = buildNameById(table.schema as TableSchema) + const rowData = rowDataNameToId(validated.data as RowData, idByName) + + const validation = await validateRowData({ + rowData, + schema: table.schema as TableSchema, + tableId, + }) + if (!validation.valid) return v2RowValidationError(validation.response) + + const row = await insertRow( + { tableId, data: rowData, workspaceId: validated.workspaceId, userId }, + table, + requestId + ) + + return v2Data({ row: toApiRow(row, nameById) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const response = v2RowWriteError(error) + if (response) return response + + logger.error(`[${requestId}] Error inserting row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +/** PUT /api/v2/tables/[tableId]/rows — Bulk update rows by filter. */ +export const PUT = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UpdateRowsByFilterContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return v2TableAccessError(accessResult) + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const patchData = rowDataNameToId(validated.data as RowData, idByName) + + const sizeValidation = validateRowSize(patchData) + if (!sizeValidation.valid) { + return v2Error('BAD_REQUEST', 'Invalid row data', { details: sizeValidation.errors }) + } + + const result = await updateRowsByFilter( + table, + { + filter: filterNamesToIds(validated.filter as Filter, idByName), + data: patchData, + limit: validated.limit, + actorUserId: userId, + }, + requestId + ) + + // v2 always returns `updatedRowIds` ([] when nothing matched); v1 dropped it + // on the zero-match branch. + return v2Data( + { updatedCount: result.affectedCount, updatedRowIds: result.affectedRowIds }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + const response = v2RowWriteError(error) + if (response) return response + + logger.error(`[${requestId}] Error updating rows by filter`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/tables/[tableId]/rows — Delete rows by filter or IDs. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: TableRowsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteTableRowsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return v2TableAccessError(accessResult) + + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // id-based and filter-based deletes share one envelope; `requestedCount`/ + // `missingRowIds` are populated only for the id-based delete (which has a + // requested set) and omitted for the filter-based delete. + if (validated.rowIds) { + const result = await deleteRowsByIds( + { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, + requestId + ) + + return v2Data( + { + deletedCount: result.deletedCount, + deletedRowIds: result.deletedRowIds, + requestedCount: result.requestedCount, + missingRowIds: result.missingRowIds, + }, + { rateLimit } + ) + } + + const idByName = buildIdByName(table.schema as TableSchema) + const result = await deleteRowsByFilter( + table, + { filter: filterNamesToIds(validated.filter as Filter, idByName), limit: validated.limit }, + requestId + ) + + return v2Data( + { deletedCount: result.affectedCount, deletedRowIds: result.affectedRowIds }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + const response = v2RowWriteError(error) + if (response) return response + + logger.error(`[${requestId}] Error deleting rows`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) 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 new file mode 100644 index 00000000000..0831e5f702b --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts @@ -0,0 +1,98 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2UpsertTableRowContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { RowData, TableSchema } from '@/lib/table' +import { buildIdByName, buildNameById, rowDataNameToId, upsertRow } from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiRow, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableUpsertAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface UpsertRouteParams { + params: Promise<{ tableId: string }> +} + +/** POST /api/v2/tables/[tableId]/rows/upsert — Insert or update a row based on unique columns. */ +export const POST = withRouteHandler(async (request: NextRequest, context: UpsertRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UpsertTableRowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const nameById = buildNameById(table.schema as TableSchema) + const upsertResult = await upsertRow( + { + tableId, + workspaceId: validated.workspaceId, + data: rowDataNameToId(validated.data as RowData, idByName), + userId, + conflictTarget: validated.conflictTarget, + }, + table, + requestId + ) + + // v2 includes `position` in the row object (via toApiRow) — v1 dropped it here. + return v2Data( + { row: toApiRow(upsertResult.row, nameById), operation: upsertResult.operation }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const errorMessage = toError(error).message + if ( + errorMessage.includes('unique column') || + errorMessage.includes('Unique constraint violation') || + errorMessage.includes('conflictTarget') || + errorMessage.includes('row limit') || + errorMessage.includes('Schema validation') || + errorMessage.includes('Upsert requires') || + errorMessage.includes('Row size exceeds') + ) { + return v2Error('BAD_REQUEST', errorMessage) + } + + logger.error(`[${requestId}] Error upserting row`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts new file mode 100644 index 00000000000..27fe7418d95 --- /dev/null +++ b/apps/sim/app/api/v2/tables/route.ts @@ -0,0 +1,140 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateTableContract, v2ListTablesContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createTable, getWorkspaceTableLimits, listTables, type TableSchema } from '@/lib/table' +import { normalizeColumn } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiTable } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TablesAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/tables — List all tables in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListTablesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const tables = await listTables(workspaceId) + const items = tables.map(toApiTable) + + // `listTables` returns the full bounded workspace set → single page. + return v2CursorList(items, null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing tables`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/tables — Create a new table. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2CreateTableContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const planLimits = await getWorkspaceTableLimits(params.workspaceId) + + const normalizedSchema: TableSchema = { + columns: params.schema.columns.map(normalizeColumn), + } + + const table = await createTable( + { + name: params.name, + description: params.description, + schema: normalizedSchema, + workspaceId: params.workspaceId, + userId, + maxTables: planLimits.maxTables, + }, + requestId + ) + + recordAudit({ + workspaceId: params.workspaceId, + actorId: userId, + action: AuditAction.TABLE_CREATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: table.name, + description: `Created table "${table.name}" via API`, + metadata: { columnCount: params.schema.columns.length }, + request, + }) + + return v2Data({ table: toApiTable(table) }, { rateLimit, status: 201 }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + if (error.message.includes('maximum table limit')) { + return v2Error('FORBIDDEN', error.message) + } + if ( + error.message.includes('Invalid table name') || + error.message.includes('Invalid schema') || + error.message.includes('already exists') + ) { + return v2Error('BAD_REQUEST', error.message) + } + } + + logger.error(`[${requestId}] Error creating table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts new file mode 100644 index 00000000000..63bd09f8e89 --- /dev/null +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -0,0 +1,103 @@ +import type { NextResponse } from 'next/server' +import type { RowData, TableDefinition, TableSchema } from '@/lib/table' +import { rowDataIdToName } from '@/lib/table' +import { normalizeColumn, rootErrorMessage, rowWriteErrorResponse } from '@/app/api/table/utils' +import { v2Error } from '@/app/api/v2/lib/response' + +/** + * Shared serialization + error helpers for the v2 tables surface. Every v2 + * table/row/column route renders its payloads and access failures through these + * so the public shape, timestamp format, and error envelope stay identical + * across the surface. These reuse the v1 platform services and classifiers — + * only the HTTP envelope is upgraded. + */ + +/** ISO-serializes a `Date | string` timestamp from the table service layer. */ +function toIso(value: Date | string): string { + return value instanceof Date ? value.toISOString() : String(value) +} + +/** + * Normalized public table shape — the same subset of fields the v1 surface + * exposes, with timestamps serialized to ISO strings. Shared by every v2 table + * endpoint so the table payload is identical across the surface. + */ +export function toApiTable(table: TableDefinition) { + return { + id: table.id, + name: table.name, + description: table.description, + schema: { + columns: (table.schema as TableSchema).columns.map(normalizeColumn), + }, + rowCount: table.rowCount, + maxRows: table.maxRows, + createdAt: toIso(table.createdAt), + updatedAt: toIso(table.updatedAt), + } +} + +/** + * Row fields the public API exposes. `data` is stored id-keyed; {@link toApiRow} + * translates it to column names. + */ +interface ApiRowInput { + id: string + data: RowData + position: number + createdAt: Date | string + updatedAt: Date | string +} + +/** + * Normalized public row shape. Callers pass the table's id→name map so `data` is + * keyed by column name (the public contract). `position` is always included — + * every v2 row endpoint, including upsert, exposes it. + */ +export function toApiRow(row: ApiRowInput, nameById: Map) { + return { + id: row.id, + data: rowDataIdToName(row.data, nameById), + position: row.position, + createdAt: toIso(row.createdAt), + updatedAt: toIso(row.updatedAt), + } +} + +/** + * Renders a failed {@link checkAccess} result on a MUTATION path: a missing + * table stays 404, a missing permission stays 403. Read paths instead mask both + * as 404 inline so cross-workspace resource existence is never leaked. + */ +export function v2TableAccessError(result: { ok: false; status: 404 | 403 }): NextResponse { + return result.status === 404 + ? v2Error('NOT_FOUND', 'Table not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** + * Maps a known user-facing row-write failure (schema/size/unique/limit) to a v2 + * `BAD_REQUEST`, reusing v1's {@link rowWriteErrorResponse} classifier as the + * single source of truth for which messages are safe to surface. Returns `null` + * for unrecognized errors so the caller logs and returns a generic 500. + */ +export function v2RowWriteError(error: unknown): NextResponse | null { + if (!rowWriteErrorResponse(error)) return null + return v2Error('BAD_REQUEST', rootErrorMessage(error)) +} + +/** + * 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]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts new file mode 100644 index 00000000000..87c46b2cd75 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -0,0 +1,169 @@ +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v1DeployWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' +import { + v2DeployWorkflowContract, + v2UndeployWorkflowContract, +} from '@/lib/api/contracts/v2/workflows' +import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowDeployAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-deploy') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeployWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rawBody = await parseOptionalJsonBody(request) + if (!rawBody.success) { + return rawBody.response.status === 413 + ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') + : v2Error('BAD_REQUEST', 'Request body must be valid JSON') + } + const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {}) + if (!body.success) return v2ValidationError(body.error) + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const { workflow, workspaceId } = target + + await assertWorkflowMutable(id) + + logger.info(`[${requestId}] Deploying workflow ${id} via v2 API`, { userId }) + + const result = await performFullDeploy({ + workflowId: id, + userId, + workflowName: workflow.name || undefined, + versionName: body.data.name, + versionDescription: body.data.description ?? undefined, + requestId, + request, + }) + + if (!result.success) { + const code = + result.errorCode === 'not_found' + ? 'NOT_FOUND' + : result.errorCode === 'validation' + ? 'BAD_REQUEST' + : 'INTERNAL_ERROR' + return v2Error(code, result.error || 'Failed to deploy workflow') + } + + captureServerEvent( + userId, + 'workflow_deployed', + { workflow_id: id, workspace_id: workspaceId }, + { + groups: { workspace: workspaceId }, + setOnce: { first_workflow_deployed_at: new Date().toISOString() }, + } + ) + + return v2Data( + { + id, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version: result.version, + warnings: result.warnings ?? [], + }, + { rateLimit } + ) + } catch (error) { + if (error instanceof WorkflowLockedError) { + return v2Error('LOCKED', error.message) + } + logger.error(`[${requestId}] Workflow deploy error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-deploy') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UndeployWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const { workflow, workspaceId } = target + + if (!workflow.isDeployed) { + return v2Error('BAD_REQUEST', 'Workflow is not deployed') + } + + await assertWorkflowMutable(id) + + logger.info(`[${requestId}] Undeploying workflow ${id} via v2 API`, { userId }) + + const result = await performFullUndeploy({ workflowId: id, userId, requestId }) + if (!result.success) { + return v2Error('INTERNAL_ERROR', result.error || 'Failed to undeploy workflow') + } + + captureServerEvent( + userId, + 'workflow_undeployed', + { workflow_id: id, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + + return v2Data( + { + id, + isDeployed: false, + deployedAt: null, + warnings: result.warnings ?? [], + }, + { rateLimit } + ) + } catch (error) { + if (error instanceof WorkflowLockedError) { + return v2Error('LOCKED', error.message) + } + logger.error(`[${requestId}] Workflow undeploy error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts new file mode 100644 index 00000000000..634cf9957cf --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -0,0 +1,122 @@ +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v1RollbackWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' +import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performActivateVersion } from '@/lib/workflows/orchestration' +import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowRollbackAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-rollback') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2RollbackWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rawBody = await parseOptionalJsonBody(request) + if (!rawBody.success) { + return rawBody.response.status === 413 + ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') + : v2Error('BAD_REQUEST', 'Request body must be valid JSON') + } + const body = v1RollbackWorkflowBodySchema.safeParse(rawBody.data ?? {}) + if (!body.success) return v2ValidationError(body.error) + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const { workflow, workspaceId } = target + + if (!workflow.isDeployed) { + return v2Error('BAD_REQUEST', 'Workflow is not deployed') + } + + await assertWorkflowMutable(id) + + let targetVersion = body.data.version + if (targetVersion === undefined) { + const previous = await findPreviousDeploymentVersion(id) + if (!previous.ok) { + const message = + previous.reason === 'no_active_version' + ? 'Workflow has no active deployment to roll back from' + : 'No previous deployment version to roll back to' + return v2Error('BAD_REQUEST', message) + } + targetVersion = previous.version + } + + logger.info( + `[${requestId}] Rolling back workflow ${id} to version ${targetVersion} via v2 API`, + { userId } + ) + + const result = await performActivateVersion({ + workflowId: id, + version: targetVersion, + userId, + workflow: workflow as Record, + requestId, + request, + }) + + if (!result.success) { + const code = + result.errorCode === 'not_found' + ? 'NOT_FOUND' + : result.errorCode === 'validation' + ? 'BAD_REQUEST' + : 'INTERNAL_ERROR' + return v2Error(code, result.error || 'Failed to roll back workflow') + } + + captureServerEvent( + userId, + 'deployment_version_activated', + { workflow_id: id, workspace_id: workspaceId, version: targetVersion }, + { groups: { workspace: workspaceId } } + ) + + return v2Data( + { + id, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version: targetVersion, + warnings: result.warnings ?? [], + }, + { rateLimit } + ) + } catch (error) { + if (error instanceof WorkflowLockedError) { + return v2Error('LOCKED', error.message) + } + logger.error(`[${requestId}] Workflow rollback error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts new file mode 100644 index 00000000000..a059d669648 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -0,0 +1,81 @@ +import { db } from '@sim/db' +import { workflowBlocks } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2WorkflowDetail, v2GetWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowDetailAPI') + +export const revalidate = 0 + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + const blockRows = await db + .select({ + id: workflowBlocks.id, + type: workflowBlocks.type, + subBlocks: workflowBlocks.subBlocks, + }) + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, id)) + + const blocksRecord = Object.fromEntries( + blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }]) + ) + const inputs = extractInputFieldsFromBlocks(blocksRecord) + + const detail: V2WorkflowDetail = { + id: workflowData.id, + name: workflowData.name, + description: workflowData.description, + folderId: workflowData.folderId, + workspaceId: workflowData.workspaceId, + isDeployed: workflowData.isDeployed, + deployedAt: workflowData.deployedAt?.toISOString() ?? null, + runCount: workflowData.runCount, + lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, + variables: (workflowData.variables as Record | null) ?? {}, + inputs, + createdAt: workflowData.createdAt.toISOString(), + updatedAt: workflowData.updatedAt.toISOString(), + } + + return v2Data(detail, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflow details fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts new file mode 100644 index 00000000000..a35f045bda7 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -0,0 +1,142 @@ +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, asc, eq, gt, isNull, or } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2WorkflowListItem, v2ListWorkflowsContract } from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** Keyset cursor for the `(sortOrder, createdAt, id)` ordering. */ +interface WorkflowListCursor { + sortOrder: number + createdAt: string + id: string +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListWorkflowsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const conditions = [eq(workflow.workspaceId, params.workspaceId), isNull(workflow.archivedAt)] + + if (params.folderId) { + conditions.push(eq(workflow.folderId, params.folderId)) + } + + if (params.deployedOnly) { + conditions.push(eq(workflow.isDeployed, true)) + } + + if (params.cursor) { + const cursorData = decodeCursor(params.cursor) + if (cursorData) { + const cursorCondition = or( + gt(workflow.sortOrder, cursorData.sortOrder), + and( + eq(workflow.sortOrder, cursorData.sortOrder), + gt(workflow.createdAt, new Date(cursorData.createdAt)) + ), + and( + eq(workflow.sortOrder, cursorData.sortOrder), + eq(workflow.createdAt, new Date(cursorData.createdAt)), + gt(workflow.id, cursorData.id) + ) + ) + if (cursorCondition) { + conditions.push(cursorCondition) + } + } + } + + const rows = await db + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + folderId: workflow.folderId, + workspaceId: workflow.workspaceId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt, + sortOrder: workflow.sortOrder, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + }) + .from(workflow) + .where(and(...conditions)) + .orderBy(asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)) + .limit(params.limit + 1) + + const hasMore = rows.length > params.limit + const data = rows.slice(0, params.limit) + + let nextCursor: string | null = null + if (hasMore && data.length > 0) { + const last = data[data.length - 1] + nextCursor = encodeCursor({ + sortOrder: last.sortOrder, + createdAt: last.createdAt.toISOString(), + id: last.id, + }) + } + + const formatted: V2WorkflowListItem[] = data.map((w) => ({ + id: w.id, + name: w.name, + description: w.description, + folderId: w.folderId, + workspaceId: w.workspaceId ?? params.workspaceId, + isDeployed: w.isDeployed, + deployedAt: w.deployedAt?.toISOString() ?? null, + runCount: w.runCount, + lastRunAt: w.lastRunAt?.toISOString() ?? null, + createdAt: w.createdAt.toISOString(), + updatedAt: w.updatedAt.toISOString(), + })) + + return v2CursorList(formatted, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflows fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/lib/api/contracts/v1/admin/organizations.ts b/apps/sim/lib/api/contracts/v1/admin/organizations.ts index 1281b5e649d..f64fd8da4b4 100644 --- a/apps/sim/lib/api/contracts/v1/admin/organizations.ts +++ b/apps/sim/lib/api/contracts/v1/admin/organizations.ts @@ -142,7 +142,8 @@ const adminV1RemoveOrganizationMemberResultSchema = z.object({ memberId: z.string(), userId: z.string(), billingActions: z.object({ - usageCaptured: z.boolean(), + /** Dollar amount of departed-member usage captured (0 when none). */ + usageCaptured: z.number(), proRestored: z.boolean(), usageRestored: z.boolean(), skipBillingLogic: z.boolean(), @@ -159,8 +160,10 @@ const adminV1TransferOwnershipResultSchema = z.object({ currentOwnerUserId: z.string(), newOwnerUserId: z.string(), workspacesReassigned: z.number(), - billedAccountReassigned: z.boolean(), - overageMigrated: z.boolean(), + /** Count of workspaces whose billed account was reassigned to the new owner. */ + billedAccountReassigned: z.number(), + /** Decimal-string dollar amount of overage migrated to the new owner ('0' when none). */ + overageMigrated: z.string(), billingBlockInherited: z.boolean(), }) diff --git a/apps/sim/lib/api/contracts/v1/audit-logs.ts b/apps/sim/lib/api/contracts/v1/audit-logs.ts index f82b86e4b6d..4ce86e22e9b 100644 --- a/apps/sim/lib/api/contracts/v1/audit-logs.ts +++ b/apps/sim/lib/api/contracts/v1/audit-logs.ts @@ -1,5 +1,11 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' +import { + adminV1ListResponseSchema, + adminV1PaginationQuerySchema, + adminV1SingleResponseSchema, +} from '@/lib/api/contracts/v1/admin/shared' +import { v1UserLimitsSchema } from '@/lib/api/contracts/v1/shared' const isoDateString = z.string().refine((value) => !Number.isNaN(Date.parse(value)), { error: 'Invalid date format. Use ISO 8601.', @@ -43,25 +49,51 @@ export const v1AdminAuditLogsQuerySchema = z.object({ actorEmail: optionalQueryString, startDate: z.preprocess((value) => (value === '' ? undefined : value), isoDateString.optional()), endDate: z.preprocess((value) => (value === '' ? undefined : value), isoDateString.optional()), + ...adminV1PaginationQuerySchema.shape, }) /** - * Generic wrapper used by v1 admin audit-log responses. The `data` and - * `limits` halves are intentionally `z.unknown()` because this proxy returns - * provider-shaped payloads that vary per route family; tightening here would - * require a discriminated union per route, which is tracked as a follow-up. - * - * boundary-policy: this is the "validates nothing" alias form that the audit - * script's `untyped-response` regex doesn't currently catch. Treat any new - * wrapper of this shape the same way and either annotate at the contract use - * site with `// untyped-response: ` or replace with a concrete schema. + * Public enterprise audit-log entry. Mirrors `formatAuditLogEntry` in + * `app/api/v1/audit-logs/format.ts`; `ipAddress`/`userAgent` are intentionally + * excluded for privacy. `metadata` is genuinely arbitrary per-action JSON. */ -const apiResponseWithLimitsSchema = z - .object({ - data: z.unknown(), - limits: z.unknown().optional(), - }) - .passthrough() +const v1AuditLogEntrySchema = z.object({ + id: z.string(), + workspaceId: z.string().nullable(), + actorId: z.string().nullable(), + actorName: z.string().nullable(), + actorEmail: z.string().nullable(), + action: z.string(), + resourceType: z.string(), + resourceId: z.string().nullable(), + resourceName: z.string().nullable(), + description: z.string().nullable(), + metadata: z.unknown(), + createdAt: z.string(), +}) + +/** + * Admin audit-log entry. Mirrors `toAdminAuditLog` in `app/api/v1/admin/types.ts`, + * which additionally exposes `ipAddress`/`userAgent`. + */ +const adminV1AuditLogEntrySchema = v1AuditLogEntrySchema.extend({ + ipAddress: z.string().nullable(), + userAgent: z.string().nullable(), +}) + +const v1ListAuditLogsResponseSchema = z.object({ + data: z.array(v1AuditLogEntrySchema), + nextCursor: z.string().optional(), + limits: v1UserLimitsSchema, +}) + +const v1GetAuditLogResponseSchema = z.object({ + data: v1AuditLogEntrySchema, + limits: v1UserLimitsSchema, +}) + +export type V1AuditLogEntry = z.output +export type AdminV1AuditLogEntry = z.output export const v1ListAuditLogsContract = defineRouteContract({ method: 'GET', @@ -69,7 +101,7 @@ export const v1ListAuditLogsContract = defineRouteContract({ query: v1ListAuditLogsQuerySchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: v1ListAuditLogsResponseSchema, }, }) @@ -79,7 +111,7 @@ export const v1GetAuditLogContract = defineRouteContract({ params: v1AuditLogParamsSchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: v1GetAuditLogResponseSchema, }, }) @@ -89,7 +121,7 @@ export const v1AdminListAuditLogsContract = defineRouteContract({ query: v1AdminAuditLogsQuerySchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: adminV1ListResponseSchema(adminV1AuditLogEntrySchema), }, }) @@ -99,6 +131,6 @@ export const v1AdminGetAuditLogContract = defineRouteContract({ params: v1AuditLogParamsSchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: adminV1SingleResponseSchema(adminV1AuditLogEntrySchema), }, }) diff --git a/apps/sim/lib/api/contracts/v1/shared.ts b/apps/sim/lib/api/contracts/v1/shared.ts new file mode 100644 index 00000000000..9502e57ee5f --- /dev/null +++ b/apps/sim/lib/api/contracts/v1/shared.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' + +/** + * Rate-limit / usage envelope injected into every Family-A v1 response by + * `createApiResponse` (see `app/api/v1/logs/meta.ts`). Mirrors the `UserLimits` + * interface in that file. Shared here so logs, audit-logs, and workflows + * contracts describe `limits` identically instead of each redefining it. + */ +export const v1UserLimitsSchema = z.object({ + workflowExecutionRateLimit: z.object({ + sync: z.object({ + requestsPerMinute: z.number(), + maxBurst: z.number(), + remaining: z.number(), + resetAt: z.string(), + }), + async: z.object({ + requestsPerMinute: z.number(), + maxBurst: z.number(), + remaining: z.number(), + resetAt: z.string(), + }), + }), + usage: z.object({ + currentPeriodCost: z.number(), + limit: z.number(), + plan: z.string(), + isExceeded: z.boolean(), + }), +}) + +export type V1UserLimits = z.output + +/** + * Family-A envelope helper: `{ data, limits }`. Use for the `createApiResponse` + * detail/action surfaces (logs/[id], workflows deploy/rollback/undeploy). List + * endpoints that also return a `nextCursor` should compose the object directly + * (`{ data, nextCursor: z.string().optional(), limits: v1UserLimitsSchema }`). + */ +export const withV1Limits = (dataSchema: T) => + z.object({ + data: dataSchema, + limits: v1UserLimitsSchema, + }) diff --git a/apps/sim/lib/api/contracts/v2/audit-logs.ts b/apps/sim/lib/api/contracts/v2/audit-logs.ts new file mode 100644 index 00000000000..1084d9bbecb --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/audit-logs.ts @@ -0,0 +1,58 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1AuditLogParamsSchema, + v1ListAuditLogsQuerySchema, +} from '@/lib/api/contracts/v1/audit-logs' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 audit-logs contracts. These are org-scoped enterprise endpoints. The + * request schemas are reused verbatim from v1 (the query/param shape is + * unchanged); only the response envelope is upgraded to the canonical v2 + * shapes. The v1 `limits` body is dropped — usage limits live on the dedicated + * usage endpoint, not inlined into every response. + */ + +/** + * Public enterprise audit-log entry. Mirrors `formatAuditLogEntry` in + * `app/api/v1/audit-logs/format.ts` and the v1 `v1AuditLogEntrySchema`; + * `ipAddress`/`userAgent` are intentionally excluded for privacy. `metadata` is + * genuinely arbitrary per-action JSON. + */ +export const v2AuditLogEntrySchema = z.object({ + id: z.string(), + workspaceId: z.string().nullable(), + actorId: z.string().nullable(), + actorName: z.string().nullable(), + actorEmail: z.string().nullable(), + action: z.string(), + resourceType: z.string(), + resourceId: z.string().nullable(), + resourceName: z.string().nullable(), + description: z.string().nullable(), + metadata: z.unknown(), + createdAt: z.string(), +}) + +export type V2AuditLogEntry = z.output + +export const v2ListAuditLogsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/audit-logs', + query: v1ListAuditLogsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2AuditLogEntrySchema), + }, +}) + +export const v2GetAuditLogContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/audit-logs/[id]', + params: v1AuditLogParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2AuditLogEntrySchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts new file mode 100644 index 00000000000..040ffa4dc80 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -0,0 +1,112 @@ +import { z } from 'zod' +import { workspaceFileIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 files contracts. v2 drops the v1 `{ success, data, limits }` envelope in + * favor of the canonical v2 shapes (`{ data }` / `{ data, nextCursor }`) and + * adds cursor pagination to the list. The workspace is always carried as a query + * param — including on upload — so the route can authorize before reading the + * multipart body. + */ + +/** A workspace file as exposed by the v2 surface. */ +export const v2FileSchema = z.object({ + id: z.string(), + name: z.string(), + size: z.number().nonnegative(), + type: z.string(), + key: z.string(), + uploadedBy: z.string(), + /** ISO-8601 timestamp. */ + uploadedAt: z.string(), +}) + +export type V2File = z.output + +/** Acknowledgement returned by a successful archive (soft delete). */ +export const v2DeleteFileResultSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) + +export type V2DeleteFileResult = z.output + +export const v2FileParamsSchema = z.object({ + fileId: workspaceFileIdSchema, +}) + +export type V2FileParams = z.output + +/** + * List query: workspace scope plus opaque keyset cursor pagination keyed on + * `(uploadedAt, id)`. `limit` clamps to `[1, 1000]` (default 100) to bound the + * response. The cursor is the base64-JSON codec shared across the v2 surface. + */ +export const v2ListFilesQuerySchema = z.object({ + workspaceId: workspaceIdSchema, + limit: z.coerce + .number() + .optional() + .default(100) + .transform((v) => Math.min(Math.max(1, Math.trunc(v)), 1000)), + cursor: z.string().min(1).optional(), +}) + +export type V2ListFilesQuery = z.output + +/** Upload carries the workspace as a query param so auth runs before buffering. */ +export const v2UploadFileQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) + +export type V2UploadFileQuery = z.output + +/** Download/delete both target a single file within a workspace-scoped query. */ +export const v2FileWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) + +export type V2FileWorkspaceQuery = z.output + +export const v2ListFilesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files', + query: v2ListFilesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2FileSchema), + }, +}) + +export const v2UploadFileContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files', + query: v2UploadFileQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileSchema), + }, +}) + +export const v2DownloadFileContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/[fileId]', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'binary', + }, +}) + +export const v2DeleteFileContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/files/[fileId]', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteFileResultSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts new file mode 100644 index 00000000000..06f4064d2fb --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -0,0 +1,270 @@ +import { z } from 'zod' +import { knowledgeBaseDataSchema } from '@/lib/api/contracts/knowledge/base' +import { documentDataSchema } from '@/lib/api/contracts/knowledge/documents' +import { + knowledgeBaseParamsSchema, + knowledgeDocumentParamsSchema, + nullableWireDateSchema, +} from '@/lib/api/contracts/knowledge/shared' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1CreateKnowledgeBaseBodySchema, + v1KnowledgeSearchBodySchema, + v1KnowledgeWorkspaceQuerySchema, + v1ListKnowledgeBasesQuerySchema, + v1ListKnowledgeDocumentsQuerySchema, + v1UpdateKnowledgeBaseBodySchema, +} from '@/lib/api/contracts/v1/knowledge' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 knowledge contracts. + * + * Request shapes (params/query/body) are reused verbatim from the v1 public + * contract (`@/lib/api/contracts/v1/knowledge`) — the public request surface is + * unchanged. Only the response envelope is upgraded to the canonical v2 shapes + * (`{ data }` for single/mutation, `{ data, pagination }` for the offset-paginated + * document list), and the success `message` strings v1 inlined are dropped. + * + * The concrete `data` item schemas reuse the first-party knowledge data schemas + * as their source of truth: the knowledge-base item is a `.pick()` of + * {@link knowledgeBaseDataSchema} matching `formatKnowledgeBase`'s projection, + * and the document items reuse the core fields of {@link documentDataSchema}. The + * v2 (and v1-public) document projection renames `uploadedAt` to `createdAt` and + * omits `fileUrl`/tag slots, so that rename is layered on via `.extend()`. + */ + +/** + * Knowledge-base item — the exact subset `formatKnowledgeBase` projects from a + * {@link KnowledgeBaseWithCounts}. `userId`, `workspaceId`, and `deletedAt` are + * intentionally not exposed on the public surface. + */ +export const v2KnowledgeBaseSchema = knowledgeBaseDataSchema.pick({ + id: true, + name: true, + description: true, + tokenCount: true, + embeddingModel: true, + embeddingDimension: true, + chunkingConfig: true, + docCount: true, + connectorTypes: true, + createdAt: true, + updatedAt: true, +}) +export type V2KnowledgeBase = z.output + +/** `{ knowledgeBase }` payload for single-KB reads and mutations. */ +export const v2KnowledgeBaseDataSchema = z.object({ knowledgeBase: v2KnowledgeBaseSchema }) +export type V2KnowledgeBaseData = z.output + +/** Delete acknowledgement — the id of the resource that was deleted. */ +export const v2KnowledgeDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2KnowledgeDeleteData = z.output + +/** + * Document core fields shared by the list item and the detail payload, reused + * from the first-party {@link documentDataSchema}. + */ +const v2KnowledgeDocumentCoreSchema = documentDataSchema.pick({ + id: true, + knowledgeBaseId: true, + filename: true, + fileSize: true, + mimeType: true, + processingStatus: true, + chunkCount: true, + tokenCount: true, + characterCount: true, + enabled: true, +}) + +/** + * Document list item / upload acknowledgement. `createdAt` is the public rename + * of the underlying `uploadedAt` column. + */ +export const v2KnowledgeDocumentSummarySchema = v2KnowledgeDocumentCoreSchema.extend({ + createdAt: nullableWireDateSchema, +}) +export type V2KnowledgeDocumentSummary = z.output + +/** + * Document detail — the summary plus processing state and connector provenance. + * Every field is always present (nullable), mirroring the v1 detail projection. + */ +export const v2KnowledgeDocumentSchema = v2KnowledgeDocumentSummarySchema.extend({ + processingError: z.string().nullable(), + processingStartedAt: nullableWireDateSchema, + processingCompletedAt: nullableWireDateSchema, + connectorId: z.string().nullable(), + connectorType: z.string().nullable(), + sourceUrl: z.string().nullable(), +}) +export type V2KnowledgeDocument = z.output + +/** `{ document }` payload for the upload acknowledgement (summary shape). */ +export const v2KnowledgeDocumentSummaryDataSchema = z.object({ + document: v2KnowledgeDocumentSummarySchema, +}) +export type V2KnowledgeDocumentSummaryData = z.output + +/** `{ document }` payload for the document detail read. */ +export const v2KnowledgeDocumentDataSchema = z.object({ document: v2KnowledgeDocumentSchema }) +export type V2KnowledgeDocumentData = z.output + +/** + * A single vector/tag search hit. `metadata` is the document's display-named tag + * map; values are user-defined and of mixed type (string/number/boolean/date), + * so they are carried as `unknown` and serialized as-is. + */ +export const v2KnowledgeSearchResultSchema = z.object({ + documentId: z.string(), + documentName: z.string().nullable(), + sourceUrl: z.string().nullable(), + content: z.string(), + chunkIndex: z.number(), + metadata: z.record(z.string(), z.unknown()), + similarity: z.number(), +}) +export type V2KnowledgeSearchResult = z.output + +/** Search response payload — mirrors the v1 `data` object. */ +export const v2KnowledgeSearchDataSchema = z.object({ + results: z.array(v2KnowledgeSearchResultSchema), + query: z.string(), + knowledgeBaseIds: z.array(z.string()), + topK: z.number(), + totalResults: z.number(), +}) +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 }) +export type V2UploadKnowledgeDocumentQuery = z.output + +/** + * KB list. `getKnowledgeBases` returns the full workspace set (a small, bounded + * per-workspace list), so today the cursor list is a single full page + * (`nextCursor` always `null`). The canonical cursor envelope keeps the v2 list + * surface uniform; real pagination can be added later behind the opaque cursor. + */ +export const v2ListKnowledgeBasesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge', + query: v1ListKnowledgeBasesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2KnowledgeBaseSchema), + }, +}) + +export const v2CreateKnowledgeBaseContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge', + body: v1CreateKnowledgeBaseBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeBaseDataSchema), + }, +}) + +export const v2GetKnowledgeBaseContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]', + params: knowledgeBaseParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeBaseDataSchema), + }, +}) + +export const v2UpdateKnowledgeBaseContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/knowledge/[id]', + params: knowledgeBaseParamsSchema, + body: v1UpdateKnowledgeBaseBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeBaseDataSchema), + }, +}) + +export const v2DeleteKnowledgeBaseContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/[id]', + params: knowledgeBaseParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDeleteDataSchema), + }, +}) + +export const v2SearchKnowledgeContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/search', + body: v1KnowledgeSearchBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeSearchDataSchema), + }, +}) + +/** + * 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. + */ +export const v2ListKnowledgeDocumentsQuerySchema = v1ListKnowledgeDocumentsQuerySchema + .omit({ offset: true }) + .extend({ cursor: z.string().min(1).optional() }) +export type V2ListKnowledgeDocumentsQuery = z.output + +export const v2ListKnowledgeDocumentsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]/documents', + params: knowledgeBaseParamsSchema, + query: v2ListKnowledgeDocumentsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2KnowledgeDocumentSummarySchema), + }, +}) + +export const v2UploadKnowledgeDocumentContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/[id]/documents', + params: knowledgeBaseParamsSchema, + query: v2UploadKnowledgeDocumentQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDocumentSummaryDataSchema), + }, +}) + +export const v2GetKnowledgeDocumentContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + params: knowledgeDocumentParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDocumentDataSchema), + }, +}) + +export const v2DeleteKnowledgeDocumentContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + params: knowledgeDocumentParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts new file mode 100644 index 00000000000..774aceb8794 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -0,0 +1,123 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1ExecutionParamsSchema, + v1ListLogsQuerySchema, + v1LogParamsSchema, +} from '@/lib/api/contracts/v1/logs' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 logs contracts. The query schemas are reused verbatim from v1 (the request + * shape is unchanged); only the response envelope is upgraded to the canonical + * v2 shapes with concrete item schemas. + */ + +const v2LogCostSchema = z.object({ total: z.number() }).nullable() + +/** Execution `files` is a per-run jsonb array of attachment metadata. */ +const v2LogFilesSchema = z.array(z.unknown()).nullable() + +const v2LogWorkflowSummarySchema = z.object({ + id: z.string().nullable(), + name: z.string(), + description: z.string().nullable(), + deleted: z.boolean(), +}) + +export const v2LogListItemSchema = z.object({ + id: z.string(), + workflowId: z.string().nullable(), + executionId: z.string(), + deploymentVersionId: z.string().nullable(), + level: z.string(), + trigger: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + totalDurationMs: z.number().nullable(), + cost: v2LogCostSchema, + files: v2LogFilesSchema, + /** Present only when `details=full`. */ + workflow: v2LogWorkflowSummarySchema.optional(), + /** Present only when `details=full` and `includeFinalOutput=true`. */ + finalOutput: z.unknown().optional(), + /** Present only when `details=full` and `includeTraceSpans=true`. */ + traceSpans: z.unknown().optional(), +}) + +export type V2LogListItem = z.output + +export const v2LogDetailSchema = z.object({ + id: z.string(), + workflowId: z.string().nullable(), + executionId: z.string(), + level: z.string(), + trigger: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + totalDurationMs: z.number().nullable(), + files: v2LogFilesSchema, + workflow: z.object({ + id: z.string().nullable(), + name: z.string(), + description: z.string().nullable(), + folderId: z.string().nullable(), + userId: z.string().nullable(), + workspaceId: z.string().nullable(), + createdAt: z.string().nullable(), + updatedAt: z.string().nullable(), + deleted: z.boolean(), + }), + /** Materialized execution trace (block states, trace spans). */ + executionData: z.unknown(), + cost: v2LogCostSchema, + createdAt: z.string(), +}) + +export type V2LogDetail = z.output + +export const v2ExecutionSchema = z.object({ + executionId: z.string(), + workflowId: z.string().nullable(), + /** Workflow state snapshot at execution time. */ + workflowState: z.unknown(), + executionMetadata: z.object({ + trigger: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + totalDurationMs: z.number().nullable(), + cost: v2LogCostSchema, + }), +}) + +export type V2Execution = z.output + +export const v2ListLogsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs', + query: v1ListLogsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2LogListItemSchema), + }, +}) + +export const v2GetLogContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs/[id]', + params: v1LogParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2LogDetailSchema), + }, +}) + +export const v2GetExecutionContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs/executions/[executionId]', + params: v1ExecutionParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ExecutionSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts new file mode 100644 index 00000000000..d0579054727 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' + +/** + * Shared building blocks for the v2 API contract surface. + * + * v2 standardizes on a single response family across every endpoint: + * - single resource: `{ data: T }` + * - list: `{ data: T[], nextCursor: string | null }` + * - error: `{ error: { code, message, details? } }` + * + * Every list uses the opaque-cursor envelope (Stripe/Slack-style): `limit` + + * `cursor` in, `{ data, nextCursor }` out. Cursors are opaque so the underlying + * scheme (keyset / offset / full-set) can change without a contract change. + * Total counts are not returned on lists — they're available on the parent + * resource where relevant (e.g. `rowCount` on a table, `docCount` on a KB). + * + * Rate-limit state is carried in `X-RateLimit-*` response headers (not the + * body). Usage limits are available from the dedicated usage endpoint rather + * than being inlined into every response. + */ + +/** Canonical v2 error envelope. */ +export const v2ErrorResponseSchema = z.object({ + error: z.object({ + code: z.string(), + message: z.string(), + details: z.unknown().optional(), + }), +}) + +/** `{ data: T }` */ +export const v2DataResponse = (dataSchema: T) => z.object({ data: dataSchema }) + +/** `{ data: T[], nextCursor: string | null }` — the v2 list envelope. */ +export const v2CursorListResponse = (itemSchema: T) => + z.object({ + data: z.array(itemSchema), + nextCursor: z.string().nullable(), + }) diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts new file mode 100644 index 00000000000..4fa64291fe6 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -0,0 +1,321 @@ +import { z } from 'zod' +import { + createTableColumnBodySchema, + deleteTableColumnBodySchema, + deleteTableRowsBodySchema, + tableColumnSchema, + tableIdParamsSchema, + tableRowParamsSchema, + updateRowsByFilterBodySchema, + updateTableColumnBodySchema, + updateTableRowBodySchema, + upsertTableRowBodySchema, +} from '@/lib/api/contracts/tables' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1CreateTableBodySchema, + v1CreateTableRowsBodySchema, + v1ListTablesQuerySchema, + v1TableRowsQuerySchema, +} from '@/lib/api/contracts/v1/tables' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 tables contracts. + * + * Request shapes (params/query/body) are reused verbatim from the v1 contract + * and the first-party `/api/table` contract — the public table request surface + * is unchanged. Only the response envelope is upgraded to the canonical v2 + * shapes (`{ data }` for single/mutation, `{ data, pagination }` for the + * list/offset surfaces), and the outcome-dependent payloads are made consistent + * (see per-contract notes below). + * + * The `data` item schemas are concrete and describe exactly what the route's + * `toApiTable`/`toApiRow` serializers emit. The first-party + * `tableDefinitionSchema`/`tableRowSchema` are NOT reused here because they are + * opaque (`z.custom`) and their inferred types include fields the public wire + * never carries (`executions`, `workspaceId`, `Date` timestamps, …). Column + * shape is reused from the concrete first-party `tableColumnSchema`. + */ + +/** + * Public table shape emitted by `toApiTable` (timestamps ISO-serialized). + * Concrete so the v2 contract describes exactly what the wire carries. + */ +export const v2ApiTableSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + schema: z.object({ columns: z.array(tableColumnSchema) }), + rowCount: z.number(), + maxRows: z.number(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2ApiTable = z.output + +/** + * Public row shape emitted by `toApiRow`. `data` is keyed by column NAME (the + * id→name translation the route applies); cell values are user-defined, so the + * map is `Record`. Timestamps ISO. + */ +export const v2ApiRowSchema = z.object({ + id: z.string(), + data: z.record(z.string(), z.unknown()), + position: z.number(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2ApiRow = z.output + +/** A single table definition payload. */ +export const v2TableDataSchema = z.object({ table: v2ApiTableSchema }) +export type V2TableData = z.output + +/** Archive confirmation — the id of the table that was archived. */ +export const v2DeleteTableDataSchema = z.object({ id: z.string() }) +export type V2DeleteTableData = z.output + +/** The table's full column list after a column mutation. */ +export const v2TableColumnsDataSchema = z.object({ columns: z.array(tableColumnSchema) }) +export type V2TableColumnsData = z.output + +/** A single row payload. */ +export const v2TableRowDataSchema = z.object({ row: v2ApiRowSchema }) +export type V2TableRowData = z.output + +/** Batch-insert payload. */ +export const v2BatchInsertRowsDataSchema = z.object({ + rows: z.array(v2ApiRowSchema), + insertedCount: z.number(), +}) +export type V2BatchInsertRowsData = z.output + +/** + * Bulk update-by-filter payload. v2 always returns `updatedRowIds` (`[]` when + * nothing matched) — v1 dropped the field on the zero-match branch. + */ +export const v2UpdateRowsDataSchema = z.object({ + updatedCount: z.number(), + updatedRowIds: z.array(z.string()), +}) +export type V2UpdateRowsData = z.output + +/** + * Bulk delete payload — one consistent shape for both id-based and + * filter-based deletes. `requestedCount`/`missingRowIds` are populated for the + * id-based delete (which has a requested set) and omitted for the filter-based + * delete; v1 emitted two divergent shapes here. + */ +export const v2DeleteRowsDataSchema = z.object({ + deletedCount: z.number(), + deletedRowIds: z.array(z.string()), + requestedCount: z.number().optional(), + missingRowIds: z.array(z.string()).optional(), +}) +export type V2DeleteRowsData = z.output + +/** Single-row delete payload — mirrors the bulk shape's required fields. */ +export const v2DeleteRowDataSchema = z.object({ + deletedCount: z.number(), + deletedRowIds: z.array(z.string()), +}) +export type V2DeleteRowData = z.output + +/** Upsert payload — the row object includes `position` like every other row endpoint. */ +export const v2UpsertRowDataSchema = z.object({ + row: v2ApiRowSchema, + operation: z.enum(['insert', 'update']), +}) +export type V2UpsertRowData = z.output + +/** + * Table list. `listTables` returns every table in the workspace (a small, + * bounded per-workspace set), so today the cursor list is a single full page + * (`nextCursor` is always `null`). Using the canonical cursor envelope keeps the + * whole v2 list surface uniform, and real pagination can be added later behind + * the opaque cursor without an interface change. + */ +export const v2ListTablesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables', + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2ApiTableSchema), + }, +}) + +export const v2CreateTableContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables', + body: v1CreateTableBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableDataSchema), + }, +}) + +export const v2GetTableContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]', + params: tableIdParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableDataSchema), + }, +}) + +export const v2DeleteTableContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]', + params: tableIdParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteTableDataSchema), + }, +}) + +export const v2AddTableColumnContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/columns', + params: tableIdParamsSchema, + body: createTableColumnBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableColumnsDataSchema), + }, +}) + +export const v2UpdateTableColumnContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]/columns', + params: tableIdParamsSchema, + body: updateTableColumnBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableColumnsDataSchema), + }, +}) + +export const v2DeleteTableColumnContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/columns', + params: tableIdParamsSchema, + body: deleteTableColumnBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableColumnsDataSchema), + }, +}) + +/** + * Row list query: the v1 filter/sort/limit request shape with `offset` swapped + * for an opaque `cursor` (cursor-uniform v2 pagination). The cursor encodes the + * underlying offset today; it can move to a keyset implementation later without + * an interface change. Total row count is available as `rowCount` on the table. + */ +export const v2TableRowsQuerySchema = v1TableRowsQuerySchema.omit({ offset: true }).extend({ + cursor: z.string().min(1).optional(), +}) +export type V2TableRowsQuery = z.output + +/** Cursor-paginated row list. */ +export const v2ListTableRowsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/rows', + params: tableIdParamsSchema, + query: v2TableRowsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2ApiRowSchema), + }, +}) + +/** + * Single contract for `POST /rows` — the body is the single|batch union so the + * route can dispatch in one `parseRequest`, and the response is the matching + * union (`{ data: { row } }` for a single insert, `{ data: { rows, + * insertedCount } }` for a batch). + */ +export const v2CreateTableRowsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/rows', + params: tableIdParamsSchema, + body: v1CreateTableRowsBodySchema, + response: { + mode: 'json', + schema: z.union([ + v2DataResponse(v2TableRowDataSchema), + v2DataResponse(v2BatchInsertRowsDataSchema), + ]), + }, +}) + +export const v2UpdateRowsByFilterContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/tables/[tableId]/rows', + params: tableIdParamsSchema, + body: updateRowsByFilterBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UpdateRowsDataSchema), + }, +}) + +export const v2DeleteTableRowsContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/rows', + params: tableIdParamsSchema, + body: deleteTableRowsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteRowsDataSchema), + }, +}) + +export const v2GetTableRowContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + params: tableRowParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableRowDataSchema), + }, +}) + +export const v2UpdateTableRowContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + params: tableRowParamsSchema, + body: updateTableRowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableRowDataSchema), + }, +}) + +export const v2DeleteTableRowContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + params: tableRowParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteRowDataSchema), + }, +}) + +export const v2UpsertTableRowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/upsert', + params: tableIdParamsSchema, + body: upsertTableRowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UpsertRowDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts new file mode 100644 index 00000000000..05ca4a36fe8 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -0,0 +1,112 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1DeployWorkflowDataSchema, + v1ListWorkflowsQuerySchema, + v1RollbackWorkflowDataSchema, +} from '@/lib/api/contracts/v1/workflows' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows' + +/** + * v2 workflows contracts. Request shapes are reused verbatim from v1 (the list + * query and `[id]` param are unchanged); only the response envelope is upgraded + * to the canonical v2 shapes with concrete item/detail schemas. The + * deploy/rollback/undeploy data payloads reuse the already-concrete v1 schemas, + * re-wrapped in `v2DataResponse` (the v1 `limits` body field is dropped — v2 + * carries rate-limit state in headers and usage on a dedicated endpoint). + */ + +export const v2WorkflowListItemSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + folderId: z.string().nullable(), + workspaceId: z.string(), + isDeployed: z.boolean(), + deployedAt: z.string().nullable(), + runCount: z.number(), + lastRunAt: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +export type V2WorkflowListItem = z.output + +/** A single trigger input field extracted from the workflow's input-definition block. */ +const v2WorkflowInputFieldSchema = z.object({ + name: z.string(), + type: z.string(), + description: z.string().optional(), +}) + +export const v2WorkflowDetailSchema = v2WorkflowListItemSchema.extend({ + /** + * Workflow-scoped variables keyed by variable id. Each value is a structured + * variable object (`{ id, name, type, value, ... }`); only the inner `value` + * is user-defined/free-form. Kept as `unknown` to tolerate legacy/unstamped + * rows — tightening to a concrete object schema later is consumer-safe (the + * wire already carries the full object), so it stays additively evolvable. + */ + variables: z.record(z.string(), z.unknown()), + inputs: z.array(v2WorkflowInputFieldSchema), +}) + +export type V2WorkflowDetail = z.output + +/** + * Undeploy returns the deployment state without a version number. Derived from + * the exported v1 deploy data schema (its private base is not exported) so the + * shape stays in lockstep with v1. + */ +const v2UndeployWorkflowDataSchema = v1DeployWorkflowDataSchema.omit({ version: true }) + +export const v2ListWorkflowsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows', + query: v1ListWorkflowsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowListItemSchema), + }, +}) + +export const v2GetWorkflowContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowDetailSchema), + }, +}) + +export const v2DeployWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/deploy', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v1DeployWorkflowDataSchema), + }, +}) + +export const v2UndeployWorkflowContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/workflows/[id]/deploy', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UndeployWorkflowDataSchema), + }, +}) + +export const v2RollbackWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/rollback', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v1RollbackWorkflowDataSchema), + }, +}) diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index 30379de8031..4d352d041a5 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -40,6 +40,12 @@ export interface PerformDeleteWorkspaceFileItemsParams { userId: string fileIds?: string[] folderIds?: string[] + /** + * Optional originating request, forwarded to the audit log so the deletion + * entry captures client IP / user agent. Omitted by in-app callers that have + * no HTTP request in scope. + */ + request?: { headers: { get(name: string): string | null } } } export interface PerformDeleteWorkspaceFileItemsResult { @@ -137,7 +143,7 @@ export interface PerformRestoreWorkspaceFileFolderResult { export async function performDeleteWorkspaceFileItems( params: PerformDeleteWorkspaceFileItemsParams ): Promise { - const { workspaceId, userId, fileIds = [], folderIds = [] } = params + const { workspaceId, userId, fileIds = [], folderIds = [], request } = params if (fileIds.length === 0 && folderIds.length === 0) { return { @@ -172,6 +178,7 @@ export async function performDeleteWorkspaceFileItems( resourceType: AuditResourceType.FILE, description: `Deleted ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}`, metadata: { fileIds }, + request, }) } @@ -190,6 +197,7 @@ export async function performDeleteWorkspaceFileItems( folders: deletedItems.folders, }, }, + request, }) } diff --git a/bun.lock b/bun.lock index 2b92202f9ae..a7d73045fb9 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -2952,7 +2951,7 @@ "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], - "lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "lucide-react": ["lucide-react@1.18.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -4306,6 +4305,8 @@ "@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], + "@sim/emcn/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "@sim/realtime/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], "@sim/runtime-secrets/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], @@ -4496,16 +4497,12 @@ "fumadocs-openapi/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "fumadocs-openapi/lucide-react": ["lucide-react@1.18.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA=="], - "fumadocs-openapi/shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], "fumadocs-openapi/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "fumadocs-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], - "fumadocs-ui/lucide-react": ["lucide-react@1.18.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA=="], - "fumadocs-ui/shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], "fumadocs-ui/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -4658,6 +4655,8 @@ "sim/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + "sim/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], "simstudio/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 680dfa03bf1..74074d6c1c0 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 872, - zodRoutes: 872, + totalRoutes: 887, + zodRoutes: 887, nonZodRoutes: 0, } as const From 52fdc4be16a81798f926dbc2391db71a7b195896 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 10:59:29 -0700 Subject: [PATCH 003/159] improvement(api): pull in the v2 external endpoint surface Cherry-picks improvement/v2-endpoints (98c85677f5) onto the current base. The v2 surface standardizes one response family across every endpoint: `{ data }`, `{ data, nextCursor }`, and `{ error: { code, message, details? } }`, rendered through apps/sim/app/api/v2/lib/response.ts. v1 auth and rate limiting are reused as-is; the workspace-access and enterprise-audit checks are split into `resolve*` cores returning structured failures, with thin v1 wrappers that render the old `{ error }` body so v1 behavior is unchanged. The branch's own /api/v2/tables/** is dropped. Staging's tables v2 (#6067, typed predicate grammar + POST /api/v2/tables/[tableId]/query) supersedes it and lands in the following merge; the two are reconciled onto the shared envelope separately. Conflict resolutions: - v1/middleware.ts: keeps resolveWorkspaceRequestActor alongside the new resolveWorkspaceAccess/resolveWorkspaceScope split - v1/audit-logs/auth.ts: keeps the newer targetOrganizationId parameter and isOrganizationBillingBlocked check inside the structured resolver - bun.lock: taken from HEAD; the branch's lock churn was unrelated lucide-react hoisting Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- .../docs/de/api-reference/getting-started.mdx | 2 +- .../content/docs/de/api-reference/meta.json | 9 +- .../(generated)/execution/meta.json | 3 + .../(generated)/workflows/meta.json | 6 +- .../docs/en/api-reference/getting-started.mdx | 2 +- .../content/docs/en/api-reference/meta.json | 8 +- .../en/platform/enterprise/audit-logs.mdx | 13 +- .../docs/es/api-reference/getting-started.mdx | 2 +- .../content/docs/es/api-reference/meta.json | 11 +- .../docs/fr/api-reference/getting-started.mdx | 2 +- .../content/docs/fr/api-reference/meta.json | 11 +- .../docs/ja/api-reference/getting-started.mdx | 2 +- .../content/docs/ja/api-reference/meta.json | 11 +- .../docs/zh/api-reference/getting-started.mdx | 2 +- .../content/docs/zh/api-reference/meta.json | 11 +- apps/docs/lib/openapi.ts | 80 +- apps/docs/openapi-core.json | 2948 +++++++++++++++++ apps/docs/openapi-v2-files-audit.json | 1125 +++++++ apps/docs/openapi-v2-knowledge.json | 1802 ++++++++++ apps/docs/openapi-v2-logs.json | 1065 ++++++ apps/docs/openapi-v2-tables.json | 2339 +++++++++++++ apps/docs/openapi-v2-workflows.json | 1024 ++++++ apps/sim/app/api/v1/admin/audit-logs/route.ts | 11 +- .../admin/organizations/[id]/billing/route.ts | 3 +- .../[id]/members/[memberId]/route.ts | 3 +- .../api/v1/admin/outbox/[id]/requeue/route.ts | 5 +- apps/sim/app/api/v1/admin/outbox/route.ts | 5 +- .../api/v1/admin/referral-campaigns/route.ts | 3 +- apps/sim/app/api/v1/audit-logs/auth.ts | 80 +- apps/sim/app/api/v1/logs/filters.ts | 12 +- apps/sim/app/api/v1/logs/route.ts | 2 +- apps/sim/app/api/v1/middleware.ts | 73 +- apps/sim/app/api/v2/audit-logs/[id]/route.ts | 76 + apps/sim/app/api/v2/audit-logs/route.ts | 103 + apps/sim/app/api/v2/files/[fileId]/route.ts | 124 + apps/sim/app/api/v2/files/route.ts | 236 ++ .../[id]/documents/[documentId]/route.ts | 209 ++ .../api/v2/knowledge/[id]/documents/route.ts | 306 ++ apps/sim/app/api/v2/knowledge/[id]/route.ts | 193 ++ apps/sim/app/api/v2/knowledge/route.ts | 140 + apps/sim/app/api/v2/knowledge/search/route.ts | 299 ++ apps/sim/app/api/v2/lib/response.ts | 144 + apps/sim/app/api/v2/logs/[id]/route.ts | 109 + .../v2/logs/executions/[executionId]/route.ts | 74 + apps/sim/app/api/v2/logs/route.ts | 168 + .../app/api/v2/workflows/[id]/deploy/route.ts | 169 + .../api/v2/workflows/[id]/rollback/route.ts | 122 + apps/sim/app/api/v2/workflows/[id]/route.ts | 81 + apps/sim/app/api/v2/workflows/route.ts | 142 + .../api/contracts/v1/admin/organizations.ts | 9 +- apps/sim/lib/api/contracts/v1/audit-logs.ts | 70 +- apps/sim/lib/api/contracts/v1/shared.ts | 44 + apps/sim/lib/api/contracts/v2/audit-logs.ts | 58 + apps/sim/lib/api/contracts/v2/files.ts | 112 + apps/sim/lib/api/contracts/v2/knowledge.ts | 270 ++ apps/sim/lib/api/contracts/v2/logs.ts | 123 + apps/sim/lib/api/contracts/v2/shared.ts | 39 + apps/sim/lib/api/contracts/v2/workflows.ts | 112 + .../orchestration/file-folder-lifecycle.ts | 10 +- 59 files changed, 14070 insertions(+), 147 deletions(-) create mode 100644 apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json create mode 100644 apps/docs/openapi-core.json create mode 100644 apps/docs/openapi-v2-files-audit.json create mode 100644 apps/docs/openapi-v2-knowledge.json create mode 100644 apps/docs/openapi-v2-logs.json create mode 100644 apps/docs/openapi-v2-tables.json create mode 100644 apps/docs/openapi-v2-workflows.json create mode 100644 apps/sim/app/api/v2/audit-logs/[id]/route.ts create mode 100644 apps/sim/app/api/v2/audit-logs/route.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/route.ts create mode 100644 apps/sim/app/api/v2/files/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/search/route.ts create mode 100644 apps/sim/app/api/v2/lib/response.ts create mode 100644 apps/sim/app/api/v2/logs/[id]/route.ts create mode 100644 apps/sim/app/api/v2/logs/executions/[executionId]/route.ts create mode 100644 apps/sim/app/api/v2/logs/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/deploy/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/rollback/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/route.ts create mode 100644 apps/sim/app/api/v2/workflows/route.ts create mode 100644 apps/sim/lib/api/contracts/v1/shared.ts create mode 100644 apps/sim/lib/api/contracts/v2/audit-logs.ts create mode 100644 apps/sim/lib/api/contracts/v2/files.ts create mode 100644 apps/sim/lib/api/contracts/v2/knowledge.ts create mode 100644 apps/sim/lib/api/contracts/v2/logs.ts create mode 100644 apps/sim/lib/api/contracts/v2/shared.ts create mode 100644 apps/sim/lib/api/contracts/v2/workflows.ts 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 25c8cfdbf2e..7e94ab0d7bd 100644 --- a/apps/docs/content/docs/de/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/de/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/de/api-reference/meta.json b/apps/docs/content/docs/de/api-reference/meta.json index d8a1fb142c6..74cedc72725 100644 --- a/apps/docs/content/docs/de/api-reference/meta.json +++ b/apps/docs/content/docs/de/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,9 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", "(generated)/audit-logs", "(generated)/tables", - "(generated)/files" + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json new file mode 100644 index 00000000000..52458d430c3 --- /dev/null +++ b/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json @@ -0,0 +1,3 @@ +{ + "pages": ["executeWorkflow", "getWorkflowExecution", "cancelExecution", "getJobStatus"] +} diff --git a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json index 8e2caa1abe8..ca2603a1d54 100644 --- a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json +++ b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json @@ -1,15 +1,11 @@ { "pages": [ - "executeWorkflow", - "getWorkflowExecution", - "cancelExecution", "listWorkflows", "getWorkflow", "exportWorkflow", "importWorkflow", "deployWorkflow", "undeployWorkflow", - "rollbackWorkflow", - "getJobStatus" + "rollbackWorkflow" ] } 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 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/en/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/en/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/en/api-reference/meta.json b/apps/docs/content/docs/en/api-reference/meta.json index c99ab8eb13f..74cedc72725 100644 --- a/apps/docs/content/docs/en/api-reference/meta.json +++ b/apps/docs/content/docs/en/api-reference/meta.json @@ -10,12 +10,14 @@ "typescript", "---Endpoints---", "(generated)/workflows", - "(generated)/human-in-the-loop", "(generated)/logs", - "(generated)/usage", "(generated)/audit-logs", "(generated)/tables", "(generated)/files", - "(generated)/knowledge-bases" + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx b/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx index 9bcf9dfb0ed..b9d039c2a56 100644 --- a/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx @@ -33,7 +33,7 @@ Audit logs are also accessible through the Sim API for integration with external ```http GET /api/v1/audit-logs -Authorization: Bearer +X-API-Key: ``` **Query parameters:** @@ -71,11 +71,18 @@ Authorization: Bearer "createdAt": "2026-04-20T21:16:00.000Z" } ], - "nextCursor": "eyJpZCI6ImFiYzEyMyJ9" + "nextCursor": "eyJpZCI6ImFiYzEyMyJ9", + "limits": { + "workflowExecutionRateLimit": { + "sync": { "requestsPerMinute": 60, "maxBurst": 10, "remaining": 59, "resetAt": "2026-04-20T21:17:00.000Z" }, + "async": { "requestsPerMinute": 30, "maxBurst": 5, "remaining": 30, "resetAt": "2026-04-20T21:17:00.000Z" } + }, + "usage": { "currentPeriodCost": 1.25, "limit": 50, "plan": "enterprise", "isExceeded": false } + } } ``` -Paginate by passing the `nextCursor` value as the `cursor` parameter in the next request. When `nextCursor` is absent, you have reached the last page. +Paginate by passing the `nextCursor` value as the `cursor` parameter in the next request. When `nextCursor` is absent, you have reached the last page. Each entry also includes `actorName`; `metadata` is an arbitrary per-action JSON object. The `limits` object reports your current rate-limit and usage status. The API accepts both personal and workspace-scoped API keys. Rate limits apply — the response includes `X-RateLimit-*` headers with your current limit and remaining quota. 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 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/es/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/es/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/es/api-reference/meta.json b/apps/docs/content/docs/es/api-reference/meta.json index c96dc5d2edc..74cedc72725 100644 --- a/apps/docs/content/docs/es/api-reference/meta.json +++ b/apps/docs/content/docs/es/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/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 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/fr/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/fr/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/fr/api-reference/meta.json b/apps/docs/content/docs/fr/api-reference/meta.json index c96dc5d2edc..74cedc72725 100644 --- a/apps/docs/content/docs/fr/api-reference/meta.json +++ b/apps/docs/content/docs/fr/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/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 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/ja/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/ja/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/ja/api-reference/meta.json b/apps/docs/content/docs/ja/api-reference/meta.json index c96dc5d2edc..74cedc72725 100644 --- a/apps/docs/content/docs/ja/api-reference/meta.json +++ b/apps/docs/content/docs/ja/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/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 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/zh/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/zh/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/zh/api-reference/meta.json b/apps/docs/content/docs/zh/api-reference/meta.json index c96dc5d2edc..74cedc72725 100644 --- a/apps/docs/content/docs/zh/api-reference/meta.json +++ b/apps/docs/content/docs/zh/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/lib/openapi.ts b/apps/docs/lib/openapi.ts index af5f7a2b4c8..41f0687139a 100644 --- a/apps/docs/lib/openapi.ts +++ b/apps/docs/lib/openapi.ts @@ -2,8 +2,17 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { createOpenAPI } from 'fumadocs-openapi/server' +const SPEC_FILES = [ + 'openapi-core.json', + 'openapi-v2-logs.json', + 'openapi-v2-workflows.json', + 'openapi-v2-tables.json', + 'openapi-v2-knowledge.json', + 'openapi-v2-files-audit.json', +] as const + export const openapi = createOpenAPI({ - input: ['./openapi.json'], + input: SPEC_FILES.map((file) => `./${file}`), }) interface OpenAPIOperation { @@ -24,20 +33,34 @@ function resolveRef(ref: string, spec: Record): unknown { return current } -function resolveRefs(obj: unknown, spec: Record, depth = 0): unknown { - if (depth > 10) return obj +function resolveRefs( + obj: unknown, + spec: Record, + seen: Set = new Set(), + depth = 0 +): unknown { + // Generous backstop against pathological fan-out; real schemas nest far shallower. + if (depth > 50) return obj if (Array.isArray(obj)) { - return obj.map((item) => resolveRefs(item, spec, depth + 1)) + return obj.map((item) => resolveRefs(item, spec, seen, depth + 1)) } if (obj && typeof obj === 'object') { const record = obj as Record - if ('$ref' in record && typeof record.$ref === 'string') { - const resolved = resolveRef(record.$ref, spec) - return resolveRefs(resolved, spec, depth + 1) + if (typeof record.$ref === 'string') { + const ref = record.$ref + // Break reference cycles: if this $ref is already being expanded above us, + // leave it untouched instead of recursing forever. + if (seen.has(ref)) return record + const resolved = resolveRef(ref, spec) + if (resolved === undefined) return record + seen.add(ref) + const out = resolveRefs(resolved, spec, seen, depth + 1) + seen.delete(ref) + return out } const result: Record = {} for (const [key, value] of Object.entries(record)) { - result[key] = resolveRefs(value, spec, depth + 1) + result[key] = resolveRefs(value, spec, seen, depth + 1) } return result } @@ -48,14 +71,34 @@ function formatSchema(schema: unknown): string { return JSON.stringify(schema, null, 2) } -let cachedSpec: Record | null = null +let cachedSpecs: Record[] | null = null + +function getSpecs(): Record[] { + if (!cachedSpecs) { + cachedSpecs = SPEC_FILES.map( + (file) => + JSON.parse(readFileSync(join(process.cwd(), file), 'utf8')) as Record + ) + } + return cachedSpecs +} -function getSpec(): Record { - if (!cachedSpec) { - const specPath = join(process.cwd(), 'openapi.json') - cachedSpec = JSON.parse(readFileSync(specPath, 'utf8')) as Record +/** + * Locate an operation by path + method across every rendered spec, returning the + * operation together with the spec that owns it so `$ref`s resolve within the + * correct document (each spec carries its own `components`). + */ +function findOperation( + path: string, + method: string +): { operation: Record; spec: Record } | undefined { + const key = method.toLowerCase() + for (const spec of getSpecs()) { + const pathObj = (spec.paths as Record> | undefined)?.[path] + const operation = pathObj?.[key] as Record | undefined + if (operation) return { operation, spec } } - return cachedSpec + return undefined } export function getApiSpecContent( @@ -63,22 +106,19 @@ export function getApiSpecContent( description: string | undefined, operations: OpenAPIOperation[] ): string { - const spec = getSpec() - if (!operations || operations.length === 0) { return `# ${title}\n\n${description || ''}` } const op = operations[0] const method = op.method.toUpperCase() - const pathObj = (spec.paths as Record>)?.[op.path] - const operation = pathObj?.[op.method.toLowerCase()] as Record | undefined + const found = findOperation(op.path, op.method) - if (!operation) { + if (!found) { return `# ${title}\n\n${description || ''}` } - const resolved = resolveRefs(operation, spec) as Record + const resolved = resolveRefs(found.operation, found.spec) as Record const lines: string[] = [] lines.push(`# ${title}`) diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json new file mode 100644 index 00000000000..53a99c2e866 --- /dev/null +++ b/apps/docs/openapi-core.json @@ -0,0 +1,2948 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API — Execution & Usage", + "description": "Run workflows, poll and cancel executions, resume Human-in-the-Loop pauses, and check usage limits.", + "version": "1.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" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Execution", + "description": "Run workflows, poll execution status, and cancel runs" + }, + { + "name": "Human in the Loop", + "description": "Manage paused workflow executions and resume them with input" + }, + { + "name": "Usage", + "description": "Check rate limits and billing usage" + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/workflows/{id}/execute": { + "post": { + "operationId": "executeWorkflow", + "summary": "Execute Workflow", + "description": "Execute a deployed workflow. Supports synchronous, asynchronous, and streaming modes. For async execution, the response includes a statusUrl you can poll for results.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/workflows/{id}/execute\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"input\": {\n \"key\": \"value\"\n }\n }'" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the deployed workflow to execute.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + } + ], + "requestBody": { + "description": "Execution configuration including input values and execution mode options.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "input": { + "type": "object", + "description": "Key-value pairs matching the workflow's defined input fields. Use the Get Workflow endpoint to discover available input fields.", + "additionalProperties": true + }, + "triggerType": { + "type": "string", + "description": "How this execution was triggered. Defaults to api when called via the REST API. Recorded in execution logs for filtering." + }, + "stream": { + "type": "boolean", + "description": "When true, returns results as Server-Sent Events (SSE) for real-time block-by-block output streaming." + }, + "selectedOutputs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of specific block IDs whose outputs to include in the response. When omitted, all block outputs are returned." + } + } + }, + "example": { + "input": { + "query": "What is the weather in Tokyo?" + } + } + } + } + }, + "responses": { + "200": { + "description": "Synchronous execution completed successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecutionResult" + }, + "example": { + "success": true, + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "output": { + "content": "The weather in Tokyo is sunny, 22°C." + }, + "error": null, + "metadata": { + "startTime": "2026-01-15T10:30:00Z", + "endTime": "2026-01-15T10:30:01Z", + "duration": 1250 + } + } + } + } + }, + "202": { + "description": "Asynchronous execution has been queued. Poll the statusUrl for results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncExecutionResult" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + } + } + }, + "/api/workflows/{id}/executions/{executionId}": { + "get": { + "operationId": "getWorkflowExecution", + "summary": "Get Execution Status", + "description": "Get the current status of a workflow execution. Returns the run's lifecycle state (`running`, `paused`, `completed`, `failed`, etc.), timing, error, and optionally per-block outputs. Designed for polling — works for any execution, including ones that pause and resume.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + }, + { + "id": "curl-with-outputs", + "label": "cURL (with block outputs)", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}?selectedOutputs=blockId,blockId.field&includeOutput=true\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique identifier of the execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "includeOutput", + "in": "query", + "required": false, + "description": "When `true` and the execution has `status: completed`, include the workflow's final output in the response.", + "schema": { + "type": "string", + "enum": ["true", "false"] + } + }, + { + "name": "selectedOutputs", + "in": "query", + "required": false, + "description": "Comma-separated block-output selectors. A bare `blockId` returns that block's full output; a dot-path like `blockId.field` or `blockId.nested.path` returns just that value. Results are returned in the `blockOutputs` map keyed by the selector string.", + "schema": { + "type": "string", + "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf,c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration" + } + } + ], + "responses": { + "200": { + "description": "Execution status returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowExecutionStatus" + }, + "examples": { + "completed": { + "summary": "Completed run", + "value": { + "executionId": "9254f1c9-5a11-4a12-91e3-8065293f3609", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "completed", + "trigger": "api", + "level": "info", + "startedAt": "2026-05-15T19:43:12.189Z", + "endedAt": "2026-05-15T19:45:45.224Z", + "totalDurationMs": 153035, + "paused": null, + "cost": { + "total": 0.005 + }, + "error": null, + "finalOutput": null, + "blockOutputs": null + } + }, + "paused": { + "summary": "Currently paused run", + "value": { + "executionId": "772749f6-ee81-414c-a2c3-671549dd62b8", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "paused", + "trigger": "manual", + "level": "info", + "startedAt": "2026-05-15T22:25:57.178Z", + "endedAt": "2026-05-15T22:25:57.215Z", + "totalDurationMs": 1, + "paused": { + "pausedAt": "2026-05-15T22:25:57.216Z", + "resumeAt": "2026-05-16T18:25:57.200Z", + "pauseKind": "time", + "blockedOnBlockId": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf", + "pausedExecutionId": "438bf05b-bd3c-4011-b78e-b19c112eeb66", + "pausePointCount": 1, + "resumedCount": 0 + }, + "cost": { + "total": 0.005 + }, + "error": null, + "finalOutput": null, + "blockOutputs": null + } + }, + "failed": { + "summary": "Failed run", + "value": { + "executionId": "3ccfdeed-a63c-4e86-98e2-8bec723bca52", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "failed", + "trigger": "api", + "level": "error", + "startedAt": "2026-05-15T22:24:50.991Z", + "endedAt": "2026-05-15T22:24:50.999Z", + "totalDurationMs": 2, + "paused": null, + "cost": { + "total": 0.005 + }, + "error": "Wait 1: Wait time exceeds maximum of 5 minutes; enable async mode to wait up to 30 days", + "finalOutput": null, + "blockOutputs": null + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/executions/{executionId}/cancel": { + "post": { + "operationId": "cancelExecution", + "summary": "Cancel Execution", + "description": "Cancel a running workflow execution. Only effective for executions that are still in progress.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}/cancel\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique identifier of the execution to cancel.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Execution was successfully cancelled.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the cancellation was successful." + }, + "executionId": { + "type": "string", + "description": "The ID of the cancelled execution." + } + } + }, + "example": { + "success": true, + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/jobs/{jobId}": { + "get": { + "operationId": "getJobStatus", + "summary": "Get Job Status", + "description": "Poll the status of an asynchronous workflow execution. Use the jobId returned from the Execute Workflow endpoint when the execution is queued asynchronously.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/jobs/{jobId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "description": "The job identifier returned in the async execution response.", + "schema": { + "type": "string", + "example": "job_4a3b2c1d0e" + } + } + ], + "responses": { + "200": { + "description": "Current status of the job. When completed, includes the execution output.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobStatus" + }, + "example": { + "success": true, + "taskId": "job_abc123", + "status": "completed", + "output": { + "content": "Done" + }, + "metadata": { + "startTime": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/paused": { + "get": { + "operationId": "listPausedExecutions", + "summary": "List Paused Executions", + "description": "List all paused executions for a workflow. Workflows pause at Human in the Loop blocks and wait for input before continuing. Use this endpoint to discover which executions need attention.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused?status=paused\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter paused executions by status.", + "schema": { + "type": "string", + "example": "paused" + } + } + ], + "responses": { + "200": { + "description": "List of paused executions.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pausedExecutions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PausedExecutionSummary" + } + } + } + }, + "example": { + "pausedExecutions": [ + { + "id": "pe_abc123", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "status": "paused", + "totalPauseCount": 1, + "resumedCount": 0, + "pausedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "expiresAt": null, + "metadata": null, + "triggerIds": [], + "pausePoints": [ + { + "contextId": "ctx_xyz789", + "blockId": "block_hitl_1", + "registeredAt": "2026-01-15T10:30:00Z", + "resumeStatus": "paused", + "snapshotReady": true, + "resumeLinks": { + "apiUrl": "https://www.sim.ai/api/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13/ctx_xyz789", + "uiUrl": "https://www.sim.ai/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "contextId": "ctx_xyz789", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "response": { + "displayData": { + "title": "Approval Required", + "message": "Please review this request" + }, + "formFields": [] + } + } + ] + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/paused/{executionId}": { + "get": { + "operationId": "getPausedExecution", + "summary": "Get Paused Execution", + "description": "Get detailed information about a specific paused execution, including its pause points, execution snapshot, and resume queue. Use this to inspect the state before resuming.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Paused execution details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PausedExecutionDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/resume/{workflowId}/{executionId}": { + "get": { + "operationId": "getPausedExecutionByResumePath", + "summary": "Get Paused Execution (Resume Path)", + "description": "Get detailed information about a specific paused execution using the resume URL path. Returns the same data as the workflow paused execution detail endpoint.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Paused execution details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PausedExecutionDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + } + } + } + }, + "/api/resume/{workflowId}/{executionId}/{contextId}": { + "get": { + "operationId": "getPauseContext", + "summary": "Get Pause Context", + "description": "Get detailed information about a specific pause context within a paused execution. Returns the pause point details, resume queue state, and any active resume entry.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "contextId", + "in": "path", + "required": true, + "description": "The pause context ID to retrieve details for.", + "schema": { + "type": "string", + "example": "ctx_xyz789" + } + } + ], + "responses": { + "200": { + "description": "Pause context details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PauseContextDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "operationId": "resumeExecution", + "summary": "Resume Execution", + "description": "Resume a paused workflow execution by providing input for a specific pause context. The execution continues from where it paused, using the provided input. Supports synchronous, asynchronous, and streaming modes (determined by the original execution's configuration).", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"input\": {\n \"approved\": true,\n \"comment\": \"Looks good to me\"\n }\n }'" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "contextId", + "in": "path", + "required": true, + "description": "The pause context ID to resume. Found in the pause point's contextId field or resumeLinks.", + "schema": { + "type": "string", + "example": "ctx_xyz789" + } + } + ], + "requestBody": { + "description": "Input data for the resumed execution. The structure depends on the workflow's Human in the Loop block configuration.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "input": { + "type": "object", + "description": "Key-value pairs to pass as input to the resumed execution. If omitted, the entire request body is used as input.", + "additionalProperties": true + } + } + }, + "example": { + "input": { + "approved": true, + "comment": "Looks good to me" + } + } + } + } + }, + "responses": { + "200": { + "description": "Resume execution completed synchronously, or resume was queued behind another in-progress resume.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResumeResult" + }, + { + "type": "object", + "description": "Resume has been queued behind another in-progress resume.", + "properties": { + "status": { + "type": "string", + "enum": ["queued"], + "description": "Indicates the resume is queued." + }, + "executionId": { + "type": "string", + "description": "The execution ID assigned to this resume." + }, + "queuePosition": { + "type": "integer", + "description": "Position in the resume queue." + }, + "message": { + "type": "string", + "description": "Human-readable status message." + } + } + }, + { + "type": "object", + "description": "Resume execution started (non-API-key callers). The execution runs asynchronously.", + "properties": { + "status": { + "type": "string", + "enum": ["started"], + "description": "Indicates the resume execution has started." + }, + "executionId": { + "type": "string", + "description": "The execution ID for the resumed workflow." + }, + "message": { + "type": "string", + "description": "Human-readable status message." + } + } + } + ] + }, + "examples": { + "sync": { + "summary": "Synchronous completion", + "value": { + "success": true, + "status": "completed", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "output": { + "result": "Approved and processed" + }, + "error": null, + "metadata": { + "duration": 850, + "startTime": "2026-01-15T10:35:00Z", + "endTime": "2026-01-15T10:35:01Z" + } + } + }, + "queued": { + "summary": "Queued behind another resume", + "value": { + "status": "queued", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "queuePosition": 2, + "message": "Resume queued. It will run after current resumes finish." + } + }, + "started": { + "summary": "Execution started (fire and forget)", + "value": { + "status": "started", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "message": "Resume execution started." + } + } + } + } + } + }, + "202": { + "description": "Resume execution has been queued for asynchronous processing. Poll the statusUrl for results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncExecutionResult" + }, + "example": { + "success": true, + "async": true, + "jobId": "job_4a3b2c1d0e", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "message": "Resume execution queued", + "statusUrl": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "503": { + "description": "Failed to queue the resume execution. Retry the request.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error message." + } + } + } + } + } + } + } + } + }, + "/api/users/me/usage-limits": { + "get": { + "operationId": "getUsageLimits", + "summary": "Get Usage Limits", + "description": "Retrieve your current rate limits, usage spending, and storage consumption for the billing period.", + "tags": ["Usage"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/users/me/usage-limits\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "Current rate limits, usage, and storage information.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageLimits" + }, + "example": { + "success": true, + "rateLimit": { + "sync": { + "limit": 100, + "remaining": 95, + "reset": "2026-01-15T11:00:00Z" + }, + "async": { + "limit": 50, + "remaining": 48, + "reset": "2026-01-15T11:00:00Z" + } + }, + "usage": { + "currentPeriodCost": 12.5, + "limit": 100, + "plan": "pro" + }, + "storage": { + "usedBytes": 5242880, + "limitBytes": 1073741824, + "percentUsed": 0.49 + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "parameters": [] + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "TableId": { + "name": "tableId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "description": "The unique identifier of the table." + }, + "RowId": { + "name": "rowId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "description": "The unique identifier of the row." + }, + "WorkspaceId": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "The unique identifier of the workspace." + } + }, + "schemas": { + "ColumnDefinition": { + "type": "object", + "description": "Definition of a table column including its type and constraints.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "description": "Column name. Must start with a letter or underscore.", + "example": "email", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "description": "Whether the column requires a value on insert.", + "default": false + }, + "unique": { + "type": "boolean", + "description": "Whether values in this column must be unique across all rows.", + "default": false + } + } + }, + "Table": { + "type": "object", + "description": "A user-defined table with a typed schema.", + "properties": { + "id": { + "type": "string", + "description": "Unique table identifier.", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "name": { + "type": "string", + "description": "Table name.", + "example": "contacts" + }, + "description": { + "type": "string", + "description": "Optional description of the table.", + "example": "Customer contact records" + }, + "schema": { + "type": "object", + "description": "Table schema definition.", + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ColumnDefinition" + }, + "description": "Array of column definitions for the table." + } + } + }, + "rowCount": { + "type": "integer", + "description": "Current number of rows in the table." + }, + "maxRows": { + "type": "integer", + "description": "Maximum rows allowed by the current billing plan." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was last modified." + } + } + }, + "TableRow": { + "type": "object", + "description": "A single row in a table.", + "properties": { + "id": { + "type": "string", + "description": "Unique row identifier.", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "data": { + "type": "object", + "additionalProperties": true, + "description": "Row data as key-value pairs matching the table schema." + }, + "position": { + "type": "integer", + "description": "Row's position/order in the table." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was last modified." + } + } + }, + "WorkflowSummary": { + "type": "object", + "description": "Summary representation of a workflow returned in list operations.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Human-readable workflow name.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description of what the workflow does.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": "string", + "nullable": true, + "description": "The folder this workflow belongs to. null if at the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + "workspaceId": { + "type": "string", + "description": "The workspace this workflow belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is currently deployed and available for API execution.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent deployment. null if never deployed.", + "example": "2025-06-15T10:30:00Z" + }, + "runCount": { + "type": "integer", + "description": "Total number of times this workflow has been executed.", + "example": 142 + }, + "lastRunAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent execution. null if never run.", + "example": "2025-06-20T14:15:22Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created.", + "example": "2025-01-10T09:00:00Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "WorkflowDetail": { + "type": "object", + "description": "Full workflow representation including input field definitions and configuration.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Human-readable workflow name.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description of what the workflow does.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": "string", + "nullable": true, + "description": "The folder this workflow belongs to. null if at the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + "workspaceId": { + "type": "string", + "description": "The workspace this workflow belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is currently deployed and available for API execution.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent deployment. null if never deployed.", + "example": "2025-06-15T10:30:00Z" + }, + "runCount": { + "type": "integer", + "description": "Total number of times this workflow has been executed.", + "example": 142 + }, + "lastRunAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent execution. null if never run.", + "example": "2025-06-20T14:15:22Z" + }, + "variables": { + "type": "object", + "description": "Workflow-level variables and their current values.", + "example": {} + }, + "inputs": { + "type": "object", + "description": "The workflow's input field definitions. Use these to construct the input object when executing the workflow.", + "properties": { + "fields": { + "type": "object", + "description": "Map of field names to their type definitions and configuration.", + "additionalProperties": true, + "example": {} + } + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created.", + "example": "2025-01-10T09:00:00Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "WorkflowDeployment": { + "type": "object", + "description": "Deployment state of a workflow after a deploy, undeploy, or rollback operation.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is deployed and available for API execution after the operation.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the active deployment. null after an undeploy.", + "example": "2026-06-12T10:30:00Z" + }, + "version": { + "type": "integer", + "description": "The deployment version that is now active. Omitted for undeploy.", + "example": 4 + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal warnings. Present when trigger, schedule, or MCP side-effect sync is still in progress or needs a redeploy." + } + } + }, + "ExecutionResult": { + "type": "object", + "description": "Result of a synchronous workflow execution.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the workflow executed successfully without errors.", + "example": true + }, + "executionId": { + "type": "string", + "description": "Unique identifier for this execution. Use this to query logs or cancel the execution.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "output": { + "type": "object", + "description": "Workflow output keyed by block name and output field. Structure depends on the workflow's block configuration.", + "additionalProperties": true, + "example": { + "result": "Hello, world!" + } + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message if the execution failed. null on success.", + "example": null + }, + "metadata": { + "type": "object", + "description": "Execution timing metadata.", + "properties": { + "duration": { + "type": "integer", + "description": "Total execution duration in milliseconds.", + "example": 1250 + }, + "startTime": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2025-06-20T14:15:22Z" + }, + "endTime": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed.", + "example": "2025-06-20T14:15:23Z" + } + } + } + } + }, + "AsyncExecutionResult": { + "type": "object", + "description": "Response returned when a workflow execution is queued for asynchronous processing.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the execution was successfully queued.", + "example": true + }, + "async": { + "type": "boolean", + "description": "Always true for async executions. Use this to distinguish from synchronous responses.", + "example": true + }, + "jobId": { + "type": "string", + "description": "Internal job queue identifier for tracking the execution.", + "example": "job_4a3b2c1d0e" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier. Use this to query execution status or cancel.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "message": { + "type": "string", + "description": "Human-readable status message (e.g., \"Execution queued\").", + "example": "Execution queued" + }, + "statusUrl": { + "type": "string", + "format": "uri", + "description": "URL to poll for execution status and results. Returns the full execution result once complete.", + "example": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" + } + } + }, + "LogEntry": { + "type": "object", + "description": "Summary of a single workflow execution log entry.", + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": "string", + "description": "The workflow that was executed.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier for this run.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "level": { + "type": "string", + "description": "Log severity. info for successful executions, error for failures.", + "example": "info" + }, + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, manual, webhook, schedule, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2025-06-20T14:15:22Z" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed.", + "example": "2025-06-20T14:15:23Z" + }, + "totalDurationMs": { + "type": "integer", + "description": "Total execution duration in milliseconds.", + "example": 1250 + }, + "cost": { + "type": "object", + "description": "Cost summary for this execution.", + "properties": { + "total": { + "type": "number", + "description": "Total cost of this execution in USD.", + "example": 0.0032 + } + } + }, + "files": { + "type": "object", + "nullable": true, + "description": "File outputs produced during execution. null if no files were generated.", + "example": null + } + } + }, + "LogDetail": { + "type": "object", + "description": "Detailed log entry with full execution data, workflow metadata, and cost breakdown.", + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": "string", + "description": "The workflow that was executed.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier for this run.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "level": { + "type": "string", + "description": "Log severity. info for successful executions, error for failures.", + "example": "info" + }, + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, manual, webhook, schedule, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2025-06-20T14:15:22Z" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed.", + "example": "2025-06-20T14:15:23Z" + }, + "totalDurationMs": { + "type": "integer", + "description": "Total execution duration in milliseconds.", + "example": 1250 + }, + "workflow": { + "type": "object", + "description": "Summary metadata about the workflow at the time of execution.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Workflow name at the time of execution.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Workflow description at the time of execution.", + "example": "Routes incoming support tickets and drafts responses" + } + } + }, + "executionData": { + "type": "object", + "description": "Detailed execution data including block-level traces and final output.", + "properties": { + "traceSpans": { + "type": "array", + "description": "Block-level execution traces with timing, inputs, and outputs for each block that ran.", + "items": { + "type": "object" + } + }, + "finalOutput": { + "type": "object", + "description": "The workflow's final output after all blocks completed." + } + } + }, + "cost": { + "type": "object", + "description": "Detailed cost breakdown for this execution.", + "properties": { + "total": { + "type": "number", + "description": "Total cost of this execution in USD.", + "example": 0.0032 + }, + "tokens": { + "type": "object", + "description": "Aggregate token usage across all AI model calls in this execution.", + "properties": { + "prompt": { + "type": "integer", + "description": "Total prompt (input) tokens consumed.", + "example": 450 + }, + "completion": { + "type": "integer", + "description": "Total completion (output) tokens generated.", + "example": 120 + }, + "total": { + "type": "integer", + "description": "Total tokens (prompt + completion).", + "example": 570 + } + } + }, + "models": { + "type": "object", + "description": "Per-model cost and token breakdown. Keys are model identifiers (e.g., gpt-4o, claude-sonnet-4-20250514).", + "additionalProperties": { + "type": "object", + "description": "Cost and token details for a specific model.", + "properties": { + "input": { + "type": "number", + "description": "Cost of prompt tokens for this model in USD." + }, + "output": { + "type": "number", + "description": "Cost of completion tokens for this model in USD." + }, + "total": { + "type": "number", + "description": "Total cost for this model in USD." + }, + "tokens": { + "type": "object", + "description": "Token usage for this specific model.", + "properties": { + "prompt": { + "type": "integer", + "description": "Prompt tokens consumed by this model." + }, + "completion": { + "type": "integer", + "description": "Completion tokens generated by this model." + }, + "total": { + "type": "integer", + "description": "Total tokens for this model." + } + } + } + } + } + } + } + } + } + }, + "Limits": { + "type": "object", + "description": "Rate limit and usage information included in every API response.", + "properties": { + "workflowExecutionRateLimit": { + "type": "object", + "description": "Current rate limit status for workflow executions.", + "properties": { + "sync": { + "description": "Rate limit bucket for synchronous executions.", + "$ref": "#/components/schemas/RateLimitBucket" + }, + "async": { + "description": "Rate limit bucket for asynchronous executions.", + "$ref": "#/components/schemas/RateLimitBucket" + } + } + }, + "usage": { + "type": "object", + "description": "Current billing period usage and plan limits.", + "properties": { + "currentPeriodCost": { + "type": "number", + "description": "Total spend in the current billing period in USD.", + "example": 1.25 + }, + "limit": { + "type": "number", + "description": "Maximum allowed spend for the current billing period in USD.", + "example": 50 + }, + "plan": { + "type": "string", + "description": "Your current subscription plan (e.g., free, pro, team).", + "example": "pro" + }, + "isExceeded": { + "type": "boolean", + "description": "Whether the usage limit has been exceeded. Executions may be blocked when true.", + "example": false + } + } + } + } + }, + "RateLimitBucket": { + "type": "object", + "description": "Rate limit status for a specific execution type.", + "properties": { + "requestsPerMinute": { + "type": "integer", + "description": "Maximum number of requests allowed per minute.", + "example": 60 + }, + "maxBurst": { + "type": "integer", + "description": "Maximum number of concurrent requests allowed in a burst.", + "example": 10 + }, + "remaining": { + "type": "integer", + "description": "Number of requests remaining in the current rate limit window.", + "example": 59 + }, + "resetAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the rate limit window resets.", + "example": "2025-06-20T14:16:00Z" + } + } + }, + "JobStatus": { + "type": "object", + "description": "Status of an asynchronous job.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the request was successful.", + "example": true + }, + "taskId": { + "type": "string", + "description": "The unique identifier of the job.", + "example": "job_4a3b2c1d0e" + }, + "status": { + "type": "string", + "enum": ["queued", "processing", "completed", "failed"], + "description": "Current status of the job.", + "example": "completed" + }, + "metadata": { + "type": "object", + "description": "Timing metadata for the job.", + "properties": { + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the job started processing.", + "example": "2025-06-20T14:15:22Z" + }, + "completedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the job completed. Present only when status is completed or failed.", + "example": "2025-06-20T14:15:23Z" + }, + "duration": { + "type": "integer", + "description": "Duration of the job in milliseconds. Present only when status is completed or failed.", + "example": 1250 + } + } + }, + "output": { + "description": "The workflow execution output. Present only when status is completed.", + "type": "object", + "example": { + "result": "Hello, world!" + } + }, + "error": { + "description": "Error details. Present only when status is failed.", + "type": "string", + "example": null + }, + "estimatedDuration": { + "type": "integer", + "description": "Estimated duration in milliseconds. Present only when status is queued or processing.", + "example": 2000 + } + } + }, + "WorkflowExecutionStatus": { + "type": "object", + "description": "Current status of a workflow execution.", + "properties": { + "executionId": { + "type": "string", + "description": "The unique identifier of the execution.", + "example": "9254f1c9-5a11-4a12-91e3-8065293f3609" + }, + "workflowId": { + "type": "string", + "description": "The unique identifier of the workflow.", + "example": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7" + }, + "status": { + "type": "string", + "enum": ["pending", "running", "paused", "completed", "failed", "cancelled"], + "description": "Current normalized lifecycle status. `paused` is set when a row exists in pausedExecutions with status `paused` or `partially_resumed`; otherwise the workflowExecutionLogs row's status field is used.", + "example": "completed" + }, + "trigger": { + "type": "string", + "enum": ["api", "manual", "schedule", "webhook", "chat"], + "description": "What triggered the execution.", + "example": "api" + }, + "level": { + "type": "string", + "enum": ["info", "warning", "error"], + "description": "Log level of the execution.", + "example": "info" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-05-15T19:43:12.189Z" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp when execution ended. Null while the run is in flight.", + "example": "2026-05-15T19:45:45.224Z" + }, + "totalDurationMs": { + "type": "integer", + "nullable": true, + "description": "Total duration of the execution in milliseconds. Null while the run is in flight.", + "example": 153035 + }, + "paused": { + "type": "object", + "nullable": true, + "description": "Pause-state details. Present only when status is `paused`.", + "properties": { + "pausedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was paused.", + "example": "2026-05-15T22:25:57.216Z" + }, + "resumeAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Earliest scheduled resume time across active pause points. Null for human-only pauses.", + "example": "2026-05-16T18:25:57.200Z" + }, + "pauseKind": { + "type": "string", + "enum": ["time", "human"], + "nullable": true, + "description": "What kind of pause the workflow is waiting on.", + "example": "time" + }, + "blockedOnBlockId": { + "type": "string", + "nullable": true, + "description": "The block currently blocking resume.", + "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf" + }, + "pausedExecutionId": { + "type": "string", + "description": "ID of the paused-execution row, useful for cross-referencing with the human-in-the-loop endpoints.", + "example": "438bf05b-bd3c-4011-b78e-b19c112eeb66" + }, + "pausePointCount": { + "type": "integer", + "description": "Total number of pause points recorded for this execution.", + "example": 1 + }, + "resumedCount": { + "type": "integer", + "description": "Number of pause points already resumed.", + "example": 0 + } + } + }, + "cost": { + "type": "object", + "nullable": true, + "description": "Cost summary. Detailed token / model breakdown lives on the /v1/logs detail endpoint.", + "properties": { + "total": { + "type": "number", + "description": "Total cost in USD.", + "example": 0.005 + } + } + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message. Present only when status is `failed`.", + "example": null + }, + "finalOutput": { + "type": "object", + "nullable": true, + "description": "The workflow's final output. Returned only when ?includeOutput=true AND status is `completed`.", + "example": null + }, + "blockOutputs": { + "type": "object", + "nullable": true, + "description": "Per-block outputs keyed by the selector string. Returned only when `?selectedOutputs` is set.", + "additionalProperties": true, + "example": { + "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration": 60000, + "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.status": "completed" + } + } + } + }, + "AuditLogEntry": { + "type": "object", + "description": "An enterprise audit log entry recording an action taken in the workspace.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the audit log entry.", + "example": "audit_2c3d4e5f6g" + }, + "workspaceId": { + "type": "string", + "nullable": true, + "description": "The workspace where the action occurred.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "actorId": { + "type": "string", + "nullable": true, + "description": "The user ID of the person who performed the action.", + "example": "user_abc123" + }, + "actorName": { + "type": "string", + "nullable": true, + "description": "Display name of the person who performed the action.", + "example": "Jane Smith" + }, + "actorEmail": { + "type": "string", + "nullable": true, + "description": "Email address of the person who performed the action.", + "example": "jane@example.com" + }, + "action": { + "type": "string", + "description": "The action that was performed (e.g., workflow.created, member.invited).", + "example": "workflow.deployed" + }, + "resourceType": { + "type": "string", + "description": "The type of resource affected (e.g., workflow, workspace, member).", + "example": "workflow" + }, + "resourceId": { + "type": "string", + "nullable": true, + "description": "The unique identifier of the affected resource.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "resourceName": { + "type": "string", + "nullable": true, + "description": "Display name of the affected resource.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Human-readable description of the action.", + "example": "Deployed workflow Customer Support Agent" + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional context about the action.", + "example": null + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the action occurred.", + "example": "2025-06-20T14:15:22Z" + } + } + }, + "UsageLimits": { + "type": "object", + "description": "Current rate limits, usage, and storage information for the authenticated user.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the request was successful." + }, + "rateLimit": { + "type": "object", + "description": "Rate limit status for workflow executions.", + "properties": { + "sync": { + "description": "Rate limit bucket for synchronous executions.", + "allOf": [ + { + "$ref": "#/components/schemas/RateLimitBucket" + }, + { + "type": "object", + "properties": { + "isLimited": { + "type": "boolean", + "description": "Whether the rate limit has been reached." + } + } + } + ] + }, + "async": { + "description": "Rate limit bucket for asynchronous executions.", + "allOf": [ + { + "$ref": "#/components/schemas/RateLimitBucket" + }, + { + "type": "object", + "properties": { + "isLimited": { + "type": "boolean", + "description": "Whether the rate limit has been reached." + } + } + } + ] + }, + "authType": { + "type": "string", + "description": "The authentication type used (api or manual)." + } + } + }, + "usage": { + "type": "object", + "description": "Current billing period usage.", + "properties": { + "currentPeriodCost": { + "type": "number", + "description": "Total spend in the current billing period in USD." + }, + "limit": { + "type": "number", + "description": "Maximum allowed spend for the current billing period in USD." + }, + "plan": { + "type": "string", + "description": "Your current subscription plan (e.g., free, pro, team)." + } + } + }, + "storage": { + "type": "object", + "description": "File storage usage.", + "properties": { + "usedBytes": { + "type": "integer", + "description": "Total storage used in bytes." + }, + "limitBytes": { + "type": "integer", + "description": "Maximum storage allowed in bytes." + }, + "percentUsed": { + "type": "number", + "description": "Percentage of storage used (0-100)." + } + } + } + } + }, + "FileMetadata": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique file identifier.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "name": { + "type": "string", + "description": "Original filename.", + "example": "data.csv" + }, + "size": { + "type": "integer", + "description": "File size in bytes.", + "example": 1024 + }, + "type": { + "type": "string", + "description": "MIME type of the file.", + "example": "text/csv" + }, + "key": { + "type": "string", + "description": "Storage key for the file.", + "example": "workspace/abc-123/1709571234-xyz-data.csv" + }, + "uploadedBy": { + "type": "string", + "description": "User ID of the uploader." + }, + "uploadedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of when the file was uploaded." + } + } + }, + "KnowledgeBase": { + "type": "object", + "description": "A knowledge base for storing and searching document embeddings.", + "properties": { + "id": { + "type": "string", + "description": "Unique knowledge base identifier." + }, + "name": { + "type": "string", + "description": "Knowledge base name." + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description." + }, + "tokenCount": { + "type": "integer", + "description": "Total token count across all documents." + }, + "embeddingModel": { + "type": "string", + "description": "Embedding model used (e.g. text-embedding-3-small)." + }, + "embeddingDimension": { + "type": "integer", + "description": "Embedding vector dimension." + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfig" + }, + "docCount": { + "type": "integer", + "description": "Number of documents in the knowledge base." + }, + "connectorTypes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Types of connectors attached to this knowledge base." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was last modified." + } + } + }, + "ChunkingConfig": { + "type": "object", + "description": "Configuration for how documents are split into chunks for embedding.", + "properties": { + "maxSize": { + "type": "integer", + "minimum": 100, + "maximum": 4000, + "default": 1024, + "description": "Maximum chunk size in tokens." + }, + "minSize": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "default": 100, + "description": "Minimum chunk size in characters." + }, + "overlap": { + "type": "integer", + "minimum": 0, + "maximum": 500, + "default": 200, + "description": "Overlap between chunks in tokens." + } + } + }, + "KnowledgeDocument": { + "type": "object", + "description": "A document in a knowledge base.", + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier." + }, + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base this document belongs to." + }, + "filename": { + "type": "string", + "description": "Original filename." + }, + "fileSize": { + "type": "integer", + "description": "File size in bytes." + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file." + }, + "processingStatus": { + "type": "string", + "enum": ["pending", "processing", "completed", "failed"], + "description": "Current processing status." + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks created from this document." + }, + "tokenCount": { + "type": "integer", + "description": "Total token count." + }, + "characterCount": { + "type": "integer", + "description": "Total character count." + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded." + } + } + }, + "KnowledgeDocumentDetail": { + "type": "object", + "description": "Detailed document information including processing and connector details.", + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier." + }, + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base this document belongs to." + }, + "filename": { + "type": "string", + "description": "Original filename." + }, + "fileSize": { + "type": "integer", + "description": "File size in bytes." + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file." + }, + "processingStatus": { + "type": "string", + "enum": ["pending", "processing", "completed", "failed"], + "description": "Current processing status." + }, + "processingError": { + "type": "string", + "nullable": true, + "description": "Error message if processing failed." + }, + "processingStartedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When processing started." + }, + "processingCompletedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When processing completed." + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks created." + }, + "tokenCount": { + "type": "integer", + "description": "Total token count." + }, + "characterCount": { + "type": "integer", + "description": "Total character count." + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search." + }, + "connectorId": { + "type": "string", + "nullable": true, + "description": "Connector ID if sourced from an external connector." + }, + "connectorType": { + "type": "string", + "nullable": true, + "description": "Connector type (e.g. google-drive, notion)." + }, + "sourceUrl": { + "type": "string", + "nullable": true, + "description": "Original source URL for connector-sourced documents." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded." + } + } + }, + "SearchResult": { + "type": "object", + "description": "A single search result from knowledge base vector search.", + "properties": { + "documentId": { + "type": "string", + "description": "ID of the source document." + }, + "documentName": { + "type": "string", + "description": "Filename of the source document." + }, + "sourceUrl": { + "type": "string", + "nullable": true, + "description": "URL to the original source document for connector-synced documents (e.g., a Confluence page, Google Doc, or Notion page). Null for documents without an external source." + }, + "content": { + "type": "string", + "description": "The matched chunk content." + }, + "chunkIndex": { + "type": "integer", + "description": "Index of the chunk within the document." + }, + "metadata": { + "type": "object", + "description": "Tag metadata associated with the chunk (display names mapped to values)." + }, + "similarity": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Similarity score (0-1, where 1 is most similar)." + } + } + }, + "TagFilter": { + "type": "object", + "description": "A tag-based filter for knowledge base search.", + "required": ["tagName", "value"], + "properties": { + "tagName": { + "type": "string", + "description": "Display name of the tag to filter by." + }, + "fieldType": { + "type": "string", + "enum": ["text", "number", "date", "boolean"], + "default": "text", + "description": "Data type of the tag field." + }, + "operator": { + "type": "string", + "default": "eq", + "description": "Comparison operator (e.g. eq, neq, gt, lt, gte, lte, contains, between)." + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "description": "Value to filter by." + }, + "valueTo": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ], + "description": "Upper bound value for 'between' operator." + } + } + }, + "PausedExecutionSummary": { + "type": "object", + "description": "Summary of a paused workflow execution.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the paused execution record." + }, + "workflowId": { + "type": "string", + "description": "The workflow this execution belongs to." + }, + "executionId": { + "type": "string", + "description": "The execution that was paused." + }, + "status": { + "type": "string", + "description": "Current status of the paused execution.", + "example": "paused" + }, + "totalPauseCount": { + "type": "integer", + "description": "Total number of pause points in this execution." + }, + "resumedCount": { + "type": "integer", + "description": "Number of pause points that have been resumed." + }, + "pausedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution was paused." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the paused execution record was last updated." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the paused execution will expire and be cleaned up." + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional metadata associated with the paused execution.", + "additionalProperties": true + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of triggers that initiated the original execution." + }, + "pausePoints": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PausePoint" + }, + "description": "List of pause points in the execution." + } + } + }, + "PausePoint": { + "type": "object", + "description": "A point in the workflow where execution has been paused awaiting human input.", + "properties": { + "contextId": { + "type": "string", + "description": "Unique identifier for this pause context. Used when resuming execution." + }, + "blockId": { + "type": "string", + "description": "The block ID where execution paused." + }, + "response": { + "description": "Data returned by the block before pausing, including display data and form fields." + }, + "registeredAt": { + "type": "string", + "format": "date-time", + "description": "When this pause point was registered." + }, + "resumeStatus": { + "type": "string", + "enum": ["paused", "resumed", "failed", "queued", "resuming"], + "description": "Current status of this pause point." + }, + "snapshotReady": { + "type": "boolean", + "description": "Whether the execution snapshot is ready for resumption." + }, + "resumeLinks": { + "type": "object", + "description": "Links for resuming this pause point.", + "properties": { + "apiUrl": { + "type": "string", + "format": "uri", + "description": "API endpoint URL to POST resume input to." + }, + "uiUrl": { + "type": "string", + "format": "uri", + "description": "UI URL for a human to review and approve." + }, + "contextId": { + "type": "string", + "description": "The context ID for this pause point." + }, + "executionId": { + "type": "string", + "description": "The execution ID." + }, + "workflowId": { + "type": "string", + "description": "The workflow ID." + } + } + }, + "queuePosition": { + "type": "integer", + "nullable": true, + "description": "Position in the resume queue, if queued." + }, + "latestResumeEntry": { + "$ref": "#/components/schemas/ResumeQueueEntry", + "nullable": true, + "description": "The most recent resume queue entry for this pause point." + }, + "parallelScope": { + "type": "object", + "description": "Scope information when the pause occurs inside a parallel branch.", + "properties": { + "parallelId": { + "type": "string", + "description": "Identifier of the parallel execution group." + }, + "branchIndex": { + "type": "integer", + "description": "Index of the branch within the parallel group." + }, + "branchTotal": { + "type": "integer", + "description": "Total number of branches in the parallel group." + } + } + }, + "loopScope": { + "type": "object", + "description": "Scope information when the pause occurs inside a loop.", + "properties": { + "loopId": { + "type": "string", + "description": "Identifier of the loop." + }, + "iteration": { + "type": "integer", + "description": "Current loop iteration number." + } + } + } + } + }, + "ResumeQueueEntry": { + "type": "object", + "description": "An entry in the resume execution queue.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for this queue entry." + }, + "pausedExecutionId": { + "type": "string", + "description": "The paused execution this entry belongs to." + }, + "parentExecutionId": { + "type": "string", + "description": "The original execution that was paused." + }, + "newExecutionId": { + "type": "string", + "description": "The new execution ID created for the resume." + }, + "contextId": { + "type": "string", + "description": "The pause context ID being resumed." + }, + "resumeInput": { + "description": "The input provided when resuming." + }, + "status": { + "type": "string", + "description": "Status of this queue entry (e.g., pending, claimed, completed, failed)." + }, + "queuedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the entry was added to the queue." + }, + "claimedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When execution started processing this entry." + }, + "completedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When execution completed." + }, + "failureReason": { + "type": "string", + "nullable": true, + "description": "Reason for failure, if the resume failed." + } + } + }, + "PausedExecutionDetail": { + "type": "object", + "description": "Detailed information about a paused execution, including the execution snapshot and resume queue.", + "allOf": [ + { + "$ref": "#/components/schemas/PausedExecutionSummary" + }, + { + "type": "object", + "properties": { + "executionSnapshot": { + "type": "object", + "description": "Serialized execution state for resumption.", + "properties": { + "snapshot": { + "type": "string", + "description": "Serialized execution snapshot data." + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Trigger IDs from the snapshot." + } + } + }, + "queue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResumeQueueEntry" + }, + "description": "Resume queue entries for this execution." + } + } + } + ] + }, + "PauseContextDetail": { + "type": "object", + "description": "Detailed information about a specific pause context within a paused execution.", + "properties": { + "execution": { + "$ref": "#/components/schemas/PausedExecutionSummary", + "description": "Summary of the parent paused execution." + }, + "pausePoint": { + "$ref": "#/components/schemas/PausePoint", + "description": "The specific pause point for this context." + }, + "queue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResumeQueueEntry" + }, + "description": "Resume queue entries for this context." + }, + "activeResumeEntry": { + "$ref": "#/components/schemas/ResumeQueueEntry", + "nullable": true, + "description": "The currently active resume entry, if any." + } + } + }, + "ResumeResult": { + "type": "object", + "description": "Result of a synchronous resume execution.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the resume execution completed successfully." + }, + "status": { + "type": "string", + "description": "Execution status.", + "enum": ["completed", "failed", "paused", "cancelled"], + "example": "completed" + }, + "executionId": { + "type": "string", + "description": "The new execution ID for the resumed workflow." + }, + "output": { + "type": "object", + "description": "Workflow output from the resumed execution.", + "additionalProperties": true + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message if the execution failed." + }, + "metadata": { + "type": "object", + "description": "Execution timing metadata.", + "properties": { + "duration": { + "type": "integer", + "description": "Total execution duration in milliseconds." + }, + "startTime": { + "type": "string", + "format": "date-time", + "description": "When the resume execution started." + }, + "endTime": { + "type": "string", + "format": "date-time", + "description": "When the resume execution completed." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request parameters. Check the details array for specific validation errors.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message describing the validation failure." + }, + "details": { + "type": "array", + "description": "List of specific validation errors with field-level details.", + "items": { + "type": "object" + } + } + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. You do not have permission to access this resource. For audit log endpoints, this requires an Enterprise subscription and organization admin/owner role.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found. Verify the ID is correct and belongs to your workspace.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration specified in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message with rate limit details." + } + } + } + } + } + }, + "RowsUpdated": { + "description": "Rows updated.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Indicates whether the request was successful." + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation message describing how many rows were updated." + }, + "updatedCount": { + "type": "integer", + "description": "Number of rows that were updated." + }, + "updatedRowIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of IDs for each row that was updated." + } + }, + "description": "Response payload." + } + } + }, + "example": { + "success": true, + "data": { + "message": "Rows updated successfully", + "updatedCount": 2, + "updatedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json new file mode 100644 index 00000000000..402866bc262 --- /dev/null +++ b/apps/docs/openapi-v2-files-audit.json @@ -0,0 +1,1125 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Files & Audit Logs", + "description": "Version 2 of the Sim REST API for the Files and Audit Logs surfaces.\n\n## Conventions (v2)\n\nEvery v2 endpoint shares one response family:\n\n- **Single resource:** `{ \"data\": T }`\n- **List:** `{ \"data\": T[], \"nextCursor\": string | null }`\n- **Error:** `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`\n\n### Cursor pagination\n\nLists use an opaque keyset cursor (Stripe/Slack-style): pass `limit` and `cursor` in, receive `data` and `nextCursor` out. Treat `cursor` as opaque — pass back the `nextCursor` from the previous page verbatim. When `nextCursor` is `null` there are no more results. Total counts are not returned on lists.\n\n### Rate limiting\n\nRate-limit state is carried in response headers, not the body: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (an ISO 8601 timestamp). A throttled request returns `429` with a `Retry-After` header (seconds).\n\n### Authentication\n\nAll endpoints authenticate with the `X-API-Key` header (a personal or workspace API key). Files endpoints are workspace-scoped via the required `workspaceId` query parameter. Audit Logs endpoints are organization-scoped enterprise endpoints and require an Enterprise subscription plus an organization admin or owner role.", + "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" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Files", + "description": "Upload, download, list, and archive workspace files (v2). Workspace-scoped via the required workspaceId query parameter." + }, + { + "name": "Audit Logs", + "description": "Query the organization audit trail (v2). Organization-scoped enterprise endpoints requiring an Enterprise subscription and an organization admin or owner role." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/files": { + "get": { + "operationId": "listFiles", + "summary": "List Files", + "description": "List the active files in a workspace with opaque cursor pagination. Results are ordered by upload time. Pass the `nextCursor` from a previous response to fetch the next page; a `null` `nextCursor` means there are no more results.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files?workspaceId=YOUR_WORKSPACE_ID&limit=100\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of files to return per page. Clamped to the range 1–1000. Defaults to 100.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + } + }, + { + "$ref": "#/components/parameters/Cursor" + } + ], + "responses": { + "200": { + "description": "A page of workspace files.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileListResponse" + }, + "example": { + "data": [ + { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z" + } + ], + "nextCursor": "eyJ1cGxvYWRlZEF0IjoiMjAyNi0wMS0xNVQxMDozMDowMFoiLCJpZCI6IndmX1YxU3RHWFI4ejVqZEhpNkJteVQ5MSJ9" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "uploadFile", + "summary": "Upload File", + "description": "Upload a file to a workspace as `multipart/form-data` with a single `file` field. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the request body is buffered. Maximum file size is 100MB. Duplicate filenames within a workspace are rejected. Returns `201 Created`.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/files?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"file=@/path/to/file.csv\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "requestBody": { + "required": true, + "description": "The file to upload, sent as multipart/form-data.", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "The file to upload. Maximum size is 100MB." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The file was uploaded successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileResponse" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "400": { + "description": "The request was malformed: an invalid `workspaceId` query parameter, a body that is not valid multipart form data, or a missing `file` form field.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "file form field is required" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "description": "A file with the same name already exists in this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "A file with this name already exists in the workspace" + } + } + } + } + }, + "413": { + "description": "The upload exceeds the 100MB file size limit, or the workspace storage limit would be exceeded.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "File size exceeds 100MB limit (142.30MB)" + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/{fileId}": { + "get": { + "operationId": "downloadFile", + "summary": "Download File", + "description": "Download the raw bytes of a file. The success response body is the file content itself — there is no JSON envelope. The actual `Content-Type` reflects the stored file's MIME type (shown here as `application/octet-stream`); `Content-Disposition` and `Content-Length` describe the attachment, and rate-limit state is returned in the `X-RateLimit-*` headers. Lookups are workspace-scoped: a file that belongs to another workspace returns `404`. Error responses still use the canonical v2 JSON error envelope.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files/{fileId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -o downloaded-file.csv" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The raw file content as binary data. The `Content-Type` header reflects the file's stored MIME type.", + "headers": { + "Content-Type": { + "description": "MIME type of the file. Varies per file; defaults to application/octet-stream when unknown.", + "schema": { + "type": "string", + "example": "text/csv" + } + }, + "Content-Disposition": { + "description": "Attachment disposition carrying the (sanitized and RFC 5987 encoded) filename.", + "schema": { + "type": "string", + "example": "attachment; filename=\"data.csv\"; filename*=UTF-8''data.csv" + } + }, + "Content-Length": { + "description": "Size of the file in bytes.", + "schema": { + "type": "string", + "example": "1024" + } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteFile", + "summary": "Delete File", + "description": "Archive (soft delete) a file in a workspace. The operation is workspace-scoped and records its own audit entry. Returns the file id and a `deleted` acknowledgement.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/files/{fileId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The file was archived.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2DeleteFileResponse" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "deleted": true + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "description": "The file could not be archived because of a conflicting state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Failed to delete file" + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/audit-logs": { + "get": { + "operationId": "listAuditLogs", + "summary": "List Audit Logs", + "description": "List audit log entries for the authenticated user's organization with opaque cursor pagination. These are organization-scoped (not workspace-scoped) enterprise endpoints: the caller must belong to an organization with an active Enterprise subscription and hold an admin or owner role — otherwise the request returns `403`. The `ipAddress` and `userAgent` fields are intentionally excluded from entries for privacy.", + "tags": ["Audit Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs?limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "action", + "in": "query", + "required": false, + "description": "Filter by action type (e.g., file.uploaded, workflow.deployed, member.invited).", + "schema": { + "type": "string" + } + }, + { + "name": "resourceType", + "in": "query", + "required": false, + "description": "Filter by resource type (e.g., file, workflow, workspace, member).", + "schema": { + "type": "string" + } + }, + { + "name": "resourceId", + "in": "query", + "required": false, + "description": "Filter by a specific resource ID.", + "schema": { + "type": "string" + } + }, + { + "name": "workspaceId", + "in": "query", + "required": false, + "description": "Filter by a workspace within your organization. Must belong to your organization, otherwise the request returns 400.", + "schema": { + "type": "string" + } + }, + { + "name": "actorId", + "in": "query", + "required": false, + "description": "Filter by the user who performed the action. Must be a member of your organization, otherwise the request returns 400.", + "schema": { + "type": "string" + } + }, + { + "name": "startDate", + "in": "query", + "required": false, + "description": "Only return entries at or after this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "required": false, + "description": "Only return entries at or before this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "includeDeparted", + "in": "query", + "required": false, + "description": "When true, include entries from users who have left the organization. Defaults to false.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of entries to return per page. Must be between 1 and 100. Defaults to 50.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "$ref": "#/components/parameters/Cursor" + } + ], + "responses": { + "200": { + "description": "A page of audit log entries.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2AuditLogListResponse" + }, + "example": { + "data": [ + { + "id": "audit_2c3d4e5f6g", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "actorId": "user_abc123", + "actorName": "Jane Smith", + "actorEmail": "jane@example.com", + "action": "file.uploaded", + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "resourceName": "data.csv", + "description": "Uploaded file \"data.csv\" via API", + "metadata": { + "fileSize": 1024, + "fileType": "text/csv" + }, + "createdAt": "2026-01-15T10:30:00Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "description": "The request was malformed: an invalid query parameter, an `actorId` that is not a member of your organization, or a `workspaceId` that does not belong to your organization.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "actorId is not a member of your organization" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/audit-logs/{id}": { + "get": { + "operationId": "getAuditLog", + "summary": "Get Audit Log", + "description": "Retrieve a single audit log entry by ID, scoped to the authenticated user's organization. Organization-scoped (not workspace-scoped): the caller must belong to an organization with an active Enterprise subscription and hold an admin or owner role — otherwise the request returns `403`. An entry outside your organization returns `404` (existence is not leaked). The `ipAddress` and `userAgent` fields are intentionally excluded for privacy.", + "tags": ["Audit Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique audit log entry identifier.", + "schema": { + "type": "string", + "minLength": 1, + "example": "audit_2c3d4e5f6g" + } + } + ], + "responses": { + "200": { + "description": "The audit log entry.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2AuditLogResponse" + }, + "example": { + "data": { + "id": "audit_2c3d4e5f6g", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "actorId": "user_abc123", + "actorName": "Jane Smith", + "actorEmail": "jane@example.com", + "action": "file.uploaded", + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "resourceName": "data.csv", + "description": "Uploaded file \"data.csv\" via API", + "metadata": { + "fileSize": 1024, + "fileType": "text/csv" + }, + "createdAt": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace.", + "schema": { + "type": "string", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + }, + "FileIdPath": { + "name": "fileId", + "in": "path", + "required": true, + "description": "The unique identifier of the file.", + "schema": { + "type": "string", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + } + }, + "Cursor": { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor. Pass the `nextCursor` value from a previous response to fetch the next page.", + "schema": { + "type": "string" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 100 + } + }, + "X-RateLimit-Remaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 95 + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-01-15T11:00:00Z" + } + } + }, + "schemas": { + "V2File": { + "type": "object", + "description": "A workspace file as exposed by the v2 surface.", + "required": ["id", "name", "size", "type", "key", "uploadedBy", "uploadedAt"], + "properties": { + "id": { + "type": "string", + "description": "Unique file identifier.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "name": { + "type": "string", + "description": "Original filename.", + "example": "data.csv" + }, + "size": { + "type": "integer", + "minimum": 0, + "description": "File size in bytes.", + "example": 1024 + }, + "type": { + "type": "string", + "description": "MIME type of the file.", + "example": "text/csv" + }, + "key": { + "type": "string", + "description": "Storage key for the file.", + "example": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv" + }, + "uploadedBy": { + "type": "string", + "description": "User ID of the uploader.", + "example": "user_abc123" + }, + "uploadedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of when the file was uploaded.", + "example": "2026-01-15T10:30:00Z" + } + } + }, + "V2DeleteFileResult": { + "type": "object", + "description": "Acknowledgement returned by a successful archive (soft delete).", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The unique identifier of the archived file.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Always true on a successful archive." + } + } + }, + "V2AuditLogEntry": { + "type": "object", + "description": "A public enterprise audit log entry. The ipAddress and userAgent fields are intentionally excluded for privacy.", + "required": [ + "id", + "workspaceId", + "actorId", + "actorName", + "actorEmail", + "action", + "resourceType", + "resourceId", + "resourceName", + "description", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the audit log entry.", + "example": "audit_2c3d4e5f6g" + }, + "workspaceId": { + "type": ["string", "null"], + "description": "The workspace where the action occurred, or null for organization-level actions.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "actorId": { + "type": ["string", "null"], + "description": "The user ID of the person who performed the action, or null when not attributable.", + "example": "user_abc123" + }, + "actorName": { + "type": ["string", "null"], + "description": "Display name of the person who performed the action.", + "example": "Jane Smith" + }, + "actorEmail": { + "type": ["string", "null"], + "description": "Email address of the person who performed the action.", + "example": "jane@example.com" + }, + "action": { + "type": "string", + "description": "The action that was performed (e.g., file.uploaded, workflow.deployed).", + "example": "file.uploaded" + }, + "resourceType": { + "type": "string", + "description": "The type of resource affected (e.g., file, workflow, workspace, member).", + "example": "file" + }, + "resourceId": { + "type": ["string", "null"], + "description": "The unique identifier of the affected resource.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "resourceName": { + "type": ["string", "null"], + "description": "Display name of the affected resource.", + "example": "data.csv" + }, + "description": { + "type": ["string", "null"], + "description": "Human-readable description of the action.", + "example": "Uploaded file \"data.csv\" via API" + }, + "metadata": { + "description": "Arbitrary per-action metadata as JSON. The shape varies by action type and may be null for some actions.", + "example": { + "fileSize": 1024, + "fileType": "text/csv" + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the action occurred.", + "example": "2026-01-15T10:30:00Z" + } + } + }, + "V2FileListResponse": { + "type": "object", + "description": "A page of files plus the cursor for the next page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The files in this page.", + "items": { + "$ref": "#/components/schemas/V2File" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more results." + } + } + }, + "V2FileResponse": { + "type": "object", + "description": "A single file resource.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2File" + } + } + }, + "V2DeleteFileResponse": { + "type": "object", + "description": "The result of archiving a file.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2DeleteFileResult" + } + } + }, + "V2AuditLogListResponse": { + "type": "object", + "description": "A page of audit log entries plus the cursor for the next page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The audit log entries in this page.", + "items": { + "$ref": "#/components/schemas/V2AuditLogEntry" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more results." + } + } + }, + "V2AuditLogResponse": { + "type": "object", + "description": "A single audit log entry resource.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2AuditLogEntry" + } + } + }, + "V2Error": { + "type": "object", + "description": "The canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code (e.g., BAD_REQUEST, NOT_FOUND, RATE_LIMITED)." + }, + "message": { + "type": "string", + "description": "Human-readable error message." + }, + "details": { + "description": "Optional structured error context. For validation errors this is an array of field-level issues; for rate limiting it carries the reset timestamp." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request. Inspect `error.message` and the optional `error.details` for specifics.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": ["workspaceId"], + "code": "invalid_type", + "message": "Required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. For Files, the API key lacks access to the workspace. For Audit Logs, this requires an Enterprise subscription and an organization admin or owner role.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Active enterprise subscription required" + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found, or it does not belong to the authorized scope.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "File not found" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer", + "example": 30 + } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-15T11:00:00Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred on the server.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json new file mode 100644 index 00000000000..5c43fd27ff7 --- /dev/null +++ b/apps/docs/openapi-v2-knowledge.json @@ -0,0 +1,1802 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Knowledge Bases", + "description": "The v2 Knowledge Bases API lets you create and manage knowledge bases, upload and inspect documents, and run vector and tag search over your indexed content.\n\n## Conventions\n\nAll endpoints live under the `/api/v2` base path and share a single set of conventions:\n\n- **Authentication** — Send your Sim API key in the `X-API-Key` header on every request. Keys are scoped to a workspace (or are personal keys that target a workspace); `workspaceId` is always required so the request can be tenant-scoped and rate-limited.\n- **Single-resource and mutation responses** return `{ \"data\": ... }`.\n- **List responses** use an opaque-cursor envelope: `{ \"data\": [ ... ], \"nextCursor\": string | null }`. Pass the returned `nextCursor` back as the `cursor` query parameter to fetch the next page. When `nextCursor` is `null` there are no more results. Cursors are opaque — do not parse or construct them.\n- **Errors** use a single envelope: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`. The HTTP status code and the stable `code` field move together (for example `404` ⇄ `NOT_FOUND`).\n- **Rate limiting** — Every response carries the current limiter state in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. A throttled request returns `429` with a `Retry-After` header.", + "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" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Knowledge Bases", + "description": "Create and manage knowledge bases, upload and inspect documents, and run vector and tag search (v2 API)." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/knowledge": { + "get": { + "operationId": "listKnowledgeBases", + "summary": "List Knowledge Bases", + "description": "List all knowledge bases in a workspace. The full bounded per-workspace set is returned as a single page, so `nextCursor` is always `null` today; treat the response as a standard cursor list so pagination can be added later without a contract change.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "Knowledge bases for the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The knowledge bases in the workspace.", + "items": { + "$ref": "#/components/schemas/KnowledgeBase" + } + }, + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createKnowledgeBase", + "summary": "Create Knowledge Base", + "description": "Create a new knowledge base in a workspace. The embedding model and dimension are fixed server-side and cannot be supplied. Returns `201` with the created knowledge base.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Product Documentation\",\n \"description\": \"All product docs and guides\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The knowledge base to create.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKnowledgeBaseBody" + } + } + } + }, + "responses": { + "201": { + "description": "The knowledge base was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + } + ], + "get": { + "operationId": "getKnowledgeBase", + "summary": "Get Knowledge Base", + "description": "Retrieve a single knowledge base by ID. A knowledge base that does not exist, belongs to another workspace, or that the caller cannot read is reported as `404` so cross-workspace existence is never leaked.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The knowledge base.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "operationId": "updateKnowledgeBase", + "summary": "Update Knowledge Base", + "description": "Update a knowledge base's name, description, or chunking config. At least one of `name`, `description`, or `chunkingConfig` must be provided. The target workspace is carried in the request body.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\n \"https://www.sim.ai/api/v2/knowledge/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Updated name\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The fields to update. At least one of name, description, or chunkingConfig is required.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateKnowledgeBaseBody" + } + } + } + }, + "responses": { + "200": { + "description": "The updated knowledge base.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteKnowledgeBase", + "summary": "Delete Knowledge Base", + "description": "Delete a knowledge base and all of its documents. Returns a delete acknowledgement with the id of the removed knowledge base.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/knowledge/{id}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The knowledge base was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/search": { + "post": { + "operationId": "searchKnowledge", + "summary": "Search Knowledge", + "description": "Run vector and/or tag search across one or more knowledge bases. Provide a `query` for semantic vector search, `tagFilters` for structured filtering, or both. At least one of `query` or `tagFilters` is required.\n\nNotes and limits:\n- Tag filters are only supported when searching a single knowledge base.\n- When a `query` is supplied, all targeted knowledge bases must use the same embedding model; otherwise the request is rejected. Search such knowledge bases separately.\n- A text query consumes hosted embedding (and optional rerank) usage; tag-only search is free.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge/search\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"knowledgeBaseIds\": [\"KB_ID\"],\n \"query\": \"How do I reset my password?\",\n \"topK\": 10\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The search request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchBody" + } + } + } + }, + "responses": { + "200": { + "description": "Search results.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request. Returned when neither `query` nor `tagFilters` is provided, when tag filters target more than one knowledge base, when the selected knowledge bases use different embedding models, or when a tag name/value is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "crossModel": { + "summary": "Knowledge bases use different embedding models", + "value": { + "error": { + "code": "BAD_REQUEST", + "message": "Selected knowledge bases use different embedding models and cannot be searched together. Search them separately." + } + } + }, + "multiKbTagFilter": { + "summary": "Tag filters across multiple knowledge bases", + "value": { + "error": { + "code": "BAD_REQUEST", + "message": "Tag filters are only supported when searching a single knowledge base" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "One or more of the requested knowledge bases do not exist or are not accessible from this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Knowledge base not found or access denied" + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}/documents": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "get": { + "operationId": "listKnowledgeDocuments", + "summary": "List Documents", + "description": "List documents in a knowledge base. Supports search, enabled-state filtering, sorting, and cursor pagination. Pass the returned `nextCursor` back as `cursor` to fetch the next page; the total document count is available as `docCount` on the parent knowledge base.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of documents to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor from a previous response's `nextCursor`. Omit for the first page.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against document filenames.", + "schema": { + "type": "string" + } + }, + { + "name": "enabledFilter", + "in": "query", + "required": false, + "description": "Filter documents by their enabled state.", + "schema": { + "type": "string", + "enum": ["all", "enabled", "disabled"], + "default": "all" + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": [ + "filename", + "fileSize", + "tokenCount", + "chunkCount", + "uploadedAt", + "processingStatus", + "enabled" + ], + "default": "uploadedAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } + } + ], + "responses": { + "200": { + "description": "Documents in the knowledge base.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The documents on this page.", + "items": { + "$ref": "#/components/schemas/DocumentSummary" + } + }, + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "uploadKnowledgeDocument", + "summary": "Upload Document", + "description": "Upload a single document to a knowledge base as `multipart/form-data`. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the file body is buffered. The maximum file size is 100 MB. Processing is asynchronous: the document is returned with `processingStatus: \"pending\"` and indexing continues in the background — poll the Get Document endpoint to observe progress.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"file=@/path/to/document.pdf\"" + } + ], + "requestBody": { + "required": true, + "description": "The file to upload.", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "The document file to upload (max 100 MB)." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The document was accepted and queued for processing.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentSummaryEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request. Returned when the body is not valid multipart form data or the required `file` field is missing.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "file form field is required" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "description": "The uploaded file exceeds the 100 MB limit, or the workspace storage limit has been reached.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "File size exceeds 100MB limit (123.45MB)" + } + } + } + } + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}/documents/{documentId}": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/DocumentId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "get": { + "operationId": "getKnowledgeDocument", + "summary": "Get Document", + "description": "Retrieve the full detail for a single document, including processing state and connector provenance.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "The document detail.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteKnowledgeDocument", + "summary": "Delete Document", + "description": "Delete a single document from a knowledge base. Returns a delete acknowledgement with the id of the removed document.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "The document was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "KnowledgeBaseId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + } + }, + "DocumentId": { + "name": "documentId", + "in": "path", + "required": true, + "description": "The unique identifier of the document.", + "schema": { + "type": "string", + "minLength": 1, + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + } + }, + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace that scopes the request.", + "schema": { + "type": "string", + "minLength": 1, + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 60 + } + }, + "RateLimitRemaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 59 + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2025-06-20T14:16:00Z" + } + }, + "RetryAfter": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer", + "example": 30 + } + } + }, + "schemas": { + "NextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more results. Pass it back as the `cursor` query parameter. Do not parse or construct cursors.", + "example": null + }, + "ChunkingConfig": { + "type": "object", + "description": "How documents in this knowledge base are split into chunks before embedding.", + "required": ["maxSize", "minSize", "overlap"], + "additionalProperties": true, + "properties": { + "maxSize": { + "type": "integer", + "description": "Maximum chunk size, in tokens.", + "example": 1024 + }, + "minSize": { + "type": "integer", + "description": "Minimum chunk size, in characters.", + "example": 100 + }, + "overlap": { + "type": "integer", + "description": "Number of overlapping characters between adjacent chunks.", + "example": 200 + }, + "strategy": { + "type": "string", + "description": "Chunking strategy applied during processing.", + "enum": ["auto", "text", "regex", "recursive", "sentence", "token"] + } + } + }, + "ChunkingConfigInput": { + "type": "object", + "description": "Chunking configuration for the knowledge base. Defaults are applied when omitted.", + "properties": { + "maxSize": { + "type": "integer", + "description": "Maximum chunk size, in tokens.", + "minimum": 100, + "maximum": 4000, + "default": 1024 + }, + "minSize": { + "type": "integer", + "description": "Minimum chunk size, in characters.", + "minimum": 1, + "maximum": 2000, + "default": 100 + }, + "overlap": { + "type": "integer", + "description": "Number of overlapping characters between adjacent chunks.", + "minimum": 0, + "maximum": 500, + "default": 200 + } + } + }, + "KnowledgeBase": { + "type": "object", + "description": "A knowledge base: a collection of documents indexed for vector and tag search.", + "required": [ + "id", + "name", + "description", + "tokenCount", + "embeddingModel", + "embeddingDimension", + "chunkingConfig", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique knowledge base identifier.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "name": { + "type": "string", + "description": "Human-readable knowledge base name.", + "example": "Product Documentation" + }, + "description": { + "type": ["string", "null"], + "description": "Optional description of the knowledge base. null when not set.", + "example": "All product docs and guides" + }, + "tokenCount": { + "type": "integer", + "description": "Total number of tokens across all indexed documents.", + "example": 48213 + }, + "embeddingModel": { + "type": "string", + "description": "The embedding model used to index documents in this knowledge base.", + "example": "text-embedding-3-small" + }, + "embeddingDimension": { + "type": "integer", + "description": "The dimensionality of the embedding vectors.", + "example": 1536 + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfig" + }, + "docCount": { + "type": "integer", + "description": "Number of documents in the knowledge base.", + "example": 12 + }, + "connectorTypes": { + "type": "array", + "description": "The set of external connector types that have synced documents into this knowledge base.", + "items": { + "type": "string" + }, + "example": ["notion", "google_drive"] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was created.", + "example": "2025-01-10T09:00:00Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was last modified.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "KnowledgeBaseEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["knowledgeBase"], + "properties": { + "knowledgeBase": { + "$ref": "#/components/schemas/KnowledgeBase" + } + } + } + } + }, + "CreateKnowledgeBaseBody": { + "type": "object", + "description": "Request body for creating a knowledge base.", + "required": ["workspaceId", "name"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace the knowledge base belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable knowledge base name.", + "example": "Product Documentation" + }, + "description": { + "type": "string", + "maxLength": 1000, + "description": "Optional description of the knowledge base.", + "example": "All product docs and guides" + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfigInput" + } + } + }, + "UpdateKnowledgeBaseBody": { + "type": "object", + "description": "Request body for updating a knowledge base. At least one of name, description, or chunkingConfig must be provided.", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace the knowledge base belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "New knowledge base name.", + "example": "Updated Product Documentation" + }, + "description": { + "type": "string", + "maxLength": 1000, + "description": "New description of the knowledge base.", + "example": "Refreshed product docs and guides" + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfigInput" + } + } + }, + "DocumentSummary": { + "type": "object", + "description": "Summary representation of a document, returned in list operations and as the upload acknowledgement.", + "required": [ + "id", + "knowledgeBaseId", + "filename", + "fileSize", + "mimeType", + "processingStatus", + "chunkCount", + "tokenCount", + "characterCount", + "enabled", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier.", + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + }, + "knowledgeBaseId": { + "type": "string", + "description": "The knowledge base this document belongs to.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "filename": { + "type": "string", + "description": "Original filename of the uploaded document.", + "example": "getting-started.pdf" + }, + "fileSize": { + "type": "integer", + "description": "Size of the file in bytes.", + "example": 248913 + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file.", + "example": "application/pdf" + }, + "processingStatus": { + "type": "string", + "description": "Current processing state of the document.", + "enum": ["pending", "processing", "completed", "failed"], + "example": "completed" + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks the document was split into. 0 until processing completes.", + "example": 24 + }, + "tokenCount": { + "type": "integer", + "description": "Total number of tokens extracted from the document.", + "example": 8123 + }, + "characterCount": { + "type": "integer", + "description": "Total number of characters extracted from the document.", + "example": 41205 + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search.", + "example": true + }, + "createdAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "DocumentSummaryEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["document"], + "properties": { + "document": { + "$ref": "#/components/schemas/DocumentSummary" + } + } + } + } + }, + "Document": { + "type": "object", + "description": "Full document detail: the summary fields plus processing state and connector provenance.", + "required": [ + "id", + "knowledgeBaseId", + "filename", + "fileSize", + "mimeType", + "processingStatus", + "chunkCount", + "tokenCount", + "characterCount", + "enabled", + "createdAt", + "processingError", + "processingStartedAt", + "processingCompletedAt", + "connectorId", + "connectorType", + "sourceUrl" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier.", + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + }, + "knowledgeBaseId": { + "type": "string", + "description": "The knowledge base this document belongs to.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "filename": { + "type": "string", + "description": "Original filename of the uploaded document.", + "example": "getting-started.pdf" + }, + "fileSize": { + "type": "integer", + "description": "Size of the file in bytes.", + "example": 248913 + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file.", + "example": "application/pdf" + }, + "processingStatus": { + "type": "string", + "description": "Current processing state of the document.", + "enum": ["pending", "processing", "completed", "failed"], + "example": "completed" + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks the document was split into. 0 until processing completes.", + "example": 24 + }, + "tokenCount": { + "type": "integer", + "description": "Total number of tokens extracted from the document.", + "example": 8123 + }, + "characterCount": { + "type": "integer", + "description": "Total number of characters extracted from the document.", + "example": 41205 + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search.", + "example": true + }, + "createdAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded.", + "example": "2025-06-18T16:45:00Z" + }, + "processingError": { + "type": ["string", "null"], + "description": "Error message if processing failed, otherwise null.", + "example": null + }, + "processingStartedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when processing started, or null.", + "example": "2025-06-18T16:45:05Z" + }, + "processingCompletedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when processing completed, or null.", + "example": "2025-06-18T16:45:42Z" + }, + "connectorId": { + "type": ["string", "null"], + "description": "Identifier of the connector that synced this document, or null for direct uploads.", + "example": null + }, + "connectorType": { + "type": ["string", "null"], + "description": "Type of the connector that synced this document, or null for direct uploads.", + "example": null + }, + "sourceUrl": { + "type": ["string", "null"], + "description": "Original source URL of the document for connector-synced documents, or null.", + "example": null + } + } + }, + "DocumentEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["document"], + "properties": { + "document": { + "$ref": "#/components/schemas/Document" + } + } + } + } + }, + "SearchTagFilter": { + "type": "object", + "description": "A structured tag filter applied to search. Tag filters are only supported when searching a single knowledge base.", + "required": ["tagName", "value"], + "properties": { + "tagName": { + "type": "string", + "description": "The display name of the tag to filter on.", + "example": "category" + }, + "fieldType": { + "type": "string", + "description": "The tag's field type.", + "enum": ["text", "number", "date", "boolean"] + }, + "operator": { + "type": "string", + "description": "Comparison operator. Valid operators depend on the field type.", + "default": "eq", + "example": "eq" + }, + "value": { + "description": "The value to compare against.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "example": "billing" + }, + "valueTo": { + "description": "Upper bound for the `between` operator (number or date).", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + } + }, + "SearchBody": { + "type": "object", + "description": "Request body for knowledge search. At least one of `query` or `tagFilters` must be provided.", + "required": ["workspaceId", "knowledgeBaseIds"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the knowledge bases.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "knowledgeBaseIds": { + "description": "A single knowledge base ID or an array of up to 20 IDs to search.", + "oneOf": [ + { + "type": "string", + "minLength": 1, + "description": "A single knowledge base ID." + }, + { + "type": "array", + "description": "An array of knowledge base IDs.", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "maxItems": 20 + } + ], + "example": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + }, + "query": { + "type": "string", + "description": "The natural-language query for semantic vector search. Required if `tagFilters` is omitted.", + "example": "How do I reset my password?" + }, + "topK": { + "type": "integer", + "description": "Maximum number of results to return.", + "minimum": 1, + "maximum": 100, + "default": 10 + }, + "tagFilters": { + "type": "array", + "description": "Structured tag filters. Only supported when searching a single knowledge base. Required if `query` is omitted.", + "items": { + "$ref": "#/components/schemas/SearchTagFilter" + } + } + } + }, + "SearchResult": { + "type": "object", + "description": "A single search hit (a matching document chunk).", + "required": [ + "documentId", + "documentName", + "sourceUrl", + "content", + "chunkIndex", + "metadata", + "similarity" + ], + "properties": { + "documentId": { + "type": "string", + "description": "Identifier of the document the chunk belongs to.", + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + }, + "documentName": { + "type": ["string", "null"], + "description": "Filename of the source document, or null if unavailable.", + "example": "getting-started.pdf" + }, + "sourceUrl": { + "type": ["string", "null"], + "description": "Original source URL of the document, or null for direct uploads.", + "example": null + }, + "content": { + "type": "string", + "description": "The matching chunk's text content.", + "example": "To reset your password, open Settings and choose \"Security\"." + }, + "chunkIndex": { + "type": "integer", + "description": "Zero-based index of the chunk within its document.", + "example": 3 + }, + "metadata": { + "type": "object", + "description": "The document's tag values keyed by tag display name. Values are user-defined and may be strings, numbers, booleans, or dates.", + "additionalProperties": true, + "example": { + "category": "billing", + "priority": 2 + } + }, + "similarity": { + "type": "number", + "description": "Similarity score in the range 0–1 for vector search (higher is more similar). 1 for tag-only matches.", + "example": 0.8423 + } + } + }, + "SearchEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["results", "query", "knowledgeBaseIds", "topK", "totalResults"], + "properties": { + "results": { + "type": "array", + "description": "The matching chunks, ordered by relevance.", + "items": { + "$ref": "#/components/schemas/SearchResult" + } + }, + "query": { + "type": "string", + "description": "The query that was executed (empty string for tag-only search).", + "example": "How do I reset my password?" + }, + "knowledgeBaseIds": { + "type": "array", + "description": "The knowledge base IDs that were searched.", + "items": { + "type": "string" + }, + "example": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + }, + "topK": { + "type": "integer", + "description": "The maximum number of results requested.", + "example": 10 + }, + "totalResults": { + "type": "integer", + "description": "The number of results returned.", + "example": 4 + } + } + } + } + }, + "DeleteEnvelope": { + "type": "object", + "description": "Delete acknowledgement.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The id of the resource that was deleted.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "deleted": { + "type": "boolean", + "description": "Always true.", + "enum": [true], + "example": true + } + } + } + } + }, + "Error": { + "type": "object", + "description": "The canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code.", + "enum": [ + "BAD_REQUEST", + "UNAUTHORIZED", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "USAGE_LIMIT_EXCEEDED", + "LOCKED", + "RATE_LIMITED", + "INTERNAL_ERROR" + ] + }, + "message": { + "type": "string", + "description": "Human-readable description of the error." + }, + "details": { + "description": "Optional structured context for the error, such as field-level validation issues." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": "workspaceId", + "message": "workspaceId query parameter is required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "The API key is missing or invalid. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "The authenticated caller does not have access to the requested workspace or resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } + } + }, + "NotFound": { + "description": "The requested resource does not exist or is not accessible from this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Knowledge base not found" + } + } + } + } + }, + "Conflict": { + "description": "The request conflicts with the current state of the resource (for example, a resource with the same name already exists).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Resource already exists" + } + } + } + } + }, + "UsageLimitExceeded": { + "description": "The workspace has exceeded its usage or billing limits. Upgrade the plan to continue.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "USAGE_LIMIT_EXCEEDED", + "message": "Usage limit exceeded. Please upgrade your plan to continue." + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request payload exceeds the allowed size, or the workspace storage limit has been reached.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Storage limit exceeded" + } + } + } + } + }, + "UnsupportedMediaType": { + "description": "The uploaded file's MIME type or extension is not supported.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNSUPPORTED_MEDIA_TYPE", + "message": "Unsupported file type" + } + } + } + } + }, + "RateLimited": { + "description": "The rate limit has been exceeded. Retry after the period indicated by the Retry-After header.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/RetryAfter" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2025-06-20T14:16:00Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred on the server.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json new file mode 100644 index 00000000000..4631df64376 --- /dev/null +++ b/apps/docs/openapi-v2-logs.json @@ -0,0 +1,1065 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Logs", + "description": "Version 2 of the Sim API for workflow execution logs. v2 standardizes every response on a single envelope: a single resource returns `{ data }`, a list returns `{ data, nextCursor }`, and an error returns `{ error: { code, message, details? } }`. Lists use opaque cursor pagination (`limit` + `cursor` in, `nextCursor` out). Rate-limit state is carried in the `X-RateLimit-*` response headers rather than the body. Authenticate every request with the `X-API-Key` header.", + "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" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "security": [ + { + "apiKey": [] + } + ], + "tags": [ + { + "name": "Logs", + "description": "Query workflow execution logs, retrieve a single log entry, and fetch the full execution state snapshot for a run." + } + ], + "paths": { + "/api/v2/logs": { + "get": { + "operationId": "listLogs", + "summary": "List Logs", + "description": "List workflow execution logs for a workspace with filtering and opaque cursor pagination. Returns `{ data, nextCursor }`. By default (`details=basic`) each entry contains summary fields only; pass `details=full` to include the per-execution `workflow` summary, and additionally `includeFinalOutput=true` / `includeTraceSpans=true` to materialize `finalOutput` / `traceSpans` on each entry.", + "tags": ["Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "workflowIds", + "in": "query", + "description": "Comma-separated list of workflow IDs to filter by. Only logs from these workflows are returned.", + "schema": { + "type": "string" + }, + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36,8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + { + "name": "folderIds", + "in": "query", + "description": "Comma-separated list of folder IDs. Returns logs for all workflows within these folders.", + "schema": { + "type": "string" + } + }, + { + "name": "triggers", + "in": "query", + "description": "Comma-separated trigger types to filter by (e.g. api, webhook, schedule, manual, chat).", + "schema": { + "type": "string" + }, + "example": "api,schedule" + }, + { + "name": "level", + "in": "query", + "description": "Filter logs by severity level. info for successful executions, error for failed ones.", + "schema": { + "type": "string", + "enum": ["info", "error"] + } + }, + { + "name": "startDate", + "in": "query", + "description": "Only return logs started at or after this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "description": "Only return logs started at or before this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "executionId", + "in": "query", + "description": "Filter by an exact execution ID. Useful for looking up a specific run.", + "schema": { + "type": "string" + } + }, + { + "name": "minDurationMs", + "in": "query", + "description": "Only return logs where total execution duration was at least this many milliseconds.", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "maxDurationMs", + "in": "query", + "description": "Only return logs where total execution duration was at most this many milliseconds.", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "minCost", + "in": "query", + "description": "Only return logs where execution cost was at least this amount in USD.", + "schema": { + "type": "number", + "minimum": 0 + } + }, + { + "name": "maxCost", + "in": "query", + "description": "Only return logs where execution cost was at most this amount in USD.", + "schema": { + "type": "number", + "minimum": 0 + } + }, + { + "name": "model", + "in": "query", + "description": "Filter by the AI model used during execution (e.g., gpt-4o, claude-sonnet-4-20250514).", + "schema": { + "type": "string" + } + }, + { + "name": "details", + "in": "query", + "description": "Response detail level. basic returns summary fields only. full additionally includes the per-entry workflow summary and enables the includeFinalOutput / includeTraceSpans materialization flags.", + "schema": { + "type": "string", + "enum": ["basic", "full"], + "default": "basic" + } + }, + { + "name": "includeTraceSpans", + "in": "query", + "description": "When true, includes block-level execution trace spans on each entry. Only applies when details=full.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "includeFinalOutput", + "in": "query", + "description": "When true, includes the workflow's final output on each entry. Only applies when details=full.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of log entries to return per page. Values are clamped to the range 1–1000.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + } + }, + { + "name": "cursor", + "in": "query", + "description": "Opaque pagination cursor returned from a previous request's nextCursor field. Omit to fetch the first page.", + "schema": { + "type": "string" + } + }, + { + "name": "order", + "in": "query", + "description": "Sort order by execution start time. desc returns newest first.", + "schema": { + "type": "string", + "enum": ["desc", "asc"], + "default": "desc" + } + } + ], + "responses": { + "200": { + "description": "A page of execution logs matching the filter criteria.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "Log entries for the current page.", + "items": { + "$ref": "#/components/schemas/LogListItem" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for fetching the next page. null when there are no more results." + } + } + }, + "example": { + "data": [ + { + "id": "log_7x8y9z0a1b", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "deploymentVersionId": "dep_2c4e6a8b0d1f", + "level": "info", + "trigger": "api", + "startedAt": "2026-01-15T10:30:00.000Z", + "endedAt": "2026-01-15T10:30:01.250Z", + "totalDurationMs": 1250, + "cost": { + "total": 0.0032 + }, + "files": null + } + ], + "nextCursor": "eyJzdGFydGVkQXQiOiIyMDI2LTAxLTE1VDEwOjMwOjAwLjAwMFoiLCJpZCI6ImxvZ183eDh5OXowYTFiIn0=" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/logs/{id}": { + "get": { + "operationId": "getLog", + "summary": "Get Log", + "description": "Retrieve a single log entry by its ID, including the workflow metadata captured at execution time, the materialized execution data, and the cost summary. Returns `{ data }`.", + "tags": ["Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the log entry.", + "schema": { + "type": "string", + "example": "log_7x8y9z0a1b" + } + } + ], + "responses": { + "200": { + "description": "The requested log entry with full execution data and cost summary.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/LogDetail" + } + } + }, + "example": { + "data": { + "id": "log_7x8y9z0a1b", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "level": "info", + "trigger": "api", + "startedAt": "2026-01-15T10:30:00.000Z", + "endedAt": "2026-01-15T10:30:01.250Z", + "totalDurationMs": 1250, + "files": null, + "workflow": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": null, + "userId": "usr_1a2b3c4d5e", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "createdAt": "2025-01-10T09:00:00.000Z", + "updatedAt": "2025-06-18T16:45:00.000Z", + "deleted": false + }, + "executionData": { + "traceSpans": [], + "finalOutput": { + "result": "Hello, world!" + } + }, + "cost": { + "total": 0.0032 + }, + "createdAt": "2026-01-15T10:30:00.000Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/logs/executions/{executionId}": { + "get": { + "operationId": "getExecution", + "summary": "Get Execution", + "description": "Retrieve the full execution state snapshot for a run: the workflow state captured at execution time plus execution metadata (trigger, timing, and cost). Returns `{ data }`.", + "tags": ["Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/executions/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique execution identifier.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "The full execution state snapshot with workflow state and metadata.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/Execution" + } + } + }, + "example": { + "data": { + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workflowState": { + "blocks": {}, + "edges": [], + "loops": {}, + "parallels": {} + }, + "executionMetadata": { + "trigger": "api", + "startedAt": "2026-01-15T10:30:00.000Z", + "endedAt": "2026-01-15T10:30:01.250Z", + "totalDurationMs": 1250, + "cost": { + "total": 0.0032 + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "WorkspaceId": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "The unique identifier of the workspace whose logs to query." + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "Maximum number of requests allowed in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "X-RateLimit-Remaining": { + "description": "Number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp when the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "schemas": { + "Cost": { + "type": ["object", "null"], + "description": "Aggregate execution cost in USD. null when no cost was recorded for the run.", + "required": ["total"], + "properties": { + "total": { + "type": "number", + "description": "Total cost of the execution in USD.", + "example": 0.0032 + } + } + }, + "LogWorkflowSummary": { + "type": "object", + "description": "Workflow summary captured at execution time. Present on a list entry only when details=full.", + "required": ["id", "name", "description", "deleted"], + "properties": { + "id": { + "type": ["string", "null"], + "description": "The workflow's identifier. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Workflow name. Falls back to \"Deleted Workflow\" when the workflow no longer exists.", + "example": "Customer Support Agent" + }, + "description": { + "type": ["string", "null"], + "description": "Workflow description, or null if none was set.", + "example": "Routes incoming support tickets and drafts responses" + }, + "deleted": { + "type": "boolean", + "description": "Whether the workflow has since been deleted.", + "example": false + } + } + }, + "LogWorkflowDetail": { + "type": "object", + "description": "Full workflow metadata captured at execution time.", + "required": [ + "id", + "name", + "description", + "folderId", + "userId", + "workspaceId", + "createdAt", + "updatedAt", + "deleted" + ], + "properties": { + "id": { + "type": ["string", "null"], + "description": "The workflow's identifier. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Workflow name. Falls back to \"Deleted Workflow\" when the workflow no longer exists.", + "example": "Customer Support Agent" + }, + "description": { + "type": ["string", "null"], + "description": "Workflow description, or null if none was set.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": ["string", "null"], + "description": "The folder the workflow belongs to. null if at the workspace root or the workflow is gone.", + "example": null + }, + "userId": { + "type": ["string", "null"], + "description": "The user that owns the workflow. null if the workflow is gone.", + "example": "usr_1a2b3c4d5e" + }, + "workspaceId": { + "type": ["string", "null"], + "description": "The workspace the workflow belongs to. null if the workflow is gone.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "createdAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created. null if the workflow is gone.", + "example": "2025-01-10T09:00:00.000Z" + }, + "updatedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified. null if the workflow is gone.", + "example": "2025-06-18T16:45:00.000Z" + }, + "deleted": { + "type": "boolean", + "description": "Whether the workflow has since been deleted.", + "example": false + } + } + }, + "LogListItem": { + "type": "object", + "description": "Summary of a single workflow execution log entry returned by the list endpoint.", + "required": [ + "id", + "workflowId", + "executionId", + "deploymentVersionId", + "level", + "trigger", + "startedAt", + "endedAt", + "totalDurationMs", + "cost", + "files" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": ["string", "null"], + "description": "The workflow that was executed. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier for this run.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "deploymentVersionId": { + "type": ["string", "null"], + "description": "The deployment version that produced this run. null for runs not tied to a deployment.", + "example": "dep_2c4e6a8b0d1f" + }, + "level": { + "type": "string", + "description": "Log severity. info for successful executions, error for failures.", + "example": "info" + }, + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-01-15T10:30:00.000Z" + }, + "endedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.", + "example": "2026-01-15T10:30:01.250Z" + }, + "totalDurationMs": { + "type": ["integer", "null"], + "description": "Total execution duration in milliseconds. null if the run has not finished.", + "example": 1250 + }, + "cost": { + "$ref": "#/components/schemas/Cost" + }, + "files": { + "type": ["array", "null"], + "description": "Attachment metadata for files produced during the run. null when the run produced no files.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "workflow": { + "allOf": [ + { + "$ref": "#/components/schemas/LogWorkflowSummary" + } + ], + "description": "Workflow summary. Present only when details=full." + }, + "finalOutput": { + "type": "object", + "additionalProperties": true, + "description": "The workflow's final output. The shape depends on the workflow. Present only when details=full and includeFinalOutput=true." + }, + "traceSpans": { + "type": "array", + "description": "Block-level execution trace spans with timing, inputs, and outputs. Present only when details=full and includeTraceSpans=true.", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "LogDetail": { + "type": "object", + "description": "Detailed log entry with full workflow metadata, materialized execution data, and cost summary.", + "required": [ + "id", + "workflowId", + "executionId", + "level", + "trigger", + "startedAt", + "endedAt", + "totalDurationMs", + "files", + "workflow", + "executionData", + "cost", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": ["string", "null"], + "description": "The workflow that was executed. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier for this run.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "level": { + "type": "string", + "description": "Log severity. info for successful executions, error for failures.", + "example": "info" + }, + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-01-15T10:30:00.000Z" + }, + "endedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.", + "example": "2026-01-15T10:30:01.250Z" + }, + "totalDurationMs": { + "type": ["integer", "null"], + "description": "Total execution duration in milliseconds. null if the run has not finished.", + "example": 1250 + }, + "files": { + "type": ["array", "null"], + "description": "Attachment metadata for files produced during the run. null when the run produced no files.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "workflow": { + "$ref": "#/components/schemas/LogWorkflowDetail" + }, + "executionData": { + "type": "object", + "additionalProperties": true, + "description": "Materialized execution trace for this run (block states, trace spans, and final output). Large blobs stored externally are resolved inline.", + "properties": { + "traceSpans": { + "type": "array", + "description": "Block-level execution traces with timing, inputs, and outputs.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "finalOutput": { + "type": "object", + "additionalProperties": true, + "description": "The workflow's final output after all blocks completed." + } + } + }, + "cost": { + "$ref": "#/components/schemas/Cost" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the log entry was recorded.", + "example": "2026-01-15T10:30:00.000Z" + } + } + }, + "Execution": { + "type": "object", + "description": "Full execution state snapshot: the workflow state at execution time plus execution metadata.", + "required": ["executionId", "workflowId", "workflowState", "executionMetadata"], + "properties": { + "executionId": { + "type": "string", + "description": "The unique identifier for this execution.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "workflowId": { + "type": ["string", "null"], + "description": "The workflow that was executed. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "workflowState": { + "type": "object", + "additionalProperties": true, + "description": "Snapshot of the workflow configuration at the time of execution.", + "properties": { + "blocks": { + "type": "object", + "additionalProperties": true, + "description": "Map of block IDs to their configuration and state during execution." + }, + "edges": { + "type": "array", + "description": "Connections between blocks defining the execution flow.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "loops": { + "type": "object", + "additionalProperties": true, + "description": "Loop configurations defining iterative execution patterns." + }, + "parallels": { + "type": "object", + "additionalProperties": true, + "description": "Parallel execution group configurations." + } + } + }, + "executionMetadata": { + "type": "object", + "description": "Metadata about the execution including trigger, timing, and cost.", + "required": ["trigger", "startedAt", "endedAt", "totalDurationMs", "cost"], + "properties": { + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-01-15T10:30:00.000Z" + }, + "endedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.", + "example": "2026-01-15T10:30:01.250Z" + }, + "totalDurationMs": { + "type": ["integer", "null"], + "description": "Total execution duration in milliseconds. null if the run has not finished.", + "example": 1250 + }, + "cost": { + "$ref": "#/components/schemas/Cost" + } + } + } + } + }, + "Error": { + "type": "object", + "description": "Canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Machine-readable error code (e.g., BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, RATE_LIMITED, INTERNAL_ERROR).", + "example": "NOT_FOUND" + }, + "message": { + "type": "string", + "description": "Human-readable error message.", + "example": "Log not found" + }, + "details": { + "description": "Optional structured details about the error (e.g., field-level validation issues or rate-limit reset info). Present only on some errors." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request parameters. Inspect error.details for field-level validation issues.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": "workspaceId", + "message": "Workspace ID is required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } + } + } + } + }, + "Forbidden": { + "description": "The API key is authenticated but not authorized for the requested workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "API key is not authorized for this workspace" + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found. An authorization failure on a single resource is also reported as 404 so resource existence is not leaked.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Log not found" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-15T10:31:00.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred while processing the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json new file mode 100644 index 00000000000..fa3daf0cd97 --- /dev/null +++ b/apps/docs/openapi-v2-tables.json @@ -0,0 +1,2339 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim Tables API v2", + "description": "Version 2 of the Sim Tables API for managing tables, their column schemas, and rows of structured data. v2 standardizes every endpoint on a single response family: a single resource is returned as `{ data }`, lists are returned as `{ data, nextCursor }` with opaque cursor pagination, and errors are returned as `{ error: { code, message, details? } }`. Rate-limit state is carried in `X-RateLimit-*` response headers. Authenticate every request with the `X-API-Key` header. Row `data` is always 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" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Tables", + "description": "Manage tables, columns, and rows for structured data storage (v2 API)." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/tables": { + "get": { + "operationId": "listTables", + "summary": "List Tables", + "description": "List all tables in a workspace. Returns the full bounded set of tables for the workspace as a single page, so `nextCursor` is always null.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The tables in the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableListEnvelope" + }, + "example": { + "data": [ + { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "contacts", + "description": "Customer contact records", + "schema": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + }, + { + "id": "col_d4e5f6", + "name": "name", + "type": "string", + "required": true + } + ] + }, + "rowCount": 2, + "maxRows": 100000, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createTable", + "summary": "Create Table", + "description": "Create a new table with a typed column schema. The schema must contain between 1 and 50 columns.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"contacts\",\n \"description\": \"Customer contacts\",\n \"schema\": {\n \"columns\": [\n { \"name\": \"email\", \"type\": \"string\", \"required\": true, \"unique\": true },\n { \"name\": \"name\", \"type\": \"string\", \"required\": true },\n { \"name\": \"age\", \"type\": \"number\" }\n ]\n }\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The table name, optional description, column schema, and target workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTableBody" + } + } + } + }, + "responses": { + "201": { + "description": "The table was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableEnvelope" + }, + "example": { + "data": { + "table": { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "contacts", + "description": "Customer contacts", + "schema": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + }, + { + "id": "col_d4e5f6", + "name": "name", + "type": "string", + "required": true + }, + { + "id": "col_g7h8i9", + "name": "age", + "type": "number", + "required": false, + "unique": false + } + ] + }, + "rowCount": 0, + "maxRows": 100000, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}": { + "get": { + "operationId": "getTable", + "summary": "Get Table", + "description": "Get a single table's metadata and column schema.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The requested table.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTable", + "summary": "Delete Table", + "description": "Archive a table. Returns the id of the archived table.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The table was archived.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteTableEnvelope" + }, + "example": { + "data": { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/columns": { + "post": { + "operationId": "addTableColumn", + "summary": "Add Column", + "description": "Add a column to the table schema. Returns the table's full column list after the change.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"column\": {\n \"name\": \"phone\",\n \"type\": \"string\",\n \"required\": false,\n \"unique\": false\n }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and the column definition to add.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddColumnBody" + } + } + } + }, + "responses": { + "200": { + "description": "The column was added.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnsEnvelope" + }, + "example": { + "data": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + }, + { + "id": "col_d4e5f6", + "name": "name", + "type": "string", + "required": true, + "unique": false + }, + { + "id": "col_x9y8z7", + "name": "phone", + "type": "string", + "required": false, + "unique": false + } + ] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateTableColumn", + "summary": "Update Column", + "description": "Update a column by name — rename it, change its type, or toggle its required/unique constraints. Provide at least one field in `updates`. Returns the table's full column list after the change.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"columnName\": \"phone\",\n \"updates\": {\n \"name\": \"phone_number\",\n \"required\": true\n }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace, the current column name, and the fields to change.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateColumnBody" + } + } + } + }, + "responses": { + "200": { + "description": "The column was updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnsEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableColumn", + "summary": "Delete Column", + "description": "Delete a column from the table schema by name. A table must always keep at least one column. Returns the table's full column list after the change.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"columnName\": \"phone_number\"\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and the name of the column to delete.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteColumnBody" + } + } + } + }, + "responses": { + "200": { + "description": "The column was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnsEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows": { + "get": { + "operationId": "listTableRows", + "summary": "List Rows", + "description": "Query rows from a table with optional filtering, sorting, and cursor pagination. `filter` and `sort` are passed as JSON-encoded query parameters and key on column names. Pagination uses an opaque cursor: pass the `nextCursor` from a previous response to fetch the next page; `nextCursor` is null on the final page. Total row count is available as `rowCount` on the table resource.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/FilterQuery" + }, + { + "$ref": "#/components/parameters/SortQuery" + }, + { + "$ref": "#/components/parameters/LimitQuery" + }, + { + "$ref": "#/components/parameters/CursorQuery" + } + ], + "responses": { + "200": { + "description": "Rows matching the query.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowListEnvelope" + }, + "example": { + "data": [ + { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "email": "jane@example.com", + "name": "Jane Doe", + "age": 30 + }, + "position": 0, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + ], + "nextCursor": "eyJvZmZzZXQiOjUwfQ==" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createTableRows", + "summary": "Create Rows", + "description": "Insert one or many rows. Send a single-row body (`{ data }`) to insert one row, or a batch body (`{ rows }`) to insert up to 1000 rows in one request. The response shape mirrors the request: a single insert returns `{ data: { row } }`, a batch insert returns `{ data: { rows, insertedCount } }`. Row `data` is keyed by column name.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": {\n \"email\": \"user@example.com\",\n \"name\": \"Jane Doe\"\n }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "Either a single-row payload or a batch payload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRowsBody" + }, + "examples": { + "single": { + "summary": "Insert a single row", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "data": { + "email": "user@example.com", + "name": "Jane Doe" + } + } + }, + "batch": { + "summary": "Insert multiple rows", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "rows": [ + { + "email": "a@example.com", + "name": "Ada" + }, + { + "email": "b@example.com", + "name": "Babbage" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The row(s) were inserted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRowsResponse" + }, + "examples": { + "single": { + "summary": "Single insert response", + "value": { + "data": { + "row": { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "email": "user@example.com", + "name": "Jane Doe" + }, + "position": 0, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + } + } + }, + "batch": { + "summary": "Batch insert response", + "value": { + "data": { + "rows": [ + { + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "data": { + "email": "a@example.com", + "name": "Ada" + }, + "position": 0, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + }, + { + "id": "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85", + "data": { + "email": "b@example.com", + "name": "Babbage" + }, + "position": 1, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + ], + "insertedCount": 2 + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "operationId": "updateTableRows", + "summary": "Update Rows by Filter", + "description": "Bulk-update every row matching a filter, applying the same partial `data` patch to each. The filter must contain at least one condition. `updatedRowIds` is always returned (empty when nothing matched).", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"filter\": { \"status\": \"pending\" },\n \"data\": { \"status\": \"active\" }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace, a non-empty filter, the patch data, and an optional row cap.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRowsByFilterBody" + } + } + } + }, + "responses": { + "200": { + "description": "The matching rows were updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRowsEnvelope" + }, + "example": { + "data": { + "updatedCount": 3, + "updatedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85", + "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + ] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableRows", + "summary": "Delete Rows", + "description": "Delete rows in bulk, either by a non-empty filter or by an explicit list of row ids. Provide exactly one of `filter` or `rowIds`. For id-based deletes the response also reports `requestedCount` and any `missingRowIds`; these fields are omitted for filter-based deletes.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"rowIds\": [\"row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93\", \"row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85\"]\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and either a non-empty filter or an explicit list of row ids.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRowsBody" + }, + "examples": { + "byIds": { + "summary": "Delete specific rows by id", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "rowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] + } + }, + "byFilter": { + "summary": "Delete rows matching a filter", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "filter": { + "status": "archived" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The rows were deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRowsEnvelope" + }, + "examples": { + "byIds": { + "summary": "Id-based delete response", + "value": { + "data": { + "deletedCount": 2, + "deletedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ], + "requestedCount": 2, + "missingRowIds": [] + } + } + }, + "byFilter": { + "summary": "Filter-based delete response", + "value": { + "data": { + "deletedCount": 5, + "deletedRowIds": ["row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93"] + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/{rowId}": { + "get": { + "operationId": "getTableRow", + "summary": "Get Row", + "description": "Get a single row by id.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The requested row.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateTableRow", + "summary": "Update Row", + "description": "Partially update a single row by id. The `data` patch is keyed by column name and merges into the existing row.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": { \"name\": \"Updated Name\" }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and the partial row data to apply.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRowBody" + } + } + } + }, + "responses": { + "200": { + "description": "The row was updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableRow", + "summary": "Delete Row", + "description": "Delete a single row by id. Returns `deletedCount` and `deletedRowIds`, mirroring the bulk delete shape.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The row was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRowEnvelope" + }, + "example": { + "data": { + "deletedCount": 1, + "deletedRowIds": ["row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07"] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/upsert": { + "post": { + "operationId": "upsertTableRow", + "summary": "Upsert Row", + "description": "Insert a row, or update the existing row that conflicts on a unique column. When `conflictTarget` is omitted the server resolves the conflict against the table's single unique column. The response reports whether the row was inserted or updated.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/upsert\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": { \"email\": \"user@example.com\", \"name\": \"John\" },\n \"conflictTarget\": \"email\"\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace, the row data, and an optional unique column to resolve the conflict against.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertRowBody" + } + } + } + }, + "responses": { + "200": { + "description": "The row was inserted or updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertRowEnvelope" + }, + "example": { + "data": { + "row": { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "email": "user@example.com", + "name": "John" + }, + "position": 0, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + }, + "operation": "insert" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "TableId": { + "name": "tableId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "description": "The unique identifier of the table." + }, + "RowId": { + "name": "rowId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "description": "The unique identifier of the row." + }, + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "The unique identifier of the workspace that owns the table." + }, + "FilterQuery": { + "name": "filter", + "in": "query", + "required": false, + "description": "JSON-encoded filter object keyed by column name. Supports equality ({\"status\": \"active\"}), comparison operators ({\"age\": {\"$gt\": 18}}), and $and/$or composition.", + "schema": { + "type": "string" + } + }, + "SortQuery": { + "name": "sort", + "in": "query", + "required": false, + "description": "JSON-encoded sort object mapping column name to direction. Example: {\"created_at\": \"desc\"}.", + "schema": { + "type": "string" + } + }, + "LimitQuery": { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum rows to return (1-1000, default 100).", + "schema": { + "type": "integer", + "default": 100, + "minimum": 1, + "maximum": 1000 + } + }, + "CursorQuery": { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor. Pass the `nextCursor` from a previous response to fetch the next page. Omit for the first page.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "Maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "RateLimitRemaining": { + "description": "Number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + "RetryAfter": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "schemas": { + "V2Error": { + "type": "object", + "description": "Canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Machine-readable error code.", + "example": "BAD_REQUEST" + }, + "message": { + "type": "string", + "description": "Human-readable error message." + }, + "details": { + "description": "Optional structured error details, such as per-field validation issues." + } + } + } + } + }, + "Column": { + "type": "object", + "description": "A column definition in a table schema.", + "required": ["name", "type"], + "properties": { + "id": { + "type": "string", + "description": "Stable server-assigned column id. May be absent on legacy columns created before id backfill.", + "example": "col_a1b2c3" + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "email" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + }, + "workflowGroupId": { + "type": "string", + "description": "Set when the column is the output of a workflow group." + } + } + }, + "ColumnInput": { + "type": "object", + "description": "Column definition supplied when creating a table or adding a column.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "email" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + } + } + }, + "Table": { + "type": "object", + "description": "A user-defined table with a typed column schema.", + "required": [ + "id", + "name", + "description", + "schema", + "rowCount", + "maxRows", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique table identifier.", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "name": { + "type": "string", + "description": "Table name.", + "example": "contacts" + }, + "description": { + "type": ["string", "null"], + "description": "Optional description of the table. Null when not set.", + "example": "Customer contact records" + }, + "schema": { + "type": "object", + "description": "Table schema definition.", + "required": ["columns"], + "properties": { + "columns": { + "type": "array", + "description": "Array of column definitions for the table.", + "items": { + "$ref": "#/components/schemas/Column" + } + } + } + }, + "rowCount": { + "type": "integer", + "description": "Current number of rows in the table." + }, + "maxRows": { + "type": "integer", + "description": "Maximum rows allowed by the current billing plan." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was last modified." + } + } + }, + "RowData": { + "type": "object", + "additionalProperties": true, + "description": "Row cells keyed by column name. Each value is typed per its column definition.", + "example": { + "email": "jane@example.com", + "name": "Jane Doe", + "age": 30 + } + }, + "Row": { + "type": "object", + "description": "A single row in a table.", + "required": ["id", "data", "position", "createdAt", "updatedAt"], + "properties": { + "id": { + "type": "string", + "description": "Unique row identifier.", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "position": { + "type": "integer", + "description": "Row's position/order in the table." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was last modified." + } + } + }, + "Filter": { + "type": "object", + "additionalProperties": true, + "minProperties": 1, + "description": "Filter object keyed by column name. Supports equality ({\"status\": \"active\"}), comparison operators ({\"age\": {\"$gt\": 18}}), and $and/$or composition. Must contain at least one condition.", + "example": { + "status": "active" + } + }, + "CreateTableBody": { + "type": "object", + "description": "Payload to create a new table.", + "required": ["workspaceId", "name", "schema"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that will own the table." + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 128, + "description": "Table name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "contacts" + }, + "description": { + "type": "string", + "maxLength": 500, + "description": "Optional description of the table." + }, + "schema": { + "type": "object", + "required": ["columns"], + "description": "The table's column schema.", + "properties": { + "columns": { + "type": "array", + "minItems": 1, + "maxItems": 50, + "description": "Column definitions. A table must have between 1 and 50 columns.", + "items": { + "$ref": "#/components/schemas/ColumnInput" + } + } + } + } + } + }, + "AddColumnBody": { + "type": "object", + "description": "Payload to add a column to a table.", + "required": ["workspaceId", "column"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "column": { + "type": "object", + "description": "The column definition to add.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "phone" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + }, + "position": { + "type": "integer", + "minimum": 0, + "description": "Zero-based insert position in the column order. Appended at the end when omitted." + } + } + } + } + }, + "UpdateColumnBody": { + "type": "object", + "description": "Payload to update an existing column by name.", + "required": ["workspaceId", "columnName", "updates"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "columnName": { + "type": "string", + "description": "The current name of the column to update.", + "example": "phone" + }, + "updates": { + "type": "object", + "description": "Fields to change. Provide at least one.", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "New column name.", + "example": "phone_number" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "New data type for the column." + }, + "required": { + "type": "boolean", + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "description": "Whether values in this column must be unique across all rows." + } + } + } + } + }, + "DeleteColumnBody": { + "type": "object", + "description": "Payload to delete a column by name.", + "required": ["workspaceId", "columnName"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "columnName": { + "type": "string", + "description": "The name of the column to delete.", + "example": "phone_number" + } + } + }, + "CreateRowSingleBody": { + "type": "object", + "description": "Insert a single row.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "afterRowId": { + "type": "string", + "minLength": 1, + "description": "Insert directly after this row id. Mutually exclusive with beforeRowId." + }, + "beforeRowId": { + "type": "string", + "minLength": 1, + "description": "Insert directly before this row id. Mutually exclusive with afterRowId." + } + } + }, + "CreateRowBatchBody": { + "type": "object", + "description": "Insert multiple rows in one request.", + "required": ["workspaceId", "rows"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "rows": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "Rows to insert. Each entry is keyed by column name. Up to 1000 rows per request.", + "items": { + "$ref": "#/components/schemas/RowData" + } + } + } + }, + "CreateRowsBody": { + "description": "Either a single-row payload or a batch payload.", + "oneOf": [ + { + "$ref": "#/components/schemas/CreateRowSingleBody" + }, + { + "$ref": "#/components/schemas/CreateRowBatchBody" + } + ] + }, + "UpdateRowsByFilterBody": { + "type": "object", + "description": "Bulk-update rows matching a filter.", + "required": ["workspaceId", "filter", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "filter": { + "$ref": "#/components/schemas/Filter" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of matching rows to update." + } + } + }, + "DeleteRowsByFilterBody": { + "type": "object", + "description": "Delete rows matching a filter.", + "required": ["workspaceId", "filter"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "filter": { + "$ref": "#/components/schemas/Filter" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of matching rows to delete." + } + } + }, + "DeleteRowsByIdsBody": { + "type": "object", + "description": "Delete an explicit list of rows by id.", + "required": ["workspaceId", "rowIds"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "rowIds": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "Row ids to delete. Up to 1000 ids per request.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of rows to delete." + } + } + }, + "DeleteRowsBody": { + "description": "Provide exactly one of `filter` or `rowIds`.", + "oneOf": [ + { + "$ref": "#/components/schemas/DeleteRowsByFilterBody" + }, + { + "$ref": "#/components/schemas/DeleteRowsByIdsBody" + } + ] + }, + "UpdateRowBody": { + "type": "object", + "description": "Partial update for a single row.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + } + } + }, + "UpsertRowBody": { + "type": "object", + "description": "Insert-or-update a row keyed by a unique column.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "conflictTarget": { + "type": "string", + "minLength": 1, + "description": "Name of the unique column to resolve the conflict against. When omitted, the server uses the table's single unique column." + } + } + }, + "TableEnvelope": { + "type": "object", + "description": "A single table wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["table"], + "properties": { + "table": { + "$ref": "#/components/schemas/Table" + } + } + } + } + }, + "TableListEnvelope": { + "type": "object", + "description": "A page of tables. `nextCursor` is always null because the full bounded workspace set is returned in one page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Table" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more pages." + } + } + }, + "DeleteTableEnvelope": { + "type": "object", + "description": "Confirmation that a table was archived.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "The id of the archived table." + } + } + } + } + }, + "ColumnsEnvelope": { + "type": "object", + "description": "The table's full column list after a column mutation.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["columns"], + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Column" + } + } + } + } + } + }, + "RowEnvelope": { + "type": "object", + "description": "A single row wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["row"], + "properties": { + "row": { + "$ref": "#/components/schemas/Row" + } + } + } + } + }, + "RowListEnvelope": { + "type": "object", + "description": "A cursor-paginated page of rows.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Row" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null on the final page." + } + } + }, + "BatchInsertRowsEnvelope": { + "type": "object", + "description": "Result of a batch row insert.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["rows", "insertedCount"], + "properties": { + "rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Row" + } + }, + "insertedCount": { + "type": "integer", + "description": "Number of rows inserted." + } + } + } + } + }, + "CreateRowsResponse": { + "description": "A single-row insert returns `{ data: { row } }`; a batch insert returns `{ data: { rows, insertedCount } }`.", + "oneOf": [ + { + "$ref": "#/components/schemas/RowEnvelope" + }, + { + "$ref": "#/components/schemas/BatchInsertRowsEnvelope" + } + ] + }, + "UpdateRowsEnvelope": { + "type": "object", + "description": "Result of a bulk update-by-filter.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["updatedCount", "updatedRowIds"], + "properties": { + "updatedCount": { + "type": "integer", + "description": "Number of rows updated." + }, + "updatedRowIds": { + "type": "array", + "description": "Ids of the updated rows. Empty when nothing matched.", + "items": { + "type": "string" + } + } + } + } + } + }, + "DeleteRowsEnvelope": { + "type": "object", + "description": "Result of a bulk delete. `requestedCount` and `missingRowIds` are present only for id-based deletes.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["deletedCount", "deletedRowIds"], + "properties": { + "deletedCount": { + "type": "integer", + "description": "Number of rows deleted." + }, + "deletedRowIds": { + "type": "array", + "description": "Ids of the deleted rows.", + "items": { + "type": "string" + } + }, + "requestedCount": { + "type": "integer", + "description": "Number of row ids requested. Present only for id-based deletes." + }, + "missingRowIds": { + "type": "array", + "description": "Requested ids that did not exist. Present only for id-based deletes.", + "items": { + "type": "string" + } + } + } + } + } + }, + "DeleteRowEnvelope": { + "type": "object", + "description": "Result of a single-row delete.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["deletedCount", "deletedRowIds"], + "properties": { + "deletedCount": { + "type": "integer", + "description": "Always 1 when a row was deleted." + }, + "deletedRowIds": { + "type": "array", + "description": "The id of the deleted row.", + "items": { + "type": "string" + } + } + } + } + } + }, + "UpsertRowEnvelope": { + "type": "object", + "description": "Result of an upsert, including whether the row was inserted or updated.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["row", "operation"], + "properties": { + "row": { + "$ref": "#/components/schemas/Row" + }, + "operation": { + "type": "string", + "enum": ["insert", "update"], + "description": "Whether the row was inserted or updated." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request. The request body, query parameters, or a JSON-encoded filter/sort failed validation. Inspect `error.details` for field-level issues.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": "schema.columns", + "message": "Table must have at least one column" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. The API key cannot access the target workspace, or a plan limit (such as the maximum number of tables) has been reached.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } + } + }, + "NotFound": { + "description": "The requested table or row was not found. Verify the id is correct and belongs to the specified workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Table not found" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/RetryAfter" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-15T10:31:00.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected server error occurred.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json new file mode 100644 index 00000000000..341c14ceb5c --- /dev/null +++ b/apps/docs/openapi-v2-workflows.json @@ -0,0 +1,1024 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Workflows", + "description": "Version 2 of the Sim REST API for listing workflows, inspecting workflow detail, and managing deployments (deploy, undeploy, rollback).\n\nThe v2 surface standardizes on a single response family across every endpoint:\n- Single resource: `{ \"data\": T }`\n- List: `{ \"data\": T[], \"nextCursor\": string | null }`\n- Error: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`\n\nLists use an opaque cursor (Stripe/Slack-style): send `limit` and `cursor`, receive `{ data, nextCursor }`. Cursors are opaque tokens — pass back the `nextCursor` from the previous page verbatim and stop when it is `null`. Total counts are not returned on lists.\n\nRate-limit state is carried in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` response headers (not in the body). A throttled request returns `429` with a `Retry-After` header.\n\nAuthenticate every request with an API key in the `X-API-Key` header.", + "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" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Workflows", + "description": "List workflows, inspect workflow detail, and manage deployments (deploy, undeploy, rollback) on the v2 API." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/workflows": { + "get": { + "operationId": "listWorkflows", + "summary": "List Workflows", + "description": "Retrieve workflows in a workspace using opaque cursor-based pagination. Results are ordered deterministically; follow `nextCursor` to page through the full set, and stop when it is `null`.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "folderId", + "in": "query", + "required": false, + "description": "Filter results to only include workflows within this folder.", + "schema": { + "type": "string", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + } + }, + { + "name": "deployedOnly", + "in": "query", + "required": false, + "description": "When true, only return workflows that are currently deployed. Useful for listing workflows available for API execution.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of workflows to return per page. Must be between 1 and 100.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor returned from a previous request's `nextCursor` field. Omit for the first page.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "A page of workflows.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "Workflows for the current page.", + "items": { + "$ref": "#/components/schemas/WorkflowListItem" + } + }, + "nextCursor": { + "type": "string", + "nullable": true, + "description": "Opaque cursor for fetching the next page. `null` when there are no more results." + } + } + }, + "example": { + "data": [ + { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 142, + "lastRunAt": "2026-06-20T14:15:22.000Z", + "createdAt": "2026-01-10T09:00:00.000Z", + "updatedAt": "2026-06-18T16:45:00.000Z" + } + ], + "nextCursor": "eyJzb3J0T3JkZXIiOjAsImNyZWF0ZWRBdCI6IjIwMjYtMDEtMTBUMDk6MDA6MDAuMDAwWiIsImlkIjoiM2IxZjdjOTIifQ==" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}": { + "get": { + "operationId": "getWorkflow", + "summary": "Get Workflow", + "description": "Retrieve a single workflow, including its workflow-level variables and trigger input field definitions. Returns 404 when the workflow does not exist or you do not have access to it (existence is not leaked).", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "responses": { + "200": { + "description": "The requested workflow.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/WorkflowDetail" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 142, + "lastRunAt": "2026-06-20T14:15:22.000Z", + "variables": { + "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": { + "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60", + "name": "supportEmail", + "type": "string", + "value": "support@example.com" + } + }, + "inputs": [ + { + "name": "ticketBody", + "type": "string", + "description": "The raw text of the incoming support ticket." + } + ], + "createdAt": "2026-01-10T09:00:00.000Z", + "updatedAt": "2026-06-18T16:45:00.000Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/deploy": { + "post": { + "operationId": "deployWorkflow", + "summary": "Deploy Workflow", + "description": "Deploy the workflow's current draft state. Creates a new deployment version, makes it live for API execution, and activates schedules and triggers. Optionally accepts a `name` and `description` for the new version; the request body may be omitted entirely. Returns 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/workflows/{id}/deploy\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\": \"Release 4\", \"description\": \"Fixes the agent prompt\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "requestBody": { + "required": false, + "description": "Optional metadata for the new deployment version. The request body may be omitted entirely.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Optional label for the new deployment version.", + "example": "Release 4" + }, + "description": { + "type": "string", + "maxLength": 50000, + "nullable": true, + "description": "Optional summary of what changed in this version.", + "example": "Fixes the agent prompt" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Workflow deployed successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/DeployResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "version": 4, + "warnings": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "undeployWorkflow", + "summary": "Undeploy Workflow", + "description": "Take the workflow offline. API execution stops and schedules, webhooks, and other deployment side effects are removed. Deployment versions are retained, so the workflow can be deployed again later. Returns 400 when the workflow is not currently deployed, 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/workflows/{id}/deploy\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "responses": { + "200": { + "description": "Workflow undeployed successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/UndeployResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": false, + "deployedAt": null, + "warnings": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/rollback": { + "post": { + "operationId": "rollbackWorkflow", + "summary": "Rollback Workflow", + "description": "Roll the live deployment back to a previous deployment version. The workflow must currently be deployed. By default the version immediately preceding the currently active one is re-activated; pass `version` to target a specific deployment version instead. The workflow's draft state is not modified. Returns 400 when the workflow is not deployed or there is no version to roll back to, 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/workflows/{id}/rollback\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"version\": 3}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "requestBody": { + "required": false, + "description": "Optional rollback target. The request body may be omitted entirely to roll back to the version immediately preceding the active one.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "The deployment version to re-activate. Defaults to the version immediately preceding the active one.", + "example": 3 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Workflow rolled back successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/RollbackResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "version": 3, + "warnings": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "WorkspaceId": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace to list workflows from.", + "schema": { + "type": "string", + "minLength": 1, + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + }, + "WorkflowId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique workflow identifier.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 60 + } + }, + "RateLimitRemaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 59 + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-06-29T21:50:00.000Z" + } + } + }, + "schemas": { + "Error": { + "type": "object", + "description": "Canonical v2 error envelope. Every non-2xx response uses this shape.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code.", + "enum": [ + "BAD_REQUEST", + "UNAUTHORIZED", + "USAGE_LIMIT_EXCEEDED", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "LOCKED", + "RATE_LIMITED", + "INTERNAL_ERROR" + ] + }, + "message": { + "type": "string", + "description": "Human-readable description of what went wrong." + }, + "details": { + "description": "Optional structured detail about the error (e.g. field-level validation issues). Shape varies by error code; absent when there is nothing to add." + } + } + } + } + }, + "WorkflowListItem": { + "type": "object", + "description": "Summary representation of a workflow returned by the list endpoint.", + "required": [ + "id", + "name", + "description", + "folderId", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Human-readable workflow name.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description of what the workflow does. `null` when unset.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": "string", + "nullable": true, + "description": "The folder this workflow belongs to. `null` when at the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + "workspaceId": { + "type": "string", + "description": "The workspace this workflow belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is currently deployed and available for API execution.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent deployment. `null` when never deployed.", + "example": "2026-06-12T10:30:00.000Z" + }, + "runCount": { + "type": "integer", + "description": "Total number of times this workflow has been executed.", + "example": 142 + }, + "lastRunAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent execution. `null` when never run.", + "example": "2026-06-20T14:15:22.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created.", + "example": "2026-01-10T09:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified.", + "example": "2026-06-18T16:45:00.000Z" + } + } + }, + "WorkflowInputField": { + "type": "object", + "description": "A single trigger input field extracted from the workflow's input-definition block. Use these to construct the `input` object when executing the workflow.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "description": "Field name as referenced by the workflow.", + "example": "ticketBody" + }, + "type": { + "type": "string", + "description": "Declared field type (e.g. `string`, `number`, `boolean`, `object`).", + "example": "string" + }, + "description": { + "type": "string", + "description": "Optional human-readable description of the field.", + "example": "The raw text of the incoming support ticket." + } + } + }, + "WorkflowDetail": { + "type": "object", + "description": "Full workflow representation: every list field plus workflow-level variables and trigger input field definitions.", + "required": [ + "id", + "name", + "description", + "folderId", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "variables", + "inputs", + "createdAt", + "updatedAt" + ], + "allOf": [ + { + "$ref": "#/components/schemas/WorkflowListItem" + }, + { + "type": "object", + "required": ["variables", "inputs"], + "properties": { + "variables": { + "type": "object", + "description": "Workflow-scoped variables keyed by variable id. Each value is a structured variable object (`{ id, name, type, value, ... }`); only the inner `value` is user-defined. Empty object when the workflow defines no variables.", + "additionalProperties": true, + "example": { + "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": { + "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60", + "name": "supportEmail", + "type": "string", + "value": "support@example.com" + } + } + }, + "inputs": { + "type": "array", + "description": "The workflow's trigger input field definitions.", + "items": { + "$ref": "#/components/schemas/WorkflowInputField" + } + } + } + } + ] + }, + "DeploymentState": { + "type": "object", + "description": "Base deployment state shared by deploy, undeploy, and rollback results.", + "required": ["id", "isDeployed", "deployedAt", "warnings"], + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is deployed and available for API execution after the operation." + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the active deployment. `null` when the workflow is not deployed.", + "example": "2026-06-12T10:30:00.000Z" + }, + "warnings": { + "type": "array", + "description": "Non-fatal warnings. Present when trigger, schedule, or MCP side-effect sync is still in progress or needs a redeploy. Empty array when there is nothing to report.", + "items": { + "type": "string" + } + } + } + }, + "DeployResult": { + "description": "Deployment state returned after a successful deploy. `isDeployed` is always `true`.", + "allOf": [ + { + "$ref": "#/components/schemas/DeploymentState" + }, + { + "type": "object", + "properties": { + "version": { + "type": "integer", + "description": "The deployment version that is now active. May be omitted when the version number is unavailable.", + "example": 4 + } + } + } + ] + }, + "UndeployResult": { + "description": "Deployment state returned after a successful undeploy. `isDeployed` is always `false`, `deployedAt` is always `null`, and no `version` is included.", + "allOf": [ + { + "$ref": "#/components/schemas/DeploymentState" + } + ] + }, + "RollbackResult": { + "description": "Deployment state returned after a successful rollback. `isDeployed` is always `true` and `version` identifies the re-activated deployment version.", + "allOf": [ + { + "$ref": "#/components/schemas/DeploymentState" + }, + { + "type": "object", + "required": ["version"], + "properties": { + "version": { + "type": "integer", + "description": "The deployment version that was re-activated.", + "example": 3 + } + } + } + ] + } + }, + "responses": { + "BadRequest": { + "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues. Also returned when an operation is not allowed in the current state (e.g. undeploying a workflow that is not deployed).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "workspaceId is required", + "details": [ + { + "path": ["workspaceId"], + "message": "workspaceId is required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the `X-API-Key` header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. You do not have permission to access the requested workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } + } + }, + "NotFound": { + "description": "The workflow does not exist or you do not have access to it. Existence is not leaked, so an access failure is reported as 404.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Workflow not found" + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request body exceeds the maximum allowed size.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } + } + } + } + }, + "Locked": { + "description": "The workflow is locked and cannot be modified. Wait for the in-progress operation to finish, then retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "Workflow is locked and cannot be modified" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration specified in the `Retry-After` header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer", + "example": 30 + } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-06-29T21:50:00.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred while processing the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/sim/app/api/v1/admin/audit-logs/route.ts b/apps/sim/app/api/v1/admin/audit-logs/route.ts index 9610232d357..f3dbc231e69 100644 --- a/apps/sim/app/api/v1/admin/audit-logs/route.ts +++ b/apps/sim/app/api/v1/admin/audit-logs/route.ts @@ -31,21 +31,13 @@ import { internalErrorResponse, listResponse, } from '@/app/api/v1/admin/responses' -import { - type AdminAuditLog, - createPaginationMeta, - parsePaginationParams, - toAdminAuditLog, -} from '@/app/api/v1/admin/types' +import { type AdminAuditLog, createPaginationMeta, toAdminAuditLog } from '@/app/api/v1/admin/types' import { buildFilterConditions } from '@/app/api/v1/audit-logs/query' const logger = createLogger('AdminAuditLogsAPI') export const GET = withRouteHandler( withAdminAuth(async (request) => { - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) - const parsed = await parseRequest( v1AdminListAuditLogsContract, request, @@ -56,6 +48,7 @@ export const GET = withRouteHandler( try { const query = parsed.data.query + const { limit, offset } = query const conditions = buildFilterConditions({ action: query.action, resourceType: query.resourceType, diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts index 69b773accf5..18bc485fbe6 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts @@ -29,6 +29,7 @@ import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, internalErrorResponse, @@ -152,7 +153,7 @@ export const PATCH = withRouteHandler( { params: routeParams }, { validationErrorResponse: adminValidationErrorResponse, - invalidJson: 'throw', + invalidJsonResponse: adminInvalidJsonResponse, } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts index 68b79e3a78a..83234df0a7b 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts @@ -45,6 +45,7 @@ import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, internalErrorResponse, @@ -144,7 +145,7 @@ export const PATCH = withRouteHandler( { params: routeParams }, { validationErrorResponse: adminValidationErrorResponse, - invalidJson: 'throw', + invalidJsonResponse: adminInvalidJsonResponse, } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts index 5c9525ca7ff..e6c84765379 100644 --- a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts +++ b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts @@ -101,7 +101,10 @@ export const POST = withRouteHandler( }) } catch (error) { logger.error('Failed to requeue outbox event', { eventId: id, error: toError(error).message }) - return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + return NextResponse.json( + { success: false, error: 'Failed to requeue outbox event' }, + { status: 500 } + ) } }) ) diff --git a/apps/sim/app/api/v1/admin/outbox/route.ts b/apps/sim/app/api/v1/admin/outbox/route.ts index f88ac55536c..57ce53c49f5 100644 --- a/apps/sim/app/api/v1/admin/outbox/route.ts +++ b/apps/sim/app/api/v1/admin/outbox/route.ts @@ -77,7 +77,10 @@ export const GET = withRouteHandler( }) } catch (error) { logger.error('Failed to list outbox events', { error: toError(error).message }) - return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + return NextResponse.json( + { success: false, error: 'Failed to list outbox events' }, + { status: 500 } + ) } }) ) diff --git a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts index b7f7c162118..1432b46d37b 100644 --- a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts +++ b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts @@ -41,6 +41,7 @@ import { requireStripeClient } from '@/lib/billing/stripe-client' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, internalErrorResponse, @@ -181,7 +182,7 @@ export const POST = withRouteHandler( {}, { validationErrorResponse: adminValidationErrorResponse, - invalidJson: 'throw', + invalidJsonResponse: adminInvalidJsonResponse, } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts index 323d1b82bdd..01eb14996f3 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -25,20 +25,21 @@ type AuthResult = | { success: false; response: NextResponse } /** - * Validates enterprise audit log access for the given user. - * - * Checks: - * 1. User belongs to an organization - * 2. User has admin or owner role - * 3. Organization has an active enterprise subscription - * - * Returns the organization ID and all member user IDs on success, - * or an error response on failure. + * Structured enterprise audit-access result shared by the v1 and v2 surfaces so + * each version can render the failure in its own response envelope. */ -export async function validateEnterpriseAuditAccess( +export type EnterpriseAuditAccessResult = + | { success: true; context: EnterpriseAuditContext } + | { success: false; status: number; message: string } + +/** + * Core enterprise audit-access check (no response rendering). See + * {@link validateEnterpriseAuditAccess} for the policy checks performed. + */ +export async function resolveEnterpriseAuditAccess( userId: string, targetOrganizationId?: string -): Promise { +): Promise { const [membership] = await db .select({ organizationId: member.organizationId, role: member.role }) .from(member) @@ -50,31 +51,16 @@ export async function validateEnterpriseAuditAccess( .limit(1) if (!membership) { - return { - success: false, - response: NextResponse.json({ error: 'Not a member of any organization' }, { status: 403 }), - } + return { success: false, status: 403, message: 'Not a member of any organization' } } if (membership.role !== 'admin' && membership.role !== 'owner') { - return { - success: false, - response: NextResponse.json( - { error: 'Organization admin or owner role required' }, - { status: 403 } - ), - } + return { success: false, status: 403, message: 'Organization admin or owner role required' } } const billingBlocked = await isOrganizationBillingBlocked(membership.organizationId) if (billingBlocked) { - return { - success: false, - response: NextResponse.json( - { error: 'Active enterprise subscription required' }, - { status: 403 } - ), - } + return { success: false, status: 403, message: 'Active enterprise subscription required' } } const [orgSub, orgMembers] = await Promise.all([ @@ -96,13 +82,7 @@ export async function validateEnterpriseAuditAccess( ]) if (orgSub.length === 0) { - return { - success: false, - response: NextResponse.json( - { error: 'Active enterprise subscription required' }, - { status: 403 } - ), - } + return { success: false, status: 403, message: 'Active enterprise subscription required' } } const orgMemberIds = orgMembers.map((m) => m.userId) @@ -115,9 +95,29 @@ export async function validateEnterpriseAuditAccess( return { success: true, - context: { - organizationId: membership.organizationId, - orgMemberIds, - }, + context: { organizationId: membership.organizationId, orgMemberIds }, + } +} + +/** + * Validates enterprise audit log access for the given user. + * + * Checks: + * 1. User belongs to an organization + * 2. User has admin or owner role + * 3. Organization has an active enterprise subscription + * + * v1 wrapper: renders {@link resolveEnterpriseAuditAccess} as the v1 `{ error }` + * response body. + */ +export async function validateEnterpriseAuditAccess( + userId: string, + targetOrganizationId?: string +): Promise { + const result = await resolveEnterpriseAuditAccess(userId, targetOrganizationId) + if (result.success) return { success: true, context: result.context } + return { + success: false, + response: NextResponse.json({ error: result.message }, { status: result.status }), } } diff --git a/apps/sim/app/api/v1/logs/filters.ts b/apps/sim/app/api/v1/logs/filters.ts index 0e409e4d53f..8e40ca1db51 100644 --- a/apps/sim/app/api/v1/logs/filters.ts +++ b/apps/sim/app/api/v1/logs/filters.ts @@ -1,5 +1,5 @@ import { workflow, workflowExecutionLogs } from '@sim/db/schema' -import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' +import { and, asc, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' export interface LogFilters { workspaceId: string @@ -103,8 +103,14 @@ export function buildLogFilters(filters: LogFilters): SQL { return conditions.length > 0 ? and(...conditions)! : sql`true` } +/** + * Order rows by `(startedAt, id)` so the sort matches the keyset cursor's tuple + * comparison in {@link buildLogFilters}. Without the `id` tie-break, rows that + * share a `startedAt` have an arbitrary order and can be skipped or duplicated + * across pages. + */ export function getOrderBy(order: 'desc' | 'asc' = 'desc') { return order === 'desc' - ? desc(workflowExecutionLogs.startedAt) - : sql`${workflowExecutionLogs.startedAt} ASC` + ? [desc(workflowExecutionLogs.startedAt), desc(workflowExecutionLogs.id)] + : [asc(workflowExecutionLogs.startedAt), asc(workflowExecutionLogs.id)] } diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index bd6a2185dd5..74f992fc207 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -124,7 +124,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const logs = await baseQuery .where(conditions) - .orderBy(orderBy) + .orderBy(...orderBy) .limit(params.limit + 1) const hasMore = logs.length > params.limit diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index c9f757d91df..0f084feec1e 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -162,36 +162,46 @@ export function createRateLimitResponse(result: RateLimitResult): NextResponse { } /** - * Verify that the API key is allowed to access the requested workspace. - * - * Enforces two policies: + * Structured workspace-access failure shared by the v1 and v2 API surfaces so + * each version can render the failure in its own response envelope. + */ +export interface WorkspaceAccessError { + status: number + code: 'FORBIDDEN' + message: string +} + +/** + * Core workspace-scope check (no response rendering). Enforces two policies: * - A workspace-scoped key may only target its own workspace. * - A personal key is rejected when the workspace has disabled personal API * keys (`allowPersonalApiKeys = false`), matching the workflow-execution * surface in `app/api/workflows/middleware.ts`. */ -export async function checkWorkspaceScope( +export async function resolveWorkspaceScope( rateLimit: RateLimitResult, requestedWorkspaceId: string -): Promise { +): Promise { if ( rateLimit.keyType === 'workspace' && rateLimit.workspaceId && rateLimit.workspaceId !== requestedWorkspaceId ) { - return NextResponse.json( - { error: 'API key is not authorized for this workspace' }, - { status: 403 } - ) + return { + status: 403, + code: 'FORBIDDEN', + message: 'API key is not authorized for this workspace', + } } if (rateLimit.keyType === 'personal') { const settings = await getWorkspaceBillingSettings(requestedWorkspaceId) if (!settings?.allowPersonalApiKeys) { - return NextResponse.json( - { error: 'Personal API keys are not allowed for this workspace' }, - { status: 403 } - ) + return { + status: 403, + code: 'FORBIDDEN', + message: 'Personal API keys are not allowed for this workspace', + } } } @@ -214,21 +224,46 @@ export async function resolveWorkspaceRequestActor( } /** - * Validates workspace-scoped API key bounds and the user's workspace permission. - * Returns null on success, NextResponse on failure. + * Core workspace-access check (scope + the user's workspace permission level), + * shared by v1 and v2. Returns a structured failure or null on success. */ -export async function validateWorkspaceAccess( +export async function resolveWorkspaceAccess( rateLimit: RateLimitResult, userId: string, workspaceId: string, level: PermissionType = 'read' -): Promise { - const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) +): Promise { + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (!permissionSatisfies(permission, level)) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + return { status: 403, code: 'FORBIDDEN', message: 'Access denied' } } return null } + +/** + * v1 wrapper: renders {@link resolveWorkspaceScope} as the v1 `{ error }` body. + */ +export async function checkWorkspaceScope( + rateLimit: RateLimitResult, + requestedWorkspaceId: string +): Promise { + const failure = await resolveWorkspaceScope(rateLimit, requestedWorkspaceId) + return failure ? NextResponse.json({ error: failure.message }, { status: failure.status }) : null +} + +/** + * v1 wrapper: renders {@link resolveWorkspaceAccess} as the v1 `{ error }` body. + * Returns null on success, NextResponse on failure. + */ +export async function validateWorkspaceAccess( + rateLimit: RateLimitResult, + userId: string, + workspaceId: string, + level: PermissionType = 'read' +): Promise { + const failure = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, level) + return failure ? NextResponse.json({ error: failure.message }, { status: failure.status }) : null +} diff --git a/apps/sim/app/api/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[id]/route.ts new file mode 100644 index 00000000000..d1fca3d0aa0 --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/[id]/route.ts @@ -0,0 +1,76 @@ +import { db } from '@sim/db' +import { auditLog } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { v2GetAuditLogContract } from '@/lib/api/contracts/v2/audit-logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' +import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2AuditLogDetailAPI') + +export const revalidate = 0 + +/** + * GET /api/v2/audit-logs/[id] + * + * Returns a single audit log entry scoped to the authenticated user's + * organization. Org-scoped (not workspace-scoped). Unlike v1, authorization + * (`checkRateLimit` → `validateEnterpriseAuditAccess`) runs BEFORE the untrusted + * param is parsed, fixing the v1 ordering inconsistency. The org-scope predicate + * is folded into the lookup so a non-org log reads as 404 (existence is not + * leaked). + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'audit-logs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const authResult = await resolveEnterpriseAuditAccess(userId) + if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) + + const parsed = await parseRequest(v2GetAuditLogContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { organizationId, orgMemberIds } = authResult.context + + const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) + const scopeCondition = buildOrgScopeCondition({ + organizationId, + orgWorkspaceIds, + orgMemberIds, + includeDeparted: true, + }) + + const [log] = await db + .select() + .from(auditLog) + .where(and(eq(auditLog.id, id), scopeCondition)) + .limit(1) + + if (!log) return v2Error('NOT_FOUND', 'Audit log not found') + + return v2Data(formatAuditLogEntry(log), { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Audit log detail fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts new file mode 100644 index 00000000000..c785ccaaede --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -0,0 +1,103 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' +import { + buildFilterConditions, + buildOrgScopeCondition, + getOrgWorkspaceIds, + queryAuditLogs, +} from '@/app/api/v1/audit-logs/query' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2AuditLogsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/audit-logs + * + * Lists audit logs scoped to the authenticated user's organization. Org-scoped + * (not workspace-scoped): `resolveWorkspaceAccess` is intentionally NOT used — + * access is gated by enterprise org admin/owner membership. Auth ordering + * matches v1: `checkRateLimit` → `validateEnterpriseAuditAccess` run before the + * untrusted query is parsed. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'audit-logs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const authResult = await resolveEnterpriseAuditAccess(userId) + if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) + + const { organizationId, orgMemberIds } = authResult.context + + const parsed = await parseRequest( + v2ListAuditLogsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + + if (params.actorId && !orgMemberIds.includes(params.actorId)) { + return v2Error('BAD_REQUEST', 'actorId is not a member of your organization') + } + + const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) + + if (params.workspaceId && !orgWorkspaceIds.includes(params.workspaceId)) { + return v2Error('BAD_REQUEST', 'workspaceId does not belong to your organization') + } + + const scopeCondition = buildOrgScopeCondition({ + organizationId, + orgWorkspaceIds, + orgMemberIds, + includeDeparted: params.includeDeparted, + }) + const filterConditions = buildFilterConditions({ + action: params.action, + resourceType: params.resourceType, + resourceId: params.resourceId, + workspaceId: params.workspaceId, + actorId: params.actorId, + startDate: params.startDate, + endDate: params.endDate, + }) + + const { data, nextCursor } = await queryAuditLogs( + [scopeCondition, ...filterConditions], + params.limit, + params.cursor + ) + + return v2CursorList(data.map(formatAuditLogEntry), nextCursor ?? null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Audit logs fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts new file mode 100644 index 00000000000..9d2e6d603b9 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -0,0 +1,124 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2DeleteFileContract, v2DownloadFileContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import type { V2ErrorCode } from '@/app/api/v2/lib/response' +import { + rateLimitHeaders, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FileRouteParams { + params: Promise<{ fileId: string }> +} + +/** + * GET /api/v2/files/[fileId] — Download file content (binary). + * + * The response carries no JSON envelope, so rate-limit state is surfaced via + * `X-RateLimit-*` headers. Errors still render the canonical v2 JSON error body. + * Lookups are workspace-scoped (IDOR-safe): a file in another workspace 404s. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DownloadFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const fileRecord = await getWorkspaceFile(workspaceId, fileId) + if (!fileRecord) return v2Error('NOT_FOUND', 'File not found') + + const buffer = await fetchWorkspaceFileBuffer(fileRecord) + + return new Response(new Uint8Array(buffer), { + status: 200, + headers: { + 'Content-Type': fileRecord.type || 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`, + 'Content-Length': String(buffer.length), + ...rateLimitHeaders(rateLimit), + }, + }) + } catch (error) { + logger.error('Error downloading file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * DELETE /api/v2/files/[fileId] — Archive (soft delete) a file. + * + * Delegates to the shared orchestration, which is workspace-scoped and records + * its own audit entry (the request is forwarded so that entry captures client + * IP / user agent). Orchestration `errorCode`s map to specific v2 codes rather + * than v1's blanket 500. + */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId, + fileIds: [fileId], + request, + }) + + if (!result.success) { + const code: V2ErrorCode = + result.errorCode === 'not_found' + ? 'NOT_FOUND' + : result.errorCode === 'validation' + ? 'BAD_REQUEST' + : result.errorCode === 'conflict' + ? 'CONFLICT' + : 'INTERNAL_ERROR' + return v2Error(code, result.error || 'Failed to delete file') + } + + logger.info(`Archived file ${fileId} from workspace ${workspaceId}`) + + return v2Data({ id: fileId, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error('Error deleting file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts new file mode 100644 index 00000000000..dbc6e982068 --- /dev/null +++ b/apps/sim/app/api/v2/files/route.ts @@ -0,0 +1,236 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + type V2File, + v2ListFilesContract, + v2UploadFileContract, +} from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + FileConflictError, + getWorkspaceFile, + listWorkspaceFiles, + uploadWorkspaceFile, +} from '@/lib/uploads/contexts/workspace' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FilesAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const MAX_FILE_SIZE = 100 * 1024 * 1024 +const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 + +interface FileCursor { + uploadedAt: string + id: string +} + +/** Stable keyset ordering: `uploadedAt` ascending, `id` ascending as the tiebreaker. */ +function compareFiles(a: V2File, b: V2File): number { + if (a.uploadedAt !== b.uploadedAt) return a.uploadedAt < b.uploadedAt ? -1 : 1 + if (a.id !== b.id) return a.id < b.id ? -1 : 1 + return 0 +} + +/** + * GET /api/v2/files — List files in a workspace with cursor pagination. + * + * The shared {@link listWorkspaceFiles} manager returns the full active set + * ordered by `uploadedAt`; v2 applies a bounded keyset slice over that result in + * the route. Pushing `limit`/`cursor` down into the manager query is a follow-up. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListFilesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, limit, cursor } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const files = await listWorkspaceFiles(workspaceId) + + const items: V2File[] = files + .map((f) => ({ + id: f.id, + name: f.name, + size: f.size, + type: f.type, + key: f.key, + uploadedBy: f.uploadedBy, + uploadedAt: + f.uploadedAt instanceof Date ? f.uploadedAt.toISOString() : String(f.uploadedAt), + })) + .sort(compareFiles) + + const decoded = cursor ? decodeCursor(cursor) : null + const afterCursor = decoded + ? items.filter( + (f) => + f.uploadedAt > decoded.uploadedAt || + (f.uploadedAt === decoded.uploadedAt && f.id > decoded.id) + ) + : items + + const hasMore = afterCursor.length > limit + const page = afterCursor.slice(0, limit) + const last = page.at(-1) + const nextCursor = + hasMore && last ? encodeCursor({ uploadedAt: last.uploadedAt, id: last.id }) : null + + return v2CursorList(page, nextCursor, { rateLimit }) + } catch (error) { + logger.error('Error listing files', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * POST /api/v2/files — Upload a file to a workspace. + * + * Authorization runs fully (rate limit → workspace write access) before the + * multipart body is buffered: the workspace is a contract-validated query param, + * so an unauthorized caller never streams a 100 MB body into memory. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2UploadFileContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + let formData: FormData + try { + formData = await readFormDataWithLimit(request, { + maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'workspace file upload body', + }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + return v2Error('BAD_REQUEST', 'Request body must be valid multipart form data') + } + + const rawFile = formData.get('file') + const file = rawFile instanceof File ? rawFile : null + if (!file) { + return v2Error('BAD_REQUEST', 'file form field is required') + } + + if (file.size > MAX_FILE_SIZE) { + return v2Error( + 'PAYLOAD_TOO_LARGE', + `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` + ) + } + + const buffer = await readFileToBufferWithLimit(file, { + maxBytes: MAX_FILE_SIZE, + label: 'workspace upload file', + }) + + const userFile = await uploadWorkspaceFile( + workspaceId, + userId, + buffer, + file.name, + file.type || 'application/octet-stream' + ) + + logger.info(`Uploaded file: ${file.name} to workspace ${workspaceId}`) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: userFile.id, + resourceName: file.name, + description: `Uploaded file "${file.name}" via API`, + metadata: { fileSize: file.size, fileType: file.type || 'application/octet-stream' }, + request, + }) + + const fileRecord = await getWorkspaceFile(workspaceId, userFile.id) + const uploadedAt = + fileRecord?.uploadedAt instanceof Date + ? fileRecord.uploadedAt.toISOString() + : fileRecord?.uploadedAt + ? String(fileRecord.uploadedAt) + : new Date().toISOString() + + const responseFile: V2File = { + id: userFile.id, + name: userFile.name, + size: userFile.size, + type: userFile.type, + key: userFile.key, + uploadedBy: userId, + uploadedAt, + } + + return v2Data(responseFile, { rateLimit, status: 201 }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + + const message = getErrorMessage(error, 'Failed to upload file') + if (error instanceof FileConflictError || message.includes('already exists')) { + return v2Error('CONFLICT', message) + } + if (message.includes('Storage limit') || message.includes('storage limit')) { + return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') + } + + logger.error('Error uploading file', { error: message }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts new file mode 100644 index 00000000000..235c80707eb --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -0,0 +1,209 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { document, knowledgeConnector } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, isNull } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { + type V2KnowledgeDocument, + v2DeleteKnowledgeDocumentContract, + v2GetKnowledgeDocumentContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deleteDocument } from '@/lib/knowledge/documents/service' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDocumentDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface DocumentDetailRouteParams { + params: Promise<{ id: string; documentId: string }> +} + +/** + * Resolves a knowledge base via the shared v1 ownership invariant + * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A + * `404` is always `NOT_FOUND`; a `403` is masked as `NOT_FOUND` on reads and + * surfaced as `FORBIDDEN` on writes. + */ +async function resolveKnowledgeBaseScoped( + id: string, + workspaceId: string, + userId: string, + rateLimit: RateLimitResult, + level: 'read' | 'write' +): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return level === 'read' + ? v2Error('NOT_FOUND', 'Knowledge base not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** GET /api/v2/knowledge/[id]/documents/[documentId] — Get document details. */ +export const GET = withRouteHandler( + async (request: NextRequest, context: DocumentDetailRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetKnowledgeDocumentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id: knowledgeBaseId, documentId } = parsed.data.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + const docs = await db + .select({ + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + filename: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + processingStatus: document.processingStatus, + processingError: document.processingError, + processingStartedAt: document.processingStartedAt, + processingCompletedAt: document.processingCompletedAt, + chunkCount: document.chunkCount, + tokenCount: document.tokenCount, + characterCount: document.characterCount, + enabled: document.enabled, + uploadedAt: document.uploadedAt, + connectorId: document.connectorId, + connectorType: knowledgeConnector.connectorType, + sourceUrl: document.sourceUrl, + }) + .from(document) + .leftJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id)) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(1) + + const doc = docs[0] + if (!doc) return v2Error('NOT_FOUND', 'Document not found') + + const documentDetail: V2KnowledgeDocument = { + id: doc.id, + knowledgeBaseId: doc.knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus as V2KnowledgeDocument['processingStatus'], + processingError: doc.processingError, + processingStartedAt: serializeDate(doc.processingStartedAt), + processingCompletedAt: serializeDate(doc.processingCompletedAt), + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + connectorId: doc.connectorId, + connectorType: doc.connectorType ?? null, + sourceUrl: doc.sourceUrl, + createdAt: serializeDate(doc.uploadedAt), + } + + return v2Data({ document: documentDetail }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error getting document`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +/** DELETE /api/v2/knowledge/[id]/documents/[documentId] — Delete a document. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: DocumentDetailRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteKnowledgeDocumentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id: knowledgeBaseId, documentId } = parsed.data.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + const docs = await db + .select({ id: document.id, filename: document.filename }) + .from(document) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(1) + + const doc = docs[0] + if (!doc) return v2Error('NOT_FOUND', 'Document not found') + + await deleteDocument(documentId, requestId) + + recordAudit({ + workspaceId: parsed.data.query.workspaceId, + actorId: userId, + action: AuditAction.DOCUMENT_DELETED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: documentId, + resourceName: doc.filename, + description: `Deleted document "${doc.filename}" from knowledge base via API`, + metadata: { knowledgeBaseId }, + request, + }) + + return v2Data({ id: documentId, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting document`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server 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 new file mode 100644 index 00000000000..9f2c7b5367a --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -0,0 +1,306 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + type V2KnowledgeDocumentSummary, + v2ListKnowledgeDocumentsContract, + v2UploadKnowledgeDocumentContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { generateRequestId } from '@/lib/core/utils/request' +import { + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createSingleDocument, + type DocumentData, + getDocuments, + processDocumentsWithQueue, +} from '@/lib/knowledge/documents/service' +import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { validateFileType } from '@/lib/uploads/utils/validation' +import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDocumentsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const MAX_FILE_SIZE = 100 * 1024 * 1024 +const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 + +interface DocumentsRouteParams { + params: Promise<{ id: string }> +} + +/** + * Resolves a knowledge base via the shared v1 ownership invariant + * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A + * `404` is always `NOT_FOUND`; a `403` is masked as `NOT_FOUND` on reads and + * surfaced as `FORBIDDEN` on writes. + */ +async function resolveKnowledgeBaseScoped( + id: string, + workspaceId: string, + userId: string, + rateLimit: RateLimitResult, + level: 'read' | 'write' +): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return level === 'read' + ? v2Error('NOT_FOUND', 'Knowledge base not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** GET /api/v2/knowledge/[id]/documents — List documents in a knowledge base. */ +export const GET = withRouteHandler(async (request: NextRequest, context: DocumentsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2ListKnowledgeDocumentsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { workspaceId, limit, cursor, search, enabledFilter, sortBy, sortOrder } = + parsed.data.query + const { id: knowledgeBaseId } = parsed.data.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + // Opaque cursor encodes the underlying offset (upgradeable to keyset later). + const offset = cursor ? (decodeCursor<{ offset: number }>(cursor)?.offset ?? 0) : 0 + + const documentsResult = await getDocuments( + knowledgeBaseId, + { + enabledFilter: enabledFilter === 'all' ? undefined : enabledFilter, + search, + limit, + offset, + sortBy: sortBy as DocumentSortField, + sortOrder: sortOrder as SortOrder, + }, + requestId + ) + + const documents: V2KnowledgeDocumentSummary[] = documentsResult.documents.map((doc) => ({ + id: doc.id, + knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus, + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + createdAt: serializeDate(doc.uploadedAt), + })) + + const nextCursor = documentsResult.pagination.hasMore + ? encodeCursor({ offset: offset + limit }) + : null + return v2CursorList(documents, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing documents`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * POST /api/v2/knowledge/[id]/documents — Upload a document to a knowledge base. + * + * Authorization runs fully before the multipart body is buffered: the workspace + * is a contract-validated query param (not a form field as in v1), so an + * unauthorized caller never streams a file into memory. Order: rate limit → + * KB ownership (write) → usage gate → buffered multipart read. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: DocumentsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UploadKnowledgeDocumentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id: knowledgeBaseId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + // Fast usage gate before the storage write + indexing (the async backstop + // in processDocumentAsync still covers non-HTTP paths). + const usage = await checkActorUsageLimits(userId, workspaceId) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + + let formData: FormData + try { + formData = await readFormDataWithLimit(request, { + maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'knowledge document upload body', + }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + return v2Error('BAD_REQUEST', 'Request body must be valid multipart form data') + } + + const rawFile = formData.get('file') + const file = rawFile instanceof File ? rawFile : null + if (!file) { + return v2Error('BAD_REQUEST', 'file form field is required') + } + + if (file.size > MAX_FILE_SIZE) { + return v2Error( + 'PAYLOAD_TOO_LARGE', + `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` + ) + } + + const fileTypeError = validateFileType(file.name, file.type || '') + if (fileTypeError) { + return v2Error('UNSUPPORTED_MEDIA_TYPE', fileTypeError.message) + } + + const buffer = await readFileToBufferWithLimit(file, { + maxBytes: MAX_FILE_SIZE, + label: 'knowledge document file', + }) + const contentType = file.type || 'application/octet-stream' + + const uploadedFile = await uploadWorkspaceFile( + workspaceId, + userId, + buffer, + file.name, + contentType + ) + + const newDocument = await createSingleDocument( + { + filename: file.name, + fileUrl: uploadedFile.url, + fileSize: file.size, + mimeType: contentType, + }, + knowledgeBaseId, + requestId, + userId + ) + + const documentData: DocumentData = { + documentId: newDocument.id, + filename: file.name, + fileUrl: uploadedFile.url, + fileSize: file.size, + mimeType: contentType, + } + + processDocumentsWithQueue([documentData], knowledgeBaseId, {}, requestId).catch(() => { + // Processing errors are logged internally by the queue. + }) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: newDocument.id, + resourceName: file.name, + description: `Uploaded document "${file.name}" to knowledge base via API`, + metadata: { knowledgeBaseId, fileSize: file.size, mimeType: contentType }, + request, + }) + + const document: V2KnowledgeDocumentSummary = { + id: newDocument.id, + knowledgeBaseId, + filename: newDocument.filename, + fileSize: newDocument.fileSize, + mimeType: newDocument.mimeType, + processingStatus: 'pending', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: newDocument.enabled, + createdAt: serializeDate(newDocument.uploadedAt), + } + + return v2Data({ document }, { rateLimit, status: 201 }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + + if (error instanceof Error) { + if ( + error.message.includes('Storage limit exceeded') || + error.message.includes('storage limit') + ) { + return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') + } + if (error.message.includes('already exists')) { + return v2Error('CONFLICT', 'Resource already exists') + } + } + + logger.error(`[${requestId}] Error uploading document`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts new file mode 100644 index 00000000000..79bb1b4b86c --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -0,0 +1,193 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + v2DeleteKnowledgeBaseContract, + v2GetKnowledgeBaseContract, + v2UpdateKnowledgeBaseContract, +} from '@/lib/api/contracts/v2/knowledge' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { formatKnowledgeBase, resolveKnowledgeBase } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface KnowledgeRouteParams { + params: Promise<{ id: string }> +} + +/** + * Resolves a knowledge base via the shared v1 ownership invariant + * ({@link resolveKnowledgeBase}: workspace access + KB-belongs-to-workspace) and + * renders any failure in the v2 envelope. A `404` (missing KB or workspace + * mismatch) is always `NOT_FOUND`; a `403` (no workspace access) is masked as + * `NOT_FOUND` on reads so cross-workspace KB existence never leaks, and surfaced + * as `FORBIDDEN` on writes. + */ +async function resolveKnowledgeBaseScoped( + id: string, + workspaceId: string, + userId: string, + rateLimit: RateLimitResult, + level: 'read' | 'write' +): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return level === 'read' + ? v2Error('NOT_FOUND', 'Knowledge base not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** GET /api/v2/knowledge/[id] — Get knowledge base details. */ +export const GET = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const result = await resolveKnowledgeBaseScoped( + id, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + return v2Data({ knowledgeBase: formatKnowledgeBase(result.kb) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error getting knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PUT /api/v2/knowledge/[id] — Update a knowledge base. */ +export const PUT = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UpdateKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, name, description, chunkingConfig } = parsed.data.body + + const result = await resolveKnowledgeBaseScoped(id, workspaceId, userId, rateLimit, 'write') + if (result instanceof NextResponse) return result + + const updates: { + name?: string + description?: string + chunkingConfig?: { maxSize: number; minSize: number; overlap: number } + } = {} + if (name !== undefined) updates.name = name + if (description !== undefined) updates.description = description + if (chunkingConfig !== undefined) updates.chunkingConfig = chunkingConfig + + const updatedKb = await updateKnowledgeBase(id, updates, requestId) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: id, + resourceName: updatedKb.name, + description: `Updated knowledge base "${updatedKb.name}" via API`, + metadata: { updatedFields: Object.keys(updates) }, + request, + }) + + return v2Data({ knowledgeBase: formatKnowledgeBase(updatedKb) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + if (error.message.includes('does not have permission')) { + return v2Error('FORBIDDEN', 'Access denied') + } + if (error.message.includes('already exists')) { + return v2Error('CONFLICT', 'Resource already exists') + } + } + + logger.error(`[${requestId}] Error updating knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/knowledge/[id] — Delete a knowledge base. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: KnowledgeRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const result = await resolveKnowledgeBaseScoped( + id, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + await deleteKnowledgeBase(id, requestId) + + recordAudit({ + workspaceId: parsed.data.query.workspaceId, + actorId: userId, + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: id, + resourceName: result.kb.name, + description: `Deleted knowledge base "${result.kb.name}" via API`, + request, + }) + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts new file mode 100644 index 00000000000..d1fb7d5b10d --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -0,0 +1,140 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateKnowledgeBaseContract, + v2ListKnowledgeBasesContract, +} from '@/lib/api/contracts/v2/knowledge' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' +import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' +import { formatKnowledgeBase } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/knowledge — List knowledge bases in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListKnowledgeBasesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const knowledgeBases = await getKnowledgeBases(userId, workspaceId) + const items = knowledgeBases.map(formatKnowledgeBase) + + // `getKnowledgeBases` returns the full bounded workspace set → single page. + return v2CursorList(items, null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing knowledge bases`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/knowledge — Create a new knowledge base. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2CreateKnowledgeBaseContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, name, description, chunkingConfig } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const kb = await createKnowledgeBase( + { + name, + description, + workspaceId, + userId, + embeddingModel: getConfiguredEmbeddingModel(), + embeddingDimension: EMBEDDING_DIMENSIONS, + chunkingConfig: chunkingConfig ?? { maxSize: 1024, minSize: 100, overlap: 200 }, + }, + requestId + ) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.KNOWLEDGE_BASE_CREATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: kb.id, + resourceName: kb.name, + description: `Created knowledge base "${kb.name}" via API`, + metadata: { chunkingConfig }, + request, + }) + + return v2Data({ knowledgeBase: formatKnowledgeBase(kb) }, { rateLimit, status: 201 }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + if (error.message.includes('does not have permission')) { + return v2Error('FORBIDDEN', 'Access denied') + } + if ( + error.message.includes('Storage limit exceeded') || + error.message.includes('storage limit') + ) { + return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') + } + if (error.message.includes('already exists')) { + return v2Error('CONFLICT', 'Resource already exists') + } + } + + logger.error(`[${requestId}] Error creating knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts new file mode 100644 index 00000000000..8f432bf467e --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -0,0 +1,299 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + type V2KnowledgeSearchResult, + v2SearchKnowledgeContract, +} from '@/lib/api/contracts/v2/knowledge' +import { isZodError, parseRequest } from '@/lib/api/server' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' +import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' +import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' +import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' +import type { StructuredFilter } from '@/lib/knowledge/types' +import { + generateSearchEmbedding, + getDocumentMetadataByIds, + getQueryStrategy, + handleTagAndVectorSearch, + handleTagOnlySearch, + handleVectorOnlySearch, + type SearchResult, +} from '@/app/api/knowledge/search/utils' +import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeSearchAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** POST /api/v2/knowledge/search — Vector / tag search across knowledge bases. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-search') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2SearchKnowledgeContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, topK, query, tagFilters } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + // A query incurs hosted embedding (+ optional rerank) cost — gate the actor's + // usage and frozen status before spending. Tag-only search is free, so skip it. + if (query && query.trim().length > 0) { + const usage = await checkActorUsageLimits(userId, workspaceId) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + } + + const knowledgeBaseIds = Array.isArray(parsed.data.body.knowledgeBaseIds) + ? parsed.data.body.knowledgeBaseIds + : [parsed.data.body.knowledgeBaseIds] + + const accessChecks = await Promise.all( + knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId)) + ) + const accessibleKbs = accessChecks + .filter( + (ac): ac is KnowledgeBaseAccessResult => + ac.hasAccess === true && ac.knowledgeBase.workspaceId === workspaceId + ) + .map((ac) => ac.knowledgeBase) + const accessibleKbIds = accessibleKbs.map((kb) => kb.id) + + if (accessibleKbIds.length === 0) { + return v2Error('NOT_FOUND', 'Knowledge base not found or access denied') + } + + const inaccessibleKbIds = knowledgeBaseIds.filter((id) => !accessibleKbIds.includes(id)) + if (inaccessibleKbIds.length > 0) { + return v2Error( + 'NOT_FOUND', + `Knowledge bases not found or access denied: ${inaccessibleKbIds.join(', ')}` + ) + } + + let structuredFilters: StructuredFilter[] = [] + const tagDefsCache = new Map>>() + + if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 1) { + return v2Error( + 'BAD_REQUEST', + 'Tag filters are only supported when searching a single knowledge base' + ) + } + + if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 0) { + const kbId = accessibleKbIds[0] + const tagDefs = await getDocumentTagDefinitions(kbId) + tagDefsCache.set(kbId, tagDefs) + + const displayNameToTagDef: Record = {} + tagDefs.forEach((def) => { + displayNameToTagDef[def.displayName] = { + tagSlot: def.tagSlot, + fieldType: def.fieldType, + } + }) + + const undefinedTags: string[] = [] + const typeErrors: string[] = [] + + for (const filter of tagFilters) { + const tagDef = displayNameToTagDef[filter.tagName] + if (!tagDef) { + undefinedTags.push(filter.tagName) + continue + } + const validationError = validateTagValue( + filter.tagName, + String(filter.value), + tagDef.fieldType + ) + if (validationError) { + typeErrors.push(validationError) + } + } + + if (undefinedTags.length > 0 || typeErrors.length > 0) { + const errorParts: string[] = [] + if (undefinedTags.length > 0) { + errorParts.push(buildUndefinedTagsError(undefinedTags)) + } + if (typeErrors.length > 0) { + errorParts.push(...typeErrors) + } + return v2Error('BAD_REQUEST', errorParts.join('\n')) + } + + structuredFilters = tagFilters.map((filter) => { + const tagDef = displayNameToTagDef[filter.tagName]! + return { + tagSlot: tagDef.tagSlot, + fieldType: tagDef.fieldType, + operator: filter.operator, + value: filter.value, + valueTo: filter.valueTo, + } + }) + } + + const hasQuery = Boolean(query && query.trim().length > 0) + const hasFilters = structuredFilters.length > 0 + + const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel))) + if (hasQuery && embeddingModels.length > 1) { + return v2Error( + 'BAD_REQUEST', + 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.' + ) + } + const queryEmbeddingModel = embeddingModels[0] + + let results: SearchResult[] + let queryEmbeddingIsBYOK: boolean | null = null + + if (!hasQuery && hasFilters) { + results = await handleTagOnlySearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + structuredFilters, + }) + } else if (hasQuery && hasFilters) { + const strategy = getQueryStrategy(accessibleKbIds.length, topK) + const queryEmbeddingResult = await generateSearchEmbedding( + query!, + queryEmbeddingModel, + workspaceId + ) + queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK + const queryVector = JSON.stringify(queryEmbeddingResult.embedding) + results = await handleTagAndVectorSearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + structuredFilters, + queryVector, + distanceThreshold: strategy.distanceThreshold, + }) + } else if (hasQuery) { + const strategy = getQueryStrategy(accessibleKbIds.length, topK) + const queryEmbeddingResult = await generateSearchEmbedding( + query!, + queryEmbeddingModel, + workspaceId + ) + queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK + const queryVector = JSON.stringify(queryEmbeddingResult.embedding) + results = await handleVectorOnlySearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + queryVector, + distanceThreshold: strategy.distanceThreshold, + }) + } else { + return v2Error('BAD_REQUEST', 'Either query or tagFilters must be provided') + } + + if (queryEmbeddingIsBYOK !== null) { + await recordSearchEmbeddingUsage({ + userId, + workspaceId, + embeddingModel: queryEmbeddingModel, + query: query!, + isBYOK: queryEmbeddingIsBYOK, + sourceReference: `v2-kb-search:${requestId}`, + }) + } + + const tagDefsResults = await Promise.all( + accessibleKbIds.map(async (kbId) => { + try { + const tagDefs = tagDefsCache.get(kbId) ?? (await getDocumentTagDefinitions(kbId)) + const map: Record = {} + tagDefs.forEach((def) => { + map[def.tagSlot] = def.displayName + }) + return { kbId, map } + } catch { + return { kbId, map: {} as Record } + } + }) + ) + const tagDefinitionsMap: Record> = {} + tagDefsResults.forEach(({ kbId, map }) => { + tagDefinitionsMap[kbId] = map + }) + + const documentIds = results.map((r) => r.documentId) + const documentMetadataMap = await getDocumentMetadataByIds(documentIds) + + const searchResults: V2KnowledgeSearchResult[] = results.map((result) => { + const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} + const metadata: Record = {} + + ALL_TAG_SLOTS.forEach((slot) => { + const tagValue = result[slot as keyof SearchResult] + if (tagValue !== null && tagValue !== undefined) { + const displayName = kbTagMap[slot] || slot + metadata[displayName] = tagValue + } + }) + + const docMeta = documentMetadataMap[result.documentId] + return { + documentId: result.documentId, + documentName: docMeta?.filename ?? null, + sourceUrl: docMeta?.sourceUrl ?? null, + content: result.content, + chunkIndex: result.chunkIndex, + metadata, + similarity: hasQuery ? 1 - result.distance : 1, + } + }) + + return v2Data( + { + results: searchResults, + query: query || '', + knowledgeBaseIds: accessibleKbIds, + topK, + totalResults: results.length, + }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + logger.error(`[${requestId}] Knowledge search error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts new file mode 100644 index 00000000000..e6c5e3dc5d2 --- /dev/null +++ b/apps/sim/app/api/v2/lib/response.ts @@ -0,0 +1,144 @@ +import { NextResponse } from 'next/server' +import type { ZodError } from 'zod' +import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' +import type { RateLimitResult, WorkspaceAccessError } from '@/app/api/v1/middleware' + +/** + * Runtime response helpers for the v2 API surface. Every v2 route renders its + * output through these so the envelope, error shape, and rate-limit headers stay + * identical across the whole surface. v2 routes reuse the v1 auth/rate-limit + * middleware and the platform domain services — these helpers only standardize + * the HTTP envelope. + */ + +export type V2ErrorCode = + | 'BAD_REQUEST' + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'CONFLICT' + | 'PAYLOAD_TOO_LARGE' + | 'UNSUPPORTED_MEDIA_TYPE' + | 'USAGE_LIMIT_EXCEEDED' + | 'LOCKED' + | 'RATE_LIMITED' + | 'INTERNAL_ERROR' + +const STATUS_BY_CODE: Record = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + USAGE_LIMIT_EXCEEDED: 402, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + PAYLOAD_TOO_LARGE: 413, + UNSUPPORTED_MEDIA_TYPE: 415, + LOCKED: 423, + RATE_LIMITED: 429, + INTERNAL_ERROR: 500, +} + +type RateLimitHeaderSource = Pick + +export function rateLimitHeaders(rateLimit?: RateLimitHeaderSource): Record { + if (!rateLimit) return {} + return { + 'X-RateLimit-Limit': rateLimit.limit.toString(), + 'X-RateLimit-Remaining': rateLimit.remaining.toString(), + 'X-RateLimit-Reset': rateLimit.resetAt.toISOString(), + } +} + +interface V2SuccessOptions { + rateLimit?: RateLimitHeaderSource + status?: number + headers?: Record +} + +function successHeaders(options: V2SuccessOptions): Record { + return { ...rateLimitHeaders(options.rateLimit), ...options.headers } +} + +/** `{ data }` (+ rate-limit headers). */ +export function v2Data(data: T, options: V2SuccessOptions = {}): NextResponse { + return NextResponse.json( + { data }, + { status: options.status ?? 200, headers: successHeaders(options) } + ) +} + +/** `{ 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 + headers?: Record +} + +/** `{ error: { code, message, details? } }`. */ +export function v2Error( + code: V2ErrorCode, + message: string, + options: V2ErrorOptions = {} +): NextResponse { + const error: { code: V2ErrorCode; message: string; details?: unknown } = { code, message } + if (options.details !== undefined) error.details = options.details + return NextResponse.json( + { error }, + { status: options.status ?? STATUS_BY_CODE[code], headers: options.headers } + ) +} + +/** Render a contract `ZodError` as the v2 error envelope. */ +export function v2ValidationError(error: ZodError): NextResponse { + return v2Error('BAD_REQUEST', getValidationErrorMessage(error, 'Invalid request'), { + details: serializeZodIssues(error), + }) +} + +/** 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 + * `Retry-After`. + */ +export function v2RateLimitError(rateLimit: RateLimitResult): NextResponse { + const headers = rateLimitHeaders(rateLimit) + if (rateLimit.error) { + return v2Error('UNAUTHORIZED', rateLimit.error, { headers }) + } + const retryAfterSeconds = rateLimit.retryAfterMs + ? Math.ceil(rateLimit.retryAfterMs / 1000) + : Math.ceil((rateLimit.resetAt.getTime() - Date.now()) / 1000) + return v2Error('RATE_LIMITED', 'API rate limit exceeded', { + headers: { ...headers, 'Retry-After': retryAfterSeconds.toString() }, + details: { retryAfter: rateLimit.resetAt.toISOString() }, + }) +} + +/** Opaque base64-JSON keyset cursor codec shared by all v2 cursor lists. */ +export function encodeCursor(data: Record): string { + return Buffer.from(JSON.stringify(data)).toString('base64') +} + +export function decodeCursor>(cursor: string): T | null { + try { + return JSON.parse(Buffer.from(cursor, 'base64').toString()) as T + } catch { + return null + } +} diff --git a/apps/sim/app/api/v2/logs/[id]/route.ts b/apps/sim/app/api/v2/logs/[id]/route.ts new file mode 100644 index 00000000000..698e59f10ed --- /dev/null +++ b/apps/sim/app/api/v2/logs/[id]/route.ts @@ -0,0 +1,109 @@ +import { db } from '@sim/db' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2LogDetail, v2GetLogContract } from '@/lib/api/contracts/v2/logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2LogDetailAPI') + +export const revalidate = 0 + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'logs-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetLogContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionId: workflowExecutionLogs.executionId, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + costTotal: workflowExecutionLogs.costTotal, + files: workflowExecutionLogs.files, + createdAt: workflowExecutionLogs.createdAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(eq(workflowExecutionLogs.id, id)) + .limit(1) + + const log = rows[0] + if (!log) return v2Error('NOT_FOUND', 'Log not found') + + // Convert an authorization failure into 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, log.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Log not found') + + const executionData = await materializeExecutionData( + log.executionData as Record | null, + { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } + ) + + const detail: V2LogDetail = { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + level: log.level, + trigger: log.trigger, + startedAt: log.startedAt.toISOString(), + endedAt: log.endedAt ? log.endedAt.toISOString() : null, + totalDurationMs: log.totalDurationMs, + files: (log.files as unknown[] | null) ?? null, + workflow: { + id: log.workflowId, + name: log.workflowName || 'Deleted Workflow', + description: log.workflowDescription, + folderId: log.workflowFolderId, + userId: log.workflowUserId, + workspaceId: log.workflowWorkspaceId, + createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null, + updatedAt: log.workflowUpdatedAt ? log.workflowUpdatedAt.toISOString() : null, + deleted: !log.workflowName, + }, + executionData, + cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, + createdAt: log.createdAt.toISOString(), + } + + return v2Data(detail, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Log detail fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts new file mode 100644 index 00000000000..da936577def --- /dev/null +++ b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts @@ -0,0 +1,74 @@ +import { db } from '@sim/db' +import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2Execution, v2GetExecutionContract } from '@/lib/api/contracts/v2/logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2ExecutionAPI') + +export const revalidate = 0 + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => { + try { + const rateLimit = await checkRateLimit(request, 'logs-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetExecutionContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { executionId } = parsed.data.params + + const rows = await db + .select() + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.executionId, executionId)) + .limit(1) + + if (rows.length === 0) return v2Error('NOT_FOUND', 'Workflow execution not found') + + const workflowLog = rows[0] + + // Convert an authorization failure into 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowLog.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow execution not found') + + const [snapshot] = await db + .select() + .from(workflowExecutionSnapshots) + .where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId)) + .limit(1) + + if (!snapshot) return v2Error('NOT_FOUND', 'Workflow state snapshot not found') + + const execution: V2Execution = { + executionId, + workflowId: workflowLog.workflowId, + workflowState: snapshot.stateData, + executionMetadata: { + trigger: workflowLog.trigger, + startedAt: workflowLog.startedAt.toISOString(), + endedAt: workflowLog.endedAt ? workflowLog.endedAt.toISOString() : null, + totalDurationMs: workflowLog.totalDurationMs, + cost: workflowLog.costTotal != null ? { total: Number(workflowLog.costTotal) } : null, + }, + } + + return v2Data(execution, { rateLimit }) + } catch (error) { + logger.error('Error fetching execution data', { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts new file mode 100644 index 00000000000..a4cc3372d37 --- /dev/null +++ b/apps/sim/app/api/v2/logs/route.ts @@ -0,0 +1,168 @@ +import { db } from '@sim/db' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { eq, sql } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2LogListItem, v2ListLogsContract } from '@/lib/api/contracts/v2/logs' +import { parseRequest } from '@/lib/api/server' +import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2LogsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'logs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListLogsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const filters = { + workspaceId: params.workspaceId, + workflowIds: params.workflowIds?.split(',').filter(Boolean), + folderIds: params.folderIds?.split(',').filter(Boolean), + triggers: params.triggers?.split(',').filter(Boolean), + level: params.level, + startDate: params.startDate ? new Date(params.startDate) : undefined, + endDate: params.endDate ? new Date(params.endDate) : undefined, + executionId: params.executionId, + minDurationMs: params.minDurationMs, + maxDurationMs: params.maxDurationMs, + minCost: params.minCost, + maxCost: params.maxCost, + model: params.model, + cursor: params.cursor + ? decodeCursor<{ startedAt: string; id: string }>(params.cursor) || undefined + : undefined, + order: params.order, + } + + const conditions = buildLogFilters(filters) + const orderBy = getOrderBy(params.order) + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionId: workflowExecutionLogs.executionId, + deploymentVersionId: workflowExecutionLogs.deploymentVersionId, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + costTotal: workflowExecutionLogs.costTotal, + files: workflowExecutionLogs.files, + executionData: params.details === 'full' ? workflowExecutionLogs.executionData : sql`null`, + workflowName: workflow.name, + workflowDescription: workflow.description, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(conditions) + .orderBy(...orderBy) + .limit(params.limit + 1) + + const hasMore = rows.length > params.limit + const data = rows.slice(0, params.limit) + + let nextCursor: string | null = null + if (hasMore && data.length > 0) { + const lastLog = data[data.length - 1] + nextCursor = encodeCursor({ startedAt: lastLog.startedAt.toISOString(), id: lastLog.id }) + } + + type LogRow = (typeof data)[number] + const buildItem = (log: LogRow): V2LogListItem => { + const item: V2LogListItem = { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + deploymentVersionId: log.deploymentVersionId, + level: log.level, + trigger: log.trigger, + startedAt: log.startedAt.toISOString(), + endedAt: log.endedAt ? log.endedAt.toISOString() : null, + totalDurationMs: log.totalDurationMs, + cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, + files: (log.files as unknown[] | null) ?? null, + } + if (params.details === 'full') { + item.workflow = { + id: log.workflowId, + name: log.workflowName || 'Deleted Workflow', + description: log.workflowDescription, + deleted: !log.workflowName, + } + } + return item + } + + const needsMaterialize = + params.details === 'full' && (params.includeFinalOutput || params.includeTraceSpans) + + const formattedLogs = needsMaterialize + ? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => { + const item = buildItem(log) + if (log.executionData) { + const execData = (await materializeExecutionData( + log.executionData as Record | null, + { + workspaceId: log.workspaceId, + workflowId: log.workflowId, + executionId: log.executionId, + } + )) as Record + if (params.includeFinalOutput && execData.finalOutput) { + item.finalOutput = execData.finalOutput + } + if (params.includeTraceSpans && execData.traceSpans) { + item.traceSpans = execData.traceSpans + } + } + return item + }) + : data.map(buildItem) + + return v2CursorList(formattedLogs, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Logs fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts new file mode 100644 index 00000000000..87c46b2cd75 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -0,0 +1,169 @@ +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v1DeployWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' +import { + v2DeployWorkflowContract, + v2UndeployWorkflowContract, +} from '@/lib/api/contracts/v2/workflows' +import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowDeployAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-deploy') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeployWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rawBody = await parseOptionalJsonBody(request) + if (!rawBody.success) { + return rawBody.response.status === 413 + ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') + : v2Error('BAD_REQUEST', 'Request body must be valid JSON') + } + const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {}) + if (!body.success) return v2ValidationError(body.error) + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const { workflow, workspaceId } = target + + await assertWorkflowMutable(id) + + logger.info(`[${requestId}] Deploying workflow ${id} via v2 API`, { userId }) + + const result = await performFullDeploy({ + workflowId: id, + userId, + workflowName: workflow.name || undefined, + versionName: body.data.name, + versionDescription: body.data.description ?? undefined, + requestId, + request, + }) + + if (!result.success) { + const code = + result.errorCode === 'not_found' + ? 'NOT_FOUND' + : result.errorCode === 'validation' + ? 'BAD_REQUEST' + : 'INTERNAL_ERROR' + return v2Error(code, result.error || 'Failed to deploy workflow') + } + + captureServerEvent( + userId, + 'workflow_deployed', + { workflow_id: id, workspace_id: workspaceId }, + { + groups: { workspace: workspaceId }, + setOnce: { first_workflow_deployed_at: new Date().toISOString() }, + } + ) + + return v2Data( + { + id, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version: result.version, + warnings: result.warnings ?? [], + }, + { rateLimit } + ) + } catch (error) { + if (error instanceof WorkflowLockedError) { + return v2Error('LOCKED', error.message) + } + logger.error(`[${requestId}] Workflow deploy error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-deploy') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UndeployWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const { workflow, workspaceId } = target + + if (!workflow.isDeployed) { + return v2Error('BAD_REQUEST', 'Workflow is not deployed') + } + + await assertWorkflowMutable(id) + + logger.info(`[${requestId}] Undeploying workflow ${id} via v2 API`, { userId }) + + const result = await performFullUndeploy({ workflowId: id, userId, requestId }) + if (!result.success) { + return v2Error('INTERNAL_ERROR', result.error || 'Failed to undeploy workflow') + } + + captureServerEvent( + userId, + 'workflow_undeployed', + { workflow_id: id, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + + return v2Data( + { + id, + isDeployed: false, + deployedAt: null, + warnings: result.warnings ?? [], + }, + { rateLimit } + ) + } catch (error) { + if (error instanceof WorkflowLockedError) { + return v2Error('LOCKED', error.message) + } + logger.error(`[${requestId}] Workflow undeploy error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts new file mode 100644 index 00000000000..634cf9957cf --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -0,0 +1,122 @@ +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v1RollbackWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' +import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performActivateVersion } from '@/lib/workflows/orchestration' +import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowRollbackAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-rollback') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2RollbackWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rawBody = await parseOptionalJsonBody(request) + if (!rawBody.success) { + return rawBody.response.status === 413 + ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') + : v2Error('BAD_REQUEST', 'Request body must be valid JSON') + } + const body = v1RollbackWorkflowBodySchema.safeParse(rawBody.data ?? {}) + if (!body.success) return v2ValidationError(body.error) + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const { workflow, workspaceId } = target + + if (!workflow.isDeployed) { + return v2Error('BAD_REQUEST', 'Workflow is not deployed') + } + + await assertWorkflowMutable(id) + + let targetVersion = body.data.version + if (targetVersion === undefined) { + const previous = await findPreviousDeploymentVersion(id) + if (!previous.ok) { + const message = + previous.reason === 'no_active_version' + ? 'Workflow has no active deployment to roll back from' + : 'No previous deployment version to roll back to' + return v2Error('BAD_REQUEST', message) + } + targetVersion = previous.version + } + + logger.info( + `[${requestId}] Rolling back workflow ${id} to version ${targetVersion} via v2 API`, + { userId } + ) + + const result = await performActivateVersion({ + workflowId: id, + version: targetVersion, + userId, + workflow: workflow as Record, + requestId, + request, + }) + + if (!result.success) { + const code = + result.errorCode === 'not_found' + ? 'NOT_FOUND' + : result.errorCode === 'validation' + ? 'BAD_REQUEST' + : 'INTERNAL_ERROR' + return v2Error(code, result.error || 'Failed to roll back workflow') + } + + captureServerEvent( + userId, + 'deployment_version_activated', + { workflow_id: id, workspace_id: workspaceId, version: targetVersion }, + { groups: { workspace: workspaceId } } + ) + + return v2Data( + { + id, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version: targetVersion, + warnings: result.warnings ?? [], + }, + { rateLimit } + ) + } catch (error) { + if (error instanceof WorkflowLockedError) { + return v2Error('LOCKED', error.message) + } + logger.error(`[${requestId}] Workflow rollback error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts new file mode 100644 index 00000000000..a059d669648 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -0,0 +1,81 @@ +import { db } from '@sim/db' +import { workflowBlocks } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2WorkflowDetail, v2GetWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowDetailAPI') + +export const revalidate = 0 + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + const blockRows = await db + .select({ + id: workflowBlocks.id, + type: workflowBlocks.type, + subBlocks: workflowBlocks.subBlocks, + }) + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, id)) + + const blocksRecord = Object.fromEntries( + blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }]) + ) + const inputs = extractInputFieldsFromBlocks(blocksRecord) + + const detail: V2WorkflowDetail = { + id: workflowData.id, + name: workflowData.name, + description: workflowData.description, + folderId: workflowData.folderId, + workspaceId: workflowData.workspaceId, + isDeployed: workflowData.isDeployed, + deployedAt: workflowData.deployedAt?.toISOString() ?? null, + runCount: workflowData.runCount, + lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, + variables: (workflowData.variables as Record | null) ?? {}, + inputs, + createdAt: workflowData.createdAt.toISOString(), + updatedAt: workflowData.updatedAt.toISOString(), + } + + return v2Data(detail, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflow details fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts new file mode 100644 index 00000000000..a35f045bda7 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -0,0 +1,142 @@ +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, asc, eq, gt, isNull, or } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2WorkflowListItem, v2ListWorkflowsContract } from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** Keyset cursor for the `(sortOrder, createdAt, id)` ordering. */ +interface WorkflowListCursor { + sortOrder: number + createdAt: string + id: string +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListWorkflowsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const conditions = [eq(workflow.workspaceId, params.workspaceId), isNull(workflow.archivedAt)] + + if (params.folderId) { + conditions.push(eq(workflow.folderId, params.folderId)) + } + + if (params.deployedOnly) { + conditions.push(eq(workflow.isDeployed, true)) + } + + if (params.cursor) { + const cursorData = decodeCursor(params.cursor) + if (cursorData) { + const cursorCondition = or( + gt(workflow.sortOrder, cursorData.sortOrder), + and( + eq(workflow.sortOrder, cursorData.sortOrder), + gt(workflow.createdAt, new Date(cursorData.createdAt)) + ), + and( + eq(workflow.sortOrder, cursorData.sortOrder), + eq(workflow.createdAt, new Date(cursorData.createdAt)), + gt(workflow.id, cursorData.id) + ) + ) + if (cursorCondition) { + conditions.push(cursorCondition) + } + } + } + + const rows = await db + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + folderId: workflow.folderId, + workspaceId: workflow.workspaceId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt, + sortOrder: workflow.sortOrder, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + }) + .from(workflow) + .where(and(...conditions)) + .orderBy(asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)) + .limit(params.limit + 1) + + const hasMore = rows.length > params.limit + const data = rows.slice(0, params.limit) + + let nextCursor: string | null = null + if (hasMore && data.length > 0) { + const last = data[data.length - 1] + nextCursor = encodeCursor({ + sortOrder: last.sortOrder, + createdAt: last.createdAt.toISOString(), + id: last.id, + }) + } + + const formatted: V2WorkflowListItem[] = data.map((w) => ({ + id: w.id, + name: w.name, + description: w.description, + folderId: w.folderId, + workspaceId: w.workspaceId ?? params.workspaceId, + isDeployed: w.isDeployed, + deployedAt: w.deployedAt?.toISOString() ?? null, + runCount: w.runCount, + lastRunAt: w.lastRunAt?.toISOString() ?? null, + createdAt: w.createdAt.toISOString(), + updatedAt: w.updatedAt.toISOString(), + })) + + return v2CursorList(formatted, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflows fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/lib/api/contracts/v1/admin/organizations.ts b/apps/sim/lib/api/contracts/v1/admin/organizations.ts index 1281b5e649d..f64fd8da4b4 100644 --- a/apps/sim/lib/api/contracts/v1/admin/organizations.ts +++ b/apps/sim/lib/api/contracts/v1/admin/organizations.ts @@ -142,7 +142,8 @@ const adminV1RemoveOrganizationMemberResultSchema = z.object({ memberId: z.string(), userId: z.string(), billingActions: z.object({ - usageCaptured: z.boolean(), + /** Dollar amount of departed-member usage captured (0 when none). */ + usageCaptured: z.number(), proRestored: z.boolean(), usageRestored: z.boolean(), skipBillingLogic: z.boolean(), @@ -159,8 +160,10 @@ const adminV1TransferOwnershipResultSchema = z.object({ currentOwnerUserId: z.string(), newOwnerUserId: z.string(), workspacesReassigned: z.number(), - billedAccountReassigned: z.boolean(), - overageMigrated: z.boolean(), + /** Count of workspaces whose billed account was reassigned to the new owner. */ + billedAccountReassigned: z.number(), + /** Decimal-string dollar amount of overage migrated to the new owner ('0' when none). */ + overageMigrated: z.string(), billingBlockInherited: z.boolean(), }) diff --git a/apps/sim/lib/api/contracts/v1/audit-logs.ts b/apps/sim/lib/api/contracts/v1/audit-logs.ts index f82b86e4b6d..4ce86e22e9b 100644 --- a/apps/sim/lib/api/contracts/v1/audit-logs.ts +++ b/apps/sim/lib/api/contracts/v1/audit-logs.ts @@ -1,5 +1,11 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' +import { + adminV1ListResponseSchema, + adminV1PaginationQuerySchema, + adminV1SingleResponseSchema, +} from '@/lib/api/contracts/v1/admin/shared' +import { v1UserLimitsSchema } from '@/lib/api/contracts/v1/shared' const isoDateString = z.string().refine((value) => !Number.isNaN(Date.parse(value)), { error: 'Invalid date format. Use ISO 8601.', @@ -43,25 +49,51 @@ export const v1AdminAuditLogsQuerySchema = z.object({ actorEmail: optionalQueryString, startDate: z.preprocess((value) => (value === '' ? undefined : value), isoDateString.optional()), endDate: z.preprocess((value) => (value === '' ? undefined : value), isoDateString.optional()), + ...adminV1PaginationQuerySchema.shape, }) /** - * Generic wrapper used by v1 admin audit-log responses. The `data` and - * `limits` halves are intentionally `z.unknown()` because this proxy returns - * provider-shaped payloads that vary per route family; tightening here would - * require a discriminated union per route, which is tracked as a follow-up. - * - * boundary-policy: this is the "validates nothing" alias form that the audit - * script's `untyped-response` regex doesn't currently catch. Treat any new - * wrapper of this shape the same way and either annotate at the contract use - * site with `// untyped-response: ` or replace with a concrete schema. + * Public enterprise audit-log entry. Mirrors `formatAuditLogEntry` in + * `app/api/v1/audit-logs/format.ts`; `ipAddress`/`userAgent` are intentionally + * excluded for privacy. `metadata` is genuinely arbitrary per-action JSON. */ -const apiResponseWithLimitsSchema = z - .object({ - data: z.unknown(), - limits: z.unknown().optional(), - }) - .passthrough() +const v1AuditLogEntrySchema = z.object({ + id: z.string(), + workspaceId: z.string().nullable(), + actorId: z.string().nullable(), + actorName: z.string().nullable(), + actorEmail: z.string().nullable(), + action: z.string(), + resourceType: z.string(), + resourceId: z.string().nullable(), + resourceName: z.string().nullable(), + description: z.string().nullable(), + metadata: z.unknown(), + createdAt: z.string(), +}) + +/** + * Admin audit-log entry. Mirrors `toAdminAuditLog` in `app/api/v1/admin/types.ts`, + * which additionally exposes `ipAddress`/`userAgent`. + */ +const adminV1AuditLogEntrySchema = v1AuditLogEntrySchema.extend({ + ipAddress: z.string().nullable(), + userAgent: z.string().nullable(), +}) + +const v1ListAuditLogsResponseSchema = z.object({ + data: z.array(v1AuditLogEntrySchema), + nextCursor: z.string().optional(), + limits: v1UserLimitsSchema, +}) + +const v1GetAuditLogResponseSchema = z.object({ + data: v1AuditLogEntrySchema, + limits: v1UserLimitsSchema, +}) + +export type V1AuditLogEntry = z.output +export type AdminV1AuditLogEntry = z.output export const v1ListAuditLogsContract = defineRouteContract({ method: 'GET', @@ -69,7 +101,7 @@ export const v1ListAuditLogsContract = defineRouteContract({ query: v1ListAuditLogsQuerySchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: v1ListAuditLogsResponseSchema, }, }) @@ -79,7 +111,7 @@ export const v1GetAuditLogContract = defineRouteContract({ params: v1AuditLogParamsSchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: v1GetAuditLogResponseSchema, }, }) @@ -89,7 +121,7 @@ export const v1AdminListAuditLogsContract = defineRouteContract({ query: v1AdminAuditLogsQuerySchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: adminV1ListResponseSchema(adminV1AuditLogEntrySchema), }, }) @@ -99,6 +131,6 @@ export const v1AdminGetAuditLogContract = defineRouteContract({ params: v1AuditLogParamsSchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: adminV1SingleResponseSchema(adminV1AuditLogEntrySchema), }, }) diff --git a/apps/sim/lib/api/contracts/v1/shared.ts b/apps/sim/lib/api/contracts/v1/shared.ts new file mode 100644 index 00000000000..9502e57ee5f --- /dev/null +++ b/apps/sim/lib/api/contracts/v1/shared.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' + +/** + * Rate-limit / usage envelope injected into every Family-A v1 response by + * `createApiResponse` (see `app/api/v1/logs/meta.ts`). Mirrors the `UserLimits` + * interface in that file. Shared here so logs, audit-logs, and workflows + * contracts describe `limits` identically instead of each redefining it. + */ +export const v1UserLimitsSchema = z.object({ + workflowExecutionRateLimit: z.object({ + sync: z.object({ + requestsPerMinute: z.number(), + maxBurst: z.number(), + remaining: z.number(), + resetAt: z.string(), + }), + async: z.object({ + requestsPerMinute: z.number(), + maxBurst: z.number(), + remaining: z.number(), + resetAt: z.string(), + }), + }), + usage: z.object({ + currentPeriodCost: z.number(), + limit: z.number(), + plan: z.string(), + isExceeded: z.boolean(), + }), +}) + +export type V1UserLimits = z.output + +/** + * Family-A envelope helper: `{ data, limits }`. Use for the `createApiResponse` + * detail/action surfaces (logs/[id], workflows deploy/rollback/undeploy). List + * endpoints that also return a `nextCursor` should compose the object directly + * (`{ data, nextCursor: z.string().optional(), limits: v1UserLimitsSchema }`). + */ +export const withV1Limits = (dataSchema: T) => + z.object({ + data: dataSchema, + limits: v1UserLimitsSchema, + }) diff --git a/apps/sim/lib/api/contracts/v2/audit-logs.ts b/apps/sim/lib/api/contracts/v2/audit-logs.ts new file mode 100644 index 00000000000..1084d9bbecb --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/audit-logs.ts @@ -0,0 +1,58 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1AuditLogParamsSchema, + v1ListAuditLogsQuerySchema, +} from '@/lib/api/contracts/v1/audit-logs' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 audit-logs contracts. These are org-scoped enterprise endpoints. The + * request schemas are reused verbatim from v1 (the query/param shape is + * unchanged); only the response envelope is upgraded to the canonical v2 + * shapes. The v1 `limits` body is dropped — usage limits live on the dedicated + * usage endpoint, not inlined into every response. + */ + +/** + * Public enterprise audit-log entry. Mirrors `formatAuditLogEntry` in + * `app/api/v1/audit-logs/format.ts` and the v1 `v1AuditLogEntrySchema`; + * `ipAddress`/`userAgent` are intentionally excluded for privacy. `metadata` is + * genuinely arbitrary per-action JSON. + */ +export const v2AuditLogEntrySchema = z.object({ + id: z.string(), + workspaceId: z.string().nullable(), + actorId: z.string().nullable(), + actorName: z.string().nullable(), + actorEmail: z.string().nullable(), + action: z.string(), + resourceType: z.string(), + resourceId: z.string().nullable(), + resourceName: z.string().nullable(), + description: z.string().nullable(), + metadata: z.unknown(), + createdAt: z.string(), +}) + +export type V2AuditLogEntry = z.output + +export const v2ListAuditLogsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/audit-logs', + query: v1ListAuditLogsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2AuditLogEntrySchema), + }, +}) + +export const v2GetAuditLogContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/audit-logs/[id]', + params: v1AuditLogParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2AuditLogEntrySchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts new file mode 100644 index 00000000000..040ffa4dc80 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -0,0 +1,112 @@ +import { z } from 'zod' +import { workspaceFileIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 files contracts. v2 drops the v1 `{ success, data, limits }` envelope in + * favor of the canonical v2 shapes (`{ data }` / `{ data, nextCursor }`) and + * adds cursor pagination to the list. The workspace is always carried as a query + * param — including on upload — so the route can authorize before reading the + * multipart body. + */ + +/** A workspace file as exposed by the v2 surface. */ +export const v2FileSchema = z.object({ + id: z.string(), + name: z.string(), + size: z.number().nonnegative(), + type: z.string(), + key: z.string(), + uploadedBy: z.string(), + /** ISO-8601 timestamp. */ + uploadedAt: z.string(), +}) + +export type V2File = z.output + +/** Acknowledgement returned by a successful archive (soft delete). */ +export const v2DeleteFileResultSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) + +export type V2DeleteFileResult = z.output + +export const v2FileParamsSchema = z.object({ + fileId: workspaceFileIdSchema, +}) + +export type V2FileParams = z.output + +/** + * List query: workspace scope plus opaque keyset cursor pagination keyed on + * `(uploadedAt, id)`. `limit` clamps to `[1, 1000]` (default 100) to bound the + * response. The cursor is the base64-JSON codec shared across the v2 surface. + */ +export const v2ListFilesQuerySchema = z.object({ + workspaceId: workspaceIdSchema, + limit: z.coerce + .number() + .optional() + .default(100) + .transform((v) => Math.min(Math.max(1, Math.trunc(v)), 1000)), + cursor: z.string().min(1).optional(), +}) + +export type V2ListFilesQuery = z.output + +/** Upload carries the workspace as a query param so auth runs before buffering. */ +export const v2UploadFileQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) + +export type V2UploadFileQuery = z.output + +/** Download/delete both target a single file within a workspace-scoped query. */ +export const v2FileWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) + +export type V2FileWorkspaceQuery = z.output + +export const v2ListFilesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files', + query: v2ListFilesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2FileSchema), + }, +}) + +export const v2UploadFileContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files', + query: v2UploadFileQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileSchema), + }, +}) + +export const v2DownloadFileContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/[fileId]', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'binary', + }, +}) + +export const v2DeleteFileContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/files/[fileId]', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteFileResultSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts new file mode 100644 index 00000000000..06f4064d2fb --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -0,0 +1,270 @@ +import { z } from 'zod' +import { knowledgeBaseDataSchema } from '@/lib/api/contracts/knowledge/base' +import { documentDataSchema } from '@/lib/api/contracts/knowledge/documents' +import { + knowledgeBaseParamsSchema, + knowledgeDocumentParamsSchema, + nullableWireDateSchema, +} from '@/lib/api/contracts/knowledge/shared' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1CreateKnowledgeBaseBodySchema, + v1KnowledgeSearchBodySchema, + v1KnowledgeWorkspaceQuerySchema, + v1ListKnowledgeBasesQuerySchema, + v1ListKnowledgeDocumentsQuerySchema, + v1UpdateKnowledgeBaseBodySchema, +} from '@/lib/api/contracts/v1/knowledge' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 knowledge contracts. + * + * Request shapes (params/query/body) are reused verbatim from the v1 public + * contract (`@/lib/api/contracts/v1/knowledge`) — the public request surface is + * unchanged. Only the response envelope is upgraded to the canonical v2 shapes + * (`{ data }` for single/mutation, `{ data, pagination }` for the offset-paginated + * document list), and the success `message` strings v1 inlined are dropped. + * + * The concrete `data` item schemas reuse the first-party knowledge data schemas + * as their source of truth: the knowledge-base item is a `.pick()` of + * {@link knowledgeBaseDataSchema} matching `formatKnowledgeBase`'s projection, + * and the document items reuse the core fields of {@link documentDataSchema}. The + * v2 (and v1-public) document projection renames `uploadedAt` to `createdAt` and + * omits `fileUrl`/tag slots, so that rename is layered on via `.extend()`. + */ + +/** + * Knowledge-base item — the exact subset `formatKnowledgeBase` projects from a + * {@link KnowledgeBaseWithCounts}. `userId`, `workspaceId`, and `deletedAt` are + * intentionally not exposed on the public surface. + */ +export const v2KnowledgeBaseSchema = knowledgeBaseDataSchema.pick({ + id: true, + name: true, + description: true, + tokenCount: true, + embeddingModel: true, + embeddingDimension: true, + chunkingConfig: true, + docCount: true, + connectorTypes: true, + createdAt: true, + updatedAt: true, +}) +export type V2KnowledgeBase = z.output + +/** `{ knowledgeBase }` payload for single-KB reads and mutations. */ +export const v2KnowledgeBaseDataSchema = z.object({ knowledgeBase: v2KnowledgeBaseSchema }) +export type V2KnowledgeBaseData = z.output + +/** Delete acknowledgement — the id of the resource that was deleted. */ +export const v2KnowledgeDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2KnowledgeDeleteData = z.output + +/** + * Document core fields shared by the list item and the detail payload, reused + * from the first-party {@link documentDataSchema}. + */ +const v2KnowledgeDocumentCoreSchema = documentDataSchema.pick({ + id: true, + knowledgeBaseId: true, + filename: true, + fileSize: true, + mimeType: true, + processingStatus: true, + chunkCount: true, + tokenCount: true, + characterCount: true, + enabled: true, +}) + +/** + * Document list item / upload acknowledgement. `createdAt` is the public rename + * of the underlying `uploadedAt` column. + */ +export const v2KnowledgeDocumentSummarySchema = v2KnowledgeDocumentCoreSchema.extend({ + createdAt: nullableWireDateSchema, +}) +export type V2KnowledgeDocumentSummary = z.output + +/** + * Document detail — the summary plus processing state and connector provenance. + * Every field is always present (nullable), mirroring the v1 detail projection. + */ +export const v2KnowledgeDocumentSchema = v2KnowledgeDocumentSummarySchema.extend({ + processingError: z.string().nullable(), + processingStartedAt: nullableWireDateSchema, + processingCompletedAt: nullableWireDateSchema, + connectorId: z.string().nullable(), + connectorType: z.string().nullable(), + sourceUrl: z.string().nullable(), +}) +export type V2KnowledgeDocument = z.output + +/** `{ document }` payload for the upload acknowledgement (summary shape). */ +export const v2KnowledgeDocumentSummaryDataSchema = z.object({ + document: v2KnowledgeDocumentSummarySchema, +}) +export type V2KnowledgeDocumentSummaryData = z.output + +/** `{ document }` payload for the document detail read. */ +export const v2KnowledgeDocumentDataSchema = z.object({ document: v2KnowledgeDocumentSchema }) +export type V2KnowledgeDocumentData = z.output + +/** + * A single vector/tag search hit. `metadata` is the document's display-named tag + * map; values are user-defined and of mixed type (string/number/boolean/date), + * so they are carried as `unknown` and serialized as-is. + */ +export const v2KnowledgeSearchResultSchema = z.object({ + documentId: z.string(), + documentName: z.string().nullable(), + sourceUrl: z.string().nullable(), + content: z.string(), + chunkIndex: z.number(), + metadata: z.record(z.string(), z.unknown()), + similarity: z.number(), +}) +export type V2KnowledgeSearchResult = z.output + +/** Search response payload — mirrors the v1 `data` object. */ +export const v2KnowledgeSearchDataSchema = z.object({ + results: z.array(v2KnowledgeSearchResultSchema), + query: z.string(), + knowledgeBaseIds: z.array(z.string()), + topK: z.number(), + totalResults: z.number(), +}) +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 }) +export type V2UploadKnowledgeDocumentQuery = z.output + +/** + * KB list. `getKnowledgeBases` returns the full workspace set (a small, bounded + * per-workspace list), so today the cursor list is a single full page + * (`nextCursor` always `null`). The canonical cursor envelope keeps the v2 list + * surface uniform; real pagination can be added later behind the opaque cursor. + */ +export const v2ListKnowledgeBasesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge', + query: v1ListKnowledgeBasesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2KnowledgeBaseSchema), + }, +}) + +export const v2CreateKnowledgeBaseContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge', + body: v1CreateKnowledgeBaseBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeBaseDataSchema), + }, +}) + +export const v2GetKnowledgeBaseContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]', + params: knowledgeBaseParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeBaseDataSchema), + }, +}) + +export const v2UpdateKnowledgeBaseContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/knowledge/[id]', + params: knowledgeBaseParamsSchema, + body: v1UpdateKnowledgeBaseBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeBaseDataSchema), + }, +}) + +export const v2DeleteKnowledgeBaseContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/[id]', + params: knowledgeBaseParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDeleteDataSchema), + }, +}) + +export const v2SearchKnowledgeContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/search', + body: v1KnowledgeSearchBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeSearchDataSchema), + }, +}) + +/** + * 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. + */ +export const v2ListKnowledgeDocumentsQuerySchema = v1ListKnowledgeDocumentsQuerySchema + .omit({ offset: true }) + .extend({ cursor: z.string().min(1).optional() }) +export type V2ListKnowledgeDocumentsQuery = z.output + +export const v2ListKnowledgeDocumentsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]/documents', + params: knowledgeBaseParamsSchema, + query: v2ListKnowledgeDocumentsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2KnowledgeDocumentSummarySchema), + }, +}) + +export const v2UploadKnowledgeDocumentContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/[id]/documents', + params: knowledgeBaseParamsSchema, + query: v2UploadKnowledgeDocumentQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDocumentSummaryDataSchema), + }, +}) + +export const v2GetKnowledgeDocumentContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + params: knowledgeDocumentParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDocumentDataSchema), + }, +}) + +export const v2DeleteKnowledgeDocumentContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + params: knowledgeDocumentParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts new file mode 100644 index 00000000000..774aceb8794 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -0,0 +1,123 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1ExecutionParamsSchema, + v1ListLogsQuerySchema, + v1LogParamsSchema, +} from '@/lib/api/contracts/v1/logs' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 logs contracts. The query schemas are reused verbatim from v1 (the request + * shape is unchanged); only the response envelope is upgraded to the canonical + * v2 shapes with concrete item schemas. + */ + +const v2LogCostSchema = z.object({ total: z.number() }).nullable() + +/** Execution `files` is a per-run jsonb array of attachment metadata. */ +const v2LogFilesSchema = z.array(z.unknown()).nullable() + +const v2LogWorkflowSummarySchema = z.object({ + id: z.string().nullable(), + name: z.string(), + description: z.string().nullable(), + deleted: z.boolean(), +}) + +export const v2LogListItemSchema = z.object({ + id: z.string(), + workflowId: z.string().nullable(), + executionId: z.string(), + deploymentVersionId: z.string().nullable(), + level: z.string(), + trigger: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + totalDurationMs: z.number().nullable(), + cost: v2LogCostSchema, + files: v2LogFilesSchema, + /** Present only when `details=full`. */ + workflow: v2LogWorkflowSummarySchema.optional(), + /** Present only when `details=full` and `includeFinalOutput=true`. */ + finalOutput: z.unknown().optional(), + /** Present only when `details=full` and `includeTraceSpans=true`. */ + traceSpans: z.unknown().optional(), +}) + +export type V2LogListItem = z.output + +export const v2LogDetailSchema = z.object({ + id: z.string(), + workflowId: z.string().nullable(), + executionId: z.string(), + level: z.string(), + trigger: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + totalDurationMs: z.number().nullable(), + files: v2LogFilesSchema, + workflow: z.object({ + id: z.string().nullable(), + name: z.string(), + description: z.string().nullable(), + folderId: z.string().nullable(), + userId: z.string().nullable(), + workspaceId: z.string().nullable(), + createdAt: z.string().nullable(), + updatedAt: z.string().nullable(), + deleted: z.boolean(), + }), + /** Materialized execution trace (block states, trace spans). */ + executionData: z.unknown(), + cost: v2LogCostSchema, + createdAt: z.string(), +}) + +export type V2LogDetail = z.output + +export const v2ExecutionSchema = z.object({ + executionId: z.string(), + workflowId: z.string().nullable(), + /** Workflow state snapshot at execution time. */ + workflowState: z.unknown(), + executionMetadata: z.object({ + trigger: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + totalDurationMs: z.number().nullable(), + cost: v2LogCostSchema, + }), +}) + +export type V2Execution = z.output + +export const v2ListLogsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs', + query: v1ListLogsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2LogListItemSchema), + }, +}) + +export const v2GetLogContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs/[id]', + params: v1LogParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2LogDetailSchema), + }, +}) + +export const v2GetExecutionContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs/executions/[executionId]', + params: v1ExecutionParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ExecutionSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts new file mode 100644 index 00000000000..d0579054727 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' + +/** + * Shared building blocks for the v2 API contract surface. + * + * v2 standardizes on a single response family across every endpoint: + * - single resource: `{ data: T }` + * - list: `{ data: T[], nextCursor: string | null }` + * - error: `{ error: { code, message, details? } }` + * + * Every list uses the opaque-cursor envelope (Stripe/Slack-style): `limit` + + * `cursor` in, `{ data, nextCursor }` out. Cursors are opaque so the underlying + * scheme (keyset / offset / full-set) can change without a contract change. + * Total counts are not returned on lists — they're available on the parent + * resource where relevant (e.g. `rowCount` on a table, `docCount` on a KB). + * + * Rate-limit state is carried in `X-RateLimit-*` response headers (not the + * body). Usage limits are available from the dedicated usage endpoint rather + * than being inlined into every response. + */ + +/** Canonical v2 error envelope. */ +export const v2ErrorResponseSchema = z.object({ + error: z.object({ + code: z.string(), + message: z.string(), + details: z.unknown().optional(), + }), +}) + +/** `{ data: T }` */ +export const v2DataResponse = (dataSchema: T) => z.object({ data: dataSchema }) + +/** `{ data: T[], nextCursor: string | null }` — the v2 list envelope. */ +export const v2CursorListResponse = (itemSchema: T) => + z.object({ + data: z.array(itemSchema), + nextCursor: z.string().nullable(), + }) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts new file mode 100644 index 00000000000..05ca4a36fe8 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -0,0 +1,112 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1DeployWorkflowDataSchema, + v1ListWorkflowsQuerySchema, + v1RollbackWorkflowDataSchema, +} from '@/lib/api/contracts/v1/workflows' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows' + +/** + * v2 workflows contracts. Request shapes are reused verbatim from v1 (the list + * query and `[id]` param are unchanged); only the response envelope is upgraded + * to the canonical v2 shapes with concrete item/detail schemas. The + * deploy/rollback/undeploy data payloads reuse the already-concrete v1 schemas, + * re-wrapped in `v2DataResponse` (the v1 `limits` body field is dropped — v2 + * carries rate-limit state in headers and usage on a dedicated endpoint). + */ + +export const v2WorkflowListItemSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + folderId: z.string().nullable(), + workspaceId: z.string(), + isDeployed: z.boolean(), + deployedAt: z.string().nullable(), + runCount: z.number(), + lastRunAt: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +export type V2WorkflowListItem = z.output + +/** A single trigger input field extracted from the workflow's input-definition block. */ +const v2WorkflowInputFieldSchema = z.object({ + name: z.string(), + type: z.string(), + description: z.string().optional(), +}) + +export const v2WorkflowDetailSchema = v2WorkflowListItemSchema.extend({ + /** + * Workflow-scoped variables keyed by variable id. Each value is a structured + * variable object (`{ id, name, type, value, ... }`); only the inner `value` + * is user-defined/free-form. Kept as `unknown` to tolerate legacy/unstamped + * rows — tightening to a concrete object schema later is consumer-safe (the + * wire already carries the full object), so it stays additively evolvable. + */ + variables: z.record(z.string(), z.unknown()), + inputs: z.array(v2WorkflowInputFieldSchema), +}) + +export type V2WorkflowDetail = z.output + +/** + * Undeploy returns the deployment state without a version number. Derived from + * the exported v1 deploy data schema (its private base is not exported) so the + * shape stays in lockstep with v1. + */ +const v2UndeployWorkflowDataSchema = v1DeployWorkflowDataSchema.omit({ version: true }) + +export const v2ListWorkflowsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows', + query: v1ListWorkflowsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowListItemSchema), + }, +}) + +export const v2GetWorkflowContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowDetailSchema), + }, +}) + +export const v2DeployWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/deploy', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v1DeployWorkflowDataSchema), + }, +}) + +export const v2UndeployWorkflowContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/workflows/[id]/deploy', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UndeployWorkflowDataSchema), + }, +}) + +export const v2RollbackWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/rollback', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v1RollbackWorkflowDataSchema), + }, +}) diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index ce11157ccb7..d9dc248abaf 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -41,6 +41,12 @@ export interface PerformDeleteWorkspaceFileItemsParams { userId: string fileIds?: string[] folderIds?: string[] + /** + * Optional originating request, forwarded to the audit log so the deletion + * entry captures client IP / user agent. Omitted by in-app callers that have + * no HTTP request in scope. + */ + request?: { headers: { get(name: string): string | null } } } export interface PerformDeleteWorkspaceFileItemsResult { @@ -138,7 +144,7 @@ export interface PerformRestoreWorkspaceFileFolderResult { export async function performDeleteWorkspaceFileItems( params: PerformDeleteWorkspaceFileItemsParams ): Promise { - const { workspaceId, userId, fileIds = [], folderIds = [] } = params + const { workspaceId, userId, fileIds = [], folderIds = [], request } = params if (fileIds.length === 0 && folderIds.length === 0) { return { @@ -173,6 +179,7 @@ export async function performDeleteWorkspaceFileItems( resourceType: AuditResourceType.FILE, description: `Deleted ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}`, metadata: { fileIds }, + request, }) } @@ -191,6 +198,7 @@ export async function performDeleteWorkspaceFileItems( folders: deletedItems.folders, }, }, + request, }) } From 65dc7e8495ee0939d1acb6b37f12712aef9fae46 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 11:17:33 -0700 Subject: [PATCH 004/159] feat(usage): accept X-API-Key on usage-logs list + export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/users/me/usage-logs and /export now use checkHybridAuth — the same auth /api/users/me/usage-limits already accepts — so external monitors can read summary.bySourceCredits (the source breakdown of usage-limits' aggregate currentPeriodCost) instead of estimating Copilot spend by subtraction. Workspace-scoped keys are pinned to their own workspace's slice of the ledger: the filter defaults to the key's workspace and an explicit mismatch 403s. Both endpoints documented in openapi-core.json. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- apps/docs/openapi-core.json | 285 ++++++++++++++++++ .../api/users/me/usage-logs/export/route.ts | 14 +- .../app/api/users/me/usage-logs/route.test.ts | 54 +++- apps/sim/app/api/users/me/usage-logs/route.ts | 19 +- .../sim/app/api/users/me/usage-logs/shared.ts | 31 ++ 5 files changed, 394 insertions(+), 9 deletions(-) diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index 53a99c2e866..e5bfe75bf6d 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -1025,6 +1025,291 @@ }, "parameters": [] } + }, + "/api/users/me/usage-logs": { + "get": { + "operationId": "listUsageLogs", + "summary": "List Usage Logs", + "description": "The authenticated account's credit-consuming usage events with a per-source summary. Accepts a session or `X-API-Key` — the same key `GET /api/users/me/usage-limits` accepts; `summary.bySourceCredits` is the source breakdown of that endpoint's aggregate `currentPeriodCost`, suitable for monitoring e.g. Copilot consumption. Workspace-scoped keys read only their own workspace's slice of the ledger.", + "tags": ["Usage"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "source", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restrict to one usage source (e.g. `workflow`, `copilot`). Omit for all sources." + }, + { + "name": "workspaceId", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restrict to one workspace. A workspace-scoped API key is always pinned to its own workspace; passing a different id returns 403." + }, + { + "name": "period", + "in": "query", + "required": false, + "schema": { + "enum": ["1d", "7d", "30d", "custom", "all"], + "default": "30d" + }, + "description": "Relative window, `all`, or `custom` (requires `startDate`)." + }, + { + "name": "startDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Start of a `custom` window. Any `Date`-parseable string." + }, + { + "name": "endDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "End of a `custom` window; defaults to now." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Opaque cursor from the previous page." + }, + { + "name": "includeCredits", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": true + }, + "description": "Set `false` to skip per-row credit apportionment when only the summary is needed." + } + ], + "responses": { + "200": { + "description": "A page of usage events with the per-source credit summary.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["success", "logs", "summary", "pagination"], + "properties": { + "success": { + "type": "boolean" + }, + "logs": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "createdAt", + "source", + "workflowName", + "creditCost", + "dollarCost" + ], + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string", + "description": "Usage source: `workflow`, `copilot`, and the other credit-consuming surfaces." + }, + "workflowName": { + "type": ["string", "null"], + "description": "Populated only when `source` is `workflow`." + }, + "creditCost": { + "type": "number", + "description": "Credit-denominated cost (1,000 credits = $5), apportioned so page rows sum exactly to the rounded page total." + }, + "dollarCost": { + "type": "number", + "description": "Raw dollar cost, so a 0 `creditCost` can be distinguished from a genuinely free event." + } + } + } + }, + "summary": { + "type": "object", + "required": ["totalCredits", "bySourceCredits"], + "properties": { + "totalCredits": { + "type": "number" + }, + "bySourceCredits": { + "type": "object", + "additionalProperties": { + "type": "number" + }, + "description": "Credits per usage source over the whole filter — the source-aware breakdown of `usage-limits`’ aggregate `currentPeriodCost`." + } + } + }, + "pagination": { + "type": "object", + "required": ["hasMore"], + "properties": { + "nextCursor": { + "type": "string" + }, + "hasMore": { + "type": "boolean" + } + } + } + } + }, + "example": { + "success": true, + "logs": [ + { + "id": "log_1", + "createdAt": "2026-07-29T18:04:11.000Z", + "source": "copilot", + "workflowName": null, + "creditCost": 12, + "dollarCost": 0.06 + } + ], + "summary": { + "totalCredits": 512, + "bySourceCredits": { + "workflow": 380, + "copilot": 120, + "knowledge-base": 12 + } + }, + "pagination": { + "hasMore": false + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/users/me/usage-logs/export": { + "get": { + "operationId": "exportUsageLogs", + "summary": "Export Usage Logs (CSV)", + "description": "Every usage event matching the filter as a CSV download (`Date`, `Type`, `Credits`) — the unpaginated form of `GET /api/users/me/usage-logs`, same auth and workspace-key scoping.", + "tags": ["Usage"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "source", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restrict to one usage source (e.g. `workflow`, `copilot`). Omit for all sources." + }, + { + "name": "workspaceId", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restrict to one workspace. A workspace-scoped API key is always pinned to its own workspace; passing a different id returns 403." + }, + { + "name": "period", + "in": "query", + "required": false, + "schema": { + "enum": ["1d", "7d", "30d", "custom", "all"], + "default": "30d" + }, + "description": "Relative window, `all`, or `custom` (requires `startDate`)." + }, + { + "name": "startDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Start of a `custom` window. Any `Date`-parseable string." + }, + { + "name": "endDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "End of a `custom` window; defaults to now." + } + ], + "responses": { + "200": { + "description": "CSV attachment. `X-Export-Truncated: 1` signals the 50,000-row safety cap was hit.", + "content": { + "text/csv": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } } }, "components": { diff --git a/apps/sim/app/api/users/me/usage-logs/export/route.ts b/apps/sim/app/api/users/me/usage-logs/export/route.ts index d32e6d59837..052ed96de6c 100644 --- a/apps/sim/app/api/users/me/usage-logs/export/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/export/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { exportUsageLogsContract } from '@/lib/api/contracts/user' import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { checkHybridAuth } from '@/lib/auth/hybrid' import { getUsageCreditsByLogId, getUserUsageLogs, @@ -10,7 +10,10 @@ import { } from '@/lib/billing/core/usage-log' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { formatCsvValue, toCsvRow } from '@/lib/table/export-format' -import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' +import { + resolveDateRange, + resolveUsageLogsWorkspaceFilter, +} from '@/app/api/users/me/usage-logs/shared' import { USAGE_LOG_SOURCE_LABELS } from '@/app/api/users/me/usage-logs/source-labels' const logger = createLogger('UsageLogsExportAPI') @@ -33,7 +36,7 @@ const CSV_HEADER = toCsvRow(['Date', 'Type', 'Credits']) * (unlike, say, a workspace's full execution history). */ export const GET = withRouteHandler(async (request: NextRequest) => { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + const auth = await checkHybridAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -42,10 +45,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { source, workspaceId, period, startDate, endDate } = parsed.data.query + const workspaceFilter = resolveUsageLogsWorkspaceFilter(auth, workspaceId) + if (!workspaceFilter.ok) return workspaceFilter.response + const dateRange = resolveDateRange(period, startDate, endDate) const filter = { source: source as UsageLogSource | undefined, - workspaceId, + workspaceId: workspaceFilter.workspaceId, startDate: dateRange.startDate, endDate: dateRange.endDate, } diff --git a/apps/sim/app/api/users/me/usage-logs/route.test.ts b/apps/sim/app/api/users/me/usage-logs/route.test.ts index a45c8a0ff92..256f73b9f48 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.test.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { authMockFns, createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest, hybridAuthMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { apportionCredits } from '@/lib/billing/credits/conversion' @@ -46,6 +46,58 @@ describe('GET /api/users/me/usage-logs', () => { expect(response.status).toBe(401) }) + it('accepts a personal API key and reads that user’s ledger unscoped', async () => { + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'key-owner', + authType: 'api_key', + apiKeyType: 'personal', + }) + + const response = await GET(createMockRequest('GET')) + + expect(response.status).toBe(200) + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'key-owner', + expect.objectContaining({ workspaceId: undefined }) + ) + }) + + it('pins a workspace API key to its own workspace’s slice of the ledger', async () => { + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'key-owner', + workspaceId: 'ws-1', + authType: 'api_key', + apiKeyType: 'workspace', + }) + + const response = await GET(createMockRequest('GET')) + + expect(response.status).toBe(200) + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'key-owner', + expect.objectContaining({ workspaceId: 'ws-1' }) + ) + }) + + it('rejects a workspace API key asking for a different workspace', async () => { + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'key-owner', + workspaceId: 'ws-1', + authType: 'api_key', + apiKeyType: 'workspace', + }) + + const response = await GET( + createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?workspaceId=ws-2') + ) + + expect(response.status).toBe(403) + expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + }) + it('converts dollar costs to credits in the logs and summary', async () => { const response = await GET(createMockRequest('GET')) const body = await response.json() diff --git a/apps/sim/app/api/users/me/usage-logs/route.ts b/apps/sim/app/api/users/me/usage-logs/route.ts index d976d188bc8..56d86cba761 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getUsageLogsContract } from '@/lib/api/contracts/user' import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { checkHybridAuth } from '@/lib/auth/hybrid' import { getUsageCreditsByLogId, getUserUsageLogs, @@ -10,16 +10,24 @@ import { } from '@/lib/billing/core/usage-log' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' +import { + resolveDateRange, + resolveUsageLogsWorkspaceFilter, +} from '@/app/api/users/me/usage-logs/shared' const logger = createLogger('UsageLogsAPI') /** * Lists the authenticated user's credit-consuming usage events (model, tool, * and fixed charges), converted to credits for display in Billing settings. + * + * Accepts session auth AND `X-API-Key` (matching `/api/users/me/usage-limits`, + * whose aggregate `currentPeriodCost` this endpoint's `summary` breaks down by + * source) so external monitors can watch e.g. Copilot consumption. Workspace + * keys are pinned to their own workspace's slice of the ledger. */ export const GET = withRouteHandler(async (request: NextRequest) => { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + const auth = await checkHybridAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -29,11 +37,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { source, workspaceId, period, startDate, endDate, limit, cursor, includeCredits } = parsed.data.query + const workspaceFilter = resolveUsageLogsWorkspaceFilter(auth, workspaceId) + if (!workspaceFilter.ok) return workspaceFilter.response + const dateRange = resolveDateRange(period, startDate, endDate) const filter = { source: source as UsageLogSource | undefined, - workspaceId, + workspaceId: workspaceFilter.workspaceId, startDate: dateRange.startDate, endDate: dateRange.endDate, } diff --git a/apps/sim/app/api/users/me/usage-logs/shared.ts b/apps/sim/app/api/users/me/usage-logs/shared.ts index 1c143248edc..e751ec52a87 100644 --- a/apps/sim/app/api/users/me/usage-logs/shared.ts +++ b/apps/sim/app/api/users/me/usage-logs/shared.ts @@ -1,7 +1,38 @@ +import { NextResponse } from 'next/server' import type { UsageLogPeriod } from '@/lib/api/contracts/user' +import type { AuthResult } from '@/lib/auth/hybrid' const PERIOD_TO_DAYS: Record<'1d' | '7d' | '30d', number> = { '1d': 1, '7d': 7, '30d': 30 } +type WorkspaceFilterResult = + | { ok: true; workspaceId: string | undefined } + | { ok: false; response: NextResponse } + +/** + * Resolves the effective `workspaceId` ledger filter for the caller's + * credential. Sessions, internal JWTs, and personal API keys read the + * authenticated user's full ledger with whatever filter they asked for; a + * workspace-scoped API key is pinned to its own workspace — the filter + * defaults to the key's workspace and an explicit mismatch is rejected rather + * than silently ignored. + */ +export function resolveUsageLogsWorkspaceFilter( + auth: AuthResult, + requestedWorkspaceId: string | undefined +): WorkspaceFilterResult { + if (auth.apiKeyType !== 'workspace') return { ok: true, workspaceId: requestedWorkspaceId } + if (requestedWorkspaceId && requestedWorkspaceId !== auth.workspaceId) { + return { + ok: false, + response: NextResponse.json( + { error: 'API key is not authorized for this workspace' }, + { status: 403 } + ), + } + } + return { ok: true, workspaceId: auth.workspaceId } +} + interface ResolvedDateRange { startDate: Date | undefined endDate: Date From 2676f49163c6559bc401789674fa7ee167cc7413 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 11:32:06 -0700 Subject: [PATCH 005/159] feat(cli): sim CLI with AWS-style profiles and a platform key exchange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `packages/sim-cli` (`@sim/cli`, bin `sim`) and extends the existing CLI key handoff so it can mint the credential the public API actually accepts. ## Key exchange The handoff already existed but only minted *copilot* keys, which do not authenticate `/api/v1` or `/api/v2` — those want a Sim platform key. The approval now carries a `scope`: - `copilot` (the default, so terminals built against the original flow are unaffected) mints as before - `platform` mints a Sim API key: workspace-scoped when the approver is a workspace admin, personal otherwise Scope and workspace are fixed at *approval*, not at poll: the poll is unauthenticated by necessity, so the browser is the only moment a human is present to consent and the only place a permission can be checked. The poll echoes back what was granted rather than what was asked for, so the CLI cannot file a copilot key under a platform profile and fail later with an opaque 401. Picking a workspace and scoping a key to it are kept separate. The terminal has no key yet, so it cannot list workspaces — the browser picker is the only place that choice can be made, and the pick comes back as the profile's default whether or not the key is bound to it. Otherwise a non-admin would pick a workspace by name and then have to go find its id by hand. Personal-key creation moves into `lib/api-key/orchestration` so the settings route and the exchange share one issuer. ## CLI Profiles work like the AWS CLI: `~/.sim/config` for settings (`[profile dev]`), `~/.sim/credentials` for keys at 0600 (`[dev]`), selected via `--profile` / `SIM_PROFILE`. Each setting resolves flag → env → file → default, and `sim whoami` reports the winning source so a surprising value is explainable. CI can skip login entirely with `SIM_API_KEY` + `SIM_WORKSPACE`. Commands cover the v2 surface pulled in earlier: workflows, logs, files, and knowledge, with `--output json` passing the API's own shapes through for `jq`. `sim tables` is deliberately absent — that surface is still in flux. ## Drift fixes The v2 routes were authored a month ago and had fallen behind their services: `checkActorUsageLimits(userId, workspaceId)` → the billing-attribution flow (which also restores correct payer attribution for workspace keys on KB upload and search), `processDocumentsWithQueue` gained a required argument, and the deploy/rollback param objects had stale fields. Caught by a cold type-check — an incremental run had reported these files clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- .../app/api/cli/auth/approve/route.test.ts | 126 ++++++++++- apps/sim/app/api/cli/auth/approve/route.ts | 57 ++++- apps/sim/app/api/cli/auth/poll/route.test.ts | 115 +++++++++- apps/sim/app/api/cli/auth/poll/route.ts | 80 ++++++- apps/sim/app/api/users/me/api-keys/route.ts | 72 ++----- .../api/v2/knowledge/[id]/documents/route.ts | 27 ++- apps/sim/app/api/v2/knowledge/search/route.ts | 28 ++- .../app/api/v2/workflows/[id]/deploy/route.ts | 2 - .../api/v2/workflows/[id]/rollback/route.ts | 2 - apps/sim/app/cli/auth/cli-auth-request.ts | 15 +- apps/sim/app/cli/auth/cli-auth-view.tsx | 61 +++++- apps/sim/app/cli/auth/page.tsx | 4 + apps/sim/app/cli/auth/search-params.ts | 17 +- apps/sim/lib/api-key/orchestration/index.ts | 119 +++++++++- apps/sim/lib/api/contracts/cli-auth.ts | 53 +++++ apps/sim/lib/cli-auth/approval-store.test.ts | 76 ++++++- apps/sim/lib/cli-auth/approval-store.ts | 45 +++- bun.lock | 125 ++++++++++- packages/sim-cli/README.md | 142 ++++++++++++ packages/sim-cli/package.json | 44 ++++ packages/sim-cli/src/auth/device-flow.ts | 159 ++++++++++++++ packages/sim-cli/src/commands/auth.ts | 195 +++++++++++++++++ packages/sim-cli/src/commands/configure.ts | 72 +++++++ packages/sim-cli/src/commands/files.ts | 129 +++++++++++ packages/sim-cli/src/commands/knowledge.ts | 165 ++++++++++++++ packages/sim-cli/src/commands/logs.ts | 159 ++++++++++++++ packages/sim-cli/src/commands/workflows.ts | 148 +++++++++++++ packages/sim-cli/src/config/index.ts | 17 ++ packages/sim-cli/src/config/ini.test.ts | 105 +++++++++ packages/sim-cli/src/config/ini.ts | 130 +++++++++++ packages/sim-cli/src/config/paths.ts | 21 ++ packages/sim-cli/src/config/profile.test.ts | 137 ++++++++++++ packages/sim-cli/src/config/profile.ts | 204 ++++++++++++++++++ packages/sim-cli/src/context.ts | 36 ++++ packages/sim-cli/src/http/client.ts | 194 +++++++++++++++++ packages/sim-cli/src/index.ts | 69 ++++++ packages/sim-cli/src/output/render.test.ts | 132 ++++++++++++ packages/sim-cli/src/output/render.ts | 123 +++++++++++ packages/sim-cli/tsconfig.json | 12 ++ packages/sim-cli/vitest.config.ts | 8 + scripts/check-api-validation-contracts.ts | 4 +- 41 files changed, 3310 insertions(+), 119 deletions(-) create mode 100644 packages/sim-cli/README.md create mode 100644 packages/sim-cli/package.json create mode 100644 packages/sim-cli/src/auth/device-flow.ts create mode 100644 packages/sim-cli/src/commands/auth.ts create mode 100644 packages/sim-cli/src/commands/configure.ts create mode 100644 packages/sim-cli/src/commands/files.ts create mode 100644 packages/sim-cli/src/commands/knowledge.ts create mode 100644 packages/sim-cli/src/commands/logs.ts create mode 100644 packages/sim-cli/src/commands/workflows.ts create mode 100644 packages/sim-cli/src/config/index.ts create mode 100644 packages/sim-cli/src/config/ini.test.ts create mode 100644 packages/sim-cli/src/config/ini.ts create mode 100644 packages/sim-cli/src/config/paths.ts create mode 100644 packages/sim-cli/src/config/profile.test.ts create mode 100644 packages/sim-cli/src/config/profile.ts create mode 100644 packages/sim-cli/src/context.ts create mode 100644 packages/sim-cli/src/http/client.ts create mode 100644 packages/sim-cli/src/index.ts create mode 100644 packages/sim-cli/src/output/render.test.ts create mode 100644 packages/sim-cli/src/output/render.ts create mode 100644 packages/sim-cli/tsconfig.json create mode 100644 packages/sim-cli/vitest.config.ts diff --git a/apps/sim/app/api/cli/auth/approve/route.test.ts b/apps/sim/app/api/cli/auth/approve/route.test.ts index b270c7567cf..ff7902092a5 100644 --- a/apps/sim/app/api/cli/auth/approve/route.test.ts +++ b/apps/sim/app/api/cli/auth/approve/route.test.ts @@ -5,11 +5,13 @@ import { createHash } from 'node:crypto' import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockCreateApproval, mockEnforceUserRateLimit } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockCreateApproval: vi.fn(), - mockEnforceUserRateLimit: vi.fn(), -})) +const { mockGetSession, mockCreateApproval, mockEnforceUserRateLimit, mockGetPermissions } = + vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockCreateApproval: vi.fn(), + mockEnforceUserRateLimit: vi.fn(), + mockGetPermissions: vi.fn(), + })) vi.mock('@/lib/auth', () => ({ auth: { api: { getSession: vi.fn() } }, @@ -24,6 +26,10 @@ vi.mock('@/lib/core/rate-limiter', () => ({ enforceUserRateLimit: mockEnforceUserRateLimit, })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetPermissions, +})) + import { POST } from '@/app/api/cli/auth/approve/route' const REQUEST = 'a'.repeat(43) @@ -35,6 +41,7 @@ describe('POST /api/cli/auth/approve', () => { mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) mockEnforceUserRateLimit.mockResolvedValue(null) mockCreateApproval.mockResolvedValue(undefined) + mockGetPermissions.mockResolvedValue('admin') }) it('records the approval for the signed-in user', async () => { @@ -43,7 +50,112 @@ describe('POST /api/cli/auth/approve', () => { ) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ ok: true }) - expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, { + scope: 'copilot', + workspaceId: undefined, + workspaceBound: false, + }) + }) + + it('defaults to the copilot scope so pre-scope terminals keep working', async () => { + await POST(createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE })) + expect(mockCreateApproval).toHaveBeenCalledWith( + 'user-1', + REQUEST, + CHALLENGE, + expect.objectContaining({ scope: 'copilot' }) + ) + }) + + it('records a workspace binding when the approver is a workspace admin', async () => { + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + expect(response.status).toBe(200) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + }) + + it("records a non-admin's pick as a default without binding the key to it", async () => { + mockGetPermissions.mockResolvedValue('write') + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + }) + ) + expect(response.status).toBe(200) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: false, + }) + }) + + it('refuses to bind a key to a workspace the approver is not admin of', async () => { + mockGetPermissions.mockResolvedValue('write') + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + expect(response.status).toBe(403) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('refuses a workspace the approver is not a member of', async () => { + mockGetPermissions.mockResolvedValue(null) + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + }) + ) + expect(response.status).toBe(404) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('refuses bindKeyToWorkspace with no workspaceId', async () => { + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + bindKeyToWorkspace: true, + }) + ) + expect(response.status).toBe(400) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('refuses a workspace binding on the copilot scope', async () => { + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'copilot', + workspaceId: 'ws-1', + }) + ) + expect(response.status).toBe(400) + expect(mockCreateApproval).not.toHaveBeenCalled() }) it('rejects an unauthenticated caller', async () => { @@ -59,7 +171,7 @@ describe('POST /api/cli/auth/approve', () => { await POST( createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE, userId: 'attacker' }) ) - expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, expect.anything()) }) it('rejects a malformed challenge', async () => { diff --git a/apps/sim/app/api/cli/auth/approve/route.ts b/apps/sim/app/api/cli/auth/approve/route.ts index 8099914be91..3c361a9bf45 100644 --- a/apps/sim/app/api/cli/auth/approve/route.ts +++ b/apps/sim/app/api/cli/auth/approve/route.ts @@ -6,6 +6,7 @@ import { getSession } from '@/lib/auth' import { createApproval } from '@/lib/cli-auth/approval-store' import { enforceUserRateLimit } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CliAuthApproveAPI') @@ -16,6 +17,10 @@ const logger = createLogger('CliAuthApproveAPI') * The approving user comes from the session and nothing else — a client-supplied * user id here would let any caller approve a request redeemable for someone * else's key. No key is generated until the CLI polls. + * + * Workspace binding is authorized here rather than at poll time: the poll is + * unauthenticated by necessity, so it has no session to check a permission + * against. Approving is the only moment a human is present. */ export const POST = withRouteHandler(async (request: NextRequest) => { const session = await getSession() @@ -29,8 +34,56 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(approveCliAuthContract, request, {}) if (!parsed.success) return parsed.response - await createApproval(session.user.id, parsed.data.body.request, parsed.data.body.challenge) - logger.info('Recorded CLI authorization approval', { userId: session.user.id }) + const { request: requestId, challenge, scope, workspaceId, bindKeyToWorkspace } = parsed.data.body + + if ((workspaceId || bindKeyToWorkspace) && scope !== 'platform') { + return NextResponse.json( + { error: 'workspaceId is only valid for the platform scope' }, + { status: 400 } + ) + } + + if (bindKeyToWorkspace && !workspaceId) { + return NextResponse.json( + { error: 'bindKeyToWorkspace requires a workspaceId' }, + { status: 400 } + ) + } + + if (workspaceId) { + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + + // Reading the workspace at all requires membership. Without this, the + // terminal could be handed the id of a workspace the approver cannot see — + // harmless for the key, but it would silently become the profile default and + // every later command would 403 with no explanation. + if (!permission) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + // Minting a workspace key is an admin action wherever else it is offered; + // the terminal is not a lower bar. Rejected outright rather than downgraded + // to a personal key, so the CLI never quietly stores a different credential + // than the browser said it would. + if (bindKeyToWorkspace && permission !== 'admin') { + return NextResponse.json( + { error: 'Workspace admin permission is required to issue a workspace API key' }, + { status: 403 } + ) + } + } + + await createApproval(session.user.id, requestId, challenge, { + scope, + workspaceId, + workspaceBound: bindKeyToWorkspace, + }) + logger.info('Recorded CLI authorization approval', { + userId: session.user.id, + scope, + workspaceId: workspaceId ?? null, + workspaceBound: bindKeyToWorkspace, + }) return NextResponse.json({ ok: true }) }) diff --git a/apps/sim/app/api/cli/auth/poll/route.test.ts b/apps/sim/app/api/cli/auth/poll/route.test.ts index 89e7422a450..8c762b4845e 100644 --- a/apps/sim/app/api/cli/auth/poll/route.test.ts +++ b/apps/sim/app/api/cli/auth/poll/route.test.ts @@ -9,12 +9,16 @@ const { mockCompleteApproval, mockReleaseMint, mockGenerateCopilotApiKey, + mockCreatePersonalApiKey, + mockCreateWorkspaceApiKey, mockEnforceIpRateLimit, } = vi.hoisted(() => ({ mockPollApproval: vi.fn(), mockCompleteApproval: vi.fn(), mockReleaseMint: vi.fn(), mockGenerateCopilotApiKey: vi.fn(), + mockCreatePersonalApiKey: vi.fn(), + mockCreateWorkspaceApiKey: vi.fn(), mockEnforceIpRateLimit: vi.fn(), })) @@ -29,6 +33,11 @@ vi.mock('@/lib/copilot/server/api-keys', () => ({ CopilotApiKeyError: class extends Error {}, })) +vi.mock('@/lib/api-key/orchestration', () => ({ + performCreatePersonalApiKey: mockCreatePersonalApiKey, + performCreateWorkspaceApiKey: mockCreateWorkspaceApiKey, +})) + vi.mock('@/lib/core/rate-limiter', () => ({ enforceIpRateLimit: mockEnforceIpRateLimit, })) @@ -42,11 +51,31 @@ function pollRequest(body: Record) { return createMockRequest('POST', body) } +/** What `pollApproval` returns for an approval recorded at the given scope. */ +function approved(overrides: Record = {}) { + return { + status: 'approved', + userId: 'user-1', + scope: 'copilot', + workspaceId: null, + workspaceBound: false, + ...overrides, + } +} + describe('POST /api/cli/auth/poll', () => { beforeEach(() => { vi.clearAllMocks() mockEnforceIpRateLimit.mockResolvedValue(null) mockGenerateCopilotApiKey.mockResolvedValue({ id: 'key-1', apiKey: 'sk-test' }) + mockCreatePersonalApiKey.mockResolvedValue({ + success: true, + key: { id: 'key-2', name: 'CLI', key: 'sim_personal', createdAt: new Date() }, + }) + mockCreateWorkspaceApiKey.mockResolvedValue({ + success: true, + key: { id: 'key-3', name: 'CLI', key: 'sim_workspace', createdAt: new Date() }, + }) mockCompleteApproval.mockResolvedValue(undefined) mockReleaseMint.mockResolvedValue(undefined) }) @@ -60,20 +89,84 @@ describe('POST /api/cli/auth/poll', () => { }) it('mints, then consumes the approval, once approved', async () => { - mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' }) + mockPollApproval.mockResolvedValue(approved()) const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ status: 'complete', key: { id: 'key-1', apiKey: 'sk-test' }, + scope: 'copilot', + workspaceId: null, + workspaceBound: false, }) expect(mockGenerateCopilotApiKey).toHaveBeenCalledWith('user-1', expect.stringMatching(/^CLI /)) expect(mockCompleteApproval).toHaveBeenCalledWith(REQUEST) expect(mockReleaseMint).not.toHaveBeenCalled() }) + it('mints a personal platform key when the approval carries no workspace', async () => { + mockPollApproval.mockResolvedValue(approved({ scope: 'platform' })) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + status: 'complete', + key: { id: 'key-2', apiKey: 'sim_personal' }, + scope: 'platform', + workspaceId: null, + workspaceBound: false, + }) + expect(mockCreatePersonalApiKey).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', source: 'cli' }) + ) + expect(mockGenerateCopilotApiKey).not.toHaveBeenCalled() + }) + + it('mints a workspace-scoped key when the approval carries a workspace', async () => { + mockPollApproval.mockResolvedValue( + approved({ scope: 'platform', workspaceId: 'ws-1', workspaceBound: true }) + ) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + status: 'complete', + key: { id: 'key-3', apiKey: 'sim_workspace' }, + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + expect(mockCreateWorkspaceApiKey).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', workspaceId: 'ws-1', source: 'cli' }) + ) + expect(mockCreatePersonalApiKey).not.toHaveBeenCalled() + }) + + it('returns the picked workspace with a personal key when the approval is unbound', async () => { + // A non-admin still picked a workspace in the browser; the terminal needs it + // as its default even though the key is not scoped to it. + mockPollApproval.mockResolvedValue(approved({ scope: 'platform', workspaceId: 'ws-1' })) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + await expect(response.json()).resolves.toEqual({ + status: 'complete', + key: { id: 'key-2', apiKey: 'sim_personal' }, + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: false, + }) + expect(mockCreatePersonalApiKey).toHaveBeenCalled() + expect(mockCreateWorkspaceApiKey).not.toHaveBeenCalled() + }) + + it('scope comes from the approval, never from the poll body', async () => { + mockPollApproval.mockResolvedValue(approved({ scope: 'copilot' })) + const response = await POST( + pollRequest({ request: REQUEST, verifier: VERIFIER, scope: 'platform' }) + ) + await expect(response.json()).resolves.toMatchObject({ scope: 'copilot' }) + expect(mockCreatePersonalApiKey).not.toHaveBeenCalled() + }) + it('releases the reservation (keeps the approval) when minting fails', async () => { - mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' }) + mockPollApproval.mockResolvedValue(approved()) mockGenerateCopilotApiKey.mockRejectedValue(new Error('mothership down')) const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) expect(response.status).toBe(500) @@ -81,14 +174,30 @@ describe('POST /api/cli/auth/poll', () => { expect(mockCompleteApproval).not.toHaveBeenCalled() }) + it('releases the reservation when a platform mint fails', async () => { + mockPollApproval.mockResolvedValue(approved({ scope: 'platform' })) + mockCreatePersonalApiKey.mockResolvedValue({ + success: false, + errorCode: 'conflict', + error: 'A personal API key named "CLI" already exists.', + }) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(409) + expect(mockReleaseMint).toHaveBeenCalledWith(REQUEST) + expect(mockCompleteApproval).not.toHaveBeenCalled() + }) + it('still returns the key when post-mint cleanup fails — never releases the lock', async () => { - mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' }) + mockPollApproval.mockResolvedValue(approved()) mockCompleteApproval.mockRejectedValue(new Error('redis blip')) const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ status: 'complete', key: { id: 'key-1', apiKey: 'sk-test' }, + scope: 'copilot', + workspaceId: null, + workspaceBound: false, }) // A cleanup failure must not release the mint lock — that would allow a re-mint. expect(mockReleaseMint).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/cli/auth/poll/route.ts b/apps/sim/app/api/cli/auth/poll/route.ts index c5a7610f9de..99bda7fa9a3 100644 --- a/apps/sim/app/api/cli/auth/poll/route.ts +++ b/apps/sim/app/api/cli/auth/poll/route.ts @@ -2,6 +2,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { pollCliAuthContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' +import { + performCreatePersonalApiKey, + performCreateWorkspaceApiKey, +} from '@/lib/api-key/orchestration' +import type { ApprovalGrant } from '@/lib/cli-auth/approval-store' import { completeApproval, pollApproval, releaseMint } from '@/lib/cli-auth/approval-store' import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/copilot/server/api-keys' import { enforceIpRateLimit } from '@/lib/core/rate-limiter' @@ -28,6 +33,54 @@ function cliKeyName(): string { return `CLI (${new Date().toISOString().slice(0, 10)})` } +/** + * Mints from the key space the approval recorded. + * + * A name collision is reported as a conflict rather than retried under a + * generated name: two logins on the same day from the same terminal should + * reuse the existing key, and silently accumulating `CLI (date) (2)` rows + * would hide that. + */ +async function mintForGrant( + grant: ApprovalGrant +): Promise< + { ok: true; key: { id: string; apiKey: string } } | { ok: false; status: number; message: string } +> { + const name = cliKeyName() + + if (grant.scope === 'copilot') { + try { + const key = await generateCopilotApiKey(grant.userId, name) + return { ok: true, key } + } catch (error) { + const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined + return { ok: false, status: status ?? 500, message: 'Failed to generate copilot API key' } + } + } + + // `workspaceId` alone only names the terminal's default workspace; binding the + // key to it is a separate, admin-gated decision made at approval. + const result = + grant.workspaceBound && grant.workspaceId + ? await performCreateWorkspaceApiKey({ + workspaceId: grant.workspaceId, + userId: grant.userId, + name, + source: 'cli', + }) + : await performCreatePersonalApiKey({ userId: grant.userId, name, source: 'cli' }) + + if (!result.success || !result.key) { + return { + ok: false, + status: result.errorCode === 'conflict' ? 409 : 500, + message: result.error ?? 'Failed to generate API key', + } + } + + return { ok: true, key: { id: result.key.id, apiKey: result.key.key } } +} + /** * The CLI's poll endpoint. Unauthenticated by necessity — the CLI has no * session — but the request id is only a rendezvous handle and minting requires @@ -49,17 +102,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ status: 'pending' }) } - let key: Awaited> - try { - key = await generateCopilotApiKey(result.userId, cliKeyName()) - } catch (error) { + const minted = await mintForGrant(result) + if (!minted.ok) { // Mint failed — release the reservation so a later poll can retry. await releaseMint(requestId) - const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined - return NextResponse.json( - { error: 'Failed to generate copilot API key' }, - { status: status ?? 500 } - ) + return NextResponse.json({ error: minted.message }, { status: minted.status }) } // Mint succeeded — the key exists. Consuming the approval is best-effort: a @@ -71,6 +118,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: result.userId, }) }) - logger.info('Minted CLI key on approved poll', { userId: result.userId }) - return NextResponse.json({ status: 'complete', key }) + logger.info('Minted CLI key on approved poll', { + userId: result.userId, + scope: result.scope, + workspaceId: result.workspaceId, + workspaceBound: result.workspaceBound, + }) + return NextResponse.json({ + status: 'complete', + key: minted.key, + scope: result.scope, + workspaceId: result.workspaceId, + workspaceBound: result.workspaceBound, + }) }) diff --git a/apps/sim/app/api/users/me/api-keys/route.ts b/apps/sim/app/api/users/me/api-keys/route.ts index cd5f2eb83ca..b6776b51db6 100644 --- a/apps/sim/app/api/users/me/api-keys/route.ts +++ b/apps/sim/app/api/users/me/api-keys/route.ts @@ -1,14 +1,12 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateShortId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createPersonalApiKeyContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' -import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' -import { hashApiKey } from '@/lib/api-key/crypto' +import { getApiKeyDisplayFormat } from '@/lib/api-key/auth' +import { performCreatePersonalApiKey } from '@/lib/api-key/orchestration' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' @@ -73,70 +71,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { name } = parsed.data.body - const existingKey = await db - .select() - .from(apiKey) - .where(and(eq(apiKey.userId, userId), eq(apiKey.name, name), eq(apiKey.type, 'personal'))) - .limit(1) - - if (existingKey.length > 0) { - return NextResponse.json( - { - error: `A personal API key named "${name}" already exists. Please choose a different name.`, - }, - { status: 409 } - ) - } - - const { key: plainKey, encryptedKey } = await createApiKey(true) - - if (!encryptedKey) { - throw new Error('Failed to encrypt API key for storage') - } - - const [newKey] = await db - .insert(apiKey) - .values({ - id: generateShortId(), - userId, - workspaceId: null, - name, - key: encryptedKey, - keyHash: hashApiKey(plainKey), - type: 'personal', - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning({ - id: apiKey.id, - name: apiKey.name, - createdAt: apiKey.createdAt, - }) - - recordAudit({ - workspaceId: null, - actorId: userId, - action: AuditAction.PERSONAL_API_KEY_CREATED, - resourceType: AuditResourceType.API_KEY, - resourceId: newKey.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: name, - description: `Created personal API key: ${name}`, + const result = await performCreatePersonalApiKey({ + userId, + name, + actorName: session.user.name, + actorEmail: session.user.email, request, }) + if (!result.success || !result.key) { + const status = result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) + } captureServerEvent(userId, 'api_key_created', { key_name: name, scope: 'personal', }) - return NextResponse.json({ - key: { - ...newKey, - key: plainKey, - }, - }) + return NextResponse.json({ key: result.key }) } catch (error) { logger.error('Failed to create API key', { error }) return NextResponse.json({ error: 'Failed to create API key' }, { status: 500 }) 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 9f2c7b5367a..054bd84d9aa 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -8,7 +8,11 @@ import { v2UploadKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' import { parseRequest } from '@/lib/api/server' -import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { + checkAttributedUsageLimits, + resolveBillingAttribution, + resolveSystemBillingAttribution, +} from '@/lib/billing/core/billing-attribution' import { generateRequestId } from '@/lib/core/utils/request' import { isPayloadSizeLimitError, @@ -174,9 +178,16 @@ export const POST = withRouteHandler( ) if (result instanceof NextResponse) return result - // Fast usage gate before the storage write + indexing (the async backstop - // in processDocumentAsync still covers non-HTTP paths). - const usage = await checkActorUsageLimits(userId, workspaceId) + /** + * Gate before storage and indexing. Workspace keys bill the billed account + * and its immutable payer from one read; personal keys keep their human + * actor. Mirrors the v1 upload path so the two attribute identically. + */ + const billingAttribution = + rateLimit.keyType === 'workspace' + ? await resolveSystemBillingAttribution(workspaceId) + : await resolveBillingAttribution({ actorUserId: userId, workspaceId }) + const usage = await checkAttributedUsageLimits(billingAttribution) if (usage.isExceeded) { return v2Error( 'USAGE_LIMIT_EXCEEDED', @@ -249,7 +260,13 @@ export const POST = withRouteHandler( mimeType: contentType, } - processDocumentsWithQueue([documentData], knowledgeBaseId, {}, requestId).catch(() => { + processDocumentsWithQueue( + [documentData], + knowledgeBaseId, + {}, + requestId, + billingAttribution + ).catch(() => { // Processing errors are logged internally by the queue. }) diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index 8f432bf467e..b390fddcde2 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -6,7 +6,11 @@ import { v2SearchKnowledgeContract, } from '@/lib/api/contracts/v2/knowledge' import { isZodError, parseRequest } from '@/lib/api/server' -import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { + checkAttributedUsageLimits, + resolveBillingAttribution, + resolveSystemBillingAttribution, +} from '@/lib/billing/core/billing-attribution' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' @@ -62,10 +66,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - // A query incurs hosted embedding (+ optional rerank) cost — gate the actor's - // usage and frozen status before spending. Tag-only search is free, so skip it. - if (query && query.trim().length > 0) { - const usage = await checkActorUsageLimits(userId, workspaceId) + /** + * A query incurs hosted embedding (+ optional rerank) cost; a tag-only + * search does not, so it is not gated and not attributed. Workspace keys + * resolve their system actor and immutable payer from one workspace read. + */ + const hasBillableQuery = Boolean(query?.trim()) + const billingAttribution = hasBillableQuery + ? rateLimit.keyType === 'workspace' + ? await resolveSystemBillingAttribution(workspaceId) + : await resolveBillingAttribution({ actorUserId: userId, workspaceId }) + : undefined + const billingActorUserId = billingAttribution?.actorUserId ?? userId + + if (billingAttribution) { + const usage = await checkAttributedUsageLimits(billingAttribution) if (usage.isExceeded) { return v2Error( 'USAGE_LIMIT_EXCEEDED', @@ -224,12 +239,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (queryEmbeddingIsBYOK !== null) { await recordSearchEmbeddingUsage({ - userId, + userId: billingActorUserId, workspaceId, embeddingModel: queryEmbeddingModel, query: query!, isBYOK: queryEmbeddingIsBYOK, sourceReference: `v2-kb-search:${requestId}`, + billingAttribution, }) } diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts index 87c46b2cd75..f545789f1e2 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -58,11 +58,9 @@ export const POST = withRouteHandler( const result = await performFullDeploy({ workflowId: id, userId, - workflowName: workflow.name || undefined, versionName: body.data.name, versionDescription: body.data.description ?? undefined, requestId, - request, }) if (!result.success) { diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts index 634cf9957cf..b2d2d1d2a92 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -77,9 +77,7 @@ export const POST = withRouteHandler( workflowId: id, version: targetVersion, userId, - workflow: workflow as Record, requestId, - request, }) if (!result.success) { diff --git a/apps/sim/app/cli/auth/cli-auth-request.ts b/apps/sim/app/cli/auth/cli-auth-request.ts index f13d1e0cffa..57a849b97b0 100644 --- a/apps/sim/app/cli/auth/cli-auth-request.ts +++ b/apps/sim/app/cli/auth/cli-auth-request.ts @@ -1,3 +1,5 @@ +import type { CliAuthScope } from '@/lib/api/contracts/cli-auth' + /** BASE64URL, 43 chars (request id or SHA-256 challenge), no padding. */ const BASE64URL_43 = /^[A-Za-z0-9\-_]{43}$/ @@ -12,6 +14,10 @@ export interface CliAuthRequest { challenge: string /** Printed by the CLI, rendered for eyeball comparison. Never sent to the API. */ pairing: string + /** Which key space the terminal is asking for. */ + scope: CliAuthScope + /** Workspace the terminal suggests preselecting. A hint only — never authority. */ + suggestedWorkspaceId: string | null } export type CliAuthRequestResolution = @@ -22,6 +28,8 @@ interface RawCliAuthParams { request: string | null challenge: string | null pairing: string | null + scope: CliAuthScope + workspace: string | null } /** @@ -32,6 +40,8 @@ export function resolveCliAuthRequest({ request, challenge, pairing, + scope, + workspace, }: RawCliAuthParams): CliAuthRequestResolution { if (!request || !challenge || !pairing) { return { valid: false, reason: 'This link is missing the parameters the Sim CLI sends.' } @@ -45,5 +55,8 @@ export function resolveCliAuthRequest({ return { valid: false, reason: 'The pairing code is malformed.' } } - return { valid: true, request: { request, challenge, pairing } } + return { + valid: true, + request: { request, challenge, pairing, scope, suggestedWorkspaceId: workspace || null }, + } } diff --git a/apps/sim/app/cli/auth/cli-auth-view.tsx b/apps/sim/app/cli/auth/cli-auth-view.tsx index 96ad0648ffc..d344b216797 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.tsx @@ -1,5 +1,7 @@ 'use client' +import { useMemo, useState } from 'react' +import { ChipSelect, type ChipSelectOption, Label } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' @@ -7,6 +9,10 @@ import { AuthFormMessage, AuthHeader, AuthSubmitButton } from '@/app/(auth)/comp import { resolveCliAuthRequest } from '@/app/cli/auth/cli-auth-request' import { cliAuthParsers } from '@/app/cli/auth/search-params' import { useApproveCliAuth } from '@/hooks/queries/cli-auth' +import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace' + +/** Sentinel for the "not bound to a workspace" row; an empty string reads as unselected. */ +const PERSONAL_VALUE = '__personal__' /** * The signed-in half of the CLI key handoff: a consent card that records the @@ -22,8 +28,20 @@ export function CliAuthView() { const router = useRouter() const [params] = useQueryStates(cliAuthParsers) const approve = useApproveCliAuth() + const [selected, setSelected] = useState(null) const resolution = resolveCliAuthRequest(params) + const isPlatform = resolution.valid && resolution.request.scope === 'platform' + + const workspaces = useWorkspacesWithMetadata(isPlatform) + + const options = useMemo(() => { + const rows: ChipSelectOption[] = (workspaces.data?.workspaces ?? []).map((workspace) => ({ + label: workspace.name, + value: workspace.id, + })) + return [...rows, { label: 'No workspace (personal key)', value: PERSONAL_VALUE }] + }, [workspaces.data]) if (!resolution.valid) { return ( @@ -41,6 +59,18 @@ export function CliAuthView() { const { request } = resolution + // The terminal's suggestion, then the user's last active workspace. Derived at + // render rather than synced into state through an effect, so the first paint + // after the list loads already shows the right row. + const workspaceId = + selected ?? request.suggestedWorkspaceId ?? workspaces.data?.lastActiveWorkspaceId ?? null + const chosen = workspaces.data?.workspaces.find((w) => w.id === workspaceId) + + // Only an admin can bind a key to a workspace. Anything less still gets a + // usable credential — a personal key — but the card says which one before the + // click rather than after, so nothing unexpected lands in the config file. + const bindsToWorkspace = chosen?.permissions === 'admin' + return (
+ {isPlatform && ( +
+ + 8} + searchPlaceholder='Search workspaces' + fullWidth + dropdownWidth='trigger' + /> +

+ {bindsToWorkspace + ? `Issues a key that can only reach ${chosen.name}.` + : 'Issues a personal key tied to your account, defaulting to this workspace. Workspace-scoped keys need admin.'} +

+
+ )} approve.mutate( - { request: request.request, challenge: request.challenge }, + { + request: request.request, + challenge: request.challenge, + scope: request.scope, + // The picked workspace travels either way — it is the terminal's + // default. Only `bindKeyToWorkspace` narrows the key itself. + ...(isPlatform && chosen ? { workspaceId: chosen.id } : {}), + bindKeyToWorkspace: isPlatform && bindsToWorkspace, + }, { onSuccess: () => router.push('/cli/auth/done') } ) } diff --git a/apps/sim/app/cli/auth/page.tsx b/apps/sim/app/cli/auth/page.tsx index d36de9f8083..966b088c038 100644 --- a/apps/sim/app/cli/auth/page.tsx +++ b/apps/sim/app/cli/auth/page.tsx @@ -42,7 +42,11 @@ export default async function CliAuthPage({ request: resolution.request.request, challenge: resolution.request.challenge, pairing: resolution.request.pairing, + scope: resolution.request.scope, }) + if (resolution.request.suggestedWorkspaceId) { + query.set('workspace', resolution.request.suggestedWorkspaceId) + } redirect(`/login?callbackUrl=${encodeURIComponent(`/cli/auth?${query}`)}`) } diff --git a/apps/sim/app/cli/auth/search-params.ts b/apps/sim/app/cli/auth/search-params.ts index e62b286e594..375a1c77ab3 100644 --- a/apps/sim/app/cli/auth/search-params.ts +++ b/apps/sim/app/cli/auth/search-params.ts @@ -1,16 +1,27 @@ -import { createSearchParamsCache, parseAsString } from 'nuqs/server' +import { createSearchParamsCache, parseAsString, parseAsStringLiteral } from 'nuqs/server' + +/** Key spaces the handoff can mint from. Mirrors `cliAuthScopeSchema`. */ +export const CLI_AUTH_SCOPES = ['copilot', 'platform'] as const /** * Co-located, typed URL query params for the CLI key handoff. Read-only for the * life of the page, so there is no `urlKeys` companion. * - * Nullable with no defaults: a missing value is an invalid request, not a state - * to fall back from. `resolveCliAuthRequest` validates them; never trusted as-is. + * `request`/`challenge`/`pairing` are nullable with no defaults: a missing value + * is an invalid request, not a state to fall back from. `resolveCliAuthRequest` + * validates them; never trusted as-is. + * + * `scope` defaults to `copilot` so a terminal built against the original handoff + * — which sent no scope — still lands on the key space it expects. `workspace` + * is only a preselection hint for the picker; the workspace that ends up bound + * to the key is the one the user confirms, and it is re-authorized server-side. */ export const cliAuthParsers = { request: parseAsString, challenge: parseAsString, pairing: parseAsString, + scope: parseAsStringLiteral(CLI_AUTH_SCOPES).withDefault('copilot'), + workspace: parseAsString, } as const /** diff --git a/apps/sim/lib/api-key/orchestration/index.ts b/apps/sim/lib/api-key/orchestration/index.ts index 934766fa4e4..ad52b304662 100644 --- a/apps/sim/lib/api-key/orchestration/index.ts +++ b/apps/sim/lib/api-key/orchestration/index.ts @@ -1,13 +1,25 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { createWorkspaceApiKey } from '@/lib/api-key/auth' +import { generateShortId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { createApiKey, createWorkspaceApiKey } from '@/lib/api-key/auth' +import { hashApiKey } from '@/lib/api-key/crypto' import { PlatformEvents } from '@/lib/core/telemetry' const logger = createLogger('ApiKeyOrchestration') export type ApiKeyOrchestrationErrorCode = 'conflict' | 'internal' +export interface CreatedApiKey { + id: string + name: string + key: string + createdAt: Date +} + export interface PerformCreateWorkspaceApiKeyParams { workspaceId: string userId: string @@ -21,11 +33,106 @@ export interface PerformCreateWorkspaceApiKeyResult { success: boolean error?: string errorCode?: ApiKeyOrchestrationErrorCode - key?: { - id: string - name: string - key: string - createdAt: Date + key?: CreatedApiKey +} + +export interface PerformCreatePersonalApiKeyParams { + userId: string + name: string + source?: string + actorName?: string | null + actorEmail?: string | null + /** Forwarded to the audit record so the entry carries the caller's IP/UA. */ + request?: Request +} + +export interface PerformCreatePersonalApiKeyResult { + success: boolean + error?: string + errorCode?: ApiKeyOrchestrationErrorCode + key?: CreatedApiKey +} + +/** + * Issues a personal API key for the given user. + * + * The single issuer for every caller — the settings route, which authenticates + * by session, and the CLI key exchange, which authenticates by a redeemed + * approval. Keeping name-collision handling, audit, and telemetry here means the + * two surfaces can never drift. + */ +export async function performCreatePersonalApiKey( + params: PerformCreatePersonalApiKeyParams +): Promise { + try { + const existing = await db + .select({ id: apiKey.id }) + .from(apiKey) + .where( + and( + eq(apiKey.userId, params.userId), + eq(apiKey.name, params.name), + eq(apiKey.type, 'personal') + ) + ) + .limit(1) + + if (existing.length > 0) { + return { + success: false, + errorCode: 'conflict', + error: `A personal API key named "${params.name}" already exists. Please choose a different name.`, + } + } + + const { key: plainKey, encryptedKey } = await createApiKey(true) + if (!encryptedKey) { + throw new Error('Failed to encrypt API key for storage') + } + + const [created] = await db + .insert(apiKey) + .values({ + id: generateShortId(), + userId: params.userId, + workspaceId: null, + name: params.name, + key: encryptedKey, + keyHash: hashApiKey(plainKey), + type: 'personal', + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning({ + id: apiKey.id, + name: apiKey.name, + createdAt: apiKey.createdAt, + }) + + recordAudit({ + workspaceId: null, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.PERSONAL_API_KEY_CREATED, + resourceType: AuditResourceType.API_KEY, + resourceId: created.id, + resourceName: params.name, + description: `Created personal API key: ${params.name}`, + metadata: { + keyName: params.name, + keyType: 'personal', + source: params.source ?? 'settings', + }, + request: params.request, + }) + + logger.info('Created personal API key', { userId: params.userId, keyId: created.id }) + + return { success: true, key: { ...created, key: plainKey } } + } catch (error) { + logger.error('Failed to create personal API key', { error }) + return { success: false, errorCode: 'internal', error: toError(error).message } } } diff --git a/apps/sim/lib/api/contracts/cli-auth.ts b/apps/sim/lib/api/contracts/cli-auth.ts index 56b40ce5953..d37d7a597e0 100644 --- a/apps/sim/lib/api/contracts/cli-auth.ts +++ b/apps/sim/lib/api/contracts/cli-auth.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' /** @@ -14,9 +15,41 @@ import { defineRouteContract } from '@/lib/api/contracts/types' /** BASE64URL, 43 chars (a 32-byte token or SHA-256 digest), no padding. */ const base64Url43 = (message: string) => z.string().regex(/^[A-Za-z0-9\-_]{43}$/, message) +/** + * Which key space the exchange mints from. + * + * `copilot` — a Sim Agent key, for the conversational surface. + * `platform` — a Sim API key (`x-api-key`), the credential the public `/api/v1` + * and `/api/v2` endpoints accept. These are separate key spaces: a copilot key + * does not authenticate a platform request, or vice versa. + * + * Defaulted to `copilot` so terminals built against the original exchange keep + * working without sending the field. + */ +export const cliAuthScopeSchema = z.enum(['copilot', 'platform']).default('copilot') +export type CliAuthScope = z.output + export const approveCliAuthBodySchema = z.object({ request: base64Url43('request must be a base64url request id'), challenge: base64Url43('challenge must be a base64url-encoded SHA-256 digest'), + scope: cliAuthScopeSchema, + /** + * Platform scope only: the workspace the user picked in the browser. Returned + * to the terminal so it can store it as the profile's default — the user chose + * it by name, and asking them to go find its id afterwards would be absurd. + * + * Recorded whether or not the key ends up bound to it; see + * {@link bindKeyToWorkspace}. + */ + workspaceId: workspaceIdSchema.optional(), + /** + * Mint a key scoped to {@link workspaceId} rather than a personal key. Only a + * workspace admin may ask for this, and the approve route rejects anything + * less rather than silently downgrading — the browser has already told the + * user which kind of key they are about to get, so a mismatch here means the + * request did not come from that UI. + */ + bindKeyToWorkspace: z.boolean().optional().default(false), }) export type ApproveCliAuthBody = z.input @@ -49,6 +82,26 @@ export const pollCliAuthContract = defineRouteContract({ z.object({ status: z.literal('complete'), key: z.object({ id: z.string(), apiKey: z.string() }), + /** + * Echoes what the approving user actually consented to. The CLI asked + * for a scope in the browser URL, but the approval is what binds it — + * a client that assumed its own request was honored could file a + * copilot key under a platform profile and fail every later call with + * an opaque 401. + */ + scope: z.enum(['copilot', 'platform']), + /** + * The workspace the user picked, for the terminal to store as its + * default. Present for a personal key too — the choice is about which + * workspace the profile targets, not about what the key can reach. + */ + workspaceId: z.string().nullable(), + /** + * Whether the key itself is scoped to {@link workspaceId}. A bound key + * can reach nothing else, so the terminal must not offer to point the + * profile somewhere the credential cannot follow. + */ + workspaceBound: z.boolean(), }), ]), }, diff --git a/apps/sim/lib/cli-auth/approval-store.test.ts b/apps/sim/lib/cli-auth/approval-store.test.ts index 47d3bd06e9b..b9edabfb582 100644 --- a/apps/sim/lib/cli-auth/approval-store.test.ts +++ b/apps/sim/lib/cli-auth/approval-store.test.ts @@ -46,15 +46,53 @@ describe('cli-auth approval store', () => { expect(JSON.parse(value)).toEqual({ challenge: CHALLENGE, userId: 'user-1', + scope: 'copilot', createdAt: expect.any(Number), }) expect([px, ttl]).toEqual(['PX', 120_000]) }) + + it('records the consented scope and workspace', async () => { + await createApproval('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + expect(JSON.parse(mockSet.mock.calls[0][1])).toMatchObject({ + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + }) + + it('omits the workspace fields entirely when no workspace was picked', async () => { + await createApproval('user-1', REQUEST, CHALLENGE, { scope: 'platform' }) + const record = JSON.parse(mockSet.mock.calls[0][1]) + expect(record).not.toHaveProperty('workspaceId') + expect(record).not.toHaveProperty('workspaceBound') + }) + + it('records a picked workspace as unbound unless binding was asked for', async () => { + await createApproval('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + }) + expect(JSON.parse(mockSet.mock.calls[0][1])).toMatchObject({ + workspaceId: 'ws-1', + workspaceBound: false, + }) + }) }) describe('pollApproval', () => { - const storedApproval = () => - JSON.stringify({ challenge: CHALLENGE, userId: 'user-1', createdAt: Date.now() }) + const storedApproval = (overrides: Record = {}) => + JSON.stringify({ + challenge: CHALLENGE, + userId: 'user-1', + scope: 'copilot', + createdAt: Date.now(), + ...overrides, + }) it('returns pending when no approval exists yet', async () => { mockGet.mockResolvedValue(null) @@ -68,6 +106,9 @@ describe('cli-auth approval store', () => { await expect(pollApproval(REQUEST, SECRET)).resolves.toEqual({ status: 'approved', userId: 'user-1', + scope: 'copilot', + workspaceId: null, + workspaceBound: false, }) // NX lock on the mint key; TTL matches the approval so they expire together // and a failed cleanup can't leave a re-mintable window. Record not deleted here. @@ -77,6 +118,37 @@ describe('cli-auth approval store', () => { expect(mockDel).not.toHaveBeenCalled() }) + it('returns the recorded scope and workspace binding', async () => { + mockGet.mockResolvedValue( + storedApproval({ scope: 'platform', workspaceId: 'ws-1', workspaceBound: true }) + ) + mockSet.mockResolvedValue('OK') + await expect(pollApproval(REQUEST, SECRET)).resolves.toEqual({ + status: 'approved', + userId: 'user-1', + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + }) + + it('reports an unbound workspace pick as a default, not a key scope', async () => { + mockGet.mockResolvedValue(storedApproval({ scope: 'platform', workspaceId: 'ws-1' })) + mockSet.mockResolvedValue('OK') + await expect(pollApproval(REQUEST, SECRET)).resolves.toMatchObject({ + workspaceId: 'ws-1', + workspaceBound: false, + }) + }) + + it('treats a record written before scopes existed as a copilot approval', async () => { + mockGet.mockResolvedValue( + JSON.stringify({ challenge: CHALLENGE, userId: 'user-1', createdAt: Date.now() }) + ) + mockSet.mockResolvedValue('OK') + await expect(pollApproval(REQUEST, SECRET)).resolves.toMatchObject({ scope: 'copilot' }) + }) + it('does NOT touch the record when the secret is wrong', async () => { mockGet.mockResolvedValue(storedApproval()) await expect(pollApproval(REQUEST, 'c'.repeat(43))).resolves.toEqual({ status: 'pending' }) diff --git a/apps/sim/lib/cli-auth/approval-store.ts b/apps/sim/lib/cli-auth/approval-store.ts index 607f6b38b73..7b07fe8cd19 100644 --- a/apps/sim/lib/cli-auth/approval-store.ts +++ b/apps/sim/lib/cli-auth/approval-store.ts @@ -1,5 +1,6 @@ import { safeCompare } from '@sim/security/compare' import { sha256Base64Url, sha256Hex } from '@sim/security/hash' +import type { CliAuthScope } from '@/lib/api/contracts/cli-auth' import { getRedisClient } from '@/lib/core/config/redis' /** @@ -29,10 +30,29 @@ interface ApprovalRecord { challenge: string /** Always taken from the approving user's session, never from a request body. */ userId: string + /** + * Which key space to mint from, fixed at approval time. Recording it here + * rather than reading it from the poll body is what makes the browser consent + * binding: the poll carries only a secret, so it cannot widen what the user + * agreed to. Absent on records written before the field existed — those are + * copilot approvals. + */ + scope?: CliAuthScope + /** Platform scope only: the workspace the user picked, for the terminal's default. */ + workspaceId?: string + /** Whether to mint a key scoped to `workspaceId`. Admin-verified at approval. */ + workspaceBound?: boolean createdAt: number } -export type PollResult = { status: 'pending' } | { status: 'approved'; userId: string } +export interface ApprovalGrant { + userId: string + scope: CliAuthScope + workspaceId: string | null + workspaceBound: boolean +} + +export type PollResult = { status: 'pending' } | ({ status: 'approved' } & ApprovalGrant) function requireRedis() { const redis = getRedisClient() @@ -61,10 +81,21 @@ function mintLockKey(requestId: string): string { export async function createApproval( userId: string, requestId: string, - challenge: string + challenge: string, + grant: { scope: CliAuthScope; workspaceId?: string; workspaceBound?: boolean } = { + scope: 'copilot', + } ): Promise { const redis = requireRedis() - const record: ApprovalRecord = { challenge, userId, createdAt: Date.now() } + const record: ApprovalRecord = { + challenge, + userId, + scope: grant.scope, + ...(grant.workspaceId + ? { workspaceId: grant.workspaceId, workspaceBound: grant.workspaceBound === true } + : {}), + createdAt: Date.now(), + } await redis.set(approvalKey(requestId), JSON.stringify(record), 'PX', APPROVAL_TTL_MS) } @@ -97,7 +128,13 @@ export async function pollApproval(requestId: string, pollSecret: string): Promi const reserved = await redis.set(mintLockKey(requestId), '1', 'PX', MINT_LOCK_TTL_MS, 'NX') if (reserved !== 'OK') return { status: 'pending' } - return { status: 'approved', userId: record.userId } + return { + status: 'approved', + userId: record.userId, + scope: record.scope ?? 'copilot', + workspaceId: record.workspaceId ?? null, + workspaceBound: record.workspaceBound === true, + } } /** Consumes the approval after a successful mint — single-use from here on. */ diff --git a/bun.lock b/bun.lock index b1527c615ef..0ad4015d018 100644 --- a/bun.lock +++ b/bun.lock @@ -580,6 +580,23 @@ "vitest": "^4.1.0", }, }, + "packages/sim-cli": { + "name": "@sim/cli", + "version": "0.1.0", + "bin": { + "sim": "dist/index.js", + }, + "dependencies": { + "chalk": "5.6.2", + "commander": "^11.1.0", + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/node": "24.2.1", + "typescript": "^7.0.2", + "vitest": "^3.2.4", + }, + }, "packages/terminal-protocol": { "name": "@sim/terminal-protocol", "version": "0.1.0", @@ -1705,7 +1722,55 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], "@s2-dev/streamstore": ["@s2-dev/streamstore@0.22.5", "", { "dependencies": { "@protobuf-ts/runtime": "^2.11.1", "debug": "^4.4.3" } }, "sha512-GqdOKIbIoIxT+40fnKzHbrsHB6gBqKdECmFe7D3Ojk4FoN1Hu0LhFzZv6ZmVMjoHHU+55debS1xSWjZwQmbIyQ=="], @@ -1737,6 +1802,8 @@ "@sim/browser-protocol": ["@sim/browser-protocol@workspace:packages/browser-protocol"], + "@sim/cli": ["@sim/cli@workspace:packages/sim-cli"], + "@sim/db": ["@sim/db@workspace:packages/db"], "@sim/desktop": ["@sim/desktop@workspace:apps/desktop"], @@ -2451,6 +2518,8 @@ "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], @@ -2467,7 +2536,7 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -2479,6 +2548,8 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + "cheerio": ["cheerio@1.1.2", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.0.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.12.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg=="], "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], @@ -2699,6 +2770,8 @@ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], @@ -3403,6 +3476,8 @@ "lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], "lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="], @@ -3771,6 +3846,8 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "pdf-lib": ["pdf-lib@1.17.1", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "pako": "^1.0.11", "tslib": "^1.11.1" } }, "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw=="], "pdfjs-dist": ["pdfjs-dist@5.4.296", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.80" } }, "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q=="], @@ -4065,6 +4142,8 @@ "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], + "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], + "rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="], "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -4257,6 +4336,8 @@ "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + "stripe": ["stripe@18.5.0", "", { "dependencies": { "qs": "^6.11.0" }, "peerDependencies": { "@types/node": ">=12.x.x" }, "optionalPeers": ["@types/node"] }, "sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA=="], "strnum": ["strnum@2.4.0", "", { "dependencies": { "anynum": "^1.0.0" } }, "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg=="], @@ -4345,8 +4426,12 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + "tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="], "tldts-core": ["tldts-core@7.4.3", "", {}, "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw=="], @@ -4483,6 +4568,8 @@ "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], @@ -4675,6 +4762,8 @@ "@earendil-works/pi-tui/marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + "@electric-sql/client/@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -4911,6 +5000,8 @@ "@sim/browser-protocol/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@sim/cli/vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="], + "@sim/terminal-protocol/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="], @@ -5007,6 +5098,8 @@ "@types/ws/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "ai/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], @@ -5353,6 +5446,8 @@ "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "svix/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], @@ -5387,6 +5482,10 @@ "unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="], + "vite-node/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "vite-node/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -5585,6 +5684,28 @@ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], + "@sim/cli/vitest/@vitest/expect": ["@vitest/expect@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w=="], + + "@sim/cli/vitest/@vitest/mocker": ["@vitest/mocker@3.2.7", "", { "dependencies": { "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA=="], + + "@sim/cli/vitest/@vitest/pretty-format": ["@vitest/pretty-format@3.2.7", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA=="], + + "@sim/cli/vitest/@vitest/runner": ["@vitest/runner@3.2.7", "", { "dependencies": { "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA=="], + + "@sim/cli/vitest/@vitest/snapshot": ["@vitest/snapshot@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g=="], + + "@sim/cli/vitest/@vitest/spy": ["@vitest/spy@3.2.7", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ=="], + + "@sim/cli/vitest/@vitest/utils": ["@vitest/utils@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw=="], + + "@sim/cli/vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "@sim/cli/vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "@sim/cli/vitest/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "@sim/cli/vitest/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + "@trigger.dev/core/@opentelemetry/api-logs/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], "@trigger.dev/core/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md new file mode 100644 index 00000000000..3eab36aa44b --- /dev/null +++ b/packages/sim-cli/README.md @@ -0,0 +1,142 @@ +# Sim CLI + +Talk to the [Sim](https://sim.ai) API from your terminal. + +```bash +npm install -g @sim/cli +sim login +sim workflows list +``` + +## Profiles + +Profiles work like the AWS CLI: one identity and one set of defaults per named +profile, selected with `--profile` or `SIM_PROFILE`. This is what lets you keep +production and a local dev stack side by side without re-authenticating. + +Non-secret settings live in `~/.sim/config`: + +```ini +[default] +endpoint = https://sim.ai +workspace = ws_abc123 +output = table + +[profile dev] +endpoint = http://localhost:3000 +workspace = ws_local +``` + +Keys live in `~/.sim/credentials`, written `0600`: + +```ini +[default] +api_key = sim_… + +[dev] +api_key = sim_… +``` + +The section-naming asymmetry — `[profile dev]` in config, `[dev]` in credentials +— is the AWS convention, kept so existing habits and tooling carry over. + +```bash +sim configure --set-endpoint http://localhost:3000 --profile dev +sim configure --set-workspace ws_local --profile dev +sim profiles # list them; * marks the active one +sim whoami # resolved values, and where each came from +``` + +## Where settings come from + +Each setting resolves independently, first match wins: + +| Rank | Source | +| --- | --- | +| 1 | Command-line flag (`--endpoint`, `--workspace`, `--output`) | +| 2 | Environment (`SIM_ENDPOINT`, `SIM_API_KEY`, `SIM_WORKSPACE`, `SIM_OUTPUT`) | +| 3 | `~/.sim/config` / `~/.sim/credentials` for the selected profile | +| 4 | Built-in default (`https://sim.ai`, `table`) | + +`sim whoami` prints the winning source per setting, which is usually the fastest +way to explain a surprising result. + +For CI, skip `sim login` entirely and set `SIM_API_KEY` and `SIM_WORKSPACE` — +nothing needs to touch the filesystem. `SIM_CONFIG_DIR` relocates both files if +you need to keep them somewhere other than `~/.sim`. + +## Logging in + +`sim login` uses the same browser handoff shape as `gh auth login`: the terminal +prints a pairing code and a URL, you approve in a browser, and the key comes back +over the CLI's own connection. Nothing redeemable crosses the browser leg, and +there is no loopback listener — so it works over SSH and inside containers. + +``` +$ sim login --profile dev --endpoint http://localhost:3000 + +Pairing code: K7M2-P9XT +Confirm this code matches what the browser shows before approving. + +http://localhost:3000/cli/auth?request=…&scope=platform +Waiting for approval… + +✓ Logged in. Key stored in /Users/you/.sim/credentials + Workspace-scoped key, pinned to ws_local. +``` + +The approval page is where you pick the workspace — the terminal has no key yet, +so it cannot list them for you. Whichever you pick becomes the profile's default +`workspace`, so you never have to go look up its id. + +What the key itself can reach depends on your role in that workspace, and the +page says which you are about to get before you approve: + +| Your role | Key issued | Reach | +| --- | --- | --- | +| Workspace admin | Workspace-scoped | That workspace only | +| Anything else | Personal | Every workspace you can access; `--workspace` overrides the default | + +`sim login --workspace ` preselects a workspace in the picker, and an +existing profile's workspace preselects itself on re-login. + +`sim logout` removes the stored key. It does not revoke it — do that in +Settings → API keys. + +## Commands + +```bash +sim workflows list [--folder ] [--deployed] [--limit ] +sim workflows get +sim workflows deploy|undeploy|rollback + +sim logs list [--level error] [--workflow …] [--trigger …] [--start ] +sim logs get +sim logs execution + +sim files list +sim files download [-o ] +sim files delete + +sim knowledge list +sim knowledge get +sim knowledge documents [--search ] +sim knowledge search --kb … +``` + +Every command takes `--output json` for scripting; the JSON is the API's own +response shape, so it pipes cleanly into `jq`. + +```bash +sim logs list --level error --output json | jq -r '.[].executionId' +``` + +## Notes + +- Commands talk to the `/api/v2` surface, which returns `{ data }` and + `{ data, nextCursor }`. List commands auto-page up to `--limit`. +- `sim tables` is not here yet — the tables v2 surface is still changing. + +## License + +Apache-2.0 diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json new file mode 100644 index 00000000000..4cc20b967fc --- /dev/null +++ b/packages/sim-cli/package.json @@ -0,0 +1,44 @@ +{ + "name": "@sim/cli", + "version": "0.1.0", + "description": "Sim CLI - talk to the Sim API from your terminal", + "type": "module", + "bin": { + "sim": "dist/index.js" + }, + "scripts": { + "build": "tsc", + "type-check": "tsc --noEmit", + "lint": "biome check --write --unsafe .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format .", + "test": "vitest run", + "prepublishOnly": "bun run build" + }, + "files": [ + "dist" + ], + "keywords": [ + "sim", + "ai", + "agents", + "cli", + "workflow" + ], + "author": "Sim", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "dependencies": { + "chalk": "5.6.2", + "commander": "^11.1.0" + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/node": "24.2.1", + "typescript": "^7.0.2", + "vitest": "^3.2.4" + } +} diff --git a/packages/sim-cli/src/auth/device-flow.ts b/packages/sim-cli/src/auth/device-flow.ts new file mode 100644 index 00000000000..01b428b817b --- /dev/null +++ b/packages/sim-cli/src/auth/device-flow.ts @@ -0,0 +1,159 @@ +import { createHash, randomBytes, randomInt } from 'node:crypto' +import { SimApiError } from '../http/client.js' + +/** + * The terminal half of the CLI key handoff. + * + * Shaped like OAuth's device authorization grant: the CLI mints a rendezvous id + * and a secret, sends only the secret's SHA-256 challenge through the browser, + * and redeems the key over its own TLS connection. The browser leg therefore + * never carries anything redeemable, and no loopback listener is required — + * which matters because the terminal is often not on the same machine as the + * browser (SSH, containers, remote dev boxes). + */ + +/** No look-alike characters: the human is comparing this across two screens. */ +const PAIRING_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + +const POLL_INTERVAL_MS = 2000 +const POLL_TIMEOUT_MS = 15 * 60 * 1000 + +export type CliAuthScope = 'copilot' | 'platform' + +export interface AuthRequest { + /** Semi-public rendezvous handle; travels in the browser URL. */ + request: string + /** Never leaves this process until the poll redeems it. */ + pollSecret: string + /** BASE64URL(SHA256(pollSecret)), registered when the user approves. */ + challenge: string + /** Printed for the user to compare against the browser. Never sent to the API. */ + pairing: string +} + +export interface MintedKey { + id: string + apiKey: string + scope: CliAuthScope + /** The workspace picked in the browser — the profile's default target. */ + workspaceId: string | null + /** Whether the key can *only* reach that workspace. */ + workspaceBound: boolean +} + +/** 32 bytes of entropy, base64url — 43 characters, exactly what the contract accepts. */ +function token(): string { + return randomBytes(32).toString('base64url') +} + +function pairingCode(): string { + const draw = (count: number) => + Array.from({ length: count }, () => PAIRING_ALPHABET[randomInt(PAIRING_ALPHABET.length)]).join( + '' + ) + return `${draw(4)}-${draw(4)}` +} + +export function createAuthRequest(): AuthRequest { + const pollSecret = token() + return { + request: token(), + pollSecret, + challenge: createHash('sha256').update(pollSecret, 'utf8').digest('base64url'), + pairing: pairingCode(), + } +} + +export function buildApprovalUrl( + endpoint: string, + auth: AuthRequest, + scope: CliAuthScope, + workspaceId?: string +): string { + const url = new URL('/cli/auth', endpoint) + url.searchParams.set('request', auth.request) + url.searchParams.set('challenge', auth.challenge) + url.searchParams.set('pairing', auth.pairing) + url.searchParams.set('scope', scope) + if (workspaceId) url.searchParams.set('workspace', workspaceId) + return url.toString() +} + +interface PollResponse { + status: 'pending' | 'complete' + key?: { id: string; apiKey: string } + scope?: CliAuthScope + workspaceId?: string | null + workspaceBound?: boolean +} + +/** + * Polls until the user approves in the browser. + * + * Transport failures are swallowed and retried rather than aborting the login: + * a laptop that slept, a VPN reconnecting, or a deploy rolling the server mid- + * wait are all recoverable, and the approval sits in Redis with its own TTL. A + * non-2xx *response*, by contrast, is the server refusing on purpose and is + * surfaced immediately. + */ +export async function pollForKey( + endpoint: string, + auth: AuthRequest, + signal?: AbortSignal +): Promise { + const deadline = Date.now() + POLL_TIMEOUT_MS + + while (Date.now() < deadline) { + if (signal?.aborted) throw new SimApiError('Login cancelled.', 0) + + let response: Response | null = null + try { + response = await fetch(new URL('/api/cli/auth/poll', endpoint), { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ request: auth.request, verifier: auth.pollSecret }), + signal, + }) + } catch { + response = null + } + + if (response) { + const raw = await response.text() + + if (!response.ok) { + // 429 is the poll cadence bumping the per-IP bucket, not a refusal — + // back off and keep the login alive instead of making the user restart. + if (response.status !== 429) { + let message = `Login failed with status ${response.status}` + try { + const body = JSON.parse(raw) as { error?: unknown } + if (typeof body.error === 'string') message = body.error + else if (body.error && typeof body.error === 'object') { + const detail = (body.error as { message?: unknown }).message + if (typeof detail === 'string') message = detail + } + } catch {} + throw new SimApiError(message, response.status) + } + } else { + const body = JSON.parse(raw) as PollResponse + if (body.status === 'complete' && body.key) { + return { + id: body.key.id, + apiKey: body.key.apiKey, + // Older servers answer without these; a key from a server that does + // not know about scopes is a copilot key by definition. + scope: body.scope ?? 'copilot', + workspaceId: body.workspaceId ?? null, + workspaceBound: body.workspaceBound === true, + } + } + } + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) + } + + throw new SimApiError('Timed out waiting for browser approval.', 0) +} diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts new file mode 100644 index 00000000000..262e2f51bac --- /dev/null +++ b/packages/sim-cli/src/commands/auth.ts @@ -0,0 +1,195 @@ +import { spawn } from 'node:child_process' +import chalk from 'chalk' +import { Command } from 'commander' +import { + buildApprovalUrl, + type CliAuthScope, + createAuthRequest, + pollForKey, +} from '../auth/device-flow.js' +import { + credentialsPath, + deleteProfile, + listProfiles, + readCredentialsProfile, + writeConfigProfile, + writeCredentialsProfile, +} from '../config/index.js' +import { profileFrom } from '../context.js' +import { SimApiError } from '../http/client.js' +import { printRecord } from '../output/render.js' + +/** + * Best-effort browser launch. Failure is not an error: the URL is always printed + * first, so a headless box, an SSH session, or a machine with no handler just + * falls through to the user pasting it somewhere. + */ +function openBrowser(url: string): void { + const command = + process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open' + try { + const child = spawn(command, [url], { + stdio: 'ignore', + detached: true, + shell: process.platform === 'win32', + }) + child.on('error', () => {}) + child.unref() + } catch {} +} + +function maskKey(key: string): string { + return key.length <= 10 ? '•'.repeat(key.length) : `${key.slice(0, 6)}…${key.slice(-4)}` +} + +export function loginCommand(): Command { + return new Command('login') + .description('Authorize this terminal and store an API key for the profile') + .option('--scope ', 'Key space to mint from: platform or copilot', 'platform') + .option('--no-browser', 'Print the URL instead of opening a browser') + .action(async (options: { scope: string; browser: boolean }, command: Command) => { + const profile = profileFrom(command) + + if (options.scope !== 'platform' && options.scope !== 'copilot') { + throw new SimApiError(`Unknown scope "${options.scope}". Use platform or copilot.`, 0) + } + const scope = options.scope as CliAuthScope + + const auth = createAuthRequest() + const url = buildApprovalUrl(profile.endpoint, auth, scope, profile.workspaceId ?? undefined) + + console.log( + `Signing in to ${chalk.bold(profile.endpoint)} as profile ${chalk.bold(profile.name)}` + ) + console.log(`\nPairing code: ${chalk.bold(auth.pairing)}`) + console.log(chalk.dim('Confirm this code matches what the browser shows before approving.\n')) + console.log(url) + + if (options.browser) openBrowser(url) + console.log(chalk.dim('\nWaiting for approval…')) + + const key = await pollForKey(profile.endpoint, auth) + + if (key.scope !== scope) { + // The approval, not the request, decides the scope. Storing a copilot + // key where a platform key belongs would fail every later call with an + // unexplained 401, so refuse now with the reason. + throw new SimApiError( + `Server issued a ${key.scope} key but this profile needs a ${scope} key. Update the Sim deployment, or run: sim login --scope ${key.scope}`, + 0 + ) + } + + writeCredentialsProfile(profile.name, key.apiKey) + + // The workspace picked in the browser becomes the profile's default, + // whether or not the key is scoped to it. The user chose it by name — + // making them look up its id afterwards would waste the one moment the + // answer was already on screen. + const settings: Record = { endpoint: profile.endpoint } + if (key.workspaceId) settings.workspace = key.workspaceId + writeConfigProfile(profile.name, settings) + + console.log(chalk.green(`\n✓ Logged in. Key stored in ${credentialsPath()}`)) + if (key.workspaceBound && key.workspaceId) { + console.log(chalk.dim(` Workspace-scoped key — it can only reach ${key.workspaceId}.`)) + } else if (key.workspaceId) { + console.log( + chalk.dim( + ` Personal key, defaulting to ${key.workspaceId}. Override per command with --workspace.` + ) + ) + } else if (!profile.workspaceId) { + console.log( + chalk.dim( + ' Personal key with no default workspace. Set one with: sim configure --set-workspace ' + ) + ) + } + }) +} + +export function logoutCommand(): Command { + return new Command('logout') + .description("Remove the profile's stored API key") + .option('--all', 'Remove the profile entirely, including its settings') + .action((options: { all?: boolean }, command: Command) => { + const profile = profileFrom(command) + + if (options.all) { + const removed = deleteProfile(profile.name) + if (!removed.config && !removed.credentials) { + console.log(chalk.dim(`Nothing stored for profile "${profile.name}".`)) + return + } + console.log(chalk.green(`✓ Removed profile "${profile.name}".`)) + return + } + + if (!readCredentialsProfile(profile.name).api_key) { + console.log(chalk.dim(`No stored key for profile "${profile.name}".`)) + return + } + + writeCredentialsProfile(profile.name, null) + console.log(chalk.green(`✓ Removed the stored key for profile "${profile.name}".`)) + // The key still exists server-side; leaving that unsaid invites the + // assumption that logging out revoked it. + console.log(chalk.dim(' The key itself is still active — revoke it in Settings → API keys.')) + }) +} + +export function whoamiCommand(): Command { + return new Command('whoami') + .description('Show the resolved profile and where each setting came from') + .action((_options: unknown, command: Command) => { + const profile = profileFrom(command) + const { sources } = profile + + const annotate = (value: string, source: string) => + source === 'unset' ? chalk.dim('not set') : `${value} ${chalk.dim(`(${source})`)}` + + printRecord( + profile.output, + [ + ['Profile', profile.name], + ['Endpoint', annotate(profile.endpoint, sources.endpoint)], + [ + 'API key', + profile.apiKey + ? annotate(maskKey(profile.apiKey), sources.apiKey) + : chalk.yellow('not logged in'), + ], + ['Workspace', annotate(profile.workspaceId ?? '', sources.workspaceId)], + ['Output', annotate(profile.output, sources.output)], + ], + { + profile: profile.name, + endpoint: profile.endpoint, + workspaceId: profile.workspaceId, + output: profile.output, + authenticated: Boolean(profile.apiKey), + sources, + } + ) + }) +} + +export function profilesCommand(): Command { + return new Command('profiles') + .description('List the profiles defined in the config and credentials files') + .action((_options: unknown, command: Command) => { + const profiles = listProfiles() + if (profiles.length === 0) { + console.log(chalk.dim('No profiles yet. Run: sim login')) + return + } + + const active = profileFrom(command).name + for (const name of profiles) { + const marker = name === active ? chalk.green('*') : ' ' + const hasKey = Boolean(readCredentialsProfile(name).api_key) + console.log(`${marker} ${name}${hasKey ? '' : chalk.dim(' (no key)')}`) + } + }) +} diff --git a/packages/sim-cli/src/commands/configure.ts b/packages/sim-cli/src/commands/configure.ts new file mode 100644 index 00000000000..af804495396 --- /dev/null +++ b/packages/sim-cli/src/commands/configure.ts @@ -0,0 +1,72 @@ +import chalk from 'chalk' +import { Command } from 'commander' +import { + configPath, + OUTPUT_FORMATS, + readConfigProfile, + writeConfigProfile, +} from '../config/index.js' +import { profileFrom } from '../context.js' +import { SimApiError } from '../http/client.js' + +/** + * Non-secret profile settings. Credentials are deliberately not settable here — + * they arrive through `sim login`, which is the only path that mints a key with + * a recorded consent behind it. + */ +export function configureCommand(): Command { + return new Command('configure') + .description("Set a profile's endpoint, default workspace, or output format") + .option('--set-endpoint ', 'Sim deployment to talk to') + .option('--set-workspace ', 'Default workspace for workspace-scoped commands') + .option('--set-output ', `Default output format (${OUTPUT_FORMATS.join(' | ')})`) + .option('--unset ', 'Remove settings (endpoint, workspace, output)') + .action( + ( + options: { + setEndpoint?: string + setWorkspace?: string + setOutput?: string + unset?: string[] + }, + command: Command + ) => { + const profile = profileFrom(command) + const updates: Record = {} + + if (options.setEndpoint) updates.endpoint = options.setEndpoint.replace(/\/+$/, '') + if (options.setWorkspace) updates.workspace = options.setWorkspace + if (options.setOutput) { + if (!(OUTPUT_FORMATS as readonly string[]).includes(options.setOutput)) { + throw new SimApiError( + `Unknown output format "${options.setOutput}". Use one of: ${OUTPUT_FORMATS.join(', ')}`, + 0 + ) + } + updates.output = options.setOutput + } + + for (const key of options.unset ?? []) { + if (!['endpoint', 'workspace', 'output'].includes(key)) { + throw new SimApiError(`Cannot unset "${key}". Use endpoint, workspace, or output.`, 0) + } + updates[key] = null + } + + if (Object.keys(updates).length === 0) { + const current = readConfigProfile(profile.name) + if (Object.keys(current).length === 0) { + console.log(chalk.dim(`No settings stored for profile "${profile.name}".`)) + return + } + for (const [key, value] of Object.entries(current)) { + console.log(`${chalk.dim(`${key}:`)} ${value}`) + } + return + } + + writeConfigProfile(profile.name, updates) + console.log(chalk.green(`✓ Updated profile "${profile.name}" in ${configPath()}`)) + } + ) +} diff --git a/packages/sim-cli/src/commands/files.ts b/packages/sim-cli/src/commands/files.ts new file mode 100644 index 00000000000..3cf5c1df6c0 --- /dev/null +++ b/packages/sim-cli/src/commands/files.ts @@ -0,0 +1,129 @@ +import { once } from 'node:events' +import { createWriteStream, type WriteStream } from 'node:fs' +import { basename } from 'node:path' +import chalk from 'chalk' +import { Command } from 'commander' +import { clientFrom } from '../context.js' +import { SimApiError } from '../http/client.js' +import { bytes, type Column, printList, timestamp } from '../output/render.js' + +interface WorkspaceFile { + id: string + name: string + size: number + type: string + key: string + uploadedBy: string + uploadedAt: string +} + +/** + * Streams a fetch body to disk, honouring backpressure. + * + * Written as an explicit reader loop rather than `Readable.fromWeb`: the DOM + * `ReadableStream` that `fetch` returns and the one `node:stream/web` declares + * are structurally incompatible under this TS config, and bridging them needs a + * cast that would erase exactly the typing this loop keeps honest. + */ +async function streamToFile(body: ReadableStream, file: WriteStream): Promise { + const reader = body.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + // `write` returning false means the internal buffer is full; waiting for + // `drain` is what stops a large file from being buffered in memory. + if (!file.write(value)) await once(file, 'drain') + } + } finally { + reader.releaseLock() + } + + await new Promise((resolve, reject) => { + file.once('error', reject) + file.end(resolve) + }) +} + +const LIST_COLUMNS: Column[] = [ + { header: 'id', value: (file) => file.id }, + { header: 'name', value: (file) => file.name }, + { header: 'size', value: (file) => bytes(file.size) }, + { header: 'type', value: (file) => file.type }, + { header: 'uploaded', value: (file) => timestamp(file.uploadedAt) }, +] + +export function filesCommand(): Command { + const files = new Command('files').alias('file').description('List and download workspace files') + + files + .command('list') + .alias('ls') + .description('List files in a workspace') + .option('--limit ', 'Maximum files to return', '100') + .action(async (options: { limit: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const limit = Number.parseInt(options.limit, 10) + + const rows = await client.collect( + '/api/v2/files', + { query: { workspaceId: client.requireWorkspace(), limit: Math.min(limit, 1000) } }, + limit + ) + + printList(profile.output, rows, LIST_COLUMNS) + }) + + files + .command('download ') + .description('Download a file') + .option('-o, --output-file ', 'Where to write it (defaults to the file name)') + .action(async (fileId: string, options: { outputFile?: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + + if (!profile.apiKey) { + throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0) + } + + // Streamed rather than routed through the JSON client: the response is + // binary of unbounded size, so buffering it just to write it out would put + // the whole file in memory. + const url = new URL(`${profile.endpoint}/api/v2/files/${fileId}`) + url.searchParams.set('workspaceId', workspaceId) + + const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } }) + if (!response.ok || !response.body) { + const raw = await response.text().catch(() => '') + throw new SimApiError( + raw || `Download failed with status ${response.status}`, + response.status + ) + } + + const target = + options.outputFile ?? + basename( + // `filename="…"` from the route's content-disposition, when present. + /filename="?([^";]+)"?/.exec(response.headers.get('content-disposition') ?? '')?.[1] ?? + fileId + ) + + await streamToFile(response.body, createWriteStream(target)) + console.log(chalk.green(`✓ Saved ${target}`)) + }) + + files + .command('delete ') + .description('Archive a file') + .action(async (fileId: string, _options: unknown, command: Command) => { + const { client } = clientFrom(command) + await client.getData(`/api/v2/files/${fileId}`, { + method: 'DELETE', + query: { workspaceId: client.requireWorkspace() }, + }) + console.log(chalk.green(`✓ Deleted ${fileId}`)) + }) + + return files +} diff --git a/packages/sim-cli/src/commands/knowledge.ts b/packages/sim-cli/src/commands/knowledge.ts new file mode 100644 index 00000000000..00a8a95ec43 --- /dev/null +++ b/packages/sim-cli/src/commands/knowledge.ts @@ -0,0 +1,165 @@ +import { Command } from 'commander' +import { clientFrom } from '../context.js' +import { bytes, type Column, printList, printRecord, text, timestamp } from '../output/render.js' + +interface KnowledgeBase { + id: string + name: string + description: string | null + docCount: number + tokenCount: number + embeddingModel: string + createdAt: string | null + updatedAt: string | null +} + +interface KnowledgeDocument { + id: string + knowledgeBaseId: string + filename: string + fileSize: number + mimeType: string + processingStatus: string + chunkCount: number + tokenCount: number + enabled: boolean + createdAt: string | null +} + +interface SearchHit { + documentId: string + documentName: string | null + content: string + chunkIndex: number + similarity: number +} + +const BASE_COLUMNS: Column[] = [ + { header: 'id', value: (kb) => kb.id }, + { header: 'name', value: (kb) => kb.name }, + { header: 'docs', value: (kb) => String(kb.docCount) }, + { header: 'tokens', value: (kb) => String(kb.tokenCount) }, + { header: 'model', value: (kb) => kb.embeddingModel }, +] + +const DOCUMENT_COLUMNS: Column[] = [ + { header: 'id', value: (doc) => doc.id }, + { header: 'filename', value: (doc) => doc.filename }, + { header: 'size', value: (doc) => bytes(doc.fileSize) }, + { header: 'status', value: (doc) => doc.processingStatus }, + { header: 'chunks', value: (doc) => String(doc.chunkCount) }, + { header: 'created', value: (doc) => timestamp(doc.createdAt) }, +] + +/** Search hits are long prose; keep the table readable and single-line. */ +function preview(content: string): string { + const collapsed = content.replace(/\s+/g, ' ').trim() + return collapsed.length <= 80 ? collapsed : `${collapsed.slice(0, 79)}…` +} + +export function knowledgeCommand(): Command { + const knowledge = new Command('knowledge') + .alias('kb') + .description('Browse and search knowledge bases') + + knowledge + .command('list') + .alias('ls') + .description('List knowledge bases in a workspace') + .action(async (_options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const page = await client.getPage('/api/v2/knowledge', { + query: { workspaceId: client.requireWorkspace() }, + }) + printList(profile.output, page.data, BASE_COLUMNS) + }) + + knowledge + .command('get ') + .description('Show one knowledge base') + .action(async (id: string, _options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const { knowledgeBase } = await client.getData<{ knowledgeBase: KnowledgeBase }>( + `/api/v2/knowledge/${id}`, + { query: { workspaceId: client.requireWorkspace() } } + ) + + printRecord( + profile.output, + [ + ['ID', knowledgeBase.id], + ['Name', knowledgeBase.name], + ['Description', text(knowledgeBase.description)], + ['Documents', String(knowledgeBase.docCount)], + ['Tokens', String(knowledgeBase.tokenCount)], + ['Embedding model', knowledgeBase.embeddingModel], + ['Updated', timestamp(knowledgeBase.updatedAt)], + ], + knowledgeBase + ) + }) + + knowledge + .command('documents ') + .alias('docs') + .description('List the documents in a knowledge base') + .option('--search ', 'Filter by filename') + .option('--status ', 'Filter by enabled state: all, enabled, or disabled', 'all') + .option('--limit ', 'Maximum documents to return', '50') + .action( + async ( + id: string, + options: { search?: string; status: string; limit: string }, + command: Command + ) => { + const { client, profile } = clientFrom(command) + const limit = Number.parseInt(options.limit, 10) + + const rows = await client.collect( + `/api/v2/knowledge/${id}/documents`, + { + query: { + workspaceId: client.requireWorkspace(), + search: options.search, + enabledFilter: options.status, + limit: Math.min(limit, 100), + }, + }, + limit + ) + + printList(profile.output, rows, DOCUMENT_COLUMNS) + } + ) + + knowledge + .command('search ') + .description('Vector-search one or more knowledge bases') + .requiredOption('--kb ', 'Knowledge base ids to search') + .option('--top-k ', 'Number of hits to return', '10') + .action(async (query: string, options: { kb: string[]; topK: string }, command: Command) => { + const { client, profile } = clientFrom(command) + + const result = await client.getData<{ results: SearchHit[]; totalResults: number }>( + '/api/v2/knowledge/search', + { + method: 'POST', + body: { + workspaceId: client.requireWorkspace(), + knowledgeBaseIds: options.kb, + query, + topK: Number.parseInt(options.topK, 10), + }, + } + ) + + printList(profile.output, result.results, [ + { header: 'score', value: (hit) => hit.similarity.toFixed(3) }, + { header: 'document', value: (hit) => text(hit.documentName ?? hit.documentId) }, + { header: 'chunk', value: (hit) => String(hit.chunkIndex) }, + { header: 'content', value: (hit) => preview(hit.content) }, + ]) + }) + + return knowledge +} diff --git a/packages/sim-cli/src/commands/logs.ts b/packages/sim-cli/src/commands/logs.ts new file mode 100644 index 00000000000..ac20e8e76d2 --- /dev/null +++ b/packages/sim-cli/src/commands/logs.ts @@ -0,0 +1,159 @@ +import chalk from 'chalk' +import { Command } from 'commander' +import { clientFrom } from '../context.js' +import { type Column, duration, printList, printRecord, text, timestamp } from '../output/render.js' + +interface LogListItem { + id: string + workflowId: string | null + executionId: string + level: string + trigger: string + startedAt: string + endedAt: string | null + totalDurationMs: number | null + cost: { total: number } | null + workflow?: { id: string | null; name: string; deleted: boolean } +} + +interface LogDetail extends LogListItem { + executionData: unknown + createdAt: string +} + +function level(value: string): string { + return value === 'error' ? chalk.red(value) : value +} + +function cost(value: { total: number } | null): string { + return value ? `$${value.total.toFixed(4)}` : text(null) +} + +const LIST_COLUMNS: Column[] = [ + { header: 'started', value: (log) => timestamp(log.startedAt) }, + { header: 'level', value: (log) => level(log.level) }, + { header: 'trigger', value: (log) => log.trigger }, + { header: 'workflow', value: (log) => text(log.workflow?.name ?? log.workflowId) }, + { header: 'duration', value: (log) => duration(log.totalDurationMs) }, + { header: 'cost', value: (log) => cost(log.cost) }, + { header: 'execution', value: (log) => log.executionId }, +] + +export function logsCommand(): Command { + const logs = new Command('logs').alias('log').description('Read workflow execution logs') + + logs + .command('list') + .alias('ls') + .description('List execution logs in a workspace') + .option('--workflow ', 'Restrict to these workflow ids') + .option('--trigger ', 'Restrict to these triggers (api, schedule, webhook, manual…)') + .option('--level ', 'Filter by level: info or error') + .option('--execution ', 'Restrict to a single execution id') + .option('--start ', 'Only runs starting at or after this ISO date') + .option('--end ', 'Only runs starting at or before this ISO date') + .option('--order ', 'Sort by start time: desc or asc', 'desc') + .option('--limit ', 'Maximum logs to return', '50') + .action( + async ( + options: { + workflow?: string[] + trigger?: string[] + level?: string + execution?: string + start?: string + end?: string + order: string + limit: string + }, + command: Command + ) => { + const { client, profile } = clientFrom(command) + const limit = Number.parseInt(options.limit, 10) + + const rows = await client.collect( + '/api/v2/logs', + { + query: { + workspaceId: client.requireWorkspace(), + // The route takes these as comma-joined strings, not repeated params. + workflowIds: options.workflow?.join(','), + triggers: options.trigger?.join(','), + level: options.level, + executionId: options.execution, + startDate: options.start, + endDate: options.end, + order: options.order, + details: 'full', + limit: Math.min(limit, 1000), + }, + }, + limit + ) + + printList(profile.output, rows, LIST_COLUMNS) + } + ) + + logs + .command('get ') + .description('Show one log, including its execution trace') + .action(async (id: string, _options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const log = await client.getData(`/api/v2/logs/${id}`) + + printRecord( + profile.output, + [ + ['ID', log.id], + ['Execution', log.executionId], + ['Workflow', text(log.workflow?.name ?? log.workflowId)], + ['Level', level(log.level)], + ['Trigger', log.trigger], + ['Started', timestamp(log.startedAt)], + ['Ended', timestamp(log.endedAt)], + ['Duration', duration(log.totalDurationMs)], + ['Cost', cost(log.cost)], + ], + log + ) + + if (profile.output === 'table') { + console.log(chalk.dim('\nRun with --output json to see the full execution trace.')) + } + }) + + logs + .command('execution ') + .description('Show the workflow state snapshot for an execution') + .action(async (executionId: string, _options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const execution = await client.getData<{ + executionId: string + workflowId: string | null + executionMetadata: { + trigger: string + startedAt: string + endedAt: string | null + totalDurationMs: number | null + cost: { total: number } | null + } + }>(`/api/v2/logs/executions/${executionId}`) + + printRecord( + profile.output, + [ + ['Execution', execution.executionId], + ['Workflow', text(execution.workflowId)], + ['Trigger', execution.executionMetadata.trigger], + ['Started', timestamp(execution.executionMetadata.startedAt)], + ['Ended', timestamp(execution.executionMetadata.endedAt)], + ['Duration', duration(execution.executionMetadata.totalDurationMs)], + ['Cost', cost(execution.executionMetadata.cost)], + ], + execution + ) + }) + + return logs +} diff --git a/packages/sim-cli/src/commands/workflows.ts b/packages/sim-cli/src/commands/workflows.ts new file mode 100644 index 00000000000..a2acca4c076 --- /dev/null +++ b/packages/sim-cli/src/commands/workflows.ts @@ -0,0 +1,148 @@ +import chalk from 'chalk' +import { Command } from 'commander' +import { clientFrom } from '../context.js' +import { bool, type Column, printList, printRecord, text, timestamp } from '../output/render.js' + +interface WorkflowListItem { + id: string + name: string + description: string | null + folderId: string | null + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string +} + +interface WorkflowDetail extends WorkflowListItem { + variables: Record + inputs: Array<{ name: string; type: string; description?: string }> +} + +const LIST_COLUMNS: Column[] = [ + { header: 'id', value: (w) => w.id }, + { header: 'name', value: (w) => w.name }, + { header: 'deployed', value: (w) => bool(w.isDeployed) }, + { header: 'runs', value: (w) => String(w.runCount) }, + { header: 'last run', value: (w) => timestamp(w.lastRunAt) }, +] + +export function workflowsCommand(): Command { + const workflows = new Command('workflows') + .alias('workflow') + .description('List and manage workflows') + + workflows + .command('list') + .alias('ls') + .description('List workflows in a workspace') + .option('--folder ', 'Only workflows in this folder') + .option('--deployed', 'Only deployed workflows') + .option('--limit ', 'Maximum workflows to return', '50') + .action( + async (options: { folder?: string; deployed?: boolean; limit: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const limit = Number.parseInt(options.limit, 10) + + const rows = await client.collect( + '/api/v2/workflows', + { + query: { + workspaceId: client.requireWorkspace(), + folderId: options.folder, + deployedOnly: options.deployed ? 'true' : undefined, + // The route caps a page at 100; `collect` pages past that up to `limit`. + limit: Math.min(limit, 100), + }, + }, + limit + ) + + printList(profile.output, rows, LIST_COLUMNS) + } + ) + + workflows + .command('get ') + .description('Show one workflow, including its trigger inputs') + .action(async (id: string, _options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const workflow = await client.getData(`/api/v2/workflows/${id}`) + + printRecord( + profile.output, + [ + ['ID', workflow.id], + ['Name', workflow.name], + ['Description', text(workflow.description)], + ['Workspace', workflow.workspaceId], + ['Folder', text(workflow.folderId)], + ['Deployed', bool(workflow.isDeployed)], + ['Deployed at', timestamp(workflow.deployedAt)], + ['Runs', String(workflow.runCount)], + ['Last run', timestamp(workflow.lastRunAt)], + [ + 'Inputs', + workflow.inputs.length > 0 + ? workflow.inputs.map((input) => `${input.name}:${input.type}`).join(', ') + : text(null), + ], + ['Updated', timestamp(workflow.updatedAt)], + ], + workflow + ) + }) + + workflows + .command('deploy ') + .description('Deploy a workflow') + .action(async (id: string, _options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const result = await client.getData>( + `/api/v2/workflows/${id}/deploy`, + { method: 'POST' } + ) + if (profile.output === 'json') { + console.log(JSON.stringify(result, null, 2)) + return + } + console.log(chalk.green(`✓ Deployed ${id}`)) + }) + + workflows + .command('undeploy ') + .description('Take a workflow out of deployment') + .action(async (id: string, _options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const result = await client.getData>( + `/api/v2/workflows/${id}/deploy`, + { method: 'DELETE' } + ) + if (profile.output === 'json') { + console.log(JSON.stringify(result, null, 2)) + return + } + console.log(chalk.green(`✓ Undeployed ${id}`)) + }) + + workflows + .command('rollback ') + .description('Roll a deployed workflow back to its previous version') + .action(async (id: string, _options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const result = await client.getData>( + `/api/v2/workflows/${id}/rollback`, + { method: 'POST' } + ) + if (profile.output === 'json') { + console.log(JSON.stringify(result, null, 2)) + return + } + console.log(chalk.green(`✓ Rolled back ${id}`)) + }) + + return workflows +} diff --git a/packages/sim-cli/src/config/index.ts b/packages/sim-cli/src/config/index.ts new file mode 100644 index 00000000000..5a11e311370 --- /dev/null +++ b/packages/sim-cli/src/config/index.ts @@ -0,0 +1,17 @@ +export { configDir, configPath, credentialsPath } from './paths.js' +export { + DEFAULT_ENDPOINT, + DEFAULT_PROFILE, + deleteProfile, + listProfiles, + OUTPUT_FORMATS, + type OutputFormat, + type ProfileOverrides, + type ResolvedProfile, + readConfigProfile, + readCredentialsProfile, + resolveProfile, + type SettingSource, + writeConfigProfile, + writeCredentialsProfile, +} from './profile.js' diff --git a/packages/sim-cli/src/config/ini.test.ts b/packages/sim-cli/src/config/ini.test.ts new file mode 100644 index 00000000000..ba3a93fb84c --- /dev/null +++ b/packages/sim-cli/src/config/ini.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { + getSection, + listSections, + parseIni, + removeSection, + serializeIni, + setSectionValues, +} from './ini.js' + +const SAMPLE = `# top-level note +[default] +endpoint = https://sim.ai +workspace = ws_1 + +[profile dev] +# points at the local stack +endpoint = http://localhost:3000 +` + +describe('ini', () => { + it('reads keys out of a section', () => { + expect(getSection(parseIni(SAMPLE), 'default')).toEqual({ + endpoint: 'https://sim.ai', + workspace: 'ws_1', + }) + }) + + it('reads a section whose name contains a space', () => { + expect(getSection(parseIni(SAMPLE), 'profile dev')).toEqual({ + endpoint: 'http://localhost:3000', + }) + }) + + it('returns null for a section that is not there', () => { + expect(getSection(parseIni(SAMPLE), 'profile nope')).toBeNull() + }) + + it('lists sections in file order', () => { + expect(listSections(parseIni(SAMPLE))).toEqual(['default', 'profile dev']) + }) + + it('preserves comments and untouched keys through a write', () => { + const doc = parseIni(SAMPLE) + setSectionValues(doc, 'profile dev', { workspace: 'ws_local' }) + const out = serializeIni(doc) + + expect(out).toContain('# top-level note') + expect(out).toContain('# points at the local stack') + expect(out).toContain('endpoint = http://localhost:3000') + expect(out).toContain('workspace = ws_local') + }) + + it('updates a key in place rather than appending a duplicate', () => { + const doc = parseIni(SAMPLE) + setSectionValues(doc, 'default', { endpoint: 'https://staging.sim.ai' }) + const out = serializeIni(doc) + + expect(out).not.toContain('https://sim.ai\n') + expect(out.match(/endpoint = /g)).toHaveLength(2) // one per section, not three + }) + + it('removes a key when the value is null', () => { + const doc = parseIni(SAMPLE) + setSectionValues(doc, 'default', { workspace: null }) + expect(getSection(parseIni(serializeIni(doc)), 'default')).toEqual({ + endpoint: 'https://sim.ai', + }) + }) + + it('creates a section that does not exist yet', () => { + const doc = parseIni(SAMPLE) + setSectionValues(doc, 'profile prod', { endpoint: 'https://sim.ai' }) + expect(getSection(parseIni(serializeIni(doc)), 'profile prod')).toEqual({ + endpoint: 'https://sim.ai', + }) + }) + + it('does not accumulate blank lines across repeated writes', () => { + let text = SAMPLE + for (let i = 0; i < 5; i++) { + const doc = parseIni(text) + setSectionValues(doc, 'default', { workspace: `ws_${i}` }) + text = serializeIni(doc) + } + expect(text).not.toContain('\n\n\n') + }) + + it('keeps a comment containing "=" as a comment', () => { + const doc = parseIni('[default]\n# note: a = b\nendpoint = https://sim.ai\n') + expect(getSection(doc, 'default')).toEqual({ endpoint: 'https://sim.ai' }) + expect(serializeIni(doc)).toContain('# note: a = b') + }) + + it('removes a whole section', () => { + const doc = parseIni(SAMPLE) + expect(removeSection(doc, 'profile dev')).toBe(true) + expect(removeSection(doc, 'profile dev')).toBe(false) + expect(listSections(doc)).toEqual(['default']) + }) + + it('round-trips an empty document without emitting a stray newline', () => { + expect(serializeIni(parseIni(''))).toBe('') + }) +}) diff --git a/packages/sim-cli/src/config/ini.ts b/packages/sim-cli/src/config/ini.ts new file mode 100644 index 00000000000..6b220b82267 --- /dev/null +++ b/packages/sim-cli/src/config/ini.ts @@ -0,0 +1,130 @@ +/** + * A minimal INI reader/writer for the AWS-style `~/.sim/config` and + * `~/.sim/credentials` files. + * + * Parsing keeps every line it did not understand — comments, blank lines, + * unrecognized keys — and writing re-emits them in place. These are files people + * hand-edit, so a round trip through `sim login` must not silently delete the + * comment above someone's staging endpoint. + * + * Deliberately not a general INI implementation: no nested sections, no `[a.b]` + * paths, no quoting rules beyond trimming. The format only has to carry a + * handful of flat string settings. + */ + +type Entry = { kind: 'kv'; key: string; value: string } | { kind: 'raw'; text: string } + +interface Section { + name: string + entries: Entry[] +} + +export interface IniDocument { + /** Lines before the first section header. */ + preamble: string[] + sections: Section[] +} + +const SECTION_PATTERN = /^\s*\[([^\]]*)\]\s*$/ +const KV_PATTERN = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(.*?)\s*$/ + +export function parseIni(text: string): IniDocument { + const doc: IniDocument = { preamble: [], sections: [] } + let current: Section | null = null + + for (const line of text.split('\n')) { + const sectionMatch = SECTION_PATTERN.exec(line) + if (sectionMatch) { + current = { name: sectionMatch[1].trim(), entries: [] } + doc.sections.push(current) + continue + } + + if (!current) { + doc.preamble.push(line) + continue + } + + const kvMatch = KV_PATTERN.exec(line) + // A `#`/`;` comment can contain `=`, so the comment check must come first. + if (kvMatch && !/^\s*[#;]/.test(line)) { + current.entries.push({ kind: 'kv', key: kvMatch[1], value: kvMatch[2] }) + } else { + current.entries.push({ kind: 'raw', text: line }) + } + } + + return doc +} + +export function serializeIni(doc: IniDocument): string { + const lines: string[] = [...doc.preamble] + + for (const section of doc.sections) { + // Keep exactly one blank line between sections without accumulating them + // across repeated writes. + while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop() + if (lines.length > 0) lines.push('') + lines.push(`[${section.name}]`) + for (const entry of section.entries) { + lines.push(entry.kind === 'kv' ? `${entry.key} = ${entry.value}` : entry.text) + } + } + + while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop() + return lines.length > 0 ? `${lines.join('\n')}\n` : '' +} + +export function getSection(doc: IniDocument, name: string): Record | null { + const section = doc.sections.find((s) => s.name === name) + if (!section) return null + + const values: Record = {} + for (const entry of section.entries) { + if (entry.kind === 'kv') values[entry.key] = entry.value + } + return values +} + +export function listSections(doc: IniDocument): string[] { + return doc.sections.map((s) => s.name) +} + +/** + * Upserts values into a section, creating it when absent. A `null` value removes + * the key. Existing keys are updated where they sit so surrounding comments keep + * describing the line they were written above. + */ +export function setSectionValues( + doc: IniDocument, + name: string, + values: Record +): void { + let section = doc.sections.find((s) => s.name === name) + if (!section) { + section = { name, entries: [] } + doc.sections.push(section) + } + + for (const [key, value] of Object.entries(values)) { + const index = section.entries.findIndex((e) => e.kind === 'kv' && e.key === key) + + if (value === null) { + if (index !== -1) section.entries.splice(index, 1) + continue + } + + if (index === -1) { + section.entries.push({ kind: 'kv', key, value }) + } else { + section.entries[index] = { kind: 'kv', key, value } + } + } +} + +export function removeSection(doc: IniDocument, name: string): boolean { + const index = doc.sections.findIndex((s) => s.name === name) + if (index === -1) return false + doc.sections.splice(index, 1) + return true +} diff --git a/packages/sim-cli/src/config/paths.ts b/packages/sim-cli/src/config/paths.ts new file mode 100644 index 00000000000..158a356d57c --- /dev/null +++ b/packages/sim-cli/src/config/paths.ts @@ -0,0 +1,21 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' + +/** + * Where the CLI keeps its state. `SIM_CONFIG_DIR` overrides the location + * wholesale, which is what lets tests and CI point at a scratch directory + * instead of the invoking user's real credentials. + */ +export function configDir(): string { + return process.env.SIM_CONFIG_DIR || join(homedir(), '.sim') +} + +/** Non-secret per-profile settings. Safe to commit to a dotfiles repo. */ +export function configPath(): string { + return process.env.SIM_CONFIG_FILE || join(configDir(), 'config') +} + +/** API keys, written 0600. Kept apart from `config` so the two can be handled differently. */ +export function credentialsPath(): string { + return process.env.SIM_CREDENTIALS_FILE || join(configDir(), 'credentials') +} diff --git a/packages/sim-cli/src/config/profile.test.ts b/packages/sim-cli/src/config/profile.test.ts new file mode 100644 index 00000000000..141166945be --- /dev/null +++ b/packages/sim-cli/src/config/profile.test.ts @@ -0,0 +1,137 @@ +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { configPath, credentialsPath } from './paths.js' +import { + deleteProfile, + listProfiles, + resolveProfile, + writeConfigProfile, + writeCredentialsProfile, +} from './profile.js' + +let dir: string +const ENV_KEYS = ['SIM_PROFILE', 'SIM_ENDPOINT', 'SIM_API_KEY', 'SIM_WORKSPACE', 'SIM_OUTPUT'] + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-cli-')) + process.env.SIM_CONFIG_DIR = dir + for (const key of ENV_KEYS) delete process.env[key] +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + process.env.SIM_CONFIG_DIR = undefined + for (const key of ENV_KEYS) delete process.env[key] +}) + +describe('profile resolution', () => { + it('falls back to built-in defaults with nothing configured', () => { + const profile = resolveProfile() + expect(profile.name).toBe('default') + expect(profile.endpoint).toBe('https://sim.ai') + expect(profile.apiKey).toBeNull() + expect(profile.output).toBe('table') + expect(profile.sources.apiKey).toBe('unset') + }) + + it('reads settings and credentials for the default profile', () => { + writeConfigProfile('default', { endpoint: 'https://a.example', workspace: 'ws_1' }) + writeCredentialsProfile('default', 'sim_key') + + const profile = resolveProfile() + expect(profile.endpoint).toBe('https://a.example') + expect(profile.workspaceId).toBe('ws_1') + expect(profile.apiKey).toBe('sim_key') + expect(profile.sources).toMatchObject({ endpoint: 'config', apiKey: 'credentials' }) + }) + + it('namespaces a non-default profile as [profile x] in config but [x] in credentials', () => { + writeConfigProfile('dev', { endpoint: 'http://localhost:3000' }) + writeCredentialsProfile('dev', 'sim_dev') + + expect(readFileSync(configPath(), 'utf8')).toContain('[profile dev]') + expect(readFileSync(credentialsPath(), 'utf8')).toContain('[dev]') + expect(readFileSync(credentialsPath(), 'utf8')).not.toContain('[profile dev]') + }) + + it('keeps profiles isolated from one another', () => { + writeConfigProfile('default', { endpoint: 'https://a.example', workspace: 'ws_a' }) + writeCredentialsProfile('default', 'key_a') + writeConfigProfile('dev', { endpoint: 'http://localhost:3000', workspace: 'ws_b' }) + writeCredentialsProfile('dev', 'key_b') + + expect(resolveProfile()).toMatchObject({ workspaceId: 'ws_a', apiKey: 'key_a' }) + expect(resolveProfile({ profile: 'dev' })).toMatchObject({ + workspaceId: 'ws_b', + apiKey: 'key_b', + }) + }) + + it('lets a flag beat the environment, and the environment beat the file', () => { + writeConfigProfile('default', { endpoint: 'https://file.example' }) + + expect(resolveProfile().endpoint).toBe('https://file.example') + + process.env.SIM_ENDPOINT = 'https://env.example' + expect(resolveProfile()).toMatchObject({ endpoint: 'https://env.example' }) + expect(resolveProfile().sources.endpoint).toBe('env') + + expect(resolveProfile({ endpoint: 'https://flag.example' })).toMatchObject({ + endpoint: 'https://flag.example', + }) + expect(resolveProfile({ endpoint: 'https://flag.example' }).sources.endpoint).toBe('flag') + }) + + it('selects the profile from SIM_PROFILE when no flag is given', () => { + writeCredentialsProfile('dev', 'key_dev') + process.env.SIM_PROFILE = 'dev' + expect(resolveProfile()).toMatchObject({ name: 'dev', apiKey: 'key_dev' }) + expect(resolveProfile({ profile: 'default' }).name).toBe('default') + }) + + it('strips a trailing slash so paths do not double up', () => { + expect(resolveProfile({ endpoint: 'https://sim.ai///' }).endpoint).toBe('https://sim.ai') + }) + + it('ignores an unrecognized output format instead of failing the whole resolve', () => { + process.env.SIM_OUTPUT = 'yaml' + expect(resolveProfile().output).toBe('table') + }) + + it('writes credentials 0600 even when the file already existed world-readable', () => { + writeFileSync(credentialsPath(), '', { mode: 0o644 }) + writeCredentialsProfile('default', 'sim_key') + expect(statSync(credentialsPath()).mode & 0o777).toBe(0o600) + }) + + it('lists profiles from both files without duplicating', () => { + writeConfigProfile('default', { endpoint: 'https://a.example' }) + writeConfigProfile('dev', { endpoint: 'http://localhost:3000' }) + writeCredentialsProfile('dev', 'key') + writeCredentialsProfile('ci', 'key') + + expect(listProfiles()).toEqual(['ci', 'default', 'dev']) + }) + + it('deletes a profile from both files', () => { + writeConfigProfile('dev', { endpoint: 'http://localhost:3000' }) + writeCredentialsProfile('dev', 'key') + + expect(deleteProfile('dev')).toEqual({ config: true, credentials: true }) + expect(listProfiles()).toEqual([]) + expect(deleteProfile('dev')).toEqual({ config: false, credentials: false }) + }) + + it('clears just the key when the credential is removed', () => { + writeConfigProfile('dev', { endpoint: 'http://localhost:3000' }) + writeCredentialsProfile('dev', 'key') + writeCredentialsProfile('dev', null) + + expect(resolveProfile({ profile: 'dev' })).toMatchObject({ + apiKey: null, + endpoint: 'http://localhost:3000', + }) + }) +}) diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts new file mode 100644 index 00000000000..943d414c7c7 --- /dev/null +++ b/packages/sim-cli/src/config/profile.ts @@ -0,0 +1,204 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { + getSection, + type IniDocument, + listSections, + parseIni, + removeSection, + serializeIni, + setSectionValues, +} from './ini.js' +import { configPath, credentialsPath } from './paths.js' + +export const DEFAULT_PROFILE = 'default' +export const DEFAULT_ENDPOINT = 'https://sim.ai' +export const OUTPUT_FORMATS = ['table', 'json'] as const +export type OutputFormat = (typeof OUTPUT_FORMATS)[number] + +/** Everything a command needs to make a call, after the resolution chain runs. */ +export interface ResolvedProfile { + name: string + endpoint: string + apiKey: string | null + workspaceId: string | null + output: OutputFormat + /** Where each value came from, for `sim whoami` to explain surprising results. */ + sources: { + endpoint: SettingSource + apiKey: SettingSource + workspaceId: SettingSource + output: SettingSource + } +} + +export type SettingSource = 'flag' | 'env' | 'config' | 'credentials' | 'default' | 'unset' + +export interface ProfileOverrides { + profile?: string + endpoint?: string + apiKey?: string + workspaceId?: string + output?: string +} + +/** + * AWS's asymmetry, reproduced deliberately: the config file namespaces + * non-default profiles as `[profile dev]` while the credentials file uses a bare + * `[dev]`. It is a wart, but matching it means muscle memory and existing + * tooling carry over. + */ +function configSectionName(profile: string): string { + return profile === DEFAULT_PROFILE ? DEFAULT_PROFILE : `profile ${profile}` +} + +function readIni(path: string): IniDocument { + if (!existsSync(path)) return { preamble: [], sections: [] } + return parseIni(readFileSync(path, 'utf8')) +} + +function writeIni(path: string, doc: IniDocument, secret: boolean): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + writeFileSync(path, serializeIni(doc), { mode: secret ? 0o600 : 0o644 }) + // `writeFileSync`'s mode only applies when it creates the file, so an existing + // credentials file written before this ran (or created by a hand `touch`) + // keeps its old, possibly world-readable, permissions without this. + if (secret) chmodSync(path, 0o600) +} + +export function readConfigProfile(profile: string): Record { + return getSection(readIni(configPath()), configSectionName(profile)) ?? {} +} + +export function readCredentialsProfile(profile: string): Record { + return getSection(readIni(credentialsPath()), profile) ?? {} +} + +/** Every profile named by either file, deduplicated and sorted. */ +export function listProfiles(): string[] { + const names = new Set() + + for (const section of listSections(readIni(configPath()))) { + if (section === DEFAULT_PROFILE) names.add(DEFAULT_PROFILE) + else if (section.startsWith('profile ')) names.add(section.slice('profile '.length).trim()) + } + for (const section of listSections(readIni(credentialsPath()))) { + names.add(section) + } + + return [...names].sort() +} + +export function writeConfigProfile(profile: string, values: Record): void { + const doc = readIni(configPath()) + setSectionValues(doc, configSectionName(profile), values) + writeIni(configPath(), doc, false) +} + +export function writeCredentialsProfile(profile: string, apiKey: string | null): void { + const doc = readIni(credentialsPath()) + setSectionValues(doc, profile, { api_key: apiKey }) + writeIni(credentialsPath(), doc, true) +} + +/** Drops the profile from both files. Returns whether anything was removed. */ +export function deleteProfile(profile: string): { config: boolean; credentials: boolean } { + const configDoc = readIni(configPath()) + const config = removeSection(configDoc, configSectionName(profile)) + if (config) writeIni(configPath(), configDoc, false) + + const credentialsDoc = readIni(credentialsPath()) + const credentials = removeSection(credentialsDoc, profile) + if (credentials) writeIni(credentialsPath(), credentialsDoc, true) + + return { config, credentials } +} + +function normalizeEndpoint(endpoint: string): string { + // A trailing slash here produces `https://sim.ai//api/v2/...`, which some + // proxies 404 rather than normalize. + return endpoint.replace(/\/+$/, '') +} + +function parseOutput(value: string | undefined): OutputFormat | null { + return value && (OUTPUT_FORMATS as readonly string[]).includes(value) + ? (value as OutputFormat) + : null +} + +/** + * Resolves one setting through the precedence chain, reporting where it landed. + * Order is flags → environment → files → built-in default, the same order every + * profile-based CLI uses: the more specific and more ephemeral the source, the + * higher it wins. + */ +function resolve( + candidates: Array<[SettingSource, T | null | undefined]>, + fallback: T | null, + fallbackSource: SettingSource +): { value: T | null; source: SettingSource } { + for (const [source, value] of candidates) { + if (value !== null && value !== undefined && value !== '') return { value, source } + } + return { value: fallback, source: fallbackSource } +} + +export function resolveProfile(overrides: ProfileOverrides = {}): ResolvedProfile { + const name = overrides.profile || process.env.SIM_PROFILE || DEFAULT_PROFILE + const config = readConfigProfile(name) + const credentials = readCredentialsProfile(name) + + const endpoint = resolve( + [ + ['flag', overrides.endpoint], + ['env', process.env.SIM_ENDPOINT], + ['config', config.endpoint], + ], + DEFAULT_ENDPOINT, + 'default' + ) + + const apiKey = resolve( + [ + ['flag', overrides.apiKey], + ['env', process.env.SIM_API_KEY], + ['credentials', credentials.api_key], + ], + null, + 'unset' + ) + + const workspaceId = resolve( + [ + ['flag', overrides.workspaceId], + ['env', process.env.SIM_WORKSPACE], + ['config', config.workspace], + ], + null, + 'unset' + ) + + const output = resolve( + [ + ['flag', parseOutput(overrides.output)], + ['env', parseOutput(process.env.SIM_OUTPUT)], + ['config', parseOutput(config.output)], + ], + 'table', + 'default' + ) + + return { + name, + endpoint: normalizeEndpoint(endpoint.value as string), + apiKey: apiKey.value, + workspaceId: workspaceId.value, + output: output.value as OutputFormat, + sources: { + endpoint: endpoint.source, + apiKey: apiKey.source, + workspaceId: workspaceId.source, + output: output.source, + }, + } +} diff --git a/packages/sim-cli/src/context.ts b/packages/sim-cli/src/context.ts new file mode 100644 index 00000000000..9e706baa404 --- /dev/null +++ b/packages/sim-cli/src/context.ts @@ -0,0 +1,36 @@ +import type { Command } from 'commander' +import { type ProfileOverrides, type ResolvedProfile, resolveProfile } from './config/index.js' +import { SimClient } from './http/client.js' + +/** Global flags, shared by every subcommand. */ +export interface GlobalOptions { + profile?: string + endpoint?: string + workspace?: string + output?: string +} + +/** + * Commander stores globals on the root command, not on the leaf that ran, so a + * subcommand handler has to walk up to find them. `optsWithGlobals()` does that + * walk; reading `command.opts()` alone silently drops `--profile`. + */ +export function globalsOf(command: Command): GlobalOptions { + return command.optsWithGlobals() as GlobalOptions +} + +export function profileFrom(command: Command, extra: ProfileOverrides = {}): ResolvedProfile { + const globals = globalsOf(command) + return resolveProfile({ + profile: globals.profile, + endpoint: globals.endpoint, + workspaceId: globals.workspace, + output: globals.output, + ...extra, + }) +} + +export function clientFrom(command: Command): { client: SimClient; profile: ResolvedProfile } { + const profile = profileFrom(command) + return { client: new SimClient(profile), profile } +} diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts new file mode 100644 index 00000000000..72afaca74e6 --- /dev/null +++ b/packages/sim-cli/src/http/client.ts @@ -0,0 +1,194 @@ +import type { ResolvedProfile } from '../config/index.js' + +/** + * A failure the CLI can explain. Anything thrown as a `SimApiError` is printed + * as a clean message and a non-zero exit; anything else escapes as a stack + * trace, which is the signal that the CLI itself is broken rather than the + * request. + */ +export class SimApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: string | null = null, + readonly details?: unknown + ) { + super(message) + this.name = 'SimApiError' + } +} + +/** `{ data }` — a single resource. */ +interface V2DataEnvelope { + data: T +} + +/** `{ data, nextCursor }` — one page of a list. */ +export interface V2Page { + data: T[] + nextCursor: string | null +} + +export type QueryValue = string | number | boolean | null | undefined + +export interface RequestOptions { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + query?: Record + body?: unknown +} + +function buildUrl(endpoint: string, path: string, query?: Record): string { + const url = new URL(`${endpoint}${path}`) + for (const [key, value] of Object.entries(query ?? {})) { + if (value === null || value === undefined || value === '') continue + url.searchParams.set(key, String(value)) + } + return url.toString() +} + +/** + * Pulls a human-readable message out of whatever the server returned. + * + * v2 answers with `{ error: { code, message } }`, but a request can also be + * turned away before it reaches a v2 route — by the v1 auth middleware + * (`{ error }`), or by a proxy that returns HTML. Each of those still has to + * produce a sentence rather than `[object Object]`. + */ +function toApiError(status: number, raw: string): SimApiError { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + const text = raw.trim() + return new SimApiError( + text ? truncate(text, 300) : `Request failed with status ${status}`, + status + ) + } + + const body = parsed as { error?: unknown; message?: unknown } + + if (body.error && typeof body.error === 'object') { + const error = body.error as { code?: unknown; message?: unknown; details?: unknown } + return new SimApiError( + typeof error.message === 'string' ? error.message : `Request failed with status ${status}`, + status, + typeof error.code === 'string' ? error.code : null, + error.details + ) + } + + if (typeof body.error === 'string') return new SimApiError(body.error, status) + if (typeof body.message === 'string') return new SimApiError(body.message, status) + + return new SimApiError(`Request failed with status ${status}`, status) +} + +function truncate(value: string, max: number): string { + return value.length <= max ? value : `${value.slice(0, max)}…` +} + +export class SimClient { + constructor(private readonly profile: ResolvedProfile) {} + + private requireAuth(): string { + if (!this.profile.apiKey) { + throw new SimApiError( + `Not logged in on profile "${this.profile.name}". Run: sim login --profile ${this.profile.name}`, + 0 + ) + } + return this.profile.apiKey + } + + /** + * The workspace every workspace-scoped command defaults to. + * + * Checks the key first even though it does not need one: commands resolve the + * workspace while building their query, so without this a brand-new install + * is told to set a workspace when the actual first step is logging in. + */ + requireWorkspace(explicit?: string): string { + this.requireAuth() + const workspaceId = explicit ?? this.profile.workspaceId + if (!workspaceId) { + throw new SimApiError( + `No workspace set for profile "${this.profile.name}". Pass --workspace, or run: sim configure --profile ${this.profile.name} --set-workspace `, + 0 + ) + } + return workspaceId + } + + async request(path: string, options: RequestOptions = {}): Promise { + const apiKey = this.requireAuth() + + const url = buildUrl(this.profile.endpoint, path, options.query) + const hasBody = options.body !== undefined + + let response: Response + try { + response = await fetch(url, { + method: options.method ?? 'GET', + headers: { + 'x-api-key': apiKey, + accept: 'application/json', + ...(hasBody ? { 'content-type': 'application/json' } : {}), + }, + body: hasBody ? JSON.stringify(options.body) : undefined, + }) + } catch (cause) { + throw new SimApiError( + `Could not reach ${this.profile.endpoint}: ${(cause as Error).message}`, + 0 + ) + } + + const raw = await response.text() + + if (!response.ok) { + const error = toApiError(response.status, raw) + if (response.status === 401) { + error.message = `${error.message} — run: sim login --profile ${this.profile.name}` + } + throw error + } + + if (!raw) return undefined as T + return JSON.parse(raw) as T + } + + /** Unwraps `{ data }`. */ + async getData(path: string, options: RequestOptions = {}): Promise { + const body = await this.request>(path, options) + return body.data + } + + /** One page of `{ data, nextCursor }`. */ + async getPage(path: string, options: RequestOptions = {}): Promise> { + return this.request>(path, options) + } + + /** + * Walks a cursor list until it is exhausted or `max` items are collected. + * + * `max` is required rather than optional: an unbounded auto-pager against a + * workspace with a million logs will happily fill memory and hammer the rate + * limiter, so the caller always states a ceiling. + */ + async collect(path: string, options: RequestOptions, max: number): Promise { + const items: T[] = [] + let cursor: string | null = null + + do { + const page: V2Page = await this.getPage(path, { + ...options, + query: { ...options.query, cursor }, + }) + items.push(...page.data) + cursor = page.nextCursor + } while (cursor && items.length < max) + + return items.slice(0, max) + } +} diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts new file mode 100644 index 00000000000..cfca5271cd2 --- /dev/null +++ b/packages/sim-cli/src/index.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +import chalk from 'chalk' +import { Command } from 'commander' +import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth.js' +import { configureCommand } from './commands/configure.js' +import { filesCommand } from './commands/files.js' +import { knowledgeCommand } from './commands/knowledge.js' +import { logsCommand } from './commands/logs.js' +import { workflowsCommand } from './commands/workflows.js' +import { OUTPUT_FORMATS } from './config/index.js' +import { SimApiError } from './http/client.js' + +const program = new Command() + +program + .name('sim') + .description('Talk to the Sim API from your terminal') + .version('0.1.0') + .option('-p, --profile ', 'Profile to use (env: SIM_PROFILE)') + .option('--endpoint ', 'Sim deployment to talk to (env: SIM_ENDPOINT)') + .option('-w, --workspace ', 'Workspace to target (env: SIM_WORKSPACE)') + .option('-o, --output ', `Output format: ${OUTPUT_FORMATS.join(' | ')} (env: SIM_OUTPUT)`) + +program.addCommand(loginCommand()) +program.addCommand(logoutCommand()) +program.addCommand(whoamiCommand()) +program.addCommand(profilesCommand()) +program.addCommand(configureCommand()) +program.addCommand(workflowsCommand()) +program.addCommand(logsCommand()) +program.addCommand(filesCommand()) +program.addCommand(knowledgeCommand()) + +program.addHelpText( + 'after', + ` +Profiles work like the AWS CLI: settings live in ~/.sim/config, keys in +~/.sim/credentials (0600). Select one with --profile or SIM_PROFILE. + +Examples: + $ sim login Authorize the default profile + $ sim login --profile dev --endpoint http://localhost:3000 + $ sim workflows list + $ sim logs list --level error --limit 20 + $ sim knowledge search "refund policy" --kb kb_123 + $ sim whoami --profile dev +` +) + +/** + * Anything the CLI can explain prints as one line and exits 1. An unexpected + * error keeps its stack trace — that is a bug in the CLI, and hiding it behind a + * friendly message would make it unreportable. + */ +async function main() { + try { + await program.parseAsync(process.argv) + } catch (error) { + if (error instanceof SimApiError) { + console.error(chalk.red(`Error: ${error.message}`)) + if (error.code) console.error(chalk.dim(` code: ${error.code}`)) + process.exit(1) + } + throw error + } +} + +main() diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts new file mode 100644 index 00000000000..c092febc001 --- /dev/null +++ b/packages/sim-cli/src/output/render.test.ts @@ -0,0 +1,132 @@ +import chalk, { Chalk } from 'chalk' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + bytes, + type Column, + duration, + printList, + printRecord, + text, + visibleWidth, +} from './render.js' + +/** Colour is stripped when not writing to a TTY, so force it on for these assertions. */ +const coloured = new Chalk({ level: 1 }) + +let logged: string[] + +beforeEach(() => { + logged = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => { + logged.push(line) + }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +interface Row { + name: string + status: string +} + +const COLUMNS: Column[] = [ + { header: 'name', value: (row) => row.name }, + { header: 'status', value: (row) => row.status }, +] + +describe('visibleWidth', () => { + it('ignores ANSI colour codes', () => { + expect(visibleWidth(coloured.red('error'))).toBe(5) + expect(visibleWidth(coloured.dim(coloured.green('ok')))).toBe(2) + }) + + it('counts plain text as-is', () => { + expect(visibleWidth('error')).toBe(5) + }) + + it('sees a wrapped string as wider than nothing but no wider than its text', () => { + // The regression this guards: a pattern that misses the ESC byte leaves it + // in the string and inflates the width, drifting every coloured column. + expect(visibleWidth(coloured.red('x'))).toBe(1) + }) +}) + +describe('printList', () => { + it('starts the second column at the same visible offset on every line', () => { + printList( + 'table', + [ + { name: 'alpha', status: coloured.red('error') }, + { name: 'b', status: coloured.green('ok') }, + ], + COLUMNS + ) + + const lines = logged[0].split('\n') + expect(lines).toHaveLength(3) // header + two rows + + // Where the status column begins, measured in visible characters: strip the + // colour, then drop the first word and the padding after it. If padding had + // counted ANSI bytes, the coloured rows would disagree with the header. + const statusOffsets = lines.map((line) => { + const plain = line.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '') + return plain.length - plain.replace(/^\S+\s+/, '').length + }) + + expect(statusOffsets).toEqual([7, 7, 7]) // 'alpha' (5) + 2-space separator + }) + + it('says so instead of printing an empty table', () => { + printList('table', [], COLUMNS) + expect(logged[0]).toContain('No results.') + }) + + it('prints the raw rows for json, not the formatted cells', () => { + printList('json', [{ name: 'alpha', status: 'error' }], COLUMNS) + expect(JSON.parse(logged[0])).toEqual([{ name: 'alpha', status: 'error' }]) + }) +}) + +describe('printRecord', () => { + it('prints the raw object for json, ignoring the field list', () => { + printRecord('json', [['Name', 'alpha']], { name: 'alpha', hidden: 1 }) + expect(JSON.parse(logged[0])).toEqual({ name: 'alpha', hidden: 1 }) + }) + + it('prints one aligned line per field for table', () => { + printRecord( + 'table', + [ + ['ID', 'abc'], + ['Name', 'alpha'], + ], + {} + ) + expect(logged).toHaveLength(2) + expect(logged[0]).toContain('abc') + expect(logged[1]).toContain('alpha') + }) +}) + +describe('formatters', () => { + it('renders absent values as a dash rather than "null"', () => { + for (const value of [null, undefined, '']) { + expect(visibleWidth(text(value))).toBe(1) + expect(chalk.reset(text(value))).not.toContain('null') + } + }) + + it('scales bytes to a readable unit', () => { + expect(bytes(512)).toBe('512 B') + expect(bytes(2048)).toBe('2.0 KB') + expect(bytes(0)).toBe('0 B') + }) + + it('scales durations across the ms/s/m boundaries', () => { + expect(duration(999)).toBe('999ms') + expect(duration(1500)).toBe('1.5s') + expect(duration(90_000)).toBe('1m30s') + }) +}) diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts new file mode 100644 index 00000000000..821043bb85a --- /dev/null +++ b/packages/sim-cli/src/output/render.ts @@ -0,0 +1,123 @@ +import chalk from 'chalk' +import type { OutputFormat } from '../config/index.js' + +export interface Column { + header: string + value: (row: T) => string +} + +/** Cell text for values that have no useful rendering, kept visually quiet. */ +const EMPTY = chalk.dim('—') + +export function text(value: unknown): string { + if (value === null || value === undefined || value === '') return EMPTY + return String(value) +} + +/** ISO timestamps are the wire format everywhere; show them without the milliseconds. */ +export function timestamp(value: string | null | undefined): string { + if (!value) return EMPTY + const date = new Date(value) + if (Number.isNaN(date.getTime())) return String(value) + return date.toISOString().replace('T', ' ').slice(0, 19) +} + +export function bool(value: boolean | null | undefined): string { + if (value === null || value === undefined) return EMPTY + return value ? chalk.green('yes') : chalk.dim('no') +} + +export function bytes(value: number | null | undefined): string { + if (value === null || value === undefined) return EMPTY + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let size = value + let unit = 0 + while (size >= 1024 && unit < units.length - 1) { + size /= 1024 + unit += 1 + } + return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}` +} + +export function duration(ms: number | null | undefined): string { + if (ms === null || ms === undefined) return EMPTY + if (ms < 1000) return `${ms}ms` + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s` + return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s` +} + +/** + * Matches an ANSI SGR sequence (`ESC [ … m`). + * + * Built from a char code rather than written as a literal so the source carries + * no raw ESC byte — an invisible control character inside a regex literal is the + * kind of thing an editor, a formatter, or a patch tool silently eats, and the + * only symptom would be columns drifting by one space per coloured cell. + */ +const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +/** + * Visible width of a cell, ignoring ANSI colour codes. + * + * Padding on the raw string would count the escape sequences as characters and + * skew every coloured column, so widths are measured on the stripped text while + * the coloured text is what gets printed. + */ +export function visibleWidth(value: string): number { + return value.replace(ANSI_PATTERN, '').length +} + +function pad(value: string, width: number): string { + return value + ' '.repeat(Math.max(0, width - visibleWidth(value))) +} + +function renderTable(rows: T[], columns: Column[]): string { + if (rows.length === 0) return chalk.dim('No results.') + + const cells = rows.map((row) => columns.map((column) => column.value(row))) + const widths = columns.map((column, index) => + Math.max(visibleWidth(column.header), ...cells.map((line) => visibleWidth(line[index]))) + ) + + const header = columns + .map((column, index) => chalk.dim(pad(column.header.toUpperCase(), widths[index]))) + .join(' ') + .trimEnd() + + const body = cells.map((line) => + line + .map((cell, index) => pad(cell, widths[index])) + .join(' ') + .trimEnd() + ) + + return [header, ...body].join('\n') +} + +/** + * Prints a list in the profile's output format. + * + * The JSON branch prints the raw rows, not the table's formatted cells — piping + * to `jq` should yield the API's own field names and types, so `--output json` + * is a passthrough rather than a second rendering. + */ +export function printList(format: OutputFormat, rows: T[], columns: Column[]): void { + if (format === 'json') { + console.log(JSON.stringify(rows, null, 2)) + return + } + console.log(renderTable(rows, columns)) +} + +/** Prints a single record: JSON as-is, table format as aligned key/value lines. */ +export function printRecord(format: OutputFormat, fields: Array<[string, string]>, raw: unknown) { + if (format === 'json') { + console.log(JSON.stringify(raw, null, 2)) + return + } + + const width = Math.max(...fields.map(([label]) => label.length)) + for (const [label, value] of fields) { + console.log(`${chalk.dim(pad(`${label}:`, width + 1))} ${value}`) + } +} diff --git a/packages/sim-cli/tsconfig.json b/packages/sim-cli/tsconfig.json new file mode 100644 index 00000000000..69711cab009 --- /dev/null +++ b/packages/sim-cli/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@sim/tsconfig/library-build.json", + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/sim-cli/vitest.config.ts b/packages/sim-cli/vitest.config.ts new file mode 100644 index 00000000000..ceafc241202 --- /dev/null +++ b/packages/sim-cli/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}) diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 8df1ad2a511..26556d9b980 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 997, - zodRoutes: 997, + totalRoutes: 1013, + zodRoutes: 1013, nonZodRoutes: 0, } as const From 3a3ceb5ced2ad9ac1eba0e920ebeaff1b5592cf6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 11:52:34 -0700 Subject: [PATCH 006/159] feat(billing): dedicated v2 usage endpoints; keep internal usage routes session-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the earlier X-API-Key enablement on /api/users/me/usage-logs with a dedicated public surface, so the internal Billing-settings endpoints can evolve with the UI while external monitors get a stable versioned contract: - GET /api/v2/billing/usage — current-billing-period summary with bySourceCredits (the source breakdown external monitors need to watch e.g. Copilot consumption without estimating by subtraction), plus limitCredits and plan - GET /api/v2/billing/usage/logs — cursor-paged credit ledger in the v2 envelope - workspace-scoped keys are pinned to their own workspace's slice; personal keys read the account ledger The public wire is credits-only: usage-logs rows now carry a hasCost boolean instead of dollarCost (the Billing UI only needed the >0 signal), and the rateLimit block is removed from the usage-limits response and docs (deploy-modal tab relabeled accordingly). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- apps/docs/openapi-core.json | 416 ++++++------------ .../app/api/users/me/usage-limits/route.ts | 36 +- .../api/users/me/usage-logs/export/route.ts | 14 +- .../app/api/users/me/usage-logs/route.test.ts | 56 +-- apps/sim/app/api/users/me/usage-logs/route.ts | 22 +- .../sim/app/api/users/me/usage-logs/shared.ts | 31 -- apps/sim/app/api/v1/middleware.ts | 1 + .../api/v2/billing/usage/logs/route.test.ts | 131 ++++++ .../app/api/v2/billing/usage/logs/route.ts | 87 ++++ .../app/api/v2/billing/usage/route.test.ts | 140 ++++++ apps/sim/app/api/v2/billing/usage/route.ts | 87 ++++ apps/sim/app/api/v2/billing/utils.ts | 30 ++ .../credit-usage/credit-usage-view.tsx | 2 +- .../deploy-modal/components/api/api.tsx | 4 +- apps/sim/lib/api/contracts/user.ts | 12 +- apps/sim/lib/api/contracts/v2/billing.ts | 97 ++++ apps/sim/lib/billing/credits/conversion.ts | 16 +- scripts/check-api-validation-contracts.ts | 4 +- 18 files changed, 738 insertions(+), 448 deletions(-) create mode 100644 apps/sim/app/api/v2/billing/usage/logs/route.test.ts create mode 100644 apps/sim/app/api/v2/billing/usage/logs/route.ts create mode 100644 apps/sim/app/api/v2/billing/usage/route.test.ts create mode 100644 apps/sim/app/api/v2/billing/usage/route.ts create mode 100644 apps/sim/app/api/v2/billing/utils.ts create mode 100644 apps/sim/lib/api/contracts/v2/billing.ts diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index e5bfe75bf6d..1e7e62a7a84 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -973,7 +973,7 @@ "get": { "operationId": "getUsageLimits", "summary": "Get Usage Limits", - "description": "Retrieve your current rate limits, usage spending, and storage consumption for the billing period.", + "description": "Retrieve your current usage spending and storage consumption for the billing period.", "tags": ["Usage"], "x-codeSamples": [ { @@ -985,7 +985,7 @@ ], "responses": { "200": { - "description": "Current rate limits, usage, and storage information.", + "description": "Current usage and storage information.", "content": { "application/json": { "schema": { @@ -993,18 +993,6 @@ }, "example": { "success": true, - "rateLimit": { - "sync": { - "limit": 100, - "remaining": 95, - "reset": "2026-01-15T11:00:00Z" - }, - "async": { - "limit": 50, - "remaining": 48, - "reset": "2026-01-15T11:00:00Z" - } - }, "usage": { "currentPeriodCost": 12.5, "limit": 100, @@ -1026,11 +1014,11 @@ "parameters": [] } }, - "/api/users/me/usage-logs": { + "/api/v2/billing/usage": { "get": { - "operationId": "listUsageLogs", - "summary": "List Usage Logs", - "description": "The authenticated account's credit-consuming usage events with a per-source summary. Accepts a session or `X-API-Key` — the same key `GET /api/users/me/usage-limits` accepts; `summary.bySourceCredits` is the source breakdown of that endpoint's aggregate `currentPeriodCost`, suitable for monitoring e.g. Copilot consumption. Workspace-scoped keys read only their own workspace's slice of the ledger.", + "operationId": "getUsageSummary", + "summary": "Get Usage Summary", + "description": "Current-billing-period usage with the per-source credit breakdown (`workflow`, `copilot`, `knowledge-base`, …) — monitor one source's consumption directly instead of estimating it by subtraction. All values are credits (1,000 credits = $5); dollar costs are not part of this surface.", "tags": ["Usage"], "security": [ { @@ -1038,15 +1026,6 @@ } ], "parameters": [ - { - "name": "source", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Restrict to one usage source (e.g. `workflow`, `copilot`). Omit for all sources." - }, { "name": "workspaceId", "in": "query", @@ -1055,121 +1034,41 @@ "type": "string" }, "description": "Restrict to one workspace. A workspace-scoped API key is always pinned to its own workspace; passing a different id returns 403." - }, - { - "name": "period", - "in": "query", - "required": false, - "schema": { - "enum": ["1d", "7d", "30d", "custom", "all"], - "default": "30d" - }, - "description": "Relative window, `all`, or `custom` (requires `startDate`)." - }, - { - "name": "startDate", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Start of a `custom` window. Any `Date`-parseable string." - }, - { - "name": "endDate", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "End of a `custom` window; defaults to now." - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - } - }, - { - "name": "cursor", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Opaque cursor from the previous page." - }, - { - "name": "includeCredits", - "in": "query", - "required": false, - "schema": { - "type": "boolean", - "default": true - }, - "description": "Set `false` to skip per-row credit apportionment when only the summary is needed." } ], "responses": { "200": { - "description": "A page of usage events with the per-source credit summary.", + "description": "The current billing period's usage summary.", "content": { "application/json": { "schema": { "type": "object", - "required": ["success", "logs", "summary", "pagination"], + "required": ["data"], "properties": { - "success": { - "type": "boolean" - }, - "logs": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "createdAt", - "source", - "workflowName", - "creditCost", - "dollarCost" - ], - "properties": { - "id": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "source": { - "type": "string", - "description": "Usage source: `workflow`, `copilot`, and the other credit-consuming surfaces." - }, - "workflowName": { - "type": ["string", "null"], - "description": "Populated only when `source` is `workflow`." - }, - "creditCost": { - "type": "number", - "description": "Credit-denominated cost (1,000 credits = $5), apportioned so page rows sum exactly to the rounded page total." - }, - "dollarCost": { - "type": "number", - "description": "Raw dollar cost, so a 0 `creditCost` can be distinguished from a genuinely free event." - } - } - } - }, - "summary": { + "data": { "type": "object", - "required": ["totalCredits", "bySourceCredits"], + "required": [ + "period", + "totalCredits", + "bySourceCredits", + "limitCredits", + "plan" + ], "properties": { + "period": { + "type": "object", + "required": ["start", "end"], + "properties": { + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + } + } + }, "totalCredits": { "type": "number" }, @@ -1178,65 +1077,57 @@ "additionalProperties": { "type": "number" }, - "description": "Credits per usage source over the whole filter — the source-aware breakdown of `usage-limits`’ aggregate `currentPeriodCost`." - } - } - }, - "pagination": { - "type": "object", - "required": ["hasMore"], - "properties": { - "nextCursor": { - "type": "string" + "description": "Credits consumed per usage source over the billing period." + }, + "limitCredits": { + "type": "number" }, - "hasMore": { - "type": "boolean" + "plan": { + "type": "string" } } } } }, "example": { - "success": true, - "logs": [ - { - "id": "log_1", - "createdAt": "2026-07-29T18:04:11.000Z", - "source": "copilot", - "workflowName": null, - "creditCost": 12, - "dollarCost": 0.06 - } - ], - "summary": { + "data": { + "period": { + "start": "2026-07-01T00:00:00.000Z", + "end": "2026-08-01T00:00:00.000Z" + }, "totalCredits": 512, "bySourceCredits": { "workflow": 380, "copilot": 120, "knowledge-base": 12 - } - }, - "pagination": { - "hasMore": false + }, + "limitCredits": 20000, + "plan": "pro" } } } } }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" } } } }, - "/api/users/me/usage-logs/export": { + "/api/v2/billing/usage/logs": { "get": { - "operationId": "exportUsageLogs", - "summary": "Export Usage Logs (CSV)", - "description": "Every usage event matching the filter as a CSV download (`Date`, `Type`, `Credits`) — the unpaginated form of `GET /api/users/me/usage-logs`, same auth and workspace-key scoping.", + "operationId": "listUsageLogs", + "summary": "List Usage Logs", + "description": "Cursor-paged, credit-denominated ledger of the account's usage events. The per-source aggregate lives on `GET /api/v2/billing/usage`; this is the row-level detail. Page by passing `nextCursor` back as `cursor` and stop when it is null.", "tags": ["Usage"], "security": [ { @@ -1251,7 +1142,7 @@ "schema": { "type": "string" }, - "description": "Restrict to one usage source (e.g. `workflow`, `copilot`). Omit for all sources." + "description": "Restrict to one usage source (e.g. `workflow`, `copilot`)." }, { "name": "workspaceId", @@ -1289,24 +1180,96 @@ "type": "string" }, "description": "End of a `custom` window; defaults to now." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Opaque cursor from the previous page." } ], "responses": { "200": { - "description": "CSV attachment. `X-Export-Truncated: 1` signals the 50,000-row safety cap was hit.", + "description": "A page of usage events.", "content": { - "text/csv": { + "application/json": { "schema": { - "type": "string" + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "createdAt", "source", "workflowName", "creditCost"], + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string" + }, + "workflowName": { + "type": ["string", "null"], + "description": "Populated only when `source` is `workflow`." + }, + "creditCost": { + "type": "number", + "description": "Apportioned so page rows sum exactly to the rounded page total; can be 0 for a sub-credit event." + } + } + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null on the final page." + } + } + }, + "example": { + "data": [ + { + "id": "log_1", + "createdAt": "2026-07-29T18:04:11.000Z", + "source": "copilot", + "workflowName": null, + "creditCost": 12 + } + ], + "nextCursor": null } } } }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" } } } @@ -1967,79 +1930,6 @@ } } }, - "Limits": { - "type": "object", - "description": "Rate limit and usage information included in every API response.", - "properties": { - "workflowExecutionRateLimit": { - "type": "object", - "description": "Current rate limit status for workflow executions.", - "properties": { - "sync": { - "description": "Rate limit bucket for synchronous executions.", - "$ref": "#/components/schemas/RateLimitBucket" - }, - "async": { - "description": "Rate limit bucket for asynchronous executions.", - "$ref": "#/components/schemas/RateLimitBucket" - } - } - }, - "usage": { - "type": "object", - "description": "Current billing period usage and plan limits.", - "properties": { - "currentPeriodCost": { - "type": "number", - "description": "Total spend in the current billing period in USD.", - "example": 1.25 - }, - "limit": { - "type": "number", - "description": "Maximum allowed spend for the current billing period in USD.", - "example": 50 - }, - "plan": { - "type": "string", - "description": "Your current subscription plan (e.g., free, pro, team).", - "example": "pro" - }, - "isExceeded": { - "type": "boolean", - "description": "Whether the usage limit has been exceeded. Executions may be blocked when true.", - "example": false - } - } - } - } - }, - "RateLimitBucket": { - "type": "object", - "description": "Rate limit status for a specific execution type.", - "properties": { - "requestsPerMinute": { - "type": "integer", - "description": "Maximum number of requests allowed per minute.", - "example": 60 - }, - "maxBurst": { - "type": "integer", - "description": "Maximum number of concurrent requests allowed in a burst.", - "example": 10 - }, - "remaining": { - "type": "integer", - "description": "Number of requests remaining in the current rate limit window.", - "example": 59 - }, - "resetAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the rate limit window resets.", - "example": "2025-06-20T14:16:00Z" - } - } - }, "JobStatus": { "type": "object", "description": "Status of an asynchronous job.", @@ -2314,56 +2204,12 @@ }, "UsageLimits": { "type": "object", - "description": "Current rate limits, usage, and storage information for the authenticated user.", + "description": "Current usage and storage information for the authenticated user.", "properties": { "success": { "type": "boolean", "description": "Whether the request was successful." }, - "rateLimit": { - "type": "object", - "description": "Rate limit status for workflow executions.", - "properties": { - "sync": { - "description": "Rate limit bucket for synchronous executions.", - "allOf": [ - { - "$ref": "#/components/schemas/RateLimitBucket" - }, - { - "type": "object", - "properties": { - "isLimited": { - "type": "boolean", - "description": "Whether the rate limit has been reached." - } - } - } - ] - }, - "async": { - "description": "Rate limit bucket for asynchronous executions.", - "allOf": [ - { - "$ref": "#/components/schemas/RateLimitBucket" - }, - { - "type": "object", - "properties": { - "isLimited": { - "type": "boolean", - "description": "Whether the rate limit has been reached." - } - } - } - ] - }, - "authType": { - "type": "string", - "description": "The authentication type used (api or manual)." - } - } - }, "usage": { "type": "object", "description": "Current billing period usage.", diff --git a/apps/sim/app/api/users/me/usage-limits/route.ts b/apps/sim/app/api/users/me/usage-limits/route.ts index 8f2b18d024e..b5ef4610fb8 100644 --- a/apps/sim/app/api/users/me/usage-limits/route.ts +++ b/apps/sim/app/api/users/me/usage-limits/route.ts @@ -2,11 +2,10 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { usageLimitsRequestSchema } from '@/lib/api/contracts/usage-limits' -import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid' +import { checkHybridAuth } from '@/lib/auth/hybrid' import { checkServerSideUsageLimits } from '@/lib/billing' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { getUserStorageLimit, getUserStorageUsage } from '@/lib/billing/storage' -import { RateLimiter } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createErrorResponse } from '@/app/api/workflows/utils' @@ -23,22 +22,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const authenticatedUserId = auth.userId const userSubscription = await getHighestPrioritySubscription(authenticatedUserId) - const rateLimiter = new RateLimiter() - const triggerType = auth.authType === AuthType.API_KEY ? 'api' : 'manual' - const [syncStatus, asyncStatus] = await Promise.all([ - rateLimiter.getRateLimitStatusWithSubscription( - authenticatedUserId, - userSubscription, - triggerType, - false - ), - rateLimiter.getRateLimitStatusWithSubscription( - authenticatedUserId, - userSubscription, - triggerType, - true - ), - ]) const [usageCheck, storageUsage, storageLimit] = await Promise.all([ checkServerSideUsageLimits(authenticatedUserId), @@ -52,23 +35,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, - rateLimit: { - sync: { - isLimited: syncStatus.remaining === 0, - requestsPerMinute: syncStatus.requestsPerMinute, - maxBurst: syncStatus.maxBurst, - remaining: syncStatus.remaining, - resetAt: syncStatus.resetAt, - }, - async: { - isLimited: asyncStatus.remaining === 0, - requestsPerMinute: asyncStatus.requestsPerMinute, - maxBurst: asyncStatus.maxBurst, - remaining: asyncStatus.remaining, - resetAt: asyncStatus.resetAt, - }, - authType: triggerType, - }, usage: { currentPeriodCost, limit: usageCheck.limit, diff --git a/apps/sim/app/api/users/me/usage-logs/export/route.ts b/apps/sim/app/api/users/me/usage-logs/export/route.ts index 052ed96de6c..d32e6d59837 100644 --- a/apps/sim/app/api/users/me/usage-logs/export/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/export/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { exportUsageLogsContract } from '@/lib/api/contracts/user' import { parseRequest } from '@/lib/api/server' -import { checkHybridAuth } from '@/lib/auth/hybrid' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { getUsageCreditsByLogId, getUserUsageLogs, @@ -10,10 +10,7 @@ import { } from '@/lib/billing/core/usage-log' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { formatCsvValue, toCsvRow } from '@/lib/table/export-format' -import { - resolveDateRange, - resolveUsageLogsWorkspaceFilter, -} from '@/app/api/users/me/usage-logs/shared' +import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' import { USAGE_LOG_SOURCE_LABELS } from '@/app/api/users/me/usage-logs/source-labels' const logger = createLogger('UsageLogsExportAPI') @@ -36,7 +33,7 @@ const CSV_HEADER = toCsvRow(['Date', 'Type', 'Credits']) * (unlike, say, a workspace's full execution history). */ export const GET = withRouteHandler(async (request: NextRequest) => { - const auth = await checkHybridAuth(request, { requireWorkflowId: false }) + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -45,13 +42,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { source, workspaceId, period, startDate, endDate } = parsed.data.query - const workspaceFilter = resolveUsageLogsWorkspaceFilter(auth, workspaceId) - if (!workspaceFilter.ok) return workspaceFilter.response - const dateRange = resolveDateRange(period, startDate, endDate) const filter = { source: source as UsageLogSource | undefined, - workspaceId: workspaceFilter.workspaceId, + workspaceId, startDate: dateRange.startDate, endDate: dateRange.endDate, } diff --git a/apps/sim/app/api/users/me/usage-logs/route.test.ts b/apps/sim/app/api/users/me/usage-logs/route.test.ts index 256f73b9f48..32295c7f887 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.test.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { authMockFns, createMockRequest, hybridAuthMockFns } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { apportionCredits } from '@/lib/billing/credits/conversion' @@ -46,58 +46,6 @@ describe('GET /api/users/me/usage-logs', () => { expect(response.status).toBe(401) }) - it('accepts a personal API key and reads that user’s ledger unscoped', async () => { - hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ - success: true, - userId: 'key-owner', - authType: 'api_key', - apiKeyType: 'personal', - }) - - const response = await GET(createMockRequest('GET')) - - expect(response.status).toBe(200) - expect(mockGetUserUsageLogs).toHaveBeenCalledWith( - 'key-owner', - expect.objectContaining({ workspaceId: undefined }) - ) - }) - - it('pins a workspace API key to its own workspace’s slice of the ledger', async () => { - hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ - success: true, - userId: 'key-owner', - workspaceId: 'ws-1', - authType: 'api_key', - apiKeyType: 'workspace', - }) - - const response = await GET(createMockRequest('GET')) - - expect(response.status).toBe(200) - expect(mockGetUserUsageLogs).toHaveBeenCalledWith( - 'key-owner', - expect.objectContaining({ workspaceId: 'ws-1' }) - ) - }) - - it('rejects a workspace API key asking for a different workspace', async () => { - hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ - success: true, - userId: 'key-owner', - workspaceId: 'ws-1', - authType: 'api_key', - apiKeyType: 'workspace', - }) - - const response = await GET( - createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?workspaceId=ws-2') - ) - - expect(response.status).toBe(403) - expect(mockGetUserUsageLogs).not.toHaveBeenCalled() - }) - it('converts dollar costs to credits in the logs and summary', async () => { const response = await GET(createMockRequest('GET')) const body = await response.json() @@ -109,7 +57,7 @@ describe('GET /api/users/me/usage-logs', () => { source: 'workflow', workflowName: null, creditCost: 100, - dollarCost: 0.5, + hasCost: true, }, ]) expect(body.summary).toEqual({ diff --git a/apps/sim/app/api/users/me/usage-logs/route.ts b/apps/sim/app/api/users/me/usage-logs/route.ts index 56d86cba761..9abd381c48c 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getUsageLogsContract } from '@/lib/api/contracts/user' import { parseRequest } from '@/lib/api/server' -import { checkHybridAuth } from '@/lib/auth/hybrid' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { getUsageCreditsByLogId, getUserUsageLogs, @@ -10,24 +10,17 @@ import { } from '@/lib/billing/core/usage-log' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - resolveDateRange, - resolveUsageLogsWorkspaceFilter, -} from '@/app/api/users/me/usage-logs/shared' +import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' const logger = createLogger('UsageLogsAPI') /** * Lists the authenticated user's credit-consuming usage events (model, tool, * and fixed charges), converted to credits for display in Billing settings. - * - * Accepts session auth AND `X-API-Key` (matching `/api/users/me/usage-limits`, - * whose aggregate `currentPeriodCost` this endpoint's `summary` breaks down by - * source) so external monitors can watch e.g. Copilot consumption. Workspace - * keys are pinned to their own workspace's slice of the ledger. + * Session-only — the API-key-facing equivalent is `GET /api/v2/billing/usage/logs`. */ export const GET = withRouteHandler(async (request: NextRequest) => { - const auth = await checkHybridAuth(request, { requireWorkflowId: false }) + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -37,14 +30,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { source, workspaceId, period, startDate, endDate, limit, cursor, includeCredits } = parsed.data.query - const workspaceFilter = resolveUsageLogsWorkspaceFilter(auth, workspaceId) - if (!workspaceFilter.ok) return workspaceFilter.response - const dateRange = resolveDateRange(period, startDate, endDate) const filter = { source: source as UsageLogSource | undefined, - workspaceId: workspaceFilter.workspaceId, + workspaceId, startDate: dateRange.startDate, endDate: dateRange.endDate, } @@ -62,7 +52,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { source: log.source, workflowName: log.workflowName ?? null, creditCost: creditsByLogId[log.id] ?? 0, - dollarCost: log.cost, + hasCost: log.cost > 0, })) const bySourceCredits = Object.fromEntries( diff --git a/apps/sim/app/api/users/me/usage-logs/shared.ts b/apps/sim/app/api/users/me/usage-logs/shared.ts index e751ec52a87..1c143248edc 100644 --- a/apps/sim/app/api/users/me/usage-logs/shared.ts +++ b/apps/sim/app/api/users/me/usage-logs/shared.ts @@ -1,38 +1,7 @@ -import { NextResponse } from 'next/server' import type { UsageLogPeriod } from '@/lib/api/contracts/user' -import type { AuthResult } from '@/lib/auth/hybrid' const PERIOD_TO_DAYS: Record<'1d' | '7d' | '30d', number> = { '1d': 1, '7d': 7, '30d': 30 } -type WorkspaceFilterResult = - | { ok: true; workspaceId: string | undefined } - | { ok: false; response: NextResponse } - -/** - * Resolves the effective `workspaceId` ledger filter for the caller's - * credential. Sessions, internal JWTs, and personal API keys read the - * authenticated user's full ledger with whatever filter they asked for; a - * workspace-scoped API key is pinned to its own workspace — the filter - * defaults to the key's workspace and an explicit mismatch is rejected rather - * than silently ignored. - */ -export function resolveUsageLogsWorkspaceFilter( - auth: AuthResult, - requestedWorkspaceId: string | undefined -): WorkspaceFilterResult { - if (auth.apiKeyType !== 'workspace') return { ok: true, workspaceId: requestedWorkspaceId } - if (requestedWorkspaceId && requestedWorkspaceId !== auth.workspaceId) { - return { - ok: false, - response: NextResponse.json( - { error: 'API key is not authorized for this workspace' }, - { status: 403 } - ), - } - } - return { ok: true, workspaceId: auth.workspaceId } -} - interface ResolvedDateRange { startDate: Date | undefined endDate: Date diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 385ad2b364a..1eb3d86acfe 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -44,6 +44,7 @@ export type ApiEndpoint = | 'knowledge-detail' | 'knowledge-search' | 'copilot-chat' + | 'billing-usage' export interface RateLimitResult { allowed: boolean diff --git a/apps/sim/app/api/v2/billing/usage/logs/route.test.ts b/apps/sim/app/api/v2/billing/usage/logs/route.test.ts new file mode 100644 index 00000000000..88cde13aa69 --- /dev/null +++ b/apps/sim/app/api/v2/billing/usage/logs/route.test.ts @@ -0,0 +1,131 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apportionCredits } from '@/lib/billing/credits/conversion' + +const { mockCheckRateLimit, mockGetUserUsageLogs, mockGetUsageCreditsByLogId } = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockGetUserUsageLogs: vi.fn(), + mockGetUsageCreditsByLogId: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, +})) + +vi.mock('@/lib/billing/core/usage-log', () => ({ + getUserUsageLogs: mockGetUserUsageLogs, + getUsageCreditsByLogId: mockGetUsageCreditsByLogId, +})) + +import { GET } from '@/app/api/v2/billing/usage/logs/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'personal', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callLogs(query = '') { + return GET(new NextRequest(`http://localhost:3000/api/v2/billing/usage/logs${query}`)) +} + +describe('GET /api/v2/billing/usage/logs', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockGetUserUsageLogs.mockResolvedValue({ + logs: [ + { + id: 'log-1', + createdAt: '2026-07-01T00:00:00.000Z', + category: 'model', + source: 'copilot', + description: 'claude-sonnet', + cost: 0.06, + }, + ], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: false }, + }) + mockGetUsageCreditsByLogId.mockResolvedValue( + apportionCredits([{ key: 'log-1', dollars: 0.06 }]) + ) + }) + + it('returns credit-denominated rows in the cursor envelope, no dollar costs', async () => { + const res = await callLogs() + expect(res.status).toBe(200) + const body = await res.json() + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'log-1', + createdAt: '2026-07-01T00:00:00.000Z', + source: 'copilot', + workflowName: null, + creditCost: 12, + }, + ]) + expect(JSON.stringify(body)).not.toContain('ollarCost') + }) + + it('forwards the cursor when more rows remain', async () => { + mockGetUserUsageLogs.mockResolvedValue({ + logs: [], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: true, nextCursor: 'log-42' }, + }) + mockGetUsageCreditsByLogId.mockResolvedValue({}) + const body = await (await callLogs()).json() + expect(body.nextCursor).toBe('log-42') + }) + + it('pins a workspace API key to its own workspace', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + keyType: 'workspace', + workspaceId: 'ws-1', + }) + const res = await callLogs() + expect(res.status).toBe(200) + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ workspaceId: 'ws-1' }) + ) + }) + + it('403s a workspace API key asking for a different workspace', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + keyType: 'workspace', + workspaceId: 'ws-1', + }) + const res = await callLogs('?workspaceId=ws-2') + expect(res.status).toBe(403) + expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + }) + + it('rejects "custom" period without a startDate', async () => { + const res = await callLogs('?period=custom') + expect(res.status).toBe(400) + expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) + const res = await callLogs() + expect(res.status).toBe(429) + }) +}) diff --git a/apps/sim/app/api/v2/billing/usage/logs/route.ts b/apps/sim/app/api/v2/billing/usage/logs/route.ts new file mode 100644 index 00000000000..531f87ea9ef --- /dev/null +++ b/apps/sim/app/api/v2/billing/usage/logs/route.ts @@ -0,0 +1,87 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2ListUsageLogsContract } from '@/lib/api/contracts/v2/billing' +import { parseRequest } from '@/lib/api/server' +import { + getUsageCreditsByLogId, + getUserUsageLogs, + type UsageLogSource, +} from '@/lib/billing/core/usage-log' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' +import { + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2BillingUsageLogsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/billing/usage/logs — Cursor-paged, credit-denominated ledger of + * the account's usage events. The per-source aggregate lives on + * `GET /api/v2/billing/usage`; this is the row-level detail. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'billing-usage') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListUsageLogsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { source, workspaceId, period, startDate, endDate, limit, cursor } = parsed.data.query + + const workspaceFilter = v2BillingWorkspaceFilter(rateLimit, workspaceId) + if (!workspaceFilter.ok) return workspaceFilter.response + + const dateRange = resolveDateRange(period, startDate, endDate) + const filter = { + source: source as UsageLogSource | undefined, + workspaceId: workspaceFilter.workspaceId, + startDate: dateRange.startDate, + endDate: dateRange.endDate, + } + + const [result, creditsByLogId] = await Promise.all([ + getUserUsageLogs(userId, { ...filter, limit, cursor, includeSummary: false }), + getUsageCreditsByLogId(userId, filter), + ]) + + const items = result.logs.map((log) => ({ + id: log.id, + createdAt: log.createdAt, + source: log.source, + workflowName: log.workflowName ?? null, + creditCost: creditsByLogId[log.id] ?? 0, + })) + + return v2CursorList( + items, + result.pagination.hasMore ? (result.pagination.nextCursor ?? null) : null, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error listing usage logs`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/billing/usage/route.test.ts b/apps/sim/app/api/v2/billing/usage/route.test.ts new file mode 100644 index 00000000000..da2bf7cb2f8 --- /dev/null +++ b/apps/sim/app/api/v2/billing/usage/route.test.ts @@ -0,0 +1,140 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockGetUserUsageLogs, + mockCheckServerSideUsageLimits, + mockGetHighestPrioritySubscription, + mockDeriveBillingContext, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockGetUserUsageLogs: vi.fn(), + mockCheckServerSideUsageLimits: vi.fn(), + mockGetHighestPrioritySubscription: vi.fn(), + mockDeriveBillingContext: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, +})) + +vi.mock('@/lib/billing', () => ({ + checkServerSideUsageLimits: mockCheckServerSideUsageLimits, +})) + +vi.mock('@/lib/billing/core/subscription', () => ({ + getHighestPrioritySubscription: mockGetHighestPrioritySubscription, +})) + +vi.mock('@/lib/billing/core/usage-log', () => ({ + deriveBillingContext: mockDeriveBillingContext, + getUserUsageLogs: mockGetUserUsageLogs, +})) + +import { GET } from '@/app/api/v2/billing/usage/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'personal', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callSummary(query = '') { + return GET(new NextRequest(`http://localhost:3000/api/v2/billing/usage${query}`)) +} + +describe('GET /api/v2/billing/usage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro' }) + mockDeriveBillingContext.mockReturnValue({ + billingEntity: { type: 'user', id: 'user-1' }, + billingPeriod: { + start: new Date('2026-07-01T00:00:00Z'), + end: new Date('2026-08-01T00:00:00Z'), + }, + }) + mockCheckServerSideUsageLimits.mockResolvedValue({ + isExceeded: false, + currentUsage: 2.5, + limit: 100, + }) + mockGetUserUsageLogs.mockResolvedValue({ + logs: [], + summary: { totalCost: 2.5, bySource: { workflow: 1.9, copilot: 0.6 } }, + pagination: { hasMore: false }, + }) + }) + + it('returns the billing-period summary with per-source credits, no dollars', async () => { + const res = await callSummary() + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toEqual({ + period: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, + totalCredits: 500, + bySourceCredits: { workflow: 380, copilot: 120 }, + limitCredits: 20000, + plan: 'pro', + }) + expect(JSON.stringify(body)).not.toContain('dollar') + }) + + it('queries the ledger summary over the derived billing period', async () => { + await callSummary() + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ + startDate: new Date('2026-07-01T00:00:00Z'), + endDate: new Date('2026-08-01T00:00:00Z'), + includeSummary: true, + }) + ) + }) + + it('pins a workspace API key to its own workspace', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + keyType: 'workspace', + workspaceId: 'ws-1', + }) + const res = await callSummary() + expect(res.status).toBe(200) + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ workspaceId: 'ws-1' }) + ) + }) + + it('403s a workspace API key asking for a different workspace', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + keyType: 'workspace', + workspaceId: 'ws-1', + }) + const res = await callSummary('?workspaceId=ws-2') + expect(res.status).toBe(403) + expect((await res.json()).error.code).toBe('FORBIDDEN') + expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) + const res = await callSummary() + expect(res.status).toBe(429) + }) +}) diff --git a/apps/sim/app/api/v2/billing/usage/route.ts b/apps/sim/app/api/v2/billing/usage/route.ts new file mode 100644 index 00000000000..1c17468ce73 --- /dev/null +++ b/apps/sim/app/api/v2/billing/usage/route.ts @@ -0,0 +1,87 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { type V2UsageSummaryData, v2GetUsageSummaryContract } from '@/lib/api/contracts/v2/billing' +import { parseRequest } from '@/lib/api/server' +import { checkServerSideUsageLimits } from '@/lib/billing' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { deriveBillingContext, getUserUsageLogs } from '@/lib/billing/core/usage-log' +import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2BillingUsageAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/billing/usage — Current-billing-period usage summary with the + * per-source credit breakdown, for external monitoring (e.g. alerting on + * Copilot consumption before an overage). Credits only — dollar costs and + * rate-limit internals are not part of this surface. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'billing-usage') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2GetUsageSummaryContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const workspaceFilter = v2BillingWorkspaceFilter(rateLimit, parsed.data.query.workspaceId) + if (!workspaceFilter.ok) return workspaceFilter.response + + const subscription = await getHighestPrioritySubscription(userId) + const { billingPeriod } = deriveBillingContext(userId, subscription) + + const [usageCheck, ledger] = await Promise.all([ + checkServerSideUsageLimits(userId, subscription), + getUserUsageLogs(userId, { + workspaceId: workspaceFilter.workspaceId, + startDate: billingPeriod.start, + endDate: billingPeriod.end, + limit: 1, + includeSummary: true, + }), + ]) + + const bySourceCredits = Object.fromEntries( + Object.entries(ledger.summary.bySource).map(([source, cost]) => [ + source, + dollarsToCredits(cost), + ]) + ) + + const data: V2UsageSummaryData = { + period: { + start: billingPeriod.start.toISOString(), + end: billingPeriod.end.toISOString(), + }, + totalCredits: dollarsToCredits(ledger.summary.totalCost), + bySourceCredits, + limitCredits: dollarsToCredits(usageCheck.limit), + plan: subscription?.plan || 'free', + } + + return v2Data(data, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error building usage summary`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/billing/utils.ts b/apps/sim/app/api/v2/billing/utils.ts new file mode 100644 index 00000000000..3e7eea9ca70 --- /dev/null +++ b/apps/sim/app/api/v2/billing/utils.ts @@ -0,0 +1,30 @@ +import type { NextResponse } from 'next/server' +import type { RateLimitResult } from '@/app/api/v1/middleware' +import { v2Error } from '@/app/api/v2/lib/response' + +type BillingWorkspaceFilter = + | { ok: true; workspaceId: string | undefined } + | { ok: false; response: NextResponse } + +/** + * Resolves the effective `workspaceId` ledger filter for the caller's key. + * Personal keys read the account's full ledger with whatever filter they asked + * for; a workspace-scoped key is pinned to its own workspace — the filter + * defaults to the key's workspace and an explicit mismatch is rejected rather + * than silently ignored. + */ +export function v2BillingWorkspaceFilter( + rateLimit: RateLimitResult, + requestedWorkspaceId: string | undefined +): BillingWorkspaceFilter { + if (rateLimit.keyType !== 'workspace') { + return { ok: true, workspaceId: requestedWorkspaceId } + } + if (requestedWorkspaceId && requestedWorkspaceId !== rateLimit.workspaceId) { + return { + ok: false, + response: v2Error('FORBIDDEN', 'API key is not authorized for this workspace'), + } + } + return { ok: true, workspaceId: rateLimit.workspaceId } +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx index 320e41f16ff..b3498e488e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx @@ -56,7 +56,7 @@ function UsageLogRow({ log }: UsageLogRowProps) { {rowLabel(log)} - {formatApportionedCreditCost(log.creditCost, log.dollarCost)} + {formatApportionedCreditCost(log.creditCost, log.hasCost)} ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx index c468acb2a3d..a5950e82f60 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx @@ -421,7 +421,7 @@ console.log(limits);` case 'status': return 'Check Status' case 'rate-limits': - return 'Rate Limits' + return 'Usage Limits' default: return 'Execute Job' } @@ -564,7 +564,7 @@ console.log(limits);` options={[ { label: 'Execute Job', value: 'execute' }, { label: 'Check Status', value: 'status' }, - { label: 'Rate Limits', value: 'rate-limits' }, + { label: 'Usage Limits', value: 'rate-limits' }, ]} value={asyncExampleType} onChange={(value) => setAsyncExampleType(value as AsyncExampleType)} diff --git a/apps/sim/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts index c799745bf1d..a2604b47589 100644 --- a/apps/sim/lib/api/contracts/user.ts +++ b/apps/sim/lib/api/contracts/user.ts @@ -341,12 +341,16 @@ export const usageLogEntrySchema = z.object({ * Credit-denominated cost of this event (Sim's usage unit; 1,000 credits = * $5), apportioned across the page so row credits always sum exactly to * the page's rounded total — this can legitimately be 0 for a row with a - * real but sub-credit `dollarCost` once a sibling row absorbs the shared - * rounding remainder. + * real but sub-credit charge once a sibling row absorbs the shared + * rounding remainder (see `hasCost`). */ creditCost: z.number(), - /** Raw dollar cost, so a 0 `creditCost` can be distinguished from a genuinely free event. */ - dollarCost: z.number(), + /** + * Whether the event carried any real charge — distinguishes a row whose + * `creditCost` apportioned to 0 from a genuinely free event, without putting + * raw dollar costs on the wire. + */ + hasCost: z.boolean(), }) export const usageLogsApiResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/v2/billing.ts b/apps/sim/lib/api/contracts/v2/billing.ts new file mode 100644 index 00000000000..6867e587db6 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/billing.ts @@ -0,0 +1,97 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { usageLogPeriodSchema, usageLogSourceSchema } from '@/lib/api/contracts/user' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 billing contracts — the read-only, API-key-facing usage surface. + * + * Deliberately separate from the session-only `/api/users/me/usage-logs` + * endpoints that back the Billing settings UI: the internal surface can evolve + * with the UI, while this one is the versioned public contract for external + * monitors. Everything is credit-denominated (Sim's usage unit; 1,000 credits + * = $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' }) + +export const v2UsageSummaryQuerySchema = z.object({ + /** + * Restrict the breakdown to one workspace. A workspace-scoped API key is + * always pinned to its own workspace; passing a different id returns 403. + */ + workspaceId: z.string().optional(), +}) + +/** + * Current-billing-period usage summary. `bySourceCredits` is the source-aware + * breakdown (workflow, copilot, knowledge-base, …) of the account's ledger for + * the period, so a monitor can watch one source's consumption directly instead + * of estimating it by subtraction. + */ +export const v2UsageSummaryDataSchema = z.object({ + period: z.object({ start: z.string(), end: z.string() }), + totalCredits: z.number(), + bySourceCredits: z.record(z.string(), z.number()), + limitCredits: z.number(), + plan: z.string(), +}) +export type V2UsageSummaryData = z.output + +export const v2GetUsageSummaryContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/billing/usage', + query: v2UsageSummaryQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UsageSummaryDataSchema), + }, +}) + +export const v2UsageLogsQuerySchema = z + .object({ + source: usageLogSourceSchema.optional(), + /** See {@link v2UsageSummaryQuerySchema}'s `workspaceId` — same pinning rules. */ + workspaceId: z.string().optional(), + period: usageLogPeriodSchema.optional().default('30d'), + /** Required when `period` is `'custom'`. */ + startDate: parseableDateSchema.optional(), + /** Defaults to now when omitted for `'custom'`. */ + endDate: parseableDateSchema.optional(), + limit: z.coerce.number().int().min(1).max(100).optional().default(50), + cursor: z.string().min(1, 'cursor must be a non-empty token').optional(), + }) + .refine((query) => query.period !== 'custom' || query.startDate !== undefined, { + error: 'startDate is required when period is "custom"', + path: ['startDate'], + }) + +/** + * One credit-consuming usage event. `creditCost` is apportioned across the + * page so row credits sum exactly to the page's rounded total; it can + * legitimately be 0 for a sub-credit event once a sibling row absorbs the + * shared rounding remainder. + */ +export const v2UsageLogEntrySchema = z.object({ + id: z.string(), + createdAt: z.string(), + source: usageLogSourceSchema, + /** Populated only when `source` is `'workflow'`. */ + workflowName: z.string().nullable(), + creditCost: z.number(), +}) +export type V2UsageLogEntry = z.output + +export const v2ListUsageLogsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/billing/usage/logs', + query: v2UsageLogsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2UsageLogEntrySchema), + }, +}) diff --git a/apps/sim/lib/billing/credits/conversion.ts b/apps/sim/lib/billing/credits/conversion.ts index 59a11264c85..cea29279136 100644 --- a/apps/sim/lib/billing/credits/conversion.ts +++ b/apps/sim/lib/billing/credits/conversion.ts @@ -67,16 +67,16 @@ export function formatCreditCost( /** * Renders an already-apportioned integer `creditCost` (see {@link apportionCredits}) - * alongside its raw `dollarCost`, so a row can legitimately apportion to 0 - * credits — once a sibling absorbs the shared rounding remainder — without - * reading as a flat, misleading "0 credits" for an event that had a real, - * positive charge. Mirrors {@link formatCreditCost}'s zero/sub-credit - * wording, but never recomputes credits from `dollarCost` (that would - * double-convert a value the caller already apportioned). + * alongside whether the row carried any real charge, so a row can legitimately + * apportion to 0 credits — once a sibling absorbs the shared rounding + * remainder — without reading as a flat, misleading "0 credits" for an event + * that had a real, positive charge. Mirrors {@link formatCreditCost}'s + * zero/sub-credit wording; `hasCost` is a boolean rather than the raw dollar + * cost because dollar amounts never go on the wire. */ -export function formatApportionedCreditCost(creditCost: number, dollarCost: number): string { +export function formatApportionedCreditCost(creditCost: number, hasCost: boolean): string { if (creditCost > 0) return formatCreditsLabel(creditCost) - return dollarCost > 0 ? '<1 credit' : '0 credits' + return hasCost ? '<1 credit' : '0 credits' } /** diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 0aad0ab56ca..8853257bcc2 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1020, - zodRoutes: 1020, + totalRoutes: 1022, + zodRoutes: 1022, nonZodRoutes: 0, } as const From b29d694adcb2c7e7b10a10bc108cb406bc245f00 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 12:48:17 -0700 Subject: [PATCH 007/159] feat(cli): generate the CLI's v2 API from the route contracts, add tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same endpoint was being described in three hand-maintained places: the Zod contracts the routes validate against, the OpenAPI documents, and the CLI's own TypeScript interfaces. Two of those are now derived. ## Generation `scripts/generate-v2-cli-api.ts` reads `apps/sim/lib/api/contracts/v2/**` and emits `packages/sim-cli/src/generated/v2-api.ts`: request/response types for all 44 operations plus an operation table (method, path, path params) the client dispatches through, so a route that moves or changes verb moves the CLI with it. The contracts are the right source because the routes validate against them — a shape that disagrees with a contract is a shape the server would reject. Zod 4's `z.toJSONSchema()` handles all 110 schema slots; the JSON-Schema-to-TS emitter is hand-rolled over that known-narrow subset and throws on anything unrecognized rather than degrading to `any`, since silence is how a generated client drifts. `packages/*` must not import `apps/*`, so the generated file is plain type declarations with no imports and the script does the crossing at build time. `check:cli-api` fails CI when the file is stale. The generated directory is excluded from biome: the pre-commit hook runs `check --write`, which would otherwise reformat generated output and fail that check with an unrelated message. ## OpenAPI: checked, not generated The docs specs carry ~1000 hand-written descriptions and ~400 examples that Zod schemas do not encode, so generating them would trade real documentation for mechanical accuracy. `check:openapi-drift` reconciles structure instead — every v2 path and method must exist on both sides — keeping the prose while still failing on divergence. Both currently agree on all 44 operations. ## Tables `sim tables list|get|columns|rows|insert|delete-rows`, built on the generated types. Rows go through the POST query endpoint even unfiltered, since it is the only shape carrying the predicate. Row columns are discovered at runtime and unioned across the page, so a sparse row cannot hide a column. Deletion requires an explicit `--row`/`--filter` selector *and* `--yes`; an argument-less call would otherwise empty the table. Path params are percent-encoded — an id containing `/` or `?` would otherwise retarget the request. The four existing command groups drop their hand-written interfaces for the generated ones. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- .github/workflows/test-build.yml | 12 + biome.json | 1 + package.json | 3 + packages/sim-cli/README.md | 52 +- packages/sim-cli/src/commands/files.ts | 11 +- packages/sim-cli/src/commands/knowledge.ts | 39 +- packages/sim-cli/src/commands/logs.ts | 35 +- packages/sim-cli/src/commands/tables.ts | 262 ++++ packages/sim-cli/src/commands/workflows.ts | 21 +- packages/sim-cli/src/generated/v2-api.ts | 1657 ++++++++++++++++++++ packages/sim-cli/src/http/client.test.ts | 91 ++ packages/sim-cli/src/http/client.ts | 43 + packages/sim-cli/src/index.ts | 2 + scripts/generate-v2-cli-api.ts | 338 ++++ 14 files changed, 2480 insertions(+), 87 deletions(-) create mode 100644 packages/sim-cli/src/commands/tables.ts create mode 100644 packages/sim-cli/src/generated/v2-api.ts create mode 100644 packages/sim-cli/src/http/client.test.ts create mode 100644 scripts/generate-v2-cli-api.ts diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 262386b6922..833f1fcc8c1 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -126,6 +126,18 @@ jobs: - name: Desktop bridge contract audit run: bun run check:desktop-bridge + # The CLI's view of the v2 API is generated from the same Zod contracts + # the routes validate against, so a contract change that skips + # `generate:cli-api` would ship a client describing endpoints the server + # no longer has. + - name: Sim CLI API generation up to date + run: bun run check:cli-api + + # Structure only — the OpenAPI documents keep their hand-written prose, + # but every v2 path/method must still exist on both sides. + - name: OpenAPI matches the v2 contracts + run: bun run check:openapi-drift + # Complements the bridge audit above, which compares against a snapshot # this same PR is allowed to regenerate. This one derives every fact from # the source both sides execute, so it has no such blind spot. diff --git a/biome.json b/biome.json index 9249402d969..31b2c99cacb 100644 --- a/biome.json +++ b/biome.json @@ -32,6 +32,7 @@ "!**/.venv", "!**/uploads", "!**/apps/sim/lib/execution/sandbox/bundles/*.cjs", + "!**/packages/sim-cli/src/generated", "!**/test-results", "!**/playwright-report" ] diff --git a/package.json b/package.json index fec0621c543..d2c3dcb148b 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,9 @@ "check:migrations": "bun run scripts/check-migrations-safety.ts", "check:desktop-bridge": "bun run scripts/check-desktop-bridge-contract.ts --check", "check:desktop-ipc": "bun run scripts/check-desktop-ipc-contract.ts", + "check:cli-api": "bun run scripts/generate-v2-cli-api.ts --check", + "check:openapi-drift": "bun run scripts/generate-v2-cli-api.ts --check-openapi", + "generate:cli-api": "bun run scripts/generate-v2-cli-api.ts", "desktop-bridge-contract:update": "bun run scripts/check-desktop-bridge-contract.ts --update", "mship-contracts:generate": "bun run scripts/sync-mothership-stream-contract.ts", "mship-contracts:check": "bun run scripts/sync-mothership-stream-contract.ts --check", diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 3eab36aa44b..25b0fa993d5 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -114,6 +114,13 @@ sim logs list [--level error] [--workflow …] [--trigger …] [--star sim logs get sim logs execution +sim tables list +sim tables get +sim tables columns +sim tables rows [--filter ] [--sort …] [--limit ] +sim tables insert --data +sim tables delete-rows (--row … | --filter ) --yes + sim files list sim files download [-o ] sim files delete @@ -124,6 +131,25 @@ sim knowledge documents [--search ] sim knowledge search --kb … ``` +### Filtering table rows + +`--filter` takes the same predicate tree the API uses — `all` (AND) or `any` +(OR) groups of `{field, op, value}` conditions, nestable. It's JSON because the +grammar is a tree; there's no honest flag encoding for it. + +```bash +sim tables rows tbl_123 \ + --filter '{"all":[{"field":"status","op":"eq","value":"open"}, + {"field":"score","op":"gt","value":10}]}' \ + --sort score:desc --limit 50 +``` + +Row columns are discovered at runtime from the returned data, unioned across the +page so a sparse row doesn't hide a column. + +Deletions require an explicit selector *and* `--yes`; there is no "delete +everything" default. + Every command takes `--output json` for scripting; the JSON is the API's own response shape, so it pipes cleanly into `jq`. @@ -131,11 +157,35 @@ response shape, so it pipes cleanly into `jq`. sim logs list --level error --output json | jq -r '.[].executionId' ``` +## How this stays in sync with the API + +`src/generated/v2-api.ts` is generated from the Zod route contracts in +`apps/sim/lib/api/contracts/v2/**` — the same contracts the routes validate +against, so a shape that disagrees with them is a shape the server would reject. +It holds every response/request type plus the operation table (method, path, +path params) the client dispatches through. + +```bash +bun run generate:cli-api # regenerate after changing a contract +bun run check:cli-api # CI: fails if the generated file is stale +bun run check:openapi-drift # CI: fails if the docs and contracts disagree +``` + +The generated file contains only type declarations and one const — no imports — +so the `packages/*` must not import `apps/*` boundary is preserved; the script +does the crossing at build time. + +The OpenAPI documents under `apps/docs` are deliberately **not** generated. They +carry ~1000 hand-written descriptions and ~400 examples that Zod schemas don't +encode, so regenerating them would trade real documentation for mechanical +accuracy. `check:openapi-drift` reconciles their *structure* against the +contracts instead — every v2 path and method must exist on both sides — so the +prose survives while drift still fails the build. + ## Notes - Commands talk to the `/api/v2` surface, which returns `{ data }` and `{ data, nextCursor }`. List commands auto-page up to `--limit`. -- `sim tables` is not here yet — the tables v2 surface is still changing. ## License diff --git a/packages/sim-cli/src/commands/files.ts b/packages/sim-cli/src/commands/files.ts index 3cf5c1df6c0..3433d8b89c2 100644 --- a/packages/sim-cli/src/commands/files.ts +++ b/packages/sim-cli/src/commands/files.ts @@ -4,18 +4,11 @@ import { basename } from 'node:path' import chalk from 'chalk' import { Command } from 'commander' import { clientFrom } from '../context.js' +import type { ListFilesResponse } from '../generated/v2-api.js' import { SimApiError } from '../http/client.js' import { bytes, type Column, printList, timestamp } from '../output/render.js' -interface WorkspaceFile { - id: string - name: string - size: number - type: string - key: string - uploadedBy: string - uploadedAt: string -} +type WorkspaceFile = ListFilesResponse['data'][number] /** * Streams a fetch body to disk, honouring backpressure. diff --git a/packages/sim-cli/src/commands/knowledge.ts b/packages/sim-cli/src/commands/knowledge.ts index 00a8a95ec43..130a7e02394 100644 --- a/packages/sim-cli/src/commands/knowledge.ts +++ b/packages/sim-cli/src/commands/knowledge.ts @@ -1,38 +1,15 @@ import { Command } from 'commander' import { clientFrom } from '../context.js' +import type { + ListKnowledgeBasesResponse, + ListKnowledgeDocumentsResponse, + SearchKnowledgeResponse, +} from '../generated/v2-api.js' import { bytes, type Column, printList, printRecord, text, timestamp } from '../output/render.js' -interface KnowledgeBase { - id: string - name: string - description: string | null - docCount: number - tokenCount: number - embeddingModel: string - createdAt: string | null - updatedAt: string | null -} - -interface KnowledgeDocument { - id: string - knowledgeBaseId: string - filename: string - fileSize: number - mimeType: string - processingStatus: string - chunkCount: number - tokenCount: number - enabled: boolean - createdAt: string | null -} - -interface SearchHit { - documentId: string - documentName: string | null - content: string - chunkIndex: number - similarity: number -} +type KnowledgeBase = ListKnowledgeBasesResponse['data'][number] +type KnowledgeDocument = ListKnowledgeDocumentsResponse['data'][number] +type SearchHit = SearchKnowledgeResponse['data']['results'][number] const BASE_COLUMNS: Column[] = [ { header: 'id', value: (kb) => kb.id }, diff --git a/packages/sim-cli/src/commands/logs.ts b/packages/sim-cli/src/commands/logs.ts index ac20e8e76d2..47525e925c5 100644 --- a/packages/sim-cli/src/commands/logs.ts +++ b/packages/sim-cli/src/commands/logs.ts @@ -1,25 +1,12 @@ import chalk from 'chalk' import { Command } from 'commander' import { clientFrom } from '../context.js' +import type { GetExecutionResponse, GetLogResponse, ListLogsResponse } from '../generated/v2-api.js' import { type Column, duration, printList, printRecord, text, timestamp } from '../output/render.js' -interface LogListItem { - id: string - workflowId: string | null - executionId: string - level: string - trigger: string - startedAt: string - endedAt: string | null - totalDurationMs: number | null - cost: { total: number } | null - workflow?: { id: string | null; name: string; deleted: boolean } -} - -interface LogDetail extends LogListItem { - executionData: unknown - createdAt: string -} +type LogListItem = ListLogsResponse['data'][number] +type LogDetail = GetLogResponse['data'] +type ExecutionDetail = GetExecutionResponse['data'] function level(value: string): string { return value === 'error' ? chalk.red(value) : value @@ -128,17 +115,9 @@ export function logsCommand(): Command { .description('Show the workflow state snapshot for an execution') .action(async (executionId: string, _options: unknown, command: Command) => { const { client, profile } = clientFrom(command) - const execution = await client.getData<{ - executionId: string - workflowId: string | null - executionMetadata: { - trigger: string - startedAt: string - endedAt: string | null - totalDurationMs: number | null - cost: { total: number } | null - } - }>(`/api/v2/logs/executions/${executionId}`) + const execution = await client.getData( + `/api/v2/logs/executions/${executionId}` + ) printRecord( profile.output, diff --git a/packages/sim-cli/src/commands/tables.ts b/packages/sim-cli/src/commands/tables.ts new file mode 100644 index 00000000000..9362d7ded17 --- /dev/null +++ b/packages/sim-cli/src/commands/tables.ts @@ -0,0 +1,262 @@ +import chalk from 'chalk' +import { Command } from 'commander' +import { clientFrom } from '../context.js' +import type { + CreateTableRowsResponse, + DeleteTableRowsResponse, + GetTableResponse, + ListTablesResponse, + QueryRowsResponse, +} from '../generated/v2-api.js' +import { SimApiError } from '../http/client.js' +import { type Column, printList, printRecord, text, timestamp } from '../output/render.js' + +type Table = ListTablesResponse['data'][number] +type TableColumn = Table['schema']['columns'][number] +type Row = QueryRowsResponse['data'][number] + +const TABLE_COLUMNS: Column[] = [ + { header: 'id', value: (t) => t.id }, + { header: 'name', value: (t) => t.name }, + { header: 'rows', value: (t) => `${t.rowCount}${t.maxRows ? ` / ${t.maxRows}` : ''}` }, + { header: 'columns', value: (t) => String(t.schema.columns.length) }, + { header: 'updated', value: (t) => timestamp(t.updatedAt) }, +] + +const COLUMN_COLUMNS: Column[] = [ + { header: 'name', value: (c) => c.name }, + { header: 'type', value: (c) => c.type }, + { header: 'required', value: (c) => (c.required ? 'yes' : '') }, + { header: 'unique', value: (c) => (c.unique ? 'yes' : '') }, + { header: 'options', value: (c) => (c.options ?? []).map((o) => o.name).join(', ') }, +] + +/** + * Parses a `--filter` / `--data` argument. + * + * The predicate grammar is a nested object (`{all|any: [{field, op, value}]}`), + * which has no honest flag encoding — so it is passed as JSON and the parse + * error names the flag rather than surfacing a bare `SyntaxError`. + */ +function parseJsonArg(value: string, flag: string): unknown { + try { + return JSON.parse(value) + } catch (error) { + throw new SimApiError(`${flag} must be valid JSON: ${(error as Error).message}`, 0) + } +} + +/** `name:desc` / `name` → the wire sort spec. */ +function parseSort(specs: string[]): Array<{ field: string; direction: 'asc' | 'desc' }> { + return specs.map((spec) => { + const [field, direction = 'asc'] = spec.split(':') + if (direction !== 'asc' && direction !== 'desc') { + throw new SimApiError(`Sort direction must be asc or desc, got "${direction}"`, 0) + } + if (!field) throw new SimApiError(`Invalid --sort value "${spec}"`, 0) + return { field, direction } + }) +} + +/** + * Row `data` is name-keyed and user-defined, so the columns are only known at + * runtime. Union the keys across the page rather than trusting the first row — + * a sparse row would otherwise hide every column it happens to omit. + */ +function rowColumns(rows: Row[]): Column[] { + const keys: string[] = [] + const seen = new Set() + for (const row of rows) { + for (const key of Object.keys(row.data)) { + if (!seen.has(key)) { + seen.add(key) + keys.push(key) + } + } + } + + return [ + { header: 'id', value: (row) => row.id }, + ...keys.map((key) => ({ + header: key, + value: (row: Row) => { + const value = row.data[key] + if (value === null || value === undefined) return text(null) + return typeof value === 'object' ? JSON.stringify(value) : String(value) + }, + })), + ] +} + +export function tablesCommand(): Command { + const tables = new Command('tables').alias('table').description('Browse and edit tables') + + tables + .command('list') + .alias('ls') + .description('List tables in a workspace') + .action(async (_options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const result = (await client.call('listTables', { + query: { workspaceId: client.requireWorkspace() }, + })) as ListTablesResponse + printList(profile.output, result.data, TABLE_COLUMNS) + }) + + tables + .command('get ') + .description('Show a table and its schema') + .action(async (tableId: string, _options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const result = (await client.call('getTable', { + pathParams: { tableId }, + query: { workspaceId: client.requireWorkspace() }, + })) as GetTableResponse + const { table } = result.data + + printRecord( + profile.output, + [ + ['ID', table.id], + ['Name', table.name], + ['Description', text(table.description)], + ['Rows', `${table.rowCount}${table.maxRows ? ` / ${table.maxRows}` : ''}`], + ['Columns', table.schema.columns.map((c) => `${c.name}:${c.type}`).join(', ')], + ['Updated', timestamp(table.updatedAt)], + ], + table + ) + }) + + tables + .command('columns ') + .description("Show a table's columns") + .action(async (tableId: string, _options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const result = (await client.call('getTable', { + pathParams: { tableId }, + query: { workspaceId: client.requireWorkspace() }, + })) as GetTableResponse + printList(profile.output, result.data.table.schema.columns, COLUMN_COLUMNS) + }) + + tables + .command('rows ') + .description('List rows, optionally filtered with the predicate grammar') + .option( + '--filter ', + 'Predicate tree, e.g. \'{"all":[{"field":"status","op":"eq","value":"open"}]}\'' + ) + .option('--sort ', 'Sort spec, e.g. --sort created_at:desc') + .option('--limit ', 'Maximum rows to return', '100') + .action( + async ( + tableId: string, + options: { filter?: string; sort?: string[]; limit: string }, + command: Command + ) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const limit = Number.parseInt(options.limit, 10) + + const rows: Row[] = [] + let cursor: string | null = null + + // Always the POST query endpoint, even unfiltered: it is the only shape + // that carries the predicate, so one path covers both cases instead of + // two that could format rows differently. + do { + const page = (await client.call('queryRows', { + pathParams: { tableId }, + body: { + workspaceId, + ...(options.filter ? { predicate: parseJsonArg(options.filter, '--filter') } : {}), + ...(options.sort ? { sort: parseSort(options.sort) } : {}), + limit: Math.min(limit, 1000), + ...(cursor ? { cursor } : {}), + }, + })) as QueryRowsResponse + rows.push(...page.data) + cursor = page.nextCursor + } while (cursor && rows.length < limit) + + printList(profile.output, rows.slice(0, limit), rowColumns(rows)) + } + ) + + tables + .command('insert ') + .description('Insert a row') + .requiredOption('--data ', 'Row data, e.g. \'{"name":"Ada","score":9}\'') + .action(async (tableId: string, options: { data: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const result = (await client.call('createTableRows', { + pathParams: { tableId }, + body: { + workspaceId: client.requireWorkspace(), + data: parseJsonArg(options.data, '--data'), + }, + })) as CreateTableRowsResponse + + if (profile.output === 'json') { + console.log(JSON.stringify(result, null, 2)) + return + } + const inserted = 'row' in result.data ? 1 : result.data.rows.length + console.log(chalk.green(`✓ Inserted ${inserted} row${inserted === 1 ? '' : 's'}`)) + }) + + tables + .command('delete-rows ') + .description('Delete rows by id or filter') + .option('--row ', 'Row ids to delete') + .option('--filter ', 'Predicate tree selecting the rows to delete') + .option('-y, --yes', 'Skip the confirmation') + .action( + async ( + tableId: string, + options: { row?: string[]; filter?: string; yes?: boolean }, + command: Command + ) => { + const { client, profile } = clientFrom(command) + + if (!options.row && !options.filter) { + // Without this, an argument-less call would delete the whole table. + throw new SimApiError( + 'Pass --row or --filter to choose what to delete.', + 0 + ) + } + + if (!options.yes) { + const target = options.row + ? `${options.row.length} row${options.row.length === 1 ? '' : 's'}` + : 'every row matching the filter' + throw new SimApiError( + `This deletes ${target} from ${tableId} and cannot be undone. Re-run with --yes to confirm.`, + 0 + ) + } + + const result = (await client.call('deleteTableRows', { + pathParams: { tableId }, + body: { + workspaceId: client.requireWorkspace(), + ...(options.row ? { rowIds: options.row } : {}), + ...(options.filter ? { filter: parseJsonArg(options.filter, '--filter') } : {}), + }, + })) as DeleteTableRowsResponse + + if (profile.output === 'json') { + console.log(JSON.stringify(result, null, 2)) + return + } + console.log(chalk.green(`✓ Deleted ${result.data.deletedCount} row(s)`)) + if (result.data.missingRowIds?.length) { + console.log(chalk.dim(` Not found: ${result.data.missingRowIds.join(', ')}`)) + } + } + ) + + return tables +} diff --git a/packages/sim-cli/src/commands/workflows.ts b/packages/sim-cli/src/commands/workflows.ts index a2acca4c076..fcedfd790d0 100644 --- a/packages/sim-cli/src/commands/workflows.ts +++ b/packages/sim-cli/src/commands/workflows.ts @@ -1,26 +1,11 @@ import chalk from 'chalk' import { Command } from 'commander' import { clientFrom } from '../context.js' +import type { GetWorkflowResponse, ListWorkflowsResponse } from '../generated/v2-api.js' import { bool, type Column, printList, printRecord, text, timestamp } from '../output/render.js' -interface WorkflowListItem { - id: string - name: string - description: string | null - folderId: string | null - workspaceId: string - isDeployed: boolean - deployedAt: string | null - runCount: number - lastRunAt: string | null - createdAt: string - updatedAt: string -} - -interface WorkflowDetail extends WorkflowListItem { - variables: Record - inputs: Array<{ name: string; type: string; description?: string }> -} +type WorkflowListItem = ListWorkflowsResponse['data'][number] +type WorkflowDetail = GetWorkflowResponse['data'] const LIST_COLUMNS: Column[] = [ { header: 'id', value: (w) => w.id }, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts new file mode 100644 index 00000000000..f6f7238c14b --- /dev/null +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -0,0 +1,1657 @@ +/** + * GENERATED FILE — DO NOT EDIT. + * + * Emitted from the Zod route contracts in + * `apps/sim/lib/api/contracts/v2/**` by `scripts/generate-v2-cli-api.ts`. + * Regenerate with `bun run generate:cli-api`; CI fails when this file is + * stale, so edit the contract rather than this file. + * + * Contains only type declarations and one const table — no imports, so the + * `packages/* must not import apps/*` boundary is preserved. + */ + +/** `POST /api/v2/tables/[tableId]/columns` */ +export type AddTableColumnParams = { + tableId: string +} + +export type AddTableColumnBody = { + workspaceId: string + column: { + id?: string + name: string + type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + position?: number + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + } +} + +export type AddTableColumnResponse = { + data: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + }> + } +} + +/** `POST /api/v2/knowledge` */ +export type CreateKnowledgeBaseBody = { + workspaceId: string + name: string + description?: string + chunkingConfig?: { + maxSize?: number + minSize?: number + overlap?: number + } +} + +export type CreateKnowledgeBaseResponse = { + data: { + knowledgeBase: { + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } + } + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + } + } +} + +/** `POST /api/v2/tables` */ +export type CreateTableBody = { + name: string + description?: string + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + }> + } + workspaceId: string + folderId?: string | null +} + +export type CreateTableResponse = { + data: { + table: { + id: string + name: string + description: string | null + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + }> + } + rowCount: number + maxRows: number + createdAt: string + updatedAt: string + } + } +} + +/** `POST /api/v2/tables/[tableId]/rows` */ +export type CreateTableRowsParams = { + tableId: string +} + +export type CreateTableRowsBody = + | { + workspaceId: string + rows: Array + } + | { + workspaceId: string + data: unknown + afterRowId?: string + beforeRowId?: string + } + +export type CreateTableRowsResponse = + | { + data: { + row: { + id: string + data: Record + createdAt: string + updatedAt: string + } + } + } + | { + data: { + rows: Array<{ + id: string + data: Record + createdAt: string + updatedAt: string + }> + insertedCount: number + } + } + +/** `DELETE /api/v2/files/[fileId]` */ +export type DeleteFileParams = { + fileId: string +} + +export type DeleteFileQuery = { + workspaceId: string +} + +export type DeleteFileResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/knowledge/[id]` */ +export type DeleteKnowledgeBaseParams = { + id: string +} + +export type DeleteKnowledgeBaseQuery = { + workspaceId: string +} + +export type DeleteKnowledgeBaseResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/knowledge/[id]/documents/[documentId]` */ +export type DeleteKnowledgeDocumentParams = { + id: string + documentId: string +} + +export type DeleteKnowledgeDocumentQuery = { + workspaceId: string +} + +export type DeleteKnowledgeDocumentResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/tables/[tableId]` */ +export type DeleteTableParams = { + tableId: string +} + +export type DeleteTableQuery = { + workspaceId: string +} + +export type DeleteTableResponse = { + data: { + id: string + } +} + +/** `DELETE /api/v2/tables/[tableId]/columns` */ +export type DeleteTableColumnParams = { + tableId: string +} + +export type DeleteTableColumnBody = { + workspaceId: string + columnName: string +} + +export type DeleteTableColumnResponse = { + data: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + }> + } +} + +/** `DELETE /api/v2/tables/[tableId]/rows/[rowId]` */ +export type DeleteTableRowParams = { + tableId: string + rowId: string +} + +export type DeleteTableRowQuery = { + workspaceId: string +} + +export type DeleteTableRowResponse = { + data: { + deletedCount: number + deletedRowIds: Array + } +} + +/** `DELETE /api/v2/tables/[tableId]/rows` */ +export type DeleteTableRowsParams = { + tableId: string +} + +export type DeleteTableRowsBody = { + workspaceId: string + filter?: unknown + limit?: number + rowIds?: Array +} + +export type DeleteTableRowsResponse = { + data: { + deletedCount: number + deletedRowIds: Array + requestedCount?: number + missingRowIds?: Array + } +} + +/** `POST /api/v2/workflows/[id]/deploy` */ +export type DeployWorkflowParams = { + id: string +} + +export type DeployWorkflowResponse = { + data: { + id: string + isDeployed: boolean + deployedAt: string | null + warnings: Array + activeDeployment: { + deploymentVersionId: string + version: number + deployedAt: string + } | null + latestDeploymentAttempt: { + id: string + deploymentVersionId: string + version: number + action: 'deploy' | 'activate' + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + readiness: { + webhooks: 'pending' | 'ready' | 'not_applicable' + schedules: 'pending' | 'ready' | 'not_applicable' + mcp: 'pending' | 'ready' | 'not_applicable' + } + requestedAt: string + activatedAt?: string | null + error?: { + code: string + message: string + retryable: boolean + } | null + } | null + version?: number + } +} + +/** `GET /api/v2/files/[fileId]` */ +export type DownloadFileParams = { + fileId: string +} + +export type DownloadFileQuery = { + workspaceId: string +} + +/** Non-JSON response (`binary`). */ +export type DownloadFileResponse = never + +/** `GET /api/v2/workflows/[id]/export` */ +export type ExportWorkflowParams = { + id: string +} + +export type ExportWorkflowResponse = { + data: { + version: '1.0' + exportedAt: string + workflow: { + id: string + name: string + description: string | null + workspaceId: string | null + folderId: string | null + } + state: { + blocks: Record< + string, + { + id: string + type: string + name: string + position: { + x: number + y: number + } + subBlocks: Record< + string, + { + id: string + type: string + value: unknown + } + > + outputs: Record + enabled: boolean + horizontalHandles?: boolean + height?: number + advancedMode?: boolean + triggerMode?: boolean + data?: { + parentId?: string + extent?: 'parent' + width?: number + height?: number + collection?: unknown + count?: number + loopType?: 'for' | 'forEach' | 'while' | 'doWhile' + whileCondition?: string + doWhileCondition?: string + parallelType?: 'collection' | 'count' + batchSize?: number + type?: string + canonicalModes?: Record + } + locked?: boolean + } + > + edges: Array<{ + id: string + source: string + target: string + sourceHandle: unknown + targetHandle: unknown + type?: string + animated?: boolean + style?: Record + data?: Record + label?: string + labelStyle?: Record + labelShowBg?: boolean + labelBgStyle?: Record + labelBgPadding?: unknown[] + labelBgBorderRadius?: number + markerStart?: string + markerEnd?: string + }> + loops?: Record< + string, + { + id: string + nodes: Array + iterations: number + loopType: 'for' | 'forEach' | 'while' | 'doWhile' + forEachItems?: Array | Record | string + whileCondition?: string + doWhileCondition?: string + enabled?: boolean + locked?: boolean + } + > + parallels?: Record< + string, + { + id: string + nodes: Array + distribution?: Array | Record | string + count?: number + parallelType?: 'count' | 'collection' + batchSize?: number + enabled?: boolean + locked?: boolean + } + > + variables?: Record< + string, + { + id: string + name: string + type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' + value: unknown + } + > + metadata?: { + name?: string + description?: string + sortOrder?: number + exportedAt?: string + } + } + } +} + +/** `GET /api/v2/audit-logs/[id]` */ +export type GetAuditLogParams = { + id: string +} + +export type GetAuditLogResponse = { + data: { + id: string + workspaceId: string | null + actorId: string | null + actorName: string | null + actorEmail: string | null + action: string + resourceType: string + resourceId: string | null + resourceName: string | null + description: string | null + metadata: unknown + createdAt: string + } +} + +/** `GET /api/v2/logs/executions/[executionId]` */ +export type GetExecutionParams = { + executionId: string +} + +export type GetExecutionResponse = { + data: { + executionId: string + workflowId: string | null + workflowState: unknown + executionMetadata: { + trigger: string + startedAt: string + endedAt: string | null + totalDurationMs: number | null + cost: { + total: number + } | null + } + } +} + +/** `GET /api/v2/knowledge/[id]` */ +export type GetKnowledgeBaseParams = { + id: string +} + +export type GetKnowledgeBaseQuery = { + workspaceId: string +} + +export type GetKnowledgeBaseResponse = { + data: { + knowledgeBase: { + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } + } + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + } + } +} + +/** `GET /api/v2/knowledge/[id]/documents/[documentId]` */ +export type GetKnowledgeDocumentParams = { + id: string + documentId: string +} + +export type GetKnowledgeDocumentQuery = { + workspaceId: string +} + +export type GetKnowledgeDocumentResponse = { + data: { + document: { + id: string + knowledgeBaseId: string + filename: string + fileSize: number + mimeType: string + processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + chunkCount: number + tokenCount: number + characterCount: number + enabled: boolean + createdAt: string | null + processingError: string | null + processingStartedAt: string | null + processingCompletedAt: string | null + connectorId: string | null + connectorType: string | null + sourceUrl: string | null + } + } +} + +/** `GET /api/v2/logs/[id]` */ +export type GetLogParams = { + id: string +} + +export type GetLogResponse = { + data: { + id: string + workflowId: string | null + executionId: string + level: string + trigger: string + startedAt: string + endedAt: string | null + totalDurationMs: number | null + files: Array | null + workflow: { + id: string | null + name: string + description: string | null + folderId: string | null + userId: string | null + workspaceId: string | null + createdAt: string | null + updatedAt: string | null + deleted: boolean + } + executionData: unknown + cost: { + total: number + } | null + createdAt: string + } +} + +/** `GET /api/v2/tables/[tableId]` */ +export type GetTableParams = { + tableId: string +} + +export type GetTableQuery = { + workspaceId: string +} + +export type GetTableResponse = { + data: { + table: { + id: string + name: string + description: string | null + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + }> + } + rowCount: number + maxRows: number + createdAt: string + updatedAt: string + } + } +} + +/** `GET /api/v2/tables/[tableId]/rows/[rowId]` */ +export type GetTableRowParams = { + tableId: string + rowId: string +} + +export type GetTableRowQuery = { + workspaceId: string +} + +export type GetTableRowResponse = { + data: { + row: { + id: string + data: Record + createdAt: string + updatedAt: string + } + } +} + +/** `GET /api/v2/billing/usage` */ +export type GetUsageSummaryQuery = { + workspaceId?: string +} + +export type GetUsageSummaryResponse = { + data: { + period: { + start: string + end: string + } + totalCredits: number + bySourceCredits: Record + limitCredits: number + plan: string + } +} + +/** `GET /api/v2/workflows/[id]` */ +export type GetWorkflowParams = { + id: string +} + +export type GetWorkflowResponse = { + data: { + id: string + name: string + description: string | null + folderId: string | null + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string + variables: Record + inputs: Array<{ + name: string + type: string + description?: string + }> + } +} + +/** `POST /api/v2/workflows/import` */ +export type ImportWorkflowBody = { + workspaceId: string + folderId?: string + name?: string + description?: string + workflow: string | Record +} + +export type ImportWorkflowResponse = { + data: { + id: string + name: string + description: string | null + workspaceId: string + folderId: string | null + createdAt: string + updatedAt: string + } +} + +/** `GET /api/v2/audit-logs` */ +export type ListAuditLogsQuery = { + action?: string + resourceType?: string + resourceId?: string + workspaceId?: string + actorId?: string + startDate?: string + endDate?: string + includeDeparted?: 'true' | 'false' + limit?: number + cursor?: string +} + +export type ListAuditLogsResponse = { + data: Array<{ + id: string + workspaceId: string | null + actorId: string | null + actorName: string | null + actorEmail: string | null + action: string + resourceType: string + resourceId: string | null + resourceName: string | null + description: string | null + metadata: unknown + createdAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/files` */ +export type ListFilesQuery = { + workspaceId: string + limit?: number + cursor?: string +} + +export type ListFilesResponse = { + data: Array<{ + id: string + name: string + size: number + type: string + key: string + uploadedBy: string + uploadedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/knowledge` */ +export type ListKnowledgeBasesQuery = { + workspaceId: string +} + +export type ListKnowledgeBasesResponse = { + data: Array<{ + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } + } + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/knowledge/[id]/documents` */ +export type ListKnowledgeDocumentsParams = { + id: string +} + +export type ListKnowledgeDocumentsQuery = { + workspaceId: string + limit?: number + search?: string + enabledFilter?: 'all' | 'enabled' | 'disabled' + sortBy?: + | 'filename' + | 'fileSize' + | 'tokenCount' + | 'chunkCount' + | 'uploadedAt' + | 'processingStatus' + | 'enabled' + sortOrder?: 'asc' | 'desc' + cursor?: string +} + +export type ListKnowledgeDocumentsResponse = { + data: Array<{ + id: string + knowledgeBaseId: string + filename: string + fileSize: number + mimeType: string + processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + chunkCount: number + tokenCount: number + characterCount: number + enabled: boolean + createdAt: string | null + }> + nextCursor: string | null +} + +/** `GET /api/v2/logs` */ +export type ListLogsQuery = { + workspaceId: string + workflowIds?: string + folderIds?: string + triggers?: string + level?: 'info' | 'error' + startDate?: string + endDate?: string + executionId?: string + minDurationMs?: number + maxDurationMs?: number + minCost?: number + maxCost?: number + model?: string + details?: 'basic' | 'full' + includeTraceSpans?: boolean + includeFinalOutput?: boolean + limit?: number + cursor?: string + order?: 'desc' | 'asc' +} + +export type ListLogsResponse = { + data: Array<{ + id: string + workflowId: string | null + executionId: string + deploymentVersionId: string | null + level: string + trigger: string + startedAt: string + endedAt: string | null + totalDurationMs: number | null + cost: { + total: number + } | null + files: Array | null + workflow?: { + id: string | null + name: string + description: string | null + deleted: boolean + } + finalOutput?: unknown + traceSpans?: unknown + }> + nextCursor: string | null +} + +/** `GET /api/v2/tables/[tableId]/rows` */ +export type ListTableRowsParams = { + tableId: string +} + +export type ListTableRowsQuery = { + workspaceId: string + limit?: number + cursor?: string +} + +export type ListTableRowsResponse = { + data: Array<{ + id: string + data: Record + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/tables` */ +export type ListTablesQuery = { + workspaceId: string +} + +export type ListTablesResponse = { + data: Array<{ + id: string + name: string + description: string | null + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + }> + } + rowCount: number + maxRows: number + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/billing/usage/logs` */ +export type ListUsageLogsQuery = { + source?: + | 'workflow' + | 'wand' + | 'copilot' + | 'workspace-chat' + | 'mcp_copilot' + | 'mothership_block' + | 'knowledge-base' + | 'voice-input' + | 'enrichment' + workspaceId?: string + period?: '1d' | '7d' | '30d' | 'all' | 'custom' + startDate?: string + endDate?: string + limit?: number + cursor?: string +} + +export type ListUsageLogsResponse = { + data: Array<{ + id: string + createdAt: string + source: + | 'workflow' + | 'wand' + | 'copilot' + | 'workspace-chat' + | 'mcp_copilot' + | 'mothership_block' + | 'knowledge-base' + | 'voice-input' + | 'enrichment' + workflowName: string | null + creditCost: number + }> + nextCursor: string | null +} + +/** `GET /api/v2/workflows` */ +export type ListWorkflowsQuery = { + workspaceId: string + folderId?: string + deployedOnly?: boolean + limit?: number + cursor?: string +} + +export type ListWorkflowsResponse = { + data: Array<{ + id: string + name: string + description: string | null + folderId: string | null + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `POST /api/v2/tables/[tableId]/query` */ +export type QueryRowsParams = { + tableId: string +} + +export type QueryRowsBody = { + workspaceId: string + predicate?: unknown + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> + limit?: number + cursor?: string +} + +export type QueryRowsResponse = { + data: Array<{ + id: string + data: Record + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `POST /api/v2/workflows/[id]/rollback` */ +export type RollbackWorkflowParams = { + id: string +} + +export type RollbackWorkflowResponse = { + data: { + id: string + isDeployed: boolean + deployedAt: string | null + warnings: Array + activeDeployment: { + deploymentVersionId: string + version: number + deployedAt: string + } | null + latestDeploymentAttempt: { + id: string + deploymentVersionId: string + version: number + action: 'deploy' | 'activate' + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + readiness: { + webhooks: 'pending' | 'ready' | 'not_applicable' + schedules: 'pending' | 'ready' | 'not_applicable' + mcp: 'pending' | 'ready' | 'not_applicable' + } + requestedAt: string + activatedAt?: string | null + error?: { + code: string + message: string + retryable: boolean + } | null + } | null + version: number + } +} + +/** `POST /api/v2/knowledge/search` */ +export type SearchKnowledgeBody = { + workspaceId: string + knowledgeBaseIds: string | Array + query?: string + topK?: number + tagFilters?: Array<{ + tagName: string + fieldType?: 'text' | 'number' | 'date' | 'boolean' + operator?: string + value: string | number | boolean + valueTo?: string | number + }> +} + +export type SearchKnowledgeResponse = { + data: { + results: Array<{ + documentId: string + documentName: string | null + sourceUrl: string | null + content: string + chunkIndex: number + metadata: Record + similarity: number + }> + query: string + knowledgeBaseIds: Array + topK: number + totalResults: number + } +} + +/** `DELETE /api/v2/workflows/[id]/deploy` */ +export type UndeployWorkflowParams = { + id: string +} + +export type UndeployWorkflowResponse = { + data: { + id: string + isDeployed: boolean + deployedAt: string | null + warnings: Array + activeDeployment: { + deploymentVersionId: string + version: number + deployedAt: string + } | null + latestDeploymentAttempt: { + id: string + deploymentVersionId: string + version: number + action: 'deploy' | 'activate' + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + readiness: { + webhooks: 'pending' | 'ready' | 'not_applicable' + schedules: 'pending' | 'ready' | 'not_applicable' + mcp: 'pending' | 'ready' | 'not_applicable' + } + requestedAt: string + activatedAt?: string | null + error?: { + code: string + message: string + retryable: boolean + } | null + } | null + } +} + +/** `PUT /api/v2/knowledge/[id]` */ +export type UpdateKnowledgeBaseParams = { + id: string +} + +export type UpdateKnowledgeBaseBody = { + workspaceId: string + name?: string + description?: string + chunkingConfig?: { + maxSize: number + minSize: number + overlap: number + } +} + +export type UpdateKnowledgeBaseResponse = { + data: { + knowledgeBase: { + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } + } + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + } + } +} + +/** `PUT /api/v2/tables/[tableId]/rows` */ +export type UpdateRowsByFilterParams = { + tableId: string +} + +export type UpdateRowsByFilterBody = { + workspaceId: string + filter: unknown + data: unknown + limit?: number +} + +export type UpdateRowsByFilterResponse = { + data: { + updatedCount: number + updatedRowIds: Array + } +} + +/** `PATCH /api/v2/tables/[tableId]/columns` */ +export type UpdateTableColumnParams = { + tableId: string +} + +export type UpdateTableColumnBody = { + workspaceId: string + columnName: string + updates: { + name?: string + type?: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + } +} + +export type UpdateTableColumnResponse = { + data: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + }> + } +} + +/** `PATCH /api/v2/tables/[tableId]/rows/[rowId]` */ +export type UpdateTableRowParams = { + tableId: string + rowId: string +} + +export type UpdateTableRowBody = { + workspaceId: string + data: unknown +} + +export type UpdateTableRowResponse = { + data: { + row: { + id: string + data: Record + createdAt: string + updatedAt: string + } + } +} + +/** `POST /api/v2/files` */ +export type UploadFileQuery = { + workspaceId: string +} + +export type UploadFileResponse = { + data: { + id: string + name: string + size: number + type: string + key: string + uploadedBy: string + uploadedAt: string + } +} + +/** `POST /api/v2/knowledge/[id]/documents` */ +export type UploadKnowledgeDocumentParams = { + id: string +} + +export type UploadKnowledgeDocumentQuery = { + workspaceId: string +} + +export type UploadKnowledgeDocumentResponse = { + data: { + document: { + id: string + knowledgeBaseId: string + filename: string + fileSize: number + mimeType: string + processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + chunkCount: number + tokenCount: number + characterCount: number + enabled: boolean + createdAt: string | null + } + } +} + +/** `POST /api/v2/tables/[tableId]/rows/upsert` */ +export type UpsertTableRowParams = { + tableId: string +} + +export type UpsertTableRowBody = { + workspaceId: string + data: unknown + conflictTarget?: string +} + +export type UpsertTableRowResponse = { + data: { + row: { + id: string + data: Record + createdAt: string + updatedAt: string + } + operation: 'insert' | 'update' + } +} + +/** Every v2 operation, keyed by name. */ +export const V2_OPERATIONS = { + addTableColumn: { + method: 'POST', + path: '/api/v2/tables/[tableId]/columns', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + createKnowledgeBase: { + method: 'POST', + path: '/api/v2/knowledge', + pathParams: [] as const, + responseMode: 'json', + }, + createTable: { + method: 'POST', + path: '/api/v2/tables', + pathParams: [] as const, + responseMode: 'json', + }, + createTableRows: { + method: 'POST', + path: '/api/v2/tables/[tableId]/rows', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + deleteFile: { + method: 'DELETE', + path: '/api/v2/files/[fileId]', + pathParams: ['fileId'] as const, + responseMode: 'json', + }, + deleteKnowledgeBase: { + method: 'DELETE', + path: '/api/v2/knowledge/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + }, + deleteKnowledgeDocument: { + method: 'DELETE', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + pathParams: ['id', 'documentId'] as const, + responseMode: 'json', + }, + deleteTable: { + method: 'DELETE', + path: '/api/v2/tables/[tableId]', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + deleteTableColumn: { + method: 'DELETE', + path: '/api/v2/tables/[tableId]/columns', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + deleteTableRow: { + method: 'DELETE', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + pathParams: ['tableId', 'rowId'] as const, + responseMode: 'json', + }, + deleteTableRows: { + method: 'DELETE', + path: '/api/v2/tables/[tableId]/rows', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + deployWorkflow: { + method: 'POST', + path: '/api/v2/workflows/[id]/deploy', + pathParams: ['id'] as const, + responseMode: 'json', + }, + downloadFile: { + method: 'GET', + path: '/api/v2/files/[fileId]', + pathParams: ['fileId'] as const, + responseMode: 'binary', + }, + exportWorkflow: { + method: 'GET', + path: '/api/v2/workflows/[id]/export', + pathParams: ['id'] as const, + responseMode: 'json', + }, + getAuditLog: { + method: 'GET', + path: '/api/v2/audit-logs/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + }, + getExecution: { + method: 'GET', + path: '/api/v2/logs/executions/[executionId]', + pathParams: ['executionId'] as const, + responseMode: 'json', + }, + getKnowledgeBase: { + method: 'GET', + path: '/api/v2/knowledge/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + }, + getKnowledgeDocument: { + method: 'GET', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + pathParams: ['id', 'documentId'] as const, + responseMode: 'json', + }, + getLog: { + method: 'GET', + path: '/api/v2/logs/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + }, + getTable: { + method: 'GET', + path: '/api/v2/tables/[tableId]', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + getTableRow: { + method: 'GET', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + pathParams: ['tableId', 'rowId'] as const, + responseMode: 'json', + }, + getUsageSummary: { + method: 'GET', + path: '/api/v2/billing/usage', + pathParams: [] as const, + responseMode: 'json', + }, + getWorkflow: { + method: 'GET', + path: '/api/v2/workflows/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + }, + importWorkflow: { + method: 'POST', + path: '/api/v2/workflows/import', + pathParams: [] as const, + responseMode: 'json', + }, + listAuditLogs: { + method: 'GET', + path: '/api/v2/audit-logs', + pathParams: [] as const, + responseMode: 'json', + }, + listFiles: { + method: 'GET', + path: '/api/v2/files', + pathParams: [] as const, + responseMode: 'json', + }, + listKnowledgeBases: { + method: 'GET', + path: '/api/v2/knowledge', + pathParams: [] as const, + responseMode: 'json', + }, + listKnowledgeDocuments: { + method: 'GET', + path: '/api/v2/knowledge/[id]/documents', + pathParams: ['id'] as const, + responseMode: 'json', + }, + listLogs: { + method: 'GET', + path: '/api/v2/logs', + pathParams: [] as const, + responseMode: 'json', + }, + listTableRows: { + method: 'GET', + path: '/api/v2/tables/[tableId]/rows', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + listTables: { + method: 'GET', + path: '/api/v2/tables', + pathParams: [] as const, + responseMode: 'json', + }, + listUsageLogs: { + method: 'GET', + path: '/api/v2/billing/usage/logs', + pathParams: [] as const, + responseMode: 'json', + }, + listWorkflows: { + method: 'GET', + path: '/api/v2/workflows', + pathParams: [] as const, + responseMode: 'json', + }, + queryRows: { + method: 'POST', + path: '/api/v2/tables/[tableId]/query', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + rollbackWorkflow: { + method: 'POST', + path: '/api/v2/workflows/[id]/rollback', + pathParams: ['id'] as const, + responseMode: 'json', + }, + searchKnowledge: { + method: 'POST', + path: '/api/v2/knowledge/search', + pathParams: [] as const, + responseMode: 'json', + }, + undeployWorkflow: { + method: 'DELETE', + path: '/api/v2/workflows/[id]/deploy', + pathParams: ['id'] as const, + responseMode: 'json', + }, + updateKnowledgeBase: { + method: 'PUT', + path: '/api/v2/knowledge/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + }, + updateRowsByFilter: { + method: 'PUT', + path: '/api/v2/tables/[tableId]/rows', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + updateTableColumn: { + method: 'PATCH', + path: '/api/v2/tables/[tableId]/columns', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + updateTableRow: { + method: 'PATCH', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + pathParams: ['tableId', 'rowId'] as const, + responseMode: 'json', + }, + uploadFile: { + method: 'POST', + path: '/api/v2/files', + pathParams: [] as const, + responseMode: 'json', + }, + uploadKnowledgeDocument: { + method: 'POST', + path: '/api/v2/knowledge/[id]/documents', + pathParams: ['id'] as const, + responseMode: 'json', + }, + upsertTableRow: { + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/upsert', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, +} as const + +export type V2OperationName = keyof typeof V2_OPERATIONS diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts new file mode 100644 index 00000000000..8542593159b --- /dev/null +++ b/packages/sim-cli/src/http/client.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' +import { resolvePath, SimApiError } from './client.js' + +describe('resolvePath', () => { + it('substitutes a path parameter', () => { + expect(resolvePath('/api/v2/tables/[tableId]/rows', { tableId: 'tbl_1' })).toBe( + '/api/v2/tables/tbl_1/rows' + ) + }) + + it('substitutes several parameters', () => { + expect( + resolvePath('/api/v2/knowledge/[id]/documents/[documentId]', { id: 'kb', documentId: 'doc' }) + ).toBe('/api/v2/knowledge/kb/documents/doc') + }) + + it('percent-encodes values so an id cannot retarget the request', () => { + // An unencoded `/` or `?` here would silently address a different endpoint. + expect(resolvePath('/api/v2/tables/[tableId]', { tableId: 'a/b?c=d' })).toBe( + '/api/v2/tables/a%2Fb%3Fc%3Dd' + ) + }) + + it('throws rather than sending a URL with a literal [param] in it', () => { + expect(() => resolvePath('/api/v2/tables/[tableId]', {})).toThrow(SimApiError) + expect(() => resolvePath('/api/v2/tables/[tableId]', {})).toThrow('tableId') + }) + + it('leaves a parameterless path alone', () => { + expect(resolvePath('/api/v2/tables')).toBe('/api/v2/tables') + }) +}) + +describe('generated operation table', () => { + const names = Object.keys(V2_OPERATIONS) as V2OperationName[] + + it('covers the operations the commands rely on', () => { + // Named explicitly: if a contract is renamed, the generator happily emits + // the new name and only this test catches that a command lost its endpoint. + for (const required of [ + 'listTables', + 'getTable', + 'queryRows', + 'createTableRows', + 'deleteTableRows', + 'listWorkflows', + 'getWorkflow', + 'deployWorkflow', + 'undeployWorkflow', + 'rollbackWorkflow', + 'listLogs', + 'getLog', + 'getExecution', + 'listFiles', + 'deleteFile', + 'listKnowledgeBases', + 'getKnowledgeBase', + 'listKnowledgeDocuments', + 'searchKnowledge', + ] satisfies V2OperationName[]) { + expect(names).toContain(required) + } + }) + + it('declares every path parameter its path contains', () => { + for (const name of names) { + const spec = V2_OPERATIONS[name] + const inPath = [...spec.path.matchAll(/\[([^\]]+)\]/g)].map((m) => m[1]) + expect(spec.pathParams, `${name} path params`).toEqual(inPath) + } + }) + + it('only targets the public v2 surface with real HTTP verbs', () => { + for (const name of names) { + const spec = V2_OPERATIONS[name] + expect(spec.path, name).toMatch(/^\/api\/v2\//) + expect(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], name).toContain(spec.method) + } + }) + + it('has no two operations sharing a method and path', () => { + const seen = new Map() + for (const name of names) { + const spec = V2_OPERATIONS[name] + const key = `${spec.method} ${spec.path}` + expect(seen.get(key), `${key} claimed by both ${seen.get(key)} and ${name}`).toBeUndefined() + seen.set(key, name) + } + }) +}) diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index 72afaca74e6..22110a846db 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -1,4 +1,5 @@ import type { ResolvedProfile } from '../config/index.js' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' /** * A failure the CLI can explain. Anything thrown as a `SimApiError` is printed @@ -191,4 +192,46 @@ export class SimClient { return items.slice(0, max) } + + /** + * Calls a generated operation by name. + * + * Method and path come from `V2_OPERATIONS`, so a route that moves or changes + * verb in a contract moves here on the next `generate:cli-api` rather than + * failing at runtime against a URL the CLI still remembers. + */ + async call( + operation: K, + options: OperationOptions = {} + ): Promise { + const spec = V2_OPERATIONS[operation] + return this.request(resolvePath(spec.path, options.pathParams), { + method: spec.method as RequestOptions['method'], + query: options.query, + body: options.body, + }) + } +} + +export interface OperationOptions { + pathParams?: Record + query?: Record + body?: unknown +} + +/** + * Substitutes `[id]`-style path segments. + * + * Values are percent-encoded: table and workspace ids are opaque, and a `/` or + * `?` inside one would otherwise silently retarget the request at a different + * endpoint. + */ +export function resolvePath(template: string, params: Record = {}): string { + return template.replace(/\[([^\]]+)\]/g, (_match, key: string) => { + const value = params[key] + if (value === undefined) { + throw new SimApiError(`Missing path parameter "${key}" for ${template}`, 0) + } + return encodeURIComponent(value) + }) } diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index cfca5271cd2..6b4d8e20149 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -7,6 +7,7 @@ import { configureCommand } from './commands/configure.js' import { filesCommand } from './commands/files.js' import { knowledgeCommand } from './commands/knowledge.js' import { logsCommand } from './commands/logs.js' +import { tablesCommand } from './commands/tables.js' import { workflowsCommand } from './commands/workflows.js' import { OUTPUT_FORMATS } from './config/index.js' import { SimApiError } from './http/client.js' @@ -29,6 +30,7 @@ program.addCommand(profilesCommand()) program.addCommand(configureCommand()) program.addCommand(workflowsCommand()) program.addCommand(logsCommand()) +program.addCommand(tablesCommand()) program.addCommand(filesCommand()) program.addCommand(knowledgeCommand()) diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts new file mode 100644 index 00000000000..991a39b52ad --- /dev/null +++ b/scripts/generate-v2-cli-api.ts @@ -0,0 +1,338 @@ +#!/usr/bin/env bun +/** + * Generates the Sim CLI's view of the public v2 API from the Zod route + * contracts, so the terminal and the server cannot describe the same endpoint + * differently. + * + * The contracts under `apps/sim/lib/api/contracts/v2/**` are the single source + * of truth: the routes validate against them, so a shape that disagrees with a + * contract is a shape the server would reject. Everything downstream is derived + * rather than restated. + * + * The CLI cannot import the contracts directly — `packages/*` must never depend + * on `apps/*` (scripts/check-monorepo-boundaries.ts). This script bridges that + * at build time instead: it reads the contracts here and emits a file of plain + * type declarations with no imports at all, so nothing about the package + * boundary changes. + * + * Deliberately NOT generated: the OpenAPI documents under `apps/docs`. They + * carry ~1000 hand-written descriptions and ~400 examples that Zod schemas do + * not encode, and regenerating them would trade real documentation for + * mechanical accuracy. `--check-openapi` reconciles their *structure* against + * the contracts instead, so the prose survives while drift still fails CI. + * + * Usage: + * bun run scripts/generate-v2-cli-api.ts # write the generated file + * bun run scripts/generate-v2-cli-api.ts --check # fail if it is stale + * bun run scripts/generate-v2-cli-api.ts --check-openapi + */ + +import { readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { z } from 'zod' + +const ROOT = path.resolve(import.meta.dir, '..') +const CONTRACTS_DIR = path.join(ROOT, 'apps/sim/lib/api/contracts/v2') +const OUTPUT = path.join(ROOT, 'packages/sim-cli/src/generated/v2-api.ts') +const DOCS_DIR = path.join(ROOT, 'apps/docs') + +/** Contract modules to read, in emit order. */ +const DOMAINS = [ + 'workflows', + 'logs', + 'tables', + 'files', + 'knowledge', + 'audit-logs', + 'billing', +] as const + +interface RouteContract { + method: string + path: string + params?: z.ZodType + query?: z.ZodType + body?: z.ZodType + headers?: z.ZodType + response: { mode: string; schema?: z.ZodType } +} + +interface Operation { + /** `listTables` — derived from the export name. */ + name: string + domain: string + contract: RouteContract +} + +function isRouteContract(value: unknown): value is RouteContract { + if (!value || typeof value !== 'object') return false + const candidate = value as Partial + return ( + typeof candidate.method === 'string' && + typeof candidate.path === 'string' && + typeof candidate.response === 'object' + ) +} + +/** `v2ListTablesContract` → `listTables`. */ +function operationName(exportName: string): string { + const stripped = exportName.replace(/^v2/, '').replace(/Contract$/, '') + return stripped.charAt(0).toLowerCase() + stripped.slice(1) +} + +function pascal(name: string): string { + return name.charAt(0).toUpperCase() + name.slice(1) +} + +async function collectOperations(): Promise { + const operations: Operation[] = [] + + for (const domain of DOMAINS) { + const mod: Record = await import(path.join(CONTRACTS_DIR, `${domain}.ts`)) + for (const [exportName, value] of Object.entries(mod)) { + if (!exportName.endsWith('Contract') || !isRouteContract(value)) continue + operations.push({ name: operationName(exportName), domain, contract: value }) + } + } + + // Import order is stable, but sort anyway so a reordered export list does not + // show up as a spurious diff in the generated file. + return operations.sort((a, b) => a.name.localeCompare(b.name)) +} + +type JsonSchema = Record + +/** + * Emits a TypeScript type for the subset of JSON Schema that `z.toJSONSchema` + * produces from these contracts. + * + * Hand-rolled rather than pulled from `json-schema-to-typescript`: the input is + * a known, narrow subset (no `$ref`, no `patternProperties`, no draft-04 + * quirks), and the output is committed and read by humans, so controlling the + * formatting is worth more here than covering spec corners that never appear. + * An unhandled construct throws rather than degrading to `any` — silence is how + * a generated client drifts from its server. + */ +function toTypeScript(schema: JsonSchema, indent = 0): string { + const pad = ' '.repeat(indent + 1) + const closePad = ' '.repeat(indent) + + if (schema.const !== undefined) return JSON.stringify(schema.const) + if (schema.enum) return schema.enum.map((v: unknown) => JSON.stringify(v)).join(' | ') + + const variants = schema.anyOf ?? schema.oneOf + if (variants) { + return variants.map((v: JsonSchema) => toTypeScript(v, indent)).join(' | ') + } + + if (schema.allOf) { + return schema.allOf.map((v: JsonSchema) => toTypeScript(v, indent)).join(' & ') + } + + switch (schema.type) { + case 'string': + return 'string' + case 'number': + case 'integer': + return 'number' + case 'boolean': + return 'boolean' + case 'null': + return 'null' + case 'array': + return schema.items ? `Array<${toTypeScript(schema.items, indent)}>` : 'unknown[]' + case 'object': { + const properties: Record = schema.properties ?? {} + const required: string[] = schema.required ?? [] + const keys = Object.keys(properties) + + if (keys.length === 0) { + // A bare object with only `additionalProperties` is a record. + const value = + schema.additionalProperties && typeof schema.additionalProperties === 'object' + ? toTypeScript(schema.additionalProperties, indent) + : 'unknown' + return `Record` + } + + const lines = keys.map((key) => { + const optional = required.includes(key) ? '' : '?' + const safeKey = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key) + return `${pad}${safeKey}${optional}: ${toTypeScript(properties[key], indent + 1)}` + }) + return `{\n${lines.join('\n')}\n${closePad}}` + } + } + + // `z.unknown()` / `z.any()` render as an empty schema. + if (Object.keys(schema).filter((k) => k !== '$schema').length === 0) return 'unknown' + + throw new Error(`Unhandled JSON Schema construct: ${JSON.stringify(schema).slice(0, 200)}`) +} + +function schemaToType(schema: z.ZodType, io: 'input' | 'output'): string { + const json = z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as JsonSchema + return toTypeScript(json) +} + +/** Path params the CLI must substitute, e.g. `/api/v2/workflows/[id]` → `['id']`. */ +function pathParams(routePath: string): string[] { + return [...routePath.matchAll(/\[([^\]]+)\]/g)].map((m) => m[1]) +} + +function render(operations: Operation[]): string { + const out: string[] = [] + + out.push('/**') + out.push(' * GENERATED FILE — DO NOT EDIT.') + out.push(' *') + out.push(' * Emitted from the Zod route contracts in') + out.push(' * `apps/sim/lib/api/contracts/v2/**` by `scripts/generate-v2-cli-api.ts`.') + out.push(' * Regenerate with `bun run generate:cli-api`; CI fails when this file is') + out.push(' * stale, so edit the contract rather than this file.') + out.push(' *') + out.push(' * Contains only type declarations and one const table — no imports, so the') + out.push(' * `packages/* must not import apps/*` boundary is preserved.') + out.push(' */') + out.push('') + + for (const op of operations) { + const Name = pascal(op.name) + const { contract } = op + + out.push(`/** \`${contract.method} ${contract.path}\` */`) + + for (const slot of ['params', 'query', 'body', 'headers'] as const) { + const schema = contract[slot] + if (!schema) continue + out.push(`export type ${Name}${pascal(slot)} = ${schemaToType(schema, 'input')}`) + out.push('') + } + + if (contract.response.mode === 'json' && contract.response.schema) { + out.push(`export type ${Name}Response = ${schemaToType(contract.response.schema, 'output')}`) + } else { + out.push(`/** Non-JSON response (\`${contract.response.mode}\`). */`) + out.push(`export type ${Name}Response = never`) + } + out.push('') + } + + out.push('/** Every v2 operation, keyed by name. */') + out.push('export const V2_OPERATIONS = {') + for (const op of operations) { + const params = pathParams(op.contract.path) + out.push(` ${op.name}: {`) + out.push(` method: '${op.contract.method}',`) + out.push(` path: '${op.contract.path}',`) + out.push(` pathParams: [${params.map((p) => `'${p}'`).join(', ')}] as const,`) + out.push(` responseMode: '${op.contract.response.mode}',`) + out.push(' },') + } + out.push('} as const') + out.push('') + out.push('export type V2OperationName = keyof typeof V2_OPERATIONS') + out.push('') + + return out.join('\n') +} + +/** + * Reconciles the hand-written OpenAPI documents against the contracts. + * + * Structure only — every contract path/method must be documented, and every + * documented v2 path/method must exist as a contract. Descriptions and examples + * are the docs' own, and are deliberately not compared. + */ +function checkOpenApi(operations: Operation[]): string[] { + const problems: string[] = [] + + const documented = new Set() + for (const file of [ + 'openapi-core.json', + 'openapi-v2-workflows.json', + 'openapi-v2-logs.json', + 'openapi-v2-tables.json', + 'openapi-v2-knowledge.json', + 'openapi-v2-files-audit.json', + ]) { + let spec: JsonSchema + try { + spec = JSON.parse(readFileSync(path.join(DOCS_DIR, file), 'utf8')) + } catch { + problems.push(`missing or unparseable spec: ${file}`) + continue + } + for (const [specPath, methods] of Object.entries(spec.paths ?? {})) { + for (const method of Object.keys(methods as object)) { + if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) continue + documented.add(`${method.toUpperCase()} ${specPath}`) + } + } + } + + for (const op of operations) { + // Contracts use Next.js `[id]`; OpenAPI uses `{id}`. + const openApiPath = op.contract.path.replace(/\[([^\]]+)\]/g, '{$1}') + const key = `${op.contract.method} ${openApiPath}` + if (!documented.has(key)) { + problems.push(`contract not documented in OpenAPI: ${key} (${op.name})`) + } + documented.delete(key) + } + + for (const stale of documented) { + if (stale.includes('/api/v2/')) { + problems.push(`documented in OpenAPI but no contract: ${stale}`) + } + } + + return problems +} + +async function main() { + const args = new Set(process.argv.slice(2)) + const operations = await collectOperations() + + if (args.has('--check-openapi')) { + const problems = checkOpenApi(operations) + if (problems.length > 0) { + console.error('OpenAPI drift against the v2 contracts:\n') + for (const problem of problems) console.error(` - ${problem}`) + console.error( + '\nUpdate apps/docs/openapi-v2-*.json to match the contracts (the contracts are authoritative).' + ) + process.exit(1) + } + console.log(`OpenAPI matches all ${operations.length} v2 contracts.`) + return + } + + const generated = render(operations) + + if (args.has('--check')) { + let current = '' + try { + current = readFileSync(OUTPUT, 'utf8') + } catch { + console.error(`${path.relative(ROOT, OUTPUT)} is missing. Run: bun run generate:cli-api`) + process.exit(1) + } + if (current !== generated) { + console.error( + `${path.relative(ROOT, OUTPUT)} is stale. Run: bun run generate:cli-api\n\n` + + 'The v2 contracts changed without the CLI being regenerated.' + ) + process.exit(1) + } + console.log(`${path.relative(ROOT, OUTPUT)} is up to date (${operations.length} operations).`) + return + } + + writeFileSync(OUTPUT, generated) + console.log( + `Wrote ${path.relative(ROOT, OUTPUT)} — ${operations.length} operations from ${DOMAINS.length} contract modules.` + ) +} + +main() From e8534dcce218722a735362af94bd642feeba86e5 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 12:49:53 -0700 Subject: [PATCH 008/159] fix(cli): make the generated v2 API a fixed point of the formatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-commit hook rewrote the generated file immediately after it was committed, so `check:cli-api` then failed in CI reporting contract drift that had not happened — the only difference was quote style. The biome.json exclusion added alongside it does not help: lint-staged runs `biome check --write` on explicit paths, which bypasses `files.includes`. It implied protection it never provided, so it is removed. The generator now pipes its output through `biome format --stdin-file-path` instead, making the emitted file conformant by construction. The hook has nothing left to change, and the check compares like with like. A formatter failure throws rather than emitting unformatted output, since falling back silently would reopen the same loop. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- biome.json | 1 - scripts/generate-v2-cli-api.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/biome.json b/biome.json index 31b2c99cacb..9249402d969 100644 --- a/biome.json +++ b/biome.json @@ -32,7 +32,6 @@ "!**/.venv", "!**/uploads", "!**/apps/sim/lib/execution/sandbox/bundles/*.cjs", - "!**/packages/sim-cli/src/generated", "!**/test-results", "!**/playwright-report" ] diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index 991a39b52ad..17aa0db715f 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -27,6 +27,7 @@ * bun run scripts/generate-v2-cli-api.ts --check-openapi */ +import { spawnSync } from 'node:child_process' import { readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' import { z } from 'zod' @@ -290,6 +291,35 @@ function checkOpenApi(operations: Operation[]): string[] { return problems } +/** + * Runs the emitted source through Biome so the generated file is a fixed point + * of the repo's formatter. + * + * Without this the file is rewritten on the way into a commit: lint-staged runs + * `biome check --write` on explicit paths, which bypasses the `files.includes` + * exclusion in biome.json. The result was a generated file that no longer + * matched its generator, so `--check` failed in CI complaining about contract + * drift that had not happened. Formatting here means the hook has nothing left + * to change. + */ +function format(source: string): string { + const result = spawnSync( + path.join(ROOT, 'node_modules/.bin/biome'), + ['format', `--stdin-file-path=${OUTPUT}`], + { input: source, encoding: 'utf8' } + ) + + if (result.status !== 0 || !result.stdout) { + // Fail loudly: silently emitting unformatted output would reintroduce the + // exact hook-rewrites-generated-file loop this exists to close. + throw new Error( + `biome failed to format the generated output (status ${result.status}): ${result.stderr ?? ''}` + ) + } + + return result.stdout +} + async function main() { const args = new Set(process.argv.slice(2)) const operations = await collectOperations() @@ -308,7 +338,7 @@ async function main() { return } - const generated = render(operations) + const generated = format(render(operations)) if (args.has('--check')) { let current = '' From 6af4fdb8aae42f5900a5404c92878fccf9492559 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 15:56:15 -0700 Subject: [PATCH 009/159] fix(cli-auth): wait for the workspace list before allowing approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker fell back to "No workspace (personal key)" while the workspace query was in flight, and Connect stayed live through that window. A fast click approved a personal key with no default workspace — when the same click a moment later would have issued a workspace-scoped key. The fallback read as an answer rather than a pending state, so the card could promise one outcome and deliver another. Connect is now disabled until the list resolves, the trigger shows a loading label (a placeholder would not show, since the fallback always counts as a selection), and the explanatory line no longer asserts the personal-key outcome before it is known. Failure is treated as degraded rather than fatal: the picker disables but Connect stays enabled and the copy says a personal key will be issued, so a transient list failure cannot strand a waiting terminal. Tests cover the pending, loaded, admin-binding, and error states; the two loading assertions fail against the previous implementation. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- apps/sim/app/cli/auth/cli-auth-view.test.tsx | 138 +++++++++++++++++++ apps/sim/app/cli/auth/cli-auth-view.tsx | 29 +++- 2 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 apps/sim/app/cli/auth/cli-auth-view.test.tsx diff --git a/apps/sim/app/cli/auth/cli-auth-view.test.tsx b/apps/sim/app/cli/auth/cli-auth-view.test.tsx new file mode 100644 index 00000000000..01d908daf05 --- /dev/null +++ b/apps/sim/app/cli/auth/cli-auth-view.test.tsx @@ -0,0 +1,138 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockApprove, mockUseWorkspaces, mockPush } = vi.hoisted(() => ({ + mockApprove: vi.fn(), + mockUseWorkspaces: vi.fn(), + mockPush: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})) + +vi.mock('nuqs', () => ({ + useQueryStates: () => [ + { + request: 'a'.repeat(43), + challenge: 'b'.repeat(43), + pairing: 'ABCD-2345', + scope: 'platform', + workspace: null, + }, + ], +})) + +vi.mock('@/hooks/queries/cli-auth', () => ({ + useApproveCliAuth: () => ({ + mutate: mockApprove, + isPending: false, + isSuccess: false, + isError: false, + error: null, + }), +})) + +vi.mock('@/hooks/queries/workspace', () => ({ + useWorkspacesWithMetadata: mockUseWorkspaces, +})) + +import { CliAuthView } from '@/app/cli/auth/cli-auth-view' + +let container: HTMLDivElement +let root: Root + +function render() { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root.render() + }) +} + +/** The primary CTA is the only button whose label mentions connecting. */ +function connectButton(): HTMLButtonElement { + const buttons = [...container.querySelectorAll('button')] as HTMLButtonElement[] + const button = buttons.find((b) => /connect/i.test(b.textContent ?? '')) + if (!button) throw new Error('Connect button not found') + return button +} + +const LOADED = { + isPending: false, + isError: false, + data: { + workspaces: [ + { id: 'ws_admin', name: 'Acme', permissions: 'admin' }, + { id: 'ws_member', name: 'Other', permissions: 'write' }, + ], + lastActiveWorkspaceId: 'ws_admin', + }, +} + +describe('CliAuthView workspace loading', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('blocks Connect until the workspace list resolves', () => { + // The regression: while pending, the picker falls back to the personal + // option, so an early click approved a personal key when the same click a + // moment later would have bound the key to the user's workspace. + mockUseWorkspaces.mockReturnValue({ isPending: true, isError: false, data: undefined }) + render() + + expect(connectButton().disabled).toBe(true) + expect(container.textContent).toContain('Loading workspaces') + expect(container.textContent).not.toContain('No workspace (personal key)') + }) + + it('does not present the personal-key wording as the answer while loading', () => { + mockUseWorkspaces.mockReturnValue({ isPending: true, isError: false, data: undefined }) + render() + + expect(container.textContent).toContain('Checking which workspaces') + expect(container.textContent).not.toContain('Issues a personal key') + }) + + it('enables Connect and preselects the last active workspace once loaded', () => { + mockUseWorkspaces.mockReturnValue(LOADED) + render() + + expect(connectButton().disabled).toBe(false) + expect(container.textContent).toContain('Acme') + expect(container.textContent).toContain('only reach Acme') + }) + + it('binds the key to the workspace when the approver is an admin', () => { + mockUseWorkspaces.mockReturnValue(LOADED) + render() + act(() => { + connectButton().click() + }) + + expect(mockApprove).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'platform', + workspaceId: 'ws_admin', + bindKeyToWorkspace: true, + }), + expect.anything() + ) + }) + + it('still lets the user connect when the workspace list fails', () => { + // A personal key is degraded but usable; blocking entirely would strand a + // terminal on a transient list failure. + mockUseWorkspaces.mockReturnValue({ isPending: false, isError: true, data: undefined }) + render() + + expect(connectButton().disabled).toBe(false) + expect(container.textContent).toContain('Could not load your workspaces') + }) +}) diff --git a/apps/sim/app/cli/auth/cli-auth-view.tsx b/apps/sim/app/cli/auth/cli-auth-view.tsx index d344b216797..74f53ec5019 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.tsx @@ -59,6 +59,18 @@ export function CliAuthView() { const { request } = resolution + /** + * Approval must wait for the workspace list. + * + * Until it arrives there is no selection to show, and the fallback would read + * as "No workspace (personal key)" — a real answer, not a pending one. Leaving + * Connect live through that window let a fast click approve a personal key + * with no default workspace, when a moment later the same click would have + * bound the key to the user's workspace. Blocking is the only way the card + * can promise what it is about to do. + */ + const loadingWorkspaces = isPlatform && workspaces.isPending + // The terminal's suggestion, then the user's last active workspace. Derived at // render rather than synced into state through an effect, so the first paint // after the list loads already shows the right row. @@ -91,7 +103,11 @@ export function CliAuthView() { options={options} value={workspaceId ?? PERSONAL_VALUE} onChange={setSelected} - disabled={workspaces.isLoading} + disabled={loadingWorkspaces || workspaces.isError} + // A placeholder only shows when nothing is selected, and the + // fallback value always counts as a selection — so the loading + // state has to override the rendered label outright. + displayLabel={loadingWorkspaces ? 'Loading workspaces…' : undefined} placeholder='Select a workspace' searchable={options.length > 8} searchPlaceholder='Search workspaces' @@ -99,15 +115,20 @@ export function CliAuthView() { dropdownWidth='trigger' />

- {bindsToWorkspace - ? `Issues a key that can only reach ${chosen.name}.` - : 'Issues a personal key tied to your account, defaulting to this workspace. Workspace-scoped keys need admin.'} + {loadingWorkspaces + ? 'Checking which workspaces you can issue a key for…' + : workspaces.isError + ? 'Could not load your workspaces. Connecting still works and issues a personal key; reload to pick a default workspace.' + : bindsToWorkspace + ? `Issues a key that can only reach ${chosen.name}.` + : 'Issues a personal key tied to your account, defaulting to this workspace. Workspace-scoped keys need admin.'}

)} approve.mutate( From 5d3785a350d2ca89f6f9cf477f0fd7de1a15f21e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 16:24:45 -0700 Subject: [PATCH 010/159] fix(cli-auth): name minted keys by timestamp, not date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second login on the same day failed with `A workspace API key named "CLI (2026-07-30)" already exists` — after the user had already approved in the browser, so the whole handoff was wasted and there was no way to complete it without renaming the existing key. Key names are unique per owner, so the name has to be unique per login. Now `CLI (2026-07-30 15:42:07Z)`: second precision, UTC so it is unambiguous in a shared workspace key list and sorts chronologically. The comment claiming a same-day collision was desirable (so logins would reuse one key) was wrong — nothing reuses the key, the mint just fails. A collision at second precision now means something genuinely unexpected, so it is still surfaced rather than retried under a suffixed name. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- apps/sim/app/api/cli/auth/poll/route.test.ts | 7 ++++++- apps/sim/app/api/cli/auth/poll/route.ts | 19 +++++++++++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/cli/auth/poll/route.test.ts b/apps/sim/app/api/cli/auth/poll/route.test.ts index 8c762b4845e..81709bd411b 100644 --- a/apps/sim/app/api/cli/auth/poll/route.test.ts +++ b/apps/sim/app/api/cli/auth/poll/route.test.ts @@ -99,7 +99,12 @@ describe('POST /api/cli/auth/poll', () => { workspaceId: null, workspaceBound: false, }) - expect(mockGenerateCopilotApiKey).toHaveBeenCalledWith('user-1', expect.stringMatching(/^CLI /)) + // Second precision, not day: a date-only name made the second login of the + // day fail after the user had already approved in the browser. + expect(mockGenerateCopilotApiKey).toHaveBeenCalledWith( + 'user-1', + expect.stringMatching(/^CLI \(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}Z\)$/) + ) expect(mockCompleteApproval).toHaveBeenCalledWith(REQUEST) expect(mockReleaseMint).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/cli/auth/poll/route.ts b/apps/sim/app/api/cli/auth/poll/route.ts index 99bda7fa9a3..c3e6e8c00c3 100644 --- a/apps/sim/app/api/cli/auth/poll/route.ts +++ b/apps/sim/app/api/cli/auth/poll/route.ts @@ -28,18 +28,25 @@ const POLL_RATE_LIMIT: TokenBucketConfig = { refillIntervalMs: 60_000, } -/** Keys are named for the day they were issued, matching what the CLI prints. */ +/** + * Names a minted key for the instant it was issued, e.g. `CLI (2026-07-30 + * 15:42:07Z)`. + * + * Second precision, not day: key names are unique per owner, so a date-only + * name made the second login of the day fail outright with "a key named … + * already exists" — after the user had already approved in the browser. UTC so + * the name is unambiguous in a shared workspace list and sorts chronologically. + */ function cliKeyName(): string { - return `CLI (${new Date().toISOString().slice(0, 10)})` + return `CLI (${new Date().toISOString().slice(0, 19).replace('T', ' ')}Z)` } /** * Mints from the key space the approval recorded. * - * A name collision is reported as a conflict rather than retried under a - * generated name: two logins on the same day from the same terminal should - * reuse the existing key, and silently accumulating `CLI (date) (2)` rows - * would hide that. + * A name collision is still surfaced rather than retried under a suffixed name: + * with second precision it means something genuinely unexpected, and silently + * accumulating near-identical rows would hide it. */ async function mintForGrant( grant: ApprovalGrant From d8021bf02fa133419674aab5ea64fdedd2a1ebd0 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 16:27:32 -0700 Subject: [PATCH 011/159] feat(docs): validate OpenAPI specs against the Zod contracts in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The specs in apps/docs are hand-authored because they carry what Zod never defines — error envelopes, status codes, prose, examples — so they can't be generated; check:openapi validates them instead: - spec integrity: $refs resolve, operationIds unique, 2xx documented, no orphaned component schemas - v2 conventions: every /api/v2 operation documents 401 + 429 and every 4xx/5xx resolves to the canonical { error: { code, message } } envelope - contract cross-check: contracts are auto-discovered from lib/api/contracts/v2 (each carries its method + path); doc<->contract coverage both ways, query/body/response field diffs via z.toJSONSchema - examples: documented request/response examples must parse with the matching contract's actual Zod schemas First run caught real drift, fixed here: 16 stale orphaned schemas in the core spec, the v2 billing ops referencing v1-shaped error components, deploy/rollback examples missing the required nullable lifecycle keys, CreateTableBody missing folderId, a legacy-grammar delete-rows example, and four knowledge document ops missing their required workspaceId query param. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- .github/workflows/test-build.yml | 3 + apps/docs/openapi-core.json | 1082 +++------------------------ apps/docs/openapi-v2-knowledge.json | 52 +- apps/docs/openapi-v2-tables.json | 12 +- apps/docs/openapi-v2-workflows.json | 12 +- package.json | 1 + scripts/check-openapi-specs.ts | 419 +++++++++++ 7 files changed, 594 insertions(+), 987 deletions(-) create mode 100644 scripts/check-openapi-specs.ts diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 262386b6922..6140dbd3ae9 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -123,6 +123,9 @@ jobs: - name: API contract boundary audit run: bun run check:api-validation:strict + - name: OpenAPI spec validation + run: bun run check:openapi + - name: Desktop bridge contract audit run: bun run check:desktop-bridge diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index 1e7e62a7a84..d5d3cccd20e 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -1109,16 +1109,16 @@ } }, "400": { - "$ref": "#/components/responses/BadRequest" + "$ref": "#/components/responses/V2BadRequest" }, "401": { - "$ref": "#/components/responses/Unauthorized" + "$ref": "#/components/responses/V2Unauthorized" }, "403": { - "$ref": "#/components/responses/Forbidden" + "$ref": "#/components/responses/V2Forbidden" }, "429": { - "$ref": "#/components/responses/RateLimited" + "$ref": "#/components/responses/V2RateLimited" } } } @@ -1260,16 +1260,16 @@ } }, "400": { - "$ref": "#/components/responses/BadRequest" + "$ref": "#/components/responses/V2BadRequest" }, "401": { - "$ref": "#/components/responses/Unauthorized" + "$ref": "#/components/responses/V2Unauthorized" }, "403": { - "$ref": "#/components/responses/Forbidden" + "$ref": "#/components/responses/V2Forbidden" }, "429": { - "$ref": "#/components/responses/RateLimited" + "$ref": "#/components/responses/V2RateLimited" } } } @@ -1316,306 +1316,6 @@ } }, "schemas": { - "ColumnDefinition": { - "type": "object", - "description": "Definition of a table column including its type and constraints.", - "required": ["name", "type"], - "properties": { - "name": { - "type": "string", - "description": "Column name. Must start with a letter or underscore.", - "example": "email", - "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" - }, - "type": { - "type": "string", - "enum": ["string", "number", "boolean", "date", "json"], - "description": "Data type of the column." - }, - "required": { - "type": "boolean", - "description": "Whether the column requires a value on insert.", - "default": false - }, - "unique": { - "type": "boolean", - "description": "Whether values in this column must be unique across all rows.", - "default": false - } - } - }, - "Table": { - "type": "object", - "description": "A user-defined table with a typed schema.", - "properties": { - "id": { - "type": "string", - "description": "Unique table identifier.", - "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" - }, - "name": { - "type": "string", - "description": "Table name.", - "example": "contacts" - }, - "description": { - "type": "string", - "description": "Optional description of the table.", - "example": "Customer contact records" - }, - "schema": { - "type": "object", - "description": "Table schema definition.", - "properties": { - "columns": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ColumnDefinition" - }, - "description": "Array of column definitions for the table." - } - } - }, - "rowCount": { - "type": "integer", - "description": "Current number of rows in the table." - }, - "maxRows": { - "type": "integer", - "description": "Maximum rows allowed by the current billing plan." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the table was created." - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the table was last modified." - } - } - }, - "TableRow": { - "type": "object", - "description": "A single row in a table.", - "properties": { - "id": { - "type": "string", - "description": "Unique row identifier.", - "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" - }, - "data": { - "type": "object", - "additionalProperties": true, - "description": "Row data as key-value pairs matching the table schema." - }, - "position": { - "type": "integer", - "description": "Row's position/order in the table." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the row was created." - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the row was last modified." - } - } - }, - "WorkflowSummary": { - "type": "object", - "description": "Summary representation of a workflow returned in list operations.", - "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier.", - "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" - }, - "name": { - "type": "string", - "description": "Human-readable workflow name.", - "example": "Customer Support Agent" - }, - "description": { - "type": "string", - "nullable": true, - "description": "Optional description of what the workflow does.", - "example": "Routes incoming support tickets and drafts responses" - }, - "folderId": { - "type": "string", - "nullable": true, - "description": "The folder this workflow belongs to. null if at the workspace root.", - "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" - }, - "workspaceId": { - "type": "string", - "description": "The workspace this workflow belongs to.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" - }, - "isDeployed": { - "type": "boolean", - "description": "Whether the workflow is currently deployed and available for API execution.", - "example": true - }, - "deployedAt": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "ISO 8601 timestamp of the most recent deployment. null if never deployed.", - "example": "2025-06-15T10:30:00Z" - }, - "runCount": { - "type": "integer", - "description": "Total number of times this workflow has been executed.", - "example": 142 - }, - "lastRunAt": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "ISO 8601 timestamp of the most recent execution. null if never run.", - "example": "2025-06-20T14:15:22Z" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the workflow was created.", - "example": "2025-01-10T09:00:00Z" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the workflow was last modified.", - "example": "2025-06-18T16:45:00Z" - } - } - }, - "WorkflowDetail": { - "type": "object", - "description": "Full workflow representation including input field definitions and configuration.", - "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier.", - "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" - }, - "name": { - "type": "string", - "description": "Human-readable workflow name.", - "example": "Customer Support Agent" - }, - "description": { - "type": "string", - "nullable": true, - "description": "Optional description of what the workflow does.", - "example": "Routes incoming support tickets and drafts responses" - }, - "folderId": { - "type": "string", - "nullable": true, - "description": "The folder this workflow belongs to. null if at the workspace root.", - "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" - }, - "workspaceId": { - "type": "string", - "description": "The workspace this workflow belongs to.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" - }, - "isDeployed": { - "type": "boolean", - "description": "Whether the workflow is currently deployed and available for API execution.", - "example": true - }, - "deployedAt": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "ISO 8601 timestamp of the most recent deployment. null if never deployed.", - "example": "2025-06-15T10:30:00Z" - }, - "runCount": { - "type": "integer", - "description": "Total number of times this workflow has been executed.", - "example": 142 - }, - "lastRunAt": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "ISO 8601 timestamp of the most recent execution. null if never run.", - "example": "2025-06-20T14:15:22Z" - }, - "variables": { - "type": "object", - "description": "Workflow-level variables and their current values.", - "example": {} - }, - "inputs": { - "type": "object", - "description": "The workflow's input field definitions. Use these to construct the input object when executing the workflow.", - "properties": { - "fields": { - "type": "object", - "description": "Map of field names to their type definitions and configuration.", - "additionalProperties": true, - "example": {} - } - } - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the workflow was created.", - "example": "2025-01-10T09:00:00Z" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the workflow was last modified.", - "example": "2025-06-18T16:45:00Z" - } - } - }, - "WorkflowDeployment": { - "type": "object", - "description": "Deployment state of a workflow after a deploy, undeploy, or rollback operation.", - "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier.", - "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" - }, - "isDeployed": { - "type": "boolean", - "description": "Whether the workflow is deployed and available for API execution after the operation.", - "example": true - }, - "deployedAt": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "ISO 8601 timestamp of the active deployment. null after an undeploy.", - "example": "2026-06-12T10:30:00Z" - }, - "version": { - "type": "integer", - "description": "The deployment version that is now active. Omitted for undeploy.", - "example": 4 - }, - "warnings": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Non-fatal warnings. Present when trigger, schedule, or MCP side-effect sync is still in progress or needs a redeploy." - } - } - }, "ExecutionResult": { "type": "object", "description": "Result of a synchronous workflow execution.", @@ -1706,230 +1406,6 @@ } } }, - "LogEntry": { - "type": "object", - "description": "Summary of a single workflow execution log entry.", - "properties": { - "id": { - "type": "string", - "description": "Unique log entry identifier.", - "example": "log_7x8y9z0a1b" - }, - "workflowId": { - "type": "string", - "description": "The workflow that was executed.", - "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" - }, - "executionId": { - "type": "string", - "description": "Unique execution identifier for this run.", - "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" - }, - "level": { - "type": "string", - "description": "Log severity. info for successful executions, error for failures.", - "example": "info" - }, - "trigger": { - "type": "string", - "description": "How the execution was triggered (e.g., api, manual, webhook, schedule, chat).", - "example": "api" - }, - "startedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when execution started.", - "example": "2025-06-20T14:15:22Z" - }, - "endedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when execution completed.", - "example": "2025-06-20T14:15:23Z" - }, - "totalDurationMs": { - "type": "integer", - "description": "Total execution duration in milliseconds.", - "example": 1250 - }, - "cost": { - "type": "object", - "description": "Cost summary for this execution.", - "properties": { - "total": { - "type": "number", - "description": "Total cost of this execution in USD.", - "example": 0.0032 - } - } - }, - "files": { - "type": "object", - "nullable": true, - "description": "File outputs produced during execution. null if no files were generated.", - "example": null - } - } - }, - "LogDetail": { - "type": "object", - "description": "Detailed log entry with full execution data, workflow metadata, and cost breakdown.", - "properties": { - "id": { - "type": "string", - "description": "Unique log entry identifier.", - "example": "log_7x8y9z0a1b" - }, - "workflowId": { - "type": "string", - "description": "The workflow that was executed.", - "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" - }, - "executionId": { - "type": "string", - "description": "Unique execution identifier for this run.", - "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" - }, - "level": { - "type": "string", - "description": "Log severity. info for successful executions, error for failures.", - "example": "info" - }, - "trigger": { - "type": "string", - "description": "How the execution was triggered (e.g., api, manual, webhook, schedule, chat).", - "example": "api" - }, - "startedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when execution started.", - "example": "2025-06-20T14:15:22Z" - }, - "endedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when execution completed.", - "example": "2025-06-20T14:15:23Z" - }, - "totalDurationMs": { - "type": "integer", - "description": "Total execution duration in milliseconds.", - "example": 1250 - }, - "workflow": { - "type": "object", - "description": "Summary metadata about the workflow at the time of execution.", - "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier.", - "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" - }, - "name": { - "type": "string", - "description": "Workflow name at the time of execution.", - "example": "Customer Support Agent" - }, - "description": { - "type": "string", - "nullable": true, - "description": "Workflow description at the time of execution.", - "example": "Routes incoming support tickets and drafts responses" - } - } - }, - "executionData": { - "type": "object", - "description": "Detailed execution data including block-level traces and final output.", - "properties": { - "traceSpans": { - "type": "array", - "description": "Block-level execution traces with timing, inputs, and outputs for each block that ran.", - "items": { - "type": "object" - } - }, - "finalOutput": { - "type": "object", - "description": "The workflow's final output after all blocks completed." - } - } - }, - "cost": { - "type": "object", - "description": "Detailed cost breakdown for this execution.", - "properties": { - "total": { - "type": "number", - "description": "Total cost of this execution in USD.", - "example": 0.0032 - }, - "tokens": { - "type": "object", - "description": "Aggregate token usage across all AI model calls in this execution.", - "properties": { - "prompt": { - "type": "integer", - "description": "Total prompt (input) tokens consumed.", - "example": 450 - }, - "completion": { - "type": "integer", - "description": "Total completion (output) tokens generated.", - "example": 120 - }, - "total": { - "type": "integer", - "description": "Total tokens (prompt + completion).", - "example": 570 - } - } - }, - "models": { - "type": "object", - "description": "Per-model cost and token breakdown. Keys are model identifiers (e.g., gpt-4o, claude-sonnet-4-20250514).", - "additionalProperties": { - "type": "object", - "description": "Cost and token details for a specific model.", - "properties": { - "input": { - "type": "number", - "description": "Cost of prompt tokens for this model in USD." - }, - "output": { - "type": "number", - "description": "Cost of completion tokens for this model in USD." - }, - "total": { - "type": "number", - "description": "Total cost for this model in USD." - }, - "tokens": { - "type": "object", - "description": "Token usage for this specific model.", - "properties": { - "prompt": { - "type": "integer", - "description": "Prompt tokens consumed by this model." - }, - "completion": { - "type": "integer", - "description": "Completion tokens generated by this model." - }, - "total": { - "type": "integer", - "description": "Total tokens for this model." - } - } - } - } - } - } - } - } - } - }, "JobStatus": { "type": "object", "description": "Status of an asynchronous job.", @@ -2082,123 +1558,48 @@ "pausePointCount": { "type": "integer", "description": "Total number of pause points recorded for this execution.", - "example": 1 - }, - "resumedCount": { - "type": "integer", - "description": "Number of pause points already resumed.", - "example": 0 - } - } - }, - "cost": { - "type": "object", - "nullable": true, - "description": "Cost summary. Detailed token / model breakdown lives on the /v1/logs detail endpoint.", - "properties": { - "total": { - "type": "number", - "description": "Total cost in USD.", - "example": 0.005 - } - } - }, - "error": { - "type": "string", - "nullable": true, - "description": "Error message. Present only when status is `failed`.", - "example": null - }, - "finalOutput": { - "type": "object", - "nullable": true, - "description": "The workflow's final output. Returned only when ?includeOutput=true AND status is `completed`.", - "example": null - }, - "blockOutputs": { - "type": "object", - "nullable": true, - "description": "Per-block outputs keyed by the selector string. Returned only when `?selectedOutputs` is set.", - "additionalProperties": true, - "example": { - "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration": 60000, - "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.status": "completed" - } - } - } - }, - "AuditLogEntry": { - "type": "object", - "description": "An enterprise audit log entry recording an action taken in the workspace.", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for the audit log entry.", - "example": "audit_2c3d4e5f6g" - }, - "workspaceId": { - "type": "string", - "nullable": true, - "description": "The workspace where the action occurred.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" - }, - "actorId": { - "type": "string", - "nullable": true, - "description": "The user ID of the person who performed the action.", - "example": "user_abc123" - }, - "actorName": { - "type": "string", - "nullable": true, - "description": "Display name of the person who performed the action.", - "example": "Jane Smith" - }, - "actorEmail": { - "type": "string", - "nullable": true, - "description": "Email address of the person who performed the action.", - "example": "jane@example.com" - }, - "action": { - "type": "string", - "description": "The action that was performed (e.g., workflow.created, member.invited).", - "example": "workflow.deployed" - }, - "resourceType": { - "type": "string", - "description": "The type of resource affected (e.g., workflow, workspace, member).", - "example": "workflow" - }, - "resourceId": { - "type": "string", - "nullable": true, - "description": "The unique identifier of the affected resource.", - "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + "example": 1 + }, + "resumedCount": { + "type": "integer", + "description": "Number of pause points already resumed.", + "example": 0 + } + } }, - "resourceName": { - "type": "string", + "cost": { + "type": "object", "nullable": true, - "description": "Display name of the affected resource.", - "example": "Customer Support Agent" + "description": "Cost summary. Detailed token / model breakdown lives on the /v1/logs detail endpoint.", + "properties": { + "total": { + "type": "number", + "description": "Total cost in USD.", + "example": 0.005 + } + } }, - "description": { + "error": { "type": "string", "nullable": true, - "description": "Human-readable description of the action.", - "example": "Deployed workflow Customer Support Agent" + "description": "Error message. Present only when status is `failed`.", + "example": null }, - "metadata": { + "finalOutput": { "type": "object", "nullable": true, - "description": "Additional context about the action.", + "description": "The workflow's final output. Returned only when ?includeOutput=true AND status is `completed`.", "example": null }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the action occurred.", - "example": "2025-06-20T14:15:22Z" + "blockOutputs": { + "type": "object", + "nullable": true, + "description": "Per-block outputs keyed by the selector string. Returned only when `?selectedOutputs` is set.", + "additionalProperties": true, + "example": { + "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration": 60000, + "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.status": "completed" + } } } }, @@ -2248,347 +1649,6 @@ } } }, - "FileMetadata": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique file identifier.", - "example": "wf_V1StGXR8z5jdHi6BmyT91" - }, - "name": { - "type": "string", - "description": "Original filename.", - "example": "data.csv" - }, - "size": { - "type": "integer", - "description": "File size in bytes.", - "example": 1024 - }, - "type": { - "type": "string", - "description": "MIME type of the file.", - "example": "text/csv" - }, - "key": { - "type": "string", - "description": "Storage key for the file.", - "example": "workspace/abc-123/1709571234-xyz-data.csv" - }, - "uploadedBy": { - "type": "string", - "description": "User ID of the uploader." - }, - "uploadedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp of when the file was uploaded." - } - } - }, - "KnowledgeBase": { - "type": "object", - "description": "A knowledge base for storing and searching document embeddings.", - "properties": { - "id": { - "type": "string", - "description": "Unique knowledge base identifier." - }, - "name": { - "type": "string", - "description": "Knowledge base name." - }, - "description": { - "type": "string", - "nullable": true, - "description": "Optional description." - }, - "tokenCount": { - "type": "integer", - "description": "Total token count across all documents." - }, - "embeddingModel": { - "type": "string", - "description": "Embedding model used (e.g. text-embedding-3-small)." - }, - "embeddingDimension": { - "type": "integer", - "description": "Embedding vector dimension." - }, - "chunkingConfig": { - "$ref": "#/components/schemas/ChunkingConfig" - }, - "docCount": { - "type": "integer", - "description": "Number of documents in the knowledge base." - }, - "connectorTypes": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Types of connectors attached to this knowledge base." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the knowledge base was created." - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the knowledge base was last modified." - } - } - }, - "ChunkingConfig": { - "type": "object", - "description": "Configuration for how documents are split into chunks for embedding.", - "properties": { - "maxSize": { - "type": "integer", - "minimum": 100, - "maximum": 4000, - "default": 1024, - "description": "Maximum chunk size in tokens." - }, - "minSize": { - "type": "integer", - "minimum": 1, - "maximum": 2000, - "default": 100, - "description": "Minimum chunk size in characters." - }, - "overlap": { - "type": "integer", - "minimum": 0, - "maximum": 500, - "default": 200, - "description": "Overlap between chunks in tokens." - } - } - }, - "KnowledgeDocument": { - "type": "object", - "description": "A document in a knowledge base.", - "properties": { - "id": { - "type": "string", - "description": "Unique document identifier." - }, - "knowledgeBaseId": { - "type": "string", - "description": "Knowledge base this document belongs to." - }, - "filename": { - "type": "string", - "description": "Original filename." - }, - "fileSize": { - "type": "integer", - "description": "File size in bytes." - }, - "mimeType": { - "type": "string", - "description": "MIME type of the file." - }, - "processingStatus": { - "type": "string", - "enum": ["pending", "processing", "completed", "failed"], - "description": "Current processing status." - }, - "chunkCount": { - "type": "integer", - "description": "Number of chunks created from this document." - }, - "tokenCount": { - "type": "integer", - "description": "Total token count." - }, - "characterCount": { - "type": "integer", - "description": "Total character count." - }, - "enabled": { - "type": "boolean", - "description": "Whether the document is enabled for search." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the document was uploaded." - } - } - }, - "KnowledgeDocumentDetail": { - "type": "object", - "description": "Detailed document information including processing and connector details.", - "properties": { - "id": { - "type": "string", - "description": "Unique document identifier." - }, - "knowledgeBaseId": { - "type": "string", - "description": "Knowledge base this document belongs to." - }, - "filename": { - "type": "string", - "description": "Original filename." - }, - "fileSize": { - "type": "integer", - "description": "File size in bytes." - }, - "mimeType": { - "type": "string", - "description": "MIME type of the file." - }, - "processingStatus": { - "type": "string", - "enum": ["pending", "processing", "completed", "failed"], - "description": "Current processing status." - }, - "processingError": { - "type": "string", - "nullable": true, - "description": "Error message if processing failed." - }, - "processingStartedAt": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "When processing started." - }, - "processingCompletedAt": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "When processing completed." - }, - "chunkCount": { - "type": "integer", - "description": "Number of chunks created." - }, - "tokenCount": { - "type": "integer", - "description": "Total token count." - }, - "characterCount": { - "type": "integer", - "description": "Total character count." - }, - "enabled": { - "type": "boolean", - "description": "Whether the document is enabled for search." - }, - "connectorId": { - "type": "string", - "nullable": true, - "description": "Connector ID if sourced from an external connector." - }, - "connectorType": { - "type": "string", - "nullable": true, - "description": "Connector type (e.g. google-drive, notion)." - }, - "sourceUrl": { - "type": "string", - "nullable": true, - "description": "Original source URL for connector-sourced documents." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the document was uploaded." - } - } - }, - "SearchResult": { - "type": "object", - "description": "A single search result from knowledge base vector search.", - "properties": { - "documentId": { - "type": "string", - "description": "ID of the source document." - }, - "documentName": { - "type": "string", - "description": "Filename of the source document." - }, - "sourceUrl": { - "type": "string", - "nullable": true, - "description": "URL to the original source document for connector-synced documents (e.g., a Confluence page, Google Doc, or Notion page). Null for documents without an external source." - }, - "content": { - "type": "string", - "description": "The matched chunk content." - }, - "chunkIndex": { - "type": "integer", - "description": "Index of the chunk within the document." - }, - "metadata": { - "type": "object", - "description": "Tag metadata associated with the chunk (display names mapped to values)." - }, - "similarity": { - "type": "number", - "minimum": 0, - "maximum": 1, - "description": "Similarity score (0-1, where 1 is most similar)." - } - } - }, - "TagFilter": { - "type": "object", - "description": "A tag-based filter for knowledge base search.", - "required": ["tagName", "value"], - "properties": { - "tagName": { - "type": "string", - "description": "Display name of the tag to filter by." - }, - "fieldType": { - "type": "string", - "enum": ["text", "number", "date", "boolean"], - "default": "text", - "description": "Data type of the tag field." - }, - "operator": { - "type": "string", - "default": "eq", - "description": "Comparison operator (e.g. eq, neq, gt, lt, gte, lte, contains, between)." - }, - "value": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ], - "description": "Value to filter by." - }, - "valueTo": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "number" - } - ], - "description": "Upper bound value for 'between' operator." - } - } - }, "PausedExecutionSummary": { "type": "object", "description": "Summary of a paused workflow execution.", @@ -2928,6 +1988,28 @@ } } } + }, + "V2Error": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable machine-readable code, e.g. `BAD_REQUEST`, `FORBIDDEN`, `RATE_LIMITED`." + }, + "message": { + "type": "string" + }, + "details": { + "description": "Optional structured context (e.g. per-field validation issues)." + } + } + } + } } }, "responses": { @@ -3073,6 +2155,46 @@ } } } + }, + "V2BadRequest": { + "description": "Invalid request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2Unauthorized": { + "description": "Missing or invalid API key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2Forbidden": { + "description": "The credential is not authorized for the requested resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2RateLimited": { + "description": "Rate limit exceeded; retry after the window resets.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } } } } diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 5c43fd27ff7..ef6de06096b 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -564,6 +564,16 @@ "enum": ["asc", "desc"], "default": "desc" } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace that owns the knowledge base." } ], "responses": { @@ -727,7 +737,19 @@ "500": { "$ref": "#/components/responses/InternalError" } - } + }, + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace that owns the knowledge base." + } + ] } }, "/api/v2/knowledge/{id}/documents/{documentId}": { @@ -791,7 +813,19 @@ "500": { "$ref": "#/components/responses/InternalError" } - } + }, + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace that owns the knowledge base." + } + ] }, "delete": { "operationId": "deleteKnowledgeDocument", @@ -845,7 +879,19 @@ "500": { "$ref": "#/components/responses/InternalError" } - } + }, + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace that owns the knowledge base." + } + ] } } }, diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 3560bf37155..acb09fa38df 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -950,7 +950,13 @@ "value": { "workspaceId": "YOUR_WORKSPACE_ID", "filter": { - "status": "archived" + "all": [ + { + "field": "status", + "op": "eq", + "value": "archived" + } + ] } } } @@ -1809,6 +1815,10 @@ } } } + }, + "folderId": { + "type": ["string", "null"], + "description": "Folder to create the table in. Omitted or null creates it at the workspace root." } } }, diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 4816d764806..79a91ab0eb6 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -334,7 +334,9 @@ "isDeployed": true, "deployedAt": "2026-06-12T10:30:00.000Z", "version": 4, - "warnings": [] + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": null } } } @@ -411,7 +413,9 @@ "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "isDeployed": false, "deployedAt": null, - "warnings": [] + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": null } } } @@ -508,7 +512,9 @@ "isDeployed": true, "deployedAt": "2026-06-12T10:30:00.000Z", "version": 3, - "warnings": [] + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": null } } } diff --git a/package.json b/package.json index fec0621c543..57fdd73e731 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "check": "turbo run format:check", "check:boundaries": "bun run scripts/check-monorepo-boundaries.ts", "check:api-validation": "bun run scripts/check-api-validation-contracts.ts --check", + "check:openapi": "bun run scripts/check-openapi-specs.ts", "check:api-validation:strict": "bun run scripts/check-api-validation-contracts.ts --check --enforce-boundary-baseline", "check:realtime-prune": "bun run scripts/check-realtime-prune-graph.ts", "check:zustand-v5": "bun run scripts/check-zustand-v5-selectors.ts", diff --git a/scripts/check-openapi-specs.ts b/scripts/check-openapi-specs.ts new file mode 100644 index 00000000000..87cab72e160 --- /dev/null +++ b/scripts/check-openapi-specs.ts @@ -0,0 +1,419 @@ +#!/usr/bin/env bun +/** + * Validates the hand-authored OpenAPI specs in `apps/docs/` against each other + * and against the runtime Zod contracts in `apps/sim/lib/api/contracts/`. + * + * The Zod contracts are the runtime source of truth for *success* request and + * response shapes, but the specs additionally carry what Zod never defines — + * error envelopes, status codes, prose, and examples — so the specs cannot be + * generated and must instead be checked: + * + * 1. Spec integrity (every file): all `$ref`s resolve, operationIds are + * present and unique, every operation documents a success response, no + * orphaned component schemas. + * 2. v2 conventions (every `/api/v2/` operation): 401 and 429 are documented, + * and every documented 4xx/5xx resolves to the canonical error envelope + * `{ error: { code, message } }`. + * 3. Contract cross-check: every contract exported from + * `lib/api/contracts/v2/*` must be documented, every documented `/api/v2/` + * operation must have a contract, and for each pair the query params, + * body fields, and response fields are diffed via `z.toJSONSchema`. + * 4. Examples: documented request/response examples are parsed with the + * matching contract's actual Zod schemas — a doc example that the runtime + * would reject fails the build. + */ + +import { readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { z } from 'zod' + +const ROOT = path.resolve(import.meta.dir, '..') +const DOCS_DIR = path.join(ROOT, 'apps/docs') +const V2_CONTRACTS_DIR = path.join(ROOT, 'apps/sim/lib/api/contracts/v2') + +const SPEC_FILES = [ + 'openapi-core.json', + 'openapi-v2-logs.json', + 'openapi-v2-workflows.json', + 'openapi-v2-tables.json', + 'openapi-v2-knowledge.json', + 'openapi-v2-files-audit.json', +] + +/** Extra non-v2 contracts that are documented in the core spec. */ +const EXTRA_CONTRACT_MODULES = [path.join(ROOT, 'apps/sim/lib/api/contracts/usage-limits.ts')] + +type Json = Record +const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete']) + +const errors: string[] = [] +const fail = (spec: string, msg: string) => errors.push(`${spec}: ${msg}`) + +interface ContractLike { + method: string + path: string + params?: z.ZodType + query?: z.ZodType + body?: z.ZodType + response?: { mode: string; schema?: z.ZodType } +} + +function isContract(value: unknown): value is ContractLike { + return ( + !!value && + typeof value === 'object' && + typeof (value as ContractLike).method === 'string' && + typeof (value as ContractLike).path === 'string' && + typeof (value as ContractLike).response === 'object' + ) +} + +/** `[tableId]` (contract) → `{tableId}` (OpenAPI). */ +const contractKey = (c: ContractLike) => + `${c.method.toUpperCase()} ${c.path.replace(/\[([^\]]+)\]/g, '{$1}')}` + +async function loadContracts(): Promise> { + const registry = new Map() + const files = readdirSync(V2_CONTRACTS_DIR) + .filter((f) => f.endsWith('.ts') && f !== 'shared.ts') + .map((f) => path.join(V2_CONTRACTS_DIR, f)) + for (const file of [...files, ...EXTRA_CONTRACT_MODULES]) { + const mod = (await import(file)) as Record + for (const [name, value] of Object.entries(mod)) { + if (!isContract(value)) continue + const key = contractKey(value) + const existing = registry.get(key) + if (existing) { + // A route may expose narrowing variants of one operation (e.g. the + // batch-create alias) — keep the first, they share the wire. + continue + } + registry.set(key, { name, contract: value }) + } + } + return registry +} + +function resolveRef(ref: string, spec: Json): unknown { + let current: unknown = spec + for (const part of ref.replace('#/', '').split('/')) { + if (!current || typeof current !== 'object') return undefined + current = (current as Json)[part] + } + return current +} + +/** Follow at most one level of `$ref` chains until a concrete node. */ +function deref(node: unknown, spec: Json): unknown { + let current = node + for (let i = 0; i < 8; i++) { + if (current && typeof current === 'object' && typeof (current as Json).$ref === 'string') { + current = resolveRef((current as Json).$ref as string, spec) + } else { + return current + } + } + return current +} + +function walkRefs(node: unknown, visit: (ref: string) => void): void { + if (Array.isArray(node)) { + for (const item of node) walkRefs(item, visit) + } else if (node && typeof node === 'object') { + for (const [key, value] of Object.entries(node)) { + if (key === '$ref' && typeof value === 'string') visit(value) + else walkRefs(value, visit) + } + } +} + +/** + * Top-level property names of a documented JSON schema, unioning `oneOf` / + * `anyOf` / `allOf` variants. Returns `null` when the schema is opaque + * (no `properties` anywhere), in which case comparison is skipped. + */ +function docPropertyNames(schema: unknown, spec: Json): Set | null { + const node = deref(schema, spec) + if (!node || typeof node !== 'object') return null + const record = node as Json + const variants = (record.oneOf ?? record.anyOf ?? record.allOf) as unknown[] | undefined + if (variants) { + const names = new Set() + let sawAny = false + for (const variant of variants) { + const sub = docPropertyNames(variant, spec) + if (sub) { + sawAny = true + for (const n of sub) names.add(n) + } + } + return sawAny ? names : null + } + if (record.properties && typeof record.properties === 'object') { + return new Set(Object.keys(record.properties as Json)) + } + return null +} + +/** Same union logic over the Zod-derived JSON schema (no `$ref`s inside). */ +function zodPropertyNames(schema: unknown): Set | null { + return docPropertyNames(schema, {} as Json) +} + +function toJsonSchema(schema: z.ZodType, io: 'input' | 'output'): Json | null { + try { + return z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as Json + } catch { + return null + } +} + +interface Operation { + specFile: string + path: string + method: string + op: Json + spec: Json +} + +function collectOperations(specFile: string, spec: Json): Operation[] { + const ops: Operation[] = [] + for (const [p, methods] of Object.entries((spec.paths as Json) ?? {})) { + if (!methods || typeof methods !== 'object') continue + for (const [method, op] of Object.entries(methods as Json)) { + if (!HTTP_METHODS.has(method)) continue + ops.push({ specFile, path: p, method, op: op as Json, spec }) + } + } + return ops +} + +function checkIntegrity(specFile: string, spec: Json, ops: Operation[]): void { + walkRefs(spec, (ref) => { + if (resolveRef(ref, spec) === undefined) fail(specFile, `unresolved $ref ${ref}`) + }) + + const seenIds = new Set() + for (const { path: p, method, op } of ops) { + const label = `${method.toUpperCase()} ${p}` + const id = op.operationId + if (typeof id !== 'string' || !id) { + fail(specFile, `${label}: missing operationId`) + } else if (seenIds.has(id)) { + fail(specFile, `${label}: duplicate operationId "${id}"`) + } else { + seenIds.add(id) + } + const responses = (op.responses as Json) ?? {} + if (!Object.keys(responses).some((code) => code.startsWith('2'))) { + fail(specFile, `${label}: no documented 2xx response`) + } + } + + const schemas = ((spec.components as Json)?.schemas as Json) ?? {} + const blobWithout = (name: string) => + JSON.stringify({ + ...spec, + components: { ...(spec.components as Json), schemas: { ...schemas, [name]: null } }, + }) + for (const name of Object.keys(schemas)) { + if (!blobWithout(name).includes(`"#/components/schemas/${name}"`)) { + fail(specFile, `orphaned component schema "${name}" (unreferenced)`) + } + } +} + +function checkV2Conventions(operation: Operation): void { + const { specFile, path: p, method, op, spec } = operation + const label = `${method.toUpperCase()} ${p}` + const responses = (op.responses as Json) ?? {} + + for (const code of ['401', '429']) { + if (!(code in responses)) fail(specFile, `${label}: v2 operation missing ${code} response`) + } + + for (const [code, response] of Object.entries(responses)) { + if (!/^[45]/.test(code)) continue + const resolved = deref(response, spec) as Json | undefined + const schema = deref( + ((resolved?.content as Json)?.['application/json'] as Json)?.schema, + spec + ) as Json | undefined + // A bodyless error (e.g. a bare 413) documents intent without a schema. + if (!schema) continue + const errorProp = deref((schema.properties as Json)?.error, spec) as Json | undefined + const inner = errorProp?.properties as Json | undefined + if (!inner || !('code' in inner) || !('message' in inner)) { + fail( + specFile, + `${label}: ${code} response is not the canonical v2 error envelope { error: { code, message } }` + ) + } + } +} + +function checkQueryParams(operation: Operation, contract: ContractLike, name: string): void { + const { specFile, path: p, method, op, spec } = operation + const label = `${method.toUpperCase()} ${p}` + if (!contract.query) return + const zodSchema = toJsonSchema(contract.query, 'input') + if (!zodSchema?.properties) return + + const docParams = new Map() + for (const raw of (op.parameters as unknown[]) ?? []) { + const param = deref(raw, spec) as Json | undefined + if (param?.in === 'query' && typeof param.name === 'string') docParams.set(param.name, param) + } + + const zodProps = Object.keys(zodSchema.properties as Json) + const zodRequired = new Set((zodSchema.required as string[]) ?? []) + for (const prop of zodProps) { + const doc = docParams.get(prop) + if (!doc) { + fail(specFile, `${label}: query param "${prop}" (${name}) is not documented`) + } else if (Boolean(doc.required) !== zodRequired.has(prop)) { + fail( + specFile, + `${label}: query param "${prop}" required mismatch (contract ${zodRequired.has(prop) ? 'required' : 'optional'}, docs ${doc.required ? 'required' : 'optional'})` + ) + } + } + for (const docName of docParams.keys()) { + if (!zodProps.includes(docName)) { + fail(specFile, `${label}: documented query param "${docName}" does not exist on ${name}`) + } + } +} + +function checkBodyAndResponse(operation: Operation, contract: ContractLike, name: string): void { + const { specFile, path: p, method, op, spec } = operation + const label = `${method.toUpperCase()} ${p}` + + const docBodySchema = ((deref(op.requestBody, spec) as Json)?.content as Json)?.[ + 'application/json' + ] as Json | undefined + if (contract.body && docBodySchema?.schema) { + const zodNames = zodPropertyNames(toJsonSchema(contract.body, 'input')) + const docNames = docPropertyNames(docBodySchema.schema, spec) + if (zodNames && docNames) { + for (const n of zodNames) { + if (!docNames.has(n)) fail(specFile, `${label}: body field "${n}" (${name}) not documented`) + } + for (const n of docNames) { + if (!zodNames.has(n)) { + fail(specFile, `${label}: documented body field "${n}" does not exist on ${name}`) + } + } + } + } + + if (contract.response?.mode === 'json' && contract.response.schema) { + const responses = (op.responses as Json) ?? {} + const successCode = Object.keys(responses).find((code) => code.startsWith('2')) + const docResponse = successCode ? (deref(responses[successCode], spec) as Json) : undefined + const docSchema = ((docResponse?.content as Json)?.['application/json'] as Json)?.schema + if (docSchema) { + const zodNames = zodPropertyNames(toJsonSchema(contract.response.schema, 'output')) + const docNames = docPropertyNames(docSchema, spec) + if (zodNames && docNames) { + for (const n of zodNames) { + if (!docNames.has(n)) { + fail(specFile, `${label}: response field "${n}" (${name}) not documented`) + } + } + for (const n of docNames) { + if (!zodNames.has(n)) { + fail(specFile, `${label}: documented response field "${n}" does not exist on ${name}`) + } + } + } + } + } +} + +function checkExamples(operation: Operation, contract: ContractLike, name: string): void { + const { specFile, path: p, method, op, spec } = operation + const label = `${method.toUpperCase()} ${p}` + + const bodyContent = ((deref(op.requestBody, spec) as Json)?.content as Json)?.[ + 'application/json' + ] as Json | undefined + if (contract.body && bodyContent) { + const candidates: Array<[string, unknown]> = [] + if (bodyContent.example !== undefined) candidates.push(['example', bodyContent.example]) + for (const [exName, ex] of Object.entries((bodyContent.examples as Json) ?? {})) { + candidates.push([exName, (ex as Json).value]) + } + for (const [exName, value] of candidates) { + const parsed = contract.body.safeParse(value) + if (!parsed.success) { + fail( + specFile, + `${label}: request example "${exName}" rejected by ${name}: ${parsed.error.issues[0]?.message}` + ) + } + } + } + + if (contract.response?.mode === 'json' && contract.response.schema) { + const responses = (op.responses as Json) ?? {} + const successCode = Object.keys(responses).find((code) => code.startsWith('2')) + const docResponse = successCode ? (deref(responses[successCode], spec) as Json) : undefined + const content = (docResponse?.content as Json)?.['application/json'] as Json | undefined + if (content?.example !== undefined) { + const parsed = contract.response.schema.safeParse(content.example) + if (!parsed.success) { + const issue = parsed.error.issues[0] + fail( + specFile, + `${label}: response example rejected by ${name} at ${issue?.path.join('.') || ''}: ${issue?.message}` + ) + } + } + } +} + +const registry = await loadContracts() +const documentedKeys = new Set() + +for (const specFile of SPEC_FILES) { + const spec = JSON.parse(readFileSync(path.join(DOCS_DIR, specFile), 'utf8')) as Json + const ops = collectOperations(specFile, spec) + checkIntegrity(specFile, spec, ops) + + for (const operation of ops) { + const key = `${operation.method.toUpperCase()} ${operation.path}` + documentedKeys.add(key) + const isV2 = operation.path.startsWith('/api/v2/') + if (isV2) checkV2Conventions(operation) + + const entry = registry.get(key) + if (!entry) { + // The core spec's execution/HITL surface predates the contract registry; + // only the v2 surface requires a contract for every documented operation. + if (isV2) fail(specFile, `${key}: documented but no contract exports this route`) + continue + } + checkQueryParams(operation, entry.contract, entry.name) + checkBodyAndResponse(operation, entry.contract, entry.name) + checkExamples(operation, entry.contract, entry.name) + } +} + +for (const [key, { name }] of registry) { + if (!key.includes('/api/v2/')) continue + if (!documentedKeys.has(key)) { + errors.push(`registry: ${name} (${key}) is not documented in any OpenAPI spec`) + } +} + +if (errors.length > 0) { + console.error( + `OpenAPI spec validation failed (${errors.length} issue${errors.length === 1 ? '' : 's'}):` + ) + for (const message of errors) console.error(` - ${message}`) + process.exit(1) +} +console.log( + `OpenAPI spec validation passed: ${SPEC_FILES.length} specs, ${documentedKeys.size} operations, ${registry.size} contracts cross-checked.` +) From 9ee409948cfc297a534dfd5fb9a76cd1a9bb50ae Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 16:55:18 -0700 Subject: [PATCH 012/159] fix(docs): recursive field diff in check:openapi + the deep drift it found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mutation test showed the doc<->contract field diff only compared top-level properties, so a typo inside the { data } envelope passed. The diff now descends through matching object properties and array items (both sides must expose a property set — passthrough contracts and prose-only docs end the descent instead of false-positive), with the Zod JSON-schema root doubling as the $defs context. Deep drift it immediately caught, fixed here: select-column config (options/multiple) missing from every tables column schema, AddColumnBody hand-rolling a third column shape (now composed from ColumnInput, with position/workflowGroupId as the per-op extensions the contracts actually admit), chunking strategyOptions undocumented, and the deployment lifecycle fields (activeDeployment/latestDeploymentAttempt) missing from DeploymentState. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- apps/docs/openapi-v2-knowledge.json | 23 +++++ apps/docs/openapi-v2-tables.json | 111 +++++++++++++++------ apps/docs/openapi-v2-workflows.json | 17 +++- scripts/check-openapi-specs.ts | 149 ++++++++++++++++++++++------ 4 files changed, 239 insertions(+), 61 deletions(-) diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index ef6de06096b..677577e8cc5 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -1001,6 +1001,29 @@ "type": "string", "description": "Chunking strategy applied during processing.", "enum": ["auto", "text", "regex", "recursive", "sentence", "token"] + }, + "strategyOptions": { + "type": "object", + "additionalProperties": false, + "description": "Strategy-specific tuning. `pattern`/`strictBoundaries` apply to the `regex` strategy; `separators` to `text`; `recipe` to `recursive`.", + "properties": { + "pattern": { + "type": "string", + "maxLength": 500 + }, + "separators": { + "type": "array", + "items": { + "type": "string" + } + }, + "recipe": { + "enum": ["plain", "markdown", "code"] + }, + "strictBoundaries": { + "type": "boolean" + } + } } } }, diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index acb09fa38df..e461f343b36 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -1648,6 +1648,17 @@ "workflowGroupId": { "type": "string", "description": "Set when the column is the output of a workflow group." + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelectOption" + }, + "description": "Declared options for a `select` column; absent on other types." + }, + "multiple": { + "type": "boolean", + "description": "A `select` column that accepts multiple options per cell." } } }, @@ -1677,6 +1688,21 @@ "type": "boolean", "default": false, "description": "Whether values in this column must be unique across all rows." + }, + "id": { + "type": "string", + "description": "Stable column id. Server-assigned — normally omit." + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelectOption" + }, + "description": "Declared options for a `select` column; absent on other types." + }, + "multiple": { + "type": "boolean", + "description": "A `select` column that accepts multiple options per cell." } } }, @@ -1811,7 +1837,20 @@ "maxItems": 50, "description": "Column definitions. A table must have between 1 and 50 columns.", "items": { - "$ref": "#/components/schemas/ColumnInput" + "allOf": [ + { + "$ref": "#/components/schemas/ColumnInput" + }, + { + "type": "object", + "properties": { + "workflowGroupId": { + "type": "string", + "description": "Advanced: binds the column to a workflow group's output." + } + } + } + ] } } } @@ -1833,38 +1872,22 @@ "description": "The workspace that owns the table." }, "column": { - "type": "object", - "description": "The column definition to add.", - "required": ["name", "type"], - "properties": { - "name": { - "type": "string", - "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", - "maxLength": 50, - "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", - "example": "phone" - }, - "type": { - "type": "string", - "enum": ["string", "number", "boolean", "date", "json"], - "description": "Data type of the column." + "allOf": [ + { + "$ref": "#/components/schemas/ColumnInput" }, - "required": { - "type": "boolean", - "default": false, - "description": "Whether the column requires a value on insert." - }, - "unique": { - "type": "boolean", - "default": false, - "description": "Whether values in this column must be unique across all rows." - }, - "position": { - "type": "integer", - "minimum": 0, - "description": "Zero-based insert position in the column order. Appended at the end when omitted." + { + "type": "object", + "properties": { + "position": { + "type": "integer", + "minimum": 0, + "description": "Zero-based insert position in the column order. Appended at the end when omitted." + } + } } - } + ], + "description": "The column definition to add." } } }, @@ -1906,6 +1929,17 @@ "unique": { "type": "boolean", "description": "Whether values in this column must be unique across all rows." + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelectOption" + }, + "description": "Declared options for a `select` column; absent on other types." + }, + "multiple": { + "type": "boolean", + "description": "A `select` column that accepts multiple options per cell." } } } @@ -2428,6 +2462,21 @@ "description": "Operand. Omit for the `is*` operators. Ranges on `number` columns require numbers, on `date` columns ISO strings (compared as UTC, independent of any session timezone); ranges on `boolean`/`json` columns are rejected." } } + }, + "SelectOption": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "string", + "description": "Stable option id — the value stored in cells." + }, + "name": { + "type": "string", + "maxLength": 100, + "description": "Display name. Filters on select columns accept names (resolved case-insensitively)." + } + } } }, "responses": { diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 79a91ab0eb6..088c75fe278 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -937,7 +937,14 @@ "DeploymentState": { "type": "object", "description": "Base deployment state shared by deploy, undeploy, and rollback results.", - "required": ["id", "isDeployed", "deployedAt", "warnings"], + "required": [ + "id", + "isDeployed", + "deployedAt", + "warnings", + "activeDeployment", + "latestDeploymentAttempt" + ], "properties": { "id": { "type": "string", @@ -961,6 +968,14 @@ "items": { "type": "string" } + }, + "activeDeployment": { + "type": ["object", "null"], + "description": "Summary of the currently live deployment version, or null when none is active." + }, + "latestDeploymentAttempt": { + "type": ["object", "null"], + "description": "Lifecycle status of the most recent deploy attempt (preparing/activating/active/failed/superseded) — poll this to a terminal state; deploys admit asynchronously, so HTTP success only means the attempt was accepted." } } }, diff --git a/scripts/check-openapi-specs.ts b/scripts/check-openapi-specs.ts index 87cab72e160..3387f058cb9 100644 --- a/scripts/check-openapi-specs.ts +++ b/scripts/check-openapi-specs.ts @@ -155,11 +155,6 @@ function docPropertyNames(schema: unknown, spec: Json): Set | null { return null } -/** Same union logic over the Zod-derived JSON schema (no `$ref`s inside). */ -function zodPropertyNames(schema: unknown): Set | null { - return docPropertyNames(schema, {} as Json) -} - function toJsonSchema(schema: z.ZodType, io: 'input' | 'output'): Json | null { try { return z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as Json @@ -285,6 +280,104 @@ function checkQueryParams(operation: Operation, contract: ContractLike, name: st } } +/** Property subschema lookup, searching `oneOf`/`anyOf`/`allOf` variants. */ +function propertyNode(schema: unknown, root: Json, prop: string): unknown { + const node = deref(schema, root) + if (!node || typeof node !== 'object') return undefined + const record = node as Json + const variants = (record.oneOf ?? record.anyOf ?? record.allOf) as unknown[] | undefined + if (variants) { + for (const variant of variants) { + const found = propertyNode(variant, root, prop) + if (found !== undefined) return found + } + return undefined + } + return (record.properties as Json | undefined)?.[prop] +} + +/** Deref + step through array wrappers so item objects compare directly. */ +function unwrapArrays(node: unknown, root: Json): unknown { + let current = deref(node, root) + for (let i = 0; i < 3; i++) { + const record = current as Json | null + if (record && typeof record === 'object' && record.type === 'array' && record.items) { + current = deref(record.items, root) + } else { + break + } + } + return current +} + +interface DiffContext { + specFile: string + label: string + name: string + where: 'body' | 'response' +} + +/** + * Recursively diffs property-name sets between the Zod-derived JSON schema and + * the documented one, descending through matching object properties and array + * items. Comparison happens only where BOTH sides expose a property set — an + * opaque side (records, `additionalProperties`, prose-only docs) ends the + * descent instead of producing false positives. The Zod root doubles as the + * `$defs` resolution context for recursive schemas. + */ +function diffSchemaFields( + zodNode: unknown, + zodRoot: Json, + docNode: unknown, + docRoot: Json, + ctx: DiffContext, + prefix: string, + depth: number +): void { + if (depth > 4) return + const zodObj = unwrapArrays(zodNode, zodRoot) + const docObj = unwrapArrays(docNode, docRoot) + const zodNames = docPropertyNames(zodObj, zodRoot) + const docNames = docPropertyNames(docObj, docRoot) + if (!zodNames || !docNames) return + const fieldPath = (n: string) => (prefix ? `${prefix}.${n}` : n) + /** + * A `.passthrough()` contract deliberately under-declares its fields, so the + * docs are allowed to document more than the Zod side names. + */ + const extra = (zodObj as Json).additionalProperties + const zodIsPassthrough = + extra === true || (!!extra && typeof extra === 'object' && Object.keys(extra).length === 0) + for (const n of zodNames) { + if (!docNames.has(n)) { + fail( + ctx.specFile, + `${ctx.label}: ${ctx.where} field "${fieldPath(n)}" (${ctx.name}) not documented` + ) + } + } + for (const n of docNames) { + if (!zodNames.has(n) && !zodIsPassthrough) { + fail( + ctx.specFile, + `${ctx.label}: documented ${ctx.where} field "${fieldPath(n)}" does not exist on ${ctx.name}` + ) + } + } + for (const n of zodNames) { + if (!docNames.has(n)) continue + diffSchemaFields( + propertyNode(zodObj, zodRoot, n), + zodRoot, + propertyNode(docObj, docRoot, n), + docRoot, + ctx, + fieldPath(n), + depth + 1 + ) + } +} + function checkBodyAndResponse(operation: Operation, contract: ContractLike, name: string): void { const { specFile, path: p, method, op, spec } = operation const label = `${method.toUpperCase()} ${p}` @@ -293,17 +386,17 @@ function checkBodyAndResponse(operation: Operation, contract: ContractLike, name 'application/json' ] as Json | undefined if (contract.body && docBodySchema?.schema) { - const zodNames = zodPropertyNames(toJsonSchema(contract.body, 'input')) - const docNames = docPropertyNames(docBodySchema.schema, spec) - if (zodNames && docNames) { - for (const n of zodNames) { - if (!docNames.has(n)) fail(specFile, `${label}: body field "${n}" (${name}) not documented`) - } - for (const n of docNames) { - if (!zodNames.has(n)) { - fail(specFile, `${label}: documented body field "${n}" does not exist on ${name}`) - } - } + const zodRoot = toJsonSchema(contract.body, 'input') + if (zodRoot) { + diffSchemaFields( + zodRoot, + zodRoot, + docBodySchema.schema, + spec, + { specFile, label, name, where: 'body' }, + '', + 0 + ) } } @@ -313,19 +406,17 @@ function checkBodyAndResponse(operation: Operation, contract: ContractLike, name const docResponse = successCode ? (deref(responses[successCode], spec) as Json) : undefined const docSchema = ((docResponse?.content as Json)?.['application/json'] as Json)?.schema if (docSchema) { - const zodNames = zodPropertyNames(toJsonSchema(contract.response.schema, 'output')) - const docNames = docPropertyNames(docSchema, spec) - if (zodNames && docNames) { - for (const n of zodNames) { - if (!docNames.has(n)) { - fail(specFile, `${label}: response field "${n}" (${name}) not documented`) - } - } - for (const n of docNames) { - if (!zodNames.has(n)) { - fail(specFile, `${label}: documented response field "${n}" does not exist on ${name}`) - } - } + const zodRoot = toJsonSchema(contract.response.schema, 'output') + if (zodRoot) { + diffSchemaFields( + zodRoot, + zodRoot, + docSchema, + spec, + { specFile, label, name, where: 'response' }, + '', + 0 + ) } } } From f6c9cdb5366eed37995c0a038ac09050f6697aef Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 10:35:14 -0700 Subject: [PATCH 013/159] fix(security): close the triggerType rate-limit bypass on workflow execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caller-supplied triggerType flowed unchecked into preprocessExecution, whose checkRateLimit default turns OFF for 'manual'/'chat' — so any API-key caller, and any anonymous public-API caller billed to the workspace owner, could execute unthrottled by sending {"triggerType":"manual"} (async runs also skipped the worker-side check via admissionCompleted). External callers may now only send the redundant 'api' value; internal JWT callers ('workflow'/'mcp') are unaffected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- .../[id]/execute/route.async.test.ts | 56 +++++++++++++++++++ .../app/api/workflows/[id]/execute/route.ts | 18 ++++++ 2 files changed, 74 insertions(+) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index 771e7cda706..3a77981effd 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -1269,4 +1269,60 @@ describe('workflow execute async route', () => { : executionCall.snapshot expect(snapshot.metadata.enforceCredentialAccess).toBe(true) }) + describe('triggerType override gate', () => { + it.each([ + ['personal API key', EXECUTION_CALLERS[1]], + ['workspace API key', EXECUTION_CALLERS[2]], + ['public API', EXECUTION_CALLERS[3]], + ] as const)( + 'rejects caller-supplied triggerType "manual" from %s callers', + async (_name, caller) => { + configureExecutionCaller(caller) + const req = createMockRequest( + 'POST', + { hello: 'world', triggerType: 'manual' }, + { 'Content-Type': 'application/json', ...caller.headers } + ) + + const response = await POST(req, { params: Promise.resolve({ id: 'workflow-1' }) }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + error: 'External callers cannot override triggerType', + }) + expect(mockPreprocessExecution).not.toHaveBeenCalled() + } + ) + + it('accepts the redundant explicit "api" triggerType from API-key callers', async () => { + const caller = EXECUTION_CALLERS[1] + configureExecutionCaller(caller) + const req = createMockRequest( + 'POST', + { hello: 'world', triggerType: 'api' }, + { 'Content-Type': 'application/json', ...caller.headers, 'X-Execution-Mode': 'async' } + ) + + const response = await POST(req, { params: Promise.resolve({ id: 'workflow-1' }) }) + + expect(response.status).toBe(202) + }) + + it('still allows internal JWT callers to set triggerType', async () => { + const caller = EXECUTION_CALLERS[4] + configureExecutionCaller(caller) + const req = createMockRequest( + 'POST', + { hello: 'world', triggerType: 'workflow' }, + { 'Content-Type': 'application/json', ...caller.headers, 'X-Execution-Mode': 'async' } + ) + + const response = await POST(req, { params: Promise.resolve({ id: 'workflow-1' }) }) + + expect(response.status).toBe(202) + expect(mockPreprocessExecution).toHaveBeenCalledWith( + expect.objectContaining({ triggerType: 'workflow' }) + ) + }) + }) }) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index f56a89504e6..b122ff694e9 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -730,6 +730,24 @@ async function handleExecutePost( ) } + /** + * External callers may not override the trigger type: `manual`/`chat` turn + * rate limiting off entirely (`preprocessExecution` defaults `checkRateLimit` + * from the trigger type), so a caller-supplied value is a quota bypass. + * `'api'` (the value they would get anyway) stays accepted for compatibility + * with callers that send it redundantly. + */ + if ( + (auth.authType === AuthType.API_KEY || isPublicApiAccess) && + body.triggerType !== undefined && + body.triggerType !== 'api' + ) { + return NextResponse.json( + { error: 'External callers cannot override triggerType' }, + { status: 400 } + ) + } + if (auth.authType === 'api_key') { if (isClientSession) { return NextResponse.json( From 51b0cf15c82aa7400a25a41f181e98c809c3538b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 10:41:35 -0700 Subject: [PATCH 014/159] refactor(execution): extract enqueue/status/cancel into shared libs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepares the v2 execution surface: handleAsyncExecution's queue logic moves to lib/workflows/executor/enqueue-execution.ts (slot/claim semantics encoded in a discriminated outcome, not HTTP statuses), the execution-status read to execution-status.ts, and the order-sensitive cancel machinery to lib/execution/cancel-workflow-execution.ts. The v1 routes re-render identically — their suites pass unmodified. Also: preprocessExecution gains rateLimitCounter ('sync'|'async') and its 429 now carries code RATE_LIMIT_EXCEEDED + retryAfterMs (previously indistinguishable from the concurrency 429 and Retry-After was discarded); and the duplicate cancel contract in contracts/logs.ts is unified on the full 5-value reason enum — its narrower copy made requestJson throw a client ZodError when cancelling a paused HITL run. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- .../app/api/workflows/[id]/execute/route.ts | 154 +-------- .../executions/[executionId]/cancel/route.ts | 268 +--------------- .../[id]/executions/[executionId]/route.ts | 215 +------------ apps/sim/lib/api/contracts/logs.ts | 3 +- apps/sim/lib/api/contracts/workflows.ts | 17 +- .../execution/cancel-workflow-execution.ts | 296 ++++++++++++++++++ apps/sim/lib/execution/preprocessing.ts | 18 +- .../workflows/executor/enqueue-execution.ts | 195 ++++++++++++ .../workflows/executor/execution-status.ts | 220 +++++++++++++ 9 files changed, 774 insertions(+), 612 deletions(-) create mode 100644 apps/sim/lib/execution/cancel-workflow-execution.ts create mode 100644 apps/sim/lib/workflows/executor/enqueue-execution.ts create mode 100644 apps/sim/lib/workflows/executor/execution-status.ts diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index b122ff694e9..735046cbf73 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -20,8 +20,6 @@ import { requireBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' -import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' -import { isAsyncJobEnqueueError } from '@/lib/core/async-jobs/types' import { createTimeoutAbortController, getTimeoutErrorMessage, @@ -69,6 +67,7 @@ import { hydrateUserFilesWithBase64, } from '@/lib/uploads/utils/user-file-base64.server' import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations' +import { enqueueWorkflowExecution } from '@/lib/workflows/executor/enqueue-execution' import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { @@ -105,7 +104,6 @@ import { } from '@/lib/workflows/streaming/streaming' import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils' import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' -import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution' import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' import { PublicApiNotAllowedError, @@ -127,8 +125,6 @@ import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/ const logger = createLogger('WorkflowExecuteAPI') const MAX_WORKFLOW_EXECUTE_BODY_BYTES = 10 * 1024 * 1024 const SERVER_EXECUTION_ID_CLAIM_ATTEMPTS = 3 -const ASYNC_ENQUEUE_ATTEMPTS = 2 -const WORKFLOW_EXECUTION_JOB_ID_PREFIX = 'workflow-execution:' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -338,170 +334,38 @@ function requirePreprocessedExecutionContext( } async function handleAsyncExecution(params: AsyncExecutionParams): Promise { - const { - requestId, - workflowId, - userId, - billingAttribution, - workspaceId, - input, - triggerType, - executionId, - callChain, - } = params - const asyncLogger = logger.withMetadata({ - requestId, - workflowId, - workspaceId, - userId, - executionId, - }) - - const correlation = { - executionId, - requestId, - source: 'workflow' as const, - workflowId, - triggerType, - } - - const payload: WorkflowExecutionPayload = { - workflowId, - userId, - billingAttribution, - workspaceId, - input, - triggerType, - executionId, - requestId, - correlation, - callChain, - executionMode: 'async', - admissionCompleted: true, - } + const enqueue = await enqueueWorkflowExecution(params) - let jobQueue: Awaited> - try { - jobQueue = await getJobQueue() - } catch (error) { - asyncLogger.error('Failed to initialize async execution queue', { - error: toError(error).message, - }) - await releaseExecutionSlot(executionId) + if (enqueue.outcome === 'rejected') { return { response: NextResponse.json({ error: 'Failed to queue async execution' }, { status: 500 }), retainExecutionClaim: false, } } - const deterministicJobId = `${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${executionId}` - const enqueueOptions = { - jobId: deterministicJobId, - metadata: { workflowId, workspaceId, userId, correlation }, - } - let jobId: string | undefined - let enqueueError: unknown - let acceptanceCouldBeUnknown = false - - for (let attempt = 1; attempt <= ASYNC_ENQUEUE_ATTEMPTS; attempt++) { - try { - jobId = await jobQueue.enqueue('workflow-execution', payload, enqueueOptions) - enqueueError = undefined - break - } catch (error) { - enqueueError = error - const classifiedError = isAsyncJobEnqueueError(error) ? error : undefined - const attemptAcceptance = classifiedError?.acceptance ?? 'unknown' - acceptanceCouldBeUnknown ||= attemptAcceptance === 'unknown' - asyncLogger.warn('Async workflow enqueue attempt failed', { - acceptance: attemptAcceptance, - attempt, - error: toError(error).message, - jobId: deterministicJobId, - }) - if (classifiedError?.retryable === false || attempt === ASYNC_ENQUEUE_ATTEMPTS) { - break - } - } - } - - if (!jobId) { - const acceptance = acceptanceCouldBeUnknown - ? 'unknown' - : isAsyncJobEnqueueError(enqueueError) - ? enqueueError.acceptance - : 'unknown' - asyncLogger.error('Failed to queue async execution', { - acceptance, - error: toError(enqueueError).message, - jobId: deterministicJobId, - }) - - if (acceptance === 'rejected') { - await releaseExecutionSlot(executionId) - return { - response: NextResponse.json({ error: 'Failed to queue async execution' }, { status: 500 }), - retainExecutionClaim: false, - } - } - + if (enqueue.outcome === 'ambiguous') { return { response: NextResponse.json( { error: 'Async execution queue acceptance could not be confirmed', code: 'ASYNC_ENQUEUE_AMBIGUOUS', - executionId, + executionId: enqueue.executionId, }, - { status: 503, headers: { [WORKFLOW_EXECUTION_ID_HEADER]: executionId } } + { status: 503, headers: { [WORKFLOW_EXECUTION_ID_HEADER]: enqueue.executionId } } ), retainExecutionClaim: true, } } - asyncLogger.info('Queued async workflow execution', { jobId }) - - if (shouldExecuteInline()) { - void (async () => { - let workerOwnsReservation = false - try { - await jobQueue.startJob(jobId) - workerOwnsReservation = true - const output = await executeWorkflowJob(payload) - await jobQueue.completeJob(jobId, output) - } catch (error) { - const errorMessage = toError(error).message - asyncLogger.error('Async workflow execution failed', { - jobId, - error: errorMessage, - }) - /** - * Before worker ownership transfers, no LoggingSession exists to - * release the route's reservation. - */ - if (!workerOwnsReservation) { - await releaseExecutionSlot(executionId) - } - try { - await jobQueue.markJobFailed(jobId, errorMessage) - } catch (markFailedError) { - asyncLogger.error('Failed to mark job as failed', { - jobId, - error: toError(markFailedError).message, - }) - } - } - })() - } - return { response: NextResponse.json( { success: true, async: true, - jobId, - executionId, + jobId: enqueue.jobId, + executionId: enqueue.executionId, message: 'Workflow execution queued', - statusUrl: `${getBaseUrl()}/api/jobs/${jobId}`, + statusUrl: `${getBaseUrl()}/api/jobs/${enqueue.jobId}`, }, { status: 202 } ), diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts index 0ca39eb6622..6f5656a8a7e 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -1,100 +1,14 @@ -import { db } from '@sim/db' -import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' -import { sleep } from '@sim/utils/helpers' -import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - type ExecutionCancellationRecordResult, - markExecutionCancelled, -} from '@/lib/execution/cancellation' -import { createExecutionEventWriter, readExecutionMetaState } from '@/lib/execution/event-buffer' -import { abortManualExecution } from '@/lib/execution/manual-cancellation' -import { captureServerEvent } from '@/lib/posthog/server' -import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' +import { cancelWorkflowExecution } from '@/lib/execution/cancel-workflow-execution' const logger = createLogger('CancelExecutionAPI') -const PAUSED_CANCELLATION_DB_ATTEMPTS = 3 -const PAUSED_CANCELLATION_DB_RETRY_MS = 200 - -async function completePausedCancellationWithRetry( - executionId: string, - workflowId: string -): Promise { - for (let attempt = 1; attempt <= PAUSED_CANCELLATION_DB_ATTEMPTS; attempt++) { - try { - const cancelled = await PauseResumeManager.completePausedCancellation(executionId, workflowId) - if (cancelled) { - logger.info('Paused execution cancelled in database', { executionId, attempt }) - return true - } - logger.warn('Paused execution cancellation could not be completed in database', { - executionId, - attempt, - }) - return false - } catch (error) { - logger.warn('Failed to complete paused execution cancellation in database', { - executionId, - attempt, - error, - }) - if (attempt < PAUSED_CANCELLATION_DB_ATTEMPTS) { - await sleep(PAUSED_CANCELLATION_DB_RETRY_MS) - } - } - } - return false -} - -async function ensurePausedCancellationEventPublished( - executionId: string, - workflowId: string, - context: { workspaceId?: string; userId?: string } = {} -): Promise { - const metaState = await readExecutionMetaState(executionId) - if (metaState.status === 'found' && metaState.meta.status === 'cancelled') { - return true - } - - const writer = createExecutionEventWriter(executionId, { - workspaceId: context.workspaceId, - workflowId, - userId: context.userId, - }) - try { - await writer.writeTerminal( - { - type: 'execution:cancelled', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { duration: 0 }, - }, - 'cancelled' - ) - return true - } catch (error) { - logger.warn('Failed to publish paused execution cancellation event', { - executionId, - error, - }) - return false - } finally { - await writer.close().catch((error) => { - logger.warn('Failed to close paused cancellation event writer', { - executionId, - error, - }) - }) - } -} export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -135,182 +49,14 @@ export const POST = withRouteHandler( logger.info('Cancel execution requested', { workflowId, executionId, userId: auth.userId }) - let pausedCancellationStarted = false - let pausedCancelled = false - try { - pausedCancellationStarted = await PauseResumeManager.beginPausedCancellation( - executionId, - workflowId - ) - } catch (error) { - logger.warn('Failed to begin paused execution cancellation in database', { - executionId, - error, - }) - } - const pendingPausedCancellation = pausedCancellationStarted - ? null - : await PauseResumeManager.getPausedCancellationStatus(executionId, workflowId) - const isPausedCancellationPath = - pausedCancellationStarted || pendingPausedCancellation !== null - - const cancellation: ExecutionCancellationRecordResult = isPausedCancellationPath - ? { durablyRecorded: false, reason: 'redis_unavailable' } - : await markExecutionCancelled(executionId) - const locallyAborted = isPausedCancellationPath ? false : abortManualExecution(executionId) - - if (pausedCancellationStarted) { - logger.info('Paused execution cancellation reserved in database', { executionId }) - } else if (cancellation.durablyRecorded) { - logger.info('Execution marked as cancelled in Redis', { executionId }) - } else if (locallyAborted) { - logger.info('Execution cancelled via local in-process fallback', { executionId }) - } else if (!pausedCancellationStarted) { - logger.warn('Execution cancellation was not durably recorded', { - executionId, - reason: cancellation.reason, - }) - } - - if (!isPausedCancellationPath && (cancellation.durablyRecorded || locallyAborted)) { - await PauseResumeManager.blockQueuedResumesForCancellation(executionId, workflowId).catch( - (error) => { - logger.warn('Failed to block queued paused resumes after cancellation', { - executionId, - error, - }) - } - ) - } else if (!isPausedCancellationPath) { - await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch( - (error) => { - logger.warn( - 'Failed to clear paused cancellation intent after unsuccessful cancellation', - { - executionId, - error, - } - ) - } - ) - } - - let pausedCancellationPublished = false - let pausedCancellationPublishFailed = false - if (pausedCancellationStarted) { - pausedCancellationPublished = await ensurePausedCancellationEventPublished( - executionId, - workflowId, - { - workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined, - userId: auth.userId, - } - ) - pausedCancellationPublishFailed = !pausedCancellationPublished - if (pausedCancellationPublished) { - pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId) - } - } else { - if (pendingPausedCancellation === 'cancelled') { - pausedCancellationPublished = await ensurePausedCancellationEventPublished( - executionId, - workflowId, - { - workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined, - userId: auth.userId, - } - ) - pausedCancellationPublishFailed = !pausedCancellationPublished - pausedCancelled = pausedCancellationPublished - } else if (pendingPausedCancellation === 'cancelling') { - pausedCancellationPublished = await ensurePausedCancellationEventPublished( - executionId, - workflowId, - { - workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined, - userId: auth.userId, - } - ) - pausedCancellationPublishFailed = !pausedCancellationPublished - if (pausedCancellationPublished) { - pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId) - } - } - } - - if ( - pausedCancellationPublishFailed && - (pausedCancellationStarted || pendingPausedCancellation === 'cancelling') - ) { - await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch( - (error) => { - logger.warn('Failed to clear paused cancellation intent after publish failure', { - executionId, - error, - }) - } - ) - } - - if ((cancellation.durablyRecorded || locallyAborted) && !pausedCancelled) { - try { - await db - .update(workflowExecutionLogs) - .set({ status: 'cancelled', endedAt: new Date() }) - .where( - and( - eq(workflowExecutionLogs.executionId, executionId), - eq(workflowExecutionLogs.status, 'running') - ) - ) - } catch (dbError) { - logger.warn('Failed to update execution log status directly', { - executionId, - error: dbError, - }) - } - } - - const success = - (isPausedCancellationPath - ? pausedCancelled && pausedCancellationPublished - : cancellation.durablyRecorded) || locallyAborted - - if (success) { - const workspaceId = workflowAuthorization.workflow?.workspaceId - captureServerEvent( - auth.userId, - 'workflow_execution_cancelled', - { workflow_id: workflowId, workspace_id: workspaceId ?? '' }, - workspaceId ? { groups: { workspace: workspaceId } } : undefined - ) - } - - const durablyRecorded = isPausedCancellationPath - ? pausedCancellationPublished - : pausedCancelled || cancellation.durablyRecorded - const reason = pausedCancellationPublishFailed - ? 'paused_event_publish_failed' - : !pausedCancelled && isPausedCancellationPath - ? 'paused_database_cancel_failed' - : pausedCancelled && !pausedCancellationPublished - ? 'paused_event_publish_failed' - : pausedCancelled || isPausedCancellationPath - ? 'recorded' - : cancellation.reason - - return NextResponse.json({ - success, + const result = await cancelWorkflowExecution({ executionId, - redisAvailable: - isPausedCancellationPath || pausedCancelled - ? pausedCancellationPublished - : cancellation.reason !== 'redis_unavailable', - durablyRecorded, - locallyAborted, - pausedCancelled, - reason, + workflowId, + userId: auth.userId, + workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined, }) + + return NextResponse.json(result) } catch (error) { logger.error('Failed to cancel execution', { workflowId, diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts index d3ff2d2a8a6..6b511174d66 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts @@ -1,105 +1,13 @@ -import { db } from '@sim/db' -import { pausedExecutions, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { - getWorkflowExecutionContract, - type WorkflowExecutionStatusResponse, -} from '@/lib/api/contracts/workflows' +import { getWorkflowExecutionContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { materializeExecutionData } from '@/lib/logs/execution/trace-store' -import { getAutomaticResumeWaitingMetadata } from '@/lib/workflows/executor/paused-execution-metadata' +import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' -import type { PausePoint } from '@/executor/types' const logger = createLogger('WorkflowExecutionStatusAPI') -type LogStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' - -interface TraceSpanShape { - blockId?: string - output?: Record - children?: TraceSpanShape[] -} - -interface ExecutionDataShape { - finalOutput?: { error?: string } & Record - error?: { message?: string } | string - completionFailure?: string - traceSpans?: TraceSpanShape[] -} - -function collectBlockOutputs(spans: TraceSpanShape[] | undefined): Map { - const map = new Map() - const visit = (list?: TraceSpanShape[]): void => { - if (!list) return - for (const span of list) { - if (span.blockId && span.output !== undefined && !map.has(span.blockId)) { - map.set(span.blockId, span.output) - } - if (span.children) visit(span.children) - } - } - visit(spans) - return map -} - -function resolvePath(value: unknown, path: string[]): unknown { - let current: unknown = value - for (const segment of path) { - if (current == null || typeof current !== 'object') return undefined - current = (current as Record)[segment] - } - return current -} - -function pickSelectedOutputs( - selectedOutputs: string[], - blockOutputs: Map -): Record { - const out: Record = {} - for (const selector of selectedOutputs) { - const [head, ...rest] = selector.split('.') - if (!head) continue - if (!blockOutputs.has(head)) continue - const blockValue = blockOutputs.get(head) - out[selector] = rest.length === 0 ? blockValue : resolvePath(blockValue, rest) - } - return out -} - -function pickEarliestPausePoint(points: PausePoint[]): PausePoint | null { - const active = points.filter((p) => p.resumeStatus === 'paused') - if (active.length === 0) return null - return active.reduce((best, current) => { - if (!best) return current - if (!current.resumeAt) return best - if (!best.resumeAt) return current - return current.resumeAt < best.resumeAt ? current : best - }, null) -} - -function normalizePausePoints(raw: unknown): PausePoint[] { - if (!raw) return [] - if (Array.isArray(raw)) return raw as PausePoint[] - if (typeof raw === 'object') return Object.values(raw as Record) - return [] -} - -function extractError(executionData: unknown): string | null { - if (!executionData || typeof executionData !== 'object') return null - const data = executionData as ExecutionDataShape - if (typeof data.error === 'string') return data.error - if (data.error && typeof data.error === 'object' && typeof data.error.message === 'string') { - return data.error.message - } - if (typeof data.finalOutput?.error === 'string') return data.finalOutput.error - if (typeof data.completionFailure === 'string') return data.completionFailure - return null -} - export const GET = withRouteHandler( async ( request: NextRequest, @@ -115,123 +23,24 @@ export const GET = withRouteHandler( return NextResponse.json({ error: access.error.message }, { status: access.error.status }) } - const [logRow] = await db - .select({ - executionId: workflowExecutionLogs.executionId, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - status: workflowExecutionLogs.status, - level: workflowExecutionLogs.level, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - costTotal: workflowExecutionLogs.costTotal, - }) - .from(workflowExecutionLogs) - .where( - and( - eq(workflowExecutionLogs.executionId, executionId), - eq(workflowExecutionLogs.workflowId, workflowId) - ) - ) - .limit(1) + const status = await getWorkflowExecutionStatus({ + workflowId, + executionId, + includeOutput, + selectedOutputs, + }) - if (!logRow) { + if (!status) { return NextResponse.json({ error: 'Execution not found' }, { status: 404 }) } - const [pausedRow] = await db - .select({ - id: pausedExecutions.id, - status: pausedExecutions.status, - pausePoints: pausedExecutions.pausePoints, - metadata: pausedExecutions.metadata, - resumedCount: pausedExecutions.resumedCount, - pausedAt: pausedExecutions.pausedAt, - nextResumeAt: pausedExecutions.nextResumeAt, - }) - .from(pausedExecutions) - .where(eq(pausedExecutions.executionId, executionId)) - .limit(1) - - const isCurrentlyPaused = - !!pausedRow && (pausedRow.status === 'paused' || pausedRow.status === 'partially_resumed') - - let status: WorkflowExecutionStatusResponse['status'] - if (isCurrentlyPaused) { - status = 'paused' - } else { - status = logRow.status as LogStatus - } - - let paused: WorkflowExecutionStatusResponse['paused'] = null - if (isCurrentlyPaused && pausedRow) { - const points = normalizePausePoints(pausedRow.pausePoints) - const earliest = pickEarliestPausePoint(points) - const automaticResumeWaiting = getAutomaticResumeWaitingMetadata(pausedRow.metadata) - paused = { - pausedAt: pausedRow.pausedAt.toISOString(), - resumeAt: pausedRow.nextResumeAt?.toISOString() ?? earliest?.resumeAt ?? null, - pauseKind: earliest?.pauseKind ?? null, - blockedOnBlockId: earliest?.blockId ?? null, - automaticResumeWaitingReason: - automaticResumeWaiting?.reason ?? earliest?.automaticResumeWaitingReason ?? null, - pausedExecutionId: pausedRow.id, - pausePointCount: points.length, - resumedCount: pausedRow.resumedCount, - } - } - - const cost = logRow.costTotal != null ? { total: Number(logRow.costTotal) } : null - - // Heavy execution data may live in object storage; resolve the pointer - // before reading error / finalOutput / traceSpans (no-op for inline rows). - const executionData = (await materializeExecutionData( - logRow.executionData as Record | null, - { - workspaceId: logRow.workspaceId, - workflowId: logRow.workflowId, - executionId: logRow.executionId, - } - )) as ExecutionDataShape | undefined - - const error = status === 'failed' ? extractError(executionData) : null - - const finalOutput = - includeOutput && status === 'completed' && executionData - ? (executionData.finalOutput ?? null) - : null - - const blockOutputs = - selectedOutputs.length > 0 - ? pickSelectedOutputs(selectedOutputs, collectBlockOutputs(executionData?.traceSpans)) - : null - - const response: WorkflowExecutionStatusResponse = { - executionId: logRow.executionId, - workflowId: logRow.workflowId ?? workflowId, - status, - trigger: logRow.trigger, - level: logRow.level, - startedAt: logRow.startedAt.toISOString(), - endedAt: logRow.endedAt?.toISOString() ?? null, - totalDurationMs: logRow.totalDurationMs ?? null, - paused, - cost, - error, - finalOutput, - blockOutputs, - } - logger.debug('Fetched execution status', { workflowId, executionId, - status, - paused: !!paused, + status: status.status, + paused: !!status.paused, }) - return NextResponse.json(response) + return NextResponse.json(status) } ) diff --git a/apps/sim/lib/api/contracts/logs.ts b/apps/sim/lib/api/contracts/logs.ts index 2e4a2753ae6..4e8071a2f6d 100644 --- a/apps/sim/lib/api/contracts/logs.ts +++ b/apps/sim/lib/api/contracts/logs.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { userFileSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' +import { cancelWorkflowExecutionReasonSchema } from '@/lib/api/contracts/workflows' const comparisonOperatorSchema = z.enum(['=', '>', '<', '>=', '<=', '!=']) @@ -327,7 +328,7 @@ export const cancelWorkflowExecutionResponseSchema = z.object({ durablyRecorded: z.boolean(), locallyAborted: z.boolean(), pausedCancelled: z.boolean(), - reason: z.enum(['recorded', 'redis_unavailable', 'redis_write_failed']), + reason: cancelWorkflowExecutionReasonSchema, }) export type SegmentStats = z.output diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 60db7b61c7c..b4228468fc7 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -598,6 +598,21 @@ const workflowExecutionStatusQuerySchema = z.object({ ), }) +/** + * Full cancellation-outcome vocabulary — mirrors + * `CancelWorkflowExecutionReason` in `lib/execution/cancel-workflow-execution` + * (contracts stay import-clean of server modules). The paused-HITL path emits + * the two `paused_*` values; a narrower copy of this enum previously lived in + * `contracts/logs.ts` and made the client reject those responses. + */ +export const cancelWorkflowExecutionReasonSchema = z.enum([ + 'recorded', + 'redis_unavailable', + 'redis_write_failed', + 'paused_event_publish_failed', + 'paused_database_cancel_failed', +]) + const cancelWorkflowExecutionResponseSchema = z.object({ success: z.boolean(), executionId: z.string(), @@ -605,7 +620,7 @@ const cancelWorkflowExecutionResponseSchema = z.object({ durablyRecorded: z.boolean(), locallyAborted: z.boolean(), pausedCancelled: z.boolean(), - reason: z.string().optional(), + reason: cancelWorkflowExecutionReasonSchema.optional(), }) const resumeWorkflowExecutionContextResponseSchema = z diff --git a/apps/sim/lib/execution/cancel-workflow-execution.ts b/apps/sim/lib/execution/cancel-workflow-execution.ts new file mode 100644 index 00000000000..8a1b35af297 --- /dev/null +++ b/apps/sim/lib/execution/cancel-workflow-execution.ts @@ -0,0 +1,296 @@ +import { db } from '@sim/db' +import { workflowExecutionLogs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { sleep } from '@sim/utils/helpers' +import { and, eq } from 'drizzle-orm' +import { + type ExecutionCancellationRecordResult, + markExecutionCancelled, +} from '@/lib/execution/cancellation' +import { createExecutionEventWriter, readExecutionMetaState } from '@/lib/execution/event-buffer' +import { abortManualExecution } from '@/lib/execution/manual-cancellation' +import { captureServerEvent } from '@/lib/posthog/server' +import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' + +const logger = createLogger('CancelWorkflowExecution') +const PAUSED_CANCELLATION_DB_ATTEMPTS = 3 +const PAUSED_CANCELLATION_DB_RETRY_MS = 200 + +/** + * Cancellation outcome vocabulary. `recorded`/`redis_unavailable`/ + * `redis_write_failed` come from the Redis record step; the two `paused_*` + * values from the paused-HITL path. + */ +export type CancelWorkflowExecutionReason = + | 'recorded' + | 'redis_unavailable' + | 'redis_write_failed' + | 'paused_event_publish_failed' + | 'paused_database_cancel_failed' + +export interface CancelWorkflowExecutionResult { + success: boolean + executionId: string + redisAvailable: boolean + durablyRecorded: boolean + locallyAborted: boolean + pausedCancelled: boolean + reason?: CancelWorkflowExecutionReason +} + +async function completePausedCancellationWithRetry( + executionId: string, + workflowId: string +): Promise { + for (let attempt = 1; attempt <= PAUSED_CANCELLATION_DB_ATTEMPTS; attempt++) { + try { + const cancelled = await PauseResumeManager.completePausedCancellation(executionId, workflowId) + if (cancelled) { + logger.info('Paused execution cancelled in database', { executionId, attempt }) + return true + } + logger.warn('Paused execution cancellation could not be completed in database', { + executionId, + attempt, + }) + return false + } catch (error) { + logger.warn('Failed to complete paused execution cancellation in database', { + executionId, + attempt, + error, + }) + if (attempt < PAUSED_CANCELLATION_DB_ATTEMPTS) { + await sleep(PAUSED_CANCELLATION_DB_RETRY_MS) + } + } + } + return false +} + +async function ensurePausedCancellationEventPublished( + executionId: string, + workflowId: string, + context: { workspaceId?: string; userId?: string } = {} +): Promise { + const metaState = await readExecutionMetaState(executionId) + if (metaState.status === 'found' && metaState.meta.status === 'cancelled') { + return true + } + + const writer = createExecutionEventWriter(executionId, { + workspaceId: context.workspaceId, + workflowId, + userId: context.userId, + }) + try { + await writer.writeTerminal( + { + type: 'execution:cancelled', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { duration: 0 }, + }, + 'cancelled' + ) + return true + } catch (error) { + logger.warn('Failed to publish paused execution cancellation event', { + executionId, + error, + }) + return false + } finally { + await writer.close().catch((error) => { + logger.warn('Failed to close paused cancellation event writer', { + executionId, + error, + }) + }) + } +} + +export interface CancelWorkflowExecutionInput { + executionId: string + workflowId: string + /** Actor for the analytics event. */ + userId: string + /** Workflow's workspace; feeds the event writer + analytics grouping. */ + workspaceId?: string +} + +/** + * Cancels a workflow execution across the Redis abort record, the in-process + * aborter, and the paused-HITL machinery. The interleaving is order-sensitive + * and shared verbatim by the v1 and v2 cancel routes. Auth is the caller's + * responsibility; this throws on unexpected infrastructure errors. + */ +export async function cancelWorkflowExecution( + input: CancelWorkflowExecutionInput +): Promise { + const { executionId, workflowId, userId, workspaceId } = input + + let pausedCancellationStarted = false + let pausedCancelled = false + try { + pausedCancellationStarted = await PauseResumeManager.beginPausedCancellation( + executionId, + workflowId + ) + } catch (error) { + logger.warn('Failed to begin paused execution cancellation in database', { + executionId, + error, + }) + } + const pendingPausedCancellation = pausedCancellationStarted + ? null + : await PauseResumeManager.getPausedCancellationStatus(executionId, workflowId) + const isPausedCancellationPath = pausedCancellationStarted || pendingPausedCancellation !== null + + const cancellation: ExecutionCancellationRecordResult = isPausedCancellationPath + ? { durablyRecorded: false, reason: 'redis_unavailable' } + : await markExecutionCancelled(executionId) + const locallyAborted = isPausedCancellationPath ? false : abortManualExecution(executionId) + + if (pausedCancellationStarted) { + logger.info('Paused execution cancellation reserved in database', { executionId }) + } else if (cancellation.durablyRecorded) { + logger.info('Execution marked as cancelled in Redis', { executionId }) + } else if (locallyAborted) { + logger.info('Execution cancelled via local in-process fallback', { executionId }) + } else if (!pausedCancellationStarted) { + logger.warn('Execution cancellation was not durably recorded', { + executionId, + reason: cancellation.reason, + }) + } + + if (!isPausedCancellationPath && (cancellation.durablyRecorded || locallyAborted)) { + await PauseResumeManager.blockQueuedResumesForCancellation(executionId, workflowId).catch( + (error) => { + logger.warn('Failed to block queued paused resumes after cancellation', { + executionId, + error, + }) + } + ) + } else if (!isPausedCancellationPath) { + await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch( + (error) => { + logger.warn('Failed to clear paused cancellation intent after unsuccessful cancellation', { + executionId, + error, + }) + } + ) + } + + let pausedCancellationPublished = false + let pausedCancellationPublishFailed = false + if (pausedCancellationStarted) { + pausedCancellationPublished = await ensurePausedCancellationEventPublished( + executionId, + workflowId, + { workspaceId, userId } + ) + pausedCancellationPublishFailed = !pausedCancellationPublished + if (pausedCancellationPublished) { + pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId) + } + } else { + if (pendingPausedCancellation === 'cancelled') { + pausedCancellationPublished = await ensurePausedCancellationEventPublished( + executionId, + workflowId, + { workspaceId, userId } + ) + pausedCancellationPublishFailed = !pausedCancellationPublished + pausedCancelled = pausedCancellationPublished + } else if (pendingPausedCancellation === 'cancelling') { + pausedCancellationPublished = await ensurePausedCancellationEventPublished( + executionId, + workflowId, + { workspaceId, userId } + ) + pausedCancellationPublishFailed = !pausedCancellationPublished + if (pausedCancellationPublished) { + pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId) + } + } + } + + if ( + pausedCancellationPublishFailed && + (pausedCancellationStarted || pendingPausedCancellation === 'cancelling') + ) { + await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch( + (error) => { + logger.warn('Failed to clear paused cancellation intent after publish failure', { + executionId, + error, + }) + } + ) + } + + if ((cancellation.durablyRecorded || locallyAborted) && !pausedCancelled) { + try { + await db + .update(workflowExecutionLogs) + .set({ status: 'cancelled', endedAt: new Date() }) + .where( + and( + eq(workflowExecutionLogs.executionId, executionId), + eq(workflowExecutionLogs.status, 'running') + ) + ) + } catch (dbError) { + logger.warn('Failed to update execution log status directly', { + executionId, + error: dbError, + }) + } + } + + const success = + (isPausedCancellationPath + ? pausedCancelled && pausedCancellationPublished + : cancellation.durablyRecorded) || locallyAborted + + if (success) { + captureServerEvent( + userId, + 'workflow_execution_cancelled', + { workflow_id: workflowId, workspace_id: workspaceId ?? '' }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + } + + const durablyRecorded = isPausedCancellationPath + ? pausedCancellationPublished + : pausedCancelled || cancellation.durablyRecorded + const reason: CancelWorkflowExecutionReason = pausedCancellationPublishFailed + ? 'paused_event_publish_failed' + : !pausedCancelled && isPausedCancellationPath + ? 'paused_database_cancel_failed' + : pausedCancelled && !pausedCancellationPublished + ? 'paused_event_publish_failed' + : pausedCancelled || isPausedCancellationPath + ? 'recorded' + : cancellation.reason + + return { + success, + executionId, + redisAvailable: + isPausedCancellationPath || pausedCancelled + ? pausedCancellationPublished + : cancellation.reason !== 'redis_unavailable', + durablyRecorded, + locallyAborted, + pausedCancelled, + reason, + } +} diff --git a/apps/sim/lib/execution/preprocessing.ts b/apps/sim/lib/execution/preprocessing.ts index 7b74e1ca1d8..8c1852aad1f 100644 --- a/apps/sim/lib/execution/preprocessing.ts +++ b/apps/sim/lib/execution/preprocessing.ts @@ -56,6 +56,12 @@ export interface PreprocessExecutionOptions { requestId: string checkRateLimit?: boolean + /** + * Which execution token bucket the rate-limit gate debits. Ignored when + * `checkRateLimit` is false. Defaults to `'sync'` — the historical behavior + * for every surface, including async-queued v1 runs. + */ + rateLimitCounter?: 'sync' | 'async' checkDeployment?: boolean skipUsageLimits?: boolean /** @@ -91,6 +97,8 @@ export interface PreprocessExecutionError { statusCode: number code?: string retryable?: boolean + /** Populated on rate-limit denials so callers can emit `Retry-After`. */ + retryAfterMs?: number cause?: Record } @@ -127,6 +135,7 @@ export async function preprocessExecution( reservationId = executionId, requestId, checkRateLimit = triggerType !== 'manual' && triggerType !== 'chat', + rateLimitCounter = 'sync', checkDeployment = triggerType !== 'manual', skipUsageLimits = false, skipConcurrencyReservation = false, @@ -569,7 +578,7 @@ export async function preprocessExecution( actorUserId, actorSubscription, triggerType, - false + rateLimitCounter === 'async' ) if (!info.allowed) { @@ -585,6 +594,13 @@ export async function preprocessExecution( error: { message: `Rate limit exceeded. Please try again later.`, statusCode: 429, + /** + * Distinguishes quota exhaustion from the concurrency-slot 429 + * (`EXECUTION_CONCURRENCY_LIMIT`, retryable in seconds) — the two + * need different caller behavior. + */ + code: 'RATE_LIMIT_EXCEEDED', + retryAfterMs: info.retryAfterMs ?? Math.max(0, info.resetAt.getTime() - Date.now()), }, }, recordError: { diff --git a/apps/sim/lib/workflows/executor/enqueue-execution.ts b/apps/sim/lib/workflows/executor/enqueue-execution.ts new file mode 100644 index 00000000000..949c98be1dc --- /dev/null +++ b/apps/sim/lib/workflows/executor/enqueue-execution.ts @@ -0,0 +1,195 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' +import { isAsyncJobEnqueueError } from '@/lib/core/async-jobs/types' +import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution' +import type { CoreTriggerType } from '@/stores/logs/filters/types' + +const logger = createLogger('WorkflowEnqueueExecution') + +const ASYNC_ENQUEUE_ATTEMPTS = 2 +export const WORKFLOW_EXECUTION_JOB_ID_PREFIX = 'workflow-execution:' + +export interface EnqueueWorkflowExecutionParams { + requestId: string + workflowId: string + userId: string + billingAttribution: BillingAttributionSnapshot + workspaceId: string + input: unknown + triggerType: CoreTriggerType + executionId: string + callChain?: string[] +} + +/** + * Outcome of an async enqueue attempt. Slot/claim semantics are encoded here, + * not in HTTP statuses (which are ambiguous — a 503 can mean five different + * things on the execute surface): + * - `queued`: the job holds the execution slot (`admissionCompleted: true`) and + * the execution-id claim must be RETAINED — the worker writes the durable log + * row under that id. + * - `rejected`: the queue definitively refused; the slot was released here and + * the claim must be released by the caller. + * - `ambiguous`: acceptance is unknown — a job may exist, so NEITHER the slot + * nor the claim may be released (releasing would double-free under a live + * job); the slot leaks to TTL only if the job genuinely never landed. + */ +export type EnqueueWorkflowExecutionResult = + | { outcome: 'queued'; jobId: string; executionId: string; retainExecutionClaim: true } + | { outcome: 'rejected'; executionId: string; retainExecutionClaim: false } + | { outcome: 'ambiguous'; executionId: string; retainExecutionClaim: true } + +/** + * Enqueues an async workflow execution. The caller must have already admitted, + * claimed the execution id, and reserved the execution slot (via + * `preprocessExecution`); the enqueued job inherits that reservation. + * Shared by the v1 and v2 execute routes so retry, acceptance classification, + * and inline-execution dispatch can never drift between surfaces. + */ +export async function enqueueWorkflowExecution( + params: EnqueueWorkflowExecutionParams +): Promise { + const { + requestId, + workflowId, + userId, + billingAttribution, + workspaceId, + input, + triggerType, + executionId, + callChain, + } = params + const asyncLogger = logger.withMetadata({ + requestId, + workflowId, + workspaceId, + userId, + executionId, + }) + + const correlation = { + executionId, + requestId, + source: 'workflow' as const, + workflowId, + triggerType, + } + + const payload: WorkflowExecutionPayload = { + workflowId, + userId, + billingAttribution, + workspaceId, + input, + triggerType, + executionId, + requestId, + correlation, + callChain, + executionMode: 'async', + admissionCompleted: true, + } + + let jobQueue: Awaited> + try { + jobQueue = await getJobQueue() + } catch (error) { + asyncLogger.error('Failed to initialize async execution queue', { + error: toError(error).message, + }) + await releaseExecutionSlot(executionId) + return { outcome: 'rejected', executionId, retainExecutionClaim: false } + } + + const deterministicJobId = `${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${executionId}` + const enqueueOptions = { + jobId: deterministicJobId, + metadata: { workflowId, workspaceId, userId, correlation }, + } + let jobId: string | undefined + let enqueueError: unknown + let acceptanceCouldBeUnknown = false + + for (let attempt = 1; attempt <= ASYNC_ENQUEUE_ATTEMPTS; attempt++) { + try { + jobId = await jobQueue.enqueue('workflow-execution', payload, enqueueOptions) + enqueueError = undefined + break + } catch (error) { + enqueueError = error + const classifiedError = isAsyncJobEnqueueError(error) ? error : undefined + const attemptAcceptance = classifiedError?.acceptance ?? 'unknown' + acceptanceCouldBeUnknown ||= attemptAcceptance === 'unknown' + asyncLogger.warn('Async workflow enqueue attempt failed', { + acceptance: attemptAcceptance, + attempt, + error: toError(error).message, + jobId: deterministicJobId, + }) + if (classifiedError?.retryable === false || attempt === ASYNC_ENQUEUE_ATTEMPTS) { + break + } + } + } + + if (!jobId) { + const acceptance = acceptanceCouldBeUnknown + ? 'unknown' + : isAsyncJobEnqueueError(enqueueError) + ? enqueueError.acceptance + : 'unknown' + asyncLogger.error('Failed to queue async execution', { + acceptance, + error: toError(enqueueError).message, + jobId: deterministicJobId, + }) + + if (acceptance === 'rejected') { + await releaseExecutionSlot(executionId) + return { outcome: 'rejected', executionId, retainExecutionClaim: false } + } + + return { outcome: 'ambiguous', executionId, retainExecutionClaim: true } + } + + asyncLogger.info('Queued async workflow execution', { jobId }) + + if (shouldExecuteInline()) { + void (async () => { + let workerOwnsReservation = false + try { + await jobQueue.startJob(jobId) + workerOwnsReservation = true + const output = await executeWorkflowJob(payload) + await jobQueue.completeJob(jobId, output) + } catch (error) { + const errorMessage = toError(error).message + asyncLogger.error('Async workflow execution failed', { + jobId, + error: errorMessage, + }) + /** + * Before worker ownership transfers, no LoggingSession exists to + * release the route's reservation. + */ + if (!workerOwnsReservation) { + await releaseExecutionSlot(executionId) + } + try { + await jobQueue.markJobFailed(jobId, errorMessage) + } catch (markFailedError) { + asyncLogger.error('Failed to mark job as failed', { + jobId, + error: toError(markFailedError).message, + }) + } + } + })() + } + + return { outcome: 'queued', jobId, executionId, retainExecutionClaim: true } +} diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts new file mode 100644 index 00000000000..5c73436a688 --- /dev/null +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -0,0 +1,220 @@ +import { db } from '@sim/db' +import { pausedExecutions, workflowExecutionLogs } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import type { WorkflowExecutionStatusResponse } from '@/lib/api/contracts/workflows' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { getAutomaticResumeWaitingMetadata } from '@/lib/workflows/executor/paused-execution-metadata' +import type { PausePoint } from '@/executor/types' + +/** + * Reads a single execution's status resource — the log row, the paused-state + * overlay, and (when requested) materialized outputs. Extracted so the v1 and + * v2 status routes render the identical resource from one read path. + * Auth is the caller's responsibility. Returns `null` when no log row exists. + */ + +type LogStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + +interface TraceSpanShape { + blockId?: string + output?: Record + children?: TraceSpanShape[] +} + +interface ExecutionDataShape { + finalOutput?: { error?: string } & Record + error?: { message?: string } | string + completionFailure?: string + traceSpans?: TraceSpanShape[] +} + +function collectBlockOutputs(spans: TraceSpanShape[] | undefined): Map { + const map = new Map() + const visit = (list?: TraceSpanShape[]): void => { + if (!list) return + for (const span of list) { + if (span.blockId && span.output !== undefined && !map.has(span.blockId)) { + map.set(span.blockId, span.output) + } + if (span.children) visit(span.children) + } + } + visit(spans) + return map +} + +function resolvePath(value: unknown, path: string[]): unknown { + let current: unknown = value + for (const segment of path) { + if (current == null || typeof current !== 'object') return undefined + current = (current as Record)[segment] + } + return current +} + +function pickSelectedOutputs( + selectedOutputs: string[], + blockOutputs: Map +): Record { + const out: Record = {} + for (const selector of selectedOutputs) { + const [head, ...rest] = selector.split('.') + if (!head) continue + if (!blockOutputs.has(head)) continue + const blockValue = blockOutputs.get(head) + out[selector] = rest.length === 0 ? blockValue : resolvePath(blockValue, rest) + } + return out +} + +function pickEarliestPausePoint(points: PausePoint[]): PausePoint | null { + const active = points.filter((p) => p.resumeStatus === 'paused') + if (active.length === 0) return null + return active.reduce((best, current) => { + if (!best) return current + if (!current.resumeAt) return best + if (!best.resumeAt) return current + return current.resumeAt < best.resumeAt ? current : best + }, null) +} + +function normalizePausePoints(raw: unknown): PausePoint[] { + if (!raw) return [] + if (Array.isArray(raw)) return raw as PausePoint[] + if (typeof raw === 'object') return Object.values(raw as Record) + return [] +} + +function extractError(executionData: unknown): string | null { + if (!executionData || typeof executionData !== 'object') return null + const data = executionData as ExecutionDataShape + if (typeof data.error === 'string') return data.error + if (data.error && typeof data.error === 'object' && typeof data.error.message === 'string') { + return data.error.message + } + if (typeof data.finalOutput?.error === 'string') return data.finalOutput.error + if (typeof data.completionFailure === 'string') return data.completionFailure + return null +} + +export interface GetWorkflowExecutionStatusInput { + workflowId: string + executionId: string + includeOutput: boolean + selectedOutputs: string[] +} + +export async function getWorkflowExecutionStatus( + input: GetWorkflowExecutionStatusInput +): Promise { + const { workflowId, executionId, includeOutput, selectedOutputs } = input + + const [logRow] = await db + .select({ + executionId: workflowExecutionLogs.executionId, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + status: workflowExecutionLogs.status, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + costTotal: workflowExecutionLogs.costTotal, + }) + .from(workflowExecutionLogs) + .where( + and( + eq(workflowExecutionLogs.executionId, executionId), + eq(workflowExecutionLogs.workflowId, workflowId) + ) + ) + .limit(1) + + if (!logRow) return null + + const [pausedRow] = await db + .select({ + id: pausedExecutions.id, + status: pausedExecutions.status, + pausePoints: pausedExecutions.pausePoints, + metadata: pausedExecutions.metadata, + resumedCount: pausedExecutions.resumedCount, + pausedAt: pausedExecutions.pausedAt, + nextResumeAt: pausedExecutions.nextResumeAt, + }) + .from(pausedExecutions) + .where(eq(pausedExecutions.executionId, executionId)) + .limit(1) + + const isCurrentlyPaused = + !!pausedRow && (pausedRow.status === 'paused' || pausedRow.status === 'partially_resumed') + + let status: WorkflowExecutionStatusResponse['status'] + if (isCurrentlyPaused) { + status = 'paused' + } else { + status = logRow.status as LogStatus + } + + let paused: WorkflowExecutionStatusResponse['paused'] = null + if (isCurrentlyPaused && pausedRow) { + const points = normalizePausePoints(pausedRow.pausePoints) + const earliest = pickEarliestPausePoint(points) + const automaticResumeWaiting = getAutomaticResumeWaitingMetadata(pausedRow.metadata) + paused = { + pausedAt: pausedRow.pausedAt.toISOString(), + resumeAt: pausedRow.nextResumeAt?.toISOString() ?? earliest?.resumeAt ?? null, + pauseKind: earliest?.pauseKind ?? null, + blockedOnBlockId: earliest?.blockId ?? null, + automaticResumeWaitingReason: + automaticResumeWaiting?.reason ?? earliest?.automaticResumeWaitingReason ?? null, + pausedExecutionId: pausedRow.id, + pausePointCount: points.length, + resumedCount: pausedRow.resumedCount, + } + } + + const cost = logRow.costTotal != null ? { total: Number(logRow.costTotal) } : null + + // Heavy execution data may live in object storage; resolve the pointer + // before reading error / finalOutput / traceSpans (no-op for inline rows). + const executionData = (await materializeExecutionData( + logRow.executionData as Record | null, + { + workspaceId: logRow.workspaceId, + workflowId: logRow.workflowId, + executionId: logRow.executionId, + } + )) as ExecutionDataShape | undefined + + const error = status === 'failed' ? extractError(executionData) : null + + const finalOutput = + includeOutput && status === 'completed' && executionData + ? (executionData.finalOutput ?? null) + : null + + const blockOutputs = + selectedOutputs.length > 0 + ? pickSelectedOutputs(selectedOutputs, collectBlockOutputs(executionData?.traceSpans)) + : null + + return { + executionId: logRow.executionId, + // Column is `set null` on workflow delete; the caller's param is the fallback. + workflowId: logRow.workflowId ?? workflowId, + status, + trigger: logRow.trigger, + level: logRow.level, + startedAt: logRow.startedAt.toISOString(), + endedAt: logRow.endedAt?.toISOString() ?? null, + totalDurationMs: logRow.totalDurationMs ?? null, + paused, + cost, + error, + finalOutput, + blockOutputs, + } +} From 4320fed5fee62a759cb3dbbcb31347650e5a9435 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 10:48:28 -0700 Subject: [PATCH 015/159] feat(execution): callable execution service + structured error classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit executeWorkflowService composes the same libs the v1 route holds inline (call-chain guard, execution-id claim, LoggingSession, preprocessing, deployed-state load + file-field processing, timeout-bound executeWorkflowCore, output hydration/compaction) for the deployed-state caller class — the seam the v2 execute route and in-process internal callers share, making the HTTP endpoint syntactic sugar. classifyExecutionError stops discarding the block context that buildBlockExecutionError already attaches at throw sites: failed runs now yield {message, code, blockId, blockName, blockType} with a stable append-only code enum (TIMEOUT/CANCELLED/USAGE_LIMIT_EXCEEDED/ INVALID_INPUT/BLOCK_EXECUTION_FAILED/CHILD_WORKFLOW_FAILED/ OUTPUT_TOO_LARGE/EXECUTION_FAILED), so callers route on error class instead of substring-matching messages — the single place raw errors are interpreted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- .../executor/utils/errors.classify.test.ts | 149 +++++ apps/sim/executor/utils/errors.ts | 117 ++++ .../lib/workflows/executor/execute-service.ts | 594 ++++++++++++++++++ 3 files changed, 860 insertions(+) create mode 100644 apps/sim/executor/utils/errors.classify.test.ts create mode 100644 apps/sim/lib/workflows/executor/execute-service.ts diff --git a/apps/sim/executor/utils/errors.classify.test.ts b/apps/sim/executor/utils/errors.classify.test.ts new file mode 100644 index 00000000000..3051cf6a52c --- /dev/null +++ b/apps/sim/executor/utils/errors.classify.test.ts @@ -0,0 +1,149 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { ExecutionResult } from '@/executor/types' +import { + attachExecutionResult, + buildBlockExecutionError, + classifyExecutionError, +} from '@/executor/utils/errors' + +function failedResult(partial?: Partial): ExecutionResult { + return { success: false, output: {}, ...partial } +} + +describe('classifyExecutionError', () => { + it('reads block context from the fields buildBlockExecutionError attaches and strips the name prefix', () => { + const error = buildBlockExecutionError({ + block: { id: 'block-1', metadata: { name: 'Send Email', id: 'gmail' } } as never, + error: new Error('Invalid credentials'), + }) + + const classified = classifyExecutionError(error) + + expect(classified).toMatchObject({ + message: 'Invalid credentials', + code: 'BLOCK_EXECUTION_FAILED', + blockId: 'block-1', + blockName: 'Send Email', + blockType: 'gmail', + }) + }) + + it('falls back to the last failed, un-handled block log', () => { + const result = failedResult({ + error: 'Agent: model refused', + logs: [ + { + blockId: 'b-ok', + blockName: 'First', + blockType: 'function', + success: true, + startedAt: '', + endedAt: '', + durationMs: 1, + }, + { + blockId: 'b-handled', + blockName: 'Handled', + blockType: 'api', + success: false, + errorHandled: true, + error: 'handled upstream', + startedAt: '', + endedAt: '', + durationMs: 1, + }, + { + blockId: 'b-fail', + blockName: 'Agent', + blockType: 'agent', + success: false, + error: 'model refused', + startedAt: '', + endedAt: '', + durationMs: 1, + }, + ], + }) + + const classified = classifyExecutionError(new Error('Agent: model refused'), result) + + expect(classified).toMatchObject({ + message: 'model refused', + code: 'BLOCK_EXECUTION_FAILED', + blockId: 'b-fail', + blockName: 'Agent', + blockType: 'agent', + }) + }) + + it('classifies child-workflow failures so parents can route on error class', () => { + const result = failedResult({ + logs: [ + { + blockId: 'wf-block', + blockName: 'Enrich Lead', + blockType: 'workflow_input', + success: false, + error: 'Child workflow failed', + startedAt: '', + endedAt: '', + durationMs: 1, + }, + ], + }) + + expect(classifyExecutionError(new Error('Child workflow failed'), result).code).toBe( + 'CHILD_WORKFLOW_FAILED' + ) + }) + + it('maps the attached 4xx statusCode families', () => { + const timeoutError = new Error('Execution exceeded the time limit') + Object.assign(timeoutError, { statusCode: 408 }) + expect(classifyExecutionError(timeoutError).code).toBe('TIMEOUT') + + const usageError = new Error('Usage limit exceeded for this billing period') + Object.assign(usageError, { statusCode: 402 }) + expect(classifyExecutionError(usageError).code).toBe('USAGE_LIMIT_EXCEEDED') + }) + + it('uses the attached executionResult when none is passed explicitly', () => { + const error = new Error('Slack: channel not found') + attachExecutionResult( + error, + failedResult({ + logs: [ + { + blockId: 'slack-1', + blockName: 'Slack', + blockType: 'slack', + success: false, + error: 'channel not found', + startedAt: '', + endedAt: '', + durationMs: 1, + }, + ], + }) + ) + + expect(classifyExecutionError(error)).toMatchObject({ + code: 'BLOCK_EXECUTION_FAILED', + blockId: 'slack-1', + message: 'channel not found', + }) + }) + + it('falls back to EXECUTION_FAILED with the raw message when nothing is classifiable', () => { + expect(classifyExecutionError(new Error('something odd'))).toEqual({ + message: 'something odd', + code: 'EXECUTION_FAILED', + blockId: undefined, + blockName: undefined, + blockType: undefined, + }) + }) +}) diff --git a/apps/sim/executor/utils/errors.ts b/apps/sim/executor/utils/errors.ts index edcff8342ea..4b317377308 100644 --- a/apps/sim/executor/utils/errors.ts +++ b/apps/sim/executor/utils/errors.ts @@ -117,3 +117,120 @@ export function normalizeError(error: unknown): string { } return String(error) } + +/** + * Stable, append-only error classes for failed workflow executions. Callers + * (v2 API consumers, parent workflows, MCP clients) route on these instead of + * substring-matching messages; this module is the single place raw errors are + * interpreted, so the executor can later attach codes natively at throw sites + * without a wire change. + */ +export type WorkflowExecutionErrorCode = + | 'TIMEOUT' + | 'CANCELLED' + | 'USAGE_LIMIT_EXCEEDED' + | 'INVALID_INPUT' + | 'BLOCK_EXECUTION_FAILED' + | 'CHILD_WORKFLOW_FAILED' + | 'OUTPUT_TOO_LARGE' + | 'EXECUTION_FAILED' + +export interface StructuredExecutionError { + message: string + code: WorkflowExecutionErrorCode + blockId?: string + blockName?: string + blockType?: string +} + +interface AttachedBlockContext { + blockId?: unknown + blockName?: unknown + blockType?: unknown +} + +function readAttachedBlockContext(error: unknown): { + blockId?: string + blockName?: string + blockType?: string +} { + if (!(error instanceof Error)) return {} + const attached = error as unknown as AttachedBlockContext + return { + blockId: typeof attached.blockId === 'string' ? attached.blockId : undefined, + blockName: typeof attached.blockName === 'string' ? attached.blockName : undefined, + blockType: typeof attached.blockType === 'string' ? attached.blockType : undefined, + } +} + +function lastFailedBlockLog(result: ExecutionResult | undefined): { + blockId?: string + blockName?: string + blockType?: string + error?: string +} { + const logs = result?.logs + if (!logs?.length) return {} + for (let i = logs.length - 1; i >= 0; i--) { + const log = logs[i] + if (!log.success && log.errorHandled !== true) { + return { + blockId: log.blockId, + blockName: log.blockName, + blockType: log.blockType, + error: log.error, + } + } + } + return {} +} + +const CHILD_WORKFLOW_BLOCK_TYPES = new Set(['workflow', 'workflow_input']) + +/** + * Classifies a failed execution into {@link StructuredExecutionError}. + * Block context comes from the fields {@link buildBlockExecutionError} already + * attaches at the throw site, falling back to the last failed, un-handled + * `BlockLog`. The message drops the historical `"BlockName: "` prefix once + * `blockName` is carried as its own field. + */ +export function classifyExecutionError( + error: unknown, + result?: ExecutionResult +): StructuredExecutionError { + const executionResult = result ?? (hasExecutionResult(error) ? error.executionResult : undefined) + const attached = readAttachedBlockContext(error) + const fromLog = lastFailedBlockLog(executionResult) + const blockId = attached.blockId ?? fromLog.blockId + const blockName = attached.blockName ?? fromLog.blockName + const blockType = attached.blockType ?? fromLog.blockType + + let message = + (error instanceof Error ? error.message : undefined) ?? + executionResult?.error ?? + fromLog.error ?? + 'Execution failed' + if (blockName && message.startsWith(`${blockName}: `)) { + message = message.slice(blockName.length + 2) + } + + const statusCode = error instanceof Error ? getExecutionErrorStatus(error) : undefined + let code: WorkflowExecutionErrorCode + if (statusCode === 408 || /\btimed? ?out\b/i.test(message)) { + code = 'TIMEOUT' + } else if (statusCode === 402 || /usage limit/i.test(message)) { + code = 'USAGE_LIMIT_EXCEEDED' + } else if (executionResult?.status === 'cancelled' || /\bcancelled\b/i.test(message)) { + code = 'CANCELLED' + } else if (/invalid input format/i.test(message)) { + code = 'INVALID_INPUT' + } else if (blockType && CHILD_WORKFLOW_BLOCK_TYPES.has(blockType)) { + code = 'CHILD_WORKFLOW_FAILED' + } else if (blockId) { + code = 'BLOCK_EXECUTION_FAILED' + } else { + code = 'EXECUTION_FAILED' + } + + return { message, code, blockId, blockName, blockType } +} diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts new file mode 100644 index 00000000000..db45a761d32 --- /dev/null +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -0,0 +1,594 @@ +import type { workflow as workflowTable } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { validateCallChain } from '@/lib/execution/call-chain' +import { processInputFileFields } from '@/lib/execution/files' +import { containsLargeValueRef } from '@/lib/execution/payloads/large-value-ref' +import { compactExecutionPayload } from '@/lib/execution/payloads/serializer' +import { preprocessExecution } from '@/lib/execution/preprocessing' +import { LoggingSession } from '@/lib/logs/execution/logging-session' +import { MAX_MCP_WORKFLOW_RESPONSE_BYTES } from '@/lib/mcp/constants' +import { hydrateUserFilesWithBase64 } from '@/lib/uploads/utils/user-file-base64.server' +import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations' +import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' +import { + claimExecutionId, + type ExecutionIdClaim, + hasDurableExecutionOwner, + releaseExecutionIdClaim, +} from '@/lib/workflows/executor/execution-id-claim' +import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence' +import { + loadDeployedWorkflowState, + loadWorkflowDeploymentVersionState, +} from '@/lib/workflows/persistence/utils' +import { workflowHasResponseBlock } from '@/lib/workflows/utils' +import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' +import { ExecutionSnapshot } from '@/executor/execution/snapshot' +import type { ExecutionMetadata } from '@/executor/execution/types' +import type { NormalizedBlockOutput } from '@/executor/types' +import { + classifyExecutionError, + hasExecutionResult, + type StructuredExecutionError, +} from '@/executor/utils/errors' +import { Serializer } from '@/serializer' +import type { CoreTriggerType } from '@/stores/logs/filters/types' + +const logger = createLogger('WorkflowExecuteService') + +const EXECUTION_ID_CLAIM_ATTEMPTS = 3 + +type WorkflowRecord = typeof workflowTable.$inferSelect + +/** + * Sync workflow execution as a callable service — the orchestration the v1 + * execute route holds inline (call-chain guard, execution-id claim, + * LoggingSession, preprocessing/billing, deployed-state load, file-field + * processing, timeout-bound core execution, output compaction), composed from + * the same libs, for the caller class that runs DEPLOYED state with no draft or + * override controls: the v2 execute route and in-process internal callers + * (MCP bridge). The HTTP endpoints are syntactic sugar over this function. + */ +export interface ExecuteWorkflowServiceParams { + workflowId: string + /** Authenticated user driving actor resolution in preprocessing. */ + userId: string + input: unknown + /** Server-derived caller class — never a wire field. */ + triggerType: CoreTriggerType + requestId: string + /** Caller-supplied idempotent execution id; a reused id fails with `conflict`. */ + executionId?: string + callChain?: string[] + useAuthenticatedUserAsActor?: boolean + /** Pre-fetched workflow row (already authorized by the caller). */ + workflowRecord?: WorkflowRecord + upstreamBillingAttribution?: BillingAttributionSnapshot + /** Pin execution to a specific deployment version (MCP bridge). */ + deploymentVersionId?: string + includeFileBase64?: boolean + base64MaxBytes?: number + selectedOutputs?: string[] + /** MCP behavior: 413-style failure instead of large-value refs in output. */ + rejectLargeInlineOutput?: boolean + /** Which rate-limit bucket preprocessing debits. */ + rateLimitCounter?: 'sync' | 'async' + /** Outer request signal; aborting cancels the run (client disconnect). */ + abortSignal?: AbortSignal +} + +export interface ExecuteWorkflowServiceFailure { + kind: 'precheck' | 'conflict' | 'input' | 'aborted' | 'output_too_large' | 'infra' + message: string + statusCode: number + code?: string + retryAfterMs?: number + /** Present when an execution identity was already minted (conflict/ambiguous cases). */ + executionId?: string +} + +export interface ExecuteWorkflowServiceRun { + ok: true + executionId: string + workflowId: string + status: 'completed' | 'failed' | 'paused' | 'cancelled' + /** Why a `cancelled`/`failed` terminal state occurred, when signal-driven. */ + aborted: 'client' | 'timeout' | null + output: NormalizedBlockOutput | undefined + error: StructuredExecutionError | null + hasResponseBlock: boolean + startedAt?: string + endedAt?: string + durationMs?: number +} + +export type ExecuteWorkflowServiceResult = + | ExecuteWorkflowServiceRun + | { ok: false; failure: ExecuteWorkflowServiceFailure } + +function failure(f: ExecuteWorkflowServiceFailure): ExecuteWorkflowServiceResult { + return { ok: false, failure: f } +} + +async function compactServiceOutput( + value: T, + context: { + workspaceId: string + workflowId: string + executionId: string + userId: string + rejectLargeInlineOutput: boolean + } +): Promise { + const compacted = await compactExecutionPayload(value, { + workspaceId: context.workspaceId, + workflowId: context.workflowId, + executionId: context.executionId, + userId: context.userId, + preserveUserFileBase64: true, + preserveRoot: !context.rejectLargeInlineOutput, + rejectLargeValues: context.rejectLargeInlineOutput, + rejectLargeValueLabel: 'Workflow execution response', + thresholdBytes: context.rejectLargeInlineOutput ? MAX_MCP_WORKFLOW_RESPONSE_BYTES : undefined, + requireDurable: true, + }) + + if (context.rejectLargeInlineOutput && containsLargeValueRef(compacted)) { + throw new PayloadSizeLimitError({ + label: 'Workflow execution response', + maxBytes: MAX_MCP_WORKFLOW_RESPONSE_BYTES, + observedBytes: MAX_MCP_WORKFLOW_RESPONSE_BYTES + 1, + }) + } + + return compacted +} + +export async function executeWorkflowService( + params: ExecuteWorkflowServiceParams +): Promise { + const { + workflowId, + userId, + input, + triggerType, + requestId, + callChain, + useAuthenticatedUserAsActor = false, + workflowRecord, + upstreamBillingAttribution, + deploymentVersionId, + includeFileBase64 = true, + base64MaxBytes, + selectedOutputs = [], + rejectLargeInlineOutput = false, + rateLimitCounter = 'sync', + abortSignal, + } = params + + let reqLogger = logger.withMetadata({ requestId, workflowId, userId }) + + if (callChain) { + const chainError = validateCallChain(callChain) + if (chainError) { + return failure({ kind: 'precheck', message: chainError, statusCode: 409 }) + } + } + + const callerProvidedExecutionId = Boolean(params.executionId) + let executionId = params.executionId ?? generateId() + reqLogger = reqLogger.withMetadata({ executionId }) + + let executionIdClaim: ExecutionIdClaim | null = null + let executionIdClaimCommitted = false + + try { + try { + for (let attempt = 1; attempt <= EXECUTION_ID_CLAIM_ATTEMPTS; attempt++) { + executionIdClaim = await claimExecutionId(executionId) + if (executionIdClaim || callerProvidedExecutionId) break + if (attempt < EXECUTION_ID_CLAIM_ATTEMPTS) { + executionId = generateId() + reqLogger = reqLogger.withMetadata({ executionId }) + } + } + } catch (error) { + reqLogger.error('Failed to claim workflow execution ID', { + error: getErrorMessage(error), + }) + return failure({ + kind: 'infra', + message: 'Workflow execution identity is temporarily unavailable', + statusCode: 503, + }) + } + + if (!executionIdClaim) { + if (callerProvidedExecutionId) { + return failure({ + kind: 'conflict', + message: 'Execution ID has already been used', + statusCode: 409, + code: 'EXECUTION_ID_CONFLICT', + executionId, + }) + } + reqLogger.error('Failed to allocate a unique server execution ID') + return failure({ + kind: 'infra', + message: 'Unable to allocate workflow execution identity', + statusCode: 503, + }) + } + + const loggingSession = new LoggingSession(workflowId, executionId, triggerType, requestId) + + const preprocessResult = await preprocessExecution({ + workflowId, + userId, + triggerType, + executionId, + requestId, + checkDeployment: true, + rateLimitCounter, + loggingSession, + useAuthenticatedUserAsActor, + workflowRecord, + billingAttribution: upstreamBillingAttribution, + }) + + if (!preprocessResult.success) { + const preprocessError = preprocessResult.error + return failure({ + kind: 'precheck', + message: preprocessError.message, + statusCode: preprocessError.statusCode, + code: preprocessError.code, + retryAfterMs: preprocessError.retryAfterMs, + }) + } + + // Preprocessing reserved an admission slot (released when the LoggingSession + // finalizes). Any path that exits before execution starts must release it + // here, or the slot leaks until its TTL and wrongly throttles later runs. + if (abortSignal?.aborted) { + await releaseExecutionSlot(executionId) + return failure({ kind: 'aborted', message: 'Client cancelled request', statusCode: 499 }) + } + + const actorUserId = preprocessResult.actorUserId + const workflow = preprocessResult.workflowRecord + const billingAttribution = preprocessResult.billingAttribution + const workspaceId = workflow.workspaceId + if (!workspaceId) { + await releaseExecutionSlot(executionId) + return failure({ + kind: 'infra', + message: 'Invalid execution context returned by preprocessing', + statusCode: 500, + }) + } + reqLogger = reqLogger.withMetadata({ workspaceId, userId: actorUserId }) + + let processedInput = input + let workflowVariables: Record = {} + try { + const workflowData = deploymentVersionId + ? await loadWorkflowDeploymentVersionState(workflowId, deploymentVersionId, workspaceId) + : await loadDeployedWorkflowState(workflowId, workspaceId) + + if (abortSignal?.aborted) { + await releaseExecutionSlot(executionId) + return failure({ kind: 'aborted', message: 'Client cancelled request', statusCode: 499 }) + } + + if (workflowData) { + workflowVariables = + ('variables' in workflowData + ? (workflowData.variables as Record | undefined) + : undefined) ?? + (workflow.variables as Record | null) ?? + {} + + // Custom blocks resolve only inside the org overlay; wrap this pre-execution + // serialize (used for input file-field discovery) the same way the core does. + const customBlockRows = await getCustomBlockRowsForWorkspace(workspaceId) + const serializedWorkflow = await withCustomBlockOverlay(customBlockRows, async () => + new Serializer().serializeWorkflow( + workflowData.blocks, + workflowData.edges, + workflowData.loops || {}, + workflowData.parallels || {}, + false + ) + ) + + processedInput = await processInputFileFields( + input, + serializedWorkflow.blocks, + { workspaceId, workflowId, executionId }, + requestId, + actorUserId + ) + } else { + workflowVariables = (workflow.variables as Record | null) ?? {} + } + } catch (fileError) { + reqLogger.error('Failed to process input file fields', { error: fileError }) + executionIdClaimCommitted = await loggingSession.safeStart({ + userId: actorUserId, + billingAttribution, + workspaceId, + variables: {}, + }) + await loggingSession.safeCompleteWithError({ + error: { + message: `File processing failed: ${getErrorMessage(fileError, 'Unable to process input files')}`, + stackTrace: fileError instanceof Error ? fileError.stack : undefined, + }, + traceSpans: [], + }) + return failure({ + kind: 'input', + message: `File processing failed: ${getErrorMessage(fileError, 'Unable to process input files')}`, + statusCode: 400, + }) + } + + const metadata: ExecutionMetadata = { + requestId, + executionId, + workflowId, + workspaceId, + userId: actorUserId, + billingAttribution, + workflowUserId: workflow.userId, + triggerType, + useDraftState: false, + startTime: new Date().toISOString(), + isClientSession: false, + enforceCredentialAccess: useAuthenticatedUserAsActor, + largeValueExecutionIds: [executionId], + largeValueKeys: [], + fileKeys: [], + allowLargeValueWorkflowScope: false, + callChain, + executionMode: 'sync', + } + + const timeoutController = createTimeoutAbortController(preprocessResult.executionTimeout?.sync) + let requestAborted = false + const abortFromRequest = () => { + requestAborted = true + timeoutController.abort() + } + if (abortSignal?.aborted) { + abortFromRequest() + } else { + abortSignal?.addEventListener('abort', abortFromRequest) + } + const isRequestAborted = () => requestAborted || Boolean(abortSignal?.aborted) + + const compactionContext = { + workspaceId, + workflowId, + executionId, + userId: actorUserId, + rejectLargeInlineOutput, + } + + try { + const snapshot = new ExecutionSnapshot( + metadata, + workflow, + processedInput, + workflowVariables, + selectedOutputs + ) + + const result = await executeWorkflowCore({ + snapshot, + callbacks: {}, + loggingSession, + includeFileBase64, + base64MaxBytes, + abortSignal: timeoutController.signal, + }) + + await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession }) + + if (result.status === 'cancelled' && isRequestAborted() && !timeoutController.isTimedOut()) { + reqLogger.info('Execution cancelled by client disconnect') + await loggingSession.markAsFailed('Client cancelled request') + return { + ok: true, + executionId, + workflowId, + status: 'cancelled', + aborted: 'client', + output: undefined, + error: { message: 'Client cancelled request', code: 'CANCELLED' }, + hasResponseBlock: false, + } + } + + if ( + result.status === 'cancelled' && + timeoutController.isTimedOut() && + timeoutController.timeoutMs + ) { + const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) + reqLogger.info('Execution timed out', { timeoutMs: timeoutController.timeoutMs }) + await loggingSession.markAsFailed(timeoutErrorMessage) + const compactTimeoutOutput = await compactServiceOutput(result.output, compactionContext) + return { + ok: true, + executionId, + workflowId, + status: 'failed', + aborted: 'timeout', + output: compactTimeoutOutput, + error: { message: timeoutErrorMessage, code: 'TIMEOUT' }, + hasResponseBlock: false, + startedAt: result.metadata?.startTime, + endedAt: result.metadata?.endTime, + durationMs: result.metadata?.duration, + } + } + + const outputWithBase64 = + includeFileBase64 && !rejectLargeInlineOutput + ? ((await hydrateUserFilesWithBase64(result.output, { + requestId, + workspaceId, + workflowId, + executionId, + largeValueExecutionIds: [executionId], + largeValueKeys: result.metadata?.largeValueKeys ?? [], + fileKeys: result.metadata?.fileKeys ?? [], + allowLargeValueWorkflowScope: false, + userId: actorUserId, + maxBytes: base64MaxBytes, + preserveLargeValueMetadata: true, + })) as NormalizedBlockOutput) + : result.output + + const compactOutput = await compactServiceOutput(outputWithBase64, compactionContext) + + const status: ExecuteWorkflowServiceRun['status'] = + result.status === 'paused' + ? 'paused' + : result.status === 'cancelled' + ? 'cancelled' + : result.success + ? 'completed' + : 'failed' + + return { + ok: true, + executionId, + workflowId, + status, + aborted: null, + output: compactOutput, + error: + status === 'failed' || (status === 'cancelled' && result.error) + ? classifyExecutionError(result.error ? new Error(result.error) : undefined, result) + : null, + hasResponseBlock: workflowHasResponseBlock(result), + startedAt: result.metadata?.startTime, + endedAt: result.metadata?.endTime, + durationMs: result.metadata?.duration, + } + } catch (error: unknown) { + const errorMessage = getErrorMessage(error, 'Unknown error') + + if (isRequestAborted() && !timeoutController.isTimedOut()) { + reqLogger.info('Execution aborted after client disconnect') + return { + ok: true, + executionId, + workflowId, + status: 'cancelled', + aborted: 'client', + output: undefined, + error: { message: 'Client cancelled request', code: 'CANCELLED' }, + hasResponseBlock: false, + } + } + + if ( + error instanceof PayloadSizeLimitError && + rejectLargeInlineOutput && + error.label === 'Workflow execution response' + ) { + return failure({ + kind: 'output_too_large', + message: 'Workflow execution response exceeds maximum size', + statusCode: 413, + code: 'workflow_response_too_large', + executionId, + }) + } + + reqLogger.error(`Execution failed: ${errorMessage}`) + + const executionResult = hasExecutionResult(error) ? error.executionResult : undefined + let compactErrorOutput: NormalizedBlockOutput | undefined + if (executionResult && Object.hasOwn(executionResult, 'output')) { + try { + compactErrorOutput = await compactServiceOutput(executionResult.output, compactionContext) + } catch (compactError) { + if ( + compactError instanceof PayloadSizeLimitError && + rejectLargeInlineOutput && + compactError.label === 'Workflow execution response' + ) { + return failure({ + kind: 'output_too_large', + message: 'Workflow execution response exceeds maximum size', + statusCode: 413, + code: 'workflow_response_too_large', + executionId, + }) + } + throw compactError + } + } + + return { + ok: true, + executionId, + workflowId, + status: 'failed', + aborted: null, + output: compactErrorOutput, + error: classifyExecutionError(error, executionResult), + hasResponseBlock: false, + startedAt: executionResult?.metadata?.startTime, + endedAt: executionResult?.metadata?.endTime, + durationMs: executionResult?.metadata?.duration, + } + } finally { + abortSignal?.removeEventListener('abort', abortFromRequest) + timeoutController.cleanup() + } + } catch (error) { + reqLogger.error('Failed to start workflow execution', { error: toError(error).message }) + if (executionId) await releaseExecutionSlot(executionId) + return failure({ + kind: 'infra', + message: toError(error).message || 'Failed to start workflow execution', + statusCode: 500, + }) + } finally { + if (executionIdClaim && !executionIdClaimCommitted) { + try { + executionIdClaimCommitted = await hasDurableExecutionOwner(executionId) + } catch (error) { + executionIdClaimCommitted = true + reqLogger.warn('Unable to verify execution ID ownership; retaining claim', { + error: toError(error).message, + executionId, + }) + } + } + + if (executionIdClaim && !executionIdClaimCommitted) { + try { + await releaseExecutionIdClaim(executionIdClaim) + } catch (error) { + reqLogger.warn('Failed to release pre-start execution ID claim', { + error: toError(error).message, + executionId, + }) + } + } + } +} From 9c364004851815e6fb89056f0ef51524cd9d7dd4 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 10:55:26 -0700 Subject: [PATCH 016/159] feat(api): POST /api/v2/workflows/[id]/execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin route over executeWorkflowService: X-API-Key or anonymous public-API auth (sync/stream only for anonymous), strict body with body-flag async (no mode headers on v2), SSE passthrough for stream, and the execution resource response — executionId always present, in-band run failures are status:'failed' with the structured {message, code, blockId, blockName, blockType} error, sync timeout is status:'failed' + TIMEOUT instead of v1's 408, and a Response block's payload stays inside output (authors never control response status/headers on this origin). Async debits the async bucket and the 202 statusUrl points at the v2 executions resource. Adds CLIENT_CLOSED_REQUEST/SERVICE_UNAVAILABLE to the v2 error codes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- apps/sim/app/api/v2/lib/response.ts | 4 + .../v2/workflows/[id]/execute/route.test.ts | 388 ++++++++++++++++++ .../api/v2/workflows/[id]/execute/route.ts | 286 +++++++++++++ apps/sim/lib/api/contracts/v2/workflows.ts | 87 ++++ .../lib/workflows/executor/execute-service.ts | 215 +++++++++- 5 files changed, 979 insertions(+), 1 deletion(-) create mode 100644 apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/execute/route.ts diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 85b117c1022..bd326d1218d 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -22,7 +22,9 @@ export type V2ErrorCode = | 'USAGE_LIMIT_EXCEEDED' | 'LOCKED' | 'RATE_LIMITED' + | 'CLIENT_CLOSED_REQUEST' | 'INTERNAL_ERROR' + | 'SERVICE_UNAVAILABLE' const STATUS_BY_CODE: Record = { BAD_REQUEST: 400, @@ -35,7 +37,9 @@ const STATUS_BY_CODE: Record = { UNSUPPORTED_MEDIA_TYPE: 415, LOCKED: 423, RATE_LIMITED: 429, + CLIENT_CLOSED_REQUEST: 499, INTERNAL_ERROR: 500, + SERVICE_UNAVAILABLE: 503, } /** diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts new file mode 100644 index 00000000000..4f70c4942f5 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts @@ -0,0 +1,388 @@ +/** + * @vitest-environment node + */ + +import { + createMockRequest, + dbChainMockFns, + executionPreprocessingMock, + executionPreprocessingMockFns, + loggingSessionMock, + resetDbChainMock, + setEnv, + workflowAuthzMockFns, + workflowsPersistenceUtilsMock, + workflowsPersistenceUtilsMockFns, + workflowsUtilsMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAuthenticateV1Request, + mockClaimExecutionId, + mockEnqueue, + mockExecuteWorkflowCore, + mockGenerateId, + mockGetWorkspaceBillingSettings, + mockHasDurableExecutionOwner, + mockReleaseExecutionIdClaim, + mockReleaseExecutionSlot, + mockValidatePublicApiAllowed, +} = vi.hoisted(() => ({ + mockAuthenticateV1Request: vi.fn(), + mockClaimExecutionId: vi.fn(), + mockEnqueue: vi.fn().mockResolvedValue('workflow-execution:execution-123'), + mockExecuteWorkflowCore: vi.fn(), + mockGenerateId: vi.fn(() => 'execution-123'), + mockGetWorkspaceBillingSettings: vi.fn(), + mockHasDurableExecutionOwner: vi.fn(), + mockReleaseExecutionIdClaim: vi.fn(), + mockReleaseExecutionSlot: vi.fn(), + mockValidatePublicApiAllowed: vi.fn(), +})) + +vi.mock('@/app/api/v1/auth', () => ({ + authenticateV1Request: mockAuthenticateV1Request, +})) + +vi.mock('@/lib/billing/calculations/usage-reservation', () => ({ + releaseExecutionSlot: mockReleaseExecutionSlot, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + PublicApiNotAllowedError: class PublicApiNotAllowedError extends Error {}, + validatePublicApiAllowed: mockValidatePublicApiAllowed, +})) + +vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) +vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock) +vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) +vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock) + +vi.mock('@/lib/workflows/executor/execution-core', () => ({ + executeWorkflowCore: mockExecuteWorkflowCore, +})) + +vi.mock('@/lib/workflows/executor/pause-persistence', () => ({ + handlePostExecutionPauseState: vi.fn(), +})) + +vi.mock('@/lib/workflows/executor/execution-id-claim', () => ({ + claimExecutionId: mockClaimExecutionId, + hasDurableExecutionOwner: mockHasDurableExecutionOwner, + releaseExecutionIdClaim: mockReleaseExecutionIdClaim, +})) + +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: vi.fn().mockResolvedValue({ + enqueue: mockEnqueue, + startJob: vi.fn(), + completeJob: vi.fn(), + markJobFailed: vi.fn(), + }), + shouldExecuteInline: vi.fn().mockReturnValue(false), +})) + +vi.mock('@/background/workflow-execution', () => ({ + executeWorkflowJob: vi.fn(), +})) + +vi.mock('@/lib/workflows/custom-blocks/operations', () => ({ + getCustomBlockRowsForWorkspace: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/blocks/custom/server-overlay', () => ({ + withCustomBlockOverlay: vi.fn(async (_rows: unknown, fn: () => unknown) => fn()), +})) + +vi.mock('@/serializer', () => ({ + Serializer: class { + serializeWorkflow() { + return { blocks: [] } + } + }, +})) + +vi.mock('@/lib/execution/files', () => ({ + processInputFileFields: vi.fn(async (input: unknown) => input), +})) + +vi.mock('@/lib/uploads/utils/user-file-base64.server', () => ({ + hydrateUserFilesWithBase64: vi.fn(async (output: unknown) => output), +})) + +vi.mock('@/lib/execution/payloads/serializer', () => ({ + compactExecutionPayload: vi.fn(async (value: unknown) => value), +})) + +vi.mock(import('@/lib/execution/payloads/large-value-ref'), async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, containsLargeValueRef: vi.fn().mockReturnValue(false) } +}) + +vi.mock('@sim/utils/id', () => ({ + generateId: mockGenerateId, + generateShortId: vi.fn(() => 'mock-short-id'), + isValidUuid: vi.fn((v: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v) + ), +})) + +import { attachExecutionResult } from '@/executor/utils/errors' +import { POST } from './route' + +const mockPreprocessExecution = executionPreprocessingMockFns.mockPreprocessExecution +const mockAuthorize = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission +const mockLoadDeployedWorkflowState = workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState + +const billingAttribution = { + actorUserId: 'actor-1', + workspaceId: 'workspace-1', + organizationId: null, + billedAccountUserId: 'actor-1', + billingEntity: { type: 'user' as const, id: 'actor-1' }, + billingPeriod: { + start: '2026-07-01T00:00:00.000Z', + end: '2026-08-01T00:00:00.000Z', + }, + payerSubscription: null, +} + +const workflowRecord = { + id: 'workflow-1', + userId: 'owner-1', + workspaceId: 'workspace-1', + isDeployed: true, + variables: {}, +} + +function callExecute(body: Record, headers: Record = {}) { + const req = createMockRequest('POST', body, { + 'Content-Type': 'application/json', + ...headers, + }) + return POST(req, { params: Promise.resolve({ id: 'workflow-1' }) }) +} + +describe('POST /api/v2/workflows/[id]/execute', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) + mockGenerateId.mockReturnValue('execution-123') + mockAuthenticateV1Request.mockResolvedValue({ + authenticated: true, + userId: 'key-user-1', + keyType: 'workspace', + workspaceId: 'workspace-1', + }) + mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord }) + mockClaimExecutionId.mockImplementation(async (executionId: string) => ({ + key: `workflow-execution-id:${executionId}`, + token: `token-${executionId}`, + })) + mockHasDurableExecutionOwner.mockResolvedValue(false) + mockPreprocessExecution.mockResolvedValue({ + success: true, + actorUserId: 'actor-1', + workflowRecord, + actorSubscription: { plan: 'pro' }, + billingAttribution, + executionTimeout: { sync: 60_000, async: 300_000 }, + }) + mockLoadDeployedWorkflowState.mockResolvedValue({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + }) + mockExecuteWorkflowCore.mockResolvedValue({ + success: true, + output: { result: 'done' }, + metadata: { + duration: 42, + startTime: '2026-07-31T00:00:00.000Z', + endTime: '2026-07-31T00:00:01.000Z', + }, + }) + }) + + it('runs sync and returns the execution resource in the v2 envelope', async () => { + const res = await callExecute({ input: { hello: 'world' } }) + + expect(res.status).toBe(200) + expect(res.headers.get('X-Execution-Id')).toBe('execution-123') + const body = await res.json() + expect(body.data).toMatchObject({ + executionId: 'execution-123', + workflowId: 'workflow-1', + status: 'completed', + output: { result: 'done' }, + error: null, + durationMs: 42, + }) + }) + + it('returns status failed with a structured error instead of an HTTP error', async () => { + const error = new Error('Send Email: Invalid credentials') + Object.assign(error, { blockId: 'block-9', blockName: 'Send Email', blockType: 'gmail' }) + attachExecutionResult(error, { + success: false, + output: { partial: true }, + metadata: { duration: 10, startTime: 's', endTime: 'e' }, + }) + mockExecuteWorkflowCore.mockRejectedValue(error) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.status).toBe('failed') + expect(body.data.executionId).toBe('execution-123') + expect(body.data.output).toEqual({ partial: true }) + expect(body.data.error).toEqual({ + message: 'Invalid credentials', + code: 'BLOCK_EXECUTION_FAILED', + blockId: 'block-9', + blockName: 'Send Email', + blockType: 'gmail', + }) + }) + + it('queues async runs and returns a 202 receipt with the v2 executions statusUrl', async () => { + const res = await callExecute({ input: {}, async: true }) + + expect(res.status).toBe(202) + const body = await res.json() + expect(body.data).toEqual({ + executionId: 'execution-123', + statusUrl: 'http://localhost:3000/api/v2/workflows/workflow-1/executions/execution-123', + }) + expect(mockPreprocessExecution).toHaveBeenCalledWith( + expect.objectContaining({ rateLimitCounter: 'async' }) + ) + }) + + it('rejects unknown body keys (strict contract)', async () => { + const res = await callExecute({ input: {}, triggerType: 'manual' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPreprocessExecution).not.toHaveBeenCalled() + }) + + it('rejects async combined with stream or output-shaping options', async () => { + expect((await callExecute({ async: true, stream: true })).status).toBe(400) + expect((await callExecute({ async: true, selectedOutputs: ['a.b'] })).status).toBe(400) + expect((await callExecute({ async: true, includeFileBase64: true })).status).toBe(400) + expect(mockPreprocessExecution).not.toHaveBeenCalled() + }) + + it('masks a workspace-key/workflow mismatch as 404', async () => { + mockAuthenticateV1Request.mockResolvedValue({ + authenticated: true, + userId: 'key-user-1', + keyType: 'workspace', + workspaceId: 'other-workspace', + }) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('rejects personal keys when the workspace disallows them', async () => { + mockAuthenticateV1Request.mockResolvedValue({ + authenticated: true, + userId: 'key-user-1', + keyType: 'personal', + }) + mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: false }) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(403) + }) + + it('returns 409 CONFLICT for a reused X-Execution-Id', async () => { + mockClaimExecutionId.mockResolvedValue(null) + + const res = await callExecute( + { input: {} }, + { 'X-Execution-Id': '11111111-1111-4111-8111-111111111111' } + ) + + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('CONFLICT') + expect(body.error.details).toMatchObject({ + code: 'EXECUTION_ID_CONFLICT', + executionId: '11111111-1111-4111-8111-111111111111', + }) + }) + + it('surfaces the rate-limit failure with Retry-After', async () => { + mockPreprocessExecution.mockResolvedValue({ + success: false, + error: { + message: 'Rate limit exceeded. Please try again later.', + statusCode: 429, + code: 'RATE_LIMIT_EXCEEDED', + retryAfterMs: 12_000, + }, + }) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(429) + expect(res.headers.get('Retry-After')).toBe('12') + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('runs the anonymous public path sync but refuses async', async () => { + mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + dbChainMockFns.limit.mockResolvedValueOnce([ + { isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, + ]) + + const okRes = await callExecute({ input: {} }) + expect(okRes.status).toBe(200) + + mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + dbChainMockFns.limit.mockResolvedValueOnce([ + { isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, + ]) + const asyncRes = await callExecute({ input: {}, async: true }) + expect(asyncRes.status).toBe(400) + }) + + it('401s non-public workflows without a key', async () => { + mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + dbChainMockFns.limit.mockResolvedValueOnce([ + { isPublicApi: false, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, + ]) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(401) + expect((await res.json()).error.code).toBe('UNAUTHORIZED') + }) + + it('releases the unused execution-id claim after a failed preprocess', async () => { + mockPreprocessExecution.mockResolvedValue({ + success: false, + error: { message: 'Workflow not found', statusCode: 404 }, + }) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(404) + expect(mockReleaseExecutionIdClaim).toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts new file mode 100644 index 00000000000..5cc786a2340 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -0,0 +1,286 @@ +import { db } from '@sim/db' +import { workflow as workflowTable } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { v2ExecuteWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { executionIdSchema, WORKFLOW_EXECUTION_ID_HEADER } from '@/lib/api/contracts/workflows' +import { parseRequest } from '@/lib/api/server' +import { tryAdmit } from '@/lib/core/admission/gate' +import { ADMISSION_ERROR_DESCRIPTOR } from '@/lib/core/admission/transient-failure' +import { generateRequestId } from '@/lib/core/utils/request' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + type ExecuteWorkflowServiceFailure, + executeWorkflowService, +} from '@/lib/workflows/executor/execute-service' +import { + AGENT_STREAM_PROTOCOL_HEADER_LABEL, + AGENT_STREAM_PROTOCOL_V1, + clientAcceptsAgentStreamProtocol, + hasAgentStreamPolicy, +} from '@/lib/workflows/streaming/agent-stream-protocol' +import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' +import { authenticateV1Request } from '@/app/api/v1/auth' +import { type V2ErrorCode, v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' +import { + PublicApiNotAllowedError, + validatePublicApiAllowed, +} from '@/ee/access-control/utils/permission-check' + +const logger = createLogger('V2WorkflowExecuteAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const FAILURE_CODE_BY_STATUS: Record = { + 400: 'BAD_REQUEST', + 401: 'UNAUTHORIZED', + 402: 'USAGE_LIMIT_EXCEEDED', + 403: 'FORBIDDEN', + 404: 'NOT_FOUND', + 408: 'BAD_REQUEST', + 409: 'CONFLICT', + 413: 'PAYLOAD_TOO_LARGE', + 429: 'RATE_LIMITED', + 499: 'CLIENT_CLOSED_REQUEST', + 503: 'SERVICE_UNAVAILABLE', +} + +function serviceFailureResponse(failure: ExecuteWorkflowServiceFailure) { + const code = FAILURE_CODE_BY_STATUS[failure.statusCode] ?? 'INTERNAL_ERROR' + const headers: Record = {} + if (failure.retryAfterMs !== undefined) { + headers['Retry-After'] = Math.max(1, Math.ceil(failure.retryAfterMs / 1000)).toString() + } + if (failure.executionId) { + headers[WORKFLOW_EXECUTION_ID_HEADER] = failure.executionId + } + return v2Error(code, failure.message, { + status: failure.statusCode, + headers, + details: + failure.code || failure.executionId + ? { + ...(failure.code ? { code: failure.code } : {}), + ...(failure.executionId ? { executionId: failure.executionId } : {}), + } + : undefined, + }) +} + +/** + * POST /api/v2/workflows/[id]/execute — syntactic sugar over + * {@link executeWorkflowService}. + * + * - Auth: `X-API-Key` (personal/workspace) or the anonymous public-API path for + * workflows deployed with `isPublicApi` (actor = owner; sync/stream only). + * - `async: true` (body flag — v2 has no mode headers) → 202 + * `{ data: { executionId, statusUrl } }`; poll the v2 executions resource. + * - `stream: true` → SSE passthrough (no `{data}` envelope on event frames). + * - Sync → 200 execution resource with the status enum and structured error; + * an in-band run failure is `status: 'failed'`, never an HTTP error. A + * Response block's declared payload stays inside `output` — v2 never lets a + * workflow author control response status or headers on this origin. + * - Rate limiting: the execution `sync`/`async` buckets via preprocessing — + * deliberately NOT the shared `api-endpoint` bucket, and async runs debit + * the async bucket (unlike v1's known sync-bucket bug). + */ +export const POST = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id: workflowId } = await context.params + + let userId: string + let isPublicApiAccess = false + let apiKeyType: 'personal' | 'workspace' | undefined + let apiKeyWorkspaceId: string | undefined + + const auth = await authenticateV1Request(req) + if (auth.authenticated && auth.userId) { + userId = auth.userId + apiKeyType = auth.keyType + apiKeyWorkspaceId = auth.workspaceId + } else { + if (req.headers.has('x-api-key')) { + return v2Error('UNAUTHORIZED', auth.error || 'Unauthorized') + } + const [wf] = await db + .select({ + isPublicApi: workflowTable.isPublicApi, + isDeployed: workflowTable.isDeployed, + userId: workflowTable.userId, + workspaceId: workflowTable.workspaceId, + }) + .from(workflowTable) + .where(eq(workflowTable.id, workflowId)) + .limit(1) + + if (!wf?.isPublicApi || !wf.isDeployed || !wf.workspaceId) { + return v2Error('UNAUTHORIZED', 'Unauthorized') + } + try { + await validatePublicApiAllowed(wf.userId, wf.workspaceId) + } catch (err) { + if (err instanceof PublicApiNotAllowedError) { + return v2Error('UNAUTHORIZED', 'Unauthorized') + } + throw err + } + userId = wf.userId + isPublicApiAccess = true + } + + const ticket = tryAdmit() + if (!ticket) { + return v2Error('RATE_LIMITED', 'Server is at capacity. Please retry shortly.', { + headers: { + 'Retry-After': ADMISSION_ERROR_DESCRIPTOR.GATE_CAPACITY.retryAfterSeconds.toString(), + }, + }) + } + + try { + const parsed = await parseRequest(v2ExecuteWorkflowContract, req, context, { + maxBodyBytes: 10 * 1024 * 1024, + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const body = parsed.data.body + + if (body.async && isPublicApiAccess) { + return v2Error('BAD_REQUEST', 'Async execution requires an API key') + } + if (body.async && body.stream) { + return v2Error('BAD_REQUEST', 'async and stream cannot be combined') + } + if ( + body.async && + (body.selectedOutputs?.length || + body.includeThinking || + body.includeToolCalls || + body.includeFileBase64 !== undefined || + body.base64MaxBytes !== undefined) + ) { + return v2Error( + 'BAD_REQUEST', + 'Async execution does not support streaming or output-shaping options' + ) + } + if ( + hasAgentStreamPolicy({ + includeThinking: body.includeThinking, + includeToolCalls: body.includeToolCalls, + }) && + !clientAcceptsAgentStreamProtocol(req.headers) + ) { + return v2Error( + 'BAD_REQUEST', + `includeThinking and includeToolCalls require the ${AGENT_STREAM_PROTOCOL_HEADER_LABEL}: ${AGENT_STREAM_PROTOCOL_V1} request header, which declares that the client understands agent-event frames.` + ) + } + + /** Idempotent execution ids are a keyed-caller feature; anonymous callers must not probe the claim table. */ + let requestedExecutionId: string | undefined + const executionIdHeader = req.headers.get(WORKFLOW_EXECUTION_ID_HEADER) + if (executionIdHeader !== null && !isPublicApiAccess) { + const headerValidation = executionIdSchema.safeParse(executionIdHeader) + if (!headerValidation.success) { + return v2Error('BAD_REQUEST', 'Invalid execution ID header') + } + requestedExecutionId = headerValidation.data + } + + const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'read', + }) + // Mask authorization failures as 404 so cross-workspace existence never leaks. + if (!workflowAuthorization.allowed || !workflowAuthorization.workflow) { + return v2Error('NOT_FOUND', 'Workflow not found') + } + const workflowRecord = workflowAuthorization.workflow + + if (apiKeyType === 'workspace' && workflowRecord.workspaceId !== apiKeyWorkspaceId) { + return v2Error('NOT_FOUND', 'Workflow not found') + } + if (apiKeyType === 'personal' && workflowRecord.workspaceId) { + const settings = await getWorkspaceBillingSettings(workflowRecord.workspaceId) + if (!settings?.allowPersonalApiKeys) { + return v2Error('FORBIDDEN', 'Personal API keys are not allowed for this workspace') + } + } + + const result = await executeWorkflowService({ + workflowId, + userId, + input: body.input ?? {}, + triggerType: 'api', + requestId, + executionId: requestedExecutionId, + useAuthenticatedUserAsActor: apiKeyType === 'personal', + workflowRecord, + includeFileBase64: body.includeFileBase64, + base64MaxBytes: body.base64MaxBytes, + selectedOutputs: body.selectedOutputs, + rateLimitCounter: body.async ? 'async' : 'sync', + abortSignal: req.signal, + mode: body.async ? 'async' : body.stream ? 'stream' : 'sync', + requestHeaders: req.headers, + includeThinking: body.includeThinking, + includeToolCalls: body.includeToolCalls, + }) + + if (!result.ok) { + return serviceFailureResponse(result.failure) + } + + if ('stream' in result) { + // SSE: pass the stream through byte-for-byte with its own headers. + return result.stream + } + + if ('queued' in result) { + return v2Data( + { + executionId: result.executionId, + statusUrl: `${getBaseUrl()}/api/v2/workflows/${workflowId}/executions/${result.executionId}`, + }, + { status: 202, headers: { [WORKFLOW_EXECUTION_ID_HEADER]: result.executionId } } + ) + } + + if (result.aborted === 'client') { + return v2Error('CLIENT_CLOSED_REQUEST', 'Client cancelled request', { + details: { executionId: result.executionId }, + }) + } + + return v2Data( + { + executionId: result.executionId, + workflowId: result.workflowId, + status: result.status, + output: result.output ?? null, + error: result.error, + startedAt: result.startedAt, + endedAt: result.endedAt, + durationMs: result.durationMs, + }, + { headers: { [WORKFLOW_EXECUTION_ID_HEADER]: result.executionId } } + ) + } catch (error) { + logger.error(`[${requestId}] v2 execute failed`, { + workflowId, + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } finally { + ticket.release() + } + } +) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 30dbe6f925a..fdb6daeea6b 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -114,6 +114,93 @@ export const v2RollbackWorkflowContract = defineRouteContract({ }, }) +/** + * Structured execution error — mirrors `WorkflowExecutionErrorCode` in + * `@/executor/utils/errors` (duplicated literally: contracts are + * client-importable and must not pull executor modules). APPEND-ONLY: callers + * route on these instead of substring-matching messages. + */ +export const v2ExecutionErrorSchema = z.object({ + message: z.string(), + code: z.enum([ + 'TIMEOUT', + 'CANCELLED', + 'USAGE_LIMIT_EXCEEDED', + 'INVALID_INPUT', + 'BLOCK_EXECUTION_FAILED', + 'CHILD_WORKFLOW_FAILED', + 'OUTPUT_TOO_LARGE', + 'EXECUTION_FAILED', + ]), + /** Failing block, when attributable. Deliberately crosses the workspace boundary for shared/child workflows — the executionId + block context is the reproducible handle a caller hands the workflow provider. */ + blockId: z.string().optional(), + blockName: z.string().optional(), + blockType: z.string().optional(), +}) +export type V2ExecutionError = z.output + +/** + * Strict public execute body. Async is body-selected (`async: true`) — v2 has + * no `X-Execution-Mode`/`X-Stream-Response` headers. Internal caller facts + * (triggerType, draft state, deployment pinning) are NEVER wire fields; they + * are typed options on the execution service. + */ +export const v2ExecuteWorkflowBodySchema = z + .object({ + input: z.record(z.string(), z.unknown()).optional(), + async: z.boolean().optional().default(false), + stream: z.boolean().optional().default(false), + selectedOutputs: z.array(z.string().min(1)).max(100).optional(), + includeThinking: z.boolean().optional().default(false), + includeToolCalls: z.boolean().optional().default(false), + includeFileBase64: z.boolean().optional(), + /** Caps inline base64 file hydration; bounded (v1 leaves it unbounded). */ + base64MaxBytes: z + .number() + .int() + .positive() + .max(10 * 1024 * 1024) + .optional(), + }) + .strict() +export type V2ExecuteWorkflowBody = z.input + +/** + * The execution result resource. In-band run failures are `status: 'failed'` + * with a structured `error` — never an HTTP error: **an `executionId` means + * 200/202 + `data`; no `executionId` means the `v2Error` envelope.** The sync + * timeout is `status:'failed'` + `error.code:'TIMEOUT'` (v1 returned 408). + */ +export const v2ExecuteWorkflowDataSchema = z.object({ + executionId: z.string(), + workflowId: z.string(), + status: z.enum(['completed', 'failed', 'paused', 'cancelled']), + output: z.unknown(), + error: v2ExecutionErrorSchema.nullable(), + startedAt: z.string().optional(), + endedAt: z.string().optional(), + durationMs: z.number().optional(), +}) +export type V2ExecuteWorkflowData = z.output + +/** 202 receipt for `async: true` — poll `statusUrl` (the v2 executions resource). */ +export const v2ExecuteWorkflowQueuedSchema = z.object({ + executionId: z.string(), + statusUrl: z.string(), +}) +export type V2ExecuteWorkflowQueued = z.output + +export const v2ExecuteWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/execute', + params: workflowIdParamsSchema, + body: v2ExecuteWorkflowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ExecuteWorkflowDataSchema), + }, +}) + /** * Export/import reuse the v1 payload and body schemas verbatim — the portable * envelope must round-trip across both surfaces — with only the response diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts index db45a761d32..d48d84ae722 100644 --- a/apps/sim/lib/workflows/executor/execute-service.ts +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -1,10 +1,11 @@ import type { workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' +import { generateId, isValidUuid } from '@sim/utils/id' import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits' +import { SSE_HEADERS } from '@/lib/core/utils/sse' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { validateCallChain } from '@/lib/execution/call-chain' import { processInputFileFields } from '@/lib/execution/files' @@ -15,6 +16,8 @@ import { LoggingSession } from '@/lib/logs/execution/logging-session' import { MAX_MCP_WORKFLOW_RESPONSE_BYTES } from '@/lib/mcp/constants' import { hydrateUserFilesWithBase64 } from '@/lib/uploads/utils/user-file-base64.server' import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations' +import { enqueueWorkflowExecution } from '@/lib/workflows/executor/enqueue-execution' +import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { claimExecutionId, @@ -27,8 +30,14 @@ import { loadDeployedWorkflowState, loadWorkflowDeploymentVersionState, } from '@/lib/workflows/persistence/utils' +import { shouldEmitAgentStreamEvents } from '@/lib/workflows/streaming/agent-stream-protocol' +import { + agentStreamProtocolResponseHeaders, + createStreamingResponse, +} from '@/lib/workflows/streaming/streaming' import { workflowHasResponseBlock } from '@/lib/workflows/utils' import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' +import { normalizeName } from '@/executor/constants' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionMetadata } from '@/executor/execution/types' import type { NormalizedBlockOutput } from '@/executor/types' @@ -81,6 +90,17 @@ export interface ExecuteWorkflowServiceParams { rateLimitCounter?: 'sync' | 'async' /** Outer request signal; aborting cancels the run (client disconnect). */ abortSignal?: AbortSignal + /** + * `sync` (default): run to completion and return the result resource. + * `async`: enqueue and return the queue receipt. + * `stream`: return an SSE Response (agent-stream protocol negotiated from + * `requestHeaders`). + */ + mode?: 'sync' | 'async' | 'stream' + /** Original request headers — stream-protocol negotiation only. */ + requestHeaders?: Headers + includeThinking?: boolean + includeToolCalls?: boolean } export interface ExecuteWorkflowServiceFailure { @@ -108,8 +128,23 @@ export interface ExecuteWorkflowServiceRun { durationMs?: number } +export interface ExecuteWorkflowServiceQueued { + ok: true + queued: true + executionId: string + jobId: string +} + +export interface ExecuteWorkflowServiceStream { + ok: true + stream: Response + executionId: string +} + export type ExecuteWorkflowServiceResult = | ExecuteWorkflowServiceRun + | ExecuteWorkflowServiceQueued + | ExecuteWorkflowServiceStream | { ok: false; failure: ExecuteWorkflowServiceFailure } function failure(f: ExecuteWorkflowServiceFailure): ExecuteWorkflowServiceResult { @@ -170,6 +205,10 @@ export async function executeWorkflowService( rejectLargeInlineOutput = false, rateLimitCounter = 'sync', abortSignal, + mode = 'sync', + requestHeaders, + includeThinking = false, + includeToolCalls = false, } = params let reqLogger = logger.withMetadata({ requestId, workflowId, userId }) @@ -276,8 +315,41 @@ export async function executeWorkflowService( } reqLogger = reqLogger.withMetadata({ workspaceId, userId: actorUserId }) + if (mode === 'async') { + const enqueue = await enqueueWorkflowExecution({ + requestId, + workflowId, + userId: actorUserId, + billingAttribution, + workspaceId, + input, + triggerType, + executionId, + callChain, + }) + executionIdClaimCommitted = enqueue.retainExecutionClaim + if (enqueue.outcome === 'rejected') { + return failure({ + kind: 'infra', + message: 'Failed to queue async execution', + statusCode: 500, + }) + } + if (enqueue.outcome === 'ambiguous') { + return failure({ + kind: 'infra', + message: 'Async execution queue acceptance could not be confirmed', + statusCode: 503, + code: 'ASYNC_ENQUEUE_AMBIGUOUS', + executionId, + }) + } + return { ok: true, queued: true, executionId, jobId: enqueue.jobId } + } + let processedInput = input let workflowVariables: Record = {} + let workflowBlocks: Record = {} try { const workflowData = deploymentVersionId ? await loadWorkflowDeploymentVersionState(workflowId, deploymentVersionId, workspaceId) @@ -289,6 +361,7 @@ export async function executeWorkflowService( } if (workflowData) { + workflowBlocks = workflowData.blocks workflowVariables = ('variables' in workflowData ? (workflowData.variables as Record | undefined) @@ -341,6 +414,88 @@ export async function executeWorkflowService( }) } + if (mode === 'stream') { + const resolvedSelectedOutputs = resolveOutputIds(selectedOutputs, workflowBlocks) + const streamWorkflow = { + id: workflow.id, + userId: actorUserId, + workspaceId, + isDeployed: workflow.isDeployed, + variables: workflowVariables, + } + const headers = requestHeaders ?? new Headers() + const agentEvents = shouldEmitAgentStreamEvents({ + includeThinking, + includeToolCalls, + requestHeaders: headers, + }) + + const stream = await createStreamingResponse({ + requestId, + streamConfig: { + selectedOutputs: resolvedSelectedOutputs, + isSecureMode: false, + workflowTriggerType: 'api', + includeFileBase64, + base64MaxBytes, + timeoutMs: preprocessResult.executionTimeout?.sync, + includeThinking, + includeToolCalls, + }, + executionId, + largeValueExecutionIds: [executionId], + largeValueKeys: [], + fileKeys: [], + workspaceId, + workflowId, + userId: actorUserId, + allowLargeValueWorkflowScope: false, + requestSignal: abortSignal, + requestHeaders: headers, + executeFn: async ({ onStream, onBlockComplete, abortSignal: streamAbortSignal }) => + executeWorkflow( + streamWorkflow, + requestId, + processedInput, + actorUserId, + { + enabled: true, + selectedOutputs: resolvedSelectedOutputs, + isSecureMode: false, + workflowTriggerType: 'api', + onStream, + onBlockComplete, + skipLoggingComplete: true, + includeFileBase64, + base64MaxBytes, + abortSignal: streamAbortSignal, + executionMode: 'stream', + billingAttribution, + largeValueKeys: [], + fileKeys: [], + includeThinking, + includeToolCalls, + agentEvents, + }, + executionId + ), + }) + + executionIdClaimCommitted = true + return { + ok: true, + executionId, + stream: new Response(stream, { + status: 200, + headers: { + ...SSE_HEADERS, + // Echo the negotiated stream protocol (same as the chat and v1 routes). + ...agentStreamProtocolResponseHeaders({ requestHeaders: headers }), + }, + }), + } + } + const metadata: ExecutionMetadata = { requestId, executionId, @@ -592,3 +747,61 @@ export async function executeWorkflowService( } } } + +/** + * Resolves caller-facing `selectedOutputs` refs (`BlockName.path` or + * `.path`) to internal `_` ids — same normalization the + * v1 streaming path applies. + */ +export function resolveOutputIds( + selectedOutputs: string[] | undefined, + blocks: Record +): string[] | undefined { + if (!selectedOutputs || selectedOutputs.length === 0) { + return selectedOutputs + } + + return selectedOutputs.map((outputId) => { + const underscoreIndex = outputId.indexOf('_') + const dotIndex = outputId.indexOf('.') + if (underscoreIndex > 0) { + const maybeUuid = outputId.substring(0, underscoreIndex) + if (isValidUuid(maybeUuid)) { + return outputId + } + } + + if (dotIndex > 0) { + const maybeUuid = outputId.substring(0, dotIndex) + if (isValidUuid(maybeUuid)) { + return `${outputId.substring(0, dotIndex)}_${outputId.substring(dotIndex + 1)}` + } + } + + if (isValidUuid(outputId)) { + return outputId + } + + if (dotIndex === -1) { + logger.warn(`Invalid output ID format (missing dot): ${outputId}`) + return outputId + } + + const blockName = outputId.substring(0, dotIndex) + const path = outputId.substring(dotIndex + 1) + + const normalizedBlockName = normalizeName(blockName) + const block = Object.values(blocks).find((candidate) => { + const record = candidate as { name?: string } + return normalizeName(record.name || '') === normalizedBlockName + }) + + if (!block) { + logger.warn(`Block not found for name: ${blockName} (from output ID: ${outputId})`) + return outputId + } + + const resolvedId = `${(block as { id: string }).id}_${path}` + return resolvedId + }) +} From 3387ee7d9f837c07e2c292ceb5d5370ba47708d2 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 10:58:04 -0700 Subject: [PATCH 017/159] feat(api): v2 executions status + cancel with queued backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v2/workflows/[id]/executions/[executionId] is the single status URL for sync and async runs: before the async worker writes the durable log row, status is backfilled from the job queue (deterministic job id) as 'queued'/'running' — closing v1's 202-to-pickup 404 window — and failed runs carry the structured error object. POST .../cancel renders the shared cancellation lib in the v2 envelope with the tightened 5-value reason enum. Both authenticate via the shared resolveV2WorkflowAccess (X-API-Key, authz masked as 404, allowPersonalApiKeys honored). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- .../executions/[executionId]/cancel/route.ts | 48 +++++ .../executions/[executionId]/route.test.ts | 171 ++++++++++++++++++ .../[id]/executions/[executionId]/route.ts | 123 +++++++++++++ apps/sim/app/api/v2/workflows/lib/access.ts | 59 ++++++ apps/sim/lib/api/contracts/v2/workflows.ts | 63 ++++++- apps/sim/lib/api/contracts/workflows.ts | 4 +- 6 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts create mode 100644 apps/sim/app/api/v2/workflows/lib/access.ts diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts new file mode 100644 index 00000000000..2d3ebe1166e --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -0,0 +1,48 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CancelWorkflowExecutionContract } from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { cancelWorkflowExecution } from '@/lib/execution/cancel-workflow-execution' +import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' +import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' + +const logger = createLogger('V2CancelExecutionAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** POST /api/v2/workflows/[id]/executions/[executionId]/cancel */ +export const POST = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string; executionId: string }> }) => { + const parsed = await parseRequest(v2CancelWorkflowExecutionContract, req, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { id: workflowId, executionId } = parsed.data.params + + const access = await resolveV2WorkflowAccess(req, workflowId, 'write') + if (!access.ok) return access.response + + try { + logger.info('Cancel execution requested', { workflowId, executionId, userId: access.userId }) + + const result = await cancelWorkflowExecution({ + executionId, + workflowId, + userId: access.userId, + workspaceId: access.workflow.workspaceId ?? undefined, + }) + + return v2Data(result) + } catch (error) { + logger.error('Failed to cancel execution', { + workflowId, + executionId, + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts new file mode 100644 index 00000000000..6f7bc473a22 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts @@ -0,0 +1,171 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, workflowAuthzMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAuthenticateV1Request, mockGetJob, mockGetWorkflowExecutionStatus, mockCancel } = + vi.hoisted(() => ({ + mockAuthenticateV1Request: vi.fn(), + mockGetJob: vi.fn(), + mockGetWorkflowExecutionStatus: vi.fn(), + mockCancel: vi.fn(), + })) + +vi.mock('@/app/api/v1/auth', () => ({ + authenticateV1Request: mockAuthenticateV1Request, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBillingSettings: vi.fn().mockResolvedValue({ allowPersonalApiKeys: true }), +})) + +vi.mock('@/lib/workflows/executor/execution-status', () => ({ + getWorkflowExecutionStatus: mockGetWorkflowExecutionStatus, +})) + +vi.mock('@/lib/execution/cancel-workflow-execution', () => ({ + cancelWorkflowExecution: mockCancel, +})) + +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: vi.fn().mockResolvedValue({ getJob: mockGetJob }), +})) + +vi.mock('@/lib/workflows/executor/enqueue-execution', () => ({ + WORKFLOW_EXECUTION_JOB_ID_PREFIX: 'workflow-execution:', +})) + +import { POST as cancelPost } from './cancel/route' +import { GET } from './route' + +const mockAuthorize = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission + +const workflowRecord = { + id: 'workflow-1', + userId: 'owner-1', + workspaceId: 'workspace-1', +} + +function callStatus(query = '') { + const req = createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/v2/workflows/workflow-1/executions/exec-1${query}` + ) + return GET(req, { params: Promise.resolve({ id: 'workflow-1', executionId: 'exec-1' }) }) +} + +describe('v2 executions status + cancel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthenticateV1Request.mockResolvedValue({ + authenticated: true, + userId: 'key-user-1', + keyType: 'workspace', + workspaceId: 'workspace-1', + }) + mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord }) + }) + + it('returns the execution resource with a structured error', async () => { + mockGetWorkflowExecutionStatus.mockResolvedValue({ + executionId: 'exec-1', + workflowId: 'workflow-1', + status: 'failed', + trigger: 'api', + level: 'error', + startedAt: '2026-07-31T00:00:00.000Z', + endedAt: '2026-07-31T00:00:05.000Z', + totalDurationMs: 5000, + paused: null, + cost: { total: 0.02 }, + error: 'Send Email: Invalid credentials', + finalOutput: null, + blockOutputs: null, + }) + + const res = await callStatus() + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.status).toBe('failed') + expect(body.data.error.code).toBe('EXECUTION_FAILED') + expect(body.data.error.message).toBe('Send Email: Invalid credentials') + expect(body.data.durationMs).toBe(5000) + }) + + it('backfills queued status from the job queue before the log row exists', async () => { + mockGetWorkflowExecutionStatus.mockResolvedValue(null) + mockGetJob.mockResolvedValue({ + status: 'pending', + metadata: { workflowId: 'workflow-1' }, + }) + + const res = await callStatus() + + expect(res.status).toBe(200) + expect((await res.json()).data.status).toBe('queued') + expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:exec-1') + }) + + it('404s when neither a log row nor a matching job exists', async () => { + mockGetWorkflowExecutionStatus.mockResolvedValue(null) + mockGetJob.mockResolvedValue(null) + + const res = await callStatus() + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('masks cross-workspace access as 404', async () => { + mockAuthenticateV1Request.mockResolvedValue({ + authenticated: true, + userId: 'key-user-1', + keyType: 'workspace', + workspaceId: 'other-workspace', + }) + + const res = await callStatus() + + expect(res.status).toBe(404) + expect(mockGetWorkflowExecutionStatus).not.toHaveBeenCalled() + }) + + it('cancels through the shared lib and returns the tightened result', async () => { + mockCancel.mockResolvedValue({ + success: true, + executionId: 'exec-1', + redisAvailable: true, + durablyRecorded: true, + locallyAborted: false, + pausedCancelled: false, + reason: 'recorded', + }) + + const req = createMockRequest('POST', undefined, {}) + const res = await cancelPost(req, { + params: Promise.resolve({ id: 'workflow-1', executionId: 'exec-1' }), + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toMatchObject({ success: true, reason: 'recorded' }) + expect(mockCancel).toHaveBeenCalledWith({ + executionId: 'exec-1', + workflowId: 'workflow-1', + userId: 'key-user-1', + workspaceId: 'workspace-1', + }) + }) + + it('401s without an API key (no session/anonymous path on executions)', async () => { + mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + + const res = await callStatus() + + expect(res.status).toBe(401) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts new file mode 100644 index 00000000000..69b52690186 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts @@ -0,0 +1,123 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + type V2WorkflowExecutionStatus, + v2GetWorkflowExecutionContract, +} from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { getJobQueue } from '@/lib/core/async-jobs' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/enqueue-execution' +import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' +import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' +import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' +import { classifyExecutionError } from '@/executor/utils/errors' + +const logger = createLogger('V2WorkflowExecutionStatusAPI') + +export const dynamic = 'force-dynamic' + +/** + * Maps the async job's phase onto the execution status enum for the window + * before the worker writes the durable log row. + */ +function jobStatusToExecutionStatus(jobStatus: string): V2WorkflowExecutionStatus['status'] | null { + switch (jobStatus) { + case 'pending': + return 'queued' + case 'processing': + return 'running' + case 'failed': + return 'failed' + case 'completed': + return 'completed' + default: + return null + } +} + +/** + * GET /api/v2/workflows/[id]/executions/[executionId] — the single status URL + * for both sync and async runs. When no log row exists yet, the async job + * queue is consulted (deterministic job id) so a freshly-queued run reports + * `queued` instead of 404. + */ +export const GET = withRouteHandler( + async ( + request: NextRequest, + context: { params: Promise<{ id: string; executionId: string }> } + ) => { + const parsed = await parseRequest(v2GetWorkflowExecutionContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { id: workflowId, executionId } = parsed.data.params + const { includeOutput, selectedOutputs } = parsed.data.query + + const access = await resolveV2WorkflowAccess(request, workflowId, 'read') + if (!access.ok) return access.response + + try { + const status = await getWorkflowExecutionStatus({ + workflowId, + executionId, + includeOutput, + selectedOutputs, + }) + + if (status) { + return v2Data({ + executionId: status.executionId, + workflowId: status.workflowId, + status: status.status, + trigger: status.trigger ?? null, + startedAt: status.startedAt, + endedAt: status.endedAt, + durationMs: status.totalDurationMs, + paused: status.paused, + cost: status.cost, + error: status.error ? classifyExecutionError(new Error(status.error)) : null, + output: status.finalOutput, + blockOutputs: status.blockOutputs, + }) + } + + // No log row yet — a queued/just-started async run. Backfilled from the + // job queue via the deterministic id; authz already ran above. + const jobQueue = await getJobQueue() + const job = await jobQueue.getJob(`${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${executionId}`) + const jobWorkflowId = + job?.metadata && typeof job.metadata === 'object' + ? (job.metadata as { workflowId?: string }).workflowId + : undefined + const mapped = job ? jobStatusToExecutionStatus(job.status) : null + if (!job || jobWorkflowId !== workflowId || !mapped) { + return v2Error('NOT_FOUND', 'Execution not found') + } + + return v2Data({ + executionId, + workflowId, + status: mapped, + trigger: 'api', + startedAt: null, + endedAt: null, + durationMs: null, + paused: null, + cost: null, + error: + mapped === 'failed' && job.error ? classifyExecutionError(new Error(job.error)) : null, + output: null, + blockOutputs: null, + }) + } catch (error) { + logger.error('Failed to fetch execution status', { + workflowId, + executionId, + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/lib/access.ts b/apps/sim/app/api/v2/workflows/lib/access.ts new file mode 100644 index 00000000000..765933793ee --- /dev/null +++ b/apps/sim/app/api/v2/workflows/lib/access.ts @@ -0,0 +1,59 @@ +import type { workflow as workflowTable } from '@sim/db/schema' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' +import type { NextRequest, NextResponse } from 'next/server' +import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' +import { authenticateV1Request } from '@/app/api/v1/auth' +import { v2Error } from '@/app/api/v2/lib/response' + +type WorkflowRecord = typeof workflowTable.$inferSelect + +export type V2WorkflowAccess = + | { + ok: true + userId: string + keyType: 'personal' | 'workspace' | undefined + workflow: WorkflowRecord + } + | { ok: false; response: NextResponse } + +/** + * X-API-Key auth + workflow authorization for the v2 execution sub-resources. + * Authorization failures and workspace-key scope mismatches are masked as 404 + * so cross-workspace workflow existence never leaks; personal keys honor the + * workspace's `allowPersonalApiKeys` setting. + */ +export async function resolveV2WorkflowAccess( + request: NextRequest, + workflowId: string, + action: 'read' | 'write' +): Promise { + const auth = await authenticateV1Request(request) + if (!auth.authenticated || !auth.userId) { + return { ok: false, response: v2Error('UNAUTHORIZED', auth.error || 'Unauthorized') } + } + + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId: auth.userId, + action, + }) + if (!authorization.allowed || !authorization.workflow) { + return { ok: false, response: v2Error('NOT_FOUND', 'Workflow not found') } + } + const workflow = authorization.workflow as WorkflowRecord + + if (auth.keyType === 'workspace' && workflow.workspaceId !== auth.workspaceId) { + return { ok: false, response: v2Error('NOT_FOUND', 'Workflow not found') } + } + if (auth.keyType === 'personal' && workflow.workspaceId) { + const settings = await getWorkspaceBillingSettings(workflow.workspaceId) + if (!settings?.allowPersonalApiKeys) { + return { + ok: false, + response: v2Error('FORBIDDEN', 'Personal API keys are not allowed for this workspace'), + } + } + } + + return { ok: true, userId: auth.userId, keyType: auth.keyType, workflow } +} diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index fdb6daeea6b..b721f347b7c 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -9,7 +9,13 @@ import { v1WorkflowExportPayloadSchema, } from '@/lib/api/contracts/v1/workflows' import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' -import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows' +import { + cancelWorkflowExecutionReasonSchema, + workflowExecutionParamsSchema, + workflowExecutionPausedDetailSchema, + workflowExecutionStatusQuerySchema, + workflowIdParamsSchema, +} from '@/lib/api/contracts/workflows' /** * v2 workflows contracts. Request shapes are reused verbatim from v1 (the list @@ -201,6 +207,61 @@ export const v2ExecuteWorkflowContract = defineRouteContract({ }, }) +/** + * The polled execution resource. `queued` is backfilled from the async job + * queue before the worker writes the durable log row — v1's jobs endpoint 404 + * window doesn't exist here. `error` is the same structured object the execute + * response carries. + */ +export const v2WorkflowExecutionStatusSchema = z.object({ + executionId: z.string(), + workflowId: z.string(), + status: z.enum(['queued', 'pending', 'running', 'completed', 'failed', 'cancelled', 'paused']), + trigger: z.string().nullable(), + startedAt: z.string().nullable(), + endedAt: z.string().nullable(), + durationMs: z.number().nullable(), + paused: workflowExecutionPausedDetailSchema.nullable(), + cost: z.object({ total: z.number() }).nullable(), + error: v2ExecutionErrorSchema.nullable(), + /** Populated only with `includeOutput=true` on completed runs. */ + output: z.unknown().nullable(), + blockOutputs: z.record(z.string(), z.unknown()).nullable(), +}) +export type V2WorkflowExecutionStatus = z.output + +export const v2GetWorkflowExecutionContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]/executions/[executionId]', + params: workflowExecutionParamsSchema, + query: workflowExecutionStatusQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowExecutionStatusSchema), + }, +}) + +export const v2CancelWorkflowExecutionDataSchema = z.object({ + success: z.boolean(), + executionId: z.string(), + redisAvailable: z.boolean(), + durablyRecorded: z.boolean(), + locallyAborted: z.boolean(), + pausedCancelled: z.boolean(), + reason: cancelWorkflowExecutionReasonSchema.optional(), +}) +export type V2CancelWorkflowExecutionData = z.output + +export const v2CancelWorkflowExecutionContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/executions/[executionId]/cancel', + params: workflowExecutionParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CancelWorkflowExecutionDataSchema), + }, +}) + /** * Export/import reuse the v1 payload and body schemas verbatim — the portable * envelope must round-trip across both surfaces — with only the response diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index b4228468fc7..d429917fe4b 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -551,7 +551,7 @@ const workflowExecutionStatusEnum = z.enum([ 'cancelled', ]) -const workflowExecutionPausedDetailSchema = z.object({ +export const workflowExecutionPausedDetailSchema = z.object({ pausedAt: z.string(), resumeAt: z.string().nullable(), pauseKind: z.enum(['time', 'human']).nullable(), @@ -580,7 +580,7 @@ const workflowExecutionStatusResponseSchema = z.object({ export type WorkflowExecutionStatusResponse = z.output -const workflowExecutionStatusQuerySchema = z.object({ +export const workflowExecutionStatusQuerySchema = z.object({ includeOutput: z .enum(['true', 'false']) .optional() From c411f6e10bcde756d4ae0c4dbf14ba8a79ea2349 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 11:05:30 -0700 Subject: [PATCH 018/159] feat(execution): workflow tool + MCP bridge run in-process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workflow_executor (workflow-as-agent-tool) short-circuits in executeTool through WorkflowBlockHandler — the same invocation boundary canvas child workflows use — mirroring the deployed_block_executor precedent. The MCP serve bridge calls executeWorkflowService directly instead of fetching its own execute endpoint; deployment-version pinning, MCP response-size rejection, and the actor override become typed options instead of header sniffing. Both callers drop the double admission slot and duplicate top-level log row the HTTP hop cost, and failed child runs now surface the structured error + child executionId so parents and MCP clients can route on error class and hand providers a reproducible handle. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- .../api/mcp/serve/[serverId]/route.test.ts | 384 ++++++++++-------- .../sim/app/api/mcp/serve/[serverId]/route.ts | 164 ++++---- .../workflow/custom-block-tool-runner.ts | 2 +- .../handlers/workflow/workflow-tool-runner.ts | 93 +++++ apps/sim/tools/index.ts | 21 + 5 files changed, 411 insertions(+), 253 deletions(-) create mode 100644 apps/sim/executor/handlers/workflow/workflow-tool-runner.ts diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index 99bf39603b6..837aec3a24b 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -16,12 +16,14 @@ import { NextRequest } from 'next/server' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { + mockExecuteWorkflowService, mockAssertBillingAttributionSnapshot, mockGenerateInternalToken, mockResolveBillingAttribution, mockSerializeBillingAttributionHeader, fetchMock, } = vi.hoisted(() => ({ + mockExecuteWorkflowService: vi.fn(), mockAssertBillingAttributionSnapshot: vi.fn(), mockGenerateInternalToken: vi.fn(), mockResolveBillingAttribution: vi.fn(), @@ -65,6 +67,10 @@ vi.mock('@/lib/core/execution-limits', () => ({ getMaxExecutionTimeout: () => 10_000, })) +vi.mock('@/lib/workflows/executor/execute-service', () => ({ + executeWorkflowService: mockExecuteWorkflowService, +})) + import { DELETE, GET, POST } from '@/app/api/mcp/serve/[serverId]/route' describe('MCP Serve Route', () => { @@ -230,7 +236,7 @@ describe('MCP Serve Route', () => { expect(response.status).toBe(401) }) - it('uses an internal bridge token for private server api_key auth', async () => { + it('executes in-process with the personal-key actor override for private server api_key auth', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -251,13 +257,16 @@ describe('MCP Serve Route', () => { apiKeyType: 'personal', }) mockGetUserEntityPermissions.mockResolvedValueOnce('write') - mockGenerateInternalToken.mockResolvedValueOnce('internal-token-user-1') - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ output: { ok: true } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'completed', + aborted: null, + output: { ok: true }, + error: null, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -272,21 +281,25 @@ describe('MCP Serve Route', () => { const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) expect(response.status).toBe(200) - expect(fetchMock).toHaveBeenCalledTimes(1) - const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit - const headers = fetchOptions.headers as Record - expect(headers.Authorization).toBe('Bearer internal-token-user-1') - expect(headers['X-Sim-MCP-Tool-Actor']).toBe('authenticated-user') - expect(headers['x-sim-billing-attribution']).toBe('serialized-attribution') - expect(headers['X-API-Key']).toBeUndefined() - expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-1') + expect(mockExecuteWorkflowService).toHaveBeenCalledTimes(1) + expect(mockExecuteWorkflowService).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'wf-1', + userId: 'user-1', + triggerType: 'mcp', + useAuthenticatedUserAsActor: true, + deploymentVersionId: 'deployment-1', + includeFileBase64: false, + rejectLargeInlineOutput: true, + }) + ) expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ actorUserId: 'user-1', workspaceId: 'ws-1', }) }) - it('forwards internal token for private server session auth', async () => { + it('executes in-process without the actor override for private server session auth', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -306,13 +319,16 @@ describe('MCP Serve Route', () => { authType: 'session', }) mockGetUserEntityPermissions.mockResolvedValueOnce('read') - mockGenerateInternalToken.mockResolvedValueOnce('internal-token-user-1') - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ output: { ok: true } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'completed', + aborted: null, + output: { ok: true }, + error: null, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -326,14 +342,12 @@ describe('MCP Serve Route', () => { const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) expect(response.status).toBe(200) - expect(fetchMock).toHaveBeenCalledTimes(1) - const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit - const headers = fetchOptions.headers as Record - expect(headers.Authorization).toBe('Bearer internal-token-user-1') - expect(headers['X-Sim-MCP-Tool-Actor']).toBeUndefined() - expect(headers['x-sim-billing-attribution']).toBe('serialized-attribution') - expect(headers['X-API-Key']).toBeUndefined() - expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-1') + expect(mockExecuteWorkflowService).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + useAuthenticatedUserAsActor: false, + }) + ) expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ actorUserId: 'user-1', workspaceId: 'ws-1', @@ -353,13 +367,16 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - mockGenerateInternalToken.mockResolvedValueOnce('internal-token-owner-1') - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ output: { ok: true } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'completed', + aborted: null, + output: { ok: true }, + error: null, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -380,12 +397,9 @@ describe('MCP Serve Route', () => { }) const attribution = createBillingAttribution('owner-1', 'ws-1') expect(mockAssertBillingAttributionSnapshot).toHaveBeenCalledWith(attribution) - expect(mockSerializeBillingAttributionHeader).toHaveBeenCalledWith(attribution) - const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit - const headers = fetchOptions.headers as Record - expect(headers.Authorization).toBe('Bearer internal-token-owner-1') - expect(headers['x-sim-billing-attribution']).toBe('serialized-attribution') - expect(headers['x-sim-billing-attribution']).not.toBe('caller-controlled-attribution') + expect(mockExecuteWorkflowService).toHaveBeenCalledWith( + expect.objectContaining({ upstreamBillingAttribution: attribution, userId: 'owner-1' }) + ) }) it.each([null, 'ws-other'])( @@ -545,8 +559,7 @@ describe('MCP Serve Route', () => { expect(fetchMock).not.toHaveBeenCalled() }) - it('cancels and rejects oversized workflow execution responses', async () => { - const cancelSpy = vi.fn() + it('maps oversized workflow outputs to the response-direction 413', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -559,17 +572,17 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce( - new Response( - new ReadableStream({ - cancel: cancelSpy, - }), - { - status: 200, - headers: { 'content-length': String(MCP_BYTE_LIMIT + 1) }, - } - ) - ) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: false, + failure: { + kind: 'output_too_large', + statusCode: 413, + message: 'Workflow execution response exceeds maximum size', + code: 'workflow_response_too_large', + executionId: 'exec-1', + }, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -580,17 +593,19 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() expect(response.status).toBe(413) - expect(body.error.message).toContain('MCP workflow execution response') - expect(cancelSpy).toHaveBeenCalled() + expect(body.error.data.httpStatus).toBe(413) + // Response-direction 413 keeps the workflow_response_too_large code so + // clients can distinguish it from a request-side payload rejection. + expect(body.error.data.code).toBe('workflow_response_too_large') + expect(body.error.data.executionId).toBe('exec-1') }) - it('cancels and rejects streamed workflow responses that exceed the cap', async () => { - const cancelSpy = vi.fn() + it('surfaces rate-limit failures with Retry-After and the retryable flag', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -603,21 +618,17 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce( - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(MCP_BYTE_LIMIT)) - controller.enqueue(new Uint8Array(1)) - }, - cancel: cancelSpy, - }), - { - status: 200, - headers: { 'content-length': '1' }, - } - ) - ) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: false, + failure: { + kind: 'precheck', + statusCode: 429, + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + retryAfterMs: 9_000, + }, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -628,13 +639,14 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() - expect(response.status).toBe(413) - expect(body.error.message).toContain('MCP workflow execution response') - expect(cancelSpy).toHaveBeenCalled() + expect(response.status).toBe(429) + expect(response.headers.get('Retry-After')).toBe('9') + expect(body.error.data.retryable).toBe(true) + expect(body.error.data.code).toBe('RATE_LIMIT_EXCEEDED') }) it('preserves recoverable workflow execution statuses through the MCP bridge', async () => { @@ -650,18 +662,15 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: false, - error: 'Workflow execution request body exceeds maximum size', - }), - { - status: 413, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: false, + failure: { + kind: 'infra', + statusCode: 503, + message: 'Error checking rate limits', + }, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -672,19 +681,13 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() - expect(response.status).toBe(413) - expect(body.error.code).toBe(-32600) - expect(body.error.data.httpStatus).toBe(413) - const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit - const headers = fetchOptions.headers as Record - expect(headers['X-Sim-MCP-Tool-Call']).toBe('true') - expect(JSON.parse(fetchOptions.body as string)).toMatchObject({ - deploymentVersionId: 'deployment-1', - }) + expect(response.status).toBe(503) + expect(body.error.data.httpStatus).toBe(503) + expect(body.error.data.retryable).toBe(true) }) it('preserves downstream attributed usage admission rejections', async () => { @@ -700,18 +703,15 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - success: false, - error: 'Workspace usage limit exceeded.', - }), - { - status: 402, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: false, + failure: { + kind: 'precheck', + statusCode: 402, + message: 'Workspace usage limit exceeded.', + }, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -722,16 +722,16 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a' }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() expect(response.status).toBe(402) - expect(body.error.message).toBe('Workspace usage limit exceeded.') expect(body.error.data.httpStatus).toBe(402) + expect(body.error.message).toBe('Workspace usage limit exceeded.') }) - it('preserves upstream error status when workflow response is not JSON', async () => { + it('maps the sync timeout onto the retryable 408 shape', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -744,7 +744,17 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce(new Response('gateway timeout', { status: 408 })) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'failed', + aborted: 'timeout', + output: undefined, + error: { message: 'Execution timed out after 60000ms', code: 'TIMEOUT' }, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -755,13 +765,14 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() expect(response.status).toBe(408) expect(body.error.data.httpStatus).toBe(408) expect(body.error.data.retryable).toBe(true) + expect(body.error.data.code).toBe('TIMEOUT') }) it('preserves falsy workflow outputs in MCP tool results', async () => { @@ -777,12 +788,17 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ success: true, output: false }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'completed', + aborted: null, + output: false, + error: null, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -793,15 +809,16 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() expect(response.status).toBe(200) expect(body.result.content[0].text).toBe('false') + expect(body.result.isError).toBe(false) }) - it('serializes missing workflow output without failing the MCP tool call', async () => { + it('serializes failed runs with the structured error and child executionId', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -814,12 +831,23 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ success: true }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-9', + workflowId: 'wf-1', + status: 'failed', + aborted: null, + output: { partial: true }, + error: { + message: 'Invalid credentials', + code: 'BLOCK_EXECUTION_FAILED', + blockId: 'b-1', + blockName: 'Send Email', + blockType: 'gmail', + }, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -830,27 +858,32 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() expect(response.status).toBe(200) - expect(body.result.content[0].text).toContain('"success": true') + expect(body.result.isError).toBe(true) + const text = body.result.content[0].text + expect(text).toContain('"executionId": "exec-9"') + expect(text).toContain('"code": "BLOCK_EXECUTION_FAILED"') + expect(text).toContain('"blockName": "Send Email"') }) - it('serializes non-object workflow JSON responses from response blocks', async () => { + it('serializes non-object workflow outputs', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { id: 'server-1', - name: 'Private Server', + name: 'Public Server', workspaceId: 'ws-1', - isPublic: false, + isPublic: true, createdBy: 'owner-1', }, ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ success: true, userId: 'user-1', @@ -858,12 +891,16 @@ describe('MCP Serve Route', () => { apiKeyType: 'personal', }) mockGetUserEntityPermissions.mockResolvedValueOnce('write') - fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify(['a', 'b']), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'completed', + aborted: null, + output: ['a', 'b'], + error: null, + hasResponseBlock: false, + }) const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', @@ -875,12 +912,12 @@ describe('MCP Serve Route', () => { params: { name: 'tool_a', arguments: { q: 'test' } }, }), }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() expect(response.status).toBe(200) - expect(body.result.content[0].text).toBe(JSON.stringify(['a', 'b'], null, 2)) + expect(JSON.parse(body.result.content[0].text)).toEqual(['a', 'b']) }) it('rejects duplicate tool names instead of choosing an arbitrary workflow', async () => { @@ -917,8 +954,7 @@ describe('MCP Serve Route', () => { expect(fetchMock).not.toHaveBeenCalled() }) - it('aborts the internal workflow fetch when the MCP client disconnects', async () => { - const requestAbortController = new AbortController() + it('maps a client-aborted run onto ConnectionClosed', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -931,36 +967,34 @@ describe('MCP Serve Route', () => { ]) .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) - fetchMock.mockImplementationOnce((_url, init: RequestInit) => { - const signal = init.signal as AbortSignal - return new Promise((_resolve, reject) => { - signal.addEventListener( - 'abort', - () => { - reject(Object.assign(new Error('aborted'), { name: 'AbortError' })) - }, - { once: true } - ) - requestAbortController.abort() - }) - }) - const req = new NextRequest( - new Request('http://localhost:3000/api/mcp/serve/server-1', { - method: 'POST', - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { name: 'tool_a', arguments: { q: 'test' } }, - }), - signal: requestAbortController.signal, - }) - ) + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'cancelled', + aborted: 'client', + output: undefined, + error: { message: 'Client cancelled request', code: 'CANCELLED' }, + hasResponseBlock: false, + }) + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'tool_a', arguments: { q: 'test' } }, + }), + }) const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + expect(response.status).toBe(499) + expect(body.error.data.httpStatus).toBe(499) + expect(body.error.data.executionId).toBe('exec-1') }) it('paginates tools/list by tool count', async () => { diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index e342f35e343..a60436ec6cf 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -35,34 +35,29 @@ import { mcpToolCallParamsSchema, } from '@/lib/api/contracts/mcp' import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid' -import { generateInternalToken } from '@/lib/auth/internal' import { assertBillingAttributionSnapshot, - BILLING_ATTRIBUTION_HEADER, type BillingAttributionSnapshot, resolveBillingAttribution, - serializeBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import { generateRequestId } from '@/lib/core/utils/request' import { assertContentLengthWithinLimit, assertKnownSizeWithinLimit, isPayloadSizeLimitError, - readResponseTextWithLimit, readStreamToBufferWithLimit, } from '@/lib/core/utils/stream-limits' -import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { SIM_VIA_HEADER } from '@/lib/execution/call-chain' +import { parseCallChain, SIM_VIA_HEADER } from '@/lib/execution/call-chain' import { MAX_MCP_PARAMETER_SCHEMA_BYTES, MAX_MCP_TOOLS_LIST_RESPONSE_BYTES, MAX_MCP_TOOLS_PER_SERVER, MAX_MCP_WORKFLOW_RESPONSE_BYTES, - MCP_TOOL_BRIDGE_ACTOR_HEADER, - MCP_TOOL_BRIDGE_HEADER, } from '@/lib/mcp/constants' import { getMeaningfulWorkflowDescription } from '@/lib/mcp/workflow-tool-schema' +import { executeWorkflowService } from '@/lib/workflows/executor/execute-service' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkflowMcpServeAPI') @@ -281,21 +276,6 @@ async function getDuplicateToolName(serverId: string): Promise { return duplicate?.toolName ?? null } -async function readWorkflowExecutionResult( - response: Response, - signal: AbortSignal -): Promise { - const text = await readResponseTextWithLimit(response, { - maxBytes: MAX_MCP_WORKFLOW_RESPONSE_BYTES, - label: 'MCP workflow execution response', - signal, - }) - const parsed = parseJsonValue(text) - if (parsed.success) return parsed.value - if (!response.ok) return { error: response.statusText || 'Workflow execution failed' } - throw new Error('Invalid workflow execution response') -} - async function getServer(serverId: string) { const [server] = await db .select({ @@ -809,83 +789,113 @@ async function handleToolsCall( wf.workspaceId ) - const executeUrl = `${getInternalApiBaseUrl()}/api/workflows/${tool.workflowId}/execute` - const headers: Record = { - 'Content-Type': 'application/json', - [BILLING_ATTRIBUTION_HEADER]: serializeBillingAttributionHeader(billingAttribution), - [MCP_TOOL_BRIDGE_HEADER]: 'true', - } - const abortedBeforeExecute = callerAbortedJsonRpcResponse(id, abortSignal) if (abortedBeforeExecute) return abortedBeforeExecute - const internalToken = await generateInternalToken(actorUserId) - headers.Authorization = `Bearer ${internalToken}` - if (executeAuthContext?.useAuthenticatedUserAsActor) { - headers[MCP_TOOL_BRIDGE_ACTOR_HEADER] = 'authenticated-user' - } - - if (simViaHeader) { - headers[SIM_VIA_HEADER] = simViaHeader - } - - logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`) + logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name} (in-process)`) - const workflowRequestBody = JSON.stringify({ - input: params.arguments || {}, - triggerType: 'mcp', - includeFileBase64: false, - ...(wf.deploymentVersionId ? { deploymentVersionId: wf.deploymentVersionId } : {}), - }) + const workflowInput = params.arguments || {} assertKnownSizeWithinLimit( - Buffer.byteLength(workflowRequestBody, 'utf-8'), + Buffer.byteLength(JSON.stringify(workflowInput), 'utf-8'), MAX_MCP_WORKFLOW_REQUEST_BYTES, 'MCP workflow execution request body' ) - const response = await fetch(executeUrl, { - method: 'POST', - headers, - body: workflowRequestBody, - signal: abortSignal.signal, - }) - const executeResult = await readWorkflowExecutionResult(response, abortSignal.signal) - const executeResultObject = isJsonObject(executeResult) ? executeResult : null + /** + * In-process execution replaces the historical HTTP hop to the execute + * endpoint: the bridge's special needs — deployment-version pinning, MCP + * response-size rejection, actor override — are typed options instead of + * header sniffing, and billing attribution is passed as the immutable + * upstream snapshot exactly as the header carried it. + */ + const serviceResult = await executeWorkflowService({ + workflowId: tool.workflowId, + userId: actorUserId, + input: workflowInput, + triggerType: 'mcp', + requestId: generateRequestId(), + useAuthenticatedUserAsActor: executeAuthContext?.useAuthenticatedUserAsActor ?? false, + upstreamBillingAttribution: billingAttribution, + deploymentVersionId: wf.deploymentVersionId, + includeFileBase64: false, + rejectLargeInlineOutput: true, + callChain: simViaHeader ? parseCallChain(simViaHeader) : undefined, + abortSignal: abortSignal.signal, + }) - if (!response.ok) { - const errorMessage = - typeof executeResultObject?.error === 'string' - ? executeResultObject.error - : 'Workflow execution failed' - const status = getWorkflowErrorStatus(response.status) + if (!serviceResult.ok) { + const failure = serviceResult.failure + const status = getWorkflowErrorStatus(failure.statusCode) const responseHeaders: Record = {} - const retryAfter = response.headers.get('retry-after') - if (retryAfter) responseHeaders['Retry-After'] = retryAfter + if (failure.retryAfterMs !== undefined) { + responseHeaders['Retry-After'] = Math.max( + 1, + Math.ceil(failure.retryAfterMs / 1000) + ).toString() + } return NextResponse.json( createError( id, - getWorkflowErrorCode(response.status, executeResultObject ?? {}), - errorMessage, + getWorkflowErrorCode(failure.statusCode, { code: failure.code }), + failure.message, { - httpStatus: response.status, - retryable: [408, 429, 503].includes(response.status), - code: - typeof executeResultObject?.code === 'string' ? executeResultObject.code : undefined, + httpStatus: failure.statusCode, + retryable: [408, 429, 503].includes(failure.statusCode), + code: failure.code, + ...(failure.executionId ? { executionId: failure.executionId } : {}), } ), { status, headers: responseHeaders } ) } - const toolOutput = - executeResultObject?.success === false - ? executeResult - : executeResultObject && hasResponseField(executeResultObject, 'output') - ? executeResultObject.output - : executeResult + if ('queued' in serviceResult || 'stream' in serviceResult) { + // The bridge never requests async or stream modes. + throw new Error('Unexpected execution mode result for MCP tool call') + } + + if (serviceResult.aborted === 'client') { + return NextResponse.json( + createError(id, ErrorCode.ConnectionClosed, 'Client cancelled request', { + httpStatus: 499, + retryable: false, + executionId: serviceResult.executionId, + }), + { status: 499 } + ) + } + + if (serviceResult.aborted === 'timeout') { + return NextResponse.json( + createError( + id, + ErrorCode.InternalError, + serviceResult.error?.message ?? 'Execution timed out', + { + httpStatus: 408, + retryable: true, + code: 'TIMEOUT', + executionId: serviceResult.executionId, + } + ), + { status: 408 } + ) + } + + const isError = serviceResult.status !== 'completed' + const toolOutput = isError + ? { + success: false, + executionId: serviceResult.executionId, + output: serviceResult.output ?? {}, + // Structured error: parents/clients route on `code` and hand the + // provider the executionId to reproduce the failure. + error: serviceResult.error, + } + : (serviceResult.output ?? {}) const result: CallToolResult = { content: [{ type: 'text', text: serializeToolText(toolOutput) }], - isError: executeResultObject?.success === false, + isError, } return createJsonRpcResponseWithLimit( diff --git a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts index bd9db63949c..5f615bf975b 100644 --- a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts +++ b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts @@ -14,7 +14,7 @@ import type { ToolResponse } from '@/tools/types' const logger = createLogger('CustomBlockToolRunner') /** Server-set execution context propagated to every agent tool call. */ -interface CustomBlockExecutorContext { +export interface CustomBlockExecutorContext { workspaceId?: string userId?: string workflowId?: string diff --git a/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts new file mode 100644 index 00000000000..e4905adacb1 --- /dev/null +++ b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts @@ -0,0 +1,93 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { ChildWorkflowError } from '@/executor/errors/child-workflow-error' +import { + buildCustomBlockExecutionContext, + type CustomBlockExecutorContext, +} from '@/executor/handlers/workflow/custom-block-tool-runner' +import { + aggregateChildCost, + WorkflowBlockHandler, +} from '@/executor/handlers/workflow/workflow-handler' +import { classifyExecutionError } from '@/executor/utils/errors' +import { parseJSON } from '@/executor/utils/json' +import type { SerializedBlock } from '@/serializer/types' +import type { ToolResponse } from '@/tools/types' + +const logger = createLogger('WorkflowToolRunner') + +interface WorkflowToolParams { + workflowId?: string + inputMapping?: Record | string + _context?: CustomBlockExecutorContext +} + +/** + * Runs a workflow selected as an Agent tool (`workflow_executor`) in-process + * via `WorkflowBlockHandler` — the same invocation boundary canvas child + * workflows use — replacing the historical HTTP hop to the execute endpoint. + * One admission slot, one top-level log row, cost rolled into the parent + * trace, and the workspace assert / call-chain depth / deployment checks the + * handler already enforces. + * + * On failure the result carries the structured error + the child executionId + * in `output` so parent workflows can route on `error.code` and report a + * reproducible handle to the workflow's provider. + */ +export async function runWorkflowTool(params: WorkflowToolParams): Promise { + if (!params.workflowId) { + return { success: false, output: {}, error: 'Missing workflowId' } + } + + const ctx = buildCustomBlockExecutionContext(params._context ?? {}) + const block: SerializedBlock = { + id: generateId(), + position: { x: 0, y: 0 }, + config: { tool: 'workflow_executor', params: {} }, + inputs: {}, + outputs: {}, + metadata: { id: 'workflow_input' }, + enabled: true, + } + + let inputMapping = params.inputMapping ?? {} + if (typeof inputMapping === 'string') { + inputMapping = parseJSON(inputMapping, {}) as Record + } + + try { + const output = await new WorkflowBlockHandler().execute(ctx, block, { + workflowId: params.workflowId, + inputMapping, + }) + const normalized: Record = + output && typeof output === 'object' && !Array.isArray(output) + ? (output as Record) + : { result: output } + return { success: true, output: normalized } + } catch (error) { + const message = getErrorMessage(error, 'Workflow execution failed') + const isChildError = ChildWorkflowError.isChildWorkflowError(error) + const failedChildSpans = isChildError ? error.childTraceSpans : [] + const childCost = aggregateChildCost(failedChildSpans) + const executionResult = isChildError ? error.executionResult : undefined + const structured = classifyExecutionError(error, executionResult) + const childExecutionId = executionResult?.metadata?.executionId + + logger.info('Workflow tool execution failed', { + workflowId: params.workflowId, + message, + code: structured.code, + }) + return { + success: false, + output: { + ...(childCost > 0 ? { cost: { total: childCost } } : {}), + ...(childExecutionId ? { executionId: childExecutionId } : {}), + error: structured, + }, + error: message, + } + } +} diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index f19c4537d88..9e6fd36b6a5 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1271,6 +1271,27 @@ export async function executeTool( // tool registry never pulls in the executor/db dependency graph (a static or // dynamic executor import in the tool descriptor itself would break the client // build — and with it `getTool('workflow_executor')`). + // Workflow-as-agent-tool runs in-process through WorkflowBlockHandler — + // the same invocation boundary canvas child workflows use. Replaces the + // historical HTTP hop to /api/workflows/{id}/execute (double admission + // slot + duplicate top-level log row); billing/observability now match the + // canvas workflow block. + if (normalizedToolId === 'workflow_executor') { + logger.info(`[${requestId}] Running workflow tool ${toolId} in-process`) + const { runWorkflowTool } = await import('@/executor/handlers/workflow/workflow-tool-runner') + const result = await runWorkflowTool(contextParams) + const endTime = new Date() + return { + ...result, + output: postProcessToolOutput(normalizedToolId, result.output ?? {}), + timing: { + startTime: startTimeISO, + endTime: endTime.toISOString(), + duration: endTime.getTime() - startTime.getTime(), + }, + } + } + if (normalizedToolId === 'deployed_block_executor') { logger.info(`[${requestId}] Running custom block tool ${toolId}`) const { runCustomBlockTool } = await import( From 63fcfe29186ab5bd023b9c53ad0d54acd321a3f4 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 11:06:20 -0700 Subject: [PATCH 019/159] feat(infra): CORS + CSP coverage for the v2 execute path /api/v2/workflows/:id/execute gets the same wildcard-origin, credential-free CORS policy as v1 (the default credentialed policy would block browser API-key calls and open a cookie CSRF surface) with X-Sim-Stream-Protocol allowed and no X-Execution-Mode (async is body-selected on v2), plus the COEP/COOP/CSP header block. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- apps/sim/next.config.ts | 11 +++++++++++ apps/sim/proxy.test.ts | 24 ++++++++++++++++++++++++ apps/sim/proxy.ts | 15 +++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 463c5683ce9..b72bec6f1ec 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -254,6 +254,17 @@ const nextConfig: NextConfig = { }, ], }, + { + source: '/api/v2/workflows/:id/execute', + headers: [ + { key: 'Cross-Origin-Embedder-Policy', value: 'unsafe-none' }, + { key: 'Cross-Origin-Opener-Policy', value: 'unsafe-none' }, + { + key: 'Content-Security-Policy', + value: getWorkflowExecutionCSPPolicy(), + }, + ], + }, { // Exclude Vercel internal resources and static assets from strict COEP, Google Drive Picker // and the /demo Cal.com booking embed to prevent 'refused to connect' / slow-load issues diff --git a/apps/sim/proxy.test.ts b/apps/sim/proxy.test.ts index 3dc7da2be17..2ee2476e2c4 100644 --- a/apps/sim/proxy.test.ts +++ b/apps/sim/proxy.test.ts @@ -90,6 +90,29 @@ describe('resolveApiCorsPolicy', () => { expect(policy.headers).toContain('X-Execution-Id') }) + it('serves v2 workflow execute with wildcard origin and the stream-protocol header', () => { + const policy = resolveApiCorsPolicy( + makeRequest('/api/v2/workflows/workflow-123/execute', 'https://other.example') + ) + expect(policy.origin).toBe('*') + expect(policy.credentials).toBe(false) + expect(policy.headers).toContain('X-Execution-Id') + expect(policy.headers).toContain('X-Sim-Stream-Protocol') + // Async is body-selected on v2 — the mode header is deliberately absent. + expect(policy.headers).not.toContain('X-Execution-Mode') + }) + + it('does not match the v2 execute rule for nested or executions paths', () => { + const nested = resolveApiCorsPolicy( + makeRequest('/api/v2/workflows/workflow-123/execute/extra', 'https://other.example') + ) + expect(nested.origin).toBe('https://app.sim.test') + const executions = resolveApiCorsPolicy( + makeRequest('/api/v2/workflows/workflow-123/executions/e-1', 'https://other.example') + ) + expect(executions.origin).toBe('https://app.sim.test') + }) + it('does not match the workflow execute rule for nested paths', () => { const policy = resolveApiCorsPolicy( makeRequest('/api/workflows/workflow-123/execute/extra', 'https://other.example') @@ -113,6 +136,7 @@ describe('resolveApiCorsPolicy', () => { '/api/mcp/copilot', '/api/chat/abc', '/api/workflows/wf/execute', + '/api/v2/workflows/wf/execute', '/api/files/upload', ] for (const path of paths) { diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 73d03e9b797..394151d3a51 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -23,6 +23,9 @@ const DEFAULT_API_ALLOWED_HEADERS = const WORKFLOW_EXECUTE_HEADERS = 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, X-Execution-Id' +/** v2 execute: async is body-selected (no X-Execution-Mode) and streaming negotiates X-Sim-Stream-Protocol. */ +const WORKFLOW_EXECUTE_V2_HEADERS = `${WORKFLOW_EXECUTE_HEADERS}, X-Sim-Stream-Protocol` + /** Subpaths under /api/chat/* that serve the workspace UI, not embeds. */ const EMBED_RESERVED_SEGMENTS = new Set(['manage', 'validate']) @@ -82,6 +85,18 @@ const CORS_RULES: readonly CorsRule[] = [ headers: WORKFLOW_EXECUTE_HEADERS, }), }, + { + // Mirrors the v1 rule: public execute endpoints are wildcard-origin and + // credential-free — the default credentialed policy would both block + // browser API-key calls and open a cookie-bearing CSRF surface. + match: (p) => /^\/api\/v2\/workflows\/[^/]+\/execute$/.test(p), + policy: () => ({ + origin: '*', + credentials: false, + methods: 'POST,OPTIONS', + headers: WORKFLOW_EXECUTE_V2_HEADERS, + }), + }, ] /** Single source of truth for /api/* CORS — resolved at request time, not baked at build. */ From 272de325b98d2130b3f21c5ea99a53755fb09e01 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 11:09:40 -0700 Subject: [PATCH 020/159] feat(ui): deploy modal + copilot advertise the v2 execute surface All 20 API-tab snippets move to POST /api/v2/workflows/{id}/execute with the nested {"input": ...} body, async as the "async": true body flag (X-Execution-Mode gone), status polling against the v2 executions resource, the third tab renamed Usage and pointed at /api/v2/billing/usage, and {data} envelope unwraps in the printed responses. Fixes the latent baseUrl derivation (endpoint.split('/api/workflows/')) that would have silently built garbage URLs under a v2 endpoint, and deletes dead code (exampleCommand across 3 sites, getAsyncExampleTitle). Copilot deploy/manage/serializer endpoint builders and the api_trigger bestPractices example follow (the latter also drops its hardcoded staging host). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- .../deploy-modal/components/api/api.tsx | 96 ++++++++----------- .../components/deploy-modal/deploy-modal.tsx | 4 +- apps/sim/blocks/blocks/api_trigger.ts | 2 +- .../tools/handlers/deployment/deploy.ts | 12 +-- .../tools/handlers/deployment/manage.ts | 2 +- apps/sim/lib/copilot/vfs/serializers.ts | 2 +- 6 files changed, 51 insertions(+), 67 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx index a5950e82f60..9dff329a647 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx @@ -12,6 +12,7 @@ import { Tooltip, } from '@sim/emcn' import { Check, Clipboard } from 'lucide-react' +import { getBaseUrl } from '@/lib/core/utils/urls' import { AGENT_STREAM_PROTOCOL_HEADER_LABEL, AGENT_STREAM_PROTOCOL_V1, @@ -24,7 +25,6 @@ interface WorkflowDeploymentInfo { deployedAt?: string apiKey: string endpoint: string - exampleCommand: string needsRedeployment: boolean isPublicApi?: boolean } @@ -39,7 +39,7 @@ interface ApiDeployProps { onSelectedStreamingOutputsChange: (outputs: string[]) => void } -type AsyncExampleType = 'execute' | 'status' | 'rate-limits' +type AsyncExampleType = 'execute' | 'status' | 'usage' type CodeLanguage = 'curl' | 'python' | 'javascript' | 'typescript' type CopiedState = { @@ -97,19 +97,23 @@ export function ApiDeploy({ return info.endpoint.replace(info.apiKey, '$SIM_API_KEY') } - const getPayloadObject = (): Record => { + /** The workflow's example input fields, parsed from the shared example command. */ + const getInputObject = (): Record => { const inputExample = getInputFormatExample ? getInputFormatExample(false) : '' const match = inputExample.match(/-d\s*'([\s\S]*)'/) if (match) { try { return JSON.parse(match[1]) as Record } catch { - return { input: 'your data here' } + return { key: 'value' } } } - return { input: 'your data here' } + return { key: 'value' } } + /** v2 body: the input nests under `input`; control fields are siblings. */ + const getPayloadObject = (): Record => ({ input: getInputObject() }) + const getStreamPayloadObject = (): Record => { const payload: Record = { ...getPayloadObject(), stream: true } if (selectedStreamingOutputs && selectedStreamingOutputs.length > 0) { @@ -148,7 +152,7 @@ ${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')} ) -print(response.json())` +print(response.json()["data"])` case 'javascript': return `const response = await fetch("${endpoint}", { @@ -159,7 +163,7 @@ ${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Typ body: JSON.stringify(${JSON.stringify(payload)}) }); -const data = await response.json(); +const { data } = await response.json(); console.log(data);` case 'typescript': @@ -171,7 +175,7 @@ ${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Typ body: JSON.stringify(${JSON.stringify(payload)}) }); -const data: Record = await response.json(); +const { data }: { data: Record } = await response.json(); console.log(data);` default: @@ -261,8 +265,8 @@ while (true) { const getAsyncCommand = (): string => { if (!info) return '' const endpoint = getBaseEndpoint() - const baseUrl = endpoint.split('/api/workflows/')[0] - const payload = getPayloadObject() + const baseUrl = getBaseUrl() + const payload = { ...getPayloadObject(), async: true } const isPublic = info.isPublicApi switch (asyncExampleType) { @@ -271,7 +275,6 @@ while (true) { case 'curl': return `curl -X POST \\ ${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\ - -H "X-Execution-Mode: async" \\ -d '${JSON.stringify(payload)}' \\ ${endpoint}` @@ -282,40 +285,38 @@ import requests response = requests.post( "${endpoint}", headers={ -${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json", - "X-Execution-Mode": "async" +${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json" }, json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')} ) -job = response.json() -print(job) # Contains jobId and executionId` +job = response.json()["data"] +print(job) # Contains executionId and statusUrl` case 'javascript': return `const response = await fetch("${endpoint}", { method: "POST", headers: { -${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json", - "X-Execution-Mode": "async" +${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json" }, body: JSON.stringify(${JSON.stringify(payload)}) }); -const job = await response.json(); -console.log(job); // Contains jobId and executionId` +const { data: job } = await response.json(); +console.log(job); // Contains executionId and statusUrl` case 'typescript': return `const response = await fetch("${endpoint}", { method: "POST", headers: { -${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json", - "X-Execution-Mode": "async" +${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json" }, body: JSON.stringify(${JSON.stringify(payload)}) }); -const job: { jobId: string; executionId: string } = await response.json(); -console.log(job); // Contains jobId and executionId` +const { data: job }: { data: { executionId: string; statusUrl: string } } = + await response.json(); +console.log(job); // Poll statusUrl until status is terminal` default: return '' @@ -325,84 +326,84 @@ console.log(job); // Contains jobId and executionId` switch (language) { case 'curl': return `curl -H "X-API-Key: $SIM_API_KEY" \\ - ${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION` + ${baseUrl}/api/v2/workflows/${workflowId}/executions/EXECUTION_ID` case 'python': return `import os import requests response = requests.get( - "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION", + "${baseUrl}/api/v2/workflows/${workflowId}/executions/EXECUTION_ID", headers={"X-API-Key": os.environ.get("SIM_API_KEY")} ) -status = response.json() -print(status)` +status = response.json()["data"] +print(status) # status: queued | running | completed | failed | cancelled | paused` case 'javascript': return `const response = await fetch( - "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION", + "${baseUrl}/api/v2/workflows/${workflowId}/executions/EXECUTION_ID", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const status = await response.json(); +const { data: status } = await response.json(); console.log(status);` case 'typescript': return `const response = await fetch( - "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION", + "${baseUrl}/api/v2/workflows/${workflowId}/executions/EXECUTION_ID", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const status: Record = await response.json(); +const { data: status }: { data: Record } = await response.json(); console.log(status);` default: return '' } - case 'rate-limits': + case 'usage': switch (language) { case 'curl': return `curl -H "X-API-Key: $SIM_API_KEY" \\ - ${baseUrl}/api/users/me/usage-limits` + ${baseUrl}/api/v2/billing/usage` case 'python': return `import os import requests response = requests.get( - "${baseUrl}/api/users/me/usage-limits", + "${baseUrl}/api/v2/billing/usage", headers={"X-API-Key": os.environ.get("SIM_API_KEY")} ) -limits = response.json() -print(limits)` +limits = response.json()["data"] +print(limits) # totalCredits + bySourceCredits breakdown` case 'javascript': return `const response = await fetch( - "${baseUrl}/api/users/me/usage-limits", + "${baseUrl}/api/v2/billing/usage", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const limits = await response.json(); +const { data: limits } = await response.json(); console.log(limits);` case 'typescript': return `const response = await fetch( - "${baseUrl}/api/users/me/usage-limits", + "${baseUrl}/api/v2/billing/usage", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const limits: Record = await response.json(); +const { data: limits }: { data: Record } = await response.json(); console.log(limits);` default: @@ -414,19 +415,6 @@ console.log(limits);` } } - const getAsyncExampleTitle = () => { - switch (asyncExampleType) { - case 'execute': - return 'Execute Job' - case 'status': - return 'Check Status' - case 'rate-limits': - return 'Usage Limits' - default: - return 'Execute Job' - } - } - const handleCopy = (key: keyof CopiedState, value: string) => { navigator.clipboard.writeText(value) setCopied((prev) => ({ ...prev, [key]: true })) @@ -564,7 +552,7 @@ console.log(limits);` options={[ { label: 'Execute Job', value: 'execute' }, { label: 'Check Status', value: 'status' }, - { label: 'Usage Limits', value: 'rate-limits' }, + { label: 'Usage', value: 'usage' }, ]} value={asyncExampleType} onChange={(value) => setAsyncExampleType(value as AsyncExampleType)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index aa33d96cc92..91bb280288e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -77,7 +77,6 @@ interface WorkflowDeploymentInfoUI { deployedAt?: string apiKey: string endpoint: string - exampleCommand: string needsRedeployment: boolean isPublicApi: boolean } @@ -234,7 +233,7 @@ export function DeployModal({ return null } - const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute` + const endpoint = `${getBaseUrl()}/api/v2/workflows/${workflowId}/execute` const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) const placeholderKey = getApiHeaderPlaceholder() @@ -243,7 +242,6 @@ export function DeployModal({ deployedAt: deploymentInfoData.deployedAt ?? undefined, apiKey: getApiKeyLabel(deploymentInfoData.apiKey), endpoint, - exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`, needsRedeployment: deploymentInfoData.needsRedeployment, isPublicApi: isPublicApiDisabled ? false : (deploymentInfoData.isPublicApi ?? false), } diff --git a/apps/sim/blocks/blocks/api_trigger.ts b/apps/sim/blocks/blocks/api_trigger.ts index 27ad2beef33..9c264b78fd9 100644 --- a/apps/sim/blocks/blocks/api_trigger.ts +++ b/apps/sim/blocks/blocks/api_trigger.ts @@ -11,7 +11,7 @@ export const ApiTriggerBlock: BlockConfig = { bestPractices: ` - Can run the workflow manually to test implementation when this is the trigger point. - The input format determines variables accesssible in the following blocks. E.g. . You can set the value in the input format to test the workflow manually. - - In production, the curl would come in as e.g. curl -X POST -H "X-API-Key: $SIM_API_KEY" -H "Content-Type: application/json" -d '{"paramName":"example"}' https://www.staging.sim.ai/api/workflows/9e7e4f26-fc5e-4659-b270-7ea474b14f4a/execute -- If user asks to test via API, you might need to clarify the API key. + - In production, the curl would come in as e.g. curl -X POST -H "X-API-Key: $SIM_API_KEY" -H "Content-Type: application/json" -d '{"input":{"paramName":"example"}}' https://www.sim.ai/api/v2/workflows/9e7e4f26-fc5e-4659-b270-7ea474b14f4a/execute -- If user asks to test via API, you might need to clarify the API key. `, category: 'triggers', hideFromToolbar: true, diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index f0d14b9ba29..3ebc74b6fee 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -30,7 +30,7 @@ import { ensureWorkflowAccess } from '../access' import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../param-types' function buildWorkflowApiEndpoint(baseUrl: string, workflowId: string): string { - return `${baseUrl}/api/workflows/${workflowId}/execute` + return `${baseUrl}/api/v2/workflows/${workflowId}/execute` } function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) { @@ -57,9 +57,8 @@ function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) { method: 'POST', transport: 'json', stream: false, - headers: { 'X-Execution-Mode': 'async' }, - body: { input: { key: 'value' } }, - jobStatusEndpointTemplate: `${baseUrl}/api/jobs/{jobId}`, + body: { async: true, input: { key: 'value' } }, + jobStatusEndpointTemplate: `${baseUrl}/api/v2/workflows/{workflowId}/executions/{executionId}`, }, }, } @@ -78,9 +77,8 @@ function buildWorkflowApiExamples(baseUrl: string, apiEndpoint: string) { async: `curl -X POST "${apiEndpoint}" \\ -H "Content-Type: application/json" \\ -H "X-API-Key: YOUR_API_KEY" \\ - -H "X-Execution-Mode: async" \\ - -d '{"input":{"key":"value"}}'`, - poll: `curl "${baseUrl}/api/jobs/JOB_ID" \\ + -d '{"async":true,"input":{"key":"value"}}'`, + poll: `curl "${baseUrl}/api/v2/workflows/WORKFLOW_ID/executions/EXECUTION_ID" \\ -H "X-API-Key: YOUR_API_KEY"`, } } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index ff61d1762d1..a649f6c7112 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -82,7 +82,7 @@ export async function executeCheckDeploymentStatus( const apiDetails = { isDeployed: isApiDeployed, deployedAt: apiDeploy[0]?.deployedAt || null, - endpoint: isApiDeployed ? `/api/workflows/${workflowId}/execute` : null, + endpoint: isApiDeployed ? `/api/v2/workflows/${workflowId}/execute` : null, apiKey: workflowRecord.workspaceId ? 'Workspace API keys' : 'Personal API keys', needsRedeployment, activeDeployment: deploymentSummary.activeDeployment, diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index d394ed07347..789c1044eb0 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -808,7 +808,7 @@ export function serializeDeployments(data: DeploymentData): string { result.api = { isDeployed: true, deployedAt: data.deployedAt?.toISOString(), - apiEndpoint: `/api/workflows/${data.workflowId}/execute`, + apiEndpoint: `/api/v2/workflows/${data.workflowId}/execute`, ...(data.api ? { version: data.api.version } : {}), } } From 35d306de755353bdeaba58c3b99fc2db7c03154a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 11:13:09 -0700 Subject: [PATCH 021/159] docs(api): document the v2 execution surface Adds execute, execution status, and cancel to openapi-v2-workflows.json with the structured ExecutionError schema (append-only code enum + block attribution) and the ExecutionResource contract, documenting the rules that differ from v1: modes are body-selected, a failed run is HTTP 200 with status 'failed', an executionId always means data (never the error envelope), queued status is visible immediately, and Response-block payloads stay inside output. Registers the three pages in the generated workflows meta.json and bumps the route-count baseline. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- .../(generated)/workflows/meta.json | 5 +- apps/docs/openapi-v2-workflows.json | 623 ++++++++++++++++++ scripts/check-api-validation-contracts.ts | 4 +- 3 files changed, 629 insertions(+), 3 deletions(-) diff --git a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json index ca2603a1d54..d5e28d23d63 100644 --- a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json +++ b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json @@ -6,6 +6,9 @@ "importWorkflow", "deployWorkflow", "undeployWorkflow", - "rollbackWorkflow" + "rollbackWorkflow", + "executeWorkflowV2", + "getWorkflowExecutionV2", + "cancelExecutionV2" ] } diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 088c75fe278..95b85a369f2 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -684,6 +684,558 @@ } } } + }, + "/api/v2/workflows/{id}/execute": { + "post": { + "operationId": "executeWorkflowV2", + "summary": "Execute a workflow", + "description": "Executes a deployed workflow. Auth: `X-API-Key`, or no key at all for workflows deployed with public API access (sync/stream only). Modes are body-selected — there are no mode headers on v2: `\"async\": true` queues the run and returns a 202 receipt whose `statusUrl` is the executions resource; `\"stream\": true` returns Server-Sent Events (no `{data}` envelope on frames; `includeThinking`/`includeToolCalls` additionally require the `X-Sim-Stream-Protocol: agent-events-v1` header). Sync runs return the execution resource: a failed run is HTTP 200 with `status: \"failed\"` and the structured error (the sync timeout is `status:\"failed\"` + `error.code:\"TIMEOUT\"`). A Response block's declared payload stays inside `output` — workflow authors never control response status or headers. Optional `X-Execution-Id` request header (keyed callers only) makes the run idempotent; a reused id returns 409. Rate limiting uses the workflow execution buckets (async runs debit the larger async bucket) and 429s carry `Retry-After`; execute responses do not carry `X-RateLimit-*` headers.", + "tags": ["Workflows"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "requestBody": { + "required": false, + "description": "Bodies over 10 MB are rejected with 413. Unknown keys are rejected (strict schema).", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "input": { + "type": "object", + "additionalProperties": true, + "description": "Workflow input, keyed by the deployed API trigger's input fields." + }, + "async": { + "type": "boolean", + "default": false, + "description": "Queue the run; poll the returned statusUrl. Not combinable with stream/output options; requires an API key." + }, + "stream": { + "type": "boolean", + "default": false, + "description": "Stream block outputs as Server-Sent Events." + }, + "selectedOutputs": { + "type": "array", + "maxItems": 100, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Restrict streamed outputs to specific `BlockName.path` refs." + }, + "includeThinking": { + "type": "boolean", + "default": false + }, + "includeToolCalls": { + "type": "boolean", + "default": false + }, + "includeFileBase64": { + "type": "boolean" + }, + "base64MaxBytes": { + "type": "integer", + "minimum": 1, + "maximum": 10485760 + } + } + }, + "example": { + "input": { + "key": "value" + } + }, + "examples": { + "sync": { + "summary": "Synchronous run", + "value": { + "input": { + "key": "value" + } + } + }, + "async": { + "summary": "Queued run", + "value": { + "input": { + "key": "value" + }, + "async": true + } + }, + "stream": { + "summary": "SSE stream", + "value": { + "input": { + "key": "value" + }, + "stream": true, + "selectedOutputs": ["Agent.content"] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The execution resource. Served with `Cache-Control: private, no-store` and the `X-Execution-Id` header.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/ExecutionResource" + } + } + }, + "example": { + "data": { + "executionId": "8f14e45f-ceea-467f-a", + "workflowId": "wf_123", + "status": "completed", + "output": { + "result": "done" + }, + "error": null, + "startedAt": "2026-07-31T00:00:00.000Z", + "endedAt": "2026-07-31T00:00:01.000Z", + "durationMs": 1000 + } + }, + "examples": { + "completed": { + "summary": "Completed run", + "value": { + "data": { + "executionId": "exec_1", + "workflowId": "wf_123", + "status": "completed", + "output": { + "result": "done" + }, + "error": null, + "durationMs": 1000 + } + } + }, + "failed": { + "summary": "Failed run (still HTTP 200)", + "value": { + "data": { + "executionId": "exec_2", + "workflowId": "wf_123", + "status": "failed", + "output": { + "partial": true + }, + "error": { + "message": "Invalid credentials", + "code": "BLOCK_EXECUTION_FAILED", + "blockId": "b_9", + "blockName": "Send Email", + "blockType": "gmail" + }, + "durationMs": 310 + } + } + } + } + } + } + }, + "202": { + "description": "Queued (async). Poll `statusUrl` until `status` is terminal.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["executionId", "statusUrl"], + "properties": { + "executionId": { + "type": "string" + }, + "statusUrl": { + "type": "string" + } + } + } + } + }, + "example": { + "data": { + "executionId": "exec_1", + "statusUrl": "https://www.sim.ai/api/v2/workflows/wf_123/executions/exec_1" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "409": { + "description": "The `X-Execution-Id` was already used.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request body exceeds the 10 MB limit." + }, + "503": { + "description": "Execution infrastructure temporarily unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v2/workflows/{id}/executions/{executionId}": { + "get": { + "operationId": "getWorkflowExecutionV2", + "summary": "Get execution status", + "description": "The single status URL for sync and async runs. Freshly queued async runs report `queued` (backfilled from the job queue before the durable record exists), then `running`, then a terminal status. Failed runs carry the structured error. `includeOutput=true` adds the final output on completed runs; `selectedOutputs` extracts specific block outputs.", + "tags": ["Workflows"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + }, + { + "name": "executionId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "includeOutput", + "in": "query", + "required": false, + "schema": { + "enum": ["true", "false"] + } + }, + { + "name": "selectedOutputs", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated `blockId.path` selectors." + } + ], + "responses": { + "200": { + "description": "The execution status resource.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": [ + "executionId", + "workflowId", + "status", + "trigger", + "startedAt", + "endedAt", + "durationMs", + "paused", + "cost", + "error", + "output", + "blockOutputs" + ], + "properties": { + "executionId": { + "type": "string" + }, + "workflowId": { + "type": "string" + }, + "status": { + "enum": [ + "queued", + "pending", + "running", + "completed", + "failed", + "cancelled", + "paused" + ] + }, + "trigger": { + "type": ["string", "null"] + }, + "startedAt": { + "type": ["string", "null"] + }, + "endedAt": { + "type": ["string", "null"] + }, + "durationMs": { + "type": ["number", "null"] + }, + "paused": { + "type": ["object", "null"], + "description": "Pause detail for human-in-the-loop runs." + }, + "cost": { + "type": ["object", "null"], + "properties": { + "total": { + "type": "number" + } + } + }, + "error": { + "oneOf": [ + { + "$ref": "#/components/schemas/ExecutionError" + }, + { + "type": "null" + } + ] + }, + "output": { + "description": "Final output; only with `includeOutput=true` on completed runs." + }, + "blockOutputs": { + "type": ["object", "null"] + } + } + } + } + }, + "example": { + "data": { + "executionId": "exec_1", + "workflowId": "wf_123", + "status": "completed", + "trigger": "api", + "startedAt": "2026-07-31T00:00:00.000Z", + "endedAt": "2026-07-31T00:00:01.000Z", + "durationMs": 1000, + "paused": null, + "cost": { + "total": 0.02 + }, + "error": null, + "output": null, + "blockOutputs": null + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/executions/{executionId}/cancel": { + "post": { + "operationId": "cancelExecutionV2", + "summary": "Cancel an execution", + "description": "Cancels a running or paused execution. `reason` explains how the cancellation was recorded.", + "tags": ["Workflows"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + }, + { + "name": "executionId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "Cancellation outcome.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": [ + "success", + "executionId", + "redisAvailable", + "durablyRecorded", + "locallyAborted", + "pausedCancelled" + ], + "properties": { + "success": { + "type": "boolean" + }, + "executionId": { + "type": "string" + }, + "redisAvailable": { + "type": "boolean" + }, + "durablyRecorded": { + "type": "boolean" + }, + "locallyAborted": { + "type": "boolean" + }, + "pausedCancelled": { + "type": "boolean" + }, + "reason": { + "enum": [ + "recorded", + "redis_unavailable", + "redis_write_failed", + "paused_event_publish_failed", + "paused_database_cancel_failed" + ] + } + } + } + } + }, + "example": { + "data": { + "success": true, + "executionId": "exec_1", + "redisAvailable": true, + "durablyRecorded": true, + "locallyAborted": false, + "pausedCancelled": false, + "reason": "recorded" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } } }, "components": { @@ -1140,6 +1692,77 @@ "format": "date-time" } } + }, + "ExecutionError": { + "type": "object", + "required": ["message", "code"], + "description": "Structured execution error. Route on `code` (append-only enum) instead of matching message text. Block fields identify the failing block when attributable — with the executionId they form the reproducible handle to hand a shared workflow's provider.", + "properties": { + "message": { + "type": "string" + }, + "code": { + "enum": [ + "TIMEOUT", + "CANCELLED", + "USAGE_LIMIT_EXCEEDED", + "INVALID_INPUT", + "BLOCK_EXECUTION_FAILED", + "CHILD_WORKFLOW_FAILED", + "OUTPUT_TOO_LARGE", + "EXECUTION_FAILED" + ] + }, + "blockId": { + "type": "string" + }, + "blockName": { + "type": "string" + }, + "blockType": { + "type": "string" + } + } + }, + "ExecutionResource": { + "type": "object", + "required": ["executionId", "workflowId", "status", "output", "error"], + "description": "The execution result resource. An executionId always means 200/202 with data; only pre-execution failures use the error envelope. In-band run failures are status 'failed' with the structured error — never an HTTP error status.", + "properties": { + "executionId": { + "type": "string" + }, + "workflowId": { + "type": "string" + }, + "status": { + "enum": ["completed", "failed", "paused", "cancelled"] + }, + "output": { + "description": "Workflow output (partial output is preserved on failures)." + }, + "error": { + "oneOf": [ + { + "$ref": "#/components/schemas/ExecutionError" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "endedAt": { + "type": "string", + "format": "date-time" + }, + "durationMs": { + "type": "number" + } + } } }, "responses": { diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 8853257bcc2..ccdeb5e3d4c 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1022, - zodRoutes: 1022, + totalRoutes: 1025, + zodRoutes: 1025, nonZodRoutes: 0, } as const From 53ea6a805001f513a2bfb12436f7ad6fc8bc07e7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 12:26:02 -0700 Subject: [PATCH 022/159] feat(api): gate the whole /api/v2 surface behind one flag; UI stays on v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every v2 route now runs exactly one check immediately after auth — v2ApiGateError — and answers 404 when the `v2-api` flag is off, so the surface is invisible until it is deliberately rolled out. The gate is keyed on userId only: a workspace/org-keyed check would have to read membership for a caller-supplied id before authorization runs, and its 404-vs-403 split would leak cohort membership (the trap the per-domain table gate worked around by running late). The two executions routes inherit it from the shared access resolver; the tables-specific gate is removed so no route checks twice. `tables-v2-api` stays, now gating only the internal predicate-grammar route /api/table/[tableId]/query — note v2 tables routes move to the unified flag, so enabling them is a `v2-api` decision now. Reverts the deploy modal, copilot handlers, and api_trigger example to the v1 execute endpoint: v1 works unchanged, and the UI must not advertise a surface most users would get a 404 from. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz --- apps/sim/app/api/v2/audit-logs/[id]/route.ts | 4 + apps/sim/app/api/v2/audit-logs/route.ts | 4 + .../api/v2/billing/usage/logs/route.test.ts | 4 + .../app/api/v2/billing/usage/logs/route.ts | 5 + .../app/api/v2/billing/usage/route.test.ts | 4 + apps/sim/app/api/v2/billing/usage/route.ts | 5 + apps/sim/app/api/v2/files/[fileId]/route.ts | 9 ++ apps/sim/app/api/v2/files/route.ts | 9 ++ .../[id]/documents/[documentId]/route.ts | 9 ++ .../api/v2/knowledge/[id]/documents/route.ts | 9 ++ apps/sim/app/api/v2/knowledge/[id]/route.ts | 13 +++ apps/sim/app/api/v2/knowledge/route.ts | 9 ++ apps/sim/app/api/v2/knowledge/search/route.ts | 5 + apps/sim/app/api/v2/lib/gate.ts | 23 +++++ apps/sim/app/api/v2/logs/[id]/route.ts | 5 + .../v2/logs/executions/[executionId]/route.ts | 5 + apps/sim/app/api/v2/logs/route.ts | 5 + .../api/v2/tables/[tableId]/columns/route.ts | 24 +++-- .../v2/tables/[tableId]/query/route.test.ts | 40 +++----- .../api/v2/tables/[tableId]/query/route.ts | 10 +- apps/sim/app/api/v2/tables/[tableId]/route.ts | 17 ++-- .../v2/tables/[tableId]/rows/[rowId]/route.ts | 24 +++-- .../app/api/v2/tables/[tableId]/rows/route.ts | 33 +++---- .../v2/tables/[tableId]/rows/upsert/route.ts | 10 +- apps/sim/app/api/v2/tables/route.test.ts | 41 ++------ apps/sim/app/api/v2/tables/route.ts | 17 ++-- apps/sim/app/api/v2/tables/utils.ts | 19 ---- .../app/api/v2/workflows/[id]/deploy/route.ts | 9 ++ .../v2/workflows/[id]/execute/route.test.ts | 16 ++++ .../api/v2/workflows/[id]/execute/route.ts | 4 + .../executions/[executionId]/route.test.ts | 4 + .../app/api/v2/workflows/[id]/export/route.ts | 5 + .../api/v2/workflows/[id]/rollback/route.ts | 5 + apps/sim/app/api/v2/workflows/[id]/route.ts | 5 + apps/sim/app/api/v2/workflows/import/route.ts | 5 + apps/sim/app/api/v2/workflows/lib/access.ts | 4 + apps/sim/app/api/v2/workflows/route.ts | 5 + .../deploy-modal/components/api/api.tsx | 96 +++++++++++-------- .../components/deploy-modal/deploy-modal.tsx | 4 +- apps/sim/blocks/blocks/api_trigger.ts | 2 +- .../tools/handlers/deployment/deploy.ts | 12 ++- .../tools/handlers/deployment/manage.ts | 2 +- apps/sim/lib/copilot/vfs/serializers.ts | 2 +- apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/core/config/feature-flags.ts | 9 ++ 45 files changed, 364 insertions(+), 188 deletions(-) create mode 100644 apps/sim/app/api/v2/lib/gate.ts diff --git a/apps/sim/app/api/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[id]/route.ts index d1fca3d0aa0..bef7e2d43ce 100644 --- a/apps/sim/app/api/v2/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v2/audit-logs/[id]/route.ts @@ -12,6 +12,7 @@ import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' import { checkRateLimit } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' const logger = createLogger('V2AuditLogDetailAPI') @@ -38,6 +39,9 @@ export const GET = withRouteHandler( const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const authResult = await resolveEnterpriseAuditAccess(userId) if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts index c785ccaaede..a264e5f47a4 100644 --- a/apps/sim/app/api/v2/audit-logs/route.ts +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -14,6 +14,7 @@ import { queryAuditLogs, } from '@/app/api/v1/audit-logs/query' import { checkRateLimit } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CursorList, v2Error, @@ -44,6 +45,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const authResult = await resolveEnterpriseAuditAccess(userId) if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) diff --git a/apps/sim/app/api/v2/billing/usage/logs/route.test.ts b/apps/sim/app/api/v2/billing/usage/logs/route.test.ts index 88cde13aa69..ed7d0390381 100644 --- a/apps/sim/app/api/v2/billing/usage/logs/route.test.ts +++ b/apps/sim/app/api/v2/billing/usage/logs/route.test.ts @@ -20,6 +20,10 @@ vi.mock('@/lib/billing/core/usage-log', () => ({ getUsageCreditsByLogId: mockGetUsageCreditsByLogId, })) +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + import { GET } from '@/app/api/v2/billing/usage/logs/route' const RATE_LIMIT_OK = { diff --git a/apps/sim/app/api/v2/billing/usage/logs/route.ts b/apps/sim/app/api/v2/billing/usage/logs/route.ts index 531f87ea9ef..93621a9606b 100644 --- a/apps/sim/app/api/v2/billing/usage/logs/route.ts +++ b/apps/sim/app/api/v2/billing/usage/logs/route.ts @@ -13,6 +13,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' import { checkRateLimit } from '@/app/api/v1/middleware' import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CursorList, v2Error, @@ -38,6 +39,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( v2ListUsageLogsContract, request, diff --git a/apps/sim/app/api/v2/billing/usage/route.test.ts b/apps/sim/app/api/v2/billing/usage/route.test.ts index da2bf7cb2f8..5e2a63f5adf 100644 --- a/apps/sim/app/api/v2/billing/usage/route.test.ts +++ b/apps/sim/app/api/v2/billing/usage/route.test.ts @@ -35,6 +35,10 @@ vi.mock('@/lib/billing/core/usage-log', () => ({ getUserUsageLogs: mockGetUserUsageLogs, })) +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + import { GET } from '@/app/api/v2/billing/usage/route' const RATE_LIMIT_OK = { diff --git a/apps/sim/app/api/v2/billing/usage/route.ts b/apps/sim/app/api/v2/billing/usage/route.ts index 1c17468ce73..60d826fc5c8 100644 --- a/apps/sim/app/api/v2/billing/usage/route.ts +++ b/apps/sim/app/api/v2/billing/usage/route.ts @@ -11,6 +11,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkRateLimit } from '@/app/api/v1/middleware' import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' const logger = createLogger('V2BillingUsageAPI') @@ -32,6 +33,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( v2GetUsageSummaryContract, request, diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index 9d2e6d603b9..d168015d793 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -7,6 +7,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import type { V2ErrorCode } from '@/app/api/v2/lib/response' import { rateLimitHeaders, @@ -39,6 +40,10 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2DownloadFileContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -84,6 +89,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2DeleteFileContract, request, context, { validationErrorResponse: v2ValidationError, }) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index dbc6e982068..47055ab713b 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -21,6 +21,7 @@ import { uploadWorkspaceFile, } from '@/lib/uploads/contexts/workspace' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { decodeCursor, encodeCursor, @@ -65,6 +66,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( v2ListFilesContract, request, @@ -130,6 +135,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( v2UploadFileContract, request, diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts index 235c80707eb..41560fb7558 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -17,6 +17,7 @@ import { deleteDocument } from '@/lib/knowledge/documents/service' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' const logger = createLogger('V2KnowledgeDocumentDetailAPI') @@ -59,6 +60,10 @@ export const GET = withRouteHandler( if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2GetKnowledgeDocumentContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -151,6 +156,10 @@ export const DELETE = withRouteHandler( if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2DeleteKnowledgeDocumentContract, request, context, { validationErrorResponse: v2ValidationError, }) 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 2bb586afaeb..60103508d3d 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -32,6 +32,7 @@ import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { validateFileType } from '@/lib/uploads/utils/validation' import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { decodeCursor, encodeCursor, @@ -84,6 +85,10 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Docume if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2ListKnowledgeDocumentsContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -161,6 +166,10 @@ export const POST = withRouteHandler( if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2UploadKnowledgeDocumentContract, request, context, { validationErrorResponse: v2ValidationError, }) diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts index 79bb1b4b86c..e6ae3849bae 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -14,6 +14,7 @@ import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/servic import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { formatKnowledgeBase, resolveKnowledgeBase } from '@/app/api/v1/knowledge/utils' import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' const logger = createLogger('V2KnowledgeDetailAPI') @@ -57,6 +58,10 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Knowle if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2GetKnowledgeBaseContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -90,6 +95,10 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2UpdateKnowledgeBaseContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -154,6 +163,10 @@ export const DELETE = withRouteHandler( if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2DeleteKnowledgeBaseContract, request, context, { validationErrorResponse: v2ValidationError, }) diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index d1fb7d5b10d..65aae6b8d5a 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -13,6 +13,7 @@ import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowled import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' import { formatKnowledgeBase } from '@/app/api/v1/knowledge/utils' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CursorList, v2Data, @@ -36,6 +37,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( v2ListKnowledgeBasesContract, request, @@ -73,6 +78,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( v2CreateKnowledgeBaseContract, request, diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index a5ac83a1360..005edb3b919 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -29,6 +29,7 @@ import { } from '@/app/api/knowledge/search/utils' import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, @@ -51,6 +52,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( v2SearchKnowledgeContract, request, diff --git a/apps/sim/app/api/v2/lib/gate.ts b/apps/sim/app/api/v2/lib/gate.ts new file mode 100644 index 00000000000..d9bf214eece --- /dev/null +++ b/apps/sim/app/api/v2/lib/gate.ts @@ -0,0 +1,23 @@ +import type { NextResponse } from 'next/server' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { v2Error } from '@/app/api/v2/lib/response' + +/** + * Rollout gate for the entire `/api/v2` surface. + * + * Exactly one check per request, placed immediately after the route + * authenticates and before it does any work. When the flag is off the route + * answers 404 as if it did not exist, so an ungated caller cannot distinguish + * "not in the rollout cohort" from "no such endpoint". + * + * Deliberately keyed on `userId` only. A workspace- or org-keyed gate would + * have to read membership for a caller-supplied id before authorization has + * run, and its 404-vs-403 split would then leak whether that workspace's org + * is in the cohort — the trap the per-domain table gate has to work around by + * running late. Keyed on the authenticated user, the check is safe to run + * first and is uniform across every v2 route. + */ +export async function v2ApiGateError(userId: string): Promise { + if (await isFeatureEnabled('v2-api', { userId })) return null + return v2Error('NOT_FOUND', 'Not found') +} diff --git a/apps/sim/app/api/v2/logs/[id]/route.ts b/apps/sim/app/api/v2/logs/[id]/route.ts index 698e59f10ed..02593d73ec9 100644 --- a/apps/sim/app/api/v2/logs/[id]/route.ts +++ b/apps/sim/app/api/v2/logs/[id]/route.ts @@ -10,6 +10,7 @@ import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' const logger = createLogger('V2LogDetailAPI') @@ -25,6 +26,10 @@ export const GET = withRouteHandler( if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2GetLogContract, request, context, { validationErrorResponse: v2ValidationError, }) diff --git a/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts index da936577def..5b811960412 100644 --- a/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts +++ b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts @@ -8,6 +8,7 @@ import { type V2Execution, v2GetExecutionContract } from '@/lib/api/contracts/v2 import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' const logger = createLogger('V2ExecutionAPI') @@ -21,6 +22,10 @@ export const GET = withRouteHandler( if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2GetExecutionContract, request, context, { validationErrorResponse: v2ValidationError, }) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index a4cc3372d37..c4e4077bdc5 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -12,6 +12,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { decodeCursor, encodeCursor, @@ -35,6 +36,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( v2ListLogsContract, request, diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts index 75df47c8026..c7c1e538600 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts @@ -19,6 +19,7 @@ import { } from '@/lib/table' import { checkAccess, normalizeColumn } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, @@ -26,7 +27,7 @@ import { v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { v2TableAccessError, v2TablesGateError } from '@/app/api/v2/tables/utils' +import { v2TableAccessError } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableColumnsAPI') @@ -46,6 +47,10 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2AddTableColumnContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -65,9 +70,6 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - const updatedTable = await addTableColumn(tableId, validated.column, requestId) recordAudit({ @@ -111,6 +113,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2UpdateTableColumnContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -130,9 +136,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - const { updates } = validated let updatedTable = null @@ -217,6 +220,10 @@ export const DELETE = withRouteHandler( if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2DeleteTableColumnContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -236,9 +243,6 @@ export const DELETE = withRouteHandler( return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - const updatedTable = await deleteColumn( { tableId, columnName: validated.columnName }, requestId diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts index d692078122f..902b8b3bac3 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts @@ -53,6 +53,11 @@ vi.mock('@/lib/workspaces/utils', () => ({ })) import { encodeCursor } from '@/lib/table/rows/cursor' + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + import { POST } from '@/app/api/v2/tables/[tableId]/query/route' const RATE_LIMIT_OK = { @@ -117,41 +122,18 @@ describe('POST /api/v2/tables/[tableId]/query', () => { mockGetWorkspaceOrganizationId.mockResolvedValue('org-1') }) - it('returns 404 when the tables-v2-api flag is off', async () => { - mockIsFeatureEnabled.mockResolvedValue(false) + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + const res = await callQuery({ workspaceId: 'workspace-1' }) + expect(res.status).toBe(404) expect((await res.json()).error.code).toBe('NOT_FOUND') expect(mockQueryRows).not.toHaveBeenCalled() }) - it('runs the flag gate only after the access check, so it cannot leak a cohort oracle', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - const res = await callQuery({ workspaceId: 'workspace-1' }) - // Read path masks not-authorized as 404 so existence never leaks. - expect(res.status).toBe(404) - expect(mockIsFeatureEnabled).not.toHaveBeenCalled() - }) - - it('translates a name-keyed predicate to storage ids', async () => { - const res = await callQuery({ - workspaceId: 'workspace-1', - predicate: { - all: [ - { field: 'status', op: 'eq', value: 'active' }, - { field: 'wins', op: 'gte', value: 10 }, - ], - }, - }) - expect(res.status).toBe(200) - expect(mockQueryRows.mock.calls[0][1].predicate).toEqual({ - all: [ - { field: 'col_status', op: 'eq', value: 'active' }, - { field: 'col_wins', op: 'gte', value: 10 }, - ], - }) - }) - it('applies the bounded default limit when omitted', async () => { await callQuery({ workspaceId: 'workspace-1' }) expect(mockQueryRows.mock.calls[0][1].limit).toBe(100) diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts index 92d316594f9..495c8aeff48 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts @@ -16,6 +16,7 @@ import { queryRows } from '@/lib/table/rows/service' import { predicateToStorage } from '@/lib/table/select-values' import { checkAccess } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CursorList, v2Error, @@ -23,7 +24,7 @@ import { v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiRow, v2TablesGateError } from '@/app/api/v2/tables/utils' +import { toApiRow } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableQueryAPI') @@ -47,6 +48,10 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Query if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2QueryRowsContract, request, context, { maxBodyBytes: TABLE_QUERY_MAX_BODY_BYTES, validationErrorResponse: v2ValidationError, @@ -68,9 +73,6 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Query return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, workspaceId) - if (gateError) return gateError - const schema = table.schema as TableSchema const cursor = cursorToken ? decodeCursor(cursorToken) : undefined diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index 3f9809d2016..03e97d590fa 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -9,6 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteTable } from '@/lib/table' import { checkAccess } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, @@ -16,7 +17,7 @@ import { v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiTable, v2TableAccessError, v2TablesGateError } from '@/app/api/v2/tables/utils' +import { toApiTable, v2TableAccessError } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableDetailAPI') @@ -36,6 +37,10 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2GetTableContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -55,9 +60,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, workspaceId) - if (gateError) return gateError - return v2Data({ table: toApiTable(result.table) }, { rateLimit }) } catch (error) { logger.error(`[${requestId}] Error getting table`, { @@ -76,6 +78,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2DeleteTableContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -94,9 +100,6 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, workspaceId) - if (gateError) return gateError - await deleteTable(tableId, requestId) recordAudit({ 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 05397d4fed0..f11cf9b2c74 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 @@ -17,6 +17,7 @@ import { buildIdByName, rowDataNameToId, updateRow } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' import { checkAccess } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, @@ -24,7 +25,7 @@ import { v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiRow, v2TableAccessError, v2TablesGateError } from '@/app/api/v2/tables/utils' +import { toApiRow, v2TableAccessError } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableRowAPI') @@ -44,6 +45,10 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2GetTableRowContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -63,9 +68,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, workspaceId) - if (gateError) return gateError - const [row] = await db .select({ id: userTableRows.id, @@ -117,6 +119,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2UpdateTableRowContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -136,9 +142,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - const idByName = buildIdByName(table.schema as TableSchema) const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) const updatedRow = await updateRow( @@ -189,6 +192,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2DeleteTableRowContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -207,9 +214,6 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, workspaceId) - if (gateError) return gateError - const [deletedRow] = await db .delete(userTableRows) .where( 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 2df533f2855..a2677af979c 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts @@ -33,6 +33,7 @@ import { type RateLimitResult, resolveWorkspaceScope, } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { decodeCursor, encodeCursor, @@ -49,7 +50,6 @@ import { v2RowValidationError, v2RowWriteError, v2TableAccessError, - v2TablesGateError, } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableRowsAPI') @@ -81,9 +81,6 @@ async function handleBatchInsert( return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - // External callers key row data by column name; storage keys by id. const idByName = buildIdByName(table.schema as TableSchema) const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) @@ -133,6 +130,10 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2ListTableRowsContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -153,9 +154,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) // Cursor-uniform v2 pagination: the opaque cursor encodes the underlying @@ -206,6 +204,10 @@ export const POST = withRouteHandler( if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2CreateTableRowsContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -232,9 +234,6 @@ export const POST = withRouteHandler( return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - const idByName = buildIdByName(table.schema as TableSchema) const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) const rowData = rowDataNameToId(validated.data as RowData, idByName) @@ -276,6 +275,10 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2UpdateRowsByFilterContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -295,9 +298,6 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - const idByName = buildIdByName(table.schema as TableSchema) const patchData = rowDataNameToId(validated.data as RowData, idByName) @@ -347,6 +347,10 @@ export const DELETE = withRouteHandler( if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2DeleteTableRowsContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -366,9 +370,6 @@ export const DELETE = withRouteHandler( return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - // id-based and filter-based deletes share one envelope; `requestedCount`/ // `missingRowIds` are populated only for the id-based delete (which has a // requested set) and omitted for the filter-based delete. 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 cab5e42538e..08f4b0873af 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 @@ -10,6 +10,7 @@ import { buildIdByName, rowDataNameToId, upsertRow } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' import { checkAccess } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, @@ -17,7 +18,7 @@ import { v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiRow, v2TableAccessError, v2TablesGateError } from '@/app/api/v2/tables/utils' +import { toApiRow, v2TableAccessError } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableUpsertAPI') @@ -37,6 +38,10 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2UpsertTableRowContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -56,9 +61,6 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser return v2Error('NOT_FOUND', 'Table not found') } - const gateError = await v2TablesGateError(userId, validated.workspaceId) - if (gateError) return gateError - const idByName = buildIdByName(table.schema as TableSchema) const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) const upsertResult = await upsertRow( diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts index 8c290804b5e..af7a12f403a 100644 --- a/apps/sim/app/api/v2/tables/route.test.ts +++ b/apps/sim/app/api/v2/tables/route.test.ts @@ -46,6 +46,10 @@ vi.mock('@/lib/workspaces/utils', () => ({ getWorkspaceOrganizationId: mockGetWorkspaceOrganizationId, })) +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + import { GET } from '@/app/api/v2/tables/route' const RATE_LIMIT_OK = { @@ -89,43 +93,18 @@ describe('GET /api/v2/tables', () => { mockGetWorkspaceOrganizationId.mockResolvedValue('org-1') }) - it('returns 404 when the tables-v2-api flag is off', async () => { - mockIsFeatureEnabled.mockResolvedValue(false) + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(404) expect((await res.json()).error.code).toBe('NOT_FOUND') expect(mockListTables).not.toHaveBeenCalled() }) - it('runs the flag gate only after the access check, so it cannot leak a cohort oracle', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(403) - expect(mockIsFeatureEnabled).not.toHaveBeenCalled() - }) - - it('returns typed table summaries in the cursor envelope with a private cache header', async () => { - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(200) - expect(res.headers.get('Cache-Control')).toBe('private, no-store') - const body = await res.json() - expect(body.nextCursor).toBeNull() - expect(body.data).toHaveLength(1) - expect(body.data[0]).toMatchObject({ - id: 'tbl_1', - name: 'People', - description: 'A table', - rowCount: 5, - maxRows: 100, - createdAt: '2024-01-01T00:00:00.000Z', - }) - expect(body.data[0].schema.columns[0].name).toBe('name') - }) - it('400s when workspaceId is missing', async () => { const res = await callList('') expect(res.status).toBe(400) diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index 5e3dec6bf66..9082e9280b9 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -9,6 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createTable, getWorkspaceTableLimits, listTables, type TableSchema } from '@/lib/table' import { normalizeColumn } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CursorList, v2Data, @@ -17,7 +18,7 @@ import { v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiTable, v2TablesGateError } from '@/app/api/v2/tables/utils' +import { toApiTable } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TablesAPI') @@ -33,6 +34,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( v2ListTablesContract, request, @@ -48,9 +53,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const gateError = await v2TablesGateError(userId, workspaceId) - if (gateError) return gateError - const tables = await listTables(workspaceId) const items = tables.map(toApiTable) @@ -73,6 +75,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( v2CreateTableContract, request, @@ -88,9 +94,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - const gateError = await v2TablesGateError(userId, params.workspaceId) - if (gateError) return gateError - const planLimits = await getWorkspaceTableLimits(params.workspaceId) const normalizedSchema: TableSchema = { diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index c6418d240a0..00a9510feb8 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -1,5 +1,4 @@ import type { NextResponse } from 'next/server' -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import type { RowData, TableDefinition, TablePredicate, TableSchema } from '@/lib/table' import { predicateToFilter } from '@/lib/table/query-builder/converters' import { @@ -8,7 +7,6 @@ import { } from '@/lib/table/query-builder/validate' import { predicateToStorage } from '@/lib/table/select-values' import type { Filter } from '@/lib/table/types' -import { getWorkspaceOrganizationId } from '@/lib/workspaces/utils' import { normalizeColumn, rootErrorMessage, rowWriteErrorResponse } from '@/app/api/table/utils' import { v2Error } from '@/app/api/v2/lib/response' @@ -25,23 +23,6 @@ function toIso(value: Date | string): string { return value instanceof Date ? value.toISOString() : String(value) } -/** - * Rollout gate for the whole v2 tables surface (`tables-v2-api` flag). - * - * **Call this AFTER the authz check, never before.** Ahead of authz it does a - * primary-DB read keyed on a caller-supplied `workspaceId`, and the 404-vs-403 - * split tells an unauthorized caller whether that workspace's org is in the - * rollout cohort. - */ -export async function v2TablesGateError( - userId: string, - workspaceId: string -): Promise { - const orgId = await getWorkspaceOrganizationId(workspaceId) - if (await isFeatureEnabled('tables-v2-api', { userId, orgId })) return null - return v2Error('NOT_FOUND', 'Not found') -} - /** * 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 diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts index b861215d388..ec2479d09b8 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -14,6 +14,7 @@ import { captureServerEvent } from '@/lib/posthog/server' import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' import { checkRateLimit } from '@/app/api/v1/middleware' import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' const logger = createLogger('V2WorkflowDeployAPI') @@ -31,6 +32,10 @@ export const POST = withRouteHandler( if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2DeployWorkflowContract, request, context, { validationErrorResponse: v2ValidationError, }) @@ -114,6 +119,10 @@ export const DELETE = withRouteHandler( if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2UndeployWorkflowContract, request, context, { validationErrorResponse: v2ValidationError, }) diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts index 4f70c4942f5..757b09e7388 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts @@ -132,6 +132,10 @@ vi.mock('@sim/utils/id', () => ({ ), })) +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + import { attachExecutionResult } from '@/executor/utils/errors' import { POST } from './route' @@ -268,6 +272,18 @@ describe('POST /api/v2/workflows/[id]/execute', () => { ) }) + it('404s the whole surface when the v2-api flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callExecute({ input: {} }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockPreprocessExecution).not.toHaveBeenCalled() + }) + it('rejects unknown body keys (strict contract)', async () => { const res = await callExecute({ input: {}, triggerType: 'manual' }) 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 5cc786a2340..ebe26b9ac2e 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -25,6 +25,7 @@ import { } from '@/lib/workflows/streaming/agent-stream-protocol' import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' import { authenticateV1Request } from '@/app/api/v1/auth' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { type V2ErrorCode, v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' import { PublicApiNotAllowedError, @@ -134,6 +135,9 @@ export const POST = withRouteHandler( isPublicApiAccess = true } + const gate = await v2ApiGateError(userId) + if (gate) return gate + const ticket = tryAdmit() if (!ticket) { return v2Error('RATE_LIMITED', 'Server is at capacity. Please retry shortly.', { diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts index 6f7bc473a22..c0e96fc8080 100644 --- a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts @@ -36,6 +36,10 @@ vi.mock('@/lib/workflows/executor/enqueue-execution', () => ({ WORKFLOW_EXECUTION_JOB_ID_PREFIX: 'workflow-execution:', })) +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + import { POST as cancelPost } from './cancel/route' import { GET } from './route' 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 e8aa31c0710..35ab0d287d6 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.ts @@ -9,6 +9,7 @@ import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' const logger = createLogger('V2WorkflowExportAPI') @@ -34,6 +35,10 @@ export const GET = withRouteHandler( if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2ExportWorkflowContract, request, context, { validationErrorResponse: v2ValidationError, }) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts index b2d2d1d2a92..8d1cea75788 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -12,6 +12,7 @@ import { performActivateVersion } from '@/lib/workflows/orchestration' import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' import { checkRateLimit } from '@/app/api/v1/middleware' import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' const logger = createLogger('V2WorkflowRollbackAPI') @@ -29,6 +30,10 @@ export const POST = withRouteHandler( if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2RollbackWorkflowContract, request, context, { validationErrorResponse: v2ValidationError, }) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index a059d669648..7698187bb7a 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -11,6 +11,7 @@ import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' const logger = createLogger('V2WorkflowDetailAPI') @@ -26,6 +27,10 @@ export const GET = withRouteHandler( if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2GetWorkflowContract, request, context, { validationErrorResponse: v2ValidationError, }) diff --git a/apps/sim/app/api/v2/workflows/import/route.ts b/apps/sim/app/api/v2/workflows/import/route.ts index 65ae7a3dc22..af66621b16c 100644 --- a/apps/sim/app/api/v2/workflows/import/route.ts +++ b/apps/sim/app/api/v2/workflows/import/route.ts @@ -10,6 +10,7 @@ import { MAX_IMPORT_BODY_BYTES, } from '@/lib/workflows/operations/import-workflow' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { type V2ErrorCode, v2Data, @@ -48,6 +49,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( v2ImportWorkflowContract, request, diff --git a/apps/sim/app/api/v2/workflows/lib/access.ts b/apps/sim/app/api/v2/workflows/lib/access.ts index 765933793ee..404b820ac89 100644 --- a/apps/sim/app/api/v2/workflows/lib/access.ts +++ b/apps/sim/app/api/v2/workflows/lib/access.ts @@ -3,6 +3,7 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/work import type { NextRequest, NextResponse } from 'next/server' import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' import { authenticateV1Request } from '@/app/api/v1/auth' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Error } from '@/app/api/v2/lib/response' type WorkflowRecord = typeof workflowTable.$inferSelect @@ -32,6 +33,9 @@ export async function resolveV2WorkflowAccess( return { ok: false, response: v2Error('UNAUTHORIZED', auth.error || 'Unauthorized') } } + const gate = await v2ApiGateError(auth.userId) + if (gate) return { ok: false, response: gate } + const authorization = await authorizeWorkflowByWorkspacePermission({ workflowId, userId: auth.userId, diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index a35f045bda7..ffe19c9ebf1 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -9,6 +9,7 @@ import { type V2WorkflowListItem, v2ListWorkflowsContract } from '@/lib/api/cont import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { decodeCursor, encodeCursor, @@ -39,6 +40,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( v2ListWorkflowsContract, request, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx index 9dff329a647..a5950e82f60 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx @@ -12,7 +12,6 @@ import { Tooltip, } from '@sim/emcn' import { Check, Clipboard } from 'lucide-react' -import { getBaseUrl } from '@/lib/core/utils/urls' import { AGENT_STREAM_PROTOCOL_HEADER_LABEL, AGENT_STREAM_PROTOCOL_V1, @@ -25,6 +24,7 @@ interface WorkflowDeploymentInfo { deployedAt?: string apiKey: string endpoint: string + exampleCommand: string needsRedeployment: boolean isPublicApi?: boolean } @@ -39,7 +39,7 @@ interface ApiDeployProps { onSelectedStreamingOutputsChange: (outputs: string[]) => void } -type AsyncExampleType = 'execute' | 'status' | 'usage' +type AsyncExampleType = 'execute' | 'status' | 'rate-limits' type CodeLanguage = 'curl' | 'python' | 'javascript' | 'typescript' type CopiedState = { @@ -97,23 +97,19 @@ export function ApiDeploy({ return info.endpoint.replace(info.apiKey, '$SIM_API_KEY') } - /** The workflow's example input fields, parsed from the shared example command. */ - const getInputObject = (): Record => { + const getPayloadObject = (): Record => { const inputExample = getInputFormatExample ? getInputFormatExample(false) : '' const match = inputExample.match(/-d\s*'([\s\S]*)'/) if (match) { try { return JSON.parse(match[1]) as Record } catch { - return { key: 'value' } + return { input: 'your data here' } } } - return { key: 'value' } + return { input: 'your data here' } } - /** v2 body: the input nests under `input`; control fields are siblings. */ - const getPayloadObject = (): Record => ({ input: getInputObject() }) - const getStreamPayloadObject = (): Record => { const payload: Record = { ...getPayloadObject(), stream: true } if (selectedStreamingOutputs && selectedStreamingOutputs.length > 0) { @@ -152,7 +148,7 @@ ${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')} ) -print(response.json()["data"])` +print(response.json())` case 'javascript': return `const response = await fetch("${endpoint}", { @@ -163,7 +159,7 @@ ${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Typ body: JSON.stringify(${JSON.stringify(payload)}) }); -const { data } = await response.json(); +const data = await response.json(); console.log(data);` case 'typescript': @@ -175,7 +171,7 @@ ${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Typ body: JSON.stringify(${JSON.stringify(payload)}) }); -const { data }: { data: Record } = await response.json(); +const data: Record = await response.json(); console.log(data);` default: @@ -265,8 +261,8 @@ while (true) { const getAsyncCommand = (): string => { if (!info) return '' const endpoint = getBaseEndpoint() - const baseUrl = getBaseUrl() - const payload = { ...getPayloadObject(), async: true } + const baseUrl = endpoint.split('/api/workflows/')[0] + const payload = getPayloadObject() const isPublic = info.isPublicApi switch (asyncExampleType) { @@ -275,6 +271,7 @@ while (true) { case 'curl': return `curl -X POST \\ ${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\ + -H "X-Execution-Mode: async" \\ -d '${JSON.stringify(payload)}' \\ ${endpoint}` @@ -285,38 +282,40 @@ import requests response = requests.post( "${endpoint}", headers={ -${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json" +${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json", + "X-Execution-Mode": "async" }, json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')} ) -job = response.json()["data"] -print(job) # Contains executionId and statusUrl` +job = response.json() +print(job) # Contains jobId and executionId` case 'javascript': return `const response = await fetch("${endpoint}", { method: "POST", headers: { -${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json" +${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json", + "X-Execution-Mode": "async" }, body: JSON.stringify(${JSON.stringify(payload)}) }); -const { data: job } = await response.json(); -console.log(job); // Contains executionId and statusUrl` +const job = await response.json(); +console.log(job); // Contains jobId and executionId` case 'typescript': return `const response = await fetch("${endpoint}", { method: "POST", headers: { -${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json" +${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json", + "X-Execution-Mode": "async" }, body: JSON.stringify(${JSON.stringify(payload)}) }); -const { data: job }: { data: { executionId: string; statusUrl: string } } = - await response.json(); -console.log(job); // Poll statusUrl until status is terminal` +const job: { jobId: string; executionId: string } = await response.json(); +console.log(job); // Contains jobId and executionId` default: return '' @@ -326,84 +325,84 @@ console.log(job); // Poll statusUrl until status is terminal` switch (language) { case 'curl': return `curl -H "X-API-Key: $SIM_API_KEY" \\ - ${baseUrl}/api/v2/workflows/${workflowId}/executions/EXECUTION_ID` + ${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION` case 'python': return `import os import requests response = requests.get( - "${baseUrl}/api/v2/workflows/${workflowId}/executions/EXECUTION_ID", + "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION", headers={"X-API-Key": os.environ.get("SIM_API_KEY")} ) -status = response.json()["data"] -print(status) # status: queued | running | completed | failed | cancelled | paused` +status = response.json() +print(status)` case 'javascript': return `const response = await fetch( - "${baseUrl}/api/v2/workflows/${workflowId}/executions/EXECUTION_ID", + "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const { data: status } = await response.json(); +const status = await response.json(); console.log(status);` case 'typescript': return `const response = await fetch( - "${baseUrl}/api/v2/workflows/${workflowId}/executions/EXECUTION_ID", + "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const { data: status }: { data: Record } = await response.json(); +const status: Record = await response.json(); console.log(status);` default: return '' } - case 'usage': + case 'rate-limits': switch (language) { case 'curl': return `curl -H "X-API-Key: $SIM_API_KEY" \\ - ${baseUrl}/api/v2/billing/usage` + ${baseUrl}/api/users/me/usage-limits` case 'python': return `import os import requests response = requests.get( - "${baseUrl}/api/v2/billing/usage", + "${baseUrl}/api/users/me/usage-limits", headers={"X-API-Key": os.environ.get("SIM_API_KEY")} ) -limits = response.json()["data"] -print(limits) # totalCredits + bySourceCredits breakdown` +limits = response.json() +print(limits)` case 'javascript': return `const response = await fetch( - "${baseUrl}/api/v2/billing/usage", + "${baseUrl}/api/users/me/usage-limits", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const { data: limits } = await response.json(); +const limits = await response.json(); console.log(limits);` case 'typescript': return `const response = await fetch( - "${baseUrl}/api/v2/billing/usage", + "${baseUrl}/api/users/me/usage-limits", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const { data: limits }: { data: Record } = await response.json(); +const limits: Record = await response.json(); console.log(limits);` default: @@ -415,6 +414,19 @@ console.log(limits);` } } + const getAsyncExampleTitle = () => { + switch (asyncExampleType) { + case 'execute': + return 'Execute Job' + case 'status': + return 'Check Status' + case 'rate-limits': + return 'Usage Limits' + default: + return 'Execute Job' + } + } + const handleCopy = (key: keyof CopiedState, value: string) => { navigator.clipboard.writeText(value) setCopied((prev) => ({ ...prev, [key]: true })) @@ -552,7 +564,7 @@ console.log(limits);` options={[ { label: 'Execute Job', value: 'execute' }, { label: 'Check Status', value: 'status' }, - { label: 'Usage', value: 'usage' }, + { label: 'Usage Limits', value: 'rate-limits' }, ]} value={asyncExampleType} onChange={(value) => setAsyncExampleType(value as AsyncExampleType)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 91bb280288e..aa33d96cc92 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -77,6 +77,7 @@ interface WorkflowDeploymentInfoUI { deployedAt?: string apiKey: string endpoint: string + exampleCommand: string needsRedeployment: boolean isPublicApi: boolean } @@ -233,7 +234,7 @@ export function DeployModal({ return null } - const endpoint = `${getBaseUrl()}/api/v2/workflows/${workflowId}/execute` + const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute` const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) const placeholderKey = getApiHeaderPlaceholder() @@ -242,6 +243,7 @@ export function DeployModal({ deployedAt: deploymentInfoData.deployedAt ?? undefined, apiKey: getApiKeyLabel(deploymentInfoData.apiKey), endpoint, + exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`, needsRedeployment: deploymentInfoData.needsRedeployment, isPublicApi: isPublicApiDisabled ? false : (deploymentInfoData.isPublicApi ?? false), } diff --git a/apps/sim/blocks/blocks/api_trigger.ts b/apps/sim/blocks/blocks/api_trigger.ts index 9c264b78fd9..27ad2beef33 100644 --- a/apps/sim/blocks/blocks/api_trigger.ts +++ b/apps/sim/blocks/blocks/api_trigger.ts @@ -11,7 +11,7 @@ export const ApiTriggerBlock: BlockConfig = { bestPractices: ` - Can run the workflow manually to test implementation when this is the trigger point. - The input format determines variables accesssible in the following blocks. E.g. . You can set the value in the input format to test the workflow manually. - - In production, the curl would come in as e.g. curl -X POST -H "X-API-Key: $SIM_API_KEY" -H "Content-Type: application/json" -d '{"input":{"paramName":"example"}}' https://www.sim.ai/api/v2/workflows/9e7e4f26-fc5e-4659-b270-7ea474b14f4a/execute -- If user asks to test via API, you might need to clarify the API key. + - In production, the curl would come in as e.g. curl -X POST -H "X-API-Key: $SIM_API_KEY" -H "Content-Type: application/json" -d '{"paramName":"example"}' https://www.staging.sim.ai/api/workflows/9e7e4f26-fc5e-4659-b270-7ea474b14f4a/execute -- If user asks to test via API, you might need to clarify the API key. `, category: 'triggers', hideFromToolbar: true, diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index 3ebc74b6fee..f0d14b9ba29 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -30,7 +30,7 @@ import { ensureWorkflowAccess } from '../access' import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../param-types' function buildWorkflowApiEndpoint(baseUrl: string, workflowId: string): string { - return `${baseUrl}/api/v2/workflows/${workflowId}/execute` + return `${baseUrl}/api/workflows/${workflowId}/execute` } function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) { @@ -57,8 +57,9 @@ function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) { method: 'POST', transport: 'json', stream: false, - body: { async: true, input: { key: 'value' } }, - jobStatusEndpointTemplate: `${baseUrl}/api/v2/workflows/{workflowId}/executions/{executionId}`, + headers: { 'X-Execution-Mode': 'async' }, + body: { input: { key: 'value' } }, + jobStatusEndpointTemplate: `${baseUrl}/api/jobs/{jobId}`, }, }, } @@ -77,8 +78,9 @@ function buildWorkflowApiExamples(baseUrl: string, apiEndpoint: string) { async: `curl -X POST "${apiEndpoint}" \\ -H "Content-Type: application/json" \\ -H "X-API-Key: YOUR_API_KEY" \\ - -d '{"async":true,"input":{"key":"value"}}'`, - poll: `curl "${baseUrl}/api/v2/workflows/WORKFLOW_ID/executions/EXECUTION_ID" \\ + -H "X-Execution-Mode: async" \\ + -d '{"input":{"key":"value"}}'`, + poll: `curl "${baseUrl}/api/jobs/JOB_ID" \\ -H "X-API-Key: YOUR_API_KEY"`, } } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index a649f6c7112..ff61d1762d1 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -82,7 +82,7 @@ export async function executeCheckDeploymentStatus( const apiDetails = { isDeployed: isApiDeployed, deployedAt: apiDeploy[0]?.deployedAt || null, - endpoint: isApiDeployed ? `/api/v2/workflows/${workflowId}/execute` : null, + endpoint: isApiDeployed ? `/api/workflows/${workflowId}/execute` : null, apiKey: workflowRecord.workspaceId ? 'Workspace API keys' : 'Personal API keys', needsRedeployment, activeDeployment: deploymentSummary.activeDeployment, diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 789c1044eb0..d394ed07347 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -808,7 +808,7 @@ export function serializeDeployments(data: DeploymentData): string { result.api = { isDeployed: true, deployedAt: data.deployedAt?.toISOString(), - apiEndpoint: `/api/v2/workflows/${data.workflowId}/execute`, + apiEndpoint: `/api/workflows/${data.workflowId}/execute`, ...(data.api ? { version: data.api.version } : {}), } } diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 8c28ba643f8..5877b11e197 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -481,6 +481,7 @@ export const env = createEnv({ SESSION_POLICIES_ENABLED: z.boolean().optional(), // Enable org session policies on self-hosted (bypasses hosted requirements) FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements) DEPLOY_AS_BLOCK: z.boolean().optional(), // Enable deploy-as-block (publish a workflow as a reusable org-wide custom block) + V2_API: z.boolean().optional(), // Enable the /api/v2 HTTP surface (all v2 routes 404 when off) TABLES_V2_API: z.boolean().optional(), // Enable the v2 tables HTTP API (public /api/v2/tables + internal /api/table/[tableId]/query predicate-grammar route) TABLE_LOCKS: z.boolean().optional(), // Enable per-table mutation locks (schema/insert/update/delete toggles) TABLE_VIEWS: z.boolean().optional(), // Enable saved table views (named filter/sort/column-visibility presets) and the column show/hide menu diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 2989f04bcf3..22c64e1708b 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -104,6 +104,15 @@ const FEATURE_FLAGS = { 'custom-block publish/list routes. Off-AppConfig falls back to DEPLOY_AS_BLOCK.', fallback: 'DEPLOY_AS_BLOCK', }, + 'v2-api': { + description: + 'Gate the whole /api/v2 HTTP surface (workflows incl. execute/executions, tables, logs, ' + + 'knowledge, files, audit-logs, billing). One check per request, immediately after auth: ' + + 'when off, every v2 route returns 404 as if the surface does not exist. The gate is keyed ' + + 'on userId only — it never reads workspace/org membership, so an ungated caller learns ' + + 'nothing beyond "no such route". Off-AppConfig falls back to V2_API.', + fallback: 'V2_API', + }, 'tables-v2-api': { description: 'Gate the v2 tables HTTP API — the public read API (GET /api/v2/tables, POST ' + From 936efcf656bcb580a1351db11e72e572d1c4f46f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 12:44:07 -0700 Subject: [PATCH 023/159] feat(cli): CLI contract for the v2 surface, incl. execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `packages/sim-cli/src/contract` — the declarative definition of how the terminal maps onto the API — and folds in the v2 execution endpoints that just landed on improvement/v2-endpoints. ## The contract Read it as a diff against what is already derivable, not a listing. Method, path, path params, field types, enum values, defaults and required-ness all come from the generated operation table (which comes from the Zod contracts), and the command name derives from ` [sub-resource] `. 23 of 47 operations therefore need no entry at all. The 24 that do carry only what a schema cannot express: - names, where REST overloads one path — `DELETE /rows` vs `DELETE /rows/[rowId]` becomes `batch-delete` vs `delete`, and `DELETE /deploy` becomes `undeploy` - flags, where a field's type misdescribes its meaning — `workflowIds` is `z.string()` that the route splits on commas; no generator can infer that - columns, which are editorial - confirm, for the 8 destructive operations ## Execution `executeWorkflow` / `getWorkflowExecution` / `cancelWorkflowExecution` derive badly (`/execute` and `/cancel` are verbs the deriver reads as nouns), so all three are named explicitly: `workflows run`, `workflows executions get|cancel`. `stream` is marked `omit`: it switches the response to SSE, which the JSON client would try to parse. Advertising a flag that breaks the response is worse than not offering it — a `--follow` command that renders the stream is separate and hand-written, like `files download`. ## Also - Drops `check:openapi-drift`. The branch landed `check:openapi`, which does the same path/method reconciliation plus a recursive field diff and validates doc examples against the real Zod schemas — mine was a strict subset. - Surfaces the new v2 rollout gate in the CLI: it answers 404 for callers outside the cohort, indistinguishable from a missing resource, so a 404 now carries that as a possibility rather than a diagnosis. - `executor/utils/errors.ts` widens instead of casting through `unknown`, which is both more honest (the value is an Error) and keeps the double-cast ratchet at 8. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- .github/workflows/test-build.yml | 5 - apps/sim/executor/utils/errors.ts | 5 +- package.json | 1 - packages/sim-cli/src/contract/commands.ts | 176 ++++++++++++++++++++++ packages/sim-cli/src/contract/types.ts | 85 +++++++++++ packages/sim-cli/src/generated/v2-api.ts | 137 +++++++++++++++++ packages/sim-cli/src/http/client.ts | 7 + scripts/generate-v2-cli-api.ts | 77 +--------- 8 files changed, 413 insertions(+), 80 deletions(-) create mode 100644 packages/sim-cli/src/contract/commands.ts create mode 100644 packages/sim-cli/src/contract/types.ts diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index e7464a6363e..5179544b447 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -136,11 +136,6 @@ jobs: - name: Sim CLI API generation up to date run: bun run check:cli-api - # Structure only — the OpenAPI documents keep their hand-written prose, - # but every v2 path/method must still exist on both sides. - - name: OpenAPI matches the v2 contracts - run: bun run check:openapi-drift - # Complements the bridge audit above, which compares against a snapshot # this same PR is allowed to regenerate. This one derives every fact from # the source both sides execute, so it has no such blind spot. diff --git a/apps/sim/executor/utils/errors.ts b/apps/sim/executor/utils/errors.ts index 4b317377308..e40ff4ad8bf 100644 --- a/apps/sim/executor/utils/errors.ts +++ b/apps/sim/executor/utils/errors.ts @@ -155,7 +155,10 @@ function readAttachedBlockContext(error: unknown): { blockType?: string } { if (!(error instanceof Error)) return {} - const attached = error as unknown as AttachedBlockContext + // Widen rather than erase: the value is an Error, it just may carry extra + // fields attached at throw time. Casting through `unknown` would discard + // that, and trips the double-cast ratchet for no benefit. + const attached = error as Error & Partial return { blockId: typeof attached.blockId === 'string' ? attached.blockId : undefined, blockName: typeof attached.blockName === 'string' ? attached.blockName : undefined, diff --git a/package.json b/package.json index 7f6341f0aeb..52a2618034a 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "check:desktop-bridge": "bun run scripts/check-desktop-bridge-contract.ts --check", "check:desktop-ipc": "bun run scripts/check-desktop-ipc-contract.ts", "check:cli-api": "bun run scripts/generate-v2-cli-api.ts --check", - "check:openapi-drift": "bun run scripts/generate-v2-cli-api.ts --check-openapi", "generate:cli-api": "bun run scripts/generate-v2-cli-api.ts", "desktop-bridge-contract:update": "bun run scripts/check-desktop-bridge-contract.ts --update", "mship-contracts:generate": "bun run scripts/sync-mothership-stream-contract.ts", diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts new file mode 100644 index 00000000000..8f000240b36 --- /dev/null +++ b/packages/sim-cli/src/contract/commands.ts @@ -0,0 +1,176 @@ +import type { CliContract } from './types.js' + +/** + * The CLI contract for the v2 surface. + * + * Read this as a diff against what is already derivable — an operation absent + * from this table still gets a command, built entirely from the generated + * operation table. Only the entries below needed a human. + * + * Derived by default: + * listTables → sim tables list + * getKnowledgeDocument → sim knowledge documents get + * upsertTableRow → sim tables upsert + */ +export const CLI_CONTRACT: CliContract = { + // ─── Name collisions: REST overloads one path for single and bulk ───────── + // The derived name is identical for both, so the bulk form is renamed. AWS's + // `batch-` prefix rather than a `--all` flag: the plural is a different and + // more dangerous operation, and it should be a different word. + deleteTableRows: { + command: 'tables rows batch-delete', + describe: 'Delete rows matching a filter, or an explicit list of ids', + flags: { rowIds: { name: 'row', list: true }, filter: { json: true } }, + confirm: 'This deletes every matching row and cannot be undone.', + }, + updateRowsByFilter: { + command: 'tables rows batch-update', + describe: 'Update every row matching a filter', + flags: { filter: { json: true }, data: { json: true } }, + confirm: 'This updates every matching row and cannot be undone.', + }, + // `DELETE /workflows/[id]/deploy` is an undeploy, not a delete. + undeployWorkflow: { + command: 'workflows undeploy', + describe: 'Take a workflow out of deployment', + }, + + // ─── Destructive single-resource operations ─────────────────────────────── + deleteTable: { confirm: 'This deletes the table and all of its rows.' }, + deleteTableRow: { confirm: 'This deletes the row.' }, + deleteTableColumn: { confirm: 'This deletes the column and its values in every row.' }, + deleteKnowledgeBase: { confirm: 'This deletes the knowledge base and every document in it.' }, + deleteKnowledgeDocument: { confirm: 'This deletes the document and its embeddings.' }, + deleteFile: { confirm: 'This archives the file.' }, + + // ─── Fields whose type misdescribes their meaning ───────────────────────── + // `z.string()` that the route splits on commas. No generator can infer this. + listLogs: { + flags: { + workflowIds: { name: 'workflow', list: true }, + folderIds: { name: 'folder', list: true }, + triggers: { name: 'trigger', list: true }, + }, + columns: [ + { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'level' }, + { header: 'trigger' }, + { header: 'workflow', path: 'workflow.name' }, + { header: 'duration', path: 'totalDurationMs', format: 'duration' }, + { header: 'cost', path: 'cost.total', format: 'cost' }, + { header: 'execution', path: 'executionId' }, + ], + }, + searchKnowledge: { + // Accepts a string or an array on the wire; the CLI always sends the array. + flags: { knowledgeBaseIds: { name: 'kb', list: true }, tagFilters: { json: true } }, + columns: [ + { header: 'score', path: 'similarity' }, + { header: 'document', path: 'documentName' }, + { header: 'chunk', path: 'chunkIndex' }, + { header: 'content' }, + ], + }, + + // ─── Friendlier flag names ──────────────────────────────────────────────── + upsertTableRow: { + describe: 'Insert a row, or update the one that conflicts on a unique column', + flags: { + data: { json: true }, + conflictTarget: { name: 'on', describe: 'Unique column to resolve the conflict against' }, + }, + columns: [{ header: 'id' }, { header: 'operation' }], + }, + queryRows: { + command: 'tables rows query', + flags: { predicate: { name: 'filter', json: true }, sort: { json: true } }, + }, + + // ─── Output columns for list commands ───────────────────────────────────── + listTables: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'rows', path: 'rowCount' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listWorkflows: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'deployed', path: 'isDeployed', format: 'bool' }, + { header: 'runs', path: 'runCount' }, + { header: 'last run', path: 'lastRunAt', format: 'timestamp' }, + ], + }, + listFiles: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'size', format: 'bytes' }, + { header: 'type' }, + { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, + ], + }, + listKnowledgeBases: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'docs', path: 'docCount' }, + { header: 'tokens', path: 'tokenCount' }, + { header: 'model', path: 'embeddingModel' }, + ], + }, + listKnowledgeDocuments: { + columns: [ + { header: 'id' }, + { header: 'filename' }, + { header: 'size', path: 'fileSize', format: 'bytes' }, + { header: 'status', path: 'processingStatus' }, + { header: 'chunks', path: 'chunkCount' }, + ], + }, + listAuditLogs: { + columns: [ + { header: 'at', path: 'createdAt', format: 'timestamp' }, + { header: 'actor', path: 'actorEmail' }, + { header: 'action' }, + { header: 'resource', path: 'resourceName' }, + ], + }, + + // ─── Execution ──────────────────────────────────────────────────────────── + // The derived names land badly here: `/execute` and `/cancel` are verbs in + // the path, but neither is in the action list, so POST would derive + // `workflows execute create` and `workflows cancel create`. + executeWorkflow: { + command: 'workflows run', + describe: 'Run a deployed workflow and wait for the result', + flags: { + input: { json: true, describe: 'Trigger input as JSON' }, + selectedOutputs: { name: 'output', list: true }, + // SSE, not JSON — the generic client cannot consume it. A `sim workflows + // run --follow` that renders the stream is a separate, hand-written + // command; advertising a flag that breaks the response is worse than + // not offering it yet. + stream: { omit: true }, + }, + }, + getWorkflowExecution: { + command: 'workflows executions get', + describe: 'Show the status of one execution', + }, + cancelWorkflowExecution: { + command: 'workflows executions cancel', + describe: 'Cancel a running execution', + // Not `confirm`-gated: cancelling is recoverable (re-run it), and the + // whole point is to stop something that is already going wrong. + }, + + // ─── Not a terminal-shaped operation ────────────────────────────────────── + // Multipart upload; `sim files upload ` needs its own file-reading + // command rather than a generated flag surface. + uploadFile: { hidden: true }, + uploadKnowledgeDocument: { hidden: true }, +} diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts new file mode 100644 index 00000000000..f255ecc88e5 --- /dev/null +++ b/packages/sim-cli/src/contract/types.ts @@ -0,0 +1,85 @@ +import type { V2OperationName } from '../generated/v2-api.js' + +/** + * The CLI contract: how the terminal surface maps onto the v2 API. + * + * Most of a command is derivable and is NOT stated here. Method, path, path + * params, field types, enum values, defaults, and required-ness all come from + * the generated operation table, which comes from the Zod route contracts. The + * command name itself derives from ` ` for 41 of + * the 44 operations. + * + * This file carries only what a schema cannot say: + * + * - `command` — when the derived name collides or reads badly. REST overloads + * one path for single and bulk (`DELETE /rows` vs `DELETE /rows/[rowId]`), so + * those need a human to pick `delete` vs `batch-delete`. + * - `flags` — when a field's *type* misdescribes its *meaning*. `workflowIds` + * is `z.string()` that the route splits on commas; nothing in the schema says + * "list". Also friendlier aliases (`conflictTarget` → `--on`). + * - `columns` — which of a response's fields belong in a table. Editorial. + * - `confirm` — which operations are destructive enough to demand `--yes`. + * + * An operation with nothing unusual needs no entry at all. + */ + +/** How one request field is exposed as a flag. */ +export interface FlagSpec { + /** Flag name, kebab-case, without `--`. Defaults to the kebab-cased field name. */ + name?: string + /** Short alias, e.g. `w` for `--workspace`. */ + short?: string + /** + * Accept a repeated flag and send it comma-joined. For fields the schema + * types as `string` but the route splits — invisible to any type-driven + * generator, so it has to be stated. + */ + list?: boolean + /** Take a JSON string. Implied for object/array/unknown fields. */ + json?: boolean + /** Overrides the help text otherwise taken from the OpenAPI description. */ + describe?: string + /** + * Never expose this field as a flag, and never send it. + * + * For request fields the terminal cannot honor — `stream: true` switches the + * response to SSE, which the JSON client would try to `JSON.parse`. Offering + * the flag would advertise a mode that breaks; a bespoke streaming command + * owns that instead. + */ + omit?: boolean +} + +/** A column in table-mode output. */ +export interface ColumnSpec { + /** Header, and the default path into the row when `value` is omitted. */ + header: string + /** Dot path into the row. Defaults to `header`. */ + path?: string + /** Rendering hint; `auto` inspects the value. */ + format?: 'auto' | 'timestamp' | 'bytes' | 'duration' | 'bool' | 'cost' +} + +export interface CommandSpec { + /** + * Command path, space-separated. Omit to accept the derived + * ` [sub-resource] ` name. + */ + command?: string + /** One-line help. Falls back to the OpenAPI summary for the operation. */ + describe?: string + /** Per-field flag overrides, keyed by the contract's field name. */ + flags?: Record + /** Columns for table output. Omit on non-list commands to print a record. */ + columns?: ColumnSpec[] + /** + * Require `--yes`. The message should say what is about to be destroyed — + * the point is that the caller can tell whether they meant it. + */ + confirm?: string + /** Keep the operation out of the CLI surface entirely. */ + hidden?: boolean +} + +/** The contract: operation name → how it appears in the terminal. */ +export type CliContract = Partial> diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index f6f7238c14b..f1bcb5ddd52 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -50,6 +50,29 @@ export type AddTableColumnResponse = { } } +/** `POST /api/v2/workflows/[id]/executions/[executionId]/cancel` */ +export type CancelWorkflowExecutionParams = { + id: string + executionId: string +} + +export type CancelWorkflowExecutionResponse = { + data: { + success: boolean + executionId: string + redisAvailable: boolean + durablyRecorded: boolean + locallyAborted: boolean + pausedCancelled: boolean + reason?: + | 'recorded' + | 'redis_unavailable' + | 'redis_write_failed' + | 'paused_event_publish_failed' + | 'paused_database_cancel_failed' + } +} + /** `POST /api/v2/knowledge` */ export type CreateKnowledgeBaseBody = { workspaceId: string @@ -364,6 +387,49 @@ export type DownloadFileQuery = { /** Non-JSON response (`binary`). */ export type DownloadFileResponse = never +/** `POST /api/v2/workflows/[id]/execute` */ +export type ExecuteWorkflowParams = { + id: string +} + +export type ExecuteWorkflowBody = { + input?: Record + async?: boolean + stream?: boolean + selectedOutputs?: Array + includeThinking?: boolean + includeToolCalls?: boolean + includeFileBase64?: boolean + base64MaxBytes?: number +} + +export type ExecuteWorkflowResponse = { + data: { + executionId: string + workflowId: string + status: 'completed' | 'failed' | 'paused' | 'cancelled' + output: unknown + error: { + message: string + code: + | 'TIMEOUT' + | 'CANCELLED' + | 'USAGE_LIMIT_EXCEEDED' + | 'INVALID_INPUT' + | 'BLOCK_EXECUTION_FAILED' + | 'CHILD_WORKFLOW_FAILED' + | 'OUTPUT_TOO_LARGE' + | 'EXECUTION_FAILED' + blockId?: string + blockName?: string + blockType?: string + } | null + startedAt?: string + endedAt?: string + durationMs?: number + } +} + /** `GET /api/v2/workflows/[id]/export` */ export type ExportWorkflowParams = { id: string @@ -743,6 +809,59 @@ export type GetWorkflowResponse = { } } +/** `GET /api/v2/workflows/[id]/executions/[executionId]` */ +export type GetWorkflowExecutionParams = { + id: string + executionId: string +} + +export type GetWorkflowExecutionQuery = { + includeOutput?: 'true' | 'false' + selectedOutputs?: string +} + +export type GetWorkflowExecutionResponse = { + data: { + executionId: string + workflowId: string + status: 'queued' | 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused' + trigger: string | null + startedAt: string | null + endedAt: string | null + durationMs: number | null + paused: { + pausedAt: string + resumeAt: string | null + pauseKind: 'time' | 'human' | null + blockedOnBlockId: string | null + automaticResumeWaitingReason: string | null + pausedExecutionId: string + pausePointCount: number + resumedCount: number + } | null + cost: { + total: number + } | null + error: { + message: string + code: + | 'TIMEOUT' + | 'CANCELLED' + | 'USAGE_LIMIT_EXCEEDED' + | 'INVALID_INPUT' + | 'BLOCK_EXECUTION_FAILED' + | 'CHILD_WORKFLOW_FAILED' + | 'OUTPUT_TOO_LARGE' + | 'EXECUTION_FAILED' + blockId?: string + blockName?: string + blockType?: string + } | null + output: unknown | null + blockOutputs: Record | null + } +} + /** `POST /api/v2/workflows/import` */ export type ImportWorkflowBody = { workspaceId: string @@ -1394,6 +1513,12 @@ export const V2_OPERATIONS = { pathParams: ['tableId'] as const, responseMode: 'json', }, + cancelWorkflowExecution: { + method: 'POST', + path: '/api/v2/workflows/[id]/executions/[executionId]/cancel', + pathParams: ['id', 'executionId'] as const, + responseMode: 'json', + }, createKnowledgeBase: { method: 'POST', path: '/api/v2/knowledge', @@ -1466,6 +1591,12 @@ export const V2_OPERATIONS = { pathParams: ['fileId'] as const, responseMode: 'binary', }, + executeWorkflow: { + method: 'POST', + path: '/api/v2/workflows/[id]/execute', + pathParams: ['id'] as const, + responseMode: 'json', + }, exportWorkflow: { method: 'GET', path: '/api/v2/workflows/[id]/export', @@ -1526,6 +1657,12 @@ export const V2_OPERATIONS = { pathParams: ['id'] as const, responseMode: 'json', }, + getWorkflowExecution: { + method: 'GET', + path: '/api/v2/workflows/[id]/executions/[executionId]', + pathParams: ['id', 'executionId'] as const, + responseMode: 'json', + }, importWorkflow: { method: 'POST', path: '/api/v2/workflows/import', diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index 22110a846db..0c806c31db8 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -152,6 +152,13 @@ export class SimClient { if (response.status === 401) { error.message = `${error.message} — run: sim login --profile ${this.profile.name}` } + if (response.status === 404) { + // The v2 surface is behind a rollout flag that answers 404 when the + // caller is not in the cohort — deliberately indistinguishable from a + // missing resource, so the CLI cannot tell which happened. Offered as a + // possibility rather than a diagnosis; a plain bad id 404s identically. + error.message = `${error.message}\n If every command returns this, the v2 API may not be enabled for your account yet.` + } throw error } diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index 17aa0db715f..b0dbc74624b 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -16,15 +16,14 @@ * boundary changes. * * Deliberately NOT generated: the OpenAPI documents under `apps/docs`. They - * carry ~1000 hand-written descriptions and ~400 examples that Zod schemas do - * not encode, and regenerating them would trade real documentation for - * mechanical accuracy. `--check-openapi` reconciles their *structure* against - * the contracts instead, so the prose survives while drift still fails CI. + * carry hand-written descriptions, examples, and error responses that Zod + * schemas do not encode. `scripts/check-openapi-specs.ts` reconciles those + * against the same contracts instead, field by field, so the prose survives + * while drift still fails CI. * * Usage: * bun run scripts/generate-v2-cli-api.ts # write the generated file * bun run scripts/generate-v2-cli-api.ts --check # fail if it is stale - * bun run scripts/generate-v2-cli-api.ts --check-openapi */ import { spawnSync } from 'node:child_process' @@ -35,7 +34,6 @@ import { z } from 'zod' const ROOT = path.resolve(import.meta.dir, '..') const CONTRACTS_DIR = path.join(ROOT, 'apps/sim/lib/api/contracts/v2') const OUTPUT = path.join(ROOT, 'packages/sim-cli/src/generated/v2-api.ts') -const DOCS_DIR = path.join(ROOT, 'apps/docs') /** Contract modules to read, in emit order. */ const DOMAINS = [ @@ -238,59 +236,6 @@ function render(operations: Operation[]): string { return out.join('\n') } -/** - * Reconciles the hand-written OpenAPI documents against the contracts. - * - * Structure only — every contract path/method must be documented, and every - * documented v2 path/method must exist as a contract. Descriptions and examples - * are the docs' own, and are deliberately not compared. - */ -function checkOpenApi(operations: Operation[]): string[] { - const problems: string[] = [] - - const documented = new Set() - for (const file of [ - 'openapi-core.json', - 'openapi-v2-workflows.json', - 'openapi-v2-logs.json', - 'openapi-v2-tables.json', - 'openapi-v2-knowledge.json', - 'openapi-v2-files-audit.json', - ]) { - let spec: JsonSchema - try { - spec = JSON.parse(readFileSync(path.join(DOCS_DIR, file), 'utf8')) - } catch { - problems.push(`missing or unparseable spec: ${file}`) - continue - } - for (const [specPath, methods] of Object.entries(spec.paths ?? {})) { - for (const method of Object.keys(methods as object)) { - if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) continue - documented.add(`${method.toUpperCase()} ${specPath}`) - } - } - } - - for (const op of operations) { - // Contracts use Next.js `[id]`; OpenAPI uses `{id}`. - const openApiPath = op.contract.path.replace(/\[([^\]]+)\]/g, '{$1}') - const key = `${op.contract.method} ${openApiPath}` - if (!documented.has(key)) { - problems.push(`contract not documented in OpenAPI: ${key} (${op.name})`) - } - documented.delete(key) - } - - for (const stale of documented) { - if (stale.includes('/api/v2/')) { - problems.push(`documented in OpenAPI but no contract: ${stale}`) - } - } - - return problems -} - /** * Runs the emitted source through Biome so the generated file is a fixed point * of the repo's formatter. @@ -324,20 +269,6 @@ async function main() { const args = new Set(process.argv.slice(2)) const operations = await collectOperations() - if (args.has('--check-openapi')) { - const problems = checkOpenApi(operations) - if (problems.length > 0) { - console.error('OpenAPI drift against the v2 contracts:\n') - for (const problem of problems) console.error(` - ${problem}`) - console.error( - '\nUpdate apps/docs/openapi-v2-*.json to match the contracts (the contracts are authoritative).' - ) - process.exit(1) - } - console.log(`OpenAPI matches all ${operations.length} v2 contracts.`) - return - } - const generated = format(render(operations)) if (args.has('--check')) { From fbc7b177b7271f6a4308a9cd8c8227e4a6866c1c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 16:01:22 -0700 Subject: [PATCH 024/159] fix(executor): restore child-cost aggregation dropped by the staging merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staging's custom-block rewrite deleted `aggregateChildCost` from workflow-handler.ts, and git merged that file cleanly — but this branch's workflow-tool-runner.ts, added for the v2 execute migration, still imports it. A silent semantic conflict: no marker, broken build. Taking staging's rewrite is correct, so the helper is defined locally in its one remaining consumer rather than resurrected in the file staging just rewrote. Same four lines over the still-exported `calculateCostSummary`, so a failed child workflow keeps billing the hosted-key spend it consumed instead of reporting $0. Co-Authored-By: Claude Opus 5 --- .../handlers/workflow/workflow-tool-runner.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts index e4905adacb1..9596b039cca 100644 --- a/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts +++ b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts @@ -1,15 +1,14 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { calculateCostSummary } from '@/lib/logs/execution/logging-factory' +import type { TraceSpan } from '@/lib/logs/types' import { ChildWorkflowError } from '@/executor/errors/child-workflow-error' import { buildCustomBlockExecutionContext, type CustomBlockExecutorContext, } from '@/executor/handlers/workflow/custom-block-tool-runner' -import { - aggregateChildCost, - WorkflowBlockHandler, -} from '@/executor/handlers/workflow/workflow-handler' +import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler' import { classifyExecutionError } from '@/executor/utils/errors' import { parseJSON } from '@/executor/utils/json' import type { SerializedBlock } from '@/serializer/types' @@ -17,6 +16,18 @@ import type { ToolResponse } from '@/tools/types' const logger = createLogger('WorkflowToolRunner') +/** + * Hosted-key spend of a failed child run, the way the parent bills it: recurse + * nested spans and de-dupe model breakdowns, then subtract the base execution + * charge the parent already applies once itself. A naive top-level `cost.total` + * sum undercounts when spend sits on nested children. + */ +function aggregateChildCost(childTraceSpans: TraceSpan[]): number { + if (childTraceSpans.length === 0) return 0 + const summary = calculateCostSummary(childTraceSpans) + return Math.max(0, summary.totalCost - summary.baseExecutionCharge) +} + interface WorkflowToolParams { workflowId?: string inputMapping?: Record | string From 1a3d00424e0384009ddd818c2d625c8517235354 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 18:23:49 -0700 Subject: [PATCH 025/159] feat(cli): yaml and text output formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--output` now takes table | json | yaml | text, settable per-command, via SIM_OUTPUT, or persisted per profile as before. `yaml` joins `json` in rendering the API's raw values rather than the table's formatted cells, so a duration stays `1500` instead of becoming `"1.5s"` — switching format changes the encoding, never the data. Line folding is disabled: valid YAML, but it breaks line-oriented greps and is miserable to read. `text` is tab-separated with no header and no colour — the shape `cut -f2` and `while IFS=$'\t' read` expect, so shell plumbing works on a box with no JSON tool. It uses the rendered cells rather than raw values, since it is a human-ish format for pipelines rather than something to parse. An absent value collapses to an empty field instead of the table's em-dash: `cut` returning a literal `—` would read as a value to every downstream emptiness test. A bad `--output` is now an error (commander `.choices`) rather than a silent fall back to `table`. The environment variable and the config file stay tolerant — those are ambient and set once, so a bad value should not break every command, but a flag just typed should not be quietly disregarded. Uses js-yaml 4.3.0, already a direct dependency of apps/sim, rather than adding a second YAML library to the monorepo. Also drops a stale README reference to check:openapi-drift, which the v2-endpoints merge superseded with the deeper check:openapi. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- bun.lock | 2 + packages/sim-cli/README.md | 49 +++++++++++---- packages/sim-cli/package.json | 4 +- packages/sim-cli/src/config/profile.test.ts | 12 +++- packages/sim-cli/src/config/profile.ts | 10 ++- packages/sim-cli/src/index.ts | 13 +++- packages/sim-cli/src/output/render.test.ts | 59 +++++++++++++++++ packages/sim-cli/src/output/render.ts | 70 ++++++++++++++++++--- 8 files changed, 194 insertions(+), 25 deletions(-) diff --git a/bun.lock b/bun.lock index 0ad4015d018..4322788f585 100644 --- a/bun.lock +++ b/bun.lock @@ -589,9 +589,11 @@ "dependencies": { "chalk": "5.6.2", "commander": "^11.1.0", + "js-yaml": "4.3.0", }, "devDependencies": { "@sim/tsconfig": "workspace:*", + "@types/js-yaml": "4.0.9", "@types/node": "24.2.1", "typescript": "^7.0.2", "vitest": "^3.2.4", diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 25b0fa993d5..25ed1d82f53 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -58,6 +58,8 @@ Each setting resolves independently, first match wins: | 3 | `~/.sim/config` / `~/.sim/credentials` for the selected profile | | 4 | Built-in default (`https://sim.ai`, `table`) | +Formats are listed under [Output formats](#output-formats). + `sim whoami` prints the winning source per setting, which is usually the fastest way to explain a surprising result. @@ -150,13 +152,38 @@ page so a sparse row doesn't hide a column. Deletions require an explicit selector *and* `--yes`; there is no "delete everything" default. -Every command takes `--output json` for scripting; the JSON is the API's own -response shape, so it pipes cleanly into `jq`. +### Output formats + +`--output` / `-o`, or `SIM_OUTPUT`, or `output =` in the profile: + +| Format | For | +| --- | --- | +| `table` | reading (default) | +| `json` | piping into `jq` | +| `yaml` | piping into anything that reads YAML | +| `text` | shell loops — tab-separated, no header, no colour | + +`json` and `yaml` emit the API's **raw** values, not the table's formatting — a +duration stays `1500`, not `"1.5s"` — so switching format never changes the data. +`text` uses the rendered cells, since it is meant for shell plumbing rather than +parsing. ```bash -sim logs list --level error --output json | jq -r '.[].executionId' +sim logs list --level error -o json | jq -r '.[].executionId' +sim logs list --level error -o yaml > logs.yaml + +sim files list -o text | while IFS=$'\t' read -r id name size type uploaded; do + echo "$id $name" +done ``` +An absent value is an em-dash in `table` and an **empty field** in `text`, so +emptiness tests downstream behave. + +A bad `--output` is an error; a bad `SIM_OUTPUT` or `output =` is ignored and +falls back to `table` — ambient settings should not brick every command, but a +flag you just typed should not be silently disregarded. + ## How this stays in sync with the API `src/generated/v2-api.ts` is generated from the Zod route contracts in @@ -166,9 +193,9 @@ It holds every response/request type plus the operation table (method, path, path params) the client dispatches through. ```bash -bun run generate:cli-api # regenerate after changing a contract -bun run check:cli-api # CI: fails if the generated file is stale -bun run check:openapi-drift # CI: fails if the docs and contracts disagree +bun run generate:cli-api # regenerate after changing a contract +bun run check:cli-api # CI: fails if the generated file is stale +bun run check:openapi # CI: fails if the docs and contracts disagree ``` The generated file contains only type declarations and one const — no imports — @@ -176,11 +203,11 @@ so the `packages/*` must not import `apps/*` boundary is preserved; the script does the crossing at build time. The OpenAPI documents under `apps/docs` are deliberately **not** generated. They -carry ~1000 hand-written descriptions and ~400 examples that Zod schemas don't -encode, so regenerating them would trade real documentation for mechanical -accuracy. `check:openapi-drift` reconciles their *structure* against the -contracts instead — every v2 path and method must exist on both sides — so the -prose survives while drift still fails the build. +carry hand-written descriptions, examples, and error responses that Zod schemas +don't encode, so regenerating them would trade real documentation for mechanical +accuracy. `check:openapi` reconciles them against the same contracts instead — +field by field, and it parses every documented example with the real Zod schema — +so the prose survives while drift still fails the build. ## Notes diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json index 4cc20b967fc..15f721ae031 100644 --- a/packages/sim-cli/package.json +++ b/packages/sim-cli/package.json @@ -33,10 +33,12 @@ }, "dependencies": { "chalk": "5.6.2", - "commander": "^11.1.0" + "commander": "^11.1.0", + "js-yaml": "4.3.0" }, "devDependencies": { "@sim/tsconfig": "workspace:*", + "@types/js-yaml": "4.0.9", "@types/node": "24.2.1", "typescript": "^7.0.2", "vitest": "^3.2.4" diff --git a/packages/sim-cli/src/config/profile.test.ts b/packages/sim-cli/src/config/profile.test.ts index 141166945be..fb18c536ab6 100644 --- a/packages/sim-cli/src/config/profile.test.ts +++ b/packages/sim-cli/src/config/profile.test.ts @@ -6,6 +6,7 @@ import { configPath, credentialsPath } from './paths.js' import { deleteProfile, listProfiles, + OUTPUT_FORMATS, resolveProfile, writeConfigProfile, writeCredentialsProfile, @@ -96,10 +97,19 @@ describe('profile resolution', () => { }) it('ignores an unrecognized output format instead of failing the whole resolve', () => { - process.env.SIM_OUTPUT = 'yaml' + // Ambient sources tolerate garbage so one bad value cannot brick every + // command; the `--output` flag is strict instead (commander `.choices`). + process.env.SIM_OUTPUT = 'xml' expect(resolveProfile().output).toBe('table') }) + it('accepts every documented output format from the environment', () => { + for (const format of OUTPUT_FORMATS) { + process.env.SIM_OUTPUT = format + expect(resolveProfile().output).toBe(format) + } + }) + it('writes credentials 0600 even when the file already existed world-readable', () => { writeFileSync(credentialsPath(), '', { mode: 0o644 }) writeCredentialsProfile('default', 'sim_key') diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts index 943d414c7c7..d2e5e85c683 100644 --- a/packages/sim-cli/src/config/profile.ts +++ b/packages/sim-cli/src/config/profile.ts @@ -13,7 +13,15 @@ import { configPath, credentialsPath } from './paths.js' export const DEFAULT_PROFILE = 'default' export const DEFAULT_ENDPOINT = 'https://sim.ai' -export const OUTPUT_FORMATS = ['table', 'json'] as const + +/** + * Output formats, in the order `--help` lists them. + * + * `table` is for reading, `json`/`yaml` for piping into a parser, and `text` is + * the one for shell loops: tab-separated, no header, no colour, so `cut`/`awk`/ + * `while read` work without a JSON tool on the box. + */ +export const OUTPUT_FORMATS = ['table', 'json', 'yaml', 'text'] as const export type OutputFormat = (typeof OUTPUT_FORMATS)[number] /** Everything a command needs to make a call, after the resolution chain runs. */ diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 6b4d8e20149..10a1fb7c191 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import chalk from 'chalk' -import { Command } from 'commander' +import { Command, Option } from 'commander' import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth.js' import { configureCommand } from './commands/configure.js' import { filesCommand } from './commands/files.js' @@ -21,7 +21,16 @@ program .option('-p, --profile ', 'Profile to use (env: SIM_PROFILE)') .option('--endpoint ', 'Sim deployment to talk to (env: SIM_ENDPOINT)') .option('-w, --workspace ', 'Workspace to target (env: SIM_WORKSPACE)') - .option('-o, --output ', `Output format: ${OUTPUT_FORMATS.join(' | ')} (env: SIM_OUTPUT)`) + // `.choices` so a typo'd format is an error, not a silent fall back to + // `table`. Deliberately stricter than SIM_OUTPUT and the config file, which + // tolerate an unknown value: those are ambient and set once, and a bad one + // should not make every command fail — but a flag is an instruction just + // typed, so honouring something else is a lie. + .addOption( + new Option('-o, --output ', 'Output format (env: SIM_OUTPUT)').choices([ + ...OUTPUT_FORMATS, + ]) + ) program.addCommand(loginCommand()) program.addCommand(logoutCommand()) diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts index c092febc001..0212bfbcd6d 100644 --- a/packages/sim-cli/src/output/render.test.ts +++ b/packages/sim-cli/src/output/render.test.ts @@ -1,4 +1,5 @@ import chalk, { Chalk } from 'chalk' +import { load } from 'js-yaml' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { bytes, @@ -87,6 +88,54 @@ describe('printList', () => { printList('json', [{ name: 'alpha', status: 'error' }], COLUMNS) expect(JSON.parse(logged[0])).toEqual([{ name: 'alpha', status: 'error' }]) }) + + it('prints the raw rows for yaml too', () => { + printList('yaml', [{ name: 'alpha', status: 'error' }], COLUMNS) + expect(load(logged[0])).toEqual([{ name: 'alpha', status: 'error' }]) + }) + + it('keeps machine formats identical in content — only the encoding differs', () => { + const rows = [{ name: 'alpha', status: 'error' }] + printList('json', rows, COLUMNS) + printList('yaml', rows, COLUMNS) + expect(load(logged[1])).toEqual(JSON.parse(logged[0])) + }) + + it('does not fold long yaml values across lines', () => { + // Folding is valid YAML but breaks line-oriented greps and is miserable to read. + const long = 'x'.repeat(300) + printList('yaml', [{ name: long, status: 'ok' }], COLUMNS) + expect(logged[0]).toContain(long) + }) + + it('emits tab-separated cells with no header for text', () => { + printList( + 'text', + [ + { name: 'alpha', status: 'error' }, + { name: 'b', status: 'ok' }, + ], + COLUMNS + ) + expect(logged).toEqual(['alpha\terror', 'b\tok']) + }) + + it('strips colour from text output so cut and awk see plain fields', () => { + printList('text', [{ name: 'alpha', status: coloured.red('error') }], COLUMNS) + expect(logged[0]).toBe('alpha\terror') + }) + + it('renders an absent value as an empty text field, not a dash', () => { + // `cut -f2` returning a literal em-dash would read as a value to every + // downstream emptiness test. + printList('text', [{ name: 'alpha', status: text(null) }], COLUMNS) + expect(logged[0]).toBe('alpha\t') + }) + + it('prints nothing at all for an empty text list', () => { + printList('text', [], COLUMNS) + expect(logged).toEqual([]) + }) }) describe('printRecord', () => { @@ -95,6 +144,16 @@ describe('printRecord', () => { expect(JSON.parse(logged[0])).toEqual({ name: 'alpha', hidden: 1 }) }) + it('prints the raw object for yaml, ignoring the field list', () => { + printRecord('yaml', [['Name', 'alpha']], { name: 'alpha', hidden: 1 }) + expect(load(logged[0])).toEqual({ name: 'alpha', hidden: 1 }) + }) + + it('prints label-tab-value for text', () => { + printRecord('text', [['ID', 'abc']], {}) + expect(logged[0]).toBe('ID\tabc') + }) + it('prints one aligned line per field for table', () => { printRecord( 'table', diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index 821043bb85a..0803973467a 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -1,4 +1,5 @@ import chalk from 'chalk' +import { dump } from 'js-yaml' import type { OutputFormat } from '../config/index.js' export interface Column { @@ -6,8 +7,11 @@ export interface Column { value: (row: T) => string } +/** The glyph standing in for "no value", before colour is applied. */ +const EMPTY_GLYPH = '—' + /** Cell text for values that have no useful rendering, kept visually quiet. */ -const EMPTY = chalk.dim('—') +const EMPTY = chalk.dim(EMPTY_GLYPH) export function text(value: unknown): string { if (value === null || value === undefined || value === '') return EMPTY @@ -67,6 +71,18 @@ export function visibleWidth(value: string): number { return value.replace(ANSI_PATTERN, '').length } +/** + * Plain text for a rendered cell. + * + * The empty placeholder collapses to an actual empty field: `cut -f3` returning + * a literal `—` for a null would be worse than useless, since every downstream + * emptiness test would read it as a value. + */ +function stripAnsi(value: string): string { + const plain = value.replace(ANSI_PATTERN, '') + return plain === EMPTY_GLYPH ? '' : plain +} + function pad(value: string, width: number): string { return value + ' '.repeat(Math.max(0, width - visibleWidth(value))) } @@ -94,25 +110,61 @@ function renderTable(rows: T[], columns: Column[]): string { return [header, ...body].join('\n') } +/** + * Renders the machine-readable formats from the RAW value. + * + * Deliberately not the table's formatted cells: `--output json` piped into `jq` + * must yield the API's own field names and types, so a `1500` stays a number + * rather than becoming the `"1.5s"` the table would show. `yaml` follows the + * same rule, so switching format never changes the data. + * + * Returns null when the format wants the human rendering instead. + */ +function renderMachine(format: OutputFormat, raw: unknown): string | null { + if (format === 'json') return JSON.stringify(raw, null, 2) + // `lineWidth: 0` disables YAML's line folding — a wrapped value is technically + // valid but is miserable to eyeball and breaks naive line-oriented greps. + if (format === 'yaml') return dump(raw, { lineWidth: 0, noRefs: true }).trimEnd() + return null +} + /** * Prints a list in the profile's output format. * - * The JSON branch prints the raw rows, not the table's formatted cells — piping - * to `jq` should yield the API's own field names and types, so `--output json` - * is a passthrough rather than a second rendering. + * `text` emits the table's cells tab-separated with no header and no colour — + * the shape `cut -f2` and `while read` expect. It uses the formatted cells + * rather than the raw values on purpose: it is a human-ish format for shell + * plumbing, and a raw ISO timestamp or byte count is worse in that context. */ export function printList(format: OutputFormat, rows: T[], columns: Column[]): void { - if (format === 'json') { - console.log(JSON.stringify(rows, null, 2)) + const machine = renderMachine(format, rows) + if (machine !== null) { + console.log(machine) + return + } + + if (format === 'text') { + for (const row of rows) { + console.log(columns.map((column) => stripAnsi(column.value(row))).join('\t')) + } return } + console.log(renderTable(rows, columns)) } -/** Prints a single record: JSON as-is, table format as aligned key/value lines. */ +/** Prints a single record: machine formats from the raw value, otherwise aligned lines. */ export function printRecord(format: OutputFormat, fields: Array<[string, string]>, raw: unknown) { - if (format === 'json') { - console.log(JSON.stringify(raw, null, 2)) + const machine = renderMachine(format, raw) + if (machine !== null) { + console.log(machine) + return + } + + if (format === 'text') { + for (const [label, value] of fields) { + console.log(`${label}\t${stripAnsi(value)}`) + } return } From 5fea5f7bc69b190714b61ee773209ff7862394b9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 18:24:49 -0700 Subject: [PATCH 026/159] refactor(tables): make lib/table/orchestration the single implementation (#6134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(orchestration): move the shared error contract out of lib/workflows OrchestrationErrorCode and statusForOrchestrationError are the contract every lib/[resource]/orchestration module returns against, but they lived inside the workflows module, so resource-neutral code (lib/folders) already had to import from a workflow path. Moved to lib/core/orchestration/types. Adds a 'locked' class mapping to 423. Both tables and workflows have a lock that forbids a mutation, and each caller was translating that to a status itself. Co-Authored-By: Claude Opus 5 * refactor(tables): make lib/table/orchestration the single implementation Column update was implemented four times — the UI route, v1, v2, and the copilot table tool — each calling the same column services but owning its own guards, error mapping, and audit. The copies had drifted, and the drift was the bug: v2 was missing both guards, only the copilot copy minted stable option ids, and only v1/v2 audited. performUpdateTableColumn, performDeleteTable, and performDeleteTableRow now own that logic; all ten call sites reduce to auth, parse, call, render. The guards are asserted once in lib/table/orchestration rather than four times against four routes. Behavior this consolidates, previously true on only some paths: - The typeChanging guard. updateColumnType early-returns on an unchanged type and drops any options sent with it, so restating the current type alongside new options silently discarded them. v2 had no guard at all and, since its contract shares v1's body schema, accepted options and ignored them. - The select-unique guard. Each write is its own locked transaction, so a rename or type change paired with a constraint write that is going to fail commits first and then throws, half-applying the schema change. - Stable select-option ids. Cells reference the option id, so an edit that re-sends an option by name has to reuse it or every cell holding it is orphaned. Only the copilot path did this; normalizeSelectOptionsInput moves to lib/table/select-options and now covers every caller. It preserves a supplied id, so it is a no-op for the fully-formed options the HTTP contracts accept. - required forwarded into the type and options writes, so a conversion validates against the constraint the same request is setting. - An audit on every successful update. The UI route and the copilot tool emitted none. - Single-row delete through the row service. v2 did a raw db.delete, skipping assertRowDelete and deleteOrderedRow, so a delete-locked table returned 200 and the row-count bookkeeping never ran. - The delete actor handed to deleteTable, which audits only when a row was actually archived. v1 and v2 omitted it and audited themselves outside that check, emitting TABLE_DELETED for a no-op delete of an archived table. Failure classes come back as OrchestrationErrorCode; v2 renders them through a new v2ErrorForOrchestration, mirroring statusForOrchestrationError on the v1 and UI surfaces, so a given failure maps to the same status everywhere. Co-Authored-By: Claude Opus 5 * test(tables): bind the column-update tests to the orchestration function The base's route tests assert which column service each payload reaches — the behavior that now lives in performUpdateTableColumn. They mocked the `@/lib/table` barrel; the orchestration module imports the service directly, so they mock that too and keep asserting the same thing through the extracted implementation. The orchestration tests move onto the base's semantics: writes address the stable column id, a rename rides inside the write it accompanies rather than running first, and the currency guards replace the non-select options guard the service now owns. Co-Authored-By: Claude Opus 5 * chore(copilot): drop the column-type import the delegation made dead Co-Authored-By: Claude Opus 5 * refactor(tables): move the audit log out of the table service `lib/table/service.ts` wrote its own audit rows, so whether an operation was audited depended on which function a caller reached for rather than on a user having performed it. That is what let v1 and v2 audit a no-op delete, and what made `deleteTable`'s optional `actingUserId` double as an audit opt-out flag. Worse, most sites fell back to `actingUserId ?? createdBy`, so an unattributed call was logged against the table's *creator*. The copilot `mv` path passed no actor at all: renaming someone else's table recorded them as the renamer. Audit now lives in the orchestration functions — performDeleteTable, performRenameTable, performMoveTableToFolder, performUpdateTableLocks — and the services just write. Internal callers (folder cascade, import rollback) keep calling the service and are silent by construction rather than by remembering to omit an argument. Two services now return what the audit needs: `deleteTable` reports whether it actually archived a row, so a repeat delete logs nothing; `updateTableLocks` returns the before/after locks, since only the locked write can observe the transition its description names. Co-Authored-By: Claude Opus 5 * fix(tables): restore audit provenance and conflict status in orchestration Moving the audits into the orchestration functions dropped three things the routes had been carrying, and added one the orchestration now owns twice. - The v1 and v2 column-update routes passed `request` to `recordAudit`, so their audit rows recorded the caller's IP and user-agent. The orchestration function had no way to receive it. Every table orchestration function now takes an optional `OrchestrationRequestContext` and every HTTP route forwards it; the copilot and VFS callers, which have no request, omit it. - `classifyTableMutation` matched `TableConflictError` on "already exists" appearing in the message and reported it as `validation`, turning the UI route's 409 on a duplicate table rename into a 400. It now matches the type, the way `performRestoreTable` already did. - `captureServerEvent` ran on every delete while the audit was gated on a row actually being archived, so a repeat delete of an archived table still reported `table_deleted`. Both now hang off the same evidence. - The copilot delete path kept its own `captureServerEvent` from when the service did not emit one, double-counting every copilot table delete. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a * fix(tables): say which type a no-op column update restated A copilot `update_column` payload whose only content was the column's current type used to return success with the live schema, while the v1, v2, and UI routes rejected the same payload with "No updates specified". Delegating to `performUpdateTableColumn` unified them onto the routes' rejection — correct, but the message tells the caller its request was empty when it named a type. The orchestration function now reports the same thing `updateColumnType` reports when it loses this race concurrently: the column is already that type, re-issue without the type change. An empty payload still reads "No updates specified". Drops the copilot's `outcome.table ?? tableForUpdate` fallback with it — the comment described the no-op that can no longer reach that line, and a success always carries a table. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a * refactor(tables): classify failures by type instead of by message text The table module decided HTTP statuses by searching error messages for phrases. `VALIDATION_MESSAGE_FRAGMENTS` and `ROW_WRITE_ERROR_PATTERNS` held 32 substrings between them, and fifteen more lists were inlined in routes — 83 matchers over 17 files, each its own copy of the guesswork and already drifted apart. It made message wording load-bearing: `TableRowLimitError`'s own doc comment noted that its text had to contain "row limit" for a route to answer 400, and adding "already exists" to a rename message silently demoted a 409 to a 400 (the bug fixed one commit ago, by adding another special case). Services now throw `OrchestrationError`, which carries the transport-neutral `OrchestrationErrorCode` the layers above already speak. Classification is one `instanceof` in `orchestrationErrorResponse` (UI + v1) and `v2CaughtOrchestrationError` (v2). Every pattern list is gone. Wording is free to change; an unclassified error still becomes a generic 500, which is what an unexpected fault should be. `asOrchestrationError` walks the `cause` chain rather than testing the caught value directly: drizzle wraps a throw raised inside a transaction callback in a `DrizzleQueryError` whose own message is the failed SQL, so a bare `instanceof` would drop every failure raised inside `withLockedTable`. That is the same reason `rootErrorMessage` had to dig for a root cause before. Three throws stay bare `Error` deliberately — `Table ID mismatch`, `Workspace ID mismatch`, and `Failed to build upsert conflict predicate` are internal invariants no consumer classified, and they keep falling through to a 500. `Insufficient capacity` was in the pattern list with no producer anywhere in the codebase. Status changes, all deliberate: - `'forbidden'` joins the code union so the table-row-limit ceiling keeps its 403; without it this refactor would have flattened it to 400. - import-async's table-limit rejection: 400 -> 403, matching the two other create routes it had drifted from. - Renaming a table to an invalid name: 500 -> 400. `validateTableName` messages don't contain "Invalid", so no matcher ever caught them. - Restoring a table that isn't archived, or into an archived workspace: 500 -> 400. - A duplicate *column* name stays `validation`/400 rather than becoming a 409 like a duplicate table name. Both v1 and the orchestration have always answered 400 for it; changing a published status is not this refactor's job. The twelve tests that changed were asserting the substring mechanism itself, constructing plain `Error`s with magic strings. They now assert the real contract, plus new cases pinning that identical wording carrying no classification stays internal and keeps its message off the wire. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a --------- Co-authored-by: Claude Opus 5 --- .../api/table/[tableId]/columns/route.test.ts | 13 +- .../app/api/table/[tableId]/columns/route.ts | 222 +------------- .../api/table/[tableId]/columns/run/route.ts | 12 +- .../api/table/[tableId]/import/route.test.ts | 14 +- .../app/api/table/[tableId]/import/route.ts | 43 +-- .../sim/app/api/table/[tableId]/route.test.ts | 20 +- apps/sim/app/api/table/[tableId]/route.ts | 95 +++--- .../api/table/[tableId]/rows/[rowId]/route.ts | 26 +- apps/sim/app/api/table/import-async/route.ts | 10 +- .../app/api/table/import-csv/route.test.ts | 21 +- apps/sim/app/api/table/import-csv/route.ts | 23 +- apps/sim/app/api/table/route.ts | 16 +- apps/sim/app/api/table/utils.test.ts | 24 +- apps/sim/app/api/table/utils.ts | 64 ++-- .../app/api/tools/deployments/deploy/route.ts | 2 +- .../api/tools/deployments/promote/route.ts | 2 +- .../api/v1/tables/[tableId]/columns/route.ts | 264 ++--------------- apps/sim/app/api/v1/tables/[tableId]/route.ts | 24 +- .../v1/tables/[tableId]/rows/[rowId]/route.ts | 44 ++- .../v1/tables/[tableId]/rows/upsert/route.ts | 23 +- apps/sim/app/api/v1/tables/route.ts | 16 +- .../app/api/v1/workflows/[id]/deploy/route.ts | 2 +- .../api/v1/workflows/[id]/rollback/route.ts | 2 +- apps/sim/app/api/v2/lib/response.ts | 34 +++ .../v2/tables/[tableId]/columns/route.test.ts | 105 +++++++ .../api/v2/tables/[tableId]/columns/route.ts | 103 ++----- .../app/api/v2/tables/[tableId]/route.test.ts | 109 +++++++ apps/sim/app/api/v2/tables/[tableId]/route.ts | 24 +- .../[tableId]/rows/[rowId]/route.test.ts | 99 +++++++ .../v2/tables/[tableId]/rows/[rowId]/route.ts | 43 +-- .../v2/tables/[tableId]/rows/upsert/route.ts | 17 +- apps/sim/app/api/v2/tables/route.ts | 15 +- apps/sim/app/api/v2/tables/utils.ts | 11 + .../app/api/workflows/[id]/deploy/route.ts | 2 +- .../[id]/deployments/[version]/route.ts | 2 +- .../lib/copilot/tools/handlers/vfs-mutate.ts | 24 +- .../tools/server/table/user-table.test.ts | 59 +--- .../copilot/tools/server/table/user-table.ts | 194 +++---------- .../orchestration/types.test.ts | 2 +- apps/sim/lib/core/orchestration/types.ts | 74 +++++ apps/sim/lib/folders/config.ts | 2 +- apps/sim/lib/folders/lifecycle.ts | 2 +- apps/sim/lib/folders/status.ts | 2 +- apps/sim/lib/table/billing.ts | 10 +- apps/sim/lib/table/columns/service.ts | 123 +++++--- apps/sim/lib/table/import-data.ts | 14 +- apps/sim/lib/table/import.ts | 19 +- .../lib/table/orchestration/columns.test.ts | 224 ++++++++++++++ apps/sim/lib/table/orchestration/columns.ts | 266 +++++++++++++++++ apps/sim/lib/table/orchestration/index.ts | 73 +---- apps/sim/lib/table/orchestration/restore.ts | 63 ++++ .../lib/table/orchestration/tables.test.ts | 142 +++++++++ apps/sim/lib/table/orchestration/tables.ts | 274 ++++++++++++++++++ apps/sim/lib/table/rows/service.ts | 113 ++++++-- apps/sim/lib/table/select-options.test.ts | 59 ++++ apps/sim/lib/table/select-options.ts | 32 ++ apps/sim/lib/table/service.ts | 159 ++++------ apps/sim/lib/table/workflow-columns.ts | 6 +- .../sim/lib/workflows/orchestration/deploy.ts | 2 +- .../orchestration/folder-lifecycle.ts | 2 +- apps/sim/lib/workflows/orchestration/types.ts | 12 - .../orchestration/workflow-lifecycle.ts | 2 +- 62 files changed, 2170 insertions(+), 1330 deletions(-) create mode 100644 apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts rename apps/sim/lib/{workflows => core}/orchestration/types.test.ts (82%) create mode 100644 apps/sim/lib/core/orchestration/types.ts create mode 100644 apps/sim/lib/table/orchestration/columns.test.ts create mode 100644 apps/sim/lib/table/orchestration/columns.ts create mode 100644 apps/sim/lib/table/orchestration/restore.ts create mode 100644 apps/sim/lib/table/orchestration/tables.test.ts create mode 100644 apps/sim/lib/table/orchestration/tables.ts create mode 100644 apps/sim/lib/table/select-options.test.ts delete mode 100644 apps/sim/lib/workflows/orchestration/types.ts diff --git a/apps/sim/app/api/table/[tableId]/columns/route.test.ts b/apps/sim/app/api/table/[tableId]/columns/route.test.ts index 4ac282861cd..e8497a0fa04 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.test.ts @@ -42,6 +42,13 @@ vi.mock('@/lib/table', () => ({ updateColumnOptions: mockUpdateColumnOptions, updateColumnType: mockUpdateColumnType, })) +vi.mock('@/lib/table/columns/service', () => ({ + renameColumn: mockRenameColumn, + updateColumnConstraints: mockUpdateColumnConstraints, + updateColumnCurrency: mockUpdateColumnCurrency, + updateColumnOptions: mockUpdateColumnOptions, + updateColumnType: mockUpdateColumnType, +})) vi.mock('@/app/api/table/utils', () => ({ accessError: () => new Response('denied', { status: 403 }), checkAccess: mockCheckAccess, @@ -50,6 +57,7 @@ vi.mock('@/app/api/table/utils', () => ({ tableLockErrorResponse: () => null, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { PATCH } from '@/app/api/table/[tableId]/columns/route' const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' @@ -159,7 +167,10 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => { // Stands in for the race the guards cannot close: the column stopped being // a currency between the snapshot the guards read and this write. mockUpdateColumnCurrency.mockRejectedValue( - new Error('Cannot set currency on column "amount" of type "string"') + new OrchestrationError( + 'validation', + 'Cannot set currency on column "amount" of type "string"' + ) ) const response = await patch({ name: 'renamed', currencyCode: 'USD' }) diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index 5cad8c5e610..f55c4a0bc52 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -8,20 +8,11 @@ import { import { parseRequest } from '@/lib/api/server' import { isZodError, validationErrorResponse } from '@/lib/api/server/validation' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - addTableColumn, - deleteColumn, - renameColumn, - updateColumnConstraints, - updateColumnCurrency, - updateColumnOptions, - updateColumnType, -} from '@/lib/table' -import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys' -import { columnTypeById } from '@/lib/table/column-types' -import { isSupportedCurrencyCode } from '@/lib/table/currency' +import { addTableColumn, deleteColumn } from '@/lib/table' +import { performUpdateTableColumn } from '@/lib/table/orchestration' import { accessError, checkAccess, @@ -120,215 +111,32 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const { updates } = validated - let updatedTable = null - - // A payload that repeats the current type must not go through - // `updateColumnType` — it early-returns on an unchanged type and would drop - // any `options` alongside it. Only a real type change routes there; an - // unchanged type with options routes to the options-only update. - const currentColumn = table.schema.columns.find((c) => - columnMatchesRef(c, validated.columnName) - ) - // Address every write below by the stable id, not the name: a rename folded - // into one of them must not break the next one's lookup. - const columnRef = currentColumn ? getColumnId(currentColumn) : validated.columnName - // The constraints write below is a separate, unconditional step, so it is - // the last one whenever it runs — that is the write the rename rides on. - const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type - if (!currentColumn) { - return NextResponse.json( - { error: `Column "${validated.columnName}" not found` }, - { status: 404 } - ) - } - - // A retype applies and validates the constraints itself, so the separate - // constraint write only runs when the type is unchanged. The rename rides - // whichever write actually runs last. - const typedWriteRuns = - typeChanging || - updates.currencyCode !== undefined || - updates.options !== undefined || - updates.multiple !== undefined - const constraintsWriteRuns = - !typedWriteRuns && (updates.required !== undefined || updates.unique !== undefined) - const renameWithTypedWrite = - updates.name && !constraintsWriteRuns ? { newName: updates.name } : {} - - // Every write below is its own locked transaction, so one that is going to - // fail leaves the earlier ones committed. These guards reject the knowable - // cases up front, before any write at all. - // Gate on the type the column ENDS UP with, not on whether the type is - // changing: an options-only update on an existing select column carries the - // same hazard as a conversion does. - const resultingType = updates.type ?? currentColumn?.type - if (updates.currencyCode !== undefined) { - if (resultingType !== 'currency') { - return NextResponse.json( - { - error: `Cannot set currency on column "${validated.columnName}" of type "${resultingType}"`, - }, - { status: 400 } - ) - } - if (!isSupportedCurrencyCode(updates.currencyCode)) { - return NextResponse.json( - { - error: `Invalid currency code "${updates.currencyCode}". Use an ISO 4217 code, e.g. USD`, - }, - { status: 400 } - ) - } - } - // The rename runs last (see below), so a name already taken would fail after - // the typed write committed. This is the only rename failure a caller can - // cause; catching it here leaves just the concurrent-collision race, which - // no pre-flight check can close. - if ( - updates.name && - table.schema.columns.some( - (c) => - c.name.toLowerCase() === updates.name?.toLowerCase() && - !columnMatchesRef(c, validated.columnName) - ) - ) { - return NextResponse.json( - { error: `Column "${updates.name}" already exists` }, - { status: 400 } - ) - } - if ( - currentColumn?.workflowGroupId && - (updates.required !== undefined || updates.unique !== undefined) - ) { - return NextResponse.json( - { - error: `Cannot change constraints on workflow-output column "${currentColumn.name}". Constraints aren't applicable to columns whose values come from workflow execution.`, - }, - { status: 400 } - ) - } - if (updates.unique === true && !columnTypeById(resultingType).supportsUnique) { + const outcome = await performUpdateTableColumn({ + table, + columnName: validated.columnName, + userId: authResult.userId, + updates: validated.updates, + requestId, + request, + }) + if (!outcome.success || !outcome.table) { return NextResponse.json( - { error: `Cannot set a ${resultingType} column as unique` }, - { status: 400 } + { error: outcome.error ?? 'Failed to update column' }, + { status: statusForOrchestrationError(outcome.errorCode) } ) } - if (typeChanging) { - updatedTable = await updateColumnType( - { - tableId, - columnName: columnRef, - newType: updates.type as NonNullable, - ...(updates.options !== undefined ? { options: updates.options } : {}), - ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), - ...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}), - // Forwarded so the conversion validates against the constraint this - // same request is about to set, not the column's current one. - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...renameWithTypedWrite, - }, - requestId - ) - } else if (updates.currencyCode !== undefined) { - // Re-denominating an existing currency column: schema-only, no cell - // rewrite. Reached only when the type is unchanged — a conversion INTO - // currency carries the code through `updateColumnType` above. - updatedTable = await updateColumnCurrency( - { - tableId, - columnName: columnRef, - currencyCode: updates.currencyCode, - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...renameWithTypedWrite, - }, - requestId - ) - } else if (updates.options !== undefined || updates.multiple !== undefined) { - updatedTable = await updateColumnOptions( - { - tableId, - columnName: columnRef, - options: updates.options ?? currentColumn?.options ?? [], - ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), - // Forwarded so the removal guard validates against the constraint this - // same request is about to set, not the column's current one. - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...renameWithTypedWrite, - }, - requestId - ) - } - - // Skipped whenever a typed write ran: that write already applied and - // validated these, in one transaction with the change they accompany. - if (constraintsWriteRuns) { - updatedTable = await updateColumnConstraints( - { - tableId, - columnName: columnRef, - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...(updates.name ? { newName: updates.name } : {}), - }, - requestId - ) - } - - // A rename rides along with the LAST write above, inside that write's - // transaction — a rename is metadata-only (rows key on the stable column - // id), so nothing forces it to be its own write, and folding it in is what - // stops a combined request from committing one half and then failing. Only - // a rename with nothing to ride on runs standalone. - if (updates.name && !updatedTable) { - updatedTable = await renameColumn( - { tableId, oldName: columnRef, newName: updates.name }, - requestId - ) - } - - if (!updatedTable) { - return NextResponse.json({ error: 'No updates specified' }, { status: 400 }) - } - return NextResponse.json({ success: true, data: { - columns: updatedTable.schema.columns.map(normalizeColumn), + columns: outcome.table.schema.columns.map(normalizeColumn), }, }) } catch (error) { - const lockError = tableLockErrorResponse(error) - if (lockError) return lockError if (isZodError(error)) { return validationErrorResponse(error, 'Invalid request data') } - const msg = rootErrorMessage(error) - if (msg.includes('not found') || msg.includes('Table not found')) { - return NextResponse.json({ error: msg }, { status: 404 }) - } - if ( - msg.includes('already exists') || - msg.includes('Cannot delete the last column') || - msg.includes('Cannot set column') || - msg.includes('Cannot set unique column') || - msg.includes('Invalid column') || - msg.includes('exceeds maximum') || - msg.includes('incompatible') || - msg.includes('duplicate') || - msg.includes('option') || - msg.includes('currency') || - msg.includes('is already type') - ) { - return NextResponse.json({ error: msg }, { status: 400 }) - } - logger.error(`[${requestId}] Error updating column in table ${tableId}:`, error) return NextResponse.json({ error: 'Failed to update column' }, { status: 500 }) } diff --git a/apps/sim/app/api/table/[tableId]/columns/run/route.ts b/apps/sim/app/api/table/[tableId]/columns/run/route.ts index 824ce73ddb4..7a047120f75 100644 --- a/apps/sim/app/api/table/[tableId]/columns/run/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/run/route.ts @@ -8,7 +8,12 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { TableQueryValidationError } from '@/lib/table/errors' import { toLegacyFilter } from '@/lib/table/query-builder/converters' import { runWorkflowColumn } from '@/lib/table/workflow-columns' -import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + orchestrationErrorResponse, + tableFilterError, +} from '@/app/api/table/utils' const logger = createLogger('TableRunColumnAPI') @@ -66,9 +71,8 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro if (error instanceof TableQueryValidationError) { return NextResponse.json({ error: error.message }, { status: 400 }) } - if (error instanceof Error && error.message === 'Invalid workspace ID') { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`run-column failed:`, error) return NextResponse.json({ error: 'Failed to run columns' }, { status: 500 }) } diff --git a/apps/sim/app/api/table/[tableId]/import/route.test.ts b/apps/sim/app/api/table/[tableId]/import/route.test.ts index baf8c313a4f..a2689295725 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.test.ts @@ -79,6 +79,7 @@ vi.mock('@/lib/table/billing', () => ({ limit >= 0 && current + added > limit, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { TableLockedError } from '@/lib/table/mutation-locks' import { POST } from '@/app/api/table/[tableId]/import/route' @@ -372,7 +373,10 @@ describe('POST /api/table/[tableId]/import', () => { it('surfaces unique violations from importAppendRows as 400', async () => { mockImportAppendRows.mockRejectedValueOnce( - new Error('Row 1: Column "name" must be unique. Value "Alice" already exists in row row_xxx') + new OrchestrationError( + 'validation', + 'Row 1: Column "name" must be unique. Value "Alice" already exists in row row_xxx' + ) ) const response = await callPost( createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' }) @@ -516,7 +520,9 @@ describe('POST /api/table/[tableId]/import', () => { }) it('surfaces column-creation failures from importAppendRows as 400', async () => { - mockImportAppendRows.mockRejectedValueOnce(new Error('Column "email" already exists')) + mockImportAppendRows.mockRejectedValueOnce( + new OrchestrationError('validation', 'Column "email" already exists') + ) const response = await callPost( createFormData(createCsvFile('name,age,email\nAlice,30,a@x.io'), { mode: 'append', @@ -529,7 +535,9 @@ describe('POST /api/table/[tableId]/import', () => { }) it('surfaces row insert failures without success when schema was mutated', async () => { - mockImportAppendRows.mockRejectedValueOnce(new Error('must be unique')) + mockImportAppendRows.mockRejectedValueOnce( + new OrchestrationError('validation', 'must be unique') + ) const response = await callPost( createFormData(createCsvFile('name,age,email\nAlice,30,a@x.io'), { mode: 'append', diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 7a9802442cc..0b6cf78a319 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -14,6 +14,7 @@ import { import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -349,21 +350,13 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro createdColumns: additions.length, error: message, }) - const isClientError = - message.includes('row limit') || - message.includes('Insufficient capacity') || - message.includes('Schema validation') || - message.includes('must be unique') || - message.includes('Row size exceeds') || - message.includes('already exists') || - message.includes('Invalid column name') || - /^Row \d+:/.test(message) + const classified = asOrchestrationError(err) return NextResponse.json( { - error: isClientError ? message : 'Failed to import CSV', + error: classified ? classified.message : 'Failed to import CSV', data: { insertedCount: 0 }, }, - { status: isClientError ? 400 : 500 } + { status: classified ? statusForOrchestrationError(classified.code) : 500 } ) } } @@ -400,17 +393,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro }, }) } catch (err) { - const message = toError(err).message - const isClientError = - message.includes('row limit') || - message.includes('Schema validation') || - message.includes('must be unique') || - message.includes('Row size exceeds') || - message.includes('already exists') || - message.includes('Invalid column name') || - /^Row \d+:/.test(message) - if (isClientError) { - return NextResponse.json({ error: message }, { status: 400 }) + const classified = asOrchestrationError(err) + if (classified) { + return NextResponse.json( + { error: classified.message }, + { status: statusForOrchestrationError(classified.code) } + ) } throw err } @@ -419,17 +407,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro if (lockError) return lockError if (isMultipartError(error)) return multipartErrorResponse(error) - const message = toError(error).message logger.error(`[${requestId}] CSV import into existing table failed:`, error) - const isClientError = - message.includes('CSV file has no') || - message.includes('already exists') || - message.includes('Invalid column name') - + const classified = asOrchestrationError(error) return NextResponse.json( - { error: isClientError ? message : 'Failed to import CSV' }, - { status: isClientError ? 400 : 500 } + { error: classified ? classified.message : 'Failed to import CSV' }, + { status: classified ? statusForOrchestrationError(classified.code) : 500 } ) } finally { fileStream?.destroy() diff --git a/apps/sim/app/api/table/[tableId]/route.test.ts b/apps/sim/app/api/table/[tableId]/route.test.ts index 7f1f48243c7..2396ba13a21 100644 --- a/apps/sim/app/api/table/[tableId]/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/route.test.ts @@ -33,6 +33,13 @@ vi.mock('@/lib/table', () => ({ updateTableLocks: mockUpdateTableLocks, TableConflictError: class extends Error {}, })) +vi.mock('@/lib/table/service', () => ({ + deleteTable: mockDeleteTable, + getTableById: mockGetTableById, + moveTableToFolder: mockMoveTableToFolder, + renameTable: mockRenameTable, + updateTableLocks: mockUpdateTableLocks, +})) vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetLimits })) vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder })) vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: vi.fn() })) @@ -77,6 +84,13 @@ const routeContext = { params: Promise.resolve({ tableId: 'tbl_1' }) } describe('PATCH /api/table/[tableId] folder moves', () => { beforeEach(() => { vi.clearAllMocks() + mockMoveTableToFolder.mockResolvedValue({ name: 'Table' }) + mockRenameTable.mockResolvedValue({ id: 'tbl_1', name: 'Table' }) + mockDeleteTable.mockResolvedValue({ archived: { name: 'Table', workspaceId: 'workspace-1' } }) + mockUpdateTableLocks.mockResolvedValue({ + table: { ...TABLE, locks: {} }, + previousLocks: {}, + }) hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'user-1', @@ -99,8 +113,7 @@ describe('PATCH /api/table/[tableId] folder moves', () => { 'tbl_1', 'workspace-1', 'folder-1', - expect.any(String), - 'user-1' + expect.any(String) ) }) @@ -118,8 +131,7 @@ describe('PATCH /api/table/[tableId] folder moves', () => { 'tbl_1', 'workspace-1', null, - expect.any(String), - 'user-1' + expect.any(String) ) }) diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 03eaa4c7243..7d75286cf83 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -5,20 +5,18 @@ import { getTableQuerySchema, updateTableContract } from '@/lib/api/contracts/ta import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/server/validation' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { findActiveFolder } from '@/lib/folders/queries' -import { captureServerEvent } from '@/lib/posthog/server' -import { - deleteTable, - getTableById, - moveTableToFolder, - renameTable, - TableConflictError, - type TableSchema, - updateTableLocks, -} from '@/lib/table' +import { getTableById, TableConflictError, type TableSchema } from '@/lib/table' import { getWorkspaceTableLimits } from '@/lib/table/billing' +import { + performDeleteTable, + performMoveTableToFolder, + performRenameTable, + performUpdateTableLocks, +} from '@/lib/table/orchestration' import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS } from '@/lib/table/types' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { @@ -180,11 +178,35 @@ export const PATCH = withRouteHandler( { status: 403 } ) } - await updateTableLocks(tableId, validated.locks, authResult.userId, requestId, request) + const lockOutcome = await performUpdateTableLocks({ + tableId, + partial: validated.locks, + userId: authResult.userId, + requestId, + request, + }) + if (!lockOutcome.success) { + return NextResponse.json( + { error: lockOutcome.error ?? 'Failed to update table locks' }, + { status: statusForOrchestrationError(lockOutcome.errorCode) } + ) + } } if (validated.name !== undefined) { - await renameTable(tableId, validated.name, requestId, authResult.userId) + const renameOutcome = await performRenameTable({ + table, + newName: validated.name, + userId: authResult.userId, + requestId, + request, + }) + if (!renameOutcome.success) { + return NextResponse.json( + { error: renameOutcome.error ?? 'Failed to rename table' }, + { status: statusForOrchestrationError(renameOutcome.errorCode) } + ) + } } if (validated.folderId !== undefined) { @@ -196,21 +218,22 @@ export const PATCH = withRouteHandler( ) { return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 }) } - try { - await moveTableToFolder( - tableId, - table.workspaceId, - validated.folderId, - requestId, - authResult.userId + // The move re-asserts workspace and active state, so a miss means the table was + // archived between `checkAccess` and the write. That is a 404, not a server fault. + const moveOutcome = await performMoveTableToFolder({ + table, + folderId: validated.folderId, + userId: authResult.userId, + requestId, + request, + }) + if (!moveOutcome.success) { + return NextResponse.json( + { + error: moveOutcome.errorCode === 'not_found' ? 'Table not found' : moveOutcome.error, + }, + { status: statusForOrchestrationError(moveOutcome.errorCode) } ) - } catch (moveError) { - // The move re-asserts workspace and active state, so a miss means the table was - // archived between `checkAccess` and the write. That is a 404, not a server fault. - if (moveError instanceof Error && moveError.message.endsWith('not found')) { - return NextResponse.json({ error: 'Table not found' }, { status: 404 }) - } - throw moveError } } @@ -268,14 +291,18 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - await deleteTable(tableId, requestId, authResult.userId) - - captureServerEvent( - authResult.userId, - 'table_deleted', - { table_id: tableId, workspace_id: table.workspaceId }, - { groups: { workspace: table.workspaceId } } - ) + const outcome = await performDeleteTable({ + table, + userId: authResult.userId, + requestId, + request, + }) + if (!outcome.success) { + return NextResponse.json( + { error: outcome.error ?? 'Failed to delete table' }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts index 82ec048ca84..6f4636d9aa1 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -1,7 +1,6 @@ import { db } from '@sim/db' import { userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { @@ -11,15 +10,17 @@ import { } from '@/lib/api/contracts/tables' import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/server/validation' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData, TableSchema } from '@/lib/table' -import { deleteRow, updateRow } from '@/lib/table' +import { updateRow } from '@/lib/table' +import { performDeleteTableRow } from '@/lib/table/orchestration' import { rowWireTranslators } from '@/app/api/table/row-wire' import { accessError, checkAccess, - rootErrorMessage, + orchestrationErrorResponse, rowWriteErrorResponse, tableLockErrorResponse, } from '@/app/api/table/utils' @@ -173,10 +174,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR }, }) } catch (error) { - if (rootErrorMessage(error) === 'Row not found') { - return NextResponse.json({ error: 'Row not found' }, { status: 404 }) - } - const response = rowWriteErrorResponse(error) if (response) return response @@ -212,7 +209,13 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - await deleteRow(table, rowId, requestId) + const outcome = await performDeleteTableRow({ table, rowId, requestId }) + if (!outcome.success) { + return NextResponse.json( + { error: outcome.error ?? 'Failed to delete row' }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, @@ -225,11 +228,8 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row const lockError = tableLockErrorResponse(error) if (lockError) return lockError - const errorMessage = toError(error).message - - if (errorMessage === 'Row not found') { - return NextResponse.json({ error: errorMessage }, { status: 404 }) - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error deleting row:`, error) return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 }) diff --git a/apps/sim/app/api/table/import-async/route.ts b/apps/sim/app/api/table/import-async/route.ts index 57d879c1f32..04039178db7 100644 --- a/apps/sim/app/api/table/import-async/route.ts +++ b/apps/sim/app/api/table/import-async/route.ts @@ -18,11 +18,11 @@ import { releaseJobClaim, sanitizeName, TABLE_LIMITS, - TableConflictError, } from '@/lib/table' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { orchestrationErrorResponse } from '@/app/api/table/utils' const logger = createLogger('TableImportAsync') @@ -101,12 +101,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { requestId ) } catch (error) { - if (error instanceof TableConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } - if (error instanceof Error && error.message.includes('maximum table limit')) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified throw error } diff --git a/apps/sim/app/api/table/import-csv/route.test.ts b/apps/sim/app/api/table/import-csv/route.test.ts index b85e1ccb01b..a9722924755 100644 --- a/apps/sim/app/api/table/import-csv/route.test.ts +++ b/apps/sim/app/api/table/import-csv/route.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node */ import { hybridAuthMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' -import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -32,6 +31,9 @@ vi.mock('@/lib/table/rows/service', () => ({ vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetLimits })) vi.mock('@/app/api/table/utils', async () => { const { NextResponse } = await import('next/server') + const { asOrchestrationError, statusForOrchestrationError } = await import( + '@/lib/core/orchestration/types' + ) return { normalizeColumn: (column: unknown) => column, csvProxyBodyCapResponse: () => null, @@ -40,16 +42,20 @@ vi.mock('@/app/api/table/utils', async () => { { error: error.message }, { status: error.code === 'FILE_TOO_LARGE' ? 413 : 400 } ), - rowWriteErrorResponse: (error: unknown) => { - const message = getErrorMessage(error) - return message.includes('row limit') - ? NextResponse.json({ error: message }, { status: 400 }) + orchestrationErrorResponse: (error: unknown) => { + const classified = asOrchestrationError(error) + return classified + ? NextResponse.json( + { error: classified.message }, + { status: statusForOrchestrationError(classified.code) } + ) : null }, } }) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { POST } from '@/app/api/table/import-csv/route' type Part = @@ -184,7 +190,10 @@ describe('POST /api/table/import-csv', () => { it('returns 400 with the reason when an insert exceeds the plan row limit', async () => { mockBatchInsertRows.mockRejectedValueOnce( - new Error('This table has reached its row limit (1,000 rows) on your current plan.') + new OrchestrationError( + 'validation', + 'This table has reached its row limit (1,000 rows) on your current plan.' + ) ) const response = await POST(makeRequest(uploadParts(csvWithRows(250)))) const data = await response.json() diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index 9ca0381fe90..f84f457e820 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -1,6 +1,5 @@ import type { Readable } from 'node:stream' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { csvExtensionSchema, csvImportFormSchema } from '@/lib/api/contracts/tables' @@ -34,7 +33,7 @@ import { csvProxyBodyCapResponse, multipartErrorResponse, normalizeColumn, - rowWriteErrorResponse, + orchestrationErrorResponse, } from '@/app/api/table/utils' const logger = createLogger('TableImportCSV') @@ -250,22 +249,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.error(`[${requestId}] CSV import failed:`, error) - // Row-write failures (e.g. the plan row-limit check) map to a 400 with the real reason. - const rowWriteError = rowWriteErrorResponse(error) - if (rowWriteError) return rowWriteError + // Every caller-fixable failure on this path — the plan row-limit check, the + // schema and CSV-shape validation, a name collision — arrives classified. + const classified = orchestrationErrorResponse(error) + if (classified) return classified - const message = toError(error).message - const isClientError = - message.includes('maximum table limit') || - message.includes('CSV file has no') || - message.includes('Invalid table name') || - message.includes('Invalid schema') || - message.includes('already exists') - - return NextResponse.json( - { error: isClientError ? message : 'Failed to import CSV' }, - { status: isClientError ? 400 : 500 } - ) + return NextResponse.json({ error: 'Failed to import CSV' }, { status: 500 }) } finally { fileStream?.destroy() } diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index 2522cddb7c6..28714885cb5 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -16,7 +16,7 @@ import { type TableScope, } from '@/lib/table' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { normalizeColumn } from '@/app/api/table/utils' +import { normalizeColumn, orchestrationErrorResponse } from '@/app/api/table/utils' const logger = createLogger('TableAPI') @@ -153,18 +153,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { - if (error instanceof Error) { - if (error.message.includes('maximum table limit')) { - return NextResponse.json({ error: error.message }, { status: 403 }) - } - if ( - error.message.includes('Invalid table name') || - error.message.includes('Invalid schema') || - error.message.includes('already exists') - ) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error creating table:`, error) return NextResponse.json({ error: 'Failed to create table' }, { status: 500 }) diff --git a/apps/sim/app/api/table/utils.test.ts b/apps/sim/app/api/table/utils.test.ts index 99d0ce0c5a5..fa7b57ac6dd 100644 --- a/apps/sim/app/api/table/utils.test.ts +++ b/apps/sim/app/api/table/utils.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { TableRowLimitError } from '@/lib/table/billing' import type { ColumnDefinition } from '@/lib/table/types' import { rootErrorMessage, rowWriteErrorResponse, tableFilterError } from '@/app/api/table/utils' @@ -38,15 +39,30 @@ describe('rowWriteErrorResponse', () => { ) }) - it('passes known validation messages through as 400', async () => { - const response = rowWriteErrorResponse(new Error('Value for column "email" must be unique')) + it('passes a classified validation failure through as 400', async () => { + const response = rowWriteErrorResponse( + new OrchestrationError('validation', 'Value for column "email" must be unique') + ) expect(response?.status).toBe(400) const body = await response?.json() expect(body.error).toBe('Value for column "email" must be unique') }) - it('matches per-row batch validation messages', () => { - expect(rowWriteErrorResponse(new Error('Row 3: name is required'))?.status).toBe(400) + it('answers the code the failure carries, not one derived from its wording', () => { + expect( + rowWriteErrorResponse(new OrchestrationError('not_found', 'Row not found'))?.status + ).toBe(404) + // The phrase that used to force a 400 no longer decides anything. + expect( + rowWriteErrorResponse(new OrchestrationError('conflict', 'Row 3: must be unique'))?.status + ).toBe(409) + }) + + it('unwraps a classified failure drizzle wrapped in a query error', () => { + expect( + rowWriteErrorResponse(wrapLikeDrizzle(new OrchestrationError('validation', 'Row 3: bad'))) + ?.status + ).toBe(400) }) it('returns null for unknown errors so callers keep their generic 500', () => { diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index ceb399556c4..805d2b16206 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -8,6 +8,7 @@ import { updateTableColumnBodySchema, } from '@/lib/api/contracts/tables' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import type { MultipartError } from '@/lib/core/utils/multipart' import type { ColumnDefinition, Filter, TableDefinition, TablePredicate } from '@/lib/table' import { buildFilterClause, getTableById, TableQueryValidationError } from '@/lib/table' @@ -42,8 +43,9 @@ export async function tablesV2GateError( * Maps a {@link TableLockedError} thrown by the service layer to a 423 response * carrying `{ error, lock }`; returns `null` for any other error so the caller * falls through to its existing handling. Call this as the FIRST statement of a - * table route's catch block — otherwise `rowWriteErrorResponse` (and the other - * substring funnels) turn the lock error into a generic 500. + * table route's catch block — `TableLockedError` is an `HttpError`, not an + * `OrchestrationError`, so nothing else classifies it and it would otherwise + * reach the route's generic 500. * * The body deliberately omits a `details` array: the client's `isValidationError` * treats any `ApiClientError` with array-valued `details` as a field-validation @@ -106,48 +108,36 @@ export function rootErrorMessage(error: unknown): string { } /** - * Known user-facing row-write failures (service validation + the best-effort - * plan row-limit check). Anything outside this list stays a generic 500 — - * unknown errors can carry SQL/internals that don't belong in a toast. - */ -const ROW_WRITE_ERROR_PATTERNS = [ - 'row limit', - 'Insufficient capacity', - 'Schema validation', - 'must be unique', - 'must be valid', - 'must be string', - 'must be number', - 'must be boolean', - 'unique column', - 'Unique constraint violation', - 'Row size exceeds', - 'conflictTarget', - 'Upsert requires', - 'Rows not found', - 'Filter is required', -] as const - -/** - * Maps a known user-facing row-write failure to a 400 carrying the real message - * (so client toasts can show the actual reason); `null` when the error is - * unrecognized and the caller should log it and return its generic 500. + * Maps a classified domain failure to its status, carrying the real message so + * client toasts can show the actual reason; `null` when the error carries no + * classification and the caller should log it and return its own generic 500 — + * an unrecognized error can hold SQL/internals that don't belong in a toast. + * + * This is the whole classification story for the UI and v1 table routes. It + * replaced per-route lists of message substrings, which decided a status by + * searching prose and so silently changed one whenever a message was reworded. */ -export function rowWriteErrorResponse(error: unknown): NextResponse | null { - // A lock violation is a 423, not a 400/500 — check before the pattern match, - // which would otherwise let it fall through to the caller's generic 500. +export function orchestrationErrorResponse(error: unknown): NextResponse | null { + // A lock violation is a 423, and `TableLockedError` is an `HttpError` rather + // than an `OrchestrationError`, so it needs its own check first. const lockResponse = tableLockErrorResponse(error) if (lockResponse) return lockResponse - const message = rootErrorMessage(error) - - if (ROW_WRITE_ERROR_PATTERNS.some((p) => message.includes(p)) || /^Row .+?:/.test(message)) { - return NextResponse.json({ error: message }, { status: 400 }) - } + const classified = asOrchestrationError(error) + if (!classified) return null - return null + return NextResponse.json( + { error: classified.message }, + { status: statusForOrchestrationError(classified.code) } + ) } +/** + * {@link orchestrationErrorResponse} under the name the row-write routes call + * it by. Row writes have no classification rules of their own any more. + */ +export const rowWriteErrorResponse = orchestrationErrorResponse + /** * Next.js buffers the request body for the proxy and silently truncates it past this * size (`experimental.proxyClientMaxBodySize`, default 10MB). The synchronous CSV diff --git a/apps/sim/app/api/tools/deployments/deploy/route.ts b/apps/sim/app/api/tools/deployments/deploy/route.ts index 5f35c91da86..0795475b549 100644 --- a/apps/sim/app/api/tools/deployments/deploy/route.ts +++ b/apps/sim/app/api/tools/deployments/deploy/route.ts @@ -3,10 +3,10 @@ import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/ import { type NextRequest, NextResponse } from 'next/server' import { deploymentsDeployContract } from '@/lib/api/contracts/tools/deployments' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performFullDeploy } from '@/lib/workflows/orchestration' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { authenticateDeploymentToolRequest, authorizeDeploymentWorkflow, diff --git a/apps/sim/app/api/tools/deployments/promote/route.ts b/apps/sim/app/api/tools/deployments/promote/route.ts index a126c3dbd57..523a5630a32 100644 --- a/apps/sim/app/api/tools/deployments/promote/route.ts +++ b/apps/sim/app/api/tools/deployments/promote/route.ts @@ -3,10 +3,10 @@ import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/ import { type NextRequest, NextResponse } from 'next/server' import { deploymentsPromoteContract } from '@/lib/api/contracts/tools/deployments' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performActivateVersion } from '@/lib/workflows/orchestration' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { authenticateDeploymentToolRequest, authorizeDeploymentWorkflow, diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index d1a507fa195..78dca2d367f 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -7,24 +7,16 @@ import { v1UpdateTableColumnContract, } from '@/lib/api/contracts/v1/tables' import { parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - addTableColumn, - deleteColumn, - renameColumn, - updateColumnConstraints, - updateColumnCurrency, - updateColumnOptions, - updateColumnType, -} from '@/lib/table' -import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys' -import { columnTypeById } from '@/lib/table/column-types' -import { isSupportedCurrencyCode } from '@/lib/table/currency' +import { addTableColumn, deleteColumn } from '@/lib/table' +import { performUpdateTableColumn } from '@/lib/table/orchestration' import { accessError, checkAccess, normalizeColumn, + orchestrationErrorResponse, tableLockErrorResponse, } from '@/app/api/table/utils' import { @@ -101,22 +93,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - if (error instanceof Error) { - // Same caller-error set the internal columns route maps — an invalid - // select option set is a bad request, not a server fault. - if ( - error.message.includes('already exists') || - error.message.includes('maximum column') || - error.message.includes('Invalid column') || - error.message.includes('exceeds maximum') || - error.message.includes('option') - ) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - if (error.message === 'Table not found') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error adding column to table:`, error) return NextResponse.json({ error: 'Failed to add column' }, { status: 500 }) @@ -154,227 +132,31 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const { updates } = validated - let updatedTable = null - - // A payload that repeats the current type must not go through - // `updateColumnType` — it early-returns on an unchanged type and would drop - // any `options` alongside it. Only a real type change routes there; an - // unchanged type with options routes to the options-only update. - const currentColumn = table.schema.columns.find((c) => - columnMatchesRef(c, validated.columnName) - ) - // Address every write below by the stable id, not the name: a rename folded - // into one of them must not break the next one's lookup. - const columnRef = currentColumn ? getColumnId(currentColumn) : validated.columnName - // The constraints write below is a separate, unconditional step, so it is - // the last one whenever it runs — that is the write the rename rides on. - const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type - if (!currentColumn) { - return NextResponse.json( - { error: `Column "${validated.columnName}" not found` }, - { status: 404 } - ) - } - - // A retype applies and validates the constraints itself, so the separate - // constraint write only runs when the type is unchanged. The rename rides - // whichever write actually runs last. - const typedWriteRuns = - typeChanging || - updates.currencyCode !== undefined || - updates.options !== undefined || - updates.multiple !== undefined - const constraintsWriteRuns = - !typedWriteRuns && (updates.required !== undefined || updates.unique !== undefined) - const renameWithTypedWrite = - updates.name && !constraintsWriteRuns ? { newName: updates.name } : {} - - // Every write below is its own locked transaction, so one that is going to - // fail leaves the earlier ones committed. These guards reject the knowable - // cases up front, before any write at all. - // Gate on the type the column ENDS UP with, not on whether the type is - // changing: an options-only update on an existing select column carries the - // same hazard as a conversion does. - const resultingType = updates.type ?? currentColumn?.type - if (updates.currencyCode !== undefined) { - if (resultingType !== 'currency') { - return NextResponse.json( - { - error: `Cannot set currency on column "${validated.columnName}" of type "${resultingType}"`, - }, - { status: 400 } - ) - } - if (!isSupportedCurrencyCode(updates.currencyCode)) { - return NextResponse.json( - { - error: `Invalid currency code "${updates.currencyCode}". Use an ISO 4217 code, e.g. USD`, - }, - { status: 400 } - ) - } - } - // The rename runs last (see below), so a name already taken would fail after - // the typed write committed. This is the only rename failure a caller can - // cause; catching it here leaves just the concurrent-collision race, which - // no pre-flight check can close. - if ( - updates.name && - table.schema.columns.some( - (c) => - c.name.toLowerCase() === updates.name?.toLowerCase() && - !columnMatchesRef(c, validated.columnName) - ) - ) { - return NextResponse.json( - { error: `Column "${updates.name}" already exists` }, - { status: 400 } - ) - } - if ( - currentColumn?.workflowGroupId && - (updates.required !== undefined || updates.unique !== undefined) - ) { - return NextResponse.json( - { - error: `Cannot change constraints on workflow-output column "${currentColumn.name}". Constraints aren't applicable to columns whose values come from workflow execution.`, - }, - { status: 400 } - ) - } - if (updates.unique === true && !columnTypeById(resultingType).supportsUnique) { + const outcome = await performUpdateTableColumn({ + table, + columnName: validated.columnName, + userId, + updates: validated.updates, + requestId, + request, + }) + if (!outcome.success || !outcome.table) { return NextResponse.json( - { error: `Cannot set a ${resultingType} column as unique` }, - { status: 400 } + { error: outcome.error ?? 'Failed to update column' }, + { status: statusForOrchestrationError(outcome.errorCode) } ) } - if (typeChanging) { - updatedTable = await updateColumnType( - { - tableId, - columnName: columnRef, - newType: updates.type as NonNullable, - ...(updates.options !== undefined ? { options: updates.options } : {}), - ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), - ...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}), - // Forwarded so the conversion validates against the constraint this - // same request is about to set, not the column's current one. - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...renameWithTypedWrite, - }, - requestId - ) - } else if (updates.currencyCode !== undefined) { - // Re-denominating an existing currency column: schema-only, no cell - // rewrite. Reached only when the type is unchanged — a conversion INTO - // currency carries the code through `updateColumnType` above. - updatedTable = await updateColumnCurrency( - { - tableId, - columnName: columnRef, - currencyCode: updates.currencyCode, - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...renameWithTypedWrite, - }, - requestId - ) - } else if (updates.options !== undefined || updates.multiple !== undefined) { - updatedTable = await updateColumnOptions( - { - tableId, - columnName: columnRef, - options: updates.options ?? currentColumn?.options ?? [], - ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), - // Forwarded so the removal guard validates against the constraint this - // same request is about to set, not the column's current one. - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...renameWithTypedWrite, - }, - requestId - ) - } - - // Skipped whenever a typed write ran: that write already applied and - // validated these, in one transaction with the change they accompany. - if (constraintsWriteRuns) { - updatedTable = await updateColumnConstraints( - { - tableId, - columnName: columnRef, - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - ...(updates.name ? { newName: updates.name } : {}), - }, - requestId - ) - } - - // A rename rides along with the LAST write above, inside that write's - // transaction — a rename is metadata-only (rows key on the stable column - // id), so nothing forces it to be its own write, and folding it in is what - // stops a combined request from committing one half and then failing. Only - // a rename with nothing to ride on runs standalone. - if (updates.name && !updatedTable) { - updatedTable = await renameColumn( - { tableId, oldName: columnRef, newName: updates.name }, - requestId - ) - } - - if (!updatedTable) { - return NextResponse.json({ error: 'No updates specified' }, { status: 400 }) - } - - recordAudit({ - workspaceId: validated.workspaceId, - actorId: userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Updated column "${validated.columnName}" in table "${table.name}"`, - metadata: { columnName: validated.columnName, updates }, - request, - }) - return NextResponse.json({ success: true, data: { - columns: updatedTable.schema.columns.map(normalizeColumn), + columns: outcome.table.schema.columns.map(normalizeColumn), }, }) } catch (error) { - const lockError = tableLockErrorResponse(error) - if (lockError) return lockError const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - if (error instanceof Error) { - const msg = error.message - if (msg.includes('not found') || msg.includes('Table not found')) { - return NextResponse.json({ error: msg }, { status: 404 }) - } - if ( - msg.includes('already exists') || - msg.includes('Cannot delete the last column') || - msg.includes('Cannot set column') || - msg.includes('Invalid column') || - msg.includes('exceeds maximum') || - msg.includes('incompatible') || - msg.includes('duplicate') || - msg.includes('option') || - msg.includes('currency') || - msg.includes('is already type') - ) { - return NextResponse.json({ error: msg }, { status: 400 }) - } - } - logger.error(`[${requestId}] Error updating column in table:`, error) return NextResponse.json({ error: 'Failed to update column' }, { status: 500 }) } @@ -441,14 +223,8 @@ export const DELETE = withRouteHandler( const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - if (error instanceof Error) { - if (error.message.includes('not found') || error.message === 'Table not found') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - if (error.message.includes('Cannot delete') || error.message.includes('last column')) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error deleting column from table:`, error) return NextResponse.json({ error: 'Failed to delete column' }, { status: 500 }) diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts index c06492d02b7..149bc674651 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts @@ -1,11 +1,12 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { v1DeleteTableContract, v1GetTableContract } from '@/lib/api/contracts/v1/tables' import { parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteTable, type TableSchema } from '@/lib/table' +import type { TableSchema } from '@/lib/table' +import { performDeleteTable } from '@/lib/table/orchestration' import { accessError, checkAccess, @@ -139,18 +140,13 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - await deleteTable(tableId, requestId) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.TABLE_DELETED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: result.table.name, - description: `Archived table "${result.table.name}"`, - request, - }) + const outcome = await performDeleteTable({ table: result.table, userId, requestId, request }) + if (!outcome.success) { + return NextResponse.json( + { error: outcome.error ?? 'Failed to delete table' }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index 5fee4f3d03b..dbe768ee830 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -1,7 +1,6 @@ import { db } from '@sim/db' import { userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { @@ -10,13 +9,20 @@ import { v1UpdateTableRowContract, } from '@/lib/api/contracts/v1/tables' import { parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData, TableSchema } from '@/lib/table' -import { deleteRow, updateRow } from '@/lib/table' +import { updateRow } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' -import { accessError, checkAccess, tableLockErrorResponse } from '@/app/api/table/utils' +import { performDeleteTableRow } from '@/lib/table/orchestration' +import { + accessError, + checkAccess, + orchestrationErrorResponse, + tableLockErrorResponse, +} from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -188,21 +194,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - const errorMessage = toError(error).message - - if (errorMessage === 'Row not found') { - return NextResponse.json({ error: errorMessage }, { status: 404 }) - } - - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error updating row:`, error) return NextResponse.json({ error: 'Failed to update row' }, { status: 500 }) @@ -238,9 +231,13 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - // Route through the service (not a raw `db.delete`) so the delete lock is - // enforced — the raw path would return 200 on a locked table. - await deleteRow(result.table, rowId, requestId) + const outcome = await performDeleteTableRow({ table: result.table, rowId, requestId }) + if (!outcome.success) { + return NextResponse.json( + { error: outcome.error ?? 'Failed to delete row' }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, @@ -252,9 +249,8 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row } catch (error) { const lockError = tableLockErrorResponse(error) if (lockError) return lockError - if (error instanceof Error && error.message === 'Row not found') { - return NextResponse.json({ error: 'Row not found' }, { status: 404 }) - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error deleting row:`, error) return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 }) } diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts index bf4a00df91b..a32f17a9c8e 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { v1UpsertTableRowContract } from '@/lib/api/contracts/v1/tables' import { parseRequest } from '@/lib/api/server' @@ -9,7 +8,12 @@ import type { RowData, TableSchema } from '@/lib/table' import { upsertRow } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' -import { accessError, checkAccess, tableLockErrorResponse } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + orchestrationErrorResponse, + tableLockErrorResponse, +} from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -101,19 +105,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - const errorMessage = toError(error).message - - if ( - errorMessage.includes('unique column') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('conflictTarget') || - errorMessage.includes('row limit') || - errorMessage.includes('Schema validation') || - errorMessage.includes('Upsert requires') || - errorMessage.includes('Row size exceeds') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error upserting row:`, error) return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 }) diff --git a/apps/sim/app/api/v1/tables/route.ts b/apps/sim/app/api/v1/tables/route.ts index 82bc6618247..6213fd59053 100644 --- a/apps/sim/app/api/v1/tables/route.ts +++ b/apps/sim/app/api/v1/tables/route.ts @@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createTable, getWorkspaceTableLimits, listTables, type TableSchema } from '@/lib/table' -import { normalizeColumn } from '@/app/api/table/utils' +import { normalizeColumn, orchestrationErrorResponse } from '@/app/api/table/utils' import { checkRateLimit, createRateLimitResponse, @@ -171,18 +171,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const validationResponse = v1ValidationErrorResponseFromError(error) if (validationResponse) return validationResponse - if (error instanceof Error) { - if (error.message.includes('maximum table limit')) { - return NextResponse.json({ error: error.message }, { status: 403 }) - } - if ( - error.message.includes('Invalid table name') || - error.message.includes('Invalid schema') || - error.message.includes('already exists') - ) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - } + const classified = orchestrationErrorResponse(error) + if (classified) return classified logger.error(`[${requestId}] Error creating table:`, error) return NextResponse.json({ error: 'Failed to create table' }, { status: 500 }) diff --git a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts index 7068239e134..304d69f63d8 100644 --- a/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/deploy/route.ts @@ -8,11 +8,11 @@ import { v1UndeployWorkflowContract, } from '@/lib/api/contracts/v1/workflows' import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, diff --git a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts index a0779babf51..d015ba4b024 100644 --- a/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/rollback/route.ts @@ -7,10 +7,10 @@ import { v1RollbackWorkflowContract, } from '@/lib/api/contracts/v1/workflows' import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performActivateVersion } from '@/lib/workflows/orchestration' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index bd326d1218d..45ed1e6fcb3 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server' import type { ZodError } from 'zod' import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' +import { asOrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' import type { RateLimitResult, WorkspaceAccessError } from '@/app/api/v1/middleware' /** @@ -155,3 +156,36 @@ export function decodeCursor>(cursor: string): T | n return null } } + +const V2_CODE_BY_ORCHESTRATION_ERROR: Record = { + validation: 'BAD_REQUEST', + forbidden: 'FORBIDDEN', + not_found: 'NOT_FOUND', + conflict: 'CONFLICT', + locked: 'LOCKED', + internal: 'INTERNAL_ERROR', +} + +/** + * Renders a `lib/[resource]/orchestration` failure in the v2 envelope, so every + * v2 route maps a given failure class to the same status without restating the + * mapping. Mirrors `statusForOrchestrationError` for the v1/UI surfaces. + */ +export function v2ErrorForOrchestration( + code: OrchestrationErrorCode | undefined, + message: string +): NextResponse { + const v2Code = code ? V2_CODE_BY_ORCHESTRATION_ERROR[code] : 'INTERNAL_ERROR' + return v2Error(v2Code, v2Code === 'INTERNAL_ERROR' ? 'Internal server error' : message) +} + +/** + * Renders a thrown domain failure in the v2 envelope, or `null` when the error + * carries no classification and the caller should log it and return its own + * generic 500. The v2 counterpart of `orchestrationErrorResponse`. + */ +export function v2CaughtOrchestrationError(error: unknown): NextResponse | null { + const classified = asOrchestrationError(error) + if (!classified) return null + return v2ErrorForOrchestration(classified.code, classified.message) +} 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 new file mode 100644 index 00000000000..a7d6235dca0 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment node + * + * v2 column update wiring: the route authenticates, scopes, delegates to the + * orchestration function, and maps its failure classes onto the v2 envelope. + * The guards themselves are covered in lib/table/orchestration/columns.test.ts. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceScope, mockCheckAccess, mockPerformUpdate } = + vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockPerformUpdate: vi.fn(), + })) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, +})) + +vi.mock('@/lib/table', () => ({ addTableColumn: vi.fn(), deleteColumn: vi.fn() })) + +vi.mock('@/lib/table/orchestration', () => ({ + performUpdateTableColumn: mockPerformUpdate, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { PATCH } from '@/app/api/v2/tables/[tableId]/columns/route' + +const COLUMN = { id: 'col-1', name: 'Status', type: 'text' } +const TABLE = { id: 'table-1', name: 'Tasks', workspaceId: 'ws-1', schema: { columns: [COLUMN] } } + +function patch(updates: Record = { name: 'State' }) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/columns', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: 'ws-1', columnName: 'Status', updates }), + }) + return PATCH(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('PATCH /api/v2/tables/[tableId]/columns', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue({ + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), + }) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockPerformUpdate.mockResolvedValue({ success: true, table: TABLE }) + }) + + it('delegates to the orchestration function with the resolved table and actor', async () => { + const res = await patch() + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ columns: [COLUMN] }) + expect(mockPerformUpdate).toHaveBeenCalledWith( + expect.objectContaining({ table: TABLE, columnName: 'Status', userId: 'user-1' }) + ) + }) + + it.each([ + ['validation', 400, 'BAD_REQUEST'], + ['not_found', 404, 'NOT_FOUND'], + ['locked', 423, 'LOCKED'], + ])('maps a %s failure to %i', async (errorCode, status, code) => { + mockPerformUpdate.mockResolvedValue({ success: false, errorCode, error: 'nope' }) + + const res = await patch() + + expect(res.status).toBe(status) + expect((await res.json()).error.code).toBe(code) + }) + + it('does not leak an internal failure message', async () => { + mockPerformUpdate.mockResolvedValue({ + success: false, + errorCode: 'internal', + error: 'connection string leaked', + }) + + const res = await patch() + + expect(res.status).toBe(500) + expect(await res.text()).not.toContain('connection string') + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts index c7c1e538600..ce480142cab 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts @@ -10,19 +10,16 @@ import { import { isZodError, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - addTableColumn, - deleteColumn, - renameColumn, - updateColumnConstraints, - updateColumnType, -} from '@/lib/table' +import { addTableColumn, deleteColumn } from '@/lib/table' +import { performUpdateTableColumn } from '@/lib/table/orchestration' import { checkAccess, normalizeColumn } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { + v2CaughtOrchestrationError, v2Data, v2Error, + v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, @@ -88,14 +85,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum } catch (error) { if (isZodError(error)) return v2ValidationError(error) - if (error instanceof Error) { - if (error.message.includes('already exists') || error.message.includes('maximum column')) { - return v2Error('BAD_REQUEST', error.message) - } - if (error.message === 'Table not found') { - return v2Error('NOT_FOUND', error.message) - } - } + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified logger.error(`[${requestId}] Error adding column to table`, { error: getErrorMessage(error, 'Unknown error'), @@ -136,73 +127,21 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu return v2Error('NOT_FOUND', 'Table not found') } - const { updates } = validated - let updatedTable = null - - if (updates.name) { - updatedTable = await renameColumn( - { tableId, oldName: validated.columnName, newName: updates.name }, - requestId - ) - } - - if (updates.type) { - updatedTable = await updateColumnType( - { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, - requestId - ) - } - - if (updates.required !== undefined || updates.unique !== undefined) { - updatedTable = await updateColumnConstraints( - { - tableId, - columnName: updates.name ?? validated.columnName, - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - }, - requestId - ) - } - - if (!updatedTable) { - return v2Error('BAD_REQUEST', 'No updates specified') - } - - recordAudit({ - workspaceId: validated.workspaceId, - actorId: userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Updated column "${validated.columnName}" in table "${table.name}"`, - metadata: { columnName: validated.columnName, updates }, + const outcome = await performUpdateTableColumn({ + table, + columnName: validated.columnName, + userId, + updates: validated.updates, + requestId, request, }) + if (!outcome.success || !outcome.table) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to update column') + } - return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) + return v2Data({ columns: outcome.table.schema.columns.map(normalizeColumn) }, { rateLimit }) } catch (error) { if (isZodError(error)) return v2ValidationError(error) - - if (error instanceof Error) { - const msg = error.message - if (msg.includes('not found') || msg.includes('Table not found')) { - return v2Error('NOT_FOUND', msg) - } - if ( - msg.includes('already exists') || - msg.includes('Cannot delete the last column') || - msg.includes('Cannot set column') || - msg.includes('Invalid column') || - msg.includes('exceeds maximum') || - msg.includes('incompatible') || - msg.includes('duplicate') - ) { - return v2Error('BAD_REQUEST', msg) - } - } - logger.error(`[${requestId}] Error updating column in table`, { error: getErrorMessage(error, 'Unknown error'), }) @@ -264,14 +203,8 @@ export const DELETE = withRouteHandler( } catch (error) { if (isZodError(error)) return v2ValidationError(error) - if (error instanceof Error) { - if (error.message.includes('not found') || error.message === 'Table not found') { - return v2Error('NOT_FOUND', error.message) - } - if (error.message.includes('Cannot delete') || error.message.includes('last column')) { - return v2Error('BAD_REQUEST', error.message) - } - } + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified logger.error(`[${requestId}] Error deleting column from table`, { error: getErrorMessage(error, 'Unknown error'), diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts new file mode 100644 index 00000000000..43210d8a8e8 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node + * + * Public v2 table delete: the actor is handed to the service so the audit is + * emitted there — and only for a delete that actually archived a row. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockPerformDeleteTable, + mockRecordAudit, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockPerformDeleteTable: vi.fn(), + mockRecordAudit: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_DELETED: 'table.deleted', TABLE_UPDATED: 'table.updated' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/table', () => ({ + updateTable: vi.fn(), + getTableById: vi.fn(), + updateRow: vi.fn(), + rowDataNameToId: vi.fn(), + buildIdByName: vi.fn(), +})) + +vi.mock('@/lib/table/orchestration', () => ({ performDeleteTable: mockPerformDeleteTable })) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE } from '@/app/api/v2/tables/[tableId]/route' + +const TABLE = { id: 'table-1', name: 'Tasks', workspaceId: 'ws-1', schema: { columns: [] } } + +function callDelete() { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1?workspaceId=ws-1', { + method: 'DELETE', + }) + return DELETE(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('DELETE /api/v2/tables/[tableId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue({ + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), + }) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + }) + + it('delegates to the orchestration function with the resolved table and actor', async () => { + mockPerformDeleteTable.mockResolvedValue({ success: true }) + + const res = await callDelete() + + expect(res.status).toBe(200) + expect(mockPerformDeleteTable).toHaveBeenCalledWith( + expect.objectContaining({ table: TABLE, userId: 'user-1' }) + ) + // The route no longer audits: doing so out here fired TABLE_DELETED even + // when the delete was a no-op on an already-archived table. + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('returns 423 LOCKED for a delete-locked table instead of a 500', async () => { + mockPerformDeleteTable.mockResolvedValue({ + success: false, + errorCode: 'locked', + error: 'Table is locked', + }) + + const res = await callDelete() + + expect(res.status).toBe(423) + expect((await res.json()).error.code).toBe('LOCKED') + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index 03e97d590fa..55e2d792f8f 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' @@ -6,18 +5,19 @@ import { v2DeleteTableContract, v2GetTableContract } from '@/lib/api/contracts/v import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteTable } from '@/lib/table' +import { performDeleteTable } from '@/lib/table/orchestration' import { checkAccess } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, + v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiTable, v2TableAccessError } from '@/app/api/v2/tables/utils' +import { toApiTable, v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableDetailAPI') @@ -100,21 +100,15 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab return v2Error('NOT_FOUND', 'Table not found') } - await deleteTable(tableId, requestId) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.TABLE_DELETED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: result.table.name, - description: `Archived table "${result.table.name}"`, - request, - }) + const outcome = await performDeleteTable({ table: result.table, userId, requestId, request }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to delete table') + } return v2Data({ id: tableId }, { rateLimit }) } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError logger.error(`[${requestId}] Error deleting table`, { error: getErrorMessage(error, 'Unknown error'), }) 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 new file mode 100644 index 00000000000..2139216449a --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts @@ -0,0 +1,99 @@ +/** + * @vitest-environment node + * + * Public v2 single-row delete: goes through the row service so the delete lock + * and row-count bookkeeping are enforced, and renders lock/not-found in the v2 + * error envelope. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceScope, mockCheckAccess, mockPerformDeleteRow } = + vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockPerformDeleteRow: vi.fn(), + })) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/table', () => ({ + updateTable: vi.fn(), + getTableById: vi.fn(), + updateRow: vi.fn(), + rowDataNameToId: vi.fn(), + buildIdByName: vi.fn(), +})) + +vi.mock('@/lib/table/orchestration', () => ({ performDeleteTableRow: mockPerformDeleteRow })) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE } from '@/app/api/v2/tables/[tableId]/rows/[rowId]/route' + +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } + +function callDelete() { + const req = new NextRequest( + 'http://localhost:3000/api/v2/tables/table-1/rows/row-1?workspaceId=ws-1', + { method: 'DELETE' } + ) + return DELETE(req, { params: Promise.resolve({ tableId: 'table-1', rowId: 'row-1' }) }) +} + +describe('DELETE /api/v2/tables/[tableId]/rows/[rowId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue({ + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), + }) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + }) + + it('delegates to the orchestration function rather than deleting inline', async () => { + mockPerformDeleteRow.mockResolvedValue({ success: true }) + + const res = await callDelete() + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ deletedCount: 1, deletedRowIds: ['row-1'] }) + // The orchestration function routes through the row service, which applies + // the delete lock and the row-count decrement; the raw delete this replaced + // skipped both. + expect(mockPerformDeleteRow).toHaveBeenCalledWith( + expect.objectContaining({ table: TABLE, rowId: 'row-1' }) + ) + }) + + it.each([ + ['locked', 423, 'LOCKED'], + ['not_found', 404, 'NOT_FOUND'], + ])('maps a %s failure to %i', async (errorCode, status, code) => { + mockPerformDeleteRow.mockResolvedValue({ success: false, errorCode, error: 'nope' }) + + const res = await callDelete() + + expect(res.status).toBe(status) + expect((await res.json()).error.code).toBe(code) + }) +}) 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 f11cf9b2c74..b0bb10b78d3 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 @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' +import { getErrorMessage } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { @@ -15,17 +15,20 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData, TableSchema } from '@/lib/table' import { buildIdByName, rowDataNameToId, updateRow } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' +import { performDeleteTableRow } from '@/lib/table/orchestration' import { checkAccess } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { + v2CaughtOrchestrationError, v2Data, v2Error, + v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiRow, v2TableAccessError } from '@/app/api/v2/tables/utils' +import { toApiRow, v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableRowAPI') @@ -163,18 +166,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR } catch (error) { if (isZodError(error)) return v2ValidationError(error) - const errorMessage = toError(error).message - if (errorMessage === 'Row not found') return v2Error('NOT_FOUND', errorMessage) - - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') - ) { - return v2Error('BAD_REQUEST', errorMessage) - } + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified logger.error(`[${requestId}] Error updating row`, { error: getErrorMessage(error, 'Unknown error'), @@ -214,22 +207,18 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row return v2Error('NOT_FOUND', 'Table not found') } - const [deletedRow] = await db - .delete(userTableRows) - .where( - and( - eq(userTableRows.id, rowId), - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, workspaceId) - ) - ) - .returning({ id: userTableRows.id }) - - if (!deletedRow) return v2Error('NOT_FOUND', 'Row not found') + const outcome = await performDeleteTableRow({ table: result.table, rowId, requestId }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to delete row') + } // v2 mirrors the bulk delete shape: always returns `deletedRowIds`. - return v2Data({ deletedCount: 1, deletedRowIds: [deletedRow.id] }, { rateLimit }) + return v2Data({ deletedCount: 1, deletedRowIds: [rowId] }, { rateLimit }) } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified logger.error(`[${requestId}] Error deleting row`, { error: getErrorMessage(error, 'Unknown error'), }) 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 08f4b0873af..a8b4c21593b 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 @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' +import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { v2UpsertTableRowContract } from '@/lib/api/contracts/v2/tables' import { isZodError, parseRequest } from '@/lib/api/server' @@ -12,6 +12,7 @@ import { checkAccess } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { + v2CaughtOrchestrationError, v2Data, v2Error, v2RateLimitError, @@ -82,18 +83,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser } catch (error) { if (isZodError(error)) return v2ValidationError(error) - const errorMessage = toError(error).message - if ( - errorMessage.includes('unique column') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('conflictTarget') || - errorMessage.includes('row limit') || - errorMessage.includes('Schema validation') || - errorMessage.includes('Upsert requires') || - errorMessage.includes('Row size exceeds') - ) { - return v2Error('BAD_REQUEST', errorMessage) - } + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified logger.error(`[${requestId}] Error upserting row`, { error: getErrorMessage(error, 'Unknown error'), diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index 9082e9280b9..85df923214c 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -11,6 +11,7 @@ import { normalizeColumn } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { + v2CaughtOrchestrationError, v2CursorList, v2Data, v2Error, @@ -128,18 +129,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { if (isZodError(error)) return v2ValidationError(error) - if (error instanceof Error) { - if (error.message.includes('maximum table limit')) { - return v2Error('FORBIDDEN', error.message) - } - if ( - error.message.includes('Invalid table name') || - error.message.includes('Invalid schema') || - error.message.includes('already exists') - ) { - return v2Error('BAD_REQUEST', error.message) - } - } + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified logger.error(`[${requestId}] Error creating table`, { error: getErrorMessage(error, 'Unknown error'), diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index 00a9510feb8..8d662be3d4a 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -1,5 +1,6 @@ import type { NextResponse } from 'next/server' import type { RowData, TableDefinition, TablePredicate, TableSchema } from '@/lib/table' +import { TableLockedError } from '@/lib/table/mutation-locks' import { predicateToFilter } from '@/lib/table/query-builder/converters' import { validatePredicateShape, @@ -95,6 +96,16 @@ export function v2TableAccessError(result: { ok: false; status: 404 | 403 }): Ne : v2Error('FORBIDDEN', 'Access denied') } +/** + * 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. + */ +export function v2TableLockError(error: unknown): NextResponse | null { + if (error instanceof TableLockedError) return v2Error('LOCKED', error.message) + return null +} + /** * Maps a known user-facing row-write failure (schema/size/unique/limit) to a v2 * `BAD_REQUEST`, reusing v1's {@link rowWriteErrorResponse} classifier as the diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 4c7e1027161..4fb34919b76 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { updatePublicApiContract } from '@/lib/api/contracts/deployments' import { parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' @@ -15,7 +16,6 @@ import { performFullDeploy, performFullUndeploy, } from '@/lib/workflows/orchestration' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { checkNeedsRedeployment, diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index d3c3337e62f..5d5300ec13d 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -4,10 +4,10 @@ import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { updateDeploymentVersionMetadataContract } from '@/lib/api/contracts/deployments' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performActivateVersion } from '@/lib/workflows/orchestration' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' import { getWorkflowDeploymentVersion, updateDeploymentVersionMetadata, diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index e5b7ffefda4..602d71664cb 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -22,7 +22,8 @@ import { getKnowledgeBases, updateKnowledgeBase, } from '@/lib/knowledge/service' -import { deleteTable, listTables, renameTable } from '@/lib/table/service' +import { performDeleteTable, performRenameTable } from '@/lib/table/orchestration' +import { listTables } from '@/lib/table/service' import { ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath, @@ -759,11 +760,19 @@ async function renameFlatResource( return { success: false, error: `Table not found at ${sources[0]}` } } assertMutationNotAborted(context) - const renamed = await renameTable(match.id, newName, generateRequestId()) + const renameOutcome = await performRenameTable({ + table: match, + newName, + userId: context.userId, + requestId: generateRequestId(), + }) + if (!renameOutcome.success) { + return { success: false, error: renameOutcome.error ?? 'Failed to rename table' } + } return buildResult(verb, [ { from: sources[0], - to: `tables/${normalizeVfsSegment(renamed.name)}`, + to: `tables/${normalizeVfsSegment(newName)}`, kind, id: match.id, }, @@ -1018,7 +1027,14 @@ async function removeTablePath( ) if (!match) return { from: path, kind: 'table', error: `Table not found at ${path}` } - await deleteTable(match.id, generateRequestId(), context.userId) + const outcome = await performDeleteTable({ + table: match, + userId: context.userId, + requestId: generateRequestId(), + }) + if (!outcome.success) { + return { from: path, kind: 'table', error: outcome.error ?? 'Failed to archive table' } + } logger.info('Archived table via rm', { tableId: match.id, workspaceId }) return { from: path, kind: 'table', id: match.id } } diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index adebd05bd7a..512401f65ea 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -137,10 +137,7 @@ vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetWorkspaceTableLimits, })) -import { - normalizeSelectOptionsInput, - userTableServerTool, -} from '@/lib/copilot/tools/server/table/user-table' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' import { encodeCursor } from '@/lib/table/rows/cursor' function buildTable(overrides: Partial = {}): TableDefinition { @@ -172,60 +169,6 @@ async function flushDetached(): Promise { await Promise.resolve() } -describe('normalizeSelectOptionsInput', () => { - it('generates a stable id for a bare-name string option', () => { - const [opt] = normalizeSelectOptionsInput(['Open']) ?? [] - expect(opt.name).toBe('Open') - expect(typeof opt.id).toBe('string') - expect(opt.id.length).toBeGreaterThan(0) - }) - - it('generates an id for an object option without one', () => { - const [opt] = normalizeSelectOptionsInput([{ name: 'Closed' }]) ?? [] - expect(opt.name).toBe('Closed') - expect(opt.id.length).toBeGreaterThan(0) - }) - - it('preserves an explicitly supplied id', () => { - const result = normalizeSelectOptionsInput([{ id: 'opt_keep', name: 'Open' }]) - expect(result).toEqual([{ id: 'opt_keep', name: 'Open' }]) - }) - - it('reuses the id of an existing option with the same name', () => { - // The agent re-sends options as bare names on every edit. Minting fresh ids - // would orphan every cell holding them — silently clearing the column. - const existing = [ - { id: 'opt_low', name: 'Low' }, - { id: 'opt_high', name: 'High' }, - ] - const result = normalizeSelectOptionsInput(['Low', 'Medium', 'High'], existing) ?? [] - - expect(result[0]).toEqual({ id: 'opt_low', name: 'Low' }) - expect(result[2]).toEqual({ id: 'opt_high', name: 'High' }) - // Only the genuinely new option gets a fresh id. - expect(result[1].name).toBe('Medium') - expect(result[1].id).not.toBe('opt_low') - expect(result[1].id).not.toBe('opt_high') - }) - - it('matches an existing option name case-insensitively', () => { - const result = normalizeSelectOptionsInput(['open'], [{ id: 'opt_open', name: 'Open' }]) ?? [] - expect(result[0].id).toBe('opt_open') - expect(result[0].name).toBe('open') - }) - - it('mints a fresh id when there is no existing column to match against', () => { - const result = normalizeSelectOptionsInput(['Open']) ?? [] - expect(result[0].id.length).toBeGreaterThan(0) - expect(result[0].name).toBe('Open') - }) - - it('returns undefined for a non-array (validation rejects it downstream)', () => { - expect(normalizeSelectOptionsInput(undefined)).toBeUndefined() - expect(normalizeSelectOptionsInput('Open')).toBeUndefined() - }) -}) - describe('userTableServerTool.import_file', () => { beforeEach(() => { vi.clearAllMocks() 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 036ceb2cd40..93b0062c7b6 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -1,7 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import { generateId, generateShortId } from '@sim/utils/id' +import { generateId } from '@sim/utils/id' import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, @@ -10,7 +10,6 @@ import { } from '@/lib/copilot/tools/server/base-tool' import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { runDetached } from '@/lib/core/utils/background' -import { captureServerEvent } from '@/lib/posthog/server' import { buildAutoMapping, COLUMN_TYPES, @@ -27,29 +26,24 @@ import { validateMapping, } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' -import { - buildIdByName, - columnMatchesRef, - rowDataNameToId, - sortSpecNamesToIds, -} from '@/lib/table/column-keys' +import { buildIdByName, rowDataNameToId, sortSpecNamesToIds } from '@/lib/table/column-keys' import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' -import { columnTypeById } from '@/lib/table/column-types' import { addTableColumn, deleteColumn, deleteColumns, renameColumn, - updateColumnConstraints, - updateColumnCurrency, - updateColumnOptions, - updateColumnType, } from '@/lib/table/columns/service' import { isSupportedCurrencyCode } from '@/lib/table/currency' import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' import { assertRowDelete, assertRowUpdate, patchColumnIds } from '@/lib/table/mutation-locks' +import { + performDeleteTable, + performRenameTable, + performUpdateTableColumn, +} from '@/lib/table/orchestration' import { predicateToFilter } from '@/lib/table/query-builder/converters' import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/validate' import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' @@ -66,13 +60,13 @@ import { updateRow, updateRowsByFilter, } from '@/lib/table/rows/service' +import { normalizeSelectOptionsInput } from '@/lib/table/select-options' import { predicateToStorage } from '@/lib/table/select-values' -import { createTable, deleteTable, getTableById, renameTable } from '@/lib/table/service' +import { createTable, deleteTable, getTableById } from '@/lib/table/service' import type { ColumnDefinition, Filter, RowData, - SelectOption, SortSpec, TableDefinition, TableDeleteJobPayload, @@ -334,30 +328,6 @@ function limitError(limit: unknown): string | null { * cell data survives the update. Non-array input returns `undefined`, letting * downstream validation reject a malformed / missing option set. */ -export function normalizeSelectOptionsInput( - raw: unknown, - existing: SelectOption[] = [] -): SelectOption[] | undefined { - if (!Array.isArray(raw)) return undefined - // Cells reference the option id, so an edit that re-sends the same option by - // name must reuse its id — minting a fresh one would orphan every cell - // holding it, silently clearing the column. - const idByName = new Map() - for (const option of existing) { - const key = option.name.toLowerCase() - if (!idByName.has(key)) idByName.set(key, option.id) - } - const resolveId = (name: string): string => idByName.get(name.toLowerCase()) ?? generateShortId() - - return raw.map((entry) => { - if (typeof entry === 'string') return { id: resolveId(entry), name: entry } - const e = (entry ?? {}) as { id?: unknown; name?: unknown } - const name = typeof e.name === 'string' ? e.name : String(e.name ?? '') - const id = typeof e.id === 'string' && e.id.length > 0 ? e.id : resolveId(name) - return { id, name } - }) -} - /** Rewrites every `select` column's options in an agent-authored create schema. */ function normalizeSchemaSelectColumns(schema: TableSchema): TableSchema { if (!schema || !Array.isArray(schema.columns)) return schema @@ -523,13 +493,14 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() - await deleteTable(tableId, requestId, context.userId) - captureServerEvent( - context.userId, - 'table_deleted', - { table_id: tableId, workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) + const deleteOutcome = await performDeleteTable({ + table, + userId: context.userId, + requestId, + }) + if (!deleteOutcome.success) { + return { success: false, message: deleteOutcome.error ?? 'Failed to delete table' } + } deleted.push(tableId) } @@ -1683,117 +1654,38 @@ export const userTableServerTool: BaseServerTool message: `Invalid currency code "${currencyCode}". Use an ISO 4217 code, e.g. USD`, } } - const tableForUpdate = await getTableById(args.tableId) - if (!tableForUpdate || tableForUpdate.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - const requestId = generateId().slice(0, 8) - // The agent authors options by name; mint ids here, reusing the id of - // any option whose name already exists so its cells survive the edit. - const currentColumn = tableForUpdate.schema.columns.find((c) => - columnMatchesRef(c, colName) - ) - const existingOptions = currentColumn?.options ?? [] - const options = normalizeSelectOptionsInput(rawOptions, existingOptions) - // An agent restating the current type alongside new options must not - // go through `updateColumnType` — it early-returns on an unchanged - // type and would drop them. Mirrors the HTTP columns route. - const typeChanging = newType !== undefined && newType !== currentColumn?.type - let result: TableDefinition | undefined if (newType !== undefined && !(COLUMN_TYPES as readonly string[]).includes(newType)) { return { success: false, message: `Invalid column type "${newType}". Must be one of: ${COLUMN_TYPES.join(', ')}`, } } - // Each write below is its own locked transaction, so pairing any of - // them with a constraint write that is going to fail commits and then - // errors. Gate on the type the column ENDS UP with — an options-only - // update on an existing select column carries the same hazard as a - // conversion. Same guard the HTTP column routes apply. - const resultingType = newType ?? currentColumn?.type - if (uniqFlag === true && !columnTypeById(resultingType).supportsUnique) { - return { - success: false, - message: `Cannot set column "${colName}" as unique: ${resultingType} columns cannot be unique.`, - } - } - if (typeChanging) { - assertNotAborted() - result = await updateColumnType( - { - tableId: args.tableId, - columnName: colName, - newType: newType as (typeof COLUMN_TYPES)[number], - options, - multiple, - ...(currencyCode !== undefined ? { currencyCode } : {}), - ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), - }, - requestId - ) - } else if (currencyCode !== undefined) { - // Re-denominating an existing currency column: schema-only, no cell - // rewrite. Mirrors the HTTP columns routes. - if (currentColumn?.type !== 'currency') { - return { - success: false, - message: `Column "${colName}" is not a currency column. Pass newType: "currency" with currencyCode to convert it.`, - } - } - assertNotAborted() - result = await updateColumnCurrency( - { - tableId: args.tableId, - columnName: colName, - currencyCode, - ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), - }, - requestId - ) - } else if (options !== undefined || multiple !== undefined) { - // Editing an existing select column's option set / mode without a - // type change. `multiple` alone is a valid update — the catalog - // documents it as independent — so fall back to the column's current - // options rather than demanding the caller resend the whole list. - const nextOptions = options ?? existingOptions - if (nextOptions.length === 0) { - return { - success: false, - message: `Column "${colName}" is not a select column. Pass newType: "select" with options to convert it.`, - } - } - assertNotAborted() - result = await updateColumnOptions( - { - tableId: args.tableId, - columnName: colName, - options: nextOptions, - multiple, - ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), - }, - requestId - ) + const tableForUpdate = await getTableById(args.tableId) + if (!tableForUpdate || tableForUpdate.workspaceId !== workspaceId) { + return { success: false, message: `Table not found: ${args.tableId}` } } - // Skipped when a typed write ran: that write already applied and - // validated the constraint, in one transaction with the change it - // accompanies. Mirrors the HTTP columns routes. - if (uniqFlag !== undefined && result === undefined) { - assertNotAborted() - result = await updateColumnConstraints( - { tableId: args.tableId, columnName: colName, unique: uniqFlag }, - requestId - ) + assertNotAborted() + const outcome = await performUpdateTableColumn({ + table: tableForUpdate, + columnName: colName, + userId: context.userId, + updates: { + ...(newType !== undefined ? { type: newType as (typeof COLUMN_TYPES)[number] } : {}), + ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), + ...(rawOptions !== undefined ? { options: rawOptions } : {}), + ...(multiple !== undefined ? { multiple } : {}), + ...(currencyCode !== undefined ? { currencyCode } : {}), + }, + }) + if (!outcome.success || !outcome.table) { + return { success: false, message: outcome.error ?? 'Failed to update column' } } return { success: true, message: `Updated column "${colName}"`, - // A payload that only restates the current type is a no-op; still - // report the live schema rather than an undefined one. - data: { schema: (result ?? tableForUpdate).schema }, + data: { schema: outcome.table.schema }, } } - case 'rename': { if (!args.tableId) { return { success: false, message: 'Table ID is required' } @@ -1813,12 +1705,20 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() - const renamed = await renameTable(args.tableId, newName, requestId, context.userId) + const renameOutcome = await performRenameTable({ + table, + newName, + userId: context.userId, + requestId, + }) + if (!renameOutcome.success) { + return { success: false, message: renameOutcome.error ?? 'Failed to rename table' } + } return { success: true, - message: `Renamed table to "${renamed.name}"`, - data: { table: { id: renamed.id, name: renamed.name } }, + message: `Renamed table to "${newName}"`, + data: { table: { id: args.tableId, name: newName } }, } } diff --git a/apps/sim/lib/workflows/orchestration/types.test.ts b/apps/sim/lib/core/orchestration/types.test.ts similarity index 82% rename from apps/sim/lib/workflows/orchestration/types.test.ts rename to apps/sim/lib/core/orchestration/types.test.ts index 26be2502be4..af8104841a1 100644 --- a/apps/sim/lib/workflows/orchestration/types.test.ts +++ b/apps/sim/lib/core/orchestration/types.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' describe('statusForOrchestrationError', () => { it.each([ diff --git a/apps/sim/lib/core/orchestration/types.ts b/apps/sim/lib/core/orchestration/types.ts new file mode 100644 index 00000000000..59ccf54aad6 --- /dev/null +++ b/apps/sim/lib/core/orchestration/types.ts @@ -0,0 +1,74 @@ +export type OrchestrationErrorCode = + | 'validation' + | 'not_found' + | 'forbidden' + | 'conflict' + | 'locked' + | 'internal' + +/** + * Transport-neutral failure classes returned by every `lib/[resource]/orchestration` + * module, so the UI routes, the public API, and the copilot tools map the same + * failure to the same status. + */ +export function statusForOrchestrationError(code: OrchestrationErrorCode | undefined): number { + if (code === 'validation') return 400 + if (code === 'forbidden') return 403 + if (code === 'not_found') return 404 + if (code === 'conflict') return 409 + if (code === 'locked') return 423 + return 500 +} + +/** + * A domain failure that already knows its own class. + * + * Services throw this instead of a bare `Error` whenever the failure is + * caller-fixable, so the layers above classify by `instanceof` and read `code` + * rather than searching the message for a phrase. Message text is then free to + * be reworded, translated, or made more specific without silently changing the + * status every caller returns — the failure mode this replaced, where adding + * "already exists" to a message demoted a 409 to a 400. + * + * The code is transport-neutral on purpose: `statusForOrchestrationError` maps + * it for the UI and v1 routes, `v2ErrorForOrchestration` maps it to the v2 + * error vocabulary, and the copilot tools surface `message` with no status at + * all. An anything-else error stays unclassified and becomes a generic 500, + * which is what an unexpected fault should be. + */ +export class OrchestrationError extends Error { + constructor( + readonly code: OrchestrationErrorCode, + message: string + ) { + super(message) + this.name = 'OrchestrationError' + } +} + +/** + * The {@link OrchestrationError} in `error`'s cause chain, or `null` when the + * failure is not a classified one. + * + * Walks `cause` rather than testing `error` alone because drizzle wraps a throw + * raised inside a transaction callback in a `DrizzleQueryError` whose own + * message is the failed SQL — the same reason the message-matching this + * replaced had to dig for a root cause before it could classify anything. + */ +export function asOrchestrationError(error: unknown): OrchestrationError | null { + let current: unknown = error + while (current instanceof Error) { + if (current instanceof OrchestrationError) return current + current = current.cause + } + return null +} + +/** + * The slice of an HTTP request the audit log reads for client IP and user-agent + * capture. Optional on every orchestration function so the non-HTTP callers — + * copilot tools, background jobs — can omit what they do not have. + */ +export interface OrchestrationRequestContext { + headers: { get(name: string): string | null } +} diff --git a/apps/sim/lib/folders/config.ts b/apps/sim/lib/folders/config.ts index 4c6b3d6333e..5c69755b75e 100644 --- a/apps/sim/lib/folders/config.ts +++ b/apps/sim/lib/folders/config.ts @@ -304,7 +304,7 @@ async function archiveTableChildren(context: CascadeChildrenContext): Promise { assertSchemaMutable(table) if (!NAME_PATTERN.test(column.name)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column name "${column.name}". Must start with a letter or underscore and contain only alphanumeric characters and underscores.` ) } if (column.name.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { - throw new Error( + throw new OrchestrationError( + 'validation', `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` ) } if (!COLUMN_TYPES.includes(column.type as (typeof COLUMN_TYPES)[number])) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column type "${column.type}". Must be one of: ${COLUMN_TYPES.join(', ')}` ) } const schema = table.schema if (schema.columns.some((c) => c.name.toLowerCase() === column.name.toLowerCase())) { - throw new Error(`Column "${column.name}" already exists`) + throw new OrchestrationError('validation', `Column "${column.name}" already exists`) } if (schema.columns.length >= TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { - throw new Error( + throw new OrchestrationError( + 'validation', `Table has reached maximum column limit (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE})` ) } @@ -124,7 +135,10 @@ export async function addTableColumn( const columnValidation = validateColumnDefinition(newColumn) if (!columnValidation.valid) { - throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`) + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) } const newColumnId = getColumnId(newColumn) @@ -194,13 +208,15 @@ export async function renameColumn( return withLockedTable(data.tableId, async (table, trx) => { assertSchemaMutable(table) if (!NAME_PATTERN.test(data.newName)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column name "${data.newName}". Column names must start with a letter or underscore, followed by alphanumeric characters or underscores.` ) } if (data.newName.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { - throw new Error( + throw new OrchestrationError( + 'validation', `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` ) } @@ -208,7 +224,7 @@ export async function renameColumn( const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.oldName)) if (columnIndex === -1) { - throw new Error(`Column "${data.oldName}" not found`) + throw new OrchestrationError('not_found', `Column "${data.oldName}" not found`) } if ( @@ -216,7 +232,7 @@ export async function renameColumn( (c, i) => i !== columnIndex && c.name.toLowerCase() === data.newName.toLowerCase() ) ) { - throw new Error(`Column "${data.newName}" already exists`) + throw new OrchestrationError('validation', `Column "${data.newName}" already exists`) } const targetColumn = schema.columns[columnIndex] @@ -331,11 +347,11 @@ export async function deleteColumn( const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) if (columnIndex === -1) { - throw new Error(`Column "${data.columnName}" not found`) + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) } if (schema.columns.length <= 1) { - throw new Error('Cannot delete the last column in a table') + throw new OrchestrationError('validation', 'Cannot delete the last column in a table') } const targetColumn = schema.columns[columnIndex] @@ -423,12 +439,12 @@ export async function deleteColumns( } if (notFound.length > 0) { - throw new Error(`Columns not found: ${notFound.join(', ')}`) + throw new OrchestrationError('not_found', `Columns not found: ${notFound.join(', ')}`) } const remaining = schema.columns.filter((c) => !namesToDelete.has(c.name)) if (remaining.length === 0) { - throw new Error('Cannot delete all columns from a table') + throw new OrchestrationError('validation', 'Cannot delete all columns from a table') } // For each group, drop outputs whose column (by id) is being deleted. Groups @@ -506,26 +522,32 @@ async function applyConstraints( if (data.required === undefined && data.unique === undefined) return column if (column.workflowGroupId) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot change constraints on workflow-output column "${column.name}". Constraints aren't applicable to columns whose values come from workflow execution.` ) } if (data.required === true && !column.required) { const emptyCount = await countEmptyCells(trx, tableId, columnKey) if (emptyCount > 0) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot set column "${column.name}" as required: ${emptyCount} row(s) have null, missing, or empty values` ) } } if (data.unique === true && !column.unique) { if (!columnTypeOf(column).supportsUnique) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot set column "${column.name}" as unique: ${column.type} columns compare stored values that would allow only one row per value.` ) } if (await hasDuplicateValues(trx, tableId, columnKey)) { - throw new Error(`Cannot set column "${column.name}" as unique: duplicate values exist`) + throw new OrchestrationError( + 'validation', + `Cannot set column "${column.name}" as unique: duplicate values exist` + ) } } return { @@ -592,17 +614,19 @@ export function applyPendingRename( if (newName === undefined || newName === column.name) return column if (!NAME_PATTERN.test(newName)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column name "${newName}". Column names must start with a letter or underscore, followed by alphanumeric characters or underscores.` ) } if (newName.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { - throw new Error( + throw new OrchestrationError( + 'validation', `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` ) } if (columns.some((c, i) => i !== columnIndex && c.name.toLowerCase() === newName.toLowerCase())) { - throw new Error(`Column "${newName}" already exists`) + throw new OrchestrationError('validation', `Column "${newName}" already exists`) } return { ...column, name: newName } } @@ -688,7 +712,8 @@ export async function updateColumnType( await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) if (!(COLUMN_TYPES as readonly string[]).includes(data.newType)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column type "${data.newType}". Valid types: ${COLUMN_TYPES.join(', ')}` ) } @@ -696,7 +721,7 @@ export async function updateColumnType( const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) if (columnIndex === -1) { - throw new Error(`Column "${data.columnName}" not found`) + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) } const column = schema.columns[columnIndex] @@ -714,7 +739,8 @@ export async function updateColumnType( data.multiple !== undefined || data.currencyCode !== undefined if (carriesOtherWork) { - throw new Error( + throw new OrchestrationError( + 'validation', `Column "${column.name}" is already type "${data.newType}"; re-issue the request without a type change.` ) } @@ -760,7 +786,8 @@ export async function updateColumnType( if (targetRequired) { const emptyCount = await countEmptyCells(trx, data.tableId, columnKey) if (emptyCount > 0) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot change column "${column.name}" to a required "${data.newType}": ${emptyCount} row(s) have null, missing, or empty values. Fill them first, or apply the type change without making the column required.` ) } @@ -828,13 +855,15 @@ export async function updateColumnType( } if (blankCount > 0) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot change column "${column.name}" to a required "${data.newType}": ${blankCount} row(s) are empty. Fill them first, or apply the type change without making the column required.` ) } if (incompatibleCount > 0) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot change column "${column.name}" to type "${data.newType}": ${incompatibleCount} row(s) have incompatible values. Fix or remove the incompatible values first.` ) } @@ -846,7 +875,10 @@ export async function updateColumnType( const columnValidation = validateColumnDefinition(updatedColumns[columnIndex]) if (!columnValidation.valid) { - throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`) + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) } const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } @@ -879,7 +911,8 @@ export async function updateColumnType( // irrecoverably rewritten. if (data.unique === true && !column.unique) { if (await hasDuplicateValues(trx, data.tableId, columnKey)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot change column "${column.name}" to type "${data.newType}" and set it as unique: the converted values contain duplicates.` ) } @@ -926,7 +959,7 @@ export async function updateColumnConstraints( const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) if (columnIndex === -1) { - throw new Error(`Column "${data.columnName}" not found`) + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) } const column = schema.columns[columnIndex] @@ -966,12 +999,15 @@ export async function updateColumnOptions( const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) if (columnIndex === -1) { - throw new Error(`Column "${data.columnName}" not found`) + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) } const column = schema.columns[columnIndex] if (column.type !== 'select') { - throw new Error(`Cannot set options on column "${column.name}" of type "${column.type}"`) + throw new OrchestrationError( + 'validation', + `Cannot set options on column "${column.name}" of type "${column.type}"` + ) } const columnKey = getColumnId(column) @@ -984,7 +1020,10 @@ export async function updateColumnOptions( } const columnValidation = validateColumnDefinition(updatedColumn) if (!columnValidation.valid) { - throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`) + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) } const nextMultiple = !!(data.multiple ?? column.multiple) @@ -1033,7 +1072,8 @@ export async function updateColumnOptions( wasMultiple ) if (strandedCount > 0) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot remove options from required column "${column.name}": ${strandedCount} row(s) would be left empty. Reassign those rows to a remaining option first.` ) } @@ -1060,7 +1100,8 @@ export async function updateColumnOptions( } if (multiValuedCount > 0) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot switch column "${column.name}" to single-select: ${multiValuedCount} row(s) have multiple options selected. Reduce them to one option first.` ) } @@ -1132,12 +1173,15 @@ export async function updateColumnCurrency( const schema = table.schema const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) if (columnIndex === -1) { - throw new Error(`Column "${data.columnName}" not found`) + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) } const column = schema.columns[columnIndex] if (column.type !== 'currency') { - throw new Error(`Cannot set currency on column "${column.name}" of type "${column.type}"`) + throw new OrchestrationError( + 'validation', + `Cannot set currency on column "${column.name}" of type "${column.type}"` + ) } const updatedColumn: ColumnDefinition = { @@ -1146,7 +1190,10 @@ export async function updateColumnCurrency( } const columnValidation = validateColumnDefinition(updatedColumn) if (!columnValidation.valid) { - throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`) + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) } const constrained = await applyConstraints( diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index 84664e077b2..f8e5fd8d0c6 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -9,6 +9,7 @@ import { userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' import { CSV_MAX_BATCH_SIZE } from '@/lib/table/import' import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks' @@ -77,11 +78,17 @@ export async function bulkInsertImportBatch( for (let i = 0; i < data.rows.length; i++) { const sizeValidation = validateRowSize(data.rows[i]) if (!sizeValidation.valid) { - throw new Error(`Row ${i + 1}: ${sizeValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${i + 1}: ${sizeValidation.errors.join(', ')}` + ) } const schemaValidation = coerceRowToSchema(data.rows[i], table.schema) if (!schemaValidation.valid) { - throw new Error(`Row ${i + 1}: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${i + 1}: ${schemaValidation.errors.join(', ')}` + ) } } @@ -94,7 +101,8 @@ export async function bulkInsertImportBatch( db ) if (!uniqueResult.valid) { - throw new Error( + throw new OrchestrationError( + 'validation', uniqueResult.errors.map((e) => `Row ${e.row + 1}: ${e.errors.join(', ')}`).join('; ') ) } diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index 02c8a6e431a..3759e4eefe0 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -12,6 +12,7 @@ */ import { type Options as CsvParseOptions, type Parser, parse as parseCsvStream } from 'csv-parse' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { getColumnId } from '@/lib/table/column-keys' import type { ColumnType } from '@/lib/table/column-types' import { parseCurrencyInput } from '@/lib/table/currency' @@ -274,11 +275,11 @@ export async function parseCsvBuffer( const parsed = parse(text, options) as unknown as Record[] if (parsed.length === 0) { - throw new Error('CSV file has no data rows') + throw new OrchestrationError('validation', 'CSV file has no data rows') } if (headers.length === 0) { - throw new Error('CSV file has no headers') + throw new OrchestrationError('validation', 'CSV file has no headers') } return { headers, rows: parsed } @@ -648,15 +649,18 @@ export function parseJsonRows(buffer: Buffer | string): { const text = typeof buffer === 'string' ? buffer : buffer.toString('utf-8') const parsed = JSON.parse(text) if (!Array.isArray(parsed)) { - throw new Error('JSON file must contain an array of objects') + throw new OrchestrationError('validation', 'JSON file must contain an array of objects') } if (parsed.length === 0) { - throw new Error('JSON file contains an empty array') + throw new OrchestrationError('validation', 'JSON file contains an empty array') } const headerSet = new Set() for (const row of parsed) { if (typeof row !== 'object' || row === null || Array.isArray(row)) { - throw new Error('Each element in the JSON array must be a plain object') + throw new OrchestrationError( + 'validation', + 'Each element in the JSON array must be a plain object' + ) } for (const key of Object.keys(row)) headerSet.add(key) } @@ -685,5 +689,8 @@ export async function parseFileRows( ) return parseCsvBuffer(buffer, delimiter) } - throw new Error(`Unsupported file format: "${ext ?? fileName}". Supported: csv, tsv, json`) + throw new OrchestrationError( + 'validation', + `Unsupported file format: "${ext ?? fileName}". Supported: csv, tsv, json` + ) } diff --git a/apps/sim/lib/table/orchestration/columns.test.ts b/apps/sim/lib/table/orchestration/columns.test.ts new file mode 100644 index 00000000000..d99eff54342 --- /dev/null +++ b/apps/sim/lib/table/orchestration/columns.test.ts @@ -0,0 +1,224 @@ +/** + * @vitest-environment node + * + * The column-update guards. These used to live in four callers (UI route, v1, + * v2, copilot tool) and had drifted apart; they are asserted here once. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const { + mockRenameColumn, + mockUpdateColumnType, + mockUpdateColumnOptions, + mockUpdateColumnConstraints, + mockUpdateColumnCurrency, + mockRecordAudit, +} = vi.hoisted(() => ({ + mockRenameColumn: vi.fn(), + mockUpdateColumnType: vi.fn(), + mockUpdateColumnOptions: vi.fn(), + mockUpdateColumnConstraints: vi.fn(), + mockUpdateColumnCurrency: vi.fn(), + mockRecordAudit: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_UPDATED: 'table.updated' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@/lib/table/columns/service', () => ({ + renameColumn: mockRenameColumn, + updateColumnConstraints: mockUpdateColumnConstraints, + updateColumnCurrency: mockUpdateColumnCurrency, + updateColumnOptions: mockUpdateColumnOptions, + updateColumnType: mockUpdateColumnType, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { performUpdateTableColumn } from '@/lib/table/orchestration/columns' + +const SELECT_COLUMN = { + id: 'col-1', + name: 'Status', + type: 'select' as const, + options: [{ id: 'opt_open', name: 'Open' }], +} +const TEXT_COLUMN = { id: 'col-2', name: 'Priority', type: 'text' as const } + +const TABLE = { + id: 'table-1', + name: 'Tasks', + workspaceId: 'ws-1', + schema: { columns: [SELECT_COLUMN, TEXT_COLUMN] }, +} as unknown as TableDefinition + +const UPDATED = { schema: { columns: [SELECT_COLUMN] } } as unknown as TableDefinition + +function run(updates: Record, columnName = 'Status') { + return performUpdateTableColumn({ + table: TABLE, + columnName, + userId: 'user-1', + updates, + requestId: 'req-1', + }) +} + +describe('performUpdateTableColumn', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRenameColumn.mockResolvedValue(UPDATED) + mockUpdateColumnType.mockResolvedValue(UPDATED) + mockUpdateColumnOptions.mockResolvedValue(UPDATED) + mockUpdateColumnConstraints.mockResolvedValue(UPDATED) + mockUpdateColumnCurrency.mockResolvedValue(UPDATED) + }) + + it('refuses to make a select column unique before writing anything', async () => { + // Each write is its own locked transaction, so an un-gated constraint write + // commits the earlier writes and then throws, half-applying the change. + const result = await run({ unique: true }) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockUpdateColumnConstraints).not.toHaveBeenCalled() + }) + + it('refuses a conversion to select that is also made unique', async () => { + const result = await run({ type: 'select', options: ['Done'], unique: true }, 'Priority') + + expect(result.errorCode).toBe('validation') + expect(mockUpdateColumnType).not.toHaveBeenCalled() + }) + + it('routes an unchanged type with options to the options update', async () => { + // updateColumnType early-returns on an unchanged type and would drop them. + await run({ type: 'select', options: ['Open', 'Closed'] }) + + expect(mockUpdateColumnType).not.toHaveBeenCalled() + // Addressed by stable id so a rename folded into the write can't break it. + expect(mockUpdateColumnOptions).toHaveBeenCalledWith( + expect.objectContaining({ columnName: 'col-1' }), + 'req-1' + ) + }) + + it('reuses the id of an option resent by name so its cells survive', async () => { + await run({ options: ['Open', 'Blocked'] }) + + const [{ options }] = mockUpdateColumnOptions.mock.calls[0] + expect(options[0]).toEqual({ id: 'opt_open', name: 'Open' }) + expect(options[1].id).not.toBe('opt_open') + }) + + it('carries options and required through a real type change', async () => { + await run({ type: 'select', options: ['Done'], required: true }, 'Priority') + + expect(mockUpdateColumnOptions).not.toHaveBeenCalled() + expect(mockUpdateColumnType).toHaveBeenCalledWith( + expect.objectContaining({ newType: 'select', required: true }), + 'req-1' + ) + }) + + it('folds a rename into the write it rides on rather than running it separately', async () => { + // A rename is metadata-only, so folding it into the last write's transaction + // is what stops a combined request committing one half and failing the other. + await run({ name: 'State', required: true }) + + expect(mockRenameColumn).not.toHaveBeenCalled() + expect(mockUpdateColumnConstraints).toHaveBeenCalledWith( + expect.objectContaining({ columnName: 'col-1', newName: 'State' }), + 'req-1' + ) + }) + + it('runs a rename standalone when there is no write to ride on', async () => { + mockRenameColumn.mockResolvedValue(UPDATED) + + await run({ name: 'State' }) + + expect(mockRenameColumn).toHaveBeenCalledWith( + { tableId: 'table-1', oldName: 'col-1', newName: 'State' }, + 'req-1' + ) + }) + + it('rejects setting a currency code on a non-currency column', async () => { + const result = await run({ currencyCode: 'USD' }) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockUpdateColumnCurrency).not.toHaveBeenCalled() + }) + + it('rejects an unsupported currency code before any write', async () => { + const result = await run({ type: 'currency', currencyCode: 'XX' }, 'Priority') + + expect(result.errorCode).toBe('validation') + expect(mockUpdateColumnType).not.toHaveBeenCalled() + }) + + it('reports an empty payload as a validation failure', async () => { + const result = await run({}) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.error).toBe('No updates specified') + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it("names the type when a payload only restates the column's current type", async () => { + const result = await run({ type: 'select' }) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.error).toContain('is already type "select"') + expect(mockUpdateColumnType).not.toHaveBeenCalled() + }) + + it('classifies a table lock as locked and does not audit', async () => { + mockUpdateColumnConstraints.mockRejectedValue(new TableLockedError('update')) + + const result = await run({ required: true }) + + expect(result.errorCode).toBe('locked') + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('reports the code the service failure carries', async () => { + mockUpdateColumnConstraints.mockRejectedValue( + new OrchestrationError('validation', 'Column "State" already exists') + ) + + expect((await run({ required: true })).errorCode).toBe('validation') + }) + + it('classifies a missing column as not_found', async () => { + mockUpdateColumnConstraints.mockRejectedValue( + new OrchestrationError('not_found', 'Column "Nope" not found') + ) + + expect((await run({ required: true })).errorCode).toBe('not_found') + }) + + it('keeps an unclassified fault internal and hides its message', async () => { + // Wording alone must never buy a status: this reads exactly like the + // caller-fixable failure above but carries no classification. + mockUpdateColumnConstraints.mockRejectedValue(new Error('Column "State" already exists')) + + const result = await run({ required: true }) + + expect(result.errorCode).toBe('internal') + expect(result.error).toBe('Failed to update column') + }) + + it('audits a successful update on every caller', async () => { + // The UI route and the copilot tool previously emitted no audit at all. + await run({ required: true }) + + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: 'ws-1', actorId: 'user-1', resourceId: 'table-1' }) + ) + }) +}) diff --git a/apps/sim/lib/table/orchestration/columns.ts b/apps/sim/lib/table/orchestration/columns.ts new file mode 100644 index 00000000000..18d321d3e3b --- /dev/null +++ b/apps/sim/lib/table/orchestration/columns.ts @@ -0,0 +1,266 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { + OrchestrationError, + type OrchestrationErrorCode, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys' +import { columnTypeById } from '@/lib/table/column-types' +import { + renameColumn, + updateColumnConstraints, + updateColumnCurrency, + updateColumnOptions, + updateColumnType, +} from '@/lib/table/columns/service' +import { isSupportedCurrencyCode } from '@/lib/table/currency' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { normalizeSelectOptionsInput } from '@/lib/table/select-options' +import type { ColumnType, SelectOption, TableDefinition } from '@/lib/table/types' + +const logger = createLogger('TableColumnOrchestration') + +export interface PerformUpdateTableColumnParams { + table: TableDefinition + columnName: string + userId: string + updates: { + name?: string + type?: ColumnType + required?: boolean + unique?: boolean + /** Accepts `{id,name}` pairs or bare names; ids are minted/reused as needed. */ + options?: unknown + multiple?: boolean + currencyCode?: string + } + requestId?: string + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: OrchestrationRequestContext +} + +export interface PerformUpdateTableColumnResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + table?: TableDefinition +} + +function classify(error: unknown): PerformUpdateTableColumnResult { + if (error instanceof TableLockedError) { + return { success: false, error: error.message, errorCode: 'locked' } + } + if (error instanceof OrchestrationError) { + return { success: false, error: error.message, errorCode: error.code } + } + return { success: false, error: 'Failed to update column', errorCode: 'internal' } +} + +function fail(error: string, errorCode: OrchestrationErrorCode): PerformUpdateTableColumnResult { + return { success: false, error, errorCode } +} + +/** + * Applies a column update — rename, type conversion, currency re-denomination, + * option-set edit, and constraint change — as the single implementation behind + * the UI route, the v1 and v2 public APIs, and the copilot table tool. + * + * Each underlying write is its own locked transaction, so a request that spans + * several of them could commit one and then fail. Two things prevent that and + * both are load-bearing: the guards below reject every knowable failure before + * any write runs, and the rename and constraint changes ride *inside* the typed + * write's transaction rather than following it. The caller owns authentication + * and workspace scoping; by the time this runs, `table` is one the actor may write. + */ +export async function performUpdateTableColumn( + params: PerformUpdateTableColumnParams +): Promise { + const { table, columnName, userId, updates, request } = params + const requestId = params.requestId ?? generateRequestId() + const tableId = table.id + + const currentColumn = table.schema.columns.find((c) => columnMatchesRef(c, columnName)) + if (!currentColumn) { + return fail(`Column "${columnName}" not found`, 'not_found') + } + + // Address every write by the stable id, not the name: a rename folded into + // one of them must not break the next one's lookup. + const columnRef = getColumnId(currentColumn) + const existingOptions: SelectOption[] = currentColumn.options ?? [] + const options = normalizeSelectOptionsInput(updates.options, existingOptions) + + // A payload that repeats the current type must not go through + // `updateColumnType` — it early-returns on an unchanged type and would drop + // any options alongside it. Only a real type change routes there. + const typeChanging = updates.type !== undefined && updates.type !== currentColumn.type + + // A retype applies and validates the constraints itself, so the separate + // constraint write only runs when no typed write does. The rename rides + // whichever write actually runs last. + const typedWriteRuns = + typeChanging || + updates.currencyCode !== undefined || + options !== undefined || + updates.multiple !== undefined + const constraintsWriteRuns = + !typedWriteRuns && (updates.required !== undefined || updates.unique !== undefined) + const renameWithTypedWrite = + updates.name && !constraintsWriteRuns ? { newName: updates.name } : {} + + // Gate on the type the column ENDS UP with, not on whether the type is + // changing: an options-only update on an existing select column carries the + // same hazard as a conversion does. + const resultingType = updates.type ?? currentColumn.type + + if (updates.currencyCode !== undefined) { + if (resultingType !== 'currency') { + return fail( + `Cannot set currency on column "${columnName}" of type "${resultingType}"`, + 'validation' + ) + } + if (!isSupportedCurrencyCode(updates.currencyCode)) { + return fail( + `Invalid currency code "${updates.currencyCode}". Use an ISO 4217 code, e.g. USD`, + 'validation' + ) + } + } + // The rename runs last, so a name already taken would fail after the typed + // write committed. This is the only rename failure a caller can cause; + // catching it here leaves just the concurrent-collision race. + if ( + updates.name && + table.schema.columns.some( + (c) => + c.name.toLowerCase() === updates.name?.toLowerCase() && !columnMatchesRef(c, columnName) + ) + ) { + return fail(`Column "${updates.name}" already exists`, 'validation') + } + if ( + currentColumn.workflowGroupId && + (updates.required !== undefined || updates.unique !== undefined) + ) { + return fail( + `Cannot change constraints on workflow-output column "${currentColumn.name}". Constraints aren't applicable to columns whose values come from workflow execution.`, + 'validation' + ) + } + if (updates.unique === true && !columnTypeById(resultingType).supportsUnique) { + return fail(`Cannot set a ${resultingType} column as unique`, 'validation') + } + + let updated: TableDefinition | undefined + + try { + if (typeChanging) { + updated = await updateColumnType( + { + tableId, + columnName: columnRef, + newType: updates.type as ColumnType, + ...(options !== undefined ? { options } : {}), + ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), + ...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}), + // Forwarded so the conversion validates against the constraints this + // same request is about to set, not the column's current ones. + ...(updates.required !== undefined ? { required: updates.required } : {}), + ...(updates.unique !== undefined ? { unique: updates.unique } : {}), + ...renameWithTypedWrite, + }, + requestId + ) + } else if (updates.currencyCode !== undefined) { + // Re-denominating an existing currency column: schema-only, no cell + // rewrite. Reached only when the type is unchanged — a conversion INTO + // currency carries the code through `updateColumnType` above. + updated = await updateColumnCurrency( + { + tableId, + columnName: columnRef, + currencyCode: updates.currencyCode, + ...(updates.required !== undefined ? { required: updates.required } : {}), + ...(updates.unique !== undefined ? { unique: updates.unique } : {}), + ...renameWithTypedWrite, + }, + requestId + ) + } else if (options !== undefined || updates.multiple !== undefined) { + updated = await updateColumnOptions( + { + tableId, + columnName: columnRef, + options: options ?? existingOptions, + ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), + ...(updates.required !== undefined ? { required: updates.required } : {}), + ...(updates.unique !== undefined ? { unique: updates.unique } : {}), + ...renameWithTypedWrite, + }, + requestId + ) + } + + // Skipped whenever a typed write ran: that write already applied and + // validated these, in one transaction with the change they accompany. + if (constraintsWriteRuns) { + updated = await updateColumnConstraints( + { + tableId, + columnName: columnRef, + ...(updates.required !== undefined ? { required: updates.required } : {}), + ...(updates.unique !== undefined ? { unique: updates.unique } : {}), + ...(updates.name ? { newName: updates.name } : {}), + }, + requestId + ) + } + + // A rename rides along with the LAST write above, inside that write's + // transaction — a rename is metadata-only (rows key on the stable column + // id), so nothing forces it to be its own write, and folding it in is what + // stops a combined request from committing one half and then failing. Only + // a rename with nothing to ride on runs standalone. + if (updates.name && !updated) { + updated = await renameColumn( + { tableId, oldName: columnRef, newName: updates.name }, + requestId + ) + } + } catch (error) { + logger.error(`[${requestId}] Failed to update column "${columnName}" on table ${tableId}`, { + error, + }) + return classify(error) + } + + if (!updated) { + // A payload whose only content is the type the column already has names a + // change and asks for nothing. Say which, the way `updateColumnType` does + // when it loses the same race, rather than claiming the request was empty. + if (updates.type !== undefined) { + return fail( + `Column "${currentColumn.name}" is already type "${currentColumn.type}"; re-issue the request without a type change.`, + 'validation' + ) + } + return fail('No updates specified', 'validation') + } + + recordAudit({ + workspaceId: table.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Updated column "${columnName}" in table "${table.name}"`, + metadata: { columnName, updates }, + ...(request ? { request } : {}), + }) + + return { success: true, table: updated } +} diff --git a/apps/sim/lib/table/orchestration/index.ts b/apps/sim/lib/table/orchestration/index.ts index af29da6aced..b1ea82abddf 100644 --- a/apps/sim/lib/table/orchestration/index.ts +++ b/apps/sim/lib/table/orchestration/index.ts @@ -1,64 +1,9 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { generateRequestId } from '@/lib/core/utils/request' -import { getTableById, restoreTable, TableConflictError } from '@/lib/table/service' -import type { TableDefinition } from '@/lib/table/types' - -const logger = createLogger('TableOrchestration') - -export type TableOrchestrationErrorCode = 'not_found' | 'validation' | 'conflict' | 'internal' - -export interface PerformRestoreTableParams { - tableId: string - userId: string - requestId?: string -} - -export interface PerformRestoreTableResult { - success: boolean - error?: string - errorCode?: TableOrchestrationErrorCode - table?: TableDefinition -} - -export async function performRestoreTable( - params: PerformRestoreTableParams -): Promise { - const { tableId, userId } = params - const requestId = params.requestId ?? generateRequestId() - - const archivedTable = await getTableById(tableId, { includeArchived: true }) - if (!archivedTable) { - return { success: false, error: 'Table not found', errorCode: 'not_found' } - } - - try { - await restoreTable(tableId, requestId) - const table = (await getTableById(tableId)) ?? archivedTable - - logger.info(`[${requestId}] Restored table ${tableId}`) - - recordAudit({ - workspaceId: archivedTable.workspaceId, - actorId: userId, - action: AuditAction.TABLE_RESTORED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Restored table "${table.name}"`, - metadata: { - tableName: table.name, - workspaceId: table.workspaceId, - }, - }) - - return { success: true, table } - } catch (error) { - logger.error(`[${requestId}] Failed to restore table ${tableId}`, { error }) - if (error instanceof TableConflictError) { - return { success: false, error: error.message, errorCode: 'conflict' } - } - return { success: false, error: toError(error).message, errorCode: 'internal' } - } -} +export { performUpdateTableColumn } from './columns' +export { performRestoreTable } from './restore' +export { + performDeleteTable, + performDeleteTableRow, + performMoveTableToFolder, + performRenameTable, + performUpdateTableLocks, +} from './tables' diff --git a/apps/sim/lib/table/orchestration/restore.ts b/apps/sim/lib/table/orchestration/restore.ts new file mode 100644 index 00000000000..1aff4de5da2 --- /dev/null +++ b/apps/sim/lib/table/orchestration/restore.ts @@ -0,0 +1,63 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { getTableById, restoreTable, TableConflictError } from '@/lib/table/service' +import type { TableDefinition } from '@/lib/table/types' + +const logger = createLogger('TableOrchestration') + +export interface PerformRestoreTableParams { + tableId: string + userId: string + requestId?: string +} + +export interface PerformRestoreTableResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + table?: TableDefinition +} + +export async function performRestoreTable( + params: PerformRestoreTableParams +): Promise { + const { tableId, userId } = params + const requestId = params.requestId ?? generateRequestId() + + const archivedTable = await getTableById(tableId, { includeArchived: true }) + if (!archivedTable) { + return { success: false, error: 'Table not found', errorCode: 'not_found' } + } + + try { + await restoreTable(tableId, requestId) + const table = (await getTableById(tableId)) ?? archivedTable + + logger.info(`[${requestId}] Restored table ${tableId}`) + + recordAudit({ + workspaceId: archivedTable.workspaceId, + actorId: userId, + action: AuditAction.TABLE_RESTORED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Restored table "${table.name}"`, + metadata: { + tableName: table.name, + workspaceId: table.workspaceId, + }, + }) + + return { success: true, table } + } catch (error) { + logger.error(`[${requestId}] Failed to restore table ${tableId}`, { error }) + if (error instanceof TableConflictError) { + return { success: false, error: error.message, errorCode: 'conflict' } + } + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} diff --git a/apps/sim/lib/table/orchestration/tables.test.ts b/apps/sim/lib/table/orchestration/tables.test.ts new file mode 100644 index 00000000000..09127e8e40b --- /dev/null +++ b/apps/sim/lib/table/orchestration/tables.test.ts @@ -0,0 +1,142 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const { mockDeleteTable, mockDeleteRow, mockRenameTable, mockCaptureServerEvent, mockRecordAudit } = + vi.hoisted(() => ({ + mockDeleteTable: vi.fn(), + mockDeleteRow: vi.fn(), + mockRenameTable: vi.fn(), + mockCaptureServerEvent: vi.fn(), + mockRecordAudit: vi.fn(), + })) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_DELETED: 'table.deleted', TABLE_UPDATED: 'table.updated' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@/lib/table/service', () => ({ + deleteTable: mockDeleteTable, + moveTableToFolder: vi.fn(), + renameTable: mockRenameTable, + updateTableLocks: vi.fn(), +})) +vi.mock('@/lib/table/rows/service', () => ({ deleteRow: mockDeleteRow })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { + performDeleteTable, + performDeleteTableRow, + performRenameTable, +} from '@/lib/table/orchestration/tables' + +const TABLE = { id: 'table-1', name: 'Tasks', workspaceId: 'ws-1' } as unknown as TableDefinition + +describe('performDeleteTable', () => { + beforeEach(() => vi.clearAllMocks()) + + it('audits a genuine archive against the acting user', async () => { + mockDeleteTable.mockResolvedValue({ archived: { name: 'Tasks', workspaceId: 'ws-1' } }) + + const result = await performDeleteTable({ table: TABLE, userId: 'user-1', requestId: 'req-1' }) + + expect(result.success).toBe(true) + // The service no longer takes an actor — auditing follows from a user + // performing the operation, not from which function the caller reached for. + expect(mockDeleteTable).toHaveBeenCalledWith('table-1', 'req-1') + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ actorId: 'user-1', resourceId: 'table-1' }) + ) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'table_deleted', + expect.objectContaining({ table_id: 'table-1' }), + expect.anything() + ) + }) + + it('carries request provenance into the audit row', async () => { + mockDeleteTable.mockResolvedValue({ archived: { name: 'Tasks', workspaceId: 'ws-1' } }) + const request = new Request('https://sim.ai', { headers: { 'user-agent': 'curl/8' } }) + + await performDeleteTable({ table: TABLE, userId: 'user-1', request }) + + expect(mockRecordAudit).toHaveBeenCalledWith(expect.objectContaining({ request })) + }) + + it('neither audits nor reports a repeat delete of an already-archived table', async () => { + mockDeleteTable.mockResolvedValue({ archived: null }) + + const result = await performDeleteTable({ table: TABLE, userId: 'user-1' }) + + expect(result.success).toBe(true) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) + + it('classifies a delete lock as locked and emits no telemetry', async () => { + mockDeleteTable.mockRejectedValue(new TableLockedError('delete')) + + const result = await performDeleteTable({ table: TABLE, userId: 'user-1' }) + + expect(result).toMatchObject({ success: false, errorCode: 'locked' }) + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) +}) + +describe('performRenameTable', () => { + beforeEach(() => vi.clearAllMocks()) + + it('classifies a name collision as a conflict, not bad input', async () => { + // `TableConflictError` is an `OrchestrationError('conflict')` — the class + // decides the status, so the 409 no longer rides on the message wording. + mockRenameTable.mockRejectedValue( + new OrchestrationError('conflict', 'A table named "Tasks" already exists in this workspace') + ) + + const result = await performRenameTable({ table: TABLE, newName: 'Tasks', userId: 'user-1' }) + + expect(result).toMatchObject({ success: false, errorCode: 'conflict' }) + }) + + it('keeps an unclassified rename failure internal', async () => { + mockRenameTable.mockRejectedValue(new Error('A table named "Tasks" already exists')) + + expect( + (await performRenameTable({ table: TABLE, newName: 'Tasks', userId: 'user-1' })).errorCode + ).toBe('internal') + }) +}) + +describe('performDeleteTableRow', () => { + beforeEach(() => vi.clearAllMocks()) + + it('deletes through the row service so the lock and bookkeeping apply', async () => { + mockDeleteRow.mockResolvedValue(undefined) + + const result = await performDeleteTableRow({ table: TABLE, rowId: 'row-1', requestId: 'req-1' }) + + expect(result.success).toBe(true) + expect(mockDeleteRow).toHaveBeenCalledWith(TABLE, 'row-1', 'req-1') + }) + + it('classifies a delete lock as locked', async () => { + mockDeleteRow.mockRejectedValue(new TableLockedError('delete')) + + expect((await performDeleteTableRow({ table: TABLE, rowId: 'row-1' })).errorCode).toBe('locked') + }) + + it('classifies a missing row as not_found', async () => { + mockDeleteRow.mockRejectedValue(new OrchestrationError('not_found', 'Row not found')) + + expect((await performDeleteTableRow({ table: TABLE, rowId: 'row-1' })).errorCode).toBe( + 'not_found' + ) + }) +}) diff --git a/apps/sim/lib/table/orchestration/tables.ts b/apps/sim/lib/table/orchestration/tables.ts new file mode 100644 index 00000000000..dcec50a25cc --- /dev/null +++ b/apps/sim/lib/table/orchestration/tables.ts @@ -0,0 +1,274 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { + OrchestrationError, + type OrchestrationErrorCode, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { captureServerEvent } from '@/lib/posthog/server' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { deleteRow } from '@/lib/table/rows/service' +import { deleteTable, moveTableToFolder, renameTable, updateTableLocks } from '@/lib/table/service' +import { + TABLE_LOCK_FLAGS, + TABLE_LOCK_KINDS, + type TableDefinition, + type TableLocks, +} from '@/lib/table/types' + +const logger = createLogger('TableOrchestration') + +export interface PerformDeleteTableParams { + table: TableDefinition + userId: string + requestId?: string + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: OrchestrationRequestContext +} + +export interface PerformDeleteTableResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode +} + +/** + * Archives a table on behalf of `userId`. + * + * The audit lives here rather than in `deleteTable` so that auditing follows + * from "a user performed this operation", not from which function a caller + * reached for. The rollback and cleanup paths call the service directly and + * are silent by construction, and a repeat delete of an already-archived table + * logs nothing because the service reports that it archived no row. + */ +export async function performDeleteTable( + params: PerformDeleteTableParams +): Promise { + const { table, userId, request } = params + const requestId = params.requestId ?? generateRequestId() + + let archived: { name: string; workspaceId: string | null } | null + try { + ;({ archived } = await deleteTable(table.id, requestId)) + } catch (error) { + if (error instanceof TableLockedError) { + return { success: false, error: error.message, errorCode: 'locked' } + } + if (error instanceof OrchestrationError) { + return { success: false, error: error.message, errorCode: error.code } + } + logger.error(`[${requestId}] Failed to delete table ${table.id}`, { error }) + return { success: false, error: toError(error).message, errorCode: 'internal' } + } + + // Both the audit and the analytics event describe an archive that happened, so + // both hang off the same evidence that one did. A repeat delete of an + // already-archived table succeeds and records nothing. + if (archived) { + recordAudit({ + workspaceId: archived.workspaceId, + actorId: userId, + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: archived.name, + description: `Archived table "${archived.name}"`, + ...(request ? { request } : {}), + }) + captureServerEvent( + userId, + 'table_deleted', + { table_id: table.id, workspace_id: table.workspaceId }, + { groups: { workspace: table.workspaceId } } + ) + } + + return { success: true } +} + +export interface PerformDeleteTableRowParams { + table: TableDefinition + rowId: string + requestId?: string +} + +export interface PerformDeleteTableRowResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode +} + +/** + * Deletes a single row through the row service, so the delete lock is enforced + * and the row-count and ordering bookkeeping runs. A raw `db.delete` skips both + * and returns success on a locked table. + */ +export async function performDeleteTableRow( + params: PerformDeleteTableRowParams +): Promise { + const { table, rowId } = params + const requestId = params.requestId ?? generateRequestId() + + try { + await deleteRow(table, rowId, requestId) + return { success: true } + } catch (error) { + if (error instanceof TableLockedError) { + return { success: false, error: error.message, errorCode: 'locked' } + } + if (error instanceof OrchestrationError) { + return { success: false, error: error.message, errorCode: error.code } + } + logger.error(`[${requestId}] Failed to delete row ${rowId} from table ${table.id}`, { error }) + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} + +export interface PerformRenameTableParams { + table: TableDefinition + newName: string + userId: string + requestId?: string + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: OrchestrationRequestContext +} + +export interface PerformTableMutationResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + table?: TableDefinition +} + +function classifyTableMutation(error: unknown, requestId: string, tableId: string) { + if (error instanceof TableLockedError) { + return { success: false as const, error: error.message, errorCode: 'locked' as const } + } + // `TableConflictError` is an `OrchestrationError('conflict')`, so a duplicate + // rename reaches 409 through this branch — by class, not by the message + // happening to contain "already exists". + if (error instanceof OrchestrationError) { + return { success: false as const, error: error.message, errorCode: error.code } + } + logger.error(`[${requestId}] Table mutation failed for ${tableId}`, { error }) + return { + success: false as const, + error: toError(error).message, + errorCode: 'internal' as const, + } +} + +/** Renames a table and records the rename against `userId`. */ +export async function performRenameTable( + params: PerformRenameTableParams +): Promise { + const { table, newName, userId, request } = params + const requestId = params.requestId ?? generateRequestId() + + try { + const renamed = await renameTable(table.id, newName, requestId) + recordAudit({ + workspaceId: table.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: renamed.name, + description: `Renamed table to "${renamed.name}"`, + metadata: { op: 'rename', previousName: table.name }, + ...(request ? { request } : {}), + }) + return { success: true } + } catch (error) { + return classifyTableMutation(error, requestId, table.id) + } +} + +export interface PerformMoveTableParams { + table: TableDefinition + folderId: string | null + userId: string + requestId?: string + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: OrchestrationRequestContext +} + +/** Moves a table between folders (or to the workspace root). */ +export async function performMoveTableToFolder( + params: PerformMoveTableParams +): Promise { + const { table, folderId, userId, request } = params + const requestId = params.requestId ?? generateRequestId() + if (!table.workspaceId) { + return { success: false, error: 'Table is not in a workspace', errorCode: 'validation' } + } + + try { + const { name } = await moveTableToFolder(table.id, table.workspaceId, folderId, requestId) + recordAudit({ + workspaceId: table.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: name, + description: folderId + ? `Moved table "${name}" into a folder` + : `Moved table "${name}" to the workspace root`, + metadata: { op: 'move', folderId }, + ...(request ? { request } : {}), + }) + return { success: true } + } catch (error) { + return classifyTableMutation(error, requestId, table.id) + } +} + +export interface PerformUpdateTableLocksParams { + tableId: string + partial: Partial + userId: string + requestId?: string + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: OrchestrationRequestContext +} + +/** + * Applies a lock change and names the transitions in the audit description, so + * the audit list answers "who locked my production table" without expanding + * metadata. The before/after state comes back from the service because only the + * locked write can observe it. + */ +export async function performUpdateTableLocks( + params: PerformUpdateTableLocksParams +): Promise { + const { tableId, partial, userId, request } = params + const requestId = params.requestId ?? generateRequestId() + + try { + const { table, previousLocks } = await updateTableLocks(tableId, partial, requestId) + const flipped = TABLE_LOCK_KINDS.filter( + (kind) => previousLocks[TABLE_LOCK_FLAGS[kind]] !== table.locks[TABLE_LOCK_FLAGS[kind]] + ) + recordAudit({ + workspaceId: table.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: flipped.length + ? `Table locks changed: ${flipped + .map((kind) => `${kind} ${table.locks[TABLE_LOCK_FLAGS[kind]] ? 'locked' : 'unlocked'}`) + .join(', ')}` + : 'Updated table locks (no change)', + metadata: { op: 'update_locks', before: previousLocks, after: table.locks }, + ...(request ? { request } : {}), + }) + return { success: true, table } + } catch (error) { + return classifyTableMutation(error, requestId, tableId) + } +} diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index 2cf9e357b63..5637daad5b5 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -16,6 +16,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, count, eq, inArray, lte, notInArray, type SQL, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { assertRowCapacity, getMaxRowsPerTable, @@ -124,13 +125,16 @@ export async function insertRow( // Validate row size const sizeValidation = validateRowSize(data.data) if (!sizeValidation.valid) { - throw new Error(sizeValidation.errors.join(', ')) + throw new OrchestrationError('validation', sizeValidation.errors.join(', ')) } // Validate against schema const schemaValidation = coerceRowToSchema(data.data, table.schema) if (!schemaValidation.valid) { - throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Schema validation failed: ${schemaValidation.errors.join(', ')}` + ) } // Check unique constraints using optimized database query @@ -138,7 +142,7 @@ export async function insertRow( if (uniqueColumns.length > 0) { const uniqueValidation = await checkUniqueConstraintsDb(data.tableId, data.data, table.schema) if (!uniqueValidation.valid) { - throw new Error(uniqueValidation.errors.join(', ')) + throw new OrchestrationError('validation', uniqueValidation.errors.join(', ')) } } @@ -261,12 +265,18 @@ export async function batchInsertRowsWithTx( const sizeValidation = validateRowSize(row) if (!sizeValidation.valid) { - throw new Error(`Row ${i + 1}: ${sizeValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${i + 1}: ${sizeValidation.errors.join(', ')}` + ) } const schemaValidation = coerceRowToSchema(row, table.schema) if (!schemaValidation.valid) { - throw new Error(`Row ${i + 1}: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${i + 1}: ${schemaValidation.errors.join(', ')}` + ) } } @@ -282,7 +292,7 @@ export async function batchInsertRowsWithTx( const errorMessages = uniqueResult.errors .map((e) => `Row ${e.row + 1}: ${e.errors.join(', ')}`) .join('; ') - throw new Error(errorMessages) + throw new OrchestrationError('validation', errorMessages) } } @@ -422,12 +432,18 @@ export async function replaceTableRowsWithTx( const sizeValidation = validateRowSize(row) if (!sizeValidation.valid) { - throw new Error(`Row ${i + 1}: ${sizeValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${i + 1}: ${sizeValidation.errors.join(', ')}` + ) } const schemaValidation = coerceRowToSchema(row, table.schema) if (!schemaValidation.valid) { - throw new Error(`Row ${i + 1}: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${i + 1}: ${schemaValidation.errors.join(', ')}` + ) } } @@ -449,7 +465,8 @@ export async function replaceTableRowsWithTx( const normalized = typeof value === 'string' ? value : JSON.stringify(value) const map = seen.get(colId)! if (map.has(normalized)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Row ${i + 1}: Column "${col.name}" must be unique. Value "${String(value)}" duplicates row ${map.get(normalized)! + 1} in batch` ) } @@ -538,7 +555,8 @@ export async function upsertRow( const uniqueColumns = getUniqueColumns(schema) if (uniqueColumns.length === 0) { - throw new Error( + throw new OrchestrationError( + 'validation', 'Upsert requires at least one unique column in the schema. Please add a unique constraint to a column or use insert instead.' ) } @@ -552,7 +570,8 @@ export async function upsertRow( (c) => getColumnId(c) === data.conflictTarget || c.name === data.conflictTarget ) if (!col) { - throw new Error( + throw new OrchestrationError( + 'validation', `Column "${data.conflictTarget}" is not a unique column. Available unique columns: ${uniqueColumns.map((c) => c.name).join(', ')}` ) } @@ -560,7 +579,8 @@ export async function upsertRow( } else if (uniqueColumns.length === 1) { targetColumnKey = getColumnId(uniqueColumns[0]) } else { - throw new Error( + throw new OrchestrationError( + 'validation', `Table has multiple unique columns (${uniqueColumns.map((c) => c.name).join(', ')}). Specify a conflict column to indicate which one to match on.` ) } @@ -568,12 +588,15 @@ export async function upsertRow( // Validate row data const sizeValidation = validateRowSize(data.data) if (!sizeValidation.valid) { - throw new Error(sizeValidation.errors.join(', ')) + throw new OrchestrationError('validation', sizeValidation.errors.join(', ')) } const schemaValidation = coerceRowToSchema(data.data, schema) if (!schemaValidation.valid) { - throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Schema validation failed: ${schemaValidation.errors.join(', ')}` + ) } // Read the conflict-target value *after* coercion so `matchFilter` branches on @@ -583,7 +606,10 @@ export async function upsertRow( // Surface the display name, not the internal id — v1 callers pass a name. const targetColumnName = uniqueColumns.find((c) => getColumnId(c) === targetColumnKey)?.name ?? targetColumnKey - throw new Error(`Upsert requires a value for the conflict target column "${targetColumnName}"`) + throw new OrchestrationError( + 'validation', + `Upsert requires a value for the conflict target column "${targetColumnName}"` + ) } // Build the conflict probe through the SAME leaf as the unique-constraint check @@ -636,7 +662,10 @@ export async function upsertRow( trx ) if (!uniqueValidation.valid) { - throw new Error(`Unique constraint violation: ${uniqueValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Unique constraint violation: ${uniqueValidation.errors.join(', ')}` + ) } const now = new Date() @@ -1429,7 +1458,7 @@ export async function updateRow( // Get existing row const existingRow = await getRowById(data.tableId, data.rowId, data.workspaceId) if (!existingRow) { - throw new Error('Row not found') + throw new OrchestrationError('not_found', 'Row not found') } // Merge partial update with existing row data so callers can pass only changed fields @@ -1453,13 +1482,16 @@ export async function updateRow( // Validate size const sizeValidation = validateRowSize(mergedData) if (!sizeValidation.valid) { - throw new Error(sizeValidation.errors.join(', ')) + throw new OrchestrationError('validation', sizeValidation.errors.join(', ')) } // Validate against schema const schemaValidation = coerceRowToSchema(mergedData, table.schema) if (!schemaValidation.valid) { - throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Schema validation failed: ${schemaValidation.errors.join(', ')}` + ) } // Check unique constraints using optimized database query @@ -1472,7 +1504,7 @@ export async function updateRow( data.rowId // Exclude current row ) if (!uniqueValidation.valid) { - throw new Error(uniqueValidation.errors.join(', ')) + throw new OrchestrationError('validation', uniqueValidation.errors.join(', ')) } } @@ -1612,7 +1644,7 @@ export async function deleteRow( workspaceId: table.workspaceId, proof, }) - if (!deleted) throw new Error('Row not found') + if (!deleted) throw new OrchestrationError('not_found', 'Row not found') logger.info(`[${requestId}] Deleted row ${rowId} from table ${table.id}`) } @@ -1636,7 +1668,7 @@ export async function updateRowsByFilter( const filterClause = buildFilterClause(data.filter, tableName, table.schema.columns) if (!filterClause) { - throw new Error('Filter is required for bulk update') + throw new OrchestrationError('validation', 'Filter is required for bulk update') } const baseConditions = and( @@ -1678,12 +1710,18 @@ export async function updateRowsByFilter( const sizeValidation = validateRowSize(mergedData) if (!sizeValidation.valid) { - throw new Error(`Row ${row.id}: ${sizeValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${row.id}: ${sizeValidation.errors.join(', ')}` + ) } const schemaValidation = coerceRowToSchema(mergedData, table.schema) if (!schemaValidation.valid) { - throw new Error(`Row ${row.id}: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${row.id}: ${schemaValidation.errors.join(', ')}` + ) } } @@ -1691,7 +1729,8 @@ export async function updateRowsByFilter( const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in data.data) if (uniqueColumnsInUpdate.length > 0) { if (matchingRows.length > 1) { - throw new Error( + throw new OrchestrationError( + 'validation', `Cannot set unique column values when updating multiple rows. ` + `Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` + `Updating ${matchingRows.length} rows with the same value would violate uniqueness.` @@ -1708,7 +1747,10 @@ export async function updateRowsByFilter( row.id ) if (!uniqueValidation.valid) { - throw new Error(`Unique constraint violation: ${uniqueValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Unique constraint violation: ${uniqueValidation.errors.join(', ')}` + ) } } @@ -1826,7 +1868,7 @@ export async function batchUpdateRows( const missing = rowIds.filter((id) => !existingMap.has(id)) if (missing.length > 0) { - throw new Error(`Rows not found: ${missing.join(', ')}`) + throw new OrchestrationError('validation', `Rows not found: ${missing.join(', ')}`) } const mergedUpdates: Array<{ @@ -1856,12 +1898,18 @@ export async function batchUpdateRows( const sizeValidation = validateRowSize(merged) if (!sizeValidation.valid) { - throw new Error(`Row ${update.rowId}: ${sizeValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${update.rowId}: ${sizeValidation.errors.join(', ')}` + ) } const schemaValidation = coerceRowToSchema(merged, table.schema) if (!schemaValidation.valid) { - throw new Error(`Row ${update.rowId}: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${update.rowId}: ${schemaValidation.errors.join(', ')}` + ) } mergedUpdates.push({ @@ -1884,7 +1932,10 @@ export async function batchUpdateRows( rowId ) if (!uniqueValidation.valid) { - throw new Error(`Row ${rowId}: ${uniqueValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Row ${rowId}: ${uniqueValidation.errors.join(', ')}` + ) } } } @@ -2006,7 +2057,7 @@ export async function deleteRowsByFilter( // Build filter clause const filterClause = buildFilterClause(data.filter, tableName, table.schema.columns) if (!filterClause) { - throw new Error('Filter is required for bulk delete') + throw new OrchestrationError('validation', 'Filter is required for bulk delete') } // Find matching rows diff --git a/apps/sim/lib/table/select-options.test.ts b/apps/sim/lib/table/select-options.test.ts new file mode 100644 index 00000000000..36841dc9926 --- /dev/null +++ b/apps/sim/lib/table/select-options.test.ts @@ -0,0 +1,59 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { normalizeSelectOptionsInput } from '@/lib/table/select-options' + +describe('normalizeSelectOptionsInput', () => { + it('generates a stable id for a bare-name string option', () => { + const [opt] = normalizeSelectOptionsInput(['Open']) ?? [] + expect(opt.name).toBe('Open') + expect(typeof opt.id).toBe('string') + expect(opt.id.length).toBeGreaterThan(0) + }) + + it('generates an id for an object option without one', () => { + const [opt] = normalizeSelectOptionsInput([{ name: 'Closed' }]) ?? [] + expect(opt.name).toBe('Closed') + expect(opt.id.length).toBeGreaterThan(0) + }) + + it('preserves an explicitly supplied id', () => { + const result = normalizeSelectOptionsInput([{ id: 'opt_keep', name: 'Open' }]) + expect(result).toEqual([{ id: 'opt_keep', name: 'Open' }]) + }) + + it('reuses the id of an existing option with the same name', () => { + // The agent re-sends options as bare names on every edit. Minting fresh ids + // would orphan every cell holding them — silently clearing the column. + const existing = [ + { id: 'opt_low', name: 'Low' }, + { id: 'opt_high', name: 'High' }, + ] + const result = normalizeSelectOptionsInput(['Low', 'Medium', 'High'], existing) ?? [] + + expect(result[0]).toEqual({ id: 'opt_low', name: 'Low' }) + expect(result[2]).toEqual({ id: 'opt_high', name: 'High' }) + // Only the genuinely new option gets a fresh id. + expect(result[1].name).toBe('Medium') + expect(result[1].id).not.toBe('opt_low') + expect(result[1].id).not.toBe('opt_high') + }) + + it('matches an existing option name case-insensitively', () => { + const result = normalizeSelectOptionsInput(['open'], [{ id: 'opt_open', name: 'Open' }]) ?? [] + expect(result[0].id).toBe('opt_open') + expect(result[0].name).toBe('open') + }) + + it('mints a fresh id when there is no existing column to match against', () => { + const result = normalizeSelectOptionsInput(['Open']) ?? [] + expect(result[0].id.length).toBeGreaterThan(0) + expect(result[0].name).toBe('Open') + }) + + it('returns undefined for a non-array (validation rejects it downstream)', () => { + expect(normalizeSelectOptionsInput(undefined)).toBeUndefined() + expect(normalizeSelectOptionsInput('Open')).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/table/select-options.ts b/apps/sim/lib/table/select-options.ts index aa62cfa0bfd..332b465cb7d 100644 --- a/apps/sim/lib/table/select-options.ts +++ b/apps/sim/lib/table/select-options.ts @@ -12,6 +12,7 @@ * Keeping the option primitives here lets both sides share one implementation. */ +import { generateShortId } from '@sim/utils/id' import type { ColumnDefinition, JsonValue, SelectOption } from '@/lib/table/types' /** Set of valid option ids for a `select`/`multiselect` column. */ @@ -85,3 +86,34 @@ export function resolveSelectCellValue(value: JsonValue, column: ColumnDefinitio const single = Array.isArray(value) ? value[0] : value return single === undefined ? null : resolveSelectOptionId(single, options) } + +/** + * Normalizes caller-supplied select options to `{ id, name }` pairs. + * + * Cells reference the option id, so an edit that re-sends an option by name + * must reuse the id it already has — minting a fresh one would orphan every + * cell holding it, silently clearing the column. A caller that already supplies + * an id keeps it, which makes this a no-op for the fully-formed options the + * HTTP contracts accept and a repair for the name-only options agents author. + */ +export function normalizeSelectOptionsInput( + raw: unknown, + existing: SelectOption[] = [] +): SelectOption[] | undefined { + if (!Array.isArray(raw)) return undefined + + const idByName = new Map() + for (const option of existing) { + const key = option.name.toLowerCase() + if (!idByName.has(key)) idByName.set(key, option.id) + } + const resolveId = (name: string): string => idByName.get(name.toLowerCase()) ?? generateShortId() + + return raw.map((entry) => { + if (typeof entry === 'string') return { id: resolveId(entry), name: entry } + const e = (entry ?? {}) as { id?: unknown; name?: unknown } + const name = typeof e.name === 'string' ? e.name : String(e.name ?? '') + const id = typeof e.id === 'string' && e.id.length > 0 ? e.id : resolveId(name) + return { id, name } + }) +} diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 5d23ec56931..5a6f78dd84a 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -14,6 +14,7 @@ import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, count, eq, isNull, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' import { resolveRestoredFolderId } from '@/lib/folders/queries' @@ -28,8 +29,6 @@ import type { DbTransaction } from '@/lib/table/planner' import { setTableTxTimeouts } from '@/lib/table/tx' import { type CreateTableData, - TABLE_LOCK_FLAGS, - TABLE_LOCK_KINDS, type TableDefinition, type TableLocks, type TableMetadata, @@ -41,10 +40,15 @@ import { stripGroupDeps } from '@/lib/table/workflow-columns' const logger = createLogger('TableService') -export class TableConflictError extends Error { - readonly code = 'TABLE_EXISTS' as const +/** + * A table name already taken in the workspace. Kept as its own class because + * several routes branch on it specifically, and typed as a `conflict` so the + * generic classifiers reach the same 409 without reading the message. + */ +export class TableConflictError extends OrchestrationError { constructor(name: string) { - super(`A table named "${name}" already exists in this workspace`) + super('conflict', `A table named "${name}" already exists in this workspace`) + this.name = 'TableConflictError' } } @@ -103,7 +107,7 @@ export async function withLockedTable( ) const table = await getTableById(tableId, { tx: trx, includeArchived: opts?.includeArchived }) if (!table) { - throw new Error('Table not found') + throw new OrchestrationError('not_found', 'Table not found') } return mutate(table, trx) }) @@ -285,13 +289,19 @@ export async function createTable( // Validate table name const nameValidation = validateTableName(data.name) if (!nameValidation.valid) { - throw new Error(`Invalid table name: ${nameValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Invalid table name: ${nameValidation.errors.join(', ')}` + ) } // Validate schema const schemaValidation = validateTableSchema(data.schema) if (!schemaValidation.valid) { - throw new Error(`Invalid schema: ${schemaValidation.errors.join(', ')}`) + throw new OrchestrationError( + 'validation', + `Invalid schema: ${schemaValidation.errors.join(', ')}` + ) } const tableId = `tbl_${generateId().replace(/-/g, '')}` @@ -355,7 +365,12 @@ export async function createTable( ) if (Number(existingCount) >= maxTables) { - throw new Error(`Workspace has reached maximum table limit (${maxTables})`) + // A quota ceiling, not bad input — both create routes have always + // answered 403 for it. + throw new OrchestrationError( + 'forbidden', + `Workspace has reached maximum table limit (${maxTables})` + ) } const duplicateName = await trx @@ -474,23 +489,26 @@ export async function addTableColumnsWithTx( for (const column of columns) { if (!NAME_PATTERN.test(column.name)) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column name "${column.name}". Must start with a letter or underscore and contain only alphanumeric characters and underscores.` ) } if (column.name.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { - throw new Error( + throw new OrchestrationError( + 'validation', `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` ) } if (!COLUMN_TYPES.includes(column.type as (typeof COLUMN_TYPES)[number])) { - throw new Error( + throw new OrchestrationError( + 'validation', `Invalid column type "${column.type}". Must be one of: ${COLUMN_TYPES.join(', ')}` ) } const lower = column.name.toLowerCase() if (usedNames.has(lower)) { - throw new Error(`Column "${column.name}" already exists`) + throw new OrchestrationError('validation', `Column "${column.name}" already exists`) } usedNames.add(lower) // Honor a caller-assigned id (the CSV append path pre-assigns so coercion @@ -506,7 +524,8 @@ export async function addTableColumnsWithTx( } if (table.schema.columns.length + additions.length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { - throw new Error( + throw new OrchestrationError( + 'validation', `Adding ${additions.length} column(s) would exceed maximum column limit (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE})` ) } @@ -572,12 +591,11 @@ export function auditTableColumnsAdded( export async function renameTable( tableId: string, newName: string, - requestId: string, - actingUserId?: string + requestId: string ): Promise<{ id: string; name: string }> { const nameValidation = validateTableName(newName) if (!nameValidation.valid) { - throw new Error(nameValidation.errors.join(', ')) + throw new OrchestrationError('validation', nameValidation.errors.join(', ')) } const now = new Date() @@ -586,29 +604,10 @@ export async function renameTable( .update(userTableDefinitions) .set({ name: newName, updatedAt: now }) .where(eq(userTableDefinitions.id, tableId)) - .returning({ - id: userTableDefinitions.id, - createdBy: userTableDefinitions.createdBy, - workspaceId: userTableDefinitions.workspaceId, - }) + .returning({ id: userTableDefinitions.id }) if (result.length === 0) { - throw new Error(`Table ${tableId} not found`) - } - - const { createdBy, workspaceId } = result[0] - const renameActorId = actingUserId ?? createdBy - if (renameActorId) { - recordAudit({ - workspaceId: workspaceId ?? null, - actorId: renameActorId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: newName, - description: `Renamed table to "${newName}"`, - metadata: { op: 'rename' }, - }) + throw new OrchestrationError('not_found', `Table ${tableId} not found`) } logger.info(`[${requestId}] Renamed table ${tableId} to "${newName}"`) @@ -636,9 +635,8 @@ export async function moveTableToFolder( tableId: string, workspaceId: string, folderId: string | null, - requestId: string, - actingUserId?: string -): Promise { + requestId: string +): Promise<{ name: string }> { const updates: Partial = { folderId, updatedAt: new Date(), @@ -666,27 +664,13 @@ export async function moveTableToFolder( }) if (result.length === 0) { - throw new Error(`Table ${tableId} not found`) + throw new OrchestrationError('not_found', `Table ${tableId} not found`) } - const { name, createdBy } = result[0] - const actorId = actingUserId ?? createdBy - if (actorId) { - recordAudit({ - workspaceId, - actorId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: name, - description: folderId - ? `Moved table "${name}" into a folder` - : `Moved table "${name}" to the workspace root`, - metadata: { op: 'move', folderId }, - }) - } + const { name } = result[0] logger.info(`[${requestId}] Moved table ${tableId} to folder ${folderId ?? 'root'}`) + return { name } } /** @@ -703,11 +687,8 @@ export async function moveTableToFolder( export async function updateTableLocks( tableId: string, partial: Partial, - actingUserId: string, - requestId: string, - /** Forwarded to the audit record for IP / user-agent capture. */ - request?: { headers: { get(name: string): string | null } } -): Promise { + requestId: string +): Promise<{ table: TableDefinition; previousLocks: TableLocks }> { let previousLocks: TableLocks = UNLOCKED_TABLE_LOCKS const updated = await withLockedTable(tableId, async (table, trx) => { previousLocks = table.locks @@ -720,36 +701,12 @@ export async function updateTableLocks( return { ...table, locks: nextLocks, updatedAt: now } }) - // Name the transitions in the description so the audit list is readable - // without expanding metadata — "who locked my production table" is the - // question this feature exists to answer. - const flipped = TABLE_LOCK_KINDS.filter( - (kind) => previousLocks[TABLE_LOCK_FLAGS[kind]] !== updated.locks[TABLE_LOCK_FLAGS[kind]] - ) - const description = flipped.length - ? `Table locks changed: ${flipped - .map((kind) => `${kind} ${updated.locks[TABLE_LOCK_FLAGS[kind]] ? 'locked' : 'unlocked'}`) - .join(', ')}` - : 'Updated table locks (no change)' - - recordAudit({ - workspaceId: updated.workspaceId, - actorId: actingUserId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: updated.name, - description, - metadata: { op: 'update_locks', before: previousLocks, after: updated.locks }, - ...(request ? { request } : {}), - }) - await appendTableEvent({ kind: 'definition', tableId, reason: 'locks' }).catch((error) => { logger.warn(`[${requestId}] Failed to emit lock-change event for table ${tableId}`, { error }) }) logger.info(`[${requestId}] Updated locks for table ${tableId}`) - return updated + return { table: updated, previousLocks } } /** @@ -830,9 +787,8 @@ export async function updateTableMetadata( export async function deleteTable( tableId: string, requestId: string, - actingUserId?: string, options?: { archivedAt?: Date } -): Promise { +): Promise<{ archived: { name: string; workspaceId: string | null } | null }> { const now = options?.archivedAt ?? new Date() // Archiving destroys access to every row, so it is gated on the delete lock. // The guard is inline in the WHERE (atomic — no separate read, no TOCTOU); @@ -874,21 +830,10 @@ export async function deleteTable( } // Otherwise the table is missing or already archived — a silent no-op, as before. } - // Audit only genuine user deletes — rollback callers omit `actingUserId`. The - // caller emits the `table_deleted` PostHog event, so it is not duplicated here. - if (deleted && actingUserId) { - recordAudit({ - workspaceId: deleted.workspaceId ?? null, - actorId: actingUserId, - action: AuditAction.TABLE_DELETED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: deleted.name, - description: `Archived table "${deleted.name}"`, - }) - } - logger.info(`[${requestId}] Archived table ${tableId}`) + // Null when the table was missing or already archived — a silent no-op. The + // caller audits only a genuine archive, so a repeat delete logs nothing. + return { archived: deleted ? { name: deleted.name, workspaceId: deleted.workspaceId } : null } } /** @@ -907,18 +852,18 @@ export async function restoreTable( ): Promise { const table = await getTableById(tableId, { includeArchived: true }) if (!table) { - throw new Error('Table not found') + throw new OrchestrationError('not_found', 'Table not found') } if (!table.archivedAt) { - throw new Error('Table is not archived') + throw new OrchestrationError('validation', 'Table is not archived') } if (table.workspaceId) { const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') const ws = await getWorkspaceWithOwner(table.workspaceId) if (!ws || ws.archivedAt) { - throw new Error('Cannot restore table into an archived workspace') + throw new OrchestrationError('validation', 'Cannot restore table into an archived workspace') } } diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index 4054113d4e2..a2ccc24d4d6 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -18,6 +18,7 @@ import { and, eq, inArray, notInArray, sql } from 'drizzle-orm' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import type { EnqueueOptions } from '@/lib/core/async-jobs/types' import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { buildCancelledExecution } from '@/lib/table/cell-write' import type { Filter, @@ -731,8 +732,9 @@ export async function runWorkflowColumn(opts: { // this module; `@trigger.dev/sdk` is heavy and only needed on this op. const { getTableById } = await import('@/lib/table/service') const table = await getTableById(tableId) - if (!table) throw new Error('Table not found') - if (table.workspaceId !== workspaceId) throw new Error('Invalid workspace ID') + if (!table) throw new OrchestrationError('not_found', 'Table not found') + if (table.workspaceId !== workspaceId) + throw new OrchestrationError('validation', 'Invalid workspace ID') const allGroups = table.schema.workflowGroups ?? [] const targetGroups = groupIds ? allGroups.filter((g) => groupIds.includes(g.id)) : allGroups diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 8a9f49f4a14..69ce7adac44 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -7,6 +7,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { env } from '@/lib/core/config/env' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { getSocketServerUrl } from '@/lib/core/utils/urls' import { captureServerEvent } from '@/lib/posthog/server' @@ -26,7 +27,6 @@ import { notifySocketDeploymentChanged, processWorkflowDeploymentOutboxEvent, } from '@/lib/workflows/deployment-outbox' -import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' import { getWorkflowDeploymentStatus, prepareWorkflowDeployment, diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index e4903974aa4..bc1c12e8c73 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -1,8 +1,8 @@ import type { folder as folderTable } from '@sim/db/schema' import type { FolderResourceType } from '@/lib/api/contracts/folders' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { createFolder, deleteFolder, restoreFolder, updateFolder } from '@/lib/folders/lifecycle' import type { FolderMutationErrorCode } from '@/lib/folders/status' -import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' /** * Workflow-bound entry points into the generic folder engine in `lib/folders/lifecycle.ts`. diff --git a/apps/sim/lib/workflows/orchestration/types.ts b/apps/sim/lib/workflows/orchestration/types.ts deleted file mode 100644 index 70c715ffb2c..00000000000 --- a/apps/sim/lib/workflows/orchestration/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type OrchestrationErrorCode = 'validation' | 'not_found' | 'conflict' | 'internal' - -/** - * Maps an orchestration error code to its HTTP status. Shared by every route - * surface (UI, v1, tool routes) so deployment errors map identically. - */ -export function statusForOrchestrationError(code: OrchestrationErrorCode | undefined): number { - if (code === 'validation') return 400 - if (code === 'not_found') return 404 - if (code === 'conflict') return 409 - return 500 -} diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 9ccee7d5171..07588af4460 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -6,11 +6,11 @@ import { isFolderInWorkspace } from '@sim/platform-authz/workflow' import { 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' import { generateRequestId } from '@/lib/core/utils/request' import { captureServerEvent } from '@/lib/posthog/server' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { archiveWorkflow, restoreWorkflow } from '@/lib/workflows/lifecycle' -import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { deduplicateWorkflowName } from '@/lib/workflows/utils' From e34372b4b13f0b04f437f459745909819aacd7e7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 18:30:40 -0700 Subject: [PATCH 027/159] refactor(cli): output format is a profile setting, not a flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops `-o, --output`. Format is set once per profile with `sim configure --set-output `, or overridden ambiently with SIM_OUTPUT for a one-off (`SIM_OUTPUT=json sim logs list | jq`) and for CI, which already runs file-less on env alone. Both remaining sources are ambient — set once, then read by every later command — so an unrecognized value falls back to `table` rather than breaking the CLI. There is no longer a strict tier, because there is no longer anything typed per-invocation to be strict about. Frees `-o` for `sim files download -o `, which previously had to share the short flag with a global that meant something else entirely. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- packages/sim-cli/README.md | 21 +++++++++++++-------- packages/sim-cli/src/config/profile.test.ts | 17 +++++++++++++++-- packages/sim-cli/src/config/profile.ts | 7 +++++-- packages/sim-cli/src/context.ts | 2 -- packages/sim-cli/src/index.ts | 14 ++------------ 5 files changed, 35 insertions(+), 26 deletions(-) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 25ed1d82f53..bdfe45410da 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -53,7 +53,7 @@ Each setting resolves independently, first match wins: | Rank | Source | | --- | --- | -| 1 | Command-line flag (`--endpoint`, `--workspace`, `--output`) | +| 1 | Command-line flag (`--endpoint`, `--workspace`) | | 2 | Environment (`SIM_ENDPOINT`, `SIM_API_KEY`, `SIM_WORKSPACE`, `SIM_OUTPUT`) | | 3 | `~/.sim/config` / `~/.sim/credentials` for the selected profile | | 4 | Built-in default (`https://sim.ai`, `table`) | @@ -154,7 +154,9 @@ everything" default. ### Output formats -`--output` / `-o`, or `SIM_OUTPUT`, or `output =` in the profile: +Output format is a **profile setting**, not a per-command flag — there is no +`--output`. Set it once with `sim configure --set-output `, or override +ambiently with `SIM_OUTPUT` for a one-off or for CI: | Format | For | | --- | --- | @@ -169,10 +171,13 @@ duration stays `1500`, not `"1.5s"` — so switching format never changes the da parsing. ```bash -sim logs list --level error -o json | jq -r '.[].executionId' -sim logs list --level error -o yaml > logs.yaml +sim configure --set-output json # for this profile, from now on +sim configure --set-output text --profile scripts # a profile dedicated to scripting -sim files list -o text | while IFS=$'\t' read -r id name size type uploaded; do +SIM_OUTPUT=json sim logs list --level error | jq -r '.[].executionId' +SIM_OUTPUT=yaml sim logs list --level error > logs.yaml + +SIM_OUTPUT=text sim files list | while IFS=$'\t' read -r id name size type uploaded; do echo "$id $name" done ``` @@ -180,9 +185,9 @@ done An absent value is an em-dash in `table` and an **empty field** in `text`, so emptiness tests downstream behave. -A bad `--output` is an error; a bad `SIM_OUTPUT` or `output =` is ignored and -falls back to `table` — ambient settings should not brick every command, but a -flag you just typed should not be silently disregarded. +A bad `SIM_OUTPUT` or `output =` is ignored and falls back to `table`. Both are +ambient — set once, then read by every later command — so one bad value should +not break the CLI outright. ## How this stays in sync with the API diff --git a/packages/sim-cli/src/config/profile.test.ts b/packages/sim-cli/src/config/profile.test.ts index fb18c536ab6..48661750b7c 100644 --- a/packages/sim-cli/src/config/profile.test.ts +++ b/packages/sim-cli/src/config/profile.test.ts @@ -97,10 +97,23 @@ describe('profile resolution', () => { }) it('ignores an unrecognized output format instead of failing the whole resolve', () => { - // Ambient sources tolerate garbage so one bad value cannot brick every - // command; the `--output` flag is strict instead (commander `.choices`). + // Both output sources are ambient — set once, then every later command reads + // them — so a bad value falls back rather than breaking the CLI outright. process.env.SIM_OUTPUT = 'xml' expect(resolveProfile().output).toBe('table') + + process.env.SIM_OUTPUT = undefined + writeConfigProfile('default', { output: 'xml' }) + expect(resolveProfile().output).toBe('table') + }) + + it('takes the output format from the profile, and lets the env override it', () => { + // There is deliberately no `--output` flag: format is a profile setting. + writeConfigProfile('default', { output: 'yaml' }) + expect(resolveProfile()).toMatchObject({ output: 'yaml', sources: { output: 'config' } }) + + process.env.SIM_OUTPUT = 'json' + expect(resolveProfile()).toMatchObject({ output: 'json', sources: { output: 'env' } }) }) it('accepts every documented output format from the environment', () => { diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts index d2e5e85c683..48fca121498 100644 --- a/packages/sim-cli/src/config/profile.ts +++ b/packages/sim-cli/src/config/profile.ts @@ -47,7 +47,6 @@ export interface ProfileOverrides { endpoint?: string apiKey?: string workspaceId?: string - output?: string } /** @@ -186,9 +185,13 @@ export function resolveProfile(overrides: ProfileOverrides = {}): ResolvedProfil 'unset' ) + /** + * No flag tier: output format is a profile setting, not a per-command one. + * `SIM_OUTPUT` stays as the one-off escape hatch (`SIM_OUTPUT=json sim … | jq`) + * and as the file-less path for CI, but there is deliberately no `--output`. + */ const output = resolve( [ - ['flag', parseOutput(overrides.output)], ['env', parseOutput(process.env.SIM_OUTPUT)], ['config', parseOutput(config.output)], ], diff --git a/packages/sim-cli/src/context.ts b/packages/sim-cli/src/context.ts index 9e706baa404..7486100815f 100644 --- a/packages/sim-cli/src/context.ts +++ b/packages/sim-cli/src/context.ts @@ -7,7 +7,6 @@ export interface GlobalOptions { profile?: string endpoint?: string workspace?: string - output?: string } /** @@ -25,7 +24,6 @@ export function profileFrom(command: Command, extra: ProfileOverrides = {}): Res profile: globals.profile, endpoint: globals.endpoint, workspaceId: globals.workspace, - output: globals.output, ...extra, }) } diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 10a1fb7c191..84daf9104db 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import chalk from 'chalk' -import { Command, Option } from 'commander' +import { Command } from 'commander' import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth.js' import { configureCommand } from './commands/configure.js' import { filesCommand } from './commands/files.js' @@ -9,7 +9,6 @@ import { knowledgeCommand } from './commands/knowledge.js' import { logsCommand } from './commands/logs.js' import { tablesCommand } from './commands/tables.js' import { workflowsCommand } from './commands/workflows.js' -import { OUTPUT_FORMATS } from './config/index.js' import { SimApiError } from './http/client.js' const program = new Command() @@ -21,16 +20,6 @@ program .option('-p, --profile ', 'Profile to use (env: SIM_PROFILE)') .option('--endpoint ', 'Sim deployment to talk to (env: SIM_ENDPOINT)') .option('-w, --workspace ', 'Workspace to target (env: SIM_WORKSPACE)') - // `.choices` so a typo'd format is an error, not a silent fall back to - // `table`. Deliberately stricter than SIM_OUTPUT and the config file, which - // tolerate an unknown value: those are ambient and set once, and a bad one - // should not make every command fail — but a flag is an instruction just - // typed, so honouring something else is a lie. - .addOption( - new Option('-o, --output ', 'Output format (env: SIM_OUTPUT)').choices([ - ...OUTPUT_FORMATS, - ]) - ) program.addCommand(loginCommand()) program.addCommand(logoutCommand()) @@ -54,6 +43,7 @@ Examples: $ sim login --profile dev --endpoint http://localhost:3000 $ sim workflows list $ sim logs list --level error --limit 20 + $ sim configure --set-output json Output format is a profile setting $ sim knowledge search "refund policy" --kb kb_123 $ sim whoami --profile dev ` From 3e423bab386e620c19ca10c105e4dd70d366a17e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 18:40:42 -0700 Subject: [PATCH 028/159] feat(cli): runtime that builds every command from the contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the CLI contract into working commands. 43 leaves across 7 groups, up from the 6 hand-written ones — every v2 operation the contract does not hide is now reachable, including `sim tables upsert`, `sim workflows run`, and the whole tables surface. ## What the generator now emits `V2_OPERATIONS` carries a field→slot map per operation: each query/body field's kind, whether it is required, its enum values, and its server-side default. Types alone could not drive this — the runtime has to *iterate* fields to build flags, and everything from argv arrives as a string, so it needs the kind to turn "50" into 50 and '{"a":1}' into an object. It also lifts each operation's one-line `summary` from the OpenAPI specs. The contracts carry validation, not prose, so `--help` had been showing raw URLs; the specs already hold a written summary per operation and `check:openapi` guarantees one exists, so this reuses documentation rather than inventing a second place to describe the same endpoint. ## The runtime `derive.ts` names a command ` [sub-resource] ` from the route, covering 41 of 47. `request.ts` assembles the call: path params from positional args, `workspaceId` injected from the profile into whichever slot declares it, everything else coerced and validated locally — so a bad enum, malformed JSON, missing required flag, or absent workspace fails before any network call. `build.ts` constructs the commander tree, auto-pages cursor lists up to `--limit` (0 for everything), and renders through the contract's columns or, for runtime-shaped rows, keys unioned across the page. Fixed while wiring: `new Command('upsert ')` makes the *whole string* the command name, so `sim tables upsert` never matched and fell through to the group's help. Arguments have to be declared with `.argument()`. ## What stays hand-written Two leaves, each for a reason generation cannot satisfy in principle: `files download` streams binary rather than the JSON envelope, and `tables rows list` discovers columns from user-defined row data nested under `data`. They attach onto the generated groups, so `sim files --help` lists them alongside the rest. The five previous command files are deleted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- packages/sim-cli/src/commands/files.ts | 122 ------- packages/sim-cli/src/commands/hand-written.ts | 152 +++++++++ packages/sim-cli/src/commands/knowledge.ts | 142 -------- packages/sim-cli/src/commands/logs.ts | 138 -------- packages/sim-cli/src/commands/tables.ts | 262 -------------- packages/sim-cli/src/commands/workflows.ts | 133 -------- packages/sim-cli/src/generated/v2-api.ts | 323 +++++++++++++++++- packages/sim-cli/src/index.ts | 30 +- packages/sim-cli/src/runtime/build.ts | 298 ++++++++++++++++ packages/sim-cli/src/runtime/derive.ts | 58 ++++ packages/sim-cli/src/runtime/request.test.ts | 110 ++++++ packages/sim-cli/src/runtime/request.ts | 164 +++++++++ scripts/generate-v2-cli-api.ts | 150 +++++++- 13 files changed, 1264 insertions(+), 818 deletions(-) delete mode 100644 packages/sim-cli/src/commands/files.ts create mode 100644 packages/sim-cli/src/commands/hand-written.ts delete mode 100644 packages/sim-cli/src/commands/knowledge.ts delete mode 100644 packages/sim-cli/src/commands/logs.ts delete mode 100644 packages/sim-cli/src/commands/tables.ts delete mode 100644 packages/sim-cli/src/commands/workflows.ts create mode 100644 packages/sim-cli/src/runtime/build.ts create mode 100644 packages/sim-cli/src/runtime/derive.ts create mode 100644 packages/sim-cli/src/runtime/request.test.ts create mode 100644 packages/sim-cli/src/runtime/request.ts diff --git a/packages/sim-cli/src/commands/files.ts b/packages/sim-cli/src/commands/files.ts deleted file mode 100644 index 3433d8b89c2..00000000000 --- a/packages/sim-cli/src/commands/files.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { once } from 'node:events' -import { createWriteStream, type WriteStream } from 'node:fs' -import { basename } from 'node:path' -import chalk from 'chalk' -import { Command } from 'commander' -import { clientFrom } from '../context.js' -import type { ListFilesResponse } from '../generated/v2-api.js' -import { SimApiError } from '../http/client.js' -import { bytes, type Column, printList, timestamp } from '../output/render.js' - -type WorkspaceFile = ListFilesResponse['data'][number] - -/** - * Streams a fetch body to disk, honouring backpressure. - * - * Written as an explicit reader loop rather than `Readable.fromWeb`: the DOM - * `ReadableStream` that `fetch` returns and the one `node:stream/web` declares - * are structurally incompatible under this TS config, and bridging them needs a - * cast that would erase exactly the typing this loop keeps honest. - */ -async function streamToFile(body: ReadableStream, file: WriteStream): Promise { - const reader = body.getReader() - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - // `write` returning false means the internal buffer is full; waiting for - // `drain` is what stops a large file from being buffered in memory. - if (!file.write(value)) await once(file, 'drain') - } - } finally { - reader.releaseLock() - } - - await new Promise((resolve, reject) => { - file.once('error', reject) - file.end(resolve) - }) -} - -const LIST_COLUMNS: Column[] = [ - { header: 'id', value: (file) => file.id }, - { header: 'name', value: (file) => file.name }, - { header: 'size', value: (file) => bytes(file.size) }, - { header: 'type', value: (file) => file.type }, - { header: 'uploaded', value: (file) => timestamp(file.uploadedAt) }, -] - -export function filesCommand(): Command { - const files = new Command('files').alias('file').description('List and download workspace files') - - files - .command('list') - .alias('ls') - .description('List files in a workspace') - .option('--limit ', 'Maximum files to return', '100') - .action(async (options: { limit: string }, command: Command) => { - const { client, profile } = clientFrom(command) - const limit = Number.parseInt(options.limit, 10) - - const rows = await client.collect( - '/api/v2/files', - { query: { workspaceId: client.requireWorkspace(), limit: Math.min(limit, 1000) } }, - limit - ) - - printList(profile.output, rows, LIST_COLUMNS) - }) - - files - .command('download ') - .description('Download a file') - .option('-o, --output-file ', 'Where to write it (defaults to the file name)') - .action(async (fileId: string, options: { outputFile?: string }, command: Command) => { - const { client, profile } = clientFrom(command) - const workspaceId = client.requireWorkspace() - - if (!profile.apiKey) { - throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0) - } - - // Streamed rather than routed through the JSON client: the response is - // binary of unbounded size, so buffering it just to write it out would put - // the whole file in memory. - const url = new URL(`${profile.endpoint}/api/v2/files/${fileId}`) - url.searchParams.set('workspaceId', workspaceId) - - const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } }) - if (!response.ok || !response.body) { - const raw = await response.text().catch(() => '') - throw new SimApiError( - raw || `Download failed with status ${response.status}`, - response.status - ) - } - - const target = - options.outputFile ?? - basename( - // `filename="…"` from the route's content-disposition, when present. - /filename="?([^";]+)"?/.exec(response.headers.get('content-disposition') ?? '')?.[1] ?? - fileId - ) - - await streamToFile(response.body, createWriteStream(target)) - console.log(chalk.green(`✓ Saved ${target}`)) - }) - - files - .command('delete ') - .description('Archive a file') - .action(async (fileId: string, _options: unknown, command: Command) => { - const { client } = clientFrom(command) - await client.getData(`/api/v2/files/${fileId}`, { - method: 'DELETE', - query: { workspaceId: client.requireWorkspace() }, - }) - console.log(chalk.green(`✓ Deleted ${fileId}`)) - }) - - return files -} diff --git a/packages/sim-cli/src/commands/hand-written.ts b/packages/sim-cli/src/commands/hand-written.ts new file mode 100644 index 00000000000..1a107c96cf1 --- /dev/null +++ b/packages/sim-cli/src/commands/hand-written.ts @@ -0,0 +1,152 @@ +import { once } from 'node:events' +import { createWriteStream, type WriteStream } from 'node:fs' +import { basename } from 'node:path' +import chalk from 'chalk' +import type { Command } from 'commander' +import { clientFrom } from '../context.js' +import type { QueryRowsResponse } from '../generated/v2-api.js' +import { SimApiError } from '../http/client.js' +import { type Column, printList, text } from '../output/render.js' + +/** + * Commands the generated runtime cannot produce. + * + * Kept deliberately small — each entry needs a reason that generation could not + * satisfy even in principle, not merely "not migrated yet". They attach onto the + * groups the runtime already built, so `sim files --help` lists them alongside + * the generated leaves rather than in a second group. + */ + +type Row = QueryRowsResponse['data'][number] + +/** + * Streams a fetch body to disk, honouring backpressure. + * + * An explicit reader loop rather than `Readable.fromWeb`: the DOM + * `ReadableStream` that `fetch` returns and the one `node:stream/web` declares + * are structurally incompatible under this TS config, and bridging them needs a + * cast that would erase exactly the typing this keeps honest. + */ +async function streamToFile(body: ReadableStream, file: WriteStream): Promise { + const reader = body.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + // `write` returning false means the buffer is full; waiting for `drain` is + // what stops a large file being buffered entirely in memory. + if (!file.write(value)) await once(file, 'drain') + } + } finally { + reader.releaseLock() + } + + await new Promise((resolve, reject) => { + file.once('error', reject) + file.end(resolve) + }) +} + +/** + * Row `data` is name-keyed and user-defined, so columns exist only at runtime. + * Keys are unioned across the page rather than read off the first row — a + * sparse row would otherwise hide every column it happens to omit. + */ +function rowColumns(rows: Row[]): Column[] { + const keys: string[] = [] + const seen = new Set() + for (const row of rows) { + for (const key of Object.keys(row.data)) { + if (seen.has(key)) continue + seen.add(key) + keys.push(key) + } + } + + return [ + { header: 'id', value: (row) => row.id }, + ...keys.map((key) => ({ + header: key, + value: (row: Row) => { + const value = row.data[key] + if (value === null || value === undefined) return text(null) + return typeof value === 'object' ? JSON.stringify(value) : String(value) + }, + })), + ] +} + +function group(program: Command, name: string): Command { + const existing = program.commands.find((command) => command.name() === name) + if (existing) return existing + const created = program.command(name) + return created +} + +export function attachHandWritten(program: Command): void { + // ── files download ── the response is binary, not the JSON envelope ──────── + group(program, 'files') + .command('download ') + .description('Download a file') + .option('-o, --output-file ', 'Where to write it (defaults to the file name)') + .action(async (fileId: string, options: { outputFile?: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + + if (!profile.apiKey) { + throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0) + } + + const url = new URL(`${profile.endpoint}/api/v2/files/${encodeURIComponent(fileId)}`) + url.searchParams.set('workspaceId', workspaceId) + + const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } }) + if (!response.ok || !response.body) { + const raw = await response.text().catch(() => '') + throw new SimApiError( + raw || `Download failed with status ${response.status}`, + response.status + ) + } + + const target = + options.outputFile ?? + basename( + /filename="?([^";]+)"?/.exec(response.headers.get('content-disposition') ?? '')?.[1] ?? + fileId + ) + + await streamToFile(response.body, createWriteStream(target)) + console.log(chalk.green(`✓ Saved ${target}`)) + }) + + // ── tables rows list ── columns come from user-defined row data ─────────── + const tables = group(program, 'tables') + const rows = + tables.commands.find((command) => command.name() === 'rows') ?? tables.command('rows') + rows + .command('list ') + .description('List rows, with columns discovered from the data') + .option('--limit ', 'Maximum rows to return (0 for everything)', '100') + .action(async (tableId: string, options: { limit: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const parsed = Number.parseInt(options.limit, 10) + if (Number.isNaN(parsed) || parsed < 0) { + throw new SimApiError('--limit must be a non-negative number', 0) + } + const limit = parsed === 0 ? Number.POSITIVE_INFINITY : parsed + + const collected: Row[] = [] + let cursor: string | null = null + do { + const page = (await client.request(`/api/v2/tables/${encodeURIComponent(tableId)}/rows`, { + query: { workspaceId: client.requireWorkspace(), cursor }, + })) as QueryRowsResponse + collected.push(...page.data) + cursor = page.nextCursor + } while (cursor && collected.length < limit) + + const page = Number.isFinite(limit) ? collected.slice(0, limit) : collected + printList(profile.output, page, rowColumns(page)) + }) +} diff --git a/packages/sim-cli/src/commands/knowledge.ts b/packages/sim-cli/src/commands/knowledge.ts deleted file mode 100644 index 130a7e02394..00000000000 --- a/packages/sim-cli/src/commands/knowledge.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { Command } from 'commander' -import { clientFrom } from '../context.js' -import type { - ListKnowledgeBasesResponse, - ListKnowledgeDocumentsResponse, - SearchKnowledgeResponse, -} from '../generated/v2-api.js' -import { bytes, type Column, printList, printRecord, text, timestamp } from '../output/render.js' - -type KnowledgeBase = ListKnowledgeBasesResponse['data'][number] -type KnowledgeDocument = ListKnowledgeDocumentsResponse['data'][number] -type SearchHit = SearchKnowledgeResponse['data']['results'][number] - -const BASE_COLUMNS: Column[] = [ - { header: 'id', value: (kb) => kb.id }, - { header: 'name', value: (kb) => kb.name }, - { header: 'docs', value: (kb) => String(kb.docCount) }, - { header: 'tokens', value: (kb) => String(kb.tokenCount) }, - { header: 'model', value: (kb) => kb.embeddingModel }, -] - -const DOCUMENT_COLUMNS: Column[] = [ - { header: 'id', value: (doc) => doc.id }, - { header: 'filename', value: (doc) => doc.filename }, - { header: 'size', value: (doc) => bytes(doc.fileSize) }, - { header: 'status', value: (doc) => doc.processingStatus }, - { header: 'chunks', value: (doc) => String(doc.chunkCount) }, - { header: 'created', value: (doc) => timestamp(doc.createdAt) }, -] - -/** Search hits are long prose; keep the table readable and single-line. */ -function preview(content: string): string { - const collapsed = content.replace(/\s+/g, ' ').trim() - return collapsed.length <= 80 ? collapsed : `${collapsed.slice(0, 79)}…` -} - -export function knowledgeCommand(): Command { - const knowledge = new Command('knowledge') - .alias('kb') - .description('Browse and search knowledge bases') - - knowledge - .command('list') - .alias('ls') - .description('List knowledge bases in a workspace') - .action(async (_options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const page = await client.getPage('/api/v2/knowledge', { - query: { workspaceId: client.requireWorkspace() }, - }) - printList(profile.output, page.data, BASE_COLUMNS) - }) - - knowledge - .command('get ') - .description('Show one knowledge base') - .action(async (id: string, _options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const { knowledgeBase } = await client.getData<{ knowledgeBase: KnowledgeBase }>( - `/api/v2/knowledge/${id}`, - { query: { workspaceId: client.requireWorkspace() } } - ) - - printRecord( - profile.output, - [ - ['ID', knowledgeBase.id], - ['Name', knowledgeBase.name], - ['Description', text(knowledgeBase.description)], - ['Documents', String(knowledgeBase.docCount)], - ['Tokens', String(knowledgeBase.tokenCount)], - ['Embedding model', knowledgeBase.embeddingModel], - ['Updated', timestamp(knowledgeBase.updatedAt)], - ], - knowledgeBase - ) - }) - - knowledge - .command('documents ') - .alias('docs') - .description('List the documents in a knowledge base') - .option('--search ', 'Filter by filename') - .option('--status ', 'Filter by enabled state: all, enabled, or disabled', 'all') - .option('--limit ', 'Maximum documents to return', '50') - .action( - async ( - id: string, - options: { search?: string; status: string; limit: string }, - command: Command - ) => { - const { client, profile } = clientFrom(command) - const limit = Number.parseInt(options.limit, 10) - - const rows = await client.collect( - `/api/v2/knowledge/${id}/documents`, - { - query: { - workspaceId: client.requireWorkspace(), - search: options.search, - enabledFilter: options.status, - limit: Math.min(limit, 100), - }, - }, - limit - ) - - printList(profile.output, rows, DOCUMENT_COLUMNS) - } - ) - - knowledge - .command('search ') - .description('Vector-search one or more knowledge bases') - .requiredOption('--kb ', 'Knowledge base ids to search') - .option('--top-k ', 'Number of hits to return', '10') - .action(async (query: string, options: { kb: string[]; topK: string }, command: Command) => { - const { client, profile } = clientFrom(command) - - const result = await client.getData<{ results: SearchHit[]; totalResults: number }>( - '/api/v2/knowledge/search', - { - method: 'POST', - body: { - workspaceId: client.requireWorkspace(), - knowledgeBaseIds: options.kb, - query, - topK: Number.parseInt(options.topK, 10), - }, - } - ) - - printList(profile.output, result.results, [ - { header: 'score', value: (hit) => hit.similarity.toFixed(3) }, - { header: 'document', value: (hit) => text(hit.documentName ?? hit.documentId) }, - { header: 'chunk', value: (hit) => String(hit.chunkIndex) }, - { header: 'content', value: (hit) => preview(hit.content) }, - ]) - }) - - return knowledge -} diff --git a/packages/sim-cli/src/commands/logs.ts b/packages/sim-cli/src/commands/logs.ts deleted file mode 100644 index 47525e925c5..00000000000 --- a/packages/sim-cli/src/commands/logs.ts +++ /dev/null @@ -1,138 +0,0 @@ -import chalk from 'chalk' -import { Command } from 'commander' -import { clientFrom } from '../context.js' -import type { GetExecutionResponse, GetLogResponse, ListLogsResponse } from '../generated/v2-api.js' -import { type Column, duration, printList, printRecord, text, timestamp } from '../output/render.js' - -type LogListItem = ListLogsResponse['data'][number] -type LogDetail = GetLogResponse['data'] -type ExecutionDetail = GetExecutionResponse['data'] - -function level(value: string): string { - return value === 'error' ? chalk.red(value) : value -} - -function cost(value: { total: number } | null): string { - return value ? `$${value.total.toFixed(4)}` : text(null) -} - -const LIST_COLUMNS: Column[] = [ - { header: 'started', value: (log) => timestamp(log.startedAt) }, - { header: 'level', value: (log) => level(log.level) }, - { header: 'trigger', value: (log) => log.trigger }, - { header: 'workflow', value: (log) => text(log.workflow?.name ?? log.workflowId) }, - { header: 'duration', value: (log) => duration(log.totalDurationMs) }, - { header: 'cost', value: (log) => cost(log.cost) }, - { header: 'execution', value: (log) => log.executionId }, -] - -export function logsCommand(): Command { - const logs = new Command('logs').alias('log').description('Read workflow execution logs') - - logs - .command('list') - .alias('ls') - .description('List execution logs in a workspace') - .option('--workflow ', 'Restrict to these workflow ids') - .option('--trigger ', 'Restrict to these triggers (api, schedule, webhook, manual…)') - .option('--level ', 'Filter by level: info or error') - .option('--execution ', 'Restrict to a single execution id') - .option('--start ', 'Only runs starting at or after this ISO date') - .option('--end ', 'Only runs starting at or before this ISO date') - .option('--order ', 'Sort by start time: desc or asc', 'desc') - .option('--limit ', 'Maximum logs to return', '50') - .action( - async ( - options: { - workflow?: string[] - trigger?: string[] - level?: string - execution?: string - start?: string - end?: string - order: string - limit: string - }, - command: Command - ) => { - const { client, profile } = clientFrom(command) - const limit = Number.parseInt(options.limit, 10) - - const rows = await client.collect( - '/api/v2/logs', - { - query: { - workspaceId: client.requireWorkspace(), - // The route takes these as comma-joined strings, not repeated params. - workflowIds: options.workflow?.join(','), - triggers: options.trigger?.join(','), - level: options.level, - executionId: options.execution, - startDate: options.start, - endDate: options.end, - order: options.order, - details: 'full', - limit: Math.min(limit, 1000), - }, - }, - limit - ) - - printList(profile.output, rows, LIST_COLUMNS) - } - ) - - logs - .command('get ') - .description('Show one log, including its execution trace') - .action(async (id: string, _options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const log = await client.getData(`/api/v2/logs/${id}`) - - printRecord( - profile.output, - [ - ['ID', log.id], - ['Execution', log.executionId], - ['Workflow', text(log.workflow?.name ?? log.workflowId)], - ['Level', level(log.level)], - ['Trigger', log.trigger], - ['Started', timestamp(log.startedAt)], - ['Ended', timestamp(log.endedAt)], - ['Duration', duration(log.totalDurationMs)], - ['Cost', cost(log.cost)], - ], - log - ) - - if (profile.output === 'table') { - console.log(chalk.dim('\nRun with --output json to see the full execution trace.')) - } - }) - - logs - .command('execution ') - .description('Show the workflow state snapshot for an execution') - .action(async (executionId: string, _options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const execution = await client.getData( - `/api/v2/logs/executions/${executionId}` - ) - - printRecord( - profile.output, - [ - ['Execution', execution.executionId], - ['Workflow', text(execution.workflowId)], - ['Trigger', execution.executionMetadata.trigger], - ['Started', timestamp(execution.executionMetadata.startedAt)], - ['Ended', timestamp(execution.executionMetadata.endedAt)], - ['Duration', duration(execution.executionMetadata.totalDurationMs)], - ['Cost', cost(execution.executionMetadata.cost)], - ], - execution - ) - }) - - return logs -} diff --git a/packages/sim-cli/src/commands/tables.ts b/packages/sim-cli/src/commands/tables.ts deleted file mode 100644 index 9362d7ded17..00000000000 --- a/packages/sim-cli/src/commands/tables.ts +++ /dev/null @@ -1,262 +0,0 @@ -import chalk from 'chalk' -import { Command } from 'commander' -import { clientFrom } from '../context.js' -import type { - CreateTableRowsResponse, - DeleteTableRowsResponse, - GetTableResponse, - ListTablesResponse, - QueryRowsResponse, -} from '../generated/v2-api.js' -import { SimApiError } from '../http/client.js' -import { type Column, printList, printRecord, text, timestamp } from '../output/render.js' - -type Table = ListTablesResponse['data'][number] -type TableColumn = Table['schema']['columns'][number] -type Row = QueryRowsResponse['data'][number] - -const TABLE_COLUMNS: Column
[] = [ - { header: 'id', value: (t) => t.id }, - { header: 'name', value: (t) => t.name }, - { header: 'rows', value: (t) => `${t.rowCount}${t.maxRows ? ` / ${t.maxRows}` : ''}` }, - { header: 'columns', value: (t) => String(t.schema.columns.length) }, - { header: 'updated', value: (t) => timestamp(t.updatedAt) }, -] - -const COLUMN_COLUMNS: Column[] = [ - { header: 'name', value: (c) => c.name }, - { header: 'type', value: (c) => c.type }, - { header: 'required', value: (c) => (c.required ? 'yes' : '') }, - { header: 'unique', value: (c) => (c.unique ? 'yes' : '') }, - { header: 'options', value: (c) => (c.options ?? []).map((o) => o.name).join(', ') }, -] - -/** - * Parses a `--filter` / `--data` argument. - * - * The predicate grammar is a nested object (`{all|any: [{field, op, value}]}`), - * which has no honest flag encoding — so it is passed as JSON and the parse - * error names the flag rather than surfacing a bare `SyntaxError`. - */ -function parseJsonArg(value: string, flag: string): unknown { - try { - return JSON.parse(value) - } catch (error) { - throw new SimApiError(`${flag} must be valid JSON: ${(error as Error).message}`, 0) - } -} - -/** `name:desc` / `name` → the wire sort spec. */ -function parseSort(specs: string[]): Array<{ field: string; direction: 'asc' | 'desc' }> { - return specs.map((spec) => { - const [field, direction = 'asc'] = spec.split(':') - if (direction !== 'asc' && direction !== 'desc') { - throw new SimApiError(`Sort direction must be asc or desc, got "${direction}"`, 0) - } - if (!field) throw new SimApiError(`Invalid --sort value "${spec}"`, 0) - return { field, direction } - }) -} - -/** - * Row `data` is name-keyed and user-defined, so the columns are only known at - * runtime. Union the keys across the page rather than trusting the first row — - * a sparse row would otherwise hide every column it happens to omit. - */ -function rowColumns(rows: Row[]): Column[] { - const keys: string[] = [] - const seen = new Set() - for (const row of rows) { - for (const key of Object.keys(row.data)) { - if (!seen.has(key)) { - seen.add(key) - keys.push(key) - } - } - } - - return [ - { header: 'id', value: (row) => row.id }, - ...keys.map((key) => ({ - header: key, - value: (row: Row) => { - const value = row.data[key] - if (value === null || value === undefined) return text(null) - return typeof value === 'object' ? JSON.stringify(value) : String(value) - }, - })), - ] -} - -export function tablesCommand(): Command { - const tables = new Command('tables').alias('table').description('Browse and edit tables') - - tables - .command('list') - .alias('ls') - .description('List tables in a workspace') - .action(async (_options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const result = (await client.call('listTables', { - query: { workspaceId: client.requireWorkspace() }, - })) as ListTablesResponse - printList(profile.output, result.data, TABLE_COLUMNS) - }) - - tables - .command('get ') - .description('Show a table and its schema') - .action(async (tableId: string, _options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const result = (await client.call('getTable', { - pathParams: { tableId }, - query: { workspaceId: client.requireWorkspace() }, - })) as GetTableResponse - const { table } = result.data - - printRecord( - profile.output, - [ - ['ID', table.id], - ['Name', table.name], - ['Description', text(table.description)], - ['Rows', `${table.rowCount}${table.maxRows ? ` / ${table.maxRows}` : ''}`], - ['Columns', table.schema.columns.map((c) => `${c.name}:${c.type}`).join(', ')], - ['Updated', timestamp(table.updatedAt)], - ], - table - ) - }) - - tables - .command('columns ') - .description("Show a table's columns") - .action(async (tableId: string, _options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const result = (await client.call('getTable', { - pathParams: { tableId }, - query: { workspaceId: client.requireWorkspace() }, - })) as GetTableResponse - printList(profile.output, result.data.table.schema.columns, COLUMN_COLUMNS) - }) - - tables - .command('rows ') - .description('List rows, optionally filtered with the predicate grammar') - .option( - '--filter ', - 'Predicate tree, e.g. \'{"all":[{"field":"status","op":"eq","value":"open"}]}\'' - ) - .option('--sort ', 'Sort spec, e.g. --sort created_at:desc') - .option('--limit ', 'Maximum rows to return', '100') - .action( - async ( - tableId: string, - options: { filter?: string; sort?: string[]; limit: string }, - command: Command - ) => { - const { client, profile } = clientFrom(command) - const workspaceId = client.requireWorkspace() - const limit = Number.parseInt(options.limit, 10) - - const rows: Row[] = [] - let cursor: string | null = null - - // Always the POST query endpoint, even unfiltered: it is the only shape - // that carries the predicate, so one path covers both cases instead of - // two that could format rows differently. - do { - const page = (await client.call('queryRows', { - pathParams: { tableId }, - body: { - workspaceId, - ...(options.filter ? { predicate: parseJsonArg(options.filter, '--filter') } : {}), - ...(options.sort ? { sort: parseSort(options.sort) } : {}), - limit: Math.min(limit, 1000), - ...(cursor ? { cursor } : {}), - }, - })) as QueryRowsResponse - rows.push(...page.data) - cursor = page.nextCursor - } while (cursor && rows.length < limit) - - printList(profile.output, rows.slice(0, limit), rowColumns(rows)) - } - ) - - tables - .command('insert ') - .description('Insert a row') - .requiredOption('--data ', 'Row data, e.g. \'{"name":"Ada","score":9}\'') - .action(async (tableId: string, options: { data: string }, command: Command) => { - const { client, profile } = clientFrom(command) - const result = (await client.call('createTableRows', { - pathParams: { tableId }, - body: { - workspaceId: client.requireWorkspace(), - data: parseJsonArg(options.data, '--data'), - }, - })) as CreateTableRowsResponse - - if (profile.output === 'json') { - console.log(JSON.stringify(result, null, 2)) - return - } - const inserted = 'row' in result.data ? 1 : result.data.rows.length - console.log(chalk.green(`✓ Inserted ${inserted} row${inserted === 1 ? '' : 's'}`)) - }) - - tables - .command('delete-rows ') - .description('Delete rows by id or filter') - .option('--row ', 'Row ids to delete') - .option('--filter ', 'Predicate tree selecting the rows to delete') - .option('-y, --yes', 'Skip the confirmation') - .action( - async ( - tableId: string, - options: { row?: string[]; filter?: string; yes?: boolean }, - command: Command - ) => { - const { client, profile } = clientFrom(command) - - if (!options.row && !options.filter) { - // Without this, an argument-less call would delete the whole table. - throw new SimApiError( - 'Pass --row or --filter to choose what to delete.', - 0 - ) - } - - if (!options.yes) { - const target = options.row - ? `${options.row.length} row${options.row.length === 1 ? '' : 's'}` - : 'every row matching the filter' - throw new SimApiError( - `This deletes ${target} from ${tableId} and cannot be undone. Re-run with --yes to confirm.`, - 0 - ) - } - - const result = (await client.call('deleteTableRows', { - pathParams: { tableId }, - body: { - workspaceId: client.requireWorkspace(), - ...(options.row ? { rowIds: options.row } : {}), - ...(options.filter ? { filter: parseJsonArg(options.filter, '--filter') } : {}), - }, - })) as DeleteTableRowsResponse - - if (profile.output === 'json') { - console.log(JSON.stringify(result, null, 2)) - return - } - console.log(chalk.green(`✓ Deleted ${result.data.deletedCount} row(s)`)) - if (result.data.missingRowIds?.length) { - console.log(chalk.dim(` Not found: ${result.data.missingRowIds.join(', ')}`)) - } - } - ) - - return tables -} diff --git a/packages/sim-cli/src/commands/workflows.ts b/packages/sim-cli/src/commands/workflows.ts deleted file mode 100644 index fcedfd790d0..00000000000 --- a/packages/sim-cli/src/commands/workflows.ts +++ /dev/null @@ -1,133 +0,0 @@ -import chalk from 'chalk' -import { Command } from 'commander' -import { clientFrom } from '../context.js' -import type { GetWorkflowResponse, ListWorkflowsResponse } from '../generated/v2-api.js' -import { bool, type Column, printList, printRecord, text, timestamp } from '../output/render.js' - -type WorkflowListItem = ListWorkflowsResponse['data'][number] -type WorkflowDetail = GetWorkflowResponse['data'] - -const LIST_COLUMNS: Column[] = [ - { header: 'id', value: (w) => w.id }, - { header: 'name', value: (w) => w.name }, - { header: 'deployed', value: (w) => bool(w.isDeployed) }, - { header: 'runs', value: (w) => String(w.runCount) }, - { header: 'last run', value: (w) => timestamp(w.lastRunAt) }, -] - -export function workflowsCommand(): Command { - const workflows = new Command('workflows') - .alias('workflow') - .description('List and manage workflows') - - workflows - .command('list') - .alias('ls') - .description('List workflows in a workspace') - .option('--folder ', 'Only workflows in this folder') - .option('--deployed', 'Only deployed workflows') - .option('--limit ', 'Maximum workflows to return', '50') - .action( - async (options: { folder?: string; deployed?: boolean; limit: string }, command: Command) => { - const { client, profile } = clientFrom(command) - const limit = Number.parseInt(options.limit, 10) - - const rows = await client.collect( - '/api/v2/workflows', - { - query: { - workspaceId: client.requireWorkspace(), - folderId: options.folder, - deployedOnly: options.deployed ? 'true' : undefined, - // The route caps a page at 100; `collect` pages past that up to `limit`. - limit: Math.min(limit, 100), - }, - }, - limit - ) - - printList(profile.output, rows, LIST_COLUMNS) - } - ) - - workflows - .command('get ') - .description('Show one workflow, including its trigger inputs') - .action(async (id: string, _options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const workflow = await client.getData(`/api/v2/workflows/${id}`) - - printRecord( - profile.output, - [ - ['ID', workflow.id], - ['Name', workflow.name], - ['Description', text(workflow.description)], - ['Workspace', workflow.workspaceId], - ['Folder', text(workflow.folderId)], - ['Deployed', bool(workflow.isDeployed)], - ['Deployed at', timestamp(workflow.deployedAt)], - ['Runs', String(workflow.runCount)], - ['Last run', timestamp(workflow.lastRunAt)], - [ - 'Inputs', - workflow.inputs.length > 0 - ? workflow.inputs.map((input) => `${input.name}:${input.type}`).join(', ') - : text(null), - ], - ['Updated', timestamp(workflow.updatedAt)], - ], - workflow - ) - }) - - workflows - .command('deploy ') - .description('Deploy a workflow') - .action(async (id: string, _options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const result = await client.getData>( - `/api/v2/workflows/${id}/deploy`, - { method: 'POST' } - ) - if (profile.output === 'json') { - console.log(JSON.stringify(result, null, 2)) - return - } - console.log(chalk.green(`✓ Deployed ${id}`)) - }) - - workflows - .command('undeploy ') - .description('Take a workflow out of deployment') - .action(async (id: string, _options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const result = await client.getData>( - `/api/v2/workflows/${id}/deploy`, - { method: 'DELETE' } - ) - if (profile.output === 'json') { - console.log(JSON.stringify(result, null, 2)) - return - } - console.log(chalk.green(`✓ Undeployed ${id}`)) - }) - - workflows - .command('rollback ') - .description('Roll a deployed workflow back to its previous version') - .action(async (id: string, _options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const result = await client.getData>( - `/api/v2/workflows/${id}/rollback`, - { method: 'POST' } - ) - if (profile.output === 'json') { - console.log(JSON.stringify(result, null, 2)) - return - } - console.log(chalk.green(`✓ Rolled back ${id}`)) - }) - - return workflows -} diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index f1bcb5ddd52..6223d5d3329 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -20,7 +20,7 @@ export type AddTableColumnBody = { column: { id?: string name: string - type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' required?: boolean unique?: boolean position?: number @@ -29,6 +29,7 @@ export type AddTableColumnBody = { name: string }> multiple?: boolean + currencyCode?: string } } @@ -37,7 +38,7 @@ export type AddTableColumnResponse = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' required: boolean unique: boolean workflowGroupId?: string @@ -46,6 +47,7 @@ export type AddTableColumnResponse = { name: string }> multiple?: boolean + currencyCode?: unknown }> } } @@ -122,7 +124,7 @@ export type CreateTableBody = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' required?: boolean unique?: boolean workflowGroupId?: string @@ -131,6 +133,7 @@ export type CreateTableBody = { name: string }> multiple?: boolean + currencyCode?: string }> } workspaceId: string @@ -147,7 +150,7 @@ export type CreateTableResponse = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' required: boolean unique: boolean workflowGroupId?: string @@ -156,6 +159,7 @@ export type CreateTableResponse = { name: string }> multiple?: boolean + currencyCode?: unknown }> } rowCount: number @@ -285,7 +289,7 @@ export type DeleteTableColumnResponse = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' required: boolean unique: boolean workflowGroupId?: string @@ -294,6 +298,7 @@ export type DeleteTableColumnResponse = { name: string }> multiple?: boolean + currencyCode?: unknown }> } } @@ -724,7 +729,7 @@ export type GetTableResponse = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' required: boolean unique: boolean workflowGroupId?: string @@ -733,6 +738,7 @@ export type GetTableResponse = { name: string }> multiple?: boolean + currencyCode?: unknown }> } rowCount: number @@ -1092,7 +1098,7 @@ export type ListTablesResponse = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' required: boolean unique: boolean workflowGroupId?: string @@ -1101,6 +1107,7 @@ export type ListTablesResponse = { name: string }> multiple?: boolean + currencyCode?: unknown }> } rowCount: number @@ -1255,6 +1262,7 @@ export type SearchKnowledgeBody = { value: string | number | boolean valueTo?: string | number }> + searchMode?: 'vector' | 'hybrid' | null } export type SearchKnowledgeResponse = { @@ -1387,7 +1395,7 @@ export type UpdateTableColumnBody = { columnName: string updates: { name?: string - type?: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + type?: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' required?: boolean unique?: boolean options?: Array<{ @@ -1395,6 +1403,7 @@ export type UpdateTableColumnBody = { name: string }> multiple?: boolean + currencyCode?: string } } @@ -1403,7 +1412,7 @@ export type UpdateTableColumnResponse = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' required: boolean unique: boolean workflowGroupId?: string @@ -1412,6 +1421,7 @@ export type UpdateTableColumnResponse = { name: string }> multiple?: boolean + currencyCode?: unknown }> } } @@ -1505,289 +1515,582 @@ export type UpsertTableRowResponse = { } } -/** Every v2 operation, keyed by name. */ +/** + * Every v2 operation, keyed by name. + * + * `query` and `body` describe each field well enough for the CLI to build a + * flag for it and coerce the string argv gives back: its kind, whether it is + * required, its enum values, and its server-side default. A slot the contract + * does not declare — or one whose shape is a union with no flat field list — + * is absent, and the runtime falls back to taking it as JSON. + * + * `summary` is the operation's one-line description, lifted from the OpenAPI + * specs so `--help` reuses prose that is already written and already checked. + */ export const V2_OPERATIONS = { addTableColumn: { method: 'POST', path: '/api/v2/tables/[tableId]/columns', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Add Column', + body: { + workspaceId: { kind: 'string', required: true }, + column: { kind: 'object', required: true }, + }, }, cancelWorkflowExecution: { method: 'POST', path: '/api/v2/workflows/[id]/executions/[executionId]/cancel', pathParams: ['id', 'executionId'] as const, responseMode: 'json', + summary: 'Cancel an execution', }, createKnowledgeBase: { method: 'POST', path: '/api/v2/knowledge', pathParams: [] as const, responseMode: 'json', + summary: 'Create Knowledge Base', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + description: { kind: 'string' }, + chunkingConfig: { kind: 'object', default: { maxSize: 1024, minSize: 100, overlap: 200 } }, + }, }, createTable: { method: 'POST', path: '/api/v2/tables', pathParams: [] as const, responseMode: 'json', + summary: 'Create Table', + body: { + name: { kind: 'string', required: true }, + description: { kind: 'string' }, + schema: { kind: 'object', required: true }, + workspaceId: { kind: 'string', required: true }, + folderId: { kind: 'string' }, + }, }, createTableRows: { method: 'POST', path: '/api/v2/tables/[tableId]/rows', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Create Rows', }, deleteFile: { method: 'DELETE', path: '/api/v2/files/[fileId]', pathParams: ['fileId'] as const, responseMode: 'json', + summary: 'Delete File', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, deleteKnowledgeBase: { method: 'DELETE', path: '/api/v2/knowledge/[id]', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Delete Knowledge Base', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, deleteKnowledgeDocument: { method: 'DELETE', path: '/api/v2/knowledge/[id]/documents/[documentId]', pathParams: ['id', 'documentId'] as const, responseMode: 'json', + summary: 'Delete Document', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, deleteTable: { method: 'DELETE', path: '/api/v2/tables/[tableId]', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Delete Table', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, deleteTableColumn: { method: 'DELETE', path: '/api/v2/tables/[tableId]/columns', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Delete Column', + body: { + workspaceId: { kind: 'string', required: true }, + columnName: { kind: 'string', required: true }, + }, }, deleteTableRow: { method: 'DELETE', path: '/api/v2/tables/[tableId]/rows/[rowId]', pathParams: ['tableId', 'rowId'] as const, responseMode: 'json', + summary: 'Delete Row', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, deleteTableRows: { method: 'DELETE', path: '/api/v2/tables/[tableId]/rows', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Delete Rows', + body: { + workspaceId: { kind: 'string', required: true }, + filter: { kind: 'unknown' }, + limit: { kind: 'integer' }, + rowIds: { kind: 'array' }, + }, }, deployWorkflow: { method: 'POST', path: '/api/v2/workflows/[id]/deploy', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Deploy Workflow', }, downloadFile: { method: 'GET', path: '/api/v2/files/[fileId]', pathParams: ['fileId'] as const, responseMode: 'binary', + summary: 'Download File', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, executeWorkflow: { method: 'POST', path: '/api/v2/workflows/[id]/execute', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Execute a workflow', + body: { + input: { kind: 'object' }, + async: { kind: 'boolean', default: false }, + stream: { kind: 'boolean', default: false }, + selectedOutputs: { kind: 'array' }, + includeThinking: { kind: 'boolean', default: false }, + includeToolCalls: { kind: 'boolean', default: false }, + includeFileBase64: { kind: 'boolean' }, + base64MaxBytes: { kind: 'integer' }, + }, }, exportWorkflow: { method: 'GET', path: '/api/v2/workflows/[id]/export', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Export a workflow', }, getAuditLog: { method: 'GET', path: '/api/v2/audit-logs/[id]', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Get Audit Log', }, getExecution: { method: 'GET', path: '/api/v2/logs/executions/[executionId]', pathParams: ['executionId'] as const, responseMode: 'json', + summary: 'Get Execution', }, getKnowledgeBase: { method: 'GET', path: '/api/v2/knowledge/[id]', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Get Knowledge Base', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, getKnowledgeDocument: { method: 'GET', path: '/api/v2/knowledge/[id]/documents/[documentId]', pathParams: ['id', 'documentId'] as const, responseMode: 'json', + summary: 'Get Document', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, getLog: { method: 'GET', path: '/api/v2/logs/[id]', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Get Log', }, getTable: { method: 'GET', path: '/api/v2/tables/[tableId]', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Get Table', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, getTableRow: { method: 'GET', path: '/api/v2/tables/[tableId]/rows/[rowId]', pathParams: ['tableId', 'rowId'] as const, responseMode: 'json', + summary: 'Get Row', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, getUsageSummary: { method: 'GET', path: '/api/v2/billing/usage', pathParams: [] as const, responseMode: 'json', + summary: 'Get Usage Summary', + query: { + workspaceId: { kind: 'string' }, + }, }, getWorkflow: { method: 'GET', path: '/api/v2/workflows/[id]', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Get Workflow', }, getWorkflowExecution: { method: 'GET', path: '/api/v2/workflows/[id]/executions/[executionId]', pathParams: ['id', 'executionId'] as const, responseMode: 'json', + summary: 'Get execution status', + query: { + includeOutput: { kind: 'enum', values: ['true', 'false'] as const }, + selectedOutputs: { kind: 'string' }, + }, }, importWorkflow: { method: 'POST', path: '/api/v2/workflows/import', pathParams: [] as const, responseMode: 'json', + summary: 'Import a workflow', + body: { + workspaceId: { kind: 'string', required: true }, + folderId: { kind: 'string' }, + name: { kind: 'string' }, + description: { kind: 'string' }, + workflow: { kind: 'unknown', required: true }, + }, }, listAuditLogs: { method: 'GET', path: '/api/v2/audit-logs', pathParams: [] as const, responseMode: 'json', + summary: 'List Audit Logs', + query: { + action: { kind: 'string' }, + resourceType: { kind: 'string' }, + resourceId: { kind: 'string' }, + workspaceId: { kind: 'string' }, + actorId: { kind: 'string' }, + startDate: { kind: 'string' }, + endDate: { kind: 'string' }, + includeDeparted: { kind: 'enum', values: ['true', 'false'] as const }, + limit: { kind: 'number', default: 50 }, + cursor: { kind: 'string' }, + }, }, listFiles: { method: 'GET', path: '/api/v2/files', pathParams: [] as const, responseMode: 'json', + summary: 'List Files', + query: { + workspaceId: { kind: 'string', required: true }, + limit: { kind: 'number', default: 100 }, + cursor: { kind: 'string' }, + }, }, listKnowledgeBases: { method: 'GET', path: '/api/v2/knowledge', pathParams: [] as const, responseMode: 'json', + summary: 'List Knowledge Bases', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, listKnowledgeDocuments: { method: 'GET', path: '/api/v2/knowledge/[id]/documents', pathParams: ['id'] as const, responseMode: 'json', + summary: 'List Documents', + query: { + workspaceId: { kind: 'string', required: true }, + limit: { kind: 'integer', default: 50 }, + search: { kind: 'string' }, + enabledFilter: { + kind: 'enum', + values: ['all', 'enabled', 'disabled'] as const, + default: 'all', + }, + sortBy: { + kind: 'enum', + values: [ + 'filename', + 'fileSize', + 'tokenCount', + 'chunkCount', + 'uploadedAt', + 'processingStatus', + 'enabled', + ] as const, + default: 'uploadedAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, + cursor: { kind: 'string' }, + }, }, listLogs: { method: 'GET', path: '/api/v2/logs', pathParams: [] as const, responseMode: 'json', + summary: 'List Logs', + query: { + workspaceId: { kind: 'string', required: true }, + workflowIds: { kind: 'string' }, + folderIds: { kind: 'string' }, + triggers: { kind: 'string' }, + level: { kind: 'enum', values: ['info', 'error'] as const }, + startDate: { kind: 'string' }, + endDate: { kind: 'string' }, + executionId: { kind: 'string' }, + minDurationMs: { kind: 'number' }, + maxDurationMs: { kind: 'number' }, + minCost: { kind: 'number' }, + maxCost: { kind: 'number' }, + model: { kind: 'string' }, + details: { kind: 'enum', values: ['basic', 'full'] as const, default: 'basic' }, + includeTraceSpans: { kind: 'boolean' }, + includeFinalOutput: { kind: 'boolean' }, + limit: { kind: 'number', default: 100 }, + cursor: { kind: 'string' }, + order: { kind: 'enum', values: ['desc', 'asc'] as const, default: 'desc' }, + }, }, listTableRows: { method: 'GET', path: '/api/v2/tables/[tableId]/rows', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'List rows', + query: { + workspaceId: { kind: 'string', required: true }, + limit: { kind: 'integer' }, + cursor: { kind: 'string' }, + }, }, listTables: { method: 'GET', path: '/api/v2/tables', pathParams: [] as const, responseMode: 'json', + summary: 'List Tables', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, listUsageLogs: { method: 'GET', path: '/api/v2/billing/usage/logs', pathParams: [] as const, responseMode: 'json', + summary: 'List Usage Logs', + query: { + source: { + kind: 'enum', + values: [ + 'workflow', + 'wand', + 'copilot', + 'workspace-chat', + 'mcp_copilot', + 'mothership_block', + 'knowledge-base', + 'voice-input', + 'enrichment', + ] as const, + }, + workspaceId: { kind: 'string' }, + period: { + kind: 'enum', + values: ['1d', '7d', '30d', 'all', 'custom'] as const, + default: '30d', + }, + startDate: { kind: 'string' }, + endDate: { kind: 'string' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + }, }, listWorkflows: { method: 'GET', path: '/api/v2/workflows', pathParams: [] as const, responseMode: 'json', + summary: 'List Workflows', + query: { + workspaceId: { kind: 'string', required: true }, + folderId: { kind: 'string' }, + deployedOnly: { kind: 'boolean' }, + limit: { kind: 'number', default: 50 }, + cursor: { kind: 'string' }, + }, }, queryRows: { method: 'POST', path: '/api/v2/tables/[tableId]/query', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Query Rows', + body: { + workspaceId: { kind: 'string', required: true }, + predicate: { kind: 'unknown' }, + sort: { kind: 'array' }, + limit: { kind: 'integer' }, + cursor: { kind: 'string' }, + }, }, rollbackWorkflow: { method: 'POST', path: '/api/v2/workflows/[id]/rollback', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Rollback Workflow', }, searchKnowledge: { method: 'POST', path: '/api/v2/knowledge/search', pathParams: [] as const, responseMode: 'json', + summary: 'Search Knowledge', + body: { + workspaceId: { kind: 'string', required: true }, + knowledgeBaseIds: { kind: 'unknown', required: true }, + query: { kind: 'string' }, + topK: { kind: 'number', default: 10 }, + tagFilters: { kind: 'array' }, + searchMode: { kind: 'enum', default: 'vector' }, + }, }, undeployWorkflow: { method: 'DELETE', path: '/api/v2/workflows/[id]/deploy', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Undeploy Workflow', }, updateKnowledgeBase: { method: 'PUT', path: '/api/v2/knowledge/[id]', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Update Knowledge Base', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string' }, + description: { kind: 'string' }, + chunkingConfig: { kind: 'object' }, + }, }, updateRowsByFilter: { method: 'PUT', path: '/api/v2/tables/[tableId]/rows', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Update Rows by Filter', + body: { + workspaceId: { kind: 'string', required: true }, + filter: { kind: 'unknown', required: true }, + data: { kind: 'unknown', required: true }, + limit: { kind: 'integer' }, + }, }, updateTableColumn: { method: 'PATCH', path: '/api/v2/tables/[tableId]/columns', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Update Column', + body: { + workspaceId: { kind: 'string', required: true }, + columnName: { kind: 'string', required: true }, + updates: { kind: 'object', required: true }, + }, }, updateTableRow: { method: 'PATCH', path: '/api/v2/tables/[tableId]/rows/[rowId]', pathParams: ['tableId', 'rowId'] as const, responseMode: 'json', + summary: 'Update Row', + body: { + workspaceId: { kind: 'string', required: true }, + data: { kind: 'unknown', required: true }, + }, }, uploadFile: { method: 'POST', path: '/api/v2/files', pathParams: [] as const, responseMode: 'json', + summary: 'Upload File', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, uploadKnowledgeDocument: { method: 'POST', path: '/api/v2/knowledge/[id]/documents', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Upload Document', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, upsertTableRow: { method: 'POST', path: '/api/v2/tables/[tableId]/rows/upsert', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Upsert Row', + body: { + workspaceId: { kind: 'string', required: true }, + data: { kind: 'unknown', required: true }, + conflictTarget: { kind: 'string' }, + }, }, } as const diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 84daf9104db..ab5728183bf 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -4,12 +4,9 @@ import chalk from 'chalk' import { Command } from 'commander' import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth.js' import { configureCommand } from './commands/configure.js' -import { filesCommand } from './commands/files.js' -import { knowledgeCommand } from './commands/knowledge.js' -import { logsCommand } from './commands/logs.js' -import { tablesCommand } from './commands/tables.js' -import { workflowsCommand } from './commands/workflows.js' +import { attachHandWritten } from './commands/hand-written.js' import { SimApiError } from './http/client.js' +import { buildGeneratedCommands } from './runtime/build.js' const program = new Command() @@ -26,11 +23,24 @@ program.addCommand(logoutCommand()) program.addCommand(whoamiCommand()) program.addCommand(profilesCommand()) program.addCommand(configureCommand()) -program.addCommand(workflowsCommand()) -program.addCommand(logsCommand()) -program.addCommand(tablesCommand()) -program.addCommand(filesCommand()) -program.addCommand(knowledgeCommand()) + +/** + * Leaves owned by hand-written commands, which the generated runtime skips. + * + * Each is here because generation genuinely cannot produce it, not because it + * has not been migrated: `files download` streams binary rather than JSON, and + * `tables rows list` discovers its columns from user-defined row data at + * runtime with a nested `data` object the generic renderer would flatten badly. + */ +const HAND_WRITTEN = new Set(['files download', 'tables rows list']) + +for (const command of buildGeneratedCommands(HAND_WRITTEN)) { + program.addCommand(command) +} + +// Added after the generated groups so their leaves merge into the same group +// object rather than creating a duplicate top-level command. +attachHandWritten(program) program.addHelpText( 'after', diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts new file mode 100644 index 00000000000..96a9e217403 --- /dev/null +++ b/packages/sim-cli/src/runtime/build.ts @@ -0,0 +1,298 @@ +import { Command, Option } from 'commander' +import { clientFrom } from '../context.js' +import { CLI_CONTRACT } from '../contract/commands.js' +import type { ColumnSpec, CommandSpec } from '../contract/types.js' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' +import { SimApiError, type V2Page } from '../http/client.js' +import { + bytes, + type Column, + duration, + printList, + printRecord, + text, + timestamp, +} from '../output/render.js' +import { deriveCommandPath } from './derive.js' +import { + buildRequest, + type FieldSpec, + flagNameFor, + flagSpecFor, + PROFILE_INJECTED_FIELD, + takesJson, +} from './request.js' + +/** Default page size when a list command is run without `--limit`. */ +const DEFAULT_LIMIT = 100 + +/** Reads `a.b.c` out of a row, tolerating a missing link anywhere along the way. */ +function at(row: unknown, path: string): unknown { + return path + .split('.') + .reduce( + (value, key) => (value && typeof value === 'object' ? (value as never)[key] : undefined), + row + ) +} + +function renderCell(value: unknown, format: ColumnSpec['format']): string { + switch (format) { + case 'timestamp': + return timestamp(value as string | null) + case 'bytes': + return bytes(value as number | null) + case 'duration': + return duration(value as number | null) + case 'bool': + return value === null || value === undefined ? text(null) : value ? 'yes' : 'no' + case 'cost': + return typeof value === 'number' ? `$${value.toFixed(4)}` : text(null) + default: + if (value === null || value === undefined || value === '') return text(null) + return typeof value === 'object' ? JSON.stringify(value) : String(value) + } +} + +function columnsFrom(specs: ColumnSpec[]): Column[] { + return specs.map((spec) => ({ + header: spec.header, + value: (row: unknown) => renderCell(at(row, spec.path ?? spec.header), spec.format), + })) +} + +/** + * Columns for a list command with none declared in the contract. + * + * Row shapes are only known at runtime here — a table's `data` is user-defined — + * so the keys are unioned across the page rather than read off the first row, + * which would let a sparse row hide every column it happens to omit. Nested + * values are skipped: they render as JSON blobs and make the table unreadable. + */ +function inferColumns(rows: unknown[]): Column[] { + const keys: string[] = [] + const seen = new Set() + + for (const row of rows) { + if (!row || typeof row !== 'object') continue + for (const [key, value] of Object.entries(row)) { + if (seen.has(key)) continue + if (value !== null && typeof value === 'object') continue + seen.add(key) + keys.push(key) + } + } + + return keys.map((key) => ({ + header: key, + value: (row: unknown) => renderCell(at(row, key), 'auto'), + })) +} + +/** The operation's one-line help, taken from the OpenAPI summary at generation time. */ +function summaryFor(operation: V2OperationName): string | undefined { + return (V2_OPERATIONS[operation] as { summary?: string }).summary +} + +/** Whether the operation answers with the `{ data, nextCursor }` list envelope. */ +function isCursorList(operation: V2OperationName): boolean { + const spec = V2_OPERATIONS[operation] as { query?: Record } + return Boolean(spec.query && 'cursor' in spec.query) +} + +/** Adds the flags a field needs, or nothing when the contract omits it. */ +function addFieldOption( + command: Command, + operation: V2OperationName, + field: string, + descriptor: FieldSpec +): void { + // Never a flag: it comes from the profile, and `cursor`/`limit` are owned by + // the auto-pager rather than exposed as raw request fields. + if (field === PROFILE_INJECTED_FIELD || field === 'cursor') return + + const flag = flagSpecFor(operation, field) + if (flag.omit) return + + const name = flagNameFor(operation, field) + const short = flag.short ? `-${flag.short}, ` : '' + + if (field === 'limit') { + command.option( + `--limit `, + 'Maximum items to return (0 for everything)', + String(DEFAULT_LIMIT) + ) + return + } + + if (descriptor.kind === 'boolean') { + command.option(`${short}--${name}`, flag.describe ?? `Set ${field}`) + return + } + + const takesList = flag.list === true + const placeholder = takesList ? `` : takesJson(descriptor, flag) ? `` : `` + const describe = + flag.describe ?? + (descriptor.values ? `One of: ${descriptor.values.join(', ')}` : `Set ${field}`) + + const option = new Option(`${short}--${name} ${placeholder}`, describe) + if (descriptor.values && !takesList) option.choices([...descriptor.values]) + if (descriptor.default !== undefined && field !== 'limit') { + option.default(undefined, String(descriptor.default)) + } + command.addOption(option) +} + +/** + * Builds one leaf command for an operation. + * + * The action closure is the whole runtime: coerce and assemble the request, + * auto-page it when the response is a cursor list, then render through whatever + * the contract says about columns. + */ +function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: string): Command { + const operationSpec = V2_OPERATIONS[operation] as { + method: string + pathParams: readonly string[] + query?: Record + body?: Record + } + + // `new Command('upsert ')` would make the whole string the command's + // NAME, so `sim tables upsert` would never match it and would silently fall + // through to the group's help. Arguments have to be declared separately. + const command = new Command(leafName) + for (const param of operationSpec.pathParams) { + command.argument(`<${param}>`) + } + + command.description( + spec.describe ?? + summaryFor(operation) ?? + `${operationSpec.method} ${V2_OPERATIONS[operation].path}` + ) + + for (const slot of ['query', 'body'] as const) { + for (const [field, descriptor] of Object.entries(operationSpec[slot] ?? {})) { + addFieldOption(command, operation, field, descriptor) + } + } + + if (spec.confirm) { + command.option('-y, --yes', 'Skip the confirmation') + } + + command.action(async (...invocation: unknown[]) => { + // commander passes positionals, then the options object, then the Command. + const host = invocation[invocation.length - 1] as Command + const flags = invocation[invocation.length - 2] as Record + const positional = invocation.slice(0, operationSpec.pathParams.length) as string[] + + if (spec.confirm && !flags.yes) { + throw new SimApiError(`${spec.confirm} Re-run with --yes to confirm.`, 0) + } + + const { client, profile } = clientFrom(host) + const request = buildRequest(operation, positional, flags, profile.workspaceId) + + if (isCursorList(operation)) { + const rawLimit = Number.parseInt(String(flags.limit ?? DEFAULT_LIMIT), 10) + if (Number.isNaN(rawLimit) || rawLimit < 0) { + throw new SimApiError('--limit must be a non-negative number', 0) + } + // 0 means everything; Infinity lets the loop run until the cursor dries up. + const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit + + const rows: unknown[] = [] + let cursor: string | null = null + do { + const page: V2Page = await client.request(request.path, { + method: operationSpec.method as 'GET' | 'POST', + query: { ...request.query, cursor }, + body: request.body, + }) + rows.push(...page.data) + cursor = page.nextCursor + } while (cursor && rows.length < limit) + + const page = Number.isFinite(limit) ? rows.slice(0, limit) : rows + printList(profile.output, page, spec.columns ? columnsFrom(spec.columns) : inferColumns(page)) + return + } + + const result = await client.request<{ data?: unknown }>(request.path, { + method: operationSpec.method as 'GET' | 'POST', + query: request.query, + body: request.body, + }) + const data = result?.data ?? result + + if (spec.columns && Array.isArray(data)) { + printList(profile.output, data, columnsFrom(spec.columns)) + return + } + + const fields: Array<[string, string]> = + data && typeof data === 'object' && !Array.isArray(data) + ? Object.entries(data) + .filter(([, value]) => value === null || typeof value !== 'object') + .map(([key, value]) => [key, renderCell(value, 'auto')]) + : [] + + printRecord(profile.output, fields, data) + }) + + return command +} + +/** + * Builds every command the contract and the generated operation table describe. + * + * Iterates `V2_OPERATIONS`, not the contract — an operation added to a Zod + * contract shows up here after `generate:cli-api` with no CLI edit at all. The + * contract is consulted only for the things a schema cannot say. + * + * `reserved` are groups owned by hand-written commands (`files download` streams + * binary, `logs get` prints a trace). A generated leaf never displaces one. + */ +export function buildGeneratedCommands(reserved: ReadonlySet): Command[] { + const groups = new Map() + + for (const operation of Object.keys(V2_OPERATIONS) as V2OperationName[]) { + const spec = CLI_CONTRACT[operation] ?? {} + if (spec.hidden) continue + // Non-JSON responses (binary downloads) need a bespoke consumer. + if (V2_OPERATIONS[operation].responseMode !== 'json') continue + + const segments = spec.command ? spec.command.split(' ') : deriveCommandPath(operation) + const [groupName, ...rest] = segments + const leafName = rest.join(' ') || 'run' + + if (reserved.has(`${groupName} ${leafName}`)) continue + + let group = groups.get(groupName) + if (!group) { + group = new Command(groupName) + groups.set(groupName, group) + } + + // A multi-word leaf (`rows batch-delete`) nests one more level so help reads + // as a tree rather than a flat list of hyphenated names. + if (rest.length > 1) { + const [subName, ...tail] = rest + let sub = group.commands.find((candidate) => candidate.name() === subName) + if (!sub) { + sub = new Command(subName) + group.addCommand(sub) + } + sub.addCommand(buildLeaf(operation, spec, tail.join(' '))) + continue + } + + group.addCommand(buildLeaf(operation, spec, leafName)) + } + + return [...groups.values()].sort((a, b) => a.name().localeCompare(b.name())) +} diff --git a/packages/sim-cli/src/runtime/derive.ts b/packages/sim-cli/src/runtime/derive.ts new file mode 100644 index 00000000000..5bcd9be3e75 --- /dev/null +++ b/packages/sim-cli/src/runtime/derive.ts @@ -0,0 +1,58 @@ +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' + +/** + * Trailing path segments that read as verbs rather than sub-resources, so + * `/tables/[id]/rows/upsert` derives `tables upsert` instead of + * `tables rows upsert create`. + * + * `execute` and `cancel` are deliberately absent: they are verbs, but their + * derived names read badly enough that the contract names them explicitly, and + * listing them here would produce `workflows execute` — close, but not the + * `workflows run` the contract asks for. Keeping them out means the contract is + * the only place that decision lives. + */ +const ACTION_SEGMENTS = new Set([ + 'upsert', + 'query', + 'search', + 'export', + 'import', + 'deploy', + 'rollback', +]) + +/** + * Derives a command path from an operation's route. + * + * ` [sub-resource] `, where the verb comes from the method and + * whether the path ends in a parameter (an item) or not (a collection). This + * covers 41 of the 47 operations; the rest are named in the CLI contract. + */ +export function deriveCommandPath(operation: V2OperationName): string[] { + const spec = V2_OPERATIONS[operation] + const segments = spec.path.replace('/api/v2/', '').split('/') + const resource = segments[0] + const nouns = segments.slice(1).filter((segment) => !segment.startsWith('[')) + const last = nouns[nouns.length - 1] + + if (last && ACTION_SEGMENTS.has(last)) return [resource, last] + + const isItem = spec.path.endsWith(']') + const verb = + spec.method === 'GET' + ? isItem + ? 'get' + : 'list' + : spec.method === 'POST' + ? 'create' + : spec.method === 'DELETE' + ? 'delete' + : 'update' + + return last ? [resource, last, verb] : [resource, verb] +} + +/** `conflictTarget` → `conflict-target`. */ +export function kebab(value: string): string { + return value.replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`) +} diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts new file mode 100644 index 00000000000..77199260fa3 --- /dev/null +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { SimApiError } from '../http/client.js' +import { deriveCommandPath } from './derive.js' +import { buildRequest } from './request.js' + +const WORKSPACE = 'ws_local' + +describe('buildRequest', () => { + it('substitutes path params from positional args and injects the workspace', () => { + expect(buildRequest('upsertTableRow', ['tbl_1'], { data: '{"a":1}' }, WORKSPACE)).toEqual({ + path: '/api/v2/tables/tbl_1/rows/upsert', + query: {}, + body: { workspaceId: WORKSPACE, data: { a: 1 } }, + }) + }) + + it('puts the workspace in whichever slot the contract declares it', () => { + // Same field, different slot: body for upsert above, query here. + const built = buildRequest('listTables', [], {}, WORKSPACE) + expect(built.query).toEqual({ workspaceId: WORKSPACE }) + expect(built.body).toBeUndefined() + }) + + it('maps a contract flag alias back to its field name', () => { + const built = buildRequest('upsertTableRow', ['t'], { data: '{}', on: 'email' }, WORKSPACE) + expect(built.body).toMatchObject({ conflictTarget: 'email' }) + }) + + it('comma-joins a list flag the route splits, which the type calls a string', () => { + const built = buildRequest('listLogs', [], { workflow: ['wf_1', 'wf_2'] }, WORKSPACE) + expect(built.query.workflowIds).toBe('wf_1,wf_2') + }) + + it('coerces numeric flags out of the strings argv gives', () => { + const built = buildRequest('listLogs', [], { 'min-duration-ms': '250' }, WORKSPACE) + expect(built.query.minDurationMs).toBe(250) + }) + + it('omits absent optional fields so the server applies its own default', () => { + const built = buildRequest('listLogs', [], {}, WORKSPACE) + expect(built.query).toEqual({ workspaceId: WORKSPACE }) + expect(built.query).not.toHaveProperty('order') + }) + + it('never sends a field the contract marked omit', () => { + // `stream` would switch the response to SSE, which the JSON client cannot read. + const built = buildRequest('executeWorkflow', ['wf_1'], { stream: true }, WORKSPACE) + expect(built.body ?? {}).not.toHaveProperty('stream') + }) + + it('percent-encodes path params so an id cannot retarget the request', () => { + expect(buildRequest('getTable', ['a/b?c'], {}, WORKSPACE).path).toBe('/api/v2/tables/a%2Fb%3Fc') + }) + + describe('failures, all before any network call', () => { + it('rejects a missing path arg', () => { + expect(() => buildRequest('getTable', [], {}, WORKSPACE)).toThrow('Missing ') + }) + + it('rejects a missing required flag', () => { + expect(() => buildRequest('upsertTableRow', ['t'], {}, WORKSPACE)).toThrow( + '--data is required' + ) + }) + + it('rejects malformed JSON, naming the flag the caller typed', () => { + expect(() => buildRequest('upsertTableRow', ['t'], { data: '{oops' }, WORKSPACE)).toThrow( + '--data must be valid JSON' + ) + }) + + it('rejects a value outside an enum', () => { + expect(() => buildRequest('listLogs', [], { level: 'warn' }, WORKSPACE)).toThrow( + '--level must be one of: info, error' + ) + }) + + it('rejects a non-numeric number', () => { + expect(() => buildRequest('listLogs', [], { 'min-cost': 'lots' }, WORKSPACE)).toThrow( + '--min-cost must be a number' + ) + }) + + it('explains an unset workspace in terms of how to set one', () => { + expect(() => buildRequest('listTables', [], {}, null)).toThrow(SimApiError) + expect(() => buildRequest('listTables', [], {}, null)).toThrow( + 'sim configure --set-workspace' + ) + }) + }) +}) + +describe('deriveCommandPath', () => { + it('derives collection and item verbs from the method and path shape', () => { + expect(deriveCommandPath('listTables')).toEqual(['tables', 'list']) + expect(deriveCommandPath('getTable')).toEqual(['tables', 'get']) + expect(deriveCommandPath('createTable')).toEqual(['tables', 'create']) + expect(deriveCommandPath('deleteTable')).toEqual(['tables', 'delete']) + }) + + it('nests a sub-resource', () => { + expect(deriveCommandPath('getKnowledgeDocument')).toEqual(['knowledge', 'documents', 'get']) + expect(deriveCommandPath('listTableRows')).toEqual(['tables', 'rows', 'list']) + }) + + it('treats a verb-like trailing segment as the command name', () => { + expect(deriveCommandPath('upsertTableRow')).toEqual(['tables', 'upsert']) + expect(deriveCommandPath('searchKnowledge')).toEqual(['knowledge', 'search']) + }) +}) diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts new file mode 100644 index 00000000000..c35afdd0976 --- /dev/null +++ b/packages/sim-cli/src/runtime/request.ts @@ -0,0 +1,164 @@ +import { CLI_CONTRACT } from '../contract/commands.js' +import type { FlagSpec } from '../contract/types.js' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' +import { type QueryValue, SimApiError } from '../http/client.js' +import { kebab } from './derive.js' + +/** One request field, as the generator describes it. */ +export interface FieldSpec { + kind: 'string' | 'number' | 'integer' | 'boolean' | 'enum' | 'array' | 'object' | 'unknown' + required?: boolean + values?: readonly string[] + default?: unknown +} + +/** + * The workspace never becomes a flag. + * + * It is the one field every workspace-scoped operation declares, and it comes + * from the profile — surfacing it as `--workspace-id` on 30-odd commands would + * duplicate the global `--workspace` and invite the two to disagree. + */ +export const PROFILE_INJECTED_FIELD = 'workspaceId' + +/** Kinds the CLI can only accept as a JSON string. */ +const JSON_KINDS = new Set(['object', 'array', 'unknown']) + +export function flagSpecFor(operation: V2OperationName, field: string): FlagSpec { + return CLI_CONTRACT[operation]?.flags?.[field] ?? {} +} + +/** The flag name a field is exposed under, honouring any contract override. */ +export function flagNameFor(operation: V2OperationName, field: string): string { + return flagSpecFor(operation, field).name ?? kebab(field) +} + +export function takesJson(field: FieldSpec, flag: FlagSpec): boolean { + return flag.json === true || JSON_KINDS.has(field.kind) +} + +/** + * Turns the string argv provides into the value the contract expects. + * + * Every failure names the flag rather than the field, because the flag is what + * the caller typed — and every one of these is caught before any request is + * made, so a typo costs nothing. + */ +export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: string): unknown { + if (raw === undefined) return undefined + + // A repeated flag whose wire form is one comma-joined string. The schema + // types these as `string`, so only the contract knows. + if (flag.list) { + const values = Array.isArray(raw) ? raw : [raw] + return values.join(',') + } + + if (takesJson(field, flag)) { + if (typeof raw !== 'string') return raw + try { + return JSON.parse(raw) + } catch (error) { + throw new SimApiError(`--${flagName} must be valid JSON: ${(error as Error).message}`, 0) + } + } + + if (field.kind === 'number' || field.kind === 'integer') { + const value = Number(raw) + if (Number.isNaN(value)) throw new SimApiError(`--${flagName} must be a number`, 0) + return value + } + + if (field.kind === 'boolean') return raw === true || raw === 'true' + + if (field.kind === 'enum' && field.values && !field.values.includes(String(raw))) { + throw new SimApiError(`--${flagName} must be one of: ${field.values.join(', ')}`, 0) + } + + return raw +} + +export interface BuiltRequest { + path: string + query: Record + body: Record | undefined +} + +/** + * A query string can only carry scalars. Every v2 query field is one today, but + * a structured field could be added — serializing it here keeps that a working + * request rather than `[object Object]`. + */ +function asQueryValue(value: unknown): QueryValue { + if (value === null || value === undefined) return undefined + if (typeof value === 'object') return JSON.stringify(value) + return value as QueryValue +} + +/** + * Assembles one operation's HTTP request from positional args, parsed flags, + * and the profile's workspace. + * + * Path params come from positional arguments in declared order; every other + * field is looked up by its flag name in the slot the contract declares it in, + * so a field that moved from query to body moves here on the next regeneration. + */ +export function buildRequest( + operation: V2OperationName, + positional: string[], + flags: Record, + workspaceId: string | null +): BuiltRequest { + const spec = V2_OPERATIONS[operation] as { + method: string + path: string + pathParams: readonly string[] + query?: Record + body?: Record + } + + let path = spec.path + spec.pathParams.forEach((param, index) => { + const value = positional[index] + if (value === undefined) throw new SimApiError(`Missing <${param}>`, 0) + // Ids are opaque; an unencoded `/` or `?` would silently retarget the request. + path = path.replace(`[${param}]`, encodeURIComponent(value)) + }) + + const query: Record = {} + const body: Record = {} + + for (const slot of ['query', 'body'] as const) { + for (const [field, descriptor] of Object.entries(spec[slot] ?? {})) { + const flag = flagSpecFor(operation, field) + if (flag.omit) continue + + const flagName = flagNameFor(operation, field) + const raw = field === PROFILE_INJECTED_FIELD ? workspaceId : flags[flagName] + const value = coerce(raw ?? undefined, descriptor, flag, flagName) + + if (value === undefined) { + if (descriptor.required) { + throw new SimApiError( + field === PROFILE_INJECTED_FIELD + ? 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ' + : `--${flagName} is required`, + 0 + ) + } + // Omitted rather than sent as null: the server applies its own default, + // and sending an explicit undefined would override it with nothing. + continue + } + + if (slot === 'query') query[field] = asQueryValue(value) + else body[field] = value + } + } + + return { + path, + query, + body: Object.keys(body).length > 0 ? body : undefined, + } +} diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index b0dbc74624b..ea0359d1883 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -34,6 +34,52 @@ import { z } from 'zod' const ROOT = path.resolve(import.meta.dir, '..') const CONTRACTS_DIR = path.join(ROOT, 'apps/sim/lib/api/contracts/v2') const OUTPUT = path.join(ROOT, 'packages/sim-cli/src/generated/v2-api.ts') +const DOCS_DIR = path.join(ROOT, 'apps/docs') + +/** OpenAPI documents to read operation summaries from. */ +const SPEC_FILES = [ + 'openapi-core.json', + 'openapi-v2-workflows.json', + 'openapi-v2-logs.json', + 'openapi-v2-tables.json', + 'openapi-v2-knowledge.json', + 'openapi-v2-files-audit.json', +] as const + +/** + * `METHOD /api/v2/{id}/…` → the spec's one-line summary. + * + * The contracts carry validation, not prose, so `--help` text has to come from + * somewhere else. The specs already hold a hand-written summary per operation + * and `check:openapi` guarantees every contract has one, so reading them here + * reuses documentation that is already written and already verified rather than + * inventing a second place to describe the same endpoint. + */ +function loadSummaries(): Map { + const summaries = new Map() + + for (const file of SPEC_FILES) { + let spec: Record + try { + spec = JSON.parse(readFileSync(path.join(DOCS_DIR, file), 'utf8')) + } catch { + // A missing spec is not fatal: the CLI falls back to `METHOD path`, and + // `check:openapi` is what actually enforces the specs' presence. + continue + } + + for (const [specPath, methods] of Object.entries(spec.paths ?? {})) { + for (const [method, operation] of Object.entries(methods as Record)) { + const summary = operation?.summary + if (typeof summary === 'string') { + summaries.set(`${method.toUpperCase()} ${specPath}`, summary) + } + } + } + } + + return summaries +} /** Contract modules to read, in emit order. */ const DOMAINS = [ @@ -179,8 +225,90 @@ function pathParams(routePath: string): string[] { return [...routePath.matchAll(/\[([^\]]+)\]/g)].map((m) => m[1]) } +/** + * The kind a request field reduces to for the CLI's purposes. + * + * Everything from argv arrives as a string, so this is what tells the runtime + * how to turn `"50"` into `50`, a bare `--flag` into `true`, and `'{"a":1}'` + * into an object. `unknown` covers `z.unknown()`/`z.any()`, which the CLI can + * only accept as JSON. + */ +type FieldKind = + | 'string' + | 'number' + | 'integer' + | 'boolean' + | 'enum' + | 'array' + | 'object' + | 'unknown' + +function fieldKind(schema: JsonSchema): FieldKind { + if (schema.enum) return 'enum' + + const variants = schema.anyOf ?? schema.oneOf + if (variants) { + // Nullable is spelled as a union with `null`; a single non-null branch is + // the field's real kind. A genuine multi-branch union has no single flag + // shape, so it falls through to `unknown` and is taken as JSON. + const concrete = variants.filter((v: JsonSchema) => v.type !== 'null') + return concrete.length === 1 ? fieldKind(concrete[0]) : 'unknown' + } + + const type = Array.isArray(schema.type) + ? schema.type.find((t: string) => t !== 'null') + : schema.type + + switch (type) { + case 'string': + case 'number': + case 'integer': + case 'boolean': + case 'array': + case 'object': + return type + default: + return 'unknown' + } +} + +/** + * Describes one request slot's fields for the runtime that builds flags. + * + * Emitted as data rather than baked into types because the CLI has to *iterate* + * these at startup to construct commands — a type alone cannot be walked. + */ +function renderSlotMap(schema: z.ZodType | undefined, indent: string): string | null { + if (!schema) return null + + const json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as JsonSchema + const properties: Record = json.properties ?? {} + const required = new Set(json.required ?? []) + const keys = Object.keys(properties) + + // A union body (e.g. single-row vs batch insert) has no flat field list; the + // runtime falls back to taking the whole body as JSON. + if (keys.length === 0) return null + + const lines = keys.map((key) => { + const property = properties[key] + const parts = [`kind: '${fieldKind(property)}'`] + if (required.has(key)) parts.push('required: true') + if (property.enum) { + parts.push( + `values: [${property.enum.map((v: unknown) => JSON.stringify(v)).join(', ')}] as const` + ) + } + if (property.default !== undefined) parts.push(`default: ${JSON.stringify(property.default)}`) + return `${indent} ${JSON.stringify(key)}: { ${parts.join(', ')} },` + }) + + return `{\n${lines.join('\n')}\n${indent}}` +} + function render(operations: Operation[]): string { const out: string[] = [] + const summaries = loadSummaries() out.push('/**') out.push(' * GENERATED FILE — DO NOT EDIT.') @@ -217,7 +345,18 @@ function render(operations: Operation[]): string { out.push('') } - out.push('/** Every v2 operation, keyed by name. */') + out.push('/**') + out.push(' * Every v2 operation, keyed by name.') + out.push(' *') + out.push(' * `query` and `body` describe each field well enough for the CLI to build a') + out.push(' * flag for it and coerce the string argv gives back: its kind, whether it is') + out.push(' * required, its enum values, and its server-side default. A slot the contract') + out.push(' * does not declare — or one whose shape is a union with no flat field list —') + out.push(' * is absent, and the runtime falls back to taking it as JSON.') + out.push(' *') + out.push(" * `summary` is the operation's one-line description, lifted from the OpenAPI") + out.push(' * specs so `--help` reuses prose that is already written and already checked.') + out.push(' */') out.push('export const V2_OPERATIONS = {') for (const op of operations) { const params = pathParams(op.contract.path) @@ -226,6 +365,15 @@ function render(operations: Operation[]): string { out.push(` path: '${op.contract.path}',`) out.push(` pathParams: [${params.map((p) => `'${p}'`).join(', ')}] as const,`) out.push(` responseMode: '${op.contract.response.mode}',`) + // OpenAPI writes `{id}` where the contract writes `[id]`. + const summary = summaries.get( + `${op.contract.method} ${op.contract.path.replace(/\[([^\]]+)\]/g, '{$1}')}` + ) + if (summary) out.push(` summary: ${JSON.stringify(summary)},`) + for (const slot of ['query', 'body'] as const) { + const map = renderSlotMap(op.contract[slot], ' ') + if (map) out.push(` ${slot}: ${map},`) + } out.push(' },') } out.push('} as const') From 4a6ac48db890e2124d17fa5c79ad9a1f36c55c22 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 19:14:19 -0700 Subject: [PATCH 029/159] =?UTF-8?q?fix(cli):=20review=20round=201=20?= =?UTF-8?q?=E2=80=94=20flag=20lookup,=20terminal=20controls,=20download=20?= =?UTF-8?q?safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## CLI flags silently dropped (Cursor, High) Commander camelCases every multi-word flag, so `--min-duration-ms` is stored as `minDurationMs`. `buildRequest` looked flags up by their own kebab name, found nothing, and dropped the field — no error, it just never reached the API. That was every multi-word flag on every generated command. The unit tests passed because they fed flag values already keyed by flag name, which is not what commander produces — they validated a fiction. Added `build.test.ts`, which parses real argv through the built commands; three of its assertions fail against the previous code. The old tests now use camelCase keys with a comment saying why. ## Terminal control sequences (Greptile, P1 security) `stripAnsi` matched only SGR (`ESC [ … m`), so a knowledge document, table cell, or workflow name could carry OSC, non-SGR CSI, or `ESC c` through to an interactive terminal — setting the window title, moving the cursor to overwrite what was already printed, or resetting the terminal. Replaced with a `sanitize` covering OSC (BEL- and ST-terminated), CSI, any ESC + printable, and the bare C0/C1 range, keeping tab and newline. Applied where API values become display text, so the colour the CLI adds afterwards still works. ## Downloads (Greptile, P1 ×2) `createWriteStream` truncated silently, and the destination name usually comes from the server's content-disposition rather than anything the caller typed — so a download could irreversibly replace an unrelated local file. Now opens `wx` and fails with a message naming `--force`, which was added for the deliberate overwrite. The stream's error listener was attached after the read loop finished, so an EEXIST/EACCES/ENOSPC during writing was an unhandled 'error' event that took down the process. It is now registered before the first write and raced against the pump. ## Personal-key caption (Cursor, Low) With "No workspace (personal key)" picked, the caption still promised a default workspace the approval does not send. It now distinguishes no-pick from picked-but-not-admin. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- apps/sim/app/cli/auth/cli-auth-view.tsx | 7 +- packages/sim-cli/src/commands/auth.ts | 22 +++- packages/sim-cli/src/commands/hand-written.ts | 123 +++++++++++------- packages/sim-cli/src/output/render.test.ts | 49 +++++++ packages/sim-cli/src/output/render.ts | 43 +++++- packages/sim-cli/src/runtime/build.test.ts | 110 ++++++++++++++++ packages/sim-cli/src/runtime/build.ts | 4 +- packages/sim-cli/src/runtime/derive.ts | 12 ++ packages/sim-cli/src/runtime/request.test.ts | 6 +- packages/sim-cli/src/runtime/request.ts | 6 +- 10 files changed, 324 insertions(+), 58 deletions(-) create mode 100644 packages/sim-cli/src/runtime/build.test.ts diff --git a/apps/sim/app/cli/auth/cli-auth-view.tsx b/apps/sim/app/cli/auth/cli-auth-view.tsx index 74f53ec5019..7a2af2ae600 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.tsx @@ -121,7 +121,12 @@ export function CliAuthView() { ? 'Could not load your workspaces. Connecting still works and issues a personal key; reload to pick a default workspace.' : bindsToWorkspace ? `Issues a key that can only reach ${chosen.name}.` - : 'Issues a personal key tied to your account, defaulting to this workspace. Workspace-scoped keys need admin.'} + : chosen + ? 'Issues a personal key tied to your account, defaulting to this workspace. Workspace-scoped keys need admin.' + : // No workspace picked, so none is sent and none becomes the + // profile default — promising one here would describe a + // grant that Connect is not about to make. + 'Issues a personal key tied to your account, with no default workspace.'}

)} diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index 262e2f51bac..ded0de9440e 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -25,14 +25,22 @@ import { printRecord } from '../output/render.js' * falls through to the user pasting it somewhere. */ function openBrowser(url: string): void { - const command = - process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open' + /** + * Windows needs `cmd /c start "" `. + * + * `start` is a cmd builtin, so it needs a shell — but its first quoted + * argument is the *window title*, and node quotes the URL because of the `?` + * and `&` in the query. Passing the URL alone therefore opens a console + * titled with the handoff link and no browser at all. The empty `""` takes + * the title slot so the URL lands where it belongs. + */ + const [command, args] = + process.platform === 'win32' + ? ['cmd', ['/c', 'start', '', url]] + : [process.platform === 'darwin' ? 'open' : 'xdg-open', [url]] + try { - const child = spawn(command, [url], { - stdio: 'ignore', - detached: true, - shell: process.platform === 'win32', - }) + const child = spawn(command, args, { stdio: 'ignore', detached: true }) child.on('error', () => {}) child.unref() } catch {} diff --git a/packages/sim-cli/src/commands/hand-written.ts b/packages/sim-cli/src/commands/hand-written.ts index 1a107c96cf1..534eba0aa08 100644 --- a/packages/sim-cli/src/commands/hand-written.ts +++ b/packages/sim-cli/src/commands/hand-written.ts @@ -6,7 +6,7 @@ import type { Command } from 'commander' import { clientFrom } from '../context.js' import type { QueryRowsResponse } from '../generated/v2-api.js' import { SimApiError } from '../http/client.js' -import { type Column, printList, text } from '../output/render.js' +import { type Column, printList, sanitize, text } from '../output/render.js' /** * Commands the generated runtime cannot produce. @@ -28,23 +28,44 @@ type Row = QueryRowsResponse['data'][number] * cast that would erase exactly the typing this keeps honest. */ async function streamToFile(body: ReadableStream, file: WriteStream): Promise { - const reader = body.getReader() + // Registered before the first write, not after the loop. `createWriteStream` + // opens lazily, so an EEXIST/EACCES/ENOSPC can surface at any point — with no + // listener attached it is an unhandled 'error' event that takes down the + // process instead of failing the download. + const failed = new Promise((_resolve, reject) => { + file.once('error', reject) + }) + + const pump = (async () => { + const reader = body.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + // `write` returning false means the buffer is full; waiting for `drain` + // is what stops a large file being buffered entirely in memory. + if (!file.write(value)) await once(file, 'drain') + } + } finally { + reader.releaseLock() + } + + await new Promise((resolve) => file.end(resolve)) + })() + try { - while (true) { - const { done, value } = await reader.read() - if (done) break - // `write` returning false means the buffer is full; waiting for `drain` is - // what stops a large file being buffered entirely in memory. - if (!file.write(value)) await once(file, 'drain') + await Promise.race([pump, failed]) + } catch (error) { + file.destroy() + const code = (error as NodeJS.ErrnoException).code + if (code === 'EEXIST') { + throw new SimApiError( + `${file.path} already exists. Pass --force to overwrite, or -o to write elsewhere.`, + 0 + ) } - } finally { - reader.releaseLock() + throw new SimApiError(`Could not write ${file.path}: ${(error as Error).message}`, 0) } - - await new Promise((resolve, reject) => { - file.once('error', reject) - file.end(resolve) - }) } /** @@ -70,7 +91,8 @@ function rowColumns(rows: Row[]): Column[] { value: (row: Row) => { const value = row.data[key] if (value === null || value === undefined) return text(null) - return typeof value === 'object' ? JSON.stringify(value) : String(value) + // User-defined cell data is remote content; strip terminal controls. + return sanitize(typeof value === 'object' ? JSON.stringify(value) : String(value)) }, })), ] @@ -89,36 +111,49 @@ export function attachHandWritten(program: Command): void { .command('download ') .description('Download a file') .option('-o, --output-file ', 'Where to write it (defaults to the file name)') - .action(async (fileId: string, options: { outputFile?: string }, command: Command) => { - const { client, profile } = clientFrom(command) - const workspaceId = client.requireWorkspace() - - if (!profile.apiKey) { - throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0) - } - - const url = new URL(`${profile.endpoint}/api/v2/files/${encodeURIComponent(fileId)}`) - url.searchParams.set('workspaceId', workspaceId) - - const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } }) - if (!response.ok || !response.body) { - const raw = await response.text().catch(() => '') - throw new SimApiError( - raw || `Download failed with status ${response.status}`, - response.status + .option('--force', 'Overwrite the destination if it already exists') + .action( + async ( + fileId: string, + options: { outputFile?: string; force?: boolean }, + command: Command + ) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + + if (!profile.apiKey) { + throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0) + } + + const url = new URL(`${profile.endpoint}/api/v2/files/${encodeURIComponent(fileId)}`) + url.searchParams.set('workspaceId', workspaceId) + + const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } }) + if (!response.ok || !response.body) { + const raw = await response.text().catch(() => '') + throw new SimApiError( + raw || `Download failed with status ${response.status}`, + response.status + ) + } + + const target = + options.outputFile ?? + basename( + /filename="?([^";]+)"?/.exec(response.headers.get('content-disposition') ?? '')?.[1] ?? + fileId + ) + + // `wx` fails rather than truncating: a download that silently replaces an + // existing file is unrecoverable, and the name often comes from the + // server's content-disposition rather than anything the caller typed. + await streamToFile( + response.body, + createWriteStream(target, { flags: options.force ? 'w' : 'wx' }) ) + console.log(chalk.green(`✓ Saved ${target}`)) } - - const target = - options.outputFile ?? - basename( - /filename="?([^";]+)"?/.exec(response.headers.get('content-disposition') ?? '')?.[1] ?? - fileId - ) - - await streamToFile(response.body, createWriteStream(target)) - console.log(chalk.green(`✓ Saved ${target}`)) - }) + ) // ── tables rows list ── columns come from user-defined row data ─────────── const tables = group(program, 'tables') diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts index 0212bfbcd6d..1bce7ecdf30 100644 --- a/packages/sim-cli/src/output/render.test.ts +++ b/packages/sim-cli/src/output/render.test.ts @@ -7,10 +7,13 @@ import { duration, printList, printRecord, + sanitize, text, visibleWidth, } from './render.js' +const ESC = String.fromCharCode(27) + /** Colour is stripped when not writing to a TTY, so force it on for these assertions. */ const coloured = new Chalk({ level: 1 }) @@ -189,3 +192,49 @@ describe('formatters', () => { expect(duration(90_000)).toBe('1m30s') }) }) + +describe('sanitize', () => { + // Remote content — knowledge document text, table cell values, workflow names — + // reaches an interactive terminal through the human-readable renderers. + it('removes an OSC window-title sequence', () => { + expect(sanitize(`${ESC}]0;pwned\u0007hello`)).toBe('hello') + }) + + it('removes OSC terminated by ST rather than BEL', () => { + expect(sanitize(`${ESC}]0;pwned${ESC}\\hello`)).toBe('hello') + }) + + it('removes cursor movement that would overwrite what was already printed', () => { + expect(sanitize(`before${ESC}[2A${ESC}[2Kafter`)).toBe('beforeafter') + }) + + it('removes a full terminal reset', () => { + expect(sanitize(`${ESC}creset`)).toBe('reset') + }) + + it('removes non-SGR CSI, which the old SGR-only pattern left executable', () => { + // The reported hole: stripping only `ESC [ … m` passed everything else through. + expect(sanitize(`${ESC}[6n`)).toBe('') + expect(sanitize(`${ESC}[?1049h`)).toBe('') + }) + + it('removes bare C0 and C1 control characters', () => { + expect(sanitize('a\u0000b\u0008c\u009bd')).toBe('abcd') + }) + + it('takes the following byte with a bare ESC, since ESC + printable is a sequence', () => { + expect(sanitize('a\u001bdb')).toBe('ab') + }) + + it('keeps tabs and newlines, which are legitimate content', () => { + expect(sanitize('a\tb\nc')).toBe('a\tb\nc') + }) + + it('leaves ordinary text untouched', () => { + expect(sanitize('refund policy — 30 days')).toBe('refund policy — 30 days') + }) + + it('is applied to values passing through text()', () => { + expect(text(`${ESC}]0;x\u0007safe`)).toBe('safe') + }) +}) diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index 0803973467a..0e885ada487 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -13,9 +13,50 @@ const EMPTY_GLYPH = '—' /** Cell text for values that have no useful rendering, kept visually quiet. */ const EMPTY = chalk.dim(EMPTY_GLYPH) +/** + * Escape sequences and control characters that must never reach a terminal + * from server-supplied data. + * + * Covers CSI (`ESC [ … final`), OSC (`ESC ] … BEL|ST`), single-character escapes + * such as `ESC c` (full reset), and the bare C0/C1 control range. Anything a + * knowledge document, table cell, or workflow name contains is remote content — + * a document could set the window title, move the cursor to overwrite what was + * already printed, reset the terminal, or on some emulators drive clipboard and + * paste controls. + * + * Matching only SGR (`… m`) was the hole: it stripped colour and left every + * other sequence executable. + */ +const ESC = String.fromCharCode(27) +const CONTROL_PATTERN = new RegExp( + [ + `${ESC}\\][^\\u0007${ESC}]*(?:\\u0007|${ESC}\\\\)?`, // OSC … BEL or ST + `${ESC}\\[[0-9;?]*[ -/]*[@-~]`, // CSI … final byte + // Any other ESC + printable: `ESC c` (full reset), `ESC 7`/`ESC 8` (cursor + // save/restore), `ESC (0` (line-drawing charset), and the rest. ESC is never + // legitimate content, so the whole two-byte form goes. OSC and CSI are + // matched above, so they win at the same position. + `${ESC}[ -~]`, + `${ESC}`, // a lone ESC with nothing valid after it + '[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f]', // C0/C1, keeping \t and \n + ].join('|'), + 'g' +) + +/** + * Removes terminal control sequences from a server-supplied string. + * + * Applied where API values become display text, so the colour the CLI adds + * afterwards still works — sanitizing the finished cell would strip our own + * formatting too. + */ +export function sanitize(value: string): string { + return value.replace(CONTROL_PATTERN, '') +} + export function text(value: unknown): string { if (value === null || value === undefined || value === '') return EMPTY - return String(value) + return sanitize(String(value)) } /** ISO timestamps are the wire format everywhere; show them without the milliseconds. */ diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts new file mode 100644 index 00000000000..c9b625594b6 --- /dev/null +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -0,0 +1,110 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from './build.js' + +/** + * Drives commands through commander's own parsing rather than calling + * `buildRequest` directly. + * + * The unit tests below `request.ts` fed flag values in already-keyed by flag + * name, which is not what commander produces — it camelCases every multi-word + * flag. That gap let `--min-duration-ms` and every other multi-word flag be + * silently dropped while the tests passed. Parsing real argv is the only way to + * catch that class of bug. + */ + +const { mockRequest } = vi.hoisted(() => ({ mockRequest: vi.fn() })) + +vi.mock('../context.js', () => ({ + clientFrom: () => ({ + client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, + profile: { workspaceId: 'ws_local', output: 'json', name: 'default', apiKey: 'k' }, + }), +})) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands(new Set())) root.addCommand(group) + return root +} + +async function run(argv: string[]) { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ data: [], nextCursor: null }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + await program().parseAsync(['node', 'sim', ...argv]) + return mockRequest.mock.calls[0] +} + +describe('commands parsed through commander', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('carries a multi-word flag all the way to the request', async () => { + // The regression: commander stores this as `minDurationMs`, so a lookup by + // `min-duration-ms` found nothing and the filter never reached the API. + const [, options] = await run(['logs', 'list', '--min-duration-ms', '250']) + expect(options.query).toMatchObject({ minDurationMs: 250 }) + }) + + it('carries every multi-word flag on a command, not just the first', async () => { + const [, options] = await run([ + 'logs', + 'list', + '--min-duration-ms', + '10', + '--max-duration-ms', + '20', + '--min-cost', + '1', + '--execution-id', + 'exec_1', + ]) + expect(options.query).toMatchObject({ + minDurationMs: 10, + maxDurationMs: 20, + minCost: 1, + executionId: 'exec_1', + }) + }) + + it('applies a contract flag alias', async () => { + const [path, options] = await run([ + 'tables', + 'upsert', + 'tbl_1', + '--data', + '{"a":1}', + '--on', + 'email', + ]) + expect(path).toBe('/api/v2/tables/tbl_1/rows/upsert') + expect(options.body).toMatchObject({ conflictTarget: 'email', data: { a: 1 } }) + }) + + it('comma-joins a repeated list flag', async () => { + const [, options] = await run(['logs', 'list', '--workflow', 'wf_1', 'wf_2']) + expect(options.query).toMatchObject({ workflowIds: 'wf_1,wf_2' }) + }) + + it('injects the profile workspace without a flag', async () => { + const [, options] = await run(['tables', 'list']) + expect(options.query).toMatchObject({ workspaceId: 'ws_local' }) + }) + + it('sends a boolean flag only when present', async () => { + const [, withFlag] = await run(['workflows', 'list', '--deployed-only']) + expect(withFlag.query).toMatchObject({ deployedOnly: true }) + + const [, without] = await run(['workflows', 'list']) + expect(without.query).not.toHaveProperty('deployedOnly') + }) + + it('refuses a destructive command without --yes, before any request', async () => { + await expect(run(['tables', 'rows', 'batch-delete', 'tbl_1', '--row', 'a'])).rejects.toThrow( + /cannot be undone/ + ) + expect(mockRequest).not.toHaveBeenCalled() + }) +}) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 96a9e217403..db394b112a0 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -10,6 +10,7 @@ import { duration, printList, printRecord, + sanitize, text, timestamp, } from '../output/render.js' @@ -50,7 +51,8 @@ function renderCell(value: unknown, format: ColumnSpec['format']): string { return typeof value === 'number' ? `$${value.toFixed(4)}` : text(null) default: if (value === null || value === undefined || value === '') return text(null) - return typeof value === 'object' ? JSON.stringify(value) : String(value) + // Server-supplied: strip terminal control sequences before it can reach a tty. + return sanitize(typeof value === 'object' ? JSON.stringify(value) : String(value)) } } diff --git a/packages/sim-cli/src/runtime/derive.ts b/packages/sim-cli/src/runtime/derive.ts index 5bcd9be3e75..f91aac678e1 100644 --- a/packages/sim-cli/src/runtime/derive.ts +++ b/packages/sim-cli/src/runtime/derive.ts @@ -56,3 +56,15 @@ export function deriveCommandPath(operation: V2OperationName): string[] { export function kebab(value: string): string { return value.replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`) } + +/** + * `min-duration-ms` → `minDurationMs`, the key commander actually stores. + * + * Commander camelCases every multi-word flag when it builds its options object, + * so a lookup by the flag's own name finds nothing and the value is silently + * dropped — no error, the field just never reaches the API. Every read of a + * parsed flag has to go through this. + */ +export function camel(flag: string): string { + return flag.replace(/-([a-z])/g, (_match, character: string) => character.toUpperCase()) +} diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 77199260fa3..37ab5926b66 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -31,8 +31,10 @@ describe('buildRequest', () => { expect(built.query.workflowIds).toBe('wf_1,wf_2') }) + // Keys here are camelCase because that is what commander stores — feeding + // flag-shaped keys is what let the camelCase mismatch through review. it('coerces numeric flags out of the strings argv gives', () => { - const built = buildRequest('listLogs', [], { 'min-duration-ms': '250' }, WORKSPACE) + const built = buildRequest('listLogs', [], { minDurationMs: '250' }, WORKSPACE) expect(built.query.minDurationMs).toBe(250) }) @@ -76,7 +78,7 @@ describe('buildRequest', () => { }) it('rejects a non-numeric number', () => { - expect(() => buildRequest('listLogs', [], { 'min-cost': 'lots' }, WORKSPACE)).toThrow( + expect(() => buildRequest('listLogs', [], { minCost: 'lots' }, WORKSPACE)).toThrow( '--min-cost must be a number' ) }) diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index c35afdd0976..b519d90d273 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -2,7 +2,7 @@ import { CLI_CONTRACT } from '../contract/commands.js' import type { FlagSpec } from '../contract/types.js' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' import { type QueryValue, SimApiError } from '../http/client.js' -import { kebab } from './derive.js' +import { camel, kebab } from './derive.js' /** One request field, as the generator describes it. */ export interface FieldSpec { @@ -134,7 +134,9 @@ export function buildRequest( if (flag.omit) continue const flagName = flagNameFor(operation, field) - const raw = field === PROFILE_INJECTED_FIELD ? workspaceId : flags[flagName] + // Commander stores `--min-duration-ms` as `minDurationMs`; reading by the + // flag's own name silently finds nothing. + const raw = field === PROFILE_INJECTED_FIELD ? workspaceId : flags[camel(flagName)] const value = coerce(raw ?? undefined, descriptor, flag, flagName) if (value === undefined) { From 0ca127c4833c33f343f93d537da0fa5ef427d59c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 19:22:07 -0700 Subject: [PATCH 030/159] =?UTF-8?q?fix(cli):=20review=20round=202=20?= =?UTF-8?q?=E2=80=94=20body-cursor=20paging,=20timestamp=20sanitization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## `tables rows query` printed nothing (Cursor, High) `isCursorList` only looked for `cursor` on the query slot, but `queryRows` is a POST whose whole filter — cursor included — is in the body. It therefore took the single-request path, which handed an array of rows to `printRecord` and printed an empty record, and it never auto-paged past the first page. Replaced with `cursorSlot`, which checks both slots and tells the pager where to put the cursor back. Added a defensive branch so an array reaching the single-resource path renders as a list with inferred columns rather than silently printing nothing. ## Invalid timestamps bypassed sanitization (Greptile, P1 security) `timestamp()` echoes an unparseable value verbatim, and that value is still server-supplied — so the branch was a way past every other formatter for the control sequences round 1 closed. Now sanitized on that path too. Audited the remaining formatters: no other path returns a server value unsanitized. Both fixes have tests that fail against the previous code. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- packages/sim-cli/src/output/render.test.ts | 11 ++++++ packages/sim-cli/src/output/render.ts | 5 ++- packages/sim-cli/src/runtime/build.test.ts | 36 ++++++++++++++++++++ packages/sim-cli/src/runtime/build.ts | 39 +++++++++++++++++----- 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts index 1bce7ecdf30..b9b87bbaefe 100644 --- a/packages/sim-cli/src/output/render.test.ts +++ b/packages/sim-cli/src/output/render.test.ts @@ -9,6 +9,7 @@ import { printRecord, sanitize, text, + timestamp, visibleWidth, } from './render.js' @@ -237,4 +238,14 @@ describe('sanitize', () => { it('is applied to values passing through text()', () => { expect(text(`${ESC}]0;x\u0007safe`)).toBe('safe') }) + + it('is applied to an unparseable timestamp, which is echoed verbatim', () => { + // The invalid-date branch returns the server's own string, so it was a way + // past every other formatter. + expect(timestamp(`${ESC}]0;pwned\u0007not-a-date`)).toBe('not-a-date') + }) + + it('still formats a valid timestamp normally', () => { + expect(timestamp('2026-07-31T09:14:22.500Z')).toBe('2026-07-31 09:14:22') + }) }) diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index 0e885ada487..e4e057339a4 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -63,7 +63,10 @@ export function text(value: unknown): string { export function timestamp(value: string | null | undefined): string { if (!value) return EMPTY const date = new Date(value) - if (Number.isNaN(date.getTime())) return String(value) + // Sanitized on the way out: an unparseable value is echoed verbatim, and it is + // still server-supplied, so this branch was a way to smuggle control sequences + // past every other formatter. + if (Number.isNaN(date.getTime())) return sanitize(String(value)) return date.toISOString().replace('T', ' ').slice(0, 19) } diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index c9b625594b6..1d22e329331 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -108,3 +108,39 @@ describe('commands parsed through commander', () => { expect(mockRequest).not.toHaveBeenCalled() }) }) + +describe('pagination slot', () => { + it('pages a body-cursor operation and renders its rows', async () => { + // `queryRows` is a POST whose cursor is in the body, not the query. Reading + // only the query made it take the single-request path and print nothing. + mockRequest.mockReset() + mockRequest + .mockResolvedValueOnce({ data: [{ id: 'r1' }], nextCursor: 'c1' }) + .mockResolvedValueOnce({ data: [{ id: 'r2' }], nextCursor: null }) + const lines: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => { + lines.push(line) + }) + + await program().parseAsync(['node', 'sim', 'tables', 'rows', 'query', 'tbl_1']) + + expect(mockRequest).toHaveBeenCalledTimes(2) + // Second call resumes from the cursor — in the body, where the contract puts it. + expect(mockRequest.mock.calls[1][1].body).toMatchObject({ cursor: 'c1' }) + expect(mockRequest.mock.calls[1][1].query).not.toHaveProperty('cursor') + // And the rows actually render rather than printing an empty record. + expect(JSON.parse(lines[0])).toEqual([{ id: 'r1' }, { id: 'r2' }]) + }) + + it('keeps a query-cursor operation on the query slot', async () => { + mockRequest.mockReset() + mockRequest + .mockResolvedValueOnce({ data: [{ id: 'a' }], nextCursor: 'c1' }) + .mockResolvedValueOnce({ data: [{ id: 'b' }], nextCursor: null }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program().parseAsync(['node', 'sim', 'logs', 'list']) + + expect(mockRequest.mock.calls[1][1].query).toMatchObject({ cursor: 'c1' }) + }) +}) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index db394b112a0..425e08d35e0 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -96,10 +96,24 @@ function summaryFor(operation: V2OperationName): string | undefined { return (V2_OPERATIONS[operation] as { summary?: string }).summary } -/** Whether the operation answers with the `{ data, nextCursor }` list envelope. */ -function isCursorList(operation: V2OperationName): boolean { - const spec = V2_OPERATIONS[operation] as { query?: Record } - return Boolean(spec.query && 'cursor' in spec.query) +/** + * Which request slot carries the pagination cursor, or null for a non-list + * operation. + * + * Both slots have to be checked: most lists take `cursor` as a query param, but + * `queryRows` is a POST whose whole filter — cursor included — is in the body. + * Looking only at the query made it fall through to the single-request path, + * which then rendered its array of rows through `printRecord` and printed + * nothing at all, and never auto-paged. + */ +function cursorSlot(operation: V2OperationName): 'query' | 'body' | null { + const spec = V2_OPERATIONS[operation] as { + query?: Record + body?: Record + } + if (spec.query && 'cursor' in spec.query) return 'query' + if (spec.body && 'cursor' in spec.body) return 'body' + return null } /** Adds the flags a field needs, or nothing when the contract omits it. */ @@ -199,7 +213,8 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri const { client, profile } = clientFrom(host) const request = buildRequest(operation, positional, flags, profile.workspaceId) - if (isCursorList(operation)) { + const paging = cursorSlot(operation) + if (paging) { const rawLimit = Number.parseInt(String(flags.limit ?? DEFAULT_LIMIT), 10) if (Number.isNaN(rawLimit) || rawLimit < 0) { throw new SimApiError('--limit must be a non-negative number', 0) @@ -210,10 +225,14 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri const rows: unknown[] = [] let cursor: string | null = null do { + // The cursor goes back in whichever slot the contract declared it. const page: V2Page = await client.request(request.path, { method: operationSpec.method as 'GET' | 'POST', - query: { ...request.query, cursor }, - body: request.body, + query: paging === 'query' ? { ...request.query, cursor } : request.query, + body: + paging === 'body' + ? { ...(request.body ?? {}), ...(cursor ? { cursor } : {}) } + : request.body, }) rows.push(...page.data) cursor = page.nextCursor @@ -231,8 +250,10 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri }) const data = result?.data ?? result - if (spec.columns && Array.isArray(data)) { - printList(profile.output, data, columnsFrom(spec.columns)) + if (Array.isArray(data)) { + // Reached when a non-paginated operation answers with a collection. + // `printRecord` would silently print nothing for an array. + printList(profile.output, data, spec.columns ? columnsFrom(spec.columns) : inferColumns(data)) return } From 9c0317d3c780deb1690047cbe62e514c0301026e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 19:29:49 -0700 Subject: [PATCH 031/159] =?UTF-8?q?fix(cli):=20review=20round=203=20?= =?UTF-8?q?=E2=80=94=20poll=20retry,=20download=20flush=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both findings are flaws in round 1's fixes rather than in the original code. ## A redeemable login was thrown away (Cursor, High) `pollForKey` treated every non-429 status as terminal. But the poll route releases its mint reservation on any mint failure — its own comment says "a later poll can retry" — so a transient 5xx or a same-second name conflict ended the login after the user had already approved in the browser, forcing a full restart for something the server had deliberately left recoverable. Retryable is now 409, 429, and 5xx. Everything else stays terminal: 400 means a malformed request id or verifier and 401/403/404 mean the server is refusing on purpose, so retrying those would just spin to the 15-minute timeout. ## A failed download reported success (Greptile, P1) `file.end(resolve)` passes the flush error to the callback as its argument, so the pump fulfilled *with* the error and the command printed "Saved" for a truncated file. Confirmed against node directly — `end`'s callback receives the errno. It now rejects on that argument, which is the path an ENOSPC actually takes, since the bytes may not reach disk until the final flush. Adds `device-flow.test.ts` (11 tests: the retry matrix, transport failure, terminal refusals, and that the poll secret never enters the browser URL) and `hand-written.test.ts` covering the download's overwrite guard and flush failure. The two retry tests fail against the previous code; the flush test needs `/dev/full` and so runs in CI rather than on macOS. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- packages/sim-cli/src/auth/device-flow.test.ts | 124 ++++++++++++++++++ packages/sim-cli/src/auth/device-flow.ts | 21 ++- .../sim-cli/src/commands/hand-written.test.ts | 61 +++++++++ packages/sim-cli/src/commands/hand-written.ts | 13 +- 4 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 packages/sim-cli/src/auth/device-flow.test.ts create mode 100644 packages/sim-cli/src/commands/hand-written.test.ts diff --git a/packages/sim-cli/src/auth/device-flow.test.ts b/packages/sim-cli/src/auth/device-flow.test.ts new file mode 100644 index 00000000000..80df1109946 --- /dev/null +++ b/packages/sim-cli/src/auth/device-flow.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { buildApprovalUrl, createAuthRequest, pollForKey } from './device-flow.js' + +const ENDPOINT = 'https://sim.test' + +function reply(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status }) as Response +} + +const COMPLETE = { + status: 'complete', + key: { id: 'k1', apiKey: 'sim_abc' }, + scope: 'platform', + workspaceId: 'ws_1', + workspaceBound: true, +} + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +/** Drives the poll loop without waiting out its real 2s interval. */ +async function poll(responses: Array<() => Response>) { + let call = 0 + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => responses[call++]()) + vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void) => { + fn() + return 0 as unknown as NodeJS.Timeout + }) as never) + + const auth = createAuthRequest() + return { result: await pollForKey(ENDPOINT, auth), calls: () => call } +} + +describe('pollForKey', () => { + it('returns the key once the approval completes', async () => { + const { result } = await poll([() => reply(200, COMPLETE)]) + expect(result).toMatchObject({ apiKey: 'sim_abc', scope: 'platform', workspaceBound: true }) + }) + + it('keeps polling while the approval is pending', async () => { + const { result, calls } = await poll([ + () => reply(200, { status: 'pending' }), + () => reply(200, { status: 'pending' }), + () => reply(200, COMPLETE), + ]) + expect(calls()).toBe(3) + expect(result.apiKey).toBe('sim_abc') + }) + + it('retries a 5xx, because the server released the approval for a later poll', async () => { + // The regression: treating every non-429 as terminal threw away an approval + // the user had already granted in the browser. + const { result } = await poll([ + () => reply(500, { error: 'Failed to generate API key' }), + () => reply(200, COMPLETE), + ]) + expect(result.apiKey).toBe('sim_abc') + }) + + it('retries a same-second name conflict', async () => { + const { result } = await poll([ + () => reply(409, { error: 'A personal API key named "CLI (…)" already exists.' }), + () => reply(200, COMPLETE), + ]) + expect(result.apiKey).toBe('sim_abc') + }) + + it('retries a rate-limited poll', async () => { + const { result } = await poll([() => reply(429, {}), () => reply(200, COMPLETE)]) + expect(result.apiKey).toBe('sim_abc') + }) + + it('survives a transport failure without ending the login', async () => { + let call = 0 + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + if (call++ === 0) throw new Error('ECONNRESET') + return reply(200, COMPLETE) + }) + vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void) => { + fn() + return 0 as unknown as NodeJS.Timeout + }) as never) + + const result = await pollForKey(ENDPOINT, createAuthRequest()) + expect(result.apiKey).toBe('sim_abc') + }) + + it('gives up on a deliberate refusal rather than spinning to the timeout', async () => { + await expect( + poll([() => reply(400, { error: 'verifier must be a base64url secret' })]) + ).rejects.toThrow('verifier must be a base64url secret') + }) + + it('gives up on a 403', async () => { + await expect(poll([() => reply(403, { error: 'Forbidden' })])).rejects.toThrow('Forbidden') + }) +}) + +describe('createAuthRequest', () => { + it('mints a 43-character base64url request id, challenge, and secret', () => { + const auth = createAuthRequest() + for (const value of [auth.request, auth.challenge, auth.pollSecret]) { + expect(value).toMatch(/^[A-Za-z0-9\-_]{43}$/) + } + }) + + it('uses a pairing alphabet with no look-alike characters', () => { + // The code is compared across two screens; O/0 and I/1 would defeat that. + for (let i = 0; i < 50; i++) { + expect(createAuthRequest().pairing).toMatch( + /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{4}-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{4}$/ + ) + } + }) + + it('never puts the poll secret in the browser URL', () => { + const auth = createAuthRequest() + const url = buildApprovalUrl(ENDPOINT, auth, 'platform', 'ws_1') + expect(url).toContain(encodeURIComponent(auth.challenge)) + expect(url).not.toContain(auth.pollSecret) + }) +}) diff --git a/packages/sim-cli/src/auth/device-flow.ts b/packages/sim-cli/src/auth/device-flow.ts index 01b428b817b..31fb0a5b5d7 100644 --- a/packages/sim-cli/src/auth/device-flow.ts +++ b/packages/sim-cli/src/auth/device-flow.ts @@ -18,6 +18,23 @@ const PAIRING_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' const POLL_INTERVAL_MS = 2000 const POLL_TIMEOUT_MS = 15 * 60 * 1000 +/** + * Poll statuses that leave the approval still redeemable, so the login should + * keep waiting rather than making the user restart the browser handoff. + * + * The poll route releases its mint reservation on any mint failure — its own + * comment says "a later poll can retry" — so giving up on those threw away an + * approval the user had already granted. A transient 5xx or a same-second name + * conflict (409) is exactly that case. + * + * 429 is the poll cadence hitting the per-IP bucket, not a refusal. + * + * Everything else stays terminal: 400 means a malformed request id or verifier, + * and 401/403/404 mean the server is refusing on purpose. Retrying those just + * spins until the 15-minute timeout. + */ +const RETRYABLE_POLL_STATUSES = new Set([409, 429, 500, 502, 503, 504]) + export type CliAuthScope = 'copilot' | 'platform' export interface AuthRequest { @@ -122,9 +139,7 @@ export async function pollForKey( const raw = await response.text() if (!response.ok) { - // 429 is the poll cadence bumping the per-IP bucket, not a refusal — - // back off and keep the login alive instead of making the user restart. - if (response.status !== 429) { + if (!RETRYABLE_POLL_STATUSES.has(response.status)) { let message = `Login failed with status ${response.status}` try { const body = JSON.parse(raw) as { error?: unknown } diff --git a/packages/sim-cli/src/commands/hand-written.test.ts b/packages/sim-cli/src/commands/hand-written.test.ts new file mode 100644 index 00000000000..eb3cedc1f50 --- /dev/null +++ b/packages/sim-cli/src/commands/hand-written.test.ts @@ -0,0 +1,61 @@ +import { createWriteStream, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { streamToFile } from './hand-written.js' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-dl-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function bodyOf(chunks: string[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)) + controller.close() + }, + }) +} + +describe('streamToFile', () => { + it('writes the body to disk', async () => { + const target = join(dir, 'out.txt') + await streamToFile(bodyOf(['hello ', 'world']), createWriteStream(target, { flags: 'wx' })) + expect(existsSync(target)).toBe(true) + }) + + it('refuses to clobber an existing file, naming --force', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'precious') + // The destination usually comes from the server's content-disposition, so a + // silent truncate could destroy a file the caller never named. + await expect( + streamToFile(bodyOf(['new']), createWriteStream(target, { flags: 'wx' })) + ).rejects.toThrow(/already exists.*--force/s) + }) + + it('overwrites when the caller asked for it', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'old') + await streamToFile(bodyOf(['new']), createWriteStream(target, { flags: 'w' })) + expect(existsSync(target)).toBe(true) + }) + + it.skipIf(!existsSync('/dev/full'))( + 'rejects when the final flush fails instead of reporting success', + async () => { + // `end`'s callback receives the flush error; passing `resolve` straight in + // made that error the resolution value, so a truncated download printed + // "Saved". /dev/full only errors at flush time, which is the exact path. + await expect( + streamToFile(bodyOf(['x'.repeat(64 * 1024)]), createWriteStream('/dev/full')) + ).rejects.toThrow(/Could not write/) + } + ) +}) diff --git a/packages/sim-cli/src/commands/hand-written.ts b/packages/sim-cli/src/commands/hand-written.ts index 534eba0aa08..782a0324dbb 100644 --- a/packages/sim-cli/src/commands/hand-written.ts +++ b/packages/sim-cli/src/commands/hand-written.ts @@ -27,7 +27,10 @@ type Row = QueryRowsResponse['data'][number] * are structurally incompatible under this TS config, and bridging them needs a * cast that would erase exactly the typing this keeps honest. */ -async function streamToFile(body: ReadableStream, file: WriteStream): Promise { +export async function streamToFile( + body: ReadableStream, + file: WriteStream +): Promise { // Registered before the first write, not after the loop. `createWriteStream` // opens lazily, so an EEXIST/EACCES/ENOSPC can surface at any point — with no // listener attached it is an unhandled 'error' event that takes down the @@ -50,7 +53,13 @@ async function streamToFile(body: ReadableStream, file: WriteStream) reader.releaseLock() } - await new Promise((resolve) => file.end(resolve)) + // `end`'s callback receives the error from a failed final flush (ENOSPC is + // the common one, since the bytes may not hit disk until here). Passing + // `resolve` directly made that error the resolution *value*, so the pump + // fulfilled and the command printed "Saved" for a truncated file. + await new Promise((resolve, reject) => { + file.end((error?: Error | null) => (error ? reject(error) : resolve())) + }) })() try { From 678bdc44e99b82d62a14f3ab0d38e8f6b156e9c8 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 19:36:37 -0700 Subject: [PATCH 032/159] =?UTF-8?q?fix(cli):=20review=20round=204=20?= =?UTF-8?q?=E2=80=94=20repeated=20flags=20encode=20per=20field=20kind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `coerce` comma-joined every `list` flag, but that is only correct for the three fields whose wire type is a `string` the route splits (`workflowIds`, `folderIds`, `triggers`). The others genuinely want an array: - `rowIds` and `selectedOutputs` are `array`, so joining sent a string where the schema expects a list — `sim tables rows batch-delete --row a b` failed validation, and so did a single `--row a` - `knowledgeBaseIds` is a string-or-array union whose array branch is the right one; joining made `kb_1,kb_2` a single bogus id, so multi-`--kb` search silently searched nothing `list` now means only "accept the flag more than once" — the encoding follows the field's kind, which the generator already records. The two questions were conflated under one contract field and the `FlagSpec` doc now says so. Four tests, three of which fail against the previous code. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- packages/sim-cli/src/contract/types.ts | 13 +++++++-- packages/sim-cli/src/runtime/request.test.ts | 30 ++++++++++++++++++++ packages/sim-cli/src/runtime/request.ts | 16 +++++++++-- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index f255ecc88e5..6e11f4cfe32 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -30,9 +30,16 @@ export interface FlagSpec { /** Short alias, e.g. `w` for `--workspace`. */ short?: string /** - * Accept a repeated flag and send it comma-joined. For fields the schema - * types as `string` but the route splits — invisible to any type-driven - * generator, so it has to be stated. + * Accept the flag more than once. + * + * Only says that several values are allowed — how they reach the wire is + * decided by the field's kind, not here. A `string` field is one the route + * splits on commas (`workflowIds`), so the values are joined; anything else + * genuinely wants an array (`rowIds`, `knowledgeBaseIds`). Conflating the two + * turned multi-value `--kb` and `--row` into a single bogus value. + * + * Still needed on the string case because "this string is really a list" is + * invisible to any type-driven generator. */ list?: boolean /** Take a JSON string. Implied for object/array/unknown fields. */ diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 37ab5926b66..d4e8cb12e42 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -110,3 +110,33 @@ describe('deriveCommandPath', () => { expect(deriveCommandPath('searchKnowledge')).toEqual(['knowledge', 'search']) }) }) + +describe('repeated flags encode per the field kind, not uniformly', () => { + it('joins a string field the route splits', () => { + const built = buildRequest('listLogs', [], { workflow: ['wf_1', 'wf_2'] }, WORKSPACE) + expect(built.query.workflowIds).toBe('wf_1,wf_2') + }) + + it('keeps an array field as an array', () => { + // Joining these produced a string where the wire wants an array, so + // `--row a b` failed validation — and so did a single `--row a`. + const built = buildRequest('deleteTableRows', ['tbl_1'], { row: ['r1', 'r2'] }, WORKSPACE) + expect(built.body?.rowIds).toEqual(['r1', 'r2']) + }) + + it('keeps a single repeated value as a one-element array, not a bare string', () => { + const built = buildRequest('deleteTableRows', ['tbl_1'], { row: ['r1'] }, WORKSPACE) + expect(built.body?.rowIds).toEqual(['r1']) + }) + + it('sends the array branch of a string-or-array union', () => { + // `knowledgeBaseIds` accepts either; joining made "kb_1,kb_2" a single id. + const built = buildRequest( + 'searchKnowledge', + [], + { kb: ['kb_1', 'kb_2'], query: 'refunds' }, + WORKSPACE + ) + expect(built.body?.knowledgeBaseIds).toEqual(['kb_1', 'kb_2']) + }) +}) diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index b519d90d273..44f4393fed4 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -47,11 +47,21 @@ export function takesJson(field: FieldSpec, flag: FlagSpec): boolean { export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: string): unknown { if (raw === undefined) return undefined - // A repeated flag whose wire form is one comma-joined string. The schema - // types these as `string`, so only the contract knows. + /** + * A repeated flag. `list` says the CLI accepts several values; the *wire* + * encoding follows the field's own kind, because the two are not the same + * question: + * + * - `string` — the route splits on commas (`workflowIds`, `folderIds`, + * `triggers`), so the values are joined. + * - anything else — the wire genuinely wants an array (`rowIds`, + * `selectedOutputs`) or a string-or-array union whose array branch is the + * right one (`knowledgeBaseIds`). Joining those produced a single bogus id + * or failed validation outright. + */ if (flag.list) { const values = Array.isArray(raw) ? raw : [raw] - return values.join(',') + return field.kind === 'string' ? values.join(',') : values } if (takesJson(field, flag)) { From 681ee3818cf51fed05835bfa89c3a788962e11b3 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 19:45:02 -0700 Subject: [PATCH 033/159] =?UTF-8?q?fix(cli):=20review=20round=205=20?= =?UTF-8?q?=E2=80=94=20header=20sanitization,=20auth=20ordering,=20stale?= =?UTF-8?q?=20suggestion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Table headers stayed executable (Greptile, P1 security) Round 1 sanitized cell *values* but not the column *names*, and a table's columns are user-defined — so the same control sequences were still executable one row higher, in the header. Sanitizing is now done inside `renderTable` rather than at each call site, so a future column source cannot reopen it, with the two key-derived column builders covered as well. ## Fresh install was told the wrong first step (Cursor, Low) Generated commands read `profile.workspaceId` directly, bypassing `requireWorkspace()` — which checks the key first precisely so a new user is told to log in rather than to set a workspace they cannot use yet. That ordering was fixed for the hand-written commands earlier and reintroduced by the runtime. `sim tables list` on an empty profile now says "Not logged in" again. ## A stale suggestion shadowed the fallback (Cursor, Medium) The picker took `selected ?? suggestedWorkspaceId ?? lastActiveWorkspaceId`. The suggestion comes from a profile the CLI wrote earlier, so it can name a workspace the user has since left — and merely being truthy, it blocked the last-active fallback and left the card on "no workspace" with a perfectly good one available. It now counts only when it resolves against the loaded list. Two of the three have tests that fail against the previous code; the third is verified end-to-end (`sim tables list` on an empty profile). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- apps/sim/app/cli/auth/cli-auth-view.tsx | 20 ++++++++++++++----- packages/sim-cli/src/commands/hand-written.ts | 4 +++- packages/sim-cli/src/output/render.test.ts | 10 ++++++++++ packages/sim-cli/src/output/render.ts | 12 +++++++---- packages/sim-cli/src/runtime/build.ts | 19 ++++++++++++++++-- 5 files changed, 53 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/cli/auth/cli-auth-view.tsx b/apps/sim/app/cli/auth/cli-auth-view.tsx index 7a2af2ae600..080b872f297 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.tsx @@ -71,11 +71,21 @@ export function CliAuthView() { */ const loadingWorkspaces = isPlatform && workspaces.isPending - // The terminal's suggestion, then the user's last active workspace. Derived at - // render rather than synced into state through an effect, so the first paint - // after the list loads already shows the right row. - const workspaceId = - selected ?? request.suggestedWorkspaceId ?? workspaces.data?.lastActiveWorkspaceId ?? null + /** + * The terminal's suggestion, then the user's last active workspace. Derived at + * render rather than synced into state through an effect, so the first paint + * after the list loads already shows the right row. + * + * The suggestion only counts when it resolves to a workspace the user + * actually has. It comes from a profile the CLI wrote earlier, so it can name + * a workspace they have since left or one that no longer exists — and being + * merely truthy, it used to shadow the last-active fallback and leave the card + * on "no workspace" with a perfectly good one available. + */ + const suggested = workspaces.data?.workspaces.some((w) => w.id === request.suggestedWorkspaceId) + ? request.suggestedWorkspaceId + : null + const workspaceId = selected ?? suggested ?? workspaces.data?.lastActiveWorkspaceId ?? null const chosen = workspaces.data?.workspaces.find((w) => w.id === workspaceId) // Only an admin can bind a key to a workspace. Anything less still gets a diff --git a/packages/sim-cli/src/commands/hand-written.ts b/packages/sim-cli/src/commands/hand-written.ts index 782a0324dbb..4afce929849 100644 --- a/packages/sim-cli/src/commands/hand-written.ts +++ b/packages/sim-cli/src/commands/hand-written.ts @@ -96,7 +96,9 @@ function rowColumns(rows: Row[]): Column[] { return [ { header: 'id', value: (row) => row.id }, ...keys.map((key) => ({ - header: key, + // A table's column names are user-defined, so the header is remote + // content just as much as the cell beneath it. + header: sanitize(key), value: (row: Row) => { const value = row.data[key] if (value === null || value === undefined) return text(null) diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts index b9b87bbaefe..cffd2cc677b 100644 --- a/packages/sim-cli/src/output/render.test.ts +++ b/packages/sim-cli/src/output/render.test.ts @@ -14,6 +14,7 @@ import { } from './render.js' const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) /** Colour is stripped when not writing to a TTY, so force it on for these assertions. */ const coloured = new Chalk({ level: 1 }) @@ -239,6 +240,15 @@ describe('sanitize', () => { expect(text(`${ESC}]0;x\u0007safe`)).toBe('safe') }) + it('is applied to a table header, not only its cells', () => { + // A table's column names are user-defined, so the header is remote content + // too — sanitizing cells alone left the sequences executable one row up. + const hostile = `${ESC}]0;pwned${BEL}email` + printList('table', [{ v: 'a@b.co' }], [{ header: hostile, value: () => 'a@b.co' }]) + expect(logged[0]).not.toContain(ESC) + expect(logged[0]).toContain('EMAIL') + }) + it('is applied to an unparseable timestamp, which is echoed verbatim', () => { // The invalid-date branch returns the server's own string, so it was a way // past every other formatter. diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index e4e057339a4..8a2a3eb1d0f 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -134,13 +134,17 @@ function pad(value: string, width: number): string { function renderTable(rows: T[], columns: Column[]): string { if (rows.length === 0) return chalk.dim('No results.') + // A header can be a user-defined column name (a table's own columns), so it is + // remote content and gets the same treatment as a cell. Doing it here rather + // than only at each call site means a future column source cannot reopen this. + const headers = columns.map((column) => sanitize(column.header)) const cells = rows.map((row) => columns.map((column) => column.value(row))) - const widths = columns.map((column, index) => - Math.max(visibleWidth(column.header), ...cells.map((line) => visibleWidth(line[index]))) + const widths = columns.map((_column, index) => + Math.max(visibleWidth(headers[index]), ...cells.map((line) => visibleWidth(line[index]))) ) - const header = columns - .map((column, index) => chalk.dim(pad(column.header.toUpperCase(), widths[index]))) + const header = headers + .map((label, index) => chalk.dim(pad(label.toUpperCase(), widths[index]))) .join(' ') .trimEnd() diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 425e08d35e0..bb2d4a38a61 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -86,7 +86,10 @@ function inferColumns(rows: unknown[]): Column[] { } return keys.map((key) => ({ - header: key, + // The key itself is remote data when the rows are user-defined, and the + // header is printed just like a cell — sanitizing values but not headers + // left the same control sequences executable one row higher. + header: sanitize(key), value: (row: unknown) => renderCell(at(row, key), 'auto'), })) } @@ -211,7 +214,19 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri } const { client, profile } = clientFrom(host) - const request = buildRequest(operation, positional, flags, profile.workspaceId) + // `requireWorkspace` checks the key first on purpose, so a fresh install is + // told to log in rather than to set a workspace it cannot use yet. Reading + // `profile.workspaceId` directly skipped that ordering. + const needsWorkspace = Boolean( + (operationSpec.query && PROFILE_INJECTED_FIELD in operationSpec.query) || + (operationSpec.body && PROFILE_INJECTED_FIELD in operationSpec.body) + ) + const request = buildRequest( + operation, + positional, + flags, + needsWorkspace ? client.requireWorkspace() : profile.workspaceId + ) const paging = cursorSlot(operation) if (paging) { From eddd53ac836956877b0ad0757902cd6688ae7f1d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 23:56:16 -0700 Subject: [PATCH 034/159] feat(api): add v2 endpoints for MCP servers, skills, custom tools, folders, and credentials (#6150) * feat(api): add v2 endpoints for MCP servers, skills, custom tools, folders, and credentials * fix(api): correct credential role, skill permission bar, MCP url identity, and custom-tool conflict mapping * fix(api): align credential mutation gating, provider-outage status, and unique-violation conflicts * fix(api): close unique-violation, revival, orphan-write, and env-rename gaps * fix(api): treat every provider-outage code as unavailable on create and update * fix(credentials): use the shared outage predicate on the session update path --- .../content/docs/de/api-reference/meta.json | 5 + .../content/docs/en/api-reference/meta.json | 5 + .../content/docs/es/api-reference/meta.json | 5 + .../content/docs/fr/api-reference/meta.json | 5 + .../content/docs/ja/api-reference/meta.json | 5 + .../content/docs/zh/api-reference/meta.json | 5 + apps/docs/lib/openapi.ts | 1 + apps/docs/openapi-v2-resources.json | 2737 +++++++++++++++++ apps/sim/app/api/credentials/[id]/route.ts | 10 +- apps/sim/app/api/credentials/route.ts | 654 +--- apps/sim/app/api/skills/route.ts | 187 +- apps/sim/app/api/v1/middleware.ts | 10 + .../app/api/v2/credentials/[id]/route.test.ts | 414 +++ apps/sim/app/api/v2/credentials/[id]/route.ts | 216 ++ apps/sim/app/api/v2/credentials/route.test.ts | 339 ++ apps/sim/app/api/v2/credentials/route.ts | 147 + apps/sim/app/api/v2/credentials/utils.ts | 75 + .../api/v2/custom-tools/[id]/route.test.ts | 308 ++ .../sim/app/api/v2/custom-tools/[id]/route.ts | 197 ++ .../sim/app/api/v2/custom-tools/route.test.ts | 267 ++ apps/sim/app/api/v2/custom-tools/route.ts | 136 + apps/sim/app/api/v2/custom-tools/utils.ts | 46 + .../sim/app/api/v2/folders/[id]/route.test.ts | 383 +++ apps/sim/app/api/v2/folders/[id]/route.ts | 209 ++ apps/sim/app/api/v2/folders/route.test.ts | 278 ++ apps/sim/app/api/v2/folders/route.ts | 120 + apps/sim/app/api/v2/folders/utils.ts | 60 + .../app/api/v2/mcp-servers/[id]/route.test.ts | 350 +++ apps/sim/app/api/v2/mcp-servers/[id]/route.ts | 181 ++ apps/sim/app/api/v2/mcp-servers/route.test.ts | 330 ++ apps/sim/app/api/v2/mcp-servers/route.ts | 167 + apps/sim/app/api/v2/mcp-servers/utils.ts | 50 + apps/sim/app/api/v2/skills/[id]/route.test.ts | 331 ++ apps/sim/app/api/v2/skills/[id]/route.ts | 169 + apps/sim/app/api/v2/skills/route.test.ts | 261 ++ apps/sim/app/api/v2/skills/route.ts | 116 + apps/sim/app/api/v2/skills/utils.ts | 48 + apps/sim/lib/api/contracts/skills.ts | 6 +- apps/sim/lib/api/contracts/v2/credentials.ts | 229 ++ apps/sim/lib/api/contracts/v2/custom-tools.ts | 148 + apps/sim/lib/api/contracts/v2/folders.ts | 178 ++ apps/sim/lib/api/contracts/v2/mcp-servers.ts | 217 ++ apps/sim/lib/api/contracts/v2/skills.ts | 156 + .../tools/handlers/management/manage-skill.ts | 150 +- .../orchestration/credential-create.ts | 588 ++++ .../lib/credentials/orchestration/index.ts | 8 + apps/sim/lib/credentials/queries.ts | 114 + apps/sim/lib/folders/queries.ts | 28 + apps/sim/lib/mcp/queries.ts | 63 + apps/sim/lib/posthog/events.ts | 6 +- apps/sim/lib/skills/orchestration/index.ts | 12 + .../skills/orchestration/skill-lifecycle.ts | 359 +++ .../lib/workflows/custom-tools/operations.ts | 80 + scripts/check-api-validation-contracts.ts | 4 +- scripts/check-openapi-specs.ts | 1 + 55 files changed, 10340 insertions(+), 834 deletions(-) create mode 100644 apps/docs/openapi-v2-resources.json create mode 100644 apps/sim/app/api/v2/credentials/[id]/route.test.ts create mode 100644 apps/sim/app/api/v2/credentials/[id]/route.ts create mode 100644 apps/sim/app/api/v2/credentials/route.test.ts create mode 100644 apps/sim/app/api/v2/credentials/route.ts create mode 100644 apps/sim/app/api/v2/credentials/utils.ts create mode 100644 apps/sim/app/api/v2/custom-tools/[id]/route.test.ts create mode 100644 apps/sim/app/api/v2/custom-tools/[id]/route.ts create mode 100644 apps/sim/app/api/v2/custom-tools/route.test.ts create mode 100644 apps/sim/app/api/v2/custom-tools/route.ts create mode 100644 apps/sim/app/api/v2/custom-tools/utils.ts create mode 100644 apps/sim/app/api/v2/folders/[id]/route.test.ts create mode 100644 apps/sim/app/api/v2/folders/[id]/route.ts create mode 100644 apps/sim/app/api/v2/folders/route.test.ts create mode 100644 apps/sim/app/api/v2/folders/route.ts create mode 100644 apps/sim/app/api/v2/folders/utils.ts create mode 100644 apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts create mode 100644 apps/sim/app/api/v2/mcp-servers/[id]/route.ts create mode 100644 apps/sim/app/api/v2/mcp-servers/route.test.ts create mode 100644 apps/sim/app/api/v2/mcp-servers/route.ts create mode 100644 apps/sim/app/api/v2/mcp-servers/utils.ts create mode 100644 apps/sim/app/api/v2/skills/[id]/route.test.ts create mode 100644 apps/sim/app/api/v2/skills/[id]/route.ts create mode 100644 apps/sim/app/api/v2/skills/route.test.ts create mode 100644 apps/sim/app/api/v2/skills/route.ts create mode 100644 apps/sim/app/api/v2/skills/utils.ts create mode 100644 apps/sim/lib/api/contracts/v2/credentials.ts create mode 100644 apps/sim/lib/api/contracts/v2/custom-tools.ts create mode 100644 apps/sim/lib/api/contracts/v2/folders.ts create mode 100644 apps/sim/lib/api/contracts/v2/mcp-servers.ts create mode 100644 apps/sim/lib/api/contracts/v2/skills.ts create mode 100644 apps/sim/lib/credentials/orchestration/credential-create.ts create mode 100644 apps/sim/lib/credentials/queries.ts create mode 100644 apps/sim/lib/mcp/queries.ts create mode 100644 apps/sim/lib/skills/orchestration/index.ts create mode 100644 apps/sim/lib/skills/orchestration/skill-lifecycle.ts diff --git a/apps/docs/content/docs/de/api-reference/meta.json b/apps/docs/content/docs/de/api-reference/meta.json index 74cedc72725..d2e994fef8e 100644 --- a/apps/docs/content/docs/de/api-reference/meta.json +++ b/apps/docs/content/docs/de/api-reference/meta.json @@ -15,6 +15,11 @@ "(generated)/tables", "(generated)/files", "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", "(generated)/human-in-the-loop", diff --git a/apps/docs/content/docs/en/api-reference/meta.json b/apps/docs/content/docs/en/api-reference/meta.json index 74cedc72725..d2e994fef8e 100644 --- a/apps/docs/content/docs/en/api-reference/meta.json +++ b/apps/docs/content/docs/en/api-reference/meta.json @@ -15,6 +15,11 @@ "(generated)/tables", "(generated)/files", "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", "(generated)/human-in-the-loop", diff --git a/apps/docs/content/docs/es/api-reference/meta.json b/apps/docs/content/docs/es/api-reference/meta.json index 74cedc72725..d2e994fef8e 100644 --- a/apps/docs/content/docs/es/api-reference/meta.json +++ b/apps/docs/content/docs/es/api-reference/meta.json @@ -15,6 +15,11 @@ "(generated)/tables", "(generated)/files", "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", "(generated)/human-in-the-loop", diff --git a/apps/docs/content/docs/fr/api-reference/meta.json b/apps/docs/content/docs/fr/api-reference/meta.json index 74cedc72725..d2e994fef8e 100644 --- a/apps/docs/content/docs/fr/api-reference/meta.json +++ b/apps/docs/content/docs/fr/api-reference/meta.json @@ -15,6 +15,11 @@ "(generated)/tables", "(generated)/files", "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", "(generated)/human-in-the-loop", diff --git a/apps/docs/content/docs/ja/api-reference/meta.json b/apps/docs/content/docs/ja/api-reference/meta.json index 74cedc72725..d2e994fef8e 100644 --- a/apps/docs/content/docs/ja/api-reference/meta.json +++ b/apps/docs/content/docs/ja/api-reference/meta.json @@ -15,6 +15,11 @@ "(generated)/tables", "(generated)/files", "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", "(generated)/human-in-the-loop", diff --git a/apps/docs/content/docs/zh/api-reference/meta.json b/apps/docs/content/docs/zh/api-reference/meta.json index 74cedc72725..d2e994fef8e 100644 --- a/apps/docs/content/docs/zh/api-reference/meta.json +++ b/apps/docs/content/docs/zh/api-reference/meta.json @@ -15,6 +15,11 @@ "(generated)/tables", "(generated)/files", "(generated)/knowledge-bases", + "(generated)/mcp-servers", + "(generated)/skills", + "(generated)/custom-tools", + "(generated)/folders", + "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", "(generated)/human-in-the-loop", diff --git a/apps/docs/lib/openapi.ts b/apps/docs/lib/openapi.ts index 41f0687139a..c3dac25a837 100644 --- a/apps/docs/lib/openapi.ts +++ b/apps/docs/lib/openapi.ts @@ -9,6 +9,7 @@ const SPEC_FILES = [ 'openapi-v2-tables.json', 'openapi-v2-knowledge.json', 'openapi-v2-files-audit.json', + 'openapi-v2-resources.json', ] as const export const openapi = createOpenAPI({ diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json new file mode 100644 index 00000000000..d2683950f38 --- /dev/null +++ b/apps/docs/openapi-v2-resources.json @@ -0,0 +1,2737 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Workspace Resources", + "description": "The v2 Workspace Resources API covers the resources a workspace is provisioned with: MCP servers, skills, custom tools, folders, and credentials.\n\n## Conventions\n\nAll endpoints live under the `/api/v2` base path and share a single set of conventions:\n\n- **Authentication** — Send your Sim API key in the `X-API-Key` header on every request. Keys are scoped to a workspace (or are personal keys that target a workspace); `workspaceId` is always required so the request can be tenant-scoped and rate-limited.\n- **Single-resource and mutation responses** return `{ \"data\": ... }`.\n- **List responses** use an opaque-cursor envelope: `{ \"data\": [ ... ], \"nextCursor\": string | null }`. Pass the returned `nextCursor` back as the `cursor` query parameter to fetch the next page. When `nextCursor` is `null` there are no more results. Cursors are opaque — do not parse or construct them.\n- **Errors** use a single envelope: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`. The HTTP status code and the stable `code` field move together (for example `404` ⇄ `NOT_FOUND`).\n- **Rate limiting** — Every response carries the current limiter state in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. A throttled request returns `429` with a `Retry-After` header.\n- **Secrets are write-only** — Fields that carry secret material (MCP request headers, credential values) are accepted on write and never returned on read. Reads expose only whether a secret is configured, and for headers their names.", + "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" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "MCP Servers", + "description": "Register and manage the Model Context Protocol servers a workspace connects to (v2 API)." + }, + { + "name": "Skills", + "description": "Create and manage the reusable instruction documents agents can be given (v2 API)." + }, + { + "name": "Custom Tools", + "description": "Create and manage the workspace's own code-backed tools that agents can call (v2 API)." + }, + { + "name": "Folders", + "description": "Organize workflows, knowledge bases, and tables into folder trees (v2 API)." + }, + { + "name": "Credentials", + "description": "Provision the secrets and connected accounts a workspace's agents authenticate with (v2 API)." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/mcp-servers": { + "get": { + "operationId": "listMcpServers", + "summary": "List MCP Servers", + "description": "List the MCP servers registered in a workspace. The per-workspace set is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`; treat the response as a standard cursor list so pagination can be added later without a contract change.\n\nConfigured request header **values** are never returned — use `hasHeaders` and `headerNames` to see which headers are set.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/mcp-servers?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "responses": { + "200": { + "description": "MCP servers registered in the workspace.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The MCP servers registered in the workspace.", + "items": { "$ref": "#/components/schemas/McpServer" } + }, + "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + } + }, + "example": { + "data": [ + { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "lastToolsRefresh": "2025-06-20T14:02:11.000Z", + "lastConnected": "2025-06-20T14:02:11.000Z", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + ], + "nextCursor": null + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "post": { + "operationId": "createMcpServer", + "summary": "Create MCP Server", + "description": "Register a new MCP server in a workspace. Requires `write` permission on the workspace.\n\nA server's identity is derived from its URL, so registering a URL that is already registered returns `409 CONFLICT` rather than overwriting the existing server — use `PATCH /api/v2/mcp-servers/{id}` to change one.\n\nThe `url` must be an absolute `http`/`https` URL and may not contain `{{ENV_VAR}}` references: templated hostnames defer domain-allowlist and SSRF checks to call time, which is not safe to accept over an API key.\n\n`headers` and `oauthClientSecret` are write-only and are never returned.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/mcp-servers\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Docs server\",\n \"url\": \"https://mcp.example.com/sse\",\n \"headers\": { \"Authorization\": \"Bearer YOUR_TOKEN\" }\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The MCP server to register.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateMcpServerBody" }, + "examples": { + "headerAuth": { + "summary": "Header-authenticated server", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Docs server", + "description": "Internal documentation tools", + "url": "https://mcp.example.com/sse", + "authType": "headers", + "headers": { "Authorization": "Bearer YOUR_TOKEN" }, + "timeout": 30000, + "retries": 3 + } + }, + "oauth": { + "summary": "OAuth server with pre-registered client credentials", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Partner server", + "url": "https://mcp.partner.example.com/mcp", + "authType": "oauth", + "oauthClientId": "sim-client", + "oauthClientSecret": "YOUR_CLIENT_SECRET" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The MCP server was registered.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/McpServerData" }, + "example": { + "data": { + "mcpServer": { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 0, + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/mcp-servers/{id}": { + "get": { + "operationId": "getMcpServer", + "summary": "Get MCP Server", + "description": "Fetch a single MCP server by id. Configured request header values and the OAuth client secret are never returned.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/mcp-servers/mcp-3f7a9c21?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/McpServerId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The MCP server.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/McpServerData" }, + "example": { + "data": { + "mcpServer": { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "patch": { + "operationId": "updateMcpServer", + "summary": "Update MCP Server", + "description": "Update an MCP server's configuration. Only the fields you send are changed. Requires `write` permission on the workspace.\n\n`url` is immutable: a server's id is derived from its URL, so re-pointing it would leave the id hashing an address the server no longer uses and allow two servers on one URL. Sending a different `url` returns `400` — delete the server and create one at the new address. Sending the URL it already has is accepted, so a full-object PATCH still works.\n\nChanging the auth type or the OAuth client credentials invalidates any existing OAuth grant for the server and resets its connection state.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/mcp-servers/mcp-3f7a9c21\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"enabled\": false\n }'" + } + ], + "parameters": [{ "$ref": "#/components/parameters/McpServerId" }], + "requestBody": { + "required": true, + "description": "The fields to change. `workspaceId` is required so the request is tenant-scoped.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateMcpServerBody" }, + "examples": { + "disable": { + "summary": "Disable a server", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "enabled": false + } + }, + "rotateHeaders": { + "summary": "Rotate the auth header", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "headers": { "Authorization": "Bearer NEW_TOKEN" } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated MCP server.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/McpServerData" }, + "example": { + "data": { + "mcpServer": { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "enabled": false, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-21T08:30:00.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "delete": { + "operationId": "deleteMcpServer", + "summary": "Delete MCP Server", + "description": "Remove an MCP server from the workspace and revoke any OAuth tokens issued for it. Requires `write` permission on the workspace. Workflows that referenced the server's tools keep their blocks but can no longer call it.", + "tags": ["MCP Servers"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/mcp-servers/mcp-3f7a9c21?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/McpServerId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The MCP server was deleted.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, + "example": { "data": { "id": "mcp-3f7a9c21", "deleted": true } } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/skills": { + "get": { + "operationId": "listSkills", + "summary": "List Skills", + "description": "List the skills available in a workspace. Built-in template skills that ship with Sim are included and are marked `readOnly: true`.\n\nSkill bodies can be up to 50 000 characters, so the list returns summaries only — fetch `GET /api/v2/skills/{id}` for a skill's `content`. The per-workspace set is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/skills?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "responses": { + "200": { + "description": "Skills available in the workspace.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The skills available in the workspace, without their bodies.", + "items": { "$ref": "#/components/schemas/SkillSummary" } + }, + "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + } + }, + "example": { + "data": [ + { + "id": "deploy-workflow", + "name": "deploy-workflow", + "description": "How to deploy a finished workflow", + "readOnly": true, + "createdAt": "1970-01-01T00:00:00.000Z", + "updatedAt": "1970-01-01T00:00:00.000Z" + }, + { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "readOnly": false, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "post": { + "operationId": "createSkill", + "summary": "Create Skill", + "description": "Create a skill in a workspace. Requires `write` permission on the workspace, and the creator becomes an editor of the new skill.\n\n`name` must be kebab-case and unique in the workspace; names reserved by built-in skills are rejected. Unlike the internal endpoint this creates exactly one skill and answers with it, not with the whole workspace list.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/skills\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"refund-policy\",\n \"description\": \"How support should handle refund requests\",\n \"content\": \"# Refund policy\\n\\nAlways check the order date first.\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The skill to create.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateSkillBody" }, + "examples": { + "refundPolicy": { + "summary": "A support playbook", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "refund-policy", + "description": "How support should handle refund requests", + "content": "# Refund policy\n\nAlways check the order date first." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The skill was created.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SkillData" }, + "example": { + "data": { + "skill": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "content": "# Refund policy\n\nAlways check the order date first.", + "readOnly": false, + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/skills/{id}": { + "get": { + "operationId": "getSkill", + "summary": "Get Skill", + "description": "Fetch a single skill by id, including its full `content`. Built-in template skills resolve here too and are marked `readOnly: true`.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/skills/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/SkillId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The skill.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SkillData" }, + "example": { + "data": { + "skill": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "content": "# Refund policy\n\nAlways check the order date first.", + "readOnly": false, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "patch": { + "operationId": "updateSkill", + "summary": "Update Skill", + "description": "Update a skill. Only the fields you send are changed, so a partial edit never clobbers a concurrent change to a field you did not send.\n\nRequires skill editor access — an explicit editor grant on the skill, or workspace admin. Built-in skills are read-only and are rejected.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/skills/V1StGXR8Z5jdHi6BmyT\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"description\": \"Updated refund guidance\"\n }'" + } + ], + "parameters": [{ "$ref": "#/components/parameters/SkillId" }], + "requestBody": { + "required": true, + "description": "The fields to change. At least one of `name`, `description`, or `content` is required.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateSkillBody" }, + "examples": { + "editDescription": { + "summary": "Change the description only", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "description": "Updated refund guidance" + } + }, + "replaceContent": { + "summary": "Replace the skill body", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "content": "# Refund policy\n\nCheck the order date, then the payment method." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated skill.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SkillData" }, + "example": { + "data": { + "skill": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "Updated refund guidance", + "content": "# Refund policy\n\nAlways check the order date first.", + "readOnly": false, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-21T08:30:00.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "delete": { + "operationId": "deleteSkill", + "summary": "Delete Skill", + "description": "Delete a skill from the workspace. Requires skill editor access — an explicit editor grant on the skill, or workspace admin. Built-in skills are read-only and are rejected.", + "tags": ["Skills"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/skills/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/SkillId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The skill was deleted.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, + "example": { "data": { "id": "V1StGXR8Z5jdHi6BmyT", "deleted": true } } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/custom-tools": { + "get": { + "operationId": "listCustomTools", + "summary": "List Custom Tools", + "description": "List the custom tools defined in a workspace. Custom tools are code-backed functions agents can call, declared with an OpenAI-style function schema.\n\nOnly workspace tools are returned — legacy personal tools, which predate workspace scoping and belong to a single user, are not part of the public API. The per-workspace set is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/custom-tools?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "responses": { + "200": { + "description": "Custom tools defined in the workspace.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The custom tools defined in the workspace.", + "items": { "$ref": "#/components/schemas/CustomTool" } + }, + "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + } + }, + "example": { + "data": [ + { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { "orderId": { "type": "string" } }, + "required": ["orderId"] + } + } + }, + "code": "const res = await fetch(`https://api.example.com/orders/${orderId}`)\nreturn await res.json()", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "post": { + "operationId": "createCustomTool", + "summary": "Create Custom Tool", + "description": "Create a custom tool in a workspace. Requires `write` permission on the workspace.\n\n`title` must be unique within the workspace — tools resolve by title at call time, so a duplicate returns `409 CONFLICT`. `code` is the tool body, executed in Sim's sandboxed function runtime with the schema's parameters bound as variables.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/custom-tools\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"title\": \"lookup_order\",\n \"schema\": {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"lookup_order\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": { \"orderId\": { \"type\": \"string\" } },\n \"required\": [\"orderId\"]\n }\n }\n },\n \"code\": \"return { ok: true }\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The custom tool to create.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateCustomToolBody" }, + "examples": { + "lookupOrder": { + "summary": "A tool that calls an internal API", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { "orderId": { "type": "string" } }, + "required": ["orderId"] + } + } + }, + "code": "const res = await fetch(`https://api.example.com/orders/${orderId}`)\nreturn await res.json()" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The custom tool was created.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomToolData" }, + "example": { + "data": { + "customTool": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "parameters": { + "type": "object", + "properties": { "orderId": { "type": "string" } }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/custom-tools/{id}": { + "get": { + "operationId": "getCustomTool", + "summary": "Get Custom Tool", + "description": "Fetch a single custom tool by id, scoped to the workspace.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/custom-tools/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/CustomToolId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The custom tool.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomToolData" }, + "example": { + "data": { + "customTool": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "parameters": { + "type": "object", + "properties": { "orderId": { "type": "string" } }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "patch": { + "operationId": "updateCustomTool", + "summary": "Update Custom Tool", + "description": "Update a custom tool. Only the fields you send are changed; omitted fields keep their stored values. Requires `write` permission on the workspace.\n\nRenaming onto a title another tool already uses returns `409 CONFLICT`.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/custom-tools/V1StGXR8Z5jdHi6BmyT\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"code\": \"return { ok: false }\"\n }'" + } + ], + "parameters": [{ "$ref": "#/components/parameters/CustomToolId" }], + "requestBody": { + "required": true, + "description": "The fields to change. At least one of `title`, `schema`, or `code` is required.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateCustomToolBody" }, + "examples": { + "editCode": { + "summary": "Replace the implementation only", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "code": "return { ok: false }" + } + }, + "rename": { + "summary": "Rename the tool", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "title": "find_order" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated custom tool.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CustomToolData" }, + "example": { + "data": { + "customTool": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "parameters": { + "type": "object", + "properties": { "orderId": { "type": "string" } }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: false }", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-21T08:30:00.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "delete": { + "operationId": "deleteCustomTool", + "summary": "Delete Custom Tool", + "description": "Delete a custom tool from the workspace. Requires `write` permission on the workspace. Agent blocks that referenced the tool keep their configuration but can no longer call it.", + "tags": ["Custom Tools"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/custom-tools/V1StGXR8Z5jdHi6BmyT?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/CustomToolId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The custom tool was deleted.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, + "example": { "data": { "id": "V1StGXR8Z5jdHi6BmyT", "deleted": true } } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/folders": { + "get": { + "operationId": "listFolders", + "summary": "List Folders", + "description": "List a workspace's folder tree for one resource type. One folder engine serves several trees, so `resourceType` is **required** — it selects which tree you are addressing.\n\nPass `scope=archived` to list folders in Recently Deleted instead of live ones. A workspace's tree for one resource type is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`. Folders come back in tree order (`sortOrder`, then creation time); build the hierarchy from `parentId`.", + "tags": ["Folders"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/folders?workspaceId=YOUR_WORKSPACE_ID&resourceType=workflow\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/FolderResourceTypeQuery" }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "`active` (default) lists live folders; `archived` lists Recently Deleted.", + "schema": { "type": "string", "enum": ["active", "archived"], "default": "active" } + } + ], + "responses": { + "200": { + "description": "Folders in the workspace's tree for the requested resource type.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The folders in the requested tree.", + "items": { "$ref": "#/components/schemas/Folder" } + }, + "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + } + }, + "example": { + "data": [ + { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "resourceType": "workflow", + "name": "Onboarding", + "parentId": null, + "locked": false, + "sortOrder": 0, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "deletedAt": null + } + ], + "nextCursor": null + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "post": { + "operationId": "createFolder", + "summary": "Create Folder", + "description": "Create a folder in one of a workspace's resource trees. Requires `write` permission on the workspace.\n\n`resourceType` is required and selects the tree. Pass `parentId: null` (or omit it) to create the folder at the root; a `parentId` must name a live folder of the same `resourceType` in the same workspace.\n\nA sibling folder with the same name returns `409 CONFLICT`. If the parent is a locked workflow folder, the request returns `423 LOCKED`.", + "tags": ["Folders"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/folders\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"resourceType\": \"workflow\",\n \"name\": \"Onboarding\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The folder to create.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateFolderBody" }, + "examples": { + "rootFolder": { + "summary": "A workflow folder at the workspace root", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "resourceType": "workflow", + "name": "Onboarding" + } + }, + "nestedKnowledgeFolder": { + "summary": "A knowledge-base folder nested under another", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "resourceType": "knowledge_base", + "name": "Policies", + "parentId": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The folder was created.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderData" }, + "example": { + "data": { + "folder": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "resourceType": "workflow", + "name": "Onboarding", + "parentId": null, + "locked": false, + "sortOrder": 0, + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "deletedAt": null + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "423": { "$ref": "#/components/responses/Locked" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/folders/{id}": { + "get": { + "operationId": "getFolder", + "summary": "Get Folder", + "description": "Fetch a single folder by id. Archived folders resolve too — check `deletedAt` to tell them apart from live ones.", + "tags": ["Folders"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/folders/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID&resourceType=workflow\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/FolderId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/FolderResourceTypeQuery" } + ], + "responses": { + "200": { + "description": "The folder.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderData" }, + "example": { + "data": { + "folder": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "resourceType": "workflow", + "name": "Onboarding", + "parentId": null, + "locked": false, + "sortOrder": 0, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z", + "deletedAt": null + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "patch": { + "operationId": "updateFolder", + "summary": "Update Folder", + "description": "Rename, move, or reorder a folder. Only the fields you send are changed.\n\nMoving is `parentId` — pass `null` to move to the root. A move that would place a folder inside its own subtree is rejected.\n\n`locked` applies to workflow folders only and requires workspace `admin`; sending it for another tree returns `400`. Everything else needs workspace `write`.\n\nArchived folders cannot be updated (`404`), and a mutation lock anywhere on the path returns `423 LOCKED`.", + "tags": ["Folders"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/folders/7c9e6679-7425-40de-944b-e07fc1f90ae7\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"resourceType\": \"workflow\",\n \"name\": \"Customer Onboarding\"\n }'" + } + ], + "parameters": [{ "$ref": "#/components/parameters/FolderId" }], + "requestBody": { + "required": true, + "description": "The fields to change. At least one of `name`, `locked`, `parentId`, or `sortOrder` is required.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateFolderBody" }, + "examples": { + "rename": { + "summary": "Rename a folder", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "resourceType": "workflow", + "name": "Customer Onboarding" + } + }, + "moveToRoot": { + "summary": "Move a folder to the workspace root", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "resourceType": "workflow", + "parentId": null + } + }, + "lock": { + "summary": "Lock a workflow folder (requires admin)", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "resourceType": "workflow", + "locked": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated folder.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderData" }, + "example": { + "data": { + "folder": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "resourceType": "workflow", + "name": "Customer Onboarding", + "parentId": null, + "locked": false, + "sortOrder": 0, + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-21T08:30:00.000Z", + "deletedAt": null + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "423": { "$ref": "#/components/responses/Locked" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "delete": { + "operationId": "deleteFolder", + "summary": "Delete Folder", + "description": "Archive a folder and everything under it. The cascade moves the subtree — subfolders and the resources filed in them — into Recently Deleted, and `deletedItems` reports how much was archived; only the count matching `resourceType` is populated.\n\nDeleting is idempotent: re-issuing it against an already archived folder retries the cascade onto the same snapshot rather than 404ing, so a run that failed partway can be completed.\n\nA mutation lock anywhere in the subtree returns `423 LOCKED`.", + "tags": ["Folders"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/folders/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID&resourceType=workflow\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/FolderId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/FolderResourceTypeQuery" } + ], + "responses": { + "200": { + "description": "The folder and its subtree were archived.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/FolderDeleteAcknowledgement" }, + "example": { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "deleted": true, + "deletedItems": { "folders": 3, "workflows": 12 } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "423": { "$ref": "#/components/responses/Locked" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/credentials": { + "get": { + "operationId": "listCredentials", + "summary": "List Credentials", + "description": "List the credentials you can see in a workspace. Visibility is per credential: an explicit membership grant, plus — for workspace admins — every shared credential, plus your own personal environment credentials.\n\n**Secret material is never returned.** A read tells you a secret is configured (`hasServiceAccountKey`) and nothing more. The workspace's credential set is small and bounded, so the full visible set is returned as a single page and `nextCursor` is always `null`.", + "tags": ["Credentials"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/credentials?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "name": "type", + "in": "query", + "required": false, + "description": "Only return credentials of this kind.", + "schema": { + "type": "string", + "enum": ["oauth", "env_workspace", "env_personal", "service_account"] + } + }, + { + "name": "providerId", + "in": "query", + "required": false, + "description": "Only return credentials for this integration.", + "schema": { "type": "string", "minLength": 1, "example": "slack" } + } + ], + "responses": { + "200": { + "description": "Credentials visible to the caller in the workspace.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The credentials visible to the caller.", + "items": { "$ref": "#/components/schemas/Credential" } + }, + "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + } + }, + "example": { + "data": [ + { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom account acct_123", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "envKey": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "post": { + "operationId": "createCredential", + "summary": "Create Credential", + "description": "Create a workspace credential. Requires `write` permission on the workspace; the creator becomes an admin of the credential.\n\n`oauth` credentials **cannot** be created here — they are minted by the interactive OAuth connect flow and bound to an account you authorized in a browser. The creatable types are:\n\n- `env_workspace` — a secret stored under `envKey`, available to everyone in the workspace.\n- `env_personal` — the same, scoped to you.\n- `service_account` — a provider secret (`serviceAccountJson`, `apiToken` + `domain`, `clientId` + `clientSecret` + `orgId`, …). The secret is verified against the provider before it is stored.\n\nEvery secret field is write-only and is never returned. Creation is idempotent on the credential's source (the account, the env key, or the provider + name), so re-issuing the same create returns the existing credential rather than a duplicate.", + "tags": ["Credentials"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/credentials\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"type\": \"env_workspace\",\n \"envKey\": \"STRIPE_API_KEY\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The credential to create.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateCredentialBody" }, + "examples": { + "workspaceEnvVar": { + "summary": "A workspace-wide environment secret", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "type": "env_workspace", + "envKey": "STRIPE_API_KEY" + } + }, + "clientCredentialServiceAccount": { + "summary": "A client-credentials service account", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "type": "service_account", + "providerId": "zoom-service-account", + "clientId": "YOUR_CLIENT_ID", + "clientSecret": "YOUR_CLIENT_SECRET", + "orgId": "YOUR_ACCOUNT_ID" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The credential exists with this source. Returned whether it was inserted now or already present.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CredentialData" }, + "example": { + "data": { + "credential": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "env_workspace", + "displayName": "STRIPE_API_KEY", + "description": null, + "providerId": null, + "accountId": null, + "envKey": "STRIPE_API_KEY", + "hasServiceAccountKey": false, + "role": "admin", + "createdAt": "2025-06-20T14:02:11.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" }, + "503": { "$ref": "#/components/responses/ServiceUnavailable" } + } + } + }, + "/api/v2/credentials/{id}": { + "get": { + "operationId": "getCredential", + "summary": "Get Credential", + "description": "Fetch a single credential. Secret material is never returned — `hasServiceAccountKey` tells you whether one is stored.\n\nA credential you have no grant on answers `404`, not `403`, so its existence is never disclosed to someone who cannot use it.", + "tags": ["Credentials"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/credentials/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/CredentialId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The credential.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CredentialData" }, + "example": { + "data": { + "credential": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom account acct_123", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "envKey": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-20T14:02:11.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "patch": { + "operationId": "updateCredential", + "summary": "Update Credential", + "description": "Rename a credential, change its description, or rotate its stored secret. Requires credential admin \u2014 access to the workspace plus admin rights on the credential itself. Workspace `write` is not required: credentials are gated per credential, not per workspace.\n\nSending a secret field rotates that secret in place: it is re-verified against the provider and re-encrypted, and the display name is preserved. Secrets are never returned in the response. If the provider cannot be reached to verify the new secret, the request returns `503`.\n\nA credential you cannot see answers `404` rather than `403`, so its existence is never disclosed.", + "tags": ["Credentials"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/credentials/7c9e6679-7425-40de-944b-e07fc1f90ae7\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"displayName\": \"Zoom (production)\"\n }'" + } + ], + "parameters": [{ "$ref": "#/components/parameters/CredentialId" }], + "requestBody": { + "required": true, + "description": "The fields to change. At least one field besides `workspaceId` is required.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateCredentialBody" }, + "examples": { + "rename": { + "summary": "Rename a credential", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "displayName": "Zoom (production)" + } + }, + "rotateSecret": { + "summary": "Rotate an API token", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "apiToken": "YOUR_NEW_TOKEN" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated credential.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CredentialData" }, + "example": { + "data": { + "credential": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom (production)", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "envKey": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2025-06-01T09:14:00.000Z", + "updatedAt": "2025-06-21T08:30:00.000Z" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" }, + "503": { "$ref": "#/components/responses/ServiceUnavailable" } + } + }, + "delete": { + "operationId": "deleteCredential", + "summary": "Delete Credential", + "description": "Delete a credential. Requires credential admin \u2014 access to the workspace plus admin rights on the credential itself; workspace `write` is not required. Blocks and workflows configured against it stop authenticating, and any environment variable it backed is removed.\n\nA credential you cannot see answers `404` rather than `403`.", + "tags": ["Credentials"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/credentials/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { "$ref": "#/components/parameters/CredentialId" }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The credential was deleted.", + "headers": { + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, + "example": { + "data": { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "deleted": true } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "headers": { + "RateLimitLimit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { "type": "integer", "example": 60 } + }, + "RateLimitRemaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { "type": "integer", "example": 59 } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { "type": "string", "format": "date-time", "example": "2025-06-20T14:16:00Z" } + }, + "RetryAfter": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { "type": "integer", "example": 30 } + } + }, + "parameters": { + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace that scopes the request.", + "schema": { + "type": "string", + "minLength": 1, + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + }, + "McpServerId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the MCP server.", + "schema": { "type": "string", "minLength": 1, "example": "mcp-3f7a9c21" } + }, + "SkillId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the skill. Built-in skills use their name as their id.", + "schema": { "type": "string", "minLength": 1, "example": "V1StGXR8Z5jdHi6BmyT" } + }, + "CustomToolId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the custom tool.", + "schema": { "type": "string", "minLength": 1, "example": "V1StGXR8Z5jdHi6BmyT" } + }, + "FolderId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the folder.", + "schema": { + "type": "string", + "minLength": 1, + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + } + }, + "CredentialId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the credential.", + "schema": { + "type": "string", + "minLength": 1, + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + } + }, + "FolderResourceTypeQuery": { + "name": "resourceType", + "in": "query", + "required": true, + "description": "Which resource tree the folder belongs to. Required — folder ids are unique, but addressing the wrong tree would file a folder where its page can never see it.", + "schema": { + "type": "string", + "enum": ["workflow", "knowledge_base", "table"], + "example": "workflow" + } + } + }, + "responses": { + "BadRequest": { + "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Workspace ID is required", + "details": [{ "path": "workspaceId", "message": "Workspace ID is required" }] + } + } + } + } + }, + "Unauthorized": { + "description": "The API key is missing or invalid. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, + "example": { "error": { "code": "UNAUTHORIZED", "message": "Invalid API key" } } + } + } + }, + "Forbidden": { + "description": "The authenticated caller does not have the required permission on the workspace, or the URL was rejected by the server's MCP domain policy.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, + "example": { "error": { "code": "FORBIDDEN", "message": "Access denied" } } + } + } + }, + "NotFound": { + "description": "The requested resource does not exist or is not accessible from this workspace.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, + "example": { "error": { "code": "NOT_FOUND", "message": "MCP server not found" } } + } + } + }, + "Conflict": { + "description": "The request conflicts with the current state of the workspace — for example a resource with the same identity already exists.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, + "example": { + "error": { + "code": "CONFLICT", + "message": "An MCP server with this URL already exists in this workspace." + } + } + } + } + }, + "Locked": { + "description": "A mutation lock on the resource (or something inside it) blocks the change. Unlock it and retry.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, + "example": { + "error": { + "code": "LOCKED", + "message": "This folder is locked and cannot be modified" + } + } + } + } + }, + "RateLimited": { + "description": "The rate limit has been exceeded. Retry after the period indicated by the Retry-After header.", + "headers": { + "Retry-After": { "$ref": "#/components/headers/RetryAfter" }, + "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, + "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { "retryAfter": "2025-06-20T14:16:00Z" } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred on the server.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, + "example": { "error": { "code": "INTERNAL_ERROR", "message": "Internal server error" } } + } + } + }, + "ServiceUnavailable": { + "description": "An upstream provider could not be reached to verify the request. Retry shortly.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "The credential provider is unavailable. Try again." + } + } + } + } + } + }, + "schemas": { + "Error": { + "type": "object", + "description": "The canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code.", + "enum": [ + "BAD_REQUEST", + "UNAUTHORIZED", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "LOCKED", + "RATE_LIMITED", + "INTERNAL_ERROR", + "SERVICE_UNAVAILABLE" + ] + }, + "message": { + "type": "string", + "description": "Human-readable description of the error." + }, + "details": { + "description": "Optional structured context for the error, such as field-level validation issues." + } + } + } + } + }, + "NextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more results. Pass it back as the `cursor` query parameter. Do not parse or construct cursors.", + "example": null + }, + "DeleteAcknowledgement": { + "type": "object", + "description": "Acknowledgement that a resource was deleted.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The identifier of the resource that was deleted." + }, + "deleted": { "type": "boolean", "const": true } + } + } + } + }, + "McpServer": { + "type": "object", + "description": "An MCP server registered in a workspace. Request header values and the OAuth client secret are write-only and never appear here.", + "required": [ + "id", + "name", + "transport", + "enabled", + "createdAt", + "updatedAt", + "hasHeaders", + "headerNames", + "hasOauthClientSecret" + ], + "properties": { + "id": { + "type": "string", + "description": "The server's unique identifier, derived from the workspace and the server URL." + }, + "name": { "type": "string", "description": "Display name of the server." }, + "description": { "type": "string", "description": "Optional description." }, + "transport": { + "type": "string", + "enum": ["streamable-http"], + "description": "Transport used to talk to the server." + }, + "authType": { + "type": "string", + "enum": ["none", "headers", "oauth"], + "description": "How Sim authenticates to the server." + }, + "url": { "type": "string", "description": "The server's endpoint URL." }, + "timeout": { + "type": "number", + "description": "Per-request timeout in milliseconds." + }, + "retries": { "type": "number", "description": "Number of retries per request." }, + "enabled": { + "type": "boolean", + "description": "Whether the server's tools are available to workflows." + }, + "connectionStatus": { + "type": "string", + "enum": ["connected", "disconnected", "error"], + "description": "Result of the most recent connection attempt." + }, + "lastError": { + "type": ["string", "null"], + "description": "Message from the most recent failed connection, if any." + }, + "toolCount": { + "type": "number", + "description": "Number of tools discovered on the server." + }, + "lastToolsRefresh": { + "type": "string", + "format": "date-time", + "description": "When the server's tool list was last refreshed." + }, + "lastConnected": { + "type": "string", + "format": "date-time", + "description": "When Sim last connected successfully." + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "oauthClientId": { + "type": "string", + "description": "Pre-registered OAuth client id, when the server does not support dynamic client registration." + }, + "hasHeaders": { + "type": "boolean", + "description": "Whether any request headers are configured. Values are never returned." + }, + "headerNames": { + "type": "array", + "items": { "type": "string" }, + "description": "Names of the configured request headers. Values are never returned." + }, + "hasOauthClientSecret": { + "type": "boolean", + "description": "Whether an OAuth client secret is stored for this server." + } + } + }, + "McpServerData": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["mcpServer"], + "properties": { "mcpServer": { "$ref": "#/components/schemas/McpServer" } } + } + } + }, + "CreateMcpServerBody": { + "type": "object", + "description": "A new MCP server registration.", + "additionalProperties": false, + "required": ["workspaceId", "name", "url"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to register the server in." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Display name of the server." + }, + "description": { + "type": "string", + "maxLength": 2000, + "description": "Optional description." + }, + "transport": { + "type": "string", + "enum": ["streamable-http"], + "description": "Transport used to talk to the server. Defaults to `streamable-http`." + }, + "url": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "description": "Absolute http or https endpoint URL. May not contain `{{ENV_VAR}}` references." + }, + "authType": { + "type": "string", + "enum": ["none", "headers", "oauth"], + "description": "How Sim should authenticate. Detected from the server when omitted." + }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Write-only. Request headers sent to the server, e.g. `Authorization`. Never returned on read." + }, + "timeout": { + "type": "integer", + "minimum": 1000, + "maximum": 300000, + "description": "Per-request timeout in milliseconds. Defaults to 30000." + }, + "retries": { + "type": "integer", + "minimum": 0, + "maximum": 10, + "description": "Number of retries per request. Defaults to 3." + }, + "enabled": { + "type": "boolean", + "description": "Whether the server's tools are available to workflows. Defaults to true." + }, + "oauthClientId": { + "type": ["string", "null"], + "maxLength": 512, + "description": "Pre-registered OAuth client id for servers without dynamic client registration." + }, + "oauthClientSecret": { + "type": ["string", "null"], + "maxLength": 2048, + "description": "Write-only. Pre-registered OAuth client secret. Never returned on read." + } + } + }, + "UpdateMcpServerBody": { + "type": "object", + "description": "Fields to change on an existing MCP server. Omitted fields are left as they are.", + "additionalProperties": false, + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the server." + }, + "name": { "type": "string", "minLength": 1, "maxLength": 255 }, + "description": { "type": "string", "maxLength": 2000 }, + "transport": { "type": "string", "enum": ["streamable-http"] }, + "url": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "description": "Immutable. Must equal the server's current URL — a different value returns `400`, because the server's id is derived from its URL." + }, + "authType": { "type": "string", "enum": ["none", "headers", "oauth"] }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Write-only. Replaces the stored header map wholesale." + }, + "timeout": { "type": "integer", "minimum": 1000, "maximum": 300000 }, + "retries": { "type": "integer", "minimum": 0, "maximum": 10 }, + "enabled": { "type": "boolean" }, + "oauthClientId": { "type": ["string", "null"], "maxLength": 512 }, + "oauthClientSecret": { + "type": ["string", "null"], + "maxLength": 2048, + "description": "Write-only. Never returned on read." + } + } + }, + "SkillSummary": { + "type": "object", + "description": "A skill without its body. Fetch the skill by id to read `content`.", + "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt"], + "properties": { + "id": { "type": "string", "description": "The skill's unique identifier." }, + "name": { + "type": "string", + "description": "Kebab-case name, unique within the workspace. This is what agents reference." + }, + "description": { + "type": "string", + "description": "One-line summary of when the skill applies." + }, + "readOnly": { + "type": "boolean", + "description": "True for built-in template skills, which ship with Sim and cannot be modified or deleted." + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + } + }, + "Skill": { + "type": "object", + "description": "A skill, including its full body.", + "required": ["id", "name", "description", "content", "readOnly", "createdAt", "updatedAt"], + "properties": { + "id": { "type": "string", "description": "The skill's unique identifier." }, + "name": { + "type": "string", + "description": "Kebab-case name, unique within the workspace. This is what agents reference." + }, + "description": { + "type": "string", + "description": "One-line summary of when the skill applies." + }, + "content": { + "type": "string", + "description": "The skill body — the instructions handed to the agent." + }, + "readOnly": { + "type": "boolean", + "description": "True for built-in template skills, which ship with Sim and cannot be modified or deleted." + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + } + }, + "SkillData": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["skill"], + "properties": { "skill": { "$ref": "#/components/schemas/Skill" } } + } + } + }, + "CreateSkillBody": { + "type": "object", + "description": "A new skill.", + "additionalProperties": false, + "required": ["workspaceId", "name", "description", "content"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to create the skill in." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "description": "Kebab-case name, unique within the workspace. Names reserved by built-in skills are rejected." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "One-line summary of when the skill applies." + }, + "content": { + "type": "string", + "minLength": 1, + "maxLength": 50000, + "description": "The skill body — the instructions handed to the agent." + } + } + }, + "UpdateSkillBody": { + "type": "object", + "description": "Fields to change on an existing skill. At least one of `name`, `description`, or `content` is required; omitted fields keep their stored values.", + "additionalProperties": false, + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the skill." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + }, + "description": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "content": { "type": "string", "minLength": 1, "maxLength": 50000 } + } + }, + "CustomToolSchema": { + "type": "object", + "description": "OpenAI-style function declaration describing the tool's callable surface. The parameter properties are caller-defined, so the shape below the function level is open.", + "required": ["type", "function"], + "properties": { + "type": { "type": "string", "const": "function" }, + "function": { + "type": "object", + "required": ["name", "parameters"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "The function name the model calls." + }, + "description": { + "type": "string", + "description": "What the tool does, shown to the model." + }, + "parameters": { + "type": "object", + "description": "JSON Schema for the tool's arguments.", + "required": ["type", "properties"], + "properties": { + "type": { "type": "string", "description": "Usually `object`." }, + "properties": { + "type": "object", + "additionalProperties": true, + "description": "Caller-defined argument schemas, keyed by argument name." + }, + "required": { + "type": "array", + "items": { "type": "string" }, + "description": "Names of the required arguments." + } + } + } + } + } + } + }, + "CustomTool": { + "type": "object", + "description": "A code-backed tool defined in a workspace that agents can call.", + "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], + "properties": { + "id": { "type": "string", "description": "The tool's unique identifier." }, + "title": { + "type": "string", + "description": "Display title, unique within the workspace. Tools also resolve by title at call time." + }, + "schema": { "$ref": "#/components/schemas/CustomToolSchema" }, + "code": { + "type": "string", + "description": "The tool body, executed in Sim's sandboxed function runtime with the schema's parameters bound as variables." + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + } + }, + "CustomToolData": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["customTool"], + "properties": { "customTool": { "$ref": "#/components/schemas/CustomTool" } } + } + } + }, + "CreateCustomToolBody": { + "type": "object", + "description": "A new custom tool.", + "additionalProperties": false, + "required": ["workspaceId", "title", "schema", "code"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to create the tool in." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Display title, unique within the workspace." + }, + "schema": { "$ref": "#/components/schemas/CustomToolSchema" }, + "code": { + "type": "string", + "maxLength": 100000, + "description": "The tool body, executed in Sim's sandboxed function runtime." + } + } + }, + "UpdateCustomToolBody": { + "type": "object", + "description": "Fields to change on an existing custom tool. At least one of `title`, `schema`, or `code` is required; omitted fields keep their stored values.", + "additionalProperties": false, + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the tool." + }, + "title": { "type": "string", "minLength": 1, "maxLength": 200 }, + "schema": { "$ref": "#/components/schemas/CustomToolSchema" }, + "code": { "type": "string", "maxLength": 100000 } + } + }, + "Folder": { + "type": "object", + "description": "A folder in one of a workspace's resource trees.", + "required": [ + "id", + "resourceType", + "name", + "parentId", + "locked", + "sortOrder", + "createdAt", + "updatedAt", + "deletedAt" + ], + "properties": { + "id": { "type": "string", "description": "The folder's unique identifier." }, + "resourceType": { + "type": "string", + "enum": ["workflow", "file", "knowledge_base", "table"], + "description": "Which resource tree the folder belongs to. Only `workflow`, `knowledge_base`, and `table` are served by this API; `file` folders have their own surface." + }, + "name": { "type": "string", "description": "Display name." }, + "parentId": { + "type": ["string", "null"], + "description": "The containing folder, or null when the folder sits at the workspace root." + }, + "locked": { + "type": "boolean", + "description": "Whether the folder is locked against modification. Workflow folders only; always false elsewhere." + }, + "sortOrder": { + "type": "number", + "description": "Position among its siblings, ascending." + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "deletedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "When the folder was archived into Recently Deleted, or null when it is live." + } + } + }, + "FolderData": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { "folder": { "$ref": "#/components/schemas/Folder" } } + } + } + }, + "FolderDeleteAcknowledgement": { + "type": "object", + "description": "Acknowledgement that a folder was archived, with what the cascade took with it.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The identifier of the folder that was archived." + }, + "deleted": { "type": "boolean", "const": true }, + "deletedItems": { + "type": "object", + "description": "How much the cascade archived. Only the count matching the folder's `resourceType` is populated.", + "required": ["folders"], + "properties": { + "folders": { + "type": "integer", + "description": "Subfolders archived, including the folder itself." + }, + "workflows": { "type": "integer" }, + "files": { "type": "integer" }, + "knowledgeBases": { "type": "integer" }, + "tables": { "type": "integer" } + } + } + } + } + } + }, + "CreateFolderBody": { + "type": "object", + "description": "A new folder.", + "additionalProperties": false, + "required": ["workspaceId", "resourceType", "name"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to create the folder in." + }, + "resourceType": { + "type": "string", + "enum": ["workflow", "knowledge_base", "table"], + "description": "Which resource tree to create the folder in. Required." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Display name. Must be unique among its siblings." + }, + "parentId": { + "type": ["string", "null"], + "minLength": 1, + "description": "The containing folder. Omit or pass null to create at the workspace root." + }, + "sortOrder": { + "type": "integer", + "minimum": 0, + "description": "Position among its siblings. Defaults to the top of the list." + } + } + }, + "UpdateFolderBody": { + "type": "object", + "description": "Fields to change on an existing folder. At least one of `name`, `locked`, `parentId`, or `sortOrder` is required; omitted fields keep their stored values.", + "additionalProperties": false, + "required": ["workspaceId", "resourceType"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the folder." + }, + "resourceType": { + "type": "string", + "enum": ["workflow", "knowledge_base", "table"], + "description": "Which resource tree the folder belongs to. Required." + }, + "name": { "type": "string", "minLength": 1, "maxLength": 255 }, + "locked": { + "type": "boolean", + "description": "Workflow folders only, and changing it requires workspace `admin`." + }, + "parentId": { + "type": ["string", "null"], + "minLength": 1, + "description": "New parent folder. Pass null to move to the workspace root." + }, + "sortOrder": { "type": "integer", "minimum": 0 } + } + }, + "Credential": { + "type": "object", + "description": "A stored credential. Secret material is write-only and never appears here.", + "required": [ + "id", + "type", + "displayName", + "description", + "providerId", + "accountId", + "envKey", + "hasServiceAccountKey", + "role", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { "type": "string", "description": "The credential's unique identifier." }, + "type": { + "type": "string", + "enum": ["oauth", "env_workspace", "env_personal", "service_account"], + "description": "What kind of credential this is." + }, + "displayName": { "type": "string", "description": "Display name." }, + "description": { "type": ["string", "null"] }, + "providerId": { + "type": ["string", "null"], + "description": "The integration this credential authenticates against, when it has one." + }, + "accountId": { + "type": ["string", "null"], + "description": "The linked OAuth account, for `oauth` credentials." + }, + "envKey": { + "type": ["string", "null"], + "description": "The environment-variable name, for `env_workspace` / `env_personal` credentials." + }, + "hasServiceAccountKey": { + "type": "boolean", + "description": "Whether a service-account secret is stored. The secret itself is never returned." + }, + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "The caller's role on this credential. Only admins can update or delete it." + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + } + }, + "CredentialData": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["credential"], + "properties": { "credential": { "$ref": "#/components/schemas/Credential" } } + } + } + }, + "CreateCredentialBody": { + "type": "object", + "description": "A new credential. Every secret field is write-only and is never returned.", + "additionalProperties": false, + "required": ["workspaceId", "type"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to create the credential in." + }, + "type": { + "type": "string", + "enum": ["env_workspace", "env_personal", "service_account"], + "description": "`oauth` is not creatable here — use the interactive OAuth connect flow." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Display name. Derived from the env key or the verified provider account when omitted." + }, + "description": { "type": "string", "maxLength": 500 }, + "providerId": { + "type": "string", + "minLength": 1, + "description": "Required for `service_account` — the integration the secret belongs to." + }, + "envKey": { + "type": "string", + "minLength": 1, + "description": "Required for env credentials. Letters, numbers, and underscores only; `{{NAME}}` is accepted and unwrapped." + }, + "serviceAccountJson": { + "type": "string", + "minLength": 1, + "description": "Write-only. Google-style service-account JSON key." + }, + "signingSecret": { + "type": "string", + "minLength": 1, + "description": "Write-only. Slack custom-bot signing secret." + }, + "botToken": { + "type": "string", + "minLength": 1, + "description": "Write-only. Slack custom-bot token." + }, + "apiToken": { + "type": "string", + "minLength": 1, + "description": "Write-only. Atlassian API token." + }, + "domain": { + "type": "string", + "minLength": 1, + "description": "Atlassian site domain, paired with `apiToken`." + }, + "clientId": { "type": "string", "minLength": 1, "maxLength": 512 }, + "clientSecret": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Write-only. Client-credentials secret." + }, + "orgId": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + "UpdateCredentialBody": { + "type": "object", + "description": "Fields to change on an existing credential. At least one field besides `workspaceId` is required. Sending a secret field rotates that secret in place; secrets are never returned.", + "additionalProperties": false, + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the credential." + }, + "displayName": { "type": "string", "minLength": 1, "maxLength": 255 }, + "description": { + "type": ["string", "null"], + "maxLength": 500, + "description": "Pass null to clear the description." + }, + "serviceAccountJson": { + "type": "string", + "minLength": 1, + "description": "Write-only. Replaces the stored service-account JSON key." + }, + "signingSecret": { "type": "string", "minLength": 1, "description": "Write-only." }, + "botToken": { "type": "string", "minLength": 1, "description": "Write-only." }, + "apiToken": { "type": "string", "minLength": 1, "description": "Write-only." }, + "domain": { "type": "string", "minLength": 1 }, + "clientId": { "type": "string", "minLength": 1, "maxLength": 512 }, + "clientSecret": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Write-only." + }, + "orgId": { "type": "string", "minLength": 1, "maxLength": 255 } + } + } + } + } +} diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index c0d72341ed5..1f63035ed85 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -5,7 +5,11 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { type CredentialActorContext, getCredentialActorContext } from '@/lib/credentials/access' -import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration' +import { + isProviderOutageCode, + performDeleteCredential, + performUpdateCredential, +} from '@/lib/credentials/orchestration' const logger = createLogger('CredentialByIdAPI') @@ -101,7 +105,9 @@ export const PUT = withRouteHandler( ? 409 : // A provider outage during reconnect is infra, not a bad // request — mirror the create route and runtime token route. - result.providerErrorCode === 'provider_unavailable' + // Every provider family names its own outage code, so this + // asks the shared predicate rather than matching one literal. + isProviderOutageCode(result.providerErrorCode) ? 502 : result.errorCode === 'validation' ? 400 diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 99ba00a6151..c8b1a7c540f 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -1,149 +1,26 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { account, credential, credentialMember } from '@sim/db/schema' +import { credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getPostgresErrorCode } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { and, eq, inArray, isNotNull, or } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createWorkspaceCredentialContract, credentialsListGetQuerySchema, - normalizeCredentialEnvKey, } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - getCredentialActorContext, - isSharedCredentialType, - SHARED_CREDENTIAL_TYPES, -} from '@/lib/credentials/access' -import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' -import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment' import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { - ServiceAccountSecretError, - verifyAndBuildServiceAccountSecret, -} from '@/lib/credentials/service-account-secret' -import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' -import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' -import { getServiceConfigByProviderId } from '@/lib/oauth' -import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' -import { captureServerEvent } from '@/lib/posthog/server' + performCreateCredential, + statusForCredentialOrchestrationError, +} from '@/lib/credentials/orchestration/credential-create' +import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CredentialsAPI') -/** - * Thrown by the inner duplicate guard inside the create transaction when a - * concurrent request slipped a row in between the outer existence check and - * our INSERT. The catch maps this to a 409 with a typed `code` so the UI can - * map to a friendly message. - */ -class DuplicateCredentialError extends Error { - constructor() { - super('duplicate_display_name') - this.name = 'DuplicateCredentialError' - } -} - -interface ExistingCredentialSourceParams { - workspaceId: string - type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' - accountId?: string | null - envKey?: string | null - envOwnerUserId?: string | null - displayName?: string | null - providerId?: string | null -} - -type DbOrTx = typeof db | Parameters[0]>[0] - -async function findExistingCredentialBySourceWith( - exec: DbOrTx, - params: ExistingCredentialSourceParams -) { - const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params - - if (type === 'oauth' && accountId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'oauth'), - eq(credential.accountId, accountId) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'env_workspace' && envKey) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_workspace'), - eq(credential.envKey, envKey) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'env_personal' && envKey && envOwnerUserId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_personal'), - eq(credential.envKey, envKey), - eq(credential.envOwnerUserId, envOwnerUserId) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'service_account' && displayName && providerId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'service_account'), - eq(credential.providerId, providerId), - eq(credential.displayName, displayName) - ) - ) - .limit(1) - return row ?? null - } - - return null -} - -async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) { - return findExistingCredentialBySourceWith(db, params) -} - -async function findExistingCredentialBySourceTx( - tx: Parameters[0]>[0], - params: ExistingCredentialSourceParams -) { - return findExistingCredentialBySourceWith(tx, params) -} - export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const session = await getSession() @@ -222,56 +99,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { await syncWorkspaceOAuthCredentialsForUser({ workspaceId, userId: session.user.id }) } - const whereClauses = [eq(credential.workspaceId, workspaceId)] - - if (type) { - whereClauses.push(eq(credential.type, type)) - } - if (providerId) { - whereClauses.push(eq(credential.providerId, providerId)) - } - - const isWorkspaceAdmin = workspaceAccess.canAdmin - const accessClause = isWorkspaceAdmin - ? or( - isNotNull(credentialMember.id), - inArray(credential.type, SHARED_CREDENTIAL_TYPES), - eq(credential.envOwnerUserId, session.user.id) - ) - : or(isNotNull(credentialMember.id), eq(credential.envOwnerUserId, session.user.id)) - - const rows = await db - .select({ - id: credential.id, - workspaceId: credential.workspaceId, - type: credential.type, - displayName: credential.displayName, - description: credential.description, - providerId: credential.providerId, - accountId: credential.accountId, - envKey: credential.envKey, - envOwnerUserId: credential.envOwnerUserId, - createdBy: credential.createdBy, - createdAt: credential.createdAt, - updatedAt: credential.updatedAt, - memberRole: credentialMember.role, - }) - .from(credential) - .leftJoin( - credentialMember, - and( - eq(credentialMember.credentialId, credential.id), - eq(credentialMember.userId, session.user.id), - eq(credentialMember.status, 'active') - ) - ) - .where(and(...whereClauses, accessClause)) - - const credentials = rows.map(({ memberRole, ...rest }) => ({ - ...rest, - role: - isWorkspaceAdmin && isSharedCredentialType(rest.type) ? 'admin' : (memberRole ?? 'member'), - })) + const visible = await listVisibleWorkspaceCredentials({ + workspaceId, + userId: session.user.id, + workspaceAccess, + type, + providerId, + }) + const credentials = visible.map(({ hasServiceAccountKey: _hasKey, ...rest }) => rest) return NextResponse.json({ credentials }) } catch (error) { @@ -288,433 +123,44 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - try { - const parsed = await parseRequest( - createWorkspaceCredentialContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const { - workspaceId, - type, - displayName, - description, - providerId, - accountId, - envKey, - envOwnerUserId, - serviceAccountJson, - apiToken, - domain, - id: clientCredentialId, - signingSecret, - botToken, - clientId, - clientSecret, - orgId, - } = parsed.data.body - - const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id) - if (!workspaceAccess.canWrite) { - return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) - } - - let resolvedDisplayName = displayName?.trim() ?? '' - const resolvedDescription = description?.trim() || null - let resolvedProviderId: string | null = providerId ?? null - let resolvedAccountId: string | null = accountId ?? null - const resolvedEnvKey: string | null = envKey ? normalizeCredentialEnvKey(envKey) : null - let resolvedEnvOwnerUserId: string | null = null - let resolvedEncryptedServiceAccountKey: string | null = null - const extraAuditMetadata: Record = {} - - if (type === 'oauth') { - const [accountRow] = await db - .select({ - id: account.id, - userId: account.userId, - providerId: account.providerId, - accountId: account.accountId, - }) - .from(account) - .where(eq(account.id, accountId!)) - .limit(1) - - if (!accountRow) { - return NextResponse.json({ error: 'OAuth account not found' }, { status: 404 }) - } - - if (accountRow.userId !== session.user.id) { - return NextResponse.json( - { error: 'Only account owners can create oauth credentials for an account' }, - { status: 403 } - ) - } - - if (providerId !== accountRow.providerId) { - return NextResponse.json( - { error: 'providerId does not match the selected OAuth account' }, - { status: 400 } - ) - } - if (!resolvedDisplayName) { - resolvedDisplayName = - getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId - } - } else if (type === 'service_account') { - try { - const secret = await verifyAndBuildServiceAccountSecret(providerId ?? '', { - signingSecret, - botToken, - apiToken, - domain, - serviceAccountJson, - clientId, - clientSecret, - orgId, - }) - resolvedProviderId = secret.providerId - resolvedAccountId = null - resolvedEnvOwnerUserId = null - if (!resolvedDisplayName) { - resolvedDisplayName = secret.displayName - } - resolvedEncryptedServiceAccountKey = secret.encryptedServiceAccountKey - Object.assign(extraAuditMetadata, secret.auditMetadata) - } catch (error) { - if (error instanceof ServiceAccountSecretError) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - throw error - } - } else if (type === 'env_personal') { - resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id - if (resolvedEnvOwnerUserId !== session.user.id) { - return NextResponse.json( - { error: 'Only the current user can create personal env credentials for themselves' }, - { status: 403 } - ) - } - resolvedProviderId = null - resolvedAccountId = null - resolvedDisplayName = resolvedEnvKey || '' - } else { - resolvedProviderId = null - resolvedAccountId = null - resolvedEnvOwnerUserId = null - resolvedDisplayName = resolvedEnvKey || '' - } - - if (!resolvedDisplayName) { - return NextResponse.json({ error: 'Display name is required' }, { status: 400 }) - } - - const existingCredential = await findExistingCredentialBySource({ - workspaceId, - type, - accountId: resolvedAccountId, - envKey: resolvedEnvKey, - envOwnerUserId: resolvedEnvOwnerUserId, - displayName: resolvedDisplayName, - providerId: resolvedProviderId, + const parsed = await parseRequest( + createWorkspaceCredentialContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), + } + ) + if (!parsed.success) return parsed.response + + const result = await performCreateCredential({ + ...parsed.data.body, + userId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + request, + }) + + if (!result.success) { + logger.warn(`[${requestId}] Credential create rejected`, { + errorCode: result.errorCode, + providerErrorCode: result.providerErrorCode, }) - - if (existingCredential) { - // A retried custom-bot create with the SAME pre-generated id is an - // idempotent replay and falls through to the normal existing-credential - // path. Any other name collision must fail loudly: returning the existing - // row as success would orphan the new id already embedded in the user's - // Slack Request URL (Slack would post to a URL no credential resolves). - if ( - resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && - clientCredentialId && - existingCredential.id !== clientCredentialId - ) { - return NextResponse.json( - { - code: 'duplicate_display_name', - error: `A Slack bot named "${resolvedDisplayName}" already exists in this workspace. Give this bot a different name.`, - }, - { status: 409 } - ) - } - - // Token service-account creates always carry a fresh token that must be - // stored — falling through to the existing-credential path would return - // the old credential as success and silently drop the submitted token. - if (resolvedProviderId && isTokenServiceAccountProviderId(resolvedProviderId)) { - return NextResponse.json( - { - code: 'duplicate_display_name', - error: `A credential named "${resolvedDisplayName}" already exists in this workspace. Give this one a different name.`, - }, - { status: 409 } - ) - } - - const access = await getCredentialActorContext(existingCredential.id, session.user.id, { - workspaceAccess, - }) - - if (!access.member && !access.isAdmin) { - return NextResponse.json( - { error: 'A credential with this source already exists in this workspace' }, - { status: 409 } - ) - } - - const canUpdateExistingCredential = access.isAdmin - const shouldUpdateDisplayName = - type === 'oauth' && - resolvedDisplayName && - resolvedDisplayName !== existingCredential.displayName - const shouldUpdateDescription = - typeof description !== 'undefined' && - (existingCredential.description ?? null) !== resolvedDescription - - if (canUpdateExistingCredential && (shouldUpdateDisplayName || shouldUpdateDescription)) { - await db - .update(credential) - .set({ - ...(shouldUpdateDisplayName ? { displayName: resolvedDisplayName } : {}), - ...(shouldUpdateDescription ? { description: resolvedDescription } : {}), - updatedAt: new Date(), - }) - .where(eq(credential.id, existingCredential.id)) - - const [updatedCredential] = await db - .select() - .from(credential) - .where(eq(credential.id, existingCredential.id)) - .limit(1) - - return NextResponse.json( - { credential: updatedCredential ?? existingCredential }, - { status: 200 } - ) - } - - return NextResponse.json({ credential: existingCredential }, { status: 200 }) - } - - const now = new Date() - // Honor a client-supplied id only for custom Slack bots — the setup modal - // shows the ingest URL `/api/webhooks/slack/custom/{id}` before secrets exist. - const credentialId = - resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && clientCredentialId - ? clientCredentialId - : generateId() - - const creationResult = await db.transaction(async (tx) => { - /** - * Discover the organization lock scope inside this transaction, then - * acquire the same organization → user → membership locks as org - * removal/transfer and re-authorize from the transaction before writing. - * - * If this insert wins, transfer sees the new source-owned personal - * credential and blocks. If transfer wins, its permission/member cleanup - * is visible to the authoritative re-read below and the insert is - * refused. - */ - const plannedContext = await getCredentialCreationWorkspaceContext({ - executor: tx, - workspaceId, - userId: session.user.id, - }) - if (!plannedContext) { - return { success: false as const, status: 403 as const, error: 'Write permission required' } - } - - await acquireOrganizationUserMutationLocks(tx, { - userId: session.user.id, - organizationIds: plannedContext.organizationId ? [plannedContext.organizationId] : [], - }) - - const currentContext = await getCredentialCreationWorkspaceContext({ - executor: tx, - workspaceId, - userId: session.user.id, - forUpdate: true, - }) - if (!currentContext) { - return { success: false as const, status: 403 as const, error: 'Write permission required' } - } - if (currentContext.organizationId !== plannedContext.organizationId) { - return { - success: false as const, - status: 409 as const, - error: 'Workspace organization changed while creating the credential. Please retry.', - } - } - if (!currentContext.canWrite) { - return { success: false as const, status: 403 as const, error: 'Write permission required' } - } - - // service_account has no DB-level unique index on (workspaceId, providerId, - // displayName), so we re-check inside the tx. OAuth/env_* are guarded by - // partial unique indexes and fall through to the 23505 handler below. - if (type === 'service_account') { - const innerExisting = await findExistingCredentialBySourceTx(tx, { - workspaceId, - type, - displayName: resolvedDisplayName, - providerId: resolvedProviderId, - }) - if (innerExisting) throw new DuplicateCredentialError() - } - - await tx.insert(credential).values({ - id: credentialId, - workspaceId, - type, - displayName: resolvedDisplayName, - description: resolvedDescription, - providerId: resolvedProviderId, - accountId: resolvedAccountId, - envKey: resolvedEnvKey, - envOwnerUserId: resolvedEnvOwnerUserId, - encryptedServiceAccountKey: resolvedEncryptedServiceAccountKey, - createdBy: session.user.id, - createdAt: now, - updatedAt: now, - }) - - if ((type === 'env_workspace' || type === 'service_account') && currentContext.ownerId) { - if (currentContext.memberUserIds.length > 0) { - for (const memberUserId of currentContext.memberUserIds) { - const isAdmin = memberUserId === session.user.id - await tx.insert(credentialMember).values({ - id: generateId(), - credentialId, - userId: memberUserId, - role: isAdmin ? 'admin' : 'member', - status: 'active', - joinedAt: now, - invitedBy: session.user.id, - createdAt: now, - updatedAt: now, - }) - } - } - } else { - await tx.insert(credentialMember).values({ - id: generateId(), - credentialId, - userId: session.user.id, - role: 'admin', - status: 'active', - joinedAt: now, - invitedBy: session.user.id, - createdAt: now, - updatedAt: now, - }) - } - - return { success: true as const } + const status = statusForCredentialOrchestrationError(result.errorCode, { + providerUnavailable: result.providerUnavailable, }) - if (!creationResult.success) { - return NextResponse.json({ error: creationResult.error }, { status: creationResult.status }) - } - - const [created] = await db - .select() - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - - captureServerEvent( - session.user.id, - 'credential_connected', - { credential_type: type, provider_id: resolvedProviderId ?? type, workspace_id: workspaceId }, - { - groups: { workspace: workspaceId }, - setOnce: { first_credential_connected_at: new Date().toISOString() }, - } + return NextResponse.json( + result.providerErrorCode + ? { code: result.providerErrorCode, error: result.error } + : { error: result.error }, + { status } ) - - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_CREATED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - resourceName: resolvedDisplayName, - description: `Created ${type} credential "${resolvedDisplayName}"`, - metadata: { - credentialType: type, - providerId: resolvedProviderId, - ...extraAuditMetadata, - }, - request, - }) - - return NextResponse.json({ credential: created }, { status: 201 }) - } catch (error: unknown) { - if (error instanceof AtlassianValidationError) { - logger.warn(`[${requestId}] Atlassian credential rejected: ${error.code}`, { - code: error.code, - upstreamStatus: error.status, - ...error.logDetail, - }) - return NextResponse.json({ code: error.code, error: error.code }, { status: 400 }) - } - if (error instanceof TokenServiceAccountValidationError) { - logger.warn(`[${requestId}] Token service-account credential rejected: ${error.code}`, { - code: error.code, - upstreamStatus: error.status, - ...error.logDetail, - }) - // A provider outage is an infra failure, not a bad request — mirror the - // runtime token route so monitoring sees a 502, not a 400. - const status = error.code === 'provider_unavailable' ? 502 : 400 - return NextResponse.json({ code: error.code, error: error.code }, { status }) - } - if (error instanceof DuplicateCredentialError) { - return NextResponse.json( - { - code: 'duplicate_display_name', - error: 'A credential with that name already exists in this workspace.', - }, - { status: 409 } - ) - } - const pgCode = getPostgresErrorCode(error) - if (pgCode === '23505') { - return NextResponse.json( - { error: 'A credential with this source already exists' }, - { status: 409 } - ) - } - if (pgCode === '23503') { - return NextResponse.json( - { error: 'Invalid credential reference or membership target' }, - { status: 400 } - ) - } - if (pgCode === '23514') { - return NextResponse.json( - { error: 'Credential source data failed validation checks' }, - { status: 400 } - ) - } - const errAsRecord = - typeof error === 'object' && error !== null ? (error as Record) : {} - logger.error(`[${requestId}] Credential create failure details`, { - code: pgCode, - detail: errAsRecord.detail, - constraint: errAsRecord.constraint, - table: errAsRecord.table, - message: errAsRecord.message, - }) - logger.error(`[${requestId}] Failed to create credential`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } + + // An existing credential matched the source: an idempotent replay, not a create. + return NextResponse.json( + { credential: result.credential }, + { status: result.created ? 201 : 200 } + ) }) diff --git a/apps/sim/app/api/skills/route.ts b/apps/sim/app/api/skills/route.ts index f31f0881e71..251635a37fb 100644 --- a/apps/sim/app/api/skills/route.ts +++ b/apps/sim/app/api/skills/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { @@ -10,10 +9,14 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { checkSkillsUpdateAccess, getSkillActorContext } from '@/lib/skills/access' +import { + performCreateSkill, + performDeleteSkill, + performUpdateSkill, + statusForSkillOrchestrationError, +} from '@/lib/skills/orchestration' import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' -import { deleteSkill, listSkillsForUser, upsertSkills } from '@/lib/workflows/skills/operations' +import { listSkillsForUser } from '@/lib/workflows/skills/operations' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('SkillsAPI') @@ -92,84 +95,75 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - if (skills.some((s) => s.id && isBuiltinSkillId(s.id))) { - return NextResponse.json({ error: 'Built-in skills are read-only' }, { status: 400 }) + /** + * Each item is applied through the skill orchestration, which owns the + * built-in guard, the field limits, the per-skill editor check, and the + * audit. Creating still requires workspace write; editing an existing skill + * is gated per skill inside `performUpdateSkill`. + * + * The batch is applied item by item rather than in one transaction: this + * endpoint's callers submit a single skill, and one shared authority for the + * rules is worth more than atomicity across a batch nobody sends. + */ + const actor = { + actorName: authResult.userName, + actorEmail: authResult.userEmail, + source, + request: req, } - // Updating an existing skill requires editor access (explicit editor row - // or derived workspace admin); creating a new one requires workspace write. - const requestedIds = skills.flatMap((s) => (s.id ? [s.id] : [])) - const { existingIds, denied } = await checkSkillsUpdateAccess({ - workspaceId, - userId, - skillIds: requestedIds, - workspaceAccess, - }) - - if (denied.length > 0) { - logger.warn(`[${requestId}] User ${userId} is not an editor of skills being updated`, { - deniedSkillIds: denied.map((s) => s.id), - }) - return NextResponse.json( - { - error: `Skill editor access required to update: ${denied.map((s) => s.name).join(', ')}`, - }, - { status: 403 } - ) - } + for (const item of skills) { + if (item.id) { + const result = await performUpdateSkill({ + workspaceId, + userId, + skillId: item.id, + name: item.name, + description: item.description, + content: item.content, + ...actor, + }) + if (!result.success) { + logger.warn(`[${requestId}] Skill update rejected`, { + skillId: item.id, + errorCode: result.errorCode, + }) + return NextResponse.json( + { error: result.error ?? 'Failed to update skill' }, + { status: statusForSkillOrchestrationError(result.errorCode) } + ) + } + continue + } - const hasCreates = skills.some((s) => !s.id || !existingIds.has(s.id)) - if (hasCreates && !workspaceAccess.canWrite) { - logger.warn( - `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) - } + if (!workspaceAccess.canWrite) { + logger.warn( + `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) + } - try { - const { touched } = await upsertSkills({ - skills, + const result = await performCreateSkill({ workspaceId, userId, - requestId, - returnSkills: false, + name: item.name!, + description: item.description!, + content: item.content!, + ...actor, }) - - for (const { id, name, operation } of touched) { - const isUpdate = operation === 'updated' - recordAudit({ - workspaceId, - actorId: userId, - actorName: authResult.userName ?? undefined, - actorEmail: authResult.userEmail ?? undefined, - action: isUpdate ? AuditAction.SKILL_UPDATED : AuditAction.SKILL_CREATED, - resourceType: AuditResourceType.SKILL, - resourceId: id, - resourceName: name, - description: `${isUpdate ? 'Updated' : 'Created'} skill "${name}"`, - metadata: { source }, - }) - captureServerEvent( - userId, - isUpdate ? 'skill_updated' : 'skill_created', - { skill_id: id, skill_name: name, workspace_id: workspaceId, source }, - { groups: { workspace: workspaceId } } + if (!result.success) { + logger.warn(`[${requestId}] Skill create rejected`, { errorCode: result.errorCode }) + return NextResponse.json( + { error: result.error ?? 'Failed to create skill' }, + { status: statusForSkillOrchestrationError(result.errorCode) } ) } + } - const resultSkills = await listSkillsForUser({ workspaceId, userId, workspaceAccess }) - const data = resultSkills.map((s) => ({ ...s, readOnly: isBuiltinSkillId(s.id) })) + const resultSkills = await listSkillsForUser({ workspaceId, userId, workspaceAccess }) + const data = resultSkills.map((s) => ({ ...s, readOnly: isBuiltinSkillId(s.id) })) - return NextResponse.json({ success: true, data }) - } catch (upsertError) { - if (upsertError instanceof Error && upsertError.message.includes('is unavailable')) { - return NextResponse.json({ error: upsertError.message }, { status: 409 }) - } - if (upsertError instanceof Error && upsertError.message.startsWith('Skill not found')) { - return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) - } - throw upsertError - } + return NextResponse.json({ success: true, data }) } catch (error) { logger.error(`[${requestId}] Error updating skills`, error) return NextResponse.json({ error: 'Failed to update skills' }, { status: 500 }) @@ -200,42 +194,25 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { } const { id: skillId, workspaceId, source } = query.data - if (!isBuiltinSkillId(skillId)) { - const actor = await getSkillActorContext(skillId, userId) - if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) { - logger.warn(`[${requestId}] Skill not found: ${skillId}`) - return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) - } - if (!actor.canEdit) { - logger.warn(`[${requestId}] User ${userId} is not an editor of skill ${skillId}`) - return NextResponse.json({ error: 'Skill editor access required' }, { status: 403 }) - } - } - - const deleted = await deleteSkill({ skillId, workspaceId }) - if (!deleted) { - logger.warn(`[${requestId}] Skill not found: ${skillId}`) - return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) - } - - recordAudit({ + const result = await performDeleteSkill({ workspaceId, - actorId: authResult.userId, - actorName: authResult.userName ?? undefined, - actorEmail: authResult.userEmail ?? undefined, - action: AuditAction.SKILL_DELETED, - resourceType: AuditResourceType.SKILL, - resourceId: skillId, - description: `Deleted skill`, - metadata: { source }, - }) - - captureServerEvent( userId, - 'skill_deleted', - { skill_id: skillId, workspace_id: workspaceId, source }, - { groups: { workspace: workspaceId } } - ) + skillId, + actorName: authResult.userName, + actorEmail: authResult.userEmail, + source, + request, + }) + if (!result.success) { + logger.warn(`[${requestId}] Skill delete rejected`, { + skillId, + errorCode: result.errorCode, + }) + return NextResponse.json( + { error: result.error ?? 'Failed to delete skill' }, + { status: statusForSkillOrchestrationError(result.errorCode) } + ) + } logger.info(`[${requestId}] Deleted skill: ${skillId}`) return NextResponse.json({ success: true }) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 1eb3d86acfe..97f7372f7a6 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -45,6 +45,16 @@ export type ApiEndpoint = | 'knowledge-search' | 'copilot-chat' | 'billing-usage' + | 'mcp-servers' + | 'mcp-server-detail' + | 'skills' + | 'skill-detail' + | 'custom-tools' + | 'custom-tool-detail' + | 'folders' + | 'folder-detail' + | 'credentials' + | 'credential-detail' export interface RateLimitResult { allowed: boolean diff --git a/apps/sim/app/api/v2/credentials/[id]/route.test.ts b/apps/sim/app/api/v2/credentials/[id]/route.test.ts new file mode 100644 index 00000000000..b76997daad2 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[id]/route.test.ts @@ -0,0 +1,414 @@ +/** + * @vitest-environment node + * + * Public v2 credential detail: workspace scoping of the id, the 404 mask for a + * credential the caller has no membership on, and secret-free reads. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetWorkspaceCredential, + mockGetCredentialActorContext, + mockPerformUpdateCredential, + mockPerformDeleteCredential, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetWorkspaceCredential: vi.fn(), + mockGetCredentialActorContext: vi.fn(), + mockPerformUpdateCredential: vi.fn(), + mockPerformDeleteCredential: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/credentials/queries', () => ({ + getWorkspaceCredential: mockGetWorkspaceCredential, +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mockGetCredentialActorContext, +})) + +vi.mock('@/lib/credentials/orchestration', async () => { + const actual = await import('@/lib/credentials/orchestration/credential-create') + return { + isProviderOutageCode: actual.isProviderOutageCode, + performUpdateCredential: mockPerformUpdateCredential, + performDeleteCredential: mockPerformDeleteCredential, + } +}) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/credentials/[id]/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +function buildRow(overrides: Record = {}) { + return { + id: 'cred_abc123', + workspaceId: WORKSPACE_ID, + type: 'service_account', + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted-blob', + createdBy: 'user-1', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'cred_abc123' }) }) +const url = (query = `workspaceId=${WORKSPACE_ID}`) => + `http://localhost:3000/api/v2/credentials/cred_abc123?${query}` + +const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) +const callDelete = (query?: string) => + DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/credentials/cred_abc123', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +describe('GET /api/v2/credentials/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCredential.mockResolvedValue(buildRow()) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockGetWorkspaceCredential).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect(mockGetWorkspaceCredential).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(403) + expect(mockGetWorkspaceCredential).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the credential belongs to another workspace', async () => { + mockGetWorkspaceCredential.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('masks a credential the caller has no membership on as 404', async () => { + mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the public shape with no secret material', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.credential).toEqual({ + id: 'cred_abc123', + type: 'service_account', + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + hasServiceAccountKey: true, + role: 'admin', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }) + expect(JSON.stringify(body)).not.toContain('encrypted-blob') + }) +}) + +describe('PATCH /api/v2/credentials/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCredential.mockResolvedValue(buildRow()) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) + mockPerformUpdateCredential.mockResolvedValue({ success: true }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + + expect(res.status).toBe(404) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('400s when no field to change is supplied', async () => { + const res = await callPatch({ workspaceId: WORKSPACE_ID }) + expect(res.status).toBe(400) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('400s when the body carries an unknown field', async () => { + const res = await callPatch({ workspaceId: WORKSPACE_ID, bogus: 'x' }) + expect(res.status).toBe(400) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(403) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the credential belongs to another workspace', async () => { + mockGetWorkspaceCredential.mockResolvedValue(null) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(404) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('403s when the caller is not a credential admin', async () => { + mockPerformUpdateCredential.mockResolvedValue({ + success: false, + error: 'Credential admin permission required', + errorCode: 'forbidden', + }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(403) + expect((await res.json()).error.code).toBe('FORBIDDEN') + }) + + it('gates on workspace read, leaving admin rights to the per-credential check', async () => { + await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + WORKSPACE_ID, + 'read' + ) + }) + + it('masks a credential the caller cannot see as 404, not 403', async () => { + mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + expect(res.status).toBe(404) + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('503s when the provider is unreachable during a secret rotation', async () => { + mockPerformUpdateCredential.mockResolvedValue({ + success: false, + error: 'provider_unavailable', + errorCode: 'validation', + providerErrorCode: 'provider_unavailable', + }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' }) + expect(res.status).toBe(503) + expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE') + }) + + it('rejects a displayName rename on an env credential instead of dropping it', async () => { + mockGetWorkspaceCredential.mockResolvedValue( + buildRow({ type: 'env_workspace', envKey: 'STRIPE_API_KEY', displayName: 'STRIPE_API_KEY' }) + ) + const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('envKey') + expect(mockPerformUpdateCredential).not.toHaveBeenCalled() + }) + + it('still allows a description change on an env credential', async () => { + mockGetWorkspaceCredential.mockResolvedValue(buildRow({ type: 'env_workspace' })) + const res = await callPatch({ workspaceId: WORKSPACE_ID, description: 'note' }) + + expect(res.status).toBe(200) + expect(mockPerformUpdateCredential).toHaveBeenCalled() + }) + + it('503s on an Atlassian outage too, not just a token-provider one', async () => { + mockPerformUpdateCredential.mockResolvedValue({ + success: false, + error: 'atlassian_unavailable', + errorCode: 'validation', + providerErrorCode: 'atlassian_unavailable', + }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' }) + expect(res.status).toBe(503) + expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE') + }) + + it('keeps a rejected secret a 400, not a 503', async () => { + mockPerformUpdateCredential.mockResolvedValue({ + success: false, + error: 'invalid_credentials', + errorCode: 'validation', + providerErrorCode: 'invalid_credentials', + }) + const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' }) + expect(res.status).toBe(400) + }) + + it('rotates a secret without echoing it back', async () => { + const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'brand-new-token' }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(JSON.stringify(body)).not.toContain('brand-new-token') + expect(mockPerformUpdateCredential).toHaveBeenCalledWith( + expect.objectContaining({ + credentialId: 'cred_abc123', + userId: 'user-1', + apiToken: 'brand-new-token', + }) + ) + }) +}) + +describe('DELETE /api/v2/credentials/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCredential.mockResolvedValue(buildRow()) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) + mockPerformDeleteCredential.mockResolvedValue({ success: true }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(403) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the credential belongs to another workspace', async () => { + mockGetWorkspaceCredential.mockResolvedValue(null) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('gates on workspace read, leaving admin rights to the per-credential check', async () => { + await callDelete() + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + WORKSPACE_ID, + 'read' + ) + }) + + it('masks a credential the caller cannot see as 404, not 403', async () => { + mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockPerformDeleteCredential).not.toHaveBeenCalled() + }) + + it('deletes the credential and acknowledges the id', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'cred_abc123', deleted: true } }) + expect(mockPerformDeleteCredential).toHaveBeenCalledWith( + expect.objectContaining({ credentialId: 'cred_abc123', userId: 'user-1' }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/credentials/[id]/route.ts b/apps/sim/app/api/v2/credentials/[id]/route.ts new file mode 100644 index 00000000000..d92c016b284 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[id]/route.ts @@ -0,0 +1,216 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteCredentialContract, + v2GetCredentialContract, + v2UpdateCredentialContract, +} from '@/lib/api/contracts/v2/credentials' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { + isProviderOutageCode, + performDeleteCredential, + performUpdateCredential, +} from '@/lib/credentials/orchestration' +import { getWorkspaceCredential } from '@/lib/credentials/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2CredentialRow, v2CredentialOrchestrationError } from '@/app/api/v2/credentials/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CredentialDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/credentials/[id] — Fetch a single credential. Secrets are never returned. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credential-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetCredentialContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const credential = await getWorkspaceCredential({ workspaceId, credentialId: id }) + if (!credential) return v2Error('NOT_FOUND', 'Credential not found') + + /** + * Workspace access is not credential access: seeing a credential requires a + * membership row (or workspace admin over a shared type). A caller who has + * neither gets 404 rather than 403 so credential existence never leaks to + * someone who cannot use it. + */ + const actor = await getCredentialActorContext(id, userId) + if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found') + + return v2Data( + { credential: toV2CredentialRow(credential, actor.isAdmin ? 'admin' : 'member') }, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error fetching credential`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/credentials/[id] — Rename, re-describe, or rotate a credential's secret. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credential-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateCredentialContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, ...changes } = parsed.data.body + + /** + * Credential mutations are gated per credential, not per workspace: + * `performUpdateCredential` requires credential admin, and the internal + * surface applies no workspace-level bar at all. Requiring workspace `write` + * here would lock out a credential admin who only holds `read`. + */ + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + // Tenant-scope the id before the orchestration re-derives access from the + // credential's own workspace. + const existing = await getWorkspaceCredential({ workspaceId, credentialId: id }) + if (!existing) return v2Error('NOT_FOUND', 'Credential not found') + + const actor = await getCredentialActorContext(id, userId) + if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found') + + /** + * An env credential's display name IS its `envKey` — the lib only applies + * `displayName` to `oauth` and `service_account`, so accepting it here would + * either drop the rename silently (when sent alongside `description`) or + * fail with an unrelated environment-editor message (when sent alone). + */ + if ( + changes.displayName !== undefined && + (existing.type === 'env_workspace' || existing.type === 'env_personal') + ) { + return v2Error( + 'BAD_REQUEST', + 'displayName cannot be set on an environment credential — its name is its envKey. Delete it and create one under the new key.' + ) + } + + const result = await performUpdateCredential({ ...changes, credentialId: id, userId, request }) + + if (!result.success) { + return v2CredentialOrchestrationError( + result.errorCode, + result.error ?? 'Failed to update credential', + { providerUnavailable: isProviderOutageCode(result.providerErrorCode) } + ) + } + + const updated = await getWorkspaceCredential({ workspaceId, credentialId: id }) + if (!updated) return v2Error('NOT_FOUND', 'Credential not found') + + return v2Data({ credential: toV2CredentialRow(updated, 'admin') }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error updating credential`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/credentials/[id] — Delete a credential and revoke what it backed. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credential-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteCredentialContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + // Gated per credential by `performDeleteCredential`, same as PATCH above. + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const existing = await getWorkspaceCredential({ workspaceId, credentialId: id }) + if (!existing) return v2Error('NOT_FOUND', 'Credential not found') + + /** + * A credential the caller cannot see answers 404, matching GET, so a + * workspace member cannot tell an inaccessible credential from a missing one + * and enumerate ids. A credential they *can* see but cannot administer still + * gets the orchestration's 403 — that distinction is not a leak, since GET + * already shows them the credential. + */ + const actor = await getCredentialActorContext(id, userId) + if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found') + + const result = await performDeleteCredential({ credentialId: id, userId, request }) + if (!result.success) { + return v2CredentialOrchestrationError( + result.errorCode, + result.error ?? 'Failed to delete credential' + ) + } + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting credential`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts new file mode 100644 index 00000000000..d9fa30535a9 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -0,0 +1,339 @@ +/** + * @vitest-environment node + * + * Public v2 credentials list/create: gate ordering, the write-only treatment of + * secret material, and the exclusion of `oauth` from the creatable types. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockCheckWorkspaceAccess, + mockListVisibleWorkspaceCredentials, + mockPerformCreateCredential, + mockGetCredentialActorContext, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockCheckWorkspaceAccess: vi.fn(), + mockListVisibleWorkspaceCredentials: vi.fn(), + mockPerformCreateCredential: vi.fn(), + mockGetCredentialActorContext: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + +vi.mock('@/lib/credentials/queries', () => ({ + listVisibleWorkspaceCredentials: mockListVisibleWorkspaceCredentials, +})) + +vi.mock('@/lib/credentials/orchestration', () => ({ + performCreateCredential: mockPerformCreateCredential, +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mockGetCredentialActorContext, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/credentials/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +function buildVisible(overrides: Record = {}) { + return { + id: 'cred_abc123', + workspaceId: WORKSPACE_ID, + type: 'service_account' as const, + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + hasServiceAccountKey: true, + role: 'admin' as const, + ...overrides, + } +} + +function buildRow(overrides: Record = {}) { + return { + id: 'cred_abc123', + workspaceId: WORKSPACE_ID, + type: 'service_account', + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted-blob', + createdBy: 'user-1', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/credentials?${query}`)) + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +const VALID_BODY = { + workspaceId: WORKSPACE_ID, + type: 'env_workspace', + envKey: 'STRIPE_API_KEY', +} + +describe('GET /api/v2/credentials', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: true }) + mockListVisibleWorkspaceCredentials.mockResolvedValue([buildVisible()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + + expect(res.status).toBe(404) + expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + expect(res.status).toBe(403) + expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns the public credential shape with no secret material', async () => { + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'cred_abc123', + type: 'service_account', + displayName: 'Zoom account acct_123', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + hasServiceAccountKey: true, + role: 'admin', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: WORKSPACE_ID, userId: 'user-1' }) + ) + }) + + it('passes the type and providerId filters through', async () => { + await callList(`workspaceId=${WORKSPACE_ID}&type=oauth&providerId=slack`) + expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( + expect.objectContaining({ type: 'oauth', providerId: 'slack' }) + ) + }) +}) + +describe('POST /api/v2/credentials', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformCreateCredential.mockResolvedValue({ + success: true, + credential: buildRow(), + created: true, + }) + mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('400s when envKey is missing for an env credential', async () => { + const res = await callCreate({ workspaceId: WORKSPACE_ID, type: 'env_workspace' }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('400s when envKey is not a valid environment variable name', async () => { + const res = await callCreate({ ...VALID_BODY, envKey: 'not-a-valid-name' }) + expect(res.status).toBe(400) + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('400s on an oauth create, which requires the interactive connect flow', async () => { + const res = await callCreate({ + workspaceId: WORKSPACE_ID, + type: 'oauth', + providerId: 'slack', + accountId: 'acct_1', + displayName: 'Slack', + }) + expect(res.status).toBe(400) + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(403) + expect(mockPerformCreateCredential).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('maps a provider outage to 503 rather than a bad request', async () => { + mockPerformCreateCredential.mockResolvedValue({ + success: false, + error: 'provider_unavailable', + errorCode: 'validation', + providerErrorCode: 'provider_unavailable', + providerUnavailable: true, + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(503) + expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE') + }) + + it('reports the real role when an idempotent create matches a credential the caller only belongs to', async () => { + mockPerformCreateCredential.mockResolvedValue({ + success: true, + credential: buildRow(), + created: false, + }) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'member' }, isAdmin: false }) + + const res = await callCreate(VALID_BODY) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.credential.role).toBe('member') + }) + + it('reports admin for a fresh insert without a second access lookup', async () => { + const res = await callCreate(VALID_BODY) + + expect((await res.json()).data.credential.role).toBe('admin') + expect(mockGetCredentialActorContext).not.toHaveBeenCalled() + }) + + it('creates the credential and never echoes the submitted secret', async () => { + const res = await callCreate({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'zoom-client-id', + clientSecret: 'super-secret-value', + orgId: 'acct_123', + }) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.credential).toMatchObject({ + id: 'cred_abc123', + hasServiceAccountKey: true, + role: 'admin', + }) + expect(JSON.stringify(body)).not.toContain('super-secret-value') + expect(JSON.stringify(body)).not.toContain('encrypted-blob') + expect(mockPerformCreateCredential).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + type: 'service_account', + clientSecret: 'super-secret-value', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts new file mode 100644 index 00000000000..232110187c1 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -0,0 +1,147 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateCredentialContract, + v2ListCredentialsContract, +} from '@/lib/api/contracts/v2/credentials' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { performCreateCredential } from '@/lib/credentials/orchestration' +import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + toV2Credential, + toV2CredentialRow, + v2CredentialOrchestrationError, +} from '@/app/api/v2/credentials/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CredentialsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/credentials — List the credentials the caller can see in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credentials') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListCredentialsContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, type, providerId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + /** + * Credential visibility is per credential, not per workspace: membership + * rows and shared-type admin access decide what this caller sees, so the + * workspace permission is re-read here for the `canAdmin` bit. + */ + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + const credentials = await listVisibleWorkspaceCredentials({ + workspaceId, + userId, + workspaceAccess, + type, + providerId, + }) + + // The per-workspace credential set is small and bounded → a single full page. + return v2CursorList(credentials.map(toV2Credential), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing credentials`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/credentials — Create a workspace credential. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'credentials') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateCredentialContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performCreateCredential({ ...parsed.data.body, userId, request }) + + if (!result.success || !result.credential) { + return v2CredentialOrchestrationError( + result.errorCode, + result.error ?? 'Failed to create credential', + { providerUnavailable: result.providerUnavailable } + ) + } + + /** + * A fresh insert makes the creator an admin, but an idempotent match against + * an existing source does not — the orchestration admits a caller who is + * only a *member* of that credential. Resolve the real role rather than + * assuming the create case, or the response would advertise administrative + * actions the caller cannot perform. + */ + const actor = result.created + ? { isAdmin: true } + : await getCredentialActorContext(result.credential.id, userId) + const credential = toV2CredentialRow(result.credential, actor.isAdmin ? 'admin' : 'member') + + /** + * Always 201, including when an existing credential already occupied this + * source. Create is idempotent on the source tuple, and the caller's + * post-condition — "a credential with this source exists, here it is" — is + * the same either way. + */ + return v2Data({ credential }, { rateLimit, status: 201 }) + } catch (error) { + logger.error(`[${requestId}] Error creating credential`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/credentials/utils.ts b/apps/sim/app/api/v2/credentials/utils.ts new file mode 100644 index 00000000000..b70903da663 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/utils.ts @@ -0,0 +1,75 @@ +import type { NextResponse } from 'next/server' +import type { V2Credential } from '@/lib/api/contracts/v2/credentials' +import type { CredentialOrchestrationErrorCode } from '@/lib/credentials/orchestration' +import type { CredentialRow, VisibleWorkspaceCredential } from '@/lib/credentials/queries' +import { v2Error } from '@/app/api/v2/lib/response' + +/** + * Shared serialization + error mapping for the v2 credentials surface. + * + * Both projections are written field by field on purpose: a credential row + * carries `encryptedServiceAccountKey`, and spreading the row would put it one + * forgotten `omit` away from the wire. + */ + +export function toV2Credential(row: VisibleWorkspaceCredential): V2Credential { + return { + id: row.id, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + envKey: row.envKey, + hasServiceAccountKey: row.hasServiceAccountKey, + role: row.role, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +/** Projection for a raw credential row, whose caller-role is resolved separately. */ +export function toV2CredentialRow(row: CredentialRow, role: V2Credential['role']): V2Credential { + return { + id: row.id, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + envKey: row.envKey, + hasServiceAccountKey: Boolean(row.encryptedServiceAccountKey), + role, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +/** + * Renders a credential orchestration failure in the v2 error envelope. + * + * `forbidden` from the orchestration means "not an admin of this credential", + * which is a resource-level denial rather than a workspace one; it stays a 403 + * because the caller already proved workspace access to reach it. + */ +export function v2CredentialOrchestrationError( + errorCode: CredentialOrchestrationErrorCode | undefined, + message: string, + options: { providerUnavailable?: boolean } = {} +): NextResponse { + if (options.providerUnavailable) { + return v2Error('SERVICE_UNAVAILABLE', 'The credential provider is unavailable. Try again.') + } + switch (errorCode) { + case 'validation': + return v2Error('BAD_REQUEST', message) + case 'forbidden': + return v2Error('FORBIDDEN', message) + case 'not_found': + return v2Error('NOT_FOUND', 'Credential not found') + case 'conflict': + return v2Error('CONFLICT', message) + default: + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +} 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 new file mode 100644 index 00000000000..5619a64b1cd --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.test.ts @@ -0,0 +1,308 @@ +/** + * @vitest-environment node + * + * Public v2 custom tool detail: the per-id get/update/delete the internal + * surface never had, and the rename guard that keeps a duplicate title from + * reaching the unique index. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetWorkspaceCustomTool, + mockGetWorkspaceCustomToolByTitle, + mockDeleteWorkspaceCustomTool, + mockUpdateWorkspaceCustomTool, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetWorkspaceCustomTool: vi.fn(), + mockGetWorkspaceCustomToolByTitle: vi.fn(), + mockDeleteWorkspaceCustomTool: vi.fn(), + mockUpdateWorkspaceCustomTool: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/custom-tools/operations', () => ({ + getWorkspaceCustomTool: mockGetWorkspaceCustomTool, + getWorkspaceCustomToolByTitle: mockGetWorkspaceCustomToolByTitle, + deleteWorkspaceCustomTool: mockDeleteWorkspaceCustomTool, + updateWorkspaceCustomTool: mockUpdateWorkspaceCustomTool, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/custom-tools/[id]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +const TOOL_SCHEMA = { + type: 'function', + function: { + name: 'lookup_order', + parameters: { type: 'object', properties: { orderId: { type: 'string' } } }, + }, +} + +function buildTool(overrides: Record = {}) { + return { + id: 'tool_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'tool_abc123' }) }) +const url = (query = 'workspaceId=workspace-1') => + `http://localhost:3000/api/v2/custom-tools/tool_abc123?${query}` + +const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) +const callDelete = (query?: string) => + DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/custom-tools/tool_abc123', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +describe('GET /api/v2/custom-tools/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCustomTool.mockResolvedValue(buildTool()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockGetWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect(mockGetWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(403) + expect(mockGetWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the tool is not in the workspace', async () => { + mockGetWorkspaceCustomTool.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the public tool shape without internal scoping columns', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.customTool).toEqual({ + id: 'tool_abc123', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }) + expect(mockGetWorkspaceCustomTool).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + toolId: 'tool_abc123', + }) + }) +}) + +describe('PATCH /api/v2/custom-tools/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCustomTool.mockResolvedValue(buildTool()) + mockGetWorkspaceCustomToolByTitle.mockResolvedValue(null) + mockUpdateWorkspaceCustomTool.mockResolvedValue(buildTool()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) + + expect(res.status).toBe(404) + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('400s when no field to change is supplied', async () => { + const res = await callPatch({ workspaceId: 'workspace-1' }) + expect(res.status).toBe(400) + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) + expect(res.status).toBe(403) + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the tool is not in the workspace', async () => { + mockGetWorkspaceCustomTool.mockResolvedValue(null) + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) + expect(res.status).toBe(404) + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('409s when renaming onto an existing title', async () => { + mockGetWorkspaceCustomToolByTitle.mockResolvedValue(buildTool({ id: 'tool_other' })) + + const res = await callPatch({ workspaceId: 'workspace-1', title: 'taken' }) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('merges the partial body against the stored tool', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 2' }) + + expect(res.status).toBe(200) + expect(mockUpdateWorkspaceCustomTool).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + toolId: 'tool_abc123', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return 2', + }) + }) + + it('404s rather than orphaning a tool deleted between the read and the write', async () => { + mockUpdateWorkspaceCustomTool.mockResolvedValue(null) + + const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 2' }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) +}) + +describe('DELETE /api/v2/custom-tools/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCustomTool.mockResolvedValue(buildTool()) + mockDeleteWorkspaceCustomTool.mockResolvedValue(true) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(403) + expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the tool is not in the workspace', async () => { + mockGetWorkspaceCustomTool.mockResolvedValue(null) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() + }) + + it('deletes the tool and acknowledges the id', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'tool_abc123', deleted: true } }) + expect(mockDeleteWorkspaceCustomTool).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + toolId: 'tool_abc123', + }) + }) +}) diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.ts new file mode 100644 index 00000000000..e793c31c3ea --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.ts @@ -0,0 +1,197 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteCustomToolContract, + v2GetCustomToolContract, + v2UpdateCustomToolContract, +} from '@/lib/api/contracts/v2/custom-tools' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + deleteWorkspaceCustomTool, + getWorkspaceCustomTool, + getWorkspaceCustomToolByTitle, + updateWorkspaceCustomTool, +} from '@/lib/workflows/custom-tools/operations' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CustomToolDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/custom-tools/[id] — Fetch a single custom tool. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tool-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetCustomToolContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const tool = await getWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!tool) return v2Error('NOT_FOUND', 'Custom tool not found') + + return v2Data({ customTool: toV2CustomTool(tool) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error fetching custom tool`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/custom-tools/[id] — Update a custom tool. Omitted fields keep their values. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tool-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateCustomToolContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, title, schema, code } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const current = await getWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!current) return v2Error('NOT_FOUND', 'Custom tool not found') + + /** + * `upsertCustomTools` replaces title/schema/code wholesale and checks for a + * duplicate title only when inserting, so a rename onto an existing title + * would hit the `custom_tools_workspace_title_unique` index as a 500. Merge + * the partial body against the stored row and check the rename here. + */ + if (title !== undefined && title !== current.title) { + if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) { + return v2Error( + 'CONFLICT', + `A custom tool titled "${title}" already exists in this workspace` + ) + } + } + + const updated = await updateWorkspaceCustomTool({ + workspaceId, + toolId: id, + title: title ?? current.title, + schema: schema ?? current.schema, + code: code ?? current.code, + }) + if (!updated) return v2Error('NOT_FOUND', 'Custom tool not found') + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.CUSTOM_TOOL_UPDATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: updated.id, + resourceName: updated.title, + description: `Updated custom tool "${updated.title}" via API`, + request, + }) + + return v2Data({ customTool: toV2CustomTool(updated) }, { rateLimit }) + } catch (error) { + const writeError = v2CustomToolWriteError(error) + if (writeError) return writeError + + logger.error(`[${requestId}] Error updating custom tool`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/custom-tools/[id] — Delete a custom tool. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tool-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteCustomToolContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const tool = await getWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!tool) return v2Error('NOT_FOUND', 'Custom tool not found') + + const deleted = await deleteWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!deleted) return v2Error('NOT_FOUND', 'Custom tool not found') + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.CUSTOM_TOOL_DELETED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: id, + resourceName: tool.title, + description: `Deleted custom tool "${tool.title}" via API`, + request, + }) + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting custom tool`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/custom-tools/route.test.ts b/apps/sim/app/api/v2/custom-tools/route.test.ts new file mode 100644 index 00000000000..5693e018448 --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/route.test.ts @@ -0,0 +1,267 @@ +/** + * @vitest-environment node + * + * Public v2 custom tools list/create: gate ordering, contract validation, and + * the workspace-scoped single-resource create that replaced the bulk upsert. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockListWorkspaceCustomTools, + mockGetWorkspaceCustomToolByTitle, + mockUpsertCustomTools, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListWorkspaceCustomTools: vi.fn(), + mockGetWorkspaceCustomToolByTitle: vi.fn(), + mockUpsertCustomTools: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/custom-tools/operations', () => ({ + listWorkspaceCustomTools: mockListWorkspaceCustomTools, + getWorkspaceCustomToolByTitle: mockGetWorkspaceCustomToolByTitle, + upsertCustomTools: mockUpsertCustomTools, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/custom-tools/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const TOOL_SCHEMA = { + type: 'function', + function: { + name: 'lookup_order', + description: 'Look up an order by id', + parameters: { + type: 'object', + properties: { orderId: { type: 'string' } }, + required: ['orderId'], + }, + }, +} + +function buildTool(overrides: Record = {}) { + return { + id: 'tool_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/custom-tools?${query}`)) + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/custom-tools', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +const VALID_BODY = { + workspaceId: 'workspace-1', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', +} + +describe('GET /api/v2/custom-tools', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListWorkspaceCustomTools.mockResolvedValue([buildTool()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList('workspaceId=workspace-1') + + expect(res.status).toBe(404) + expect(mockListWorkspaceCustomTools).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListWorkspaceCustomTools).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(403) + expect(mockListWorkspaceCustomTools).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns the public tool shape in the cursor envelope, workspace-scoped', async () => { + const res = await callList('workspaceId=workspace-1') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'tool_abc123', + title: 'lookup_order', + schema: TOOL_SCHEMA, + code: 'return { ok: true }', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + expect(mockListWorkspaceCustomTools).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) + }) +}) + +describe('POST /api/v2/custom-tools', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceCustomToolByTitle.mockResolvedValue(null) + mockUpsertCustomTools.mockResolvedValue([buildTool()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('400s when the schema is not an OpenAI function declaration', async () => { + const res = await callCreate({ ...VALID_BODY, schema: { type: 'nonsense' } }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('400s when the body carries an unknown field', async () => { + const res = await callCreate({ ...VALID_BODY, bogus: true }) + expect(res.status).toBe(400) + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(403) + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('409s on a duplicate title instead of hitting the unique index', async () => { + mockGetWorkspaceCustomToolByTitle.mockResolvedValue(buildTool()) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + expect(mockUpsertCustomTools).not.toHaveBeenCalled() + }) + + it('409s when a concurrent create loses the title race inside the lib', async () => { + mockUpsertCustomTools.mockRejectedValue( + new Error('A tool with the title "v2_smoke_tool" already exists in this workspace') + ) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('409s when the unique index rejects the loser of a title race', async () => { + const pgError = Object.assign(new Error('duplicate key value violates unique constraint'), { + code: '23505', + }) + mockUpsertCustomTools.mockRejectedValue(pgError) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('creates the tool and returns 201 with the single tool', async () => { + const res = await callCreate(VALID_BODY) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.customTool).toMatchObject({ id: 'tool_abc123', title: 'lookup_order' }) + expect(mockUpsertCustomTools).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + tools: [{ title: 'lookup_order', schema: TOOL_SCHEMA, code: 'return { ok: true }' }], + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/custom-tools/route.ts b/apps/sim/app/api/v2/custom-tools/route.ts new file mode 100644 index 00000000000..b746b285cc2 --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/route.ts @@ -0,0 +1,136 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateCustomToolContract, + v2ListCustomToolsContract, +} from '@/lib/api/contracts/v2/custom-tools' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + getWorkspaceCustomToolByTitle, + listWorkspaceCustomTools, + upsertCustomTools, +} from '@/lib/workflows/custom-tools/operations' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CustomToolsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/custom-tools — List custom tools in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tools') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListCustomToolsContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const rows = await listWorkspaceCustomTools({ workspaceId }) + + // The per-workspace tool set is small and bounded → a single full page. + return v2CursorList(rows.map(toV2CustomTool), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing custom tools`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/custom-tools — Create a custom tool. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'custom-tools') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateCustomToolContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, title, schema, code } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + /** + * Titles are unique per workspace and tools resolve by title at call time, + * so a collision is reported rather than surfacing as a unique-index 500. + */ + if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) { + return v2Error('CONFLICT', `A custom tool titled "${title}" already exists in this workspace`) + } + + const tools = await upsertCustomTools({ + tools: [{ title, schema, code }], + workspaceId, + userId, + requestId, + }) + const created = tools.find((tool) => tool.title === title) + if (!created) return v2Error('INTERNAL_ERROR', 'Internal server error') + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.CUSTOM_TOOL_CREATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: created.id, + resourceName: created.title, + description: `Created custom tool "${created.title}" via API`, + request, + }) + + return v2Data({ customTool: toV2CustomTool(created) }, { rateLimit, status: 201 }) + } catch (error) { + const writeError = v2CustomToolWriteError(error) + if (writeError) return writeError + + logger.error(`[${requestId}] Error creating custom tool`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/custom-tools/utils.ts b/apps/sim/app/api/v2/custom-tools/utils.ts new file mode 100644 index 00000000000..516101065ad --- /dev/null +++ b/apps/sim/app/api/v2/custom-tools/utils.ts @@ -0,0 +1,46 @@ +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 + +/** + * Public custom tool projection. `workspaceId` and `userId` are internal + * scoping columns and are not exposed. + */ +export function toV2CustomTool(row: CustomToolRow): V2CustomTool { + return { + id: row.id, + title: row.title, + schema: row.schema as V2CustomTool['schema'], + code: row.code, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} diff --git a/apps/sim/app/api/v2/folders/[id]/route.test.ts b/apps/sim/app/api/v2/folders/[id]/route.test.ts new file mode 100644 index 00000000000..6ab9c1d3957 --- /dev/null +++ b/apps/sim/app/api/v2/folders/[id]/route.test.ts @@ -0,0 +1,383 @@ +/** + * @vitest-environment node + * + * Public v2 folder detail: the archived-row split between PATCH and DELETE, the + * admin gate on `locked`, and the 423 a mutation lock produces. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockFindActiveFolder, + mockFindFolderInWorkspace, + mockUpdateFolder, + mockDeleteFolder, + mockAssertFolderMutable, + FolderLockedErrorMock, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockFindActiveFolder: vi.fn(), + mockFindFolderInWorkspace: vi.fn(), + mockUpdateFolder: vi.fn(), + mockDeleteFolder: vi.fn(), + mockAssertFolderMutable: vi.fn(), + FolderLockedErrorMock: class FolderLockedError extends Error { + status = 423 + }, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/folders/queries', () => ({ + findActiveFolder: mockFindActiveFolder, + findFolderInWorkspace: mockFindFolderInWorkspace, +})) + +vi.mock('@/lib/folders/lifecycle', () => ({ + updateFolder: mockUpdateFolder, + deleteFolder: mockDeleteFolder, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mockAssertFolderMutable, + FolderLockedError: FolderLockedErrorMock, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/folders/[id]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +function buildRow(overrides: Record = {}) { + return { + id: 'fld_abc123', + resourceType: 'workflow', + name: 'Onboarding', + userId: 'user-1', + workspaceId: 'workspace-1', + parentId: null, + locked: false, + sortOrder: 0, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + deletedAt: null, + ...overrides, + } +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'fld_abc123' }) }) +const url = (query = 'workspaceId=workspace-1&resourceType=workflow') => + `http://localhost:3000/api/v2/folders/fld_abc123?${query}` + +const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) +const callDelete = (query?: string) => + DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/folders/fld_abc123', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +describe('GET /api/v2/folders/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockFindFolderInWorkspace.mockResolvedValue(buildRow()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockFindFolderInWorkspace).not.toHaveBeenCalled() + }) + + it('400s when resourceType is missing', async () => { + const res = await callGet('workspaceId=workspace-1') + expect(res.status).toBe(400) + expect(mockFindFolderInWorkspace).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(403) + expect(mockFindFolderInWorkspace).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the folder is not in this workspace tree', async () => { + mockFindFolderInWorkspace.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the public folder shape', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.folder).toEqual({ + id: 'fld_abc123', + resourceType: 'workflow', + name: 'Onboarding', + parentId: null, + locked: false, + sortOrder: 0, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + deletedAt: null, + }) + expect(mockFindFolderInWorkspace).toHaveBeenCalledWith('fld_abc123', 'workspace-1', 'workflow') + }) +}) + +describe('PATCH /api/v2/folders/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockFindActiveFolder.mockResolvedValue(buildRow()) + mockAssertFolderMutable.mockResolvedValue(undefined) + mockUpdateFolder.mockResolvedValue({ success: true, folder: buildRow({ name: 'Renamed' }) }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Renamed', + }) + + expect(res.status).toBe(404) + expect(mockUpdateFolder).not.toHaveBeenCalled() + }) + + it('400s when no field to change is supplied', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', resourceType: 'workflow' }) + expect(res.status).toBe(400) + expect(mockUpdateFolder).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Renamed', + }) + expect(res.status).toBe(403) + expect(mockUpdateFolder).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Renamed', + }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('requires only write permission for an ordinary rename', async () => { + await callPatch({ workspaceId: 'workspace-1', resourceType: 'workflow', name: 'Renamed' }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'workspace-1', + 'write' + ) + }) + + it('escalates to admin when locked is being set', async () => { + await callPatch({ workspaceId: 'workspace-1', resourceType: 'workflow', locked: true }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'workspace-1', + 'admin' + ) + }) + + it('400s when locked is sent for a tree that does not support locking', async () => { + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'table', + locked: true, + }) + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('workflow folders') + expect(mockUpdateFolder).not.toHaveBeenCalled() + }) + + it('404s on an archived folder so a locked subtree cannot be edited through it', async () => { + mockFindActiveFolder.mockResolvedValue(null) + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Renamed', + }) + expect(res.status).toBe(404) + expect(mockUpdateFolder).not.toHaveBeenCalled() + }) + + it('423s when a mutation lock blocks the change', async () => { + mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Renamed', + }) + expect(res.status).toBe(423) + expect((await res.json()).error.code).toBe('LOCKED') + expect(mockUpdateFolder).not.toHaveBeenCalled() + }) + + it('updates the folder and returns the public shape', async () => { + const res = await callPatch({ + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Renamed', + }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.folder.name).toBe('Renamed') + expect(mockUpdateFolder).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'workflow', + folderId: 'fld_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Renamed', + }) + ) + }) +}) + +describe('DELETE /api/v2/folders/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockFindFolderInWorkspace.mockResolvedValue(buildRow()) + mockAssertFolderMutable.mockResolvedValue(undefined) + mockDeleteFolder.mockResolvedValue({ + success: true, + deletedItems: { folders: 2, workflows: 5 }, + }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockDeleteFolder).not.toHaveBeenCalled() + }) + + it('400s when resourceType is missing', async () => { + const res = await callDelete('workspaceId=workspace-1') + expect(res.status).toBe(400) + expect(mockDeleteFolder).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(403) + expect(mockDeleteFolder).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the folder is not in this workspace tree', async () => { + mockFindFolderInWorkspace.mockResolvedValue(null) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockDeleteFolder).not.toHaveBeenCalled() + }) + + it('423s when a mutation lock blocks the delete', async () => { + mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) + const res = await callDelete() + expect(res.status).toBe(423) + expect((await res.json()).error.code).toBe('LOCKED') + expect(mockDeleteFolder).not.toHaveBeenCalled() + }) + + it('deletes the folder and reports the cascade counts', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + data: { id: 'fld_abc123', deleted: true, deletedItems: { folders: 2, workflows: 5 } }, + }) + expect(mockDeleteFolder).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'workflow', + folderId: 'fld_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + folderName: 'Onboarding', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/folders/[id]/route.ts b/apps/sim/app/api/v2/folders/[id]/route.ts new file mode 100644 index 00000000000..b05d090d600 --- /dev/null +++ b/apps/sim/app/api/v2/folders/[id]/route.ts @@ -0,0 +1,209 @@ +import { createLogger } from '@sim/logger' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteFolderContract, + v2GetFolderContract, + v2UpdateFolderContract, +} from '@/lib/api/contracts/v2/folders' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { folderResourceConfig } from '@/lib/folders/config' +import { deleteFolder, updateFolder } from '@/lib/folders/lifecycle' +import { findActiveFolder, findFolderInWorkspace } from '@/lib/folders/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2Folder, v2FolderMutationError } from '@/app/api/v2/folders/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FolderDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/folders/[id] — Fetch a single folder, archived or live. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'folder-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetFolderContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, resourceType } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const folder = await findFolderInWorkspace(id, workspaceId, resourceType) + if (!folder) return v2Error('NOT_FOUND', 'Folder not found') + + return v2Data({ folder: toV2Folder(folder) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error fetching folder`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/folders/[id] — Rename, move, reorder, or lock a folder. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'folder-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateFolderContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, resourceType, name, locked, parentId, sortOrder } = parsed.data.body + + /** + * Setting `locked` is an admin capability, matching the UI; every other + * field needs only workspace write. + */ + const access = await resolveWorkspaceAccess( + rateLimit, + userId, + workspaceId, + locked === undefined ? 'write' : 'admin' + ) + if (access) return v2WorkspaceAccessError(access) + + /** + * Archived folders are excluded deliberately: `getFolderLockStatus` skips + * archived rows, so an archived-but-locked folder reports unlocked. Without + * this filter, deleting a folder would make every locked subfolder under it + * freely renameable and reparentable. + */ + const existing = await findActiveFolder(id, workspaceId, resourceType) + if (!existing) return v2Error('NOT_FOUND', 'Folder not found') + + const supportsLocking = Boolean(folderResourceConfig(resourceType).supportsLocking) + if (locked !== undefined && !supportsLocking) { + return v2Error('BAD_REQUEST', 'Folder locking is only supported for workflow folders') + } + + if (supportsLocking) { + const hasNonLockUpdate = + name !== undefined || parentId !== undefined || sortOrder !== undefined + if (hasNonLockUpdate) await assertFolderMutable(id) + if (parentId !== undefined) await assertFolderMutable(parentId) + } + + const result = await updateFolder({ + resourceType, + folderId: id, + workspaceId, + userId, + name, + locked, + parentId, + sortOrder, + }) + + if (!result.success || !result.folder) { + return v2FolderMutationError(result.errorCode, result.error ?? 'Failed to update folder') + } + + return v2Data({ folder: toV2Folder(result.folder) }, { rateLimit }) + } catch (error) { + if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) + + logger.error(`[${requestId}] Error updating folder`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/folders/[id] — Archive a folder and cascade to its contents. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'folder-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteFolderContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, resourceType } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + /** + * Archived rows are included on purpose: `deleteFolder` reuses an already + * archived folder's own `deletedAt` so a cascade that failed partway can be + * retried onto the same snapshot. 404ing here would strand those. + */ + const existing = await findFolderInWorkspace(id, workspaceId, resourceType) + if (!existing) return v2Error('NOT_FOUND', 'Folder not found') + + if (folderResourceConfig(resourceType).supportsLocking) { + await assertFolderMutable(id) + } + + const result = await deleteFolder({ + resourceType, + folderId: id, + workspaceId, + userId, + folderName: existing.name, + }) + + if (!result.success) { + return v2FolderMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + + return v2Data({ id, deleted: true as const, deletedItems: result.deletedItems }, { rateLimit }) + } catch (error) { + if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) + + logger.error(`[${requestId}] Error deleting folder`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/folders/route.test.ts b/apps/sim/app/api/v2/folders/route.test.ts new file mode 100644 index 00000000000..85399484b5b --- /dev/null +++ b/apps/sim/app/api/v2/folders/route.test.ts @@ -0,0 +1,278 @@ +/** + * @vitest-environment node + * + * Public v2 folders list/create: gate ordering, the required-`resourceType` + * departure from the internal default, and the lock check on create. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockListFoldersForWorkspace, + mockCreateFolder, + mockAssertFolderMutable, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListFoldersForWorkspace: vi.fn(), + mockCreateFolder: vi.fn(), + mockAssertFolderMutable: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/folders/queries', () => ({ + listFoldersForWorkspace: mockListFoldersForWorkspace, +})) + +vi.mock('@/lib/folders/lifecycle', () => ({ + createFolder: mockCreateFolder, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mockAssertFolderMutable, + FolderLockedError: class FolderLockedError extends Error { + status = 423 + }, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/folders/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const FOLDER_API = { + id: 'fld_abc123', + resourceType: 'workflow' as const, + name: 'Onboarding', + userId: 'user-1', + workspaceId: 'workspace-1', + parentId: null, + locked: false, + sortOrder: 0, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + deletedAt: null, +} + +function buildRow(overrides: Record = {}) { + return { + id: 'fld_abc123', + resourceType: 'workflow', + name: 'Onboarding', + userId: 'user-1', + workspaceId: 'workspace-1', + parentId: null, + locked: false, + sortOrder: 0, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + deletedAt: null, + ...overrides, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/folders?${query}`)) + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +const VALID_BODY = { + workspaceId: 'workspace-1', + resourceType: 'workflow', + name: 'Onboarding', +} + +describe('GET /api/v2/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListFoldersForWorkspace.mockResolvedValue([FOLDER_API]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList('workspaceId=workspace-1&resourceType=workflow') + + expect(res.status).toBe(404) + expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('resourceType=workflow') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() + }) + + it('400s when resourceType is omitted instead of defaulting to workflow', async () => { + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(400) + expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() + }) + + it('400s on a resourceType outside the served set', async () => { + const res = await callList('workspaceId=workspace-1&resourceType=file') + expect(res.status).toBe(400) + expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList('workspaceId=workspace-1&resourceType=workflow') + expect(res.status).toBe(403) + expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList('workspaceId=workspace-1&resourceType=workflow') + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns the public folder shape without internal scoping columns', async () => { + const res = await callList('workspaceId=workspace-1&resourceType=workflow') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'fld_abc123', + resourceType: 'workflow', + name: 'Onboarding', + parentId: null, + locked: false, + sortOrder: 0, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + deletedAt: null, + }, + ]) + expect(mockListFoldersForWorkspace).toHaveBeenCalledWith('workspace-1', 'active', 'workflow') + }) + + it('passes the archived scope through', async () => { + await callList('workspaceId=workspace-1&resourceType=table&scope=archived') + expect(mockListFoldersForWorkspace).toHaveBeenCalledWith('workspace-1', 'archived', 'table') + }) +}) + +describe('POST /api/v2/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockAssertFolderMutable.mockResolvedValue(undefined) + mockCreateFolder.mockResolvedValue({ success: true, folder: buildRow() }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockCreateFolder).not.toHaveBeenCalled() + }) + + it('400s when the name is empty', async () => { + const res = await callCreate({ ...VALID_BODY, name: ' ' }) + expect(res.status).toBe(400) + expect(mockCreateFolder).not.toHaveBeenCalled() + }) + + it('400s when resourceType is omitted', async () => { + const res = await callCreate({ workspaceId: 'workspace-1', name: 'Onboarding' }) + expect(res.status).toBe(400) + expect(mockCreateFolder).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(403) + expect(mockCreateFolder).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('409s when a sibling folder already has the name', async () => { + mockCreateFolder.mockResolvedValue({ + success: false, + error: 'A folder with this name already exists in this location', + errorCode: 'conflict', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('creates the folder and returns 201', async () => { + const res = await callCreate({ ...VALID_BODY, parentId: null }) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.folder).toMatchObject({ id: 'fld_abc123', name: 'Onboarding' }) + expect(body.data.folder.userId).toBeUndefined() + expect(mockCreateFolder).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'workflow', + userId: 'user-1', + workspaceId: 'workspace-1', + name: 'Onboarding', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/folders/route.ts b/apps/sim/app/api/v2/folders/route.ts new file mode 100644 index 00000000000..2ce758499b4 --- /dev/null +++ b/apps/sim/app/api/v2/folders/route.ts @@ -0,0 +1,120 @@ +import { createLogger } from '@sim/logger' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateFolderContract, v2ListFoldersContract } from '@/lib/api/contracts/v2/folders' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { folderResourceConfig } from '@/lib/folders/config' +import { createFolder } from '@/lib/folders/lifecycle' +import { listFoldersForWorkspace } from '@/lib/folders/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2Folder, toV2FolderFromApi, v2FolderMutationError } from '@/app/api/v2/folders/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FoldersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/folders — List a workspace's folder tree for one resource type. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'folders') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListFoldersContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, resourceType, scope } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const folders = await listFoldersForWorkspace(workspaceId, scope, resourceType) + + // One workspace's tree for one resource type is bounded → a single full page. + return v2CursorList(folders.map(toV2FolderFromApi), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing folders`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/folders — Create a folder in one of a workspace's resource trees. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'folders') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateFolderContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, resourceType, name, parentId, sortOrder } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + // Locking is a workflow-only feature; other trees leave `locked` false. + if (folderResourceConfig(resourceType).supportsLocking) { + await assertFolderMutable(parentId ?? null) + } + + const result = await createFolder({ + resourceType, + userId, + workspaceId, + name, + parentId, + sortOrder, + }) + + if (!result.success || !result.folder) { + return v2FolderMutationError(result.errorCode, result.error ?? 'Failed to create folder') + } + + return v2Data({ folder: toV2Folder(result.folder) }, { rateLimit, status: 201 }) + } catch (error) { + if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) + + logger.error(`[${requestId}] Error creating folder`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/folders/utils.ts b/apps/sim/app/api/v2/folders/utils.ts new file mode 100644 index 00000000000..c041e880013 --- /dev/null +++ b/apps/sim/app/api/v2/folders/utils.ts @@ -0,0 +1,60 @@ +import type { folder as folderTable } from '@sim/db/schema' +import { omit } from '@sim/utils/object' +import type { NextResponse } from 'next/server' +import type { FolderApi } from '@/lib/api/contracts/folders' +import type { V2Folder } from '@/lib/api/contracts/v2/folders' +import type { FolderMutationErrorCode } from '@/lib/folders/status' +import { v2Error } from '@/app/api/v2/lib/response' + +/** Shared serialization + error mapping for the v2 folders surface. */ + +type FolderRow = typeof folderTable.$inferSelect + +/** + * Narrows an already-serialized {@link FolderApi} (what the shared list query + * returns) to the public projection. + */ +export function toV2FolderFromApi(row: FolderApi): V2Folder { + return omit(row, ['userId', 'workspaceId']) +} + +/** + * Public folder projection. `userId` and `workspaceId` are internal scoping + * columns and are not exposed. + */ +export function toV2Folder(row: FolderRow): V2Folder { + return { + id: row.id, + resourceType: row.resourceType, + name: row.name, + parentId: row.parentId, + locked: row.locked, + sortOrder: row.sortOrder, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null, + } +} + +/** + * Renders a folder mutation failure in the v2 error envelope. `locked` keeps its + * 423, matching what the table domain returns when the same mutation lock blocks + * a single-table delete. + */ +export function v2FolderMutationError( + errorCode: FolderMutationErrorCode | undefined, + message: string +): NextResponse { + switch (errorCode) { + case 'validation': + return v2Error('BAD_REQUEST', message) + case 'not_found': + return v2Error('NOT_FOUND', 'Folder not found') + case 'conflict': + return v2Error('CONFLICT', message) + case 'locked': + return v2Error('LOCKED', message) + default: + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +} 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 new file mode 100644 index 00000000000..1d5b93a53de --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.test.ts @@ -0,0 +1,350 @@ +/** + * @vitest-environment node + * + * Public v2 MCP server detail: gate ordering, contract validation, workspace + * access, and the thin-wrapper mapping onto `lib/mcp/orchestration`. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { McpServerRow } from '@/lib/mcp/queries' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetWorkspaceMcpServer, + mockPerformUpdateMcpServer, + mockPerformDeleteMcpServer, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetWorkspaceMcpServer: vi.fn(), + mockPerformUpdateMcpServer: vi.fn(), + mockPerformDeleteMcpServer: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/mcp/queries', () => ({ + getWorkspaceMcpServer: mockGetWorkspaceMcpServer, +})) + +vi.mock('@/lib/mcp/orchestration', () => ({ + performUpdateMcpServer: mockPerformUpdateMcpServer, + performDeleteMcpServer: mockPerformDeleteMcpServer, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/mcp-servers/[id]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +function buildRow(overrides: Partial = {}): McpServerRow { + return { + id: 'mcp-abc12345', + workspaceId: 'workspace-1', + createdBy: 'user-1', + name: 'Docs server', + description: null, + transport: 'streamable-http', + url: 'https://mcp.example.com/sse', + authType: 'headers', + oauthClientId: null, + oauthClientSecret: 'encrypted-secret', + headers: { Authorization: 'Bearer super-secret-token' }, + timeout: 30000, + retries: 3, + enabled: true, + lastConnected: null, + connectionStatus: 'disconnected', + lastError: null, + statusConfig: {}, + toolCount: 0, + lastToolsRefresh: null, + totalRequests: 0, + lastUsed: null, + deletedAt: null, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } as McpServerRow +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'mcp-abc12345' }) }) + +const url = (query = 'workspaceId=workspace-1') => + `http://localhost:3000/api/v2/mcp-servers/mcp-abc12345?${query}` + +function callGet(query?: string) { + return GET(new NextRequest(url(query)), routeContext()) +} + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/mcp-servers/mcp-abc12345', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +function callDelete(query?: string) { + return DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) +} + +describe('GET /api/v2/mcp-servers/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockGetWorkspaceMcpServer).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockGetWorkspaceMcpServer).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(403) + expect(mockGetWorkspaceMcpServer).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the server does not exist in the workspace', async () => { + mockGetWorkspaceMcpServer.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the public server shape without header values', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.mcpServer).toMatchObject({ + id: 'mcp-abc12345', + hasHeaders: true, + headerNames: ['Authorization'], + hasOauthClientSecret: true, + }) + expect(JSON.stringify(body)).not.toContain('super-secret-token') + expect(JSON.stringify(body)).not.toContain('encrypted-secret') + expect(mockGetWorkspaceMcpServer).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + serverId: 'mcp-abc12345', + }) + }) +}) + +describe('PATCH /api/v2/mcp-servers/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformUpdateMcpServer.mockResolvedValue({ success: true, server: buildRow() }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) + + expect(res.status).toBe(404) + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the body has an unknown field', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', bogus: true }) + expect(res.status).toBe(400) + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the url carries an environment-variable template', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', url: 'https://{{HOST}}/sse' }) + expect(res.status).toBe(400) + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) + expect(res.status).toBe(403) + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('maps a not_found orchestration failure to 404', async () => { + mockPerformUpdateMcpServer.mockResolvedValue({ + success: false, + error: 'Server not found', + errorCode: 'not_found', + }) + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('400s when the url is changed, since the id is derived from it', async () => { + mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) + + const res = await callPatch({ + workspaceId: 'workspace-1', + url: 'https://different.example.com/sse', + }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('url cannot be changed') + expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() + }) + + it('allows a url that matches the stored one, so a full-object PATCH still works', async () => { + mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) + + const res = await callPatch({ + workspaceId: 'workspace-1', + url: 'https://mcp.example.com/sse', + enabled: false, + }) + + expect(res.status).toBe(200) + expect(mockPerformUpdateMcpServer).toHaveBeenCalled() + }) + + it('updates the server and returns the public shape', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed', enabled: false }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.mcpServer.id).toBe('mcp-abc12345') + expect(body.data.mcpServer.headers).toBeUndefined() + expect(mockPerformUpdateMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + serverId: 'mcp-abc12345', + name: 'Renamed', + enabled: false, + }) + ) + }) +}) + +describe('DELETE /api/v2/mcp-servers/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformDeleteMcpServer.mockResolvedValue({ success: true, server: buildRow() }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockPerformDeleteMcpServer).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockPerformDeleteMcpServer).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(403) + expect(mockPerformDeleteMcpServer).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('maps a not_found orchestration failure to 404', async () => { + mockPerformDeleteMcpServer.mockResolvedValue({ + success: false, + error: 'Server not found', + errorCode: 'not_found', + }) + const res = await callDelete() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('deletes the server and acknowledges the id', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'mcp-abc12345', deleted: true } }) + expect(mockPerformDeleteMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + serverId: 'mcp-abc12345', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts new file mode 100644 index 00000000000..22231ef8eba --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts @@ -0,0 +1,181 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteMcpServerContract, + v2GetMcpServerContract, + v2UpdateMcpServerContract, +} from '@/lib/api/contracts/v2/mcp-servers' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performDeleteMcpServer, performUpdateMcpServer } from '@/lib/mcp/orchestration' +import { getWorkspaceMcpServer } from '@/lib/mcp/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toV2McpServer, v2McpOrchestrationError } from '@/app/api/v2/mcp-servers/utils' + +const logger = createLogger('V2McpServerDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/mcp-servers/[id] — Fetch a single MCP server. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-server-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetMcpServerContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const server = await getWorkspaceMcpServer({ workspaceId, serverId: id }) + if (!server) return v2Error('NOT_FOUND', 'MCP server not found') + + return v2Data({ mcpServer: toV2McpServer(server) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error fetching MCP server`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/mcp-servers/[id] — Update an MCP server's configuration. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-server-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateMcpServerContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, ...body } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + /** + * A server's id is the hash of its workspace + URL, and this surface promises + * that identity. The lib will happily move `url` while the id keeps hashing + * the old one, which both breaks that promise and defeats the duplicate + * check on create (id-keyed, so it would not see the moved URL) — leaving two + * rows on one URL. Re-pointing a server at a different URL is a new server. + */ + if (body.url !== undefined) { + const current = await getWorkspaceMcpServer({ workspaceId, serverId: id }) + if (!current) return v2Error('NOT_FOUND', 'MCP server not found') + if (current.url !== body.url) { + return v2Error( + 'BAD_REQUEST', + 'url cannot be changed: an MCP server’s id is derived from its URL. Delete this server and create one at the new URL.' + ) + } + } + + const result = await performUpdateMcpServer({ + workspaceId, + userId, + serverId: id, + name: body.name, + description: body.description, + transport: body.transport, + url: body.url, + headers: body.headers, + timeout: body.timeout, + retries: body.retries, + enabled: body.enabled, + authType: body.authType, + oauthClientId: body.oauthClientId ?? null, + oauthClientIdProvided: body.oauthClientId !== undefined, + oauthClientSecret: body.oauthClientSecret, + oauthClientSecretProvided: body.oauthClientSecret !== undefined, + request, + }) + + if (!result.success || !result.server) { + return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to update server') + } + + return v2Data({ mcpServer: toV2McpServer(result.server) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error updating MCP server`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/mcp-servers/[id] — Remove an MCP server from the workspace. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-server-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteMcpServerContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteMcpServer({ workspaceId, userId, serverId: id, request }) + if (!result.success) { + return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to delete server') + } + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting MCP server`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/mcp-servers/route.test.ts b/apps/sim/app/api/v2/mcp-servers/route.test.ts new file mode 100644 index 00000000000..f9893f60c8e --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/route.test.ts @@ -0,0 +1,330 @@ +/** + * @vitest-environment node + * + * Public v2 MCP servers list/create: gate ordering, contract validation, the + * write-only `headers` projection, and the 409-on-duplicate-URL departure from + * the internal upsert. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { McpServerRow } from '@/lib/mcp/queries' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockListWorkspaceMcpServers, + mockGetWorkspaceMcpServer, + mockGetMcpServerIdState, + mockPerformCreateMcpServer, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListWorkspaceMcpServers: vi.fn(), + mockGetWorkspaceMcpServer: vi.fn(), + mockGetMcpServerIdState: vi.fn(), + mockPerformCreateMcpServer: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/mcp/queries', () => ({ + listWorkspaceMcpServers: mockListWorkspaceMcpServers, + getWorkspaceMcpServer: mockGetWorkspaceMcpServer, + getMcpServerIdState: mockGetMcpServerIdState, +})) + +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateMcpServer: mockPerformCreateMcpServer, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/mcp-servers/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +function buildRow(overrides: Partial = {}): McpServerRow { + return { + id: 'mcp-abc12345', + workspaceId: 'workspace-1', + createdBy: 'user-1', + name: 'Docs server', + description: 'Internal docs', + transport: 'streamable-http', + url: 'https://mcp.example.com/sse', + authType: 'headers', + oauthClientId: null, + oauthClientSecret: null, + headers: { Authorization: 'Bearer super-secret-token' }, + timeout: 30000, + retries: 3, + enabled: true, + lastConnected: new Date('2024-01-02T00:00:00Z'), + connectionStatus: 'connected', + lastError: null, + statusConfig: {}, + toolCount: 4, + lastToolsRefresh: new Date('2024-01-02T00:00:00Z'), + totalRequests: 0, + lastUsed: null, + deletedAt: null, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } as McpServerRow +} + +function callList(query: string) { + return GET(new NextRequest(`http://localhost:3000/api/v2/mcp-servers?${query}`)) +} + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/mcp-servers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +const VALID_BODY = { + workspaceId: 'workspace-1', + name: 'Docs server', + url: 'https://mcp.example.com/sse', +} + +describe('GET /api/v2/mcp-servers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListWorkspaceMcpServers.mockResolvedValue([buildRow()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList('workspaceId=workspace-1') + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockListWorkspaceMcpServers).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListWorkspaceMcpServers).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(403) + expect((await res.json()).error).toMatchObject({ code: 'FORBIDDEN', message: 'Access denied' }) + expect(mockListWorkspaceMcpServers).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns the public server shape in the cursor envelope', async () => { + const res = await callList('workspaceId=workspace-1') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'mcp-abc12345', + name: 'Docs server', + description: 'Internal docs', + transport: 'streamable-http', + authType: 'headers', + url: 'https://mcp.example.com/sse', + timeout: 30000, + retries: 3, + enabled: true, + connectionStatus: 'connected', + lastError: null, + toolCount: 4, + lastToolsRefresh: '2024-01-02T00:00:00.000Z', + lastConnected: '2024-01-02T00:00:00.000Z', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + hasHeaders: true, + headerNames: ['Authorization'], + hasOauthClientSecret: false, + }, + ]) + expect(mockListWorkspaceMcpServers).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) + }) + + it('never returns configured header values', async () => { + const res = await callList('workspaceId=workspace-1') + const raw = JSON.stringify(await res.json()) + + expect(raw).not.toContain('super-secret-token') + expect(raw).not.toContain('"headers":') + }) +}) + +describe('POST /api/v2/mcp-servers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetMcpServerIdState.mockResolvedValue(null) + mockPerformCreateMcpServer.mockResolvedValue({ + success: true, + serverId: 'mcp-abc12345', + updated: false, + }) + mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the body is missing a required field', async () => { + const res = await callCreate({ workspaceId: 'workspace-1', name: 'Docs server' }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the url carries an environment-variable template', async () => { + const res = await callCreate({ ...VALID_BODY, url: 'https://{{MCP_HOST}}/sse' }) + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('{{ENV_VAR}}') + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('400s when the url is not an absolute http(s) URL', async () => { + const res = await callCreate({ ...VALID_BODY, url: 'file:///etc/passwd' }) + expect(res.status).toBe(400) + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(403) + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('409s on a duplicate URL without letting the lib upsert', async () => { + mockGetMcpServerIdState.mockResolvedValue({ deleted: false }) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + }) + + it('409s when a concurrent create made the lib upsert instead of insert', async () => { + mockPerformCreateMcpServer.mockResolvedValue({ + success: true, + serverId: 'mcp-abc12345', + updated: true, + }) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('revives a soft-deleted URL instead of stranding it behind a 409', async () => { + mockGetMcpServerIdState.mockResolvedValue({ deleted: true }) + mockPerformCreateMcpServer.mockResolvedValue({ + success: true, + serverId: 'mcp-abc12345', + updated: true, + }) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(201) + expect(mockPerformCreateMcpServer).toHaveBeenCalled() + }) + + it('creates the server and returns 201 with the public shape', async () => { + const res = await callCreate({ ...VALID_BODY, headers: { Authorization: 'Bearer tok' } }) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.mcpServer).toMatchObject({ + id: 'mcp-abc12345', + name: 'Docs server', + hasHeaders: true, + headerNames: ['Authorization'], + }) + expect(body.data.mcpServer.headers).toBeUndefined() + expect(mockPerformCreateMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Docs server', + url: 'https://mcp.example.com/sse', + headers: { Authorization: 'Bearer tok' }, + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts new file mode 100644 index 00000000000..73a18037501 --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/route.ts @@ -0,0 +1,167 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateMcpServerContract, + v2ListMcpServersContract, +} from '@/lib/api/contracts/v2/mcp-servers' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performCreateMcpServer } from '@/lib/mcp/orchestration' +import { + getMcpServerIdState, + getWorkspaceMcpServer, + listWorkspaceMcpServers, +} from '@/lib/mcp/queries' +import { generateMcpServerId } from '@/lib/mcp/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toV2McpServer, v2McpOrchestrationError } from '@/app/api/v2/mcp-servers/utils' + +const logger = createLogger('V2McpServersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/mcp-servers — List MCP servers in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-servers') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListMcpServersContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const rows = await listWorkspaceMcpServers({ workspaceId }) + + // The per-workspace server set is small and bounded → a single full page. + return v2CursorList(rows.map(toV2McpServer), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing MCP servers`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/mcp-servers — Register a new MCP server. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'mcp-servers') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateMcpServerContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, ...body } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + /** + * The server id is a deterministic hash of workspace + normalized URL, and + * `performCreateMcpServer` upserts onto it — a second registration of the + * same URL silently overwrites the first. The internal surface and the + * copilot rely on that; a public create must not, so the collision is + * detected here, before the lib is given a chance to clobber the row. + * + * Only a *live* row is a conflict. A soft-deleted one is revived by the lib + * rather than inserted alongside, and reporting it as a duplicate would + * strand that URL for good: the detail routes resolve live rows only, so it + * could be neither fetched, patched, nor re-created. + */ + const serverId = generateMcpServerId(workspaceId, body.url) + const idState = await getMcpServerIdState({ workspaceId, serverId }) + if (idState && !idState.deleted) { + return v2Error( + 'CONFLICT', + 'An MCP server with this URL already exists in this workspace. Update it with PATCH /api/v2/mcp-servers/{id}.' + ) + } + const revivingSoftDeleted = idState?.deleted === true + + const result = await performCreateMcpServer({ + workspaceId, + userId, + name: body.name, + description: body.description, + transport: body.transport, + url: body.url, + headers: body.headers, + timeout: body.timeout, + retries: body.retries, + enabled: body.enabled, + authType: body.authType, + oauthClientId: body.oauthClientId ?? null, + oauthClientIdProvided: body.oauthClientId !== undefined, + oauthClientSecret: body.oauthClientSecret, + oauthClientSecretProvided: body.oauthClientSecret !== undefined, + request, + }) + + if (!result.success || !result.serverId) { + return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to register server') + } + + /** + * `updated` means the lib wrote onto an existing row. Reviving the + * soft-deleted row we already saw is the intended outcome; otherwise a + * concurrent create won the id race between the check above and the write. + */ + if (result.updated && !revivingSoftDeleted) { + return v2Error('CONFLICT', 'An MCP server with this URL already exists in this workspace.') + } + + const created = await getWorkspaceMcpServer({ workspaceId, serverId: result.serverId }) + if (!created) return v2Error('INTERNAL_ERROR', 'Internal server error') + + return v2Data({ mcpServer: toV2McpServer(created) }, { rateLimit, status: 201 }) + } catch (error) { + logger.error(`[${requestId}] Error creating MCP server`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/mcp-servers/utils.ts b/apps/sim/app/api/v2/mcp-servers/utils.ts new file mode 100644 index 00000000000..ba4fec6ee87 --- /dev/null +++ b/apps/sim/app/api/v2/mcp-servers/utils.ts @@ -0,0 +1,50 @@ +import type { NextResponse } from 'next/server' +import { type V2McpServer, v2McpServerSchema } from '@/lib/api/contracts/v2/mcp-servers' +import type { McpServerRow } from '@/lib/mcp/queries' +import { v2Error } from '@/app/api/v2/lib/response' + +/** + * Shared serialization + error mapping for the v2 MCP server surface. + */ + +/** + * Projects a stored MCP server row onto the public shape. + * + * The row is parsed through {@link v2McpServerSchema}, whose strip behaviour is + * the security boundary: `headers`, `oauthClientSecret`, `statusConfig`, and the + * rest of the row are dropped rather than enumerated by hand, so a column added + * later cannot leak by omission. Header *names* are lifted out explicitly. + */ +export function toV2McpServer(row: McpServerRow): V2McpServer { + const headers = (row.headers ?? {}) as Record + const headerNames = Object.keys(headers) + return v2McpServerSchema.parse({ + ...row, + hasHeaders: headerNames.length > 0, + headerNames, + hasOauthClientSecret: Boolean(row.oauthClientSecret), + }) +} + +/** + * Renders an MCP orchestration failure in the v2 error envelope. + * + * `forbidden` is the domain-allowlist / SSRF rejection and keeps its 403. + * `bad_gateway` is a DNS failure on the caller-supplied hostname — the caller's + * input is at fault, so it surfaces as a 400 rather than implying a Sim outage. + */ +export function v2McpOrchestrationError( + errorCode: string | undefined, + message: string +): NextResponse { + switch (errorCode) { + case 'not_found': + return v2Error('NOT_FOUND', 'MCP server not found') + case 'forbidden': + return v2Error('FORBIDDEN', message) + case 'bad_gateway': + return v2Error('BAD_REQUEST', message) + default: + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +} diff --git a/apps/sim/app/api/v2/skills/[id]/route.test.ts b/apps/sim/app/api/v2/skills/[id]/route.test.ts new file mode 100644 index 00000000000..834191497ff --- /dev/null +++ b/apps/sim/app/api/v2/skills/[id]/route.test.ts @@ -0,0 +1,331 @@ +/** + * @vitest-environment node + * + * Public v2 skill detail: the get-by-id that has no internal equivalent, plus + * the per-id update/delete that replaced the bulk upsert. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetSkillById, + mockPerformUpdateSkill, + mockPerformDeleteSkill, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetSkillById: vi.fn(), + mockPerformUpdateSkill: vi.fn(), + mockPerformDeleteSkill: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/skills/operations', () => ({ + getSkillById: mockGetSkillById, +})) + +vi.mock('@/lib/skills/orchestration', () => ({ + performUpdateSkill: mockPerformUpdateSkill, + performDeleteSkill: mockPerformDeleteSkill, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/skills/[id]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +function buildSkill(overrides: Record = {}) { + return { + id: 'skl_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'skl_abc123' }) }) +const url = (query = 'workspaceId=workspace-1') => + `http://localhost:3000/api/v2/skills/skl_abc123?${query}` + +const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) +const callDelete = (query?: string) => + DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/skills/skl_abc123', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +describe('GET /api/v2/skills/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetSkillById.mockResolvedValue(buildSkill()) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockGetSkillById).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect(mockGetSkillById).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(403) + expect(mockGetSkillById).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the skill is not in the workspace', async () => { + mockGetSkillById.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the single skill including its body', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ + skill: { + id: 'skl_abc123', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy', + readOnly: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + }) + expect(mockGetSkillById).toHaveBeenCalledWith({ + skillId: 'skl_abc123', + workspaceId: 'workspace-1', + }) + }) +}) + +describe('PATCH /api/v2/skills/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformUpdateSkill.mockResolvedValue({ + success: true, + skill: buildSkill({ description: 'Updated' }), + }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + + expect(res.status).toBe(404) + expect(mockPerformUpdateSkill).not.toHaveBeenCalled() + }) + + it('400s when no field to change is supplied', async () => { + const res = await callPatch({ workspaceId: 'workspace-1' }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformUpdateSkill).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(res.status).toBe(403) + expect(mockPerformUpdateSkill).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('403s when the caller is not a skill editor', async () => { + mockPerformUpdateSkill.mockResolvedValue({ + success: false, + error: 'Skill editor access required to modify "refund-policy"', + errorCode: 'forbidden', + }) + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(res.status).toBe(403) + expect((await res.json()).error.code).toBe('FORBIDDEN') + }) + + it('400s when the orchestration rejects a built-in skill', async () => { + mockPerformUpdateSkill.mockResolvedValue({ + success: false, + error: 'Built-in skills are read-only and cannot be modified', + errorCode: 'validation', + }) + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('Built-in') + }) + + it('gates on workspace read, leaving edit rights to the per-skill editor check', async () => { + await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'workspace-1', + 'read' + ) + }) + + it('updates the skill and returns the single skill', async () => { + const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.skill.description).toBe('Updated') + expect(Array.isArray(body.data)).toBe(false) + expect(mockPerformUpdateSkill).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + skillId: 'skl_abc123', + description: 'Updated', + source: 'api', + }) + ) + }) +}) + +describe('DELETE /api/v2/skills/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformDeleteSkill.mockResolvedValue({ success: true, skill: buildSkill() }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockPerformDeleteSkill).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockPerformDeleteSkill).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(403) + expect(mockPerformDeleteSkill).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('400s when the skill is a read-only built-in', async () => { + mockPerformDeleteSkill.mockResolvedValue({ + success: false, + error: 'Built-in skills are read-only and cannot be modified', + errorCode: 'validation', + }) + const res = await callDelete() + expect(res.status).toBe(400) + }) + + it('gates on workspace read, leaving delete rights to the per-skill editor check', async () => { + await callDelete() + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'workspace-1', + 'read' + ) + }) + + it('deletes the skill and acknowledges the id', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'skl_abc123', deleted: true } }) + expect(mockPerformDeleteSkill).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + skillId: 'skl_abc123', + source: 'api', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/skills/[id]/route.ts b/apps/sim/app/api/v2/skills/[id]/route.ts new file mode 100644 index 00000000000..2cb7d1f2017 --- /dev/null +++ b/apps/sim/app/api/v2/skills/[id]/route.ts @@ -0,0 +1,169 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteSkillContract, + v2GetSkillContract, + v2UpdateSkillContract, +} from '@/lib/api/contracts/v2/skills' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performDeleteSkill, performUpdateSkill } from '@/lib/skills/orchestration' +import { getSkillById } from '@/lib/workflows/skills/operations' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toV2Skill, v2SkillOrchestrationError } from '@/app/api/v2/skills/utils' + +const logger = createLogger('V2SkillDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** GET /api/v2/skills/[id] — Fetch a single skill, including its body. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skill-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetSkillContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const skill = await getSkillById({ skillId: id, workspaceId }) + if (!skill) return v2Error('NOT_FOUND', 'Skill not found') + + return v2Data({ skill: toV2Skill(skill) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error fetching skill`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/skills/[id] — Update a skill. Omitted fields keep their values. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skill-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateSkillContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, name, description, content } = parsed.data.body + + /** + * Editing an existing skill is gated per skill, not per workspace: an + * explicit editor grant (or workspace admin) is the authority, and + * `performUpdateSkill` enforces it. Requiring workspace `write` here would + * reject a legitimate skill editor who only holds `read` — stricter than the + * UI and than what this endpoint documents. Creating still needs `write`. + */ + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const result = await performUpdateSkill({ + workspaceId, + userId, + skillId: id, + name, + description, + content, + source: 'api', + request, + }) + + if (!result.success || !result.skill) { + return v2SkillOrchestrationError(result.errorCode, result.error ?? 'Failed to update skill') + } + + return v2Data({ skill: toV2Skill(result.skill) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error updating skill`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/skills/[id] — Delete a skill. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skill-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteSkillContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId } = parsed.data.query + + // Gated per skill by `performDeleteSkill`, same as PATCH above. + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteSkill({ + workspaceId, + userId, + skillId: id, + source: 'api', + request, + }) + + if (!result.success) { + return v2SkillOrchestrationError(result.errorCode, result.error ?? 'Failed to delete skill') + } + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting skill`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/skills/route.test.ts b/apps/sim/app/api/v2/skills/route.test.ts new file mode 100644 index 00000000000..6cf0ae6f52f --- /dev/null +++ b/apps/sim/app/api/v2/skills/route.test.ts @@ -0,0 +1,261 @@ +/** + * @vitest-environment node + * + * Public v2 skills list/create: gate ordering, contract validation, and the + * single-resource create that replaced the internal bulk upsert. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockListSkills, mockPerformCreateSkill } = + vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListSkills: vi.fn(), + mockPerformCreateSkill: vi.fn(), + })) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/skills/operations', () => ({ + listSkills: mockListSkills, +})) + +vi.mock('@/lib/skills/orchestration', () => ({ + performCreateSkill: mockPerformCreateSkill, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET, POST } from '@/app/api/v2/skills/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +function buildSkill(overrides: Record = {}) { + return { + id: 'skl_abc123', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy\n\nAlways be kind.', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +function callList(query: string) { + return GET(new NextRequest(`http://localhost:3000/api/v2/skills?${query}`)) +} + +function callCreate(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/skills', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +const VALID_BODY = { + workspaceId: 'workspace-1', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy', +} + +describe('GET /api/v2/skills', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListSkills.mockResolvedValue([buildSkill()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList('workspaceId=workspace-1') + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockListSkills).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListSkills).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(403) + expect(mockListSkills).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList('workspaceId=workspace-1') + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns summaries without skill bodies in the cursor envelope', async () => { + const res = await callList('workspaceId=workspace-1') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'skl_abc123', + name: 'refund-policy', + description: 'How to handle refunds', + readOnly: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + expect(mockListSkills).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) + }) +}) + +describe('POST /api/v2/skills', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformCreateSkill.mockResolvedValue({ success: true, skill: buildSkill() }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callCreate(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockPerformCreateSkill).not.toHaveBeenCalled() + }) + + it('400s when the body is missing content', async () => { + const res = await callCreate({ + workspaceId: 'workspace-1', + name: 'refund-policy', + description: 'How to handle refunds', + }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformCreateSkill).not.toHaveBeenCalled() + }) + + it('400s when the name is not kebab-case', async () => { + const res = await callCreate({ ...VALID_BODY, name: 'Refund Policy' }) + expect(res.status).toBe(400) + expect(mockPerformCreateSkill).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(403) + expect(mockPerformCreateSkill).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('400s when the orchestration rejects a built-in skill name', async () => { + mockPerformCreateSkill.mockResolvedValue({ + success: false, + error: 'The skill name "deploy-workflow" is reserved by a built-in skill', + errorCode: 'validation', + }) + + const res = await callCreate({ ...VALID_BODY, name: 'deploy-workflow' }) + const body = await res.json() + + expect(res.status).toBe(400) + expect(body.error.code).toBe('BAD_REQUEST') + expect(body.error.message).toContain('built-in') + }) + + it('409s when the skill name is already taken', async () => { + mockPerformCreateSkill.mockResolvedValue({ + success: false, + error: 'The skill name "refund-policy" is unavailable in this workspace', + errorCode: 'conflict', + }) + const res = await callCreate(VALID_BODY) + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('creates the skill and returns 201 with the single skill, not the workspace list', async () => { + const res = await callCreate(VALID_BODY) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data).toEqual({ + skill: { + id: 'skl_abc123', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy\n\nAlways be kind.', + readOnly: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + }) + expect(mockPerformCreateSkill).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy', + source: 'api', + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/skills/route.ts b/apps/sim/app/api/v2/skills/route.ts new file mode 100644 index 00000000000..5e1d6a825b2 --- /dev/null +++ b/apps/sim/app/api/v2/skills/route.ts @@ -0,0 +1,116 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateSkillContract, v2ListSkillsContract } from '@/lib/api/contracts/v2/skills' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performCreateSkill } from '@/lib/skills/orchestration' +import { listSkills } from '@/lib/workflows/skills/operations' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toV2Skill, toV2SkillSummary, v2SkillOrchestrationError } from '@/app/api/v2/skills/utils' + +const logger = createLogger('V2SkillsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/skills — List skills in a workspace, built-ins included. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skills') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListSkillsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const skills = await listSkills({ workspaceId }) + + // The per-workspace skill set is small and bounded → a single full page. + return v2CursorList(skills.map(toV2SkillSummary), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing skills`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/skills — Create a skill. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'skills') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateSkillContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, name, description, content } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performCreateSkill({ + workspaceId, + userId, + name, + description, + content, + source: 'api', + request, + }) + + if (!result.success || !result.skill) { + return v2SkillOrchestrationError(result.errorCode, result.error ?? 'Failed to create skill') + } + + return v2Data({ skill: toV2Skill(result.skill) }, { rateLimit, status: 201 }) + } catch (error) { + logger.error(`[${requestId}] Error creating skill`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/skills/utils.ts b/apps/sim/app/api/v2/skills/utils.ts new file mode 100644 index 00000000000..a1cc30ceb02 --- /dev/null +++ b/apps/sim/app/api/v2/skills/utils.ts @@ -0,0 +1,48 @@ +import type { skill } from '@sim/db/schema' +import type { NextResponse } from 'next/server' +import type { V2Skill, V2SkillSummary } from '@/lib/api/contracts/v2/skills' +import type { SkillOrchestrationErrorCode } from '@/lib/skills/orchestration' +import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' +import { v2Error } from '@/app/api/v2/lib/response' + +/** + * Shared serialization + error mapping for the v2 skills surface. + */ + +type SkillRow = typeof skill.$inferSelect + +/** List projection — no `content`; skill bodies are fetched per skill. */ +export function toV2SkillSummary(row: SkillRow): V2SkillSummary { + return { + id: row.id, + name: row.name, + description: row.description, + readOnly: isBuiltinSkillId(row.id), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +/** Detail projection — the summary plus the skill body. */ +export function toV2Skill(row: SkillRow): V2Skill { + return { ...toV2SkillSummary(row), content: row.content } +} + +/** Renders a skill orchestration failure in the v2 error envelope. */ +export function v2SkillOrchestrationError( + errorCode: SkillOrchestrationErrorCode | undefined, + message: string +): NextResponse { + switch (errorCode) { + case 'validation': + return v2Error('BAD_REQUEST', message) + case 'forbidden': + return v2Error('FORBIDDEN', message) + case 'not_found': + return v2Error('NOT_FOUND', 'Skill not found') + case 'conflict': + return v2Error('CONFLICT', message) + default: + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +} diff --git a/apps/sim/lib/api/contracts/skills.ts b/apps/sim/lib/api/contracts/skills.ts index 31af51cd489..3b29193d87b 100644 --- a/apps/sim/lib/api/contracts/skills.ts +++ b/apps/sim/lib/api/contracts/skills.ts @@ -36,13 +36,13 @@ export const skillEditorSchema = z.object({ export type SkillEditor = z.output -const skillNameSchema = z +export const skillNameSchema = z .string() .min(1, 'Skill name is required') .max(64) .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'Name must be kebab-case (e.g. my-skill)') -const skillDescriptionSchema = z.string().min(1, 'Description is required').max(1024) -const skillContentSchema = z +export const skillDescriptionSchema = z.string().min(1, 'Description is required').max(1024) +export const skillContentSchema = z .string() .min(1, 'Content is required') .max(50_000, 'Content is too large') diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts new file mode 100644 index 00000000000..205dd794bb7 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -0,0 +1,229 @@ +import { z } from 'zod' +import { + normalizeCredentialEnvKey, + workspaceCredentialRoleSchema, + workspaceCredentialTypeSchema, +} from '@/lib/api/contracts/credentials' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { getServiceAccountRequiredFields } from '@/lib/credentials/service-account-fields' + +/** + * v2 credential contracts. + * + * Secret material — service-account JSON, API tokens, signing secrets, bot + * tokens, client secrets — is accepted on write and **never** returned on read, + * the same treatment MCP request headers get. A read exposes only whether a + * secret is stored (`hasServiceAccountKey`). + * + * `oauth` credentials cannot be created here: they are minted by the interactive + * OAuth connect flow and are bound to an `account` row the caller authorized in + * a browser. They are listed, read, updated, and deleted like any other type. + * + * Credential sharing (`/api/credentials/[id]/members`) is not part of this + * surface. + */ + +const ENV_VAR_NAME_REGEX = /^[A-Za-z0-9_]+$/ + +/** The types a public caller can create. `oauth` requires the browser connect flow. */ +export const v2CreatableCredentialTypeSchema = z.enum( + ['env_workspace', 'env_personal', 'service_account'], + { error: 'type must be one of env_workspace, env_personal, service_account' } +) +export type V2CreatableCredentialType = z.output + +/** + * Public credential projection. `workspaceId` (supplied by the caller), + * `createdBy`, and every encrypted column are omitted. + */ +export const v2CredentialSchema = z.object({ + id: z.string(), + type: workspaceCredentialTypeSchema, + displayName: z.string(), + description: z.string().nullable(), + /** The integration this credential authenticates against, when it has one. */ + providerId: z.string().nullable(), + /** The linked OAuth account, for `oauth` credentials. */ + accountId: z.string().nullable(), + /** The environment-variable name, for `env_workspace` / `env_personal` credentials. */ + envKey: z.string().nullable(), + /** Whether a service-account secret is stored. The secret itself is never returned. */ + hasServiceAccountKey: z.boolean(), + /** The caller's role on this credential. */ + role: workspaceCredentialRoleSchema, + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2Credential = z.output + +/** `{ credential }` payload for single-credential reads and mutations. */ +export const v2CredentialDataSchema = z.object({ credential: v2CredentialSchema }) +export type V2CredentialData = z.output + +export const v2CredentialDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2CredentialDeleteData = z.output + +export const v2CredentialParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2CredentialParams = z.output + +export const v2CredentialWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) +export type V2CredentialWorkspaceQuery = z.output + +export const v2ListCredentialsQuerySchema = v2CredentialWorkspaceQuerySchema.extend({ + type: workspaceCredentialTypeSchema.optional(), + providerId: z.string().min(1, 'providerId cannot be empty').optional(), +}) +export type V2ListCredentialsQuery = z.output + +/** Write-only secret fields, shared by create and the reconnect-style update. */ +const credentialSecretFields = { + /** Write-only. Google-style service-account JSON key. */ + serviceAccountJson: z.string().min(1, 'serviceAccountJson cannot be empty').optional(), + /** Write-only. Slack custom-bot signing secret. */ + signingSecret: z.string().trim().min(1, 'signingSecret cannot be empty').optional(), + /** Write-only. Slack custom-bot token. */ + botToken: z.string().trim().min(1, 'botToken cannot be empty').optional(), + /** Write-only. Atlassian API token. */ + apiToken: z.string().trim().min(1, 'apiToken cannot be empty').optional(), + domain: z.string().trim().min(1, 'domain cannot be empty').optional(), + /** Write-only. Client-credentials service-account id/secret pair. */ + clientId: z.string().trim().min(1, 'clientId cannot be empty').max(512).optional(), + clientSecret: z.string().trim().min(1, 'clientSecret cannot be empty').max(1024).optional(), + orgId: z.string().trim().min(1, 'orgId cannot be empty').max(255).optional(), +} as const + +export const v2CreateCredentialBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + type: v2CreatableCredentialTypeSchema, + displayName: z.string().trim().min(1).max(255).optional(), + description: z.string().trim().max(500).optional(), + providerId: z.string().trim().min(1, 'providerId cannot be empty').optional(), + /** Required for `env_workspace` / `env_personal`. Accepts `NAME` or `{{NAME}}`. */ + envKey: z.string().trim().min(1, 'envKey cannot be empty').optional(), + ...credentialSecretFields, + }) + .strict() + .superRefine((data, ctx) => { + if (data.type === 'service_account') { + for (const field of getServiceAccountRequiredFields(data.providerId)) { + if (!data[field]) { + ctx.addIssue({ + code: 'custom', + path: [field], + message: `${field} is required for ${data.providerId ?? 'service account'} credentials`, + }) + } + } + return + } + + const normalizedEnvKey = data.envKey ? normalizeCredentialEnvKey(data.envKey) : '' + if (!normalizedEnvKey) { + ctx.addIssue({ + code: 'custom', + path: ['envKey'], + message: 'envKey is required for env credentials', + }) + return + } + if (!ENV_VAR_NAME_REGEX.test(normalizedEnvKey)) { + ctx.addIssue({ + code: 'custom', + path: ['envKey'], + message: 'envKey must contain only letters, numbers, and underscores', + }) + } + }) +export type V2CreateCredentialBody = z.input + +/** + * Update body. Renaming and re-describing apply to any type; the secret fields + * rotate a stored secret in place (the provider re-verifies it). + */ +export const v2UpdateCredentialBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + displayName: z.string().trim().min(1).max(255).optional(), + description: z.string().trim().max(500).nullish(), + ...credentialSecretFields, + }) + .strict() + .superRefine((data, ctx) => { + const { workspaceId: _workspaceId, ...changes } = data + if (Object.values(changes).every((value) => value === undefined)) { + ctx.addIssue({ + code: 'custom', + path: ['displayName'], + message: 'At least one field to change is required', + }) + } + }) +export type V2UpdateCredentialBody = z.input + +/** + * Credential list. A workspace's credential set is small and bounded, so the + * full visible set is returned as a single page (`nextCursor` is always `null`); + * the canonical cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListCredentialsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/credentials', + query: v2ListCredentialsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2CredentialSchema), + }, +}) + +export const v2CreateCredentialContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/credentials', + body: v2CreateCredentialBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDataSchema), + }, +}) + +export const v2GetCredentialContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/credentials/[id]', + params: v2CredentialParamsSchema, + query: v2CredentialWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDataSchema), + }, +}) + +export const v2UpdateCredentialContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/credentials/[id]', + params: v2CredentialParamsSchema, + body: v2UpdateCredentialBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDataSchema), + }, +}) + +export const v2DeleteCredentialContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/credentials/[id]', + params: v2CredentialParamsSchema, + query: v2CredentialWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/custom-tools.ts b/apps/sim/lib/api/contracts/v2/custom-tools.ts new file mode 100644 index 00000000000..7082c6d1c82 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/custom-tools.ts @@ -0,0 +1,148 @@ +import { z } from 'zod' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { customToolSchemaSchema } from '@/lib/api/contracts/tools/custom' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 custom tool contracts. + * + * The internal `/api/tools/custom` surface is a bulk upsert with no per-id + * update, and it tolerates legacy *personal* tools (`workspaceId: null`, owned + * by one user) alongside workspace ones. v2 splits create from update and is + * workspace-scoped in every direction — a workspace key never reaches another + * user's personal tool. + * + * The JSON-Schema `schema` field is reused verbatim from the internal contract: + * it is an OpenAI-style function declaration whose `parameters.properties` are + * caller-defined, so the shape is deliberately open below the function level. + */ + +const customToolTitleSchema = z + .string({ error: 'title is required' }) + .min(1, 'title is required') + .max(200, 'title must be at most 200 characters') + +const customToolCodeSchema = z + .string({ error: 'code is required' }) + .max(100_000, 'code must be at most 100000 characters') + +export const v2CustomToolSchema = z.object({ + id: z.string(), + title: z.string(), + /** OpenAI-style function declaration describing the tool's callable surface. */ + schema: customToolSchemaSchema, + /** The tool's implementation body, executed in Sim's sandboxed function runtime. */ + code: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2CustomTool = z.output + +/** `{ customTool }` payload for single-tool reads and mutations. */ +export const v2CustomToolDataSchema = z.object({ customTool: v2CustomToolSchema }) +export type V2CustomToolData = z.output + +export const v2CustomToolDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2CustomToolDeleteData = z.output + +export const v2CustomToolParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2CustomToolParams = z.output + +export const v2CustomToolWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) +export type V2CustomToolWorkspaceQuery = z.output + +export const v2CreateCustomToolBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + title: customToolTitleSchema, + schema: customToolSchemaSchema, + code: customToolCodeSchema, + }) + .strict() +export type V2CreateCustomToolBody = z.input + +/** Update body. Omitted fields keep their stored values. */ +export const v2UpdateCustomToolBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + title: customToolTitleSchema.optional(), + schema: customToolSchemaSchema.optional(), + code: customToolCodeSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.title === undefined && body.schema === undefined && body.code === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['title'], + message: 'At least one of title, schema, or code is required', + }) + } + }) +export type V2UpdateCustomToolBody = z.input + +/** + * Custom tool list. The per-workspace set is small and bounded, so the full set + * is returned as a single page (`nextCursor` is always `null`); the canonical + * cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListCustomToolsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/custom-tools', + query: v2CustomToolWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2CustomToolSchema), + }, +}) + +export const v2CreateCustomToolContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/custom-tools', + body: v2CreateCustomToolBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CustomToolDataSchema), + }, +}) + +export const v2GetCustomToolContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/custom-tools/[id]', + params: v2CustomToolParamsSchema, + query: v2CustomToolWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CustomToolDataSchema), + }, +}) + +export const v2UpdateCustomToolContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/custom-tools/[id]', + params: v2CustomToolParamsSchema, + body: v2UpdateCustomToolBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CustomToolDataSchema), + }, +}) + +export const v2DeleteCustomToolContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/custom-tools/[id]', + params: v2CustomToolParamsSchema, + query: v2CustomToolWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CustomToolDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/folders.ts b/apps/sim/lib/api/contracts/v2/folders.ts new file mode 100644 index 00000000000..6f12fe25637 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/folders.ts @@ -0,0 +1,178 @@ +import { z } from 'zod' +import { + folderCascadeCountsSchema, + folderResourceTypeSchema, + folderScopeSchema, + servedFolderResourceTypeSchema, +} from '@/lib/api/contracts/folders' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 folder contracts. + * + * One folder engine serves several resource trees (`workflow`, `knowledge_base`, + * `table`), discriminated by `resourceType`. The internal surface defaults that + * field to `workflow` so an old client that never sends it keeps working across + * a deploy; the public surface has no such legacy, and defaulting it would let a + * caller silently file a knowledge-base folder into the workflow tree where the + * Knowledge page can never see it again. So v2 **requires** it on every + * operation, reusing the served enum with its default stripped. + * + * `duplicate`, `restore`, and `reorder` are not part of the public surface. + */ + +/** The served resource types, required rather than defaulted. */ +export const v2FolderResourceTypeSchema = servedFolderResourceTypeSchema.unwrap() +export type V2FolderResourceType = z.output + +/** + * Public folder projection. `userId` (the creator) and `workspaceId` (already + * known to the caller, who supplied it) are internal columns and not exposed. + */ +export const v2FolderSchema = z.object({ + id: z.string(), + resourceType: folderResourceTypeSchema, + name: z.string(), + parentId: z.string().nullable(), + /** Workflow folders only; always `false` for the other resource types. */ + locked: z.boolean(), + sortOrder: z.number(), + createdAt: z.string(), + updatedAt: z.string(), + /** Set when the folder is archived (in Recently Deleted) rather than live. */ + deletedAt: z.string().nullable(), +}) +export type V2Folder = z.output + +/** `{ folder }` payload for single-folder reads and mutations. */ +export const v2FolderDataSchema = z.object({ folder: v2FolderSchema }) +export type V2FolderData = z.output + +/** + * Delete acknowledgement. `deletedItems` reports what the cascade archived + * alongside the folder; only the key matching `resourceType` is populated. + */ +export const v2FolderDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), + deletedItems: folderCascadeCountsSchema.optional(), +}) +export type V2FolderDeleteData = z.output + +export const v2FolderParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2FolderParams = z.output + +/** Query for the id-keyed reads and the delete. */ +export const v2FolderScopedQuerySchema = z.object({ + workspaceId: workspaceIdSchema, + resourceType: v2FolderResourceTypeSchema, +}) +export type V2FolderScopedQuery = z.output + +export const v2ListFoldersQuerySchema = v2FolderScopedQuerySchema.extend({ + /** `active` (default) lists live folders; `archived` lists Recently Deleted. */ + scope: folderScopeSchema.default('active'), +}) +export type V2ListFoldersQuery = z.output + +export const v2CreateFolderBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + resourceType: v2FolderResourceTypeSchema, + name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), + /** Explicit `null` creates the folder at the workspace root. */ + parentId: z.string().min(1, 'parentId cannot be empty').nullable().optional(), + sortOrder: z.number().int('sortOrder must be an integer').min(0).optional(), + }) + .strict() +export type V2CreateFolderBody = z.input + +/** Update body. Omitted fields keep their stored values. */ +export const v2UpdateFolderBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + resourceType: v2FolderResourceTypeSchema, + name: z.string().trim().min(1, 'name cannot be empty').max(255, 'name is too long').optional(), + /** Workflow folders only, and changing it requires workspace `admin`. */ + locked: z.boolean().optional(), + parentId: z.string().min(1, 'parentId cannot be empty').nullable().optional(), + sortOrder: z.number().int('sortOrder must be an integer').min(0).optional(), + }) + .strict() + .superRefine((body, ctx) => { + if ( + body.name === undefined && + body.locked === undefined && + body.parentId === undefined && + body.sortOrder === undefined + ) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: 'At least one of name, locked, parentId, or sortOrder is required', + }) + } + }) +export type V2UpdateFolderBody = z.input + +/** + * Folder list. A workspace's folder tree for one resource type is small and + * bounded, so the full set is returned as a single page (`nextCursor` is always + * `null`); the canonical cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/folders', + query: v2ListFoldersQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2FolderSchema), + }, +}) + +export const v2CreateFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/folders', + body: v2CreateFolderBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FolderDataSchema), + }, +}) + +export const v2GetFolderContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/folders/[id]', + params: v2FolderParamsSchema, + query: v2FolderScopedQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FolderDataSchema), + }, +}) + +export const v2UpdateFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/folders/[id]', + params: v2FolderParamsSchema, + body: v2UpdateFolderBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FolderDataSchema), + }, +}) + +export const v2DeleteFolderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/folders/[id]', + params: v2FolderParamsSchema, + query: v2FolderScopedQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FolderDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts new file mode 100644 index 00000000000..54324148896 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -0,0 +1,217 @@ +import { z } from 'zod' +import { mcpAuthTypeSchema, mcpServerSchema, mcpTransportSchema } from '@/lib/api/contracts/mcp' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { createEnvVarPattern } from '@/executor/utils/reference-validation' + +/** + * v2 MCP server contracts. + * + * The routes are thin wrappers over `lib/mcp/orchestration`, but the public + * contract deliberately departs from the internal `/api/mcp/servers` shape in + * four places, each closing a hole that is merely awkward in a browser session + * and unsafe over an API key: + * + * 1. `headers` is write-only. The internal list returns the header map verbatim, + * which is where callers put `Authorization: Bearer …`; reusing that shape + * here would turn a read-scoped key into a token-exfiltration primitive. The + * public read exposes `hasHeaders` and `headerNames` only. + * 2. `url` must be a real absolute `http(s)` URL — the internal body accepts any + * string (or none at all). + * 3. `url` may not carry a `{{ENV_VAR}}` template. `lib/mcp/domain-check` skips + * both the domain allowlist and the SSRF resolve for templated hostnames, + * deferring validation to call time; over an API key that is a stored SSRF + * path. + * 4. Bodies are strict. An unrecognized field is a caller mistake, not something + * to silently pass through to storage. + */ + +/** A `{{ENV_VAR}}` reference anywhere in a URL defers domain/SSRF validation to call time. */ +function hasEnvVarTemplate(value: string): boolean { + return createEnvVarPattern().test(value) +} + +const v2McpServerUrlSchema = z + .string({ error: 'url is required' }) + .min(1, 'url is required') + .max(2048, 'url must be at most 2048 characters') + .refine((value) => !hasEnvVarTemplate(value), { + error: 'url must not contain {{ENV_VAR}} references on the public API', + }) + .refine( + (value) => { + try { + const protocol = new URL(value).protocol + return protocol === 'http:' || protocol === 'https:' + } catch { + return false + } + }, + { error: 'url must be an absolute http or https URL' } + ) + +const v2McpServerHeadersSchema = z.record( + z.string().min(1, 'Header names cannot be empty'), + z.string() +) + +/** + * Public MCP server projection. + * + * The field schemas are picked from {@link mcpServerSchema} so the legacy-row + * tolerance (`.catch()` on the free-text `transport`/`authType`/ + * `connection_status` columns) is shared with the internal surface. The pick is + * re-wrapped in a plain object so the result strips unknown keys instead of + * passing them through — that strip is what keeps `headers` and + * `oauthClientSecret` out of the response when a whole row is handed to it. + */ +export const v2McpServerSchema = z.object({ + ...mcpServerSchema.pick({ + id: true, + name: true, + description: true, + transport: true, + authType: true, + url: true, + timeout: true, + retries: true, + enabled: true, + connectionStatus: true, + lastError: true, + toolCount: true, + lastToolsRefresh: true, + lastConnected: true, + createdAt: true, + updatedAt: true, + oauthClientId: true, + }).shape, + /** Whether any request headers are configured. Values are never returned. */ + hasHeaders: z.boolean(), + /** Names of the configured request headers. Values are never returned. */ + headerNames: z.array(z.string()), + hasOauthClientSecret: z.boolean(), +}) +export type V2McpServer = z.output + +/** `{ mcpServer }` payload for single-server reads and mutations. */ +export const v2McpServerDataSchema = z.object({ mcpServer: v2McpServerSchema }) +export type V2McpServerData = z.output + +/** Delete acknowledgement — the id of the server that was deleted. */ +export const v2McpServerDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2McpServerDeleteData = z.output + +export const v2McpServerParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2McpServerParams = z.output + +export const v2McpServerWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) +export type V2McpServerWorkspaceQuery = z.output + +export const v2CreateMcpServerBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: z + .string({ error: 'name is required' }) + .min(1, 'name is required') + .max(255, 'name must be at most 255 characters'), + description: z.string().max(2000, 'description must be at most 2000 characters').optional(), + transport: mcpTransportSchema.optional(), + url: v2McpServerUrlSchema, + authType: mcpAuthTypeSchema.optional(), + /** Write-only. Reads expose `hasHeaders` and `headerNames` instead. */ + headers: v2McpServerHeadersSchema.optional(), + timeout: z + .number() + .int('timeout must be an integer number of milliseconds') + .min(1000, 'timeout must be at least 1000ms') + .max(300000, 'timeout must be at most 300000ms') + .optional(), + retries: z + .number() + .int('retries must be an integer') + .min(0, 'retries cannot be negative') + .max(10, 'retries must be at most 10') + .optional(), + enabled: z.boolean().optional(), + oauthClientId: z.string().max(512, 'oauthClientId is too long').nullable().optional(), + /** Write-only. Reads expose `hasOauthClientSecret` instead. */ + oauthClientSecret: z.string().max(2048, 'oauthClientSecret is too long').nullable().optional(), + }) + .strict() +export type V2CreateMcpServerBody = z.input + +/** + * Update body. Every configuration field is optional; `workspaceId` stays + * required so the request is tenant-scoped before the server id is resolved. + */ +export const v2UpdateMcpServerBodySchema = v2CreateMcpServerBodySchema + .partial() + .extend({ workspaceId: workspaceIdSchema }) + .strict() +export type V2UpdateMcpServerBody = z.input + +/** + * MCP server list. The per-workspace set is small and bounded, so the full set + * is returned as a single page (`nextCursor` is always `null`); the canonical + * cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListMcpServersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/mcp-servers', + query: v2McpServerWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2McpServerSchema), + }, +}) + +export const v2CreateMcpServerContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/mcp-servers', + body: v2CreateMcpServerBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2McpServerDataSchema), + }, +}) + +export const v2GetMcpServerContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/mcp-servers/[id]', + params: v2McpServerParamsSchema, + query: v2McpServerWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2McpServerDataSchema), + }, +}) + +export const v2UpdateMcpServerContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/mcp-servers/[id]', + params: v2McpServerParamsSchema, + body: v2UpdateMcpServerBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2McpServerDataSchema), + }, +}) + +export const v2DeleteMcpServerContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/mcp-servers/[id]', + params: v2McpServerParamsSchema, + query: v2McpServerWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2McpServerDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/skills.ts b/apps/sim/lib/api/contracts/v2/skills.ts new file mode 100644 index 00000000000..3bc7174ea81 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/skills.ts @@ -0,0 +1,156 @@ +import { z } from 'zod' +import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + skillContentSchema, + skillDescriptionSchema, + skillNameSchema, +} from '@/lib/api/contracts/skills' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 skills contracts. + * + * Two departures from the internal `/api/skills` shape: + * + * 1. **Single-resource writes.** The internal `POST` takes an array, conflates + * create and update, and answers with the whole workspace skill list. v2 + * splits it into `POST /v2/skills` (201) and `PATCH /v2/skills/[id]`, each + * answering with the one skill that changed. + * 2. **`content` is detail-only.** A skill body is up to 50 000 characters, so + * the list returns summaries and the full body is fetched per skill from + * `GET /v2/skills/[id]`. + * + * Field validation lives in `lib/skills/orchestration`, so these schemas and the + * lib enforce the same limits — the schemas reuse the shared field primitives + * rather than restating them. + */ + +/** List item — everything but the skill body. */ +export const v2SkillSummarySchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + /** True for built-in template skills, which ship with Sim and cannot be written to. */ + readOnly: z.boolean(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2SkillSummary = z.output + +/** Detail — the summary plus the skill body. */ +export const v2SkillSchema = v2SkillSummarySchema.extend({ + content: z.string(), +}) +export type V2Skill = z.output + +/** `{ skill }` payload for single-skill reads and mutations. */ +export const v2SkillDataSchema = z.object({ skill: v2SkillSchema }) +export type V2SkillData = z.output + +export const v2SkillDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2SkillDeleteData = z.output + +export const v2SkillParamsSchema = z.object({ + id: nonEmptyIdSchema, +}) +export type V2SkillParams = z.output + +export const v2SkillWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) +export type V2SkillWorkspaceQuery = z.output + +export const v2CreateSkillBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: skillNameSchema, + description: skillDescriptionSchema, + content: skillContentSchema, + }) + .strict() +export type V2CreateSkillBody = z.input + +/** + * Update body. Omitted fields keep their stored values, so a partial edit can + * never clobber a concurrent change to a field the caller did not send. + */ +export const v2UpdateSkillBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: skillNameSchema.optional(), + description: skillDescriptionSchema.optional(), + content: skillContentSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.name === undefined && body.description === undefined && body.content === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: 'At least one of name, description, or content is required', + }) + } + }) +export type V2UpdateSkillBody = z.input + +/** + * Skill list. The per-workspace set is small and bounded, so the full set is + * returned as a single page (`nextCursor` is always `null`); the canonical + * cursor envelope keeps the v2 list surface uniform. + */ +export const v2ListSkillsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/skills', + query: v2SkillWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2SkillSummarySchema), + }, +}) + +export const v2CreateSkillContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/skills', + body: v2CreateSkillBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SkillDataSchema), + }, +}) + +export const v2GetSkillContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/skills/[id]', + params: v2SkillParamsSchema, + query: v2SkillWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SkillDataSchema), + }, +}) + +export const v2UpdateSkillContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/skills/[id]', + params: v2SkillParamsSchema, + body: v2UpdateSkillBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SkillDataSchema), + }, +}) + +export const v2DeleteSkillContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/skills/[id]', + params: v2SkillParamsSchema, + query: v2SkillWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SkillDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts index e3c1c7bfebc..61ab053f07b 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts @@ -1,11 +1,12 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { captureServerEvent } from '@/lib/posthog/server' -import { getSkillActorContext } from '@/lib/skills/access' -import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' -import { deleteSkill, listSkillsForUser, upsertSkills } from '@/lib/workflows/skills/operations' +import { + performCreateSkill, + performDeleteSkill, + performUpdateSkill, +} from '@/lib/skills/orchestration' +import { listSkillsForUser } from '@/lib/workflows/skills/operations' const logger = createLogger('CopilotToolExecutor') @@ -77,35 +78,16 @@ export async function executeManageSkill( } } - const { skills: resultSkills } = await upsertSkills({ - skills: [{ name: params.name, description: params.description, content: params.content }], + const result = await performCreateSkill({ workspaceId, userId: context.userId, + name: params.name, + description: params.description, + content: params.content, + source: 'tool_input', }) - const created = resultSkills.find((s) => s.name === params.name) - - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.SKILL_CREATED, - resourceType: AuditResourceType.SKILL, - resourceId: created?.id, - resourceName: params.name, - description: `Created skill "${params.name}"`, - metadata: { source: 'tool_input' }, - }) - if (created?.id) { - captureServerEvent( - context.userId, - 'skill_created', - { - skill_id: created.id, - skill_name: params.name, - workspace_id: workspaceId, - source: 'tool_input', - }, - { groups: { workspace: workspaceId } } - ) + if (!result.success || !result.skill) { + return { success: false, error: result.error ?? 'Failed to create skill' } } return { @@ -113,9 +95,9 @@ export async function executeManageSkill( output: { success: true, operation, - skillId: created?.id, - name: params.name, - message: `Created skill "${params.name}"`, + skillId: result.skill.id, + name: result.skill.name, + message: `Created skill "${result.skill.name}"`, }, } } @@ -131,66 +113,28 @@ export async function executeManageSkill( } } - if (isBuiltinSkillId(params.skillId)) { - return { success: false, error: 'Built-in skills are read-only and cannot be modified' } - } - - const actor = await getSkillActorContext(params.skillId, context.userId) - if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) { - return { success: false, error: `Skill not found: ${params.skillId}` } - } - if (!actor.canEdit) { - return { - success: false, - error: `Permission denied: editing skill "${actor.skill.name}" requires skill editor access. Ask a skill editor to add you.`, - } - } - // Partial update: omitted fields keep their current values server-side. - await upsertSkills({ - skills: [ - { - id: params.skillId, - ...(params.name ? { name: params.name } : {}), - ...(params.description ? { description: params.description } : {}), - ...(params.content ? { content: params.content } : {}), - }, - ], + const result = await performUpdateSkill({ workspaceId, userId: context.userId, + skillId: params.skillId, + ...(params.name ? { name: params.name } : {}), + ...(params.description ? { description: params.description } : {}), + ...(params.content ? { content: params.content } : {}), + source: 'tool_input', }) - - const updatedName = params.name || actor.skill.name - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.SKILL_UPDATED, - resourceType: AuditResourceType.SKILL, - resourceId: params.skillId, - resourceName: updatedName, - description: `Updated skill "${updatedName}"`, - metadata: { source: 'tool_input' }, - }) - captureServerEvent( - context.userId, - 'skill_updated', - { - skill_id: params.skillId, - skill_name: updatedName, - workspace_id: workspaceId, - source: 'tool_input', - }, - { groups: { workspace: workspaceId } } - ) + if (!result.success || !result.skill) { + return { success: false, error: result.error ?? 'Failed to update skill' } + } return { success: true, output: { success: true, operation, - skillId: params.skillId, - name: updatedName, - message: `Updated skill "${updatedName}"`, + skillId: result.skill.id, + name: result.skill.name, + message: `Updated skill "${result.skill.name}"`, }, } } @@ -200,39 +144,15 @@ export async function executeManageSkill( return { success: false, error: "'skillId' is required for 'delete'" } } - if (!isBuiltinSkillId(params.skillId)) { - const actor = await getSkillActorContext(params.skillId, context.userId) - if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) { - return { success: false, error: `Skill not found: ${params.skillId}` } - } - if (!actor.canEdit) { - return { - success: false, - error: `Permission denied: deleting skill "${actor.skill.name}" requires skill editor access. Ask a skill editor to add you.`, - } - } - } - - const deleted = await deleteSkill({ skillId: params.skillId, workspaceId }) - if (!deleted) { - return { success: false, error: `Skill not found: ${params.skillId}` } - } - - recordAudit({ + const result = await performDeleteSkill({ workspaceId, - actorId: context.userId, - action: AuditAction.SKILL_DELETED, - resourceType: AuditResourceType.SKILL, - resourceId: params.skillId, - description: 'Deleted skill', - metadata: { source: 'tool_input' }, + userId: context.userId, + skillId: params.skillId, + source: 'tool_input', }) - captureServerEvent( - context.userId, - 'skill_deleted', - { skill_id: params.skillId, workspace_id: workspaceId, source: 'tool_input' }, - { groups: { workspace: workspaceId } } - ) + if (!result.success) { + return { success: false, error: result.error ?? 'Failed to delete skill' } + } return { success: true, diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts new file mode 100644 index 00000000000..07bad9ebfa1 --- /dev/null +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -0,0 +1,588 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { account, credential, credentialMember } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getPostgresErrorCode } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { normalizeCredentialEnvKey } from '@/lib/api/contracts/credentials' +import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' +import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment' +import type { CredentialOrchestrationErrorCode } from '@/lib/credentials/orchestration' +import { + ServiceAccountSecretError, + verifyAndBuildServiceAccountSecret, +} from '@/lib/credentials/service-account-secret' +import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { getServiceConfigByProviderId } from '@/lib/oauth' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import { captureServerEvent } from '@/lib/posthog/server' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('CredentialCreateOrchestration') + +/** + * Credential creation, shared by the session surface (`POST /api/credentials`) + * and the public API (`POST /api/v2/credentials`). + * + * Everything here was previously inline in the session route: the per-type + * source resolution, the existing-credential replay rules, the organization + * mutation locks and in-transaction re-authorization, and the audit. Callers + * render the outcome in their own envelope from `errorCode` / + * `providerErrorCode`. + */ + +type CredentialRow = typeof credential.$inferSelect +type CredentialType = CredentialRow['type'] +type DbOrTx = typeof db | Parameters[0]>[0] + +/** + * Raised by the in-transaction duplicate guard when a concurrent request slipped + * a row in between the outer existence check and the INSERT. + */ +class DuplicateCredentialError extends Error { + constructor() { + super('duplicate_display_name') + this.name = 'DuplicateCredentialError' + } +} + +export interface PerformCreateCredentialParams { + workspaceId: string + type: CredentialType + userId: string + actorName?: string | null + actorEmail?: string | null + displayName?: string + description?: string + providerId?: string + accountId?: string + envKey?: string + envOwnerUserId?: string + serviceAccountJson?: string + apiToken?: string + domain?: string + signingSecret?: string + botToken?: string + clientId?: string + clientSecret?: string + orgId?: string + /** + * Client-supplied credential id, honored only for `slack-custom-bot`: the + * setup modal shows the ingest URL `/api/webhooks/slack/custom/{id}` before + * secrets exist, so the id must be known up front. + */ + id?: string + request?: NextRequest +} + +export interface PerformCreateCredentialResult { + success: boolean + error?: string + errorCode?: CredentialOrchestrationErrorCode + /** Provider-specific code (e.g. Atlassian `invalid_credentials`) for client message mapping. */ + providerErrorCode?: string + /** A provider outage rather than a rejected secret — callers surface 502, not 400. */ + providerUnavailable?: boolean + credential?: CredentialRow + /** False when an existing credential matched the source and was returned instead. */ + created?: boolean +} + +interface ExistingCredentialSourceParams { + workspaceId: string + type: CredentialType + accountId?: string | null + envKey?: string | null + envOwnerUserId?: string | null + displayName?: string | null + providerId?: string | null +} + +/** + * Finds the credential that already occupies a source slot. Each type keys on a + * different tuple, matching the partial unique indexes on the table. + */ +async function findExistingCredentialBySourceWith( + exec: DbOrTx, + params: ExistingCredentialSourceParams +): Promise { + const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params + + if (type === 'oauth' && accountId) { + const [row] = await exec + .select() + .from(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'oauth'), + eq(credential.accountId, accountId) + ) + ) + .limit(1) + return row ?? null + } + + if (type === 'env_workspace' && envKey) { + const [row] = await exec + .select() + .from(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'env_workspace'), + eq(credential.envKey, envKey) + ) + ) + .limit(1) + return row ?? null + } + + if (type === 'env_personal' && envKey && envOwnerUserId) { + const [row] = await exec + .select() + .from(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'env_personal'), + eq(credential.envKey, envKey), + eq(credential.envOwnerUserId, envOwnerUserId) + ) + ) + .limit(1) + return row ?? null + } + + if (type === 'service_account' && displayName && providerId) { + const [row] = await exec + .select() + .from(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'service_account'), + eq(credential.providerId, providerId), + eq(credential.displayName, displayName) + ) + ) + .limit(1) + return row ?? null + } + + return null +} + +function failure( + error: string, + errorCode: CredentialOrchestrationErrorCode, + extra: Partial = {} +): PerformCreateCredentialResult { + return { success: false, error, errorCode, ...extra } +} + +export async function performCreateCredential( + params: PerformCreateCredentialParams +): Promise { + const { workspaceId, type, userId } = params + + try { + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.canWrite) { + return failure('Write permission required', 'forbidden') + } + + let resolvedDisplayName = params.displayName?.trim() ?? '' + const resolvedDescription = params.description?.trim() || null + let resolvedProviderId: string | null = params.providerId ?? null + let resolvedAccountId: string | null = params.accountId ?? null + const resolvedEnvKey: string | null = params.envKey + ? normalizeCredentialEnvKey(params.envKey) + : null + let resolvedEnvOwnerUserId: string | null = null + let resolvedEncryptedServiceAccountKey: string | null = null + const extraAuditMetadata: Record = {} + + if (type === 'oauth') { + const [accountRow] = await db + .select({ + id: account.id, + userId: account.userId, + providerId: account.providerId, + accountId: account.accountId, + }) + .from(account) + .where(eq(account.id, params.accountId!)) + .limit(1) + + if (!accountRow) return failure('OAuth account not found', 'not_found') + + if (accountRow.userId !== userId) { + return failure( + 'Only account owners can create oauth credentials for an account', + 'forbidden' + ) + } + + if (params.providerId !== accountRow.providerId) { + return failure('providerId does not match the selected OAuth account', 'validation') + } + if (!resolvedDisplayName) { + resolvedDisplayName = + getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId + } + } else if (type === 'service_account') { + try { + const secret = await verifyAndBuildServiceAccountSecret(params.providerId ?? '', { + signingSecret: params.signingSecret, + botToken: params.botToken, + apiToken: params.apiToken, + domain: params.domain, + serviceAccountJson: params.serviceAccountJson, + clientId: params.clientId, + clientSecret: params.clientSecret, + orgId: params.orgId, + }) + resolvedProviderId = secret.providerId + resolvedAccountId = null + resolvedEnvOwnerUserId = null + if (!resolvedDisplayName) resolvedDisplayName = secret.displayName + resolvedEncryptedServiceAccountKey = secret.encryptedServiceAccountKey + Object.assign(extraAuditMetadata, secret.auditMetadata) + } catch (error) { + if (error instanceof ServiceAccountSecretError) { + return failure(error.message, 'validation') + } + throw error + } + } else if (type === 'env_personal') { + resolvedEnvOwnerUserId = params.envOwnerUserId ?? userId + if (resolvedEnvOwnerUserId !== userId) { + return failure( + 'Only the current user can create personal env credentials for themselves', + 'forbidden' + ) + } + resolvedProviderId = null + resolvedAccountId = null + resolvedDisplayName = resolvedEnvKey || '' + } else { + resolvedProviderId = null + resolvedAccountId = null + resolvedEnvOwnerUserId = null + resolvedDisplayName = resolvedEnvKey || '' + } + + if (!resolvedDisplayName) return failure('Display name is required', 'validation') + + const existingCredential = await findExistingCredentialBySourceWith(db, { + workspaceId, + type, + accountId: resolvedAccountId, + envKey: resolvedEnvKey, + envOwnerUserId: resolvedEnvOwnerUserId, + displayName: resolvedDisplayName, + providerId: resolvedProviderId, + }) + + if (existingCredential) { + /** + * A retried custom-bot create with the SAME pre-generated id is an + * idempotent replay and falls through to the normal existing-credential + * path. Any other name collision must fail loudly: returning the existing + * row as success would orphan the new id already embedded in the user's + * Slack Request URL (Slack would post to a URL no credential resolves). + */ + if ( + resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && + params.id && + existingCredential.id !== params.id + ) { + return failure( + `A Slack bot named "${resolvedDisplayName}" already exists in this workspace. Give this bot a different name.`, + 'conflict', + { providerErrorCode: 'duplicate_display_name' } + ) + } + + /** + * Token service-account creates always carry a fresh token that must be + * stored — falling through to the existing-credential path would return + * the old credential as success and silently drop the submitted token. + */ + if (resolvedProviderId && isTokenServiceAccountProviderId(resolvedProviderId)) { + return failure( + `A credential named "${resolvedDisplayName}" already exists in this workspace. Give this one a different name.`, + 'conflict', + { providerErrorCode: 'duplicate_display_name' } + ) + } + + const access = await getCredentialActorContext(existingCredential.id, userId, { + workspaceAccess, + }) + + if (!access.member && !access.isAdmin) { + return failure('A credential with this source already exists in this workspace', 'conflict') + } + + const shouldUpdateDisplayName = + type === 'oauth' && + resolvedDisplayName && + resolvedDisplayName !== existingCredential.displayName + const shouldUpdateDescription = + params.description !== undefined && + (existingCredential.description ?? null) !== resolvedDescription + + if (access.isAdmin && (shouldUpdateDisplayName || shouldUpdateDescription)) { + await db + .update(credential) + .set({ + ...(shouldUpdateDisplayName ? { displayName: resolvedDisplayName } : {}), + ...(shouldUpdateDescription ? { description: resolvedDescription } : {}), + updatedAt: new Date(), + }) + .where(eq(credential.id, existingCredential.id)) + + const [updatedCredential] = await db + .select() + .from(credential) + .where(eq(credential.id, existingCredential.id)) + .limit(1) + + return { + success: true, + credential: updatedCredential ?? existingCredential, + created: false, + } + } + + return { success: true, credential: existingCredential, created: false } + } + + const now = new Date() + const credentialId = + resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && params.id ? params.id : generateId() + + const creationResult = await db.transaction(async (tx) => { + /** + * Discover the organization lock scope inside this transaction, then + * acquire the same organization → user → membership locks as org + * removal/transfer and re-authorize from the transaction before writing. + * + * If this insert wins, transfer sees the new source-owned personal + * credential and blocks. If transfer wins, its permission/member cleanup + * is visible to the authoritative re-read below and the insert is refused. + */ + const plannedContext = await getCredentialCreationWorkspaceContext({ + executor: tx, + workspaceId, + userId, + }) + if (!plannedContext) return failure('Write permission required', 'forbidden') + + await acquireOrganizationUserMutationLocks(tx, { + userId, + organizationIds: plannedContext.organizationId ? [plannedContext.organizationId] : [], + }) + + const currentContext = await getCredentialCreationWorkspaceContext({ + executor: tx, + workspaceId, + userId, + forUpdate: true, + }) + if (!currentContext) return failure('Write permission required', 'forbidden') + if (currentContext.organizationId !== plannedContext.organizationId) { + return failure( + 'Workspace organization changed while creating the credential. Please retry.', + 'conflict' + ) + } + if (!currentContext.canWrite) return failure('Write permission required', 'forbidden') + + /** + * `service_account` has no DB-level unique index on (workspaceId, + * providerId, displayName), so re-check inside the tx. OAuth/env_* are + * guarded by partial unique indexes and fall through to the 23505 handler. + */ + if (type === 'service_account') { + const innerExisting = await findExistingCredentialBySourceWith(tx, { + workspaceId, + type, + displayName: resolvedDisplayName, + providerId: resolvedProviderId, + }) + if (innerExisting) throw new DuplicateCredentialError() + } + + await tx.insert(credential).values({ + id: credentialId, + workspaceId, + type, + displayName: resolvedDisplayName, + description: resolvedDescription, + providerId: resolvedProviderId, + accountId: resolvedAccountId, + envKey: resolvedEnvKey, + envOwnerUserId: resolvedEnvOwnerUserId, + encryptedServiceAccountKey: resolvedEncryptedServiceAccountKey, + createdBy: userId, + createdAt: now, + updatedAt: now, + }) + + if ((type === 'env_workspace' || type === 'service_account') && currentContext.ownerId) { + for (const memberUserId of currentContext.memberUserIds) { + await tx.insert(credentialMember).values({ + id: generateId(), + credentialId, + userId: memberUserId, + role: memberUserId === userId ? 'admin' : 'member', + status: 'active', + joinedAt: now, + invitedBy: userId, + createdAt: now, + updatedAt: now, + }) + } + } else { + await tx.insert(credentialMember).values({ + id: generateId(), + credentialId, + userId, + role: 'admin', + status: 'active', + joinedAt: now, + invitedBy: userId, + createdAt: now, + updatedAt: now, + }) + } + + return { success: true as const } + }) + + if (!creationResult.success) return creationResult + + const [created] = await db + .select() + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + + captureServerEvent( + userId, + 'credential_connected', + { credential_type: type, provider_id: resolvedProviderId ?? type, workspace_id: workspaceId }, + { + groups: { workspace: workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + + recordAudit({ + workspaceId, + actorId: userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credentialId, + resourceName: resolvedDisplayName, + description: `Created ${type} credential "${resolvedDisplayName}"`, + metadata: { + credentialType: type, + providerId: resolvedProviderId, + ...extraAuditMetadata, + }, + request: params.request, + }) + + return { success: true, credential: created, created: true } + } catch (error: unknown) { + if (error instanceof AtlassianValidationError) { + logger.warn(`Atlassian credential rejected: ${error.code}`, { + code: error.code, + upstreamStatus: error.status, + ...error.logDetail, + }) + return failure(error.code, 'validation', { + providerErrorCode: error.code, + providerUnavailable: isProviderOutageCode(error.code), + }) + } + if (error instanceof TokenServiceAccountValidationError) { + logger.warn(`Token service-account credential rejected: ${error.code}`, { + code: error.code, + upstreamStatus: error.status, + ...error.logDetail, + }) + // A provider outage is an infra failure, not a bad request. + return failure(error.code, 'validation', { + providerErrorCode: error.code, + providerUnavailable: isProviderOutageCode(error.code), + }) + } + if (error instanceof DuplicateCredentialError) { + return failure('A credential with that name already exists in this workspace.', 'conflict', { + providerErrorCode: 'duplicate_display_name', + }) + } + + const pgCode = getPostgresErrorCode(error) + if (pgCode === '23505') { + return failure('A credential with this source already exists', 'conflict') + } + if (pgCode === '23503') { + return failure('Invalid credential reference or membership target', 'validation') + } + if (pgCode === '23514') { + return failure('Credential source data failed validation checks', 'validation') + } + + const errAsRecord = + typeof error === 'object' && error !== null ? (error as Record) : {} + logger.error('Credential create failure details', { + code: pgCode, + detail: errAsRecord.detail, + constraint: errAsRecord.constraint, + table: errAsRecord.table, + message: errAsRecord.message, + }) + logger.error('Failed to create credential', { error }) + return failure('Internal server error', 'internal') + } +} + +/** + * Provider error codes that mean the upstream service could not be reached, + * rather than that the caller's secret was rejected. Each provider family names + * its own — Atlassian raises `atlassian_unavailable`, the token service accounts + * raise `provider_unavailable` — and both must map to 503, not 400. Kept as one + * set so a new provider family is added in a single place instead of being + * missed on whichever call path nobody re-checked. + */ +const PROVIDER_OUTAGE_CODES = new Set(['provider_unavailable', 'atlassian_unavailable']) + +export function isProviderOutageCode(code: string | undefined): boolean { + return code !== undefined && PROVIDER_OUTAGE_CODES.has(code) +} + +/** HTTP status for a credential orchestration failure, shared by every route surface. */ +export function statusForCredentialOrchestrationError( + code: CredentialOrchestrationErrorCode | undefined, + options: { providerUnavailable?: boolean } = {} +): number { + if (options.providerUnavailable) return 502 + if (code === 'validation') return 400 + if (code === 'forbidden') return 403 + if (code === 'not_found') return 404 + if (code === 'conflict') return 409 + return 500 +} diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index a26a190f126..e0c6a375e39 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -22,6 +22,14 @@ import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialOrchestration') +export { + isProviderOutageCode, + type PerformCreateCredentialParams, + type PerformCreateCredentialResult, + performCreateCredential, + statusForCredentialOrchestrationError, +} from './credential-create' + export type CredentialOrchestrationErrorCode = | 'not_found' | 'forbidden' diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts new file mode 100644 index 00000000000..397e96b9f59 --- /dev/null +++ b/apps/sim/lib/credentials/queries.ts @@ -0,0 +1,114 @@ +import { db } from '@sim/db' +import { credential, credentialMember } from '@sim/db/schema' +import { and, eq, inArray, isNotNull, or } from 'drizzle-orm' +import type { WorkspaceCredentialType } from '@/lib/api/contracts/credentials' +import { isSharedCredentialType, SHARED_CREDENTIAL_TYPES } from '@/lib/credentials/access' +import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +/** + * Workspace-scoped credential reads shared by the session surface and the public + * API, so the visibility rules cannot drift between them. + */ + +export type CredentialRow = typeof credential.$inferSelect + +export interface VisibleWorkspaceCredential { + id: string + workspaceId: string + type: CredentialRow['type'] + displayName: string + description: string | null + providerId: string | null + accountId: string | null + envKey: string | null + envOwnerUserId: string | null + createdBy: string + createdAt: Date + updatedAt: Date + hasServiceAccountKey: boolean + role: 'admin' | 'member' +} + +/** + * The credentials a user may see in a workspace. + * + * Visibility is an explicit `credential_member` row, plus — for workspace + * admins — every shared-type credential, plus the caller's own personal env + * credentials. Encrypted secret material is never selected. + */ +export async function listVisibleWorkspaceCredentials(params: { + workspaceId: string + userId: string + workspaceAccess: Pick + type?: WorkspaceCredentialType + providerId?: string +}): Promise { + const { workspaceId, userId, workspaceAccess, type, providerId } = params + + const whereClauses = [eq(credential.workspaceId, workspaceId)] + if (type) whereClauses.push(eq(credential.type, type)) + if (providerId) whereClauses.push(eq(credential.providerId, providerId)) + + const isWorkspaceAdmin = workspaceAccess.canAdmin + const accessClause = isWorkspaceAdmin + ? or( + isNotNull(credentialMember.id), + inArray(credential.type, SHARED_CREDENTIAL_TYPES), + eq(credential.envOwnerUserId, userId) + ) + : or(isNotNull(credentialMember.id), eq(credential.envOwnerUserId, userId)) + + const rows = await db + .select({ + id: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + displayName: credential.displayName, + description: credential.description, + providerId: credential.providerId, + accountId: credential.accountId, + envKey: credential.envKey, + envOwnerUserId: credential.envOwnerUserId, + createdBy: credential.createdBy, + createdAt: credential.createdAt, + updatedAt: credential.updatedAt, + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + memberRole: credentialMember.role, + }) + .from(credential) + .leftJoin( + credentialMember, + and( + eq(credentialMember.credentialId, credential.id), + eq(credentialMember.userId, userId), + eq(credentialMember.status, 'active') + ) + ) + .where(and(...whereClauses, accessClause)) + + return rows.map(({ memberRole, encryptedServiceAccountKey, ...rest }) => ({ + ...rest, + hasServiceAccountKey: Boolean(encryptedServiceAccountKey), + role: + isWorkspaceAdmin && isSharedCredentialType(rest.type) ? 'admin' : (memberRole ?? 'member'), + })) +} + +/** + * A single credential scoped to a workspace, or null when it does not exist + * there. Scoping by workspace is what keeps a credential id from another tenant + * from resolving at all. + */ +export async function getWorkspaceCredential(params: { + workspaceId: string + credentialId: string +}): Promise { + const [row] = await db + .select() + .from(credential) + .where( + and(eq(credential.id, params.credentialId), eq(credential.workspaceId, params.workspaceId)) + ) + .limit(1) + return row ?? null +} diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index 77cc4392756..092387cea90 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -83,6 +83,34 @@ export async function findActiveFolder( return row ?? null } +/** + * A folder in a workspace's tree regardless of archive state. + * + * {@link findActiveFolder} answers "is this a valid destination"; this answers "does this row + * exist here at all". Delete needs the second question — `deleteFolder` reuses an already + * archived folder's own `deletedAt` so a cascade that failed partway can be retried, and + * filtering archived rows out would strand those stragglers. + */ +export async function findFolderInWorkspace( + folderId: string, + workspaceId: string, + resourceType: FolderResourceType +): Promise { + const [row] = await db + .select() + .from(folder) + .where( + and( + eq(folder.id, folderId), + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, resourceType) + ) + ) + .limit(1) + + return row ?? null +} + /** * Where a restored resource should land: its original folder when that folder is reachable, * otherwise the workspace root. diff --git a/apps/sim/lib/mcp/queries.ts b/apps/sim/lib/mcp/queries.ts new file mode 100644 index 00000000000..81a50f0b1d6 --- /dev/null +++ b/apps/sim/lib/mcp/queries.ts @@ -0,0 +1,63 @@ +import { db } from '@sim/db' +import { mcpServers } from '@sim/db/schema' +import { and, desc, eq, isNull } from 'drizzle-orm' + +/** + * Workspace-scoped MCP server reads. The lifecycle functions in + * `lib/mcp/orchestration` cover the write paths; these cover the read paths the + * public API needs without duplicating the scoping predicate per route. + */ + +export type McpServerRow = typeof mcpServers.$inferSelect + +/** Live (non-soft-deleted) MCP servers in a workspace, newest first. */ +export async function listWorkspaceMcpServers(params: { + workspaceId: string +}): Promise { + return db + .select() + .from(mcpServers) + .where(and(eq(mcpServers.workspaceId, params.workspaceId), isNull(mcpServers.deletedAt))) + .orderBy(desc(mcpServers.createdAt)) +} + +/** A single live MCP server, or null when it does not exist in this workspace. */ +export async function getWorkspaceMcpServer(params: { + workspaceId: string + serverId: string +}): Promise { + const [row] = await db + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.id, params.serverId), + eq(mcpServers.workspaceId, params.workspaceId), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + return row ?? null +} + +/** + * The state of the row occupying the deterministic id derived from a workspace + * and URL, or null when the id is free. + * + * The soft-deleted case has to be distinguished rather than merged into "taken": + * `performCreateMcpServer` revives such a row instead of inserting alongside it, + * so reporting it as a duplicate would make a soft-deleted URL permanently + * unusable — it cannot be fetched or patched either, since those resolve live + * rows only. + */ +export async function getMcpServerIdState(params: { + workspaceId: string + serverId: string +}): Promise<{ deleted: boolean } | null> { + const [row] = await db + .select({ deletedAt: mcpServers.deletedAt }) + .from(mcpServers) + .where(and(eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, params.workspaceId))) + .limit(1) + return row ? { deleted: row.deletedAt !== null } : null +} diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index aa520209e87..d6383133d8f 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -197,20 +197,20 @@ export interface PostHogEventMap { skill_id: string skill_name: string workspace_id: string - source?: 'settings' | 'tool_input' + source?: 'settings' | 'tool_input' | 'api' } skill_updated: { skill_id: string skill_name: string workspace_id: string - source?: 'settings' | 'tool_input' + source?: 'settings' | 'tool_input' | 'api' } skill_deleted: { skill_id: string workspace_id: string - source?: 'settings' | 'tool_input' + source?: 'settings' | 'tool_input' | 'api' } skill_shared: { diff --git a/apps/sim/lib/skills/orchestration/index.ts b/apps/sim/lib/skills/orchestration/index.ts new file mode 100644 index 00000000000..48bf621ec27 --- /dev/null +++ b/apps/sim/lib/skills/orchestration/index.ts @@ -0,0 +1,12 @@ +export { + type PerformCreateSkillParams, + type PerformDeleteSkillParams, + type PerformSkillResult, + type PerformUpdateSkillParams, + performCreateSkill, + performDeleteSkill, + performUpdateSkill, + type SkillOrchestrationErrorCode, + type SkillWriteSource, + statusForSkillOrchestrationError, +} from './skill-lifecycle' diff --git a/apps/sim/lib/skills/orchestration/skill-lifecycle.ts b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts new file mode 100644 index 00000000000..b45d6b7db75 --- /dev/null +++ b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts @@ -0,0 +1,359 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import type { skill } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import type { z } from 'zod' +import { + skillContentSchema, + skillDescriptionSchema, + skillNameSchema, +} from '@/lib/api/contracts/skills' +import { captureServerEvent } from '@/lib/posthog/server' +import { getSkillActorContext } from '@/lib/skills/access' +import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' +import { getBuiltinSkillByName, isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' +import { deleteSkill, getSkillById, upsertSkills } from '@/lib/workflows/skills/operations' + +const logger = createLogger('SkillOrchestration') + +/** + * Single authority for skill create/update/delete. + * + * Before this module the API route owned the create-vs-update split, the + * built-in guard, the per-skill editor check, the field limits (which lived + * only in the route's Zod contract), and the audit — so the copilot's + * `manage_skill`, which calls `upsertSkills` directly, bypassed all of them. + * Every caller now goes through these functions and gets the same rules. + * + * Workspace-level authorization stays with the caller: each surface has already + * established workspace access by the time it gets here (session middleware, + * the v2 `resolveWorkspaceAccess`, the copilot's permission context). What is + * owned here is everything *per skill*. + */ + +/** + * Skills need a `forbidden` outcome the shared code set does not carry: a + * caller can hold workspace write and still not be an editor of a given skill. + */ +export type SkillOrchestrationErrorCode = OrchestrationErrorCode | 'forbidden' + +/** HTTP status for a skill orchestration failure, shared by every route surface. */ +export function statusForSkillOrchestrationError( + code: SkillOrchestrationErrorCode | undefined +): number { + if (code === 'validation') return 400 + if (code === 'forbidden') return 403 + if (code === 'not_found') return 404 + if (code === 'conflict') return 409 + return 500 +} + +type SkillRow = typeof skill.$inferSelect + +/** Which surface performed the write. Recorded on the audit entry and the analytics event. */ +export type SkillWriteSource = 'settings' | 'tool_input' | 'api' + +interface ActorMetadata { + actorName?: string | null + actorEmail?: string | null + source?: SkillWriteSource + request?: NextRequest +} + +export interface PerformCreateSkillParams extends ActorMetadata { + workspaceId: string + userId: string + name: string + description: string + content: string +} + +export interface PerformUpdateSkillParams extends ActorMetadata { + workspaceId: string + userId: string + skillId: string + name?: string + description?: string + content?: string +} + +export interface PerformDeleteSkillParams extends ActorMetadata { + workspaceId: string + userId: string + skillId: string +} + +export interface PerformSkillResult { + success: boolean + error?: string + errorCode?: SkillOrchestrationErrorCode + skill?: SkillRow +} + +function validationFailure(error: string): PerformSkillResult { + return { success: false, error, errorCode: 'validation' } +} + +/** First message from a failed field parse, or null when the value is valid. */ +function fieldError(schema: z.ZodType, value: unknown): string | null { + const parsed = schema.safeParse(value) + return parsed.success ? null : (parsed.error.issues[0]?.message ?? 'Invalid value') +} + +/** + * A workspace skill sharing a built-in's name silently shadows it everywhere the + * two lists are merged. Reject the collision at the write instead of resolving + * it at every read. + */ +function builtinNameCollision(name: string): string | null { + return getBuiltinSkillByName(name) + ? `The skill name "${name}" is reserved by a built-in skill` + : null +} + +/** + * Resolves the acting user's edit rights over an existing workspace skill. + * Returns the loaded row, or the failure to surface. + */ +async function resolveEditableSkill(params: { + workspaceId: string + userId: string + skillId: string +}): Promise<{ ok: true; skill: SkillRow } | { ok: false; result: PerformSkillResult }> { + if (isBuiltinSkillId(params.skillId)) { + return { + ok: false, + result: validationFailure('Built-in skills are read-only and cannot be modified'), + } + } + + const actor = await getSkillActorContext(params.skillId, params.userId) + if (!actor.skill || actor.skill.workspaceId !== params.workspaceId || !actor.hasWorkspaceAccess) { + return { + ok: false, + result: { success: false, error: 'Skill not found', errorCode: 'not_found' }, + } + } + if (!actor.canEdit) { + return { + ok: false, + result: { + success: false, + error: `Skill editor access required to modify "${actor.skill.name}"`, + errorCode: 'forbidden', + }, + } + } + return { ok: true, skill: actor.skill } +} + +/** + * `upsertSkills` reports name collisions and vanished ids as thrown Errors. + * Classify them rather than letting every caller re-match the message. + * + * The `23505` arm covers the race its in-transaction name `SELECT` cannot: two + * concurrent creates (or renames) both pass that check, and the loser is rejected + * by `skill_workspace_name_unique` as a raw Postgres error whose message matches + * nothing here — which would otherwise surface as a 500 for what is a conflict. + */ +function classifyUpsertError(error: unknown): PerformSkillResult { + const message = getErrorMessage(error, 'Failed to save skill') + if (getPostgresErrorCode(error) === '23505') { + return { + success: false, + error: 'That skill name is unavailable in this workspace', + errorCode: 'conflict', + } + } + if (message.includes('is unavailable')) { + return { success: false, error: message, errorCode: 'conflict' } + } + if (message.startsWith('Skill not found')) { + return { success: false, error: 'Skill not found', errorCode: 'not_found' } + } + logger.error('Skill upsert failed', { error: message }) + return { success: false, error: 'Failed to save skill', errorCode: 'internal' } +} + +type SkillLifecycleAction = 'created' | 'updated' | 'deleted' + +const AUDIT_ACTION = { + created: AuditAction.SKILL_CREATED, + updated: AuditAction.SKILL_UPDATED, + deleted: AuditAction.SKILL_DELETED, +} as const satisfies Record + +const AUDIT_VERB = { + created: 'Created', + updated: 'Updated', + deleted: 'Deleted', +} as const satisfies Record + +function recordSkillEvent(params: { + action: SkillLifecycleAction + workspaceId: string + userId: string + skillId: string + skillName: string + actor: ActorMetadata +}): void { + const { action, workspaceId, userId, skillId, skillName, actor } = params + + recordAudit({ + workspaceId, + actorId: userId, + actorName: actor.actorName ?? undefined, + actorEmail: actor.actorEmail ?? undefined, + action: AUDIT_ACTION[action], + resourceType: AuditResourceType.SKILL, + resourceId: skillId, + resourceName: skillName, + description: `${AUDIT_VERB[action]} skill "${skillName}"`, + metadata: { source: actor.source }, + request: actor.request, + }) + + // The delete event carries no skill_name — the skill no longer exists to name. + if (action === 'deleted') { + captureServerEvent( + userId, + 'skill_deleted', + { skill_id: skillId, workspace_id: workspaceId, source: actor.source }, + { groups: { workspace: workspaceId } } + ) + return + } + + captureServerEvent( + userId, + action === 'created' ? 'skill_created' : 'skill_updated', + { + skill_id: skillId, + skill_name: skillName, + workspace_id: workspaceId, + source: actor.source, + }, + { groups: { workspace: workspaceId } } + ) +} + +export async function performCreateSkill( + params: PerformCreateSkillParams +): Promise { + const invalid = + fieldError(skillNameSchema, params.name) ?? + fieldError(skillDescriptionSchema, params.description) ?? + fieldError(skillContentSchema, params.content) ?? + builtinNameCollision(params.name) + if (invalid) return validationFailure(invalid) + + let created: { id: string; name: string } | undefined + try { + const { touched } = await upsertSkills({ + skills: [{ name: params.name, description: params.description, content: params.content }], + workspaceId: params.workspaceId, + userId: params.userId, + returnSkills: false, + }) + created = touched[0] + } catch (error) { + return classifyUpsertError(error) + } + + if (!created) { + logger.error('Skill create returned no touched row', { workspaceId: params.workspaceId }) + return { success: false, error: 'Failed to create skill', errorCode: 'internal' } + } + + recordSkillEvent({ + action: 'created', + workspaceId: params.workspaceId, + userId: params.userId, + skillId: created.id, + skillName: created.name, + actor: params, + }) + + const row = await getSkillById({ skillId: created.id, workspaceId: params.workspaceId }) + if (!row) return { success: false, error: 'Failed to create skill', errorCode: 'internal' } + return { success: true, skill: row } +} + +export async function performUpdateSkill( + params: PerformUpdateSkillParams +): Promise { + if ( + params.name === undefined && + params.description === undefined && + params.content === undefined + ) { + return validationFailure('At least one of name, description, or content is required') + } + + const invalid = + (params.name !== undefined + ? (fieldError(skillNameSchema, params.name) ?? builtinNameCollision(params.name)) + : null) ?? + (params.description !== undefined + ? fieldError(skillDescriptionSchema, params.description) + : null) ?? + (params.content !== undefined ? fieldError(skillContentSchema, params.content) : null) + if (invalid) return validationFailure(invalid) + + const resolved = await resolveEditableSkill(params) + if (!resolved.ok) return resolved.result + + try { + await upsertSkills({ + skills: [ + { + id: params.skillId, + ...(params.name !== undefined ? { name: params.name } : {}), + ...(params.description !== undefined ? { description: params.description } : {}), + ...(params.content !== undefined ? { content: params.content } : {}), + }, + ], + workspaceId: params.workspaceId, + userId: params.userId, + returnSkills: false, + }) + } catch (error) { + return classifyUpsertError(error) + } + + const row = await getSkillById({ skillId: params.skillId, workspaceId: params.workspaceId }) + if (!row) return { success: false, error: 'Skill not found', errorCode: 'not_found' } + + recordSkillEvent({ + action: 'updated', + workspaceId: params.workspaceId, + userId: params.userId, + skillId: row.id, + skillName: row.name, + actor: params, + }) + + return { success: true, skill: row } +} + +export async function performDeleteSkill( + params: PerformDeleteSkillParams +): Promise { + const resolved = await resolveEditableSkill(params) + if (!resolved.ok) return resolved.result + + const deleted = await deleteSkill({ skillId: params.skillId, workspaceId: params.workspaceId }) + if (!deleted) return { success: false, error: 'Skill not found', errorCode: 'not_found' } + + recordSkillEvent({ + action: 'deleted', + workspaceId: params.workspaceId, + userId: params.userId, + skillId: params.skillId, + skillName: resolved.skill.name, + actor: params, + }) + + return { success: true, skill: resolved.skill } +} diff --git a/apps/sim/lib/workflows/custom-tools/operations.ts b/apps/sim/lib/workflows/custom-tools/operations.ts index 8fbccef1b43..2b6a779776b 100644 --- a/apps/sim/lib/workflows/custom-tools/operations.ts +++ b/apps/sim/lib/workflows/custom-tools/operations.ts @@ -128,6 +128,86 @@ export async function listCustomTools(params: { userId: string; workspaceId?: st .orderBy(desc(customTools.createdAt)) } +/** + * Workspace-scoped reads and deletes. + * + * The functions above tolerate legacy personal tools (`workspace_id IS NULL`, + * owned by one user) alongside workspace ones. The public API is workspace- + * scoped in every direction, so it uses these instead — a caller holding a + * workspace key must never reach another user's personal tool. + */ +export async function listWorkspaceCustomTools(params: { workspaceId: string }) { + return db + .select() + .from(customTools) + .where(eq(customTools.workspaceId, params.workspaceId)) + .orderBy(desc(customTools.createdAt)) +} + +export async function getWorkspaceCustomTool(params: { workspaceId: string; toolId: string }) { + const [row] = await db + .select() + .from(customTools) + .where(and(eq(customTools.id, params.toolId), eq(customTools.workspaceId, params.workspaceId))) + .limit(1) + return row ?? null +} + +/** Titles are unique per workspace (`custom_tools_workspace_title_unique`). */ +export async function getWorkspaceCustomToolByTitle(params: { + workspaceId: string + title: string +}) { + const [row] = await db + .select() + .from(customTools) + .where( + and(eq(customTools.workspaceId, params.workspaceId), eq(customTools.title, params.title)) + ) + .limit(1) + return row ?? null +} + +/** + * Updates a workspace tool in place, returning the updated row or null when the + * id no longer resolves in that workspace. + * + * Deliberately not `upsertCustomTools`: that treats an unresolvable id as a + * create and inserts under a *new* id, so a tool deleted concurrently with an + * edit would be silently re-created as an orphan under a different id while the + * caller's follow-up read of the original id 404s. + */ +export async function updateWorkspaceCustomTool(params: { + workspaceId: string + toolId: string + title: string + schema: unknown + code: string +}) { + const [row] = await db + .update(customTools) + .set({ + title: params.title, + schema: params.schema, + code: params.code, + updatedAt: new Date(), + }) + .where(and(eq(customTools.id, params.toolId), eq(customTools.workspaceId, params.workspaceId))) + .returning() + return row ?? null +} + +export async function deleteWorkspaceCustomTool(params: { + workspaceId: string + toolId: string +}): Promise { + const deleted = await db + .delete(customTools) + .where(and(eq(customTools.id, params.toolId), eq(customTools.workspaceId, params.workspaceId))) + .returning({ id: customTools.id }) + return deleted.length > 0 +} + export async function getCustomToolById(params: { toolId: string userId: string diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 0cea7a4ec91..137f60f185b 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1028, - zodRoutes: 1028, + totalRoutes: 1038, + zodRoutes: 1038, nonZodRoutes: 0, } as const diff --git a/scripts/check-openapi-specs.ts b/scripts/check-openapi-specs.ts index 3387f058cb9..5fa5ad826a5 100644 --- a/scripts/check-openapi-specs.ts +++ b/scripts/check-openapi-specs.ts @@ -38,6 +38,7 @@ const SPEC_FILES = [ 'openapi-v2-tables.json', 'openapi-v2-knowledge.json', 'openapi-v2-files-audit.json', + 'openapi-v2-resources.json', ] /** Extra non-v2 contracts that are documented in the core spec. */ From ab6684fdb21771a3ddb5d80b96ba20243f5aaf0b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 00:03:53 -0700 Subject: [PATCH 035/159] fix(contracts): anchor the predicate double-cast annotation to the cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:api-validation:strict` counted 9 unannotated double-casts against a baseline of 8, failing CI. The predicate leaf schema was annotated, but the annotation sat above the declaration while the checker anchors on the line carrying the cast — five lines below, at the close of the object literal. The scanner walks back at most three lines and stops at the first non-comment one, so it hit `value: z.unknown().optional(),` and never saw the reason. Splitting the object schema from the cast puts them adjacent, so the existing reason binds. No behavior change — the cast, the schema, and the reasoning are unchanged. Also lowers the rawJsonReads ratchet 6 -> 5 to match the current count, which had drifted down; leaving it high lets a removed raw read silently come back. Co-Authored-By: Claude Opus 5 --- apps/sim/lib/api/contracts/tables.ts | 12 +++++++----- scripts/check-api-validation-contracts.ts | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 075a1a8a199..89e68b9715f 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -477,14 +477,16 @@ function predicateTreeTooLarge(root: unknown): string | null { * before it ran. Strict on BOTH branches is required: strict on the group alone * would just fall through to the leaf branch, which is the more dangerous reading. */ -// double-cast-allowed: `z.unknown()` keeps the runtime permissive (a leaf value -// is arbitrary JSON), but infers `unknown`, which is wider than -// `Predicate['value']`. The narrowing is type-level only — nothing is coerced. -const predicateLeafSchema = z.strictObject({ +const predicateLeafObjectSchema = z.strictObject({ field: z.string().min(1, 'field is required').max(128), op: z.enum(FILTER_OPS), value: z.unknown().optional(), -}) as unknown as z.ZodType +}) + +// double-cast-allowed: `z.unknown()` keeps the runtime permissive (a leaf value +// is arbitrary JSON), but infers `unknown`, which is wider than +// `Predicate['value']`. The narrowing is type-level only — nothing is coerced. +const predicateLeafSchema = predicateLeafObjectSchema as unknown as z.ZodType const predicateNodeSchema: z.ZodType = z.lazy(() => z.union([predicateGroupSchema, predicateLeafSchema]) diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 0cea7a4ec91..67020777e2e 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -25,7 +25,7 @@ const BOUNDARY_POLICY_BASELINE = { clientHookRawFetches: 0, clientSameOriginApiFetches: 0, doubleCasts: 8, - rawJsonReads: 6, + rawJsonReads: 5, untypedResponses: 0, annotationsMissingReason: 0, } as const From 29b64c6cc6f82339994b3a9c00189f2e73d3b65b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 00:12:30 -0700 Subject: [PATCH 036/159] fix(skills): point the orchestration error contract at its moved module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6150 branched before #6134, so skill-lifecycle.ts imports @/lib/workflows/orchestration/types — the module #6134 moved to @/lib/core/orchestration/types. Git merged a file deletion on one side with a new file referencing it on the other: no textual conflict, broken build. Co-Authored-By: Claude Opus 5 --- apps/sim/lib/skills/orchestration/skill-lifecycle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/skills/orchestration/skill-lifecycle.ts b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts index b45d6b7db75..5cb13daf037 100644 --- a/apps/sim/lib/skills/orchestration/skill-lifecycle.ts +++ b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts @@ -9,9 +9,9 @@ import { skillDescriptionSchema, skillNameSchema, } from '@/lib/api/contracts/skills' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { captureServerEvent } from '@/lib/posthog/server' import { getSkillActorContext } from '@/lib/skills/access' -import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' import { getBuiltinSkillByName, isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' import { deleteSkill, getSkillById, upsertSkills } from '@/lib/workflows/skills/operations' From 9a0e1e3651e8cba5786db8343ec0475ed5092b2c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 00:14:01 -0700 Subject: [PATCH 037/159] feat(cli): pick up the new v2 domains; discover modules instead of listing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges `v2-api-spec` (#6150 — v2 endpoints for MCP servers, skills, custom tools, folders, credentials) and the newer `improvement/v2-endpoints`. ## The generator was list-driven, so none of it would have appeared `DOMAINS` and `SPEC_FILES` were hardcoded. Five new contract modules and a new `openapi-v2-resources.json` had landed, and the generator would have skipped every one — silently, with `--check` still passing, because the generated file matched a generator that never looked. Both are now discovered from disk. That is the same silent-drop class the review rounds kept surfacing, and it is the property the whole pipeline rests on: a new v2 domain should reach the CLI by regenerating, not by remembering to edit a list. Result: 47 → 72 operations, 13 contract modules, and 25 new commands (`sim skills list`, `sim mcp-servers get`, `sim folders delete`, …) with no CLI change beyond the discovery fix. Summaries for the new domains now resolve too, so their `--help` reads properly instead of falling back to `METHOD /path`. ## Confirmation gates for the new destructive operations Five new DELETEs arrived ungated. `deleteFolder` is the sharpest — the route archives the folder *and cascades to its contents* — so its message says so rather than reading like a single-item removal. Added a test asserting every DELETE carries a confirmation, with `undeployWorkflow` the one documented exception (reversible by redeploying). It fails against this commit's own starting state, so the next domain to arrive cannot land ungated the way these did. ## One fix outside the CLI `lib/skills/orchestration/skill-lifecycle.ts`, added by #6150, imports `OrchestrationErrorCode` from `@/lib/workflows/orchestration/types`, which does not exist — the type lives in `@/lib/core/orchestration/types`, where every other consumer reads it. The branch does not type-check without this. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- .../skills/orchestration/skill-lifecycle.ts | 2 +- packages/sim-cli/src/contract/commands.ts | 14 + packages/sim-cli/src/generated/v2-api.ts | 1078 ++++++++++++++++- packages/sim-cli/src/http/client.test.ts | 31 + scripts/generate-v2-cli-api.ts | 75 +- 5 files changed, 1167 insertions(+), 33 deletions(-) diff --git a/apps/sim/lib/skills/orchestration/skill-lifecycle.ts b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts index b45d6b7db75..5cb13daf037 100644 --- a/apps/sim/lib/skills/orchestration/skill-lifecycle.ts +++ b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts @@ -9,9 +9,9 @@ import { skillDescriptionSchema, skillNameSchema, } from '@/lib/api/contracts/skills' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { captureServerEvent } from '@/lib/posthog/server' import { getSkillActorContext } from '@/lib/skills/access' -import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' import { getBuiltinSkillByName, isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' import { deleteSkill, getSkillById, upsertSkills } from '@/lib/workflows/skills/operations' diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 8f000240b36..e9642ac3eda 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -42,6 +42,20 @@ export const CLI_CONTRACT: CliContract = { deleteKnowledgeBase: { confirm: 'This deletes the knowledge base and every document in it.' }, deleteKnowledgeDocument: { confirm: 'This deletes the document and its embeddings.' }, deleteFile: { confirm: 'This archives the file.' }, + deleteSkill: { confirm: 'This deletes the skill.' }, + deleteCustomTool: { confirm: 'This deletes the custom tool.' }, + deleteMcpServer: { + confirm: 'This removes the MCP server and the tools it provides.', + }, + deleteCredential: { + confirm: 'This deletes the credential; anything authenticating with it stops working.', + }, + deleteFolder: { + // The route archives the folder *and cascades to its contents*, so this is + // the broadest delete on the surface — the message says so rather than + // reading like a single-item removal. + confirm: 'This archives the folder and everything inside it.', + }, // ─── Fields whose type misdescribes their meaning ───────────────────────── // `z.string()` that the route splits on commas. No generator can infer this. diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 6223d5d3329..895dbcdae3d 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -75,6 +75,110 @@ export type CancelWorkflowExecutionResponse = { } } +/** `POST /api/v2/credentials` */ +export type CreateCredentialBody = { + workspaceId: string + type: 'env_workspace' | 'env_personal' | 'service_account' + displayName?: string + description?: string + providerId?: string + envKey?: string + serviceAccountJson?: string + signingSecret?: string + botToken?: string + apiToken?: string + domain?: string + clientId?: string + clientSecret?: string + orgId?: string +} + +export type CreateCredentialResponse = { + data: { + credential: { + id: string + type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + displayName: string + description: string | null + providerId: string | null + accountId: string | null + envKey: string | null + hasServiceAccountKey: boolean + role: 'admin' | 'member' + createdAt: string + updatedAt: string + } + } +} + +/** `POST /api/v2/custom-tools` */ +export type CreateCustomToolBody = { + workspaceId: string + title: string + schema: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code: string +} + +export type CreateCustomToolResponse = { + data: { + customTool: { + id: string + title: string + schema: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code: string + createdAt: string + updatedAt: string + } + } +} + +/** `POST /api/v2/folders` */ +export type CreateFolderBody = { + workspaceId: string + resourceType: 'workflow' | 'knowledge_base' | 'table' + name: string + parentId?: string | null + sortOrder?: number +} + +export type CreateFolderResponse = { + data: { + folder: { + id: string + resourceType: 'workflow' | 'file' | 'knowledge_base' | 'table' + name: string + parentId: string | null + locked: boolean + sortOrder: number + createdAt: string + updatedAt: string + deletedAt: string | null + } + } +} + /** `POST /api/v2/knowledge` */ export type CreateKnowledgeBaseBody = { workspaceId: string @@ -116,6 +220,71 @@ export type CreateKnowledgeBaseResponse = { } } +/** `POST /api/v2/mcp-servers` */ +export type CreateMcpServerBody = { + workspaceId: string + name: string + description?: string + transport?: 'streamable-http' + url: string + authType?: 'none' | 'headers' | 'oauth' + headers?: Record + timeout?: number + retries?: number + enabled?: boolean + oauthClientId?: string | null + oauthClientSecret?: string | null +} + +export type CreateMcpServerResponse = { + data: { + mcpServer: { + id: string + name: string + description?: string + transport: 'streamable-http' + authType?: 'none' | 'headers' | 'oauth' + url?: string + timeout?: number + retries?: number + enabled: boolean + connectionStatus?: 'connected' | 'disconnected' | 'error' + lastError?: string | null + toolCount?: number + lastToolsRefresh?: string + lastConnected?: string + createdAt: string + updatedAt: string + oauthClientId?: string + hasHeaders: boolean + headerNames: Array + hasOauthClientSecret: boolean + } + } +} + +/** `POST /api/v2/skills` */ +export type CreateSkillBody = { + workspaceId: string + name: string + description: string + content: string +} + +export type CreateSkillResponse = { + data: { + skill: { + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string + content: string + } + } +} + /** `POST /api/v2/tables` */ export type CreateTableBody = { name: string @@ -210,6 +379,38 @@ export type CreateTableRowsResponse = } } +/** `DELETE /api/v2/credentials/[id]` */ +export type DeleteCredentialParams = { + id: string +} + +export type DeleteCredentialQuery = { + workspaceId: string +} + +export type DeleteCredentialResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/custom-tools/[id]` */ +export type DeleteCustomToolParams = { + id: string +} + +export type DeleteCustomToolQuery = { + workspaceId: string +} + +export type DeleteCustomToolResponse = { + data: { + id: string + deleted: true + } +} + /** `DELETE /api/v2/files/[fileId]` */ export type DeleteFileParams = { fileId: string @@ -226,6 +427,30 @@ export type DeleteFileResponse = { } } +/** `DELETE /api/v2/folders/[id]` */ +export type DeleteFolderParams = { + id: string +} + +export type DeleteFolderQuery = { + workspaceId: string + resourceType: 'workflow' | 'knowledge_base' | 'table' +} + +export type DeleteFolderResponse = { + data: { + id: string + deleted: true + deletedItems?: { + folders: number + workflows?: number + files?: number + knowledgeBases?: number + tables?: number + } + } +} + /** `DELETE /api/v2/knowledge/[id]` */ export type DeleteKnowledgeBaseParams = { id: string @@ -259,6 +484,38 @@ export type DeleteKnowledgeDocumentResponse = { } } +/** `DELETE /api/v2/mcp-servers/[id]` */ +export type DeleteMcpServerParams = { + id: string +} + +export type DeleteMcpServerQuery = { + workspaceId: string +} + +export type DeleteMcpServerResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/skills/[id]` */ +export type DeleteSkillParams = { + id: string +} + +export type DeleteSkillQuery = { + workspaceId: string +} + +export type DeleteSkillResponse = { + data: { + id: string + deleted: true + } +} + /** `DELETE /api/v2/tables/[tableId]` */ export type DeleteTableParams = { tableId: string @@ -581,6 +838,66 @@ export type GetAuditLogResponse = { } } +/** `GET /api/v2/credentials/[id]` */ +export type GetCredentialParams = { + id: string +} + +export type GetCredentialQuery = { + workspaceId: string +} + +export type GetCredentialResponse = { + data: { + credential: { + id: string + type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + displayName: string + description: string | null + providerId: string | null + accountId: string | null + envKey: string | null + hasServiceAccountKey: boolean + role: 'admin' | 'member' + createdAt: string + updatedAt: string + } + } +} + +/** `GET /api/v2/custom-tools/[id]` */ +export type GetCustomToolParams = { + id: string +} + +export type GetCustomToolQuery = { + workspaceId: string +} + +export type GetCustomToolResponse = { + data: { + customTool: { + id: string + title: string + schema: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code: string + createdAt: string + updatedAt: string + } + } +} + /** `GET /api/v2/logs/executions/[executionId]` */ export type GetExecutionParams = { executionId: string @@ -603,6 +920,32 @@ export type GetExecutionResponse = { } } +/** `GET /api/v2/folders/[id]` */ +export type GetFolderParams = { + id: string +} + +export type GetFolderQuery = { + workspaceId: string + resourceType: 'workflow' | 'knowledge_base' | 'table' +} + +export type GetFolderResponse = { + data: { + folder: { + id: string + resourceType: 'workflow' | 'file' | 'knowledge_base' | 'table' + name: string + parentId: string | null + locked: boolean + sortOrder: number + createdAt: string + updatedAt: string + deletedAt: string | null + } + } +} + /** `GET /api/v2/knowledge/[id]` */ export type GetKnowledgeBaseParams = { id: string @@ -710,6 +1053,65 @@ export type GetLogResponse = { } } +/** `GET /api/v2/mcp-servers/[id]` */ +export type GetMcpServerParams = { + id: string +} + +export type GetMcpServerQuery = { + workspaceId: string +} + +export type GetMcpServerResponse = { + data: { + mcpServer: { + id: string + name: string + description?: string + transport: 'streamable-http' + authType?: 'none' | 'headers' | 'oauth' + url?: string + timeout?: number + retries?: number + enabled: boolean + connectionStatus?: 'connected' | 'disconnected' | 'error' + lastError?: string | null + toolCount?: number + lastToolsRefresh?: string + lastConnected?: string + createdAt: string + updatedAt: string + oauthClientId?: string + hasHeaders: boolean + headerNames: Array + hasOauthClientSecret: boolean + } + } +} + +/** `GET /api/v2/skills/[id]` */ +export type GetSkillParams = { + id: string +} + +export type GetSkillQuery = { + workspaceId: string +} + +export type GetSkillResponse = { + data: { + skill: { + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string + content: string + } + } +} + /** `GET /api/v2/tables/[tableId]` */ export type GetTableParams = { tableId: string @@ -921,6 +1323,58 @@ export type ListAuditLogsResponse = { nextCursor: string | null } +/** `GET /api/v2/credentials` */ +export type ListCredentialsQuery = { + workspaceId: string + type?: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + providerId?: string +} + +export type ListCredentialsResponse = { + data: Array<{ + id: string + type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + displayName: string + description: string | null + providerId: string | null + accountId: string | null + envKey: string | null + hasServiceAccountKey: boolean + role: 'admin' | 'member' + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/custom-tools` */ +export type ListCustomToolsQuery = { + workspaceId: string +} + +export type ListCustomToolsResponse = { + data: Array<{ + id: string + title: string + schema: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code: string + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + /** `GET /api/v2/files` */ export type ListFilesQuery = { workspaceId: string @@ -941,21 +1395,43 @@ export type ListFilesResponse = { nextCursor: string | null } -/** `GET /api/v2/knowledge` */ -export type ListKnowledgeBasesQuery = { +/** `GET /api/v2/folders` */ +export type ListFoldersQuery = { workspaceId: string + resourceType: 'workflow' | 'knowledge_base' | 'table' + scope?: 'active' | 'archived' } -export type ListKnowledgeBasesResponse = { +export type ListFoldersResponse = { data: Array<{ id: string + resourceType: 'workflow' | 'file' | 'knowledge_base' | 'table' name: string - description: string | null - tokenCount: number - embeddingModel: string - embeddingDimension: number - chunkingConfig: { - maxSize: number + parentId: string | null + locked: boolean + sortOrder: number + createdAt: string + updatedAt: string + deletedAt: string | null + }> + nextCursor: string | null +} + +/** `GET /api/v2/knowledge` */ +export type ListKnowledgeBasesQuery = { + workspaceId: string +} + +export type ListKnowledgeBasesResponse = { + data: Array<{ + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: { + maxSize: number minSize: number overlap: number strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' @@ -1063,6 +1539,54 @@ export type ListLogsResponse = { nextCursor: string | null } +/** `GET /api/v2/mcp-servers` */ +export type ListMcpServersQuery = { + workspaceId: string +} + +export type ListMcpServersResponse = { + data: Array<{ + id: string + name: string + description?: string + transport: 'streamable-http' + authType?: 'none' | 'headers' | 'oauth' + url?: string + timeout?: number + retries?: number + enabled: boolean + connectionStatus?: 'connected' | 'disconnected' | 'error' + lastError?: string | null + toolCount?: number + lastToolsRefresh?: string + lastConnected?: string + createdAt: string + updatedAt: string + oauthClientId?: string + hasHeaders: boolean + headerNames: Array + hasOauthClientSecret: boolean + }> + nextCursor: string | null +} + +/** `GET /api/v2/skills` */ +export type ListSkillsQuery = { + workspaceId: string +} + +export type ListSkillsResponse = { + data: Array<{ + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + /** `GET /api/v2/tables/[tableId]/rows` */ export type ListTableRowsParams = { tableId: string @@ -1321,6 +1845,120 @@ export type UndeployWorkflowResponse = { } } +/** `PATCH /api/v2/credentials/[id]` */ +export type UpdateCredentialParams = { + id: string +} + +export type UpdateCredentialBody = { + workspaceId: string + displayName?: string + description?: string | null + serviceAccountJson?: string + signingSecret?: string + botToken?: string + apiToken?: string + domain?: string + clientId?: string + clientSecret?: string + orgId?: string +} + +export type UpdateCredentialResponse = { + data: { + credential: { + id: string + type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + displayName: string + description: string | null + providerId: string | null + accountId: string | null + envKey: string | null + hasServiceAccountKey: boolean + role: 'admin' | 'member' + createdAt: string + updatedAt: string + } + } +} + +/** `PATCH /api/v2/custom-tools/[id]` */ +export type UpdateCustomToolParams = { + id: string +} + +export type UpdateCustomToolBody = { + workspaceId: string + title?: string + schema?: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code?: string +} + +export type UpdateCustomToolResponse = { + data: { + customTool: { + id: string + title: string + schema: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code: string + createdAt: string + updatedAt: string + } + } +} + +/** `PATCH /api/v2/folders/[id]` */ +export type UpdateFolderParams = { + id: string +} + +export type UpdateFolderBody = { + workspaceId: string + resourceType: 'workflow' | 'knowledge_base' | 'table' + name?: string + locked?: boolean + parentId?: string | null + sortOrder?: number +} + +export type UpdateFolderResponse = { + data: { + folder: { + id: string + resourceType: 'workflow' | 'file' | 'knowledge_base' | 'table' + name: string + parentId: string | null + locked: boolean + sortOrder: number + createdAt: string + updatedAt: string + deletedAt: string | null + } + } +} + /** `PUT /api/v2/knowledge/[id]` */ export type UpdateKnowledgeBaseParams = { id: string @@ -1366,6 +2004,53 @@ export type UpdateKnowledgeBaseResponse = { } } +/** `PATCH /api/v2/mcp-servers/[id]` */ +export type UpdateMcpServerParams = { + id: string +} + +export type UpdateMcpServerBody = { + workspaceId: string + name?: string + description?: string + transport?: 'streamable-http' + url?: string + authType?: 'none' | 'headers' | 'oauth' + headers?: Record + timeout?: number + retries?: number + enabled?: boolean + oauthClientId?: string | null + oauthClientSecret?: string | null +} + +export type UpdateMcpServerResponse = { + data: { + mcpServer: { + id: string + name: string + description?: string + transport: 'streamable-http' + authType?: 'none' | 'headers' | 'oauth' + url?: string + timeout?: number + retries?: number + enabled: boolean + connectionStatus?: 'connected' | 'disconnected' | 'error' + lastError?: string | null + toolCount?: number + lastToolsRefresh?: string + lastConnected?: string + createdAt: string + updatedAt: string + oauthClientId?: string + hasHeaders: boolean + headerNames: Array + hasOauthClientSecret: boolean + } + } +} + /** `PUT /api/v2/tables/[tableId]/rows` */ export type UpdateRowsByFilterParams = { tableId: string @@ -1385,6 +2070,32 @@ export type UpdateRowsByFilterResponse = { } } +/** `PATCH /api/v2/skills/[id]` */ +export type UpdateSkillParams = { + id: string +} + +export type UpdateSkillBody = { + workspaceId: string + name?: string + description?: string + content?: string +} + +export type UpdateSkillResponse = { + data: { + skill: { + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string + content: string + } + } +} + /** `PATCH /api/v2/tables/[tableId]/columns` */ export type UpdateTableColumnParams = { tableId: string @@ -1546,6 +2257,64 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Cancel an execution', }, + createCredential: { + method: 'POST', + path: '/api/v2/credentials', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Credential', + body: { + workspaceId: { kind: 'string', required: true }, + type: { + kind: 'enum', + required: true, + values: ['env_workspace', 'env_personal', 'service_account'] as const, + }, + displayName: { kind: 'string' }, + description: { kind: 'string' }, + providerId: { kind: 'string' }, + envKey: { kind: 'string' }, + serviceAccountJson: { kind: 'string' }, + signingSecret: { kind: 'string' }, + botToken: { kind: 'string' }, + apiToken: { kind: 'string' }, + domain: { kind: 'string' }, + clientId: { kind: 'string' }, + clientSecret: { kind: 'string' }, + orgId: { kind: 'string' }, + }, + }, + createCustomTool: { + method: 'POST', + path: '/api/v2/custom-tools', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Custom Tool', + body: { + workspaceId: { kind: 'string', required: true }, + title: { kind: 'string', required: true }, + schema: { kind: 'object', required: true }, + code: { kind: 'string', required: true }, + }, + }, + createFolder: { + method: 'POST', + path: '/api/v2/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Folder', + body: { + workspaceId: { kind: 'string', required: true }, + resourceType: { + kind: 'enum', + required: true, + values: ['workflow', 'knowledge_base', 'table'] as const, + }, + name: { kind: 'string', required: true }, + parentId: { kind: 'string' }, + sortOrder: { kind: 'integer' }, + }, + }, createKnowledgeBase: { method: 'POST', path: '/api/v2/knowledge', @@ -1559,6 +2328,40 @@ export const V2_OPERATIONS = { chunkingConfig: { kind: 'object', default: { maxSize: 1024, minSize: 100, overlap: 200 } }, }, }, + createMcpServer: { + method: 'POST', + path: '/api/v2/mcp-servers', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create MCP Server', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + description: { kind: 'string' }, + transport: { kind: 'enum', values: ['streamable-http'] as const }, + url: { kind: 'string', required: true }, + authType: { kind: 'enum', values: ['none', 'headers', 'oauth'] as const }, + headers: { kind: 'object' }, + timeout: { kind: 'integer' }, + retries: { kind: 'integer' }, + enabled: { kind: 'boolean' }, + oauthClientId: { kind: 'string' }, + oauthClientSecret: { kind: 'string' }, + }, + }, + createSkill: { + method: 'POST', + path: '/api/v2/skills', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Skill', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + description: { kind: 'string', required: true }, + content: { kind: 'string', required: true }, + }, + }, createTable: { method: 'POST', path: '/api/v2/tables', @@ -1580,6 +2383,26 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Create Rows', }, + deleteCredential: { + method: 'DELETE', + path: '/api/v2/credentials/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete Credential', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + deleteCustomTool: { + method: 'DELETE', + path: '/api/v2/custom-tools/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete Custom Tool', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, deleteFile: { method: 'DELETE', path: '/api/v2/files/[fileId]', @@ -1590,6 +2413,21 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + deleteFolder: { + method: 'DELETE', + path: '/api/v2/folders/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete Folder', + query: { + workspaceId: { kind: 'string', required: true }, + resourceType: { + kind: 'enum', + required: true, + values: ['workflow', 'knowledge_base', 'table'] as const, + }, + }, + }, deleteKnowledgeBase: { method: 'DELETE', path: '/api/v2/knowledge/[id]', @@ -1610,6 +2448,26 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + deleteMcpServer: { + method: 'DELETE', + path: '/api/v2/mcp-servers/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete MCP Server', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + deleteSkill: { + method: 'DELETE', + path: '/api/v2/skills/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete Skill', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, deleteTable: { method: 'DELETE', path: '/api/v2/tables/[tableId]', @@ -1702,6 +2560,26 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Audit Log', }, + getCredential: { + method: 'GET', + path: '/api/v2/credentials/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Credential', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + getCustomTool: { + method: 'GET', + path: '/api/v2/custom-tools/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Custom Tool', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, getExecution: { method: 'GET', path: '/api/v2/logs/executions/[executionId]', @@ -1709,6 +2587,21 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Execution', }, + getFolder: { + method: 'GET', + path: '/api/v2/folders/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Folder', + query: { + workspaceId: { kind: 'string', required: true }, + resourceType: { + kind: 'enum', + required: true, + values: ['workflow', 'knowledge_base', 'table'] as const, + }, + }, + }, getKnowledgeBase: { method: 'GET', path: '/api/v2/knowledge/[id]', @@ -1736,6 +2629,26 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Log', }, + getMcpServer: { + method: 'GET', + path: '/api/v2/mcp-servers/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get MCP Server', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + getSkill: { + method: 'GET', + path: '/api/v2/skills/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Skill', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, getTable: { method: 'GET', path: '/api/v2/tables/[tableId]', @@ -1817,6 +2730,31 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + listCredentials: { + method: 'GET', + path: '/api/v2/credentials', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Credentials', + query: { + workspaceId: { kind: 'string', required: true }, + type: { + kind: 'enum', + values: ['oauth', 'env_workspace', 'env_personal', 'service_account'] as const, + }, + providerId: { kind: 'string' }, + }, + }, + listCustomTools: { + method: 'GET', + path: '/api/v2/custom-tools', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Custom Tools', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, listFiles: { method: 'GET', path: '/api/v2/files', @@ -1829,6 +2767,22 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + listFolders: { + method: 'GET', + path: '/api/v2/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Folders', + query: { + workspaceId: { kind: 'string', required: true }, + resourceType: { + kind: 'enum', + required: true, + values: ['workflow', 'knowledge_base', 'table'] as const, + }, + scope: { kind: 'enum', values: ['active', 'archived'] as const, default: 'active' }, + }, + }, listKnowledgeBases: { method: 'GET', path: '/api/v2/knowledge', @@ -1899,6 +2853,26 @@ export const V2_OPERATIONS = { order: { kind: 'enum', values: ['desc', 'asc'] as const, default: 'desc' }, }, }, + listMcpServers: { + method: 'GET', + path: '/api/v2/mcp-servers', + pathParams: [] as const, + responseMode: 'json', + summary: 'List MCP Servers', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + listSkills: { + method: 'GET', + path: '/api/v2/skills', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Skills', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, listTableRows: { method: 'GET', path: '/api/v2/tables/[tableId]/rows', @@ -2011,6 +2985,58 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Undeploy Workflow', }, + updateCredential: { + method: 'PATCH', + path: '/api/v2/credentials/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update Credential', + body: { + workspaceId: { kind: 'string', required: true }, + displayName: { kind: 'string' }, + description: { kind: 'string' }, + serviceAccountJson: { kind: 'string' }, + signingSecret: { kind: 'string' }, + botToken: { kind: 'string' }, + apiToken: { kind: 'string' }, + domain: { kind: 'string' }, + clientId: { kind: 'string' }, + clientSecret: { kind: 'string' }, + orgId: { kind: 'string' }, + }, + }, + updateCustomTool: { + method: 'PATCH', + path: '/api/v2/custom-tools/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update Custom Tool', + body: { + workspaceId: { kind: 'string', required: true }, + title: { kind: 'string' }, + schema: { kind: 'object' }, + code: { kind: 'string' }, + }, + }, + updateFolder: { + method: 'PATCH', + path: '/api/v2/folders/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update Folder', + body: { + workspaceId: { kind: 'string', required: true }, + resourceType: { + kind: 'enum', + required: true, + values: ['workflow', 'knowledge_base', 'table'] as const, + }, + name: { kind: 'string' }, + locked: { kind: 'boolean' }, + parentId: { kind: 'string' }, + sortOrder: { kind: 'integer' }, + }, + }, updateKnowledgeBase: { method: 'PUT', path: '/api/v2/knowledge/[id]', @@ -2024,6 +3050,27 @@ export const V2_OPERATIONS = { chunkingConfig: { kind: 'object' }, }, }, + updateMcpServer: { + method: 'PATCH', + path: '/api/v2/mcp-servers/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update MCP Server', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string' }, + description: { kind: 'string' }, + transport: { kind: 'enum', values: ['streamable-http'] as const }, + url: { kind: 'string' }, + authType: { kind: 'enum', values: ['none', 'headers', 'oauth'] as const }, + headers: { kind: 'object' }, + timeout: { kind: 'integer' }, + retries: { kind: 'integer' }, + enabled: { kind: 'boolean' }, + oauthClientId: { kind: 'string' }, + oauthClientSecret: { kind: 'string' }, + }, + }, updateRowsByFilter: { method: 'PUT', path: '/api/v2/tables/[tableId]/rows', @@ -2037,6 +3084,19 @@ export const V2_OPERATIONS = { limit: { kind: 'integer' }, }, }, + updateSkill: { + method: 'PATCH', + path: '/api/v2/skills/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update Skill', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string' }, + description: { kind: 'string' }, + content: { kind: 'string' }, + }, + }, updateTableColumn: { method: 'PATCH', path: '/api/v2/tables/[tableId]/columns', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 8542593159b..af5189b9aab 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { CLI_CONTRACT } from '../contract/commands.js' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' import { resolvePath, SimApiError } from './client.js' @@ -89,3 +90,33 @@ describe('generated operation table', () => { } }) }) + +describe('destructive operations are gated', () => { + /** + * `DELETE /workflows/[id]/deploy` is an undeploy — reversible by redeploying, + * and the contract renames it accordingly. Everything else that deletes is + * gated behind `--yes`. + */ + const NOT_DESTRUCTIVE = new Set(['undeployWorkflow']) + + it('every DELETE carries a confirmation message', () => { + // Without this, a new v2 domain arrives through generation with working + // delete commands and no gate — which is exactly what happened when the + // MCP/skills/folders/credentials endpoints landed. + const ungated = (Object.keys(V2_OPERATIONS) as V2OperationName[]).filter( + (name) => + V2_OPERATIONS[name].method === 'DELETE' && + !NOT_DESTRUCTIVE.has(name) && + !CLI_CONTRACT[name]?.confirm + ) + expect(ungated).toEqual([]) + }) + + it('states what is destroyed, not just that something is', () => { + for (const [name, spec] of Object.entries(CLI_CONTRACT)) { + if (!spec?.confirm) continue + expect(spec.confirm, name).toMatch(/^This /) + expect(spec.confirm.length, name).toBeGreaterThan(20) + } + }) +}) diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index ea0359d1883..67d3c2c741d 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -27,7 +27,7 @@ */ import { spawnSync } from 'node:child_process' -import { readFileSync, writeFileSync } from 'node:fs' +import { readdirSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' import { z } from 'zod' @@ -36,15 +36,30 @@ const CONTRACTS_DIR = path.join(ROOT, 'apps/sim/lib/api/contracts/v2') const OUTPUT = path.join(ROOT, 'packages/sim-cli/src/generated/v2-api.ts') const DOCS_DIR = path.join(ROOT, 'apps/docs') -/** OpenAPI documents to read operation summaries from. */ -const SPEC_FILES = [ - 'openapi-core.json', - 'openapi-v2-workflows.json', - 'openapi-v2-logs.json', - 'openapi-v2-tables.json', - 'openapi-v2-knowledge.json', - 'openapi-v2-files-audit.json', -] as const +/** + * OpenAPI documents to read operation summaries from, discovered rather than + * listed — same reason as {@link contractModules}. + * + * A new spec file (`openapi-v2-resources.json` arrived with the MCP/skills/ + * folders/credentials endpoints) would otherwise go unread, and the only symptom + * would be `--help` quietly falling back to `METHOD /path` for a whole domain. + * + * `openapi.json` is the retired single-document spec, superseded by the split + * files; it is excluded by name because it still exists on disk and would + * contribute stale duplicates. + */ +function specFiles(): string[] { + return readdirSync(DOCS_DIR, { withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && + entry.name.startsWith('openapi') && + entry.name.endsWith('.json') && + entry.name !== 'openapi.json' + ) + .map((entry) => entry.name) + .sort() +} /** * `METHOD /api/v2/{id}/…` → the spec's one-line summary. @@ -58,7 +73,7 @@ const SPEC_FILES = [ function loadSummaries(): Map { const summaries = new Map() - for (const file of SPEC_FILES) { + for (const file of specFiles()) { let spec: Record try { spec = JSON.parse(readFileSync(path.join(DOCS_DIR, file), 'utf8')) @@ -81,16 +96,30 @@ function loadSummaries(): Map { return summaries } -/** Contract modules to read, in emit order. */ -const DOMAINS = [ - 'workflows', - 'logs', - 'tables', - 'files', - 'knowledge', - 'audit-logs', - 'billing', -] as const +/** + * Every contract module under `contracts/v2`, discovered rather than listed. + * + * A hardcoded list is the wrong shape for this: adding a v2 domain would leave + * its operations silently absent from the CLI, with no error and nothing in + * `--check` to notice, because the generated file would still match a generator + * that never looked. Discovery makes a new domain appear on the next + * regeneration, which is the property the whole pipeline is built on. + * + * `shared.ts` holds the response-envelope helpers, not contracts; it is skipped + * because it exports no route contract, not because it is named here. + */ +function contractModules(): string[] { + return readdirSync(CONTRACTS_DIR, { withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && + entry.name.endsWith('.ts') && + !entry.name.endsWith('.test.ts') && + entry.name !== 'index.ts' + ) + .map((entry) => entry.name.replace(/\.ts$/, '')) + .sort() +} interface RouteContract { method: string @@ -132,7 +161,7 @@ function pascal(name: string): string { async function collectOperations(): Promise { const operations: Operation[] = [] - for (const domain of DOMAINS) { + for (const domain of contractModules()) { const mod: Record = await import(path.join(CONTRACTS_DIR, `${domain}.ts`)) for (const [exportName, value] of Object.entries(mod)) { if (!exportName.endsWith('Contract') || !isRouteContract(value)) continue @@ -440,7 +469,7 @@ async function main() { writeFileSync(OUTPUT, generated) console.log( - `Wrote ${path.relative(ROOT, OUTPUT)} — ${operations.length} operations from ${DOMAINS.length} contract modules.` + `Wrote ${path.relative(ROOT, OUTPUT)} — ${operations.length} operations from ${contractModules().length} contract modules.` ) } From 344a01222154983c1de09f56718f65cb6be0d287 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 00:55:01 -0700 Subject: [PATCH 038/159] fix(cli): render single-key resource envelopes, and column the new domains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sim mcp-servers create` created the server, exited 0, and printed nothing. The v2 route answers `{ data: { mcpServer: {...} } }`, and the record renderer keeps only scalar fields — one key holding an object left it with none. Unwrap a lone object-valued key before rendering; a payload with siblings (`{ row, operation }` from upsert) is a real result and is left alone. The five domains that arrived with the last generation had no contract columns, so `mcp-servers list` inferred 20 including `hasOauthClientSecret`. Give each a column set. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/contract/commands.ts | 46 +++++++++++++++++ packages/sim-cli/src/runtime/build.test.ts | 60 +++++++++++++++++++++- packages/sim-cli/src/runtime/build.ts | 21 +++++++- 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index e9642ac3eda..a7d67728ff9 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -145,6 +145,52 @@ export const CLI_CONTRACT: CliContract = { { header: 'chunks', path: 'chunkCount' }, ], }, + // Without these the inferred fallback dumps every scalar field — 20 columns + // for an MCP server, including `hasOauthClientSecret`. + listMcpServers: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'transport' }, + { header: 'url' }, + { header: 'status', path: 'connectionStatus' }, + { header: 'tools', path: 'toolCount' }, + { header: 'enabled', format: 'bool' }, + ], + }, + listSkills: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'description' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listCustomTools: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'description' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listFolders: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'parent', path: 'parentId' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listCredentials: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'provider' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listAuditLogs: { columns: [ { header: 'at', path: 'createdAt', format: 'timestamp' }, diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 1d22e329331..8ec2cdb2880 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -13,12 +13,15 @@ import { buildGeneratedCommands } from './build.js' * catch that class of bug. */ -const { mockRequest } = vi.hoisted(() => ({ mockRequest: vi.fn() })) +const { mockRequest, output } = vi.hoisted(() => ({ + mockRequest: vi.fn(), + output: { format: 'json' }, +})) vi.mock('../context.js', () => ({ clientFrom: () => ({ client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, - profile: { workspaceId: 'ws_local', output: 'json', name: 'default', apiKey: 'k' }, + profile: { workspaceId: 'ws_local', output: output.format, name: 'default', apiKey: 'k' }, }), })) @@ -109,6 +112,59 @@ describe('commands parsed through commander', () => { }) }) +describe('single-resource rendering', () => { + async function lines(argv: string[], data: unknown, format = 'json'): Promise { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ data }) + const captured: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => { + captured.push(line) + }) + output.format = format + try { + await program().parseAsync(['node', 'sim', ...argv]) + } finally { + output.format = 'json' + } + return captured + } + + it('unwraps the single-key envelope a resource is returned in', async () => { + // `createMcpServer` answers `{ data: { mcpServer: {...} } }`. Rendering that + // as-is found one key holding an object, filtered it out as non-scalar, and + // printed nothing at all — the server was created and the CLI said so + // nowhere. Same silent-empty class as the body-cursor bug below. + const printed = await lines( + [ + 'mcp-servers', + 'create', + '--name', + 'Deepwiki', + '--transport', + 'streamable-http', + '--url', + 'https://mcp.deepwiki.com/mcp', + ], + { mcpServer: { id: 'mcp-1', name: 'Deepwiki', enabled: true } }, + 'text' + ) + + expect(printed.join('\n')).toMatch(/mcp-1/) + expect(printed.join('\n')).toMatch(/Deepwiki/) + }) + + it('leaves a payload with sibling keys intact', async () => { + // `upsertTableRow` returns `{ row, operation }` — two real fields, not an + // envelope. Unwrapping there would drop whether it inserted or updated. + const printed = await lines(['tables', 'upsert', 'tbl_1', '--data', '{}'], { + row: { id: 'r1' }, + operation: 'inserted', + }) + + expect(JSON.parse(printed[0])).toEqual({ row: { id: 'r1' }, operation: 'inserted' }) + }) +}) + describe('pagination slot', () => { it('pages a body-cursor operation and renders its rows', async () => { // `queryRows` is a POST whose cursor is in the body, not the query. Reading diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index bb2d4a38a61..499b3afb4b5 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -94,6 +94,25 @@ function inferColumns(rows: unknown[]): Column[] { })) } +/** + * Unwraps the single-key envelope several v2 responses put their resource in — + * `{ mcpServer }`, `{ knowledgeBase }`, `{ row }`, `{ document }`, `{ table }`. + * + * Without this the record renderer sees one key whose value is an object, + * filters it out as non-scalar, and prints nothing at all: `sim mcp-servers + * create` exited 0 having created the server and said nothing about it. + * + * Only a lone key is unwrapped. A payload with siblings (`{ row, operation }` + * from upsert) is a real multi-field result and is rendered as it stands. + */ +function unwrapResource(data: unknown): unknown { + if (!data || typeof data !== 'object' || Array.isArray(data)) return data + const entries = Object.entries(data) + if (entries.length !== 1) return data + const [, value] = entries[0] + return value && typeof value === 'object' && !Array.isArray(value) ? value : data +} + /** The operation's one-line help, taken from the OpenAPI summary at generation time. */ function summaryFor(operation: V2OperationName): string | undefined { return (V2_OPERATIONS[operation] as { summary?: string }).summary @@ -263,7 +282,7 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri query: request.query, body: request.body, }) - const data = result?.data ?? result + const data = unwrapResource(result?.data ?? result) if (Array.isArray(data)) { // Reached when a non-paginated operation answers with a collection. From edd9a06707c30314456bf57442a0caa4bfb2204b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 01:02:50 -0700 Subject: [PATCH 039/159] refactor(knowledge): make lib/knowledge/orchestration the single implementation (#6154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(knowledge): make lib/knowledge/orchestration the single implementation Knowledge base create was implemented four times — the internal route, v1, v2, and the copilot tool — and the orchestration around the shared write had drifted. Extract it the same way lib/table/orchestration was: services write, orchestration decides which writes run, guards them, audits them, and returns a transport-neutral failure. Behavior converged, not preserved: - One chunking default (DEFAULT_CHUNKING_CONFIG). The agent defaulted minSize to 1 against the API's 100, so identical input produced differently-chunked knowledge bases depending on who created it. The agent path now chunks at 100. - Every successful mutation is audited inside the orchestration function. The copilot tool called recordAudit zero times, so agent-created knowledge bases, document uploads, updates and deletes left no audit trail at all. - Failures classify by class, not by message text. The knowledge service errors are OrchestrationError subclasses and storage-quota rejections throw a shared StorageLimitExceededError, replacing four separate message greps for "already exists" / "does not have permission" / "storage limit". delete_connector reported the opposite of what happened. It reached the route through an internal HTTP self-call that sent no query string, so the route's keep-documents default always applied while the agent told the user the documents had been removed. The self-call is gone — all four connector operations run in-process — and the orchestration returns the real counts. Also: - OrchestrationErrorCode gains 'payload_too_large' (413 / PAYLOAD_TOO_LARGE). Without it, dropping the storage-limit message match would have regressed the documented 413 on knowledge base create and document upload to a 500. - messageForOrchestrationError renders a route's own wording for an unclassified fault, so a driver's message no longer reaches the client on a 500. - v1 and v2 knowledge base update now forward actorUserId, which the service requires for a workspace move; both omitted it. - The connector DELETE route reads deleteDocuments through parseRequest. Its contract declared z.boolean(), which would have rejected the string a query param actually is. - Drop the 409 from POST /api/v2/knowledge/{id}/documents in the OpenAPI spec. Nothing on the upload path throws a conflict; it was only ever reachable by the message match this change removes. Behavior change worth noting: a v1/v2 PUT carrying only the workspaceId scope field and no actual updates now returns 400 rather than 200 with the unchanged knowledge base. Deliberately deferred: document update remains internal-only. Extracting performUpdateKnowledgeDocument makes exposing it on v1/v2 a contract and a route away, but that is a new public surface rather than part of this consolidation. * fix(knowledge): make connector create atomic and stop flattening failures Review round 1 on #6154. - Resolve the billing payer before the connector is committed, not after. A malformed attribution header rejected post-commit left a live connector behind a 500, and a retry created a duplicate plus duplicate sync work. Manual sync resolves before writing its audit for the same reason. - Let the source-config validator carry its own failure class. Collapsing every rejection to `validation` flattened the connector PATCH route's 401 (stale stored credential) and 409 (missing workspace context) into a 400. - Add `unauthorized` to OrchestrationErrorCode. It is the class that 401 was already expressing on this route, and the v2 vocabulary already had UNAUTHORIZED; only the shared union was missing it. - Report a knowledge base that exists but failed to archive as failed, with the reason, rather than as not found. The copilot delete loop folded every non-not-found failure into `notFound`, telling the user it was never there. - Route copilot failures through the same message helper the HTTP surfaces use, so an unclassified fault's raw text (a driver's failed SQL) no longer reaches the agent verbatim while the UI and public APIs get the generic wording. --- apps/docs/openapi-v2-knowledge.json | 3 - .../connectors/[connectorId]/route.test.ts | 9 +- .../[id]/connectors/[connectorId]/route.ts | 511 ++++-------- .../[connectorId]/sync/route.test.ts | 10 +- .../connectors/[connectorId]/sync/route.ts | 145 +--- .../knowledge/[id]/connectors/route.test.ts | 5 +- .../api/knowledge/[id]/connectors/route.ts | 319 ++------ .../[id]/documents/[documentId]/route.ts | 198 ++--- .../knowledge/[id]/documents/route.test.ts | 4 +- .../app/api/knowledge/[id]/documents/route.ts | 199 ++--- .../app/api/knowledge/[id]/restore/route.ts | 15 +- apps/sim/app/api/knowledge/[id]/route.ts | 231 +++--- apps/sim/app/api/knowledge/route.ts | 166 ++-- .../[id]/documents/[documentId]/route.ts | 34 +- .../v1/knowledge/[id]/documents/route.test.ts | 3 + .../api/v1/knowledge/[id]/documents/route.ts | 61 +- apps/sim/app/api/v1/knowledge/[id]/route.ts | 68 +- apps/sim/app/api/v1/knowledge/route.ts | 45 +- .../[id]/documents/[documentId]/route.ts | 35 +- .../api/v2/knowledge/[id]/documents/route.ts | 67 +- apps/sim/app/api/v2/knowledge/[id]/route.ts | 75 +- apps/sim/app/api/v2/knowledge/route.ts | 61 +- apps/sim/app/api/v2/lib/response.ts | 2 + apps/sim/lib/api/contracts/knowledge/base.ts | 11 +- .../lib/api/contracts/knowledge/connectors.ts | 3 +- .../lib/api/contracts/v1/knowledge/index.ts | 17 +- apps/sim/lib/billing/storage/index.ts | 1 + apps/sim/lib/billing/storage/limits.ts | 15 + apps/sim/lib/billing/storage/tracking.ts | 5 +- .../server/knowledge/knowledge-base.test.ts | 193 +++-- .../tools/server/knowledge/knowledge-base.ts | 446 ++++++----- apps/sim/lib/core/orchestration/types.ts | 26 + apps/sim/lib/knowledge/constants.ts | 14 + apps/sim/lib/knowledge/documents/service.ts | 20 +- apps/sim/lib/knowledge/folders.test.ts | 2 +- .../orchestration/connectors.test.ts | 298 +++++++ .../lib/knowledge/orchestration/connectors.ts | 735 ++++++++++++++++++ .../knowledge/orchestration/documents.test.ts | 329 ++++++++ .../lib/knowledge/orchestration/documents.ts | 486 ++++++++++++ apps/sim/lib/knowledge/orchestration/index.ts | 122 +-- .../orchestration/knowledge-bases.test.ts | 256 ++++++ .../orchestration/knowledge-bases.ts | 236 ++++++ .../knowledge/orchestration/restore.test.ts | 91 +++ .../lib/knowledge/orchestration/restore.ts | 88 +++ .../sim/lib/knowledge/orchestration/shared.ts | 84 ++ apps/sim/lib/knowledge/service.test.ts | 4 +- apps/sim/lib/knowledge/service.ts | 51 +- .../orchestration/restore-resource.ts | 5 +- 48 files changed, 3873 insertions(+), 1931 deletions(-) create mode 100644 apps/sim/lib/knowledge/orchestration/connectors.test.ts create mode 100644 apps/sim/lib/knowledge/orchestration/connectors.ts create mode 100644 apps/sim/lib/knowledge/orchestration/documents.test.ts create mode 100644 apps/sim/lib/knowledge/orchestration/documents.ts create mode 100644 apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts create mode 100644 apps/sim/lib/knowledge/orchestration/knowledge-bases.ts create mode 100644 apps/sim/lib/knowledge/orchestration/restore.test.ts create mode 100644 apps/sim/lib/knowledge/orchestration/restore.ts create mode 100644 apps/sim/lib/knowledge/orchestration/shared.ts diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index eeac6c843b5..806bbbccd83 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -709,9 +709,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "413": { "description": "The uploaded file exceeds the 100 MB limit, or the workspace storage limit has been reached.", "content": { diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts index bf347078d73..ce255768db1 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts @@ -151,7 +151,10 @@ describe('Knowledge Connector By ID API Route', () => { success: true, userId: 'user-1', }) - mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) + mockCheckWriteAccess.mockResolvedValue({ + hasAccess: true, + knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, + }) dbChainMockFns.limit.mockResolvedValueOnce([]) const req = createMockRequest('PATCH', { sourceConfig: { project: 'NEW' } }) @@ -174,7 +177,8 @@ describe('Knowledge Connector By ID API Route', () => { mockHasWorkspaceLiveSyncAccess.mockResolvedValue(true) const updatedConnector = { id: 'conn-456', status: 'paused', syncIntervalMinutes: 5 } - dbChainMockFns.limit.mockResolvedValueOnce([updatedConnector]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }]) + dbChainMockFns.returning.mockResolvedValueOnce([updatedConnector]) const req = createMockRequest('PATCH', { status: 'paused', syncIntervalMinutes: 5 }) const response = await PATCH(req, { params: mockParams }) @@ -196,6 +200,7 @@ describe('Knowledge Connector By ID API Route', () => { knowledgeBase: { workspaceId: 'ws-free', name: 'Free KB' }, }) mockHasWorkspaceLiveSyncAccess.mockResolvedValue(false) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }]) const req = createMockRequest('PATCH', { syncIntervalMinutes: 5 }) const response = await PATCH(req, { params: mockParams }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts index 3ff1d479cd0..d63513af694 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts @@ -1,20 +1,29 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { document, embedding, knowledgeConnector, knowledgeConnectorSyncLog } from '@sim/db/schema' +import { knowledgeConnectorSyncLog } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm' +import { desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { updateKnowledgeConnectorContract } from '@/lib/api/contracts/knowledge' +import { + deleteKnowledgeConnectorContract, + updateKnowledgeConnectorContract, +} from '@/lib/api/contracts/knowledge' import { parseRequest } from '@/lib/api/server' import { decryptApiKey } from '@/lib/api-key/crypto' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { hasWorkspaceLiveSyncAccess } from '@/lib/billing/core/subscription' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { resolveCredentialTokenIdentity } from '@/lib/credentials/access' -import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service' -import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service' -import { captureServerEvent } from '@/lib/posthog/server' +import { + getKnowledgeConnector, + type KnowledgeConnectorRow, + performDeleteKnowledgeConnector, + performUpdateKnowledgeConnector, + type SourceConfigRejection, +} from '@/lib/knowledge/orchestration' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' @@ -42,20 +51,8 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) } - const connectorRows = await db - .select() - .from(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - .limit(1) - - if (connectorRows.length === 0) { + const connector = await getKnowledgeConnector(knowledgeBaseId, connectorId) + if (!connector) { return NextResponse.json({ error: 'Connector not found' }, { status: 404 }) } @@ -66,7 +63,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou .orderBy(desc(knowledgeConnectorSyncLog.startedAt)) .limit(10) - const { encryptedApiKey: _, ...connectorData } = connectorRows[0] + const { encryptedApiKey: _, ...connectorData } = connector return NextResponse.json({ success: true, data: { @@ -81,357 +78,179 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou }) /** - * PATCH /api/knowledge/[id]/connectors/[connectorId] - Update a connector + * Validates a replacement `sourceConfig` against the live source, resolving the + * connector's own token first. Returns a rejection message, or `null` to accept. + * + * Stays with the route rather than moving into orchestration because resolving + * the token needs the requesting identity: workspace credentials are shared and + * token reads are scoped to `account.userId`, so the credential's own account + * owner is used — not the knowledge base owner, and not the acting user when a + * service account mints its own token. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteParams) => { - const requestId = generateRequestId() - const { id: knowledgeBaseId, connectorId } = await context.params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!writeCheck.hasAccess) { - const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 - return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) - } - - const parsed = await parseRequest(updateKnowledgeConnectorContract, request, context) - if (!parsed.success) return parsed.response - const body = parsed.data.body - - if ( - body.syncIntervalMinutes !== undefined && - body.syncIntervalMinutes > 0 && - body.syncIntervalMinutes < 60 - ) { - const workspaceId = writeCheck.knowledgeBase.workspaceId - if (!workspaceId) { - return NextResponse.json( - { error: 'Knowledge base is missing workspace billing context' }, - { status: 409 } - ) - } - const canUseLiveSync = await hasWorkspaceLiveSyncAccess(workspaceId) - if (!canUseLiveSync) { - return NextResponse.json( - { error: 'Live sync requires a Max or Enterprise plan' }, - { status: 403 } - ) +function makeSourceConfigValidator( + actingUserId: string, + workspaceId: string | null, + connectorId: string +) { + return async ( + connector: KnowledgeConnectorRow, + sourceConfig: Record + ): Promise => { + const connectorConfig = CONNECTOR_REGISTRY[connector.connectorType] + if (!connectorConfig) { + return { + message: `Unknown connector type: ${connector.connectorType}`, + errorCode: 'validation', } } - if (body.sourceConfig !== undefined) { - const existingRows = await db - .select() - .from(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - .limit(1) - - if (existingRows.length === 0) { - return NextResponse.json({ error: 'Connector not found' }, { status: 404 }) - } - - const existing = existingRows[0] - const connectorConfig = CONNECTOR_REGISTRY[existing.connectorType] - - if (!connectorConfig) { - return NextResponse.json( - { error: `Unknown connector type: ${existing.connectorType}` }, - { status: 400 } - ) - } - - let accessToken: string | null = null - if (connectorConfig.auth.mode === 'apiKey') { - if (!existing.encryptedApiKey) { - return NextResponse.json( - { error: 'API key not found. Please reconfigure the connector.' }, - { status: 400 } - ) - } - accessToken = (await decryptApiKey(existing.encryptedApiKey)).decrypted - } else { - if (!existing.credentialId) { - return NextResponse.json( - { error: 'OAuth credential not found. Please reconfigure the connector.' }, - { status: 400 } - ) + let accessToken: string | null = null + if (connectorConfig.auth.mode === 'apiKey') { + if (!connector.encryptedApiKey) { + return { + message: 'API key not found. Please reconfigure the connector.', + errorCode: 'validation', } - const connectorWorkspaceId = writeCheck.knowledgeBase.workspaceId - if (!connectorWorkspaceId) { - return NextResponse.json( - { error: 'Knowledge base is missing workspace context' }, - { status: 409 } - ) - } - /** - * Resolve the credential's own account owner, not the knowledge base owner: - * workspace credentials are shared, and token reads are scoped to - * `account.userId`. - */ - const identity = await resolveCredentialTokenIdentity( - existing.credentialId, - connectorWorkspaceId - ) - if (!identity) { - return NextResponse.json( - { error: 'Credential is no longer usable in this workspace. Please reconnect it.' }, - { status: 400 } - ) + } + accessToken = (await decryptApiKey(connector.encryptedApiKey)).decrypted + } else { + if (!connector.credentialId) { + return { + message: 'OAuth credential not found. Please reconfigure the connector.', + errorCode: 'validation', } - accessToken = await refreshAccessTokenIfNeeded( - existing.credentialId, - // Service accounts mint their own token and ignore the acting user. - identity.kind === 'oauth' ? identity.userId : auth.userId, - `patch-${connectorId}` - ) } - - if (!accessToken) { - return NextResponse.json( - { error: 'Failed to refresh access token. Please reconnect your account.' }, - { status: 401 } - ) + if (!workspaceId) { + return { + message: 'Knowledge base is missing workspace context', + errorCode: 'conflict', + } } - - const validation = await connectorConfig.validateConfig(accessToken, body.sourceConfig) - if (!validation.valid) { - return NextResponse.json( - { error: validation.error || 'Invalid source configuration' }, - { status: 400 } - ) + const identity = await resolveCredentialTokenIdentity(connector.credentialId, workspaceId) + if (!identity) { + return { + message: 'Credential is no longer usable in this workspace. Please reconnect it.', + errorCode: 'validation', + } } + accessToken = await refreshAccessTokenIfNeeded( + connector.credentialId, + // Service accounts mint their own token and ignore the acting user. + identity.kind === 'oauth' ? identity.userId : actingUserId, + `patch-${connectorId}` + ) } - const updates: Record = { updatedAt: new Date() } - if (body.sourceConfig !== undefined) { - updates.sourceConfig = body.sourceConfig - } - if (body.syncIntervalMinutes !== undefined) { - updates.syncIntervalMinutes = body.syncIntervalMinutes - if (body.syncIntervalMinutes > 0) { - updates.nextSyncAt = new Date(Date.now() + body.syncIntervalMinutes * 60 * 1000) - } else { - updates.nextSyncAt = null - } - } - if (body.status !== undefined) { - updates.status = body.status - if (body.status === 'active') { - updates.consecutiveFailures = 0 - updates.lastSyncError = null - if (updates.nextSyncAt === undefined) { - updates.nextSyncAt = new Date() - } + if (!accessToken) { + // A stale stored credential, not an unauthenticated caller — but the route + // has always answered 401 here, so keep that rather than silently + // reclassifying it as part of this refactor. + return { + message: 'Failed to refresh access token. Please reconnect your account.', + errorCode: 'unauthorized', } } - await db - .update(knowledgeConnector) - .set(updates) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) + const validation = await connectorConfig.validateConfig(accessToken, sourceConfig) + return validation.valid + ? null + : { message: validation.error || 'Invalid source configuration', errorCode: 'validation' } + } +} - const updated = await db - .select() - .from(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - .limit(1) +/** + * PATCH /api/knowledge/[id]/connectors/[connectorId] - Update a connector + */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteParams) => { + const requestId = generateRequestId() + const { id: knowledgeBaseId, connectorId } = await context.params - const { encryptedApiKey: __, ...updatedData } = updated[0] + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - recordAudit({ - workspaceId: writeCheck.knowledgeBase.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.CONNECTOR_UPDATED, - resourceType: AuditResourceType.CONNECTOR, - resourceId: connectorId, - resourceName: updatedData.connectorType, - description: `Updated connector for knowledge base "${writeCheck.knowledgeBase.name}"`, - metadata: { - knowledgeBaseId, - knowledgeBaseName: writeCheck.knowledgeBase.name, - connectorType: updatedData.connectorType, - updatedFields: Object.keys(parsed.data), - ...(body.syncIntervalMinutes !== undefined && { - syncIntervalMinutes: body.syncIntervalMinutes, - }), - ...(body.status !== undefined && { newStatus: body.status }), - }, - request, - }) + const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) + if (!writeCheck.hasAccess) { + const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 + return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) + } - return NextResponse.json({ success: true, data: updatedData }) - } catch (error) { - logger.error(`[${requestId}] Error updating connector`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + const parsed = await parseRequest(updateKnowledgeConnectorContract, request, context) + if (!parsed.success) return parsed.response + + const outcome = await performUpdateKnowledgeConnector({ + knowledgeBase: { + id: knowledgeBaseId, + name: writeCheck.knowledgeBase.name, + workspaceId: writeCheck.knowledgeBase.workspaceId ?? null, + }, + connectorId, + updates: parsed.data.body, + validateSourceConfig: makeSourceConfigValidator( + auth.userId, + writeCheck.knowledgeBase.workspaceId ?? null, + connectorId + ), + userId: auth.userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + source: 'ui', + requestId, + request, + }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Internal server error') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) } + + return NextResponse.json({ success: true, data: outcome.connector }) }) /** * DELETE /api/knowledge/[id]/connectors/[connectorId] - Hard-delete a connector */ -export const DELETE = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteParams) => { const requestId = generateRequestId() - const { id: knowledgeBaseId, connectorId } = await params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!writeCheck.hasAccess) { - const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 - return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) - } - - const existingConnector = await db - .select({ id: knowledgeConnector.id, connectorType: knowledgeConnector.connectorType }) - .from(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - .limit(1) - - if (existingConnector.length === 0) { - return NextResponse.json({ error: 'Connector not found' }, { status: 404 }) - } - - const { searchParams } = new URL(request.url) - const deleteDocuments = searchParams.get('deleteDocuments') === 'true' - - const { deletedDocs, docCount } = await db.transaction(async (tx) => { - await tx.execute(sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE`) - - // Includes pending-removal (tombstoned) docs — the connector is being - // deleted, so there's no future sync left to confirm or resurrect them. - const docs = await tx - .select({ id: document.id, fileUrl: document.fileUrl }) - .from(document) - .where(and(eq(document.connectorId, connectorId), isNull(document.archivedAt))) - - const documentIds = docs.map((doc) => doc.id) - if (deleteDocuments) { - if (documentIds.length > 0) { - await tx.delete(embedding).where(inArray(embedding.documentId, documentIds)) - await tx.delete(document).where(inArray(document.id, documentIds)) - } - } else if (documentIds.length > 0) { - // Kept documents become normal standalone KB entries once their connector - // is gone — resurrect any pending-removal ones rather than leaving them - // invisible tombstones with no future sync left to ever confirm or - // resurrect them. - await tx.update(document).set({ deletedAt: null }).where(inArray(document.id, documentIds)) - } - - const deletedConnectors = await tx - .delete(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - .returning({ id: knowledgeConnector.id }) - - if (deletedConnectors.length === 0) { - throw new Error('Connector not found') - } - - return { deletedDocs: deleteDocuments ? docs : [], docCount: docs.length } - }) - - const kbWorkspaceId = writeCheck.knowledgeBase?.workspaceId ?? null + const { id: knowledgeBaseId, connectorId } = await context.params - if (deleteDocuments) { - await Promise.all([ - deletedDocs.length > 0 - ? deleteDocumentStorageFiles( - deletedDocs.map((doc) => ({ ...doc, workspaceId: kbWorkspaceId })), - requestId - ) - : Promise.resolve(), - cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => { - logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error) - }), - ]) - } + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - logger.info( - `[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}` - ) + const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) + if (!writeCheck.hasAccess) { + const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 + return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) + } - captureServerEvent( - auth.userId, - 'knowledge_base_connector_removed', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: kbWorkspaceId ?? '', - connector_type: existingConnector[0].connectorType, - documents_deleted: deleteDocuments ? docCount : 0, - }, - kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined + const parsed = await parseRequest(deleteKnowledgeConnectorContract, request, context) + if (!parsed.success) return parsed.response + + const outcome = await performDeleteKnowledgeConnector({ + knowledgeBase: { + id: knowledgeBaseId, + name: writeCheck.knowledgeBase.name, + workspaceId: writeCheck.knowledgeBase.workspaceId ?? null, + }, + connectorId, + deleteDocuments: parsed.data.query.deleteDocuments, + userId: auth.userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + source: 'ui', + requestId, + request, + }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Internal server error') }, + { status: statusForOrchestrationError(outcome.errorCode) } ) - - recordAudit({ - workspaceId: writeCheck.knowledgeBase.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.CONNECTOR_DELETED, - resourceType: AuditResourceType.CONNECTOR, - resourceId: connectorId, - resourceName: existingConnector[0].connectorType, - description: `Deleted connector from knowledge base "${writeCheck.knowledgeBase.name}"`, - metadata: { - knowledgeBaseId, - knowledgeBaseName: writeCheck.knowledgeBase.name, - connectorType: existingConnector[0].connectorType, - deleteDocuments, - documentsDeleted: deleteDocuments ? docCount : 0, - documentsKept: deleteDocuments ? 0 : docCount, - }, - request, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error(`[${requestId}] Error deleting connector`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } + + return NextResponse.json({ success: true }) }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts index c79c85df58a..b8869013644 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts @@ -62,7 +62,10 @@ describe('Connector Manual Sync API Route', () => { success: true, userId: 'user-1', }) - mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) + mockCheckWriteAccess.mockResolvedValue({ + hasAccess: true, + knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, + }) dbChainMockFns.limit.mockResolvedValueOnce([]) const req = createMockRequest('POST') @@ -76,7 +79,10 @@ describe('Connector Manual Sync API Route', () => { success: true, userId: 'user-1', }) - mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) + mockCheckWriteAccess.mockResolvedValue({ + hasAccess: true, + knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, + }) dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'syncing' }]) const req = createMockRequest('POST') diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts index 714f554040b..21e6bfdb50e 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts @@ -1,8 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { knowledgeConnector } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { triggerKnowledgeConnectorSyncContract } from '@/lib/api/contracts/knowledge' import { parseRequest } from '@/lib/api/server' @@ -11,14 +6,15 @@ import { requireBillingAttributionHeader, resolveBillingAttribution, } from '@/lib/billing/core/billing-attribution' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { dispatchSync } from '@/lib/knowledge/connectors/queue' -import { captureServerEvent } from '@/lib/posthog/server' +import { performSyncKnowledgeConnector } from '@/lib/knowledge/orchestration' import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' -const logger = createLogger('ConnectorManualSyncAPI') - type RouteParams = { params: Promise<{ id: string; connectorId: string }> } /** @@ -31,105 +27,50 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route const { id: knowledgeBaseId, connectorId } = parsed.data.params const { rehydrate } = parsed.data.query - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!writeCheck.hasAccess) { - const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 - return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) - } - - const connectorRows = await db - .select() - .from(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - .limit(1) + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - if (connectorRows.length === 0) { - return NextResponse.json({ error: 'Connector not found' }, { status: 404 }) - } + const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) + if (!writeCheck.hasAccess) { + const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 + return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) + } - if (connectorRows[0].status === 'syncing') { - return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 }) - } + const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? null - const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId - if (!kbWorkspaceId) { - return NextResponse.json( - { error: 'Knowledge base is missing workspace billing context' }, - { status: 409 } - ) - } - const billingAttribution = + const outcome = await performSyncKnowledgeConnector({ + knowledgeBase: { + id: knowledgeBaseId, + name: writeCheck.knowledgeBase.name, + workspaceId: kbWorkspaceId, + }, + connectorId, + resolveBillingAttribution: async () => auth.authType === AuthType.INTERNAL_JWT ? requireBillingAttributionHeader(request.headers, { - actorUserId: auth.userId, - workspaceId: kbWorkspaceId, - }) - : await resolveBillingAttribution({ - actorUserId: auth.userId, - workspaceId: kbWorkspaceId, + actorUserId: auth.userId as string, + workspaceId: kbWorkspaceId as string, }) - - logger.info( - `[${requestId}] Manual sync${rehydrate ? ' (full rehydrate)' : ''} triggered for connector ${connectorId}` - ) - - captureServerEvent( - auth.userId, - 'knowledge_base_connector_synced', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: kbWorkspaceId, - connector_type: connectorRows[0].connectorType, - }, - kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined + : resolveBillingAttribution({ + actorUserId: auth.userId as string, + workspaceId: kbWorkspaceId as string, + }), + rehydrate, + userId: auth.userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + source: 'ui', + requestId, + request, + }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Internal server error') }, + { status: statusForOrchestrationError(outcome.errorCode) } ) - - recordAudit({ - workspaceId: writeCheck.knowledgeBase.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.CONNECTOR_SYNCED, - resourceType: AuditResourceType.CONNECTOR, - resourceId: connectorId, - resourceName: connectorRows[0].connectorType, - description: `Triggered manual sync for connector on knowledge base "${writeCheck.knowledgeBase.name}"`, - metadata: { - knowledgeBaseId, - knowledgeBaseName: writeCheck.knowledgeBase.name, - connectorType: connectorRows[0].connectorType, - connectorStatus: connectorRows[0].status, - syncType: rehydrate ? 'manual-rehydrate' : 'manual', - }, - request, - }) - - dispatchSync(connectorId, { billingAttribution, requestId, rehydrate }).catch((error) => { - logger.error( - `[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`, - error - ) - }) - - return NextResponse.json({ - success: true, - message: 'Sync triggered', - }) - } catch (error) { - logger.error(`[${requestId}] Error triggering manual sync`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } + + return NextResponse.json({ success: true, message: 'Sync triggered' }) }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts index 6087572fb40..361a8e2ad68 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts @@ -118,7 +118,8 @@ describe('Knowledge Connectors API Route', () => { }) mockHasWorkspaceLiveSyncAccess.mockResolvedValue(true) mockResolveBillingAttribution.mockResolvedValue(BILLING_ATTRIBUTION) - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'knowledge-base-1' }]).mockResolvedValueOnce([ + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'knowledge-base-1' }]) + dbChainMockFns.returning.mockResolvedValueOnce([ { id: 'connector-1', knowledgeBaseId: 'knowledge-base-1', @@ -173,6 +174,8 @@ describe('Knowledge Connectors API Route', () => { expect(response.status).toBe(403) expect(mockHasWorkspaceLiveSyncAccess).toHaveBeenCalledWith('workspace-free') + // The payer is resolved lazily, so a request the plan gate rejects never + // pays for the lookup. expect(mockResolveBillingAttribution).not.toHaveBeenCalled() expect(mockDispatchSync).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.ts index b7df3198990..df2f246ae1d 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.ts @@ -1,28 +1,25 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { knowledgeBase, knowledgeBaseTagDefinitions, knowledgeConnector } from '@sim/db/schema' +import { knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, desc, eq, isNull, sql } from 'drizzle-orm' +import { and, desc, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createKnowledgeConnectorContract } from '@/lib/api/contracts/knowledge' import { parseRequest } from '@/lib/api/server' -import { encryptApiKey } from '@/lib/api-key/crypto' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { requireBillingAttributionHeader, resolveBillingAttribution, } from '@/lib/billing/core/billing-attribution' -import { hasWorkspaceLiveSyncAccess } from '@/lib/billing/core/subscription' +import { + messageForOrchestrationError, + OrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { dispatchSync } from '@/lib/knowledge/connectors/queue' -import { allocateTagSlots } from '@/lib/knowledge/constants' -import { createTagDefinition } from '@/lib/knowledge/tags/service' -import { captureServerEvent } from '@/lib/posthog/server' +import { performCreateKnowledgeConnector } from '@/lib/knowledge/orchestration' import { getCredential } from '@/app/api/auth/oauth/utils' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' -import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' const logger = createLogger('KnowledgeConnectorsAPI') @@ -80,263 +77,71 @@ export const POST = withRouteHandler( const requestId = generateRequestId() const { id: knowledgeBaseId } = await context.params - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!writeCheck.hasAccess) { - const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 - return NextResponse.json( - { error: status === 404 ? 'Not found' : 'Unauthorized' }, - { status } - ) - } + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const parsed = await parseRequest(createKnowledgeConnectorContract, request, context) - if (!parsed.success) return parsed.response + const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) + if (!writeCheck.hasAccess) { + const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 + return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) + } - const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } = - parsed.data.body + const parsed = await parseRequest(createKnowledgeConnectorContract, request, context) + if (!parsed.success) return parsed.response - const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId - if (!kbWorkspaceId) { - return NextResponse.json( - { error: 'Knowledge base is missing workspace billing context' }, - { status: 409 } - ) - } + const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } = + parsed.data.body - if (syncIntervalMinutes > 0 && syncIntervalMinutes < 60) { - const canUseLiveSync = await hasWorkspaceLiveSyncAccess(kbWorkspaceId) - if (!canUseLiveSync) { - return NextResponse.json( - { error: 'Live sync requires a Max or Enterprise plan' }, - { status: 403 } - ) - } - } + const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId + if (!kbWorkspaceId) { + return NextResponse.json( + { error: 'Knowledge base is missing workspace billing context' }, + { status: 409 } + ) + } - const billingAttribution = + const outcome = await performCreateKnowledgeConnector({ + knowledgeBase: { + id: knowledgeBaseId, + name: writeCheck.knowledgeBase.name, + workspaceId: kbWorkspaceId, + }, + connectorType, + credentialId, + apiKey, + sourceConfig, + syncIntervalMinutes, + resolveBillingAttribution: async () => auth.authType === AuthType.INTERNAL_JWT ? requireBillingAttributionHeader(request.headers, { - actorUserId: auth.userId, + actorUserId: auth.userId as string, workspaceId: kbWorkspaceId, }) - : await resolveBillingAttribution({ - actorUserId: auth.userId, + : resolveBillingAttribution({ + actorUserId: auth.userId as string, workspaceId: kbWorkspaceId, - }) - - const connectorConfig = CONNECTOR_REGISTRY[connectorType] - if (!connectorConfig) { - return NextResponse.json( - { error: `Unknown connector type: ${connectorType}` }, - { status: 400 } - ) - } - - let resolvedCredentialId: string | null = null - let resolvedEncryptedApiKey: string | null = null - let accessToken: string - - if (connectorConfig.auth.mode === 'apiKey') { - if (!apiKey) { - return NextResponse.json({ error: 'API key is required' }, { status: 400 }) - } - accessToken = apiKey - } else { - if (!credentialId) { - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } - - const credential = await getCredential(requestId, credentialId, auth.userId) - if (!credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 400 }) - } - - if (!credential.accessToken) { - return NextResponse.json( - { error: 'Credential has no access token. Please reconnect your account.' }, - { status: 400 } - ) - } - - accessToken = credential.accessToken - resolvedCredentialId = credentialId - } - - const configValidation = await connectorConfig.validateConfig(accessToken, sourceConfig) - if (!configValidation.valid) { - return NextResponse.json( - { error: configValidation.error || 'Invalid source configuration' }, - { status: 400 } - ) - } - - let finalSourceConfig: Record = { ...sourceConfig } - - if (connectorConfig.auth.mode === 'apiKey' && apiKey) { - const { encrypted } = await encryptApiKey(apiKey) - resolvedEncryptedApiKey = encrypted - } - - const tagSlotMapping: Record = {} - let newTagSlots: Record = {} - - if (connectorConfig.tagDefinitions?.length) { - const disabledIds = new Set((sourceConfig.disabledTagIds as string[] | undefined) ?? []) - const enabledDefs = connectorConfig.tagDefinitions.filter((td) => !disabledIds.has(td.id)) - - const existingDefs = await db - .select({ - tagSlot: knowledgeBaseTagDefinitions.tagSlot, - displayName: knowledgeBaseTagDefinitions.displayName, - fieldType: knowledgeBaseTagDefinitions.fieldType, - }) - .from(knowledgeBaseTagDefinitions) - .where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId)) - - const usedSlots = new Set(existingDefs.map((d) => d.tagSlot)) - const existingByName = new Map( - existingDefs.map((d) => [d.displayName, { tagSlot: d.tagSlot, fieldType: d.fieldType }]) - ) - - const defsNeedingSlots: typeof enabledDefs = [] - for (const td of enabledDefs) { - const existing = existingByName.get(td.displayName) - if (existing && existing.fieldType === td.fieldType) { - tagSlotMapping[td.id] = existing.tagSlot - } else { - defsNeedingSlots.push(td) - } - } - - const { mapping, skipped: skippedTags } = allocateTagSlots(defsNeedingSlots, usedSlots) - Object.assign(tagSlotMapping, mapping) - newTagSlots = mapping - - for (const name of skippedTags) { - logger.warn(`[${requestId}] No available slots for "${name}"`) - } - - if (skippedTags.length > 0 && Object.keys(tagSlotMapping).length === 0) { - return NextResponse.json( - { error: `No available tag slots. Could not assign: ${skippedTags.join(', ')}` }, - { status: 422 } - ) - } - - finalSourceConfig = { ...finalSourceConfig, tagSlotMapping } - } - - const now = new Date() - const connectorId = generateId() - const nextSyncAt = - syncIntervalMinutes > 0 ? new Date(now.getTime() + syncIntervalMinutes * 60 * 1000) : null - - await db.transaction(async (tx) => { - await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) - - const activeKb = await tx - .select({ id: knowledgeBase.id }) - .from(knowledgeBase) - .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) - .limit(1) - - if (activeKb.length === 0) { - throw new Error('Knowledge base not found') - } - - for (const [semanticId, slot] of Object.entries(newTagSlots)) { - const td = connectorConfig.tagDefinitions!.find((d) => d.id === semanticId)! - await createTagDefinition( - { - knowledgeBaseId, - tagSlot: slot, - displayName: td.displayName, - fieldType: td.fieldType, - }, - requestId, - tx - ) - } - - await tx.insert(knowledgeConnector).values({ - id: connectorId, - knowledgeBaseId, - connectorType, - credentialId: resolvedCredentialId, - encryptedApiKey: resolvedEncryptedApiKey, - sourceConfig: finalSourceConfig, - syncIntervalMinutes, - status: 'active', - nextSyncAt, - createdAt: now, - updatedAt: now, - }) - }) - - logger.info(`[${requestId}] Created connector ${connectorId} for KB ${knowledgeBaseId}`) - - captureServerEvent( - auth.userId, - 'knowledge_base_connector_added', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: kbWorkspaceId, - connector_type: connectorType, - sync_interval_minutes: syncIntervalMinutes, - }, - { - groups: kbWorkspaceId ? { workspace: kbWorkspaceId } : undefined, - setOnce: { first_connector_added_at: new Date().toISOString() }, - } + }), + resolveAccessToken: async (id) => { + const credential = await getCredential(requestId, id, auth.userId as string) + if (!credential) throw new OrchestrationError('validation', 'Credential not found') + return credential.accessToken ?? null + }, + userId: auth.userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + source: 'ui', + requestId, + request, + }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Internal server error') }, + { status: statusForOrchestrationError(outcome.errorCode) } ) - - recordAudit({ - workspaceId: writeCheck.knowledgeBase.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.CONNECTOR_CREATED, - resourceType: AuditResourceType.CONNECTOR, - resourceId: connectorId, - resourceName: connectorType, - description: `Created ${connectorType} connector for knowledge base "${writeCheck.knowledgeBase.name}"`, - metadata: { - knowledgeBaseId, - knowledgeBaseName: writeCheck.knowledgeBase.name, - connectorType, - syncIntervalMinutes, - authMode: connectorConfig.auth.mode, - }, - request, - }) - - dispatchSync(connectorId, { billingAttribution, requestId }).catch((error) => { - logger.error( - `[${requestId}] Failed to dispatch initial sync for connector ${connectorId}`, - error - ) - }) - - const created = await db - .select() - .from(knowledgeConnector) - .where(eq(knowledgeConnector.id, connectorId)) - .limit(1) - - const { encryptedApiKey: _, ...createdData } = created[0] - return NextResponse.json({ success: true, data: createdData }, { status: 201 }) - } catch (error) { - if (error instanceof Error && error.message === 'Knowledge base not found') { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - logger.error(`[${requestId}] Error creating connector`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } + + return NextResponse.json({ success: true, data: outcome.connector }, { status: 201 }) } ) diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts index 7acdc821391..dab693e462f 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { updateKnowledgeDocumentContract } from '@/lib/api/contracts/knowledge' @@ -8,15 +7,19 @@ import { requireBillingAttributionHeader, resolveBillingAttribution, } from '@/lib/billing/core/billing-attribution' +import { + messageForOrchestrationError, + type OrchestrationErrorCode, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - deleteDocument, - markDocumentAsFailedTimeout, - retryDocumentProcessing, - updateDocument, -} from '@/lib/knowledge/documents/service' -import { captureServerEvent } from '@/lib/posthog/server' + performDeleteKnowledgeDocument, + performMarkKnowledgeDocumentTimedOut, + performRetryKnowledgeDocumentProcessing, + performUpdateKnowledgeDocument, +} from '@/lib/knowledge/orchestration' import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('DocumentByIdAPI') @@ -108,58 +111,30 @@ export const PUT = withRouteHandler( ) if (!parsed.success) return parsed.response - const validatedData = parsed.data.body - - const updateData: any = {} + const { markFailedDueToTimeout, retryProcessing, ...documentUpdates } = parsed.data.body + const doc = accessCheck.document + const workspaceId = accessCheck.knowledgeBase?.workspaceId ?? null - if (validatedData.markFailedDueToTimeout) { - const doc = accessCheck.document - - if (doc.processingStatus !== 'processing') { - return NextResponse.json( - { error: `Document is not in processing state (current: ${doc.processingStatus})` }, - { status: 400 } - ) - } - - if (!doc.processingStartedAt) { - return NextResponse.json( - { error: 'Document has no processing start time' }, - { status: 400 } - ) - } - - try { - await markDocumentAsFailedTimeout(documentId, doc.processingStartedAt, requestId) + const failed = (outcome: { error?: string; errorCode?: OrchestrationErrorCode }) => + NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to update document') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) - return NextResponse.json({ - success: true, - data: { - documentId, - status: 'failed', - message: 'Document marked as failed due to timeout', - }, - }) - } catch (error) { - if (error instanceof Error) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - throw error - } - } else if (validatedData.retryProcessing) { - const doc = accessCheck.document + if (markFailedDueToTimeout) { + const outcome = await performMarkKnowledgeDocumentTimedOut({ + document: doc, + requestId, + }) + if (!outcome.success) return failed(outcome) - if (doc.processingStatus !== 'failed') { - return NextResponse.json({ error: 'Document is not in failed state' }, { status: 400 }) - } + return NextResponse.json({ + success: true, + data: { documentId, status: outcome.status, message: outcome.message }, + }) + } - const docData = { - filename: doc.filename, - fileUrl: doc.fileUrl, - fileSize: doc.fileSize, - mimeType: doc.mimeType, - } - const workspaceId = accessCheck.knowledgeBase?.workspaceId + if (retryProcessing) { const billingAttribution = workspaceId ? auth.authType === AuthType.INTERNAL_JWT ? requireBillingAttributionHeader(req.headers, { @@ -172,56 +147,38 @@ export const PUT = withRouteHandler( }) : undefined - const result = await retryDocumentProcessing( + const outcome = await performRetryKnowledgeDocumentProcessing({ knowledgeBaseId, - documentId, - docData, + document: doc, + billingAttribution, requestId, - billingAttribution - ) - - return NextResponse.json({ - success: true, - data: { - documentId, - status: result.status, - message: result.message, - }, - }) - } else { - const updatedDocument = await updateDocument(documentId, validatedData, requestId) - - logger.info( - `[${requestId}] Document updated: ${documentId} in knowledge base ${knowledgeBaseId}` - ) - - recordAudit({ - workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.DOCUMENT_UPDATED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: documentId, - resourceName: validatedData.filename ?? accessCheck.document?.filename, - description: `Updated document "${validatedData.filename ?? accessCheck.document?.filename}" in knowledge base "${knowledgeBaseId}"`, - metadata: { - knowledgeBaseId, - knowledgeBaseName: accessCheck.knowledgeBase?.name, - fileName: validatedData.filename ?? accessCheck.document?.filename, - updatedFields: Object.keys(validatedData).filter( - (k) => validatedData[k as keyof typeof validatedData] !== undefined - ), - ...(validatedData.enabled !== undefined && { enabled: validatedData.enabled }), - }, - request: req, }) + if (!outcome.success) return failed(outcome) return NextResponse.json({ success: true, - data: updatedDocument, + data: { documentId, status: outcome.status, message: outcome.message }, }) } + + const outcome = await performUpdateKnowledgeDocument({ + knowledgeBase: { + id: knowledgeBaseId, + name: accessCheck.knowledgeBase?.name, + workspaceId, + }, + document: { id: documentId, filename: doc.filename }, + updates: documentUpdates, + userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + source: 'ui', + requestId, + request: req, + }) + if (!outcome.success) return failed(outcome) + + return NextResponse.json({ success: true, data: outcome.document }) } catch (error) { logger.error(`[${requestId}] Error updating document ${documentId}`, error) return NextResponse.json({ error: 'Failed to update document' }, { status: 500 }) @@ -257,43 +214,30 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const result = await deleteDocument(documentId, requestId) - - logger.info( - `[${requestId}] Document deleted: ${documentId} from knowledge base ${knowledgeBaseId}` - ) - - recordAudit({ - workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, - actorId: userId, + const outcome = await performDeleteKnowledgeDocument({ + knowledgeBase: { + id: knowledgeBaseId, + name: accessCheck.knowledgeBase?.name, + workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, + }, + document: accessCheck.document, + userId, actorName: auth.userName, actorEmail: auth.userEmail, - action: AuditAction.DOCUMENT_DELETED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: documentId, - resourceName: accessCheck.document?.filename, - description: `Deleted document "${accessCheck.document?.filename}" from knowledge base "${knowledgeBaseId}"`, - metadata: { - knowledgeBaseId, - knowledgeBaseName: accessCheck.knowledgeBase?.name, - fileName: accessCheck.document?.filename, - fileSize: accessCheck.document?.fileSize, - mimeType: accessCheck.document?.mimeType, - }, + source: 'ui', + requestId, request: req, }) - - const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId ?? '' - captureServerEvent( - userId, - 'knowledge_base_document_deleted', - { knowledge_base_id: knowledgeBaseId, workspace_id: kbWorkspaceId }, - kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined - ) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to delete document') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, - data: result, + data: { success: true, message: 'Document deleted successfully' }, }) } catch (error) { logger.error(`[${requestId}] Error deleting document`, error) diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts index 971c4a8f28b..84b523c7870 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts @@ -570,7 +570,9 @@ describe('Knowledge Base Documents API Route', () => { const data = await response.json() expect(response.status).toBe(500) - expect(data.error).toBe('Database error') + // An unclassified fault renders the route's own wording; the driver's + // message is logged, not returned. + expect(data.error).toBe('Failed to create document') }) }) }) diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index 9025cea9891..a46d08abae4 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' @@ -19,19 +18,22 @@ import { requireBillingAttributionHeader, resolveBillingAttribution, } from '@/lib/billing/core/billing-attribution' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { bulkDocumentOperation, bulkDocumentOperationByFilter, - createDocumentRecords, - createSingleDocument, getDocuments, getProcessingConfig, - KnowledgeBaseFileOwnershipError, - processDocumentsWithQueue, } from '@/lib/knowledge/documents/service' import type { TagFilterCondition } from '@/lib/knowledge/documents/tag-filter' -import { captureServerEvent } from '@/lib/posthog/server' +import { + performUploadKnowledgeDocument, + performUploadKnowledgeDocuments, +} from '@/lib/knowledge/orchestration' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('DocumentsAPI') @@ -210,168 +212,77 @@ export const POST = withRouteHandler( ) } - if (body.bulk === true) { - const createdDocuments = await createDocumentRecords( - body.documents, - knowledgeBaseId, - requestId, - userId - ) - - logger.info( - `[${requestId}] Starting controlled async processing of ${createdDocuments.length} documents` - ) - - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.knowledgeBaseDocumentsUploaded({ - knowledgeBaseId, - documentsCount: createdDocuments.length, - uploadType: 'bulk', - recipe: body.processingOptions?.recipe, - }) - } catch (_e) { - // Silently fail - } - - captureServerEvent( - userId, - 'knowledge_base_document_uploaded', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: kbWorkspaceId ?? '', - document_count: createdDocuments.length, - upload_type: 'bulk', - }, - { - ...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}), - setOnce: { first_document_uploaded_at: new Date().toISOString() }, - } - ) - - processDocumentsWithQueue( - createdDocuments, - knowledgeBaseId, - body.processingOptions ?? {}, - requestId, - billingAttribution - ).catch((error: unknown) => { - logger.error(`[${requestId}] Critical error in document processing pipeline:`, error) - }) + const knowledgeBase = { + id: knowledgeBaseId, + name: accessCheck.knowledgeBase?.name, + workspaceId: kbWorkspaceId ?? null, + } + const actor = { + userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + source: 'ui' as const, + requestId, + request: req, + } - recordAudit({ - workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.DOCUMENT_UPLOADED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: knowledgeBaseId, - resourceName: `${createdDocuments.length} document(s)`, - description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${knowledgeBaseId}"`, - metadata: { - knowledgeBaseName: accessCheck.knowledgeBase?.name, - fileCount: createdDocuments.length, - }, - request: req, + if (body.bulk === true) { + const outcome = await performUploadKnowledgeDocuments({ + ...actor, + knowledgeBase, + documents: body.documents, + processingOptions: body.processingOptions, + billingAttribution, }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to create document') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } + const { batchSize, maxConcurrentDocuments } = getProcessingConfig() return NextResponse.json({ success: true, data: { - total: createdDocuments.length, - documentsCreated: createdDocuments.map((doc) => ({ + total: outcome.documents.length, + documentsCreated: outcome.documents.map((doc) => ({ documentId: doc.documentId, filename: doc.filename, status: 'pending', })), processingMethod: 'background', processingConfig: { - maxConcurrentDocuments: getProcessingConfig().maxConcurrentDocuments, - batchSize: getProcessingConfig().batchSize, - totalBatches: Math.ceil(createdDocuments.length / getProcessingConfig().batchSize), + maxConcurrentDocuments, + batchSize, + totalBatches: Math.ceil(outcome.documents.length / batchSize), }, }, }) } const { bulk: _bulk, workflowId: _workflowId, ...singleDocumentData } = body - const newDocument = await createSingleDocument( - singleDocumentData, - knowledgeBaseId, - requestId, - userId - ) - - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.knowledgeBaseDocumentsUploaded({ - knowledgeBaseId, - documentsCount: 1, - uploadType: 'single', - mimeType: singleDocumentData.mimeType, - fileSize: singleDocumentData.fileSize, - }) - } catch (_e) { - // Silently fail - } - - captureServerEvent( - userId, - 'knowledge_base_document_uploaded', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: kbWorkspaceId ?? '', - document_count: 1, - upload_type: 'single', - }, - { - ...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}), - setOnce: { first_document_uploaded_at: new Date().toISOString() }, - } - ) - - recordAudit({ - workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.DOCUMENT_UPLOADED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: knowledgeBaseId, - resourceName: singleDocumentData.filename, - description: `Uploaded document "${singleDocumentData.filename}" to knowledge base "${knowledgeBaseId}"`, - metadata: { - knowledgeBaseName: accessCheck.knowledgeBase?.name, - fileName: singleDocumentData.filename, - fileType: singleDocumentData.mimeType, - fileSize: singleDocumentData.fileSize, - }, - request: req, - }) - - return NextResponse.json({ - success: true, - data: newDocument, + // Indexing is deliberately not started here: this path only records the + // document, and its caller drives processing separately. + const outcome = await performUploadKnowledgeDocument({ + ...actor, + knowledgeBase, + document: singleDocumentData, + billingAttribution, }) - } catch (error) { - logger.error(`[${requestId}] Error creating document`, error) - - if (error instanceof KnowledgeBaseFileOwnershipError) { + if (!outcome.success) { return NextResponse.json( - { error: 'File URL does not reference a file owned by this knowledge base' }, - { status: 403 } + { error: messageForOrchestrationError(outcome, 'Failed to create document') }, + { status: statusForOrchestrationError(outcome.errorCode) } ) } - const errorMessage = getErrorMessage(error, 'Failed to create document') - const isStorageLimitError = - errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit') - const isMissingKnowledgeBase = errorMessage === 'Knowledge base not found' - + return NextResponse.json({ success: true, data: outcome.document }) + } catch (error) { + logger.error(`[${requestId}] Error creating document`, error) return NextResponse.json( - { error: errorMessage }, - { status: isMissingKnowledgeBase ? 404 : isStorageLimitError ? 413 : 500 } + { error: getErrorMessage(error, 'Failed to create document') }, + { status: 500 } ) } } diff --git a/apps/sim/app/api/knowledge/[id]/restore/route.ts b/apps/sim/app/api/knowledge/[id]/restore/route.ts index 5dee08582a6..a5ed8b85808 100644 --- a/apps/sim/app/api/knowledge/[id]/restore/route.ts +++ b/apps/sim/app/api/knowledge/[id]/restore/route.ts @@ -4,6 +4,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { restoreKnowledgeBaseContract } from '@/lib/api/contracts/knowledge' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -45,12 +49,17 @@ export const POST = withRouteHandler( const result = await performRestoreKnowledgeBase({ knowledgeBaseId: id, userId: auth.userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + source: 'ui', requestId, + request, }) if (!result.success) { - const status = - result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 500 - return NextResponse.json({ error: result.error }, { status }) + return NextResponse.json( + { error: messageForOrchestrationError(result, 'Failed to restore knowledge base') }, + { status: statusForOrchestrationError(result.errorCode) } + ) } logger.info(`[${requestId}] Restored knowledge base ${id}`) diff --git a/apps/sim/app/api/knowledge/[id]/route.ts b/apps/sim/app/api/knowledge/[id]/route.ts index cd47c173ab4..3b91289af20 100644 --- a/apps/sim/app/api/knowledge/[id]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/route.ts @@ -1,20 +1,19 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { updateKnowledgeBaseContract } from '@/lib/api/contracts/knowledge' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { PlatformEvents } from '@/lib/core/telemetry' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - deleteKnowledgeBase, - getKnowledgeBaseById, - KnowledgeBaseConflictError, - KnowledgeBaseFolderError, - KnowledgeBasePermissionError, - updateKnowledgeBase, -} from '@/lib/knowledge/service' + performDeleteKnowledgeBase, + performUpdateKnowledgeBase, +} from '@/lib/knowledge/orchestration' +import { getKnowledgeBaseById } from '@/lib/knowledge/service' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('KnowledgeBaseByIdAPI') @@ -69,93 +68,56 @@ export const PUT = withRouteHandler( const requestId = generateRequestId() const { id } = await context.params - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized knowledge base update attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized knowledge base update attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId - const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId) + const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId) - if (!accessCheck.hasAccess) { - if ('notFound' in accessCheck && accessCheck.notFound) { - logger.warn(`[${requestId}] Knowledge base not found: ${id}`) - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted to update unauthorized knowledge base ${id}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + if (!accessCheck.hasAccess) { + if ('notFound' in accessCheck && accessCheck.notFound) { + logger.warn(`[${requestId}] Knowledge base not found: ${id}`) + return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) } - - const parsed = await parseRequest(updateKnowledgeBaseContract, req, context) - if (!parsed.success) return parsed.response - - const validatedData = parsed.data.body - - const updatedKnowledgeBase = await updateKnowledgeBase( - id, - { - name: validatedData.name, - description: validatedData.description, - workspaceId: validatedData.workspaceId, - folderId: validatedData.folderId, - chunkingConfig: validatedData.chunkingConfig, - }, - requestId, - { actorUserId: userId } + logger.warn( + `[${requestId}] User ${userId} attempted to update unauthorized knowledge base ${id}` ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - logger.info(`[${requestId}] Knowledge base updated: ${id} for user ${userId}`) - - recordAudit({ - workspaceId: accessCheck.knowledgeBase.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.KNOWLEDGE_BASE_UPDATED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: validatedData.name ?? updatedKnowledgeBase.name, - description: `Updated knowledge base "${validatedData.name ?? updatedKnowledgeBase.name}"`, - metadata: { - updatedFields: Object.keys(validatedData).filter( - (k) => validatedData[k as keyof typeof validatedData] !== undefined - ), - ...(validatedData.name && { newName: validatedData.name }), - ...(validatedData.description !== undefined && { - description: validatedData.description, - }), - ...(validatedData.chunkingConfig && { - chunkMaxSize: validatedData.chunkingConfig.maxSize, - chunkMinSize: validatedData.chunkingConfig.minSize, - chunkOverlap: validatedData.chunkingConfig.overlap, - }), - }, - request: req, - }) - - return NextResponse.json({ - success: true, - data: updatedKnowledgeBase, - }) - } catch (error) { - if (error instanceof KnowledgeBaseConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } - if (error instanceof KnowledgeBaseFolderError) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - if (error instanceof KnowledgeBasePermissionError) { - logger.warn(`[${requestId}] Forbidden knowledge base update on ${id}: ${error.message}`) - return NextResponse.json({ error: error.message }, { status: 403 }) - } - - logger.error(`[${requestId}] Error updating knowledge base`, error) - return NextResponse.json({ error: 'Failed to update knowledge base' }, { status: 500 }) + const parsed = await parseRequest(updateKnowledgeBaseContract, req, context) + if (!parsed.success) return parsed.response + + const body = parsed.data.body + + const outcome = await performUpdateKnowledgeBase({ + knowledgeBaseId: id, + workspaceId: accessCheck.knowledgeBase.workspaceId ?? null, + userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + source: 'ui', + updates: { + name: body.name, + description: body.description, + workspaceId: body.workspaceId, + folderId: body.folderId, + chunkingConfig: body.chunkingConfig, + }, + requestId, + request: req, + }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to update knowledge base') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) } + + return NextResponse.json({ success: true, data: outcome.knowledgeBase }) } ) @@ -164,62 +126,49 @@ export const DELETE = withRouteHandler( const requestId = generateRequestId() const { id } = await params - try { - const auth = await checkSessionOrInternalAuth(_request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized knowledge base delete attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId) - - if (!accessCheck.hasAccess) { - if ('notFound' in accessCheck && accessCheck.notFound) { - logger.warn(`[${requestId}] Knowledge base not found: ${id}`) - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted to delete unauthorized knowledge base ${id}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const auth = await checkSessionOrInternalAuth(_request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized knowledge base delete attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId - await deleteKnowledgeBase(id, requestId) + const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId) - try { - PlatformEvents.knowledgeBaseDeleted({ - knowledgeBaseId: id, - }) - } catch { - // Telemetry should not fail the operation + if (!accessCheck.hasAccess) { + if ('notFound' in accessCheck && accessCheck.notFound) { + logger.warn(`[${requestId}] Knowledge base not found: ${id}`) + return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) } + logger.warn( + `[${requestId}] User ${userId} attempted to delete unauthorized knowledge base ${id}` + ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - logger.info(`[${requestId}] Knowledge base deleted: ${id} for user ${userId}`) - - recordAudit({ + const outcome = await performDeleteKnowledgeBase({ + knowledgeBase: { + id, + name: accessCheck.knowledgeBase.name, workspaceId: accessCheck.knowledgeBase.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.KNOWLEDGE_BASE_DELETED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: accessCheck.knowledgeBase.name, - description: `Deleted knowledge base "${accessCheck.knowledgeBase.name || id}"`, - metadata: { - knowledgeBaseName: accessCheck.knowledgeBase.name, - }, - request: _request, - }) - - return NextResponse.json({ - success: true, - data: { message: 'Knowledge base deleted successfully' }, - }) - } catch (error) { - logger.error(`[${requestId}] Error deleting knowledge base`, error) - return NextResponse.json({ error: 'Failed to delete knowledge base' }, { status: 500 }) + }, + userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + source: 'ui', + requestId, + request: _request, + }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to delete knowledge base') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) } + + return NextResponse.json({ + success: true, + data: { message: 'Knowledge base deleted successfully' }, + }) } ) diff --git a/apps/sim/app/api/knowledge/route.ts b/apps/sim/app/api/knowledge/route.ts index b2f9177b49d..09178f9dff1 100644 --- a/apps/sim/app/api/knowledge/route.ts +++ b/apps/sim/app/api/knowledge/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { @@ -7,19 +6,14 @@ import { } from '@/lib/api/contracts/knowledge' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { PlatformEvents } from '@/lib/core/telemetry' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' -import { - createKnowledgeBase, - getKnowledgeBases, - KnowledgeBaseConflictError, - KnowledgeBaseFolderError, - KnowledgeBasePermissionError, - type KnowledgeBaseScope, -} from '@/lib/knowledge/service' -import { captureServerEvent } from '@/lib/posthog/server' +import { performCreateKnowledgeBase } from '@/lib/knowledge/orchestration' +import { getKnowledgeBases, type KnowledgeBaseScope } from '@/lib/knowledge/service' const logger = createLogger('KnowledgeBaseAPI') @@ -65,113 +59,49 @@ export const GET = withRouteHandler(async (req: NextRequest) => { export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized knowledge base creation attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest( - createKnowledgeBaseContract, - req, - {}, - { - validationErrorResponse: (error) => { - logger.warn(`[${requestId}] Invalid knowledge base data`, { errors: error.issues }) - return NextResponse.json( - { error: 'Invalid request data', details: error.issues }, - { status: 400 } - ) - }, - } - ) - if (!parsed.success) return parsed.response - - const validatedData = parsed.data.body - - try { - const embeddingModel = getConfiguredEmbeddingModel() - - const createData = { - ...validatedData, - userId: session.user.id, - embeddingModel, - embeddingDimension: EMBEDDING_DIMENSIONS, - } - - const newKnowledgeBase = await createKnowledgeBase(createData, requestId) - - try { - PlatformEvents.knowledgeBaseCreated({ - knowledgeBaseId: newKnowledgeBase.id, - name: validatedData.name, - workspaceId: validatedData.workspaceId, - }) - } catch { - // Telemetry should not fail the operation - } - - captureServerEvent( - session.user.id, - 'knowledge_base_created', - { - knowledge_base_id: newKnowledgeBase.id, - workspace_id: validatedData.workspaceId, - name: validatedData.name, - }, - { - groups: { workspace: validatedData.workspaceId }, - setOnce: { first_kb_created_at: new Date().toISOString() }, - } - ) - - logger.info( - `[${requestId}] Knowledge base created: ${newKnowledgeBase.id} for user ${session.user.id}` - ) - - recordAudit({ - workspaceId: validatedData.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.KNOWLEDGE_BASE_CREATED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: newKnowledgeBase.id, - resourceName: validatedData.name, - description: `Created knowledge base "${validatedData.name}"`, - metadata: { - name: validatedData.name, - description: validatedData.description, - embeddingModel, - embeddingDimension: EMBEDDING_DIMENSIONS, - chunkingStrategy: validatedData.chunkingConfig.strategy, - chunkMaxSize: validatedData.chunkingConfig.maxSize, - chunkMinSize: validatedData.chunkingConfig.minSize, - chunkOverlap: validatedData.chunkingConfig.overlap, - }, - request: req, - }) + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized knowledge base creation attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - return NextResponse.json({ - success: true, - data: newKnowledgeBase, - }) - } catch (createError) { - if (createError instanceof KnowledgeBaseConflictError) { - return NextResponse.json({ error: createError.message }, { status: 409 }) - } - if (createError instanceof KnowledgeBaseFolderError) { - return NextResponse.json({ error: createError.message }, { status: 400 }) - } - if (createError instanceof KnowledgeBasePermissionError) { - logger.warn(`[${requestId}] Forbidden knowledge base creation: ${createError.message}`) - return NextResponse.json({ error: createError.message }, { status: 403 }) - } - throw createError + const parsed = await parseRequest( + createKnowledgeBaseContract, + req, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid knowledge base data`, { errors: error.issues }) + return NextResponse.json( + { error: 'Invalid request data', details: error.issues }, + { status: 400 } + ) + }, } - } catch (error) { - logger.error(`[${requestId}] Error creating knowledge base`, error) - return NextResponse.json({ error: 'Failed to create knowledge base' }, { status: 500 }) + ) + if (!parsed.success) return parsed.response + + const body = parsed.data.body + + const outcome = await performCreateKnowledgeBase({ + userId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + source: 'ui', + workspaceId: body.workspaceId, + name: body.name, + description: body.description, + folderId: body.folderId, + chunkingConfig: body.chunkingConfig, + requestId, + request: req, + }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to create knowledge base') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) } + + return NextResponse.json({ success: true, data: outcome.knowledgeBase }) }) diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts index 94c4832f265..33ac4611ffe 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { document, knowledgeConnector } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' @@ -8,8 +7,12 @@ import { v1GetKnowledgeDocumentContract, } from '@/lib/api/contracts/v1/knowledge' import { parseRequest } from '@/lib/api/server' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteDocument } from '@/lib/knowledge/documents/service' +import { performDeleteKnowledgeDocument } from '@/lib/knowledge/orchestration' import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' import { authenticateRequest, v1ValidationErrorResponse } from '@/app/api/v1/middleware' @@ -152,19 +155,24 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Document not found' }, { status: 404 }) } - await deleteDocument(documentId, requestId) - - recordAudit({ - workspaceId: parsed.data.query.workspaceId, - actorId: userId, - action: AuditAction.DOCUMENT_DELETED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: documentId, - resourceName: docs[0].filename, - description: `Deleted document "${docs[0].filename}" from knowledge base via API`, - metadata: { knowledgeBaseId }, + const outcome = await performDeleteKnowledgeDocument({ + knowledgeBase: { + id: knowledgeBaseId, + name: result.kb.name, + workspaceId: parsed.data.query.workspaceId, + }, + document: { id: documentId, filename: docs[0].filename }, + userId, + source: 'api', + requestId, request, }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to delete document') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts index 18898d704af..5cba10e6338 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts @@ -75,6 +75,9 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({ vi.mock('@/lib/uploads/utils/validation', () => ({ validateFileType: mockValidateFileType, + // Read at module scope by `lib/uploads/utils/file-utils`, which the route now + // reaches transitively through the knowledge orchestration module. + SUPPORTED_ARCHIVE_EXTENSIONS: [], })) vi.mock('@/lib/knowledge/documents/service', () => ({ 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 dfd08d4c892..8f77bb467c6 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type NextRequest, NextResponse } from 'next/server' import { v1ListKnowledgeDocumentsContract, @@ -10,19 +9,19 @@ import { resolveBillingAttribution, resolveSystemBillingAttribution, } from '@/lib/billing/core/billing-attribution' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { isPayloadSizeLimitError, MAX_MULTIPART_OVERHEAD_BYTES, readFormDataWithLimit, } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - createSingleDocument, - type DocumentData, - getDocuments, - processDocumentsWithQueue, -} from '@/lib/knowledge/documents/service' +import { getDocuments } from '@/lib/knowledge/documents/service' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration' import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { validateFileType } from '@/lib/uploads/utils/validation' import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' @@ -189,47 +188,29 @@ export const POST = withRouteHandler( contentType ) - const newDocument = await createSingleDocument( - { + const outcome = await performUploadKnowledgeDocument({ + knowledgeBase: { id: knowledgeBaseId, name: result.kb.name, workspaceId }, + document: { filename: file.name, fileUrl: uploadedFile.url, fileSize: file.size, mimeType: contentType, }, - knowledgeBaseId, - requestId, - billingActorUserId - ) - - const documentData: DocumentData = { - documentId: newDocument.id, - filename: file.name, - fileUrl: uploadedFile.url, - fileSize: file.size, - mimeType: contentType, - } - - processDocumentsWithQueue( - [documentData], - knowledgeBaseId, - {}, + startProcessing: 'queue', + billingAttribution, + uploadedBy: billingActorUserId, + userId, + source: 'api', requestId, - billingAttribution - ).catch(() => { - // Processing errors are logged internally - }) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.DOCUMENT_UPLOADED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: newDocument.id, - resourceName: file.name, - description: `Uploaded document "${file.name}" to knowledge base via API`, - metadata: { knowledgeBaseId, fileSize: file.size, mimeType: contentType }, request, }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to upload document') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } + const newDocument = outcome.document return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/v1/knowledge/[id]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/route.ts index 8dbb280559f..373d0951636 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type NextRequest, NextResponse } from 'next/server' import { v1DeleteKnowledgeBaseContract, @@ -6,8 +5,15 @@ import { v1UpdateKnowledgeBaseContract, } from '@/lib/api/contracts/v1/knowledge' import { parseRequest } from '@/lib/api/server' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service' +import { + performDeleteKnowledgeBase, + performUpdateKnowledgeBase, +} from '@/lib/knowledge/orchestration' import { formatKnowledgeBase, handleError, @@ -67,33 +73,26 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, 'write') if (result instanceof NextResponse) return result - const updates: { - name?: string - description?: string - chunkingConfig?: { maxSize: number; minSize: number; overlap: number } - } = {} - if (name !== undefined) updates.name = name - if (description !== undefined) updates.description = description - if (chunkingConfig !== undefined) updates.chunkingConfig = chunkingConfig - - const updatedKb = await updateKnowledgeBase(id, updates, requestId) - - recordAudit({ + const outcome = await performUpdateKnowledgeBase({ + knowledgeBaseId: id, workspaceId, - actorId: userId, - action: AuditAction.KNOWLEDGE_BASE_UPDATED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: updatedKb.name, - description: `Updated knowledge base "${updatedKb.name}" via API`, - metadata: { updatedFields: Object.keys(updates) }, + userId, + source: 'api', + updates: { name, description, chunkingConfig }, + requestId, request, }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to update knowledge base') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, data: { - knowledgeBase: formatKnowledgeBase(updatedKb), + knowledgeBase: formatKnowledgeBase(outcome.knowledgeBase), message: 'Knowledge base updated successfully', }, }) @@ -125,18 +124,23 @@ export const DELETE = withRouteHandler( ) if (result instanceof NextResponse) return result - await deleteKnowledgeBase(id, requestId) - - recordAudit({ - workspaceId: parsed.data.query.workspaceId, - actorId: userId, - action: AuditAction.KNOWLEDGE_BASE_DELETED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: result.kb.name, - description: `Deleted knowledge base "${result.kb.name}" via API`, + const outcome = await performDeleteKnowledgeBase({ + knowledgeBase: { + id, + name: result.kb.name, + workspaceId: parsed.data.query.workspaceId, + }, + userId, + source: 'api', + requestId, request, }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to delete knowledge base') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/v1/knowledge/route.ts b/apps/sim/app/api/v1/knowledge/route.ts index 5b608484025..cacb36ed482 100644 --- a/apps/sim/app/api/v1/knowledge/route.ts +++ b/apps/sim/app/api/v1/knowledge/route.ts @@ -1,13 +1,16 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type NextRequest, NextResponse } from 'next/server' import { v1CreateKnowledgeBaseContract, v1ListKnowledgeBasesContract, } from '@/lib/api/contracts/v1/knowledge' import { parseRequest } from '@/lib/api/server' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' -import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' +import { performCreateKnowledgeBase } from '@/lib/knowledge/orchestration' +import { getKnowledgeBases } from '@/lib/knowledge/service' import { formatKnowledgeBase, handleError } from '@/app/api/v1/knowledge/utils' import { authenticateRequest, @@ -76,35 +79,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (accessError) return accessError - const kb = await createKnowledgeBase( - { - name, - description, - workspaceId, - userId, - embeddingModel: getConfiguredEmbeddingModel(), - embeddingDimension: EMBEDDING_DIMENSIONS, - chunkingConfig: chunkingConfig ?? { maxSize: 1024, minSize: 100, overlap: 200 }, - }, - requestId - ) - - recordAudit({ + const outcome = await performCreateKnowledgeBase({ + userId, + source: 'api', workspaceId, - actorId: userId, - action: AuditAction.KNOWLEDGE_BASE_CREATED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: kb.id, - resourceName: kb.name, - description: `Created knowledge base "${kb.name}" via API`, - metadata: { chunkingConfig }, + name, + description, + chunkingConfig, + requestId, request, }) + if (!outcome.success) { + return NextResponse.json( + { error: messageForOrchestrationError(outcome, 'Failed to create knowledge base') }, + { status: statusForOrchestrationError(outcome.errorCode) } + ) + } return NextResponse.json({ success: true, data: { - knowledgeBase: formatKnowledgeBase(kb), + knowledgeBase: formatKnowledgeBase(outcome.knowledgeBase), message: 'Knowledge base created successfully', }, }) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts index 41560fb7558..ef9318b1dc6 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { document, knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -13,12 +12,18 @@ import { import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteDocument } from '@/lib/knowledge/documents/service' +import { performDeleteKnowledgeDocument } from '@/lib/knowledge/orchestration' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' const logger = createLogger('V2KnowledgeDocumentDetailAPI') @@ -193,19 +198,21 @@ export const DELETE = withRouteHandler( const doc = docs[0] if (!doc) return v2Error('NOT_FOUND', 'Document not found') - await deleteDocument(documentId, requestId) - - recordAudit({ - workspaceId: parsed.data.query.workspaceId, - actorId: userId, - action: AuditAction.DOCUMENT_DELETED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: documentId, - resourceName: doc.filename, - description: `Deleted document "${doc.filename}" from knowledge base via API`, - metadata: { knowledgeBaseId }, + const outcome = await performDeleteKnowledgeDocument({ + knowledgeBase: { + id: knowledgeBaseId, + name: result.kb.name, + workspaceId: parsed.data.query.workspaceId, + }, + document: { id: documentId, filename: doc.filename }, + userId, + source: 'api', + requestId, request, }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) + } return v2Data({ id: documentId, deleted: true as const }, { rateLimit }) } catch (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 60103508d3d..1f513d5f2ed 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' @@ -20,13 +19,9 @@ import { readFormDataWithLimit, } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - createSingleDocument, - type DocumentData, - getDocuments, - processDocumentsWithQueue, -} from '@/lib/knowledge/documents/service' +import { getDocuments } from '@/lib/knowledge/documents/service' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { validateFileType } from '@/lib/uploads/utils/validation' @@ -39,6 +34,7 @@ import { v2CursorList, v2Data, v2Error, + v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, } from '@/app/api/v2/lib/response' @@ -248,47 +244,26 @@ export const POST = withRouteHandler( contentType ) - const newDocument = await createSingleDocument( - { + const outcome = await performUploadKnowledgeDocument({ + knowledgeBase: { id: knowledgeBaseId, name: result.kb.name, workspaceId }, + document: { filename: file.name, fileUrl: uploadedFile.url, fileSize: file.size, mimeType: contentType, }, - knowledgeBaseId, - requestId, - billingAttribution.actorUserId - ) - - const documentData: DocumentData = { - documentId: newDocument.id, - filename: file.name, - fileUrl: uploadedFile.url, - fileSize: file.size, - mimeType: contentType, - } - - processDocumentsWithQueue( - [documentData], - knowledgeBaseId, - {}, + startProcessing: 'queue', + billingAttribution, + uploadedBy: billingAttribution.actorUserId, + userId, + source: 'api', requestId, - billingAttribution - ).catch(() => { - // Processing errors are logged internally by the queue. - }) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.DOCUMENT_UPLOADED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: newDocument.id, - resourceName: file.name, - description: `Uploaded document "${file.name}" to knowledge base via API`, - metadata: { knowledgeBaseId, fileSize: file.size, mimeType: contentType }, request, }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) + } + const newDocument = outcome.document const document: V2KnowledgeDocumentSummary = { id: newDocument.id, @@ -310,18 +285,6 @@ export const POST = withRouteHandler( return v2Error('PAYLOAD_TOO_LARGE', error.message) } - if (error instanceof Error) { - if ( - error.message.includes('Storage limit exceeded') || - error.message.includes('storage limit') - ) { - return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') - } - if (error.message.includes('already exists')) { - return v2Error('CONFLICT', 'Resource already exists') - } - } - logger.error(`[${requestId}] Error uploading document`, { error: getErrorMessage(error, 'Unknown error'), }) diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts index e6ae3849bae..65364a2b808 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' @@ -7,15 +6,24 @@ import { v2GetKnowledgeBaseContract, v2UpdateKnowledgeBaseContract, } from '@/lib/api/contracts/v2/knowledge' -import { isZodError, parseRequest } from '@/lib/api/server' +import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service' +import { + performDeleteKnowledgeBase, + performUpdateKnowledgeBase, +} from '@/lib/knowledge/orchestration' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { formatKnowledgeBase, resolveKnowledgeBase } from '@/app/api/v1/knowledge/utils' import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' const logger = createLogger('V2KnowledgeDetailAPI') @@ -110,42 +118,21 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle const result = await resolveKnowledgeBaseScoped(id, workspaceId, userId, rateLimit, 'write') if (result instanceof NextResponse) return result - const updates: { - name?: string - description?: string - chunkingConfig?: { maxSize: number; minSize: number; overlap: number } - } = {} - if (name !== undefined) updates.name = name - if (description !== undefined) updates.description = description - if (chunkingConfig !== undefined) updates.chunkingConfig = chunkingConfig - - const updatedKb = await updateKnowledgeBase(id, updates, requestId) - - recordAudit({ + const outcome = await performUpdateKnowledgeBase({ + knowledgeBaseId: id, workspaceId, - actorId: userId, - action: AuditAction.KNOWLEDGE_BASE_UPDATED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: updatedKb.name, - description: `Updated knowledge base "${updatedKb.name}" via API`, - metadata: { updatedFields: Object.keys(updates) }, + userId, + source: 'api', + updates: { name, description, chunkingConfig }, + requestId, request, }) - - return v2Data({ knowledgeBase: formatKnowledgeBase(updatedKb) }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - if (error instanceof Error) { - if (error.message.includes('does not have permission')) { - return v2Error('FORBIDDEN', 'Access denied') - } - if (error.message.includes('already exists')) { - return v2Error('CONFLICT', 'Resource already exists') - } + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) } + return v2Data({ knowledgeBase: formatKnowledgeBase(outcome.knowledgeBase) }, { rateLimit }) + } catch (error) { logger.error(`[${requestId}] Error updating knowledge base`, { error: getErrorMessage(error, 'Unknown error'), }) @@ -182,18 +169,16 @@ export const DELETE = withRouteHandler( ) if (result instanceof NextResponse) return result - await deleteKnowledgeBase(id, requestId) - - recordAudit({ - workspaceId: parsed.data.query.workspaceId, - actorId: userId, - action: AuditAction.KNOWLEDGE_BASE_DELETED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: result.kb.name, - description: `Deleted knowledge base "${result.kb.name}" via API`, + const outcome = await performDeleteKnowledgeBase({ + knowledgeBase: { id, name: result.kb.name, workspaceId: parsed.data.query.workspaceId }, + userId, + source: 'api', + requestId, request, }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) + } return v2Data({ id, deleted: true as const }, { rateLimit }) } catch (error) { diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index 65aae6b8d5a..64e6445687b 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -1,4 +1,3 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' @@ -6,11 +5,11 @@ import { v2CreateKnowledgeBaseContract, v2ListKnowledgeBasesContract, } from '@/lib/api/contracts/v2/knowledge' -import { isZodError, parseRequest } from '@/lib/api/server' +import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' -import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' +import { performCreateKnowledgeBase } from '@/lib/knowledge/orchestration' +import { getKnowledgeBases } from '@/lib/knowledge/service' import { formatKnowledgeBase } from '@/app/api/v1/knowledge/utils' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -18,6 +17,7 @@ import { v2CursorList, v2Data, v2Error, + v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, @@ -97,50 +97,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - const kb = await createKnowledgeBase( - { - name, - description, - workspaceId, - userId, - embeddingModel: getConfiguredEmbeddingModel(), - embeddingDimension: EMBEDDING_DIMENSIONS, - chunkingConfig: chunkingConfig ?? { maxSize: 1024, minSize: 100, overlap: 200 }, - }, - requestId - ) - - recordAudit({ + const outcome = await performCreateKnowledgeBase({ + userId, + source: 'api', workspaceId, - actorId: userId, - action: AuditAction.KNOWLEDGE_BASE_CREATED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: kb.id, - resourceName: kb.name, - description: `Created knowledge base "${kb.name}" via API`, - metadata: { chunkingConfig }, + name, + description, + chunkingConfig, + requestId, request, }) - - return v2Data({ knowledgeBase: formatKnowledgeBase(kb) }, { rateLimit, status: 201 }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - if (error instanceof Error) { - if (error.message.includes('does not have permission')) { - return v2Error('FORBIDDEN', 'Access denied') - } - if ( - error.message.includes('Storage limit exceeded') || - error.message.includes('storage limit') - ) { - return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') - } - if (error.message.includes('already exists')) { - return v2Error('CONFLICT', 'Resource already exists') - } + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) } + return v2Data( + { knowledgeBase: formatKnowledgeBase(outcome.knowledgeBase) }, + { rateLimit, status: 201 } + ) + } catch (error) { logger.error(`[${requestId}] Error creating knowledge base`, { error: getErrorMessage(error, 'Unknown error'), }) diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 45ed1e6fcb3..3bdc2b90b91 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -159,10 +159,12 @@ export function decodeCursor>(cursor: string): T | n const V2_CODE_BY_ORCHESTRATION_ERROR: Record = { validation: 'BAD_REQUEST', + unauthorized: 'UNAUTHORIZED', forbidden: 'FORBIDDEN', not_found: 'NOT_FOUND', conflict: 'CONFLICT', locked: 'LOCKED', + payload_too_large: 'PAYLOAD_TOO_LARGE', internal: 'INTERNAL_ERROR', } diff --git a/apps/sim/lib/api/contracts/knowledge/base.ts b/apps/sim/lib/api/contracts/knowledge/base.ts index 701073e3a23..2c142049afc 100644 --- a/apps/sim/lib/api/contracts/knowledge/base.ts +++ b/apps/sim/lib/api/contracts/knowledge/base.ts @@ -7,7 +7,10 @@ import { } from '@/lib/api/contracts/knowledge/shared' import { defineRouteContract } from '@/lib/api/contracts/types' import type { StrategyOptions } from '@/lib/chunkers/types' -import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants' +import { + DEFAULT_CHUNKING_CONFIG, + KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH, +} from '@/lib/knowledge/constants' export const knowledgeScopeSchema = z.enum(['active', 'archived', 'all']) export type KnowledgeScope = z.output @@ -67,11 +70,7 @@ export const createKnowledgeBaseBodySchema = z.object({ folderId: z.string().min(1, 'Folder ID cannot be empty').nullable().optional(), embeddingModel: z.literal('text-embedding-3-small').default('text-embedding-3-small'), embeddingDimension: z.literal(1536).default(1536), - chunkingConfig: chunkingConfigSchema.default({ - maxSize: 1024, - minSize: 100, - overlap: 200, - }), + chunkingConfig: chunkingConfigSchema.default(DEFAULT_CHUNKING_CONFIG), }) export const updateKnowledgeBaseBodySchema = createKnowledgeBaseBodySchema diff --git a/apps/sim/lib/api/contracts/knowledge/connectors.ts b/apps/sim/lib/api/contracts/knowledge/connectors.ts index ed147c741a1..cef3d8718d1 100644 --- a/apps/sim/lib/api/contracts/knowledge/connectors.ts +++ b/apps/sim/lib/api/contracts/knowledge/connectors.ts @@ -22,7 +22,8 @@ export const updateConnectorBodySchema = z.object({ }) export const deleteConnectorQuerySchema = z.object({ - deleteDocuments: z.boolean().optional(), + /** Also hard-delete the documents the connector produced; kept by default. */ + deleteDocuments: booleanQueryFlagSchema.optional().default(false), }) export const connectorDocumentsQuerySchema = z.object({ diff --git a/apps/sim/lib/api/contracts/v1/knowledge/index.ts b/apps/sim/lib/api/contracts/v1/knowledge/index.ts index 76abfcf16a0..926786090b4 100644 --- a/apps/sim/lib/api/contracts/v1/knowledge/index.ts +++ b/apps/sim/lib/api/contracts/v1/knowledge/index.ts @@ -7,7 +7,10 @@ import { } from '@/lib/api/contracts/knowledge/shared' import { requiredFieldSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants' +import { + DEFAULT_CHUNKING_CONFIG, + KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH, +} from '@/lib/knowledge/constants' /** * Public API v1 schemas (`/api/v1/knowledge/**`) @@ -25,9 +28,9 @@ import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants /** Simpler chunking config used by the public API (no `strategy`). */ export const v1ChunkingConfigSchema = z.object({ - maxSize: z.number().min(100).max(4000).default(1024), - minSize: z.number().min(1).max(2000).default(100), - overlap: z.number().min(0).max(500).default(200), + maxSize: z.number().min(100).max(4000).default(DEFAULT_CHUNKING_CONFIG.maxSize), + minSize: z.number().min(1).max(2000).default(DEFAULT_CHUNKING_CONFIG.minSize), + overlap: z.number().min(0).max(500).default(DEFAULT_CHUNKING_CONFIG.overlap), }) /** GET `/api/v1/knowledge` — list knowledge bases scoped to a workspace. */ @@ -46,11 +49,7 @@ export const v1CreateKnowledgeBaseBodySchema = z.object({ `Description must be ${KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH} characters or less` ) .optional(), - chunkingConfig: v1ChunkingConfigSchema.optional().default({ - maxSize: 1024, - minSize: 100, - overlap: 200, - }), + chunkingConfig: v1ChunkingConfigSchema.optional().default(DEFAULT_CHUNKING_CONFIG), }) /** GET/DELETE `/api/v1/knowledge/[id]` — workspace scope param. */ diff --git a/apps/sim/lib/billing/storage/index.ts b/apps/sim/lib/billing/storage/index.ts index 5496ad64a5d..59e829499df 100644 --- a/apps/sim/lib/billing/storage/index.ts +++ b/apps/sim/lib/billing/storage/index.ts @@ -6,6 +6,7 @@ export { getStorageUsageForBillingContext, getUserStorageLimit, getUserStorageUsage, + StorageLimitExceededError, } from './limits' export { applyStorageUsageDeltasInTx, diff --git a/apps/sim/lib/billing/storage/limits.ts b/apps/sim/lib/billing/storage/limits.ts index 34b01d97b07..96d27a82265 100644 --- a/apps/sim/lib/billing/storage/limits.ts +++ b/apps/sim/lib/billing/storage/limits.ts @@ -21,9 +21,24 @@ import type { StorageBillingContext } from '@/lib/billing/storage/context' import { getLegacyStorageBillingEntity } from '@/lib/billing/storage/entity' import { getEnv } from '@/lib/core/config/env' import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' const logger = createLogger('StorageLimits') +/** + * Thrown when accepting a write would push its payer past its storage quota. + * + * An {@link OrchestrationError} so every surface reaches 413 by class. The bare + * `Error` this replaced was classified by searching the message for "storage + * limit", which the UI, v1, and v2 knowledge routes each re-implemented. + */ +export class StorageLimitExceededError extends OrchestrationError { + constructor(message: string) { + super('payload_too_large', message) + this.name = 'StorageLimitExceededError' + } +} + type StorageLimits = ReturnType interface StorageLimitResolutionInput { diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts index 1fab48fa652..0de9b03507b 100644 --- a/apps/sim/lib/billing/storage/tracking.ts +++ b/apps/sim/lib/billing/storage/tracking.ts @@ -18,6 +18,7 @@ import { getUserStorageLimit, getUserStorageUsage, isStorageEnforcementEnabled, + StorageLimitExceededError, } from '@/lib/billing/storage/limits' import { getFreeTierLimit, isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import type { DbOrTx } from '@/lib/db/types' @@ -362,7 +363,7 @@ export async function applyStorageUsageDeltasInTx( payerDelta.maximumUsage !== undefined && nextUsage > payerDelta.maximumUsage ) { - throw new Error( + throw new StorageLimitExceededError( `Storage limit exceeded. Used: ${(nextUsage / 1024 ** 3).toFixed(2)}GB, Limit: ${(payerDelta.maximumUsage / 1024 ** 3).toFixed(0)}GB` ) } @@ -471,7 +472,7 @@ async function mutateWorkspaceStorageUsage( currentPayerUsage + bytes > maximumUsage ) { const newUsage = currentPayerUsage + bytes - throw new Error( + throw new StorageLimitExceededError( `Storage limit exceeded. Used: ${(newUsage / 1024 ** 3).toFixed(2)}GB, Limit: ${(maximumUsage / 1024 ** 3).toFixed(0)}GB` ) } diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index 186e785d29c..20980bc6a95 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -2,34 +2,33 @@ * @vitest-environment node */ import { knowledgeConnector } from '@sim/db/schema' -import { queueTableRows, resetDbChainMock, resetUrlsMock, urlsMockFns } from '@sim/testing' -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockAssertBillingAttributionSnapshot, mockCheckKnowledgeBaseWriteAccess, - mockFetch, - mockGenerateInternalToken, - mockSerializeBillingAttributionHeader, + mockGetKnowledgeBaseById, + mockPerformCreateKnowledgeConnector, + mockPerformDeleteKnowledgeBase, + mockPerformDeleteKnowledgeConnector, + mockPerformSyncKnowledgeConnector, } = vi.hoisted(() => ({ mockAssertBillingAttributionSnapshot: vi.fn(), mockCheckKnowledgeBaseWriteAccess: vi.fn(), - mockFetch: vi.fn(), - mockGenerateInternalToken: vi.fn(), - mockSerializeBillingAttributionHeader: vi.fn(), + mockGetKnowledgeBaseById: vi.fn(), + mockPerformCreateKnowledgeConnector: vi.fn(), + mockPerformDeleteKnowledgeBase: vi.fn(), + mockPerformDeleteKnowledgeConnector: vi.fn(), + mockPerformSyncKnowledgeConnector: vi.fn(), })) -vi.mock('@/lib/auth/internal', () => ({ - generateInternalToken: mockGenerateInternalToken, -})) vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ checkActorUsageLimits: vi.fn(), })) vi.mock('@/lib/billing/core/billing-attribution', () => ({ - BILLING_ATTRIBUTION_HEADER: 'x-sim-billing-attribution', assertBillingAttributionSnapshot: mockAssertBillingAttributionSnapshot, checkAttributedUsageLimits: vi.fn(), - serializeBillingAttributionHeader: mockSerializeBillingAttributionHeader, })) vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ KnowledgeBase: { id: 'knowledge_base' }, @@ -37,28 +36,24 @@ vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ vi.mock('@/lib/copilot/tools/server/base-tool', () => ({ assertServerToolNotAborted: vi.fn(), })) -beforeAll(() => { - urlsMockFns.mockGetInternalApiBaseUrl.mockReturnValue('http://internal.test') -}) - -afterAll(resetUrlsMock) -vi.mock('@/lib/knowledge/documents/service', () => ({ - createSingleDocument: vi.fn(), - deleteDocument: vi.fn(), - processDocumentAsync: vi.fn(), - updateDocument: vi.fn(), -})) vi.mock('@/lib/knowledge/embeddings', () => ({ - EMBEDDING_DIMENSIONS: 1536, generateSearchEmbedding: vi.fn(), - getConfiguredEmbeddingModel: vi.fn(), recordSearchEmbeddingUsage: vi.fn(), })) +vi.mock('@/lib/knowledge/orchestration', () => ({ + performCreateKnowledgeBase: vi.fn(), + performDeleteKnowledgeBase: mockPerformDeleteKnowledgeBase, + performCreateKnowledgeConnector: mockPerformCreateKnowledgeConnector, + performDeleteKnowledgeConnector: mockPerformDeleteKnowledgeConnector, + performDeleteKnowledgeDocument: vi.fn(), + performSyncKnowledgeConnector: mockPerformSyncKnowledgeConnector, + performUpdateKnowledgeBase: vi.fn(), + performUpdateKnowledgeConnector: vi.fn(), + performUpdateKnowledgeDocument: vi.fn(), + performUploadKnowledgeDocument: vi.fn(), +})) vi.mock('@/lib/knowledge/service', () => ({ - createKnowledgeBase: vi.fn(), - deleteKnowledgeBase: vi.fn(), - getKnowledgeBaseById: vi.fn(), - updateKnowledgeBase: vi.fn(), + getKnowledgeBaseById: mockGetKnowledgeBaseById, })) vi.mock('@/lib/knowledge/tags/service', () => ({ createTagDefinition: vi.fn(), @@ -73,6 +68,7 @@ vi.mock('@/lib/uploads', () => ({ StorageService: {} })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: vi.fn(), })) +vi.mock('@/app/api/auth/oauth/utils', () => ({ getCredential: vi.fn() })) vi.mock('@/app/api/knowledge/search/utils', () => ({ executeKnowledgeSearch: vi.fn(), })) @@ -97,6 +93,12 @@ const BILLING_ATTRIBUTION = { payerSubscription: null, } +const CONTEXT = { + userId: 'external-admin', + workspaceId: 'workspace-paid', + billingAttribution: BILLING_ATTRIBUTION, +} + describe('knowledge base connector Copilot operations', () => { afterAll(() => { resetDbChainMock() @@ -105,11 +107,8 @@ describe('knowledge base connector Copilot operations', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - vi.stubGlobal('fetch', mockFetch) queueTableRows(knowledgeConnector, [{ knowledgeBaseId: 'knowledge-base-1' }]) mockAssertBillingAttributionSnapshot.mockReturnValue(BILLING_ATTRIBUTION) - mockSerializeBillingAttributionHeader.mockReturnValue('serialized-attribution') - mockGenerateInternalToken.mockResolvedValue('internal-token') mockCheckKnowledgeBaseWriteAccess.mockResolvedValue({ hasAccess: true, knowledgeBase: { @@ -118,21 +117,21 @@ describe('knowledge base connector Copilot operations', () => { name: 'Paid KB', }, }) - mockFetch.mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue({ - success: true, - data: { - id: 'connector-1', - connectorType: 'notion', - status: 'active', - }, - }), + mockPerformCreateKnowledgeConnector.mockResolvedValue({ + success: true, + connector: { id: 'connector-1', connectorType: 'notion', status: 'active' }, + }) + mockPerformSyncKnowledgeConnector.mockResolvedValue({ success: true }) + mockPerformDeleteKnowledgeConnector.mockResolvedValue({ + success: true, + documentsDeleted: 0, + documentsKept: 3, }) }) it.each([ { + operation: 'add_connector', params: { operation: 'add_connector', args: { @@ -141,35 +140,87 @@ describe('knowledge base connector Copilot operations', () => { apiKey: 'api-key', }, }, - expectedPath: '/api/knowledge/knowledge-base-1/connectors', + perform: mockPerformCreateKnowledgeConnector, }, { - params: { - operation: 'sync_connector', - args: { connectorId: 'connector-1' }, - }, - expectedPath: '/api/knowledge/knowledge-base-1/connectors/connector-1/sync', + operation: 'sync_connector', + params: { operation: 'sync_connector', args: { connectorId: 'connector-1' } }, + perform: mockPerformSyncKnowledgeConnector, }, - ])( - 'forwards immutable billing attribution for $params.operation', - async ({ params, expectedPath }) => { - const result = await knowledgeBaseServerTool.execute(params, { - userId: 'external-admin', - workspaceId: 'workspace-paid', - billingAttribution: BILLING_ATTRIBUTION, - }) - - expect(result.success).toBe(true) - expect(mockFetch).toHaveBeenCalledWith( - `http://internal.test${expectedPath}`, - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer internal-token', - 'x-sim-billing-attribution': 'serialized-attribution', - }), - }) - ) - expect(mockSerializeBillingAttributionHeader).toHaveBeenCalledWith(BILLING_ATTRIBUTION) - } - ) + ])('forwards immutable billing attribution for $operation', async ({ params, perform }) => { + const result = await knowledgeBaseServerTool.execute(params, CONTEXT) + + expect(result.success).toBe(true) + // The operation runs in-process now. The payer travels as a value on the + // orchestration call rather than as a serialized header on an internal + // HTTP self-call back into this same process. + const call = perform.mock.calls[0][0] + expect(await call.resolveBillingAttribution()).toEqual(BILLING_ATTRIBUTION) + expect(call.source).toBe('agent') + expect(mockAssertBillingAttributionSnapshot).toHaveBeenCalledWith(BILLING_ATTRIBUTION) + }) + + it('reports a failed knowledge base delete as failed, not as missing', async () => { + mockGetKnowledgeBaseById.mockResolvedValue({ + id: 'knowledge-base-1', + name: 'Paid KB', + workspaceId: 'workspace-paid', + }) + mockPerformDeleteKnowledgeBase.mockResolvedValue({ + success: false, + error: 'Knowledge base is locked', + errorCode: 'conflict', + }) + + const result = await knowledgeBaseServerTool.execute( + { operation: 'delete', args: { knowledgeBaseId: 'knowledge-base-1' } }, + CONTEXT + ) + + // A knowledge base that exists but could not be archived is neither deleted + // nor missing — folding it into notFound told the user it was never there. + expect(result.data.notFound).toEqual([]) + expect(result.data.failed).toEqual([ + { id: 'knowledge-base-1', name: 'Paid KB', reason: 'Knowledge base is locked' }, + ]) + expect(result.message).toContain('Knowledge base is locked') + }) + + it('never relays an unclassified fault to the agent verbatim', async () => { + mockGetKnowledgeBaseById.mockResolvedValue({ + id: 'knowledge-base-1', + name: 'Paid KB', + workspaceId: 'workspace-paid', + }) + mockPerformDeleteKnowledgeBase.mockResolvedValue({ + success: false, + error: 'select "id" from "knowledge_base" — connection terminated', + errorCode: 'internal', + }) + + const result = await knowledgeBaseServerTool.execute( + { operation: 'delete', args: { knowledgeBaseId: 'knowledge-base-1' } }, + CONTEXT + ) + + expect(result.data.failed[0].reason).toBe('Failed to delete knowledge base') + expect(result.message).not.toContain('connection terminated') + }) + + it('reports that a deleted connector kept its documents, because it did', async () => { + const result = await knowledgeBaseServerTool.execute( + { operation: 'delete_connector', args: { connectorId: 'connector-1' } }, + CONTEXT + ) + + // The old wording claimed the documents "have been removed". They never + // were: the tool reached the route over HTTP with no query string, so the + // route's keep-documents default always applied. + expect(result.success).toBe(true) + expect(result.message).toContain('3 document(s) were kept') + expect(result.message).not.toContain('removed') + expect(mockPerformDeleteKnowledgeConnector).toHaveBeenCalledWith( + expect.objectContaining({ connectorId: 'connector-1', source: 'agent' }) + ) + }) }) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index bf19100c9ec..a7f41a21768 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -1,18 +1,16 @@ import { db } from '@sim/db' import { knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { filterUndefined } from '@sim/utils/object' import { truncate } from '@sim/utils/string' import { and, eq, isNull } from 'drizzle-orm' -import { generateInternalToken } from '@/lib/auth/internal' import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { assertBillingAttributionSnapshot, - BILLING_ATTRIBUTION_HEADER, type BillingAttributionSnapshot, checkAttributedUsageLimits, - serializeBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' import { KnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' import { @@ -20,25 +18,24 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { - createSingleDocument, - deleteDocument, - processDocumentAsync, - updateDocument, -} from '@/lib/knowledge/documents/service' + messageForOrchestrationError, + type OrchestrationErrorCode, +} from '@/lib/core/orchestration/types' +import { generateSearchEmbedding, recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' import { - EMBEDDING_DIMENSIONS, - generateSearchEmbedding, - getConfiguredEmbeddingModel, - recordSearchEmbeddingUsage, -} from '@/lib/knowledge/embeddings' -import { - createKnowledgeBase, - deleteKnowledgeBase, - getKnowledgeBaseById, - updateKnowledgeBase, -} from '@/lib/knowledge/service' + performCreateKnowledgeBase, + performCreateKnowledgeConnector, + performDeleteKnowledgeBase, + performDeleteKnowledgeConnector, + performDeleteKnowledgeDocument, + performSyncKnowledgeConnector, + performUpdateKnowledgeBase, + performUpdateKnowledgeConnector, + performUpdateKnowledgeDocument, + performUploadKnowledgeDocument, +} from '@/lib/knowledge/orchestration' +import { getKnowledgeBaseById } from '@/lib/knowledge/service' import { createTagDefinition, deleteTagDefinition, @@ -50,6 +47,7 @@ import { } from '@/lib/knowledge/tags/service' import { StorageService } from '@/lib/uploads' import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { getCredential } from '@/app/api/auth/oauth/utils' import { executeKnowledgeSearch } from '@/app/api/knowledge/search/utils' import { checkDocumentWriteAccess, @@ -73,6 +71,20 @@ function requireKnowledgeBillingAttribution( return attribution } +/** + * The message the agent — and therefore the user — is shown for a failed + * operation. Mirrors `messageForOrchestrationError` on the HTTP surfaces: a + * classified failure is caller-fixable and safe to relay, an unclassified one + * carries whatever text the fault happened to have (a driver's failed SQL, say) + * and is replaced by the operation's own wording. + */ +function agentFacingError( + outcome: { error?: string; errorCode?: OrchestrationErrorCode }, + fallback: string +): string { + return messageForOrchestrationError(outcome, fallback) +} + type KnowledgeBaseArgs = { operation: string args?: Record @@ -109,6 +121,17 @@ export const knowledgeBaseServerTool: BaseServerTool ({ + userId: context.userId as string, + source: 'agent' as const, + requestId, + }) try { switch (operation) { @@ -129,29 +152,21 @@ export const knowledgeBaseServerTool: BaseServerTool { - logger.error('Background document processing failed', { - documentId: doc.id, - error: toError(err).message, - }) + startProcessing: 'async', + billingAttribution, }) + if (!outcome.success) { + failedFiles.push(fileRef) + continue + } - added.push({ documentId: doc.id, filename: fileRecord.name }) - - logger.info('Workspace file added to knowledge base via copilot', { - knowledgeBaseId: args.knowledgeBaseId, - documentId: doc.id, - fileName: fileRecord.name, - userId: context.userId, - }) + added.push({ documentId: outcome.document.id, filename: fileRecord.name }) } const addedNames = added.map((a) => a.filename).join(', ') @@ -461,13 +460,20 @@ export const knowledgeBaseServerTool: BaseServerTool = [] const notFound: string[] = [] + // A knowledge base that exists but could not be archived is neither + // deleted nor missing. Folding it into `notFound` told the user it was + // never there instead of why the delete failed. + const failed: Array<{ id: string; name: string; reason: string }> = [] for (const kbId of kbIds) { const writeAccess = await checkKnowledgeBaseWriteAccess(kbId, context.userId) @@ -510,23 +520,42 @@ export const knowledgeBaseServerTool: BaseServerTool 0 ? `Deleted: ${deleted.map((d) => d.name).join(', ')}` : null, + failed.length > 0 + ? `Failed: ${failed.map((f) => `${f.name} (${f.reason})`).join(', ')}` + : null, + ] + .filter(Boolean) + .join('. ') + return { success: deleted.length > 0, - message: - deleted.length > 0 - ? `Deleted: ${deleted.map((d) => d.name).join(', ')}` - : 'No knowledge bases found', - data: { deleted, notFound }, + message: deleteSummary || 'No knowledge bases found', + data: { deleted, notFound, failed }, } } @@ -557,8 +586,16 @@ export const knowledgeBaseServerTool: BaseServerTool = { - connectorType: args.connectorType, - sourceConfig: args.sourceConfig ?? {}, - syncIntervalMinutes: args.syncIntervalMinutes ?? 1440, - } - - if (args.credentialId) { - createBody.credentialId = args.credentialId - } - if (args.apiKey) { - createBody.apiKey = args.apiKey - } - + const sourceConfig: Record = { ...(args.sourceConfig ?? {}) } if (args.disabledTagIds?.length) { - ;(createBody.sourceConfig as Record).disabledTagIds = - args.disabledTagIds + sourceConfig.disabledTagIds = args.disabledTagIds } + const requestId = generateId().slice(0, 8) assertNotAborted() - const createRes = await connectorApiCall( - context.userId, - `/api/knowledge/${args.knowledgeBaseId}/connectors`, - 'POST', - createBody, - billingAttribution - ) - - if (!createRes.success) { - return { success: false, message: createRes.error ?? 'Failed to create connector' } - } - - const connector = createRes.data - logger.info('Connector created via copilot', { - connectorId: connector.id, + const outcome = await performCreateKnowledgeConnector({ + ...actor(requestId), + knowledgeBase: { + id: args.knowledgeBaseId, + name: writeAccess.knowledgeBase.name, + workspaceId: connectorWorkspaceId, + }, connectorType: args.connectorType, - knowledgeBaseId: args.knowledgeBaseId, - userId: context.userId, + credentialId: args.credentialId, + apiKey: args.apiKey, + sourceConfig, + syncIntervalMinutes: args.syncIntervalMinutes ?? 1440, + resolveBillingAttribution: async () => billingAttribution, + resolveAccessToken: async (credentialId) => + (await getCredential(requestId, credentialId, context.userId as string)) + ?.accessToken ?? null, }) + if (!outcome.success) { + return { success: false, message: agentFacingError(outcome, 'Failed to add connector') } + } + const connector = outcome.connector return { success: true, message: `Connector "${args.connectorType}" added to knowledge base. Initial sync started.`, data: { id: connector.id, - connectorType: connector.connectorType ?? connector.connector_type, + connectorType: connector.connectorType, status: connector.status, knowledgeBaseId: args.knowledgeBaseId, }, @@ -962,41 +1005,38 @@ export const knowledgeBaseServerTool: BaseServerTool = {} - if (args.sourceConfig !== undefined) updateBody.sourceConfig = args.sourceConfig - if (args.syncIntervalMinutes !== undefined) - updateBody.syncIntervalMinutes = args.syncIntervalMinutes - if (args.connectorStatus !== undefined) updateBody.status = args.connectorStatus - - if (Object.keys(updateBody).length === 0) { - return { - success: false, - message: - 'At least one of sourceConfig, syncIntervalMinutes, or connectorStatus is required', - } + const updates = { + sourceConfig: args.sourceConfig, + syncIntervalMinutes: args.syncIntervalMinutes, + status: args.connectorStatus, } + const requestId = generateId().slice(0, 8) assertNotAborted() - const updateRes = await connectorApiCall( - context.userId, - `/api/knowledge/${kbId}/connectors/${args.connectorId}`, - 'PATCH', - updateBody - ) - - if (!updateRes.success) { - return { success: false, message: updateRes.error ?? 'Failed to update connector' } - } - - logger.info('Connector updated via copilot', { + // No `validateSourceConfig`: the agent has no requesting identity to + // resolve the connector's OAuth token with, so a replacement config is + // stored unvalidated and the next sync reports any problem with it. + const outcome = await performUpdateKnowledgeConnector({ + ...actor(requestId), + knowledgeBase: { + id: kbId, + name: writeAccess.knowledgeBase.name, + workspaceId: writeAccess.knowledgeBase.workspaceId ?? null, + }, connectorId: args.connectorId, - userId: context.userId, + updates, }) + if (!outcome.success) { + return { + success: false, + message: agentFacingError(outcome, 'Failed to update connector'), + } + } return { success: true, message: 'Connector updated successfully', - data: { id: args.connectorId, ...updateBody }, + data: { id: args.connectorId, ...filterUndefined(updates) }, } } @@ -1015,26 +1055,39 @@ export const knowledgeBaseServerTool: BaseServerTool 0 + ? `Connector deleted successfully. Its ${outcome.documentsKept} document(s) were kept in the knowledge base.` + : 'Connector deleted successfully.', + data: { + id: args.connectorId, + documentsKept: outcome.documentsKept, + documentsDeleted: outcome.documentsDeleted, + }, } } @@ -1064,23 +1117,24 @@ export const knowledgeBaseServerTool: BaseServerTool billingAttribution, }) + if (!outcome.success) { + return { + success: false, + message: agentFacingError(outcome, 'Failed to sync connector'), + } + } return { success: true, @@ -1111,42 +1165,6 @@ export const knowledgeBaseServerTool: BaseServerTool, - billingAttribution?: BillingAttributionSnapshot -): Promise<{ success: boolean; data?: any; error?: string }> { - const token = await generateInternalToken(userId) - const baseUrl = getInternalApiBaseUrl() - - const res = await fetch(`${baseUrl}${path}`, { - method, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - ...(billingAttribution - ? { - [BILLING_ATTRIBUTION_HEADER]: serializeBillingAttributionHeader(billingAttribution), - } - : {}), - }, - ...(body ? { body: JSON.stringify(body) } : {}), - }) - - const json = await res.json().catch(() => ({})) - - if (!res.ok) { - return { - success: false, - error: json.error || `API returned ${res.status}`, - } - } - - return { success: true, data: json.data } -} - async function resolveKnowledgeBaseId(connectorId: string): Promise { const rows = await db .select({ knowledgeBaseId: knowledgeConnector.knowledgeBaseId }) diff --git a/apps/sim/lib/core/orchestration/types.ts b/apps/sim/lib/core/orchestration/types.ts index 59ccf54aad6..eb42af6bf65 100644 --- a/apps/sim/lib/core/orchestration/types.ts +++ b/apps/sim/lib/core/orchestration/types.ts @@ -1,9 +1,16 @@ export type OrchestrationErrorCode = | 'validation' + /** + * The credentials this operation depends on are no longer usable — a stored + * third-party token that will not refresh, not an unauthenticated caller. + * Distinct from `forbidden`, which is the caller lacking permission. + */ + | 'unauthorized' | 'not_found' | 'forbidden' | 'conflict' | 'locked' + | 'payload_too_large' | 'internal' /** @@ -13,13 +20,32 @@ export type OrchestrationErrorCode = */ export function statusForOrchestrationError(code: OrchestrationErrorCode | undefined): number { if (code === 'validation') return 400 + if (code === 'unauthorized') return 401 if (code === 'forbidden') return 403 if (code === 'not_found') return 404 if (code === 'conflict') return 409 if (code === 'locked') return 423 + if (code === 'payload_too_large') return 413 return 500 } +/** + * The message a JSON route should render for an orchestration failure. + * + * A classified failure is caller-fixable, so its message is written for the + * caller and is safe to return. An unclassified one carries whatever text the + * fault happened to have — a driver's failed SQL, say — so the caller gets the + * route's own generic wording instead. `v2ErrorForOrchestration` applies the + * same rule for the v2 envelope. + */ +export function messageForOrchestrationError( + result: { error?: string; errorCode?: OrchestrationErrorCode }, + fallback: string +): string { + if (!result.errorCode || result.errorCode === 'internal') return fallback + return result.error ?? fallback +} + /** * A domain failure that already knows its own class. * diff --git a/apps/sim/lib/knowledge/constants.ts b/apps/sim/lib/knowledge/constants.ts index 84b9d8c9830..57ee50321be 100644 --- a/apps/sim/lib/knowledge/constants.ts +++ b/apps/sim/lib/knowledge/constants.ts @@ -1,6 +1,20 @@ /** Max character length for a knowledge base description, enforced at every layer (UI, internal API, v1 API). */ export const KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH = 10_000 +/** + * Chunking a knowledge base gets when its creator names no configuration. + * + * Applied in `lib/knowledge/orchestration` so the UI, the v1 and v2 APIs, and + * the copilot agent all index identical input identically. Previously each + * caller carried its own literal and the agent's `minSize` was 1, so the same + * document chunked differently depending on who uploaded it. + */ +export const DEFAULT_CHUNKING_CONFIG = { + maxSize: 1024, + minSize: 100, + overlap: 200, +} as const + export const TAG_SLOT_CONFIG = { text: { slots: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const, diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 1b8651a8424..9ca64bb6d4b 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -32,6 +32,7 @@ import { maybeNotifyStorageLimitForBillingContext, resolveStorageBillingContext, type StorageBillingContext, + StorageLimitExceededError, } from '@/lib/billing/storage' import { checkAndBillOverageThreshold, @@ -41,6 +42,7 @@ import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' import { resolveTriggerRegion } from '@/lib/core/async-jobs/region' import { env, envNumber } from '@/lib/core/config/env' import { getCostMultiplier, isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { processDocument } from '@/lib/knowledge/documents/document-processor' import { @@ -85,9 +87,9 @@ const logger = createLogger('DocumentService') * storage object that is not owned by the target knowledge base's workspace. * Routes map this to a 403. */ -export class KnowledgeBaseFileOwnershipError extends Error { +export class KnowledgeBaseFileOwnershipError extends OrchestrationError { constructor(public readonly storageKey: string) { - super('Document file is not owned by this knowledge base') + super('forbidden', 'Document file is not owned by this knowledge base') this.name = 'KnowledgeBaseFileOwnershipError' } } @@ -1052,7 +1054,7 @@ async function resolveDocumentStorageAdmission( .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) .limit(1) if (!kb) { - throw new Error('Knowledge base not found') + throw new OrchestrationError('not_found', 'Knowledge base not found') } if (bytes <= 0) { @@ -1064,7 +1066,7 @@ async function resolveDocumentStorageAdmission( const context = await resolveStorageBillingContext(kb.workspaceId) const quotaCheck = await checkStorageQuotaForBillingContext(context, bytes) if (!quotaCheck.allowed) { - throw new Error(quotaCheck.error || 'Storage limit exceeded') + throw new StorageLimitExceededError(quotaCheck.error || 'Storage limit exceeded') } return { workspaceId: kb.workspaceId, @@ -1078,7 +1080,7 @@ async function resolveDocumentStorageAdmission( getHighestPrioritySubscription(billedUserId), ]) if (!quotaCheck.allowed) { - throw new Error(quotaCheck.error || 'Storage limit exceeded') + throw new StorageLimitExceededError(quotaCheck.error || 'Storage limit exceeded') } return { workspaceId: null, @@ -1126,7 +1128,7 @@ export async function createDocumentRecords( .limit(1) if (kb.length === 0) { - throw new Error('Knowledge base not found') + throw new OrchestrationError('not_found', 'Knowledge base not found') } if ( @@ -1176,7 +1178,7 @@ export async function createDocumentRecords( preparedBilling.bytes ) if (!quotaCheck.allowed) { - throw new Error(quotaCheck.error || 'Storage limit exceeded') + throw new StorageLimitExceededError(quotaCheck.error || 'Storage limit exceeded') } } } @@ -1618,7 +1620,7 @@ export async function createSingleDocument( .limit(1) if (kb.length === 0) { - throw new Error('Knowledge base not found') + throw new OrchestrationError('not_found', 'Knowledge base not found') } if ( @@ -1665,7 +1667,7 @@ export async function createSingleDocument( preparedBilling.bytes ) if (!quotaCheck.allowed) { - throw new Error(quotaCheck.error || 'Storage limit exceeded') + throw new StorageLimitExceededError(quotaCheck.error || 'Storage limit exceeded') } } } diff --git a/apps/sim/lib/knowledge/folders.test.ts b/apps/sim/lib/knowledge/folders.test.ts index a81ebba83bf..009827d4a90 100644 --- a/apps/sim/lib/knowledge/folders.test.ts +++ b/apps/sim/lib/knowledge/folders.test.ts @@ -105,7 +105,7 @@ describe('createKnowledgeBase — folder assignment', () => { await expect( createKnowledgeBase({ ...CREATE_INPUT, folderId: 'f-1' }, 'req-1') - ).rejects.toMatchObject({ code: 'KNOWLEDGE_BASE_FORBIDDEN' }) + ).rejects.toMatchObject({ code: 'forbidden' }) expect(mockFindActiveFolder).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/knowledge/orchestration/connectors.test.ts b/apps/sim/lib/knowledge/orchestration/connectors.test.ts new file mode 100644 index 00000000000..68f7a10f242 --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/connectors.test.ts @@ -0,0 +1,298 @@ +/** + * @vitest-environment node + */ +import { document } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCaptureServerEvent, + mockDispatchSync, + mockHasWorkspaceLiveSyncAccess, + mockRecordAudit, +} = vi.hoisted(() => ({ + mockCaptureServerEvent: vi.fn(), + mockDispatchSync: vi.fn(), + mockHasWorkspaceLiveSyncAccess: vi.fn(), + mockRecordAudit: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + CONNECTOR_CREATED: 'connector.created', + CONNECTOR_UPDATED: 'connector.updated', + CONNECTOR_DELETED: 'connector.deleted', + CONNECTOR_SYNCED: 'connector.synced', + }, + AuditResourceType: { CONNECTOR: 'connector' }, + recordAudit: mockRecordAudit, +})) +vi.mock('@/lib/api-key/crypto', () => ({ encryptApiKey: vi.fn() })) +vi.mock('@/lib/billing/core/subscription', () => ({ + hasWorkspaceLiveSyncAccess: mockHasWorkspaceLiveSyncAccess, +})) +vi.mock('@/lib/knowledge/connectors/queue', () => ({ dispatchSync: mockDispatchSync })) +vi.mock('@/lib/knowledge/documents/service', () => ({ + deleteDocumentStorageFiles: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/lib/knowledge/tags/service', () => ({ + cleanupUnusedTagDefinitions: vi.fn().mockResolvedValue(undefined), + createTagDefinition: vi.fn(), +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) + +import { + performDeleteKnowledgeConnector, + performSyncKnowledgeConnector, + performUpdateKnowledgeConnector, +} from '@/lib/knowledge/orchestration/connectors' + +const KB = { id: 'kb-1', name: 'Docs', workspaceId: 'ws-1' } +const ACTOR = { userId: 'user-1', source: 'agent' as const, requestId: 'req-1' } +const BILLING = { actorUserId: 'user-1', workspaceId: 'ws-1' } as never +const resolveBillingAttribution = vi.fn().mockResolvedValue(BILLING) + +describe('performDeleteKnowledgeConnector', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(resetDbChainMock) + + it('reports the documents it kept, so the caller cannot claim otherwise', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + queueTableRows(document, [ + { id: 'doc-1', fileUrl: '/a.txt' }, + { id: 'doc-2', fileUrl: '/b.txt' }, + ]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'conn-1' }]) + + const outcome = await performDeleteKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + }) + + // The default keeps the documents. The copilot tool used to assert they had + // been removed while taking exactly this path. + expect(outcome).toMatchObject({ success: true, documentsKept: 2, documentsDeleted: 0 }) + expect(dbChainMockFns.delete).not.toHaveBeenCalledWith(document) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ deleteDocuments: false, documentsKept: 2 }), + }) + ) + }) + + it('reports the documents it deleted when asked to delete them', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + queueTableRows(document, [{ id: 'doc-1', fileUrl: '/a.txt' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'conn-1' }]) + + const outcome = await performDeleteKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + deleteDocuments: true, + }) + + expect(outcome).toMatchObject({ success: true, documentsDeleted: 1, documentsKept: 0 }) + }) + + it('reports a missing connector as not found', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + const outcome = await performDeleteKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'not_found' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) +}) + +describe('performUpdateKnowledgeConnector', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(resetDbChainMock) + + it('rejects an update that names nothing before reading the connector', async () => { + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + updates: {}, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('classifies a sub-hourly interval on an unentitled workspace as forbidden', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + mockHasWorkspaceLiveSyncAccess.mockResolvedValue(false) + + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + updates: { syncIntervalMinutes: 5 }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'forbidden' }) + expect(mockHasWorkspaceLiveSyncAccess).toHaveBeenCalledWith('ws-1') + }) + + it('leaves a caller-supplied validator to reject a bad source config', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + updates: { sourceConfig: { database: 'gone' } }, + validateSourceConfig: async () => ({ + message: 'Database not found', + errorCode: 'validation' as const, + }), + }) + + expect(outcome).toMatchObject({ + success: false, + errorCode: 'validation', + error: 'Database not found', + }) + }) + + it('preserves the failure class the validator chose', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + + // A stale stored credential kept the route's 401; collapsing every + // rejection to `validation` had flattened it (and the 409) into a 400. + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + updates: { sourceConfig: { database: 'x' } }, + validateSourceConfig: async () => ({ + message: 'Failed to refresh access token. Please reconnect your account.', + errorCode: 'unauthorized' as const, + }), + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'unauthorized' }) + }) + + it('clears the failure counters when a paused connector is resumed', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'active' }, + ]) + + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + updates: { status: 'active' }, + }) + + expect(outcome).toMatchObject({ success: true }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ consecutiveFailures: 0, lastSyncError: null }) + ) + }) +}) + +describe('performSyncKnowledgeConnector', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockDispatchSync.mockResolvedValue(undefined) + }) + + afterAll(resetDbChainMock) + + it('resolves the payer before writing the audit, not after', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'active' }, + ]) + const rejects = vi.fn().mockRejectedValue(new Error('billing attribution header is malformed')) + + const outcome = await performSyncKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + resolveBillingAttribution: rejects, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'internal' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockDispatchSync).not.toHaveBeenCalled() + }) + + it('refuses to stack a sync on one already running', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'syncing' }, + ]) + + const outcome = await performSyncKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + resolveBillingAttribution, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'conflict' }) + // A rejected request never pays for the payer lookup. + expect(resolveBillingAttribution).not.toHaveBeenCalled() + expect(mockDispatchSync).not.toHaveBeenCalled() + }) + + it('dispatches and records who asked for it', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'active' }, + ]) + + const outcome = await performSyncKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + resolveBillingAttribution, + rehydrate: true, + }) + + expect(outcome).toMatchObject({ success: true }) + expect(mockDispatchSync).toHaveBeenCalledWith('conn-1', { + billingAttribution: BILLING, + requestId: 'req-1', + rehydrate: true, + }) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'user-1', + metadata: expect.objectContaining({ syncType: 'manual-rehydrate' }), + }) + ) + }) + + it('rejects a knowledge base with no workspace to bill', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'active' }, + ]) + + const outcome = await performSyncKnowledgeConnector({ + ...ACTOR, + knowledgeBase: { ...KB, workspaceId: null }, + connectorId: 'conn-1', + resolveBillingAttribution, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'conflict' }) + }) +}) diff --git a/apps/sim/lib/knowledge/orchestration/connectors.ts b/apps/sim/lib/knowledge/orchestration/connectors.ts new file mode 100644 index 00000000000..01d18f5ccea --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/connectors.ts @@ -0,0 +1,735 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { + document, + embedding, + knowledgeBase, + knowledgeBaseTagDefinitions, + knowledgeConnector, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +import { encryptApiKey } from '@/lib/api-key/crypto' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { hasWorkspaceLiveSyncAccess } from '@/lib/billing/core/subscription' +import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { allocateTagSlots } from '@/lib/knowledge/constants' +import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service' +import { + auditActorFields, + classifyKnowledgeFailure, + fail, + type KnowledgeOperationContext, + type KnowledgeOrchestrationResult, +} from '@/lib/knowledge/orchestration/shared' +import { cleanupUnusedTagDefinitions, createTagDefinition } from '@/lib/knowledge/tags/service' +import { captureServerEvent } from '@/lib/posthog/server' + +const logger = createLogger('KnowledgeConnectorOrchestration') + +/** + * The connector registry and the sync queue are loaded on demand rather than + * imported at module scope. Both pull in every connector's SDK and the whole + * sync engine, and this module is re-exported from the knowledge orchestration + * barrel — a static edge would drag that graph into the bundle of every route + * that merely creates a knowledge base or uploads a document. + */ +async function loadDispatchSync() { + return (await import('@/lib/knowledge/connectors/queue')).dispatchSync +} + +/** A connector row exactly as stored, including its encrypted API key. */ +export type KnowledgeConnectorRow = typeof knowledgeConnector.$inferSelect +type ConnectorRow = KnowledgeConnectorRow +/** The connector row as it reaches every caller: never carrying the stored API key. */ +export type ConnectorWithoutSecret = Omit + +/** A refused `sourceConfig`, with the failure class the caller wants surfaced. */ +export interface SourceConfigRejection { + message: string + errorCode: OrchestrationErrorCode +} + +/** The knowledge base a connector operation targets, already authorized by the caller. */ +export interface ConnectorKnowledgeBase { + id: string + name: string + workspaceId: string | null +} + +function withoutSecret(row: ConnectorRow): ConnectorWithoutSecret { + const { encryptedApiKey: _encryptedApiKey, ...rest } = row + return rest +} + +/** + * Rejects a sub-hourly sync interval on a workspace without the plan for it. + * `0` disables scheduled syncs and is always allowed. + */ +async function assertLiveSyncAllowed( + workspaceId: string, + syncIntervalMinutes: number | undefined +): Promise { + if (syncIntervalMinutes === undefined || syncIntervalMinutes <= 0 || syncIntervalMinutes >= 60) { + return + } + if (!(await hasWorkspaceLiveSyncAccess(workspaceId))) { + throw new OrchestrationError('forbidden', 'Live sync requires a Max or Enterprise plan') + } +} + +export interface PerformCreateKnowledgeConnectorParams extends KnowledgeOperationContext { + knowledgeBase: ConnectorKnowledgeBase + connectorType: string + credentialId?: string + apiKey?: string + sourceConfig: Record + syncIntervalMinutes: number + /** + * Resolves the payer the sync is billed to. A thunk so a request rejected by + * a guard never pays for the lookup, and so the payer is read at the moment + * the sync is dispatched. + */ + resolveBillingAttribution: () => Promise + /** + * Resolves an OAuth credential to its access token. Supplied by the caller + * because credential lookup is scoped to the requesting identity. + */ + resolveAccessToken: (credentialId: string) => Promise +} + +export type PerformConnectorResult = KnowledgeOrchestrationResult<{ + connector: ConnectorWithoutSecret +}> + +/** + * Creates a connector on a knowledge base and dispatches its first sync. + * + * The tag-slot allocation and the connector insert share one transaction under + * the knowledge base's row lock, so a knowledge base archived mid-request can + * never end up with a live connector, and a partial slot allocation cannot + * outlive a failed insert. + */ +export async function performCreateKnowledgeConnector( + params: PerformCreateKnowledgeConnectorParams +): Promise { + const { + knowledgeBase: kb, + connectorType, + credentialId, + apiKey, + sourceConfig, + syncIntervalMinutes, + resolveBillingAttribution, + resolveAccessToken, + request, + source, + } = params + const requestId = params.requestId ?? generateRequestId() + + if (!kb.workspaceId) { + return fail('Knowledge base is missing workspace billing context', 'conflict') + } + const workspaceId = kb.workspaceId + + const { CONNECTOR_REGISTRY } = await import('@/connectors/registry.server') + const connectorConfig = CONNECTOR_REGISTRY[connectorType] + if (!connectorConfig) { + return fail(`Unknown connector type: ${connectorType}`, 'validation') + } + + try { + await assertLiveSyncAllowed(workspaceId, syncIntervalMinutes) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Create ${connectorType} connector`) + } + + let resolvedCredentialId: string | null = null + let resolvedEncryptedApiKey: string | null = null + let accessToken: string + + if (connectorConfig.auth.mode === 'apiKey') { + if (!apiKey) { + return fail('API key is required', 'validation') + } + accessToken = apiKey + } else { + if (!credentialId) { + return fail('Credential is required', 'validation') + } + let token: string | null + try { + token = await resolveAccessToken(credentialId) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Create ${connectorType} connector`) + } + if (!token) { + return fail('Credential has no access token. Please reconnect your account.', 'validation') + } + accessToken = token + resolvedCredentialId = credentialId + } + + const configValidation = await connectorConfig.validateConfig(accessToken, sourceConfig) + if (!configValidation.valid) { + return fail(configValidation.error || 'Invalid source configuration', 'validation') + } + + if (connectorConfig.auth.mode === 'apiKey' && apiKey) { + resolvedEncryptedApiKey = (await encryptApiKey(apiKey)).encrypted + } + + let finalSourceConfig: Record = { ...sourceConfig } + const tagSlotMapping: Record = {} + let newTagSlots: Record = {} + + if (connectorConfig.tagDefinitions?.length) { + const disabledIds = new Set((sourceConfig.disabledTagIds as string[] | undefined) ?? []) + const enabledDefs = connectorConfig.tagDefinitions.filter((td) => !disabledIds.has(td.id)) + + const existingDefs = await db + .select({ + tagSlot: knowledgeBaseTagDefinitions.tagSlot, + displayName: knowledgeBaseTagDefinitions.displayName, + fieldType: knowledgeBaseTagDefinitions.fieldType, + }) + .from(knowledgeBaseTagDefinitions) + .where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, kb.id)) + + const usedSlots = new Set(existingDefs.map((d) => d.tagSlot)) + const existingByName = new Map( + existingDefs.map((d) => [d.displayName, { tagSlot: d.tagSlot, fieldType: d.fieldType }]) + ) + + const defsNeedingSlots: typeof enabledDefs = [] + for (const td of enabledDefs) { + const existing = existingByName.get(td.displayName) + if (existing && existing.fieldType === td.fieldType) { + tagSlotMapping[td.id] = existing.tagSlot + } else { + defsNeedingSlots.push(td) + } + } + + const { mapping, skipped: skippedTags } = allocateTagSlots(defsNeedingSlots, usedSlots) + Object.assign(tagSlotMapping, mapping) + newTagSlots = mapping + + for (const name of skippedTags) { + logger.warn(`[${requestId}] No available slots for "${name}"`) + } + + if (skippedTags.length > 0 && Object.keys(tagSlotMapping).length === 0) { + return fail( + `No available tag slots. Could not assign: ${skippedTags.join(', ')}`, + 'validation' + ) + } + + finalSourceConfig = { ...finalSourceConfig, tagSlotMapping } + } + + // Resolved before the write, not after: every guard that can cheaply reject + // the request has already run, and `requireBillingAttributionHeader` throws on + // a malformed header. Resolving it post-commit would leave a live connector + // behind a 500 and let a retry create a duplicate. + let billingAttribution: BillingAttributionSnapshot + try { + billingAttribution = await resolveBillingAttribution() + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Create ${connectorType} connector`) + } + + const now = new Date() + const connectorId = generateId() + const nextSyncAt = + syncIntervalMinutes > 0 ? new Date(now.getTime() + syncIntervalMinutes * 60 * 1000) : null + + let created: ConnectorRow + try { + created = await db.transaction(async (tx) => { + await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${kb.id} FOR UPDATE`) + + const activeKb = await tx + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where(and(eq(knowledgeBase.id, kb.id), isNull(knowledgeBase.deletedAt))) + .limit(1) + + if (activeKb.length === 0) { + throw new OrchestrationError('not_found', 'Knowledge base not found') + } + + for (const [semanticId, slot] of Object.entries(newTagSlots)) { + const td = connectorConfig.tagDefinitions?.find((d) => d.id === semanticId) + if (!td) continue + await createTagDefinition( + { + knowledgeBaseId: kb.id, + tagSlot: slot, + displayName: td.displayName, + fieldType: td.fieldType, + }, + requestId, + tx + ) + } + + const [row] = await tx + .insert(knowledgeConnector) + .values({ + id: connectorId, + knowledgeBaseId: kb.id, + connectorType, + credentialId: resolvedCredentialId, + encryptedApiKey: resolvedEncryptedApiKey, + sourceConfig: finalSourceConfig, + syncIntervalMinutes, + status: 'active', + nextSyncAt, + createdAt: now, + updatedAt: now, + }) + .returning() + + return row + }) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Create ${connectorType} connector`) + } + + logger.info(`[${requestId}] Created connector ${connectorId} for KB ${kb.id}`) + + captureServerEvent( + params.userId, + 'knowledge_base_connector_added', + { + knowledge_base_id: kb.id, + workspace_id: workspaceId, + connector_type: connectorType, + sync_interval_minutes: syncIntervalMinutes, + }, + { + groups: { workspace: workspaceId }, + setOnce: { first_connector_added_at: new Date().toISOString() }, + } + ) + + recordAudit({ + workspaceId, + ...auditActorFields(params), + action: AuditAction.CONNECTOR_CREATED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: connectorId, + resourceName: connectorType, + description: `Created ${connectorType} connector for knowledge base "${kb.name}"`, + metadata: { + source, + knowledgeBaseId: kb.id, + knowledgeBaseName: kb.name, + connectorType, + syncIntervalMinutes, + authMode: connectorConfig.auth.mode, + }, + ...(request ? { request } : {}), + }) + + const dispatchSync = await loadDispatchSync() + dispatchSync(connectorId, { billingAttribution, requestId }).catch((error) => { + logger.error( + `[${requestId}] Failed to dispatch initial sync for connector ${connectorId}`, + error + ) + }) + + return { success: true, connector: withoutSecret(created) } +} + +export interface PerformUpdateKnowledgeConnectorParams extends KnowledgeOperationContext { + knowledgeBase: ConnectorKnowledgeBase + connectorId: string + updates: { + sourceConfig?: Record + syncIntervalMinutes?: number + status?: 'active' | 'paused' + } + /** + * Validates a replacement `sourceConfig` against the live source. Supplied by + * the caller because resolving the connector's token needs the requesting + * identity. Returning a rejection fails the update. + * + * The rejection carries its own `errorCode` so a stale credential and a bad + * config stay distinguishable — collapsing every rejection to `validation` + * flattened the route's 401 and 409 into a 400. + */ + validateSourceConfig?: ( + connector: KnowledgeConnectorRow, + sourceConfig: Record + ) => Promise +} + +/** Loads an active connector scoped to its knowledge base. */ +export async function getKnowledgeConnector( + knowledgeBaseId: string, + connectorId: string +): Promise { + const [row] = await db + .select() + .from(knowledgeConnector) + .where( + and( + eq(knowledgeConnector.id, connectorId), + eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + .limit(1) + + return row ?? null +} + +/** Applies a connector configuration change and records it against the actor. */ +export async function performUpdateKnowledgeConnector( + params: PerformUpdateKnowledgeConnectorParams +): Promise { + const { knowledgeBase: kb, connectorId, updates, validateSourceConfig, request, source } = params + const requestId = params.requestId ?? generateRequestId() + + const updatedFields = Object.keys(updates).filter( + (key) => updates[key as keyof typeof updates] !== undefined + ) + if (updatedFields.length === 0) { + return fail( + 'At least one of sourceConfig, syncIntervalMinutes, or status is required', + 'validation' + ) + } + + const existing = await getKnowledgeConnector(kb.id, connectorId) + if (!existing) { + return fail('Connector not found', 'not_found') + } + + if (updates.syncIntervalMinutes !== undefined) { + if (!kb.workspaceId && updates.syncIntervalMinutes > 0 && updates.syncIntervalMinutes < 60) { + return fail('Knowledge base is missing workspace billing context', 'conflict') + } + if (kb.workspaceId) { + try { + await assertLiveSyncAllowed(kb.workspaceId, updates.syncIntervalMinutes) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Update connector ${connectorId}`) + } + } + } + + if (updates.sourceConfig !== undefined && validateSourceConfig) { + const rejection = await validateSourceConfig(existing, updates.sourceConfig) + if (rejection) { + return fail(rejection.message, rejection.errorCode) + } + } + + const values: Partial = { updatedAt: new Date() } + if (updates.sourceConfig !== undefined) { + values.sourceConfig = updates.sourceConfig + } + if (updates.syncIntervalMinutes !== undefined) { + values.syncIntervalMinutes = updates.syncIntervalMinutes + values.nextSyncAt = + updates.syncIntervalMinutes > 0 + ? new Date(Date.now() + updates.syncIntervalMinutes * 60 * 1000) + : null + } + if (updates.status !== undefined) { + values.status = updates.status + if (updates.status === 'active') { + values.consecutiveFailures = 0 + values.lastSyncError = null + // Resuming a paused connector syncs immediately unless this same request + // set a schedule, which then owns the next run. + if (values.nextSyncAt === undefined) { + values.nextSyncAt = new Date() + } + } + } + + let updated: ConnectorRow + try { + const [row] = await db + .update(knowledgeConnector) + .set(values) + .where( + and( + eq(knowledgeConnector.id, connectorId), + eq(knowledgeConnector.knowledgeBaseId, kb.id), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + .returning() + + if (!row) { + return fail('Connector not found', 'not_found') + } + updated = row + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Update connector ${connectorId}`) + } + + recordAudit({ + workspaceId: kb.workspaceId, + ...auditActorFields(params), + action: AuditAction.CONNECTOR_UPDATED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: connectorId, + resourceName: updated.connectorType, + description: `Updated connector for knowledge base "${kb.name}"`, + metadata: { + source, + knowledgeBaseId: kb.id, + knowledgeBaseName: kb.name, + connectorType: updated.connectorType, + updatedFields, + ...(updates.syncIntervalMinutes !== undefined && { + syncIntervalMinutes: updates.syncIntervalMinutes, + }), + ...(updates.status !== undefined && { newStatus: updates.status }), + }, + ...(request ? { request } : {}), + }) + + return { success: true, connector: withoutSecret(updated) } +} + +export interface PerformDeleteKnowledgeConnectorParams extends KnowledgeOperationContext { + knowledgeBase: ConnectorKnowledgeBase + connectorId: string + /** + * Also hard-delete the documents the connector produced. Defaults to keeping + * them, which turns them into ordinary standalone knowledge base entries. + */ + deleteDocuments?: boolean +} + +/** What actually happened to the connector's documents, for the caller to report. */ +export type PerformDeleteKnowledgeConnectorResult = KnowledgeOrchestrationResult<{ + documentsDeleted: number + documentsKept: number +}> + +/** + * Hard-deletes a connector, either removing the documents it produced or + * releasing them as standalone entries. + * + * Returns the counts so callers state what happened rather than assert it. The + * copilot tool used to reach this through an internal HTTP self-call that sent + * no query string, so it always took the keep-documents default while telling + * the user the documents had been removed. + */ +export async function performDeleteKnowledgeConnector( + params: PerformDeleteKnowledgeConnectorParams +): Promise { + const { knowledgeBase: kb, connectorId, request, source } = params + const deleteDocuments = params.deleteDocuments ?? false + const requestId = params.requestId ?? generateRequestId() + + const existing = await getKnowledgeConnector(kb.id, connectorId) + if (!existing) { + return fail('Connector not found', 'not_found') + } + + let deletedDocs: Array<{ id: string; fileUrl: string }> + let docCount: number + try { + ;({ deletedDocs, docCount } = await db.transaction(async (tx) => { + await tx.execute(sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE`) + + // Includes pending-removal (tombstoned) docs — the connector is being + // deleted, so there's no future sync left to confirm or resurrect them. + const docs = await tx + .select({ id: document.id, fileUrl: document.fileUrl }) + .from(document) + .where(and(eq(document.connectorId, connectorId), isNull(document.archivedAt))) + + const documentIds = docs.map((doc) => doc.id) + if (deleteDocuments) { + if (documentIds.length > 0) { + await tx.delete(embedding).where(inArray(embedding.documentId, documentIds)) + await tx.delete(document).where(inArray(document.id, documentIds)) + } + } else if (documentIds.length > 0) { + // Kept documents become normal standalone KB entries once their connector + // is gone — resurrect any pending-removal ones rather than leaving them + // invisible tombstones with no future sync left to ever confirm or + // resurrect them. + await tx.update(document).set({ deletedAt: null }).where(inArray(document.id, documentIds)) + } + + const deletedConnectors = await tx + .delete(knowledgeConnector) + .where( + and( + eq(knowledgeConnector.id, connectorId), + eq(knowledgeConnector.knowledgeBaseId, kb.id), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + .returning({ id: knowledgeConnector.id }) + + if (deletedConnectors.length === 0) { + throw new OrchestrationError('not_found', 'Connector not found') + } + + return { deletedDocs: deleteDocuments ? docs : [], docCount: docs.length } + })) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Delete connector ${connectorId}`) + } + + if (deleteDocuments) { + await Promise.all([ + deletedDocs.length > 0 + ? deleteDocumentStorageFiles( + deletedDocs.map((doc) => ({ ...doc, workspaceId: kb.workspaceId })), + requestId + ) + : Promise.resolve(), + cleanupUnusedTagDefinitions(kb.id, requestId).catch((error) => { + logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error) + }), + ]) + } + + logger.info( + `[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}` + ) + + captureServerEvent( + params.userId, + 'knowledge_base_connector_removed', + { + knowledge_base_id: kb.id, + workspace_id: kb.workspaceId ?? '', + connector_type: existing.connectorType, + documents_deleted: deleteDocuments ? docCount : 0, + }, + kb.workspaceId ? { groups: { workspace: kb.workspaceId } } : undefined + ) + + recordAudit({ + workspaceId: kb.workspaceId, + ...auditActorFields(params), + action: AuditAction.CONNECTOR_DELETED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: connectorId, + resourceName: existing.connectorType, + description: `Deleted connector from knowledge base "${kb.name}"`, + metadata: { + source, + knowledgeBaseId: kb.id, + knowledgeBaseName: kb.name, + connectorType: existing.connectorType, + deleteDocuments, + documentsDeleted: deleteDocuments ? docCount : 0, + documentsKept: deleteDocuments ? 0 : docCount, + }, + ...(request ? { request } : {}), + }) + + return { + success: true, + documentsDeleted: deleteDocuments ? docCount : 0, + documentsKept: deleteDocuments ? 0 : docCount, + } +} + +export interface PerformSyncKnowledgeConnectorParams extends KnowledgeOperationContext { + knowledgeBase: ConnectorKnowledgeBase + connectorId: string + /** + * Resolves the payer the sync is billed to. A thunk so a request rejected by + * a guard never pays for the lookup. + */ + resolveBillingAttribution: () => Promise + /** Re-fetch and re-index every already-synced document, not only changed ones. */ + rehydrate?: boolean +} + +export type PerformSyncKnowledgeConnectorResult = KnowledgeOrchestrationResult + +/** Triggers a manual sync for a connector and records who asked for it. */ +export async function performSyncKnowledgeConnector( + params: PerformSyncKnowledgeConnectorParams +): Promise { + const { knowledgeBase: kb, connectorId, resolveBillingAttribution, request, source } = params + const rehydrate = params.rehydrate ?? false + const requestId = params.requestId ?? generateRequestId() + + const connector = await getKnowledgeConnector(kb.id, connectorId) + if (!connector) { + return fail('Connector not found', 'not_found') + } + if (connector.status === 'syncing') { + return fail('Sync already in progress', 'conflict') + } + if (!kb.workspaceId) { + return fail('Knowledge base is missing workspace billing context', 'conflict') + } + // Resolved before the audit is written, so a rejected payer lookup returns a + // classified failure rather than escaping as a 500 with a sync already recorded. + let billingAttribution: BillingAttributionSnapshot + try { + billingAttribution = await resolveBillingAttribution() + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Sync connector ${connectorId}`) + } + + logger.info( + `[${requestId}] Manual sync${rehydrate ? ' (full rehydrate)' : ''} triggered for connector ${connectorId}` + ) + + captureServerEvent( + params.userId, + 'knowledge_base_connector_synced', + { + knowledge_base_id: kb.id, + workspace_id: kb.workspaceId ?? '', + connector_type: connector.connectorType, + }, + kb.workspaceId ? { groups: { workspace: kb.workspaceId } } : undefined + ) + + recordAudit({ + workspaceId: kb.workspaceId, + ...auditActorFields(params), + action: AuditAction.CONNECTOR_SYNCED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: connectorId, + resourceName: connector.connectorType, + description: `Triggered manual sync for connector on knowledge base "${kb.name}"`, + metadata: { + source, + knowledgeBaseId: kb.id, + knowledgeBaseName: kb.name, + connectorType: connector.connectorType, + connectorStatus: connector.status, + syncType: rehydrate ? 'manual-rehydrate' : 'manual', + }, + ...(request ? { request } : {}), + }) + + const dispatchSync = await loadDispatchSync() + dispatchSync(connectorId, { billingAttribution, requestId, rehydrate }).catch((error) => { + logger.error( + `[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`, + error + ) + }) + + return { success: true } +} diff --git a/apps/sim/lib/knowledge/orchestration/documents.test.ts b/apps/sim/lib/knowledge/orchestration/documents.test.ts new file mode 100644 index 00000000000..bfbe1bdc403 --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/documents.test.ts @@ -0,0 +1,329 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCaptureServerEvent, + mockCreateDocumentRecords, + mockCreateSingleDocument, + mockDeleteDocument, + mockMarkDocumentAsFailedTimeout, + mockProcessDocumentAsync, + mockProcessDocumentsWithQueue, + mockRecordAudit, + mockRetryDocumentProcessing, + mockUpdateDocument, +} = vi.hoisted(() => ({ + mockCaptureServerEvent: vi.fn(), + mockCreateDocumentRecords: vi.fn(), + mockCreateSingleDocument: vi.fn(), + mockDeleteDocument: vi.fn(), + mockMarkDocumentAsFailedTimeout: vi.fn(), + mockProcessDocumentAsync: vi.fn(), + mockProcessDocumentsWithQueue: vi.fn(), + mockRecordAudit: vi.fn(), + mockRetryDocumentProcessing: vi.fn(), + mockUpdateDocument: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + DOCUMENT_UPLOADED: 'document.uploaded', + DOCUMENT_UPDATED: 'document.updated', + DOCUMENT_DELETED: 'document.deleted', + }, + AuditResourceType: { DOCUMENT: 'document' }, + recordAudit: mockRecordAudit, +})) +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { knowledgeBaseDocumentsUploaded: vi.fn() }, +})) +vi.mock('@/lib/knowledge/documents/service', () => ({ + createDocumentRecords: mockCreateDocumentRecords, + createSingleDocument: mockCreateSingleDocument, + deleteDocument: mockDeleteDocument, + markDocumentAsFailedTimeout: mockMarkDocumentAsFailedTimeout, + processDocumentAsync: mockProcessDocumentAsync, + processDocumentsWithQueue: mockProcessDocumentsWithQueue, + retryDocumentProcessing: mockRetryDocumentProcessing, + updateDocument: mockUpdateDocument, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + performDeleteKnowledgeDocument, + performMarkKnowledgeDocumentTimedOut, + performRetryKnowledgeDocumentProcessing, + performUpdateKnowledgeDocument, + performUploadKnowledgeDocument, + performUploadKnowledgeDocuments, +} from '@/lib/knowledge/orchestration/documents' + +const KB = { id: 'kb-1', name: 'Docs', workspaceId: 'ws-1' } +const FILE = { + filename: 'report.pdf', + fileUrl: 'https://storage/report.pdf', + fileSize: 1024, + mimeType: 'application/pdf', +} +const ACTOR = { userId: 'user-1', source: 'agent' as const, requestId: 'req-1' } + +describe('performUploadKnowledgeDocument', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreateSingleDocument.mockResolvedValue({ id: 'doc-1', filename: 'report.pdf' }) + mockProcessDocumentsWithQueue.mockResolvedValue(undefined) + mockProcessDocumentAsync.mockResolvedValue(undefined) + }) + + it('audits an agent upload, which the copilot path never did', async () => { + const outcome = await performUploadKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: FILE, + }) + + expect(outcome).toMatchObject({ success: true }) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'user-1', + resourceId: 'doc-1', + resourceName: 'report.pdf', + metadata: expect.objectContaining({ source: 'agent', knowledgeBaseId: 'kb-1' }), + }) + ) + }) + + it('records the document owner the caller names, not the acting user', async () => { + // A workspace API key bills and owns as the workspace account, while the + // acting user stays the audit actor. + await performUploadKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: FILE, + uploadedBy: 'workspace-owner', + }) + + expect(mockCreateSingleDocument).toHaveBeenCalledWith(FILE, 'kb-1', 'req-1', 'workspace-owner') + }) + + it('starts no indexing unless the caller asks for it', async () => { + await performUploadKnowledgeDocument({ ...ACTOR, knowledgeBase: KB, document: FILE }) + + expect(mockProcessDocumentsWithQueue).not.toHaveBeenCalled() + expect(mockProcessDocumentAsync).not.toHaveBeenCalled() + }) + + it.each([ + { startProcessing: 'queue' as const, expected: mockProcessDocumentsWithQueue }, + { startProcessing: 'async' as const, expected: mockProcessDocumentAsync }, + ])('hands the record to the $startProcessing pipeline', async ({ startProcessing, expected }) => { + await performUploadKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: FILE, + startProcessing, + }) + + expect(expected).toHaveBeenCalled() + }) + + it('classifies a storage-quota rejection as too large, by class not message', async () => { + mockCreateSingleDocument.mockRejectedValue( + new OrchestrationError('payload_too_large', 'Storage limit exceeded. Used: 5.10GB') + ) + + const outcome = await performUploadKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: FILE, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'payload_too_large' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('classifies a foreign file reference as forbidden', async () => { + mockCreateSingleDocument.mockRejectedValue( + new OrchestrationError('forbidden', 'Document file is not owned by this knowledge base') + ) + + expect( + (await performUploadKnowledgeDocument({ ...ACTOR, knowledgeBase: KB, document: FILE })) + .errorCode + ).toBe('forbidden') + }) +}) + +describe('performUploadKnowledgeDocuments', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreateDocumentRecords.mockResolvedValue([ + { documentId: 'doc-1', filename: 'a.pdf' }, + { documentId: 'doc-2', filename: 'b.pdf' }, + ]) + mockProcessDocumentsWithQueue.mockResolvedValue(undefined) + }) + + it('admits the whole batch in one call and queues it', async () => { + const outcome = await performUploadKnowledgeDocuments({ + ...ACTOR, + knowledgeBase: KB, + documents: [FILE, { ...FILE, filename: 'b.pdf' }], + }) + + expect(outcome).toMatchObject({ success: true }) + expect(mockCreateDocumentRecords).toHaveBeenCalledTimes(1) + expect(mockProcessDocumentsWithQueue).toHaveBeenCalledTimes(1) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ resourceName: '2 document(s)' }) + ) + }) + + it('rejects an empty batch before touching the service', async () => { + const outcome = await performUploadKnowledgeDocuments({ + ...ACTOR, + knowledgeBase: KB, + documents: [], + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockCreateDocumentRecords).not.toHaveBeenCalled() + }) +}) + +describe('performUpdateKnowledgeDocument', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUpdateDocument.mockResolvedValue({ id: 'doc-1', filename: 'renamed.pdf' }) + }) + + it('rejects an update that names nothing before touching the service', async () => { + const outcome = await performUpdateKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: { id: 'doc-1', filename: 'report.pdf' }, + updates: { filename: undefined, enabled: undefined }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockUpdateDocument).not.toHaveBeenCalled() + }) + + it('names the changed fields in the audit metadata', async () => { + await performUpdateKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: { id: 'doc-1', filename: 'report.pdf' }, + updates: { filename: 'renamed.pdf', enabled: false }, + }) + + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + resourceName: 'renamed.pdf', + metadata: expect.objectContaining({ + updatedFields: ['filename', 'enabled'], + enabled: false, + }), + }) + ) + }) +}) + +describe('performDeleteKnowledgeDocument', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDeleteDocument.mockResolvedValue({ success: true, message: 'ok' }) + }) + + it('audits the deletion against the acting user', async () => { + const outcome = await performDeleteKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: { id: 'doc-1', filename: 'report.pdf', fileSize: 10, mimeType: 'application/pdf' }, + }) + + expect(outcome).toMatchObject({ success: true }) + expect(mockDeleteDocument).toHaveBeenCalledWith('doc-1', 'req-1') + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ actorId: 'user-1', resourceId: 'doc-1' }) + ) + expect(mockCaptureServerEvent).toHaveBeenCalled() + }) + + it('emits no telemetry when the delete fails', async () => { + mockDeleteDocument.mockRejectedValue(new Error('deadlock detected')) + + const outcome = await performDeleteKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: { id: 'doc-1', filename: 'report.pdf' }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'internal' }) + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) +}) + +describe('document processing state changes', () => { + beforeEach(() => vi.clearAllMocks()) + + it('refuses to time out a document that is not processing', async () => { + const outcome = await performMarkKnowledgeDocumentTimedOut({ + document: { id: 'doc-1', processingStatus: 'completed', processingStartedAt: new Date() }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockMarkDocumentAsFailedTimeout).not.toHaveBeenCalled() + }) + + it('refuses to time out a document with no processing start time', async () => { + const outcome = await performMarkKnowledgeDocumentTimedOut({ + document: { id: 'doc-1', processingStatus: 'processing', processingStartedAt: null }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + }) + + it('surfaces a too-soon timeout as caller-fixable, not a fault', async () => { + mockMarkDocumentAsFailedTimeout.mockRejectedValue( + new Error('Document has not been processing long enough to be considered dead') + ) + + const outcome = await performMarkKnowledgeDocumentTimedOut({ + document: { id: 'doc-1', processingStatus: 'processing', processingStartedAt: new Date() }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + }) + + it('refuses to retry a document that has not failed', async () => { + const outcome = await performRetryKnowledgeDocumentProcessing({ + knowledgeBaseId: 'kb-1', + document: { ...FILE, id: 'doc-1', processingStatus: 'completed' }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockRetryDocumentProcessing).not.toHaveBeenCalled() + }) + + it('re-queues a failed document and never audits it', async () => { + mockRetryDocumentProcessing.mockResolvedValue({ + success: true, + status: 'pending', + message: 'Document retry processing started', + }) + + const outcome = await performRetryKnowledgeDocumentProcessing({ + knowledgeBaseId: 'kb-1', + document: { ...FILE, id: 'doc-1', processingStatus: 'failed' }, + requestId: 'req-1', + }) + + expect(outcome).toMatchObject({ success: true, status: 'pending' }) + // No document state the user chose changes, so there is nothing to record. + expect(mockRecordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/orchestration/documents.ts b/apps/sim/lib/knowledge/orchestration/documents.ts new file mode 100644 index 00000000000..d0111d53289 --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/documents.ts @@ -0,0 +1,486 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { PlatformEvents } from '@/lib/core/telemetry' +import { generateRequestId } from '@/lib/core/utils/request' +import { + createDocumentRecords, + createSingleDocument, + type DocumentData, + deleteDocument, + markDocumentAsFailedTimeout, + type ProcessingOptions, + processDocumentAsync, + processDocumentsWithQueue, + retryDocumentProcessing, + updateDocument, +} from '@/lib/knowledge/documents/service' +import { + auditActorFields, + classifyKnowledgeFailure, + fail, + type KnowledgeOperationContext, + type KnowledgeOrchestrationResult, +} from '@/lib/knowledge/orchestration/shared' +import { captureServerEvent } from '@/lib/posthog/server' + +const logger = createLogger('KnowledgeDocumentOrchestration') + +/** The knowledge base a document operation targets, already authorized by the caller. */ +export interface KnowledgeBaseTarget { + id: string + name?: string | null + workspaceId: string | null +} + +export interface KnowledgeDocumentInput { + filename: string + fileUrl: string + fileSize: number + mimeType: string + documentTagsData?: string + tag1?: string + tag2?: string + tag3?: string + tag4?: string + tag5?: string + tag6?: string + tag7?: string +} + +/** + * How the created record is handed to the indexing pipeline. + * + * `queue` runs the shared bounded-concurrency queue; `async` starts one + * detached processing run. Omitted starts nothing — the internal single-document + * route deliberately only creates the row and leaves indexing to its caller. + */ +export type KnowledgeDocumentProcessing = 'queue' | 'async' + +export type CreatedKnowledgeDocument = Awaited> + +export interface PerformUploadKnowledgeDocumentParams extends KnowledgeOperationContext { + knowledgeBase: KnowledgeBaseTarget + document: KnowledgeDocumentInput + startProcessing?: KnowledgeDocumentProcessing + processingOptions?: ProcessingOptions + billingAttribution?: BillingAttributionSnapshot + /** Row owner recorded on the document; defaults to the acting user. */ + uploadedBy?: string | null +} + +export type PerformUploadKnowledgeDocumentResult = KnowledgeOrchestrationResult<{ + document: CreatedKnowledgeDocument +}> + +function auditUpload( + params: KnowledgeOperationContext & { knowledgeBase: KnowledgeBaseTarget }, + entry: { resourceId: string; resourceName: string; description: string; metadata: object } +) { + recordAudit({ + workspaceId: params.knowledgeBase.workspaceId, + ...auditActorFields(params), + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: entry.resourceId, + resourceName: entry.resourceName, + description: entry.description, + metadata: { + source: params.source, + knowledgeBaseId: params.knowledgeBase.id, + knowledgeBaseName: params.knowledgeBase.name, + ...entry.metadata, + }, + ...(params.request ? { request: params.request } : {}), + }) +} + +function captureUpload( + params: KnowledgeOperationContext & { knowledgeBase: KnowledgeBaseTarget }, + documentCount: number, + uploadType: 'single' | 'bulk' +) { + const workspaceId = params.knowledgeBase.workspaceId + captureServerEvent( + params.userId, + 'knowledge_base_document_uploaded', + { + knowledge_base_id: params.knowledgeBase.id, + workspace_id: workspaceId ?? '', + document_count: documentCount, + upload_type: uploadType, + }, + { + ...(workspaceId ? { groups: { workspace: workspaceId } } : {}), + setOnce: { first_document_uploaded_at: new Date().toISOString() }, + } + ) +} + +/** + * Adds one already-resolved file to a knowledge base. + * + * Each surface acquires the file differently — a multipart body uploaded to + * workspace storage, a virtual-filesystem reference resolved to a presigned URL, + * a client-supplied URL — so acquisition stays with the caller and this takes + * the resolved `{filename, fileUrl, fileSize, mimeType}`. Everything downstream + * of that (the record, the indexing hand-off, telemetry, and the audit) is + * identical for all of them, which is why the copilot path used to index + * documents that appear nowhere in the audit log. + */ +export async function performUploadKnowledgeDocument( + params: PerformUploadKnowledgeDocumentParams +): Promise { + const { knowledgeBase, document, startProcessing, processingOptions, billingAttribution } = params + const requestId = params.requestId ?? generateRequestId() + + let created: CreatedKnowledgeDocument + try { + created = await createSingleDocument( + document, + knowledgeBase.id, + requestId, + params.uploadedBy ?? params.userId + ) + } catch (error) { + return classifyKnowledgeFailure( + error, + requestId, + `Upload document "${document.filename}" to knowledge base ${knowledgeBase.id}` + ) + } + + const documentData: DocumentData = { + documentId: created.id, + filename: document.filename, + fileUrl: document.fileUrl, + fileSize: document.fileSize, + mimeType: document.mimeType, + } + + if (startProcessing === 'queue') { + processDocumentsWithQueue( + [documentData], + knowledgeBase.id, + processingOptions ?? {}, + requestId, + billingAttribution + ).catch((error: unknown) => { + logger.error(`[${requestId}] Document processing pipeline failed`, { error }) + }) + } else if (startProcessing === 'async') { + processDocumentAsync( + knowledgeBase.id, + created.id, + document, + processingOptions ?? {}, + billingAttribution + ).catch((error: unknown) => { + logger.error(`[${requestId}] Background document processing failed`, { + documentId: created.id, + error: toError(error).message, + }) + }) + } + + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId: knowledgeBase.id, + documentsCount: 1, + uploadType: 'single', + mimeType: document.mimeType, + fileSize: document.fileSize, + }) + captureUpload(params, 1, 'single') + + auditUpload(params, { + resourceId: created.id, + resourceName: document.filename, + description: `Uploaded document "${document.filename}" to knowledge base "${knowledgeBase.name ?? knowledgeBase.id}"`, + metadata: { + fileName: document.filename, + fileType: document.mimeType, + fileSize: document.fileSize, + }, + }) + + return { success: true, document: created } +} + +export interface PerformUploadKnowledgeDocumentsParams extends KnowledgeOperationContext { + knowledgeBase: KnowledgeBaseTarget + documents: KnowledgeDocumentInput[] + processingOptions?: ProcessingOptions + billingAttribution?: BillingAttributionSnapshot + uploadedBy?: string | null +} + +export type PerformUploadKnowledgeDocumentsResult = KnowledgeOrchestrationResult<{ + documents: DocumentData[] +}> + +/** + * Adds many files to a knowledge base in one storage admission and hands the + * whole set to the bounded-concurrency processing queue. + * + * Kept separate from the single-document path because `createDocumentRecords` + * admits the batch's bytes as one unit; running it per document would let a set + * that exceeds the quota commit its first half. + */ +export async function performUploadKnowledgeDocuments( + params: PerformUploadKnowledgeDocumentsParams +): Promise { + const { knowledgeBase, documents, processingOptions, billingAttribution } = params + const requestId = params.requestId ?? generateRequestId() + + if (documents.length === 0) { + return fail('No documents specified', 'validation') + } + + let created: DocumentData[] + try { + created = await createDocumentRecords( + documents, + knowledgeBase.id, + requestId, + params.uploadedBy ?? params.userId + ) + } catch (error) { + return classifyKnowledgeFailure( + error, + requestId, + `Upload ${documents.length} document(s) to knowledge base ${knowledgeBase.id}` + ) + } + + logger.info(`[${requestId}] Starting controlled async processing of ${created.length} documents`) + + processDocumentsWithQueue( + created, + knowledgeBase.id, + processingOptions ?? {}, + requestId, + billingAttribution + ).catch((error: unknown) => { + logger.error(`[${requestId}] Critical error in document processing pipeline`, { error }) + }) + + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId: knowledgeBase.id, + documentsCount: created.length, + uploadType: 'bulk', + recipe: processingOptions?.recipe, + }) + captureUpload(params, created.length, 'bulk') + + auditUpload(params, { + resourceId: knowledgeBase.id, + resourceName: `${created.length} document(s)`, + description: `Uploaded ${created.length} document(s) to knowledge base "${knowledgeBase.name ?? knowledgeBase.id}"`, + metadata: { fileCount: created.length }, + }) + + return { success: true, documents: created } +} + +export interface PerformUpdateKnowledgeDocumentParams extends KnowledgeOperationContext { + knowledgeBase: KnowledgeBaseTarget + document: { id: string; filename: string } + updates: Parameters[1] +} + +export type PerformUpdateKnowledgeDocumentResult = KnowledgeOrchestrationResult<{ + document: Awaited> +}> + +/** Renames a document, toggles it, or edits its tags, and records the change. */ +export async function performUpdateKnowledgeDocument( + params: PerformUpdateKnowledgeDocumentParams +): Promise { + const { knowledgeBase, document, updates, request, source } = params + const requestId = params.requestId ?? generateRequestId() + + const updatedFields = Object.keys(updates).filter( + (key) => updates[key as keyof typeof updates] !== undefined + ) + if (updatedFields.length === 0) { + return fail('No updates specified', 'validation') + } + + let updated: Awaited> + try { + updated = await updateDocument(document.id, updates, requestId) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Update document ${document.id}`) + } + + const filename = updates.filename ?? document.filename + + recordAudit({ + workspaceId: knowledgeBase.workspaceId, + ...auditActorFields(params), + action: AuditAction.DOCUMENT_UPDATED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: document.id, + resourceName: filename, + description: `Updated document "${filename}" in knowledge base "${knowledgeBase.name ?? knowledgeBase.id}"`, + metadata: { + source, + knowledgeBaseId: knowledgeBase.id, + knowledgeBaseName: knowledgeBase.name, + fileName: filename, + updatedFields, + ...(updates.enabled !== undefined && { enabled: updates.enabled }), + }, + ...(request ? { request } : {}), + }) + + return { success: true, document: updated } +} + +export interface PerformDeleteKnowledgeDocumentParams extends KnowledgeOperationContext { + knowledgeBase: KnowledgeBaseTarget + document: { id: string; filename: string; fileSize?: number; mimeType?: string } +} + +export type PerformDeleteKnowledgeDocumentResult = KnowledgeOrchestrationResult + +/** Deletes a document and its embeddings, and records the deletion. */ +export async function performDeleteKnowledgeDocument( + params: PerformDeleteKnowledgeDocumentParams +): Promise { + const { knowledgeBase, document, request, source } = params + const requestId = params.requestId ?? generateRequestId() + + try { + await deleteDocument(document.id, requestId) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Delete document ${document.id}`) + } + + logger.info( + `[${requestId}] Deleted document ${document.id} from knowledge base ${knowledgeBase.id}` + ) + + recordAudit({ + workspaceId: knowledgeBase.workspaceId, + ...auditActorFields(params), + action: AuditAction.DOCUMENT_DELETED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: document.id, + resourceName: document.filename, + description: `Deleted document "${document.filename}" from knowledge base "${knowledgeBase.name ?? knowledgeBase.id}"`, + metadata: { + source, + knowledgeBaseId: knowledgeBase.id, + knowledgeBaseName: knowledgeBase.name, + fileName: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + }, + ...(request ? { request } : {}), + }) + + const workspaceId = knowledgeBase.workspaceId + captureServerEvent( + params.userId, + 'knowledge_base_document_deleted', + { knowledge_base_id: knowledgeBase.id, workspace_id: workspaceId ?? '' }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + + return { success: true } +} + +export interface PerformMarkKnowledgeDocumentTimedOutParams { + document: { + id: string + processingStatus: string + processingStartedAt?: Date | null + } + requestId?: string +} + +export type PerformKnowledgeDocumentProcessingResult = KnowledgeOrchestrationResult<{ + status: string + message: string +}> + +/** + * Marks a document whose processing run died as failed. Not audited: the state + * change is the system conceding a run it lost, not a user editing a document. + */ +export async function performMarkKnowledgeDocumentTimedOut( + params: PerformMarkKnowledgeDocumentTimedOutParams +): Promise { + const { document } = params + const requestId = params.requestId ?? generateRequestId() + + if (document.processingStatus !== 'processing') { + return fail( + `Document is not in processing state (current: ${document.processingStatus})`, + 'validation' + ) + } + if (!document.processingStartedAt) { + return fail('Document has no processing start time', 'validation') + } + + try { + await markDocumentAsFailedTimeout(document.id, document.processingStartedAt, requestId) + } catch (error) { + // The service rejects a document that has not been processing long enough + // to be presumed dead; that is a caller-fixable "try again later", not a fault. + if (!(error instanceof OrchestrationError)) { + return fail(toError(error).message, 'validation') + } + return classifyKnowledgeFailure(error, requestId, `Time out document ${document.id}`) + } + + return { success: true, status: 'failed', message: 'Document marked as failed due to timeout' } +} + +export interface PerformRetryKnowledgeDocumentParams { + knowledgeBaseId: string + document: { + id: string + filename: string + fileUrl: string + fileSize: number + mimeType: string + processingStatus: string + } + billingAttribution?: BillingAttributionSnapshot + requestId?: string +} + +/** Re-queues a failed document for indexing. Not audited: no document state a user chose changes. */ +export async function performRetryKnowledgeDocumentProcessing( + params: PerformRetryKnowledgeDocumentParams +): Promise { + const { knowledgeBaseId, document, billingAttribution } = params + const requestId = params.requestId ?? generateRequestId() + + if (document.processingStatus !== 'failed') { + return fail('Document is not in failed state', 'validation') + } + + try { + const result = await retryDocumentProcessing( + knowledgeBaseId, + document.id, + { + filename: document.filename, + fileUrl: document.fileUrl, + fileSize: document.fileSize, + mimeType: document.mimeType, + }, + requestId, + billingAttribution + ) + return { success: true, status: result.status, message: result.message } + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Retry document ${document.id}`) + } +} diff --git a/apps/sim/lib/knowledge/orchestration/index.ts b/apps/sim/lib/knowledge/orchestration/index.ts index dc44ac10ee9..2f9d033a355 100644 --- a/apps/sim/lib/knowledge/orchestration/index.ts +++ b/apps/sim/lib/knowledge/orchestration/index.ts @@ -1,88 +1,34 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { knowledgeBase } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' -import { generateRequestId } from '@/lib/core/utils/request' -import { KnowledgeBaseConflictError, restoreKnowledgeBase } from '@/lib/knowledge/service' - -const logger = createLogger('KnowledgeBaseOrchestration') - -export type KnowledgeOrchestrationErrorCode = 'not_found' | 'conflict' | 'internal' - -export interface RestorableKnowledgeBase { - id: string - name: string - workspaceId: string | null - userId: string -} - -export interface PerformRestoreKnowledgeBaseParams { - knowledgeBaseId: string - userId: string - requestId?: string -} - -export interface PerformRestoreKnowledgeBaseResult { - success: boolean - error?: string - errorCode?: KnowledgeOrchestrationErrorCode - knowledgeBase?: RestorableKnowledgeBase -} - -export async function getRestorableKnowledgeBase( - knowledgeBaseId: string -): Promise { - const [kb] = await db - .select({ - id: knowledgeBase.id, - name: knowledgeBase.name, - workspaceId: knowledgeBase.workspaceId, - userId: knowledgeBase.userId, - }) - .from(knowledgeBase) - .where(eq(knowledgeBase.id, knowledgeBaseId)) - .limit(1) - - return kb ?? null -} - -export async function performRestoreKnowledgeBase( - params: PerformRestoreKnowledgeBaseParams -): Promise { - const { knowledgeBaseId, userId } = params - const requestId = params.requestId ?? generateRequestId() - - const kb = await getRestorableKnowledgeBase(knowledgeBaseId) - if (!kb) { - return { success: false, error: 'Knowledge base not found', errorCode: 'not_found' } - } - - try { - await restoreKnowledgeBase(knowledgeBaseId, requestId) - - logger.info(`[${requestId}] Restored knowledge base ${knowledgeBaseId}`) - - recordAudit({ - workspaceId: kb.workspaceId, - actorId: userId, - action: AuditAction.KNOWLEDGE_BASE_RESTORED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: knowledgeBaseId, - resourceName: kb.name, - description: `Restored knowledge base "${kb.name}"`, - metadata: { - knowledgeBaseName: kb.name, - }, - }) - - return { success: true, knowledgeBase: kb } - } catch (error) { - logger.error(`[${requestId}] Failed to restore knowledge base ${knowledgeBaseId}`, { error }) - if (error instanceof KnowledgeBaseConflictError) { - return { success: false, error: error.message, errorCode: 'conflict' } - } - return { success: false, error: toError(error).message, errorCode: 'internal' } - } -} +export { + type ConnectorKnowledgeBase, + type ConnectorWithoutSecret, + getKnowledgeConnector, + type KnowledgeConnectorRow, + performCreateKnowledgeConnector, + performDeleteKnowledgeConnector, + performSyncKnowledgeConnector, + performUpdateKnowledgeConnector, + type SourceConfigRejection, +} from './connectors' +export { + type CreatedKnowledgeDocument, + type KnowledgeBaseTarget, + type KnowledgeDocumentInput, + performDeleteKnowledgeDocument, + performMarkKnowledgeDocumentTimedOut, + performRetryKnowledgeDocumentProcessing, + performUpdateKnowledgeDocument, + performUploadKnowledgeDocument, + performUploadKnowledgeDocuments, +} from './documents' +export { + type PerformKnowledgeBaseResult, + performCreateKnowledgeBase, + performDeleteKnowledgeBase, + performUpdateKnowledgeBase, +} from './knowledge-bases' +export { + getRestorableKnowledgeBase, + performRestoreKnowledgeBase, + type RestorableKnowledgeBase, +} from './restore' +export type { KnowledgeActor, KnowledgeOperationSource } from './shared' diff --git a/apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts b/apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts new file mode 100644 index 00000000000..6aed845a9be --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts @@ -0,0 +1,256 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCaptureServerEvent, + mockCreateKnowledgeBase, + mockDeleteKnowledgeBase, + mockRecordAudit, + mockUpdateKnowledgeBase, +} = vi.hoisted(() => ({ + mockCaptureServerEvent: vi.fn(), + mockCreateKnowledgeBase: vi.fn(), + mockDeleteKnowledgeBase: vi.fn(), + mockRecordAudit: vi.fn(), + mockUpdateKnowledgeBase: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + KNOWLEDGE_BASE_CREATED: 'knowledge_base.created', + KNOWLEDGE_BASE_UPDATED: 'knowledge_base.updated', + KNOWLEDGE_BASE_DELETED: 'knowledge_base.deleted', + }, + AuditResourceType: { KNOWLEDGE_BASE: 'knowledge_base' }, + recordAudit: mockRecordAudit, +})) +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { knowledgeBaseCreated: vi.fn(), knowledgeBaseDeleted: vi.fn() }, +})) +vi.mock('@/lib/knowledge/embeddings', () => ({ + EMBEDDING_DIMENSIONS: 1536, + getConfiguredEmbeddingModel: () => 'text-embedding-3-small', +})) +vi.mock('@/lib/knowledge/service', () => ({ + createKnowledgeBase: mockCreateKnowledgeBase, + deleteKnowledgeBase: mockDeleteKnowledgeBase, + updateKnowledgeBase: mockUpdateKnowledgeBase, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { DEFAULT_CHUNKING_CONFIG } from '@/lib/knowledge/constants' +import { + performCreateKnowledgeBase, + performDeleteKnowledgeBase, + performUpdateKnowledgeBase, +} from '@/lib/knowledge/orchestration/knowledge-bases' + +const CREATED = { id: 'kb-1', name: 'Docs', description: null, workspaceId: 'ws-1' } + +describe('performCreateKnowledgeBase', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreateKnowledgeBase.mockResolvedValue(CREATED) + }) + + it('applies one chunking default for every caller', async () => { + await performCreateKnowledgeBase({ + userId: 'user-1', + source: 'agent', + workspaceId: 'ws-1', + name: 'Docs', + }) + + // The agent used to default minSize to 1 against the API's 100, so the same + // document chunked differently depending on who created the knowledge base. + expect(mockCreateKnowledgeBase).toHaveBeenCalledWith( + expect.objectContaining({ chunkingConfig: { ...DEFAULT_CHUNKING_CONFIG } }), + expect.any(String) + ) + }) + + it('lets a caller override individual chunking fields', async () => { + await performCreateKnowledgeBase({ + userId: 'user-1', + source: 'api', + workspaceId: 'ws-1', + name: 'Docs', + chunkingConfig: { maxSize: 512 }, + }) + + expect(mockCreateKnowledgeBase).toHaveBeenCalledWith( + expect.objectContaining({ + chunkingConfig: { ...DEFAULT_CHUNKING_CONFIG, maxSize: 512 }, + }), + expect.any(String) + ) + }) + + it('audits an agent-created knowledge base, which the copilot path never did', async () => { + await performCreateKnowledgeBase({ + userId: 'user-1', + source: 'agent', + workspaceId: 'ws-1', + name: 'Docs', + }) + + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'user-1', + resourceId: 'kb-1', + workspaceId: 'ws-1', + metadata: expect.objectContaining({ source: 'agent' }), + }) + ) + }) + + it('carries request provenance into the audit row', async () => { + const request = new Request('https://sim.ai', { headers: { 'user-agent': 'curl/8' } }) + + await performCreateKnowledgeBase({ + userId: 'user-1', + source: 'ui', + workspaceId: 'ws-1', + name: 'Docs', + request, + }) + + expect(mockRecordAudit).toHaveBeenCalledWith(expect.objectContaining({ request })) + }) + + it('classifies a duplicate name as a conflict, not bad input', async () => { + mockCreateKnowledgeBase.mockRejectedValue( + new OrchestrationError('conflict', 'A knowledge base named "Docs" already exists') + ) + + const outcome = await performCreateKnowledgeBase({ + userId: 'user-1', + source: 'ui', + workspaceId: 'ws-1', + name: 'Docs', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'conflict' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('keeps an unclassified failure internal and records nothing', async () => { + mockCreateKnowledgeBase.mockRejectedValue(new Error('connection terminated')) + + const outcome = await performCreateKnowledgeBase({ + userId: 'user-1', + source: 'ui', + workspaceId: 'ws-1', + name: 'Docs', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'internal' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) +}) + +describe('performUpdateKnowledgeBase', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUpdateKnowledgeBase.mockResolvedValue({ ...CREATED, name: 'Renamed' }) + }) + + it('rejects an update that names nothing before touching the service', async () => { + const outcome = await performUpdateKnowledgeBase({ + knowledgeBaseId: 'kb-1', + workspaceId: 'ws-1', + userId: 'user-1', + source: 'api', + updates: { name: undefined, description: undefined }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockUpdateKnowledgeBase).not.toHaveBeenCalled() + }) + + it('always forwards the actor, so a workspace move is authorized not rejected', async () => { + await performUpdateKnowledgeBase({ + knowledgeBaseId: 'kb-1', + workspaceId: 'ws-1', + userId: 'user-1', + source: 'api', + updates: { workspaceId: 'ws-2' }, + }) + + // The v1 and v2 routes used to omit `actorUserId`, which the service rejects + // outright on a workspace change. + expect(mockUpdateKnowledgeBase).toHaveBeenCalledWith( + 'kb-1', + { workspaceId: 'ws-2' }, + expect.any(String), + { actorUserId: 'user-1' } + ) + }) + + it('files the audit against the destination workspace on a move', async () => { + await performUpdateKnowledgeBase({ + knowledgeBaseId: 'kb-1', + workspaceId: 'ws-1', + userId: 'user-1', + source: 'ui', + updates: { workspaceId: 'ws-2' }, + }) + + expect(mockRecordAudit).toHaveBeenCalledWith(expect.objectContaining({ workspaceId: 'ws-2' })) + }) + + it('classifies a rejected folder as bad input', async () => { + mockUpdateKnowledgeBase.mockRejectedValue( + new OrchestrationError('validation', 'Folder not found in this workspace') + ) + + const outcome = await performUpdateKnowledgeBase({ + knowledgeBaseId: 'kb-1', + workspaceId: 'ws-1', + userId: 'user-1', + source: 'ui', + updates: { folderId: 'folder-elsewhere' }, + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + }) +}) + +describe('performDeleteKnowledgeBase', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDeleteKnowledgeBase.mockResolvedValue(undefined) + }) + + it('audits the archive against the acting user', async () => { + const outcome = await performDeleteKnowledgeBase({ + knowledgeBase: { id: 'kb-1', name: 'Docs', workspaceId: 'ws-1' }, + userId: 'user-1', + source: 'agent', + requestId: 'req-1', + }) + + expect(outcome.success).toBe(true) + expect(mockDeleteKnowledgeBase).toHaveBeenCalledWith('kb-1', 'req-1') + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ actorId: 'user-1', resourceId: 'kb-1' }) + ) + }) + + it('records nothing when the archive fails', async () => { + mockDeleteKnowledgeBase.mockRejectedValue(new Error('deadlock detected')) + + const outcome = await performDeleteKnowledgeBase({ + knowledgeBase: { id: 'kb-1', name: 'Docs', workspaceId: 'ws-1' }, + userId: 'user-1', + source: 'ui', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'internal' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts b/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts new file mode 100644 index 00000000000..b808f820e97 --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts @@ -0,0 +1,236 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { PlatformEvents } from '@/lib/core/telemetry' +import { generateRequestId } from '@/lib/core/utils/request' +import { DEFAULT_CHUNKING_CONFIG } from '@/lib/knowledge/constants' +import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' +import { + auditActorFields, + classifyKnowledgeFailure, + fail, + type KnowledgeOperationContext, + type KnowledgeOrchestrationResult, +} from '@/lib/knowledge/orchestration/shared' +import { + createKnowledgeBase, + deleteKnowledgeBase, + updateKnowledgeBase, +} from '@/lib/knowledge/service' +import type { ChunkingConfig, KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { captureServerEvent } from '@/lib/posthog/server' + +const logger = createLogger('KnowledgeBaseOrchestration') + +export type PerformKnowledgeBaseResult = KnowledgeOrchestrationResult<{ + knowledgeBase: KnowledgeBaseWithCounts +}> + +export interface PerformCreateKnowledgeBaseParams extends KnowledgeOperationContext { + workspaceId: string + name: string + description?: string + /** Folder in the workspace's `knowledge_base` tree; `null`/omitted is the root. */ + folderId?: string | null + /** Omitted fields fall back to {@link DEFAULT_CHUNKING_CONFIG}. */ + chunkingConfig?: Partial +} + +/** + * Creates a knowledge base on behalf of an actor, as the single implementation + * behind the UI route, the v1 and v2 public APIs, and the copilot agent tool. + * + * The chunking default lives here rather than at each boundary: when every + * caller carried its own literal the agent's `minSize` was 1 against the API's + * 100, so identical input produced differently-chunked knowledge bases + * depending on who created it. The audit likewise lives here rather than in the + * three HTTP routes that each had their own copy — which is why an + * agent-created knowledge base used to leave no audit trail at all. + * + * The caller owns authentication; `createKnowledgeBase` still enforces the + * workspace write permission itself, and that failure comes back as `forbidden`. + */ +export async function performCreateKnowledgeBase( + params: PerformCreateKnowledgeBaseParams +): Promise { + const { workspaceId, name, description, folderId, request, source } = params + const requestId = params.requestId ?? generateRequestId() + const chunkingConfig: ChunkingConfig = { ...DEFAULT_CHUNKING_CONFIG, ...params.chunkingConfig } + const embeddingModel = getConfiguredEmbeddingModel() + + let created: KnowledgeBaseWithCounts + try { + created = await createKnowledgeBase( + { + name, + description, + workspaceId, + folderId, + userId: params.userId, + embeddingModel, + embeddingDimension: EMBEDDING_DIMENSIONS, + chunkingConfig, + }, + requestId + ) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Create knowledge base "${name}"`) + } + + logger.info(`[${requestId}] Created knowledge base ${created.id} for user ${params.userId}`) + + PlatformEvents.knowledgeBaseCreated({ + knowledgeBaseId: created.id, + name: created.name, + workspaceId, + }) + + captureServerEvent( + params.userId, + 'knowledge_base_created', + { knowledge_base_id: created.id, workspace_id: workspaceId, name: created.name }, + { + groups: { workspace: workspaceId }, + setOnce: { first_kb_created_at: new Date().toISOString() }, + } + ) + + recordAudit({ + workspaceId, + ...auditActorFields(params), + action: AuditAction.KNOWLEDGE_BASE_CREATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: created.id, + resourceName: created.name, + description: `Created knowledge base "${created.name}"`, + metadata: { + source, + name: created.name, + description: created.description, + embeddingModel, + embeddingDimension: EMBEDDING_DIMENSIONS, + chunkingStrategy: chunkingConfig.strategy, + chunkMaxSize: chunkingConfig.maxSize, + chunkMinSize: chunkingConfig.minSize, + chunkOverlap: chunkingConfig.overlap, + }, + ...(request ? { request } : {}), + }) + + return { success: true, knowledgeBase: created } +} + +export interface PerformUpdateKnowledgeBaseParams extends KnowledgeOperationContext { + knowledgeBaseId: string + /** Workspace the knowledge base currently belongs to, for the audit row. */ + workspaceId: string | null + updates: { + name?: string + description?: string + /** Moves the knowledge base between workspaces; omitted leaves it in place. */ + workspaceId?: string | null + folderId?: string | null + chunkingConfig?: ChunkingConfig + } +} + +/** + * Applies a knowledge base update and records it against the actor. + * + * `actorUserId` is always forwarded, so a workspace move is authorized against + * the caller rather than rejected for a missing actor — the v1 and v2 routes + * previously omitted it. + */ +export async function performUpdateKnowledgeBase( + params: PerformUpdateKnowledgeBaseParams +): Promise { + const { knowledgeBaseId, updates, request, source } = params + const requestId = params.requestId ?? generateRequestId() + + const updatedFields = Object.keys(updates).filter( + (key) => updates[key as keyof typeof updates] !== undefined + ) + if (updatedFields.length === 0) { + return fail('No updates specified', 'validation') + } + + let updated: KnowledgeBaseWithCounts + try { + updated = await updateKnowledgeBase(knowledgeBaseId, updates, requestId, { + actorUserId: params.userId, + }) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Update knowledge base ${knowledgeBaseId}`) + } + + logger.info(`[${requestId}] Updated knowledge base ${knowledgeBaseId}`) + + recordAudit({ + // The destination workspace when this update moved it, so the audit row + // lands where the knowledge base now lives. + workspaceId: updates.workspaceId !== undefined ? updates.workspaceId : params.workspaceId, + ...auditActorFields(params), + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: knowledgeBaseId, + resourceName: updated.name, + description: `Updated knowledge base "${updated.name}"`, + metadata: { + source, + updatedFields, + ...(updates.name && { newName: updates.name }), + ...(updates.description !== undefined && { description: updates.description }), + ...(updates.chunkingConfig && { + chunkMaxSize: updates.chunkingConfig.maxSize, + chunkMinSize: updates.chunkingConfig.minSize, + chunkOverlap: updates.chunkingConfig.overlap, + }), + }, + ...(request ? { request } : {}), + }) + + return { success: true, knowledgeBase: updated } +} + +export interface PerformDeleteKnowledgeBaseParams extends KnowledgeOperationContext { + knowledgeBase: { id: string; name: string; workspaceId: string | null } +} + +export type PerformDeleteKnowledgeBaseResult = KnowledgeOrchestrationResult + +/** + * Archives a knowledge base and its documents and connectors. + * + * The folder cascade and other internal callers keep calling + * `deleteKnowledgeBase` directly and stay silent by construction — auditing + * follows from a user performing this operation, not from the write running. + */ +export async function performDeleteKnowledgeBase( + params: PerformDeleteKnowledgeBaseParams +): Promise { + const { knowledgeBase, request, source } = params + const requestId = params.requestId ?? generateRequestId() + + try { + await deleteKnowledgeBase(knowledgeBase.id, requestId) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Delete knowledge base ${knowledgeBase.id}`) + } + + logger.info(`[${requestId}] Deleted knowledge base ${knowledgeBase.id}`) + + PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: knowledgeBase.id }) + + recordAudit({ + workspaceId: knowledgeBase.workspaceId, + ...auditActorFields(params), + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: knowledgeBase.id, + resourceName: knowledgeBase.name, + description: `Deleted knowledge base "${knowledgeBase.name}"`, + metadata: { source, knowledgeBaseName: knowledgeBase.name }, + ...(request ? { request } : {}), + }) + + return { success: true } +} diff --git a/apps/sim/lib/knowledge/orchestration/restore.test.ts b/apps/sim/lib/knowledge/orchestration/restore.test.ts new file mode 100644 index 00000000000..558df668d1a --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/restore.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRecordAudit, mockRestoreKnowledgeBase } = vi.hoisted(() => ({ + mockRecordAudit: vi.fn(), + mockRestoreKnowledgeBase: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { KNOWLEDGE_BASE_RESTORED: 'knowledge_base.restored' }, + AuditResourceType: { KNOWLEDGE_BASE: 'knowledge_base' }, + recordAudit: mockRecordAudit, +})) +vi.mock('@/lib/knowledge/service', () => ({ restoreKnowledgeBase: mockRestoreKnowledgeBase })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { performRestoreKnowledgeBase } from '@/lib/knowledge/orchestration/restore' + +const ARCHIVED = { id: 'kb-1', name: 'Docs', workspaceId: 'ws-1', userId: 'owner' } + +describe('performRestoreKnowledgeBase', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(resetDbChainMock) + + it('audits the restore against the acting user', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ARCHIVED]) + mockRestoreKnowledgeBase.mockResolvedValue(undefined) + + const outcome = await performRestoreKnowledgeBase({ + knowledgeBaseId: 'kb-1', + userId: 'user-1', + source: 'ui', + requestId: 'req-1', + }) + + expect(outcome).toMatchObject({ success: true }) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ actorId: 'user-1', resourceId: 'kb-1', workspaceId: 'ws-1' }) + ) + }) + + it('reports a knowledge base that is not archived as a conflict', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ARCHIVED]) + mockRestoreKnowledgeBase.mockRejectedValue( + new OrchestrationError('conflict', 'Knowledge base is not archived') + ) + + const outcome = await performRestoreKnowledgeBase({ + knowledgeBaseId: 'kb-1', + userId: 'user-1', + source: 'ui', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'conflict' }) + }) + + it('reports bad input as validation, which the narrow local alias could not express', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ARCHIVED]) + mockRestoreKnowledgeBase.mockRejectedValue( + new OrchestrationError('validation', 'Folder not found in this workspace') + ) + + const outcome = await performRestoreKnowledgeBase({ + knowledgeBaseId: 'kb-1', + userId: 'user-1', + source: 'ui', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) + }) + + it('reports a knowledge base that does not exist as not found', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + const outcome = await performRestoreKnowledgeBase({ + knowledgeBaseId: 'kb-1', + userId: 'user-1', + source: 'ui', + }) + + expect(outcome).toMatchObject({ success: false, errorCode: 'not_found' }) + expect(mockRestoreKnowledgeBase).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/orchestration/restore.ts b/apps/sim/lib/knowledge/orchestration/restore.ts new file mode 100644 index 00000000000..c162dc160b7 --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/restore.ts @@ -0,0 +1,88 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { knowledgeBase } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { generateRequestId } from '@/lib/core/utils/request' +import { + auditActorFields, + classifyKnowledgeFailure, + fail, + type KnowledgeOperationContext, + type KnowledgeOrchestrationResult, +} from '@/lib/knowledge/orchestration/shared' +import { restoreKnowledgeBase } from '@/lib/knowledge/service' + +const logger = createLogger('KnowledgeBaseRestoreOrchestration') + +export interface RestorableKnowledgeBase { + id: string + name: string + workspaceId: string | null + userId: string +} + +export interface PerformRestoreKnowledgeBaseParams extends KnowledgeOperationContext { + knowledgeBaseId: string +} + +export type PerformRestoreKnowledgeBaseResult = KnowledgeOrchestrationResult<{ + knowledgeBase: RestorableKnowledgeBase +}> + +/** + * Loads an archived knowledge base's identity so the caller can authorize the + * restore. Reads regardless of `deletedAt` — an archived row is exactly what a + * restore targets. + */ +export async function getRestorableKnowledgeBase( + knowledgeBaseId: string +): Promise { + const [kb] = await db + .select({ + id: knowledgeBase.id, + name: knowledgeBase.name, + workspaceId: knowledgeBase.workspaceId, + userId: knowledgeBase.userId, + }) + .from(knowledgeBase) + .where(eq(knowledgeBase.id, knowledgeBaseId)) + .limit(1) + + return kb ?? null +} + +/** Un-archives a knowledge base and its documents and connectors. */ +export async function performRestoreKnowledgeBase( + params: PerformRestoreKnowledgeBaseParams +): Promise { + const { knowledgeBaseId, request, source } = params + const requestId = params.requestId ?? generateRequestId() + + const kb = await getRestorableKnowledgeBase(knowledgeBaseId) + if (!kb) { + return fail('Knowledge base not found', 'not_found') + } + + try { + await restoreKnowledgeBase(knowledgeBaseId, requestId) + } catch (error) { + return classifyKnowledgeFailure(error, requestId, `Restore knowledge base ${knowledgeBaseId}`) + } + + logger.info(`[${requestId}] Restored knowledge base ${knowledgeBaseId}`) + + recordAudit({ + workspaceId: kb.workspaceId, + ...auditActorFields(params), + action: AuditAction.KNOWLEDGE_BASE_RESTORED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: knowledgeBaseId, + resourceName: kb.name, + description: `Restored knowledge base "${kb.name}"`, + metadata: { source, knowledgeBaseName: kb.name }, + ...(request ? { request } : {}), + }) + + return { success: true, knowledgeBase: kb } +} diff --git a/apps/sim/lib/knowledge/orchestration/shared.ts b/apps/sim/lib/knowledge/orchestration/shared.ts new file mode 100644 index 00000000000..2c2581deee7 --- /dev/null +++ b/apps/sim/lib/knowledge/orchestration/shared.ts @@ -0,0 +1,84 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { + asOrchestrationError, + type OrchestrationErrorCode, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' + +const logger = createLogger('KnowledgeOrchestration') + +/** + * Which surface an operation came in through. Recorded in the audit metadata so + * the audit list still distinguishes a knowledge base the UI created from one an + * API key or the agent created, now that all three share one description. + */ +export type KnowledgeOperationSource = 'ui' | 'api' | 'agent' + +/** The acting user, plus the labels the audit row displays when it has them. */ +export interface KnowledgeActor { + userId: string + actorName?: string | null + actorEmail?: string | null + source: KnowledgeOperationSource +} + +/** Fields every knowledge orchestration function accepts. */ +export interface KnowledgeOperationContext extends KnowledgeActor { + requestId?: string + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: OrchestrationRequestContext +} + +export interface KnowledgeOrchestrationFailure { + success: false + error: string + errorCode: OrchestrationErrorCode +} + +/** + * Every knowledge orchestration function returns this shape. A discriminated + * union rather than an all-optional record, so `if (!outcome.success) return …` + * narrows the success branch and callers reach the payload without asserting it + * is there. + */ +export type KnowledgeOrchestrationResult = + | ({ success: true } & TData) + | KnowledgeOrchestrationFailure + +export function fail( + error: string, + errorCode: OrchestrationErrorCode +): KnowledgeOrchestrationFailure { + return { success: false, error, errorCode } +} + +/** + * Maps a thrown failure to its transport-neutral class. + * + * Every caller-fixable knowledge failure is an {@link OrchestrationError} + * subclass, so the class decides the status rather than the message wording. + * Anything unclassified stays a generic 500, which is what an unexpected fault + * should be, and is logged here because no layer above will see the cause. + */ +export function classifyKnowledgeFailure( + error: unknown, + requestId: string, + operation: string +): KnowledgeOrchestrationFailure { + const classified = asOrchestrationError(error) + if (classified) { + return fail(classified.message, classified.code) + } + logger.error(`[${requestId}] ${operation} failed`, { error }) + return fail(toError(error).message, 'internal') +} + +/** The audit fields carried from the actor, omitting labels the caller lacks. */ +export function auditActorFields(actor: KnowledgeActor) { + return { + actorId: actor.userId, + ...(actor.actorName !== undefined ? { actorName: actor.actorName } : {}), + ...(actor.actorEmail !== undefined ? { actorEmail: actor.actorEmail } : {}), + } +} diff --git a/apps/sim/lib/knowledge/service.test.ts b/apps/sim/lib/knowledge/service.test.ts index 8b9ca673f2d..ce59bc0087b 100644 --- a/apps/sim/lib/knowledge/service.test.ts +++ b/apps/sim/lib/knowledge/service.test.ts @@ -71,7 +71,7 @@ describe('updateKnowledgeBase — workspace transfer authorization', () => { await expect( updateKnowledgeBase('kb-1', { workspaceId: null }, 'req-1', { actorUserId: 'attacker' }) ).rejects.toMatchObject({ - code: 'KNOWLEDGE_BASE_FORBIDDEN', + code: 'forbidden', message: 'Only the knowledge base owner can remove it from a workspace', }) expect(permissionsMockFns.mockGetUserEntityPermissions).not.toHaveBeenCalled() @@ -95,7 +95,7 @@ describe('updateKnowledgeBase — workspace transfer authorization', () => { actorUserId: 'attacker', }) ).rejects.toMatchObject({ - code: 'KNOWLEDGE_BASE_FORBIDDEN', + code: 'forbidden', message: 'User does not have permission on the target workspace', }) expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledWith( diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index cbac1283b63..e4e8cc3707b 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -20,6 +20,7 @@ import { resolveStorageBillingContext, type StorageBillingContext, } from '@/lib/billing/storage' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRestoreName } from '@/lib/core/utils/restore-name' import { findActiveFolder, resolveRestoredFolderId } from '@/lib/folders/queries' import type { @@ -31,22 +32,39 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('KnowledgeBaseService') -export class KnowledgeBaseConflictError extends Error { - readonly code = 'KNOWLEDGE_BASE_EXISTS' as const +/** + * Every caller-fixable knowledge-base failure is an {@link OrchestrationError}, + * so `lib/knowledge/orchestration` classifies it by class and each surface maps + * that one class to its own status. Message text is then free to change without + * silently moving a 409 to a 400. + */ +export class KnowledgeBaseConflictError extends OrchestrationError { constructor(name: string) { - super(`A knowledge base named "${name}" already exists in this workspace`) + super('conflict', `A knowledge base named "${name}" already exists in this workspace`) + this.name = 'KnowledgeBaseConflictError' } } -export class KnowledgeBasePermissionError extends Error { - readonly code = 'KNOWLEDGE_BASE_FORBIDDEN' as const +export class KnowledgeBasePermissionError extends OrchestrationError { + constructor(message: string) { + super('forbidden', message) + this.name = 'KnowledgeBasePermissionError' + } } /** Raised when a caller files a knowledge base under a folder it may not use. */ -export class KnowledgeBaseFolderError extends Error { - readonly code = 'KNOWLEDGE_BASE_FOLDER_INVALID' as const +export class KnowledgeBaseFolderError extends OrchestrationError { constructor() { - super('Folder not found in this workspace') + super('validation', 'Folder not found in this workspace') + this.name = 'KnowledgeBaseFolderError' + } +} + +/** Raised when a knowledge base the caller named does not exist (or is archived). */ +export class KnowledgeBaseNotFoundError extends OrchestrationError { + constructor(knowledgeBaseId: string) { + super('not_found', `Knowledge base ${knowledgeBaseId} not found`) + this.name = 'KnowledgeBaseNotFoundError' } } @@ -341,7 +359,7 @@ export async function updateKnowledgeBase( .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) .limit(1) if (!snapshot) { - throw new Error(`Knowledge base ${knowledgeBaseId} not found`) + throw new KnowledgeBaseNotFoundError(knowledgeBaseId) } effectiveWorkspaceId = snapshot.workspaceId } @@ -365,7 +383,7 @@ export async function updateKnowledgeBase( .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) .limit(1) if (!kbSnapshot) { - throw new Error(`Knowledge base ${knowledgeBaseId} not found`) + throw new KnowledgeBaseNotFoundError(knowledgeBaseId) } const sourceWorkspaceId = kbSnapshot.workspaceId ?? null const destinationWorkspaceId = updates.workspaceId ?? null @@ -450,7 +468,7 @@ export async function updateKnowledgeBase( .limit(1) if (!currentKb) { - throw new Error(`Knowledge base ${knowledgeBaseId} not found`) + throw new KnowledgeBaseNotFoundError(knowledgeBaseId) } if (storageMove && (currentKb.workspaceId ?? null) !== storageMove.sourceWorkspaceId) { @@ -666,7 +684,7 @@ export async function updateKnowledgeBase( .limit(1) if (updatedKb.length === 0) { - throw new Error(`Knowledge base ${knowledgeBaseId} not found`) + throw new KnowledgeBaseNotFoundError(knowledgeBaseId) } logger.info(`[${requestId}] Updated knowledge base: ${knowledgeBaseId}`) @@ -809,18 +827,21 @@ export async function restoreKnowledgeBase( .limit(1) if (!kb) { - throw new Error('Knowledge base not found') + throw new KnowledgeBaseNotFoundError(knowledgeBaseId) } if (!kb.deletedAt) { - throw new Error('Knowledge base is not archived') + throw new OrchestrationError('conflict', 'Knowledge base is not archived') } if (kb.workspaceId) { const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') const ws = await getWorkspaceWithOwner(kb.workspaceId) if (!ws || ws.archivedAt) { - throw new Error('Cannot restore knowledge base into an archived workspace') + throw new OrchestrationError( + 'conflict', + 'Cannot restore knowledge base into an archived workspace' + ) } } diff --git a/apps/sim/lib/resources/orchestration/restore-resource.ts b/apps/sim/lib/resources/orchestration/restore-resource.ts index 75baa13aaf4..ec7055f9c5f 100644 --- a/apps/sim/lib/resources/orchestration/restore-resource.ts +++ b/apps/sim/lib/resources/orchestration/restore-resource.ts @@ -148,10 +148,11 @@ export async function performRestoreResource( const result = await performRestoreKnowledgeBase({ knowledgeBaseId: id, userId, + source: 'agent', requestId, }) - if (!result.success || !result.knowledgeBase) { - return { success: false, error: result.error || 'Failed to restore knowledge base' } + if (!result.success) { + return { success: false, error: result.error } } logger.info('Knowledge base restored via restore_resource', { knowledgeBaseId: id }) From 676bd83f2eb9e520021659287fa911d6ae235d33 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 01:26:21 -0700 Subject: [PATCH 040/159] fix(cli): stop dropping nested fields, and emit exports as documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sim workflows export ` printed `version` and `exportedAt` and nothing else. The record builder kept only scalar fields, so `workflow` and `state` — the entire export — were discarded with nothing to say they had been. Same for `workflows get`, which silently dropped `variables` and `inputs`. Record views now render every field. Nested values serialize to one line and are cut at 160 chars: visibly partial beats silently absent, and json/yaml output still prints them whole. Export is a document, not a record — it exists to be redirected to a file and fed back to `import`, and table/text flatten and truncate, so neither can round-trip it. `document: true` in the contract makes those formats fall back to JSON; yaml is honoured because it round-trips. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/contract/commands.ts | 8 ++++ packages/sim-cli/src/contract/types.ts | 9 +++++ packages/sim-cli/src/output/render.ts | 15 ++++++++ packages/sim-cli/src/runtime/build.test.ts | 43 ++++++++++++++++++++++ packages/sim-cli/src/runtime/build.ts | 37 ++++++++++++++++--- 5 files changed, 107 insertions(+), 5 deletions(-) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index a7d67728ff9..de3e2749147 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -200,6 +200,14 @@ export const CLI_CONTRACT: CliContract = { ], }, + // ─── Documents, not records ─────────────────────────────────────────────── + // The payload is the artifact: `sim workflows export > wf.json` has to + // produce something `sim workflows import` accepts back. + exportWorkflow: { + describe: 'Print a workflow as a portable JSON document', + document: true, + }, + // ─── Execution ──────────────────────────────────────────────────────────── // The derived names land badly here: `/execute` and `/cancel` are verbs in // the path, but neither is in the action list, so POST would derive diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 6e11f4cfe32..9158f64813b 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -84,6 +84,15 @@ export interface CommandSpec { * the point is that the caller can tell whether they meant it. */ confirm?: string + /** + * The response IS a document, not a record to look at. + * + * `workflows export` exists to be redirected into a file and fed back to + * `import`, so a key/value view of it is wrong at any fidelity — the useful + * artifact is the payload itself. Document commands emit raw JSON (or YAML + * when the profile says so) whatever the profile's display format is. + */ + document?: boolean /** Keep the operation out of the CLI surface entirely. */ hidden?: boolean } diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index 8a2a3eb1d0f..3905bab418c 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -201,6 +201,21 @@ export function printList(format: OutputFormat, rows: T[], columns: Column console.log(renderTable(rows, columns)) } +/** + * Prints a payload whose value IS the deliverable — `workflows export`, which + * is meant to be redirected to a file and fed back to `import`. + * + * `table` and `text` are display formats: they flatten, truncate and colour, so + * neither can round-trip a document. Rather than emit something that looks like + * an export but cannot be re-imported, those two fall back to JSON. Only `yaml` + * is honoured, because it round-trips. + */ +export function printDocument(format: OutputFormat, raw: unknown): void { + console.log( + format === 'yaml' ? (renderMachine('yaml', raw) as string) : JSON.stringify(raw, null, 2) + ) +} + /** Prints a single record: machine formats from the raw value, otherwise aligned lines. */ export function printRecord(format: OutputFormat, fields: Array<[string, string]>, raw: unknown) { const machine = renderMachine(format, raw) diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 8ec2cdb2880..958d4741493 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -153,6 +153,49 @@ describe('single-resource rendering', () => { expect(printed.join('\n')).toMatch(/Deepwiki/) }) + it('renders nested fields instead of dropping them', async () => { + // `workflows export` printed `version` and `exportedAt` and nothing else: + // the record builder kept only scalars, so `workflow` and `state` — the + // entire export — vanished with no indication anything was missing. + const printed = await lines( + ['workflows', 'get', 'wf_1'], + { id: 'wf_1', name: 'Onboarding', inputs: [{ name: 'email', type: 'string' }] }, + 'text' + ) + + expect(printed.join('\n')).toMatch(/inputs/) + expect(printed.join('\n')).toMatch(/email/) + }) + + it('truncates a nested value rather than flooding the terminal', async () => { + const printed = await lines( + ['workflows', 'get', 'wf_1'], + { id: 'wf_1', state: { blocks: 'x'.repeat(5000) } }, + 'text' + ) + + const stateLine = printed.find((line) => line.startsWith('state')) ?? '' + expect(stateLine.length).toBeLessThan(300) + expect(stateLine).toMatch(/…$/) + }) + + it('emits a document command as JSON whatever the display format is', async () => { + // Redirecting this to a file has to yield something `import` accepts, so + // `table`/`text` — which flatten and truncate — must not be honoured here. + const printed = await lines( + ['workflows', 'export', 'wf_1'], + { version: '1.0', exportedAt: 'now', workflow: { id: 'wf_1' }, state: { blocks: {} } }, + 'text' + ) + + expect(JSON.parse(printed.join('\n'))).toEqual({ + version: '1.0', + exportedAt: 'now', + workflow: { id: 'wf_1' }, + state: { blocks: {} }, + }) + }) + it('leaves a payload with sibling keys intact', async () => { // `upsertTableRow` returns `{ row, operation }` — two real fields, not an // envelope. Unwrapping there would drop whether it inserted or updated. diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 499b3afb4b5..3fa374b1030 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -8,6 +8,7 @@ import { bytes, type Column, duration, + printDocument, printList, printRecord, sanitize, @@ -56,6 +57,25 @@ function renderCell(value: unknown, format: ColumnSpec['format']): string { } } +/** + * How wide a nested value may get before a record line stops being readable. + * A workflow's `state` serializes to tens of kilobytes on one line. + */ +const NESTED_CELL_WIDTH = 160 + +/** + * A field in a record view. + * + * Nested values are rendered, not skipped: a record that quietly omits half of + * what the server sent is worse than a long line, because nothing tells the + * caller anything is missing. Long ones are cut with an ellipsis — visibly + * partial, and `sim configure --set-output json` prints them whole. + */ +function recordCell(value: unknown): string { + const rendered = renderCell(value, 'auto') + return rendered.length > NESTED_CELL_WIDTH ? `${rendered.slice(0, NESTED_CELL_WIDTH)}…` : rendered +} + function columnsFrom(specs: ColumnSpec[]): Column[] { return specs.map((spec) => ({ header: spec.header, @@ -282,7 +302,14 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri query: request.query, body: request.body, }) - const data = unwrapResource(result?.data ?? result) + const raw = result?.data ?? result + + if (spec.document) { + printDocument(profile.output, raw) + return + } + + const data = unwrapResource(raw) if (Array.isArray(data)) { // Reached when a non-paginated operation answers with a collection. @@ -291,11 +318,11 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri return } + // Every field, nested ones included. Filtering to scalars here is what made + // `workflows export` print its two timestamps and drop the actual workflow. const fields: Array<[string, string]> = - data && typeof data === 'object' && !Array.isArray(data) - ? Object.entries(data) - .filter(([, value]) => value === null || typeof value !== 'object') - .map(([key, value]) => [key, renderCell(value, 'auto')]) + data && typeof data === 'object' + ? Object.entries(data).map(([key, value]) => [key, recordCell(value)]) : [] printRecord(profile.output, fields, data) From 791878472899cea3c0bc26efd3e16e7a6cd534b1 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 01:32:15 -0700 Subject: [PATCH 041/159] feat(cli): JSON flags accept @file and @- alongside inline JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workflow export is hundreds of lines, and `--workflow` only took it inline. The shell makes that miserable: unquoted `$(cat wf.json)` word-splits into broken JSON, and nothing in the help said passing a file was an option. Every JSON flag now reads `@path`, or `@-` for stdin, so the round trip is `sim workflows export > wf.json` then `import --workflow @wf.json` — or one pipe. `@` cannot collide with a real value because JSON only ever starts with `{ [ " -`, a digit, or t/f/n. Stdin drains with a readSync loop rather than readFileSync(0): a pipe is opened non-blocking, so the single-read form returned EAGAIN and died with a raw stack trace exactly when the upstream process had not written yet. Parse failures that look like a filename now say so — naming @path, or the file itself when the bare value turns out to exist. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/index.ts | 2 + packages/sim-cli/src/runtime/build.ts | 10 ++- packages/sim-cli/src/runtime/request.test.ts | 50 ++++++++++- packages/sim-cli/src/runtime/request.ts | 92 +++++++++++++++++++- 4 files changed, 148 insertions(+), 6 deletions(-) diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index ab5728183bf..6d2185e70d8 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -55,6 +55,8 @@ Examples: $ sim logs list --level error --limit 20 $ sim configure --set-output json Output format is a profile setting $ sim knowledge search "refund policy" --kb kb_123 + $ sim workflows export wf_123 > wf.json JSON flags read files with @ + $ sim workflows import --workflow @wf.json $ sim whoami --profile dev ` ) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 3fa374b1030..365a6390fda 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -190,10 +190,14 @@ function addFieldOption( } const takesList = flag.list === true - const placeholder = takesList ? `` : takesJson(descriptor, flag) ? `` : `` + const wantsJson = takesJson(descriptor, flag) + const placeholder = takesList ? `` : wantsJson ? `` : `` const describe = - flag.describe ?? - (descriptor.values ? `One of: ${descriptor.values.join(', ')}` : `Set ${field}`) + (flag.describe ?? + (descriptor.values ? `One of: ${descriptor.values.join(', ')}` : `Set ${field}`)) + + // Otherwise the only way to discover `@file` is to read the source. A JSON + // document big enough to want a file is exactly when help gets consulted. + (wantsJson ? ' (JSON, or @path / @- to read a file or stdin)' : '') const option = new Option(`${short}--${name} ${placeholder}`, describe) if (descriptor.values && !takesList) option.choices([...descriptor.values]) diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index d4e8cb12e42..286c7bd8d8b 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -1,7 +1,10 @@ +import { rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { SimApiError } from '../http/client.js' import { deriveCommandPath } from './derive.js' -import { buildRequest } from './request.js' +import { buildRequest, coerce, type FieldSpec } from './request.js' const WORKSPACE = 'ws_local' @@ -140,3 +143,48 @@ describe('repeated flags encode per the field kind, not uniformly', () => { expect(built.body?.knowledgeBaseIds).toEqual(['kb_1', 'kb_2']) }) }) + +describe('JSON flags that name a file', () => { + const field: FieldSpec = { kind: 'object' } + + it('reads @path', () => { + const path = join(tmpdir(), 'sim-cli-arg.json') + writeFileSync(path, '{"version":"1.0","state":{"blocks":{}}}') + expect(coerce(`@${path}`, field, {}, 'workflow')).toEqual({ + version: '1.0', + state: { blocks: {} }, + }) + rmSync(path) + }) + + it('still accepts inline JSON', () => { + expect(coerce('{"a":1}', field, {}, 'workflow')).toEqual({ a: 1 }) + }) + + it('names the file it could not read', () => { + expect(() => coerce('@/nope/missing.json', field, {}, 'workflow')).toThrow( + /cannot read \/nope\/missing\.json/ + ) + }) + + it('says which file the bad JSON came from', () => { + const path = join(tmpdir(), 'sim-cli-bad.json') + writeFileSync(path, 'not json') + expect(() => coerce(`@${path}`, field, {}, 'workflow')).toThrow(/read from .*sim-cli-bad\.json/) + rmSync(path) + }) + + it('points at @ when a bare filename was passed instead', () => { + // `--workflow export.json` is the natural first guess; "must be valid JSON" + // alone never reveals that passing a file is supported at all. + const path = join(tmpdir(), 'sim-cli-bare.json') + writeFileSync(path, '{}') + expect(() => coerce(path, field, {}, 'workflow')).toThrow(new RegExp(`pass it as @${path}`)) + rmSync(path) + expect(() => coerce('export.json', field, {}, 'workflow')).toThrow(/pass @path/) + }) + + it('does not suggest a path for malformed inline JSON', () => { + expect(() => coerce('{"a":', field, {}, 'workflow')).not.toThrow(/@path/) + }) +}) diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 44f4393fed4..ccda57dbc89 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -1,3 +1,4 @@ +import { existsSync, readFileSync, readSync } from 'node:fs' import { CLI_CONTRACT } from '../contract/commands.js' import type { FlagSpec } from '../contract/types.js' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' @@ -37,6 +38,89 @@ export function takesJson(field: FieldSpec, flag: FlagSpec): boolean { return flag.json === true || JSON_KINDS.has(field.kind) } +/** + * Drains stdin synchronously. + * + * `readFileSync(0)` looks like the obvious way to do this and fails on the one + * case that matters: a pipe is opened non-blocking, so a single read of an + * upstream process that has not written yet returns EAGAIN rather than waiting, + * and `export … | import --workflow @-` died with a raw stack trace. Reading in + * a loop and treating EAGAIN as "not ready yet" is what makes a pipe work. + * + * `Atomics.wait` is the only synchronous sleep available; without it the retry + * spins a core for as long as the writer takes. + */ +function readStdin(): string { + const idle = new Int32Array(new SharedArrayBuffer(4)) + const buffer = Buffer.alloc(64 * 1024) + const chunks: Buffer[] = [] + + for (;;) { + let read: number + try { + read = readSync(0, buffer, 0, buffer.length, null) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'EAGAIN') { + Atomics.wait(idle, 0, 0, 5) + continue + } + // Some platforms report end-of-input on a pipe as EOF rather than 0. + if (code === 'EOF') break + throw error + } + if (read === 0) break + chunks.push(Buffer.from(buffer.subarray(0, read))) + } + + return Buffer.concat(chunks).toString('utf8') +} + +/** + * Resolves a JSON flag's argument, which may name a file instead of carrying + * the document inline. + * + * `@path` reads the file and `@-` reads stdin, the curl convention. A workflow + * export is hundreds of lines, and the shell makes passing that literally + * unpleasant — unquoted `$(cat f.json)` word-splits into broken JSON, and the + * quoted form is easy to get wrong. `@` cannot collide with a real value + * because JSON only ever starts with `{ [ " -`, a digit, or t/f/n. + */ +function readJsonArgument(raw: string, flagName: string): { text: string; from: string } { + if (!raw.startsWith('@')) return { text: raw, from: '' } + + const path = raw.slice(1) + if (path === '-') { + if (process.stdin.isTTY) { + throw new SimApiError(`--${flagName} @- reads stdin, but nothing is piped in`, 0) + } + try { + return { text: readStdin(), from: ' (read from stdin)' } + } catch (error) { + throw new SimApiError(`--${flagName} cannot read stdin: ${(error as Error).message}`, 0) + } + } + + try { + return { text: readFileSync(path, 'utf8'), from: ` (read from ${path})` } + } catch (error) { + throw new SimApiError(`--${flagName} cannot read ${path}: ${(error as Error).message}`, 0) + } +} + +/** + * Points at `@` when a value that failed to parse looks like a filename. + * + * `--workflow export.json` is the natural first guess, and "must be valid JSON" + * alone gives no clue that passing a file is even supported. + */ +function pathHint(raw: string): string { + if (raw.startsWith('@') || /^\s*[[{"\-\d]|^\s*(true|false|null)/.test(raw)) return '' + return existsSync(raw) + ? `. ${raw} is a file — pass it as @${raw}` + : '. To read a file, pass @path (or @- for stdin)' +} + /** * Turns the string argv provides into the value the contract expects. * @@ -66,10 +150,14 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: if (takesJson(field, flag)) { if (typeof raw !== 'string') return raw + const source = readJsonArgument(raw, flagName) try { - return JSON.parse(raw) + return JSON.parse(source.text) } catch (error) { - throw new SimApiError(`--${flagName} must be valid JSON: ${(error as Error).message}`, 0) + throw new SimApiError( + `--${flagName} must be valid JSON${source.from}: ${(error as Error).message}${pathHint(raw)}`, + 0 + ) } } From 5df4c7555f0ade6e08bbf8388d29de91e76006dc Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 11:22:12 -0700 Subject: [PATCH 042/159] feat(api): expand the public v2 files surface (#6160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): expand the public v2 files surface Adds folder support, rename/restore, move, bulk archive, share, and content replace to /api/v2/files, so managing files by API no longer stops at upload + download + archive-one. Routes are thin: auth -> parse -> perform* -> serialize. Share and content replace get their orchestration extracted first so the session routes and the public ones cannot diverge on the effective-authType resolution, the EE public-sharing gate, or the storage-quota classification. Presigned upload stays session-only: presign does an advisory quota check and the real debit happens in the separate register step, so a caller that never registers leaves unaccounted bytes with no reaper. The buffered multipart path debits inside uploadWorkspaceFile's own transaction. * fix(files): classify folder and content failures instead of 500ing them Bugbot round 1. The v2 routes map errorCode straight to a status, so every manager failure that arrived unclassified became a 500 for what is really a caller-fixable 400 or 404. - Folder manager throws OrchestrationError: missing target/folder -> not_found, reparent cycle / self-parent / restore-into-archived-workspace -> validation. - File manager does the same for the in-transaction 'File not found' paths that the earlier pass missed. - updateWorkspaceFileContent's outer catch re-wrapped everything in a bare Error, which stripped the class off StorageLimitExceededError and the new not_found alike. It now rethrows a classified failure untouched and attaches cause to the generic wrap, so asOrchestrationError can still walk the chain. - Every remaining perform* gained the asOrchestrationError branch. - renameWorkspaceFile returned the pre-update read, so the v2 PATCH reported a stale updatedAt; it now returns the timestamp it actually wrote. Docs: upload auto-suffixes a duplicate name rather than rejecting it, matching the in-app uploader. The description claimed 409 and was simply wrong. * fix(files): surface a failed upload read-back as the real error getWorkspaceFile swallows a query failure and returns null unless throwOnError is set, so a transient blip on the post-upload read reported as 'file could not be read back'. Distinguish the two: a real null after a just-committed write is an invariant break, a query failure is itself. * revert(api): drop the dedicated v2 file-folder routes File folders already live in the shared folder table as resourceType 'file' (#6045 cut them over, #6051 dropped workspace_file_folders), and the remaining file-specific folder machinery is being folded into the generic folder engine. Publishing /api/v2/files/folders/** would pin that transitional split into a public contract we'd then have to keep or break. Files stay folder-aware — folderId/folderPath on the projection, folderId on upload, and the move route — because a folder id is a folder.id and survives the unification untouched. Folder management belongs on /api/v2/folders once that surface serves resourceType 'file'; until then there is no v2 way to enumerate file folders, which is the deliberate gap. The orchestration classification fixes stay: the internal routes and the copilot file-folder tools still call those perform* functions. * fix(files): classify upload failures instead of matching their wording Bugbot round 2. uploadWorkspaceFile had the same outer-catch rewrap that updateWorkspaceFileContent did, so a blown storage quota reached the route as a bare Error and the v2 handler recovered the status by substring-matching the message. Any rewording silently demoted a 413 to a 500. - uploadWorkspaceFile rethrows a classified failure untouched and attaches cause to the generic wrap. - FileConflictError is now an OrchestrationError('conflict'), so a duplicate name classifies like every other conflict. Its 'FILE_EXISTS' discriminator had no readers and is gone; the instanceof checks elsewhere still hold. - The v2 upload handler uses v2CaughtOrchestrationError, dropping all three string matches. Also documents that bulk-archive is best-effort: unknown or already-archived ids are skipped rather than failing the call, and deletedItems is what actually happened. That asymmetry with the single-id DELETE was undocumented. --- apps/docs/openapi-v2-files-audit.json | 1281 +++++++++++++++-- apps/sim/app/api/v1/middleware.ts | 4 + .../v2/files/[fileId]/content/route.test.ts | 186 +++ .../api/v2/files/[fileId]/content/route.ts | 80 + .../v2/files/[fileId]/restore/route.test.ts | 130 ++ .../api/v2/files/[fileId]/restore/route.ts | 71 + .../app/api/v2/files/[fileId]/route.test.ts | 333 +++++ apps/sim/app/api/v2/files/[fileId]/route.ts | 72 +- .../api/v2/files/[fileId]/share/route.test.ts | 271 ++++ .../app/api/v2/files/[fileId]/share/route.ts | 130 ++ .../api/v2/files/bulk-archive/route.test.ts | 127 ++ .../app/api/v2/files/bulk-archive/route.ts | 75 + apps/sim/app/api/v2/files/move/route.test.ts | 139 ++ apps/sim/app/api/v2/files/move/route.ts | 76 + apps/sim/app/api/v2/files/route.test.ts | 322 +++++ apps/sim/app/api/v2/files/route.ts | 87 +- apps/sim/app/api/v2/files/utils.ts | 23 + .../[id]/files/[fileId]/content/route.ts | 109 +- .../[id]/files/[fileId]/share/route.ts | 176 +-- .../files/folders/[folderId]/restore/route.ts | 8 +- .../[id]/files/folders/[folderId]/route.ts | 4 +- .../workspaces/[id]/files/folders/route.ts | 8 +- apps/sim/lib/api/contracts/v2/files.ts | 276 +++- .../workspace-file-folder-manager.ts | 36 +- .../workspace/workspace-file-manager.ts | 54 +- .../workspace-files/orchestration/content.ts | 98 ++ .../file-folder-lifecycle.test.ts | 219 +++ .../orchestration/file-folder-lifecycle.ts | 60 +- .../workspace-files/orchestration/index.ts | 16 +- .../workspace-files/orchestration/share.ts | 165 +++ scripts/check-api-validation-contracts.ts | 4 +- 31 files changed, 4214 insertions(+), 426 deletions(-) create mode 100644 apps/sim/app/api/v2/files/[fileId]/content/route.test.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/content/route.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/restore/route.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/route.test.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/share/route.test.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/share/route.ts create mode 100644 apps/sim/app/api/v2/files/bulk-archive/route.test.ts create mode 100644 apps/sim/app/api/v2/files/bulk-archive/route.ts create mode 100644 apps/sim/app/api/v2/files/move/route.test.ts create mode 100644 apps/sim/app/api/v2/files/move/route.ts create mode 100644 apps/sim/app/api/v2/files/route.test.ts create mode 100644 apps/sim/app/api/v2/files/utils.ts create mode 100644 apps/sim/lib/workspace-files/orchestration/content.ts create mode 100644 apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts create mode 100644 apps/sim/lib/workspace-files/orchestration/share.ts diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 402866bc262..81df2b36c50 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -23,7 +23,7 @@ "tags": [ { "name": "Files", - "description": "Upload, download, list, and archive workspace files (v2). Workspace-scoped via the required workspaceId query parameter." + "description": "Upload, download, list, rename, archive, restore, share, and replace the contents of workspace files (v2). Workspace-scoped via the required workspaceId query parameter or body field." }, { "name": "Audit Logs", @@ -40,7 +40,7 @@ "get": { "operationId": "listFiles", "summary": "List Files", - "description": "List the active files in a workspace with opaque cursor pagination. Results are ordered by upload time. Pass the `nextCursor` from a previous response to fetch the next page; a `null` `nextCursor` means there are no more results.", + "description": "List a workspace's files with opaque cursor pagination. Results are ordered by upload time. Pass the `nextCursor` from a previous response to fetch the next page; a `null` `nextCursor` means there are no more results. Use `scope=archived` to page through Recently Deleted — that is how you find the id of a file to restore.", "tags": ["Files"], "x-codeSamples": [ { @@ -54,6 +54,17 @@ { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "`active` (the default) lists live files; `archived` lists the ones in Recently Deleted, which is how you find an id to restore.", + "schema": { + "type": "string", + "enum": ["active", "archived"], + "default": "active" + } + }, { "name": "limit", "in": "query", @@ -97,8 +108,11 @@ "size": 1024, "type": "text/csv", "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "folderPath": "Reports/Q1", "uploadedBy": "user_abc123", - "uploadedAt": "2026-01-15T10:30:00Z" + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z" } ], "nextCursor": "eyJ1cGxvYWRlZEF0IjoiMjAyNi0wMS0xNVQxMDozMDowMFoiLCJpZCI6IndmX1YxU3RHWFI4ejVqZEhpNkJteVQ5MSJ9" @@ -126,7 +140,7 @@ "post": { "operationId": "uploadFile", "summary": "Upload File", - "description": "Upload a file to a workspace as `multipart/form-data` with a single `file` field. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the request body is buffered. Maximum file size is 100MB. Duplicate filenames within a workspace are rejected. Returns `201 Created`.", + "description": "Upload a file to a workspace as `multipart/form-data` with a single `file` field. The workspace — and the optional target `folderId` — are supplied as query parameters (not form fields) so authorization runs before the request body is buffered. Maximum file size is 100MB. A name already taken in the destination folder is **not** an error: the name is auto-suffixed (`data.csv` -> `data (1).csv`), matching the in-app uploader, so a `201` can come back with a `name` different from the one you sent — always read `name` from the response rather than assuming it. `409` is returned only if a unique name cannot be allocated after several attempts. Use `PATCH /api/v2/files/{fileId}` if you need a specific name to be exact-or-fail. Returns `201 Created`.\n\nPresigned upload is not part of the public API: it debits the storage quota only in a separate register step, so a caller that never registers would leave unaccounted bytes in storage. This buffered path debits inside the upload transaction.", "tags": ["Files"], "x-codeSamples": [ { @@ -139,6 +153,16 @@ "parameters": [ { "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "folderId", + "in": "query", + "required": false, + "description": "Target file folder. Omit to upload to the workspace root. Supplied as a query parameter, like `workspaceId`, so authorization runs before the multipart body is buffered.", + "schema": { + "type": "string", + "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + } } ], "requestBody": { @@ -186,8 +210,11 @@ "size": 1024, "type": "text/csv", "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "folderPath": "Reports/Q1", "uploadedBy": "user_abc123", - "uploadedAt": "2026-01-15T10:30:00Z" + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z" } } } @@ -216,7 +243,7 @@ "$ref": "#/components/responses/Forbidden" }, "409": { - "description": "A file with the same name already exists in this workspace.", + "description": "A unique filename could not be allocated in the destination folder after several attempts. An ordinary name collision is auto-suffixed instead, not rejected.", "content": { "application/json": { "schema": { @@ -225,7 +252,7 @@ "example": { "error": { "code": "CONFLICT", - "message": "A file with this name already exists in the workspace" + "message": "A file named \"data.csv\" already exists in this workspace" } } } @@ -426,6 +453,112 @@ "$ref": "#/components/responses/InternalError" } } + }, + "patch": { + "operationId": "renameFile", + "summary": "Rename File", + "description": "Rename a file. Renaming only — use `POST /api/v2/files/move` to change which folder a file lives in. A name already taken in the same folder is rejected with `409`.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"name\": \"renamed.csv\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "name"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the file.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "The new filename. Cannot contain `/`, `\\`, or be `.` / `..`.", + "example": "renamed.csv" + } + } + }, + "example": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "renamed.csv" + } + } + } + }, + "responses": { + "200": { + "description": "The renamed file.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileResponse" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "name": "renamed.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "folderPath": "Reports/Q1", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } } }, "/api/v2/audit-logs": { @@ -694,115 +827,818 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." - } - }, - "parameters": { - "WorkspaceIdQuery": { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "The unique identifier of the workspace.", - "schema": { - "type": "string", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" - } - }, - "FileIdPath": { - "name": "fileId", - "in": "path", - "required": true, - "description": "The unique identifier of the file.", - "schema": { - "type": "string", - "example": "wf_V1StGXR8z5jdHi6BmyT91" - } - }, - "Cursor": { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque pagination cursor. Pass the `nextCursor` value from a previous response to fetch the next page.", - "schema": { - "type": "string" - } - } - }, - "headers": { - "X-RateLimit-Limit": { - "description": "The maximum number of requests permitted in the current rate-limit window.", - "schema": { - "type": "integer", - "example": 100 - } - }, - "X-RateLimit-Remaining": { - "description": "The number of requests remaining in the current rate-limit window.", - "schema": { - "type": "integer", - "example": 95 - } - }, - "X-RateLimit-Reset": { - "description": "ISO 8601 timestamp at which the current rate-limit window resets.", - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-01-15T11:00:00Z" - } - } }, - "schemas": { - "V2File": { - "type": "object", - "description": "A workspace file as exposed by the v2 surface.", - "required": ["id", "name", "size", "type", "key", "uploadedBy", "uploadedAt"], - "properties": { - "id": { - "type": "string", - "description": "Unique file identifier.", - "example": "wf_V1StGXR8z5jdHi6BmyT91" + "/api/v2/files/{fileId}/restore": { + "post": { + "operationId": "restoreFile", + "summary": "Restore File", + "description": "Restore an archived file. Find archived ids with `GET /api/v2/files?scope=archived`. If the original name has since been taken by a live file, the file is restored under a suffixed name.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/restore\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the file.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + } + }, + "example": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + } + } + }, + "responses": { + "200": { + "description": "The file was restored.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2RestoreFileResponse" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "restored": true + } + } + } + } }, - "name": { - "type": "string", - "description": "Original filename.", - "example": "data.csv" + "400": { + "$ref": "#/components/responses/BadRequest" }, - "size": { - "type": "integer", - "minimum": 0, - "description": "File size in bytes.", - "example": 1024 + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "type": { - "type": "string", - "description": "MIME type of the file.", - "example": "text/csv" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "key": { - "type": "string", - "description": "Storage key for the file.", - "example": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv" + "404": { + "$ref": "#/components/responses/NotFound" }, - "uploadedBy": { - "type": "string", - "description": "User ID of the uploader.", - "example": "user_abc123" + "409": { + "$ref": "#/components/responses/Conflict" }, - "uploadedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp of when the file was uploaded.", - "example": "2026-01-15T10:30:00Z" - } + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/move": { + "post": { + "operationId": "moveFileItems", + "summary": "Move Files and Folders", + "description": "Move files and/or folders into a folder. `targetFolderId: null` — or omitting it — moves the selection to the workspace root. At least one of `fileIds` or `folderIds` must be non-empty. The whole selection moves under one lock, so a name collision at the destination fails the request with `409` instead of applying part of it.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/move\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"fileIds\": [\"wf_V1StGXR8z5jdHi6BmyT91\"], \"targetFolderId\": \"fold_9Kq2mZ7pR4tLxWc0Ye3Nu\"}'" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the items.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "fileIds": { + "type": "array", + "maxItems": 1000, + "default": [], + "items": { + "type": "string" + }, + "description": "Files to move." + }, + "folderIds": { + "type": "array", + "maxItems": 1000, + "default": [], + "items": { + "type": "string" + }, + "description": "Folders to move. Descendants follow their folder." + }, + "targetFolderId": { + "type": ["string", "null"], + "description": "Destination folder. `null` or omitted moves to the workspace root.", + "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + } + } + }, + "examples": { + "intoFolder": { + "summary": "Move two files into a folder", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "fileIds": ["wf_V1StGXR8z5jdHi6BmyT91", "wf_2QrTb9xLm4PvZc7Ns1Ka"], + "targetFolderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + } + }, + "toRoot": { + "summary": "Move a folder back to the workspace root", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "folderIds": ["fold_9Kq2mZ7pR4tLxWc0Ye3Nu"], + "targetFolderId": null + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The items were moved.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2MoveFileItemsResponse" + }, + "example": { + "data": { + "movedItems": { + "files": 2, + "folders": 0 + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/bulk-archive": { + "post": { + "operationId": "bulkArchiveFileItems", + "summary": "Archive Files and Folders", + "description": "Archive (soft delete) files and/or folders in one call. Archiving a folder cascades to everything under it, so `deletedItems` reports totals larger than the selection. Archived items remain listable via `scope=archived` and can be restored.\n\n**This endpoint is best-effort and idempotent.** Ids that do not exist, belong to another workspace, or are already archived are skipped rather than failing the request — the call still returns `200`. `deletedItems` is what was actually archived, so compare it against your selection if you need to detect that something was skipped. The single-item `DELETE /api/v2/files/{fileId}` does return `404` for a missing id.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/bulk-archive\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"fileIds\": [\"wf_V1StGXR8z5jdHi6BmyT91\"], \"folderIds\": [\"fold_9Kq2mZ7pR4tLxWc0Ye3Nu\"]}'" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the items.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "fileIds": { + "type": "array", + "maxItems": 1000, + "default": [], + "items": { + "type": "string" + }, + "description": "Files to archive." + }, + "folderIds": { + "type": "array", + "maxItems": 1000, + "default": [], + "items": { + "type": "string" + }, + "description": "Folders to archive, together with their contents." + } + } + }, + "example": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "fileIds": ["wf_V1StGXR8z5jdHi6BmyT91"], + "folderIds": ["fold_9Kq2mZ7pR4tLxWc0Ye3Nu"] + } + } + } + }, + "responses": { + "200": { + "description": "The items were archived.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2BulkArchiveFileItemsResponse" + }, + "example": { + "data": { + "deletedItems": { + "files": 3, + "folders": 1 + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/{fileId}/share": { + "get": { + "operationId": "getFileShare", + "summary": "Get File Share", + "description": "Read a file's public share state. `share` is `null` when the file has never been shared. The encrypted password is never returned — `hasPassword` is the only password signal.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, archive the file instead.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share?workspaceId=YOUR_WORKSPACE_ID\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The file's share state.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2GetFileShareResponse" + }, + "example": { + "data": { + "share": { + "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", + "token": "share-token-example", + "url": "https://www.sim.ai/f/share-token-example", + "isActive": true, + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "authType": "public", + "hasPassword": false, + "allowedEmails": [] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "operationId": "upsertFileShare", + "summary": "Enable or Disable File Share", + "description": "Enable or disable a file's public share. Requires workspace `write`.\n\nThe share token is always server-generated; there is no way to supply one. `authType` selects how the link is gated: `public` (anyone with the link), `password` (requires `password` on first enable), or `email` / `sso` (requires a non-empty `allowedEmails`). Omitting `authType` on a re-enable keeps the stored mode, and the org access-control policy is evaluated against that stored mode rather than against `public`. Disabling is never blocked by the policy.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, archive the file instead.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"isActive\": true, \"authType\": \"public\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "isActive"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the file.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "isActive": { + "type": "boolean", + "description": "Whether the share should resolve. `false` disables without revoking." + }, + "authType": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "How the link is gated. Omit on a re-enable to keep the stored mode." + }, + "password": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Plaintext password for a `password` share. Required on first enable; omit to keep the stored one." + }, + "allowedEmails": { + "type": "array", + "maxItems": 200, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 320 + }, + "description": "Allowed addresses or `@domain` patterns for an `email` / `sso` share. Must be non-empty when enabling one." + } + } + }, + "examples": { + "publicLink": { + "summary": "Enable a public link", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isActive": true, + "authType": "public" + } + }, + "passwordProtected": { + "summary": "Enable a password-protected link", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isActive": true, + "authType": "password", + "password": "EXAMPLE_PASSWORD" + } + }, + "disable": { + "summary": "Disable (keeps the token and stored config)", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isActive": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The share after the update.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2UpsertFileShareResponse" + }, + "example": { + "data": { + "share": { + "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", + "token": "share-token-example", + "url": "https://www.sim.ai/f/share-token-example", + "isActive": true, + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "authType": "public", + "hasPassword": false, + "allowedEmails": [] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/{fileId}/content": { + "put": { + "operationId": "updateFileContent", + "summary": "Replace File Content", + "description": "Replace a file's bytes. This is a full replace, not an append: `content` becomes the entire body of the file. Use `encoding: \"base64\"` for non-UTF-8 bytes. The decoded body is capped at 50MB and still debits the workspace storage quota, so a write that would push the payer past its limit fails with `413`.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/content\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"content\": \"id,name\\\\n1,alpha\\\\n\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "content"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the file.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "content": { + "type": "string", + "description": "The file's new full contents, interpreted per `encoding`." + }, + "encoding": { + "type": "string", + "enum": ["utf-8", "base64"], + "default": "utf-8", + "description": "How to decode `content` into bytes." + } + } + }, + "examples": { + "text": { + "summary": "Replace with UTF-8 text", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "content": "id,name\n1,alpha\n" + } + }, + "binary": { + "summary": "Replace with base64-encoded bytes", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "content": "aWQsbmFtZQoxLGFscGhhCg==", + "encoding": "base64" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated file.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileResponse" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 16, + "type": "text/csv", + "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "folderPath": "Reports/Q1", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T11:05:00Z" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace.", + "schema": { + "type": "string", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + }, + "FileIdPath": { + "name": "fileId", + "in": "path", + "required": true, + "description": "The unique identifier of the file.", + "schema": { + "type": "string", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + } + }, + "Cursor": { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor. Pass the `nextCursor` value from a previous response to fetch the next page.", + "schema": { + "type": "string" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 100 + } + }, + "X-RateLimit-Remaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 95 + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-01-15T11:00:00Z" + } + } + }, + "schemas": { + "V2File": { + "type": "object", + "description": "A workspace file as exposed by the v2 surface.", + "required": [ + "id", + "name", + "size", + "type", + "key", + "folderId", + "folderPath", + "uploadedBy", + "uploadedAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique file identifier.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "name": { + "type": "string", + "description": "Original filename.", + "example": "data.csv" + }, + "size": { + "type": "integer", + "minimum": 0, + "description": "File size in bytes.", + "example": 1024 + }, + "type": { + "type": "string", + "description": "MIME type of the file.", + "example": "text/csv" + }, + "key": { + "type": "string", + "description": "Storage key for the file.", + "example": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv" + }, + "uploadedBy": { + "type": "string", + "description": "User ID of the uploader.", + "example": "user_abc123" + }, + "uploadedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of when the file was uploaded.", + "example": "2026-01-15T10:30:00Z" + }, + "folderId": { + "type": ["string", "null"], + "description": "The containing file folder, or null when the file sits at the workspace root.", + "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + }, + "folderPath": { + "type": ["string", "null"], + "description": "Slash-joined folder names for `folderId`, or null at the workspace root.", + "example": "Reports/Q1" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of the last write, content or metadata.", + "example": "2026-01-15T10:30:00Z" + } } }, "V2DeleteFileResult": { @@ -993,6 +1829,201 @@ } } } + }, + "V2FileItemCounts": { + "type": "object", + "description": "Counts of what an operation actually touched. A folder cascades to its descendants, so these exceed the size of the selection.", + "required": ["files", "folders"], + "properties": { + "files": { + "type": "integer", + "description": "Number of files affected.", + "example": 3 + }, + "folders": { + "type": "integer", + "description": "Number of folders affected.", + "example": 1 + } + } + }, + "V2FileShare": { + "type": "object", + "description": "A file's public share. Never carries the storage key or the encrypted password — `hasPassword` is the only password signal exposed.", + "required": [ + "id", + "token", + "url", + "isActive", + "resourceType", + "resourceId", + "authType", + "hasPassword", + "allowedEmails" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique share identifier.", + "example": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb" + }, + "token": { + "type": "string", + "description": "The public token embedded in the share URL. Always server-generated.", + "example": "share-token-example" + }, + "url": { + "type": "string", + "format": "uri", + "description": "The public share URL.", + "example": "https://www.sim.ai/f/share-token-example" + }, + "isActive": { + "type": "boolean", + "description": "Whether the share currently resolves. Disabling does not revoke — see the endpoint description." + }, + "resourceType": { + "type": "string", + "enum": ["file", "folder"], + "description": "The kind of resource shared. Always `file` on this surface." + }, + "resourceId": { + "type": "string", + "description": "The shared resource id.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "authType": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "How the share is gated." + }, + "hasPassword": { + "type": "boolean", + "description": "Whether a password is stored for this share." + }, + "allowedEmails": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Allow-list of addresses or `@domain` patterns for `email`/`sso` shares. Empty otherwise." + } + } + }, + "V2RestoreFileResult": { + "type": "object", + "description": "Acknowledgement returned by a successful file restore.", + "required": ["id", "restored"], + "properties": { + "id": { + "type": "string", + "description": "The unique identifier of the restored file.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "restored": { + "type": "boolean", + "const": true, + "description": "Always true on a successful restore." + } + } + }, + "V2MoveFileItemsResult": { + "type": "object", + "description": "What the move actually relocated.", + "required": ["movedItems"], + "properties": { + "movedItems": { + "$ref": "#/components/schemas/V2FileItemCounts" + } + } + }, + "V2BulkArchiveFileItemsResult": { + "type": "object", + "description": "What the archive actually soft-deleted, including the cascade.", + "required": ["deletedItems"], + "properties": { + "deletedItems": { + "$ref": "#/components/schemas/V2FileItemCounts" + } + } + }, + "V2GetFileShareResult": { + "type": "object", + "description": "The file's share state, or null when the file has never been shared.", + "required": ["share"], + "properties": { + "share": { + "oneOf": [ + { + "$ref": "#/components/schemas/V2FileShare" + }, + { + "type": "null" + } + ], + "description": "The share, or null when the file has never been shared." + } + } + }, + "V2UpsertFileShareResult": { + "type": "object", + "description": "The share after the upsert.", + "required": ["share"], + "properties": { + "share": { + "$ref": "#/components/schemas/V2FileShare" + } + } + }, + "V2RestoreFileResponse": { + "type": "object", + "description": "The result of restoring a file.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2RestoreFileResult" + } + } + }, + "V2MoveFileItemsResponse": { + "type": "object", + "description": "The result of a move.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2MoveFileItemsResult" + } + } + }, + "V2BulkArchiveFileItemsResponse": { + "type": "object", + "description": "The result of a bulk archive.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2BulkArchiveFileItemsResult" + } + } + }, + "V2GetFileShareResponse": { + "type": "object", + "description": "The file's public share state.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2GetFileShareResult" + } + } + }, + "V2UpsertFileShareResponse": { + "type": "object", + "description": "The share after enabling or disabling it.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2UpsertFileShareResult" + } + } } }, "responses": { @@ -1119,6 +2150,38 @@ } } } + }, + "Conflict": { + "description": "The request conflicts with existing state — most often a name already taken in the destination folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "A file named \"data.csv\" already exists in this workspace" + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The body exceeds the per-request size limit, or accepting it would push the workspace past its storage quota.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Storage limit exceeded" + } + } + } + } } } } diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 97f7372f7a6..5fb64caf1df 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -40,6 +40,10 @@ export type ApiEndpoint = | 'table-columns' | 'files' | 'file-detail' + | 'file-share' + | 'file-content' + | 'file-move' + | 'file-bulk-archive' | 'knowledge' | 'knowledge-detail' | 'knowledge-search' diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts new file mode 100644 index 00000000000..b2fd918c924 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -0,0 +1,186 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformUpdateContent } = vi.hoisted( + () => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockPerformUpdateContent: vi.fn(), + }) +) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performUpdateWorkspaceFileContent: mockPerformUpdateContent, +})) + +import { PUT } from '@/app/api/v2/files/[fileId]/content/route' + +const WS = 'workspace-1' +const FILE_ID = 'wf_1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const RECORD = { + id: FILE_ID, + workspaceId: WS, + name: 'data.csv', + key: 'workspace/ws/1-x-data.csv', + path: '/api/files/serve/x', + size: 8, + type: 'text/csv', + uploadedBy: 'user-1', + folderId: null, + folderPath: null, + uploadedAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-03T00:00:00Z'), +} + +const callPut = (body: unknown) => + PUT( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/content`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ fileId: FILE_ID }) } + ) + +describe('PUT /api/v2/files/[fileId]/content', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformUpdateContent.mockResolvedValue({ success: true, file: RECORD }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) + + expect(res.status).toBe(404) + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) + + it('400s when content is missing', async () => { + const res = await callPut({ workspaceId: WS }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) + + it('400s on an encoding outside the enum', async () => { + const res = await callPut({ workspaceId: WS, content: 'x', encoding: 'latin1' }) + expect(res.status).toBe(400) + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) + expect(res.status).toBe(403) + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('replaces the content and returns the updated file', async () => { + const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ + id: FILE_ID, + name: 'data.csv', + size: 8, + type: 'text/csv', + key: 'workspace/ws/1-x-data.csv', + folderId: null, + folderPath: null, + uploadedBy: 'user-1', + uploadedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-03T00:00:00.000Z', + }) + expect(mockPerformUpdateContent).toHaveBeenCalledWith({ + workspaceId: WS, + fileId: FILE_ID, + userId: 'user-1', + content: 'id,name\n', + encoding: 'utf-8', + request: expect.anything(), + }) + }) + + it('forwards base64 encoding through to the orchestration', async () => { + await callPut({ workspaceId: WS, content: 'aWQsbmFtZQo=', encoding: 'base64' }) + expect(mockPerformUpdateContent).toHaveBeenCalledWith( + expect.objectContaining({ encoding: 'base64' }) + ) + }) + + it('maps a payload_too_large errorCode to 413 rather than string-sniffing', async () => { + mockPerformUpdateContent.mockResolvedValue({ + success: false, + error: 'Storage limit exceeded. Used: 5.10GB, Limit: 5GB', + errorCode: 'payload_too_large', + }) + + const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) + const body = await res.json() + + expect(res.status).toBe(413) + expect(body.error.code).toBe('PAYLOAD_TOO_LARGE') + expect(body.error.message).toContain('Storage limit exceeded') + }) + + it('maps a not_found errorCode to 404', async () => { + mockPerformUpdateContent.mockResolvedValue({ + success: false, + error: 'File not found', + errorCode: 'not_found', + }) + + const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts new file mode 100644 index 00000000000..fb310ec2e94 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -0,0 +1,80 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performUpdateWorkspaceFileContent } from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2File } from '@/app/api/v2/files/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileContentAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FileRouteParams { + params: Promise<{ fileId: string }> +} + +/** + * PUT /api/v2/files/[fileId]/content — Replace a file's bytes. + * + * A full replace, not an append: `content` becomes the entire body of the file. + * `encoding: 'base64'` carries non-UTF-8 bytes. The decoded body is capped at + * 50 MB and still debits the workspace storage quota, so a write that would push + * the payer past its limit fails with 413. + */ +export const PUT = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-content') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateFileContentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId, content, encoding } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performUpdateWorkspaceFileContent({ + workspaceId, + fileId, + userId, + content, + encoding, + request, + }) + + if (!result.success || !result.file) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to update file content') + ) + } + + return v2Data(toV2File(result.file), { rateLimit }) + } catch (error) { + logger.error('Error updating file content', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts new file mode 100644 index 00000000000..0610ef9649e --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts @@ -0,0 +1,130 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformRestore } = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockPerformRestore: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performRestoreWorkspaceFile: mockPerformRestore, +})) + +import { POST } from '@/app/api/v2/files/[fileId]/restore/route' + +const WS = 'workspace-1' +const FILE_ID = 'wf_1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const callRestore = (body: unknown) => + POST( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/restore`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ fileId: FILE_ID }) } + ) + +describe('POST /api/v2/files/[fileId]/restore', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformRestore.mockResolvedValue({ success: true }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callRestore({ workspaceId: WS }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockPerformRestore).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callRestore({}) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformRestore).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callRestore({ workspaceId: WS }) + expect(res.status).toBe(403) + expect(mockPerformRestore).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callRestore({ workspaceId: WS }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('restores the file and acknowledges', async () => { + const res = await callRestore({ workspaceId: WS }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ id: FILE_ID, restored: true }) + expect(mockPerformRestore).toHaveBeenCalledWith({ + workspaceId: WS, + fileId: FILE_ID, + userId: 'user-1', + }) + }) + + it('maps a not_found errorCode to 404 rather than a blanket 500', async () => { + mockPerformRestore.mockResolvedValue({ + success: false, + error: 'File not found', + errorCode: 'not_found', + }) + + const res = await callRestore({ workspaceId: WS }) + const body = await res.json() + + expect(res.status).toBe(404) + expect(body.error.code).toBe('NOT_FOUND') + expect(body.error.message).toBe('File not found') + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/restore/route.ts b/apps/sim/app/api/v2/files/[fileId]/restore/route.ts new file mode 100644 index 00000000000..0b559b9ed90 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/restore/route.ts @@ -0,0 +1,71 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2RestoreFileContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performRestoreWorkspaceFile } from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileRestoreAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FileRouteParams { + params: Promise<{ fileId: string }> +} + +/** + * POST /api/v2/files/[fileId]/restore — Restore an archived file. + * + * Find archived ids with `GET /api/v2/files?scope=archived`. A name collision + * with a live file is resolved by the manager's restore-name suffix, so the + * restored file may come back under a different name. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2RestoreFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performRestoreWorkspaceFile({ workspaceId, fileId, userId }) + + if (!result.success) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to restore file') + ) + } + + return v2Data({ id: fileId, restored: true as const }, { rateLimit }) + } catch (error) { + logger.error('Error restoring file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/route.test.ts new file mode 100644 index 00000000000..1afe6c6b97b --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -0,0 +1,333 @@ +/** + * @vitest-environment node + * + * Public v2 file detail: download, rename, archive. Covers the orchestration + * error mapping that replaced the route-local status switch. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetWorkspaceFile, + mockFetchWorkspaceFileBuffer, + mockPerformRename, + mockPerformDelete, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetWorkspaceFile: vi.fn(), + mockFetchWorkspaceFileBuffer: vi.fn(), + mockPerformRename: vi.fn(), + mockPerformDelete: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + getWorkspaceFile: mockGetWorkspaceFile, + fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer, +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performRenameWorkspaceFile: mockPerformRename, + performDeleteWorkspaceFileItems: mockPerformDelete, +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/files/[fileId]/route' + +const WS = 'workspace-1' +const FILE_ID = 'wf_1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +function buildRecord(overrides: Record = {}) { + return { + id: FILE_ID, + workspaceId: WS, + name: 'data.csv', + key: 'workspace/ws/1-x-data.csv', + path: '/api/files/serve/x', + size: 1024, + type: 'text/csv', + uploadedBy: 'user-1', + folderId: null, + folderPath: null, + uploadedAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const ctx = { params: Promise.resolve({ fileId: FILE_ID }) } + +const callDownload = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?${query}`), ctx) + +const callRename = (body: unknown) => + PATCH( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + ctx + ) + +const callDelete = (query: string) => + DELETE(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?${query}`), ctx) + +describe('GET /api/v2/files/[fileId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceFile.mockResolvedValue(buildRecord()) + mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('id,name\n')) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDownload(`workspaceId=${WS}`) + + expect(res.status).toBe(404) + expect(mockGetWorkspaceFile).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDownload('') + expect(res.status).toBe(400) + expect(mockGetWorkspaceFile).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callDownload(`workspaceId=${WS}`) + expect(res.status).toBe(403) + expect(mockGetWorkspaceFile).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDownload(`workspaceId=${WS}`) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('streams the bytes with rate-limit headers', async () => { + const res = await callDownload(`workspaceId=${WS}`) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('text/csv') + expect(res.headers.get('X-RateLimit-Remaining')).toBe('99') + expect(await res.text()).toBe('id,name\n') + }) +}) + +describe('PATCH /api/v2/files/[fileId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformRename.mockResolvedValue({ + success: true, + file: buildRecord({ name: 'renamed.csv' }), + }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) + + expect(res.status).toBe(404) + expect(mockPerformRename).not.toHaveBeenCalled() + }) + + it('400s on a name containing a path separator', async () => { + const res = await callRename({ workspaceId: WS, name: 'nested/renamed.csv' }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformRename).not.toHaveBeenCalled() + }) + + it('400s on an unknown body field', async () => { + const res = await callRename({ workspaceId: WS, name: 'renamed.csv', folderId: 'fold_1' }) + expect(res.status).toBe(400) + expect(mockPerformRename).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) + expect(res.status).toBe(403) + expect(mockPerformRename).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('renames and returns the public file shape', async () => { + const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ + id: FILE_ID, + name: 'renamed.csv', + size: 1024, + type: 'text/csv', + key: 'workspace/ws/1-x-data.csv', + folderId: null, + folderPath: null, + uploadedBy: 'user-1', + uploadedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }) + expect(mockPerformRename).toHaveBeenCalledWith({ + workspaceId: WS, + fileId: FILE_ID, + name: 'renamed.csv', + userId: 'user-1', + }) + }) + + it('maps a conflict errorCode to 409 through the shared mapper', async () => { + mockPerformRename.mockResolvedValue({ + success: false, + error: 'A file named "renamed.csv" already exists in this workspace', + errorCode: 'conflict', + }) + + const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) + const body = await res.json() + + expect(res.status).toBe(409) + expect(body.error.code).toBe('CONFLICT') + expect(body.error.message).toContain('already exists') + }) + + it('hides an unclassified failure behind a generic 500', async () => { + mockPerformRename.mockResolvedValue({ + success: false, + error: 'update "workspace_files" set ... failed', + errorCode: 'internal', + }) + + const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) + const body = await res.json() + + expect(res.status).toBe(500) + expect(body.error.message).toBe('Internal server error') + }) +}) + +describe('DELETE /api/v2/files/[fileId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformDelete.mockResolvedValue({ success: true, deletedItems: { files: 1, folders: 0 } }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete(`workspaceId=${WS}`) + + expect(res.status).toBe(404) + expect(mockPerformDelete).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockPerformDelete).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callDelete(`workspaceId=${WS}`) + expect(res.status).toBe(403) + expect(mockPerformDelete).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete(`workspaceId=${WS}`) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('archives the file and acknowledges', async () => { + const res = await callDelete(`workspaceId=${WS}`) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ id: FILE_ID, deleted: true }) + expect(mockPerformDelete).toHaveBeenCalledWith({ + workspaceId: WS, + userId: 'user-1', + fileIds: [FILE_ID], + request: expect.anything(), + }) + }) + + it('maps a not_found errorCode to 404', async () => { + mockPerformDelete.mockResolvedValue({ + success: false, + error: 'File not found', + errorCode: 'not_found', + }) + + const res = await callDelete(`workspaceId=${WS}`) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index d168015d793..e85ccd4b923 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -1,18 +1,27 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { v2DeleteFileContract, v2DownloadFileContract } from '@/lib/api/contracts/v2/files' +import { + v2DeleteFileContract, + v2DownloadFileContract, + v2RenameFileContract, +} from '@/lib/api/contracts/v2/files' import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { + performDeleteWorkspaceFileItems, + performRenameWorkspaceFile, +} from '@/lib/workspace-files/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2File } from '@/app/api/v2/files/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import type { V2ErrorCode } from '@/app/api/v2/lib/response' import { rateLimitHeaders, v2Data, v2Error, + v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, @@ -75,6 +84,50 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo } }) +/** + * PATCH /api/v2/files/[fileId] — Rename a file. + * + * Renaming only; use `POST /api/v2/files/move` to change a file's folder. + * Names that collide within the destination folder are rejected as `CONFLICT` — + * unlike upload, which auto-suffixes on the internal surface. + */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2RenameFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId, name } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performRenameWorkspaceFile({ workspaceId, fileId, name, userId }) + + if (!result.success || !result.file) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to rename file') + ) + } + + return v2Data(toV2File(result.file), { rateLimit }) + } catch (error) { + logger.error('Error renaming file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + /** * DELETE /api/v2/files/[fileId] — Archive (soft delete) a file. * @@ -112,15 +165,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil }) if (!result.success) { - const code: V2ErrorCode = - result.errorCode === 'not_found' - ? 'NOT_FOUND' - : result.errorCode === 'validation' - ? 'BAD_REQUEST' - : result.errorCode === 'conflict' - ? 'CONFLICT' - : 'INTERNAL_ERROR' - return v2Error(code, result.error || 'Failed to delete file') + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to delete file') + ) } logger.info(`Archived file ${fileId} from workspace ${workspaceId}`) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts new file mode 100644 index 00000000000..25b15d2f47b --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts @@ -0,0 +1,271 @@ +/** + * @vitest-environment node + * + * Public v2 file share. The two decisions that separate it from the internal + * route are pinned here: the caller-supplied `token` is rejected, and a bare + * re-enable keeps the token the orchestration already stored. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformGetShare, mockPerformUpsert } = + vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockPerformGetShare: vi.fn(), + mockPerformUpsert: vi.fn(), + })) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performGetWorkspaceFileShare: mockPerformGetShare, + performUpsertWorkspaceFileShare: mockPerformUpsert, +})) + +import { GET, PUT } from '@/app/api/v2/files/[fileId]/share/route' + +const WS = 'workspace-1' +const FILE_ID = 'wf_1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const SHARE = { + id: 'shr_1', + token: 'existing-token-abcd', + url: 'https://www.sim.ai/f/existing-token-abcd', + isActive: true, + resourceType: 'file' as const, + resourceId: FILE_ID, + authType: 'public' as const, + hasPassword: false, + allowedEmails: [] as string[], +} + +const ctx = { params: Promise.resolve({ fileId: FILE_ID }) } + +const callGet = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share?${query}`), ctx) + +const callPut = (body: unknown) => + PUT( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + ctx + ) + +describe('GET /api/v2/files/[fileId]/share', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformGetShare.mockResolvedValue({ success: true, share: SHARE }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet(`workspaceId=${WS}`) + + expect(res.status).toBe(404) + expect(mockPerformGetShare).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect(mockPerformGetShare).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callGet(`workspaceId=${WS}`) + expect(res.status).toBe(403) + expect(mockPerformGetShare).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet(`workspaceId=${WS}`) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('reads at workspace read level and returns the share', async () => { + const res = await callGet(`workspaceId=${WS}`) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ share: SHARE }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(expect.anything(), 'user-1', WS, 'read') + expect(mockPerformGetShare).toHaveBeenCalledWith({ workspaceId: WS, fileId: FILE_ID }) + }) + + it('returns a null share for a file that was never shared', async () => { + mockPerformGetShare.mockResolvedValue({ success: true, share: null }) + const res = await callGet(`workspaceId=${WS}`) + expect((await res.json()).data).toEqual({ share: null }) + }) +}) + +describe('PUT /api/v2/files/[fileId]/share', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformUpsert.mockResolvedValue({ success: true, share: SHARE }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPut({ workspaceId: WS, isActive: true }) + + expect(res.status).toBe(404) + expect(mockPerformUpsert).not.toHaveBeenCalled() + }) + + it('400s when isActive is missing', async () => { + const res = await callPut({ workspaceId: WS }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformUpsert).not.toHaveBeenCalled() + }) + + it('rejects a caller-supplied token instead of minting a predictable URL', async () => { + const res = await callPut({ + workspaceId: WS, + isActive: true, + token: 'attacker-chosen-token', + }) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformUpsert).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callPut({ workspaceId: WS, isActive: true }) + expect(res.status).toBe(403) + expect(mockPerformUpsert).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPut({ workspaceId: WS, isActive: true }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('enables the share at workspace write level and never forwards a token', async () => { + const res = await callPut({ + workspaceId: WS, + isActive: true, + authType: 'password', + password: 'hunter2hunter2', + }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ share: SHARE }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + WS, + 'write' + ) + expect(mockPerformUpsert).toHaveBeenCalledWith({ + workspaceId: WS, + fileId: FILE_ID, + userId: 'user-1', + isActive: true, + authType: 'password', + password: 'hunter2hunter2', + allowedEmails: undefined, + request: expect.anything(), + }) + expect(mockPerformUpsert.mock.calls[0][0]).not.toHaveProperty('token') + }) + + it('preserves the existing token on a bare re-enable', async () => { + const res = await callPut({ workspaceId: WS, isActive: true }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.share.token).toBe('existing-token-abcd') + expect(body.data.share.url).toBe('https://www.sim.ai/f/existing-token-abcd') + // No authType either: the orchestration resolves the stored one, so the + // access-control gate is evaluated against the real mode, not 'public'. + expect(mockPerformUpsert).toHaveBeenCalledWith( + expect.objectContaining({ isActive: true, authType: undefined }) + ) + }) + + it('maps a forbidden errorCode from the access-control policy to 403', async () => { + mockPerformUpsert.mockResolvedValue({ + success: false, + error: 'Public file sharing is not allowed based on your permission group settings', + errorCode: 'forbidden', + }) + + const res = await callPut({ workspaceId: WS, isActive: true }) + const body = await res.json() + + expect(res.status).toBe(403) + expect(body.error.code).toBe('FORBIDDEN') + expect(body.error.message).toContain('not allowed') + }) + + it('maps a validation errorCode to 400', async () => { + mockPerformUpsert.mockResolvedValue({ + success: false, + error: 'Password is required for password-protected shares', + errorCode: 'validation', + }) + + const res = await callPut({ workspaceId: WS, isActive: true, authType: 'password' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toBe( + 'Password is required for password-protected shares' + ) + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.ts new file mode 100644 index 00000000000..088672432c5 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.ts @@ -0,0 +1,130 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2GetFileShareContract, v2UpsertFileShareContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + performGetWorkspaceFileShare, + performUpsertWorkspaceFileShare, +} from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileShareAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FileRouteParams { + params: Promise<{ fileId: string }> +} + +/** + * GET /api/v2/files/[fileId]/share — Read a file's public share state. + * + * `null` means the file has never been shared. `hasPassword` is the only signal + * carried for a password-gated share; the ciphertext is never exposed. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-share') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetFileShareContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const result = await performGetWorkspaceFileShare({ workspaceId, fileId }) + + if (!result.success) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to fetch share') + ) + } + + return v2Data({ share: result.share ?? null }, { rateLimit }) + } catch (error) { + logger.error('Error fetching file share', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * PUT /api/v2/files/[fileId]/share — Enable or disable a file's public share. + * + * Requires workspace `write`, matching the UI. The share token is always + * server-generated: the internal surface accepts a caller-supplied one so the UI + * can render a link before saving, but over an API key that would mint + * predictable public URLs and collide with the token unique index. + * + * `isActive: false` disables, it does not revoke — the token and the stored + * password / allow-list survive, so re-enabling resurrects the same URL. + */ +export const PUT = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-share') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpsertFileShareContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId, isActive, authType, password, allowedEmails } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performUpsertWorkspaceFileShare({ + workspaceId, + fileId, + userId, + isActive, + authType, + password, + allowedEmails, + request, + }) + + if (!result.success || !result.share) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to update share') + ) + } + + return v2Data({ share: result.share }, { rateLimit }) + } catch (error) { + logger.error('Error updating file share', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/bulk-archive/route.test.ts b/apps/sim/app/api/v2/files/bulk-archive/route.test.ts new file mode 100644 index 00000000000..9d4982abc62 --- /dev/null +++ b/apps/sim/app/api/v2/files/bulk-archive/route.test.ts @@ -0,0 +1,127 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformDelete } = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockPerformDelete: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performDeleteWorkspaceFileItems: mockPerformDelete, +})) + +import { POST } from '@/app/api/v2/files/bulk-archive/route' + +const WS = 'workspace-1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const callArchive = (body: unknown) => + POST( + new NextRequest('http://localhost:3000/api/v2/files/bulk-archive', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) + +describe('POST /api/v2/files/bulk-archive', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformDelete.mockResolvedValue({ success: true, deletedItems: { files: 3, folders: 1 } }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callArchive({ workspaceId: WS, fileIds: ['wf_1'] }) + + expect(res.status).toBe(404) + expect(mockPerformDelete).not.toHaveBeenCalled() + }) + + it('400s when the selection is empty', async () => { + const res = await callArchive({ workspaceId: WS, fileIds: [], folderIds: [] }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformDelete).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callArchive({ workspaceId: WS, fileIds: ['wf_1'] }) + expect(res.status).toBe(403) + expect(mockPerformDelete).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callArchive({ workspaceId: WS, fileIds: ['wf_1'] }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('archives the selection and reports the full cascade', async () => { + const res = await callArchive({ workspaceId: WS, fileIds: ['wf_1'], folderIds: ['fold_1'] }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ deletedItems: { files: 3, folders: 1 } }) + expect(mockPerformDelete).toHaveBeenCalledWith({ + workspaceId: WS, + userId: 'user-1', + fileIds: ['wf_1'], + folderIds: ['fold_1'], + request: expect.anything(), + }) + }) + + it('maps a not_found errorCode to 404', async () => { + mockPerformDelete.mockResolvedValue({ + success: false, + error: 'File not found', + errorCode: 'not_found', + }) + + const res = await callArchive({ workspaceId: WS, fileIds: ['wf_missing'] }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) +}) diff --git a/apps/sim/app/api/v2/files/bulk-archive/route.ts b/apps/sim/app/api/v2/files/bulk-archive/route.ts new file mode 100644 index 00000000000..3f8ff8c256f --- /dev/null +++ b/apps/sim/app/api/v2/files/bulk-archive/route.ts @@ -0,0 +1,75 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2BulkArchiveFileItemsContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileBulkArchiveAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * POST /api/v2/files/bulk-archive — Archive (soft delete) files and folders. + * + * Archiving a folder cascades to its descendants; `deletedItems` reports the + * totals actually archived, which therefore exceed the selection size. Archived + * items stay listable via `scope=archived` and can be restored. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'file-bulk-archive') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2BulkArchiveFileItemsContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, fileIds, folderIds } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId, + fileIds, + folderIds, + request, + }) + + if (!result.success || !result.deletedItems) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to archive file items') + ) + } + + return v2Data({ deletedItems: result.deletedItems }, { rateLimit }) + } catch (error) { + logger.error('Error archiving file items', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/move/route.test.ts b/apps/sim/app/api/v2/files/move/route.test.ts new file mode 100644 index 00000000000..02c232ba4a7 --- /dev/null +++ b/apps/sim/app/api/v2/files/move/route.test.ts @@ -0,0 +1,139 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformMove } = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockPerformMove: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performMoveWorkspaceFileItems: mockPerformMove, +})) + +import { POST } from '@/app/api/v2/files/move/route' + +const WS = 'workspace-1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const callMove = (body: unknown) => + POST( + new NextRequest('http://localhost:3000/api/v2/files/move', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) + +describe('POST /api/v2/files/move', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformMove.mockResolvedValue({ success: true, movedItems: { files: 2, folders: 0 } }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) + + expect(res.status).toBe(404) + expect(mockPerformMove).not.toHaveBeenCalled() + }) + + it('400s when the selection is empty', async () => { + const res = await callMove({ workspaceId: WS }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformMove).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) + expect(res.status).toBe(403) + expect(mockPerformMove).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('moves the selection into the target folder', async () => { + const res = await callMove({ + workspaceId: WS, + fileIds: ['wf_1', 'wf_2'], + targetFolderId: 'fold_1', + }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ movedItems: { files: 2, folders: 0 } }) + expect(mockPerformMove).toHaveBeenCalledWith({ + workspaceId: WS, + userId: 'user-1', + fileIds: ['wf_1', 'wf_2'], + folderIds: [], + targetFolderId: 'fold_1', + }) + }) + + it('treats an omitted targetFolderId as the workspace root', async () => { + await callMove({ workspaceId: WS, folderIds: ['fold_2'] }) + expect(mockPerformMove).toHaveBeenCalledWith( + expect.objectContaining({ folderIds: ['fold_2'], targetFolderId: null }) + ) + }) + + it('maps a conflict errorCode to 409 without partially applying', async () => { + mockPerformMove.mockResolvedValue({ + success: false, + error: 'A file named "data.csv" already exists in the destination folder', + errorCode: 'conflict', + }) + + const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'], targetFolderId: 'fold_1' }) + const body = await res.json() + + expect(res.status).toBe(409) + expect(body.error.code).toBe('CONFLICT') + }) +}) diff --git a/apps/sim/app/api/v2/files/move/route.ts b/apps/sim/app/api/v2/files/move/route.ts new file mode 100644 index 00000000000..bd48c7c55e7 --- /dev/null +++ b/apps/sim/app/api/v2/files/move/route.ts @@ -0,0 +1,76 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2MoveFileItemsContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileMoveAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * POST /api/v2/files/move — Move files and/or folders into a folder. + * + * `targetFolderId: null` (or an omitted field) moves the selection to the + * workspace root. The whole selection moves under one advisory lock, so a name + * collision at the destination fails the request as `CONFLICT` rather than + * partially applying. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'file-move') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2MoveFileItemsContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, fileIds, folderIds, targetFolderId } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performMoveWorkspaceFileItems({ + workspaceId, + userId, + fileIds, + folderIds, + targetFolderId: targetFolderId ?? null, + }) + + if (!result.success || !result.movedItems) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to move file items') + ) + } + + return v2Data({ movedItems: result.movedItems }, { rateLimit }) + } catch (error) { + logger.error('Error moving file items', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts new file mode 100644 index 00000000000..270661356a4 --- /dev/null +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -0,0 +1,322 @@ +/** + * @vitest-environment node + * + * Public v2 files list/upload: gate ordering, the `scope` split that makes + * Recently Deleted reachable, and folder-targeted upload. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockListWorkspaceFiles, + mockUploadWorkspaceFile, + mockGetWorkspaceFile, + mockReadFormDataWithLimit, + mockReadFileToBufferWithLimit, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListWorkspaceFiles: vi.fn(), + mockUploadWorkspaceFile: vi.fn(), + mockGetWorkspaceFile: vi.fn(), + mockReadFormDataWithLimit: vi.fn(), + mockReadFileToBufferWithLimit: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + listWorkspaceFiles: mockListWorkspaceFiles, + uploadWorkspaceFile: mockUploadWorkspaceFile, + getWorkspaceFile: mockGetWorkspaceFile, + FileConflictError: class FileConflictError extends Error {}, +})) + +vi.mock('@/lib/core/utils/stream-limits', () => ({ + readFormDataWithLimit: mockReadFormDataWithLimit, + readFileToBufferWithLimit: mockReadFileToBufferWithLimit, + isPayloadSizeLimitError: () => false, +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: vi.fn(), + AuditAction: { FILE_UPLOADED: 'file.uploaded' }, + AuditResourceType: { FILE: 'file' }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET, POST } from '@/app/api/v2/files/route' + +const WS = 'workspace-1' +const FOLDER_ID = 'fold_1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +function buildRecord(overrides: Record = {}) { + return { + id: 'wf_1', + workspaceId: WS, + name: 'data.csv', + key: 'workspace/ws/1-x-data.csv', + path: '/api/files/serve/x', + size: 1024, + type: 'text/csv', + uploadedBy: 'user-1', + folderId: null, + folderPath: null, + uploadedAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/files?${query}`)) + +const callUpload = (query: string) => + POST( + new NextRequest(`http://localhost:3000/api/v2/files?${query}`, { + method: 'POST', + headers: { 'Content-Type': 'multipart/form-data; boundary=x' }, + body: 'x', + }) + ) + +describe('GET /api/v2/files', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListWorkspaceFiles.mockResolvedValue([buildRecord()]) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callList(`workspaceId=${WS}`) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockListWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('limit=10') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('400s on a scope outside the enum', async () => { + const res = await callList(`workspaceId=${WS}&scope=everything`) + expect(res.status).toBe(400) + expect(mockListWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callList(`workspaceId=${WS}`) + expect(res.status).toBe(403) + expect(mockListWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList(`workspaceId=${WS}`) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('returns the public file shape including folder and updatedAt', async () => { + mockListWorkspaceFiles.mockResolvedValue([ + buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' }), + ]) + + const res = await callList(`workspaceId=${WS}`) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + id: 'wf_1', + name: 'data.csv', + size: 1024, + type: 'text/csv', + key: 'workspace/ws/1-x-data.csv', + folderId: FOLDER_ID, + folderPath: 'Reports/Q1', + uploadedBy: 'user-1', + uploadedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + expect(mockListWorkspaceFiles).toHaveBeenCalledWith(WS, { scope: 'active' }) + }) + + it('defaults to the active scope and passes archived through', async () => { + await callList(`workspaceId=${WS}`) + expect(mockListWorkspaceFiles).toHaveBeenCalledWith(WS, { scope: 'active' }) + + const archived = buildRecord({ id: 'wf_gone', name: 'gone.csv' }) + mockListWorkspaceFiles.mockResolvedValue([archived]) + + const res = await callList(`workspaceId=${WS}&scope=archived`) + const body = await res.json() + + expect(mockListWorkspaceFiles).toHaveBeenLastCalledWith(WS, { scope: 'archived' }) + expect(body.data.map((f: { id: string }) => f.id)).toEqual(['wf_gone']) + }) +}) + +describe('POST /api/v2/files', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockReadFileToBufferWithLimit.mockResolvedValue(Buffer.from('id,name\n')) + mockUploadWorkspaceFile.mockResolvedValue({ id: 'wf_1' }) + mockGetWorkspaceFile.mockResolvedValue(buildRecord()) + + const form = new FormData() + form.set('file', new File(['id,name\n'], 'data.csv', { type: 'text/csv' })) + mockReadFormDataWithLimit.mockResolvedValue(form) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callUpload(`workspaceId=${WS}`) + + expect(res.status).toBe(404) + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callUpload('folderId=fold_1') + expect(res.status).toBe(400) + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure before buffering the body', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callUpload(`workspaceId=${WS}`) + expect(res.status).toBe(403) + expect(mockReadFormDataWithLimit).not.toHaveBeenCalled() + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callUpload(`workspaceId=${WS}`) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('uploads to the workspace root and returns 201 with the stored record', async () => { + const res = await callUpload(`workspaceId=${WS}`) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.id).toBe('wf_1') + expect(body.data.folderId).toBeNull() + expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( + WS, + 'user-1', + expect.any(Buffer), + 'data.csv', + 'text/csv', + { folderId: null } + ) + }) + + it('lands the upload in the folder named by folderId', async () => { + mockGetWorkspaceFile.mockResolvedValue( + buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' }) + ) + + const res = await callUpload(`workspaceId=${WS}&folderId=${FOLDER_ID}`) + const body = await res.json() + + expect(res.status).toBe(201) + expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( + WS, + 'user-1', + expect.any(Buffer), + 'data.csv', + 'text/csv', + { folderId: FOLDER_ID } + ) + expect(body.data.folderId).toBe(FOLDER_ID) + expect(body.data.folderPath).toBe('Reports/Q1') + }) + + it('404s when the target folder does not exist', async () => { + mockUploadWorkspaceFile.mockRejectedValue( + new OrchestrationError('not_found', 'Target folder not found') + ) + + const res = await callUpload(`workspaceId=${WS}&folderId=missing`) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) + + it('413s on a blown storage quota by class, not by message wording', async () => { + mockUploadWorkspaceFile.mockRejectedValue( + new OrchestrationError('payload_too_large', 'Quota exceeded for this workspace') + ) + + const res = await callUpload(`workspaceId=${WS}`) + + expect(res.status).toBe(413) + expect((await res.json()).error.code).toBe('PAYLOAD_TOO_LARGE') + }) + + it('409s on a duplicate-name conflict by class', async () => { + mockUploadWorkspaceFile.mockRejectedValue( + new OrchestrationError('conflict', 'A file named "data.csv" already exists in this workspace') + ) + + const res = await callUpload(`workspaceId=${WS}`) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) +}) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index 47055ab713b..fb0a0cb8bce 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -15,16 +15,17 @@ import { } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - FileConflictError, getWorkspaceFile, listWorkspaceFiles, uploadWorkspaceFile, } from '@/lib/uploads/contexts/workspace' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2File } from '@/app/api/v2/files/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { decodeCursor, encodeCursor, + v2CaughtOrchestrationError, v2CursorList, v2Data, v2Error, @@ -56,9 +57,13 @@ function compareFiles(a: V2File, b: V2File): number { /** * GET /api/v2/files — List files in a workspace with cursor pagination. * - * The shared {@link listWorkspaceFiles} manager returns the full active set - * ordered by `uploadedAt`; v2 applies a bounded keyset slice over that result in - * the route. Pushing `limit`/`cursor` down into the manager query is a follow-up. + * `scope=archived` reads Recently Deleted, which is what makes the restore + * endpoints usable — a caller can find the id of something it deleted. + * + * The shared {@link listWorkspaceFiles} manager returns the full set for the + * requested scope ordered by `uploadedAt`; v2 applies a bounded keyset slice + * over that result in the route. Pushing `limit`/`cursor` down into the manager + * query is a follow-up, and `scope` makes it more valuable, not less. */ export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -80,25 +85,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, limit, cursor } = parsed.data.query + const { workspaceId, scope, limit, cursor } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const files = await listWorkspaceFiles(workspaceId) - - const items: V2File[] = files - .map((f) => ({ - id: f.id, - name: f.name, - size: f.size, - type: f.type, - key: f.key, - uploadedBy: f.uploadedBy, - uploadedAt: - f.uploadedAt instanceof Date ? f.uploadedAt.toISOString() : String(f.uploadedAt), - })) - .sort(compareFiles) + const files = await listWorkspaceFiles(workspaceId, { scope }) + + const items: V2File[] = files.map(toV2File).sort(compareFiles) const decoded = cursor ? decodeCursor(cursor) : null const afterCursor = decoded @@ -126,8 +120,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { * POST /api/v2/files — Upload a file to a workspace. * * Authorization runs fully (rate limit → workspace write access) before the - * multipart body is buffered: the workspace is a contract-validated query param, - * so an unauthorized caller never streams a 100 MB body into memory. + * multipart body is buffered: the workspace and the optional target `folderId` + * are contract-validated query params, so an unauthorized caller never streams a + * 100 MB body into memory. */ export const POST = withRouteHandler(async (request: NextRequest) => { try { @@ -149,7 +144,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId, folderId } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -190,7 +185,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId, buffer, file.name, - file.type || 'application/octet-stream' + file.type || 'application/octet-stream', + { folderId: folderId ?? null } ) logger.info(`Uploaded file: ${file.name} to workspace ${workspaceId}`) @@ -207,38 +203,35 @@ export const POST = withRouteHandler(async (request: NextRequest) => { request, }) - const fileRecord = await getWorkspaceFile(workspaceId, userFile.id) - const uploadedAt = - fileRecord?.uploadedAt instanceof Date - ? fileRecord.uploadedAt.toISOString() - : fileRecord?.uploadedAt - ? String(fileRecord.uploadedAt) - : new Date().toISOString() - - const responseFile: V2File = { - id: userFile.id, - name: userFile.name, - size: userFile.size, - type: userFile.type, - key: userFile.key, - uploadedBy: userId, - uploadedAt, + /** + * `uploadWorkspaceFile` returns the executor-facing `UserFile`, which carries + * neither the folder path nor the persisted timestamps, so the stored record + * is the source for the response projection. + * + * `throwOnError` matters here: by default this reader swallows a query + * failure and returns `null`, which would make a transient blip on the read + * indistinguishable from the row being gone. The row was committed by the + * upload moments earlier on the same primary, so a genuine `null` is an + * invariant break — worth a 500 — while a transient failure should surface + * as itself rather than being reported as a missing file. + */ + const fileRecord = await getWorkspaceFile(workspaceId, userFile.id, { throwOnError: true }) + if (!fileRecord) { + throw new Error(`Uploaded file ${userFile.id} could not be read back`) } - return v2Data(responseFile, { rateLimit, status: 201 }) + return v2Data(toV2File(fileRecord), { rateLimit, status: 201 }) } catch (error) { if (isPayloadSizeLimitError(error)) { return v2Error('PAYLOAD_TOO_LARGE', error.message) } - const message = getErrorMessage(error, 'Failed to upload file') - if (error instanceof FileConflictError || message.includes('already exists')) { - return v2Error('CONFLICT', message) - } - if (message.includes('Storage limit') || message.includes('storage limit')) { - return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') - } + // Conflicts, a missing target folder, and a blown storage quota all arrive classified + // now, so the status comes off the error's code rather than its wording. + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + const message = getErrorMessage(error, 'Failed to upload file') logger.error('Error uploading file', { error: message }) return v2Error('INTERNAL_ERROR', 'Internal server error') } diff --git a/apps/sim/app/api/v2/files/utils.ts b/apps/sim/app/api/v2/files/utils.ts new file mode 100644 index 00000000000..c49966ce7f2 --- /dev/null +++ b/apps/sim/app/api/v2/files/utils.ts @@ -0,0 +1,23 @@ +import type { V2File } from '@/lib/api/contracts/v2/files' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' + +/** Shared serialization for the v2 files surface. */ + +/** + * Public file projection. `workspaceId` (already known to the caller, who + * supplied it) and the internal storage/versioning columns are not exposed. + */ +export function toV2File(record: WorkspaceFileRecord): V2File { + return { + id: record.id, + name: record.name, + size: record.size, + type: record.type, + key: record.key, + folderId: record.folderId ?? null, + folderPath: record.folderPath ?? null, + uploadedBy: record.uploadedBy, + uploadedAt: record.uploadedAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + } +} diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts index beece206917..e2963b00f94 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts @@ -1,12 +1,14 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { updateWorkspaceFileContentContract } from '@/lib/api/contracts/workspace-files' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace' +import { performUpdateWorkspaceFileContent } from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -19,78 +21,43 @@ const logger = createLogger('WorkspaceFileContentAPI') */ export const PUT = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(updateWorkspaceFileContentContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const { content, encoding } = parsed.data.body - - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const buffer = - encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf-8') + const parsed = await parseRequest(updateWorkspaceFileContentContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, fileId } = parsed.data.params + const { content, encoding } = parsed.data.body - const maxFileSizeBytes = 50 * 1024 * 1024 - if (buffer.length > maxFileSizeBytes) { - return NextResponse.json( - { error: `File size exceeds ${maxFileSizeBytes / 1024 / 1024}MB limit` }, - { status: 413 } - ) - } + const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (userPermission !== 'admin' && userPermission !== 'write') { + logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`) + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } - const updatedFile = await updateWorkspaceFileContent( - workspaceId, - fileId, - session.user.id, - buffer + const result = await performUpdateWorkspaceFileContent({ + workspaceId, + fileId, + userId: session.user.id, + content, + encoding: encoding === 'base64' ? 'base64' : 'utf-8', + actorName: session.user.name, + actorEmail: session.user.email, + request, + }) + + if (!result.success || !result.file) { + return NextResponse.json( + { + success: false, + error: messageForOrchestrationError(result, 'Failed to update file content'), + }, + { status: statusForOrchestrationError(result.errorCode) } ) - - logger.info(`Updated content for workspace file: ${updatedFile.name}`) - - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_UPDATED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: updatedFile.name, - description: `Updated content of file "${updatedFile.name}"`, - metadata: { contentSize: buffer.length }, - request, - }) - - return NextResponse.json({ - success: true, - file: updatedFile, - }) - } catch (error) { - const errorMessage = toError(error).message || 'Failed to update file content' - const isNotFound = errorMessage.includes('File not found') - const isQuotaExceeded = errorMessage.includes('Storage limit exceeded') - const status = isNotFound ? 404 : isQuotaExceeded ? 402 : 500 - - if (status === 500) { - logger.error('Error updating file content:', error) - } else { - logger.warn(errorMessage) - } - - return NextResponse.json({ success: false, error: errorMessage }, { status }) } + + return NextResponse.json({ success: true, file: result.file }) } ) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts index 0d6a09d6361..f5810627a1b 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts @@ -1,23 +1,19 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { getFileShareContract, upsertFileShareContract } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - getShareForResource, - ShareValidationError, - upsertFileShare, -} from '@/lib/public-shares/share-manager' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' + performGetWorkspaceFileShare, + performUpsertWorkspaceFileShare, +} from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { - PublicFileSharingNotAllowedError, - validatePublicFileSharing, -} from '@/ee/access-control/utils/permission-check' export const dynamic = 'force-dynamic' @@ -31,40 +27,30 @@ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { const requestId = generateRequestId() - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getFileShareContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission === null) { - logger.warn( - `[${requestId}] User ${session.user.id} lacks access to workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } + const parsed = await parseRequest(getFileShareContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, fileId } = parsed.data.params - const file = await getWorkspaceFile(workspaceId, fileId) - if (!file) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission === null) { + logger.warn(`[${requestId}] User ${session.user.id} lacks access to workspace ${workspaceId}`) + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } - const share = await getShareForResource('file', fileId) - return NextResponse.json({ share }) - } catch (error) { - logger.error(`[${requestId}] Error fetching file share:`, error) + const result = await performGetWorkspaceFileShare({ workspaceId, fileId }) + if (!result.success) { return NextResponse.json( - { error: getErrorMessage(error, 'Failed to fetch share') }, - { - status: 500, - } + { error: messageForOrchestrationError(result, 'Failed to fetch share') }, + { status: statusForOrchestrationError(result.errorCode) } ) } + + return NextResponse.json({ share: result.share ?? null }) } ) @@ -76,89 +62,45 @@ export const PUT = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { const requestId = generateRequestId() - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(upsertFileShareContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const { isActive, authType, password, allowedEmails, token } = parsed.data.body - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const file = await getWorkspaceFile(workspaceId, fileId) - if (!file) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } - - // Enabling a share is gated by the org's access-control policy (both the - // master on/off and the per-auth-type allow-list); disabling is always - // allowed so users can still un-share after the policy is turned on. - if (isActive) { - // Validate the auth type that will ACTUALLY be persisted. upsertFileShare - // falls back to the existing share's authType when none is passed, so a bare - // re-enable must be checked against that stored mode — not 'public' — or a - // now-disallowed password/email/sso share could be silently reactivated. - const existingShare = await getShareForResource('file', fileId) - const effectiveAuthType = authType ?? existingShare?.authType ?? 'public' - try { - await validatePublicFileSharing(session.user.id, workspaceId, effectiveAuthType) - } catch (error) { - if (error instanceof PublicFileSharingNotAllowedError) { - logger.warn(`[${requestId}] Public file sharing disabled for workspace ${workspaceId}`) - return NextResponse.json({ error: error.message }, { status: 403 }) - } - throw error - } - } - - const share = await upsertFileShare({ - workspaceId, - fileId, - userId: session.user.id, - isActive, - authType, - password, - allowedEmails, - token, - }) + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - logger.info(`[${requestId}] ${isActive ? 'Enabled' : 'Disabled'} share for file ${fileId}`) + const parsed = await parseRequest(upsertFileShareContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, fileId } = parsed.data.params + const { isActive, authType, password, allowedEmails, token } = parsed.data.body - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: file.name, - description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`, - request, - }) + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + logger.warn( + `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } - return NextResponse.json({ share }) - } catch (error) { - if (error instanceof ShareValidationError) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - logger.error(`[${requestId}] Error updating file share:`, error) + const result = await performUpsertWorkspaceFileShare({ + workspaceId, + fileId, + userId: session.user.id, + isActive, + authType, + password, + allowedEmails, + token, + actorName: session.user.name, + actorEmail: session.user.email, + request, + }) + + if (!result.success || !result.share) { return NextResponse.json( - { error: getErrorMessage(error, 'Failed to update share') }, - { - status: 500, - } + { error: messageForOrchestrationError(result, 'Failed to update share') }, + { status: statusForOrchestrationError(result.errorCode) } ) } + + return NextResponse.json({ share: result.share }) } ) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts index 86df6e83f91..ecf7b17b281 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts @@ -3,12 +3,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { restoreWorkspaceFileFolderContract } from '@/lib/api/contracts/workspace-file-folders' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { - performRestoreWorkspaceFileFolder, - workspaceFilesOrchestrationStatus, -} from '@/lib/workspace-files/orchestration' +import { performRestoreWorkspaceFileFolder } from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceFileFolderRestoreAPI') @@ -38,7 +36,7 @@ export const POST = withRouteHandler( if (!result.success) { return NextResponse.json( { success: false, error: result.error }, - { status: workspaceFilesOrchestrationStatus(result.errorCode) } + { status: statusForOrchestrationError(result.errorCode) } ) } const { folder, restoredItems } = result diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts index 78232e8a704..079f25e8459 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts @@ -6,12 +6,12 @@ import { } from '@/lib/api/contracts/workspace-file-folders' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performDeleteWorkspaceFileItems, performUpdateWorkspaceFileFolder, - workspaceFilesOrchestrationStatus, } from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -47,7 +47,7 @@ export const PATCH = withRouteHandler( if (!result.success || !result.folder) { return NextResponse.json( { success: false, error: result.error }, - { status: workspaceFilesOrchestrationStatus(result.errorCode) } + { status: statusForOrchestrationError(result.errorCode) } ) } captureServerEvent( diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts index 02de14dcf1e..ba3180cb609 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts @@ -6,13 +6,11 @@ import { } from '@/lib/api/contracts/workspace-file-folders' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace' -import { - performCreateWorkspaceFileFolder, - workspaceFilesOrchestrationStatus, -} from '@/lib/workspace-files/orchestration' +import { performCreateWorkspaceFileFolder } from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceFileFoldersAPI') @@ -70,7 +68,7 @@ export const POST = withRouteHandler( if (!result.success || !result.folder) { return NextResponse.json( { success: false, error: result.error }, - { status: workspaceFilesOrchestrationStatus(result.errorCode) } + { status: statusForOrchestrationError(result.errorCode) } ) } captureServerEvent( diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 040ffa4dc80..52fbeadd284 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { workspaceFileIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { shareAuthTypeSchema, shareRecordSchema } from '@/lib/api/contracts/public-shares' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' @@ -9,6 +10,21 @@ import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/sha * adds cursor pagination to the list. The workspace is always carried as a query * param — including on upload — so the route can authorize before reading the * multipart body. + * + * Folders are referenced but not managed here. A file carries `folderId` / + * `folderPath`, and `move` retargets it, but there are deliberately no + * folder-CRUD routes on this surface: file folders already live in the shared + * `folder` table (`resourceType: 'file'`), and the remaining file-specific + * folder machinery is being folded into the generic folder engine. Publishing + * `/api/v2/files/folders/**` would pin a transitional split into a public + * contract; folder management belongs on `/api/v2/folders` once that surface + * serves `resourceType: 'file'`. + * + * Presigned upload is deliberately absent. Presign only performs an advisory + * quota pre-check; the storage debit happens in the separate register step, so + * a caller that presigns, PUTs bytes, and never registers leaves unaccounted + * bytes in the bucket. The buffered multipart upload debits inside + * `uploadWorkspaceFile`'s own transaction, so it is the only public path. */ /** A workspace file as exposed by the v2 surface. */ @@ -18,9 +34,15 @@ export const v2FileSchema = z.object({ size: z.number().nonnegative(), type: z.string(), key: z.string(), + /** Containing file folder, or `null` when the file sits at the workspace root. */ + folderId: z.string().nullable(), + /** Slash-joined folder names for {@link v2FileSchema.folderId}; `null` at the root. */ + folderPath: z.string().nullable(), uploadedBy: z.string(), /** ISO-8601 timestamp. */ uploadedAt: z.string(), + /** ISO-8601 timestamp; advances on content and metadata writes alike. */ + updatedAt: z.string(), }) export type V2File = z.output @@ -33,12 +55,40 @@ export const v2DeleteFileResultSchema = z.object({ export type V2DeleteFileResult = z.output +/** Counts of what a cascading archive or restore touched. */ +export const v2FileItemCountsSchema = z.object({ + files: z.number().int(), + folders: z.number().int(), +}) + +export type V2FileItemCounts = z.output + export const v2FileParamsSchema = z.object({ fileId: workspaceFileIdSchema, }) export type V2FileParams = z.output +/** `active` lists live items; `archived` lists Recently Deleted. */ +export const v2FileScopeSchema = z.enum(['active', 'archived']) + +export type V2FileScope = z.output + +/** + * A file-folder name becomes a path segment, so path separators and dot + * segments are rejected rather than normalized. Mirrors + * `normalizeWorkspaceFileItemName`, which enforces the same rule in the manager. + */ +const v2FileItemNameSchema = z + .string() + .trim() + .min(1, 'name is required') + .max(255, 'name is too long') + .refine( + (name) => name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\'), + 'name cannot contain path separators or dot segments' + ) + /** * List query: workspace scope plus opaque keyset cursor pagination keyed on * `(uploadedAt, id)`. `limit` clamps to `[1, 1000]` (default 100) to bound the @@ -46,6 +96,7 @@ export type V2FileParams = z.output */ export const v2ListFilesQuerySchema = z.object({ workspaceId: workspaceIdSchema, + scope: v2FileScopeSchema.default('active'), limit: z.coerce .number() .optional() @@ -56,9 +107,15 @@ export const v2ListFilesQuerySchema = z.object({ export type V2ListFilesQuery = z.output -/** Upload carries the workspace as a query param so auth runs before buffering. */ +/** + * Upload carries the workspace as a query param so auth runs before buffering. + * `folderId` is a query param for the same reason — the multipart body is never + * read until the caller is authorized. + */ export const v2UploadFileQuerySchema = z.object({ workspaceId: workspaceIdSchema, + /** Target file folder. Omit to upload to the workspace root. */ + folderId: z.string().min(1, 'folderId cannot be empty').optional(), }) export type V2UploadFileQuery = z.output @@ -70,6 +127,148 @@ export const v2FileWorkspaceQuerySchema = z.object({ export type V2FileWorkspaceQuery = z.output +export const v2RenameFileBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: v2FileItemNameSchema, + }) + .strict() + +export type V2RenameFileBody = z.input + +export const v2WorkspaceScopedBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + }) + .strict() + +export type V2WorkspaceScopedBody = z.input + +/** A restore acknowledgement carries no payload beyond the restored id. */ +export const v2RestoreFileResultSchema = z.object({ + id: z.string(), + restored: z.literal(true), +}) + +export type V2RestoreFileResult = z.output + +const fileItemSelectionSchema = { + fileIds: z.array(z.string().min(1, 'fileIds entries cannot be empty')).max(1000).default([]), + folderIds: z.array(z.string().min(1, 'folderIds entries cannot be empty')).max(1000).default([]), +} + +export const v2MoveFileItemsBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + ...fileItemSelectionSchema, + /** Explicit `null` moves the selection to the workspace root. */ + targetFolderId: z.string().min(1, 'targetFolderId cannot be empty').nullable().optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.fileIds.length === 0 && body.folderIds.length === 0) { + ctx.addIssue({ + code: 'custom', + path: ['fileIds'], + message: 'At least one of fileIds or folderIds must be non-empty', + }) + } + }) + +export type V2MoveFileItemsBody = z.input + +export const v2MoveFileItemsResultSchema = z.object({ + movedItems: v2FileItemCountsSchema, +}) + +export type V2MoveFileItemsResult = z.output + +export const v2BulkArchiveFileItemsBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + ...fileItemSelectionSchema, + }) + .strict() + .superRefine((body, ctx) => { + if (body.fileIds.length === 0 && body.folderIds.length === 0) { + ctx.addIssue({ + code: 'custom', + path: ['fileIds'], + message: 'At least one of fileIds or folderIds must be non-empty', + }) + } + }) + +export type V2BulkArchiveFileItemsBody = z.input + +export const v2BulkArchiveFileItemsResultSchema = z.object({ + deletedItems: v2FileItemCountsSchema, +}) + +export type V2BulkArchiveFileItemsResult = z.output + +/** + * Public share state. Reuses the internal {@link shareRecordSchema}, which is + * already public-safe — `hasPassword` is a boolean and neither the ciphertext + * nor the storage key is carried — with `url` tightened to a real URL. + */ +export const v2FileShareSchema = shareRecordSchema.extend({ + url: z.string().url(), +}) + +export type V2FileShare = z.output + +export const v2GetFileShareResultSchema = z.object({ + share: v2FileShareSchema.nullable(), +}) + +export type V2GetFileShareResult = z.output + +export const v2UpsertFileShareResultSchema = z.object({ + share: v2FileShareSchema, +}) + +export type V2UpsertFileShareResult = z.output + +/** + * Share upsert body. The internal surface accepts a caller-supplied `token` so + * the UI can show a link before saving; v2 drops it. Over an API key it would + * let a caller mint predictable public URLs, and a token collision surfaces as + * an unhandled unique-index violation. v2 tokens are always server-generated. + */ +export const v2UpsertFileShareBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + isActive: z.boolean(), + authType: shareAuthTypeSchema.optional(), + password: z + .string() + .min(1, 'password cannot be empty') + .max(1024, 'password is too long') + .optional(), + allowedEmails: z + .array(z.string().min(1, 'allowedEmails entries cannot be empty').max(320)) + .max(200, 'Too many allowed emails') + .optional(), + }) + .strict() + +export type V2UpsertFileShareBody = z.input + +/** + * Content replace body. `content` is the whole new body of the file — this is a + * replace, not an append. Base64 is the escape hatch for non-UTF-8 bytes. + */ +export const v2UpdateFileContentBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + content: z.string().max(70_000_000, 'content is too large'), + encoding: z.enum(['utf-8', 'base64']).default('utf-8'), + }) + .strict() + +export type V2UpdateFileContentBody = z.input + export const v2ListFilesContract = defineRouteContract({ method: 'GET', path: '/api/v2/files', @@ -100,6 +299,17 @@ export const v2DownloadFileContract = defineRouteContract({ }, }) +export const v2RenameFileContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/files/[fileId]', + params: v2FileParamsSchema, + body: v2RenameFileBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileSchema), + }, +}) + export const v2DeleteFileContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/files/[fileId]', @@ -110,3 +320,67 @@ export const v2DeleteFileContract = defineRouteContract({ schema: v2DataResponse(v2DeleteFileResultSchema), }, }) + +export const v2RestoreFileContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/[fileId]/restore', + params: v2FileParamsSchema, + body: v2WorkspaceScopedBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2RestoreFileResultSchema), + }, +}) + +export const v2MoveFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/move', + body: v2MoveFileItemsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2MoveFileItemsResultSchema), + }, +}) + +export const v2BulkArchiveFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/bulk-archive', + body: v2BulkArchiveFileItemsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2BulkArchiveFileItemsResultSchema), + }, +}) + +export const v2GetFileShareContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/[fileId]/share', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2GetFileShareResultSchema), + }, +}) + +export const v2UpsertFileShareContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/files/[fileId]/share', + params: v2FileParamsSchema, + body: v2UpsertFileShareBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UpsertFileShareResultSchema), + }, +}) + +export const v2UpdateFileContentContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/files/[fileId]/content', + params: v2FileParamsSchema, + body: v2UpdateFileContentBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileSchema), + }, +}) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 03f58a0d3a3..ca2b80229cc 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray, isNull, min, type SQL, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { deduplicateFolderName } from '@/lib/folders/naming' import { collectDescendantFolderIds } from '@/lib/folders/subtree' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' @@ -346,7 +347,7 @@ export async function assertWorkspaceFileFolderTarget( const folder = await getWorkspaceFileFolder(workspaceId, normalized) if (!folder) { - throw new Error('Target folder not found') + throw new OrchestrationError('not_found', 'Target folder not found') } return normalized @@ -380,7 +381,7 @@ export async function createWorkspaceFileFolder(params: { .limit(1) if (!target) { - throw new Error('Target folder not found') + throw new OrchestrationError('not_found', 'Target folder not found') } } @@ -558,7 +559,7 @@ export async function updateWorkspaceFileFolder(params: { ) .limit(1) - if (!existing) throw new Error('Folder not found') + if (!existing) throw new OrchestrationError('not_found', 'Folder not found') const updates: Partial = { updatedAt: new Date() } const finalName = @@ -568,7 +569,8 @@ export async function updateWorkspaceFileFolder(params: { const finalParentId = params.parentId !== undefined ? normalizeParentId(params.parentId) : existing.parentId - if (finalParentId === params.folderId) throw new Error('Folder cannot be its own parent') + if (finalParentId === params.folderId) + throw new OrchestrationError('validation', 'Folder cannot be its own parent') if (finalParentId) { const [target] = await tx @@ -585,7 +587,7 @@ export async function updateWorkspaceFileFolder(params: { .limit(1) if (!target) { - throw new Error('Target folder not found') + throw new OrchestrationError('not_found', 'Target folder not found') } } @@ -603,7 +605,10 @@ export async function updateWorkspaceFileFolder(params: { const descendants = collectDescendantFolderIds(activeFolders, params.folderId) if (finalParentId && descendants.includes(finalParentId)) { - throw new Error('Cannot move a folder into one of its descendants') + throw new OrchestrationError( + 'validation', + 'Cannot move a folder into one of its descendants' + ) } } @@ -653,7 +658,7 @@ export async function updateWorkspaceFileFolder(params: { ) .returning() - if (!updatedFolder) throw new Error('Folder not found') + if (!updatedFolder) throw new OrchestrationError('not_found', 'Folder not found') return updatedFolder } catch (error) { if (getPostgresErrorCode(error) === '23505') { @@ -717,12 +722,12 @@ export async function moveWorkspaceFileItems(params: { .limit(1) if (!target) { - throw new Error('Target folder not found') + throw new OrchestrationError('not_found', 'Target folder not found') } } if (folderIds.includes(targetFolderId ?? '')) { - throw new Error('Cannot move a folder into itself') + throw new OrchestrationError('validation', 'Cannot move a folder into itself') } if (folderIds.length > 0) { @@ -740,7 +745,10 @@ export async function moveWorkspaceFileItems(params: { for (const folderId of folderIds) { const descendants = collectDescendantFolderIds(activeFolders, folderId) if (targetFolderId && descendants.includes(targetFolderId)) { - throw new Error('Cannot move a folder into one of its descendants') + throw new OrchestrationError( + 'validation', + 'Cannot move a folder into one of its descendants' + ) } } } @@ -891,7 +899,7 @@ export async function archiveWorkspaceFileFolderRecursive( ) .limit(1) - if (!folder) throw new Error('Folder not found') + if (!folder) throw new OrchestrationError('not_found', 'Folder not found') const activeFolders = await tx .select({ id: folderTable.id, parentId: folderTable.parentId }) @@ -944,7 +952,7 @@ export async function restoreWorkspaceFileFolder( ): Promise { const ws = await getWorkspaceWithOwner(workspaceId) if (!ws || ws.archivedAt) { - throw new Error('Cannot restore folder into an archived workspace') + throw new OrchestrationError('validation', 'Cannot restore folder into an archived workspace') } const { restored, restoredItems } = await db.transaction(async (tx) => { @@ -959,8 +967,8 @@ export async function restoreWorkspaceFileFolder( .limit(1) .then((rows) => rows[0] ?? null) - if (!raw) throw new Error('Folder not found') - if (!raw.deletedAt) throw new Error('Folder is not archived') + if (!raw) throw new OrchestrationError('not_found', 'Folder not found') + if (!raw.deletedAt) throw new OrchestrationError('validation', 'Folder is not archived') const folderDeletedAt = raw.deletedAt 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 c0dbbbecce9..c77719e6860 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -19,6 +19,7 @@ import { } from '@/lib/billing/storage' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' @@ -51,10 +52,15 @@ const logger = createLogger('WorkspaceFileStorage') export type WorkspaceFileScope = 'active' | 'archived' | 'all' -export class FileConflictError extends Error { - readonly code = 'FILE_EXISTS' as const +/** + * An {@link OrchestrationError} so every surface reaches 409 by class rather than by + * searching the message for "already exists". Carries the inherited `code: 'conflict'`; + * the old `'FILE_EXISTS'` discriminator had no readers. + */ +export class FileConflictError extends OrchestrationError { constructor(name: string) { - super(`A file named "${name}" already exists in this workspace`) + super('conflict', `A file named "${name}" already exists in this workspace`) + this.name = 'FileConflictError' } } @@ -423,8 +429,15 @@ export async function uploadWorkspaceFile( ) continue } + // A classified failure (a blown storage quota, a missing target folder) keeps its class: + // re-wrapping it in a bare Error is what forced every caller to substring-match the + // message to recover the status. + const classified = asOrchestrationError(error) + if (classified) throw classified logger.error(`Failed to upload workspace file ${fileName}:`, error) - throw new Error(`Failed to upload file: ${getErrorMessage(error, 'Unknown error')}`) + throw new Error(`Failed to upload file: ${getErrorMessage(error, 'Unknown error')}`, { + cause: error, + }) } } @@ -1072,7 +1085,7 @@ export async function updateWorkspaceFileContent( const fileRecord = await getWorkspaceFile(workspaceId, fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } const storageBillingContext = await resolveStorageBillingContext(workspaceId) @@ -1122,7 +1135,7 @@ export async function updateWorkspaceFileContent( .for('update') .limit(1) if (!currentFile) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } // Optimistic-concurrency guard: the row is `FOR UPDATE`-locked, so comparing its committed @@ -1169,7 +1182,7 @@ export async function updateWorkspaceFileContent( ) .returning() if (!updatedFile) { - throw new Error('File not found or could not be updated') + throw new OrchestrationError('not_found', 'File not found or could not be updated') } let updatedUsage: number | undefined @@ -1255,8 +1268,15 @@ export async function updateWorkspaceFileContent( // the optimistic-concurrency guard, not a failure to wrap. The orphan upload was already cleaned up // by the inner finalization catch before it propagated here. if (error instanceof ContentVersionConflictError) throw error + // Same reasoning for an already-classified failure: a missing file and a blown storage quota are + // caller-fixable outcomes that every surface maps to 404/413 by class. Re-wrapping them in a bare + // Error stripped that classification and turned both into a 500. + const classified = asOrchestrationError(error) + if (classified) throw classified logger.error(`Failed to update workspace file content ${fileId}:`, error) - throw new Error(`Failed to update file content: ${getErrorMessage(error, 'Unknown error')}`) + throw new Error(`Failed to update file content: ${getErrorMessage(error, 'Unknown error')}`, { + cause: error, + }) } } @@ -1275,7 +1295,7 @@ export async function renameWorkspaceFile( const fileRecord = await getWorkspaceFile(workspaceId, fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } if (fileRecord.name === normalizedName) { @@ -1288,10 +1308,11 @@ export async function renameWorkspaceFile( } let updated: { id: string }[] + const renamedAt = new Date() try { updated = await db .update(workspaceFiles) - .set({ originalName: normalizedName, updatedAt: new Date() }) + .set({ originalName: normalizedName, updatedAt: renamedAt }) .where( and( eq(workspaceFiles.id, fileId), @@ -1308,7 +1329,7 @@ export async function renameWorkspaceFile( } if (updated.length === 0) { - throw new Error('File not found or could not be renamed') + throw new OrchestrationError('not_found', 'File not found or could not be renamed') } logger.info(`Successfully renamed workspace file ${fileId} to "${normalizedName}"`) @@ -1316,6 +1337,7 @@ export async function renameWorkspaceFile( return { ...fileRecord, name: normalizedName, + updatedAt: renamedAt, } } @@ -1336,7 +1358,7 @@ export async function moveRenameWorkspaceFile(params: { const fileRecord = await getWorkspaceFile(params.workspaceId, params.fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } const targetFolderId = await assertWorkspaceFileFolderTarget( @@ -1376,7 +1398,7 @@ export async function moveRenameWorkspaceFile(params: { } if (updated.length === 0) { - throw new Error('File not found or could not be moved') + throw new OrchestrationError('not_found', 'File not found or could not be moved') } return { @@ -1399,7 +1421,7 @@ export async function deleteWorkspaceFile(workspaceId: string, fileId: string): try { const fileRecord = await findWorkspaceFileForLifecycle(db, workspaceId, fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } if (fileRecord.deletedAt) return @@ -1432,7 +1454,7 @@ export async function restoreWorkspaceFile(workspaceId: string, fileId: string): const fileRecord = await findWorkspaceFileForLifecycle(db, workspaceId, fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } if (!fileRecord.deletedAt) { @@ -1441,7 +1463,7 @@ export async function restoreWorkspaceFile(workspaceId: string, fileId: string): const ws = await getWorkspaceWithOwner(workspaceId) if (!ws || ws.archivedAt) { - throw new Error('Cannot restore file into an archived workspace') + throw new OrchestrationError('validation', 'Cannot restore file into an archived workspace') } /** diff --git a/apps/sim/lib/workspace-files/orchestration/content.ts b/apps/sim/lib/workspace-files/orchestration/content.ts new file mode 100644 index 00000000000..dacb48be300 --- /dev/null +++ b/apps/sim/lib/workspace-files/orchestration/content.ts @@ -0,0 +1,98 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { + asOrchestrationError, + type OrchestrationErrorCode, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { + ContentVersionConflictError, + updateWorkspaceFileContent, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' + +const logger = createLogger('WorkspaceFileContentOrchestration') + +/** Ceiling on a single content replace, independent of the workspace quota. */ +export const MAX_WORKSPACE_FILE_CONTENT_BYTES = 50 * 1024 * 1024 + +export interface PerformUpdateWorkspaceFileContentParams { + workspaceId: string + fileId: string + userId: string + content: string + encoding: 'utf-8' | 'base64' + actorName?: string + actorEmail?: string + request?: OrchestrationRequestContext +} + +export interface PerformUpdateWorkspaceFileContentResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + file?: WorkspaceFileRecord +} + +/** + * Replaces a workspace file's bytes. + * + * Failures are classified rather than message-matched: the manager throws a + * classified `not_found`, the storage ledger throws `StorageLimitExceededError` + * (a `payload_too_large` {@link OrchestrationError}), and both reach here through + * `asOrchestrationError`, which walks the `cause` chain past drizzle's + * transaction wrapper. + */ +export async function performUpdateWorkspaceFileContent( + params: PerformUpdateWorkspaceFileContentParams +): Promise { + const { workspaceId, fileId, userId, content, encoding, actorName, actorEmail, request } = params + + const buffer = Buffer.from(content, encoding === 'base64' ? 'base64' : 'utf-8') + + if (buffer.length > MAX_WORKSPACE_FILE_CONTENT_BYTES) { + return { + success: false, + error: `File size exceeds ${MAX_WORKSPACE_FILE_CONTENT_BYTES / 1024 / 1024}MB limit`, + errorCode: 'payload_too_large', + } + } + + try { + const file = await updateWorkspaceFileContent(workspaceId, fileId, userId, buffer) + + logger.info('Updated workspace file content', { workspaceId, fileId, size: buffer.length }) + + recordAudit({ + workspaceId, + actorId: userId, + actorName, + actorEmail, + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: file.name, + description: `Updated content of file "${file.name}"`, + metadata: { contentSize: buffer.length }, + request, + }) + + return { success: true, file } + } catch (error) { + const classified = asOrchestrationError(error) + if (classified) { + logger.warn('Workspace file content update rejected', { + workspaceId, + fileId, + errorCode: classified.code, + }) + return { success: false, error: classified.message, errorCode: classified.code } + } + if (error instanceof ContentVersionConflictError) { + return { success: false, error: error.message, errorCode: 'conflict' } + } + logger.error('Failed to update workspace file content', { error }) + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts new file mode 100644 index 00000000000..21c8be4f630 --- /dev/null +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts @@ -0,0 +1,219 @@ +/** + * @vitest-environment node + * + * Failure classification. Every `perform*` here is consumed by a public v2 route + * that maps `errorCode` straight to an HTTP status, so a manager failure that + * arrives unclassified silently becomes a 500 for what is really a caller-fixable + * 400 or 404. These pin the mapping rather than the happy paths. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockMoveWorkspaceFileItems, + mockUpdateWorkspaceFileFolder, + mockCreateWorkspaceFileFolder, + mockRestoreWorkspaceFileFolder, + mockRenameWorkspaceFile, + mockRestoreWorkspaceFile, + mockBulkArchive, +} = vi.hoisted(() => ({ + mockMoveWorkspaceFileItems: vi.fn(), + mockUpdateWorkspaceFileFolder: vi.fn(), + mockCreateWorkspaceFileFolder: vi.fn(), + mockRestoreWorkspaceFileFolder: vi.fn(), + mockRenameWorkspaceFile: vi.fn(), + mockRestoreWorkspaceFile: vi.fn(), + mockBulkArchive: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + moveWorkspaceFileItems: mockMoveWorkspaceFileItems, + updateWorkspaceFileFolder: mockUpdateWorkspaceFileFolder, + createWorkspaceFileFolder: mockCreateWorkspaceFileFolder, + restoreWorkspaceFileFolder: mockRestoreWorkspaceFileFolder, + renameWorkspaceFile: mockRenameWorkspaceFile, + restoreWorkspaceFile: mockRestoreWorkspaceFile, + bulkArchiveWorkspaceFileItems: mockBulkArchive, + moveRenameWorkspaceFile: vi.fn(), + FileConflictError: class FileConflictError extends Error {}, + WorkspaceFileFolderConflictError: class WorkspaceFileFolderConflictError extends Error {}, + WorkspaceFileMoveConflictError: class WorkspaceFileMoveConflictError extends Error {}, + WorkspaceFileItemsNotFoundError: class WorkspaceFileItemsNotFoundError extends Error {}, +})) + +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkspaceFilesChanged: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: vi.fn(), + AuditAction: new Proxy({}, { get: (_t, k) => String(k) }), + AuditResourceType: new Proxy({}, { get: (_t, k) => String(k) }), +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + performCreateWorkspaceFileFolder, + performMoveWorkspaceFileItems, + performRenameWorkspaceFile, + performRestoreWorkspaceFile, + performRestoreWorkspaceFileFolder, + performUpdateWorkspaceFileFolder, +} from '@/lib/workspace-files/orchestration' + +const WS = 'workspace-1' +const USER = 'user-1' + +describe('workspace file orchestration error classification', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('maps a missing move target to not_found, not internal', async () => { + mockMoveWorkspaceFileItems.mockRejectedValue( + new OrchestrationError('not_found', 'Target folder not found') + ) + + const result = await performMoveWorkspaceFileItems({ + workspaceId: WS, + userId: USER, + fileIds: ['wf_1'], + targetFolderId: 'fold_missing', + }) + + expect(result.success).toBe(false) + expect(result.errorCode).toBe('not_found') + expect(result.error).toBe('Target folder not found') + }) + + it('maps a self-descendant move to validation, not internal', async () => { + mockMoveWorkspaceFileItems.mockRejectedValue( + new OrchestrationError('validation', 'Cannot move a folder into one of its descendants') + ) + + const result = await performMoveWorkspaceFileItems({ + workspaceId: WS, + userId: USER, + folderIds: ['fold_1'], + targetFolderId: 'fold_child', + }) + + expect(result.errorCode).toBe('validation') + }) + + it('maps a folder reparent cycle to validation, not internal', async () => { + mockUpdateWorkspaceFileFolder.mockRejectedValue( + new OrchestrationError('validation', 'Cannot move a folder into one of its descendants') + ) + + const result = await performUpdateWorkspaceFileFolder({ + workspaceId: WS, + folderId: 'fold_1', + userId: USER, + parentId: 'fold_child', + }) + + expect(result.errorCode).toBe('validation') + }) + + it('maps a missing folder on update to not_found', async () => { + mockUpdateWorkspaceFileFolder.mockRejectedValue( + new OrchestrationError('not_found', 'Folder not found') + ) + + const result = await performUpdateWorkspaceFileFolder({ + workspaceId: WS, + folderId: 'fold_missing', + userId: USER, + name: 'Q2', + }) + + expect(result.errorCode).toBe('not_found') + }) + + it('maps a missing parent on create to not_found', async () => { + mockCreateWorkspaceFileFolder.mockRejectedValue( + new OrchestrationError('not_found', 'Target folder not found') + ) + + const result = await performCreateWorkspaceFileFolder({ + workspaceId: WS, + userId: USER, + name: 'Q1', + parentId: 'fold_missing', + }) + + expect(result.errorCode).toBe('not_found') + }) + + it('maps restoring into an archived workspace to validation', async () => { + mockRestoreWorkspaceFileFolder.mockRejectedValue( + new OrchestrationError('validation', 'Cannot restore folder into an archived workspace') + ) + + const result = await performRestoreWorkspaceFileFolder({ + workspaceId: WS, + folderId: 'fold_1', + userId: USER, + }) + + expect(result.errorCode).toBe('validation') + }) + + it('maps a missing file on rename to not_found', async () => { + mockRenameWorkspaceFile.mockRejectedValue(new OrchestrationError('not_found', 'File not found')) + + const result = await performRenameWorkspaceFile({ + workspaceId: WS, + fileId: 'wf_missing', + name: 'renamed.csv', + userId: USER, + }) + + expect(result.errorCode).toBe('not_found') + }) + + it('maps a missing file on restore to not_found', async () => { + mockRestoreWorkspaceFile.mockRejectedValue( + new OrchestrationError('not_found', 'File not found') + ) + + const result = await performRestoreWorkspaceFile({ + workspaceId: WS, + fileId: 'wf_missing', + userId: USER, + }) + + expect(result.errorCode).toBe('not_found') + }) + + it('classifies through a wrapper error chain, as drizzle produces inside a transaction', async () => { + const wrapped = new Error('update "folder" set ... failed', { + cause: new OrchestrationError('validation', 'Folder cannot be its own parent'), + }) + mockUpdateWorkspaceFileFolder.mockRejectedValue(wrapped) + + const result = await performUpdateWorkspaceFileFolder({ + workspaceId: WS, + folderId: 'fold_1', + userId: USER, + parentId: 'fold_1', + }) + + expect(result.errorCode).toBe('validation') + expect(result.error).toBe('Folder cannot be its own parent') + }) + + it('leaves a genuinely unexpected fault as internal', async () => { + mockRenameWorkspaceFile.mockRejectedValue(new Error('connection terminated unexpectedly')) + + const result = await performRenameWorkspaceFile({ + workspaceId: WS, + fileId: 'wf_1', + name: 'renamed.csv', + userId: USER, + }) + + expect(result.errorCode).toBe('internal') + }) +}) diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index 7de50ed8b73..8057fb8a0c8 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -1,6 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getPostgresErrorCode, toError } from '@sim/utils/errors' +import { asOrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { bulkArchiveWorkspaceFileItems, @@ -22,21 +23,6 @@ import { const logger = createLogger('WorkspaceFileFolderLifecycle') -export type WorkspaceFilesOrchestrationErrorCode = - | 'validation' - | 'not_found' - | 'conflict' - | 'internal' - -export function workspaceFilesOrchestrationStatus( - errorCode: WorkspaceFilesOrchestrationErrorCode | undefined -): number { - if (errorCode === 'validation') return 400 - if (errorCode === 'conflict') return 409 - if (errorCode === 'not_found') return 404 - return 500 -} - export interface PerformDeleteWorkspaceFileItemsParams { workspaceId: string userId: string @@ -53,7 +39,7 @@ export interface PerformDeleteWorkspaceFileItemsParams { export interface PerformDeleteWorkspaceFileItemsResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode deletedItems?: WorkspaceFileArchiveResult } @@ -68,7 +54,7 @@ export interface PerformMoveWorkspaceFileItemsParams { export interface PerformMoveWorkspaceFileItemsResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode movedItems?: { files: number; folders: number } } @@ -82,7 +68,7 @@ export interface PerformRenameWorkspaceFileParams { export interface PerformRenameWorkspaceFileResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode file?: WorkspaceFileRecord } @@ -95,7 +81,7 @@ export interface PerformRestoreWorkspaceFileParams { export interface PerformRestoreWorkspaceFileResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode } export interface PerformCreateWorkspaceFileFolderParams { @@ -108,7 +94,7 @@ export interface PerformCreateWorkspaceFileFolderParams { export interface PerformCreateWorkspaceFileFolderResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode folder?: WorkspaceFileFolderRecord } @@ -124,7 +110,7 @@ export interface PerformUpdateWorkspaceFileFolderParams { export interface PerformUpdateWorkspaceFileFolderResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode folder?: WorkspaceFileFolderRecord } @@ -137,7 +123,7 @@ export interface PerformRestoreWorkspaceFileFolderParams { export interface PerformRestoreWorkspaceFileFolderResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode folder?: WorkspaceFileFolderRecord restoredItems?: WorkspaceFileArchiveResult } @@ -207,6 +193,10 @@ export async function performDeleteWorkspaceFileItems( return { success: true, deletedItems } } catch (error) { logger.error('Failed to delete workspace file items', { error }) + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -285,6 +275,10 @@ export async function performMoveWorkspaceFileItems( if (error instanceof WorkspaceFileItemsNotFoundError) { return { success: false, error: error.message, errorCode: 'not_found' } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -316,6 +310,10 @@ export async function performRenameWorkspaceFile( if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { return { success: false, error: toError(error).message, errorCode: 'conflict' } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -331,7 +329,7 @@ export interface PerformMoveRenameWorkspaceFileParams { export interface PerformMoveRenameWorkspaceFileResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode file?: WorkspaceFileRecord } @@ -414,6 +412,10 @@ export async function performRestoreWorkspaceFile( if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { return { success: false, error: toError(error).message, errorCode: 'conflict' } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -448,6 +450,10 @@ export async function performCreateWorkspaceFileFolder( ) { return { success: false, error: toError(error).message, errorCode: 'conflict' } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -495,6 +501,10 @@ export async function performUpdateWorkspaceFileFolder( errorCode: 'conflict', } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -536,6 +546,10 @@ export async function performRestoreWorkspaceFileFolder( errorCode: 'conflict', } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } diff --git a/apps/sim/lib/workspace-files/orchestration/index.ts b/apps/sim/lib/workspace-files/orchestration/index.ts index 81c7af23352..1940e6c165c 100644 --- a/apps/sim/lib/workspace-files/orchestration/index.ts +++ b/apps/sim/lib/workspace-files/orchestration/index.ts @@ -1,3 +1,9 @@ +export { + MAX_WORKSPACE_FILE_CONTENT_BYTES, + type PerformUpdateWorkspaceFileContentParams, + type PerformUpdateWorkspaceFileContentResult, + performUpdateWorkspaceFileContent, +} from './content' export { type PerformCreateWorkspaceFileFolderParams, type PerformCreateWorkspaceFileFolderResult, @@ -23,6 +29,12 @@ export { performRestoreWorkspaceFile, performRestoreWorkspaceFileFolder, performUpdateWorkspaceFileFolder, - type WorkspaceFilesOrchestrationErrorCode, - workspaceFilesOrchestrationStatus, } from './file-folder-lifecycle' +export { + type PerformGetWorkspaceFileShareParams, + type PerformGetWorkspaceFileShareResult, + type PerformUpsertWorkspaceFileShareParams, + type PerformUpsertWorkspaceFileShareResult, + performGetWorkspaceFileShare, + performUpsertWorkspaceFileShare, +} from './share' diff --git a/apps/sim/lib/workspace-files/orchestration/share.ts b/apps/sim/lib/workspace-files/orchestration/share.ts new file mode 100644 index 00000000000..350a6d9958a --- /dev/null +++ b/apps/sim/lib/workspace-files/orchestration/share.ts @@ -0,0 +1,165 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' +import type { + OrchestrationErrorCode, + OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { + getShareForResource, + ShareValidationError, + upsertFileShare, +} from '@/lib/public-shares/share-manager' +import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { + PublicFileSharingNotAllowedError, + validatePublicFileSharing, +} from '@/ee/access-control/utils/permission-check' + +const logger = createLogger('WorkspaceFileShareOrchestration') + +export interface PerformGetWorkspaceFileShareParams { + workspaceId: string + fileId: string +} + +export interface PerformGetWorkspaceFileShareResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + share?: ShareRecord | null +} + +export interface PerformUpsertWorkspaceFileShareParams { + workspaceId: string + fileId: string + userId: string + isActive: boolean + authType?: ShareAuthType + password?: string + allowedEmails?: string[] + /** + * Caller-reserved share token. Only the session UI supplies one, so it can + * show the public link before the share is saved; the public API never does, + * because a caller-chosen token is both guessable and able to collide with an + * existing row's unique index. Omitted means the manager generates one. + */ + token?: string + actorName?: string + actorEmail?: string + request?: OrchestrationRequestContext +} + +export interface PerformUpsertWorkspaceFileShareResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + share?: ShareRecord +} + +export async function performGetWorkspaceFileShare( + params: PerformGetWorkspaceFileShareParams +): Promise { + const { workspaceId, fileId } = params + + try { + const file = await getWorkspaceFile(workspaceId, fileId) + if (!file) return { success: false, error: 'File not found', errorCode: 'not_found' } + + const share = await getShareForResource('file', fileId) + return { success: true, share } + } catch (error) { + logger.error('Failed to fetch workspace file share', { error }) + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} + +/** + * Enables or disables a file's public share. + * + * Both the access-control gate and the effective-authType resolution live here + * rather than in a route, so the session surface and the public API cannot + * diverge on either. Disabling is deliberately never gated — a user must be able + * to un-share after the org policy is turned on. + * + * Note that disabling is not revoking: {@link upsertFileShare} preserves the + * token and the stored password / allow-list, so re-enabling resurrects the + * identical URL. + */ +export async function performUpsertWorkspaceFileShare( + params: PerformUpsertWorkspaceFileShareParams +): Promise { + const { + workspaceId, + fileId, + userId, + isActive, + authType, + password, + allowedEmails, + token, + actorName, + actorEmail, + request, + } = params + + try { + const file = await getWorkspaceFile(workspaceId, fileId) + if (!file) return { success: false, error: 'File not found', errorCode: 'not_found' } + + if (isActive) { + /** + * Validate the auth type that will ACTUALLY be persisted. `upsertFileShare` + * falls back to the existing share's authType when none is passed, so a bare + * re-enable must be checked against that stored mode — not `'public'` — or a + * now-disallowed password/email/sso share could be silently reactivated. + */ + const existingShare = await getShareForResource('file', fileId) + const effectiveAuthType = authType ?? existingShare?.authType ?? 'public' + try { + await validatePublicFileSharing(userId, workspaceId, effectiveAuthType) + } catch (error) { + if (error instanceof PublicFileSharingNotAllowedError) { + logger.warn('Public file sharing disabled for workspace', { workspaceId, fileId }) + return { success: false, error: error.message, errorCode: 'forbidden' } + } + throw error + } + } + + const share = await upsertFileShare({ + workspaceId, + fileId, + userId, + isActive, + authType, + password, + allowedEmails, + token, + }) + + logger.info(`${isActive ? 'Enabled' : 'Disabled'} share for file ${fileId}`) + + recordAudit({ + workspaceId, + actorId: userId, + actorName, + actorEmail, + action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: file.name, + description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`, + request, + }) + + return { success: true, share } + } catch (error) { + if (error instanceof ShareValidationError) { + return { success: false, error: error.message, errorCode: 'validation' } + } + logger.error('Failed to update workspace file share', { error }) + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index eebf44d92e8..c52c7f59a55 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1041, - zodRoutes: 1041, + totalRoutes: 1046, + zodRoutes: 1046, nonZodRoutes: 0, } as const From eb9c1bb8016e089aff1ad7597b29a1133409e7fc Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 11:29:28 -0700 Subject: [PATCH 043/159] feat(cli): wire the expanded v2 files surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regeneration picked up seven new operations (72 → 79), every one of which derived badly. `/files/move` and `/files/bulk-archive` put a verb where the deriver expects a sub-resource, so each became a group holding a lone `create`; `GET /files/[id]/share` fetches one share and was read as a collection and named `list`; and `PATCH /files/[id]` derived to `files update` while its own summary said "Rename File". Named them: batch-archive (matching tables rows batch-delete), move, rename, restore, set-content, share get, share set. Bulk archive is gated behind --yes like the other batch destructives. `files list` gained --scope active|archived, and its rows now carry folderPath — added as a column, since which folder a file sits in is what distinguishes two rows sharing a name. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/contract/commands.ts | 40 ++++ packages/sim-cli/src/generated/v2-api.ts | 246 ++++++++++++++++++++++ 2 files changed, 286 insertions(+) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index de3e2749147..f94b5ed2466 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -122,6 +122,9 @@ export const CLI_CONTRACT: CliContract = { columns: [ { header: 'id' }, { header: 'name' }, + // Now that files live in folders, which one is the difference between two + // identically-named rows. + { header: 'folder', path: 'folderPath' }, { header: 'size', format: 'bytes' }, { header: 'type' }, { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, @@ -200,6 +203,43 @@ export const CLI_CONTRACT: CliContract = { ], }, + // ─── The expanded files surface ─────────────────────────────────────────── + // Every one of these derives badly. `/files/move` and `/files/bulk-archive` + // are verbs sitting where the deriver expects a sub-resource, so it made them + // groups holding a lone `create`; and `GET /files/[id]/share` fetches one + // share, which the deriver read as a collection and named `list`. + bulkArchiveFileItems: { + // `batch-` for the bulk form, matching `tables rows batch-delete`. + command: 'files batch-archive', + describe: 'Archive several files and folders at once', + confirm: 'This archives every listed file and folder, and everything inside those folders.', + }, + moveFileItems: { + command: 'files move', + describe: 'Move files and folders into another folder', + }, + renameFile: { + // Derived to `files update`, which contradicted its own summary. + command: 'files rename', + describe: 'Rename a file', + }, + restoreFile: { + command: 'files restore', + describe: 'Restore an archived file', + }, + updateFileContent: { + command: 'files set-content', + describe: 'Replace a file’s contents', + }, + getFileShare: { + command: 'files share get', + describe: 'Show a file’s share settings', + }, + upsertFileShare: { + command: 'files share set', + describe: 'Enable or disable sharing for a file', + }, + // ─── Documents, not records ─────────────────────────────────────────────── // The payload is the artifact: `sim workflows export > wf.json` has to // produce something `sim workflows import` accepts back. diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 895dbcdae3d..e31c6f1df5b 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -52,6 +52,22 @@ export type AddTableColumnResponse = { } } +/** `POST /api/v2/files/bulk-archive` */ +export type BulkArchiveFileItemsBody = { + workspaceId: string + fileIds?: Array + folderIds?: Array +} + +export type BulkArchiveFileItemsResponse = { + data: { + deletedItems: { + files: number + folders: number + } + } +} + /** `POST /api/v2/workflows/[id]/executions/[executionId]/cancel` */ export type CancelWorkflowExecutionParams = { id: string @@ -920,6 +936,31 @@ export type GetExecutionResponse = { } } +/** `GET /api/v2/files/[fileId]/share` */ +export type GetFileShareParams = { + fileId: string +} + +export type GetFileShareQuery = { + workspaceId: string +} + +export type GetFileShareResponse = { + data: { + share: { + id: string + token: string + url: string + isActive: boolean + resourceType: 'file' | 'folder' + resourceId: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array + } | null + } +} + /** `GET /api/v2/folders/[id]` */ export type GetFolderParams = { id: string @@ -1378,6 +1419,7 @@ export type ListCustomToolsResponse = { /** `GET /api/v2/files` */ export type ListFilesQuery = { workspaceId: string + scope?: 'active' | 'archived' limit?: number cursor?: string } @@ -1389,8 +1431,11 @@ export type ListFilesResponse = { size: number type: string key: string + folderId: string | null + folderPath: string | null uploadedBy: string uploadedAt: string + updatedAt: string }> nextCursor: string | null } @@ -1708,6 +1753,23 @@ export type ListWorkflowsResponse = { nextCursor: string | null } +/** `POST /api/v2/files/move` */ +export type MoveFileItemsBody = { + workspaceId: string + fileIds?: Array + folderIds?: Array + targetFolderId?: string | null +} + +export type MoveFileItemsResponse = { + data: { + movedItems: { + files: number + folders: number + } + } +} + /** `POST /api/v2/tables/[tableId]/query` */ export type QueryRowsParams = { tableId: string @@ -1734,6 +1796,47 @@ export type QueryRowsResponse = { nextCursor: string | null } +/** `PATCH /api/v2/files/[fileId]` */ +export type RenameFileParams = { + fileId: string +} + +export type RenameFileBody = { + workspaceId: string + name: string +} + +export type RenameFileResponse = { + data: { + id: string + name: string + size: number + type: string + key: string + folderId: string | null + folderPath: string | null + uploadedBy: string + uploadedAt: string + updatedAt: string + } +} + +/** `POST /api/v2/files/[fileId]/restore` */ +export type RestoreFileParams = { + fileId: string +} + +export type RestoreFileBody = { + workspaceId: string +} + +export type RestoreFileResponse = { + data: { + id: string + restored: true + } +} + /** `POST /api/v2/workflows/[id]/rollback` */ export type RollbackWorkflowParams = { id: string @@ -1929,6 +2032,32 @@ export type UpdateCustomToolResponse = { } } +/** `PUT /api/v2/files/[fileId]/content` */ +export type UpdateFileContentParams = { + fileId: string +} + +export type UpdateFileContentBody = { + workspaceId: string + content: string + encoding?: 'utf-8' | 'base64' +} + +export type UpdateFileContentResponse = { + data: { + id: string + name: string + size: number + type: string + key: string + folderId: string | null + folderPath: string | null + uploadedBy: string + uploadedAt: string + updatedAt: string + } +} + /** `PATCH /api/v2/folders/[id]` */ export type UpdateFolderParams = { id: string @@ -2162,6 +2291,7 @@ export type UpdateTableRowResponse = { /** `POST /api/v2/files` */ export type UploadFileQuery = { workspaceId: string + folderId?: string } export type UploadFileResponse = { @@ -2171,8 +2301,11 @@ export type UploadFileResponse = { size: number type: string key: string + folderId: string | null + folderPath: string | null uploadedBy: string uploadedAt: string + updatedAt: string } } @@ -2203,6 +2336,35 @@ export type UploadKnowledgeDocumentResponse = { } } +/** `PUT /api/v2/files/[fileId]/share` */ +export type UpsertFileShareParams = { + fileId: string +} + +export type UpsertFileShareBody = { + workspaceId: string + isActive: boolean + authType?: 'public' | 'password' | 'email' | 'sso' + password?: string + allowedEmails?: Array +} + +export type UpsertFileShareResponse = { + data: { + share: { + id: string + token: string + url: string + isActive: boolean + resourceType: 'file' | 'folder' + resourceId: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array + } + } +} + /** `POST /api/v2/tables/[tableId]/rows/upsert` */ export type UpsertTableRowParams = { tableId: string @@ -2250,6 +2412,18 @@ export const V2_OPERATIONS = { column: { kind: 'object', required: true }, }, }, + bulkArchiveFileItems: { + method: 'POST', + path: '/api/v2/files/bulk-archive', + pathParams: [] as const, + responseMode: 'json', + summary: 'Archive Files and Folders', + body: { + workspaceId: { kind: 'string', required: true }, + fileIds: { kind: 'array', default: [] }, + folderIds: { kind: 'array', default: [] }, + }, + }, cancelWorkflowExecution: { method: 'POST', path: '/api/v2/workflows/[id]/executions/[executionId]/cancel', @@ -2587,6 +2761,16 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Execution', }, + getFileShare: { + method: 'GET', + path: '/api/v2/files/[fileId]/share', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Get File Share', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, getFolder: { method: 'GET', path: '/api/v2/folders/[id]', @@ -2763,6 +2947,7 @@ export const V2_OPERATIONS = { summary: 'List Files', query: { workspaceId: { kind: 'string', required: true }, + scope: { kind: 'enum', values: ['active', 'archived'] as const, default: 'active' }, limit: { kind: 'number', default: 100 }, cursor: { kind: 'string' }, }, @@ -2942,6 +3127,19 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + moveFileItems: { + method: 'POST', + path: '/api/v2/files/move', + pathParams: [] as const, + responseMode: 'json', + summary: 'Move Files and Folders', + body: { + workspaceId: { kind: 'string', required: true }, + fileIds: { kind: 'array', default: [] }, + folderIds: { kind: 'array', default: [] }, + targetFolderId: { kind: 'string' }, + }, + }, queryRows: { method: 'POST', path: '/api/v2/tables/[tableId]/query', @@ -2956,6 +3154,27 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + renameFile: { + method: 'PATCH', + path: '/api/v2/files/[fileId]', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Rename File', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + }, + }, + restoreFile: { + method: 'POST', + path: '/api/v2/files/[fileId]/restore', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Restore File', + body: { + workspaceId: { kind: 'string', required: true }, + }, + }, rollbackWorkflow: { method: 'POST', path: '/api/v2/workflows/[id]/rollback', @@ -3018,6 +3237,18 @@ export const V2_OPERATIONS = { code: { kind: 'string' }, }, }, + updateFileContent: { + method: 'PUT', + path: '/api/v2/files/[fileId]/content', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Replace File Content', + body: { + workspaceId: { kind: 'string', required: true }, + content: { kind: 'string', required: true }, + encoding: { kind: 'enum', values: ['utf-8', 'base64'] as const, default: 'utf-8' }, + }, + }, updateFolder: { method: 'PATCH', path: '/api/v2/folders/[id]', @@ -3128,6 +3359,7 @@ export const V2_OPERATIONS = { summary: 'Upload File', query: { workspaceId: { kind: 'string', required: true }, + folderId: { kind: 'string' }, }, }, uploadKnowledgeDocument: { @@ -3140,6 +3372,20 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + upsertFileShare: { + method: 'PUT', + path: '/api/v2/files/[fileId]/share', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Enable or Disable File Share', + body: { + workspaceId: { kind: 'string', required: true }, + isActive: { kind: 'boolean', required: true }, + authType: { kind: 'enum', values: ['public', 'password', 'email', 'sso'] as const }, + password: { kind: 'string' }, + allowedEmails: { kind: 'array' }, + }, + }, upsertTableRow: { method: 'POST', path: '/api/v2/tables/[tableId]/rows/upsert', From 5154a92ec89ea6692ec828c24798dc0a97fc1373 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 11:43:08 -0700 Subject: [PATCH 044/159] feat(cli): sim files upload The counterpart to `files download`, and hand-written for the same reason: POST /api/v2/files is multipart, which the generated flag surface cannot express, so `uploadFile` has been hidden since the start. Reads the file with openAsBlob so it stays on disk while the request is written, rather than buffering the whole upload in memory. Size is checked against the route's own 100MB ceiling before anything is sent. Content type comes from the extension, since the stored type decides whether the workspace later renders a file or offers it for download. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/commands/hand-written.ts | 103 +++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/packages/sim-cli/src/commands/hand-written.ts b/packages/sim-cli/src/commands/hand-written.ts index 4afce929849..75910fc356a 100644 --- a/packages/sim-cli/src/commands/hand-written.ts +++ b/packages/sim-cli/src/commands/hand-written.ts @@ -1,5 +1,6 @@ import { once } from 'node:events' -import { createWriteStream, type WriteStream } from 'node:fs' +import { createWriteStream, openAsBlob, type WriteStream } from 'node:fs' +import { stat } from 'node:fs/promises' import { basename } from 'node:path' import chalk from 'chalk' import type { Command } from 'commander' @@ -116,7 +117,107 @@ function group(program: Command, name: string): Command { return created } +/** + * The server stores whatever content type the part carries, falling back to + * `application/octet-stream`, and that type is what later decides whether the + * workspace renders a file or offers it as a download. Node does not ship a + * mime table, so the common cases are listed and everything else falls back. + */ +const CONTENT_TYPES: Record = { + css: 'text/css', + csv: 'text/csv', + gif: 'image/gif', + html: 'text/html', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + js: 'text/javascript', + json: 'application/json', + md: 'text/markdown', + pdf: 'application/pdf', + png: 'image/png', + svg: 'image/svg+xml', + txt: 'text/plain', + webp: 'image/webp', + yaml: 'application/yaml', + yml: 'application/yaml', + zip: 'application/zip', +} + +function contentTypeFor(name: string): string { + const dot = name.lastIndexOf('.') + const extension = dot === -1 ? '' : name.slice(dot + 1).toLowerCase() + return CONTENT_TYPES[extension] ?? 'application/octet-stream' +} + +/** The route's own ceiling. Checked here so a 100 MB body is never sent to be refused. */ +const MAX_UPLOAD_BYTES = 100 * 1024 * 1024 + export function attachHandWritten(program: Command): void { + // ── files upload ── multipart, which the generated flag surface cannot express ── + group(program, 'files') + .command('upload ') + .description('Upload a file to the workspace') + .option('--folder-id ', 'Target folder (defaults to the workspace root)') + .option('--name ', 'Store it under a different name') + .action( + async (path: string, options: { folderId?: string; name?: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + + if (!profile.apiKey) { + throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0) + } + + let size: number + try { + const stats = await stat(path) + if (stats.isDirectory()) throw new SimApiError(`${path} is a directory`, 0) + size = stats.size + } catch (error) { + if (error instanceof SimApiError) throw error + throw new SimApiError(`Cannot read ${path}: ${(error as Error).message}`, 0) + } + + // Fail here rather than after streaming 100 MB the server will reject. + if (size > MAX_UPLOAD_BYTES) { + throw new SimApiError( + `${path} is ${(size / 1024 / 1024).toFixed(1)}MB; the limit is 100MB`, + 0 + ) + } + + const name = options.name ?? basename(path) + const url = new URL(`${profile.endpoint}/api/v2/files`) + url.searchParams.set('workspaceId', workspaceId) + if (options.folderId) url.searchParams.set('folderId', options.folderId) + + // `openAsBlob` keeps the file on disk and reads it as the request is + // written; building a Buffer first would hold the whole upload in memory. + const body = new FormData() + body.append('file', await openAsBlob(path, { type: contentTypeFor(name) }), name) + + const response = await fetch(url, { + method: 'POST', + headers: { 'x-api-key': profile.apiKey }, + body, + }) + + const payload = (await response.json().catch(() => null)) as { + data?: { id?: string } + error?: { message?: string } + } | null + + if (!response.ok) { + throw new SimApiError( + payload?.error?.message ?? `Upload failed with status ${response.status}`, + response.status + ) + } + + console.log(chalk.green(`✓ Uploaded ${name} (${payload?.data?.id ?? 'unknown id'})`)) + } + ) + // ── files download ── the response is binary, not the JSON envelope ──────── group(program, 'files') .command('download ') From b97987ee55800bdad5861131ee56b17b95de23a0 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 11:56:28 -0700 Subject: [PATCH 045/159] fix(cli): make tables rows query show the rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things stacked up so the command appeared to do nothing. A row's cells live under `data`, and column inference skips object-valued fields — so the table came back listing an id and two timestamps per row and none of the content the query was run for. `expand` names the wrapper whose keys become columns, unioned across the page like the top-level ones. A cell key that shadows a top-level field is shown by its full path, so two different values never share a header. A cell containing a newline pushed the rest of its row onto the next line and every column after it lost alignment; in text mode a tab invented a field that `cut -f` reads as real. Display cells are now flattened to one line. `sanitize` still keeps \t and \n — json and yaml must round-trip them, and this is applied only to finished cells. A single cell holding an LLM response set the column width for the whole table and pushed everything after it off-screen, so table cells clamp at 60 columns. text/json/yaml are untouched: those exist for the whole value. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/contract/commands.ts | 3 ++ packages/sim-cli/src/contract/types.ts | 10 ++++ packages/sim-cli/src/output/render.test.ts | 58 ++++++++++++++++++++++ packages/sim-cli/src/output/render.ts | 43 ++++++++++++++-- packages/sim-cli/src/runtime/build.test.ts | 27 ++++++++++ packages/sim-cli/src/runtime/build.ts | 46 +++++++++++++---- 6 files changed, 174 insertions(+), 13 deletions(-) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index f94b5ed2466..07f69806d57 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -98,6 +98,9 @@ export const CLI_CONTRACT: CliContract = { queryRows: { command: 'tables rows query', flags: { predicate: { name: 'filter', json: true }, sort: { json: true } }, + // A row's cells live under `data`; without this the table showed an id and + // two timestamps per row and none of the content anyone ran the query for. + expand: 'data', }, // ─── Output columns for list commands ───────────────────────────────────── diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 9158f64813b..2460a351b4d 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -84,6 +84,16 @@ export interface CommandSpec { * the point is that the caller can tell whether they meant it. */ confirm?: string + /** + * Discover table columns from inside this nested field as well as from the + * row's own scalars. + * + * For rows whose real content sits in a wrapper the server chose — a table + * row's user-defined cells live under `data` — the inferred columns would + * otherwise be `id` and two timestamps, because a nested object cannot be a + * column. Only meaningful when `columns` is absent. + */ + expand?: string /** * The response IS a document, not a record to look at. * diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts index cffd2cc677b..57e78bc8395 100644 --- a/packages/sim-cli/src/output/render.test.ts +++ b/packages/sim-cli/src/output/render.test.ts @@ -259,3 +259,61 @@ describe('sanitize', () => { expect(timestamp('2026-07-31T09:14:22.500Z')).toBe('2026-07-31 09:14:22') }) }) + +describe('cells stay on their own line', () => { + const rows = [{ note: 'first\nsecond', tabbed: 'a\tb' }] + const columns: Column<(typeof rows)[number]>[] = [ + { header: 'note', value: (row) => row.note }, + { header: 'tabbed', value: (row) => row.tabbed }, + ] + + function captured(format: 'table' | 'text' | 'json'): string[] { + const lines: string[] = [] + const spy = vi.spyOn(console, 'log').mockImplementation((line: string) => { + lines.push(line) + }) + printList(format, rows, columns) + spy.mockRestore() + return lines + } + + it('collapses a newline inside a table cell', () => { + // One newline pushed the rest of the row onto the next line and every + // column after it lost its alignment. + const table = captured('table').join('\n') + expect(table.split('\n')).toHaveLength(2) + expect(table).toContain('first second') + }) + + it('collapses a tab in text mode, so cut -f still sees real fields', () => { + const [line] = captured('text') + expect(line.split('\t')).toHaveLength(2) + expect(line).toBe('first second\ta b') + }) + + it('leaves json untouched', () => { + expect(JSON.parse(captured('json').join('\n'))).toEqual([ + { note: 'first\nsecond', tabbed: 'a\tb' }, + ]) + }) + + it('clamps a very wide cell in table mode only', () => { + const wide = [{ blob: 'x'.repeat(500) }] + const cols: Column<(typeof wide)[number]>[] = [{ header: 'blob', value: (row) => row.blob }] + const lines: string[] = [] + const spy = vi.spyOn(console, 'log').mockImplementation((line: string) => { + lines.push(line) + }) + printList('table', wide, cols) + printList('text', wide, cols) + spy.mockRestore() + + // The table arrives as one string: header line, then the clamped body line. + const [header, body] = lines[0].split('\n') + expect(header.trim()).toBe('BLOB') + expect(body).toMatch(/…$/) + expect(body.length).toBeLessThan(100) + // `text` feeds pipelines; truncating there would corrupt the data. + expect(lines[1]).toHaveLength(500) + }) +}) diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index 3905bab418c..011c9a8155b 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -131,6 +131,41 @@ function pad(value: string, width: number): string { return value + ' '.repeat(Math.max(0, width - visibleWidth(value))) } +/** + * Flattens a cell onto one line. + * + * `sanitize` keeps `\t` and `\n` on purpose — they are legitimate content, and + * json/yaml must round-trip them. Every *display* format is line-oriented + * though: one newline inside a table cell pushes the rest of the row into the + * next line and every column after it loses its alignment, and in `text` mode a + * stray tab invents a field that `cut -f` then reads as real. A table row of a + * workflow's Slack output did exactly this. + * + * Applied to finished cells only, so it cannot reach the machine formats. + */ +function oneLine(value: string): string { + return value.replace(/\s*[\r\n\t]+\s*/g, ' ') +} + +/** + * Widest a single table column may render. + * + * A table row can hold a whole LLM response; at full width one such cell sets + * the column width for every row and pushes everything after it off-screen. + * `text`, `json` and `yaml` are untouched — this is a legibility cap on the + * human view, and the other three formats exist for the whole value. + */ +const MAX_CELL_WIDTH = 60 + +function clampCell(value: string): string { + // ANSI-bearing cells come from the short formatters (`yes`/`no`, the empty + // glyph); slicing one mid-escape would corrupt it, and none are ever wide. + if (visibleWidth(value) <= MAX_CELL_WIDTH || value !== value.replace(ANSI_PATTERN, '')) { + return value + } + return `${value.slice(0, MAX_CELL_WIDTH - 1)}…` +} + function renderTable(rows: T[], columns: Column[]): string { if (rows.length === 0) return chalk.dim('No results.') @@ -138,7 +173,7 @@ function renderTable(rows: T[], columns: Column[]): string { // remote content and gets the same treatment as a cell. Doing it here rather // than only at each call site means a future column source cannot reopen this. const headers = columns.map((column) => sanitize(column.header)) - const cells = rows.map((row) => columns.map((column) => column.value(row))) + const cells = rows.map((row) => columns.map((column) => clampCell(oneLine(column.value(row))))) const widths = columns.map((_column, index) => Math.max(visibleWidth(headers[index]), ...cells.map((line) => visibleWidth(line[index]))) ) @@ -193,7 +228,7 @@ export function printList(format: OutputFormat, rows: T[], columns: Column if (format === 'text') { for (const row of rows) { - console.log(columns.map((column) => stripAnsi(column.value(row))).join('\t')) + console.log(columns.map((column) => oneLine(stripAnsi(column.value(row)))).join('\t')) } return } @@ -226,13 +261,13 @@ export function printRecord(format: OutputFormat, fields: Array<[string, string] if (format === 'text') { for (const [label, value] of fields) { - console.log(`${label}\t${stripAnsi(value)}`) + console.log(`${label}\t${oneLine(stripAnsi(value))}`) } return } const width = Math.max(...fields.map(([label]) => label.length)) for (const [label, value] of fields) { - console.log(`${chalk.dim(pad(`${label}:`, width + 1))} ${value}`) + console.log(`${chalk.dim(pad(`${label}:`, width + 1))} ${oneLine(value)}`) } } diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 958d4741493..155c62fe5fe 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -243,3 +243,30 @@ describe('pagination slot', () => { expect(mockRequest.mock.calls[1][1].query).toMatchObject({ cursor: 'c1' }) }) }) + +describe('rows whose content sits in a wrapper', () => { + it('discovers columns from the expanded field', async () => { + // `tables rows query` returned a table of ids and timestamps: a row's cells + // live under `data`, and column inference skipped it for being an object. + mockRequest.mockReset() + mockRequest.mockResolvedValue({ + data: [ + { id: 'r1', data: { url: 'https://a', title: 'A' }, createdAt: 'now' }, + { id: 'r2', data: { url: 'https://b', extra: 'E' }, createdAt: 'now' }, + ], + nextCursor: null, + }) + const lines: string[] = [] + output.format = 'text' + vi.spyOn(console, 'log').mockImplementation((line: string) => { + lines.push(line) + }) + await program().parseAsync(['node', 'sim', 'tables', 'rows', 'query', 'tbl_1']) + output.format = 'json' + + // Unioned across the page: `extra` appears only on the second row. + expect(lines[0]).toContain('https://a') + expect(lines[0]).toContain('A') + expect(lines[1]).toContain('E') + }) +}) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 365a6390fda..a359c6f6d16 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -89,10 +89,12 @@ function columnsFrom(specs: ColumnSpec[]): Column[] { * Row shapes are only known at runtime here — a table's `data` is user-defined — * so the keys are unioned across the page rather than read off the first row, * which would let a sparse row hide every column it happens to omit. Nested - * values are skipped: they render as JSON blobs and make the table unreadable. + * values are skipped: they render as JSON blobs and make the table unreadable — + * unless the contract names one with `expand`, which is how a row's cells reach + * the table. */ -function inferColumns(rows: unknown[]): Column[] { - const keys: string[] = [] +function inferColumns(rows: unknown[], expand?: string): Column[] { + const paths: Array<{ path: string; header: string }> = [] const seen = new Set() for (const row of rows) { @@ -101,16 +103,34 @@ function inferColumns(rows: unknown[]): Column[] { if (seen.has(key)) continue if (value !== null && typeof value === 'object') continue seen.add(key) - keys.push(key) + paths.push({ path: key, header: key }) } } - return keys.map((key) => ({ + // The wrapper named by `expand` holds the only content the caller cares about; + // the loop above skipped it for being an object, which is how `tables rows + // query` came back showing nothing but ids and timestamps. + if (expand) { + const nested = new Set() + for (const row of rows) { + const container = at(row, expand) + if (!container || typeof container !== 'object' || Array.isArray(container)) continue + for (const key of Object.keys(container)) { + if (nested.has(key)) continue + nested.add(key) + // A user-defined key that shadows a top-level one is shown by its full + // path, so two different values never appear under one header. + paths.push({ path: `${expand}.${key}`, header: seen.has(key) ? `${expand}.${key}` : key }) + } + } + } + + return paths.map(({ path, header }) => ({ // The key itself is remote data when the rows are user-defined, and the // header is printed just like a cell — sanitizing values but not headers // left the same control sequences executable one row higher. - header: sanitize(key), - value: (row: unknown) => renderCell(at(row, key), 'auto'), + header: sanitize(header), + value: (row: unknown) => renderCell(at(row, path), 'auto'), })) } @@ -297,7 +317,11 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri } while (cursor && rows.length < limit) const page = Number.isFinite(limit) ? rows.slice(0, limit) : rows - printList(profile.output, page, spec.columns ? columnsFrom(spec.columns) : inferColumns(page)) + printList( + profile.output, + page, + spec.columns ? columnsFrom(spec.columns) : inferColumns(page, spec.expand) + ) return } @@ -318,7 +342,11 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri if (Array.isArray(data)) { // Reached when a non-paginated operation answers with a collection. // `printRecord` would silently print nothing for an array. - printList(profile.output, data, spec.columns ? columnsFrom(spec.columns) : inferColumns(data)) + printList( + profile.output, + data, + spec.columns ? columnsFrom(spec.columns) : inferColumns(data, spec.expand) + ) return } From 89b4d9b7f1fb2a91dc2dea63b857ce3f62e2b809 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 12:04:29 -0700 Subject: [PATCH 046/159] fix(cli): make boolean flags able to say false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--is-active false` turned sharing ON and reported success. Booleans were declared presence-only, so the flag meant `true` and commander dropped the `false` as an argument the command had no use for — silently, because excess arguments are ignored by default. A required boolean now takes its value (`--is-active `): it is a state to set, not a switch to flip on, and as a presence flag it could only ever send one of the two values it needs to express. Optional booleans stay presence-flags — `--deployed-only` reads better than `--deployed-only true` — but each also gets `--no-`. Omitting one means "leave it alone", which is not the same as setting it false; without the negation there was no way to disable an MCP server or unlock a folder. Excess arguments are now an error on every generated command, so a value attached to the wrong flag stops rather than being silently discarded. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/runtime/build.test.ts | 58 ++++++++++++++++++++++ packages/sim-cli/src/runtime/build.ts | 24 +++++++++ 2 files changed, 82 insertions(+) diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 155c62fe5fe..15677b76ed6 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -28,6 +28,14 @@ vi.mock('../context.js', () => ({ function program(): Command { const root = new Command('sim').exitOverride() for (const group of buildGeneratedCommands(new Set())) root.addCommand(group) + // Recursively, not just on the root: a parse error raised by a leaf (an + // unknown option, an excess argument) exits the process otherwise, which a + // test cannot assert on. + const override = (command: Command) => { + command.exitOverride() + command.commands.forEach(override) + } + override(root) return root } @@ -270,3 +278,53 @@ describe('rows whose content sits in a wrapper', () => { expect(lines[1]).toContain('E') }) }) + +describe('boolean flags', () => { + it('takes an explicit value when the field is required', async () => { + // As a presence-only flag this could only ever send `true`: `--is-active + // false` turned sharing ON and reported success, with the `false` dropped + // as a stray argument. + const [, options] = await run([ + 'files', + 'share', + 'set', + 'f_1', + '--is-active', + 'false', + '--auth-type', + 'public', + ]) + expect(options.body).toMatchObject({ isActive: false }) + + const [, on] = await run([ + 'files', + 'share', + 'set', + 'f_1', + '--is-active', + 'true', + '--auth-type', + 'public', + ]) + expect(on.body).toMatchObject({ isActive: true }) + }) + + it('negates an optional boolean, which omitting it cannot do', async () => { + // Omitting `enabled` means "leave it alone"; there was no way to say false, + // so an MCP server could not be disabled or a folder unlocked. + const [, off] = await run(['mcp-servers', 'update', 'mcp_1', '--no-enabled']) + expect(off.body).toMatchObject({ enabled: false }) + + const [, on] = await run(['mcp-servers', 'update', 'mcp_1', '--enabled']) + expect(on.body).toMatchObject({ enabled: true }) + + const [, absent] = await run(['mcp-servers', 'update', 'mcp_1', '--name', 'x']) + expect(absent.body).not.toHaveProperty('enabled') + }) + + it('rejects an argument the command has no meaning for', async () => { + await expect(run(['mcp-servers', 'update', 'mcp_1', '--enabled', 'bogus'])).rejects.toThrow( + /too many arguments/ + ) + }) +}) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index a359c6f6d16..3b76d0a4699 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -205,7 +205,27 @@ function addFieldOption( } if (descriptor.kind === 'boolean') { + // A required boolean is a state to set, not a switch to flip on: it takes + // the value explicitly. As a presence-only flag it could only ever send + // `true`, so `--is-active false` set sharing ON — commander read the flag as + // true and dropped the `false` as a stray argument. + if (descriptor.required) { + command.addOption( + new Option(`${short}--${name} `, flag.describe ?? `Set ${field}`).choices([ + 'true', + 'false', + ]) + ) + return + } + + // Optional booleans stay presence-flags — `--deployed-only` reads better + // than `--deployed-only true` — but every one of them also gets a negation, + // because for a state field (`enabled`, `locked`) omitting the flag means + // "leave it alone", which is not the same as setting it false. Without this + // there was no way to disable an MCP server or unlock a folder. command.option(`${short}--${name}`, flag.describe ?? `Set ${field}`) + command.option(`--no-${name}`, `Set ${field} to false`) return } @@ -246,6 +266,10 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri // NAME, so `sim tables upsert` would never match it and would silently fall // through to the group's help. Arguments have to be declared separately. const command = new Command(leafName) + // Commander ignores arguments beyond those declared. That silence is how + // `--is-active false` ran as though the `false` had never been typed; an + // argument the command has no meaning for is a mistake worth stopping on. + command.allowExcessArguments(false) for (const param of operationSpec.pathParams) { command.argument(`<${param}>`) } From 2d8350e70f0a0f4a40f275c2be113f1b5d36056d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 22:12:44 -0700 Subject: [PATCH 047/159] feat(api): add search, filtering, and sorting to the v2 list endpoints (#6189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): add search, filtering, and sorting to the v2 list endpoints One convention across every v2 list, documented on lib/api/contracts/v2/shared.ts: `search` (case-insensitive substring on the resource's natural name field), `sortBy` + `sortOrder` (per-resource enum, never a free string), and enumerated resource-specific filters. Reuses the sortBy/sortOrder pair v2 logs and v2 knowledge-documents already ship rather than inventing a third dialect alongside the Logs filters and the Tables predicate grammar. Every filter and sort is pushed into SQL. GET /api/v2/files previously read the whole scope and sorted/sliced it in JS; it now goes through a new queryWorkspaceFiles that filters, orders, and bounds the page in one query. Cursors are stamped with the sort they were minted under, so replaying one under a different sort is a 400 instead of silently duplicated or skipped rows. * fix(api): validate v2 cursor key values and compare timestamps at ms precision Two review findings, fixed at the root by making a keyset key own its cursor codec instead of hand-writing a decoder per sort. Cursor key values are caller-controlled, and matching the sort stamp and key count was not enough: an unparseable timestamp or a non-numeric size reached the query as an Invalid Date or NaN and surfaced as a 500. Each key now type- checks its own value and rejects a cursor it cannot hold, which both routes render as the documented 400. Timestamp keys now order and compare on date_trunc('milliseconds', col). Postgres keeps microseconds and defaultNow() populates them, but a cursor value round-trips through a millisecond-only JS Date — comparing the raw column against the truncated value re-admitted the page's own last row, duplicating it and stalling pagination outright at a page size of one. Reachable today via workspace_files.updated_at, which insertFileMetadata leaves to defaultNow(). --- apps/docs/openapi-v2-files-audit.json | 32 +++ apps/docs/openapi-v2-knowledge.json | 32 +++ apps/docs/openapi-v2-resources.json | 137 ++++++++++- apps/docs/openapi-v2-tables.json | 32 +++ apps/docs/openapi-v2-workflows.json | 25 ++ apps/sim/app/api/v2/credentials/route.test.ts | 27 +++ apps/sim/app/api/v2/credentials/route.ts | 5 +- .../sim/app/api/v2/custom-tools/route.test.ts | 37 ++- apps/sim/app/api/v2/custom-tools/route.ts | 4 +- apps/sim/app/api/v2/files/route.test.ts | 141 +++++++++-- apps/sim/app/api/v2/files/route.ts | 71 +++--- apps/sim/app/api/v2/folders/route.test.ts | 48 +++- apps/sim/app/api/v2/folders/route.ts | 8 +- apps/sim/app/api/v2/knowledge/route.test.ts | 135 +++++++++++ apps/sim/app/api/v2/knowledge/route.ts | 9 +- apps/sim/app/api/v2/lib/response.ts | 55 +++++ apps/sim/app/api/v2/mcp-servers/route.test.ts | 37 ++- apps/sim/app/api/v2/mcp-servers/route.ts | 4 +- apps/sim/app/api/v2/skills/route.test.ts | 31 ++- apps/sim/app/api/v2/skills/route.ts | 4 +- apps/sim/app/api/v2/tables/route.test.ts | 25 ++ apps/sim/app/api/v2/tables/route.ts | 4 +- apps/sim/app/api/v2/workflows/route.test.ts | 212 +++++++++++++++++ apps/sim/app/api/v2/workflows/route.ts | 122 ++++++---- apps/sim/lib/api/contracts/v2/credentials.ts | 14 +- apps/sim/lib/api/contracts/v2/custom-tools.ts | 21 +- apps/sim/lib/api/contracts/v2/files.ts | 26 +- apps/sim/lib/api/contracts/v2/folders.ts | 23 +- apps/sim/lib/api/contracts/v2/knowledge.ts | 28 ++- apps/sim/lib/api/contracts/v2/mcp-servers.ts | 20 +- apps/sim/lib/api/contracts/v2/shared.ts | 69 ++++++ apps/sim/lib/api/contracts/v2/skills.ts | 20 +- apps/sim/lib/api/contracts/v2/tables.ts | 31 ++- apps/sim/lib/api/contracts/v2/workflows.ts | 49 +++- apps/sim/lib/api/list-convention.test.ts | 225 ++++++++++++++++++ apps/sim/lib/api/list-query.test.ts | 193 +++++++++++++++ apps/sim/lib/api/list-query.ts | 176 ++++++++++++++ apps/sim/lib/credentials/queries.ts | 34 ++- apps/sim/lib/folders/queries.ts | 44 +++- apps/sim/lib/knowledge/service.test.ts | 2 +- apps/sim/lib/knowledge/service.ts | 50 +++- apps/sim/lib/mcp/queries.ts | 31 ++- apps/sim/lib/table/service.ts | 62 +++-- .../workspace/workspace-file-manager.ts | 158 +++++++++--- .../workspace/workspace-file-query.test.ts | 206 ++++++++++++++++ .../lib/workflows/custom-tools/operations.ts | 34 ++- apps/sim/lib/workflows/skills/operations.ts | 75 +++++- packages/testing/src/mocks/database.mock.ts | 2 + 48 files changed, 2612 insertions(+), 218 deletions(-) create mode 100644 apps/sim/app/api/v2/knowledge/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/route.test.ts create mode 100644 apps/sim/lib/api/list-convention.test.ts create mode 100644 apps/sim/lib/api/list-query.test.ts create mode 100644 apps/sim/lib/api/list-query.ts create mode 100644 apps/sim/lib/uploads/contexts/workspace/workspace-file-query.test.ts diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 81df2b36c50..476d289159e 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -79,6 +79,38 @@ }, { "$ref": "#/components/parameters/Cursor" + }, + { + "name": "folderId", + "in": "query", + "required": false, + "description": "Restrict the list to one folder. Omit to list every file in the workspace.", + "schema": { "type": "string", "minLength": 1 } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the file `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.", + "schema": { + "type": "string", + "enum": ["name", "size", "uploadedAt", "updatedAt"], + "default": "uploadedAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } } ], "responses": { diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 806bbbccd83..38a6d2ed2f5 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -48,6 +48,38 @@ "parameters": [ { "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "folderId", + "in": "query", + "required": false, + "description": "Restrict the list to one folder. Omit to list every knowledge base in the workspace.", + "schema": { "type": "string", "minLength": 1 } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the knowledge base `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } } ], "responses": { diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index d2683950f38..f7a6117a2c9 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -61,7 +61,34 @@ "source": "curl \\\n \"https://www.sim.ai/api/v2/mcp-servers?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], - "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "parameters": [ + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the MCP server `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + } + ], "responses": { "200": { "description": "MCP servers registered in the workspace.", @@ -405,7 +432,34 @@ "source": "curl \\\n \"https://www.sim.ai/api/v2/skills?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], - "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "parameters": [ + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the skill `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by. Built-in skills have no stored timestamps and sort as if created at the Unix epoch.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + } + ], "responses": { "200": { "description": "Skills available in the workspace.", @@ -706,7 +760,34 @@ "source": "curl \\\n \"https://www.sim.ai/api/v2/custom-tools?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], - "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "parameters": [ + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the custom tool `title`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["title", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + } + ], "responses": { "200": { "description": "Custom tools defined in the workspace.", @@ -1057,6 +1138,31 @@ "required": false, "description": "`active` (default) lists live folders; `archived` lists Recently Deleted.", "schema": { "type": "string", "enum": ["active", "archived"], "default": "active" } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the folder `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by. `position` is the tree's own manual arrangement, which is the default order.", + "schema": { + "type": "string", + "enum": ["position", "name", "createdAt", "updatedAt"], + "default": "position" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } } ], "responses": { @@ -1407,6 +1513,31 @@ "required": false, "description": "Only return credentials for this integration.", "schema": { "type": "string", "minLength": 1, "example": "slack" } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the credential `displayName`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["displayName", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } } ], "responses": { diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 3f50df8b4b0..00f523d23eb 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -49,6 +49,38 @@ "parameters": [ { "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "folderId", + "in": "query", + "required": false, + "description": "Restrict the list to one folder. Omit to list every table in the workspace.", + "schema": { "type": "string", "minLength": 1 } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the table `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "createdAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } } ], "responses": { diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 95b85a369f2..0f7cb0a95b6 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -90,6 +90,31 @@ "schema": { "type": "string" } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the workflow `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by. `position` is the workspace's own manual arrangement of its workflows, which is the default order. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.", + "schema": { + "type": "string", + "enum": ["position", "name", "createdAt", "updatedAt", "runCount"], + "default": "position" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } } ], "responses": { diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index d9fa30535a9..2bfe1cbfad4 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -204,6 +204,33 @@ describe('GET /api/v2/credentials', () => { expect.objectContaining({ type: 'oauth', providerId: 'slack' }) ) }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=${WORKSPACE_ID}&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=${WORKSPACE_ID}&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=${WORKSPACE_ID}&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList( + `workspaceId=${WORKSPACE_ID}&search=report&sortBy=displayName&sortOrder=asc` + ) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() + }) }) describe('POST /api/v2/credentials', () => { diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index 232110187c1..2b710b275bf 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -54,7 +54,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, type, providerId } = parsed.data.query + const { workspaceId, type, providerId, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -71,6 +71,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { workspaceAccess, type, providerId, + search, + sortBy, + sortOrder, }) // The per-workspace credential set is small and bounded → a single full page. 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 5693e018448..7ca46e81b0c 100644 --- a/apps/sim/app/api/v2/custom-tools/route.test.ts +++ b/apps/sim/app/api/v2/custom-tools/route.test.ts @@ -82,6 +82,13 @@ function buildTool(overrides: Record = {}) { } } +/** What the route forwards for a bare `?workspaceId=` list. */ +const DEFAULT_LIST_ARGS = { + search: undefined, + sortBy: 'createdAt', + sortOrder: 'desc', +} + const callList = (query: string) => GET(new NextRequest(`http://localhost:3000/api/v2/custom-tools?${query}`)) @@ -162,7 +169,35 @@ describe('GET /api/v2/custom-tools', () => { updatedAt: '2024-01-02T00:00:00.000Z', }, ]) - expect(mockListWorkspaceCustomTools).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) + expect(mockListWorkspaceCustomTools).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + ...DEFAULT_LIST_ARGS, + }) + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=workspace-1&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList(`workspaceId=workspace-1&search=report&sortBy=title&sortOrder=asc`) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() }) }) diff --git a/apps/sim/app/api/v2/custom-tools/route.ts b/apps/sim/app/api/v2/custom-tools/route.ts index b746b285cc2..96b678a5078 100644 --- a/apps/sim/app/api/v2/custom-tools/route.ts +++ b/apps/sim/app/api/v2/custom-tools/route.ts @@ -52,12 +52,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const rows = await listWorkspaceCustomTools({ workspaceId }) + const rows = await listWorkspaceCustomTools({ workspaceId, search, sortBy, sortOrder }) // The per-workspace tool set is small and bounded → a single full page. return v2CursorList(rows.map(toV2CustomTool), null, { rateLimit }) diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index 270661356a4..c6e68913959 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -10,7 +10,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckRateLimit, mockResolveWorkspaceAccess, - mockListWorkspaceFiles, + mockQueryWorkspaceFiles, mockUploadWorkspaceFile, mockGetWorkspaceFile, mockReadFormDataWithLimit, @@ -18,7 +18,7 @@ const { } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), - mockListWorkspaceFiles: vi.fn(), + mockQueryWorkspaceFiles: vi.fn(), mockUploadWorkspaceFile: vi.fn(), mockGetWorkspaceFile: vi.fn(), mockReadFormDataWithLimit: vi.fn(), @@ -35,7 +35,7 @@ vi.mock('@/app/api/v2/lib/gate', () => ({ })) vi.mock('@/lib/uploads/contexts/workspace', () => ({ - listWorkspaceFiles: mockListWorkspaceFiles, + queryWorkspaceFiles: mockQueryWorkspaceFiles, uploadWorkspaceFile: mockUploadWorkspaceFile, getWorkspaceFile: mockGetWorkspaceFile, FileConflictError: class FileConflictError extends Error {}, @@ -94,6 +94,17 @@ function buildRecord(overrides: Record = {}) { } } +/** What the route forwards for a bare `?workspaceId=` list. */ +const DEFAULT_LIST_ARGS = { + scope: 'active', + folderId: undefined, + search: undefined, + sortBy: 'uploadedAt', + sortOrder: 'asc', + limit: 100, + after: undefined, +} + const callList = (query: string) => GET(new NextRequest(`http://localhost:3000/api/v2/files?${query}`)) @@ -111,7 +122,7 @@ describe('GET /api/v2/files', () => { vi.clearAllMocks() mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) - mockListWorkspaceFiles.mockResolvedValue([buildRecord()]) + mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null }) }) it('returns 404 when the v2 API surface flag is off', async () => { @@ -123,20 +134,20 @@ describe('GET /api/v2/files', () => { expect(res.status).toBe(404) expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockListWorkspaceFiles).not.toHaveBeenCalled() + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() }) it('400s when workspaceId is missing', async () => { const res = await callList('limit=10') expect(res.status).toBe(400) expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockListWorkspaceFiles).not.toHaveBeenCalled() + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() }) it('400s on a scope outside the enum', async () => { const res = await callList(`workspaceId=${WS}&scope=everything`) expect(res.status).toBe(400) - expect(mockListWorkspaceFiles).not.toHaveBeenCalled() + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() }) it('surfaces an access-denied failure in the v2 error envelope', async () => { @@ -147,7 +158,7 @@ describe('GET /api/v2/files', () => { }) const res = await callList(`workspaceId=${WS}`) expect(res.status).toBe(403) - expect(mockListWorkspaceFiles).not.toHaveBeenCalled() + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() }) it('returns the rate-limit response when denied', async () => { @@ -158,9 +169,10 @@ describe('GET /api/v2/files', () => { }) it('returns the public file shape including folder and updatedAt', async () => { - mockListWorkspaceFiles.mockResolvedValue([ - buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' }), - ]) + mockQueryWorkspaceFiles.mockResolvedValue({ + files: [buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' })], + nextKeys: null, + }) const res = await callList(`workspaceId=${WS}`) const body = await res.json() @@ -181,22 +193,121 @@ describe('GET /api/v2/files', () => { updatedAt: '2024-01-02T00:00:00.000Z', }, ]) - expect(mockListWorkspaceFiles).toHaveBeenCalledWith(WS, { scope: 'active' }) + expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) }) it('defaults to the active scope and passes archived through', async () => { await callList(`workspaceId=${WS}`) - expect(mockListWorkspaceFiles).toHaveBeenCalledWith(WS, { scope: 'active' }) + expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) const archived = buildRecord({ id: 'wf_gone', name: 'gone.csv' }) - mockListWorkspaceFiles.mockResolvedValue([archived]) + mockQueryWorkspaceFiles.mockResolvedValue({ files: [archived], nextKeys: null }) const res = await callList(`workspaceId=${WS}&scope=archived`) const body = await res.json() - expect(mockListWorkspaceFiles).toHaveBeenLastCalledWith(WS, { scope: 'archived' }) + expect(mockQueryWorkspaceFiles).toHaveBeenLastCalledWith(WS, { + ...DEFAULT_LIST_ARGS, + scope: 'archived', + }) expect(body.data.map((f: { id: string }) => f.id)).toEqual(['wf_gone']) }) + + it('forwards search, folder, and sort into the query rather than filtering the result', async () => { + await callList( + `workspaceId=${WS}&search=report&folderId=${FOLDER_ID}&sortBy=name&sortOrder=desc` + ) + + expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, { + ...DEFAULT_LIST_ARGS, + folderId: FOLDER_ID, + search: 'report', + sortBy: 'name', + sortOrder: 'desc', + }) + }) + + it('400s on a sort field outside the enum instead of passing it toward the query', async () => { + const res = await callList(`workspaceId=${WS}&sortBy=name;DROP TABLE workspace_files`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=${WS}&search=`) + + expect(res.status).toBe(400) + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('emits a cursor stamped with the sort and resumes from its keys', async () => { + mockQueryWorkspaceFiles.mockResolvedValue({ + files: [buildRecord()], + nextKeys: ['data.csv', 'wf_1'], + }) + + const first = await callList(`workspaceId=${WS}&sortBy=name`) + const { nextCursor } = await first.json() + expect(nextCursor).not.toBeNull() + + await callList(`workspaceId=${WS}&sortBy=name&cursor=${encodeURIComponent(nextCursor)}`) + + expect(mockQueryWorkspaceFiles).toHaveBeenLastCalledWith(WS, { + ...DEFAULT_LIST_ARGS, + sortBy: 'name', + after: ['data.csv', 'wf_1'], + }) + }) + + it('400s when a cursor is replayed under a different sort', async () => { + mockQueryWorkspaceFiles.mockResolvedValue({ + files: [buildRecord()], + nextKeys: ['data.csv', 'wf_1'], + }) + + const first = await callList(`workspaceId=${WS}&sortBy=name`) + const { nextCursor } = await first.json() + mockQueryWorkspaceFiles.mockClear() + + const res = await callList( + `workspaceId=${WS}&sortBy=size&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toMatch(/cursor does not match/i) + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('400s on a malformed cursor instead of silently restarting from page one', async () => { + const res = await callList(`workspaceId=${WS}&cursor=not-a-cursor`) + + expect(res.status).toBe(400) + expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() + }) + + it('400s when the cursor carries values the sort cannot hold', async () => { + mockQueryWorkspaceFiles.mockRejectedValue( + new OrchestrationError('validation', 'cursor does not match the requested sortBy/sortOrder.') + ) + const cursor = Buffer.from( + JSON.stringify({ sort: 'uploadedAt:asc', keys: ['not-a-date', 'wf_1'] }) + ).toString('base64') + + const res = await callList(`workspaceId=${WS}&cursor=${encodeURIComponent(cursor)}`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('terminates pagination when the query reports no further keys', async () => { + mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null }) + + const res = await callList(`workspaceId=${WS}&search=data`) + + expect((await res.json()).nextCursor).toBeNull() + }) }) describe('POST /api/v2/files', () => { diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index fb0a0cb8bce..2c088b8b611 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -16,17 +16,19 @@ import { import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getWorkspaceFile, - listWorkspaceFiles, + queryWorkspaceFiles, uploadWorkspaceFile, } from '@/lib/uploads/contexts/workspace' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2File } from '@/app/api/v2/files/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { - decodeCursor, - encodeCursor, + cursorSortKey, + decodeSortedCursor, + encodeSortedCursor, v2CaughtOrchestrationError, v2CursorList, + v2CursorSortError, v2Data, v2Error, v2RateLimitError, @@ -42,28 +44,16 @@ export const revalidate = 0 const MAX_FILE_SIZE = 100 * 1024 * 1024 const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 -interface FileCursor { - uploadedAt: string - id: string -} - -/** Stable keyset ordering: `uploadedAt` ascending, `id` ascending as the tiebreaker. */ -function compareFiles(a: V2File, b: V2File): number { - if (a.uploadedAt !== b.uploadedAt) return a.uploadedAt < b.uploadedAt ? -1 : 1 - if (a.id !== b.id) return a.id < b.id ? -1 : 1 - return 0 -} - /** - * GET /api/v2/files — List files in a workspace with cursor pagination. + * GET /api/v2/files — List files in a workspace with search, sort, and cursor + * pagination. * * `scope=archived` reads Recently Deleted, which is what makes the restore * endpoints usable — a caller can find the id of something it deleted. * - * The shared {@link listWorkspaceFiles} manager returns the full set for the - * requested scope ordered by `uploadedAt`; v2 applies a bounded keyset slice - * over that result in the route. Pushing `limit`/`cursor` down into the manager - * query is a follow-up, and `scope` makes it more valuable, not less. + * Filtering, ordering, and the page slice all run inside + * {@link queryWorkspaceFiles}' query. The route only translates the validated + * params and the opaque cursor, so a `search` never costs a full-workspace read. */ export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -85,32 +75,35 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, scope, limit, cursor } = parsed.data.query + const { workspaceId, scope, folderId, search, sortBy, sortOrder, limit, cursor } = + parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const files = await listWorkspaceFiles(workspaceId, { scope }) - - const items: V2File[] = files.map(toV2File).sort(compareFiles) - - const decoded = cursor ? decodeCursor(cursor) : null - const afterCursor = decoded - ? items.filter( - (f) => - f.uploadedAt > decoded.uploadedAt || - (f.uploadedAt === decoded.uploadedAt && f.id > decoded.id) - ) - : items + const sort = cursorSortKey(sortBy, sortOrder) + const decoded = decodeSortedCursor(cursor, sort) + if (decoded.status === 'invalid') return v2CursorSortError() + + const { files, nextKeys } = await queryWorkspaceFiles(workspaceId, { + scope, + folderId, + search, + sortBy, + sortOrder, + limit, + after: decoded.status === 'ok' ? decoded.keys : undefined, + }) - const hasMore = afterCursor.length > limit - const page = afterCursor.slice(0, limit) - const last = page.at(-1) - const nextCursor = - hasMore && last ? encodeCursor({ uploadedAt: last.uploadedAt, id: last.id }) : null + const items: V2File[] = files.map(toV2File) + const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null - return v2CursorList(page, nextCursor, { rateLimit }) + return v2CursorList(items, nextCursor, { rateLimit }) } catch (error) { + // A cursor that doesn't fit the requested sort arrives classified as `validation` → 400. + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Error listing files', { error: getErrorMessage(error, 'Unknown error') }) return v2Error('INTERNAL_ERROR', 'Internal server error') } diff --git a/apps/sim/app/api/v2/folders/route.test.ts b/apps/sim/app/api/v2/folders/route.test.ts index 85399484b5b..4f2622ee903 100644 --- a/apps/sim/app/api/v2/folders/route.test.ts +++ b/apps/sim/app/api/v2/folders/route.test.ts @@ -95,6 +95,13 @@ function buildRow(overrides: Record = {}) { } } +/** What the route forwards for a bare `?workspaceId=` list. */ +const DEFAULT_LIST_ARGS = { + search: undefined, + sortBy: 'position', + sortOrder: 'asc', +} + const callList = (query: string) => GET(new NextRequest(`http://localhost:3000/api/v2/folders?${query}`)) @@ -189,12 +196,49 @@ describe('GET /api/v2/folders', () => { deletedAt: null, }, ]) - expect(mockListFoldersForWorkspace).toHaveBeenCalledWith('workspace-1', 'active', 'workflow') + expect(mockListFoldersForWorkspace).toHaveBeenCalledWith( + 'workspace-1', + 'active', + 'workflow', + DEFAULT_LIST_ARGS + ) }) it('passes the archived scope through', async () => { await callList('workspaceId=workspace-1&resourceType=table&scope=archived') - expect(mockListFoldersForWorkspace).toHaveBeenCalledWith('workspace-1', 'archived', 'table') + expect(mockListFoldersForWorkspace).toHaveBeenCalledWith( + 'workspace-1', + 'archived', + 'table', + DEFAULT_LIST_ARGS + ) + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=workspace-1&resourceType=workflow&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=workspace-1&resourceType=workflow&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=workspace-1&resourceType=workflow&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList( + `workspaceId=workspace-1&resourceType=workflow&search=report&sortBy=name&sortOrder=asc` + ) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() }) }) diff --git a/apps/sim/app/api/v2/folders/route.ts b/apps/sim/app/api/v2/folders/route.ts index 2ce758499b4..ed74572a617 100644 --- a/apps/sim/app/api/v2/folders/route.ts +++ b/apps/sim/app/api/v2/folders/route.ts @@ -47,12 +47,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, resourceType, scope } = parsed.data.query + const { workspaceId, resourceType, scope, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const folders = await listFoldersForWorkspace(workspaceId, scope, resourceType) + const folders = await listFoldersForWorkspace(workspaceId, scope, resourceType, { + search, + sortBy, + sortOrder, + }) // One workspace's tree for one resource type is bounded → a single full page. return v2CursorList(folders.map(toV2FolderFromApi), null, { rateLimit }) diff --git a/apps/sim/app/api/v2/knowledge/route.test.ts b/apps/sim/app/api/v2/knowledge/route.test.ts new file mode 100644 index 00000000000..2b6d8f635a6 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/route.test.ts @@ -0,0 +1,135 @@ +/** + * @vitest-environment node + * + * Public v2 knowledge-base list: the search/filter/sort convention reaching the + * lib rather than being applied over its result. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockGetKnowledgeBases } = vi.hoisted( + () => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetKnowledgeBases: vi.fn(), + }) +) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/knowledge/service', () => ({ + getKnowledgeBases: mockGetKnowledgeBases, +})) + +vi.mock('@/lib/knowledge/orchestration', () => ({ + performCreateKnowledgeBase: vi.fn(), +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/knowledge/route' + +const WS = 'workspace-1' +const FOLDER_ID = 'fold_1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +/** What the route forwards for a bare `?workspaceId=` list. */ +const DEFAULT_LIST_ARGS = { + folderId: undefined, + search: undefined, + sortBy: 'createdAt', + sortOrder: 'asc', +} + +function buildKnowledgeBase(overrides: Record = {}) { + return { + id: 'kb_1', + userId: 'user-1', + name: 'Support docs', + description: null, + tokenCount: 0, + embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, + chunkingConfig: { maxSize: 1024, minSize: 1, overlap: 200 }, + workspaceId: WS, + folderId: null, + docCount: 2, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + deletedAt: null, + ...overrides, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/knowledge?${query}`)) + +describe('GET /api/v2/knowledge', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetKnowledgeBases.mockResolvedValue([buildKnowledgeBase()]) + }) + + it('forwards search, folder, and sort into the query rather than filtering the result', async () => { + const res = await callList( + `workspaceId=${WS}&search=support&folderId=${FOLDER_ID}&sortBy=name&sortOrder=desc` + ) + + expect(res.status).toBe(200) + expect(mockGetKnowledgeBases).toHaveBeenCalledWith('user-1', WS, 'active', { + folderId: FOLDER_ID, + search: 'support', + sortBy: 'name', + sortOrder: 'desc', + }) + }) + + it('defaults to the createdAt ordering when no sort is requested', async () => { + await callList(`workspaceId=${WS}`) + + expect(mockGetKnowledgeBases).toHaveBeenCalledWith('user-1', WS, 'active', DEFAULT_LIST_ARGS) + }) + + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=${WS}&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockGetKnowledgeBases).not.toHaveBeenCalled() + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=${WS}&sortOrder=sideways`) + + expect(res.status).toBe(400) + expect(mockGetKnowledgeBases).not.toHaveBeenCalled() + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=${WS}&search=`) + + expect(res.status).toBe(400) + expect(mockGetKnowledgeBases).not.toHaveBeenCalled() + }) + + it('terminates pagination with a filter applied', async () => { + const res = await callList(`workspaceId=${WS}&search=support`) + + expect((await res.json()).nextCursor).toBeNull() + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index 64e6445687b..01811a343f5 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -51,12 +51,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId, folderId, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const knowledgeBases = await getKnowledgeBases(userId, workspaceId) + const knowledgeBases = await getKnowledgeBases(userId, workspaceId, 'active', { + folderId, + search, + sortBy, + sortOrder, + }) const items = knowledgeBases.map(formatKnowledgeBase) // `getKnowledgeBases` returns the full bounded workspace set → single page. diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 3bdc2b90b91..f226e77a345 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server' import type { ZodError } from 'zod' +import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' import { asOrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' import type { RateLimitResult, WorkspaceAccessError } from '@/app/api/v1/middleware' @@ -157,6 +158,60 @@ export function decodeCursor>(cursor: string): T | n } } +/** + * The sort a keyset cursor was minted under, as it is written into the cursor + * payload. Comparing the whole string is what makes a mid-pagination sort + * change detectable. + */ +export function cursorSortKey(sortBy: string, sortOrder: string): string { + return `${sortBy}:${sortOrder}` +} + +interface SortedCursorPayload { + sort: string + keys: CursorKey[] +} + +/** + * 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. + */ +export function encodeSortedCursor(sort: string, keys: CursorKey[]): string { + return encodeCursor({ sort, keys } satisfies SortedCursorPayload) +} + +export type DecodedSortedCursor = + | { status: 'absent' } + | { status: 'ok'; keys: CursorKey[] } + /** Malformed, or minted under a different sort — the page cannot be resumed. */ + | { status: 'invalid' } + +/** + * 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. + * + * 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 { + if (!cursor) return { status: 'absent' } + const decoded = decodeCursor>(cursor) + if (!decoded || decoded.sort !== sort || !Array.isArray(decoded.keys)) { + return { status: 'invalid' } + } + return { status: 'ok', keys: decoded.keys } +} + +/** The 400 for a cursor that cannot be resumed under the request's sort. */ +export function v2CursorSortError(): NextResponse { + return v2Error('BAD_REQUEST', INVALID_CURSOR_MESSAGE) +} + const V2_CODE_BY_ORCHESTRATION_ERROR: Record = { validation: 'BAD_REQUEST', unauthorized: 'UNAUTHORIZED', 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 f9893f60c8e..cb0df3ac683 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.test.ts @@ -100,6 +100,13 @@ function callCreate(body: unknown) { ) } +/** What the route forwards for a bare `?workspaceId=` list. */ +const DEFAULT_LIST_ARGS = { + search: undefined, + sortBy: 'createdAt', + sortOrder: 'desc', +} + const VALID_BODY = { workspaceId: 'workspace-1', name: 'Docs server', @@ -187,7 +194,10 @@ describe('GET /api/v2/mcp-servers', () => { hasOauthClientSecret: false, }, ]) - expect(mockListWorkspaceMcpServers).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) + expect(mockListWorkspaceMcpServers).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + ...DEFAULT_LIST_ARGS, + }) }) it('never returns configured header values', async () => { @@ -197,6 +207,31 @@ describe('GET /api/v2/mcp-servers', () => { expect(raw).not.toContain('super-secret-token') expect(raw).not.toContain('"headers":') }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=workspace-1&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() + }) }) describe('POST /api/v2/mcp-servers', () => { diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts index 73a18037501..e9a32f7251f 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.ts @@ -55,12 +55,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const rows = await listWorkspaceMcpServers({ workspaceId }) + const rows = await listWorkspaceMcpServers({ workspaceId, search, sortBy, sortOrder }) // The per-workspace server set is small and bounded → a single full page. return v2CursorList(rows.map(toV2McpServer), null, { rateLimit }) diff --git a/apps/sim/app/api/v2/skills/route.test.ts b/apps/sim/app/api/v2/skills/route.test.ts index 6cf0ae6f52f..8e1c5131c2e 100644 --- a/apps/sim/app/api/v2/skills/route.test.ts +++ b/apps/sim/app/api/v2/skills/route.test.ts @@ -147,7 +147,36 @@ describe('GET /api/v2/skills', () => { updatedAt: '2024-01-02T00:00:00.000Z', }, ]) - expect(mockListSkills).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) + expect(mockListSkills).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + search: undefined, + sort: { sortBy: 'createdAt', sortOrder: 'desc' }, + }) + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=workspace-1&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() }) }) diff --git a/apps/sim/app/api/v2/skills/route.ts b/apps/sim/app/api/v2/skills/route.ts index 5e1d6a825b2..1541ca2be7f 100644 --- a/apps/sim/app/api/v2/skills/route.ts +++ b/apps/sim/app/api/v2/skills/route.ts @@ -47,12 +47,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const skills = await listSkills({ workspaceId }) + const skills = await listSkills({ workspaceId, search, sort: { sortBy, sortOrder } }) // The per-workspace skill set is small and bounded → a single full page. return v2CursorList(skills.map(toV2SkillSummary), null, { rateLimit }) diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts index af7a12f403a..f13c00e3027 100644 --- a/apps/sim/app/api/v2/tables/route.test.ts +++ b/apps/sim/app/api/v2/tables/route.test.ts @@ -136,4 +136,29 @@ describe('GET /api/v2/tables', () => { expect(res.status).toBe(429) expect((await res.json()).error.code).toBe('RATE_LIMITED') }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) + + expect(res.status).toBe(400) + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=workspace-1&search=`) + + expect(res.status).toBe(400) + }) + + it('forwards search and sort into the query and still terminates pagination', async () => { + const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`) + + expect(res.status).toBe(200) + expect((await res.json()).nextCursor).toBeNull() + }) }) diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index 85df923214c..1fcafeff9fd 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -49,12 +49,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId, folderId, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const tables = await listTables(workspaceId) + const tables = await listTables(workspaceId, { folderId, search, sortBy, sortOrder }) const items = tables.map(toApiTable) // `listTables` returns the full bounded workspace set → single page. diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts new file mode 100644 index 00000000000..5c2b922e40b --- /dev/null +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -0,0 +1,212 @@ +/** + * @vitest-environment node + * + * Public v2 workflow list: the search/sort/filter convention, and the keyset + * cursor's binding to the sort it was minted under. The assertions look at the + * WHERE/ORDER BY the route hands drizzle, because that is the whole point of + * the change — a search must narrow the query, not the result. + */ +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess } = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/workflows/route' + +const WS = 'workspace-1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +function buildRow(overrides: Record = {}) { + return { + id: 'wf_1', + name: 'Daily digest', + description: null, + folderId: null, + workspaceId: WS, + isDeployed: false, + deployedAt: null, + runCount: 3, + lastRunAt: null, + sortOrder: 0, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/workflows?${query}`)) + +/** The condition nodes the route passed to `.where()` on the last query. */ +const lastConditions = () => + flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).filter(Boolean) + +const lastOrderBy = () => dbChainMockFns.orderBy.mock.calls.at(-1) ?? [] + +/** + * Timestamp keys order on `date_trunc('milliseconds', col)` rather than the raw + * column, so the mocked `sql` fragment carries the column in its interpolated + * values rather than being the column itself. + */ +const truncatedColumnOf = (entry: { column: { values?: unknown[] } }) => entry.column?.values?.[0] + +describe('GET /api/v2/workflows', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + }) + + it('narrows the query with a case-insensitive substring match on the name', async () => { + queueTableRows(schemaMock.workflow, [buildRow()]) + + const res = await callList(`workspaceId=${WS}&search=digest`) + + expect(res.status).toBe(200) + const search = lastConditions().find((c) => c.type === 'ilike') + expect(search).toMatchObject({ column: schemaMock.workflow.name, pattern: '%digest%' }) + }) + + it('escapes LIKE wildcards so a caller cannot widen its own match', async () => { + queueTableRows(schemaMock.workflow, []) + + await callList(`workspaceId=${WS}&search=${encodeURIComponent('100%_x')}`) + + expect(lastConditions().find((c) => c.type === 'ilike')).toMatchObject({ + pattern: '%100\\%\\_x%', + }) + }) + + it('adds no search condition when the caller did not search', async () => { + queueTableRows(schemaMock.workflow, [buildRow()]) + + await callList(`workspaceId=${WS}`) + + expect(lastConditions().some((c) => c.type === 'ilike')).toBe(false) + }) + + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { + const res = await callList(`workspaceId=${WS}&sortBy=(select 1)`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('400s on a sort direction outside the enum', async () => { + const res = await callList(`workspaceId=${WS}&sortOrder=sideways`) + + expect(res.status).toBe(400) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('400s on an empty search rather than treating it as unsearched', async () => { + const res = await callList(`workspaceId=${WS}&search=`) + + expect(res.status).toBe(400) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('defaults to the workspace position ordering', async () => { + queueTableRows(schemaMock.workflow, [buildRow()]) + + await callList(`workspaceId=${WS}`) + + const orderBy = lastOrderBy() + expect(orderBy.map((e: { type: string }) => e.type)).toEqual(['asc', 'asc', 'asc']) + expect(orderBy[0].column).toBe(schemaMock.workflow.sortOrder) + expect(truncatedColumnOf(orderBy[1])).toBe(schemaMock.workflow.createdAt) + expect(orderBy[2].column).toBe(schemaMock.workflow.id) + }) + + it('orders by the requested field and direction', async () => { + queueTableRows(schemaMock.workflow, [buildRow()]) + + await callList(`workspaceId=${WS}&sortBy=name&sortOrder=desc`) + + expect(lastOrderBy()).toEqual([ + { type: 'desc', column: schemaMock.workflow.name }, + { type: 'desc', column: schemaMock.workflow.id }, + ]) + }) + + it('combines a filter with a cursor into one consistent page', async () => { + queueTableRows(schemaMock.workflow, [buildRow(), buildRow({ id: 'wf_2', name: 'Zebra' })]) + + const first = await callList(`workspaceId=${WS}&search=a&sortBy=name&limit=1`) + const body = await first.json() + + expect(body.data).toHaveLength(1) + expect(body.nextCursor).not.toBeNull() + + queueTableRows(schemaMock.workflow, [buildRow({ id: 'wf_2', name: 'Zebra' })]) + const second = await callList( + `workspaceId=${WS}&search=a&sortBy=name&limit=1&cursor=${encodeURIComponent(body.nextCursor)}` + ) + + expect(second.status).toBe(200) + const conditions = lastConditions() + // The filter survives the cursor page, and the keyset resumes from the last row. + expect(conditions.find((c) => c.type === 'ilike')).toMatchObject({ pattern: '%a%' }) + expect(conditions.some((c) => c.type === 'or')).toBe(true) + }) + + it('terminates pagination once a filtered page is not full', async () => { + queueTableRows(schemaMock.workflow, [buildRow()]) + + const res = await callList(`workspaceId=${WS}&search=digest&limit=50`) + + expect((await res.json()).nextCursor).toBeNull() + }) + + it('400s when a cursor is replayed under a different sort', async () => { + queueTableRows(schemaMock.workflow, [buildRow(), buildRow({ id: 'wf_2' })]) + + const first = await callList(`workspaceId=${WS}&sortBy=name&limit=1`) + const { nextCursor } = await first.json() + vi.clearAllMocks() + + const res = await callList( + `workspaceId=${WS}&sortBy=createdAt&limit=1&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toMatch(/cursor does not match/i) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) + + it('400s on a malformed cursor instead of silently restarting from page one', async () => { + const res = await callList(`workspaceId=${WS}&cursor=not-a-cursor`) + + expect(res.status).toBe(400) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index ffe19c9ebf1..0706f835c53 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -3,17 +3,34 @@ import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, asc, eq, gt, isNull, or } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { type V2WorkflowListItem, v2ListWorkflowsContract } from '@/lib/api/contracts/v2/workflows' +import { + type V2WorkflowListItem, + type V2WorkflowSortBy, + v2ListWorkflowsContract, +} from '@/lib/api/contracts/v2/workflows' +import { + encodeKeyset, + type KeysetKey, + keysetAfter, + keysetColumns, + listOrderBy, + numberKey, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { - decodeCursor, - encodeCursor, + cursorSortKey, + decodeSortedCursor, + encodeSortedCursor, v2CursorList, + v2CursorSortError, v2Error, v2RateLimitError, v2ValidationError, @@ -25,13 +42,40 @@ const logger = createLogger('V2WorkflowsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -/** Keyset cursor for the `(sortOrder, createdAt, id)` ordering. */ -interface WorkflowListCursor { - sortOrder: number - createdAt: string +type WorkflowRow = { id: string + name: string + sortOrder: number + runCount: number + createdAt: Date + updatedAt: Date } +/** + * The keysets behind the sortable workflow fields. `satisfies` makes the map + * total over the contract enum, so a new sortable field cannot ship without an + * ordering. Every key column is `NOT NULL` and each keyset ends in `id`, which + * is what keeps a page boundary inside a run of equal values stable. + * + * `position` keeps its historical three-part ordering: workflows share a + * `sortOrder` freely, and dropping `createdAt` from the tiebreak would reshuffle + * every workspace's default list. + */ +const workflowId = textKey(workflow.id, (row) => row.id) +const workflowCreatedAt = timestampKey(workflow.createdAt, (row) => row.createdAt) + +const WORKFLOW_SORTS = { + position: [ + numberKey(workflow.sortOrder, (row) => row.sortOrder), + workflowCreatedAt, + workflowId, + ], + name: [textKey(workflow.name, (row) => row.name), workflowId], + createdAt: [workflowCreatedAt, workflowId], + updatedAt: [timestampKey(workflow.updatedAt, (row) => row.updatedAt), workflowId], + runCount: [numberKey(workflow.runCount, (row) => row.runCount), workflowId], +} satisfies Record[]> + export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -59,36 +103,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const conditions = [eq(workflow.workspaceId, params.workspaceId), isNull(workflow.archivedAt)] - - if (params.folderId) { - conditions.push(eq(workflow.folderId, params.folderId)) - } - - if (params.deployedOnly) { - conditions.push(eq(workflow.isDeployed, true)) - } - - if (params.cursor) { - const cursorData = decodeCursor(params.cursor) - if (cursorData) { - const cursorCondition = or( - gt(workflow.sortOrder, cursorData.sortOrder), - and( - eq(workflow.sortOrder, cursorData.sortOrder), - gt(workflow.createdAt, new Date(cursorData.createdAt)) - ), - and( - eq(workflow.sortOrder, cursorData.sortOrder), - eq(workflow.createdAt, new Date(cursorData.createdAt)), - gt(workflow.id, cursorData.id) - ) - ) - if (cursorCondition) { - conditions.push(cursorCondition) - } - } - } + const sortKey = cursorSortKey(params.sortBy, params.sortOrder) + const keys: readonly KeysetKey[] = WORKFLOW_SORTS[params.sortBy] + const decoded = decodeSortedCursor(params.cursor, sortKey) + if (decoded.status === 'invalid') return v2CursorSortError() + + // `null` here is a cursor whose values don't fit this sort — a client error, not an empty page. + const resumeAfter = + decoded.status === 'ok' ? keysetAfter(keys, decoded.keys, params.sortOrder) : undefined + if (resumeAfter === null) return v2CursorSortError() + + const conditions = [ + eq(workflow.workspaceId, params.workspaceId), + isNull(workflow.archivedAt), + params.folderId ? eq(workflow.folderId, params.folderId) : undefined, + params.deployedOnly ? eq(workflow.isDeployed, true) : undefined, + searchFilter(workflow.name, params.search), + resumeAfter, + ] const rows = await db .select({ @@ -107,21 +139,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) .from(workflow) .where(and(...conditions)) - .orderBy(asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)) + .orderBy(...listOrderBy(keysetColumns(keys), params.sortOrder)) .limit(params.limit + 1) const hasMore = rows.length > params.limit const data = rows.slice(0, params.limit) - let nextCursor: string | null = null - if (hasMore && data.length > 0) { - const last = data[data.length - 1] - nextCursor = encodeCursor({ - sortOrder: last.sortOrder, - createdAt: last.createdAt.toISOString(), - id: last.id, - }) - } + const last = data.at(-1) + const nextCursor = + hasMore && last ? encodeSortedCursor(sortKey, encodeKeyset(keys, last)) : null const formatted: V2WorkflowListItem[] = data.map((w) => ({ id: w.id, diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 205dd794bb7..7c411ec532d 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -6,7 +6,12 @@ import { } from '@/lib/api/contracts/credentials' import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' import { getServiceAccountRequiredFields } from '@/lib/credentials/service-account-fields' /** @@ -78,9 +83,16 @@ export const v2CredentialWorkspaceQuerySchema = z.object({ }) export type V2CredentialWorkspaceQuery = z.output +/** A credential's natural name field is `displayName`, so that is what `search` matches. */ +export const v2CredentialSortFields = ['displayName', 'createdAt', 'updatedAt'] as const + +export type V2CredentialSortBy = (typeof v2CredentialSortFields)[number] + export const v2ListCredentialsQuerySchema = v2CredentialWorkspaceQuerySchema.extend({ type: workspaceCredentialTypeSchema.optional(), providerId: z.string().min(1, 'providerId cannot be empty').optional(), + search: v2SearchSchema, + ...v2SortFields(v2CredentialSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), }) export type V2ListCredentialsQuery = z.output diff --git a/apps/sim/lib/api/contracts/v2/custom-tools.ts b/apps/sim/lib/api/contracts/v2/custom-tools.ts index 7082c6d1c82..c2e6221e776 100644 --- a/apps/sim/lib/api/contracts/v2/custom-tools.ts +++ b/apps/sim/lib/api/contracts/v2/custom-tools.ts @@ -2,7 +2,12 @@ import { z } from 'zod' import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { customToolSchemaSchema } from '@/lib/api/contracts/tools/custom' import { defineRouteContract } from '@/lib/api/contracts/types' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' /** * v2 custom tool contracts. @@ -59,6 +64,18 @@ export const v2CustomToolWorkspaceQuerySchema = z.object({ }) export type V2CustomToolWorkspaceQuery = z.output +/** A custom tool's natural name field is `title`, so that is what `search` matches. */ +export const v2CustomToolSortFields = ['title', 'createdAt', 'updatedAt'] as const + +export type V2CustomToolSortBy = (typeof v2CustomToolSortFields)[number] + +export const v2ListCustomToolsQuerySchema = v2CustomToolWorkspaceQuerySchema.extend({ + search: v2SearchSchema, + ...v2SortFields(v2CustomToolSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), +}) + +export type V2ListCustomToolsQuery = z.output + export const v2CreateCustomToolBodySchema = z .object({ workspaceId: workspaceIdSchema, @@ -97,7 +114,7 @@ export type V2UpdateCustomToolBody = z.input, id)`, so the cursor is stamped with the sort it was + * minted under and rejected if the request's sort has since changed. Filtering, + * ordering, and the page slice all happen in the query. */ export const v2ListFilesQuerySchema = z.object({ workspaceId: workspaceIdSchema, scope: v2FileScopeSchema.default('active'), + /** Restrict to one file folder. Omit to list the whole workspace. */ + folderId: z.string().min(1, 'folderId cannot be empty').optional(), + search: v2SearchSchema, + ...v2SortFields(v2FileSortFields, { sortBy: 'uploadedAt', sortOrder: 'asc' }), limit: z.coerce .number() .optional() diff --git a/apps/sim/lib/api/contracts/v2/folders.ts b/apps/sim/lib/api/contracts/v2/folders.ts index 6f12fe25637..e59abdbdab1 100644 --- a/apps/sim/lib/api/contracts/v2/folders.ts +++ b/apps/sim/lib/api/contracts/v2/folders.ts @@ -7,7 +7,12 @@ import { } from '@/lib/api/contracts/folders' import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' /** * v2 folder contracts. @@ -73,9 +78,25 @@ export const v2FolderScopedQuerySchema = z.object({ }) export type V2FolderScopedQuery = z.output +/** + * Sortable folder fields. `position` is the tree's manual arrangement (the + * `sort_order` column), kept as the default so a bare list still comes back in + * the order the workspace arranged it. + */ +export const v2FolderSortFields = ['position', 'name', 'createdAt', 'updatedAt'] as const + +export type V2FolderSortBy = (typeof v2FolderSortFields)[number] + +/** + * List query. `search` narrows to folders whose name matches; the result stays + * a flat list either way, so a matching folder is returned without its + * ancestors — reconstruct a tree from `parentId` only on an unsearched list. + */ export const v2ListFoldersQuerySchema = v2FolderScopedQuerySchema.extend({ /** `active` (default) lists live folders; `archived` lists Recently Deleted. */ scope: folderScopeSchema.default('active'), + search: v2SearchSchema, + ...v2SortFields(v2FolderSortFields, { sortBy: 'position', sortOrder: 'asc' }), }) export type V2ListFoldersQuery = z.output diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 06f4064d2fb..d92f30c20a1 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -16,7 +16,12 @@ import { v1ListKnowledgeDocumentsQuerySchema, v1UpdateKnowledgeBaseBodySchema, } from '@/lib/api/contracts/v1/knowledge' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' /** * v2 knowledge contracts. @@ -146,16 +151,35 @@ export type V2KnowledgeSearchData = z.output export const v2UploadKnowledgeDocumentQuerySchema = z.object({ workspaceId: workspaceIdSchema }) export type V2UploadKnowledgeDocumentQuery = z.output +export const v2KnowledgeBaseSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export type V2KnowledgeBaseSortBy = (typeof v2KnowledgeBaseSortFields)[number] + +/** + * KB list query: v1's workspace scope plus the v2 search/sort convention and a + * folder filter. v1's own list query stays untouched — it does not implement + * these, and advertising a param a route ignores is worse than not having it. + */ +export const v2ListKnowledgeBasesQuerySchema = v1ListKnowledgeBasesQuerySchema.extend({ + /** Restrict to one knowledge-base folder. */ + folderId: z.string().min(1, 'folderId cannot be empty').optional(), + search: v2SearchSchema, + ...v2SortFields(v2KnowledgeBaseSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), +}) + +export type V2ListKnowledgeBasesQuery = z.output + /** * KB list. `getKnowledgeBases` returns the full workspace set (a small, bounded * per-workspace list), so today the cursor list is a single full page * (`nextCursor` always `null`). The canonical cursor envelope keeps the v2 list * surface uniform; real pagination can be added later behind the opaque cursor. + * Search, folder filter, and sort all run in that query, not over its result. */ export const v2ListKnowledgeBasesContract = defineRouteContract({ method: 'GET', path: '/api/v2/knowledge', - query: v1ListKnowledgeBasesQuerySchema, + query: v2ListKnowledgeBasesQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2KnowledgeBaseSchema), diff --git a/apps/sim/lib/api/contracts/v2/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts index 54324148896..96b40e38b13 100644 --- a/apps/sim/lib/api/contracts/v2/mcp-servers.ts +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -2,7 +2,12 @@ import { z } from 'zod' import { mcpAuthTypeSchema, mcpServerSchema, mcpTransportSchema } from '@/lib/api/contracts/mcp' import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' import { createEnvVarPattern } from '@/executor/utils/reference-validation' /** @@ -115,6 +120,17 @@ export const v2McpServerWorkspaceQuerySchema = z.object({ }) export type V2McpServerWorkspaceQuery = z.output +export const v2McpServerSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export type V2McpServerSortBy = (typeof v2McpServerSortFields)[number] + +export const v2ListMcpServersQuerySchema = v2McpServerWorkspaceQuerySchema.extend({ + search: v2SearchSchema, + ...v2SortFields(v2McpServerSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), +}) + +export type V2ListMcpServersQuery = z.output + export const v2CreateMcpServerBodySchema = z .object({ workspaceId: workspaceIdSchema, @@ -166,7 +182,7 @@ export type V2UpdateMcpServerBody = z.input export const v2ListMcpServersContract = defineRouteContract({ method: 'GET', path: '/api/v2/mcp-servers', - query: v2McpServerWorkspaceQuerySchema, + query: v2ListMcpServersQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2McpServerSchema), diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index d0579054727..02a10f692d9 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -17,6 +17,43 @@ import { z } from 'zod' * Rate-limit state is carried in `X-RateLimit-*` response headers (not the * body). Usage limits are available from the dedicated usage endpoint rather * than being inlined into every response. + * + * ## Search, filtering, and sorting + * + * One convention, applied by every v2 list. It is deliberately the narrow + * scalar-param form the app's own list endpoints already speak — not a third + * dialect alongside the Logs filter set and the Tables predicate grammar. + * A list that needs a real expression tree (Tables) keeps its own `POST /query`. + * + * - **`search`** ({@link v2SearchSchema}) — a case-insensitive substring match + * against the resource's *single* natural name field, and nothing else: + * `name` for files/folders/workflows/tables/knowledge bases/MCP servers/ + * skills, `title` for custom tools, `displayName` for credentials. It never + * matches ids, descriptions, or content. `%` and `_` in the term are matched + * literally, not as wildcards. Empty is rejected rather than silently + * ignored — omit the param instead. + * - **`sortBy` + `sortOrder`** ({@link v2SortFields}) — `sortBy` is a + * per-resource enum, never a free string, because the value selects a column + * in the query. `sortOrder` is `asc`/`desc`. Both always have a default, so + * an omitted sort is a defined order rather than whatever the planner + * returns. `position` names a resource's stored manual arrangement (the + * `sortOrder` *column* on workflows and folders) — it is spelled differently + * from the `sortOrder` *param* on purpose. + * - **Filters** — resource-specific and enumerated, reusing the names already + * on the surface (`scope`, `folderId`, `deployedOnly`, `type`, `providerId`, + * `resourceType`). No generic filter expression. + * + * Every one of these is pushed into SQL. No v2 list fetches a full result set + * to filter or sort it in memory. + * + * ## Sort and the opaque cursor + * + * On the lists that paginate ({@link v2CursorListResponse} with a non-null + * `nextCursor` — files and workflows), the cursor 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. */ /** Canonical v2 error envelope. */ @@ -37,3 +74,35 @@ export const v2CursorListResponse = (itemSchema: T) => data: z.array(itemSchema), nextCursor: z.string().nullable(), }) + +/** + * The v2 `search` term: a case-insensitive substring match on the resource's + * natural name field. Bounded at 200 characters — a longer term cannot match + * any of the name columns it is aimed at, and every one of these matches is an + * unindexed scan. + */ +export const v2SearchSchema = z + .string() + .trim() + .min(1, 'search cannot be empty') + .max(200, 'search is too long') + .optional() + +export const v2SortOrderSchema = z.enum(['asc', 'desc']) + +export type V2SortOrder = z.output + +/** + * The `sortBy` + `sortOrder` pair for one resource. `fields` is the closed set + * of sortable fields — the value reaches the query as a column, so it can never + * be a free string — and both params always resolve to the given defaults. + */ +export function v2SortFields( + fields: F, + defaults: { sortBy: F[number]; sortOrder: V2SortOrder } +) { + return { + sortBy: z.enum(fields).default(defaults.sortBy), + sortOrder: v2SortOrderSchema.default(defaults.sortOrder), + } +} diff --git a/apps/sim/lib/api/contracts/v2/skills.ts b/apps/sim/lib/api/contracts/v2/skills.ts index 3bc7174ea81..003151aef9f 100644 --- a/apps/sim/lib/api/contracts/v2/skills.ts +++ b/apps/sim/lib/api/contracts/v2/skills.ts @@ -6,7 +6,12 @@ import { skillNameSchema, } from '@/lib/api/contracts/skills' import { defineRouteContract } from '@/lib/api/contracts/types' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' /** * v2 skills contracts. @@ -64,6 +69,17 @@ export const v2SkillWorkspaceQuerySchema = z.object({ }) export type V2SkillWorkspaceQuery = z.output +export const v2SkillSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export type V2SkillSortBy = (typeof v2SkillSortFields)[number] + +export const v2ListSkillsQuerySchema = v2SkillWorkspaceQuerySchema.extend({ + search: v2SearchSchema, + ...v2SortFields(v2SkillSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), +}) + +export type V2ListSkillsQuery = z.output + export const v2CreateSkillBodySchema = z .object({ workspaceId: workspaceIdSchema, @@ -105,7 +121,7 @@ export type V2UpdateSkillBody = z.input export const v2ListSkillsContract = defineRouteContract({ method: 'GET', path: '/api/v2/skills', - query: v2SkillWorkspaceQuerySchema, + query: v2ListSkillsQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2SkillSummarySchema), diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 4c6f886ffe7..fc9d4046979 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -20,7 +20,12 @@ import { v1CreateTableRowsBodySchema, v1ListTablesQuerySchema, } from '@/lib/api/contracts/v1/tables' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' import { TABLE_LIMITS } from '@/lib/table/constants' /** @@ -141,17 +146,37 @@ export const v2UpsertRowDataSchema = z.object({ }) export type V2UpsertRowData = z.output +export const v2TableSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export type V2TableSortBy = (typeof v2TableSortFields)[number] + +/** + * Table list query: the workspace scope every table route shares, plus the v2 + * search/sort convention and a folder filter. Kept separate from + * `v1ListTablesQuerySchema` — the single-table read/delete routes reuse that + * schema and have no list params. + */ +export const v2ListTablesQuerySchema = v1ListTablesQuerySchema.extend({ + /** Restrict to one table folder. */ + folderId: z.string().min(1, 'folderId cannot be empty').optional(), + search: v2SearchSchema, + ...v2SortFields(v2TableSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), +}) + +export type V2ListTablesQuery = z.output + /** * Table list. `listTables` returns every table in the workspace (a small, * bounded per-workspace set), so today the cursor list is a single full page * (`nextCursor` is always `null`). Using the canonical cursor envelope keeps the * whole v2 list surface uniform, and real pagination can be added later behind - * the opaque cursor without an interface change. + * the opaque cursor without an interface change. Search, folder filter, and + * sort all run in that query, not over its result. */ export const v2ListTablesContract = defineRouteContract({ method: 'GET', path: '/api/v2/tables', - query: v1ListTablesQuerySchema, + query: v2ListTablesQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2ApiTableSchema), diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index b721f347b7c..76e944480b9 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -8,7 +8,12 @@ import { v1RollbackWorkflowDataSchema, v1WorkflowExportPayloadSchema, } from '@/lib/api/contracts/v1/workflows' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' import { cancelWorkflowExecutionReasonSchema, workflowExecutionParamsSchema, @@ -18,13 +23,41 @@ import { } from '@/lib/api/contracts/workflows' /** - * v2 workflows contracts. Request shapes are reused verbatim from v1 (the list - * query and `[id]` param are unchanged); only the response envelope is upgraded - * to the canonical v2 shapes with concrete item/detail schemas. The - * deploy/rollback/undeploy data payloads reuse the already-concrete v1 schemas, - * re-wrapped in `v2DataResponse` (the v1 `limits` body field is dropped — v2 - * carries rate-limit state in headers and usage on a dedicated endpoint). + * v2 workflows contracts. Request shapes are reused from v1 (the `[id]` param + * is unchanged, and the list query extends v1's with the v2 search/sort + * convention); only the response envelope is upgraded to the canonical v2 + * shapes with concrete item/detail schemas. The deploy/rollback/undeploy data + * payloads reuse the already-concrete v1 schemas, re-wrapped in + * `v2DataResponse` (the v1 `limits` body field is dropped — v2 carries + * rate-limit state in headers and usage on a dedicated endpoint). + */ + +/** + * Sortable workflow fields. `position` is the workspace's manual arrangement + * (the `sort_order` column the sidebar writes), kept as the default so a bare + * list still returns workflows in the order the workspace put them in. + */ +export const v2WorkflowSortFields = [ + 'position', + 'name', + 'createdAt', + 'updatedAt', + 'runCount', +] as const + +export type V2WorkflowSortBy = (typeof v2WorkflowSortFields)[number] + +/** + * List query: v1's workspace/folder/deployment filters plus the v2 search and + * sort convention. The keyset behind the cursor follows `sortBy`, so the cursor + * carries the sort it was minted under and is rejected once that changes. */ +export const v2ListWorkflowsQuerySchema = v1ListWorkflowsQuerySchema.extend({ + search: v2SearchSchema, + ...v2SortFields(v2WorkflowSortFields, { sortBy: 'position', sortOrder: 'asc' }), +}) + +export type V2ListWorkflowsQuery = z.output export const v2WorkflowListItemSchema = z.object({ id: z.string(), @@ -73,7 +106,7 @@ const v2UndeployWorkflowDataSchema = v1DeployWorkflowDataSchema.omit({ version: export const v2ListWorkflowsContract = defineRouteContract({ method: 'GET', path: '/api/v2/workflows', - query: v1ListWorkflowsQuerySchema, + query: v2ListWorkflowsQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2WorkflowListItemSchema), diff --git a/apps/sim/lib/api/list-convention.test.ts b/apps/sim/lib/api/list-convention.test.ts new file mode 100644 index 00000000000..fcb2594dc26 --- /dev/null +++ b/apps/sim/lib/api/list-convention.test.ts @@ -0,0 +1,225 @@ +/** + * @vitest-environment node + * + * One test per v2 list-backing query, asserting the same two things everywhere: + * `search` becomes a bound case-insensitive substring predicate on that + * resource's natural name column, and `sortBy` selects the ordering columns. + * + * This is the surface-wide guard the convention needs. `list-query.test.ts` + * proves the generated SQL is parameterized and wildcard-escaped; what can still + * go wrong per resource is aiming the search at the wrong column, or a sort that + * never reaches `ORDER BY` — both visible only in the query each lib builds. + * + * Files (`queryWorkspaceFiles`) and workflows have their own suites, since they + * additionally paginate. + */ +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/billing/core/subscription', () => ({ getHighestPrioritySubscription: vi.fn() })) +vi.mock('@/lib/billing/core/usage', () => ({ ensureUserStatsExists: vi.fn() })) +vi.mock('@/lib/billing/storage', () => ({ + applyStorageUsageDeltasInTx: vi.fn(), + decrementStorageUsageForBillingContextInTx: vi.fn(), + incrementStorageUsageForBillingContextInTx: vi.fn(), + maybeNotifyStorageLimitForBillingContext: vi.fn(), + resolveStorageBillingContext: vi.fn(), +})) +vi.mock('@/lib/table/billing', () => ({ + assertRowCapacity: vi.fn(), + notifyTableRowUsage: vi.fn(), +})) +vi.mock('@/lib/table/jobs/service', () => ({ + EMPTY_JOB_FIELDS: {}, + latestJobForTable: vi.fn(async () => null), + latestJobsForTables: vi.fn(async () => new Map()), +})) +vi.mock('@/lib/table/events', () => ({ appendTableEvent: vi.fn() })) +vi.mock('@/lib/realtime/notify', () => ({ + mergeEditIntoLiveFileDoc: vi.fn(), + notifyWorkspaceFilesChanged: vi.fn(), + notifyWorkspaceTablesChanged: vi.fn(), +})) +vi.mock('@/lib/skills/access', () => ({ getEditableSkillIds: vi.fn() })) +vi.mock('@/lib/workflows/skills/builtin-skills', () => ({ + BUILTIN_SKILLS: [], + getBuiltinSkillById: vi.fn(), + isBuiltinSkillId: vi.fn(() => false), +})) + +import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' +import { listFoldersForWorkspace } from '@/lib/folders/queries' +import { getKnowledgeBases } from '@/lib/knowledge/service' +import { listWorkspaceMcpServers } from '@/lib/mcp/queries' +import { listTables } from '@/lib/table/service' +import { listWorkspaceCustomTools } from '@/lib/workflows/custom-tools/operations' +import { listSkills } from '@/lib/workflows/skills/operations' + +const WS = 'workspace-1' + +const lastConditions = () => + flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).filter(Boolean) + +const lastOrderBy = () => dbChainMockFns.orderBy.mock.calls.at(-1) ?? [] + +const searchNode = () => lastConditions().find((c) => c.type === 'ilike') + +interface ListCase { + name: string + /** Column the resource's `search` must match on. */ + column: unknown + /** Rows table to queue against, so the chain resolves. */ + table: unknown + run: (options: { + search?: string + sortBy?: string + sortOrder?: 'asc' | 'desc' + }) => Promise + /** A non-default sort, and the columns it must order by. */ + sort: { sortBy: string; sortOrder: 'asc' | 'desc'; columns: unknown[] } +} + +const CASES: ListCase[] = [ + { + name: 'folders', + column: schemaMock.folder.name, + table: schemaMock.folder, + run: ({ search, sortBy, sortOrder }) => + listFoldersForWorkspace(WS, 'active', 'workflow', { + search, + sortBy: sortBy as never, + sortOrder, + }), + sort: { + sortBy: 'name', + sortOrder: 'desc', + columns: [schemaMock.folder.name, schemaMock.folder.createdAt], + }, + }, + { + name: 'tables', + column: schemaMock.userTableDefinitions.name, + table: schemaMock.userTableDefinitions, + run: ({ search, sortBy, sortOrder }) => + listTables(WS, { search, sortBy: sortBy as never, sortOrder }), + sort: { + sortBy: 'updatedAt', + sortOrder: 'desc', + columns: [ + schemaMock.userTableDefinitions.updatedAt, + schemaMock.userTableDefinitions.createdAt, + ], + }, + }, + { + name: 'knowledge bases', + column: schemaMock.knowledgeBase.name, + table: schemaMock.knowledgeBase, + run: ({ search, sortBy, sortOrder }) => + getKnowledgeBases('user-1', WS, 'active', { search, sortBy: sortBy as never, sortOrder }), + sort: { + sortBy: 'name', + sortOrder: 'asc', + columns: [schemaMock.knowledgeBase.name, schemaMock.knowledgeBase.createdAt], + }, + }, + { + name: 'credentials', + column: schemaMock.credential.displayName, + table: schemaMock.credential, + run: ({ search, sortBy, sortOrder }) => + listVisibleWorkspaceCredentials({ + workspaceId: WS, + userId: 'user-1', + workspaceAccess: { canAdmin: false }, + search, + sortBy: sortBy as never, + sortOrder, + }), + sort: { + sortBy: 'displayName', + sortOrder: 'asc', + columns: [schemaMock.credential.displayName, schemaMock.credential.id], + }, + }, + { + name: 'MCP servers', + column: schemaMock.mcpServers.name, + table: schemaMock.mcpServers, + run: ({ search, sortBy, sortOrder }) => + listWorkspaceMcpServers({ workspaceId: WS, search, sortBy: sortBy as never, sortOrder }), + sort: { + sortBy: 'name', + sortOrder: 'asc', + columns: [schemaMock.mcpServers.name, schemaMock.mcpServers.id], + }, + }, + { + name: 'custom tools', + column: schemaMock.customTools.title, + table: schemaMock.customTools, + run: ({ search, sortBy, sortOrder }) => + listWorkspaceCustomTools({ workspaceId: WS, search, sortBy: sortBy as never, sortOrder }), + sort: { + sortBy: 'title', + sortOrder: 'asc', + columns: [schemaMock.customTools.title, schemaMock.customTools.id], + }, + }, + { + name: 'skills', + column: schemaMock.skill.name, + table: schemaMock.skill, + run: ({ search, sortBy, sortOrder }) => + listSkills({ + workspaceId: WS, + search, + sort: sortBy ? { sortBy: sortBy as never, sortOrder: sortOrder ?? 'desc' } : undefined, + }), + sort: { + sortBy: 'name', + sortOrder: 'asc', + columns: [schemaMock.skill.name, schemaMock.skill.id], + }, + }, +] + +describe.each(CASES)('$name list query', (listCase) => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + queueTableRows(listCase.table, []) + }) + + it('narrows the query with a case-insensitive substring match on its name column', async () => { + await listCase.run({ search: 'quarterly' }) + + expect(searchNode()).toMatchObject({ column: listCase.column, pattern: '%quarterly%' }) + }) + + it('escapes LIKE wildcards so a caller cannot widen its own match', async () => { + await listCase.run({ search: '50%_off' }) + + expect(searchNode()).toMatchObject({ pattern: '%50\\%\\_off%' }) + }) + + it('adds no search condition when the caller did not search', async () => { + await listCase.run({}) + + expect(searchNode()).toBeUndefined() + }) + + it('orders by the requested field and direction', async () => { + const { sortBy, sortOrder, columns } = listCase.sort + + await listCase.run({ sortBy, sortOrder }) + + expect(lastOrderBy()).toEqual(columns.map((column) => ({ type: sortOrder, column }))) + }) +}) diff --git a/apps/sim/lib/api/list-query.test.ts b/apps/sim/lib/api/list-query.test.ts new file mode 100644 index 00000000000..4da992aa2f4 --- /dev/null +++ b/apps/sim/lib/api/list-query.test.ts @@ -0,0 +1,193 @@ +/** + * @vitest-environment node + * + * The v2 list convention's SQL half. These run against REAL drizzle (the global + * `drizzle-orm` mock is lifted for this file) and render the generated SQL, so + * the assertions are about the query that would actually be sent — the point + * being that a caller's `search` term only ever arrives as a bound parameter. + */ +import { describe, expect, it, vi } from 'vitest' + +vi.unmock('drizzle-orm') + +import { integer, PgDialect, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { + encodeKeyset, + escapeLikePattern, + keysetAfter, + keysetColumns, + listOrderBy, + numberKey, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' + +const thing = pgTable('thing', { + id: text('id').primaryKey(), + name: text('name').notNull(), + size: integer('size').notNull(), + createdAt: timestamp('created_at').notNull(), +}) + +const dialect = new PgDialect() + +function render(fragment: Parameters[0]) { + return dialect.sqlToQuery(fragment) +} + +describe('escapeLikePattern', () => { + it('neutralizes the LIKE wildcards so a caller cannot widen its own match', () => { + expect(escapeLikePattern('100%')).toBe('100\\%') + expect(escapeLikePattern('a_b')).toBe('a\\_b') + expect(escapeLikePattern('back\\slash')).toBe('back\\\\slash') + }) + + it('leaves an ordinary term untouched', () => { + expect(escapeLikePattern('quarterly report')).toBe('quarterly report') + }) +}) + +describe('searchFilter', () => { + it('binds the caller term as a parameter instead of inlining it into the SQL', () => { + const { sql, params } = render(searchFilter(thing.name, "o'brien; drop table thing --")!) + + expect(sql).toBe('"thing"."name" ilike $1') + expect(params).toEqual(["%o'brien; drop table thing --%"]) + expect(sql).not.toContain('drop table') + }) + + it('escapes wildcards inside the bound pattern', () => { + const { params } = render(searchFilter(thing.name, '50%_off')!) + + expect(params).toEqual(['%50\\%\\_off%']) + }) + + it('is case-insensitive (ILIKE, not LIKE)', () => { + const { sql } = render(searchFilter(thing.name, 'Report')!) + + expect(sql).toContain('ilike') + }) + + it('drops out of the WHERE clause entirely when no term was given', () => { + expect(searchFilter(thing.name, undefined)).toBeUndefined() + }) +}) + +describe('listOrderBy', () => { + it('applies the direction to every key so the ordering is total', () => { + const [first, second] = listOrderBy([thing.name, thing.id], 'desc') + + expect(render(first).sql).toBe('"thing"."name" desc') + expect(render(second).sql).toBe('"thing"."id" desc') + }) +}) + +interface Row { + id: string + name: string + createdAt: Date +} + +const nameKey = textKey(thing.name, (r) => r.name) +const idKey = textKey(thing.id, (r) => r.id) +const createdKey = timestampKey(thing.createdAt, (r) => r.createdAt) + +describe('timestampKey', () => { + /** + * The regression this exists for: Postgres keeps microseconds, a cursor value + * round-trips through a millisecond-only JS Date, and comparing the raw column + * against the truncated value re-admits the page's own last row. + */ + it('orders on the millisecond-truncated column so the cursor can express the ordering', () => { + expect(render(createdKey.expr as never).sql).toBe( + `date_trunc('milliseconds', "thing"."created_at")` + ) + }) + + it('truncates the bound cursor value to match, binding it through the column encoder', () => { + const { sql: text, params } = render(createdKey.bind('2024-01-01T00:00:00.123Z')!) + + expect(text).toBe(`date_trunc('milliseconds', $1)`) + expect(params).toEqual(['2024-01-01T00:00:00.123Z']) + }) + + it('rejects a cursor value that is not a parseable timestamp', () => { + expect(createdKey.bind('not-a-date')).toBeNull() + expect(createdKey.bind(1700000000000)).toBeNull() + }) +}) + +describe('cursor key value validation', () => { + it('rejects a non-string for a text key', () => { + expect(nameKey.bind(42)).toBeNull() + expect(nameKey.bind('ok')).not.toBeNull() + }) + + it('rejects a non-finite or non-numeric value for a numeric key', () => { + const sizeKey = numberKey(thing.size, () => 0) + + expect(sizeKey.bind('12')).toBeNull() + expect(sizeKey.bind(Number.NaN)).toBeNull() + expect(sizeKey.bind(Number.POSITIVE_INFINITY)).toBeNull() + expect(sizeKey.bind(12)).not.toBeNull() + }) +}) + +describe('encodeKeyset / keysetColumns', () => { + it('reads the cursor values and the ordering expressions in key order', () => { + const row: Row = { id: 'file-7', name: 'data.csv', createdAt: new Date('2024-03-04T05:06:07Z') } + + expect(encodeKeyset([nameKey, idKey], row)).toEqual(['data.csv', 'file-7']) + expect(keysetColumns([nameKey, idKey])).toEqual([thing.name, thing.id]) + }) +}) + +describe('keysetAfter', () => { + it('expands lexicographically so a tie on a leading key falls through', () => { + const { sql: text, params } = render( + keysetAfter([nameKey, idKey], ['data.csv', 'file-7'], 'asc')! + ) + + expect(text).toBe('("thing"."name" > $1 or ("thing"."name" = $2 and "thing"."id" > $3))') + expect(params).toEqual(['data.csv', 'data.csv', 'file-7']) + }) + + it('flips the comparison for a descending sort', () => { + const { sql: text } = render(keysetAfter([nameKey, idKey], ['b', 'x'], 'desc')!) + + expect(text).toContain('"thing"."name" < $1') + expect(text).not.toContain('>') + }) + + it('binds every keyset value as a parameter', () => { + const { sql: text, params } = render(keysetAfter([idKey], ["'; delete from thing --"], 'asc')!) + + expect(text).toBe('"thing"."id" > $1') + expect(params).toEqual(["'; delete from thing --"]) + }) + + it('compares the truncated timestamp on both sides', () => { + const { sql: text, params } = render( + keysetAfter([createdKey, idKey], ['2024-01-01T00:00:00.123Z', 'file-7'], 'asc')! + ) + + expect(text).toBe( + `(date_trunc('milliseconds', "thing"."created_at") > date_trunc('milliseconds', $1) or ` + + `(date_trunc('milliseconds', "thing"."created_at") = date_trunc('milliseconds', $2) and ` + + `"thing"."id" > $3))` + ) + expect(params).toEqual(['2024-01-01T00:00:00.123Z', '2024-01-01T00:00:00.123Z', 'file-7']) + }) + + /** A caller controls the cursor's contents, so a bad value is a 400, not a 500 from SQL. */ + it('refuses a cursor carrying a value its key cannot hold', () => { + expect(keysetAfter([createdKey, idKey], ['not-a-date', 'file-7'], 'asc')).toBeNull() + expect(keysetAfter([nameKey, idKey], [7, 'file-7'], 'asc')).toBeNull() + }) + + it('refuses a cursor with the wrong number of keys for the sort', () => { + expect(keysetAfter([nameKey, idKey], ['only-one'], 'asc')).toBeNull() + expect(keysetAfter([nameKey, idKey], ['a', 'b', 'c'], 'asc')).toBeNull() + }) +}) diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts new file mode 100644 index 00000000000..d5f74aa9660 --- /dev/null +++ b/apps/sim/lib/api/list-query.ts @@ -0,0 +1,176 @@ +import { + and, + asc, + type Column, + desc, + eq, + gt, + ilike, + lt, + or, + type SQL, + type SQLWrapper, + sql, +} from 'drizzle-orm' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' + +/** + * Runtime half of the v2 list convention declared in + * `lib/api/contracts/v2/shared.ts`: turns a validated `search` term and a + * validated `sortBy`/`sortOrder` pair into SQL. + * + * Nothing here accepts a caller string as SQL. `search` becomes a bound ILIKE + * parameter, a sort is only ever expressed as one of the keys the resource + * itself listed (the contract enum is what makes that lookup total), and a + * cursor's values are type-checked against their key before they are bound. + */ + +/** + * Escapes LIKE/ILIKE wildcards so `%`, `_`, and `\` in a caller's term match + * themselves. Postgres treats `\` as the default LIKE escape character, so no + * explicit `ESCAPE` clause is needed. + * + * `lib/table/sql.ts` carries its own copy for the JSONB predicate engine; the + * two are worth folding together, but that module is table-specific and pulls + * the whole column-type registry with it. + */ +export function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, '\\$&') +} + +/** + * Case-insensitive substring predicate for a v2 `search` term, or `undefined` + * when the caller did not search (which drops out of an `and(...)`). + */ +export function searchFilter(column: Column, term: string | undefined): SQL | undefined { + if (term === undefined) return undefined + return ilike(column, `%${escapeLikePattern(term)}%`) +} + +/** A cursor key value, as it survives the base64-JSON round trip. */ +export type CursorKey = string | number + +/** Caller-facing message for a cursor that cannot be resumed under the requested sort. */ +export const INVALID_CURSOR_MESSAGE = + 'cursor does not match the requested sortBy/sortOrder. Restart pagination without a cursor after changing the sort.' + +/** + * One column of a keyset ordering, with the codec that moves its value through + * the opaque cursor. + * + * `bind` returning `null` is how a malformed cursor becomes a 400. The values + * inside a cursor are caller-controlled, so "the sort stamp and key count + * match" is not enough — a non-numeric `size` or an unparseable timestamp has + * to be rejected at the boundary instead of reaching the query as `NaN` or an + * `Invalid Date`, which surfaces as a 500. + */ +export interface KeysetKey { + /** The expression this key both orders and compares on. */ + expr: SQLWrapper + /** This key's cursor value for `row`. */ + encode: (row: Row) => CursorKey + /** The cursor value as bindable SQL, or `null` when this key cannot hold it. */ + bind: (value: CursorKey) => SQL | null +} + +/** A text key — names, titles, ids. */ +export function textKey(column: Column, read: (row: Row) => string): KeysetKey { + return { + expr: column, + encode: read, + bind: (value) => (typeof value === 'string' ? sql`${value}` : null), + } +} + +/** A numeric key — sizes, counts, manual positions. */ +export function numberKey(column: Column, read: (row: Row) => number): KeysetKey { + return { + expr: column, + encode: read, + bind: (value) => (typeof value === 'number' && Number.isFinite(value) ? sql`${value}` : null), + } +} + +/** + * A timestamp key, ordered and compared at millisecond precision. + * + * Postgres keeps microseconds and `defaultNow()` populates them, but a cursor + * value round-trips through a JS `Date`, which cannot represent them. Ordering + * on the raw column while comparing against a truncated cursor value re-admits + * the page's own last row — `stored > truncated` is true for it — which + * duplicates that row and stalls pagination outright at a page size of one. + * Truncating both sides makes the SQL ordering exactly the ordering a cursor + * can express, so the `id` tiebreaker is what actually separates rows inside a + * millisecond. + * + * `date_trunc` rules out an index-ordered scan, but none of the timestamp + * columns sorted here are indexed, so it costs nothing today. Adding an index + * to serve one of these sorts means indexing this same expression. + */ +export function timestampKey(column: Column, read: (row: Row) => Date): KeysetKey { + return { + expr: sql`date_trunc('milliseconds', ${column})`, + encode: (row) => read(row).toISOString(), + bind: (value) => { + if (typeof value !== 'string') return null + const date = new Date(value) + if (Number.isNaN(date.getTime())) return null + // Bound through the column so drizzle's own timestamp encoder serializes it. + return sql`date_trunc('milliseconds', ${sql.param(date, column)})` + }, + } +} + +export function sortDirection(order: V2SortOrder): typeof asc { + return order === 'asc' ? asc : desc +} + +/** + * `ORDER BY` for an ordered key list, every key taking the requested direction. + * On a paginated list these are the keyset's keys; on a single-page list they + * are just the sort plus its tiebreaker. + */ +export function listOrderBy(keys: readonly SQLWrapper[], order: V2SortOrder): SQL[] { + const direction = sortDirection(order) + return keys.map((key) => direction(key)) +} + +/** The `expr` of each keyset key, for `ORDER BY`. */ +export function keysetColumns(keys: readonly KeysetKey[]): SQLWrapper[] { + return keys.map((key) => key.expr) +} + +/** The cursor values for `row`, in key order. */ +export function encodeKeyset(keys: readonly KeysetKey[], row: Row): CursorKey[] { + return keys.map((key) => key.encode(row)) +} + +/** + * The `WHERE` half of the keyset: strictly after `values` in the requested + * direction, expanded lexicographically so ties on a leading key fall through + * to the next one. + * + * Returns `null` when the cursor does not fit this sort — wrong number of keys, + * or a value the key cannot hold. Callers render that as a 400 rather than + * paging from a nonsense position. + */ +export function keysetAfter( + keys: readonly KeysetKey[], + values: CursorKey[], + order: V2SortOrder +): SQL | null { + if (values.length !== keys.length) return null + + const bound: SQL[] = [] + for (const [i, key] of keys.entries()) { + const value = key.bind(values[i]) + if (value === null) return null + bound.push(value) + } + + const beyond = order === 'asc' ? gt : lt + const clauses = keys.map((key, i) => + and(...keys.slice(0, i).map((prior, j) => eq(prior.expr, bound[j])), beyond(key.expr, bound[i])) + ) + return or(...clauses) ?? null +} diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index 397e96b9f59..ef0fccd4267 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -1,7 +1,10 @@ import { db } from '@sim/db' import { credential, credentialMember } from '@sim/db/schema' -import { and, eq, inArray, isNotNull, or } from 'drizzle-orm' +import { and, type Column, eq, inArray, isNotNull, or } from 'drizzle-orm' import type { WorkspaceCredentialType } from '@/lib/api/contracts/credentials' +import type { V2CredentialSortBy } from '@/lib/api/contracts/v2/credentials' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import { isSharedCredentialType, SHARED_CREDENTIAL_TYPES } from '@/lib/credentials/access' import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -36,14 +39,38 @@ export interface VisibleWorkspaceCredential { * admins — every shared-type credential, plus the caller's own personal env * credentials. Encrypted secret material is never selected. */ +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `id` so credentials sharing a display name + * or a timestamp still come back in a stable order. + */ +const CREDENTIAL_SORTS = { + displayName: [credential.displayName, credential.id], + createdAt: [credential.createdAt, credential.id], + updatedAt: [credential.updatedAt, credential.id], +} satisfies Record + export async function listVisibleWorkspaceCredentials(params: { workspaceId: string userId: string workspaceAccess: Pick type?: WorkspaceCredentialType providerId?: string + /** Case-insensitive substring match on the credential display name. */ + search?: string + sortBy?: V2CredentialSortBy + sortOrder?: V2SortOrder }): Promise { - const { workspaceId, userId, workspaceAccess, type, providerId } = params + const { + workspaceId, + userId, + workspaceAccess, + type, + providerId, + search, + sortBy = 'createdAt', + sortOrder = 'desc', + } = params const whereClauses = [eq(credential.workspaceId, workspaceId)] if (type) whereClauses.push(eq(credential.type, type)) @@ -84,7 +111,8 @@ export async function listVisibleWorkspaceCredentials(params: { eq(credentialMember.status, 'active') ) ) - .where(and(...whereClauses, accessClause)) + .where(and(...whereClauses, accessClause, searchFilter(credential.displayName, search))) + .orderBy(...listOrderBy(CREDENTIAL_SORTS[sortBy], sortOrder)) return rows.map(({ memberRole, encryptedServiceAccountKey, ...rest }) => ({ ...rest, diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index 092387cea90..98f616e1a21 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -1,7 +1,10 @@ import { db } from '@sim/db' import { folder } from '@sim/db/schema' -import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm' +import { and, type Column, eq, isNotNull, isNull } from 'drizzle-orm' import type { FolderApi, FolderResourceType } from '@/lib/api/contracts/folders' +import type { V2FolderSortBy } from '@/lib/api/contracts/v2/folders' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import type { FolderQueryScope } from '@/hooks/queries/utils/folder-keys' /** @@ -135,21 +138,52 @@ export async function resolveRestoredFolderId( return (await findActiveFolder(folderId, workspaceId, resourceType)) ? folderId : null } -/** Shared by `GET /api/folders` and the sidebar prefetch so the query never drifts between them. */ +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `createdAt` so folders sharing a name or a + * `sortOrder` still come back in a stable order. + */ +const FOLDER_SORTS = { + position: [folder.sortOrder, folder.createdAt], + name: [folder.name, folder.createdAt], + createdAt: [folder.createdAt], + updatedAt: [folder.updatedAt, folder.createdAt], +} satisfies Record + +interface ListFoldersOptions { + /** Case-insensitive substring match on the folder name. */ + search?: string + sortBy?: V2FolderSortBy + sortOrder?: V2SortOrder +} + +/** + * Shared by `GET /api/folders`, the public v2 list, and the sidebar prefetch so + * the query never drifts between them. Search and sort are applied in the + * query; the in-app callers omit them and keep the default `position` ordering. + */ export async function listFoldersForWorkspace( workspaceId: string, scope: FolderQueryScope, - resourceType: FolderResourceType + resourceType: FolderResourceType, + options?: ListFoldersOptions ): Promise { const scopeFilter = scope === 'archived' ? isNotNull(folder.deletedAt) : isNull(folder.deletedAt) + const sortBy = options?.sortBy ?? 'position' + const sortOrder = options?.sortOrder ?? 'asc' const rows = await db .select() .from(folder) .where( - and(eq(folder.workspaceId, workspaceId), eq(folder.resourceType, resourceType), scopeFilter) + and( + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, resourceType), + scopeFilter, + searchFilter(folder.name, options?.search) + ) ) - .orderBy(asc(folder.sortOrder), asc(folder.createdAt)) + .orderBy(...listOrderBy(FOLDER_SORTS[sortBy], sortOrder)) return rows.map(toFolderApi) } diff --git a/apps/sim/lib/knowledge/service.test.ts b/apps/sim/lib/knowledge/service.test.ts index ce59bc0087b..7399b83aebe 100644 --- a/apps/sim/lib/knowledge/service.test.ts +++ b/apps/sim/lib/knowledge/service.test.ts @@ -82,7 +82,7 @@ describe('updateKnowledgeBase — workspace transfer authorization', () => { await expect( updateKnowledgeBase('kb-1', { workspaceId: null }, 'req-1', { actorUserId: 'owner' }) - ).rejects.not.toBeInstanceOf(KnowledgeBasePermissionError) + ).resolves.not.toThrow() expect(permissionsMockFns.mockGetUserEntityPermissions).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index e4e8cc3707b..6131ae58fd0 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -10,7 +10,22 @@ import { import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, count, eq, exists, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm' +import { + and, + type Column, + count, + eq, + exists, + inArray, + isNotNull, + isNull, + ne, + or, + sql, +} from 'drizzle-orm' +import type { V2KnowledgeBaseSortBy } from '@/lib/api/contracts/v2/knowledge' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { ensureUserStatsExists } from '@/lib/billing/core/usage' @@ -108,13 +123,38 @@ type KnowledgeBaseStorageMove = } /** - * Get knowledge bases that a user can access + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `createdAt` so knowledge bases sharing a + * name still come back in a stable order. + */ +const KNOWLEDGE_BASE_SORTS = { + name: [knowledgeBase.name, knowledgeBase.createdAt], + createdAt: [knowledgeBase.createdAt], + updatedAt: [knowledgeBase.updatedAt, knowledgeBase.createdAt], +} satisfies Record + +interface GetKnowledgeBasesOptions { + /** Restrict to one knowledge-base folder. */ + folderId?: string + /** Case-insensitive substring match on the knowledge base name. */ + search?: string + sortBy?: V2KnowledgeBaseSortBy + sortOrder?: V2SortOrder +} + +/** + * Get knowledge bases that a user can access. + * + * Filter and sort are applied in the query, so a search costs one narrowed scan + * rather than materializing every knowledge base the caller can reach. */ export async function getKnowledgeBases( userId: string, workspaceId?: string | null, - scope: KnowledgeBaseScope = 'active' + scope: KnowledgeBaseScope = 'active', + options?: GetKnowledgeBasesOptions ): Promise { + const { folderId, search, sortBy = 'createdAt', sortOrder = 'asc' } = options ?? {} const scopeCondition = scope === 'all' ? undefined @@ -161,6 +201,8 @@ export async function getKnowledgeBases( .where( and( scopeCondition, + folderId ? eq(knowledgeBase.folderId, folderId) : undefined, + searchFilter(knowledgeBase.name, search), workspaceId ? // When filtering by workspace or( @@ -183,7 +225,7 @@ export async function getKnowledgeBases( ) ) .groupBy(knowledgeBase.id) - .orderBy(knowledgeBase.createdAt) + .orderBy(...listOrderBy(KNOWLEDGE_BASE_SORTS[sortBy], sortOrder)) const kbIds = knowledgeBasesWithCounts.map((kb) => kb.id) diff --git a/apps/sim/lib/mcp/queries.ts b/apps/sim/lib/mcp/queries.ts index 81a50f0b1d6..789a86a4454 100644 --- a/apps/sim/lib/mcp/queries.ts +++ b/apps/sim/lib/mcp/queries.ts @@ -1,6 +1,9 @@ import { db } from '@sim/db' import { mcpServers } from '@sim/db/schema' -import { and, desc, eq, isNull } from 'drizzle-orm' +import { and, type Column, eq, isNull } from 'drizzle-orm' +import type { V2McpServerSortBy } from '@/lib/api/contracts/v2/mcp-servers' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' /** * Workspace-scoped MCP server reads. The lifecycle functions in @@ -11,14 +14,36 @@ import { and, desc, eq, isNull } from 'drizzle-orm' export type McpServerRow = typeof mcpServers.$inferSelect /** Live (non-soft-deleted) MCP servers in a workspace, newest first. */ +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `id` so servers sharing a name or a + * timestamp still come back in a stable order. + */ +const MCP_SERVER_SORTS = { + name: [mcpServers.name, mcpServers.id], + createdAt: [mcpServers.createdAt, mcpServers.id], + updatedAt: [mcpServers.updatedAt, mcpServers.id], +} satisfies Record + export async function listWorkspaceMcpServers(params: { workspaceId: string + /** Case-insensitive substring match on the server name. */ + search?: string + sortBy?: V2McpServerSortBy + sortOrder?: V2SortOrder }): Promise { + const { sortBy = 'createdAt', sortOrder = 'desc' } = params return db .select() .from(mcpServers) - .where(and(eq(mcpServers.workspaceId, params.workspaceId), isNull(mcpServers.deletedAt))) - .orderBy(desc(mcpServers.createdAt)) + .where( + and( + eq(mcpServers.workspaceId, params.workspaceId), + isNull(mcpServers.deletedAt), + searchFilter(mcpServers.name, params.search) + ) + ) + .orderBy(...listOrderBy(MCP_SERVER_SORTS[sortBy], sortOrder)) } /** A single live MCP server, or null when it does not exist in this workspace. */ diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 99f9cf82404..dd41b525317 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -13,7 +13,10 @@ import { tableJobs, userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, count, eq, isNull, sql } from 'drizzle-orm' +import { and, type Column, count, eq, isNotNull, isNull, sql } from 'drizzle-orm' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { V2TableSortBy } from '@/lib/api/contracts/v2/tables' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' @@ -206,17 +209,47 @@ export async function getTableById( } } +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `createdAt` so tables sharing a name still + * come back in a stable order. + */ +const TABLE_SORTS = { + name: [userTableDefinitions.name, userTableDefinitions.createdAt], + createdAt: [userTableDefinitions.createdAt], + updatedAt: [userTableDefinitions.updatedAt, userTableDefinitions.createdAt], +} satisfies Record + +interface ListTablesOptions { + scope?: TableScope + /** Restrict to one table folder. */ + folderId?: string + /** Case-insensitive substring match on the table name. */ + search?: string + sortBy?: V2TableSortBy + sortOrder?: V2SortOrder +} + /** * Lists all tables in a workspace. * + * Filter and sort are applied in the query — a name search must not become + * "read every table in the workspace, then discard most of them". + * * @param workspaceId - Workspace ID to list tables for * @returns Array of table definitions */ export async function listTables( workspaceId: string, - options?: { scope?: TableScope } + options?: ListTablesOptions ): Promise { - const { scope = 'active' } = options ?? {} + const { + scope = 'active', + folderId, + search, + sortBy = 'createdAt', + sortOrder = 'asc', + } = options ?? {} const tables = await db .select({ id: userTableDefinitions.id, @@ -236,19 +269,18 @@ export async function listTables( }) .from(userTableDefinitions) .where( - scope === 'all' - ? eq(userTableDefinitions.workspaceId, workspaceId) - : scope === 'archived' - ? and( - eq(userTableDefinitions.workspaceId, workspaceId), - sql`${userTableDefinitions.archivedAt} IS NOT NULL` - ) - : and( - eq(userTableDefinitions.workspaceId, workspaceId), - isNull(userTableDefinitions.archivedAt) - ) + and( + eq(userTableDefinitions.workspaceId, workspaceId), + scope === 'all' + ? undefined + : scope === 'archived' + ? isNotNull(userTableDefinitions.archivedAt) + : isNull(userTableDefinitions.archivedAt), + folderId ? eq(userTableDefinitions.folderId, folderId) : undefined, + searchFilter(userTableDefinitions.name, search) + ) ) - .orderBy(userTableDefinitions.createdAt) + .orderBy(...listOrderBy(TABLE_SORTS[sortBy], sortOrder)) const jobsByTable = await latestJobsForTables(tables.map((t) => t.id)) 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 c77719e6860..6e141853a9a 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -9,8 +9,23 @@ import { workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' -import { and, eq, isNotNull, isNull, sql } from 'drizzle-orm' +import { and, eq, isNotNull, isNull, type SQL } from 'drizzle-orm' import type { ShareRecord } from '@/lib/api/contracts/public-shares' +import type { V2FileSortBy } from '@/lib/api/contracts/v2/files' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { + type CursorKey, + encodeKeyset, + INVALID_CURSOR_MESSAGE, + type KeysetKey, + keysetAfter, + keysetColumns, + listOrderBy, + numberKey, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' import { decrementStorageUsageForBillingContextInTx, incrementStorageUsageForBillingContextInTx, @@ -775,6 +790,33 @@ export async function getWorkspaceFileByName( return mapSingleWorkspaceFileRecord(files[0], workspaceId) } +/** Workspace-file rows for one scope: live, Recently Deleted, or both. */ +function workspaceFileScopeCondition(workspaceId: string, scope: WorkspaceFileScope) { + const base = [ + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.context, 'workspace'), + ] + if (scope === 'all') return and(...base) + return scope === 'archived' + ? and(...base, isNotNull(workspaceFiles.deletedAt)) + : and(...base, isNull(workspaceFiles.deletedAt)) +} + +/** Resolves `folderPath` for a page of rows, reading the folder tree only if any row needs it. */ +async function hydrateWorkspaceFilePaths( + files: (typeof workspaceFiles.$inferSelect)[], + workspaceId: string, + options?: { folders?: WorkspaceFileFolderRecord[]; hydrateFolderPaths?: boolean } +): Promise { + const needsFolderPaths = + files.some((file) => file.folderId) && (options?.hydrateFolderPaths ?? true) + const folders = needsFolderPaths + ? (options?.folders ?? (await listWorkspaceFileFolders(workspaceId, { scope: 'all' }))) + : [] + const folderPaths = needsFolderPaths ? buildWorkspaceFileFolderPathMap(folders) : new Map() + return files.map((file) => mapWorkspaceFileRecord(file, workspaceId, folderPaths)) +} + /** * List all files for a workspace */ @@ -783,37 +825,14 @@ export async function listWorkspaceFiles( options?: ListWorkspaceFilesOptions ): Promise { try { - const { scope = 'active', hydrateFolderPaths = true } = options ?? {} + const { scope = 'active' } = options ?? {} const files = await db .select() .from(workspaceFiles) - .where( - scope === 'all' - ? and( - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace') - ) - : scope === 'archived' - ? and( - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace'), - sql`${workspaceFiles.deletedAt} IS NOT NULL` - ) - : and( - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace'), - isNull(workspaceFiles.deletedAt) - ) - ) + .where(workspaceFileScopeCondition(workspaceId, scope)) .orderBy(workspaceFiles.uploadedAt) - const needsFolderPaths = files.some((file) => file.folderId) && hydrateFolderPaths - const folders = needsFolderPaths - ? (options?.folders ?? (await listWorkspaceFileFolders(workspaceId, { scope: 'all' }))) - : [] - const folderPaths = needsFolderPaths ? buildWorkspaceFileFolderPathMap(folders) : new Map() - - return files.map((file) => mapWorkspaceFileRecord(file, workspaceId, folderPaths)) + return hydrateWorkspaceFilePaths(files, workspaceId, options) } catch (error) { logger.error(`Failed to list workspace files for ${workspaceId}:`, error) if (options?.throwOnError) throw error @@ -821,6 +840,91 @@ export async function listWorkspaceFiles( } } +/** + * The keysets behind {@link queryWorkspaceFiles}' sortable fields. `satisfies` + * makes this total over the contract enum: a new sortable field in the contract + * fails to compile until it has a keyset here, rather than silently falling + * through to an unordered scan. + * + * Every key column is `NOT NULL`, and `id` closes each keyset so a page + * boundary inside a run of equal names/sizes/timestamps is still stable. + */ +const fileId = textKey(workspaceFiles.id, (row) => row.id) + +const WORKSPACE_FILE_SORTS = { + name: [textKey(workspaceFiles.originalName, (row) => row.name), fileId], + size: [numberKey(workspaceFiles.size, (row) => row.size), fileId], + uploadedAt: [timestampKey(workspaceFiles.uploadedAt, (row) => row.uploadedAt), fileId], + updatedAt: [timestampKey(workspaceFiles.updatedAt, (row) => row.updatedAt), fileId], +} satisfies Record[]> + +export interface QueryWorkspaceFilesOptions { + scope?: WorkspaceFileScope + /** Restrict to one file folder. */ + folderId?: string + /** Case-insensitive substring match on the file name. */ + search?: string + sortBy: V2FileSortBy + sortOrder: V2SortOrder + limit: number + /** Keyset values from a cursor, in the sort's key order. */ + after?: CursorKey[] +} + +export interface QueryWorkspaceFilesResult { + files: WorkspaceFileRecord[] + /** Keyset values to resume from, or `null` when this page is the last one. */ + nextKeys: CursorKey[] | null +} + +/** + * One filtered, sorted, bounded page of a workspace's files. + * + * Distinct from {@link listWorkspaceFiles}, which materializes the whole scope + * for callers that genuinely need it. Here the filter, the ordering, and the + * slice are all in the query: a name search must not become "read every row, + * then discard almost all of them in JS". + * + * Throws rather than returning a short page — a swallowed storage error here is + * indistinguishable from "no more results" and would silently end pagination. + * A cursor that does not fit the requested sort is a classified `validation` + * failure, so the route renders it as a 400 rather than a 500. + */ +export async function queryWorkspaceFiles( + workspaceId: string, + options: QueryWorkspaceFilesOptions +): Promise { + const { scope = 'active', folderId, search, sortBy, sortOrder, limit, after } = options + const keys: readonly KeysetKey[] = WORKSPACE_FILE_SORTS[sortBy] + + let resumeAfter: SQL | undefined + if (after) { + const condition = keysetAfter(keys, after, sortOrder) + if (!condition) throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + resumeAfter = condition + } + + const conditions = [ + workspaceFileScopeCondition(workspaceId, scope), + folderId ? eq(workspaceFiles.folderId, folderId) : undefined, + searchFilter(workspaceFiles.originalName, search), + resumeAfter, + ] + + const rows = await db + .select() + .from(workspaceFiles) + .where(and(...conditions)) + .orderBy(...listOrderBy(keysetColumns(keys), sortOrder)) + .limit(limit + 1) + + const hasMore = rows.length > limit + const files = await hydrateWorkspaceFilePaths(rows.slice(0, limit), workspaceId) + const last = files.at(-1) + + return { files, nextKeys: hasMore && last ? encodeKeyset(keys, last) : null } +} + /** * Normalize a workspace file reference to either a display name or canonical file ID. * Supports raw IDs, `files/{name}`, `files/{name}/content`, and `files/{name}/meta.json`. diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-query.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-query.test.ts new file mode 100644 index 00000000000..21b5be978f6 --- /dev/null +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-query.test.ts @@ -0,0 +1,206 @@ +/** + * @vitest-environment node + * + * `queryWorkspaceFiles` — the paged, filtered, sorted read behind + * `GET /api/v2/files`. The assertions are on the query it builds, because the + * point of this function existing is that the scope filter, the name search, + * the ordering, and the page slice all happen in SQL rather than over a + * full-workspace result. + */ +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/billing/storage', () => ({ + decrementStorageUsageForBillingContextInTx: vi.fn(), + incrementStorageUsageForBillingContextInTx: vi.fn(), + maybeNotifyStorageLimitForBillingContext: vi.fn(), + resolveStorageBillingContext: vi.fn(), +})) + +vi.mock('@/lib/uploads', () => ({ + getServePathPrefix: vi.fn(() => '/api/files/serve/s3/'), +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + deleteFile: vi.fn(), + downloadFile: vi.fn(), + hasCloudStorage: vi.fn(() => false), + headObject: vi.fn(), + uploadFile: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ + assertWorkspaceFileFolderTarget: vi.fn(async () => null), + buildWorkspaceFileFolderPathMap: vi.fn(() => new Map()), + fileNameExistsInWorkspaceFolder: vi.fn(async () => false), + findWorkspaceFileFolderIdByPath: vi.fn(), + getWorkspaceFileFolderPath: vi.fn(), + listWorkspaceFileFolders: vi.fn(async () => []), + normalizeWorkspaceFileItemName: vi.fn((name: string) => name), +})) + +import { queryWorkspaceFiles } from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +const WS = 'workspace-1' + +const DEFAULTS = { sortBy: 'uploadedAt', sortOrder: 'asc', limit: 100 } as const + +function buildRow(overrides: Record = {}) { + return { + id: 'wf_1', + key: 'workspace/ws/1-x-data.csv', + userId: 'user-1', + workspaceId: WS, + folderId: null, + context: 'workspace', + originalName: 'data.csv', + contentType: 'text/csv', + size: 1024, + deletedAt: null, + uploadedAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + contentUpdatedAt: new Date('2024-01-01T00:00:00Z'), + ...overrides, + } +} + +const lastConditions = () => + flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).filter(Boolean) + +const lastOrderBy = () => dbChainMockFns.orderBy.mock.calls.at(-1) ?? [] + +describe('queryWorkspaceFiles', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('narrows the query with a case-insensitive substring match on the file name', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, search: 'data' }) + + expect(lastConditions().find((c) => c.type === 'ilike')).toMatchObject({ + column: schemaMock.workspaceFiles.originalName, + pattern: '%data%', + }) + }) + + it('escapes LIKE wildcards in the search term', async () => { + queueTableRows(schemaMock.workspaceFiles, []) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, search: '50%_off' }) + + expect(lastConditions().find((c) => c.type === 'ilike')).toMatchObject({ + pattern: '%50\\%\\_off%', + }) + }) + + it('adds no search condition when the caller did not search', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, DEFAULTS) + + expect(lastConditions().some((c) => c.type === 'ilike')).toBe(false) + }) + + it('filters to one folder in the query', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, folderId: 'fold_1' }) + + expect( + lastConditions().some( + (c) => + c.type === 'eq' && c.left === schemaMock.workspaceFiles.folderId && c.right === 'fold_1' + ) + ).toBe(true) + }) + + it('orders by the requested field, with id closing the keyset', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, sortBy: 'name', sortOrder: 'desc' }) + + expect(lastOrderBy()).toEqual([ + { type: 'desc', column: schemaMock.workspaceFiles.originalName }, + { type: 'desc', column: schemaMock.workspaceFiles.id }, + ]) + }) + + it('bounds the page in SQL by fetching one row past the limit', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await queryWorkspaceFiles(WS, { ...DEFAULTS, limit: 25 }) + + expect(dbChainMockFns.limit).toHaveBeenLastCalledWith(26) + }) + + it('reports no further keys when the page is not full, terminating pagination', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + const { files, nextKeys } = await queryWorkspaceFiles(WS, { ...DEFAULTS, limit: 10 }) + + expect(files).toHaveLength(1) + expect(nextKeys).toBeNull() + }) + + it('returns the keyset of the last row when more results exist', async () => { + queueTableRows(schemaMock.workspaceFiles, [ + buildRow(), + buildRow({ id: 'wf_2', originalName: 'b.csv' }), + ]) + + const { files, nextKeys } = await queryWorkspaceFiles(WS, { + ...DEFAULTS, + sortBy: 'name', + limit: 1, + }) + + expect(files.map((f) => f.id)).toEqual(['wf_1']) + expect(nextKeys).toEqual(['data.csv', 'wf_1']) + }) + + it('resumes strictly after the cursor keys, alongside the filter', async () => { + queueTableRows(schemaMock.workspaceFiles, [ + buildRow({ id: 'wf_2', originalName: 'data-2.csv' }), + ]) + + await queryWorkspaceFiles(WS, { + ...DEFAULTS, + sortBy: 'name', + search: 'data', + after: ['data.csv', 'wf_1'], + }) + + const conditions = lastConditions() + expect(conditions.find((c) => c.type === 'ilike')).toMatchObject({ pattern: '%data%' }) + expect(conditions.some((c) => c.type === 'or')).toBe(true) + }) + + /** + * Cursor contents are caller-controlled, so a value the key cannot hold is a + * client error. Classified `validation` so the route renders 400, not 500. + */ + it('rejects a cursor whose values do not fit the sort', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await expect( + queryWorkspaceFiles(WS, { ...DEFAULTS, sortBy: 'uploadedAt', after: ['not-a-date', 'wf_1'] }) + ).rejects.toMatchObject({ code: 'validation' }) + }) + + it('rejects a cursor with the wrong number of keys for the sort', async () => { + queueTableRows(schemaMock.workspaceFiles, [buildRow()]) + + await expect( + queryWorkspaceFiles(WS, { ...DEFAULTS, sortBy: 'name', after: ['data.csv'] }) + ).rejects.toMatchObject({ code: 'validation' }) + }) +}) diff --git a/apps/sim/lib/workflows/custom-tools/operations.ts b/apps/sim/lib/workflows/custom-tools/operations.ts index 2b6a779776b..32e2ba8bf3c 100644 --- a/apps/sim/lib/workflows/custom-tools/operations.ts +++ b/apps/sim/lib/workflows/custom-tools/operations.ts @@ -2,7 +2,10 @@ import { db } from '@sim/db' import { customTools } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' -import { and, desc, eq, isNull, or } from 'drizzle-orm' +import { and, type Column, desc, eq, isNull, or } from 'drizzle-orm' +import type { V2CustomToolSortBy } from '@/lib/api/contracts/v2/custom-tools' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import { generateRequestId } from '@/lib/core/utils/request' const logger = createLogger('CustomToolsOperations') @@ -136,12 +139,35 @@ export async function listCustomTools(params: { userId: string; workspaceId?: st * scoped in every direction, so it uses these instead — a caller holding a * workspace key must never reach another user's personal tool. */ -export async function listWorkspaceCustomTools(params: { workspaceId: string }) { +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `id` so tools sharing a timestamp still come + * back in a stable order. + */ +const CUSTOM_TOOL_SORTS = { + title: [customTools.title, customTools.id], + createdAt: [customTools.createdAt, customTools.id], + updatedAt: [customTools.updatedAt, customTools.id], +} satisfies Record + +export async function listWorkspaceCustomTools(params: { + workspaceId: string + /** Case-insensitive substring match on the tool title. */ + search?: string + sortBy?: V2CustomToolSortBy + sortOrder?: V2SortOrder +}) { + const { sortBy = 'createdAt', sortOrder = 'desc' } = params return db .select() .from(customTools) - .where(eq(customTools.workspaceId, params.workspaceId)) - .orderBy(desc(customTools.createdAt)) + .where( + and( + eq(customTools.workspaceId, params.workspaceId), + searchFilter(customTools.title, params.search) + ) + ) + .orderBy(...listOrderBy(CUSTOM_TOOL_SORTS[sortBy], sortOrder)) } export async function getWorkspaceCustomTool(params: { workspaceId: string; toolId: string }) { diff --git a/apps/sim/lib/workflows/skills/operations.ts b/apps/sim/lib/workflows/skills/operations.ts index 4746cc0d5f7..daf82b2d285 100644 --- a/apps/sim/lib/workflows/skills/operations.ts +++ b/apps/sim/lib/workflows/skills/operations.ts @@ -2,7 +2,10 @@ import { db } from '@sim/db' import { skill, skillMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId, generateShortId } from '@sim/utils/id' -import { and, desc, eq, ne } from 'drizzle-orm' +import { and, type Column, desc, eq, ne } from 'drizzle-orm' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { V2SkillSortBy } from '@/lib/api/contracts/v2/skills' +import { listOrderBy, searchFilter } from '@/lib/api/list-query' import { generateRequestId } from '@/lib/core/utils/request' import { getEditableSkillIds } from '@/lib/skills/access' import { @@ -32,6 +35,32 @@ function builtinSkillRow(workspaceId: string, builtin: BuiltinSkill): typeof ski } } +type SkillRow = typeof skill.$inferSelect + +/** + * Orderings for the public list's sortable fields, made total over the contract + * enum by `satisfies`. Each ends in `id` so skills sharing a timestamp still + * come back in a stable order. + */ +const SKILL_SORTS = { + name: [skill.name, skill.id], + createdAt: [skill.createdAt, skill.id], + updatedAt: [skill.updatedAt, skill.id], +} satisfies Record + +/** The sort key {@link SKILL_SORTS} orders on, for one row. */ +function skillSortKey(row: SkillRow, sortBy: V2SkillSortBy): [string | number, string] { + if (sortBy === 'name') return [row.name, row.id] + return [(sortBy === 'createdAt' ? row.createdAt : row.updatedAt).getTime(), row.id] +} + +function compareSkills(a: SkillRow, b: SkillRow, sortBy: V2SkillSortBy): number { + const [aKey, aId] = skillSortKey(a, sortBy) + const [bKey, bId] = skillSortKey(b, sortBy) + if (aKey !== bKey) return aKey < bKey ? -1 : 1 + return aId < bId ? -1 : aId > bId ? 1 : 0 +} + /** * List skills for a workspace, ordered by createdAt desc. Built-in template * skills are prepended (they live in code, not the DB) so they appear wherever @@ -40,23 +69,53 @@ function builtinSkillRow(workspaceId: string, builtin: BuiltinSkill): typeof ski * Pass `includeBuiltins: false` to return only user-created skills. The * mothership uses this for the workspace skill inventory it sees, which lists * only user-created skills and never the code-only templates. + * + * `search` and `sort` serve the public list. The DB half of both runs in the + * query; the built-ins are a small code constant with no row to order, so they + * are filtered and merged in memory — the one place a v2 list cannot push its + * sort all the way down. Passing `sort` also re-orders the built-ins into the + * requested order instead of pinning them first, so the public list is sorted + * as documented; callers that omit it keep the historical builtins-first order. + * + * The merged ordering compares names with JS string order rather than the + * database collation. Only the handful of ASCII built-in names are placed by + * it, so the two agree in practice. */ -export async function listSkills(params: { workspaceId: string; includeBuiltins?: boolean }) { +export async function listSkills(params: { + workspaceId: string + includeBuiltins?: boolean + /** Case-insensitive substring match on the skill name. */ + search?: string + sort?: { sortBy: V2SkillSortBy; sortOrder: V2SortOrder } +}): Promise { + const sortBy = params.sort?.sortBy ?? 'createdAt' + const sortOrder = params.sort?.sortOrder ?? 'desc' + const dbRows = await db .select() .from(skill) - .where(eq(skill.workspaceId, params.workspaceId)) - .orderBy(desc(skill.createdAt)) + .where(and(eq(skill.workspaceId, params.workspaceId), searchFilter(skill.name, params.search))) + .orderBy(...listOrderBy(SKILL_SORTS[sortBy], sortOrder)) if (params.includeBuiltins === false) { return dbRows } + /** + * Restricting `dbNames` to the searched rows is safe: a DB skill only shadows + * a built-in by sharing its name, and a name that matches the search on the + * built-in matches it on the DB row too. + */ const dbNames = new Set(dbRows.map((r) => r.name.toLowerCase())) - const builtins = BUILTIN_SKILLS.filter((b) => !dbNames.has(b.name.toLowerCase())).map((b) => - builtinSkillRow(params.workspaceId, b) - ) - return [...builtins, ...dbRows] + const term = params.search?.toLowerCase() + const builtins = BUILTIN_SKILLS.filter( + (b) => !dbNames.has(b.name.toLowerCase()) && (!term || b.name.toLowerCase().includes(term)) + ).map((b) => builtinSkillRow(params.workspaceId, b)) + + if (!params.sort) return [...builtins, ...dbRows] + + const direction = sortOrder === 'asc' ? 1 : -1 + return [...builtins, ...dbRows].sort((a, b) => direction * compareSkills(a, b, sortBy)) } /** A skill row tagged with whether the caller can edit it (always false on builtins). */ diff --git a/packages/testing/src/mocks/database.mock.ts b/packages/testing/src/mocks/database.mock.ts index 455c90f66c8..c6612ef276f 100644 --- a/packages/testing/src/mocks/database.mock.ts +++ b/packages/testing/src/mocks/database.mock.ts @@ -12,6 +12,8 @@ export function createMockSql() { toSQL: () => ({ sql: strings.join('?'), params: values }), /** Mirrors drizzle's `sql``…`.as(alias)` for aliased select expressions. */ as: (alias: string) => ({ ...fragment, alias }), + /** Mirrors drizzle's `sql``…`.mapWith(Number)` result decoder. */ + mapWith: (_decoder: unknown) => fragment, } return fragment } From ca1dad861d9f7c5d8c8886dd41db20f8dad9c7c9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 22:51:11 -0700 Subject: [PATCH 048/159] feat(api): complete the v2 workflows resource with versions and CRUD (#6184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): complete the v2 workflows resource with versions and CRUD Adds version listing/detail plus create, update, and delete to the v2 workflows surface, which previously covered only execution and deployment. - GET /api/v2/workflows/[id]/versions — cursor-paginated, newest first - GET /api/v2/workflows/[id]/versions/[version] — version + pinned state - POST /api/v2/workflows, PATCH and DELETE /api/v2/workflows/[id] All six delegate to the existing orchestration and persistence helpers; no new domain logic. * fix(api): check folder containment before lock state; reject malformed version cursors assertFolderMutable walks a folder's ancestor chain without filtering on workspace, so inspecting it before containment let a caller tell a locked folder in someone else's workspace (423) from a nonexistent one (400). Create and update now assert containment first, matching the ordering import-workflow.ts already uses. A version cursor that decodes to JSON without a numeric version filtered every row out and returned an empty page with nextCursor null, which reads as a clean end-of-list. Malformed cursors are now a 400. * refactor(api): page workflow versions in the persistence helper listWorkflowVersions read every version row and the route filtered and sliced the result in memory, so the response was bounded but the query was not. It now takes optional limit/afterVersion, turning the cursor into a real keyset query; the route asks for limit + 1 and only trims the has-more probe. Both params are optional, so the internal, v1 admin, and copilot callers are unchanged. Also restores the untouched GET handler in [id]/route.ts to its original formatting — collapsing its signature had re-indented the whole body and buried the actual additions in whitespace churn. --- apps/docs/openapi-v2-workflows.json | 765 +++++++++++++++++- apps/sim/app/api/v1/middleware.ts | 2 + .../app/api/v2/workflows/[id]/route.test.ts | 355 ++++++++ apps/sim/app/api/v2/workflows/[id]/route.ts | 173 +++- .../[id]/versions/[version]/route.test.ts | 154 ++++ .../[id]/versions/[version]/route.ts | 74 ++ .../v2/workflows/[id]/versions/route.test.ts | 221 +++++ .../api/v2/workflows/[id]/versions/route.ts | 111 +++ apps/sim/app/api/v2/workflows/route.test.ts | 216 ++++- apps/sim/app/api/v2/workflows/route.ts | 85 ++ apps/sim/lib/api/contracts/deployments.ts | 2 +- apps/sim/lib/api/contracts/v2/workflows.ts | 144 ++++ apps/sim/lib/workflows/persistence/utils.ts | 53 +- scripts/check-api-validation-contracts.ts | 4 +- 14 files changed, 2300 insertions(+), 59 deletions(-) create mode 100644 apps/sim/app/api/v2/workflows/[id]/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/versions/route.ts diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 0f7cb0a95b6..40984613cbb 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Sim API v2 — Workflows", - "description": "Version 2 of the Sim REST API for listing workflows, inspecting workflow detail, and managing deployments (deploy, undeploy, rollback).\n\nThe v2 surface standardizes on a single response family across every endpoint:\n- Single resource: `{ \"data\": T }`\n- List: `{ \"data\": T[], \"nextCursor\": string | null }`\n- Error: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`\n\nLists use an opaque cursor (Stripe/Slack-style): send `limit` and `cursor`, receive `{ data, nextCursor }`. Cursors are opaque tokens — pass back the `nextCursor` from the previous page verbatim and stop when it is `null`. Total counts are not returned on lists.\n\nRate-limit state is carried in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` response headers (not in the body). A throttled request returns `429` with a `Retry-After` header.\n\nAuthenticate every request with an API key in the `X-API-Key` header.", + "description": "Version 2 of the Sim REST API for managing workflows (create, list, inspect, update, delete), their deployment versions, and deployments (deploy, undeploy, rollback).\n\nThe v2 surface standardizes on a single response family across every endpoint:\n- Single resource: `{ \"data\": T }`\n- List: `{ \"data\": T[], \"nextCursor\": string | null }`\n- Error: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`\n\nLists use an opaque cursor (Stripe/Slack-style): send `limit` and `cursor`, receive `{ data, nextCursor }`. Cursors are opaque tokens — pass back the `nextCursor` from the previous page verbatim and stop when it is `null`. Total counts are not returned on lists.\n\nRate-limit state is carried in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` response headers (not in the body). A throttled request returns `429` with a `Retry-After` header.\n\nAuthenticate every request with an API key in the `X-API-Key` header.", "version": "2.0.0", "contact": { "name": "Sim Support", @@ -23,7 +23,7 @@ "tags": [ { "name": "Workflows", - "description": "List workflows, inspect workflow detail, and manage deployments (deploy, undeploy, rollback) on the v2 API." + "description": "Create, list, inspect, update, and delete workflows, enumerate their deployment versions, and manage deployments (deploy, undeploy, rollback) on the v2 API." } ], "security": [ @@ -188,30 +188,546 @@ "$ref": "#/components/responses/InternalError" } } + }, + "post": { + "operationId": "createWorkflowV2", + "summary": "Create Workflow", + "description": "Create an empty workflow in a workspace. The workflow is created with a default start block and no deployment, so it must be edited and deployed before it can be executed. Names must be unique within the target folder — a collision is reported as 409 rather than silently renamed.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/workflows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Customer Support Agent\"\n }'" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkflowBody" + }, + "examples": { + "minimal": { + "summary": "At the workspace root", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Customer Support Agent" + } + }, + "inFolder": { + "summary": "Inside a folder, with a description", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The created workflow.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/WorkflowListItem" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": false, + "deployedAt": null, + "runCount": 0, + "lastRunAt": null, + "createdAt": "2026-06-29T21:30:00.000Z", + "updatedAt": "2026-06-29T21:30:00.000Z" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "description": "A workflow with the same name already exists in the target folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}": { + "get": { + "operationId": "getWorkflow", + "summary": "Get Workflow", + "description": "Retrieve a single workflow, including its workflow-level variables and trigger input field definitions. Returns 404 when the workflow does not exist or you do not have access to it (existence is not leaked).", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "responses": { + "200": { + "description": "The requested workflow.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/WorkflowDetail" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 142, + "lastRunAt": "2026-06-20T14:15:22.000Z", + "variables": { + "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": { + "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60", + "name": "supportEmail", + "type": "string", + "value": "support@example.com" + } + }, + "inputs": [ + { + "name": "ticketBody", + "type": "string", + "description": "The raw text of the incoming support ticket." + } + ], + "createdAt": "2026-01-10T09:00:00.000Z", + "updatedAt": "2026-06-18T16:45:00.000Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateWorkflowV2", + "summary": "Update Workflow", + "description": "Rename a workflow, change its description, or move it between folders. Omitted fields keep their stored values, and at least one field must be supplied. Editing the workflow's graph is not part of this endpoint — use import/export for that. Returns 404 when the workflow does not exist or you do not have write access to it (existence is not leaked).", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/workflows/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"Customer Support Agent v2\"\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkflowBody" + }, + "examples": { + "rename": { + "summary": "Rename", + "value": { + "name": "Customer Support Agent v2" + } + }, + "moveToRoot": { + "summary": "Move out of its folder to the workspace root", + "value": { + "folderId": null + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated workflow.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/WorkflowListItem" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent v2", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": false, + "deployedAt": null, + "runCount": 0, + "lastRunAt": null, + "createdAt": "2026-06-29T21:30:00.000Z", + "updatedAt": "2026-06-30T08:12:00.000Z" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "description": "A workflow with the target name already exists in the destination folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteWorkflowV2", + "summary": "Delete Workflow", + "description": "Archive a workflow. The workflow moves to Recently Deleted rather than being dropped, so its execution logs stay attributable, and it stops being returned by the list and detail endpoints. The last remaining workflow in a workspace cannot be deleted (400).", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/workflows/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "responses": { + "200": { + "description": "The workflow was archived.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/DeleteWorkflowResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "deleted": true + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } } }, - "/api/v2/workflows/{id}": { + "/api/v2/workflows/{id}/versions": { "get": { - "operationId": "getWorkflow", - "summary": "Get Workflow", - "description": "Retrieve a single workflow, including its workflow-level variables and trigger input field definitions. Returns 404 when the workflow does not exist or you do not have access to it (existence is not leaked).", + "operationId": "listWorkflowVersionsV2", + "summary": "List Workflow Versions", + "description": "List a workflow's deployment versions, newest first. Every successful deploy appends a version; these are the version numbers `POST /api/v2/workflows/{id}/rollback` accepts. Results are cursor-paginated — follow `nextCursor` and stop when it is `null`.", "tags": ["Workflows"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}/versions\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], "parameters": [ { "$ref": "#/components/parameters/WorkflowId" + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of versions to return per page. Must be between 1 and 100.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor returned from a previous request's `nextCursor` field. Omit for the first page.", + "schema": { + "type": "string" + } } ], "responses": { "200": { - "description": "The requested workflow.", + "description": "A page of deployment versions, newest first.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "Deployment versions for the current page.", + "items": { + "$ref": "#/components/schemas/WorkflowVersion" + } + }, + "nextCursor": { + "type": "string", + "nullable": true, + "description": "Opaque cursor for fetching the next page. `null` when there are no more results." + } + } + }, + "example": { + "data": [ + { + "id": "d41a9f0c-7b25-4e18-9a3d-1c6f0b8e5d24", + "version": 3, + "name": "Adds escalation branch", + "description": "Routes P1 tickets straight to on-call", + "isActive": true, + "createdAt": "2026-06-12T10:30:00.000Z", + "deployedBy": "Ada Lovelace", + "latestOperationStatus": "active" + }, + { + "id": "b70e2c81-4d93-4a17-8f52-93a1c7e0d6b8", + "version": 2, + "name": null, + "description": null, + "isActive": false, + "createdAt": "2026-05-02T09:04:00.000Z", + "deployedBy": "Ada Lovelace", + "latestOperationStatus": null + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/versions/{version}": { + "get": { + "operationId": "getWorkflowVersionV2", + "summary": "Get Workflow Version", + "description": "Fetch one deployment version and the workflow state it pins. Use this to inspect or diff a version before activating it with `POST /api/v2/workflows/{id}/rollback`.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}/versions/{version}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + }, + { + "$ref": "#/components/parameters/VersionNumber" + } + ], + "responses": { + "200": { + "description": "The requested deployment version.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -230,43 +746,32 @@ "required": ["data"], "properties": { "data": { - "$ref": "#/components/schemas/WorkflowDetail" + "$ref": "#/components/schemas/WorkflowVersionDetail" } } }, "example": { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer Support Agent", - "description": "Routes incoming support tickets and drafts responses", - "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 142, - "lastRunAt": "2026-06-20T14:15:22.000Z", - "variables": { - "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": { - "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60", - "name": "supportEmail", - "type": "string", - "value": "support@example.com" - } - }, - "inputs": [ - { - "name": "ticketBody", - "type": "string", - "description": "The raw text of the incoming support ticket." - } - ], - "createdAt": "2026-01-10T09:00:00.000Z", - "updatedAt": "2026-06-18T16:45:00.000Z" + "id": "d41a9f0c-7b25-4e18-9a3d-1c6f0b8e5d24", + "version": 3, + "name": "Adds escalation branch", + "description": "Routes P1 tickets straight to on-call", + "isActive": true, + "createdAt": "2026-06-12T10:30:00.000Z", + "state": { + "blocks": {}, + "edges": [], + "loops": {}, + "parallels": {} + } } } } } }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, "401": { "$ref": "#/components/responses/Unauthorized" }, @@ -1293,6 +1798,17 @@ "type": "string", "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" } + }, + "VersionNumber": { + "name": "version", + "in": "path", + "required": true, + "description": "The deployment version number, as returned by the version list.", + "schema": { + "type": "integer", + "minimum": 1, + "example": 3 + } } }, "headers": { @@ -1788,6 +2304,185 @@ "type": "number" } } + }, + "CreateWorkflowBody": { + "type": "object", + "description": "Request body for creating a workflow.", + "required": ["workspaceId", "name"], + "additionalProperties": false, + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace to create the workflow in. Requires write access.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Workflow name. Must be unique within the target folder.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "maxLength": 50000, + "nullable": true, + "description": "Optional description of what the workflow does.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": "string", + "minLength": 1, + "nullable": true, + "description": "Folder to create the workflow in. Omit or send `null` to create it at the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + } + } + }, + "UpdateWorkflowBody": { + "type": "object", + "description": "Request body for updating a workflow's metadata. Omitted fields keep their stored values; at least one field is required.", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "New workflow name. Must be unique within the destination folder.", + "example": "Customer Support Agent v2" + }, + "description": { + "type": "string", + "maxLength": 50000, + "nullable": true, + "description": "New description. Send `null` to clear it.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": "string", + "minLength": 1, + "nullable": true, + "description": "Destination folder. Send `null` to move the workflow to the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + } + } + }, + "DeleteWorkflowResult": { + "type": "object", + "description": "Acknowledgement that a workflow was archived.", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The archived workflow's identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "deleted": { + "type": "boolean", + "enum": [true], + "description": "Always `true` on a successful archive." + } + } + }, + "WorkflowVersion": { + "type": "object", + "description": "A deployment version of a workflow, as returned by the version list.", + "required": ["id", "version", "isActive", "createdAt"], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier of the deployment version record.", + "example": "d41a9f0c-7b25-4e18-9a3d-1c6f0b8e5d24" + }, + "version": { + "type": "integer", + "description": "Monotonically increasing version number. Pass this to the rollback endpoint.", + "example": 3 + }, + "name": { + "type": "string", + "nullable": true, + "description": "Optional label given to the version at deploy time. `null` when unset.", + "example": "Adds escalation branch" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional release note for the version. `null` when unset.", + "example": "Routes P1 tickets straight to on-call" + }, + "isActive": { + "type": "boolean", + "description": "Whether this version is the one currently serving executions.", + "example": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the version was created.", + "example": "2026-06-12T10:30:00.000Z" + }, + "deployedBy": { + "type": "string", + "nullable": true, + "description": "Display name of the user who deployed the version. `null` when the deployer is no longer resolvable.", + "example": "Ada Lovelace" + }, + "latestOperationStatus": { + "type": "string", + "nullable": true, + "enum": ["preparing", "activating", "active", "failed", "superseded"], + "description": "Lifecycle status of the workflow's current deploy attempt, present only on the version that attempt targets. `null` on every other version — a superseded attempt is history, not live state.", + "example": "active" + } + } + }, + "WorkflowVersionDetail": { + "type": "object", + "description": "A deployment version together with the workflow state it pins.", + "required": ["id", "version", "name", "description", "isActive", "createdAt", "state"], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier of the deployment version record.", + "example": "d41a9f0c-7b25-4e18-9a3d-1c6f0b8e5d24" + }, + "version": { + "type": "integer", + "description": "Monotonically increasing version number. Pass this to the rollback endpoint.", + "example": 3 + }, + "name": { + "type": "string", + "nullable": true, + "description": "Optional label given to the version at deploy time. `null` when unset.", + "example": "Adds escalation branch" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional release note for the version. `null` when unset.", + "example": "Routes P1 tickets straight to on-call" + }, + "isActive": { + "type": "boolean", + "description": "Whether this version is the one currently serving executions.", + "example": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the version was created.", + "example": "2026-06-12T10:30:00.000Z" + }, + "state": { + "type": "object", + "additionalProperties": true, + "description": "The deployed workflow graph snapshot (blocks, edges, loops, parallels). This is the state that executes while the version is active, and the state a rollback restores." + } + } } }, "responses": { diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 5fb64caf1df..2f994150296 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -30,6 +30,8 @@ export type ApiEndpoint = | 'workflow-detail' | 'workflow-deploy' | 'workflow-rollback' + | 'workflow-versions' + | 'workflow-version-detail' | 'workflow-export' | 'workflow-import' | 'audit-logs' diff --git a/apps/sim/app/api/v2/workflows/[id]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/route.test.ts new file mode 100644 index 00000000000..432027d8cc9 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/route.test.ts @@ -0,0 +1,355 @@ +/** + * @vitest-environment node + * + * Public v2 workflow update/delete: the 404 mask on an access failure (the + * caller never names a workspace, so a 403 would confirm the workflow exists), + * the 423 a workflow mutation lock produces, and the orchestration failure + * codes rendered in the v2 error envelope. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetActiveWorkflowRecord, + mockPerformUpdateWorkflow, + mockPerformDeleteWorkflow, + mockAssertWorkflowMutable, + mockAssertFolderMutable, + mockAssertFolderInWorkspace, + WorkflowLockedErrorMock, + FolderLockedErrorMock, + FolderNotFoundErrorMock, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetActiveWorkflowRecord: vi.fn(), + mockPerformUpdateWorkflow: vi.fn(), + mockPerformDeleteWorkflow: vi.fn(), + mockAssertWorkflowMutable: vi.fn(), + mockAssertFolderMutable: vi.fn(), + mockAssertFolderInWorkspace: vi.fn(), + WorkflowLockedErrorMock: class WorkflowLockedError extends Error { + status = 423 + }, + FolderLockedErrorMock: class FolderLockedError extends Error { + status = 423 + }, + FolderNotFoundErrorMock: class FolderNotFoundError extends Error { + status = 400 + }, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performUpdateWorkflow: mockPerformUpdateWorkflow, + performDeleteWorkflow: mockPerformDeleteWorkflow, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + getActiveWorkflowRecord: mockGetActiveWorkflowRecord, + assertWorkflowMutable: mockAssertWorkflowMutable, + assertFolderMutable: mockAssertFolderMutable, + assertFolderInWorkspace: mockAssertFolderInWorkspace, + WorkflowLockedError: WorkflowLockedErrorMock, + FolderLockedError: FolderLockedErrorMock, + FolderNotFoundError: FolderNotFoundErrorMock, +})) + +vi.mock('@/lib/workflows/input-format', () => ({ + extractInputFieldsFromBlocks: vi.fn().mockReturnValue([]), +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, PATCH } from '@/app/api/v2/workflows/[id]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +const WORKFLOW_RECORD = { + id: 'wf-1', + name: 'Support Agent', + description: 'Handles tickets', + folderId: null, + workspaceId: 'workspace-1', + isDeployed: true, + deployedAt: new Date('2024-01-03T00:00:00Z'), + runCount: 12, + lastRunAt: new Date('2024-01-04T00:00:00Z'), + locked: false, + forkSyncExcluded: false, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), +} + +const UPDATED = { + id: 'wf-1', + name: 'Support Agent v2', + description: 'Handles tickets', + workspaceId: 'workspace-1', + folderId: null, + sortOrder: 0, + locked: false, + forkSyncExcluded: false, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-05T00:00:00Z'), + archivedAt: null, +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'wf-1' }) }) + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/workflows/wf-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + routeContext() + ) +} + +const callDelete = () => + DELETE( + new NextRequest('http://localhost:3000/api/v2/workflows/wf-1', { method: 'DELETE' }), + routeContext() + ) + +describe('PATCH /api/v2/workflows/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) + mockAssertWorkflowMutable.mockResolvedValue(undefined) + mockAssertFolderMutable.mockResolvedValue(undefined) + mockAssertFolderInWorkspace.mockResolvedValue(undefined) + mockPerformUpdateWorkflow.mockResolvedValue({ success: true, workflow: UPDATED }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPatch({ name: 'Support Agent v2' }) + + expect(res.status).toBe(404) + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('400s when no field to change is supplied', async () => { + const res = await callPatch({}) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('masks an access-denied failure as 404 so existence is not leaked', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPatch({ name: 'Support Agent v2' }) + expect(res.status).toBe(404) + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ name: 'Support Agent v2' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the workflow does not exist or is archived', async () => { + mockGetActiveWorkflowRecord.mockResolvedValue(null) + const res = await callPatch({ name: 'Support Agent v2' }) + expect(res.status).toBe(404) + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('423s the denial when the workflow is locked rather than failing with a 500', async () => { + mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedErrorMock('Workflow is locked')) + const res = await callPatch({ name: 'Support Agent v2' }) + expect(res.status).toBe(423) + expect((await res.json()).error.code).toBe('LOCKED') + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('423s when the destination folder is locked', async () => { + mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) + const res = await callPatch({ folderId: 'fld-1' }) + expect(res.status).toBe(423) + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('400s a folder outside the workspace without ever reading its lock state', async () => { + mockAssertFolderInWorkspace.mockRejectedValue( + new FolderNotFoundErrorMock('Target folder not found') + ) + const res = await callPatch({ folderId: 'fld-other-workspace' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + // Containment runs first, so a locked foreign folder cannot be told apart + // from a nonexistent one by its status code. + expect(mockAssertFolderMutable).not.toHaveBeenCalled() + expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + }) + + it('checks folder containment against the workflow workspace before mutability', async () => { + const order: string[] = [] + mockAssertFolderInWorkspace.mockImplementation(async () => { + order.push('containment') + }) + mockAssertFolderMutable.mockImplementation(async () => { + order.push('mutability') + }) + + await callPatch({ folderId: 'fld-1' }) + + expect(order).toEqual(['containment', 'mutability']) + expect(mockAssertFolderInWorkspace).toHaveBeenCalledWith('fld-1', 'workspace-1') + }) + + it('skips the containment check on a rename that does not move the workflow', async () => { + await callPatch({ name: 'Support Agent v2' }) + expect(mockAssertFolderInWorkspace).not.toHaveBeenCalled() + }) + + it('409s when the target name is taken in the destination folder', async () => { + mockPerformUpdateWorkflow.mockResolvedValue({ + success: false, + error: 'A workflow named "Support Agent v2" already exists in this folder', + errorCode: 'conflict', + }) + const res = await callPatch({ name: 'Support Agent v2' }) + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('updates the workflow and carries the untouched deployment counters through', async () => { + const res = await callPatch({ name: 'Support Agent v2' }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body).toEqual({ + data: { + id: 'wf-1', + name: 'Support Agent v2', + description: 'Handles tickets', + folderId: null, + workspaceId: 'workspace-1', + isDeployed: true, + deployedAt: '2024-01-03T00:00:00.000Z', + runCount: 12, + lastRunAt: '2024-01-04T00:00:00.000Z', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-05T00:00:00.000Z', + }, + }) + expect(mockPerformUpdateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'wf-1', + userId: 'user-1', + workspaceId: 'workspace-1', + currentName: 'Support Agent', + currentFolderId: null, + name: 'Support Agent v2', + }) + ) + }) +}) + +describe('DELETE /api/v2/workflows/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) + mockAssertWorkflowMutable.mockResolvedValue(undefined) + mockPerformDeleteWorkflow.mockResolvedValue({ success: true }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callDelete() + + expect(res.status).toBe(404) + expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() + }) + + it('masks an access-denied failure as 404 so existence is not leaked', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callDelete() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the workflow does not exist or is already archived', async () => { + mockGetActiveWorkflowRecord.mockResolvedValue(null) + const res = await callDelete() + expect(res.status).toBe(404) + expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() + }) + + it('423s the denial when the workflow is locked rather than failing with a 500', async () => { + mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedErrorMock('Workflow is locked')) + const res = await callDelete() + expect(res.status).toBe(423) + expect((await res.json()).error.code).toBe('LOCKED') + expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() + }) + + it('400s when it is the last workflow in the workspace', async () => { + mockPerformDeleteWorkflow.mockResolvedValue({ + success: false, + error: 'Cannot delete the only workflow in the workspace', + errorCode: 'validation', + }) + const res = await callDelete() + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('only workflow') + }) + + it('archives the workflow and acknowledges the delete', async () => { + const res = await callDelete() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: { id: 'wf-1', deleted: true } }) + expect(mockPerformDeleteWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'wf-1', userId: 'user-1' }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index 7698187bb7a..a3b22e05dc0 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -1,23 +1,48 @@ import { db } from '@sim/db' import { workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' +import { + assertFolderInWorkspace, + assertFolderMutable, + assertWorkflowMutable, + FolderLockedError, + FolderNotFoundError, + getActiveWorkflowRecord, + WorkflowLockedError, +} from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { type V2WorkflowDetail, v2GetWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { + type V2WorkflowDetail, + type V2WorkflowListItem, + v2DeleteWorkflowContract, + v2GetWorkflowContract, + v2UpdateWorkflowContract, +} from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' const logger = createLogger('V2WorkflowDetailAPI') export const revalidate = 0 +interface RouteContext { + params: Promise<{ id: string }> +} + export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateId().slice(0, 8) @@ -84,3 +109,145 @@ export const GET = withRouteHandler( } } ) + +/** PATCH /api/v2/workflows/[id] — Rename, re-describe, or move a workflow. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { name, description, folderId } = parsed.data.body + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess( + rateLimit, + userId, + workflowData.workspaceId, + 'write' + ) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + /** + * Ownership before lock state: `assertFolderMutable` walks the folder's + * ancestor chain without filtering on workspace, so checking it first would + * let a caller distinguish a locked folder in someone else's workspace + * (423) from one that simply does not exist (400). + */ + if (folderId) await assertFolderInWorkspace(folderId, workflowData.workspaceId) + await assertWorkflowMutable(id) + if (folderId !== undefined) await assertFolderMutable(folderId) + + const result = await performUpdateWorkflow({ + workflowId: id, + userId, + workspaceId: workflowData.workspaceId, + currentName: workflowData.name, + currentFolderId: workflowData.folderId, + name, + description, + folderId, + requestId, + }) + + if (!result.success || !result.workflow) { + return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to update workflow') + } + + const updated = result.workflow + /** + * Deployment and run counters are untouched by a metadata update, so they + * come from the record read above rather than a second query. + */ + const item: V2WorkflowListItem = { + id: updated.id, + name: updated.name, + description: updated.description, + folderId: updated.folderId, + workspaceId: updated.workspaceId ?? workflowData.workspaceId, + isDeployed: workflowData.isDeployed, + deployedAt: workflowData.deployedAt?.toISOString() ?? null, + runCount: workflowData.runCount, + lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, + createdAt: updated.createdAt.toISOString(), + updatedAt: updated.updatedAt.toISOString(), + } + + return v2Data(item, { rateLimit }) + } catch (error) { + if (error instanceof FolderNotFoundError) return v2Error('BAD_REQUEST', error.message) + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + return v2Error('LOCKED', error.message) + } + + logger.error(`[${requestId}] Workflow update error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/workflows/[id] — Archive a workflow into Recently Deleted. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess( + rateLimit, + userId, + workflowData.workspaceId, + 'write' + ) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + await assertWorkflowMutable(id) + + const result = await performDeleteWorkflow({ workflowId: id, userId, requestId }) + if (!result.success) { + return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to delete workflow') + } + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + if (error instanceof WorkflowLockedError) return v2Error('LOCKED', error.message) + + logger.error(`[${requestId}] Workflow delete error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts new file mode 100644 index 00000000000..72e3811cb6e --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts @@ -0,0 +1,154 @@ +/** + * @vitest-environment node + * + * Public v2 deployment-version detail: the 404 mask on an access failure, the + * coerced numeric version param, and the pinned workflow state it serves. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetActiveWorkflowRecord, + mockGetWorkflowDeploymentVersion, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetActiveWorkflowRecord: vi.fn(), + mockGetWorkflowDeploymentVersion: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + getActiveWorkflowRecord: mockGetActiveWorkflowRecord, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + getWorkflowDeploymentVersion: mockGetWorkflowDeploymentVersion, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/workflows/[id]/versions/[version]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +const WORKFLOW_RECORD = { id: 'wf-1', name: 'Support Agent', workspaceId: 'workspace-1' } + +const DEPLOYED_STATE = { blocks: {}, edges: [], loops: {}, parallels: {} } + +const VERSION_ROW = { + id: 'dv-3', + version: 3, + name: 'Escalation branch', + description: null, + isActive: true, + createdAt: new Date('2024-01-03T00:00:00Z'), + state: DEPLOYED_STATE, +} + +const routeContext = (version = '3') => ({ params: Promise.resolve({ id: 'wf-1', version }) }) +const callGet = (version = '3') => + GET( + new NextRequest(`http://localhost:3000/api/v2/workflows/wf-1/versions/${version}`), + routeContext(version) + ) + +describe('GET /api/v2/workflows/[id]/versions/[version]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) + mockGetWorkflowDeploymentVersion.mockResolvedValue(VERSION_ROW) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() + }) + + it('400s on a non-numeric version', async () => { + const res = await callGet('latest') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() + }) + + it('masks an access-denied failure as 404 so existence is not leaked', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(404) + expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the workflow does not exist or is archived', async () => { + mockGetActiveWorkflowRecord.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() + }) + + it('404s when the version does not exist on this workflow', async () => { + mockGetWorkflowDeploymentVersion.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect((await res.json()).error.message).toBe('Deployment version not found') + }) + + it('returns the version with the workflow state it pins', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body).toEqual({ + data: { + id: 'dv-3', + version: 3, + name: 'Escalation branch', + description: null, + isActive: true, + createdAt: '2024-01-03T00:00:00.000Z', + state: DEPLOYED_STATE, + }, + }) + expect(mockGetWorkflowDeploymentVersion).toHaveBeenCalledWith('wf-1', 3) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts new file mode 100644 index 00000000000..d8096bf5ea5 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts @@ -0,0 +1,74 @@ +import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { + type V2WorkflowVersionDetail, + v2GetWorkflowVersionContract, +} from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowVersionDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/workflows/[id]/versions/[version] — Fetch one deployment version + * and the workflow state it pins. + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; version: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-version-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetWorkflowVersionContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id, version } = parsed.data.params + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + const row = await getWorkflowDeploymentVersion(id, version) + if (!row?.state) return v2Error('NOT_FOUND', 'Deployment version not found') + + const detail: V2WorkflowVersionDetail = { + id: row.id, + version: row.version, + name: row.name, + description: row.description, + isActive: row.isActive, + createdAt: row.createdAt.toISOString(), + state: row.state as V2WorkflowVersionDetail['state'], + } + + return v2Data(detail, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflow version fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) 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 new file mode 100644 index 00000000000..53025c2d07d --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.test.ts @@ -0,0 +1,221 @@ +/** + * @vitest-environment node + * + * Public v2 deployment-version listing: the 404 mask on an access failure, the + * public projection (no raw `createdBy` user id), and the version-keyed cursor. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetActiveWorkflowRecord, + mockListWorkflowVersions, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetActiveWorkflowRecord: vi.fn(), + mockListWorkflowVersions: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + getActiveWorkflowRecord: mockGetActiveWorkflowRecord, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + listWorkflowVersions: mockListWorkflowVersions, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/workflows/[id]/versions/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +const WORKFLOW_RECORD = { id: 'wf-1', name: 'Support Agent', workspaceId: 'workspace-1' } + +function buildVersion(version: number, overrides: Record = {}) { + return { + id: `dv-${version}`, + version, + name: null, + description: null, + isActive: false, + createdAt: new Date(`2024-01-0${version}T00:00:00Z`), + createdBy: 'user-9', + deployedByName: 'Ada Lovelace', + latestOperationStatus: null, + ...overrides, + } +} + +const ALL_VERSIONS = [ + buildVersion(3, { isActive: true, name: 'Escalation branch', latestOperationStatus: 'active' }), + buildVersion(2), + buildVersion(1), +] + +const routeContext = () => ({ params: Promise.resolve({ id: 'wf-1' }) }) +const callGet = (query = '') => + GET( + new NextRequest(`http://localhost:3000/api/v2/workflows/wf-1/versions${query}`), + routeContext() + ) + +describe('GET /api/v2/workflows/[id]/versions', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) + /** + * Stands in for the keyset query the helper now runs, so the route's + * has-more probe and cursor round-trip are exercised against realistic + * `limit`/`afterVersion` behavior rather than a fixed array. + */ + mockListWorkflowVersions.mockImplementation( + async (_workflowId: string, options: { limit?: number; afterVersion?: number } = {}) => { + let versions = ALL_VERSIONS + if (options.afterVersion !== undefined) { + versions = versions.filter((row) => row.version < options.afterVersion!) + } + if (options.limit !== undefined) versions = versions.slice(0, options.limit) + return { versions } + } + ) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockListWorkflowVersions).not.toHaveBeenCalled() + }) + + it('400s on an out-of-range limit', async () => { + const res = await callGet('?limit=0') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListWorkflowVersions).not.toHaveBeenCalled() + }) + + it('masks an access-denied failure as 404 so existence is not leaked', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callGet() + expect(res.status).toBe(404) + expect(mockListWorkflowVersions).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callGet() + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('404s when the workflow does not exist or is archived', async () => { + mockGetActiveWorkflowRecord.mockResolvedValue(null) + const res = await callGet() + expect(res.status).toBe(404) + expect(mockListWorkflowVersions).not.toHaveBeenCalled() + }) + + it('returns the public version shape newest-first, without the raw creator id', async () => { + const res = await callGet() + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toHaveLength(3) + expect(body.data[0]).toEqual({ + id: 'dv-3', + version: 3, + name: 'Escalation branch', + description: null, + isActive: true, + createdAt: '2024-01-03T00:00:00.000Z', + deployedBy: 'Ada Lovelace', + latestOperationStatus: 'active', + }) + expect(body.data[0]).not.toHaveProperty('createdBy') + // Paging is pushed into the helper — the route never reads the full set. + expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1', { + limit: 51, + afterVersion: undefined, + }) + }) + + it('bounds the read to one page plus the has-more probe', async () => { + await callGet('?limit=2') + expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1', { + limit: 3, + afterVersion: undefined, + }) + }) + + it('pushes the cursor down to the helper as a keyset bound', async () => { + const cursor = Buffer.from(JSON.stringify({ version: 3 })).toString('base64') + await callGet(`?limit=2&cursor=${encodeURIComponent(cursor)}`) + expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1', { limit: 3, afterVersion: 3 }) + }) + + it('400s a structurally invalid cursor instead of silently truncating the list', async () => { + // Decodes to valid JSON with no numeric `version` — the shape that would + // otherwise filter every row out and report a clean end-of-list. + const bogus = Buffer.from(JSON.stringify({ offset: 2 })).toString('base64') + const res = await callGet(`?cursor=${encodeURIComponent(bogus)}`) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListWorkflowVersions).not.toHaveBeenCalled() + }) + + it('400s a cursor that is not decodable at all', async () => { + const res = await callGet('?cursor=not-a-cursor') + expect(res.status).toBe(400) + expect(mockListWorkflowVersions).not.toHaveBeenCalled() + }) + + it('pages with a version-keyed cursor', async () => { + const first = await callGet('?limit=2') + const firstBody = await first.json() + + expect(firstBody.data.map((v: { version: number }) => v.version)).toEqual([3, 2]) + expect(firstBody.nextCursor).toEqual(expect.any(String)) + + const second = await callGet(`?limit=2&cursor=${encodeURIComponent(firstBody.nextCursor)}`) + const secondBody = await second.json() + + expect(secondBody.data.map((v: { version: number }) => v.version)).toEqual([1]) + expect(secondBody.nextCursor).toBeNull() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts new file mode 100644 index 00000000000..82c26667795 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts @@ -0,0 +1,111 @@ +import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { + type V2WorkflowVersion, + v2ListWorkflowVersionsContract, +} from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listWorkflowVersions } from '@/lib/workflows/persistence/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowVersionsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** Keyset cursor over the dense, strictly-descending version number. */ +interface WorkflowVersionCursor { + version: number +} + +/** + * GET /api/v2/workflows/[id]/versions — List a workflow's deployment versions, + * newest first. These are the versions `POST /api/v2/workflows/[id]/rollback` + * accepts, so a caller no longer has to guess a version number. + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-versions') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ListWorkflowVersionsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { limit, cursor } = parsed.data.query + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + /** + * A cursor that decodes to anything other than a version number is + * rejected rather than ignored: comparing every row against a missing + * `version` yields an empty page with `nextCursor: null`, which reads to + * the caller as a clean end-of-list while versions are still pending. + */ + const after = cursor ? decodeCursor(cursor) : null + if (cursor && (!after || !Number.isInteger(after.version) || after.version < 1)) { + return v2Error('BAD_REQUEST', 'Invalid cursor') + } + + // One extra row is the has-more probe, matching the other v2 cursor lists. + const { versions: rows } = await listWorkflowVersions(id, { + limit: limit + 1, + afterVersion: after?.version, + }) + + const hasMore = rows.length > limit + const page = rows.slice(0, limit) + + const data: V2WorkflowVersion[] = page.map((row) => ({ + id: row.id, + version: row.version, + name: row.name, + description: row.description, + isActive: row.isActive, + createdAt: row.createdAt.toISOString(), + deployedBy: row.deployedByName, + // The shared helper widens the operation-status pg enum to `string`. + latestOperationStatus: + row.latestOperationStatus as V2WorkflowVersion['latestOperationStatus'], + })) + + const nextCursor = + hasMore && data.length > 0 ? encodeCursor({ version: data[data.length - 1].version }) : null + + return v2CursorList(data, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflow versions fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts index 5c2b922e40b..6b3e0ee67c2 100644 --- a/apps/sim/app/api/v2/workflows/route.test.ts +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -16,9 +16,26 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceAccess } = vi.hoisted(() => ({ +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockPerformCreateWorkflow, + mockAssertFolderMutable, + mockAssertFolderInWorkspace, + FolderLockedErrorMock, + FolderNotFoundErrorMock, +} = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), + mockPerformCreateWorkflow: vi.fn(), + mockAssertFolderMutable: vi.fn(), + mockAssertFolderInWorkspace: vi.fn(), + FolderLockedErrorMock: class FolderLockedError extends Error { + status = 423 + }, + FolderNotFoundErrorMock: class FolderNotFoundError extends Error { + status = 400 + }, })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -26,11 +43,22 @@ vi.mock('@/app/api/v1/middleware', () => ({ resolveWorkspaceAccess: mockResolveWorkspaceAccess, })) +vi.mock('@/lib/workflows/orchestration', () => ({ + performCreateWorkflow: mockPerformCreateWorkflow, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mockAssertFolderMutable, + assertFolderInWorkspace: mockAssertFolderInWorkspace, + FolderLockedError: FolderLockedErrorMock, + FolderNotFoundError: FolderNotFoundErrorMock, +})) + vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null), })) -import { GET } from '@/app/api/v2/workflows/route' +import { GET, POST } from '@/app/api/v2/workflows/route' const WS = 'workspace-1' @@ -210,3 +238,187 @@ describe('GET /api/v2/workflows', () => { expect(dbChainMockFns.where).not.toHaveBeenCalled() }) }) + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + +const CREATED = { + id: 'wf-1', + name: 'Support Agent', + description: 'Handles tickets', + workspaceId: 'workspace-1', + folderId: null, + sortOrder: 0, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + startBlockId: 'block-1', + subBlockValues: {}, +} + +const VALID_BODY = { + workspaceId: 'workspace-1', + name: 'Support Agent', + description: 'Handles tickets', +} + +function callPost(body: unknown) { + return POST( + new NextRequest('http://localhost:3000/api/v2/workflows', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +describe('POST /api/v2/workflows', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockAssertFolderMutable.mockResolvedValue(undefined) + mockAssertFolderInWorkspace.mockResolvedValue(undefined) + mockPerformCreateWorkflow.mockResolvedValue({ success: true, workflow: CREATED }) + }) + + it('returns 404 when the v2 API surface flag is off', async () => { + const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') + const { v2Error } = await import('@/app/api/v2/lib/response') + vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const res = await callPost(VALID_BODY) + + expect(res.status).toBe(404) + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('400s when name is missing', async () => { + const res = await callPost({ workspaceId: 'workspace-1' }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('400s on an unknown body field', async () => { + const res = await callPost({ ...VALID_BODY, sortOrder: 3 }) + expect(res.status).toBe(400) + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) + const res = await callPost(VALID_BODY) + expect(res.status).toBe(403) + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('requires write access on the target workspace', async () => { + await callPost(VALID_BODY) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'workspace-1', + 'write' + ) + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPost(VALID_BODY) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('423s when the destination folder is locked', async () => { + mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) + const res = await callPost({ ...VALID_BODY, folderId: 'fld-1' }) + expect(res.status).toBe(423) + expect((await res.json()).error.code).toBe('LOCKED') + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('400s a folder outside the workspace without ever reading its lock state', async () => { + mockAssertFolderInWorkspace.mockRejectedValue( + new FolderNotFoundErrorMock('Target folder not found') + ) + const res = await callPost({ ...VALID_BODY, folderId: 'fld-other-workspace' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + // Containment runs first, so a locked foreign folder cannot be told apart + // from a nonexistent one by its status code. + expect(mockAssertFolderMutable).not.toHaveBeenCalled() + expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() + }) + + it('checks folder containment before mutability', async () => { + const order: string[] = [] + mockAssertFolderInWorkspace.mockImplementation(async () => { + order.push('containment') + }) + mockAssertFolderMutable.mockImplementation(async () => { + order.push('mutability') + }) + + await callPost({ ...VALID_BODY, folderId: 'fld-1' }) + + expect(order).toEqual(['containment', 'mutability']) + expect(mockAssertFolderInWorkspace).toHaveBeenCalledWith('fld-1', 'workspace-1') + }) + + it('skips the containment check when no folder is supplied', async () => { + await callPost(VALID_BODY) + expect(mockAssertFolderInWorkspace).not.toHaveBeenCalled() + expect(mockAssertFolderMutable).toHaveBeenCalledWith(null) + }) + + it('409s when the name is already taken in the target folder', async () => { + mockPerformCreateWorkflow.mockResolvedValue({ + success: false, + error: 'A workflow named "Support Agent" already exists in this folder', + errorCode: 'conflict', + }) + const res = await callPost(VALID_BODY) + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('creates the workflow and returns 201 with the public shape', async () => { + const res = await callPost(VALID_BODY) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body).toEqual({ + data: { + id: 'wf-1', + name: 'Support Agent', + description: 'Handles tickets', + folderId: null, + workspaceId: 'workspace-1', + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + }) + expect(res.headers.get('X-RateLimit-Remaining')).toBe('99') + expect(mockPerformCreateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + workspaceId: 'workspace-1', + name: 'Support Agent', + description: 'Handles tickets', + folderId: undefined, + }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index 0706f835c53..88d9b457264 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -1,6 +1,12 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { + assertFolderInWorkspace, + assertFolderMutable, + FolderLockedError, + FolderNotFoundError, +} from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' @@ -8,6 +14,7 @@ import type { NextRequest } from 'next/server' import { type V2WorkflowListItem, type V2WorkflowSortBy, + v2CreateWorkflowContract, v2ListWorkflowsContract, } from '@/lib/api/contracts/v2/workflows' import { @@ -23,6 +30,7 @@ import { } from '@/lib/api/list-query' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performCreateWorkflow } from '@/lib/workflows/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { @@ -31,7 +39,9 @@ import { encodeSortedCursor, v2CursorList, v2CursorSortError, + v2Data, v2Error, + v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, @@ -171,3 +181,78 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return v2Error('INTERNAL_ERROR', 'Internal server error') } }) + +/** POST /api/v2/workflows — Create an empty workflow in a workspace. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateWorkflowContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, name, description, folderId } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + /** + * Ownership before lock state: `assertFolderMutable` walks the folder's + * ancestor chain without filtering on workspace, so checking it first would + * let a caller distinguish a locked folder in someone else's workspace + * (423) from one that simply does not exist (400). + */ + if (folderId) await assertFolderInWorkspace(folderId, workspaceId) + await assertFolderMutable(folderId ?? null) + + const result = await performCreateWorkflow({ + userId, + workspaceId, + name, + description, + folderId, + requestId, + }) + + if (!result.success || !result.workflow) { + return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to create workflow') + } + + const created = result.workflow + const item: V2WorkflowListItem = { + id: created.id, + name: created.name, + description: created.description ?? null, + folderId: created.folderId ?? null, + workspaceId: created.workspaceId, + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + createdAt: created.createdAt.toISOString(), + updatedAt: created.updatedAt.toISOString(), + } + + return v2Data(item, { rateLimit, status: 201 }) + } catch (error) { + if (error instanceof FolderNotFoundError) return v2Error('BAD_REQUEST', error.message) + if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) + + logger.error(`[${requestId}] Workflow create error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index db8e82adb6b..1d87bfe4edb 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -8,7 +8,7 @@ import { } from '@/lib/workflows/deployment-lifecycle' import type { WorkflowState } from '@/stores/workflows/workflow/types' -const deployedWorkflowStateSchema = z.custom( +export const deployedWorkflowStateSchema = z.custom( (value) => typeof value === 'object' && value !== null, 'Expected workflow state' ) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 76e944480b9..119ae6ba1a0 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -1,4 +1,10 @@ import { z } from 'zod' +import { + deployedWorkflowStateSchema, + deploymentVersionParamsSchema, + deploymentVersionSchema, +} from '@/lib/api/contracts/deployments' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v1DeployWorkflowDataSchema, @@ -30,6 +36,11 @@ import { * payloads reuse the already-concrete v1 schemas, re-wrapped in * `v2DataResponse` (the v1 `limits` body field is dropped — v2 carries * rate-limit state in headers and usage on a dedicated endpoint). + * + * The create/update bodies have no v1 counterpart and are v2-native: they carry + * only the fields a public caller owns (name, description, folder placement). + * `sortOrder`, `locked`, and `forkSyncExcluded` are workspace-UI concerns and + * are not part of the public surface. */ /** @@ -123,6 +134,139 @@ export const v2GetWorkflowContract = defineRouteContract({ }, }) +/** + * Create body. `workspaceId` is required — personal (workspace-less) workflows + * are not creatable on any surface. Name collisions inside the target folder + * are a 409 rather than being silently deduplicated: a public caller that asked + * for a name should learn it was taken, not discover "My Agent (2)" later. + */ +export const v2CreateWorkflowBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), + description: z.string().max(50_000, 'description is too long').nullable().optional(), + /** Explicit `null` (or omission) creates the workflow at the workspace root. */ + folderId: z.string().min(1, 'folderId cannot be empty').nullable().optional(), + }) + .strict() +export type V2CreateWorkflowBody = z.input + +/** Update body. Omitted fields keep their stored values. */ +export const v2UpdateWorkflowBodySchema = z + .object({ + name: z.string().trim().min(1, 'name cannot be empty').max(255, 'name is too long').optional(), + description: z.string().max(50_000, 'description is too long').nullable().optional(), + folderId: z.string().min(1, 'folderId cannot be empty').nullable().optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.name === undefined && body.description === undefined && body.folderId === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: 'At least one of name, description, or folderId is required', + }) + } + }) +export type V2UpdateWorkflowBody = z.input + +/** + * Delete acknowledgement. Deletion archives the workflow (it lands in Recently + * Deleted) rather than dropping its rows, so runs and logs stay attributable. + */ +export const v2DeleteWorkflowDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2DeleteWorkflowData = z.output + +export const v2CreateWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows', + body: v2CreateWorkflowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowListItemSchema), + }, +}) + +export const v2UpdateWorkflowContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/workflows/[id]', + params: workflowIdParamsSchema, + body: v2UpdateWorkflowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowListItemSchema), + }, +}) + +export const v2DeleteWorkflowContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/workflows/[id]', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteWorkflowDataSchema), + }, +}) + +/** + * A deployment version as the public surface sees it: the internal row minus + * `createdBy`, which is a raw user id with no public resolution path — + * `deployedBy` already carries the human-readable name. + */ +export const v2WorkflowVersionSchema = deploymentVersionSchema.omit({ createdBy: true }) +export type V2WorkflowVersion = z.output + +/** + * Version listing is cursor-paginated: a workflow accrues one version per + * deploy and nothing prunes them, so the set is unbounded. The cursor is keyed + * on the version number, which is dense and strictly descending. + */ +export const v2ListWorkflowVersionsQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).optional().default(50), + cursor: z.string().optional(), +}) +export type V2ListWorkflowVersionsQuery = 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 + * serves — and is the thing a caller diffs before rolling back to it. + */ +export const v2WorkflowVersionDetailSchema = z.object({ + id: z.string(), + version: z.number().int().positive(), + name: z.string().nullable(), + description: z.string().nullable(), + isActive: z.boolean(), + createdAt: z.string(), + state: deployedWorkflowStateSchema, +}) +export type V2WorkflowVersionDetail = z.output + +export const v2ListWorkflowVersionsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]/versions', + params: workflowIdParamsSchema, + query: v2ListWorkflowVersionsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowVersionSchema), + }, +}) + +export const v2GetWorkflowVersionContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]/versions/[version]', + params: deploymentVersionParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowVersionDetailSchema), + }, +}) + export const v2DeployWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/[id]/deploy', diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 584d96cc566..4b72877d086 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -937,7 +937,21 @@ export async function getWorkflowDeploymentVersion( return row ?? null } -export async function listWorkflowVersions(workflowId: string): Promise<{ +export interface ListWorkflowVersionsOptions { + /** Caps the rows read. Omitted reads every version. */ + limit?: number + /** + * Keyset bound for the `version DESC` ordering: returns only versions + * strictly below this number, i.e. the page *after* it. Paired with `limit` + * this keeps a paginated caller off a full-table read. + */ + afterVersion?: number +} + +export async function listWorkflowVersions( + workflowId: string, + options: ListWorkflowVersionsOptions = {} +): Promise<{ versions: Array<{ id: string version: number @@ -952,22 +966,29 @@ export async function listWorkflowVersions(workflowId: string): Promise<{ }> { const { user } = await import('@sim/db') + const versionConditions = [eq(workflowDeploymentVersion.workflowId, workflowId)] + if (options.afterVersion !== undefined) { + versionConditions.push(lt(workflowDeploymentVersion.version, options.afterVersion)) + } + + const versionQuery = db + .select({ + id: workflowDeploymentVersion.id, + version: workflowDeploymentVersion.version, + name: workflowDeploymentVersion.name, + description: workflowDeploymentVersion.description, + isActive: workflowDeploymentVersion.isActive, + createdAt: workflowDeploymentVersion.createdAt, + createdBy: workflowDeploymentVersion.createdBy, + deployedByName: user.name, + }) + .from(workflowDeploymentVersion) + .leftJoin(user, eq(workflowDeploymentVersion.createdBy, user.id)) + .where(and(...versionConditions)) + .orderBy(desc(workflowDeploymentVersion.version)) + const [rows, [currentOperation]] = await Promise.all([ - db - .select({ - id: workflowDeploymentVersion.id, - version: workflowDeploymentVersion.version, - name: workflowDeploymentVersion.name, - description: workflowDeploymentVersion.description, - isActive: workflowDeploymentVersion.isActive, - createdAt: workflowDeploymentVersion.createdAt, - createdBy: workflowDeploymentVersion.createdBy, - deployedByName: user.name, - }) - .from(workflowDeploymentVersion) - .leftJoin(user, eq(workflowDeploymentVersion.createdBy, user.id)) - .where(eq(workflowDeploymentVersion.workflowId, workflowId)) - .orderBy(desc(workflowDeploymentVersion.version)), + options.limit !== undefined ? versionQuery.limit(options.limit) : versionQuery, /** * Only the workflow's current (latest-generation) operation carries a * status marker: a failed or in-flight attempt is live information until diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index c52c7f59a55..f8a7a617938 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1046, - zodRoutes: 1046, + totalRoutes: 1048, + zodRoutes: 1048, nonZodRoutes: 0, } as const From 5692261f8a54542e0444dcff45661426617937e7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 17:58:30 -0700 Subject: [PATCH 049/159] feat(api): expand v2 tables with stateless multipart transfers (#6188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): expand the public v2 tables surface Adds 16 operations so a v2 caller can do what the internal surface can: rename/move/lock a table, restore it, manage saved views, run enrichment columns, look up rows, and import/export with observable job control. Extracts lib/table/orchestration/import.ts (performTableCsvImport, performCreateTableFromCsv) and lib/table/export-stream.ts from the first-party routes, then repoints those routes at them, so v1 and v2 cannot drift on what an import or export actually does. events/stream, metadata and dispatches stay internal — they are editor state, not public API. * fix(api): make v2 table PATCH all-or-nothing and name the lock in every 423 Greptile P1: PATCH applied locks, rename and move as three sequential transactions, so a folder rejected mid-request left the earlier writes persisted while the response reported failure — and the schema-changed signal was skipped, leaving open clients on stale state. Every rejectable condition now runs before the first write, and the signal fires whenever anything did land. Cursor: v2TableLockError dropped the lock kind, so async import, column run, enrichment and table mutations returned a bare LOCKED. A table has four independent locks, so the caller could not tell which to clear. * fix(api): report the lock kind on classified 423s too, not just thrown ones The previous commit named the lock only where the rejection was thrown and caught at the route boundary. Where it instead arrives as a classified `errorCode: 'locked'` outcome — delete table, delete row, update column, and the table mutations — the kind was dropped, so those 423s stayed unactionable while their neighbours improved. The orchestration results now carry `lock`, and a shared `v2TableOrchestrationError` renders both arrival paths into the same `{ code, message, details: { lock } }` body. `details` is omitted rather than sent null when the kind is unknown, so a caller branching on it sees absence instead of a phantom value. * fix(api): make async table imports observable, not just startable `POST /import-async` pointed callers at `GET /api/v2/tables/jobs` to track progress, but that endpoint filters to `type = 'export'` — imports are derived onto the table itself, one write job at a time, and exports get a separate list precisely because they are excluded from that derivation. The public Table shape omitted those derived fields, so an async import could be started and cancelled but never observed to completion, failure, or progress. That is the gap the import/export/job-control set was meant to close. Table now carries `job` — id, type, status, rowsProcessed, error, or null when idle — and the import-async docs point at the table rather than the export list. * feat(api): make v2 table PATCH state which operations landed on failure Greptile held the PR at 4/5 on the residual non-atomicity and named two acceptable resolutions: make PATCH atomic, or have the contract adopt and expose partial-success explicitly. Atomicity would mean threading one transaction through renameTable, moveTableToFolder and updateTableLocks — three shared service functions with four non-test callers including the first-party route and two copilot tools — and deferring their per-operation audits to commit time. That is a refactor of shared write paths well outside this PR. So the contract states it instead. Every rejectable condition is already pre-validated, so a failure here is a genuine fault; when one follows a successful operation the error now carries `details.applied` listing what is live. Absent when nothing applied, so its presence always means "these changes took effect despite the error". Documented on the operation. `v2ErrorForOrchestration` gained the optional `details` this needs. * fix(api): make table lock flags read-only on the public v2 surface The new PATCH /api/v2/tables/[tableId] accepted a `locks` object, gated on workspace admin plus the table-locks feature. That still lets an API key clear the guard placed there to stop it: `write` is the floor for the endpoint, and admin keys are ordinary API keys, so a lock is no longer a boundary the key cannot cross. Locks stay readable on the table resource and enforcement is unchanged (a locked verb still returns 423). Changing one is now a first-party admin action only. The v2 body is declared here rather than reusing the first-party updateTableBodySchema, which keeps its `locks` field so the UI can still toggle them. It is .strict(), so a request carrying `locks` is rejected with a 400 naming the field instead of silently succeeding without applying it. * fix(api): keep reporting applied operations when the PATCH re-read fails The composite table PATCH promises that `error.details.applied` names the operations that are live despite an error, but `applied` was scoped inside the try. A rename or move that committed and was then followed by a throw in the final re-read — or a re-read finding the table archived — returned a bare 500/404 with no details, telling the caller nothing had landed. It would then retry into a duplicate-name conflict or repeat the move. `applied` is now function-scoped so every post-write exit carries it: the 404 on a missing re-read, a thrown lock error, a classified orchestration error, and the generic 500. `v2TableLockError` gains the same `extraDetails` parameter `v2TableOrchestrationError` already had. * feat(api): add workflow group writes to the v2 tables surface v2 exposed GET /groups but none of the writes, so the public API could run an enrichment or workflow column and read its binding, but never create one. A caller could add a plain data column and trigger the machine; wiring the two together still required the UI. Adds POST/PATCH/DELETE on /api/v2/tables/[tableId]/groups. The group is the unit that fills columns — one group feeds several — so creating one creates its output columns in the same call, matching the first-party shape rather than inverting it onto the column endpoint. Four departures from the first-party body, all public-surface concerns: - group.id is optional and server-generated. The UI mints an id to render optimistically; a public caller has no such need and a client-chosen id is a collision waiting to happen. - outputColumns[].workflowGroupId is dropped from the body and stamped from the resolved group, so it cannot disagree with it. - autoRun defaults to false. First-party defaults true so a UI add fills cells immediately; here it would make one POST fan out a metered run across every existing row. - A group naming neither a workflowId (type manual) nor an enrichmentId (type enrichment) is a 400 rather than a half-specified group the route has to guess about. Also rejects an outputColumns entry no group output feeds — the two arrays are joined by column name, and the first-party client builds both from one picker so it cannot desync, but a public caller can. Workspace containment on workflowId is asserted before it is persisted, on create and on any update that re-points the group; without it a table becomes a way to invoke workflows the key cannot otherwise reach. * improvement(api): make v2 table import and export async-only Drops the three synchronous entry points: POST /tables/[tableId]/import, POST /tables/import-csv, and GET /tables/[tableId]/export. Sync import tied a write to the lifetime of an HTTP request. The body *was* the data, so it carried a 10 MB cap that Next silently truncates past — a partial import reporting success. It also had no job, so a timeout mid-write left rows in place with nothing to poll and nothing to cancel. The async path reads the file from storage instead: upload via POST /api/v2/files for a key, start with POST /import-async, watch GET /tables/[tableId] -> job, stop with POST /job/cancel. Sync export carried no such hazard, but one shape per operation beats two: with both removed the surface has exactly one way to move a table in or out, and the CLI wraps the extra calls. This also removes the last multipart handling in v2 tables. Those were the only routes bypassing parseRequest — form fields were parsed by hand against separate form schemas, outside the contract system every other v2 write goes through. Create-a-table-from-CSV is now two calls: POST /tables, then /import-async with createColumns. csvImportModeSchema is append|replace, so there is no single-call create. Route baseline 1064 -> 1061. * docs(api): correct the import-async note about upload size limits The docstring claimed there is no synchronous upload endpoint and so no request-body size cliff. Both are wrong: POST /api/v2/files is a synchronous multipart upload with a 100 MB cap, and it is the only v2 upload path (presigned is deliberately absent). What async-only actually bought: the cap went 10 MB -> 100 MB, it fails on an explicit size check and a bounded body read rather than a proxy cap that silently truncates, authorization completes before any body is buffered, and the table write is a job that can be watched and cancelled. * feat(api): unify file and table transfers * improvement(api): make multipart transfers stateless * fix(api): make table import completion retries idempotent --- apps/docs/openapi-v2-files-audit.json | 129 +- apps/docs/openapi-v2-tables.json | 4902 ++++- .../uploads/[uploadId]/complete/route.ts | 64 + .../files/uploads/[uploadId]/parts/route.ts | 45 + .../app/api/files/uploads/[uploadId]/route.ts | 38 + apps/sim/app/api/files/uploads/route.ts | 39 + apps/sim/app/api/files/uploads/utils.ts | 29 + .../app/api/table/[tableId]/export/route.ts | 100 +- .../app/api/table/[tableId]/exports/route.ts | 39 + .../app/api/table/[tableId]/import/route.ts | 294 +- .../exports/[exportId]/download/route.ts | 47 + .../app/api/table/exports/[exportId]/route.ts | 68 + apps/sim/app/api/table/import-csv/route.ts | 161 +- .../imports/[importId]/complete/route.ts | 52 + .../table/imports/[importId]/parts/route.ts | 39 + .../app/api/table/imports/[importId]/route.ts | 76 + apps/sim/app/api/table/imports/route.ts | 27 + apps/sim/app/api/v1/middleware.ts | 9 + apps/sim/app/api/v2/files/route.test.ts | 175 +- apps/sim/app/api/v2/files/route.ts | 143 +- .../uploads/[uploadId]/complete/route.ts | 95 + .../files/uploads/[uploadId]/parts/route.ts | 63 + .../api/v2/files/uploads/[uploadId]/route.ts | 57 + .../app/api/v2/files/uploads/route.test.ts | 148 + apps/sim/app/api/v2/files/uploads/route.ts | 61 + apps/sim/app/api/v2/files/uploads/utils.ts | 38 + apps/sim/app/api/v2/lib/response.ts | 8 +- .../[tableId]/cancel-runs/route.test.ts | 192 + .../v2/tables/[tableId]/cancel-runs/route.ts | 100 + .../api/v2/tables/[tableId]/columns/route.ts | 5 +- .../[tableId]/columns/run/route.test.ts | 195 + .../v2/tables/[tableId]/columns/run/route.ts | 118 + .../api/v2/tables/[tableId]/exports/route.ts | 67 + .../v2/tables/[tableId]/groups/route.test.ts | 437 + .../api/v2/tables/[tableId]/groups/route.ts | 367 + .../v2/tables/[tableId]/restore/route.test.ts | 190 + .../api/v2/tables/[tableId]/restore/route.ts | 88 + .../app/api/v2/tables/[tableId]/route.test.ts | 382 +- apps/sim/app/api/v2/tables/[tableId]/route.ts | 183 +- .../enrichment/[groupId]/route.test.ts | 160 + .../[rowId]/enrichment/[groupId]/route.ts | 96 + .../[tableId]/rows/[rowId]/route.test.ts | 23 + .../v2/tables/[tableId]/rows/[rowId]/route.ts | 10 +- .../tables/[tableId]/rows/find/route.test.ts | 207 + .../v2/tables/[tableId]/rows/find/route.ts | 116 + .../[tableId]/views/[viewId]/route.test.ts | 240 + .../tables/[tableId]/views/[viewId]/route.ts | 183 + .../v2/tables/[tableId]/views/route.test.ts | 204 + .../api/v2/tables/[tableId]/views/route.ts | 127 + .../exports/[exportId]/download/route.ts | 69 + .../api/v2/tables/exports/[exportId]/route.ts | 92 + .../imports/[importId]/complete/route.test.ts | 115 + .../imports/[importId]/complete/route.ts | 75 + .../tables/imports/[importId]/parts/route.ts | 60 + .../api/v2/tables/imports/[importId]/route.ts | 99 + apps/sim/app/api/v2/tables/imports/route.ts | 53 + apps/sim/app/api/v2/tables/utils.ts | 136 +- .../[uploadId]/parts/[partNumber]/route.ts | 56 + .../components/file-viewer/csv-import.ts | 26 +- .../components/file-viewer/file-viewer.tsx | 1 + .../components/file-viewer/preview-panel.tsx | 4 +- .../components/file-viewer/text-editor.tsx | 1 + .../resource-content/resource-content.tsx | 15 +- .../[tableId]/hooks/use-table-event-stream.ts | 2 +- .../[workspaceId]/tables/[tableId]/table.tsx | 20 +- .../import-csv-dialog/import-csv-dialog.tsx | 104 +- .../import-progress-menu.tsx | 6 +- .../use-workspace-imports.ts | 5 +- .../workspace/[workspaceId]/tables/tables.tsx | 167 +- apps/sim/background/cleanup-soft-deletes.ts | 11 +- .../lib/copy/storage-quota.ts | 2 +- apps/sim/hooks/queries/tables.ts | 411 +- apps/sim/hooks/queries/workspace-files.ts | 124 +- apps/sim/lib/api/contracts/table-transfers.ts | 95 + apps/sim/lib/api/contracts/tables.ts | 163 +- apps/sim/lib/api/contracts/upload-sessions.ts | 68 + apps/sim/lib/api/contracts/v2/files.ts | 100 +- apps/sim/lib/api/contracts/v2/tables.ts | 726 +- apps/sim/lib/api/contracts/v2/uploads.ts | 53 + apps/sim/lib/api/list-query.ts | 2 +- .../sim/lib/billing/storage/payer-transfer.ts | 4 +- apps/sim/lib/table/export-stream.ts | 100 + apps/sim/lib/table/import-runner.ts | 13 +- apps/sim/lib/table/orchestration/columns.ts | 6 +- .../table/orchestration/export-resource.ts | 129 + .../table/orchestration/import-resource.ts | 455 + .../lib/table/orchestration/import.test.ts | 235 + apps/sim/lib/table/orchestration/import.ts | 514 + apps/sim/lib/table/orchestration/index.ts | 1 + .../lib/table/orchestration/tables.test.ts | 7 +- apps/sim/lib/table/orchestration/tables.ts | 18 +- apps/sim/lib/table/service.ts | 1 + apps/sim/lib/table/types.ts | 19 +- apps/sim/lib/table/views/service.test.ts | 35 + apps/sim/lib/table/views/service.ts | 15 + .../uploads/client/multipart-session.test.ts | 86 + .../lib/uploads/client/multipart-session.ts | 88 + apps/sim/lib/uploads/client/session-upload.ts | 68 + apps/sim/lib/uploads/config.ts | 3 + .../workspace/workspace-file-manager.ts | 38 +- apps/sim/lib/uploads/core/storage-service.ts | 18 +- .../sim/lib/uploads/core/upload-token.test.ts | 63 + apps/sim/lib/uploads/core/upload-token.ts | 34 + .../lib/uploads/multipart-session/provider.ts | 319 + .../lib/uploads/multipart-session/service.ts | 386 + apps/sim/lib/uploads/providers/blob/client.ts | 4 +- apps/sim/lib/uploads/server/metadata.ts | 11 +- apps/sim/lib/uploads/shared/types.ts | 12 + apps/sim/stores/table/import-tray/store.ts | 6 +- packages/db/migrations/0280_smart_la_nuit.sql | 1 + .../db/migrations/meta/0280_snapshot.json | 18376 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 3 + scripts/check-api-validation-contracts.ts | 4 +- 114 files changed, 32330 insertions(+), 2246 deletions(-) create mode 100644 apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts create mode 100644 apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts create mode 100644 apps/sim/app/api/files/uploads/[uploadId]/route.ts create mode 100644 apps/sim/app/api/files/uploads/route.ts create mode 100644 apps/sim/app/api/files/uploads/utils.ts create mode 100644 apps/sim/app/api/table/[tableId]/exports/route.ts create mode 100644 apps/sim/app/api/table/exports/[exportId]/download/route.ts create mode 100644 apps/sim/app/api/table/exports/[exportId]/route.ts create mode 100644 apps/sim/app/api/table/imports/[importId]/complete/route.ts create mode 100644 apps/sim/app/api/table/imports/[importId]/parts/route.ts create mode 100644 apps/sim/app/api/table/imports/[importId]/route.ts create mode 100644 apps/sim/app/api/table/imports/route.ts create mode 100644 apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts create mode 100644 apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts create mode 100644 apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts create mode 100644 apps/sim/app/api/v2/files/uploads/route.test.ts create mode 100644 apps/sim/app/api/v2/files/uploads/route.ts create mode 100644 apps/sim/app/api/v2/files/uploads/utils.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/exports/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/groups/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/restore/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/views/route.ts create mode 100644 apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts create mode 100644 apps/sim/app/api/v2/tables/exports/[exportId]/route.ts create mode 100644 apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts create mode 100644 apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts create mode 100644 apps/sim/app/api/v2/tables/imports/[importId]/route.ts create mode 100644 apps/sim/app/api/v2/tables/imports/route.ts create mode 100644 apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts create mode 100644 apps/sim/lib/api/contracts/table-transfers.ts create mode 100644 apps/sim/lib/api/contracts/upload-sessions.ts create mode 100644 apps/sim/lib/api/contracts/v2/uploads.ts create mode 100644 apps/sim/lib/table/export-stream.ts create mode 100644 apps/sim/lib/table/orchestration/export-resource.ts create mode 100644 apps/sim/lib/table/orchestration/import-resource.ts create mode 100644 apps/sim/lib/table/orchestration/import.test.ts create mode 100644 apps/sim/lib/table/orchestration/import.ts create mode 100644 apps/sim/lib/uploads/client/multipart-session.test.ts create mode 100644 apps/sim/lib/uploads/client/multipart-session.ts create mode 100644 apps/sim/lib/uploads/client/session-upload.ts create mode 100644 apps/sim/lib/uploads/core/upload-token.test.ts create mode 100644 apps/sim/lib/uploads/multipart-session/provider.ts create mode 100644 apps/sim/lib/uploads/multipart-session/service.ts create mode 100644 packages/db/migrations/0280_smart_la_nuit.sql create mode 100644 packages/db/migrations/meta/0280_snapshot.json diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 476d289159e..c379903774c 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -169,7 +169,7 @@ } } }, - "post": { + "x-removed-buffered-post": { "operationId": "uploadFile", "summary": "Upload File", "description": "Upload a file to a workspace as `multipart/form-data` with a single `file` field. The workspace — and the optional target `folderId` — are supplied as query parameters (not form fields) so authorization runs before the request body is buffered. Maximum file size is 100MB. A name already taken in the destination folder is **not** an error: the name is auto-suffixed (`data.csv` -> `data (1).csv`), matching the in-app uploader, so a `201` can come back with a `name` different from the one you sent — always read `name` from the response rather than assuming it. `409` is returned only if a unique name cannot be allocated after several attempts. Use `PATCH /api/v2/files/{fileId}` if you need a specific name to be exact-or-fail. Returns `201 Created`.\n\nPresigned upload is not part of the public API: it debits the storage quota only in a separate register step, so a caller that never registers would leave unaccounted bytes in storage. This buffered path debits inside the upload transaction.", @@ -315,6 +315,126 @@ } } }, + "/api/v2/files/uploads": { + "post": { + "operationId": "createFileUpload", + "summary": "Create File Upload", + "description": "Create a stateless multipart upload session and signed upload token. Every file uses this flow; a small file is a single part. The maximum file size is 5 GB.", + "tags": ["Files"], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": {} } } + }, + "responses": { + "201": { + "description": "The upload session.", + "content": { "application/json": { "schema": {} } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/files/uploads/{uploadId}": { + "delete": { + "operationId": "abortFileUpload", + "summary": "Abort File Upload", + "description": "Abort an incomplete upload and discard its provider parts.", + "tags": ["Files"], + "parameters": [ + { + "name": "uploadId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/UploadTokenHeader" } + ], + "responses": { + "200": { + "description": "The aborted upload session.", + "content": { "application/json": { "schema": {} } } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/files/uploads/{uploadId}/parts": { + "post": { + "operationId": "createFileUploadPartUrls", + "summary": "Create File Upload Part URLs", + "description": "Issue short-lived signed PUT URLs for a bounded set of upload part numbers.", + "tags": ["Files"], + "parameters": [ + { + "name": "uploadId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/UploadTokenHeader" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": {} } } + }, + "responses": { + "200": { + "description": "Signed URLs for the requested parts.", + "content": { "application/json": { "schema": {} } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/files/uploads/{uploadId}/complete": { + "post": { + "operationId": "completeFileUpload", + "summary": "Complete File Upload", + "description": "Verify every part, assemble the object, and atomically register the workspace file.", + "tags": ["Files"], + "parameters": [ + { + "name": "uploadId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/UploadTokenHeader" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": {} } } + }, + "responses": { + "200": { + "description": "The completed upload and registered file.", + "content": { "application/json": { "schema": {} } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, "/api/v2/files/{fileId}": { "get": { "operationId": "downloadFile", @@ -1557,6 +1677,13 @@ "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" } }, + "UploadTokenHeader": { + "name": "upload-token", + "in": "header", + "required": true, + "description": "The signed token returned when the multipart upload was created.", + "schema": { "type": "string", "minLength": 1 } + }, "FileIdPath": { "name": "fileId", "in": "path", diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 00f523d23eb..5212bd88b78 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -55,14 +55,21 @@ "in": "query", "required": false, "description": "Restrict the list to one folder. Omit to list every table in the workspace.", - "schema": { "type": "string", "minLength": 1 } + "schema": { + "type": "string", + "minLength": 1 + } }, { "name": "search", "in": "query", "required": false, "description": "Case-insensitive substring match against the table `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", - "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, { "name": "sortBy", @@ -80,7 +87,11 @@ "in": "query", "required": false, "description": "Sort direction.", - "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } } ], "responses": { @@ -128,7 +139,15 @@ "rowCount": 2, "maxRows": 100000, "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-01-15T10:30:00.000Z" + "updatedAt": "2026-01-15T10:30:00.000Z", + "folderId": null, + "locks": { + "schemaLocked": false, + "insertLocked": false, + "updateLocked": false, + "deleteLocked": false + }, + "job": null } ], "nextCursor": null @@ -229,7 +248,15 @@ "rowCount": 0, "maxRows": 100000, "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-01-15T10:30:00.000Z" + "updatedAt": "2026-01-15T10:30:00.000Z", + "folderId": null, + "locks": { + "schemaLocked": false, + "insertLocked": false, + "updateLocked": false, + "deleteLocked": false + }, + "job": null } } } @@ -385,6 +412,130 @@ "$ref": "#/components/responses/InternalError" } } + }, + "patch": { + "operationId": "updateTable", + "summary": "Update Table", + "description": "Rename a table and/or move it between folders. Provide at least one of `name` or `folderId`. Each field is applied independently, so one request can rename and move at once, and the response reflects every applied change.\n\nBoth fields need workspace write.\n\n**Lock flags are read-only here.** A table's `locks` are returned on the table resource and enforced on every write (a locked verb returns 423), but they cannot be changed through the API — a write-level key must not be able to clear the guard placed there to stop it. Changing a lock is a first-party workspace-admin action. A request carrying `locks` is rejected with 400 rather than silently ignored.\n\n**Partial-success semantics.** The two operations commit independently, so this endpoint is not atomic. Everything that can be *rejected* — the body shape, folder existence — is validated before the first write, so a rejected request changes nothing. If a genuine fault (a lost race, the table archived mid-request, a database error) fails the later operation after the earlier one has committed, the response is an error whose `error.details.applied` lists the operations that are nevertheless live (`\"name\"`, `\"folderId\"`). The field is absent when nothing was applied, so its presence always means \"these changes took effect despite the error\" — re-read the table to confirm before retrying.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"name\":\"customers\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTableBody" + }, + "examples": { + "rename": { + "summary": "Rename", + "value": { + "workspaceId": "ws_123", + "name": "customers" + } + }, + "move": { + "summary": "Move to the workspace root", + "value": { + "workspaceId": "ws_123", + "folderId": null + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated table.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableEnvelope" + }, + "example": { + "data": { + "table": { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "customers", + "description": "Customer contact records", + "schema": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + } + ] + }, + "rowCount": 42, + "maxRows": 100000, + "folderId": "fld_7a1c3e5d9b2f4068a3c5e7d9f1b3a507", + "locks": { + "schemaLocked": false, + "insertLocked": false, + "updateLocked": false, + "deleteLocked": true + }, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z", + "job": null + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } } }, "/api/v2/tables/{tableId}/columns": { @@ -489,7 +640,7 @@ "patch": { "operationId": "updateTableColumn", "summary": "Update Column", - "description": "Update a column by name \u2014 rename it, change its type, or toggle its required/unique constraints. Provide at least one field in `updates`. Returns the table's full column list after the change.", + "description": "Update a column by name — rename it, change its type, or toggle its required/unique constraints. Provide at least one field in `updates`. Returns the table's full column list after the change.", "tags": ["Tables"], "x-codeSamples": [ { @@ -633,7 +784,7 @@ "get": { "operationId": "listTableRows", "summary": "List rows", - "description": "Plain cursor page over the default row order. Filtering and sorting are not part of this surface \u2014 use `POST /api/v2/tables/{tableId}/query` for predicate-filtered, sorted reads. The cursor is opaque; page by passing the previous response's `nextCursor` back as `cursor` and stop when it is `null`.", + "description": "Plain cursor page over the default row order. Filtering and sorting are not part of this surface — use `POST /api/v2/tables/{tableId}/query` for predicate-filtered, sorted reads. The cursor is opaque; page by passing the previous response's `nextCursor` back as `cursor` and stop when it is `null`.", "tags": ["Tables"], "x-codeSamples": [ { @@ -1369,7 +1520,7 @@ "post": { "operationId": "queryTableRows", "summary": "Query Rows", - "description": "Query rows with a typed predicate filter, an ordered sort spec, and opaque cursor pagination. Row `data` is keyed by column NAME; `select` cells return option names, and filter operands on select columns accept option names (resolved case-insensitively).\n\n**Pagination contract:** page by passing the previous response's `nextCursor` back as `cursor`, and stop only when it is `null` \u2014 a page may return fewer than `limit` rows and still have more behind it, so page fullness is never a termination signal. A cursor is bound to the exact query shape it was minted under: keyset cursors to the default row order, offset cursors (sorted views) to that sort. Replaying one under a different `sort` returns 400 `CURSOR_SORT_CONFLICT`.", + "description": "Query rows with a typed predicate filter, an ordered sort spec, and opaque cursor pagination. Row `data` is keyed by column NAME; `select` cells return option names, and filter operands on select columns accept option names (resolved case-insensitively).\n\n**Pagination contract:** page by passing the previous response's `nextCursor` back as `cursor`, and stop only when it is `null` — a page may return fewer than `limit` rows and still have more behind it, so page fullness is never a termination signal. A cursor is bound to the exact query shape it was minted under: keyset cursors to the default row order, offset cursors (sorted views) to that sort. Replaying one under a different `sort` returns 400 `CURSOR_SORT_CONFLICT`.", "tags": ["Tables"], "parameters": [ { @@ -1414,7 +1565,7 @@ "minimum": 0, "maximum": 1000, "default": 100, - "description": "Omitted \u2192 100. `1..1000` \u2192 page size. `0` \u2192 the ENTIRE matching result in one response; fails with 400 `TABLE_QUERY_RESULT_TOO_LARGE` if it exceeds the 5 MB row-data budget (narrow the predicate or page instead)." + "description": "Omitted → 100. `1..1000` → page size. `0` → the ENTIRE matching result in one response; fails with 400 `TABLE_QUERY_RESULT_TOO_LARGE` if it exceeds the 5 MB row-data budget (narrow the predicate or page instead)." }, "cursor": { "type": "string", @@ -1527,1005 +1678,4306 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." - } - }, - "parameters": { - "TableId": { - "name": "tableId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" - }, - "description": "The unique identifier of the table." - }, - "RowId": { - "name": "rowId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" - }, - "description": "The unique identifier of the row." - }, - "WorkspaceIdQuery": { - "name": "workspaceId", - "in": "query", - "required": true, - "schema": { - "type": "string", - "minLength": 1 - }, - "description": "The unique identifier of the workspace that owns the table." - }, - "LimitQuery": { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum rows to return (1-1000, default 100).", - "schema": { - "type": "integer", - "default": 100, - "minimum": 1, - "maximum": 1000 - } - }, - "CursorQuery": { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque pagination cursor. Pass the `nextCursor` from a previous response to fetch the next page. Omit for the first page.", - "schema": { - "type": "string", - "minLength": 1 - } - } - }, - "headers": { - "RateLimitLimit": { - "description": "Maximum number of requests permitted in the current rate-limit window.", - "schema": { - "type": "integer" - } - }, - "RateLimitRemaining": { - "description": "Number of requests remaining in the current rate-limit window.", - "schema": { - "type": "integer" - } - }, - "RateLimitReset": { - "description": "ISO 8601 timestamp at which the current rate-limit window resets.", - "schema": { - "type": "string", - "format": "date-time" - } - }, - "RetryAfter": { - "description": "Number of seconds to wait before retrying the request.", - "schema": { - "type": "integer" - } - } }, - "schemas": { - "V2Error": { - "type": "object", - "description": "Canonical v2 error envelope.", - "required": ["error"], - "properties": { - "error": { - "type": "object", - "required": ["code", "message"], - "properties": { - "code": { - "type": "string", - "description": "Machine-readable error code.", - "example": "BAD_REQUEST" - }, - "message": { - "type": "string", - "description": "Human-readable error message." + "/api/v2/tables/{tableId}/restore": { + "post": { + "operationId": "restoreTable", + "summary": "Restore Table", + "description": "Un-archive a table archived by `DELETE /api/v2/tables/{tableId}`, along with its rows. Requires workspace write. Returns 409 when a different active table has since taken the archived table’s name — rename that table first, then retry.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/restore\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceScopedBody" }, - "details": { - "description": "Optional structured error details, such as per-field validation issues." + "example": { + "workspaceId": "ws_123" } } } - } - }, - "Column": { - "type": "object", - "description": "A column definition in a table schema.", - "required": ["name", "type"], - "properties": { - "id": { - "type": "string", - "description": "Stable server-assigned column id. May be absent on legacy columns created before id backfill.", - "example": "col_a1b2c3" - }, - "name": { - "type": "string", + }, + "responses": { + "200": { + "description": "The restored table.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableEnvelope" + }, + "example": { + "data": { + "table": { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "customers", + "description": "Customer contact records", + "schema": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + } + ] + }, + "rowCount": 42, + "maxRows": 100000, + "folderId": "fld_7a1c3e5d9b2f4068a3c5e7d9f1b3a507", + "locks": { + "schemaLocked": false, + "insertLocked": false, + "updateLocked": false, + "deleteLocked": true + }, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z", + "job": null + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/views": { + "get": { + "operationId": "listTableViews", + "summary": "List Views", + "description": "Every saved view on the table, oldest first. A table carries a bounded set of views, so this is a single full page and `nextCursor` is always null. References to columns that no longer exist are pruned from each config on read.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The table’s saved views.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewListEnvelope" + }, + "example": { + "data": [ + { + "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e", + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "Active customers", + "config": { + "hiddenColumns": ["col_x9y8z7"], + "filter": { + "all": [ + { + "field": "col_a1b2c3", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "col_d4e5f6", + "direction": "desc" + } + ] + }, + "isDefault": true, + "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248", + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createTableView", + "summary": "Create View", + "description": "Save a filter, sort, and column layout as a named view. A view is presentation state, never an access boundary — rows it hides stay readable through the row and query endpoints.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"name\":\"Active customers\",\"config\":{}}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateViewBody" + }, + "example": { + "workspaceId": "ws_123", + "name": "Active customers", + "config": { + "filter": { + "all": [ + { + "field": "col_a1b2c3", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "col_d4e5f6", + "direction": "desc" + } + ] + } + } + } + } + }, + "responses": { + "201": { + "description": "The created view.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewEnvelope" + }, + "example": { + "data": { + "view": { + "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e", + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "Active customers", + "config": { + "hiddenColumns": ["col_x9y8z7"], + "filter": { + "all": [ + { + "field": "col_a1b2c3", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "col_d4e5f6", + "direction": "desc" + } + ] + }, + "isDefault": true, + "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248", + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/views/{viewId}": { + "get": { + "operationId": "getTableView", + "summary": "Get View", + "description": "One saved view, with references to deleted columns pruned from its config.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views/{viewId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/ViewId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The requested view.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewEnvelope" + }, + "example": { + "data": { + "view": { + "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e", + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "Active customers", + "config": { + "hiddenColumns": ["col_x9y8z7"], + "filter": { + "all": [ + { + "field": "col_a1b2c3", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "col_d4e5f6", + "direction": "desc" + } + ] + }, + "isDefault": true, + "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248", + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateTableView", + "summary": "Update View", + "description": "Rename a view, replace or merge its config, or promote it to the table’s default. Provide at least one of `name`, `config`, `configPatch`, or `isDefault`.\n\n`config` replaces the stored config wholesale; `configPatch` is shallow-merged server-side so overlapping partial writes cannot clobber each other. The two are mutually exclusive. Setting `isDefault: true` demotes the table’s existing default in the same transaction.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views/{viewId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"isDefault\":true}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/ViewId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateViewBody" + }, + "examples": { + "promote": { + "summary": "Make this the table’s default view", + "value": { + "workspaceId": "ws_123", + "isDefault": true + } + }, + "replaceConfig": { + "summary": "Replace the saved filter", + "value": { + "workspaceId": "ws_123", + "config": { + "filter": { + "any": [ + { + "field": "col_a1b2c3", + "op": "isNotEmpty" + } + ] + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated view.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewEnvelope" + }, + "example": { + "data": { + "view": { + "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e", + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "Active customers", + "config": { + "hiddenColumns": ["col_x9y8z7"], + "filter": { + "all": [ + { + "field": "col_a1b2c3", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "col_d4e5f6", + "direction": "desc" + } + ] + }, + "isDefault": true, + "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248", + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-16T09:12:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableView", + "summary": "Delete View", + "description": "Remove a saved view. Deleting the table’s default simply leaves the table unfiltered; no rows are affected.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/views/{viewId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/ViewId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The view was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteViewEnvelope" + }, + "example": { + "data": { + "id": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/groups": { + "get": { + "operationId": "listTableWorkflowGroups", + "summary": "List Workflow Groups", + "description": "The table’s workflow and enrichment groups — the units the run endpoints dispatch. Read-only: groups are authored in the workflow builder. Bounded per table, so this is a single full page and `nextCursor` is always null.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/groups?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The table’s workflow groups.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowGroupListEnvelope" + }, + "example": { + "data": [ + { + "id": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", + "name": "Enrich company", + "type": "manual", + "dependencies": { + "columns": ["col_a1b2c3"] + }, + "outputs": [ + { + "blockId": "blk_agent1", + "path": "content", + "columnName": "summary" + } + ], + "deploymentMode": "deployed", + "autoRun": true + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "addTableWorkflowGroup", + "summary": "Add Workflow Group", + "description": "Bind a workflow or enrichment to the table and create the columns its runs populate, in one call.\n\nThe group is the unit that fills columns — one group can feed several. `group.outputs[].columnName` says where each value lands; `outputColumns` defines the columns to create. Every `outputColumns` entry must be named by an output, or the request is rejected rather than creating a column nothing feeds.\n\n`autoRun` defaults to **false**: enabling it backfills every existing row, which on an API key is a metered fan-out from a single call.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/groups\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"group\": {\n \"workflowId\": \"wf_...\",\n \"outputs\": [{\"blockId\": \"blk_7f2a\", \"path\": \"output.revenue\", \"columnName\": \"revenue\"}]\n },\n \"outputColumns\": [{\"name\": \"revenue\", \"type\": \"currency\"}]\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddWorkflowGroupBody" + }, + "examples": { + "workflow": { + "summary": "Workflow-backed column", + "value": { + "workspaceId": "ws_123", + "group": { + "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", + "name": "Enrich company", + "outputs": [ + { + "blockId": "blk_7f2a", + "path": "output.revenue", + "columnName": "revenue" + } + ] + }, + "outputColumns": [ + { + "name": "revenue", + "type": "currency" + } + ] + } + }, + "enrichment": { + "summary": "Registry enrichment filling two columns", + "value": { + "workspaceId": "ws_123", + "group": { + "type": "enrichment", + "enrichmentId": "company_lookup", + "outputs": [ + { + "outputId": "annual_revenue", + "columnName": "revenue" + }, + { + "outputId": "headquarters", + "columnName": "hq" + } + ] + }, + "outputColumns": [ + { + "name": "revenue", + "type": "currency" + }, + { + "name": "hq", + "type": "string" + } + ] + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The created group and the table's columns.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowGroupEnvelope" + }, + "example": { + "data": { + "group": { + "id": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", + "name": "Enrich company", + "type": "manual", + "outputs": [ + { + "blockId": "blk_7f2a", + "path": "output.revenue", + "columnName": "revenue" + } + ], + "deploymentMode": "deployed", + "autoRun": true + }, + "columns": [ + { + "id": "col_a1b2c3", + "name": "revenue", + "type": "currency", + "required": false, + "unique": false + } + ] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateTableWorkflowGroup", + "summary": "Update Workflow Group", + "description": "Restructure a group: re-point it at a different workflow, add or remove outputs, or change how its runs are scheduled.\n\n**Removing an output deletes that column and its values.** There is currently no way to detach a column from its group while keeping the data.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/groups\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"groupId\": \"grp_...\", \"name\": \"Renamed\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkflowGroupBody" + }, + "examples": { + "rename": { + "summary": "Rename", + "value": { + "workspaceId": "ws_123", + "groupId": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "name": "Renamed" + } + }, + "addOutput": { + "summary": "Add a second output column", + "value": { + "workspaceId": "ws_123", + "groupId": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "outputs": [ + { + "blockId": "blk_7f2a", + "path": "output.revenue", + "columnName": "revenue" + }, + { + "blockId": "blk_7f2a", + "path": "output.hq", + "columnName": "hq" + } + ], + "newOutputColumns": [ + { + "name": "hq", + "type": "string" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated group and the table's columns.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowGroupEnvelope" + }, + "example": { + "data": { + "group": { + "id": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", + "name": "Enrich company", + "type": "manual", + "outputs": [ + { + "blockId": "blk_7f2a", + "path": "output.revenue", + "columnName": "revenue" + } + ], + "deploymentMode": "deployed", + "autoRun": true + }, + "columns": [ + { + "id": "col_a1b2c3", + "name": "revenue", + "type": "currency", + "required": false, + "unique": false + } + ] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableWorkflowGroup", + "summary": "Delete Workflow Group", + "description": "Remove a group **and every column it fed**, along with their values. The surviving column list is returned so a caller does not have to re-read the table.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/groups\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"groupId\": \"grp_...\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteWorkflowGroupBody" + }, + "example": { + "workspaceId": "ws_123", + "groupId": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204" + } + } + } + }, + "responses": { + "200": { + "description": "The group was removed.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteWorkflowGroupEnvelope" + }, + "example": { + "data": { + "id": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204", + "deleted": true, + "columns": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/columns/run": { + "post": { + "operationId": "runTableColumns", + "summary": "Run Column Groups", + "description": "Run one or more workflow or enrichment groups and write their outputs into the table.\n\n**Asynchronous.** The response acknowledges the dispatch, not the results: the runner walks the scoped rows and writes cells as runs land. Poll `POST /api/v2/tables/{tableId}/query` for results, and stop an in-flight run with the same group ids and an empty scope.\n\nScope with `rowIds` (an explicit set) or `filter` (every matching row, walked in pages so no id list is materialized) — never both. Omit both to run every row. Starting a run clears the target groups’ cells to pending, so a read taken immediately after will show them empty.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns/run\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"groupIds\":[\"grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204\"]}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunColumnBody" + }, + "examples": { + "everyRow": { + "summary": "Run a group across the whole table", + "value": { + "workspaceId": "ws_123", + "groupIds": ["grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204"] + } + }, + "backfillFiltered": { + "summary": "Backfill only unfinished rows matching a predicate, capped at 500", + "value": { + "workspaceId": "ws_123", + "groupIds": ["grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204"], + "runMode": "incomplete", + "filter": { + "all": [ + { + "field": "status", + "op": "eq", + "value": "active" + } + ] + }, + "limit": { + "type": "rows", + "max": 500 + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The run was dispatched.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunEnvelope" + }, + "example": { + "data": { + "dispatchId": "dsp_4e6a8c0b2d1f4735896a0c2e4b6d8f13" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/{rowId}/enrichment/{groupId}": { + "post": { + "operationId": "runRowEnrichment", + "summary": "Run Enrichment For One Row", + "description": "The single-cell case of `POST /api/v2/tables/{tableId}/columns/run`: runs one group for one row. Naming a specific cell is an explicit re-run request, so an already-populated cell recomputes rather than being skipped.\n\n**Asynchronous** — the response acknowledges the dispatch; read the row back for the result.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}/enrichment/{groupId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + }, + { + "$ref": "#/components/parameters/GroupId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceScopedBody" + }, + "example": { + "workspaceId": "ws_123" + } + } + } + }, + "responses": { + "200": { + "description": "The run was dispatched.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunEnvelope" + }, + "example": { + "data": { + "dispatchId": "dsp_4e6a8c0b2d1f4735896a0c2e4b6d8f13" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/find": { + "post": { + "operationId": "findTableRows", + "summary": "Find Rows", + "description": "Case-insensitive substring search across every cell, narrowed by the same predicate and sort grammar as `POST /api/v2/tables/{tableId}/query`. Select cells match on their option names.\n\nReturns matching **cells**, not rows. Each match carries the row’s ordinal in exactly the view a query with the same `predicate` and `sort` returns, so a caller can page straight to it. Matches are capped server-side and have no cursor — when `truncated` is true, narrow the predicate rather than paging.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/find\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"q\":\"acme\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FindRowsBody" + }, + "examples": { + "wholeTable": { + "summary": "Search every cell", + "value": { + "workspaceId": "ws_123", + "q": "acme" + } + }, + "withinFilter": { + "summary": "Search inside a filtered, sorted view", + "value": { + "workspaceId": "ws_123", + "q": "acme", + "predicate": { + "all": [ + { + "field": "status", + "op": "eq", + "value": "active" + } + ] + }, + "sort": [ + { + "field": "name", + "direction": "asc" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The matching cells.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FindRowsEnvelope" + }, + "example": { + "data": { + "matches": [ + { + "ordinal": 12, + "rowId": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "column": "company" + } + ], + "truncated": false + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/jobs": { + "x-removed-get": { + "operationId": "listTableJobs", + "summary": "List Export Jobs", + "description": "Export jobs across a workspace — running ones plus recently finished ones, so a completed export stays re-downloadable. Poll this after `POST /export-async`, then fetch the file from `GET /export/download` once a job reports `ready`.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/jobs?workspaceId=YOUR_WORKSPACE_ID&type=export\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/JobTypeQuery" + } + ], + "responses": { + "200": { + "description": "The workspace’s export jobs.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableJobListEnvelope" + }, + "example": { + "data": [ + { + "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248", + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "tableName": "customers", + "status": "ready", + "rowsProcessed": 12043, + "format": "csv", + "hasResult": true, + "error": null + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/import-async": { + "x-removed-post": { + "operationId": "importTableCsvAsync", + "summary": "Import CSV (Background)", + "description": "Start a background import of a file already uploaded to workspace storage — the path for files too large for the synchronous import.\n\nReturns as soon as the job is queued. Track it on the table itself — `GET /api/v2/tables/{tableId}` returns a `job` object with `status`, `rowsProcessed` and `error` while the import runs — and stop it with `POST /job/cancel`. (`GET /api/v2/tables/jobs` lists exports only: those run concurrently and are not derived onto the table.) `fileKey` must sit under this workspace’s storage prefix. The table’s lock flags are checked before the job slot is claimed, so a locked table answers 423 here rather than failing inside the worker.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/import-async\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"fileKey\":\"workspace/YOUR_WORKSPACE_ID/imports/contacts.csv\",\"fileName\":\"contacts.csv\",\"mode\":\"append\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportAsyncBody" + }, + "example": { + "workspaceId": "ws_123", + "fileKey": "workspace/ws_123/imports/contacts.csv", + "fileName": "contacts.csv", + "mode": "append" + } + } + } + }, + "responses": { + "200": { + "description": "The import was queued.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportAsyncEnvelope" + }, + "example": { + "data": { + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "importId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/export-async": { + "x-removed-post": { + "operationId": "exportTableAsync", + "summary": "Export Table (Background)", + "description": "Start a background export. Export jobs are read-only, so they bypass the one-write-job-per-table gate and can run alongside an import or delete.\n\nReturns as soon as the job is queued. Poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download` once the job reports `ready`.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/export-async\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"format\":\"csv\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportAsyncBody" + }, + "example": { + "workspaceId": "ws_123", + "format": "csv" + } + } + } + }, + "responses": { + "200": { + "description": "The export was queued.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportAsyncEnvelope" + }, + "example": { + "data": { + "tableId": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/export/download": { + "x-removed-get": { + "operationId": "downloadTableExport", + "summary": "Download Export", + "description": "Resolve a finished export job to a short-lived presigned download URL.\n\nThe failure modes are deliberately distinct: a job that is not an export of this table is 404, one still running is 409 (retry later), and one whose file has aged out of storage is 410 (start a new export). A caller polling to completion needs to tell \"not yet\" from \"never again\".", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/export/download?workspaceId=YOUR_WORKSPACE_ID&jobId=YOUR_JOB_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/JobIdQuery" + } + ], + "responses": { + "200": { + "description": "The presigned download URL.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportDownloadEnvelope" + }, + "example": { + "data": { + "url": "https://storage.sim.ai/workspace/ws_123/exports/customers.csv?X-Amz-Signature=...", + "fileName": "customers.csv" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "410": { + "$ref": "#/components/responses/Gone" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/job/cancel": { + "x-removed-post": { + "operationId": "cancelTableJob", + "summary": "Cancel Job", + "description": "Stop an in-flight import or delete job. The worker halts at its next ownership check; work already committed (rows inserted or deleted) is left in place — there is no rollback.\n\nIdempotent: cancelling a job that already finished answers `canceled: false` rather than failing, so a client racing the worker is not an error. To stop workflow or enrichment cell runs instead, use `POST /cancel-runs`.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/job/cancel\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"jobId\":\"YOUR_JOB_ID\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelJobBody" + }, + "example": { + "workspaceId": "ws_123", + "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248" + } + } + } + }, + "responses": { + "200": { + "description": "The cancel outcome.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelJobEnvelope" + }, + "example": { + "data": { + "jobId": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248", + "canceled": true + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/imports": { + "post": { + "operationId": "createTableImport", + "summary": "Create Table Import", + "description": "Create a table import. Upload sources return a stateless multipart token; workspace-file sources start immediately and both use table jobs for processing state.", + "tags": ["Tables"], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": {} } } + }, + "responses": { + "201": { + "description": "The table import resource.", + "content": { "application/json": { "schema": {} } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "423": { "$ref": "#/components/responses/Locked" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/tables/imports/{importId}": { + "get": { + "operationId": "getTableImport", + "summary": "Get Table Import", + "description": "Read processing progress and terminal state from the table job using the same import id.", + "tags": ["Tables"], + "parameters": [ + { + "name": "importId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The table import resource.", + "content": { "application/json": { "schema": {} } } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "delete": { + "operationId": "cancelTableImport", + "summary": "Cancel Table Import", + "description": "Cancel an upload or processing import. Already committed row batches remain in the table.", + "tags": ["Tables"], + "parameters": [ + { + "name": "importId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/OptionalUploadTokenHeader" } + ], + "responses": { + "200": { + "description": "The canceled import resource.", + "content": { "application/json": { "schema": {} } } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/tables/imports/{importId}/parts": { + "post": { + "operationId": "createTableImportPartUrls", + "summary": "Create Table Import Part URLs", + "description": "Issue short-lived signed PUT URLs for a bounded set of import part numbers.", + "tags": ["Tables"], + "parameters": [ + { + "name": "importId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/UploadTokenHeader" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": {} } } + }, + "responses": { + "200": { + "description": "Signed URLs for the requested import parts.", + "content": { "application/json": { "schema": {} } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/tables/imports/{importId}/complete": { + "post": { + "operationId": "completeTableImportUpload", + "summary": "Complete Table Import Upload", + "description": "Verify and assemble the uploaded CSV or TSV, then start processing with the same import id.", + "tags": ["Tables"], + "parameters": [ + { + "name": "importId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { "$ref": "#/components/parameters/UploadTokenHeader" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": {} } } + }, + "responses": { + "200": { + "description": "The queued import resource.", + "content": { "application/json": { "schema": {} } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "423": { "$ref": "#/components/responses/Locked" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/tables/{tableId}/exports": { + "post": { + "operationId": "createTableExport", + "summary": "Create Table Export", + "description": "Create one export resource. The server completes small exports inline and queues larger exports without changing the API path.", + "tags": ["Tables"], + "parameters": [ + { + "name": "tableId", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": {} } } + }, + "responses": { + "201": { + "description": "The completed or processing export resource.", + "content": { "application/json": { "schema": {} } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/tables/exports/{exportId}": { + "get": { + "operationId": "getTableExport", + "summary": "Get Table Export", + "description": "Read processing, progress, and terminal state for an export resource.", + "tags": ["Tables"], + "parameters": [ + { + "name": "exportId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The table export resource.", + "content": { "application/json": { "schema": {} } } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "delete": { + "operationId": "cancelTableExport", + "summary": "Cancel Table Export", + "description": "Cancel an export that is still processing.", + "tags": ["Tables"], + "parameters": [ + { + "name": "exportId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "The canceled export resource.", + "content": { "application/json": { "schema": {} } } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/tables/exports/{exportId}/download": { + "get": { + "operationId": "downloadTableExport", + "summary": "Download Table Export", + "description": "Return a short-lived download URL once an export has completed.", + "tags": ["Tables"], + "parameters": [ + { + "name": "exportId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { "$ref": "#/components/parameters/WorkspaceIdQuery" } + ], + "responses": { + "200": { + "description": "A short-lived URL for the generated export file.", + "content": { "application/json": { "schema": {} } } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/tables/{tableId}/cancel-runs": { + "post": { + "operationId": "cancelTableRuns", + "summary": "Cancel Column Runs", + "description": "Stop in-flight and pending workflow or enrichment cell runs — the counterpart to `POST /columns/run`, and distinct from `POST /job/cancel`, which stops an import or delete.\n\n`scope: \"all\"` cancels every running and pending cell, optionally narrowed to rows matching `filter`; `scope: \"row\"` cancels one row’s cells and requires `rowId`. Cancelling clears the affected cells, so a read taken immediately after will show them empty.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/cancel-runs\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"scope\":\"all\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelRunsBody" + }, + "examples": { + "everything": { + "summary": "Stop every run on the table", + "value": { + "workspaceId": "ws_123", + "scope": "all" + } + }, + "oneRow": { + "summary": "Stop one row’s runs", + "value": { + "workspaceId": "ws_123", + "scope": "row", + "rowId": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "How many runs were stopped.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelRunsEnvelope" + }, + "example": { + "data": { + "cancelled": 17 + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "TableId": { + "name": "tableId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "description": "The unique identifier of the table." + }, + "RowId": { + "name": "rowId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "description": "The unique identifier of the row." + }, + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "The unique identifier of the workspace that owns the table." + }, + "UploadTokenHeader": { + "name": "upload-token", + "in": "header", + "required": true, + "description": "The signed token returned for an upload-backed table import.", + "schema": { "type": "string", "minLength": 1 } + }, + "OptionalUploadTokenHeader": { + "name": "upload-token", + "in": "header", + "required": false, + "description": "Required when canceling before upload completion; omitted when canceling a running table job.", + "schema": { "type": "string", "minLength": 1 } + }, + "LimitQuery": { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum rows to return (1-1000, default 100).", + "schema": { + "type": "integer", + "default": 100, + "minimum": 1, + "maximum": 1000 + } + }, + "CursorQuery": { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor. Pass the `nextCursor` from a previous response to fetch the next page. Omit for the first page.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "ViewId": { + "name": "viewId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e" + }, + "description": "The unique identifier of the saved view." + }, + "GroupId": { + "name": "groupId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204" + }, + "description": "The unique identifier of the workflow or enrichment group." + }, + "JobIdQuery": { + "name": "jobId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "example": "job_8f2a4c6e0b1d47539ac1e3b5d7f90248" + }, + "description": "The export job to resolve." + }, + "ExportFormatQuery": { + "name": "format", + "in": "query", + "required": false, + "description": "Serialization for the exported file. Defaults to `csv`.", + "schema": { + "enum": ["csv", "json"], + "default": "csv" + } + }, + "JobTypeQuery": { + "name": "type", + "in": "query", + "required": true, + "description": "Job kind to list. Only `export` is supported today; the parameter is required so widening it later cannot silently change what an existing caller receives.", + "schema": { + "enum": ["export"] + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "Maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "RateLimitRemaining": { + "description": "Number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + "RetryAfter": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "schemas": { + "V2Error": { + "type": "object", + "description": "Canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Machine-readable error code.", + "example": "BAD_REQUEST" + }, + "message": { + "type": "string", + "description": "Human-readable error message." + }, + "details": { + "description": "Optional structured error details, such as per-field validation issues." + } + } + } + } + }, + "Column": { + "type": "object", + "description": "A column definition in a table schema.", + "required": ["name", "type"], + "properties": { + "id": { + "type": "string", + "description": "Stable server-assigned column id. May be absent on legacy columns created before id backfill.", + "example": "col_a1b2c3" + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "email" + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + }, + "workflowGroupId": { + "type": "string", + "description": "Set when the column is the output of a workflow group." + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelectOption" + }, + "description": "Declared options for a `select` column; absent on other types." + }, + "multiple": { + "type": "boolean", + "description": "A `select` column that accepts multiple options per cell." + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Za-z]{3}$", + "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", + "example": "USD" + } + } + }, + "ColumnInput": { + "type": "object", + "description": "Column definition supplied when creating a table or adding a column.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", "maxLength": 50, "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", "example": "email" }, - "type": { + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + }, + "id": { + "type": "string", + "description": "Stable column id. Server-assigned — normally omit." + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelectOption" + }, + "description": "Declared options for a `select` column; absent on other types." + }, + "multiple": { + "type": "boolean", + "description": "A `select` column that accepts multiple options per cell." + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Za-z]{3}$", + "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", + "example": "USD" + } + } + }, + "Table": { + "type": "object", + "description": "A user-defined table with a typed column schema.", + "required": [ + "id", + "name", + "description", + "schema", + "rowCount", + "maxRows", + "folderId", + "locks", + "createdAt", + "updatedAt", + "job" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique table identifier.", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "name": { + "type": "string", + "description": "Table name.", + "example": "contacts" + }, + "description": { + "type": ["string", "null"], + "description": "Optional description of the table. Null when not set.", + "example": "Customer contact records" + }, + "schema": { + "type": "object", + "description": "Table schema definition.", + "required": ["columns"], + "properties": { + "columns": { + "type": "array", + "description": "Array of column definitions for the table.", + "items": { + "$ref": "#/components/schemas/Column" + } + } + } + }, + "rowCount": { + "type": "integer", + "description": "Current number of rows in the table." + }, + "maxRows": { + "type": "integer", + "description": "Maximum rows allowed by the current billing plan." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was last modified." + }, + "folderId": { + "type": ["string", "null"], + "description": "Folder holding the table, or null when it sits at the workspace root." + }, + "locks": { + "$ref": "#/components/schemas/TableLocks" + }, + "job": { + "oneOf": [ + { + "$ref": "#/components/schemas/TableJobState" + }, + { + "type": "null" + } + ], + "description": "In-flight background job, or null when the table is idle." + } + } + }, + "RowData": { + "type": "object", + "additionalProperties": true, + "description": "Row cells keyed by column name. Each value is typed per its column definition.", + "example": { + "email": "jane@example.com", + "name": "Jane Doe", + "age": 30 + } + }, + "Row": { + "type": "object", + "description": "A single row in a table.", + "required": ["id", "data", "createdAt", "updatedAt"], + "properties": { + "id": { + "type": "string", + "description": "Unique row identifier.", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was last modified." + } + } + }, + "CreateTableBody": { + "type": "object", + "description": "Payload to create a new table.", + "required": ["workspaceId", "name", "schema"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that will own the table." + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 128, + "description": "Table name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "contacts" + }, + "description": { + "type": "string", + "maxLength": 500, + "description": "Optional description of the table." + }, + "schema": { + "type": "object", + "required": ["columns"], + "description": "The table's column schema.", + "properties": { + "columns": { + "type": "array", + "minItems": 1, + "maxItems": 50, + "description": "Column definitions. A table must have between 1 and 50 columns.", + "items": { + "allOf": [ + { + "$ref": "#/components/schemas/ColumnInput" + }, + { + "type": "object", + "properties": { + "workflowGroupId": { + "type": "string", + "description": "Advanced: binds the column to a workflow group's output." + } + } + } + ] + } + } + } + }, + "folderId": { + "type": ["string", "null"], + "description": "Folder to create the table in. Omitted or null creates it at the workspace root." + } + } + }, + "AddColumnBody": { + "type": "object", + "description": "Payload to add a column to a table.", + "required": ["workspaceId", "column"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "column": { + "allOf": [ + { + "$ref": "#/components/schemas/ColumnInput" + }, + { + "type": "object", + "properties": { + "position": { + "type": "integer", + "minimum": 0, + "description": "Zero-based insert position in the column order. Appended at the end when omitted." + } + } + } + ], + "description": "The column definition to add." + } + } + }, + "UpdateColumnBody": { + "type": "object", + "description": "Payload to update an existing column by name.", + "required": ["workspaceId", "columnName", "updates"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "columnName": { "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], - "description": "Data type of the column." + "description": "The current name of the column to update.", + "example": "phone" }, - "required": { - "type": "boolean", - "default": false, - "description": "Whether the column requires a value on insert." + "updates": { + "type": "object", + "description": "Fields to change. Provide at least one.", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "New column name.", + "example": "phone_number" + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "description": "New data type for the column." + }, + "required": { + "type": "boolean", + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "description": "Whether values in this column must be unique across all rows." + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelectOption" + }, + "description": "Declared options for a `select` column; absent on other types." + }, + "multiple": { + "type": "boolean", + "description": "A `select` column that accepts multiple options per cell." + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Za-z]{3}$", + "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", + "example": "USD" + } + } + } + } + }, + "DeleteColumnBody": { + "type": "object", + "description": "Payload to delete a column by name.", + "required": ["workspaceId", "columnName"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." }, - "unique": { - "type": "boolean", - "default": false, - "description": "Whether values in this column must be unique across all rows." + "columnName": { + "type": "string", + "description": "The name of the column to delete.", + "example": "phone_number" + } + } + }, + "CreateRowSingleBody": { + "type": "object", + "description": "Insert a single row.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." }, - "workflowGroupId": { + "data": { + "$ref": "#/components/schemas/RowData" + }, + "afterRowId": { "type": "string", - "description": "Set when the column is the output of a workflow group." + "minLength": 1, + "description": "Insert directly after this row id. Mutually exclusive with beforeRowId." }, - "options": { + "beforeRowId": { + "type": "string", + "minLength": 1, + "description": "Insert directly before this row id. Mutually exclusive with afterRowId." + } + } + }, + "CreateRowBatchBody": { + "type": "object", + "description": "Insert multiple rows in one request.", + "required": ["workspaceId", "rows"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "rows": { "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "Rows to insert. Each entry is keyed by column name. Up to 1000 rows per request.", "items": { - "$ref": "#/components/schemas/SelectOption" - }, - "description": "Declared options for a `select` column; absent on other types." + "$ref": "#/components/schemas/RowData" + } + } + } + }, + "CreateRowsBody": { + "description": "Either a single-row payload or a batch payload.", + "oneOf": [ + { + "$ref": "#/components/schemas/CreateRowSingleBody" }, - "multiple": { - "type": "boolean", - "description": "A `select` column that accepts multiple options per cell." + { + "$ref": "#/components/schemas/CreateRowBatchBody" + } + ] + }, + "UpdateRowsByFilterBody": { + "type": "object", + "description": "Bulk-update rows matching a filter.", + "required": ["workspaceId", "filter", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." }, - "currencyCode": { + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of matching rows to update." + } + } + }, + "DeleteRowsByFilterBody": { + "type": "object", + "description": "Delete rows matching a filter.", + "required": ["workspaceId", "filter"], + "properties": { + "workspaceId": { "type": "string", - "pattern": "^[A-Za-z]{3}$", - "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", - "example": "USD" + "minLength": 1, + "description": "The workspace that owns the table." + }, + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of matching rows to delete." + } + } + }, + "DeleteRowsByIdsBody": { + "type": "object", + "description": "Delete an explicit list of rows by id.", + "required": ["workspaceId", "rowIds"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "rowIds": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "Row ids to delete. Up to 1000 ids per request.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of rows to delete." + } + } + }, + "DeleteRowsBody": { + "description": "Provide exactly one of `filter` or `rowIds`.", + "oneOf": [ + { + "$ref": "#/components/schemas/DeleteRowsByFilterBody" + }, + { + "$ref": "#/components/schemas/DeleteRowsByIdsBody" + } + ] + }, + "UpdateRowBody": { + "type": "object", + "description": "Partial update for a single row.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + } + } + }, + "UpsertRowBody": { + "type": "object", + "description": "Insert-or-update a row keyed by a unique column.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "conflictTarget": { + "type": "string", + "minLength": 1, + "description": "Name of the unique column to resolve the conflict against. When omitted, the server uses the table's single unique column." } } }, - "ColumnInput": { + "TableEnvelope": { "type": "object", - "description": "Column definition supplied when creating a table or adding a column.", - "required": ["name", "type"], + "description": "A single table wrapped in the v2 data envelope.", + "required": ["data"], "properties": { - "name": { - "type": "string", - "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", - "maxLength": 50, - "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", - "example": "email" - }, - "type": { - "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], - "description": "Data type of the column." - }, - "required": { - "type": "boolean", - "default": false, - "description": "Whether the column requires a value on insert." - }, - "unique": { - "type": "boolean", - "default": false, - "description": "Whether values in this column must be unique across all rows." - }, - "id": { - "type": "string", - "description": "Stable column id. Server-assigned \u2014 normally omit." - }, - "options": { + "data": { + "type": "object", + "required": ["table"], + "properties": { + "table": { + "$ref": "#/components/schemas/Table" + } + } + } + } + }, + "TableListEnvelope": { + "type": "object", + "description": "A page of tables. `nextCursor` is always null because the full bounded workspace set is returned in one page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SelectOption" - }, - "description": "Declared options for a `select` column; absent on other types." - }, - "multiple": { - "type": "boolean", - "description": "A `select` column that accepts multiple options per cell." + "$ref": "#/components/schemas/Table" + } }, - "currencyCode": { - "type": "string", - "pattern": "^[A-Za-z]{3}$", - "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", - "example": "USD" + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more pages." } } }, - "Table": { + "DeleteTableEnvelope": { "type": "object", - "description": "A user-defined table with a typed column schema.", - "required": [ - "id", - "name", - "description", - "schema", - "rowCount", - "maxRows", - "createdAt", - "updatedAt" - ], + "description": "Confirmation that a table was archived.", + "required": ["data"], "properties": { - "id": { - "type": "string", - "description": "Unique table identifier.", - "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" - }, - "name": { - "type": "string", - "description": "Table name.", - "example": "contacts" - }, - "description": { - "type": ["string", "null"], - "description": "Optional description of the table. Null when not set.", - "example": "Customer contact records" - }, - "schema": { + "data": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "The id of the archived table." + } + } + } + } + }, + "ColumnsEnvelope": { + "type": "object", + "description": "The table's full column list after a column mutation.", + "required": ["data"], + "properties": { + "data": { "type": "object", - "description": "Table schema definition.", "required": ["columns"], "properties": { "columns": { "type": "array", - "description": "Array of column definitions for the table.", "items": { "$ref": "#/components/schemas/Column" } } } + } + } + }, + "RowEnvelope": { + "type": "object", + "description": "A single row wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["row"], + "properties": { + "row": { + "$ref": "#/components/schemas/Row" + } + } + } + } + }, + "RowListEnvelope": { + "type": "object", + "description": "A cursor-paginated page of rows.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Row" + } }, - "rowCount": { - "type": "integer", - "description": "Current number of rows in the table." - }, - "maxRows": { - "type": "integer", - "description": "Maximum rows allowed by the current billing plan." - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the table was created." + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null on the final page." + } + } + }, + "BatchInsertRowsEnvelope": { + "type": "object", + "description": "Result of a batch row insert.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["rows", "insertedCount"], + "properties": { + "rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Row" + } + }, + "insertedCount": { + "type": "integer", + "description": "Number of rows inserted." + } + } + } + } + }, + "CreateRowsResponse": { + "description": "A single-row insert returns `{ data: { row } }`; a batch insert returns `{ data: { rows, insertedCount } }`.", + "oneOf": [ + { + "$ref": "#/components/schemas/RowEnvelope" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the table was last modified." + { + "$ref": "#/components/schemas/BatchInsertRowsEnvelope" + } + ] + }, + "UpdateRowsEnvelope": { + "type": "object", + "description": "Result of a bulk update-by-filter.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["updatedCount", "updatedRowIds"], + "properties": { + "updatedCount": { + "type": "integer", + "description": "Number of rows updated." + }, + "updatedRowIds": { + "type": "array", + "description": "Ids of the updated rows. Empty when nothing matched.", + "items": { + "type": "string" + } + } + } } } }, - "RowData": { + "DeleteRowsEnvelope": { "type": "object", - "additionalProperties": true, - "description": "Row cells keyed by column name. Each value is typed per its column definition.", - "example": { - "email": "jane@example.com", - "name": "Jane Doe", - "age": 30 + "description": "Result of a bulk delete. `requestedCount` and `missingRowIds` are present only for id-based deletes.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["deletedCount", "deletedRowIds"], + "properties": { + "deletedCount": { + "type": "integer", + "description": "Number of rows deleted." + }, + "deletedRowIds": { + "type": "array", + "description": "Ids of the deleted rows.", + "items": { + "type": "string" + } + }, + "requestedCount": { + "type": "integer", + "description": "Number of row ids requested. Present only for id-based deletes." + }, + "missingRowIds": { + "type": "array", + "description": "Requested ids that did not exist. Present only for id-based deletes.", + "items": { + "type": "string" + } + } + } + } } }, - "Row": { + "DeleteRowEnvelope": { "type": "object", - "description": "A single row in a table.", - "required": ["id", "data", "createdAt", "updatedAt"], + "description": "Result of a single-row delete.", + "required": ["data"], "properties": { - "id": { - "type": "string", - "description": "Unique row identifier.", - "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" - }, "data": { - "$ref": "#/components/schemas/RowData" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the row was created." - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the row was last modified." + "type": "object", + "required": ["deletedCount", "deletedRowIds"], + "properties": { + "deletedCount": { + "type": "integer", + "description": "Always 1 when a row was deleted." + }, + "deletedRowIds": { + "type": "array", + "description": "The id of the deleted row.", + "items": { + "type": "string" + } + } + } } } }, - "CreateTableBody": { + "UpsertRowEnvelope": { "type": "object", - "description": "Payload to create a new table.", - "required": ["workspaceId", "name", "schema"], + "description": "Result of an upsert, including whether the row was inserted or updated.", + "required": ["data"], "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "The workspace that will own the table." - }, - "name": { - "type": "string", - "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", - "maxLength": 128, - "description": "Table name. Starts with a letter or underscore; contains only alphanumerics and underscores.", - "example": "contacts" - }, - "description": { - "type": "string", - "maxLength": 500, - "description": "Optional description of the table." + "data": { + "type": "object", + "required": ["row", "operation"], + "properties": { + "row": { + "$ref": "#/components/schemas/Row" + }, + "operation": { + "type": "string", + "enum": ["insert", "update"], + "description": "Whether the row was inserted or updated." + } + } + } + } + }, + "Predicate": { + "description": "A predicate tree: exactly one of `all` (every member must match) or `any` (at least one must). Members are conditions or nested groups; nesting expresses mixed AND/OR logic. Groups must be non-empty (1–100 members), trees at most 10 levels deep and 500 nodes total. Nodes are STRICT: unknown keys, or a node carrying both a group key and condition keys, are rejected rather than ignored.", + "oneOf": [ + { + "type": "object", + "required": ["all"], + "additionalProperties": false, + "properties": { + "all": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "$ref": "#/components/schemas/PredicateNode" + } + } + } }, - "schema": { + { "type": "object", - "required": ["columns"], - "description": "The table's column schema.", + "required": ["any"], + "additionalProperties": false, "properties": { - "columns": { + "any": { "type": "array", "minItems": 1, - "maxItems": 50, - "description": "Column definitions. A table must have between 1 and 50 columns.", + "maxItems": 100, "items": { - "allOf": [ - { - "$ref": "#/components/schemas/ColumnInput" - }, - { - "type": "object", - "properties": { - "workflowGroupId": { - "type": "string", - "description": "Advanced: binds the column to a workflow group's output." - } - } - } - ] + "$ref": "#/components/schemas/PredicateNode" } } } + } + ] + }, + "PredicateNode": { + "oneOf": [ + { + "$ref": "#/components/schemas/Predicate" }, - "folderId": { - "type": ["string", "null"], - "description": "Folder to create the table in. Omitted or null creates it at the workspace root." + { + "$ref": "#/components/schemas/Condition" } - } + ] }, - "AddColumnBody": { + "Condition": { "type": "object", - "description": "Payload to add a column to a table.", - "required": ["workspaceId", "column"], + "required": ["field", "op"], + "additionalProperties": false, "properties": { - "workspaceId": { + "field": { "type": "string", - "minLength": 1, - "description": "The workspace that owns the table." + "maxLength": 128, + "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase — snake_case is treated as a user column and matches nothing)." }, - "column": { - "allOf": [ - { - "$ref": "#/components/schemas/ColumnInput" - }, - { - "type": "object", - "properties": { - "position": { - "type": "integer", - "minimum": 0, - "description": "Zero-based insert position in the column order. Appended at the end when omitted." - } - } - } + "op": { + "enum": [ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "nin", + "contains", + "ncontains", + "startsWith", + "endsWith", + "like", + "ilike", + "nlike", + "nilike", + "isEmpty", + "isNotEmpty", + "isNull", + "isNotNull" ], - "description": "The column definition to add." + "description": "`eq`/`ne`/`in`/`nin` are case-sensitive equality/membership. `contains`/`ncontains`/`startsWith`/`endsWith` are case-insensitive text matches — except on a multi-select column, where `contains`/`ncontains` mean set membership by option name. `like`/`nlike` are case-sensitive and `ilike`/`nilike` case-insensitive patterns with `*` as the only wildcard (literal `%`/`_` match themselves). `isEmpty`/`isNotEmpty` treat null and empty string as empty; `isNull`/`isNotNull` are strict null checks. The four `is*` operators take no `value`. Negated text matches retain rows where the cell is absent. `in`/`nin` require a non-empty array of at most 1000 values; other value-taking operators reject arrays. Select columns accept only equality/membership operators appropriate to their cardinality (single: eq/ne/in/nin; multi: contains/ncontains; both: the `is*` checks)." + }, + "value": { + "description": "Operand. Omit for the `is*` operators. Ranges on `number` columns require numbers, on `date` columns ISO strings (compared as UTC, independent of any session timezone); ranges on `boolean`/`json` columns are rejected." } } }, - "UpdateColumnBody": { + "SelectOption": { "type": "object", - "description": "Payload to update an existing column by name.", - "required": ["workspaceId", "columnName", "updates"], + "required": ["id", "name"], "properties": { - "workspaceId": { + "id": { "type": "string", - "minLength": 1, - "description": "The workspace that owns the table." + "description": "Stable option id — the value stored in cells." }, - "columnName": { + "name": { "type": "string", - "description": "The current name of the column to update.", - "example": "phone" + "maxLength": 100, + "description": "Display name. Filters on select columns accept names (resolved case-insensitively)." + } + } + }, + "TableLocks": { + "type": "object", + "description": "Per-table governance flags. Every flag is present. Changing them requires workspace admin.", + "required": ["schemaLocked", "insertLocked", "updateLocked", "deleteLocked"], + "properties": { + "schemaLocked": { + "type": "boolean", + "description": "Blocks column adds, edits, and deletes." }, - "updates": { - "type": "object", - "description": "Fields to change. Provide at least one.", - "properties": { - "name": { - "type": "string", - "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", - "maxLength": 50, - "description": "New column name.", - "example": "phone_number" - }, - "type": { - "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], - "description": "New data type for the column." - }, - "required": { - "type": "boolean", - "description": "Whether the column requires a value on insert." - }, - "unique": { - "type": "boolean", - "description": "Whether values in this column must be unique across all rows." - }, - "options": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SelectOption" - }, - "description": "Declared options for a `select` column; absent on other types." - }, - "multiple": { - "type": "boolean", - "description": "A `select` column that accepts multiple options per cell." - }, - "currencyCode": { - "type": "string", - "pattern": "^[A-Za-z]{3}$", - "description": "ISO 4217 currency code for a `currency` column, e.g. `USD`. Normalized to uppercase. Only meaningful on a `currency` column.", - "example": "USD" - } - } + "insertLocked": { + "type": "boolean", + "description": "Blocks new rows." + }, + "updateLocked": { + "type": "boolean", + "description": "Blocks cell writes to existing rows." + }, + "deleteLocked": { + "type": "boolean", + "description": "Blocks row deletes and archiving the table." } } }, - "DeleteColumnBody": { + "UpdateTableBody": { "type": "object", - "description": "Payload to delete a column by name.", - "required": ["workspaceId", "columnName"], + "description": "Rename and/or move a table. Every field beyond `workspaceId` is optional, but at least one must be present. Lock flags are read-only on this API and are not accepted here.", + "required": ["workspaceId"], "properties": { "workspaceId": { "type": "string", "minLength": 1, "description": "The workspace that owns the table." }, - "columnName": { + "name": { "type": "string", - "description": "The name of the column to delete.", - "example": "phone_number" + "minLength": 1, + "description": "New table name." + }, + "folderId": { + "type": ["string", "null"], + "description": "Folder to move the table into. Pass null to move it to the workspace root; omit to leave the placement untouched." } - } + }, + "additionalProperties": false }, - "CreateRowSingleBody": { + "WorkspaceScopedBody": { "type": "object", - "description": "Insert a single row.", - "required": ["workspaceId", "data"], + "description": "Endpoints whose only input is the workspace the table must belong to.", + "required": ["workspaceId"], "properties": { "workspaceId": { "type": "string", "minLength": 1, "description": "The workspace that owns the table." + } + } + }, + "SortSpec": { + "type": "array", + "maxItems": 16, + "description": "Ordered sort spec, highest priority first. Fields are column names.", + "items": { + "type": "object", + "required": ["field", "direction"], + "properties": { + "field": { + "type": "string" + }, + "direction": { + "enum": ["asc", "desc"] + } + } + } + }, + "ViewConfig": { + "type": "object", + "description": "A view’s saved preset: the row predicate and sort plus the column layout. Column references are stable column ids, so renaming a column never invalidates a view.", + "properties": { + "columnWidths": { + "type": "object", + "description": "Pixel widths keyed by column id.", + "additionalProperties": { + "type": "number", + "exclusiveMinimum": 0 + } }, - "data": { - "$ref": "#/components/schemas/RowData" + "columnOrder": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Left-to-right column order, as column ids." }, - "afterRowId": { + "pinnedColumns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Column ids pinned while scrolling horizontally." + }, + "hiddenColumns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Column ids hidden by the view. A deny-list — a column added later is visible by default. Hiding is presentation only; the data is untouched and reappears intact when unhidden." + }, + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "sort": { + "$ref": "#/components/schemas/SortSpec" + } + } + }, + "View": { + "type": "object", + "description": "A saved view: a named preset of filter, sort, and column layout over a table. Presentation only — a view narrows what a reader sees by default, it is never an access boundary, and every row it hides stays reachable by reading the table without it.", + "required": [ + "id", + "tableId", + "name", + "config", + "isDefault", + "createdBy", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique view identifier.", + "example": "view_3c7f1a9e5d2b4086b1e6c8a0d4f2b73e" + }, + "tableId": { + "type": "string", + "description": "The table the view belongs to." + }, + "name": { + "type": "string", + "description": "Display name." + }, + "config": { + "$ref": "#/components/schemas/ViewConfig" + }, + "isDefault": { + "type": "boolean", + "description": "Whether this view is the table’s default. At most one view per table is." + }, + "createdBy": { + "type": ["string", "null"], + "description": "User who saved the view, or null when that user no longer exists." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CreateViewBody": { + "type": "object", + "description": "Save a filter/sort/layout preset as a named view.", + "required": ["workspaceId", "name", "config"], + "properties": { + "workspaceId": { "type": "string", "minLength": 1, - "description": "Insert directly after this row id. Mutually exclusive with beforeRowId." + "description": "The workspace that owns the table." }, - "beforeRowId": { + "name": { "type": "string", "minLength": 1, - "description": "Insert directly before this row id. Mutually exclusive with afterRowId." + "description": "Display name for the view." + }, + "config": { + "$ref": "#/components/schemas/ViewConfig" } } }, - "CreateRowBatchBody": { + "UpdateViewBody": { "type": "object", - "description": "Insert multiple rows in one request.", - "required": ["workspaceId", "rows"], + "description": "Change a saved view. At least one of `name`, `config`, `configPatch`, or `isDefault` is required; `config` and `configPatch` are mutually exclusive.", + "required": ["workspaceId"], "properties": { "workspaceId": { "type": "string", "minLength": 1, "description": "The workspace that owns the table." }, - "rows": { + "name": { + "type": "string", + "minLength": 1, + "description": "New display name." + }, + "config": { + "allOf": [ + { + "$ref": "#/components/schemas/ViewConfig" + } + ], + "description": "Replaces the stored config wholesale. Use when dropping a removed filter must persist." + }, + "configPatch": { + "allOf": [ + { + "$ref": "#/components/schemas/ViewConfig" + } + ], + "description": "Shallow-merged into the stored config server-side, so two overlapping partial writes cannot clobber each other from stale snapshots." + }, + "isDefault": { + "type": "boolean", + "description": "Promote this view to the table’s default. Setting it demotes the table’s existing default in the same transaction." + } + } + }, + "ViewEnvelope": { + "type": "object", + "description": "A single view wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["view"], + "properties": { + "view": { + "$ref": "#/components/schemas/View" + } + } + } + } + }, + "ViewListEnvelope": { + "type": "object", + "description": "Saved views wrapped in the v2 cursor-list envelope.", + "required": ["data", "nextCursor"], + "properties": { + "data": { "type": "array", - "minItems": 1, - "maxItems": 1000, - "description": "Rows to insert. Each entry is keyed by column name. Up to 1000 rows per request.", "items": { - "$ref": "#/components/schemas/RowData" + "$ref": "#/components/schemas/View" } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Always null — a table carries a bounded set of views, so the list is a single full page." } } }, - "CreateRowsBody": { - "description": "Either a single-row payload or a batch payload.", - "oneOf": [ - { - "$ref": "#/components/schemas/CreateRowSingleBody" - }, - { - "$ref": "#/components/schemas/CreateRowBatchBody" + "DeleteViewEnvelope": { + "type": "object", + "description": "Delete confirmation carrying the id of the removed view.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "The view that was deleted." + } + } } - ] + } }, - "UpdateRowsByFilterBody": { + "WorkflowGroup": { "type": "object", - "description": "Bulk-update rows matching a filter.", - "required": ["workspaceId", "filter", "data"], + "description": "A workflow or enrichment group: a backing workflow (or registry enrichment) plus the output columns its runs populate. Authored in the workflow builder; exposed here so a caller can discover the group ids the run endpoints take.", + "required": ["id", "workflowId", "outputs"], "properties": { - "workspaceId": { + "id": { "type": "string", - "minLength": 1, - "description": "The workspace that owns the table." + "description": "Group id — pass to the run endpoints." }, - "filter": { - "$ref": "#/components/schemas/Predicate" + "workflowId": { + "type": "string", + "description": "Backing workflow id for manual groups; empty string for enrichment groups." }, - "data": { - "$ref": "#/components/schemas/RowData" + "enrichmentId": { + "type": "string", + "description": "Registry enrichment id, present on enrichment groups." }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "description": "Maximum number of matching rows to update." + "name": { + "type": "string", + "description": "Display name." + }, + "type": { + "enum": ["manual", "enrichment"], + "description": "Provenance of the group. Defaults to manual when absent." + }, + "dependencies": { + "type": "object", + "description": "Columns whose values must be present before the group is eligible to run.", + "properties": { + "columns": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "outputs": { + "type": "array", + "description": "Which produced value flows into which column.", + "items": { + "type": "object", + "required": ["blockId", "path", "columnName"], + "properties": { + "blockId": { + "type": "string", + "description": "Source block in the workflow. Empty on enrichment outputs." + }, + "path": { + "type": "string", + "description": "Path into the block output. Empty on enrichment outputs." + }, + "outputId": { + "type": "string", + "description": "Enrichment output id, on enrichment groups." + }, + "columnName": { + "type": "string", + "description": "Column the value is written to." + } + } + } + }, + "inputMappings": { + "type": "array", + "description": "Which table column supplies each workflow Start-block input.", + "items": { + "type": "object", + "required": ["inputName", "columnName"], + "properties": { + "inputName": { + "type": "string" + }, + "columnName": { + "type": "string" + } + } + } + }, + "deploymentMode": { + "enum": ["live", "deployed"], + "description": "Which workflow state per-cell runs execute against. Defaults to live (the editable draft)." + }, + "autoRun": { + "type": "boolean", + "description": "When false the group never auto-fires; it runs only on an explicit request. Defaults to true." } } }, - "DeleteRowsByFilterBody": { + "WorkflowGroupListEnvelope": { "type": "object", - "description": "Delete rows matching a filter.", - "required": ["workspaceId", "filter"], + "description": "Workflow groups wrapped in the v2 cursor-list envelope.", + "required": ["data", "nextCursor"], "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "The workspace that owns the table." - }, - "filter": { - "$ref": "#/components/schemas/Predicate" + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowGroup" + } }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "description": "Maximum number of matching rows to delete." + "nextCursor": { + "type": ["string", "null"], + "description": "Always null — groups are bounded per table, so the list is a single full page." } } }, - "DeleteRowsByIdsBody": { + "RunColumnBody": { "type": "object", - "description": "Delete an explicit list of rows by id.", - "required": ["workspaceId", "rowIds"], + "description": "Run one or more groups. Scope with `rowIds` (an explicit set) or `filter` (every matching row) — never both; omit both to run every row. `excludeRowIds` applies only to the filter scope.", + "required": ["workspaceId", "groupIds"], "properties": { "workspaceId": { "type": "string", "minLength": 1, "description": "The workspace that owns the table." }, + "groupIds": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Groups to run, from `GET /api/v2/tables/{tableId}/groups`." + }, + "runMode": { + "enum": ["all", "incomplete"], + "default": "all", + "description": "`all` re-runs every dep-satisfied row. `incomplete` restricts to rows whose group has never run or whose last run failed or aborted." + }, "rowIds": { "type": "array", "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Run only these rows. Mutually exclusive with `filter`." + }, + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "excludeRowIds": { + "type": "array", "maxItems": 1000, - "description": "Row ids to delete. Up to 1000 ids per request.", "items": { "type": "string", "minLength": 1 + }, + "description": "Rows to skip within the `filter` scope." + }, + "limit": { + "type": "object", + "description": "Cap the run to the first N eligible rows. Omit for an unbounded run.", + "required": ["type", "max"], + "properties": { + "type": { + "enum": ["rows"] + }, + "max": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } } - }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "description": "Maximum number of rows to delete." } } }, - "DeleteRowsBody": { - "description": "Provide exactly one of `filter` or `rowIds`.", - "oneOf": [ - { - "$ref": "#/components/schemas/DeleteRowsByFilterBody" - }, - { - "$ref": "#/components/schemas/DeleteRowsByIdsBody" + "RunEnvelope": { + "type": "object", + "description": "Acknowledgement that a run was dispatched.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["dispatchId"], + "properties": { + "dispatchId": { + "type": ["string", "null"], + "description": "Identifies the dispatch the runner walks. Null where no background runner is configured and cells execute inline." + } + } } - ] + } }, - "UpdateRowBody": { + "FindRowsBody": { "type": "object", - "description": "Partial update for a single row.", - "required": ["workspaceId", "data"], + "description": "Case-insensitive substring search across every cell, narrowed by the same predicate and sort grammar as `POST /api/v2/tables/{tableId}/query`.", + "required": ["workspaceId", "q"], "properties": { "workspaceId": { "type": "string", "minLength": 1, "description": "The workspace that owns the table." }, - "data": { - "$ref": "#/components/schemas/RowData" + "q": { + "type": "string", + "minLength": 1, + "description": "Substring to search for." + }, + "predicate": { + "$ref": "#/components/schemas/Predicate" + }, + "sort": { + "$ref": "#/components/schemas/SortSpec" } } }, - "UpsertRowBody": { + "RowMatch": { "type": "object", - "description": "Insert-or-update a row keyed by a unique column.", - "required": ["workspaceId", "data"], + "description": "One matching cell.", + "required": ["ordinal", "rowId", "column"], "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "The workspace that owns the table." + "ordinal": { + "type": "integer", + "description": "The row’s 0-based index in the same predicate-filtered, sorted view that `POST /api/v2/tables/{tableId}/query` returns for these arguments — use it to page straight to the match." }, - "data": { - "$ref": "#/components/schemas/RowData" + "rowId": { + "type": "string", + "description": "The row holding the matching cell." }, - "conflictTarget": { + "column": { "type": "string", - "minLength": 1, - "description": "Name of the unique column to resolve the conflict against. When omitted, the server uses the table's single unique column." + "description": "Name of the matching column." } } }, - "TableEnvelope": { + "FindRowsEnvelope": { "type": "object", - "description": "A single table wrapped in the v2 data envelope.", + "description": "Matching cells wrapped in the v2 data envelope.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["table"], + "required": ["matches", "truncated"], "properties": { - "table": { - "$ref": "#/components/schemas/Table" + "matches": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RowMatch" + } + }, + "truncated": { + "type": "boolean", + "description": "True when the search hit the server-side cap and more cells match than were returned. Matches have no cursor — narrow the predicate instead of paging." } } } } }, - "TableListEnvelope": { + "ImportAsyncEnvelope": { "type": "object", - "description": "A page of tables. `nextCursor` is always null because the full bounded workspace set is returned in one page.", - "required": ["data", "nextCursor"], + "description": "Background-import kickoff acknowledgement.", + "required": ["data"], "properties": { "data": { + "type": "object", + "required": ["tableId", "importId"], + "properties": { + "tableId": { + "type": "string" + }, + "importId": { + "type": "string", + "description": "Job id — pass to `POST /job/cancel` to stop the import." + } + } + } + } + }, + "ImportAsyncBody": { + "type": "object", + "description": "Starts a background import of a file already uploaded to workspace storage. The file is read by the worker, not from this request.", + "required": ["workspaceId", "fileKey", "fileName", "mode"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "fileKey": { + "type": "string", + "minLength": 1, + "description": "Storage key of the uploaded file. Must sit under this workspace’s `workspace/{workspaceId}/` prefix.", + "example": "workspace/ws_123/imports/contacts.csv" + }, + "fileName": { + "type": "string", + "minLength": 1, + "description": "Original filename. Its extension selects the separator (.csv or .tsv)." + }, + "mode": { + "enum": ["append", "replace"], + "description": "`append` adds rows; `replace` deletes every existing row first." + }, + "mapping": { + "type": "object", + "description": "CSV header → column name, or null to skip the header.", + "additionalProperties": { + "type": ["string", "null"] + } + }, + "createColumns": { "type": "array", "items": { - "$ref": "#/components/schemas/Table" - } + "type": "string" + }, + "description": "CSV headers to create as new columns before importing." }, - "nextCursor": { - "type": ["string", "null"], - "description": "Opaque cursor for the next page, or null when there are no more pages." + "timezone": { + "type": "string", + "description": "IANA zone used to read naive datetimes.", + "example": "America/New_York" } } }, - "DeleteTableEnvelope": { + "ExportAsyncBody": { "type": "object", - "description": "Confirmation that a table was archived.", + "description": "Starts a background export.", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "format": { + "enum": ["csv", "json"], + "default": "csv", + "description": "Serialization to produce." + } + } + }, + "ExportAsyncEnvelope": { + "type": "object", + "description": "Background-export kickoff acknowledgement.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["id"], + "required": ["tableId", "jobId"], "properties": { - "id": { + "tableId": { + "type": "string" + }, + "jobId": { "type": "string", - "description": "The id of the archived table." + "description": "Job id — poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download`." } } } } }, - "ColumnsEnvelope": { + "ExportDownloadEnvelope": { "type": "object", - "description": "The table's full column list after a column mutation.", + "description": "A short-lived presigned download URL for a finished export.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["columns"], + "required": ["url", "fileName"], "properties": { - "columns": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Column" - } + "url": { + "type": "string", + "description": "Presigned URL. Expires shortly after issue — fetch it promptly." + }, + "fileName": { + "type": "string", + "description": "Suggested filename for the download." } } } } }, - "RowEnvelope": { + "TableJob": { "type": "object", - "description": "A single row wrapped in the v2 data envelope.", - "required": ["data"], + "description": "One export job.", + "required": [ + "jobId", + "tableId", + "tableName", + "status", + "rowsProcessed", + "format", + "hasResult", + "error" + ], "properties": { - "data": { - "type": "object", - "required": ["row"], - "properties": { - "row": { - "$ref": "#/components/schemas/Row" - } - } + "jobId": { + "type": "string" + }, + "tableId": { + "type": "string" + }, + "tableName": { + "type": "string" + }, + "status": { + "enum": ["running", "ready", "failed", "canceled"], + "description": "Only `ready` jobs can be downloaded." + }, + "rowsProcessed": { + "type": "integer", + "description": "Rows written so far." + }, + "format": { + "enum": ["csv", "json"] + }, + "hasResult": { + "type": "boolean", + "description": "Whether a generated file is still available to download." + }, + "error": { + "type": ["string", "null"], + "description": "Failure reason for a `failed` job; null otherwise." } } }, - "RowListEnvelope": { + "TableJobListEnvelope": { "type": "object", - "description": "A cursor-paginated page of rows.", + "description": "Export jobs wrapped in the v2 cursor-list envelope.", "required": ["data", "nextCursor"], "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Row" + "$ref": "#/components/schemas/TableJob" } }, "nextCursor": { "type": ["string", "null"], - "description": "Opaque cursor for the next page, or null on the final page." + "description": "Always null — the listing is bounded server-side to a single page." } } }, - "BatchInsertRowsEnvelope": { + "CancelJobBody": { "type": "object", - "description": "Result of a batch row insert.", + "description": "Stops an in-flight import or delete job.", + "required": ["workspaceId", "jobId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "jobId": { + "type": "string", + "minLength": 1, + "description": "The job to stop." + } + } + }, + "CancelJobEnvelope": { + "type": "object", + "description": "Cancel outcome.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["rows", "insertedCount"], + "required": ["jobId", "canceled"], "properties": { - "rows": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Row" - } + "jobId": { + "type": "string" }, - "insertedCount": { - "type": "integer", - "description": "Number of rows inserted." + "canceled": { + "type": "boolean", + "description": "False when the job had already finished. Cancelling is idempotent — a late request is not an error." } } } } }, - "CreateRowsResponse": { - "description": "A single-row insert returns `{ data: { row } }`; a batch insert returns `{ data: { rows, insertedCount } }`.", - "oneOf": [ - { - "$ref": "#/components/schemas/RowEnvelope" + "CancelRunsBody": { + "type": "object", + "description": "Stops in-flight and pending cell runs. `filter` and `excludeRowIds` apply only to `scope: \"all\"`; `rowId` is required for `scope: \"row\"`.", + "required": ["workspaceId", "scope"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." }, - { - "$ref": "#/components/schemas/BatchInsertRowsEnvelope" + "scope": { + "enum": ["all", "row"], + "description": "`all` cancels every running and pending cell; `row` cancels one row’s cells." + }, + "rowId": { + "type": "string", + "minLength": 1, + "description": "Required when `scope` is `row`." + }, + "filter": { + "$ref": "#/components/schemas/Predicate" + }, + "excludeRowIds": { + "type": "array", + "maxItems": 1000, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Rows to leave running within the `filter` scope." } - ] + } }, - "UpdateRowsEnvelope": { + "CancelRunsEnvelope": { "type": "object", - "description": "Result of a bulk update-by-filter.", + "description": "How many in-flight cell runs were stopped.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["updatedCount", "updatedRowIds"], + "required": ["cancelled"], "properties": { - "updatedCount": { - "type": "integer", - "description": "Number of rows updated." - }, - "updatedRowIds": { - "type": "array", - "description": "Ids of the updated rows. Empty when nothing matched.", - "items": { - "type": "string" - } + "cancelled": { + "type": "integer" } } } } }, - "DeleteRowsEnvelope": { + "TableJobState": { "type": "object", - "description": "Result of a bulk delete. `requestedCount` and `missingRowIds` are present only for id-based deletes.", - "required": ["data"], + "description": "The latest write job derived onto the table. Durable imports also expose their full lifecycle at `GET /api/v2/tables/imports/{importId}`. Exports are read-only resources and do not replace this field.", + "required": ["id", "type", "status", "rowsProcessed", "error"], "properties": { - "data": { + "id": { + "type": ["string", "null"], + "description": "Job id. For durable imports this is also the import resource id." + }, + "type": { + "enum": ["import", "delete", "export", "backfill", "update", null], + "description": "Which kind of job is running." + }, + "status": { + "enum": ["running", "ready", "failed", "canceled"], + "description": "`running` is in-flight; the rest are terminal." + }, + "rowsProcessed": { + "type": "integer", + "description": "Rows handled so far — progress for a running job." + }, + "error": { + "type": ["string", "null"], + "description": "Failure reason for a `failed` job; null otherwise." + } + } + }, + "WorkflowGroupOutputColumnInput": { + "type": "object", + "description": "A column the group's runs will populate. `workflowGroupId` is NOT accepted — the server stamps it from the group being written.", + "required": ["name", "type"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Column name. Must match one of `group.outputs[].columnName`.", + "example": "revenue" + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"] + }, + "required": { + "type": "boolean" + }, + "unique": { + "type": "boolean" + } + } + }, + "AddWorkflowGroupBody": { + "type": "object", + "description": "Bind a workflow or enrichment to the table and create the columns its runs populate, in one call.", + "required": ["workspaceId", "group", "outputColumns"], + "additionalProperties": false, + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1 + }, + "group": { "type": "object", - "required": ["deletedCount", "deletedRowIds"], + "description": "The binding. `id` is optional and server-generated. Supply `workflowId` when `type` is `manual` (the default), or `enrichmentId` when it is `enrichment` — the mismatch is a 400.", + "required": ["outputs"], "properties": { - "deletedCount": { - "type": "integer", - "description": "Number of rows deleted." + "id": { + "type": "string", + "minLength": 1, + "description": "Optional. Omit to have the server generate one." }, - "deletedRowIds": { + "workflowId": { + "type": "string", + "description": "Required for `manual` groups." + }, + "enrichmentId": { + "type": "string", + "minLength": 1, + "description": "Required for `enrichment` groups." + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["manual", "enrichment"], + "default": "manual", + "description": "`manual` means workflow-backed — not hand-entered." + }, + "dependencies": { + "type": "object", + "description": "Columns that must be populated before this group runs." + }, + "outputs": { "type": "array", - "description": "Ids of the deleted rows.", + "minItems": 1, + "description": "Where each value comes from. Workflow outputs carry `blockId`/`path`; enrichment outputs carry `outputId`.", "items": { - "type": "string" + "type": "object" } }, - "requestedCount": { - "type": "integer", - "description": "Number of row ids requested. Present only for id-based deletes." - }, - "missingRowIds": { + "inputMappings": { "type": "array", - "description": "Requested ids that did not exist. Present only for id-based deletes.", + "description": "Workflow Start-block inputs fed from table columns.", "items": { - "type": "string" + "type": "object" } + }, + "deploymentMode": { + "type": "string", + "enum": ["live", "deployed"] + }, + "autoRun": { + "type": "boolean", + "description": "Whether the group auto-fires from the scheduler." } } + }, + "outputColumns": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/components/schemas/WorkflowGroupOutputColumnInput" + } + }, + "autoRun": { + "type": "boolean", + "default": false, + "description": "Backfill every existing row on creation. Defaults to **false** here (the first-party surface defaults true) — on an API key this fans out a metered run per row. Prefer POST /columns/run." } } }, - "DeleteRowEnvelope": { + "UpdateWorkflowGroupBody": { + "type": "object", + "description": "Restructure a group. Omitted fields keep their stored values.\n\n**Removing an output deletes that column and its values** — the same behavior as DELETE /columns on a bound column. There is no detach.", + "required": ["workspaceId", "groupId"], + "additionalProperties": false, + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1 + }, + "groupId": { + "type": "string", + "minLength": 1 + }, + "workflowId": { + "type": "string", + "minLength": 1, + "description": "Re-point the group. Re-checked against the workspace." + }, + "name": { + "type": "string" + }, + "dependencies": { + "type": "object" + }, + "outputs": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Full replacement set. Entries dropped here delete their columns." + }, + "newOutputColumns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowGroupOutputColumnInput" + } + }, + "mappingUpdates": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Re-point a column to a different workflow output, keeping the column." + }, + "inputMappings": { + "type": "array", + "items": { + "type": "object" + } + }, + "deploymentMode": { + "type": "string", + "enum": ["live", "deployed"] + }, + "type": { + "type": "string", + "enum": ["manual", "enrichment"] + }, + "autoRun": { + "type": "boolean" + } + } + }, + "DeleteWorkflowGroupBody": { + "type": "object", + "description": "Remove a group and every column it fed.", + "required": ["workspaceId", "groupId"], + "additionalProperties": false, + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1 + }, + "groupId": { + "type": "string", + "minLength": 1 + } + } + }, + "WorkflowGroupEnvelope": { "type": "object", - "description": "Result of a single-row delete.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["deletedCount", "deletedRowIds"], + "required": ["group", "columns"], "properties": { - "deletedCount": { - "type": "integer", - "description": "Always 1 when a row was deleted." + "group": { + "$ref": "#/components/schemas/WorkflowGroup" }, - "deletedRowIds": { + "columns": { "type": "array", - "description": "The id of the deleted row.", "items": { - "type": "string" + "$ref": "#/components/schemas/Column" } } } } } }, - "UpsertRowEnvelope": { + "DeleteWorkflowGroupEnvelope": { "type": "object", - "description": "Result of an upsert, including whether the row was inserted or updated.", "required": ["data"], "properties": { "data": { "type": "object", - "required": ["row", "operation"], + "required": ["id", "deleted", "columns"], "properties": { - "row": { - "$ref": "#/components/schemas/Row" + "id": { + "type": "string" }, - "operation": { - "type": "string", - "enum": ["insert", "update"], - "description": "Whether the row was inserted or updated." - } - } - } - } - }, - "Predicate": { - "description": "A predicate tree: exactly one of `all` (every member must match) or `any` (at least one must). Members are conditions or nested groups; nesting expresses mixed AND/OR logic. Groups must be non-empty (1\u2013100 members), trees at most 10 levels deep and 500 nodes total. Nodes are STRICT: unknown keys, or a node carrying both a group key and condition keys, are rejected rather than ignored.", - "oneOf": [ - { - "type": "object", - "required": ["all"], - "additionalProperties": false, - "properties": { - "all": { - "type": "array", - "minItems": 1, - "maxItems": 100, - "items": { - "$ref": "#/components/schemas/PredicateNode" - } - } - } - }, - { - "type": "object", - "required": ["any"], - "additionalProperties": false, - "properties": { - "any": { + "deleted": { + "type": "boolean", + "enum": [true] + }, + "columns": { "type": "array", - "minItems": 1, - "maxItems": 100, "items": { - "$ref": "#/components/schemas/PredicateNode" + "$ref": "#/components/schemas/Column" } } } } - ] - }, - "PredicateNode": { - "oneOf": [ - { - "$ref": "#/components/schemas/Predicate" - }, - { - "$ref": "#/components/schemas/Condition" - } - ] - }, - "Condition": { - "type": "object", - "required": ["field", "op"], - "additionalProperties": false, - "properties": { - "field": { - "type": "string", - "maxLength": 128, - "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase \u2014 snake_case is treated as a user column and matches nothing)." - }, - "op": { - "enum": [ - "eq", - "ne", - "gt", - "gte", - "lt", - "lte", - "in", - "nin", - "contains", - "ncontains", - "startsWith", - "endsWith", - "like", - "ilike", - "nlike", - "nilike", - "isEmpty", - "isNotEmpty", - "isNull", - "isNotNull" - ], - "description": "`eq`/`ne`/`in`/`nin` are case-sensitive equality/membership. `contains`/`ncontains`/`startsWith`/`endsWith` are case-insensitive text matches \u2014 except on a multi-select column, where `contains`/`ncontains` mean set membership by option name. `like`/`nlike` are case-sensitive and `ilike`/`nilike` case-insensitive patterns with `*` as the only wildcard (literal `%`/`_` match themselves). `isEmpty`/`isNotEmpty` treat null and empty string as empty; `isNull`/`isNotNull` are strict null checks. The four `is*` operators take no `value`. Negated text matches retain rows where the cell is absent. `in`/`nin` require a non-empty array of at most 1000 values; other value-taking operators reject arrays. Select columns accept only equality/membership operators appropriate to their cardinality (single: eq/ne/in/nin; multi: contains/ncontains; both: the `is*` checks)." - }, - "value": { - "description": "Operand. Omit for the `is*` operators. Ranges on `number` columns require numbers, on `date` columns ISO strings (compared as UTC, independent of any session timezone); ranges on `boolean`/`json` columns are rejected." - } - } - }, - "SelectOption": { - "type": "object", - "required": ["id", "name"], - "properties": { - "id": { - "type": "string", - "description": "Stable option id \u2014 the value stored in cells." - }, - "name": { - "type": "string", - "maxLength": 100, - "description": "Display name. Filters on select columns accept names (resolved case-insensitively)." - } } } }, @@ -2648,6 +6100,70 @@ } } } + }, + "Conflict": { + "description": "The request conflicts with the current state of the resource — for example a rename to a name another table in the workspace already uses.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "A table named \"contacts\" already exists" + } + } + } + } + }, + "Locked": { + "description": "The table has a lock that forbids this operation. Clear the relevant lock with `PATCH /api/v2/tables/{tableId}` (workspace admin only) and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "Schema changes are locked for this table" + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The import source exceeds the 5 GB resource limit.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "CSV import file exceeds maximum size" + } + } + } + } + }, + "Gone": { + "description": "The generated export file has aged out of storage. Start a new export rather than retrying this download.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Export file is no longer available" + } + } + } + } } } } diff --git a/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts new file mode 100644 index 00000000000..560357745da --- /dev/null +++ b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts @@ -0,0 +1,64 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { completeWorkspaceFileUploadContract } from '@/lib/api/contracts/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { getWorkspaceFile, registerUploadedWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { + completeUploadSession, + getOwnedUploadSession, +} from '@/lib/uploads/multipart-session/service' +import { + requireUploadUser, + requireWorkspaceWrite, + uploadSessionErrorResponse, +} from '@/app/api/files/uploads/utils' +import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' + +interface UploadRouteParams { + params: Promise<{ uploadId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, context: UploadRouteParams) => { + const user = await requireUploadUser() + if (user instanceof NextResponse) return user + const parsed = await parseRequest(completeWorkspaceFileUploadContract, request, context) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const access = await requireWorkspaceWrite(user, workspaceId) + if (access) return access + try { + const upload = getOwnedUploadSession({ + uploadId: parsed.data.params.uploadId, + workspaceId, + userId: user, + uploadToken: parsed.data.headers['upload-token'], + }) + const metadata = upload.metadata as { folderId?: string | null } + const completed = await completeUploadSession({ + session: upload, + parts: parsed.data.body.parts, + finalize: async (claimed) => { + const registered = await registerUploadedWorkspaceFile({ + workspaceId, + userId: user, + key: claimed.storageKey, + originalName: claimed.fileName, + contentType: claimed.contentType, + folderId: metadata.folderId, + }) + return { value: registered.file.id, completedFileId: registered.file.id } + }, + }) + const fileId = completed.value + if (!fileId) throw new Error('Completed upload is missing its workspace file id') + const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) + if (!file) throw new Error(`Completed workspace file ${fileId} not found`) + if (!completed.alreadyCompleted) await notifyWorkspaceFilesChanged(workspaceId) + return NextResponse.json({ data: toV2FileUpload(completed.session, file) }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts new file mode 100644 index 00000000000..a01a9c4ca9d --- /dev/null +++ b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts @@ -0,0 +1,45 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createWorkspaceFileUploadPartUrlsContract } from '@/lib/api/contracts/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createUploadPartUrls, + getOwnedUploadSession, +} from '@/lib/uploads/multipart-session/service' +import { + requireUploadUser, + requireWorkspaceWrite, + uploadSessionErrorResponse, +} from '@/app/api/files/uploads/utils' + +interface UploadRouteParams { + params: Promise<{ uploadId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, context: UploadRouteParams) => { + const user = await requireUploadUser() + if (user instanceof NextResponse) return user + const parsed = await parseRequest(createWorkspaceFileUploadPartUrlsContract, request, context) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const access = await requireWorkspaceWrite(user, workspaceId) + if (access) return access + try { + const upload = getOwnedUploadSession({ + uploadId: parsed.data.params.uploadId, + workspaceId, + userId: user, + uploadToken: parsed.data.headers['upload-token'], + }) + const parts = await createUploadPartUrls({ + session: upload, + partNumbers: parsed.data.body.partNumbers, + localOrigin: request.nextUrl.origin, + }) + return NextResponse.json({ data: { parts } }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/route.ts new file mode 100644 index 00000000000..ffda91c0c44 --- /dev/null +++ b/apps/sim/app/api/files/uploads/[uploadId]/route.ts @@ -0,0 +1,38 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { abortWorkspaceFileUploadContract } from '@/lib/api/contracts/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/multipart-session/service' +import { + requireUploadUser, + requireWorkspaceWrite, + uploadSessionErrorResponse, +} from '@/app/api/files/uploads/utils' +import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' + +interface UploadRouteParams { + params: Promise<{ uploadId: string }> +} + +export const DELETE = withRouteHandler(async (request: NextRequest, context: UploadRouteParams) => { + const user = await requireUploadUser() + if (user instanceof NextResponse) return user + const parsed = await parseRequest(abortWorkspaceFileUploadContract, request, context) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const access = await requireWorkspaceWrite(user, workspaceId) + if (access) return access + try { + const upload = getOwnedUploadSession({ + uploadId: parsed.data.params.uploadId, + workspaceId, + userId: user, + uploadToken: parsed.data.headers['upload-token'], + }) + return NextResponse.json({ data: toV2FileUpload(await abortUploadSession(upload), null) }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/files/uploads/route.ts b/apps/sim/app/api/files/uploads/route.ts new file mode 100644 index 00000000000..25385b370f9 --- /dev/null +++ b/apps/sim/app/api/files/uploads/route.ts @@ -0,0 +1,39 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createWorkspaceFileUploadContract } from '@/lib/api/contracts/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' +import { createUploadSession } from '@/lib/uploads/multipart-session/service' +import { + requireUploadUser, + requireWorkspaceWrite, + uploadSessionErrorResponse, +} from '@/app/api/files/uploads/utils' +import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' + +export const POST = withRouteHandler(async (request: NextRequest) => { + const user = await requireUploadUser() + if (user instanceof NextResponse) return user + const parsed = await parseRequest(createWorkspaceFileUploadContract, request, {}) + if (!parsed.success) return parsed.response + const { workspaceId, name, contentType, size, folderId } = parsed.data.body + const access = await requireWorkspaceWrite(user, workspaceId) + if (access) return access + try { + const normalizedFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId) + const upload = await createUploadSession({ + workspaceId, + userId: user, + purpose: 'workspace_file', + fileName: name, + contentType, + fileSize: size, + metadata: { folderId: normalizedFolderId }, + }) + return NextResponse.json({ data: toV2FileUpload(upload, null) }, { status: 201 }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/files/uploads/utils.ts b/apps/sim/app/api/files/uploads/utils.ts new file mode 100644 index 00000000000..b4530be82ca --- /dev/null +++ b/apps/sim/app/api/files/uploads/utils.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +export async function requireUploadUser(): Promise { + const session = await getSession() + return session?.user?.id ?? NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +} + +export async function requireWorkspaceWrite( + userId: string, + workspaceId: string +): Promise { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + return permission === 'write' || permission === 'admin' + ? null + : NextResponse.json({ error: 'Forbidden' }, { status: 403 }) +} + +export function uploadSessionErrorResponse(error: unknown): NextResponse | null { + const classified = asOrchestrationError(error) + return classified + ? NextResponse.json( + { error: classified.message }, + { status: statusForOrchestrationError(classified.code) } + ) + : null +} diff --git a/apps/sim/app/api/table/[tableId]/export/route.ts b/apps/sim/app/api/table/[tableId]/export/route.ts index 58df047c629..17a845ed0ae 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.ts @@ -1,25 +1,18 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { tableExportFormatSchema, tableIdParamsSchema } from '@/lib/api/contracts/tables' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { neutralizeCsvFormula } from '@/lib/core/utils/csv' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { namedRowMapper } from '@/lib/table/cell-format' -import { getColumnId } from '@/lib/table/column-keys' -import { formatCsvCell } from '@/lib/table/export-format' -import { queryRows } from '@/lib/table/rows/service' +import { + createTableExportStream, + exportContentType, + sanitizeExportFilename, +} from '@/lib/table/export-stream' import { accessError, checkAccess } from '@/app/api/table/utils' -const logger = createLogger('TableExport') - -const EXPORT_BATCH_SIZE = 1000 - -type ExportFormat = 'csv' | 'json' - interface RouteParams { params: Promise<{ tableId: string }> } @@ -45,19 +38,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou { status: 400 } ) } - const format: ExportFormat = formatValidation.data + const format = formatValidation.data const access = await checkAccess(tableId, auth.userId, 'read') if (!access.ok) return accessError(access, requestId, tableId) const { table } = access - const columns = table.schema.columns - // Stored row data is id-keyed; CSV headers and JSON keys are display names, so - // translate id → name on the way out (export is a name-friendly boundary). - const toNamedRow = namedRowMapper(columns) - const safeName = sanitizeFilename(table.name) - const filename = `${safeName}.${format}` - // Audit before streaming: rows leave incrementally, so a mid-stream failure still exfiltrates partial data. recordAudit({ workspaceId: table.workspaceId ?? null, @@ -79,80 +65,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou ) } - const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder() - try { - if (format === 'csv') { - controller.enqueue( - encoder.encode(`${toCsvRow(columns.map((c) => neutralizeCsvFormula(c.name)))}\n`) - ) - } else { - controller.enqueue(encoder.encode('[')) - } - - let offset = 0 - let firstJsonRow = true - while (true) { - const result = await queryRows( - table, - { limit: EXPORT_BATCH_SIZE, offset, includeTotal: false }, - requestId - ) - - for (const row of result.rows) { - if (format === 'csv') { - const values = columns.map((c) => formatCsvCell(c, row.data[getColumnId(c)])) - controller.enqueue(encoder.encode(`${toCsvRow(values)}\n`)) - } else { - const prefix = firstJsonRow ? '' : ',' - firstJsonRow = false - controller.enqueue(encoder.encode(prefix + JSON.stringify(toNamedRow(row.data)))) - } - } - - // A page can be cut by the byte budget before reaching EXPORT_BATCH_SIZE, - // so a short page does NOT mean the export is done — only a null cursor does. - if (!result.nextCursor) break - offset += result.rows.length - } - - if (format === 'json') controller.enqueue(encoder.encode(']')) - controller.close() - - logger.info(`[${requestId}] Exported table ${tableId}`, { - format, - rowCount: table.rowCount, - }) - } catch (err) { - logger.error(`[${requestId}] Export failed for table ${tableId}`, err) - controller.error(err) - } - }, - }) - - return new NextResponse(stream, { + return new NextResponse(createTableExportStream(table, format, requestId), { status: 200, headers: { - 'Content-Type': format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json', - 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Type': exportContentType(format), + 'Content-Disposition': `attachment; filename="${sanitizeExportFilename(table.name)}.${format}"`, 'Cache-Control': 'no-store', }, }) }) - -function sanitizeFilename(name: string): string { - const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') - return cleaned || 'table' -} - -function toCsvRow(values: string[]): string { - return values.map(escapeCsvField).join(',') -} - -function escapeCsvField(field: string): string { - if (/[",\n\r]/.test(field)) { - return `"${field.replace(/"/g, '""')}"` - } - return field -} diff --git a/apps/sim/app/api/table/[tableId]/exports/route.ts b/apps/sim/app/api/table/[tableId]/exports/route.ts new file mode 100644 index 00000000000..525f455b81b --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/exports/route.ts @@ -0,0 +1,39 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createTableExportResourceContract } from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createTableExportResource, + toV2TableExport, +} from '@/lib/table/orchestration/export-resource' +import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils' + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(createTableExportResourceContract, request, context) + if (!parsed.success) return parsed.response + const access = await checkAccess(parsed.data.params.tableId, auth.userId, 'read') + if (!access.ok) return accessError(access, 'table-export') + if (access.table.workspaceId !== parsed.data.body.workspaceId) { + return NextResponse.json({ error: 'Table not found' }, { status: 404 }) + } + try { + const record = await createTableExportResource({ + table: access.table, + format: parsed.data.body.format, + }) + return NextResponse.json({ data: toV2TableExport(record, true) }, { status: 201 }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 3aa28fe34ee..465777f46a3 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -1,7 +1,5 @@ import type { Readable } from 'node:stream' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { csvExtensionSchema, @@ -14,39 +12,18 @@ import { import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - buildAutoMapping, - CSV_MAX_FILE_SIZE_BYTES, - type CsvHeaderMapping, - CsvImportValidationError, - coerceRowsForTable, - createCsvParser, - dispatchAfterBatchInsert, - generateColumnId, - getMaxRowsPerTable, - inferColumnType, - markTableJobRunning, - releaseJobClaim, - sanitizeName, - type TableDefinition, - type TableSchema, - validateMapping, - wouldExceedRowLimit, -} from '@/lib/table' -import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' -import { signalTableSchemaChanged } from '@/lib/table/events' -import { importAppendRows, importReplaceRows } from '@/lib/table/import-data' +import { CSV_MAX_FILE_SIZE_BYTES, type CsvHeaderMapping } from '@/lib/table' +import { performTableCsvImport } from '@/lib/table/orchestration' import { getUserSettings } from '@/lib/users/queries' import { accessError, checkAccess, csvProxyBodyCapResponse, multipartErrorResponse, - tableLockErrorResponse, } from '@/app/api/table/utils' const logger = createLogger('TableImportCSVExisting') @@ -63,7 +40,6 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const requestId = generateRequestId() const { tableId } = tableIdParamsSchema.parse(await params) let fileStream: Readable | undefined - let claimedImportId: string | null = null try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -132,18 +108,6 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - if (table.archivedAt) { - return NextResponse.json({ error: 'Cannot import into an archived table' }, { status: 400 }) - } - // Don't run a sync import on top of an in-flight background job — concurrent writers - // would insert at colliding row positions. - if (table.jobStatus === 'running') { - return NextResponse.json( - { error: 'A job is already in progress for this table' }, - { status: 409 } - ) - } - let mapping: CsvHeaderMapping | undefined if (fields.mapping) { const mappingValidation = csvImportMappingSchema.safeParse(fields.mapping) @@ -180,246 +144,46 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro timezone = timezoneValidation.data } - // The extension only picks the fallback — the separator is sniffed from the file's - // head so semicolon/pipe exports (European-locale Excel) don't land in one column. - const { delimiter, stream: csvStream } = await sniffCsvDelimiterFromStream( - file.stream, - extensionValidation.data === 'tsv' ? '\t' : ',' - ) - let headers: string[] = [] - const parser = createCsvParser(delimiter, (parsedHeaders) => { - headers = parsedHeaders + const outcome = await performTableCsvImport({ + table, + workspaceId, + userId: authResult.userId, + fileStream: file.stream, + fileName: file.filename, + fallbackDelimiter: extensionValidation.data === 'tsv' ? '\t' : ',', + mode, + mapping, + createColumns, + timezone, + requestId, }) - // `.pipe` doesn't forward source errors; forward them so the iterator throws. - csvStream.on('error', (streamErr) => parser.destroy(streamErr)) - csvStream.pipe(parser) - const rows: Record[] = [] - for await (const record of parser as AsyncIterable>) { - rows.push(record) - } - if (rows.length === 0) { - return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 }) - } - - let effectiveMapping = mapping ?? buildAutoMapping(headers, table.schema) - let prospectiveTable: TableDefinition = table - const additions: { id?: string; name: string; type: string }[] = [] - - if (createColumns && createColumns.length > 0) { - const headerSet = new Set(headers) - const unknownHeaders = createColumns.filter((h) => !headerSet.has(h)) - if (unknownHeaders.length > 0) { - return NextResponse.json( - { - error: `createColumns references unknown CSV headers: ${unknownHeaders.join(', ')}`, - }, - { status: 400 } - ) - } - - const usedNames = new Set(table.schema.columns.map((c) => c.name.toLowerCase())) - const updatedMapping: CsvHeaderMapping = { ...effectiveMapping } - const newColumns: TableSchema['columns'] = [] - for (const header of createColumns) { - const base = sanitizeName(header) - let columnName = base - let suffix = 2 - while (usedNames.has(columnName.toLowerCase())) { - columnName = `${base}_${suffix}` - suffix++ - } - usedNames.add(columnName.toLowerCase()) - const inferredType = inferColumnType(rows.map((r) => r[header])) - // Pre-assign the id so the prospective schema (used to coerce rows) and - // the persisted column (created in importAppendRows) share the same key. - const id = generateColumnId() - additions.push({ id, name: columnName, type: inferredType }) - newColumns.push({ - id, - name: columnName, - type: inferredType as TableSchema['columns'][number]['type'], - required: false, - unique: false, - }) - updatedMapping[header] = columnName + if (!outcome.success) { + // A lock rejection renders `{ error, lock }` and deliberately carries NO + // `details`: the client's `isValidationError` treats any array-valued + // `details` as a field-validation error and swallows the toast. + if (outcome.errorCode === 'locked') { + return NextResponse.json({ error: outcome.error, lock: outcome.lock }, { status: 423 }) } - - prospectiveTable = { - ...table, - schema: { columns: [...table.schema.columns, ...newColumns] }, - } - effectiveMapping = updatedMapping - } - - let validation: ReturnType - try { - validation = validateMapping({ - csvHeaders: headers, - mapping: effectiveMapping, - tableSchema: prospectiveTable.schema, - }) - } catch (err) { - if (err instanceof CsvImportValidationError) { - return NextResponse.json({ error: err.message, details: err.details }, { status: 400 }) - } - throw err - } - - if (validation.mappedHeaders.length === 0) { return NextResponse.json( { - error: `No CSV headers map to columns on the table. CSV headers: ${headers.join(', ')}. Table columns: ${prospectiveTable.schema.columns.map((c) => c.name).join(', ')}`, + error: outcome.errorCode === 'internal' ? 'Failed to import CSV' : outcome.error, + ...(outcome.details !== undefined ? { details: outcome.details } : {}), + // The append dialog reads this to distinguish "nothing landed" from a + // partial import; only that mode has ever carried it. + ...(mode === 'append' ? { data: { insertedCount: 0 } } : {}), }, - { status: 400 } + { status: statusForOrchestrationError(outcome.errorCode) } ) } - const coerced = coerceRowsForTable(rows, prospectiveTable.schema, validation.effectiveMap, { - timezone, - }) - - // Atomically claim the table before writing. The pre-check above reads a checkAccess snapshot - // taken before the parse/validation; a background import could claim the table in that window. - // markTableJobRunning is the single atomic gate (same one the async kickoff uses) — released in - // the finally so a sync import can't write concurrently with a background one (corrupts replace). - const syncImportId = generateId() - if (!(await markTableJobRunning(tableId, syncImportId, 'import'))) { - return NextResponse.json( - { error: 'A job is already in progress for this table' }, - { status: 409 } - ) - } - claimedImportId = syncImportId - - if (mode === 'append') { - const maxRows = await getMaxRowsPerTable(workspaceId) - if (wouldExceedRowLimit(maxRows, prospectiveTable.rowCount, coerced.length)) { - const deficit = prospectiveTable.rowCount + coerced.length - maxRows - return NextResponse.json( - { - error: `Append would exceed table row limit (${maxRows}). Currently ${prospectiveTable.rowCount} rows, ${coerced.length} new rows, ${deficit} over.`, - }, - { status: 400 } - ) - } - - try { - const { inserted: insertedRows, table: finalTable } = await importAppendRows( - table, - additions, - coerced, - { workspaceId, userId: authResult.userId, requestId } - ) - const inserted = insertedRows.length - // Fire trigger + scheduler AFTER the tx commits — both read through the - // global db connection and would otherwise see no rows. - dispatchAfterBatchInsert(finalTable, insertedRows, requestId, authResult.userId) - - logger.info(`[${requestId}] Append CSV imported`, { - tableId: table.id, - fileName: file.filename, - mode, - inserted, - createdColumns: additions.length, - mappedColumns: validation.mappedHeaders.length, - skippedHeaders: validation.skippedHeaders.length, - }) - signalTableSchemaChanged(tableId) - - return NextResponse.json({ - success: true, - data: { - tableId: table.id, - mode, - insertedCount: inserted, - mappedColumns: validation.mappedHeaders, - skippedHeaders: validation.skippedHeaders, - unmappedColumns: validation.unmappedColumns, - sourceFile: file.filename, - }, - }) - } catch (err) { - // This branch returns rather than rethrowing, so the outer catch's - // mapper is unreachable from here — map the lock error first or a 423 - // degrades into a generic 500 (replace mode rethrows and maps fine). - const lockError = tableLockErrorResponse(err) - if (lockError) return lockError - - const message = toError(err).message - logger.warn(`[${requestId}] Append failed for table ${tableId}`, { - total: coerced.length, - createdColumns: additions.length, - error: message, - }) - const classified = asOrchestrationError(err) - return NextResponse.json( - { - error: classified ? classified.message : 'Failed to import CSV', - data: { insertedCount: 0 }, - }, - { status: classified ? statusForOrchestrationError(classified.code) : 500 } - ) - } - } - - try { - const result = await importReplaceRows( - table, - additions, - { rows: coerced, workspaceId, userId: authResult.userId }, - requestId - ) - - logger.info(`[${requestId}] Replace CSV imported`, { - tableId: table.id, - fileName: file.filename, - mode, - deleted: result.deletedCount, - inserted: result.insertedCount, - createdColumns: additions.length, - mappedColumns: validation.mappedHeaders.length, - }) - signalTableSchemaChanged(tableId) - - return NextResponse.json({ - success: true, - data: { - tableId: table.id, - mode, - deletedCount: result.deletedCount, - insertedCount: result.insertedCount, - mappedColumns: validation.mappedHeaders, - skippedHeaders: validation.skippedHeaders, - unmappedColumns: validation.unmappedColumns, - sourceFile: file.filename, - }, - }) - } catch (err) { - const classified = asOrchestrationError(err) - if (classified) { - return NextResponse.json( - { error: classified.message }, - { status: statusForOrchestrationError(classified.code) } - ) - } - throw err - } + return NextResponse.json({ success: true, data: outcome.data }) } catch (error) { - const lockError = tableLockErrorResponse(error) - if (lockError) return lockError if (isMultipartError(error)) return multipartErrorResponse(error) logger.error(`[${requestId}] CSV import into existing table failed:`, error) - - const classified = asOrchestrationError(error) - return NextResponse.json( - { error: classified ? classified.message : 'Failed to import CSV' }, - { status: classified ? statusForOrchestrationError(classified.code) : 500 } - ) + return NextResponse.json({ error: 'Failed to import CSV' }, { status: 500 }) } finally { fileStream?.destroy() - // Release before the response returns, so a client refetch never observes the transient claim. - if (claimedImportId) await releaseJobClaim(tableId, claimedImportId).catch(() => {}) } }) diff --git a/apps/sim/app/api/table/exports/[exportId]/download/route.ts b/apps/sim/app/api/table/exports/[exportId]/download/route.ts new file mode 100644 index 00000000000..93ba5175585 --- /dev/null +++ b/apps/sim/app/api/table/exports/[exportId]/download/route.ts @@ -0,0 +1,47 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { downloadTableExportResourceContract } from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { requireTableExport, tableExportResult } from '@/lib/table/orchestration/export-resource' +import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' +import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils' + +const DOWNLOAD_TTL_SECONDS = 60 * 60 + +interface ExportRouteParams { + params: Promise<{ exportId: string }> +} + +export const GET = withRouteHandler(async (request: NextRequest, context: ExportRouteParams) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(downloadTableExportResourceContract, request, context) + if (!parsed.success) return parsed.response + try { + const record = await requireTableExport( + parsed.data.params.exportId, + parsed.data.query.workspaceId + ) + const access = await checkAccess(record.tableId, auth.userId, 'read') + if (!access.ok) return accessError(access, 'table-export') + const result = tableExportResult(record) + return NextResponse.json({ + data: { + url: await generatePresignedDownloadUrl( + result.resultKey, + 'workspace', + DOWNLOAD_TTL_SECONDS + ), + fileName: result.resultKey.split('/').pop() ?? `export.${result.format}`, + expiresAt: new Date(Date.now() + DOWNLOAD_TTL_SECONDS * 1000).toISOString(), + }, + }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/table/exports/[exportId]/route.ts b/apps/sim/app/api/table/exports/[exportId]/route.ts new file mode 100644 index 00000000000..c7e9f56b405 --- /dev/null +++ b/apps/sim/app/api/table/exports/[exportId]/route.ts @@ -0,0 +1,68 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { + cancelTableExportResourceContract, + getTableExportResourceContract, +} from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + cancelTableExportResource, + requireTableExport, + toV2TableExport, +} from '@/lib/table/orchestration/export-resource' +import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils' + +interface ExportRouteParams { + params: Promise<{ exportId: string }> +} + +async function authorizedExport(exportId: string, workspaceId: string, userId: string) { + const record = await requireTableExport(exportId, workspaceId) + const access = await checkAccess(record.tableId, userId, 'read') + return { record, access } +} + +export const GET = withRouteHandler(async (request: NextRequest, context: ExportRouteParams) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(getTableExportResourceContract, request, context) + if (!parsed.success) return parsed.response + try { + const { record, access } = await authorizedExport( + parsed.data.params.exportId, + parsed.data.query.workspaceId, + auth.userId + ) + if (!access.ok) return accessError(access, 'table-export') + return NextResponse.json({ data: toV2TableExport(record) }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) + +export const DELETE = withRouteHandler(async (request: NextRequest, context: ExportRouteParams) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(cancelTableExportResourceContract, request, context) + if (!parsed.success) return parsed.response + try { + const { record, access } = await authorizedExport( + parsed.data.params.exportId, + parsed.data.query.workspaceId, + auth.userId + ) + if (!access.ok) return accessError(access, 'table-export') + return NextResponse.json({ data: toV2TableExport(await cancelTableExportResource(record)) }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index f84f457e820..ef4f1cc7547 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -1,40 +1,20 @@ import type { Readable } from 'node:stream' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { csvExtensionSchema, csvImportFormSchema } from '@/lib/api/contracts/tables' import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { getValidationErrorMessage } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { statusForOrchestrationError } from '@/lib/core/orchestration/types' import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { findActiveFolder } from '@/lib/folders/queries' -import { - batchInsertRows, - CSV_MAX_BATCH_SIZE, - CSV_MAX_FILE_SIZE_BYTES, - CSV_SCHEMA_SAMPLE_SIZE, - coerceRowsForTable, - createCsvParser, - createTable, - deleteTable, - getWorkspaceTableLimits, - inferSchemaFromCsv, - sanitizeName, - TABLE_LIMITS, - type TableDefinition, - type TableSchema, -} from '@/lib/table' -import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' +import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table' +import { performCreateTableFromCsv } from '@/lib/table/orchestration' import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { - csvProxyBodyCapResponse, - multipartErrorResponse, - normalizeColumn, - orchestrationErrorResponse, -} from '@/app/api/table/utils' +import { csvProxyBodyCapResponse, multipartErrorResponse } from '@/app/api/table/utils' const logger = createLogger('TableImportCSV') @@ -125,135 +105,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } - // The extension only picks the fallback — the separator is sniffed from the file's - // head so semicolon/pipe exports (European-locale Excel) don't land in one column. - const { delimiter, stream: csvStream } = await sniffCsvDelimiterFromStream( - file.stream, - extensionResult.data === 'tsv' ? '\t' : ',' - ) - let csvHeaders: string[] = [] - const parser = createCsvParser(delimiter, (headers) => { - csvHeaders = headers + const outcome = await performCreateTableFromCsv({ + workspaceId, + userId, + fileStream: file.stream, + fileName: file.filename, + fallbackDelimiter: extensionResult.data === 'tsv' ? '\t' : ',', + folderId, + timezone, + requestId, }) - // `.pipe` doesn't forward source errors; forward them so the iterator throws. - csvStream.on('error', (err) => parser.destroy(err)) - csvStream.pipe(parser) - - interface ImportState { - table: TableDefinition - schema: TableSchema - headerToColumn: Map - } - - const insertRows = async ( - rows: Record[], - state: ImportState, - currentRowCount: number - ) => { - if (rows.length === 0) return 0 - const coerced = coerceRowsForTable(rows, state.schema, state.headerToColumn, { timezone }) - const result = await batchInsertRows( - { tableId: state.table.id, rows: coerced, workspaceId, userId }, - // The created table's rowCount is frozen at 0; pass the running total so the - // per-batch capacity check sees cumulative rows, not an always-empty table. - { ...state.table, rowCount: currentRowCount }, - generateId().slice(0, 8) - ) - return result.length - } - /** Infer the schema from the buffered sample and create the (empty) table. */ - const buildTable = async (sampleRows: Record[]): Promise => { - const inferred = inferSchemaFromCsv(csvHeaders, sampleRows) - const schema: TableSchema = { columns: inferred.columns.map(normalizeColumn) } - const planLimits = await getWorkspaceTableLimits(workspaceId) - const tableName = sanitizeName(file.filename.replace(/\.[^.]+$/, ''), 'imported_table').slice( - 0, - TABLE_LIMITS.MAX_TABLE_NAME_LENGTH - ) - const table = await createTable( - { - name: tableName, - description: `Imported from ${file.filename}`, - schema, - workspaceId, - folderId, - userId, - maxTables: planLimits.maxTables, - }, - requestId + if (!outcome.success) { + return NextResponse.json( + { error: outcome.errorCode === 'internal' ? 'Failed to import CSV' : outcome.error }, + { status: statusForOrchestrationError(outcome.errorCode) } ) - // Coerce against the *created* schema so rows key by the ids `createTable` - // assigned (the local `schema` is the id-less inferred one). - return { table, schema: table.schema, headerToColumn: inferred.headerToColumn } } - let state: ImportState | null = null - let inserted = 0 - const sample: Record[] = [] - let batch: Record[] = [] - - try { - for await (const record of parser as AsyncIterable>) { - if (!state) { - sample.push(record) - if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE) { - state = await buildTable(sample) - inserted += await insertRows(sample, state, inserted) - } - continue - } - batch.push(record) - if (batch.length >= CSV_MAX_BATCH_SIZE) { - inserted += await insertRows(batch, state, inserted) - batch = [] - } - } - - if (!state) { - if (sample.length === 0) { - return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 }) - } - state = await buildTable(sample) - inserted += await insertRows(sample, state, inserted) - } else { - inserted += await insertRows(batch, state, inserted) - } - } catch (streamError) { - if (state) await deleteTable(state.table.id, requestId).catch(() => {}) - throw streamError - } - - logger.info(`[${requestId}] CSV imported`, { - tableId: state.table.id, - fileName: file.filename, - columns: state.schema.columns.length, - rows: inserted, - }) - - return NextResponse.json({ - success: true, - data: { - table: { - id: state.table.id, - name: state.table.name, - description: state.table.description, - schema: state.schema, - rowCount: inserted, - }, - }, - }) + return NextResponse.json({ success: true, data: outcome.data }) } catch (error) { if (isMultipartError(error)) return multipartErrorResponse(error) logger.error(`[${requestId}] CSV import failed:`, error) - - // Every caller-fixable failure on this path — the plan row-limit check, the - // schema and CSV-shape validation, a name collision — arrives classified. - const classified = orchestrationErrorResponse(error) - if (classified) return classified - return NextResponse.json({ error: 'Failed to import CSV' }, { status: 500 }) } finally { fileStream?.destroy() diff --git a/apps/sim/app/api/table/imports/[importId]/complete/route.ts b/apps/sim/app/api/table/imports/[importId]/complete/route.ts new file mode 100644 index 00000000000..4ca1b13974c --- /dev/null +++ b/apps/sim/app/api/table/imports/[importId]/complete/route.ts @@ -0,0 +1,52 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { completeTableImportResourceContract } from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + findOwnedTableImport, + getOwnedTableImportUpload, + startUploadedTableImport, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +import { completeUploadSession } from '@/lib/uploads/multipart-session/service' +import { orchestrationErrorResponse } from '@/app/api/table/utils' + +interface ImportRouteParams { + params: Promise<{ importId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(completeTableImportResourceContract, request, context) + if (!parsed.success) return parsed.response + try { + const upload = getOwnedTableImportUpload({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId: auth.userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const existing = await findOwnedTableImport({ + importId: upload.id, + workspaceId: upload.workspaceId, + userId: upload.userId, + }) + if (existing) return NextResponse.json({ data: toV2TableImport(existing) }) + const completed = await completeUploadSession({ + session: upload, + parts: parsed.data.body.parts, + finalize: async () => ({ value: null }), + }) + return NextResponse.json({ + data: toV2TableImport(await startUploadedTableImport(completed.session)), + }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/table/imports/[importId]/parts/route.ts b/apps/sim/app/api/table/imports/[importId]/parts/route.ts new file mode 100644 index 00000000000..f3534b65958 --- /dev/null +++ b/apps/sim/app/api/table/imports/[importId]/parts/route.ts @@ -0,0 +1,39 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createTableImportPartUrlsContract } from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getOwnedTableImportUpload } from '@/lib/table/orchestration/import-resource' +import { createUploadPartUrls } from '@/lib/uploads/multipart-session/service' +import { orchestrationErrorResponse } from '@/app/api/table/utils' + +interface ImportRouteParams { + params: Promise<{ importId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(createTableImportPartUrlsContract, request, context) + if (!parsed.success) return parsed.response + try { + const upload = getOwnedTableImportUpload({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId: auth.userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const parts = await createUploadPartUrls({ + session: upload, + partNumbers: parsed.data.body.partNumbers, + localOrigin: request.nextUrl.origin, + }) + return NextResponse.json({ data: { parts } }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/table/imports/[importId]/route.ts b/apps/sim/app/api/table/imports/[importId]/route.ts new file mode 100644 index 00000000000..15fb5cd2914 --- /dev/null +++ b/apps/sim/app/api/table/imports/[importId]/route.ts @@ -0,0 +1,76 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { + cancelTableImportResourceContract, + getTableImportResourceContract, +} from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + abortTableImportUpload, + cancelTableImportResource, + getOwnedTableImport, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +import { orchestrationErrorResponse } from '@/app/api/table/utils' + +interface ImportRouteParams { + params: Promise<{ importId: string }> +} + +async function userId(request: NextRequest): Promise { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + return auth.success && auth.userId + ? auth.userId + : NextResponse.json({ error: 'Authentication required' }, { status: 401 }) +} + +export const GET = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { + const user = await userId(request) + if (user instanceof NextResponse) return user + const parsed = await parseRequest(getTableImportResourceContract, request, context) + if (!parsed.success) return parsed.response + try { + const record = await getOwnedTableImport({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId: user, + }) + return NextResponse.json({ data: await toV2TableImport(record) }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) + +export const DELETE = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { + const user = await userId(request) + if (user instanceof NextResponse) return user + const parsed = await parseRequest(cancelTableImportResourceContract, request, context) + if (!parsed.success) return parsed.response + try { + const uploadToken = parsed.data.headers['upload-token'] + const record = uploadToken + ? await abortTableImportUpload({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId: user, + uploadToken, + }) + : await cancelTableImportResource( + await getOwnedTableImport({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId: user, + }) + ) + return NextResponse.json({ + data: toV2TableImport(record), + }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/table/imports/route.ts b/apps/sim/app/api/table/imports/route.ts new file mode 100644 index 00000000000..254420388fe --- /dev/null +++ b/apps/sim/app/api/table/imports/route.ts @@ -0,0 +1,27 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createTableImportResourceContract } from '@/lib/api/contracts/table-transfers' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createTableImportResource, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +import { orchestrationErrorResponse } from '@/app/api/table/utils' + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(createTableImportResourceContract, request, {}) + if (!parsed.success) return parsed.response + try { + const created = await createTableImportResource(parsed.data.body, auth.userId) + return NextResponse.json({ data: await toV2TableImport(created.record) }, { status: 201 }) + } catch (error) { + const classified = orchestrationErrorResponse(error) + if (classified) return classified + throw error + } +}) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 2f994150296..93627bc4105 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -37,9 +37,18 @@ export type ApiEndpoint = | 'audit-logs' | 'tables' | 'table-detail' + | 'table-restore' | 'table-rows' | 'table-row-detail' + | 'table-rows-find' | 'table-columns' + | 'table-views' + | 'table-view-detail' + | 'table-groups' + | 'table-enrichment' + | 'table-import' + | 'table-export' + | 'table-jobs' | 'files' | 'file-detail' | 'file-share' diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index c6e68913959..ac6c3dca5bd 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -1,29 +1,18 @@ /** * @vitest-environment node * - * Public v2 files list/upload: gate ordering, the `scope` split that makes - * Recently Deleted reachable, and folder-targeted upload. + * Public v2 files list: gate ordering and the `scope` split that makes Recently Deleted reachable. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockQueryWorkspaceFiles, - mockUploadWorkspaceFile, - mockGetWorkspaceFile, - mockReadFormDataWithLimit, - mockReadFileToBufferWithLimit, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockQueryWorkspaceFiles: vi.fn(), - mockUploadWorkspaceFile: vi.fn(), - mockGetWorkspaceFile: vi.fn(), - mockReadFormDataWithLimit: vi.fn(), - mockReadFileToBufferWithLimit: vi.fn(), -})) +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockQueryWorkspaceFiles } = vi.hoisted( + () => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockQueryWorkspaceFiles: vi.fn(), + }) +) vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit, @@ -36,25 +25,10 @@ vi.mock('@/app/api/v2/lib/gate', () => ({ vi.mock('@/lib/uploads/contexts/workspace', () => ({ queryWorkspaceFiles: mockQueryWorkspaceFiles, - uploadWorkspaceFile: mockUploadWorkspaceFile, - getWorkspaceFile: mockGetWorkspaceFile, - FileConflictError: class FileConflictError extends Error {}, -})) - -vi.mock('@/lib/core/utils/stream-limits', () => ({ - readFormDataWithLimit: mockReadFormDataWithLimit, - readFileToBufferWithLimit: mockReadFileToBufferWithLimit, - isPayloadSizeLimitError: () => false, -})) - -vi.mock('@sim/audit', () => ({ - recordAudit: vi.fn(), - AuditAction: { FILE_UPLOADED: 'file.uploaded' }, - AuditResourceType: { FILE: 'file' }, })) import { OrchestrationError } from '@/lib/core/orchestration/types' -import { GET, POST } from '@/app/api/v2/files/route' +import { GET } from '@/app/api/v2/files/route' const WS = 'workspace-1' const FOLDER_ID = 'fold_1' @@ -108,15 +82,6 @@ const DEFAULT_LIST_ARGS = { const callList = (query: string) => GET(new NextRequest(`http://localhost:3000/api/v2/files?${query}`)) -const callUpload = (query: string) => - POST( - new NextRequest(`http://localhost:3000/api/v2/files?${query}`, { - method: 'POST', - headers: { 'Content-Type': 'multipart/form-data; boundary=x' }, - body: 'x', - }) - ) - describe('GET /api/v2/files', () => { beforeEach(() => { vi.clearAllMocks() @@ -309,125 +274,3 @@ describe('GET /api/v2/files', () => { expect((await res.json()).nextCursor).toBeNull() }) }) - -describe('POST /api/v2/files', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockReadFileToBufferWithLimit.mockResolvedValue(Buffer.from('id,name\n')) - mockUploadWorkspaceFile.mockResolvedValue({ id: 'wf_1' }) - mockGetWorkspaceFile.mockResolvedValue(buildRecord()) - - const form = new FormData() - form.set('file', new File(['id,name\n'], 'data.csv', { type: 'text/csv' })) - mockReadFormDataWithLimit.mockResolvedValue(form) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callUpload(`workspaceId=${WS}`) - - expect(res.status).toBe(404) - expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callUpload('folderId=fold_1') - expect(res.status).toBe(400) - expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure before buffering the body', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - const res = await callUpload(`workspaceId=${WS}`) - expect(res.status).toBe(403) - expect(mockReadFormDataWithLimit).not.toHaveBeenCalled() - expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callUpload(`workspaceId=${WS}`) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('uploads to the workspace root and returns 201 with the stored record', async () => { - const res = await callUpload(`workspaceId=${WS}`) - const body = await res.json() - - expect(res.status).toBe(201) - expect(body.data.id).toBe('wf_1') - expect(body.data.folderId).toBeNull() - expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( - WS, - 'user-1', - expect.any(Buffer), - 'data.csv', - 'text/csv', - { folderId: null } - ) - }) - - it('lands the upload in the folder named by folderId', async () => { - mockGetWorkspaceFile.mockResolvedValue( - buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' }) - ) - - const res = await callUpload(`workspaceId=${WS}&folderId=${FOLDER_ID}`) - const body = await res.json() - - expect(res.status).toBe(201) - expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( - WS, - 'user-1', - expect.any(Buffer), - 'data.csv', - 'text/csv', - { folderId: FOLDER_ID } - ) - expect(body.data.folderId).toBe(FOLDER_ID) - expect(body.data.folderPath).toBe('Reports/Q1') - }) - - it('404s when the target folder does not exist', async () => { - mockUploadWorkspaceFile.mockRejectedValue( - new OrchestrationError('not_found', 'Target folder not found') - ) - - const res = await callUpload(`workspaceId=${WS}&folderId=missing`) - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - }) - - it('413s on a blown storage quota by class, not by message wording', async () => { - mockUploadWorkspaceFile.mockRejectedValue( - new OrchestrationError('payload_too_large', 'Quota exceeded for this workspace') - ) - - const res = await callUpload(`workspaceId=${WS}`) - - expect(res.status).toBe(413) - expect((await res.json()).error.code).toBe('PAYLOAD_TOO_LARGE') - }) - - it('409s on a duplicate-name conflict by class', async () => { - mockUploadWorkspaceFile.mockRejectedValue( - new OrchestrationError('conflict', 'A file named "data.csv" already exists in this workspace') - ) - - const res = await callUpload(`workspaceId=${WS}`) - - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - }) -}) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index 2c088b8b611..e6fd3e6698c 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -1,24 +1,10 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { - type V2File, - v2ListFilesContract, - v2UploadFileContract, -} from '@/lib/api/contracts/v2/files' +import { type V2File, v2ListFilesContract } from '@/lib/api/contracts/v2/files' import { parseRequest } from '@/lib/api/server' -import { - isPayloadSizeLimitError, - readFileToBufferWithLimit, - readFormDataWithLimit, -} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - getWorkspaceFile, - queryWorkspaceFiles, - uploadWorkspaceFile, -} from '@/lib/uploads/contexts/workspace' +import { queryWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2File } from '@/app/api/v2/files/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -29,7 +15,6 @@ import { v2CaughtOrchestrationError, v2CursorList, v2CursorSortError, - v2Data, v2Error, v2RateLimitError, v2ValidationError, @@ -41,9 +26,6 @@ const logger = createLogger('V2FilesAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -const MAX_FILE_SIZE = 100 * 1024 * 1024 -const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 - /** * GET /api/v2/files — List files in a workspace with search, sort, and cursor * pagination. @@ -108,124 +90,3 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return v2Error('INTERNAL_ERROR', 'Internal server error') } }) - -/** - * POST /api/v2/files — Upload a file to a workspace. - * - * Authorization runs fully (rate limit → workspace write access) before the - * multipart body is buffered: the workspace and the optional target `folderId` - * are contract-validated query params, so an unauthorized caller never streams a - * 100 MB body into memory. - */ -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2UploadFileContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, folderId } = parsed.data.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - let formData: FormData - try { - formData = await readFormDataWithLimit(request, { - maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, - label: 'workspace file upload body', - }) - } catch (error) { - if (isPayloadSizeLimitError(error)) { - return v2Error('PAYLOAD_TOO_LARGE', error.message) - } - return v2Error('BAD_REQUEST', 'Request body must be valid multipart form data') - } - - const rawFile = formData.get('file') - const file = rawFile instanceof File ? rawFile : null - if (!file) { - return v2Error('BAD_REQUEST', 'file form field is required') - } - - if (file.size > MAX_FILE_SIZE) { - return v2Error( - 'PAYLOAD_TOO_LARGE', - `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` - ) - } - - const buffer = await readFileToBufferWithLimit(file, { - maxBytes: MAX_FILE_SIZE, - label: 'workspace upload file', - }) - - const userFile = await uploadWorkspaceFile( - workspaceId, - userId, - buffer, - file.name, - file.type || 'application/octet-stream', - { folderId: folderId ?? null } - ) - - logger.info(`Uploaded file: ${file.name} to workspace ${workspaceId}`) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.FILE_UPLOADED, - resourceType: AuditResourceType.FILE, - resourceId: userFile.id, - resourceName: file.name, - description: `Uploaded file "${file.name}" via API`, - metadata: { fileSize: file.size, fileType: file.type || 'application/octet-stream' }, - request, - }) - - /** - * `uploadWorkspaceFile` returns the executor-facing `UserFile`, which carries - * neither the folder path nor the persisted timestamps, so the stored record - * is the source for the response projection. - * - * `throwOnError` matters here: by default this reader swallows a query - * failure and returns `null`, which would make a transient blip on the read - * indistinguishable from the row being gone. The row was committed by the - * upload moments earlier on the same primary, so a genuine `null` is an - * invariant break — worth a 500 — while a transient failure should surface - * as itself rather than being reported as a missing file. - */ - const fileRecord = await getWorkspaceFile(workspaceId, userFile.id, { throwOnError: true }) - if (!fileRecord) { - throw new Error(`Uploaded file ${userFile.id} could not be read back`) - } - - return v2Data(toV2File(fileRecord), { rateLimit, status: 201 }) - } catch (error) { - if (isPayloadSizeLimitError(error)) { - return v2Error('PAYLOAD_TOO_LARGE', error.message) - } - - // Conflicts, a missing target folder, and a blown storage quota all arrive classified - // now, so the status comes off the error's code rather than its wording. - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - const message = getErrorMessage(error, 'Failed to upload file') - logger.error('Error uploading file', { error: message }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts new file mode 100644 index 00000000000..3dfca6ca127 --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts @@ -0,0 +1,95 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CompleteFileUploadContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getWorkspaceFile, registerUploadedWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { + completeUploadSession, + getOwnedUploadSession, +} from '@/lib/uploads/multipart-session/service' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CompleteFileUploadAPI') + +interface FileUploadRouteParams { + params: Promise<{ uploadId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: FileUploadRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2CompleteFileUploadContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const session = getOwnedUploadSession({ + uploadId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const metadata = session.metadata as { folderId?: string | null } + const result = await completeUploadSession({ + session, + parts: parsed.data.body.parts, + finalize: async (claimed) => { + const registered = await registerUploadedWorkspaceFile({ + workspaceId, + userId, + key: claimed.storageKey, + originalName: claimed.fileName, + contentType: claimed.contentType, + folderId: metadata.folderId, + }) + return { value: registered.file.id, completedFileId: registered.file.id } + }, + }) + const fileId = result.value + if (!fileId) throw new Error('Completed upload is missing its workspace file id') + const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) + if (!file) throw new Error(`Completed workspace file ${fileId} not found`) + + if (!result.alreadyCompleted) { + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.name, + description: `Uploaded file "${file.name}" via API`, + metadata: { fileSize: file.size, fileType: file.type }, + request, + }) + } + return v2Data(toV2FileUpload(result.session, file), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to complete file upload', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts new file mode 100644 index 00000000000..4272e75796f --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts @@ -0,0 +1,63 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateFileUploadPartUrlsContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createUploadPartUrls, + getOwnedUploadSession, +} from '@/lib/uploads/multipart-session/service' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileUploadPartsAPI') + +interface FileUploadRouteParams { + params: Promise<{ uploadId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: FileUploadRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2CreateFileUploadPartUrlsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const session = getOwnedUploadSession({ + uploadId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const parts = await createUploadPartUrls({ + session, + partNumbers: parsed.data.body.partNumbers, + localOrigin: request.nextUrl.origin, + }) + return v2Data({ parts }, { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create file upload part URLs', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts new file mode 100644 index 00000000000..cccb93f3524 --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts @@ -0,0 +1,57 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2AbortFileUploadContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/multipart-session/service' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileUploadAPI') + +interface FileUploadRouteParams { + params: Promise<{ uploadId: string }> +} + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: FileUploadRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2AbortFileUploadContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const session = getOwnedUploadSession({ + uploadId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const aborted = await abortUploadSession(session) + return v2Data(toV2FileUpload(aborted, null), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to abort file upload session', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/files/uploads/route.test.ts b/apps/sim/app/api/v2/files/uploads/route.test.ts new file mode 100644 index 00000000000..34c934f0183 --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/route.test.ts @@ -0,0 +1,148 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockAssertFolder, + mockCreateUploadSession, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockAssertFolder: vi.fn(), + mockCreateUploadSession: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + assertWorkspaceFileFolderTarget: mockAssertFolder, +})) + +vi.mock('@/lib/uploads/multipart-session/service', () => ({ + createUploadSession: mockCreateUploadSession, +})) + +import { POST } from '@/app/api/v2/files/uploads/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-03T22:00:00.000Z'), +} + +function request(body: Record) { + return POST( + new NextRequest('http://localhost:3000/api/v2/files/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + +describe('POST /api/v2/files/uploads', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockAssertFolder.mockResolvedValue(null) + mockCreateUploadSession.mockResolvedValue({ + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + purpose: 'workspace_file', + storageContext: 'workspace', + storageKey: `${WORKSPACE_ID}/file.csv`, + storageProvider: 's3', + providerUploadId: 'provider-1', + fileName: 'file.csv', + contentType: 'text/csv', + fileSize: 10, + partSize: 8 * 1024 * 1024, + partCount: 1, + status: 'uploading', + uploadToken: 'signed-upload-token', + metadata: {}, + completedFileId: null, + error: null, + expiresAt: new Date('2026-08-04T21:00:00.000Z'), + createdAt: new Date('2026-08-03T21:00:00.000Z'), + updatedAt: new Date('2026-08-03T21:00:00.000Z'), + completedAt: null, + }) + }) + + it('creates one signed multipart session for a small file', async () => { + const response = await request({ + workspaceId: WORKSPACE_ID, + name: 'file.csv', + contentType: 'text/csv', + size: 10, + }) + + expect(response.status).toBe(201) + expect((await response.json()).data).toMatchObject({ + id: 'upload-1', + status: 'uploading', + partCount: 1, + uploadToken: 'signed-upload-token', + file: null, + }) + expect(mockCreateUploadSession).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + purpose: 'workspace_file', + fileName: 'file.csv', + contentType: 'text/csv', + fileSize: 10, + metadata: { folderId: null }, + }) + }) + + it('authorizes workspace write access before creating provider state', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await request({ + workspaceId: WORKSPACE_ID, + name: 'file.csv', + contentType: 'text/csv', + size: 10, + }) + + expect(response.status).toBe(403) + expect(mockAssertFolder).not.toHaveBeenCalled() + expect(mockCreateUploadSession).not.toHaveBeenCalled() + }) + + it('rejects an empty file before creating provider state', async () => { + const response = await request({ + workspaceId: WORKSPACE_ID, + name: 'file.csv', + contentType: 'text/csv', + size: 0, + }) + + expect(response.status).toBe(400) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockCreateUploadSession).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/files/uploads/route.ts b/apps/sim/app/api/v2/files/uploads/route.ts new file mode 100644 index 00000000000..8ee6777176d --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/route.ts @@ -0,0 +1,61 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateFileUploadContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' +import { createUploadSession } from '@/lib/uploads/multipart-session/service' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileUploadsAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateFileUploadContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, name, contentType, size, folderId } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const normalizedFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId) + + const session = await createUploadSession({ + workspaceId, + userId, + purpose: 'workspace_file', + fileName: name, + contentType, + fileSize: size, + metadata: { folderId: normalizedFolderId }, + }) + return v2Data(toV2FileUpload(session, null), { rateLimit, status: 201 }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create file upload session', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/uploads/utils.ts b/apps/sim/app/api/v2/files/uploads/utils.ts new file mode 100644 index 00000000000..c79baf22bb1 --- /dev/null +++ b/apps/sim/app/api/v2/files/uploads/utils.ts @@ -0,0 +1,38 @@ +import type { V2FileUpload } from '@/lib/api/contracts/v2/files' +import type { V2UploadStatus } from '@/lib/api/contracts/v2/uploads' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import type { UploadSessionRecord } from '@/lib/uploads/multipart-session/service' +import { toV2File } from '@/app/api/v2/files/utils' + +export function toV2FileUpload( + session: UploadSessionRecord, + file: WorkspaceFileRecord | null +): V2FileUpload { + return { + id: session.id, + status: uploadStatus(session.status), + name: session.fileName, + contentType: session.contentType, + size: session.fileSize, + partSize: session.partSize, + partCount: session.partCount, + uploadToken: session.uploadToken, + expiresAt: session.expiresAt.toISOString(), + error: session.error, + file: file ? toV2File(file) : null, + } +} + +function uploadStatus(status: string): V2UploadStatus { + if ( + status !== 'uploading' && + status !== 'finalizing' && + status !== 'completed' && + status !== 'failed' && + status !== 'aborted' && + status !== 'expired' + ) { + throw new Error(`Invalid upload session status: ${status}`) + } + return status +} diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index f226e77a345..475f73cb5c5 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -230,10 +230,14 @@ const V2_CODE_BY_ORCHESTRATION_ERROR: Record ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockCancelRuns: vi.fn(), + mockPredicateToFilter: vi.fn(), + mockSignalRowsChanged: vi.fn(), + mockGateError: vi.fn(), + TableQueryValidationError: class TableQueryValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + checkAccess: mockCheckAccess, +})) + +vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + v2BulkPredicateToFilter: mockPredicateToFilter, +})) + +vi.mock('@/lib/table/workflow-columns', () => ({ cancelWorkflowGroupRuns: mockCancelRuns })) +vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged })) +vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/cancel-runs/route' + +const TABLE = { + id: 'table-1', + workspaceId: 'ws-1', + schema: { columns: [{ id: 'col-1', name: 'status', type: 'string' }] }, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/cancel-runs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockCancelRuns.mockResolvedValue(4) + mockGateError.mockResolvedValue(null) +}) + +describe('POST /api/v2/tables/[tableId]/cancel-runs', () => { + it('cancels every run under scope "all" and reports the count', async () => { + const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ cancelled: 4 }) + expect(mockCancelRuns).toHaveBeenCalledWith('table-1', undefined, { + filter: undefined, + excludeRowIds: undefined, + }) + // Cancelling clears the affected cells, so open readers must refetch. + expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1') + }) + + it('scopes to a single row when asked', async () => { + const res = await callPost({ workspaceId: 'ws-1', scope: 'row', rowId: 'row-1' }) + + expect(res.status).toBe(200) + expect(mockCancelRuns).toHaveBeenCalledWith('table-1', 'row-1', expect.anything()) + }) + + it('translates a name-keyed predicate to the storage-keyed filter', async () => { + mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } }) + const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } + + await callPost({ workspaceId: 'ws-1', scope: 'all', filter: predicate }) + + expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema) + expect(mockCancelRuns).toHaveBeenCalledWith( + 'table-1', + undefined, + expect.objectContaining({ filter: { 'col-1': { $eq: 'active' } } }) + ) + }) + + it('400s an unresolvable predicate field instead of cancelling nothing', async () => { + mockPredicateToFilter.mockImplementation(() => { + throw new TableQueryValidationError('Unknown column "nope"') + }) + + const res = await callPost({ + workspaceId: 'ws-1', + scope: 'all', + filter: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, + }) + + expect(res.status).toBe(400) + expect(mockCancelRuns).not.toHaveBeenCalled() + }) + + it('400s scope "row" with no rowId', async () => { + const res = await callPost({ workspaceId: 'ws-1', scope: 'row' }) + + expect(res.status).toBe(400) + expect(mockCancelRuns).not.toHaveBeenCalled() + }) + + it('400s scope "row" combined with a filter', async () => { + const res = await callPost({ + workspaceId: 'ws-1', + scope: 'row', + rowId: 'row-1', + filter: { all: [{ field: 'status', op: 'eq', value: 'active' }] }, + }) + + expect(res.status).toBe(400) + expect(mockCancelRuns).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) + + expect(res.status).toBe(403) + expect(mockCancelRuns).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) + + expect(res.status).toBe(429) + expect(mockCancelRuns).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts new file mode 100644 index 00000000000..69fe9094e67 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts @@ -0,0 +1,100 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CancelTableRunsContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { Filter, TableSchema } from '@/lib/table' +import { TableQueryValidationError } from '@/lib/table/errors' +import { signalTableRowsChanged } from '@/lib/table/events' +import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2BulkPredicateToFilter, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableCancelRunsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/cancel-runs — Stop in-flight cell runs. + * + * The counterpart to `POST /columns/run`, and distinct from + * `POST /job/cancel`, which stops an import or delete. `scope: 'all'` cancels + * every running and pending cell (optionally narrowed by `filter`); `row` + * cancels one row's cells. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-enrichment') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2CancelTableRunsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, scope, rowId, filter, excludeRowIds } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + if (access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // The public predicate is column-NAME keyed; the runners compile the + // storage-keyed legacy filter. Translating up front makes an unknown field + // a 400 rather than a cancel that silently matches nothing. + let legacyFilter: Filter | undefined + if (filter) { + legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) + } + + const cancelled = await cancelWorkflowGroupRuns(tableId, scope === 'row' ? rowId : undefined, { + filter: legacyFilter, + excludeRowIds, + }) + + // Cancelling clears the affected rows' exec state, so open readers must + // refetch to pick up the cleared cells. + signalTableRowsChanged(tableId) + + logger.info(`[${requestId}] Cancelled table runs`, { tableId, scope, rowId, cancelled }) + + return v2Data({ cancelled }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error cancelling table runs`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts index ce480142cab..77a3f1f5e1c 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts @@ -19,12 +19,11 @@ import { v2CaughtOrchestrationError, v2Data, v2Error, - v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { v2TableAccessError } from '@/app/api/v2/tables/utils' +import { v2TableAccessError, v2TableOrchestrationError } from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableColumnsAPI') @@ -136,7 +135,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu request, }) if (!outcome.success || !outcome.table) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to update column') + return v2TableOrchestrationError(outcome, 'Failed to update column') } return v2Data({ columns: outcome.table.schema.columns.map(normalizeColumn) }, { rateLimit }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts new file mode 100644 index 00000000000..e3dc1b23c0b --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts @@ -0,0 +1,195 @@ +/** + * @vitest-environment node + * + * Public v2 column run. The public predicate is column-NAME keyed and the + * dispatcher compiles a storage-keyed legacy filter, so the route translates + * before dispatching — an unknown field must 400 here rather than becoming a + * run that silently matches nothing. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockRunWorkflowColumn, + mockPredicateToFilter, + mockSignalRowsChanged, + mockGateError, + TableQueryValidationError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockRunWorkflowColumn: vi.fn(), + mockPredicateToFilter: vi.fn(), + mockSignalRowsChanged: vi.fn(), + mockGateError: vi.fn(), + TableQueryValidationError: class TableQueryValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + v2BulkPredicateToFilter: mockPredicateToFilter, +})) + +vi.mock('@/lib/table/workflow-columns', () => ({ runWorkflowColumn: mockRunWorkflowColumn })) +vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged })) +vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/columns/run/route' + +const TABLE = { + id: 'table-1', + workspaceId: 'ws-1', + schema: { columns: [{ id: 'col-1', name: 'status', type: 'string' }] }, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/columns/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('POST /api/v2/tables/[tableId]/columns/run', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockRunWorkflowColumn.mockResolvedValue({ dispatchId: 'dispatch-1' }) + mockGateError.mockResolvedValue(null) + }) + + it('dispatches the run and returns the dispatch id', async () => { + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'], rowIds: ['row-1'] }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ dispatchId: 'dispatch-1' }) + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: 'table-1', + workspaceId: 'ws-1', + groupIds: ['group-1'], + rowIds: ['row-1'], + mode: 'all', + filter: undefined, + triggeredByUserId: 'user-1', + }) + ) + // The bulk clear is a row change even when the dispatch is a no-op. + expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1') + }) + + it('translates a name-keyed predicate to the storage-keyed filter the dispatcher walks', async () => { + mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } }) + const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } + + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'], filter: predicate }) + + expect(res.status).toBe(200) + expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema) + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ filter: { 'col-1': { $eq: 'active' } } }) + ) + }) + + it('400s an unresolvable predicate field instead of dispatching a no-match run', async () => { + mockPredicateToFilter.mockImplementation(() => { + throw new TableQueryValidationError('Unknown column "nope"') + }) + + const res = await callPost({ + workspaceId: 'ws-1', + groupIds: ['group-1'], + filter: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, + }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toBe('Unknown column "nope"') + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('400s rowIds and filter together', async () => { + const res = await callPost({ + workspaceId: 'ws-1', + groupIds: ['group-1'], + rowIds: ['row-1'], + filter: { all: [{ field: 'status', op: 'eq', value: 'active' }] }, + }) + + expect(res.status).toBe(400) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('400s an empty groupIds list', async () => { + const res = await callPost({ workspaceId: 'ws-1', groupIds: [] }) + + expect(res.status).toBe(400) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] }) + + expect(res.status).toBe(403) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] }) + + expect(res.status).toBe(429) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts new file mode 100644 index 00000000000..f534f57f5fd --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts @@ -0,0 +1,118 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2RunTableColumnContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { Filter, TableSchema } from '@/lib/table' +import { TableQueryValidationError } from '@/lib/table/errors' +import { signalTableRowsChanged } from '@/lib/table/events' +import { runWorkflowColumn } from '@/lib/table/workflow-columns' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { + v2BulkPredicateToFilter, + v2TableAccessError, + v2TableLockError, +} from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRunColumnAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/columns/run — Run workflow/enrichment groups. + * + * Asynchronous: the response acknowledges the dispatch, not the results. The + * dispatcher walks the scoped rows and writes cells as runs land, so callers + * poll the row endpoints. `dispatchId` is `null` where no background runner is + * configured and cells execute inline. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-enrichment') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2RunTableColumnContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, groupIds, runMode, rowIds, filter, excludeRowIds, limit } = + parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + if (access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // The public predicate is column-NAME keyed; the dispatcher compiles the + // storage-keyed legacy filter. Translating up front also makes an unknown + // field a 400 here rather than a dispatch that silently matches nothing. + let legacyFilter: Filter | undefined + if (filter) { + legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) + } + + const { dispatchId } = await runWorkflowColumn({ + tableId, + workspaceId, + groupIds, + mode: runMode, + rowIds, + filter: legacyFilter, + excludeRowIds, + limit, + requestId, + triggeredByUserId: userId, + }) + + // Starting a run clears the target groups' cells to pending — a row change + // open readers must pick up. + signalTableRowsChanged(tableId) + + return v2Data({ dispatchId }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + const lockError = v2TableLockError(error) + if (lockError) return lockError + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error(`[${requestId}] Error running table columns`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts new file mode 100644 index 00000000000..63bf970f013 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts @@ -0,0 +1,67 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateTableExportContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createTableExportResource, + toV2TableExport, +} from '@/lib/table/orchestration/export-resource' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableExportsAPI') + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'table-export') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2CreateTableExportContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { workspaceId, format } = parsed.data.body + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const access = await checkAccess(parsed.data.params.tableId, userId, 'read') + if (!access.ok || access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + const record = await createTableExportResource({ table: access.table, format }) + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.TABLE_EXPORTED, + resourceType: AuditResourceType.TABLE, + resourceId: access.table.id, + resourceName: access.table.name, + description: `Exported table "${access.table.name}" as ${format.toUpperCase()}`, + metadata: { format, rowCount: access.table.rowCount }, + request, + }) + return v2Data(toV2TableExport(record, true), { rateLimit, status: 201 }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create table export', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) 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 new file mode 100644 index 00000000000..0a847cabcac --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts @@ -0,0 +1,437 @@ +/** + * @vitest-environment node + * + * Public v2 workflow-group listing — a read-only projection of the table's + * schema, exposed so a caller can discover the group ids the run endpoints + * take. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockGateError, + mockAddWorkflowGroup, + mockUpdateWorkflowGroup, + mockDeleteWorkflowGroup, + mockGetActiveWorkflowContext, + mockSignalSchemaChanged, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockGateError: vi.fn(), + mockAddWorkflowGroup: vi.fn(), + mockUpdateWorkflowGroup: vi.fn(), + mockDeleteWorkflowGroup: vi.fn(), + mockGetActiveWorkflowContext: vi.fn(), + mockSignalSchemaChanged: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/table/workflow-groups/service', () => ({ + addWorkflowGroup: mockAddWorkflowGroup, + updateWorkflowGroup: mockUpdateWorkflowGroup, + deleteWorkflowGroup: mockDeleteWorkflowGroup, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + getActiveWorkflowContext: mockGetActiveWorkflowContext, +})) + +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mockSignalSchemaChanged })) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { DELETE, GET, PATCH, POST } from '@/app/api/v2/tables/[tableId]/groups/route' + +const GROUP = { + id: 'group-1', + workflowId: 'wf-1', + name: 'Enrich', + outputs: [{ blockId: 'blk-1', path: 'content', columnName: 'summary' }], +} +const TABLE = { + id: 'table-1', + workspaceId: 'ws-1', + schema: { columns: [], workflowGroups: [GROUP] }, +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callGet() { + const req = new NextRequest( + 'http://localhost:3000/api/v2/tables/table-1/groups?workspaceId=ws-1', + { method: 'GET' } + ) + return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('GET /api/v2/tables/[tableId]/groups', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGateError.mockResolvedValue(null) + }) + + it('returns the schema groups as one full page', async () => { + const res = await callGet() + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: [GROUP], nextCursor: null }) + }) + + it('returns an empty page for a table with no groups', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, schema: { columns: [] } } }) + + const res = await callGet() + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: [], nextCursor: null }) + }) + + it('masks a permission failure as 404 so table existence never leaks', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callGet() + + expect(res.status).toBe(404) + }) + + it('400s a request with no workspaceId', async () => { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/groups', { + method: 'GET', + }) + const res = await GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) + + expect(res.status).toBe(400) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callGet() + + expect(res.status).toBe(429) + expect(mockCheckAccess).not.toHaveBeenCalled() + }) +}) + +const ADD_BODY = { + workspaceId: 'ws-1', + group: { + workflowId: 'wf-1', + outputs: [{ blockId: 'blk-1', path: 'content', columnName: 'summary' }], + }, + outputColumns: [{ name: 'summary', type: 'string' }], +} + +const UPDATED_TABLE = { + id: 'table-1', + workspaceId: 'ws-1', + schema: { columns: [{ name: 'summary', type: 'string' }], workflowGroups: [GROUP] }, +} + +function callWrite(method: 'POST' | 'PATCH' | 'DELETE', body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/groups', { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const handler = method === 'POST' ? POST : method === 'PATCH' ? PATCH : DELETE + return handler(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('POST /api/v2/tables/[tableId]/groups', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGateError.mockResolvedValue(null) + mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-1' }) + // Echo back the id the route generated, as the real service does. + mockAddWorkflowGroup.mockImplementation(async (data: { group: { id: string } }) => ({ + ...UPDATED_TABLE, + schema: { + ...UPDATED_TABLE.schema, + workflowGroups: [{ ...GROUP, id: data.group.id }], + }, + })) + }) + + it('creates the group and its columns, returning both', async () => { + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(201) + const body = await res.json() + expect(body.data.group).toMatchObject({ workflowId: 'wf-1', name: 'Enrich' }) + expect(body.data.columns).toEqual([{ name: 'summary', type: 'string' }]) + expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1') + }) + + it('500s rather than emitting a body without the group it claims to have written', async () => { + // Write reports success but the group is absent — an internal inconsistency + // must not surface as a 200 with `group: undefined`. + mockAddWorkflowGroup.mockResolvedValue({ + ...UPDATED_TABLE, + schema: { columns: [], workflowGroups: [] }, + }) + + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(500) + expect((await res.json()).error.code).toBe('INTERNAL_ERROR') + }) + + it('server-generates the group id and stamps it onto the output columns', async () => { + await callWrite('POST', ADD_BODY) + + const call = mockAddWorkflowGroup.mock.calls[0][0] + expect(call.group.id).toEqual(expect.any(String)) + expect(call.group.id).not.toBe('') + // The caller never supplies workflowGroupId — it is derived from the group. + expect(call.outputColumns[0].workflowGroupId).toBe(call.group.id) + }) + + it('defaults autoRun to false so one POST cannot fan out a metered backfill', async () => { + await callWrite('POST', ADD_BODY) + expect(mockAddWorkflowGroup.mock.calls[0][0].autoRun).toBe(false) + }) + + it('rejects a workflow from another workspace before persisting it', async () => { + mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-other' }) + + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('Workflow not found') + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('rejects an output column that no group output feeds', async () => { + const res = await callWrite('POST', { + ...ADD_BODY, + outputColumns: [{ name: 'summry', type: 'string' }], + }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('summry') + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('400s an enrichment group with no enrichmentId', async () => { + const res = await callWrite('POST', { + ...ADD_BODY, + group: { ...ADD_BODY.group, workflowId: '', type: 'enrichment' }, + }) + + expect(res.status).toBe(400) + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('400s a workflow group with no workflowId', async () => { + const res = await callWrite('POST', { + ...ADD_BODY, + group: { ...ADD_BODY.group, workflowId: '' }, + }) + + expect(res.status).toBe(400) + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('masks a permission failure as 404', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(404) + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(404) + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT_OK, allowed: false, retryAfterMs: 1000 }) + + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(429) + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('surfaces a duplicate-column failure as 400, not 500', async () => { + mockAddWorkflowGroup.mockRejectedValue(new Error('Column "summary" already exists')) + + const res = await callWrite('POST', ADD_BODY) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toContain('already exists') + }) +}) + +describe('PATCH /api/v2/tables/[tableId]/groups', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGateError.mockResolvedValue(null) + mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-1' }) + mockUpdateWorkflowGroup.mockResolvedValue(UPDATED_TABLE) + }) + + it('updates the group and returns it with the resulting columns', async () => { + const res = await callWrite('PATCH', { + workspaceId: 'ws-1', + groupId: 'group-1', + name: 'Renamed', + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.group).toEqual(GROUP) + expect(body.data.columns).toEqual([{ name: 'summary', type: 'string' }]) + expect(mockUpdateWorkflowGroup).toHaveBeenCalledWith( + expect.objectContaining({ tableId: 'table-1', groupId: 'group-1', name: 'Renamed' }), + expect.any(String) + ) + }) + + it('re-checks workspace containment when the group is re-pointed', async () => { + mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-other' }) + + const res = await callWrite('PATCH', { + workspaceId: 'ws-1', + groupId: 'group-1', + workflowId: 'wf-elsewhere', + }) + + expect(res.status).toBe(400) + expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled() + }) + + it('stamps the group id onto any newly added output columns', async () => { + await callWrite('PATCH', { + workspaceId: 'ws-1', + groupId: 'group-1', + newOutputColumns: [{ name: 'score', type: 'number' }], + }) + + expect(mockUpdateWorkflowGroup.mock.calls[0][0].newOutputColumns[0].workflowGroupId).toBe( + 'group-1' + ) + }) + + it('masks a permission failure as 404', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callWrite('PATCH', { workspaceId: 'ws-1', groupId: 'group-1' }) + + expect(res.status).toBe(404) + expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled() + }) + + it('404s an unknown group rather than reporting a generic failure', async () => { + mockUpdateWorkflowGroup.mockRejectedValue(new Error('Workflow group not found')) + + const res = await callWrite('PATCH', { workspaceId: 'ws-1', groupId: 'nope' }) + + expect(res.status).toBe(404) + }) +}) + +describe('DELETE /api/v2/tables/[tableId]/groups', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGateError.mockResolvedValue(null) + mockDeleteWorkflowGroup.mockResolvedValue({ + ...UPDATED_TABLE, + schema: { columns: [], workflowGroups: [] }, + }) + }) + + it('deletes the group and reports the surviving columns', async () => { + const res = await callWrite('DELETE', { workspaceId: 'ws-1', groupId: 'group-1' }) + + expect(res.status).toBe(200) + // The group's columns go with it — the caller sees what is left, not a bare ack. + expect(await res.json()).toEqual({ data: { id: 'group-1', deleted: true, columns: [] } }) + expect(mockDeleteWorkflowGroup).toHaveBeenCalledWith( + { tableId: 'table-1', groupId: 'group-1' }, + expect.any(String) + ) + }) + + it('masks a permission failure as 404', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callWrite('DELETE', { workspaceId: 'ws-1', groupId: 'group-1' }) + + expect(res.status).toBe(404) + expect(mockDeleteWorkflowGroup).not.toHaveBeenCalled() + }) + + it('400s a body with no groupId', async () => { + const res = await callWrite('DELETE', { workspaceId: 'ws-1' }) + + expect(res.status).toBe(400) + expect(mockDeleteWorkflowGroup).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts new file mode 100644 index 00000000000..2d96bf9149a --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts @@ -0,0 +1,367 @@ +import { createLogger } from '@sim/logger' +import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { + v2AddWorkflowGroupContract, + v2DeleteWorkflowGroupContract, + v2ListWorkflowGroupsContract, + v2UpdateWorkflowGroupContract, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { TableDefinition, TableSchema } from '@/lib/table' +import { signalTableSchemaChanged } from '@/lib/table/events' +import { + addWorkflowGroup, + deleteWorkflowGroup, + updateWorkflowGroup, +} from '@/lib/table/workflow-groups/service' +import { checkAccess, normalizeColumn } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2TableLockError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableGroupsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * GET /api/v2/tables/[tableId]/groups — The table's workflow/enrichment groups. + * + * Read-only: groups are authored in the workflow builder, and the public + * surface exposes them so a caller can discover the `groupIds` the run + * endpoints take. Groups live on the table's schema, so this is a projection of + * the already-loaded definition rather than a second query, and the set is + * bounded per table — one full page, `nextCursor` always `null`. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-groups') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ListWorkflowGroupsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok || result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const groups = (result.table.schema as TableSchema).workflowGroups ?? [] + + return v2CursorList(groups, null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing workflow groups`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * Renders a group-service failure in the v2 envelope. The service signals + * through thrown `Error` messages rather than classified codes, so the string + * matching mirrors the first-party mapper — the two surfaces must agree on + * which failures are the caller's fault. + */ +function groupMutationError(error: unknown, requestId: string, fallback: string) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + + if (error instanceof Error) { + const message = error.message + if (message === 'Table not found' || message.includes('not found')) { + return v2Error('NOT_FOUND', message) + } + if ( + message.includes('Schema validation') || + message.includes('Missing column definition') || + message.includes('already exists') || + message.includes('exceed') + ) { + return v2Error('BAD_REQUEST', message) + } + } + + logger.error(`[${requestId}] ${fallback}`, { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') +} + +/** + * A group persists a `workflowId` that its runs later execute. Without this the + * table becomes a way to invoke workflows the API key cannot otherwise reach, + * so containment is asserted before the id is stored — on create and on any + * update that re-points the group. + */ +async function assertWorkflowInWorkspace(workflowId: string, workspaceId: string) { + const context = await getActiveWorkflowContext(workflowId) + if (!context || context.workspaceId !== workspaceId) { + return v2Error('BAD_REQUEST', 'Workflow not found in this workspace') + } + return null +} + +/** + * `{ group, columns }` for the group a mutation touched. + * + * Throws when the write reports success but the group is absent from the + * returned schema. The contract declares `group` as present, so emitting + * `undefined` there would ship a body no client can parse while reporting 200 — + * an internal inconsistency is worth a 500, not a malformed success. + */ +function groupResponse(table: TableDefinition, groupId: string) { + const schema = table.schema as TableSchema + const group = (schema.workflowGroups ?? []).find((candidate) => candidate.id === groupId) + if (!group) { + throw new Error(`Workflow group ${groupId} missing from the table after a successful write`) + } + return { group, columns: schema.columns.map(normalizeColumn) } +} + +/** + * POST /api/v2/tables/[tableId]/groups — Bind a workflow or enrichment to the + * table and create the columns its runs populate, in one call. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-groups') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2AddWorkflowGroupContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok || result.table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + if (validated.group.workflowId) { + const workflowError = await assertWorkflowInWorkspace( + validated.group.workflowId, + result.table.workspaceId + ) + if (workflowError) return workflowError + } + + /** + * `outputs` and `outputColumns` are two arrays joined by column name, so a + * typo in either silently creates a column nothing feeds. The first-party + * client builds both from one picker and can't desync; a public caller can, + * so the mismatch is rejected rather than persisted. + */ + const outputNames = new Set(validated.group.outputs.map((output) => output.columnName)) + const orphan = validated.outputColumns.find((column) => !outputNames.has(column.name)) + if (orphan) { + return v2Error( + 'BAD_REQUEST', + `outputColumns entry "${orphan.name}" has no matching group.outputs[].columnName` + ) + } + + const groupId = validated.group.id ?? generateId() + + const updatedTable = await addWorkflowGroup( + { + tableId, + group: { ...validated.group, id: groupId }, + // Stamped from the resolved group rather than trusted from the caller. + outputColumns: validated.outputColumns.map((column) => ({ + ...column, + workflowGroupId: groupId, + })), + autoRun: validated.autoRun, + actorUserId: userId, + }, + requestId + ) + + signalTableSchemaChanged(tableId) + + return v2Data(groupResponse(updatedTable, groupId), { rateLimit, status: 201 }) + } catch (error) { + return groupMutationError(error, requestId, 'Failed to add workflow group') + } +}) + +/** + * PATCH /api/v2/tables/[tableId]/groups — Restructure a group: re-point it, + * add or remove outputs, or change how its runs are scheduled. + * + * Removing an output **deletes that column and its values** — the same + * behavior as `DELETE /columns` on a bound column. There is no detach. + */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-groups') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateWorkflowGroupContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok || result.table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + if (validated.workflowId !== undefined) { + const workflowError = await assertWorkflowInWorkspace( + validated.workflowId, + result.table.workspaceId + ) + if (workflowError) return workflowError + } + + const updatedTable = await updateWorkflowGroup( + { + tableId, + groupId: validated.groupId, + actorUserId: userId, + ...(validated.workflowId !== undefined ? { workflowId: validated.workflowId } : {}), + ...(validated.name !== undefined ? { name: validated.name } : {}), + ...(validated.dependencies !== undefined ? { dependencies: validated.dependencies } : {}), + ...(validated.outputs !== undefined ? { outputs: validated.outputs } : {}), + ...(validated.newOutputColumns !== undefined + ? { + newOutputColumns: validated.newOutputColumns.map((column) => ({ + ...column, + workflowGroupId: validated.groupId, + })), + } + : {}), + ...(validated.mappingUpdates !== undefined + ? { mappingUpdates: validated.mappingUpdates } + : {}), + ...(validated.inputMappings !== undefined + ? { inputMappings: validated.inputMappings } + : {}), + ...(validated.deploymentMode !== undefined + ? { deploymentMode: validated.deploymentMode } + : {}), + ...(validated.type !== undefined ? { type: validated.type } : {}), + ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}), + }, + requestId + ) + + signalTableSchemaChanged(tableId) + + return v2Data(groupResponse(updatedTable, validated.groupId), { rateLimit }) + } catch (error) { + return groupMutationError(error, requestId, 'Failed to update workflow group') + } +}) + +/** + * DELETE /api/v2/tables/[tableId]/groups — Remove a group **and every column it + * fed**, along with their values. The surviving column list comes back so a + * caller does not have to re-read the table to see what is left. + */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-groups') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteWorkflowGroupContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok || result.table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const updatedTable = await deleteWorkflowGroup( + { tableId, groupId: validated.groupId }, + requestId + ) + + signalTableSchemaChanged(tableId) + + return v2Data( + { + id: validated.groupId, + deleted: true as const, + columns: (updatedTable.schema as TableSchema).columns.map(normalizeColumn), + }, + { rateLimit } + ) + } catch (error) { + return groupMutationError(error, requestId, 'Failed to delete workflow group') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts new file mode 100644 index 00000000000..804db7fd7f0 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts @@ -0,0 +1,190 @@ +/** + * @vitest-environment node + * + * Public v2 table restore. The target is archived by definition, so the route + * resolves it with archived rows included and checks the permission against + * that row's own workspace rather than going through `checkAccess`. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockGetTableById, + mockGetUserEntityPermissions, + mockPerformRestoreTable, + mockGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockGetTableById: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockPerformRestoreTable: vi.fn(), + mockGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/lib/table', () => ({ getTableById: mockGetTableById })) +vi.mock('@/lib/table/orchestration', () => ({ performRestoreTable: mockPerformRestoreTable })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) +vi.mock('@/app/api/table/utils', () => ({ + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/restore/route' + +const UNLOCKED = { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, +} +const ARCHIVED_TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } +const RESTORED_TABLE = { + id: 'table-1', + name: 'Tasks', + description: null, + workspaceId: 'ws-1', + schema: { columns: [] }, + rowCount: 7, + maxRows: 1000, + folderId: null, + locks: UNLOCKED, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('POST /api/v2/tables/[tableId]/restore', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockGetTableById.mockResolvedValue(ARCHIVED_TABLE) + mockGetUserEntityPermissions.mockResolvedValue('write') + mockGateError.mockResolvedValue(null) + }) + + it('restores through the orchestration function and returns the table', async () => { + mockPerformRestoreTable.mockResolvedValue({ success: true, table: RESTORED_TABLE }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ + table: { + id: 'table-1', + name: 'Tasks', + description: null, + schema: { columns: [] }, + rowCount: 7, + maxRows: 1000, + folderId: null, + locks: UNLOCKED, + job: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, + }) + // Archived tables are invisible to `getTableById` by default; without the + // opt-in the route would 404 every restore. + expect(mockGetTableById).toHaveBeenCalledWith('table-1', { includeArchived: true }) + expect(mockPerformRestoreTable).toHaveBeenCalledWith( + expect.objectContaining({ tableId: 'table-1', userId: 'user-1' }) + ) + }) + + it('404s an archived table belonging to another workspace', async () => { + mockGetTableById.mockResolvedValue({ ...ARCHIVED_TABLE, workspaceId: 'ws-other' }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(404) + expect(mockPerformRestoreTable).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockGetUserEntityPermissions.mockResolvedValue('read') + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(403) + expect(mockPerformRestoreTable).not.toHaveBeenCalled() + }) + + it('maps a name collision with a live table to 409 CONFLICT', async () => { + mockPerformRestoreTable.mockResolvedValue({ + success: false, + errorCode: 'conflict', + error: 'A table named "Tasks" already exists', + }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('400s a body with no workspace', async () => { + const res = await callPost({}) + + expect(res.status).toBe(400) + expect(mockPerformRestoreTable).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(404) + expect(mockGetTableById).not.toHaveBeenCalled() + expect(mockPerformRestoreTable).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(429) + expect(mockPerformRestoreTable).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/restore/route.ts b/apps/sim/app/api/v2/tables/[tableId]/restore/route.ts new file mode 100644 index 00000000000..25485da61da --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/restore/route.ts @@ -0,0 +1,88 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2RestoreTableContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getTableById } from '@/lib/table' +import { performRestoreTable } from '@/lib/table/orchestration' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiTable } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRestoreAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/restore — Un-archive a table. + * + * The only table endpoint that cannot use `checkAccess`: its target is archived + * by definition, and `checkAccess` resolves active tables only. The permission + * check is therefore done against the archived row's own workspace, which is + * also what makes the workspace-match check an IDOR guard rather than a + * formality. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-restore') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2RestoreTableContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const archived = await getTableById(tableId, { includeArchived: true }) + // Mask a missing table and a foreign one alike so archived-table existence + // never leaks across workspaces. + if (!archived || archived.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const permission = await getUserEntityPermissions(userId, 'workspace', archived.workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return v2Error('FORBIDDEN', 'Access denied') + } + + const outcome = await performRestoreTable({ tableId, userId, requestId }) + if (!outcome.success || !outcome.table) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to restore table') + } + + return v2Data({ table: toApiTable(outcome.table) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error restoring table`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts index 43210d8a8e8..735529f80a0 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts @@ -1,8 +1,10 @@ /** * @vitest-environment node * - * Public v2 table delete: the actor is handed to the service so the audit is - * emitted there — and only for a delete that actually archived a row. + * Public v2 table delete and update. Delete hands the actor to the service so + * the audit is emitted there — and only for a delete that actually archived a + * row. Update routes each field to its own orchestration call; lock flags are + * read-only on this surface and a request carrying them is refused outright. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,13 +14,27 @@ const { mockResolveWorkspaceScope, mockCheckAccess, mockPerformDeleteTable, + mockPerformRenameTable, + mockPerformMoveTableToFolder, + mockPerformUpdateTableLocks, mockRecordAudit, + mockGetTableById, + mockFindActiveFolder, + mockGateError, + mockSignalSchemaChanged, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceScope: vi.fn(), mockCheckAccess: vi.fn(), mockPerformDeleteTable: vi.fn(), + mockPerformRenameTable: vi.fn(), + mockPerformMoveTableToFolder: vi.fn(), + mockPerformUpdateTableLocks: vi.fn(), mockRecordAudit: vi.fn(), + mockGetTableById: vi.fn(), + mockFindActiveFolder: vi.fn(), + mockGateError: vi.fn(), + mockSignalSchemaChanged: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -41,21 +57,64 @@ vi.mock('@/app/api/table/utils', () => ({ vi.mock('@/lib/table', () => ({ updateTable: vi.fn(), - getTableById: vi.fn(), + getTableById: mockGetTableById, updateRow: vi.fn(), rowDataNameToId: vi.fn(), buildIdByName: vi.fn(), })) -vi.mock('@/lib/table/orchestration', () => ({ performDeleteTable: mockPerformDeleteTable })) +vi.mock('@/lib/table/events', () => ({ + signalTableSchemaChanged: mockSignalSchemaChanged, +})) +vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: vi.fn().mockResolvedValue({ organizationId: 'org-1' }), +})) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/table/orchestration', () => ({ + performDeleteTable: mockPerformDeleteTable, + performRenameTable: mockPerformRenameTable, + performMoveTableToFolder: mockPerformMoveTableToFolder, + performUpdateTableLocks: mockPerformUpdateTableLocks, })) -import { DELETE } from '@/app/api/v2/tables/[tableId]/route' +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { DELETE, PATCH } from '@/app/api/v2/tables/[tableId]/route' + +const UNLOCKED = { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, +} +const TABLE = { + id: 'table-1', + name: 'Tasks', + workspaceId: 'ws-1', + schema: { columns: [] }, + locks: UNLOCKED, +} +const UPDATED_TABLE = { + ...TABLE, + name: 'Renamed', + description: null, + rowCount: 0, + maxRows: 1000, + folderId: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} -const TABLE = { id: 'table-1', name: 'Tasks', workspaceId: 'ws-1', schema: { columns: [] } } +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} function callDelete() { const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1?workspaceId=ws-1', { @@ -64,22 +123,26 @@ function callDelete() { return DELETE(req, { params: Promise.resolve({ tableId: 'table-1' }) }) } -describe('DELETE /api/v2/tables/[tableId]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue({ - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), - }) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) +function callPatch(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), }) + return PATCH(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGetTableById.mockResolvedValue(UPDATED_TABLE) + mockFindActiveFolder.mockResolvedValue({ id: 'folder-1' }) + mockGateError.mockResolvedValue(null) +}) +describe('DELETE /api/v2/tables/[tableId]', () => { it('delegates to the orchestration function with the resolved table and actor', async () => { mockPerformDeleteTable.mockResolvedValue({ success: true }) @@ -107,3 +170,278 @@ describe('DELETE /api/v2/tables/[tableId]', () => { expect((await res.json()).error.code).toBe('LOCKED') }) }) + +describe('PATCH /api/v2/tables/[tableId]', () => { + it('renames through the orchestration function and returns the re-read table', async () => { + mockPerformRenameTable.mockResolvedValue({ success: true }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ + table: { + id: 'table-1', + name: 'Renamed', + description: null, + schema: { columns: [] }, + rowCount: 0, + maxRows: 1000, + folderId: null, + locks: UNLOCKED, + job: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, + }) + expect(mockPerformRenameTable).toHaveBeenCalledWith( + expect.objectContaining({ table: TABLE, newName: 'Renamed', userId: 'user-1' }) + ) + expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() + expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() + }) + + it('surfaces a running import so an async job is observable, not just startable', async () => { + // `POST /import-async` and `POST /job/cancel` let a caller start and stop an + // import; without this the table never reports that it is running, so there + // is nothing to poll between the two. + mockGetTableById.mockResolvedValue({ + ...UPDATED_TABLE, + jobStatus: 'running', + jobId: 'job-1', + jobType: 'import', + jobRowsProcessed: 250, + jobError: null, + }) + mockPerformRenameTable.mockResolvedValue({ success: true }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect((await res.json()).data.table.job).toEqual({ + id: 'job-1', + type: 'import', + status: 'running', + rowsProcessed: 250, + error: null, + }) + }) + + it('moves the table only after confirming the folder belongs to the workspace', async () => { + mockPerformMoveTableToFolder.mockResolvedValue({ success: true }) + + const res = await callPatch({ workspaceId: 'ws-1', folderId: 'folder-1' }) + + expect(res.status).toBe(200) + expect(mockFindActiveFolder).toHaveBeenCalledWith('folder-1', 'ws-1', 'table') + expect(mockPerformMoveTableToFolder).toHaveBeenCalledWith( + expect.objectContaining({ table: TABLE, folderId: 'folder-1', userId: 'user-1' }) + ) + }) + + it('404s a folder from outside the workspace without attempting the move', async () => { + mockFindActiveFolder.mockResolvedValue(null) + + const res = await callPatch({ workspaceId: 'ws-1', folderId: 'folder-elsewhere' }) + + expect(res.status).toBe(404) + expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() + }) + + it('rejects a bad folder without applying the rename that came with it', async () => { + // The three operations are separate transactions, so validation has to run + // before the first write — otherwise a rejected PATCH still renames. + mockFindActiveFolder.mockResolvedValue(null) + + const res = await callPatch({ + workspaceId: 'ws-1', + name: 'Renamed', + folderId: 'folder-elsewhere', + }) + + expect(res.status).toBe(404) + expect(mockPerformRenameTable).not.toHaveBeenCalled() + expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() + expect(mockSignalSchemaChanged).not.toHaveBeenCalled() + }) + + it('reports which operations landed when a later one fails', async () => { + // The three writes commit independently, so rather than pretending + // atomicity the error states what is already live — a caller can reconcile + // instead of re-reading and diffing. + mockPerformRenameTable.mockResolvedValue({ success: true }) + mockPerformMoveTableToFolder.mockResolvedValue({ + success: false, + errorCode: 'not_found', + error: 'gone', + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderId: 'folder-1' }) + + expect(res.status).toBe(404) + expect((await res.json()).error.details).toEqual({ applied: ['name'] }) + }) + + it('omits the applied list when the very first operation fails', async () => { + // `details.applied` present must always mean "these changes are live". + mockPerformRenameTable.mockResolvedValue({ + success: false, + errorCode: 'conflict', + error: 'taken', + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderId: 'folder-1' }) + + expect(res.status).toBe(409) + expect((await res.json()).error.details).toBeUndefined() + expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() + }) + + it('still signals collaborators when a later operation fails after an earlier one landed', async () => { + // A mid-write fault can't be rolled back across three transactions, so the + // clients must at least be told to refetch what did apply. + mockPerformRenameTable.mockResolvedValue({ success: true }) + mockPerformMoveTableToFolder.mockResolvedValue({ + success: false, + errorCode: 'not_found', + error: 'gone', + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderId: 'folder-1' }) + + expect(res.status).toBe(404) + expect(mockPerformRenameTable).toHaveBeenCalled() + expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1') + }) + + /** + * Locks are read-only on the public API. A `write`-level API key can already + * mutate the table, so letting it clear a lock would let it undo the guard + * placed there to stop it. The strict body rejects the field outright rather + * than dropping it silently, which would report success for a change that + * never happened. + */ + it('rejects a lock change instead of applying or silently ignoring it', async () => { + const res = await callPatch({ workspaceId: 'ws-1', locks: { deleteLocked: true } }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('BAD_REQUEST') + expect(JSON.stringify(body.error)).toContain('locks') + expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() + }) + + it('rejects a lock change even when paired with an otherwise valid rename', async () => { + const res = await callPatch({ + workspaceId: 'ws-1', + name: 'Renamed', + locks: { deleteLocked: false }, + }) + + expect(res.status).toBe(400) + // The whole request is refused — the rename must not land either. + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) + + /** + * The re-read runs after the writes have committed, so a failure there must + * still name what landed. Reporting a bare 500 tells the caller nothing took + * effect and it retries into a duplicate-name conflict. + */ + it('reports the applied operations when the final re-read throws', async () => { + mockPerformRenameTable.mockResolvedValue({ success: true }) + mockGetTableById.mockRejectedValue(new Error('connection reset')) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(500) + expect((await res.json()).error.details).toEqual({ applied: ['name'] }) + }) + + it('reports the applied operations when the re-read finds the table archived', async () => { + mockPerformRenameTable.mockResolvedValue({ success: true }) + mockGetTableById.mockResolvedValue(null) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(404) + expect((await res.json()).error.details).toEqual({ applied: ['name'] }) + }) + + it('omits applied details when the failure happened before any write', async () => { + mockGetTableById.mockRejectedValue(new Error('connection reset')) + + const res = await callPatch({ workspaceId: 'ws-1', folderId: 'nope' }) + + // Absence is meaningful: nothing is live, so a retry is safe. + expect((await res.json()).error.details).toBeUndefined() + }) + + it('still reports the stored lock flags on the table it returns', async () => { + // The response is a re-read, so the locked state has to come from there. + mockGetTableById.mockResolvedValue({ + ...UPDATED_TABLE, + locks: { ...UNLOCKED, deleteLocked: true }, + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(200) + expect((await res.json()).data.table.locks).toMatchObject({ deleteLocked: true }) + }) + + it('maps a duplicate-name rename to 409 CONFLICT', async () => { + mockPerformRenameTable.mockResolvedValue({ + success: false, + errorCode: 'conflict', + error: 'A table named "Renamed" already exists', + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) + + it('rejects a body with nothing to change', async () => { + const res = await callPatch({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(400) + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) + + it('404s a table in another workspace without writing', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(404) + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + + expect(res.status).toBe(429) + expect(mockPerformRenameTable).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index 55e2d792f8f..447bd76e4c2 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -1,26 +1,54 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { v2DeleteTableContract, v2GetTableContract } from '@/lib/api/contracts/v2/tables' +import { + v2DeleteTableContract, + v2GetTableContract, + v2UpdateTableContract, +} from '@/lib/api/contracts/v2/tables' import { parseRequest } from '@/lib/api/server' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performDeleteTable } from '@/lib/table/orchestration' +import { findActiveFolder } from '@/lib/folders/queries' +import { getTableById } from '@/lib/table' +import { signalTableSchemaChanged } from '@/lib/table/events' +import { + performDeleteTable, + performMoveTableToFolder, + performRenameTable, +} from '@/lib/table/orchestration' import { checkAccess } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, - v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiTable, v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' +import type { OrchestrationOutcome } from '@/app/api/v2/tables/utils' +import { + toApiTable, + v2TableAccessError, + v2TableLockError, + v2TableOrchestrationError, +} from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableDetailAPI') +/** + * `details` payload naming the operations of a composite write that committed, + * or `undefined` when none did — so `details.applied` being present always + * means "these changes are live despite the error". + */ +function appliedDetails( + applied: readonly ('name' | 'folderId')[] +): { applied: readonly string[] } | undefined { + return applied.length > 0 ? { applied } : undefined +} + export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -69,6 +97,151 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR } }) +/** + * PATCH /api/v2/tables/[tableId] — Rename and/or move a table. + * + * Each field routes to its own orchestration call so the audit records the + * operation the caller actually performed. + * + * Lock flags are **not** settable here. They are readable on the table resource + * and enforced on every write, but an API key that can mutate a table must not + * also be able to clear the lock placed there to stop it; changing a lock stays + * a first-party admin action. The contract body is `.strict()`, so a request + * carrying `locks` is rejected rather than silently ignored. + */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + /** + * Hoisted above the `try` so every exit path can report it. Once a write has + * committed, the response must say so even when the failure came *after* the + * writes — a throw in the final re-read, or the re-read finding the table + * archived. Reporting a bare 500 there tells the caller nothing landed, and + * it retries into a duplicate-name conflict or a repeated move. + */ + const applied: ('name' | 'folderId')[] = [] + + try { + const rateLimit = await checkRateLimit(request, 'table-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateTableContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const validated = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // The two operations are separate transactions, so a rejection discovered + // partway through would leave the earlier one persisted while the response + // reports failure. Everything a request can be rejected for is therefore + // checked up front: a rejected PATCH changes nothing. + if (validated.folderId != null) { + // Scoped to `resourceType: 'table'` so a folder id from another resource's + // tree can't file the table somewhere Tables never lists. + if (!(await findActiveFolder(validated.folderId, table.workspaceId, 'table'))) { + return v2Error('NOT_FOUND', 'Folder not found in this workspace') + } + } + + // Every deterministic rejection is already behind us, so a failure here is + // a genuine fault (lost race, archived mid-request, database error) rather + // than a bad request. The two operations commit independently — a single + // transaction would have to span two shared service functions that also + // back the first-party route and two copilot tools, and would break their + // per-operation audits — so instead of pretending atomicity the response + // states exactly which operations landed. A caller that gets an error can + // then reconcile rather than having to re-read and diff. + let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null + + if (validated.name !== undefined) { + const outcome = await performRenameTable({ + table, + newName: validated.name, + userId, + requestId, + request, + }) + if (outcome.success) applied.push('name') + else failure = { outcome, fallback: 'Failed to rename table' } + } + + if (!failure && validated.folderId !== undefined) { + const outcome = await performMoveTableToFolder({ + table, + folderId: validated.folderId, + userId, + requestId, + request, + }) + if (outcome.success) { + applied.push('folderId') + } else { + // The move re-asserts workspace and active state, so a miss means the + // table was archived between `checkAccess` and the write. + failure = { + outcome: + outcome.errorCode === 'not_found' ? { ...outcome, error: 'Table not found' } : outcome, + fallback: 'Failed to move table', + } + } + } + + // Live-collab: tell open viewers the definition changed so they refetch. + if (applied.length > 0) signalTableSchemaChanged(tableId) + if (failure) { + return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied)) + } + + // Re-read so the response reflects every applied change at once. A miss + // means the table was archived after the writes committed, so the caller + // still has to be told what landed. + const updated = await getTableById(tableId) + if (!updated) { + return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) }) + } + + return v2Data({ table: toApiTable(updated) }, { rateLimit }) + } catch (error) { + const details = appliedDetails(applied) + + const lockError = v2TableLockError(error, details) + if (lockError) return lockError + + const classified = asOrchestrationError(error) + if (classified) { + return v2TableOrchestrationError( + { errorCode: classified.code, error: classified.message }, + 'Failed to update table', + details + ) + } + + logger.error(`[${requestId}] Error updating table`, { + error: getErrorMessage(error, 'Unknown error'), + applied, + }) + return v2Error('INTERNAL_ERROR', 'Internal server error', { details }) + } +}) + /** DELETE /api/v2/tables/[tableId] — Archive a table. */ export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { const requestId = generateRequestId() @@ -102,7 +275,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab const outcome = await performDeleteTable({ table: result.table, userId, requestId, request }) if (!outcome.success) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to delete table') + return v2TableOrchestrationError(outcome, 'Failed to delete table') } return v2Data({ id: tableId }, { rateLimit }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts new file mode 100644 index 00000000000..cc1566ce848 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts @@ -0,0 +1,160 @@ +/** + * @vitest-environment node + * + * Public v2 per-row enrichment run — the single-cell case of the column run. + * Naming a specific cell is an explicit re-run, so it dispatches in `all` mode + * and recomputes an already-populated cell. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockRunWorkflowColumn, + mockSignalRowsChanged, + mockGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockRunWorkflowColumn: vi.fn(), + mockSignalRowsChanged: vi.fn(), + mockGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/table/workflow-columns', () => ({ runWorkflowColumn: mockRunWorkflowColumn })) +vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route' + +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest( + 'http://localhost:3000/api/v2/tables/table-1/rows/row-1/enrichment/group-1', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + ) + return POST(req, { + params: Promise.resolve({ tableId: 'table-1', rowId: 'row-1', groupId: 'group-1' }), + }) +} + +describe('POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockRunWorkflowColumn.mockResolvedValue({ dispatchId: 'dispatch-1' }) + mockGateError.mockResolvedValue(null) + }) + + it('scopes the dispatch to the one row and group in the path', async () => { + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ dispatchId: 'dispatch-1' }) + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: 'table-1', + workspaceId: 'ws-1', + groupIds: ['group-1'], + rowIds: ['row-1'], + mode: 'all', + triggeredByUserId: 'user-1', + }) + ) + expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1') + }) + + it('reports a null dispatch id verbatim rather than inventing one', async () => { + mockRunWorkflowColumn.mockResolvedValue({ dispatchId: null }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ dispatchId: null }) + }) + + it('404s a table in another workspace without dispatching', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(404) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('400s a body with no workspace', async () => { + const res = await callPost({}) + + expect(res.status).toBe(400) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(403) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(429) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts new file mode 100644 index 00000000000..9f3e7a27b69 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts @@ -0,0 +1,96 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2RunRowEnrichmentContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { signalTableRowsChanged } from '@/lib/table/events' +import { runWorkflowColumn } from '@/lib/table/workflow-columns' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRowEnrichmentAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RowEnrichmentRouteParams { + params: Promise<{ tableId: string; rowId: string; groupId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId] + * + * The single-cell case of `POST /columns/run`: runs one group for one row. + * `mode: 'all'` because naming a specific cell is an explicit re-run request — + * an already-populated cell must recompute rather than be skipped. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: RowEnrichmentRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-enrichment') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2RunRowEnrichmentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, rowId, groupId } = parsed.data.params + const { workspaceId } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + if (access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const { dispatchId } = await runWorkflowColumn({ + tableId, + workspaceId, + groupIds: [groupId], + rowIds: [rowId], + mode: 'all', + requestId, + triggeredByUserId: userId, + }) + + signalTableRowsChanged(tableId) + + return v2Data({ dispatchId }, { rateLimit }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + logger.error(`[${requestId}] Error running row enrichment`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) 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 2139216449a..626dd3ba567 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 @@ -96,4 +96,27 @@ describe('DELETE /api/v2/tables/[tableId]/rows/[rowId]', () => { expect(res.status).toBe(status) expect((await res.json()).error.code).toBe(code) }) + + it('names the lock on a 423 that arrived as a classified outcome, not a throw', async () => { + mockPerformDeleteRow.mockResolvedValue({ + success: false, + errorCode: 'locked', + error: 'Row deletes are locked for this table', + lock: 'delete', + }) + + const res = await callDelete() + + expect(res.status).toBe(423) + expect((await res.json()).error.details).toEqual({ lock: 'delete' }) + }) + + it('omits details entirely when the lock kind is unknown', async () => { + // A caller branching on `details.lock` should see absence, not a null. + mockPerformDeleteRow.mockResolvedValue({ success: false, errorCode: 'locked', error: 'nope' }) + + const res = await callDelete() + + expect((await res.json()).error.details).toBeUndefined() + }) }) 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 b0bb10b78d3..026348e69f9 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 @@ -23,12 +23,16 @@ import { v2CaughtOrchestrationError, v2Data, v2Error, - v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -import { toApiRow, v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' +import { + toApiRow, + v2TableAccessError, + v2TableLockError, + v2TableOrchestrationError, +} from '@/app/api/v2/tables/utils' const logger = createLogger('V2TableRowAPI') @@ -209,7 +213,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row const outcome = await performDeleteTableRow({ table: result.table, rowId, requestId }) if (!outcome.success) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to delete row') + return v2TableOrchestrationError(outcome, 'Failed to delete row') } // v2 mirrors the bulk delete shape: always returns `deletedRowIds`. diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts new file mode 100644 index 00000000000..19f38dfb59f --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts @@ -0,0 +1,207 @@ +/** + * @vitest-environment node + * + * Public v2 row lookup. The wire is column-NAME keyed both ways: the predicate + * and sort translate down to storage ids on the way in, and the matched column + * id translates back to its name on the way out. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockFindRowMatches, + mockPredicateToFilter, + mockValidateSortSpec, + mockSortSpecNamesToIds, + mockGateError, + TableQueryValidationError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockFindRowMatches: vi.fn(), + mockPredicateToFilter: vi.fn(), + mockValidateSortSpec: vi.fn(), + mockSortSpecNamesToIds: vi.fn(), + mockGateError: vi.fn(), + TableQueryValidationError: class TableQueryValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({ + ...(await importOriginal>()), + v2BulkPredicateToFilter: mockPredicateToFilter, +})) + +vi.mock('@/lib/table', () => ({ + buildIdByName: vi.fn().mockReturnValue({ status: 'col-1', name: 'col-2' }), + sortSpecNamesToIds: mockSortSpecNamesToIds, +})) +vi.mock('@/lib/table/rows/service', () => ({ findRowMatches: mockFindRowMatches })) +vi.mock('@/lib/table/query-builder/validate', () => ({ validateSortSpec: mockValidateSortSpec })) +vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { POST } from '@/app/api/v2/tables/[tableId]/rows/find/route' + +const COLUMNS = [ + { id: 'col-1', name: 'status', type: 'string' }, + { id: 'col-2', name: 'name', type: 'string' }, +] +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } } + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/rows/find', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +describe('POST /api/v2/tables/[tableId]/rows/find', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockFindRowMatches.mockResolvedValue({ + matches: [{ ordinal: 3, rowId: 'row-1', column: 'col-2' }], + truncated: false, + }) + mockSortSpecNamesToIds.mockImplementation((spec: { field: string }[]) => + spec.map((s) => ({ ...s, field: s.field === 'name' ? 'col-2' : s.field })) + ) + mockGateError.mockResolvedValue(null) + }) + + it('reports the matched column by NAME, not its storage id', async () => { + const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ + matches: [{ ordinal: 3, rowId: 'row-1', column: 'name' }], + truncated: false, + }) + expect(mockFindRowMatches).toHaveBeenCalledWith( + TABLE, + { q: 'acme', filter: undefined, sort: undefined }, + expect.any(String) + ) + }) + + it('translates the predicate and sort to storage keys before searching', async () => { + mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } }) + const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } + + const res = await callPost({ + workspaceId: 'ws-1', + q: 'acme', + predicate, + sort: [{ field: 'name', direction: 'asc' }], + }) + + expect(res.status).toBe(200) + expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema) + expect(mockValidateSortSpec).toHaveBeenCalledWith( + [{ field: 'name', direction: 'asc' }], + COLUMNS + ) + expect(mockFindRowMatches).toHaveBeenCalledWith( + TABLE, + { q: 'acme', filter: { 'col-1': { $eq: 'active' } }, sort: { 'col-2': 'asc' } }, + expect.any(String) + ) + }) + + it('surfaces truncation so a caller narrows instead of paging', async () => { + mockFindRowMatches.mockResolvedValue({ matches: [], truncated: true }) + + const res = await callPost({ workspaceId: 'ws-1', q: 'a' }) + + expect((await res.json()).data).toEqual({ matches: [], truncated: true }) + }) + + it('400s an unresolvable predicate field instead of returning zero matches', async () => { + mockPredicateToFilter.mockImplementation(() => { + throw new TableQueryValidationError('Unknown column "nope"') + }) + + const res = await callPost({ + workspaceId: 'ws-1', + q: 'acme', + predicate: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, + }) + + expect(res.status).toBe(400) + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) + + it('400s an empty search string', async () => { + const res = await callPost({ workspaceId: 'ws-1', q: '' }) + + expect(res.status).toBe(400) + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) + + it('masks a permission failure as 404 so table existence never leaks', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) + + expect(res.status).toBe(404) + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) + + expect(res.status).toBe(429) + expect(mockFindRowMatches).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts new file mode 100644 index 00000000000..68d86f3dea5 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts @@ -0,0 +1,116 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2FindTableRowsContract } from '@/lib/api/contracts/v2/tables' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { Filter, Sort, TableSchema } from '@/lib/table' +import { buildIdByName, sortSpecNamesToIds } from '@/lib/table' +import { TableQueryValidationError } from '@/lib/table/errors' +import { validateSortSpec } from '@/lib/table/query-builder/validate' +import { findRowMatches } from '@/lib/table/rows/service' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { columnNameById, v2BulkPredicateToFilter } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableRowsFindAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/v2/tables/[tableId]/rows/find — Case-insensitive substring search + * across every cell, narrowed by the same predicate/sort grammar as + * `POST /query`. + * + * Returns matching CELLS, not rows: each match carries the row's ordinal in the + * same filtered+sorted view a `POST /query` with these arguments would return, + * so a caller can jump straight to the page holding it. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-rows-find') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2FindTableRowsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, q, predicate, sort } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!accessResult.ok || accessResult.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const { table } = accessResult + const schema = table.schema as TableSchema + + // The public wire is column-NAME keyed both ways: translate the predicate + // and sort down to storage ids on the way in, and the matched column id + // back to its name on the way out. + let filter: Filter | undefined + if (predicate) filter = v2BulkPredicateToFilter(predicate, schema) + + let sortObj: Sort | undefined + if (sort?.length) { + validateSortSpec(sort, schema.columns) + const storageSort = sortSpecNamesToIds(sort, buildIdByName(schema)) + sortObj = Object.fromEntries(storageSort.map((s) => [s.field, s.direction])) + } + + const { matches, truncated } = await findRowMatches( + table, + { q, filter, sort: sortObj }, + requestId + ) + + const toColumnName = columnNameById(schema) + + return v2Data( + { + matches: matches.map((match) => ({ + ordinal: match.ordinal, + rowId: match.rowId, + column: toColumnName(match.column), + })), + truncated, + }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error finding rows`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) 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 new file mode 100644 index 00000000000..25488f0ad26 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts @@ -0,0 +1,240 @@ +/** + * @vitest-environment node + * + * Public v2 saved-view detail: read, patch, delete. A view that is not on this + * table is a 404 rather than a silent no-op, so a caller can tell a wrong id + * from a successful write. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockGetTableView, + mockUpdateTableView, + mockDeleteTableView, + mockGateError, + TableViewValidationError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockGetTableView: vi.fn(), + mockUpdateTableView: vi.fn(), + mockDeleteTableView: vi.fn(), + mockGateError: vi.fn(), + TableViewValidationError: class TableViewValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/table', () => ({ + getTableView: mockGetTableView, + updateTableView: mockUpdateTableView, + deleteTableView: mockDeleteTableView, + TableViewValidationError, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { DELETE, GET, PATCH } from '@/app/api/v2/tables/[tableId]/views/[viewId]/route' + +const COLUMNS = [{ id: 'col-1', name: 'status', type: 'string' }] +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } } +const VIEW = { + id: 'view-1', + tableId: 'table-1', + name: 'Active', + config: {}, + isDefault: false, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} +const API_VIEW = { + ...VIEW, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +const params = { params: Promise.resolve({ tableId: 'table-1', viewId: 'view-1' }) } + +function callGet() { + return GET( + new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1?workspaceId=ws-1', { + method: 'GET', + }), + params + ) +} + +function callPatch(body: unknown) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + params + ) +} + +function callDelete() { + return DELETE( + new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1?workspaceId=ws-1', { + method: 'DELETE', + }), + params + ) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGateError.mockResolvedValue(null) +}) + +describe('GET /api/v2/tables/[tableId]/views/[viewId]', () => { + it('returns the view scoped to its table', async () => { + mockGetTableView.mockResolvedValue(VIEW) + + const res = await callGet() + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ view: API_VIEW }) + expect(mockGetTableView).toHaveBeenCalledWith('view-1', 'table-1', COLUMNS) + }) + + it('404s a view id that belongs to a different table', async () => { + mockGetTableView.mockResolvedValue(null) + + const res = await callGet() + + expect(res.status).toBe(404) + expect((await res.json()).error.message).toBe('View not found') + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callGet() + + expect(res.status).toBe(429) + expect(mockGetTableView).not.toHaveBeenCalled() + }) +}) + +describe('PATCH /api/v2/tables/[tableId]/views/[viewId]', () => { + it('forwards the patch fields to the service', async () => { + mockUpdateTableView.mockResolvedValue({ ...VIEW, isDefault: true }) + + const res = await callPatch({ workspaceId: 'ws-1', isDefault: true }) + + expect(res.status).toBe(200) + expect((await res.json()).data.view.isDefault).toBe(true) + expect(mockUpdateTableView).toHaveBeenCalledWith({ + viewId: 'view-1', + tableId: 'table-1', + name: undefined, + config: undefined, + configPatch: undefined, + isDefault: true, + columns: COLUMNS, + }) + }) + + it('400s a body that changes nothing', async () => { + const res = await callPatch({ workspaceId: 'ws-1' }) + + expect(res.status).toBe(400) + expect(mockUpdateTableView).not.toHaveBeenCalled() + }) + + it('400s config and configPatch together', async () => { + const res = await callPatch({ workspaceId: 'ws-1', config: {}, configPatch: {} }) + + expect(res.status).toBe(400) + expect(mockUpdateTableView).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPatch({ workspaceId: 'ws-1', isDefault: true }) + + expect(res.status).toBe(403) + expect(mockUpdateTableView).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPatch({ workspaceId: 'ws-1', isDefault: true }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockUpdateTableView).not.toHaveBeenCalled() + }) +}) + +describe('DELETE /api/v2/tables/[tableId]/views/[viewId]', () => { + it('returns the deleted view id', async () => { + mockDeleteTableView.mockResolvedValue(true) + + const res = await callDelete() + + expect(res.status).toBe(200) + expect((await res.json()).data).toEqual({ id: 'view-1' }) + expect(mockDeleteTableView).toHaveBeenCalledWith('view-1', 'table-1') + }) + + it('404s when nothing was deleted rather than reporting a phantom success', async () => { + mockDeleteTableView.mockResolvedValue(false) + + const res = await callDelete() + + expect(res.status).toBe(404) + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callDelete() + + expect(res.status).toBe(403) + expect(mockDeleteTableView).not.toHaveBeenCalled() + }) +}) 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 new file mode 100644 index 00000000000..ba29f7665c0 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts @@ -0,0 +1,183 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteTableViewContract, + v2GetTableViewContract, + v2UpdateTableViewContract, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { TableSchema } from '@/lib/table' +import { + deleteTableView, + getTableView, + TableViewValidationError, + updateTableView, +} from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiView, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableViewDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableViewRouteParams { + params: Promise<{ tableId: string; viewId: string }> +} + +/** GET /api/v2/tables/[tableId]/views/[viewId] — One saved view. */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableViewRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-view-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetTableViewContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, viewId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok || result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const view = await getTableView(viewId, tableId, (result.table.schema as TableSchema).columns) + if (!view) return v2Error('NOT_FOUND', 'View not found') + + return v2Data({ view: toApiView(view) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error getting table view`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * PATCH /api/v2/tables/[tableId]/views/[viewId] — Rename, replace or merge the + * config, or promote the view to the table's default. + */ +export const PATCH = withRouteHandler( + async (request: NextRequest, context: TableViewRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-view-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateTableViewContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, viewId } = parsed.data.params + const { workspaceId, name, config, configPatch, isDefault } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const view = await updateTableView({ + viewId, + tableId, + name, + config, + configPatch, + isDefault, + columns: (result.table.schema as TableSchema).columns, + }) + if (!view) return v2Error('NOT_FOUND', 'View not found') + + return v2Data({ view: toApiView(view) }, { rateLimit }) + } catch (error) { + if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error updating table view`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +/** DELETE /api/v2/tables/[tableId]/views/[viewId] — Remove a saved view. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: TableViewRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-view-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteTableViewContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId, viewId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const deleted = await deleteTableView(viewId, tableId) + if (!deleted) return v2Error('NOT_FOUND', 'View not found') + + return v2Data({ id: viewId }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting table view`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) 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 new file mode 100644 index 00000000000..8a789e0de94 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts @@ -0,0 +1,204 @@ +/** + * @vitest-environment node + * + * Public v2 saved views: list and create. A view is presentation state, so the + * read needs only `read` while saving one needs `write`. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCheckAccess, + mockListTableViews, + mockCreateTableView, + mockGateError, + TableViewValidationError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCheckAccess: vi.fn(), + mockListTableViews: vi.fn(), + mockCreateTableView: vi.fn(), + mockGateError: vi.fn(), + TableViewValidationError: class TableViewValidationError extends Error {}, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/table/utils', () => ({ + checkAccess: mockCheckAccess, + normalizeColumn: (col: Record) => col, + rootErrorMessage: (error: unknown) => String(error), + rowWriteErrorResponse: () => null, +})) + +vi.mock('@/lib/table', () => ({ + listTableViews: mockListTableViews, + createTableView: mockCreateTableView, + TableViewValidationError, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) + +import { GET, POST } from '@/app/api/v2/tables/[tableId]/views/route' + +const COLUMNS = [{ id: 'col-1', name: 'status', type: 'string' }] +const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } } +const VIEW = { + id: 'view-1', + tableId: 'table-1', + name: 'Active', + config: { filter: { all: [{ field: 'col-1', op: 'eq', value: 'active' }] } }, + isDefault: true, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} +const API_VIEW = { + ...VIEW, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', +} + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + workspaceId: 'ws-1', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callGet() { + const req = new NextRequest( + 'http://localhost:3000/api/v2/tables/table-1/views?workspaceId=ws-1', + { method: 'GET' } + ) + return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +function callPost(body: unknown) { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/views', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) + mockGateError.mockResolvedValue(null) +}) + +describe('GET /api/v2/tables/[tableId]/views', () => { + it('returns every view as one full page with ISO timestamps', async () => { + mockListTableViews.mockResolvedValue([VIEW]) + + const res = await callGet() + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: [API_VIEW], nextCursor: null }) + // The columns are passed so stale references are pruned from each config. + expect(mockListTableViews).toHaveBeenCalledWith('table-1', COLUMNS) + }) + + it('404s a table in another workspace without listing', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockListTableViews).not.toHaveBeenCalled() + }) + + it('masks a permission failure as 404 so table existence never leaks', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callGet() + + expect(res.status).toBe(404) + expect(mockListTableViews).not.toHaveBeenCalled() + }) + + it('429s a throttled caller', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + allowed: false, + remaining: 0, + retryAfterMs: 1000, + }) + + const res = await callGet() + + expect(res.status).toBe(429) + expect(mockListTableViews).not.toHaveBeenCalled() + }) +}) + +describe('POST /api/v2/tables/[tableId]/views', () => { + it('creates the view with the caller as author and answers 201', async () => { + mockCreateTableView.mockResolvedValue(VIEW) + + const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) + + expect(res.status).toBe(201) + expect((await res.json()).data).toEqual({ view: API_VIEW }) + expect(mockCreateTableView).toHaveBeenCalledWith({ + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'Active', + config: {}, + userId: 'user-1', + columns: COLUMNS, + }) + }) + + it('400s a blank view name without touching the service', async () => { + const res = await callPost({ workspaceId: 'ws-1', name: ' ', config: {} }) + + expect(res.status).toBe(400) + expect(mockCreateTableView).not.toHaveBeenCalled() + }) + + it('403s a read-only member', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + + const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) + + expect(res.status).toBe(403) + expect(mockCreateTableView).not.toHaveBeenCalled() + }) + + it('404s with the gate off, before any work', async () => { + mockGateError.mockResolvedValue( + new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { + status: 404, + }) + ) + + const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) + + expect(res.status).toBe(404) + expect(mockCheckAccess).not.toHaveBeenCalled() + expect(mockCreateTableView).not.toHaveBeenCalled() + }) + + it('surfaces a service-level view validation failure as 400', async () => { + mockCreateTableView.mockRejectedValue(new TableViewValidationError('View name cannot be empty')) + + const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toBe('View name cannot be empty') + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts new file mode 100644 index 00000000000..be0dcbe0fa7 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts @@ -0,0 +1,127 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateTableViewContract, v2ListTableViewsContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { TableSchema } from '@/lib/table' +import { createTableView, listTableViews, TableViewValidationError } from '@/lib/table' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { toApiView, v2TableAccessError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableViewsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TableRouteParams { + params: Promise<{ tableId: string }> +} + +/** + * GET /api/v2/tables/[tableId]/views — Every saved view on the table. + * + * A table carries a bounded set of views, so this is one full page and + * `nextCursor` is always `null`. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-views') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ListTableViewsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!result.ok || result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const views = await listTableViews(tableId, (result.table.schema as TableSchema).columns) + + return v2CursorList(views.map(toApiView), null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing table views`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/tables/[tableId]/views — Save a filter/sort/layout as a named view. */ +export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-views') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2CreateTableViewContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { tableId } = parsed.data.params + const { workspaceId, name, config } = parsed.data.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const view = await createTableView({ + tableId, + workspaceId, + name, + config, + userId, + columns: (result.table.schema as TableSchema).columns, + }) + + return v2Data({ view: toApiView(view) }, { rateLimit, status: 201 }) + } catch (error) { + if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) + + logger.error(`[${requestId}] Error creating table view`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts new file mode 100644 index 00000000000..87268ab070f --- /dev/null +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts @@ -0,0 +1,69 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2TableExportDownloadContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { requireTableExport, tableExportResult } from '@/lib/table/orchestration/export-resource' +import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableExportDownloadAPI') +const DOWNLOAD_TTL_SECONDS = 60 * 60 + +interface TableExportRouteParams { + params: Promise<{ exportId: string }> +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: TableExportRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'table-export') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2TableExportDownloadContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const record = await requireTableExport(parsed.data.params.exportId, workspaceId) + const access = await checkAccess(record.tableId, userId, 'read') + if (!access.ok || access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table export not found') + } + const result = tableExportResult(record) + const url = await generatePresignedDownloadUrl( + result.resultKey, + 'workspace', + DOWNLOAD_TTL_SECONDS + ) + return v2Data( + { + url, + fileName: result.resultKey.split('/').pop() ?? `export.${result.format}`, + expiresAt: new Date(Date.now() + DOWNLOAD_TTL_SECONDS * 1000).toISOString(), + }, + { rateLimit } + ) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to issue table export download', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts new file mode 100644 index 00000000000..4fa1032e782 --- /dev/null +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts @@ -0,0 +1,92 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CancelTableExportContract, + v2GetTableExportContract, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + cancelTableExportResource, + requireTableExport, + toV2TableExport, +} from '@/lib/table/orchestration/export-resource' +import { checkAccess } from '@/app/api/table/utils' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableExportAPI') + +interface TableExportRouteParams { + params: Promise<{ exportId: string }> +} + +async function authorizeExport(exportId: string, workspaceId: string, userId: string) { + const record = await requireTableExport(exportId, workspaceId) + const access = await checkAccess(record.tableId, userId, 'read') + if (!access.ok || access.table.workspaceId !== workspaceId) return null + return record +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: TableExportRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'table-export') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2GetTableExportContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const record = await authorizeExport(parsed.data.params.exportId, workspaceId, userId) + if (!record) return v2Error('NOT_FOUND', 'Table export not found') + return v2Data(toV2TableExport(record), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to read table export', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: TableExportRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'table-export') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2CancelTableExportContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const record = await authorizeExport(parsed.data.params.exportId, workspaceId, userId) + if (!record) return v2Error('NOT_FOUND', 'Table export not found') + return v2Data(toV2TableExport(await cancelTableExportResource(record)), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to cancel table export', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts new file mode 100644 index 00000000000..783a473fda5 --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts @@ -0,0 +1,115 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockGetOwnedTableImportUpload, + mockFindOwnedTableImport, + mockStartUploadedTableImport, + mockToV2TableImport, + mockCompleteUploadSession, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockGetOwnedTableImportUpload: vi.fn(), + mockFindOwnedTableImport: vi.fn(), + mockStartUploadedTableImport: vi.fn(), + mockToV2TableImport: vi.fn(), + mockCompleteUploadSession: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/app/api/v2/tables/utils', () => ({ + v2TableLockError: vi.fn().mockReturnValue(null), +})) + +vi.mock('@/lib/table/orchestration/import-resource', () => ({ + findOwnedTableImport: mockFindOwnedTableImport, + getOwnedTableImportUpload: mockGetOwnedTableImportUpload, + startUploadedTableImport: mockStartUploadedTableImport, + toV2TableImport: mockToV2TableImport, +})) + +vi.mock('@/lib/uploads/multipart-session/service', () => ({ + completeUploadSession: mockCompleteUploadSession, +})) + +import { POST } from '@/app/api/v2/tables/imports/[importId]/complete/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-03T22:00:00.000Z'), +} +const UPLOAD = { + id: 'import-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', +} + +function request() { + return POST( + new NextRequest( + `http://localhost:3000/api/v2/tables/imports/import-1/complete?workspaceId=${WORKSPACE_ID}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'upload-token': 'signed-upload-token', + }, + body: JSON.stringify({ parts: [{ partNumber: 1, etag: 'etag-1' }] }), + } + ), + { params: Promise.resolve({ importId: 'import-1' }) } + ) +} + +describe('POST /api/v2/tables/imports/[importId]/complete', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockResolveWorkspaceScope.mockResolvedValue(null) + mockGetOwnedTableImportUpload.mockReturnValue(UPLOAD) + }) + + it('returns the existing table job when completion is retried', async () => { + const existing = { id: 'import-1', tableId: 'table-1', status: 'ready' } + const responseBody = { id: 'import-1', tableId: 'table-1', status: 'completed' } + mockFindOwnedTableImport.mockResolvedValue(existing) + mockToV2TableImport.mockReturnValue(responseBody) + + const response = await request() + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: responseBody }) + expect(mockGetOwnedTableImportUpload).toHaveBeenCalledWith({ + importId: 'import-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + uploadToken: 'signed-upload-token', + }) + expect(mockFindOwnedTableImport).toHaveBeenCalledWith({ + importId: 'import-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + }) + expect(mockCompleteUploadSession).not.toHaveBeenCalled() + expect(mockStartUploadedTableImport).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts new file mode 100644 index 00000000000..4e44d28dafe --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts @@ -0,0 +1,75 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CompleteTableImportContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + findOwnedTableImport, + getOwnedTableImportUpload, + startUploadedTableImport, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +import { completeUploadSession } from '@/lib/uploads/multipart-session/service' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2TableLockError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2CompleteTableImportAPI') + +interface TableImportRouteParams { + params: Promise<{ importId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: TableImportRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'table-import') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2CompleteTableImportContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const upload = getOwnedTableImportUpload({ + importId: parsed.data.params.importId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const existing = await findOwnedTableImport({ + importId: upload.id, + workspaceId: upload.workspaceId, + userId: upload.userId, + }) + if (existing) return v2Data(toV2TableImport(existing), { rateLimit }) + const completed = await completeUploadSession({ + session: upload, + parts: parsed.data.body.parts, + finalize: async () => ({ value: null }), + }) + const started = await startUploadedTableImport(completed.session) + return v2Data(await toV2TableImport(started), { rateLimit }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to complete table import upload', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts new file mode 100644 index 00000000000..74f3153a9be --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts @@ -0,0 +1,60 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateTableImportPartUrlsContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getOwnedTableImportUpload } from '@/lib/table/orchestration/import-resource' +import { createUploadPartUrls } from '@/lib/uploads/multipart-session/service' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableImportPartsAPI') + +interface TableImportRouteParams { + params: Promise<{ importId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: TableImportRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'table-import') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2CreateTableImportPartUrlsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const session = getOwnedTableImportUpload({ + importId: parsed.data.params.importId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const parts = await createUploadPartUrls({ + session, + partNumbers: parsed.data.body.partNumbers, + localOrigin: request.nextUrl.origin, + }) + return v2Data({ parts }, { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create table import part URLs', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts new file mode 100644 index 00000000000..22005ef907e --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts @@ -0,0 +1,99 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CancelTableImportContract, + v2GetTableImportContract, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + abortTableImportUpload, + cancelTableImportResource, + getOwnedTableImport, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableImportAPI') + +interface TableImportRouteParams { + params: Promise<{ importId: string }> +} + +export const GET = withRouteHandler( + async (request: NextRequest, context: TableImportRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'table-import') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2GetTableImportContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.query.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const record = await getOwnedTableImport({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId, + }) + return v2Data(await toV2TableImport(record), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to read table import', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: TableImportRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'table-import') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest(v2CancelTableImportContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.query.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const uploadToken = parsed.data.headers['upload-token'] + const record = uploadToken + ? await abortTableImportUpload({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId, + uploadToken, + }) + : await cancelTableImportResource( + await getOwnedTableImport({ + importId: parsed.data.params.importId, + workspaceId: parsed.data.query.workspaceId, + userId, + }) + ) + return v2Data(toV2TableImport(record), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to cancel table import', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/tables/imports/route.ts b/apps/sim/app/api/v2/tables/imports/route.ts new file mode 100644 index 00000000000..2a0aeaf07a5 --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/route.ts @@ -0,0 +1,53 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateTableImportContract } from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createTableImportResource, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { v2TableLockError } from '@/app/api/v2/tables/utils' + +const logger = createLogger('V2TableImportsAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'table-import') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2CreateTableImportContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.body.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const created = await createTableImportResource(parsed.data.body, userId) + return v2Data(await toV2TableImport(created.record), { rateLimit, status: 201 }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create table import', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index 8d662be3d4a..bcc780e23ab 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -1,5 +1,8 @@ import type { NextResponse } from 'next/server' +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 { getColumnId } from '@/lib/table/column-keys' import { TableLockedError } from '@/lib/table/mutation-locks' import { predicateToFilter } from '@/lib/table/query-builder/converters' import { @@ -7,9 +10,15 @@ import { validateStoragePredicate, } from '@/lib/table/query-builder/validate' import { predicateToStorage } from '@/lib/table/select-values' -import type { Filter } from '@/lib/table/types' -import { normalizeColumn, rootErrorMessage, rowWriteErrorResponse } from '@/app/api/table/utils' -import { v2Error } from '@/app/api/v2/lib/response' +import type { Filter, TableLockKind } from '@/lib/table/types' +import type { TableView } from '@/lib/table/views/service' +import { + CSV_IMPORT_PROXY_BODY_CAP_BYTES, + normalizeColumn, + rootErrorMessage, + rowWriteErrorResponse, +} 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 @@ -54,11 +63,52 @@ export function toApiTable(table: TableDefinition) { }, rowCount: table.rowCount, maxRows: table.maxRows, + folderId: table.folderId ?? null, + locks: table.locks, + // `jobStatus` is the presence signal — the service leaves the whole group + // null when the table is idle. Without this an async import could be + // started and cancelled but never observed to completion or failure. + job: table.jobStatus + ? { + id: table.jobId ?? null, + type: table.jobType ?? null, + status: table.jobStatus, + rowsProcessed: table.jobRowsProcessed ?? 0, + error: table.jobError ?? null, + } + : null, createdAt: toIso(table.createdAt), updatedAt: toIso(table.updatedAt), } } +/** + * Normalized public view shape. Identical to the stored view except that the + * timestamps are ISO strings, matching every other v2 payload. + */ +export function toApiView(view: TableView) { + return { + id: view.id, + tableId: view.tableId, + name: view.name, + config: view.config, + isDefault: view.isDefault, + createdBy: view.createdBy, + createdAt: toIso(view.createdAt), + updatedAt: toIso(view.updatedAt), + } +} + +/** + * Maps a stored column id (the JSONB key that `findRowMatches` reports) back to + * its display name, so cell references on the public wire are name-keyed like + * 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])) + return (columnId) => nameById.get(columnId) ?? columnId +} + /** * Row fields the public API exposes. `data` is stored id-keyed; {@link toApiRow} * translates it to column names. @@ -85,6 +135,35 @@ export function toApiRow(row: ApiRowInput, toNamedRow: (data: RowData) => RowDat } } +/** + * 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.' + ) +} + /** * Renders a failed {@link checkAccess} result on a MUTATION path: a missing * table stays 404, a missing permission stays 403. Read paths instead mask both @@ -100,12 +179,59 @@ export function v2TableAccessError(result: { ok: false; status: 404 | 403 }): Ne * 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): NextResponse | null { - if (error instanceof TableLockedError) return v2Error('LOCKED', error.message) +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 + ) +} + /** * Maps a known user-facing row-write failure (schema/size/unique/limit) to a v2 * `BAD_REQUEST`, reusing v1's {@link rowWriteErrorResponse} classifier as the 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 new file mode 100644 index 00000000000..3baff93ec04 --- /dev/null +++ b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts @@ -0,0 +1,56 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { localUploadPartContract } from '@/lib/api/contracts/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { writeLocalMultipartPart } from '@/lib/uploads/multipart-session/provider' +import { + expectedUploadPartSize, + type UploadSessionRecord, + verifyUploadSessionToken, +} from '@/lib/uploads/multipart-session/service' + +interface LocalPartRouteParams { + params: Promise<{ uploadId: string; partNumber: string }> +} + +/** + * 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. + */ +export const PUT = withRouteHandler( + async (request: NextRequest, context: LocalPartRouteParams): Promise => { + const { uploadId } = await context.params + const token = request.nextUrl.searchParams.get('token') ?? '' + let session: UploadSessionRecord + try { + session = verifyUploadSessionToken(token) + } catch { + return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) + } + const parsed = await parseRequest(localUploadPartContract, request, context) + 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 }) + } + if (session.status !== 'uploading') { + return NextResponse.json({ error: `Upload session is ${session.status}` }, { status: 409 }) + } + + 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 } + ) + } + if (!request.body) { + return NextResponse.json({ error: 'Upload part body is required' }, { status: 400 }) + } + + await writeLocalMultipartPart({ uploadId, partNumber, body: request.body, expectedSize }) + return new NextResponse(null, { status: 204 }) + } +) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts index b91d1b99318..efd99ab7b40 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts @@ -2,14 +2,13 @@ import { useCallback, useEffect, useRef } from 'react' import { toast } from '@sim/emcn' -import { generateId } from '@sim/utils/id' import { useRouter } from 'next/navigation' import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { useImportFileAsTable } from '@/hooks/queries/tables' import { useImportTrayStore } from '@/stores/table/import-tray/store' -export type CsvImportFileDescriptor = Pick +export type CsvImportFileDescriptor = Pick /** * Wires the "Import as a table" affordance for a capped CSV preview. When the preview is @@ -32,10 +31,7 @@ export function useCsvTruncationImport( const importAsTable = useCallback(() => { if (importingRef.current) return importingRef.current = true - const pendingId = `pending_${generateId()}` - useImportTrayStore - .getState() - .startUpload({ uploadId: pendingId, workspaceId, title: file.name }) + let importId: string | null = null toast.success(`Importing "${file.name}" as a table`, { description: 'This runs in the background.', action: { @@ -44,17 +40,29 @@ export function useCsvTruncationImport( }, }) importFile.mutate( - { workspaceId, fileKey: file.key, fileName: file.name }, + { + workspaceId, + fileId: file.id, + fileName: file.name, + onCreated: (createdImportId) => { + importId = createdImportId + useImportTrayStore.getState().startUpload({ + uploadId: createdImportId, + workspaceId, + title: file.name, + }) + }, + }, { onSettled: () => { importingRef.current = false - useImportTrayStore.getState().endUpload(pendingId) + if (importId) useImportTrayStore.getState().endUpload(importId) }, } ) // importFile.mutate and router are stable references // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspaceId, file.key, file.name]) + }, [workspaceId, file.id, file.key, file.name]) // Surface the cap as a warning toast with an import action, once per file. const notifiedKeyRef = useRef(null) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 006012191ac..33effd87945 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -288,6 +288,7 @@ const ReadOnlyTextPreview = memo(function ReadOnlyTextPreview({ mimeType={file.type} filename={file.name} workspaceId={workspaceId} + fileId={file.id} fileKey={file.key} readOnly /> diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 764349c42ad..4dbb0528483 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -42,6 +42,7 @@ interface PreviewPanelProps { mimeType: string | null filename: string workspaceId: string + fileId: string fileKey: string isStreaming?: boolean /** @@ -57,6 +58,7 @@ export const PreviewPanel = memo(function PreviewPanel({ mimeType, filename, workspaceId, + fileId, fileKey, isStreaming, readOnly, @@ -69,7 +71,7 @@ export const PreviewPanel = memo(function PreviewPanel({ ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx index 6316f141f7d..0aa57e987ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx @@ -637,6 +637,7 @@ export const TextEditor = memo(function TextEditor({ mimeType={file.type} filename={file.name} workspaceId={workspaceId} + fileId={file.id} fileKey={file.key} isStreaming={isStreaming} /> diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 2b063f54dc4..030e540c708 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -59,7 +59,7 @@ import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowI import { useFolders } from '@/hooks/queries/folders' import { useLogDetail } from '@/hooks/queries/logs' import { useScheduleById } from '@/hooks/queries/schedules' -import { downloadTableExport } from '@/hooks/queries/tables' +import { exportTable } from '@/hooks/queries/tables' import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -331,13 +331,7 @@ export function ResourceActions({ workspaceId, resource }: ResourceActionsProps) ) case 'table': - return ( - - ) + return case 'log': return case 'scheduledtask': @@ -495,10 +489,9 @@ const tableLogger = createLogger('EmbeddedTableActions') interface EmbeddedTableActionsProps { workspaceId: string tableId: string - tableName: string } -function EmbeddedTableActions({ workspaceId, tableId, tableName }: EmbeddedTableActionsProps) { +function EmbeddedTableActions({ workspaceId, tableId }: EmbeddedTableActionsProps) { const router = useRouter() const handleOpenTable = () => { @@ -507,7 +500,7 @@ function EmbeddedTableActions({ workspaceId, tableId, tableName }: EmbeddedTable const handleExport = async () => { try { - await downloadTableExport(tableId, tableName) + await exportTable(workspaceId, tableId) } catch (err) { tableLogger.error('Failed to export table:', err) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts index 6d7fa21927e..341c581af1b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts @@ -253,7 +253,7 @@ export function useTableEventStream({ // Keep the tray's export list fresh between its polls. void queryClient.invalidateQueries({ queryKey: tableKeys.exportJobs(workspaceId) }) if (status === 'ready' && jobId && consumeInitiatedExport(jobId)) { - void downloadExportResult(workspaceId, tableId, jobId) + void downloadExportResult(workspaceId, jobId) .then(() => toast.success('Export ready — downloading')) .catch((err) => { logger.error('Export download failed', { tableId, jobId, err }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 13191a7347a..e193f6e3da8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -21,7 +21,6 @@ import type { WorkflowGroup, } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' -import { TABLE_LIMITS } from '@/lib/table/constants' import { type BreadcrumbItem, type ColumnOption, @@ -35,13 +34,13 @@ import { ImportCsvDialog } from '@/app/workspace/[workspaceId]/tables/components import { ImportProgressMenu } from '@/app/workspace/[workspaceId]/tables/components/import-progress-menu' import { useLogByExecutionId } from '@/hooks/queries/logs' import { - downloadTableExport, + downloadExportResult, useCancelTableRuns, useCreateTableView, useDeleteTable, useDeleteTableRowsAsync, useDeleteTableView, - useExportTableAsync, + useExportTable, useRenameTable, useRunColumn, useTableViews, @@ -1026,16 +1025,11 @@ export function Table({ const handleExportCsv = useCallback(async () => { if (!tableData) return try { - // Big tables export as a background job (the file downloads when the job completes via the - // SSE stream); small ones keep the instant synchronous stream. While a delete job runs, - // rowCount is a doomed-estimate-adjusted number — not ground truth — so always take the - // async path (safe at any size; exports bypass the one-job-per-table gate). - const deleteRunning = tableData.jobType === 'delete' && tableData.jobStatus === 'running' - if (deleteRunning || tableData.rowCount > TABLE_LIMITS.EXPORT_ASYNC_THRESHOLD_ROWS) { - await exportTableAsync.mutateAsync({ format: 'csv' }) - toast.success('Export started — the download will begin when it finishes') + const exported = await exportTableAsync.mutateAsync({ format: 'csv' }) + if (exported.status === 'completed') { + await downloadExportResult(workspaceId, exported.id) } else { - await downloadTableExport(tableData.id, tableData.name) + toast.success('Export started — the download will begin when it finishes') } captureEvent(posthogRef.current, 'table_exported', { table_id: tableData.id, @@ -1256,7 +1250,7 @@ export function Table({ const deleteTableMutation = useDeleteTable(workspaceId) const deleteRowsAsyncMutation = useDeleteTableRowsAsync({ workspaceId, tableId }) - const exportTableAsync = useExportTableAsync({ workspaceId, tableId }) + const exportTableAsync = useExportTable({ workspaceId, tableId }) const handleDeleteTable = async () => { try { await deleteTableMutation.mutateAsync(tableId) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx index e306aeac52d..de5983f975b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx @@ -24,7 +24,6 @@ import { import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { truncate } from '@sim/utils/string' -import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES } from '@/lib/table/constants' import { buildAutoMapping, CSV_DELIMITER_SNIFF_BYTES, @@ -33,12 +32,7 @@ import { parseCsvBuffer, } from '@/lib/table/import' import type { TableDefinition } from '@/lib/table/types' -import { - type CsvImportMode, - cancelTableJob, - useImportCsvIntoTable, - useImportCsvIntoTableAsync, -} from '@/hooks/queries/tables' +import { type CsvImportMode, useImportCsvIntoTable } from '@/hooks/queries/tables' import { useImportTrayStore } from '@/stores/table/import-tray/store' const logger = createLogger('ImportCsvDialog') @@ -152,7 +146,6 @@ export function ImportCsvDialog({ const [createHeaders, setCreateHeaders] = useState>(new Set()) const [mode, setMode] = useState('append') const importMutation = useImportCsvIntoTable() - const importAsyncMutation = useImportCsvIntoTableAsync() function resetState() { setParsed(null) @@ -306,7 +299,6 @@ export function ImportCsvDialog({ const canSubmit = parsed !== null && !importMutation.isPending && - !importAsyncMutation.isPending && missingRequired.length === 0 && duplicateTargets.length === 0 && mappedCount + createCount > 0 @@ -320,76 +312,44 @@ export function ImportCsvDialog({ const createColumns = canCreateColumns && createHeaders.size > 0 ? [...createHeaders] : undefined - // Large files can't be POSTed through the server (request-body cap) — upload them - // straight to storage and import in the background instead. Seed the header tray and - // close the dialog immediately so the indicator is visible during the upload, then run - // the upload + kickoff in the background (don't block the dialog on it). - if (parsed.file.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES) { - useImportTrayStore.getState().startUpload({ - uploadId: table.id, - workspaceId, - title: parsed.file.name, - }) - onOpenChange(false) - toast.success(`Importing "${parsed.file.name}" into "${table.name}" in the background`) - importAsyncMutation.mutate( - { - workspaceId, - tableId: table.id, - file: parsed.file, - mode: effectiveMode, - mapping, - createColumns, - onProgress: (percent) => { - useImportTrayStore.getState().setUploadPercent(table.id, percent) - }, - }, - { - onSuccess: (data) => { - useImportTrayStore.getState().endUpload(table.id) - // The server row drives the tray once the list refetches. If canceled mid-upload, flag - // the id so it's not shown and cancel the worker server-side. - if (useImportTrayStore.getState().consumeCanceled(table.id) && data?.importId) { - useImportTrayStore.getState().cancel(table.id) - void cancelTableJob(workspaceId, table.id, data.importId).catch(() => {}) - } - }, - onError: () => { - // The hook's onError surfaces the toast; just clear the tray indicator here. - useImportTrayStore.getState().endUpload(table.id) - }, - } - ) - return - } - - try { - const result = await importMutation.mutateAsync({ + let importId: string | null = null + onOpenChange(false) + toast.success(`Importing "${parsed.file.name}" into "${table.name}" in the background`) + importMutation.mutate( + { workspaceId, tableId: table.id, file: parsed.file, mode: effectiveMode, mapping, createColumns, - }) - const data = result.data - if (effectiveMode === 'append') { - toast.success(`Imported ${data?.insertedCount ?? 0} rows into "${table.name}"`) - } else { - toast.success( - `Replaced rows in "${table.name}": deleted ${data?.deletedCount ?? 0}, inserted ${data?.insertedCount ?? 0}` - ) + onCreated: (createdImportId) => { + importId = createdImportId + useImportTrayStore.getState().startUpload({ + uploadId: createdImportId, + tableId: table.id, + workspaceId, + title: parsed.file.name, + }) + }, + onProgress: (percent) => { + if (importId) useImportTrayStore.getState().setUploadPercent(importId, percent) + }, + }, + { + onSuccess: () => { + if (importId) { + useImportTrayStore.getState().endUpload(importId) + useImportTrayStore.getState().consumeCanceled(importId) + } + onImported?.({}) + }, + onError: (error) => { + if (importId) useImportTrayStore.getState().endUpload(importId) + setSubmitError(summarizeImportError(error.message)) + }, } - onImported?.({ - insertedCount: data?.insertedCount, - deletedCount: data?.deletedCount, - }) - onOpenChange(false) - } catch (err) { - const message = getErrorMessage(err, 'Failed to import CSV') - setSubmitError(summarizeImportError(message)) - logger.error('CSV import into existing table failed', err) - } + ) } const hasWarning = missingRequired.length > 0 || duplicateTargets.length > 0 diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx index 46deda65b1c..9cb0c793d6a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx @@ -10,7 +10,7 @@ import { } from '@sim/emcn' import { CircleAlert, CircleCheck, Loader } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' -import { cancelTableJob, downloadExportResult } from '@/hooks/queries/tables' +import { cancelTableImport, downloadExportResult } from '@/hooks/queries/tables' import { useImportTrayStore } from '@/stores/table/import-tray/store' import { getImportStage } from './import-stage' import { type ImportRow, useWorkspaceImports } from './use-workspace-imports' @@ -49,13 +49,13 @@ export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuP // Worker already running — cancel it server-side now. (An upload still mid-flight is canceled by // the kickoff handler once its jobId is known; see the `consumeCanceled` branches.) if (row.jobId) { - void cancelTableJob(row.workspaceId, row.tableId, row.jobId).catch(() => {}) + void cancelTableImport(row.workspaceId, row.jobId).catch(() => {}) } } const download = (row: ImportRow) => { if (!row.jobId) return - void downloadExportResult(row.workspaceId, row.tableId, row.jobId).catch((err) => { + void downloadExportResult(row.workspaceId, row.jobId).catch((err) => { logger.error('Export download failed', { jobId: row.jobId, err }) toast.error('Download failed — the export may have expired') }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts index d72ed8b6d20..934757a198e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-workspace-imports.ts @@ -132,17 +132,18 @@ export function useWorkspaceImports( for (const upload of uploads) { if (upload.workspaceId !== workspaceId) continue - if (scopeTableId && upload.uploadId !== scopeTableId) continue + if (scopeTableId && upload.tableId !== scopeTableId) continue if (canceledIds[upload.uploadId] || seen.has(upload.uploadId)) continue rows.push({ id: upload.uploadId, - tableId: upload.uploadId, + tableId: upload.tableId ?? upload.uploadId, workspaceId: upload.workspaceId, title: upload.title, phase: 'importing', jobType: 'import', rowsProcessed: 0, percent: upload.percent, + jobId: upload.uploadId, }) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 81a44336bbd..d5443a320d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -6,11 +6,10 @@ import { ChipCombobox, ChipConfirmModal, Plus, toast, Upload } from '@sim/emcn' import { Columns3, FolderPlus, Rows3, Table as TableIcon } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import type { TableDefinition } from '@/lib/table' -import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES, generateUniqueTableName } from '@/lib/table/constants' +import { generateUniqueTableName } from '@/lib/table/constants' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { DropdownOption, @@ -58,15 +57,13 @@ import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sideb import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hooks/queries/folders' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { - cancelTableJob, - downloadTableExport, + exportTable, useCreateTable, useDeleteTable, - useImportCsvAsync, + useImportCsv, useMoveTable, useRenameTable, useTablesList, - useUploadCsvToTable, } from '@/hooks/queries/tables' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' @@ -150,8 +147,7 @@ export function Tables() { const renameTable = useRenameTable(workspaceId) const createTable = useCreateTable(workspaceId) const moveTable = useMoveTable(workspaceId) - const uploadCsv = useUploadCsvToTable() - const importCsvAsync = useImportCsvAsync() + const importCsv = useImportCsv() const createFolder = useCreateFolder() const updateFolder = useUpdateFolder() const deleteFolder = useDeleteFolderMutation() @@ -869,112 +865,64 @@ export function Tables() { } } - const handleCsvChange = useCallback( - async (e: React.ChangeEvent) => { - const list = e.target.files - if (!list || list.length === 0 || !workspaceId) return + const handleCsvChange = async (e: React.ChangeEvent) => { + const list = e.target.files + if (!list || list.length === 0 || !workspaceId) return - const csvFiles = Array.from(list).filter((f) => { - const ext = f.name.split('.').pop()?.toLowerCase() - return ext === 'csv' || ext === 'tsv' - }) - - if (csvFiles.length === 0) { - toast.error('No CSV or TSV files selected') - if (csvInputRef.current) csvInputRef.current.value = '' - return - } - - // Large files can't be POSTed through the server (request-body cap) — upload them - // straight to storage and import in the background. These are tracked by the import - // tray, never the header upload button, so don't touch uploading/uploadProgress here. - const asyncFiles = csvFiles.filter((f) => f.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES) - const syncFiles = csvFiles.filter((f) => f.size < CSV_ASYNC_IMPORT_THRESHOLD_BYTES) - - try { - for (const file of asyncFiles) { - // Show the indicator immediately under a temporary id (the real table id doesn't - // exist until kickoff returns), then let the tray track it. Don't redirect — the - // table is still empty/importing, so stay on the list. - const pendingId = `pending_${generateId()}` - useImportTrayStore - .getState() - .startUpload({ uploadId: pendingId, workspaceId, title: file.name }) - toast.success(`Importing "${file.name}" in the background`) - try { - const result = await importCsvAsync.mutateAsync({ - workspaceId, - folderId: currentFolderId, - file, - onProgress: (percent) => { - useImportTrayStore.getState().setUploadPercent(pendingId, percent) - }, - }) - useImportTrayStore.getState().endUpload(pendingId) - // The server row drives the tray once the list refetches (mutation invalidates it). - // If canceled mid-upload, flag the real id so it's not shown and cancel server-side. - if ( - result?.tableId && - result.importId && - useImportTrayStore.getState().consumeCanceled(pendingId) - ) { - useImportTrayStore.getState().cancel(result.tableId) - void cancelTableJob(workspaceId, result.tableId, result.importId).catch(() => {}) - } - } catch { - // The hook's onError surfaces the toast; just clear the tray indicator here. - useImportTrayStore.getState().endUpload(pendingId) - } - } - - if (syncFiles.length === 0) return + const csvFiles = Array.from(list).filter((f) => { + const ext = f.name.split('.').pop()?.toLowerCase() + return ext === 'csv' || ext === 'tsv' + }) - setUploadProgress({ completed: 0, total: syncFiles.length }) - const failed: string[] = [] + if (csvFiles.length === 0) { + toast.error('No CSV or TSV files selected') + if (csvInputRef.current) csvInputRef.current.value = '' + return + } - for (let i = 0; i < syncFiles.length; i++) { - const file = syncFiles[i] - try { - const result = await uploadCsv.mutateAsync({ - workspaceId, - folderId: currentFolderId, - file, - }) - - if (syncFiles.length === 1 && asyncFiles.length === 0) { - const tableId = result?.data?.table?.id - if (tableId) { - router.push(`/workspace/${workspaceId}/tables/${tableId}`) - } - } - } catch (err) { - failed.push(file.name) - logger.error('Error uploading CSV:', err) - } finally { - setUploadProgress({ completed: i + 1, total: syncFiles.length }) + try { + setUploadProgress({ completed: 0, total: csvFiles.length }) + for (let index = 0; index < csvFiles.length; index++) { + const file = csvFiles[index] + let importId: string | null = null + toast.success(`Importing "${file.name}" in the background`) + try { + await importCsv.mutateAsync({ + workspaceId, + folderId: currentFolderId, + file, + onCreated: (createdImportId) => { + importId = createdImportId + useImportTrayStore.getState().startUpload({ + uploadId: createdImportId, + workspaceId, + title: file.name, + }) + }, + onProgress: (percent) => { + if (importId) useImportTrayStore.getState().setUploadPercent(importId, percent) + }, + }) + if (importId) { + useImportTrayStore.getState().endUpload(importId) + useImportTrayStore.getState().consumeCanceled(importId) } - } - - if (failed.length > 0) { - toast.error( - failed.length === 1 - ? `Failed to import ${failed[0]}` - : `Failed to import ${failed.length} file${failed.length > 1 ? 's' : ''}: ${failed.join(', ')}` - ) - } - } catch (err) { - logger.error('Error uploading CSV:', err) - toast.error('Failed to import CSV') - } finally { - setUploadProgress({ completed: 0, total: 0 }) - if (csvInputRef.current) { - csvInputRef.current.value = '' + } catch { + if (importId) useImportTrayStore.getState().endUpload(importId) + } finally { + setUploadProgress({ completed: index + 1, total: csvFiles.length }) } } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5 - [workspaceId, currentFolderId, router] - ) + } catch (err) { + logger.error('Error uploading CSV:', err) + toast.error('Failed to import CSV') + } finally { + setUploadProgress({ completed: 0, total: 0 }) + if (csvInputRef.current) { + csvInputRef.current.value = '' + } + } + } const handleListUploadCsv = useCallback(() => { csvInputRef.current?.click() @@ -1132,7 +1080,8 @@ export function Tables() { onExportCsv={async () => { if (!activeTable) return try { - await downloadTableExport(activeTable.id, activeTable.name) + const status = await exportTable(workspaceId, activeTable.id) + if (status === 'processing') toast.success('Export started') } catch (err) { logger.error('Failed to export table:', err) toast.error('Failed to export table') diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index 42bf64f7237..b9ffd3c932b 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -113,7 +113,9 @@ async function selectExpiredWorkspaceFiles( key: workspaceFiles.key, workspaceId: workspaceFiles.workspaceId, context: workspaceFiles.context, - size: workspaceFiles.size, + size: sql`coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})`.mapWith( + Number + ), }) .from(workspaceFiles) .where( @@ -325,7 +327,12 @@ async function deleteExpiredBillableWorkspaceFileRows( lt(workspaceFiles.deletedAt, retentionDate) ) ) - .returning({ id: workspaceFiles.id, size: workspaceFiles.size }) + .returning({ + id: workspaceFiles.id, + size: sql`coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})`.mapWith( + Number + ), + }) if (deletedRows.some(({ size }) => size < 0)) { throw new Error('Cannot delete workspace files with negative stored-byte metadata') } diff --git a/apps/sim/ee/workspace-forking/lib/copy/storage-quota.ts b/apps/sim/ee/workspace-forking/lib/copy/storage-quota.ts index 760cd187c76..f936afaa055 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/storage-quota.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/storage-quota.ts @@ -48,7 +48,7 @@ export async function sumForkCopyBytes( fileSelectors.length === 0 ? sql`0` : sql`( - SELECT coalesce(sum(${workspaceFiles.size}), 0) + SELECT coalesce(sum(coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})), 0) FROM ${workspaceFiles} WHERE ${and( fileSelectors.length === 1 ? fileSelectors[0] : or(...fileSelectors), diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index a1f13eba635..f519258ad33 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -17,13 +17,20 @@ import { } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { - ApiClientError, extractValidationIssues, isApiClientError, isValidationError, } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import type { ContractJsonResponse } from '@/lib/api/contracts' +import { + cancelTableImportResourceContract, + completeTableImportResourceContract, + createTableExportResourceContract, + createTableImportPartUrlsContract, + createTableImportResourceContract, + downloadTableExportResourceContract, +} from '@/lib/api/contracts/table-transfers' import { type ActiveDispatch, type AddWorkflowGroupBodyInput, @@ -35,7 +42,6 @@ import { batchUpdateTableRowsContract, type CreateTableBodyInput, type CreateTableColumnBodyInput, - cancelTableJobContract, cancelTableRunsContract, createTableContract, createTableRowContract, @@ -48,14 +54,10 @@ import { deleteTableRowsContract, deleteTableViewContract, deleteWorkflowGroupContract, - exportDownloadContract, - exportTableAsyncContract, findTableRowsContract, getEnrichmentDetailContract, getTableContract, type InsertTableRowBodyInput, - importIntoTableAsyncContract, - importTableAsyncContract, listActiveDispatchesContract, listTableJobsContract, listTableRowsContract, @@ -84,6 +86,7 @@ import { updateTableViewContract, updateWorkflowGroupContract, } from '@/lib/api/contracts/tables' +import type { V2TableImportSource, V2TableImportTarget } from '@/lib/api/contracts/v2/tables' import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons' import type { CsvHeaderMapping, @@ -107,7 +110,8 @@ import { isExecInFlight, optimisticallyScheduleNewlyEligibleGroups, } from '@/lib/table/deps' -import { runUploadStrategy } from '@/lib/uploads/client/direct-upload' +import { sanitizeName } from '@/lib/table/import' +import { uploadMultipartSession } from '@/lib/uploads/client/multipart-session' import { useTimezone } from '@/hooks/queries/general-settings' import { TABLE_LIST_STALE_TIME, @@ -1722,105 +1726,112 @@ export function useRestoreTable() { }) } -interface UploadCsvParams { - workspaceId: string - /** Folder to create the imported table in; omitted imports to the workspace root. */ - folderId?: string | null - file: File -} - -/** - * Upload a CSV file to create a new table with inferred schema. - */ -export function useUploadCsvToTable() { - const queryClient = useQueryClient() - const timezone = useTimezone() - - return useMutation({ - mutationFn: async ({ workspaceId, folderId, file }: UploadCsvParams) => { - // Text fields must precede the file part: the server parses the body as a - // stream and resolves as soon as it reaches the file, so any field appended - // after it is never seen. - const formData = new FormData() - formData.append('workspaceId', workspaceId) - if (folderId) formData.append('folderId', folderId) - formData.append('timezone', timezone) - formData.append('file', file) - - // boundary-raw-fetch: multipart/form-data CSV upload, requestJson only supports JSON bodies - const response = await fetch('/api/table/import-csv', { - method: 'POST', - body: formData, - }) - - if (!response.ok) { - const data = await response.json().catch(() => ({})) - // Carry the status: a plain Error drops it, and the 423 self-heal below - // keys off `error.status`. - throw new ApiClientError({ - status: response.status, - body: data, - message: data.error || 'CSV import failed', - }) - } - - return response.json() - }, - onError: (error) => { - logger.error('Failed to upload CSV:', error) - toast.error(error.message, { duration: 5000 }) - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) - }, - }) -} - interface ImportCsvAsyncParams { workspaceId: string /** Folder to create the imported table in; omitted imports to the workspace root. */ folderId?: string | null file: File + onCreated?: (importId: string) => void onProgress?: (percent: number) => void } -/** - * Uploads a CSV/TSV straight to workspace storage (bypassing the server's request-body - * cap) and returns its storage key. Shared by the async-import kickoff hooks. - */ -async function uploadCsvToWorkspaceStorage( - file: File, - workspaceId: string, +async function createAndUploadTableImport(params: { + workspaceId: string + source: V2TableImportSource + target: V2TableImportTarget + file?: File + mapping?: CsvHeaderMapping + createColumns?: string[] + timezone: string + onCreated?: (importId: string) => void onProgress?: (percent: number) => void -): Promise { - const upload = await runUploadStrategy({ - file, - workspaceId, - context: 'workspace', - presignedEndpoint: `/api/workspaces/${workspaceId}/files/presigned`, - onProgress: onProgress ? (event) => onProgress(event.percent) : undefined, +}) { + const created = await requestJson(createTableImportResourceContract, { + body: { + workspaceId: params.workspaceId, + source: params.source, + target: params.target, + mapping: params.mapping, + createColumns: params.createColumns, + timezone: params.timezone, + }, + }) + params.onCreated?.(created.data.id) + if (params.source.type === 'workspace_file') return created.data + if (!params.file || !created.data.upload) { + throw new Error('Upload-backed table import returned no upload session') + } + const upload = created.data.upload + return uploadMultipartSession({ + file: params.file, + partSize: upload.partSize, + partCount: upload.partCount, + onProgress: params.onProgress ? (event) => params.onProgress?.(event.percent) : undefined, + getPartUrls: async (partNumbers) => { + const response = await requestJson(createTableImportPartUrlsContract, { + params: { importId: created.data.id }, + query: { workspaceId: params.workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + body: { partNumbers }, + }) + return response.data.parts + }, + complete: async (parts) => { + const response = await requestJson(completeTableImportResourceContract, { + params: { importId: created.data.id }, + query: { workspaceId: params.workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + body: { parts }, + }) + return response.data + }, + abort: async () => { + await requestJson(cancelTableImportResourceContract, { + params: { importId: created.data.id }, + query: { workspaceId: params.workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + }) + }, }) - return upload.key } -/** - * Uploads a large CSV/TSV straight to storage, then kicks off a background import into a - * new table. Resolves with `{ tableId, importId }` immediately — load progress and the - * terminal state arrive over the table-events SSE stream (see `useTableEventStream`). - */ -export function useImportCsvAsync() { +/** Uploads a CSV/TSV through a signed multipart session and creates a table from it. */ +export function useImportCsv() { const queryClient = useQueryClient() const timezone = useTimezone() return useMutation({ - mutationFn: async ({ workspaceId, folderId, file, onProgress }: ImportCsvAsyncParams) => { - const fileKey = await uploadCsvToWorkspaceStorage(file, workspaceId, onProgress) - const response = await requestJson(importTableAsyncContract, { - body: { workspaceId, folderId, fileKey, fileName: file.name, timezone }, + mutationFn: async ({ + workspaceId, + folderId, + file, + onCreated, + onProgress, + }: ImportCsvAsyncParams) => { + const imported = await createAndUploadTableImport({ + workspaceId, + source: { + type: 'upload', + name: file.name, + contentType: file.type || 'text/csv', + size: file.size, + }, + target: { + type: 'new', + name: sanitizeName(file.name.replace(/\.[^.]+$/, ''), 'imported_table').slice( + 0, + TABLE_LIMITS.MAX_TABLE_NAME_LENGTH + ), + folderId: folderId ?? undefined, + }, + file, + timezone, + onCreated, + onProgress, }) - return response.data + return { tableId: imported.tableId, importId: imported.id } }, onError: (error) => { - logger.error('Failed to start async CSV import:', error) + logger.error('Failed to start CSV import:', error) toast.error(error.message, { duration: 5000 }) }, onSettled: () => { @@ -1831,8 +1842,9 @@ export function useImportCsvAsync() { interface ImportFileAsTableParams { workspaceId: string - fileKey: string + fileId: string fileName: string + onCreated?: (importId: string) => void } /** @@ -1846,11 +1858,21 @@ export function useImportFileAsTable() { const queryClient = useQueryClient() const timezone = useTimezone() return useMutation({ - mutationFn: async ({ workspaceId, fileKey, fileName }: ImportFileAsTableParams) => { - const response = await requestJson(importTableAsyncContract, { - body: { workspaceId, fileKey, fileName, deleteSourceFile: false, timezone }, + mutationFn: async ({ workspaceId, fileId, fileName, onCreated }: ImportFileAsTableParams) => { + const imported = await createAndUploadTableImport({ + workspaceId, + source: { type: 'workspace_file', fileId }, + target: { + type: 'new', + name: sanitizeName(fileName.replace(/\.[^.]+$/, ''), 'imported_table').slice( + 0, + TABLE_LIMITS.MAX_TABLE_NAME_LENGTH + ), + }, + timezone, + onCreated, }) - return response.data + return { tableId: imported.tableId, importId: imported.id } }, onError: (error) => { logger.error('Failed to start import from file:', error) @@ -1871,15 +1893,12 @@ interface ImportCsvIntoTableAsyncParams { mode: CsvImportMode mapping?: CsvHeaderMapping createColumns?: string[] + onCreated?: (importId: string) => void onProgress?: (percent: number) => void } -/** - * Async append/replace import into an existing table for large files: uploads straight to - * storage (bypassing the server's request-body cap), then kicks off the background worker. - * Resolves immediately; progress + completion arrive over the table-events SSE stream. - */ -export function useImportCsvIntoTableAsync() { +/** Imports a CSV/TSV into an existing table through the same durable resource for every size. */ +export function useImportCsvIntoTable() { const queryClient = useQueryClient() const timezone = useTimezone() return useMutation({ @@ -1890,98 +1909,30 @@ export function useImportCsvIntoTableAsync() { mode, mapping, createColumns, + onCreated, onProgress, }: ImportCsvIntoTableAsyncParams) => { - const fileKey = await uploadCsvToWorkspaceStorage(file, workspaceId, onProgress) - const response = await requestJson(importIntoTableAsyncContract, { - params: { tableId }, - body: { workspaceId, fileKey, fileName: file.name, mode, mapping, createColumns, timezone }, - }) - return response.data - }, - onError: (error, variables) => { - if (handleTableLockRejection(error, queryClient, variables.tableId)) return - logger.error('Failed to start async CSV import:', error) - toast.error(error.message, { duration: 5000 }) - }, - onSettled: (_data, _error, variables) => { - invalidateRowCount(queryClient, variables.tableId) - }, - }) -} - -interface ImportCsvIntoTableParams { - workspaceId: string - tableId: string - file: File - mode: CsvImportMode - mapping?: CsvHeaderMapping - /** CSV headers to auto-create as new columns on the target table. */ - createColumns?: string[] -} - -interface ImportCsvIntoTableResponse { - success: boolean - data?: { - tableId: string - mode: CsvImportMode - insertedCount?: number - deletedCount?: number - mappedColumns?: string[] - skippedHeaders?: string[] - unmappedColumns?: string[] - sourceFile?: string - } -} - -/** - * Upload a CSV file to an existing table in append or replace mode. Supports - * an optional explicit header-to-column mapping; when omitted the server - * auto-maps headers by sanitized name. - */ -export function useImportCsvIntoTable() { - const queryClient = useQueryClient() - const timezone = useTimezone() - - return useMutation({ - mutationFn: async ({ - workspaceId, - tableId, - file, - mode, - mapping, - createColumns, - }: ImportCsvIntoTableParams): Promise => { - // Text fields must precede the file part: the server parses the body as a - // stream and needs these fields before it reaches the (large) file. - const formData = new FormData() - formData.append('workspaceId', workspaceId) - formData.append('mode', mode) - formData.append('timezone', timezone) - if (mapping) { - formData.append('mapping', JSON.stringify(mapping)) - } - if (createColumns && createColumns.length > 0) { - formData.append('createColumns', JSON.stringify(createColumns)) - } - formData.append('file', file) - - // boundary-raw-fetch: multipart/form-data CSV upload, requestJson only supports JSON bodies - const response = await fetch(`/api/table/${tableId}/import`, { - method: 'POST', - body: formData, + const imported = await createAndUploadTableImport({ + workspaceId, + source: { + type: 'upload', + name: file.name, + contentType: file.type || 'text/csv', + size: file.size, + }, + target: { type: 'existing', tableId, mode }, + file, + mapping, + createColumns, + timezone, + onCreated, + onProgress, }) - - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || 'CSV import failed') - } - - return response.json() + return { tableId: imported.tableId, importId: imported.id } }, onError: (error, variables) => { if (handleTableLockRejection(error, queryClient, variables.tableId)) return - logger.error('Failed to import CSV into table:', error) + logger.error('Failed to start CSV import:', error) toast.error(error.message, { duration: 5000 }) }, onSettled: (_data, _error, variables) => { @@ -1990,19 +1941,12 @@ export function useImportCsvIntoTable() { }) } -/** - * Cancels an in-flight async table job (import or delete). Plain function (not a hook) because the - * job tray lists multiple tables and cancels a chosen one by id rather than binding to a single - * table. - */ -export async function cancelTableJob( - workspaceId: string, - tableId: string, - jobId: string -): Promise { - await requestJson(cancelTableJobContract, { - params: { tableId }, - body: { workspaceId, jobId }, +/** Cancels an in-flight table import resource. */ +export async function cancelTableImport(workspaceId: string, importId: string): Promise { + await requestJson(cancelTableImportResourceContract, { + params: { importId }, + query: { workspaceId }, + headers: {}, }) } @@ -2046,23 +1990,17 @@ export function consumeInitiatedExport(jobId: string): boolean { } /** - * Kicks off a background export job for large tables (small ones stream synchronously via - * {@link downloadTableExport}). The SSE job stream auto-downloads the file when the job is ready. + * Creates an export resource. The server completes small exports before responding and processes + * large exports in the background; the client follows the same path for both. */ -export function useExportTableAsync({ workspaceId, tableId }: RowMutationContext) { +export function useExportTable({ workspaceId, tableId }: RowMutationContext) { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ format }: { format: 'csv' | 'json' }) => { - const response = await requestJson(exportTableAsyncContract, { - params: { tableId }, - body: { workspaceId, format }, - }) - initiatedExportJobIds.add(response.data.jobId) - return response.data + return createTableExport(workspaceId, tableId, format) }, - onSuccess: () => { - // Surface the new running job in the tray immediately — its poll only - // self-sustains once a running job is already in the cache. + onSettled: () => { + // Reconcile failed creation and seed polling after a successful background export. void queryClient.invalidateQueries({ queryKey: tableKeys.exportJobs(workspaceId) }) }, onError: (error) => { @@ -2073,15 +2011,20 @@ export function useExportTableAsync({ workspaceId, tableId }: RowMutationContext }) } -/** Resolves a ready export job to its presigned URL and triggers the browser download. */ -export async function downloadExportResult( - workspaceId: string, - tableId: string, - jobId: string -): Promise { - const response = await requestJson(exportDownloadContract, { +async function createTableExport(workspaceId: string, tableId: string, format: 'csv' | 'json') { + const response = await requestJson(createTableExportResourceContract, { params: { tableId }, - query: { workspaceId, jobId }, + body: { workspaceId, format }, + }) + if (response.data.status !== 'completed') initiatedExportJobIds.add(response.data.id) + return response.data +} + +/** Resolves a ready export job to its presigned URL and triggers the browser download. */ +export async function downloadExportResult(workspaceId: string, exportId: string): Promise { + const response = await requestJson(downloadTableExportResourceContract, { + params: { exportId }, + query: { workspaceId }, }) const a = document.createElement('a') a.href = response.data.url @@ -2091,32 +2034,18 @@ export async function downloadExportResult( document.body.removeChild(a) } -/** - * Downloads the full contents of a table to the user's device by streaming - * `/api/table/[tableId]/export`. Defaults to CSV; pass `'json'` for JSON. - */ -export async function downloadTableExport( +/** Creates one export resource and downloads it immediately when the server completed it inline. */ +export async function exportTable( + workspaceId: string, tableId: string, - fileName: string, format: 'csv' | 'json' = 'csv' -): Promise { - const url = `/api/table/${tableId}/export?format=${format}&t=${Date.now()}` - // boundary-raw-fetch: streaming download to a Blob, requestJson cannot consume non-JSON streams - const response = await fetch(url, { cache: 'no-store' }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || `Failed to export table: ${response.statusText}`) +): Promise<'completed' | 'processing'> { + const exported = await createTableExport(workspaceId, tableId, format) + if (exported.status === 'completed') { + await downloadExportResult(workspaceId, exported.id) + return 'completed' } - const blob = await response.blob() - const objectUrl = URL.createObjectURL(blob) - const safeName = fileName.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') || 'table' - const a = document.createElement('a') - a.href = objectUrl - a.download = `${safeName}.${format}` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(objectUrl) + return 'processing' } export function useDeleteColumn({ workspaceId, tableId }: RowMutationContext) { diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index ad49ba3e283..922236c4918 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -1,26 +1,21 @@ import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { sleep } from '@sim/utils/helpers' import { backoffWithJitter } from '@sim/utils/retry' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { ApiClientError, isApiClientError } from '@/lib/api/client/errors' +import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { fileStorageStatusContract } from '@/lib/api/contracts/storage-transfer' import { getUsageLimitsContract } from '@/lib/api/contracts/usage-limits' import { deleteWorkspaceFileContract, listWorkspaceFilesContract, - registerWorkspaceFileContract, renameWorkspaceFileContract, restoreWorkspaceFileContract, updateWorkspaceFileContentContract, } from '@/lib/api/contracts/workspace-files' -import { - DirectUploadError, - runUploadStrategy, - type UploadProgressEvent, -} from '@/lib/uploads/client/direct-upload' +import type { UploadProgressEvent } from '@/lib/uploads/client/direct-upload' +import { uploadWorkspaceFileSession } from '@/lib/uploads/client/session-upload' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import type { UserFile } from '@/executor/types' import { useFileContentSource } from '@/hooks/use-file-content-source' @@ -335,41 +330,6 @@ interface UploadFileResponse { file: UserFile } -async function uploadViaApiFallback( - workspaceId: string, - file: File, - folderId?: string | null, - signal?: AbortSignal -): Promise { - const formData = new FormData() - formData.append('file', file) - if (folderId) formData.append('folderId', folderId) - - // boundary-raw-fetch: multipart/form-data fallback upload, requestJson only supports JSON bodies - const response = await fetch(`/api/workspaces/${workspaceId}/files`, { - method: 'POST', - body: formData, - signal, - }) - - return parseUploadResponse(response, 'Upload failed') -} - -async function parseUploadResponse( - response: Response, - fallbackMessage: string -): Promise { - let data: { success?: boolean; error?: string; file?: UserFile } | null = null - try { - data = await response.json() - } catch {} - - if (!response.ok || !data?.success) { - throw new Error(data?.error || `${fallbackMessage} (${response.status})`) - } - return data as UploadFileResponse -} - async function uploadWorkspaceFile( workspaceId: string, file: File, @@ -377,69 +337,25 @@ async function uploadWorkspaceFile( onProgress?: (event: UploadProgressEvent) => void, signal?: AbortSignal ): Promise { - let result - try { - result = await runUploadStrategy({ - file, - presignedEndpoint: `/api/workspaces/${workspaceId}/files/presigned`, - presignedBody: { folderId }, - workspaceId, + const uploaded = await uploadWorkspaceFileSession({ + workspaceId, + folderId, + file, + onProgress, + signal, + }) + return { + success: true, + file: { + id: uploaded.id, + name: uploaded.name, + size: uploaded.size, + type: uploaded.type, + url: `/api/files/serve/${encodeURIComponent(uploaded.key)}?context=workspace`, + key: uploaded.key, context: 'workspace', - onProgress, - signal, - }) - } catch (error) { - if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') { - return uploadViaApiFallback(workspaceId, file, folderId, signal) - } - throw error - } - - const data = await registerWithRetry(workspaceId, result, folderId, signal) - - if (!data.success || !data.file) { - throw new Error(data.error || 'Failed to register file') - } - return { success: true, file: data.file } -} - -const REGISTER_MAX_ATTEMPTS = 3 -const REGISTER_RETRY_DELAY_MS = 500 - -/** - * Register the uploaded object with bounded retries. The server-side handler - * is idempotent (existing-record short-circuit), so safely retrying handles - * dropped responses that would otherwise orphan the object in storage. - */ -async function registerWithRetry( - workspaceId: string, - result: { key: string; name: string; contentType: string }, - folderId?: string | null, - signal?: AbortSignal -) { - let lastError: unknown - for (let attempt = 1; attempt <= REGISTER_MAX_ATTEMPTS; attempt++) { - try { - return await requestJson(registerWorkspaceFileContract, { - params: { id: workspaceId }, - body: { - key: result.key, - name: result.name, - contentType: result.contentType, - folderId, - }, - signal, - }) - } catch (error) { - lastError = error - if (signal?.aborted) throw error - const isTransient = - !(error instanceof ApiClientError) || (error.status >= 500 && error.status < 600) - if (!isTransient || attempt === REGISTER_MAX_ATTEMPTS) throw error - await sleep(REGISTER_RETRY_DELAY_MS * attempt) - } + }, } - throw lastError } export function useUploadWorkspaceFile() { diff --git a/apps/sim/lib/api/contracts/table-transfers.ts b/apps/sim/lib/api/contracts/table-transfers.ts new file mode 100644 index 00000000000..69a5c4f4ec6 --- /dev/null +++ b/apps/sim/lib/api/contracts/table-transfers.ts @@ -0,0 +1,95 @@ +import { exportTableAsyncBodySchema, tableIdParamsSchema } from '@/lib/api/contracts/tables' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CreateTableImportBodySchema, + v2TableExportDownloadDataSchema, + v2TableExportParamsSchema, + v2TableExportSchema, + v2TableImportParamsSchema, + v2TableImportSchema, + v2TableTransferWorkspaceQuerySchema, +} from '@/lib/api/contracts/v2/tables' +import { + v2CompleteUploadBodySchema, + v2OptionalUploadTokenHeadersSchema, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadTokenHeadersSchema, +} from '@/lib/api/contracts/v2/uploads' + +export const createTableImportResourceContract = defineRouteContract({ + method: 'POST', + path: '/api/table/imports', + body: v2CreateTableImportBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const getTableImportResourceContract = defineRouteContract({ + method: 'GET', + path: '/api/table/imports/[importId]', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const cancelTableImportResourceContract = defineRouteContract({ + method: 'DELETE', + path: '/api/table/imports/[importId]', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + headers: v2OptionalUploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const createTableImportPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/table/imports/[importId]/parts', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) + +export const completeTableImportResourceContract = defineRouteContract({ + method: 'POST', + path: '/api/table/imports/[importId]/complete', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2CompleteUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const createTableExportResourceContract = defineRouteContract({ + method: 'POST', + path: '/api/table/[tableId]/exports', + params: tableIdParamsSchema, + body: exportTableAsyncBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, +}) + +export const getTableExportResourceContract = defineRouteContract({ + method: 'GET', + path: '/api/table/exports/[exportId]', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, +}) + +export const cancelTableExportResourceContract = defineRouteContract({ + method: 'DELETE', + path: '/api/table/exports/[exportId]', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, +}) + +export const downloadTableExportResourceContract = defineRouteContract({ + method: 'GET', + path: '/api/table/exports/[exportId]/download', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportDownloadDataSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 89e68b9715f..666b9d55552 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -127,7 +127,7 @@ function refineColumnOptions( * Identifier for tables/columns: starts with letter or underscore, contains * only alphanumerics + underscores, capped at `MAX_TABLE_NAME_LENGTH`. */ -const tableNameSchema = z +export const tableNameSchema = z .string() .min(1, 'Name is required') .max( @@ -1402,7 +1402,7 @@ const workflowGroupInputMappingSchema = z.object({ columnName: z.string().min(1, 'columnName cannot be empty'), }) -const workflowGroupOutputColumnSchema = z.object({ +export const workflowGroupOutputColumnSchema = z.object({ name: z.string().min(1), type: columnTypeSchema, required: z.boolean().optional(), @@ -1534,44 +1534,60 @@ export const deleteWorkflowGroupContract = defineRouteContract({ * cells on rows matching it (filtered "select all" Stop) * - `row` — every running/pending cell for a specific row (`rowId` required) */ -export const cancelTableRunsBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - scope: z.enum(['all', 'row']), - rowId: z.string().min(1).optional(), - filter: z.union([predicateSchema, domainObjectSchema()]).optional(), - /** Scope-`all` only: rows deselected from the selection — their cells keep running. */ - excludeRowIds: z - .array(z.string().min(1)) - .max( - TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, - `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` - ) - .optional(), - }) - .superRefine((value, ctx) => { - if (value.scope === 'row' && !value.rowId) { - ctx.addIssue({ - code: 'custom', - path: ['rowId'], - message: 'rowId is required when scope is "row"', - }) - } - if (value.scope === 'row' && value.filter) { - ctx.addIssue({ - code: 'custom', - path: ['filter'], - message: 'filter only applies to scope "all"', - }) - } - if (value.scope === 'row' && value.excludeRowIds) { - ctx.addIssue({ - code: 'custom', - path: ['excludeRowIds'], - message: 'excludeRowIds only applies to scope "all"', - }) - } - }) +/** + * Plain-object base for the cancel-runs body. Kept un-refined so callers (e.g. + * the v2 public contract, which narrows `filter` to the predicate grammar) can + * `.extend()` before applying {@link refineCancelTableRunsScope} — Zod forbids + * `.extend()` on a refined schema. + */ +export const cancelTableRunsBodyBaseSchema = z.object({ + workspaceId: workspaceIdSchema, + scope: z.enum(['all', 'row']), + rowId: z.string().min(1).optional(), + filter: z.union([predicateSchema, domainObjectSchema()]).optional(), + /** Scope-`all` only: rows deselected from the selection — their cells keep running. */ + excludeRowIds: z + .array(z.string().min(1)) + .max( + TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, + `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` + ) + .optional(), +}) + +/** + * `row` scope names exactly one row, so it requires `rowId` and rejects the + * two select-all-only narrowing fields rather than ignoring them — a caller + * that sends both has misunderstood the scope. + */ +export function refineCancelTableRunsScope(value: { + scope: 'all' | 'row' + rowId?: string + filter?: unknown + excludeRowIds?: string[] +}): { path: string[]; message: string }[] { + if (value.scope !== 'row') return [] + const issues: { path: string[]; message: string }[] = [] + if (!value.rowId) { + issues.push({ path: ['rowId'], message: 'rowId is required when scope is "row"' }) + } + if (value.filter) { + issues.push({ path: ['filter'], message: 'filter only applies to scope "all"' }) + } + if (value.excludeRowIds) { + issues.push({ + path: ['excludeRowIds'], + message: 'excludeRowIds only applies to scope "all"', + }) + } + return issues +} + +export const cancelTableRunsBodySchema = cancelTableRunsBodyBaseSchema.superRefine((value, ctx) => { + for (const issue of refineCancelTableRunsScope(value)) { + ctx.addIssue({ code: 'custom', ...issue }) + } +}) export const cancelTableRunsContract = defineRouteContract({ method: 'POST', @@ -1635,32 +1651,47 @@ export const runLimitSchema = z.object({ .max(1_000_000, 'max cannot exceed 1,000,000'), }) -export const runColumnBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - groupIds: z.array(z.string().min(1)).min(1), - runMode: z.enum(['all', 'incomplete']).default('all'), - rowIds: z.array(z.string().min(1)).min(1).optional(), - /** "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(), - /** Select-all scope only: rows deselected from the selection — the dispatcher skips them. */ - excludeRowIds: z - .array(z.string().min(1)) - .max( - TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, - `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` - ) - .optional(), - /** Cap the run to the first `max` eligible rows. Omit for an unbounded run. */ - limit: runLimitSchema.optional(), - }) - .refine((data) => !(data.rowIds && data.filter), { - message: 'Provide either filter or rowIds, but not both', - }) - .refine((data) => !(data.rowIds && data.excludeRowIds), { - message: 'excludeRowIds only applies to select-all scope (no rowIds)', - }) +/** + * Plain-object base for the run-column body. Kept un-refined so callers (e.g. + * the v2 public contract, which narrows `filter` to the predicate grammar) can + * `.extend()` before applying the mutex refines — Zod forbids `.extend()` on a + * refined schema. + */ +export const runColumnBodyBaseSchema = z.object({ + workspaceId: workspaceIdSchema, + groupIds: z.array(z.string().min(1)).min(1), + runMode: z.enum(['all', 'incomplete']).default('all'), + rowIds: z.array(z.string().min(1)).min(1).optional(), + /** "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(), + /** Select-all scope only: rows deselected from the selection — the dispatcher skips them. */ + excludeRowIds: z + .array(z.string().min(1)) + .max( + TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS, + `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` + ) + .optional(), + /** Cap the run to the first `max` eligible rows. Omit for an unbounded run. */ + limit: runLimitSchema.optional(), +}) + +/** An explicit row set and a select-all filter are mutually exclusive scopes. */ +export const runColumnScopeMutexRefine = [ + (data: { rowIds?: string[]; filter?: unknown }) => !(data.rowIds && data.filter), + { message: 'Provide either filter or rowIds, but not both' }, +] as const + +/** Deselections only mean something under select-all scope. */ +export const runColumnExcludeMutexRefine = [ + (data: { rowIds?: string[]; excludeRowIds?: string[] }) => !(data.rowIds && data.excludeRowIds), + { message: 'excludeRowIds only applies to select-all scope (no rowIds)' }, +] as const + +export const runColumnBodySchema = runColumnBodyBaseSchema + .refine(...runColumnScopeMutexRefine) + .refine(...runColumnExcludeMutexRefine) export const runColumnContract = defineRouteContract({ method: 'POST', diff --git a/apps/sim/lib/api/contracts/upload-sessions.ts b/apps/sim/lib/api/contracts/upload-sessions.ts new file mode 100644 index 00000000000..c56d8ca014b --- /dev/null +++ b/apps/sim/lib/api/contracts/upload-sessions.ts @@ -0,0 +1,68 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CreateFileUploadBodySchema, + v2FileUploadParamsSchema, + v2FileUploadSchema, + v2FileUploadWorkspaceQuerySchema, +} from '@/lib/api/contracts/v2/files' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CompleteUploadBodySchema, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadTokenHeadersSchema, +} from '@/lib/api/contracts/v2/uploads' + +export const createWorkspaceFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/files/uploads', + body: v2CreateFileUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, +}) + +export const abortWorkspaceFileUploadContract = defineRouteContract({ + method: 'DELETE', + path: '/api/files/uploads/[uploadId]', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, +}) + +export const createWorkspaceFileUploadPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/files/uploads/[uploadId]/parts', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) + +export const completeWorkspaceFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/files/uploads/[uploadId]/complete', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2CompleteUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, +}) + +export const localUploadPartParamsSchema = z.object({ + uploadId: z.string().min(1, 'uploadId is required'), + partNumber: z.coerce.number().int().min(1), +}) + +export const localUploadPartQuerySchema = z.object({ + token: z.string().min(1, 'token is required'), +}) + +export const localUploadPartContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/uploads/[uploadId]/parts/[partNumber]', + params: localUploadPartParamsSchema, + query: localUploadPartQuerySchema, + response: { mode: 'empty', status: 204 }, +}) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index fc7f81c0697..749d4a9bda7 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -8,6 +8,14 @@ import { v2SearchSchema, v2SortFields, } from '@/lib/api/contracts/v2/shared' +import { + v2CompleteUploadBodySchema, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadStatusSchema, + v2UploadTokenHeadersSchema, +} from '@/lib/api/contracts/v2/uploads' +import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' /** * v2 files contracts. v2 drops the v1 `{ success, data, limits }` envelope in @@ -25,11 +33,8 @@ import { * contract; folder management belongs on `/api/v2/folders` once that surface * serves `resourceType: 'file'`. * - * Presigned upload is deliberately absent. Presign only performs an advisory - * quota pre-check; the storage debit happens in the separate register step, so - * a caller that presigns, PUTs bytes, and never registers leaves unaccounted - * bytes in the bucket. The buffered multipart upload debits inside - * `uploadWorkspaceFile`'s own transaction, so it is the only public path. + * Uploads use a signed stateless control token. The storage provider owns the + * multipart part state; completion atomically registers the workspace file. */ /** A workspace file as exposed by the v2 surface. */ @@ -52,6 +57,38 @@ export const v2FileSchema = z.object({ export type V2File = z.output +export const v2FileUploadParamsSchema = z.object({ uploadId: z.string().min(1) }) +export type V2FileUploadParams = z.output + +export const v2CreateFileUploadBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), + contentType: z.string().trim().min(1, 'contentType is required').max(255), + size: z.number().int().min(1).max(MAX_WORKSPACE_FILE_SIZE), + folderId: z.string().min(1, 'folderId cannot be empty').optional(), + }) + .strict() +export type V2CreateFileUploadBody = z.input + +export const v2FileUploadWorkspaceQuerySchema = z.object({ workspaceId: workspaceIdSchema }) +export type V2FileUploadWorkspaceQuery = z.output + +export const v2FileUploadSchema = z.object({ + id: z.string(), + status: v2UploadStatusSchema, + name: z.string(), + contentType: z.string(), + size: z.number().int().positive(), + partSize: z.number().int().positive(), + partCount: z.number().int().positive(), + uploadToken: z.string().min(1), + expiresAt: z.string().datetime(), + error: z.string().nullable(), + file: v2FileSchema.nullable(), +}) +export type V2FileUpload = z.output + /** Acknowledgement returned by a successful archive (soft delete). */ export const v2DeleteFileResultSchema = z.object({ id: z.string(), @@ -125,19 +162,6 @@ export const v2ListFilesQuerySchema = z.object({ export type V2ListFilesQuery = z.output -/** - * Upload carries the workspace as a query param so auth runs before buffering. - * `folderId` is a query param for the same reason — the multipart body is never - * read until the caller is authorized. - */ -export const v2UploadFileQuerySchema = z.object({ - workspaceId: workspaceIdSchema, - /** Target file folder. Omit to upload to the workspace root. */ - folderId: z.string().min(1, 'folderId cannot be empty').optional(), -}) - -export type V2UploadFileQuery = z.output - /** Download/delete both target a single file within a workspace-scoped query. */ export const v2FileWorkspaceQuerySchema = z.object({ workspaceId: workspaceIdSchema, @@ -297,14 +321,40 @@ export const v2ListFilesContract = defineRouteContract({ }, }) -export const v2UploadFileContract = defineRouteContract({ +export const v2CreateFileUploadContract = defineRouteContract({ method: 'POST', - path: '/api/v2/files', - query: v2UploadFileQuerySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2FileSchema), - }, + path: '/api/v2/files/uploads', + body: v2CreateFileUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, +}) + +export const v2AbortFileUploadContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/files/uploads/[uploadId]', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, +}) + +export const v2CreateFileUploadPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/uploads/[uploadId]/parts', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) + +export const v2CompleteFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/uploads/[uploadId]/complete', + params: v2FileUploadParamsSchema, + query: v2FileUploadWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2CompleteUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, }) export const v2DownloadFileContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index fc9d4046979..18e817d58a8 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -1,20 +1,39 @@ import { z } from 'zod' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { folderIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { + addWorkflowGroupBodySchema, + cancelTableRunsBodyBaseSchema, createTableColumnBodySchema, + createTableViewBodySchema, + csvImportCreateColumnsSchema, + csvImportMappingSchema, deleteTableColumnBodySchema, + deleteWorkflowGroupBodySchema, + exportTableAsyncBodySchema, predicateSchema, + refineCancelTableRunsScope, + runColumnBodyBaseSchema, + runColumnExcludeMutexRefine, + runColumnScopeMutexRefine, sortSpecSchema, tableColumnSchema, tableIdParamsSchema, + tableLocksSchema, + tableNameSchema, tableRowParamsSchema, tableRowsQueryBaseSchema, + tableViewConfigSchema, + tableViewParamsSchema, updateRowsByFilterBodySchema, updateTableColumnBodySchema, updateTableRowBodySchema, + updateTableViewBodySchema, + updateWorkflowGroupBodySchema, upsertTableRowBodySchema, + workflowGroupOutputColumnSchema, } from '@/lib/api/contracts/tables' import { defineRouteContract } from '@/lib/api/contracts/types' +import { ianaTimezoneSchema } from '@/lib/api/contracts/user' import { v1CreateTableBodySchema, v1CreateTableRowsBodySchema, @@ -26,7 +45,15 @@ import { v2SearchSchema, v2SortFields, } from '@/lib/api/contracts/v2/shared' +import { + v2CompleteUploadBodySchema, + v2OptionalUploadTokenHeadersSchema, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadTokenHeadersSchema, +} from '@/lib/api/contracts/v2/uploads' import { TABLE_LIMITS } from '@/lib/table/constants' +import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' /** * v2 tables contracts. @@ -52,13 +79,29 @@ import { TABLE_LIMITS } from '@/lib/table/constants' /** Default page size when a row query/list `limit` is omitted. */ export const V2_DEFAULT_ROW_LIMIT = 100 -/** Hard cap on an explicit page `limit`. Larger pulls use `limit=0` (query) or the async export. */ +/** Hard cap on an explicit page `limit`. Larger pulls use `limit=0` (query) or an export resource. */ export const V2_MAX_ROW_LIMIT = 1000 /** * Public table shape emitted by `toApiTable` (timestamps ISO-serialized). * Concrete so the v2 contract describes exactly what the wire carries. */ +/** + * The table's current background job, or `null` when idle. + * + * Import and delete jobs are also derived onto the table (one write job per table at a time). + * Durable imports and exports have their own resource endpoints for complete lifecycle state. + */ +export const v2TableJobStateSchema = z.object({ + id: z.string().nullable(), + type: z.enum(['import', 'delete', 'export', 'backfill', 'update']).nullable(), + status: z.enum(['running', 'ready', 'failed', 'canceled']), + rowsProcessed: z.number(), + /** Failure reason for a `failed` job; `null` otherwise. */ + error: z.string().nullable(), +}) +export type V2TableJobState = z.output + export const v2ApiTableSchema = z.object({ id: z.string(), name: z.string(), @@ -66,6 +109,16 @@ export const v2ApiTableSchema = z.object({ schema: z.object({ columns: z.array(tableColumnSchema) }), rowCount: z.number(), maxRows: z.number(), + /** Owning folder, or `null` when the table sits at the workspace root. */ + folderId: z.string().nullable(), + /** + * Governance flags, read-only on the public API. They are enforced on every + * write (a locked verb returns 423), but flipping them is a first-party admin + * action — see {@link v2UpdateTableBodySchema}. + */ + locks: tableLocksSchema, + /** In-flight background job, or `null` when the table is idle. */ + job: v2TableJobStateSchema.nullable(), createdAt: z.string(), updatedAt: z.string(), }) @@ -204,6 +257,48 @@ export const v2GetTableContract = defineRouteContract({ }, }) +/** + * Table update. Every field is optional but at least one must be present: + * `name` renames and `folderId` moves the table (explicit `null` moves it to + * the workspace root; omission leaves the placement untouched). + * + * `locks` is deliberately **not** accepted here, which is why this body is + * declared rather than reusing the first-party `updateTableBodySchema`. The + * governance flags are read-only on the public surface: an API key that can + * write a table must not also be able to clear the lock that was put there to + * stop it. Flipping a lock stays a first-party admin action. The body is + * `.strict()`, so a caller sending `locks` gets a 400 naming the field instead + * of a silent no-op that reads as success. + */ +export const v2UpdateTableBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: tableNameSchema.optional(), + folderId: folderIdSchema.nullable().optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.name === undefined && body.folderId === undefined) { + ctx.addIssue({ + code: 'custom', + message: 'Provide a new name or folder', + path: ['name'], + }) + } + }) + +export const v2UpdateTableContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]', + params: tableIdParamsSchema, + body: v2UpdateTableBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableDataSchema), + }, +}) +export type V2UpdateTableBody = z.input + export const v2DeleteTableContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/tables/[tableId]', @@ -290,7 +385,7 @@ export const v2QueryRowsBodySchema = z.object({ .min(0, 'Limit must be at least 0 (use 0 for an unbounded query)') .max( V2_MAX_ROW_LIMIT, - `Limit cannot exceed ${V2_MAX_ROW_LIMIT}; use limit=0 for a full result or the async export for large datasets` + `Limit cannot exceed ${V2_MAX_ROW_LIMIT}; use limit=0 for a full result or create an export resource for large datasets` ) .optional(), cursor: z.string().min(1, 'cursor must be a non-empty token').optional(), @@ -432,3 +527,628 @@ export const v2UpsertTableRowContract = defineRouteContract({ schema: v2DataResponse(v2UpsertRowDataSchema), }, }) + +/** + * Body for the endpoints whose only input is the workspace the table must + * belong to. Present so every v2 mutation carries the same scope check the rest + * of the surface applies through `resolveWorkspaceScope`. + */ +export const v2WorkspaceScopedBodySchema = z.object({ workspaceId: workspaceIdSchema }) +export type V2WorkspaceScopedBody = z.input + +/** + * Un-archives a table archived by `DELETE /api/v2/tables/[tableId]`. Resolves + * the table with archived rows included, so it is the one table endpoint whose + * target is expected NOT to be active. + */ +export const v2RestoreTableContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/restore', + params: tableIdParamsSchema, + body: v2WorkspaceScopedBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableDataSchema), + }, +}) + +/** + * A saved view: a named preset of `{ filter, sort, column layout }` over a + * table. Presentation state only — a view narrows what a reader sees by + * default, it is never an access boundary, and every row it hides stays + * reachable by reading the table without it. Timestamps ISO-serialized. + */ +export const v2ApiViewSchema = z.object({ + id: z.string(), + tableId: z.string(), + name: z.string(), + config: tableViewConfigSchema, + isDefault: z.boolean(), + /** User who saved the view; `null` for views whose author is gone. */ + createdBy: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2ApiView = z.output + +/** A single view payload. */ +export const v2TableViewDataSchema = z.object({ view: v2ApiViewSchema }) +export type V2TableViewData = z.output + +/** Delete confirmation — the id of the view that was removed. */ +export const v2DeleteTableViewDataSchema = z.object({ id: z.string() }) +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. + */ +export const v2ListTableViewsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/views', + params: tableIdParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2ApiViewSchema), + }, +}) + +export const v2CreateTableViewContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/views', + params: tableIdParamsSchema, + body: createTableViewBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableViewDataSchema), + }, +}) + +export const v2GetTableViewContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/views/[viewId]', + params: tableViewParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableViewDataSchema), + }, +}) + +export const v2UpdateTableViewContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]/views/[viewId]', + params: tableViewParamsSchema, + body: updateTableViewBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2TableViewDataSchema), + }, +}) + +/** Deleting the default view simply leaves the table unfiltered. */ +export const v2DeleteTableViewContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/views/[viewId]', + params: tableViewParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteTableViewDataSchema), + }, +}) + +/** + * One workflow/enrichment column group: a backing workflow (or registry + * enrichment) plus the output columns its runs populate. Read-only on v2 — + * groups are authored in the workflow builder, and the public surface exposes + * them so a caller can discover the `groupIds` the run endpoints take. + */ +export const v2WorkflowGroupSchema = z.object({ + id: z.string(), + /** Backing workflow id for `manual` groups; `''` for enrichment groups. */ + workflowId: z.string(), + /** Registry enrichment id for `enrichment` groups. */ + enrichmentId: z.string().optional(), + name: z.string().optional(), + type: z.enum(['manual', 'enrichment']).optional(), + dependencies: z.object({ columns: z.array(z.string()).optional() }).optional(), + outputs: z.array( + z.object({ + blockId: z.string(), + path: z.string(), + outputId: z.string().optional(), + columnName: z.string(), + }) + ), + inputMappings: z.array(z.object({ inputName: z.string(), columnName: z.string() })).optional(), + deploymentMode: z.enum(['live', 'deployed']).optional(), + /** When `false` the group never auto-fires; it runs only on an explicit request. */ + autoRun: z.boolean().optional(), +}) +export type V2WorkflowGroup = z.output + +/** + * The table's workflow/enrichment groups. Bounded per table, so a single full + * page (`nextCursor` is always `null`). + */ +export const v2ListWorkflowGroupsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/[tableId]/groups', + params: tableIdParamsSchema, + query: v1ListTablesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowGroupSchema), + }, +}) + +/** + * Output column of a group, as the public surface accepts it. The first-party + * shape carries `workflowGroupId` because the client mints the group id before + * posting; v2 server-generates it, so the field is stamped from the group being + * written rather than being a caller's to supply (and get wrong). + */ +const v2WorkflowGroupOutputColumnSchema = workflowGroupOutputColumnSchema.omit({ + workflowGroupId: true, +}) + +/** + * A group names its producer two mutually exclusive ways, and the underlying + * shape leaves both optional. Rejecting the mismatch here means the route never + * has to guess which one a half-specified group meant. + */ +function refineGroupSource( + group: { type?: 'manual' | 'enrichment'; workflowId?: string; enrichmentId?: string }, + ctx: z.RefinementCtx, + path: (string | number)[] +): void { + // `manual` is the workflow-backed default — it does not mean hand-entered. + const type = group.type ?? 'manual' + if (type === 'enrichment' && !group.enrichmentId) { + ctx.addIssue({ + code: 'custom', + path: [...path, 'enrichmentId'], + message: 'enrichmentId is required when type is "enrichment"', + }) + } + if (type === 'manual' && !group.workflowId) { + ctx.addIssue({ + code: 'custom', + path: [...path, 'workflowId'], + message: 'workflowId is required when type is "manual"', + }) + } +} + +/** + * Create a group and the columns its runs populate, in one call. + * + * Two deliberate departures from the first-party body: + * - `group.id` is optional and server-generated. The UI mints an id so it can + * render optimistically; a public caller has no such need and a client-chosen + * id is a collision waiting to happen. + * - `autoRun` defaults to **false**. On the first-party surface it defaults to + * true so a UI add fills cells immediately, but here it would make one POST + * fan out a metered run across every existing row. Callers opt in, or fire + * explicitly via `POST /columns/run`. + */ +export const v2AddWorkflowGroupBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + group: addWorkflowGroupBodySchema.shape.group.extend({ + id: z.string().min(1).optional(), + }), + outputColumns: z.array(v2WorkflowGroupOutputColumnSchema).min(1), + autoRun: z.boolean().optional().default(false), + }) + .strict() + .superRefine((body, ctx) => refineGroupSource(body.group, ctx, ['group'])) +export type V2AddWorkflowGroupBody = z.input + +/** Update body. Omitted fields keep their stored values. */ +export const v2UpdateWorkflowGroupBodySchema = updateWorkflowGroupBodySchema + .extend({ + newOutputColumns: z.array(v2WorkflowGroupOutputColumnSchema).optional(), + }) + .strict() +export type V2UpdateWorkflowGroupBody = z.input + +export const v2DeleteWorkflowGroupBodySchema = deleteWorkflowGroupBodySchema.strict() +export type V2DeleteWorkflowGroupBody = z.input + +/** + * Create and update both mutate the group *and* the table's columns, so both + * are returned — otherwise a caller has to re-read the table to learn which + * columns it just got. + */ +export const v2WorkflowGroupDataSchema = z.object({ + group: v2WorkflowGroupSchema, + columns: z.array(tableColumnSchema), +}) +export type V2WorkflowGroupData = z.output + +/** + * Delete acknowledgement. Removing a group removes the columns it fed, so the + * surviving column list is returned rather than left for the caller to guess. + */ +export const v2DeleteWorkflowGroupDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), + columns: z.array(tableColumnSchema), +}) +export type V2DeleteWorkflowGroupData = z.output + +export const v2AddWorkflowGroupContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/groups', + params: tableIdParamsSchema, + body: v2AddWorkflowGroupBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowGroupDataSchema), + }, +}) + +export const v2UpdateWorkflowGroupContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/[tableId]/groups', + params: tableIdParamsSchema, + body: v2UpdateWorkflowGroupBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowGroupDataSchema), + }, +}) + +export const v2DeleteWorkflowGroupContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/[tableId]/groups', + params: tableIdParamsSchema, + body: v2DeleteWorkflowGroupBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteWorkflowGroupDataSchema), + }, +}) + +/** + * Run-column body. Identical to the first-party shape except `filter`, which v2 + * narrows to the typed predicate tree — the legacy `$`-operator dialect stays + * v1-only across the whole v2 surface. + */ +export const v2RunColumnBodySchema = runColumnBodyBaseSchema + .extend({ filter: predicateSchema.optional() }) + .refine(...runColumnScopeMutexRefine) + .refine(...runColumnExcludeMutexRefine) +export type V2RunColumnBody = z.input + +/** + * A started run. `dispatchId` identifies the `table_run_dispatches` row the + * dispatcher walks; it is `null` in deployments without a background runner, + * where cells execute inline and no dispatch row is created. + */ +export const v2RunColumnDataSchema = z.object({ dispatchId: z.string().nullable() }) +export type V2RunColumnData = z.output + +/** + * Runs one or more workflow/enrichment groups across the table or a row subset. + * Asynchronous: the response acknowledges the dispatch, and cell values land as + * the runs complete. Poll the rows endpoints for results. + */ +export const v2RunTableColumnContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/columns/run', + params: tableIdParamsSchema, + body: v2RunColumnBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2RunColumnDataSchema), + }, +}) + +export const v2RowEnrichmentParamsSchema = tableRowParamsSchema.extend({ + groupId: z.string().min(1), +}) +export type V2RowEnrichmentParams = z.output + +/** + * The single-cell case of {@link v2RunTableColumnContract}: runs one group for + * one row. The scope lives entirely in the path, so the body carries only the + * workspace. + */ +export const v2RunRowEnrichmentContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', + params: v2RowEnrichmentParamsSchema, + body: v2WorkspaceScopedBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2RunColumnDataSchema), + }, +}) + +/** + * Lookup body: a case-insensitive substring search across every cell, narrowed + * by the same predicate/sort grammar as `POST /query`. POST because the + * predicate tree is a structured body, not a querystring dialect. + */ +export const v2FindRowsBodySchema = z.object({ + workspaceId: workspaceIdSchema, + q: z.string().min(1, 'q must be a non-empty search string'), + predicate: predicateSchema.optional(), + sort: sortSpecSchema.optional(), +}) +export type V2FindRowsBody = z.input + +/** + * One matching cell. `ordinal` is the row's 0-based index in the + * predicate-filtered, sorted view, so it lines up with the same page a + * `POST /query` with the same predicate and sort would return. `column` is the + * column NAME, matching how row `data` is keyed everywhere on the public wire. + */ +export const v2RowMatchSchema = z.object({ + ordinal: z.number(), + rowId: z.string(), + column: z.string(), +}) +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. + */ +export const v2FindRowsDataSchema = z.object({ + matches: z.array(v2RowMatchSchema), + truncated: z.boolean(), +}) +export type V2FindRowsData = z.output + +export const v2FindTableRowsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/find', + params: tableIdParamsSchema, + body: v2FindRowsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FindRowsDataSchema), + }, +}) + +export const v2TableImportParamsSchema = z.object({ importId: z.string().min(1) }) +export const v2TableExportParamsSchema = z.object({ exportId: z.string().min(1) }) +export const v2TableTransferWorkspaceQuerySchema = z.object({ workspaceId: workspaceIdSchema }) + +export const v2TableImportSourceSchema = z.discriminatedUnion('type', [ + z + .object({ + type: z.literal('upload'), + name: z.string().trim().min(1, 'name is required').max(255), + contentType: z.string().trim().min(1, 'contentType is required').max(255), + size: z.number().int().min(1).max(MAX_WORKSPACE_FILE_SIZE), + }) + .strict(), + z.object({ type: z.literal('workspace_file'), fileId: z.string().min(1) }).strict(), +]) +export type V2TableImportSource = z.input + +export const v2TableImportTargetSchema = z.discriminatedUnion('type', [ + z + .object({ + type: z.literal('new'), + name: tableNameSchema, + folderId: folderIdSchema.optional(), + }) + .strict(), + z + .object({ + type: z.literal('existing'), + tableId: z.string().min(1), + mode: z.enum(['append', 'replace']), + }) + .strict(), +]) +export type V2TableImportTarget = z.input + +export const v2CreateTableImportBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + source: v2TableImportSourceSchema, + target: v2TableImportTargetSchema, + mapping: csvImportMappingSchema.optional(), + createColumns: csvImportCreateColumnsSchema.optional(), + timezone: ianaTimezoneSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.target.type === 'new' && body.mapping !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['mapping'], + message: 'mapping is only supported for an existing table target', + }) + } + if (body.target.type === 'new' && body.createColumns !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['createColumns'], + message: 'createColumns is only supported for an existing table target', + }) + } + }) +export type V2CreateTableImportBody = z.input + +export const v2TableImportStatusSchema = z.enum([ + 'uploading', + 'queued', + 'processing', + 'completed', + 'failed', + 'canceled', + 'expired', +]) +export type V2TableImportStatus = z.output + +export const v2TableImportUploadSchema = z.object({ + uploadToken: z.string().min(1), + partSize: z.number().int().positive(), + partCount: z.number().int().positive(), + expiresAt: z.string().datetime(), +}) + +export const v2TableImportSchema = z.object({ + id: z.string(), + workspaceId: z.string(), + status: v2TableImportStatusSchema, + source: v2TableImportSourceSchema, + target: v2TableImportTargetSchema, + tableId: z.string().nullable(), + rowsProcessed: z.number().int().nonnegative(), + error: z.string().nullable(), + upload: v2TableImportUploadSchema.nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + completedAt: z.string().datetime().nullable(), +}) +export type V2TableImport = z.output + +export const v2CreateTableImportContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/imports', + body: v2CreateTableImportBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const v2GetTableImportContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/imports/[importId]', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const v2CancelTableImportContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/imports/[importId]', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + headers: v2OptionalUploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const v2CreateTableImportPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/imports/[importId]/parts', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) + +export const v2CompleteTableImportContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/imports/[importId]/complete', + params: v2TableImportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2CompleteUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, +}) + +export const v2TableExportStatusSchema = z.enum([ + 'queued', + 'processing', + 'completed', + 'failed', + 'canceled', +]) +export type V2TableExportStatus = z.output + +export const v2TableExportSchema = z.object({ + id: z.string(), + tableId: z.string(), + workspaceId: z.string(), + format: z.enum(['csv', 'json']), + status: v2TableExportStatusSchema, + rowsProcessed: z.number().int().nonnegative(), + error: z.string().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + completedAt: z.string().datetime().nullable(), +}) +export type V2TableExport = z.output + +export const v2CreateTableExportContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/exports', + params: tableIdParamsSchema, + body: exportTableAsyncBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, +}) + +export const v2GetTableExportContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/exports/[exportId]', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, +}) + +export const v2CancelTableExportContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/exports/[exportId]', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, +}) + +export const v2TableExportDownloadDataSchema = z.object({ + url: z.string().url(), + fileName: z.string(), + expiresAt: z.string().datetime(), +}) + +export const v2TableExportDownloadContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/exports/[exportId]/download', + params: v2TableExportParamsSchema, + query: v2TableTransferWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableExportDownloadDataSchema) }, +}) + +/** + * Cancel-runs body. Identical to the first-party shape except `filter`, which + * v2 narrows to the typed predicate tree. + */ +export const v2CancelTableRunsBodySchema = cancelTableRunsBodyBaseSchema + .extend({ filter: predicateSchema.optional() }) + .superRefine((value, ctx) => { + for (const issue of refineCancelTableRunsScope(value)) { + ctx.addIssue({ code: 'custom', ...issue }) + } + }) +export type V2CancelTableRunsBody = z.input + +/** How many in-flight cell runs the cancel actually stopped. */ +export const v2CancelTableRunsDataSchema = z.object({ cancelled: z.number() }) +export type V2CancelTableRunsData = z.output + +/** + * Stops in-flight and pending workflow/enrichment cell runs — the counterpart + * to `POST /columns/run`. Import and export work is canceled by deleting its + * resource instead. + */ +export const v2CancelTableRunsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/[tableId]/cancel-runs', + params: tableIdParamsSchema, + body: v2CancelTableRunsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CancelTableRunsDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/uploads.ts b/apps/sim/lib/api/contracts/v2/uploads.ts new file mode 100644 index 00000000000..d18f9124692 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/uploads.ts @@ -0,0 +1,53 @@ +import { z } from 'zod' + +export const v2UploadStatusSchema = z.enum([ + 'uploading', + 'finalizing', + 'completed', + 'failed', + 'aborted', + 'expired', +]) +export type V2UploadStatus = z.output + +export const v2UploadTokenHeadersSchema = z.object({ + 'upload-token': z.string().min(1, 'upload-token header is required'), +}) +export type V2UploadTokenHeaders = z.input + +export const v2OptionalUploadTokenHeadersSchema = z.object({ + 'upload-token': z.string().min(1, 'upload-token header cannot be empty').optional(), +}) + +export const v2CompletedPartSchema = z + .object({ + partNumber: z.number().int().min(1), + etag: z.string().min(1).optional(), + }) + .strict() +export type V2CompletedPart = z.input + +export const v2CompleteUploadBodySchema = z + .object({ + parts: z.array(v2CompletedPartSchema).min(1).max(640), + }) + .strict() +export type V2CompleteUploadBody = z.input + +export const v2PartUrlsBodySchema = z + .object({ + partNumbers: z.array(z.number().int().min(1)).min(1).max(100), + }) + .strict() +export type V2PartUrlsBody = z.input + +export const v2UploadPartUrlSchema = z.object({ + partNumber: z.number().int().min(1), + url: z.string().url(), + headers: z.record(z.string(), z.string()), + expiresAt: z.string().datetime(), +}) +export type V2UploadPartUrl = z.output + +export const v2PartUrlsDataSchema = z.object({ parts: z.array(v2UploadPartUrlSchema).max(100) }) +export type V2PartUrlsData = z.output diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts index d5f74aa9660..8740d11a541 100644 --- a/apps/sim/lib/api/list-query.ts +++ b/apps/sim/lib/api/list-query.ts @@ -83,7 +83,7 @@ export function textKey(column: Column, read: (row: Row) => string): Keyset } /** A numeric key — sizes, counts, manual positions. */ -export function numberKey(column: Column, read: (row: Row) => number): KeysetKey { +export function numberKey(column: SQLWrapper, read: (row: Row) => number): KeysetKey { return { expr: column, encode: read, diff --git a/apps/sim/lib/billing/storage/payer-transfer.ts b/apps/sim/lib/billing/storage/payer-transfer.ts index 6eb9ca9337a..50d7ee2fe76 100644 --- a/apps/sim/lib/billing/storage/payer-transfer.ts +++ b/apps/sim/lib/billing/storage/payer-transfer.ts @@ -82,7 +82,7 @@ async function getExactWorkspaceStorageBytes(tx: DbOrTx, workspaceId: string): P const [row] = await tx.execute(sql` SELECT COALESCE(( - SELECT SUM(${workspaceFiles.size}::bigint) + SELECT SUM(coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size}::bigint)) FROM ${workspaceFiles} WHERE ${workspaceFiles.workspaceId} = ${workspaceId} AND ${workspaceFiles.context} = 'workspace' @@ -171,7 +171,7 @@ async function getExactWorkspaceStorageBytesBatch( FROM ( SELECT ${workspaceFiles.workspaceId} AS workspace_id, - SUM(${workspaceFiles.size}::bigint) AS workspace_file_bytes, + SUM(coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size}::bigint)) AS workspace_file_bytes, 0::bigint AS document_bytes FROM ${workspaceFiles} WHERE ${inArray(workspaceFiles.workspaceId, workspaceIds)} diff --git a/apps/sim/lib/table/export-stream.ts b/apps/sim/lib/table/export-stream.ts new file mode 100644 index 00000000000..abf2d027820 --- /dev/null +++ b/apps/sim/lib/table/export-stream.ts @@ -0,0 +1,100 @@ +import { createLogger } from '@sim/logger' +import { neutralizeCsvFormula } from '@/lib/core/utils/csv' +import { namedRowMapper } from '@/lib/table/cell-format' +import { getColumnId } from '@/lib/table/column-keys' +import { formatCsvCell } from '@/lib/table/export-format' +import { queryRows } from '@/lib/table/rows/service' +import type { TableDefinition, TableExportFormat } from '@/lib/table/types' + +const logger = createLogger('TableExportStream') + +const EXPORT_BATCH_SIZE = 1000 + +export function sanitizeExportFilename(name: string): string { + const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') + return cleaned || 'table' +} + +function escapeCsvField(field: string): string { + return /[",\n\r]/.test(field) ? `"${field.replace(/"/g, '""')}"` : field +} + +function toCsvRow(values: string[]): string { + return values.map(escapeCsvField).join(',') +} + +export function exportContentType(format: TableExportFormat): string { + return format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json' +} + +/** + * Synchronous table export as a byte stream, shared by the first-party and + * public surfaces so both emit byte-identical files. + * + * Rows are paged out as they are read rather than buffered, so a table larger + * than memory still exports — at the cost of a mid-stream failure being + * unrecoverable (the response has already started). Large tables should use the + * background export instead. + */ +export function createTableExportStream( + table: TableDefinition, + format: TableExportFormat, + requestId: string +): ReadableStream { + const columns = table.schema.columns + // Stored row data is id-keyed; CSV headers and JSON keys are display names, so + // translate id → name on the way out (export is a name-friendly boundary). + const toNamedRow = namedRowMapper(columns) + + return new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder() + try { + if (format === 'csv') { + controller.enqueue( + encoder.encode(`${toCsvRow(columns.map((c) => neutralizeCsvFormula(c.name)))}\n`) + ) + } else { + controller.enqueue(encoder.encode('[')) + } + + let offset = 0 + let firstJsonRow = true + while (true) { + const result = await queryRows( + table, + { limit: EXPORT_BATCH_SIZE, offset, includeTotal: false }, + requestId + ) + + for (const row of result.rows) { + if (format === 'csv') { + const values = columns.map((c) => formatCsvCell(c, row.data[getColumnId(c)])) + controller.enqueue(encoder.encode(`${toCsvRow(values)}\n`)) + } else { + const prefix = firstJsonRow ? '' : ',' + firstJsonRow = false + controller.enqueue(encoder.encode(prefix + JSON.stringify(toNamedRow(row.data)))) + } + } + + // A page can be cut by the byte budget before reaching EXPORT_BATCH_SIZE, + // so a short page does NOT mean the export is done — only a null cursor does. + if (!result.nextCursor) break + offset += result.rows.length + } + + if (format === 'json') controller.enqueue(encoder.encode(']')) + controller.close() + + logger.info(`[${requestId}] Exported table ${table.id}`, { + format, + rowCount: table.rowCount, + }) + } catch (err) { + logger.error(`[${requestId}] Export failed for table ${table.id}`, err) + controller.error(err) + } + }, + }) +} diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index 93ae112d415..b0eba05c14d 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -77,6 +77,8 @@ export interface TableImportPayload { * worker never needs a settings lookup. */ timezone?: string + /** Storage context for the source object. Legacy imports default to `workspace`. */ + storageContext?: 'workspace' | 'table-import' } /** @@ -89,12 +91,14 @@ export interface TableImportPayload { */ export async function runTableImport(payload: TableImportPayload): Promise { const { importId, tableId, workspaceId, userId, fileKey, fileName, delimiter, mode } = payload + const storageContext = payload.storageContext ?? 'workspace' const requestId = generateId().slice(0, 8) // Hoisted so `finally` can destroy it on any failure — otherwise the storage HTTP body leaks // open until it times out. let source: Readable | undefined try { + if (!(await updateJobProgress(tableId, 0, importId))) throw new ImportSupersededError() const loaded = await getTableById(tableId, { includeArchived: true }) if (!loaded) throw new Error(`Import target table ${tableId} not found`) const table = loaded @@ -131,10 +135,10 @@ export async function runTableImport(payload: TableImportPayload): Promise // Total byte size for the progress estimate — a cheap HEAD, no download. May be null on // the local dev provider, in which case the bar stays indeterminate (rows still show). - const totalBytes = (await headObject(fileKey, 'workspace'))?.size ?? 0 + const totalBytes = (await headObject(fileKey, storageContext))?.size ?? 0 // Stream the file rather than buffering it — a ~1M-row import must never be held in memory. - source = await downloadFileStream({ key: fileKey, context: 'workspace' }) + source = await downloadFileStream({ key: fileKey, context: storageContext }) // The kickoff route's extension-derived delimiter is only the fallback — the separator is // sniffed from the file's head so semicolon/pipe exports don't collapse into one column. @@ -183,6 +187,9 @@ export async function runTableImport(payload: TableImportPayload): Promise * map onto the existing schema, optionally auto-creating `createColumns` first. */ const resolveSetup = async () => { + if (!(await updateJobProgress(tableId, inserted, importId))) { + throw new ImportSupersededError() + } const headers = csvHeaders if (mode === 'create') { @@ -433,7 +440,7 @@ export async function runTableImport(payload: TableImportPayload): Promise // import is terminal so the workspace bucket doesn't accumulate. Best-effort. Skipped for // persistent workspace files (deleteSourceFile: false). if (payload.deleteSourceFile !== false) { - await deleteFile({ key: fileKey, context: 'workspace' }).catch((err) => { + await deleteFile({ key: fileKey, context: storageContext }).catch((err) => { logger.warn(`[${requestId}] Failed to delete imported file`, { fileKey, err }) }) } diff --git a/apps/sim/lib/table/orchestration/columns.ts b/apps/sim/lib/table/orchestration/columns.ts index 18d321d3e3b..71048def646 100644 --- a/apps/sim/lib/table/orchestration/columns.ts +++ b/apps/sim/lib/table/orchestration/columns.ts @@ -18,7 +18,7 @@ import { import { isSupportedCurrencyCode } from '@/lib/table/currency' import { TableLockedError } from '@/lib/table/mutation-locks' import { normalizeSelectOptionsInput } from '@/lib/table/select-options' -import type { ColumnType, SelectOption, TableDefinition } from '@/lib/table/types' +import type { ColumnType, SelectOption, TableDefinition, TableLockKind } from '@/lib/table/types' const logger = createLogger('TableColumnOrchestration') @@ -45,12 +45,14 @@ export interface PerformUpdateTableColumnResult { success: boolean error?: string errorCode?: OrchestrationErrorCode + /** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */ + lock?: TableLockKind table?: TableDefinition } function classify(error: unknown): PerformUpdateTableColumnResult { if (error instanceof TableLockedError) { - return { success: false, error: error.message, errorCode: 'locked' } + return { success: false, error: error.message, errorCode: 'locked', lock: error.lock } } if (error instanceof OrchestrationError) { return { success: false, error: error.message, errorCode: error.code } diff --git a/apps/sim/lib/table/orchestration/export-resource.ts b/apps/sim/lib/table/orchestration/export-resource.ts new file mode 100644 index 00000000000..381ae860051 --- /dev/null +++ b/apps/sim/lib/table/orchestration/export-resource.ts @@ -0,0 +1,129 @@ +import { db } from '@sim/db' +import { tableJobs } from '@sim/db/schema' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import type { V2TableExport, V2TableExportStatus } from '@/lib/api/contracts/v2/tables' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { runDetached } from '@/lib/core/utils/background' +import { TABLE_LIMITS } from '@/lib/table/constants' +import { runTableExport, type TableExportPayload } from '@/lib/table/export-runner' +import { markJobCanceled, markJobFailed, markTableJobRunning } from '@/lib/table/jobs/service' +import type { TableDefinition, TableExportJobPayload } from '@/lib/table/types' + +export type TableExportRecord = typeof tableJobs.$inferSelect + +export async function createTableExportResource(params: { + table: TableDefinition + format: 'csv' | 'json' +}): Promise { + const exportId = generateId() + const payload: TableExportJobPayload = { format: params.format } + if (!(await markTableJobRunning(params.table.id, exportId, 'export', payload))) { + throw new OrchestrationError('conflict', 'Failed to start export') + } + const runnerPayload: TableExportPayload = { + jobId: exportId, + tableId: params.table.id, + workspaceId: params.table.workspaceId, + format: params.format, + } + + if (params.table.rowCount <= TABLE_LIMITS.EXPORT_ASYNC_THRESHOLD_ROWS) { + await runTableExport(runnerPayload) + } else { + try { + if (isTriggerDevEnabled) { + const [{ tableExportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ + import('@/background/table-export'), + import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), + ]) + await tasks.trigger('table-export', runnerPayload, { + tags: [`tableId:${params.table.id}`, `jobId:${exportId}`], + region: await resolveTriggerRegion(), + }) + } else { + runDetached('table-export', () => runTableExport(runnerPayload)) + } + } catch (error) { + await markJobFailed( + params.table.id, + exportId, + getErrorMessage(error, 'Failed to dispatch table export') + ) + throw error + } + } + + return requireTableExport(exportId, params.table.workspaceId) +} + +export async function requireTableExport( + exportId: string, + workspaceId: string +): Promise { + const [record] = await db + .select() + .from(tableJobs) + .where( + and( + eq(tableJobs.id, exportId), + eq(tableJobs.workspaceId, workspaceId), + eq(tableJobs.type, 'export') + ) + ) + .limit(1) + if (!record) throw new OrchestrationError('not_found', 'Table export not found') + return record +} + +export async function cancelTableExportResource( + record: TableExportRecord +): Promise { + if (record.status === 'canceled') return record + if (record.status !== 'running') { + throw new OrchestrationError('conflict', `Table export is ${publicExportStatus(record.status)}`) + } + await markJobCanceled(record.tableId, record.id) + return requireTableExport(record.id, record.workspaceId) +} + +export function toV2TableExport(record: TableExportRecord, queued = false): V2TableExport { + const payload = record.payload as TableExportJobPayload | null + if (!payload?.format) throw new Error(`Table export ${record.id} has no format`) + return { + id: record.id, + tableId: record.tableId, + workspaceId: record.workspaceId, + format: payload.format, + status: queued && record.status === 'running' ? 'queued' : publicExportStatus(record.status), + rowsProcessed: record.rowsProcessed, + error: record.error, + createdAt: record.startedAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + completedAt: record.completedAt?.toISOString() ?? null, + } +} + +export function tableExportResult(record: TableExportRecord): { + resultKey: string + format: 'csv' | 'json' +} { + if (record.status !== 'ready') { + throw new OrchestrationError('conflict', `Table export is ${publicExportStatus(record.status)}`) + } + const payload = record.payload as TableExportJobPayload | null + if (!payload?.resultKey || !payload.format) { + throw new OrchestrationError('not_found', 'Export file is no longer available') + } + return { resultKey: payload.resultKey, format: payload.format } +} + +function publicExportStatus(status: string): V2TableExportStatus { + if (status === 'running') return 'processing' + if (status === 'ready') return 'completed' + if (status === 'failed' || status === 'canceled') return status + throw new Error(`Invalid table export status: ${status}`) +} diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts new file mode 100644 index 00000000000..4e0902dc9db --- /dev/null +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -0,0 +1,455 @@ +import { db } from '@sim/db' +import { tableJobs } from '@sim/db/schema' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { + type V2CreateTableImportBody, + type V2TableImport, + type V2TableImportSource, + type V2TableImportTarget, + v2CreateTableImportBodySchema, + v2TableImportSourceSchema, + v2TableImportTargetSchema, +} from '@/lib/api/contracts/v2/tables' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' +import { findActiveFolder } from '@/lib/folders/queries' +import { getWorkspaceTableLimits } from '@/lib/table/billing' +import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' +import { markJobCanceled, markJobFailed, markTableJobRunning } from '@/lib/table/jobs/service' +import { assertRowDelete, assertRowInsert } from '@/lib/table/mutation-locks' +import { createTable, getTableById } from '@/lib/table/service' +import type { TableImportJobPayload } from '@/lib/table/types' +import { getWorkspaceFile, type WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { + abortUploadSession, + createUploadSession, + getOwnedUploadSession, + type UploadSessionRecord, +} from '@/lib/uploads/multipart-session/service' +import { getUserSettings } from '@/lib/users/queries' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +type TableImportStatus = 'uploading' | 'running' | 'ready' | 'failed' | 'canceled' + +interface TableImportResource { + id: string + workspaceId: string + userId: string + source: V2TableImportSource + target: V2TableImportTarget + options: TableImportJobPayload['options'] + tableId: string | null + status: TableImportStatus + rowsProcessed: number + error: string | null + upload: UploadSessionRecord | null + createdAt: Date + updatedAt: Date + completedAt: Date | null +} + +interface CreateTableImportResult { + record: TableImportResource + upload: UploadSessionRecord | null +} + +export async function createTableImportResource( + body: V2CreateTableImportBody, + userId: string +): Promise { + await assertWorkspaceWrite(userId, body.workspaceId) + await validateTarget(body.workspaceId, body.target) + const importId = generateId() + const options = importOptions(body) + + if (body.source.type === 'upload') { + assertCsvFileName(body.source.name) + const upload = await createUploadSession({ + id: importId, + workspaceId: body.workspaceId, + userId, + purpose: 'table_import', + fileName: body.source.name, + contentType: body.source.contentType, + fileSize: body.source.size, + metadata: { tableImport: body }, + }) + return { record: resourceFromUpload(upload, body), upload } + } + + const file = await requireWorkspaceSource(body.workspaceId, body.source.fileId) + assertCsvFileName(file.name) + return { + record: await startTableImport({ + id: importId, + workspaceId: body.workspaceId, + userId, + source: body.source, + target: body.target, + options, + fileKey: file.key, + fileName: file.name, + storageContext: 'workspace', + deleteSourceFile: false, + }), + upload: null, + } +} + +export async function startUploadedTableImport( + upload: UploadSessionRecord +): Promise { + const body = tableImportBodyFromUpload(upload) + const existing = await findOwnedTableImport({ + importId: upload.id, + workspaceId: upload.workspaceId, + userId: upload.userId, + }) + if (existing) return existing + return startTableImport({ + id: upload.id, + workspaceId: upload.workspaceId, + userId: upload.userId, + source: body.source, + target: body.target, + options: importOptions(body), + fileKey: upload.storageKey, + fileName: upload.fileName, + storageContext: 'table-import', + deleteSourceFile: true, + }) +} + +export function getOwnedTableImportUpload(params: { + importId: string + workspaceId: string + userId: string + uploadToken: string +}): UploadSessionRecord { + const upload = getOwnedUploadSession({ + uploadId: params.importId, + workspaceId: params.workspaceId, + userId: params.userId, + uploadToken: params.uploadToken, + }) + tableImportBodyFromUpload(upload) + return upload +} + +export async function abortTableImportUpload(params: { + importId: string + workspaceId: string + userId: string + uploadToken: string +}): Promise { + const upload = getOwnedTableImportUpload(params) + const body = tableImportBodyFromUpload(upload) + return resourceFromUpload(await abortUploadSession(upload), body) +} + +export async function getOwnedTableImport(params: { + importId: string + workspaceId: string + userId: string +}): Promise { + const record = await findOwnedTableImport(params) + if (!record) throw new OrchestrationError('not_found', 'Table import not found') + return record +} + +export async function findOwnedTableImport(params: { + importId: string + workspaceId: string + userId: string +}): Promise { + const [job] = await db + .select() + .from(tableJobs) + .where( + and( + eq(tableJobs.id, params.importId), + eq(tableJobs.workspaceId, params.workspaceId), + eq(tableJobs.type, 'import') + ) + ) + .limit(1) + if (!job) return null + const payload = parseImportJobPayload(job.payload) + if (payload.userId !== params.userId) return null + return { + id: job.id, + workspaceId: job.workspaceId, + userId: payload.userId, + source: v2TableImportSourceSchema.parse(payload.source), + target: v2TableImportTargetSchema.parse(payload.target), + options: payload.options, + tableId: job.tableId, + status: tableImportStatus(job.status), + rowsProcessed: job.rowsProcessed, + error: job.error, + upload: null, + createdAt: job.startedAt, + updatedAt: job.updatedAt, + completedAt: job.completedAt, + } +} + +export async function cancelTableImportResource( + record: TableImportResource +): Promise { + if (record.status === 'canceled') return record + if (record.status !== 'running' || !record.tableId) { + throw new OrchestrationError('conflict', `Table import is ${publicImportStatus(record.status)}`) + } + await markJobCanceled(record.tableId, record.id) + return getOwnedTableImport({ + importId: record.id, + workspaceId: record.workspaceId, + userId: record.userId, + }) +} + +export function toV2TableImport(record: TableImportResource): V2TableImport { + return { + id: record.id, + workspaceId: record.workspaceId, + status: publicImportStatus(record.status), + source: record.source, + target: record.target, + tableId: record.tableId, + rowsProcessed: record.rowsProcessed, + error: record.error, + upload: record.upload + ? { + uploadToken: record.upload.uploadToken, + partSize: record.upload.partSize, + partCount: record.upload.partCount, + expiresAt: record.upload.expiresAt.toISOString(), + } + : null, + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + completedAt: record.completedAt?.toISOString() ?? null, + } +} + +interface StartTableImportParams { + id: string + workspaceId: string + userId: string + source: V2TableImportSource + target: V2TableImportTarget + options: TableImportJobPayload['options'] + fileKey: string + fileName: string + storageContext: 'workspace' | 'table-import' + deleteSourceFile: boolean +} + +async function startTableImport(params: StartTableImportParams): Promise { + const requestId = generateRequestId() + const jobPayload: TableImportJobPayload = { + kind: 'table_import', + userId: params.userId, + source: params.source, + target: params.target, + options: params.options, + } + let tableId: string | null = null + try { + if (params.target.type === 'new') { + const limits = await getWorkspaceTableLimits(params.workspaceId) + const table = await createTable( + { + name: params.target.name, + description: `Imported from ${params.fileName}`, + schema: { columns: [{ name: 'column_1', type: 'string' }] }, + workspaceId: params.workspaceId, + folderId: params.target.folderId ?? null, + userId: params.userId, + maxTables: limits.maxTables, + jobStatus: 'running', + jobType: 'import', + jobId: params.id, + jobPayload, + }, + requestId + ) + tableId = table.id + } else { + const table = await requireExistingTarget(params.workspaceId, params.target) + tableId = table.id + if (!(await markTableJobRunning(tableId, params.id, 'import', jobPayload))) { + throw new OrchestrationError('conflict', 'A job is already in progress for this table') + } + } + + const payload: TableImportPayload = { + importId: params.id, + tableId, + workspaceId: params.workspaceId, + userId: params.userId, + fileKey: params.fileKey, + fileName: params.fileName, + delimiter: params.fileName.toLowerCase().endsWith('.tsv') ? '\t' : ',', + mode: params.target.type === 'new' ? 'create' : params.target.mode, + mapping: params.options.mapping as TableImportPayload['mapping'], + createColumns: params.options.createColumns, + deleteSourceFile: params.deleteSourceFile, + storageContext: params.storageContext, + timezone: params.options.timezone ?? (await getUserSettings(params.userId)).timezone ?? 'UTC', + } + + if (isTriggerDevEnabled) { + const [{ tableImportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ + import('@/background/table-import'), + import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), + ]) + await tasks.trigger('table-import', payload, { + tags: [`tableId:${tableId}`, `jobId:${params.id}`], + region: await resolveTriggerRegion(), + }) + } else { + runDetached('table-import', () => runTableImport(payload)) + } + return getOwnedTableImport({ + importId: params.id, + workspaceId: params.workspaceId, + userId: params.userId, + }) + } catch (error) { + const message = getErrorMessage(error, 'Failed to dispatch table import') + if (tableId) await markJobFailed(tableId, params.id, message).catch(() => {}) + if (params.deleteSourceFile) { + const { deleteFile } = await import('@/lib/uploads/core/storage-service') + await deleteFile({ key: params.fileKey, context: params.storageContext }).catch(() => {}) + } + throw error + } +} + +function resourceFromUpload( + upload: UploadSessionRecord, + body: V2CreateTableImportBody +): TableImportResource { + return { + id: upload.id, + workspaceId: upload.workspaceId, + userId: upload.userId, + source: body.source, + target: body.target, + options: importOptions(body), + tableId: body.target.type === 'existing' ? body.target.tableId : null, + status: upload.status === 'aborted' ? 'canceled' : 'uploading', + rowsProcessed: 0, + error: null, + upload, + createdAt: upload.createdAt, + updatedAt: upload.updatedAt, + completedAt: upload.completedAt, + } +} + +function tableImportBodyFromUpload(upload: UploadSessionRecord): V2CreateTableImportBody { + if (upload.purpose !== 'table_import' || upload.storageContext !== 'table-import') { + throw new OrchestrationError('conflict', 'Upload is not a table import') + } + const body = v2CreateTableImportBodySchema.parse(upload.metadata.tableImport) + if (body.workspaceId !== upload.workspaceId || body.source.type !== 'upload') { + throw new OrchestrationError('conflict', 'Upload token table import metadata does not match') + } + return body +} + +function importOptions(body: V2CreateTableImportBody): TableImportJobPayload['options'] { + return { + ...(body.mapping ? { mapping: body.mapping } : {}), + ...(body.createColumns ? { createColumns: body.createColumns as string[] } : {}), + ...(body.timezone ? { timezone: body.timezone } : {}), + } +} + +function parseImportJobPayload(payload: unknown): TableImportJobPayload { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('Table import job is missing its payload') + } + const candidate = payload as Partial + if ( + candidate.kind !== 'table_import' || + typeof candidate.userId !== 'string' || + !candidate.options || + typeof candidate.options !== 'object' + ) { + throw new Error('Table import job has an invalid payload') + } + v2TableImportSourceSchema.parse(candidate.source) + v2TableImportTargetSchema.parse(candidate.target) + return candidate as TableImportJobPayload +} + +async function validateTarget(workspaceId: string, target: V2TableImportTarget): Promise { + if (target.type === 'new') { + if (target.folderId && !(await findActiveFolder(target.folderId, workspaceId, 'table'))) { + throw new OrchestrationError('not_found', 'Folder not found in this workspace') + } + return + } + await requireExistingTarget(workspaceId, target) +} + +async function requireExistingTarget( + workspaceId: string, + target: Extract +) { + const table = await getTableById(target.tableId) + if (!table || table.workspaceId !== workspaceId) { + throw new OrchestrationError('not_found', 'Table not found') + } + if (table.archivedAt) { + throw new OrchestrationError('validation', 'Cannot import into an archived table') + } + assertRowInsert(table) + if (target.mode === 'replace') assertRowDelete(table) + return table +} + +async function requireWorkspaceSource( + workspaceId: string, + fileId: string +): Promise { + const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) + if (!file) throw new OrchestrationError('not_found', 'Workspace file not found') + return file +} + +async function assertWorkspaceWrite(userId: string, workspaceId: string): Promise { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission !== 'write' && permission !== 'admin') { + throw new OrchestrationError('forbidden', 'Access denied') + } +} + +function assertCsvFileName(fileName: string): void { + const normalized = fileName.toLowerCase() + if (!normalized.endsWith('.csv') && !normalized.endsWith('.tsv')) { + throw new OrchestrationError('validation', 'Only CSV and TSV files are supported') + } +} + +function tableImportStatus(status: string): TableImportStatus { + if (status !== 'running' && status !== 'ready' && status !== 'failed' && status !== 'canceled') { + throw new Error(`Invalid table import job status: ${status}`) + } + return status +} + +function publicImportStatus(status: TableImportStatus): V2TableImport['status'] { + if (status === 'running') return 'processing' + if (status === 'ready') return 'completed' + return status +} diff --git a/apps/sim/lib/table/orchestration/import.test.ts b/apps/sim/lib/table/orchestration/import.test.ts new file mode 100644 index 00000000000..d511266ad43 --- /dev/null +++ b/apps/sim/lib/table/orchestration/import.test.ts @@ -0,0 +1,235 @@ +/** + * @vitest-environment node + * + * CSV import orchestration — the logic both the first-party and public import + * routes delegate to, so neither can drift on what an import actually does. + */ +import { Readable } from 'node:stream' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockMarkTableJobRunning, + mockReleaseJobClaim, + mockImportAppendRows, + mockImportReplaceRows, + mockGetMaxRowsPerTable, + mockDispatchAfterBatchInsert, + mockSignalSchemaChanged, +} = vi.hoisted(() => ({ + mockMarkTableJobRunning: vi.fn(), + mockReleaseJobClaim: vi.fn(), + mockImportAppendRows: vi.fn(), + mockImportReplaceRows: vi.fn(), + mockGetMaxRowsPerTable: vi.fn(), + mockDispatchAfterBatchInsert: vi.fn(), + mockSignalSchemaChanged: vi.fn(), +})) + +vi.mock('@/lib/table/jobs/service', () => ({ + markTableJobRunning: mockMarkTableJobRunning, + releaseJobClaim: mockReleaseJobClaim, +})) +vi.mock('@/lib/table/import-data', () => ({ + importAppendRows: mockImportAppendRows, + importReplaceRows: mockImportReplaceRows, +})) +vi.mock('@/lib/table/billing', () => ({ + getMaxRowsPerTable: mockGetMaxRowsPerTable, + getWorkspaceTableLimits: vi.fn(), + wouldExceedRowLimit: (limit: number, current: number, added: number) => + limit >= 0 && current + added > limit, +})) +vi.mock('@/lib/table/rows/service', () => ({ + batchInsertRows: vi.fn(), + dispatchAfterBatchInsert: mockDispatchAfterBatchInsert, +})) +vi.mock('@/lib/table/service', () => ({ createTable: vi.fn(), deleteTable: vi.fn() })) +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mockSignalSchemaChanged })) + +import { performTableCsvImport } from '@/lib/table/orchestration/import' + +const TABLE = { + id: 'table-1', + name: 'contacts', + workspaceId: 'ws-1', + rowCount: 10, + archivedAt: null, + jobStatus: null, + schema: { + columns: [ + { id: 'col_email', name: 'email', type: 'string', required: false, unique: false }, + { id: 'col_name', name: 'name', type: 'string', required: false, unique: false }, + ], + }, +} as never + +const CSV = 'email,name\na@b.c,Ann\nd@e.f,Dan\n' + +function csvStream(text = CSV) { + return Readable.from([Buffer.from(text)]) +} + +function importParams(overrides: Record = {}) { + return { + table: TABLE, + workspaceId: 'ws-1', + userId: 'user-1', + fileStream: csvStream(), + fileName: 'contacts.csv', + fallbackDelimiter: ',' as const, + mode: 'append' as const, + timezone: 'UTC', + requestId: 'req-1', + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockMarkTableJobRunning.mockResolvedValue(true) + mockReleaseJobClaim.mockResolvedValue(undefined) + mockGetMaxRowsPerTable.mockResolvedValue(1000) + mockImportAppendRows.mockResolvedValue({ + inserted: [{ id: 'row-1' }, { id: 'row-2' }], + table: TABLE, + }) + mockImportReplaceRows.mockResolvedValue({ insertedCount: 2, deletedCount: 10 }) +}) + +describe('performTableCsvImport', () => { + it('auto-maps same-named headers and appends the parsed rows', async () => { + const result = await performTableCsvImport(importParams()) + + expect(result.success).toBe(true) + expect(result.data).toEqual({ + tableId: 'table-1', + mode: 'append', + insertedCount: 2, + mappedColumns: ['email', 'name'], + skippedHeaders: [], + unmappedColumns: [], + sourceFile: 'contacts.csv', + }) + // The trigger/scheduler fan-out must run AFTER the tx commits, so it is the + // orchestration's job rather than the writer's. + expect(mockDispatchAfterBatchInsert).toHaveBeenCalled() + expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1') + }) + + it('reports the deleted count on a replace', async () => { + const result = await performTableCsvImport(importParams({ mode: 'replace' })) + + expect(result.data).toMatchObject({ mode: 'replace', insertedCount: 2, deletedCount: 10 }) + expect(mockImportReplaceRows).toHaveBeenCalled() + expect(mockImportAppendRows).not.toHaveBeenCalled() + }) + + it('holds the table job slot for the write and releases it before returning', async () => { + await performTableCsvImport(importParams()) + + expect(mockMarkTableJobRunning).toHaveBeenCalledWith('table-1', expect.any(String), 'import') + // Released before the response, so a client refetch never observes the claim. + expect(mockReleaseJobClaim).toHaveBeenCalledWith('table-1', expect.any(String)) + }) + + it('releases the claim even when the write throws', async () => { + mockImportAppendRows.mockRejectedValue(new Error('boom')) + + const result = await performTableCsvImport(importParams()) + + expect(result.success).toBe(false) + expect(result.errorCode).toBe('internal') + expect(mockReleaseJobClaim).toHaveBeenCalled() + }) + + it('refuses when another job already holds the slot', async () => { + mockMarkTableJobRunning.mockResolvedValue(false) + + const result = await performTableCsvImport(importParams()) + + expect(result).toMatchObject({ success: false, errorCode: 'conflict' }) + expect(mockImportAppendRows).not.toHaveBeenCalled() + // Nothing was claimed, so nothing may be released — releasing here would + // free the *other* job's slot. + expect(mockReleaseJobClaim).not.toHaveBeenCalled() + }) + + it('refuses an import that would exceed the plan row limit, before writing', async () => { + mockGetMaxRowsPerTable.mockResolvedValue(11) + + const result = await performTableCsvImport(importParams()) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.error).toContain('exceed table row limit') + expect(mockImportAppendRows).not.toHaveBeenCalled() + }) + + it('rejects an archived table and a table with a job already running', async () => { + const archived = await performTableCsvImport( + importParams({ table: { ...TABLE, archivedAt: new Date() } }) + ) + expect(archived).toMatchObject({ success: false, errorCode: 'validation' }) + + const busy = await performTableCsvImport( + importParams({ table: { ...TABLE, jobStatus: 'running' } }) + ) + expect(busy).toMatchObject({ success: false, errorCode: 'conflict' }) + + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('rejects a file with no data rows', async () => { + const result = await performTableCsvImport( + importParams({ fileStream: csvStream('email,name\n') }) + ) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.error).toBe('CSV file has no data rows') + }) + + it('rejects a file whose headers map to nothing on the table', async () => { + const result = await performTableCsvImport( + importParams({ fileStream: csvStream('alpha,beta\n1,2\n') }) + ) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.error).toContain('No CSV headers map to columns') + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('reports which headers were skipped and which columns went unfilled', async () => { + const result = await performTableCsvImport( + importParams({ + fileStream: csvStream('email,notes\na@b.c,hi\n'), + mapping: { email: 'email', notes: null }, + }) + ) + + expect(result.data).toMatchObject({ + mappedColumns: ['email'], + skippedHeaders: ['notes'], + unmappedColumns: ['name'], + }) + }) + + it('rejects createColumns naming a header the file does not have', async () => { + const result = await performTableCsvImport(importParams({ createColumns: ['phone'] })) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result.error).toContain('unknown CSV headers') + }) + + it('creates the requested columns with ids the coerced rows already key by', async () => { + const result = await performTableCsvImport( + importParams({ fileStream: csvStream('email,phone\na@b.c,555\n'), createColumns: ['phone'] }) + ) + + expect(result.success).toBe(true) + const [, additions, rows] = mockImportAppendRows.mock.calls[0] + expect(additions).toEqual([{ id: expect.any(String), name: 'phone', type: expect.any(String) }]) + // The id is pre-assigned so the prospective schema used to coerce and the + // column the write creates share one key — otherwise the values land under + // a key nothing reads. + expect(Object.keys(rows[0])).toContain(additions[0].id) + }) +}) diff --git a/apps/sim/lib/table/orchestration/import.ts b/apps/sim/lib/table/orchestration/import.ts new file mode 100644 index 00000000000..7739d5e473b --- /dev/null +++ b/apps/sim/lib/table/orchestration/import.ts @@ -0,0 +1,514 @@ +import type { Readable } from 'node:stream' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { + getMaxRowsPerTable, + getWorkspaceTableLimits, + wouldExceedRowLimit, +} from '@/lib/table/billing' +import { generateColumnId } from '@/lib/table/column-keys' +import { TABLE_LIMITS } from '@/lib/table/constants' +import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' +import { signalTableSchemaChanged } from '@/lib/table/events' +import { + buildAutoMapping, + CSV_MAX_BATCH_SIZE, + CSV_SCHEMA_SAMPLE_SIZE, + type CsvDelimiter, + type CsvHeaderMapping, + CsvImportValidationError, + coerceRowsForTable, + createCsvParser, + inferColumnType, + inferSchemaFromCsv, + sanitizeName, + validateMapping, +} from '@/lib/table/import' +import { importAppendRows, importReplaceRows } from '@/lib/table/import-data' +import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { batchInsertRows, dispatchAfterBatchInsert } from '@/lib/table/rows/service' +import { createTable, deleteTable } from '@/lib/table/service' +import type { RowData, TableDefinition, TableLockKind, TableSchema } from '@/lib/table/types' + +const logger = createLogger('TableImportOrchestration') + +/** + * CSV import orchestration. + * + * Both entry points own the whole import: they consume the caller's file + * stream, sniff the separator, parse, map, coerce, claim the table's job slot, + * and write. Routes are left holding only transport concerns — reading the + * multipart body, authorizing, and rendering the result — so the v1 and v2 + * surfaces cannot drift on what an import actually does. + */ + +interface ImportFailure { + success: false + error: string + errorCode: OrchestrationErrorCode + details?: unknown + /** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */ + lock?: TableLockKind +} + +function fail(error: string, errorCode: OrchestrationErrorCode, details?: unknown): ImportFailure { + return { success: false, error, errorCode, ...(details !== undefined ? { details } : {}) } +} + +/** + * Classifies a write failure raised inside an import. A lock rejection is a + * 423 and `TableLockedError` is not an `OrchestrationError`, so it needs its + * own branch; anything unclassified is a server fault whose message must not + * reach the caller. + * + * A lock rejection carries its `lock` kind through, because "locked" alone does + * not tell a caller which of the four flags to clear. + */ +function classifyImportFailure(error: unknown, requestId: string, tableId: string): ImportFailure { + if (error instanceof TableLockedError) { + return { ...fail(error.message, 'locked'), lock: error.lock } + } + if (error instanceof OrchestrationError) return fail(error.message, error.code) + logger.error(`[${requestId}] CSV import failed for table ${tableId}`, { error }) + return fail(toError(error).message, 'internal') +} + +/** + * Drains a CSV/TSV stream into memory. The extension only picks the fallback — + * the separator is sniffed from the file's head so semicolon/pipe exports + * (European-locale Excel) don't land in one column. + */ +async function readCsvRows( + fileStream: Readable, + fallbackDelimiter: CsvDelimiter +): Promise<{ headers: string[]; rows: Record[] }> { + const { delimiter, stream } = await sniffCsvDelimiterFromStream(fileStream, fallbackDelimiter) + + let headers: string[] = [] + const parser = createCsvParser(delimiter, (parsedHeaders) => { + headers = parsedHeaders + }) + // `.pipe` doesn't forward source errors; forward them so the iterator throws. + stream.on('error', (streamError) => parser.destroy(streamError)) + stream.pipe(parser) + + const rows: Record[] = [] + for await (const record of parser as AsyncIterable>) { + rows.push(record) + } + return { headers, rows } +} + +/** + * Resolves `createColumns` into pending column definitions plus the schema the + * rows should be coerced against. Ids are pre-assigned so the prospective + * schema and the columns the write actually creates share the same keys — the + * coerced rows are keyed by id before those columns exist. + */ +function planNewColumns( + table: TableDefinition, + headers: string[], + createColumns: string[], + mapping: CsvHeaderMapping, + rows: Record[] +): + | { + ok: true + additions: { id: string; name: string; type: string }[] + schema: TableSchema + mapping: CsvHeaderMapping + } + | { ok: false; failure: ImportFailure } { + const headerSet = new Set(headers) + const unknownHeaders = createColumns.filter((header) => !headerSet.has(header)) + if (unknownHeaders.length > 0) { + return { + ok: false, + failure: fail( + `createColumns references unknown CSV headers: ${unknownHeaders.join(', ')}`, + 'validation' + ), + } + } + + const usedNames = new Set(table.schema.columns.map((column) => column.name.toLowerCase())) + const updatedMapping: CsvHeaderMapping = { ...mapping } + const additions: { id: string; name: string; type: string }[] = [] + const newColumns: TableSchema['columns'] = [] + + for (const header of createColumns) { + const base = sanitizeName(header) + let columnName = base + let suffix = 2 + while (usedNames.has(columnName.toLowerCase())) { + columnName = `${base}_${suffix}` + suffix++ + } + usedNames.add(columnName.toLowerCase()) + const inferredType = inferColumnType(rows.map((row) => row[header])) + const id = generateColumnId() + additions.push({ id, name: columnName, type: inferredType }) + newColumns.push({ + id, + name: columnName, + type: inferredType as TableSchema['columns'][number]['type'], + required: false, + unique: false, + }) + updatedMapping[header] = columnName + } + + return { + ok: true, + additions, + schema: { columns: [...table.schema.columns, ...newColumns] }, + mapping: updatedMapping, + } +} + +export interface PerformTableCsvImportParams { + table: TableDefinition + workspaceId: string + userId: string + /** Multipart file stream. The caller still owns destroying it. */ + fileStream: Readable + fileName: string + /** Separator to fall back to when sniffing is inconclusive. */ + fallbackDelimiter: CsvDelimiter + mode: 'append' | 'replace' + /** Explicit CSV header → column name map. Auto-derived from the schema when omitted. */ + mapping?: CsvHeaderMapping + /** CSV headers to create as new columns on the table before importing. */ + createColumns?: string[] + /** IANA zone used to read naive datetimes (Excel/Sheets exports carry no offset). */ + timezone: string + requestId?: string +} + +export interface TableCsvImportData { + tableId: string + mode: 'append' | 'replace' + insertedCount: number + /** Replace mode only — rows removed before the insert. */ + deletedCount?: number + mappedColumns: string[] + skippedHeaders: string[] + unmappedColumns: string[] + sourceFile: string +} + +export interface PerformTableCsvImportResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + /** Per-header mapping issues, when the failure is a mapping validation. */ + details?: unknown + /** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */ + lock?: TableLockKind + data?: TableCsvImportData +} + +/** + * Imports a CSV into an EXISTING table, appending or replacing its rows. + * + * The table's single write-job slot is claimed for the whole write and released + * before returning. The claim is the real concurrency gate — the `jobStatus` + * pre-check reads a snapshot taken before the parse, and a background import + * can start in that window; without the claim a synchronous and a background + * import would interleave and corrupt a replace. + */ +export async function performTableCsvImport( + params: PerformTableCsvImportParams +): Promise { + const { table, workspaceId, userId, fileStream, fileName, fallbackDelimiter, mode, timezone } = + params + const requestId = params.requestId ?? generateRequestId() + + if (table.archivedAt) return fail('Cannot import into an archived table', 'validation') + if (table.jobStatus === 'running') { + return fail('A job is already in progress for this table', 'conflict') + } + + const { headers, rows } = await readCsvRows(fileStream, fallbackDelimiter) + if (rows.length === 0) return fail('CSV file has no data rows', 'validation') + + let effectiveMapping = params.mapping ?? buildAutoMapping(headers, table.schema) + let prospectiveSchema = table.schema + let additions: { id: string; name: string; type: string }[] = [] + + if (params.createColumns && params.createColumns.length > 0) { + const planned = planNewColumns(table, headers, params.createColumns, effectiveMapping, rows) + if (!planned.ok) return planned.failure + additions = planned.additions + prospectiveSchema = planned.schema + effectiveMapping = planned.mapping + } + + let validation: ReturnType + try { + validation = validateMapping({ + csvHeaders: headers, + mapping: effectiveMapping, + tableSchema: prospectiveSchema, + }) + } catch (error) { + if (error instanceof CsvImportValidationError) { + return fail(error.message, 'validation', error.details) + } + throw error + } + + if (validation.mappedHeaders.length === 0) { + return fail( + `No CSV headers map to columns on the table. CSV headers: ${headers.join(', ')}. Table columns: ${prospectiveSchema.columns + .map((column) => column.name) + .join(', ')}`, + 'validation' + ) + } + + const coerced = coerceRowsForTable(rows, prospectiveSchema, validation.effectiveMap, { timezone }) + + const importId = generateId() + if (!(await markTableJobRunning(table.id, importId, 'import'))) { + return fail('A job is already in progress for this table', 'conflict') + } + + const summary = { + tableId: table.id, + mode, + mappedColumns: validation.mappedHeaders, + skippedHeaders: validation.skippedHeaders, + unmappedColumns: validation.unmappedColumns, + sourceFile: fileName, + } + + try { + if (mode === 'append') { + const maxRows = await getMaxRowsPerTable(workspaceId) + if (wouldExceedRowLimit(maxRows, table.rowCount, coerced.length)) { + const deficit = table.rowCount + coerced.length - maxRows + return fail( + `Append would exceed table row limit (${maxRows}). Currently ${table.rowCount} rows, ${coerced.length} new rows, ${deficit} over.`, + 'validation' + ) + } + + const { inserted, table: finalTable } = await importAppendRows(table, additions, coerced, { + workspaceId, + userId, + requestId, + }) + // Fire trigger + scheduler AFTER the tx commits — both read through the + // global db connection and would otherwise see no rows. + dispatchAfterBatchInsert(finalTable, inserted, requestId, userId) + + logger.info(`[${requestId}] Append CSV imported`, { + tableId: table.id, + fileName, + inserted: inserted.length, + createdColumns: additions.length, + }) + signalTableSchemaChanged(table.id) + + return { success: true, data: { ...summary, insertedCount: inserted.length } } + } + + const result = await importReplaceRows( + table, + additions, + { rows: coerced, workspaceId, userId }, + requestId + ) + + logger.info(`[${requestId}] Replace CSV imported`, { + tableId: table.id, + fileName, + deleted: result.deletedCount, + inserted: result.insertedCount, + createdColumns: additions.length, + }) + signalTableSchemaChanged(table.id) + + return { + success: true, + data: { + ...summary, + insertedCount: result.insertedCount, + deletedCount: result.deletedCount, + }, + } + } catch (error) { + return classifyImportFailure(error, requestId, table.id) + } finally { + // Release before returning, so a client refetch never observes the transient claim. + await releaseJobClaim(table.id, importId).catch(() => {}) + } +} + +export interface PerformCreateTableFromCsvParams { + workspaceId: string + userId: string + /** Multipart file stream. The caller still owns destroying it. */ + fileStream: Readable + fileName: string + fallbackDelimiter: CsvDelimiter + /** Folder to create the table in; `null` creates it at the workspace root. */ + folderId: string | null + timezone: string + requestId?: string +} + +export interface CreatedTableFromCsv { + id: string + name: string + description: string | null + schema: TableSchema + rowCount: number +} + +export interface PerformCreateTableFromCsvResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + data?: { table: CreatedTableFromCsv } +} + +/** + * Creates a NEW table from a CSV and streams its rows in. + * + * Unlike {@link performTableCsvImport} this never buffers the whole file: it + * infers the schema from the first {@link CSV_SCHEMA_SAMPLE_SIZE} records, + * creates the table, then inserts in batches as records arrive. A failure part + * way through drops the half-populated table rather than leaving it behind. + */ +export async function performCreateTableFromCsv( + params: PerformCreateTableFromCsvParams +): Promise { + const { workspaceId, userId, fileStream, fileName, fallbackDelimiter, folderId, timezone } = + params + const requestId = params.requestId ?? generateRequestId() + + const { delimiter, stream } = await sniffCsvDelimiterFromStream(fileStream, fallbackDelimiter) + + let csvHeaders: string[] = [] + const parser = createCsvParser(delimiter, (headers) => { + csvHeaders = headers + }) + stream.on('error', (streamError) => parser.destroy(streamError)) + stream.pipe(parser) + + interface ImportState { + table: TableDefinition + schema: TableSchema + headerToColumn: Map + } + + const insertRows = async ( + batch: Record[], + state: ImportState, + currentRowCount: number + ): Promise => { + if (batch.length === 0) return 0 + const coerced = coerceRowsForTable(batch, state.schema, state.headerToColumn, { timezone }) + const inserted = await batchInsertRows( + { tableId: state.table.id, rows: coerced as RowData[], workspaceId, userId }, + // The created table's rowCount is frozen at 0; pass the running total so the + // per-batch capacity check sees cumulative rows, not an always-empty table. + { ...state.table, rowCount: currentRowCount }, + generateId().slice(0, 8) + ) + return inserted.length + } + + /** Infer the schema from the buffered sample and create the (empty) table. */ + const buildTable = async (sampleRows: Record[]): Promise => { + const inferred = inferSchemaFromCsv(csvHeaders, sampleRows) + // Inference emits only `{ name, type }`; the stored schema carries the + // constraint flags explicitly so a later read never has to guess a default. + const columns = inferred.columns.map((column) => ({ + ...column, + required: false, + unique: false, + })) + const planLimits = await getWorkspaceTableLimits(workspaceId) + const tableName = sanitizeName(fileName.replace(/\.[^.]+$/, ''), 'imported_table').slice( + 0, + TABLE_LIMITS.MAX_TABLE_NAME_LENGTH + ) + const table = await createTable( + { + name: tableName, + description: `Imported from ${fileName}`, + schema: { columns }, + workspaceId, + folderId, + userId, + maxTables: planLimits.maxTables, + }, + requestId + ) + // Coerce against the *created* schema so rows key by the ids `createTable` + // assigned (the inferred schema above is id-less). + return { table, schema: table.schema, headerToColumn: inferred.headerToColumn } + } + + let state: ImportState | null = null + let inserted = 0 + const sample: Record[] = [] + let batch: Record[] = [] + + try { + for await (const record of parser as AsyncIterable>) { + if (!state) { + sample.push(record) + if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE) { + state = await buildTable(sample) + inserted += await insertRows(sample, state, inserted) + } + continue + } + batch.push(record) + if (batch.length >= CSV_MAX_BATCH_SIZE) { + inserted += await insertRows(batch, state, inserted) + batch = [] + } + } + + if (!state) { + if (sample.length === 0) return fail('CSV file has no data rows', 'validation') + state = await buildTable(sample) + inserted += await insertRows(sample, state, inserted) + } else { + inserted += await insertRows(batch, state, inserted) + } + } catch (error) { + // A half-populated table from a mid-stream failure is worse than none. + if (state) await deleteTable(state.table.id, requestId).catch(() => {}) + return classifyImportFailure(error, requestId, state?.table.id ?? 'unknown') + } + + logger.info(`[${requestId}] CSV imported`, { + tableId: state.table.id, + fileName, + columns: state.schema.columns.length, + rows: inserted, + }) + + return { + success: true, + data: { + table: { + id: state.table.id, + name: state.table.name, + description: state.table.description ?? null, + schema: state.schema, + rowCount: inserted, + }, + }, + } +} diff --git a/apps/sim/lib/table/orchestration/index.ts b/apps/sim/lib/table/orchestration/index.ts index b1ea82abddf..9fa267d7f33 100644 --- a/apps/sim/lib/table/orchestration/index.ts +++ b/apps/sim/lib/table/orchestration/index.ts @@ -1,4 +1,5 @@ export { performUpdateTableColumn } from './columns' +export { performCreateTableFromCsv, performTableCsvImport } from './import' export { performRestoreTable } from './restore' export { performDeleteTable, diff --git a/apps/sim/lib/table/orchestration/tables.test.ts b/apps/sim/lib/table/orchestration/tables.test.ts index 09127e8e40b..33cb68566ab 100644 --- a/apps/sim/lib/table/orchestration/tables.test.ts +++ b/apps/sim/lib/table/orchestration/tables.test.ts @@ -85,7 +85,7 @@ describe('performDeleteTable', () => { const result = await performDeleteTable({ table: TABLE, userId: 'user-1' }) - expect(result).toMatchObject({ success: false, errorCode: 'locked' }) + expect(result).toMatchObject({ success: false, errorCode: 'locked', lock: 'delete' }) expect(mockCaptureServerEvent).not.toHaveBeenCalled() }) }) @@ -129,7 +129,10 @@ describe('performDeleteTableRow', () => { it('classifies a delete lock as locked', async () => { mockDeleteRow.mockRejectedValue(new TableLockedError('delete')) - expect((await performDeleteTableRow({ table: TABLE, rowId: 'row-1' })).errorCode).toBe('locked') + const rowResult = await performDeleteTableRow({ table: TABLE, rowId: 'row-1' }) + expect(rowResult.errorCode).toBe('locked') + // The kind rides along so the route can name which flag to clear. + expect(rowResult.lock).toBe('delete') }) it('classifies a missing row as not_found', async () => { diff --git a/apps/sim/lib/table/orchestration/tables.ts b/apps/sim/lib/table/orchestration/tables.ts index dcec50a25cc..3c0abcf6ae0 100644 --- a/apps/sim/lib/table/orchestration/tables.ts +++ b/apps/sim/lib/table/orchestration/tables.ts @@ -15,6 +15,7 @@ import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS, type TableDefinition, + type TableLockKind, type TableLocks, } from '@/lib/table/types' @@ -32,6 +33,8 @@ export interface PerformDeleteTableResult { success: boolean error?: string errorCode?: OrchestrationErrorCode + /** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */ + lock?: TableLockKind } /** @@ -54,7 +57,7 @@ export async function performDeleteTable( ;({ archived } = await deleteTable(table.id, requestId)) } catch (error) { if (error instanceof TableLockedError) { - return { success: false, error: error.message, errorCode: 'locked' } + return { success: false, error: error.message, errorCode: 'locked', lock: error.lock } } if (error instanceof OrchestrationError) { return { success: false, error: error.message, errorCode: error.code } @@ -98,6 +101,8 @@ export interface PerformDeleteTableRowResult { success: boolean error?: string errorCode?: OrchestrationErrorCode + /** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */ + lock?: TableLockKind } /** @@ -116,7 +121,7 @@ export async function performDeleteTableRow( return { success: true } } catch (error) { if (error instanceof TableLockedError) { - return { success: false, error: error.message, errorCode: 'locked' } + return { success: false, error: error.message, errorCode: 'locked', lock: error.lock } } if (error instanceof OrchestrationError) { return { success: false, error: error.message, errorCode: error.code } @@ -139,12 +144,19 @@ export interface PerformTableMutationResult { success: boolean error?: string errorCode?: OrchestrationErrorCode + /** Which lock rejected the write. Set only when `errorCode` is `'locked'`. */ + lock?: TableLockKind table?: TableDefinition } function classifyTableMutation(error: unknown, requestId: string, tableId: string) { if (error instanceof TableLockedError) { - return { success: false as const, error: error.message, errorCode: 'locked' as const } + return { + success: false as const, + error: error.message, + errorCode: 'locked' as const, + lock: error.lock, + } } // `TableConflictError` is an `OrchestrationError('conflict')`, so a duplicate // rename reaches 409 through this branch — by class, not by the message diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index dd41b525317..3cfb06a5842 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -431,6 +431,7 @@ export async function createTable( workspaceId: data.workspaceId, type: initialJob.type, status: 'running', + payload: data.jobPayload ?? null, startedAt: initialJob.startedAt, updatedAt: initialJob.startedAt, }) diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 40231c77900..fbe997dd29f 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -348,16 +348,31 @@ export interface TableUpdateJobPayload { maxRows?: number } +export type TableExportFormat = 'csv' | 'json' + /** * Persisted scope of an export job (`table_jobs.payload`). `resultKey` is merged in by the worker * on completion — the storage key of the generated file, served to the client via a presigned URL * and deleted by the janitor when the terminal job is pruned. */ export interface TableExportJobPayload { - format: 'csv' | 'json' + format: TableExportFormat resultKey?: string } +/** Durable import descriptor stored on the existing `table_jobs` row. */ +export interface TableImportJobPayload { + kind: 'table_import' + userId: string + source: unknown + target: unknown + options: { + mapping?: unknown + createColumns?: string[] + timezone?: string + } +} + /** * Keyset cursor for paginating a table's default row order, `(order_key, id)`. The grid's * infinite scroll threads this instead of an OFFSET — offset paging re-scans every prior row per @@ -644,6 +659,8 @@ export interface CreateTableData { jobType?: TableJobType /** Async job id stamped on the table when `jobStatus` is set. */ jobId?: string + /** Type-specific payload stored on the initial async job. */ + jobPayload?: unknown } export interface InsertRowData { diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index 0033d7b2ce2..24dde87c3fa 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -16,6 +16,7 @@ vi.mock('@/lib/table/events', () => ({ import { createTableView, deleteTableView, + getTableView, normalizeStoredViewConfig, pruneViewConfig, updateTableView, @@ -184,3 +185,37 @@ describe('table-view mutations signal collaborators', () => { expect(mockSignalTableViewsChanged).not.toHaveBeenCalled() }) }) + +describe('getTableView', () => { + const columns: ColumnDefinition[] = [{ id: 'col_a', name: 'Name', type: 'text' }] + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('prunes stale column references the same way the list read does', async () => { + queueTableRows(tableViews, [ + { + id: 'view-1', + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'My View', + config: { columnOrder: ['col_a', 'col_gone'], hiddenColumns: ['col_gone'] }, + isDefault: false, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ]) + + const view = await getTableView('view-1', 'table-1', columns) + + expect(view?.config.columnOrder).toEqual(['col_a']) + expect(view?.config.hiddenColumns).toEqual([]) + }) + + it('returns null for a view id that is not on this table', async () => { + expect(await getTableView('view-elsewhere', 'table-1', columns)).toBeNull() + }) +}) diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index 1487be7dd35..bd3fb64963e 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -151,6 +151,21 @@ export async function listTableViews( return rows.map((row) => toTableView(row, columns)) } +/** One view by id, scoped to its table, or `null` when it doesn't exist there. */ +export async function getTableView( + viewId: string, + tableId: string, + columns: ColumnDefinition[] +): Promise { + const [row] = await db + .select() + .from(tableViews) + .where(and(eq(tableViews.id, viewId), eq(tableViews.tableId, tableId))) + .limit(1) + + return row ? toTableView(row, columns) : null +} + function normalizeName(name: string): string { const trimmed = name.trim() if (!trimmed) throw new TableViewValidationError('View name cannot be empty') diff --git a/apps/sim/lib/uploads/client/multipart-session.test.ts b/apps/sim/lib/uploads/client/multipart-session.test.ts new file mode 100644 index 00000000000..0f8b495a2b1 --- /dev/null +++ b/apps/sim/lib/uploads/client/multipart-session.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { V2CompletedPart } from '@/lib/api/contracts/v2/uploads' +import { uploadMultipartSession } from '@/lib/uploads/client/multipart-session' + +describe('uploadMultipartSession', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('requests fresh URL batches and completes with every uploaded part in order', async () => { + const file = new File(['abcdefghijklmnopqrstuvwxyz'], 'letters.txt') + const getPartUrls = vi.fn(async (partNumbers: number[]) => + partNumbers.map((partNumber) => ({ + partNumber, + url: `https://storage.example/part/${partNumber}`, + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt: '2026-08-03T22:00:00.000Z', + })) + ) + const complete = vi.fn(async (parts: V2CompletedPart[]) => parts) + const abort = vi.fn(async () => {}) + const onProgress = vi.fn() + vi.stubGlobal( + 'fetch', + vi.fn( + async (_url: string) => new Response(null, { status: 200, headers: { etag: '"etag"' } }) + ) + ) + + const result = await uploadMultipartSession({ + file, + partSize: 1, + partCount: 26, + getPartUrls, + complete, + abort, + onProgress, + }) + + expect(getPartUrls).toHaveBeenCalledTimes(2) + expect(getPartUrls.mock.calls[0][0]).toEqual( + Array.from({ length: 25 }, (_, index) => index + 1) + ) + expect(getPartUrls.mock.calls[1][0]).toEqual([26]) + expect(result).toHaveLength(26) + expect(result[0]).toEqual({ partNumber: 1, etag: 'etag' }) + expect(result[25]).toEqual({ partNumber: 26, etag: 'etag' }) + expect(onProgress).toHaveBeenLastCalledWith({ loaded: 26, total: 26, percent: 100 }) + expect(abort).not.toHaveBeenCalled() + }) + + it('aborts the signed session when a part upload is aborted', async () => { + const file = new File(['part'], 'part.txt') + const complete = vi.fn() + const abort = vi.fn(async () => {}) + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new DOMException('The operation was aborted', 'AbortError') + }) + ) + + await expect( + uploadMultipartSession({ + file, + partSize: 4, + partCount: 1, + getPartUrls: async () => [ + { + partNumber: 1, + url: 'https://storage.example/part/1', + headers: {}, + expiresAt: '2026-08-03T22:00:00.000Z', + }, + ], + complete, + abort, + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(abort).toHaveBeenCalledTimes(1) + expect(complete).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/uploads/client/multipart-session.ts b/apps/sim/lib/uploads/client/multipart-session.ts new file mode 100644 index 00000000000..2c3b1da05fd --- /dev/null +++ b/apps/sim/lib/uploads/client/multipart-session.ts @@ -0,0 +1,88 @@ +import { sleep } from '@sim/utils/helpers' +import type { V2CompletedPart, V2UploadPartUrl } from '@/lib/api/contracts/v2/uploads' +import { + MULTIPART_MAX_RETRIES, + MULTIPART_PART_CONCURRENCY, + MULTIPART_RETRY_BACKOFF, + MULTIPART_RETRY_DELAY_MS, + runWithConcurrency, + type UploadProgressEvent, +} from '@/lib/uploads/client/direct-upload' +import { isAbortError } from '@/lib/uploads/utils/file-utils' + +interface UploadMultipartSessionParams { + file: File + partSize: number + partCount: number + signal?: AbortSignal + onProgress?: (event: UploadProgressEvent) => void + getPartUrls: (partNumbers: number[]) => Promise + complete: (parts: V2CompletedPart[]) => Promise + abort: () => Promise +} + +export async function uploadMultipartSession( + params: UploadMultipartSessionParams +): Promise { + const { file, partSize, partCount, signal, onProgress } = params + const completedBytes = new Array(partCount).fill(0) + const completedParts: V2CompletedPart[] = [] + try { + for (let start = 1; start <= partCount; start += 25) { + const partNumbers = Array.from( + { length: Math.min(25, partCount - start + 1) }, + (_, index) => start + index + ) + const partUrls = await params.getPartUrls(partNumbers) + const results = await runWithConcurrency( + partUrls, + MULTIPART_PART_CONCURRENCY, + async (part): Promise => { + const partStart = (part.partNumber - 1) * partSize + const end = Math.min(partStart + partSize, file.size) + const chunk = file.slice(partStart, end) + for (let attempt = 0; attempt <= MULTIPART_MAX_RETRIES; attempt++) { + try { + // boundary-raw-fetch: signed multipart data-plane URL may target cloud storage or local Sim + const response = await fetch(part.url, { + method: 'PUT', + body: chunk, + headers: part.headers, + signal, + }) + if (!response.ok) { + throw new Error(`Part ${part.partNumber} failed (${response.status})`) + } + completedBytes[part.partNumber - 1] = end - partStart + const loaded = completedBytes.reduce((sum, bytes) => sum + bytes, 0) + onProgress?.({ + loaded, + total: file.size, + percent: Math.min(100, Math.round((loaded / file.size) * 100)), + }) + const etag = response.headers.get('etag') + return { + partNumber: part.partNumber, + ...(etag ? { etag: etag.replaceAll('"', '') } : {}), + } + } catch (error) { + if (isAbortError(error) || attempt >= MULTIPART_MAX_RETRIES) throw error + await sleep(MULTIPART_RETRY_DELAY_MS * MULTIPART_RETRY_BACKOFF ** attempt) + } + } + throw new Error(`Retries exhausted for part ${part.partNumber}`) + } + ) + completedParts.push( + ...results.map((result) => { + if (result.status === 'rejected') throw result.reason + return result.value + }) + ) + } + return await params.complete(completedParts) + } catch (error) { + await params.abort().catch(() => {}) + throw error + } +} diff --git a/apps/sim/lib/uploads/client/session-upload.ts b/apps/sim/lib/uploads/client/session-upload.ts new file mode 100644 index 00000000000..eecc4237419 --- /dev/null +++ b/apps/sim/lib/uploads/client/session-upload.ts @@ -0,0 +1,68 @@ +import { requestJson } from '@/lib/api/client/request' +import { + abortWorkspaceFileUploadContract, + completeWorkspaceFileUploadContract, + createWorkspaceFileUploadContract, + createWorkspaceFileUploadPartUrlsContract, +} from '@/lib/api/contracts/upload-sessions' +import type { UploadProgressEvent } from '@/lib/uploads/client/direct-upload' +import { uploadMultipartSession } from '@/lib/uploads/client/multipart-session' +import { getFileContentType } from '@/lib/uploads/utils/file-utils' + +interface UploadWorkspaceFileSessionParams { + workspaceId: string + folderId?: string | null + file: File + signal?: AbortSignal + onProgress?: (event: UploadProgressEvent) => void +} + +export async function uploadWorkspaceFileSession(params: UploadWorkspaceFileSessionParams) { + const { workspaceId, folderId, file, signal, onProgress } = params + const created = await requestJson(createWorkspaceFileUploadContract, { + body: { + workspaceId, + name: file.name, + contentType: getFileContentType(file), + size: file.size, + ...(folderId ? { folderId } : {}), + }, + signal, + }) + const upload = created.data + return uploadMultipartSession({ + file, + partSize: upload.partSize, + partCount: upload.partCount, + signal, + onProgress, + getPartUrls: async (partNumbers) => { + const batch = await requestJson(createWorkspaceFileUploadPartUrlsContract, { + params: { uploadId: upload.id }, + query: { workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + body: { partNumbers }, + signal, + }) + return batch.data.parts + }, + complete: async (parts) => { + const completed = await requestJson(completeWorkspaceFileUploadContract, { + params: { uploadId: upload.id }, + query: { workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + body: { parts }, + signal, + }) + if (!completed.data.file) throw new Error('Completed upload returned no workspace file') + return completed.data.file + }, + abort: async () => { + await requestJson(abortWorkspaceFileUploadContract, { + params: { uploadId: upload.id }, + query: { workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + }) + }, + }) +} diff --git a/apps/sim/lib/uploads/config.ts b/apps/sim/lib/uploads/config.ts index 10cb9a2eff7..55cf13d7803 100644 --- a/apps/sim/lib/uploads/config.ts +++ b/apps/sim/lib/uploads/config.ts @@ -226,6 +226,7 @@ function getS3Config(context: StorageContext): StorageConfig { } case 'mothership': case 'workspace': + case 'table-import': return { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region, @@ -288,6 +289,7 @@ function getBlobConfig(context: StorageContext): StorageConfig { } case 'mothership': case 'workspace': + case 'table-import': return { accountName: BLOB_CONFIG.accountName, accountKey: BLOB_CONFIG.accountKey, @@ -347,6 +349,7 @@ function getGcsConfig(context: StorageContext): StorageConfig { return { bucket: GCS_EXECUTION_FILES_CONFIG.bucket || GCS_CONFIG.bucket } case 'mothership': case 'workspace': + case 'table-import': return { bucket: GCS_CONFIG.bucket } case 'profile-pictures': return { bucket: GCS_PROFILE_PICTURES_CONFIG.bucket || GCS_CONFIG.bucket } 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 6e141853a9a..368bf422e90 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -9,7 +9,7 @@ import { workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' -import { and, eq, isNotNull, isNull, type SQL } from 'drizzle-orm' +import { and, eq, isNotNull, isNull, type SQL, sql } from 'drizzle-orm' import type { ShareRecord } from '@/lib/api/contracts/public-shares' import type { V2FileSortBy } from '@/lib/api/contracts/v2/files' import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' @@ -43,11 +43,10 @@ import { getServePathPrefix } from '@/lib/uploads' import { deleteFile, downloadFile, - hasCloudStorage, headObject, uploadFile, } from '@/lib/uploads/core/storage-service' -import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' +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' @@ -175,6 +174,10 @@ interface WorkspaceFileMetadataInsert { size: number } +function workspaceFileSize(file: typeof workspaceFiles.$inferSelect): number { + return file.sizeBytes ?? file.size +} + /** * Attempts one active workspace-file insert and reports the row that this call * created. Conflict losers receive `undefined` and must inspect the active key @@ -188,6 +191,8 @@ async function insertWorkspaceFileMetadataInTx( .insert(workspaceFiles) .values({ ...metadata, + size: toLegacyWorkspaceFileSize(metadata.size), + sizeBytes: metadata.size, context: 'workspace', displayName: metadata.originalName, deletedAt: null, @@ -267,7 +272,7 @@ function isSameWorkspaceFileRegistration( file.folderId === params.folderId && file.context === 'workspace' && file.contentType === params.contentType && - file.size === params.size && + workspaceFileSize(file) === params.size && file.deletedAt === null ) } @@ -489,10 +494,6 @@ export async function registerUploadedWorkspaceFile(params: { const { workspaceId, userId, key, originalName, contentType } = params const normalizedOriginalName = normalizeWorkspaceFileItemName(originalName, 'File') - if (!hasCloudStorage()) { - throw new Error('Direct-upload registration requires cloud storage') - } - if (parseWorkspaceFileKey(key) !== workspaceId) { throw new Error('Storage key does not belong to this workspace') } @@ -528,7 +529,7 @@ export async function registerUploadedWorkspaceFile(params: { file: { id: existing.id, name: existing.originalName, - size: existing.size, + size: workspaceFileSize(existing), type: existing.contentType, url: `${pathPrefix}${encodeURIComponent(existing.key)}?context=workspace`, key: existing.key, @@ -591,7 +592,7 @@ export async function registerUploadedWorkspaceFile(params: { file: { id: finalized.file.id, name: finalized.file.originalName, - size: finalized.file.size, + size: workspaceFileSize(finalized.file), type: finalized.file.contentType, url: `${pathPrefix}${encodeURIComponent(finalized.file.key)}?context=workspace`, key: finalized.file.key, @@ -730,7 +731,7 @@ function mapWorkspaceFileRecord( name: file.originalName, key: file.key, path: `${pathPrefix}${encodeURIComponent(file.key)}?context=workspace`, - size: file.size, + size: workspaceFileSize(file), type: file.contentType, uploadedBy: file.userId, folderId: file.folderId, @@ -853,7 +854,13 @@ const fileId = textKey(workspaceFiles.id, (row) => row.id) const WORKSPACE_FILE_SORTS = { name: [textKey(workspaceFiles.originalName, (row) => row.name), fileId], - size: [numberKey(workspaceFiles.size, (row) => row.size), fileId], + size: [ + numberKey( + sql`coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})`.mapWith(Number), + (row) => row.size + ), + fileId, + ], uploadedAt: [timestampKey(workspaceFiles.uploadedAt, (row) => row.uploadedAt), fileId], updatedAt: [timestampKey(workspaceFiles.updatedAt, (row) => row.updatedAt), fileId], } satisfies Record[]> @@ -1256,7 +1263,7 @@ export async function updateWorkspaceFileContent( throw new ContentVersionConflictError(fileId) } - const sizeDiff = content.length - currentFile.size + const sizeDiff = content.length - workspaceFileSize(currentFile) const now = new Date() // `contentUpdatedAt` is the persist If-Match token, so it MUST be strictly monotonic per file — a // bare `new Date()` is not: cross-instance clock skew can stamp a later write with an earlier time, @@ -1271,7 +1278,8 @@ export async function updateWorkspaceFileContent( .update(workspaceFiles) .set({ key: uploadResult.key, - size: content.length, + size: toLegacyWorkspaceFileSize(content.length), + sizeBytes: content.length, contentType: nextContentType, updatedAt: now, contentUpdatedAt, @@ -1357,7 +1365,7 @@ export async function updateWorkspaceFileContent( name: finalized.file.originalName, key: finalized.file.key, path: `${pathPrefix}${encodeURIComponent(finalized.file.key)}?context=workspace`, - size: finalized.file.size, + size: workspaceFileSize(finalized.file), type: finalized.file.contentType, uploadedBy: finalized.file.userId, folderId: finalized.file.folderId, diff --git a/apps/sim/lib/uploads/core/storage-service.ts b/apps/sim/lib/uploads/core/storage-service.ts index 0a49d320e02..619116c72ae 100644 --- a/apps/sim/lib/uploads/core/storage-service.ts +++ b/apps/sim/lib/uploads/core/storage-service.ts @@ -34,7 +34,7 @@ const logger = createLogger('StorageService') * Create a Blob config from StorageConfig * @throws Error if required properties are missing */ -function createBlobConfig(config: StorageConfig): BlobConfig { +export function createBlobConfig(config: StorageConfig): BlobConfig { if (!config.containerName) { throw new Error('Blob configuration missing required property: containerName') } @@ -57,7 +57,7 @@ function createBlobConfig(config: StorageConfig): BlobConfig { * Create an S3 config from StorageConfig * @throws Error if required properties are missing */ -function createS3Config(config: StorageConfig): S3Config { +export function createS3Config(config: StorageConfig): S3Config { if (!config.bucket || !config.region) { throw new Error('S3 configuration missing required properties: bucket and region') } @@ -72,7 +72,7 @@ function createS3Config(config: StorageConfig): S3Config { * Create a GCS config from StorageConfig * @throws Error if required properties are missing */ -function createGcsConfig(config: StorageConfig): GcsConfig { +export function createGcsConfig(config: StorageConfig): GcsConfig { if (!config.bucket) { throw new Error('GCS configuration missing required property: bucket') } @@ -634,7 +634,17 @@ export async function headObject( return headGcsObject(key, createGcsConfig(config)) } - return null + const { stat } = await import('fs/promises') + const { join } = await import('path') + const { UPLOAD_DIR_SERVER } = await import('./setup.server') + try { + const file = await stat(join(UPLOAD_DIR_SERVER, sanitizeFileKey(key))) + return { size: file.size } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') return null + throw error + } } /** diff --git a/apps/sim/lib/uploads/core/upload-token.test.ts b/apps/sim/lib/uploads/core/upload-token.test.ts new file mode 100644 index 00000000000..532985f5411 --- /dev/null +++ b/apps/sim/lib/uploads/core/upload-token.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { signUploadToken, verifyUploadToken } from '@/lib/uploads/core/upload-token' + +describe('upload token', () => { + it('round-trips stateless multipart session state', () => { + const token = signUploadToken({ + uploadId: 'upload-1', + key: 'workspace-1/file.csv', + userId: 'user-1', + workspaceId: 'workspace-1', + context: 'workspace', + fileName: 'file.csv', + contentType: 'text/csv', + fileSize: 12, + purpose: 'workspace_file', + provider: 's3', + providerUploadId: 'provider-upload-1', + partSize: 8, + partCount: 2, + metadata: { folderId: 'folder-1' }, + createdAt: '2026-08-03T20:00:00.000Z', + expiresAt: '2026-08-04T20:00:00.000Z', + }) + + expect(verifyUploadToken(token)).toEqual({ + valid: true, + payload: { + uploadId: 'upload-1', + key: 'workspace-1/file.csv', + userId: 'user-1', + workspaceId: 'workspace-1', + context: 'workspace', + fileName: 'file.csv', + contentType: 'text/csv', + fileSize: 12, + purpose: 'workspace_file', + provider: 's3', + providerUploadId: 'provider-upload-1', + partSize: 8, + partCount: 2, + metadata: { folderId: 'folder-1' }, + createdAt: '2026-08-03T20:00:00.000Z', + expiresAt: '2026-08-04T20:00:00.000Z', + }, + }) + }) + + it('rejects a modified token', () => { + const token = signUploadToken({ + uploadId: 'upload-1', + key: 'workspace-1/file.csv', + userId: 'user-1', + workspaceId: 'workspace-1', + context: 'workspace', + }) + const [payload, signature] = token.split('.') + + expect(verifyUploadToken(`${payload}x.${signature}`)).toEqual({ valid: false }) + }) +}) diff --git a/apps/sim/lib/uploads/core/upload-token.ts b/apps/sim/lib/uploads/core/upload-token.ts index 21a7b199a9a..d030655b58b 100644 --- a/apps/sim/lib/uploads/core/upload-token.ts +++ b/apps/sim/lib/uploads/core/upload-token.ts @@ -15,6 +15,21 @@ export interface UploadTokenPayload { contentType?: string /** File size in bytes, carried for ownership metadata at completion. */ fileSize?: number + /** Multipart-session purpose. Omitted by the legacy multipart endpoint. */ + purpose?: 'workspace_file' | 'table_import' + /** Storage provider that owns the multipart upload state. */ + provider?: 's3' | 'blob' | 'gcs' | 'local' + /** Provider-issued multipart upload id. Local and block-blob uploads do not need one. */ + providerUploadId?: string | null + /** Fixed byte size of every part except the final part. */ + partSize?: number + /** Exact number of parts the client must complete. */ + partCount?: number + /** Signed purpose-specific data needed during finalization. */ + metadata?: Record + /** ISO timestamps used to reconstruct the stateless session response. */ + createdAt?: string + expiresAt?: string } interface SignedPayload extends UploadTokenPayload { @@ -91,6 +106,25 @@ export function verifyUploadToken(token: string): UploadTokenVerification { ...(typeof parsed.fileName === 'string' ? { fileName: parsed.fileName } : {}), ...(typeof parsed.contentType === 'string' ? { contentType: parsed.contentType } : {}), ...(typeof parsed.fileSize === 'number' ? { fileSize: parsed.fileSize } : {}), + ...(parsed.purpose === 'workspace_file' || parsed.purpose === 'table_import' + ? { purpose: parsed.purpose } + : {}), + ...(parsed.provider === 's3' || + parsed.provider === 'blob' || + parsed.provider === 'gcs' || + parsed.provider === 'local' + ? { provider: parsed.provider } + : {}), + ...(typeof parsed.providerUploadId === 'string' || parsed.providerUploadId === null + ? { providerUploadId: parsed.providerUploadId } + : {}), + ...(typeof parsed.partSize === 'number' ? { partSize: parsed.partSize } : {}), + ...(typeof parsed.partCount === 'number' ? { partCount: parsed.partCount } : {}), + ...(parsed.metadata && typeof parsed.metadata === 'object' && !Array.isArray(parsed.metadata) + ? { metadata: parsed.metadata } + : {}), + ...(typeof parsed.createdAt === 'string' ? { createdAt: parsed.createdAt } : {}), + ...(typeof parsed.expiresAt === 'string' ? { expiresAt: parsed.expiresAt } : {}), }, } } diff --git a/apps/sim/lib/uploads/multipart-session/provider.ts b/apps/sim/lib/uploads/multipart-session/provider.ts new file mode 100644 index 00000000000..64e258cc879 --- /dev/null +++ b/apps/sim/lib/uploads/multipart-session/provider.ts @@ -0,0 +1,319 @@ +import { createReadStream, createWriteStream } from 'node:fs' +import { mkdir, rename, rm, stat } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { pipeline } from 'node:stream/promises' +import { getErrorMessage } from '@sim/utils/errors' +import { + getStorageConfig, + USE_BLOB_STORAGE, + USE_GCS_STORAGE, + USE_S3_STORAGE, +} from '@/lib/uploads/config' +import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server' +import { + createBlobConfig, + createGcsConfig, + createS3Config, +} from '@/lib/uploads/core/storage-service' +import type { StorageContext } from '@/lib/uploads/shared/types' +import { sanitizeFileKey } from '@/lib/uploads/utils/file-utils' + +export type MultipartStorageProvider = 's3' | 'blob' | 'gcs' | 'local' + +export interface CompletedUploadPart { + partNumber: number + etag?: string +} + +export interface MultipartPartUrl { + partNumber: number + url: string + headers: Record + expiresAt: string +} + +export function multipartStorageProvider(): MultipartStorageProvider { + if (USE_BLOB_STORAGE) return 'blob' + if (USE_S3_STORAGE) return 's3' + if (USE_GCS_STORAGE) return 'gcs' + return 'local' +} + +export async function initiateMultipartProviderUpload(params: { + key: string + fileName: string + contentType: string + fileSize: number + context: StorageContext + localUploadId: string +}): Promise<{ provider: MultipartStorageProvider; providerUploadId: string | null }> { + const { key, fileName, contentType, fileSize, context, localUploadId } = params + const provider = multipartStorageProvider() + const config = getStorageConfig(context) + + if (provider === 's3') { + const { initiateS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') + const result = await initiateS3MultipartUpload({ + fileName, + contentType, + fileSize, + customConfig: createS3Config(config), + customKey: key, + purpose: context, + }) + return { provider, providerUploadId: result.uploadId } + } + if (provider === 'blob') { + const { initiateMultipartUpload } = await import('@/lib/uploads/providers/blob/client') + const result = await initiateMultipartUpload({ + fileName, + contentType, + fileSize, + customConfig: createBlobConfig(config), + customKey: key, + }) + return { provider, providerUploadId: result.uploadId } + } + if (provider === 'gcs') { + const { initiateGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') + const result = await initiateGcsMultipartUpload({ + fileName, + contentType, + fileSize, + customConfig: createGcsConfig(config), + customKey: key, + purpose: context, + }) + return { provider, providerUploadId: result.uploadId } + } + + await mkdir(localPartsDirectory(localUploadId), { recursive: true }) + return { provider, providerUploadId: null } +} + +export async function getMultipartProviderPartUrls(params: { + provider: MultipartStorageProvider + providerUploadId: string | null + key: string + context: StorageContext + partNumbers: number[] + localUrl: (partNumber: number) => string +}): Promise { + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString() + const { provider, providerUploadId, key, context, partNumbers } = params + if (provider === 'local') { + return partNumbers.map((partNumber) => ({ + partNumber, + url: params.localUrl(partNumber), + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt, + })) + } + if (!providerUploadId) throw new Error(`Missing ${provider} multipart upload id`) + const config = getStorageConfig(context) + + if (provider === 's3') { + const { getS3MultipartPartUrls } = await import('@/lib/uploads/providers/s3/client') + const urls = await getS3MultipartPartUrls( + key, + providerUploadId, + partNumbers, + createS3Config(config) + ) + return urls.map(({ partNumber, url }) => ({ + partNumber, + url, + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt, + })) + } + if (provider === 'blob') { + const { getMultipartPartUrls } = await import('@/lib/uploads/providers/blob/client') + const urls = await getMultipartPartUrls(key, partNumbers, createBlobConfig(config)) + return urls.map(({ partNumber, url }) => ({ + partNumber, + url, + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt, + })) + } + const { getGcsMultipartPartUrls } = await import('@/lib/uploads/providers/gcs/client') + const urls = await getGcsMultipartPartUrls( + key, + providerUploadId, + partNumbers, + createGcsConfig(config) + ) + return urls.map(({ partNumber, url }) => ({ + partNumber, + url, + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt, + })) +} + +export async function completeMultipartProviderUpload(params: { + provider: MultipartStorageProvider + providerUploadId: string | null + uploadId: string + key: string + contentType: string + context: StorageContext + parts: CompletedUploadPart[] +}): Promise { + const { provider, providerUploadId, uploadId, key, contentType, context, parts } = params + if (provider === 'local') { + await assembleLocalParts(uploadId, key, parts) + return + } + if (!providerUploadId) throw new Error(`Missing ${provider} multipart upload id`) + const config = getStorageConfig(context) + if (provider === 's3') { + const { completeS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') + await completeS3MultipartUpload( + key, + providerUploadId, + parts.map((part) => ({ + PartNumber: part.partNumber, + ETag: requiredEtag(provider, part), + })), + createS3Config(config) + ) + return + } + if (provider === 'blob') { + const { completeMultipartUpload, deriveBlobBlockId } = await import( + '@/lib/uploads/providers/blob/client' + ) + await completeMultipartUpload( + key, + parts.map((part) => ({ + partNumber: part.partNumber, + blockId: deriveBlobBlockId(part.partNumber), + })), + createBlobConfig(config), + contentType + ) + return + } + const { completeGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') + await completeGcsMultipartUpload( + key, + providerUploadId, + parts.map((part) => ({ + PartNumber: part.partNumber, + ETag: requiredEtag(provider, part), + })), + createGcsConfig(config) + ) +} + +export async function abortMultipartProviderUpload(params: { + provider: MultipartStorageProvider + providerUploadId: string | null + uploadId: string + key: string + context: StorageContext +}): Promise { + const { provider, providerUploadId, uploadId, key, context } = params + if (provider === 'local') { + await rm(localPartsDirectory(uploadId), { recursive: true, force: true }) + const destination = join(UPLOAD_DIR_SERVER, sanitizeFileKey(key)) + await rm(`${destination}.uploading-${uploadId}`, { force: true }) + return + } + if (!providerUploadId) throw new Error(`Missing ${provider} multipart upload id`) + const config = getStorageConfig(context) + if (provider === 's3') { + const { abortS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') + await abortS3MultipartUpload(key, providerUploadId, createS3Config(config)) + return + } + if (provider === 'blob') { + const { abortMultipartUpload } = await import('@/lib/uploads/providers/blob/client') + await abortMultipartUpload(key, createBlobConfig(config)) + return + } + const { abortGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') + await abortGcsMultipartUpload(key, providerUploadId, createGcsConfig(config)) +} + +export async function writeLocalMultipartPart(params: { + uploadId: string + partNumber: number + body: ReadableStream + expectedSize: number +}): Promise { + const { Readable, Transform } = await import('node:stream') + const directory = localPartsDirectory(params.uploadId) + await mkdir(directory, { recursive: true }) + const destination = localPartPath(params.uploadId, params.partNumber) + let bytes = 0 + const counter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + bytes += chunk.length + if (bytes > params.expectedSize) { + callback(new Error(`Part ${params.partNumber} exceeds ${params.expectedSize} bytes`)) + return + } + callback(null, chunk) + }, + }) + try { + await pipeline( + Readable.fromWeb(params.body as Parameters[0]), + counter, + createWriteStream(destination, { flags: 'w' }) + ) + if (bytes !== params.expectedSize) { + throw new Error( + `Part ${params.partNumber} has ${bytes} bytes; expected ${params.expectedSize}` + ) + } + } catch (error) { + await rm(destination, { force: true }).catch(() => {}) + throw new Error(getErrorMessage(error, `Failed to store part ${params.partNumber}`), { + cause: error, + }) + } +} + +function localPartsDirectory(uploadId: string): string { + return join(UPLOAD_DIR_SERVER, '.multipart', uploadId) +} + +function localPartPath(uploadId: string, partNumber: number): string { + return join(localPartsDirectory(uploadId), `${partNumber}.part`) +} + +async function assembleLocalParts( + uploadId: string, + key: string, + parts: CompletedUploadPart[] +): Promise { + const safeKey = sanitizeFileKey(key) + const destination = join(UPLOAD_DIR_SERVER, safeKey) + const temporary = `${destination}.uploading-${uploadId}` + await mkdir(dirname(destination), { recursive: true }) + await rm(temporary, { force: true }) + try { + for (const part of parts) { + await pipeline( + createReadStream(localPartPath(uploadId, part.partNumber)), + createWriteStream(temporary, { flags: 'a' }) + ) + } + const assembled = await stat(temporary) + if (assembled.size === 0) throw new Error('Assembled upload is empty') + await rename(temporary, destination) + await rm(localPartsDirectory(uploadId), { recursive: true, force: true }) + } catch (error) { + await rm(temporary, { force: true }).catch(() => {}) + throw error + } +} + +function requiredEtag(provider: 's3' | 'gcs', part: CompletedUploadPart): string { + if (!part.etag) throw new Error(`Missing etag for ${provider} part ${part.partNumber}`) + return part.etag +} diff --git a/apps/sim/lib/uploads/multipart-session/service.ts b/apps/sim/lib/uploads/multipart-session/service.ts new file mode 100644 index 00000000000..fba4eb353ef --- /dev/null +++ b/apps/sim/lib/uploads/multipart-session/service.ts @@ -0,0 +1,386 @@ +import { generateId } from '@sim/utils/id' +import { + checkStorageQuotaForBillingContext, + resolveStorageBillingContext, +} from '@/lib/billing/storage' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace' +import { headObject } from '@/lib/uploads/core/storage-service' +import { signUploadToken, verifyUploadToken } from '@/lib/uploads/core/upload-token' +import { + abortMultipartProviderUpload, + type CompletedUploadPart, + completeMultipartProviderUpload, + getMultipartProviderPartUrls, + initiateMultipartProviderUpload, + type MultipartPartUrl, + type MultipartStorageProvider, +} from '@/lib/uploads/multipart-session/provider' +import { MAX_WORKSPACE_FILE_SIZE, type StorageContext } from '@/lib/uploads/shared/types' +import { sanitizeFileName } from '@/executor/constants' + +export const MULTIPART_SESSION_PART_SIZE = 8 * 1024 * 1024 +export const MULTIPART_SESSION_MAX_PART_URLS = 100 +export const MULTIPART_SESSION_TTL_MS = 24 * 60 * 60 * 1000 + +export type UploadSessionPurpose = 'workspace_file' | 'table_import' +export type UploadSessionStatus = 'uploading' | 'completed' | 'aborted' + +export interface UploadSessionRecord { + id: string + workspaceId: string + userId: string + purpose: UploadSessionPurpose + storageContext: StorageContext + storageKey: string + storageProvider: MultipartStorageProvider + providerUploadId: string | null + fileName: string + contentType: string + fileSize: number + partSize: number + partCount: number + status: UploadSessionStatus + metadata: Record + uploadToken: string + createdAt: Date + expiresAt: Date + completedFileId: string | null + error: string | null + completedAt: Date | null + updatedAt: Date +} + +export class UploadSessionError extends OrchestrationError { + constructor( + code: 'validation' | 'not_found' | 'forbidden' | 'conflict' | 'payload_too_large' | 'internal', + message: string + ) { + super(code, message) + this.name = 'UploadSessionError' + } +} + +interface CreateUploadSessionParams { + id?: string + workspaceId: string + userId: string + purpose: UploadSessionPurpose + fileName: string + contentType: string + fileSize: number + metadata?: Record +} + +export async function createUploadSession( + params: CreateUploadSessionParams +): Promise { + validateFileSize(params.fileSize) + const id = params.id ?? generateId() + const storageContext: StorageContext = + params.purpose === 'workspace_file' ? 'workspace' : 'table-import' + const storageKey = + params.purpose === 'workspace_file' + ? generateWorkspaceFileKey(params.workspaceId, params.fileName) + : `table-import/${params.workspaceId}/${id}/${sanitizeFileName(params.fileName)}` + const partCount = Math.ceil(params.fileSize / MULTIPART_SESSION_PART_SIZE) + + if (params.purpose === 'workspace_file') { + const billingContext = await resolveStorageBillingContext(params.workspaceId) + const quota = await checkStorageQuotaForBillingContext(billingContext, params.fileSize) + if (!quota.allowed) { + throw new UploadSessionError('payload_too_large', quota.error ?? 'Storage limit exceeded') + } + } + + const initiated = await initiateMultipartProviderUpload({ + key: storageKey, + fileName: params.fileName, + contentType: params.contentType, + fileSize: params.fileSize, + context: storageContext, + localUploadId: id, + }) + const createdAt = new Date() + const expiresAt = new Date(createdAt.getTime() + MULTIPART_SESSION_TTL_MS) + const metadata = params.metadata ?? {} + const uploadToken = signUploadToken( + { + uploadId: id, + key: storageKey, + userId: params.userId, + workspaceId: params.workspaceId, + context: storageContext, + fileName: params.fileName, + contentType: params.contentType, + fileSize: params.fileSize, + purpose: params.purpose, + provider: initiated.provider, + providerUploadId: initiated.providerUploadId, + partSize: MULTIPART_SESSION_PART_SIZE, + partCount, + metadata, + createdAt: createdAt.toISOString(), + expiresAt: expiresAt.toISOString(), + }, + MULTIPART_SESSION_TTL_MS / 1000 + ) + + return { + id, + workspaceId: params.workspaceId, + userId: params.userId, + purpose: params.purpose, + storageContext, + storageKey, + storageProvider: initiated.provider, + providerUploadId: initiated.providerUploadId, + fileName: params.fileName, + contentType: params.contentType, + fileSize: params.fileSize, + partSize: MULTIPART_SESSION_PART_SIZE, + partCount, + status: 'uploading', + metadata, + uploadToken, + createdAt, + expiresAt, + completedFileId: null, + error: null, + completedAt: null, + updatedAt: createdAt, + } +} + +export function getOwnedUploadSession(params: { + uploadId: string + workspaceId: string + userId?: string + uploadToken: string +}): UploadSessionRecord { + const session = verifyUploadSessionToken(params.uploadToken) + if (session.id !== params.uploadId || session.workspaceId !== params.workspaceId) { + throw new UploadSessionError('not_found', 'Upload session not found') + } + if (params.userId && session.userId !== params.userId) { + throw new UploadSessionError('not_found', 'Upload session not found') + } + return session +} + +export function verifyUploadSessionToken(uploadToken: string): UploadSessionRecord { + const verified = verifyUploadToken(uploadToken) + if (!verified.valid) throw new UploadSessionError('forbidden', 'Invalid or expired upload token') + const payload = verified.payload + if ( + !payload.fileName || + !payload.contentType || + typeof payload.fileSize !== 'number' || + !Number.isSafeInteger(payload.fileSize) || + !payload.purpose || + !payload.provider || + typeof payload.partSize !== 'number' || + !Number.isSafeInteger(payload.partSize) || + typeof payload.partCount !== 'number' || + !Number.isSafeInteger(payload.partCount) || + !payload.createdAt || + !payload.expiresAt + ) { + throw new UploadSessionError('forbidden', 'Upload token is not a multipart session token') + } + if (payload.context !== 'workspace' && payload.context !== 'table-import') { + throw new UploadSessionError('forbidden', 'Upload token has an invalid storage context') + } + const createdAt = new Date(payload.createdAt) + const expiresAt = new Date(payload.expiresAt) + if (!Number.isFinite(createdAt.getTime()) || !Number.isFinite(expiresAt.getTime())) { + throw new UploadSessionError('forbidden', 'Upload token has invalid timestamps') + } + const now = new Date() + return { + id: payload.uploadId, + workspaceId: payload.workspaceId, + userId: payload.userId, + purpose: payload.purpose, + storageContext: payload.context, + storageKey: payload.key, + storageProvider: payload.provider, + providerUploadId: payload.providerUploadId ?? null, + fileName: payload.fileName, + contentType: payload.contentType, + fileSize: payload.fileSize, + partSize: payload.partSize, + partCount: payload.partCount, + status: 'uploading', + metadata: payload.metadata ?? {}, + uploadToken, + createdAt, + expiresAt, + completedFileId: null, + error: null, + completedAt: null, + updatedAt: now, + } +} + +export async function createUploadPartUrls(params: { + session: UploadSessionRecord + partNumbers: number[] + localOrigin: string +}): Promise { + assertUploadable(params.session) + const unique = new Set(params.partNumbers) + if (unique.size !== params.partNumbers.length) { + throw new UploadSessionError('validation', 'partNumbers must not contain duplicates') + } + if ( + params.partNumbers.length === 0 || + params.partNumbers.length > MULTIPART_SESSION_MAX_PART_URLS + ) { + throw new UploadSessionError( + 'validation', + `partNumbers must contain between 1 and ${MULTIPART_SESSION_MAX_PART_URLS} entries` + ) + } + for (const partNumber of params.partNumbers) { + if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > params.session.partCount) { + throw new UploadSessionError( + 'validation', + `partNumber must be between 1 and ${params.session.partCount}` + ) + } + } + + return getMultipartProviderPartUrls({ + provider: params.session.storageProvider, + providerUploadId: params.session.providerUploadId, + key: params.session.storageKey, + context: params.session.storageContext, + partNumbers: params.partNumbers, + localUrl: (partNumber) => + `${params.localOrigin}/api/v2/uploads/${params.session.id}/parts/${partNumber}?token=${encodeURIComponent(params.session.uploadToken)}`, + }) +} + +export async function completeUploadSession(params: { + session: UploadSessionRecord + parts: CompletedUploadPart[] + finalize: (session: UploadSessionRecord) => Promise<{ value: T; completedFileId?: string }> +}): Promise<{ session: UploadSessionRecord; value: T; alreadyCompleted: boolean }> { + assertUploadable(params.session) + validateCompletedParts(params.session, params.parts) + + const existingObject = await headObject(params.session.storageKey, params.session.storageContext) + const alreadyCompleted = existingObject?.size === params.session.fileSize + if (existingObject && !alreadyCompleted) { + throw new UploadSessionError( + 'conflict', + `Upload object has ${existingObject.size} bytes; expected ${params.session.fileSize}` + ) + } + if (!alreadyCompleted) { + await completeMultipartProviderUpload({ + provider: params.session.storageProvider, + providerUploadId: params.session.providerUploadId, + uploadId: params.session.id, + key: params.session.storageKey, + contentType: params.session.contentType, + context: params.session.storageContext, + parts: params.parts, + }) + } + + const head = await headObject(params.session.storageKey, params.session.storageContext) + if (!head) throw new Error('Completed upload object not found') + if (head.size !== params.session.fileSize) { + throw new UploadSessionError( + 'validation', + `Uploaded object has ${head.size} bytes; expected ${params.session.fileSize}` + ) + } + + const finalized = await params.finalize(params.session) + const completedAt = new Date() + return { + session: { + ...params.session, + status: 'completed', + completedFileId: finalized.completedFileId ?? null, + completedAt, + updatedAt: completedAt, + }, + value: finalized.value, + alreadyCompleted, + } +} + +export async function abortUploadSession( + session: UploadSessionRecord +): Promise { + assertUploadable(session) + await abortMultipartProviderUpload({ + provider: session.storageProvider, + providerUploadId: session.providerUploadId, + uploadId: session.id, + key: session.storageKey, + context: session.storageContext, + }) + const completedAt = new Date() + return { ...session, status: 'aborted', completedAt, updatedAt: completedAt } +} + +export function expectedUploadPartSize(session: UploadSessionRecord, partNumber: number): number { + if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > session.partCount) { + throw new UploadSessionError('validation', 'Invalid upload part number') + } + if (partNumber < session.partCount) return session.partSize + return session.fileSize - session.partSize * (session.partCount - 1) +} + +function assertUploadable(session: UploadSessionRecord): void { + if (session.status !== 'uploading') { + throw new UploadSessionError('conflict', `Upload session is ${session.status}`) + } + if (session.expiresAt.getTime() <= Date.now()) { + throw new UploadSessionError('conflict', 'Upload session has expired') + } +} + +function validateCompletedParts(session: UploadSessionRecord, parts: CompletedUploadPart[]): void { + if (parts.length !== session.partCount) { + throw new UploadSessionError( + 'validation', + `Expected ${session.partCount} completed parts; received ${parts.length}` + ) + } + const sorted = [...parts].sort((a, b) => a.partNumber - b.partNumber) + for (let index = 0; index < sorted.length; index++) { + if (sorted[index].partNumber !== index + 1) { + throw new UploadSessionError( + 'validation', + 'Completed parts must contain every part exactly once' + ) + } + if ( + (session.storageProvider === 's3' || session.storageProvider === 'gcs') && + !sorted[index].etag + ) { + throw new UploadSessionError( + 'validation', + `etag is required for ${session.storageProvider} part ${sorted[index].partNumber}` + ) + } + } +} + +function validateFileSize(fileSize: number): void { + if (!Number.isSafeInteger(fileSize) || fileSize < 1) { + throw new UploadSessionError('validation', 'fileSize must be a positive integer') + } + if (fileSize > MAX_WORKSPACE_FILE_SIZE) { + throw new UploadSessionError( + 'validation', + `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` + ) + } +} diff --git a/apps/sim/lib/uploads/providers/blob/client.ts b/apps/sim/lib/uploads/providers/blob/client.ts index eda490a8b82..076933db181 100644 --- a/apps/sim/lib/uploads/providers/blob/client.ts +++ b/apps/sim/lib/uploads/providers/blob/client.ts @@ -691,7 +691,8 @@ export async function commitBlobBlockList( export async function completeMultipartUpload( key: string, parts: AzureMultipartPart[], - customConfig?: BlobConfig + customConfig?: BlobConfig, + contentType?: string ): Promise<{ location: string; path: string; key: string }> { const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob') let blobServiceClient: BlobServiceClientType @@ -726,6 +727,7 @@ export async function completeMultipartUpload( .map((part) => part.blockId) await blockBlobClient.commitBlockList(sortedBlockIds, { + ...(contentType ? { blobHTTPHeaders: { blobContentType: contentType } } : {}), metadata: { multipartUpload: 'completed', uploadCompletedAt: new Date().toISOString(), diff --git a/apps/sim/lib/uploads/server/metadata.ts b/apps/sim/lib/uploads/server/metadata.ts index 4a3faa58366..94b8317cdc9 100644 --- a/apps/sim/lib/uploads/server/metadata.ts +++ b/apps/sim/lib/uploads/server/metadata.ts @@ -3,7 +3,7 @@ import { workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, sql } from 'drizzle-orm' -import type { StorageContext } from '../shared/types' +import { type StorageContext, toLegacyWorkspaceFileSize } from '../shared/types' const logger = createLogger('FileMetadata') @@ -49,7 +49,8 @@ export async function insertFileMetadata( originalName, displayName: originalName, contentType, - size, + size: toLegacyWorkspaceFileSize(size), + sizeBytes: size, deletedAt: null, uploadedAt: new Date(), }) @@ -86,7 +87,8 @@ export async function insertFileMetadata( originalName, displayName: originalName, contentType, - size, + size: toLegacyWorkspaceFileSize(size), + sizeBytes: size, deletedAt: null, uploadedAt: new Date(), }) @@ -142,7 +144,8 @@ export async function insertFileMetadataMany( originalName: row.originalName, displayName: row.originalName, contentType: row.contentType, - size: row.size, + size: toLegacyWorkspaceFileSize(row.size), + sizeBytes: row.size, deletedAt: null, uploadedAt: new Date(), })) diff --git a/apps/sim/lib/uploads/shared/types.ts b/apps/sim/lib/uploads/shared/types.ts index 65f2570ecaf..0c24f770444 100644 --- a/apps/sim/lib/uploads/shared/types.ts +++ b/apps/sim/lib/uploads/shared/types.ts @@ -5,6 +5,17 @@ */ export const MAX_WORKSPACE_FILE_SIZE = 5 * 1024 * 1024 * 1024 +const MAX_POSTGRES_INTEGER = 2_147_483_647 + +/** + * Keeps the legacy int4 metadata projection writable while `size_bytes` stores the exact value. + */ +export function toLegacyWorkspaceFileSize(size: number): number { + if (!Number.isSafeInteger(size) || size < 0) + throw new Error(`Invalid workspace file size: ${size}`) + return Math.min(size, MAX_POSTGRES_INTEGER) +} + /** * Cap on the legacy FormData upload route, which buffers the whole file in * worker memory. Direct-to-storage uploads use {@link MAX_WORKSPACE_FILE_SIZE}. @@ -18,6 +29,7 @@ export type StorageContext = | 'mothership' | 'execution' | 'workspace' + | 'table-import' | 'profile-pictures' | 'og-images' | 'logs' diff --git a/apps/sim/stores/table/import-tray/store.ts b/apps/sim/stores/table/import-tray/store.ts index 174485acb61..9b547b5f6f4 100644 --- a/apps/sim/stores/table/import-tray/store.ts +++ b/apps/sim/stores/table/import-tray/store.ts @@ -2,12 +2,12 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' /** - * An in-flight client upload, shown optimistically before its server import row exists or the - * table list has refreshed. Keyed by `uploadId`: a `pending_*` id (creating a new table, no row - * yet) or the target tableId (append/replace into an existing table). + * An in-flight client upload, shown after its signed upload session is created but before the + * table list has refreshed. `uploadId` is the import id across upload and processing. */ export interface ImportUpload { uploadId: string + tableId?: string workspaceId: string title: string /** Byte-based upload percent from the client XHR. */ diff --git a/packages/db/migrations/0280_smart_la_nuit.sql b/packages/db/migrations/0280_smart_la_nuit.sql new file mode 100644 index 00000000000..c4ee77d7e2a --- /dev/null +++ b/packages/db/migrations/0280_smart_la_nuit.sql @@ -0,0 +1 @@ +ALTER TABLE "workspace_files" ADD COLUMN "size_bytes" bigint; \ No newline at end of file diff --git a/packages/db/migrations/meta/0280_snapshot.json b/packages/db/migrations/meta/0280_snapshot.json new file mode 100644 index 00000000000..2dee9eb794e --- /dev/null +++ b/packages/db/migrations/meta/0280_snapshot.json @@ -0,0 +1,18376 @@ +{ + "id": "006931de-2a8e-418b-91ea-92f853773e45", + "prevId": "4b619949-ee98-4251-b621-5f37a9fa23a3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 30be907c184..43444b15f85 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1954,6 +1954,13 @@ "when": 1785542556609, "tag": "0279_collab_doc_state_and_content_version", "breakpoints": true + }, + { + "idx": 280, + "version": "7", + "when": 1785800321549, + "tag": "0280_smart_la_nuit", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 87805620307..75dc5b04d60 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1910,7 +1910,10 @@ export const workspaceFiles = pgTable( */ displayName: text('display_name'), contentType: text('content_type').notNull(), + // contract-pending(after #6188 is fully deployed and sizeBytes is backfilled): drop size — new code dual-writes and reads sizeBytes first size: integer('size').notNull(), + /** Exact byte size for files above PostgreSQL's int4 ceiling; legacy rows fall back to `size`. */ + sizeBytes: bigint('size_bytes', { mode: 'number' }), deletedAt: timestamp('deleted_at'), uploadedAt: timestamp('uploaded_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index f8a7a617938..712093e3aea 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1048, - zodRoutes: 1048, + totalRoutes: 1079, + zodRoutes: 1079, nonZodRoutes: 0, } as const From a0fa865c7956df6698dbac5d2b69d9d4f5de3a20 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 18:12:14 -0700 Subject: [PATCH 050/159] feat(cli): pick up v2 workflow CRUD, table transfers, and list search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 79 → 111 operations across three merged PRs. The generator could not read the new contracts at all: a table view's filter is a recursive predicate, so Zod lifts it into `$defs` and refers to it, and `toTypeScript` threw on the first `$ref`. Those definitions are now hoisted into named aliases — recursion TypeScript resolves without complaint — named after the type that owns them so two operations lifting their own `__schema0` cannot collide. Uploading is no longer one multipart POST. `POST /api/v2/files` is gone, replaced by a presigned handshake, so `files upload` was left calling a route that no longer exists. It now creates the upload, signs part URLs in batches of 100 (each is short-lived, so signing all of them up front would expire the last ones), PUTs each part straight to storage, and completes with the ETags — aborting the upload if any step fails, since a half-finished one holds storage. Parts are read through `Blob.slice`, so only the part in flight is in memory. Verified byte-identical on a 24MB round trip. The rest is naming. `/cancel-runs`, `/rows/find`, `/restore`, `/columns/run` and the enrichment path each put a verb where a sub-resource was expected, so each had become a group holding a lone `create`. Transfer steps keep names that say what they are, since no single command drives a table import yet. Three new DELETEs needed gates, which the existing guard test caught. Aborting an upload and cancelling an import or export stop something in flight rather than destroying something kept, so those are exempt. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/commands/hand-written.ts | 161 +- packages/sim-cli/src/contract/commands.ts | 57 +- packages/sim-cli/src/generated/v2-api.ts | 2002 ++++++++++++++++- packages/sim-cli/src/http/client.test.ts | 10 +- packages/sim-cli/src/http/client.ts | 3 + scripts/generate-v2-cli-api.ts | 69 +- 6 files changed, 2227 insertions(+), 75 deletions(-) diff --git a/packages/sim-cli/src/commands/hand-written.ts b/packages/sim-cli/src/commands/hand-written.ts index 75910fc356a..af16b39e2dc 100644 --- a/packages/sim-cli/src/commands/hand-written.ts +++ b/packages/sim-cli/src/commands/hand-written.ts @@ -6,7 +6,7 @@ import chalk from 'chalk' import type { Command } from 'commander' import { clientFrom } from '../context.js' import type { QueryRowsResponse } from '../generated/v2-api.js' -import { SimApiError } from '../http/client.js' +import { SimApiError, type SimClient } from '../http/client.js' import { type Column, printList, sanitize, text } from '../output/render.js' /** @@ -149,11 +149,91 @@ function contentTypeFor(name: string): string { return CONTENT_TYPES[extension] ?? 'application/octet-stream' } -/** The route's own ceiling. Checked here so a 100 MB body is never sent to be refused. */ -const MAX_UPLOAD_BYTES = 100 * 1024 * 1024 +interface UploadPartUrl { + partNumber: number + url: string + headers: Record +} + +interface FileUpload { + id: string + size: number + partSize: number + partCount: number + uploadToken: string + file: { id: string } | null +} + +/** The parts endpoint signs at most this many URLs per request. */ +const PART_URL_BATCH = 100 + +/** + * Sends every part of a file to the storage URLs the API signs for it, and + * returns what `complete` needs to reassemble them. + * + * URLs are requested in batches because each one is short-lived: signing all + * 640 possible parts up front would leave the last ones expired by the time a + * slow connection reached them. + * + * Parts go out one at a time. Concurrency would be faster, but a failure + * mid-flight has to abort the whole upload anyway, and a sequential loop makes + * "which part failed" unambiguous. + */ +async function uploadParts( + client: SimClient, + workspaceId: string, + upload: FileUpload, + blob: Blob +): Promise> { + const completed: Array<{ partNumber: number; etag?: string }> = [] + + for (let first = 1; first <= upload.partCount; first += PART_URL_BATCH) { + const partNumbers = [] + for (let n = first; n < first + PART_URL_BATCH && n <= upload.partCount; n++) { + partNumbers.push(n) + } + + const signed = await client.request<{ data: { parts: UploadPartUrl[] } }>( + `/api/v2/files/uploads/${encodeURIComponent(upload.id)}/parts`, + { + method: 'POST', + query: { workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + body: { partNumbers }, + } + ) + + for (const part of signed.data.parts) { + const start = (part.partNumber - 1) * upload.partSize + // `Blob.slice` is a view over the file on disk, so only the part being + // sent is ever read — the point of not buffering the upload. + const chunk = blob.slice(start, Math.min(start + upload.partSize, upload.size)) + + // boundary-raw-fetch: storage-signed URL on another origin, not the API + const response = await fetch(part.url, { + method: 'PUT', + headers: part.headers, + body: chunk, + }) + if (!response.ok) { + throw new SimApiError( + `Part ${part.partNumber} failed with status ${response.status}`, + response.status + ) + } + + // S3-compatible stores identify a part by the ETag they return; the API + // treats it as optional because not every backend sends one. + const etag = response.headers.get('etag')?.replace(/"/g, '') + completed.push(etag ? { partNumber: part.partNumber, etag } : { partNumber: part.partNumber }) + } + } + + return completed +} export function attachHandWritten(program: Command): void { - // ── files upload ── multipart, which the generated flag surface cannot express ── + // ── files upload ── a presigned multipart handshake, not one request ────── group(program, 'files') .command('upload ') .description('Upload a file to the workspace') @@ -161,13 +241,9 @@ export function attachHandWritten(program: Command): void { .option('--name ', 'Store it under a different name') .action( async (path: string, options: { folderId?: string; name?: string }, command: Command) => { - const { client, profile } = clientFrom(command) + const { client } = clientFrom(command) const workspaceId = client.requireWorkspace() - if (!profile.apiKey) { - throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0) - } - let size: number try { const stats = await stat(path) @@ -178,43 +254,54 @@ export function attachHandWritten(program: Command): void { throw new SimApiError(`Cannot read ${path}: ${(error as Error).message}`, 0) } - // Fail here rather than after streaming 100 MB the server will reject. - if (size > MAX_UPLOAD_BYTES) { - throw new SimApiError( - `${path} is ${(size / 1024 / 1024).toFixed(1)}MB; the limit is 100MB`, - 0 - ) - } + // The server sizes its own parts, but it cannot reject an empty file any + // more cheaply than we can: a zero-byte upload has no parts to send. + if (size === 0) throw new SimApiError(`${path} is empty`, 0) const name = options.name ?? basename(path) - const url = new URL(`${profile.endpoint}/api/v2/files`) - url.searchParams.set('workspaceId', workspaceId) - if (options.folderId) url.searchParams.set('folderId', options.folderId) - - // `openAsBlob` keeps the file on disk and reads it as the request is - // written; building a Buffer first would hold the whole upload in memory. - const body = new FormData() - body.append('file', await openAsBlob(path, { type: contentTypeFor(name) }), name) - const response = await fetch(url, { + const created = await client.request<{ data: FileUpload }>('/api/v2/files/uploads', { method: 'POST', - headers: { 'x-api-key': profile.apiKey }, - body, + body: { + workspaceId, + name, + contentType: contentTypeFor(name), + size, + ...(options.folderId ? { folderId: options.folderId } : {}), + }, }) + const upload = created.data - const payload = (await response.json().catch(() => null)) as { - data?: { id?: string } - error?: { message?: string } - } | null + // Any failure past this point leaves an upload holding storage, so the + // rest runs under an abort that the server also uses to release it. + try { + const blob = await openAsBlob(path) + const parts = await uploadParts(client, workspaceId, upload, blob) - if (!response.ok) { - throw new SimApiError( - payload?.error?.message ?? `Upload failed with status ${response.status}`, - response.status + const completed = await client.request<{ data: FileUpload }>( + `/api/v2/files/uploads/${encodeURIComponent(upload.id)}/complete`, + { + method: 'POST', + query: { workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + body: { parts }, + } + ) + console.log( + chalk.green(`✓ Uploaded ${name} (${completed.data.file?.id ?? completed.data.id})`) ) + } catch (error) { + await client + .request(`/api/v2/files/uploads/${encodeURIComponent(upload.id)}`, { + method: 'DELETE', + query: { workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + }) + // The original failure is what the caller needs; a failed cleanup + // must not replace it with a message about the cleanup. + .catch(() => undefined) + throw error } - - console.log(chalk.green(`✓ Uploaded ${name} (${payload?.data?.id ?? 'unknown id'})`)) } ) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 07f69806d57..92adecf48bc 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -50,6 +50,13 @@ export const CLI_CONTRACT: CliContract = { deleteCredential: { confirm: 'This deletes the credential; anything authenticating with it stops working.', }, + deleteWorkflow: { confirm: 'This deletes the workflow and its run history.' }, + deleteTableView: { confirm: 'This deletes the saved view and its filters.' }, + deleteWorkflowGroup: { + // Not just the grouping: the documented behaviour is that every column the + // group fed goes with it, values included. + confirm: 'This deletes the group, every column it fed, and the values in them.', + }, deleteFolder: { // The route archives the folder *and cascades to its contents*, so this is // the broadest delete on the surface — the message says so rather than @@ -243,6 +250,40 @@ export const CLI_CONTRACT: CliContract = { describe: 'Enable or disable sharing for a file', }, + // ─── The expanded tables surface ────────────────────────────────────────── + // `/cancel-runs`, `/rows/find`, `/restore`, `/columns/run` and the enrichment + // path all put a verb where the deriver expects a sub-resource, so each became + // a group holding a lone `create`. + cancelTableRuns: { command: 'tables cancel-runs', describe: 'Stop every running column job' }, + findTableRows: { command: 'tables rows find', describe: 'Find rows matching a predicate' }, + restoreTable: { command: 'tables restore', describe: 'Restore a deleted table' }, + runTableColumn: { command: 'tables columns run', describe: 'Run a column’s workflow' }, + runRowEnrichment: { + command: 'tables rows enrich', + describe: 'Run one row’s enrichment group', + }, + + // Transfers are a handshake: create, request part URLs, send the parts, then + // complete. Unlike `files upload` there is no single command driving this yet + // — the import body carries source/target/mapping choices a one-liner cannot + // express — so each step stays reachable under a name that says what it is. + createTableImport: { command: 'tables imports create' }, + createTableImportPartUrls: { + command: 'tables imports parts', + describe: 'Sign upload URLs for a batch of parts', + }, + completeTableImport: { + command: 'tables imports complete', + describe: 'Finish an import once every part is uploaded', + }, + cancelTableImport: { command: 'tables imports cancel' }, + cancelTableExport: { command: 'tables exports cancel' }, + tableExportDownload: { + // GET, but it returns a signed URL rather than a listing. + command: 'tables exports download', + describe: 'Get the download URL for a finished export', + }, + // ─── Documents, not records ─────────────────────────────────────────────── // The payload is the artifact: `sim workflows export > wf.json` has to // produce something `sim workflows import` accepts back. @@ -280,8 +321,18 @@ export const CLI_CONTRACT: CliContract = { }, // ─── Not a terminal-shaped operation ────────────────────────────────────── - // Multipart upload; `sim files upload ` needs its own file-reading - // command rather than a generated flag surface. - uploadFile: { hidden: true }, + // Multipart upload; `sim knowledge documents upload ` would need its own + // file-reading command rather than a generated flag surface. uploadKnowledgeDocument: { hidden: true }, + + // ─── Steps of a transfer, not commands ──────────────────────────────────── + // Uploading is now a presigned multipart handshake: create the upload, ask for + // part URLs in batches, PUT each part to storage, then complete with the + // ETags — and abort if any of it fails. Exposing the steps individually would + // advertise a protocol whose halfway states leak storage, so `sim files + // upload` drives the whole sequence and these stay out of the surface. + createFileUpload: { hidden: true }, + createFileUploadPartUrls: { hidden: true }, + completeFileUpload: { hidden: true }, + abortFileUpload: { hidden: true }, } diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index e31c6f1df5b..34c5aa8b163 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -10,6 +10,46 @@ * `packages/* must not import apps/*` boundary is preserved. */ +/** `DELETE /api/v2/files/uploads/[uploadId]` */ +export type AbortFileUploadParams = { + uploadId: string +} + +export type AbortFileUploadQuery = { + workspaceId: string +} + +export type AbortFileUploadHeaders = { + 'upload-token': string +} + +export type AbortFileUploadResponse = { + data: { + id: string + status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + name: string + contentType: string + size: number + partSize: number + partCount: number + uploadToken: string + expiresAt: string + error: string | null + file: { + id: string + name: string + size: number + type: string + key: string + folderId: string | null + folderPath: string | null + uploadedBy: string + uploadedAt: string + updatedAt: string + } | null + } +} + /** `POST /api/v2/tables/[tableId]/columns` */ export type AddTableColumnParams = { tableId: string @@ -52,6 +92,85 @@ export type AddTableColumnResponse = { } } +/** `POST /api/v2/tables/[tableId]/groups` */ +export type AddWorkflowGroupParams = { + tableId: string +} + +export type AddWorkflowGroupBody = { + workspaceId: string + group: { + id?: string + workflowId?: string + enrichmentId?: string + name?: string + type?: 'manual' | 'enrichment' + dependencies?: { + columns?: Array + } + outputs: Array<{ + blockId?: string + path?: string + outputId?: string + columnName: string + }> + inputMappings?: Array<{ + inputName: string + columnName: string + }> + deploymentMode?: 'live' | 'deployed' + autoRun?: boolean + } + outputColumns: Array<{ + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + }> + autoRun?: boolean +} + +export type AddWorkflowGroupResponse = { + data: { + group: { + id: string + workflowId: string + enrichmentId?: string + name?: string + type?: 'manual' | 'enrichment' + dependencies?: { + columns?: Array + } + outputs: Array<{ + blockId: string + path: string + outputId?: string + columnName: string + }> + inputMappings?: Array<{ + inputName: string + columnName: string + }> + deploymentMode?: 'live' | 'deployed' + autoRun?: boolean + } + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } +} + /** `POST /api/v2/files/bulk-archive` */ export type BulkArchiveFileItemsBody = { workspaceId: string @@ -68,6 +187,104 @@ export type BulkArchiveFileItemsResponse = { } } +/** `DELETE /api/v2/tables/exports/[exportId]` */ +export type CancelTableExportParams = { + exportId: string +} + +export type CancelTableExportQuery = { + workspaceId: string +} + +export type CancelTableExportResponse = { + data: { + id: string + tableId: string + workspaceId: string + format: 'csv' | 'json' + status: 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + +/** `DELETE /api/v2/tables/imports/[importId]` */ +export type CancelTableImportParams = { + importId: string +} + +export type CancelTableImportQuery = { + workspaceId: string +} + +export type CancelTableImportHeaders = { + 'upload-token'?: string +} + +export type CancelTableImportResponse = { + data: { + id: string + workspaceId: string + status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: + | { + type: 'upload' + name: string + contentType: string + size: number + } + | { + type: 'workspace_file' + fileId: string + } + target: + | { + type: 'new' + name: string + folderId?: string + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + upload: { + uploadToken: string + partSize: number + partCount: number + expiresAt: string + } | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + +/** `POST /api/v2/tables/[tableId]/cancel-runs` */ +export type CancelTableRunsParams = { + tableId: string +} + +export type CancelTableRunsBody = { + workspaceId: string + scope: 'all' | 'row' + rowId?: string + filter?: unknown + excludeRowIds?: Array +} + +export type CancelTableRunsResponse = { + data: { + cancelled: number + } +} + /** `POST /api/v2/workflows/[id]/executions/[executionId]/cancel` */ export type CancelWorkflowExecutionParams = { id: string @@ -91,6 +308,115 @@ export type CancelWorkflowExecutionResponse = { } } +/** `POST /api/v2/files/uploads/[uploadId]/complete` */ +export type CompleteFileUploadParams = { + uploadId: string +} + +export type CompleteFileUploadQuery = { + workspaceId: string +} + +export type CompleteFileUploadBody = { + parts: Array<{ + partNumber: number + etag?: string + }> +} + +export type CompleteFileUploadHeaders = { + 'upload-token': string +} + +export type CompleteFileUploadResponse = { + data: { + id: string + status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + name: string + contentType: string + size: number + partSize: number + partCount: number + uploadToken: string + expiresAt: string + error: string | null + file: { + id: string + name: string + size: number + type: string + key: string + folderId: string | null + folderPath: string | null + uploadedBy: string + uploadedAt: string + updatedAt: string + } | null + } +} + +/** `POST /api/v2/tables/imports/[importId]/complete` */ +export type CompleteTableImportParams = { + importId: string +} + +export type CompleteTableImportQuery = { + workspaceId: string +} + +export type CompleteTableImportBody = { + parts: Array<{ + partNumber: number + etag?: string + }> +} + +export type CompleteTableImportHeaders = { + 'upload-token': string +} + +export type CompleteTableImportResponse = { + data: { + id: string + workspaceId: string + status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: + | { + type: 'upload' + name: string + contentType: string + size: number + } + | { + type: 'workspace_file' + fileId: string + } + target: + | { + type: 'new' + name: string + folderId?: string + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + upload: { + uploadToken: string + partSize: number + partCount: number + expiresAt: string + } | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + /** `POST /api/v2/credentials` */ export type CreateCredentialBody = { workspaceId: string @@ -170,6 +496,70 @@ export type CreateCustomToolResponse = { } } +/** `POST /api/v2/files/uploads` */ +export type CreateFileUploadBody = { + workspaceId: string + name: string + contentType: string + size: number + folderId?: string +} + +export type CreateFileUploadResponse = { + data: { + id: string + status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + name: string + contentType: string + size: number + partSize: number + partCount: number + uploadToken: string + expiresAt: string + error: string | null + file: { + id: string + name: string + size: number + type: string + key: string + folderId: string | null + folderPath: string | null + uploadedBy: string + uploadedAt: string + updatedAt: string + } | null + } +} + +/** `POST /api/v2/files/uploads/[uploadId]/parts` */ +export type CreateFileUploadPartUrlsParams = { + uploadId: string +} + +export type CreateFileUploadPartUrlsQuery = { + workspaceId: string +} + +export type CreateFileUploadPartUrlsBody = { + partNumbers: Array +} + +export type CreateFileUploadPartUrlsHeaders = { + 'upload-token': string +} + +export type CreateFileUploadPartUrlsResponse = { + data: { + parts: Array<{ + partNumber: number + url: string + headers: Record + expiresAt: string + }> + } +} + /** `POST /api/v2/folders` */ export type CreateFolderBody = { workspaceId: string @@ -349,12 +739,151 @@ export type CreateTableResponse = { } rowCount: number maxRows: number + folderId: string | null + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + } | null createdAt: string updatedAt: string } } } +/** `POST /api/v2/tables/[tableId]/exports` */ +export type CreateTableExportParams = { + tableId: string +} + +export type CreateTableExportBody = { + workspaceId: string + format?: 'csv' | 'json' +} + +export type CreateTableExportResponse = { + data: { + id: string + tableId: string + workspaceId: string + format: 'csv' | 'json' + status: 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + +/** `POST /api/v2/tables/imports` */ +export type CreateTableImportBody = { + workspaceId: string + source: + | { + type: 'upload' + name: string + contentType: string + size: number + } + | { + type: 'workspace_file' + fileId: string + } + target: + | { + type: 'new' + name: string + folderId?: string + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + mapping?: unknown + createColumns?: unknown + timezone?: string +} + +export type CreateTableImportResponse = { + data: { + id: string + workspaceId: string + status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: + | { + type: 'upload' + name: string + contentType: string + size: number + } + | { + type: 'workspace_file' + fileId: string + } + target: + | { + type: 'new' + name: string + folderId?: string + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + upload: { + uploadToken: string + partSize: number + partCount: number + expiresAt: string + } | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + +/** `POST /api/v2/tables/imports/[importId]/parts` */ +export type CreateTableImportPartUrlsParams = { + importId: string +} + +export type CreateTableImportPartUrlsQuery = { + workspaceId: string +} + +export type CreateTableImportPartUrlsBody = { + partNumbers: Array +} + +export type CreateTableImportPartUrlsHeaders = { + 'upload-token': string +} + +export type CreateTableImportPartUrlsResponse = { + data: { + parts: Array<{ + partNumber: number + url: string + headers: Record + expiresAt: string + }> + } +} + /** `POST /api/v2/tables/[tableId]/rows` */ export type CreateTableRowsParams = { tableId: string @@ -395,6 +924,138 @@ export type CreateTableRowsResponse = } } +/** `POST /api/v2/tables/[tableId]/views` */ +export type CreateTableViewParams = { + tableId: string +} + +export type CreateTableViewBody = { + workspaceId: string + name: string + config: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: unknown | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } +} + +type CreateTableViewResponseRef0 = + | { + all: Array< + | CreateTableViewResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + | { + any: Array< + | CreateTableViewResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + +export type CreateTableViewResponse = { + data: { + view: { + id: string + tableId: string + name: string + config: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: CreateTableViewResponseRef0 | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + isDefault: boolean + createdBy: string | null + createdAt: string + updatedAt: string + } + } +} + +/** `POST /api/v2/workflows` */ +export type CreateWorkflowBody = { + workspaceId: string + name: string + description?: string | null + folderId?: string | null +} + +export type CreateWorkflowResponse = { + data: { + id: string + name: string + description: string | null + folderId: string | null + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string + } +} + /** `DELETE /api/v2/credentials/[id]` */ export type DeleteCredentialParams = { id: string @@ -614,6 +1275,65 @@ export type DeleteTableRowsResponse = { } } +/** `DELETE /api/v2/tables/[tableId]/views/[viewId]` */ +export type DeleteTableViewParams = { + tableId: string + viewId: string +} + +export type DeleteTableViewQuery = { + workspaceId: string +} + +export type DeleteTableViewResponse = { + data: { + id: string + } +} + +/** `DELETE /api/v2/workflows/[id]` */ +export type DeleteWorkflowParams = { + id: string +} + +export type DeleteWorkflowResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/tables/[tableId]/groups` */ +export type DeleteWorkflowGroupParams = { + tableId: string +} + +export type DeleteWorkflowGroupBody = { + workspaceId: string + groupId: string +} + +export type DeleteWorkflowGroupResponse = { + data: { + id: string + deleted: true + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } +} + /** `POST /api/v2/workflows/[id]/deploy` */ export type DeployWorkflowParams = { id: string @@ -832,6 +1552,32 @@ export type ExportWorkflowResponse = { } } +/** `POST /api/v2/tables/[tableId]/rows/find` */ +export type FindTableRowsParams = { + tableId: string +} + +export type FindTableRowsBody = { + workspaceId: string + q: string + predicate?: unknown + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> +} + +export type FindTableRowsResponse = { + data: { + matches: Array<{ + ordinal: number + rowId: string + column: string + }> + truncated: boolean + } +} + /** `GET /api/v2/audit-logs/[id]` */ export type GetAuditLogParams = { id: string @@ -1186,12 +1932,101 @@ export type GetTableResponse = { } rowCount: number maxRows: number + folderId: string | null + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + } | null createdAt: string updatedAt: string } } } +/** `GET /api/v2/tables/exports/[exportId]` */ +export type GetTableExportParams = { + exportId: string +} + +export type GetTableExportQuery = { + workspaceId: string +} + +export type GetTableExportResponse = { + data: { + id: string + tableId: string + workspaceId: string + format: 'csv' | 'json' + status: 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + +/** `GET /api/v2/tables/imports/[importId]` */ +export type GetTableImportParams = { + importId: string +} + +export type GetTableImportQuery = { + workspaceId: string +} + +export type GetTableImportResponse = { + data: { + id: string + workspaceId: string + status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: + | { + type: 'upload' + name: string + contentType: string + size: number + } + | { + type: 'workspace_file' + fileId: string + } + target: + | { + type: 'new' + name: string + folderId?: string + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + upload: { + uploadToken: string + partSize: number + partCount: number + expiresAt: string + } | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + /** `GET /api/v2/tables/[tableId]/rows/[rowId]` */ export type GetTableRowParams = { tableId: string @@ -1213,6 +2048,103 @@ export type GetTableRowResponse = { } } +/** `GET /api/v2/tables/[tableId]/views/[viewId]` */ +export type GetTableViewParams = { + tableId: string + viewId: string +} + +export type GetTableViewQuery = { + workspaceId: string +} + +type GetTableViewResponseRef0 = + | { + all: Array< + | GetTableViewResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + | { + any: Array< + | GetTableViewResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + +export type GetTableViewResponse = { + data: { + view: { + id: string + tableId: string + name: string + config: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: GetTableViewResponseRef0 | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + isDefault: boolean + createdBy: string | null + createdAt: string + updatedAt: string + } + } +} + /** `GET /api/v2/billing/usage` */ export type GetUsageSummaryQuery = { workspaceId?: string @@ -1311,6 +2243,24 @@ export type GetWorkflowExecutionResponse = { } } +/** `GET /api/v2/workflows/[id]/versions/[version]` */ +export type GetWorkflowVersionParams = { + id: string + version: number +} + +export type GetWorkflowVersionResponse = { + data: { + id: string + version: number + name: string | null + description: string | null + isActive: boolean + createdAt: string + state: unknown + } +} + /** `POST /api/v2/workflows/import` */ export type ImportWorkflowBody = { workspaceId: string @@ -1369,6 +2319,9 @@ export type ListCredentialsQuery = { workspaceId: string type?: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' providerId?: string + search?: string + sortBy?: 'displayName' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' } export type ListCredentialsResponse = { @@ -1391,6 +2344,9 @@ export type ListCredentialsResponse = { /** `GET /api/v2/custom-tools` */ export type ListCustomToolsQuery = { workspaceId: string + search?: string + sortBy?: 'title' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' } export type ListCustomToolsResponse = { @@ -1420,6 +2376,10 @@ export type ListCustomToolsResponse = { export type ListFilesQuery = { workspaceId: string scope?: 'active' | 'archived' + folderId?: string + search?: string + sortBy?: 'name' | 'size' | 'uploadedAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' limit?: number cursor?: string } @@ -1445,6 +2405,9 @@ export type ListFoldersQuery = { workspaceId: string resourceType: 'workflow' | 'knowledge_base' | 'table' scope?: 'active' | 'archived' + search?: string + sortBy?: 'position' | 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' } export type ListFoldersResponse = { @@ -1465,6 +2428,10 @@ export type ListFoldersResponse = { /** `GET /api/v2/knowledge` */ export type ListKnowledgeBasesQuery = { workspaceId: string + folderId?: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' } export type ListKnowledgeBasesResponse = { @@ -1587,6 +2554,9 @@ export type ListLogsResponse = { /** `GET /api/v2/mcp-servers` */ export type ListMcpServersQuery = { workspaceId: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' } export type ListMcpServersResponse = { @@ -1618,6 +2588,9 @@ export type ListMcpServersResponse = { /** `GET /api/v2/skills` */ export type ListSkillsQuery = { workspaceId: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' } export type ListSkillsResponse = { @@ -1656,6 +2629,10 @@ export type ListTableRowsResponse = { /** `GET /api/v2/tables` */ export type ListTablesQuery = { workspaceId: string + folderId?: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' } export type ListTablesResponse = { @@ -1681,6 +2658,115 @@ export type ListTablesResponse = { } rowCount: number maxRows: number + folderId: string | null + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + } | null + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/tables/[tableId]/views` */ +export type ListTableViewsParams = { + tableId: string +} + +export type ListTableViewsQuery = { + workspaceId: string +} + +type ListTableViewsResponseRef0 = + | { + all: Array< + | ListTableViewsResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + | { + any: Array< + | ListTableViewsResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + +export type ListTableViewsResponse = { + data: Array<{ + id: string + tableId: string + name: string + config: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: ListTableViewsResponseRef0 | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + isDefault: boolean + createdBy: string | null createdAt: string updatedAt: string }> @@ -1727,6 +2813,41 @@ export type ListUsageLogsResponse = { nextCursor: string | null } +/** `GET /api/v2/tables/[tableId]/groups` */ +export type ListWorkflowGroupsParams = { + tableId: string +} + +export type ListWorkflowGroupsQuery = { + workspaceId: string +} + +export type ListWorkflowGroupsResponse = { + data: Array<{ + id: string + workflowId: string + enrichmentId?: string + name?: string + type?: 'manual' | 'enrichment' + dependencies?: { + columns?: Array + } + outputs: Array<{ + blockId: string + path: string + outputId?: string + columnName: string + }> + inputMappings?: Array<{ + inputName: string + columnName: string + }> + deploymentMode?: 'live' | 'deployed' + autoRun?: boolean + }> + nextCursor: string | null +} + /** `GET /api/v2/workflows` */ export type ListWorkflowsQuery = { workspaceId: string @@ -1734,6 +2855,9 @@ export type ListWorkflowsQuery = { deployedOnly?: boolean limit?: number cursor?: string + search?: string + sortBy?: 'position' | 'name' | 'createdAt' | 'updatedAt' | 'runCount' + sortOrder?: 'asc' | 'desc' } export type ListWorkflowsResponse = { @@ -1753,6 +2877,30 @@ export type ListWorkflowsResponse = { nextCursor: string | null } +/** `GET /api/v2/workflows/[id]/versions` */ +export type ListWorkflowVersionsParams = { + id: string +} + +export type ListWorkflowVersionsQuery = { + limit?: number + cursor?: string +} + +export type ListWorkflowVersionsResponse = { + data: Array<{ + id: string + version: number + name?: string | null + description?: string | null + isActive: boolean + createdAt: string + deployedBy?: string | null + latestOperationStatus?: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' | null + }> + nextCursor: string | null +} + /** `POST /api/v2/files/move` */ export type MoveFileItemsBody = { workspaceId: string @@ -1837,6 +2985,59 @@ export type RestoreFileResponse = { } } +/** `POST /api/v2/tables/[tableId]/restore` */ +export type RestoreTableParams = { + tableId: string +} + +export type RestoreTableBody = { + workspaceId: string +} + +export type RestoreTableResponse = { + data: { + table: { + id: string + name: string + description: string | null + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } + rowCount: number + maxRows: number + folderId: string | null + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + } | null + createdAt: string + updatedAt: string + } + } +} + /** `POST /api/v2/workflows/[id]/rollback` */ export type RollbackWorkflowParams = { id: string @@ -1876,6 +3077,47 @@ export type RollbackWorkflowResponse = { } } +/** `POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]` */ +export type RunRowEnrichmentParams = { + tableId: string + rowId: string + groupId: string +} + +export type RunRowEnrichmentBody = { + workspaceId: string +} + +export type RunRowEnrichmentResponse = { + data: { + dispatchId: string | null + } +} + +/** `POST /api/v2/tables/[tableId]/columns/run` */ +export type RunTableColumnParams = { + tableId: string +} + +export type RunTableColumnBody = { + workspaceId: string + groupIds: Array + runMode?: 'all' | 'incomplete' + rowIds?: Array + filter?: unknown + excludeRowIds?: Array + limit?: { + type: 'rows' + max: number + } +} + +export type RunTableColumnResponse = { + data: { + dispatchId: string | null + } +} + /** `POST /api/v2/knowledge/search` */ export type SearchKnowledgeBody = { workspaceId: string @@ -1910,6 +3152,23 @@ export type SearchKnowledgeResponse = { } } +/** `GET /api/v2/tables/exports/[exportId]/download` */ +export type TableExportDownloadParams = { + exportId: string +} + +export type TableExportDownloadQuery = { + workspaceId: string +} + +export type TableExportDownloadResponse = { + data: { + url: string + fileName: string + expiresAt: string + } +} + /** `DELETE /api/v2/workflows/[id]/deploy` */ export type UndeployWorkflowParams = { id: string @@ -2225,6 +3484,61 @@ export type UpdateSkillResponse = { } } +/** `PATCH /api/v2/tables/[tableId]` */ +export type UpdateTableParams = { + tableId: string +} + +export type UpdateTableBody = { + workspaceId: string + name?: string + folderId?: string | null +} + +export type UpdateTableResponse = { + data: { + table: { + id: string + name: string + description: string | null + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } + rowCount: number + maxRows: number + folderId: string | null + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + } | null + createdAt: string + updatedAt: string + } + } +} + /** `PATCH /api/v2/tables/[tableId]/columns` */ export type UpdateTableColumnParams = { tableId: string @@ -2288,27 +3602,234 @@ export type UpdateTableRowResponse = { } } -/** `POST /api/v2/files` */ -export type UploadFileQuery = { +/** `PATCH /api/v2/tables/[tableId]/views/[viewId]` */ +export type UpdateTableViewParams = { + tableId: string + viewId: string +} + +export type UpdateTableViewBody = { workspaceId: string - folderId?: string + name?: string + config?: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: unknown | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + configPatch?: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: unknown | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + isDefault?: boolean +} + +type UpdateTableViewResponseRef0 = + | { + all: Array< + | UpdateTableViewResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + | { + any: Array< + | UpdateTableViewResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + +export type UpdateTableViewResponse = { + data: { + view: { + id: string + tableId: string + name: string + config: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: UpdateTableViewResponseRef0 | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + isDefault: boolean + createdBy: string | null + createdAt: string + updatedAt: string + } + } +} + +/** `PATCH /api/v2/workflows/[id]` */ +export type UpdateWorkflowParams = { + id: string +} + +export type UpdateWorkflowBody = { + name?: string + description?: string | null + folderId?: string | null } -export type UploadFileResponse = { +export type UpdateWorkflowResponse = { data: { id: string name: string - size: number - type: string - key: string + description: string | null folderId: string | null - folderPath: string | null - uploadedBy: string - uploadedAt: string + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string updatedAt: string } } +/** `PATCH /api/v2/tables/[tableId]/groups` */ +export type UpdateWorkflowGroupParams = { + tableId: string +} + +export type UpdateWorkflowGroupBody = { + workspaceId: string + groupId: string + workflowId?: string + name?: string + dependencies?: { + columns?: Array + } + outputs?: Array<{ + blockId?: string + path?: string + outputId?: string + columnName: string + }> + newOutputColumns?: Array<{ + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + }> + mappingUpdates?: Array<{ + columnName: string + blockId: string + path: string + }> + inputMappings?: Array<{ + inputName: string + columnName: string + }> + deploymentMode?: 'live' | 'deployed' + type?: 'manual' | 'enrichment' + autoRun?: boolean +} + +export type UpdateWorkflowGroupResponse = { + data: { + group: { + id: string + workflowId: string + enrichmentId?: string + name?: string + type?: 'manual' | 'enrichment' + dependencies?: { + columns?: Array + } + outputs: Array<{ + blockId: string + path: string + outputId?: string + columnName: string + }> + inputMappings?: Array<{ + inputName: string + columnName: string + }> + deploymentMode?: 'live' | 'deployed' + autoRun?: boolean + } + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } +} + /** `POST /api/v2/knowledge/[id]/documents` */ export type UploadKnowledgeDocumentParams = { id: string @@ -2401,6 +3922,16 @@ export type UpsertTableRowResponse = { * specs so `--help` reuses prose that is already written and already checked. */ export const V2_OPERATIONS = { + abortFileUpload: { + method: 'DELETE', + path: '/api/v2/files/uploads/[uploadId]', + pathParams: ['uploadId'] as const, + responseMode: 'json', + summary: 'Abort File Upload', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, addTableColumn: { method: 'POST', path: '/api/v2/tables/[tableId]/columns', @@ -2412,16 +3943,63 @@ export const V2_OPERATIONS = { column: { kind: 'object', required: true }, }, }, + addWorkflowGroup: { + method: 'POST', + path: '/api/v2/tables/[tableId]/groups', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Add Workflow Group', + body: { + workspaceId: { kind: 'string', required: true }, + group: { kind: 'object', required: true }, + outputColumns: { kind: 'array', required: true }, + autoRun: { kind: 'boolean', default: false }, + }, + }, bulkArchiveFileItems: { method: 'POST', path: '/api/v2/files/bulk-archive', pathParams: [] as const, responseMode: 'json', - summary: 'Archive Files and Folders', + summary: 'Archive Files and Folders', + body: { + workspaceId: { kind: 'string', required: true }, + fileIds: { kind: 'array', default: [] }, + folderIds: { kind: 'array', default: [] }, + }, + }, + cancelTableExport: { + method: 'DELETE', + path: '/api/v2/tables/exports/[exportId]', + pathParams: ['exportId'] as const, + responseMode: 'json', + summary: 'Cancel Table Export', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + cancelTableImport: { + method: 'DELETE', + path: '/api/v2/tables/imports/[importId]', + pathParams: ['importId'] as const, + responseMode: 'json', + summary: 'Cancel Table Import', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + cancelTableRuns: { + method: 'POST', + path: '/api/v2/tables/[tableId]/cancel-runs', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Cancel Column Runs', body: { workspaceId: { kind: 'string', required: true }, - fileIds: { kind: 'array', default: [] }, - folderIds: { kind: 'array', default: [] }, + scope: { kind: 'enum', required: true, values: ['all', 'row'] as const }, + rowId: { kind: 'string' }, + filter: { kind: 'unknown' }, + excludeRowIds: { kind: 'array' }, }, }, cancelWorkflowExecution: { @@ -2431,6 +4009,32 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Cancel an execution', }, + completeFileUpload: { + method: 'POST', + path: '/api/v2/files/uploads/[uploadId]/complete', + pathParams: ['uploadId'] as const, + responseMode: 'json', + summary: 'Complete File Upload', + query: { + workspaceId: { kind: 'string', required: true }, + }, + body: { + parts: { kind: 'array', required: true }, + }, + }, + completeTableImport: { + method: 'POST', + path: '/api/v2/tables/imports/[importId]/complete', + pathParams: ['importId'] as const, + responseMode: 'json', + summary: 'Complete Table Import Upload', + query: { + workspaceId: { kind: 'string', required: true }, + }, + body: { + parts: { kind: 'array', required: true }, + }, + }, createCredential: { method: 'POST', path: '/api/v2/credentials', @@ -2471,6 +4075,33 @@ export const V2_OPERATIONS = { code: { kind: 'string', required: true }, }, }, + createFileUpload: { + method: 'POST', + path: '/api/v2/files/uploads', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create File Upload', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + contentType: { kind: 'string', required: true }, + size: { kind: 'integer', required: true }, + folderId: { kind: 'string' }, + }, + }, + createFileUploadPartUrls: { + method: 'POST', + path: '/api/v2/files/uploads/[uploadId]/parts', + pathParams: ['uploadId'] as const, + responseMode: 'json', + summary: 'Create File Upload Part URLs', + query: { + workspaceId: { kind: 'string', required: true }, + }, + body: { + partNumbers: { kind: 'array', required: true }, + }, + }, createFolder: { method: 'POST', path: '/api/v2/folders', @@ -2550,6 +4181,45 @@ export const V2_OPERATIONS = { folderId: { kind: 'string' }, }, }, + createTableExport: { + method: 'POST', + path: '/api/v2/tables/[tableId]/exports', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Create Table Export', + body: { + workspaceId: { kind: 'string', required: true }, + format: { kind: 'enum', values: ['csv', 'json'] as const, default: 'csv' }, + }, + }, + createTableImport: { + method: 'POST', + path: '/api/v2/tables/imports', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Table Import', + body: { + workspaceId: { kind: 'string', required: true }, + source: { kind: 'unknown', required: true }, + target: { kind: 'unknown', required: true }, + mapping: { kind: 'unknown' }, + createColumns: { kind: 'unknown' }, + timezone: { kind: 'string' }, + }, + }, + createTableImportPartUrls: { + method: 'POST', + path: '/api/v2/tables/imports/[importId]/parts', + pathParams: ['importId'] as const, + responseMode: 'json', + summary: 'Create Table Import Part URLs', + query: { + workspaceId: { kind: 'string', required: true }, + }, + body: { + partNumbers: { kind: 'array', required: true }, + }, + }, createTableRows: { method: 'POST', path: '/api/v2/tables/[tableId]/rows', @@ -2557,6 +4227,31 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Create Rows', }, + createTableView: { + method: 'POST', + path: '/api/v2/tables/[tableId]/views', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Create View', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + config: { kind: 'object', required: true }, + }, + }, + createWorkflow: { + method: 'POST', + path: '/api/v2/workflows', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Workflow', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + description: { kind: 'string' }, + folderId: { kind: 'string' }, + }, + }, deleteCredential: { method: 'DELETE', path: '/api/v2/credentials/[id]', @@ -2686,6 +4381,34 @@ export const V2_OPERATIONS = { rowIds: { kind: 'array' }, }, }, + deleteTableView: { + method: 'DELETE', + path: '/api/v2/tables/[tableId]/views/[viewId]', + pathParams: ['tableId', 'viewId'] as const, + responseMode: 'json', + summary: 'Delete View', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + deleteWorkflow: { + method: 'DELETE', + path: '/api/v2/workflows/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete Workflow', + }, + deleteWorkflowGroup: { + method: 'DELETE', + path: '/api/v2/tables/[tableId]/groups', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Delete Workflow Group', + body: { + workspaceId: { kind: 'string', required: true }, + groupId: { kind: 'string', required: true }, + }, + }, deployWorkflow: { method: 'POST', path: '/api/v2/workflows/[id]/deploy', @@ -2727,6 +4450,19 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Export a workflow', }, + findTableRows: { + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/find', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Find Rows', + body: { + workspaceId: { kind: 'string', required: true }, + q: { kind: 'string', required: true }, + predicate: { kind: 'unknown' }, + sort: { kind: 'array' }, + }, + }, getAuditLog: { method: 'GET', path: '/api/v2/audit-logs/[id]', @@ -2843,6 +4579,26 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + getTableExport: { + method: 'GET', + path: '/api/v2/tables/exports/[exportId]', + pathParams: ['exportId'] as const, + responseMode: 'json', + summary: 'Get Table Export', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + getTableImport: { + method: 'GET', + path: '/api/v2/tables/imports/[importId]', + pathParams: ['importId'] as const, + responseMode: 'json', + summary: 'Get Table Import', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, getTableRow: { method: 'GET', path: '/api/v2/tables/[tableId]/rows/[rowId]', @@ -2853,6 +4609,16 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + getTableView: { + method: 'GET', + path: '/api/v2/tables/[tableId]/views/[viewId]', + pathParams: ['tableId', 'viewId'] as const, + responseMode: 'json', + summary: 'Get View', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, getUsageSummary: { method: 'GET', path: '/api/v2/billing/usage', @@ -2881,6 +4647,13 @@ export const V2_OPERATIONS = { selectedOutputs: { kind: 'string' }, }, }, + getWorkflowVersion: { + method: 'GET', + path: '/api/v2/workflows/[id]/versions/[version]', + pathParams: ['id', 'version'] as const, + responseMode: 'json', + summary: 'Get Workflow Version', + }, importWorkflow: { method: 'POST', path: '/api/v2/workflows/import', @@ -2927,6 +4700,13 @@ export const V2_OPERATIONS = { values: ['oauth', 'env_workspace', 'env_personal', 'service_account'] as const, }, providerId: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['displayName', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, }, }, listCustomTools: { @@ -2937,6 +4717,13 @@ export const V2_OPERATIONS = { summary: 'List Custom Tools', query: { workspaceId: { kind: 'string', required: true }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['title', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, }, }, listFiles: { @@ -2948,6 +4735,14 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, scope: { kind: 'enum', values: ['active', 'archived'] as const, default: 'active' }, + folderId: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'size', 'uploadedAt', 'updatedAt'] as const, + default: 'uploadedAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, limit: { kind: 'number', default: 100 }, cursor: { kind: 'string' }, }, @@ -2966,6 +4761,13 @@ export const V2_OPERATIONS = { values: ['workflow', 'knowledge_base', 'table'] as const, }, scope: { kind: 'enum', values: ['active', 'archived'] as const, default: 'active' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['position', 'name', 'createdAt', 'updatedAt'] as const, + default: 'position', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, }, }, listKnowledgeBases: { @@ -2976,6 +4778,14 @@ export const V2_OPERATIONS = { summary: 'List Knowledge Bases', query: { workspaceId: { kind: 'string', required: true }, + folderId: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, }, }, listKnowledgeDocuments: { @@ -3046,6 +4856,13 @@ export const V2_OPERATIONS = { summary: 'List MCP Servers', query: { workspaceId: { kind: 'string', required: true }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, }, }, listSkills: { @@ -3056,6 +4873,13 @@ export const V2_OPERATIONS = { summary: 'List Skills', query: { workspaceId: { kind: 'string', required: true }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, }, }, listTableRows: { @@ -3076,6 +4900,24 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'List Tables', + query: { + workspaceId: { kind: 'string', required: true }, + folderId: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, + listTableViews: { + method: 'GET', + path: '/api/v2/tables/[tableId]/views', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'List Views', query: { workspaceId: { kind: 'string', required: true }, }, @@ -3113,6 +4955,16 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + listWorkflowGroups: { + method: 'GET', + path: '/api/v2/tables/[tableId]/groups', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'List Workflow Groups', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, listWorkflows: { method: 'GET', path: '/api/v2/workflows', @@ -3125,6 +4977,24 @@ export const V2_OPERATIONS = { deployedOnly: { kind: 'boolean' }, limit: { kind: 'number', default: 50 }, cursor: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['position', 'name', 'createdAt', 'updatedAt', 'runCount'] as const, + default: 'position', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, + listWorkflowVersions: { + method: 'GET', + path: '/api/v2/workflows/[id]/versions', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'List Workflow Versions', + query: { + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, }, }, moveFileItems: { @@ -3175,6 +5045,16 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + restoreTable: { + method: 'POST', + path: '/api/v2/tables/[tableId]/restore', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Restore Table', + body: { + workspaceId: { kind: 'string', required: true }, + }, + }, rollbackWorkflow: { method: 'POST', path: '/api/v2/workflows/[id]/rollback', @@ -3182,6 +5062,32 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Rollback Workflow', }, + runRowEnrichment: { + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', + pathParams: ['tableId', 'rowId', 'groupId'] as const, + responseMode: 'json', + summary: 'Run Enrichment For One Row', + body: { + workspaceId: { kind: 'string', required: true }, + }, + }, + runTableColumn: { + method: 'POST', + path: '/api/v2/tables/[tableId]/columns/run', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Run Column Groups', + body: { + workspaceId: { kind: 'string', required: true }, + groupIds: { kind: 'array', required: true }, + runMode: { kind: 'enum', values: ['all', 'incomplete'] as const, default: 'all' }, + rowIds: { kind: 'array' }, + filter: { kind: 'unknown' }, + excludeRowIds: { kind: 'array' }, + limit: { kind: 'object' }, + }, + }, searchKnowledge: { method: 'POST', path: '/api/v2/knowledge/search', @@ -3197,6 +5103,16 @@ export const V2_OPERATIONS = { searchMode: { kind: 'enum', default: 'vector' }, }, }, + tableExportDownload: { + method: 'GET', + path: '/api/v2/tables/exports/[exportId]/download', + pathParams: ['exportId'] as const, + responseMode: 'json', + summary: 'Download Table Export', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, undeployWorkflow: { method: 'DELETE', path: '/api/v2/workflows/[id]/deploy', @@ -3328,6 +5244,18 @@ export const V2_OPERATIONS = { content: { kind: 'string' }, }, }, + updateTable: { + method: 'PATCH', + path: '/api/v2/tables/[tableId]', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Update Table', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string' }, + folderId: { kind: 'string' }, + }, + }, updateTableColumn: { method: 'PATCH', path: '/api/v2/tables/[tableId]/columns', @@ -3351,17 +5279,53 @@ export const V2_OPERATIONS = { data: { kind: 'unknown', required: true }, }, }, - uploadFile: { - method: 'POST', - path: '/api/v2/files', - pathParams: [] as const, + updateTableView: { + method: 'PATCH', + path: '/api/v2/tables/[tableId]/views/[viewId]', + pathParams: ['tableId', 'viewId'] as const, responseMode: 'json', - summary: 'Upload File', - query: { + summary: 'Update View', + body: { workspaceId: { kind: 'string', required: true }, + name: { kind: 'string' }, + config: { kind: 'object' }, + configPatch: { kind: 'object' }, + isDefault: { kind: 'boolean' }, + }, + }, + updateWorkflow: { + method: 'PATCH', + path: '/api/v2/workflows/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update Workflow', + body: { + name: { kind: 'string' }, + description: { kind: 'string' }, folderId: { kind: 'string' }, }, }, + updateWorkflowGroup: { + method: 'PATCH', + path: '/api/v2/tables/[tableId]/groups', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Update Workflow Group', + body: { + workspaceId: { kind: 'string', required: true }, + groupId: { kind: 'string', required: true }, + workflowId: { kind: 'string' }, + name: { kind: 'string' }, + dependencies: { kind: 'object' }, + outputs: { kind: 'array' }, + newOutputColumns: { kind: 'array' }, + mappingUpdates: { kind: 'array' }, + inputMappings: { kind: 'array' }, + deploymentMode: { kind: 'enum', values: ['live', 'deployed'] as const }, + type: { kind: 'enum', values: ['manual', 'enrichment'] as const }, + autoRun: { kind: 'boolean' }, + }, + }, uploadKnowledgeDocument: { method: 'POST', path: '/api/v2/knowledge/[id]/documents', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index af5189b9aab..9e36ea232db 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -97,7 +97,15 @@ describe('destructive operations are gated', () => { * and the contract renames it accordingly. Everything else that deletes is * gated behind `--yes`. */ - const NOT_DESTRUCTIVE = new Set(['undeployWorkflow']) + const NOT_DESTRUCTIVE = new Set([ + 'undeployWorkflow', + // Each of these stops something in flight rather than destroying something + // kept: an upload that has not been completed owns nothing but its own + // parts, and a cancelled import or export can simply be started again. + 'abortFileUpload', + 'cancelTableImport', + 'cancelTableExport', + ]) it('every DELETE carries a confirmation message', () => { // Without this, a new v2 domain arrives through generation with working diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index 0c806c31db8..96b640a43ac 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -36,6 +36,8 @@ export interface RequestOptions { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' query?: Record body?: unknown + /** Contract-declared headers, e.g. the `upload-token` a transfer is bound to. */ + headers?: Record } function buildUrl(endpoint: string, path: string, query?: Record): string { @@ -135,6 +137,7 @@ export class SimClient { 'x-api-key': apiKey, accept: 'application/json', ...(hasBody ? { 'content-type': 'application/json' } : {}), + ...options.headers, }, body: hasBody ? JSON.stringify(options.body) : undefined, }) diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index 67d3c2c741d..bd85c450597 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -181,13 +181,24 @@ type JsonSchema = Record * produces from these contracts. * * Hand-rolled rather than pulled from `json-schema-to-typescript`: the input is - * a known, narrow subset (no `$ref`, no `patternProperties`, no draft-04 - * quirks), and the output is committed and read by humans, so controlling the - * formatting is worth more here than covering spec corners that never appear. - * An unhandled construct throws rather than degrading to `any` — silence is how - * a generated client drifts from its server. + * a known, narrow subset (no `patternProperties`, no draft-04 quirks), and the + * output is committed and read by humans, so controlling the formatting is + * worth more here than covering spec corners that never appear. An unhandled + * construct throws rather than degrading to `any` — silence is how a generated + * client drifts from its server. + * + * `refs` maps a `$defs` key to the TypeScript alias hoisted for it. Zod factors + * a schema out into `$defs` when it is recursive, which the table view's filter + * grammar is — a predicate holds predicates — so it cannot be inlined. */ -function toTypeScript(schema: JsonSchema, indent = 0): string { +function toTypeScript(schema: JsonSchema, indent = 0, refs?: Map): string { + if (typeof schema.$ref === 'string') { + const key = schema.$ref.replace('#/$defs/', '') + const name = refs?.get(key) + if (!name) throw new Error(`Unresolved $ref: ${schema.$ref}`) + return name + } + const pad = ' '.repeat(indent + 1) const closePad = ' '.repeat(indent) @@ -196,11 +207,11 @@ function toTypeScript(schema: JsonSchema, indent = 0): string { const variants = schema.anyOf ?? schema.oneOf if (variants) { - return variants.map((v: JsonSchema) => toTypeScript(v, indent)).join(' | ') + return variants.map((v: JsonSchema) => toTypeScript(v, indent, refs)).join(' | ') } if (schema.allOf) { - return schema.allOf.map((v: JsonSchema) => toTypeScript(v, indent)).join(' & ') + return schema.allOf.map((v: JsonSchema) => toTypeScript(v, indent, refs)).join(' & ') } switch (schema.type) { @@ -214,7 +225,7 @@ function toTypeScript(schema: JsonSchema, indent = 0): string { case 'null': return 'null' case 'array': - return schema.items ? `Array<${toTypeScript(schema.items, indent)}>` : 'unknown[]' + return schema.items ? `Array<${toTypeScript(schema.items, indent, refs)}>` : 'unknown[]' case 'object': { const properties: Record = schema.properties ?? {} const required: string[] = schema.required ?? [] @@ -224,7 +235,7 @@ function toTypeScript(schema: JsonSchema, indent = 0): string { // A bare object with only `additionalProperties` is a record. const value = schema.additionalProperties && typeof schema.additionalProperties === 'object' - ? toTypeScript(schema.additionalProperties, indent) + ? toTypeScript(schema.additionalProperties, indent, refs) : 'unknown' return `Record` } @@ -232,7 +243,7 @@ function toTypeScript(schema: JsonSchema, indent = 0): string { const lines = keys.map((key) => { const optional = required.includes(key) ? '' : '?' const safeKey = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key) - return `${pad}${safeKey}${optional}: ${toTypeScript(properties[key], indent + 1)}` + return `${pad}${safeKey}${optional}: ${toTypeScript(properties[key], indent + 1, refs)}` }) return `{\n${lines.join('\n')}\n${closePad}}` } @@ -244,9 +255,32 @@ function toTypeScript(schema: JsonSchema, indent = 0): string { throw new Error(`Unhandled JSON Schema construct: ${JSON.stringify(schema).slice(0, 200)}`) } -function schemaToType(schema: z.ZodType, io: 'input' | 'output'): string { +/** + * A type plus any aliases that must be declared before it. + * + * A recursive schema cannot be written inline, so Zod lifts it into `$defs` and + * points at it; those become real named types, which TypeScript resolves + * recursively without complaint. + */ +interface GeneratedType { + type: string + declarations: string[] +} + +function schemaToType(schema: z.ZodType, io: 'input' | 'output', name: string): GeneratedType { const json = z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as JsonSchema - return toTypeScript(json) + const defs = json.$defs as Record | undefined + if (!defs) return { type: toTypeScript(json), declarations: [] } + + // Named after the type that owns them, so two operations lifting their own + // `__schema0` cannot collide in the single generated module. + const refs = new Map(Object.keys(defs).map((key, index) => [key, `${name}Ref${index}`])) + const declarations = Object.entries(defs).map( + ([key, def]) => `type ${refs.get(key)} = ${toTypeScript(def, 0, refs)}\n` + ) + + const { $defs, ...root } = json + return { type: toTypeScript(root, 0, refs), declarations } } /** Path params the CLI must substitute, e.g. `/api/v2/workflows/[id]` → `['id']`. */ @@ -361,12 +395,17 @@ function render(operations: Operation[]): string { for (const slot of ['params', 'query', 'body', 'headers'] as const) { const schema = contract[slot] if (!schema) continue - out.push(`export type ${Name}${pascal(slot)} = ${schemaToType(schema, 'input')}`) + const slotName = `${Name}${pascal(slot)}` + const generated = schemaToType(schema, 'input', slotName) + out.push(...generated.declarations) + out.push(`export type ${slotName} = ${generated.type}`) out.push('') } if (contract.response.mode === 'json' && contract.response.schema) { - out.push(`export type ${Name}Response = ${schemaToType(contract.response.schema, 'output')}`) + const generated = schemaToType(contract.response.schema, 'output', `${Name}Response`) + out.push(...generated.declarations) + out.push(`export type ${Name}Response = ${generated.type}`) } else { out.push(`/** Non-JSON response (\`${contract.response.mode}\`). */`) out.push(`export type ${Name}Response = never`) From 39a4a4fcdebe300c6fd23f38e7f45e08b03b059f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 18:25:26 -0700 Subject: [PATCH 051/159] feat(cli): sim tables import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Imports a CSV into a new or existing table, driving the same presigned handshake `files upload` uses — the two are the same protocol against different paths, so they now share one implementation. What made this more than a wrapper is that the import carries decisions the handshake does not: the source is a local file or one already in the workspace, the target is a new table or an existing one to append to or replace, and mapping/createColumns are rejected unless the target is existing. Both choices are required rather than inferred — defaulting to a new table would turn a forgotten --to-table into a silent second copy of the data — and the conditional flags are checked here so the error names the flag instead of arriving as a complaint about the request body. The transfer only queues the work; rows are parsed afterwards, so returning at `complete` would report success for an import that goes on to fail on a bad row. It polls to a settled status and reports the rows written, with progress on a terminal only. --no-wait opts out. The handshake steps are hidden now that a command drives them; `imports get` and `imports cancel` stay, being useful against an import already running. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- .../sim-cli/src/commands/hand-written.test.ts | 57 ++- packages/sim-cli/src/commands/hand-written.ts | 328 +++++++++++++++--- packages/sim-cli/src/contract/commands.ts | 20 +- 3 files changed, 333 insertions(+), 72 deletions(-) diff --git a/packages/sim-cli/src/commands/hand-written.test.ts b/packages/sim-cli/src/commands/hand-written.test.ts index eb3cedc1f50..b2fa54e6c99 100644 --- a/packages/sim-cli/src/commands/hand-written.test.ts +++ b/packages/sim-cli/src/commands/hand-written.test.ts @@ -1,8 +1,16 @@ import { createWriteStream, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { streamToFile } from './hand-written.js' +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { attachHandWritten, streamToFile } from './hand-written.js' + +vi.mock('../context.js', () => ({ + clientFrom: () => ({ + client: { request: vi.fn(), requireWorkspace: () => 'ws_local' }, + profile: { workspaceId: 'ws_local', output: 'json', name: 'default', apiKey: 'k' }, + }), +})) let dir: string @@ -59,3 +67,48 @@ describe('streamToFile', () => { } ) }) + +describe('tables import argument guards', () => { + function importCommand(): Command { + const root = new Command('sim').exitOverride() + attachHandWritten(root) + const walk = (command: Command) => { + command.exitOverride() + command.commands.forEach(walk) + } + walk(root) + return root + } + + async function run(argv: string[]) { + await importCommand().parseAsync(['node', 'sim', 'tables', 'import', ...argv]) + } + + it('refuses to guess the target', async () => { + // Defaulting to a new table would turn a forgotten `--to-table` into a + // silent second copy of the data. + await expect(run(['f.csv'])).rejects.toThrow(/exactly one of --new-table/) + await expect(run(['f.csv', '--new-table', 'a', '--to-table', 't'])).rejects.toThrow( + /exactly one of --new-table/ + ) + }) + + it('refuses to guess the source', async () => { + await expect(run(['--new-table', 'a'])).rejects.toThrow(/exactly one of /) + await expect(run(['f.csv', '--new-table', 'a', '--file-id', 'w_1'])).rejects.toThrow( + /exactly one of / + ) + }) + + it('names the flag when mapping is paired with a new table', async () => { + // The server rejects this too, but as a message about the request body. + await expect(run(['f.csv', '--new-table', 'a', '--mapping', '{}'])).rejects.toThrow( + /--to-table only/ + ) + }) + + it('checks all of that before touching the filesystem', async () => { + // `f.csv` does not exist; a "cannot read" error would mean a guard ran late. + await expect(run(['f.csv'])).rejects.toThrow(/exactly one of/) + }) +}) diff --git a/packages/sim-cli/src/commands/hand-written.ts b/packages/sim-cli/src/commands/hand-written.ts index af16b39e2dc..4979868bac4 100644 --- a/packages/sim-cli/src/commands/hand-written.ts +++ b/packages/sim-cli/src/commands/hand-written.ts @@ -8,6 +8,7 @@ import { clientFrom } from '../context.js' import type { QueryRowsResponse } from '../generated/v2-api.js' import { SimApiError, type SimClient } from '../http/client.js' import { type Column, printList, sanitize, text } from '../output/render.js' +import { coerce } from '../runtime/request.js' /** * Commands the generated runtime cannot produce. @@ -155,12 +156,27 @@ interface UploadPartUrl { headers: Record } +/** + * What a transfer needs to send its bytes, however it was started. + * + * File uploads and table imports are the same handshake against different + * paths — identical part-URL and complete bodies, the same `upload-token` + * header — so one implementation drives both. `basePath` is the transfer's own + * resource; `/parts` and `/complete` hang off it and DELETE aborts it. + */ +interface Transfer { + basePath: string + uploadToken: string + partSize: number + partCount: number + size: number +} + interface FileUpload { id: string - size: number + uploadToken: string partSize: number partCount: number - uploadToken: string file: { id: string } | null } @@ -176,38 +192,38 @@ const PART_URL_BATCH = 100 * slow connection reached them. * * Parts go out one at a time. Concurrency would be faster, but a failure - * mid-flight has to abort the whole upload anyway, and a sequential loop makes - * "which part failed" unambiguous. + * mid-flight has to abort the whole transfer anyway, and a sequential loop + * makes "which part failed" unambiguous. */ async function uploadParts( client: SimClient, workspaceId: string, - upload: FileUpload, + transfer: Transfer, blob: Blob ): Promise> { const completed: Array<{ partNumber: number; etag?: string }> = [] - for (let first = 1; first <= upload.partCount; first += PART_URL_BATCH) { + for (let first = 1; first <= transfer.partCount; first += PART_URL_BATCH) { const partNumbers = [] - for (let n = first; n < first + PART_URL_BATCH && n <= upload.partCount; n++) { + for (let n = first; n < first + PART_URL_BATCH && n <= transfer.partCount; n++) { partNumbers.push(n) } const signed = await client.request<{ data: { parts: UploadPartUrl[] } }>( - `/api/v2/files/uploads/${encodeURIComponent(upload.id)}/parts`, + `${transfer.basePath}/parts`, { method: 'POST', query: { workspaceId }, - headers: { 'upload-token': upload.uploadToken }, + headers: { 'upload-token': transfer.uploadToken }, body: { partNumbers }, } ) for (const part of signed.data.parts) { - const start = (part.partNumber - 1) * upload.partSize + const start = (part.partNumber - 1) * transfer.partSize // `Blob.slice` is a view over the file on disk, so only the part being // sent is ever read — the point of not buffering the upload. - const chunk = blob.slice(start, Math.min(start + upload.partSize, upload.size)) + const chunk = blob.slice(start, Math.min(start + transfer.partSize, transfer.size)) // boundary-raw-fetch: storage-signed URL on another origin, not the API const response = await fetch(part.url, { @@ -232,6 +248,129 @@ async function uploadParts( return completed } +/** + * Runs a started transfer to completion: send the parts, then complete it. + * + * Anything that fails in between aborts the transfer, because a half-finished + * one holds storage the server would otherwise keep until it expires. A failed + * abort is swallowed — the original failure is what the caller needs to see. + */ +async function finishTransfer( + client: SimClient, + workspaceId: string, + transfer: Transfer, + path: string +): Promise { + try { + const blob = await openAsBlob(path) + const parts = await uploadParts(client, workspaceId, transfer, blob) + + const completed = await client.request<{ data: T }>(`${transfer.basePath}/complete`, { + method: 'POST', + query: { workspaceId }, + headers: { 'upload-token': transfer.uploadToken }, + body: { parts }, + }) + return completed.data + } catch (error) { + await client + .request(transfer.basePath, { + method: 'DELETE', + query: { workspaceId }, + headers: { 'upload-token': transfer.uploadToken }, + }) + .catch(() => undefined) + throw error + } +} + +interface TableImport { + id: string + status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + tableId: string | null + rowsProcessed: number + error: string | null + upload: { uploadToken: string; partSize: number; partCount: number } | null +} + +interface ImportOptions { + newTable?: string + toTable?: string + mode: string + folderId?: string + fileId?: string + mapping?: string + createColumns?: string + timezone?: string + /** commander sets this false for `--no-wait`. */ + wait: boolean +} + +/** How often to ask an in-progress import where it got to. */ +const IMPORT_POLL_MS = 1500 + +/** Statuses the server will not move away from. */ +const IMPORT_SETTLED = new Set(['completed', 'failed', 'canceled', 'expired']) + +/** + * Parses a JSON flag through the same path the generated commands use, so + * `@file` and `@-` work here too rather than only on generated flags. + */ +function jsonFlag(raw: string, flagName: string): unknown { + return coerce(raw, { kind: 'object' }, { json: true }, flagName) +} + +/** + * Polls an import until it settles. + * + * The transfer only queues the work: rows are parsed server-side afterwards, so + * a command that returned at `complete` would report success for an import that + * goes on to fail on a malformed row. + */ +async function watchImport( + client: SimClient, + workspaceId: string, + job: TableImport +): Promise { + let current = job + let reported = -1 + + while (!IMPORT_SETTLED.has(current.status)) { + await new Promise((resolve) => setTimeout(resolve, IMPORT_POLL_MS)) + const next = await client.request<{ data: TableImport }>( + `/api/v2/tables/imports/${encodeURIComponent(current.id)}`, + { query: { workspaceId } } + ) + current = next.data + + // Only on a terminal, and only when it moves: the line rewrites itself with + // a carriage return, which in a redirected log is just escape noise. + if (process.stderr.isTTY && current.rowsProcessed !== reported) { + reported = current.rowsProcessed + process.stderr.write(`\r${chalk.dim(`${current.status}… ${reported} rows`)}\u001b[K`) + } + } + + if (process.stderr.isTTY && reported >= 0) process.stderr.write('\r\u001b[K') + return current +} + +/** Size and name checks every local-file transfer needs before starting one. */ +async function localFile(path: string, override?: string): Promise<{ name: string; size: number }> { + let size: number + try { + const stats = await stat(path) + if (stats.isDirectory()) throw new SimApiError(`${path} is a directory`, 0) + size = stats.size + } catch (error) { + if (error instanceof SimApiError) throw error + throw new SimApiError(`Cannot read ${path}: ${(error as Error).message}`, 0) + } + // A zero-byte transfer has no parts to send; the server cannot accept one. + if (size === 0) throw new SimApiError(`${path} is empty`, 0) + return { name: override ?? basename(path), size } +} + export function attachHandWritten(program: Command): void { // ── files upload ── a presigned multipart handshake, not one request ────── group(program, 'files') @@ -243,22 +382,7 @@ export function attachHandWritten(program: Command): void { async (path: string, options: { folderId?: string; name?: string }, command: Command) => { const { client } = clientFrom(command) const workspaceId = client.requireWorkspace() - - let size: number - try { - const stats = await stat(path) - if (stats.isDirectory()) throw new SimApiError(`${path} is a directory`, 0) - size = stats.size - } catch (error) { - if (error instanceof SimApiError) throw error - throw new SimApiError(`Cannot read ${path}: ${(error as Error).message}`, 0) - } - - // The server sizes its own parts, but it cannot reject an empty file any - // more cheaply than we can: a zero-byte upload has no parts to send. - if (size === 0) throw new SimApiError(`${path} is empty`, 0) - - const name = options.name ?? basename(path) + const { name, size } = await localFile(path, options.name) const created = await client.request<{ data: FileUpload }>('/api/v2/files/uploads', { method: 'POST', @@ -272,39 +396,129 @@ export function attachHandWritten(program: Command): void { }) const upload = created.data - // Any failure past this point leaves an upload holding storage, so the - // rest runs under an abort that the server also uses to release it. - try { - const blob = await openAsBlob(path) - const parts = await uploadParts(client, workspaceId, upload, blob) - - const completed = await client.request<{ data: FileUpload }>( - `/api/v2/files/uploads/${encodeURIComponent(upload.id)}/complete`, - { - method: 'POST', - query: { workspaceId }, - headers: { 'upload-token': upload.uploadToken }, - body: { parts }, - } - ) - console.log( - chalk.green(`✓ Uploaded ${name} (${completed.data.file?.id ?? completed.data.id})`) - ) - } catch (error) { - await client - .request(`/api/v2/files/uploads/${encodeURIComponent(upload.id)}`, { - method: 'DELETE', - query: { workspaceId }, - headers: { 'upload-token': upload.uploadToken }, - }) - // The original failure is what the caller needs; a failed cleanup - // must not replace it with a message about the cleanup. - .catch(() => undefined) - throw error - } + const completed = await finishTransfer( + client, + workspaceId, + { + basePath: `/api/v2/files/uploads/${encodeURIComponent(upload.id)}`, + uploadToken: upload.uploadToken, + partSize: upload.partSize, + partCount: upload.partCount, + size, + }, + path + ) + + console.log(chalk.green(`✓ Uploaded ${name} (${completed.file?.id ?? completed.id})`)) } ) + // ── tables import ── a transfer, then an async job to watch ────────────── + const tablesGroup = group(program, 'tables') + tablesGroup + .command('import [path]') + .description('Import a CSV into a new or existing table') + .option('--new-table ', 'Create a table with this name') + .option('--to-table ', 'Import into an existing table') + .option('--mode ', 'How to write into an existing table', 'append') + .option('--folder-id ', 'Folder for a new table') + .option('--file-id ', 'Import a file already in the workspace instead of a local path') + .option('--mapping ', 'Column mapping (existing table only)') + .option('--create-columns ', 'Columns to create (existing table only)') + .option('--timezone ', 'Timezone for date parsing, e.g. America/New_York') + .option('--no-wait', 'Return once the import is queued instead of watching it') + .action(async (path: string | undefined, options: ImportOptions, command: Command) => { + const { client } = clientFrom(command) + const workspaceId = client.requireWorkspace() + + // Both target choices are stated, never inferred. Defaulting to a new + // table would turn a forgotten `--to-table` into a second copy of the + // data, which is not something to discover afterwards. + if (Boolean(options.newTable) === Boolean(options.toTable)) { + throw new SimApiError('Pass exactly one of --new-table or --to-table ', 0) + } + if (Boolean(path) === Boolean(options.fileId)) { + throw new SimApiError('Pass exactly one of or --file-id ', 0) + } + // The server rejects these against a new table; saying so here names the + // flag rather than returning a validation error about the request body. + if (options.newTable && (options.mapping || options.createColumns)) { + throw new SimApiError( + '--mapping and --create-columns apply to --to-table only: a new table takes its columns from the CSV', + 0 + ) + } + + const local = path ? await localFile(path, undefined) : null + const source = local + ? { + type: 'upload', + name: local.name, + contentType: contentTypeFor(local.name), + size: local.size, + } + : { type: 'workspace_file', fileId: options.fileId } + + const target = options.toTable + ? { type: 'existing', tableId: options.toTable, mode: options.mode } + : { + type: 'new', + name: options.newTable, + ...(options.folderId ? { folderId: options.folderId } : {}), + } + + const started = await client.request<{ data: TableImport }>('/api/v2/tables/imports', { + method: 'POST', + body: { + workspaceId, + source, + target, + ...(options.mapping ? { mapping: jsonFlag(options.mapping, 'mapping') } : {}), + ...(options.createColumns + ? { createColumns: jsonFlag(options.createColumns, 'create-columns') } + : {}), + ...(options.timezone ? { timezone: options.timezone } : {}), + }, + }) + + let job = started.data + + // A workspace_file source has nothing to upload — the bytes are already + // there, and the server starts the job without a transfer. + if (path && job.upload) { + job = await finishTransfer( + client, + workspaceId, + { + basePath: `/api/v2/tables/imports/${encodeURIComponent(job.id)}`, + uploadToken: job.upload.uploadToken, + partSize: job.upload.partSize, + partCount: job.upload.partCount, + size: local?.size ?? 0, + }, + path + ) + } + + if (!options.wait) { + console.log(chalk.green(`✓ Import ${job.id} ${job.status}`)) + return + } + + const finished = await watchImport(client, workspaceId, job) + if (finished.status !== 'completed') { + throw new SimApiError( + `Import ${finished.status}${finished.error ? `: ${finished.error}` : ''}`, + 0 + ) + } + console.log( + chalk.green( + `✓ Imported ${finished.rowsProcessed} rows${finished.tableId ? ` into ${finished.tableId}` : ''}` + ) + ) + }) + // ── files download ── the response is binary, not the JSON envelope ──────── group(program, 'files') .command('download ') diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 92adecf48bc..c74c16c4132 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -263,19 +263,13 @@ export const CLI_CONTRACT: CliContract = { describe: 'Run one row’s enrichment group', }, - // Transfers are a handshake: create, request part URLs, send the parts, then - // complete. Unlike `files upload` there is no single command driving this yet - // — the import body carries source/target/mapping choices a one-liner cannot - // express — so each step stays reachable under a name that says what it is. - createTableImport: { command: 'tables imports create' }, - createTableImportPartUrls: { - command: 'tables imports parts', - describe: 'Sign upload URLs for a batch of parts', - }, - completeTableImport: { - command: 'tables imports complete', - describe: 'Finish an import once every part is uploaded', - }, + // The handshake behind `sim tables import`. Its halfway states hold storage + // and a half-sent import is not something to leave reachable, so the steps + // stay hidden — unlike `get` and `cancel`, which are useful on their own for + // an import already running. + createTableImport: { hidden: true }, + createTableImportPartUrls: { hidden: true }, + completeTableImport: { hidden: true }, cancelTableImport: { command: 'tables imports cancel' }, cancelTableExport: { command: 'tables exports cancel' }, tableExportDownload: { From 4ece5b7619f7e12b59911e01adadb4876850c97c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 18:41:58 -0700 Subject: [PATCH 052/159] feat(cli): default tables import to a new table named after the file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sim tables import people.csv` now does the obvious thing rather than demanding a target. Requiring one guarded the wrong direction: a forgotten flag creating a new table is visible and easily undone, while the outcome worth protecting — writing into an existing table — is the one that now has to be asked for by name. --to-table becomes --table-id, and --mode/--mapping/--create-columns apply only alongside it. Passing one without it is an error rather than a no-op: silently ignoring `--mode replace` would let it read as honoured while a new table was created beside the one it was meant to overwrite. The reverse is also refused, since --table-id already names the destination. The derived name is sanitized, because table names are identifiers: the obvious basename would reject most real files, so `2026-quarterly sales.csv` imports as `_2026_quarterly_sales` instead of failing. --name overrides it, and is required for --file-id, where there is no file name to take one from. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- .../sim-cli/src/commands/hand-written.test.ts | 38 ++++---- packages/sim-cli/src/commands/hand-written.ts | 91 +++++++++++++------ 2 files changed, 83 insertions(+), 46 deletions(-) diff --git a/packages/sim-cli/src/commands/hand-written.test.ts b/packages/sim-cli/src/commands/hand-written.test.ts index b2fa54e6c99..10f3d08b5fd 100644 --- a/packages/sim-cli/src/commands/hand-written.test.ts +++ b/packages/sim-cli/src/commands/hand-written.test.ts @@ -84,31 +84,35 @@ describe('tables import argument guards', () => { await importCommand().parseAsync(['node', 'sim', 'tables', 'import', ...argv]) } - it('refuses to guess the target', async () => { - // Defaulting to a new table would turn a forgotten `--to-table` into a - // silent second copy of the data. - await expect(run(['f.csv'])).rejects.toThrow(/exactly one of --new-table/) - await expect(run(['f.csv', '--new-table', 'a', '--to-table', 't'])).rejects.toThrow( - /exactly one of --new-table/ - ) + it('refuses to guess the source', async () => { + // A new table is a safe default; where the bytes are is not inferable. + await expect(run([])).rejects.toThrow(/exactly one of /) + await expect(run(['f.csv', '--file-id', 'w_1'])).rejects.toThrow(/exactly one of /) }) - it('refuses to guess the source', async () => { - await expect(run(['--new-table', 'a'])).rejects.toThrow(/exactly one of /) - await expect(run(['f.csv', '--new-table', 'a', '--file-id', 'w_1'])).rejects.toThrow( - /exactly one of / - ) + it('rejects existing-table flags when creating one', async () => { + // Ignoring these would let `--mode replace` read as honoured while a new + // table is created beside the one it was meant to overwrite. + await expect(run(['f.csv', '--mode', 'replace'])).rejects.toThrow(/applies to --table-id/) + await expect(run(['f.csv', '--mapping', '{}'])).rejects.toThrow(/applies to --table-id/) + await expect(run(['f.csv', '--create-columns', '{}'])).rejects.toThrow(/applies to --table-id/) }) - it('names the flag when mapping is paired with a new table', async () => { - // The server rejects this too, but as a message about the request body. - await expect(run(['f.csv', '--new-table', 'a', '--mapping', '{}'])).rejects.toThrow( - /--to-table only/ + it('rejects new-table flags when importing into an existing one', async () => { + await expect(run(['f.csv', '--table-id', 't', '--name', 'x'])).rejects.toThrow( + /--table-id already names the destination/ ) + await expect(run(['f.csv', '--table-id', 't', '--folder-id', 'f'])).rejects.toThrow( + /--table-id already names the destination/ + ) + }) + + it('asks for a name when there is no file name to take one from', async () => { + await expect(run(['--file-id', 'w_1'])).rejects.toThrow(/--name /) }) it('checks all of that before touching the filesystem', async () => { // `f.csv` does not exist; a "cannot read" error would mean a guard ran late. - await expect(run(['f.csv'])).rejects.toThrow(/exactly one of/) + await expect(run(['f.csv', '--mode', 'append'])).rejects.toThrow(/applies to --table-id/) }) }) diff --git a/packages/sim-cli/src/commands/hand-written.ts b/packages/sim-cli/src/commands/hand-written.ts index 4979868bac4..80142fba0a6 100644 --- a/packages/sim-cli/src/commands/hand-written.ts +++ b/packages/sim-cli/src/commands/hand-written.ts @@ -294,9 +294,9 @@ interface TableImport { } interface ImportOptions { - newTable?: string - toTable?: string - mode: string + name?: string + tableId?: string + mode?: string folderId?: string fileId?: string mapping?: string @@ -306,6 +306,22 @@ interface ImportOptions { wait: boolean } +/** + * Turns a file name into a legal table name. + * + * Table names are identifiers — `^[A-Za-z_][A-Za-z0-9_]*$`, 128 max — so the + * obvious `basename(path)` would reject most real files: `2026-sales.csv` and + * `customer data.csv` both fail. Runs of anything else collapse to a single + * underscore, and a leading digit gets one in front, so a default derived from + * the file is a name the server actually accepts. + */ +function tableNameFrom(fileName: string): string { + const stem = fileName.replace(/\.[^.]+$/, '') + const cleaned = stem.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '') + if (!cleaned) return 'imported_table' + return (/^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned).slice(0, 128) +} + /** How often to ask an in-progress import where it got to. */ const IMPORT_POLL_MS = 1500 @@ -414,37 +430,49 @@ export function attachHandWritten(program: Command): void { ) // ── tables import ── a transfer, then an async job to watch ────────────── - const tablesGroup = group(program, 'tables') - tablesGroup + group(program, 'tables') .command('import [path]') - .description('Import a CSV into a new or existing table') - .option('--new-table ', 'Create a table with this name') - .option('--to-table ', 'Import into an existing table') - .option('--mode ', 'How to write into an existing table', 'append') - .option('--folder-id ', 'Folder for a new table') + .description('Import a CSV, into a new table by default') + .option('--name ', 'Name for the new table (defaults to the file name)') + .option('--table-id ', 'Import into this existing table instead of creating one') + .option('--mode ', 'How to write into --table-id (default: append)') + .option('--folder-id ', 'Folder for the new table') .option('--file-id ', 'Import a file already in the workspace instead of a local path') - .option('--mapping ', 'Column mapping (existing table only)') - .option('--create-columns ', 'Columns to create (existing table only)') + .option('--mapping ', 'Column mapping (--table-id only)') + .option('--create-columns ', 'Columns to create (--table-id only)') .option('--timezone ', 'Timezone for date parsing, e.g. America/New_York') .option('--no-wait', 'Return once the import is queued instead of watching it') .action(async (path: string | undefined, options: ImportOptions, command: Command) => { const { client } = clientFrom(command) const workspaceId = client.requireWorkspace() - // Both target choices are stated, never inferred. Defaulting to a new - // table would turn a forgotten `--to-table` into a second copy of the - // data, which is not something to discover afterwards. - if (Boolean(options.newTable) === Boolean(options.toTable)) { - throw new SimApiError('Pass exactly one of --new-table or --to-table ', 0) - } + // The one thing that cannot be inferred: the bytes are either local or + // already in the workspace, and neither implies the other. if (Boolean(path) === Boolean(options.fileId)) { throw new SimApiError('Pass exactly one of or --file-id ', 0) } - // The server rejects these against a new table; saying so here names the - // flag rather than returning a validation error about the request body. - if (options.newTable && (options.mapping || options.createColumns)) { + + const intoExisting = Boolean(options.tableId) + + // Flags that only mean something for one target. Silently ignoring them + // would let `--mode replace` read as honoured while a new table is + // created beside the one it was meant to overwrite. + const misplaced = intoExisting + ? ([ + ['--name', options.name], + ['--folder-id', options.folderId], + ] as const) + : ([ + ['--mode', options.mode], + ['--mapping', options.mapping], + ['--create-columns', options.createColumns], + ] as const) + for (const [flag, value] of misplaced) { + if (value === undefined) continue throw new SimApiError( - '--mapping and --create-columns apply to --to-table only: a new table takes its columns from the CSV', + intoExisting + ? `${flag} applies to a new table; --table-id already names the destination` + : `${flag} applies to --table-id: a new table takes its name and columns from the CSV`, 0 ) } @@ -459,13 +487,18 @@ export function attachHandWritten(program: Command): void { } : { type: 'workspace_file', fileId: options.fileId } - const target = options.toTable - ? { type: 'existing', tableId: options.toTable, mode: options.mode } - : { - type: 'new', - name: options.newTable, - ...(options.folderId ? { folderId: options.folderId } : {}), - } + let target: Record + if (intoExisting) { + target = { type: 'existing', tableId: options.tableId, mode: options.mode ?? 'append' } + } else { + // A local file names the table; a workspace file id does not, and + // guessing one from an id would produce nonsense. + const name = options.name ?? (local ? tableNameFrom(local.name) : undefined) + if (!name) { + throw new SimApiError('Pass --name to say what the new table is called', 0) + } + target = { type: 'new', name, ...(options.folderId ? { folderId: options.folderId } : {}) } + } const started = await client.request<{ data: TableImport }>('/api/v2/tables/imports', { method: 'POST', From e1ec2333a42d20671f94d3504df1c8a3d7b4d4ec Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 19:06:18 -0700 Subject: [PATCH 053/159] feat(v2-tables): paginate the table list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /api/v2/tables` returned every table in the workspace in one response — it used the cursor envelope but hardcoded `nextCursor: null`, and had no `limit`. That was defensible when tables were only created through the UI; `POST /api/v2/tables` is public now, so a script can create them in bulk and the list has no way to ask for less. Adds `queryTables` alongside `listTables` rather than changing it, so the internal callers that genuinely want the whole scope are untouched — the same split `queryWorkspaceFiles` / `listWorkspaceFiles` already uses. Filter, order and slice all run in the query, so a `search` never costs a full-workspace read. A cursor whose values don't bind raises a validation error instead of being coerced to "no filter", which would have silently served page 1 under a resumed cursor. The keyset closes on `id` so a page boundary inside a run of equal names or timestamps stays stable. The shared `LimitQuery` doc component said "Maximum rows to return"; it now serves the table list too, so the wording is resource-neutral. Co-Authored-By: Claude Opus 5 --- apps/docs/openapi-v2-tables.json | 478 ++++++++++++++++------- apps/sim/app/api/v2/tables/route.test.ts | 51 ++- apps/sim/app/api/v2/tables/route.ts | 26 +- apps/sim/lib/api/contracts/v2/tables.ts | 6 + apps/sim/lib/table/service.ts | 163 +++++++- 5 files changed, 553 insertions(+), 171 deletions(-) diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 5212bd88b78..2c82c47276a 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -64,7 +64,7 @@ "name": "search", "in": "query", "required": false, - "description": "Case-insensitive substring match against the table `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "description": "Case-insensitive substring match against the table `name`. Matches nothing else \u2014 not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", "schema": { "type": "string", "minLength": 1, @@ -92,6 +92,12 @@ "enum": ["asc", "desc"], "default": "asc" } + }, + { + "$ref": "#/components/parameters/LimitQuery" + }, + { + "$ref": "#/components/parameters/CursorQuery" } ], "responses": { @@ -416,7 +422,7 @@ "patch": { "operationId": "updateTable", "summary": "Update Table", - "description": "Rename a table and/or move it between folders. Provide at least one of `name` or `folderId`. Each field is applied independently, so one request can rename and move at once, and the response reflects every applied change.\n\nBoth fields need workspace write.\n\n**Lock flags are read-only here.** A table's `locks` are returned on the table resource and enforced on every write (a locked verb returns 423), but they cannot be changed through the API — a write-level key must not be able to clear the guard placed there to stop it. Changing a lock is a first-party workspace-admin action. A request carrying `locks` is rejected with 400 rather than silently ignored.\n\n**Partial-success semantics.** The two operations commit independently, so this endpoint is not atomic. Everything that can be *rejected* — the body shape, folder existence — is validated before the first write, so a rejected request changes nothing. If a genuine fault (a lost race, the table archived mid-request, a database error) fails the later operation after the earlier one has committed, the response is an error whose `error.details.applied` lists the operations that are nevertheless live (`\"name\"`, `\"folderId\"`). The field is absent when nothing was applied, so its presence always means \"these changes took effect despite the error\" — re-read the table to confirm before retrying.", + "description": "Rename a table and/or move it between folders. Provide at least one of `name` or `folderId`. Each field is applied independently, so one request can rename and move at once, and the response reflects every applied change.\n\nBoth fields need workspace write.\n\n**Lock flags are read-only here.** A table's `locks` are returned on the table resource and enforced on every write (a locked verb returns 423), but they cannot be changed through the API \u2014 a write-level key must not be able to clear the guard placed there to stop it. Changing a lock is a first-party workspace-admin action. A request carrying `locks` is rejected with 400 rather than silently ignored.\n\n**Partial-success semantics.** The two operations commit independently, so this endpoint is not atomic. Everything that can be *rejected* \u2014 the body shape, folder existence \u2014 is validated before the first write, so a rejected request changes nothing. If a genuine fault (a lost race, the table archived mid-request, a database error) fails the later operation after the earlier one has committed, the response is an error whose `error.details.applied` lists the operations that are nevertheless live (`\"name\"`, `\"folderId\"`). The field is absent when nothing was applied, so its presence always means \"these changes took effect despite the error\" \u2014 re-read the table to confirm before retrying.", "tags": ["Tables"], "x-codeSamples": [ { @@ -640,7 +646,7 @@ "patch": { "operationId": "updateTableColumn", "summary": "Update Column", - "description": "Update a column by name — rename it, change its type, or toggle its required/unique constraints. Provide at least one field in `updates`. Returns the table's full column list after the change.", + "description": "Update a column by name \u2014 rename it, change its type, or toggle its required/unique constraints. Provide at least one field in `updates`. Returns the table's full column list after the change.", "tags": ["Tables"], "x-codeSamples": [ { @@ -784,7 +790,7 @@ "get": { "operationId": "listTableRows", "summary": "List rows", - "description": "Plain cursor page over the default row order. Filtering and sorting are not part of this surface — use `POST /api/v2/tables/{tableId}/query` for predicate-filtered, sorted reads. The cursor is opaque; page by passing the previous response's `nextCursor` back as `cursor` and stop when it is `null`.", + "description": "Plain cursor page over the default row order. Filtering and sorting are not part of this surface \u2014 use `POST /api/v2/tables/{tableId}/query` for predicate-filtered, sorted reads. The cursor is opaque; page by passing the previous response's `nextCursor` back as `cursor` and stop when it is `null`.", "tags": ["Tables"], "x-codeSamples": [ { @@ -1520,7 +1526,7 @@ "post": { "operationId": "queryTableRows", "summary": "Query Rows", - "description": "Query rows with a typed predicate filter, an ordered sort spec, and opaque cursor pagination. Row `data` is keyed by column NAME; `select` cells return option names, and filter operands on select columns accept option names (resolved case-insensitively).\n\n**Pagination contract:** page by passing the previous response's `nextCursor` back as `cursor`, and stop only when it is `null` — a page may return fewer than `limit` rows and still have more behind it, so page fullness is never a termination signal. A cursor is bound to the exact query shape it was minted under: keyset cursors to the default row order, offset cursors (sorted views) to that sort. Replaying one under a different `sort` returns 400 `CURSOR_SORT_CONFLICT`.", + "description": "Query rows with a typed predicate filter, an ordered sort spec, and opaque cursor pagination. Row `data` is keyed by column NAME; `select` cells return option names, and filter operands on select columns accept option names (resolved case-insensitively).\n\n**Pagination contract:** page by passing the previous response's `nextCursor` back as `cursor`, and stop only when it is `null` \u2014 a page may return fewer than `limit` rows and still have more behind it, so page fullness is never a termination signal. A cursor is bound to the exact query shape it was minted under: keyset cursors to the default row order, offset cursors (sorted views) to that sort. Replaying one under a different `sort` returns 400 `CURSOR_SORT_CONFLICT`.", "tags": ["Tables"], "parameters": [ { @@ -1565,7 +1571,7 @@ "minimum": 0, "maximum": 1000, "default": 100, - "description": "Omitted → 100. `1..1000` → page size. `0` → the ENTIRE matching result in one response; fails with 400 `TABLE_QUERY_RESULT_TOO_LARGE` if it exceeds the 5 MB row-data budget (narrow the predicate or page instead)." + "description": "Omitted \u2192 100. `1..1000` \u2192 page size. `0` \u2192 the ENTIRE matching result in one response; fails with 400 `TABLE_QUERY_RESULT_TOO_LARGE` if it exceeds the 5 MB row-data budget (narrow the predicate or page instead)." }, "cursor": { "type": "string", @@ -1683,7 +1689,7 @@ "post": { "operationId": "restoreTable", "summary": "Restore Table", - "description": "Un-archive a table archived by `DELETE /api/v2/tables/{tableId}`, along with its rows. Requires workspace write. Returns 409 when a different active table has since taken the archived table’s name — rename that table first, then retry.", + "description": "Un-archive a table archived by `DELETE /api/v2/tables/{tableId}`, along with its rows. Requires workspace write. Returns 409 when a different active table has since taken the archived table\u2019s name \u2014 rename that table first, then retry.", "tags": ["Tables"], "x-codeSamples": [ { @@ -1813,7 +1819,7 @@ ], "responses": { "200": { - "description": "The table’s saved views.", + "description": "The table\u2019s saved views.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -1888,7 +1894,7 @@ "post": { "operationId": "createTableView", "summary": "Create View", - "description": "Save a filter, sort, and column layout as a named view. A view is presentation state, never an access boundary — rows it hides stay readable through the row and query endpoints.", + "description": "Save a filter, sort, and column layout as a named view. A view is presentation state, never an access boundary \u2014 rows it hides stay readable through the row and query endpoints.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2109,7 +2115,7 @@ "patch": { "operationId": "updateTableView", "summary": "Update View", - "description": "Rename a view, replace or merge its config, or promote it to the table’s default. Provide at least one of `name`, `config`, `configPatch`, or `isDefault`.\n\n`config` replaces the stored config wholesale; `configPatch` is shallow-merged server-side so overlapping partial writes cannot clobber each other. The two are mutually exclusive. Setting `isDefault: true` demotes the table’s existing default in the same transaction.", + "description": "Rename a view, replace or merge its config, or promote it to the table\u2019s default. Provide at least one of `name`, `config`, `configPatch`, or `isDefault`.\n\n`config` replaces the stored config wholesale; `configPatch` is shallow-merged server-side so overlapping partial writes cannot clobber each other. The two are mutually exclusive. Setting `isDefault: true` demotes the table\u2019s existing default in the same transaction.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2136,7 +2142,7 @@ }, "examples": { "promote": { - "summary": "Make this the table’s default view", + "summary": "Make this the table\u2019s default view", "value": { "workspaceId": "ws_123", "isDefault": true @@ -2238,7 +2244,7 @@ "delete": { "operationId": "deleteTableView", "summary": "Delete View", - "description": "Remove a saved view. Deleting the table’s default simply leaves the table unfiltered; no rows are affected.", + "description": "Remove a saved view. Deleting the table\u2019s default simply leaves the table unfiltered; no rows are affected.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2311,7 +2317,7 @@ "get": { "operationId": "listTableWorkflowGroups", "summary": "List Workflow Groups", - "description": "The table’s workflow and enrichment groups — the units the run endpoints dispatch. Read-only: groups are authored in the workflow builder. Bounded per table, so this is a single full page and `nextCursor` is always null.", + "description": "The table\u2019s workflow and enrichment groups \u2014 the units the run endpoints dispatch. Read-only: groups are authored in the workflow builder. Bounded per table, so this is a single full page and `nextCursor` is always null.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2331,7 +2337,7 @@ ], "responses": { "200": { - "description": "The table’s workflow groups.", + "description": "The table\u2019s workflow groups.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -2397,7 +2403,7 @@ "post": { "operationId": "addTableWorkflowGroup", "summary": "Add Workflow Group", - "description": "Bind a workflow or enrichment to the table and create the columns its runs populate, in one call.\n\nThe group is the unit that fills columns — one group can feed several. `group.outputs[].columnName` says where each value lands; `outputColumns` defines the columns to create. Every `outputColumns` entry must be named by an output, or the request is rejected rather than creating a column nothing feeds.\n\n`autoRun` defaults to **false**: enabling it backfills every existing row, which on an API key is a metered fan-out from a single call.", + "description": "Bind a workflow or enrichment to the table and create the columns its runs populate, in one call.\n\nThe group is the unit that fills columns \u2014 one group can feed several. `group.outputs[].columnName` says where each value lands; `outputColumns` defines the columns to create. Every `outputColumns` entry must be named by an output, or the request is rejected rather than creating a column nothing feeds.\n\n`autoRun` defaults to **false**: enabling it backfills every existing row, which on an API key is a metered fan-out from a single call.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2766,7 +2772,7 @@ "post": { "operationId": "runTableColumns", "summary": "Run Column Groups", - "description": "Run one or more workflow or enrichment groups and write their outputs into the table.\n\n**Asynchronous.** The response acknowledges the dispatch, not the results: the runner walks the scoped rows and writes cells as runs land. Poll `POST /api/v2/tables/{tableId}/query` for results, and stop an in-flight run with the same group ids and an empty scope.\n\nScope with `rowIds` (an explicit set) or `filter` (every matching row, walked in pages so no id list is materialized) — never both. Omit both to run every row. Starting a run clears the target groups’ cells to pending, so a read taken immediately after will show them empty.", + "description": "Run one or more workflow or enrichment groups and write their outputs into the table.\n\n**Asynchronous.** The response acknowledges the dispatch, not the results: the runner walks the scoped rows and writes cells as runs land. Poll `POST /api/v2/tables/{tableId}/query` for results, and stop an in-flight run with the same group ids and an empty scope.\n\nScope with `rowIds` (an explicit set) or `filter` (every matching row, walked in pages so no id list is materialized) \u2014 never both. Omit both to run every row. Starting a run clears the target groups\u2019 cells to pending, so a read taken immediately after will show them empty.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2876,7 +2882,7 @@ "post": { "operationId": "runRowEnrichment", "summary": "Run Enrichment For One Row", - "description": "The single-cell case of `POST /api/v2/tables/{tableId}/columns/run`: runs one group for one row. Naming a specific cell is an explicit re-run request, so an already-populated cell recomputes rather than being skipped.\n\n**Asynchronous** — the response acknowledges the dispatch; read the row back for the result.", + "description": "The single-cell case of `POST /api/v2/tables/{tableId}/columns/run`: runs one group for one row. Naming a specific cell is an explicit re-run request, so an already-populated cell recomputes rather than being skipped.\n\n**Asynchronous** \u2014 the response acknowledges the dispatch; read the row back for the result.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2965,7 +2971,7 @@ "post": { "operationId": "findTableRows", "summary": "Find Rows", - "description": "Case-insensitive substring search across every cell, narrowed by the same predicate and sort grammar as `POST /api/v2/tables/{tableId}/query`. Select cells match on their option names.\n\nReturns matching **cells**, not rows. Each match carries the row’s ordinal in exactly the view a query with the same `predicate` and `sort` returns, so a caller can page straight to it. Matches are capped server-side and have no cursor — when `truncated` is true, narrow the predicate rather than paging.", + "description": "Case-insensitive substring search across every cell, narrowed by the same predicate and sort grammar as `POST /api/v2/tables/{tableId}/query`. Select cells match on their option names.\n\nReturns matching **cells**, not rows. Each match carries the row\u2019s ordinal in exactly the view a query with the same `predicate` and `sort` returns, so a caller can page straight to it. Matches are capped server-side and have no cursor \u2014 when `truncated` is true, narrow the predicate rather than paging.", "tags": ["Tables"], "x-codeSamples": [ { @@ -3080,7 +3086,7 @@ "x-removed-get": { "operationId": "listTableJobs", "summary": "List Export Jobs", - "description": "Export jobs across a workspace — running ones plus recently finished ones, so a completed export stays re-downloadable. Poll this after `POST /export-async`, then fetch the file from `GET /export/download` once a job reports `ready`.", + "description": "Export jobs across a workspace \u2014 running ones plus recently finished ones, so a completed export stays re-downloadable. Poll this after `POST /export-async`, then fetch the file from `GET /export/download` once a job reports `ready`.", "tags": ["Tables"], "x-codeSamples": [ { @@ -3100,7 +3106,7 @@ ], "responses": { "200": { - "description": "The workspace’s export jobs.", + "description": "The workspace\u2019s export jobs.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -3157,7 +3163,7 @@ "x-removed-post": { "operationId": "importTableCsvAsync", "summary": "Import CSV (Background)", - "description": "Start a background import of a file already uploaded to workspace storage — the path for files too large for the synchronous import.\n\nReturns as soon as the job is queued. Track it on the table itself — `GET /api/v2/tables/{tableId}` returns a `job` object with `status`, `rowsProcessed` and `error` while the import runs — and stop it with `POST /job/cancel`. (`GET /api/v2/tables/jobs` lists exports only: those run concurrently and are not derived onto the table.) `fileKey` must sit under this workspace’s storage prefix. The table’s lock flags are checked before the job slot is claimed, so a locked table answers 423 here rather than failing inside the worker.", + "description": "Start a background import of a file already uploaded to workspace storage \u2014 the path for files too large for the synchronous import.\n\nReturns as soon as the job is queued. Track it on the table itself \u2014 `GET /api/v2/tables/{tableId}` returns a `job` object with `status`, `rowsProcessed` and `error` while the import runs \u2014 and stop it with `POST /job/cancel`. (`GET /api/v2/tables/jobs` lists exports only: those run concurrently and are not derived onto the table.) `fileKey` must sit under this workspace\u2019s storage prefix. The table\u2019s lock flags are checked before the job slot is claimed, so a locked table answers 423 here rather than failing inside the worker.", "tags": ["Tables"], "x-codeSamples": [ { @@ -3412,7 +3418,7 @@ "x-removed-post": { "operationId": "cancelTableJob", "summary": "Cancel Job", - "description": "Stop an in-flight import or delete job. The worker halts at its next ownership check; work already committed (rows inserted or deleted) is left in place — there is no rollback.\n\nIdempotent: cancelling a job that already finished answers `canceled: false` rather than failing, so a client racing the worker is not an error. To stop workflow or enrichment cell runs instead, use `POST /cancel-runs`.", + "description": "Stop an in-flight import or delete job. The worker halts at its next ownership check; work already committed (rows inserted or deleted) is left in place \u2014 there is no rollback.\n\nIdempotent: cancelling a job that already finished answers `canceled: false` rather than failing, so a client racing the worker is not an error. To stop workflow or enrichment cell runs instead, use `POST /cancel-runs`.", "tags": ["Tables"], "x-codeSamples": [ { @@ -3498,20 +3504,42 @@ "tags": ["Tables"], "requestBody": { "required": true, - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } }, "responses": { "201": { "description": "The table import resource.", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "423": { "$ref": "#/components/responses/Locked" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -3526,19 +3554,35 @@ "name": "importId", "in": "path", "required": true, - "schema": { "type": "string" } + "schema": { + "type": "string" + } }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The table import resource.", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "delete": { @@ -3551,21 +3595,41 @@ "name": "importId", "in": "path", "required": true, - "schema": { "type": "string" } + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/OptionalUploadTokenHeader" } + { + "$ref": "#/components/parameters/OptionalUploadTokenHeader" + } ], "responses": { "200": { "description": "The canceled import resource.", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -3580,26 +3644,52 @@ "name": "importId", "in": "path", "required": true, - "schema": { "type": "string" } + "schema": { + "type": "string" + } }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/UploadTokenHeader" } + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/UploadTokenHeader" + } ], "requestBody": { "required": true, - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } }, "responses": { "200": { "description": "Signed URLs for the requested import parts.", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -3614,27 +3704,55 @@ "name": "importId", "in": "path", "required": true, - "schema": { "type": "string" } + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/UploadTokenHeader" } + { + "$ref": "#/components/parameters/UploadTokenHeader" + } ], "requestBody": { "required": true, - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } }, "responses": { "200": { "description": "The queued import resource.", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "423": { "$ref": "#/components/responses/Locked" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -3649,25 +3767,49 @@ "name": "tableId", "in": "path", "required": true, - "schema": { "type": "string" } + "schema": { + "type": "string" + } } ], "requestBody": { "required": true, - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } }, "responses": { "201": { "description": "The completed or processing export resource.", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -3682,19 +3824,35 @@ "name": "exportId", "in": "path", "required": true, - "schema": { "type": "string" } + "schema": { + "type": "string" + } }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The table export resource.", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "delete": { @@ -3707,20 +3865,38 @@ "name": "exportId", "in": "path", "required": true, - "schema": { "type": "string" } + "schema": { + "type": "string" + } }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The canceled export resource.", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -3735,20 +3911,38 @@ "name": "exportId", "in": "path", "required": true, - "schema": { "type": "string" } + "schema": { + "type": "string" + } }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "A short-lived URL for the generated export file.", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -3756,7 +3950,7 @@ "post": { "operationId": "cancelTableRuns", "summary": "Cancel Column Runs", - "description": "Stop in-flight and pending workflow or enrichment cell runs — the counterpart to `POST /columns/run`, and distinct from `POST /job/cancel`, which stops an import or delete.\n\n`scope: \"all\"` cancels every running and pending cell, optionally narrowed to rows matching `filter`; `scope: \"row\"` cancels one row’s cells and requires `rowId`. Cancelling clears the affected cells, so a read taken immediately after will show them empty.", + "description": "Stop in-flight and pending workflow or enrichment cell runs \u2014 the counterpart to `POST /columns/run`, and distinct from `POST /job/cancel`, which stops an import or delete.\n\n`scope: \"all\"` cancels every running and pending cell, optionally narrowed to rows matching `filter`; `scope: \"row\"` cancels one row\u2019s cells and requires `rowId`. Cancelling clears the affected cells, so a read taken immediately after will show them empty.", "tags": ["Tables"], "x-codeSamples": [ { @@ -3787,7 +3981,7 @@ } }, "oneRow": { - "summary": "Stop one row’s runs", + "summary": "Stop one row\u2019s runs", "value": { "workspaceId": "ws_123", "scope": "row", @@ -3892,20 +4086,26 @@ "in": "header", "required": true, "description": "The signed token returned for an upload-backed table import.", - "schema": { "type": "string", "minLength": 1 } + "schema": { + "type": "string", + "minLength": 1 + } }, "OptionalUploadTokenHeader": { "name": "upload-token", "in": "header", "required": false, "description": "Required when canceling before upload completion; omitted when canceling a running table job.", - "schema": { "type": "string", "minLength": 1 } + "schema": { + "type": "string", + "minLength": 1 + } }, "LimitQuery": { "name": "limit", "in": "query", "required": false, - "description": "Maximum rows to return (1-1000, default 100).", + "description": "Maximum number of items to return per page (1-1000, default 100).", "schema": { "type": "integer", "default": 100, @@ -4111,7 +4311,7 @@ }, "id": { "type": "string", - "description": "Stable column id. Server-assigned — normally omit." + "description": "Stable column id. Server-assigned \u2014 normally omit." }, "options": { "type": "array", @@ -4832,7 +5032,7 @@ } }, "Predicate": { - "description": "A predicate tree: exactly one of `all` (every member must match) or `any` (at least one must). Members are conditions or nested groups; nesting expresses mixed AND/OR logic. Groups must be non-empty (1–100 members), trees at most 10 levels deep and 500 nodes total. Nodes are STRICT: unknown keys, or a node carrying both a group key and condition keys, are rejected rather than ignored.", + "description": "A predicate tree: exactly one of `all` (every member must match) or `any` (at least one must). Members are conditions or nested groups; nesting expresses mixed AND/OR logic. Groups must be non-empty (1\u2013100 members), trees at most 10 levels deep and 500 nodes total. Nodes are STRICT: unknown keys, or a node carrying both a group key and condition keys, are rejected rather than ignored.", "oneOf": [ { "type": "object", @@ -4884,7 +5084,7 @@ "field": { "type": "string", "maxLength": 128, - "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase — snake_case is treated as a user column and matches nothing)." + "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase \u2014 snake_case is treated as a user column and matches nothing)." }, "op": { "enum": [ @@ -4909,7 +5109,7 @@ "isNull", "isNotNull" ], - "description": "`eq`/`ne`/`in`/`nin` are case-sensitive equality/membership. `contains`/`ncontains`/`startsWith`/`endsWith` are case-insensitive text matches — except on a multi-select column, where `contains`/`ncontains` mean set membership by option name. `like`/`nlike` are case-sensitive and `ilike`/`nilike` case-insensitive patterns with `*` as the only wildcard (literal `%`/`_` match themselves). `isEmpty`/`isNotEmpty` treat null and empty string as empty; `isNull`/`isNotNull` are strict null checks. The four `is*` operators take no `value`. Negated text matches retain rows where the cell is absent. `in`/`nin` require a non-empty array of at most 1000 values; other value-taking operators reject arrays. Select columns accept only equality/membership operators appropriate to their cardinality (single: eq/ne/in/nin; multi: contains/ncontains; both: the `is*` checks)." + "description": "`eq`/`ne`/`in`/`nin` are case-sensitive equality/membership. `contains`/`ncontains`/`startsWith`/`endsWith` are case-insensitive text matches \u2014 except on a multi-select column, where `contains`/`ncontains` mean set membership by option name. `like`/`nlike` are case-sensitive and `ilike`/`nilike` case-insensitive patterns with `*` as the only wildcard (literal `%`/`_` match themselves). `isEmpty`/`isNotEmpty` treat null and empty string as empty; `isNull`/`isNotNull` are strict null checks. The four `is*` operators take no `value`. Negated text matches retain rows where the cell is absent. `in`/`nin` require a non-empty array of at most 1000 values; other value-taking operators reject arrays. Select columns accept only equality/membership operators appropriate to their cardinality (single: eq/ne/in/nin; multi: contains/ncontains; both: the `is*` checks)." }, "value": { "description": "Operand. Omit for the `is*` operators. Ranges on `number` columns require numbers, on `date` columns ISO strings (compared as UTC, independent of any session timezone); ranges on `boolean`/`json` columns are rejected." @@ -4922,7 +5122,7 @@ "properties": { "id": { "type": "string", - "description": "Stable option id — the value stored in cells." + "description": "Stable option id \u2014 the value stored in cells." }, "name": { "type": "string", @@ -5007,7 +5207,7 @@ }, "ViewConfig": { "type": "object", - "description": "A view’s saved preset: the row predicate and sort plus the column layout. Column references are stable column ids, so renaming a column never invalidates a view.", + "description": "A view\u2019s saved preset: the row predicate and sort plus the column layout. Column references are stable column ids, so renaming a column never invalidates a view.", "properties": { "columnWidths": { "type": "object", @@ -5036,7 +5236,7 @@ "items": { "type": "string" }, - "description": "Column ids hidden by the view. A deny-list — a column added later is visible by default. Hiding is presentation only; the data is untouched and reappears intact when unhidden." + "description": "Column ids hidden by the view. A deny-list \u2014 a column added later is visible by default. Hiding is presentation only; the data is untouched and reappears intact when unhidden." }, "filter": { "$ref": "#/components/schemas/Predicate" @@ -5048,7 +5248,7 @@ }, "View": { "type": "object", - "description": "A saved view: a named preset of filter, sort, and column layout over a table. Presentation only — a view narrows what a reader sees by default, it is never an access boundary, and every row it hides stays reachable by reading the table without it.", + "description": "A saved view: a named preset of filter, sort, and column layout over a table. Presentation only \u2014 a view narrows what a reader sees by default, it is never an access boundary, and every row it hides stays reachable by reading the table without it.", "required": [ "id", "tableId", @@ -5078,7 +5278,7 @@ }, "isDefault": { "type": "boolean", - "description": "Whether this view is the table’s default. At most one view per table is." + "description": "Whether this view is the table\u2019s default. At most one view per table is." }, "createdBy": { "type": ["string", "null"], @@ -5147,7 +5347,7 @@ }, "isDefault": { "type": "boolean", - "description": "Promote this view to the table’s default. Setting it demotes the table’s existing default in the same transaction." + "description": "Promote this view to the table\u2019s default. Setting it demotes the table\u2019s existing default in the same transaction." } } }, @@ -5180,7 +5380,7 @@ }, "nextCursor": { "type": ["string", "null"], - "description": "Always null — a table carries a bounded set of views, so the list is a single full page." + "description": "Always null \u2014 a table carries a bounded set of views, so the list is a single full page." } } }, @@ -5208,7 +5408,7 @@ "properties": { "id": { "type": "string", - "description": "Group id — pass to the run endpoints." + "description": "Group id \u2014 pass to the run endpoints." }, "workflowId": { "type": "string", @@ -5303,13 +5503,13 @@ }, "nextCursor": { "type": ["string", "null"], - "description": "Always null — groups are bounded per table, so the list is a single full page." + "description": "Always null \u2014 groups are bounded per table, so the list is a single full page." } } }, "RunColumnBody": { "type": "object", - "description": "Run one or more groups. Scope with `rowIds` (an explicit set) or `filter` (every matching row) — never both; omit both to run every row. `excludeRowIds` applies only to the filter scope.", + "description": "Run one or more groups. Scope with `rowIds` (an explicit set) or `filter` (every matching row) \u2014 never both; omit both to run every row. `excludeRowIds` applies only to the filter scope.", "required": ["workspaceId", "groupIds"], "properties": { "workspaceId": { @@ -5416,7 +5616,7 @@ "properties": { "ordinal": { "type": "integer", - "description": "The row’s 0-based index in the same predicate-filtered, sorted view that `POST /api/v2/tables/{tableId}/query` returns for these arguments — use it to page straight to the match." + "description": "The row\u2019s 0-based index in the same predicate-filtered, sorted view that `POST /api/v2/tables/{tableId}/query` returns for these arguments \u2014 use it to page straight to the match." }, "rowId": { "type": "string", @@ -5445,7 +5645,7 @@ }, "truncated": { "type": "boolean", - "description": "True when the search hit the server-side cap and more cells match than were returned. Matches have no cursor — narrow the predicate instead of paging." + "description": "True when the search hit the server-side cap and more cells match than were returned. Matches have no cursor \u2014 narrow the predicate instead of paging." } } } @@ -5465,7 +5665,7 @@ }, "importId": { "type": "string", - "description": "Job id — pass to `POST /job/cancel` to stop the import." + "description": "Job id \u2014 pass to `POST /job/cancel` to stop the import." } } } @@ -5484,7 +5684,7 @@ "fileKey": { "type": "string", "minLength": 1, - "description": "Storage key of the uploaded file. Must sit under this workspace’s `workspace/{workspaceId}/` prefix.", + "description": "Storage key of the uploaded file. Must sit under this workspace\u2019s `workspace/{workspaceId}/` prefix.", "example": "workspace/ws_123/imports/contacts.csv" }, "fileName": { @@ -5498,7 +5698,7 @@ }, "mapping": { "type": "object", - "description": "CSV header → column name, or null to skip the header.", + "description": "CSV header \u2192 column name, or null to skip the header.", "additionalProperties": { "type": ["string", "null"] } @@ -5548,7 +5748,7 @@ }, "jobId": { "type": "string", - "description": "Job id — poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download`." + "description": "Job id \u2014 poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download`." } } } @@ -5565,7 +5765,7 @@ "properties": { "url": { "type": "string", - "description": "Presigned URL. Expires shortly after issue — fetch it promptly." + "description": "Presigned URL. Expires shortly after issue \u2014 fetch it promptly." }, "fileName": { "type": "string", @@ -5632,7 +5832,7 @@ }, "nextCursor": { "type": ["string", "null"], - "description": "Always null — the listing is bounded server-side to a single page." + "description": "Always null \u2014 the listing is bounded server-side to a single page." } } }, @@ -5667,7 +5867,7 @@ }, "canceled": { "type": "boolean", - "description": "False when the job had already finished. Cancelling is idempotent — a late request is not an error." + "description": "False when the job had already finished. Cancelling is idempotent \u2014 a late request is not an error." } } } @@ -5685,7 +5885,7 @@ }, "scope": { "enum": ["all", "row"], - "description": "`all` cancels every running and pending cell; `row` cancels one row’s cells." + "description": "`all` cancels every running and pending cell; `row` cancels one row\u2019s cells." }, "rowId": { "type": "string", @@ -5741,7 +5941,7 @@ }, "rowsProcessed": { "type": "integer", - "description": "Rows handled so far — progress for a running job." + "description": "Rows handled so far \u2014 progress for a running job." }, "error": { "type": ["string", "null"], @@ -5751,7 +5951,7 @@ }, "WorkflowGroupOutputColumnInput": { "type": "object", - "description": "A column the group's runs will populate. `workflowGroupId` is NOT accepted — the server stamps it from the group being written.", + "description": "A column the group's runs will populate. `workflowGroupId` is NOT accepted \u2014 the server stamps it from the group being written.", "required": ["name", "type"], "additionalProperties": false, "properties": { @@ -5785,7 +5985,7 @@ }, "group": { "type": "object", - "description": "The binding. `id` is optional and server-generated. Supply `workflowId` when `type` is `manual` (the default), or `enrichmentId` when it is `enrichment` — the mismatch is a 400.", + "description": "The binding. `id` is optional and server-generated. Supply `workflowId` when `type` is `manual` (the default), or `enrichmentId` when it is `enrichment` \u2014 the mismatch is a 400.", "required": ["outputs"], "properties": { "id": { @@ -5809,7 +6009,7 @@ "type": "string", "enum": ["manual", "enrichment"], "default": "manual", - "description": "`manual` means workflow-backed — not hand-entered." + "description": "`manual` means workflow-backed \u2014 not hand-entered." }, "dependencies": { "type": "object", @@ -5850,13 +6050,13 @@ "autoRun": { "type": "boolean", "default": false, - "description": "Backfill every existing row on creation. Defaults to **false** here (the first-party surface defaults true) — on an API key this fans out a metered run per row. Prefer POST /columns/run." + "description": "Backfill every existing row on creation. Defaults to **false** here (the first-party surface defaults true) \u2014 on an API key this fans out a metered run per row. Prefer POST /columns/run." } } }, "UpdateWorkflowGroupBody": { "type": "object", - "description": "Restructure a group. Omitted fields keep their stored values.\n\n**Removing an output deletes that column and its values** — the same behavior as DELETE /columns on a bound column. There is no detach.", + "description": "Restructure a group. Omitted fields keep their stored values.\n\n**Removing an output deletes that column and its values** \u2014 the same behavior as DELETE /columns on a bound column. There is no detach.", "required": ["workspaceId", "groupId"], "additionalProperties": false, "properties": { @@ -6102,7 +6302,7 @@ } }, "Conflict": { - "description": "The request conflicts with the current state of the resource — for example a rename to a name another table in the workspace already uses.", + "description": "The request conflicts with the current state of the resource \u2014 for example a rename to a name another table in the workspace already uses.", "content": { "application/json": { "schema": { diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts index f13c00e3027..51eff8841ec 100644 --- a/apps/sim/app/api/v2/tables/route.test.ts +++ b/apps/sim/app/api/v2/tables/route.test.ts @@ -9,13 +9,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table/types' const { - mockListTables, + mockQueryTables, mockCheckRateLimit, mockResolveWorkspaceAccess, mockIsFeatureEnabled, mockGetWorkspaceOrganizationId, } = vi.hoisted(() => ({ - mockListTables: vi.fn(), + mockQueryTables: vi.fn(), mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), mockIsFeatureEnabled: vi.fn(), @@ -29,7 +29,7 @@ vi.mock('@/app/api/v1/middleware', () => ({ vi.mock('@/lib/table', async () => { const actual = await import('@/lib/table/column-keys') - return { ...actual, listTables: mockListTables } + return { ...actual, queryTables: mockQueryTables } }) vi.mock('@/app/api/table/utils', () => ({ @@ -88,7 +88,7 @@ describe('GET /api/v2/tables', () => { vi.clearAllMocks() mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) - mockListTables.mockResolvedValue([buildTable()]) + mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: null }) mockIsFeatureEnabled.mockResolvedValue(true) mockGetWorkspaceOrganizationId.mockResolvedValue('org-1') }) @@ -102,14 +102,14 @@ describe('GET /api/v2/tables', () => { expect(res.status).toBe(404) expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockListTables).not.toHaveBeenCalled() + expect(mockQueryTables).not.toHaveBeenCalled() }) it('400s when workspaceId is missing', async () => { const res = await callList('') expect(res.status).toBe(400) expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockListTables).not.toHaveBeenCalled() + expect(mockQueryTables).not.toHaveBeenCalled() }) it('surfaces an access-denied failure in the v2 error envelope', async () => { @@ -121,7 +121,7 @@ describe('GET /api/v2/tables', () => { const res = await callList('workspaceId=workspace-1') expect(res.status).toBe(403) expect((await res.json()).error).toMatchObject({ code: 'FORBIDDEN', message: 'Access denied' }) - expect(mockListTables).not.toHaveBeenCalled() + expect(mockQueryTables).not.toHaveBeenCalled() }) it('returns the rate-limit response when denied', async () => { @@ -161,4 +161,41 @@ describe('GET /api/v2/tables', () => { expect(res.status).toBe(200) expect((await res.json()).nextCursor).toBeNull() }) + + it('passes limit and the decoded cursor through to the query', async () => { + mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: null }) + + await callList('workspaceId=workspace-1&limit=25&sortBy=name&sortOrder=desc') + + // The slice must happen in the query, not after a full-workspace read. + expect(mockQueryTables).toHaveBeenCalledWith( + 'workspace-1', + expect.objectContaining({ limit: 25, sortBy: 'name', sortOrder: 'desc' }) + ) + }) + + it('returns a nextCursor when the query reports another page', async () => { + mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: ['Alpha', 'tbl_1'] }) + + const res = await callList('workspaceId=workspace-1&limit=1') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.nextCursor).toEqual(expect.any(String)) + }) + + it('rejects a cursor that does not match the requested sort', async () => { + const first = await callList('workspaceId=workspace-1&sortBy=name') + // Encoded under sortBy=name, replayed under sortBy=createdAt. + mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: ['Alpha', 'tbl_1'] }) + const paged = await callList('workspaceId=workspace-1&sortBy=name&limit=1') + const cursor = (await paged.json()).nextCursor + + const res = await callList( + `?workspaceId=workspace-1&sortBy=createdAt&cursor=${encodeURIComponent(cursor)}` + ) + + expect(res.status).toBe(400) + expect(first.status).toBe(200) + }) }) diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index 1fcafeff9fd..a8db12e87f9 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -6,13 +6,17 @@ import { v2CreateTableContract, v2ListTablesContract } from '@/lib/api/contracts import { isZodError, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createTable, getWorkspaceTableLimits, listTables, type TableSchema } from '@/lib/table' +import { createTable, getWorkspaceTableLimits, queryTables, type TableSchema } from '@/lib/table' import { normalizeColumn } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { + cursorSortKey, + decodeSortedCursor, + encodeSortedCursor, v2CaughtOrchestrationError, v2CursorList, + v2CursorSortError, v2Data, v2Error, v2RateLimitError, @@ -49,16 +53,28 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, folderId, search, sortBy, sortOrder } = parsed.data.query + const { workspaceId, folderId, search, sortBy, sortOrder, limit, cursor } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const tables = await listTables(workspaceId, { folderId, search, sortBy, sortOrder }) + const sort = cursorSortKey(sortBy, sortOrder) + const decoded = decodeSortedCursor(cursor, sort) + if (decoded.status === 'invalid') return v2CursorSortError() + + const { tables, nextKeys } = await queryTables(workspaceId, { + folderId, + search, + sortBy, + sortOrder, + limit, + after: decoded.status === 'ok' ? decoded.keys : undefined, + }) + const items = tables.map(toApiTable) + const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null - // `listTables` returns the full bounded workspace set → single page. - return v2CursorList(items, null, { rateLimit }) + return v2CursorList(items, nextCursor, { rateLimit }) } catch (error) { logger.error(`[${requestId}] Error listing tables`, { error: getErrorMessage(error, 'Unknown error'), diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 18e817d58a8..30b79fe9a37 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -214,6 +214,12 @@ export const v2ListTablesQuerySchema = v1ListTablesQuerySchema.extend({ folderId: z.string().min(1, 'folderId cannot be empty').optional(), search: v2SearchSchema, ...v2SortFields(v2TableSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), + limit: z.coerce + .number() + .optional() + .default(100) + .transform((v) => Math.min(Math.max(1, Math.trunc(v)), 1000)), + cursor: z.string().min(1).optional(), }) export type V2ListTablesQuery = z.output diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 3cfb06a5842..a94feb4b5fd 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -13,10 +13,21 @@ import { tableJobs, userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, type Column, count, eq, isNotNull, isNull, sql } from 'drizzle-orm' +import { and, type Column, count, eq, isNotNull, isNull, type SQL, sql } from 'drizzle-orm' import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' import type { V2TableSortBy } from '@/lib/api/contracts/v2/tables' -import { listOrderBy, searchFilter } from '@/lib/api/list-query' +import { + type CursorKey, + encodeKeyset, + INVALID_CURSOR_MESSAGE, + type KeysetKey, + keysetAfter, + keysetColumns, + listOrderBy, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' @@ -220,6 +231,54 @@ const TABLE_SORTS = { updatedAt: [userTableDefinitions.updatedAt, userTableDefinitions.createdAt], } satisfies Record +/** + * The keysets behind {@link queryTables}' sortable fields. `satisfies` makes + * this total over the contract enum, so a new sortable field fails to compile + * until it has a keyset here rather than silently falling through to an + * unordered scan. + * + * Every key column is `NOT NULL`, and `id` closes each keyset so a page + * boundary inside a run of equal names or timestamps is still stable. + */ +const tableId = textKey(userTableDefinitions.id, (row) => row.id) + +/** `TableDefinition` widens its timestamps to `Date | string`; the keyset needs a `Date`. */ +const asDate = (value: Date | string): Date => (value instanceof Date ? value : new Date(value)) + +const TABLE_KEYSETS = { + name: [textKey(userTableDefinitions.name, (row) => row.name), tableId], + createdAt: [ + timestampKey(userTableDefinitions.createdAt, (row) => asDate(row.createdAt)), + tableId, + ], + updatedAt: [ + timestampKey(userTableDefinitions.updatedAt, (row) => asDate(row.updatedAt)), + tableId, + ], +} satisfies Record[]> + +/** The column projection every table listing reads, shared so one row type serves both. */ +const TABLE_ROW_SELECT = { + id: userTableDefinitions.id, + name: userTableDefinitions.name, + description: userTableDefinitions.description, + schema: userTableDefinitions.schema, + metadata: userTableDefinitions.metadata, + maxRows: userTableDefinitions.maxRows, + workspaceId: userTableDefinitions.workspaceId, + folderId: userTableDefinitions.folderId, + createdBy: userTableDefinitions.createdBy, + archivedAt: userTableDefinitions.archivedAt, + createdAt: userTableDefinitions.createdAt, + updatedAt: userTableDefinitions.updatedAt, + rowCount: userTableDefinitions.rowCount, + ...LOCK_SELECT, +} as const + +type TableRowSelection = Awaited< + ReturnType>['from']> +>[number] + interface ListTablesOptions { scope?: TableScope /** Restrict to one table folder. */ @@ -251,22 +310,7 @@ export async function listTables( sortOrder = 'asc', } = options ?? {} const tables = await db - .select({ - id: userTableDefinitions.id, - name: userTableDefinitions.name, - description: userTableDefinitions.description, - schema: userTableDefinitions.schema, - metadata: userTableDefinitions.metadata, - maxRows: userTableDefinitions.maxRows, - workspaceId: userTableDefinitions.workspaceId, - folderId: userTableDefinitions.folderId, - createdBy: userTableDefinitions.createdBy, - archivedAt: userTableDefinitions.archivedAt, - createdAt: userTableDefinitions.createdAt, - updatedAt: userTableDefinitions.updatedAt, - rowCount: userTableDefinitions.rowCount, - ...LOCK_SELECT, - }) + .select(TABLE_ROW_SELECT) .from(userTableDefinitions) .where( and( @@ -282,9 +326,18 @@ export async function listTables( ) .orderBy(...listOrderBy(TABLE_SORTS[sortBy], sortOrder)) - const jobsByTable = await latestJobsForTables(tables.map((t) => t.id)) + return hydrateTableRows(tables) +} - return tables.map((t) => { +/** + * Attaches each table's latest job fields and its order-corrected schema. The + * `rowCount` subtracts rows a pending delete has already claimed, so a table + * mid-delete reports what a caller can still read rather than the raw column. + */ +async function hydrateTableRows(rows: TableRowSelection[]): Promise { + const jobsByTable = await latestJobsForTables(rows.map((t) => t.id)) + + return rows.map((t) => { const metadata = (t.metadata as TableMetadata) ?? null const { pendingDeleteRemaining, ...jobFields } = jobsByTable.get(t.id) ?? EMPTY_JOB_FIELDS return { @@ -307,6 +360,76 @@ export async function listTables( }) } +export interface QueryTablesOptions { + scope?: TableScope + /** Restrict to one table folder. */ + folderId?: string + /** Case-insensitive substring match on the table name. */ + search?: string + sortBy: V2TableSortBy + sortOrder: V2SortOrder + limit: number + /** Keyset values from a cursor, in the sort's key order. */ + after?: CursorKey[] +} + +export interface QueryTablesResult { + tables: TableDefinition[] + /** Keyset values to resume from, or `null` when this page is the last one. */ + nextKeys: CursorKey[] | null +} + +/** + * One filtered, sorted, bounded page of a workspace's tables. + * + * Distinct from {@link listTables}, which materializes the whole scope for + * callers that genuinely need it. Here the filter, the ordering, and the slice + * are all in the query, so a `search` never costs a full-workspace read — which + * matters now that tables can be created through the public API in bulk. + */ +export async function queryTables( + workspaceId: string, + options: QueryTablesOptions +): Promise { + const { scope = 'active', folderId, search, sortBy, sortOrder, limit, after } = options + const keys = TABLE_KEYSETS[sortBy] + + // A cursor whose values don't bind is a caller error, not an empty filter — + // coercing it away would silently serve page 1 under a resumed cursor. + let resumeAfter: SQL | undefined + if (after) { + const condition = keysetAfter(keys, after, sortOrder) + if (!condition) throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + resumeAfter = condition + } + + const rows = await db + .select(TABLE_ROW_SELECT) + .from(userTableDefinitions) + .where( + and( + eq(userTableDefinitions.workspaceId, workspaceId), + scope === 'all' + ? undefined + : scope === 'archived' + ? isNotNull(userTableDefinitions.archivedAt) + : isNull(userTableDefinitions.archivedAt), + folderId ? eq(userTableDefinitions.folderId, folderId) : undefined, + searchFilter(userTableDefinitions.name, search), + resumeAfter + ) + ) + .orderBy(...listOrderBy(keysetColumns(keys), sortOrder)) + // One extra row is the has-more probe; it never reaches the caller. + .limit(limit + 1) + + const hasMore = rows.length > limit + const tables = await hydrateTableRows(rows.slice(0, limit)) + const last = tables.at(-1) + + return { tables, nextKeys: hasMore && last ? encodeKeyset(keys, last) : null } +} + /** * Creates a new table. * From 0c7b64a55a92b8527ac260ee562795a64161bd55 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 22:26:28 -0700 Subject: [PATCH 054/159] chore(cli): regenerate for the paginated table list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `listTables` gained `limit` and `cursor`, so the CLI's auto-pager now drives it like every other paginated list — no CLI change, which is the point of generating this file. Also picks up `isCurrent` on workflow versions and a new `voice-output` enum member. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/generated/v2-api.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 34c5aa8b163..d57cd49c79b 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -1356,6 +1356,7 @@ export type DeployWorkflowResponse = { version: number action: 'deploy' | 'activate' status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + isCurrent: boolean readiness: { webhooks: 'pending' | 'ready' | 'not_applicable' schedules: 'pending' | 'ready' | 'not_applicable' @@ -2633,6 +2634,8 @@ export type ListTablesQuery = { search?: string sortBy?: 'name' | 'createdAt' | 'updatedAt' sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string } export type ListTablesResponse = { @@ -2785,6 +2788,7 @@ export type ListUsageLogsQuery = { | 'knowledge-base' | 'voice-input' | 'enrichment' + | 'voice-output' workspaceId?: string period?: '1d' | '7d' | '30d' | 'all' | 'custom' startDate?: string @@ -2807,6 +2811,7 @@ export type ListUsageLogsResponse = { | 'knowledge-base' | 'voice-input' | 'enrichment' + | 'voice-output' workflowName: string | null creditCost: number }> @@ -3060,6 +3065,7 @@ export type RollbackWorkflowResponse = { version: number action: 'deploy' | 'activate' status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + isCurrent: boolean readiness: { webhooks: 'pending' | 'ready' | 'not_applicable' schedules: 'pending' | 'ready' | 'not_applicable' @@ -3191,6 +3197,7 @@ export type UndeployWorkflowResponse = { version: number action: 'deploy' | 'activate' status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + isCurrent: boolean readiness: { webhooks: 'pending' | 'ready' | 'not_applicable' schedules: 'pending' | 'ready' | 'not_applicable' @@ -4910,6 +4917,8 @@ export const V2_OPERATIONS = { default: 'createdAt', }, sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + limit: { kind: 'number', default: 100 }, + cursor: { kind: 'string' }, }, }, listTableViews: { @@ -4941,6 +4950,7 @@ export const V2_OPERATIONS = { 'knowledge-base', 'voice-input', 'enrichment', + 'voice-output', ] as const, }, workspaceId: { kind: 'string' }, From e1810a6536bc4099fb1231805876e85ccc13b88a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 23:06:29 -0700 Subject: [PATCH 055/159] fix(cli): make tables rows create and tables columns run usable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were dead on arrival, for opposite reasons. `createTableRows` takes `z.union([batch, single])`. A union has no flat field list, so the generator emitted no body slot — and slot absence reads the same as "this operation has no body", so the command offered nothing and sent nothing. The generator's own comment claimed the runtime fell back to taking the body as JSON; nothing did. Unions are now marked, the fields every branch shares are still emitted (both require `workspaceId`, which comes from the profile), and `--body ` carries the rest, merged over them so the caller still wins on any key it sets. Dropping that merge was my first attempt and it failed on the missing workspace. `runTableColumn` takes `limit: { type, max }`. The pager claimed the *name* `limit` regardless of type, so it became `--limit ` with a default of 100 and sent a number the route rejected on every call, whether or not the flag was passed. The special case now applies only where `limit` is numeric; elsewhere it is an ordinary field and gets the JSON flag its type calls for. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/generated/v2-api.ts | 4 ++ packages/sim-cli/src/runtime/build.test.ts | 63 ++++++++++++++++++++++ packages/sim-cli/src/runtime/build.ts | 22 +++++++- packages/sim-cli/src/runtime/request.ts | 15 ++++++ scripts/generate-v2-cli-api.ts | 45 ++++++++++++++-- 5 files changed, 144 insertions(+), 5 deletions(-) diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index d57cd49c79b..bec61da9beb 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -4233,6 +4233,10 @@ export const V2_OPERATIONS = { pathParams: ['tableId'] as const, responseMode: 'json', summary: 'Create Rows', + body: { + workspaceId: { kind: 'string', required: true }, + }, + opaqueBody: true, }, createTableView: { method: 'POST', diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 15677b76ed6..15cc616340e 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -328,3 +328,66 @@ describe('boolean flags', () => { ) }) }) + +describe('bodies and fields the generator cannot flatten', () => { + it('sends a union body whole, with the profile workspace merged in', async () => { + // `createTableRows` is `z.union([batch, single])`, so there is no field list + // to build flags from. The command exposed nothing at all and sent no body, + // and every call failed with "Request body must be valid JSON". + const [path, options] = await run([ + 'tables', + 'rows', + 'create', + 'tbl_1', + '--body', + '{"rows":[{"city":"Paris"}]}', + ]) + + expect(path).toBe('/api/v2/tables/tbl_1/rows') + // Both branches require `workspaceId`, and it comes from the profile. + expect(options.body).toEqual({ workspaceId: 'ws_local', rows: [{ city: 'Paris' }] }) + }) + + it('lets the caller override a shared field', async () => { + const [, options] = await run([ + 'tables', + 'rows', + 'create', + 'tbl_1', + '--body', + '{"workspaceId":"ws_other","rows":[]}', + ]) + expect(options.body).toMatchObject({ workspaceId: 'ws_other' }) + }) + + it('refuses a union body that is not an object', async () => { + await expect(run(['tables', 'rows', 'create', 'tbl_1', '--body', '[1,2]'])).rejects.toThrow( + /--body must be a JSON object/ + ) + }) + + it('leaves a non-numeric `limit` alone', async () => { + // `runTableColumn` takes `limit: { type, max }`. The pager claimed the name + // regardless of type, turning it into `--limit ` that defaulted to 100, + // so every call failed with "expected object, received number". + const [, omitted] = await run(['tables', 'columns', 'run', 'tbl_1', '--group-ids', '["g1"]']) + expect(omitted.body).not.toHaveProperty('limit') + + const [, given] = await run([ + 'tables', + 'columns', + 'run', + 'tbl_1', + '--group-ids', + '["g1"]', + '--limit', + '{"type":"rows","max":5}', + ]) + expect(given.body).toMatchObject({ limit: { type: 'rows', max: 5 } }) + }) + + it('still gives paginated lists their numeric --limit', async () => { + const [, options] = await run(['files', 'list', '--limit', '7']) + expect(options.query).toMatchObject({ limit: 7 }) + }) +}) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 3b76d0a4699..a1da31835c5 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -153,6 +153,11 @@ function unwrapResource(data: unknown): unknown { return value && typeof value === 'object' && !Array.isArray(value) ? value : data } +/** Whether the operation's body is one the generator could not describe field by field. */ +function opaqueBody(spec: object): boolean { + return (spec as { opaqueBody?: boolean }).opaqueBody === true +} + /** The operation's one-line help, taken from the OpenAPI summary at generation time. */ function summaryFor(operation: V2OperationName): string | undefined { return (V2_OPERATIONS[operation] as { summary?: string }).summary @@ -195,7 +200,11 @@ function addFieldOption( const name = flagNameFor(operation, field) const short = flag.short ? `-${flag.short}, ` : '' - if (field === 'limit') { + // The pager owns `--limit`, but only where `limit` means a page size. The + // name is not reserved: `runTableColumn` takes `limit: { type, max }`, and + // claiming it here turned that into a numeric flag that defaulted to 100 and + // made every invocation fail with "expected object, received number". + if (field === 'limit' && (descriptor.kind === 'number' || descriptor.kind === 'integer')) { command.option( `--limit `, 'Maximum items to return (0 for everything)', @@ -286,6 +295,17 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri } } + // A body the generator could not break into fields is offered whole. The + // union behind `tables rows create` (one row, or a batch) has no field list + // to build flags from, and without this the command sent no body at all and + // the server rejected the request as malformed JSON. + if (opaqueBody(operationSpec)) { + command.requiredOption( + '--body ', + 'Request body as JSON (or @path / @- to read a file or stdin)' + ) + } + if (spec.confirm) { command.option('-y, --yes', 'Skip the confirmation') } diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index ccda57dbc89..fa7743bde31 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -213,6 +213,7 @@ export function buildRequest( pathParams: readonly string[] query?: Record body?: Record + opaqueBody?: boolean } let path = spec.path @@ -256,6 +257,20 @@ export function buildRequest( } } + // A union body comes in whole through `--body`, merged over the fields the + // branches share. Replacing outright dropped the profile's `workspaceId`, + // which both branches require, so every insert came back as invalid input. + // The caller's JSON still wins on any key it sets. + if (spec.opaqueBody) { + const raw = flags.body + if (typeof raw !== 'string') throw new SimApiError('--body is required', 0) + const parsed = coerce(raw, { kind: 'object' }, { json: true }, 'body') + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new SimApiError('--body must be a JSON object', 0) + } + return { path, query, body: { ...body, ...(parsed as Record) } } + } + return { path, query, diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index bd85c450597..6b93778c00f 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -341,16 +341,46 @@ function fieldKind(schema: JsonSchema): FieldKind { * Emitted as data rather than baked into types because the CLI has to *iterate* * these at startup to construct commands — a type alone cannot be walked. */ +/** + * Whether the slot is a union, whose branches the CLI cannot turn into flags. + * + * Distinct from "the map came out empty": the shared fields of a union are + * emitted as a map, so emptiness alone no longer identifies one, and the + * runtime still has to know the rest of the body must come in as JSON. + */ +function isUnionSlot(schema: z.ZodType): boolean { + const json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as JsonSchema + return Object.keys(json.properties ?? {}).length === 0 && Boolean(json.anyOf ?? json.oneOf) +} + function renderSlotMap(schema: z.ZodType | undefined, indent: string): string | null { if (!schema) return null const json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as JsonSchema - const properties: Record = json.properties ?? {} - const required = new Set(json.required ?? []) + let properties: Record = json.properties ?? {} + let required = new Set(json.required ?? []) + + // A union has no properties of its own, but the fields every branch agrees on + // are still known and still have to be sent — `workspaceId` is required by + // both branches of the row-insert body and comes from the profile, so + // dropping it left `tables rows create` rejected as invalid input. + if (Object.keys(properties).length === 0) { + const branches = (json.anyOf ?? json.oneOf) as JsonSchema[] | undefined + if (branches?.length) { + const shared = branches.reduce( + (keys, branch) => keys.filter((key) => branch.properties?.[key] !== undefined), + Object.keys(branches[0].properties ?? {}) + ) + properties = Object.fromEntries(shared.map((key) => [key, branches[0].properties[key]])) + required = new Set(shared.filter((key) => branches.every((b) => b.required?.includes(key)))) + } + } + const keys = Object.keys(properties) - // A union body (e.g. single-row vs batch insert) has no flat field list; the - // runtime falls back to taking the whole body as JSON. + // A union body (e.g. single-row vs batch insert) has no flat field list. The + // caller marks it `opaqueBody` so the runtime can offer the whole body as one + // JSON flag instead. if (keys.length === 0) return null const lines = keys.map((key) => { @@ -441,6 +471,13 @@ function render(operations: Operation[]): string { for (const slot of ['query', 'body'] as const) { const map = renderSlotMap(op.contract[slot], ' ') if (map) out.push(` ${slot}: ${map},`) + // A declared slot with no flat field list still has to be sendable. + // Absence alone cannot say so: it means both "no body" and "a body the + // generator could not describe", and reading it as the former left + // `tables rows create` unable to send anything at all. + if (slot === 'body' && op.contract.body && isUnionSlot(op.contract.body)) { + out.push(` opaqueBody: true,`) + } } out.push(' },') } From 3de8dc3ec501ac7ce6602fe64ff95be373ac8715 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 00:12:34 -0700 Subject: [PATCH 056/159] feat(api): add multipart knowledge document uploads --- apps/docs/openapi-v2-knowledge.json | 384 ++++++++++++++++++ .../uploads/[uploadId]/complete/route.ts | 1 + .../files/uploads/[uploadId]/parts/route.ts | 1 + .../app/api/files/uploads/[uploadId]/route.ts | 1 + .../uploads/[uploadId]/complete/route.ts | 1 + .../files/uploads/[uploadId]/parts/route.ts | 1 + .../api/v2/files/uploads/[uploadId]/route.ts | 1 + .../app/api/v2/files/uploads/route.test.ts | 1 + .../api/v2/knowledge/[id]/documents/route.ts | 3 +- .../uploads/[uploadId]/complete/route.test.ts | 193 +++++++++ .../uploads/[uploadId]/complete/route.ts | 146 +++++++ .../uploads/[uploadId]/parts/route.ts | 78 ++++ .../documents/uploads/[uploadId]/route.ts | 72 ++++ .../[id]/documents/uploads/route.test.ts | 126 ++++++ .../knowledge/[id]/documents/uploads/route.ts | 86 ++++ .../knowledge/[id]/documents/uploads/utils.ts | 126 ++++++ apps/sim/lib/api/contracts/v2/knowledge.ts | 78 ++++ apps/sim/lib/knowledge/documents/service.ts | 58 ++- .../knowledge/orchestration/documents.test.ts | 69 ++++ .../lib/knowledge/orchestration/documents.ts | 61 ++- .../table/orchestration/import-resource.ts | 1 + apps/sim/lib/uploads/client/session-upload.ts | 69 ++++ apps/sim/lib/uploads/core/upload-token.ts | 18 +- .../uploads/multipart-session/service.test.ts | 129 ++++++ .../lib/uploads/multipart-session/service.ts | 108 ++++- apps/sim/lib/uploads/shared/types.ts | 3 + scripts/check-api-validation-contracts.ts | 4 +- 27 files changed, 1782 insertions(+), 37 deletions(-) create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts create mode 100644 apps/sim/lib/uploads/multipart-session/service.test.ts diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 38a6d2ed2f5..6d057459490 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -781,6 +781,179 @@ ] } }, + "/api/v2/knowledge/{id}/documents/uploads": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + } + ], + "post": { + "operationId": "createKnowledgeDocumentUpload", + "summary": "Create Document Upload", + "description": "Create a stateless multipart upload session for a knowledge document. Write access, billing, usage, file type, file size, and workspace storage are checked before provider storage is allocated. The signed upload token binds the caller, workspace, knowledge base, filename, content type, byte size, provider, and knowledge-document purpose. Files may be up to 100 MB.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/uploads\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"name\":\"guide.pdf\",\"contentType\":\"application/pdf\",\"size\":248913}'" + } + ], + "requestBody": { + "required": true, + "description": "Metadata for the document that will be uploaded through signed part URLs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDocumentUploadBody" + } + } + } + }, + "responses": { + "201": { + "description": "The multipart upload session and its signed control-plane token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentUploadEnvelope" + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "402": { "$ref": "#/components/responses/UsageLimitExceeded" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { "$ref": "#/components/responses/UnsupportedMediaType" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/knowledge/{id}/documents/uploads/{uploadId}": { + "parameters": [ + { "$ref": "#/components/parameters/KnowledgeBaseId" }, + { "$ref": "#/components/parameters/UploadId" }, + { "$ref": "#/components/parameters/UploadTokenHeader" } + ], + "delete": { + "operationId": "abortKnowledgeDocumentUpload", + "summary": "Abort Document Upload", + "description": "Abort an incomplete knowledge-document upload and discard its provider parts. Aborting an already aborted session is safe.", + "tags": ["Knowledge Bases"], + "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "responses": { + "200": { + "description": "The aborted upload session.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentUploadEnvelope" + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/knowledge/{id}/documents/uploads/{uploadId}/parts": { + "parameters": [ + { "$ref": "#/components/parameters/KnowledgeBaseId" }, + { "$ref": "#/components/parameters/UploadId" }, + { "$ref": "#/components/parameters/UploadTokenHeader" } + ], + "post": { + "operationId": "createKnowledgeDocumentUploadPartUrls", + "summary": "Create Document Upload Part URLs", + "description": "Issue short-lived signed PUT URLs for up to 100 part numbers. PUT each byte range directly to the returned URL with the returned headers.", + "tags": ["Knowledge Bases"], + "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePartUrlsBody" + } + } + } + }, + "responses": { + "200": { + "description": "Signed URLs for the requested parts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartUrlsEnvelope" + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/v2/knowledge/{id}/documents/uploads/{uploadId}/complete": { + "parameters": [ + { "$ref": "#/components/parameters/KnowledgeBaseId" }, + { "$ref": "#/components/parameters/UploadId" }, + { "$ref": "#/components/parameters/UploadTokenHeader" } + ], + "post": { + "operationId": "completeKnowledgeDocumentUpload", + "summary": "Complete Document Upload", + "description": "Verify and assemble all parts, record knowledge-base storage ownership, create the knowledge document, and queue asynchronous processing. Repeating the same completion is idempotent and returns the same document. It never registers a general workspace file.", + "tags": ["Knowledge Bases"], + "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompleteUploadBody" + } + } + } + }, + "responses": { + "200": { + "description": "The completed upload and queued knowledge document.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentUploadEnvelope" + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "402": { "$ref": "#/components/responses/UsageLimitExceeded" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, "/api/v2/knowledge/{id}/documents/{documentId}": { "parameters": [ { @@ -956,6 +1129,17 @@ "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" } }, + "UploadId": { + "name": "uploadId", + "in": "path", + "required": true, + "description": "The upload session identifier returned when the upload was created.", + "schema": { + "type": "string", + "minLength": 1, + "example": "upload_01K0M9J4W6K4J3T73Q8W2NYR9P" + } + }, "WorkspaceIdQuery": { "name": "workspaceId", "in": "query", @@ -966,6 +1150,16 @@ "minLength": 1, "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" } + }, + "UploadTokenHeader": { + "name": "upload-token", + "in": "header", + "required": true, + "description": "The signed token returned when this upload was created. It is bound to the caller and all upload metadata.", + "schema": { + "type": "string", + "minLength": 1 + } } }, "headers": { @@ -1231,6 +1425,196 @@ } } }, + "CreateDocumentUploadBody": { + "type": "object", + "additionalProperties": false, + "required": ["workspaceId", "name", "contentType", "size"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace that owns the knowledge base." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Filename recorded on the knowledge document." + }, + "contentType": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Supported MIME type for the document." + }, + "size": { + "type": "integer", + "minimum": 1, + "maximum": 104857600, + "description": "Exact file size in bytes." + } + } + }, + "DocumentUpload": { + "type": "object", + "required": [ + "id", + "knowledgeBaseId", + "status", + "name", + "contentType", + "size", + "partSize", + "partCount", + "uploadToken", + "expiresAt", + "error", + "document" + ], + "properties": { + "id": { + "type": "string", + "description": "Upload session identifier." + }, + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base that will own the document." + }, + "status": { + "type": "string", + "enum": ["uploading", "finalizing", "completed", "failed", "aborted", "expired"] + }, + "name": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "size": { + "type": "integer", + "minimum": 1 + }, + "partSize": { + "type": "integer", + "minimum": 1 + }, + "partCount": { + "type": "integer", + "minimum": 1 + }, + "uploadToken": { + "type": "string", + "minLength": 1, + "description": "Signed token required for part URLs, completion, and abort." + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "error": { + "type": ["string", "null"] + }, + "document": { + "oneOf": [{ "$ref": "#/components/schemas/DocumentSummary" }, { "type": "null" }], + "description": "The queued document after completion; null while uploading or after abort." + } + } + }, + "DocumentUploadEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/DocumentUpload" + } + } + }, + "CreatePartUrlsBody": { + "type": "object", + "additionalProperties": false, + "required": ["partNumbers"], + "properties": { + "partNumbers": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "integer", + "minimum": 1 + } + } + } + }, + "UploadPartUrl": { + "type": "object", + "required": ["partNumber", "url", "headers", "expiresAt"], + "properties": { + "partNumber": { + "type": "integer", + "minimum": 1 + }, + "url": { + "type": "string", + "format": "uri" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "PartUrlsEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["parts"], + "properties": { + "parts": { + "type": "array", + "maxItems": 100, + "items": { + "$ref": "#/components/schemas/UploadPartUrl" + } + } + } + } + } + }, + "CompleteUploadBody": { + "type": "object", + "additionalProperties": false, + "required": ["parts"], + "properties": { + "parts": { + "type": "array", + "minItems": 1, + "maxItems": 640, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["partNumber"], + "properties": { + "partNumber": { + "type": "integer", + "minimum": 1 + }, + "etag": { + "type": "string", + "minLength": 1 + } + } + } + } + } + }, "DocumentSummary": { "type": "object", "description": "Summary representation of a document, returned in list operations and as the upload acknowledgement.", diff --git a/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts index 560357745da..ce66561f854 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts @@ -32,6 +32,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Uploa uploadId: parsed.data.params.uploadId, workspaceId, userId: user, + purpose: 'workspace_file', uploadToken: parsed.data.headers['upload-token'], }) const metadata = upload.metadata as { folderId?: string | null } diff --git a/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts index a01a9c4ca9d..80e0edd9e12 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts @@ -29,6 +29,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Uploa uploadId: parsed.data.params.uploadId, workspaceId, userId: user, + purpose: 'workspace_file', uploadToken: parsed.data.headers['upload-token'], }) const parts = await createUploadPartUrls({ diff --git a/apps/sim/app/api/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/route.ts index ffda91c0c44..f33f3bb3004 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/route.ts @@ -27,6 +27,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Upl uploadId: parsed.data.params.uploadId, workspaceId, userId: user, + purpose: 'workspace_file', uploadToken: parsed.data.headers['upload-token'], }) return NextResponse.json({ data: toV2FileUpload(await abortUploadSession(upload), null) }) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts index 3dfca6ca127..e4504fd952a 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts @@ -48,6 +48,7 @@ export const POST = withRouteHandler( uploadId, workspaceId, userId, + purpose: 'workspace_file', uploadToken: parsed.data.headers['upload-token'], }) const metadata = session.metadata as { folderId?: string | null } diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts index 4272e75796f..c3148698c7b 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts @@ -45,6 +45,7 @@ export const POST = withRouteHandler( uploadId, workspaceId, userId, + purpose: 'workspace_file', uploadToken: parsed.data.headers['upload-token'], }) const parts = await createUploadPartUrls({ diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts index cccb93f3524..dd07baa1d72 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts @@ -43,6 +43,7 @@ export const DELETE = withRouteHandler( uploadId, workspaceId, userId, + purpose: 'workspace_file', uploadToken: parsed.data.headers['upload-token'], }) const aborted = await abortUploadSession(session) diff --git a/apps/sim/app/api/v2/files/uploads/route.test.ts b/apps/sim/app/api/v2/files/uploads/route.test.ts index 34c934f0183..b70ac9aba3d 100644 --- a/apps/sim/app/api/v2/files/uploads/route.test.ts +++ b/apps/sim/app/api/v2/files/uploads/route.test.ts @@ -65,6 +65,7 @@ describe('POST /api/v2/files/uploads', () => { id: 'upload-1', workspaceId: WORKSPACE_ID, userId: 'user-1', + knowledgeBaseId: null, purpose: 'workspace_file', storageContext: 'workspace', storageKey: `${WORKSPACE_ID}/file.csv`, 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 1f513d5f2ed..b6adfff3c07 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -24,6 +24,7 @@ import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/typ import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' import { validateFileType } from '@/lib/uploads/utils/validation' import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' @@ -44,7 +45,7 @@ const logger = createLogger('V2KnowledgeDocumentsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -const MAX_FILE_SIZE = 100 * 1024 * 1024 +const MAX_FILE_SIZE = MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 interface DocumentsRouteParams { 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 new file mode 100644 index 00000000000..ea59e90d66b --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts @@ -0,0 +1,193 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockCompleteUploadSession, + mockDeleteFile, + mockDeleteFileMetadata, + mockPerformUploadKnowledgeDocument, + mockRecordKnowledgeBaseFileOwnership, + mockResolveKnowledgeDocumentUploadAccess, + mockResolveKnowledgeDocumentUploadBilling, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockCompleteUploadSession: vi.fn(), + mockDeleteFile: vi.fn(), + mockDeleteFileMetadata: vi.fn(), + mockPerformUploadKnowledgeDocument: vi.fn(), + mockRecordKnowledgeBaseFileOwnership: vi.fn(), + mockResolveKnowledgeDocumentUploadAccess: vi.fn(), + mockResolveKnowledgeDocumentUploadBilling: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit })) +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) +vi.mock('@/lib/knowledge/orchestration', () => ({ + performUploadKnowledgeDocument: mockPerformUploadKnowledgeDocument, +})) +vi.mock('@/lib/uploads/core/storage-service', () => ({ deleteFile: mockDeleteFile })) +vi.mock('@/lib/uploads/server/metadata', () => ({ + deleteFileMetadata: mockDeleteFileMetadata, + recordKnowledgeBaseFileOwnership: mockRecordKnowledgeBaseFileOwnership, +})) +vi.mock('@/lib/uploads/multipart-session/service', () => ({ + completeUploadSession: mockCompleteUploadSession, +})) +vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ + getOwnedKnowledgeDocumentUpload: vi.fn(() => SESSION), + knowledgeDocumentFileUrl: vi.fn(() => FILE_URL), + resolveKnowledgeDocumentUploadAccess: mockResolveKnowledgeDocumentUploadAccess, + resolveKnowledgeDocumentUploadBilling: mockResolveKnowledgeDocumentUploadBilling, + toV2KnowledgeDocumentUpload: (session: Record, document: unknown) => ({ + ...session, + name: session.fileName, + contentType: session.contentType, + size: session.fileSize, + expiresAt: '2026-08-04T21:00:00.000Z', + document, + }), +})) + +import { POST } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const FILE_URL = '/api/files/serve/s3/kb%2Fguide.pdf?context=knowledge-base' +const SESSION = { + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + purpose: 'knowledge_document', + storageContext: 'knowledge-base', + storageKey: 'kb/guide.pdf', + storageProvider: 's3', + providerUploadId: 'provider-1', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + partSize: 8 * 1024 * 1024, + partCount: 1, + status: 'uploading', + metadata: {}, + uploadToken: 'token', + createdAt: new Date('2026-08-03T21:00:00.000Z'), + expiresAt: new Date('2026-08-04T21:00:00.000Z'), + completedFileId: null, + error: null, + completedAt: null, + updatedAt: new Date('2026-08-03T21:00:00.000Z'), +} as const +const DOCUMENT = { + id: 'upload-1', + knowledgeBaseId: 'kb-1', + filename: 'guide.pdf', + fileUrl: FILE_URL, + fileSize: 1024, + mimeType: 'application/pdf', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: true, + uploadedAt: new Date('2026-08-03T21:01:00.000Z'), +} +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-03T22:00:00.000Z'), +} + +function request() { + return POST( + new NextRequest( + `http://localhost:3000/api/v2/knowledge/kb-1/documents/uploads/upload-1/complete?workspaceId=${WORKSPACE_ID}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'upload-token': 'token' }, + body: JSON.stringify({ parts: [{ partNumber: 1, etag: 'etag-1' }] }), + } + ), + { params: Promise.resolve({ id: 'kb-1', uploadId: 'upload-1' }) } + ) +} + +describe('POST knowledge-document multipart completion', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockResolveKnowledgeDocumentUploadAccess.mockResolvedValue({ + kb: { id: 'kb-1', name: 'Docs' }, + }) + mockResolveKnowledgeDocumentUploadBilling.mockResolvedValue({ actorUserId: 'payer-1' }) + mockRecordKnowledgeBaseFileOwnership.mockResolvedValue(undefined) + mockDeleteFile.mockResolvedValue(undefined) + mockDeleteFileMetadata.mockResolvedValue(true) + mockPerformUploadKnowledgeDocument.mockResolvedValue({ + success: true, + document: DOCUMENT, + created: true, + }) + mockCompleteUploadSession.mockImplementation(async ({ session, finalize }) => { + const finalized = await finalize(session) + return { + session: { ...session, status: 'completed', completedFileId: finalized.completedFileId }, + value: finalized.value, + alreadyCompleted: false, + } + }) + }) + + it('records knowledge ownership and invokes the shared document orchestration', async () => { + const response = await request() + + expect(response.status).toBe(200) + expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenCalledWith({ + key: 'kb/guide.pdf', + userId: 'user-1', + workspaceId: WORKSPACE_ID, + originalName: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + }) + expect(mockPerformUploadKnowledgeDocument).toHaveBeenCalledWith( + expect.objectContaining({ + documentId: 'upload-1', + startProcessing: 'queue', + uploadedBy: 'payer-1', + document: { + filename: 'guide.pdf', + fileUrl: FILE_URL, + fileSize: 1024, + mimeType: 'application/pdf', + }, + }) + ) + expect(mockDeleteFile).not.toHaveBeenCalled() + expect(mockDeleteFileMetadata).not.toHaveBeenCalled() + }) + + it('removes the committed object and ownership binding when document creation fails', async () => { + mockPerformUploadKnowledgeDocument.mockResolvedValue({ + success: false, + errorCode: 'payload_too_large', + error: 'Storage limit exceeded', + }) + + const response = await request() + + expect(response.status).toBe(413) + expect(mockDeleteFile).toHaveBeenCalledWith({ + key: 'kb/guide.pdf', + context: 'knowledge-base', + }) + expect(mockDeleteFileMetadata).toHaveBeenCalledWith('kb/guide.pdf') + }) +}) 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 new file mode 100644 index 00000000000..bb0f28461d0 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts @@ -0,0 +1,146 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { v2CompleteKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration' +import { deleteFile } from '@/lib/uploads/core/storage-service' +import { + completeUploadSession, + type UploadSessionRecord, +} from '@/lib/uploads/multipart-session/service' +import { deleteFileMetadata, recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { + getOwnedKnowledgeDocumentUpload, + knowledgeDocumentFileUrl, + resolveKnowledgeDocumentUploadAccess, + resolveKnowledgeDocumentUploadBilling, + toV2KnowledgeDocumentUpload, +} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2CompleteKnowledgeDocumentUploadAPI') + +interface KnowledgeDocumentUploadRouteParams { + params: Promise<{ id: string; uploadId: string }> +} + +async function cleanupFailedKnowledgeDocumentUpload(session: UploadSessionRecord): Promise { + await deleteFile({ key: session.storageKey, context: 'knowledge-base' }) + await deleteFileMetadata(session.storageKey) +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CompleteKnowledgeDocumentUploadContract, + request, + context, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId, uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveKnowledgeDocumentUploadAccess({ + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + }) + if (access instanceof NextResponse) return access + + const billingAttribution = await resolveKnowledgeDocumentUploadBilling({ + workspaceId, + userId, + rateLimit, + }) + if (billingAttribution instanceof NextResponse) return billingAttribution + + const session = getOwnedKnowledgeDocumentUpload({ + knowledgeBaseId, + uploadId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const result = await completeUploadSession({ + session, + parts: parsed.data.body.parts, + finalize: async (claimed) => { + try { + await recordKnowledgeBaseFileOwnership({ + key: claimed.storageKey, + userId, + workspaceId, + originalName: claimed.fileName, + contentType: claimed.contentType, + size: claimed.fileSize, + }) + const outcome = await performUploadKnowledgeDocument({ + knowledgeBase: { + id: knowledgeBaseId, + name: access.kb.name, + workspaceId, + }, + document: { + filename: claimed.fileName, + fileUrl: knowledgeDocumentFileUrl(claimed), + fileSize: claimed.fileSize, + mimeType: claimed.contentType, + }, + documentId: claimed.id, + startProcessing: 'queue', + billingAttribution, + uploadedBy: billingAttribution.actorUserId, + userId, + source: 'api', + requestId, + request, + }) + if (!outcome.success) { + throw new OrchestrationError(outcome.errorCode, outcome.error) + } + return { + value: outcome.document, + completedFileId: outcome.document.id, + } + } catch (error) { + await cleanupFailedKnowledgeDocumentUpload(claimed) + throw error + } + }, + }) + + return v2Data(toV2KnowledgeDocumentUpload(result.session, result.value), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error(`[${requestId}] Failed to complete knowledge-document upload`, { + error: getErrorMessage(error), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) 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 new file mode 100644 index 00000000000..80b079ad985 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts @@ -0,0 +1,78 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { v2CreateKnowledgeDocumentUploadPartUrlsContract } from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createUploadPartUrls } from '@/lib/uploads/multipart-session/service' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { + getOwnedKnowledgeDocumentUpload, + resolveKnowledgeDocumentUploadAccess, +} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDocumentUploadPartsAPI') + +interface KnowledgeDocumentUploadRouteParams { + params: Promise<{ id: string; uploadId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateKnowledgeDocumentUploadPartUrlsContract, + request, + context, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId, uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveKnowledgeDocumentUploadAccess({ + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + }) + if (access instanceof NextResponse) return access + + const session = getOwnedKnowledgeDocumentUpload({ + knowledgeBaseId, + uploadId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const parts = await createUploadPartUrls({ + session, + partNumbers: parsed.data.body.partNumbers, + localOrigin: request.nextUrl.origin, + }) + return v2Data({ parts }, { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create knowledge-document upload part URLs', { + error: getErrorMessage(error), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) 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 new file mode 100644 index 00000000000..b30f79617e3 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts @@ -0,0 +1,72 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { v2AbortKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { abortUploadSession } from '@/lib/uploads/multipart-session/service' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { + getOwnedKnowledgeDocumentUpload, + resolveKnowledgeDocumentUploadAccess, + toV2KnowledgeDocumentUpload, +} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDocumentUploadAPI') + +interface KnowledgeDocumentUploadRouteParams { + params: Promise<{ id: string; uploadId: string }> +} + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2AbortKnowledgeDocumentUploadContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId, uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveKnowledgeDocumentUploadAccess({ + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + }) + if (access instanceof NextResponse) return access + + const session = getOwnedKnowledgeDocumentUpload({ + knowledgeBaseId, + uploadId, + workspaceId, + userId, + uploadToken: parsed.data.headers['upload-token'], + }) + const aborted = await abortUploadSession(session) + return v2Data(toV2KnowledgeDocumentUpload(aborted, null), { rateLimit }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to abort knowledge-document upload session', { + error: getErrorMessage(error), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) 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 new file mode 100644 index 00000000000..06eb0ca5d16 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockCreateUploadSession, + mockResolveKnowledgeDocumentUploadAccess, + mockResolveKnowledgeDocumentUploadBilling, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockCreateUploadSession: vi.fn(), + mockResolveKnowledgeDocumentUploadAccess: vi.fn(), + mockResolveKnowledgeDocumentUploadBilling: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit })) +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) +vi.mock('@/lib/uploads/multipart-session/service', () => ({ + createUploadSession: mockCreateUploadSession, +})) +vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ + resolveKnowledgeDocumentUploadAccess: mockResolveKnowledgeDocumentUploadAccess, + resolveKnowledgeDocumentUploadBilling: mockResolveKnowledgeDocumentUploadBilling, + toV2KnowledgeDocumentUpload: (session: Record) => ({ + ...session, + name: session.fileName, + contentType: session.contentType, + size: session.fileSize, + expiresAt: '2026-08-04T21:00:00.000Z', + document: null, + }), +})) + +import { POST } from '@/app/api/v2/knowledge/[id]/documents/uploads/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-03T22:00:00.000Z'), +} + +function request() { + return POST( + new NextRequest('http://localhost:3000/api/v2/knowledge/kb-1/documents/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + name: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + }), + }), + { params: Promise.resolve({ id: 'kb-1' }) } + ) +} + +describe('POST /api/v2/knowledge/[id]/documents/uploads', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockResolveKnowledgeDocumentUploadAccess.mockResolvedValue({ + kb: { id: 'kb-1', name: 'Docs' }, + }) + mockResolveKnowledgeDocumentUploadBilling.mockResolvedValue({ actorUserId: 'user-1' }) + mockCreateUploadSession.mockResolvedValue({ + id: 'upload-1', + knowledgeBaseId: 'kb-1', + status: 'uploading', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + partSize: 8 * 1024 * 1024, + partCount: 1, + uploadToken: 'token', + error: null, + }) + }) + + it('authorizes the knowledge base and runs usage billing before accepting storage', async () => { + const response = await request() + + expect(response.status).toBe(201) + expect(mockResolveKnowledgeDocumentUploadAccess).toHaveBeenCalledWith( + expect.objectContaining({ + knowledgeBaseId: 'kb-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + }) + ) + expect(mockResolveKnowledgeDocumentUploadBilling).toHaveBeenCalled() + expect(mockCreateUploadSession).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + purpose: 'knowledge_document', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + }) + expect(mockResolveKnowledgeDocumentUploadBilling.mock.invocationCallOrder[0]).toBeLessThan( + mockCreateUploadSession.mock.invocationCallOrder[0] + ) + }) + + it('does not run billing or create provider state when knowledge write access is denied', async () => { + mockResolveKnowledgeDocumentUploadAccess.mockResolvedValue( + NextResponse.json({ error: { code: 'FORBIDDEN', message: 'Access denied' } }, { status: 403 }) + ) + + const response = await request() + + expect(response.status).toBe(403) + expect(mockResolveKnowledgeDocumentUploadBilling).not.toHaveBeenCalled() + expect(mockCreateUploadSession).not.toHaveBeenCalled() + }) +}) 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 new file mode 100644 index 00000000000..c13649a8fb3 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts @@ -0,0 +1,86 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { v2CreateKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createUploadSession } from '@/lib/uploads/multipart-session/service' +import { validateFileType } from '@/lib/uploads/utils/validation' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { + resolveKnowledgeDocumentUploadAccess, + resolveKnowledgeDocumentUploadBilling, + toV2KnowledgeDocumentUpload, +} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDocumentUploadsAPI') + +interface KnowledgeDocumentUploadsRouteParams { + params: Promise<{ id: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: KnowledgeDocumentUploadsRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2CreateKnowledgeDocumentUploadContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId } = parsed.data.params + const { workspaceId, name, contentType, size } = parsed.data.body + + const access = await resolveKnowledgeDocumentUploadAccess({ + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + }) + if (access instanceof NextResponse) return access + + const billing = await resolveKnowledgeDocumentUploadBilling({ + workspaceId, + userId, + rateLimit, + }) + if (billing instanceof NextResponse) return billing + + const fileTypeError = validateFileType(name, contentType) + if (fileTypeError) { + return v2Error('UNSUPPORTED_MEDIA_TYPE', fileTypeError.message) + } + + const session = await createUploadSession({ + workspaceId, + userId, + knowledgeBaseId, + purpose: 'knowledge_document', + fileName: name, + contentType, + fileSize: size, + }) + return v2Data(toV2KnowledgeDocumentUpload(session, null), { rateLimit, status: 201 }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + logger.error('Failed to create knowledge-document upload session', { + error: getErrorMessage(error), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) 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 new file mode 100644 index 00000000000..4b1e61a4e74 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts @@ -0,0 +1,126 @@ +import { NextResponse } from 'next/server' +import type { + V2KnowledgeDocumentSummary, + V2KnowledgeDocumentUpload, +} from '@/lib/api/contracts/v2/knowledge' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { + checkAttributedUsageLimits, + resolveBillingAttribution, + resolveSystemBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import type { CreatedKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { + getOwnedUploadSession, + type UploadSessionRecord, +} from '@/lib/uploads/multipart-session/service' +import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' +import type { RateLimitResult } from '@/app/api/v1/middleware' +import { v2Error } from '@/app/api/v2/lib/response' + +export async function resolveKnowledgeDocumentUploadAccess(params: { + knowledgeBaseId: string + workspaceId: string + userId: string + rateLimit: RateLimitResult +}): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase( + params.knowledgeBaseId, + params.workspaceId, + params.userId, + params.rateLimit, + 'write' + ) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return v2Error('FORBIDDEN', 'Access denied') +} + +export async function resolveKnowledgeDocumentUploadBilling(params: { + workspaceId: string + userId: string + rateLimit: RateLimitResult +}): Promise { + const attribution = + params.rateLimit.keyType === 'workspace' + ? await resolveSystemBillingAttribution(params.workspaceId) + : await resolveBillingAttribution({ + actorUserId: params.userId, + workspaceId: params.workspaceId, + }) + const usage = await checkAttributedUsageLimits(attribution) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + return attribution +} + +export function getOwnedKnowledgeDocumentUpload(params: { + knowledgeBaseId: string + uploadId: string + workspaceId: string + userId: string + uploadToken: string +}): UploadSessionRecord { + return getOwnedUploadSession({ + uploadId: params.uploadId, + workspaceId: params.workspaceId, + userId: params.userId, + purpose: 'knowledge_document', + knowledgeBaseId: params.knowledgeBaseId, + uploadToken: params.uploadToken, + }) +} + +export function toV2KnowledgeDocumentSummary( + document: CreatedKnowledgeDocument +): V2KnowledgeDocumentSummary { + return { + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + filename: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + processingStatus: document.processingStatus ?? 'pending', + chunkCount: document.chunkCount, + tokenCount: document.tokenCount, + characterCount: document.characterCount, + enabled: document.enabled, + createdAt: serializeDate(document.uploadedAt), + } +} + +export function toV2KnowledgeDocumentUpload( + session: UploadSessionRecord, + document: CreatedKnowledgeDocument | null +): V2KnowledgeDocumentUpload { + if (!session.knowledgeBaseId) { + throw new Error('Knowledge-document upload session is missing its knowledge base') + } + return { + id: session.id, + knowledgeBaseId: session.knowledgeBaseId, + status: session.status, + name: session.fileName, + contentType: session.contentType, + size: session.fileSize, + partSize: session.partSize, + partCount: session.partCount, + uploadToken: session.uploadToken, + expiresAt: session.expiresAt.toISOString(), + error: session.error, + document: document ? toV2KnowledgeDocumentSummary(document) : null, + } +} + +export function knowledgeDocumentFileUrl(session: UploadSessionRecord): string { + if (session.storageContext !== 'knowledge-base') { + throw new Error('Knowledge-document upload has an invalid storage context') + } + const providerPrefix = session.storageProvider === 'local' ? '' : `${session.storageProvider}/` + return `/api/files/serve/${providerPrefix}${encodeURIComponent(session.storageKey)}?context=knowledge-base` +} diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index d92f30c20a1..40b16177d1d 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -22,6 +22,14 @@ import { v2SearchSchema, v2SortFields, } from '@/lib/api/contracts/v2/shared' +import { + v2CompleteUploadBodySchema, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadStatusSchema, + v2UploadTokenHeadersSchema, +} from '@/lib/api/contracts/v2/uploads' +import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' /** * v2 knowledge contracts. @@ -151,6 +159,39 @@ export type V2KnowledgeSearchData = z.output export const v2UploadKnowledgeDocumentQuerySchema = z.object({ workspaceId: workspaceIdSchema }) export type V2UploadKnowledgeDocumentQuery = z.output +export const v2KnowledgeDocumentUploadParamsSchema = knowledgeBaseParamsSchema.extend({ + uploadId: z.string().min(1, 'uploadId is required'), +}) +export type V2KnowledgeDocumentUploadParams = z.output + +export const v2CreateKnowledgeDocumentUploadBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), + contentType: z.string().trim().min(1, 'contentType is required').max(255), + size: z.number().int().min(1).max(MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE), + }) + .strict() +export type V2CreateKnowledgeDocumentUploadBody = z.input< + typeof v2CreateKnowledgeDocumentUploadBodySchema +> + +export const v2KnowledgeDocumentUploadSchema = z.object({ + id: z.string(), + knowledgeBaseId: z.string(), + status: v2UploadStatusSchema, + name: z.string(), + contentType: z.string(), + size: z.number().int().positive(), + partSize: z.number().int().positive(), + partCount: z.number().int().positive(), + uploadToken: z.string().min(1), + expiresAt: z.string().datetime(), + error: z.string().nullable(), + document: v2KnowledgeDocumentSummarySchema.nullable(), +}) +export type V2KnowledgeDocumentUpload = z.output + export const v2KnowledgeBaseSortFields = ['name', 'createdAt', 'updatedAt'] as const export type V2KnowledgeBaseSortBy = (typeof v2KnowledgeBaseSortFields)[number] @@ -271,6 +312,43 @@ export const v2UploadKnowledgeDocumentContract = defineRouteContract({ }, }) +export const v2CreateKnowledgeDocumentUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/[id]/documents/uploads', + params: knowledgeBaseParamsSchema, + body: v2CreateKnowledgeDocumentUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentUploadSchema) }, +}) + +export const v2AbortKnowledgeDocumentUploadContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/[id]/documents/uploads/[uploadId]', + params: v2KnowledgeDocumentUploadParamsSchema, + query: v2UploadKnowledgeDocumentQuerySchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentUploadSchema) }, +}) + +export const v2CreateKnowledgeDocumentUploadPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts', + params: v2KnowledgeDocumentUploadParamsSchema, + query: v2UploadKnowledgeDocumentQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) + +export const v2CompleteKnowledgeDocumentUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete', + params: v2KnowledgeDocumentUploadParamsSchema, + query: v2UploadKnowledgeDocumentQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2CompleteUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentUploadSchema) }, +}) + export const v2GetKnowledgeDocumentContract = defineRouteContract({ method: 'GET', path: '/api/v2/knowledge/[id]/documents/[documentId]', diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 9ca64bb6d4b..70b6dc6c691 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -1521,7 +1521,8 @@ export async function createSingleDocument( }, knowledgeBaseId: string, requestId: string, - uploadedBy: string | null = null + uploadedBy: string | null = null, + documentId = generateId() ): Promise<{ id: string knowledgeBaseId: string @@ -1542,7 +1543,6 @@ export async function createSingleDocument( tag6: string | null tag7: string | null }> { - const documentId = generateId() const now = new Date() const [resolvedDocumentData] = await resolveServerKnownDocumentSizes([documentData]) const admission = await resolveDocumentStorageAdmission( @@ -1713,6 +1713,60 @@ export async function createSingleDocument( } } +/** Returns one active document by its deterministic upload id. */ +export async function getDocumentByUploadId( + documentId: string, + knowledgeBaseId: string +): Promise< + | (Awaited> & { + processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + }) + | null +> { + const [existing] = await db + .select({ + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + filename: document.filename, + fileUrl: document.fileUrl, + fileSize: document.fileSize, + mimeType: document.mimeType, + chunkCount: document.chunkCount, + tokenCount: document.tokenCount, + characterCount: document.characterCount, + enabled: document.enabled, + uploadedAt: document.uploadedAt, + tag1: document.tag1, + tag2: document.tag2, + tag3: document.tag3, + tag4: document.tag4, + tag5: document.tag5, + tag6: document.tag6, + tag7: document.tag7, + processingStatus: document.processingStatus, + }) + .from(document) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + isNull(document.deletedAt) + ) + ) + .limit(1) + if (!existing) return null + const processingStatus = existing.processingStatus + if ( + processingStatus !== 'pending' && + processingStatus !== 'processing' && + processingStatus !== 'completed' && + processingStatus !== 'failed' + ) { + throw new Error(`Document ${existing.id} has invalid processing status`) + } + return { ...existing, processingStatus } +} + export async function bulkDocumentOperation( knowledgeBaseId: string, operation: 'enable' | 'disable' | 'delete', diff --git a/apps/sim/lib/knowledge/orchestration/documents.test.ts b/apps/sim/lib/knowledge/orchestration/documents.test.ts index bfbe1bdc403..42a8b250e05 100644 --- a/apps/sim/lib/knowledge/orchestration/documents.test.ts +++ b/apps/sim/lib/knowledge/orchestration/documents.test.ts @@ -8,6 +8,7 @@ const { mockCreateDocumentRecords, mockCreateSingleDocument, mockDeleteDocument, + mockGetDocumentByUploadId, mockMarkDocumentAsFailedTimeout, mockProcessDocumentAsync, mockProcessDocumentsWithQueue, @@ -19,6 +20,7 @@ const { mockCreateDocumentRecords: vi.fn(), mockCreateSingleDocument: vi.fn(), mockDeleteDocument: vi.fn(), + mockGetDocumentByUploadId: vi.fn(), mockMarkDocumentAsFailedTimeout: vi.fn(), mockProcessDocumentAsync: vi.fn(), mockProcessDocumentsWithQueue: vi.fn(), @@ -43,6 +45,7 @@ vi.mock('@/lib/knowledge/documents/service', () => ({ createDocumentRecords: mockCreateDocumentRecords, createSingleDocument: mockCreateSingleDocument, deleteDocument: mockDeleteDocument, + getDocumentByUploadId: mockGetDocumentByUploadId, markDocumentAsFailedTimeout: mockMarkDocumentAsFailedTimeout, processDocumentAsync: mockProcessDocumentAsync, processDocumentsWithQueue: mockProcessDocumentsWithQueue, @@ -74,6 +77,7 @@ describe('performUploadKnowledgeDocument', () => { beforeEach(() => { vi.clearAllMocks() mockCreateSingleDocument.mockResolvedValue({ id: 'doc-1', filename: 'report.pdf' }) + mockGetDocumentByUploadId.mockResolvedValue(null) mockProcessDocumentsWithQueue.mockResolvedValue(undefined) mockProcessDocumentAsync.mockResolvedValue(undefined) }) @@ -155,6 +159,71 @@ describe('performUploadKnowledgeDocument', () => { .errorCode ).toBe('forbidden') }) + + it('returns the document already bound to a stateless upload id without duplicating work', async () => { + const existing = { + id: 'upload-1', + knowledgeBaseId: 'kb-1', + filename: FILE.filename, + fileUrl: FILE.fileUrl, + fileSize: FILE.fileSize, + mimeType: FILE.mimeType, + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: true, + uploadedAt: new Date(), + } + mockGetDocumentByUploadId.mockResolvedValue(existing) + + const outcome = await performUploadKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: FILE, + documentId: 'upload-1', + startProcessing: 'queue', + }) + + expect(outcome).toMatchObject({ success: true, created: false, document: existing }) + expect(mockCreateSingleDocument).not.toHaveBeenCalled() + expect(mockProcessDocumentsWithQueue).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('converges on the existing document when concurrent completions race to insert', async () => { + const existing = { + id: 'upload-1', + knowledgeBaseId: 'kb-1', + ...FILE, + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: true, + uploadedAt: new Date(), + processingStatus: 'pending', + } + mockGetDocumentByUploadId.mockResolvedValueOnce(null).mockResolvedValueOnce(existing) + mockCreateSingleDocument.mockRejectedValue(new Error('duplicate key')) + + const outcome = await performUploadKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: FILE, + documentId: 'upload-1', + startProcessing: 'queue', + }) + + expect(outcome).toMatchObject({ success: true, created: false, document: existing }) + expect(mockCreateSingleDocument).toHaveBeenCalledWith( + FILE, + 'kb-1', + 'req-1', + 'user-1', + 'upload-1' + ) + expect(mockProcessDocumentsWithQueue).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + }) }) describe('performUploadKnowledgeDocuments', () => { diff --git a/apps/sim/lib/knowledge/orchestration/documents.ts b/apps/sim/lib/knowledge/orchestration/documents.ts index d0111d53289..99eb8226560 100644 --- a/apps/sim/lib/knowledge/orchestration/documents.ts +++ b/apps/sim/lib/knowledge/orchestration/documents.ts @@ -10,6 +10,7 @@ import { createSingleDocument, type DocumentData, deleteDocument, + getDocumentByUploadId, markDocumentAsFailedTimeout, type ProcessingOptions, processDocumentAsync, @@ -59,7 +60,10 @@ export interface KnowledgeDocumentInput { */ export type KnowledgeDocumentProcessing = 'queue' | 'async' -export type CreatedKnowledgeDocument = Awaited> +export type CreatedKnowledgeDocument = Awaited> & { + /** Present when an idempotent completion returns an already-processing document. */ + processingStatus?: 'pending' | 'processing' | 'completed' | 'failed' +} export interface PerformUploadKnowledgeDocumentParams extends KnowledgeOperationContext { knowledgeBase: KnowledgeBaseTarget @@ -69,12 +73,27 @@ export interface PerformUploadKnowledgeDocumentParams extends KnowledgeOperation billingAttribution?: BillingAttributionSnapshot /** Row owner recorded on the document; defaults to the acting user. */ uploadedBy?: string | null + /** Deterministic id carried by a stateless upload token for completion retries. */ + documentId?: string } export type PerformUploadKnowledgeDocumentResult = KnowledgeOrchestrationResult<{ document: CreatedKnowledgeDocument + created: boolean }> +function isSameKnowledgeDocumentUpload( + existing: CreatedKnowledgeDocument, + document: KnowledgeDocumentInput +): boolean { + return ( + existing.filename === document.filename && + existing.fileUrl === document.fileUrl && + existing.fileSize === document.fileSize && + existing.mimeType === document.mimeType + ) +} + function auditUpload( params: KnowledgeOperationContext & { knowledgeBase: KnowledgeBaseTarget }, entry: { resourceId: string; resourceName: string; description: string; metadata: object } @@ -137,14 +156,40 @@ export async function performUploadKnowledgeDocument( const requestId = params.requestId ?? generateRequestId() let created: CreatedKnowledgeDocument + if (params.documentId) { + const existing = await getDocumentByUploadId(params.documentId, knowledgeBase.id) + if (existing) { + if (!isSameKnowledgeDocumentUpload(existing, document)) { + return fail('Upload id is already bound to a different document', 'conflict') + } + return { success: true, document: existing, created: false } + } + } + try { - created = await createSingleDocument( - document, - knowledgeBase.id, - requestId, - params.uploadedBy ?? params.userId - ) + created = params.documentId + ? await createSingleDocument( + document, + knowledgeBase.id, + requestId, + params.uploadedBy ?? params.userId, + params.documentId + ) + : await createSingleDocument( + document, + knowledgeBase.id, + requestId, + params.uploadedBy ?? params.userId + ) } catch (error) { + if (params.documentId) { + const existing = await getDocumentByUploadId(params.documentId, knowledgeBase.id) + if (existing) { + return isSameKnowledgeDocumentUpload(existing, document) + ? { success: true, document: existing, created: false } + : fail('Upload id is already bound to a different document', 'conflict') + } + } return classifyKnowledgeFailure( error, requestId, @@ -205,7 +250,7 @@ export async function performUploadKnowledgeDocument( }, }) - return { success: true, document: created } + return { success: true, document: created, created: true } } export interface PerformUploadKnowledgeDocumentsParams extends KnowledgeOperationContext { diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts index 4e0902dc9db..699ac1ead3f 100644 --- a/apps/sim/lib/table/orchestration/import-resource.ts +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -134,6 +134,7 @@ export function getOwnedTableImportUpload(params: { uploadId: params.importId, workspaceId: params.workspaceId, userId: params.userId, + purpose: 'table_import', uploadToken: params.uploadToken, }) tableImportBodyFromUpload(upload) diff --git a/apps/sim/lib/uploads/client/session-upload.ts b/apps/sim/lib/uploads/client/session-upload.ts index eecc4237419..b6845c51062 100644 --- a/apps/sim/lib/uploads/client/session-upload.ts +++ b/apps/sim/lib/uploads/client/session-upload.ts @@ -5,6 +5,13 @@ import { createWorkspaceFileUploadContract, createWorkspaceFileUploadPartUrlsContract, } from '@/lib/api/contracts/upload-sessions' +import { + type V2KnowledgeDocumentSummary, + v2AbortKnowledgeDocumentUploadContract, + v2CompleteKnowledgeDocumentUploadContract, + v2CreateKnowledgeDocumentUploadContract, + v2CreateKnowledgeDocumentUploadPartUrlsContract, +} from '@/lib/api/contracts/v2/knowledge' import type { UploadProgressEvent } from '@/lib/uploads/client/direct-upload' import { uploadMultipartSession } from '@/lib/uploads/client/multipart-session' import { getFileContentType } from '@/lib/uploads/utils/file-utils' @@ -17,6 +24,14 @@ interface UploadWorkspaceFileSessionParams { onProgress?: (event: UploadProgressEvent) => void } +interface UploadKnowledgeDocumentSessionParams { + workspaceId: string + knowledgeBaseId: string + file: File + signal?: AbortSignal + onProgress?: (event: UploadProgressEvent) => void +} + export async function uploadWorkspaceFileSession(params: UploadWorkspaceFileSessionParams) { const { workspaceId, folderId, file, signal, onProgress } = params const created = await requestJson(createWorkspaceFileUploadContract, { @@ -66,3 +81,57 @@ export async function uploadWorkspaceFileSession(params: UploadWorkspaceFileSess }, }) } + +export async function uploadKnowledgeDocumentSession( + params: UploadKnowledgeDocumentSessionParams +): Promise { + const { workspaceId, knowledgeBaseId, file, signal, onProgress } = params + const created = await requestJson(v2CreateKnowledgeDocumentUploadContract, { + params: { id: knowledgeBaseId }, + body: { + workspaceId, + name: file.name, + contentType: getFileContentType(file), + size: file.size, + }, + signal, + }) + const upload = created.data + return uploadMultipartSession({ + file, + partSize: upload.partSize, + partCount: upload.partCount, + signal, + onProgress, + getPartUrls: async (partNumbers) => { + const batch = await requestJson(v2CreateKnowledgeDocumentUploadPartUrlsContract, { + params: { id: knowledgeBaseId, uploadId: upload.id }, + query: { workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + body: { partNumbers }, + signal, + }) + return batch.data.parts + }, + complete: async (parts) => { + const completed = await requestJson(v2CompleteKnowledgeDocumentUploadContract, { + params: { id: knowledgeBaseId, uploadId: upload.id }, + query: { workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + body: { parts }, + signal, + }) + if (!completed.data.document) { + throw new Error('Completed upload returned no knowledge document') + } + return completed.data.document + }, + abort: async () => { + await requestJson(v2AbortKnowledgeDocumentUploadContract, { + params: { id: knowledgeBaseId, uploadId: upload.id }, + query: { workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + }) + }, + }) +} diff --git a/apps/sim/lib/uploads/core/upload-token.ts b/apps/sim/lib/uploads/core/upload-token.ts index d030655b58b..da058708536 100644 --- a/apps/sim/lib/uploads/core/upload-token.ts +++ b/apps/sim/lib/uploads/core/upload-token.ts @@ -9,6 +9,8 @@ export interface UploadTokenPayload { userId: string workspaceId: string context: StorageContext + /** Knowledge base bound to a knowledge-document multipart session. */ + knowledgeBaseId?: string /** Original file name, carried so the completion handler can record ownership metadata. */ fileName?: string /** File MIME type, carried for ownership metadata at completion. */ @@ -16,7 +18,7 @@ export interface UploadTokenPayload { /** File size in bytes, carried for ownership metadata at completion. */ fileSize?: number /** Multipart-session purpose. Omitted by the legacy multipart endpoint. */ - purpose?: 'workspace_file' | 'table_import' + purpose?: 'workspace_file' | 'table_import' | 'knowledge_document' /** Storage provider that owns the multipart upload state. */ provider?: 's3' | 'blob' | 'gcs' | 'local' /** Provider-issued multipart upload id. Local and block-blob uploads do not need one. */ @@ -44,8 +46,11 @@ const fromBase64Url = (input: string): string => Buffer.from(input, 'base64url') const sign = (payload: string): string => hmacSha256Base64(payload, env.INTERNAL_API_SECRET) /** - * Sign an upload session token binding (uploadId, key, userId, workspaceId, context). - * Used to prevent IDOR on multipart upload follow-up calls (get-part-urls, complete, abort). + * Sign an upload session token binding every supplied field to its signature. + * Multipart sessions include the caller, workspace, storage context and key, + * purpose, provider state, file metadata, part geometry, and—for knowledge + * documents—the target knowledge base. Follow-up calls reconstruct their + * complete trusted session exclusively from this signed state. */ export function signUploadToken(payload: UploadTokenPayload, expiresInSeconds = 60 * 60): string { const signed: SignedPayload = { @@ -103,10 +108,15 @@ export function verifyUploadToken(token: string): UploadTokenVerification { userId: parsed.userId, workspaceId: parsed.workspaceId, context: parsed.context as StorageContext, + ...(typeof parsed.knowledgeBaseId === 'string' + ? { knowledgeBaseId: parsed.knowledgeBaseId } + : {}), ...(typeof parsed.fileName === 'string' ? { fileName: parsed.fileName } : {}), ...(typeof parsed.contentType === 'string' ? { contentType: parsed.contentType } : {}), ...(typeof parsed.fileSize === 'number' ? { fileSize: parsed.fileSize } : {}), - ...(parsed.purpose === 'workspace_file' || parsed.purpose === 'table_import' + ...(parsed.purpose === 'workspace_file' || + parsed.purpose === 'table_import' || + parsed.purpose === 'knowledge_document' ? { purpose: parsed.purpose } : {}), ...(parsed.provider === 's3' || diff --git a/apps/sim/lib/uploads/multipart-session/service.test.ts b/apps/sim/lib/uploads/multipart-session/service.test.ts new file mode 100644 index 00000000000..3c030a9028b --- /dev/null +++ b/apps/sim/lib/uploads/multipart-session/service.test.ts @@ -0,0 +1,129 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckStorageQuotaForBillingContext, + mockInitiateMultipartProviderUpload, + mockResolveStorageBillingContext, +} = vi.hoisted(() => ({ + mockCheckStorageQuotaForBillingContext: vi.fn(), + mockInitiateMultipartProviderUpload: vi.fn(), + mockResolveStorageBillingContext: vi.fn(), +})) + +vi.mock('@/lib/billing/storage', () => ({ + checkStorageQuotaForBillingContext: mockCheckStorageQuotaForBillingContext, + resolveStorageBillingContext: mockResolveStorageBillingContext, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ headObject: vi.fn() })) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + generateWorkspaceFileKey: vi.fn( + (workspaceId: string, fileName: string) => `workspace/${workspaceId}/${fileName}` + ), +})) + +vi.mock('@/lib/uploads/multipart-session/provider', () => ({ + abortMultipartProviderUpload: vi.fn(), + completeMultipartProviderUpload: vi.fn(), + getMultipartProviderPartUrls: vi.fn(), + initiateMultipartProviderUpload: mockInitiateMultipartProviderUpload, +})) + +import { + createUploadSession, + getOwnedUploadSession, + verifyUploadSessionToken, +} from '@/lib/uploads/multipart-session/service' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' + +describe('knowledge-document multipart sessions', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveStorageBillingContext.mockResolvedValue({ workspaceId: WORKSPACE_ID }) + mockCheckStorageQuotaForBillingContext.mockResolvedValue({ allowed: true }) + mockInitiateMultipartProviderUpload.mockResolvedValue({ + provider: 's3', + providerUploadId: 'provider-upload-1', + }) + }) + + it('binds knowledge ownership and all storage state into the signed token', async () => { + const created = await createUploadSession({ + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + purpose: 'knowledge_document', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + }) + + const verified = verifyUploadSessionToken(created.uploadToken) + expect(verified).toMatchObject({ + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + purpose: 'knowledge_document', + storageContext: 'knowledge-base', + storageProvider: 's3', + providerUploadId: 'provider-upload-1', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + }) + expect(verified.storageKey).toMatch(/^kb\/.*-guide\.pdf$/) + }) + + it.each([ + { userId: 'other-user', knowledgeBaseId: 'kb-1', purpose: 'knowledge_document' as const }, + { userId: 'user-1', knowledgeBaseId: 'kb-2', purpose: 'knowledge_document' as const }, + { userId: 'user-1', knowledgeBaseId: 'kb-1', purpose: 'workspace_file' as const }, + ])('rejects a session whose signed scope does not match $purpose', async (scope) => { + const created = await createUploadSession({ + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + purpose: 'knowledge_document', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + }) + + expect(() => + getOwnedUploadSession({ + uploadId: 'upload-1', + workspaceId: WORKSPACE_ID, + uploadToken: created.uploadToken, + ...scope, + }) + ).toThrow('Upload session not found') + }) + + it('runs the storage quota gate before creating provider state', async () => { + mockCheckStorageQuotaForBillingContext.mockResolvedValue({ + allowed: false, + error: 'Storage limit exceeded', + }) + + await expect( + createUploadSession({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + purpose: 'knowledge_document', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + }) + ).rejects.toMatchObject({ code: 'payload_too_large' }) + expect(mockInitiateMultipartProviderUpload).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/uploads/multipart-session/service.ts b/apps/sim/lib/uploads/multipart-session/service.ts index fba4eb353ef..2157091cb73 100644 --- a/apps/sim/lib/uploads/multipart-session/service.ts +++ b/apps/sim/lib/uploads/multipart-session/service.ts @@ -4,6 +4,7 @@ import { resolveStorageBillingContext, } from '@/lib/billing/storage' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace' import { headObject } from '@/lib/uploads/core/storage-service' import { signUploadToken, verifyUploadToken } from '@/lib/uploads/core/upload-token' @@ -16,20 +17,25 @@ import { type MultipartPartUrl, type MultipartStorageProvider, } from '@/lib/uploads/multipart-session/provider' -import { MAX_WORKSPACE_FILE_SIZE, type StorageContext } from '@/lib/uploads/shared/types' +import { + MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE, + MAX_WORKSPACE_FILE_SIZE, + type StorageContext, +} from '@/lib/uploads/shared/types' import { sanitizeFileName } from '@/executor/constants' export const MULTIPART_SESSION_PART_SIZE = 8 * 1024 * 1024 export const MULTIPART_SESSION_MAX_PART_URLS = 100 export const MULTIPART_SESSION_TTL_MS = 24 * 60 * 60 * 1000 -export type UploadSessionPurpose = 'workspace_file' | 'table_import' +export type UploadSessionPurpose = 'workspace_file' | 'table_import' | 'knowledge_document' export type UploadSessionStatus = 'uploading' | 'completed' | 'aborted' export interface UploadSessionRecord { id: string workspaceId: string userId: string + knowledgeBaseId: string | null purpose: UploadSessionPurpose storageContext: StorageContext storageKey: string @@ -61,31 +67,31 @@ export class UploadSessionError extends OrchestrationError { } } -interface CreateUploadSessionParams { +interface CreateUploadSessionBaseParams { id?: string workspaceId: string userId: string - purpose: UploadSessionPurpose fileName: string contentType: string fileSize: number metadata?: Record } +type CreateUploadSessionParams = CreateUploadSessionBaseParams & + ( + | { purpose: 'workspace_file' | 'table_import'; knowledgeBaseId?: never } + | { purpose: 'knowledge_document'; knowledgeBaseId: string } + ) + export async function createUploadSession( params: CreateUploadSessionParams ): Promise { - validateFileSize(params.fileSize) + validateFile(params) const id = params.id ?? generateId() - const storageContext: StorageContext = - params.purpose === 'workspace_file' ? 'workspace' : 'table-import' - const storageKey = - params.purpose === 'workspace_file' - ? generateWorkspaceFileKey(params.workspaceId, params.fileName) - : `table-import/${params.workspaceId}/${id}/${sanitizeFileName(params.fileName)}` + const { storageContext, storageKey } = resolveUploadStorage(params, id) const partCount = Math.ceil(params.fileSize / MULTIPART_SESSION_PART_SIZE) - if (params.purpose === 'workspace_file') { + if (params.purpose === 'workspace_file' || params.purpose === 'knowledge_document') { const billingContext = await resolveStorageBillingContext(params.workspaceId) const quota = await checkStorageQuotaForBillingContext(billingContext, params.fileSize) if (!quota.allowed) { @@ -111,6 +117,9 @@ export async function createUploadSession( userId: params.userId, workspaceId: params.workspaceId, context: storageContext, + ...(params.purpose === 'knowledge_document' + ? { knowledgeBaseId: params.knowledgeBaseId } + : {}), fileName: params.fileName, contentType: params.contentType, fileSize: params.fileSize, @@ -130,6 +139,7 @@ export async function createUploadSession( id, workspaceId: params.workspaceId, userId: params.userId, + knowledgeBaseId: params.purpose === 'knowledge_document' ? params.knowledgeBaseId : null, purpose: params.purpose, storageContext, storageKey, @@ -156,6 +166,8 @@ export function getOwnedUploadSession(params: { uploadId: string workspaceId: string userId?: string + purpose: UploadSessionPurpose + knowledgeBaseId?: string uploadToken: string }): UploadSessionRecord { const session = verifyUploadSessionToken(params.uploadToken) @@ -165,6 +177,12 @@ export function getOwnedUploadSession(params: { if (params.userId && session.userId !== params.userId) { throw new UploadSessionError('not_found', 'Upload session not found') } + if (session.purpose !== params.purpose) { + throw new UploadSessionError('not_found', 'Upload session not found') + } + if (params.knowledgeBaseId !== undefined && session.knowledgeBaseId !== params.knowledgeBaseId) { + throw new UploadSessionError('not_found', 'Upload session not found') + } return session } @@ -188,9 +206,25 @@ export function verifyUploadSessionToken(uploadToken: string): UploadSessionReco ) { throw new UploadSessionError('forbidden', 'Upload token is not a multipart session token') } - if (payload.context !== 'workspace' && payload.context !== 'table-import') { + if ( + payload.context !== 'workspace' && + payload.context !== 'table-import' && + payload.context !== 'knowledge-base' + ) { throw new UploadSessionError('forbidden', 'Upload token has an invalid storage context') } + const knowledgeBaseId = payload.knowledgeBaseId?.trim() || null + if ( + (payload.purpose === 'workspace_file' && payload.context !== 'workspace') || + (payload.purpose === 'table_import' && payload.context !== 'table-import') || + (payload.purpose === 'knowledge_document' && + (payload.context !== 'knowledge-base' || !knowledgeBaseId || !payload.key.startsWith('kb/'))) + ) { + throw new UploadSessionError('forbidden', 'Upload token purpose does not match its storage') + } + if (payload.purpose !== 'knowledge_document' && knowledgeBaseId) { + throw new UploadSessionError('forbidden', 'Upload token has unexpected knowledge-base state') + } const createdAt = new Date(payload.createdAt) const expiresAt = new Date(payload.expiresAt) if (!Number.isFinite(createdAt.getTime()) || !Number.isFinite(expiresAt.getTime())) { @@ -201,6 +235,7 @@ export function verifyUploadSessionToken(uploadToken: string): UploadSessionReco id: payload.uploadId, workspaceId: payload.workspaceId, userId: payload.userId, + knowledgeBaseId, purpose: payload.purpose, storageContext: payload.context, storageKey: payload.key, @@ -373,14 +408,47 @@ function validateCompletedParts(session: UploadSessionRecord, parts: CompletedUp } } -function validateFileSize(fileSize: number): void { - if (!Number.isSafeInteger(fileSize) || fileSize < 1) { +function validateFile(params: CreateUploadSessionParams): void { + if (!params.fileName.trim()) { + throw new UploadSessionError('validation', 'fileName must not be empty') + } + if (!params.contentType.trim()) { + throw new UploadSessionError('validation', 'contentType must not be empty') + } + if (!Number.isSafeInteger(params.fileSize) || params.fileSize < 1) { throw new UploadSessionError('validation', 'fileSize must be a positive integer') } - if (fileSize > MAX_WORKSPACE_FILE_SIZE) { - throw new UploadSessionError( - 'validation', - `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` - ) + const maximum = + params.purpose === 'knowledge_document' + ? MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE + : MAX_WORKSPACE_FILE_SIZE + if (params.fileSize > maximum) { + throw new UploadSessionError('validation', `File size exceeds maximum of ${maximum} bytes`) + } + if (params.purpose === 'knowledge_document' && !params.knowledgeBaseId.trim()) { + throw new UploadSessionError('validation', 'knowledgeBaseId must not be empty') + } +} + +function resolveUploadStorage( + params: CreateUploadSessionParams, + id: string +): { storageContext: StorageContext; storageKey: string } { + switch (params.purpose) { + case 'workspace_file': + return { + storageContext: 'workspace', + storageKey: generateWorkspaceFileKey(params.workspaceId, params.fileName), + } + case 'table_import': + return { + storageContext: 'table-import', + storageKey: `table-import/${params.workspaceId}/${id}/${sanitizeFileName(params.fileName)}`, + } + case 'knowledge_document': + return { + storageContext: 'knowledge-base', + storageKey: generateKnowledgeBaseFileKey(params.fileName), + } } } diff --git a/apps/sim/lib/uploads/shared/types.ts b/apps/sim/lib/uploads/shared/types.ts index 0c24f770444..d7de7961ee5 100644 --- a/apps/sim/lib/uploads/shared/types.ts +++ b/apps/sim/lib/uploads/shared/types.ts @@ -22,6 +22,9 @@ export function toLegacyWorkspaceFileSize(size: number): number { */ 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 + export type StorageContext = | 'knowledge-base' | 'chat' diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 0a8378abdae..00bdafaa2d5 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1083, - zodRoutes: 1083, + totalRoutes: 1087, + zodRoutes: 1087, nonZodRoutes: 0, } as const From 94eb1dc7b1e62f0ab21f2bda544a25e3c7b1f164 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 00:21:23 -0700 Subject: [PATCH 057/159] fix(api): keep usage admission at knowledge upload session creation --- .../uploads/[uploadId]/complete/route.test.ts | 22 ++++++++++++--- .../uploads/[uploadId]/complete/route.ts | 5 ++-- .../knowledge/[id]/documents/uploads/utils.ts | 27 ++++++++++++++----- 3 files changed, 40 insertions(+), 14 deletions(-) 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 ea59e90d66b..2a307133eca 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 @@ -12,7 +12,7 @@ const { mockPerformUploadKnowledgeDocument, mockRecordKnowledgeBaseFileOwnership, mockResolveKnowledgeDocumentUploadAccess, - mockResolveKnowledgeDocumentUploadBilling, + mockResolveKnowledgeDocumentUploadAttribution, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockCompleteUploadSession: vi.fn(), @@ -21,7 +21,7 @@ const { mockPerformUploadKnowledgeDocument: vi.fn(), mockRecordKnowledgeBaseFileOwnership: vi.fn(), mockResolveKnowledgeDocumentUploadAccess: vi.fn(), - mockResolveKnowledgeDocumentUploadBilling: vi.fn(), + mockResolveKnowledgeDocumentUploadAttribution: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit })) @@ -43,7 +43,7 @@ vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ getOwnedKnowledgeDocumentUpload: vi.fn(() => SESSION), knowledgeDocumentFileUrl: vi.fn(() => FILE_URL), resolveKnowledgeDocumentUploadAccess: mockResolveKnowledgeDocumentUploadAccess, - resolveKnowledgeDocumentUploadBilling: mockResolveKnowledgeDocumentUploadBilling, + resolveKnowledgeDocumentUploadAttribution: mockResolveKnowledgeDocumentUploadAttribution, toV2KnowledgeDocumentUpload: (session: Record, document: unknown) => ({ ...session, name: session.fileName, @@ -126,7 +126,7 @@ describe('POST knowledge-document multipart completion', () => { mockResolveKnowledgeDocumentUploadAccess.mockResolvedValue({ kb: { id: 'kb-1', name: 'Docs' }, }) - mockResolveKnowledgeDocumentUploadBilling.mockResolvedValue({ actorUserId: 'payer-1' }) + mockResolveKnowledgeDocumentUploadAttribution.mockResolvedValue({ actorUserId: 'payer-1' }) mockRecordKnowledgeBaseFileOwnership.mockResolvedValue(undefined) mockDeleteFile.mockResolvedValue(undefined) mockDeleteFileMetadata.mockResolvedValue(true) @@ -174,6 +174,20 @@ describe('POST knowledge-document multipart completion', () => { expect(mockDeleteFileMetadata).not.toHaveBeenCalled() }) + it('finalizes an already-admitted upload without re-running usage admission', async () => { + mockPerformUploadKnowledgeDocument.mockResolvedValue({ + success: true, + document: DOCUMENT, + created: false, + }) + + const response = await request() + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ data: { document: { id: 'upload-1' } } }) + expect(mockDeleteFile).not.toHaveBeenCalled() + }) + it('removes the committed object and ownership binding when document creation fails', async () => { mockPerformUploadKnowledgeDocument.mockResolvedValue({ success: false, 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 bb0f28461d0..73cd59e4070 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 @@ -19,7 +19,7 @@ import { getOwnedKnowledgeDocumentUpload, knowledgeDocumentFileUrl, resolveKnowledgeDocumentUploadAccess, - resolveKnowledgeDocumentUploadBilling, + resolveKnowledgeDocumentUploadAttribution, toV2KnowledgeDocumentUpload, } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -71,12 +71,11 @@ export const POST = withRouteHandler( }) if (access instanceof NextResponse) return access - const billingAttribution = await resolveKnowledgeDocumentUploadBilling({ + const billingAttribution = await resolveKnowledgeDocumentUploadAttribution({ workspaceId, userId, rateLimit, }) - if (billingAttribution instanceof NextResponse) return billingAttribution const session = getOwnedKnowledgeDocumentUpload({ knowledgeBaseId, 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 4b1e61a4e74..fedc8cf4983 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 @@ -37,18 +37,31 @@ export async function resolveKnowledgeDocumentUploadAccess(params: { return v2Error('FORBIDDEN', 'Access denied') } +/** + * Resolves the payer for an upload without enforcing usage limits. Completion uses this + * because its bytes were already admitted when the session was created; re-running + * admission there would strand uploaded parts and fail idempotent completion retries. + */ +export async function resolveKnowledgeDocumentUploadAttribution(params: { + workspaceId: string + userId: string + rateLimit: RateLimitResult +}): Promise { + return params.rateLimit.keyType === 'workspace' + ? resolveSystemBillingAttribution(params.workspaceId) + : resolveBillingAttribution({ + actorUserId: params.userId, + workspaceId: params.workspaceId, + }) +} + +/** Admission check for a new upload session. Enforced only at session creation. */ export async function resolveKnowledgeDocumentUploadBilling(params: { workspaceId: string userId: string rateLimit: RateLimitResult }): Promise { - const attribution = - params.rateLimit.keyType === 'workspace' - ? await resolveSystemBillingAttribution(params.workspaceId) - : await resolveBillingAttribution({ - actorUserId: params.userId, - workspaceId: params.workspaceId, - }) + const attribution = await resolveKnowledgeDocumentUploadAttribution(params) const usage = await checkAttributedUsageLimits(attribution) if (usage.isExceeded) { return v2Error( From 05f298a9d1a4831499aae51bba0d680ad9a8bd6e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 00:44:35 -0700 Subject: [PATCH 058/159] feat(knowledge): wire knowledge base uploads to multipart sessions --- apps/docs/openapi-v2-knowledge.json | 16 + .../uploads/[uploadId]/complete/route.ts | 74 ++++ .../uploads/[uploadId]/parts/route.ts | 55 +++ .../documents/uploads/[uploadId]/route.ts | 50 +++ .../[id]/documents/uploads/route.test.ts | 121 +++++++ .../knowledge/[id]/documents/uploads/route.ts | 60 ++++ .../knowledge/[id]/documents/uploads/utils.ts | 73 ++++ .../uploads/[uploadId]/complete/route.test.ts | 102 ++---- .../uploads/[uploadId]/complete/route.ts | 78 +---- .../[id]/documents/uploads/route.test.ts | 6 + .../knowledge/[id]/documents/uploads/route.ts | 3 +- .../[id]/documents/uploads/utils.test.ts | 162 +++++++++ .../knowledge/[id]/documents/uploads/utils.ts | 97 ++++++ .../knowledge/hooks/use-knowledge-upload.ts | 316 +++--------------- .../contracts/knowledge/upload-sessions.ts | 51 +++ apps/sim/lib/api/contracts/v2/knowledge.ts | 28 ++ .../lib/knowledge/orchestration/documents.ts | 53 ++- .../lib/uploads/client/session-upload.test.ts | 94 ++++++ apps/sim/lib/uploads/client/session-upload.ts | 28 +- scripts/check-api-validation-contracts.ts | 4 +- 20 files changed, 1043 insertions(+), 428 deletions(-) create mode 100644 apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts create mode 100644 apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts create mode 100644 apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts create mode 100644 apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts create mode 100644 apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts create mode 100644 apps/sim/app/api/knowledge/[id]/documents/uploads/utils.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts create mode 100644 apps/sim/lib/api/contracts/knowledge/upload-sessions.ts create mode 100644 apps/sim/lib/uploads/client/session-upload.test.ts diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 6d057459490..f9c45723d60 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -1452,6 +1452,22 @@ "minimum": 1, "maximum": 104857600, "description": "Exact file size in bytes." + }, + "tag1": { "type": "string", "maxLength": 1000 }, + "tag2": { "type": "string", "maxLength": 1000 }, + "tag3": { "type": "string", "maxLength": 1000 }, + "tag4": { "type": "string", "maxLength": 1000 }, + "tag5": { "type": "string", "maxLength": 1000 }, + "tag6": { "type": "string", "maxLength": 1000 }, + "tag7": { "type": "string", "maxLength": 1000 }, + "processingOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "recipe": { "type": "string", "maxLength": 255 }, + "lang": { "type": "string", "maxLength": 35 } + }, + "description": "Optional processing recipe and language, bound into the signed upload state." } } }, diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts new file mode 100644 index 00000000000..8d0cf3e8bf6 --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts @@ -0,0 +1,74 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { completeKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { completeUploadSession } from '@/lib/uploads/multipart-session/service' +import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' +import { + requireKnowledgeDocumentUploadAccess, + requireKnowledgeDocumentUploadActor, + resolveKnowledgeDocumentUploadAttribution, +} from '@/app/api/knowledge/[id]/documents/uploads/utils' +import { + finalizeKnowledgeDocumentUpload, + getOwnedKnowledgeDocumentUpload, + toV2KnowledgeDocumentUpload, +} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' + +interface KnowledgeDocumentUploadRouteParams { + params: Promise<{ id: string; uploadId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { + const actor = await requireKnowledgeDocumentUploadActor() + if (actor instanceof NextResponse) return actor + const parsed = await parseRequest(completeKnowledgeDocumentUploadContract, request, context) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId, uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + const access = await requireKnowledgeDocumentUploadAccess({ + knowledgeBaseId, + workspaceId, + userId: actor.id, + }) + if (access instanceof NextResponse) return access + const requestId = generateRequestId() + try { + const upload = getOwnedKnowledgeDocumentUpload({ + knowledgeBaseId, + uploadId, + workspaceId, + userId: actor.id, + uploadToken: parsed.data.headers['upload-token'], + }) + const completed = await completeUploadSession({ + session: upload, + parts: parsed.data.body.parts, + finalize: (claimed) => + finalizeKnowledgeDocumentUpload({ + claimed, + knowledgeBaseId, + knowledgeBaseName: access.knowledgeBase.name, + workspaceId, + userId: actor.id, + resolveAttribution: () => + resolveKnowledgeDocumentUploadAttribution({ workspaceId, userId: actor.id }), + source: 'ui', + requestId, + request, + actorName: actor.name, + actorEmail: actor.email, + }), + }) + return NextResponse.json({ + data: toV2KnowledgeDocumentUpload(completed.session, completed.value), + }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } + } +) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts new file mode 100644 index 00000000000..482705e5b62 --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts @@ -0,0 +1,55 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createKnowledgeDocumentUploadPartUrlsContract } from '@/lib/api/contracts/knowledge/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createUploadPartUrls } from '@/lib/uploads/multipart-session/service' +import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' +import { + requireKnowledgeDocumentUploadAccess, + requireKnowledgeDocumentUploadActor, +} from '@/app/api/knowledge/[id]/documents/uploads/utils' +import { getOwnedKnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' + +interface KnowledgeDocumentUploadRouteParams { + params: Promise<{ id: string; uploadId: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { + const actor = await requireKnowledgeDocumentUploadActor() + if (actor instanceof NextResponse) return actor + const parsed = await parseRequest( + createKnowledgeDocumentUploadPartUrlsContract, + request, + context + ) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId, uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + const access = await requireKnowledgeDocumentUploadAccess({ + knowledgeBaseId, + workspaceId, + userId: actor.id, + }) + if (access instanceof NextResponse) return access + try { + const upload = getOwnedKnowledgeDocumentUpload({ + knowledgeBaseId, + uploadId, + workspaceId, + userId: actor.id, + uploadToken: parsed.data.headers['upload-token'], + }) + const parts = await createUploadPartUrls({ + session: upload, + partNumbers: parsed.data.body.partNumbers, + localOrigin: request.nextUrl.origin, + }) + return NextResponse.json({ data: { parts } }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } + } +) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts new file mode 100644 index 00000000000..83ce4e9e858 --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts @@ -0,0 +1,50 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { abortKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { abortUploadSession } from '@/lib/uploads/multipart-session/service' +import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' +import { + requireKnowledgeDocumentUploadAccess, + requireKnowledgeDocumentUploadActor, +} from '@/app/api/knowledge/[id]/documents/uploads/utils' +import { + getOwnedKnowledgeDocumentUpload, + toV2KnowledgeDocumentUpload, +} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' + +interface KnowledgeDocumentUploadRouteParams { + params: Promise<{ id: string; uploadId: string }> +} + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { + const actor = await requireKnowledgeDocumentUploadActor() + if (actor instanceof NextResponse) return actor + const parsed = await parseRequest(abortKnowledgeDocumentUploadContract, request, context) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId, uploadId } = parsed.data.params + const { workspaceId } = parsed.data.query + const access = await requireKnowledgeDocumentUploadAccess({ + knowledgeBaseId, + workspaceId, + userId: actor.id, + }) + if (access instanceof NextResponse) return access + try { + const upload = getOwnedKnowledgeDocumentUpload({ + knowledgeBaseId, + uploadId, + workspaceId, + userId: actor.id, + uploadToken: parsed.data.headers['upload-token'], + }) + const aborted = await abortUploadSession(upload) + return NextResponse.json({ data: toV2KnowledgeDocumentUpload(aborted, null) }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } + } +) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts new file mode 100644 index 00000000000..7148f14379e --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts @@ -0,0 +1,121 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCreateUploadSession, + mockRequireKnowledgeDocumentUploadAccess, + mockRequireKnowledgeDocumentUploadActor, + mockRequireKnowledgeDocumentUploadBilling, +} = vi.hoisted(() => ({ + mockCreateUploadSession: vi.fn(), + mockRequireKnowledgeDocumentUploadAccess: vi.fn(), + mockRequireKnowledgeDocumentUploadActor: vi.fn(), + mockRequireKnowledgeDocumentUploadBilling: vi.fn(), +})) + +vi.mock('@/lib/uploads/multipart-session/service', () => ({ + createUploadSession: mockCreateUploadSession, +})) +vi.mock('@/app/api/knowledge/[id]/documents/uploads/utils', () => ({ + requireKnowledgeDocumentUploadAccess: mockRequireKnowledgeDocumentUploadAccess, + requireKnowledgeDocumentUploadActor: mockRequireKnowledgeDocumentUploadActor, + requireKnowledgeDocumentUploadBilling: mockRequireKnowledgeDocumentUploadBilling, +})) +vi.mock('@/app/api/files/uploads/utils', () => ({ uploadSessionErrorResponse: vi.fn() })) +vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ + toV2KnowledgeDocumentUpload: (session: Record) => ({ + ...session, + name: session.fileName, + contentType: session.contentType, + size: session.fileSize, + expiresAt: '2026-08-05T00:00:00.000Z', + document: null, + }), +})) + +import { POST } from '@/app/api/knowledge/[id]/documents/uploads/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' + +function request() { + return POST( + new NextRequest('http://localhost:3000/api/knowledge/kb-1/documents/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + name: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + tag1: 'product', + processingOptions: { recipe: 'default', lang: 'en' }, + }), + }), + { params: Promise.resolve({ id: 'kb-1' }) } + ) +} + +describe('POST /api/knowledge/[id]/documents/uploads', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRequireKnowledgeDocumentUploadActor.mockResolvedValue({ id: 'user-1' }) + mockRequireKnowledgeDocumentUploadAccess.mockResolvedValue({ + knowledgeBase: { id: 'kb-1', name: 'Docs', workspaceId: WORKSPACE_ID }, + }) + mockRequireKnowledgeDocumentUploadBilling.mockResolvedValue({ actorUserId: 'user-1' }) + mockCreateUploadSession.mockResolvedValue({ + id: 'upload-1', + knowledgeBaseId: 'kb-1', + status: 'uploading', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + partSize: 8 * 1024 * 1024, + partCount: 1, + uploadToken: 'token', + error: null, + }) + }) + + it('authorizes and bills before allocating a first-party upload session', async () => { + const response = await request() + + expect(response.status).toBe(201) + expect(mockRequireKnowledgeDocumentUploadAccess).toHaveBeenCalledWith({ + knowledgeBaseId: 'kb-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + }) + expect(mockCreateUploadSession).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + purpose: 'knowledge_document', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + metadata: { + tag1: 'product', + processingOptions: { recipe: 'default', lang: 'en' }, + }, + }) + expect(mockRequireKnowledgeDocumentUploadBilling.mock.invocationCallOrder[0]).toBeLessThan( + mockCreateUploadSession.mock.invocationCallOrder[0] + ) + }) + + it('does not bill or allocate storage when write access is denied', async () => { + mockRequireKnowledgeDocumentUploadAccess.mockResolvedValue( + NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + ) + + const response = await request() + + expect(response.status).toBe(403) + expect(mockRequireKnowledgeDocumentUploadBilling).not.toHaveBeenCalled() + expect(mockCreateUploadSession).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts new file mode 100644 index 00000000000..fd0e11620f4 --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts @@ -0,0 +1,60 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createUploadSession } from '@/lib/uploads/multipart-session/service' +import { validateFileType } from '@/lib/uploads/utils/validation' +import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' +import { + requireKnowledgeDocumentUploadAccess, + requireKnowledgeDocumentUploadActor, + requireKnowledgeDocumentUploadBilling, +} from '@/app/api/knowledge/[id]/documents/uploads/utils' +import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' + +interface KnowledgeDocumentUploadsRouteParams { + params: Promise<{ id: string }> +} + +export const POST = withRouteHandler( + async (request: NextRequest, context: KnowledgeDocumentUploadsRouteParams) => { + const actor = await requireKnowledgeDocumentUploadActor() + if (actor instanceof NextResponse) return actor + const parsed = await parseRequest(createKnowledgeDocumentUploadContract, request, context) + if (!parsed.success) return parsed.response + const { id: knowledgeBaseId } = parsed.data.params + const { workspaceId, name, contentType, size, ...metadata } = parsed.data.body + const access = await requireKnowledgeDocumentUploadAccess({ + knowledgeBaseId, + workspaceId, + userId: actor.id, + }) + if (access instanceof NextResponse) return access + const billing = await requireKnowledgeDocumentUploadBilling({ + workspaceId, + userId: actor.id, + }) + if (billing instanceof NextResponse) return billing + const fileTypeError = validateFileType(name, contentType) + if (fileTypeError) { + return NextResponse.json({ error: fileTypeError.message }, { status: 415 }) + } + try { + const upload = await createUploadSession({ + workspaceId, + userId: actor.id, + knowledgeBaseId, + purpose: 'knowledge_document', + fileName: name, + contentType, + fileSize: size, + metadata, + }) + return NextResponse.json({ data: toV2KnowledgeDocumentUpload(upload, null) }, { status: 201 }) + } catch (error) { + const classified = uploadSessionErrorResponse(error) + if (classified) return classified + throw error + } + } +) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.ts new file mode 100644 index 00000000000..450b17ecd0b --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.ts @@ -0,0 +1,73 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { + checkAttributedUsageLimits, + resolveBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import type { KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' +import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' + +export interface KnowledgeDocumentUploadActor { + id: string + name?: string | null + email?: string | null +} + +export async function requireKnowledgeDocumentUploadActor(): Promise< + KnowledgeDocumentUploadActor | NextResponse +> { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + return { + id: session.user.id, + name: session.user.name, + email: session.user.email, + } +} + +export async function requireKnowledgeDocumentUploadAccess(params: { + knowledgeBaseId: string + workspaceId: string + userId: string +}): Promise<{ knowledgeBase: KnowledgeBaseAccessResult['knowledgeBase'] } | NextResponse> { + const access = await checkKnowledgeBaseWriteAccess(params.knowledgeBaseId, params.userId) + if (!access.hasAccess) { + return 'notFound' in access && access.notFound + ? NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + : NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + if (access.knowledgeBase.workspaceId !== params.workspaceId) { + return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + } + return { knowledgeBase: access.knowledgeBase } +} + +export async function requireKnowledgeDocumentUploadBilling(params: { + workspaceId: string + userId: string +}): Promise { + const attribution = await resolveKnowledgeDocumentUploadAttribution(params) + const usage = await checkAttributedUsageLimits(attribution) + if (usage.isExceeded) { + return NextResponse.json( + { + error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.', + }, + { status: 402 } + ) + } + return attribution +} + +export function resolveKnowledgeDocumentUploadAttribution(params: { + workspaceId: string + userId: string +}): Promise { + return resolveBillingAttribution({ + actorUserId: params.userId, + workspaceId: params.workspaceId, + }) +} 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 2a307133eca..b923f82a39c 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 @@ -7,19 +7,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckRateLimit, mockCompleteUploadSession, - mockDeleteFile, - mockDeleteFileMetadata, - mockPerformUploadKnowledgeDocument, - mockRecordKnowledgeBaseFileOwnership, + mockFinalizeKnowledgeDocumentUpload, mockResolveKnowledgeDocumentUploadAccess, mockResolveKnowledgeDocumentUploadAttribution, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockCompleteUploadSession: vi.fn(), - mockDeleteFile: vi.fn(), - mockDeleteFileMetadata: vi.fn(), - mockPerformUploadKnowledgeDocument: vi.fn(), - mockRecordKnowledgeBaseFileOwnership: vi.fn(), + mockFinalizeKnowledgeDocumentUpload: vi.fn(), mockResolveKnowledgeDocumentUploadAccess: vi.fn(), mockResolveKnowledgeDocumentUploadAttribution: vi.fn(), })) @@ -28,20 +22,12 @@ vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit } vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null), })) -vi.mock('@/lib/knowledge/orchestration', () => ({ - performUploadKnowledgeDocument: mockPerformUploadKnowledgeDocument, -})) -vi.mock('@/lib/uploads/core/storage-service', () => ({ deleteFile: mockDeleteFile })) -vi.mock('@/lib/uploads/server/metadata', () => ({ - deleteFileMetadata: mockDeleteFileMetadata, - recordKnowledgeBaseFileOwnership: mockRecordKnowledgeBaseFileOwnership, -})) vi.mock('@/lib/uploads/multipart-session/service', () => ({ completeUploadSession: mockCompleteUploadSession, })) vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ + finalizeKnowledgeDocumentUpload: mockFinalizeKnowledgeDocumentUpload, getOwnedKnowledgeDocumentUpload: vi.fn(() => SESSION), - knowledgeDocumentFileUrl: vi.fn(() => FILE_URL), resolveKnowledgeDocumentUploadAccess: mockResolveKnowledgeDocumentUploadAccess, resolveKnowledgeDocumentUploadAttribution: mockResolveKnowledgeDocumentUploadAttribution, toV2KnowledgeDocumentUpload: (session: Record, document: unknown) => ({ @@ -54,6 +40,7 @@ vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ }), })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { POST } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' @@ -74,7 +61,10 @@ const SESSION = { partSize: 8 * 1024 * 1024, partCount: 1, status: 'uploading', - metadata: {}, + metadata: { + tag1: 'product', + processingOptions: { recipe: 'default', lang: 'en' }, + }, uploadToken: 'token', createdAt: new Date('2026-08-03T21:00:00.000Z'), expiresAt: new Date('2026-08-04T21:00:00.000Z'), @@ -127,13 +117,9 @@ describe('POST knowledge-document multipart completion', () => { kb: { id: 'kb-1', name: 'Docs' }, }) mockResolveKnowledgeDocumentUploadAttribution.mockResolvedValue({ actorUserId: 'payer-1' }) - mockRecordKnowledgeBaseFileOwnership.mockResolvedValue(undefined) - mockDeleteFile.mockResolvedValue(undefined) - mockDeleteFileMetadata.mockResolvedValue(true) - mockPerformUploadKnowledgeDocument.mockResolvedValue({ - success: true, - document: DOCUMENT, - created: true, + mockFinalizeKnowledgeDocumentUpload.mockResolvedValue({ + value: DOCUMENT, + completedFileId: DOCUMENT.id, }) mockCompleteUploadSession.mockImplementation(async ({ session, finalize }) => { const finalized = await finalize(session) @@ -145,63 +131,45 @@ describe('POST knowledge-document multipart completion', () => { }) }) - it('records knowledge ownership and invokes the shared document orchestration', async () => { + it('delegates completion to the shared finalizer and returns the bound document', async () => { const response = await request() expect(response.status).toBe(200) - expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenCalledWith({ - key: 'kb/guide.pdf', - userId: 'user-1', - workspaceId: WORKSPACE_ID, - originalName: 'guide.pdf', - contentType: 'application/pdf', - size: 1024, - }) - expect(mockPerformUploadKnowledgeDocument).toHaveBeenCalledWith( + expect(await response.json()).toMatchObject({ data: { document: { id: 'upload-1' } } }) + expect(mockFinalizeKnowledgeDocumentUpload).toHaveBeenCalledWith( expect.objectContaining({ - documentId: 'upload-1', - startProcessing: 'queue', - uploadedBy: 'payer-1', - document: { - filename: 'guide.pdf', - fileUrl: FILE_URL, - fileSize: 1024, - mimeType: 'application/pdf', - }, + claimed: SESSION, + knowledgeBaseId: 'kb-1', + knowledgeBaseName: 'Docs', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + source: 'api', }) ) - expect(mockDeleteFile).not.toHaveBeenCalled() - expect(mockDeleteFileMetadata).not.toHaveBeenCalled() }) - it('finalizes an already-admitted upload without re-running usage admission', async () => { - mockPerformUploadKnowledgeDocument.mockResolvedValue({ - success: true, - document: DOCUMENT, - created: false, - }) + it('resolves the payer lazily, only when the finalizer asks for one', async () => { + await request() - const response = await request() + expect(mockResolveKnowledgeDocumentUploadAttribution).not.toHaveBeenCalled() - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ data: { document: { id: 'upload-1' } } }) - expect(mockDeleteFile).not.toHaveBeenCalled() - }) + const { resolveAttribution } = mockFinalizeKnowledgeDocumentUpload.mock.calls[0][0] + await resolveAttribution() - it('removes the committed object and ownership binding when document creation fails', async () => { - mockPerformUploadKnowledgeDocument.mockResolvedValue({ - success: false, - errorCode: 'payload_too_large', - error: 'Storage limit exceeded', + expect(mockResolveKnowledgeDocumentUploadAttribution).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + rateLimit: RATE_LIMIT, }) + }) + + it('maps an orchestration failure from the finalizer onto its v2 status', async () => { + mockFinalizeKnowledgeDocumentUpload.mockRejectedValue( + new OrchestrationError('payload_too_large', 'Storage limit exceeded') + ) const response = await request() expect(response.status).toBe(413) - expect(mockDeleteFile).toHaveBeenCalledWith({ - key: 'kb/guide.pdf', - context: 'knowledge-base', - }) - expect(mockDeleteFileMetadata).toHaveBeenCalledWith('kb/guide.pdf') }) }) 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 73cd59e4070..b4835d232ea 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 @@ -4,20 +4,13 @@ import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { v2CompleteKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' import { parseRequest } from '@/lib/api/server' -import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration' -import { deleteFile } from '@/lib/uploads/core/storage-service' -import { - completeUploadSession, - type UploadSessionRecord, -} from '@/lib/uploads/multipart-session/service' -import { deleteFileMetadata, recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' +import { completeUploadSession } from '@/lib/uploads/multipart-session/service' import { checkRateLimit } from '@/app/api/v1/middleware' import { + finalizeKnowledgeDocumentUpload, getOwnedKnowledgeDocumentUpload, - knowledgeDocumentFileUrl, resolveKnowledgeDocumentUploadAccess, resolveKnowledgeDocumentUploadAttribution, toV2KnowledgeDocumentUpload, @@ -37,11 +30,6 @@ interface KnowledgeDocumentUploadRouteParams { params: Promise<{ id: string; uploadId: string }> } -async function cleanupFailedKnowledgeDocumentUpload(session: UploadSessionRecord): Promise { - await deleteFile({ key: session.storageKey, context: 'knowledge-base' }) - await deleteFileMetadata(session.storageKey) -} - export const POST = withRouteHandler( async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { const requestId = generateRequestId() @@ -71,12 +59,6 @@ export const POST = withRouteHandler( }) if (access instanceof NextResponse) return access - const billingAttribution = await resolveKnowledgeDocumentUploadAttribution({ - workspaceId, - userId, - rateLimit, - }) - const session = getOwnedKnowledgeDocumentUpload({ knowledgeBaseId, uploadId, @@ -87,49 +69,19 @@ export const POST = withRouteHandler( const result = await completeUploadSession({ session, parts: parsed.data.body.parts, - finalize: async (claimed) => { - try { - await recordKnowledgeBaseFileOwnership({ - key: claimed.storageKey, - userId, - workspaceId, - originalName: claimed.fileName, - contentType: claimed.contentType, - size: claimed.fileSize, - }) - const outcome = await performUploadKnowledgeDocument({ - knowledgeBase: { - id: knowledgeBaseId, - name: access.kb.name, - workspaceId, - }, - document: { - filename: claimed.fileName, - fileUrl: knowledgeDocumentFileUrl(claimed), - fileSize: claimed.fileSize, - mimeType: claimed.contentType, - }, - documentId: claimed.id, - startProcessing: 'queue', - billingAttribution, - uploadedBy: billingAttribution.actorUserId, - userId, - source: 'api', - requestId, - request, - }) - if (!outcome.success) { - throw new OrchestrationError(outcome.errorCode, outcome.error) - } - return { - value: outcome.document, - completedFileId: outcome.document.id, - } - } catch (error) { - await cleanupFailedKnowledgeDocumentUpload(claimed) - throw error - } - }, + finalize: (claimed) => + finalizeKnowledgeDocumentUpload({ + claimed, + knowledgeBaseId, + knowledgeBaseName: access.kb.name, + workspaceId, + userId, + resolveAttribution: () => + resolveKnowledgeDocumentUploadAttribution({ workspaceId, userId, rateLimit }), + source: 'api', + requestId, + request, + }), }) return v2Data(toV2KnowledgeDocumentUpload(result.session, result.value), { rateLimit }) 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 06eb0ca5d16..0f07d093530 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 @@ -58,6 +58,8 @@ function request() { name: 'guide.pdf', contentType: 'application/pdf', size: 1024, + tag1: 'product', + processingOptions: { recipe: 'default', lang: 'en' }, }), }), { params: Promise.resolve({ id: 'kb-1' }) } @@ -106,6 +108,10 @@ describe('POST /api/v2/knowledge/[id]/documents/uploads', () => { fileName: 'guide.pdf', contentType: 'application/pdf', fileSize: 1024, + metadata: { + tag1: 'product', + processingOptions: { recipe: 'default', lang: 'en' }, + }, }) expect(mockResolveKnowledgeDocumentUploadBilling.mock.invocationCallOrder[0]).toBeLessThan( mockCreateUploadSession.mock.invocationCallOrder[0] 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 c13649a8fb3..444867c1f14 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 @@ -42,7 +42,7 @@ export const POST = withRouteHandler( }) if (!parsed.success) return parsed.response const { id: knowledgeBaseId } = parsed.data.params - const { workspaceId, name, contentType, size } = parsed.data.body + const { workspaceId, name, contentType, size, ...metadata } = parsed.data.body const access = await resolveKnowledgeDocumentUploadAccess({ knowledgeBaseId, @@ -72,6 +72,7 @@ export const POST = withRouteHandler( fileName: name, contentType, fileSize: size, + metadata, }) return v2Data(toV2KnowledgeDocumentUpload(session, null), { rateLimit, status: 201 }) } catch (error) { diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts new file mode 100644 index 00000000000..2c4af9fdb0a --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts @@ -0,0 +1,162 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockDeleteFile, + mockDeleteFileMetadata, + mockFindBoundKnowledgeDocument, + mockPerformUploadKnowledgeDocument, + mockRecordKnowledgeBaseFileOwnership, +} = vi.hoisted(() => ({ + mockDeleteFile: vi.fn(), + mockDeleteFileMetadata: vi.fn(), + mockFindBoundKnowledgeDocument: vi.fn(), + mockPerformUploadKnowledgeDocument: vi.fn(), + mockRecordKnowledgeBaseFileOwnership: vi.fn(), +})) + +vi.mock('@/lib/knowledge/orchestration', () => ({ + performUploadKnowledgeDocument: mockPerformUploadKnowledgeDocument, +})) +vi.mock('@/lib/knowledge/orchestration/documents', () => ({ + findBoundKnowledgeDocument: mockFindBoundKnowledgeDocument, +})) +vi.mock('@/lib/uploads/core/storage-service', () => ({ deleteFile: mockDeleteFile })) +vi.mock('@/lib/uploads/server/metadata', () => ({ + deleteFileMetadata: mockDeleteFileMetadata, + recordKnowledgeBaseFileOwnership: mockRecordKnowledgeBaseFileOwnership, +})) + +import { finalizeKnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const CLAIMED = { + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + purpose: 'knowledge_document', + storageContext: 'knowledge-base', + storageKey: 'kb/guide.pdf', + storageProvider: 's3', + providerUploadId: 'provider-1', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + partSize: 8 * 1024 * 1024, + partCount: 1, + status: 'uploading', + metadata: { tag1: 'product', processingOptions: { recipe: 'default', lang: 'en' } }, + uploadToken: 'token', + createdAt: new Date('2026-08-03T21:00:00.000Z'), + expiresAt: new Date('2026-08-04T21:00:00.000Z'), + completedFileId: null, + error: null, + completedAt: null, + updatedAt: new Date('2026-08-03T21:00:00.000Z'), + // biome-ignore lint/suspicious/noExplicitAny: partial session shape for the test +} as any +const DOCUMENT = { id: 'upload-1', knowledgeBaseId: 'kb-1', filename: 'guide.pdf' } + +function finalize(resolveAttribution = vi.fn().mockResolvedValue({ actorUserId: 'payer-1' })) { + return finalizeKnowledgeDocumentUpload({ + claimed: CLAIMED, + knowledgeBaseId: 'kb-1', + knowledgeBaseName: 'Docs', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + resolveAttribution, + source: 'api', + requestId: 'req-1', + request: new NextRequest('http://localhost:3000/api/v2/knowledge/kb-1'), + }) +} + +describe('finalizeKnowledgeDocumentUpload', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'absent' }) + mockRecordKnowledgeBaseFileOwnership.mockResolvedValue(undefined) + mockDeleteFile.mockResolvedValue(undefined) + mockDeleteFileMetadata.mockResolvedValue(true) + mockPerformUploadKnowledgeDocument.mockResolvedValue({ + success: true, + document: DOCUMENT, + created: true, + }) + }) + + it('creates the document, carrying session tags and processing options through', async () => { + const result = await finalize() + + expect(result).toEqual({ value: DOCUMENT, completedFileId: 'upload-1' }) + expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenCalledWith({ + key: 'kb/guide.pdf', + userId: 'user-1', + workspaceId: WORKSPACE_ID, + originalName: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + }) + expect(mockPerformUploadKnowledgeDocument).toHaveBeenCalledWith( + expect.objectContaining({ + documentId: 'upload-1', + startProcessing: 'queue', + uploadedBy: 'payer-1', + processingOptions: { recipe: 'default', lang: 'en' }, + document: expect.objectContaining({ filename: 'guide.pdf', tag1: 'product' }), + }) + ) + expect(mockDeleteFile).not.toHaveBeenCalled() + }) + + it('answers a retry from the bound document without resolving a payer', async () => { + mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'bound', document: DOCUMENT }) + const resolveAttribution = vi.fn() + + const result = await finalize(resolveAttribution) + + expect(result).toEqual({ value: DOCUMENT, completedFileId: 'upload-1' }) + expect(resolveAttribution).not.toHaveBeenCalled() + expect(mockRecordKnowledgeBaseFileOwnership).not.toHaveBeenCalled() + expect(mockPerformUploadKnowledgeDocument).not.toHaveBeenCalled() + expect(mockDeleteFile).not.toHaveBeenCalled() + }) + + it('deletes the uploaded object when creation fails and nothing is bound', async () => { + mockPerformUploadKnowledgeDocument.mockResolvedValue({ + success: false, + errorCode: 'payload_too_large', + error: 'Storage limit exceeded', + }) + + await expect(finalize()).rejects.toThrow('Storage limit exceeded') + expect(mockDeleteFile).toHaveBeenCalledWith({ key: 'kb/guide.pdf', context: 'knowledge-base' }) + expect(mockDeleteFileMetadata).toHaveBeenCalledWith('kb/guide.pdf') + }) + + it('keeps the uploaded object when a document is bound despite the failure', async () => { + mockFindBoundKnowledgeDocument + .mockResolvedValueOnce({ status: 'absent' }) + .mockResolvedValueOnce({ status: 'bound', document: DOCUMENT }) + mockPerformUploadKnowledgeDocument.mockRejectedValue(new Error('audit sink exploded')) + + await expect(finalize()).rejects.toThrow('audit sink exploded') + expect(mockDeleteFile).not.toHaveBeenCalled() + expect(mockDeleteFileMetadata).not.toHaveBeenCalled() + }) + + it('rejects an upload id already bound to a different document without deleting anything', async () => { + mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'conflict' }) + const resolveAttribution = vi.fn() + + await expect(finalize(resolveAttribution)).rejects.toThrow( + 'Upload id is already bound to a different document' + ) + expect(resolveAttribution).not.toHaveBeenCalled() + expect(mockDeleteFile).not.toHaveBeenCalled() + }) +}) 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 fedc8cf4983..bbe7dcfeab0 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,20 +1,27 @@ +import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import type { V2KnowledgeDocumentSummary, V2KnowledgeDocumentUpload, } from '@/lib/api/contracts/v2/knowledge' +import { v2KnowledgeDocumentUploadMetadataSchema } from '@/lib/api/contracts/v2/knowledge' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { checkAttributedUsageLimits, resolveBillingAttribution, resolveSystemBillingAttribution, } from '@/lib/billing/core/billing-attribution' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration' import type { CreatedKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' +import { findBoundKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { deleteFile } from '@/lib/uploads/core/storage-service' import { getOwnedUploadSession, type UploadSessionRecord, } from '@/lib/uploads/multipart-session/service' +import { deleteFileMetadata, recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' import type { RateLimitResult } from '@/app/api/v1/middleware' import { v2Error } from '@/app/api/v2/lib/response' @@ -137,3 +144,93 @@ export function knowledgeDocumentFileUrl(session: UploadSessionRecord): string { const providerPrefix = session.storageProvider === 'local' ? '' : `${session.storageProvider}/` return `/api/files/serve/${providerPrefix}${encodeURIComponent(session.storageKey)}?context=knowledge-base` } + +/** + * Binds a completed multipart session to its knowledge document. Shared by the public v2 + * and session-authenticated routes so both get identical completion semantics. + * + * Ordering is load-bearing. A retry is answered from the already-bound document before any + * work that can fail independently of the upload runs, so a payer that became unresolvable + * after the session was created cannot turn a valid retry into an error. Cleanup is likewise + * gated on the upload still being unbound — uploaded bytes are never deleted out from under + * a live document row. + */ +export async function finalizeKnowledgeDocumentUpload(params: { + claimed: UploadSessionRecord + knowledgeBaseId: string + knowledgeBaseName: string | null + workspaceId: string + userId: string + resolveAttribution: () => Promise + source: 'api' | 'ui' + requestId: string + request: NextRequest + actorName?: string | null + actorEmail?: string | null +}): Promise<{ value: CreatedKnowledgeDocument; completedFileId: string }> { + const { claimed, knowledgeBaseId, workspaceId, requestId } = params + const { processingOptions, ...documentTags } = v2KnowledgeDocumentUploadMetadataSchema.parse( + claimed.metadata + ) + const document = { + filename: claimed.fileName, + fileUrl: knowledgeDocumentFileUrl(claimed), + fileSize: claimed.fileSize, + mimeType: claimed.contentType, + ...documentTags, + } + + const bound = await findBoundKnowledgeDocument({ + documentId: claimed.id, + knowledgeBaseId, + document, + }) + if (bound.status === 'bound') { + return { value: bound.document, completedFileId: bound.document.id } + } + if (bound.status === 'conflict') { + throw new OrchestrationError('conflict', 'Upload id is already bound to a different document') + } + + const billingAttribution = await params.resolveAttribution() + try { + await recordKnowledgeBaseFileOwnership({ + key: claimed.storageKey, + userId: params.userId, + workspaceId, + originalName: claimed.fileName, + contentType: claimed.contentType, + size: claimed.fileSize, + }) + const outcome = await performUploadKnowledgeDocument({ + knowledgeBase: { id: knowledgeBaseId, name: params.knowledgeBaseName, workspaceId }, + document, + documentId: claimed.id, + startProcessing: 'queue', + processingOptions, + billingAttribution, + uploadedBy: billingAttribution.actorUserId, + userId: params.userId, + ...(params.actorName ? { actorName: params.actorName } : {}), + ...(params.actorEmail ? { actorEmail: params.actorEmail } : {}), + source: params.source, + requestId, + request: params.request, + }) + if (!outcome.success) { + throw new OrchestrationError(outcome.errorCode, outcome.error) + } + return { value: outcome.document, completedFileId: outcome.document.id } + } catch (error) { + const rebound = await findBoundKnowledgeDocument({ + documentId: claimed.id, + knowledgeBaseId, + document, + }) + if (rebound.status === 'absent') { + await deleteFile({ key: claimed.storageKey, context: 'knowledge-base' }) + await deleteFileMetadata(claimed.storageKey) + } + throw error + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts index 3d096f53535..d4270b5ae34 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts @@ -1,38 +1,19 @@ import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { sleep } from '@sim/utils/helpers' import { useQueryClient } from '@tanstack/react-query' +import type { V2KnowledgeDocumentSummary } from '@/lib/api/contracts/v2/knowledge' import { - calculateUploadTimeoutMs, - DirectUploadError, - isTransientUploadError, - LARGE_FILE_THRESHOLD, - MULTIPART_MAX_RETRIES, - MULTIPART_RETRY_BACKOFF, - MULTIPART_RETRY_DELAY_MS, - normalizePresignedData, - type PresignedUploadInfo, - runUploadStrategy, runWithConcurrency, type UploadProgressEvent, WHOLE_FILE_PARALLEL_UPLOADS, } from '@/lib/uploads/client/direct-upload' -import { getFileContentType, isAbortError, isNetworkError } from '@/lib/uploads/utils/file-utils' +import { uploadKnowledgeDocumentSession } from '@/lib/uploads/client/session-upload' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' const logger = createLogger('KnowledgeUpload') -const KB_BATCH_PRESIGNED_ENDPOINT = '/api/files/presigned/batch?type=knowledge-base' -const KB_API_UPLOAD_ENDPOINT = '/api/files/upload' - -const BATCH_REQUEST_SIZE = 50 - -export interface UploadedFile { - filename: string - fileUrl: string - fileSize: number - mimeType: string +interface KnowledgeDocumentUploadFile extends File { tag1?: string tag2?: string tag3?: string @@ -86,153 +67,6 @@ class KnowledgeUploadError extends Error { } } -class ProcessingError extends KnowledgeUploadError { - constructor(message: string, details?: unknown) { - super(message, 'PROCESSING_ERROR', details) - } -} - -interface BatchPresignedFile { - fileName: string - contentType: string - fileSize: number -} - -/** - * Fetch presigned upload data for the small files in `files`. Returns a sparse - * array aligned with the input: entries for files >= LARGE_FILE_THRESHOLD are - * `undefined` because those uploads use multipart and never consume a presigned - * single-PUT URL. - */ -const fetchBatchPresignedData = async ( - files: File[], - workspaceId: string -): Promise<(PresignedUploadInfo | undefined)[]> => { - const result: (PresignedUploadInfo | undefined)[] = new Array(files.length).fill(undefined) - const smallFileIndices: number[] = [] - for (let i = 0; i < files.length; i++) { - if (files[i].size <= LARGE_FILE_THRESHOLD) smallFileIndices.push(i) - } - if (smallFileIndices.length === 0) return result - - const batchEndpoint = `${KB_BATCH_PRESIGNED_ENDPOINT}&workspaceId=${encodeURIComponent(workspaceId)}` - - for (let start = 0; start < smallFileIndices.length; start += BATCH_REQUEST_SIZE) { - const batchIndices = smallFileIndices.slice(start, start + BATCH_REQUEST_SIZE) - const batchFiles = batchIndices.map((i) => files[i]) - const body: { files: BatchPresignedFile[] } = { - files: batchFiles.map((file) => ({ - fileName: file.name, - contentType: getFileContentType(file), - fileSize: file.size, - })), - } - - const response = await fetch(batchEndpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - - if (!response.ok) { - throw new Error(`Batch presigned URL generation failed: ${response.statusText}`) - } - - const { files: presignedItems } = (await response.json()) as { files: unknown[] } - batchIndices.forEach((fileIdx, batchPos) => { - result[fileIdx] = normalizePresignedData(presignedItems[batchPos], batchFiles[batchPos].name) - }) - } - - return result -} - -/** - * Server-proxied fallback used when cloud storage isn't configured. - */ -const uploadFileThroughAPI = async ( - file: File, - workspaceId: string | undefined -): Promise<{ filePath: string }> => { - const formData = new FormData() - formData.append('file', file) - formData.append('context', 'knowledge-base') - if (workspaceId) formData.append('workspaceId', workspaceId) - - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), calculateUploadTimeoutMs(file.size)) - - try { - const response = await fetch(KB_API_UPLOAD_ENDPOINT, { - method: 'POST', - body: formData, - signal: controller.signal, - }) - - if (!response.ok) { - let errorData: { message?: string; error?: string } | null = null - try { - errorData = (await response.json()) as { message?: string; error?: string } - } catch {} - throw new KnowledgeUploadError( - `Failed to upload ${file.name}: ${errorData?.message || errorData?.error || response.statusText}`, - 'API_UPLOAD_ERROR', - errorData - ) - } - - const result = (await response.json()) as { - fileInfo?: { path?: string } - path?: string - } - const filePath = result.fileInfo?.path ?? result.path - if (!filePath) { - throw new KnowledgeUploadError( - `Invalid upload response for ${file.name}: missing file path`, - 'API_UPLOAD_ERROR', - result - ) - } - - return { filePath } - } finally { - clearTimeout(timeoutId) - } -} - -const toAbsoluteUrl = (path: string): string => - path.startsWith('http') ? path : `${window.location.origin}${path}` - -/** - * Build the {@link UploadedFile} payload from a `File`, carrying through any - * `tagN` fields the caller attached to it. Pure — kept at module scope so it - * isn't rebuilt on every render of the hook. - */ -const buildUploadedFile = (file: File, fileUrl: string): UploadedFile => { - const f = file as File & { - tag1?: string - tag2?: string - tag3?: string - tag4?: string - tag5?: string - tag6?: string - tag7?: string - } - return { - filename: file.name, - fileUrl, - fileSize: file.size, - mimeType: getFileContentType(file), - tag1: f.tag1, - tag2: f.tag2, - tag3: f.tag3, - tag4: f.tag4, - tag5: f.tag5, - tag6: f.tag6, - tag7: f.tag7, - } -} - export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { const queryClient = useQueryClient() const [isUploading, setIsUploading] = useState(false) @@ -255,51 +89,40 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { const uploadOneFile = async ( file: File, fileIndex: number, - presigned: PresignedUploadInfo | undefined - ): Promise => { + knowledgeBaseId: string, + processingOptions: ProcessingOptions + ): Promise => { if (!options.workspaceId) { throw new KnowledgeUploadError('workspaceId is required for upload', 'MISSING_WORKSPACE_ID') } - const onProgress = (event: UploadProgressEvent) => { updateFileStatus(fileIndex, { progress: event.percent, status: 'uploading' }) } - - let attempt = 0 - while (true) { - try { - const result = await runUploadStrategy({ - file, - workspaceId: options.workspaceId, - context: 'knowledge-base', - presignedEndpoint: `/api/files/presigned?type=knowledge-base&workspaceId=${encodeURIComponent(options.workspaceId)}`, - presignedOverride: presigned, - onProgress, - }) - return buildUploadedFile(file, toAbsoluteUrl(result.path)) - } catch (error) { - if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') { - const { filePath } = await uploadFileThroughAPI(file, options.workspaceId) - return buildUploadedFile(file, toAbsoluteUrl(filePath)) - } - - const retryable = isNetworkError(error) || isTransientUploadError(error) - if (isAbortError(error) || !retryable || attempt >= MULTIPART_MAX_RETRIES) { - throw error - } - - const delay = MULTIPART_RETRY_DELAY_MS * MULTIPART_RETRY_BACKOFF ** attempt - attempt++ - logger.warn( - `Upload retry ${attempt}/${MULTIPART_MAX_RETRIES} for ${file.name} in ${Math.round(delay / 1000)}s` - ) - updateFileStatus(fileIndex, { progress: 0, status: 'uploading' }) - await sleep(delay) - } - } + const taggedFile = file as KnowledgeDocumentUploadFile + return uploadKnowledgeDocumentSession({ + workspaceId: options.workspaceId, + knowledgeBaseId, + file, + onProgress, + tag1: taggedFile.tag1, + tag2: taggedFile.tag2, + tag3: taggedFile.tag3, + tag4: taggedFile.tag4, + tag5: taggedFile.tag5, + tag6: taggedFile.tag6, + tag7: taggedFile.tag7, + processingOptions: { + recipe: processingOptions.recipe ?? 'default', + lang: 'en', + }, + }) } - const uploadFilesInBatches = async (files: File[]): Promise => { + const uploadFilesInBatches = async ( + files: File[], + knowledgeBaseId: string, + processingOptions: ProcessingOptions + ): Promise => { if (!options.workspaceId) { throw new KnowledgeUploadError('workspaceId is required for upload', 'MISSING_WORKSPACE_ID') } @@ -313,9 +136,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { setUploadProgress((prev) => ({ ...prev, fileStatuses })) - logger.info(`Starting batch upload of ${files.length} files`) - - const presignedData = await fetchBatchPresignedData(files, options.workspaceId) + logger.info(`Starting signed session upload of ${files.length} files`) const settled = await runWithConcurrency( files, @@ -323,7 +144,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { async (file, index) => { updateFileStatus(index, { status: 'uploading' }) try { - const uploaded = await uploadOneFile(file, index, presignedData[index]) + const uploaded = await uploadOneFile(file, index, knowledgeBaseId, processingOptions) setUploadProgress((prev) => ({ ...prev, filesCompleted: prev.filesCompleted + 1, @@ -337,7 +158,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { } ) - const succeeded: UploadedFile[] = [] + const succeeded: V2KnowledgeDocumentSummary[] = [] const failed: Array<{ file: File; error: Error }> = [] settled.forEach((result, idx) => { if (result?.status === 'fulfilled') { @@ -354,7 +175,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { throw new KnowledgeUploadError( `Failed to upload ${failed.length} file(s)`, 'PARTIAL_UPLOAD_FAILURE', - { failedFiles: failed, uploadedFiles: succeeded } + { failedFiles: failed, uploadedDocuments: succeeded } ) } @@ -365,7 +186,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { files: File[], knowledgeBaseId: string, processingOptions: ProcessingOptions = {} - ): Promise => { + ): Promise => { if (files.length === 0) { throw new KnowledgeUploadError('No files provided for upload', 'NO_FILES') } @@ -378,76 +199,27 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { setUploadError(null) setUploadProgress({ stage: 'uploading', filesCompleted: 0, totalFiles: files.length }) - const uploadedFiles = await uploadFilesInBatches(files) + const uploadedDocuments = await uploadFilesInBatches( + files, + knowledgeBaseId, + processingOptions + ) setUploadProgress((prev) => ({ ...prev, stage: 'processing' })) - - // boundary-raw-fetch: bulk document-processing kickoff with dynamic recipe payload; response is consumed alongside the upload progress lifecycle and not modeled by a single contract - const processResponse = await fetch(`/api/knowledge/${knowledgeBaseId}/documents`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - documents: uploadedFiles.map((f) => ({ ...f })), - processingOptions: { - recipe: processingOptions.recipe ?? 'default', - lang: 'en', - }, - bulk: true, - }), - }) - - if (!processResponse.ok) { - let errorData: { error?: string; message?: string } | null = null - try { - errorData = (await processResponse.json()) as { error?: string; message?: string } - } catch {} - logger.error('Document processing failed:', { - status: processResponse.status, - error: errorData, - }) - throw new ProcessingError( - `Failed to start document processing: ${errorData?.error || errorData?.message || 'Unknown error'}`, - errorData - ) - } - - const processResult = (await processResponse.json()) as { - success?: boolean - error?: string - data?: { documentsCreated?: unknown } - } - - if (!processResult.success) { - throw new ProcessingError( - `Document processing failed: ${processResult.error || 'Unknown error'}`, - processResult - ) - } - - if (!processResult.data?.documentsCreated) { - throw new ProcessingError( - 'Invalid processing response: missing document data', - processResult - ) - } - - setUploadProgress((prev) => ({ ...prev, stage: 'completing' })) - logger.info(`Successfully started processing ${uploadedFiles.length} documents`) + logger.info(`Successfully started processing ${uploadedDocuments.length} documents`) await queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId) }) - return uploadedFiles + return uploadedDocuments } catch (err) { logger.error('Error uploading documents:', err) const error: UploadError = err instanceof KnowledgeUploadError ? { message: err.message, code: err.code, details: err.details, timestamp: Date.now() } - : err instanceof DirectUploadError - ? { message: err.message, code: err.code, details: err.details, timestamp: Date.now() } - : err instanceof Error - ? { message: err.message, timestamp: Date.now() } - : { message: 'Unknown error occurred during upload', timestamp: Date.now() } + : err instanceof Error + ? { message: err.message, timestamp: Date.now() } + : { message: 'Unknown error occurred during upload', timestamp: Date.now() } setUploadError(error) options.onError?.(error) diff --git a/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts b/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts new file mode 100644 index 00000000000..7a15defe3fd --- /dev/null +++ b/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts @@ -0,0 +1,51 @@ +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CreateKnowledgeDocumentUploadBodySchema, + v2KnowledgeDocumentUploadParamsSchema, + v2KnowledgeDocumentUploadSchema, + v2UploadKnowledgeDocumentQuerySchema, +} from '@/lib/api/contracts/v2/knowledge' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CompleteUploadBodySchema, + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadTokenHeadersSchema, +} from '@/lib/api/contracts/v2/uploads' + +export const createKnowledgeDocumentUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/knowledge/[id]/documents/uploads', + params: v2KnowledgeDocumentUploadParamsSchema.omit({ uploadId: true }), + body: v2CreateKnowledgeDocumentUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentUploadSchema) }, +}) + +export const abortKnowledgeDocumentUploadContract = defineRouteContract({ + method: 'DELETE', + path: '/api/knowledge/[id]/documents/uploads/[uploadId]', + params: v2KnowledgeDocumentUploadParamsSchema, + query: v2UploadKnowledgeDocumentQuerySchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentUploadSchema) }, +}) + +export const createKnowledgeDocumentUploadPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/knowledge/[id]/documents/uploads/[uploadId]/parts', + params: v2KnowledgeDocumentUploadParamsSchema, + query: v2UploadKnowledgeDocumentQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) + +export const completeKnowledgeDocumentUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/knowledge/[id]/documents/uploads/[uploadId]/complete', + params: v2KnowledgeDocumentUploadParamsSchema, + query: v2UploadKnowledgeDocumentQuerySchema, + headers: v2UploadTokenHeadersSchema, + body: v2CompleteUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentUploadSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 40b16177d1d..bdca6fe4f01 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -164,12 +164,40 @@ export const v2KnowledgeDocumentUploadParamsSchema = knowledgeBaseParamsSchema.e }) export type V2KnowledgeDocumentUploadParams = z.output +const knowledgeDocumentUploadTagSchema = z + .string() + .max(1000, 'Knowledge document tag values cannot exceed 1000 characters') + .optional() + +export const v2KnowledgeDocumentUploadMetadataSchema = z + .object({ + tag1: knowledgeDocumentUploadTagSchema, + tag2: knowledgeDocumentUploadTagSchema, + tag3: knowledgeDocumentUploadTagSchema, + tag4: knowledgeDocumentUploadTagSchema, + tag5: knowledgeDocumentUploadTagSchema, + tag6: knowledgeDocumentUploadTagSchema, + tag7: knowledgeDocumentUploadTagSchema, + processingOptions: z + .object({ + recipe: z.string().max(255, 'recipe cannot exceed 255 characters').optional(), + lang: z.string().max(35, 'lang cannot exceed 35 characters').optional(), + }) + .strict() + .optional(), + }) + .strict() +export type V2KnowledgeDocumentUploadMetadata = z.output< + typeof v2KnowledgeDocumentUploadMetadataSchema +> + export const v2CreateKnowledgeDocumentUploadBodySchema = z .object({ workspaceId: workspaceIdSchema, name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), contentType: z.string().trim().min(1, 'contentType is required').max(255), size: z.number().int().min(1).max(MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE), + ...v2KnowledgeDocumentUploadMetadataSchema.shape, }) .strict() export type V2CreateKnowledgeDocumentUploadBody = z.input< diff --git a/apps/sim/lib/knowledge/orchestration/documents.ts b/apps/sim/lib/knowledge/orchestration/documents.ts index 99eb8226560..de91b1a6ee0 100644 --- a/apps/sim/lib/knowledge/orchestration/documents.ts +++ b/apps/sim/lib/knowledge/orchestration/documents.ts @@ -94,6 +94,28 @@ function isSameKnowledgeDocumentUpload( ) } +export type BoundKnowledgeDocument = + | { status: 'absent' } + | { status: 'bound'; document: CreatedKnowledgeDocument } + | { status: 'conflict' } + +/** + * Resolves what a stateless upload id is already bound to. Callers use this to answer a + * completion retry from existing state before doing any work that can fail independently + * of the upload — resolving a billing payer, or deleting the uploaded object. + */ +export async function findBoundKnowledgeDocument(params: { + documentId: string + knowledgeBaseId: string + document: KnowledgeDocumentInput +}): Promise { + const existing = await getDocumentByUploadId(params.documentId, params.knowledgeBaseId) + if (!existing) return { status: 'absent' } + return isSameKnowledgeDocumentUpload(existing, params.document) + ? { status: 'bound', document: existing } + : { status: 'conflict' } +} + function auditUpload( params: KnowledgeOperationContext & { knowledgeBase: KnowledgeBaseTarget }, entry: { resourceId: string; resourceName: string; description: string; metadata: object } @@ -157,12 +179,16 @@ export async function performUploadKnowledgeDocument( let created: CreatedKnowledgeDocument if (params.documentId) { - const existing = await getDocumentByUploadId(params.documentId, knowledgeBase.id) - if (existing) { - if (!isSameKnowledgeDocumentUpload(existing, document)) { - return fail('Upload id is already bound to a different document', 'conflict') - } - return { success: true, document: existing, created: false } + const bound = await findBoundKnowledgeDocument({ + documentId: params.documentId, + knowledgeBaseId: knowledgeBase.id, + document, + }) + if (bound.status === 'conflict') { + return fail('Upload id is already bound to a different document', 'conflict') + } + if (bound.status === 'bound') { + return { success: true, document: bound.document, created: false } } } @@ -183,11 +209,16 @@ export async function performUploadKnowledgeDocument( ) } catch (error) { if (params.documentId) { - const existing = await getDocumentByUploadId(params.documentId, knowledgeBase.id) - if (existing) { - return isSameKnowledgeDocumentUpload(existing, document) - ? { success: true, document: existing, created: false } - : fail('Upload id is already bound to a different document', 'conflict') + const bound = await findBoundKnowledgeDocument({ + documentId: params.documentId, + knowledgeBaseId: knowledgeBase.id, + document, + }) + if (bound.status === 'conflict') { + return fail('Upload id is already bound to a different document', 'conflict') + } + if (bound.status === 'bound') { + return { success: true, document: bound.document, created: false } } } return classifyKnowledgeFailure( diff --git a/apps/sim/lib/uploads/client/session-upload.test.ts b/apps/sim/lib/uploads/client/session-upload.test.ts new file mode 100644 index 00000000000..790f15ae108 --- /dev/null +++ b/apps/sim/lib/uploads/client/session-upload.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { V2CompletedPart, V2UploadPartUrl } from '@/lib/api/contracts/v2/uploads' + +interface MultipartMockParams { + getPartUrls: (partNumbers: number[]) => Promise + complete: (parts: V2CompletedPart[]) => Promise +} + +const { mockRequestJson, mockUploadMultipartSession } = vi.hoisted(() => ({ + mockRequestJson: vi.fn(), + mockUploadMultipartSession: vi.fn(), +})) + +vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) +vi.mock('@/lib/uploads/client/multipart-session', () => ({ + uploadMultipartSession: mockUploadMultipartSession, +})) + +import { uploadKnowledgeDocumentSession } from '@/lib/uploads/client/session-upload' + +const DOCUMENT = { + id: 'upload-1', + knowledgeBaseId: 'kb-1', + filename: 'guide.pdf', + fileSize: 1024, + mimeType: 'application/pdf', + processingStatus: 'pending', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: true, + createdAt: '2026-08-04T21:00:00.000Z', +} as const + +describe('uploadKnowledgeDocumentSession', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRequestJson + .mockResolvedValueOnce({ + data: { + id: 'upload-1', + partSize: 8 * 1024 * 1024, + partCount: 1, + uploadToken: 'token', + }, + }) + .mockResolvedValueOnce({ + data: { parts: [{ partNumber: 1, url: 'https://storage.example/part-1', headers: {} }] }, + }) + .mockResolvedValueOnce({ data: { document: DOCUMENT } }) + mockUploadMultipartSession.mockImplementation( + async (params: MultipartMockParams) => { + await params.getPartUrls([1]) + return params.complete([{ partNumber: 1, etag: 'etag-1' }]) + } + ) + }) + + it('uses the first-party session routes and preserves signed processing metadata', async () => { + const file = { + name: 'guide.pdf', + type: 'application/pdf', + size: 1024, + } as File + + await expect( + uploadKnowledgeDocumentSession({ + workspaceId: '6fc7631d-88cd-46f8-9f0a-d4764daef7f8', + knowledgeBaseId: 'kb-1', + file, + tag1: 'product', + processingOptions: { recipe: 'default', lang: 'en' }, + }) + ).resolves.toEqual(DOCUMENT) + + expect(mockRequestJson.mock.calls[0][0].path).toBe('/api/knowledge/[id]/documents/uploads') + expect(mockRequestJson.mock.calls[0][1].body).toMatchObject({ + name: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + tag1: 'product', + processingOptions: { recipe: 'default', lang: 'en' }, + }) + expect(mockRequestJson.mock.calls[1][0].path).toBe( + '/api/knowledge/[id]/documents/uploads/[uploadId]/parts' + ) + expect(mockRequestJson.mock.calls[2][0].path).toBe( + '/api/knowledge/[id]/documents/uploads/[uploadId]/complete' + ) + }) +}) diff --git a/apps/sim/lib/uploads/client/session-upload.ts b/apps/sim/lib/uploads/client/session-upload.ts index b6845c51062..e06d9c57860 100644 --- a/apps/sim/lib/uploads/client/session-upload.ts +++ b/apps/sim/lib/uploads/client/session-upload.ts @@ -1,16 +1,19 @@ import { requestJson } from '@/lib/api/client/request' +import { + abortKnowledgeDocumentUploadContract, + completeKnowledgeDocumentUploadContract, + createKnowledgeDocumentUploadContract, + createKnowledgeDocumentUploadPartUrlsContract, +} from '@/lib/api/contracts/knowledge/upload-sessions' import { abortWorkspaceFileUploadContract, completeWorkspaceFileUploadContract, createWorkspaceFileUploadContract, createWorkspaceFileUploadPartUrlsContract, } from '@/lib/api/contracts/upload-sessions' -import { - type V2KnowledgeDocumentSummary, - v2AbortKnowledgeDocumentUploadContract, - v2CompleteKnowledgeDocumentUploadContract, - v2CreateKnowledgeDocumentUploadContract, - v2CreateKnowledgeDocumentUploadPartUrlsContract, +import type { + V2KnowledgeDocumentSummary, + V2KnowledgeDocumentUploadMetadata, } from '@/lib/api/contracts/v2/knowledge' import type { UploadProgressEvent } from '@/lib/uploads/client/direct-upload' import { uploadMultipartSession } from '@/lib/uploads/client/multipart-session' @@ -24,7 +27,7 @@ interface UploadWorkspaceFileSessionParams { onProgress?: (event: UploadProgressEvent) => void } -interface UploadKnowledgeDocumentSessionParams { +interface UploadKnowledgeDocumentSessionParams extends V2KnowledgeDocumentUploadMetadata { workspaceId: string knowledgeBaseId: string file: File @@ -85,14 +88,15 @@ export async function uploadWorkspaceFileSession(params: UploadWorkspaceFileSess export async function uploadKnowledgeDocumentSession( params: UploadKnowledgeDocumentSessionParams ): Promise { - const { workspaceId, knowledgeBaseId, file, signal, onProgress } = params - const created = await requestJson(v2CreateKnowledgeDocumentUploadContract, { + const { workspaceId, knowledgeBaseId, file, signal, onProgress, ...metadata } = params + const created = await requestJson(createKnowledgeDocumentUploadContract, { params: { id: knowledgeBaseId }, body: { workspaceId, name: file.name, contentType: getFileContentType(file), size: file.size, + ...metadata, }, signal, }) @@ -104,7 +108,7 @@ export async function uploadKnowledgeDocumentSession( signal, onProgress, getPartUrls: async (partNumbers) => { - const batch = await requestJson(v2CreateKnowledgeDocumentUploadPartUrlsContract, { + const batch = await requestJson(createKnowledgeDocumentUploadPartUrlsContract, { params: { id: knowledgeBaseId, uploadId: upload.id }, query: { workspaceId }, headers: { 'upload-token': upload.uploadToken }, @@ -114,7 +118,7 @@ export async function uploadKnowledgeDocumentSession( return batch.data.parts }, complete: async (parts) => { - const completed = await requestJson(v2CompleteKnowledgeDocumentUploadContract, { + const completed = await requestJson(completeKnowledgeDocumentUploadContract, { params: { id: knowledgeBaseId, uploadId: upload.id }, query: { workspaceId }, headers: { 'upload-token': upload.uploadToken }, @@ -127,7 +131,7 @@ export async function uploadKnowledgeDocumentSession( return completed.data.document }, abort: async () => { - await requestJson(v2AbortKnowledgeDocumentUploadContract, { + await requestJson(abortKnowledgeDocumentUploadContract, { params: { id: knowledgeBaseId, uploadId: upload.id }, query: { workspaceId }, headers: { 'upload-token': upload.uploadToken }, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 00bdafaa2d5..001621c4f3a 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1087, - zodRoutes: 1087, + totalRoutes: 1091, + zodRoutes: 1091, nonZodRoutes: 0, } as const From b528ad0fa1808dc272621d8b098531ac6f6605e7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 01:01:35 -0700 Subject: [PATCH 059/159] fix(knowledge): refuse to abort an upload once a document is bound --- .../documents/uploads/[uploadId]/route.ts | 4 +- .../documents/uploads/[uploadId]/route.ts | 4 +- .../[id]/documents/uploads/utils.test.ts | 36 ++++++++++++- .../knowledge/[id]/documents/uploads/utils.ts | 50 +++++++++++++++---- 4 files changed, 79 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts index 83ce4e9e858..bc37c1026c2 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts @@ -2,13 +2,13 @@ import { type NextRequest, NextResponse } from 'next/server' import { abortKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { abortUploadSession } from '@/lib/uploads/multipart-session/service' import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' import { requireKnowledgeDocumentUploadAccess, requireKnowledgeDocumentUploadActor, } from '@/app/api/knowledge/[id]/documents/uploads/utils' import { + abortKnowledgeDocumentUpload, getOwnedKnowledgeDocumentUpload, toV2KnowledgeDocumentUpload, } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' @@ -39,7 +39,7 @@ export const DELETE = withRouteHandler( userId: actor.id, uploadToken: parsed.data.headers['upload-token'], }) - const aborted = await abortUploadSession(upload) + const aborted = await abortKnowledgeDocumentUpload(upload, knowledgeBaseId) return NextResponse.json({ data: toV2KnowledgeDocumentUpload(aborted, null) }) } catch (error) { const classified = uploadSessionErrorResponse(error) 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 b30f79617e3..108f4137573 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 @@ -5,9 +5,9 @@ import { NextResponse } from 'next/server' import { v2AbortKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { abortUploadSession } from '@/lib/uploads/multipart-session/service' import { checkRateLimit } from '@/app/api/v1/middleware' import { + abortKnowledgeDocumentUpload, getOwnedKnowledgeDocumentUpload, resolveKnowledgeDocumentUploadAccess, toV2KnowledgeDocumentUpload, @@ -58,7 +58,7 @@ export const DELETE = withRouteHandler( userId, uploadToken: parsed.data.headers['upload-token'], }) - const aborted = await abortUploadSession(session) + const aborted = await abortKnowledgeDocumentUpload(session, knowledgeBaseId) return v2Data(toV2KnowledgeDocumentUpload(aborted, null), { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts index 2c4af9fdb0a..82982238102 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts @@ -5,12 +5,14 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const { + mockAbortUploadSession, mockDeleteFile, mockDeleteFileMetadata, mockFindBoundKnowledgeDocument, mockPerformUploadKnowledgeDocument, mockRecordKnowledgeBaseFileOwnership, } = vi.hoisted(() => ({ + mockAbortUploadSession: vi.fn(), mockDeleteFile: vi.fn(), mockDeleteFileMetadata: vi.fn(), mockFindBoundKnowledgeDocument: vi.fn(), @@ -24,13 +26,20 @@ vi.mock('@/lib/knowledge/orchestration', () => ({ vi.mock('@/lib/knowledge/orchestration/documents', () => ({ findBoundKnowledgeDocument: mockFindBoundKnowledgeDocument, })) +vi.mock('@/lib/uploads/multipart-session/service', () => ({ + abortUploadSession: mockAbortUploadSession, + getOwnedUploadSession: vi.fn(), +})) vi.mock('@/lib/uploads/core/storage-service', () => ({ deleteFile: mockDeleteFile })) vi.mock('@/lib/uploads/server/metadata', () => ({ deleteFileMetadata: mockDeleteFileMetadata, recordKnowledgeBaseFileOwnership: mockRecordKnowledgeBaseFileOwnership, })) -import { finalizeKnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { + abortKnowledgeDocumentUpload, + finalizeKnowledgeDocumentUpload, +} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' const CLAIMED = { @@ -75,6 +84,31 @@ function finalize(resolveAttribution = vi.fn().mockResolvedValue({ actorUserId: }) } +describe('abortKnowledgeDocumentUpload', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAbortUploadSession.mockResolvedValue({ ...CLAIMED, status: 'aborted' }) + }) + + it('aborts an upload that no document is bound to', async () => { + mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'absent' }) + + await expect(abortKnowledgeDocumentUpload(CLAIMED, 'kb-1')).resolves.toMatchObject({ + status: 'aborted', + }) + expect(mockAbortUploadSession).toHaveBeenCalledWith(CLAIMED) + }) + + it('refuses to abort once a document is bound, so committed bytes survive', async () => { + mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'bound', document: DOCUMENT }) + + await expect(abortKnowledgeDocumentUpload(CLAIMED, 'kb-1')).rejects.toThrow( + 'Upload has already been completed' + ) + expect(mockAbortUploadSession).not.toHaveBeenCalled() + }) +}) + describe('finalizeKnowledgeDocumentUpload', () => { beforeEach(() => { vi.clearAllMocks() 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 bbe7dcfeab0..b959f3ebd6a 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 @@ -18,6 +18,7 @@ import { findBoundKnowledgeDocument } from '@/lib/knowledge/orchestration/docume import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { deleteFile } from '@/lib/uploads/core/storage-service' import { + abortUploadSession, getOwnedUploadSession, type UploadSessionRecord, } from '@/lib/uploads/multipart-session/service' @@ -145,6 +146,43 @@ export function knowledgeDocumentFileUrl(session: UploadSessionRecord): string { return `/api/files/serve/${providerPrefix}${encodeURIComponent(session.storageKey)}?context=knowledge-base` } +function knowledgeDocumentInputFor(session: UploadSessionRecord) { + const { processingOptions: _processingOptions, ...documentTags } = + v2KnowledgeDocumentUploadMetadataSchema.parse(session.metadata) + return { + filename: session.fileName, + fileUrl: knowledgeDocumentFileUrl(session), + fileSize: session.fileSize, + mimeType: session.contentType, + ...documentTags, + } +} + +/** + * Aborts an upload session, refusing once a document is bound to it. + * + * Upload sessions are stateless — the signed token always reconstructs as `uploading`, so + * nothing else stops an abort from arriving after a successful completion. That matters + * because the abort is not uniformly a no-op on a committed object: the blob provider + * deletes the blob outright. Without this guard a late abort (the client aborts when a + * completion response is lost, and the token stays valid for its full TTL) would strip the + * bytes from a live document. + */ +export async function abortKnowledgeDocumentUpload( + session: UploadSessionRecord, + knowledgeBaseId: string +): Promise { + const bound = await findBoundKnowledgeDocument({ + documentId: session.id, + knowledgeBaseId, + document: knowledgeDocumentInputFor(session), + }) + if (bound.status !== 'absent') { + throw new OrchestrationError('conflict', 'Upload has already been completed') + } + return abortUploadSession(session) +} + /** * Binds a completed multipart session to its knowledge document. Shared by the public v2 * and session-authenticated routes so both get identical completion semantics. @@ -169,16 +207,8 @@ export async function finalizeKnowledgeDocumentUpload(params: { actorEmail?: string | null }): Promise<{ value: CreatedKnowledgeDocument; completedFileId: string }> { const { claimed, knowledgeBaseId, workspaceId, requestId } = params - const { processingOptions, ...documentTags } = v2KnowledgeDocumentUploadMetadataSchema.parse( - claimed.metadata - ) - const document = { - filename: claimed.fileName, - fileUrl: knowledgeDocumentFileUrl(claimed), - fileSize: claimed.fileSize, - mimeType: claimed.contentType, - ...documentTags, - } + const { processingOptions } = v2KnowledgeDocumentUploadMetadataSchema.parse(claimed.metadata) + const document = knowledgeDocumentInputFor(claimed) const bound = await findBoundKnowledgeDocument({ documentId: claimed.id, From 09c27a4f1ec92b0d9d92c1ae10dea1b19074001c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 10:39:39 -0700 Subject: [PATCH 060/159] fix(uploads): prevent multipart cleanup races --- .../[id]/documents/uploads/route.test.ts | 17 ++- .../knowledge/[id]/documents/uploads/route.ts | 9 +- .../[id]/documents/uploads/route.test.ts | 17 ++- .../knowledge/[id]/documents/uploads/route.ts | 5 +- .../[id]/documents/uploads/utils.test.ts | 99 +++++++++++----- .../knowledge/[id]/documents/uploads/utils.ts | 111 ++++++++++-------- apps/sim/background/cleanup-soft-deletes.ts | 15 ++- .../lib/uploads/providers/blob/client.test.ts | 10 ++ apps/sim/lib/uploads/providers/blob/client.ts | 42 ++----- 9 files changed, 175 insertions(+), 150 deletions(-) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts index 7148f14379e..df28c35218f 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts @@ -5,20 +5,17 @@ import { NextRequest, NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockCreateUploadSession, + mockCreateKnowledgeDocumentUploadSession, mockRequireKnowledgeDocumentUploadAccess, mockRequireKnowledgeDocumentUploadActor, mockRequireKnowledgeDocumentUploadBilling, } = vi.hoisted(() => ({ - mockCreateUploadSession: vi.fn(), + mockCreateKnowledgeDocumentUploadSession: vi.fn(), mockRequireKnowledgeDocumentUploadAccess: vi.fn(), mockRequireKnowledgeDocumentUploadActor: vi.fn(), mockRequireKnowledgeDocumentUploadBilling: vi.fn(), })) -vi.mock('@/lib/uploads/multipart-session/service', () => ({ - createUploadSession: mockCreateUploadSession, -})) vi.mock('@/app/api/knowledge/[id]/documents/uploads/utils', () => ({ requireKnowledgeDocumentUploadAccess: mockRequireKnowledgeDocumentUploadAccess, requireKnowledgeDocumentUploadActor: mockRequireKnowledgeDocumentUploadActor, @@ -26,6 +23,7 @@ vi.mock('@/app/api/knowledge/[id]/documents/uploads/utils', () => ({ })) vi.mock('@/app/api/files/uploads/utils', () => ({ uploadSessionErrorResponse: vi.fn() })) vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ + createKnowledgeDocumentUploadSession: mockCreateKnowledgeDocumentUploadSession, toV2KnowledgeDocumentUpload: (session: Record) => ({ ...session, name: session.fileName, @@ -66,7 +64,7 @@ describe('POST /api/knowledge/[id]/documents/uploads', () => { knowledgeBase: { id: 'kb-1', name: 'Docs', workspaceId: WORKSPACE_ID }, }) mockRequireKnowledgeDocumentUploadBilling.mockResolvedValue({ actorUserId: 'user-1' }) - mockCreateUploadSession.mockResolvedValue({ + mockCreateKnowledgeDocumentUploadSession.mockResolvedValue({ id: 'upload-1', knowledgeBaseId: 'kb-1', status: 'uploading', @@ -89,11 +87,10 @@ describe('POST /api/knowledge/[id]/documents/uploads', () => { workspaceId: WORKSPACE_ID, userId: 'user-1', }) - expect(mockCreateUploadSession).toHaveBeenCalledWith({ + expect(mockCreateKnowledgeDocumentUploadSession).toHaveBeenCalledWith({ workspaceId: WORKSPACE_ID, userId: 'user-1', knowledgeBaseId: 'kb-1', - purpose: 'knowledge_document', fileName: 'guide.pdf', contentType: 'application/pdf', fileSize: 1024, @@ -103,7 +100,7 @@ describe('POST /api/knowledge/[id]/documents/uploads', () => { }, }) expect(mockRequireKnowledgeDocumentUploadBilling.mock.invocationCallOrder[0]).toBeLessThan( - mockCreateUploadSession.mock.invocationCallOrder[0] + mockCreateKnowledgeDocumentUploadSession.mock.invocationCallOrder[0] ) }) @@ -116,6 +113,6 @@ describe('POST /api/knowledge/[id]/documents/uploads', () => { expect(response.status).toBe(403) expect(mockRequireKnowledgeDocumentUploadBilling).not.toHaveBeenCalled() - expect(mockCreateUploadSession).not.toHaveBeenCalled() + expect(mockCreateKnowledgeDocumentUploadSession).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts index fd0e11620f4..09b7aec9e59 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts @@ -2,7 +2,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { createKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createUploadSession } from '@/lib/uploads/multipart-session/service' import { validateFileType } from '@/lib/uploads/utils/validation' import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' import { @@ -10,7 +9,10 @@ import { requireKnowledgeDocumentUploadActor, requireKnowledgeDocumentUploadBilling, } from '@/app/api/knowledge/[id]/documents/uploads/utils' -import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { + createKnowledgeDocumentUploadSession, + toV2KnowledgeDocumentUpload, +} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' interface KnowledgeDocumentUploadsRouteParams { params: Promise<{ id: string }> @@ -40,11 +42,10 @@ export const POST = withRouteHandler( return NextResponse.json({ error: fileTypeError.message }, { status: 415 }) } try { - const upload = await createUploadSession({ + const upload = await createKnowledgeDocumentUploadSession({ workspaceId, userId: actor.id, knowledgeBaseId, - purpose: 'knowledge_document', fileName: name, contentType, fileSize: size, 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 0f07d093530..cf3ea905882 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 @@ -6,12 +6,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckRateLimit, - mockCreateUploadSession, + mockCreateKnowledgeDocumentUploadSession, mockResolveKnowledgeDocumentUploadAccess, mockResolveKnowledgeDocumentUploadBilling, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), - mockCreateUploadSession: vi.fn(), + mockCreateKnowledgeDocumentUploadSession: vi.fn(), mockResolveKnowledgeDocumentUploadAccess: vi.fn(), mockResolveKnowledgeDocumentUploadBilling: vi.fn(), })) @@ -20,10 +20,8 @@ vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit } vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null), })) -vi.mock('@/lib/uploads/multipart-session/service', () => ({ - createUploadSession: mockCreateUploadSession, -})) vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ + createKnowledgeDocumentUploadSession: mockCreateKnowledgeDocumentUploadSession, resolveKnowledgeDocumentUploadAccess: mockResolveKnowledgeDocumentUploadAccess, resolveKnowledgeDocumentUploadBilling: mockResolveKnowledgeDocumentUploadBilling, toV2KnowledgeDocumentUpload: (session: Record) => ({ @@ -74,7 +72,7 @@ describe('POST /api/v2/knowledge/[id]/documents/uploads', () => { kb: { id: 'kb-1', name: 'Docs' }, }) mockResolveKnowledgeDocumentUploadBilling.mockResolvedValue({ actorUserId: 'user-1' }) - mockCreateUploadSession.mockResolvedValue({ + mockCreateKnowledgeDocumentUploadSession.mockResolvedValue({ id: 'upload-1', knowledgeBaseId: 'kb-1', status: 'uploading', @@ -100,11 +98,10 @@ describe('POST /api/v2/knowledge/[id]/documents/uploads', () => { }) ) expect(mockResolveKnowledgeDocumentUploadBilling).toHaveBeenCalled() - expect(mockCreateUploadSession).toHaveBeenCalledWith({ + expect(mockCreateKnowledgeDocumentUploadSession).toHaveBeenCalledWith({ workspaceId: WORKSPACE_ID, userId: 'user-1', knowledgeBaseId: 'kb-1', - purpose: 'knowledge_document', fileName: 'guide.pdf', contentType: 'application/pdf', fileSize: 1024, @@ -114,7 +111,7 @@ describe('POST /api/v2/knowledge/[id]/documents/uploads', () => { }, }) expect(mockResolveKnowledgeDocumentUploadBilling.mock.invocationCallOrder[0]).toBeLessThan( - mockCreateUploadSession.mock.invocationCallOrder[0] + mockCreateKnowledgeDocumentUploadSession.mock.invocationCallOrder[0] ) }) @@ -127,6 +124,6 @@ describe('POST /api/v2/knowledge/[id]/documents/uploads', () => { expect(response.status).toBe(403) expect(mockResolveKnowledgeDocumentUploadBilling).not.toHaveBeenCalled() - expect(mockCreateUploadSession).not.toHaveBeenCalled() + expect(mockCreateKnowledgeDocumentUploadSession).not.toHaveBeenCalled() }) }) 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 444867c1f14..fb15afddeea 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 @@ -5,10 +5,10 @@ import { NextResponse } from 'next/server' import { v2CreateKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createUploadSession } from '@/lib/uploads/multipart-session/service' import { validateFileType } from '@/lib/uploads/utils/validation' import { checkRateLimit } from '@/app/api/v1/middleware' import { + createKnowledgeDocumentUploadSession, resolveKnowledgeDocumentUploadAccess, resolveKnowledgeDocumentUploadBilling, toV2KnowledgeDocumentUpload, @@ -64,11 +64,10 @@ export const POST = withRouteHandler( return v2Error('UNSUPPORTED_MEDIA_TYPE', fileTypeError.message) } - const session = await createUploadSession({ + const session = await createKnowledgeDocumentUploadSession({ workspaceId, userId, knowledgeBaseId, - purpose: 'knowledge_document', fileName: name, contentType, fileSize: size, diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts index 82982238102..708f666b2a9 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts @@ -3,18 +3,17 @@ */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { UploadSessionRecord } from '@/lib/uploads/multipart-session/service' const { mockAbortUploadSession, - mockDeleteFile, - mockDeleteFileMetadata, + mockCreateUploadSession, mockFindBoundKnowledgeDocument, mockPerformUploadKnowledgeDocument, mockRecordKnowledgeBaseFileOwnership, } = vi.hoisted(() => ({ mockAbortUploadSession: vi.fn(), - mockDeleteFile: vi.fn(), - mockDeleteFileMetadata: vi.fn(), + mockCreateUploadSession: vi.fn(), mockFindBoundKnowledgeDocument: vi.fn(), mockPerformUploadKnowledgeDocument: vi.fn(), mockRecordKnowledgeBaseFileOwnership: vi.fn(), @@ -28,21 +27,21 @@ vi.mock('@/lib/knowledge/orchestration/documents', () => ({ })) vi.mock('@/lib/uploads/multipart-session/service', () => ({ abortUploadSession: mockAbortUploadSession, + createUploadSession: mockCreateUploadSession, getOwnedUploadSession: vi.fn(), })) -vi.mock('@/lib/uploads/core/storage-service', () => ({ deleteFile: mockDeleteFile })) vi.mock('@/lib/uploads/server/metadata', () => ({ - deleteFileMetadata: mockDeleteFileMetadata, recordKnowledgeBaseFileOwnership: mockRecordKnowledgeBaseFileOwnership, })) import { abortKnowledgeDocumentUpload, + createKnowledgeDocumentUploadSession, finalizeKnowledgeDocumentUpload, } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' -const CLAIMED = { +const CLAIMED: UploadSessionRecord = { id: 'upload-1', workspaceId: WORKSPACE_ID, userId: 'user-1', @@ -66,8 +65,7 @@ const CLAIMED = { error: null, completedAt: null, updatedAt: new Date('2026-08-03T21:00:00.000Z'), - // biome-ignore lint/suspicious/noExplicitAny: partial session shape for the test -} as any +} const DOCUMENT = { id: 'upload-1', knowledgeBaseId: 'kb-1', filename: 'guide.pdf' } function finalize(resolveAttribution = vi.fn().mockResolvedValue({ actorUserId: 'payer-1' })) { @@ -84,6 +82,60 @@ function finalize(resolveAttribution = vi.fn().mockResolvedValue({ actorUserId: }) } +function createSession() { + return createKnowledgeDocumentUploadSession({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + metadata: { tag1: 'product' }, + }) +} + +describe('createKnowledgeDocumentUploadSession', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreateUploadSession.mockResolvedValue(CLAIMED) + mockRecordKnowledgeBaseFileOwnership.mockResolvedValue(undefined) + mockAbortUploadSession.mockResolvedValue({ ...CLAIMED, status: 'aborted' }) + }) + + it('records the ownership binding before returning the upload token', async () => { + await expect(createSession()).resolves.toBe(CLAIMED) + + expect(mockCreateUploadSession).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + purpose: 'knowledge_document', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + metadata: { tag1: 'product' }, + }) + expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenCalledWith({ + key: 'kb/guide.pdf', + userId: 'user-1', + workspaceId: WORKSPACE_ID, + originalName: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + }) + expect(mockCreateUploadSession.mock.invocationCallOrder[0]).toBeLessThan( + mockRecordKnowledgeBaseFileOwnership.mock.invocationCallOrder[0] + ) + }) + + it('aborts provider state when the ownership binding cannot be recorded', async () => { + mockRecordKnowledgeBaseFileOwnership.mockRejectedValue(new Error('database unavailable')) + + await expect(createSession()).rejects.toThrow('database unavailable') + expect(mockAbortUploadSession).toHaveBeenCalledWith(CLAIMED) + }) +}) + describe('abortKnowledgeDocumentUpload', () => { beforeEach(() => { vi.clearAllMocks() @@ -113,9 +165,6 @@ describe('finalizeKnowledgeDocumentUpload', () => { beforeEach(() => { vi.clearAllMocks() mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'absent' }) - mockRecordKnowledgeBaseFileOwnership.mockResolvedValue(undefined) - mockDeleteFile.mockResolvedValue(undefined) - mockDeleteFileMetadata.mockResolvedValue(true) mockPerformUploadKnowledgeDocument.mockResolvedValue({ success: true, document: DOCUMENT, @@ -127,14 +176,6 @@ describe('finalizeKnowledgeDocumentUpload', () => { const result = await finalize() expect(result).toEqual({ value: DOCUMENT, completedFileId: 'upload-1' }) - expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenCalledWith({ - key: 'kb/guide.pdf', - userId: 'user-1', - workspaceId: WORKSPACE_ID, - originalName: 'guide.pdf', - contentType: 'application/pdf', - size: 1024, - }) expect(mockPerformUploadKnowledgeDocument).toHaveBeenCalledWith( expect.objectContaining({ documentId: 'upload-1', @@ -144,7 +185,6 @@ describe('finalizeKnowledgeDocumentUpload', () => { document: expect.objectContaining({ filename: 'guide.pdf', tag1: 'product' }), }) ) - expect(mockDeleteFile).not.toHaveBeenCalled() }) it('answers a retry from the bound document without resolving a payer', async () => { @@ -155,12 +195,10 @@ describe('finalizeKnowledgeDocumentUpload', () => { expect(result).toEqual({ value: DOCUMENT, completedFileId: 'upload-1' }) expect(resolveAttribution).not.toHaveBeenCalled() - expect(mockRecordKnowledgeBaseFileOwnership).not.toHaveBeenCalled() expect(mockPerformUploadKnowledgeDocument).not.toHaveBeenCalled() - expect(mockDeleteFile).not.toHaveBeenCalled() }) - it('deletes the uploaded object when creation fails and nothing is bound', async () => { + it('retains completed bytes for retry when document creation fails', async () => { mockPerformUploadKnowledgeDocument.mockResolvedValue({ success: false, errorCode: 'payload_too_large', @@ -168,19 +206,21 @@ describe('finalizeKnowledgeDocumentUpload', () => { }) await expect(finalize()).rejects.toThrow('Storage limit exceeded') - expect(mockDeleteFile).toHaveBeenCalledWith({ key: 'kb/guide.pdf', context: 'knowledge-base' }) - expect(mockDeleteFileMetadata).toHaveBeenCalledWith('kb/guide.pdf') + expect(mockFindBoundKnowledgeDocument).toHaveBeenCalledTimes(1) }) - it('keeps the uploaded object when a document is bound despite the failure', async () => { + it('lets a retry converge when the first response fails after the document binds', async () => { mockFindBoundKnowledgeDocument .mockResolvedValueOnce({ status: 'absent' }) .mockResolvedValueOnce({ status: 'bound', document: DOCUMENT }) mockPerformUploadKnowledgeDocument.mockRejectedValue(new Error('audit sink exploded')) await expect(finalize()).rejects.toThrow('audit sink exploded') - expect(mockDeleteFile).not.toHaveBeenCalled() - expect(mockDeleteFileMetadata).not.toHaveBeenCalled() + await expect(finalize()).resolves.toEqual({ + value: DOCUMENT, + completedFileId: 'upload-1', + }) + expect(mockPerformUploadKnowledgeDocument).toHaveBeenCalledTimes(1) }) it('rejects an upload id already bound to a different document without deleting anything', async () => { @@ -191,6 +231,5 @@ describe('finalizeKnowledgeDocumentUpload', () => { 'Upload id is already bound to a different document' ) expect(resolveAttribution).not.toHaveBeenCalled() - expect(mockDeleteFile).not.toHaveBeenCalled() }) }) 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 b959f3ebd6a..2844b5245dc 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 @@ -16,13 +16,13 @@ import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration' import type { CreatedKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' import { findBoundKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' -import { deleteFile } from '@/lib/uploads/core/storage-service' import { abortUploadSession, + createUploadSession, getOwnedUploadSession, type UploadSessionRecord, } from '@/lib/uploads/multipart-session/service' -import { deleteFileMetadata, recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' +import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' import type { RateLimitResult } from '@/app/api/v1/middleware' import { v2Error } from '@/app/api/v2/lib/response' @@ -97,6 +97,40 @@ export function getOwnedKnowledgeDocumentUpload(params: { }) } +/** + * Creates a knowledge-document upload and records its ownership binding before the token is + * returned. Failed or abandoned sessions can then be reclaimed by the knowledge-base orphan + * sweeper without racing a later document insert. + */ +export async function createKnowledgeDocumentUploadSession(params: { + workspaceId: string + userId: string + knowledgeBaseId: string + fileName: string + contentType: string + fileSize: number + metadata: Record +}): Promise { + const session = await createUploadSession({ + ...params, + purpose: 'knowledge_document', + }) + try { + await recordKnowledgeBaseFileOwnership({ + key: session.storageKey, + userId: params.userId, + workspaceId: params.workspaceId, + originalName: params.fileName, + contentType: params.contentType, + size: params.fileSize, + }) + } catch (error) { + await abortUploadSession(session) + throw error + } + return session +} + export function toV2KnowledgeDocumentSummary( document: CreatedKnowledgeDocument ): V2KnowledgeDocumentSummary { @@ -161,12 +195,10 @@ function knowledgeDocumentInputFor(session: UploadSessionRecord) { /** * Aborts an upload session, refusing once a document is bound to it. * - * Upload sessions are stateless — the signed token always reconstructs as `uploading`, so - * nothing else stops an abort from arriving after a successful completion. That matters - * because the abort is not uniformly a no-op on a committed object: the blob provider - * deletes the blob outright. Without this guard a late abort (the client aborts when a - * completion response is lost, and the token stays valid for its full TTL) would strip the - * bytes from a live document. + * Upload sessions are stateless — the signed token always reconstructs as `uploading`, so this + * guard preserves the completed state exposed by the document binding. Provider aborts must + * also remain non-destructive after commit because an in-flight completion is not visible here + * until its document transaction commits. */ export async function abortKnowledgeDocumentUpload( session: UploadSessionRecord, @@ -189,9 +221,9 @@ export async function abortKnowledgeDocumentUpload( * * Ordering is load-bearing. A retry is answered from the already-bound document before any * work that can fail independently of the upload runs, so a payer that became unresolvable - * after the session was created cannot turn a valid retry into an error. Cleanup is likewise - * gated on the upload still being unbound — uploaded bytes are never deleted out from under - * a live document row. + * after the session was created cannot turn a valid retry into an error. The ownership binding + * is recorded before the upload token is issued, so failures retain retriable state and the + * delayed orphan sweeper reclaims sessions that never bind to a document. */ export async function finalizeKnowledgeDocumentUpload(params: { claimed: UploadSessionRecord @@ -223,44 +255,23 @@ export async function finalizeKnowledgeDocumentUpload(params: { } const billingAttribution = await params.resolveAttribution() - try { - await recordKnowledgeBaseFileOwnership({ - key: claimed.storageKey, - userId: params.userId, - workspaceId, - originalName: claimed.fileName, - contentType: claimed.contentType, - size: claimed.fileSize, - }) - const outcome = await performUploadKnowledgeDocument({ - knowledgeBase: { id: knowledgeBaseId, name: params.knowledgeBaseName, workspaceId }, - document, - documentId: claimed.id, - startProcessing: 'queue', - processingOptions, - billingAttribution, - uploadedBy: billingAttribution.actorUserId, - userId: params.userId, - ...(params.actorName ? { actorName: params.actorName } : {}), - ...(params.actorEmail ? { actorEmail: params.actorEmail } : {}), - source: params.source, - requestId, - request: params.request, - }) - if (!outcome.success) { - throw new OrchestrationError(outcome.errorCode, outcome.error) - } - return { value: outcome.document, completedFileId: outcome.document.id } - } catch (error) { - const rebound = await findBoundKnowledgeDocument({ - documentId: claimed.id, - knowledgeBaseId, - document, - }) - if (rebound.status === 'absent') { - await deleteFile({ key: claimed.storageKey, context: 'knowledge-base' }) - await deleteFileMetadata(claimed.storageKey) - } - throw error + const outcome = await performUploadKnowledgeDocument({ + knowledgeBase: { id: knowledgeBaseId, name: params.knowledgeBaseName, workspaceId }, + document, + documentId: claimed.id, + startProcessing: 'queue', + processingOptions, + billingAttribution, + uploadedBy: billingAttribution.actorUserId, + userId: params.userId, + ...(params.actorName ? { actorName: params.actorName } : {}), + ...(params.actorEmail ? { actorEmail: params.actorEmail } : {}), + source: params.source, + requestId, + request: params.request, + }) + if (!outcome.success) { + throw new OrchestrationError(outcome.errorCode, outcome.error) } + return { value: outcome.document, completedFileId: outcome.document.id } } diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index b9ffd3c932b..3227d82e92c 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -702,14 +702,13 @@ const CLEANUP_TARGETS = [ ] as const /** - * Sweep abandoned knowledge-base ownership bindings. The presigned upload flow - * writes a `workspace_files` binding when it hands out an upload URL, before the - * object is stored and before any document is created. If the upload is never - * completed, that binding is orphaned — no `document.storageKey` ever references - * its key. Such bindings are inert (read access requires a live document, and - * the move re-point only follows referenced keys), but they accumulate, so we - * drop the best-effort object and soft-delete the binding once they are older - * than the grace window. + * Sweep abandoned knowledge-base ownership bindings. Presigned and multipart upload flows + * write a `workspace_files` binding before the object is stored and before any document is + * created. If the upload is never completed, that binding is orphaned — no + * `document.storageKey` ever references its key. Such bindings are inert (read access requires + * a live document, and the move re-point only follows referenced keys), but they accumulate, + * so we drop the best-effort object and soft-delete the binding once they are older than the + * grace window. */ async function cleanupOrphanedKnowledgeBaseBindings( workspaceIds: string[], diff --git a/apps/sim/lib/uploads/providers/blob/client.test.ts b/apps/sim/lib/uploads/providers/blob/client.test.ts index 19b06c93a91..f31cf572108 100644 --- a/apps/sim/lib/uploads/providers/blob/client.test.ts +++ b/apps/sim/lib/uploads/providers/blob/client.test.ts @@ -51,6 +51,7 @@ vi.mock('@/lib/uploads/config', () => ({ })) import { + abortMultipartUpload, deleteFromBlob, downloadFromBlob, getPresignedUrl, @@ -194,6 +195,15 @@ describe('Azure Blob Storage Client', () => { }) }) + describe('abortMultipartUpload', () => { + it('leaves the blob key untouched while Azure garbage-collects uncommitted blocks', async () => { + await abortMultipartUpload('test-file-key') + + expect(mockGetBlockBlobClient).not.toHaveBeenCalled() + expect(mockDeleteIfExists).not.toHaveBeenCalled() + }) + }) + describe('getPresignedUrl', () => { it('should generate a presigned URL for Azure Blob Storage', async () => { const testKey = 'test-file-key' diff --git a/apps/sim/lib/uploads/providers/blob/client.ts b/apps/sim/lib/uploads/providers/blob/client.ts index 076933db181..7f2faf821e3 100644 --- a/apps/sim/lib/uploads/providers/blob/client.ts +++ b/apps/sim/lib/uploads/providers/blob/client.ts @@ -745,40 +745,12 @@ export async function completeMultipartUpload( } /** - * Abort multipart upload by deleting the blob if it exists + * Abandons an Azure multipart upload without deleting its key. + * + * Azure has no cancellation operation for staged blocks and garbage-collects uncommitted blocks + * after a week. Deleting by key is unsafe because a concurrent completion may already have + * committed the final blob at that key. */ -export async function abortMultipartUpload(key: string, customConfig?: BlobConfig): Promise { - const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob') - let blobServiceClient: BlobServiceClientType - let containerName: string - - if (customConfig) { - if (customConfig.connectionString) { - blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString) - } else if (customConfig.accountName && customConfig.accountKey) { - const credential = new StorageSharedKeyCredential( - customConfig.accountName, - customConfig.accountKey - ) - blobServiceClient = new BlobServiceClient( - `https://${customConfig.accountName}.blob.core.windows.net`, - credential - ) - } else { - throw new Error('Invalid custom blob configuration') - } - containerName = customConfig.containerName - } else { - blobServiceClient = await getBlobServiceClient() - containerName = BLOB_CONFIG.containerName - } - - const containerClient = blobServiceClient.getContainerClient(containerName) - const blockBlobClient = containerClient.getBlockBlobClient(key) - - try { - await blockBlobClient.deleteIfExists() - } catch (error) { - logger.warn('Error cleaning up multipart upload:', error) - } +export function abortMultipartUpload(_key: string, _customConfig?: BlobConfig): Promise { + return Promise.resolve() } From 7612e8daab5c92b8b26f033cbecfa3161bdd5914 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 11:11:28 -0700 Subject: [PATCH 061/159] fix(cli): improve command usability and structure --- packages/sim-cli/README.md | 16 +- packages/sim-cli/src/commands/auth.test.ts | 8 + packages/sim-cli/src/commands/auth.ts | 1 + .../sim-cli/src/commands/hand-written.test.ts | 118 ---- packages/sim-cli/src/commands/hand-written.ts | 633 ------------------ .../commands/protocol/files-download.test.ts | 107 +++ .../src/commands/protocol/files-download.ts | 96 +++ .../src/commands/protocol/files-upload.ts | 59 ++ .../sim-cli/src/commands/protocol/index.ts | 20 + .../sim-cli/src/commands/protocol/result.ts | 7 + .../commands/protocol/tables-import.test.ts | 110 +++ .../src/commands/protocol/tables-import.ts | 201 ++++++ packages/sim-cli/src/contract/commands.ts | 104 ++- packages/sim-cli/src/contract/types.ts | 8 +- packages/sim-cli/src/http/client.test.ts | 83 ++- packages/sim-cli/src/http/client.ts | 41 +- packages/sim-cli/src/index.ts | 28 +- packages/sim-cli/src/output/render.test.ts | 7 + packages/sim-cli/src/output/render.ts | 9 +- packages/sim-cli/src/runtime/build.test.ts | 207 +++++- packages/sim-cli/src/runtime/build.ts | 467 ++----------- packages/sim-cli/src/runtime/execute.ts | 80 +++ packages/sim-cli/src/runtime/options.ts | 98 +++ packages/sim-cli/src/runtime/request.test.ts | 11 + packages/sim-cli/src/runtime/request.ts | 5 +- packages/sim-cli/src/runtime/result.ts | 159 +++++ packages/sim-cli/src/runtime/types.ts | 13 + packages/sim-cli/src/transfer/local-file.ts | 49 ++ packages/sim-cli/src/transfer/multipart.ts | 96 +++ 29 files changed, 1619 insertions(+), 1222 deletions(-) create mode 100644 packages/sim-cli/src/commands/auth.test.ts delete mode 100644 packages/sim-cli/src/commands/hand-written.test.ts delete mode 100644 packages/sim-cli/src/commands/hand-written.ts create mode 100644 packages/sim-cli/src/commands/protocol/files-download.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/files-download.ts create mode 100644 packages/sim-cli/src/commands/protocol/files-upload.ts create mode 100644 packages/sim-cli/src/commands/protocol/index.ts create mode 100644 packages/sim-cli/src/commands/protocol/result.ts create mode 100644 packages/sim-cli/src/commands/protocol/tables-import.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/tables-import.ts create mode 100644 packages/sim-cli/src/runtime/execute.ts create mode 100644 packages/sim-cli/src/runtime/options.ts create mode 100644 packages/sim-cli/src/runtime/result.ts create mode 100644 packages/sim-cli/src/runtime/types.ts create mode 100644 packages/sim-cli/src/transfer/local-file.ts create mode 100644 packages/sim-cli/src/transfer/multipart.ts diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index bdfe45410da..bf7c3f400c0 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -107,6 +107,11 @@ Settings → API keys. ## Commands +Plural resource names are canonical, but every plural top-level resource group +also accepts its singular form: for example, `sim table list`, +`sim file download`, and `sim workflow get` are equivalent to their plural +spellings. + ```bash sim workflows list [--folder ] [--deployed] [--limit ] sim workflows get @@ -119,9 +124,10 @@ sim logs execution sim tables list sim tables get sim tables columns -sim tables rows [--filter ] [--sort …] [--limit ] -sim tables insert --data -sim tables delete-rows (--row … | --filter ) --yes +sim tables rows list [--limit ] +sim tables rows query [--filter ] [--sort ] [--limit ] +sim tables upsert --data +sim tables rows batch-delete (--row … | --filter ) --yes sim files list sim files download [-o ] @@ -130,7 +136,7 @@ sim files delete sim knowledge list sim knowledge get sim knowledge documents [--search ] -sim knowledge search --kb … +sim knowledge search --query --kb … [--search-mode vector|hybrid] ``` ### Filtering table rows @@ -140,7 +146,7 @@ sim knowledge search --kb … grammar is a tree; there's no honest flag encoding for it. ```bash -sim tables rows tbl_123 \ +sim tables rows query tbl_123 \ --filter '{"all":[{"field":"status","op":"eq","value":"open"}, {"field":"score","op":"gt","value":10}]}' \ --sort score:desc --limit 50 diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts new file mode 100644 index 00000000000..59f41e578d4 --- /dev/null +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest' +import { profilesCommand } from './auth.js' + +describe('profiles command', () => { + it('accepts the singular profile alias', () => { + expect(profilesCommand().alias()).toBe('profile') + }) +}) diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index ded0de9440e..e54b58db17e 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -185,6 +185,7 @@ export function whoamiCommand(): Command { export function profilesCommand(): Command { return new Command('profiles') + .alias('profile') .description('List the profiles defined in the config and credentials files') .action((_options: unknown, command: Command) => { const profiles = listProfiles() diff --git a/packages/sim-cli/src/commands/hand-written.test.ts b/packages/sim-cli/src/commands/hand-written.test.ts deleted file mode 100644 index 10f3d08b5fd..00000000000 --- a/packages/sim-cli/src/commands/hand-written.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { createWriteStream, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Command } from 'commander' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { attachHandWritten, streamToFile } from './hand-written.js' - -vi.mock('../context.js', () => ({ - clientFrom: () => ({ - client: { request: vi.fn(), requireWorkspace: () => 'ws_local' }, - profile: { workspaceId: 'ws_local', output: 'json', name: 'default', apiKey: 'k' }, - }), -})) - -let dir: string - -beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'sim-dl-')) -}) - -afterEach(() => { - rmSync(dir, { recursive: true, force: true }) -}) - -function bodyOf(chunks: string[]): ReadableStream { - return new ReadableStream({ - start(controller) { - for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)) - controller.close() - }, - }) -} - -describe('streamToFile', () => { - it('writes the body to disk', async () => { - const target = join(dir, 'out.txt') - await streamToFile(bodyOf(['hello ', 'world']), createWriteStream(target, { flags: 'wx' })) - expect(existsSync(target)).toBe(true) - }) - - it('refuses to clobber an existing file, naming --force', async () => { - const target = join(dir, 'out.txt') - writeFileSync(target, 'precious') - // The destination usually comes from the server's content-disposition, so a - // silent truncate could destroy a file the caller never named. - await expect( - streamToFile(bodyOf(['new']), createWriteStream(target, { flags: 'wx' })) - ).rejects.toThrow(/already exists.*--force/s) - }) - - it('overwrites when the caller asked for it', async () => { - const target = join(dir, 'out.txt') - writeFileSync(target, 'old') - await streamToFile(bodyOf(['new']), createWriteStream(target, { flags: 'w' })) - expect(existsSync(target)).toBe(true) - }) - - it.skipIf(!existsSync('/dev/full'))( - 'rejects when the final flush fails instead of reporting success', - async () => { - // `end`'s callback receives the flush error; passing `resolve` straight in - // made that error the resolution value, so a truncated download printed - // "Saved". /dev/full only errors at flush time, which is the exact path. - await expect( - streamToFile(bodyOf(['x'.repeat(64 * 1024)]), createWriteStream('/dev/full')) - ).rejects.toThrow(/Could not write/) - } - ) -}) - -describe('tables import argument guards', () => { - function importCommand(): Command { - const root = new Command('sim').exitOverride() - attachHandWritten(root) - const walk = (command: Command) => { - command.exitOverride() - command.commands.forEach(walk) - } - walk(root) - return root - } - - async function run(argv: string[]) { - await importCommand().parseAsync(['node', 'sim', 'tables', 'import', ...argv]) - } - - it('refuses to guess the source', async () => { - // A new table is a safe default; where the bytes are is not inferable. - await expect(run([])).rejects.toThrow(/exactly one of /) - await expect(run(['f.csv', '--file-id', 'w_1'])).rejects.toThrow(/exactly one of /) - }) - - it('rejects existing-table flags when creating one', async () => { - // Ignoring these would let `--mode replace` read as honoured while a new - // table is created beside the one it was meant to overwrite. - await expect(run(['f.csv', '--mode', 'replace'])).rejects.toThrow(/applies to --table-id/) - await expect(run(['f.csv', '--mapping', '{}'])).rejects.toThrow(/applies to --table-id/) - await expect(run(['f.csv', '--create-columns', '{}'])).rejects.toThrow(/applies to --table-id/) - }) - - it('rejects new-table flags when importing into an existing one', async () => { - await expect(run(['f.csv', '--table-id', 't', '--name', 'x'])).rejects.toThrow( - /--table-id already names the destination/ - ) - await expect(run(['f.csv', '--table-id', 't', '--folder-id', 'f'])).rejects.toThrow( - /--table-id already names the destination/ - ) - }) - - it('asks for a name when there is no file name to take one from', async () => { - await expect(run(['--file-id', 'w_1'])).rejects.toThrow(/--name /) - }) - - it('checks all of that before touching the filesystem', async () => { - // `f.csv` does not exist; a "cannot read" error would mean a guard ran late. - await expect(run(['f.csv', '--mode', 'append'])).rejects.toThrow(/applies to --table-id/) - }) -}) diff --git a/packages/sim-cli/src/commands/hand-written.ts b/packages/sim-cli/src/commands/hand-written.ts deleted file mode 100644 index 80142fba0a6..00000000000 --- a/packages/sim-cli/src/commands/hand-written.ts +++ /dev/null @@ -1,633 +0,0 @@ -import { once } from 'node:events' -import { createWriteStream, openAsBlob, type WriteStream } from 'node:fs' -import { stat } from 'node:fs/promises' -import { basename } from 'node:path' -import chalk from 'chalk' -import type { Command } from 'commander' -import { clientFrom } from '../context.js' -import type { QueryRowsResponse } from '../generated/v2-api.js' -import { SimApiError, type SimClient } from '../http/client.js' -import { type Column, printList, sanitize, text } from '../output/render.js' -import { coerce } from '../runtime/request.js' - -/** - * Commands the generated runtime cannot produce. - * - * Kept deliberately small — each entry needs a reason that generation could not - * satisfy even in principle, not merely "not migrated yet". They attach onto the - * groups the runtime already built, so `sim files --help` lists them alongside - * the generated leaves rather than in a second group. - */ - -type Row = QueryRowsResponse['data'][number] - -/** - * Streams a fetch body to disk, honouring backpressure. - * - * An explicit reader loop rather than `Readable.fromWeb`: the DOM - * `ReadableStream` that `fetch` returns and the one `node:stream/web` declares - * are structurally incompatible under this TS config, and bridging them needs a - * cast that would erase exactly the typing this keeps honest. - */ -export async function streamToFile( - body: ReadableStream, - file: WriteStream -): Promise { - // Registered before the first write, not after the loop. `createWriteStream` - // opens lazily, so an EEXIST/EACCES/ENOSPC can surface at any point — with no - // listener attached it is an unhandled 'error' event that takes down the - // process instead of failing the download. - const failed = new Promise((_resolve, reject) => { - file.once('error', reject) - }) - - const pump = (async () => { - const reader = body.getReader() - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - // `write` returning false means the buffer is full; waiting for `drain` - // is what stops a large file being buffered entirely in memory. - if (!file.write(value)) await once(file, 'drain') - } - } finally { - reader.releaseLock() - } - - // `end`'s callback receives the error from a failed final flush (ENOSPC is - // the common one, since the bytes may not hit disk until here). Passing - // `resolve` directly made that error the resolution *value*, so the pump - // fulfilled and the command printed "Saved" for a truncated file. - await new Promise((resolve, reject) => { - file.end((error?: Error | null) => (error ? reject(error) : resolve())) - }) - })() - - try { - await Promise.race([pump, failed]) - } catch (error) { - file.destroy() - const code = (error as NodeJS.ErrnoException).code - if (code === 'EEXIST') { - throw new SimApiError( - `${file.path} already exists. Pass --force to overwrite, or -o to write elsewhere.`, - 0 - ) - } - throw new SimApiError(`Could not write ${file.path}: ${(error as Error).message}`, 0) - } -} - -/** - * Row `data` is name-keyed and user-defined, so columns exist only at runtime. - * Keys are unioned across the page rather than read off the first row — a - * sparse row would otherwise hide every column it happens to omit. - */ -function rowColumns(rows: Row[]): Column[] { - const keys: string[] = [] - const seen = new Set() - for (const row of rows) { - for (const key of Object.keys(row.data)) { - if (seen.has(key)) continue - seen.add(key) - keys.push(key) - } - } - - return [ - { header: 'id', value: (row) => row.id }, - ...keys.map((key) => ({ - // A table's column names are user-defined, so the header is remote - // content just as much as the cell beneath it. - header: sanitize(key), - value: (row: Row) => { - const value = row.data[key] - if (value === null || value === undefined) return text(null) - // User-defined cell data is remote content; strip terminal controls. - return sanitize(typeof value === 'object' ? JSON.stringify(value) : String(value)) - }, - })), - ] -} - -function group(program: Command, name: string): Command { - const existing = program.commands.find((command) => command.name() === name) - if (existing) return existing - const created = program.command(name) - return created -} - -/** - * The server stores whatever content type the part carries, falling back to - * `application/octet-stream`, and that type is what later decides whether the - * workspace renders a file or offers it as a download. Node does not ship a - * mime table, so the common cases are listed and everything else falls back. - */ -const CONTENT_TYPES: Record = { - css: 'text/css', - csv: 'text/csv', - gif: 'image/gif', - html: 'text/html', - jpeg: 'image/jpeg', - jpg: 'image/jpeg', - js: 'text/javascript', - json: 'application/json', - md: 'text/markdown', - pdf: 'application/pdf', - png: 'image/png', - svg: 'image/svg+xml', - txt: 'text/plain', - webp: 'image/webp', - yaml: 'application/yaml', - yml: 'application/yaml', - zip: 'application/zip', -} - -function contentTypeFor(name: string): string { - const dot = name.lastIndexOf('.') - const extension = dot === -1 ? '' : name.slice(dot + 1).toLowerCase() - return CONTENT_TYPES[extension] ?? 'application/octet-stream' -} - -interface UploadPartUrl { - partNumber: number - url: string - headers: Record -} - -/** - * What a transfer needs to send its bytes, however it was started. - * - * File uploads and table imports are the same handshake against different - * paths — identical part-URL and complete bodies, the same `upload-token` - * header — so one implementation drives both. `basePath` is the transfer's own - * resource; `/parts` and `/complete` hang off it and DELETE aborts it. - */ -interface Transfer { - basePath: string - uploadToken: string - partSize: number - partCount: number - size: number -} - -interface FileUpload { - id: string - uploadToken: string - partSize: number - partCount: number - file: { id: string } | null -} - -/** The parts endpoint signs at most this many URLs per request. */ -const PART_URL_BATCH = 100 - -/** - * Sends every part of a file to the storage URLs the API signs for it, and - * returns what `complete` needs to reassemble them. - * - * URLs are requested in batches because each one is short-lived: signing all - * 640 possible parts up front would leave the last ones expired by the time a - * slow connection reached them. - * - * Parts go out one at a time. Concurrency would be faster, but a failure - * mid-flight has to abort the whole transfer anyway, and a sequential loop - * makes "which part failed" unambiguous. - */ -async function uploadParts( - client: SimClient, - workspaceId: string, - transfer: Transfer, - blob: Blob -): Promise> { - const completed: Array<{ partNumber: number; etag?: string }> = [] - - for (let first = 1; first <= transfer.partCount; first += PART_URL_BATCH) { - const partNumbers = [] - for (let n = first; n < first + PART_URL_BATCH && n <= transfer.partCount; n++) { - partNumbers.push(n) - } - - const signed = await client.request<{ data: { parts: UploadPartUrl[] } }>( - `${transfer.basePath}/parts`, - { - method: 'POST', - query: { workspaceId }, - headers: { 'upload-token': transfer.uploadToken }, - body: { partNumbers }, - } - ) - - for (const part of signed.data.parts) { - const start = (part.partNumber - 1) * transfer.partSize - // `Blob.slice` is a view over the file on disk, so only the part being - // sent is ever read — the point of not buffering the upload. - const chunk = blob.slice(start, Math.min(start + transfer.partSize, transfer.size)) - - // boundary-raw-fetch: storage-signed URL on another origin, not the API - const response = await fetch(part.url, { - method: 'PUT', - headers: part.headers, - body: chunk, - }) - if (!response.ok) { - throw new SimApiError( - `Part ${part.partNumber} failed with status ${response.status}`, - response.status - ) - } - - // S3-compatible stores identify a part by the ETag they return; the API - // treats it as optional because not every backend sends one. - const etag = response.headers.get('etag')?.replace(/"/g, '') - completed.push(etag ? { partNumber: part.partNumber, etag } : { partNumber: part.partNumber }) - } - } - - return completed -} - -/** - * Runs a started transfer to completion: send the parts, then complete it. - * - * Anything that fails in between aborts the transfer, because a half-finished - * one holds storage the server would otherwise keep until it expires. A failed - * abort is swallowed — the original failure is what the caller needs to see. - */ -async function finishTransfer( - client: SimClient, - workspaceId: string, - transfer: Transfer, - path: string -): Promise { - try { - const blob = await openAsBlob(path) - const parts = await uploadParts(client, workspaceId, transfer, blob) - - const completed = await client.request<{ data: T }>(`${transfer.basePath}/complete`, { - method: 'POST', - query: { workspaceId }, - headers: { 'upload-token': transfer.uploadToken }, - body: { parts }, - }) - return completed.data - } catch (error) { - await client - .request(transfer.basePath, { - method: 'DELETE', - query: { workspaceId }, - headers: { 'upload-token': transfer.uploadToken }, - }) - .catch(() => undefined) - throw error - } -} - -interface TableImport { - id: string - status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' - tableId: string | null - rowsProcessed: number - error: string | null - upload: { uploadToken: string; partSize: number; partCount: number } | null -} - -interface ImportOptions { - name?: string - tableId?: string - mode?: string - folderId?: string - fileId?: string - mapping?: string - createColumns?: string - timezone?: string - /** commander sets this false for `--no-wait`. */ - wait: boolean -} - -/** - * Turns a file name into a legal table name. - * - * Table names are identifiers — `^[A-Za-z_][A-Za-z0-9_]*$`, 128 max — so the - * obvious `basename(path)` would reject most real files: `2026-sales.csv` and - * `customer data.csv` both fail. Runs of anything else collapse to a single - * underscore, and a leading digit gets one in front, so a default derived from - * the file is a name the server actually accepts. - */ -function tableNameFrom(fileName: string): string { - const stem = fileName.replace(/\.[^.]+$/, '') - const cleaned = stem.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '') - if (!cleaned) return 'imported_table' - return (/^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned).slice(0, 128) -} - -/** How often to ask an in-progress import where it got to. */ -const IMPORT_POLL_MS = 1500 - -/** Statuses the server will not move away from. */ -const IMPORT_SETTLED = new Set(['completed', 'failed', 'canceled', 'expired']) - -/** - * Parses a JSON flag through the same path the generated commands use, so - * `@file` and `@-` work here too rather than only on generated flags. - */ -function jsonFlag(raw: string, flagName: string): unknown { - return coerce(raw, { kind: 'object' }, { json: true }, flagName) -} - -/** - * Polls an import until it settles. - * - * The transfer only queues the work: rows are parsed server-side afterwards, so - * a command that returned at `complete` would report success for an import that - * goes on to fail on a malformed row. - */ -async function watchImport( - client: SimClient, - workspaceId: string, - job: TableImport -): Promise { - let current = job - let reported = -1 - - while (!IMPORT_SETTLED.has(current.status)) { - await new Promise((resolve) => setTimeout(resolve, IMPORT_POLL_MS)) - const next = await client.request<{ data: TableImport }>( - `/api/v2/tables/imports/${encodeURIComponent(current.id)}`, - { query: { workspaceId } } - ) - current = next.data - - // Only on a terminal, and only when it moves: the line rewrites itself with - // a carriage return, which in a redirected log is just escape noise. - if (process.stderr.isTTY && current.rowsProcessed !== reported) { - reported = current.rowsProcessed - process.stderr.write(`\r${chalk.dim(`${current.status}… ${reported} rows`)}\u001b[K`) - } - } - - if (process.stderr.isTTY && reported >= 0) process.stderr.write('\r\u001b[K') - return current -} - -/** Size and name checks every local-file transfer needs before starting one. */ -async function localFile(path: string, override?: string): Promise<{ name: string; size: number }> { - let size: number - try { - const stats = await stat(path) - if (stats.isDirectory()) throw new SimApiError(`${path} is a directory`, 0) - size = stats.size - } catch (error) { - if (error instanceof SimApiError) throw error - throw new SimApiError(`Cannot read ${path}: ${(error as Error).message}`, 0) - } - // A zero-byte transfer has no parts to send; the server cannot accept one. - if (size === 0) throw new SimApiError(`${path} is empty`, 0) - return { name: override ?? basename(path), size } -} - -export function attachHandWritten(program: Command): void { - // ── files upload ── a presigned multipart handshake, not one request ────── - group(program, 'files') - .command('upload ') - .description('Upload a file to the workspace') - .option('--folder-id ', 'Target folder (defaults to the workspace root)') - .option('--name ', 'Store it under a different name') - .action( - async (path: string, options: { folderId?: string; name?: string }, command: Command) => { - const { client } = clientFrom(command) - const workspaceId = client.requireWorkspace() - const { name, size } = await localFile(path, options.name) - - const created = await client.request<{ data: FileUpload }>('/api/v2/files/uploads', { - method: 'POST', - body: { - workspaceId, - name, - contentType: contentTypeFor(name), - size, - ...(options.folderId ? { folderId: options.folderId } : {}), - }, - }) - const upload = created.data - - const completed = await finishTransfer( - client, - workspaceId, - { - basePath: `/api/v2/files/uploads/${encodeURIComponent(upload.id)}`, - uploadToken: upload.uploadToken, - partSize: upload.partSize, - partCount: upload.partCount, - size, - }, - path - ) - - console.log(chalk.green(`✓ Uploaded ${name} (${completed.file?.id ?? completed.id})`)) - } - ) - - // ── tables import ── a transfer, then an async job to watch ────────────── - group(program, 'tables') - .command('import [path]') - .description('Import a CSV, into a new table by default') - .option('--name ', 'Name for the new table (defaults to the file name)') - .option('--table-id ', 'Import into this existing table instead of creating one') - .option('--mode ', 'How to write into --table-id (default: append)') - .option('--folder-id ', 'Folder for the new table') - .option('--file-id ', 'Import a file already in the workspace instead of a local path') - .option('--mapping ', 'Column mapping (--table-id only)') - .option('--create-columns ', 'Columns to create (--table-id only)') - .option('--timezone ', 'Timezone for date parsing, e.g. America/New_York') - .option('--no-wait', 'Return once the import is queued instead of watching it') - .action(async (path: string | undefined, options: ImportOptions, command: Command) => { - const { client } = clientFrom(command) - const workspaceId = client.requireWorkspace() - - // The one thing that cannot be inferred: the bytes are either local or - // already in the workspace, and neither implies the other. - if (Boolean(path) === Boolean(options.fileId)) { - throw new SimApiError('Pass exactly one of or --file-id ', 0) - } - - const intoExisting = Boolean(options.tableId) - - // Flags that only mean something for one target. Silently ignoring them - // would let `--mode replace` read as honoured while a new table is - // created beside the one it was meant to overwrite. - const misplaced = intoExisting - ? ([ - ['--name', options.name], - ['--folder-id', options.folderId], - ] as const) - : ([ - ['--mode', options.mode], - ['--mapping', options.mapping], - ['--create-columns', options.createColumns], - ] as const) - for (const [flag, value] of misplaced) { - if (value === undefined) continue - throw new SimApiError( - intoExisting - ? `${flag} applies to a new table; --table-id already names the destination` - : `${flag} applies to --table-id: a new table takes its name and columns from the CSV`, - 0 - ) - } - - const local = path ? await localFile(path, undefined) : null - const source = local - ? { - type: 'upload', - name: local.name, - contentType: contentTypeFor(local.name), - size: local.size, - } - : { type: 'workspace_file', fileId: options.fileId } - - let target: Record - if (intoExisting) { - target = { type: 'existing', tableId: options.tableId, mode: options.mode ?? 'append' } - } else { - // A local file names the table; a workspace file id does not, and - // guessing one from an id would produce nonsense. - const name = options.name ?? (local ? tableNameFrom(local.name) : undefined) - if (!name) { - throw new SimApiError('Pass --name to say what the new table is called', 0) - } - target = { type: 'new', name, ...(options.folderId ? { folderId: options.folderId } : {}) } - } - - const started = await client.request<{ data: TableImport }>('/api/v2/tables/imports', { - method: 'POST', - body: { - workspaceId, - source, - target, - ...(options.mapping ? { mapping: jsonFlag(options.mapping, 'mapping') } : {}), - ...(options.createColumns - ? { createColumns: jsonFlag(options.createColumns, 'create-columns') } - : {}), - ...(options.timezone ? { timezone: options.timezone } : {}), - }, - }) - - let job = started.data - - // A workspace_file source has nothing to upload — the bytes are already - // there, and the server starts the job without a transfer. - if (path && job.upload) { - job = await finishTransfer( - client, - workspaceId, - { - basePath: `/api/v2/tables/imports/${encodeURIComponent(job.id)}`, - uploadToken: job.upload.uploadToken, - partSize: job.upload.partSize, - partCount: job.upload.partCount, - size: local?.size ?? 0, - }, - path - ) - } - - if (!options.wait) { - console.log(chalk.green(`✓ Import ${job.id} ${job.status}`)) - return - } - - const finished = await watchImport(client, workspaceId, job) - if (finished.status !== 'completed') { - throw new SimApiError( - `Import ${finished.status}${finished.error ? `: ${finished.error}` : ''}`, - 0 - ) - } - console.log( - chalk.green( - `✓ Imported ${finished.rowsProcessed} rows${finished.tableId ? ` into ${finished.tableId}` : ''}` - ) - ) - }) - - // ── files download ── the response is binary, not the JSON envelope ──────── - group(program, 'files') - .command('download ') - .description('Download a file') - .option('-o, --output-file ', 'Where to write it (defaults to the file name)') - .option('--force', 'Overwrite the destination if it already exists') - .action( - async ( - fileId: string, - options: { outputFile?: string; force?: boolean }, - command: Command - ) => { - const { client, profile } = clientFrom(command) - const workspaceId = client.requireWorkspace() - - if (!profile.apiKey) { - throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0) - } - - const url = new URL(`${profile.endpoint}/api/v2/files/${encodeURIComponent(fileId)}`) - url.searchParams.set('workspaceId', workspaceId) - - const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } }) - if (!response.ok || !response.body) { - const raw = await response.text().catch(() => '') - throw new SimApiError( - raw || `Download failed with status ${response.status}`, - response.status - ) - } - - const target = - options.outputFile ?? - basename( - /filename="?([^";]+)"?/.exec(response.headers.get('content-disposition') ?? '')?.[1] ?? - fileId - ) - - // `wx` fails rather than truncating: a download that silently replaces an - // existing file is unrecoverable, and the name often comes from the - // server's content-disposition rather than anything the caller typed. - await streamToFile( - response.body, - createWriteStream(target, { flags: options.force ? 'w' : 'wx' }) - ) - console.log(chalk.green(`✓ Saved ${target}`)) - } - ) - - // ── tables rows list ── columns come from user-defined row data ─────────── - const tables = group(program, 'tables') - const rows = - tables.commands.find((command) => command.name() === 'rows') ?? tables.command('rows') - rows - .command('list ') - .description('List rows, with columns discovered from the data') - .option('--limit ', 'Maximum rows to return (0 for everything)', '100') - .action(async (tableId: string, options: { limit: string }, command: Command) => { - const { client, profile } = clientFrom(command) - const parsed = Number.parseInt(options.limit, 10) - if (Number.isNaN(parsed) || parsed < 0) { - throw new SimApiError('--limit must be a non-negative number', 0) - } - const limit = parsed === 0 ? Number.POSITIVE_INFINITY : parsed - - const collected: Row[] = [] - let cursor: string | null = null - do { - const page = (await client.request(`/api/v2/tables/${encodeURIComponent(tableId)}/rows`, { - query: { workspaceId: client.requireWorkspace(), cursor }, - })) as QueryRowsResponse - collected.push(...page.data) - cursor = page.nextCursor - } while (cursor && collected.length < limit) - - const page = Number.isFinite(limit) ? collected.slice(0, limit) : collected - printList(profile.output, page, rowColumns(page)) - }) -} diff --git a/packages/sim-cli/src/commands/protocol/files-download.test.ts b/packages/sim-cli/src/commands/protocol/files-download.test.ts new file mode 100644 index 00000000000..11b4f743bce --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/files-download.test.ts @@ -0,0 +1,107 @@ +import { createWriteStream, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../../runtime/build.js' +import { streamToFile } from './files-download.js' +import { attachProtocolCommands } from './index.js' + +const { output } = vi.hoisted(() => ({ + output: { format: 'json' }, +})) + +vi.mock('../../context.js', () => ({ + clientFrom: () => ({ + client: { request: vi.fn(), requireWorkspace: () => 'ws_local' }, + profile: { + workspaceId: 'ws_local', + output: output.format, + name: 'default', + apiKey: 'k', + endpoint: 'https://sim.example', + }, + }), +})) + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-dl-')) + output.format = 'json' +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + rmSync(dir, { recursive: true, force: true }) +}) + +function bodyOf(chunks: string[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)) + controller.close() + }, + }) +} + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachProtocolCommands(root) + return root +} + +describe('streamToFile', () => { + it('writes the body to disk', async () => { + const target = join(dir, 'out.txt') + await streamToFile(bodyOf(['hello ', 'world']), createWriteStream(target, { flags: 'wx' })) + expect(existsSync(target)).toBe(true) + }) + + it('refuses to clobber an existing file, naming --force', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'precious') + await expect( + streamToFile(bodyOf(['new']), createWriteStream(target, { flags: 'wx' })) + ).rejects.toThrow(/already exists.*--force/s) + }) + + it('overwrites when the caller asked for it', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'old') + await streamToFile(bodyOf(['new']), createWriteStream(target, { flags: 'w' })) + expect(existsSync(target)).toBe(true) + }) + + it.skipIf(!existsSync('/dev/full'))( + 'rejects when the final flush fails instead of reporting success', + async () => { + await expect( + streamToFile(bodyOf(['x'.repeat(64 * 1024)]), createWriteStream('/dev/full')) + ).rejects.toThrow(/Could not write/) + } + ) +}) + +describe('files download', () => { + it('prints a normalized machine-readable result', async () => { + const target = join(dir, 'download.txt') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('downloaded', { status: 200 }))) + const logged: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + await program().parseAsync([ + 'node', + 'sim', + 'file', + 'download', + 'file_1', + '--output-file', + target, + ]) + + expect(JSON.parse(logged[0])).toEqual({ id: 'file_1', path: target, status: 'saved' }) + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/files-download.ts b/packages/sim-cli/src/commands/protocol/files-download.ts new file mode 100644 index 00000000000..a2683bf735a --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/files-download.ts @@ -0,0 +1,96 @@ +import { once } from 'node:events' +import { createWriteStream, type WriteStream } from 'node:fs' +import { basename } from 'node:path' +import type { Command } from 'commander' +import { clientFrom } from '../../context.js' +import { SimApiError } from '../../http/client.js' +import { printProtocolResult } from './result.js' + +/** Streams a fetch body to disk while honoring write-stream backpressure. */ +export async function streamToFile( + body: ReadableStream, + file: WriteStream +): Promise { + const failed = new Promise((_resolve, reject) => { + file.once('error', reject) + }) + + const pump = (async () => { + const reader = body.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + if (!file.write(value)) await once(file, 'drain') + } + } finally { + reader.releaseLock() + } + + await new Promise((resolve, reject) => { + file.end((error?: Error | null) => (error ? reject(error) : resolve())) + }) + })() + + try { + await Promise.race([pump, failed]) + } catch (error) { + file.destroy() + const code = (error as NodeJS.ErrnoException).code + if (code === 'EEXIST') { + throw new SimApiError( + `${file.path} already exists. Pass --force to overwrite, or -o to write elsewhere.`, + 0 + ) + } + throw new SimApiError(`Could not write ${file.path}: ${(error as Error).message}`, 0) + } +} + +export function attachFileDownload(files: Command): void { + files + .command('download ') + .description('Download a file') + .option('-o, --output-file ', 'Where to write it (defaults to the file name)') + .option('--force', 'Overwrite the destination if it already exists') + .action( + async ( + fileId: string, + options: { outputFile?: string; force?: boolean }, + command: Command + ) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + + if (!profile.apiKey) { + throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0) + } + + const url = new URL(`${profile.endpoint}/api/v2/files/${encodeURIComponent(fileId)}`) + url.searchParams.set('workspaceId', workspaceId) + + // boundary-raw-fetch: binary download cannot pass through the JSON client + const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } }) + if (!response.ok || !response.body) { + const raw = await response.text().catch(() => '') + throw new SimApiError( + raw || `Download failed with status ${response.status}`, + response.status + ) + } + + const target = + options.outputFile ?? + basename( + /filename="?([^";]+)"?/.exec(response.headers.get('content-disposition') ?? '')?.[1] ?? + fileId + ) + + await streamToFile( + response.body, + createWriteStream(target, { flags: options.force ? 'w' : 'wx' }) + ) + printProtocolResult(profile.output, { id: fileId, path: target, status: 'saved' }) + } + ) +} diff --git a/packages/sim-cli/src/commands/protocol/files-upload.ts b/packages/sim-cli/src/commands/protocol/files-upload.ts new file mode 100644 index 00000000000..a0221efffb7 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -0,0 +1,59 @@ +import type { Command } from 'commander' +import { clientFrom } from '../../context.js' +import { contentTypeFor, localFile } from '../../transfer/local-file.js' +import { finishTransfer } from '../../transfer/multipart.js' +import { printProtocolResult } from './result.js' + +interface FileUpload { + id: string + uploadToken: string + partSize: number + partCount: number + file: { id: string } | null +} + +export function attachFileUpload(files: Command): void { + files + .command('upload ') + .description('Upload a file to the workspace') + .option('--folder-id ', 'Target folder (defaults to the workspace root)') + .option('--name ', 'Store it under a different name') + .action( + async (path: string, options: { folderId?: string; name?: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const { name, size } = await localFile(path, options.name) + + const created = await client.request<{ data: FileUpload }>('/api/v2/files/uploads', { + method: 'POST', + body: { + workspaceId, + name, + contentType: contentTypeFor(name), + size, + ...(options.folderId ? { folderId: options.folderId } : {}), + }, + }) + const upload = created.data + const completed = await finishTransfer( + client, + workspaceId, + { + basePath: `/api/v2/files/uploads/${encodeURIComponent(upload.id)}`, + uploadToken: upload.uploadToken, + partSize: upload.partSize, + partCount: upload.partCount, + size, + }, + path + ) + + printProtocolResult(profile.output, { + id: completed.file?.id ?? completed.id, + name, + size, + status: 'uploaded', + }) + } + ) +} diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts new file mode 100644 index 00000000000..d159f35633b --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -0,0 +1,20 @@ +import { Command } from 'commander' +import { attachFileDownload } from './files-download.js' +import { attachFileUpload } from './files-upload.js' +import { attachTableImport } from './tables-import.js' + +function group(program: Command, name: string): Command { + const existing = program.commands.find((command) => command.name() === name) + if (existing) return existing + const created = new Command(name) + program.addCommand(created) + return created +} + +/** Attaches commands whose multi-request or binary protocols cannot be generated. */ +export function attachProtocolCommands(program: Command): void { + const files = group(program, 'files') + attachFileUpload(files) + attachFileDownload(files) + attachTableImport(group(program, 'tables')) +} diff --git a/packages/sim-cli/src/commands/protocol/result.ts b/packages/sim-cli/src/commands/protocol/result.ts new file mode 100644 index 00000000000..304f852b394 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/result.ts @@ -0,0 +1,7 @@ +import type { OutputFormat } from '../../config/index.js' +import { printRecord, text } from '../../output/render.js' + +export function printProtocolResult(format: OutputFormat, result: Record): void { + const fields = Object.entries(result).map<[string, string]>(([key, value]) => [key, text(value)]) + printRecord(format, fields, result) +} diff --git a/packages/sim-cli/src/commands/protocol/tables-import.test.ts b/packages/sim-cli/src/commands/protocol/tables-import.test.ts new file mode 100644 index 00000000000..ae238855b34 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/tables-import.test.ts @@ -0,0 +1,110 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../../runtime/build.js' +import { attachProtocolCommands } from './index.js' + +const { mockRequest, output } = vi.hoisted(() => ({ + mockRequest: vi.fn(), + output: { format: 'json' }, +})) + +vi.mock('../../context.js', () => ({ + clientFrom: () => ({ + client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, + profile: { + workspaceId: 'ws_local', + output: output.format, + name: 'default', + apiKey: 'k', + endpoint: 'https://sim.example', + }, + }), +})) + +beforeEach(() => { + vi.restoreAllMocks() + mockRequest.mockReset() + output.format = 'json' +}) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachProtocolCommands(root) + const override = (command: Command) => { + command.exitOverride() + command.commands.forEach(override) + } + override(root) + return root +} + +async function runImport(argv: string[]) { + await program().parseAsync(['node', 'sim', 'table', 'import', ...argv]) +} + +describe('tables import argument guards', () => { + it('refuses to guess the source', async () => { + await expect(runImport([])).rejects.toThrow(/exactly one of /) + await expect(runImport(['f.csv', '--file-id', 'w_1'])).rejects.toThrow(/exactly one of /) + }) + + it('rejects existing-table flags when creating one', async () => { + await expect(runImport(['f.csv', '--mode', 'replace'])).rejects.toThrow(/applies to --table-id/) + await expect(runImport(['f.csv', '--mapping', '{}'])).rejects.toThrow(/applies to --table-id/) + await expect(runImport(['f.csv', '--create-columns', '{}'])).rejects.toThrow( + /applies to --table-id/ + ) + }) + + it('rejects new-table flags when importing into an existing one', async () => { + await expect(runImport(['f.csv', '--table-id', 't', '--name', 'x'])).rejects.toThrow( + /--table-id already names the destination/ + ) + await expect(runImport(['f.csv', '--table-id', 't', '--folder-id', 'f'])).rejects.toThrow( + /--table-id already names the destination/ + ) + }) + + it('asks for a name when there is no file name to take one from', async () => { + await expect(runImport(['--file-id', 'w_1'])).rejects.toThrow(/--name /) + }) + + it('checks target options before touching the filesystem', async () => { + await expect(runImport(['f.csv', '--mode', 'append'])).rejects.toThrow(/applies to --table-id/) + }) + + it('rejects an invalid import mode before making a request', async () => { + await expect( + runImport(['--file-id', 'w_1', '--name', 'Customers', '--mode', 'merge']) + ).rejects.toThrow(/allowed choices are append, replace/i) + expect(mockRequest).not.toHaveBeenCalled() + }) +}) + +describe('tables import output', () => { + it('prints a normalized result without transfer secrets', async () => { + mockRequest.mockResolvedValue({ + data: { + id: 'import_1', + status: 'queued', + tableId: 'table_1', + rowsProcessed: 0, + error: null, + upload: null, + }, + }) + const logged: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + await runImport(['--file-id', 'file_1', '--name', 'Customers', '--no-wait']) + + expect(JSON.parse(logged[0])).toEqual({ + id: 'import_1', + status: 'queued', + tableId: 'table_1', + rowsProcessed: 0, + }) + expect(logged[0]).not.toContain('uploadToken') + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts new file mode 100644 index 00000000000..9c2ca5b0cc7 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -0,0 +1,201 @@ +import { setTimeout as sleep } from 'node:timers/promises' +import chalk from 'chalk' +import { type Command, Option } from 'commander' +import { clientFrom } from '../../context.js' +import { SimApiError, type SimClient } from '../../http/client.js' +import { coerce } from '../../runtime/request.js' +import { contentTypeFor, localFile } from '../../transfer/local-file.js' +import { finishTransfer } from '../../transfer/multipart.js' +import { printProtocolResult } from './result.js' + +interface TableImport { + id: string + status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + tableId: string | null + rowsProcessed: number + error: string | null + upload: { uploadToken: string; partSize: number; partCount: number } | null +} + +interface ImportOptions { + name?: string + tableId?: string + mode?: string + folderId?: string + fileId?: string + mapping?: string + createColumns?: string + timezone?: string + wait: boolean +} + +const IMPORT_POLL_MS = 1500 +const IMPORT_SETTLED = new Set(['completed', 'failed', 'canceled', 'expired']) + +function tableNameFrom(fileName: string): string { + const stem = fileName.replace(/\.[^.]+$/, '') + const cleaned = stem.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '') + if (!cleaned) return 'imported_table' + return (/^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned).slice(0, 128) +} + +function jsonFlag(raw: string, flagName: string): unknown { + return coerce(raw, { kind: 'object' }, { json: true }, flagName) +} + +async function watchImport( + client: SimClient, + workspaceId: string, + job: TableImport +): Promise { + let current = job + let reported = -1 + + while (!IMPORT_SETTLED.has(current.status)) { + await sleep(IMPORT_POLL_MS) + const next = await client.request<{ data: TableImport }>( + `/api/v2/tables/imports/${encodeURIComponent(current.id)}`, + { query: { workspaceId } } + ) + current = next.data + if (process.stderr.isTTY && current.rowsProcessed !== reported) { + reported = current.rowsProcessed + process.stderr.write(`\r${chalk.dim(`${current.status}… ${reported} rows`)}\u001b[K`) + } + } + + if (process.stderr.isTTY && reported >= 0) process.stderr.write('\r\u001b[K') + return current +} + +function validateTargetOptions(options: ImportOptions): boolean { + const intoExisting = Boolean(options.tableId) + const misplaced = intoExisting + ? ([ + ['--name', options.name], + ['--folder-id', options.folderId], + ] as const) + : ([ + ['--mode', options.mode], + ['--mapping', options.mapping], + ['--create-columns', options.createColumns], + ] as const) + + for (const [flag, value] of misplaced) { + if (value === undefined) continue + throw new SimApiError( + intoExisting + ? `${flag} applies to a new table; --table-id already names the destination` + : `${flag} applies to --table-id: a new table takes its name and columns from the CSV`, + 0 + ) + } + return intoExisting +} + +export function attachTableImport(tables: Command): void { + tables + .command('import [path]') + .description('Import a CSV, into a new table by default') + .option( + '--name ', + 'Identifier for the new table: letters, numbers, and underscores; defaults to the sanitized file name' + ) + .option('--table-id ', 'Import into this existing table instead of creating one') + .addOption( + new Option( + '--mode ', + 'How to write into --table-id (default: append)' + ).choices(['append', 'replace']) + ) + .option('--folder-id ', 'Folder for the new table') + .option('--file-id ', 'Import a file already in the workspace instead of a local path') + .option('--mapping ', 'Column mapping (--table-id only)') + .option('--create-columns ', 'Columns to create (--table-id only)') + .option('--timezone ', 'Timezone for date parsing, e.g. America/New_York') + .option('--no-wait', 'Return once the import is queued instead of watching it') + .action(async (path: string | undefined, options: ImportOptions, command: Command) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + + if (Boolean(path) === Boolean(options.fileId)) { + throw new SimApiError('Pass exactly one of or --file-id ', 0) + } + + const intoExisting = validateTargetOptions(options) + const local = path ? await localFile(path) : null + const source = local + ? { + type: 'upload', + name: local.name, + contentType: contentTypeFor(local.name), + size: local.size, + } + : { type: 'workspace_file', fileId: options.fileId } + + let target: Record + if (intoExisting) { + target = { type: 'existing', tableId: options.tableId, mode: options.mode ?? 'append' } + } else { + const name = options.name ?? (local ? tableNameFrom(local.name) : undefined) + if (!name) { + throw new SimApiError('Pass --name to say what the new table is called', 0) + } + target = { type: 'new', name, ...(options.folderId ? { folderId: options.folderId } : {}) } + } + + const started = await client.request<{ data: TableImport }>('/api/v2/tables/imports', { + method: 'POST', + body: { + workspaceId, + source, + target, + ...(options.mapping ? { mapping: jsonFlag(options.mapping, 'mapping') } : {}), + ...(options.createColumns + ? { createColumns: jsonFlag(options.createColumns, 'create-columns') } + : {}), + ...(options.timezone ? { timezone: options.timezone } : {}), + }, + }) + + let job = started.data + if (path && job.upload) { + job = await finishTransfer( + client, + workspaceId, + { + basePath: `/api/v2/tables/imports/${encodeURIComponent(job.id)}`, + uploadToken: job.upload.uploadToken, + partSize: job.upload.partSize, + partCount: job.upload.partCount, + size: local?.size ?? 0, + }, + path + ) + } + + if (!options.wait) { + printProtocolResult(profile.output, { + id: job.id, + status: job.status, + tableId: job.tableId, + rowsProcessed: job.rowsProcessed, + }) + return + } + + const finished = await watchImport(client, workspaceId, job) + if (finished.status !== 'completed') { + throw new SimApiError( + `Import ${finished.status}${finished.error ? `: ${finished.error}` : ''}`, + 0 + ) + } + printProtocolResult(profile.output, { + id: finished.id, + status: finished.status, + tableId: finished.tableId, + rowsProcessed: finished.rowsProcessed, + }) + }) +} diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index c74c16c4132..26a2ba3e57a 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -1,5 +1,11 @@ import type { CliContract } from './types.js' +const TABLE_NAME_HELP = 'Identifier: letters, numbers, and underscores; cannot start with a number' +const TABLE_FILTER_HELP = + 'Predicate tree using all/any and operators eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, or isNotNull' +const CUSTOM_TOOL_SCHEMA_HELP = + 'OpenAI function schema: {"type":"function","function":{"name":"...","parameters":{"type":"object","properties":{}}}}' + /** * The CLI contract for the v2 surface. * @@ -20,13 +26,19 @@ export const CLI_CONTRACT: CliContract = { deleteTableRows: { command: 'tables rows batch-delete', describe: 'Delete rows matching a filter, or an explicit list of ids', - flags: { rowIds: { name: 'row', list: true }, filter: { json: true } }, + flags: { + rowIds: { name: 'row', list: true }, + filter: { json: true, describe: TABLE_FILTER_HELP }, + }, confirm: 'This deletes every matching row and cannot be undone.', }, updateRowsByFilter: { command: 'tables rows batch-update', describe: 'Update every row matching a filter', - flags: { filter: { json: true }, data: { json: true } }, + flags: { + filter: { json: true, describe: TABLE_FILTER_HELP }, + data: { json: true }, + }, confirm: 'This updates every matching row and cannot be undone.', }, // `DELETE /workflows/[id]/deploy` is an undeploy, not a delete. @@ -38,7 +50,10 @@ export const CLI_CONTRACT: CliContract = { // ─── Destructive single-resource operations ─────────────────────────────── deleteTable: { confirm: 'This deletes the table and all of its rows.' }, deleteTableRow: { confirm: 'This deletes the row.' }, - deleteTableColumn: { confirm: 'This deletes the column and its values in every row.' }, + deleteTableColumn: { + confirm: 'This deletes the column and its values in every row.', + fields: [{ header: 'remaining columns', path: 'columns', format: 'count' }], + }, deleteKnowledgeBase: { confirm: 'This deletes the knowledge base and every document in it.' }, deleteKnowledgeDocument: { confirm: 'This deletes the document and its embeddings.' }, deleteFile: { confirm: 'This archives the file.' }, @@ -56,6 +71,11 @@ export const CLI_CONTRACT: CliContract = { // Not just the grouping: the documented behaviour is that every column the // group fed goes with it, values included. confirm: 'This deletes the group, every column it fed, and the values in them.', + fields: [ + { header: 'id' }, + { header: 'deleted', format: 'bool' }, + { header: 'remaining columns', path: 'columns', format: 'count' }, + ], }, deleteFolder: { // The route archives the folder *and cascades to its contents*, so this is @@ -82,9 +102,36 @@ export const CLI_CONTRACT: CliContract = { { header: 'execution', path: 'executionId' }, ], }, + getLog: { + describe: 'Show a log summary (execution data is available in JSON or YAML output)', + fields: [ + { header: 'id' }, + { header: 'execution', path: 'executionId' }, + { header: 'workflow', path: 'workflow.name' }, + { header: 'level' }, + { header: 'trigger' }, + { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'ended', path: 'endedAt', format: 'timestamp' }, + { header: 'duration', path: 'totalDurationMs', format: 'duration' }, + { header: 'cost', path: 'cost.total', format: 'cost' }, + { header: 'files', format: 'count' }, + ], + }, searchKnowledge: { // Accepts a string or an array on the wire; the CLI always sends the array. - flags: { knowledgeBaseIds: { name: 'kb', list: true }, tagFilters: { json: true } }, + flags: { + knowledgeBaseIds: { name: 'kb', list: true, describe: 'Knowledge base ID (repeatable)' }, + query: { describe: 'Text to search for' }, + tagFilters: { + json: true, + describe: 'Tag filters as [{"tagName":"...","operator":"...","value":"..."}]', + }, + searchMode: { + choices: ['vector', 'hybrid'], + describe: 'Search algorithm', + }, + }, + itemsPath: 'results', columns: [ { header: 'score', path: 'similarity' }, { header: 'document', path: 'documentName' }, @@ -104,11 +151,26 @@ export const CLI_CONTRACT: CliContract = { }, queryRows: { command: 'tables rows query', - flags: { predicate: { name: 'filter', json: true }, sort: { json: true } }, + flags: { + predicate: { name: 'filter', json: true, describe: TABLE_FILTER_HELP }, + sort: { json: true }, + }, // A row's cells live under `data`; without this the table showed an id and // two timestamps per row and none of the content anyone ran the query for. expand: 'data', }, + createTable: { + flags: { + name: { describe: TABLE_NAME_HELP }, + schema: { + json: true, + describe: 'Table schema: {"columns":[{"name":"email","type":"string"}]}', + }, + }, + }, + updateTable: { flags: { name: { describe: TABLE_NAME_HELP } } }, + createCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, + updateCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, // ─── Output columns for list commands ───────────────────────────────────── listTables: { @@ -140,6 +202,7 @@ export const CLI_CONTRACT: CliContract = { { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, ], }, + listTableRows: { expand: 'data' }, listKnowledgeBases: { columns: [ { header: 'id' }, @@ -176,14 +239,14 @@ export const CLI_CONTRACT: CliContract = { { header: 'id' }, { header: 'name' }, { header: 'description' }, - { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + { header: 'built-in', path: 'readOnly', format: 'bool' }, ], }, listCustomTools: { columns: [ { header: 'id' }, - { header: 'name' }, - { header: 'description' }, + { header: 'name', path: 'title' }, + { header: 'description', path: 'schema.function.description' }, { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ], }, @@ -198,8 +261,8 @@ export const CLI_CONTRACT: CliContract = { listCredentials: { columns: [ { header: 'id' }, - { header: 'name' }, - { header: 'provider' }, + { header: 'name', path: 'displayName' }, + { header: 'provider', path: 'providerId' }, { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ], }, @@ -240,6 +303,9 @@ export const CLI_CONTRACT: CliContract = { updateFileContent: { command: 'files set-content', describe: 'Replace a file’s contents', + flags: { + encoding: { choices: ['utf-8', 'base64'], describe: 'Content encoding' }, + }, }, getFileShare: { command: 'files share get', @@ -255,9 +321,23 @@ export const CLI_CONTRACT: CliContract = { // path all put a verb where the deriver expects a sub-resource, so each became // a group holding a lone `create`. cancelTableRuns: { command: 'tables cancel-runs', describe: 'Stop every running column job' }, - findTableRows: { command: 'tables rows find', describe: 'Find rows matching a predicate' }, + findTableRows: { + command: 'tables rows find', + describe: 'Find rows matching a predicate', + flags: { + q: { describe: 'Value to find' }, + predicate: { name: 'filter', json: true, describe: TABLE_FILTER_HELP }, + sort: { json: true }, + }, + itemsPath: 'matches', + columns: [{ header: 'ordinal' }, { header: 'row', path: 'rowId' }, { header: 'column' }], + }, restoreTable: { command: 'tables restore', describe: 'Restore a deleted table' }, - runTableColumn: { command: 'tables columns run', describe: 'Run a column’s workflow' }, + runTableColumn: { + command: 'tables columns run', + describe: 'Run a column’s workflow', + flags: { filter: { json: true, describe: TABLE_FILTER_HELP } }, + }, runRowEnrichment: { command: 'tables rows enrich', describe: 'Run one row’s enrichment group', diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 2460a351b4d..1f585d15b98 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -46,6 +46,8 @@ export interface FlagSpec { json?: boolean /** Overrides the help text otherwise taken from the OpenAPI description. */ describe?: string + /** Accepted values when the generated descriptor cannot recover an enum. */ + choices?: readonly string[] /** * Never expose this field as a flag, and never send it. * @@ -64,7 +66,7 @@ export interface ColumnSpec { /** Dot path into the row. Defaults to `header`. */ path?: string /** Rendering hint; `auto` inspects the value. */ - format?: 'auto' | 'timestamp' | 'bytes' | 'duration' | 'bool' | 'cost' + format?: 'auto' | 'timestamp' | 'bytes' | 'duration' | 'bool' | 'cost' | 'count' } export interface CommandSpec { @@ -79,6 +81,10 @@ export interface CommandSpec { flags?: Record /** Columns for table output. Omit on non-list commands to print a record. */ columns?: ColumnSpec[] + /** Fields shown for a single record in human formats. Machine output stays raw. */ + fields?: ColumnSpec[] + /** Dot path to a nested result array rendered as the command's human list. */ + itemsPath?: string /** * Require `--yes`. The message should say what is about to be destroyed — * the point is that the caller can tell whether they meant it. diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 9e36ea232db..f031dfc39a1 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -1,7 +1,86 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { CLI_CONTRACT } from '../contract/commands.js' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' -import { resolvePath, SimApiError } from './client.js' +import { formatApiErrorDetails, resolvePath, SimApiError, SimClient } from './client.js' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('API errors', () => { + it('keeps structured details and does not misdiagnose an ordinary 404', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: 'NOT_FOUND', + message: 'Workflow not found', + details: { id: 'missing' }, + }, + }), + { status: 404 } + ) + ) + ) + const client = new SimClient({ + name: 'default', + endpoint: 'https://sim.example', + apiKey: 'key', + workspaceId: 'ws_1', + output: 'json', + sources: { + endpoint: 'default', + apiKey: 'env', + workspaceId: 'env', + output: 'default', + }, + }) + + const request = client.request('/api/v2/workflows/missing') + await expect(request).rejects.toMatchObject({ + message: 'Workflow not found', + code: 'NOT_FOUND', + details: { id: 'missing' }, + }) + await expect(request).rejects.not.toThrow(/v2 API may not be enabled/) + }) + + it('turns nested validation details into concise path-aware lines', () => { + const lines = formatApiErrorDetails([ + { + code: 'invalid_union', + path: ['predicate'], + message: 'Invalid input', + errors: [ + [ + { + code: 'invalid_union', + path: ['all', 0], + message: 'Invalid input', + errors: [ + [ + { + code: 'invalid_value', + path: ['op'], + message: 'Expected one of eq, ne', + }, + ], + ], + }, + ], + ], + }, + ]) + + expect(lines).toEqual([' details:', ' predicate.all.0.op: Expected one of eq, ne']) + }) + + it('keeps non-validation details as JSON', () => { + expect(formatApiErrorDetails({ id: 'missing' })).toEqual([' details: {"id":"missing"}']) + }) +}) describe('resolvePath', () => { it('substitutes a path parameter', () => { diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index 96b640a43ac..af14b433da7 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -91,6 +91,40 @@ function truncate(value: string, max: number): string { return value.length <= max ? value : `${value.slice(0, max)}…` } +/** Formats nested validation issues as readable, path-aware lines. */ +export function formatApiErrorDetails(details: unknown): string[] { + const issues = new Set() + + const visit = (value: unknown, parentPath: string[] = []): void => { + if (Array.isArray(value)) { + value.forEach((item) => visit(item, parentPath)) + return + } + if (!value || typeof value !== 'object') return + + const issue = value as Record + const ownPath = Array.isArray(issue.path) ? issue.path.map(String) : [] + const path = [...parentPath, ...ownPath] + const nested = Array.isArray(issue.errors) ? issue.errors : [] + + if (nested.length > 0) { + visit(nested, path) + return + } + if (typeof issue.message !== 'string' || issue.message === 'Invalid input') return + + issues.add(`${path.length > 0 ? path.join('.') : 'request'}: ${issue.message}`) + } + + visit(details) + if (issues.size === 0) return [` details: ${truncate(JSON.stringify(details), 1000)}`] + + const visible = [...issues].slice(0, 8) + const lines = [' details:', ...visible.map((issue) => ` ${issue}`)] + if (issues.size > visible.length) lines.push(` … ${issues.size - visible.length} more issues`) + return lines +} + export class SimClient { constructor(private readonly profile: ResolvedProfile) {} @@ -155,13 +189,6 @@ export class SimClient { if (response.status === 401) { error.message = `${error.message} — run: sim login --profile ${this.profile.name}` } - if (response.status === 404) { - // The v2 surface is behind a rollout flag that answers 404 when the - // caller is not in the cohort — deliberately indistinguishable from a - // missing resource, so the CLI cannot tell which happened. Offered as a - // possibility rather than a diagnosis; a plain bad id 404s identically. - error.message = `${error.message}\n If every command returns this, the v2 API may not be enabled for your account yet.` - } throw error } diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 6d2185e70d8..85e67d3635c 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -4,8 +4,9 @@ import chalk from 'chalk' import { Command } from 'commander' import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth.js' import { configureCommand } from './commands/configure.js' -import { attachHandWritten } from './commands/hand-written.js' -import { SimApiError } from './http/client.js' +import { attachProtocolCommands } from './commands/protocol/index.js' +import { formatApiErrorDetails, SimApiError } from './http/client.js' +import { sanitize } from './output/render.js' import { buildGeneratedCommands } from './runtime/build.js' const program = new Command() @@ -24,23 +25,11 @@ program.addCommand(whoamiCommand()) program.addCommand(profilesCommand()) program.addCommand(configureCommand()) -/** - * Leaves owned by hand-written commands, which the generated runtime skips. - * - * Each is here because generation genuinely cannot produce it, not because it - * has not been migrated: `files download` streams binary rather than JSON, and - * `tables rows list` discovers its columns from user-defined row data at - * runtime with a nested `data` object the generic renderer would flatten badly. - */ -const HAND_WRITTEN = new Set(['files download', 'tables rows list']) - -for (const command of buildGeneratedCommands(HAND_WRITTEN)) { +for (const command of buildGeneratedCommands()) { program.addCommand(command) } -// Added after the generated groups so their leaves merge into the same group -// object rather than creating a duplicate top-level command. -attachHandWritten(program) +attachProtocolCommands(program) program.addHelpText( 'after', @@ -54,7 +43,7 @@ Examples: $ sim workflows list $ sim logs list --level error --limit 20 $ sim configure --set-output json Output format is a profile setting - $ sim knowledge search "refund policy" --kb kb_123 + $ sim knowledge search --query "refund policy" --kb kb_123 $ sim workflows export wf_123 > wf.json JSON flags read files with @ $ sim workflows import --workflow @wf.json $ sim whoami --profile dev @@ -73,6 +62,11 @@ async function main() { if (error instanceof SimApiError) { console.error(chalk.red(`Error: ${error.message}`)) if (error.code) console.error(chalk.dim(` code: ${error.code}`)) + if (error.details !== undefined) { + for (const line of formatApiErrorDetails(error.details)) { + console.error(chalk.dim(sanitize(line))) + } + } process.exit(1) } throw error diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts index 57e78bc8395..ce554b63b5c 100644 --- a/packages/sim-cli/src/output/render.test.ts +++ b/packages/sim-cli/src/output/render.test.ts @@ -94,6 +94,13 @@ describe('printList', () => { expect(JSON.parse(logged[0])).toEqual([{ name: 'alpha', status: 'error' }]) }) + it('can preserve a containing response for machine output', () => { + const rows = [{ name: 'alpha', status: 'error' }] + const response = { results: rows, totalResults: 1 } + printList('json', rows, COLUMNS, response) + expect(JSON.parse(logged[0])).toEqual(response) + }) + it('prints the raw rows for yaml too', () => { printList('yaml', [{ name: 'alpha', status: 'error' }], COLUMNS) expect(load(logged[0])).toEqual([{ name: 'alpha', status: 'error' }]) diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index 011c9a8155b..5235356e2e5 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -219,8 +219,13 @@ function renderMachine(format: OutputFormat, raw: unknown): string | null { * rather than the raw values on purpose: it is a human-ish format for shell * plumbing, and a raw ISO timestamp or byte count is worse in that context. */ -export function printList(format: OutputFormat, rows: T[], columns: Column[]): void { - const machine = renderMachine(format, rows) +export function printList( + format: OutputFormat, + rows: T[], + columns: Column[], + raw: unknown = rows +): void { + const machine = renderMachine(format, raw) if (machine !== null) { console.log(machine) return diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 15cc616340e..df782306a19 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -27,7 +27,7 @@ vi.mock('../context.js', () => ({ function program(): Command { const root = new Command('sim').exitOverride() - for (const group of buildGeneratedCommands(new Set())) root.addCommand(group) + for (const group of buildGeneratedCommands()) root.addCommand(group) // Recursively, not just on the root: a parse error raised by a leaf (an // unknown option, an excess argument) exits the process otherwise, which a // test cannot assert on. @@ -39,9 +39,19 @@ function program(): Command { return root } -async function run(argv: string[]) { +function commandAt(...names: string[]): Command { + let current = program() + for (const name of names) { + const next = current.commands.find((command) => command.name() === name) + if (!next) throw new Error(`Missing command ${names.join(' ')}`) + current = next + } + return current +} + +async function run(argv: string[], response: unknown = { data: [], nextCursor: null }) { mockRequest.mockReset() - mockRequest.mockResolvedValue({ data: [], nextCursor: null }) + mockRequest.mockResolvedValue(response) vi.spyOn(console, 'log').mockImplementation(() => {}) await program().parseAsync(['node', 'sim', ...argv]) return mockRequest.mock.calls[0] @@ -59,6 +69,37 @@ describe('commands parsed through commander', () => { expect(options.query).toMatchObject({ minDurationMs: 250 }) }) + it('registers singular aliases for every plural resource group', () => { + const aliases = { + 'audit-logs': 'audit-log', + credentials: 'credential', + 'custom-tools': 'custom-tool', + files: 'file', + folders: 'folder', + logs: 'log', + 'mcp-servers': 'mcp-server', + skills: 'skill', + tables: 'table', + workflows: 'workflow', + } + + for (const [name, alias] of Object.entries(aliases)) { + expect( + program() + .commands.find((command) => command.name() === name) + ?.alias() + ).toBe(alias) + } + }) + + it('dispatches generated commands through their singular resource alias', async () => { + const [tablePath] = await run(['table', 'list']) + expect(tablePath).toBe('/api/v2/tables') + + const [filePath] = await run(['file', 'list']) + expect(filePath).toBe('/api/v2/files') + }) + it('carries every multi-word flag on a command, not just the first', async () => { const [, options] = await run([ 'logs', @@ -118,6 +159,40 @@ describe('commands parsed through commander', () => { ) expect(mockRequest).not.toHaveBeenCalled() }) + + it('marks required flags in help and rejects omissions before a request', async () => { + const help = commandAt('tables', 'create').helpInformation() + expect(help).toMatch(/--name.*required/s) + expect(help).toMatch(/--schema.*required/s) + + await expect(run(['tables', 'create', '--name', 'Customers'])).rejects.toThrow( + /required option '--schema/ + ) + expect(mockRequest).not.toHaveBeenCalled() + }) + + it('shows repeated values and recovered enum choices accurately', async () => { + const help = commandAt('knowledge', 'search').helpInformation() + expect(help).toContain('--kb ') + expect(help).not.toMatch(/--kb[^\n]*JSON/) + expect(help).toMatch(/--search-mode.*vector.*hybrid/s) + + await expect( + run(['knowledge', 'search', '--kb', 'kb_1', '--search-mode', 'semantic']) + ).rejects.toThrow(/allowed choices are vector, hybrid/i) + + const [, options] = await run( + ['knowledge', 'search', '--kb', 'kb_1', '--search-mode', 'hybrid'], + { data: { results: [] } } + ) + expect(options.body).toMatchObject({ knowledgeBaseIds: ['kb_1'], searchMode: 'hybrid' }) + }) + + it('advertises the file-content encoding choices', () => { + expect(commandAt('files', 'set-content').helpInformation()).toMatch( + /--encoding.*utf-8.*base64/s + ) + }) }) describe('single-resource rendering', () => { @@ -214,6 +289,101 @@ describe('single-resource rendering', () => { expect(JSON.parse(printed[0])).toEqual({ row: { id: 'r1' }, operation: 'inserted' }) }) + + it('keeps sensitive execution data out of human log output', async () => { + const log = { + id: 'log_1', + executionId: 'exec_1', + workflow: { name: 'Billing' }, + level: 'info', + trigger: 'api', + startedAt: '2026-08-04T00:00:00.000Z', + endedAt: null, + totalDurationMs: 50, + cost: { total: 0.001 }, + files: [], + executionData: { env: { SECRET_TOKEN: 'encrypted-value' } }, + } + + const human = await lines(['logs', 'get', 'log_1'], log, 'text') + expect(human.join('\n')).not.toContain('executionData') + expect(human.join('\n')).not.toContain('SECRET_TOKEN') + + const machine = await lines(['logs', 'get', 'log_1'], log, 'json') + expect(JSON.parse(machine[0])).toMatchObject({ executionData: log.executionData }) + }) +}) + +describe('contract-selected list rendering', () => { + async function lines(argv: string[], data: unknown): Promise { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ data }) + output.format = 'text' + const captured: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => captured.push(line)) + try { + await program().parseAsync(['node', 'sim', ...argv]) + } finally { + output.format = 'json' + } + return captured + } + + it('renders knowledge results as rows instead of a truncated JSON blob', async () => { + const printed = await lines(['knowledge', 'search', '--kb', 'kb_1', '--query', 'refund'], { + results: [ + { + similarity: 0.91, + documentName: 'policy.md', + chunkIndex: 2, + content: 'Refunds are available for 30 days.', + }, + ], + query: 'refund', + totalResults: 1, + }) + + expect(printed).toEqual(['0.91\tpolicy.md\t2\tRefunds are available for 30 days.']) + }) + + it('renders row matches as rows', async () => { + const printed = await lines(['tables', 'rows', 'find', 'tbl_1', '--q', 'alice'], { + matches: [{ ordinal: 3, rowId: 'row_1', column: 'email' }], + truncated: false, + }) + + expect(printed).toEqual(['3\trow_1\temail']) + }) + + it('maps custom-tool and credential fields to their actual response paths', async () => { + const tools = await lines( + ['custom-tools', 'list'], + [ + { + id: 'tool_1', + title: 'Lookup', + schema: { function: { description: 'Find a customer' } }, + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ] + ) + expect(tools[0]).toContain('Lookup') + expect(tools[0]).toContain('Find a customer') + + const credentials = await lines( + ['credentials', 'list'], + [ + { + id: 'cred_1', + displayName: 'Production Stripe', + providerId: 'stripe', + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ] + ) + expect(credentials[0]).toContain('Production Stripe') + expect(credentials[0]).toContain('stripe') + }) }) describe('pagination slot', () => { @@ -250,6 +420,18 @@ describe('pagination slot', () => { expect(mockRequest.mock.calls[1][1].query).toMatchObject({ cursor: 'c1' }) }) + + it('uses a valid per-page size for unlimited and large totals', async () => { + for (const requested of ['0', '250']) { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ data: [], nextCursor: null }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program().parseAsync(['node', 'sim', 'files', 'list', '--limit', requested]) + + expect(mockRequest.mock.calls[0][1].query.limit).toBe(100) + } + }) }) describe('rows whose content sits in a wrapper', () => { @@ -277,6 +459,25 @@ describe('rows whose content sits in a wrapper', () => { expect(lines[0]).toContain('A') expect(lines[1]).toContain('E') }) + + it('uses the generated list command for table rows', async () => { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ + data: [{ id: 'r1', data: { email: 'a@example.com' } }], + nextCursor: null, + }) + const lines: string[] = [] + output.format = 'text' + vi.spyOn(console, 'log').mockImplementation((line: string) => lines.push(line)) + try { + await program().parseAsync(['node', 'sim', 'tables', 'rows', 'list', 'tbl_1']) + } finally { + output.format = 'json' + } + + expect(lines[0]).toContain('a@example.com') + expect(mockRequest.mock.calls[0][0]).toBe('/api/v2/tables/tbl_1/rows') + }) }) describe('boolean flags', () => { diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index a1da31835c5..bbcf7ae6534 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -1,453 +1,80 @@ -import { Command, Option } from 'commander' -import { clientFrom } from '../context.js' +import { Command } from 'commander' import { CLI_CONTRACT } from '../contract/commands.js' -import type { ColumnSpec, CommandSpec } from '../contract/types.js' +import type { CommandSpec } from '../contract/types.js' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' -import { SimApiError, type V2Page } from '../http/client.js' -import { - bytes, - type Column, - duration, - printDocument, - printList, - printRecord, - sanitize, - text, - timestamp, -} from '../output/render.js' import { deriveCommandPath } from './derive.js' -import { - buildRequest, - type FieldSpec, - flagNameFor, - flagSpecFor, - PROFILE_INJECTED_FIELD, - takesJson, -} from './request.js' - -/** Default page size when a list command is run without `--limit`. */ -const DEFAULT_LIMIT = 100 - -/** Reads `a.b.c` out of a row, tolerating a missing link anywhere along the way. */ -function at(row: unknown, path: string): unknown { - return path - .split('.') - .reduce( - (value, key) => (value && typeof value === 'object' ? (value as never)[key] : undefined), - row - ) -} - -function renderCell(value: unknown, format: ColumnSpec['format']): string { - switch (format) { - case 'timestamp': - return timestamp(value as string | null) - case 'bytes': - return bytes(value as number | null) - case 'duration': - return duration(value as number | null) - case 'bool': - return value === null || value === undefined ? text(null) : value ? 'yes' : 'no' - case 'cost': - return typeof value === 'number' ? `$${value.toFixed(4)}` : text(null) - default: - if (value === null || value === undefined || value === '') return text(null) - // Server-supplied: strip terminal control sequences before it can reach a tty. - return sanitize(typeof value === 'object' ? JSON.stringify(value) : String(value)) - } -} - -/** - * How wide a nested value may get before a record line stops being readable. - * A workflow's `state` serializes to tens of kilobytes on one line. - */ -const NESTED_CELL_WIDTH = 160 - -/** - * A field in a record view. - * - * Nested values are rendered, not skipped: a record that quietly omits half of - * what the server sent is worse than a long line, because nothing tells the - * caller anything is missing. Long ones are cut with an ellipsis — visibly - * partial, and `sim configure --set-output json` prints them whole. - */ -function recordCell(value: unknown): string { - const rendered = renderCell(value, 'auto') - return rendered.length > NESTED_CELL_WIDTH ? `${rendered.slice(0, NESTED_CELL_WIDTH)}…` : rendered -} - -function columnsFrom(specs: ColumnSpec[]): Column[] { - return specs.map((spec) => ({ - header: spec.header, - value: (row: unknown) => renderCell(at(row, spec.path ?? spec.header), spec.format), - })) -} - -/** - * Columns for a list command with none declared in the contract. - * - * Row shapes are only known at runtime here — a table's `data` is user-defined — - * so the keys are unioned across the page rather than read off the first row, - * which would let a sparse row hide every column it happens to omit. Nested - * values are skipped: they render as JSON blobs and make the table unreadable — - * unless the contract names one with `expand`, which is how a row's cells reach - * the table. - */ -function inferColumns(rows: unknown[], expand?: string): Column[] { - const paths: Array<{ path: string; header: string }> = [] - const seen = new Set() - - for (const row of rows) { - if (!row || typeof row !== 'object') continue - for (const [key, value] of Object.entries(row)) { - if (seen.has(key)) continue - if (value !== null && typeof value === 'object') continue - seen.add(key) - paths.push({ path: key, header: key }) - } - } - - // The wrapper named by `expand` holds the only content the caller cares about; - // the loop above skipped it for being an object, which is how `tables rows - // query` came back showing nothing but ids and timestamps. - if (expand) { - const nested = new Set() - for (const row of rows) { - const container = at(row, expand) - if (!container || typeof container !== 'object' || Array.isArray(container)) continue - for (const key of Object.keys(container)) { - if (nested.has(key)) continue - nested.add(key) - // A user-defined key that shadows a top-level one is shown by its full - // path, so two different values never appear under one header. - paths.push({ path: `${expand}.${key}`, header: seen.has(key) ? `${expand}.${key}` : key }) - } - } - } - - return paths.map(({ path, header }) => ({ - // The key itself is remote data when the rows are user-defined, and the - // header is printed just like a cell — sanitizing values but not headers - // left the same control sequences executable one row higher. - header: sanitize(header), - value: (row: unknown) => renderCell(at(row, path), 'auto'), - })) -} - -/** - * Unwraps the single-key envelope several v2 responses put their resource in — - * `{ mcpServer }`, `{ knowledgeBase }`, `{ row }`, `{ document }`, `{ table }`. - * - * Without this the record renderer sees one key whose value is an object, - * filters it out as non-scalar, and prints nothing at all: `sim mcp-servers - * create` exited 0 having created the server and said nothing about it. - * - * Only a lone key is unwrapped. A payload with siblings (`{ row, operation }` - * from upsert) is a real multi-field result and is rendered as it stands. - */ -function unwrapResource(data: unknown): unknown { - if (!data || typeof data !== 'object' || Array.isArray(data)) return data - const entries = Object.entries(data) - if (entries.length !== 1) return data - const [, value] = entries[0] - return value && typeof value === 'object' && !Array.isArray(value) ? value : data -} - -/** Whether the operation's body is one the generator could not describe field by field. */ -function opaqueBody(spec: object): boolean { - return (spec as { opaqueBody?: boolean }).opaqueBody === true -} - -/** The operation's one-line help, taken from the OpenAPI summary at generation time. */ -function summaryFor(operation: V2OperationName): string | undefined { - return (V2_OPERATIONS[operation] as { summary?: string }).summary -} - -/** - * Which request slot carries the pagination cursor, or null for a non-list - * operation. - * - * Both slots have to be checked: most lists take `cursor` as a query param, but - * `queryRows` is a POST whose whole filter — cursor included — is in the body. - * Looking only at the query made it fall through to the single-request path, - * which then rendered its array of rows through `printRecord` and printed - * nothing at all, and never auto-paged. - */ -function cursorSlot(operation: V2OperationName): 'query' | 'body' | null { - const spec = V2_OPERATIONS[operation] as { - query?: Record - body?: Record - } - if (spec.query && 'cursor' in spec.query) return 'query' - if (spec.body && 'cursor' in spec.body) return 'body' - return null -} - -/** Adds the flags a field needs, or nothing when the contract omits it. */ -function addFieldOption( - command: Command, - operation: V2OperationName, - field: string, - descriptor: FieldSpec -): void { - // Never a flag: it comes from the profile, and `cursor`/`limit` are owned by - // the auto-pager rather than exposed as raw request fields. - if (field === PROFILE_INJECTED_FIELD || field === 'cursor') return - - const flag = flagSpecFor(operation, field) - if (flag.omit) return - - const name = flagNameFor(operation, field) - const short = flag.short ? `-${flag.short}, ` : '' - - // The pager owns `--limit`, but only where `limit` means a page size. The - // name is not reserved: `runTableColumn` takes `limit: { type, max }`, and - // claiming it here turned that into a numeric flag that defaulted to 100 and - // made every invocation fail with "expected object, received number". - if (field === 'limit' && (descriptor.kind === 'number' || descriptor.kind === 'integer')) { - command.option( - `--limit `, - 'Maximum items to return (0 for everything)', - String(DEFAULT_LIMIT) - ) - return - } - - if (descriptor.kind === 'boolean') { - // A required boolean is a state to set, not a switch to flip on: it takes - // the value explicitly. As a presence-only flag it could only ever send - // `true`, so `--is-active false` set sharing ON — commander read the flag as - // true and dropped the `false` as a stray argument. - if (descriptor.required) { - command.addOption( - new Option(`${short}--${name} `, flag.describe ?? `Set ${field}`).choices([ - 'true', - 'false', - ]) - ) - return - } - - // Optional booleans stay presence-flags — `--deployed-only` reads better - // than `--deployed-only true` — but every one of them also gets a negation, - // because for a state field (`enabled`, `locked`) omitting the flag means - // "leave it alone", which is not the same as setting it false. Without this - // there was no way to disable an MCP server or unlock a folder. - command.option(`${short}--${name}`, flag.describe ?? `Set ${field}`) - command.option(`--no-${name}`, `Set ${field} to false`) - return - } - - const takesList = flag.list === true - const wantsJson = takesJson(descriptor, flag) - const placeholder = takesList ? `` : wantsJson ? `` : `` - const describe = - (flag.describe ?? - (descriptor.values ? `One of: ${descriptor.values.join(', ')}` : `Set ${field}`)) + - // Otherwise the only way to discover `@file` is to read the source. A JSON - // document big enough to want a file is exactly when help gets consulted. - (wantsJson ? ' (JSON, or @path / @- to read a file or stdin)' : '') - - const option = new Option(`${short}--${name} ${placeholder}`, describe) - if (descriptor.values && !takesList) option.choices([...descriptor.values]) - if (descriptor.default !== undefined && field !== 'limit') { - option.default(undefined, String(descriptor.default)) - } - command.addOption(option) +import { executeOperation } from './execute.js' +import { addOperationOptions } from './options.js' +import type { OperationSpec } from './types.js' + +const GROUP_ALIASES: Readonly> = { + 'audit-logs': 'audit-log', + credentials: 'credential', + 'custom-tools': 'custom-tool', + files: 'file', + folders: 'folder', + logs: 'log', + 'mcp-servers': 'mcp-server', + skills: 'skill', + tables: 'table', + workflows: 'workflow', } -/** - * Builds one leaf command for an operation. - * - * The action closure is the whole runtime: coerce and assemble the request, - * auto-page it when the response is a cursor list, then render through whatever - * the contract says about columns. - */ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: string): Command { - const operationSpec = V2_OPERATIONS[operation] as { - method: string - pathParams: readonly string[] - query?: Record - body?: Record - } + const operationSpec = V2_OPERATIONS[operation] as OperationSpec + const command = new Command(leafName).allowExcessArguments(false) - // `new Command('upsert ')` would make the whole string the command's - // NAME, so `sim tables upsert` would never match it and would silently fall - // through to the group's help. Arguments have to be declared separately. - const command = new Command(leafName) - // Commander ignores arguments beyond those declared. That silence is how - // `--is-active false` ran as though the `false` had never been typed; an - // argument the command has no meaning for is a mistake worth stopping on. - command.allowExcessArguments(false) for (const param of operationSpec.pathParams) { command.argument(`<${param}>`) } command.description( - spec.describe ?? - summaryFor(operation) ?? - `${operationSpec.method} ${V2_OPERATIONS[operation].path}` + spec.describe ?? operationSpec.summary ?? `${operationSpec.method} ${operationSpec.path}` ) + addOperationOptions(command, operation, spec, operationSpec) + command.action((...invocation: unknown[]) => + executeOperation(operation, spec, operationSpec, invocation) + ) + return command +} - for (const slot of ['query', 'body'] as const) { - for (const [field, descriptor] of Object.entries(operationSpec[slot] ?? {})) { - addFieldOption(command, operation, field, descriptor) - } - } - - // A body the generator could not break into fields is offered whole. The - // union behind `tables rows create` (one row, or a batch) has no field list - // to build flags from, and without this the command sent no body at all and - // the server rejected the request as malformed JSON. - if (opaqueBody(operationSpec)) { - command.requiredOption( - '--body ', - 'Request body as JSON (or @path / @- to read a file or stdin)' - ) - } - - if (spec.confirm) { - command.option('-y, --yes', 'Skip the confirmation') - } - - command.action(async (...invocation: unknown[]) => { - // commander passes positionals, then the options object, then the Command. - const host = invocation[invocation.length - 1] as Command - const flags = invocation[invocation.length - 2] as Record - const positional = invocation.slice(0, operationSpec.pathParams.length) as string[] - - if (spec.confirm && !flags.yes) { - throw new SimApiError(`${spec.confirm} Re-run with --yes to confirm.`, 0) - } - - const { client, profile } = clientFrom(host) - // `requireWorkspace` checks the key first on purpose, so a fresh install is - // told to log in rather than to set a workspace it cannot use yet. Reading - // `profile.workspaceId` directly skipped that ordering. - const needsWorkspace = Boolean( - (operationSpec.query && PROFILE_INJECTED_FIELD in operationSpec.query) || - (operationSpec.body && PROFILE_INJECTED_FIELD in operationSpec.body) - ) - const request = buildRequest( - operation, - positional, - flags, - needsWorkspace ? client.requireWorkspace() : profile.workspaceId - ) - - const paging = cursorSlot(operation) - if (paging) { - const rawLimit = Number.parseInt(String(flags.limit ?? DEFAULT_LIMIT), 10) - if (Number.isNaN(rawLimit) || rawLimit < 0) { - throw new SimApiError('--limit must be a non-negative number', 0) - } - // 0 means everything; Infinity lets the loop run until the cursor dries up. - const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit - - const rows: unknown[] = [] - let cursor: string | null = null - do { - // The cursor goes back in whichever slot the contract declared it. - const page: V2Page = await client.request(request.path, { - method: operationSpec.method as 'GET' | 'POST', - query: paging === 'query' ? { ...request.query, cursor } : request.query, - body: - paging === 'body' - ? { ...(request.body ?? {}), ...(cursor ? { cursor } : {}) } - : request.body, - }) - rows.push(...page.data) - cursor = page.nextCursor - } while (cursor && rows.length < limit) - - const page = Number.isFinite(limit) ? rows.slice(0, limit) : rows - printList( - profile.output, - page, - spec.columns ? columnsFrom(spec.columns) : inferColumns(page, spec.expand) - ) - return - } - - const result = await client.request<{ data?: unknown }>(request.path, { - method: operationSpec.method as 'GET' | 'POST', - query: request.query, - body: request.body, - }) - const raw = result?.data ?? result - - if (spec.document) { - printDocument(profile.output, raw) - return - } - - const data = unwrapResource(raw) - - if (Array.isArray(data)) { - // Reached when a non-paginated operation answers with a collection. - // `printRecord` would silently print nothing for an array. - printList( - profile.output, - data, - spec.columns ? columnsFrom(spec.columns) : inferColumns(data, spec.expand) - ) - return - } +function groupFor(groups: Map, name: string): Command { + const existing = groups.get(name) + if (existing) return existing - // Every field, nested ones included. Filtering to scalars here is what made - // `workflows export` print its two timestamps and drop the actual workflow. - const fields: Array<[string, string]> = - data && typeof data === 'object' - ? Object.entries(data).map(([key, value]) => [key, recordCell(value)]) - : [] + const group = new Command(name) + const alias = GROUP_ALIASES[name] + if (alias) group.alias(alias) + groups.set(name, group) + return group +} - printRecord(profile.output, fields, data) - }) +function nestedGroup(parent: Command, name: string): Command { + const existing = parent.commands.find((candidate) => candidate.name() === name) + if (existing) return existing - return command + const created = new Command(name) + parent.addCommand(created) + return created } -/** - * Builds every command the contract and the generated operation table describe. - * - * Iterates `V2_OPERATIONS`, not the contract — an operation added to a Zod - * contract shows up here after `generate:cli-api` with no CLI edit at all. The - * contract is consulted only for the things a schema cannot say. - * - * `reserved` are groups owned by hand-written commands (`files download` streams - * binary, `logs get` prints a trace). A generated leaf never displaces one. - */ -export function buildGeneratedCommands(reserved: ReadonlySet): Command[] { +/** Builds every JSON command described by the generated operation table. */ +export function buildGeneratedCommands(): Command[] { const groups = new Map() for (const operation of Object.keys(V2_OPERATIONS) as V2OperationName[]) { const spec = CLI_CONTRACT[operation] ?? {} - if (spec.hidden) continue - // Non-JSON responses (binary downloads) need a bespoke consumer. - if (V2_OPERATIONS[operation].responseMode !== 'json') continue + const operationSpec = V2_OPERATIONS[operation] as OperationSpec + if (spec.hidden || operationSpec.responseMode !== 'json') continue const segments = spec.command ? spec.command.split(' ') : deriveCommandPath(operation) const [groupName, ...rest] = segments const leafName = rest.join(' ') || 'run' + const group = groupFor(groups, groupName) - if (reserved.has(`${groupName} ${leafName}`)) continue - - let group = groups.get(groupName) - if (!group) { - group = new Command(groupName) - groups.set(groupName, group) - } - - // A multi-word leaf (`rows batch-delete`) nests one more level so help reads - // as a tree rather than a flat list of hyphenated names. if (rest.length > 1) { const [subName, ...tail] = rest - let sub = group.commands.find((candidate) => candidate.name() === subName) - if (!sub) { - sub = new Command(subName) - group.addCommand(sub) - } - sub.addCommand(buildLeaf(operation, spec, tail.join(' '))) + nestedGroup(group, subName).addCommand(buildLeaf(operation, spec, tail.join(' '))) continue } diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts new file mode 100644 index 00000000000..0454c896319 --- /dev/null +++ b/packages/sim-cli/src/runtime/execute.ts @@ -0,0 +1,80 @@ +import type { Command } from 'commander' +import { clientFrom } from '../context.js' +import type { CommandSpec } from '../contract/types.js' +import type { V2OperationName } from '../generated/v2-api.js' +import { SimApiError, type V2Page } from '../http/client.js' +import { DEFAULT_LIMIT } from './options.js' +import { buildRequest, PROFILE_INJECTED_FIELD } from './request.js' +import { renderPage, renderResult } from './result.js' +import type { OperationSpec } from './types.js' + +function cursorSlot(operationSpec: OperationSpec): 'query' | 'body' | null { + if (operationSpec.query && 'cursor' in operationSpec.query) return 'query' + if (operationSpec.body && 'cursor' in operationSpec.body) return 'body' + return null +} + +/** Executes a parsed generated command, including cursor pagination. */ +export async function executeOperation( + operation: V2OperationName, + commandSpec: CommandSpec, + operationSpec: OperationSpec, + invocation: unknown[] +): Promise { + const host = invocation[invocation.length - 1] as Command + const flags = invocation[invocation.length - 2] as Record + const positional = invocation.slice(0, operationSpec.pathParams.length) as string[] + + if (commandSpec.confirm && !flags.yes) { + throw new SimApiError(`${commandSpec.confirm} Re-run with --yes to confirm.`, 0) + } + + const { client, profile } = clientFrom(host) + const needsWorkspace = Boolean( + (operationSpec.query && PROFILE_INJECTED_FIELD in operationSpec.query) || + (operationSpec.body && PROFILE_INJECTED_FIELD in operationSpec.body) + ) + const request = buildRequest( + operation, + positional, + flags, + needsWorkspace ? client.requireWorkspace() : profile.workspaceId + ) + const paging = cursorSlot(operationSpec) + + if (paging) { + const rawLimit = Number.parseInt(String(flags.limit ?? DEFAULT_LIMIT), 10) + if (Number.isNaN(rawLimit) || rawLimit < 0) { + throw new SimApiError('--limit must be a non-negative number', 0) + } + + const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit + const pageSize = Math.min(Number.isFinite(limit) ? limit : DEFAULT_LIMIT, DEFAULT_LIMIT) + const pageLimit = 'limit' in (operationSpec[paging] ?? {}) ? { limit: pageSize } : {} + const rows: unknown[] = [] + let cursor: string | null = null + + do { + const page: V2Page = await client.request(request.path, { + method: operationSpec.method, + query: paging === 'query' ? { ...request.query, ...pageLimit, cursor } : request.query, + body: + paging === 'body' + ? { ...(request.body ?? {}), ...pageLimit, ...(cursor ? { cursor } : {}) } + : request.body, + }) + rows.push(...page.data) + cursor = page.nextCursor + } while (cursor && rows.length < limit) + + renderPage(profile.output, Number.isFinite(limit) ? rows.slice(0, limit) : rows, commandSpec) + return + } + + const result = await client.request<{ data?: unknown }>(request.path, { + method: operationSpec.method, + query: request.query, + body: request.body, + }) + renderResult(operation, profile.output, result?.data ?? result, commandSpec) +} diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts new file mode 100644 index 00000000000..c3a393ec6af --- /dev/null +++ b/packages/sim-cli/src/runtime/options.ts @@ -0,0 +1,98 @@ +import { type Command, Option } from 'commander' +import type { CommandSpec } from '../contract/types.js' +import type { V2OperationName } from '../generated/v2-api.js' +import { + type FieldSpec, + flagNameFor, + flagSpecFor, + PROFILE_INJECTED_FIELD, + takesJson, +} from './request.js' +import type { OperationSpec } from './types.js' + +export const DEFAULT_LIMIT = 100 + +function addFieldOption( + command: Command, + operation: V2OperationName, + field: string, + descriptor: FieldSpec +): void { + if (field === PROFILE_INJECTED_FIELD || field === 'cursor') return + + const flag = flagSpecFor(operation, field) + if (flag.omit) return + + const name = flagNameFor(operation, field) + const short = flag.short ? `-${flag.short}, ` : '' + + if (field === 'limit' && (descriptor.kind === 'number' || descriptor.kind === 'integer')) { + command.option( + '--limit ', + 'Maximum items to return (0 for everything)', + String(DEFAULT_LIMIT) + ) + return + } + + if (descriptor.kind === 'boolean') { + if (descriptor.required) { + command.addOption( + new Option( + `${short}--${name} `, + `${flag.describe ?? `Set ${field}`} (required)` + ) + .choices(['true', 'false']) + .makeOptionMandatory() + ) + return + } + + command.option(`${short}--${name}`, flag.describe ?? `Set ${field}`) + command.option(`--no-${name}`, `Set ${field} to false`) + return + } + + const takesList = flag.list === true + const wantsJson = takesJson(descriptor, flag) + const placeholder = takesList ? '' : wantsJson ? '' : '' + const choices = flag.choices ?? descriptor.values + const describe = `${ + flag.describe ?? (choices ? `One of: ${choices.join(', ')}` : `Set ${field}`) + }${wantsJson && !takesList ? ' (JSON, or @path / @- to read a file or stdin)' : ''}${ + descriptor.required ? ' (required)' : '' + }` + + const option = new Option(`${short}--${name} ${placeholder}`, describe) + if (choices && !takesList) option.choices([...choices]) + if (descriptor.default !== undefined && field !== 'limit') { + option.default(undefined, String(descriptor.default)) + } + if (descriptor.required) option.makeOptionMandatory() + command.addOption(option) +} + +/** Adds request-field and safety options for one generated operation. */ +export function addOperationOptions( + command: Command, + operation: V2OperationName, + commandSpec: CommandSpec, + operationSpec: OperationSpec +): void { + for (const slot of ['query', 'body'] as const) { + for (const [field, descriptor] of Object.entries(operationSpec[slot] ?? {})) { + addFieldOption(command, operation, field, descriptor) + } + } + + if (operationSpec.opaqueBody) { + command.requiredOption( + '--body ', + 'Request body as JSON (or @path / @- to read a file or stdin) (required)' + ) + } + + if (commandSpec.confirm) { + command.option('-y, --yes', 'Skip the confirmation') + } +} diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 286c7bd8d8b..7470db8bb79 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -144,6 +144,17 @@ describe('repeated flags encode per the field kind, not uniformly', () => { }) }) +describe('contract-provided choices', () => { + it('validates an enum the generator could not recover', () => { + const field: FieldSpec = { kind: 'enum' } + const flag = { choices: ['vector', 'hybrid'] } as const + expect(coerce('hybrid', field, flag, 'search-mode')).toBe('hybrid') + expect(() => coerce('semantic', field, flag, 'search-mode')).toThrow( + '--search-mode must be one of: vector, hybrid' + ) + }) +}) + describe('JSON flags that name a file', () => { const field: FieldSpec = { kind: 'object' } diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index fa7743bde31..292d413bf10 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -169,8 +169,9 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: if (field.kind === 'boolean') return raw === true || raw === 'true' - if (field.kind === 'enum' && field.values && !field.values.includes(String(raw))) { - throw new SimApiError(`--${flagName} must be one of: ${field.values.join(', ')}`, 0) + const choices = flag.choices ?? field.values + if (choices && !choices.includes(String(raw))) { + throw new SimApiError(`--${flagName} must be one of: ${choices.join(', ')}`, 0) } return raw diff --git a/packages/sim-cli/src/runtime/result.ts b/packages/sim-cli/src/runtime/result.ts new file mode 100644 index 00000000000..e9b990e6f2d --- /dev/null +++ b/packages/sim-cli/src/runtime/result.ts @@ -0,0 +1,159 @@ +import type { OutputFormat } from '../config/index.js' +import type { ColumnSpec, CommandSpec } from '../contract/types.js' +import type { V2OperationName } from '../generated/v2-api.js' +import { + bool, + bytes, + type Column, + duration, + printDocument, + printList, + printRecord, + sanitize, + text, + timestamp, +} from '../output/render.js' + +function at(row: unknown, path: string): unknown { + return path + .split('.') + .reduce( + (value, key) => (value && typeof value === 'object' ? (value as never)[key] : undefined), + row + ) +} + +function renderCell(value: unknown, format: ColumnSpec['format']): string { + switch (format) { + case 'timestamp': + return timestamp(value as string | null) + case 'bytes': + return bytes(value as number | null) + case 'duration': + return duration(value as number | null) + case 'bool': + return bool(value as boolean | null) + case 'cost': + return typeof value === 'number' ? `$${value.toFixed(4)}` : text(null) + case 'count': + return Array.isArray(value) ? String(value.length) : text(null) + default: + if (value === null || value === undefined || value === '') return text(null) + return sanitize(typeof value === 'object' ? JSON.stringify(value) : String(value)) + } +} + +const NESTED_CELL_WIDTH = 160 + +function recordCell(value: unknown): string { + const rendered = renderCell(value, 'auto') + return rendered.length > NESTED_CELL_WIDTH ? `${rendered.slice(0, NESTED_CELL_WIDTH)}…` : rendered +} + +function columnsFrom(specs: ColumnSpec[]): Column[] { + return specs.map((spec) => ({ + header: spec.header, + value: (row: unknown) => renderCell(at(row, spec.path ?? spec.header), spec.format), + })) +} + +function fieldsFrom(data: unknown, specs: ColumnSpec[]): Array<[string, string]> { + return specs.map((spec) => [ + spec.header, + renderCell(at(data, spec.path ?? spec.header), spec.format), + ]) +} + +function inferColumns(rows: unknown[], expand?: string): Column[] { + const paths: Array<{ path: string; header: string }> = [] + const seen = new Set() + + for (const row of rows) { + if (!row || typeof row !== 'object') continue + for (const [key, value] of Object.entries(row)) { + if (seen.has(key)) continue + if (value !== null && typeof value === 'object') continue + seen.add(key) + paths.push({ path: key, header: key }) + } + } + + if (expand) { + const nested = new Set() + for (const row of rows) { + const container = at(row, expand) + if (!container || typeof container !== 'object' || Array.isArray(container)) continue + for (const key of Object.keys(container)) { + if (nested.has(key)) continue + nested.add(key) + paths.push({ path: `${expand}.${key}`, header: seen.has(key) ? `${expand}.${key}` : key }) + } + } + } + + return paths.map(({ path, header }) => ({ + header: sanitize(header), + value: (row: unknown) => renderCell(at(row, path), 'auto'), + })) +} + +function unwrapResource(data: unknown): unknown { + if (!data || typeof data !== 'object' || Array.isArray(data)) return data + const entries = Object.entries(data) + if (entries.length !== 1) return data + const [, value] = entries[0] + return value && typeof value === 'object' && !Array.isArray(value) ? value : data +} + +export function renderPage(format: OutputFormat, rows: unknown[], spec: CommandSpec): void { + printList( + format, + rows, + spec.columns ? columnsFrom(spec.columns) : inferColumns(rows, spec.expand) + ) +} + +/** Renders one non-paginated operation result according to its CLI contract. */ +export function renderResult( + operation: V2OperationName, + format: OutputFormat, + raw: unknown, + spec: CommandSpec +): void { + if (spec.document) { + printDocument(format, raw) + return + } + + const data = unwrapResource(raw) + if (spec.itemsPath) { + const items = at(data, spec.itemsPath) + if (!Array.isArray(items)) { + throw new Error(`${operation} expected an array at response path ${spec.itemsPath}`) + } + printList( + format, + items, + spec.columns ? columnsFrom(spec.columns) : inferColumns(items, spec.expand), + data + ) + return + } + + if (Array.isArray(data)) { + printList( + format, + data, + spec.columns ? columnsFrom(spec.columns) : inferColumns(data, spec.expand) + ) + return + } + + const fields = spec.fields + ? fieldsFrom(data, spec.fields) + : data && typeof data === 'object' + ? Object.entries(data).map<[string, string]>(([key, value]) => [key, recordCell(value)]) + : [] + + printRecord(format, fields, data) +} diff --git a/packages/sim-cli/src/runtime/types.ts b/packages/sim-cli/src/runtime/types.ts new file mode 100644 index 00000000000..c9d98db84fc --- /dev/null +++ b/packages/sim-cli/src/runtime/types.ts @@ -0,0 +1,13 @@ +import type { RequestOptions } from '../http/client.js' +import type { FieldSpec } from './request.js' + +export interface OperationSpec { + method: NonNullable + path: string + pathParams: readonly string[] + query?: Record + body?: Record + opaqueBody?: boolean + summary?: string + responseMode?: 'json' | 'binary' +} diff --git a/packages/sim-cli/src/transfer/local-file.ts b/packages/sim-cli/src/transfer/local-file.ts new file mode 100644 index 00000000000..2d29d31c9a3 --- /dev/null +++ b/packages/sim-cli/src/transfer/local-file.ts @@ -0,0 +1,49 @@ +import { stat } from 'node:fs/promises' +import { basename } from 'node:path' +import { SimApiError } from '../http/client.js' + +const CONTENT_TYPES: Record = { + css: 'text/css', + csv: 'text/csv', + gif: 'image/gif', + html: 'text/html', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + js: 'text/javascript', + json: 'application/json', + md: 'text/markdown', + pdf: 'application/pdf', + png: 'image/png', + svg: 'image/svg+xml', + txt: 'text/plain', + webp: 'image/webp', + yaml: 'application/yaml', + yml: 'application/yaml', + zip: 'application/zip', +} + +export function contentTypeFor(name: string): string { + const dot = name.lastIndexOf('.') + const extension = dot === -1 ? '' : name.slice(dot + 1).toLowerCase() + return CONTENT_TYPES[extension] ?? 'application/octet-stream' +} + +export interface LocalFile { + name: string + size: number +} + +/** Validates the size and name shared by every local-file transfer. */ +export async function localFile(path: string, override?: string): Promise { + let size: number + try { + const stats = await stat(path) + if (stats.isDirectory()) throw new SimApiError(`${path} is a directory`, 0) + size = stats.size + } catch (error) { + if (error instanceof SimApiError) throw error + throw new SimApiError(`Cannot read ${path}: ${(error as Error).message}`, 0) + } + if (size === 0) throw new SimApiError(`${path} is empty`, 0) + return { name: override ?? basename(path), size } +} diff --git a/packages/sim-cli/src/transfer/multipart.ts b/packages/sim-cli/src/transfer/multipart.ts new file mode 100644 index 00000000000..20a7cfa1b3e --- /dev/null +++ b/packages/sim-cli/src/transfer/multipart.ts @@ -0,0 +1,96 @@ +import { openAsBlob } from 'node:fs' +import { SimApiError, type SimClient } from '../http/client.js' + +interface UploadPartUrl { + partNumber: number + url: string + headers: Record +} + +export interface Transfer { + basePath: string + uploadToken: string + partSize: number + partCount: number + size: number +} + +const PART_URL_BATCH = 100 + +async function uploadParts( + client: SimClient, + workspaceId: string, + transfer: Transfer, + blob: Blob +): Promise> { + const completed: Array<{ partNumber: number; etag?: string }> = [] + + for (let first = 1; first <= transfer.partCount; first += PART_URL_BATCH) { + const partNumbers = [] + for (let n = first; n < first + PART_URL_BATCH && n <= transfer.partCount; n++) { + partNumbers.push(n) + } + + const signed = await client.request<{ data: { parts: UploadPartUrl[] } }>( + `${transfer.basePath}/parts`, + { + method: 'POST', + query: { workspaceId }, + headers: { 'upload-token': transfer.uploadToken }, + body: { partNumbers }, + } + ) + + for (const part of signed.data.parts) { + const start = (part.partNumber - 1) * transfer.partSize + const chunk = blob.slice(start, Math.min(start + transfer.partSize, transfer.size)) + + // boundary-raw-fetch: storage-signed URL on another origin, not the API + const response = await fetch(part.url, { + method: 'PUT', + headers: part.headers, + body: chunk, + }) + if (!response.ok) { + throw new SimApiError( + `Part ${part.partNumber} failed with status ${response.status}`, + response.status + ) + } + + const etag = response.headers.get('etag')?.replace(/"/g, '') + completed.push(etag ? { partNumber: part.partNumber, etag } : { partNumber: part.partNumber }) + } + } + + return completed +} + +/** Uploads and completes a multipart transfer, aborting it if either step fails. */ +export async function finishTransfer( + client: SimClient, + workspaceId: string, + transfer: Transfer, + path: string +): Promise { + try { + const blob = await openAsBlob(path) + const parts = await uploadParts(client, workspaceId, transfer, blob) + const completed = await client.request<{ data: T }>(`${transfer.basePath}/complete`, { + method: 'POST', + query: { workspaceId }, + headers: { 'upload-token': transfer.uploadToken }, + body: { parts }, + }) + return completed.data + } catch (error) { + await client + .request(transfer.basePath, { + method: 'DELETE', + query: { workspaceId }, + headers: { 'upload-token': transfer.uploadToken }, + }) + .catch(() => undefined) + throw error + } +} From 4e132f09d9439e5b1482a5d2aa625300d3a7b21f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 12:31:50 -0700 Subject: [PATCH 062/159] feat(cli): support knowledge document uploads --- packages/sim-cli/README.md | 1 + .../sim-cli/src/commands/protocol/index.ts | 2 + .../knowledge-document-upload.test.ts | 205 ++++++++++++++++ .../protocol/knowledge-document-upload.ts | 96 ++++++++ packages/sim-cli/src/contract/commands.ts | 8 +- packages/sim-cli/src/generated/v2-api.ts | 231 ++++++++++++++++++ packages/sim-cli/src/http/client.test.ts | 1 + packages/sim-cli/src/transfer/local-file.ts | 8 + 8 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index bf7c3f400c0..77f0f045f3c 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -136,6 +136,7 @@ sim files delete sim knowledge list sim knowledge get sim knowledge documents [--search ] +sim knowledge documents upload [--tag ...] sim knowledge search --query --kb … [--search-mode vector|hybrid] ``` diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index d159f35633b..5ad2647d06a 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -1,6 +1,7 @@ import { Command } from 'commander' import { attachFileDownload } from './files-download.js' import { attachFileUpload } from './files-upload.js' +import { attachKnowledgeDocumentUpload } from './knowledge-document-upload.js' import { attachTableImport } from './tables-import.js' function group(program: Command, name: string): Command { @@ -16,5 +17,6 @@ export function attachProtocolCommands(program: Command): void { const files = group(program, 'files') attachFileUpload(files) attachFileDownload(files) + attachKnowledgeDocumentUpload(group(group(program, 'knowledge'), 'documents')) attachTableImport(group(program, 'tables')) } diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts new file mode 100644 index 00000000000..4dd4e68c865 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts @@ -0,0 +1,205 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../../runtime/build.js' +import { attachProtocolCommands } from './index.js' + +const { mockRequest } = vi.hoisted(() => ({ + mockRequest: vi.fn(), +})) + +vi.mock('../../context.js', () => ({ + clientFrom: () => ({ + client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, + profile: { + workspaceId: 'ws_local', + output: 'json', + name: 'default', + apiKey: 'k', + endpoint: 'https://sim.example', + }, + }), +})) + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-kb-upload-')) + mockRequest.mockReset() +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + rmSync(dir, { recursive: true, force: true }) +}) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachProtocolCommands(root) + const override = (command: Command) => { + command.exitOverride() + command.commands.forEach(override) + } + override(root) + return root +} + +function uploadSession() { + return { + id: 'upload_1', + knowledgeBaseId: 'kb_1', + status: 'uploading', + name: 'notes.doc', + contentType: 'application/msword', + size: 5, + partSize: 10, + partCount: 1, + uploadToken: 'secret-token', + expiresAt: '2026-08-04T20:00:00.000Z', + error: null, + document: null, + } +} + +describe('knowledge documents upload', () => { + it('owns the multipart protocol while hiding its low-level operations', () => { + const knowledge = program().commands.find((command) => command.name() === 'knowledge') + expect(knowledge?.commands.map((command) => command.name())).not.toEqual( + expect.arrayContaining(['uploads', 'parts', 'complete']) + ) + + const documents = knowledge?.commands.find((command) => command.name() === 'documents') + expect(documents?.commands.map((command) => command.name())).toContain('upload') + }) + + it('uploads a local document and prints the created document without transfer secrets', async () => { + const path = join(dir, 'notes.doc') + writeFileSync(path, 'hello') + const session = uploadSession() + mockRequest + .mockResolvedValueOnce({ data: session }) + .mockResolvedValueOnce({ + data: { + parts: [ + { + partNumber: 1, + url: 'https://storage.example/part', + headers: { 'content-type': 'application/octet-stream' }, + expiresAt: '2026-08-04T20:00:00.000Z', + }, + ], + }, + }) + .mockResolvedValueOnce({ + data: { + ...session, + status: 'completed', + document: { + id: 'doc_1', + knowledgeBaseId: 'kb_1', + filename: 'notes.doc', + fileSize: 5, + mimeType: 'application/msword', + processingStatus: 'pending', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: true, + createdAt: '2026-08-04T19:00:00.000Z', + }, + }, + }) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(null, { + status: 200, + headers: { etag: '"etag-1"' }, + }) + ) + ) + const logged: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + await program().parseAsync([ + 'node', + 'sim', + 'knowledge', + 'documents', + 'upload', + 'kb_1', + path, + '--tag', + 'customer', + 'priority', + '--recipe', + 'default', + '--lang', + 'en', + ]) + + expect(mockRequest.mock.calls[0]).toEqual([ + '/api/v2/knowledge/kb_1/documents/uploads', + { + method: 'POST', + body: { + workspaceId: 'ws_local', + name: 'notes.doc', + contentType: 'application/msword', + size: 5, + tag1: 'customer', + tag2: 'priority', + processingOptions: { recipe: 'default', lang: 'en' }, + }, + }, + ]) + expect(mockRequest.mock.calls[1][0]).toBe( + '/api/v2/knowledge/kb_1/documents/uploads/upload_1/parts' + ) + expect(mockRequest.mock.calls[2][0]).toBe( + '/api/v2/knowledge/kb_1/documents/uploads/upload_1/complete' + ) + expect(mockRequest.mock.calls[2][1].body).toEqual({ + parts: [{ partNumber: 1, etag: 'etag-1' }], + }) + expect(JSON.parse(logged[0])).toEqual({ + id: 'doc_1', + knowledgeBaseId: 'kb_1', + name: 'notes.doc', + size: 5, + status: 'pending', + }) + expect(logged[0]).not.toContain('secret-token') + }) + + it('rejects more tags than the protocol supports before making a request', async () => { + const path = join(dir, 'notes.txt') + writeFileSync(path, 'hello') + + await expect( + program().parseAsync([ + 'node', + 'sim', + 'knowledge', + 'documents', + 'upload', + 'kb_1', + path, + '--tag', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + ]) + ).rejects.toThrow(/at most seven/) + expect(mockRequest).not.toHaveBeenCalled() + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts new file mode 100644 index 00000000000..b9fd2d88c4f --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts @@ -0,0 +1,96 @@ +import type { Command } from 'commander' +import { clientFrom } from '../../context.js' +import type { CreateKnowledgeDocumentUploadResponse } from '../../generated/v2-api.js' +import { SimApiError } from '../../http/client.js' +import { contentTypeFor, localFile } from '../../transfer/local-file.js' +import { finishTransfer } from '../../transfer/multipart.js' +import { printProtocolResult } from './result.js' + +type KnowledgeDocumentUpload = CreateKnowledgeDocumentUploadResponse['data'] + +interface KnowledgeDocumentUploadOptions { + name?: string + tag?: string[] + recipe?: string + lang?: string +} + +function uploadMetadata(options: KnowledgeDocumentUploadOptions): Record { + if (options.tag && options.tag.length > 7) { + throw new SimApiError('--tag accepts at most seven values', 0) + } + + const metadata: Record = {} + options.tag?.forEach((value, index) => { + metadata[`tag${index + 1}`] = value + }) + + if (options.recipe || options.lang) { + metadata.processingOptions = { + ...(options.recipe ? { recipe: options.recipe } : {}), + ...(options.lang ? { lang: options.lang } : {}), + } + } + return metadata +} + +export function attachKnowledgeDocumentUpload(documents: Command): void { + documents + .command('upload ') + .description('Upload a document to a knowledge base') + .option('--name ', 'Store it under a different name') + .option('--tag ', 'Document tags, in tag1 through tag7 order') + .option('--recipe ', 'Document processing recipe') + .option('--lang ', 'Document language code') + .action( + async ( + knowledgeBaseId: string, + path: string, + options: KnowledgeDocumentUploadOptions, + command: Command + ) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const { name, size } = await localFile(path, options.name) + const created = await client.request( + `/api/v2/knowledge/${encodeURIComponent(knowledgeBaseId)}/documents/uploads`, + { + method: 'POST', + body: { + workspaceId, + name, + contentType: contentTypeFor(name), + size, + ...uploadMetadata(options), + }, + } + ) + const upload = created.data + const completed = await finishTransfer( + client, + workspaceId, + { + basePath: `/api/v2/knowledge/${encodeURIComponent( + knowledgeBaseId + )}/documents/uploads/${encodeURIComponent(upload.id)}`, + uploadToken: upload.uploadToken, + partSize: upload.partSize, + partCount: upload.partCount, + size, + }, + path + ) + + if (!completed.document) { + throw new Error(`Knowledge upload ${completed.id} completed without a document`) + } + printProtocolResult(profile.output, { + id: completed.document.id, + knowledgeBaseId: completed.document.knowledgeBaseId, + name: completed.document.filename, + size: completed.document.fileSize, + status: completed.document.processingStatus, + }) + } + ) +} diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 26a2ba3e57a..fca6ef16a66 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -395,9 +395,13 @@ export const CLI_CONTRACT: CliContract = { }, // ─── Not a terminal-shaped operation ────────────────────────────────────── - // Multipart upload; `sim knowledge documents upload ` would need its own - // file-reading command rather than a generated flag surface. + // Multipart upload; `sim knowledge documents upload ` needs its + // own file-reading command rather than a generated flag surface. uploadKnowledgeDocument: { hidden: true }, + createKnowledgeDocumentUpload: { hidden: true }, + createKnowledgeDocumentUploadPartUrls: { hidden: true }, + completeKnowledgeDocumentUpload: { hidden: true }, + abortKnowledgeDocumentUpload: { hidden: true }, // ─── Steps of a transfer, not commands ──────────────────────────────────── // Uploading is now a presigned multipart handshake: create the upload, ask for diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index bec61da9beb..116f7b5f368 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -50,6 +50,49 @@ export type AbortFileUploadResponse = { } } +/** `DELETE /api/v2/knowledge/[id]/documents/uploads/[uploadId]` */ +export type AbortKnowledgeDocumentUploadParams = { + id: string + uploadId: string +} + +export type AbortKnowledgeDocumentUploadQuery = { + workspaceId: string +} + +export type AbortKnowledgeDocumentUploadHeaders = { + 'upload-token': string +} + +export type AbortKnowledgeDocumentUploadResponse = { + data: { + id: string + knowledgeBaseId: string + status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + name: string + contentType: string + size: number + partSize: number + partCount: number + uploadToken: string + expiresAt: string + error: string | null + document: { + id: string + knowledgeBaseId: string + filename: string + fileSize: number + mimeType: string + processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + chunkCount: number + tokenCount: number + characterCount: number + enabled: boolean + createdAt: string | null + } | null + } +} + /** `POST /api/v2/tables/[tableId]/columns` */ export type AddTableColumnParams = { tableId: string @@ -355,6 +398,56 @@ export type CompleteFileUploadResponse = { } } +/** `POST /api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete` */ +export type CompleteKnowledgeDocumentUploadParams = { + id: string + uploadId: string +} + +export type CompleteKnowledgeDocumentUploadQuery = { + workspaceId: string +} + +export type CompleteKnowledgeDocumentUploadBody = { + parts: Array<{ + partNumber: number + etag?: string + }> +} + +export type CompleteKnowledgeDocumentUploadHeaders = { + 'upload-token': string +} + +export type CompleteKnowledgeDocumentUploadResponse = { + data: { + id: string + knowledgeBaseId: string + status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + name: string + contentType: string + size: number + partSize: number + partCount: number + uploadToken: string + expiresAt: string + error: string | null + document: { + id: string + knowledgeBaseId: string + filename: string + fileSize: number + mimeType: string + processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + chunkCount: number + tokenCount: number + characterCount: number + enabled: boolean + createdAt: string | null + } | null + } +} + /** `POST /api/v2/tables/imports/[importId]/complete` */ export type CompleteTableImportParams = { importId: string @@ -626,6 +719,87 @@ export type CreateKnowledgeBaseResponse = { } } +/** `POST /api/v2/knowledge/[id]/documents/uploads` */ +export type CreateKnowledgeDocumentUploadParams = { + id: string +} + +export type CreateKnowledgeDocumentUploadBody = { + workspaceId: string + name: string + contentType: string + size: number + tag1?: string + tag2?: string + tag3?: string + tag4?: string + tag5?: string + tag6?: string + tag7?: string + processingOptions?: { + recipe?: string + lang?: string + } +} + +export type CreateKnowledgeDocumentUploadResponse = { + data: { + id: string + knowledgeBaseId: string + status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + name: string + contentType: string + size: number + partSize: number + partCount: number + uploadToken: string + expiresAt: string + error: string | null + document: { + id: string + knowledgeBaseId: string + filename: string + fileSize: number + mimeType: string + processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + chunkCount: number + tokenCount: number + characterCount: number + enabled: boolean + createdAt: string | null + } | null + } +} + +/** `POST /api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts` */ +export type CreateKnowledgeDocumentUploadPartUrlsParams = { + id: string + uploadId: string +} + +export type CreateKnowledgeDocumentUploadPartUrlsQuery = { + workspaceId: string +} + +export type CreateKnowledgeDocumentUploadPartUrlsBody = { + partNumbers: Array +} + +export type CreateKnowledgeDocumentUploadPartUrlsHeaders = { + 'upload-token': string +} + +export type CreateKnowledgeDocumentUploadPartUrlsResponse = { + data: { + parts: Array<{ + partNumber: number + url: string + headers: Record + expiresAt: string + }> + } +} + /** `POST /api/v2/mcp-servers` */ export type CreateMcpServerBody = { workspaceId: string @@ -3939,6 +4113,16 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + abortKnowledgeDocumentUpload: { + method: 'DELETE', + path: '/api/v2/knowledge/[id]/documents/uploads/[uploadId]', + pathParams: ['id', 'uploadId'] as const, + responseMode: 'json', + summary: 'Abort Document Upload', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, addTableColumn: { method: 'POST', path: '/api/v2/tables/[tableId]/columns', @@ -4029,6 +4213,19 @@ export const V2_OPERATIONS = { parts: { kind: 'array', required: true }, }, }, + completeKnowledgeDocumentUpload: { + method: 'POST', + path: '/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete', + pathParams: ['id', 'uploadId'] as const, + responseMode: 'json', + summary: 'Complete Document Upload', + query: { + workspaceId: { kind: 'string', required: true }, + }, + body: { + parts: { kind: 'array', required: true }, + }, + }, completeTableImport: { method: 'POST', path: '/api/v2/tables/imports/[importId]/complete', @@ -4140,6 +4337,40 @@ export const V2_OPERATIONS = { chunkingConfig: { kind: 'object', default: { maxSize: 1024, minSize: 100, overlap: 200 } }, }, }, + createKnowledgeDocumentUpload: { + method: 'POST', + path: '/api/v2/knowledge/[id]/documents/uploads', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Create Document Upload', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + contentType: { kind: 'string', required: true }, + size: { kind: 'integer', required: true }, + tag1: { kind: 'string' }, + tag2: { kind: 'string' }, + tag3: { kind: 'string' }, + tag4: { kind: 'string' }, + tag5: { kind: 'string' }, + tag6: { kind: 'string' }, + tag7: { kind: 'string' }, + processingOptions: { kind: 'object' }, + }, + }, + createKnowledgeDocumentUploadPartUrls: { + method: 'POST', + path: '/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts', + pathParams: ['id', 'uploadId'] as const, + responseMode: 'json', + summary: 'Create Document Upload Part URLs', + query: { + workspaceId: { kind: 'string', required: true }, + }, + body: { + partNumbers: { kind: 'array', required: true }, + }, + }, createMcpServer: { method: 'POST', path: '/api/v2/mcp-servers', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index f031dfc39a1..ebbc07f5d1e 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -182,6 +182,7 @@ describe('destructive operations are gated', () => { // kept: an upload that has not been completed owns nothing but its own // parts, and a cancelled import or export can simply be started again. 'abortFileUpload', + 'abortKnowledgeDocumentUpload', 'cancelTableImport', 'cancelTableExport', ]) diff --git a/packages/sim-cli/src/transfer/local-file.ts b/packages/sim-cli/src/transfer/local-file.ts index 2d29d31c9a3..b9056243cb0 100644 --- a/packages/sim-cli/src/transfer/local-file.ts +++ b/packages/sim-cli/src/transfer/local-file.ts @@ -5,20 +5,28 @@ import { SimApiError } from '../http/client.js' const CONTENT_TYPES: Record = { css: 'text/css', csv: 'text/csv', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', gif: 'image/gif', html: 'text/html', + htm: 'text/html', jpeg: 'image/jpeg', jpg: 'image/jpeg', js: 'text/javascript', json: 'application/json', + jsonl: 'application/jsonl', md: 'text/markdown', pdf: 'application/pdf', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', png: 'image/png', svg: 'image/svg+xml', txt: 'text/plain', webp: 'image/webp', yaml: 'application/yaml', yml: 'application/yaml', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', zip: 'application/zip', } From f01c4be0abe789ea0d679a2c41c747c9ac71ae51 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 17:45:11 -0700 Subject: [PATCH 063/159] Unify file creation and signed upload sessions (#6264) * feat(uploads): unify signed upload sessions * fix(uploads): preserve attachment storage semantics * feat(files): add authored file creation * fix(uploads): omit hoisted S3 metadata headers --- .../platform/self-hosting/object-storage.mdx | 43 + apps/docs/openapi-v2-files-audit.json | 159 ++- apps/docs/openapi-v2-knowledge.json | 122 ++- apps/docs/openapi-v2-resources.json | 16 +- apps/docs/openapi-v2-tables.json | 4 +- .../sim/app/api/files/multipart/route.test.ts | 315 ------ apps/sim/app/api/files/multipart/route.ts | 533 ---------- .../api/files/presigned/batch/route.test.ts | 189 ---- .../app/api/files/presigned/batch/route.ts | 184 ---- .../sim/app/api/files/presigned/route.test.ts | 937 ------------------ apps/sim/app/api/files/presigned/route.ts | 335 ------- apps/sim/app/api/files/upload/route.test.ts | 814 --------------- apps/sim/app/api/files/upload/route.ts | 488 --------- .../uploads/[uploadId]/complete/route.ts | 57 +- .../files/uploads/[uploadId]/parts/route.ts | 33 +- .../app/api/files/uploads/[uploadId]/route.ts | 28 +- .../app/api/files/uploads/finalizers.test.ts | 238 +++++ apps/sim/app/api/files/uploads/finalizers.ts | 362 +++++++ apps/sim/app/api/files/uploads/purposes.ts | 188 ++++ apps/sim/app/api/files/uploads/route.test.ts | 307 ++++++ apps/sim/app/api/files/uploads/route.ts | 44 +- apps/sim/app/api/files/uploads/utils.ts | 45 +- apps/sim/app/api/help/route.ts | 2 +- .../uploads/[uploadId]/complete/route.ts | 4 +- .../uploads/[uploadId]/parts/route.ts | 2 +- .../[id]/documents/uploads/route.test.ts | 13 +- .../knowledge/[id]/documents/uploads/route.ts | 12 +- .../imports/[importId]/complete/route.ts | 10 +- .../table/imports/[importId]/parts/route.ts | 2 +- apps/sim/app/api/table/imports/route.ts | 10 +- apps/sim/app/api/v2/credentials/route.test.ts | 18 + .../v2/files/[fileId]/content/route.test.ts | 49 +- .../api/v2/files/[fileId]/content/route.ts | 13 +- apps/sim/app/api/v2/files/route.test.ts | 247 ++++- apps/sim/app/api/v2/files/route.ts | 68 +- .../uploads/[uploadId]/complete/route.ts | 45 +- .../files/uploads/[uploadId]/parts/route.ts | 5 +- .../api/v2/files/uploads/[uploadId]/route.ts | 2 +- .../app/api/v2/files/uploads/route.test.ts | 43 +- apps/sim/app/api/v2/files/uploads/route.ts | 12 +- apps/sim/app/api/v2/files/uploads/utils.ts | 5 +- .../uploads/[uploadId]/complete/route.test.ts | 13 +- .../uploads/[uploadId]/complete/route.ts | 4 +- .../uploads/[uploadId]/parts/route.ts | 2 +- .../[id]/documents/uploads/route.test.ts | 13 +- .../knowledge/[id]/documents/uploads/route.ts | 10 +- .../[id]/documents/uploads/utils.test.ts | 23 +- .../knowledge/[id]/documents/uploads/utils.ts | 13 +- .../imports/[importId]/complete/route.test.ts | 53 +- .../imports/[importId]/complete/route.ts | 10 +- .../tables/imports/[importId]/parts/route.ts | 2 +- .../app/api/v2/tables/imports/route.test.ts | 135 +++ apps/sim/app/api/v2/tables/imports/route.ts | 10 +- .../parts/[partNumber]/route.test.ts | 100 ++ .../[uploadId]/parts/[partNumber]/route.ts | 19 +- .../api/v2/uploads/[uploadId]/route.test.ts | 142 +++ .../app/api/v2/uploads/[uploadId]/route.ts | 73 ++ .../[id]/files/[fileId]/content/route.test.ts | 135 +++ .../[id]/files/[fileId]/content/route.ts | 30 +- .../[id]/files/presigned/route.test.ts | 174 ---- .../workspaces/[id]/files/presigned/route.ts | 113 --- .../[id]/files/register/route.test.ts | 177 ---- .../workspaces/[id]/files/register/route.ts | 106 -- .../api/workspaces/[id]/files/route.test.ts | 308 ++++-- .../app/api/workspaces/[id]/files/route.ts | 146 +-- .../workspace/[workspaceId]/files/files.tsx | 14 +- .../add-documents-modal.tsx | 14 +- .../create-base-modal/create-base-modal.tsx | 14 +- .../hooks/use-knowledge-upload.test.tsx | 88 ++ .../knowledge/hooks/use-knowledge-upload.ts | 22 +- .../hooks/use-profile-picture-upload.ts | 34 +- .../hooks/use-file-attachments.test.tsx | 118 +++ .../user-input/hooks/use-file-attachments.ts | 160 +-- .../hooks/use-workflow-execution.test.tsx | 41 +- .../utils/workflow-attachment-upload.ts | 78 +- .../hooks/use-workspace-logo-upload.ts | 29 +- apps/sim/background/cleanup-soft-deletes.ts | 6 +- apps/sim/hooks/queries/tables.ts | 57 +- .../hooks/queries/workspace-files.test.tsx | 79 +- apps/sim/hooks/queries/workspace-files.ts | 29 +- apps/sim/lib/api/contracts/file-uploads.ts | 88 -- apps/sim/lib/api/contracts/index.ts | 1 - .../contracts/knowledge/upload-sessions.ts | 6 +- apps/sim/lib/api/contracts/primitives.test.ts | 14 + apps/sim/lib/api/contracts/primitives.ts | 44 + .../sim/lib/api/contracts/storage-transfer.ts | 296 ------ apps/sim/lib/api/contracts/table-transfers.ts | 3 +- apps/sim/lib/api/contracts/tables.ts | 4 +- apps/sim/lib/api/contracts/upload-sessions.ts | 193 +++- .../api/contracts/v2/__tests__/tables.test.ts | 137 +++ .../contracts/v2/__tests__/uploads.test.ts | 46 + apps/sim/lib/api/contracts/v2/credentials.ts | 1 + apps/sim/lib/api/contracts/v2/files.ts | 79 +- apps/sim/lib/api/contracts/v2/knowledge.ts | 20 +- apps/sim/lib/api/contracts/v2/tables.ts | 114 ++- apps/sim/lib/api/contracts/v2/uploads.ts | 33 +- apps/sim/lib/api/contracts/workspace-files.ts | 144 ++- apps/sim/lib/table/import-runner.test.ts | 24 + apps/sim/lib/table/import-runner.ts | 37 +- apps/sim/lib/table/import.test.ts | 15 +- apps/sim/lib/table/import.ts | 12 + .../orchestration/import-resource.test.ts | 191 ++++ .../table/orchestration/import-resource.ts | 44 +- apps/sim/lib/uploads/client/admission.test.ts | 77 ++ apps/sim/lib/uploads/client/admission.ts | 102 ++ .../lib/uploads/client/api-fallback.test.ts | 83 -- apps/sim/lib/uploads/client/api-fallback.ts | 131 --- .../lib/uploads/client/concurrency.test.ts | 44 + apps/sim/lib/uploads/client/concurrency.ts | 38 + .../lib/uploads/client/direct-upload.test.ts | 266 ----- apps/sim/lib/uploads/client/direct-upload.ts | 637 ------------ .../uploads/client/multipart-session.test.ts | 86 -- .../lib/uploads/client/multipart-session.ts | 88 -- .../lib/uploads/client/session-upload.test.ts | 114 ++- apps/sim/lib/uploads/client/session-upload.ts | 193 +++- apps/sim/lib/uploads/client/types.ts | 5 + .../lib/uploads/client/upload-session.test.ts | 325 ++++++ apps/sim/lib/uploads/client/upload-session.ts | 374 +++++++ .../contexts/copilot/copilot-file-manager.ts | 53 - .../sim/lib/uploads/contexts/copilot/index.ts | 1 - .../workspace-file-folder-manager.ts | 14 +- .../workspace/workspace-file-manager.ts | 107 +- .../workspace/workspace-file-query.test.ts | 1 + .../workspace-file-storage-accounting.test.ts | 68 +- .../workspace-file-storage-billing.test.ts | 4 + apps/sim/lib/uploads/core/storage-service.ts | 36 +- .../sim/lib/uploads/core/upload-token.test.ts | 134 ++- apps/sim/lib/uploads/core/upload-token.ts | 353 +++++-- .../lib/uploads/multipart-session/provider.ts | 319 ------ .../uploads/multipart-session/service.test.ts | 129 --- .../lib/uploads/multipart-session/service.ts | 454 --------- .../lib/uploads/providers/blob/client.test.ts | 102 ++ apps/sim/lib/uploads/providers/blob/client.ts | 139 ++- apps/sim/lib/uploads/providers/blob/types.ts | 2 + .../lib/uploads/providers/gcs/client.test.ts | 59 +- apps/sim/lib/uploads/providers/gcs/client.ts | 57 +- apps/sim/lib/uploads/providers/gcs/types.ts | 2 + .../lib/uploads/providers/s3/client.test.ts | 93 ++ apps/sim/lib/uploads/providers/s3/client.ts | 91 +- apps/sim/lib/uploads/providers/s3/types.ts | 2 + apps/sim/lib/uploads/server/metadata.ts | 58 +- apps/sim/lib/uploads/shared/types.ts | 2 +- apps/sim/lib/uploads/upload-session/README.md | 27 + .../uploads/upload-session/cleanup.test.ts | 96 ++ .../sim/lib/uploads/upload-session/cleanup.ts | 127 +++ .../uploads/upload-session/provider.test.ts | 243 +++++ .../lib/uploads/upload-session/provider.ts | 754 ++++++++++++++ .../uploads/upload-session/service.test.ts | 432 ++++++++ .../sim/lib/uploads/upload-session/service.ts | 792 +++++++++++++++ .../workspace-files/orchestration/content.ts | 3 + .../orchestration/create.test.ts | 212 ++++ .../workspace-files/orchestration/create.ts | 120 +++ .../workspace-files/orchestration/index.ts | 6 + apps/sim/proxy.test.ts | 4 +- .../testing/src/mocks/storage-service.mock.ts | 2 - 155 files changed, 9397 insertions(+), 8407 deletions(-) delete mode 100644 apps/sim/app/api/files/multipart/route.test.ts delete mode 100644 apps/sim/app/api/files/multipart/route.ts delete mode 100644 apps/sim/app/api/files/presigned/batch/route.test.ts delete mode 100644 apps/sim/app/api/files/presigned/batch/route.ts delete mode 100644 apps/sim/app/api/files/presigned/route.test.ts delete mode 100644 apps/sim/app/api/files/presigned/route.ts delete mode 100644 apps/sim/app/api/files/upload/route.test.ts delete mode 100644 apps/sim/app/api/files/upload/route.ts create mode 100644 apps/sim/app/api/files/uploads/finalizers.test.ts create mode 100644 apps/sim/app/api/files/uploads/finalizers.ts create mode 100644 apps/sim/app/api/files/uploads/purposes.ts create mode 100644 apps/sim/app/api/files/uploads/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/imports/route.test.ts create mode 100644 apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.test.ts create mode 100644 apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts create mode 100644 apps/sim/app/api/v2/uploads/[uploadId]/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts delete mode 100644 apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts delete mode 100644 apps/sim/app/api/workspaces/[id]/files/presigned/route.ts delete mode 100644 apps/sim/app/api/workspaces/[id]/files/register/route.test.ts delete mode 100644 apps/sim/app/api/workspaces/[id]/files/register/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.test.tsx delete mode 100644 apps/sim/lib/api/contracts/file-uploads.ts create mode 100644 apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts create mode 100644 apps/sim/lib/api/contracts/v2/__tests__/uploads.test.ts create mode 100644 apps/sim/lib/table/orchestration/import-resource.test.ts create mode 100644 apps/sim/lib/uploads/client/admission.test.ts create mode 100644 apps/sim/lib/uploads/client/admission.ts delete mode 100644 apps/sim/lib/uploads/client/api-fallback.test.ts delete mode 100644 apps/sim/lib/uploads/client/api-fallback.ts create mode 100644 apps/sim/lib/uploads/client/concurrency.test.ts create mode 100644 apps/sim/lib/uploads/client/concurrency.ts delete mode 100644 apps/sim/lib/uploads/client/direct-upload.test.ts delete mode 100644 apps/sim/lib/uploads/client/direct-upload.ts delete mode 100644 apps/sim/lib/uploads/client/multipart-session.test.ts delete mode 100644 apps/sim/lib/uploads/client/multipart-session.ts create mode 100644 apps/sim/lib/uploads/client/types.ts create mode 100644 apps/sim/lib/uploads/client/upload-session.test.ts create mode 100644 apps/sim/lib/uploads/client/upload-session.ts delete mode 100644 apps/sim/lib/uploads/multipart-session/provider.ts delete mode 100644 apps/sim/lib/uploads/multipart-session/service.test.ts delete mode 100644 apps/sim/lib/uploads/multipart-session/service.ts create mode 100644 apps/sim/lib/uploads/upload-session/README.md create mode 100644 apps/sim/lib/uploads/upload-session/cleanup.test.ts create mode 100644 apps/sim/lib/uploads/upload-session/cleanup.ts create mode 100644 apps/sim/lib/uploads/upload-session/provider.test.ts create mode 100644 apps/sim/lib/uploads/upload-session/provider.ts create mode 100644 apps/sim/lib/uploads/upload-session/service.test.ts create mode 100644 apps/sim/lib/uploads/upload-session/service.ts create mode 100644 apps/sim/lib/workspace-files/orchestration/create.test.ts create mode 100644 apps/sim/lib/workspace-files/orchestration/create.ts diff --git a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx index 64716f9f0f3..7233cb1bc70 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx @@ -237,6 +237,26 @@ AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME=og-images AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME=workspace-logos ``` +Direct browser uploads require a Blob service CORS rule on the storage account. Allow your exact +Sim origin, `GET` and `PUT`, the `Content-Type` header, and the `x-ms-*` prefix used by signed blob +and metadata headers: + +```bash +az storage cors add \ + --services b \ + --methods GET PUT \ + --origins https://sim.yourdomain.com \ + --allowed-headers content-type 'x-ms-*' \ + --exposed-headers ETag \ + --max-age 3600 \ + --account-name mystorageaccount \ + --account-key '' +``` + +If you authenticate with a connection string, replace the last two options with +`--connection-string "$AZURE_CONNECTION_STRING"`. CORS is configured once for the account's Blob +service and applies to all of its containers. + A full Helm example lives at `helm/sim/examples/values-azure.yaml`. ## Set up Google Cloud Storage @@ -276,11 +296,13 @@ cat > /tmp/cors.json <<'EOF' "responseHeader": [ "Content-Type", "ETag", + "x-goog-meta-uploadid", "x-goog-meta-originalname", "x-goog-meta-uploadedat", "x-goog-meta-purpose", "x-goog-meta-userid", "x-goog-meta-workspaceid", + "x-goog-meta-knowledgebaseid", "x-goog-meta-folderid", "x-goog-meta-workflowid", "x-goog-meta-executionid" @@ -445,6 +467,27 @@ The same browser-reachability and CORS requirements apply. +## Configure temporary upload cleanup + +Sim stages every direct upload under the `upload-sessions/` prefix before promoting it to its final, +immutable object key. Apply the cleanup policy to **every** purpose-specific bucket or container +configured above: + +- On AWS S3 and Google Cloud Storage, expire objects under `upload-sessions/` after two days and + abort incomplete multipart uploads after two days. +- On Azure Blob, expire committed blobs under `upload-sessions/` after two days. Azure automatically + removes uncommitted blocks after seven days. +- For an S3-compatible provider, configure both rules when its lifecycle implementation supports + them. Check the provider's documentation because lifecycle feature support varies. + +The two-day window exceeds the 24-hour upload-token lifetime and leaves time to retry completion. +Do not apply this prefix rule to final objects outside `upload-sessions/`. + + + Configure both expiration and incomplete-multipart cleanup where available. Expiring staged + objects alone does not necessarily remove abandoned multipart parts. + + ## Verify it works After restarting with the new configuration: diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index c379903774c..8ad1d4363ce 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -169,56 +169,75 @@ } } }, - "x-removed-buffered-post": { - "operationId": "uploadFile", - "summary": "Upload File", - "description": "Upload a file to a workspace as `multipart/form-data` with a single `file` field. The workspace — and the optional target `folderId` — are supplied as query parameters (not form fields) so authorization runs before the request body is buffered. Maximum file size is 100MB. A name already taken in the destination folder is **not** an error: the name is auto-suffixed (`data.csv` -> `data (1).csv`), matching the in-app uploader, so a `201` can come back with a `name` different from the one you sent — always read `name` from the response rather than assuming it. `409` is returned only if a unique name cannot be allocated after several attempts. Use `PATCH /api/v2/files/{fileId}` if you need a specific name to be exact-or-fail. Returns `201 Created`.\n\nPresigned upload is not part of the public API: it debits the storage quota only in a separate register step, so a caller that never registers would leave unaccounted bytes in storage. This buffered path debits inside the upload transaction.", + "post": { + "operationId": "createFile", + "summary": "Create File", + "description": "Create an authored workspace file, either empty or with initial inline content. Use this endpoint for files whose bytes are already available as UTF-8 text or base64 and are at most 50 MiB after decoding. Use the upload-session endpoints for streamed or larger files. A live file with the same name in the same folder is rejected with `409`.", "tags": ["Files"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/files?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"file=@/path/to/file.csv\"" - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/WorkspaceIdQuery" - }, - { - "name": "folderId", - "in": "query", - "required": false, - "description": "Target file folder. Omit to upload to the workspace root. Supplied as a query parameter, like `workspaceId`, so authorization runs before the multipart body is buffered.", - "schema": { - "type": "string", - "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" - } + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/files\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"name\": \"notes.md\"}'" } ], "requestBody": { "required": true, - "description": "The file to upload, sent as multipart/form-data.", "content": { - "multipart/form-data": { + "application/json": { "schema": { "type": "object", - "required": ["file"], + "additionalProperties": false, + "required": ["workspaceId", "name"], "properties": { - "file": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace in which to create the file." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "File name, including its extension. Path separators and dot segments are rejected." + }, + "contentType": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "MIME type. When omitted, it is inferred from the file extension." + }, + "folderId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Destination folder. Omit to create the file at the workspace root." + }, + "content": { + "type": "string", + "maxLength": 70000000, + "default": "", + "description": "Initial file content. Omit or send an empty string to create a zero-byte file." + }, + "encoding": { "type": "string", - "format": "binary", - "description": "The file to upload. Maximum size is 100MB." + "enum": ["utf-8", "base64"], + "default": "utf-8", + "description": "Encoding of `content`." } } + }, + "example": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "notes.md" } } } }, "responses": { "201": { - "description": "The file was uploaded successfully.", + "description": "The created file.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -238,12 +257,12 @@ "example": { "data": { "id": "wf_V1StGXR8z5jdHi6BmyT91", - "name": "data.csv", - "size": 1024, - "type": "text/csv", - "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", - "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", - "folderPath": "Reports/Q1", + "name": "notes.md", + "size": 0, + "type": "text/markdown", + "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-notes.md", + "folderId": null, + "folderPath": null, "uploadedBy": "user_abc123", "uploadedAt": "2026-01-15T10:30:00Z", "updatedAt": "2026-01-15T10:30:00Z" @@ -252,66 +271,14 @@ } } }, - "400": { - "description": "The request was malformed: an invalid `workspaceId` query parameter, a body that is not valid multipart form data, or a missing `file` form field.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "BAD_REQUEST", - "message": "file form field is required" - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "409": { - "description": "A unique filename could not be allocated in the destination folder after several attempts. An ordinary name collision is auto-suffixed instead, not rejected.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CONFLICT", - "message": "A file named \"data.csv\" already exists in this workspace" - } - } - } - } - }, - "413": { - "description": "The upload exceeds the 100MB file size limit, or the workspace storage limit would be exceeded.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "PAYLOAD_TOO_LARGE", - "message": "File size exceeds 100MB limit (142.30MB)" - } - } - } - } - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } } } }, @@ -319,7 +286,7 @@ "post": { "operationId": "createFileUpload", "summary": "Create File Upload", - "description": "Create a stateless multipart upload session and signed upload token. Every file uses this flow; a small file is a single part. The maximum file size is 5 GB.", + "description": "Create an upload session and signed control token. Empty files and files up to and including 50 MiB receive a single signed PUT URL; larger files receive multipart transfer instructions. The maximum file size is 5 GB.", "tags": ["Files"], "requestBody": { "required": true, @@ -405,7 +372,7 @@ "post": { "operationId": "completeFileUpload", "summary": "Complete File Upload", - "description": "Verify every part, assemble the object, and atomically register the workspace file.", + "description": "Verify the single PUT or assemble every multipart part, then atomically register the workspace file.", "tags": ["Files"], "parameters": [ { @@ -1681,7 +1648,7 @@ "name": "upload-token", "in": "header", "required": true, - "description": "The signed token returned when the multipart upload was created.", + "description": "The signed control token returned when the upload session was created.", "schema": { "type": "string", "minLength": 1 } }, "FileIdPath": { diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index f9c45723d60..4a5924ad949 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -790,7 +790,7 @@ "post": { "operationId": "createKnowledgeDocumentUpload", "summary": "Create Document Upload", - "description": "Create a stateless multipart upload session for a knowledge document. Write access, billing, usage, file type, file size, and workspace storage are checked before provider storage is allocated. The signed upload token binds the caller, workspace, knowledge base, filename, content type, byte size, provider, and knowledge-document purpose. Files may be up to 100 MB.", + "description": "Create an upload session for a knowledge document. Files up to and including 50 MiB use a single signed PUT; larger files use multipart transfer. Write access, billing, usage, file type, file size, and workspace storage are checked before provider storage is allocated. The signed upload token binds the caller, workspace, knowledge base, filename, content type, byte size, provider, and knowledge-document purpose. Files may be up to 100 MB.", "tags": ["Knowledge Bases"], "x-codeSamples": [ { @@ -801,7 +801,7 @@ ], "requestBody": { "required": true, - "description": "Metadata for the document that will be uploaded through signed part URLs.", + "description": "Metadata for the document that will be uploaded through the returned transfer instructions.", "content": { "application/json": { "schema": { @@ -812,11 +812,11 @@ }, "responses": { "201": { - "description": "The multipart upload session and its signed control-plane token.", + "description": "The terminal-safe upload session, signed control-plane token, and PUT or multipart transfer instructions.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DocumentUploadEnvelope" + "$ref": "#/components/schemas/CreateDocumentUploadEnvelope" } } } @@ -918,7 +918,7 @@ "post": { "operationId": "completeKnowledgeDocumentUpload", "summary": "Complete Document Upload", - "description": "Verify and assemble all parts, record knowledge-base storage ownership, create the knowledge document, and queue asynchronous processing. Repeating the same completion is idempotent and returns the same document. It never registers a general workspace file.", + "description": "Verify the single PUT or assemble all multipart parts, record knowledge-base storage ownership, create the knowledge document, and queue asynchronous processing. Repeating the same completion is idempotent and returns the same document. It never registers a general workspace file.", "tags": ["Knowledge Bases"], "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], "requestBody": { @@ -1480,9 +1480,6 @@ "name", "contentType", "size", - "partSize", - "partCount", - "uploadToken", "expiresAt", "error", "document" @@ -1510,19 +1507,6 @@ "type": "integer", "minimum": 1 }, - "partSize": { - "type": "integer", - "minimum": 1 - }, - "partCount": { - "type": "integer", - "minimum": 1 - }, - "uploadToken": { - "type": "string", - "minLength": 1, - "description": "Signed token required for part URLs, completion, and abort." - }, "expiresAt": { "type": "string", "format": "date-time" @@ -1545,6 +1529,52 @@ } } }, + "PutUploadTransfer": { + "type": "object", + "additionalProperties": false, + "required": ["method", "url", "headers"], + "properties": { + "method": { "type": "string", "const": "put" }, + "url": { "type": "string", "format": "uri" }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" } + } + } + }, + "MultipartUploadTransfer": { + "type": "object", + "additionalProperties": false, + "required": ["method", "partSize", "partCount"], + "properties": { + "method": { "type": "string", "const": "multipart" }, + "partSize": { "type": "integer", "minimum": 1 }, + "partCount": { "type": "integer", "minimum": 1, "maximum": 640 } + } + }, + "UploadTransfer": { + "oneOf": [ + { "$ref": "#/components/schemas/PutUploadTransfer" }, + { "$ref": "#/components/schemas/MultipartUploadTransfer" } + ], + "discriminator": { "propertyName": "method" } + }, + "CreateDocumentUploadEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "required": ["session", "uploadToken", "transfer"], + "properties": { + "session": { "$ref": "#/components/schemas/DocumentUpload" }, + "uploadToken": { "type": "string", "minLength": 1 }, + "transfer": { "$ref": "#/components/schemas/UploadTransfer" } + } + } + } + }, "CreatePartUrlsBody": { "type": "object", "additionalProperties": false, @@ -1605,31 +1635,39 @@ } }, "CompleteUploadBody": { - "type": "object", - "additionalProperties": false, - "required": ["parts"], - "properties": { - "parts": { - "type": "array", - "minItems": 1, - "maxItems": 640, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["partNumber"], - "properties": { - "partNumber": { - "type": "integer", - "minimum": 1 - }, - "etag": { - "type": "string", - "minLength": 1 + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["parts"], + "properties": { + "parts": { + "type": "array", + "minItems": 1, + "maxItems": 640, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["partNumber"], + "properties": { + "partNumber": { + "type": "integer", + "minimum": 1 + }, + "etag": { + "type": "string", + "minLength": 1 + } + } } } } + }, + { + "type": "object", + "additionalProperties": false } - } + ] }, "DocumentSummary": { "type": "object", diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index f7a6117a2c9..3e4466ecbcc 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -2824,7 +2824,13 @@ "maxLength": 1024, "description": "Write-only. Client-credentials secret." }, - "orgId": { "type": "string", "minLength": 1, "maxLength": 255 } + "orgId": { "type": "string", "minLength": 1, "maxLength": 255 }, + "dataCenter": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "description": "Optional provider region selector, such as a Zoho Desk data center." + } } }, "UpdateCredentialBody": { @@ -2860,7 +2866,13 @@ "maxLength": 1024, "description": "Write-only." }, - "orgId": { "type": "string", "minLength": 1, "maxLength": 255 } + "orgId": { "type": "string", "minLength": 1, "maxLength": 255 }, + "dataCenter": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "description": "Optional provider region selector, such as a Zoho Desk data center." + } } } } diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 2c82c47276a..a80ff6774b4 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -3500,7 +3500,7 @@ "post": { "operationId": "createTableImport", "summary": "Create Table Import", - "description": "Create a table import. Upload sources return a stateless multipart token; workspace-file sources start immediately and both use table jobs for processing state.", + "description": "Create a table import. Upload sources return a signed control token plus single-PUT or multipart transfer instructions; workspace-file sources start immediately. Both use table jobs for processing state.", "tags": ["Tables"], "requestBody": { "required": true, @@ -3697,7 +3697,7 @@ "post": { "operationId": "completeTableImportUpload", "summary": "Complete Table Import Upload", - "description": "Verify and assemble the uploaded CSV or TSV, then start processing with the same import id.", + "description": "Verify the single PUT or assemble the multipart CSV or TSV, then start processing with the same import id.", "tags": ["Tables"], "parameters": [ { diff --git a/apps/sim/app/api/files/multipart/route.test.ts b/apps/sim/app/api/files/multipart/route.test.ts deleted file mode 100644 index a1200ec18c9..00000000000 --- a/apps/sim/app/api/files/multipart/route.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -/** - * @vitest-environment node - */ -import { authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockIsUsingCloudStorage, - mockGetStorageProvider, - mockGetStorageConfig, - mockCompleteS3MultipartUpload, - mockCompleteBlobMultipartUpload, - mockDeriveBlobBlockId, - mockVerifyUploadToken, - mockSignUploadToken, -} = vi.hoisted(() => ({ - mockIsUsingCloudStorage: vi.fn(), - mockGetStorageProvider: vi.fn(), - mockGetStorageConfig: vi.fn(), - mockCompleteS3MultipartUpload: vi.fn(), - mockCompleteBlobMultipartUpload: vi.fn(), - mockDeriveBlobBlockId: vi.fn(), - mockVerifyUploadToken: vi.fn(), - mockSignUploadToken: vi.fn(), -})) - -vi.mock('@/lib/uploads', () => ({ - isUsingCloudStorage: mockIsUsingCloudStorage, - getStorageProvider: mockGetStorageProvider, - getStorageConfig: mockGetStorageConfig, -})) - -vi.mock('@/lib/uploads/core/upload-token', () => ({ - signUploadToken: mockSignUploadToken, - verifyUploadToken: mockVerifyUploadToken, -})) - -vi.mock('@/lib/uploads/providers/s3/client', () => ({ - completeS3MultipartUpload: mockCompleteS3MultipartUpload, - initiateS3MultipartUpload: mockInitiateS3MultipartUpload, - getS3MultipartPartUrls: vi.fn(), - abortS3MultipartUpload: vi.fn(), -})) - -vi.mock('@/lib/uploads/providers/blob/client', () => ({ - completeMultipartUpload: mockCompleteBlobMultipartUpload, - deriveBlobBlockId: mockDeriveBlobBlockId, - initiateMultipartUpload: vi.fn(), - getMultipartPartUrls: vi.fn(), - abortMultipartUpload: vi.fn(), -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) - -const { mockCheckStorageQuota, mockInitiateS3MultipartUpload, mockResolveStorageBillingContext } = - vi.hoisted(() => ({ - mockCheckStorageQuota: vi.fn(), - mockInitiateS3MultipartUpload: vi.fn(), - mockResolveStorageBillingContext: vi.fn(), - })) - -vi.mock('@/lib/billing/storage', () => ({ - checkStorageQuotaForBillingContext: mockCheckStorageQuota, - resolveStorageBillingContext: mockResolveStorageBillingContext, -})) - -import { POST } from '@/app/api/files/multipart/route' - -const STORAGE_CONTEXT = { - workspaceId: 'ws-1', - billedAccountUserId: 'workspace-owner', - billingEntity: { type: 'organization' as const, id: 'workspace-org' }, - plan: 'team_25000', - customStorageLimitGB: null, -} - -const tokenPayload = { - uploadId: 'upload-1', - key: 'workspace/ws-1/123-abc-file.bin', - userId: 'user-1', - workspaceId: 'ws-1', - context: 'workspace' as const, -} - -const makeRequest = (action: string, body: unknown) => - new NextRequest(`http://localhost/api/files/multipart?action=${action}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - -describe('POST /api/files/multipart action=complete', () => { - beforeEach(() => { - vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mockIsUsingCloudStorage.mockReturnValue(true) - mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' }) - mockVerifyUploadToken.mockReturnValue({ valid: true, payload: tokenPayload }) - mockSignUploadToken.mockReturnValue('signed-token') - mockCompleteS3MultipartUpload.mockResolvedValue({ - location: 'loc', - path: '/api/files/serve/...', - key: tokenPayload.key, - }) - mockCompleteBlobMultipartUpload.mockResolvedValue({ - location: 'loc', - path: '/api/files/serve/...', - key: tokenPayload.key, - }) - mockDeriveBlobBlockId.mockImplementation( - (n: number) => `block-${n.toString().padStart(6, '0')}` - ) - }) - - it('rejects parts without partNumber', async () => { - mockGetStorageProvider.mockReturnValue('s3') - const res = await POST( - makeRequest('complete', { - uploadToken: 'tok', - parts: [{ etag: 'abc' }], - }) - ) - expect(res.status).toBe(400) - expect(mockCompleteS3MultipartUpload).not.toHaveBeenCalled() - }) - - it('S3 path requires etag and forwards { ETag, PartNumber }', async () => { - mockGetStorageProvider.mockReturnValue('s3') - - const missingEtag = await POST( - makeRequest('complete', { - uploadToken: 'tok', - parts: [{ partNumber: 1 }], - }) - ) - expect(missingEtag.status).toBe(500) - - mockCompleteS3MultipartUpload.mockClear() - - const ok = await POST( - makeRequest('complete', { - uploadToken: 'tok', - parts: [ - { partNumber: 1, etag: 'aaa' }, - { partNumber: 2, etag: 'bbb' }, - ], - }) - ) - expect(ok.status).toBe(200) - expect(mockCompleteS3MultipartUpload).toHaveBeenCalledWith( - tokenPayload.key, - tokenPayload.uploadId, - [ - { ETag: 'aaa', PartNumber: 1 }, - { ETag: 'bbb', PartNumber: 2 }, - ], - expect.any(Object) - ) - }) - - it('Blob path derives blockId from partNumber and ignores etag', async () => { - mockGetStorageProvider.mockReturnValue('blob') - mockGetStorageConfig.mockReturnValue({ - containerName: 'c', - accountName: 'a', - accountKey: 'k', - }) - - const res = await POST( - makeRequest('complete', { - uploadToken: 'tok', - parts: [{ partNumber: 1, etag: 'irrelevant' }, { partNumber: 2 }], - }) - ) - - expect(res.status).toBe(200) - expect(mockDeriveBlobBlockId).toHaveBeenCalledWith(1) - expect(mockDeriveBlobBlockId).toHaveBeenCalledWith(2) - expect(mockCompleteBlobMultipartUpload).toHaveBeenCalledWith( - tokenPayload.key, - [ - { partNumber: 1, blockId: 'block-000001' }, - { partNumber: 2, blockId: 'block-000002' }, - ], - expect.objectContaining({ containerName: 'c' }) - ) - }) - - it('returns 403 when token is invalid', async () => { - mockGetStorageProvider.mockReturnValue('s3') - mockVerifyUploadToken.mockReturnValueOnce({ valid: false }) - const res = await POST( - makeRequest('complete', { - uploadToken: 'bad', - parts: [{ partNumber: 1, etag: 'a' }], - }) - ) - expect(res.status).toBe(403) - }) - - it('batch complete normalizes per upload', async () => { - mockGetStorageProvider.mockReturnValue('s3') - const res = await POST( - makeRequest('complete', { - uploads: [ - { - uploadToken: 'tok-a', - parts: [{ partNumber: 1, etag: 'aaa' }], - }, - { - uploadToken: 'tok-b', - parts: [{ partNumber: 1, etag: 'bbb' }], - }, - ], - }) - ) - expect(res.status).toBe(200) - expect(mockCompleteS3MultipartUpload).toHaveBeenCalledTimes(2) - }) -}) - -describe('POST /api/files/multipart action=initiate quota enforcement', () => { - const makeInitiateRequest = (body: unknown) => - new NextRequest('http://localhost/api/files/multipart?action=initiate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - - beforeEach(() => { - vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mockIsUsingCloudStorage.mockReturnValue(true) - mockGetStorageProvider.mockReturnValue('s3') - mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' }) - mockSignUploadToken.mockReturnValue('signed-token') - mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT) - mockCheckStorageQuota.mockResolvedValue({ allowed: true }) - mockInitiateS3MultipartUpload.mockResolvedValue({ uploadId: 'up-1', key: 'k/file.bin' }) - }) - - it('blocks upload when fileSize: 0 exceeds quota', async () => { - mockCheckStorageQuota.mockResolvedValue({ allowed: false, error: 'Storage limit exceeded' }) - - const res = await makeInitiateRequest({ - fileName: 'file.bin', - contentType: 'application/octet-stream', - fileSize: 0, - workspaceId: 'ws-1', - context: 'knowledge-base', - }) - - const response = await POST(res) - expect(response.status).toBe(413) - const body = await response.json() - expect(body.error).toContain('Storage limit exceeded') - }) - - it('allows quota-enforced contexts that pass the quota check', async () => { - const res = await makeInitiateRequest({ - fileName: 'doc.pdf', - contentType: 'application/pdf', - fileSize: 99999, - workspaceId: 'ws-1', - context: 'knowledge-base', - }) - - const response = await POST(res) - expect(response.status).toBe(200) - expect(mockResolveStorageBillingContext).toHaveBeenCalledWith('ws-1') - expect(mockCheckStorageQuota).toHaveBeenCalledWith(STORAGE_CONTEXT, 99999) - expect(mockInitiateS3MultipartUpload).toHaveBeenCalled() - }) - - it('keeps mothership chat uploads outside workspace storage quotas', async () => { - mockCheckStorageQuota.mockResolvedValue({ allowed: false, error: 'Storage limit exceeded' }) - - const res = await makeInitiateRequest({ - fileName: 'conversation.bin', - contentType: 'application/octet-stream', - fileSize: 99999, - workspaceId: 'ws-1', - context: 'mothership', - }) - - const response = await POST(res) - expect(response.status).toBe(200) - expect(mockResolveStorageBillingContext).not.toHaveBeenCalled() - expect(mockCheckStorageQuota).not.toHaveBeenCalled() - expect(mockInitiateS3MultipartUpload).toHaveBeenCalled() - }) - - it.each(['og-images', 'profile-pictures', 'workspace-logos', 'logs'])( - 'rejects quota-exempt context %s — not allowed via the multipart endpoint', - async (context) => { - const res = await makeInitiateRequest({ - fileName: 'asset.png', - contentType: 'image/png', - fileSize: 100 * 1024 * 1024 * 1024, - workspaceId: 'ws-1', - context, - }) - - const response = await POST(res) - expect(response.status).toBe(400) - const body = await response.json() - expect(body.error).toMatch(/invalid storage context/i) - expect(mockCheckStorageQuota).not.toHaveBeenCalled() - expect(mockInitiateS3MultipartUpload).not.toHaveBeenCalled() - } - ) -}) diff --git a/apps/sim/app/api/files/multipart/route.ts b/apps/sim/app/api/files/multipart/route.ts deleted file mode 100644 index 07fbac67361..00000000000 --- a/apps/sim/app/api/files/multipart/route.ts +++ /dev/null @@ -1,533 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { - abortMultipartUploadContract, - type CompleteMultipartBody, - completeMultipartUploadContract, - getMultipartPartUrlsContract, - initiateMultipartUploadContract, - multipartActionSchema, -} from '@/lib/api/contracts/storage-transfer' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - getStorageConfig, - getStorageProvider, - isUsingCloudStorage, - type StorageContext, -} from '@/lib/uploads' -import { deleteFile } from '@/lib/uploads/core/storage-service' -import { - signUploadToken, - type UploadTokenPayload, - verifyUploadToken, -} from '@/lib/uploads/core/upload-token' -import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' -import { QUOTA_EXEMPT_STORAGE_CONTEXTS, type StorageConfig } from '@/lib/uploads/shared/types' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('MultipartUploadAPI') - -/** - * Contexts the multipart endpoint accepts. Small public assets and internal logs - * are excluded because they have no large-file flow. Mothership remains - * available for large chat attachments but is quota-exempt because chat uploads - * do not count as durable workspace-file storage. Every other accepted context - * is quota-enforced below. - */ -const ALLOWED_UPLOAD_CONTEXTS = new Set([ - 'knowledge-base', - 'chat', - 'copilot', - 'mothership', - 'execution', - 'workspace', -]) - -/** - * Unified part identity sent by the client when completing a multipart upload. - * `etag` is required for S3 and GCS (CompleteMultipartUpload). For Azure the - * server derives the block id from `partNumber` via {@link deriveBlobBlockId}. - */ -interface ClientCompletedPart { - partNumber: number - etag?: string -} - -const isClientCompletedParts = (value: unknown): value is ClientCompletedPart[] => - Array.isArray(value) && - value.every( - (p) => - p !== null && - typeof p === 'object' && - typeof (p as ClientCompletedPart).partNumber === 'number' && - ((p as ClientCompletedPart).etag === undefined || - typeof (p as ClientCompletedPart).etag === 'string') - ) - -const buildS3CustomConfig = (config: StorageConfig) => - config.bucket && config.region ? { bucket: config.bucket, region: config.region } : undefined - -const buildBlobCustomConfig = (config: StorageConfig) => ({ - containerName: config.containerName!, - accountName: config.accountName!, - accountKey: config.accountKey, - connectionString: config.connectionString, -}) - -const buildGcsCustomConfig = (config: StorageConfig) => - config.bucket ? { bucket: config.bucket } : undefined - -const verifyTokenForUser = (token: string | undefined, userId: string) => { - if (!token || typeof token !== 'string') { - return null - } - const result = verifyUploadToken(token) - if (!result.valid || result.payload.userId !== userId) { - return null - } - return result.payload -} - -/** - * Record a trusted storage-key -> workspace ownership binding for completed - * knowledge-base uploads. KB file authorization resolves the owning workspace - * from this binding, so every KB object must have one. No-op for other contexts. - */ -const recordKnowledgeBaseOwnership = async ( - payload: UploadTokenPayload, - key: string -): Promise => { - if (payload.context !== 'knowledge-base' || !payload.workspaceId) { - return - } - await recordKnowledgeBaseFileOwnership({ - key, - userId: payload.userId, - workspaceId: payload.workspaceId, - originalName: payload.fileName ?? key.split('/').pop() ?? key, - contentType: payload.contentType ?? 'application/octet-stream', - size: typeof payload.fileSize === 'number' ? payload.fileSize : 0, - }) -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = session.user.id - - const actionParam = request.nextUrl.searchParams.get('action') - const actionResult = multipartActionSchema.safeParse(actionParam) - const action = actionResult.success ? actionResult.data : null - - if (!isUsingCloudStorage()) { - return NextResponse.json( - { - error: - 'Multipart upload is only available with cloud storage (S3, Azure Blob, or Google Cloud Storage)', - }, - { status: 400 } - ) - } - - const storageProvider = getStorageProvider() - - switch (action) { - case 'initiate': { - const parsed = await parseRequest( - initiateMultipartUploadContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const data = parsed.data.body - const { fileName, contentType, fileSize, workspaceId, context = 'knowledge-base' } = data - - if (!workspaceId || typeof workspaceId !== 'string') { - return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) - } - - if (!ALLOWED_UPLOAD_CONTEXTS.has(context as StorageContext)) { - return NextResponse.json({ error: 'Invalid storage context' }, { status: 400 }) - } - const storageContext = context as StorageContext - - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - const config = getStorageConfig(storageContext) - - if (!QUOTA_EXEMPT_STORAGE_CONTEXTS.has(storageContext)) { - const { checkStorageQuotaForBillingContext, resolveStorageBillingContext } = await import( - '@/lib/billing/storage' - ) - const storageBillingContext = await resolveStorageBillingContext(workspaceId) - const quotaCheck = await checkStorageQuotaForBillingContext( - storageBillingContext, - fileSize ?? 0 - ) - if (!quotaCheck.allowed) { - return NextResponse.json( - { error: quotaCheck.error || 'Storage limit exceeded' }, - { status: 413 } - ) - } - } - - let customKey: string | undefined - if (context === 'workspace' || context === 'mothership') { - const { MAX_WORKSPACE_FILE_SIZE } = await import('@/lib/uploads/shared/types') - if (typeof fileSize === 'number' && fileSize > MAX_WORKSPACE_FILE_SIZE) { - return NextResponse.json( - { error: `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` }, - { status: 413 } - ) - } - - const { generateWorkspaceFileKey } = await import( - '@/lib/uploads/contexts/workspace/workspace-file-manager' - ) - customKey = generateWorkspaceFileKey(workspaceId, fileName) - } else if (context === 'execution') { - const workflowId = (data as { workflowId?: unknown }).workflowId - const executionId = (data as { executionId?: unknown }).executionId - if (typeof workflowId !== 'string' || !workflowId.trim()) { - return NextResponse.json( - { error: 'workflowId is required for execution uploads' }, - { status: 400 } - ) - } - if (typeof executionId !== 'string' || !executionId.trim()) { - return NextResponse.json( - { error: 'executionId is required for execution uploads' }, - { status: 400 } - ) - } - const { generateExecutionFileKey } = await import( - '@/lib/uploads/contexts/execution/utils' - ) - customKey = generateExecutionFileKey({ workspaceId, workflowId, executionId }, fileName) - } - - let uploadId: string - let key: string - - if (storageProvider === 's3') { - const { initiateS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') - const result = await initiateS3MultipartUpload({ - fileName, - contentType, - fileSize, - customConfig: buildS3CustomConfig(config), - customKey, - purpose: context, - }) - uploadId = result.uploadId - key = result.key - } else if (storageProvider === 'blob') { - const { initiateMultipartUpload } = await import('@/lib/uploads/providers/blob/client') - const result = await initiateMultipartUpload({ - fileName, - contentType, - fileSize, - customConfig: buildBlobCustomConfig(config), - customKey, - }) - uploadId = result.uploadId - key = result.key - } else if (storageProvider === 'gcs') { - const { initiateGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') - const result = await initiateGcsMultipartUpload({ - fileName, - contentType, - fileSize, - customConfig: buildGcsCustomConfig(config), - customKey, - purpose: context, - }) - uploadId = result.uploadId - key = result.key - } else { - return NextResponse.json( - { error: `Unsupported storage provider: ${storageProvider}` }, - { status: 400 } - ) - } - - const uploadToken = signUploadToken({ - uploadId, - key, - userId, - workspaceId, - context: storageContext, - fileName, - contentType, - ...(typeof fileSize === 'number' ? { fileSize } : {}), - }) - - logger.info( - `Initiated ${storageProvider} multipart upload for ${fileName} (context: ${storageContext}, workspace: ${workspaceId}): ${uploadId}` - ) - - return NextResponse.json({ uploadId, key, uploadToken }) - } - - case 'get-part-urls': { - const parsed = await parseRequest( - getMultipartPartUrlsContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const data = parsed.data.body - const { partNumbers } = data - - const tokenPayload = verifyTokenForUser(data.uploadToken, userId) - if (!tokenPayload) { - return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) - } - - const { uploadId, key, context } = tokenPayload - const config = getStorageConfig(context) - - if (storageProvider === 's3') { - const { getS3MultipartPartUrls } = await import('@/lib/uploads/providers/s3/client') - const presignedUrls = await getS3MultipartPartUrls( - key, - uploadId, - partNumbers, - buildS3CustomConfig(config) - ) - return NextResponse.json({ presignedUrls }) - } - if (storageProvider === 'blob') { - const { getMultipartPartUrls } = await import('@/lib/uploads/providers/blob/client') - const presignedUrls = await getMultipartPartUrls( - key, - partNumbers, - buildBlobCustomConfig(config) - ) - return NextResponse.json({ presignedUrls }) - } - if (storageProvider === 'gcs') { - const { getGcsMultipartPartUrls } = await import('@/lib/uploads/providers/gcs/client') - const presignedUrls = await getGcsMultipartPartUrls( - key, - uploadId, - partNumbers, - buildGcsCustomConfig(config) - ) - return NextResponse.json({ presignedUrls }) - } - - return NextResponse.json( - { error: `Unsupported storage provider: ${storageProvider}` }, - { status: 400 } - ) - } - - case 'complete': { - const parsed = await parseRequest( - completeMultipartUploadContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const data: CompleteMultipartBody = parsed.data.body - - const s3Module = - storageProvider === 's3' ? await import('@/lib/uploads/providers/s3/client') : null - const blobModule = - storageProvider === 'blob' ? await import('@/lib/uploads/providers/blob/client') : null - const gcsModule = - storageProvider === 'gcs' ? await import('@/lib/uploads/providers/gcs/client') : null - - const completeOne = async (payload: UploadTokenPayload, parts: ClientCompletedPart[]) => { - const { uploadId, key, context } = payload - const config = getStorageConfig(context) - - let completed: { location: string; path: string; key: string } - if (storageProvider === 's3' && s3Module) { - const { completeS3MultipartUpload } = s3Module - const s3Parts = parts.map((p) => { - if (!p.etag) { - throw new Error(`Missing etag for S3 part ${p.partNumber}`) - } - return { ETag: p.etag, PartNumber: p.partNumber } - }) - completed = await completeS3MultipartUpload( - key, - uploadId, - s3Parts, - buildS3CustomConfig(config) - ) - } else if (storageProvider === 'blob' && blobModule) { - const { completeMultipartUpload, deriveBlobBlockId } = blobModule - const blobParts = parts.map((p) => ({ - partNumber: p.partNumber, - blockId: deriveBlobBlockId(p.partNumber), - })) - completed = await completeMultipartUpload(key, blobParts, buildBlobCustomConfig(config)) - } else if (storageProvider === 'gcs' && gcsModule) { - const { completeGcsMultipartUpload } = gcsModule - const gcsParts = parts.map((p) => { - if (!p.etag) { - throw new Error(`Missing etag for GCS part ${p.partNumber}`) - } - return { ETag: p.etag, PartNumber: p.partNumber } - }) - completed = await completeGcsMultipartUpload( - key, - uploadId, - gcsParts, - buildGcsCustomConfig(config) - ) - } else { - throw new Error(`Unsupported storage provider: ${storageProvider}`) - } - - try { - await recordKnowledgeBaseOwnership(payload, completed.key) - } catch (error) { - // The object is committed, but without an ownership binding a KB file - // is unreadable and undeletable via the KB paths. Remove the orphan - // best-effort and surface a retryable error so the client re-uploads. - if (payload.context === 'knowledge-base') { - await deleteFile({ key: completed.key, context: 'knowledge-base' }).catch(() => {}) - } - throw error - } - - return { - success: true as const, - location: completed.location, - path: completed.path, - key: completed.key, - } - } - - if ('uploads' in data && Array.isArray(data.uploads)) { - const verified: Array<{ payload: UploadTokenPayload; parts: ClientCompletedPart[] }> = [] - for (const upload of data.uploads) { - const payload = verifyTokenForUser(upload.uploadToken, userId) - if (!payload) { - return NextResponse.json( - { error: 'Invalid or expired upload token' }, - { status: 403 } - ) - } - if (!isClientCompletedParts(upload.parts)) { - return NextResponse.json( - { error: 'Invalid parts payload: expected [{ partNumber, etag? }]' }, - { status: 400 } - ) - } - verified.push({ payload, parts: upload.parts }) - } - - const results = await Promise.all( - verified.map(({ payload, parts }) => completeOne(payload, parts)) - ) - - logger.info(`Completed ${verified.length} multipart uploads`) - return NextResponse.json({ results }) - } - - const single = data - const tokenPayload = verifyTokenForUser(single.uploadToken, userId) - if (!tokenPayload) { - return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) - } - if (!isClientCompletedParts(single.parts)) { - return NextResponse.json( - { error: 'Invalid parts payload: expected [{ partNumber, etag? }]' }, - { status: 400 } - ) - } - - const result = await completeOne(tokenPayload, single.parts) - logger.info( - `Completed ${storageProvider} multipart upload for key ${tokenPayload.key} (context: ${tokenPayload.context})` - ) - return NextResponse.json(result) - } - - case 'abort': { - const parsed = await parseRequest( - abortMultipartUploadContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const data = parsed.data.body - const tokenPayload = verifyTokenForUser(data.uploadToken, userId) - if (!tokenPayload) { - return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) - } - - const { uploadId, key, context } = tokenPayload - const config = getStorageConfig(context) - - if (storageProvider === 's3') { - const { abortS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') - await abortS3MultipartUpload(key, uploadId, buildS3CustomConfig(config)) - logger.info(`Aborted S3 multipart upload for key ${key} (context: ${context})`) - } else if (storageProvider === 'blob') { - const { abortMultipartUpload } = await import('@/lib/uploads/providers/blob/client') - await abortMultipartUpload(key, buildBlobCustomConfig(config)) - logger.info(`Aborted Azure multipart upload for key ${key} (context: ${context})`) - } else if (storageProvider === 'gcs') { - const { abortGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') - await abortGcsMultipartUpload(key, uploadId, buildGcsCustomConfig(config)) - logger.info(`Aborted GCS multipart upload for key ${key} (context: ${context})`) - } else { - return NextResponse.json( - { error: `Unsupported storage provider: ${storageProvider}` }, - { status: 400 } - ) - } - - return NextResponse.json({ success: true }) - } - - default: - return NextResponse.json( - { error: 'Invalid action. Use: initiate, get-part-urls, complete, or abort' }, - { status: 400 } - ) - } - } catch (error) { - logger.error('Multipart upload error:', error) - return NextResponse.json( - { error: getErrorMessage(error, 'Multipart upload failed') }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/files/presigned/batch/route.test.ts b/apps/sim/app/api/files/presigned/batch/route.test.ts deleted file mode 100644 index 988fae9cce4..00000000000 --- a/apps/sim/app/api/files/presigned/batch/route.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Tests for the batch presigned upload API route - * - * @vitest-environment node - */ - -import { authMockFns, storageServiceMock, storageServiceMockFns } from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockValidateFileType, - mockGetUserEntityPermissions, - mockRecordKnowledgeBaseFileOwnershipMany, -} = vi.hoisted(() => ({ - mockValidateFileType: vi.fn().mockReturnValue(null), - mockGetUserEntityPermissions: vi.fn().mockResolvedValue('write'), - mockRecordKnowledgeBaseFileOwnershipMany: vi.fn().mockResolvedValue(undefined), -})) - -vi.mock('@/lib/uploads/config', () => ({ - getServeStoragePrefix: () => 's3', -})) - -vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) - -vi.mock('@/lib/uploads/utils/validation', () => ({ - validateFileType: mockValidateFileType, - SUPPORTED_ARCHIVE_EXTENSIONS: ['zip'] as const, -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, -})) - -vi.mock('@/lib/uploads/server/metadata', () => ({ - recordKnowledgeBaseFileOwnershipMany: mockRecordKnowledgeBaseFileOwnershipMany, -})) - -import { POST } from '@/app/api/files/presigned/batch/route' - -const KB_QUERY = 'type=knowledge-base&workspaceId=ws-1' - -const buildRequest = (query: string, files?: unknown) => - new NextRequest(`http://localhost:3000/api/files/presigned/batch?${query}`, { - method: 'POST', - body: JSON.stringify({ - files: files ?? [{ fileName: 'doc.pdf', contentType: 'application/pdf', fileSize: 1024 }], - }), - }) - -describe('/api/files/presigned/batch', () => { - beforeEach(() => { - vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - mockValidateFileType.mockReturnValue(null) - mockGetUserEntityPermissions.mockResolvedValue('write') - mockRecordKnowledgeBaseFileOwnershipMany.mockResolvedValue(undefined) - storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true) - storageServiceMockFns.mockGenerateBatchPresignedUploadUrls.mockImplementation( - async (files: Array<{ fileName: string }>, context: string) => - files.map((file) => ({ - url: `https://example.com/${context}/${file.fileName}`, - key: `${context}/${file.fileName}`, - })) - ) - }) - - it('returns 401 when the caller has no session', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const response = await POST(buildRequest(KB_QUERY)) - - expect(response.status).toBe(401) - expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() - }) - - it.each([ - 'workspace-logos', - 'profile-pictures', - 'execution', - 'mothership', - 'chat', - 'copilot', - 'workspace', - ])('refuses to presign the %s context', async (type) => { - const response = await POST(buildRequest(`type=${type}&workspaceId=ws-1`)) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toContain('Invalid type parameter') - expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() - }) - - it('returns 400 when type is missing', async () => { - const response = await POST(buildRequest('workspaceId=ws-1')) - - expect(response.status).toBe(400) - expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() - }) - - it('returns 400 when workspaceId is missing', async () => { - const response = await POST(buildRequest('type=knowledge-base')) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toContain('workspaceId') - expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() - expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() - }) - - it.each([['read'], [null]])( - 'returns 403 when the caller has %s access to the workspace', - async (permission) => { - mockGetUserEntityPermissions.mockResolvedValue(permission) - - const response = await POST(buildRequest(KB_QUERY)) - - expect(response.status).toBe(403) - expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() - } - ) - - it('authorizes the workspace before returning the local-storage fallback', async () => { - storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false) - mockGetUserEntityPermissions.mockResolvedValue('read') - - const response = await POST(buildRequest(KB_QUERY)) - - expect(response.status).toBe(403) - }) - - it('rejects unsupported file types before minting any URL', async () => { - mockValidateFileType.mockReturnValue({ - code: 'UNSUPPORTED_FILE_TYPE', - message: 'Unsupported file type: html.', - supportedTypes: ['pdf'], - }) - - const response = await POST( - buildRequest(KB_QUERY, [{ fileName: 'poc.html', contentType: 'text/html', fileSize: 41 }]) - ) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.code).toBe('UNSUPPORTED_FILE_TYPE') - expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() - }) - - it('mints knowledge-base URLs and records workspace ownership for a permitted caller', async () => { - const response = await POST(buildRequest(KB_QUERY)) - const data = await response.json() - - expect(response.status).toBe(200) - expect(mockGetUserEntityPermissions).toHaveBeenCalledWith('user-1', 'workspace', 'ws-1') - expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).toHaveBeenCalledWith( - [{ fileName: 'doc.pdf', contentType: 'application/pdf', fileSize: 1024 }], - 'knowledge-base', - 'user-1', - 3600 - ) - expect(data.files).toHaveLength(1) - expect(data.files[0].fileInfo.key).toBe('knowledge-base/doc.pdf') - expect(data.files[0].fileInfo.path).toContain('?context=knowledge-base') - expect(data.directUploadSupported).toBe(true) - expect(mockRecordKnowledgeBaseFileOwnershipMany).toHaveBeenCalledWith([ - { - key: 'knowledge-base/doc.pdf', - userId: 'user-1', - workspaceId: 'ws-1', - originalName: 'doc.pdf', - contentType: 'application/pdf', - size: 1024, - }, - ]) - }) - - it('returns the fallback response when cloud storage is not configured', async () => { - storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false) - - const response = await POST(buildRequest(KB_QUERY)) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.directUploadSupported).toBe(false) - expect(data.files[0].presignedUrl).toBe('') - expect(storageServiceMockFns.mockGenerateBatchPresignedUploadUrls).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/files/presigned/batch/route.ts b/apps/sim/app/api/files/presigned/batch/route.ts deleted file mode 100644 index 226fdc9ed87..00000000000 --- a/apps/sim/app/api/files/presigned/batch/route.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { - batchPresignedUploadBodyContract, - batchPresignedUploadTypeSchema, - batchPresignedUploadTypes, -} from '@/lib/api/contracts/storage-transfer' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getServeStoragePrefix } from '@/lib/uploads/config' -import { - generateBatchPresignedUploadUrls, - hasCloudStorage, -} from '@/lib/uploads/core/storage-service' -import { recordKnowledgeBaseFileOwnershipMany } from '@/lib/uploads/server/metadata' -import { validateFileType } from '@/lib/uploads/utils/validation' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { createErrorResponse } from '@/app/api/files/utils' - -const logger = createLogger('BatchPresignedUploadAPI') - -/** - * Mints presigned upload URLs for knowledge-base ingest, the only context this - * endpoint can authorize. Every request must name a workspace the caller has - * write access to; other storage contexts are rejected rather than presigned, - * because a presigned PUT is a write grant into a bucket served from a trusted - * origin. - */ -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest( - batchPresignedUploadBodyContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json( - { error: getValidationErrorMessage(error, 'Invalid request data') }, - { status: 400 } - ), - invalidJsonResponse: () => - NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const { files } = parsed.data.body - - const uploadTypeParam = request.nextUrl.searchParams.get('type') - if (!uploadTypeParam) { - return NextResponse.json({ error: 'type query parameter is required' }, { status: 400 }) - } - - const uploadTypeResult = batchPresignedUploadTypeSchema.safeParse(uploadTypeParam) - if (!uploadTypeResult.success) { - return NextResponse.json( - { - error: `Invalid type parameter. Must be one of: ${batchPresignedUploadTypes.join(', ')}`, - }, - { status: 400 } - ) - } - - const uploadType = uploadTypeResult.data - const sessionUserId = session.user.id - - for (const file of files) { - const fileValidationError = validateFileType(file.fileName, file.contentType) - if (fileValidationError) { - return NextResponse.json( - { - error: fileValidationError.message, - code: fileValidationError.code, - supportedTypes: fileValidationError.supportedTypes, - }, - { status: 400 } - ) - } - } - - const workspaceId = request.nextUrl.searchParams.get('workspaceId') - if (!workspaceId?.trim()) { - return NextResponse.json( - { error: 'workspaceId query parameter is required for knowledge-base uploads' }, - { status: 400 } - ) - } - - const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json( - { error: 'Write or Admin access required for knowledge-base uploads' }, - { status: 403 } - ) - } - - if (!hasCloudStorage()) { - logger.info( - `Local storage detected - batch presigned URLs not available, client will use API fallback` - ) - return NextResponse.json({ - files: files.map((file) => ({ - fileName: file.fileName, - presignedUrl: '', // Empty URL signals fallback to API upload - fileInfo: { - path: '', - key: '', - name: file.fileName, - size: file.fileSize, - type: file.contentType, - }, - directUploadSupported: false, - })), - directUploadSupported: false, - }) - } - - logger.info(`Generating batch ${uploadType} presigned URLs for ${files.length} files`) - - const startTime = Date.now() - - const presignedUrls = await generateBatchPresignedUploadUrls( - files.map((file) => ({ - fileName: file.fileName, - contentType: file.contentType, - fileSize: file.fileSize, - })), - uploadType, - sessionUserId, - 3600 // 1 hour - ) - - const duration = Date.now() - startTime - logger.info( - `Generated ${files.length} presigned URLs in ${duration}ms (avg ${Math.round(duration / files.length)}ms per file)` - ) - - await recordKnowledgeBaseFileOwnershipMany( - presignedUrls.map((urlResponse, index) => ({ - key: urlResponse.key, - userId: sessionUserId, - workspaceId, - originalName: files[index].fileName, - contentType: files[index].contentType, - size: files[index].fileSize, - })) - ) - - const storagePrefix = getServeStoragePrefix() - - return NextResponse.json({ - files: presignedUrls.map((urlResponse, index) => { - const finalPath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(urlResponse.key)}?context=${uploadType}` - const file = files[index] - - return { - fileName: file.fileName, - presignedUrl: urlResponse.url, - fileInfo: { - path: finalPath, - key: urlResponse.key, - name: file.fileName, - size: file.fileSize, - type: file.contentType, - }, - uploadHeaders: urlResponse.uploadHeaders, - directUploadSupported: true, - } - }), - directUploadSupported: true, - }) - } catch (error) { - logger.error('Error generating batch presigned URLs:', error) - return createErrorResponse( - error instanceof Error ? error : new Error('Failed to generate batch presigned URLs') - ) - } -}) diff --git a/apps/sim/app/api/files/presigned/route.test.ts b/apps/sim/app/api/files/presigned/route.test.ts deleted file mode 100644 index 3674ac70b76..00000000000 --- a/apps/sim/app/api/files/presigned/route.test.ts +++ /dev/null @@ -1,937 +0,0 @@ -/** - * Tests for file presigned API route - * - * @vitest-environment node - */ - -import { authMockFns, storageServiceMock, storageServiceMockFns } from '@sim/testing' -import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockVerifyFileAccess, - mockVerifyWorkspaceFileAccess, - mockUseBlobStorage, - mockUseS3Storage, - mockGetStorageConfig, - mockIsUsingCloudStorage, - mockGetStorageProvider, - mockValidateFileType, - mockValidateAttachmentFileType, - mockGenerateCopilotUploadUrl, - mockIsImageFileType, - mockGetStorageProviderUploads, - mockIsUsingCloudStorageUploads, - mockGetUserEntityPermissions, - mockGenerateWorkspaceFileKey, - mockGenerateExecutionFileKey, - mockInsertFileMetadata, - mockCheckStorageQuotaForBillingContext, - mockDecrementStorageUsageForBillingContext, - mockIncrementStorageUsageForBillingContext, - mockResolveStorageBillingContext, -} = vi.hoisted(() => ({ - mockVerifyFileAccess: vi.fn().mockResolvedValue(true), - mockVerifyWorkspaceFileAccess: vi.fn().mockResolvedValue(true), - mockUseBlobStorage: { value: false }, - mockUseS3Storage: { value: true }, - mockGetStorageConfig: vi.fn(), - mockIsUsingCloudStorage: vi.fn(), - mockGetStorageProvider: vi.fn(), - mockValidateFileType: vi.fn().mockReturnValue(null), - mockValidateAttachmentFileType: vi.fn().mockReturnValue(null), - mockGenerateCopilotUploadUrl: vi.fn().mockResolvedValue({ - url: 'https://example.com/presigned-url', - key: 'copilot/test-key.txt', - }), - mockIsImageFileType: vi.fn().mockReturnValue(true), - mockGetStorageProviderUploads: vi.fn(), - mockIsUsingCloudStorageUploads: vi.fn(), - mockGetUserEntityPermissions: vi.fn().mockResolvedValue('admin'), - mockGenerateWorkspaceFileKey: vi.fn( - (workspaceId: string, fileName: string) => `workspace/${workspaceId}/${fileName}` - ), - mockGenerateExecutionFileKey: vi.fn( - (ctx: { workspaceId: string; workflowId: string; executionId: string }, fileName: string) => - `execution/${ctx.workspaceId}/${ctx.workflowId}/${ctx.executionId}/${fileName}` - ), - mockInsertFileMetadata: vi.fn().mockResolvedValue({ id: 'wf_test' }), - mockCheckStorageQuotaForBillingContext: vi.fn(), - mockDecrementStorageUsageForBillingContext: vi.fn(), - mockIncrementStorageUsageForBillingContext: vi.fn(), - mockResolveStorageBillingContext: vi.fn(), -})) - -vi.mock('@/app/api/files/authorization', () => ({ - verifyFileAccess: mockVerifyFileAccess, - verifyWorkspaceFileAccess: mockVerifyWorkspaceFileAccess, -})) - -vi.mock('@/lib/uploads/config', () => ({ - get USE_BLOB_STORAGE() { - return mockUseBlobStorage.value - }, - get USE_S3_STORAGE() { - return mockUseS3Storage.value - }, - UPLOAD_DIR: '/uploads', - getServeStoragePrefix: () => (mockUseBlobStorage.value ? 'blob' : 's3'), - getStorageConfig: mockGetStorageConfig, - isUsingCloudStorage: mockIsUsingCloudStorage, - getStorageProvider: mockGetStorageProvider, -})) - -vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) - -vi.mock('@/lib/billing/storage', () => ({ - checkStorageQuotaForBillingContext: mockCheckStorageQuotaForBillingContext, - decrementStorageUsageForBillingContext: mockDecrementStorageUsageForBillingContext, - incrementStorageUsageForBillingContext: mockIncrementStorageUsageForBillingContext, - resolveStorageBillingContext: mockResolveStorageBillingContext, -})) - -vi.mock('@/lib/uploads/utils/validation', () => ({ - validateFileType: mockValidateFileType, - validateAttachmentFileType: mockValidateAttachmentFileType, -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - generateWorkspaceFileKey: mockGenerateWorkspaceFileKey, -})) - -vi.mock('@/lib/uploads/contexts/execution/utils', () => ({ - generateExecutionFileKey: mockGenerateExecutionFileKey, -})) - -vi.mock('@/lib/uploads/server/metadata', () => ({ - insertFileMetadata: mockInsertFileMetadata, - recordKnowledgeBaseFileOwnership: (ownership: Record) => - mockInsertFileMetadata({ ...ownership, context: 'knowledge-base' }), -})) - -vi.mock('@/lib/uploads/utils/file-utils', () => ({ - isImageFileType: mockIsImageFileType, -})) - -vi.mock('@/lib/uploads', () => ({ - CopilotFiles: { - generateCopilotUploadUrl: mockGenerateCopilotUploadUrl, - }, - getStorageProvider: mockGetStorageProviderUploads, - isUsingCloudStorage: mockIsUsingCloudStorageUploads, -})) - -import { POST } from '@/app/api/files/presigned/route' - -const defaultMockUser = { - id: 'test-user-id', - name: 'Test User', - email: 'test@example.com', -} - -function setupFileApiMocks( - options: { - authenticated?: boolean - storageProvider?: 's3' | 'blob' | 'local' - cloudEnabled?: boolean - } = {} -) { - const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options - - if (authenticated) { - authMockFns.mockGetSession.mockResolvedValue({ user: defaultMockUser }) - } else { - authMockFns.mockGetSession.mockResolvedValue(null) - } - - const useBlobStorage = storageProvider === 'blob' && cloudEnabled - const useS3Storage = storageProvider === 's3' && cloudEnabled - - mockUseBlobStorage.value = useBlobStorage - mockUseS3Storage.value = useS3Storage - - mockGetStorageConfig.mockReturnValue( - useBlobStorage - ? { - accountName: 'testaccount', - accountKey: 'testkey', - connectionString: 'testconnection', - containerName: 'testcontainer', - } - : { - bucket: 'test-bucket', - region: 'us-east-1', - } - ) - mockIsUsingCloudStorage.mockReturnValue(cloudEnabled) - mockGetStorageProvider.mockReturnValue( - storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local' - ) - - storageServiceMockFns.mockHasCloudStorage.mockReturnValue(cloudEnabled) - storageServiceMockFns.mockGeneratePresignedUploadUrl.mockImplementation( - async (opts: { fileName: string; context: string; customKey?: string }) => { - const timestamp = Date.now() - const safeFileName = opts.fileName.replace(/[^a-zA-Z0-9.-]/g, '_') - const key = opts.customKey ?? `${opts.context}/${timestamp}-ik3a6w4-${safeFileName}` - return { - url: 'https://example.com/presigned-url', - key, - } - } - ) - storageServiceMockFns.mockGeneratePresignedDownloadUrl.mockResolvedValue( - 'https://example.com/presigned-url' - ) - - mockValidateFileType.mockReturnValue(null) - mockValidateAttachmentFileType.mockReturnValue(null) - mockGetUserEntityPermissions.mockResolvedValue('admin') - - mockGetStorageProviderUploads.mockReturnValue( - storageProvider === 'blob' ? 'Azure Blob' : storageProvider === 's3' ? 'S3' : 'Local' - ) - mockIsUsingCloudStorageUploads.mockReturnValue(cloudEnabled) -} - -describe('/api/files/presigned', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.useFakeTimers() - vi.setSystemTime(new Date('2024-01-01T00:00:00Z')) - - vi.stubGlobal('crypto', { - randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), - }) - }) - - afterEach(() => { - vi.useRealTimers() - }) - - describe('POST', () => { - it('should return graceful fallback response when cloud storage is not enabled', async () => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 's3', - }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=profile-pictures', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'avatar.png', - contentType: 'image/png', - fileSize: 1024, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.directUploadSupported).toBe(false) - expect(data.presignedUrl).toBe('') - expect(data.fileName).toBe('avatar.png') - expect(data.fileInfo).toBeDefined() - expect(data.fileInfo.name).toBe('avatar.png') - expect(data.fileInfo.size).toBe(1024) - expect(data.fileInfo.type).toBe('image/png') - }) - - it('should return error when fileName is missing', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - const request = new NextRequest('http://localhost:3000/api/files/presigned', { - method: 'POST', - body: JSON.stringify({ - contentType: 'text/plain', - fileSize: 1024, - }), - }) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toBe('fileName is required and cannot be empty') - expect(data.code).toBe('VALIDATION_ERROR') - }) - - it('should return error when contentType is missing', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - const request = new NextRequest('http://localhost:3000/api/files/presigned', { - method: 'POST', - body: JSON.stringify({ - fileName: 'test.txt', - fileSize: 1024, - }), - }) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toBe('contentType is required and cannot be empty') - expect(data.code).toBe('VALIDATION_ERROR') - }) - - it('should return error when fileSize is invalid', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - const request = new NextRequest('http://localhost:3000/api/files/presigned', { - method: 'POST', - body: JSON.stringify({ - fileName: 'test.txt', - contentType: 'text/plain', - fileSize: 0, - }), - }) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toBe('fileSize must be a positive number') - expect(data.code).toBe('VALIDATION_ERROR') - }) - - it('should return error when file size exceeds limit', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - const largeFileSize = 150 * 1024 * 1024 // 150MB (exceeds 100MB limit) - const request = new NextRequest('http://localhost:3000/api/files/presigned', { - method: 'POST', - body: JSON.stringify({ - fileName: 'large-file.txt', - contentType: 'text/plain', - fileSize: largeFileSize, - }), - }) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toContain('exceeds maximum allowed size') - expect(data.code).toBe('VALIDATION_ERROR') - }) - - it('should generate S3 presigned URL successfully', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=profile-pictures', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'test avatar.png', - contentType: 'image/png', - fileSize: 1024, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.presignedUrl).toBe('https://example.com/presigned-url') - expect(data.fileInfo).toMatchObject({ - path: expect.stringMatching(/\/api\/files\/serve\/s3\/.+\?context=profile-pictures$/), - key: expect.stringMatching(/.*test.avatar\.png$/), - name: 'test avatar.png', - size: 1024, - type: 'image/png', - }) - expect(data.directUploadSupported).toBe(true) - }) - - it('should generate knowledge-base S3 presigned URL with kb prefix', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=knowledge-base&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'knowledge-doc.pdf', - contentType: 'application/pdf', - fileSize: 2048, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.fileInfo.key).toMatch(/^kb\/.*knowledge-doc\.pdf$/) - expect(data.directUploadSupported).toBe(true) - }) - - it('should generate profile-pictures S3 presigned URL with its prefix and direct path', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=profile-pictures', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'avatar.png', - contentType: 'image/png', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.fileInfo.key).toMatch(/^profile-pictures\/.*avatar\.png$/) - expect(data.fileInfo.path).toMatch(/\/api\/files\/serve\/s3\/.+\?context=profile-pictures$/) - expect(data.presignedUrl).toBeTruthy() - expect(data.directUploadSupported).toBe(true) - }) - - it('should generate Azure Blob presigned URL successfully', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 'blob', - }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=profile-pictures', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'test avatar.png', - contentType: 'image/png', - fileSize: 1024, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.presignedUrl).toBeTruthy() - expect(typeof data.presignedUrl).toBe('string') - expect(data.fileInfo).toMatchObject({ - key: expect.stringMatching(/.*test.avatar\.png$/), - name: 'test avatar.png', - size: 1024, - type: 'image/png', - }) - expect(data.directUploadSupported).toBe(true) - }) - - it('should generate profile-pictures Azure Blob presigned URL with its prefix and direct path', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 'blob', - }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=profile-pictures', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'avatar.png', - contentType: 'image/png', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.fileInfo.key).toMatch(/^profile-pictures\/.*avatar\.png$/) - expect(data.fileInfo.path).toMatch(/\/api\/files\/serve\/blob\/.+\?context=profile-pictures$/) - expect(data.presignedUrl).toBeTruthy() - expect(data.directUploadSupported).toBe(true) - }) - - it('should return error for unknown storage provider', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - storageServiceMockFns.mockGeneratePresignedUploadUrl.mockRejectedValue( - new Error('Unknown storage provider: unknown') - ) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=profile-pictures', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'avatar.png', - contentType: 'image/png', - fileSize: 1024, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.error).toBeTruthy() - expect(typeof data.error).toBe('string') - }) - - it('should handle S3 errors gracefully', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - storageServiceMockFns.mockGeneratePresignedUploadUrl.mockRejectedValue( - new Error('S3 service unavailable') - ) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=profile-pictures', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'avatar.png', - contentType: 'image/png', - fileSize: 1024, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.error).toBeTruthy() - expect(typeof data.error).toBe('string') - }) - - it('should handle Azure Blob errors gracefully', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 'blob', - }) - - storageServiceMockFns.mockGeneratePresignedUploadUrl.mockRejectedValue( - new Error('Azure service unavailable') - ) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=profile-pictures', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'avatar.png', - contentType: 'image/png', - fileSize: 1024, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.error).toBeTruthy() - expect(typeof data.error).toBe('string') - }) - - it('should handle malformed JSON gracefully', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - const request = new NextRequest('http://localhost:3000/api/files/presigned', { - method: 'POST', - body: 'invalid json', - }) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(400) // Changed from 500 to 400 (ValidationError) - expect(data.error).toBe('Invalid JSON in request body') // Updated error message - expect(data.code).toBe('VALIDATION_ERROR') - }) - - it('rejects the unauthorizable chat context without minting a URL', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const request = new NextRequest('http://localhost:3000/api/files/presigned?type=chat', { - method: 'POST', - body: JSON.stringify({ - fileName: 'poc.html', - contentType: 'text/html', - fileSize: 41, - }), - }) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toContain('Invalid type parameter') - expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).not.toHaveBeenCalled() - }) - }) - - describe('mothership uploads', () => { - it('uses validateAttachmentFileType (not validateFileType) — accepts images', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'screenshot.png', - contentType: 'image/png', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - expect(response.status).toBe(200) - expect(mockValidateAttachmentFileType).toHaveBeenCalledWith('screenshot.png', { - allowArchives: true, - }) - expect(mockValidateFileType).not.toHaveBeenCalled() - }) - - it('rejects unsupported types when validator returns an error', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - mockValidateAttachmentFileType.mockReturnValue({ - code: 'UNSUPPORTED_FILE_TYPE', - message: 'Unsupported file type: exe.', - supportedTypes: [], - }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'virus.exe', - contentType: 'application/octet-stream', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - expect(response.status).toBe(400) - expect(data.code).toBe('VALIDATION_ERROR') - expect(data.error).toContain('exe') - }) - - it('returns 403 when user lacks workspace write permission', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - mockGetUserEntityPermissions.mockResolvedValue('read') - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'doc.pdf', - contentType: 'application/pdf', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - expect(response.status).toBe(403) - }) - - it('issues an unbilled pending mothership upload binding', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'screenshot.png', - contentType: 'image/png', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(200) - expect(mockInsertFileMetadata).toHaveBeenCalledTimes(1) - expect(mockInsertFileMetadata).toHaveBeenCalledWith({ - key: data.fileInfo.key, - userId: 'test-user-id', - workspaceId: 'ws-1', - context: 'mothership', - originalName: 'screenshot.png', - contentType: 'image/png', - size: 4096, - }) - expect(mockCheckStorageQuotaForBillingContext).not.toHaveBeenCalled() - expect(mockResolveStorageBillingContext).not.toHaveBeenCalled() - expect(mockIncrementStorageUsageForBillingContext).not.toHaveBeenCalled() - expect(mockDecrementStorageUsageForBillingContext).not.toHaveBeenCalled() - }) - - it('returns 500 when insertFileMetadata fails so callers do not get an unauthorizable URL', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - mockInsertFileMetadata.mockRejectedValueOnce(new Error('DB connection lost')) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=mothership&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'screenshot.png', - contentType: 'image/png', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - expect(response.status).toBe(500) - }) - }) - - describe('execution uploads', () => { - it('uses validateAttachmentFileType — accepts video', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'output.mp4', - contentType: 'video/mp4', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - expect(response.status).toBe(200) - expect(mockValidateAttachmentFileType).toHaveBeenCalledWith('output.mp4') - expect(mockValidateFileType).not.toHaveBeenCalled() - }) - - it('rejects when validator returns an error', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - mockValidateAttachmentFileType.mockReturnValue({ - code: 'UNSUPPORTED_FILE_TYPE', - message: 'Unsupported file type: bin.', - supportedTypes: [], - }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'blob.bin', - contentType: 'application/octet-stream', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - expect(response.status).toBe(400) - expect(data.code).toBe('VALIDATION_ERROR') - }) - - it('returns 400 when missing workflowId/executionId', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'output.mp4', - contentType: 'video/mp4', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - expect(response.status).toBe(400) - }) - - it('inserts a workspaceFiles row with context=execution so previews authorize', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=execution&workspaceId=ws-1&workflowId=wf-1&executionId=exec-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'output.mp4', - contentType: 'video/mp4', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(200) - expect(mockInsertFileMetadata).toHaveBeenCalledTimes(1) - expect(mockInsertFileMetadata).toHaveBeenCalledWith({ - key: data.fileInfo.key, - userId: 'test-user-id', - workspaceId: 'ws-1', - context: 'execution', - originalName: 'output.mp4', - contentType: 'video/mp4', - size: 4096, - }) - }) - }) - - describe('workspace-logos uploads', () => { - it('inserts a workspaceFiles row with context=workspace-logos so logos authorize', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=workspace-logos&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'logo.png', - contentType: 'image/png', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - const data = await response.json() - - expect(response.status).toBe(200) - expect(mockInsertFileMetadata).toHaveBeenCalledTimes(1) - expect(mockInsertFileMetadata).toHaveBeenCalledWith({ - key: data.fileInfo.key, - userId: 'test-user-id', - workspaceId: 'ws-1', - context: 'workspace-logos', - originalName: 'logo.png', - contentType: 'image/png', - size: 4096, - }) - }) - }) - - describe('knowledge-base uploads', () => { - it('uses validateFileType (docs-only), not validateAttachmentFileType', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=knowledge-base&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'doc.pdf', - contentType: 'application/pdf', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - expect(response.status).toBe(200) - expect(mockValidateFileType).toHaveBeenCalledWith('doc.pdf', 'application/pdf') - expect(mockValidateAttachmentFileType).not.toHaveBeenCalled() - }) - - it('requires workspaceId for knowledge-base uploads', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=knowledge-base', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'doc.pdf', - contentType: 'application/pdf', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - expect(response.status).toBe(400) - }) - - it('returns 403 when the user lacks write access to the workspace', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - mockGetUserEntityPermissions.mockResolvedValue('read') - - const request = new NextRequest( - 'http://localhost:3000/api/files/presigned?type=knowledge-base&workspaceId=ws-1', - { - method: 'POST', - body: JSON.stringify({ - fileName: 'doc.pdf', - contentType: 'application/pdf', - fileSize: 4096, - }), - } - ) - - const response = await POST(request) - expect(response.status).toBe(403) - }) - }) -}) diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts deleted file mode 100644 index 49bec3aab16..00000000000 --- a/apps/sim/app/api/files/presigned/route.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { - presignedUploadBodyContract, - presignedUploadTypeSchema, - presignedUploadTypes, -} from '@/lib/api/contracts/storage-transfer' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { CopilotFiles } from '@/lib/uploads' -import { getServeStoragePrefix } from '@/lib/uploads/config' -import { generateExecutionFileKey } 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/workspace-file-manager' -import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service' -import { insertFileMetadata, recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' -import { isImageFileType } from '@/lib/uploads/utils/file-utils' -import { validateAttachmentFileType, validateFileType } from '@/lib/uploads/utils/validation' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { createErrorResponse } from '@/app/api/files/utils' - -const logger = createLogger('PresignedUploadAPI') - -class PresignedUrlError extends Error { - constructor( - message: string, - public code: string, - public statusCode = 400 - ) { - super(message) - this.name = 'PresignedUrlError' - } -} - -class ValidationError extends PresignedUrlError { - constructor(message: string) { - super(message, 'VALIDATION_ERROR', 400) - } -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest( - presignedUploadBodyContract, - request, - {}, - { - validationErrorResponse: (error) => { - throw new ValidationError(getValidationErrorMessage(error, 'Invalid request data')) - }, - invalidJsonResponse: () => { - throw new ValidationError('Invalid JSON in request body') - }, - } - ) - if (!parsed.success) return parsed.response - - const { fileName, contentType, fileSize } = parsed.data.body - - const uploadTypeParam = request.nextUrl.searchParams.get('type') - if (!uploadTypeParam) { - throw new ValidationError('type query parameter is required') - } - - const uploadTypeResult = presignedUploadTypeSchema.safeParse(uploadTypeParam) - if (!uploadTypeResult.success) { - throw new ValidationError( - `Invalid type parameter. Must be one of: ${presignedUploadTypes.join(', ')}` - ) - } - - const uploadType = uploadTypeResult.data - - if (uploadType === 'knowledge-base') { - const fileValidationError = validateFileType(fileName, contentType) - if (fileValidationError) { - throw new ValidationError(`${fileValidationError.message}`) - } - } - - const sessionUserId = session.user.id - - if (!hasCloudStorage()) { - logger.info( - `Local storage detected - presigned URL not available for ${fileName}, client will use API fallback` - ) - return NextResponse.json({ - fileName, - presignedUrl: '', // Empty URL signals fallback to API upload - fileInfo: { - path: '', - key: '', - name: fileName, - size: fileSize, - type: contentType, - }, - directUploadSupported: false, - }) - } - - logger.info(`Generating ${uploadType} presigned URL for ${fileName}`) - - let presignedUrlResponse - - if (uploadType === 'copilot') { - try { - presignedUrlResponse = await CopilotFiles.generateCopilotUploadUrl({ - fileName, - contentType, - fileSize, - userId: sessionUserId, - expirationSeconds: 3600, - }) - } catch (error) { - throw new ValidationError(getErrorMessage(error, 'Chat validation failed')) - } - } else if (uploadType === 'mothership') { - const workspaceId = request.nextUrl.searchParams.get('workspaceId') - if (!workspaceId?.trim()) { - throw new ValidationError('workspaceId query parameter is required for chat uploads') - } - - const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json( - { error: 'Write or Admin access required for chat uploads' }, - { status: 403 } - ) - } - - const fileValidationError = validateAttachmentFileType(fileName, { allowArchives: true }) - if (fileValidationError) { - throw new ValidationError(fileValidationError.message) - } - - const customKey = generateWorkspaceFileKey(workspaceId, fileName) - presignedUrlResponse = await generatePresignedUploadUrl({ - fileName, - contentType, - fileSize, - context: 'mothership', - userId: sessionUserId, - customKey, - expirationSeconds: 3600, - metadata: { workspaceId }, - }) - - await insertFileMetadata({ - key: presignedUrlResponse.key, - userId: sessionUserId, - workspaceId, - context: 'mothership', - originalName: fileName, - contentType, - size: fileSize, - }) - } else if (uploadType === 'execution') { - const workflowId = request.nextUrl.searchParams.get('workflowId') - const executionId = request.nextUrl.searchParams.get('executionId') - const workspaceId = request.nextUrl.searchParams.get('workspaceId') - if (!workflowId?.trim() || !executionId?.trim() || !workspaceId?.trim()) { - throw new ValidationError( - 'workflowId, executionId, and workspaceId query parameters are required for execution uploads' - ) - } - - const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json( - { error: 'Write or Admin access required for execution uploads' }, - { status: 403 } - ) - } - - const fileValidationError = validateAttachmentFileType(fileName) - if (fileValidationError) { - throw new ValidationError(fileValidationError.message) - } - - const customKey = generateExecutionFileKey({ workspaceId, workflowId, executionId }, fileName) - presignedUrlResponse = await generatePresignedUploadUrl({ - fileName, - contentType, - fileSize, - context: 'execution', - userId: sessionUserId, - customKey, - expirationSeconds: 3600, - metadata: { workspaceId, workflowId, executionId }, - }) - - await insertFileMetadata({ - key: presignedUrlResponse.key, - userId: sessionUserId, - workspaceId, - context: 'execution', - originalName: fileName, - contentType, - size: fileSize, - }) - } else if (uploadType === 'workspace-logos') { - const workspaceId = request.nextUrl.searchParams.get('workspaceId') - if (!workspaceId?.trim()) { - throw new ValidationError( - 'workspaceId query parameter is required for workspace-logos uploads' - ) - } - - const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) - if (permission !== 'admin') { - return NextResponse.json( - { error: 'Admin access required for workspace logo uploads' }, - { status: 403 } - ) - } - - if (!isImageFileType(contentType)) { - throw new ValidationError( - 'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for workspace logo uploads' - ) - } - - presignedUrlResponse = await generatePresignedUploadUrl({ - fileName, - contentType, - fileSize, - context: 'workspace-logos', - userId: sessionUserId, - expirationSeconds: 3600, - metadata: { workspaceId }, - }) - - await insertFileMetadata({ - key: presignedUrlResponse.key, - userId: sessionUserId, - workspaceId, - context: 'workspace-logos', - originalName: fileName, - contentType, - size: fileSize, - }) - } else if (uploadType === 'knowledge-base') { - const workspaceId = request.nextUrl.searchParams.get('workspaceId') - if (!workspaceId?.trim()) { - throw new ValidationError( - 'workspaceId query parameter is required for knowledge-base uploads' - ) - } - - const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json( - { error: 'Write or Admin access required for knowledge-base uploads' }, - { status: 403 } - ) - } - - const customKey = generateKnowledgeBaseFileKey(fileName) - presignedUrlResponse = await generatePresignedUploadUrl({ - fileName, - contentType, - fileSize, - context: 'knowledge-base', - userId: sessionUserId, - customKey, - expirationSeconds: 3600, - metadata: { workspaceId }, - }) - - await recordKnowledgeBaseFileOwnership({ - key: presignedUrlResponse.key, - userId: sessionUserId, - workspaceId, - originalName: fileName, - contentType, - size: fileSize, - }) - } else { - if (!isImageFileType(contentType)) { - throw new ValidationError( - 'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for profile picture uploads' - ) - } - - presignedUrlResponse = await generatePresignedUploadUrl({ - fileName, - contentType, - fileSize, - context: uploadType, - userId: sessionUserId, - expirationSeconds: 3600, // 1 hour - }) - } - - const finalPath = `/api/files/serve/${getServeStoragePrefix()}/${encodeURIComponent(presignedUrlResponse.key)}?context=${uploadType}` - - return NextResponse.json({ - fileName, - presignedUrl: presignedUrlResponse.url, - fileInfo: { - path: finalPath, - key: presignedUrlResponse.key, - name: fileName, - size: fileSize, - type: contentType, - }, - uploadHeaders: presignedUrlResponse.uploadHeaders, - directUploadSupported: true, - }) - } catch (error) { - logger.error('Error generating presigned URL:', error) - - if (error instanceof PresignedUrlError) { - return NextResponse.json( - { - error: error.message, - code: error.code, - directUploadSupported: false, - }, - { status: error.statusCode } - ) - } - - return createErrorResponse( - error instanceof Error ? error : new Error('Failed to generate presigned URL') - ) - } -}) diff --git a/apps/sim/app/api/files/upload/route.test.ts b/apps/sim/app/api/files/upload/route.test.ts deleted file mode 100644 index 034efb5eb70..00000000000 --- a/apps/sim/app/api/files/upload/route.test.ts +++ /dev/null @@ -1,814 +0,0 @@ -/** - * Tests for file upload API route - * - * @vitest-environment node - */ -import { - authMockFns, - hybridAuthMockFns, - permissionsMock, - permissionsMockFns, - storageServiceMock, - storageServiceMockFns, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => { - const mockVerifyFileAccess = vi.fn() - const mockVerifyWorkspaceFileAccess = vi.fn() - const mockVerifyKBFileAccess = vi.fn() - const mockVerifyCopilotFileAccess = vi.fn() - const mockUploadWorkspaceFile = vi.fn() - const mockGetStorageProvider = vi.fn() - const mockIsUsingCloudStorage = vi.fn() - const mockUploadFile = vi.fn() - const mockUploadExecutionFile = vi.fn() - const mockCheckStorageQuota = vi.fn() - const mockCheckStorageQuotaForBillingContext = vi.fn() - const mockDecrementStorageUsageForBillingContext = vi.fn() - const mockIncrementStorageUsageForBillingContext = vi.fn() - const mockResolveStorageBillingContext = vi.fn() - - return { - mockVerifyFileAccess, - mockVerifyWorkspaceFileAccess, - mockVerifyKBFileAccess, - mockVerifyCopilotFileAccess, - mockUploadWorkspaceFile, - mockGetStorageProvider, - mockIsUsingCloudStorage, - mockUploadFile, - mockUploadExecutionFile, - mockCheckStorageQuota, - mockCheckStorageQuotaForBillingContext, - mockDecrementStorageUsageForBillingContext, - mockIncrementStorageUsageForBillingContext, - mockResolveStorageBillingContext, - } -}) - -vi.mock('@sim/utils/id', () => ({ - generateId: vi.fn(() => 'test-uuid'), - generateShortId: vi.fn(() => 'mock-short-id'), - isValidUuid: vi.fn((v: string) => - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v) - ), -})) - -vi.mock('@/app/api/files/authorization', () => ({ - verifyFileAccess: mocks.mockVerifyFileAccess, - verifyWorkspaceFileAccess: mocks.mockVerifyWorkspaceFileAccess, - verifyKBFileAccess: mocks.mockVerifyKBFileAccess, - verifyCopilotFileAccess: mocks.mockVerifyCopilotFileAccess, -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) - -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - uploadWorkspaceFile: mocks.mockUploadWorkspaceFile, -})) - -vi.mock('@/lib/uploads/contexts/execution', () => ({ - uploadExecutionFile: mocks.mockUploadExecutionFile, -})) - -vi.mock('@/lib/uploads', () => ({ - getStorageProvider: mocks.mockGetStorageProvider, - isUsingCloudStorage: mocks.mockIsUsingCloudStorage, - uploadFile: mocks.mockUploadFile, -})) - -vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) - -vi.mock('@/lib/billing/storage', () => ({ - checkStorageQuota: mocks.mockCheckStorageQuota, - checkStorageQuotaForBillingContext: mocks.mockCheckStorageQuotaForBillingContext, - decrementStorageUsageForBillingContext: mocks.mockDecrementStorageUsageForBillingContext, - incrementStorageUsageForBillingContext: mocks.mockIncrementStorageUsageForBillingContext, - resolveStorageBillingContext: mocks.mockResolveStorageBillingContext, -})) - -vi.mock('@/lib/uploads/shared/types', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - MAX_WORKSPACE_FORMDATA_FILE_SIZE: 1024, - } -}) - -vi.mock('@/lib/uploads/setup.server', () => ({ - UPLOAD_DIR_SERVER: '/tmp/test-uploads', -})) - -import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { POST } from '@/app/api/files/upload/route' - -/** - * Configure mocks for authenticated file upload tests - */ -function setupFileApiMocks( - options: { - authenticated?: boolean - storageProvider?: 's3' | 'blob' | 'local' - cloudEnabled?: boolean - } = {} -) { - const { authenticated = true, storageProvider = 's3', cloudEnabled = true } = options - - vi.stubGlobal('crypto', { - randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), - }) - - if (authenticated) { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'test-user-id' } }) - } else { - authMockFns.mockGetSession.mockResolvedValue(null) - } - - hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({ - success: authenticated, - userId: authenticated ? 'test-user-id' : undefined, - error: authenticated ? undefined : 'Unauthorized', - }) - - mocks.mockVerifyFileAccess.mockResolvedValue(true) - mocks.mockVerifyWorkspaceFileAccess.mockResolvedValue(true) - mocks.mockVerifyKBFileAccess.mockResolvedValue(true) - mocks.mockVerifyCopilotFileAccess.mockResolvedValue(true) - - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') - - mocks.mockUploadWorkspaceFile.mockResolvedValue({ - id: 'test-file-id', - name: 'test.txt', - url: '/api/files/serve/workspace/test-workspace-id/test-file.txt', - size: 100, - type: 'text/plain', - key: 'workspace/test-workspace-id/1234567890-test.txt', - uploadedAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - }) - - mocks.mockUploadExecutionFile.mockResolvedValue({ - id: 'test-execution-file-id', - name: 'test.txt', - url: '/api/files/serve/execution/test-workspace-id/test-file.txt', - size: 100, - type: 'text/plain', - key: 'execution/test-workspace-id/1234567890-test.txt', - uploadedAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - }) - - mocks.mockGetStorageProvider.mockReturnValue(storageProvider) - mocks.mockIsUsingCloudStorage.mockReturnValue(cloudEnabled) - mocks.mockUploadFile.mockResolvedValue({ - path: '/api/files/serve/test-key.txt', - key: 'test-key.txt', - name: 'test.txt', - size: 100, - type: 'text/plain', - }) - - storageServiceMockFns.mockHasCloudStorage.mockReturnValue(cloudEnabled) - storageServiceMockFns.mockUploadFile.mockResolvedValue({ - key: 'test-key', - path: '/test/path', - }) - - mocks.mockCheckStorageQuota.mockResolvedValue({ - allowed: true, - currentUsage: 0, - limit: Number.MAX_SAFE_INTEGER, - }) -} - -describe('File Upload API Route', () => { - const createMockFormData = (files: File[], context = 'workspace'): FormData => { - const formData = new FormData() - formData.append('context', context) - formData.append('workspaceId', 'test-workspace-id') - files.forEach((file) => { - formData.append('file', file) - }) - return formData - } - - const createMockFile = ( - name = 'test.txt', - type = 'text/plain', - content = 'test content' - ): File => { - return new File([content], name, { type }) - } - - const createUploadRequest = (formData: FormData): NextRequest => - new NextRequest('http://localhost:3000/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('should upload a file to local storage', async () => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 'local', - }) - - const mockFile = createMockFile() - const formData = createMockFormData([mockFile]) - - const req = createUploadRequest(formData) - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data).toHaveProperty('url') - expect(data.url).toMatch(/\/api\/files\/serve\/.*\.txt$/) - expect(data).toHaveProperty('name', 'test.txt') - expect(data).toHaveProperty('size') - expect(data).toHaveProperty('type', 'text/plain') - expect(data).toHaveProperty('key') - - expect(uploadWorkspaceFile).toHaveBeenCalled() - }) - - it('should accept chunked multipart uploads without a content-length header', async () => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 'local', - }) - - const formData = createMockFormData([createMockFile()]) - const req = new NextRequest('http://localhost:3000/api/files/upload', { - method: 'POST', - body: formData, - }) - - expect(req.headers.get('content-length')).toBeNull() - - const response = await POST(req) - - expect(response.status).toBe(200) - expect(uploadWorkspaceFile).toHaveBeenCalled() - }) - - it('should upload a file to S3 when in S3 mode', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - const mockFile = createMockFile() - const formData = createMockFormData([mockFile]) - - const req = createUploadRequest(formData) - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data).toHaveProperty('url') - expect(data.url).toContain('/api/files/serve/') - expect(data).toHaveProperty('name', 'test.txt') - expect(data).toHaveProperty('size') - expect(data).toHaveProperty('type', 'text/plain') - expect(data).toHaveProperty('key') - - expect(uploadWorkspaceFile).toHaveBeenCalled() - }) - - it('uploads a direct mothership attachment without workspace storage accounting', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - - const response = await POST( - createUploadRequest(createMockFormData([createMockFile('attachment.txt')], 'mothership')) - ) - - expect(response.status).toBe(200) - expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledWith( - expect.objectContaining({ context: 'mothership' }) - ) - expect(mocks.mockCheckStorageQuotaForBillingContext).not.toHaveBeenCalled() - expect(mocks.mockResolveStorageBillingContext).not.toHaveBeenCalled() - expect(mocks.mockIncrementStorageUsageForBillingContext).not.toHaveBeenCalled() - expect(mocks.mockDecrementStorageUsageForBillingContext).not.toHaveBeenCalled() - }) - - it('does not mutate storage counters when a direct mothership upload fails', async () => { - setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - storageServiceMockFns.mockUploadFile.mockRejectedValueOnce(new Error('storage unavailable')) - - const response = await POST( - createUploadRequest(createMockFormData([createMockFile('attachment.txt')], 'mothership')) - ) - - expect(response.status).toBe(500) - expect(mocks.mockCheckStorageQuotaForBillingContext).not.toHaveBeenCalled() - expect(mocks.mockResolveStorageBillingContext).not.toHaveBeenCalled() - expect(mocks.mockIncrementStorageUsageForBillingContext).not.toHaveBeenCalled() - expect(mocks.mockDecrementStorageUsageForBillingContext).not.toHaveBeenCalled() - }) - - it('should handle multiple file uploads', async () => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 'local', - }) - - const mockFile1 = createMockFile('file1.txt', 'text/plain') - const mockFile2 = createMockFile('file2.txt', 'text/plain') - const formData = createMockFormData([mockFile1, mockFile2]) - - const req = createUploadRequest(formData) - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBeGreaterThanOrEqual(200) - expect(response.status).toBeLessThan(600) - expect(data).toBeDefined() - }) - - it('rejects oversized workspace uploads before materializing file contents', async () => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 'local', - }) - - const mockFile = createMockFile('large.txt', 'text/plain', 'x'.repeat(1025)) - const arrayBufferSpy = vi.spyOn(mockFile, 'arrayBuffer') - const formData = { - getAll: (name: string) => (name === 'file' ? [mockFile] : []), - get: (name: string) => { - if (name === 'context') return 'workspace' - if (name === 'workspaceId') return 'test-workspace-id' - return null - }, - } as unknown as FormData - - const req = { - formData: async () => formData, - } as unknown as NextRequest - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(413) - expect(data.error).toBe('PayloadSizeLimitError') - expect(data.message).toContain('File exceeds the server upload limit') - expect(data.message).toContain('Use direct upload for larger workspace files') - expect(arrayBufferSpy).not.toHaveBeenCalled() - expect(uploadWorkspaceFile).not.toHaveBeenCalled() - }) - - it('should handle missing files', async () => { - setupFileApiMocks() - - const formData = new FormData() - - const req = createUploadRequest(formData) - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data).toHaveProperty('error', 'InvalidRequestError') - expect(data).toHaveProperty('message', 'No files provided') - }) - - it('should handle S3 upload errors', async () => { - setupFileApiMocks({ - cloudEnabled: true, - storageProvider: 's3', - }) - - mocks.mockUploadWorkspaceFile.mockRejectedValue(new Error('Storage limit exceeded')) - - const mockFile = createMockFile() - const formData = createMockFormData([mockFile]) - - const req = createUploadRequest(formData) - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(413) - expect(data).toHaveProperty('error') - expect(typeof data.error).toBe('string') - }) -}) - -describe('File Upload Security Tests', () => { - beforeEach(() => { - vi.clearAllMocks() - - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'test-user-id' }, - }) - - storageServiceMockFns.mockHasCloudStorage.mockReturnValue(false) - storageServiceMockFns.mockUploadFile.mockResolvedValue({ - key: 'test-key', - path: '/test/path', - }) - mocks.mockIsUsingCloudStorage.mockReturnValue(false) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - describe('File Extension Validation', () => { - beforeEach(() => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 'local', - }) - }) - - it('should accept allowed file types', async () => { - const allowedTypes = [ - 'pdf', - 'doc', - 'docx', - 'txt', - 'md', - 'png', - 'jpg', - 'jpeg', - 'gif', - 'csv', - 'xlsx', - 'xls', - ] - - for (const ext of allowedTypes) { - const formData = new FormData() - const file = new File(['test content'], `test.${ext}`, { type: 'application/octet-stream' }) - formData.append('file', file) - formData.append('context', 'workspace') - formData.append('workspaceId', 'test-workspace-id') - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) - - expect(response.status).toBe(200) - } - }) - - it('should accept HTML files (supported document type)', async () => { - const formData = new FormData() - const htmlContent = '

Hello World

' - const file = new File([htmlContent], 'document.html', { type: 'text/html' }) - formData.append('file', file) - formData.append('context', 'workspace') - formData.append('workspaceId', 'test-workspace-id') - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) - - expect(response.status).toBe(200) - }) - - it('should accept SVG files (supported image type)', async () => { - const formData = new FormData() - const svgContent = - '' - const file = new File([svgContent], 'image.svg', { type: 'image/svg+xml' }) - formData.append('file', file) - formData.append('context', 'workspace') - formData.append('workspaceId', 'test-workspace-id') - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) - - expect(response.status).toBe(200) - }) - - it('should reject unsupported file types', async () => { - const formData = new FormData() - const content = 'binary data' - const file = new File([content], 'archive.exe', { type: 'application/octet-stream' }) - formData.append('file', file) - formData.append('context', 'workspace') - formData.append('workspaceId', 'test-workspace-id') - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) - - expect(response.status).toBe(400) - const data = await response.json() - expect(data.message).toContain("File type 'exe' is not allowed") - }) - - it('should reject files without extensions', async () => { - const formData = new FormData() - const file = new File(['test content'], 'noextension', { type: 'application/octet-stream' }) - formData.append('file', file) - formData.append('context', 'workspace') - formData.append('workspaceId', 'test-workspace-id') - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) - - expect(response.status).toBe(400) - const data = await response.json() - expect(data.message).toContain("File type 'noextension' is not allowed") - }) - - it('should handle multiple files with mixed valid/invalid types', async () => { - const formData = new FormData() - - const validFile = new File(['valid content'], 'valid.pdf', { type: 'application/pdf' }) - formData.append('file', validFile) - - const invalidFile = new File(['binary content'], 'malicious.exe', { - type: 'application/x-msdownload', - }) - formData.append('file', invalidFile) - formData.append('context', 'workspace') - formData.append('workspaceId', 'test-workspace-id') - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) - - expect(response.status).toBe(400) - const data = await response.json() - expect(data.message).toContain("File type 'exe' is not allowed") - }) - }) - - describe('Execution Context Permission Gate', () => { - const createExecutionFormData = ( - file: File, - workspaceId: string | null = 'test-workspace-id' - ) => { - const formData = new FormData() - formData.append('file', file) - formData.append('context', 'execution') - formData.append('workflowId', 'test-workflow-id') - formData.append('executionId', 'test-execution-id') - if (workspaceId !== null) formData.append('workspaceId', workspaceId) - return formData - } - - const postExecutionUpload = async (workspaceId: string | null = 'test-workspace-id') => { - const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) - const formData = createExecutionFormData(file, workspaceId) - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - return POST(req as unknown as NextRequest) - } - - beforeEach(() => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 'local', - }) - }) - - it('rejects execution uploads without workspaceId', async () => { - const response = await postExecutionUpload(null) - - expect(response.status).toBe(400) - const data = await response.json() - expect(data.message).toContain('workflowId, executionId, and workspaceId') - expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled() - }) - - it('rejects execution uploads for a read-only workspace member', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read') - - const response = await postExecutionUpload() - - expect(response.status).toBe(403) - const data = await response.json() - expect(data.error).toBe('Write or Admin access required for execution uploads') - expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled() - }) - - it('rejects execution uploads for a member with no workspace permission', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue(null) - - const response = await postExecutionUpload() - - expect(response.status).toBe(403) - expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled() - }) - - it('allows execution uploads for a write-permission workspace member', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - - const response = await postExecutionUpload() - - expect(response.status).toBe(200) - expect(mocks.mockUploadExecutionFile).toHaveBeenCalledWith( - { - workspaceId: 'test-workspace-id', - workflowId: 'test-workflow-id', - executionId: 'test-execution-id', - }, - expect.anything(), - 'test.pdf', - 'application/pdf', - 'test-user-id' - ) - }) - - it('allows execution uploads for an admin-permission workspace member', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') - - const response = await postExecutionUpload() - - expect(response.status).toBe(200) - expect(mocks.mockUploadExecutionFile).toHaveBeenCalled() - }) - }) - - describe('Mothership Context Permission Gate', () => { - const postMothershipUpload = async (workspaceId: string | null = 'test-workspace-id') => { - const formData = new FormData() - const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) - formData.append('file', file) - formData.append('context', 'mothership') - if (workspaceId !== null) formData.append('workspaceId', workspaceId) - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - return POST(req as unknown as NextRequest) - } - - beforeEach(() => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 'local', - }) - }) - - it('rejects mothership uploads without workspaceId', async () => { - const response = await postMothershipUpload(null) - - expect(response.status).toBe(400) - const data = await response.json() - expect(data.message).toContain('workspaceId') - expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() - }) - - it('rejects mothership uploads for a workspace the caller does not belong to', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue(null) - - const response = await postMothershipUpload() - - expect(response.status).toBe(403) - const data = await response.json() - expect(data.error).toBe('Write or Admin access required for mothership uploads') - expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() - }) - - it('rejects mothership uploads for a read-only workspace member', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read') - - const response = await postMothershipUpload() - - expect(response.status).toBe(403) - expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() - }) - - it('rejects mothership uploads over the caller storage quota', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mocks.mockCheckStorageQuota.mockResolvedValue({ - allowed: false, - currentUsage: 100, - limit: 100, - error: 'Storage limit exceeded. Used: 0.00GB, Limit: 0GB', - }) - - const response = await postMothershipUpload() - - expect(response.status).toBe(413) - const data = await response.json() - expect(data.error).toContain('Storage limit exceeded') - expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() - }) - - it('allows mothership uploads for a write-permission workspace member', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - - const response = await postMothershipUpload() - - expect(response.status).toBe(200) - expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledWith( - 'test-user-id', - 'workspace', - 'test-workspace-id' - ) - expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalled() - }) - - it('allows mothership uploads for an admin-permission workspace member', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') - - const response = await postMothershipUpload() - - expect(response.status).toBe(200) - expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalled() - }) - - it('checks quota once against the combined size of a multi-file batch', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - - const formData = new FormData() - const fileA = new File(['a'.repeat(10)], 'a.pdf', { type: 'application/pdf' }) - const fileB = new File(['b'.repeat(20)], 'b.pdf', { type: 'application/pdf' }) - formData.append('file', fileA) - formData.append('file', fileB) - formData.append('context', 'mothership') - formData.append('workspaceId', 'test-workspace-id') - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) - - expect(response.status).toBe(200) - expect(mocks.mockCheckStorageQuota).toHaveBeenCalledTimes(1) - expect(mocks.mockCheckStorageQuota).toHaveBeenCalledWith('test-user-id', 30) - expect(permissionsMockFns.mockGetUserEntityPermissions).toHaveBeenCalledTimes(1) - }) - }) - - describe('Authentication Requirements', () => { - it('should reject uploads without authentication', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const formData = new FormData() - const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) - formData.append('file', file) - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) - - expect(response.status).toBe(401) - const data = await response.json() - expect(data.error).toBe('Unauthorized') - }) - }) -}) diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts deleted file mode 100644 index 1c013d19c98..00000000000 --- a/apps/sim/app/api/files/upload/route.ts +++ /dev/null @@ -1,488 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { sanitizeFileName } from '@/executor/constants' -import '@/lib/uploads/core/setup.server' -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { - uploadFilesFormFieldsSchema, - uploadFilesFormFilesSchema, -} from '@/lib/api/contracts/storage-transfer' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { - assertKnownSizeWithinLimit, - isPayloadSizeLimitError, - MAX_MULTIPART_OVERHEAD_BYTES, - readFileToBufferWithLimit, - readFormDataWithLimit, -} from '@/lib/core/utils/stream-limits' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import type { StorageContext } from '@/lib/uploads/config' -import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' -import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types' -import { isArchiveFileName, isImageFileType, resolveFileType } from '@/lib/uploads/utils/file-utils' -import { - SUPPORTED_ATTACHMENT_EXTENSIONS, - SUPPORTED_IMAGE_EXTENSIONS, - validateFileType, -} from '@/lib/uploads/utils/validation' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { createErrorResponse, InvalidRequestError } from '@/app/api/files/utils' - -const ALLOWED_EXTENSIONS = new Set(SUPPORTED_ATTACHMENT_EXTENSIONS) - -function validateFileExtension(filename: string, context: StorageContext): boolean { - const extension = filename.split('.').pop()?.toLowerCase() - if (!extension) return false - // Archives are only extractable in the mothership copilot flow; every other - // context keeps rejecting them up front instead of failing downstream. - if (context === 'mothership' && isArchiveFileName(filename)) return true - return ALLOWED_EXTENSIONS.has(extension) -} - -export const dynamic = 'force-dynamic' - -const logger = createLogger('FilesUploadAPI') - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const formData = await readFormDataWithLimit(request, { - maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, - label: 'multipart upload body', - }) - - const rawFiles = formData.getAll('file') - const filesResult = uploadFilesFormFilesSchema.safeParse(rawFiles) - if (!filesResult.success) { - throw new InvalidRequestError('No files provided') - } - const files = filesResult.data - const totalFileSize = files.reduce((total, file) => total + file.size, 0) - assertKnownSizeWithinLimit(totalFileSize, MAX_WORKSPACE_FORMDATA_FILE_SIZE, 'uploaded files') - - const formFieldsResult = uploadFilesFormFieldsSchema.safeParse({ - workflowId: formData.get('workflowId'), - executionId: formData.get('executionId'), - workspaceId: formData.get('workspaceId'), - context: formData.get('context'), - }) - if (!formFieldsResult.success) { - throw new InvalidRequestError( - getValidationErrorMessage(formFieldsResult.error, 'Invalid upload form data') - ) - } - const formFields = formFieldsResult.data - const { workflowId, executionId, workspaceId, context: contextParam } = formFields - - // Context must be explicitly provided - if (!contextParam) { - throw new InvalidRequestError( - 'Upload requires explicit context parameter (knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos)' - ) - } - - const context = contextParam as StorageContext - - const storageService = await import('@/lib/uploads/core/storage-service') - const usingCloudStorage = storageService.hasCloudStorage() - logger.info(`Using storage mode: ${usingCloudStorage ? 'Cloud' : 'Local'} for file upload`) - - // Execution context requires a workspace write/admin permission check. Resolve it once per - // request (not per file) since workspaceId is invariant across all files in the upload. - let executionUploadContext: - | { workspaceId: string; workflowId: string; executionId: string } - | undefined - if (context === 'execution') { - if (!workflowId || !executionId || !workspaceId) { - throw new InvalidRequestError( - 'Execution context requires workflowId, executionId, and workspaceId parameters' - ) - } - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json( - { error: 'Write or Admin access required for execution uploads' }, - { status: 403 } - ) - } - - executionUploadContext = { workspaceId, workflowId, executionId } - } - - // Mothership context requires the same workspace write/admin permission check, plus a - // storage quota check. Resolve both once per request (not per file) since workspaceId is - // invariant across all files in the upload and quota must account for the full batch size, - // not just one file. - let mothershipWorkspaceId: string | undefined - if (context === 'mothership') { - if (!workspaceId) { - throw new InvalidRequestError('Mothership context requires workspaceId parameter') - } - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json( - { error: 'Write or Admin access required for mothership uploads' }, - { status: 403 } - ) - } - - const { checkStorageQuota } = await import('@/lib/billing/storage') - const quotaCheck = await checkStorageQuota(session.user.id, totalFileSize) - if (!quotaCheck.allowed) { - return NextResponse.json( - { error: quotaCheck.error || 'Storage limit exceeded' }, - { status: 413 } - ) - } - - mothershipWorkspaceId = workspaceId - } - - const uploadResults = [] - - for (const file of files) { - const originalName = file.name || 'untitled.md' - - if (!validateFileExtension(originalName, context)) { - const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown' - throw new InvalidRequestError( - `File type '${extension}' is not allowed. Allowed types: ${Array.from(ALLOWED_EXTENSIONS).join(', ')}` - ) - } - - const buffer = await readFileToBufferWithLimit(file, { - maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE, - label: 'uploaded file', - }) - - // Handle execution context - if (context === 'execution' && executionUploadContext) { - const { uploadExecutionFile } = await import('@/lib/uploads/contexts/execution') - const userFile = await uploadExecutionFile( - executionUploadContext, - buffer, - originalName, - file.type, - session.user.id - ) - - uploadResults.push(userFile) - continue - } - - // Handle knowledge-base context - if (context === 'knowledge-base') { - // Validate file type for knowledge base - const validationError = validateFileType(originalName, file.type) - if (validationError) { - throw new InvalidRequestError(validationError.message) - } - - if (!workspaceId) { - throw new InvalidRequestError('workspaceId is required for knowledge-base uploads') - } - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json( - { error: 'Write or Admin access required for knowledge-base uploads' }, - { status: 403 } - ) - } - - logger.info(`Uploading knowledge-base file: ${originalName}`) - - const storageKey = generateKnowledgeBaseFileKey(originalName) - - const metadata: Record = { - originalName: originalName, - uploadedAt: new Date().toISOString(), - purpose: 'knowledge-base', - userId: session.user.id, - workspaceId, - } - - const fileInfo = await storageService.uploadFile({ - file: buffer, - fileName: storageKey, - contentType: file.type, - context: 'knowledge-base', - preserveKey: true, - customKey: storageKey, - metadata, - }) - - const finalPath = usingCloudStorage - ? `${fileInfo.path}?context=knowledge-base` - : fileInfo.path - - const uploadResult = { - fileName: originalName, - presignedUrl: '', // Not used for server-side uploads - fileInfo: { - path: finalPath, - key: fileInfo.key, - name: originalName, - size: buffer.length, - type: file.type, - }, - directUploadSupported: false, - } - - logger.info(`Successfully uploaded knowledge-base file: ${fileInfo.key}`) - uploadResults.push(uploadResult) - continue - } - - // Handle workspace context - if (context === 'workspace') { - if (!workspaceId) { - throw new InvalidRequestError('Workspace context requires workspaceId parameter') - } - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json( - { error: 'Write or Admin access required for workspace uploads' }, - { status: 403 } - ) - } - - try { - const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace') - const userFile = await uploadWorkspaceFile( - workspaceId, - session.user.id, - buffer, - originalName, - file.type || 'application/octet-stream' - ) - - uploadResults.push(userFile) - continue - } catch (workspaceError) { - const errorMessage = getErrorMessage(workspaceError, 'Upload failed') - const isDuplicate = errorMessage.includes('already exists') - const isStorageLimitError = - errorMessage.includes('Storage limit exceeded') || - errorMessage.includes('storage limit') - - logger.warn(`Workspace file upload failed: ${errorMessage}`) - - let statusCode = 500 - if (isDuplicate) statusCode = 409 - else if (isStorageLimitError) statusCode = 413 - - return NextResponse.json( - { - success: false, - error: errorMessage, - isDuplicate, - }, - { status: statusCode } - ) - } - } - - // Handle mothership context (chat-scoped uploads to workspace S3) - if (context === 'mothership' && mothershipWorkspaceId) { - logger.info(`Uploading mothership file: ${originalName}`) - - const storageKey = generateWorkspaceFileKey(mothershipWorkspaceId, originalName) - - const metadata: Record = { - originalName: originalName, - uploadedAt: new Date().toISOString(), - purpose: 'mothership', - userId: session.user.id, - workspaceId: mothershipWorkspaceId, - } - - const fileInfo = await storageService.uploadFile({ - file: buffer, - fileName: storageKey, - contentType: file.type || 'application/octet-stream', - context: 'mothership', - preserveKey: true, - customKey: storageKey, - metadata, - }) - - const finalPath = usingCloudStorage ? `${fileInfo.path}?context=mothership` : fileInfo.path - - uploadResults.push({ - fileName: originalName, - presignedUrl: '', - fileInfo: { - path: finalPath, - key: fileInfo.key, - name: originalName, - size: buffer.length, - type: file.type || 'application/octet-stream', - }, - directUploadSupported: false, - }) - - logger.info(`Successfully uploaded mothership file: ${fileInfo.key}`) - continue - } - - if ( - context === 'copilot' || - context === 'chat' || - context === 'profile-pictures' || - context === 'workspace-logos' - ) { - if (context !== 'copilot') { - const mimeType = file.type - const isGenericMime = !mimeType || mimeType === 'application/octet-stream' - const extension = originalName.split('.').pop()?.toLowerCase() ?? '' - const extensionIsImage = (SUPPORTED_IMAGE_EXTENSIONS as readonly string[]).includes( - extension - ) - const isImage = isGenericMime ? extensionIsImage : isImageFileType(mimeType) - if (!isImage) { - throw new InvalidRequestError(`Only image files are allowed for ${context} uploads`) - } - } - - if (context === 'workspace-logos') { - if (!workspaceId) { - throw new InvalidRequestError('workspace-logos context requires workspaceId parameter') - } - const permission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - if (permission !== 'admin') { - return NextResponse.json( - { error: 'Admin access required for workspace logo uploads' }, - { status: 403 } - ) - } - } - - if (context === 'chat' && workspaceId) { - const permission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - if (permission === null) { - return NextResponse.json( - { error: 'Insufficient permissions for workspace' }, - { status: 403 } - ) - } - } - - logger.info(`Uploading ${context} file: ${originalName}`) - - const resolvedContentType = resolveFileType({ type: file.type, name: originalName }) - - const timestamp = Date.now() - const safeFileName = sanitizeFileName(originalName) - const storageKey = `${context}/${timestamp}-${safeFileName}` - - const metadata: Record = { - originalName: originalName, - uploadedAt: new Date().toISOString(), - purpose: context, - userId: session.user.id, - } - - if (workspaceId && context === 'chat') { - metadata.workspaceId = workspaceId - } - - const fileInfo = await storageService.uploadFile({ - file: buffer, - fileName: storageKey, - contentType: resolvedContentType, - context, - preserveKey: true, - customKey: storageKey, - metadata, - }) - - const finalPath = usingCloudStorage ? `${fileInfo.path}?context=${context}` : fileInfo.path - - const uploadResult = { - fileName: originalName, - presignedUrl: '', // Not used for server-side uploads - fileInfo: { - path: finalPath, - key: fileInfo.key, - name: originalName, - size: buffer.length, - type: resolvedContentType, - }, - directUploadSupported: false, - } - - logger.info(`Successfully uploaded ${context} file: ${fileInfo.key}`) - - if (context === 'workspace-logos' && workspaceId) { - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_UPLOADED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: workspaceId, - description: `Uploaded workspace logo "${originalName}"`, - metadata: { - fileName: originalName, - fileKey: fileInfo.key, - fileSize: buffer.length, - fileType: resolvedContentType, - }, - request, - }) - - captureServerEvent(session.user.id, 'workspace_logo_uploaded', { - workspace_id: workspaceId, - file_name: originalName, - file_size: buffer.length, - }) - } - - uploadResults.push(uploadResult) - continue - } - - // Unknown context - throw new InvalidRequestError( - `Unsupported context: ${context}. Use knowledge-base, workspace, execution, copilot, chat, profile-pictures, or workspace-logos` - ) - } - - if (uploadResults.length === 1) { - return NextResponse.json(uploadResults[0]) - } - return NextResponse.json({ files: uploadResults }) - } catch (error) { - logger.error('Error in file upload:', error) - if (isPayloadSizeLimitError(error)) { - return NextResponse.json( - { - error: 'PayloadSizeLimitError', - message: `File exceeds the server upload limit of ${Math.round(error.maxBytes / (1024 * 1024))}MB. Use direct upload for larger workspace files.`, - }, - { status: 413 } - ) - } - return createErrorResponse(error instanceof Error ? error : new Error('File upload failed')) - } -}) diff --git a/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts index ce66561f854..8f8e75574f5 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts @@ -1,62 +1,41 @@ import { type NextRequest, NextResponse } from 'next/server' -import { completeWorkspaceFileUploadContract } from '@/lib/api/contracts/upload-sessions' +import { completeInternalFileUploadContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' -import { getWorkspaceFile, registerUploadedWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { - completeUploadSession, - getOwnedUploadSession, -} from '@/lib/uploads/multipart-session/service' +import { completeUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' +import { finalizeUploadPurpose } from '@/app/api/files/uploads/finalizers' +import { reauthorizeUploadPurpose } from '@/app/api/files/uploads/purposes' import { requireUploadUser, - requireWorkspaceWrite, + toInternalUploadSession, uploadSessionErrorResponse, } from '@/app/api/files/uploads/utils' -import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' interface UploadRouteParams { params: Promise<{ uploadId: string }> } export const POST = withRouteHandler(async (request: NextRequest, context: UploadRouteParams) => { - const user = await requireUploadUser() - if (user instanceof NextResponse) return user - const parsed = await parseRequest(completeWorkspaceFileUploadContract, request, context) + const actor = await requireUploadUser() + if (actor instanceof NextResponse) return actor + const parsed = await parseRequest(completeInternalFileUploadContract, request, context) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query - const access = await requireWorkspaceWrite(user, workspaceId) - if (access) return access + try { - const upload = getOwnedUploadSession({ + const session = getOwnedUploadSession({ uploadId: parsed.data.params.uploadId, - workspaceId, - userId: user, - purpose: 'workspace_file', uploadToken: parsed.data.headers['upload-token'], + userId: actor.id, }) - const metadata = upload.metadata as { folderId?: string | null } + await reauthorizeUploadPurpose(actor.id, session) const completed = await completeUploadSession({ - session: upload, - parts: parsed.data.body.parts, - finalize: async (claimed) => { - const registered = await registerUploadedWorkspaceFile({ - workspaceId, - userId: user, - key: claimed.storageKey, - originalName: claimed.fileName, - contentType: claimed.contentType, - folderId: metadata.folderId, - }) - return { value: registered.file.id, completedFileId: registered.file.id } - }, + session, + completion: parsed.data.body, + finalize: (claimed) => finalizeUploadPurpose({ session: claimed, actor, request }), + }) + return NextResponse.json({ + data: toInternalUploadSession(completed.session, completed.value), }) - const fileId = completed.value - if (!fileId) throw new Error('Completed upload is missing its workspace file id') - const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) - if (!file) throw new Error(`Completed workspace file ${fileId} not found`) - if (!completed.alreadyCompleted) await notifyWorkspaceFilesChanged(workspaceId) - return NextResponse.json({ data: toV2FileUpload(completed.session, file) }) } catch (error) { const classified = uploadSessionErrorResponse(error) if (classified) return classified diff --git a/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts index 80e0edd9e12..5c79744cf75 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts @@ -1,39 +1,30 @@ import { type NextRequest, NextResponse } from 'next/server' -import { createWorkspaceFileUploadPartUrlsContract } from '@/lib/api/contracts/upload-sessions' +import { createInternalFileUploadPartUrlsContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - createUploadPartUrls, - getOwnedUploadSession, -} from '@/lib/uploads/multipart-session/service' -import { - requireUploadUser, - requireWorkspaceWrite, - uploadSessionErrorResponse, -} from '@/app/api/files/uploads/utils' +import { createUploadPartUrls, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' +import { reauthorizeUploadPurpose } from '@/app/api/files/uploads/purposes' +import { requireUploadUser, uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' interface UploadRouteParams { params: Promise<{ uploadId: string }> } export const POST = withRouteHandler(async (request: NextRequest, context: UploadRouteParams) => { - const user = await requireUploadUser() - if (user instanceof NextResponse) return user - const parsed = await parseRequest(createWorkspaceFileUploadPartUrlsContract, request, context) + const actor = await requireUploadUser() + if (actor instanceof NextResponse) return actor + const parsed = await parseRequest(createInternalFileUploadPartUrlsContract, request, context) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query - const access = await requireWorkspaceWrite(user, workspaceId) - if (access) return access + try { - const upload = getOwnedUploadSession({ + const session = getOwnedUploadSession({ uploadId: parsed.data.params.uploadId, - workspaceId, - userId: user, - purpose: 'workspace_file', uploadToken: parsed.data.headers['upload-token'], + userId: actor.id, }) + await reauthorizeUploadPurpose(actor.id, session) const parts = await createUploadPartUrls({ - session: upload, + session, partNumbers: parsed.data.body.partNumbers, localOrigin: request.nextUrl.origin, }) diff --git a/apps/sim/app/api/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/route.ts index f33f3bb3004..e1cef9cf378 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/route.ts @@ -1,36 +1,34 @@ import { type NextRequest, NextResponse } from 'next/server' -import { abortWorkspaceFileUploadContract } from '@/lib/api/contracts/upload-sessions' +import { abortInternalFileUploadContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/multipart-session/service' +import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' +import { reauthorizeUploadPurpose } from '@/app/api/files/uploads/purposes' import { requireUploadUser, - requireWorkspaceWrite, + toInternalUploadSession, uploadSessionErrorResponse, } from '@/app/api/files/uploads/utils' -import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' interface UploadRouteParams { params: Promise<{ uploadId: string }> } export const DELETE = withRouteHandler(async (request: NextRequest, context: UploadRouteParams) => { - const user = await requireUploadUser() - if (user instanceof NextResponse) return user - const parsed = await parseRequest(abortWorkspaceFileUploadContract, request, context) + const actor = await requireUploadUser() + if (actor instanceof NextResponse) return actor + const parsed = await parseRequest(abortInternalFileUploadContract, request, context) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query - const access = await requireWorkspaceWrite(user, workspaceId) - if (access) return access + try { - const upload = getOwnedUploadSession({ + const session = getOwnedUploadSession({ uploadId: parsed.data.params.uploadId, - workspaceId, - userId: user, - purpose: 'workspace_file', uploadToken: parsed.data.headers['upload-token'], + userId: actor.id, }) - return NextResponse.json({ data: toV2FileUpload(await abortUploadSession(upload), null) }) + await reauthorizeUploadPurpose(actor.id, session) + const aborted = await abortUploadSession(session) + return NextResponse.json({ data: toInternalUploadSession(aborted, null) }) } catch (error) { const classified = uploadSessionErrorResponse(error) if (classified) return classified diff --git a/apps/sim/app/api/files/uploads/finalizers.test.ts b/apps/sim/app/api/files/uploads/finalizers.test.ts new file mode 100644 index 00000000000..a257244e0ef --- /dev/null +++ b/apps/sim/app/api/files/uploads/finalizers.test.ts @@ -0,0 +1,238 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockInsertReturning, + mockSelectLimit, + mockRecordAudit, + mockCaptureServerEvent, + mockGetWorkspaceFile, + mockRegisterUploadedWorkspaceFile, + mockNotifyWorkspaceFilesChanged, +} = vi.hoisted(() => ({ + mockInsertReturning: vi.fn(), + mockSelectLimit: vi.fn(), + mockRecordAudit: vi.fn(), + mockCaptureServerEvent: vi.fn(), + mockGetWorkspaceFile: vi.fn(), + mockRegisterUploadedWorkspaceFile: vi.fn(), + mockNotifyWorkspaceFilesChanged: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + onConflictDoNothing: vi.fn(() => ({ returning: mockInsertReturning })), + })), + })), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn(() => ({ limit: mockSelectLimit })), + })), + })), + })), + }, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_UPLOADED: 'file.uploaded' }, + AuditResourceType: { WORKSPACE: 'workspace' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) +vi.mock('@/lib/uploads/config', () => ({ getServeStoragePrefix: () => 's3' })) +vi.mock('@/lib/uploads/upload-session/service', () => ({ + UploadSessionError: class UploadSessionError extends Error { + constructor( + readonly code: string, + message: string + ) { + super(message) + } + }, +})) +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + getWorkspaceFile: mockGetWorkspaceFile, + registerUploadedWorkspaceFile: mockRegisterUploadedWorkspaceFile, +})) +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkspaceFilesChanged: mockNotifyWorkspaceFilesChanged, +})) + +import { finalizeUploadPurpose } from '@/app/api/files/uploads/finalizers' + +const now = new Date('2026-08-04T12:00:00.000Z') +const actor = { id: 'user-1', name: 'Ada', email: 'ada@example.com' } +const metadataRow = { + id: 'file-1', + key: 'workspace-logos/upload-1-logo.png', + userId: actor.id, + workspaceId: 'workspace-1', + folderId: null, + context: 'workspace-logos', + chatId: null, + messageId: null, + originalName: 'logo.png', + displayName: 'logo.png', + contentType: 'image/png', + size: 128, + sizeBytes: 128, + deletedAt: null, + uploadedAt: now, + updatedAt: now, + contentUpdatedAt: now, +} +const uploadSession = { + id: 'upload-1', + workspaceId: 'workspace-1', + userId: actor.id, + knowledgeBaseId: null, + workflowId: null, + executionId: null, + purpose: 'workspace_logo' as const, + method: 'put' as const, + storageContext: 'workspace-logos' as const, + storageKey: metadataRow.key, + storageProvider: 's3' as const, + providerUploadId: null, + fileName: 'logo.png', + contentType: 'image/png', + fileSize: 128, + status: 'uploading' as const, + metadata: {}, + uploadToken: 'signed-token', + createdAt: now, + expiresAt: new Date('2026-08-05T12:00:00.000Z'), + completedFileId: null, + error: null, + completedAt: null, + updatedAt: now, +} +const workspaceFile = { + id: 'wf-1', + workspaceId: 'workspace-1', + name: 'report.csv', + key: 'workspace/workspace-1/upload-1-report.csv', + path: '/api/files/serve/s3/workspace%2Fworkspace-1%2Fupload-1-report.csv?context=workspace', + size: 128, + type: 'text/csv', + uploadedBy: actor.id, + folderId: null, + deletedAt: null, + uploadedAt: now, + updatedAt: now, +} + +describe('upload purpose finalizers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('emits workspace-logo side effects only for the metadata insert winner', async () => { + mockSelectLimit.mockResolvedValueOnce([]).mockResolvedValueOnce([metadataRow]) + mockInsertReturning.mockResolvedValueOnce([metadataRow]) + const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete') + + const first = await finalizeUploadPurpose({ session: uploadSession, actor, request }) + const retry = await finalizeUploadPurpose({ session: uploadSession, actor, request }) + + expect(first.value).toEqual({ + path: `/api/files/serve/s3/${encodeURIComponent(metadataRow.key)}?context=workspace-logos`, + key: metadataRow.key, + name: 'logo.png', + size: 128, + type: 'image/png', + }) + expect(retry.value).toEqual(first.value) + expect(mockRecordAudit).toHaveBeenCalledTimes(1) + expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1) + }) + + it('rejects a storage key already bound to a different owner', async () => { + mockSelectLimit.mockResolvedValueOnce([{ ...metadataRow, userId: 'other-user' }]) + + await expect( + finalizeUploadPurpose({ + session: uploadSession, + actor, + request: new NextRequest('http://localhost/api/files/uploads/upload-1/complete'), + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) + + it('rejects a replay after its metadata was archived', async () => { + mockSelectLimit.mockResolvedValueOnce([ + { ...metadataRow, deletedAt: new Date('2026-08-04T13:00:00.000Z') }, + ]) + + await expect( + finalizeUploadPurpose({ + session: uploadSession, + actor, + request: new NextRequest('http://localhost/api/files/uploads/upload-1/complete'), + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mockInsertReturning).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) + + it('emits workspace-file side effects only for the metadata insert winner', async () => { + const workspaceSession = { + ...uploadSession, + purpose: 'workspace_file' as const, + storageContext: 'workspace' as const, + storageKey: workspaceFile.key, + fileName: workspaceFile.name, + contentType: workspaceFile.type, + } + mockRegisterUploadedWorkspaceFile + .mockResolvedValueOnce({ file: { id: workspaceFile.id }, created: true }) + .mockResolvedValueOnce({ file: { id: workspaceFile.id }, created: false }) + mockGetWorkspaceFile.mockResolvedValue(workspaceFile) + const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete') + + const first = await finalizeUploadPurpose({ session: workspaceSession, actor, request }) + const retry = await finalizeUploadPurpose({ session: workspaceSession, actor, request }) + + expect(retry.value).toEqual(first.value) + expect(mockNotifyWorkspaceFilesChanged).toHaveBeenCalledTimes(1) + expect(mockRecordAudit).toHaveBeenCalledTimes(1) + expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1) + }) + + it('rejects a workspace-file replay after its metadata was archived', async () => { + const workspaceSession = { + ...uploadSession, + purpose: 'workspace_file' as const, + storageContext: 'workspace' as const, + storageKey: workspaceFile.key, + fileName: workspaceFile.name, + contentType: workspaceFile.type, + } + mockRegisterUploadedWorkspaceFile.mockResolvedValueOnce({ + file: { id: workspaceFile.id }, + created: false, + }) + mockGetWorkspaceFile.mockResolvedValueOnce({ ...workspaceFile, deletedAt: now }) + + await expect( + finalizeUploadPurpose({ + session: workspaceSession, + actor, + request: new NextRequest('http://localhost/api/files/uploads/upload-1/complete'), + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mockNotifyWorkspaceFilesChanged).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/files/uploads/finalizers.ts b/apps/sim/app/api/files/uploads/finalizers.ts new file mode 100644 index 00000000000..6673b9a60d4 --- /dev/null +++ b/apps/sim/app/api/files/uploads/finalizers.ts @@ -0,0 +1,362 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { workspaceFiles } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { eq, sql } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import type { V2File } from '@/lib/api/contracts/v2/files' +import { captureServerEvent } from '@/lib/posthog/server' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { getServeStoragePrefix } from '@/lib/uploads/config' +import { + getWorkspaceFile, + registerUploadedWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { type StorageContext, toLegacyWorkspaceFileSize } from '@/lib/uploads/shared/types' +import { UploadSessionError, type UploadSessionRecord } from '@/lib/uploads/upload-session/service' +import { toV2File } from '@/app/api/v2/files/utils' + +export interface UploadActor { + id: string + name?: string | null + email?: string | null +} + +export interface StoredUploadResult { + path: string + key: string + name: string + size: number + type: string +} + +export interface ExecutionUploadResult { + id: string + name: string + url: string + size: number + type: string + key: string + context: 'execution' +} + +export type UploadPurposeResult = V2File | StoredUploadResult | ExecutionUploadResult + +interface FinalizedWorkspaceFile { + file: WorkspaceFileRecord + created: boolean +} + +interface FinalizeUploadPurposeParams { + session: UploadSessionRecord + actor: UploadActor + request: NextRequest +} + +interface FinalizedUploadPurpose { + value: UploadPurposeResult + completedFileId?: string +} + +interface FinalizedMetadataInput { + key: string + userId: string + workspaceId: string + context: StorageContext + originalName: string + contentType: string + size: number +} + +type FileMetadataRecord = typeof workspaceFiles.$inferSelect + +/** + * Finalizes the domain resource represented by a verified upload object. + * Metadata-backed purposes use the storage key as their idempotency identity. + */ +export async function finalizeUploadPurpose({ + session, + actor, + request, +}: FinalizeUploadPurposeParams): Promise { + switch (session.purpose) { + case 'workspace_file': + return finalizeInternalWorkspaceFile(session, actor, request) + case 'profile_picture': + return { value: storedAssetResult(session, 'profile-pictures') } + case 'workspace_logo': + return finalizeWorkspaceLogo(session, actor, request) + case 'mothership_attachment': + return finalizeMothershipAttachment(session) + case 'execution_attachment': + return finalizeExecutionAttachment(session) + case 'table_import': + case 'knowledge_document': + throw new UploadSessionError( + 'validation', + `Purpose ${session.purpose} is not finalized by the internal files route` + ) + } +} + +async function finalizeInternalWorkspaceFile( + session: UploadSessionRecord, + actor: UploadActor, + request: NextRequest +): Promise { + const finalized = await finalizeWorkspaceFileUpload({ session, actor, request, source: 'ui' }) + return { + value: toV2File(finalized.file), + completedFileId: finalized.file.id, + } +} + +/** + * Registers a verified workspace object and emits its one-time domain side effects. + * The metadata insert winner is the only caller that notifies, audits, or records analytics. + */ +export async function finalizeWorkspaceFileUpload(params: { + session: UploadSessionRecord + actor: UploadActor + request: NextRequest + source: 'api' | 'ui' +}): Promise { + const { session, actor, request, source } = params + const workspaceId = requireWorkspaceId(session) + const metadata = session.metadata as { folderId?: string | null } + const registered = await registerUploadedWorkspaceFile({ + workspaceId, + userId: session.userId, + key: session.storageKey, + originalName: session.fileName, + contentType: session.contentType, + folderId: metadata.folderId, + }) + const file = await getWorkspaceFile(workspaceId, registered.file.id, { + includeDeleted: true, + throwOnError: true, + }) + if (!file) { + throw new Error(`Completed workspace file ${registered.file.id} not found`) + } + if (file.deletedAt) { + throw new UploadSessionError('conflict', 'Upload result was deleted') + } + if (registered.created) { + await notifyWorkspaceFilesChanged(workspaceId) + captureServerEvent( + actor.id, + 'file_uploaded', + { workspace_id: workspaceId, file_type: session.contentType }, + { groups: { workspace: workspaceId } } + ) + recordAudit({ + workspaceId, + actorId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.name, + description: `Uploaded file "${file.name}"${source === 'api' ? ' via API' : ''}`, + metadata: { fileSize: file.size, fileType: file.type }, + request, + }) + } + return { file, created: registered.created } +} + +async function finalizeWorkspaceLogo( + session: UploadSessionRecord, + actor: UploadActor, + request: NextRequest +): Promise { + const workspaceId = requireWorkspaceId(session) + const finalized = await insertOrLoadFileMetadata({ + key: session.storageKey, + userId: session.userId, + workspaceId, + context: 'workspace-logos', + originalName: session.fileName, + contentType: session.contentType, + size: session.fileSize, + }) + + if (finalized.created) { + recordAudit({ + workspaceId, + actorId: actor.id, + actorName: actor.name, + actorEmail: actor.email, + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + description: `Uploaded workspace logo "${session.fileName}"`, + metadata: { + fileName: session.fileName, + fileKey: session.storageKey, + fileSize: session.fileSize, + fileType: session.contentType, + }, + request, + }) + captureServerEvent(actor.id, 'workspace_logo_uploaded', { + workspace_id: workspaceId, + file_name: session.fileName, + file_size: session.fileSize, + }) + } + + return { value: storedAssetResult(session, 'workspace-logos') } +} + +async function finalizeMothershipAttachment( + session: UploadSessionRecord +): Promise { + const workspaceId = requireWorkspaceId(session) + await insertOrLoadFileMetadata({ + key: session.storageKey, + userId: session.userId, + workspaceId, + context: 'mothership', + originalName: session.fileName, + contentType: session.contentType, + size: session.fileSize, + }) + return { + value: storedAssetResult(session, 'mothership'), + } +} + +async function finalizeExecutionAttachment( + session: UploadSessionRecord +): Promise { + const workspaceId = requireWorkspaceId(session) + const finalized = await insertOrLoadFileMetadata({ + key: session.storageKey, + userId: session.userId, + workspaceId, + context: 'execution', + originalName: session.fileName, + contentType: session.contentType, + size: session.fileSize, + }) + return { + value: { + id: finalized.file.id, + name: session.fileName, + url: servePath(session.storageKey, 'execution'), + size: session.fileSize, + type: session.contentType, + key: session.storageKey, + context: 'execution', + }, + completedFileId: finalized.file.id, + } +} + +async function insertOrLoadFileMetadata( + input: FinalizedMetadataInput +): Promise<{ file: FileMetadataRecord; created: boolean }> { + const existing = await findFileMetadataByKey(input.key) + if (existing) { + assertMatchingMetadata(existing, input) + assertActiveFileMetadata(existing) + return { file: existing, created: false } + } + + const now = new Date() + const [inserted] = await db + .insert(workspaceFiles) + .values({ + id: generateId(), + key: input.key, + userId: input.userId, + workspaceId: input.workspaceId, + context: input.context, + originalName: input.originalName, + displayName: input.originalName, + contentType: input.contentType, + size: toLegacyWorkspaceFileSize(input.size), + sizeBytes: input.size, + deletedAt: null, + uploadedAt: now, + updatedAt: now, + contentUpdatedAt: now, + }) + .onConflictDoNothing() + .returning() + + if (inserted) return { file: inserted, created: true } + + const raceWinner = await findFileMetadataByKey(input.key) + if (!raceWinner) { + throw new UploadSessionError('conflict', `Storage key ${input.key} could not be registered`) + } + assertMatchingMetadata(raceWinner, input) + assertActiveFileMetadata(raceWinner) + return { file: raceWinner, created: false } +} + +async function findFileMetadataByKey(key: string): Promise { + const [file] = await db + .select() + .from(workspaceFiles) + .where(eq(workspaceFiles.key, key)) + .orderBy(sql`${workspaceFiles.deletedAt} IS NULL DESC`) + .limit(1) + return file +} + +function assertMatchingMetadata(existing: FileMetadataRecord, input: FinalizedMetadataInput): void { + const existingSize = existing.sizeBytes ?? existing.size + if ( + existing.key !== input.key || + existing.userId !== input.userId || + existing.workspaceId !== input.workspaceId || + existing.context !== input.context || + existing.originalName !== input.originalName || + existing.contentType !== input.contentType || + existingSize !== input.size + ) { + throw new UploadSessionError( + 'conflict', + `Storage key ${input.key} belongs to a different upload` + ) + } +} + +function assertActiveFileMetadata(file: FileMetadataRecord): void { + if (file.deletedAt) { + throw new UploadSessionError('conflict', 'Upload result was deleted') + } +} + +function servePath(key: string, context: StorageContext): string { + return `/api/files/serve/${getServeStoragePrefix()}/${encodeURIComponent(key)}?context=${context}` +} + +function storedAssetResult( + session: UploadSessionRecord, + context: 'profile-pictures' | 'workspace-logos' | 'mothership' +): StoredUploadResult { + return { + path: servePath(session.storageKey, context), + key: session.storageKey, + name: session.fileName, + size: session.fileSize, + type: session.contentType, + } +} + +function requireWorkspaceId(session: UploadSessionRecord): string { + if (!session.workspaceId) { + throw new UploadSessionError( + 'forbidden', + `Upload session ${session.id} is missing its workspace scope` + ) + } + return session.workspaceId +} diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts new file mode 100644 index 00000000000..a67d2e3298c --- /dev/null +++ b/apps/sim/app/api/files/uploads/purposes.ts @@ -0,0 +1,188 @@ +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' +import type { CreateInternalFileUploadBody } from '@/lib/api/contracts/upload-sessions' +import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' +import { + createUploadSession, + UploadSessionError, + type UploadSessionRecord, +} from '@/lib/uploads/upload-session/service' +import { isImageFileType } from '@/lib/uploads/utils/file-utils' +import { validateAttachmentFileType } from '@/lib/uploads/utils/validation' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +export type InternalUploadPurpose = CreateInternalFileUploadBody['purpose'] + +const INTERNAL_UPLOAD_PURPOSES = new Set([ + 'workspace_file', + 'profile_picture', + 'workspace_logo', + 'mothership_attachment', + 'execution_attachment', +]) + +export async function createPurposeUploadSession( + userId: string, + body: CreateInternalFileUploadBody, + localOrigin: string +) { + validatePurposeFile(body) + + switch (body.purpose) { + case 'workspace_file': { + await requireWorkspacePermission(userId, body.workspaceId, 'write') + const folderId = await assertWorkspaceFileFolderTarget(body.workspaceId, body.folderId) + return createUploadSession({ + purpose: body.purpose, + workspaceId: body.workspaceId, + userId, + fileName: body.name, + contentType: body.contentType, + fileSize: body.size, + metadata: { folderId }, + localOrigin, + }) + } + case 'profile_picture': + return createUploadSession({ + purpose: body.purpose, + userId, + fileName: body.name, + contentType: body.contentType, + fileSize: body.size, + localOrigin, + }) + case 'workspace_logo': + await requireWorkspacePermission(userId, body.workspaceId, 'admin') + return createUploadSession({ + purpose: body.purpose, + workspaceId: body.workspaceId, + userId, + fileName: body.name, + contentType: body.contentType, + fileSize: body.size, + localOrigin, + }) + case 'mothership_attachment': + await requireWorkspacePermission(userId, body.workspaceId, 'write') + return createUploadSession({ + purpose: body.purpose, + workspaceId: body.workspaceId, + userId, + fileName: body.name, + contentType: body.contentType, + fileSize: body.size, + localOrigin, + }) + case 'execution_attachment': + await requireExecutionPermission(userId, body.workflowId, body.workspaceId) + return createUploadSession({ + purpose: body.purpose, + workspaceId: body.workspaceId, + workflowId: body.workflowId, + executionId: body.executionId, + userId, + fileName: body.name, + contentType: body.contentType, + fileSize: body.size, + localOrigin, + }) + } +} + +/** + * Rechecks current domain authorization for every control-plane session request. + */ +export async function reauthorizeUploadPurpose( + userId: string, + session: UploadSessionRecord +): Promise { + if (session.userId !== userId || !isInternalUploadPurpose(session.purpose)) { + throw new UploadSessionError('not_found', 'Upload session not found') + } + + switch (session.purpose) { + case 'workspace_file': + case 'mothership_attachment': + await requireWorkspacePermission(userId, requireSessionScope(session.workspaceId), 'write') + return + case 'profile_picture': + return + case 'workspace_logo': + await requireWorkspacePermission(userId, requireSessionScope(session.workspaceId), 'admin') + return + case 'execution_attachment': + await requireExecutionPermission( + userId, + requireSessionScope(session.workflowId), + requireSessionScope(session.workspaceId) + ) + return + } +} + +export function isInternalUploadPurpose(purpose: string): purpose is InternalUploadPurpose { + return INTERNAL_UPLOAD_PURPOSES.has(purpose as InternalUploadPurpose) +} + +function validatePurposeFile(body: CreateInternalFileUploadBody): void { + if (body.purpose === 'profile_picture' || body.purpose === 'workspace_logo') { + if (!isImageFileType(body.contentType)) { + throw new UploadSessionError( + 'validation', + `Only image files are allowed for ${body.purpose.replace('_', ' ')} uploads` + ) + } + return + } + + if (body.purpose === 'mothership_attachment' || body.purpose === 'execution_attachment') { + const validation = validateAttachmentFileType(body.name, { + allowArchives: body.purpose === 'mothership_attachment', + }) + if (validation) throw new UploadSessionError('validation', validation.message) + } +} + +async function requireWorkspacePermission( + userId: string, + workspaceId: string, + action: 'write' | 'admin' +): Promise { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + const allowed = + action === 'admin' ? permission === 'admin' : permission === 'write' || permission === 'admin' + if (!allowed) { + throw new UploadSessionError( + 'forbidden', + action === 'admin' ? 'Admin access required' : 'Write or Admin access required' + ) + } +} + +async function requireExecutionPermission( + userId: string, + workflowId: string, + signedWorkspaceId: string +): Promise { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'write', + }) + if (!authorization.workflow) { + throw new UploadSessionError('not_found', 'Workflow not found') + } + if (!authorization.allowed) { + throw new UploadSessionError('forbidden', authorization.message ?? 'Workflow access denied') + } + if (authorization.workflow.workspaceId !== signedWorkspaceId) { + throw new UploadSessionError('forbidden', 'Workflow does not belong to the upload workspace') + } +} + +function requireSessionScope(value: string | null, label = 'scope'): string { + if (!value) { + throw new UploadSessionError('forbidden', `Upload session is missing its ${label}`) + } + return value +} diff --git a/apps/sim/app/api/files/uploads/route.test.ts b/apps/sim/app/api/files/uploads/route.test.ts new file mode 100644 index 00000000000..1d6d3e0576f --- /dev/null +++ b/apps/sim/app/api/files/uploads/route.test.ts @@ -0,0 +1,307 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetSession, + mockCreateUploadSession, + mockGetOwnedUploadSession, + mockCompleteUploadSession, + mockGetUserEntityPermissions, + mockAuthorizeWorkflow, +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockCreateUploadSession: vi.fn(), + mockGetOwnedUploadSession: vi.fn(), + mockCompleteUploadSession: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockAuthorizeWorkflow: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) + +vi.mock('@/lib/uploads/upload-session/service', () => ({ + UploadSessionError: class UploadSessionError extends Error { + constructor( + readonly code: string, + message: string + ) { + super(message) + } + }, + createUploadSession: mockCreateUploadSession, + getOwnedUploadSession: mockGetOwnedUploadSession, + completeUploadSession: mockCompleteUploadSession, + createUploadPartUrls: vi.fn(), + abortUploadSession: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + assertWorkspaceFileFolderTarget: vi.fn(), + getWorkspaceFile: vi.fn(), + registerUploadedWorkspaceFile: vi.fn(), +})) + +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: vi.fn() })) + +import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' +import { POST as completeUpload } from '@/app/api/files/uploads/[uploadId]/complete/route' +import { POST as createUpload } from '@/app/api/files/uploads/route' + +const actor = { id: 'user-1', name: 'Ada', email: 'ada@example.com' } +const now = new Date('2026-08-04T12:00:00.000Z') + +function session(overrides: Record = {}) { + return { + id: 'upload-1', + workspaceId: null, + userId: actor.id, + knowledgeBaseId: null, + workflowId: null, + executionId: null, + purpose: 'profile_picture', + method: 'put', + storageContext: 'profile-pictures', + storageKey: 'profile-pictures/upload-1-avatar.png', + storageProvider: 's3', + providerUploadId: null, + fileName: 'avatar.png', + contentType: 'image/png', + fileSize: 128, + status: 'uploading', + metadata: {}, + uploadToken: 'signed-token', + createdAt: now, + expiresAt: new Date('2026-08-05T12:00:00.000Z'), + completedFileId: null, + error: null, + completedAt: null, + updatedAt: now, + ...overrides, + } +} + +describe('/api/files/uploads', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: actor }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + }) + + it('creates a purpose-scoped PUT session without exposing write capability in the session', async () => { + mockCreateUploadSession.mockResolvedValue({ + ...session(), + transfer: { + method: 'put', + url: 'https://storage.example.com/upload', + headers: { 'Content-Type': 'image/png' }, + }, + }) + const request = new NextRequest('http://localhost/api/files/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + purpose: 'profile_picture', + name: 'avatar.png', + contentType: 'image/png', + size: 128, + }), + }) + + const response = await createUpload(request) + const body = await response.json() + + expect(response.status).toBe(201) + expect(mockCreateUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ + purpose: 'profile_picture', + userId: actor.id, + localOrigin: 'http://localhost', + }) + ) + expect(body.data).toMatchObject({ + session: { + id: 'upload-1', + purpose: 'profile_picture', + status: 'uploading', + result: null, + }, + uploadToken: 'signed-token', + transfer: { method: 'put' }, + }) + expect(body.data.session).not.toHaveProperty('uploadToken') + expect(body.data.session).not.toHaveProperty('transfer') + }) + + it('creates a PUT session for an empty workspace file', async () => { + mockCreateUploadSession.mockResolvedValue({ + ...session({ + workspaceId: 'workspace-1', + purpose: 'workspace_file', + storageContext: 'workspace', + storageKey: 'workspace/workspace-1/empty.md', + fileName: 'empty.md', + contentType: 'text/markdown', + fileSize: 0, + }), + transfer: { + method: 'put', + url: 'https://storage.example.com/upload', + headers: { 'Content-Type': 'text/markdown' }, + }, + }) + const request = new NextRequest('http://localhost/api/files/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + purpose: 'workspace_file', + workspaceId: 'workspace-1', + name: 'empty.md', + contentType: 'text/markdown', + size: 0, + }), + }) + + const response = await createUpload(request) + + expect(response.status).toBe(201) + expect(mockCreateUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ purpose: 'workspace_file', fileSize: 0 }) + ) + await expect(response.json()).resolves.toMatchObject({ + data: { session: { purpose: 'workspace_file', size: 0 } }, + }) + }) + + it('preserves the 5 GiB direct-to-storage limit for mothership attachments', async () => { + mockCreateUploadSession.mockResolvedValue({ + ...session({ + workspaceId: 'workspace-1', + purpose: 'mothership_attachment', + method: 'multipart', + storageContext: 'mothership', + storageKey: 'mothership/workspace-1/archive.zip', + fileName: 'archive.zip', + contentType: 'application/zip', + fileSize: MAX_WORKSPACE_FILE_SIZE, + }), + transfer: { method: 'multipart', partSize: 8 * 1024 * 1024, partCount: 640 }, + }) + const request = new NextRequest('http://localhost/api/files/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + purpose: 'mothership_attachment', + workspaceId: 'workspace-1', + name: 'archive.zip', + contentType: 'application/zip', + size: MAX_WORKSPACE_FILE_SIZE, + }), + }) + + const response = await createUpload(request) + + expect(response.status).toBe(201) + expect(mockCreateUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ + purpose: 'mothership_attachment', + fileSize: MAX_WORKSPACE_FILE_SIZE, + }) + ) + }) + + it('rejects mothership attachments above the 5 GiB direct-to-storage limit', async () => { + const request = new NextRequest('http://localhost/api/files/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + purpose: 'mothership_attachment', + workspaceId: 'workspace-1', + name: 'archive.zip', + contentType: 'application/zip', + size: MAX_WORKSPACE_FILE_SIZE + 1, + }), + }) + + const response = await createUpload(request) + + expect(response.status).toBe(400) + expect(mockCreateUploadSession).not.toHaveBeenCalled() + }) + + it('reauthorizes a terminal request and returns only the terminal-safe session', async () => { + const logoSession = session({ + workspaceId: 'workspace-1', + purpose: 'workspace_logo', + storageContext: 'workspace-logos', + storageKey: 'workspace-logos/upload-1-logo.png', + fileName: 'logo.png', + }) + const result = { + path: '/api/files/serve/s3/workspace-logos%2Fupload-1-logo.png?context=workspace-logos', + key: 'workspace-logos/upload-1-logo.png', + name: 'logo.png', + size: 128, + type: 'image/png', + } + mockGetOwnedUploadSession.mockReturnValue(logoSession) + mockCompleteUploadSession.mockResolvedValue({ + session: { ...logoSession, status: 'completed', completedAt: now }, + value: result, + alreadyCompleted: false, + }) + const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'upload-token': 'signed-token', + }, + body: '{}', + }) + + const response = await completeUpload(request, { + params: Promise.resolve({ uploadId: 'upload-1' }), + }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(actor.id, 'workspace', 'workspace-1') + expect(mockCompleteUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ session: logoSession, completion: {} }) + ) + expect(body).toEqual({ + data: expect.objectContaining({ + id: 'upload-1', + purpose: 'workspace_logo', + status: 'completed', + result, + }), + }) + expect(body.data).not.toHaveProperty('uploadToken') + expect(body.data).not.toHaveProperty('transfer') + }) + + it('authenticates before parsing the request body', async () => { + mockGetSession.mockResolvedValue(null) + const request = new NextRequest('http://localhost/api/files/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{not json', + }) + + const response = await createUpload(request) + + expect(response.status).toBe(401) + expect(mockCreateUploadSession).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/files/uploads/route.ts b/apps/sim/app/api/files/uploads/route.ts index 25385b370f9..85752ea6e82 100644 --- a/apps/sim/app/api/files/uploads/route.ts +++ b/apps/sim/app/api/files/uploads/route.ts @@ -1,36 +1,36 @@ import { type NextRequest, NextResponse } from 'next/server' -import { createWorkspaceFileUploadContract } from '@/lib/api/contracts/upload-sessions' +import { createInternalFileUploadContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' -import { createUploadSession } from '@/lib/uploads/multipart-session/service' +import { createPurposeUploadSession } from '@/app/api/files/uploads/purposes' import { requireUploadUser, - requireWorkspaceWrite, + toInternalUploadSession, uploadSessionErrorResponse, } from '@/app/api/files/uploads/utils' -import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' export const POST = withRouteHandler(async (request: NextRequest) => { - const user = await requireUploadUser() - if (user instanceof NextResponse) return user - const parsed = await parseRequest(createWorkspaceFileUploadContract, request, {}) + const actor = await requireUploadUser() + if (actor instanceof NextResponse) return actor + const parsed = await parseRequest(createInternalFileUploadContract, request, {}) if (!parsed.success) return parsed.response - const { workspaceId, name, contentType, size, folderId } = parsed.data.body - const access = await requireWorkspaceWrite(user, workspaceId) - if (access) return access + try { - const normalizedFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId) - const upload = await createUploadSession({ - workspaceId, - userId: user, - purpose: 'workspace_file', - fileName: name, - contentType, - fileSize: size, - metadata: { folderId: normalizedFolderId }, - }) - return NextResponse.json({ data: toV2FileUpload(upload, null) }, { status: 201 }) + const created = await createPurposeUploadSession( + actor.id, + parsed.data.body, + request.nextUrl.origin + ) + return NextResponse.json( + { + data: { + session: toInternalUploadSession(created, null), + uploadToken: created.uploadToken, + transfer: created.transfer, + }, + }, + { status: 201 } + ) } catch (error) { const classified = uploadSessionErrorResponse(error) if (classified) return classified diff --git a/apps/sim/app/api/files/uploads/utils.ts b/apps/sim/app/api/files/uploads/utils.ts index b4530be82ca..ca8e9356cb3 100644 --- a/apps/sim/app/api/files/uploads/utils.ts +++ b/apps/sim/app/api/files/uploads/utils.ts @@ -1,21 +1,23 @@ import { NextResponse } from 'next/server' +import { + type InternalFileUploadSession, + internalFileUploadSessionSchema, +} from '@/lib/api/contracts/upload-sessions' import { getSession } from '@/lib/auth' import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' +import type { UploadActor, UploadPurposeResult } from '@/app/api/files/uploads/finalizers' -export async function requireUploadUser(): Promise { +export async function requireUploadUser(): Promise { const session = await getSession() - return session?.user?.id ?? NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) -} - -export async function requireWorkspaceWrite( - userId: string, - workspaceId: string -): Promise { - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - return permission === 'write' || permission === 'admin' - ? null - : NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + return { + id: session.user.id, + name: session.user.name, + email: session.user.email, + } } export function uploadSessionErrorResponse(error: unknown): NextResponse | null { @@ -27,3 +29,20 @@ export function uploadSessionErrorResponse(error: unknown): NextResponse | null ) : null } + +export function toInternalUploadSession( + session: UploadSessionRecord, + result: UploadPurposeResult | null +): InternalFileUploadSession { + return internalFileUploadSessionSchema.parse({ + id: session.id, + purpose: session.purpose, + status: session.status, + name: session.fileName, + contentType: session.contentType, + size: session.fileSize, + expiresAt: session.expiresAt.toISOString(), + error: session.error, + result, + }) +} diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index 3c10f68f4d4..88de0594622 100644 --- a/apps/sim/app/api/help/route.ts +++ b/apps/sim/app/api/help/route.ts @@ -20,7 +20,7 @@ const logger = createLogger('HelpAPI') /** * The form can carry several image attachments with no server-side count * cap, so this reuses the repo's largest existing per-request form-data - * bound (see files/upload route) rather than an arbitrary smaller limit + * multipart bound rather than an arbitrary smaller limit * that could reject a legitimate multi-image submission. */ const MAX_HELP_FORM_BYTES = MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts index 8d0cf3e8bf6..7426a000fbd 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts @@ -3,7 +3,7 @@ import { completeKnowledgeDocumentUploadContract } from '@/lib/api/contracts/kno import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { completeUploadSession } from '@/lib/uploads/multipart-session/service' +import { completeUploadSession } from '@/lib/uploads/upload-session/service' import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' import { requireKnowledgeDocumentUploadAccess, @@ -45,7 +45,7 @@ export const POST = withRouteHandler( }) const completed = await completeUploadSession({ session: upload, - parts: parsed.data.body.parts, + completion: parsed.data.body, finalize: (claimed) => finalizeKnowledgeDocumentUpload({ claimed, diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts index 482705e5b62..38390d4c13e 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts @@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { createKnowledgeDocumentUploadPartUrlsContract } from '@/lib/api/contracts/knowledge/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createUploadPartUrls } from '@/lib/uploads/multipart-session/service' +import { createUploadPartUrls } from '@/lib/uploads/upload-session/service' import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' import { requireKnowledgeDocumentUploadAccess, diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts index df28c35218f..ea79d0f4dc9 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts @@ -71,10 +71,13 @@ describe('POST /api/knowledge/[id]/documents/uploads', () => { fileName: 'guide.pdf', contentType: 'application/pdf', fileSize: 1024, - partSize: 8 * 1024 * 1024, - partCount: 1, uploadToken: 'token', error: null, + transfer: { + method: 'put', + url: 'https://storage.example/upload', + headers: { 'content-type': 'application/pdf' }, + }, }) }) @@ -98,6 +101,12 @@ describe('POST /api/knowledge/[id]/documents/uploads', () => { tag1: 'product', processingOptions: { recipe: 'default', lang: 'en' }, }, + localOrigin: 'http://localhost:3000', + }) + expect((await response.json()).data).toMatchObject({ + session: { id: 'upload-1', status: 'uploading', document: null }, + uploadToken: 'token', + transfer: { method: 'put', url: 'https://storage.example/upload' }, }) expect(mockRequireKnowledgeDocumentUploadBilling.mock.invocationCallOrder[0]).toBeLessThan( mockCreateKnowledgeDocumentUploadSession.mock.invocationCallOrder[0] diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts index 09b7aec9e59..58a1c69c253 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts @@ -50,8 +50,18 @@ export const POST = withRouteHandler( contentType, fileSize: size, metadata, + localOrigin: request.nextUrl.origin, }) - return NextResponse.json({ data: toV2KnowledgeDocumentUpload(upload, null) }, { status: 201 }) + return NextResponse.json( + { + data: { + session: toV2KnowledgeDocumentUpload(upload, null), + uploadToken: upload.uploadToken, + transfer: upload.transfer, + }, + }, + { status: 201 } + ) } catch (error) { const classified = uploadSessionErrorResponse(error) if (classified) return classified diff --git a/apps/sim/app/api/table/imports/[importId]/complete/route.ts b/apps/sim/app/api/table/imports/[importId]/complete/route.ts index 4ca1b13974c..54b93e41b19 100644 --- a/apps/sim/app/api/table/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/complete/route.ts @@ -9,7 +9,10 @@ import { startUploadedTableImport, toV2TableImport, } from '@/lib/table/orchestration/import-resource' -import { completeUploadSession } from '@/lib/uploads/multipart-session/service' +import { + completeUploadSession, + validateUploadCompletion, +} from '@/lib/uploads/upload-session/service' import { orchestrationErrorResponse } from '@/app/api/table/utils' interface ImportRouteParams { @@ -30,15 +33,16 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Impor userId: auth.userId, uploadToken: parsed.data.headers['upload-token'], }) + validateUploadCompletion(upload, parsed.data.body) const existing = await findOwnedTableImport({ importId: upload.id, - workspaceId: upload.workspaceId, + workspaceId: parsed.data.query.workspaceId, userId: upload.userId, }) if (existing) return NextResponse.json({ data: toV2TableImport(existing) }) const completed = await completeUploadSession({ session: upload, - parts: parsed.data.body.parts, + completion: parsed.data.body, finalize: async () => ({ value: null }), }) return NextResponse.json({ diff --git a/apps/sim/app/api/table/imports/[importId]/parts/route.ts b/apps/sim/app/api/table/imports/[importId]/parts/route.ts index f3534b65958..130fe570a29 100644 --- a/apps/sim/app/api/table/imports/[importId]/parts/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/parts/route.ts @@ -4,7 +4,7 @@ import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getOwnedTableImportUpload } from '@/lib/table/orchestration/import-resource' -import { createUploadPartUrls } from '@/lib/uploads/multipart-session/service' +import { createUploadPartUrls } from '@/lib/uploads/upload-session/service' import { orchestrationErrorResponse } from '@/app/api/table/utils' interface ImportRouteParams { diff --git a/apps/sim/app/api/table/imports/route.ts b/apps/sim/app/api/table/imports/route.ts index 254420388fe..d7e42288f09 100644 --- a/apps/sim/app/api/table/imports/route.ts +++ b/apps/sim/app/api/table/imports/route.ts @@ -5,7 +5,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createTableImportResource, - toV2TableImport, + toV2CreateTableImport, } from '@/lib/table/orchestration/import-resource' import { orchestrationErrorResponse } from '@/app/api/table/utils' @@ -17,8 +17,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(createTableImportResourceContract, request, {}) if (!parsed.success) return parsed.response try { - const created = await createTableImportResource(parsed.data.body, auth.userId) - return NextResponse.json({ data: await toV2TableImport(created.record) }, { status: 201 }) + const created = await createTableImportResource( + parsed.data.body, + auth.userId, + request.nextUrl.origin + ) + return NextResponse.json({ data: toV2CreateTableImport(created) }, { status: 201 }) } catch (error) { const classified = orchestrationErrorResponse(error) if (classified) return classified diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index 2bfe1cbfad4..3833eb52c84 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -363,4 +363,22 @@ describe('POST /api/v2/credentials', () => { }) ) }) + + it('accepts and forwards an optional service-account data center', async () => { + const res = await callCreate({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoho-desk-service-account', + clientId: 'zoho-client-id', + clientSecret: 'zoho-client-secret', + orgId: '600123456', + dataCenter: 'eu', + }) + + expect(res.status).toBe(201) + expect(mockPerformCreateCredential).toHaveBeenCalledWith( + expect.objectContaining({ dataCenter: 'eu' }) + ) + expect(JSON.stringify(await res.json())).not.toContain('dataCenter') + }) }) diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts index b2fd918c924..860002a33e5 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -22,6 +22,7 @@ vi.mock('@/app/api/v2/lib/gate', () => ({ })) vi.mock('@/lib/workspace-files/orchestration', () => ({ + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, performUpdateWorkspaceFileContent: mockPerformUpdateContent, })) @@ -62,12 +63,15 @@ const RECORD = { updatedAt: new Date('2024-01-03T00:00:00Z'), } -const callPut = (body: unknown) => +const callPut = (body: unknown, contentLength?: number) => PUT( new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/content`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + ...(contentLength === undefined ? {} : { 'Content-Length': String(contentLength) }), + }, + body: typeof body === 'string' ? body : JSON.stringify(body), }), { params: Promise.resolve({ fileId: FILE_ID }) } ) @@ -104,6 +108,45 @@ describe('PUT /api/v2/files/[fileId]/content', () => { expect(mockPerformUpdateContent).not.toHaveBeenCalled() }) + it('400s malformed base64 in the v2 error envelope', async () => { + const res = await callPut({ workspaceId: WS, content: 'not-base64!', encoding: 'base64' }) + const body = await res.json() + + expect(res.status).toBe(400) + expect(body.error.code).toBe('BAD_REQUEST') + expect(body.error.message).toBe('content must be valid base64') + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) + + it('accepts empty base64 as a zero-byte replacement', async () => { + const res = await callPut({ workspaceId: WS, content: '', encoding: 'base64' }) + + expect(res.status).toBe(200) + expect(mockPerformUpdateContent).toHaveBeenCalledWith( + expect.objectContaining({ content: '', encoding: 'base64' }) + ) + }) + + it('allows JSON bodies above the default 50 MiB cap for base64 expansion', async () => { + const res = await callPut( + { workspaceId: WS, content: 'TQ==', encoding: 'base64' }, + 60 * 1024 * 1024 + ) + + expect(res.status).toBe(200) + expect(mockPerformUpdateContent).toHaveBeenCalled() + }) + + it('returns an oversized JSON body in the canonical v2 413 envelope', async () => { + const res = await callPut({ workspaceId: WS, content: '' }, 70 * 1024 * 1024 + 1) + + expect(res.status).toBe(413) + await expect(res.json()).resolves.toEqual({ + error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' }, + }) + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) + it('surfaces an access-denied failure in the v2 error envelope', async () => { mockResolveWorkspaceAccess.mockResolvedValue({ status: 403, diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts index fb310ec2e94..bb03695aa5f 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -5,7 +5,10 @@ import { v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files' import { parseRequest } from '@/lib/api/server' import { messageForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performUpdateWorkspaceFileContent } from '@/lib/workspace-files/orchestration' +import { + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + performUpdateWorkspaceFileContent, +} from '@/lib/workspace-files/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2File } from '@/app/api/v2/files/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -46,9 +49,15 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: FileRo if (gate) return gate const parsed = await parseRequest(v2UpdateFileContentContract, request, context, { + invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), + maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, validationErrorResponse: v2ValidationError, }) - if (!parsed.success) return parsed.response + if (!parsed.success) { + return parsed.response.status === 413 + ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') + : parsed.response + } const { fileId } = parsed.data.params const { workspaceId, content, encoding } = parsed.data.body diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index ac6c3dca5bd..31d0eea1b59 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -6,13 +6,19 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockQueryWorkspaceFiles } = vi.hoisted( - () => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockQueryWorkspaceFiles: vi.fn(), - }) -) +const { + mockCheckRateLimit, + mockPerformCreateWorkspaceFile, + mockQueryWorkspaceFiles, + mockResolveWorkspaceAccess, + mockV2ApiGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockPerformCreateWorkspaceFile: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockQueryWorkspaceFiles: vi.fn(), + mockV2ApiGateError: vi.fn().mockResolvedValue(null), +})) vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit, @@ -20,15 +26,21 @@ vi.mock('@/app/api/v1/middleware', () => ({ })) vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), + v2ApiGateError: mockV2ApiGateError, })) vi.mock('@/lib/uploads/contexts/workspace', () => ({ queryWorkspaceFiles: mockQueryWorkspaceFiles, })) +vi.mock('@/lib/workspace-files/orchestration', () => ({ + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, + performCreateWorkspaceFile: mockPerformCreateWorkspaceFile, +})) + import { OrchestrationError } from '@/lib/core/orchestration/types' -import { GET } from '@/app/api/v2/files/route' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' +import { GET, POST } from '@/app/api/v2/files/route' const WS = 'workspace-1' const FOLDER_ID = 'fold_1' @@ -82,6 +94,14 @@ const DEFAULT_LIST_ARGS = { const callList = (query: string) => GET(new NextRequest(`http://localhost:3000/api/v2/files?${query}`)) +function createRequest(body: Record) { + return new NextRequest('http://localhost:3000/api/v2/files', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + describe('GET /api/v2/files', () => { beforeEach(() => { vi.clearAllMocks() @@ -274,3 +294,212 @@ describe('GET /api/v2/files', () => { expect((await res.json()).nextCursor).toBeNull() }) }) + +describe('POST /api/v2/files', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockV2ApiGateError.mockResolvedValue(null) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformCreateWorkspaceFile.mockResolvedValue({ + success: true, + file: buildRecord({ name: 'untitled.md', size: 0, type: 'text/markdown' }), + }) + }) + + it('creates an empty exact-name file with an inferred MIME type', async () => { + const request = createRequest({ workspaceId: WS, name: 'untitled.md' }) + + const response = await POST(request) + + expect(response.status).toBe(201) + await expect(response.json()).resolves.toMatchObject({ + data: { id: 'wf_1', name: 'untitled.md', size: 0, type: 'text/markdown' }, + }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(RATE_LIMIT_OK, 'user-1', WS, 'write') + expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith({ + workspaceId: WS, + userId: 'user-1', + name: 'untitled.md', + contentType: 'text/markdown', + folderId: undefined, + content: Buffer.alloc(0), + exactName: true, + request, + }) + }) + + it('decodes initialized base64 content before orchestration', async () => { + mockPerformCreateWorkspaceFile.mockResolvedValue({ + success: true, + file: buildRecord({ + name: 'seed.bin', + size: 3, + type: 'application/octet-stream', + folderId: FOLDER_ID, + folderPath: 'Fixtures', + }), + }) + const request = createRequest({ + workspaceId: WS, + name: 'seed.bin', + contentType: 'application/octet-stream', + folderId: FOLDER_ID, + content: Buffer.from([1, 2, 3]).toString('base64'), + encoding: 'base64', + }) + + const response = await POST(request) + + expect(response.status).toBe(201) + expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WS, + name: 'seed.bin', + contentType: 'application/octet-stream', + folderId: FOLDER_ID, + content: Buffer.from([1, 2, 3]), + exactName: true, + }) + ) + }) + + it('rejects malformed base64 before workspace access or orchestration', async () => { + const response = await POST( + createRequest({ + workspaceId: WS, + name: 'seed.bin', + content: 'not-base64!', + encoding: 'base64', + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'BAD_REQUEST' }, + }) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + }) + + it('accepts empty base64 as a zero-byte file', async () => { + const response = await POST( + createRequest({ workspaceId: WS, name: 'empty.bin', content: '', encoding: 'base64' }) + ) + + expect(response.status).toBe(201) + expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ content: Buffer.alloc(0) }) + ) + }) + + it('returns the canonical v2 envelope when the JSON body exceeds the inline limit', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/files', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': String(MAX_WORKSPACE_FILE_INLINE_BODY_BYTES + 1), + }, + body: '{}', + }) + + const response = await POST(request) + + expect(response.status).toBe(413) + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' }, + }) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + }) + + it('returns the canonical v2 envelope for malformed JSON', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/files', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{not-json', + }) + + const response = await POST(request) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'BAD_REQUEST', message: 'Request body must be valid JSON' }, + }) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + }) + + it.each([ + { + label: 'name conflict', + result: { + success: false, + error: 'A file with this name already exists', + errorCode: 'conflict', + }, + status: 409, + code: 'CONFLICT', + message: 'A file with this name already exists', + }, + { + label: 'internal orchestration failure', + result: { success: false, error: 'database connection details', errorCode: 'internal' }, + status: 500, + code: 'INTERNAL_ERROR', + message: 'Internal server error', + }, + ])('maps a $label into the v2 error envelope', async ({ result, status, code, message }) => { + mockPerformCreateWorkspaceFile.mockResolvedValue(result) + + const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) + + expect(response.status).toBe(status) + await expect(response.json()).resolves.toMatchObject({ error: { code, message } }) + }) + + it('returns the auth failure before gating, access checks, or orchestration', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + error: 'Invalid API key', + limit: 100, + remaining: 0, + resetAt: RATE_LIMIT_OK.resetAt, + }) + + const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'UNAUTHORIZED', message: 'Invalid API key' }, + }) + expect(mockV2ApiGateError).not.toHaveBeenCalled() + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + }) + + it('returns the v2 gate failure before access checks or orchestration', async () => { + const { v2Error } = await import('@/app/api/v2/lib/response') + mockV2ApiGateError.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + + const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) + + expect(response.status).toBe(404) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + }) + + it('requires workspace write access before orchestration', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) + + expect(response.status).toBe(403) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(RATE_LIMIT_OK, 'user-1', WS, 'write') + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index e6fd3e6698c..cc2e0177832 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -1,10 +1,20 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { type V2File, v2ListFilesContract } from '@/lib/api/contracts/v2/files' +import { + type V2File, + v2CreateFileContract, + v2ListFilesContract, +} from '@/lib/api/contracts/v2/files' import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { queryWorkspaceFiles } from '@/lib/uploads/contexts/workspace' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + performCreateWorkspaceFile, +} from '@/lib/workspace-files/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2File } from '@/app/api/v2/files/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -15,7 +25,9 @@ import { v2CaughtOrchestrationError, v2CursorList, v2CursorSortError, + v2Data, v2Error, + v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, @@ -90,3 +102,57 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return v2Error('INTERNAL_ERROR', 'Internal server error') } }) + +/** POST /api/v2/files — Create an authored workspace file, optionally with initial content. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateFileContract, + request, + {}, + { + invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), + maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) { + return parsed.response.status === 413 + ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') + : parsed.response + } + + const { workspaceId, name, contentType, folderId, content, encoding } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performCreateWorkspaceFile({ + workspaceId, + userId, + name, + contentType: contentType ?? getMimeTypeFromExtension(getFileExtension(name)), + folderId, + content: Buffer.from(content, encoding), + exactName: true, + request, + }) + if (!result.success || !result.file) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to create file') + ) + } + + return v2Data(toV2File(result.file), { rateLimit, status: 201 }) + } catch (error) { + logger.error('Error creating file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts index e4504fd952a..9739994d62d 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts @@ -1,15 +1,11 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { v2CompleteFileUploadContract } from '@/lib/api/contracts/v2/files' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getWorkspaceFile, registerUploadedWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { - completeUploadSession, - getOwnedUploadSession, -} from '@/lib/uploads/multipart-session/service' +import { completeUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' +import { finalizeWorkspaceFileUpload } from '@/app/api/files/uploads/finalizers' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -51,41 +47,20 @@ export const POST = withRouteHandler( purpose: 'workspace_file', uploadToken: parsed.data.headers['upload-token'], }) - const metadata = session.metadata as { folderId?: string | null } const result = await completeUploadSession({ session, - parts: parsed.data.body.parts, + completion: parsed.data.body, finalize: async (claimed) => { - const registered = await registerUploadedWorkspaceFile({ - workspaceId, - userId, - key: claimed.storageKey, - originalName: claimed.fileName, - contentType: claimed.contentType, - folderId: metadata.folderId, + const finalized = await finalizeWorkspaceFileUpload({ + session: claimed, + actor: { id: userId }, + request, + source: 'api', }) - return { value: registered.file.id, completedFileId: registered.file.id } + return { value: finalized.file, completedFileId: finalized.file.id } }, }) - const fileId = result.value - if (!fileId) throw new Error('Completed upload is missing its workspace file id') - const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) - if (!file) throw new Error(`Completed workspace file ${fileId} not found`) - - if (!result.alreadyCompleted) { - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.FILE_UPLOADED, - resourceType: AuditResourceType.FILE, - resourceId: file.id, - resourceName: file.name, - description: `Uploaded file "${file.name}" via API`, - metadata: { fileSize: file.size, fileType: file.type }, - request, - }) - } - return v2Data(toV2FileUpload(result.session, file), { rateLimit }) + return v2Data(toV2FileUpload(result.session, result.value), { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts index c3148698c7b..f27f759faa7 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts @@ -4,10 +4,7 @@ import type { NextRequest } from 'next/server' import { v2CreateFileUploadPartUrlsContract } from '@/lib/api/contracts/v2/files' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - createUploadPartUrls, - getOwnedUploadSession, -} from '@/lib/uploads/multipart-session/service' +import { createUploadPartUrls, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts index dd07baa1d72..9561a1abcfa 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts @@ -4,7 +4,7 @@ import type { NextRequest } from 'next/server' import { v2AbortFileUploadContract } from '@/lib/api/contracts/v2/files' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/multipart-session/service' +import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' diff --git a/apps/sim/app/api/v2/files/uploads/route.test.ts b/apps/sim/app/api/v2/files/uploads/route.test.ts index b70ac9aba3d..8065f99830b 100644 --- a/apps/sim/app/api/v2/files/uploads/route.test.ts +++ b/apps/sim/app/api/v2/files/uploads/route.test.ts @@ -29,7 +29,7 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({ assertWorkspaceFileFolderTarget: mockAssertFolder, })) -vi.mock('@/lib/uploads/multipart-session/service', () => ({ +vi.mock('@/lib/uploads/upload-session/service', () => ({ createUploadSession: mockCreateUploadSession, })) @@ -66,16 +66,21 @@ describe('POST /api/v2/files/uploads', () => { workspaceId: WORKSPACE_ID, userId: 'user-1', knowledgeBaseId: null, + workflowId: null, + executionId: null, purpose: 'workspace_file', + method: 'put', storageContext: 'workspace', storageKey: `${WORKSPACE_ID}/file.csv`, + finalKey: `${WORKSPACE_ID}/file.csv`, + stagingKey: 'upload-sessions/upload-1/file.csv', storageProvider: 's3', - providerUploadId: 'provider-1', + providerUploadId: null, fileName: 'file.csv', contentType: 'text/csv', fileSize: 10, - partSize: 8 * 1024 * 1024, - partCount: 1, + partSize: null, + partCount: null, status: 'uploading', uploadToken: 'signed-upload-token', metadata: {}, @@ -85,10 +90,15 @@ describe('POST /api/v2/files/uploads', () => { createdAt: new Date('2026-08-03T21:00:00.000Z'), updatedAt: new Date('2026-08-03T21:00:00.000Z'), completedAt: null, + transfer: { + method: 'put', + url: 'https://storage.example/upload', + headers: { 'content-type': 'text/csv' }, + }, }) }) - it('creates one signed multipart session for a small file', async () => { + it('creates one signed PUT session for a small file', async () => { const response = await request({ workspaceId: WORKSPACE_ID, name: 'file.csv', @@ -97,13 +107,16 @@ describe('POST /api/v2/files/uploads', () => { }) expect(response.status).toBe(201) - expect((await response.json()).data).toMatchObject({ - id: 'upload-1', - status: 'uploading', - partCount: 1, + const { data } = await response.json() + expect(data).toMatchObject({ + session: { id: 'upload-1', status: 'uploading', file: null }, uploadToken: 'signed-upload-token', - file: null, + transfer: { method: 'put', url: 'https://storage.example/upload' }, }) + expect(data.session).not.toHaveProperty('uploadToken') + expect(data.session).not.toHaveProperty('transfer') + expect(data.session).not.toHaveProperty('partSize') + expect(data.session).not.toHaveProperty('partCount') expect(mockCreateUploadSession).toHaveBeenCalledWith({ workspaceId: WORKSPACE_ID, userId: 'user-1', @@ -112,6 +125,7 @@ describe('POST /api/v2/files/uploads', () => { contentType: 'text/csv', fileSize: 10, metadata: { folderId: null }, + localOrigin: 'http://localhost:3000', }) }) @@ -134,7 +148,7 @@ describe('POST /api/v2/files/uploads', () => { expect(mockCreateUploadSession).not.toHaveBeenCalled() }) - it('rejects an empty file before creating provider state', async () => { + it('creates an upload session for an empty workspace file', async () => { const response = await request({ workspaceId: WORKSPACE_ID, name: 'file.csv', @@ -142,8 +156,9 @@ describe('POST /api/v2/files/uploads', () => { size: 0, }) - expect(response.status).toBe(400) - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockCreateUploadSession).not.toHaveBeenCalled() + expect(response.status).toBe(201) + expect(mockCreateUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ purpose: 'workspace_file', fileSize: 0 }) + ) }) }) diff --git a/apps/sim/app/api/v2/files/uploads/route.ts b/apps/sim/app/api/v2/files/uploads/route.ts index 8ee6777176d..10cc3089ef2 100644 --- a/apps/sim/app/api/v2/files/uploads/route.ts +++ b/apps/sim/app/api/v2/files/uploads/route.ts @@ -5,7 +5,7 @@ import { v2CreateFileUploadContract } from '@/lib/api/contracts/v2/files' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' -import { createUploadSession } from '@/lib/uploads/multipart-session/service' +import { createUploadSession } from '@/lib/uploads/upload-session/service' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -50,8 +50,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { contentType, fileSize: size, metadata: { folderId: normalizedFolderId }, + localOrigin: request.nextUrl.origin, }) - return v2Data(toV2FileUpload(session, null), { rateLimit, status: 201 }) + return v2Data( + { + session: toV2FileUpload(session, null), + uploadToken: session.uploadToken, + transfer: session.transfer, + }, + { rateLimit, status: 201 } + ) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified diff --git a/apps/sim/app/api/v2/files/uploads/utils.ts b/apps/sim/app/api/v2/files/uploads/utils.ts index c79baf22bb1..45cebe61bab 100644 --- a/apps/sim/app/api/v2/files/uploads/utils.ts +++ b/apps/sim/app/api/v2/files/uploads/utils.ts @@ -1,7 +1,7 @@ import type { V2FileUpload } from '@/lib/api/contracts/v2/files' import type { V2UploadStatus } from '@/lib/api/contracts/v2/uploads' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' -import type { UploadSessionRecord } from '@/lib/uploads/multipart-session/service' +import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' import { toV2File } from '@/app/api/v2/files/utils' export function toV2FileUpload( @@ -14,9 +14,6 @@ export function toV2FileUpload( name: session.fileName, contentType: session.contentType, size: session.fileSize, - partSize: session.partSize, - partCount: session.partCount, - uploadToken: session.uploadToken, expiresAt: session.expiresAt.toISOString(), error: session.error, file: file ? toV2File(file) : null, 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 b923f82a39c..91a07f307f5 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 @@ -22,7 +22,7 @@ vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit } vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null), })) -vi.mock('@/lib/uploads/multipart-session/service', () => ({ +vi.mock('@/lib/uploads/upload-session/service', () => ({ completeUploadSession: mockCompleteUploadSession, })) vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ @@ -50,9 +50,14 @@ const SESSION = { workspaceId: WORKSPACE_ID, userId: 'user-1', knowledgeBaseId: 'kb-1', + workflowId: null, + executionId: null, purpose: 'knowledge_document', + method: 'multipart', storageContext: 'knowledge-base', storageKey: 'kb/guide.pdf', + finalKey: 'kb/guide.pdf', + stagingKey: 'upload-sessions/upload-1/guide.pdf', storageProvider: 's3', providerUploadId: 'provider-1', fileName: 'guide.pdf', @@ -146,6 +151,12 @@ describe('POST knowledge-document multipart completion', () => { source: 'api', }) ) + expect(mockCompleteUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ + session: SESSION, + completion: { parts: [{ partNumber: 1, etag: 'etag-1' }] }, + }) + ) }) it('resolves the payer lazily, only when the finalizer asks for one', async () => { 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 b4835d232ea..0da551ca60d 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 @@ -6,7 +6,7 @@ import { v2CompleteKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { completeUploadSession } from '@/lib/uploads/multipart-session/service' +import { completeUploadSession } from '@/lib/uploads/upload-session/service' import { checkRateLimit } from '@/app/api/v1/middleware' import { finalizeKnowledgeDocumentUpload, @@ -68,7 +68,7 @@ export const POST = withRouteHandler( }) const result = await completeUploadSession({ session, - parts: parsed.data.body.parts, + completion: parsed.data.body, finalize: (claimed) => finalizeKnowledgeDocumentUpload({ claimed, 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 80b079ad985..e69f1eece4b 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 @@ -5,7 +5,7 @@ import { NextResponse } from 'next/server' import { v2CreateKnowledgeDocumentUploadPartUrlsContract } from '@/lib/api/contracts/v2/knowledge' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createUploadPartUrls } from '@/lib/uploads/multipart-session/service' +import { createUploadPartUrls } from '@/lib/uploads/upload-session/service' import { checkRateLimit } from '@/app/api/v1/middleware' import { getOwnedKnowledgeDocumentUpload, 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 cf3ea905882..0d0b0462332 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 @@ -79,10 +79,13 @@ describe('POST /api/v2/knowledge/[id]/documents/uploads', () => { fileName: 'guide.pdf', contentType: 'application/pdf', fileSize: 1024, - partSize: 8 * 1024 * 1024, - partCount: 1, uploadToken: 'token', error: null, + transfer: { + method: 'put', + url: 'https://storage.example/upload', + headers: { 'content-type': 'application/pdf' }, + }, }) }) @@ -109,6 +112,12 @@ describe('POST /api/v2/knowledge/[id]/documents/uploads', () => { tag1: 'product', processingOptions: { recipe: 'default', lang: 'en' }, }, + localOrigin: 'http://localhost:3000', + }) + expect((await response.json()).data).toMatchObject({ + session: { id: 'upload-1', status: 'uploading', document: null }, + uploadToken: 'token', + transfer: { method: 'put', url: 'https://storage.example/upload' }, }) expect(mockResolveKnowledgeDocumentUploadBilling.mock.invocationCallOrder[0]).toBeLessThan( mockCreateKnowledgeDocumentUploadSession.mock.invocationCallOrder[0] 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 fb15afddeea..e736548fd98 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 @@ -72,8 +72,16 @@ export const POST = withRouteHandler( contentType, fileSize: size, metadata, + localOrigin: request.nextUrl.origin, }) - return v2Data(toV2KnowledgeDocumentUpload(session, null), { rateLimit, status: 201 }) + return v2Data( + { + session: toV2KnowledgeDocumentUpload(session, null), + uploadToken: session.uploadToken, + transfer: session.transfer, + }, + { rateLimit, status: 201 } + ) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts index 708f666b2a9..564388df5d2 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts @@ -3,7 +3,7 @@ */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { UploadSessionRecord } from '@/lib/uploads/multipart-session/service' +import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' const { mockAbortUploadSession, @@ -25,7 +25,7 @@ vi.mock('@/lib/knowledge/orchestration', () => ({ vi.mock('@/lib/knowledge/orchestration/documents', () => ({ findBoundKnowledgeDocument: mockFindBoundKnowledgeDocument, })) -vi.mock('@/lib/uploads/multipart-session/service', () => ({ +vi.mock('@/lib/uploads/upload-session/service', () => ({ abortUploadSession: mockAbortUploadSession, createUploadSession: mockCreateUploadSession, getOwnedUploadSession: vi.fn(), @@ -38,6 +38,7 @@ import { abortKnowledgeDocumentUpload, createKnowledgeDocumentUploadSession, finalizeKnowledgeDocumentUpload, + toV2KnowledgeDocumentUpload, } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' @@ -46,9 +47,14 @@ const CLAIMED: UploadSessionRecord = { workspaceId: WORKSPACE_ID, userId: 'user-1', knowledgeBaseId: 'kb-1', + workflowId: null, + executionId: null, purpose: 'knowledge_document', + method: 'multipart', storageContext: 'knowledge-base', storageKey: 'kb/guide.pdf', + finalKey: 'kb/guide.pdf', + stagingKey: 'upload-sessions/upload-1/guide.pdf', storageProvider: 's3', providerUploadId: 'provider-1', fileName: 'guide.pdf', @@ -91,6 +97,7 @@ function createSession() { contentType: 'application/pdf', fileSize: 1024, metadata: { tag1: 'product' }, + localOrigin: 'http://localhost:3000', }) } @@ -114,6 +121,7 @@ describe('createKnowledgeDocumentUploadSession', () => { contentType: 'application/pdf', fileSize: 1024, metadata: { tag1: 'product' }, + localOrigin: 'http://localhost:3000', }) expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenCalledWith({ key: 'kb/guide.pdf', @@ -136,6 +144,17 @@ describe('createKnowledgeDocumentUploadSession', () => { }) }) +describe('toV2KnowledgeDocumentUpload', () => { + it('does not expose reusable upload capabilities after session creation', () => { + const serialized = toV2KnowledgeDocumentUpload(CLAIMED, null) + + expect(serialized).not.toHaveProperty('uploadToken') + expect(serialized).not.toHaveProperty('partSize') + expect(serialized).not.toHaveProperty('partCount') + expect(serialized).not.toHaveProperty('transfer') + }) +}) + describe('abortKnowledgeDocumentUpload', () => { beforeEach(() => { vi.clearAllMocks() 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 2844b5245dc..4e4122221f0 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 @@ -16,13 +16,14 @@ import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration' import type { CreatedKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' import { findBoundKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' import { abortUploadSession, + type CreatedUploadSession, createUploadSession, getOwnedUploadSession, type UploadSessionRecord, -} from '@/lib/uploads/multipart-session/service' -import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' +} from '@/lib/uploads/upload-session/service' import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' import type { RateLimitResult } from '@/app/api/v1/middleware' import { v2Error } from '@/app/api/v2/lib/response' @@ -110,7 +111,8 @@ export async function createKnowledgeDocumentUploadSession(params: { contentType: string fileSize: number metadata: Record -}): Promise { + localOrigin: string +}): Promise { const session = await createUploadSession({ ...params, purpose: 'knowledge_document', @@ -163,9 +165,6 @@ export function toV2KnowledgeDocumentUpload( name: session.fileName, contentType: session.contentType, size: session.fileSize, - partSize: session.partSize, - partCount: session.partCount, - uploadToken: session.uploadToken, expiresAt: session.expiresAt.toISOString(), error: session.error, document: document ? toV2KnowledgeDocumentSummary(document) : null, @@ -216,7 +215,7 @@ export async function abortKnowledgeDocumentUpload( } /** - * Binds a completed multipart session to its knowledge document. Shared by the public v2 + * Binds a completed upload session to its knowledge document. Shared by the public v2 * and session-authenticated routes so both get identical completion semantics. * * Ordering is load-bearing. A retry is answered from the already-bound document before any diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts index 783a473fda5..637e56f8f99 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts @@ -3,6 +3,7 @@ */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' const { mockCheckRateLimit, @@ -12,6 +13,7 @@ const { mockStartUploadedTableImport, mockToV2TableImport, mockCompleteUploadSession, + mockValidateUploadCompletion, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceScope: vi.fn(), @@ -20,6 +22,7 @@ const { mockStartUploadedTableImport: vi.fn(), mockToV2TableImport: vi.fn(), mockCompleteUploadSession: vi.fn(), + mockValidateUploadCompletion: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -42,8 +45,9 @@ vi.mock('@/lib/table/orchestration/import-resource', () => ({ toV2TableImport: mockToV2TableImport, })) -vi.mock('@/lib/uploads/multipart-session/service', () => ({ +vi.mock('@/lib/uploads/upload-session/service', () => ({ completeUploadSession: mockCompleteUploadSession, + validateUploadCompletion: mockValidateUploadCompletion, })) import { POST } from '@/app/api/v2/tables/imports/[importId]/complete/route' @@ -63,7 +67,7 @@ const UPLOAD = { userId: 'user-1', } -function request() { +function request(body: Record = { parts: [{ partNumber: 1, etag: 'etag-1' }] }) { return POST( new NextRequest( `http://localhost:3000/api/v2/tables/imports/import-1/complete?workspaceId=${WORKSPACE_ID}`, @@ -73,7 +77,7 @@ function request() { 'Content-Type': 'application/json', 'upload-token': 'signed-upload-token', }, - body: JSON.stringify({ parts: [{ partNumber: 1, etag: 'etag-1' }] }), + body: JSON.stringify(body), } ), { params: Promise.resolve({ importId: 'import-1' }) } @@ -109,7 +113,50 @@ describe('POST /api/v2/tables/imports/[importId]/complete', () => { workspaceId: WORKSPACE_ID, userId: 'user-1', }) + expect(mockValidateUploadCompletion).toHaveBeenCalledWith(UPLOAD, { + parts: [{ partNumber: 1, etag: 'etag-1' }], + }) expect(mockCompleteUploadSession).not.toHaveBeenCalled() expect(mockStartUploadedTableImport).not.toHaveBeenCalled() }) + + it('validates completion shape before returning an existing table job', async () => { + mockFindOwnedTableImport.mockResolvedValue({ id: 'import-1' }) + mockValidateUploadCompletion.mockImplementationOnce(() => { + throw new OrchestrationError('validation', 'Multipart completion requires parts') + }) + + const response = await request({}) + + expect(response.status).toBe(400) + expect(mockFindOwnedTableImport).not.toHaveBeenCalled() + expect(mockCompleteUploadSession).not.toHaveBeenCalled() + }) + + it.each([ + ['PUT', {}], + ['multipart', { parts: [{ partNumber: 1, etag: 'etag-1' }] }], + ])('forwards a %s completion body and starts the import job', async (_method, completion) => { + const started = { id: 'import-1', tableId: 'table-1', status: 'running' } + const responseBody = { id: 'import-1', tableId: 'table-1', status: 'processing' } + mockFindOwnedTableImport.mockResolvedValue(null) + mockCompleteUploadSession.mockResolvedValue({ + session: UPLOAD, + value: null, + alreadyCompleted: false, + }) + mockStartUploadedTableImport.mockResolvedValue(started) + mockToV2TableImport.mockReturnValue(responseBody) + + const response = await request(completion) + + expect(response.status).toBe(200) + expect(mockCompleteUploadSession).toHaveBeenCalledWith({ + session: UPLOAD, + completion, + finalize: expect.any(Function), + }) + expect(mockStartUploadedTableImport).toHaveBeenCalledWith(UPLOAD) + expect(await response.json()).toEqual({ data: responseBody }) + }) }) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts index 4e44d28dafe..17860487743 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts @@ -10,7 +10,10 @@ import { startUploadedTableImport, toV2TableImport, } from '@/lib/table/orchestration/import-resource' -import { completeUploadSession } from '@/lib/uploads/multipart-session/service' +import { + completeUploadSession, + validateUploadCompletion, +} from '@/lib/uploads/upload-session/service' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { @@ -50,15 +53,16 @@ export const POST = withRouteHandler( userId, uploadToken: parsed.data.headers['upload-token'], }) + validateUploadCompletion(upload, parsed.data.body) const existing = await findOwnedTableImport({ importId: upload.id, - workspaceId: upload.workspaceId, + workspaceId, userId: upload.userId, }) if (existing) return v2Data(toV2TableImport(existing), { rateLimit }) const completed = await completeUploadSession({ session: upload, - parts: parsed.data.body.parts, + completion: parsed.data.body, finalize: async () => ({ value: null }), }) const started = await startUploadedTableImport(completed.session) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts index 74f3153a9be..1d419975ac5 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts @@ -5,7 +5,7 @@ import { v2CreateTableImportPartUrlsContract } from '@/lib/api/contracts/v2/tabl import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getOwnedTableImportUpload } from '@/lib/table/orchestration/import-resource' -import { createUploadPartUrls } from '@/lib/uploads/multipart-session/service' +import { createUploadPartUrls } from '@/lib/uploads/upload-session/service' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { diff --git a/apps/sim/app/api/v2/tables/imports/route.test.ts b/apps/sim/app/api/v2/tables/imports/route.test.ts new file mode 100644 index 00000000000..a45e7de4fce --- /dev/null +++ b/apps/sim/app/api/v2/tables/imports/route.test.ts @@ -0,0 +1,135 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceScope, + mockCreateTableImportResource, + mockToV2CreateTableImport, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceScope: vi.fn(), + mockCreateTableImportResource: vi.fn(), + mockToV2CreateTableImport: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceScope: mockResolveWorkspaceScope, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/app/api/v2/tables/utils', () => ({ + v2TableLockError: vi.fn().mockReturnValue(null), +})) + +vi.mock('@/lib/table/orchestration/import-resource', () => ({ + createTableImportResource: mockCreateTableImportResource, + toV2CreateTableImport: mockToV2CreateTableImport, +})) + +import { POST } from '@/app/api/v2/tables/imports/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-03T22:00:00.000Z'), +} + +describe('POST /api/v2/tables/imports', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockResolveWorkspaceScope.mockResolvedValue(null) + }) + + it.each([ + [ + 'upload', + { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 128 }, + { + session: { id: 'import-1', source: { type: 'upload' } }, + uploadToken: 'signed-token', + transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, + }, + ], + [ + 'workspace file', + { type: 'workspace_file', fileId: 'file-1' }, + { + session: { id: 'import-1', source: { type: 'workspace_file', fileId: 'file-1' } }, + uploadToken: null, + transfer: null, + }, + ], + ])('returns the create envelope for a %s source', async (_label, source, responseData) => { + const requestBody = { + workspaceId: WORKSPACE_ID, + source, + target: { type: 'new', name: 'imported_data' }, + } + const created = { record: { id: 'import-1' }, upload: null } + mockCreateTableImportResource.mockResolvedValue(created) + mockToV2CreateTableImport.mockReturnValue(responseData) + + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/tables/imports', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }) + ) + + expect(response.status).toBe(201) + expect(mockCreateTableImportResource).toHaveBeenCalledWith( + requestBody, + 'user-1', + 'http://localhost:3000' + ) + expect(mockToV2CreateTableImport).toHaveBeenCalledWith(created) + expect(await response.json()).toEqual({ data: responseData }) + }) + + it('accepts native JSON mapping and createColumns values', async () => { + const requestBody = { + workspaceId: WORKSPACE_ID, + source: { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 128 }, + target: { type: 'existing', tableId: 'table-1', mode: 'append' }, + mapping: { email: 'email_address', notes: null }, + createColumns: ['phone'], + } + const created = { record: { id: 'import-1' }, upload: null } + const responseData = { + session: { id: 'import-1', source: { type: 'upload' } }, + uploadToken: 'signed-token', + transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, + } + mockCreateTableImportResource.mockResolvedValue(created) + mockToV2CreateTableImport.mockReturnValue(responseData) + + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/tables/imports', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }) + ) + + expect(response.status).toBe(201) + expect(mockCreateTableImportResource).toHaveBeenCalledWith( + requestBody, + 'user-1', + 'http://localhost:3000' + ) + }) +}) diff --git a/apps/sim/app/api/v2/tables/imports/route.ts b/apps/sim/app/api/v2/tables/imports/route.ts index 2a0aeaf07a5..ad533a55e58 100644 --- a/apps/sim/app/api/v2/tables/imports/route.ts +++ b/apps/sim/app/api/v2/tables/imports/route.ts @@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createTableImportResource, - toV2TableImport, + toV2CreateTableImport, } from '@/lib/table/orchestration/import-resource' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -40,8 +40,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.body.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const created = await createTableImportResource(parsed.data.body, userId) - return v2Data(await toV2TableImport(created.record), { rateLimit, status: 201 }) + const created = await createTableImportResource( + parsed.data.body, + userId, + request.nextUrl.origin + ) + return v2Data(toV2CreateTableImport(created), { rateLimit, status: 201 }) } catch (error) { const lockError = v2TableLockError(error) if (lockError) return lockError 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 new file mode 100644 index 00000000000..18fe9a6e23f --- /dev/null +++ b/apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.test.ts @@ -0,0 +1,100 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + MockLocalUploadBodyError, + mockExpectedUploadPartSize, + mockVerifyUploadSessionToken, + mockWriteLocalMultipartPart, +} = vi.hoisted(() => { + class MockLocalUploadBodyError extends Error {} + return { + MockLocalUploadBodyError, + mockExpectedUploadPartSize: vi.fn(), + mockVerifyUploadSessionToken: vi.fn(), + mockWriteLocalMultipartPart: vi.fn(), + } +}) + +vi.mock('@/lib/uploads/upload-session/provider', () => ({ + LocalUploadBodyError: MockLocalUploadBodyError, + writeLocalMultipartPart: mockWriteLocalMultipartPart, +})) + +vi.mock('@/lib/uploads/upload-session/service', () => ({ + expectedUploadPartSize: mockExpectedUploadPartSize, + verifyUploadSessionToken: mockVerifyUploadSessionToken, +})) + +import { PUT } from '@/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route' + +const SESSION = { + id: 'upload-1', + storageProvider: 'local', + method: 'multipart', + status: 'uploading', +} as const + +describe('PUT /api/v2/uploads/[uploadId]/parts/[partNumber]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockVerifyUploadSessionToken.mockReturnValue(SESSION) + mockExpectedUploadPartSize.mockReturnValue(3) + mockWriteLocalMultipartPart.mockResolvedValue(undefined) + }) + + it('streams an exact-size local multipart part', async () => { + const response = await request() + + expect(response.status).toBe(204) + expect(mockVerifyUploadSessionToken).toHaveBeenCalledWith('signed-token') + expect(mockExpectedUploadPartSize).toHaveBeenCalledWith(SESSION, 1) + expect(mockWriteLocalMultipartPart).toHaveBeenCalledWith({ + uploadId: 'upload-1', + partNumber: 1, + body: expect.any(ReadableStream), + expectedSize: 3, + }) + }) + + it('maps a streamed-size failure to 400', async () => { + mockWriteLocalMultipartPart.mockRejectedValue( + new MockLocalUploadBodyError('Part 1 has 2 bytes; expected 3') + ) + + const response = await request({ contentLength: null }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + error: 'Part 1 has 2 bytes; expected 3', + }) + }) + + it('rejects PUT sessions before calculating a part size', async () => { + mockVerifyUploadSessionToken.mockReturnValue({ ...SESSION, method: 'put' }) + + const response = await request() + + expect(response.status).toBe(409) + expect(mockExpectedUploadPartSize).not.toHaveBeenCalled() + expect(mockWriteLocalMultipartPart).not.toHaveBeenCalled() + }) +}) + +function request(options?: { contentLength?: string | null }) { + const headers = new Headers({ 'Content-Type': 'application/octet-stream' }) + if (options?.contentLength !== null) { + headers.set('Content-Length', options?.contentLength ?? '3') + } + return PUT( + new NextRequest('http://localhost:3000/api/v2/uploads/upload-1/parts/1?token=signed-token', { + method: 'PUT', + headers, + body: new Uint8Array([1, 2, 3]), + }), + { params: Promise.resolve({ uploadId: 'upload-1', partNumber: '1' }) } + ) +} 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 3baff93ec04..8e7c8cf4933 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 @@ -2,12 +2,15 @@ import { type NextRequest, NextResponse } from 'next/server' import { localUploadPartContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { writeLocalMultipartPart } from '@/lib/uploads/multipart-session/provider' +import { + LocalUploadBodyError, + writeLocalMultipartPart, +} from '@/lib/uploads/upload-session/provider' import { expectedUploadPartSize, type UploadSessionRecord, verifyUploadSessionToken, -} from '@/lib/uploads/multipart-session/service' +} from '@/lib/uploads/upload-session/service' interface LocalPartRouteParams { params: Promise<{ uploadId: string; partNumber: string }> @@ -36,6 +39,9 @@ export const PUT = withRouteHandler( if (session.status !== 'uploading') { return NextResponse.json({ error: `Upload session is ${session.status}` }, { status: 409 }) } + if (session.method !== 'multipart') { + return NextResponse.json({ error: 'PUT upload sessions do not have parts' }, { status: 409 }) + } const { partNumber } = parsed.data.params const expectedSize = expectedUploadPartSize(session, partNumber) @@ -50,7 +56,14 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Upload part body is required' }, { status: 400 }) } - await writeLocalMultipartPart({ uploadId, partNumber, body: request.body, expectedSize }) + try { + await writeLocalMultipartPart({ uploadId, partNumber, body: request.body, expectedSize }) + } catch (error) { + if (error instanceof LocalUploadBodyError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + throw error + } return new NextResponse(null, { status: 204 }) } ) diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts b/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts new file mode 100644 index 00000000000..22e18916d89 --- /dev/null +++ b/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts @@ -0,0 +1,142 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { MockLocalUploadBodyError, mockGetOwnedUploadSession, mockMetadata, mockWriteLocalPut } = + vi.hoisted(() => { + class MockLocalUploadBodyError extends Error {} + return { + MockLocalUploadBodyError, + mockGetOwnedUploadSession: vi.fn(), + mockMetadata: vi.fn(), + mockWriteLocalPut: vi.fn(), + } + }) + +vi.mock('@/lib/uploads/upload-session/provider', () => ({ + LocalUploadBodyError: MockLocalUploadBodyError, + writeLocalPutObject: mockWriteLocalPut, +})) + +vi.mock('@/lib/uploads/upload-session/service', () => ({ + getOwnedUploadSession: mockGetOwnedUploadSession, + uploadSessionObjectMetadata: mockMetadata, +})) + +import { PUT } from '@/app/api/v2/uploads/[uploadId]/route' + +const SESSION = { + id: 'upload-1', + workspaceId: 'workspace-1', + userId: 'user-1', + knowledgeBaseId: null, + workflowId: null, + executionId: null, + purpose: 'workspace_file', + method: 'put', + storageContext: 'workspace', + storageKey: 'workspace/workspace-1/file.bin', + finalKey: 'workspace/workspace-1/file.bin', + stagingKey: 'upload-sessions/upload-1/file.bin', + storageProvider: 'local', + providerUploadId: null, + fileName: 'file.bin', + contentType: 'application/octet-stream', + fileSize: 3, + partSize: null, + partCount: null, + status: 'uploading', + metadata: {}, + uploadToken: 'signed-token', + createdAt: new Date('2026-08-04T12:00:00.000Z'), + expiresAt: new Date('2099-08-05T12:00:00.000Z'), + completedFileId: null, + error: null, + completedAt: null, + updatedAt: new Date('2026-08-04T12:00:00.000Z'), +} as const + +describe('PUT /api/v2/uploads/[uploadId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetOwnedUploadSession.mockReturnValue(SESSION) + mockMetadata.mockReturnValue({ uploadId: 'upload-1', purpose: 'workspace_file' }) + mockWriteLocalPut.mockResolvedValue(undefined) + }) + + it('streams the local PUT with the signed session size and canonical metadata', async () => { + const response = await request() + + expect(response.status).toBe(204) + expect(mockGetOwnedUploadSession).toHaveBeenCalledWith({ + uploadId: 'upload-1', + uploadToken: 'signed-token', + }) + expect(mockWriteLocalPut).toHaveBeenCalledWith({ + uploadId: 'upload-1', + stagingKey: 'upload-sessions/upload-1/file.bin', + body: expect.any(ReadableStream), + expectedSize: 3, + contentType: 'application/octet-stream', + metadata: { uploadId: 'upload-1', purpose: 'workspace_file' }, + }) + }) + + it('streams an empty local PUT body for an empty workspace-file session', async () => { + mockGetOwnedUploadSession.mockReturnValue({ ...SESSION, fileSize: 0 }) + const response = await request({ contentLength: '0', body: new Uint8Array() }) + + expect(response.status).toBe(204) + expect(mockWriteLocalPut).toHaveBeenCalledWith( + expect.objectContaining({ expectedSize: 0, body: expect.any(ReadableStream) }) + ) + }) + + it('rejects a mismatched Content-Length before opening the local writer', async () => { + const response = await request({ contentLength: '2' }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + error: 'Upload must contain exactly 3 bytes', + }) + expect(mockWriteLocalPut).not.toHaveBeenCalled() + }) + + it('rejects a URL whose token names a non-local or multipart session', async () => { + mockGetOwnedUploadSession.mockReturnValue({ ...SESSION, method: 'multipart' }) + + const response = await request() + + expect(response.status).toBe(403) + expect(mockWriteLocalPut).not.toHaveBeenCalled() + }) + + it('maps exact-size streaming failures to a caller error', async () => { + mockWriteLocalPut.mockRejectedValue(new MockLocalUploadBodyError('Upload exceeds 3 bytes')) + + const response = await request({ contentLength: null }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'Upload exceeds 3 bytes' }) + }) +}) + +function request(options?: { contentLength?: string | null; body?: Uint8Array }) { + const headers = new Headers({ + 'Content-Type': 'application/octet-stream', + 'upload-token': 'signed-token', + }) + if (options?.contentLength !== null) { + headers.set('Content-Length', options?.contentLength ?? '3') + } + return PUT( + new NextRequest('http://localhost:3000/api/v2/uploads/upload-1', { + method: 'PUT', + headers, + body: options?.body ?? new Uint8Array([1, 2, 3]), + }), + { params: Promise.resolve({ uploadId: 'upload-1' }) } + ) +} diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/uploads/[uploadId]/route.ts new file mode 100644 index 00000000000..c44c33489a2 --- /dev/null +++ b/apps/sim/app/api/v2/uploads/[uploadId]/route.ts @@ -0,0 +1,73 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { localPutUploadContract } from '@/lib/api/contracts/upload-sessions' +import { parseRequest } from '@/lib/api/server' +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' + +interface LocalPutRouteParams { + params: Promise<{ uploadId: string }> +} + +/** Local-storage data plane for a signed whole-object PUT upload session. */ +export const PUT = withRouteHandler( + async (request: NextRequest, context: LocalPutRouteParams): Promise => { + const parsed = await parseRequest(localPutUploadContract, request, context) + if (!parsed.success) return parsed.response + + let session + try { + session = getOwnedUploadSession({ + uploadId: parsed.data.params.uploadId, + uploadToken: parsed.data.headers['upload-token'], + }) + } catch { + return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) + } + + if (session.storageProvider !== 'local' || session.method !== 'put') { + return NextResponse.json({ error: 'Upload URL does not match this session' }, { status: 403 }) + } + if (session.expiresAt.getTime() <= Date.now()) { + return NextResponse.json({ error: 'Upload session has expired' }, { status: 409 }) + } + + const contentType = request.headers.get('content-type') + if (contentType !== session.contentType) { + return NextResponse.json( + { error: `Content-Type must be ${session.contentType}` }, + { status: 400 } + ) + } + 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 } + ) + } + if (!request.body) { + return NextResponse.json({ error: 'Upload body is required' }, { status: 400 }) + } + + try { + await writeLocalPutObject({ + uploadId: session.id, + stagingKey: session.stagingKey, + body: request.body, + expectedSize: session.fileSize, + contentType: session.contentType, + metadata: uploadSessionObjectMetadata(session), + }) + } catch (error) { + if (error instanceof LocalUploadBodyError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + throw error + } + return new NextResponse(null, { status: 204 }) + } +) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts new file mode 100644 index 00000000000..78f5cdb124f --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts @@ -0,0 +1,135 @@ +/** + * @vitest-environment node + */ +import { authMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetUserEntityPermissions, mockPerformUpdateContent } = vi.hoisted(() => ({ + mockGetUserEntityPermissions: vi.fn(), + mockPerformUpdateContent: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, + performUpdateWorkspaceFileContent: mockPerformUpdateContent, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +import { PUT } from '@/app/api/workspaces/[id]/files/[fileId]/content/route' + +const WORKSPACE_ID = 'workspace-1' +const FILE_ID = 'wf_1' +const USER = { id: 'user-1', name: 'Test User', email: 'test@sim.ai' } +const RECORD = { + id: FILE_ID, + workspaceId: WORKSPACE_ID, + name: 'notes.md', + key: `workspace/${WORKSPACE_ID}/notes.md`, + path: '/api/files/serve/notes.md?context=workspace', + size: 5, + type: 'text/markdown', + uploadedBy: USER.id, + folderId: null, + folderPath: null, + uploadedAt: new Date('2026-08-04T00:00:00.000Z'), + updatedAt: new Date('2026-08-04T00:00:00.000Z'), +} + +const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID, fileId: FILE_ID }) } + +function createRequest(body: unknown, contentLength?: number): NextRequest { + return new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}/content`, + { + method: 'PUT', + headers: { + 'content-type': 'application/json', + ...(contentLength === undefined ? {} : { 'content-length': String(contentLength) }), + }, + body: typeof body === 'string' ? body : JSON.stringify(body), + } + ) +} + +describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ user: USER }) + mockGetUserEntityPermissions.mockResolvedValue('write') + mockPerformUpdateContent.mockResolvedValue({ success: true, file: RECORD }) + }) + + it('authenticates before parsing an invalid request body', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + + const response = await PUT(createRequest('{not-json'), routeContext) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }) + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) + + it('authorizes the workspace before parsing the request body', async () => { + mockGetUserEntityPermissions.mockResolvedValue('read') + + const response = await PUT(createRequest('{not-json'), routeContext) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) + expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) + + it('rejects malformed base64 after authorization', async () => { + const response = await PUT( + createRequest({ content: 'not-base64!', encoding: 'base64' }), + routeContext + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'Validation error' }) + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) + + it('accepts empty base64 as a zero-byte replacement', async () => { + const request = createRequest({ content: '', encoding: 'base64' }) + const response = await PUT(request, routeContext) + + expect(response.status).toBe(200) + expect(mockPerformUpdateContent).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + fileId: FILE_ID, + userId: USER.id, + content: '', + encoding: 'base64', + actorName: USER.name, + actorEmail: USER.email, + request, + }) + }) + + it('allows JSON bodies above the default 50 MiB cap for base64 expansion', async () => { + const response = await PUT( + createRequest({ content: 'TQ==', encoding: 'base64' }, 60 * 1024 * 1024), + routeContext + ) + + expect(response.status).toBe(200) + expect(mockPerformUpdateContent).toHaveBeenCalled() + }) + + it('rejects a JSON body above the inline-content cap', async () => { + const response = await PUT(createRequest({ content: '' }, 70 * 1024 * 1024 + 1), routeContext) + + expect(response.status).toBe(413) + await expect(response.json()).resolves.toEqual({ + error: `Request body exceeds the maximum allowed size of ${70 * 1024 * 1024} bytes`, + }) + expect(mockPerformUpdateContent).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts index e2963b00f94..a7d4934c783 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts @@ -1,14 +1,20 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { updateWorkspaceFileContentContract } from '@/lib/api/contracts/workspace-files' -import { parseRequest } from '@/lib/api/server' +import { + updateWorkspaceFileContentContract, + workspaceFileParamsSchema, +} from '@/lib/api/contracts/workspace-files' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { messageForOrchestrationError, statusForOrchestrationError, } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performUpdateWorkspaceFileContent } from '@/lib/workspace-files/orchestration' +import { + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + performUpdateWorkspaceFileContent, +} from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -26,10 +32,14 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const parsed = await parseRequest(updateWorkspaceFileContentContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const { content, encoding } = parsed.data.body + const paramsResult = workspaceFileParamsSchema.safeParse(await context.params) + if (!paramsResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, + { status: 400 } + ) + } + const { id: workspaceId, fileId } = paramsResult.data const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) if (userPermission !== 'admin' && userPermission !== 'write') { @@ -37,6 +47,12 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } + const parsed = await parseRequest(updateWorkspaceFileContentContract, request, context, { + maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + }) + if (!parsed.success) return parsed.response + const { content, encoding } = parsed.data.body + const result = await performUpdateWorkspaceFileContent({ workspaceId, fileId, diff --git a/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts deleted file mode 100644 index d69aa933389..00000000000 --- a/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * @vitest-environment node - */ -import { - authMockFns, - permissionsMock, - permissionsMockFns, - storageServiceMock, - storageServiceMockFns, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckStorageQuota, - mockGenerateWorkspaceFileKey, - mockResolveStorageBillingContext, - mockUseBlobStorage, -} = vi.hoisted(() => ({ - mockCheckStorageQuota: vi.fn(), - mockGenerateWorkspaceFileKey: vi.fn(), - mockResolveStorageBillingContext: vi.fn(), - mockUseBlobStorage: { value: false }, -})) - -vi.mock('@/lib/billing/storage', () => ({ - checkStorageQuotaForBillingContext: mockCheckStorageQuota, - resolveStorageBillingContext: mockResolveStorageBillingContext, -})) - -vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - generateWorkspaceFileKey: mockGenerateWorkspaceFileKey, -})) - -vi.mock('@/lib/uploads/config', () => ({ - getServeStoragePrefix: () => (mockUseBlobStorage.value ? 'blob' : 's3'), -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) - -const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' -const STORAGE_CONTEXT = { - workspaceId: WS, - billedAccountUserId: 'workspace-owner', - billingEntity: { type: 'organization' as const, id: 'workspace-org' }, - plan: 'team_25000', - customStorageLimitGB: null, -} - -import { POST } from '@/app/api/workspaces/[id]/files/presigned/route' - -const params = (id = WS) => ({ params: Promise.resolve({ id }) }) - -const makeRequest = (body: unknown) => - new NextRequest(`http://localhost/api/workspaces/${WS}/files/presigned`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - -const validBody = { - fileName: 'video.mp4', - contentType: 'video/mp4', - fileSize: 10 * 1024 * 1024, -} - -describe('POST /api/workspaces/[id]/files/presigned', () => { - beforeEach(() => { - vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mockCheckStorageQuota.mockResolvedValue({ allowed: true }) - mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT) - storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true) - mockGenerateWorkspaceFileKey.mockReturnValue(`workspace/${WS}/123-abc-video.mp4`) - storageServiceMockFns.mockGeneratePresignedUploadUrl.mockResolvedValue({ - url: 'https://s3/presigned', - key: `workspace/${WS}/123-abc-video.mp4`, - uploadHeaders: { 'Content-Type': 'video/mp4' }, - }) - }) - - it('returns 401 when unauthenticated', async () => { - authMockFns.mockGetSession.mockResolvedValueOnce(null) - const res = await POST(makeRequest(validBody), params()) - expect(res.status).toBe(401) - }) - - it('returns 403 when user has read-only permission', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read') - const res = await POST(makeRequest(validBody), params()) - expect(res.status).toBe(403) - }) - - it('returns 400 for missing fileName', async () => { - const res = await POST(makeRequest({ ...validBody, fileName: '' }), params()) - expect(res.status).toBe(400) - }) - - it('returns 400 for negative fileSize', async () => { - const res = await POST(makeRequest({ ...validBody, fileSize: -1 }), params()) - expect(res.status).toBe(400) - }) - - it('accepts fileSize === 0 (empty new files)', async () => { - const res = await POST(makeRequest({ ...validBody, fileSize: 0 }), params()) - expect(res.status).toBe(200) - }) - - it('returns 413 when fileSize exceeds 5 GiB ceiling', async () => { - const res = await POST( - makeRequest({ ...validBody, fileSize: 6 * 1024 * 1024 * 1024 }), - params() - ) - expect(res.status).toBe(413) - }) - - it('returns 413 when storage quota would be exceeded', async () => { - mockCheckStorageQuota.mockResolvedValueOnce({ allowed: false, error: 'Over quota' }) - const res = await POST(makeRequest(validBody), params()) - const body = await res.json() - expect(res.status).toBe(413) - expect(body.error).toBe('Over quota') - }) - - it('returns local fallback signal when cloud storage is not configured', async () => { - storageServiceMockFns.mockHasCloudStorage.mockReturnValueOnce(false) - const res = await POST(makeRequest(validBody), params()) - const body = await res.json() - expect(res.status).toBe(200) - expect(body.directUploadSupported).toBe(false) - expect(body.presignedUrl).toBe('') - expect(body.fileInfo.name).toBe('video.mp4') - expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).not.toHaveBeenCalled() - }) - - it('issues a presigned URL bound to the workspace', async () => { - const res = await POST(makeRequest(validBody), params()) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.directUploadSupported).toBe(true) - expect(body.presignedUrl).toBe('https://s3/presigned') - expect(body.fileInfo.key).toBe(`workspace/${WS}/123-abc-video.mp4`) - expect(body.fileInfo.path).toContain('?context=workspace') - expect(body.fileInfo.path).toContain('s3') - expect(body.uploadHeaders).toEqual({ 'Content-Type': 'video/mp4' }) - - expect(mockGenerateWorkspaceFileKey).toHaveBeenCalledWith(WS, 'video.mp4') - expect(mockResolveStorageBillingContext).toHaveBeenCalledWith(WS) - expect(mockCheckStorageQuota).toHaveBeenCalledWith(STORAGE_CONTEXT, validBody.fileSize) - expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).toHaveBeenCalledWith( - expect.objectContaining({ - context: 'workspace', - userId: 'user-1', - customKey: `workspace/${WS}/123-abc-video.mp4`, - metadata: { workspaceId: WS }, - }) - ) - }) - - it('serves blob path when blob storage is configured', async () => { - mockUseBlobStorage.value = true - try { - const res = await POST(makeRequest(validBody), params()) - const body = await res.json() - expect(body.fileInfo.path).toContain('/blob/') - } finally { - mockUseBlobStorage.value = false - } - }) -}) diff --git a/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts b/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts deleted file mode 100644 index 905ad938d98..00000000000 --- a/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { workspacePresignedUploadContract } from '@/lib/api/contracts/workspace-files' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { - checkStorageQuotaForBillingContext, - resolveStorageBillingContext, -} from '@/lib/billing/storage' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getServeStoragePrefix } from '@/lib/uploads/config' -import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' -import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service' -import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('WorkspacePresignedAPI') - -/** - * POST /api/workspaces/[id]/files/presigned - * Returns a presigned PUT URL for a workspace-scoped object key. The client - * uploads the bytes directly to S3/Blob, then calls /files/register to - * insert metadata. - */ -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = session.user.id - - const parsed = await parseRequest(workspacePresignedUploadContract, request, context) - if (!parsed.success) return parsed.response - const { params, body } = parsed.data - const workspaceId = params.id - const { fileName, contentType, fileSize, folderId } = body - - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - logger.warn(`User ${userId} lacks write permission for ${workspaceId}`) - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - if (fileSize > MAX_WORKSPACE_FILE_SIZE) { - return NextResponse.json( - { error: `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` }, - { status: 413 } - ) - } - - let targetFolderId: string | null - try { - targetFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId) - } catch (error) { - return NextResponse.json( - { error: getErrorMessage(error, 'Invalid target folder') }, - { status: 400 } - ) - } - - if (!hasCloudStorage()) { - logger.info(`Local storage detected, signaling API fallback for ${fileName}`) - return NextResponse.json({ - fileName, - presignedUrl: '', - fileInfo: { path: '', key: '', name: fileName, size: fileSize, type: contentType }, - directUploadSupported: false, - }) - } - - const storageBillingContext = await resolveStorageBillingContext(workspaceId) - const quotaCheck = await checkStorageQuotaForBillingContext(storageBillingContext, fileSize) - if (!quotaCheck.allowed) { - return NextResponse.json( - { error: quotaCheck.error || 'Storage limit exceeded' }, - { status: 413 } - ) - } - - const key = generateWorkspaceFileKey(workspaceId, fileName) - const presigned = await generatePresignedUploadUrl({ - fileName, - contentType, - fileSize, - context: 'workspace', - userId, - customKey: key, - expirationSeconds: 3600, - metadata: { workspaceId, ...(targetFolderId ? { folderId: targetFolderId } : {}) }, - }) - - const finalPath = `/api/files/serve/${getServeStoragePrefix()}/${encodeURIComponent(key)}?context=workspace` - - logger.info(`Issued workspace presigned URL for ${fileName} -> ${key}`) - - return NextResponse.json({ - fileName, - presignedUrl: presigned.url, - fileInfo: { - path: finalPath, - key: presigned.key, - name: fileName, - size: fileSize, - type: contentType, - }, - uploadHeaders: presigned.uploadHeaders, - directUploadSupported: true, - }) - } -) diff --git a/apps/sim/app/api/workspaces/[id]/files/register/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/register/route.test.ts deleted file mode 100644 index cce56f7b8e8..00000000000 --- a/apps/sim/app/api/workspaces/[id]/files/register/route.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * @vitest-environment node - */ -import { - auditMock, - auditMockFns, - authMockFns, - permissionsMock, - permissionsMockFns, - posthogServerMock, - posthogServerMockFns, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockRegisterUploadedWorkspaceFile, mockParseWorkspaceFileKey, FileConflictErrorImpl } = - vi.hoisted(() => { - class FileConflictErrorImpl extends Error { - constructor(message: string) { - super(message) - this.name = 'FileConflictError' - } - } - return { - mockRegisterUploadedWorkspaceFile: vi.fn(), - mockParseWorkspaceFileKey: vi.fn(), - FileConflictErrorImpl, - } - }) - -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - registerUploadedWorkspaceFile: mockRegisterUploadedWorkspaceFile, - parseWorkspaceFileKey: mockParseWorkspaceFileKey, - FileConflictError: FileConflictErrorImpl, -})) - -vi.mock('@/lib/posthog/server', () => posthogServerMock) -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) -vi.mock('@sim/audit', () => auditMock) - -const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' -const VALID_KEY = `workspace/${WS}/123-abc-video.mp4` - -import { POST } from '@/app/api/workspaces/[id]/files/register/route' - -const params = (id = WS) => ({ params: Promise.resolve({ id }) }) - -const makeRequest = (body: unknown) => - new NextRequest(`http://localhost/api/workspaces/${WS}/files/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - -const validBody = { - key: VALID_KEY, - name: 'video.mp4', - contentType: 'video/mp4', -} - -describe('POST /api/workspaces/[id]/files/register', () => { - beforeEach(() => { - vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-1', name: 'User One', email: 'u@example.com' }, - }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mockParseWorkspaceFileKey.mockImplementation((key: string) => { - const match = key.match(/^workspace\/([^/]+)\//) - return match ? match[1] : null - }) - mockRegisterUploadedWorkspaceFile.mockResolvedValue({ - file: { - id: 'wf_123', - name: 'video.mp4', - size: 10 * 1024 * 1024, - type: 'video/mp4', - url: '/api/files/serve/...', - key: VALID_KEY, - context: 'workspace', - }, - created: true, - }) - }) - - it('returns 401 when unauthenticated', async () => { - authMockFns.mockGetSession.mockResolvedValueOnce(null) - const res = await POST(makeRequest(validBody), params()) - expect(res.status).toBe(401) - }) - - it('returns 403 when user lacks write permission', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read') - const res = await POST(makeRequest(validBody), params()) - expect(res.status).toBe(403) - }) - - it('rejects keys belonging to a different workspace', async () => { - const otherWsKey = `workspace/00000000-0000-0000-0000-000000000000/123-abc-video.mp4` - const res = await POST(makeRequest({ ...validBody, key: otherWsKey }), params()) - const body = await res.json() - expect(res.status).toBe(400) - expect(body.error).toContain('does not belong') - expect(mockRegisterUploadedWorkspaceFile).not.toHaveBeenCalled() - }) - - it('returns 400 for empty key/name', async () => { - const res = await POST(makeRequest({ ...validBody, key: '' }), params()) - expect(res.status).toBe(400) - }) - - it('returns 404 when storage object is missing', async () => { - mockRegisterUploadedWorkspaceFile.mockRejectedValueOnce( - new Error('Uploaded object not found in storage') - ) - const res = await POST(makeRequest(validBody), params()) - expect(res.status).toBe(404) - }) - - it('returns 409 on duplicate file conflict', async () => { - mockRegisterUploadedWorkspaceFile.mockRejectedValueOnce(new FileConflictErrorImpl('video.mp4')) - const res = await POST(makeRequest(validBody), params()) - const body = await res.json() - expect(res.status).toBe(409) - expect(body.isDuplicate).toBe(true) - }) - - it('skips audit + analytics on idempotent re-register (created=false)', async () => { - mockRegisterUploadedWorkspaceFile.mockResolvedValueOnce({ - file: { - id: 'wf_123', - name: 'video.mp4', - size: 10 * 1024 * 1024, - type: 'video/mp4', - url: '/api/files/serve/...', - key: VALID_KEY, - context: 'workspace', - }, - created: false, - }) - - const res = await POST(makeRequest(validBody), params()) - expect(res.status).toBe(200) - expect(posthogServerMockFns.mockCaptureServerEvent).not.toHaveBeenCalled() - expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() - }) - - it('finalizes upload, records audit and analytics', async () => { - const res = await POST(makeRequest(validBody), params()) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.success).toBe(true) - expect(body.file).toMatchObject({ id: 'wf_123', key: VALID_KEY }) - - expect(mockRegisterUploadedWorkspaceFile).toHaveBeenCalledWith({ - workspaceId: WS, - userId: 'user-1', - key: VALID_KEY, - originalName: 'video.mp4', - contentType: 'video/mp4', - }) - - expect(posthogServerMockFns.mockCaptureServerEvent).toHaveBeenCalledWith( - 'user-1', - 'file_uploaded', - expect.objectContaining({ workspace_id: WS, file_type: 'video/mp4' }), - expect.any(Object) - ) - expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( - expect.objectContaining({ - actorId: 'user-1', - workspaceId: WS, - }) - ) - }) -}) diff --git a/apps/sim/app/api/workspaces/[id]/files/register/route.ts b/apps/sim/app/api/workspaces/[id]/files/register/route.ts deleted file mode 100644 index 4ed0b90c285..00000000000 --- a/apps/sim/app/api/workspaces/[id]/files/register/route.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { registerWorkspaceFileContract } from '@/lib/api/contracts/workspace-files' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' -import { - FileConflictError, - parseWorkspaceFileKey, - registerUploadedWorkspaceFile, -} from '@/lib/uploads/contexts/workspace' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('WorkspaceRegisterAPI') - -/** - * POST /api/workspaces/[id]/files/register - * Finalize a direct-to-storage upload by inserting metadata, updating quota, - * and recording an audit log. Validates the storage key belongs to the - * caller's workspace to prevent cross-tenant key smuggling. - */ -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = session.user.id - - const parsed = await parseRequest(registerWorkspaceFileContract, request, context) - if (!parsed.success) return parsed.response - const { params, body } = parsed.data - const workspaceId = params.id - const { key, name, contentType, folderId } = body - - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - logger.warn(`User ${userId} lacks write permission for ${workspaceId}`) - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - if (parseWorkspaceFileKey(key) !== workspaceId) { - logger.warn(`Key ${key} does not belong to workspace ${workspaceId}`) - return NextResponse.json( - { error: 'Storage key does not belong to this workspace' }, - { status: 400 } - ) - } - - try { - const { file: userFile, created } = await registerUploadedWorkspaceFile({ - workspaceId, - userId, - key, - originalName: name, - contentType, - folderId, - }) - - if (created) { - logger.info(`Registered direct upload ${name} -> ${key}`) - - await notifyWorkspaceFilesChanged(workspaceId) - - captureServerEvent( - userId, - 'file_uploaded', - { workspace_id: workspaceId, file_type: contentType }, - { groups: { workspace: workspaceId } } - ) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_UPLOADED, - resourceType: AuditResourceType.FILE, - resourceId: userFile.id, - resourceName: name, - description: `Uploaded file "${name}"`, - metadata: { fileSize: userFile.size, fileType: contentType }, - request, - }) - } else { - logger.info(`Idempotent re-register for existing upload ${name} -> ${key}`) - } - - return NextResponse.json({ success: true, file: userFile }) - } catch (error) { - logger.error('Failed to register workspace file:', error) - - const errorMessage = getErrorMessage(error, 'Failed to register file') - const isDuplicate = - error instanceof FileConflictError || errorMessage.includes('already exists') - const isMissing = errorMessage.includes('not found in storage') - - const status = isDuplicate ? 409 : isMissing ? 404 : 500 - return NextResponse.json({ success: false, error: errorMessage, isDuplicate }, { status }) - } - } -) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/route.test.ts index 9d71894ecfa..7ce4f9f1a4c 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.test.ts @@ -1,143 +1,253 @@ /** - * Tests for the workspace files upload route's bounded multipart read. - * * @vitest-environment node */ -import { authMockFns, permissionsMock, permissionsMockFns, posthogServerMock } from '@sim/testing' +import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockUploadWorkspaceFile, mockGetWorkspaceShares, mockRecordAudit } = vi.hoisted(() => ({ - mockUploadWorkspaceFile: vi.fn(), +const { + mockGetUserEntityPermissions, + mockGetWorkspaceShares, + mockListWorkspaceFiles, + mockPerformCreateWorkspaceFile, +} = vi.hoisted(() => ({ + mockGetUserEntityPermissions: vi.fn(), mockGetWorkspaceShares: vi.fn(), - mockRecordAudit: vi.fn(), + mockListWorkspaceFiles: vi.fn(), + mockPerformCreateWorkspaceFile: vi.fn(), })) -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - uploadWorkspaceFile: mockUploadWorkspaceFile, - FileConflictError: class FileConflictError extends Error {}, +vi.mock('@/lib/public-shares/share-manager', () => ({ + getWorkspaceShares: mockGetWorkspaceShares, })) -vi.mock('@/lib/uploads/shared/types', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - MAX_WORKSPACE_FORMDATA_FILE_SIZE: 1024, - } -}) +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + listWorkspaceFiles: mockListWorkspaceFiles, +})) -vi.mock('@/lib/public-shares/share-manager', () => ({ - getWorkspaceShares: mockGetWorkspaceShares, +vi.mock('@/lib/workspace-files/orchestration', () => ({ + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, + performCreateWorkspaceFile: mockPerformCreateWorkspaceFile, })) -vi.mock('@/lib/posthog/server', () => posthogServerMock) -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) vi.mock('@/app/api/workflows/utils', () => ({ verifyWorkspaceMembership: vi.fn().mockResolvedValue('write'), })) -vi.mock('@sim/audit', () => ({ - recordAudit: mockRecordAudit, - AuditAction: { FILE_UPLOADED: 'file_uploaded' }, - AuditResourceType: { FILE: 'file' }, -})) - -const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' import { POST } from '@/app/api/workspaces/[id]/files/route' -const routeContext = { params: Promise.resolve({ id: WS }) } - -function buildFormData(file: File): FormData { - const formData = new FormData() - formData.append('file', file) - return formData +const WORKSPACE_ID = '7727ef3f-8cf6-4686-b063-2bb006a10785' +const USER = { id: 'user-1', name: 'Test User', email: 'test@sim.ai' } +const CREATED_FILE = { + id: 'wf_created', + workspaceId: WORKSPACE_ID, + name: 'untitled.md', + key: `workspace/${WORKSPACE_ID}/untitled.md`, + path: '/api/files/serve/untitled.md?context=workspace', + size: 0, + type: 'text/markdown', + uploadedBy: USER.id, + folderId: null, + folderPath: null, + deletedAt: null, + uploadedAt: new Date('2026-08-04T00:00:00.000Z'), + updatedAt: new Date('2026-08-04T00:00:00.000Z'), } -/** - * Builds a pull-based stream that emits fixed-size chunks on demand, so the - * size-capped reader's `reader.cancel()` simply stops future `pull` calls - * instead of racing an external (e.g. undici FormData) chunk producer. - */ -function makeChunkedOverLimitBody( - chunkBytes: number, - chunkCount: number -): ReadableStream { - let emitted = 0 - return new ReadableStream({ - pull(controller) { - if (emitted >= chunkCount) { - controller.close() - return - } - emitted++ - controller.enqueue(new Uint8Array(chunkBytes)) - }, +const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) } + +function createRequest(body: unknown): NextRequest { + return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: typeof body === 'string' ? body : JSON.stringify(body), }) } -describe('workspace files upload route', () => { +describe('POST /api/workspaces/[id]/files', () => { beforeEach(() => { vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + authMockFns.mockGetSession.mockResolvedValue({ user: USER }) + mockGetUserEntityPermissions.mockResolvedValue('write') mockGetWorkspaceShares.mockResolvedValue(new Map()) - mockUploadWorkspaceFile.mockResolvedValue({ - id: 'file-1', - name: 'file.txt', - url: 'https://example.com/file.txt', - size: 11, - type: 'text/plain', - }) + mockListWorkspaceFiles.mockResolvedValue([]) + mockPerformCreateWorkspaceFile.mockResolvedValue({ success: true, file: CREATED_FILE }) }) - it('rejects a declared content-length above the limit before reading the body', async () => { - const formData = buildFormData(new File(['x'.repeat(10)], 'file.txt', { type: 'text/plain' })) - const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, { - method: 'POST', - headers: { 'content-length': String(10 * 1024 * 1024) }, - body: formData, - }) + it('authenticates before parsing an invalid request body', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + + const response = await POST(createRequest('{not-json'), routeContext) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }) + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + }) - const response = await POST(req, routeContext) - const data = await response.json() + it('authorizes the workspace before parsing the request body', async () => { + mockGetUserEntityPermissions.mockResolvedValue('read') - expect(response.status).toBe(413) - expect(data.error).toContain('exceeds maximum size') - expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + const response = await POST(createRequest({ content: 'missing a name' }), routeContext) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) + expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() }) - it('rejects a chunked body without content-length once the streamed size trips the cap', async () => { - const body = makeChunkedOverLimitBody(64 * 1024, 32) - const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, { - method: 'POST', - body, - // @ts-expect-error - duplex is required by undici for streamed bodies but missing from NextRequestInit types - duplex: 'half', + it('rejects an invalid body after workspace authorization', async () => { + const response = await POST(createRequest({ content: 'missing a name' }), routeContext) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error).toBe('Validation error') + expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + }) + + it.each(['read', null])( + 'requires write or admin permission (%s is rejected)', + async (permission) => { + mockGetUserEntityPermissions.mockResolvedValue(permission) + + const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + } + ) + + it.each(['write', 'admin'])( + 'creates an empty file with defaults for %s users', + async (permission) => { + mockGetUserEntityPermissions.mockResolvedValue(permission) + const request = createRequest({ name: 'untitled.md' }) + + const response = await POST(request, routeContext) + const body = await response.json() + + expect(response.status).toBe(201) + expect(body).toMatchObject({ success: true, file: { id: CREATED_FILE.id } }) + expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledTimes(1) + const params = mockPerformCreateWorkspaceFile.mock.calls[0][0] + expect(params).toMatchObject({ + workspaceId: WORKSPACE_ID, + userId: USER.id, + actorName: USER.name, + actorEmail: USER.email, + name: 'untitled.md', + contentType: 'text/markdown', + exactName: false, + }) + expect(params.folderId).toBeUndefined() + expect(params.content).toEqual(Buffer.alloc(0)) + expect(params.request).toBe(request) + } + ) + + it('decodes initialized base64 content and preserves folder and content type', async () => { + const content = Buffer.from([0, 1, 2, 255]) + const request = createRequest({ + name: 'data.bin', + contentType: 'application/octet-stream', + folderId: 'folder-1', + content: content.toString('base64'), + encoding: 'base64', + }) + mockPerformCreateWorkspaceFile.mockResolvedValue({ + success: true, + file: { + ...CREATED_FILE, + name: 'data.bin', + type: 'application/octet-stream', + size: content.length, + folderId: 'folder-1', + }, }) - expect(req.headers.get('content-length')).toBeNull() - const response = await POST(req, routeContext) - const data = await response.json() + const response = await POST(request, routeContext) + + expect(response.status).toBe(201) + expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WORKSPACE_ID, + name: 'data.bin', + contentType: 'application/octet-stream', + folderId: 'folder-1', + content, + exactName: false, + }) + ) + }) + + it('rejects malformed base64 after authorization and before orchestration', async () => { + const response = await POST( + createRequest({ name: 'data.bin', content: 'not-base64!', encoding: 'base64' }), + routeContext + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'Validation error' }) + expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) + expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + }) + + it('accepts empty base64 as a zero-byte file', async () => { + const response = await POST( + createRequest({ name: 'empty.bin', content: '', encoding: 'base64' }), + routeContext + ) - expect(response.status).toBe(413) - expect(data.error).toContain('exceeds maximum size') - expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + expect(response.status).toBe(201) + expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ content: Buffer.alloc(0) }) + ) }) - it('uploads a normal, well-under-limit file successfully', async () => { - const file = new File(['hello world'], 'file.txt', { type: 'text/plain' }) - const formData = buildFormData(file) - const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, { - method: 'POST', - headers: { 'content-length': '512' }, - body: formData, + it.each([ + ['validation', 400, 'Invalid file name'], + ['not_found', 404, 'Target folder not found'], + ['conflict', 409, 'A file with this name already exists'], + ['payload_too_large', 413, 'File size exceeds 50MB limit'], + ] as const)('maps a %s orchestration failure to %i', async (errorCode, expectedStatus, error) => { + mockPerformCreateWorkspaceFile.mockResolvedValue({ success: false, error, errorCode }) + + const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) + + expect(response.status).toBe(expectedStatus) + await expect(response.json()).resolves.toEqual({ success: false, error }) + }) + + it('does not expose an internal orchestration error', async () => { + mockPerformCreateWorkspaceFile.mockResolvedValue({ + success: false, + error: 'update workspace_files set ... failed', + errorCode: 'internal', + }) + + const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + success: false, + error: 'Failed to create file', }) + }) - const response = await POST(req, routeContext) - const data = await response.json() + it('maps an unexpected throw to a 500 response', async () => { + mockPerformCreateWorkspaceFile.mockRejectedValue(new Error('storage unavailable')) - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(1) + const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + success: false, + error: 'Failed to create file', + }) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index b5d1d4f1fdc..9a370fb4f94 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -1,28 +1,26 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { + createWorkspaceFileContract, listWorkspaceFilesQuerySchema, workspaceFilesParamsSchema, } from '@/lib/api/contracts/workspace-files' -import { getValidationErrorMessage } from '@/lib/api/server' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' import { - isPayloadSizeLimitError, - MAX_MULTIPART_OVERHEAD_BYTES, - readFormDataWithLimit, -} from '@/lib/core/utils/stream-limits' + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' import { getWorkspaceShares } from '@/lib/public-shares/share-manager' +import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { - FileConflictError, - listWorkspaceFiles, - uploadWorkspaceFile, -} from '@/lib/uploads/contexts/workspace' -import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types' + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + performCreateWorkspaceFile, +} from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' @@ -101,19 +99,11 @@ export const GET = withRouteHandler( /** * POST /api/workspaces/[id]/files - * Upload a new file to workspace storage (requires write permission) + * Create an authored workspace file (requires write permission) */ export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const paramsResult = workspaceFilesParamsSchema.safeParse(await params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId } = paramsResult.data try { const session = await getSession() @@ -121,7 +111,15 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // Check workspace permissions (requires write) + const paramsResult = workspaceFilesParamsSchema.safeParse(await context.params) + if (!paramsResult.success) { + return NextResponse.json( + { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, + { status: 400 } + ) + } + const { id: workspaceId } = paramsResult.data + const userPermission = await getUserEntityPermissions( session.user.id, 'workspace', @@ -134,93 +132,45 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - let formData: FormData - try { - formData = await readFormDataWithLimit(request, { - maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, - label: 'workspace file upload body', - }) - } catch (error) { - if (isPayloadSizeLimitError(error)) { - return NextResponse.json({ error: error.message }, { status: 413 }) - } - return NextResponse.json( - { error: 'Request body must be valid multipart form data' }, - { status: 400 } - ) - } - const rawFile = formData.get('file') - const rawFolderId = formData.get('folderId') - const folderId = - typeof rawFolderId === 'string' && rawFolderId.length > 0 ? rawFolderId : null - - if (!rawFile || !(rawFile instanceof File)) { - return NextResponse.json({ error: 'No file provided' }, { status: 400 }) - } - - const fileName = rawFile.name || 'untitled.md' - - if (rawFile.size > MAX_WORKSPACE_FORMDATA_FILE_SIZE) { - return NextResponse.json( - { - error: `File size exceeds maximum of ${MAX_WORKSPACE_FORMDATA_FILE_SIZE} bytes (${(rawFile.size / (1024 * 1024)).toFixed(2)}MB)`, - }, - { status: 413 } - ) - } - - const buffer = Buffer.from(await rawFile.arrayBuffer()) - - const userFile = await uploadWorkspaceFile( - workspaceId, - session.user.id, - buffer, - fileName, - rawFile.type || 'application/octet-stream', - { folderId } - ) - - logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`) - - captureServerEvent( - session.user.id, - 'file_uploaded', - { workspace_id: workspaceId, file_type: rawFile.type || 'application/octet-stream' }, - { groups: { workspace: workspaceId } } - ) + const parsed = await parseRequest(createWorkspaceFileContract, request, context, { + maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + }) + if (!parsed.success) return parsed.response + const { name, contentType, folderId, content, encoding } = parsed.data.body - recordAudit({ + const result = await performCreateWorkspaceFile({ workspaceId, - actorId: session.user.id, + userId: session.user.id, + name, + contentType: contentType ?? getMimeTypeFromExtension(getFileExtension(name)), + folderId, + content: Buffer.from(content, encoding), + exactName: false, actorName: session.user.name, actorEmail: session.user.email, - action: AuditAction.FILE_UPLOADED, - resourceType: AuditResourceType.FILE, - resourceId: userFile.id, - resourceName: fileName, - description: `Uploaded file "${fileName}"`, - metadata: { fileSize: rawFile.size, fileType: rawFile.type || 'application/octet-stream' }, request, }) + if (!result.success || !result.file) { + return NextResponse.json( + { + success: false, + error: messageForOrchestrationError(result, 'Failed to create file'), + }, + { status: statusForOrchestrationError(result.errorCode) } + ) + } - return NextResponse.json({ - success: true, - file: userFile, - }) + logger.info(`[${requestId}] Created workspace file: ${result.file.name}`) + return NextResponse.json({ success: true, file: result.file }, { status: 201 }) } catch (error) { - logger.error(`[${requestId}] Error uploading workspace file:`, error) - - const errorMessage = getErrorMessage(error, 'Failed to upload file') - const isDuplicate = - error instanceof FileConflictError || errorMessage.includes('already exists') + logger.error(`[${requestId}] Error creating workspace file:`, error) return NextResponse.json( { success: false, - error: errorMessage, - isDuplicate, + error: 'Failed to create file', }, - { status: isDuplicate ? 409 : 500 } + { status: 500 } ) } } diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 736ec48089e..d772c13c800 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -112,6 +112,7 @@ import { type WorkspaceFileFolderApi, } from '@/hooks/queries/workspace-file-folders' import { + useCreateWorkspaceFile, useDeleteWorkspaceFile, useRenameWorkspaceFile, useUploadWorkspaceFile, @@ -256,6 +257,7 @@ export function Files() { return map }, [members]) const uploadFile = useUploadWorkspaceFile() + const createWorkspaceFile = useCreateWorkspaceFile() const notifyLimit = useLimitUpgradeToast() const deleteFile = useDeleteWorkspaceFile() const renameFile = useRenameWorkspaceFile() @@ -1313,15 +1315,13 @@ export function Files() { const name = uniqueMarkdownName(DEFAULT_UNTITLED_NAME, existingNames) const mimeType = getMimeTypeFromExtension('md') - const blob = new Blob([''], { type: mimeType }) - const file = new File([blob], name, { type: mimeType }) - const result = await uploadFile.mutateAsync({ + const result = await createWorkspaceFile.mutateAsync({ workspaceId, - file, - folderId: currentFolderId, - skipToast: true, + name, + contentType: mimeType, + folderId: currentFolderId ?? undefined, }) - const fileId = result.file?.id + const fileId = result.file.id if (fileId) { justCreatedFileIdRef.current = fileId const params = new URLSearchParams({ new: '1' }) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx index cfdc62d32af..ba29e66597d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx @@ -15,6 +15,10 @@ import { import { createLogger } from '@sim/logger' import { RotateCcw, X } from 'lucide-react' import { useParams } from 'next/navigation' +import { + assertMultiFileUploadAdmission, + MultiFileUploadAdmissionError, +} from '@/lib/uploads/client/admission' import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils' import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload' @@ -77,11 +81,11 @@ export function AddDocumentsModal({ } const processFiles = (selectedFiles: File[]) => { - setFileError(null) - if (!selectedFiles || selectedFiles.length === 0) return try { + assertMultiFileUploadAdmission(selectedFiles, { existingFiles: files }) + setFileError(null) const newFiles: File[] = [] let hasError = false @@ -100,6 +104,10 @@ export function AddDocumentsModal({ setFiles((prev) => [...prev, ...newFiles]) } } catch (error) { + if (error instanceof MultiFileUploadAdmissionError) { + setFileError(error.message) + return + } logger.error('Error processing files:', error) setFileError('An error occurred while processing files. Please try again.') } @@ -156,7 +164,7 @@ export function AddDocumentsModal({ accept={ACCEPT_ATTRIBUTE} multiple onChange={processFiles} - description='PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSONL (max 100MB each)' + description='PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSONL (max 20 files, 100MB each, 500MB total)' error={fileError} /> diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx index 5364d754044..88c4b5c8603 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx @@ -27,6 +27,10 @@ import { type FieldErrors, useForm } from 'react-hook-form' import { z } from 'zod' import type { StrategyOptions } from '@/lib/chunkers/types' import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants' +import { + assertMultiFileUploadAdmission, + MultiFileUploadAdmissionError, +} from '@/lib/uploads/client/admission' import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils' import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload' @@ -202,11 +206,11 @@ export const CreateBaseModal = memo(function CreateBaseModal({ }, [open, reset]) const processFiles = (selectedFiles: File[]) => { - setFileError(null) - if (!selectedFiles || selectedFiles.length === 0) return try { + assertMultiFileUploadAdmission(selectedFiles, { existingFiles: files }) + setFileError(null) const newFiles: File[] = [] let hasError = false @@ -225,6 +229,10 @@ export const CreateBaseModal = memo(function CreateBaseModal({ setFiles((prev) => [...prev, ...newFiles]) } } catch (error) { + if (error instanceof MultiFileUploadAdmissionError) { + setFileError(error.message) + return + } logger.error('Error processing files:', error) setFileError('An error occurred while processing files. Please try again.') } @@ -474,7 +482,7 @@ export const CreateBaseModal = memo(function CreateBaseModal({ accept={ACCEPT_ATTRIBUTE} multiple onChange={processFiles} - description='PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSONL (max 100MB each)' + description='PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSONL (max 20 files, 100MB each, 500MB total)' error={fileError} /> diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.test.tsx new file mode 100644 index 00000000000..c14cc716e7c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.test.tsx @@ -0,0 +1,88 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { mockInvalidateQueries, mockUploadKnowledgeDocumentSession } = vi.hoisted(() => ({ + mockInvalidateQueries: vi.fn(), + mockUploadKnowledgeDocumentSession: vi.fn(), +})) + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }), +})) + +vi.mock('@/lib/uploads/client/session-upload', () => ({ + uploadKnowledgeDocumentSession: mockUploadKnowledgeDocumentSession, +})) + +import { MULTI_FILE_UPLOAD_MAX_FILE_BYTES } from '@/lib/uploads/client/admission' +import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload' + +interface HookHarness { + result: () => ReturnType + unmount: () => void +} + +function renderKnowledgeUploadHook(onError: ReturnType): HookHarness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root: Root = createRoot(document.createElement('div')) + let latest: ReturnType + + function Probe() { + latest = useKnowledgeUpload({ workspaceId: 'workspace-1', onError }) + return null + } + + act(() => root.render()) + return { + result: () => latest, + unmount: () => act(() => root.unmount()), + } +} + +function sizedFile(name: string, size: number): File { + const file = new File([], name, { type: 'application/octet-stream' }) + Object.defineProperty(file, 'size', { value: size }) + return file +} + +describe('useKnowledgeUpload admission', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it('rejects aggregate bytes before allocating upload progress or sessions', async () => { + const onError = vi.fn() + const { result, unmount } = renderKnowledgeUploadHook(onError) + const files = Array.from({ length: 6 }, (_, index) => + sizedFile(`file-${index}.bin`, MULTI_FILE_UPLOAD_MAX_FILE_BYTES) + ) + + await act(async () => { + await expect(result().uploadFiles(files, 'kb-1')).rejects.toMatchObject({ + code: 'UPLOAD_TOTAL_SIZE_EXCEEDED', + }) + }) + + expect(mockUploadKnowledgeDocumentSession).not.toHaveBeenCalled() + expect(mockInvalidateQueries).not.toHaveBeenCalled() + expect(result().isUploading).toBe(false) + expect(result().uploadProgress).toEqual({ + stage: 'idle', + filesCompleted: 0, + totalFiles: 0, + }) + expect(result().uploadError).toMatchObject({ + code: 'UPLOAD_TOTAL_SIZE_EXCEEDED', + message: 'Select files totaling 500 MiB or less.', + }) + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'UPLOAD_TOTAL_SIZE_EXCEEDED' }) + ) + + unmount() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts index d4270b5ae34..c9cd91416ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts @@ -4,11 +4,12 @@ import { getErrorMessage } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' import type { V2KnowledgeDocumentSummary } from '@/lib/api/contracts/v2/knowledge' import { - runWithConcurrency, - type UploadProgressEvent, - WHOLE_FILE_PARALLEL_UPLOADS, -} from '@/lib/uploads/client/direct-upload' + assertMultiFileUploadAdmission, + MultiFileUploadAdmissionError, +} from '@/lib/uploads/client/admission' +import { runWithConcurrency, WHOLE_FILE_PARALLEL_UPLOADS } from '@/lib/uploads/client/concurrency' import { uploadKnowledgeDocumentSession } from '@/lib/uploads/client/session-upload' +import type { UploadProgressEvent } from '@/lib/uploads/client/types' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' const logger = createLogger('KnowledgeUpload') @@ -69,13 +70,13 @@ class KnowledgeUploadError extends Error { export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { const queryClient = useQueryClient() - const [isUploading, setIsUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState({ stage: 'idle', filesCompleted: 0, totalFiles: 0, }) const [uploadError, setUploadError] = useState(null) + const isUploading = uploadProgress.stage !== 'idle' const updateFileStatus = (fileIndex: number, patch: Partial) => { setUploadProgress((prev) => ({ @@ -195,7 +196,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { } try { - setIsUploading(true) + assertMultiFileUploadAdmission(files) setUploadError(null) setUploadProgress({ stage: 'uploading', filesCompleted: 0, totalFiles: files.length }) @@ -217,15 +218,16 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) { const error: UploadError = err instanceof KnowledgeUploadError ? { message: err.message, code: err.code, details: err.details, timestamp: Date.now() } - : err instanceof Error - ? { message: err.message, timestamp: Date.now() } - : { message: 'Unknown error occurred during upload', timestamp: Date.now() } + : err instanceof MultiFileUploadAdmissionError + ? { message: err.message, code: err.code, timestamp: Date.now() } + : err instanceof Error + ? { message: err.message, timestamp: Date.now() } + : { message: 'Unknown error occurred during upload', timestamp: Date.now() } setUploadError(error) options.onError?.(error) throw err } finally { - setIsUploading(false) setUploadProgress({ stage: 'idle', filesCompleted: 0, totalFiles: 0 }) } } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts index 09fdbd75eff..879b875d637 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload.ts @@ -1,8 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback' -import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' +import { uploadInternalFileSession } from '@/lib/uploads/client/session-upload' const logger = createLogger('ProfilePictureUpload') const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB @@ -66,28 +65,25 @@ export function useProfilePictureUpload({ const uploadFileToServer = useCallback( async (file: File): Promise => { - const presignedEndpoint = - context === 'workspace-logos' && workspaceId - ? `/api/files/presigned?type=workspace-logos&workspaceId=${encodeURIComponent(workspaceId)}` - : `/api/files/presigned?type=${context}` - - try { - const result = await runUploadStrategy({ + if (context === 'workspace-logos') { + if (!workspaceId) { + throw new Error('workspaceId is required for workspace logo upload') + } + const result = await uploadInternalFileSession({ + purpose: 'workspace_logo', + workspaceId, file, - workspaceId: workspaceId ?? '', - context, - presignedEndpoint, }) logger.info(`${context} uploaded successfully: ${result.path}`) return result.path - } catch (error) { - if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') { - const { path } = await uploadViaApiFallback(file, context, workspaceId) - logger.info(`${context} uploaded successfully via API fallback: ${path}`) - return path - } - throw error } + + const result = await uploadInternalFileSession({ + purpose: 'profile_picture', + file, + }) + logger.info(`${context} uploaded successfully: ${result.path}`) + return result.path }, [context, workspaceId] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.test.tsx new file mode 100644 index 00000000000..9d1a3d26d63 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.test.tsx @@ -0,0 +1,118 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockToastError, mockUploadInternalFileSession } = vi.hoisted(() => ({ + mockToastError: vi.fn(), + mockUploadInternalFileSession: vi.fn(), +})) + +vi.mock('@sim/emcn', () => ({ toast: { error: mockToastError } })) + +vi.mock('@/lib/uploads/client/session-upload', () => ({ + uploadInternalFileSession: mockUploadInternalFileSession, +})) + +import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' +import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' + +interface HookHarness { + result: () => ReturnType + unmount: () => void +} + +function renderFileAttachmentsHook(): HookHarness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root: Root = createRoot(document.createElement('div')) + let latest: ReturnType + + function Probe() { + latest = useFileAttachments({ userId: 'user-1', workspaceId: 'workspace-1' }) + return null + } + + act(() => root.render()) + return { + result: () => latest, + unmount: () => act(() => root.unmount()), + } +} + +function sizedFile(name: string, size: number): File { + const file = new File([], name, { type: 'image/png' }) + Object.defineProperty(file, 'size', { value: size }) + return file +} + +function asFileList(files: File[]): FileList { + return Object.assign(files, { item: (index: number) => files[index] ?? null }) +} + +describe('useFileAttachments admission', () => { + const originalCreateObjectUrl = Object.getOwnPropertyDescriptor(URL, 'createObjectURL') + const createObjectUrl = vi.fn() + + beforeEach(() => { + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: createObjectUrl, + }) + }) + + afterEach(() => { + vi.clearAllMocks() + if (originalCreateObjectUrl) { + Object.defineProperty(URL, 'createObjectURL', originalCreateObjectUrl) + } else { + Reflect.deleteProperty(URL, 'createObjectURL') + } + }) + + it('rejects aggregate bytes before previews, placeholders, or sessions are allocated', async () => { + const { result, unmount } = renderFileAttachmentsHook() + const files = asFileList([ + ...Array.from({ length: 5 }, (_, index) => + sizedFile(`large-image-${index}.png`, MAX_WORKSPACE_FILE_SIZE) + ), + sizedFile('extra-image.png', 1), + ]) + + await act(async () => { + await result().processFiles(files) + }) + + expect(mockToastError).toHaveBeenCalledWith("Couldn't add files", { + description: 'Select files totaling 25 GiB or less.', + }) + expect(createObjectUrl).not.toHaveBeenCalled() + expect(mockUploadInternalFileSession).not.toHaveBeenCalled() + expect(result().attachedFiles).toEqual([]) + + unmount() + }) + + it('starts a mothership session for a file above the old FormData limit', async () => { + mockUploadInternalFileSession.mockResolvedValue({ + path: '/api/files/serve/s3/mothership%2Flarge-image.png?context=mothership', + key: 'mothership/large-image.png', + }) + const { result, unmount } = renderFileAttachmentsHook() + const file = sizedFile('large-image.png', 101 * 1024 * 1024) + + await act(async () => { + await result().processFiles(asFileList([file])) + }) + + expect(mockUploadInternalFileSession).toHaveBeenCalledWith( + expect.objectContaining({ purpose: 'mothership_attachment', file }) + ) + expect(result().attachedFiles).toEqual([ + expect.objectContaining({ name: file.name, uploading: false }), + ]) + + unmount() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts index 4c40839d27b..ef68d2bb113 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts @@ -5,8 +5,10 @@ import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback' -import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' +import { assertMultiFileUploadAdmission } from '@/lib/uploads/client/admission' +import { runWithConcurrency, WHOLE_FILE_PARALLEL_UPLOADS } from '@/lib/uploads/client/concurrency' +import { uploadInternalFileSession } from '@/lib/uploads/client/session-upload' +import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' import { resolveFileType } from '@/lib/uploads/utils/file-utils' const logger = createLogger('useFileAttachments') @@ -64,16 +66,26 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { const { userId, workspaceId, disabled, isLoading } = props const [attachedFiles, setAttachedFiles] = useState([]) - const [isDragging, setIsDragging] = useState(false) const [dragCounter, setDragCounter] = useState(0) + const isDragging = dragCounter > 0 const fileInputRef = useRef(null) + const attachedFilesRef = useRef([]) + const uploadControllersRef = useRef(new Map()) + + const updateAttachedFiles = useCallback((update: (files: AttachedFile[]) => AttachedFile[]) => { + const next = update(attachedFilesRef.current) + attachedFilesRef.current = next + setAttachedFiles(next) + }, []) /** * Cleanup preview URLs on unmount */ useEffect(() => { return () => { - attachedFiles.forEach((f) => { + for (const controller of uploadControllersRef.current.values()) controller.abort() + uploadControllersRef.current.clear() + attachedFilesRef.current.forEach((f) => { if (f.previewUrl) { URL.revokeObjectURL(f.previewUrl) } @@ -122,8 +134,18 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { return } + if (fileList.length === 0) return + try { + assertMultiFileUploadAdmission(fileList, { + existingFiles: attachedFilesRef.current, + maxFileBytes: MAX_WORKSPACE_FILE_SIZE, + }) + } catch (error) { + toast.error("Couldn't add files", { description: toError(error).message }) + return + } + const files = Array.from(fileList) - if (files.length === 0) return const placeholders: AttachedFile[] = files.map((file) => ({ id: generateId(), @@ -137,56 +159,48 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { ? URL.createObjectURL(file) : undefined, })) + const controllers = placeholders.map(() => new AbortController()) + placeholders.forEach((placeholder, index) => { + uploadControllersRef.current.set(placeholder.id, controllers[index]) + }) - setAttachedFiles((prev) => [...prev, ...placeholders]) - - const presignedEndpoint = `/api/files/presigned?type=mothership&workspaceId=${encodeURIComponent(workspaceId)}` - - await Promise.all( - files.map(async (file, i) => { - const placeholder = placeholders[i] - try { - let result: { path: string; key: string } - try { - result = await runUploadStrategy({ - file, - workspaceId, - context: 'mothership', - presignedEndpoint, - }) - } catch (error) { - if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') { - const fallback = await uploadViaApiFallback(file, 'mothership', workspaceId) - if (!fallback.key) { - throw new Error('Invalid upload response: missing key') - } - result = { path: fallback.path, key: fallback.key } - } else { - throw error - } - } - - logger.info(`File uploaded successfully: ${result.path}`) - - setAttachedFiles((prev) => - prev.map((f) => - f.id === placeholder.id - ? { ...f, path: result.path, key: result.key, uploading: false } - : f - ) + updateAttachedFiles((current) => [...current, ...placeholders]) + + await runWithConcurrency(files, WHOLE_FILE_PARALLEL_UPLOADS, async (file, i) => { + const placeholder = placeholders[i] + const controller = controllers[i] + try { + const result = await uploadInternalFileSession({ + purpose: 'mothership_attachment', + file, + workspaceId, + signal: controller.signal, + }) + + logger.info(`File uploaded successfully: ${result.path}`) + + updateAttachedFiles((current) => + current.map((f) => + f.id === placeholder.id + ? { ...f, path: result.path, key: result.key, uploading: false } + : f ) - } catch (error) { + ) + } catch (error) { + if (!controller.signal.aborted) { logger.error(`File upload failed: ${error}`) toast.error(`Couldn't upload "${file.name}"`, { description: toError(error).message, }) - if (placeholder.previewUrl) URL.revokeObjectURL(placeholder.previewUrl) - setAttachedFiles((prev) => prev.filter((f) => f.id !== placeholder.id)) } - }) - ) + if (placeholder.previewUrl) URL.revokeObjectURL(placeholder.previewUrl) + updateAttachedFiles((current) => current.filter((file) => file.id !== placeholder.id)) + } finally { + uploadControllersRef.current.delete(placeholder.id) + } + }) }, - [userId, workspaceId] + [userId, workspaceId, updateAttachedFiles] ) /** @@ -222,13 +236,15 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { */ const removeFile = useCallback( (fileId: string) => { - const file = attachedFiles.find((f) => f.id === fileId) + uploadControllersRef.current.get(fileId)?.abort() + uploadControllersRef.current.delete(fileId) + const file = attachedFilesRef.current.find((f) => f.id === fileId) if (file?.previewUrl) { URL.revokeObjectURL(file.previewUrl) } - setAttachedFiles((prev) => prev.filter((f) => f.id !== fileId)) + updateAttachedFiles((current) => current.filter((file) => file.id !== fileId)) }, - [attachedFiles] + [updateAttachedFiles] ) /** @@ -249,13 +265,7 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault() e.stopPropagation() - setDragCounter((prev) => { - const newCount = prev + 1 - if (newCount === 1) { - setIsDragging(true) - } - return newCount - }) + setDragCounter((prev) => prev + 1) }, []) /** @@ -264,13 +274,7 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault() e.stopPropagation() - setDragCounter((prev) => { - const newCount = prev - 1 - if (newCount === 0) { - setIsDragging(false) - } - return newCount - }) + setDragCounter((prev) => Math.max(0, prev - 1)) }, []) /** @@ -289,7 +293,6 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { async (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() - setIsDragging(false) setDragCounter(0) if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { @@ -303,26 +306,33 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { * Clears all attached files and cleanup preview URLs */ const clearAttachedFiles = useCallback(() => { - attachedFiles.forEach((f) => { + for (const controller of uploadControllersRef.current.values()) controller.abort() + uploadControllersRef.current.clear() + attachedFilesRef.current.forEach((f) => { if (f.previewUrl) { URL.revokeObjectURL(f.previewUrl) } }) - setAttachedFiles([]) - }, [attachedFiles]) + updateAttachedFiles(() => []) + }, [updateAttachedFiles]) /** * Replaces the current attached files with a given set. * Cleans up preview URLs from the prior set before replacing. */ - const restoreAttachedFiles = useCallback((files: AttachedFile[]) => { - setAttachedFiles((prev) => { - prev.forEach((f) => { - if (f.previewUrl) URL.revokeObjectURL(f.previewUrl) + const restoreAttachedFiles = useCallback( + (files: AttachedFile[]) => { + for (const controller of uploadControllersRef.current.values()) controller.abort() + uploadControllersRef.current.clear() + updateAttachedFiles((current) => { + current.forEach((f) => { + if (f.previewUrl) URL.revokeObjectURL(f.previewUrl) + }) + return files }) - return files - }) - }, []) + }, + [updateAttachedFiles] + ) return { // State diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx index 3ed0c144334..3ba4638306d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx @@ -7,28 +7,17 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { - DirectUploadErrorMock, executionStoreState, mockExecute, mockExecuteFromBlock, mockFetch, mockResolveStartCandidates, - mockRunUploadStrategy, mockSelectBestTrigger, + mockUploadInternalFileSession, terminalStoreState, workflowBlocks, workflowStoreState, } = vi.hoisted(() => { - class DirectUploadErrorMock extends Error { - constructor( - message: string, - public code: string - ) { - super(message) - this.name = 'DirectUploadError' - } - } - const workflowBlocks = { start: { id: 'start', @@ -88,14 +77,13 @@ const { } return { - DirectUploadErrorMock, executionStoreState, mockExecute: vi.fn(), mockExecuteFromBlock: vi.fn(), mockFetch: vi.fn(), mockResolveStartCandidates: vi.fn(), - mockRunUploadStrategy: vi.fn(), mockSelectBestTrigger: vi.fn(), + mockUploadInternalFileSession: vi.fn(), terminalStoreState, workflowBlocks, workflowStoreState, @@ -127,9 +115,8 @@ vi.mock('@/lib/tokenization', () => ({ processStreamingBlockLogs: () => 0, })) -vi.mock('@/lib/uploads/client/direct-upload', () => ({ - DirectUploadError: DirectUploadErrorMock, - runUploadStrategy: mockRunUploadStrategy, +vi.mock('@/lib/uploads/client/session-upload', () => ({ + uploadInternalFileSession: mockUploadInternalFileSession, })) vi.mock('@/lib/workflows/input-format', () => ({ @@ -354,8 +341,8 @@ describe('useWorkflowExecution attachment uploads', () => { mockResolveStartCandidates.mockReturnValue([]) mockSelectBestTrigger.mockReturnValue([]) vi.stubGlobal('fetch', mockFetch) - mockRunUploadStrategy.mockRejectedValue( - new DirectUploadErrorMock('Server signaled fallback to API upload', 'FALLBACK_REQUIRED') + mockUploadInternalFileSession.mockRejectedValue( + new Error('Workspace file storage limit exceeded') ) mockFetch.mockResolvedValue( new Response(JSON.stringify({ error: 'Workspace file storage limit exceeded' }), { @@ -378,12 +365,14 @@ describe('useWorkflowExecution attachment uploads', () => { const file = new File(['report'], 'report.pdf', { type: 'application/pdf' }) let uploadError: unknown - mockRunUploadStrategy.mockResolvedValueOnce({ + mockUploadInternalFileSession.mockResolvedValueOnce({ + id: 'attachment-context', key: 'executions/context.txt', - path: '/uploads/context.txt', + url: '/uploads/context.txt', name: contextFile.name, size: contextFile.size, - contentType: contextFile.type, + type: contextFile.type, + context: 'execution', }) await act(async () => { @@ -437,12 +426,14 @@ describe('useWorkflowExecution attachment uploads', () => { } let runResult: unknown - mockRunUploadStrategy.mockResolvedValueOnce({ + mockUploadInternalFileSession.mockResolvedValueOnce({ + id: 'attachment-diagram', key: 'execution/diagram.png', - path: '/api/files/serve/execution%2Fdiagram.png', + url: '/api/files/serve/execution%2Fdiagram.png', name: file.name, size: file.size, - contentType: file.type, + type: file.type, + context: 'execution', }) await act(async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.ts index 938366c71bb..48a77406ed5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-attachment-upload.ts @@ -1,11 +1,5 @@ import { getErrorMessage } from '@sim/utils/errors' -import { generateShortId } from '@sim/utils/id' -import { isRecordLike } from '@sim/utils/object' -import { - type ApiFallbackUploadMetadata, - uploadViaApiFallbackWithMetadata, -} from '@/lib/uploads/client/api-fallback' -import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' +import { uploadInternalFileSession } from '@/lib/uploads/client/session-upload' export interface WorkflowAttachmentInput { name: string @@ -33,39 +27,6 @@ interface UploadWorkflowAttachmentsParams { executionId: string } -function getOptionalString(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined - const trimmed = value.trim() - return trimmed || undefined -} - -function getDirectUploadFailureReason(error: unknown): string { - if (error instanceof DirectUploadError && isRecordLike(error.details)) { - const message = - getOptionalString(error.details.message) ?? getOptionalString(error.details.error) - if (message) return message - } - - return getErrorMessage(error, 'Unknown upload error') -} - -function normalizeFallbackUpload( - value: ApiFallbackUploadMetadata, - fallbackFile: WorkflowAttachmentInput -): UploadedWorkflowAttachment { - return { - id: value.id ?? `file_${Date.now()}_${generateShortId(7)}`, - name: value.name ?? fallbackFile.name, - url: value.path, - size: typeof value.size === 'number' ? value.size : fallbackFile.size, - type: value.type ?? fallbackFile.type, - key: value.key, - context: 'execution', - uploadedAt: value.uploadedAt, - expiresAt: value.expiresAt, - } -} - /** * Uploads every explicit workflow attachment before execution may begin. * @@ -78,46 +39,21 @@ export async function uploadWorkflowAttachments({ executionId, }: UploadWorkflowAttachmentsParams): Promise { const uploadedFiles: UploadedWorkflowAttachment[] = [] - const presignedEndpoint = `/api/files/presigned?type=execution&workflowId=${encodeURIComponent(workflowId)}&executionId=${encodeURIComponent(executionId)}&workspaceId=${encodeURIComponent(workspaceId)}` for (const fileData of files) { try { - const result = await runUploadStrategy({ + const result = await uploadInternalFileSession({ + purpose: 'execution_attachment', file: fileData.file, workspaceId, - context: 'execution', workflowId, executionId, - presignedEndpoint, - }) - uploadedFiles.push({ - id: `file_${Date.now()}_${generateShortId(7)}`, - name: fileData.file.name, - url: result.path, - size: fileData.file.size, - type: fileData.file.type, - key: result.key, - context: 'execution', }) + uploadedFiles.push(result) } catch (uploadError) { - if (!(uploadError instanceof DirectUploadError) || uploadError.code !== 'FALLBACK_REQUIRED') { - throw new Error( - `Failed to upload ${fileData.name}: ${getDirectUploadFailureReason(uploadError)}` - ) - } - - try { - const fallbackResult = await uploadViaApiFallbackWithMetadata(fileData.file, 'execution', { - workflowId, - executionId, - workspaceId, - }) - uploadedFiles.push(normalizeFallbackUpload(fallbackResult, fileData)) - } catch (error) { - throw new Error( - `Failed to upload ${fileData.name}: ${getErrorMessage(error, 'Network error')}` - ) - } + throw new Error( + `Failed to upload ${fileData.name}: ${getErrorMessage(uploadError, 'Network error')}` + ) } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts index 0d589d24996..a902e2f18bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-logo-upload.ts @@ -1,8 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback' -import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' +import { uploadInternalFileSession } from '@/lib/uploads/client/session-upload' const logger = createLogger('WorkspaceLogoUpload') const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB @@ -68,25 +67,13 @@ export function useWorkspaceLogoUpload({ throw new Error('workspaceId is required for workspace logo upload') } - const presignedEndpoint = `/api/files/presigned?type=workspace-logos&workspaceId=${encodeURIComponent(targetWorkspaceId)}` - - try { - const result = await runUploadStrategy({ - file, - workspaceId: targetWorkspaceId, - context: 'workspace-logos', - presignedEndpoint, - }) - logger.info(`Workspace logo uploaded successfully: ${result.path}`) - return result.path - } catch (error) { - if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') { - const { path } = await uploadViaApiFallback(file, 'workspace-logos', targetWorkspaceId) - logger.info(`Workspace logo uploaded via API fallback: ${path}`) - return path - } - throw error - } + const result = await uploadInternalFileSession({ + purpose: 'workspace_logo', + file, + workspaceId: targetWorkspaceId, + }) + logger.info(`Workspace logo uploaded successfully: ${result.path}`) + return result.path }, []) const processFile = useCallback( diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index 3227d82e92c..0dc4b025fea 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -702,9 +702,9 @@ const CLEANUP_TARGETS = [ ] as const /** - * Sweep abandoned knowledge-base ownership bindings. Presigned and multipart upload flows - * write a `workspace_files` binding before the object is stored and before any document is - * created. If the upload is never completed, that binding is orphaned — no + * Sweep abandoned knowledge-base ownership bindings. Knowledge upload sessions write a + * `workspace_files` binding before the object is stored and before any document is created. + * If the upload is never completed, that binding is orphaned — no * `document.storageKey` ever references its key. Such bindings are inert (read access requires * a live document, and the move re-point only follows referenced keys), but they accumulate, * so we drop the best-effort object and soft-delete the binding once they are older than the diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index f519258ad33..471e1620a93 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -87,6 +87,7 @@ import { updateWorkflowGroupContract, } from '@/lib/api/contracts/tables' import type { V2TableImportSource, V2TableImportTarget } from '@/lib/api/contracts/v2/tables' +import type { V2CompleteUploadBody } from '@/lib/api/contracts/v2/uploads' import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons' import type { CsvHeaderMapping, @@ -111,7 +112,8 @@ import { optimisticallyScheduleNewlyEligibleGroups, } from '@/lib/table/deps' import { sanitizeName } from '@/lib/table/import' -import { uploadMultipartSession } from '@/lib/uploads/client/multipart-session' +import type { UploadProgressEvent } from '@/lib/uploads/client/types' +import { uploadFileSession } from '@/lib/uploads/client/upload-session' import { useTimezone } from '@/hooks/queries/general-settings' import { TABLE_LIST_STALE_TIME, @@ -1756,46 +1758,49 @@ async function createAndUploadTableImport(params: { timezone: params.timezone, }, }) - params.onCreated?.(created.data.id) - if (params.source.type === 'workspace_file') return created.data - if (!params.file || !created.data.upload) { + const { session, uploadToken, transfer } = created.data + params.onCreated?.(session.id) + if (params.source.type === 'workspace_file') return session + if (!params.file || !uploadToken || !transfer) { throw new Error('Upload-backed table import returned no upload session') } - const upload = created.data.upload - return uploadMultipartSession({ + const getPartUrls = async (partNumbers: number[]) => { + const response = await requestJson(createTableImportPartUrlsContract, { + params: { importId: session.id }, + query: { workspaceId: params.workspaceId }, + headers: { 'upload-token': uploadToken }, + body: { partNumbers }, + }) + return response.data.parts + } + const common = { file: params.file, - partSize: upload.partSize, - partCount: upload.partCount, - onProgress: params.onProgress ? (event) => params.onProgress?.(event.percent) : undefined, - getPartUrls: async (partNumbers) => { - const response = await requestJson(createTableImportPartUrlsContract, { - params: { importId: created.data.id }, - query: { workspaceId: params.workspaceId }, - headers: { 'upload-token': upload.uploadToken }, - body: { partNumbers }, - }) - return response.data.parts - }, - complete: async (parts) => { + onProgress: params.onProgress + ? (event: UploadProgressEvent) => params.onProgress?.(event.percent) + : undefined, + complete: async (body: V2CompleteUploadBody) => { const response = await requestJson(completeTableImportResourceContract, { - params: { importId: created.data.id }, + params: { importId: session.id }, query: { workspaceId: params.workspaceId }, - headers: { 'upload-token': upload.uploadToken }, - body: { parts }, + headers: { 'upload-token': uploadToken }, + body, }) return response.data }, abort: async () => { await requestJson(cancelTableImportResourceContract, { - params: { importId: created.data.id }, + params: { importId: session.id }, query: { workspaceId: params.workspaceId }, - headers: { 'upload-token': upload.uploadToken }, + headers: { 'upload-token': uploadToken }, }) }, - }) + } + return transfer.method === 'put' + ? uploadFileSession({ ...common, transfer }) + : uploadFileSession({ ...common, transfer, getPartUrls }) } -/** Uploads a CSV/TSV through a signed multipart session and creates a table from it. */ +/** Uploads a CSV/TSV through a signed upload session and creates a table from it. */ export function useImportCsv() { const queryClient = useQueryClient() const timezone = useTimezone() diff --git a/apps/sim/hooks/queries/workspace-files.test.tsx b/apps/sim/hooks/queries/workspace-files.test.tsx index db51e9fc452..64afac2dd03 100644 --- a/apps/sim/hooks/queries/workspace-files.test.tsx +++ b/apps/sim/hooks/queries/workspace-files.test.tsx @@ -12,11 +12,21 @@ import { sleep } from '@sim/utils/helpers' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useWorkspaceFileContent } from '@/hooks/queries/workspace-files' +import { createWorkspaceFileContract } from '@/lib/api/contracts/workspace-files' +import { + useCreateWorkspaceFile, + useWorkspaceFileContent, + workspaceFilesKeys, +} from '@/hooks/queries/workspace-files' + +const { mockRequestJson } = vi.hoisted(() => ({ mockRequestJson: vi.fn() })) + +vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) let fetchCount = 0 beforeEach(() => { + vi.clearAllMocks() ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true fetchCount = 0 vi.stubGlobal( @@ -63,6 +73,42 @@ function renderContentHook(options?: { } } +function renderCreateHook(): { + getMutation: () => ReturnType + queryClient: QueryClient + unmount: () => void +} { + const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + const container = document.createElement('div') + const root: Root = createRoot(container) + let mutation: ReturnType | undefined + + function Probe() { + mutation = useCreateWorkspaceFile() + return null + } + + act(() => { + root.render( + + + + ) + }) + + return { + getMutation: () => { + if (!mutation) throw new Error('Create mutation did not render') + return mutation + }, + queryClient, + unmount: () => { + act(() => root.unmount()) + queryClient.clear() + }, + } +} + describe('useWorkspaceFileContent refetchInterval passthrough', () => { it('fetches once and does not poll by default', async () => { const { unmount } = renderContentHook() @@ -102,3 +148,34 @@ describe('useWorkspaceFileContent refetchInterval passthrough', () => { unmount() }) }) + +describe('useCreateWorkspaceFile', () => { + it('uses the create contract and reconciles workspace file caches', async () => { + const response = { success: true, file: { id: 'wf-created' } } + mockRequestJson.mockResolvedValue(response) + const { getMutation, queryClient, unmount } = renderCreateHook() + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + let result: unknown + + await act(async () => { + result = await getMutation().mutateAsync({ + workspaceId: 'ws-1', + name: 'notes.md', + contentType: 'text/markdown', + }) + }) + + expect(result).toBe(response) + expect(mockRequestJson).toHaveBeenCalledWith(createWorkspaceFileContract, { + params: { id: 'ws-1' }, + body: { name: 'notes.md', contentType: 'text/markdown' }, + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: workspaceFilesKeys.workspaceLists('ws-1'), + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: workspaceFilesKeys.storageInfo(), + }) + unmount() + }) +}) diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index 922236c4918..0c5d0f09cbf 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -8,14 +8,16 @@ import { requestJson } from '@/lib/api/client/request' import { fileStorageStatusContract } from '@/lib/api/contracts/storage-transfer' import { getUsageLimitsContract } from '@/lib/api/contracts/usage-limits' import { + type CreateWorkspaceFileBody, + createWorkspaceFileContract, deleteWorkspaceFileContract, listWorkspaceFilesContract, renameWorkspaceFileContract, restoreWorkspaceFileContract, updateWorkspaceFileContentContract, } from '@/lib/api/contracts/workspace-files' -import type { UploadProgressEvent } from '@/lib/uploads/client/direct-upload' import { uploadWorkspaceFileSession } from '@/lib/uploads/client/session-upload' +import type { UploadProgressEvent } from '@/lib/uploads/client/types' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import type { UserFile } from '@/executor/types' import { useFileContentSource } from '@/hooks/use-file-content-source' @@ -387,6 +389,31 @@ export function useUploadWorkspaceFile() { }) } +type CreateWorkspaceFileParams = CreateWorkspaceFileBody & { + workspaceId: string +} + +export function useCreateWorkspaceFile() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ workspaceId, ...body }: CreateWorkspaceFileParams) => + requestJson(createWorkspaceFileContract, { + params: { id: workspaceId }, + body, + }), + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: workspaceFilesKeys.workspaceLists(variables.workspaceId), + }) + queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) + }, + onError: (error) => { + logger.error('Failed to create file:', error) + }, + }) +} + /** * Update workspace file content mutation */ diff --git a/apps/sim/lib/api/contracts/file-uploads.ts b/apps/sim/lib/api/contracts/file-uploads.ts deleted file mode 100644 index 10a1868ef6c..00000000000 --- a/apps/sim/lib/api/contracts/file-uploads.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { z } from 'zod' -import { defineRouteContract } from '@/lib/api/contracts/types' - -export const fileUploadTypeSchema = z.enum([ - 'knowledge-base', - 'chat', - 'copilot', - 'profile-pictures', -]) - -export const fileUploadTypeQuerySchema = z.object({ - type: fileUploadTypeSchema, -}) - -export const presignedUploadBodySchema = z - .object({ - fileName: z.string().optional(), - contentType: z.string().optional(), - fileSize: z.number().optional(), - userId: z.string().optional(), - chatId: z.string().optional(), - }) - .passthrough() - -export const batchPresignedUploadBodySchema = z - .object({ - files: z - .array( - z - .object({ - fileName: z.string().optional(), - contentType: z.string().optional(), - fileSize: z.number().optional(), - }) - .passthrough() - ) - .optional(), - }) - .passthrough() - -export const presignedFileInfoSchema = z - .object({ - path: z.string(), - key: z.string(), - name: z.string(), - size: z.number(), - type: z.string(), - }) - .passthrough() - -export const presignedUploadResponseSchema = z - .object({ - fileName: z.string(), - presignedUrl: z.string(), - fileInfo: presignedFileInfoSchema, - uploadHeaders: z.record(z.string(), z.string()).optional(), - directUploadSupported: z.boolean(), - }) - .passthrough() - -export const batchPresignedUploadResponseSchema = z - .object({ - files: z.array(presignedUploadResponseSchema), - directUploadSupported: z.boolean(), - }) - .passthrough() - -export const createPresignedUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/presigned', - query: fileUploadTypeQuerySchema, - body: presignedUploadBodySchema, - response: { - mode: 'json', - schema: presignedUploadResponseSchema, - }, -}) - -export const createBatchPresignedUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/presigned/batch', - query: fileUploadTypeQuerySchema, - body: batchPresignedUploadBodySchema, - response: { - mode: 'json', - schema: batchPresignedUploadResponseSchema, - }, -}) diff --git a/apps/sim/lib/api/contracts/index.ts b/apps/sim/lib/api/contracts/index.ts index 10ad693347c..1b74fdb1567 100644 --- a/apps/sim/lib/api/contracts/index.ts +++ b/apps/sim/lib/api/contracts/index.ts @@ -12,7 +12,6 @@ export * from './desktop-auth' export * from './desktop-tool-authorization' export * from './environment' export * from './execution-payloads' -export * from './file-uploads' export * from './folders' export * from './hotspots' export * from './inbox' diff --git a/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts b/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts index 7a15defe3fd..805b40a6936 100644 --- a/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts +++ b/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts @@ -1,6 +1,7 @@ import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CreateKnowledgeDocumentUploadBodySchema, + v2CreateKnowledgeDocumentUploadDataSchema, v2KnowledgeDocumentUploadParamsSchema, v2KnowledgeDocumentUploadSchema, v2UploadKnowledgeDocumentQuerySchema, @@ -18,7 +19,10 @@ export const createKnowledgeDocumentUploadContract = defineRouteContract({ path: '/api/knowledge/[id]/documents/uploads', params: v2KnowledgeDocumentUploadParamsSchema.omit({ uploadId: true }), body: v2CreateKnowledgeDocumentUploadBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentUploadSchema) }, + response: { + mode: 'json', + schema: v2DataResponse(v2CreateKnowledgeDocumentUploadDataSchema), + }, }) export const abortKnowledgeDocumentUploadContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/primitives.test.ts b/apps/sim/lib/api/contracts/primitives.test.ts index 204e6e9d2fe..fe8ad146961 100644 --- a/apps/sim/lib/api/contracts/primitives.test.ts +++ b/apps/sim/lib/api/contracts/primitives.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { customPatternSchema, + isCanonicalBase64, organizationIdSchema, piiStagePolicySchema, piiStagesSchema, @@ -12,6 +13,19 @@ import { workspaceIdSchema, } from '@/lib/api/contracts/primitives' +describe('isCanonicalBase64', () => { + it.each(['', 'TQ==', 'TWE=', 'TWFu', 'AAEC/w=='])('accepts canonical base64 %j', (value) => { + expect(isCanonicalBase64(value)).toBe(true) + }) + + it.each(['TQ', 'TQ=', 'TQ===', 'T=Q=', 'TQ==\n', 'TR==', 'TWF='])( + 'rejects malformed or non-canonical base64 %j', + (value) => { + expect(isCanonicalBase64(value)).toBe(false) + } + ) +}) + describe('customPatternSchema', () => { it('accepts a well-formed pattern', () => { expect( diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index f899a27174f..142e0990a54 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -37,6 +37,50 @@ export function flattenFieldErrors( export const noInputSchema = z.object({}).strict() export type NoInput = z.output +/** + * Accepts canonical RFC 4648 base64, including the empty encoding used for a + * zero-byte file. Padding is required when the final quantum is incomplete, + * and non-zero unused pad bits are rejected. + */ +export function isCanonicalBase64(value: string): boolean { + if (value.length === 0) return true + if (value.length % 4 !== 0) return false + + let contentLength = value.length + while (contentLength > 0 && value.charCodeAt(contentLength - 1) === 61) { + contentLength -= 1 + } + + const paddingLength = value.length - contentLength + if (paddingLength > 2) return false + if (paddingLength === 1 && contentLength % 4 !== 3) return false + if (paddingLength === 2 && contentLength % 4 !== 2) return false + + let finalSextet = 0 + for (let index = 0; index < contentLength; index += 1) { + const code = value.charCodeAt(index) + const sextet = + code >= 65 && code <= 90 + ? code - 65 + : code >= 97 && code <= 122 + ? code - 71 + : code >= 48 && code <= 57 + ? code + 4 + : code === 43 + ? 62 + : code === 47 + ? 63 + : -1 + + if (sextet === -1) return false + finalSextet = sextet + } + + if (paddingLength === 1 && (finalSextet & 0b11) !== 0) return false + if (paddingLength === 2 && (finalSextet & 0b1111) !== 0) return false + return true +} + export const jobIdParamsSchema = z.object({ jobId: z.string().min(1), }) diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index b5ad82d362c..aaa0a54f5ba 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -1,8 +1,4 @@ import { z } from 'zod' -import { - batchPresignedUploadResponseSchema, - presignedUploadResponseSchema, -} from '@/lib/api/contracts/file-uploads' import { workspaceFileIdSchema } from '@/lib/api/contracts/primitives' import { type ContractBodyInput, @@ -19,56 +15,6 @@ import { const jsonResponseSchema = z.unknown() -function formatFileSize(bytes: number): string { - if (!Number.isFinite(bytes) || bytes <= 0) return '0 Bytes' - const k = 1024 - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] - const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1) - const value = bytes / k ** i - return `${value.toFixed(value >= 100 || i === 0 ? 0 : 1)} ${sizes[i]}` -} - -const multipartPartUrlSchema = z.object({ - partNumber: z.number(), - url: z.string(), - blockId: z.string().optional(), -}) - -const multipartCompletedUploadSchema = z.object({ - success: z.literal(true), - location: z.string(), - path: z.string(), - key: z.string(), -}) - -export const initiateMultipartResponseSchema = z.object({ - uploadId: z.string(), - key: z.string(), - uploadToken: z.string(), -}) - -export const getMultipartPartUrlsResponseSchema = z.object({ - presignedUrls: z.array(multipartPartUrlSchema), -}) - -export const completeMultipartResponseSchema = z.union([ - multipartCompletedUploadSchema, - z.object({ - results: z.array(multipartCompletedUploadSchema), - }), -]) - -export const abortMultipartResponseSchema = z.object({ - success: z.literal(true), -}) - -export const multipartUploadResponseSchema = z.union([ - initiateMultipartResponseSchema, - getMultipartPartUrlsResponseSchema, - completeMultipartResponseSchema, - abortMultipartResponseSchema, -]) - const connectionFields = { host: z.string().min(1, 'Host is required'), port: z.coerce.number().int().positive().default(22), @@ -325,168 +271,6 @@ export const fileDeleteBodySchema = z }) .passthrough() -const MAX_FILE_SIZE = 100 * 1024 * 1024 -export const validUploadTypes = [ - 'knowledge-base', - 'chat', - 'copilot', - 'profile-pictures', - 'mothership', - 'workspace-logos', - 'execution', -] as const - -export const uploadTypeSchema = z.enum(validUploadTypes) - -/** - * Storage contexts a client may mint a single presigned upload URL for. Each one - * has a per-context authorization predicate in `/api/files/presigned`; a context - * that cannot be authorized must not be listed here. `chat` is deliberately - * absent — it has no owning entity to authorize against and no client that mints - * one (chat assets go through the server-proxied `/api/files/upload`). - */ -export const presignedUploadTypes = [ - 'knowledge-base', - 'copilot', - 'profile-pictures', - 'mothership', - 'workspace-logos', - 'execution', -] as const - -export const presignedUploadTypeSchema = z.enum(presignedUploadTypes) - -/** - * Storage contexts `/api/files/presigned/batch` serves. Batching exists only for - * knowledge-base ingest; no other context has a batch client, and the batch - * endpoint carries no authorization predicate for one. - */ -export const batchPresignedUploadTypes = ['knowledge-base'] as const - -export const batchPresignedUploadTypeSchema = z.enum(batchPresignedUploadTypes) - -export const presignedUploadQuerySchema = z.object({ - type: presignedUploadTypeSchema, -}) - -export const presignedUrlBodySchema = z - .object({ - fileName: z - .string({ error: 'fileName is required and cannot be empty' }) - .refine((value) => value.trim().length > 0, { - message: 'fileName is required and cannot be empty', - }), - contentType: z - .string({ error: 'contentType is required and cannot be empty' }) - .refine((value) => value.trim().length > 0, { - message: 'contentType is required and cannot be empty', - }), - fileSize: z - .number({ error: 'fileSize must be a positive number' }) - .positive('fileSize must be a positive number') - .superRefine((val, ctx) => { - if (val > MAX_FILE_SIZE) { - ctx.addIssue({ - code: 'custom', - message: `File size ${formatFileSize(val)} exceeds maximum allowed size of ${formatFileSize(MAX_FILE_SIZE)}`, - }) - } - }), - userId: z.string().optional(), - chatId: z.string().optional(), - }) - .passthrough() - -export const batchPresignedUrlBodySchema = z - .object({ - files: z - .array( - z - .object({ - fileName: z.string().refine((value) => value.trim().length > 0, { - message: 'fileName is required for all files', - }), - contentType: z.string().refine((value) => value.trim().length > 0, { - message: 'contentType is required for all files', - }), - fileSize: z.number(), - }) - .passthrough() - .superRefine((file, ctx) => { - const name = typeof file.fileName === 'string' ? file.fileName : 'file' - if (!Number.isFinite(file.fileSize) || file.fileSize <= 0) { - ctx.addIssue({ - code: 'custom', - path: ['fileSize'], - message: `${name} is empty (fileSize must be greater than 0)`, - }) - } else if (file.fileSize > MAX_FILE_SIZE) { - ctx.addIssue({ - code: 'custom', - path: ['fileSize'], - message: `${name} (${formatFileSize(file.fileSize)}) exceeds maximum allowed size of ${formatFileSize(MAX_FILE_SIZE)}`, - }) - } - }) - ) - .min(1, 'files array is required and cannot be empty') - .max(100, 'Cannot process more than 100 files at once'), - }) - .passthrough() - -export const multipartActionSchema = z.enum(['initiate', 'get-part-urls', 'complete', 'abort']) - -export const initiateMultipartBodySchema = z - .object({ - fileName: z.string(), - contentType: z.string(), - fileSize: z.number(), - workspaceId: z.string({ error: 'workspaceId is required' }).min(1, 'workspaceId is required'), - context: z.string().optional(), - }) - .passthrough() - -export const tokenBoundMultipartBodySchema = z - .object({ - uploadToken: z.string().optional(), - }) - .passthrough() - -export const getMultipartPartUrlsBodySchema = tokenBoundMultipartBodySchema.extend({ - partNumbers: z.array(z.number()), -}) - -export const completeMultipartBodySchema = z - .object({ - uploadToken: z.string().optional(), - parts: z.unknown().optional(), - uploads: z - .array( - z - .object({ - uploadToken: z.string().optional(), - parts: z.unknown().optional(), - }) - .passthrough() - ) - .optional(), - }) - .passthrough() - -export type CompleteMultipartBody = z.output - -export const uploadFilesFormFilesSchema = z.preprocess( - (value) => (Array.isArray(value) ? value.filter((entry) => entry instanceof File) : value), - z.array(z.custom((value) => value instanceof File)).min(1, 'No files provided') -) - -export const uploadFilesFormFieldsSchema = z.object({ - workflowId: z.string().nullable(), - executionId: z.string().nullable(), - workspaceId: z.string().nullable(), - context: z.string().nullable(), -}) - export const fileServeParamsSchema = z.object({ path: z.array(z.string()).min(1), }) @@ -680,76 +464,6 @@ export const fileDeleteContract = defineRouteContract({ response: { mode: 'json', schema: jsonResponseSchema }, }) -export const fileUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/upload', - response: { mode: 'json', schema: jsonResponseSchema }, -}) - -export const presignedUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/presigned', - query: presignedUploadQuerySchema, - body: presignedUrlBodySchema, - response: { mode: 'json', schema: presignedUploadResponseSchema }, -}) - -export const presignedUploadBodyContract = defineRouteContract({ - method: 'POST', - path: '/api/files/presigned', - body: presignedUrlBodySchema, - response: { mode: 'json', schema: presignedUploadResponseSchema }, -}) - -export const batchPresignedUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/presigned/batch', - query: presignedUploadQuerySchema, - body: batchPresignedUrlBodySchema, - response: { mode: 'json', schema: batchPresignedUploadResponseSchema }, -}) - -export const batchPresignedUploadBodyContract = defineRouteContract({ - method: 'POST', - path: '/api/files/presigned/batch', - body: batchPresignedUrlBodySchema, - response: { mode: 'json', schema: batchPresignedUploadResponseSchema }, -}) - -export const multipartUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/multipart', - response: { mode: 'json', schema: multipartUploadResponseSchema }, -}) - -export const initiateMultipartUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/multipart', - body: initiateMultipartBodySchema, - response: { mode: 'json', schema: initiateMultipartResponseSchema }, -}) - -export const getMultipartPartUrlsContract = defineRouteContract({ - method: 'POST', - path: '/api/files/multipart', - body: getMultipartPartUrlsBodySchema, - response: { mode: 'json', schema: getMultipartPartUrlsResponseSchema }, -}) - -export const completeMultipartUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/multipart', - body: completeMultipartBodySchema, - response: { mode: 'json', schema: completeMultipartResponseSchema }, -}) - -export const abortMultipartUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/multipart', - body: tokenBoundMultipartBodySchema, - response: { mode: 'json', schema: abortMultipartResponseSchema }, -}) - export const fileServeContract = defineRouteContract({ method: 'GET', path: '/api/files/serve/[...path]', @@ -815,16 +529,6 @@ export type FileParseBody = ContractBodyInput export type FileParseResponse = ContractJsonResponse export type FileDeleteBody = ContractBodyInput export type FileDeleteResponse = ContractJsonResponse -export type PresignedUploadQuery = ContractQueryInput -export type PresignedUploadBody = ContractBodyInput -export type PresignedUploadResponse = ContractJsonResponse -export type BatchPresignedUploadQuery = ContractQueryInput -export type BatchPresignedUploadBody = ContractBodyInput -export type BatchPresignedUploadResponse = ContractJsonResponse -export type MultipartAction = z.output -export type InitiateMultipartBody = z.output -export type TokenBoundMultipartBody = z.output -export type GetMultipartPartUrlsBody = z.output export type FileServeParams = ContractParamsInput export type FileServeQuery = ContractQueryInput export type FileViewParams = ContractParamsInput diff --git a/apps/sim/lib/api/contracts/table-transfers.ts b/apps/sim/lib/api/contracts/table-transfers.ts index 69a5c4f4ec6..3b83e074603 100644 --- a/apps/sim/lib/api/contracts/table-transfers.ts +++ b/apps/sim/lib/api/contracts/table-transfers.ts @@ -3,6 +3,7 @@ import { defineRouteContract } from '@/lib/api/contracts/types' import { v2DataResponse } from '@/lib/api/contracts/v2/shared' import { v2CreateTableImportBodySchema, + v2CreateTableImportDataSchema, v2TableExportDownloadDataSchema, v2TableExportParamsSchema, v2TableExportSchema, @@ -22,7 +23,7 @@ export const createTableImportResourceContract = defineRouteContract({ method: 'POST', path: '/api/table/imports', body: v2CreateTableImportBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, + response: { mode: 'json', schema: v2DataResponse(v2CreateTableImportDataSchema) }, }) export const getTableImportResourceContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 666b9d55552..d7abbcbe383 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -32,7 +32,7 @@ import { SORT_DIRECTIONS, TABLE_LIMITS, } from '@/lib/table/constants' -import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' +import { CSV_MAX_FILE_SIZE_BYTES, CSV_MAX_FILE_SIZE_MESSAGE } from '@/lib/table/import' export const domainObjectSchema = () => z.custom(isRecordLike) @@ -1038,7 +1038,7 @@ export const csvFileSchema = z if (value.size > CSV_MAX_FILE_SIZE_BYTES) { ctx.addIssue({ code: 'custom', - message: `File exceeds maximum allowed size of ${CSV_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB`, + message: CSV_MAX_FILE_SIZE_MESSAGE, }) } }) diff --git a/apps/sim/lib/api/contracts/upload-sessions.ts b/apps/sim/lib/api/contracts/upload-sessions.ts index c56d8ca014b..4e3a5153f3f 100644 --- a/apps/sim/lib/api/contracts/upload-sessions.ts +++ b/apps/sim/lib/api/contracts/upload-sessions.ts @@ -1,53 +1,198 @@ import { z } from 'zod' +import { folderIdSchema, workflowIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -import { - v2CreateFileUploadBodySchema, - v2FileUploadParamsSchema, - v2FileUploadSchema, - v2FileUploadWorkspaceQuerySchema, -} from '@/lib/api/contracts/v2/files' +import { v2FileSchema } from '@/lib/api/contracts/v2/files' import { v2DataResponse } from '@/lib/api/contracts/v2/shared' import { v2CompleteUploadBodySchema, v2PartUrlsBodySchema, v2PartUrlsDataSchema, + v2UploadStatusSchema, v2UploadTokenHeadersSchema, + v2UploadTransferSchema, } from '@/lib/api/contracts/v2/uploads' +import { executionIdSchema } from '@/lib/api/contracts/workflows' +import { + MAX_WORKSPACE_FILE_SIZE, + MAX_WORKSPACE_FORMDATA_FILE_SIZE, +} from '@/lib/uploads/shared/types' + +const MAX_ASSET_FILE_SIZE = 5 * 1024 * 1024 + +const internalFileUploadBaseShape = { + name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), + contentType: z + .string() + .trim() + .min(1, 'contentType is required') + .max(255, 'contentType is too long'), +} as const + +export const createInternalFileUploadBodySchema = z.discriminatedUnion('purpose', [ + z + .object({ + purpose: z.literal('workspace_file'), + ...internalFileUploadBaseShape, + size: z.number().int().nonnegative().max(MAX_WORKSPACE_FILE_SIZE), + workspaceId: workspaceIdSchema, + folderId: folderIdSchema.optional(), + }) + .strict(), + z + .object({ + purpose: z.literal('profile_picture'), + ...internalFileUploadBaseShape, + size: z.number().int().min(1).max(MAX_ASSET_FILE_SIZE), + }) + .strict(), + z + .object({ + purpose: z.literal('workspace_logo'), + ...internalFileUploadBaseShape, + size: z.number().int().min(1).max(MAX_ASSET_FILE_SIZE), + workspaceId: workspaceIdSchema, + }) + .strict(), + z + .object({ + purpose: z.literal('mothership_attachment'), + ...internalFileUploadBaseShape, + size: z.number().int().min(1).max(MAX_WORKSPACE_FILE_SIZE), + workspaceId: workspaceIdSchema, + }) + .strict(), + z + .object({ + purpose: z.literal('execution_attachment'), + ...internalFileUploadBaseShape, + size: z.number().int().min(1).max(MAX_WORKSPACE_FORMDATA_FILE_SIZE), + workspaceId: workspaceIdSchema, + workflowId: workflowIdSchema, + executionId: executionIdSchema, + }) + .strict(), +]) +export type CreateInternalFileUploadBody = z.input + +export const internalFileUploadParamsSchema = z.object({ + uploadId: z.string().min(1, 'uploadId is required'), +}) + +const internalFileUploadSessionBaseShape = { + id: z.string().min(1), + status: v2UploadStatusSchema, + name: z.string(), + contentType: z.string(), + expiresAt: z.string().datetime(), + error: z.string().nullable(), +} as const -export const createWorkspaceFileUploadContract = defineRouteContract({ +export const internalUploadedAssetSchema = z + .object({ + path: z.string().min(1), + key: z.string().min(1), + name: z.string().min(1), + size: z.number().int().positive(), + type: z.string().min(1), + }) + .strict() + +export const internalExecutionAttachmentSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + size: z.number().int().positive(), + type: z.string().min(1), + url: z.string().min(1), + key: z.string().min(1), + context: z.literal('execution'), + }) + .strict() + +export const internalFileUploadSessionSchema = z.discriminatedUnion('purpose', [ + z + .object({ + ...internalFileUploadSessionBaseShape, + purpose: z.literal('workspace_file'), + size: z.number().int().nonnegative(), + result: v2FileSchema.nullable(), + }) + .strict(), + z + .object({ + ...internalFileUploadSessionBaseShape, + purpose: z.literal('profile_picture'), + size: z.number().int().positive(), + result: internalUploadedAssetSchema.nullable(), + }) + .strict(), + z + .object({ + ...internalFileUploadSessionBaseShape, + purpose: z.literal('workspace_logo'), + size: z.number().int().positive(), + result: internalUploadedAssetSchema.nullable(), + }) + .strict(), + z + .object({ + ...internalFileUploadSessionBaseShape, + purpose: z.literal('mothership_attachment'), + size: z.number().int().positive(), + result: internalUploadedAssetSchema.nullable(), + }) + .strict(), + z + .object({ + ...internalFileUploadSessionBaseShape, + purpose: z.literal('execution_attachment'), + size: z.number().int().positive(), + result: internalExecutionAttachmentSchema.nullable(), + }) + .strict(), +]) +export type InternalFileUploadSession = z.output + +export const createInternalFileUploadDataSchema = z + .object({ + session: internalFileUploadSessionSchema, + uploadToken: z.string().min(1), + transfer: v2UploadTransferSchema, + }) + .strict() +export type CreateInternalFileUploadData = z.output + +export const createInternalFileUploadContract = defineRouteContract({ method: 'POST', path: '/api/files/uploads', - body: v2CreateFileUploadBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, + body: createInternalFileUploadBodySchema, + response: { mode: 'json', schema: v2DataResponse(createInternalFileUploadDataSchema) }, }) -export const abortWorkspaceFileUploadContract = defineRouteContract({ +export const abortInternalFileUploadContract = defineRouteContract({ method: 'DELETE', path: '/api/files/uploads/[uploadId]', - params: v2FileUploadParamsSchema, - query: v2FileUploadWorkspaceQuerySchema, + params: internalFileUploadParamsSchema, headers: v2UploadTokenHeadersSchema, - response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, + response: { mode: 'json', schema: v2DataResponse(internalFileUploadSessionSchema) }, }) -export const createWorkspaceFileUploadPartUrlsContract = defineRouteContract({ +export const createInternalFileUploadPartUrlsContract = defineRouteContract({ method: 'POST', path: '/api/files/uploads/[uploadId]/parts', - params: v2FileUploadParamsSchema, - query: v2FileUploadWorkspaceQuerySchema, + params: internalFileUploadParamsSchema, headers: v2UploadTokenHeadersSchema, body: v2PartUrlsBodySchema, response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, }) -export const completeWorkspaceFileUploadContract = defineRouteContract({ +export const completeInternalFileUploadContract = defineRouteContract({ method: 'POST', path: '/api/files/uploads/[uploadId]/complete', - params: v2FileUploadParamsSchema, - query: v2FileUploadWorkspaceQuerySchema, + params: internalFileUploadParamsSchema, headers: v2UploadTokenHeadersSchema, body: v2CompleteUploadBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, + response: { mode: 'json', schema: v2DataResponse(internalFileUploadSessionSchema) }, }) export const localUploadPartParamsSchema = z.object({ @@ -66,3 +211,11 @@ export const localUploadPartContract = defineRouteContract({ query: localUploadPartQuerySchema, response: { mode: 'empty', status: 204 }, }) + +export const localPutUploadContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/uploads/[uploadId]', + params: internalFileUploadParamsSchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'empty', status: 204 }, +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts new file mode 100644 index 00000000000..9e9dca55d01 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { + V2_TABLE_IMPORT_OPTIONS_MAX_BYTES, + v2CreateTableImportBodySchema, + v2TableUploadImportSourceSchema, +} from '@/lib/api/contracts/v2/tables' +import { TABLE_LIMITS } from '@/lib/table/constants' +import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' + +function uploadSource(size: number) { + return { + type: 'upload' as const, + name: 'data.csv', + contentType: 'text/csv', + size, + } +} + +function existingTableImport(overrides: Record = {}) { + return { + workspaceId: WORKSPACE_ID, + source: uploadSource(128), + target: { type: 'existing' as const, tableId: 'table-1', mode: 'append' as const }, + ...overrides, + } +} + +describe('v2 table import contracts', () => { + it('accepts the exact CSV byte limit and rejects one byte over it', () => { + expect( + v2TableUploadImportSourceSchema.safeParse(uploadSource(CSV_MAX_FILE_SIZE_BYTES)).success + ).toBe(true) + expect( + v2TableUploadImportSourceSchema.safeParse(uploadSource(CSV_MAX_FILE_SIZE_BYTES + 1)).success + ).toBe(false) + }) + + it('accepts native JSON mapping and createColumns values', () => { + const body = existingTableImport({ + mapping: { email: 'email_address', notes: null }, + createColumns: ['phone'], + }) + + expect(v2CreateTableImportBodySchema.parse(body)).toEqual(body) + }) + + it('rejects the legacy FormData JSON-string representation', () => { + expect( + v2CreateTableImportBodySchema.safeParse( + existingTableImport({ mapping: JSON.stringify({ email: 'email_address' }) }) + ).success + ).toBe(false) + expect( + v2CreateTableImportBodySchema.safeParse( + existingTableImport({ createColumns: JSON.stringify(['phone']) }) + ).success + ).toBe(false) + }) + + it('caps mapping entries and createColumns items at the table column limit', () => { + const mapping = Object.fromEntries( + Array.from({ length: TABLE_LIMITS.MAX_COLUMNS_PER_TABLE }, (_, index) => [ + `header_${index}`, + `column_${index}`, + ]) + ) + const createColumns = Array.from( + { length: TABLE_LIMITS.MAX_COLUMNS_PER_TABLE }, + (_, index) => `header_${index}` + ) + + expect(v2CreateTableImportBodySchema.safeParse(existingTableImport({ mapping })).success).toBe( + true + ) + expect( + v2CreateTableImportBodySchema.safeParse(existingTableImport({ createColumns })).success + ).toBe(true) + expect( + v2CreateTableImportBodySchema.safeParse( + existingTableImport({ mapping: { ...mapping, overflow: 'overflow' } }) + ).success + ).toBe(false) + expect( + v2CreateTableImportBodySchema.safeParse( + existingTableImport({ createColumns: [...createColumns, 'overflow'] }) + ).success + ).toBe(false) + }) + + it('bounds CSV header and mapped column names', () => { + const exact = 'x'.repeat(TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) + const over = `${exact}x` + + expect( + v2CreateTableImportBodySchema.safeParse( + existingTableImport({ mapping: { [exact]: exact }, createColumns: [exact] }) + ).success + ).toBe(true) + expect( + v2CreateTableImportBodySchema.safeParse(existingTableImport({ mapping: { [over]: exact } })) + .success + ).toBe(false) + expect( + v2CreateTableImportBodySchema.safeParse(existingTableImport({ mapping: { header: over } })) + .success + ).toBe(false) + expect( + v2CreateTableImportBodySchema.safeParse(existingTableImport({ createColumns: [over] })) + .success + ).toBe(false) + }) + + it('caps aggregate mapping metadata before it is embedded in the signed upload token', () => { + const mapping = Object.fromEntries( + Array.from({ length: 30 }, (_, index) => [ + `header_${index}_${'h'.repeat(30)}`, + `column_${index}_${'c'.repeat(30)}`, + ]) + ) + const result = v2CreateTableImportBodySchema.safeParse(existingTableImport({ mapping })) + + expect(new TextEncoder().encode(JSON.stringify({ mapping })).byteLength).toBeGreaterThan( + V2_TABLE_IMPORT_OPTIONS_MAX_BYTES + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + path: ['mapping'], + message: expect.stringMatching(/signed request token/), + }) + ) + } + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/uploads.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/uploads.test.ts new file mode 100644 index 00000000000..b301ab84ea1 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/uploads.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { v2CompleteUploadBodySchema, v2UploadTransferSchema } from '@/lib/api/contracts/v2/uploads' + +describe('v2 upload transfer contracts', () => { + it('accepts only an empty object for PUT completion', () => { + expect(v2CompleteUploadBodySchema.parse({})).toEqual({}) + expect(v2CompleteUploadBodySchema.safeParse({ method: 'put' }).success).toBe(false) + }) + + it('accepts a strict completed-parts body for multipart completion', () => { + expect( + v2CompleteUploadBodySchema.parse({ parts: [{ partNumber: 1, etag: 'etag-1' }] }) + ).toEqual({ parts: [{ partNumber: 1, etag: 'etag-1' }] }) + expect(v2CompleteUploadBodySchema.safeParse({ parts: [] }).success).toBe(false) + expect( + v2CompleteUploadBodySchema.safeParse({ + parts: [{ partNumber: 1 }], + ignored: true, + }).success + ).toBe(false) + }) + + it('discriminates a PUT transfer from multipart geometry', () => { + expect( + v2UploadTransferSchema.parse({ + method: 'put', + url: 'https://storage.example/upload', + headers: { 'Content-Type': 'application/octet-stream' }, + }) + ).toMatchObject({ method: 'put' }) + expect( + v2UploadTransferSchema.parse({ + method: 'multipart', + partSize: 8 * 1024 * 1024, + partCount: 7, + }) + ).toMatchObject({ method: 'multipart' }) + expect( + v2UploadTransferSchema.safeParse({ + method: 'put', + partSize: 8 * 1024 * 1024, + partCount: 1, + }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 7c411ec532d..aea3f744be0 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -111,6 +111,7 @@ const credentialSecretFields = { clientId: z.string().trim().min(1, 'clientId cannot be empty').max(512).optional(), clientSecret: z.string().trim().min(1, 'clientSecret cannot be empty').max(1024).optional(), orgId: z.string().trim().min(1, 'orgId cannot be empty').max(255).optional(), + dataCenter: z.string().trim().min(1, 'dataCenter cannot be empty').max(32).optional(), } as const export const v2CreateCredentialBodySchema = z diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 749d4a9bda7..e871a5438a3 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -1,5 +1,10 @@ import { z } from 'zod' -import { workspaceFileIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + folderIdSchema, + isCanonicalBase64, + workspaceFileIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { shareAuthTypeSchema, shareRecordSchema } from '@/lib/api/contracts/public-shares' import { defineRouteContract } from '@/lib/api/contracts/types' import { @@ -14,15 +19,15 @@ import { v2PartUrlsDataSchema, v2UploadStatusSchema, v2UploadTokenHeadersSchema, + v2UploadTransferSchema, } from '@/lib/api/contracts/v2/uploads' import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' /** * v2 files contracts. v2 drops the v1 `{ success, data, limits }` envelope in * favor of the canonical v2 shapes (`{ data }` / `{ data, nextCursor }`) and - * adds cursor pagination to the list. The workspace is always carried as a query - * param — including on upload — so the route can authorize before reading the - * multipart body. + * adds cursor pagination to the list. List and item routes carry the workspace + * as a query parameter; upload-session creation carries it in the JSON body. * * Folders are referenced but not managed here. A file carries `folderId` / * `folderPath`, and `move` retargets it, but there are deliberately no @@ -65,8 +70,8 @@ export const v2CreateFileUploadBodySchema = z workspaceId: workspaceIdSchema, name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), contentType: z.string().trim().min(1, 'contentType is required').max(255), - size: z.number().int().min(1).max(MAX_WORKSPACE_FILE_SIZE), - folderId: z.string().min(1, 'folderId cannot be empty').optional(), + size: z.number().int().nonnegative().max(MAX_WORKSPACE_FILE_SIZE), + folderId: folderIdSchema.optional(), }) .strict() export type V2CreateFileUploadBody = z.input @@ -79,16 +84,22 @@ export const v2FileUploadSchema = z.object({ status: v2UploadStatusSchema, name: z.string(), contentType: z.string(), - size: z.number().int().positive(), - partSize: z.number().int().positive(), - partCount: z.number().int().positive(), - uploadToken: z.string().min(1), + size: z.number().int().nonnegative(), expiresAt: z.string().datetime(), error: z.string().nullable(), file: v2FileSchema.nullable(), }) export type V2FileUpload = z.output +export const v2CreateFileUploadDataSchema = z + .object({ + session: v2FileUploadSchema, + uploadToken: z.string().min(1), + transfer: v2UploadTransferSchema, + }) + .strict() +export type V2CreateFileUploadData = z.output + /** Acknowledgement returned by a successful archive (soft delete). */ export const v2DeleteFileResultSchema = z.object({ id: z.string(), @@ -131,6 +142,33 @@ const v2FileItemNameSchema = z 'name cannot contain path separators or dot segments' ) +export const v2CreateFileBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: v2FileItemNameSchema, + contentType: z + .string() + .trim() + .min(1, 'contentType cannot be empty') + .max(255, 'contentType is too long') + .optional(), + folderId: folderIdSchema.optional(), + content: z.string().max(70_000_000, 'content is too large').default(''), + encoding: z.enum(['utf-8', 'base64']).default('utf-8'), + }) + .superRefine(({ content, encoding }, ctx) => { + if (encoding === 'base64' && !isCanonicalBase64(content)) { + ctx.addIssue({ + code: 'custom', + path: ['content'], + message: 'content must be valid base64', + }) + } + }) + .strict() + +export type V2CreateFileBody = z.input + /** Sortable file fields. `name` is the uploaded file name, not the storage key. */ export const v2FileSortFields = ['name', 'size', 'uploadedAt', 'updatedAt'] as const @@ -307,6 +345,15 @@ export const v2UpdateFileContentBodySchema = z content: z.string().max(70_000_000, 'content is too large'), encoding: z.enum(['utf-8', 'base64']).default('utf-8'), }) + .superRefine(({ content, encoding }, ctx) => { + if (encoding === 'base64' && !isCanonicalBase64(content)) { + ctx.addIssue({ + code: 'custom', + path: ['content'], + message: 'content must be valid base64', + }) + } + }) .strict() export type V2UpdateFileContentBody = z.input @@ -321,11 +368,21 @@ export const v2ListFilesContract = defineRouteContract({ }, }) +export const v2CreateFileContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files', + body: v2CreateFileBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileSchema), + }, +}) + export const v2CreateFileUploadContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/uploads', body: v2CreateFileUploadBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, + response: { mode: 'json', schema: v2DataResponse(v2CreateFileUploadDataSchema) }, }) export const v2AbortFileUploadContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index bdca6fe4f01..0e7ee1b1967 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -28,6 +28,7 @@ import { v2PartUrlsDataSchema, v2UploadStatusSchema, v2UploadTokenHeadersSchema, + v2UploadTransferSchema, } from '@/lib/api/contracts/v2/uploads' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' @@ -211,15 +212,23 @@ export const v2KnowledgeDocumentUploadSchema = z.object({ name: z.string(), contentType: z.string(), size: z.number().int().positive(), - partSize: z.number().int().positive(), - partCount: z.number().int().positive(), - uploadToken: z.string().min(1), expiresAt: z.string().datetime(), error: z.string().nullable(), document: v2KnowledgeDocumentSummarySchema.nullable(), }) export type V2KnowledgeDocumentUpload = z.output +export const v2CreateKnowledgeDocumentUploadDataSchema = z + .object({ + session: v2KnowledgeDocumentUploadSchema, + uploadToken: z.string().min(1), + transfer: v2UploadTransferSchema, + }) + .strict() +export type V2CreateKnowledgeDocumentUploadData = z.output< + typeof v2CreateKnowledgeDocumentUploadDataSchema +> + export const v2KnowledgeBaseSortFields = ['name', 'createdAt', 'updatedAt'] as const export type V2KnowledgeBaseSortBy = (typeof v2KnowledgeBaseSortFields)[number] @@ -345,7 +354,10 @@ export const v2CreateKnowledgeDocumentUploadContract = defineRouteContract({ path: '/api/v2/knowledge/[id]/documents/uploads', params: knowledgeBaseParamsSchema, body: v2CreateKnowledgeDocumentUploadBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentUploadSchema) }, + response: { + mode: 'json', + schema: v2DataResponse(v2CreateKnowledgeDocumentUploadDataSchema), + }, }) export const v2AbortKnowledgeDocumentUploadContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 30b79fe9a37..3add52daf85 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -5,8 +5,6 @@ import { cancelTableRunsBodyBaseSchema, createTableColumnBodySchema, createTableViewBodySchema, - csvImportCreateColumnsSchema, - csvImportMappingSchema, deleteTableColumnBodySchema, deleteWorkflowGroupBodySchema, exportTableAsyncBodySchema, @@ -51,9 +49,10 @@ import { v2PartUrlsBodySchema, v2PartUrlsDataSchema, v2UploadTokenHeadersSchema, + v2UploadTransferSchema, } from '@/lib/api/contracts/v2/uploads' import { TABLE_LIMITS } from '@/lib/table/constants' -import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' +import { CSV_MAX_FILE_SIZE_BYTES, CSV_MAX_FILE_SIZE_MESSAGE } from '@/lib/table/import' /** * v2 tables contracts. @@ -81,6 +80,8 @@ import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' export const V2_DEFAULT_ROW_LIMIT = 100 /** Hard cap on an explicit page `limit`. Larger pulls use `limit=0` (query) or an export resource. */ export const V2_MAX_ROW_LIMIT = 1000 +/** Keeps upload-token metadata comfortably below common 8 KiB request-header limits after signing. */ +export const V2_TABLE_IMPORT_OPTIONS_MAX_BYTES = 2 * 1024 /** * Public table shape emitted by `toApiTable` (timestamps ISO-serialized). @@ -928,16 +929,22 @@ export const v2TableImportParamsSchema = z.object({ importId: z.string().min(1) export const v2TableExportParamsSchema = z.object({ exportId: z.string().min(1) }) export const v2TableTransferWorkspaceQuerySchema = z.object({ workspaceId: workspaceIdSchema }) +export const v2TableUploadImportSourceSchema = z + .object({ + type: z.literal('upload'), + name: z.string().trim().min(1, 'name is required').max(255), + contentType: z.string().trim().min(1, 'contentType is required').max(255), + size: z.number().int().min(1).max(CSV_MAX_FILE_SIZE_BYTES, CSV_MAX_FILE_SIZE_MESSAGE), + }) + .strict() + +export const v2TableWorkspaceFileImportSourceSchema = z + .object({ type: z.literal('workspace_file'), fileId: z.string().min(1) }) + .strict() + export const v2TableImportSourceSchema = z.discriminatedUnion('type', [ - z - .object({ - type: z.literal('upload'), - name: z.string().trim().min(1, 'name is required').max(255), - contentType: z.string().trim().min(1, 'contentType is required').max(255), - size: z.number().int().min(1).max(MAX_WORKSPACE_FILE_SIZE), - }) - .strict(), - z.object({ type: z.literal('workspace_file'), fileId: z.string().min(1) }).strict(), + v2TableUploadImportSourceSchema, + v2TableWorkspaceFileImportSourceSchema, ]) export type V2TableImportSource = z.input @@ -959,13 +966,43 @@ export const v2TableImportTargetSchema = z.discriminatedUnion('type', [ ]) export type V2TableImportTarget = z.input +const v2CsvHeaderSchema = z + .string() + .min(1, 'CSV header must not be empty') + .max( + TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH, + `CSV header must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less` + ) + +const v2CsvColumnNameSchema = z + .string() + .min(1, 'Column name must not be empty') + .max( + TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH, + `Column name must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less` + ) + +export const v2CsvImportMappingSchema = z + .record(v2CsvHeaderSchema, v2CsvColumnNameSchema.nullable()) + .refine( + (mapping) => Object.keys(mapping).length <= TABLE_LIMITS.MAX_COLUMNS_PER_TABLE, + `mapping cannot contain more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} entries` + ) + +export const v2CsvImportCreateColumnsSchema = z + .array(v2CsvHeaderSchema) + .max( + TABLE_LIMITS.MAX_COLUMNS_PER_TABLE, + `createColumns cannot contain more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} items` + ) + export const v2CreateTableImportBodySchema = z .object({ workspaceId: workspaceIdSchema, source: v2TableImportSourceSchema, target: v2TableImportTargetSchema, - mapping: csvImportMappingSchema.optional(), - createColumns: csvImportCreateColumnsSchema.optional(), + mapping: v2CsvImportMappingSchema.optional(), + createColumns: v2CsvImportCreateColumnsSchema.optional(), timezone: ianaTimezoneSchema.optional(), }) .strict() @@ -984,6 +1021,19 @@ export const v2CreateTableImportBodySchema = z message: 'createColumns is only supported for an existing table target', }) } + const serializedOptions = JSON.stringify({ + ...(body.mapping !== undefined ? { mapping: body.mapping } : {}), + ...(body.createColumns !== undefined ? { createColumns: body.createColumns } : {}), + }) + if ( + new TextEncoder().encode(serializedOptions).byteLength > V2_TABLE_IMPORT_OPTIONS_MAX_BYTES + ) { + ctx.addIssue({ + code: 'custom', + path: [body.mapping !== undefined ? 'mapping' : 'createColumns'], + message: `mapping and createColumns must serialize to at most ${V2_TABLE_IMPORT_OPTIONS_MAX_BYTES} bytes because upload metadata is carried in a signed request token`, + }) + } }) export type V2CreateTableImportBody = z.input @@ -998,13 +1048,6 @@ export const v2TableImportStatusSchema = z.enum([ ]) export type V2TableImportStatus = z.output -export const v2TableImportUploadSchema = z.object({ - uploadToken: z.string().min(1), - partSize: z.number().int().positive(), - partCount: z.number().int().positive(), - expiresAt: z.string().datetime(), -}) - export const v2TableImportSchema = z.object({ id: z.string(), workspaceId: z.string(), @@ -1014,18 +1057,43 @@ export const v2TableImportSchema = z.object({ tableId: z.string().nullable(), rowsProcessed: z.number().int().nonnegative(), error: z.string().nullable(), - upload: v2TableImportUploadSchema.nullable(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), completedAt: z.string().datetime().nullable(), }) export type V2TableImport = z.output +const v2UploadBackedTableImportSchema = v2TableImportSchema.extend({ + source: v2TableUploadImportSourceSchema, +}) + +const v2WorkspaceFileTableImportSchema = v2TableImportSchema.extend({ + source: v2TableWorkspaceFileImportSourceSchema, +}) + +export const v2CreateTableImportDataSchema = z.union([ + z + .object({ + session: v2UploadBackedTableImportSchema, + uploadToken: z.string().min(1), + transfer: v2UploadTransferSchema, + }) + .strict(), + z + .object({ + session: v2WorkspaceFileTableImportSchema, + uploadToken: z.null(), + transfer: z.null(), + }) + .strict(), +]) +export type V2CreateTableImportData = z.output + export const v2CreateTableImportContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/imports', body: v2CreateTableImportBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, + response: { mode: 'json', schema: v2DataResponse(v2CreateTableImportDataSchema) }, }) export const v2GetTableImportContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/uploads.ts b/apps/sim/lib/api/contracts/v2/uploads.ts index d18f9124692..0e91d768b83 100644 --- a/apps/sim/lib/api/contracts/v2/uploads.ts +++ b/apps/sim/lib/api/contracts/v2/uploads.ts @@ -27,13 +27,44 @@ export const v2CompletedPartSchema = z .strict() export type V2CompletedPart = z.input -export const v2CompleteUploadBodySchema = z +const v2CompleteMultipartUploadBodySchema = z .object({ parts: z.array(v2CompletedPartSchema).min(1).max(640), }) .strict() + +const v2CompletePutUploadBodySchema = z.object({}).strict() + +export const v2CompleteUploadBodySchema = z.union([ + v2CompleteMultipartUploadBodySchema, + v2CompletePutUploadBodySchema, +]) export type V2CompleteUploadBody = z.input +export const v2PutUploadTransferSchema = z + .object({ + method: z.literal('put'), + url: z.string().url(), + headers: z.record(z.string(), z.string()), + }) + .strict() +export type V2PutUploadTransfer = z.output + +export const v2MultipartUploadTransferSchema = z + .object({ + method: z.literal('multipart'), + partSize: z.number().int().positive(), + partCount: z.number().int().positive().max(640), + }) + .strict() +export type V2MultipartUploadTransfer = z.output + +export const v2UploadTransferSchema = z.discriminatedUnion('method', [ + v2PutUploadTransferSchema, + v2MultipartUploadTransferSchema, +]) +export type V2UploadTransfer = z.output + export const v2PartUrlsBodySchema = z .object({ partNumbers: z.array(z.number().int().min(1)).min(1).max(100), diff --git a/apps/sim/lib/api/contracts/workspace-files.ts b/apps/sim/lib/api/contracts/workspace-files.ts index 1351e5f9527..e917e7f5ffb 100644 --- a/apps/sim/lib/api/contracts/workspace-files.ts +++ b/apps/sim/lib/api/contracts/workspace-files.ts @@ -1,5 +1,10 @@ import { z } from 'zod' -import { inlineFileRefQuerySchema } from '@/lib/api/contracts/primitives' +import { + folderIdSchema, + inlineFileRefQuerySchema, + isCanonicalBase64, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { shareRecordSchema } from '@/lib/api/contracts/public-shares' import { defineRouteContract } from '@/lib/api/contracts/types' @@ -12,7 +17,7 @@ import { defineRouteContract } from '@/lib/api/contracts/types' export const workspaceFileScopeSchema = z.enum(['active', 'archived']) export const workspaceFilesParamsSchema = z.object({ - id: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'), + id: workspaceIdSchema, }) export const workspaceFileParamsSchema = workspaceFilesParamsSchema.extend({ @@ -38,10 +43,11 @@ export const getInlineWorkspaceFileContract = defineRouteContract({ }, }) -const workspaceFileNameSchema = z +export const workspaceFileNameSchema = z .string({ error: 'Name is required' }) .trim() .min(1, 'Name is required') + .max(255, 'Name is too long') .refine( (name) => name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\'), 'Name cannot contain path separators or dot segments' @@ -51,10 +57,46 @@ export const renameWorkspaceFileBodySchema = z.object({ name: workspaceFileNameSchema, }) -export const updateWorkspaceFileContentBodySchema = z.object({ - content: z.string(), - encoding: z.enum(['base64', 'utf-8']).optional(), -}) +export const updateWorkspaceFileContentBodySchema = z + .object({ + content: z.string().max(70_000_000, 'Content is too large'), + encoding: z.enum(['base64', 'utf-8']).optional(), + }) + .superRefine(({ content, encoding }, ctx) => { + if (encoding === 'base64' && !isCanonicalBase64(content)) { + ctx.addIssue({ + code: 'custom', + path: ['content'], + message: 'Content must be valid base64', + }) + } + }) + +export const createWorkspaceFileBodySchema = z + .object({ + name: workspaceFileNameSchema, + contentType: z + .string() + .trim() + .min(1, 'Content type cannot be empty') + .max(255, 'Content type is too long') + .optional(), + folderId: folderIdSchema.optional(), + content: z.string().max(70_000_000, 'Content is too large').default(''), + encoding: z.enum(['utf-8', 'base64']).default('utf-8'), + }) + .superRefine(({ content, encoding }, ctx) => { + if (encoding === 'base64' && !isCanonicalBase64(content)) { + ctx.addIssue({ + code: 'custom', + path: ['content'], + message: 'Content must be valid base64', + }) + } + }) + .strict() + +export type CreateWorkspaceFileBody = z.input export const workspaceFileRecordSchema = z.object({ id: z.string(), @@ -96,6 +138,19 @@ export const listWorkspaceFilesContract = defineRouteContract({ }, }) +export const createWorkspaceFileContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/files', + params: workspaceFilesParamsSchema, + body: createWorkspaceFileBodySchema, + response: { + mode: 'json', + schema: workspaceFileSuccessSchema.extend({ + file: workspaceFileRecordSchema, + }), + }, +}) + export const renameWorkspaceFileContract = defineRouteContract({ method: 'PATCH', path: '/api/workspaces/[id]/files/[fileId]', @@ -195,78 +250,3 @@ export const workspaceFileCompiledCheckContract = defineRouteContract({ schema: compiledCheckResponseSchema, }, }) - -export const workspacePresignedUploadBodySchema = z.object({ - fileName: workspaceFileNameSchema, - contentType: z.string().min(1, 'contentType is required'), - fileSize: z.number().nonnegative('fileSize must be a non-negative number'), - folderId: z.string().nullable().optional(), -}) - -export type WorkspacePresignedUploadBody = z.input - -const workspacePresignedFileInfoSchema = z.object({ - path: z.string(), - key: z.string(), - name: z.string(), - size: z.number(), - type: z.string(), -}) - -const workspacePresignedUploadResponseSchema = z.object({ - fileName: z.string(), - presignedUrl: z.string(), - fileInfo: workspacePresignedFileInfoSchema, - uploadHeaders: z.record(z.string(), z.string()).optional(), - directUploadSupported: z.boolean(), -}) - -export const workspacePresignedUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/workspaces/[id]/files/presigned', - params: workspaceFilesParamsSchema, - body: workspacePresignedUploadBodySchema, - response: { - mode: 'json', - schema: workspacePresignedUploadResponseSchema, - }, -}) - -export const registerWorkspaceFileBodySchema = z.object({ - key: z.string().min(1, 'key is required'), - name: workspaceFileNameSchema, - contentType: z.string().min(1, 'contentType is required'), - folderId: z.string().nullable().optional(), -}) - -export type RegisterWorkspaceFileBody = z.input - -const registeredWorkspaceFileSchema = z.object({ - id: z.string(), - name: z.string(), - url: z.string(), - size: z.number(), - type: z.string(), - key: z.string(), - context: z.string().optional(), -}) - -const registerWorkspaceFileResponseSchema = z.object({ - success: z.boolean(), - file: registeredWorkspaceFileSchema.optional(), - error: z.string().optional(), - isDuplicate: z.boolean().optional(), -}) - -export type RegisterWorkspaceFileResponse = z.output - -export const registerWorkspaceFileContract = defineRouteContract({ - method: 'POST', - path: '/api/workspaces/[id]/files/register', - params: workspaceFilesParamsSchema, - body: registerWorkspaceFileBodySchema, - response: { - mode: 'json', - schema: registerWorkspaceFileResponseSchema, - }, -}) diff --git a/apps/sim/lib/table/import-runner.test.ts b/apps/sim/lib/table/import-runner.test.ts index df66a5e0984..b0fae115c83 100644 --- a/apps/sim/lib/table/import-runner.test.ts +++ b/apps/sim/lib/table/import-runner.test.ts @@ -59,6 +59,7 @@ vi.mock('@/app/api/table/utils', () => ({ normalizeColumn: (col: unknown) => col, })) +import { CSV_MAX_BATCH_SIZE_BYTES } from '@/lib/table/import' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' const table = { @@ -136,4 +137,27 @@ describe('runTableImport source-file cleanup', () => { expect(mockMarkJobReady).toHaveBeenCalled() expect(mockDeleteFile).not.toHaveBeenCalled() }) + + it('flushes retained records before the serialized batch byte budget is exceeded', async () => { + const cell = 'x'.repeat(390 * 1024) + const csv = `name\n${Array.from({ length: 14 }, () => cell).join('\n')}\n` + mockHeadObject.mockResolvedValue({ size: Buffer.byteLength(csv) }) + mockDownloadFileStream.mockResolvedValue(Readable.from(csv)) + mockBulkInsertImportBatch.mockImplementation(async ({ rows }) => ({ + inserted: rows.length, + lastOrderKey: 'a1', + })) + + await runTableImport(buildPayload()) + + expect(mockBulkInsertImportBatch).toHaveBeenCalledTimes(2) + for (const [input] of mockBulkInsertImportBatch.mock.calls) { + const retainedBytes = input.rows.reduce( + (total: number, row: Record) => + total + Buffer.byteLength(JSON.stringify(row), 'utf8'), + 0 + ) + expect(retainedBytes).toBeLessThanOrEqual(CSV_MAX_BATCH_SIZE_BYTES) + } + }) }) diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index b0eba05c14d..669fe7169ee 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -7,6 +7,7 @@ import { captureServerEvent } from '@/lib/posthog/server' import { buildAutoMapping, CSV_MAX_BATCH_SIZE, + CSV_MAX_BATCH_SIZE_BYTES, CSV_SCHEMA_SAMPLE_SIZE, type CsvHeaderMapping, coerceRowsForTable, @@ -178,8 +179,10 @@ export async function runTableImport(payload: TableImportPayload): Promise let headerToColumn: Map | null = null let inserted = 0 let lastReported = 0 - const sample: Record[] = [] + let sample: Record[] = [] + let sampleBytes = 0 let batch: Record[] = [] + let batchBytes = 0 /** * Resolve the schema + header→column mapping from the buffered sample (runs once). @@ -311,19 +314,43 @@ export async function runTableImport(payload: TableImportPayload): Promise let ready = false for await (const record of parser as AsyncIterable>) { + const recordBytes = Buffer.byteLength(JSON.stringify(record), 'utf8') + if (recordBytes > CSV_MAX_BATCH_SIZE_BYTES) { + throw new Error(`CSV record exceeds ${CSV_MAX_BATCH_SIZE_BYTES} serialized bytes`) + } + if (!ready) { - sample.push(record) - if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE) { + if (sample.length > 0 && sampleBytes + recordBytes > CSV_MAX_BATCH_SIZE_BYTES) { await resolveSetup() await flush(sample) + sample = [] + sampleBytes = 0 ready = true + } else { + sample.push(record) + sampleBytes += recordBytes + if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE || sampleBytes >= CSV_MAX_BATCH_SIZE_BYTES) { + await resolveSetup() + await flush(sample) + sample = [] + sampleBytes = 0 + ready = true + } + continue } - continue + } + + if (batch.length > 0 && batchBytes + recordBytes > CSV_MAX_BATCH_SIZE_BYTES) { + await flush(batch) + batch = [] + batchBytes = 0 } batch.push(record) - if (batch.length >= CSV_MAX_BATCH_SIZE) { + batchBytes += recordBytes + if (batch.length >= CSV_MAX_BATCH_SIZE || batchBytes >= CSV_MAX_BATCH_SIZE_BYTES) { await flush(batch) batch = [] + batchBytes = 0 } } diff --git a/apps/sim/lib/table/import.test.ts b/apps/sim/lib/table/import.test.ts index 4ef9a6ef375..f58565ee118 100644 --- a/apps/sim/lib/table/import.test.ts +++ b/apps/sim/lib/table/import.test.ts @@ -8,6 +8,7 @@ import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' import { buildAutoMapping, CSV_DELIMITER_SNIFF_BYTES, + CSV_MAX_RECORD_SIZE_BYTES, CsvImportValidationError, coerceRowsForTable, coerceValue, @@ -336,12 +337,24 @@ describe('import', () => { const rows = await parseViaStream('name,age\nAlice,30\n') expect(Object.keys(rows[0])).toEqual(['name', 'age']) }) + + it('rejects a record larger than the parser byte budget', async () => { + const oversizedValue = 'x'.repeat(CSV_MAX_RECORD_SIZE_BYTES * 2) + + await expect(parseViaStream(`value\n${oversizedValue}\n`)).rejects.toThrow( + new RegExp(`maximum number of tolerated bytes of ${CSV_MAX_RECORD_SIZE_BYTES}`) + ) + }) }) describe('csvParseOptions', () => { it('sets bom and the delimiter, with a header-capturing columns callback', () => { const options = csvParseOptions('\t') - expect(options).toMatchObject({ bom: true, delimiter: '\t' }) + expect(options).toMatchObject({ + bom: true, + delimiter: '\t', + max_record_size: CSV_MAX_RECORD_SIZE_BYTES, + }) expect(typeof options.columns).toBe('function') }) diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index 3759e4eefe0..e6f597e67b7 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -34,6 +34,9 @@ export type CsvDelimiter = (typeof CSV_DELIMITER_CANDIDATES)[number] */ export const CSV_DELIMITER_SNIFF_BYTES = 64 * 1024 +/** Maximum characters buffered for one CSV record before parsing fails. */ +export const CSV_MAX_RECORD_SIZE_BYTES = 1024 * 1024 + /** * Single source of truth for the `csv-parse` options used by both the buffered * sync parser and the streaming parser. @@ -62,9 +65,13 @@ export function csvParseOptions( relax_column_count: true, relax_quotes: true, skip_records_with_error: true, + on_skip(error) { + if (error?.code === 'CSV_MAX_RECORD_SIZE') throw error + }, cast: false, bom: true, delimiter, + max_record_size: CSV_MAX_RECORD_SIZE_BYTES, } } @@ -220,9 +227,14 @@ export const CSV_SCHEMA_SAMPLE_SIZE = 100 */ export const CSV_MAX_BATCH_SIZE = 5000 +/** Maximum serialized CSV row data retained before an import batch is flushed. */ +export const CSV_MAX_BATCH_SIZE_BYTES = 5 * 1024 * 1024 + /** Maximum CSV/TSV file size accepted by import routes (25 MB). */ export const CSV_MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024 +export const CSV_MAX_FILE_SIZE_MESSAGE = `File exceeds maximum allowed size of ${CSV_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB` + /** * Error thrown when the user-supplied mapping or CSV does not line up with the * target table. Callers should translate this into a 400 response. diff --git a/apps/sim/lib/table/orchestration/import-resource.test.ts b/apps/sim/lib/table/orchestration/import-resource.test.ts new file mode 100644 index 00000000000..b281965477d --- /dev/null +++ b/apps/sim/lib/table/orchestration/import-resource.test.ts @@ -0,0 +1,191 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCreateTable, + mockCreateUploadSession, + mockDbLimit, + mockGetUserEntityPermissions, + mockGetUserSettings, + mockGetWorkspaceFile, + mockGetWorkspaceTableLimits, + mockRunDetached, +} = vi.hoisted(() => ({ + mockCreateTable: vi.fn(), + mockCreateUploadSession: vi.fn(), + mockDbLimit: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetUserSettings: vi.fn(), + mockGetWorkspaceFile: vi.fn(), + mockGetWorkspaceTableLimits: vi.fn(), + mockRunDetached: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ + from: () => ({ + where: () => ({ limit: mockDbLimit }), + }), + }), + }, +})) +vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: false })) +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', () => ({ + createTable: mockCreateTable, + getTableById: vi.fn(), +})) +vi.mock('@/lib/uploads/contexts/workspace', () => ({ getWorkspaceFile: mockGetWorkspaceFile })) +vi.mock('@/lib/uploads/upload-session/service', () => ({ + abortUploadSession: vi.fn(), + createUploadSession: mockCreateUploadSession, + getOwnedUploadSession: vi.fn(), +})) +vi.mock('@/lib/users/queries', () => ({ getUserSettings: mockGetUserSettings })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' +import { createTableImportResource } from '@/lib/table/orchestration/import-resource' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const SOURCE = { type: 'workspace_file' as const, fileId: 'file-1' } +const TARGET = { type: 'new' as const, name: 'imported_data' } + +function workspaceFile(size: number) { + return { + id: 'file-1', + workspaceId: WORKSPACE_ID, + name: 'data.csv', + key: 'workspace/data.csv', + path: '/api/files/serve/workspace/data.csv', + size, + type: 'text/csv', + uploadedBy: 'user-1', + uploadedAt: new Date('2026-08-04T12:00:00.000Z'), + updatedAt: new Date('2026-08-04T12:00:00.000Z'), + } +} + +describe('createTableImportResource workspace file size', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetUserEntityPermissions.mockResolvedValue('write') + mockGetWorkspaceTableLimits.mockResolvedValue({ maxTables: 100, maxRowsPerTable: 10_000 }) + mockCreateTable.mockResolvedValue({ id: 'table-1' }) + mockGetUserSettings.mockResolvedValue({ timezone: 'UTC' }) + mockDbLimit.mockResolvedValue([ + { + id: 'import-1', + workspaceId: WORKSPACE_ID, + tableId: 'table-1', + type: 'import', + status: 'running', + rowsProcessed: 0, + error: null, + payload: { + kind: 'table_import', + userId: 'user-1', + source: SOURCE, + target: TARGET, + options: {}, + }, + startedAt: new Date('2026-08-04T12:00:00.000Z'), + updatedAt: new Date('2026-08-04T12:00:00.000Z'), + completedAt: null, + }, + ]) + }) + + it('accepts a workspace CSV at the exact byte limit', async () => { + mockGetWorkspaceFile.mockResolvedValue(workspaceFile(CSV_MAX_FILE_SIZE_BYTES)) + + const result = await createTableImportResource( + { workspaceId: WORKSPACE_ID, source: SOURCE, target: TARGET }, + 'user-1', + 'http://localhost:3000' + ) + + expect(result.upload).toBeNull() + expect(mockCreateTable).toHaveBeenCalledOnce() + expect(mockRunDetached).toHaveBeenCalledOnce() + }) + + it('rejects a workspace CSV one byte over the limit before creating a table', async () => { + mockGetWorkspaceFile.mockResolvedValue(workspaceFile(CSV_MAX_FILE_SIZE_BYTES + 1)) + + await expect( + createTableImportResource( + { workspaceId: WORKSPACE_ID, source: SOURCE, target: TARGET }, + 'user-1', + 'http://localhost:3000' + ) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mockCreateTable).not.toHaveBeenCalled() + expect(mockRunDetached).not.toHaveBeenCalled() + }) +}) + +describe('createTableImportResource upload size', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetUserEntityPermissions.mockResolvedValue('write') + 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('creates an upload session for a CSV at the exact byte limit', async () => { + await createTableImportResource( + { + workspaceId: WORKSPACE_ID, + source: { + type: 'upload', + name: 'data.csv', + contentType: 'text/csv', + size: CSV_MAX_FILE_SIZE_BYTES, + }, + target: TARGET, + }, + 'user-1', + 'http://localhost:3000' + ) + + expect(mockCreateUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ fileSize: CSV_MAX_FILE_SIZE_BYTES, purpose: 'table_import' }) + ) + }) + + it('rejects an upload one byte over the limit before creating a session', async () => { + await expect( + createTableImportResource( + { + workspaceId: WORKSPACE_ID, + source: { + type: 'upload', + name: 'data.csv', + contentType: 'text/csv', + size: CSV_MAX_FILE_SIZE_BYTES + 1, + }, + target: TARGET, + }, + 'user-1', + 'http://localhost:3000' + ) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mockCreateUploadSession).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts index 699ac1ead3f..2f1e88ef8d1 100644 --- a/apps/sim/lib/table/orchestration/import-resource.ts +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -5,10 +5,12 @@ import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type V2CreateTableImportBody, + type V2CreateTableImportData, type V2TableImport, type V2TableImportSource, type V2TableImportTarget, v2CreateTableImportBodySchema, + v2CreateTableImportDataSchema, v2TableImportSourceSchema, v2TableImportTargetSchema, } from '@/lib/api/contracts/v2/tables' @@ -18,6 +20,7 @@ import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { findActiveFolder } from '@/lib/folders/queries' import { getWorkspaceTableLimits } from '@/lib/table/billing' +import { CSV_MAX_FILE_SIZE_BYTES, CSV_MAX_FILE_SIZE_MESSAGE } from '@/lib/table/import' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { markJobCanceled, markJobFailed, markTableJobRunning } from '@/lib/table/jobs/service' import { assertRowDelete, assertRowInsert } from '@/lib/table/mutation-locks' @@ -26,10 +29,11 @@ import type { TableImportJobPayload } from '@/lib/table/types' import { getWorkspaceFile, type WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { abortUploadSession, + type CreatedUploadSession, createUploadSession, getOwnedUploadSession, type UploadSessionRecord, -} from '@/lib/uploads/multipart-session/service' +} from '@/lib/uploads/upload-session/service' import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -46,7 +50,6 @@ interface TableImportResource { status: TableImportStatus rowsProcessed: number error: string | null - upload: UploadSessionRecord | null createdAt: Date updatedAt: Date completedAt: Date | null @@ -54,12 +57,13 @@ interface TableImportResource { interface CreateTableImportResult { record: TableImportResource - upload: UploadSessionRecord | null + upload: CreatedUploadSession | null } export async function createTableImportResource( body: V2CreateTableImportBody, - userId: string + userId: string, + localOrigin: string ): Promise { await assertWorkspaceWrite(userId, body.workspaceId) await validateTarget(body.workspaceId, body.target) @@ -68,6 +72,9 @@ export async function createTableImportResource( if (body.source.type === 'upload') { assertCsvFileName(body.source.name) + if (body.source.size > CSV_MAX_FILE_SIZE_BYTES) { + throw new OrchestrationError('validation', CSV_MAX_FILE_SIZE_MESSAGE) + } const upload = await createUploadSession({ id: importId, workspaceId: body.workspaceId, @@ -77,6 +84,7 @@ export async function createTableImportResource( contentType: body.source.contentType, fileSize: body.source.size, metadata: { tableImport: body }, + localOrigin, }) return { record: resourceFromUpload(upload, body), upload } } @@ -104,15 +112,16 @@ export async function startUploadedTableImport( upload: UploadSessionRecord ): Promise { const body = tableImportBodyFromUpload(upload) + const workspaceId = body.workspaceId const existing = await findOwnedTableImport({ importId: upload.id, - workspaceId: upload.workspaceId, + workspaceId, userId: upload.userId, }) if (existing) return existing return startTableImport({ id: upload.id, - workspaceId: upload.workspaceId, + workspaceId, userId: upload.userId, source: body.source, target: body.target, @@ -192,7 +201,6 @@ export async function findOwnedTableImport(params: { status: tableImportStatus(job.status), rowsProcessed: job.rowsProcessed, error: job.error, - upload: null, createdAt: job.startedAt, updatedAt: job.updatedAt, completedAt: job.completedAt, @@ -224,20 +232,20 @@ export function toV2TableImport(record: TableImportResource): V2TableImport { tableId: record.tableId, rowsProcessed: record.rowsProcessed, error: record.error, - upload: record.upload - ? { - uploadToken: record.upload.uploadToken, - partSize: record.upload.partSize, - partCount: record.upload.partCount, - expiresAt: record.upload.expiresAt.toISOString(), - } - : null, createdAt: record.createdAt.toISOString(), updatedAt: record.updatedAt.toISOString(), completedAt: record.completedAt?.toISOString() ?? null, } } +export function toV2CreateTableImport(result: CreateTableImportResult): V2CreateTableImportData { + return v2CreateTableImportDataSchema.parse({ + session: toV2TableImport(result.record), + uploadToken: result.upload?.uploadToken ?? null, + transfer: result.upload?.transfer ?? null, + }) +} + interface StartTableImportParams { id: string workspaceId: string @@ -340,7 +348,7 @@ function resourceFromUpload( ): TableImportResource { return { id: upload.id, - workspaceId: upload.workspaceId, + workspaceId: body.workspaceId, userId: upload.userId, source: body.source, target: body.target, @@ -349,7 +357,6 @@ function resourceFromUpload( status: upload.status === 'aborted' ? 'canceled' : 'uploading', rowsProcessed: 0, error: null, - upload, createdAt: upload.createdAt, updatedAt: upload.updatedAt, completedAt: upload.completedAt, @@ -425,6 +432,9 @@ async function requireWorkspaceSource( ): Promise { const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) if (!file) throw new OrchestrationError('not_found', 'Workspace file not found') + if (file.size > CSV_MAX_FILE_SIZE_BYTES) { + throw new OrchestrationError('validation', CSV_MAX_FILE_SIZE_MESSAGE) + } return file } diff --git a/apps/sim/lib/uploads/client/admission.test.ts b/apps/sim/lib/uploads/client/admission.test.ts new file mode 100644 index 00000000000..889678594e6 --- /dev/null +++ b/apps/sim/lib/uploads/client/admission.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { + assertMultiFileUploadAdmission, + MULTI_FILE_UPLOAD_MAX_FILE_BYTES, + MULTI_FILE_UPLOAD_MAX_FILES, + MULTI_FILE_UPLOAD_MAX_TOTAL_BYTES, + type MultiFileUploadAdmissionError, +} from '@/lib/uploads/client/admission' + +function files(count: number, size: number) { + return Array.from({ length: count }, (_, index) => ({ name: `file-${index}.bin`, size })) +} + +describe('multi-file upload admission', () => { + it('accepts the exact aggregate-byte boundary', () => { + expect(() => + assertMultiFileUploadAdmission(files(5, MULTI_FILE_UPLOAD_MAX_FILE_BYTES)) + ).not.toThrow() + expect(MULTI_FILE_UPLOAD_MAX_TOTAL_BYTES).toBe(5 * MULTI_FILE_UPLOAD_MAX_FILE_BYTES) + }) + + it('rejects a selection whose combined count exceeds the action cap', () => { + expect(() => + assertMultiFileUploadAdmission([{ name: 'new.bin', size: 1 }], { + existingFiles: files(MULTI_FILE_UPLOAD_MAX_FILES, 1), + }) + ).toThrow( + expect.objectContaining>({ + code: 'UPLOAD_FILE_COUNT_EXCEEDED', + }) + ) + }) + + it('rejects one file above the shared per-file ceiling', () => { + expect(() => + assertMultiFileUploadAdmission([ + { name: 'oversized.bin', size: MULTI_FILE_UPLOAD_MAX_FILE_BYTES + 1 }, + ]) + ).toThrow( + expect.objectContaining>({ + code: 'UPLOAD_FILE_SIZE_EXCEEDED', + }) + ) + }) + + it('rejects aggregate bytes across existing and newly selected files', () => { + expect(() => + assertMultiFileUploadAdmission(files(1, MULTI_FILE_UPLOAD_MAX_FILE_BYTES), { + existingFiles: files(5, MULTI_FILE_UPLOAD_MAX_FILE_BYTES), + }) + ).toThrow( + expect.objectContaining>({ + code: 'UPLOAD_TOTAL_SIZE_EXCEEDED', + }) + ) + }) + + it('supports a larger direct-to-storage limit without weakening aggregate admission', () => { + expect(() => + assertMultiFileUploadAdmission([{ name: 'archive.zip', size: 1024 }], { + maxFileBytes: 1024, + maxTotalBytes: 2048, + }) + ).not.toThrow() + + expect(() => + assertMultiFileUploadAdmission(files(3, 1024), { + maxFileBytes: 1024, + maxTotalBytes: 2048, + }) + ).toThrow( + expect.objectContaining>({ + code: 'UPLOAD_TOTAL_SIZE_EXCEEDED', + }) + ) + }) +}) diff --git a/apps/sim/lib/uploads/client/admission.ts b/apps/sim/lib/uploads/client/admission.ts new file mode 100644 index 00000000000..670a570a2fd --- /dev/null +++ b/apps/sim/lib/uploads/client/admission.ts @@ -0,0 +1,102 @@ +import { + MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE, + MAX_WORKSPACE_FORMDATA_FILE_SIZE, +} from '@/lib/uploads/shared/types' + +export const MULTI_FILE_UPLOAD_MAX_FILES = 20 +const MULTI_FILE_UPLOAD_MAX_TOTAL_FILE_EQUIVALENTS = 5 +export const MULTI_FILE_UPLOAD_MAX_FILE_BYTES = Math.min( + MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE, + MAX_WORKSPACE_FORMDATA_FILE_SIZE +) +export const MULTI_FILE_UPLOAD_MAX_TOTAL_BYTES = + MULTI_FILE_UPLOAD_MAX_TOTAL_FILE_EQUIVALENTS * MULTI_FILE_UPLOAD_MAX_FILE_BYTES + +export type MultiFileUploadAdmissionErrorCode = + | 'UPLOAD_FILE_COUNT_EXCEEDED' + | 'UPLOAD_FILE_SIZE_EXCEEDED' + | 'UPLOAD_TOTAL_SIZE_EXCEEDED' + +export class MultiFileUploadAdmissionError extends Error { + constructor( + message: string, + readonly code: MultiFileUploadAdmissionErrorCode + ) { + super(message) + this.name = 'MultiFileUploadAdmissionError' + } +} + +interface UploadAdmissionFile { + readonly name?: string + readonly size: number +} + +interface MultiFileUploadAdmissionOptions { + existingFiles?: ArrayLike + maxFileBytes?: number + maxTotalBytes?: number +} + +/** + * Bounds one user upload action before previews, UI rows, or upload sessions are allocated. + * Knowledge uploads use the shared 100 MiB / 500 MiB defaults. Direct-to-storage consumers may + * provide their larger server-side limit while retaining the same count and aggregate bounds. + */ +export function assertMultiFileUploadAdmission( + files: ArrayLike, + options: MultiFileUploadAdmissionOptions = {} +): void { + const existingFiles = options.existingFiles + const maxFileBytes = options.maxFileBytes ?? MULTI_FILE_UPLOAD_MAX_FILE_BYTES + const maxTotalBytes = + options.maxTotalBytes ?? MULTI_FILE_UPLOAD_MAX_TOTAL_FILE_EQUIVALENTS * maxFileBytes + if (!Number.isSafeInteger(maxFileBytes) || maxFileBytes < 1) { + throw new Error('Invalid per-file upload limit') + } + if (!Number.isSafeInteger(maxTotalBytes) || maxTotalBytes < maxFileBytes) { + throw new Error('Invalid aggregate upload limit') + } + const existingCount = existingFiles?.length ?? 0 + const totalCount = existingCount + files.length + if (totalCount > MULTI_FILE_UPLOAD_MAX_FILES) { + throw new MultiFileUploadAdmissionError( + `Select up to ${MULTI_FILE_UPLOAD_MAX_FILES} files at a time.`, + 'UPLOAD_FILE_COUNT_EXCEEDED' + ) + } + + let totalBytes = 0 + const groups = existingFiles ? [existingFiles, files] : [files] + for (const group of groups) { + for (let index = 0; index < group.length; index++) { + const file = group[index] + if (!file || !Number.isSafeInteger(file.size) || file.size < 0) { + throw new Error('Invalid file size in upload selection') + } + if (file.size > maxFileBytes) { + const label = file.name ? `"${file.name}"` : 'A selected file' + throw new MultiFileUploadAdmissionError( + `${label} is too large. Each file must be ${formatBinaryBytes(maxFileBytes)} or smaller.`, + 'UPLOAD_FILE_SIZE_EXCEEDED' + ) + } + totalBytes += file.size + } + } + + if (totalBytes > maxTotalBytes) { + throw new MultiFileUploadAdmissionError( + `Select files totaling ${formatBinaryBytes(maxTotalBytes)} or less.`, + 'UPLOAD_TOTAL_SIZE_EXCEEDED' + ) + } +} + +function formatBinaryBytes(bytes: number): string { + const gibibyte = 1024 ** 3 + if (bytes % gibibyte === 0) return `${bytes / gibibyte} GiB` + const mebibyte = 1024 ** 2 + if (bytes % mebibyte === 0) return `${bytes / mebibyte} MiB` + return `${bytes} bytes` +} diff --git a/apps/sim/lib/uploads/client/api-fallback.test.ts b/apps/sim/lib/uploads/client/api-fallback.test.ts deleted file mode 100644 index 0c0d4196144..00000000000 --- a/apps/sim/lib/uploads/client/api-fallback.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { uploadViaApiFallbackWithMetadata } from '@/lib/uploads/client/api-fallback' - -const mockFetch = vi.fn() - -describe('uploadViaApiFallbackWithMetadata', () => { - beforeEach(() => { - mockFetch.mockReset() - vi.stubGlobal('fetch', mockFetch) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('builds execution multipart fields and normalizes an array response', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - files: [ - { - id: 'file-1', - name: 'diagram.png', - url: '/api/files/serve/execution%2Fdiagram.png', - size: 7, - type: 'image/png', - key: 'execution/diagram.png', - }, - ], - }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ) - ) - const file = new File(['diagram'], 'diagram.png', { type: 'image/png' }) - - const result = await uploadViaApiFallbackWithMetadata(file, 'execution', { - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - }) - - expect(result).toEqual({ - id: 'file-1', - name: 'diagram.png', - path: '/api/files/serve/execution%2Fdiagram.png', - size: 7, - type: 'image/png', - key: 'execution/diagram.png', - }) - - const request = mockFetch.mock.calls[0]?.[1] as RequestInit - const formData = request.body as FormData - expect(formData.get('file')).toBe(file) - expect(formData.get('context')).toBe('execution') - expect(formData.get('workspaceId')).toBe('workspace-1') - expect(formData.get('workflowId')).toBe('workflow-1') - expect(formData.get('executionId')).toBe('execution-1') - }) - - it('throws the exact server upload error', async () => { - mockFetch.mockResolvedValue( - new Response(JSON.stringify({ error: 'Workspace file storage limit exceeded' }), { - status: 413, - headers: { 'Content-Type': 'application/json' }, - }) - ) - - await expect( - uploadViaApiFallbackWithMetadata( - new File(['report'], 'report.pdf', { type: 'application/pdf' }), - 'execution', - { - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - } - ) - ).rejects.toThrow('Workspace file storage limit exceeded') - }) -}) diff --git a/apps/sim/lib/uploads/client/api-fallback.ts b/apps/sim/lib/uploads/client/api-fallback.ts deleted file mode 100644 index 2281fd05e61..00000000000 --- a/apps/sim/lib/uploads/client/api-fallback.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { isRecordLike } from '@sim/utils/object' -import type { StorageContext } from '@/lib/uploads/shared/types' - -export interface ApiFallbackUploadOptions { - workspaceId?: string - workflowId?: string - executionId?: string -} - -export interface ApiFallbackUploadMetadata { - path: string - key?: string - id?: string - name?: string - size?: number - type?: string - uploadedAt?: string - expiresAt?: string -} - -function getOptionalString(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined - const trimmed = value.trim() - return trimmed || undefined -} - -function getOptionalNumber(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined -} - -function getUploadFailureReason(value: unknown, response: Response): string { - if (isRecordLike(value)) { - const message = getOptionalString(value.message) ?? getOptionalString(value.error) - if (message) return message - } - - return `Failed to upload file: ${response.status}` -} - -function normalizeUploadMetadata(value: unknown): ApiFallbackUploadMetadata { - if (!isRecordLike(value)) { - throw new Error('Invalid upload response: expected file metadata') - } - - const fileInfo = isRecordLike(value.fileInfo) ? value.fileInfo : undefined - const path = - getOptionalString(fileInfo?.path) ?? - getOptionalString(value.path) ?? - getOptionalString(value.url) - if (!path) { - throw new Error('Invalid upload response: missing path') - } - - return { - path, - key: getOptionalString(fileInfo?.key) ?? getOptionalString(value.key), - id: getOptionalString(fileInfo?.id) ?? getOptionalString(value.id), - name: - getOptionalString(fileInfo?.name) ?? - getOptionalString(value.name) ?? - getOptionalString(value.fileName), - size: getOptionalNumber(fileInfo?.size) ?? getOptionalNumber(value.size), - type: getOptionalString(fileInfo?.type) ?? getOptionalString(value.type), - uploadedAt: getOptionalString(fileInfo?.uploadedAt) ?? getOptionalString(value.uploadedAt), - expiresAt: getOptionalString(fileInfo?.expiresAt) ?? getOptionalString(value.expiresAt), - } -} - -async function parseUploadResponse(response: Response): Promise { - if (!response.ok) { - const errorData: unknown = await response.json().catch(() => null) - throw new Error(getUploadFailureReason(errorData, response)) - } - - let data: unknown - try { - data = await response.json() - } catch { - throw new Error('Invalid upload response: response was not JSON') - } - - const results = isRecordLike(data) && Array.isArray(data.files) ? data.files : [data] - if (results.length === 0) { - throw new Error('Invalid upload response: no files returned') - } - if (results.length > 1) { - throw new Error('Invalid upload response: multiple files returned for a single-file upload') - } - - return normalizeUploadMetadata(results[0]) -} - -/** - * Uploads one file through the server-proxied multipart fallback and returns - * normalized metadata for either the singular or `{ files: [...] }` response. - */ -export async function uploadViaApiFallbackWithMetadata( - file: File, - context: StorageContext, - options: ApiFallbackUploadOptions = {} -): Promise { - const formData = new FormData() - formData.append('file', file) - formData.append('context', context) - if (options.workspaceId) { - formData.append('workspaceId', options.workspaceId) - } - if (options.workflowId) { - formData.append('workflowId', options.workflowId) - } - if (options.executionId) { - formData.append('executionId', options.executionId) - } - - // boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson - const response = await fetch('/api/files/upload', { method: 'POST', body: formData }) - return parseUploadResponse(response) -} - -/** - * Server-proxied fallback used only when cloud storage isn't configured (local dev). - * Production always takes the presigned PUT path. - */ -export async function uploadViaApiFallback( - file: File, - context: StorageContext, - workspaceId?: string -): Promise<{ path: string; key?: string }> { - const { path, key } = await uploadViaApiFallbackWithMetadata(file, context, { workspaceId }) - return { path, key } -} diff --git a/apps/sim/lib/uploads/client/concurrency.test.ts b/apps/sim/lib/uploads/client/concurrency.test.ts new file mode 100644 index 00000000000..b0880ef091d --- /dev/null +++ b/apps/sim/lib/uploads/client/concurrency.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import { runWithConcurrency } from '@/lib/uploads/client/concurrency' + +describe('runWithConcurrency', () => { + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + 'rejects invalid concurrency limit %s', + async (limit) => { + await expect(runWithConcurrency([1], limit, async (item) => item)).rejects.toThrow( + 'Concurrency limit must be a positive safe integer' + ) + } + ) + + it('preserves input order while bounding concurrency', async () => { + let active = 0 + let peak = 0 + const release: Array<() => void> = [] + const worker = vi.fn(async (item: number) => { + active += 1 + peak = Math.max(peak, active) + await new Promise((resolve) => release.push(resolve)) + active -= 1 + return item * 2 + }) + + const resultPromise = runWithConcurrency([1, 2, 3], 2, worker) + await Promise.resolve() + expect(worker).toHaveBeenCalledTimes(2) + + release.shift()?.() + release.shift()?.() + await Promise.resolve() + await Promise.resolve() + expect(worker).toHaveBeenCalledTimes(3) + release.shift()?.() + + await expect(resultPromise).resolves.toEqual([ + { status: 'fulfilled', value: 2 }, + { status: 'fulfilled', value: 4 }, + { status: 'fulfilled', value: 6 }, + ]) + expect(peak).toBe(2) + }) +}) diff --git a/apps/sim/lib/uploads/client/concurrency.ts b/apps/sim/lib/uploads/client/concurrency.ts new file mode 100644 index 00000000000..307e145776f --- /dev/null +++ b/apps/sim/lib/uploads/client/concurrency.ts @@ -0,0 +1,38 @@ +export const WHOLE_FILE_PARALLEL_UPLOADS = 3 + +/** + * Runs a worker with bounded concurrency and preserves input ordering in the settled results. + */ +export async function runWithConcurrency( + items: T[], + limit: number, + worker: (item: T, index: number) => Promise +): Promise>> { + if (!Number.isSafeInteger(limit) || limit <= 0) { + throw new Error('Concurrency limit must be a positive safe integer') + } + + const results: Array> = Array(items.length) + if (items.length === 0) return results + + const concurrency = Math.min(limit, items.length) + let nextIndex = 0 + + const runners = Array.from({ length: concurrency }, async () => { + while (true) { + const currentIndex = nextIndex++ + if (currentIndex >= items.length) return + try { + results[currentIndex] = { + status: 'fulfilled', + value: await worker(items[currentIndex], currentIndex), + } + } catch (error) { + results[currentIndex] = { status: 'rejected', reason: error } + } + } + }) + + await Promise.all(runners) + return results +} diff --git a/apps/sim/lib/uploads/client/direct-upload.test.ts b/apps/sim/lib/uploads/client/direct-upload.test.ts deleted file mode 100644 index da2b2aca65f..00000000000 --- a/apps/sim/lib/uploads/client/direct-upload.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - DirectUploadError, - type PresignedUploadInfo, - runUploadStrategy, -} from '@/lib/uploads/client/direct-upload' - -const ONE_MB = 1024 * 1024 -const LARGE_THRESHOLD = 50 * ONE_MB - -const makeFile = (size: number, name = 'test.bin', type = 'application/octet-stream'): File => { - const file = new File([new Uint8Array(0)], name, { type }) - Object.defineProperty(file, 'size', { value: size }) - return file -} - -const presigned = (overrides?: Partial): PresignedUploadInfo => ({ - fileName: 'test.bin', - presignedUrl: 'https://s3/presigned', - fileInfo: { - path: '/api/files/serve/test', - key: 'workspace/ws-1/test.bin', - name: 'test.bin', - size: ONE_MB, - type: 'application/octet-stream', - }, - uploadHeaders: undefined, - directUploadSupported: true, - ...overrides, -}) - -class MockXHR { - static instances: MockXHR[] = [] - upload = { addEventListener: vi.fn() } - status = 200 - statusText = 'OK' - private listeners: Record void>> = {} - open = vi.fn() - setRequestHeader = vi.fn() - abort = vi.fn() - send = vi.fn(() => { - queueMicrotask(() => this.listeners.load?.forEach((cb) => cb())) - }) - addEventListener = (event: string, cb: () => void) => { - ;(this.listeners[event] ??= []).push(cb) - } - removeEventListener = vi.fn() - constructor() { - MockXHR.instances.push(this) - } -} - -describe('runUploadStrategy', () => { - let originalXHR: typeof XMLHttpRequest - - beforeEach(() => { - MockXHR.instances = [] - originalXHR = globalThis.XMLHttpRequest - globalThis.XMLHttpRequest = MockXHR as unknown as typeof XMLHttpRequest - }) - - afterEach(() => { - globalThis.XMLHttpRequest = originalXHR - vi.restoreAllMocks() - }) - - it('uses presigned PUT for files at or below the multipart threshold', async () => { - const file = makeFile(LARGE_THRESHOLD) - - const result = await runUploadStrategy({ - file, - workspaceId: 'ws-1', - context: 'workspace', - presignedOverride: presigned(), - }) - - expect(result.key).toBe('workspace/ws-1/test.bin') - expect(MockXHR.instances).toHaveLength(1) - expect(MockXHR.instances[0].open).toHaveBeenCalledWith('PUT', 'https://s3/presigned') - }) - - it('sets Content-Type exactly once when uploadHeaders already carry it (GCS signed uploads)', async () => { - const file = makeFile(1024) - - await runUploadStrategy({ - file, - workspaceId: 'ws-1', - context: 'workspace', - presignedOverride: presigned({ - uploadHeaders: { - 'Content-Type': 'application/octet-stream', - 'x-goog-meta-workspaceid': 'ws-1', - }, - }), - }) - - const calls = MockXHR.instances[0].setRequestHeader.mock.calls - const contentTypeCalls = calls.filter( - ([k]: [string, string]) => k.toLowerCase() === 'content-type' - ) - expect(contentTypeCalls).toHaveLength(1) - expect(contentTypeCalls[0][1]).toBe('application/octet-stream') - expect(calls.some(([k]: [string, string]) => k === 'x-goog-meta-workspaceid')).toBe(true) - }) - - it('falls back to the file content type when uploadHeaders omit Content-Type', async () => { - const file = makeFile(1024) - - await runUploadStrategy({ - file, - workspaceId: 'ws-1', - context: 'workspace', - presignedOverride: presigned({ uploadHeaders: { 'x-ms-blob-type': 'BlockBlob' } }), - }) - - const calls = MockXHR.instances[0].setRequestHeader.mock.calls - const contentTypeCalls = calls.filter( - ([k]: [string, string]) => k.toLowerCase() === 'content-type' - ) - expect(contentTypeCalls).toHaveLength(1) - }) - - it('throws FALLBACK_REQUIRED when server signals no cloud storage', async () => { - const file = makeFile(ONE_MB) - - await expect( - runUploadStrategy({ - file, - workspaceId: 'ws-1', - context: 'workspace', - presignedOverride: presigned({ presignedUrl: '', directUploadSupported: false }), - }) - ).rejects.toMatchObject({ - name: 'DirectUploadError', - code: 'FALLBACK_REQUIRED', - }) - }) - - it('takes the multipart path for files larger than the threshold and posts unified parts', async () => { - const file = makeFile(LARGE_THRESHOLD + ONE_MB) - const calls: Array<{ url: string; body: unknown }> = [] - - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - const rawBody = init?.body - const body = typeof rawBody === 'string' ? JSON.parse(rawBody) : undefined - calls.push({ url, body }) - - if (url.includes('action=initiate')) { - return new Response( - JSON.stringify({ uploadId: 'u1', key: 'workspace/ws-1/big.bin', uploadToken: 't' }), - { status: 200 } - ) - } - if (url.includes('action=get-part-urls')) { - return new Response( - JSON.stringify({ - presignedUrls: [ - { partNumber: 1, url: 'https://s3/part1' }, - { partNumber: 2, url: 'https://s3/part2' }, - ], - }), - { status: 200 } - ) - } - if (url.startsWith('https://s3/part')) { - return new Response(null, { status: 200, headers: { ETag: '"etag-x"' } }) - } - if (url.includes('action=complete')) { - return new Response(JSON.stringify({ path: '/api/files/serve/big' }), { status: 200 }) - } - throw new Error(`unexpected url ${url}`) - }) - - vi.stubGlobal('fetch', fetchMock) - - const result = await runUploadStrategy({ - file, - workspaceId: 'ws-1', - context: 'workspace', - }) - - expect(result.path).toBe('/api/files/serve/big') - - const completeCall = calls.find((c) => c.url.includes('action=complete'))! - expect(completeCall.body).toMatchObject({ - uploadToken: 't', - parts: [ - { partNumber: 1, etag: 'etag-x' }, - { partNumber: 2, etag: 'etag-x' }, - ], - }) - }) - - it('rejects with ABORTED when signal is already aborted before PUT begins', async () => { - const file = makeFile(ONE_MB) - const controller = new AbortController() - controller.abort() - - await expect( - runUploadStrategy({ - file, - workspaceId: 'ws-1', - context: 'workspace', - presignedOverride: presigned(), - signal: controller.signal, - }) - ).rejects.toMatchObject({ name: 'DirectUploadError', code: 'ABORTED' }) - }) - - it('fires action=abort when the multipart complete call fails', async () => { - const file = makeFile(LARGE_THRESHOLD + ONE_MB) - const calls: Array<{ url: string }> = [] - - const fetchMock = vi.fn(async (input: RequestInfo | URL) => { - const url = typeof input === 'string' ? input : input.toString() - calls.push({ url }) - - if (url.includes('action=initiate')) { - return new Response( - JSON.stringify({ uploadId: 'u1', key: 'workspace/ws-1/big.bin', uploadToken: 't' }), - { status: 200 } - ) - } - if (url.includes('action=get-part-urls')) { - return new Response( - JSON.stringify({ - presignedUrls: [ - { partNumber: 1, url: 'https://s3/part1' }, - { partNumber: 2, url: 'https://s3/part2' }, - ], - }), - { status: 200 } - ) - } - if (url.startsWith('https://s3/part')) { - return new Response(null, { status: 200, headers: { ETag: '"etag-x"' } }) - } - if (url.includes('action=complete')) { - return new Response(JSON.stringify({ error: 'kaboom' }), { status: 500 }) - } - if (url.includes('action=abort')) { - return new Response(null, { status: 200 }) - } - throw new Error(`unexpected url ${url}`) - }) - - vi.stubGlobal('fetch', fetchMock) - - await expect( - runUploadStrategy({ file, workspaceId: 'ws-1', context: 'workspace' }) - ).rejects.toBeInstanceOf(DirectUploadError) - - expect(calls.some((c) => c.url.includes('action=abort'))).toBe(true) - }) - - it('throws when neither presignedEndpoint nor presignedOverride is supplied', async () => { - const file = makeFile(ONE_MB) - await expect( - runUploadStrategy({ file, workspaceId: 'ws-1', context: 'workspace' }) - ).rejects.toBeInstanceOf(DirectUploadError) - }) -}) diff --git a/apps/sim/lib/uploads/client/direct-upload.ts b/apps/sim/lib/uploads/client/direct-upload.ts deleted file mode 100644 index 41e9911c26d..00000000000 --- a/apps/sim/lib/uploads/client/direct-upload.ts +++ /dev/null @@ -1,637 +0,0 @@ -import { createLogger } from '@sim/logger' -import { sleep } from '@sim/utils/helpers' -import { getFileContentType, isAbortError } from '@/lib/uploads/utils/file-utils' - -const logger = createLogger('DirectUpload') - -const CHUNK_SIZE = 8 * 1024 * 1024 -export const LARGE_FILE_THRESHOLD = 50 * 1024 * 1024 -const BASE_TIMEOUT_MS = 2 * 60 * 1000 -const TIMEOUT_PER_MB_MS = 1500 -const MAX_TIMEOUT_MS = 10 * 60 * 1000 -export const MULTIPART_PART_CONCURRENCY = 3 -export const MULTIPART_MAX_RETRIES = 3 -export const MULTIPART_RETRY_DELAY_MS = 2000 -export const MULTIPART_RETRY_BACKOFF = 2 -export const WHOLE_FILE_PARALLEL_UPLOADS = 3 - -interface PresignedFileInfo { - path: string - key: string - name: string - size: number - type: string -} - -export interface PresignedUploadInfo { - fileName: string - presignedUrl: string - fileInfo: PresignedFileInfo - uploadHeaders?: Record - directUploadSupported: boolean -} - -export interface UploadStrategyResult { - key: string - path: string - name: string - size: number - contentType: string -} - -export interface UploadProgressEvent { - loaded: number - total: number - percent: number -} - -export type DirectUploadErrorCode = - | 'PRESIGNED_URL_ERROR' - | 'DIRECT_UPLOAD_ERROR' - | 'MULTIPART_ERROR' - | 'ABORTED' - | 'FALLBACK_REQUIRED' - -export class DirectUploadError extends Error { - constructor( - message: string, - public code: DirectUploadErrorCode, - public details?: unknown, - public status?: number - ) { - super(message) - this.name = 'DirectUploadError' - } -} - -/** - * Transport-level upload errors worth retrying at the outer level: timeouts, - * network failures, and 5xx from the storage backend. Excludes deterministic - * client failures (4xx, `PRESIGNED_URL_ERROR`, `FALLBACK_REQUIRED`) and aborts. - */ -export const isTransientUploadError = (error: unknown): boolean => { - if (!(error instanceof DirectUploadError)) return false - if (error.code !== 'DIRECT_UPLOAD_ERROR' && error.code !== 'MULTIPART_ERROR') return false - if (error.status === undefined) return true - return error.status >= 500 && error.status < 600 -} - -export const calculateUploadTimeoutMs = (fileSize: number): number => { - const sizeInMb = fileSize / (1024 * 1024) - const dynamicBudget = BASE_TIMEOUT_MS + sizeInMb * TIMEOUT_PER_MB_MS - return Math.min(dynamicBudget, MAX_TIMEOUT_MS) -} - -/** - * Run `worker` over `items` with at most `limit` concurrent invocations. - * Returns a settled result per item (never rejects), so callers can handle - * partial failures explicitly. - */ -export const runWithConcurrency = async ( - items: T[], - limit: number, - worker: (item: T, index: number) => Promise -): Promise>> => { - const results: Array> = Array(items.length) - if (items.length === 0) return results - - const concurrency = Math.max(1, Math.min(limit, items.length)) - let nextIndex = 0 - - const runners = Array.from({ length: concurrency }, async () => { - while (true) { - const currentIndex = nextIndex++ - if (currentIndex >= items.length) break - try { - const value = await worker(items[currentIndex], currentIndex) - results[currentIndex] = { status: 'fulfilled', value } - } catch (error) { - results[currentIndex] = { status: 'rejected', reason: error } - } - } - }) - - await Promise.all(runners) - return results -} - -/** - * Normalize a presigned-upload server response into a {@link PresignedUploadInfo}. - * Accepts both single (`/api/files/presigned`) and batch entry shapes, tolerates - * `presignedUrl` vs `uploadUrl` aliases, and short-circuits when the server - * signals no cloud storage (`directUploadSupported: false`) so callers can fall - * back to a server-proxied upload path. - * - * @throws {@link DirectUploadError} with code `PRESIGNED_URL_ERROR` if the - * response is missing a presigned URL or `fileInfo.path`. - */ -export const normalizePresignedData = (data: unknown, context: string): PresignedUploadInfo => { - const d = (data ?? {}) as Record - const presignedUrl = (d.presignedUrl as string) || (d.uploadUrl as string) || '' - const fileInfo = d.fileInfo as Record | undefined - const directUploadSupported = d.directUploadSupported !== false - - if (!directUploadSupported) { - return { - fileName: (d.fileName as string) || context, - presignedUrl: '', - fileInfo: { path: '', key: '', name: context, size: 0, type: '' }, - directUploadSupported: false, - } - } - - if (!presignedUrl || !fileInfo?.path) { - throw new DirectUploadError( - `Invalid presigned response for ${context}`, - 'PRESIGNED_URL_ERROR', - data - ) - } - - return { - fileName: (d.fileName as string) || (fileInfo.name as string) || context, - presignedUrl, - fileInfo: { - path: fileInfo.path as string, - key: (fileInfo.key as string) || '', - name: (fileInfo.name as string) || context, - size: (fileInfo.size as number) || (d.fileSize as number) || 0, - type: (fileInfo.type as string) || (d.contentType as string) || '', - }, - uploadHeaders: (d.uploadHeaders as Record) || undefined, - directUploadSupported: true, - } -} - -interface GetPresignedOptions { - endpoint: string - file: File - body?: Record - signal?: AbortSignal -} - -/** - * Fetch a single presigned upload URL from a server endpoint that follows the - * `{ presignedUrl, fileInfo, uploadHeaders?, directUploadSupported }` contract. - */ -export const getPresignedUploadInfo = async ( - opts: GetPresignedOptions -): Promise => { - const { endpoint, file, body, signal } = opts - const response = await fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - fileName: file.name, - contentType: getFileContentType(file), - fileSize: file.size, - ...body, - }), - signal, - }) - - if (!response.ok) { - let errorDetails: unknown = null - try { - errorDetails = await response.json() - } catch {} - const serverMessage = - errorDetails != null && - typeof errorDetails === 'object' && - typeof (errorDetails as Record).error === 'string' - ? ((errorDetails as Record).error as string) - : null - throw new DirectUploadError( - serverMessage || - `Failed to get presigned URL for ${file.name}: ${response.status} ${response.statusText}`, - 'PRESIGNED_URL_ERROR', - errorDetails - ) - } - - return normalizePresignedData(await response.json(), file.name) -} - -interface UploadViaPutOptions { - file: File - presignedUrl: string - uploadHeaders?: Record - signal?: AbortSignal - onProgress?: (event: UploadProgressEvent) => void -} - -const uploadViaPresignedPut = (opts: UploadViaPutOptions): Promise => { - const { file, presignedUrl, uploadHeaders, signal, onProgress } = opts - - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest() - let isCompleted = false - const timeoutMs = calculateUploadTimeoutMs(file.size) - - const timeoutId = setTimeout(() => { - if (isCompleted) return - isCompleted = true - signal?.removeEventListener('abort', abortHandler) - xhr.abort() - reject(new DirectUploadError(`Upload timeout for ${file.name}`, 'DIRECT_UPLOAD_ERROR')) - }, timeoutMs) - - const abortHandler = () => { - if (isCompleted) return - isCompleted = true - clearTimeout(timeoutId) - xhr.abort() - reject(new DirectUploadError(`Upload aborted for ${file.name}`, 'ABORTED')) - } - - if (signal) { - if (signal.aborted) { - abortHandler() - return - } - signal.addEventListener('abort', abortHandler) - } - - xhr.upload.addEventListener('progress', (event) => { - if (event.lengthComputable && !isCompleted) { - onProgress?.({ - loaded: event.loaded, - total: event.total, - percent: Math.round((event.loaded / event.total) * 100), - }) - } - }) - - xhr.addEventListener('load', () => { - if (isCompleted) return - isCompleted = true - clearTimeout(timeoutId) - signal?.removeEventListener('abort', abortHandler) - - if (xhr.status >= 200 && xhr.status < 300) { - onProgress?.({ loaded: file.size, total: file.size, percent: 100 }) - resolve() - } else { - reject( - new DirectUploadError( - `Direct upload failed for ${file.name}: ${xhr.status} ${xhr.statusText}`, - 'DIRECT_UPLOAD_ERROR', - undefined, - xhr.status - ) - ) - } - }) - - xhr.addEventListener('error', () => { - if (isCompleted) return - isCompleted = true - clearTimeout(timeoutId) - signal?.removeEventListener('abort', abortHandler) - reject(new DirectUploadError(`Network error uploading ${file.name}`, 'DIRECT_UPLOAD_ERROR')) - }) - - xhr.open('PUT', presignedUrl) - const providesContentType = - uploadHeaders && - Object.keys(uploadHeaders).some((key) => key.toLowerCase() === 'content-type') - if (!providesContentType) { - xhr.setRequestHeader('Content-Type', getFileContentType(file)) - } - if (uploadHeaders) { - for (const [key, value] of Object.entries(uploadHeaders)) { - xhr.setRequestHeader(key, value) - } - } - xhr.send(file) - }) -} - -interface MultipartUploadOptions { - file: File - workspaceId: string - context: - | 'workspace' - | 'knowledge-base' - | 'mothership' - | 'profile-pictures' - | 'workspace-logos' - | 'execution' - workflowId?: string - executionId?: string - signal?: AbortSignal - onProgress?: (event: UploadProgressEvent) => void -} - -interface CompletedPart { - partNumber: number - etag?: string -} - -interface PartUrl { - partNumber: number - url: string -} - -const uploadViaMultipart = async ( - opts: MultipartUploadOptions -): Promise<{ key: string; path: string }> => { - const { file, workspaceId, context, workflowId, executionId, signal, onProgress } = opts - - // boundary-raw-fetch: multipart upload control plane uses action query strings; client lifecycle (initiate/get-part-urls/complete/abort) is sequenced manually and not modeled by a single contract - const initiateResponse = await fetch('/api/files/multipart?action=initiate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - fileName: file.name, - contentType: getFileContentType(file), - fileSize: file.size, - workspaceId, - context, - ...(workflowId ? { workflowId } : {}), - ...(executionId ? { executionId } : {}), - }), - signal, - }) - - if (!initiateResponse.ok) { - let errorBody: { error?: string } | null = null - try { - errorBody = (await initiateResponse.clone().json()) as { error?: string } - } catch {} - if ( - initiateResponse.status === 400 && - typeof errorBody?.error === 'string' && - errorBody.error.toLowerCase().includes('cloud storage') - ) { - throw new DirectUploadError( - 'Server signaled fallback to API upload', - 'FALLBACK_REQUIRED', - errorBody - ) - } - throw new DirectUploadError( - `Failed to initiate multipart upload: ${initiateResponse.statusText}`, - 'MULTIPART_ERROR', - undefined, - initiateResponse.status - ) - } - - const { key, uploadToken } = (await initiateResponse.json()) as { - uploadId: string - key: string - uploadToken: string - } - - const numParts = Math.ceil(file.size / CHUNK_SIZE) - const partNumbers = Array.from({ length: numParts }, (_, i) => i + 1) - - const abortMultipart = async () => { - try { - // boundary-raw-fetch: fire-and-forget abort during multipart cleanup; intentionally avoids contract response parsing so cleanup cannot mask the original error - await fetch('/api/files/multipart?action=abort', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ uploadToken }), - }) - } catch (err) { - logger.warn('Failed to abort multipart upload:', err) - } - } - - let presignedUrls: PartUrl[] - try { - // boundary-raw-fetch: multipart upload control plane uses action query strings; sequenced with initiate/complete/abort outside the contract layer - const partUrlsResponse = await fetch('/api/files/multipart?action=get-part-urls', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ uploadToken, partNumbers }), - signal, - }) - - if (!partUrlsResponse.ok) { - throw new DirectUploadError( - `Failed to get part URLs: ${partUrlsResponse.statusText}`, - 'MULTIPART_ERROR', - undefined, - partUrlsResponse.status - ) - } - - ;({ presignedUrls } = (await partUrlsResponse.json()) as { presignedUrls: PartUrl[] }) - } catch (err) { - await abortMultipart() - throw err - } - - const completedBytes = new Array(numParts).fill(0) - const reportProgress = () => { - const loaded = completedBytes.reduce((a, b) => a + b, 0) - onProgress?.({ - loaded, - total: file.size, - percent: Math.min(100, Math.round((loaded / file.size) * 100)), - }) - } - - const uploadedParts: CompletedPart[] = [] - - try { - const uploadPart = async ({ partNumber, url }: PartUrl): Promise => { - const start = (partNumber - 1) * CHUNK_SIZE - const end = Math.min(start + CHUNK_SIZE, file.size) - const chunk = file.slice(start, end) - - for (let attempt = 0; attempt <= MULTIPART_MAX_RETRIES; attempt++) { - try { - const partResponse = await fetch(url, { - method: 'PUT', - body: chunk, - signal, - headers: { 'Content-Type': getFileContentType(file) }, - }) - - if (!partResponse.ok) { - throw new DirectUploadError( - `Failed to upload part ${partNumber}: ${partResponse.statusText}`, - 'MULTIPART_ERROR', - undefined, - partResponse.status - ) - } - - const etag = partResponse.headers.get('ETag') || undefined - completedBytes[partNumber - 1] = end - start - reportProgress() - - return { partNumber, etag: etag?.replace(/"/g, '') } - } catch (partError) { - const isClientError = - partError instanceof DirectUploadError && - partError.status !== undefined && - partError.status >= 400 && - partError.status < 500 - if (isAbortError(partError) || isClientError || attempt >= MULTIPART_MAX_RETRIES) { - throw partError - } - const delay = MULTIPART_RETRY_DELAY_MS * MULTIPART_RETRY_BACKOFF ** attempt - logger.warn( - `Part ${partNumber} failed (attempt ${attempt + 1}), retrying in ${Math.round(delay / 1000)}s` - ) - await sleep(delay) - } - } - - throw new DirectUploadError(`Retries exhausted for part ${partNumber}`, 'MULTIPART_ERROR') - } - - const partResults = await runWithConcurrency( - presignedUrls, - MULTIPART_PART_CONCURRENCY, - uploadPart - ) - - for (const result of partResults) { - if (result?.status === 'fulfilled') { - uploadedParts.push(result.value) - } else if (result?.status === 'rejected') { - throw result.reason - } - } - } catch (error) { - await abortMultipart() - throw error - } - - let path: string - try { - // boundary-raw-fetch: multipart upload control plane uses action query strings; sequenced with initiate/get-part-urls/abort outside the contract layer - const completeResponse = await fetch('/api/files/multipart?action=complete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ uploadToken, parts: uploadedParts }), - signal, - }) - - if (!completeResponse.ok) { - throw new DirectUploadError( - `Failed to complete multipart upload: ${completeResponse.statusText}`, - 'MULTIPART_ERROR', - undefined, - completeResponse.status - ) - } - - ;({ path } = (await completeResponse.json()) as { path: string }) - } catch (err) { - await abortMultipart() - throw err - } - return { key, path } -} - -export interface RunUploadStrategyOptions { - file: File - workspaceId: string - context: - | 'workspace' - | 'knowledge-base' - | 'mothership' - | 'profile-pictures' - | 'workspace-logos' - | 'execution' - /** Endpoint to mint a presigned PUT URL. Required unless `presignedOverride` is provided. */ - presignedEndpoint?: string - /** Pre-fetched presigned data (e.g. from a batch endpoint). Skips per-file fetch. */ - presignedOverride?: PresignedUploadInfo - /** Extra JSON body fields for the presigned endpoint. */ - presignedBody?: Record - /** Required when context is `execution`; forwarded to the multipart route to scope the storage key. */ - workflowId?: string - /** Required when context is `execution`; forwarded to the multipart route to scope the storage key. */ - executionId?: string - signal?: AbortSignal - onProgress?: (event: UploadProgressEvent) => void -} - -/** - * Strategy ladder for client-side uploads: - * - Files larger than {@link LARGE_FILE_THRESHOLD} use multipart S3/Blob with chunked PUTs. - * - Smaller files use a presigned PUT URL (fetched per-file, or supplied via - * `presignedOverride` for batched flows like KB). - * - If the server signals no cloud storage is configured, a {@link DirectUploadError} - * with code `FALLBACK_REQUIRED` is thrown so callers can fall back to a server-proxied path. - */ -export const runUploadStrategy = async ( - opts: RunUploadStrategyOptions -): Promise => { - const { - file, - presignedEndpoint, - presignedOverride, - presignedBody, - workspaceId, - context, - workflowId, - executionId, - signal, - onProgress, - } = opts - const contentType = getFileContentType(file) - - if (presignedOverride && !presignedOverride.directUploadSupported) { - throw new DirectUploadError('Server signaled fallback to API upload', 'FALLBACK_REQUIRED') - } - - if (file.size > LARGE_FILE_THRESHOLD) { - const { key, path } = await uploadViaMultipart({ - file, - workspaceId, - context, - workflowId, - executionId, - signal, - onProgress, - }) - return { key, path, name: file.name, size: file.size, contentType } - } - - let presigned: PresignedUploadInfo - if (presignedOverride) { - presigned = presignedOverride - } else { - if (!presignedEndpoint) { - throw new DirectUploadError( - 'runUploadStrategy requires either presignedEndpoint or presignedOverride', - 'PRESIGNED_URL_ERROR' - ) - } - presigned = await getPresignedUploadInfo({ - endpoint: presignedEndpoint, - file, - body: presignedBody, - signal, - }) - } - - if (!presigned.directUploadSupported) { - throw new DirectUploadError('Server signaled fallback to API upload', 'FALLBACK_REQUIRED') - } - - await uploadViaPresignedPut({ - file, - presignedUrl: presigned.presignedUrl, - uploadHeaders: presigned.uploadHeaders, - signal, - onProgress, - }) - - return { - key: presigned.fileInfo.key, - path: presigned.fileInfo.path, - name: file.name, - size: file.size, - contentType, - } -} diff --git a/apps/sim/lib/uploads/client/multipart-session.test.ts b/apps/sim/lib/uploads/client/multipart-session.test.ts deleted file mode 100644 index 0f8b495a2b1..00000000000 --- a/apps/sim/lib/uploads/client/multipart-session.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * @vitest-environment node - */ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { V2CompletedPart } from '@/lib/api/contracts/v2/uploads' -import { uploadMultipartSession } from '@/lib/uploads/client/multipart-session' - -describe('uploadMultipartSession', () => { - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('requests fresh URL batches and completes with every uploaded part in order', async () => { - const file = new File(['abcdefghijklmnopqrstuvwxyz'], 'letters.txt') - const getPartUrls = vi.fn(async (partNumbers: number[]) => - partNumbers.map((partNumber) => ({ - partNumber, - url: `https://storage.example/part/${partNumber}`, - headers: { 'Content-Type': 'application/octet-stream' }, - expiresAt: '2026-08-03T22:00:00.000Z', - })) - ) - const complete = vi.fn(async (parts: V2CompletedPart[]) => parts) - const abort = vi.fn(async () => {}) - const onProgress = vi.fn() - vi.stubGlobal( - 'fetch', - vi.fn( - async (_url: string) => new Response(null, { status: 200, headers: { etag: '"etag"' } }) - ) - ) - - const result = await uploadMultipartSession({ - file, - partSize: 1, - partCount: 26, - getPartUrls, - complete, - abort, - onProgress, - }) - - expect(getPartUrls).toHaveBeenCalledTimes(2) - expect(getPartUrls.mock.calls[0][0]).toEqual( - Array.from({ length: 25 }, (_, index) => index + 1) - ) - expect(getPartUrls.mock.calls[1][0]).toEqual([26]) - expect(result).toHaveLength(26) - expect(result[0]).toEqual({ partNumber: 1, etag: 'etag' }) - expect(result[25]).toEqual({ partNumber: 26, etag: 'etag' }) - expect(onProgress).toHaveBeenLastCalledWith({ loaded: 26, total: 26, percent: 100 }) - expect(abort).not.toHaveBeenCalled() - }) - - it('aborts the signed session when a part upload is aborted', async () => { - const file = new File(['part'], 'part.txt') - const complete = vi.fn() - const abort = vi.fn(async () => {}) - vi.stubGlobal( - 'fetch', - vi.fn(async () => { - throw new DOMException('The operation was aborted', 'AbortError') - }) - ) - - await expect( - uploadMultipartSession({ - file, - partSize: 4, - partCount: 1, - getPartUrls: async () => [ - { - partNumber: 1, - url: 'https://storage.example/part/1', - headers: {}, - expiresAt: '2026-08-03T22:00:00.000Z', - }, - ], - complete, - abort, - }) - ).rejects.toMatchObject({ name: 'AbortError' }) - expect(abort).toHaveBeenCalledTimes(1) - expect(complete).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/uploads/client/multipart-session.ts b/apps/sim/lib/uploads/client/multipart-session.ts deleted file mode 100644 index 2c3b1da05fd..00000000000 --- a/apps/sim/lib/uploads/client/multipart-session.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { sleep } from '@sim/utils/helpers' -import type { V2CompletedPart, V2UploadPartUrl } from '@/lib/api/contracts/v2/uploads' -import { - MULTIPART_MAX_RETRIES, - MULTIPART_PART_CONCURRENCY, - MULTIPART_RETRY_BACKOFF, - MULTIPART_RETRY_DELAY_MS, - runWithConcurrency, - type UploadProgressEvent, -} from '@/lib/uploads/client/direct-upload' -import { isAbortError } from '@/lib/uploads/utils/file-utils' - -interface UploadMultipartSessionParams { - file: File - partSize: number - partCount: number - signal?: AbortSignal - onProgress?: (event: UploadProgressEvent) => void - getPartUrls: (partNumbers: number[]) => Promise - complete: (parts: V2CompletedPart[]) => Promise - abort: () => Promise -} - -export async function uploadMultipartSession( - params: UploadMultipartSessionParams -): Promise { - const { file, partSize, partCount, signal, onProgress } = params - const completedBytes = new Array(partCount).fill(0) - const completedParts: V2CompletedPart[] = [] - try { - for (let start = 1; start <= partCount; start += 25) { - const partNumbers = Array.from( - { length: Math.min(25, partCount - start + 1) }, - (_, index) => start + index - ) - const partUrls = await params.getPartUrls(partNumbers) - const results = await runWithConcurrency( - partUrls, - MULTIPART_PART_CONCURRENCY, - async (part): Promise => { - const partStart = (part.partNumber - 1) * partSize - const end = Math.min(partStart + partSize, file.size) - const chunk = file.slice(partStart, end) - for (let attempt = 0; attempt <= MULTIPART_MAX_RETRIES; attempt++) { - try { - // boundary-raw-fetch: signed multipart data-plane URL may target cloud storage or local Sim - const response = await fetch(part.url, { - method: 'PUT', - body: chunk, - headers: part.headers, - signal, - }) - if (!response.ok) { - throw new Error(`Part ${part.partNumber} failed (${response.status})`) - } - completedBytes[part.partNumber - 1] = end - partStart - const loaded = completedBytes.reduce((sum, bytes) => sum + bytes, 0) - onProgress?.({ - loaded, - total: file.size, - percent: Math.min(100, Math.round((loaded / file.size) * 100)), - }) - const etag = response.headers.get('etag') - return { - partNumber: part.partNumber, - ...(etag ? { etag: etag.replaceAll('"', '') } : {}), - } - } catch (error) { - if (isAbortError(error) || attempt >= MULTIPART_MAX_RETRIES) throw error - await sleep(MULTIPART_RETRY_DELAY_MS * MULTIPART_RETRY_BACKOFF ** attempt) - } - } - throw new Error(`Retries exhausted for part ${part.partNumber}`) - } - ) - completedParts.push( - ...results.map((result) => { - if (result.status === 'rejected') throw result.reason - return result.value - }) - ) - } - return await params.complete(completedParts) - } catch (error) { - await params.abort().catch(() => {}) - throw error - } -} diff --git a/apps/sim/lib/uploads/client/session-upload.test.ts b/apps/sim/lib/uploads/client/session-upload.test.ts index 790f15ae108..026c3f55a0e 100644 --- a/apps/sim/lib/uploads/client/session-upload.test.ts +++ b/apps/sim/lib/uploads/client/session-upload.test.ts @@ -2,24 +2,26 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { V2CompletedPart, V2UploadPartUrl } from '@/lib/api/contracts/v2/uploads' +import type { V2CompleteUploadBody } from '@/lib/api/contracts/v2/uploads' -interface MultipartMockParams { - getPartUrls: (partNumbers: number[]) => Promise - complete: (parts: V2CompletedPart[]) => Promise +interface UploadClientMockParams { + complete: (body: V2CompleteUploadBody) => Promise } -const { mockRequestJson, mockUploadMultipartSession } = vi.hoisted(() => ({ +const { mockRequestJson, mockUploadFileSession } = vi.hoisted(() => ({ mockRequestJson: vi.fn(), - mockUploadMultipartSession: vi.fn(), + mockUploadFileSession: vi.fn(), })) vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) -vi.mock('@/lib/uploads/client/multipart-session', () => ({ - uploadMultipartSession: mockUploadMultipartSession, +vi.mock('@/lib/uploads/client/upload-session', () => ({ + uploadFileSession: mockUploadFileSession, })) -import { uploadKnowledgeDocumentSession } from '@/lib/uploads/client/session-upload' +import { + uploadInternalFileSession, + uploadKnowledgeDocumentSession, +} from '@/lib/uploads/client/session-upload' const DOCUMENT = { id: 'upload-1', @@ -35,60 +37,90 @@ const DOCUMENT = { createdAt: '2026-08-04T21:00:00.000Z', } as const -describe('uploadKnowledgeDocumentSession', () => { +describe('session upload domain clients', () => { beforeEach(() => { vi.clearAllMocks() + }) + + it('uses the PUT knowledge session without requesting part URLs', async () => { mockRequestJson .mockResolvedValueOnce({ data: { - id: 'upload-1', - partSize: 8 * 1024 * 1024, - partCount: 1, + session: { id: 'upload-1' }, uploadToken: 'token', + transfer: { + method: 'put', + url: 'https://storage.example/upload', + headers: { 'Content-Type': 'application/pdf' }, + }, }, }) - .mockResolvedValueOnce({ - data: { parts: [{ partNumber: 1, url: 'https://storage.example/part-1', headers: {} }] }, - }) .mockResolvedValueOnce({ data: { document: DOCUMENT } }) - mockUploadMultipartSession.mockImplementation( - async (params: MultipartMockParams) => { - await params.getPartUrls([1]) - return params.complete([{ partNumber: 1, etag: 'etag-1' }]) - } + mockUploadFileSession.mockImplementation( + async (params: UploadClientMockParams) => params.complete({}) ) - }) - - it('uses the first-party session routes and preserves signed processing metadata', async () => { - const file = { - name: 'guide.pdf', - type: 'application/pdf', - size: 1024, - } as File + const file = { name: 'guide.pdf', type: 'application/pdf', size: 1024 } as File await expect( uploadKnowledgeDocumentSession({ - workspaceId: '6fc7631d-88cd-46f8-9f0a-d4764daef7f8', + workspaceId: 'workspace-1', knowledgeBaseId: 'kb-1', file, tag1: 'product', - processingOptions: { recipe: 'default', lang: 'en' }, }) ).resolves.toEqual(DOCUMENT) expect(mockRequestJson.mock.calls[0][0].path).toBe('/api/knowledge/[id]/documents/uploads') - expect(mockRequestJson.mock.calls[0][1].body).toMatchObject({ - name: 'guide.pdf', - contentType: 'application/pdf', - size: 1024, - tag1: 'product', - processingOptions: { recipe: 'default', lang: 'en' }, - }) expect(mockRequestJson.mock.calls[1][0].path).toBe( - '/api/knowledge/[id]/documents/uploads/[uploadId]/parts' - ) - expect(mockRequestJson.mock.calls[2][0].path).toBe( '/api/knowledge/[id]/documents/uploads/[uploadId]/complete' ) + expect(mockRequestJson.mock.calls[1][1].body).toEqual({}) + expect(mockRequestJson.mock.calls.some(([contract]) => contract.path.endsWith('/parts'))).toBe( + false + ) + }) + + it('returns the purpose-specific result from the generic internal session', async () => { + const result = { + path: '/api/files/serve/logo.png', + key: 'workspace-logos/logo.png', + name: 'logo.png', + size: 100, + type: 'image/png', + } + mockRequestJson + .mockResolvedValueOnce({ + data: { + session: { id: 'upload-2', purpose: 'workspace_logo' }, + uploadToken: 'token', + transfer: { + method: 'put', + url: 'https://storage.example/upload', + headers: { 'Content-Type': 'image/png' }, + }, + }, + }) + .mockResolvedValueOnce({ + data: { id: 'upload-2', purpose: 'workspace_logo', result }, + }) + mockUploadFileSession.mockImplementation( + async (params: UploadClientMockParams) => params.complete({}) + ) + + await expect( + uploadInternalFileSession({ + purpose: 'workspace_logo', + workspaceId: 'workspace-1', + file: { name: 'logo.png', type: 'image/png', size: 100 } as File, + }) + ).resolves.toEqual(result) + + expect(mockRequestJson.mock.calls[0][1].body).toEqual({ + purpose: 'workspace_logo', + workspaceId: 'workspace-1', + name: 'logo.png', + contentType: 'image/png', + size: 100, + }) }) }) diff --git a/apps/sim/lib/uploads/client/session-upload.ts b/apps/sim/lib/uploads/client/session-upload.ts index e06d9c57860..dabb59b893e 100644 --- a/apps/sim/lib/uploads/client/session-upload.ts +++ b/apps/sim/lib/uploads/client/session-upload.ts @@ -6,17 +6,24 @@ import { createKnowledgeDocumentUploadPartUrlsContract, } from '@/lib/api/contracts/knowledge/upload-sessions' import { - abortWorkspaceFileUploadContract, - completeWorkspaceFileUploadContract, - createWorkspaceFileUploadContract, - createWorkspaceFileUploadPartUrlsContract, + abortInternalFileUploadContract, + type CreateInternalFileUploadBody, + completeInternalFileUploadContract, + createInternalFileUploadContract, + createInternalFileUploadPartUrlsContract, + type InternalFileUploadSession, } from '@/lib/api/contracts/upload-sessions' import type { V2KnowledgeDocumentSummary, V2KnowledgeDocumentUploadMetadata, } from '@/lib/api/contracts/v2/knowledge' -import type { UploadProgressEvent } from '@/lib/uploads/client/direct-upload' -import { uploadMultipartSession } from '@/lib/uploads/client/multipart-session' +import type { + V2CompleteUploadBody, + V2UploadPartUrl, + V2UploadTransfer, +} from '@/lib/api/contracts/v2/uploads' +import type { UploadProgressEvent } from '@/lib/uploads/client/types' +import { uploadFileSession } from '@/lib/uploads/client/upload-session' import { getFileContentType } from '@/lib/uploads/utils/file-utils' interface UploadWorkspaceFileSessionParams { @@ -27,6 +34,31 @@ interface UploadWorkspaceFileSessionParams { onProgress?: (event: UploadProgressEvent) => void } +interface InternalUploadCommonParams { + file: File + signal?: AbortSignal + onProgress?: (event: UploadProgressEvent) => void +} + +type InternalUploadContext = + | { purpose: 'workspace_file'; workspaceId: string; folderId?: string | null } + | { purpose: 'profile_picture' } + | { purpose: 'workspace_logo'; workspaceId: string } + | { purpose: 'mothership_attachment'; workspaceId: string } + | { + purpose: 'execution_attachment' + workspaceId: string + workflowId: string + executionId: string + } + +type InternalUploadPurpose = InternalUploadContext['purpose'] +type InternalUploadResult = NonNullable< + Extract['result'] +> + +export type UploadInternalFileSessionParams = InternalUploadCommonParams & InternalUploadContext + interface UploadKnowledgeDocumentSessionParams extends V2KnowledgeDocumentUploadMetadata { workspaceId: string knowledgeBaseId: string @@ -35,56 +67,124 @@ interface UploadKnowledgeDocumentSessionParams extends V2KnowledgeDocumentUpload onProgress?: (event: UploadProgressEvent) => void } +interface RunCreatedUploadParams { + file: File + transfer: V2UploadTransfer + signal?: AbortSignal + onProgress?: (event: UploadProgressEvent) => void + getPartUrls: (partNumbers: number[]) => Promise + complete: (body: V2CompleteUploadBody) => Promise + abort: () => Promise +} + +function runCreatedUpload(params: RunCreatedUploadParams): Promise { + const common = { + file: params.file, + signal: params.signal, + onProgress: params.onProgress, + complete: params.complete, + abort: params.abort, + } + return params.transfer.method === 'put' + ? uploadFileSession({ ...common, transfer: params.transfer }) + : uploadFileSession({ + ...common, + transfer: params.transfer, + getPartUrls: params.getPartUrls, + }) +} + export async function uploadWorkspaceFileSession(params: UploadWorkspaceFileSessionParams) { - const { workspaceId, folderId, file, signal, onProgress } = params - const created = await requestJson(createWorkspaceFileUploadContract, { - body: { - workspaceId, - name: file.name, - contentType: getFileContentType(file), - size: file.size, - ...(folderId ? { folderId } : {}), - }, + return uploadInternalFileSession({ + purpose: 'workspace_file', + ...params, + }) +} + +export async function uploadInternalFileSession( + params: InternalUploadCommonParams & Extract +): Promise> { + const { file, signal, onProgress } = params + const created = await requestJson(createInternalFileUploadContract, { + body: internalUploadBody(params), signal, }) - const upload = created.data - return uploadMultipartSession({ + const { session, uploadToken, transfer } = created.data + if (session.purpose !== params.purpose) { + throw new Error(`Expected ${params.purpose} upload session; received ${session.purpose}`) + } + return runCreatedUpload({ file, - partSize: upload.partSize, - partCount: upload.partCount, + transfer, signal, onProgress, getPartUrls: async (partNumbers) => { - const batch = await requestJson(createWorkspaceFileUploadPartUrlsContract, { - params: { uploadId: upload.id }, - query: { workspaceId }, - headers: { 'upload-token': upload.uploadToken }, + const batch = await requestJson(createInternalFileUploadPartUrlsContract, { + params: { uploadId: session.id }, + headers: { 'upload-token': uploadToken }, body: { partNumbers }, signal, }) return batch.data.parts }, - complete: async (parts) => { - const completed = await requestJson(completeWorkspaceFileUploadContract, { - params: { uploadId: upload.id }, - query: { workspaceId }, - headers: { 'upload-token': upload.uploadToken }, - body: { parts }, + complete: async (body) => { + const completed = await requestJson(completeInternalFileUploadContract, { + params: { uploadId: session.id }, + headers: { 'upload-token': uploadToken }, + body, signal, }) - if (!completed.data.file) throw new Error('Completed upload returned no workspace file') - return completed.data.file + if (completed.data.purpose !== params.purpose) { + throw new Error(`Expected ${params.purpose} completion; received ${completed.data.purpose}`) + } + if (!completed.data.result) { + throw new Error(`Completed ${params.purpose} upload returned no result`) + } + return completed.data.result as InternalUploadResult }, abort: async () => { - await requestJson(abortWorkspaceFileUploadContract, { - params: { uploadId: upload.id }, - query: { workspaceId }, - headers: { 'upload-token': upload.uploadToken }, + await requestJson(abortInternalFileUploadContract, { + params: { uploadId: session.id }, + headers: { 'upload-token': uploadToken }, }) }, }) } +function internalUploadBody(params: UploadInternalFileSessionParams): CreateInternalFileUploadBody { + const fileFields = { + name: params.file.name, + contentType: getFileContentType(params.file), + size: params.file.size, + } + switch (params.purpose) { + case 'workspace_file': + return { + purpose: params.purpose, + workspaceId: params.workspaceId, + ...fileFields, + ...(params.folderId ? { folderId: params.folderId } : {}), + } + case 'profile_picture': + return { purpose: params.purpose, ...fileFields } + case 'workspace_logo': + case 'mothership_attachment': + return { + purpose: params.purpose, + workspaceId: params.workspaceId, + ...fileFields, + } + case 'execution_attachment': + return { + purpose: params.purpose, + workspaceId: params.workspaceId, + workflowId: params.workflowId, + executionId: params.executionId, + ...fileFields, + } + } +} + export async function uploadKnowledgeDocumentSession( params: UploadKnowledgeDocumentSessionParams ): Promise { @@ -100,29 +200,28 @@ export async function uploadKnowledgeDocumentSession( }, signal, }) - const upload = created.data - return uploadMultipartSession({ + const { session, uploadToken, transfer } = created.data + return runCreatedUpload({ file, - partSize: upload.partSize, - partCount: upload.partCount, + transfer, signal, onProgress, getPartUrls: async (partNumbers) => { const batch = await requestJson(createKnowledgeDocumentUploadPartUrlsContract, { - params: { id: knowledgeBaseId, uploadId: upload.id }, + params: { id: knowledgeBaseId, uploadId: session.id }, query: { workspaceId }, - headers: { 'upload-token': upload.uploadToken }, + headers: { 'upload-token': uploadToken }, body: { partNumbers }, signal, }) return batch.data.parts }, - complete: async (parts) => { + complete: async (body) => { const completed = await requestJson(completeKnowledgeDocumentUploadContract, { - params: { id: knowledgeBaseId, uploadId: upload.id }, + params: { id: knowledgeBaseId, uploadId: session.id }, query: { workspaceId }, - headers: { 'upload-token': upload.uploadToken }, - body: { parts }, + headers: { 'upload-token': uploadToken }, + body, signal, }) if (!completed.data.document) { @@ -132,9 +231,9 @@ export async function uploadKnowledgeDocumentSession( }, abort: async () => { await requestJson(abortKnowledgeDocumentUploadContract, { - params: { id: knowledgeBaseId, uploadId: upload.id }, + params: { id: knowledgeBaseId, uploadId: session.id }, query: { workspaceId }, - headers: { 'upload-token': upload.uploadToken }, + headers: { 'upload-token': uploadToken }, }) }, }) diff --git a/apps/sim/lib/uploads/client/types.ts b/apps/sim/lib/uploads/client/types.ts new file mode 100644 index 00000000000..28d4c02ad98 --- /dev/null +++ b/apps/sim/lib/uploads/client/types.ts @@ -0,0 +1,5 @@ +export interface UploadProgressEvent { + loaded: number + total: number + percent: number +} diff --git a/apps/sim/lib/uploads/client/upload-session.test.ts b/apps/sim/lib/uploads/client/upload-session.test.ts new file mode 100644 index 00000000000..8df72f6b24f --- /dev/null +++ b/apps/sim/lib/uploads/client/upload-session.test.ts @@ -0,0 +1,325 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { V2CompleteUploadBody } from '@/lib/api/contracts/v2/uploads' +import { calculateUploadTimeoutMs, uploadFileSession } from '@/lib/uploads/client/upload-session' + +const MIB = 1024 * 1024 +const PUT_THRESHOLD = 50 * MIB + +function sizedFile(size: number): File { + const file = new File([], 'data.bin', { type: 'application/octet-stream' }) + Object.defineProperty(file, 'size', { value: size }) + return file +} + +class MockXhr extends EventTarget { + static instances: MockXhr[] = [] + static onSend: (xhr: MockXhr) => void = (xhr) => { + queueMicrotask(() => xhr.dispatchEvent(new Event('load'))) + } + + readonly upload = new EventTarget() + readonly requestHeaders = new Map() + status = 200 + statusText = 'OK' + timeout = 0 + responseHeaders = new Map() + open = vi.fn() + abort = vi.fn() + send = vi.fn(() => MockXhr.onSend(this)) + + constructor() { + super() + MockXhr.instances.push(this) + } + + setRequestHeader(name: string, value: string) { + this.requestHeaders.set(name, value) + } + + getResponseHeader(name: string) { + return this.responseHeaders.get(name) ?? null + } +} + +describe('uploadFileSession', () => { + const originalXhr = globalThis.XMLHttpRequest + + beforeEach(() => { + MockXhr.instances = [] + MockXhr.onSend = (xhr) => queueMicrotask(() => xhr.dispatchEvent(new Event('load'))) + globalThis.XMLHttpRequest = MockXhr as unknown as typeof XMLHttpRequest + }) + + afterEach(() => { + globalThis.XMLHttpRequest = originalXhr + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('uploads an exact-threshold file with PUT and completes with an empty body', async () => { + const file = sizedFile(PUT_THRESHOLD) + const complete = vi.fn(async (_body: V2CompleteUploadBody) => 'done') + const abort = vi.fn(async () => undefined) + const onProgress = vi.fn() + + await expect( + uploadFileSession({ + file, + transfer: { + method: 'put', + url: 'https://storage.example/upload', + headers: { 'Content-Type': 'application/octet-stream', 'x-upload': 'signed' }, + }, + complete, + abort, + onProgress, + }) + ).resolves.toBe('done') + + expect(MockXhr.instances).toHaveLength(1) + expect(MockXhr.instances[0].open).toHaveBeenCalledWith('PUT', 'https://storage.example/upload') + expect(MockXhr.instances[0].requestHeaders).toEqual( + new Map([ + ['Content-Type', 'application/octet-stream'], + ['x-upload', 'signed'], + ]) + ) + expect(MockXhr.instances[0].timeout).toBe(calculateUploadTimeoutMs(file.size)) + expect(complete).toHaveBeenCalledWith({}) + expect(onProgress).toHaveBeenLastCalledWith({ + loaded: PUT_THRESHOLD, + total: PUT_THRESHOLD, + percent: 100, + }) + expect(abort).not.toHaveBeenCalled() + }) + + it('uploads an empty file with PUT and reports finite completion progress', async () => { + const complete = vi.fn(async (_body: V2CompleteUploadBody) => 'done') + const onProgress = vi.fn() + + await expect( + uploadFileSession({ + file: sizedFile(0), + transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, + complete, + abort: vi.fn(async () => undefined), + onProgress, + }) + ).resolves.toBe('done') + + expect(complete).toHaveBeenCalledWith({}) + expect(onProgress).toHaveBeenLastCalledWith({ loaded: 0, total: 0, percent: 100 }) + }) + + it('uploads a file above the threshold through bounded multipart batches', async () => { + const file = sizedFile(PUT_THRESHOLD + 1) + const partSize = 8 * MIB + const partCount = Math.ceil(file.size / partSize) + const getPartUrls = vi.fn(async (partNumbers: number[]) => + [...partNumbers].reverse().map((partNumber) => ({ + partNumber, + url: `https://storage.example/parts/${partNumber}`, + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt: '2026-08-05T00:00:00.000Z', + })) + ) + const complete = vi.fn(async (_body: V2CompleteUploadBody) => 'done') + const abort = vi.fn(async () => undefined) + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 200, headers: { etag: '"part-etag"' } })) + ) + + await expect( + uploadFileSession({ + file, + transfer: { method: 'multipart', partSize, partCount }, + getPartUrls, + complete, + abort, + }) + ).resolves.toBe('done') + + expect(getPartUrls).toHaveBeenCalledWith(Array.from({ length: partCount }, (_, i) => i + 1)) + expect(complete).toHaveBeenCalledWith({ + parts: Array.from({ length: partCount }, (_, index) => ({ + partNumber: index + 1, + etag: 'part-etag', + })), + }) + expect(abort).not.toHaveBeenCalled() + }) + + it('does not retry a deterministic PUT 4xx', async () => { + MockXhr.onSend = (xhr) => { + xhr.status = 403 + xhr.statusText = 'Forbidden' + queueMicrotask(() => xhr.dispatchEvent(new Event('load'))) + } + const abort = vi.fn(async () => undefined) + + await expect( + uploadFileSession({ + file: sizedFile(1), + transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, + complete: vi.fn(), + abort, + }) + ).rejects.toMatchObject({ status: 403 }) + + expect(MockXhr.instances).toHaveLength(1) + expect(abort).toHaveBeenCalledTimes(1) + }) + + it('retains a completed PUT transfer when completion fails', async () => { + const abort = vi.fn(async () => undefined) + + await expect( + uploadFileSession({ + file: sizedFile(1), + transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, + complete: async () => { + throw new Error('finalizer unavailable') + }, + abort, + }) + ).rejects.toThrow('finalizer unavailable') + + expect(abort).not.toHaveBeenCalled() + }) + + it('retries transient PUT failures and keeps progress monotonic', async () => { + vi.useFakeTimers() + const onProgress = vi.fn() + MockXhr.onSend = (xhr) => { + const attempt = MockXhr.instances.length + if (attempt === 1) { + xhr.upload.dispatchEvent( + new ProgressEvent('progress', { lengthComputable: true, loaded: 8, total: 10 }) + ) + queueMicrotask(() => xhr.dispatchEvent(new Event('error'))) + return + } + xhr.upload.dispatchEvent( + new ProgressEvent('progress', { lengthComputable: true, loaded: 2, total: 10 }) + ) + queueMicrotask(() => xhr.dispatchEvent(new Event('load'))) + } + + const promise = uploadFileSession({ + file: sizedFile(10), + transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, + complete: async () => 'done', + abort: async () => undefined, + onProgress, + }) + await vi.runAllTimersAsync() + + await expect(promise).resolves.toBe('done') + expect(MockXhr.instances).toHaveLength(2) + expect(onProgress.mock.calls.map(([event]) => event.loaded)).toEqual([8, 8, 10]) + }) + + it('aborts XHR and the control session when the caller cancels', async () => { + const controller = new AbortController() + MockXhr.onSend = () => undefined + const abort = vi.fn(async () => undefined) + const promise = uploadFileSession({ + file: sizedFile(10), + transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, + complete: vi.fn(), + abort, + signal: controller.signal, + }) + + controller.abort() + + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }) + expect(MockXhr.instances[0].abort).toHaveBeenCalledTimes(1) + expect(abort).toHaveBeenCalledTimes(1) + }) + + it('fails before uploading when a multipart URL batch is incomplete', async () => { + const abort = vi.fn(async () => undefined) + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + await expect( + uploadFileSession({ + file: sizedFile(2), + transfer: { method: 'multipart', partSize: 1, partCount: 2 }, + getPartUrls: async () => [ + { + partNumber: 1, + url: 'https://storage.example/parts/1', + headers: {}, + expiresAt: '2026-08-05T00:00:00.000Z', + }, + ], + complete: vi.fn(), + abort, + }) + ).rejects.toThrow('Expected 2 part URLs; received 1') + + expect(fetchMock).not.toHaveBeenCalled() + expect(abort).toHaveBeenCalledTimes(1) + }) + + it('does not retry a multipart 4xx response', async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 403 })) + vi.stubGlobal('fetch', fetchMock) + + await expect( + uploadFileSession({ + file: sizedFile(1), + transfer: { method: 'multipart', partSize: 1, partCount: 1 }, + getPartUrls: async () => [ + { + partNumber: 1, + url: 'https://storage.example/parts/1', + headers: {}, + expiresAt: '2026-08-05T00:00:00.000Z', + }, + ], + complete: vi.fn(), + abort: async () => undefined, + }) + ).rejects.toMatchObject({ status: 403 }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('retains a completed multipart transfer when completion fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 200 })) + ) + const abort = vi.fn(async () => undefined) + + await expect( + uploadFileSession({ + file: sizedFile(1), + transfer: { method: 'multipart', partSize: 1, partCount: 1 }, + getPartUrls: async () => [ + { + partNumber: 1, + url: 'https://storage.example/parts/1', + headers: {}, + expiresAt: '2026-08-05T00:00:00.000Z', + }, + ], + complete: async () => { + throw new Error('finalizer unavailable') + }, + abort, + }) + ).rejects.toThrow('finalizer unavailable') + + expect(abort).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/uploads/client/upload-session.ts b/apps/sim/lib/uploads/client/upload-session.ts new file mode 100644 index 00000000000..7ad7695b3ee --- /dev/null +++ b/apps/sim/lib/uploads/client/upload-session.ts @@ -0,0 +1,374 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' +import type { + V2CompletedPart, + V2CompleteUploadBody, + V2MultipartUploadTransfer, + V2PutUploadTransfer, + V2UploadPartUrl, +} from '@/lib/api/contracts/v2/uploads' +import { runWithConcurrency } from '@/lib/uploads/client/concurrency' +import type { UploadProgressEvent } from '@/lib/uploads/client/types' +import { isAbortError } from '@/lib/uploads/utils/file-utils' + +const BASE_TIMEOUT_MS = 2 * 60 * 1000 +const TIMEOUT_PER_MB_MS = 1500 +const MAX_TIMEOUT_MS = 10 * 60 * 1000 +const PART_URL_BATCH_SIZE = 25 +const PART_UPLOAD_CONCURRENCY = 3 +const MAX_RETRIES = 3 +const RETRY_BASE_MS = 500 +const RETRY_MAX_MS = 8000 + +const logger = createLogger('UploadSessionClient') + +export function calculateUploadTimeoutMs(fileSize: number): number { + const sizeInMb = fileSize / (1024 * 1024) + return Math.min(BASE_TIMEOUT_MS + sizeInMb * TIMEOUT_PER_MB_MS, MAX_TIMEOUT_MS) +} + +export class UploadSessionTransportError extends Error { + constructor( + message: string, + readonly status?: number, + readonly retryAfterMs: number | null = null, + readonly transient = false + ) { + super(message) + this.name = 'UploadSessionTransportError' + } +} + +interface UploadFileSessionCommon { + file: File + signal?: AbortSignal + onProgress?: (event: UploadProgressEvent) => void + complete: (body: V2CompleteUploadBody) => Promise + abort: () => Promise +} + +interface UploadPutFileSession extends UploadFileSessionCommon { + transfer: V2PutUploadTransfer + getPartUrls?: never +} + +interface UploadMultipartFileSession extends UploadFileSessionCommon { + transfer: V2MultipartUploadTransfer + getPartUrls: (partNumbers: number[]) => Promise +} + +export type UploadFileSessionParams = UploadPutFileSession | UploadMultipartFileSession + +function isPutFileSession( + params: UploadFileSessionParams +): params is UploadPutFileSession { + return params.transfer.method === 'put' +} + +export async function uploadFileSession(params: UploadFileSessionParams): Promise { + let completion: V2CompleteUploadBody + try { + if (isPutFileSession(params)) { + await uploadPut(params) + completion = {} + } else { + completion = { parts: await uploadMultipart(params) } + } + } catch (error) { + await params.abort().catch((abortError) => { + logger.warn('Failed to abort upload session after an upload error', { + error: getErrorMessage(abortError), + }) + }) + throw error + } + + return params.complete(completion) +} + +async function uploadPut(params: UploadPutFileSession): Promise { + let reportedLoaded = 0 + const reportProgress = (loaded: number) => { + reportedLoaded = Math.max(reportedLoaded, loaded) + params.onProgress?.({ + loaded: reportedLoaded, + total: params.file.size, + percent: + params.file.size === 0 + ? 100 + : Math.min(100, Math.round((reportedLoaded / params.file.size) * 100)), + }) + } + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + await uploadPutAttempt({ + file: params.file, + transfer: params.transfer, + signal: params.signal, + onProgress: reportProgress, + }) + reportProgress(params.file.size) + return + } catch (error) { + if (isAbortError(error) || !isRetryableUploadError(error) || attempt >= MAX_RETRIES) { + throw error + } + await waitForRetry(attempt + 1, error.retryAfterMs, params.file.name, params.signal) + } + } + throw new Error(`PUT upload retries exhausted for ${params.file.name}`) +} + +interface UploadPutAttemptParams { + file: File + transfer: V2PutUploadTransfer + signal?: AbortSignal + onProgress: (loaded: number) => void +} + +function uploadPutAttempt(params: UploadPutAttemptParams): Promise { + if (params.signal?.aborted) return Promise.reject(uploadAbortError(params.file.name)) + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + let settled = false + + const cleanup = () => { + params.signal?.removeEventListener('abort', handleSignalAbort) + xhr.upload.removeEventListener('progress', handleProgress) + xhr.removeEventListener('load', handleLoad) + xhr.removeEventListener('error', handleError) + xhr.removeEventListener('timeout', handleTimeout) + } + + const resolveOnce = () => { + if (settled) return + settled = true + cleanup() + resolve() + } + + const rejectOnce = (error: Error) => { + if (settled) return + settled = true + cleanup() + reject(error) + } + + const handleSignalAbort = () => { + xhr.abort() + rejectOnce(uploadAbortError(params.file.name)) + } + + const handleProgress = (event: ProgressEvent) => { + if (event.lengthComputable && !settled) params.onProgress(event.loaded) + } + + const handleLoad = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolveOnce() + return + } + rejectOnce( + new UploadSessionTransportError( + `PUT upload failed for ${params.file.name}: ${xhr.status} ${xhr.statusText}`, + xhr.status, + parseRetryAfter(xhr.getResponseHeader('Retry-After'), RETRY_MAX_MS), + isRetryableStatus(xhr.status) + ) + ) + } + + const handleError = () => { + rejectOnce( + new UploadSessionTransportError( + `Network error uploading ${params.file.name}`, + undefined, + null, + true + ) + ) + } + + const handleTimeout = () => { + rejectOnce( + new UploadSessionTransportError( + `Upload timed out for ${params.file.name}`, + undefined, + null, + true + ) + ) + } + + xhr.open('PUT', params.transfer.url) + xhr.timeout = calculateUploadTimeoutMs(params.file.size) + for (const [key, value] of Object.entries(params.transfer.headers)) { + xhr.setRequestHeader(key, value) + } + xhr.upload.addEventListener('progress', handleProgress) + xhr.addEventListener('load', handleLoad) + xhr.addEventListener('error', handleError) + xhr.addEventListener('timeout', handleTimeout) + params.signal?.addEventListener('abort', handleSignalAbort, { once: true }) + xhr.send(params.file) + }) +} + +async function uploadMultipart( + params: UploadMultipartFileSession +): Promise { + const { file, transfer, signal, onProgress } = params + const expectedPartCount = Math.ceil(file.size / transfer.partSize) + if (expectedPartCount !== transfer.partCount) { + throw new Error( + `Upload session expected ${transfer.partCount} parts, but file requires ${expectedPartCount}` + ) + } + + const completedBytes = new Array(transfer.partCount).fill(0) + const completedParts: V2CompletedPart[] = [] + for (let start = 1; start <= transfer.partCount; start += PART_URL_BATCH_SIZE) { + const partNumbers = Array.from( + { length: Math.min(PART_URL_BATCH_SIZE, transfer.partCount - start + 1) }, + (_, index) => start + index + ) + const partUrls = validatePartUrlBatch(partNumbers, await params.getPartUrls(partNumbers)) + const results = await runWithConcurrency( + partUrls, + PART_UPLOAD_CONCURRENCY, + async (part): Promise => { + const partStart = (part.partNumber - 1) * transfer.partSize + const end = Math.min(partStart + transfer.partSize, file.size) + const chunk = file.slice(partStart, end) + const etag = await uploadMultipartPart({ part, chunk, fileName: file.name, signal }) + completedBytes[part.partNumber - 1] = end - partStart + const loaded = completedBytes.reduce((sum, bytes) => sum + bytes, 0) + onProgress?.({ + loaded, + total: file.size, + percent: Math.min(100, Math.round((loaded / file.size) * 100)), + }) + return { partNumber: part.partNumber, ...(etag ? { etag } : {}) } + } + ) + completedParts.push( + ...results.map((result) => { + if (result.status === 'rejected') throw result.reason + return result.value + }) + ) + } + return completedParts +} + +function validatePartUrlBatch(requested: number[], received: V2UploadPartUrl[]): V2UploadPartUrl[] { + if (received.length !== requested.length) { + throw new Error(`Expected ${requested.length} part URLs; received ${received.length}`) + } + const byPartNumber = new Map() + for (const part of received) { + if (byPartNumber.has(part.partNumber)) { + throw new Error(`Received duplicate URL for part ${part.partNumber}`) + } + byPartNumber.set(part.partNumber, part) + } + return requested.map((partNumber) => { + const part = byPartNumber.get(partNumber) + if (!part) throw new Error(`Missing upload URL for part ${partNumber}`) + return part + }) +} + +async function uploadMultipartPart(params: { + part: V2UploadPartUrl + chunk: Blob + fileName: string + signal?: AbortSignal +}): Promise { + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + // boundary-raw-fetch: signed multipart data-plane URL may target cloud storage or local Sim + const response = await fetch(params.part.url, { + method: 'PUT', + body: params.chunk, + headers: params.part.headers, + signal: params.signal, + }) + if (!response.ok) { + throw new UploadSessionTransportError( + `Part ${params.part.partNumber} failed (${response.status})`, + response.status, + parseRetryAfter(response.headers.get('Retry-After'), RETRY_MAX_MS), + isRetryableStatus(response.status) + ) + } + return response.headers.get('etag')?.replaceAll('"', '') + } catch (error) { + if (isAbortError(error)) throw error + const classified = + error instanceof TypeError + ? new UploadSessionTransportError( + `Network error uploading part ${params.part.partNumber}`, + undefined, + null, + true + ) + : error + if (!isRetryableUploadError(classified) || attempt >= MAX_RETRIES) { + throw classified + } + await waitForRetry(attempt + 1, classified.retryAfterMs, params.fileName, params.signal) + } + } + throw new Error(`Part ${params.part.partNumber} upload retries exhausted`) +} + +function isRetryableUploadError(error: unknown): error is UploadSessionTransportError { + return error instanceof UploadSessionTransportError && error.transient +} + +function isRetryableStatus(status: number): boolean { + return status === 408 || status === 429 || (status >= 500 && status < 600) +} + +async function waitForRetry( + attempt: number, + retryAfterMs: number | null, + fileName: string, + signal?: AbortSignal +): Promise { + if (signal?.aborted) throw uploadAbortError(fileName) + const delay = backoffWithJitter(attempt, retryAfterMs, { + baseMs: RETRY_BASE_MS, + maxMs: RETRY_MAX_MS, + }) + if (!signal) { + await sleep(delay) + return + } + await new Promise((resolve, reject) => { + const handleAbort = () => { + signal.removeEventListener('abort', handleAbort) + reject(uploadAbortError(fileName)) + } + signal.addEventListener('abort', handleAbort, { once: true }) + sleep(delay).then( + () => { + signal.removeEventListener('abort', handleAbort) + resolve() + }, + (error) => { + signal.removeEventListener('abort', handleAbort) + reject(error) + } + ) + }) +} + +function uploadAbortError(fileName: string): DOMException { + return new DOMException(`Upload aborted for ${fileName}`, 'AbortError') +} diff --git a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts index 2e9b3fdf93d..f0406e73601 100644 --- a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts @@ -4,11 +4,8 @@ import { deleteFile, downloadFile, generatePresignedDownloadUrl, - generatePresignedUploadUrl, uploadFile, } from '@/lib/uploads/core/storage-service' -import type { PresignedUrlResponse } from '@/lib/uploads/shared/types' -import { isImageFileType } from '@/lib/uploads/utils/file-utils' const logger = createLogger('CopilotFileManager') @@ -49,14 +46,6 @@ interface CopilotFileAttachment { media_type: string } -export interface GenerateCopilotUploadUrlOptions { - fileName: string - contentType: string - fileSize: number - userId: string - expirationSeconds?: number -} - export interface CopilotStoredFile { id: string key: string @@ -68,48 +57,6 @@ export interface CopilotStoredFile { mimeType: string } -/** - * Generate a presigned URL for copilot file upload - * - * Images and document files are allowed for copilot uploads. - * Requires authenticated user session. - * - * @param options Upload URL generation options - * @returns Presigned URL response with upload URL and file key - * @throws Error if file type is unsupported or user is not authenticated - */ -export async function generateCopilotUploadUrl( - options: GenerateCopilotUploadUrlOptions -): Promise { - const { fileName, contentType, fileSize, userId, expirationSeconds = 3600 } = options - - if (!userId?.trim()) { - throw new Error('Authenticated user session is required for copilot uploads') - } - - if (!isSupportedFileType(contentType) && !isImageFileType(contentType)) { - throw new Error( - 'Unsupported file type. Allowed: images (JPEG, PNG, GIF, WebP), PDF, and text files (TXT, CSV, MD, HTML, JSON, XML).' - ) - } - - const presignedUrlResponse = await generatePresignedUploadUrl({ - fileName, - contentType, - fileSize, - context: 'copilot', - userId, - expirationSeconds, - }) - - logger.info(`Generated copilot upload URL for: ${fileName}`, { - key: presignedUrlResponse.key, - userId, - }) - - return presignedUrlResponse -} - export async function uploadCopilotFile(options: { buffer: Buffer fileName: string diff --git a/apps/sim/lib/uploads/contexts/copilot/index.ts b/apps/sim/lib/uploads/contexts/copilot/index.ts index d4b1c93a3e2..5fdfc829dc4 100644 --- a/apps/sim/lib/uploads/contexts/copilot/index.ts +++ b/apps/sim/lib/uploads/contexts/copilot/index.ts @@ -1,6 +1,5 @@ export type { CopilotStoredFile } from './copilot-file-manager' export { downloadCopilotFile, - generateCopilotUploadUrl, uploadCopilotFile, } from './copilot-file-manager' diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index ca2b80229cc..3c561c8bd1f 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -338,10 +338,10 @@ export async function getWorkspaceFileFolder( return mapFolder(folder, paths) } -export async function assertWorkspaceFileFolderTarget( +export async function resolveWorkspaceFileFolderTarget( workspaceId: string, folderId?: string | null -): Promise { +): Promise { const normalized = normalizeParentId(folderId) if (!normalized) return null @@ -350,7 +350,15 @@ export async function assertWorkspaceFileFolderTarget( throw new OrchestrationError('not_found', 'Target folder not found') } - return normalized + return folder +} + +export async function assertWorkspaceFileFolderTarget( + workspaceId: string, + folderId?: string | null +): Promise { + const folder = await resolveWorkspaceFileFolderTarget(workspaceId, folderId) + return folder?.id ?? null } export async function createWorkspaceFileFolder(params: { 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 3a7afab9538..ebd22f89f83 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -43,6 +43,7 @@ import { getServePathPrefix } from '@/lib/uploads' import { deleteFile, downloadFile, + hasCloudStorage, headObject, uploadFile, } from '@/lib/uploads/core/storage-service' @@ -60,6 +61,7 @@ import { getWorkspaceFileFolderPath, listWorkspaceFileFolders, normalizeWorkspaceFileItemName, + resolveWorkspaceFileFolderTarget, } from './workspace-file-folder-manager' const logger = createLogger('WorkspaceFileStorage') @@ -106,6 +108,14 @@ export interface WorkspaceFileRecord { share?: ShareRecord | null } +export interface UploadedWorkspaceFileRecord extends WorkspaceFileRecord { + url: string + context: 'workspace' + folderId: string | null + folderPath: string | null + deletedAt: Date | null +} + interface ListWorkspaceFilesOptions { scope?: WorkspaceFileScope folders?: WorkspaceFileFolderRecord[] @@ -213,18 +223,19 @@ class WorkspaceFileRegistrationConflictError extends Error { } /** - * Reads one active metadata row by its unique storage key. + * Reads metadata by upload-operation key across its full lifecycle, preferring an active row. */ -async function findActiveWorkspaceFileByKey( +async function findWorkspaceFileByRegistrationKey( executor: DbOrTx, key: string ): Promise { - const [file] = await executor + const files = await executor .select() .from(workspaceFiles) - .where(and(eq(workspaceFiles.key, key), isNull(workspaceFiles.deletedAt))) + .where(eq(workspaceFiles.key, key)) + .orderBy(sql`${workspaceFiles.deletedAt} IS NULL DESC`) .limit(1) - return file + return files[0] } /** @@ -250,7 +261,7 @@ async function findWorkspaceFileForLifecycle( } /** - * Confirms that an active-key conflict belongs to the same direct-upload + * Confirms that a key belongs to the same upload-session * operation. The generated storage key is the operation identity; immutable * ownership and object attributes prevent unrelated callers from reusing it. */ @@ -260,7 +271,6 @@ function isSameWorkspaceFileRegistration( workspaceId: string userId: string key: string - folderId: string | null contentType: string size: number } @@ -269,11 +279,9 @@ function isSameWorkspaceFileRegistration( file.key === params.key && file.workspaceId === params.workspaceId && file.userId === params.userId && - file.folderId === params.folderId && file.context === 'workspace' && file.contentType === params.contentType && - workspaceFileSize(file) === params.size && - file.deletedAt === null + workspaceFileSize(file) === params.size ) } @@ -331,10 +339,12 @@ export async function uploadWorkspaceFile( fileName: string, contentType: string, options?: { folderId?: string | null; exactName?: boolean } -): Promise { +): Promise { logger.info(`Uploading workspace file: ${fileName} for workspace ${workspaceId}`) - const folderId = await assertWorkspaceFileFolderTarget(workspaceId, options?.folderId) + const folderTarget = await resolveWorkspaceFileFolderTarget(workspaceId, options?.folderId) + const folderId = folderTarget?.id ?? null + const folderPath = folderTarget?.path ?? null const normalizedFileName = normalizeWorkspaceFileItemName(fileName, 'File') const exactName = options?.exactName ?? false const storageBillingContext = await resolveStorageBillingContext(workspaceId) @@ -349,7 +359,7 @@ export async function uploadWorkspaceFile( throw new FileConflictError(uniqueName) } const storageKey = generateWorkspaceFileKey(workspaceId, uniqueName) - let fileId = `wf_${generateShortId()}` + const fileId = `wf_${generateShortId()}` try { logger.info(`Generated storage key: ${storageKey}`) @@ -376,9 +386,12 @@ export async function uploadWorkspaceFile( logger.info(`Upload returned key: ${uploadResult.key}`) - let updatedUsage: number | undefined + let finalized: { + inserted: typeof workspaceFiles.$inferSelect + updatedUsage: number | undefined + } try { - const finalized = await db.transaction(async (tx) => { + finalized = await db.transaction(async (tx) => { const inserted = await insertWorkspaceFileMetadataInTx(tx, { id: fileId, key: uploadResult.key, @@ -399,36 +412,22 @@ export async function uploadWorkspaceFile( ) return { inserted, updatedUsage: usage } }) - fileId = finalized.inserted.id - updatedUsage = finalized.updatedUsage } catch (finalizationError) { await cleanupWorkspaceStorageObject(uploadResult.key, 'metadata finalization failure') throw finalizationError } - void maybeNotifyStorageLimitForBillingContext(storageBillingContext, updatedUsage) + void maybeNotifyStorageLimitForBillingContext(storageBillingContext, finalized.updatedUsage) logger.info( `Successfully uploaded workspace file: ${uniqueName} with key: ${uploadResult.key}` ) - const pathPrefix = getServePathPrefix() - const serveUrl = `${pathPrefix}${encodeURIComponent(uploadResult.key)}?context=workspace` - - // Fan out the live-tree signal for the direct-upload paths (multipart - // fallback, copilot create, /api/files/upload, v1 files) — the presigned - // path already notifies from its register route. + // Fan out the live-tree signal for this server-buffered path. Upload-session + // finalization sends its own notification after registering metadata. await notifyWorkspaceFilesChanged(workspaceId) - return { - id: fileId, - name: uniqueName, - size: fileBuffer.length, - type: contentType, - url: serveUrl, - key: uploadResult.key, - context: 'workspace', - } + return mapUploadedWorkspaceFileRecord(finalized.inserted, workspaceId, folderPath) } catch (error) { lastError = error if (error instanceof FileConflictError) { @@ -469,8 +468,8 @@ export async function uploadWorkspaceFile( } /** - * Finalize a workspace file that was uploaded directly to cloud storage - * (presigned PUT or completed multipart). Verifies the object exists, + * Finalize a workspace file that was uploaded through a transfer session + * (signed PUT or completed multipart). Verifies the object exists, * checks quota, allocates a non-colliding display name, inserts metadata, * and increments storage usage. * @@ -503,7 +502,6 @@ export async function registerUploadedWorkspaceFile(params: { throw new Error('Uploaded object not found in storage') } const verifiedSize = head.size - const folderId = await assertWorkspaceFileFolderTarget(workspaceId, params.folderId) if (verifiedSize > MAX_WORKSPACE_FILE_SIZE) { await cleanupWorkspaceStorageObject(key, 'size-cap rejection') @@ -514,16 +512,16 @@ export async function registerUploadedWorkspaceFile(params: { workspaceId, userId, key, - folderId, contentType, size: verifiedSize, } - const existing = await findActiveWorkspaceFileByKey(db, key) + const existing = await findWorkspaceFileByRegistrationKey(db, key) if (existing) { if (!isSameWorkspaceFileRegistration(existing, registrationIdentity)) { throw new WorkspaceFileRegistrationConflictError(key) } - logger.info(`Using existing metadata record for direct upload: ${key}`) + assertActiveWorkspaceFileRegistration(existing) + logger.info(`Using existing metadata record for upload session: ${key}`) const pathPrefix = getServePathPrefix() return { file: { @@ -539,6 +537,8 @@ export async function registerUploadedWorkspaceFile(params: { } } + const folderId = await assertWorkspaceFileFolderTarget(workspaceId, params.folderId) + const storageBillingContext = await resolveStorageBillingContext(workspaceId) for (let attempt = 0; attempt < MAX_UPLOAD_UNIQUE_RETRIES; attempt++) { const fileId = `wf_${generateShortId()}` @@ -560,11 +560,12 @@ export async function registerUploadedWorkspaceFile(params: { size: verifiedSize, }) if (!inserted) { - const raceWinner = await findActiveWorkspaceFileByKey(tx, key) + const raceWinner = await findWorkspaceFileByRegistrationKey(tx, key) if (!raceWinner) return { kind: 'name-conflict' } as const if (!isSameWorkspaceFileRegistration(raceWinner, registrationIdentity)) { throw new WorkspaceFileRegistrationConflictError(key) } + assertActiveWorkspaceFileRegistration(raceWinner) return { kind: 'existing', file: raceWinner } as const } @@ -605,6 +606,12 @@ export async function registerUploadedWorkspaceFile(params: { throw new FileConflictError(normalizedOriginalName) } +function assertActiveWorkspaceFileRegistration(file: typeof workspaceFiles.$inferSelect): void { + if (file.deletedAt) { + throw new OrchestrationError('conflict', 'Upload result was deleted') + } +} + /** * Like `withCopySuffix` but with `n=1` meaning "no suffix" — used by retry loops where * the first attempt should try the original name (`image.png`, `image (2).png`, ...). @@ -867,6 +874,26 @@ function mapWorkspaceFileRecord( } } +function mapUploadedWorkspaceFileRecord( + file: typeof workspaceFiles.$inferSelect, + workspaceId: string, + folderPath: string | null +): UploadedWorkspaceFileRecord { + const record = mapWorkspaceFileRecord( + file, + workspaceId, + file.folderId && folderPath ? new Map([[file.folderId, folderPath]]) : new Map() + ) + return { + ...record, + url: record.path, + context: 'workspace', + folderId: record.folderId ?? null, + folderPath: record.folderPath ?? null, + deletedAt: record.deletedAt ?? null, + } +} + async function mapSingleWorkspaceFileRecord( file: typeof workspaceFiles.$inferSelect, workspaceId: string diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-query.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-query.test.ts index 21b5be978f6..f4c81ef8bc3 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-query.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-query.test.ts @@ -43,6 +43,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => getWorkspaceFileFolderPath: vi.fn(), listWorkspaceFileFolders: vi.fn(async () => []), normalizeWorkspaceFileItemName: vi.fn((name: string) => name), + resolveWorkspaceFileFolderTarget: vi.fn(async () => null), })) import { queryWorkspaceFiles } from '@/lib/uploads/contexts/workspace/workspace-file-manager' diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts index cf0b5cf7189..cb101d5455e 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts @@ -15,6 +15,7 @@ const { mockMergeEditIntoLiveFileDoc, mockNotifyWorkspaceFilesChanged, mockResolveStorageBillingContext, + mockResolveWorkspaceFileFolderTarget, mockUploadFile, } = vi.hoisted(() => ({ mockDecrementStorageUsageForBillingContextInTx: vi.fn(), @@ -27,6 +28,7 @@ const { mockMergeEditIntoLiveFileDoc: vi.fn(), mockNotifyWorkspaceFilesChanged: vi.fn(), mockResolveStorageBillingContext: vi.fn(), + mockResolveWorkspaceFileFolderTarget: vi.fn(), mockUploadFile: vi.fn(), })) @@ -62,6 +64,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => getWorkspaceFileFolderPath: vi.fn(), listWorkspaceFileFolders: vi.fn(async () => []), normalizeWorkspaceFileItemName: vi.fn((name: string) => name), + resolveWorkspaceFileFolderTarget: mockResolveWorkspaceFileFolderTarget, })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ @@ -108,6 +111,7 @@ describe('workspace file metadata and storage accounting', () => { vi.clearAllMocks() resetDbChainMock() mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT) + mockResolveWorkspaceFileFolderTarget.mockResolvedValue(null) mockHasCloudStorage.mockReturnValue(false) mockHeadObject.mockResolvedValue({ size: FILE_ROW.size }) mockUploadFile.mockResolvedValue({ key: FILE_ROW.key }) @@ -120,6 +124,46 @@ describe('workspace file metadata and storage accounting', () => { mockNotifyWorkspaceFilesChanged.mockResolvedValue(undefined) }) + it('returns the canonical inserted record with the pre-resolved folder path', async () => { + const folderId = 'folder-1' + const folderPath = 'Docs/Notes' + const inserted = { ...FILE_ROW, folderId } + mockResolveWorkspaceFileFolderTarget.mockResolvedValueOnce({ id: folderId, path: folderPath }) + dbChainMockFns.returning.mockResolvedValueOnce([inserted]) + + const uploaded = await uploadWorkspaceFile( + FILE_ROW.workspaceId, + FILE_ROW.userId, + Buffer.from('hello'), + FILE_ROW.originalName, + FILE_ROW.contentType, + { folderId } + ) + + const serveUrl = `/api/files/serve/s3/${encodeURIComponent(FILE_ROW.key)}?context=workspace` + expect(uploaded).toEqual( + expect.objectContaining({ + id: FILE_ROW.id, + workspaceId: FILE_ROW.workspaceId, + name: FILE_ROW.originalName, + key: FILE_ROW.key, + path: serveUrl, + url: serveUrl, + size: FILE_ROW.size, + type: FILE_ROW.contentType, + uploadedBy: FILE_ROW.userId, + folderId, + folderPath, + deletedAt: null, + uploadedAt: FILE_ROW.uploadedAt, + updatedAt: FILE_ROW.updatedAt, + contentUpdatedAt: FILE_ROW.contentUpdatedAt, + context: 'workspace', + }) + ) + expect(mockResolveWorkspaceFileFolderTarget).toHaveBeenCalledOnce() + }) + it('cleans up a newly uploaded object when atomic metadata finalization rolls back', async () => { dbChainMockFns.returning.mockResolvedValueOnce([FILE_ROW]) mockIncrementStorageUsageForBillingContextInTx.mockRejectedValueOnce( @@ -196,7 +240,7 @@ describe('workspace file metadata and storage accounting', () => { expect(mockDeleteFile).not.toHaveBeenCalled() }) - it('does not delete a direct-upload object when atomic finalization rolls back', async () => { + it('does not delete an upload-session object when atomic finalization rolls back', async () => { mockHasCloudStorage.mockReturnValue(true) dbChainMockFns.limit.mockResolvedValueOnce([]) dbChainMockFns.returning.mockResolvedValueOnce([FILE_ROW]) @@ -220,6 +264,28 @@ describe('workspace file metadata and storage accounting', () => { ) }) + it('rejects archived upload metadata without charging storage again', async () => { + const archivedFile = { + ...FILE_ROW, + deletedAt: new Date('2026-07-02T00:00:00.000Z'), + } + mockHasCloudStorage.mockReturnValue(true) + dbChainMockFns.limit.mockResolvedValueOnce([archivedFile]) + + await expect( + registerUploadedWorkspaceFile({ + workspaceId: FILE_ROW.workspaceId, + userId: FILE_ROW.userId, + key: FILE_ROW.key, + originalName: FILE_ROW.originalName, + contentType: FILE_ROW.contentType, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.returning).not.toHaveBeenCalled() + expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() + expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled() + }) + it('archives metadata without changing stored-byte counters', async () => { dbChainMockFns.limit.mockResolvedValueOnce([FILE_ROW]) dbChainMockFns.returning.mockResolvedValueOnce([FILE_ROW]) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts index eaf463774bc..4f3b0af4102 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts @@ -8,11 +8,13 @@ const { mockIncrementStorageUsageForBillingContextInTx, mockMaybeNotifyStorageLimitForBillingContext, mockResolveStorageBillingContext, + mockResolveWorkspaceFileFolderTarget, mockUploadFile, } = vi.hoisted(() => ({ mockIncrementStorageUsageForBillingContextInTx: vi.fn(), mockMaybeNotifyStorageLimitForBillingContext: vi.fn(), mockResolveStorageBillingContext: vi.fn(), + mockResolveWorkspaceFileFolderTarget: vi.fn(), mockUploadFile: vi.fn(), })) @@ -43,6 +45,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => getWorkspaceFileFolderPath: vi.fn(), listWorkspaceFileFolders: vi.fn(async () => []), normalizeWorkspaceFileItemName: vi.fn((name: string) => name), + resolveWorkspaceFileFolderTarget: mockResolveWorkspaceFileFolderTarget, })) import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -60,6 +63,7 @@ describe('workspace file storage attribution', () => { vi.clearAllMocks() resetDbChainMock() mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT) + mockResolveWorkspaceFileFolderTarget.mockResolvedValue(null) mockIncrementStorageUsageForBillingContextInTx.mockResolvedValue(5) mockMaybeNotifyStorageLimitForBillingContext.mockResolvedValue(undefined) mockUploadFile.mockResolvedValue({ diff --git a/apps/sim/lib/uploads/core/storage-service.ts b/apps/sim/lib/uploads/core/storage-service.ts index 619116c72ae..70bbb925ceb 100644 --- a/apps/sim/lib/uploads/core/storage-service.ts +++ b/apps/sim/lib/uploads/core/storage-service.ts @@ -30,6 +30,9 @@ import { 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 @@ -556,7 +559,7 @@ export async function deleteFile(options: DeleteFileOptions): Promise { } } - const { unlink } = await import('fs/promises') + const { rm, unlink } = await import('fs/promises') const { join } = await import('path') const { UPLOAD_DIR_SERVER } = await import('./setup.server') @@ -564,6 +567,7 @@ export async function deleteFile(options: DeleteFileOptions): Promise { const filePath = join(UPLOAD_DIR_SERVER, safeKey) await unlink(filePath) + await rm(`${filePath}${LOCAL_UPLOAD_METADATA_SUFFIX}`, { force: true }) } /** AWS SDK v3 silently caps HTTP connections at 50/endpoint — stay well under. */ @@ -846,36 +850,6 @@ async function generateBlobPresignedUrl( } } -/** - * Generate multiple presigned URLs at once (batch operation) - */ -export async function generateBatchPresignedUploadUrls( - files: Array<{ - fileName: string - contentType: string - fileSize: number - }>, - context: StorageContext, - userId?: string, - expirationSeconds?: number -): Promise { - const results: PresignedUrlResponse[] = [] - - for (const file of files) { - const result = await generatePresignedUploadUrl({ - fileName: file.fileName, - contentType: file.contentType, - fileSize: file.fileSize, - context, - userId, - expirationSeconds, - }) - results.push(result) - } - - return results -} - /** * Generate a presigned URL for downloading/accessing an existing file */ diff --git a/apps/sim/lib/uploads/core/upload-token.test.ts b/apps/sim/lib/uploads/core/upload-token.test.ts index 532985f5411..85beca8acbc 100644 --- a/apps/sim/lib/uploads/core/upload-token.test.ts +++ b/apps/sim/lib/uploads/core/upload-token.test.ts @@ -4,57 +4,121 @@ import { describe, expect, it } from 'vitest' import { signUploadToken, verifyUploadToken } from '@/lib/uploads/core/upload-token' +const TIMESTAMPS = { + createdAt: '2099-08-03T20:00:00.000Z', + expiresAt: '2099-08-04T20:00:00.000Z', +} as const + describe('upload token', () => { - it('round-trips stateless multipart session state', () => { - const token = signUploadToken({ + it('round-trips strict multipart session state', () => { + const payload = { uploadId: 'upload-1', - key: 'workspace-1/file.csv', - userId: 'user-1', + actorId: 'user-1', workspaceId: 'workspace-1', - context: 'workspace', + purpose: 'knowledge_document', + knowledgeBaseId: 'kb-1', + context: 'knowledge-base', + finalKey: 'kb/final-file.csv', + stagingKey: 'upload-sessions/upload-1/file.csv', + provider: 's3', + providerUploadId: 'provider-upload-1', + method: 'multipart', fileName: 'file.csv', contentType: 'text/csv', fileSize: 12, - purpose: 'workspace_file', - provider: 's3', - providerUploadId: 'provider-upload-1', partSize: 8, partCount: 2, - metadata: { folderId: 'folder-1' }, - createdAt: '2026-08-03T20:00:00.000Z', - expiresAt: '2026-08-04T20:00:00.000Z', - }) + metadata: { tag1: 'product' }, + ...TIMESTAMPS, + } as const + const token = signUploadToken(payload) - expect(verifyUploadToken(token)).toEqual({ - valid: true, - payload: { - uploadId: 'upload-1', - key: 'workspace-1/file.csv', - userId: 'user-1', - workspaceId: 'workspace-1', - context: 'workspace', - fileName: 'file.csv', - contentType: 'text/csv', - fileSize: 12, - purpose: 'workspace_file', - provider: 's3', - providerUploadId: 'provider-upload-1', - partSize: 8, - partCount: 2, - metadata: { folderId: 'folder-1' }, - createdAt: '2026-08-03T20:00:00.000Z', - expiresAt: '2026-08-04T20:00:00.000Z', - }, - }) + expect(verifyUploadToken(token)).toEqual({ valid: true, payload }) + }) + + it('round-trips a user-scoped PUT without a synthetic workspace', () => { + const payload = { + uploadId: 'upload-2', + actorId: 'user-1', + workspaceId: null, + purpose: 'profile_picture', + context: 'profile-pictures', + finalKey: 'profile-pictures/upload-2-avatar.png', + stagingKey: 'upload-sessions/upload-2/avatar.png', + provider: 'local', + providerUploadId: null, + method: 'put', + fileName: 'avatar.png', + contentType: 'image/png', + fileSize: 12, + metadata: {}, + ...TIMESTAMPS, + } as const + + expect(verifyUploadToken(signUploadToken(payload))).toEqual({ valid: true, payload }) + }) + + it('round-trips an empty workspace-file PUT', () => { + const payload = { + uploadId: 'upload-empty', + actorId: 'user-1', + workspaceId: 'workspace-1', + purpose: 'workspace_file', + context: 'workspace', + finalKey: 'workspace/workspace-1/empty.md', + stagingKey: 'upload-sessions/upload-empty/empty.md', + provider: 'local', + providerUploadId: null, + method: 'put', + fileName: 'empty.md', + contentType: 'text/markdown', + fileSize: 0, + metadata: {}, + ...TIMESTAMPS, + } as const + + expect(verifyUploadToken(signUploadToken(payload))).toEqual({ valid: true, payload }) + }) + + it('rejects an empty PUT for non-workspace-file purposes', () => { + expect(() => + signUploadToken({ + uploadId: 'upload-empty', + actorId: 'user-1', + workspaceId: null, + purpose: 'profile_picture', + context: 'profile-pictures', + finalKey: 'profile-pictures/empty.png', + stagingKey: 'upload-sessions/upload-empty/empty.png', + provider: 'local', + providerUploadId: null, + method: 'put', + fileName: 'empty.png', + contentType: 'image/png', + fileSize: 0, + metadata: {}, + ...TIMESTAMPS, + }) + ).toThrow('Upload token payload has invalid object state') }) it('rejects a modified token', () => { const token = signUploadToken({ uploadId: 'upload-1', - key: 'workspace-1/file.csv', - userId: 'user-1', + actorId: 'user-1', workspaceId: 'workspace-1', + purpose: 'workspace_file', context: 'workspace', + finalKey: 'workspace/workspace-1/final.csv', + stagingKey: 'upload-sessions/upload-1/file.csv', + provider: 'local', + providerUploadId: null, + method: 'put', + fileName: 'file.csv', + contentType: 'text/csv', + fileSize: 12, + metadata: {}, + ...TIMESTAMPS, }) const [payload, signature] = token.split('.') diff --git a/apps/sim/lib/uploads/core/upload-token.ts b/apps/sim/lib/uploads/core/upload-token.ts index da058708536..86fba13110e 100644 --- a/apps/sim/lib/uploads/core/upload-token.ts +++ b/apps/sim/lib/uploads/core/upload-token.ts @@ -1,44 +1,113 @@ import { safeCompare } from '@sim/security/compare' import { hmacSha256Base64 } from '@sim/security/hmac' import { env } from '@/lib/core/config/env' -import type { StorageContext } from '@/lib/uploads/shared/types' -export interface UploadTokenPayload { +export type UploadSessionPurpose = + | 'workspace_file' + | 'table_import' + | 'knowledge_document' + | 'profile_picture' + | 'workspace_logo' + | 'mothership_attachment' + | 'execution_attachment' + +export type UploadStorageProvider = 's3' | 'blob' | 'gcs' | 'local' +export type UploadTransferMethod = 'put' | 'multipart' + +type UploadPurposeScope = + | { + purpose: 'workspace_file' + workspaceId: string + context: 'workspace' + } + | { + purpose: 'table_import' + workspaceId: string + context: 'table-import' + } + | { + purpose: 'knowledge_document' + workspaceId: string + context: 'knowledge-base' + knowledgeBaseId: string + } + | { + purpose: 'profile_picture' + workspaceId: null + context: 'profile-pictures' + } + | { + purpose: 'workspace_logo' + workspaceId: string + context: 'workspace-logos' + } + | { + purpose: 'mothership_attachment' + workspaceId: string + context: 'mothership' + } + | { + purpose: 'execution_attachment' + workspaceId: string + context: 'execution' + workflowId: string + executionId: string + } + +type UploadTransferState = + | { + method: 'put' + providerUploadId: null + } + | { + method: 'multipart' + providerUploadId: string | null + partSize: number + partCount: number + } + +interface UploadTokenBase { uploadId: string - key: string - userId: string - workspaceId: string - context: StorageContext - /** Knowledge base bound to a knowledge-document multipart session. */ - knowledgeBaseId?: string - /** Original file name, carried so the completion handler can record ownership metadata. */ - fileName?: string - /** File MIME type, carried for ownership metadata at completion. */ - contentType?: string - /** File size in bytes, carried for ownership metadata at completion. */ - fileSize?: number - /** Multipart-session purpose. Omitted by the legacy multipart endpoint. */ - purpose?: 'workspace_file' | 'table_import' | 'knowledge_document' - /** Storage provider that owns the multipart upload state. */ - provider?: 's3' | 'blob' | 'gcs' | 'local' - /** Provider-issued multipart upload id. Local and block-blob uploads do not need one. */ - providerUploadId?: string | null - /** Fixed byte size of every part except the final part. */ - partSize?: number - /** Exact number of parts the client must complete. */ - partCount?: number - /** Signed purpose-specific data needed during finalization. */ - metadata?: Record - /** ISO timestamps used to reconstruct the stateless session response. */ - createdAt?: string - expiresAt?: string + actorId: string + finalKey: string + stagingKey: string + provider: UploadStorageProvider + fileName: string + contentType: string + fileSize: number + metadata: Record + createdAt: string + expiresAt: string } -interface SignedPayload extends UploadTokenPayload { +export type UploadTokenPayload = UploadTokenBase & UploadPurposeScope & UploadTransferState + +type SignedPayload = UploadTokenPayload & { exp: number - v: 1 + v: 2 } +const BASE_KEYS = [ + 'uploadId', + 'actorId', + 'finalKey', + 'stagingKey', + 'provider', + 'providerUploadId', + 'method', + 'purpose', + 'workspaceId', + 'context', + 'fileName', + 'contentType', + 'fileSize', + 'metadata', + 'createdAt', + 'expiresAt', + 'exp', + 'v', +] as const + const toBase64Url = (input: string): string => Buffer.from(input, 'utf8').toString('base64url') const fromBase64Url = (input: string): string => Buffer.from(input, 'base64url').toString('utf8') @@ -46,17 +115,19 @@ const fromBase64Url = (input: string): string => Buffer.from(input, 'base64url') const sign = (payload: string): string => hmacSha256Base64(payload, env.INTERNAL_API_SECRET) /** - * Sign an upload session token binding every supplied field to its signature. - * Multipart sessions include the caller, workspace, storage context and key, - * purpose, provider state, file metadata, part geometry, and—for knowledge - * documents—the target knowledge base. Follow-up calls reconstruct their - * complete trusted session exclusively from this signed state. + * Signs the complete, immutable state of one upload session. + * + * Version 2 intentionally has no compatibility parser for legacy multipart tokens. A token must + * carry a purpose-specific scope, transfer method, staging and final keys, provider state, exact + * object identity, and one canonical expiry. */ -export function signUploadToken(payload: UploadTokenPayload, expiresInSeconds = 60 * 60): string { +export function signUploadToken(payload: UploadTokenPayload): string { + assertUploadTokenPayload(payload) + const expiresAt = new Date(payload.expiresAt) const signed: SignedPayload = { ...payload, - exp: Math.floor(Date.now() / 1000) + expiresInSeconds, - v: 1, + exp: Math.floor(expiresAt.getTime() / 1000), + v: 2, } const encoded = toBase64Url(JSON.stringify(signed)) return `${encoded}.${sign(encoded)}` @@ -67,74 +138,172 @@ export type UploadTokenVerification = | { valid: false } export function verifyUploadToken(token: string): UploadTokenVerification { - if (typeof token !== 'string') { - return { valid: false } - } + if (typeof token !== 'string') return { valid: false } const parts = token.split('.') if (parts.length !== 2) return { valid: false } const [encoded, signature] = parts - if (!encoded || !signature) return { valid: false } + if (!encoded || !signature || !safeCompare(signature, sign(encoded))) return { valid: false } - const expected = sign(encoded) - if (!safeCompare(signature, expected)) { + let parsed: unknown + try { + parsed = JSON.parse(fromBase64Url(encoded)) + } catch { return { valid: false } } - let parsed: SignedPayload + if (!isRecord(parsed) || parsed.v !== 2 || !isSafePositiveInteger(parsed.exp)) { + return { valid: false } + } + if (parsed.exp <= Math.floor(Date.now() / 1000)) return { valid: false } + try { - parsed = JSON.parse(fromBase64Url(encoded)) as SignedPayload + assertUploadTokenPayload(parsed) } catch { return { valid: false } } + if (Math.floor(new Date(parsed.expiresAt).getTime() / 1000) !== parsed.exp) { + return { valid: false } + } + + const { exp: _exp, v: _version, ...payload } = parsed + return { valid: true, payload } +} + +function assertUploadTokenPayload(value: unknown): asserts value is UploadTokenPayload { + if (!isRecord(value)) throw new Error('Upload token payload must be an object') + + const purposeKeys = + value.purpose === 'knowledge_document' + ? ['knowledgeBaseId'] + : value.purpose === 'execution_attachment' + ? ['workflowId', 'executionId'] + : [] + const methodKeys = value.method === 'multipart' ? ['partSize', 'partCount'] : [] + const allowedKeys = new Set([...BASE_KEYS, ...purposeKeys, ...methodKeys]) + if (Object.keys(value).some((key) => !allowedKeys.has(key))) { + throw new Error('Upload token payload contains unexpected state') + } + if ( - parsed.v !== 1 || - typeof parsed.exp !== 'number' || - parsed.exp < Math.floor(Date.now() / 1000) || - typeof parsed.uploadId !== 'string' || - typeof parsed.key !== 'string' || - typeof parsed.userId !== 'string' || - typeof parsed.workspaceId !== 'string' || - typeof parsed.context !== 'string' + !isNonEmptyString(value.uploadId) || + !isNonEmptyString(value.actorId) || + !isNonEmptyString(value.finalKey) || + !isNonEmptyString(value.stagingKey) || + value.finalKey === value.stagingKey || + !value.stagingKey.startsWith(`upload-sessions/${value.uploadId}/`) || + !isNonEmptyString(value.fileName) || + !isNonEmptyString(value.contentType) || + !isValidFileSize(value.purpose, value.fileSize) || + !isPlainRecord(value.metadata) ) { - return { valid: false } + throw new Error('Upload token payload has invalid object state') + } + + if ( + value.provider !== 's3' && + value.provider !== 'blob' && + value.provider !== 'gcs' && + value.provider !== 'local' + ) { + throw new Error('Upload token payload has an invalid provider') + } + + if (value.method === 'put') { + if (value.providerUploadId !== null || 'partSize' in value || 'partCount' in value) { + throw new Error('PUT upload token has multipart state') + } + } else if (value.method === 'multipart') { + if (!isSafePositiveInteger(value.partSize) || !isSafePositiveInteger(value.partCount)) { + throw new Error('Multipart upload token has invalid geometry') + } + if (value.provider === 'local') { + if (value.providerUploadId !== null) { + throw new Error('Local multipart upload token has a provider upload id') + } + } else if (!isNonEmptyString(value.providerUploadId)) { + throw new Error('Cloud multipart upload token is missing its provider upload id') + } + } else { + throw new Error('Upload token payload has an invalid transfer method') + } + + assertPurposeScope(value) + + if (!isNonEmptyString(value.createdAt) || !isNonEmptyString(value.expiresAt)) { + throw new Error('Upload token payload is missing timestamps') + } + const createdAt = new Date(value.createdAt).getTime() + const expiresAt = new Date(value.expiresAt).getTime() + if (!Number.isFinite(createdAt) || !Number.isFinite(expiresAt) || expiresAt <= createdAt) { + throw new Error('Upload token payload has invalid timestamps') } +} - return { - valid: true, - payload: { - uploadId: parsed.uploadId, - key: parsed.key, - userId: parsed.userId, - workspaceId: parsed.workspaceId, - context: parsed.context as StorageContext, - ...(typeof parsed.knowledgeBaseId === 'string' - ? { knowledgeBaseId: parsed.knowledgeBaseId } - : {}), - ...(typeof parsed.fileName === 'string' ? { fileName: parsed.fileName } : {}), - ...(typeof parsed.contentType === 'string' ? { contentType: parsed.contentType } : {}), - ...(typeof parsed.fileSize === 'number' ? { fileSize: parsed.fileSize } : {}), - ...(parsed.purpose === 'workspace_file' || - parsed.purpose === 'table_import' || - parsed.purpose === 'knowledge_document' - ? { purpose: parsed.purpose } - : {}), - ...(parsed.provider === 's3' || - parsed.provider === 'blob' || - parsed.provider === 'gcs' || - parsed.provider === 'local' - ? { provider: parsed.provider } - : {}), - ...(typeof parsed.providerUploadId === 'string' || parsed.providerUploadId === null - ? { providerUploadId: parsed.providerUploadId } - : {}), - ...(typeof parsed.partSize === 'number' ? { partSize: parsed.partSize } : {}), - ...(typeof parsed.partCount === 'number' ? { partCount: parsed.partCount } : {}), - ...(parsed.metadata && typeof parsed.metadata === 'object' && !Array.isArray(parsed.metadata) - ? { metadata: parsed.metadata } - : {}), - ...(typeof parsed.createdAt === 'string' ? { createdAt: parsed.createdAt } : {}), - ...(typeof parsed.expiresAt === 'string' ? { expiresAt: parsed.expiresAt } : {}), - }, +function assertPurposeScope(value: Record): void { + switch (value.purpose) { + case 'workspace_file': + assertWorkspacePurpose(value, 'workspace') + break + case 'table_import': + assertWorkspacePurpose(value, 'table-import') + break + case 'knowledge_document': + assertWorkspacePurpose(value, 'knowledge-base') + if (!isNonEmptyString(value.knowledgeBaseId)) { + throw new Error('Knowledge upload token is missing knowledgeBaseId') + } + break + case 'profile_picture': + if (value.workspaceId !== null || value.context !== 'profile-pictures') { + throw new Error('Profile-picture upload token has invalid scope') + } + break + case 'workspace_logo': + assertWorkspacePurpose(value, 'workspace-logos') + break + case 'mothership_attachment': + assertWorkspacePurpose(value, 'mothership') + break + case 'execution_attachment': + assertWorkspacePurpose(value, 'execution') + if (!isNonEmptyString(value.workflowId) || !isNonEmptyString(value.executionId)) { + throw new Error('Execution upload token is missing workflow scope') + } + break + default: + throw new Error('Upload token payload has an invalid purpose') } } + +function assertWorkspacePurpose(value: Record, context: string): void { + if (!isNonEmptyString(value.workspaceId) || value.context !== context) { + throw new Error('Upload token payload has invalid workspace scope') + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isPlainRecord(value: unknown): value is Record { + if (!isRecord(value)) return false + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +function isSafePositiveInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 +} + +function isValidFileSize(purpose: unknown, value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isSafeInteger(value) && + (value > 0 || (purpose === 'workspace_file' && value === 0)) + ) +} diff --git a/apps/sim/lib/uploads/multipart-session/provider.ts b/apps/sim/lib/uploads/multipart-session/provider.ts deleted file mode 100644 index 64e258cc879..00000000000 --- a/apps/sim/lib/uploads/multipart-session/provider.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { createReadStream, createWriteStream } from 'node:fs' -import { mkdir, rename, rm, stat } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { pipeline } from 'node:stream/promises' -import { getErrorMessage } from '@sim/utils/errors' -import { - getStorageConfig, - USE_BLOB_STORAGE, - USE_GCS_STORAGE, - USE_S3_STORAGE, -} from '@/lib/uploads/config' -import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server' -import { - createBlobConfig, - createGcsConfig, - createS3Config, -} from '@/lib/uploads/core/storage-service' -import type { StorageContext } from '@/lib/uploads/shared/types' -import { sanitizeFileKey } from '@/lib/uploads/utils/file-utils' - -export type MultipartStorageProvider = 's3' | 'blob' | 'gcs' | 'local' - -export interface CompletedUploadPart { - partNumber: number - etag?: string -} - -export interface MultipartPartUrl { - partNumber: number - url: string - headers: Record - expiresAt: string -} - -export function multipartStorageProvider(): MultipartStorageProvider { - if (USE_BLOB_STORAGE) return 'blob' - if (USE_S3_STORAGE) return 's3' - if (USE_GCS_STORAGE) return 'gcs' - return 'local' -} - -export async function initiateMultipartProviderUpload(params: { - key: string - fileName: string - contentType: string - fileSize: number - context: StorageContext - localUploadId: string -}): Promise<{ provider: MultipartStorageProvider; providerUploadId: string | null }> { - const { key, fileName, contentType, fileSize, context, localUploadId } = params - const provider = multipartStorageProvider() - const config = getStorageConfig(context) - - if (provider === 's3') { - const { initiateS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') - const result = await initiateS3MultipartUpload({ - fileName, - contentType, - fileSize, - customConfig: createS3Config(config), - customKey: key, - purpose: context, - }) - return { provider, providerUploadId: result.uploadId } - } - if (provider === 'blob') { - const { initiateMultipartUpload } = await import('@/lib/uploads/providers/blob/client') - const result = await initiateMultipartUpload({ - fileName, - contentType, - fileSize, - customConfig: createBlobConfig(config), - customKey: key, - }) - return { provider, providerUploadId: result.uploadId } - } - if (provider === 'gcs') { - const { initiateGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') - const result = await initiateGcsMultipartUpload({ - fileName, - contentType, - fileSize, - customConfig: createGcsConfig(config), - customKey: key, - purpose: context, - }) - return { provider, providerUploadId: result.uploadId } - } - - await mkdir(localPartsDirectory(localUploadId), { recursive: true }) - return { provider, providerUploadId: null } -} - -export async function getMultipartProviderPartUrls(params: { - provider: MultipartStorageProvider - providerUploadId: string | null - key: string - context: StorageContext - partNumbers: number[] - localUrl: (partNumber: number) => string -}): Promise { - const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString() - const { provider, providerUploadId, key, context, partNumbers } = params - if (provider === 'local') { - return partNumbers.map((partNumber) => ({ - partNumber, - url: params.localUrl(partNumber), - headers: { 'Content-Type': 'application/octet-stream' }, - expiresAt, - })) - } - if (!providerUploadId) throw new Error(`Missing ${provider} multipart upload id`) - const config = getStorageConfig(context) - - if (provider === 's3') { - const { getS3MultipartPartUrls } = await import('@/lib/uploads/providers/s3/client') - const urls = await getS3MultipartPartUrls( - key, - providerUploadId, - partNumbers, - createS3Config(config) - ) - return urls.map(({ partNumber, url }) => ({ - partNumber, - url, - headers: { 'Content-Type': 'application/octet-stream' }, - expiresAt, - })) - } - if (provider === 'blob') { - const { getMultipartPartUrls } = await import('@/lib/uploads/providers/blob/client') - const urls = await getMultipartPartUrls(key, partNumbers, createBlobConfig(config)) - return urls.map(({ partNumber, url }) => ({ - partNumber, - url, - headers: { 'Content-Type': 'application/octet-stream' }, - expiresAt, - })) - } - const { getGcsMultipartPartUrls } = await import('@/lib/uploads/providers/gcs/client') - const urls = await getGcsMultipartPartUrls( - key, - providerUploadId, - partNumbers, - createGcsConfig(config) - ) - return urls.map(({ partNumber, url }) => ({ - partNumber, - url, - headers: { 'Content-Type': 'application/octet-stream' }, - expiresAt, - })) -} - -export async function completeMultipartProviderUpload(params: { - provider: MultipartStorageProvider - providerUploadId: string | null - uploadId: string - key: string - contentType: string - context: StorageContext - parts: CompletedUploadPart[] -}): Promise { - const { provider, providerUploadId, uploadId, key, contentType, context, parts } = params - if (provider === 'local') { - await assembleLocalParts(uploadId, key, parts) - return - } - if (!providerUploadId) throw new Error(`Missing ${provider} multipart upload id`) - const config = getStorageConfig(context) - if (provider === 's3') { - const { completeS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') - await completeS3MultipartUpload( - key, - providerUploadId, - parts.map((part) => ({ - PartNumber: part.partNumber, - ETag: requiredEtag(provider, part), - })), - createS3Config(config) - ) - return - } - if (provider === 'blob') { - const { completeMultipartUpload, deriveBlobBlockId } = await import( - '@/lib/uploads/providers/blob/client' - ) - await completeMultipartUpload( - key, - parts.map((part) => ({ - partNumber: part.partNumber, - blockId: deriveBlobBlockId(part.partNumber), - })), - createBlobConfig(config), - contentType - ) - return - } - const { completeGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') - await completeGcsMultipartUpload( - key, - providerUploadId, - parts.map((part) => ({ - PartNumber: part.partNumber, - ETag: requiredEtag(provider, part), - })), - createGcsConfig(config) - ) -} - -export async function abortMultipartProviderUpload(params: { - provider: MultipartStorageProvider - providerUploadId: string | null - uploadId: string - key: string - context: StorageContext -}): Promise { - const { provider, providerUploadId, uploadId, key, context } = params - if (provider === 'local') { - await rm(localPartsDirectory(uploadId), { recursive: true, force: true }) - const destination = join(UPLOAD_DIR_SERVER, sanitizeFileKey(key)) - await rm(`${destination}.uploading-${uploadId}`, { force: true }) - return - } - if (!providerUploadId) throw new Error(`Missing ${provider} multipart upload id`) - const config = getStorageConfig(context) - if (provider === 's3') { - const { abortS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') - await abortS3MultipartUpload(key, providerUploadId, createS3Config(config)) - return - } - if (provider === 'blob') { - const { abortMultipartUpload } = await import('@/lib/uploads/providers/blob/client') - await abortMultipartUpload(key, createBlobConfig(config)) - return - } - const { abortGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') - await abortGcsMultipartUpload(key, providerUploadId, createGcsConfig(config)) -} - -export async function writeLocalMultipartPart(params: { - uploadId: string - partNumber: number - body: ReadableStream - expectedSize: number -}): Promise { - const { Readable, Transform } = await import('node:stream') - const directory = localPartsDirectory(params.uploadId) - await mkdir(directory, { recursive: true }) - const destination = localPartPath(params.uploadId, params.partNumber) - let bytes = 0 - const counter = new Transform({ - transform(chunk: Buffer, _encoding, callback) { - bytes += chunk.length - if (bytes > params.expectedSize) { - callback(new Error(`Part ${params.partNumber} exceeds ${params.expectedSize} bytes`)) - return - } - callback(null, chunk) - }, - }) - try { - await pipeline( - Readable.fromWeb(params.body as Parameters[0]), - counter, - createWriteStream(destination, { flags: 'w' }) - ) - if (bytes !== params.expectedSize) { - throw new Error( - `Part ${params.partNumber} has ${bytes} bytes; expected ${params.expectedSize}` - ) - } - } catch (error) { - await rm(destination, { force: true }).catch(() => {}) - throw new Error(getErrorMessage(error, `Failed to store part ${params.partNumber}`), { - cause: error, - }) - } -} - -function localPartsDirectory(uploadId: string): string { - return join(UPLOAD_DIR_SERVER, '.multipart', uploadId) -} - -function localPartPath(uploadId: string, partNumber: number): string { - return join(localPartsDirectory(uploadId), `${partNumber}.part`) -} - -async function assembleLocalParts( - uploadId: string, - key: string, - parts: CompletedUploadPart[] -): Promise { - const safeKey = sanitizeFileKey(key) - const destination = join(UPLOAD_DIR_SERVER, safeKey) - const temporary = `${destination}.uploading-${uploadId}` - await mkdir(dirname(destination), { recursive: true }) - await rm(temporary, { force: true }) - try { - for (const part of parts) { - await pipeline( - createReadStream(localPartPath(uploadId, part.partNumber)), - createWriteStream(temporary, { flags: 'a' }) - ) - } - const assembled = await stat(temporary) - if (assembled.size === 0) throw new Error('Assembled upload is empty') - await rename(temporary, destination) - await rm(localPartsDirectory(uploadId), { recursive: true, force: true }) - } catch (error) { - await rm(temporary, { force: true }).catch(() => {}) - throw error - } -} - -function requiredEtag(provider: 's3' | 'gcs', part: CompletedUploadPart): string { - if (!part.etag) throw new Error(`Missing etag for ${provider} part ${part.partNumber}`) - return part.etag -} diff --git a/apps/sim/lib/uploads/multipart-session/service.test.ts b/apps/sim/lib/uploads/multipart-session/service.test.ts deleted file mode 100644 index 3c030a9028b..00000000000 --- a/apps/sim/lib/uploads/multipart-session/service.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckStorageQuotaForBillingContext, - mockInitiateMultipartProviderUpload, - mockResolveStorageBillingContext, -} = vi.hoisted(() => ({ - mockCheckStorageQuotaForBillingContext: vi.fn(), - mockInitiateMultipartProviderUpload: vi.fn(), - mockResolveStorageBillingContext: vi.fn(), -})) - -vi.mock('@/lib/billing/storage', () => ({ - checkStorageQuotaForBillingContext: mockCheckStorageQuotaForBillingContext, - resolveStorageBillingContext: mockResolveStorageBillingContext, -})) - -vi.mock('@/lib/uploads/core/storage-service', () => ({ headObject: vi.fn() })) - -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - generateWorkspaceFileKey: vi.fn( - (workspaceId: string, fileName: string) => `workspace/${workspaceId}/${fileName}` - ), -})) - -vi.mock('@/lib/uploads/multipart-session/provider', () => ({ - abortMultipartProviderUpload: vi.fn(), - completeMultipartProviderUpload: vi.fn(), - getMultipartProviderPartUrls: vi.fn(), - initiateMultipartProviderUpload: mockInitiateMultipartProviderUpload, -})) - -import { - createUploadSession, - getOwnedUploadSession, - verifyUploadSessionToken, -} from '@/lib/uploads/multipart-session/service' - -const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' - -describe('knowledge-document multipart sessions', () => { - beforeEach(() => { - vi.clearAllMocks() - mockResolveStorageBillingContext.mockResolvedValue({ workspaceId: WORKSPACE_ID }) - mockCheckStorageQuotaForBillingContext.mockResolvedValue({ allowed: true }) - mockInitiateMultipartProviderUpload.mockResolvedValue({ - provider: 's3', - providerUploadId: 'provider-upload-1', - }) - }) - - it('binds knowledge ownership and all storage state into the signed token', async () => { - const created = await createUploadSession({ - id: 'upload-1', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - knowledgeBaseId: 'kb-1', - purpose: 'knowledge_document', - fileName: 'guide.pdf', - contentType: 'application/pdf', - fileSize: 1024, - }) - - const verified = verifyUploadSessionToken(created.uploadToken) - expect(verified).toMatchObject({ - id: 'upload-1', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - knowledgeBaseId: 'kb-1', - purpose: 'knowledge_document', - storageContext: 'knowledge-base', - storageProvider: 's3', - providerUploadId: 'provider-upload-1', - fileName: 'guide.pdf', - contentType: 'application/pdf', - fileSize: 1024, - }) - expect(verified.storageKey).toMatch(/^kb\/.*-guide\.pdf$/) - }) - - it.each([ - { userId: 'other-user', knowledgeBaseId: 'kb-1', purpose: 'knowledge_document' as const }, - { userId: 'user-1', knowledgeBaseId: 'kb-2', purpose: 'knowledge_document' as const }, - { userId: 'user-1', knowledgeBaseId: 'kb-1', purpose: 'workspace_file' as const }, - ])('rejects a session whose signed scope does not match $purpose', async (scope) => { - const created = await createUploadSession({ - id: 'upload-1', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - knowledgeBaseId: 'kb-1', - purpose: 'knowledge_document', - fileName: 'guide.pdf', - contentType: 'application/pdf', - fileSize: 1024, - }) - - expect(() => - getOwnedUploadSession({ - uploadId: 'upload-1', - workspaceId: WORKSPACE_ID, - uploadToken: created.uploadToken, - ...scope, - }) - ).toThrow('Upload session not found') - }) - - it('runs the storage quota gate before creating provider state', async () => { - mockCheckStorageQuotaForBillingContext.mockResolvedValue({ - allowed: false, - error: 'Storage limit exceeded', - }) - - await expect( - createUploadSession({ - workspaceId: WORKSPACE_ID, - userId: 'user-1', - knowledgeBaseId: 'kb-1', - purpose: 'knowledge_document', - fileName: 'guide.pdf', - contentType: 'application/pdf', - fileSize: 1024, - }) - ).rejects.toMatchObject({ code: 'payload_too_large' }) - expect(mockInitiateMultipartProviderUpload).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/uploads/multipart-session/service.ts b/apps/sim/lib/uploads/multipart-session/service.ts deleted file mode 100644 index 2157091cb73..00000000000 --- a/apps/sim/lib/uploads/multipart-session/service.ts +++ /dev/null @@ -1,454 +0,0 @@ -import { generateId } from '@sim/utils/id' -import { - checkStorageQuotaForBillingContext, - resolveStorageBillingContext, -} from '@/lib/billing/storage' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' -import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace' -import { headObject } from '@/lib/uploads/core/storage-service' -import { signUploadToken, verifyUploadToken } from '@/lib/uploads/core/upload-token' -import { - abortMultipartProviderUpload, - type CompletedUploadPart, - completeMultipartProviderUpload, - getMultipartProviderPartUrls, - initiateMultipartProviderUpload, - type MultipartPartUrl, - type MultipartStorageProvider, -} from '@/lib/uploads/multipart-session/provider' -import { - MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE, - MAX_WORKSPACE_FILE_SIZE, - type StorageContext, -} from '@/lib/uploads/shared/types' -import { sanitizeFileName } from '@/executor/constants' - -export const MULTIPART_SESSION_PART_SIZE = 8 * 1024 * 1024 -export const MULTIPART_SESSION_MAX_PART_URLS = 100 -export const MULTIPART_SESSION_TTL_MS = 24 * 60 * 60 * 1000 - -export type UploadSessionPurpose = 'workspace_file' | 'table_import' | 'knowledge_document' -export type UploadSessionStatus = 'uploading' | 'completed' | 'aborted' - -export interface UploadSessionRecord { - id: string - workspaceId: string - userId: string - knowledgeBaseId: string | null - purpose: UploadSessionPurpose - storageContext: StorageContext - storageKey: string - storageProvider: MultipartStorageProvider - providerUploadId: string | null - fileName: string - contentType: string - fileSize: number - partSize: number - partCount: number - status: UploadSessionStatus - metadata: Record - uploadToken: string - createdAt: Date - expiresAt: Date - completedFileId: string | null - error: string | null - completedAt: Date | null - updatedAt: Date -} - -export class UploadSessionError extends OrchestrationError { - constructor( - code: 'validation' | 'not_found' | 'forbidden' | 'conflict' | 'payload_too_large' | 'internal', - message: string - ) { - super(code, message) - this.name = 'UploadSessionError' - } -} - -interface CreateUploadSessionBaseParams { - id?: string - workspaceId: string - userId: string - fileName: string - contentType: string - fileSize: number - metadata?: Record -} - -type CreateUploadSessionParams = CreateUploadSessionBaseParams & - ( - | { purpose: 'workspace_file' | 'table_import'; knowledgeBaseId?: never } - | { purpose: 'knowledge_document'; knowledgeBaseId: string } - ) - -export async function createUploadSession( - params: CreateUploadSessionParams -): Promise { - validateFile(params) - const id = params.id ?? generateId() - const { storageContext, storageKey } = resolveUploadStorage(params, id) - const partCount = Math.ceil(params.fileSize / MULTIPART_SESSION_PART_SIZE) - - if (params.purpose === 'workspace_file' || params.purpose === 'knowledge_document') { - const billingContext = await resolveStorageBillingContext(params.workspaceId) - const quota = await checkStorageQuotaForBillingContext(billingContext, params.fileSize) - if (!quota.allowed) { - throw new UploadSessionError('payload_too_large', quota.error ?? 'Storage limit exceeded') - } - } - - const initiated = await initiateMultipartProviderUpload({ - key: storageKey, - fileName: params.fileName, - contentType: params.contentType, - fileSize: params.fileSize, - context: storageContext, - localUploadId: id, - }) - const createdAt = new Date() - const expiresAt = new Date(createdAt.getTime() + MULTIPART_SESSION_TTL_MS) - const metadata = params.metadata ?? {} - const uploadToken = signUploadToken( - { - uploadId: id, - key: storageKey, - userId: params.userId, - workspaceId: params.workspaceId, - context: storageContext, - ...(params.purpose === 'knowledge_document' - ? { knowledgeBaseId: params.knowledgeBaseId } - : {}), - fileName: params.fileName, - contentType: params.contentType, - fileSize: params.fileSize, - purpose: params.purpose, - provider: initiated.provider, - providerUploadId: initiated.providerUploadId, - partSize: MULTIPART_SESSION_PART_SIZE, - partCount, - metadata, - createdAt: createdAt.toISOString(), - expiresAt: expiresAt.toISOString(), - }, - MULTIPART_SESSION_TTL_MS / 1000 - ) - - return { - id, - workspaceId: params.workspaceId, - userId: params.userId, - knowledgeBaseId: params.purpose === 'knowledge_document' ? params.knowledgeBaseId : null, - purpose: params.purpose, - storageContext, - storageKey, - storageProvider: initiated.provider, - providerUploadId: initiated.providerUploadId, - fileName: params.fileName, - contentType: params.contentType, - fileSize: params.fileSize, - partSize: MULTIPART_SESSION_PART_SIZE, - partCount, - status: 'uploading', - metadata, - uploadToken, - createdAt, - expiresAt, - completedFileId: null, - error: null, - completedAt: null, - updatedAt: createdAt, - } -} - -export function getOwnedUploadSession(params: { - uploadId: string - workspaceId: string - userId?: string - purpose: UploadSessionPurpose - knowledgeBaseId?: string - uploadToken: string -}): UploadSessionRecord { - const session = verifyUploadSessionToken(params.uploadToken) - if (session.id !== params.uploadId || session.workspaceId !== params.workspaceId) { - throw new UploadSessionError('not_found', 'Upload session not found') - } - if (params.userId && session.userId !== params.userId) { - throw new UploadSessionError('not_found', 'Upload session not found') - } - if (session.purpose !== params.purpose) { - throw new UploadSessionError('not_found', 'Upload session not found') - } - if (params.knowledgeBaseId !== undefined && session.knowledgeBaseId !== params.knowledgeBaseId) { - throw new UploadSessionError('not_found', 'Upload session not found') - } - return session -} - -export function verifyUploadSessionToken(uploadToken: string): UploadSessionRecord { - const verified = verifyUploadToken(uploadToken) - if (!verified.valid) throw new UploadSessionError('forbidden', 'Invalid or expired upload token') - const payload = verified.payload - if ( - !payload.fileName || - !payload.contentType || - typeof payload.fileSize !== 'number' || - !Number.isSafeInteger(payload.fileSize) || - !payload.purpose || - !payload.provider || - typeof payload.partSize !== 'number' || - !Number.isSafeInteger(payload.partSize) || - typeof payload.partCount !== 'number' || - !Number.isSafeInteger(payload.partCount) || - !payload.createdAt || - !payload.expiresAt - ) { - throw new UploadSessionError('forbidden', 'Upload token is not a multipart session token') - } - if ( - payload.context !== 'workspace' && - payload.context !== 'table-import' && - payload.context !== 'knowledge-base' - ) { - throw new UploadSessionError('forbidden', 'Upload token has an invalid storage context') - } - const knowledgeBaseId = payload.knowledgeBaseId?.trim() || null - if ( - (payload.purpose === 'workspace_file' && payload.context !== 'workspace') || - (payload.purpose === 'table_import' && payload.context !== 'table-import') || - (payload.purpose === 'knowledge_document' && - (payload.context !== 'knowledge-base' || !knowledgeBaseId || !payload.key.startsWith('kb/'))) - ) { - throw new UploadSessionError('forbidden', 'Upload token purpose does not match its storage') - } - if (payload.purpose !== 'knowledge_document' && knowledgeBaseId) { - throw new UploadSessionError('forbidden', 'Upload token has unexpected knowledge-base state') - } - const createdAt = new Date(payload.createdAt) - const expiresAt = new Date(payload.expiresAt) - if (!Number.isFinite(createdAt.getTime()) || !Number.isFinite(expiresAt.getTime())) { - throw new UploadSessionError('forbidden', 'Upload token has invalid timestamps') - } - const now = new Date() - return { - id: payload.uploadId, - workspaceId: payload.workspaceId, - userId: payload.userId, - knowledgeBaseId, - purpose: payload.purpose, - storageContext: payload.context, - storageKey: payload.key, - storageProvider: payload.provider, - providerUploadId: payload.providerUploadId ?? null, - fileName: payload.fileName, - contentType: payload.contentType, - fileSize: payload.fileSize, - partSize: payload.partSize, - partCount: payload.partCount, - status: 'uploading', - metadata: payload.metadata ?? {}, - uploadToken, - createdAt, - expiresAt, - completedFileId: null, - error: null, - completedAt: null, - updatedAt: now, - } -} - -export async function createUploadPartUrls(params: { - session: UploadSessionRecord - partNumbers: number[] - localOrigin: string -}): Promise { - assertUploadable(params.session) - const unique = new Set(params.partNumbers) - if (unique.size !== params.partNumbers.length) { - throw new UploadSessionError('validation', 'partNumbers must not contain duplicates') - } - if ( - params.partNumbers.length === 0 || - params.partNumbers.length > MULTIPART_SESSION_MAX_PART_URLS - ) { - throw new UploadSessionError( - 'validation', - `partNumbers must contain between 1 and ${MULTIPART_SESSION_MAX_PART_URLS} entries` - ) - } - for (const partNumber of params.partNumbers) { - if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > params.session.partCount) { - throw new UploadSessionError( - 'validation', - `partNumber must be between 1 and ${params.session.partCount}` - ) - } - } - - return getMultipartProviderPartUrls({ - provider: params.session.storageProvider, - providerUploadId: params.session.providerUploadId, - key: params.session.storageKey, - context: params.session.storageContext, - partNumbers: params.partNumbers, - localUrl: (partNumber) => - `${params.localOrigin}/api/v2/uploads/${params.session.id}/parts/${partNumber}?token=${encodeURIComponent(params.session.uploadToken)}`, - }) -} - -export async function completeUploadSession(params: { - session: UploadSessionRecord - parts: CompletedUploadPart[] - finalize: (session: UploadSessionRecord) => Promise<{ value: T; completedFileId?: string }> -}): Promise<{ session: UploadSessionRecord; value: T; alreadyCompleted: boolean }> { - assertUploadable(params.session) - validateCompletedParts(params.session, params.parts) - - const existingObject = await headObject(params.session.storageKey, params.session.storageContext) - const alreadyCompleted = existingObject?.size === params.session.fileSize - if (existingObject && !alreadyCompleted) { - throw new UploadSessionError( - 'conflict', - `Upload object has ${existingObject.size} bytes; expected ${params.session.fileSize}` - ) - } - if (!alreadyCompleted) { - await completeMultipartProviderUpload({ - provider: params.session.storageProvider, - providerUploadId: params.session.providerUploadId, - uploadId: params.session.id, - key: params.session.storageKey, - contentType: params.session.contentType, - context: params.session.storageContext, - parts: params.parts, - }) - } - - const head = await headObject(params.session.storageKey, params.session.storageContext) - if (!head) throw new Error('Completed upload object not found') - if (head.size !== params.session.fileSize) { - throw new UploadSessionError( - 'validation', - `Uploaded object has ${head.size} bytes; expected ${params.session.fileSize}` - ) - } - - const finalized = await params.finalize(params.session) - const completedAt = new Date() - return { - session: { - ...params.session, - status: 'completed', - completedFileId: finalized.completedFileId ?? null, - completedAt, - updatedAt: completedAt, - }, - value: finalized.value, - alreadyCompleted, - } -} - -export async function abortUploadSession( - session: UploadSessionRecord -): Promise { - assertUploadable(session) - await abortMultipartProviderUpload({ - provider: session.storageProvider, - providerUploadId: session.providerUploadId, - uploadId: session.id, - key: session.storageKey, - context: session.storageContext, - }) - const completedAt = new Date() - return { ...session, status: 'aborted', completedAt, updatedAt: completedAt } -} - -export function expectedUploadPartSize(session: UploadSessionRecord, partNumber: number): number { - if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > session.partCount) { - throw new UploadSessionError('validation', 'Invalid upload part number') - } - if (partNumber < session.partCount) return session.partSize - return session.fileSize - session.partSize * (session.partCount - 1) -} - -function assertUploadable(session: UploadSessionRecord): void { - if (session.status !== 'uploading') { - throw new UploadSessionError('conflict', `Upload session is ${session.status}`) - } - if (session.expiresAt.getTime() <= Date.now()) { - throw new UploadSessionError('conflict', 'Upload session has expired') - } -} - -function validateCompletedParts(session: UploadSessionRecord, parts: CompletedUploadPart[]): void { - if (parts.length !== session.partCount) { - throw new UploadSessionError( - 'validation', - `Expected ${session.partCount} completed parts; received ${parts.length}` - ) - } - const sorted = [...parts].sort((a, b) => a.partNumber - b.partNumber) - for (let index = 0; index < sorted.length; index++) { - if (sorted[index].partNumber !== index + 1) { - throw new UploadSessionError( - 'validation', - 'Completed parts must contain every part exactly once' - ) - } - if ( - (session.storageProvider === 's3' || session.storageProvider === 'gcs') && - !sorted[index].etag - ) { - throw new UploadSessionError( - 'validation', - `etag is required for ${session.storageProvider} part ${sorted[index].partNumber}` - ) - } - } -} - -function validateFile(params: CreateUploadSessionParams): void { - if (!params.fileName.trim()) { - throw new UploadSessionError('validation', 'fileName must not be empty') - } - if (!params.contentType.trim()) { - throw new UploadSessionError('validation', 'contentType must not be empty') - } - if (!Number.isSafeInteger(params.fileSize) || params.fileSize < 1) { - throw new UploadSessionError('validation', 'fileSize must be a positive integer') - } - const maximum = - params.purpose === 'knowledge_document' - ? MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE - : MAX_WORKSPACE_FILE_SIZE - if (params.fileSize > maximum) { - throw new UploadSessionError('validation', `File size exceeds maximum of ${maximum} bytes`) - } - if (params.purpose === 'knowledge_document' && !params.knowledgeBaseId.trim()) { - throw new UploadSessionError('validation', 'knowledgeBaseId must not be empty') - } -} - -function resolveUploadStorage( - params: CreateUploadSessionParams, - id: string -): { storageContext: StorageContext; storageKey: string } { - switch (params.purpose) { - case 'workspace_file': - return { - storageContext: 'workspace', - storageKey: generateWorkspaceFileKey(params.workspaceId, params.fileName), - } - case 'table_import': - return { - storageContext: 'table-import', - storageKey: `table-import/${params.workspaceId}/${id}/${sanitizeFileName(params.fileName)}`, - } - case 'knowledge_document': - return { - storageContext: 'knowledge-base', - storageKey: generateKnowledgeBaseFileKey(params.fileName), - } - } -} diff --git a/apps/sim/lib/uploads/providers/blob/client.test.ts b/apps/sim/lib/uploads/providers/blob/client.test.ts index f31cf572108..5b604425474 100644 --- a/apps/sim/lib/uploads/providers/blob/client.test.ts +++ b/apps/sim/lib/uploads/providers/blob/client.test.ts @@ -10,6 +10,9 @@ const { mockDownload, mockDelete, mockDeleteIfExists, + mockBeginCopyFromURL, + mockPollUntilDone, + mockGetProperties, mockGetBlockBlobClient, mockGetContainerClient, mockFromConnectionString, @@ -21,6 +24,9 @@ const { mockDownload: vi.fn(), mockDelete: vi.fn(), mockDeleteIfExists: vi.fn(), + mockBeginCopyFromURL: vi.fn(), + mockPollUntilDone: vi.fn(), + mockGetProperties: vi.fn(), mockGetBlockBlobClient: vi.fn(), mockGetContainerClient: vi.fn(), mockFromConnectionString: vi.fn(), @@ -52,10 +58,14 @@ vi.mock('@/lib/uploads/config', () => ({ import { abortMultipartUpload, + deleteBlobObjectVersion, deleteFromBlob, downloadFromBlob, + getBlobPresignedUploadUrl, getPresignedUrl, + headBlobObject, parseConnectionString, + promoteBlobObject, uploadToBlob, } from '@/lib/uploads/providers/blob/client' import { sanitizeFilenameForMetadata } from '@/lib/uploads/utils/file-utils' @@ -71,6 +81,8 @@ describe('Azure Blob Storage Client', () => { download: mockDownload, delete: mockDelete, deleteIfExists: mockDeleteIfExists, + beginCopyFromURL: mockBeginCopyFromURL, + getProperties: mockGetProperties, url: 'https://test.blob.core.windows.net/container/test-file', }) @@ -85,6 +97,8 @@ describe('Azure Blob Storage Client', () => { mockGenerateBlobSASQueryParameters.mockReturnValue({ toString: () => 'sv=2021-06-08&se=2023-01-01T00%3A00%3A00Z&sr=b&sp=r&sig=test', }) + mockBeginCopyFromURL.mockResolvedValue({ pollUntilDone: mockPollUntilDone }) + mockPollUntilDone.mockResolvedValue({ copyStatus: 'success' }) }) describe('uploadToBlob', () => { @@ -136,6 +150,94 @@ describe('Azure Blob Storage Client', () => { }) }) + describe('staged upload primitives', () => { + const customConfig = { + containerName: 'testcontainer', + accountName: 'testaccount', + accountKey: 'testkey', + connectionString: + 'DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=testkey;EndpointSuffix=core.windows.net', + } + + it('signs a PUT with the required blob and metadata headers', async () => { + mockBlobSASPermissionsParse.mockReturnValueOnce('w') + + const result = await getBlobPresignedUploadUrl({ + key: 'upload-sessions/upload-1/file.bin', + contentType: 'application/octet-stream', + metadata: { uploadId: 'upload-1', purpose: 'workspace_file' }, + customConfig, + expiresIn: 600, + }) + + expect(mockBlobSASPermissionsParse).toHaveBeenCalledWith('w') + expect(result).toEqual({ + url: expect.stringContaining('?sv=2021-06-08'), + headers: { + 'Content-Type': 'application/octet-stream', + 'x-ms-blob-type': 'BlockBlob', + 'x-ms-blob-content-type': 'application/octet-stream', + 'x-ms-meta-uploadId': 'upload-1', + 'x-ms-meta-purpose': 'workspace_file', + }, + }) + }) + + it('pins the source ETag and requires an absent promotion destination', async () => { + await promoteBlobObject({ + sourceKey: 'upload-sessions/upload-1/file.bin', + destinationKey: 'workspace/workspace-1/file.bin', + sourceEtag: '"etag-1"', + customConfig, + }) + + expect(mockBeginCopyFromURL).toHaveBeenCalledWith( + 'https://test.blob.core.windows.net/container/test-file', + { + conditions: { ifNoneMatch: '*' }, + sourceConditions: { ifMatch: '"etag-1"' }, + } + ) + expect(mockPollUntilDone).toHaveBeenCalledOnce() + }) + + it('returns only completed copied objects as usable upload identities', async () => { + mockGetProperties.mockResolvedValueOnce({ + contentLength: 3, + contentType: 'application/octet-stream', + metadata: { uploadid: 'upload-1' }, + etag: '"etag-1"', + copyStatus: 'success', + }) + + await expect(headBlobObject('workspace/workspace-1/file.bin', customConfig)).resolves.toEqual( + { + size: 3, + contentType: 'application/octet-stream', + uploadId: 'upload-1', + version: '"etag-1"', + } + ) + + mockGetProperties.mockResolvedValueOnce({ copyStatus: 'pending' }) + await expect(headBlobObject('workspace/workspace-1/file.bin', customConfig)).rejects.toThrow( + 'Blob copy for workspace/workspace-1/file.bin is pending' + ) + }) + + it('deletes staging only when its ETag still matches', async () => { + mockDeleteIfExists.mockResolvedValueOnce({}) + + await deleteBlobObjectVersion({ + key: 'upload-sessions/upload-1/file.bin', + etag: '"etag-1"', + customConfig, + }) + + expect(mockDeleteIfExists).toHaveBeenCalledWith({ conditions: { ifMatch: '"etag-1"' } }) + }) + }) + describe('downloadFromBlob', () => { it('should download a file from Azure Blob Storage', async () => { const testKey = 'test-file-key' diff --git a/apps/sim/lib/uploads/providers/blob/client.ts b/apps/sim/lib/uploads/providers/blob/client.ts index 7f2faf821e3..993eeda8c74 100644 --- a/apps/sim/lib/uploads/providers/blob/client.ts +++ b/apps/sim/lib/uploads/providers/blob/client.ts @@ -257,6 +257,52 @@ export async function getPresignedUrlWithConfig( return `${blockBlobClient.url}?${sasToken}` } +/** Generates a SAS-backed single-object PUT for a caller-selected staging key. */ +export async function getBlobPresignedUploadUrl(params: { + key: string + contentType: string + metadata: Record + customConfig: BlobConfig + expiresIn: number +}): Promise<{ url: string; headers: Record }> { + const { BlobSASPermissions, generateBlobSASQueryParameters, StorageSharedKeyCredential } = + await import('@azure/storage-blob') + const client = await getBlockBlobClientFor(params.key, params.customConfig) + const credentials = params.customConfig.connectionString + ? parseConnectionString(params.customConfig.connectionString) + : { + accountName: params.customConfig.accountName, + accountKey: params.customConfig.accountKey, + } + if (!credentials.accountName || !credentials.accountKey) { + throw new Error('Azure Blob SAS generation requires accountName and accountKey') + } + const startsOn = new Date() + const expiresOn = new Date(startsOn.getTime() + params.expiresIn * 1000) + const sasToken = generateBlobSASQueryParameters( + { + containerName: params.customConfig.containerName, + blobName: params.key, + permissions: BlobSASPermissions.parse('w'), + startsOn, + expiresOn, + }, + new StorageSharedKeyCredential(credentials.accountName, credentials.accountKey) + ).toString() + const metadata = sanitizeStorageMetadata(params.metadata, 8000) + return { + url: `${client.url}?${sasToken}`, + headers: { + 'Content-Type': params.contentType, + 'x-ms-blob-type': 'BlockBlob', + 'x-ms-blob-content-type': params.contentType, + ...Object.fromEntries( + Object.entries(metadata).map(([key, value]) => [`x-ms-meta-${key}`, value]) + ), + }, + } +} + /** * Download a file from Azure Blob Storage * @param key Blob name @@ -392,7 +438,12 @@ export async function downloadFromBlobStream( export async function headBlobObject( key: string, customConfig?: BlobConfig -): Promise<{ size: number; contentType?: string } | null> { +): Promise<{ + size: number + contentType?: string + uploadId?: string + version: string +} | null> { const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob') let blobServiceClient: BlobServiceClientType let containerName: string @@ -423,9 +474,14 @@ export async function headBlobObject( try { const properties = await blockBlobClient.getProperties() + if (properties.copyStatus && properties.copyStatus !== 'success') { + throw new Error(`Blob copy for ${key} is ${properties.copyStatus}`) + } return { size: properties.contentLength ?? 0, contentType: properties.contentType, + uploadId: readUploadId(properties.metadata), + version: properties.etag ?? '', } } catch (err) { const status = (err as { statusCode?: number }).statusCode @@ -437,6 +493,40 @@ export async function headBlobObject( } } +/** + * Copies one immutable staging version into a destination that must not already exist. + * The asynchronous API supports objects above the synchronous copy operation's 256 MiB limit. + */ +export async function promoteBlobObject(params: { + sourceKey: string + destinationKey: string + sourceEtag: string + customConfig: BlobConfig +}): Promise { + if (!params.sourceEtag) throw new Error('Blob staging object is missing its ETag') + const source = await getBlockBlobClientFor(params.sourceKey, params.customConfig) + const destination = await getBlockBlobClientFor(params.destinationKey, params.customConfig) + const copy = await destination.beginCopyFromURL(source.url, { + conditions: { ifNoneMatch: '*' }, + sourceConditions: { ifMatch: params.sourceEtag }, + }) + const result = await copy.pollUntilDone() + if (result.copyStatus !== 'success') { + throw new Error(`Blob promotion finished with status ${result.copyStatus ?? 'unknown'}`) + } +} + +/** Deletes a staging blob only if it is still the version completion inspected. */ +export async function deleteBlobObjectVersion(params: { + key: string + etag: string + customConfig: BlobConfig +}): Promise { + if (!params.etag) throw new Error('Blob staging object is missing its ETag') + const client = await getBlockBlobClientFor(params.key, params.customConfig) + await client.deleteIfExists({ conditions: { ifMatch: params.etag } }) +} + /** * Delete a file from Azure Blob Storage * @param key Blob name @@ -498,49 +588,13 @@ export function deriveBlobBlockId(partNumber: number): string { export async function initiateMultipartUpload( options: AzureMultipartUploadInit ): Promise<{ uploadId: string; key: string }> { - const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob') - const { fileName, contentType, customConfig, customKey } = options - - let blobServiceClient: BlobServiceClientType - let containerName: string - - if (customConfig) { - if (customConfig.connectionString) { - blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString) - } else if (customConfig.accountName && customConfig.accountKey) { - const credential = new StorageSharedKeyCredential( - customConfig.accountName, - customConfig.accountKey - ) - blobServiceClient = new BlobServiceClient( - `https://${customConfig.accountName}.blob.core.windows.net`, - credential - ) - } else { - throw new Error('Invalid custom blob configuration') - } - containerName = customConfig.containerName - } else { - blobServiceClient = await getBlobServiceClient() - containerName = BLOB_CONFIG.containerName - } + const { fileName, customKey } = options const safeFileName = sanitizeFileName(fileName) const uniqueKey = customKey || `kb/${generateId()}-${safeFileName}` const uploadId = generateId() - const containerClient = blobServiceClient.getContainerClient(containerName) - const blockBlobClient = containerClient.getBlockBlobClient(uniqueKey) - - await blockBlobClient.setMetadata({ - uploadId, - fileName: encodeURIComponent(fileName), - contentType, - uploadStarted: new Date().toISOString(), - multipartUpload: 'true', - }) - return { uploadId, key: uniqueKey, @@ -692,7 +746,8 @@ export async function completeMultipartUpload( key: string, parts: AzureMultipartPart[], customConfig?: BlobConfig, - contentType?: string + contentType?: string, + metadata?: Record ): Promise<{ location: string; path: string; key: string }> { const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob') let blobServiceClient: BlobServiceClientType @@ -731,6 +786,7 @@ export async function completeMultipartUpload( metadata: { multipartUpload: 'completed', uploadCompletedAt: new Date().toISOString(), + ...sanitizeStorageMetadata(metadata ?? {}, 8000), }, }) @@ -754,3 +810,8 @@ export async function completeMultipartUpload( export function abortMultipartUpload(_key: string, _customConfig?: BlobConfig): Promise { return Promise.resolve() } + +function readUploadId(metadata?: Record): string | undefined { + if (!metadata) return undefined + return Object.entries(metadata).find(([key]) => key.toLowerCase() === 'uploadid')?.[1] +} diff --git a/apps/sim/lib/uploads/providers/blob/types.ts b/apps/sim/lib/uploads/providers/blob/types.ts index a24a87e384e..5d0e36e307a 100644 --- a/apps/sim/lib/uploads/providers/blob/types.ts +++ b/apps/sim/lib/uploads/providers/blob/types.ts @@ -15,6 +15,8 @@ export interface AzureMultipartUploadInit { * Caller is responsible for uniqueness and prefix conventions. */ customKey?: string + /** Additional object metadata preserved when the block list is committed. */ + metadata?: Record } export interface AzurePartUploadUrl { diff --git a/apps/sim/lib/uploads/providers/gcs/client.test.ts b/apps/sim/lib/uploads/providers/gcs/client.test.ts index dc0b01d2093..280066feec3 100644 --- a/apps/sim/lib/uploads/providers/gcs/client.test.ts +++ b/apps/sim/lib/uploads/providers/gcs/client.test.ts @@ -21,6 +21,7 @@ const { getMetadata: vi.fn(), delete: vi.fn(), getSignedUrl: vi.fn(), + copy: vi.fn(), } const mockBucket = { file: vi.fn(() => mockFile) } const mockGetAccessToken = vi.fn() @@ -71,6 +72,7 @@ import { abortGcsMultipartUpload, completeGcsMultipartUpload, deleteFromGcs, + deleteGcsObjectVersion, downloadFromGcs, getGcsClient, getGcsMultipartPartUrls, @@ -78,6 +80,7 @@ import { getPresignedUrlWithConfig, headGcsObject, initiateGcsMultipartUpload, + promoteGcsObject, resetGcsClientForTesting, uploadGcsPart, uploadToGcs, @@ -300,11 +303,23 @@ describe('GCS Client', () => { describe('headGcsObject', () => { it('should return size and content type when the object exists', async () => { - mockFile.getMetadata.mockResolvedValueOnce([{ size: '2048', contentType: 'text/csv' }]) + mockFile.getMetadata.mockResolvedValueOnce([ + { + size: '2048', + contentType: 'text/csv', + generation: '42', + metadata: { uploadid: 'upload-1' }, + }, + ]) const result = await headGcsObject('data.csv') - expect(result).toEqual({ size: 2048, contentType: 'text/csv' }) + expect(result).toEqual({ + size: 2048, + contentType: 'text/csv', + uploadId: 'upload-1', + version: '42', + }) }) it('should return null when the object is missing', async () => { @@ -326,6 +341,42 @@ describe('GCS Client', () => { }) }) + describe('staged upload promotion', () => { + it('pins the source generation and requires an absent destination', async () => { + mockFile.copy.mockResolvedValueOnce(undefined) + + await promoteGcsObject({ + sourceKey: 'upload-sessions/upload-1/file.bin', + destinationKey: 'workspace/workspace-1/file.bin', + sourceGeneration: '42', + customConfig: { bucket: 'test-bucket' }, + }) + + expect(mockBucket.file).toHaveBeenCalledWith('upload-sessions/upload-1/file.bin', { + generation: '42', + }) + expect(mockBucket.file).toHaveBeenCalledWith('workspace/workspace-1/file.bin') + expect(mockFile.copy).toHaveBeenCalledWith(mockFile, { + preconditionOpts: { ifGenerationMatch: 0 }, + }) + }) + + it('deletes staging only at the inspected generation', async () => { + mockFile.delete.mockResolvedValueOnce(undefined) + + await deleteGcsObjectVersion({ + key: 'upload-sessions/upload-1/file.bin', + generation: '42', + customConfig: { bucket: 'test-bucket' }, + }) + + expect(mockBucket.file).toHaveBeenCalledWith('upload-sessions/upload-1/file.bin', { + generation: '42', + }) + expect(mockFile.delete).toHaveBeenCalledWith({ ifGenerationMatch: '42' }) + }) + }) + describe('deleteFromGcs', () => { it('should delete a file, ignoring missing objects', async () => { mockFile.delete.mockResolvedValueOnce(undefined) @@ -483,10 +534,10 @@ describe('GCS Client', () => { expect(init.method).toBe('DELETE') }) - it('should swallow abort errors', async () => { + it('should surface abort errors', async () => { mockFetch.mockResolvedValueOnce(new Response('boom', { status: 500, statusText: 'ISE' })) - await expect(abortGcsMultipartUpload('key.csv', 'upload-123')).resolves.toBeUndefined() + await expect(abortGcsMultipartUpload('key.csv', 'upload-123')).rejects.toThrow('500 ISE') }) it('should fail multipart calls when no access token is available', async () => { diff --git a/apps/sim/lib/uploads/providers/gcs/client.ts b/apps/sim/lib/uploads/providers/gcs/client.ts index b4d5194a0d9..29c45559ef7 100644 --- a/apps/sim/lib/uploads/providers/gcs/client.ts +++ b/apps/sim/lib/uploads/providers/gcs/client.ts @@ -328,7 +328,12 @@ export async function downloadFromGcsStream( export async function headGcsObject( key: string, customConfig?: GcsConfig -): Promise<{ size: number; contentType?: string } | null> { +): Promise<{ + size: number + contentType?: string + uploadId?: string + version: string +} | null> { const config = customConfig || { bucket: GCS_CONFIG.bucket } const storage = await getGcsClient() @@ -337,6 +342,8 @@ export async function headGcsObject( return { size: Number(fileMetadata.size) || 0, contentType: fileMetadata.contentType, + uploadId: readUploadId(fileMetadata.metadata as Record | undefined), + version: String(fileMetadata.generation ?? ''), } } catch (error) { const code = (error as { code?: number } | null)?.code @@ -347,6 +354,35 @@ export async function headGcsObject( } } +/** Copies one immutable staging generation into a destination that must not already exist. */ +export async function promoteGcsObject(params: { + sourceKey: string + destinationKey: string + sourceGeneration: string + customConfig: GcsConfig +}): Promise { + if (!params.sourceGeneration) throw new Error('GCS staging object is missing its generation') + const storage = await getGcsClient() + const bucket = storage.bucket(params.customConfig.bucket) + const source = bucket.file(params.sourceKey, { generation: params.sourceGeneration }) + const destination = bucket.file(params.destinationKey) + await source.copy(destination, { preconditionOpts: { ifGenerationMatch: 0 } }) +} + +/** Deletes a staging object only if it is still the generation completion inspected. */ +export async function deleteGcsObjectVersion(params: { + key: string + generation: string + customConfig: GcsConfig +}): Promise { + if (!params.generation) throw new Error('GCS staging object is missing its generation') + const storage = await getGcsClient() + await storage + .bucket(params.customConfig.bucket) + .file(params.key, { generation: params.generation }) + .delete({ ifGenerationMatch: params.generation }) +} + /** * Get the custom metadata stored on a GCS object. */ @@ -439,7 +475,7 @@ async function gcsXmlApiRequest( export async function initiateGcsMultipartUpload( options: GcsMultipartUploadInit ): Promise<{ uploadId: string; key: string }> { - const { fileName, contentType, customConfig, customKey, purpose } = options + const { fileName, contentType, customConfig, customKey, purpose, metadata } = options const config = customConfig || { bucket: GCS_CONFIG.bucket } @@ -452,6 +488,12 @@ export async function initiateGcsMultipartUpload( 'x-goog-meta-originalname': encodeURIComponent(sanitizeFilenameForMetadata(fileName)), 'x-goog-meta-uploadedat': new Date().toISOString(), 'x-goog-meta-purpose': purpose || 'knowledge-base', + ...Object.fromEntries( + Object.entries(sanitizeStorageMetadata(metadata ?? {}, 8000)).map(([key, value]) => [ + `x-goog-meta-${key.toLowerCase()}`, + value, + ]) + ), }, }) @@ -581,9 +623,10 @@ export async function abortGcsMultipartUpload( customConfig?: GcsConfig ): Promise { const config = customConfig || { bucket: GCS_CONFIG.bucket } - try { - await gcsXmlApiRequest('DELETE', config.bucket, key, `uploadId=${encodeURIComponent(uploadId)}`) - } catch (error) { - logger.warn('Error cleaning up GCS multipart upload:', error) - } + await gcsXmlApiRequest('DELETE', config.bucket, key, `uploadId=${encodeURIComponent(uploadId)}`) +} + +function readUploadId(metadata?: Record): string | undefined { + if (!metadata) return undefined + return Object.entries(metadata).find(([key]) => key.toLowerCase() === 'uploadid')?.[1] } diff --git a/apps/sim/lib/uploads/providers/gcs/types.ts b/apps/sim/lib/uploads/providers/gcs/types.ts index 4a54bf3c1d3..3705296edb7 100644 --- a/apps/sim/lib/uploads/providers/gcs/types.ts +++ b/apps/sim/lib/uploads/providers/gcs/types.ts @@ -17,6 +17,8 @@ export interface GcsMultipartUploadInit { * for backwards compatibility. */ purpose?: string + /** Additional object metadata fixed when the multipart upload is initiated. */ + metadata?: Record } export interface GcsPartUploadUrl { diff --git a/apps/sim/lib/uploads/providers/s3/client.test.ts b/apps/sim/lib/uploads/providers/s3/client.test.ts index 75eea9a3dde..0979c5c874a 100644 --- a/apps/sim/lib/uploads/providers/s3/client.test.ts +++ b/apps/sim/lib/uploads/providers/s3/client.test.ts @@ -11,7 +11,9 @@ const { mockS3ClientConstructor, mockPutObjectCommand, mockGetObjectCommand, + mockHeadObjectCommand, mockDeleteObjectCommand, + mockCopyObjectCommand, mockCompleteMultipartUploadCommand, mockGetSignedUrl, mockEnv, @@ -51,7 +53,9 @@ const { ), mockPutObjectCommand: vi.fn().mockImplementation(class {}), mockGetObjectCommand: vi.fn().mockImplementation(class {}), + mockHeadObjectCommand: vi.fn().mockImplementation(class {}), mockDeleteObjectCommand: vi.fn().mockImplementation(class {}), + mockCopyObjectCommand: vi.fn().mockImplementation(class {}), mockCompleteMultipartUploadCommand: vi.fn().mockImplementation(class {}), mockGetSignedUrl: vi.fn(), mockEnv, @@ -62,7 +66,9 @@ vi.mock('@aws-sdk/client-s3', () => ({ S3Client: mockS3ClientConstructor, PutObjectCommand: mockPutObjectCommand, GetObjectCommand: mockGetObjectCommand, + HeadObjectCommand: mockHeadObjectCommand, DeleteObjectCommand: mockDeleteObjectCommand, + CopyObjectCommand: mockCopyObjectCommand, CompleteMultipartUploadCommand: mockCompleteMultipartUploadCommand, })) @@ -97,9 +103,13 @@ vi.mock('@/lib/uploads/config', () => ({ import { completeS3MultipartUpload, deleteFromS3, + deleteS3ObjectVersion, downloadFromS3, getPresignedUrl, getS3Client, + getS3PresignedUploadUrl, + headS3Object, + promoteS3Object, resetS3ClientForTesting, uploadToS3, } from '@/lib/uploads/providers/s3/client' @@ -236,6 +246,89 @@ describe('S3 Client', () => { }) }) + describe('staged upload primitives', () => { + it('signs metadata without returning duplicate x-amz-meta headers', async () => { + mockGetSignedUrl.mockResolvedValueOnce('https://example.com/signed-put') + + const result = await getS3PresignedUploadUrl({ + key: 'upload-sessions/upload-1/file.bin', + contentType: 'application/octet-stream', + fileSize: 3, + metadata: { uploadId: 'upload-1', purpose: 'workspace_file' }, + customConfig: mockS3Config, + expiresIn: 600, + }) + + expect(mockPutObjectCommand).toHaveBeenCalledWith({ + Bucket: 'test-bucket', + Key: 'upload-sessions/upload-1/file.bin', + ContentType: 'application/octet-stream', + ContentLength: 3, + Metadata: { uploadId: 'upload-1', purpose: 'workspace_file' }, + }) + expect(result).toEqual({ + url: 'https://example.com/signed-put', + headers: { + 'Content-Type': 'application/octet-stream', + }, + }) + }) + + it('reads the upload identity and immutable ETag', async () => { + mockSend.mockResolvedValueOnce({ + ContentLength: 3, + ContentType: 'application/octet-stream', + Metadata: { uploadid: 'upload-1' }, + ETag: '"etag-1"', + }) + + await expect( + headS3Object('upload-sessions/upload-1/file.bin', mockS3Config) + ).resolves.toEqual({ + size: 3, + contentType: 'application/octet-stream', + uploadId: 'upload-1', + version: '"etag-1"', + }) + }) + + it('pins the source ETag and requires an absent promotion destination', async () => { + mockSend.mockResolvedValueOnce({}) + + await promoteS3Object({ + sourceKey: 'upload-sessions/upload-1/file.bin', + destinationKey: 'workspace/workspace-1/file.bin', + sourceEtag: '"etag-1"', + customConfig: mockS3Config, + }) + + expect(mockCopyObjectCommand).toHaveBeenCalledWith({ + Bucket: 'test-bucket', + Key: 'workspace/workspace-1/file.bin', + CopySource: 'test-bucket/upload-sessions/upload-1/file.bin', + CopySourceIfMatch: '"etag-1"', + IfNoneMatch: '*', + MetadataDirective: 'COPY', + }) + }) + + it('deletes staging only when its ETag still matches', async () => { + mockSend.mockResolvedValueOnce({}) + + await deleteS3ObjectVersion({ + key: 'upload-sessions/upload-1/file.bin', + etag: '"etag-1"', + customConfig: mockS3Config, + }) + + expect(mockDeleteObjectCommand).toHaveBeenCalledWith({ + Bucket: 'test-bucket', + Key: 'upload-sessions/upload-1/file.bin', + IfMatch: '"etag-1"', + }) + }) + }) + describe('downloadFromS3', () => { it('should download a file from S3', async () => { const mockStream = { diff --git a/apps/sim/lib/uploads/providers/s3/client.ts b/apps/sim/lib/uploads/providers/s3/client.ts index fafe4fc8897..ecbc8879562 100644 --- a/apps/sim/lib/uploads/providers/s3/client.ts +++ b/apps/sim/lib/uploads/providers/s3/client.ts @@ -2,6 +2,7 @@ import type { Readable } from 'node:stream' import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, + CopyObjectCommand, CreateMultipartUploadCommand, DeleteObjectCommand, DeleteObjectsCommand, @@ -167,6 +168,37 @@ export async function getPresignedUrlWithConfig( return getSignedUrl(getS3Client(), command, { expiresIn }) } +/** + * Generates a signed single-object PUT for a caller-selected staging key. + * The AWS presigner hoists `x-amz-meta-*` values into the signed query string, + * so only ordinary transfer headers are returned. Repeating that metadata as + * request headers makes S3 reject the otherwise-valid signature. + */ +export async function getS3PresignedUploadUrl(params: { + key: string + contentType: string + fileSize: number + metadata: Record + customConfig: S3Config + expiresIn: number +}): Promise<{ url: string; headers: Record }> { + const metadata = sanitizeStorageMetadata(params.metadata, 2000) + const command = new PutObjectCommand({ + Bucket: params.customConfig.bucket, + Key: params.key, + ContentType: params.contentType, + ContentLength: params.fileSize, + Metadata: metadata, + }) + const url = await getSignedUrl(getS3Client(), command, { expiresIn: params.expiresIn }) + return { + url, + headers: { + 'Content-Type': params.contentType, + }, + } +} + /** * Download a file from S3 * @param key S3 object key @@ -243,7 +275,12 @@ export async function downloadFromS3Stream( export async function headS3Object( key: string, customConfig?: S3Config -): Promise<{ size: number; contentType?: string } | null> { +): Promise<{ + size: number + contentType?: string + uploadId?: string + version: string +} | null> { const config = customConfig || { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region } try { @@ -253,6 +290,8 @@ export async function headS3Object( return { size: response.ContentLength ?? 0, contentType: response.ContentType, + uploadId: readUploadId(response.Metadata), + version: response.ETag ?? '', } } catch (error) { const code = (error as { name?: string; $metadata?: { httpStatusCode?: number } } | null)?.name @@ -265,6 +304,48 @@ export async function headS3Object( } } +/** + * Copies one immutable staging version into a destination that must not already exist. + */ +export async function promoteS3Object(params: { + sourceKey: string + destinationKey: string + sourceEtag: string + customConfig: S3Config +}): Promise { + if (!params.sourceEtag) throw new Error('S3 staging object is missing its ETag') + const encodedSource = `${params.customConfig.bucket}/${params.sourceKey + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/')}` + await getS3Client().send( + new CopyObjectCommand({ + Bucket: params.customConfig.bucket, + Key: params.destinationKey, + CopySource: encodedSource, + CopySourceIfMatch: params.sourceEtag, + IfNoneMatch: '*', + MetadataDirective: 'COPY', + }) + ) +} + +/** Deletes a staging object only if it is still the version completion inspected. */ +export async function deleteS3ObjectVersion(params: { + key: string + etag: string + customConfig: S3Config +}): Promise { + if (!params.etag) throw new Error('S3 staging object is missing its ETag') + await getS3Client().send( + new DeleteObjectCommand({ + Bucket: params.customConfig.bucket, + Key: params.key, + IfMatch: params.etag, + }) + ) +} + /** * Delete a file from S3 * @param key S3 object key @@ -341,7 +422,7 @@ export async function deleteManyFromS3( export async function initiateS3MultipartUpload( options: S3MultipartUploadInit ): Promise<{ uploadId: string; key: string }> { - const { fileName, contentType, customConfig, customKey, purpose } = options + const { fileName, contentType, customConfig, customKey, purpose, metadata } = options const config = customConfig || { bucket: S3_KB_CONFIG.bucket, region: S3_KB_CONFIG.region } const s3Client = getS3Client() @@ -357,6 +438,7 @@ export async function initiateS3MultipartUpload( originalName: sanitizeFilenameForMetadata(fileName), uploadedAt: new Date().toISOString(), purpose: purpose || 'knowledge-base', + ...sanitizeStorageMetadata(metadata ?? {}, 2000), }, }) @@ -372,6 +454,11 @@ export async function initiateS3MultipartUpload( } } +function readUploadId(metadata?: Record): string | undefined { + if (!metadata) return undefined + return Object.entries(metadata).find(([key]) => key.toLowerCase() === 'uploadid')?.[1] +} + /** * Upload a single multipart part from the server (Body in hand), returning its * `{ PartNumber, ETag }`. The presigned variant ({@link getS3MultipartPartUrls}) diff --git a/apps/sim/lib/uploads/providers/s3/types.ts b/apps/sim/lib/uploads/providers/s3/types.ts index 266a86a862c..0f960e3e4a0 100644 --- a/apps/sim/lib/uploads/providers/s3/types.ts +++ b/apps/sim/lib/uploads/providers/s3/types.ts @@ -18,6 +18,8 @@ export interface S3MultipartUploadInit { * for backwards compatibility. */ purpose?: string + /** Additional object metadata fixed when the multipart upload is initiated. */ + metadata?: Record } export interface S3PartUploadUrl { diff --git a/apps/sim/lib/uploads/server/metadata.ts b/apps/sim/lib/uploads/server/metadata.ts index 94b8317cdc9..48c2e50939b 100644 --- a/apps/sim/lib/uploads/server/metadata.ts +++ b/apps/sim/lib/uploads/server/metadata.ts @@ -114,45 +114,6 @@ export async function insertFileMetadata( } } -/** - * Bulk-insert file metadata rows in a single statement. - * - * Intended for batch upload flows that create many fresh keys at once (e.g. the - * presigned batch route), replacing a fan-out of individual `insertFileMetadata` - * calls. Uses `ON CONFLICT DO NOTHING` on the active-key unique index, so it is - * safe against a concurrent single insert and idempotent for already-present - * active keys. Unlike {@link insertFileMetadata} it does NOT restore - * soft-deleted rows — callers use this only for newly generated keys. - */ -export async function insertFileMetadataMany( - rows: Array & { id?: string }> -): Promise { - if (rows.length === 0) { - return - } - - await db - .insert(workspaceFiles) - .values( - rows.map((row) => ({ - id: row.id || generateId(), - key: row.key, - userId: row.userId, - workspaceId: row.workspaceId || null, - folderId: row.folderId ?? null, - context: row.context, - originalName: row.originalName, - displayName: row.originalName, - contentType: row.contentType, - size: toLegacyWorkspaceFileSize(row.size), - sizeBytes: row.size, - deletedAt: null, - uploadedAt: new Date(), - })) - ) - .onConflictDoNothing() -} - /** * Get file metadata by key with optional context filter */ @@ -255,26 +216,11 @@ export interface KnowledgeBaseFileOwnership { * Record the ownership binding for a single knowledge-base upload. KB file * authorization (`verifyKBFileAccess`) resolves the owning workspace from this * binding, so every KB object must have exactly one. Single source of truth for - * the binding shape across the presigned, batch-presigned, and multipart upload - * paths — keep all callers routed through here so they cannot drift. + * the binding shape for knowledge upload sessions — keep all callers routed + * through here so they cannot drift. */ export async function recordKnowledgeBaseFileOwnership( ownership: KnowledgeBaseFileOwnership ): Promise { await insertFileMetadata({ ...ownership, context: 'knowledge-base' }) } - -/** - * Bulk variant of {@link recordKnowledgeBaseFileOwnership} for batch upload flows. - * Idempotent against the active-key unique index (ON CONFLICT DO NOTHING). - */ -export async function recordKnowledgeBaseFileOwnershipMany( - ownerships: KnowledgeBaseFileOwnership[] -): Promise { - if (ownerships.length === 0) { - return - } - await insertFileMetadataMany( - ownerships.map((ownership) => ({ ...ownership, context: 'knowledge-base' })) - ) -} diff --git a/apps/sim/lib/uploads/shared/types.ts b/apps/sim/lib/uploads/shared/types.ts index d7de7961ee5..b26977f117e 100644 --- a/apps/sim/lib/uploads/shared/types.ts +++ b/apps/sim/lib/uploads/shared/types.ts @@ -1,6 +1,6 @@ /** * Defense-in-depth ceiling on the size of any single workspace file upload. - * Enforced both server-side (presigned route) and client-side (Files tab) so + * Enforced both server-side (upload-session creation) and client-side (Files tab) so * users get fast feedback before bytes are streamed. */ export const MAX_WORKSPACE_FILE_SIZE = 5 * 1024 * 1024 * 1024 diff --git a/apps/sim/lib/uploads/upload-session/README.md b/apps/sim/lib/uploads/upload-session/README.md new file mode 100644 index 00000000000..c29f9a312cc --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/README.md @@ -0,0 +1,27 @@ +# Upload sessions + +Upload sessions use a signed, stateless control-plane token and an immutable staging object. Files +up to and including 50 MiB use one signed `PUT`; larger files use multipart upload. Completion +verifies the staged object's upload ID, byte size, and content type before promoting it to a +create-only final key. + +The `upload-sessions/` prefix is temporary. Production S3 and GCS buckets must expire objects under +that prefix after two days and abort incomplete multipart uploads after two days. Azure containers +must expire committed blobs under that prefix after two days; Azure automatically garbage-collects +uncommitted blocks after seven days. These policies exceed the 24-hour token lifetime, preserve a +retry window, and bound abandoned provider state. Local storage applies the equivalent 25-hour +policy with the bounded cleanup sweep in `cleanup.ts`. The local sweep retains process-local +directory cursors between bounded runs, so a large set of fresh entries cannot indefinitely hide +expired entries later in either directory. + +Local cleanup currently runs opportunistically when that same Sim process creates an upload +session. The repository has no scheduler that safely reaches every process-local filesystem in a +multi-replica self-hosted deployment: an HTTP cron request can land on only one replica, while the +Trigger workers do not own the web replica's disk. Operators using non-shared local disks must +therefore ensure uploads continue to trigger the sweep on each replica or invoke the exported +bounded sweep from their own per-replica maintenance hook. Cloud deployments should use the +provider lifecycle rules above instead. + +Final objects are not covered by the staging lifecycle. Completion retains staging until the +domain finalizer succeeds, then conditionally deletes only the exact staging version it verified. +Abort is also staging-only and must never delete a promoted final object. diff --git a/apps/sim/lib/uploads/upload-session/cleanup.test.ts b/apps/sim/lib/uploads/upload-session/cleanup.test.ts new file mode 100644 index 00000000000..2a9dad064a2 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/cleanup.test.ts @@ -0,0 +1,96 @@ +/** + * @vitest-environment node + */ +import { mkdir, rm, stat, utimes, writeFile } from 'node:fs/promises' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { testUploadDirectory } = vi.hoisted(() => ({ + testUploadDirectory: `/tmp/sim-upload-session-cleanup-${process.pid}`, +})) + +vi.mock('@/lib/uploads/core/setup.server', () => ({ + UPLOAD_DIR_SERVER: testUploadDirectory, +})) + +import { + LOCAL_UPLOAD_ARTIFACT_TTL_MS, + maybeCleanupLocalUploadArtifacts, + resetLocalUploadCleanupForTesting, + sweepLocalUploadArtifacts, +} from '@/lib/uploads/upload-session/cleanup' + +describe('local upload artifact cleanup', () => { + beforeEach(async () => { + resetLocalUploadCleanupForTesting() + await rm(testUploadDirectory, { recursive: true, force: true }) + await mkdir(testUploadDirectory, { recursive: true }) + }) + + it('removes expired multipart and staging entries while retaining fresh entries', async () => { + const now = Date.UTC(2026, 7, 4, 12) + await createArtifact('.multipart/expired', now - LOCAL_UPLOAD_ARTIFACT_TTL_MS - 1) + await createArtifact('upload-sessions/expired', now - LOCAL_UPLOAD_ARTIFACT_TTL_MS - 1) + await createArtifact('upload-sessions/fresh', now) + + await expect(sweepLocalUploadArtifacts({ now })).resolves.toEqual({ scanned: 3, removed: 2 }) + await expect(stat(`${testUploadDirectory}/.multipart/expired`)).rejects.toMatchObject({ + code: 'ENOENT', + }) + await expect(stat(`${testUploadDirectory}/upload-sessions/expired`)).rejects.toMatchObject({ + code: 'ENOENT', + }) + await expect(stat(`${testUploadDirectory}/upload-sessions/fresh`)).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) + await createArtifact('.multipart/two', now - LOCAL_UPLOAD_ARTIFACT_TTL_MS - 1) + + const result = await sweepLocalUploadArtifacts({ now, maxEntries: 1 }) + + expect(result).toEqual({ scanned: 1, removed: 1 }) + }) + + it('continues from its directory cursor so old entries cannot starve behind fresh ones', async () => { + const now = Date.UTC(2026, 7, 4, 12) + for (let index = 0; index < 5; index++) { + await createArtifact(`.multipart/fresh-${index}`, now) + } + await createArtifact('.multipart/expired-last', now - LOCAL_UPLOAD_ARTIFACT_TTL_MS - 1) + + const sweeps = [ + await sweepLocalUploadArtifacts({ now, maxEntries: 2 }), + await sweepLocalUploadArtifacts({ now, maxEntries: 2 }), + await sweepLocalUploadArtifacts({ now, maxEntries: 2 }), + ] + + expect(sweeps.reduce((total, result) => total + result.scanned, 0)).toBe(6) + expect(sweeps.reduce((total, result) => total + result.removed, 0)).toBe(1) + await expect(stat(`${testUploadDirectory}/.multipart/expired-last`)).rejects.toMatchObject({ + code: 'ENOENT', + }) + }) + + it('coalesces concurrent cleanup and rate-limits the next sweep', async () => { + const now = Date.UTC(2026, 7, 4, 12) + await createArtifact('.multipart/expired', now - LOCAL_UPLOAD_ARTIFACT_TTL_MS - 1) + + const [first, concurrent] = await Promise.all([ + maybeCleanupLocalUploadArtifacts(now), + maybeCleanupLocalUploadArtifacts(now), + ]) + + expect(first).toEqual({ scanned: 1, removed: 1 }) + expect(concurrent).toEqual(first) + await expect(maybeCleanupLocalUploadArtifacts(now)).resolves.toEqual({ scanned: 0, removed: 0 }) + }) +}) + +async function createArtifact(relativePath: string, modifiedAt: number): Promise { + const path = `${testUploadDirectory}/${relativePath}` + await mkdir(path, { recursive: true }) + await writeFile(`${path}/payload`, 'test') + const time = new Date(modifiedAt) + await utimes(path, time, time) +} diff --git a/apps/sim/lib/uploads/upload-session/cleanup.ts b/apps/sim/lib/uploads/upload-session/cleanup.ts new file mode 100644 index 00000000000..bdb39b75cb9 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/cleanup.ts @@ -0,0 +1,127 @@ +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' + +export const LOCAL_UPLOAD_CLEANUP_INTERVAL_MS = 15 * 60 * 1000 +export const LOCAL_UPLOAD_ARTIFACT_TTL_MS = 25 * 60 * 60 * 1000 +export const LOCAL_UPLOAD_CLEANUP_MAX_ENTRIES = 200 + +export interface LocalUploadCleanupResult { + scanned: number + removed: number +} + +let activeCleanup: Promise | null = null +let lastCleanupAt = 0 + +const CLEANUP_ROOTS = ['.multipart', 'upload-sessions'] as const + +interface CleanupRootState { + directory: Awaited> | null +} + +const cleanupRootStates: CleanupRootState[] = CLEANUP_ROOTS.map(() => ({ directory: null })) +let nextCleanupRootIndex = 0 + +/** + * Opportunistically removes expired local multipart and staged-PUT state. + * Calls are single-flight and rate-limited; each sweep examines a bounded number of entries. + */ +export function maybeCleanupLocalUploadArtifacts( + now = Date.now() +): Promise { + if (activeCleanup) return activeCleanup + if (now - lastCleanupAt < LOCAL_UPLOAD_CLEANUP_INTERVAL_MS) { + return Promise.resolve({ scanned: 0, removed: 0 }) + } + activeCleanup = sweepLocalUploadArtifacts({ now }).then((result) => { + lastCleanupAt = now + return result + }) + return activeCleanup.finally(() => { + activeCleanup = null + }) +} + +/** Performs one bounded sweep for per-replica maintenance hooks and deterministic tests. */ +export async function sweepLocalUploadArtifacts(params?: { + now?: number + maxEntries?: number +}): Promise { + const now = params?.now ?? Date.now() + const maxEntries = params?.maxEntries ?? LOCAL_UPLOAD_CLEANUP_MAX_ENTRIES + if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) { + throw new Error('maxEntries must be a positive integer') + } + const cutoff = now - LOCAL_UPLOAD_ARTIFACT_TTL_MS + let scanned = 0 + let removed = 0 + const exhaustedRoots = new Set() + + while (scanned < maxEntries) { + const artifact = await readNextCleanupArtifact(exhaustedRoots) + if (!artifact) break + scanned++ + const path = join(artifact.directoryPath, artifact.entry.name) + let file + try { + file = await stat(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue + throw error + } + if (file.mtimeMs > cutoff) continue + await rm(path, { recursive: artifact.entry.isDirectory(), force: true }) + removed++ + } + + return { scanned, removed } +} + +export function resetLocalUploadCleanupForTesting(): void { + for (const state of cleanupRootStates) { + if (!state.directory) continue + try { + state.directory.closeSync() + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ERR_DIR_CLOSED') throw error + } + state.directory = null + } + nextCleanupRootIndex = 0 + activeCleanup = null + lastCleanupAt = 0 +} + +async function readNextCleanupArtifact( + exhaustedRoots: Set +): Promise<{ directoryPath: string; entry: Dirent } | null> { + for (let attempt = 0; attempt < CLEANUP_ROOTS.length; attempt++) { + const rootIndex = nextCleanupRootIndex + nextCleanupRootIndex = (nextCleanupRootIndex + 1) % CLEANUP_ROOTS.length + if (exhaustedRoots.has(rootIndex)) continue + + const directoryPath = join(UPLOAD_DIR_SERVER, CLEANUP_ROOTS[rootIndex]) + const state = cleanupRootStates[rootIndex] + if (!state.directory) { + try { + state.directory = await opendir(directoryPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + exhaustedRoots.add(rootIndex) + continue + } + throw error + } + } + + const entry = await state.directory.read() + if (entry) return { directoryPath, entry } + + await state.directory.close() + state.directory = null + exhaustedRoots.add(rootIndex) + } + return null +} diff --git a/apps/sim/lib/uploads/upload-session/provider.test.ts b/apps/sim/lib/uploads/upload-session/provider.test.ts new file mode 100644 index 00000000000..7fa9990ea11 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/provider.test.ts @@ -0,0 +1,243 @@ +/** + * @vitest-environment node + */ +import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { testUploadDirectory } = vi.hoisted(() => ({ + testUploadDirectory: `/tmp/sim-upload-session-provider-${process.pid}`, +})) + +vi.mock('@/lib/uploads/core/setup.server', () => ({ + UPLOAD_DIR_SERVER: testUploadDirectory, +})) + +vi.mock('@/lib/uploads/config', () => ({ + USE_BLOB_STORAGE: false, + USE_GCS_STORAGE: false, + USE_S3_STORAGE: false, + getStorageConfig: vi.fn(() => ({})), +})) + +import { deleteFile } from '@/lib/uploads/core/storage-service' +import { + headProviderObject, + LocalUploadBodyError, + promoteProviderObject, + writeLocalMultipartPart, + writeLocalPutObject, +} from '@/lib/uploads/upload-session/provider' + +const CONTEXT = 'workspace' as const +const METADATA = { + uploadId: 'upload-1', + userId: 'user-1', + originalName: 'file.bin', + purpose: 'workspace_file', + workspaceId: 'workspace-1', +} + +describe('local upload-session provider', () => { + beforeEach(async () => { + await rm(testUploadDirectory, { recursive: true, force: true }) + await mkdir(testUploadDirectory, { recursive: true }) + }) + + it('streams an exact-size PUT and persists its object identity', async () => { + await writeLocalPutObject({ + uploadId: 'upload-1', + stagingKey: 'upload-sessions/upload-1/file.bin', + body: byteStream('ab', 'cd'), + expectedSize: 4, + contentType: 'application/octet-stream', + metadata: METADATA, + }) + + await expect(readFile(localPath('upload-sessions/upload-1/file.bin'), 'utf8')).resolves.toBe( + 'abcd' + ) + await expect( + headProviderObject({ + provider: 'local', + key: 'upload-sessions/upload-1/file.bin', + context: CONTEXT, + }) + ).resolves.toMatchObject({ + size: 4, + contentType: 'application/octet-stream', + uploadId: 'upload-1', + version: expect.any(String), + }) + expect(await temporaryFiles('upload-sessions/upload-1')).toEqual([]) + }) + + it('persists an empty PUT object with its identity metadata', async () => { + await writeLocalPutObject({ + uploadId: 'upload-1', + stagingKey: 'upload-sessions/upload-1/empty.md', + body: byteStream(), + expectedSize: 0, + contentType: 'text/markdown', + metadata: METADATA, + }) + + await expect(stat(localPath('upload-sessions/upload-1/empty.md'))).resolves.toMatchObject({ + size: 0, + }) + await expect( + headProviderObject({ + provider: 'local', + key: 'upload-sessions/upload-1/empty.md', + context: CONTEXT, + }) + ).resolves.toMatchObject({ + size: 0, + contentType: 'text/markdown', + uploadId: 'upload-1', + version: expect.any(String), + }) + }) + + it.each([ + { name: 'short', chunks: ['ab'], expectedSize: 3 }, + { name: 'oversized', chunks: ['ab', 'cd'], expectedSize: 3 }, + ])('rejects a $name PUT and removes its temporary files', async ({ chunks, expectedSize }) => { + await expect( + writeLocalPutObject({ + uploadId: 'upload-1', + stagingKey: 'upload-sessions/upload-1/file.bin', + body: byteStream(...chunks), + expectedSize, + contentType: 'application/octet-stream', + metadata: METADATA, + }) + ).rejects.toBeInstanceOf(LocalUploadBodyError) + + await expect( + headProviderObject({ + provider: 'local', + key: 'upload-sessions/upload-1/file.bin', + context: CONTEXT, + }) + ).resolves.toBeNull() + expect(await temporaryFiles('upload-sessions/upload-1')).toEqual([]) + }) + + it('publishes a multipart part atomically after exact-size validation', async () => { + await writeLocalMultipartPart({ + uploadId: 'upload-1', + partNumber: 1, + body: byteStream('abc'), + expectedSize: 3, + }) + + await expect(readFile(localPath('.multipart/upload-1/1.part'), 'utf8')).resolves.toBe('abc') + + await expect( + writeLocalMultipartPart({ + uploadId: 'upload-1', + partNumber: 1, + body: byteStream('x'), + expectedSize: 3, + }) + ).rejects.toBeInstanceOf(LocalUploadBodyError) + + await expect(readFile(localPath('.multipart/upload-1/1.part'), 'utf8')).resolves.toBe('abc') + expect(await temporaryFiles('.multipart/upload-1')).toEqual([]) + }) + + it('promotes only the inspected source version into a new destination', async () => { + const stagingKey = 'upload-sessions/upload-1/file.bin' + await writeLocalPutObject({ + uploadId: 'upload-1', + stagingKey, + body: byteStream('old'), + expectedSize: 3, + contentType: 'application/octet-stream', + metadata: METADATA, + }) + const inspected = await requiredLocalHead(stagingKey) + + await promoteProviderObject({ + provider: 'local', + sourceKey: stagingKey, + destinationKey: 'workspace/workspace-1/file.bin', + sourceVersion: inspected.version, + context: CONTEXT, + }) + + await expect(readFile(localPath('workspace/workspace-1/file.bin'), 'utf8')).resolves.toBe('old') + await expect( + promoteProviderObject({ + provider: 'local', + sourceKey: stagingKey, + destinationKey: 'workspace/workspace-1/file.bin', + sourceVersion: inspected.version, + context: CONTEXT, + }) + ).rejects.toMatchObject({ code: 'EEXIST' }) + + await writeLocalPutObject({ + uploadId: 'upload-1', + stagingKey, + body: byteStream('new'), + expectedSize: 3, + contentType: 'application/octet-stream', + metadata: METADATA, + }) + await expect( + promoteProviderObject({ + provider: 'local', + sourceKey: stagingKey, + destinationKey: 'workspace/workspace-1/changed.bin', + sourceVersion: inspected.version, + context: CONTEXT, + }) + ).rejects.toThrow('Local staging object changed during promotion') + await expect( + headProviderObject({ + provider: 'local', + key: 'workspace/workspace-1/changed.bin', + context: CONTEXT, + }) + ).resolves.toBeNull() + + await deleteFile({ key: 'workspace/workspace-1/file.bin', context: CONTEXT }) + await expect(stat(localPath('workspace/workspace-1/file.bin'))).rejects.toMatchObject({ + code: 'ENOENT', + }) + await expect( + stat(localPath('workspace/workspace-1/file.bin.upload-metadata.json')) + ).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) + +function byteStream(...chunks: string[]): ReadableStream { + const encoder = new TextEncoder() + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.close() + }, + }) +} + +function localPath(key: string): string { + return `${testUploadDirectory}/${key}` +} + +async function temporaryFiles(relativeDirectory: string): Promise { + const entries = await readdir(localPath(relativeDirectory)).catch( + (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return [] + throw error + } + ) + return entries.filter((entry) => entry.startsWith('.')) +} + +async function requiredLocalHead(key: string) { + const head = await headProviderObject({ provider: 'local', key, context: CONTEXT }) + if (!head) throw new Error(`Missing local test object ${key}`) + return head +} diff --git a/apps/sim/lib/uploads/upload-session/provider.ts b/apps/sim/lib/uploads/upload-session/provider.ts new file mode 100644 index 00000000000..f40f74ee921 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/provider.ts @@ -0,0 +1,754 @@ +import { createReadStream, createWriteStream } from 'node:fs' +import { link, mkdir, readFile, rename, rm, rmdir, stat, unlink, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { pipeline } from 'node:stream/promises' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { + getStorageConfig, + USE_BLOB_STORAGE, + USE_GCS_STORAGE, + USE_S3_STORAGE, +} from '@/lib/uploads/config' +import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server' +import { + createBlobConfig, + createGcsConfig, + createS3Config, + LOCAL_UPLOAD_METADATA_SUFFIX, +} from '@/lib/uploads/core/storage-service' +import type { UploadStorageProvider } from '@/lib/uploads/core/upload-token' +import type { StorageContext } from '@/lib/uploads/shared/types' +import { sanitizeFileKey } from '@/lib/uploads/utils/file-utils' + +export type { UploadStorageProvider } from '@/lib/uploads/core/upload-token' + +export interface CompletedUploadPart { + partNumber: number + etag?: string +} + +export interface UploadPartUrl { + partNumber: number + url: string + headers: Record + expiresAt: string +} + +export interface UploadObjectHead { + size: number + contentType: string + uploadId: string + version: string +} + +interface LocalUploadMetadata { + uploadId: string + contentType: string + metadata: Record +} + +export class LocalUploadBodyError extends Error { + constructor(message: string) { + super(message) + this.name = 'LocalUploadBodyError' + } +} + +export function uploadStorageProvider(): UploadStorageProvider { + if (USE_BLOB_STORAGE) return 'blob' + if (USE_S3_STORAGE) return 's3' + if (USE_GCS_STORAGE) return 'gcs' + return 'local' +} + +export async function initiateMultipartProviderUpload(params: { + stagingKey: string + fileName: string + contentType: string + fileSize: number + context: StorageContext + uploadId: string + metadata: Record +}): Promise<{ provider: UploadStorageProvider; providerUploadId: string | null }> { + const provider = uploadStorageProvider() + const config = getStorageConfig(params.context) + const metadata = { ...params.metadata, uploadId: params.uploadId } + + if (provider === 's3') { + const { initiateS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') + const result = await initiateS3MultipartUpload({ + fileName: params.fileName, + contentType: params.contentType, + fileSize: params.fileSize, + customConfig: createS3Config(config), + customKey: params.stagingKey, + purpose: params.context, + metadata, + }) + return { provider, providerUploadId: result.uploadId } + } + if (provider === 'blob') { + const { initiateMultipartUpload } = await import('@/lib/uploads/providers/blob/client') + const result = await initiateMultipartUpload({ + fileName: params.fileName, + contentType: params.contentType, + fileSize: params.fileSize, + customConfig: createBlobConfig(config), + customKey: params.stagingKey, + metadata, + }) + return { provider, providerUploadId: result.uploadId } + } + if (provider === 'gcs') { + const { initiateGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') + const result = await initiateGcsMultipartUpload({ + fileName: params.fileName, + contentType: params.contentType, + fileSize: params.fileSize, + customConfig: createGcsConfig(config), + customKey: params.stagingKey, + purpose: params.context, + metadata, + }) + return { provider, providerUploadId: result.uploadId } + } + + await mkdir(localPartsDirectory(params.uploadId), { recursive: true }) + return { provider, providerUploadId: null } +} + +export async function createPutProviderTransfer(params: { + provider: UploadStorageProvider + stagingKey: string + contentType: string + fileSize: number + context: StorageContext + uploadId: string + uploadToken: string + localOrigin?: string + expiresAt: Date + metadata: Record +}): Promise<{ method: 'put'; url: string; headers: Record }> { + const expiresIn = Math.floor((params.expiresAt.getTime() - Date.now()) / 1000) + if (expiresIn < 1) throw new Error('Cannot sign an expired PUT upload session') + + if (params.provider === 'local') { + if (!params.localOrigin) throw new Error('localOrigin is required for local PUT uploads') + const origin = new URL(params.localOrigin) + const url = new URL(`/api/v2/uploads/${encodeURIComponent(params.uploadId)}`, origin) + return { + method: 'put', + url: url.toString(), + headers: { + 'Content-Type': params.contentType, + 'upload-token': params.uploadToken, + }, + } + } + + const config = getStorageConfig(params.context) + const metadata = { ...params.metadata, uploadId: params.uploadId } + if (params.provider === 's3') { + const { getS3PresignedUploadUrl } = await import('@/lib/uploads/providers/s3/client') + const transfer = await getS3PresignedUploadUrl({ + key: params.stagingKey, + contentType: params.contentType, + fileSize: params.fileSize, + metadata, + customConfig: createS3Config(config), + expiresIn, + }) + return { method: 'put', ...transfer } + } + if (params.provider === 'blob') { + const { getBlobPresignedUploadUrl } = await import('@/lib/uploads/providers/blob/client') + const transfer = await getBlobPresignedUploadUrl({ + key: params.stagingKey, + contentType: params.contentType, + metadata, + customConfig: createBlobConfig(config), + expiresIn, + }) + return { method: 'put', ...transfer } + } + const { getGcsPresignedUploadUrl } = await import('@/lib/uploads/providers/gcs/client') + const transfer = await getGcsPresignedUploadUrl( + params.stagingKey, + params.contentType, + metadata, + createGcsConfig(config), + expiresIn + ) + return { method: 'put', url: transfer.url, headers: transfer.signedHeaders } +} + +export async function getMultipartProviderPartUrls(params: { + provider: UploadStorageProvider + providerUploadId: string | null + stagingKey: string + context: StorageContext + partNumbers: number[] + localUrl: (partNumber: number) => string +}): Promise { + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString() + if (params.provider === 'local') { + return params.partNumbers.map((partNumber) => ({ + partNumber, + url: params.localUrl(partNumber), + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt, + })) + } + if (!params.providerUploadId) throw new Error(`Missing ${params.provider} multipart upload id`) + const config = getStorageConfig(params.context) + + if (params.provider === 's3') { + const { getS3MultipartPartUrls } = await import('@/lib/uploads/providers/s3/client') + const urls = await getS3MultipartPartUrls( + params.stagingKey, + params.providerUploadId, + params.partNumbers, + createS3Config(config) + ) + return urls.map(({ partNumber, url }) => ({ + partNumber, + url, + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt, + })) + } + if (params.provider === 'blob') { + const { getMultipartPartUrls } = await import('@/lib/uploads/providers/blob/client') + const urls = await getMultipartPartUrls( + params.stagingKey, + params.partNumbers, + createBlobConfig(config) + ) + return urls.map(({ partNumber, url }) => ({ + partNumber, + url, + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt, + })) + } + const { getGcsMultipartPartUrls } = await import('@/lib/uploads/providers/gcs/client') + const urls = await getGcsMultipartPartUrls( + params.stagingKey, + params.providerUploadId, + params.partNumbers, + createGcsConfig(config) + ) + return urls.map(({ partNumber, url }) => ({ + partNumber, + url, + headers: { 'Content-Type': 'application/octet-stream' }, + expiresAt, + })) +} + +export async function completeMultipartProviderUpload(params: { + provider: UploadStorageProvider + providerUploadId: string | null + uploadId: string + stagingKey: string + contentType: string + context: StorageContext + parts: CompletedUploadPart[] + metadata: Record +}): Promise { + if (params.provider === 'local') { + await assembleLocalParts( + params.uploadId, + params.stagingKey, + params.parts, + params.contentType, + params.metadata + ) + return + } + if (!params.providerUploadId) throw new Error(`Missing ${params.provider} multipart upload id`) + const config = getStorageConfig(params.context) + if (params.provider === 's3') { + const { completeS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') + await completeS3MultipartUpload( + params.stagingKey, + params.providerUploadId, + params.parts.map((part) => ({ + PartNumber: part.partNumber, + ETag: requiredEtag('s3', part), + })), + createS3Config(config) + ) + return + } + if (params.provider === 'blob') { + const { completeMultipartUpload, deriveBlobBlockId } = await import( + '@/lib/uploads/providers/blob/client' + ) + await completeMultipartUpload( + params.stagingKey, + params.parts.map((part) => ({ + partNumber: part.partNumber, + blockId: deriveBlobBlockId(part.partNumber), + })), + createBlobConfig(config), + params.contentType, + { ...params.metadata, uploadId: params.uploadId } + ) + return + } + const { completeGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') + await completeGcsMultipartUpload( + params.stagingKey, + params.providerUploadId, + params.parts.map((part) => ({ + PartNumber: part.partNumber, + ETag: requiredEtag('gcs', part), + })), + createGcsConfig(config) + ) +} + +export async function headProviderObject(params: { + provider: UploadStorageProvider + key: string + context: StorageContext +}): Promise { + if (params.provider === 'local') return headLocalObject(params.key) + const config = getStorageConfig(params.context) + const head = + params.provider === 's3' + ? await import('@/lib/uploads/providers/s3/client').then(({ headS3Object }) => + headS3Object(params.key, createS3Config(config)) + ) + : params.provider === 'blob' + ? await import('@/lib/uploads/providers/blob/client').then(({ headBlobObject }) => + headBlobObject(params.key, createBlobConfig(config)) + ) + : await import('@/lib/uploads/providers/gcs/client').then(({ headGcsObject }) => + headGcsObject(params.key, createGcsConfig(config)) + ) + if (!head) return null + if (!head.contentType || !head.uploadId || !head.version) { + throw new Error(`Upload object ${params.key} is missing required provider metadata`) + } + return { + size: head.size, + contentType: head.contentType, + uploadId: head.uploadId, + version: head.version, + } +} + +export async function promoteProviderObject(params: { + provider: UploadStorageProvider + sourceKey: string + destinationKey: string + sourceVersion: string + context: StorageContext +}): Promise { + if (params.provider === 'local') { + await promoteLocalObject(params.sourceKey, params.destinationKey, params.sourceVersion) + return + } + const config = getStorageConfig(params.context) + if (params.provider === 's3') { + const { promoteS3Object } = await import('@/lib/uploads/providers/s3/client') + await promoteS3Object({ + sourceKey: params.sourceKey, + destinationKey: params.destinationKey, + sourceEtag: params.sourceVersion, + customConfig: createS3Config(config), + }) + return + } + if (params.provider === 'blob') { + const { promoteBlobObject } = await import('@/lib/uploads/providers/blob/client') + await promoteBlobObject({ + sourceKey: params.sourceKey, + destinationKey: params.destinationKey, + sourceEtag: params.sourceVersion, + customConfig: createBlobConfig(config), + }) + return + } + const { promoteGcsObject } = await import('@/lib/uploads/providers/gcs/client') + await promoteGcsObject({ + sourceKey: params.sourceKey, + destinationKey: params.destinationKey, + sourceGeneration: params.sourceVersion, + customConfig: createGcsConfig(config), + }) +} + +export async function deleteProviderObjectVersion(params: { + provider: UploadStorageProvider + key: string + version: string + context: StorageContext +}): Promise { + if (params.provider === 'local') { + await deleteLocalObjectVersion(params.key, params.version) + return + } + const config = getStorageConfig(params.context) + if (params.provider === 's3') { + const { deleteS3ObjectVersion } = await import('@/lib/uploads/providers/s3/client') + await deleteS3ObjectVersion({ + key: params.key, + etag: params.version, + customConfig: createS3Config(config), + }) + return + } + if (params.provider === 'blob') { + const { deleteBlobObjectVersion } = await import('@/lib/uploads/providers/blob/client') + await deleteBlobObjectVersion({ + key: params.key, + etag: params.version, + customConfig: createBlobConfig(config), + }) + return + } + const { deleteGcsObjectVersion } = await import('@/lib/uploads/providers/gcs/client') + await deleteGcsObjectVersion({ + key: params.key, + generation: params.version, + customConfig: createGcsConfig(config), + }) +} + +export async function abortProviderUpload(params: { + provider: UploadStorageProvider + method: 'put' | 'multipart' + providerUploadId: string | null + uploadId: string + stagingKey: string + context: StorageContext +}): Promise { + if (params.provider === 'local') { + await rm(localPartsDirectory(params.uploadId), { recursive: true, force: true }) + await rm(localUploadDirectory(params.uploadId), { recursive: true, force: true }) + return + } + + const config = getStorageConfig(params.context) + if (params.method === 'multipart') { + if (!params.providerUploadId) { + throw new Error(`Missing ${params.provider} multipart upload id`) + } + if (params.provider === 's3') { + const { abortS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') + await abortS3MultipartUpload( + params.stagingKey, + params.providerUploadId, + createS3Config(config) + ) + } else if (params.provider === 'blob') { + const { abortMultipartUpload } = await import('@/lib/uploads/providers/blob/client') + await abortMultipartUpload(params.stagingKey, createBlobConfig(config)) + } else { + const { abortGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') + await abortGcsMultipartUpload( + params.stagingKey, + params.providerUploadId, + createGcsConfig(config) + ) + } + } + + if (params.provider === 's3') { + const { deleteFromS3 } = await import('@/lib/uploads/providers/s3/client') + await deleteFromS3(params.stagingKey, createS3Config(config)) + } else if (params.provider === 'blob') { + const { deleteFromBlob } = await import('@/lib/uploads/providers/blob/client') + await deleteFromBlob(params.stagingKey, createBlobConfig(config)) + } else { + const { deleteFromGcs } = await import('@/lib/uploads/providers/gcs/client') + await deleteFromGcs(params.stagingKey, createGcsConfig(config)) + } +} + +export async function writeLocalPutObject(params: { + uploadId: string + stagingKey: string + body: ReadableStream + expectedSize: number + contentType: string + metadata: Record +}): Promise { + assertLocalStagingKey(params.stagingKey, params.uploadId) + const { Readable, Transform } = await import('node:stream') + const directory = localUploadDirectory(params.uploadId) + const destination = localObjectPath(params.stagingKey) + const temporary = join(directory, `.put-${generateId()}`) + const temporaryMetadata = `${temporary}${LOCAL_UPLOAD_METADATA_SUFFIX}` + await mkdir(dirname(destination), { recursive: true }) + let bytes = 0 + const counter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + bytes += chunk.length + if (bytes > params.expectedSize) { + callback(new LocalUploadBodyError(`Upload exceeds ${params.expectedSize} bytes`)) + return + } + callback(null, chunk) + }, + }) + + try { + await pipeline( + Readable.fromWeb(params.body as Parameters[0]), + counter, + createWriteStream(temporary, { flags: 'wx' }) + ) + if (bytes !== params.expectedSize) { + throw new LocalUploadBodyError(`Upload has ${bytes} bytes; expected ${params.expectedSize}`) + } + await writeLocalMetadata(temporaryMetadata, { + uploadId: params.uploadId, + contentType: params.contentType, + metadata: { ...params.metadata, uploadId: params.uploadId }, + }) + await rename(temporary, destination) + try { + await rename(temporaryMetadata, localMetadataPath(params.stagingKey)) + } catch (error) { + await rm(destination, { force: true }) + throw error + } + } catch (error) { + await Promise.allSettled([ + rm(temporary, { force: true }), + rm(temporaryMetadata, { force: true }), + ]) + if (error instanceof LocalUploadBodyError) throw error + throw new Error(getErrorMessage(error, 'Failed to store PUT upload'), { cause: error }) + } +} + +export async function writeLocalMultipartPart(params: { + uploadId: string + partNumber: number + body: ReadableStream + expectedSize: number +}): Promise { + const { Readable, Transform } = await import('node:stream') + const directory = localPartsDirectory(params.uploadId) + await mkdir(directory, { recursive: true }) + const destination = localPartPath(params.uploadId, params.partNumber) + const temporary = join(directory, `.${params.partNumber}-${generateId()}.part`) + let bytes = 0 + const counter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + bytes += chunk.length + if (bytes > params.expectedSize) { + callback( + new LocalUploadBodyError(`Part ${params.partNumber} exceeds ${params.expectedSize} bytes`) + ) + return + } + callback(null, chunk) + }, + }) + try { + await pipeline( + Readable.fromWeb(params.body as Parameters[0]), + counter, + createWriteStream(temporary, { flags: 'wx' }) + ) + if (bytes !== params.expectedSize) { + throw new LocalUploadBodyError( + `Part ${params.partNumber} has ${bytes} bytes; expected ${params.expectedSize}` + ) + } + await rename(temporary, destination) + } catch (error) { + await rm(temporary, { force: true }).catch(() => {}) + if (error instanceof LocalUploadBodyError) throw error + throw new Error(getErrorMessage(error, `Failed to store part ${params.partNumber}`), { + cause: error, + }) + } +} + +function localPartsDirectory(uploadId: string): string { + return join(UPLOAD_DIR_SERVER, '.multipart', uploadId) +} + +function localUploadDirectory(uploadId: string): string { + return join(UPLOAD_DIR_SERVER, 'upload-sessions', uploadId) +} + +function localPartPath(uploadId: string, partNumber: number): string { + return join(localPartsDirectory(uploadId), `${partNumber}.part`) +} + +function localObjectPath(key: string): string { + return join(UPLOAD_DIR_SERVER, sanitizeFileKey(key)) +} + +function localMetadataPath(key: string): string { + return `${localObjectPath(key)}${LOCAL_UPLOAD_METADATA_SUFFIX}` +} + +async function assembleLocalParts( + uploadId: string, + stagingKey: string, + parts: CompletedUploadPart[], + contentType: string, + metadata: Record +): Promise { + assertLocalStagingKey(stagingKey, uploadId) + const destination = localObjectPath(stagingKey) + const temporary = join(localUploadDirectory(uploadId), `.multipart-${generateId()}`) + const temporaryMetadata = `${temporary}${LOCAL_UPLOAD_METADATA_SUFFIX}` + await mkdir(dirname(destination), { recursive: true }) + try { + for (const part of parts) { + await pipeline( + createReadStream(localPartPath(uploadId, part.partNumber)), + createWriteStream(temporary, { flags: 'a' }) + ) + } + await writeLocalMetadata(temporaryMetadata, { + uploadId, + contentType, + metadata: { ...metadata, uploadId }, + }) + await rename(temporary, destination) + try { + await rename(temporaryMetadata, localMetadataPath(stagingKey)) + } catch (error) { + await rm(destination, { force: true }) + throw error + } + await rm(localPartsDirectory(uploadId), { recursive: true, force: true }) + } catch (error) { + await Promise.allSettled([ + rm(temporary, { force: true }), + rm(temporaryMetadata, { force: true }), + ]) + throw error + } +} + +async function headLocalObject(key: string): Promise { + const path = localObjectPath(key) + let file: Awaited> + try { + file = await stat(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + throw error + } + const metadata = await readLocalMetadata(localMetadataPath(key)) + return { + size: file.size, + contentType: metadata.contentType, + uploadId: metadata.uploadId, + version: localVersion(file), + } +} + +async function promoteLocalObject( + sourceKey: string, + destinationKey: string, + sourceVersion: string +): Promise { + const source = localObjectPath(sourceKey) + const sourceMetadata = localMetadataPath(sourceKey) + const destination = localObjectPath(destinationKey) + const destinationMetadata = localMetadataPath(destinationKey) + const metadata = await readLocalMetadata(sourceMetadata) + await mkdir(dirname(destination), { recursive: true }) + + let createdMetadata = false + try { + await link(sourceMetadata, destinationMetadata) + createdMetadata = true + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + const existing = await readLocalMetadata(destinationMetadata) + if (existing.uploadId !== metadata.uploadId) throw error + } + + try { + await link(source, destination) + } catch (error) { + if (createdMetadata) await rm(destinationMetadata, { force: true }) + throw error + } + + const destinationStat = await stat(destination) + if (localVersion(destinationStat) !== sourceVersion) { + await Promise.allSettled([ + rm(destination, { force: true }), + ...(createdMetadata ? [rm(destinationMetadata, { force: true })] : []), + ]) + throw new Error('Local staging object changed during promotion') + } +} + +async function deleteLocalObjectVersion(key: string, version: string): Promise { + const path = localObjectPath(key) + let current: Awaited> + try { + current = await stat(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + if (localVersion(current) !== version) return + await unlink(path) + await rm(localMetadataPath(key), { force: true }) + await rmdir(dirname(path)).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOTEMPTY' && error.code !== 'ENOENT') throw error + }) +} + +async function writeLocalMetadata(path: string, metadata: LocalUploadMetadata): Promise { + await writeFile(path, JSON.stringify(metadata), { encoding: 'utf8', flag: 'wx' }) +} + +async function readLocalMetadata(path: string): Promise { + const parsed: unknown = JSON.parse(await readFile(path, 'utf8')) + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) || + typeof (parsed as Record).uploadId !== 'string' || + typeof (parsed as Record).contentType !== 'string' || + typeof (parsed as Record).metadata !== 'object' || + (parsed as Record).metadata === null || + Array.isArray((parsed as Record).metadata) + ) { + throw new Error(`Invalid local upload metadata at ${path}`) + } + const record = parsed as Record + const metadata = record.metadata as Record + if (Object.values(metadata).some((value) => typeof value !== 'string')) { + throw new Error(`Invalid local upload metadata at ${path}`) + } + return { + uploadId: record.uploadId as string, + contentType: record.contentType as string, + metadata: metadata as Record, + } +} + +function localVersion(file: Awaited>): string { + return `${file.dev}:${file.ino}:${file.size}:${file.mtimeMs}` +} + +function assertLocalStagingKey(stagingKey: string, uploadId: string): void { + if (!stagingKey.startsWith(`upload-sessions/${uploadId}/`)) { + throw new Error('Local staging key does not belong to this upload') + } +} + +function requiredEtag(provider: 's3' | 'gcs', part: CompletedUploadPart): string { + if (!part.etag) throw new Error(`Missing etag for ${provider} part ${part.partNumber}`) + return part.etag +} diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts new file mode 100644 index 00000000000..d228c24980a --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -0,0 +1,432 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckStorageQuota, + mockCompleteMultipart, + mockCreatePutTransfer, + mockDeleteObjectVersion, + mockHeadObject, + mockInitiateMultipart, + mockPromoteObject, + mockResolveBillingContext, +} = vi.hoisted(() => ({ + mockCheckStorageQuota: vi.fn(), + mockCompleteMultipart: vi.fn(), + mockCreatePutTransfer: vi.fn(), + mockDeleteObjectVersion: vi.fn(), + mockHeadObject: vi.fn(), + mockInitiateMultipart: vi.fn(), + mockPromoteObject: vi.fn(), + mockResolveBillingContext: vi.fn(), +})) + +vi.mock('@/lib/billing/storage', () => ({ + checkStorageQuotaForBillingContext: mockCheckStorageQuota, + resolveStorageBillingContext: mockResolveBillingContext, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + generateWorkspaceFileKey: vi.fn( + (workspaceId: string, fileName: string) => `workspace/${workspaceId}/final-${fileName}` + ), +})) + +vi.mock('@/lib/uploads/upload-session/cleanup', () => ({ + maybeCleanupLocalUploadArtifacts: vi.fn().mockResolvedValue({ scanned: 0, removed: 0 }), +})) + +vi.mock('@/lib/uploads/upload-session/provider', () => ({ + abortProviderUpload: vi.fn(), + completeMultipartProviderUpload: mockCompleteMultipart, + createPutProviderTransfer: mockCreatePutTransfer, + deleteProviderObjectVersion: mockDeleteObjectVersion, + getMultipartProviderPartUrls: vi.fn(), + headProviderObject: mockHeadObject, + initiateMultipartProviderUpload: mockInitiateMultipart, + promoteProviderObject: mockPromoteObject, + uploadStorageProvider: vi.fn(() => 's3'), +})) + +import { + MAX_WORKSPACE_FILE_SIZE, + MAX_WORKSPACE_FORMDATA_FILE_SIZE, +} from '@/lib/uploads/shared/types' +import { + completeUploadSession, + createUploadSession, + UPLOAD_SESSION_PUT_MAX_BYTES, + validateUploadCompletion, + verifyUploadSessionToken, +} from '@/lib/uploads/upload-session/service' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' + +describe('upload sessions', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveBillingContext.mockResolvedValue({ workspaceId: WORKSPACE_ID }) + mockCheckStorageQuota.mockResolvedValue({ allowed: true }) + mockCreatePutTransfer.mockResolvedValue({ + method: 'put', + url: 'https://storage.example/upload', + headers: { 'Content-Type': 'application/octet-stream' }, + }) + mockInitiateMultipart.mockResolvedValue({ + provider: 's3', + providerUploadId: 'provider-upload-1', + }) + }) + + it('selects PUT at exactly 50 MiB', async () => { + const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES) + + expect(created.transfer.method).toBe('put') + expect(created.method).toBe('put') + expect(created.partSize).toBeNull() + expect(created.partCount).toBeNull() + expect(mockInitiateMultipart).not.toHaveBeenCalled() + }) + + it('creates a PUT session for an empty workspace file', async () => { + const created = await createWorkspaceUpload(0) + + expect(created).toMatchObject({ + purpose: 'workspace_file', + fileSize: 0, + method: 'put', + transfer: { method: 'put' }, + }) + expect(verifyUploadSessionToken(created.uploadToken)).toMatchObject({ + purpose: 'workspace_file', + fileSize: 0, + method: 'put', + }) + }) + + it('rejects an empty upload for non-workspace-file purposes', async () => { + await expect( + createUploadSession({ + id: 'empty-attachment', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + purpose: 'mothership_attachment', + fileName: 'empty.txt', + contentType: 'text/plain', + fileSize: 0, + }) + ).rejects.toThrow('fileSize must be a positive integer') + }) + + it('rejects a negative workspace-file size', async () => { + await expect(createWorkspaceUpload(-1)).rejects.toThrow( + 'fileSize must be a non-negative integer' + ) + }) + + it('selects multipart at 50 MiB plus one byte', async () => { + const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) + + expect(created.transfer).toMatchObject({ method: 'multipart', partCount: 7 }) + expect(created.method).toBe('multipart') + expect(mockInitiateMultipart).toHaveBeenCalledOnce() + }) + + it('binds purpose scope, staging, destination, method, and identity into the token', async () => { + const created = await createUploadSession({ + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + purpose: 'knowledge_document', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + }) + + expect(verifyUploadSessionToken(created.uploadToken)).toMatchObject({ + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: 'kb-1', + purpose: 'knowledge_document', + method: 'put', + storageContext: 'knowledge-base', + storageProvider: 's3', + stagingKey: 'upload-sessions/upload-1/guide.pdf', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + }) + }) + + it('quota-gates durable files while exempting retention-scoped attachments', async () => { + await createWorkspaceUpload(1024) + expect(mockResolveBillingContext).toHaveBeenCalledOnce() + expect(mockCheckStorageQuota).toHaveBeenCalledOnce() + + await createUploadSession({ + id: 'execution-upload', + workspaceId: WORKSPACE_ID, + workflowId: 'workflow-1', + executionId: 'execution-1', + userId: 'user-1', + purpose: 'execution_attachment', + fileName: 'result.txt', + contentType: 'text/plain', + fileSize: 1024, + }) + expect(mockResolveBillingContext).toHaveBeenCalledOnce() + expect(mockCheckStorageQuota).toHaveBeenCalledOnce() + + await createUploadSession({ + id: 'mothership-upload', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + purpose: 'mothership_attachment', + fileName: 'prompt.txt', + contentType: 'text/plain', + fileSize: 1024, + }) + expect(mockResolveBillingContext).toHaveBeenCalledOnce() + expect(mockCheckStorageQuota).toHaveBeenCalledOnce() + }) + + it('preserves the 5 GiB mothership limit while bounding execution attachments at 100 MiB', async () => { + await expect( + createUploadSession({ + id: 'mothership-upload', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + purpose: 'mothership_attachment', + fileName: 'archive.zip', + contentType: 'application/zip', + fileSize: MAX_WORKSPACE_FILE_SIZE, + }) + ).resolves.toMatchObject({ + method: 'multipart', + transfer: { method: 'multipart', partCount: 640 }, + }) + + await expect( + createUploadSession({ + id: 'oversized-mothership-upload', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + purpose: 'mothership_attachment', + fileName: 'archive.zip', + contentType: 'application/zip', + fileSize: MAX_WORKSPACE_FILE_SIZE + 1, + }) + ).rejects.toThrow(`File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes`) + + await expect( + createUploadSession({ + id: 'execution-upload', + workspaceId: WORKSPACE_ID, + workflowId: 'workflow-1', + executionId: 'execution-1', + userId: 'user-1', + purpose: 'execution_attachment', + fileName: 'result.txt', + contentType: 'text/plain', + fileSize: MAX_WORKSPACE_FORMDATA_FILE_SIZE + 1, + }) + ).rejects.toThrow(`File size exceeds maximum of ${MAX_WORKSPACE_FORMDATA_FILE_SIZE} bytes`) + }) + + it('validates PUT completion input independently of finalization', async () => { + const created = await createWorkspaceUpload(1024) + + expect(validateUploadCompletion(created, {})).toEqual([]) + expect(() => validateUploadCompletion(created, { parts: [] })).toThrow( + 'PUT completion must not include parts' + ) + }) + + it('requires every multipart part and cloud ETag before finalization or replay', async () => { + const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) + const parts = Array.from({ length: created.partCount ?? 0 }, (_, index) => ({ + partNumber: index + 1, + etag: `etag-${index + 1}`, + })) + + expect(validateUploadCompletion(created, { parts })).toBe(parts) + expect(() => validateUploadCompletion(created, {})).toThrow( + 'Multipart completion requires parts' + ) + expect(() => + validateUploadCompletion(created, { parts: parts.map(({ partNumber }) => ({ partNumber })) }) + ).toThrow('etag is required for s3 part 1') + }) + + it('resumes after multipart assembly without consuming the provider upload twice', async () => { + const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) + const identity = objectIdentity(created.id, created.fileSize, created.contentType) + mockHeadObject + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(identity) + .mockResolvedValueOnce(identity) + .mockResolvedValueOnce(identity) + + await expect( + completeUploadSession({ + session: created, + completion: { parts: completedParts(created.partCount) }, + finalize: async () => ({ value: 'file-1', completedFileId: 'file-1' }), + }) + ).resolves.toMatchObject({ value: 'file-1', alreadyCompleted: false }) + + expect(mockCompleteMultipart).not.toHaveBeenCalled() + expect(mockPromoteObject).toHaveBeenCalledOnce() + }) + + it('completes multipart at staging when no assembled object exists yet', async () => { + const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) + const identity = objectIdentity(created.id, created.fileSize, created.contentType) + mockHeadObject + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(identity) + .mockResolvedValueOnce(identity) + .mockResolvedValueOnce(identity) + + await completeUploadSession({ + session: created, + completion: { parts: completedParts(created.partCount) }, + finalize: async () => ({ value: 'file-1' }), + }) + + expect(mockCompleteMultipart).toHaveBeenCalledOnce() + expect(mockCompleteMultipart).toHaveBeenCalledWith( + expect.objectContaining({ stagingKey: created.stagingKey }) + ) + }) + + it('recovers when another completion consumes the provider upload concurrently', async () => { + const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) + const identity = objectIdentity(created.id, created.fileSize, created.contentType) + mockCompleteMultipart.mockRejectedValueOnce(new Error('NoSuchUpload')) + mockHeadObject + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(identity) + .mockResolvedValueOnce(identity) + .mockResolvedValueOnce(identity) + + await expect( + completeUploadSession({ + session: created, + completion: { parts: completedParts(created.partCount) }, + finalize: async () => ({ value: 'file-1' }), + }) + ).resolves.toMatchObject({ value: 'file-1', alreadyCompleted: false }) + + expect(mockCompleteMultipart).toHaveBeenCalledOnce() + expect(mockPromoteObject).toHaveBeenCalledOnce() + }) + + it('preserves the provider completion error when no staged object was created', async () => { + const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) + mockCompleteMultipart.mockRejectedValueOnce(new Error('NoSuchUpload')) + mockHeadObject + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + + await expect( + completeUploadSession({ + session: created, + completion: { parts: completedParts(created.partCount) }, + finalize: async () => ({ value: 'file-1' }), + }) + ).rejects.toThrow('NoSuchUpload') + + expect(mockPromoteObject).not.toHaveBeenCalled() + }) + + it('rejects a mismatched staged multipart object before provider completion', async () => { + const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) + mockHeadObject.mockResolvedValueOnce(null).mockResolvedValueOnce({ + ...objectIdentity(created.id, created.fileSize, created.contentType), + uploadId: 'another-upload', + }) + + await expect( + completeUploadSession({ + session: created, + completion: { parts: completedParts(created.partCount) }, + finalize: async () => ({ value: 'file-1' }), + }) + ).rejects.toThrow('Uploaded object belongs to another upload') + + expect(mockCompleteMultipart).not.toHaveBeenCalled() + expect(mockPromoteObject).not.toHaveBeenCalled() + }) + + it('retains staging when finalization fails after promotion', async () => { + const created = await createWorkspaceUpload(1024) + const identity = objectIdentity(created.id, created.fileSize, created.contentType) + mockHeadObject + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(identity) + .mockResolvedValueOnce(identity) + + await expect( + completeUploadSession({ + session: created, + completion: {}, + finalize: async () => { + throw new Error('database unavailable') + }, + }) + ).rejects.toThrow('database unavailable') + + expect(mockPromoteObject).toHaveBeenCalledOnce() + expect(mockDeleteObjectVersion).not.toHaveBeenCalled() + }) + + it('retries finalization from an exact final object, then removes staging conditionally', async () => { + const created = await createWorkspaceUpload(1024) + const identity = objectIdentity(created.id, created.fileSize, created.contentType) + mockHeadObject.mockResolvedValueOnce(identity).mockResolvedValueOnce(identity) + + await expect( + completeUploadSession({ + session: created, + completion: {}, + finalize: async () => ({ value: 'file-1', completedFileId: 'file-1' }), + }) + ).resolves.toMatchObject({ value: 'file-1', alreadyCompleted: true }) + + expect(mockPromoteObject).not.toHaveBeenCalled() + expect(mockDeleteObjectVersion).toHaveBeenCalledWith( + expect.objectContaining({ key: created.stagingKey, version: 'version-1' }) + ) + }) +}) + +async function createWorkspaceUpload(fileSize: number) { + return createUploadSession({ + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + purpose: 'workspace_file', + fileName: 'file.bin', + contentType: 'application/octet-stream', + fileSize, + }) +} + +function objectIdentity(uploadId: string, size: number, contentType: string) { + return { uploadId, size, contentType, version: 'version-1' } +} + +function completedParts(partCount: number | null) { + return Array.from({ length: partCount ?? 0 }, (_, index) => ({ + partNumber: index + 1, + etag: `etag-${index + 1}`, + })) +} diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts new file mode 100644 index 00000000000..048ff658c11 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -0,0 +1,792 @@ +import { generateId } from '@sim/utils/id' +import { + checkStorageQuotaForBillingContext, + resolveStorageBillingContext, +} from '@/lib/billing/storage' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateExecutionFileKey } 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 { + signUploadToken, + type UploadSessionPurpose, + type UploadStorageProvider, + type UploadTokenPayload, + type UploadTransferMethod, + verifyUploadToken, +} from '@/lib/uploads/core/upload-token' +import { + MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE, + MAX_WORKSPACE_FILE_SIZE, + MAX_WORKSPACE_FORMDATA_FILE_SIZE, + type StorageContext, +} from '@/lib/uploads/shared/types' +import { maybeCleanupLocalUploadArtifacts } from '@/lib/uploads/upload-session/cleanup' +import { + abortProviderUpload, + type CompletedUploadPart, + completeMultipartProviderUpload, + createPutProviderTransfer, + deleteProviderObjectVersion, + getMultipartProviderPartUrls, + headProviderObject, + initiateMultipartProviderUpload, + promoteProviderObject, + type UploadPartUrl, + uploadStorageProvider, +} from '@/lib/uploads/upload-session/provider' +import { sanitizeFileName } from '@/executor/constants' + +export const UPLOAD_SESSION_PUT_MAX_BYTES = 50 * 1024 * 1024 +export const UPLOAD_SESSION_PART_SIZE = 8 * 1024 * 1024 +export const UPLOAD_SESSION_MAX_PART_URLS = 100 +export const UPLOAD_SESSION_TTL_MS = 24 * 60 * 60 * 1000 +export const UPLOAD_SESSION_ASSET_MAX_BYTES = 5 * 1024 * 1024 + +export type { UploadSessionPurpose, UploadTransferMethod } + +export type UploadSessionStatus = 'uploading' | 'completed' | 'aborted' + +export type UploadSessionTransfer = + | { method: 'put'; url: string; headers: Record } + | { method: 'multipart'; partSize: number; partCount: number } + +export interface UploadSessionRecord { + id: string + workspaceId: string | null + userId: string + knowledgeBaseId: string | null + workflowId: string | null + executionId: string | null + purpose: UploadSessionPurpose + method: UploadTransferMethod + storageContext: StorageContext + /** Canonical destination key retained for existing domain finalizers. */ + storageKey: string + finalKey: string + stagingKey: string + storageProvider: UploadStorageProvider + providerUploadId: string | null + fileName: string + contentType: string + fileSize: number + partSize: number | null + partCount: number | null + status: UploadSessionStatus + metadata: Record + uploadToken: string + createdAt: Date + expiresAt: Date + completedFileId: string | null + error: string | null + completedAt: Date | null + updatedAt: Date +} + +export interface CreatedUploadSession extends UploadSessionRecord { + transfer: UploadSessionTransfer +} + +export type UploadCompletion = { parts?: never } | { parts: CompletedUploadPart[] } + +export class UploadSessionError extends OrchestrationError { + constructor( + code: 'validation' | 'not_found' | 'forbidden' | 'conflict' | 'payload_too_large' | 'internal', + message: string + ) { + super(code, message) + this.name = 'UploadSessionError' + } +} + +interface CreateUploadSessionBaseParams { + id?: string + userId: string + fileName: string + contentType: string + fileSize: number + metadata?: Record + localOrigin?: string +} + +export type CreateUploadSessionParams = CreateUploadSessionBaseParams & + ( + | { purpose: 'workspace_file' | 'table_import'; workspaceId: string } + | { purpose: 'knowledge_document'; workspaceId: string; knowledgeBaseId: string } + | { purpose: 'profile_picture'; workspaceId?: null } + | { purpose: 'workspace_logo' | 'mothership_attachment'; workspaceId: string } + | { + purpose: 'execution_attachment' + workspaceId: string + workflowId: string + executionId: string + } + ) + +export async function createUploadSession( + params: CreateUploadSessionParams +): Promise { + validateFile(params) + const id = params.id ?? generateId() + const workspaceId = params.purpose === 'profile_picture' ? null : params.workspaceId + const { storageContext, finalKey } = resolveUploadStorage(params, id) + const stagingKey = `upload-sessions/${id}/${sanitizeFileName(params.fileName)}` + const method: UploadTransferMethod = + params.fileSize <= UPLOAD_SESSION_PUT_MAX_BYTES ? 'put' : 'multipart' + const partSize = method === 'multipart' ? UPLOAD_SESSION_PART_SIZE : null + const partCount = + method === 'multipart' ? Math.ceil(params.fileSize / UPLOAD_SESSION_PART_SIZE) : null + + if (requiresStorageQuota(params.purpose)) { + if (!workspaceId) throw new Error(`${params.purpose} upload is missing workspaceId`) + const billingContext = await resolveStorageBillingContext(workspaceId) + const quota = await checkStorageQuotaForBillingContext(billingContext, params.fileSize) + if (!quota.allowed) { + throw new UploadSessionError('payload_too_large', quota.error ?? 'Storage limit exceeded') + } + } + + const provider = uploadStorageProvider() + if (provider === 'local') await maybeCleanupLocalUploadArtifacts() + const objectMetadata = uploadSessionObjectMetadata({ + id, + userId: params.userId, + workspaceId, + purpose: params.purpose, + fileName: params.fileName, + knowledgeBaseId: params.purpose === 'knowledge_document' ? params.knowledgeBaseId : null, + workflowId: params.purpose === 'execution_attachment' ? params.workflowId : null, + executionId: params.purpose === 'execution_attachment' ? params.executionId : null, + }) + const initiated = + method === 'multipart' + ? await initiateMultipartProviderUpload({ + stagingKey, + fileName: params.fileName, + contentType: params.contentType, + fileSize: params.fileSize, + context: storageContext, + uploadId: id, + metadata: objectMetadata, + }) + : { provider, providerUploadId: null } + if (initiated.provider !== provider) { + throw new Error('Storage provider changed while creating upload session') + } + + const createdAt = new Date() + const expiresAt = new Date(createdAt.getTime() + UPLOAD_SESSION_TTL_MS) + const metadata = params.metadata ?? {} + const tokenPayload = createUploadTokenPayload({ + params, + id, + workspaceId, + storageContext, + finalKey, + stagingKey, + provider, + providerUploadId: initiated.providerUploadId, + method, + partSize, + partCount, + metadata, + createdAt, + expiresAt, + }) + const uploadToken = signUploadToken(tokenPayload) + const transfer: UploadSessionTransfer = + method === 'put' + ? await createPutProviderTransfer({ + provider, + stagingKey, + contentType: params.contentType, + fileSize: params.fileSize, + context: storageContext, + uploadId: id, + uploadToken, + localOrigin: params.localOrigin, + expiresAt, + metadata: objectMetadata, + }) + : { method, partSize: requireNumber(partSize), partCount: requireNumber(partCount) } + + return sessionFromPayload(tokenPayload, uploadToken, transfer) +} + +export function getOwnedUploadSession(params: { + uploadId: string + uploadToken: string + userId?: string + workspaceId?: string | null + purpose?: UploadSessionPurpose + knowledgeBaseId?: string + workflowId?: string + executionId?: string +}): UploadSessionRecord { + const session = verifyUploadSessionToken(params.uploadToken) + if (session.id !== params.uploadId) throw uploadNotFound() + if (params.userId !== undefined && session.userId !== params.userId) throw uploadNotFound() + if (params.workspaceId !== undefined && session.workspaceId !== params.workspaceId) { + throw uploadNotFound() + } + if (params.purpose !== undefined && session.purpose !== params.purpose) throw uploadNotFound() + if (params.knowledgeBaseId !== undefined && session.knowledgeBaseId !== params.knowledgeBaseId) { + throw uploadNotFound() + } + if (params.workflowId !== undefined && session.workflowId !== params.workflowId) { + throw uploadNotFound() + } + if (params.executionId !== undefined && session.executionId !== params.executionId) { + throw uploadNotFound() + } + return session +} + +export function verifyUploadSessionToken(uploadToken: string): UploadSessionRecord { + const verified = verifyUploadToken(uploadToken) + if (!verified.valid) throw new UploadSessionError('forbidden', 'Invalid or expired upload token') + return sessionFromPayload(verified.payload, uploadToken) +} + +export async function createUploadPartUrls(params: { + session: UploadSessionRecord + partNumbers: number[] + localOrigin: string +}): Promise { + assertUploadable(params.session) + if (params.session.method !== 'multipart' || !params.session.partCount) { + throw new UploadSessionError('conflict', 'PUT upload sessions do not have multipart parts') + } + const unique = new Set(params.partNumbers) + if (unique.size !== params.partNumbers.length) { + throw new UploadSessionError('validation', 'partNumbers must not contain duplicates') + } + if (params.partNumbers.length === 0 || params.partNumbers.length > UPLOAD_SESSION_MAX_PART_URLS) { + throw new UploadSessionError( + 'validation', + `partNumbers must contain between 1 and ${UPLOAD_SESSION_MAX_PART_URLS} entries` + ) + } + for (const partNumber of params.partNumbers) { + if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > params.session.partCount) { + throw new UploadSessionError( + 'validation', + `partNumber must be between 1 and ${params.session.partCount}` + ) + } + } + + return getMultipartProviderPartUrls({ + provider: params.session.storageProvider, + providerUploadId: params.session.providerUploadId, + stagingKey: params.session.stagingKey, + context: params.session.storageContext, + partNumbers: params.partNumbers, + localUrl: (partNumber) => + `${params.localOrigin}/api/v2/uploads/${params.session.id}/parts/${partNumber}?token=${encodeURIComponent(params.session.uploadToken)}`, + }) +} + +export async function completeUploadSession(params: { + session: UploadSessionRecord + completion: UploadCompletion + finalize: (session: UploadSessionRecord) => Promise<{ value: T; completedFileId?: string }> +}): Promise<{ session: UploadSessionRecord; value: T; alreadyCompleted: boolean }> { + assertUploadable(params.session) + const parts = validateUploadCompletion(params.session, params.completion) + let existingFinal = await headProviderObject({ + provider: params.session.storageProvider, + key: params.session.finalKey, + context: params.session.storageContext, + }) + const alreadyCompleted = existingFinal !== null + if (existingFinal) assertObjectIdentity(params.session, existingFinal, 'Final') + + if (!existingFinal) { + let staging = await headProviderObject({ + provider: params.session.storageProvider, + key: params.session.stagingKey, + context: params.session.storageContext, + }) + if (staging) assertObjectIdentity(params.session, staging, 'Uploaded') + + if (!staging && params.session.method === 'multipart') { + try { + await completeMultipartProviderUpload({ + provider: params.session.storageProvider, + providerUploadId: params.session.providerUploadId, + uploadId: params.session.id, + stagingKey: params.session.stagingKey, + contentType: params.session.contentType, + context: params.session.storageContext, + parts, + metadata: uploadSessionObjectMetadata(params.session), + }) + } catch (completionError) { + staging = await headProviderObject({ + provider: params.session.storageProvider, + key: params.session.stagingKey, + context: params.session.storageContext, + }) + if (!staging) throw completionError + assertObjectIdentity(params.session, staging, 'Uploaded') + } + } + + if (!staging) { + staging = await headProviderObject({ + provider: params.session.storageProvider, + key: params.session.stagingKey, + context: params.session.storageContext, + }) + } + + if (!staging) throw new UploadSessionError('conflict', 'Uploaded staging object not found') + assertObjectIdentity(params.session, staging, 'Uploaded') + + try { + await promoteProviderObject({ + provider: params.session.storageProvider, + sourceKey: params.session.stagingKey, + destinationKey: params.session.finalKey, + sourceVersion: staging.version, + context: params.session.storageContext, + }) + } catch (error) { + existingFinal = await headProviderObject({ + provider: params.session.storageProvider, + key: params.session.finalKey, + context: params.session.storageContext, + }) + if (!existingFinal) throw error + assertObjectIdentity(params.session, existingFinal, 'Final') + } + + const promoted = await headProviderObject({ + provider: params.session.storageProvider, + key: params.session.finalKey, + context: params.session.storageContext, + }) + if (!promoted) throw new Error('Promoted upload object not found') + assertObjectIdentity(params.session, promoted, 'Promoted') + } + + const finalized = await params.finalize(params.session) + await cleanupStagingObject(params.session) + const completedAt = new Date() + return { + session: { + ...params.session, + status: 'completed', + completedFileId: finalized.completedFileId ?? null, + completedAt, + updatedAt: completedAt, + }, + value: finalized.value, + alreadyCompleted, + } +} + +export async function abortUploadSession( + session: UploadSessionRecord +): Promise { + assertUploadable(session) + await abortProviderUpload({ + provider: session.storageProvider, + method: session.method, + providerUploadId: session.providerUploadId, + uploadId: session.id, + stagingKey: session.stagingKey, + context: session.storageContext, + }) + const completedAt = new Date() + return { ...session, status: 'aborted', completedAt, updatedAt: completedAt } +} + +export function expectedUploadPartSize(session: UploadSessionRecord, partNumber: number): number { + if (session.method !== 'multipart' || !session.partSize || !session.partCount) { + throw new UploadSessionError('conflict', 'PUT upload sessions do not have multipart parts') + } + if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > session.partCount) { + throw new UploadSessionError('validation', 'Invalid upload part number') + } + if (partNumber < session.partCount) return session.partSize + return session.fileSize - session.partSize * (session.partCount - 1) +} + +export function uploadSessionObjectMetadata( + session: Pick< + UploadSessionRecord, + | 'id' + | 'userId' + | 'workspaceId' + | 'purpose' + | 'fileName' + | 'knowledgeBaseId' + | 'workflowId' + | 'executionId' + > +): Record { + return { + uploadId: session.id, + userId: session.userId, + originalName: session.fileName, + purpose: session.purpose, + ...(session.workspaceId ? { workspaceId: session.workspaceId } : {}), + ...(session.knowledgeBaseId ? { knowledgeBaseId: session.knowledgeBaseId } : {}), + ...(session.workflowId ? { workflowId: session.workflowId } : {}), + ...(session.executionId ? { executionId: session.executionId } : {}), + } +} + +function sessionFromPayload( + payload: UploadTokenPayload, + uploadToken: string, + transfer: UploadSessionTransfer +): CreatedUploadSession +function sessionFromPayload( + payload: UploadTokenPayload, + uploadToken: string, + transfer?: undefined +): UploadSessionRecord +function sessionFromPayload( + payload: UploadTokenPayload, + uploadToken: string, + transfer?: UploadSessionTransfer +): CreatedUploadSession | UploadSessionRecord { + const createdAt = new Date(payload.createdAt) + const expiresAt = new Date(payload.expiresAt) + const session: UploadSessionRecord = { + id: payload.uploadId, + workspaceId: payload.workspaceId, + userId: payload.actorId, + knowledgeBaseId: payload.purpose === 'knowledge_document' ? payload.knowledgeBaseId : null, + workflowId: payload.purpose === 'execution_attachment' ? payload.workflowId : null, + executionId: payload.purpose === 'execution_attachment' ? payload.executionId : null, + purpose: payload.purpose, + method: payload.method, + storageContext: payload.context, + storageKey: payload.finalKey, + finalKey: payload.finalKey, + stagingKey: payload.stagingKey, + storageProvider: payload.provider, + providerUploadId: payload.providerUploadId, + fileName: payload.fileName, + contentType: payload.contentType, + fileSize: payload.fileSize, + partSize: payload.method === 'multipart' ? payload.partSize : null, + partCount: payload.method === 'multipart' ? payload.partCount : null, + status: 'uploading', + metadata: payload.metadata, + uploadToken, + createdAt, + expiresAt, + completedFileId: null, + error: null, + completedAt: null, + updatedAt: new Date(), + } + return transfer ? { ...session, transfer } : session +} + +function createUploadTokenPayload(params: { + params: CreateUploadSessionParams + id: string + workspaceId: string | null + storageContext: StorageContext + finalKey: string + stagingKey: string + provider: UploadStorageProvider + providerUploadId: string | null + method: UploadTransferMethod + partSize: number | null + partCount: number | null + metadata: Record + createdAt: Date + expiresAt: Date +}): UploadTokenPayload { + const base = { + uploadId: params.id, + actorId: params.params.userId, + finalKey: params.finalKey, + stagingKey: params.stagingKey, + provider: params.provider, + fileName: params.params.fileName, + contentType: params.params.contentType, + fileSize: params.params.fileSize, + metadata: params.metadata, + createdAt: params.createdAt.toISOString(), + expiresAt: params.expiresAt.toISOString(), + } + const transfer = + params.method === 'put' + ? ({ method: 'put', providerUploadId: null } as const) + : ({ + method: 'multipart', + providerUploadId: params.providerUploadId, + partSize: requireNumber(params.partSize), + partCount: requireNumber(params.partCount), + } as const) + + switch (params.params.purpose) { + case 'workspace_file': + return { + ...base, + ...transfer, + purpose: params.params.purpose, + workspaceId: params.params.workspaceId, + context: 'workspace', + } + case 'table_import': + return { + ...base, + ...transfer, + purpose: params.params.purpose, + workspaceId: params.params.workspaceId, + context: 'table-import', + } + case 'knowledge_document': + return { + ...base, + ...transfer, + purpose: params.params.purpose, + workspaceId: params.params.workspaceId, + context: 'knowledge-base', + knowledgeBaseId: params.params.knowledgeBaseId, + } + case 'profile_picture': + return { + ...base, + ...transfer, + purpose: params.params.purpose, + workspaceId: null, + context: 'profile-pictures', + } + case 'workspace_logo': + return { + ...base, + ...transfer, + purpose: params.params.purpose, + workspaceId: params.params.workspaceId, + context: 'workspace-logos', + } + case 'mothership_attachment': + return { + ...base, + ...transfer, + purpose: params.params.purpose, + workspaceId: params.params.workspaceId, + context: 'mothership', + } + case 'execution_attachment': + return { + ...base, + ...transfer, + purpose: params.params.purpose, + workspaceId: params.params.workspaceId, + context: 'execution', + workflowId: params.params.workflowId, + executionId: params.params.executionId, + } + } +} + +function assertUploadable(session: UploadSessionRecord): void { + if (session.status !== 'uploading') { + throw new UploadSessionError('conflict', `Upload session is ${session.status}`) + } + if (session.expiresAt.getTime() <= Date.now()) { + throw new UploadSessionError('conflict', 'Upload session has expired') + } +} + +/** + * Validates method-specific completion input before any idempotent replay shortcut is taken. + * Callers that can return an already-completed resource must run this first as well. + */ +export function validateUploadCompletion( + session: UploadSessionRecord, + completion: UploadCompletion +): CompletedUploadPart[] { + if (session.method === 'put') { + if ('parts' in completion) { + throw new UploadSessionError('validation', 'PUT completion must not include parts') + } + return [] + } + if (!('parts' in completion) || !completion.parts) { + throw new UploadSessionError('validation', 'Multipart completion requires parts') + } + validateCompletedParts(session, completion.parts) + return completion.parts +} + +function validateCompletedParts(session: UploadSessionRecord, parts: CompletedUploadPart[]): void { + if (!session.partCount) throw new Error('Multipart upload is missing partCount') + if (parts.length !== session.partCount) { + throw new UploadSessionError( + 'validation', + `Expected ${session.partCount} completed parts; received ${parts.length}` + ) + } + const sorted = [...parts].sort((a, b) => a.partNumber - b.partNumber) + for (let index = 0; index < sorted.length; index++) { + if (sorted[index].partNumber !== index + 1) { + throw new UploadSessionError( + 'validation', + 'Completed parts must contain every part exactly once' + ) + } + if ( + (session.storageProvider === 's3' || session.storageProvider === 'gcs') && + !sorted[index].etag + ) { + throw new UploadSessionError( + 'validation', + `etag is required for ${session.storageProvider} part ${sorted[index].partNumber}` + ) + } + } +} + +function assertObjectIdentity( + session: UploadSessionRecord, + object: { size: number; contentType: string; uploadId: string }, + label: string +): void { + if (object.uploadId !== session.id) { + throw new UploadSessionError('conflict', `${label} object belongs to another upload`) + } + if (object.size !== session.fileSize) { + throw new UploadSessionError( + 'conflict', + `${label} object has ${object.size} bytes; expected ${session.fileSize}` + ) + } + if (object.contentType !== session.contentType) { + throw new UploadSessionError( + 'conflict', + `${label} object has content type ${object.contentType}; expected ${session.contentType}` + ) + } +} + +async function cleanupStagingObject(session: UploadSessionRecord): Promise { + const staging = await headProviderObject({ + provider: session.storageProvider, + key: session.stagingKey, + context: session.storageContext, + }) + if (!staging) return + assertObjectIdentity(session, staging, 'Staging') + await deleteProviderObjectVersion({ + provider: session.storageProvider, + key: session.stagingKey, + version: staging.version, + context: session.storageContext, + }) +} + +function validateFile(params: CreateUploadSessionParams): void { + if (!params.fileName.trim()) { + throw new UploadSessionError('validation', 'fileName must not be empty') + } + if (!params.contentType.trim()) { + throw new UploadSessionError('validation', 'contentType must not be empty') + } + const minimum = params.purpose === 'workspace_file' ? 0 : 1 + if (!Number.isSafeInteger(params.fileSize) || params.fileSize < minimum) { + const range = minimum === 0 ? 'a non-negative integer' : 'a positive integer' + throw new UploadSessionError('validation', `fileSize must be ${range}`) + } + const maximum = maximumFileSize(params.purpose) + if (params.fileSize > maximum) { + throw new UploadSessionError('validation', `File size exceeds maximum of ${maximum} bytes`) + } + if (params.purpose !== 'profile_picture' && !params.workspaceId.trim()) { + throw new UploadSessionError('validation', 'workspaceId must not be empty') + } + if (params.purpose === 'knowledge_document' && !params.knowledgeBaseId.trim()) { + throw new UploadSessionError('validation', 'knowledgeBaseId must not be empty') + } + if ( + params.purpose === 'execution_attachment' && + (!params.workflowId.trim() || !params.executionId.trim()) + ) { + throw new UploadSessionError('validation', 'workflowId and executionId must not be empty') + } +} + +function maximumFileSize(purpose: UploadSessionPurpose): number { + if (purpose === 'knowledge_document') return MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE + if (purpose === 'profile_picture' || purpose === 'workspace_logo') { + return UPLOAD_SESSION_ASSET_MAX_BYTES + } + if (purpose === 'execution_attachment') { + return MAX_WORKSPACE_FORMDATA_FILE_SIZE + } + return MAX_WORKSPACE_FILE_SIZE +} + +function requiresStorageQuota(purpose: UploadSessionPurpose): boolean { + return purpose === 'workspace_file' || purpose === 'knowledge_document' +} + +function resolveUploadStorage( + params: CreateUploadSessionParams, + id: string +): { storageContext: StorageContext; finalKey: string } { + switch (params.purpose) { + case 'workspace_file': + return { + storageContext: 'workspace', + finalKey: generateWorkspaceFileKey(params.workspaceId, params.fileName), + } + case 'table_import': + return { + storageContext: 'table-import', + finalKey: `table-import/${params.workspaceId}/${id}/${sanitizeFileName(params.fileName)}`, + } + case 'knowledge_document': + return { + storageContext: 'knowledge-base', + finalKey: generateKnowledgeBaseFileKey(params.fileName), + } + case 'profile_picture': + return { + storageContext: 'profile-pictures', + finalKey: `profile-pictures/${id}-${sanitizeFileName(params.fileName)}`, + } + case 'workspace_logo': + return { + storageContext: 'workspace-logos', + finalKey: `workspace-logos/${params.workspaceId}/${id}-${sanitizeFileName(params.fileName)}`, + } + case 'mothership_attachment': + return { + storageContext: 'mothership', + finalKey: generateWorkspaceFileKey(params.workspaceId, params.fileName), + } + case 'execution_attachment': + return { + storageContext: 'execution', + finalKey: generateExecutionFileKey( + { + workspaceId: params.workspaceId, + workflowId: params.workflowId, + executionId: params.executionId, + }, + `${id}-${params.fileName}` + ), + } + } +} + +function uploadNotFound(): UploadSessionError { + return new UploadSessionError('not_found', 'Upload session not found') +} + +function requireNumber(value: number | null): number { + if (value === null) throw new Error('Multipart upload geometry is missing') + return value +} diff --git a/apps/sim/lib/workspace-files/orchestration/content.ts b/apps/sim/lib/workspace-files/orchestration/content.ts index dacb48be300..f9703370d8e 100644 --- a/apps/sim/lib/workspace-files/orchestration/content.ts +++ b/apps/sim/lib/workspace-files/orchestration/content.ts @@ -17,6 +17,9 @@ const logger = createLogger('WorkspaceFileContentOrchestration') /** Ceiling on a single content replace, independent of the workspace quota. */ export const MAX_WORKSPACE_FILE_CONTENT_BYTES = 50 * 1024 * 1024 +/** JSON-body ceiling with room for a 50 MiB file's base64 expansion and envelope. */ +export const MAX_WORKSPACE_FILE_INLINE_BODY_BYTES = 70 * 1024 * 1024 + export interface PerformUpdateWorkspaceFileContentParams { workspaceId: string fileId: string diff --git a/apps/sim/lib/workspace-files/orchestration/create.test.ts b/apps/sim/lib/workspace-files/orchestration/create.test.ts new file mode 100644 index 00000000000..1e093ccac65 --- /dev/null +++ b/apps/sim/lib/workspace-files/orchestration/create.test.ts @@ -0,0 +1,212 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCaptureServerEvent, mockRecordAudit, mockUploadWorkspaceFile } = vi.hoisted(() => ({ + mockCaptureServerEvent: vi.fn(), + mockRecordAudit: vi.fn(), + mockUploadWorkspaceFile: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_UPLOADED: 'file.uploaded' }, + AuditResourceType: { FILE: 'file' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + FileConflictError: class FileConflictError extends Error { + constructor(name: string) { + super(`A file named "${name}" already exists in this workspace`) + this.name = 'FileConflictError' + } + }, + uploadWorkspaceFile: mockUploadWorkspaceFile, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { FileConflictError } from '@/lib/uploads/contexts/workspace' +import { + MAX_WORKSPACE_FILE_CONTENT_BYTES, + performCreateWorkspaceFile, +} from '@/lib/workspace-files/orchestration' + +const WORKSPACE_ID = 'workspace-1' +const USER_ID = 'user-1' +const CREATED_FILE = { + id: 'wf_created', + workspaceId: WORKSPACE_ID, + name: 'untitled.md', + key: 'workspace/workspace-1/untitled.md', + path: '/api/files/serve/untitled.md', + url: '/api/files/serve/untitled.md', + size: 0, + type: 'text/markdown', + uploadedBy: USER_ID, + folderId: null, + folderPath: null, + deletedAt: null, + uploadedAt: new Date('2026-08-04T00:00:00.000Z'), + updatedAt: new Date('2026-08-04T00:00:00.000Z'), + context: 'workspace' as const, +} + +describe('performCreateWorkspaceFile', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUploadWorkspaceFile.mockResolvedValue(CREATED_FILE) + }) + + it('creates an empty file with exact-name conflict semantics and returns its canonical record', async () => { + const request = new Request('https://sim.ai', { headers: { 'user-agent': 'test' } }) + + const result = await performCreateWorkspaceFile({ + workspaceId: WORKSPACE_ID, + userId: USER_ID, + actorName: 'Test User', + actorEmail: 'test@sim.ai', + name: 'untitled.md', + contentType: 'text/markdown', + folderId: null, + request, + }) + + expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( + WORKSPACE_ID, + USER_ID, + expect.objectContaining({ length: 0 }), + 'untitled.md', + 'text/markdown', + { folderId: null, exactName: true } + ) + expect(result).toEqual({ success: true, file: CREATED_FILE }) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: USER_ID, + actorName: 'Test User', + actorEmail: 'test@sim.ai', + workspaceId: WORKSPACE_ID, + resourceId: CREATED_FILE.id, + resourceName: CREATED_FILE.name, + metadata: { fileSize: 0, fileType: 'text/markdown' }, + request, + }) + ) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + USER_ID, + 'file_uploaded', + { workspace_id: WORKSPACE_ID, file_type: 'text/markdown' }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it('preserves initialized content, folder, and content type', async () => { + const content = Buffer.from('# Ready\n') + const file = { + ...CREATED_FILE, + name: 'ready.md', + folderId: 'folder-1', + folderPath: 'Docs', + size: content.length, + } + mockUploadWorkspaceFile.mockResolvedValue(file) + + const result = await performCreateWorkspaceFile({ + workspaceId: WORKSPACE_ID, + userId: USER_ID, + name: 'ready.md', + contentType: 'text/markdown; charset=utf-8', + folderId: 'folder-1', + content, + exactName: false, + }) + + expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( + WORKSPACE_ID, + USER_ID, + content, + 'ready.md', + 'text/markdown; charset=utf-8', + { folderId: 'folder-1', exactName: false } + ) + expect(result).toEqual({ success: true, file }) + }) + + it('rejects decoded content above the content-update limit before storage I/O', async () => { + const result = await performCreateWorkspaceFile({ + workspaceId: WORKSPACE_ID, + userId: USER_ID, + name: 'too-large.md', + contentType: 'text/markdown', + content: Buffer.alloc(MAX_WORKSPACE_FILE_CONTENT_BYTES + 1), + }) + + expect(result).toEqual({ + success: false, + error: 'File size exceeds 50MB limit', + errorCode: 'payload_too_large', + }) + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) + + it('classifies an exact-name collision as conflict and records no audit', async () => { + mockUploadWorkspaceFile.mockRejectedValue(new FileConflictError('untitled.md')) + + const result = await performCreateWorkspaceFile({ + workspaceId: WORKSPACE_ID, + userId: USER_ID, + name: 'untitled.md', + contentType: 'text/markdown', + }) + + expect(result).toEqual({ + success: false, + error: 'A file named "untitled.md" already exists in this workspace', + errorCode: 'conflict', + }) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) + + it('preserves classified folder failures and keeps unexpected faults internal', async () => { + mockUploadWorkspaceFile.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Target folder not found') + ) + + const missingFolder = await performCreateWorkspaceFile({ + workspaceId: WORKSPACE_ID, + userId: USER_ID, + name: 'untitled.md', + contentType: 'text/markdown', + folderId: 'missing', + }) + + expect(missingFolder).toEqual({ + success: false, + error: 'Target folder not found', + errorCode: 'not_found', + }) + + mockUploadWorkspaceFile.mockRejectedValueOnce(new Error('connection terminated')) + + const unexpected = await performCreateWorkspaceFile({ + workspaceId: WORKSPACE_ID, + userId: USER_ID, + name: 'untitled.md', + contentType: 'text/markdown', + }) + + expect(unexpected).toEqual({ + success: false, + error: 'connection terminated', + errorCode: 'internal', + }) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/orchestration/create.ts b/apps/sim/lib/workspace-files/orchestration/create.ts new file mode 100644 index 00000000000..c1f70cb0d46 --- /dev/null +++ b/apps/sim/lib/workspace-files/orchestration/create.ts @@ -0,0 +1,120 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getPostgresErrorCode, toError } from '@sim/utils/errors' +import { + asOrchestrationError, + type OrchestrationErrorCode, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { captureServerEvent } from '@/lib/posthog/server' +import { + FileConflictError, + uploadWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration/content' + +const logger = createLogger('WorkspaceFileCreateOrchestration') + +export interface PerformCreateWorkspaceFileParams { + workspaceId: string + userId: string + name: string + contentType: string + folderId?: string | null + content?: Buffer + exactName?: boolean + actorName?: string + actorEmail?: string + request?: OrchestrationRequestContext +} + +export interface PerformCreateWorkspaceFileResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + file?: WorkspaceFileRecord +} + +/** + * Creates a workspace file from server-held bytes and returns its canonical record. + * + * Exact-name mode keeps this operation suitable for public create surfaces: a + * live sibling with the requested name is a conflict instead of silently + * producing a suffixed copy. Uploads remain the storage and accounting + * primitive, including for an empty buffer. + */ +export async function performCreateWorkspaceFile( + params: PerformCreateWorkspaceFileParams +): Promise { + const { + workspaceId, + userId, + name, + contentType, + folderId, + content = Buffer.alloc(0), + exactName = true, + actorName, + actorEmail, + request, + } = params + + if (content.length > MAX_WORKSPACE_FILE_CONTENT_BYTES) { + return { + success: false, + error: `File size exceeds ${MAX_WORKSPACE_FILE_CONTENT_BYTES / 1024 / 1024}MB limit`, + errorCode: 'payload_too_large', + } + } + + try { + const file = await uploadWorkspaceFile(workspaceId, userId, content, name, contentType, { + folderId, + exactName, + }) + + logger.info('Created workspace file', { + workspaceId, + fileId: file.id, + folderId: file.folderId, + size: file.size, + }) + + recordAudit({ + workspaceId, + actorId: userId, + actorName, + actorEmail, + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.name, + description: `Uploaded file "${file.name}"`, + metadata: { fileSize: file.size, fileType: file.type }, + request, + }) + + captureServerEvent( + userId, + 'file_uploaded', + { workspace_id: workspaceId, file_type: file.type }, + { groups: { workspace: workspaceId } } + ) + + return { success: true, file } + } catch (error) { + logger.error('Failed to create workspace file', { error, workspaceId, folderId }) + + if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { + return { success: false, error: toError(error).message, errorCode: 'conflict' } + } + + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } + + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} diff --git a/apps/sim/lib/workspace-files/orchestration/index.ts b/apps/sim/lib/workspace-files/orchestration/index.ts index 1940e6c165c..166870b86b6 100644 --- a/apps/sim/lib/workspace-files/orchestration/index.ts +++ b/apps/sim/lib/workspace-files/orchestration/index.ts @@ -1,9 +1,15 @@ export { MAX_WORKSPACE_FILE_CONTENT_BYTES, + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, type PerformUpdateWorkspaceFileContentParams, type PerformUpdateWorkspaceFileContentResult, performUpdateWorkspaceFileContent, } from './content' +export { + type PerformCreateWorkspaceFileParams, + type PerformCreateWorkspaceFileResult, + performCreateWorkspaceFile, +} from './create' export { type PerformCreateWorkspaceFileFolderParams, type PerformCreateWorkspaceFileFolderResult, diff --git a/apps/sim/proxy.test.ts b/apps/sim/proxy.test.ts index 2ee2476e2c4..83627d42a5a 100644 --- a/apps/sim/proxy.test.ts +++ b/apps/sim/proxy.test.ts @@ -121,7 +121,7 @@ describe('resolveApiCorsPolicy', () => { }) it('returns default policy with APP_URL and credentials for other API routes', () => { - const policy = resolveApiCorsPolicy(makeRequest('/api/files/upload')) + const policy = resolveApiCorsPolicy(makeRequest('/api/files/uploads')) expect(policy).toEqual({ origin: 'https://app.sim.test', credentials: true, @@ -137,7 +137,7 @@ describe('resolveApiCorsPolicy', () => { '/api/chat/abc', '/api/workflows/wf/execute', '/api/v2/workflows/wf/execute', - '/api/files/upload', + '/api/files/uploads', ] for (const path of paths) { const policy = resolveApiCorsPolicy(makeRequest(path)) diff --git a/packages/testing/src/mocks/storage-service.mock.ts b/packages/testing/src/mocks/storage-service.mock.ts index effa9666f11..a61c5a381fd 100644 --- a/packages/testing/src/mocks/storage-service.mock.ts +++ b/packages/testing/src/mocks/storage-service.mock.ts @@ -20,7 +20,6 @@ export const storageServiceMockFns = { mockDeleteFile: vi.fn(), mockHeadObject: vi.fn(), mockGeneratePresignedUploadUrl: vi.fn(), - mockGenerateBatchPresignedUploadUrls: vi.fn(), mockGeneratePresignedDownloadUrl: vi.fn(), mockHasCloudStorage: vi.fn(() => false), mockGetS3InfoForKey: vi.fn(), @@ -40,7 +39,6 @@ export const storageServiceMock = { deleteFile: storageServiceMockFns.mockDeleteFile, headObject: storageServiceMockFns.mockHeadObject, generatePresignedUploadUrl: storageServiceMockFns.mockGeneratePresignedUploadUrl, - generateBatchPresignedUploadUrls: storageServiceMockFns.mockGenerateBatchPresignedUploadUrls, generatePresignedDownloadUrl: storageServiceMockFns.mockGeneratePresignedDownloadUrl, hasCloudStorage: storageServiceMockFns.mockHasCloudStorage, getS3InfoForKey: storageServiceMockFns.mockGetS3InfoForKey, From 383f0f8786642a8778d85faa2ca051d20da85d98 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 17:53:25 -0700 Subject: [PATCH 064/159] fix(cli): support unified upload sessions --- packages/sim-cli/README.md | 2 + .../commands/protocol/files-upload.test.ts | 126 ++++++ .../src/commands/protocol/files-upload.ts | 29 +- .../knowledge-document-upload.test.ts | 11 +- .../protocol/knowledge-document-upload.ts | 24 +- .../commands/protocol/tables-import.test.ts | 15 +- .../src/commands/protocol/tables-import.ts | 44 +-- packages/sim-cli/src/generated/v2-api.ts | 362 +++++++++++------- packages/sim-cli/src/runtime/build.test.ts | 34 ++ .../{multipart.ts => upload-session.ts} | 74 +++- 10 files changed, 503 insertions(+), 218 deletions(-) create mode 100644 packages/sim-cli/src/commands/protocol/files-upload.test.ts rename packages/sim-cli/src/transfer/{multipart.ts => upload-session.ts} (50%) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 77f0f045f3c..64745473ca5 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -130,6 +130,8 @@ sim tables upsert --data sim tables rows batch-delete (--row … | --filter ) --yes sim files list +sim files create --name [--content ] [--encoding utf-8|base64] +sim files upload [--name ] [--folder-id ] sim files download [-o ] sim files delete diff --git a/packages/sim-cli/src/commands/protocol/files-upload.test.ts b/packages/sim-cli/src/commands/protocol/files-upload.test.ts new file mode 100644 index 00000000000..9c09fb66207 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/files-upload.test.ts @@ -0,0 +1,126 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../../runtime/build.js' +import { attachProtocolCommands } from './index.js' + +const { mockRequest } = vi.hoisted(() => ({ + mockRequest: vi.fn(), +})) + +vi.mock('../../context.js', () => ({ + clientFrom: () => ({ + client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, + profile: { + workspaceId: 'ws_local', + output: 'json', + name: 'default', + apiKey: 'k', + endpoint: 'https://sim.example', + }, + }), +})) + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-file-upload-')) + mockRequest.mockReset() +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + rmSync(dir, { recursive: true, force: true }) +}) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachProtocolCommands(root) + return root +} + +describe('files upload', () => { + it('uses a signed PUT transfer and completes with an empty body', async () => { + const path = join(dir, 'notes.txt') + writeFileSync(path, 'hello') + mockRequest + .mockResolvedValueOnce({ + data: { + session: { + id: 'upload_1', + status: 'uploading', + name: 'notes.txt', + contentType: 'text/plain', + size: 5, + expiresAt: '2026-08-04T20:00:00.000Z', + error: null, + file: null, + }, + uploadToken: 'secret-token', + transfer: { + method: 'put', + url: 'https://storage.example/file', + headers: { 'content-type': 'text/plain' }, + }, + }, + }) + .mockResolvedValueOnce({ + data: { + id: 'upload_1', + status: 'completed', + name: 'notes.txt', + contentType: 'text/plain', + size: 5, + expiresAt: '2026-08-04T20:00:00.000Z', + error: null, + file: { + id: 'file_1', + name: 'notes.txt', + size: 5, + type: 'text/plain', + key: 'workspace/ws_local/notes.txt', + folderId: null, + folderPath: null, + uploadedBy: 'user_1', + uploadedAt: '2026-08-04T19:00:00.000Z', + updatedAt: '2026-08-04T19:00:00.000Z', + }, + }, + }) + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + const logged: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + await program().parseAsync(['node', 'sim', 'file', 'upload', path]) + + expect(fetchMock).toHaveBeenCalledWith( + 'https://storage.example/file', + expect.objectContaining({ + method: 'PUT', + headers: { 'content-type': 'text/plain' }, + body: expect.any(Blob), + }) + ) + expect(mockRequest.mock.calls[1]).toEqual([ + '/api/v2/files/uploads/upload_1/complete', + { + method: 'POST', + query: { workspaceId: 'ws_local' }, + headers: { 'upload-token': 'secret-token' }, + body: {}, + }, + ]) + expect(JSON.parse(logged[0])).toEqual({ + id: 'file_1', + name: 'notes.txt', + size: 5, + status: 'uploaded', + }) + expect(logged[0]).not.toContain('secret-token') + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/files-upload.ts b/packages/sim-cli/src/commands/protocol/files-upload.ts index a0221efffb7..1a2e5a9b337 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -1,17 +1,13 @@ import type { Command } from 'commander' import { clientFrom } from '../../context.js' +import type { + CompleteFileUploadResponse, + CreateFileUploadResponse, +} from '../../generated/v2-api.js' import { contentTypeFor, localFile } from '../../transfer/local-file.js' -import { finishTransfer } from '../../transfer/multipart.js' +import { finishUploadSession } from '../../transfer/upload-session.js' import { printProtocolResult } from './result.js' -interface FileUpload { - id: string - uploadToken: string - partSize: number - partCount: number - file: { id: string } | null -} - export function attachFileUpload(files: Command): void { files .command('upload ') @@ -24,7 +20,7 @@ export function attachFileUpload(files: Command): void { const workspaceId = client.requireWorkspace() const { name, size } = await localFile(path, options.name) - const created = await client.request<{ data: FileUpload }>('/api/v2/files/uploads', { + const created = await client.request('/api/v2/files/uploads', { method: 'POST', body: { workspaceId, @@ -34,22 +30,21 @@ export function attachFileUpload(files: Command): void { ...(options.folderId ? { folderId: options.folderId } : {}), }, }) - const upload = created.data - const completed = await finishTransfer( + const { session, uploadToken, transfer } = created.data + const completed = await finishUploadSession( client, workspaceId, { - basePath: `/api/v2/files/uploads/${encodeURIComponent(upload.id)}`, - uploadToken: upload.uploadToken, - partSize: upload.partSize, - partCount: upload.partCount, + basePath: `/api/v2/files/uploads/${encodeURIComponent(session.id)}`, + uploadToken, + transfer, size, }, path ) printProtocolResult(profile.output, { - id: completed.file?.id ?? completed.id, + id: completed.file?.id ?? session.id, name, size, status: 'uploaded', diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts index 4dd4e68c865..525751c58d9 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts @@ -56,9 +56,6 @@ function uploadSession() { name: 'notes.doc', contentType: 'application/msword', size: 5, - partSize: 10, - partCount: 1, - uploadToken: 'secret-token', expiresAt: '2026-08-04T20:00:00.000Z', error: null, document: null, @@ -81,7 +78,13 @@ describe('knowledge documents upload', () => { writeFileSync(path, 'hello') const session = uploadSession() mockRequest - .mockResolvedValueOnce({ data: session }) + .mockResolvedValueOnce({ + data: { + session, + uploadToken: 'secret-token', + transfer: { method: 'multipart', partSize: 10, partCount: 1 }, + }, + }) .mockResolvedValueOnce({ data: { parts: [ diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts index b9fd2d88c4f..1a459930628 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts @@ -1,13 +1,14 @@ import type { Command } from 'commander' import { clientFrom } from '../../context.js' -import type { CreateKnowledgeDocumentUploadResponse } from '../../generated/v2-api.js' +import type { + CompleteKnowledgeDocumentUploadResponse, + CreateKnowledgeDocumentUploadResponse, +} from '../../generated/v2-api.js' import { SimApiError } from '../../http/client.js' import { contentTypeFor, localFile } from '../../transfer/local-file.js' -import { finishTransfer } from '../../transfer/multipart.js' +import { finishUploadSession } from '../../transfer/upload-session.js' import { printProtocolResult } from './result.js' -type KnowledgeDocumentUpload = CreateKnowledgeDocumentUploadResponse['data'] - interface KnowledgeDocumentUploadOptions { name?: string tag?: string[] @@ -65,24 +66,25 @@ export function attachKnowledgeDocumentUpload(documents: Command): void { }, } ) - const upload = created.data - const completed = await finishTransfer( + const { session, uploadToken, transfer } = created.data + const completed = await finishUploadSession< + CompleteKnowledgeDocumentUploadResponse['data'] + >( client, workspaceId, { basePath: `/api/v2/knowledge/${encodeURIComponent( knowledgeBaseId - )}/documents/uploads/${encodeURIComponent(upload.id)}`, - uploadToken: upload.uploadToken, - partSize: upload.partSize, - partCount: upload.partCount, + )}/documents/uploads/${encodeURIComponent(session.id)}`, + uploadToken, + transfer, size, }, path ) if (!completed.document) { - throw new Error(`Knowledge upload ${completed.id} completed without a document`) + throw new Error(`Knowledge upload ${session.id} completed without a document`) } printProtocolResult(profile.output, { id: completed.document.id, diff --git a/packages/sim-cli/src/commands/protocol/tables-import.test.ts b/packages/sim-cli/src/commands/protocol/tables-import.test.ts index ae238855b34..ba907338272 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.test.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.test.ts @@ -86,12 +86,15 @@ describe('tables import output', () => { it('prints a normalized result without transfer secrets', async () => { mockRequest.mockResolvedValue({ data: { - id: 'import_1', - status: 'queued', - tableId: 'table_1', - rowsProcessed: 0, - error: null, - upload: null, + session: { + id: 'import_1', + status: 'queued', + tableId: 'table_1', + rowsProcessed: 0, + error: null, + }, + uploadToken: null, + transfer: null, }, }) const logged: string[] = [] diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts index 9c2ca5b0cc7..4ef2e33b31d 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -2,20 +2,18 @@ import { setTimeout as sleep } from 'node:timers/promises' import chalk from 'chalk' import { type Command, Option } from 'commander' import { clientFrom } from '../../context.js' +import type { + CompleteTableImportResponse, + CreateTableImportResponse, + GetTableImportResponse, +} from '../../generated/v2-api.js' import { SimApiError, type SimClient } from '../../http/client.js' -import { coerce } from '../../runtime/request.js' +import { coerce, type FieldSpec } from '../../runtime/request.js' import { contentTypeFor, localFile } from '../../transfer/local-file.js' -import { finishTransfer } from '../../transfer/multipart.js' +import { finishUploadSession } from '../../transfer/upload-session.js' import { printProtocolResult } from './result.js' -interface TableImport { - id: string - status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' - tableId: string | null - rowsProcessed: number - error: string | null - upload: { uploadToken: string; partSize: number; partCount: number } | null -} +type TableImport = GetTableImportResponse['data'] interface ImportOptions { name?: string @@ -39,8 +37,8 @@ function tableNameFrom(fileName: string): string { return (/^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned).slice(0, 128) } -function jsonFlag(raw: string, flagName: string): unknown { - return coerce(raw, { kind: 'object' }, { json: true }, flagName) +function jsonFlag(raw: string, flagName: string, kind: FieldSpec['kind']): unknown { + return coerce(raw, { kind }, { json: true }, flagName) } async function watchImport( @@ -144,31 +142,33 @@ export function attachTableImport(tables: Command): void { target = { type: 'new', name, ...(options.folderId ? { folderId: options.folderId } : {}) } } - const started = await client.request<{ data: TableImport }>('/api/v2/tables/imports', { + const started = await client.request('/api/v2/tables/imports', { method: 'POST', body: { workspaceId, source, target, - ...(options.mapping ? { mapping: jsonFlag(options.mapping, 'mapping') } : {}), + ...(options.mapping ? { mapping: jsonFlag(options.mapping, 'mapping', 'object') } : {}), ...(options.createColumns - ? { createColumns: jsonFlag(options.createColumns, 'create-columns') } + ? { createColumns: jsonFlag(options.createColumns, 'create-columns', 'array') } : {}), ...(options.timezone ? { timezone: options.timezone } : {}), }, }) - let job = started.data - if (path && job.upload) { - job = await finishTransfer( + let job: TableImport = started.data.session + if (path) { + if (!local || !started.data.uploadToken || !started.data.transfer) { + throw new Error('Local table import did not return an upload transfer') + } + job = await finishUploadSession( client, workspaceId, { basePath: `/api/v2/tables/imports/${encodeURIComponent(job.id)}`, - uploadToken: job.upload.uploadToken, - partSize: job.upload.partSize, - partCount: job.upload.partCount, - size: local?.size ?? 0, + uploadToken: started.data.uploadToken, + transfer: started.data.transfer, + size: local.size, }, path ) diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 116f7b5f368..dc9cd5ad51e 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -30,9 +30,6 @@ export type AbortFileUploadResponse = { name: string contentType: string size: number - partSize: number - partCount: number - uploadToken: string expiresAt: string error: string | null file: { @@ -72,9 +69,6 @@ export type AbortKnowledgeDocumentUploadResponse = { name: string contentType: string size: number - partSize: number - partCount: number - uploadToken: string expiresAt: string error: string | null document: { @@ -297,12 +291,6 @@ export type CancelTableImportResponse = { tableId: string | null rowsProcessed: number error: string | null - upload: { - uploadToken: string - partSize: number - partCount: number - expiresAt: string - } | null createdAt: string updatedAt: string completedAt: string | null @@ -360,12 +348,14 @@ export type CompleteFileUploadQuery = { workspaceId: string } -export type CompleteFileUploadBody = { - parts: Array<{ - partNumber: number - etag?: string - }> -} +export type CompleteFileUploadBody = + | { + parts: Array<{ + partNumber: number + etag?: string + }> + } + | Record export type CompleteFileUploadHeaders = { 'upload-token': string @@ -378,9 +368,6 @@ export type CompleteFileUploadResponse = { name: string contentType: string size: number - partSize: number - partCount: number - uploadToken: string expiresAt: string error: string | null file: { @@ -408,12 +395,14 @@ export type CompleteKnowledgeDocumentUploadQuery = { workspaceId: string } -export type CompleteKnowledgeDocumentUploadBody = { - parts: Array<{ - partNumber: number - etag?: string - }> -} +export type CompleteKnowledgeDocumentUploadBody = + | { + parts: Array<{ + partNumber: number + etag?: string + }> + } + | Record export type CompleteKnowledgeDocumentUploadHeaders = { 'upload-token': string @@ -427,9 +416,6 @@ export type CompleteKnowledgeDocumentUploadResponse = { name: string contentType: string size: number - partSize: number - partCount: number - uploadToken: string expiresAt: string error: string | null document: { @@ -457,12 +443,14 @@ export type CompleteTableImportQuery = { workspaceId: string } -export type CompleteTableImportBody = { - parts: Array<{ - partNumber: number - etag?: string - }> -} +export type CompleteTableImportBody = + | { + parts: Array<{ + partNumber: number + etag?: string + }> + } + | Record export type CompleteTableImportHeaders = { 'upload-token': string @@ -498,12 +486,6 @@ export type CompleteTableImportResponse = { tableId: string | null rowsProcessed: number error: string | null - upload: { - uploadToken: string - partSize: number - partCount: number - expiresAt: string - } | null createdAt: string updatedAt: string completedAt: string | null @@ -526,6 +508,7 @@ export type CreateCredentialBody = { clientId?: string clientSecret?: string orgId?: string + dataCenter?: string } export type CreateCredentialResponse = { @@ -589,6 +572,31 @@ export type CreateCustomToolResponse = { } } +/** `POST /api/v2/files` */ +export type CreateFileBody = { + workspaceId: string + name: string + contentType?: string + folderId?: string + content?: string + encoding?: 'utf-8' | 'base64' +} + +export type CreateFileResponse = { + data: { + id: string + name: string + size: number + type: string + key: string + folderId: string | null + folderPath: string | null + uploadedBy: string + uploadedAt: string + updatedAt: string + } +} + /** `POST /api/v2/files/uploads` */ export type CreateFileUploadBody = { workspaceId: string @@ -600,28 +608,39 @@ export type CreateFileUploadBody = { export type CreateFileUploadResponse = { data: { - id: string - status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' - name: string - contentType: string - size: number - partSize: number - partCount: number - uploadToken: string - expiresAt: string - error: string | null - file: { + session: { id: string + status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' name: string + contentType: string size: number - type: string - key: string - folderId: string | null - folderPath: string | null - uploadedBy: string - uploadedAt: string - updatedAt: string - } | null + expiresAt: string + error: string | null + file: { + id: string + name: string + size: number + type: string + key: string + folderId: string | null + folderPath: string | null + uploadedBy: string + uploadedAt: string + updatedAt: string + } | null + } + uploadToken: string + transfer: + | { + method: 'put' + url: string + headers: Record + } + | { + method: 'multipart' + partSize: number + partCount: number + } } } @@ -744,30 +763,41 @@ export type CreateKnowledgeDocumentUploadBody = { export type CreateKnowledgeDocumentUploadResponse = { data: { - id: string - knowledgeBaseId: string - status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' - name: string - contentType: string - size: number - partSize: number - partCount: number - uploadToken: string - expiresAt: string - error: string | null - document: { + session: { id: string knowledgeBaseId: string - filename: string - fileSize: number - mimeType: string - processingStatus: 'pending' | 'processing' | 'completed' | 'failed' - chunkCount: number - tokenCount: number - characterCount: number - enabled: boolean - createdAt: string | null - } | null + status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + document: { + id: string + knowledgeBaseId: string + filename: string + fileSize: number + mimeType: string + processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + chunkCount: number + tokenCount: number + characterCount: number + enabled: boolean + createdAt: string | null + } | null + } + uploadToken: string + transfer: + | { + method: 'put' + url: string + headers: Record + } + | { + method: 'multipart' + partSize: number + partCount: number + } } } @@ -983,51 +1013,99 @@ export type CreateTableImportBody = { tableId: string mode: 'append' | 'replace' } - mapping?: unknown - createColumns?: unknown + mapping?: Record + createColumns?: Array timezone?: string } export type CreateTableImportResponse = { - data: { - id: string - workspaceId: string - status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' - source: - | { - type: 'upload' - name: string - contentType: string - size: number - } - | { - type: 'workspace_file' - fileId: string - } - target: - | { - type: 'new' - name: string - folderId?: string + data: + | { + session: { + id: string + workspaceId: string + status: + | 'uploading' + | 'queued' + | 'processing' + | 'completed' + | 'failed' + | 'canceled' + | 'expired' + source: { + type: 'upload' + name: string + contentType: string + size: number + } + target: + | { + type: 'new' + name: string + folderId?: string + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null } - | { - type: 'existing' - tableId: string - mode: 'append' | 'replace' + uploadToken: string + transfer: + | { + method: 'put' + url: string + headers: Record + } + | { + method: 'multipart' + partSize: number + partCount: number + } + } + | { + session: { + id: string + workspaceId: string + status: + | 'uploading' + | 'queued' + | 'processing' + | 'completed' + | 'failed' + | 'canceled' + | 'expired' + source: { + type: 'workspace_file' + fileId: string + } + target: + | { + type: 'new' + name: string + folderId?: string + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null } - tableId: string | null - rowsProcessed: number - error: string | null - upload: { - uploadToken: string - partSize: number - partCount: number - expiresAt: string - } | null - createdAt: string - updatedAt: string - completedAt: string | null - } + uploadToken: null + transfer: null + } } /** `POST /api/v2/tables/imports/[importId]/parts` */ @@ -2190,12 +2268,6 @@ export type GetTableImportResponse = { tableId: string | null rowsProcessed: number error: string | null - upload: { - uploadToken: string - partSize: number - partCount: number - expiresAt: string - } | null createdAt: string updatedAt: string completedAt: string | null @@ -3405,6 +3477,7 @@ export type UpdateCredentialBody = { clientId?: string clientSecret?: string orgId?: string + dataCenter?: string } export type UpdateCredentialResponse = { @@ -4209,9 +4282,7 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, }, - body: { - parts: { kind: 'array', required: true }, - }, + opaqueBody: true, }, completeKnowledgeDocumentUpload: { method: 'POST', @@ -4222,9 +4293,7 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, }, - body: { - parts: { kind: 'array', required: true }, - }, + opaqueBody: true, }, completeTableImport: { method: 'POST', @@ -4235,9 +4304,7 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, }, - body: { - parts: { kind: 'array', required: true }, - }, + opaqueBody: true, }, createCredential: { method: 'POST', @@ -4264,6 +4331,7 @@ export const V2_OPERATIONS = { clientId: { kind: 'string' }, clientSecret: { kind: 'string' }, orgId: { kind: 'string' }, + dataCenter: { kind: 'string' }, }, }, createCustomTool: { @@ -4279,6 +4347,21 @@ export const V2_OPERATIONS = { code: { kind: 'string', required: true }, }, }, + createFile: { + method: 'POST', + path: '/api/v2/files', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create File', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + contentType: { kind: 'string' }, + folderId: { kind: 'string' }, + content: { kind: 'string', default: '' }, + encoding: { kind: 'enum', values: ['utf-8', 'base64'] as const, default: 'utf-8' }, + }, + }, createFileUpload: { method: 'POST', path: '/api/v2/files/uploads', @@ -4440,8 +4523,8 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, source: { kind: 'unknown', required: true }, target: { kind: 'unknown', required: true }, - mapping: { kind: 'unknown' }, - createColumns: { kind: 'unknown' }, + mapping: { kind: 'object' }, + createColumns: { kind: 'array' }, timezone: { kind: 'string' }, }, }, @@ -5383,6 +5466,7 @@ export const V2_OPERATIONS = { clientId: { kind: 'string' }, clientSecret: { kind: 'string' }, orgId: { kind: 'string' }, + dataCenter: { kind: 'string' }, }, }, updateCustomTool: { diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index df782306a19..2ce5121b80e 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -135,6 +135,40 @@ describe('commands parsed through commander', () => { expect(options.body).toMatchObject({ conflictTarget: 'email', data: { a: 1 } }) }) + it('exposes inline file creation added by the v2 files contract', async () => { + const [path, options] = await run([ + 'file', + 'create', + '--name', + 'notes.txt', + '--content', + 'hello', + '--encoding', + 'utf-8', + ]) + expect(path).toBe('/api/v2/files') + expect(options.body).toEqual({ + workspaceId: 'ws_local', + name: 'notes.txt', + content: 'hello', + encoding: 'utf-8', + }) + }) + + it('exposes credential data centers added by the v2 credential contract', async () => { + const [, options] = await run([ + 'credential', + 'create', + '--type', + 'service_account', + '--display-name', + 'Zoho', + '--data-center', + 'eu', + ]) + expect(options.body).toMatchObject({ dataCenter: 'eu' }) + }) + it('comma-joins a repeated list flag', async () => { const [, options] = await run(['logs', 'list', '--workflow', 'wf_1', 'wf_2']) expect(options.query).toMatchObject({ workflowIds: 'wf_1,wf_2' }) diff --git a/packages/sim-cli/src/transfer/multipart.ts b/packages/sim-cli/src/transfer/upload-session.ts similarity index 50% rename from packages/sim-cli/src/transfer/multipart.ts rename to packages/sim-cli/src/transfer/upload-session.ts index 20a7cfa1b3e..959643095f6 100644 --- a/packages/sim-cli/src/transfer/multipart.ts +++ b/packages/sim-cli/src/transfer/upload-session.ts @@ -7,24 +7,54 @@ interface UploadPartUrl { headers: Record } -export interface Transfer { +export type UploadTransfer = + | { + method: 'put' + url: string + headers: Record + } + | { + method: 'multipart' + partSize: number + partCount: number + } + +export interface UploadSession { basePath: string uploadToken: string - partSize: number - partCount: number + transfer: UploadTransfer size: number } const PART_URL_BATCH = 100 +async function uploadPut(transfer: Extract, blob: Blob) { + // boundary-raw-fetch: signed upload data-plane URL may target cloud storage or local Sim + const response = await fetch(transfer.url, { + method: 'PUT', + headers: transfer.headers, + body: blob, + }) + if (!response.ok) { + throw new SimApiError(`Upload failed with status ${response.status}`, response.status) + } +} + async function uploadParts( client: SimClient, workspaceId: string, - transfer: Transfer, + session: UploadSession, + transfer: Extract, blob: Blob ): Promise> { - const completed: Array<{ partNumber: number; etag?: string }> = [] + const expectedPartCount = Math.ceil(session.size / transfer.partSize) + if (expectedPartCount !== transfer.partCount) { + throw new Error( + `Upload session expected ${transfer.partCount} parts, but file requires ${expectedPartCount}` + ) + } + const completed: Array<{ partNumber: number; etag?: string }> = [] for (let first = 1; first <= transfer.partCount; first += PART_URL_BATCH) { const partNumbers = [] for (let n = first; n < first + PART_URL_BATCH && n <= transfer.partCount; n++) { @@ -32,20 +62,20 @@ async function uploadParts( } const signed = await client.request<{ data: { parts: UploadPartUrl[] } }>( - `${transfer.basePath}/parts`, + `${session.basePath}/parts`, { method: 'POST', query: { workspaceId }, - headers: { 'upload-token': transfer.uploadToken }, + headers: { 'upload-token': session.uploadToken }, body: { partNumbers }, } ) for (const part of signed.data.parts) { const start = (part.partNumber - 1) * transfer.partSize - const chunk = blob.slice(start, Math.min(start + transfer.partSize, transfer.size)) + const chunk = blob.slice(start, Math.min(start + transfer.partSize, session.size)) - // boundary-raw-fetch: storage-signed URL on another origin, not the API + // boundary-raw-fetch: signed upload data-plane URL may target cloud storage or local Sim const response = await fetch(part.url, { method: 'PUT', headers: part.headers, @@ -62,33 +92,39 @@ async function uploadParts( completed.push(etag ? { partNumber: part.partNumber, etag } : { partNumber: part.partNumber }) } } - return completed } -/** Uploads and completes a multipart transfer, aborting it if either step fails. */ -export async function finishTransfer( +/** Uploads and completes a signed transfer, aborting its session if the transfer fails. */ +export async function finishUploadSession( client: SimClient, workspaceId: string, - transfer: Transfer, + session: UploadSession, path: string ): Promise { try { const blob = await openAsBlob(path) - const parts = await uploadParts(client, workspaceId, transfer, blob) - const completed = await client.request<{ data: T }>(`${transfer.basePath}/complete`, { + let body: Record + if (session.transfer.method === 'put') { + await uploadPut(session.transfer, blob) + body = {} + } else { + body = { parts: await uploadParts(client, workspaceId, session, session.transfer, blob) } + } + + const completed = await client.request<{ data: T }>(`${session.basePath}/complete`, { method: 'POST', query: { workspaceId }, - headers: { 'upload-token': transfer.uploadToken }, - body: { parts }, + headers: { 'upload-token': session.uploadToken }, + body, }) return completed.data } catch (error) { await client - .request(transfer.basePath, { + .request(session.basePath, { method: 'DELETE', query: { workspaceId }, - headers: { 'upload-token': transfer.uploadToken }, + headers: { 'upload-token': session.uploadToken }, }) .catch(() => undefined) throw error From 98aa51f56c2f97db8a17436d81f3d6706ab9e84e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 18:27:51 -0700 Subject: [PATCH 065/159] feat(api): add file metadata endpoint --- apps/docs/openapi-v2-files-audit.json | 79 +++++++++++ .../v2/files/[fileId]/metadata/route.test.ts | 127 ++++++++++++++++++ .../api/v2/files/[fileId]/metadata/route.ts | 61 +++++++++ apps/sim/lib/api/contracts/v2/files.ts | 11 ++ 4 files changed, 278 insertions(+) create mode 100644 apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/metadata/route.ts diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 8ad1d4363ce..282faaf165d 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -680,6 +680,85 @@ } } }, + "/api/v2/files/{fileId}/metadata": { + "get": { + "operationId": "getFile", + "summary": "Get File Metadata", + "description": "Return one workspace file's metadata without downloading its content. Lookups are workspace-scoped: a file that belongs to another workspace returns `404`.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files/{fileId}/metadata?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The file metadata.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileResponse" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "folderPath": "Reports/Q1", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, "/api/v2/audit-logs": { "get": { "operationId": "listAuditLogs", diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts new file mode 100644 index 00000000000..cfabd3446dd --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts @@ -0,0 +1,127 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockGetWorkspaceFile } = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetWorkspaceFile: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + getWorkspaceFile: mockGetWorkspaceFile, +})) + +import { GET } from '@/app/api/v2/files/[fileId]/metadata/route' + +const WORKSPACE_ID = 'workspace-1' +const FILE_ID = 'wf_1' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const ctx = { params: Promise.resolve({ fileId: FILE_ID }) } + +function buildRecord() { + return { + id: FILE_ID, + workspaceId: WORKSPACE_ID, + name: 'data.csv', + key: 'workspace/ws/1-x-data.csv', + path: '/api/files/serve/x', + size: 1024, + type: 'text/csv', + uploadedBy: 'user-1', + folderId: null, + folderPath: null, + uploadedAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + } +} + +const callGet = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/metadata?${query}`), ctx) + +describe('GET /api/v2/files/[fileId]/metadata', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceFile.mockResolvedValue(buildRecord()) + }) + + it('400s when workspaceId is missing', async () => { + const response = await callGet('') + + expect(response.status).toBe(400) + expect(mockGetWorkspaceFile).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await callGet(`workspaceId=${WORKSPACE_ID}`) + + expect(response.status).toBe(403) + expect(mockGetWorkspaceFile).not.toHaveBeenCalled() + }) + + it('404s when the workspace-scoped file does not exist', async () => { + mockGetWorkspaceFile.mockResolvedValue(null) + + const response = await callGet(`workspaceId=${WORKSPACE_ID}`) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + }) + + it('returns the public metadata projection without loading content', async () => { + const response = await callGet(`workspaceId=${WORKSPACE_ID}`) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: FILE_ID, + name: 'data.csv', + size: 1024, + type: 'text/csv', + key: 'workspace/ws/1-x-data.csv', + folderId: null, + folderPath: null, + uploadedBy: 'user-1', + uploadedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + WORKSPACE_ID, + 'read' + ) + expect(mockGetWorkspaceFile).toHaveBeenCalledWith(WORKSPACE_ID, FILE_ID, { + throwOnError: true, + }) + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts new file mode 100644 index 00000000000..69d1b8ce243 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts @@ -0,0 +1,61 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2GetFileContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2File } from '@/app/api/v2/files/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileMetadataAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FileMetadataRouteParams { + params: Promise<{ fileId: string }> +} + +/** GET /api/v2/files/[fileId]/metadata — Return file metadata without downloading its bytes. */ +export const GET = withRouteHandler( + async (request: NextRequest, context: FileMetadataRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) + if (!file) return v2Error('NOT_FOUND', 'File not found') + + return v2Data(toV2File(file), { rateLimit }) + } catch (error) { + logger.error('Error fetching file metadata', { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index e871a5438a3..48839b9214e 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -424,6 +424,17 @@ export const v2DownloadFileContract = defineRouteContract({ }, }) +export const v2GetFileContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/[fileId]/metadata', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileSchema), + }, +}) + export const v2RenameFileContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/files/[fileId]', From 9c3df54b68ae0cc08b3722d8ba4c6bd005b24a40 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 18:33:08 -0700 Subject: [PATCH 066/159] feat(cli): accept simple list inputs --- packages/sim-cli/README.md | 16 +++++++ packages/sim-cli/src/contract/commands.ts | 27 ++++++++++- packages/sim-cli/src/contract/types.ts | 3 +- packages/sim-cli/src/runtime/build.test.ts | 27 +++++++++++ packages/sim-cli/src/runtime/options.ts | 10 ++-- packages/sim-cli/src/runtime/request.test.ts | 19 ++++++++ packages/sim-cli/src/runtime/request.ts | 50 +++++++++++++++++--- 7 files changed, 139 insertions(+), 13 deletions(-) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 64745473ca5..bfe618954ed 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -133,6 +133,8 @@ sim files list sim files create --name [--content ] [--encoding utf-8|base64] sim files upload [--name ] [--folder-id ] sim files download [-o ] +sim files move [--file-ids …] [--folder-ids …] [--target-folder-id ] +sim files batch-archive [--file-ids …] [--folder-ids …] --yes sim files delete sim knowledge list @@ -142,6 +144,20 @@ sim knowledge documents upload [--tag ...] sim knowledge search --query --kb … [--search-mode vector|hybrid] ``` +### List inputs + +Primitive lists take space-separated values. Prefix a path with `@` to read +one value per line, or use `@-` to read the list from stdin. + +```bash +sim files move --file-ids file_1 file_2 --target-folder-id folder_1 +sim files move --file-ids @file-ids.txt --target-folder-id folder_1 +printf 'file_1\nfile_2\n' | sim files move --file-ids @- --target-folder-id folder_1 +``` + +Arrays of objects remain JSON inputs because they cannot be represented as a +flat list without losing structure. + ### Filtering table rows `--filter` takes the same predicate tree the API uses — `all` (AND) or `any` diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index fca6ef16a66..c739043a1bf 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -285,11 +285,19 @@ export const CLI_CONTRACT: CliContract = { // `batch-` for the bulk form, matching `tables rows batch-delete`. command: 'files batch-archive', describe: 'Archive several files and folders at once', + flags: { + fileIds: { list: true }, + folderIds: { list: true }, + }, confirm: 'This archives every listed file and folder, and everything inside those folders.', }, moveFileItems: { command: 'files move', describe: 'Move files and folders into another folder', + flags: { + fileIds: { list: true }, + folderIds: { list: true }, + }, }, renameFile: { // Derived to `files update`, which contradicted its own summary. @@ -314,13 +322,23 @@ export const CLI_CONTRACT: CliContract = { upsertFileShare: { command: 'files share set', describe: 'Enable or disable sharing for a file', + flags: { + allowedEmails: { list: true }, + }, }, // ─── The expanded tables surface ────────────────────────────────────────── // `/cancel-runs`, `/rows/find`, `/restore`, `/columns/run` and the enrichment // path all put a verb where the deriver expects a sub-resource, so each became // a group holding a lone `create`. - cancelTableRuns: { command: 'tables cancel-runs', describe: 'Stop every running column job' }, + cancelTableRuns: { + command: 'tables cancel-runs', + describe: 'Stop every running column job', + flags: { + excludeRowIds: { list: true }, + filter: { json: true, describe: TABLE_FILTER_HELP }, + }, + }, findTableRows: { command: 'tables rows find', describe: 'Find rows matching a predicate', @@ -336,7 +354,12 @@ export const CLI_CONTRACT: CliContract = { runTableColumn: { command: 'tables columns run', describe: 'Run a column’s workflow', - flags: { filter: { json: true, describe: TABLE_FILTER_HELP } }, + flags: { + groupIds: { list: true }, + rowIds: { list: true }, + excludeRowIds: { list: true }, + filter: { json: true, describe: TABLE_FILTER_HELP }, + }, }, runRowEnrichment: { command: 'tables rows enrich', diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 1f585d15b98..43dbc7cba80 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -30,7 +30,8 @@ export interface FlagSpec { /** Short alias, e.g. `w` for `--workspace`. */ short?: string /** - * Accept the flag more than once. + * Accept one or more space-separated values, or `@path` / `@-` with one + * value per line. * * Only says that several values are allowed — how they reach the wire is * decided by the field's kind, not here. A `string` field is one the route diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 2ce5121b80e..0b76fd3e1a9 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -155,6 +155,27 @@ describe('commands parsed through commander', () => { }) }) + it('accepts space-separated file and folder ids', async () => { + const [path, options] = await run([ + 'file', + 'move', + '--file-ids', + 'file_1', + 'file_2', + '--folder-ids', + 'folder_1', + '--target-folder-id', + 'folder_2', + ]) + expect(path).toBe('/api/v2/files/move') + expect(options.body).toEqual({ + workspaceId: 'ws_local', + fileIds: ['file_1', 'file_2'], + folderIds: ['folder_1'], + targetFolderId: 'folder_2', + }) + }) + it('exposes credential data centers added by the v2 credential contract', async () => { const [, options] = await run([ 'credential', @@ -222,6 +243,12 @@ describe('commands parsed through commander', () => { expect(options.body).toMatchObject({ knowledgeBaseIds: ['kb_1'], searchMode: 'hybrid' }) }) + it('documents space-separated and file-backed lists', () => { + const help = commandAt('files', 'move').helpInformation() + expect(help).toContain('--file-ids ') + expect(help).toMatch(/space-separated.*@path.*one value per line/s) + }) + it('advertises the file-content encoding choices', () => { expect(commandAt('files', 'set-content').helpInformation()).toMatch( /--encoding.*utf-8.*base64/s diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index c3a393ec6af..9c5ab175618 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -59,9 +59,13 @@ function addFieldOption( const choices = flag.choices ?? descriptor.values const describe = `${ flag.describe ?? (choices ? `One of: ${choices.join(', ')}` : `Set ${field}`) - }${wantsJson && !takesList ? ' (JSON, or @path / @- to read a file or stdin)' : ''}${ - descriptor.required ? ' (required)' : '' - }` + }${ + takesList + ? ' (space-separated, or @path / @- with one value per line)' + : wantsJson + ? ' (JSON, or @path / @- to read a file or stdin)' + : '' + }${descriptor.required ? ' (required)' : ''}` const option = new Option(`${short}--${name} ${placeholder}`, describe) if (choices && !takesList) option.choices([...choices]) diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 7470db8bb79..88dfa4e3d25 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -142,6 +142,25 @@ describe('repeated flags encode per the field kind, not uniformly', () => { ) expect(built.body?.knowledgeBaseIds).toEqual(['kb_1', 'kb_2']) }) + + it('reads one list value per line from @path', () => { + const path = join(tmpdir(), 'sim-cli-list-values.txt') + writeFileSync(path, 'file_1\nfile_2\n') + expect(coerce(`@${path}`, { kind: 'array' }, { list: true }, 'file-ids')).toEqual([ + 'file_1', + 'file_2', + ]) + rmSync(path) + }) + + it('rejects empty lines in a list file', () => { + const path = join(tmpdir(), 'sim-cli-list-empty-line.txt') + writeFileSync(path, 'file_1\n\nfile_2') + expect(() => coerce(`@${path}`, { kind: 'array' }, { list: true }, 'file-ids')).toThrow( + /empty value on line 2/ + ) + rmSync(path) + }) }) describe('contract-provided choices', () => { diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 292d413bf10..7883cebd5f7 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -77,16 +77,16 @@ function readStdin(): string { } /** - * Resolves a JSON flag's argument, which may name a file instead of carrying - * the document inline. + * Resolves a flag argument that may name a file instead of carrying its value + * inline. * * `@path` reads the file and `@-` reads stdin, the curl convention. A workflow * export is hundreds of lines, and the shell makes passing that literally * unpleasant — unquoted `$(cat f.json)` word-splits into broken JSON, and the - * quoted form is easy to get wrong. `@` cannot collide with a real value - * because JSON only ever starts with `{ [ " -`, a digit, or t/f/n. + * quoted form is easy to get wrong. JSON never starts with `@`; primitive list + * flags reserve it for this explicit file-input form. */ -function readJsonArgument(raw: string, flagName: string): { text: string; from: string } { +function readArgumentSource(raw: string, flagName: string): { text: string; from: string } { if (!raw.startsWith('@')) return { text: raw, from: '' } const path = raw.slice(1) @@ -108,6 +108,42 @@ function readJsonArgument(raw: string, flagName: string): { text: string; from: } } +/** Reads a primitive list from argv or a newline-delimited file. */ +function readListValues(raw: unknown, flagName: string): string[] { + const arguments_ = Array.isArray(raw) ? raw : [raw] + const values = arguments_.flatMap((argument) => { + if (typeof argument !== 'string') { + throw new SimApiError(`--${flagName} values must be strings`, 0) + } + + if (!argument.startsWith('@')) return [argument] + + const source = readArgumentSource(argument, flagName) + const lines = source.text.split(/\r?\n/) + if (lines.at(-1) === '') lines.pop() + if (lines.length === 0) { + throw new SimApiError(`--${flagName}${source.from} contains no values`, 0) + } + + return lines.map((line, index) => { + const value = line.trim() + if (!value) { + throw new SimApiError( + `--${flagName}${source.from} has an empty value on line ${index + 1}`, + 0 + ) + } + return value + }) + }) + + return values.map((value) => { + const trimmed = value.trim() + if (!trimmed) throw new SimApiError(`--${flagName} values cannot be empty`, 0) + return trimmed + }) +} + /** * Points at `@` when a value that failed to parse looks like a filename. * @@ -144,13 +180,13 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: * or failed validation outright. */ if (flag.list) { - const values = Array.isArray(raw) ? raw : [raw] + const values = readListValues(raw, flagName) return field.kind === 'string' ? values.join(',') : values } if (takesJson(field, flag)) { if (typeof raw !== 'string') return raw - const source = readJsonArgument(raw, flagName) + const source = readArgumentSource(raw, flagName) try { return JSON.parse(source.text) } catch (error) { From 2a27f9e383bf0d2571c91d20a2de064ca5595dcb Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 23:09:59 -0700 Subject: [PATCH 067/159] improvement(api): scope folders to resource paths (#6284) * improvement(api): scope folders to resource paths * fix(files): serialize folder resolution with uploads * fix(files): release folder lock before upload setup --- .../content/docs/de/api-reference/meta.json | 1 - .../content/docs/en/api-reference/meta.json | 1 - .../content/docs/es/api-reference/meta.json | 1 - .../content/docs/fr/api-reference/meta.json | 1 - .../content/docs/ja/api-reference/meta.json | 1 - .../content/docs/zh/api-reference/meta.json | 1 - apps/docs/openapi-v2-files-audit.json | 1275 ++++++----- apps/docs/openapi-v2-knowledge.json | 913 ++++++-- apps/docs/openapi-v2-logs.json | 16 +- apps/docs/openapi-v2-resources.json | 2026 ++++++++++------- apps/docs/openapi-v2-tables.json | 803 ++++--- apps/docs/openapi-v2-workflows.json | 638 ++++-- .../app/api/folders/[id]/duplicate/route.ts | 2 +- .../sim/app/api/folders/[id]/restore/route.ts | 2 +- apps/sim/app/api/folders/[id]/route.test.ts | 10 +- apps/sim/app/api/folders/[id]/route.ts | 2 +- .../sim/app/api/folders/reorder/route.test.ts | 22 +- apps/sim/app/api/folders/reorder/route.ts | 237 +- apps/sim/app/api/folders/route.test.ts | 34 +- apps/sim/app/api/folders/route.ts | 2 +- apps/sim/app/api/v1/middleware.ts | 5 +- apps/sim/app/api/v2/audit-logs/[id]/route.ts | 4 +- apps/sim/app/api/v2/audit-logs/format.test.ts | 28 + apps/sim/app/api/v2/audit-logs/format.ts | 57 + apps/sim/app/api/v2/audit-logs/route.ts | 4 +- .../v2/files/[fileId]/content/route.test.ts | 3 +- .../v2/files/[fileId]/metadata/route.test.ts | 3 +- .../v2/files/[fileId]/restore/route.test.ts | 130 -- .../api/v2/files/[fileId]/restore/route.ts | 71 - .../app/api/v2/files/[fileId]/route.test.ts | 3 +- apps/sim/app/api/v2/files/[fileId]/route.ts | 4 +- .../route.test.ts | 25 +- .../{bulk-archive => bulk-delete}/route.ts | 24 +- apps/sim/app/api/v2/files/folders/route.ts | 172 ++ apps/sim/app/api/v2/files/move/route.test.ts | 19 +- apps/sim/app/api/v2/files/move/route.ts | 11 +- apps/sim/app/api/v2/files/route.test.ts | 50 +- apps/sim/app/api/v2/files/route.ts | 20 +- .../app/api/v2/files/uploads/route.test.ts | 128 +- apps/sim/app/api/v2/files/uploads/route.ts | 14 +- apps/sim/app/api/v2/files/utils.ts | 13 +- .../sim/app/api/v2/folders/[id]/route.test.ts | 383 ---- apps/sim/app/api/v2/folders/[id]/route.ts | 209 -- apps/sim/app/api/v2/folders/route.test.ts | 322 --- apps/sim/app/api/v2/folders/route.ts | 124 - apps/sim/app/api/v2/folders/utils.ts | 60 - apps/sim/app/api/v2/knowledge/[id]/route.ts | 57 +- .../sim/app/api/v2/knowledge/folders/route.ts | 187 ++ apps/sim/app/api/v2/knowledge/route.test.ts | 38 +- apps/sim/app/api/v2/knowledge/route.ts | 54 +- apps/sim/app/api/v2/lib/folders.ts | 85 + apps/sim/app/api/v2/logs/[id]/route.ts | 10 +- apps/sim/app/api/v2/logs/route.ts | 32 +- .../v2/tables/[tableId]/restore/route.test.ts | 190 -- .../api/v2/tables/[tableId]/restore/route.ts | 88 - .../app/api/v2/tables/[tableId]/route.test.ts | 38 +- apps/sim/app/api/v2/tables/[tableId]/route.ts | 136 +- apps/sim/app/api/v2/tables/folders/route.ts | 182 ++ .../app/api/v2/tables/imports/route.test.ts | 14 +- apps/sim/app/api/v2/tables/imports/route.ts | 20 +- apps/sim/app/api/v2/tables/route.test.ts | 20 + apps/sim/app/api/v2/tables/route.ts | 55 +- apps/sim/app/api/v2/tables/utils.ts | 4 +- .../app/api/v2/workflows/[id]/export/route.ts | 20 +- .../app/api/v2/workflows/[id]/route.test.ts | 59 +- apps/sim/app/api/v2/workflows/[id]/route.ts | 59 +- .../api/v2/workflows/folders/route.test.ts | 216 ++ .../sim/app/api/v2/workflows/folders/route.ts | 186 ++ apps/sim/app/api/v2/workflows/import/route.ts | 29 +- apps/sim/app/api/v2/workflows/route.test.ts | 78 +- apps/sim/app/api/v2/workflows/route.ts | 68 +- .../workspace/[workspaceId]/tables/tables.tsx | 3 +- apps/sim/hooks/queries/tables.ts | 6 +- apps/sim/hooks/queries/utils/folder-tree.ts | 26 + apps/sim/lib/api/contracts/v2/files.ts | 184 +- apps/sim/lib/api/contracts/v2/folders.ts | 199 -- apps/sim/lib/api/contracts/v2/knowledge.ts | 118 +- apps/sim/lib/api/contracts/v2/logs.ts | 29 +- apps/sim/lib/api/contracts/v2/shared.ts | 77 +- apps/sim/lib/api/contracts/v2/tables.ts | 108 +- apps/sim/lib/api/contracts/v2/workflows.ts | 135 +- .../lib/copilot/tools/handlers/vfs-mutate.ts | 22 +- apps/sim/lib/folders/config.ts | 2 +- apps/sim/lib/folders/locks.ts | 36 + ...ifecycle.test.ts => orchestration.test.ts} | 103 +- .../{lifecycle.ts => orchestration.ts} | 452 +++- apps/sim/lib/folders/paths.test.ts | 124 + apps/sim/lib/folders/paths.ts | 184 ++ apps/sim/lib/folders/queries.ts | 76 +- apps/sim/lib/knowledge/service.ts | 9 +- .../orchestration/restore-resource.ts | 5 +- .../table/orchestration/import-resource.ts | 29 +- apps/sim/lib/table/service.ts | 18 +- .../workspace-file-folder-manager.ts | 282 ++- .../workspace/workspace-file-manager.ts | 59 +- .../workspace-file-storage-accounting.test.ts | 60 +- .../orchestration/folder-lifecycle.ts | 98 - apps/sim/lib/workflows/orchestration/index.ts | 6 - .../workspace-files/orchestration/create.ts | 5 +- .../file-folder-lifecycle.test.ts | 77 + .../orchestration/file-folder-lifecycle.ts | 130 +- .../workspace-files/orchestration/index.ts | 5 + .../src/mocks/folders-lifecycle.mock.ts | 35 - .../src/mocks/folders-orchestration.mock.ts | 35 + packages/testing/src/mocks/index.ts | 6 +- 105 files changed, 7662 insertions(+), 4880 deletions(-) create mode 100644 apps/sim/app/api/v2/audit-logs/format.test.ts create mode 100644 apps/sim/app/api/v2/audit-logs/format.ts delete mode 100644 apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts delete mode 100644 apps/sim/app/api/v2/files/[fileId]/restore/route.ts rename apps/sim/app/api/v2/files/{bulk-archive => bulk-delete}/route.test.ts (77%) rename apps/sim/app/api/v2/files/{bulk-archive => bulk-delete}/route.ts (66%) create mode 100644 apps/sim/app/api/v2/files/folders/route.ts delete mode 100644 apps/sim/app/api/v2/folders/[id]/route.test.ts delete mode 100644 apps/sim/app/api/v2/folders/[id]/route.ts delete mode 100644 apps/sim/app/api/v2/folders/route.test.ts delete mode 100644 apps/sim/app/api/v2/folders/route.ts delete mode 100644 apps/sim/app/api/v2/folders/utils.ts create mode 100644 apps/sim/app/api/v2/knowledge/folders/route.ts create mode 100644 apps/sim/app/api/v2/lib/folders.ts delete mode 100644 apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts delete mode 100644 apps/sim/app/api/v2/tables/[tableId]/restore/route.ts create mode 100644 apps/sim/app/api/v2/tables/folders/route.ts create mode 100644 apps/sim/app/api/v2/workflows/folders/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/folders/route.ts delete mode 100644 apps/sim/lib/api/contracts/v2/folders.ts create mode 100644 apps/sim/lib/folders/locks.ts rename apps/sim/lib/folders/{lifecycle.test.ts => orchestration.test.ts} (88%) rename apps/sim/lib/folders/{lifecycle.ts => orchestration.ts} (57%) create mode 100644 apps/sim/lib/folders/paths.test.ts create mode 100644 apps/sim/lib/folders/paths.ts delete mode 100644 apps/sim/lib/workflows/orchestration/folder-lifecycle.ts delete mode 100644 packages/testing/src/mocks/folders-lifecycle.mock.ts create mode 100644 packages/testing/src/mocks/folders-orchestration.mock.ts diff --git a/apps/docs/content/docs/de/api-reference/meta.json b/apps/docs/content/docs/de/api-reference/meta.json index d2e994fef8e..f4a24c829e1 100644 --- a/apps/docs/content/docs/de/api-reference/meta.json +++ b/apps/docs/content/docs/de/api-reference/meta.json @@ -18,7 +18,6 @@ "(generated)/mcp-servers", "(generated)/skills", "(generated)/custom-tools", - "(generated)/folders", "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", diff --git a/apps/docs/content/docs/en/api-reference/meta.json b/apps/docs/content/docs/en/api-reference/meta.json index d2e994fef8e..f4a24c829e1 100644 --- a/apps/docs/content/docs/en/api-reference/meta.json +++ b/apps/docs/content/docs/en/api-reference/meta.json @@ -18,7 +18,6 @@ "(generated)/mcp-servers", "(generated)/skills", "(generated)/custom-tools", - "(generated)/folders", "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", diff --git a/apps/docs/content/docs/es/api-reference/meta.json b/apps/docs/content/docs/es/api-reference/meta.json index d2e994fef8e..f4a24c829e1 100644 --- a/apps/docs/content/docs/es/api-reference/meta.json +++ b/apps/docs/content/docs/es/api-reference/meta.json @@ -18,7 +18,6 @@ "(generated)/mcp-servers", "(generated)/skills", "(generated)/custom-tools", - "(generated)/folders", "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", diff --git a/apps/docs/content/docs/fr/api-reference/meta.json b/apps/docs/content/docs/fr/api-reference/meta.json index d2e994fef8e..f4a24c829e1 100644 --- a/apps/docs/content/docs/fr/api-reference/meta.json +++ b/apps/docs/content/docs/fr/api-reference/meta.json @@ -18,7 +18,6 @@ "(generated)/mcp-servers", "(generated)/skills", "(generated)/custom-tools", - "(generated)/folders", "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", diff --git a/apps/docs/content/docs/ja/api-reference/meta.json b/apps/docs/content/docs/ja/api-reference/meta.json index d2e994fef8e..f4a24c829e1 100644 --- a/apps/docs/content/docs/ja/api-reference/meta.json +++ b/apps/docs/content/docs/ja/api-reference/meta.json @@ -18,7 +18,6 @@ "(generated)/mcp-servers", "(generated)/skills", "(generated)/custom-tools", - "(generated)/folders", "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", diff --git a/apps/docs/content/docs/zh/api-reference/meta.json b/apps/docs/content/docs/zh/api-reference/meta.json index d2e994fef8e..f4a24c829e1 100644 --- a/apps/docs/content/docs/zh/api-reference/meta.json +++ b/apps/docs/content/docs/zh/api-reference/meta.json @@ -18,7 +18,6 @@ "(generated)/mcp-servers", "(generated)/skills", "(generated)/custom-tools", - "(generated)/folders", "(generated)/credentials", "---Execution and Usage---", "(generated)/execution", diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 282faaf165d..27f2b0f1c8e 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -23,7 +23,7 @@ "tags": [ { "name": "Files", - "description": "Upload, download, list, rename, archive, restore, share, and replace the contents of workspace files (v2). Workspace-scoped via the required workspaceId query parameter or body field." + "description": "Upload, download, list, rename, delete, share, and replace the contents of workspace files (v2). Workspace-scoped via the required workspaceId query parameter or body field." }, { "name": "Audit Logs", @@ -40,7 +40,7 @@ "get": { "operationId": "listFiles", "summary": "List Files", - "description": "List a workspace's files with opaque cursor pagination. Results are ordered by upload time. Pass the `nextCursor` from a previous response to fetch the next page; a `null` `nextCursor` means there are no more results. Use `scope=archived` to page through Recently Deleted — that is how you find the id of a file to restore.", + "description": "List a workspace's files with opaque cursor pagination. Pass the `nextCursor` from a previous response to fetch the next page; a `null` `nextCursor` means there are no more results. Use `folderPath` to return only files directly inside one canonical folder path; omit it to list files from every folder.", "tags": ["Files"], "x-codeSamples": [ { @@ -54,17 +54,6 @@ { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { - "name": "scope", - "in": "query", - "required": false, - "description": "`active` (the default) lists live files; `archived` lists the ones in Recently Deleted, which is how you find an id to restore.", - "schema": { - "type": "string", - "enum": ["active", "archived"], - "default": "active" - } - }, { "name": "limit", "in": "query", @@ -81,18 +70,25 @@ "$ref": "#/components/parameters/Cursor" }, { - "name": "folderId", + "name": "folderPath", "in": "query", "required": false, "description": "Restrict the list to one folder. Omit to list every file in the workspace.", - "schema": { "type": "string", "minLength": 1 } + "schema": { + "type": "string", + "minLength": 1 + } }, { "name": "search", "in": "query", "required": false, "description": "Case-insensitive substring match against the file `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", - "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, { "name": "sortBy", @@ -110,7 +106,11 @@ "in": "query", "required": false, "description": "Sort direction. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.", - "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } } ], "responses": { @@ -131,23 +131,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2FileListResponse" - }, - "example": { - "data": [ - { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "name": "data.csv", - "size": 1024, - "type": "text/csv", - "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", - "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", - "folderPath": "Reports/Q1", - "uploadedBy": "user_abc123", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z" - } - ], - "nextCursor": "eyJ1cGxvYWRlZEF0IjoiMjAyNi0wMS0xNVQxMDozMDowMFoiLCJpZCI6IndmX1YxU3RHWFI4ejVqZEhpNkJteVQ5MSJ9" } } } @@ -208,11 +191,11 @@ "maxLength": 255, "description": "MIME type. When omitted, it is inferred from the file extension." }, - "folderId": { + "folderPath": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Destination folder. Omit to create the file at the workspace root." + "description": "Canonical containing-folder path. `/` is the workspace root." }, "content": { "type": "string", @@ -227,10 +210,6 @@ "description": "Encoding of `content`." } } - }, - "example": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "notes.md" } } } @@ -253,32 +232,34 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2FileResponse" - }, - "example": { - "data": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "name": "notes.md", - "size": 0, - "type": "text/markdown", - "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-notes.md", - "folderId": null, - "folderPath": null, - "uploadedBy": "user_abc123", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z" - } } } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "413": { "$ref": "#/components/responses/PayloadTooLarge" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -290,18 +271,36 @@ "tags": ["Files"], "requestBody": { "required": true, - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } }, "responses": { "201": { "description": "The upload session.", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -316,21 +315,41 @@ "name": "uploadId", "in": "path", "required": true, - "schema": { "type": "string" } + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/UploadTokenHeader" } + { + "$ref": "#/components/parameters/UploadTokenHeader" + } ], "responses": { "200": { "description": "The aborted upload session.", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -345,26 +364,52 @@ "name": "uploadId", "in": "path", "required": true, - "schema": { "type": "string" } + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/UploadTokenHeader" } + { + "$ref": "#/components/parameters/UploadTokenHeader" + } ], "requestBody": { "required": true, - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } }, "responses": { "200": { "description": "Signed URLs for the requested parts.", - "content": { "application/json": { "schema": {} } } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -379,26 +424,52 @@ "name": "uploadId", "in": "path", "required": true, - "schema": { "type": "string" } + "schema": { + "type": "string" + } }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/UploadTokenHeader" } + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/UploadTokenHeader" + } ], "requestBody": { "required": true, - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": {} + } + } }, "responses": { "200": { "description": "The completed upload and registered file.", - "content": { "application/json": { "schema": {} } } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -431,22 +502,19 @@ "Content-Type": { "description": "MIME type of the file. Varies per file; defaults to application/octet-stream when unknown.", "schema": { - "type": "string", - "example": "text/csv" + "type": "string" } }, "Content-Disposition": { "description": "Attachment disposition carrying the (sanitized and RFC 5987 encoded) filename.", "schema": { - "type": "string", - "example": "attachment; filename=\"data.csv\"; filename*=UTF-8''data.csv" + "type": "string" } }, "Content-Length": { "description": "Size of the file in bytes.", "schema": { - "type": "string", - "example": "1024" + "type": "string" } }, "X-RateLimit-Limit": { @@ -491,7 +559,7 @@ "delete": { "operationId": "deleteFile", "summary": "Delete File", - "description": "Archive (soft delete) a file in a workspace. The operation is workspace-scoped and records its own audit entry. Returns the file id and a `deleted` acknowledgement.", + "description": "Delete a file in a workspace. The operation is workspace-scoped and records its own audit entry. Returns the file id and a `deleted` acknowledgement.", "tags": ["Files"], "x-codeSamples": [ { @@ -511,7 +579,7 @@ ], "responses": { "200": { - "description": "The file was archived.", + "description": "The file was deleted.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -527,12 +595,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2DeleteFileResponse" - }, - "example": { - "data": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "deleted": true - } } } } @@ -550,17 +612,11 @@ "$ref": "#/components/responses/NotFound" }, "409": { - "description": "The file could not be archived because of a conflicting state.", + "description": "The file could not be deleted because of a conflicting state.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CONFLICT", - "message": "Failed to delete file" - } } } } @@ -601,21 +657,15 @@ "properties": { "workspaceId": { "type": "string", - "description": "The workspace that owns the file.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + "description": "The workspace that owns the file." }, "name": { "type": "string", "minLength": 1, "maxLength": 255, - "description": "The new filename. Cannot contain `/`, `\\`, or be `.` / `..`.", - "example": "renamed.csv" + "description": "The new filename. Cannot contain `/`, `\\`, or be `.` / `..`." } } - }, - "example": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "renamed.csv" } } } @@ -638,20 +688,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2FileResponse" - }, - "example": { - "data": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "name": "renamed.csv", - "size": 1024, - "type": "text/csv", - "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", - "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", - "folderPath": "Reports/Q1", - "uploadedBy": "user_abc123", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z" - } } } } @@ -720,20 +756,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2FileResponse" - }, - "example": { - "data": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "name": "data.csv", - "size": 1024, - "type": "text/csv", - "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", - "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", - "folderPath": "Reports/Q1", - "uploadedBy": "user_abc123", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z" - } } } } @@ -1026,23 +1048,18 @@ } } }, - "/api/v2/files/{fileId}/restore": { + "/api/v2/files/move": { "post": { - "operationId": "restoreFile", - "summary": "Restore File", - "description": "Restore an archived file. Find archived ids with `GET /api/v2/files?scope=archived`. If the original name has since been taken by a live file, the file is restored under a suffixed name.", + "operationId": "moveFileItems", + "summary": "Move Files and Folders", + "description": "Move files and/or folders into a folder. `targetFolderPath: null` — or omitting it — moves the selection to the workspace root. At least one of `fileIds` or `folderPaths` must be non-empty. The whole selection moves under one lock, so a name collision at the destination fails the request with `409` instead of applying part of it.", "tags": ["Files"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/restore\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\"}'" - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/FileIdPath" + "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/move\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"fileIds\": [\"wf_V1StGXR8z5jdHi6BmyT91\"], \"targetFolderPath\": \"fold_9Kq2mZ7pR4tLxWc0Ye3Nu\"}'" } ], "requestBody": { @@ -1051,24 +1068,33 @@ "application/json": { "schema": { "type": "object", - "required": ["workspaceId"], + "required": ["workspaceId", "fileIds"], "properties": { "workspaceId": { "type": "string", - "description": "The workspace that owns the file.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + "description": "The workspace that owns the items." + }, + "fileIds": { + "type": "array", + "maxItems": 1000, + "default": [], + "items": { + "type": "string" + }, + "description": "Files to move." + }, + "targetFolderPath": { + "type": "string", + "description": "Canonical destination folder path. Omit to use the workspace root." } } - }, - "example": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" } } } }, "responses": { "200": { - "description": "The file was restored.", + "description": "The items were moved.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1083,13 +1109,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2RestoreFileResponse" - }, - "example": { - "data": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "restored": true - } + "$ref": "#/components/schemas/V2MoveFileItemsResponse" } } } @@ -1118,73 +1138,166 @@ } } }, - "/api/v2/files/move": { - "post": { - "operationId": "moveFileItems", - "summary": "Move Files and Folders", - "description": "Move files and/or folders into a folder. `targetFolderId: null` — or omitting it — moves the selection to the workspace root. At least one of `fileIds` or `folderIds` must be non-empty. The whole selection moves under one lock, so a name collision at the destination fails the request with `409` instead of applying part of it.", + "/api/v2/files/{fileId}/share": { + "get": { + "operationId": "getFileShare", + "summary": "Get File Share", + "description": "Read a file's public share state. `share` is `null` when the file has never been shared. The encrypted password is never returned — `hasPassword` is the only password signal.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, delete the file instead.", "tags": ["Files"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/move\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"fileIds\": [\"wf_V1StGXR8z5jdHi6BmyT91\"], \"targetFolderId\": \"fold_9Kq2mZ7pR4tLxWc0Ye3Nu\"}'" + "source": "curl -X GET \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share?workspaceId=YOUR_WORKSPACE_ID\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["workspaceId"], - "properties": { - "workspaceId": { - "type": "string", - "description": "The workspace that owns the items.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" - }, - "fileIds": { - "type": "array", - "maxItems": 1000, - "default": [], - "items": { - "type": "string" - }, - "description": "Files to move." - }, - "folderIds": { - "type": "array", - "maxItems": 1000, - "default": [], - "items": { - "type": "string" - }, - "description": "Folders to move. Descendants follow their folder." + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The file's share state.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2GetFileShareResponse" + }, + "example": { + "data": { + "share": { + "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", + "token": "share-token-example", + "url": "https://www.sim.ai/f/share-token-example", + "isActive": true, + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "authType": "public", + "hasPassword": false, + "allowedEmails": [] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "operationId": "upsertFileShare", + "summary": "Enable or Disable File Share", + "description": "Enable or disable a file's public share. Requires workspace `write`.\n\nThe share token is always server-generated; there is no way to supply one. `authType` selects how the link is gated: `public` (anyone with the link), `password` (requires `password` on first enable), or `email` / `sso` (requires a non-empty `allowedEmails`). Omitting `authType` on a re-enable keeps the stored mode, and the org access-control policy is evaluated against that stored mode rather than against `public`. Disabling is never blocked by the policy.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, delete the file instead.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"isActive\": true, \"authType\": \"public\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "isActive"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the file.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" }, - "targetFolderId": { - "type": ["string", "null"], - "description": "Destination folder. `null` or omitted moves to the workspace root.", - "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + "isActive": { + "type": "boolean", + "description": "Whether the share should resolve. `false` disables without revoking." + }, + "authType": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "How the link is gated. Omit on a re-enable to keep the stored mode." + }, + "password": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Plaintext password for a `password` share. Required on first enable; omit to keep the stored one." + }, + "allowedEmails": { + "type": "array", + "maxItems": 200, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 320 + }, + "description": "Allowed addresses or `@domain` patterns for an `email` / `sso` share. Must be non-empty when enabling one." } } }, "examples": { - "intoFolder": { - "summary": "Move two files into a folder", + "publicLink": { + "summary": "Enable a public link", "value": { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "fileIds": ["wf_V1StGXR8z5jdHi6BmyT91", "wf_2QrTb9xLm4PvZc7Ns1Ka"], - "targetFolderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + "isActive": true, + "authType": "public" } }, - "toRoot": { - "summary": "Move a folder back to the workspace root", + "passwordProtected": { + "summary": "Enable a password-protected link", "value": { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "folderIds": ["fold_9Kq2mZ7pR4tLxWc0Ye3Nu"], - "targetFolderId": null + "isActive": true, + "authType": "password", + "password": "EXAMPLE_PASSWORD" + } + }, + "disable": { + "summary": "Disable (keeps the token and stored config)", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isActive": false } } } @@ -1193,7 +1306,7 @@ }, "responses": { "200": { - "description": "The items were moved.", + "description": "The share after the update.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1208,13 +1321,20 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2MoveFileItemsResponse" + "$ref": "#/components/schemas/V2UpsertFileShareResponse" }, "example": { "data": { - "movedItems": { - "files": 2, - "folders": 0 + "share": { + "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", + "token": "share-token-example", + "url": "https://www.sim.ai/f/share-token-example", + "isActive": true, + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "authType": "public", + "hasPassword": false, + "allowedEmails": [] } } } @@ -1233,9 +1353,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1245,18 +1362,23 @@ } } }, - "/api/v2/files/bulk-archive": { - "post": { - "operationId": "bulkArchiveFileItems", - "summary": "Archive Files and Folders", - "description": "Archive (soft delete) files and/or folders in one call. Archiving a folder cascades to everything under it, so `deletedItems` reports totals larger than the selection. Archived items remain listable via `scope=archived` and can be restored.\n\n**This endpoint is best-effort and idempotent.** Ids that do not exist, belong to another workspace, or are already archived are skipped rather than failing the request — the call still returns `200`. `deletedItems` is what was actually archived, so compare it against your selection if you need to detect that something was skipped. The single-item `DELETE /api/v2/files/{fileId}` does return `404` for a missing id.", + "/api/v2/files/{fileId}/content": { + "put": { + "operationId": "updateFileContent", + "summary": "Replace File Content", + "description": "Replace a file's bytes. This is a full replace, not an append: `content` becomes the entire body of the file. Use `encoding: \"base64\"` for non-UTF-8 bytes. The decoded body is capped at 50MB and still debits the workspace storage quota, so a write that would push the payer past its limit fails with `413`.", "tags": ["Files"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/bulk-archive\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"fileIds\": [\"wf_V1StGXR8z5jdHi6BmyT91\"], \"folderIds\": [\"fold_9Kq2mZ7pR4tLxWc0Ye3Nu\"]}'" + "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/content\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"content\": \"id,name\\\\n1,alpha\\\\n\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" } ], "requestBody": { @@ -1265,44 +1387,30 @@ "application/json": { "schema": { "type": "object", - "required": ["workspaceId"], + "required": ["workspaceId", "content"], "properties": { "workspaceId": { "type": "string", - "description": "The workspace that owns the items.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + "description": "The workspace that owns the file." }, - "fileIds": { - "type": "array", - "maxItems": 1000, - "default": [], - "items": { - "type": "string" - }, - "description": "Files to archive." + "content": { + "type": "string", + "description": "The file's new full contents, interpreted per `encoding`." }, - "folderIds": { - "type": "array", - "maxItems": 1000, - "default": [], - "items": { - "type": "string" - }, - "description": "Folders to archive, together with their contents." + "encoding": { + "type": "string", + "enum": ["utf-8", "base64"], + "default": "utf-8", + "description": "How to decode `content` into bytes." } } - }, - "example": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "fileIds": ["wf_V1StGXR8z5jdHi6BmyT91"], - "folderIds": ["fold_9Kq2mZ7pR4tLxWc0Ye3Nu"] } } } }, "responses": { "200": { - "description": "The items were archived.", + "description": "The updated file.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1317,13 +1425,88 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2BulkArchiveFileItemsResponse" - }, - "example": { - "data": { - "deletedItems": { - "files": 3, - "folders": 1 + "$ref": "#/components/schemas/V2FileResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/bulk-delete": { + "post": { + "operationId": "bulkDeleteFiles", + "summary": "Delete Files", + "description": "Delete up to 1,000 files. Folder deletion is available at `/api/v2/files/folders`.", + "tags": ["Files"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "fileIds"], + "properties": { + "workspaceId": { + "type": "string" + }, + "fileIds": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "items": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Deletion result.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["deletedItems"], + "properties": { + "deletedItems": { + "type": "object", + "required": ["files"], + "properties": { + "files": { + "type": "integer" + } + } + } + } } } } @@ -1354,31 +1537,144 @@ } } }, - "/api/v2/files/{fileId}/share": { + "/api/v2/files/folders": { "get": { - "operationId": "getFileShare", - "summary": "Get File Share", - "description": "Read a file's public share state. `share` is `null` when the file has never been shared. The encrypted password is never returned — `hasPassword` is the only password signal.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, archive the file instead.", + "operationId": "listFilesFolders", + "summary": "List Folders", + "description": "List active folders for this resource. Omit `parentPath` for the full tree, or pass a canonical path (including `/`) for immediate children only.", "tags": ["Files"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X GET \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share?workspaceId=YOUR_WORKSPACE_ID\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\"" - } - ], "parameters": [ { - "$ref": "#/components/parameters/FileIdPath" + "$ref": "#/components/parameters/WorkspaceIdQuery" }, { - "$ref": "#/components/parameters/WorkspaceIdQuery" + "name": "parentPath", + "in": "query", + "required": false, + "description": "Canonical parent path. `/` lists root folders; omit for every folder.", + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Name search.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Sort field.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "name" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } + } + ], + "responses": { + "200": { + "description": "Folders.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilesFolder" + } + }, + "nextCursor": { + "type": ["string", "null"] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createFilesFolder", + "summary": "Create Folder", + "description": "Create exactly one folder leaf. Its parent path must already exist.", + "tags": ["Files"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "path"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Canonical non-root folder path." + } + } + } + } } - ], + }, "responses": { - "200": { - "description": "The file's share state.", + "201": { + "description": "Folder.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1393,20 +1689,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2GetFileShareResponse" - }, - "example": { - "data": { - "share": { - "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", - "token": "share-token-example", - "url": "https://www.sim.ai/f/share-token-example", - "isActive": true, - "resourceType": "file", - "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", - "authType": "public", - "hasPassword": false, - "allowedEmails": [] + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/FilesFolder" + } + } } } } @@ -1425,6 +1718,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1433,87 +1729,29 @@ } } }, - "put": { - "operationId": "upsertFileShare", - "summary": "Enable or Disable File Share", - "description": "Enable or disable a file's public share. Requires workspace `write`.\n\nThe share token is always server-generated; there is no way to supply one. `authType` selects how the link is gated: `public` (anyone with the link), `password` (requires `password` on first enable), or `email` / `sso` (requires a non-empty `allowedEmails`). Omitting `authType` on a re-enable keeps the stored mode, and the org access-control policy is evaluated against that stored mode rather than against `public`. Disabling is never blocked by the policy.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, archive the file instead.", + "patch": { + "operationId": "relocateFilesFolder", + "summary": "Rename or Move Folder", + "description": "Rename, move, or rename and move a folder. Descendant paths change with the folder.", "tags": ["Files"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"isActive\": true, \"authType\": \"public\"}'" - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/FileIdPath" - } - ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["workspaceId", "isActive"], + "required": ["workspaceId", "path", "destinationPath"], "properties": { "workspaceId": { - "type": "string", - "description": "The workspace that owns the file.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" - }, - "isActive": { - "type": "boolean", - "description": "Whether the share should resolve. `false` disables without revoking." + "type": "string" }, - "authType": { + "path": { "type": "string", - "enum": ["public", "password", "email", "sso"], - "description": "How the link is gated. Omit on a re-enable to keep the stored mode." + "description": "Current canonical non-root path." }, - "password": { + "destinationPath": { "type": "string", - "minLength": 1, - "maxLength": 1024, - "description": "Plaintext password for a `password` share. Required on first enable; omit to keep the stored one." - }, - "allowedEmails": { - "type": "array", - "maxItems": 200, - "items": { - "type": "string", - "minLength": 1, - "maxLength": 320 - }, - "description": "Allowed addresses or `@domain` patterns for an `email` / `sso` share. Must be non-empty when enabling one." - } - } - }, - "examples": { - "publicLink": { - "summary": "Enable a public link", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isActive": true, - "authType": "public" - } - }, - "passwordProtected": { - "summary": "Enable a password-protected link", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isActive": true, - "authType": "password", - "password": "EXAMPLE_PASSWORD" - } - }, - "disable": { - "summary": "Disable (keeps the token and stored config)", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isActive": false + "description": "New canonical non-root path." } } } @@ -1522,7 +1760,7 @@ }, "responses": { "200": { - "description": "The share after the update.", + "description": "Folder.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1537,20 +1775,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2UpsertFileShareResponse" - }, - "example": { - "data": { - "share": { - "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", - "token": "share-token-example", - "url": "https://www.sim.ai/f/share-token-example", - "isActive": true, - "resourceType": "file", - "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", - "authType": "public", - "hasPassword": false, - "allowedEmails": [] + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/FilesFolder" + } + } } } } @@ -1569,6 +1804,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1576,75 +1814,38 @@ "$ref": "#/components/responses/InternalError" } } - } - }, - "/api/v2/files/{fileId}/content": { - "put": { - "operationId": "updateFileContent", - "summary": "Replace File Content", - "description": "Replace a file's bytes. This is a full replace, not an append: `content` becomes the entire body of the file. Use `encoding: \"base64\"` for non-UTF-8 bytes. The decoded body is capped at 50MB and still debits the workspace storage quota, so a write that would push the payer past its limit fails with `413`.", + }, + "delete": { + "operationId": "deleteFilesFolder", + "summary": "Delete Folder", + "description": "Delete a folder. With `recursive=false`, the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", "tags": ["Files"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/content\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"content\": \"id,name\\\\n1,alpha\\\\n\"}'" - } - ], "parameters": [ { - "$ref": "#/components/parameters/FileIdPath" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["workspaceId", "content"], - "properties": { - "workspaceId": { - "type": "string", - "description": "The workspace that owns the file.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" - }, - "content": { - "type": "string", - "description": "The file's new full contents, interpreted per `encoding`." - }, - "encoding": { - "type": "string", - "enum": ["utf-8", "base64"], - "default": "utf-8", - "description": "How to decode `content` into bytes." - } - } - }, - "examples": { - "text": { - "summary": "Replace with UTF-8 text", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "content": "id,name\n1,alpha\n" - } - }, - "binary": { - "summary": "Replace with base64-encoded bytes", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "content": "aWQsbmFtZQoxLGFscGhhCg==", - "encoding": "base64" - } - } - } + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "path", + "in": "query", + "required": true, + "description": "Canonical non-root folder path.", + "schema": { + "type": "string" + } + }, + { + "name": "recursive", + "in": "query", + "required": true, + "description": "Whether to delete the subtree.", + "schema": { + "type": "boolean" } } - }, + ], "responses": { "200": { - "description": "The updated file.", + "description": "Deletion result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1659,20 +1860,34 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileResponse" - }, - "example": { - "data": { - "id": "wf_V1StGXR8z5jdHi6BmyT91", - "name": "data.csv", - "size": 16, - "type": "text/csv", - "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", - "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", - "folderPath": "Reports/Q1", - "uploadedBy": "user_abc123", - "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T11:05:00Z" + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["path", "deleted", "deletedItems"], + "properties": { + "path": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "const": true + }, + "deletedItems": { + "type": "object", + "required": ["folders", "files"], + "properties": { + "folders": { + "type": "integer" + }, + "files": { + "type": "integer" + } + } + } + } + } } } } @@ -1690,8 +1905,8 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" + "409": { + "$ref": "#/components/responses/Conflict" }, "429": { "$ref": "#/components/responses/RateLimited" @@ -1728,7 +1943,10 @@ "in": "header", "required": true, "description": "The signed control token returned when the upload session was created.", - "schema": { "type": "string", "minLength": 1 } + "schema": { + "type": "string", + "minLength": 1 + } }, "FileIdPath": { "name": "fileId", @@ -1784,7 +2002,7 @@ "size", "type", "key", - "folderId", + "folderPath", "folderPath", "uploadedBy", "uploadedAt", @@ -1828,15 +2046,10 @@ "description": "ISO 8601 timestamp of when the file was uploaded.", "example": "2026-01-15T10:30:00Z" }, - "folderId": { - "type": ["string", "null"], - "description": "The containing file folder, or null when the file sits at the workspace root.", - "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" - }, "folderPath": { - "type": ["string", "null"], - "description": "Slash-joined folder names for `folderId`, or null at the workspace root.", - "example": "Reports/Q1" + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root.", + "example": "/Engineering" }, "updatedAt": { "type": "string", @@ -1848,18 +2061,18 @@ }, "V2DeleteFileResult": { "type": "object", - "description": "Acknowledgement returned by a successful archive (soft delete).", + "description": "Acknowledgement returned by a successful delete.", "required": ["id", "deleted"], "properties": { "id": { "type": "string", - "description": "The unique identifier of the archived file.", + "description": "The unique identifier of the deleted file.", "example": "wf_V1StGXR8z5jdHi6BmyT91" }, "deleted": { "type": "boolean", "const": true, - "description": "Always true on a successful archive." + "description": "Always true on a successful delete." } } }, @@ -2035,23 +2248,6 @@ } } }, - "V2FileItemCounts": { - "type": "object", - "description": "Counts of what an operation actually touched. A folder cascades to its descendants, so these exceed the size of the selection.", - "required": ["files", "folders"], - "properties": { - "files": { - "type": "integer", - "description": "Number of files affected.", - "example": 3 - }, - "folders": { - "type": "integer", - "description": "Number of folders affected.", - "example": 1 - } - } - }, "V2FileShare": { "type": "object", "description": "A file's public share. Never carries the storage key or the encrypted password — `hasPassword` is the only password signal exposed.", @@ -2115,40 +2311,19 @@ } } }, - "V2RestoreFileResult": { - "type": "object", - "description": "Acknowledgement returned by a successful file restore.", - "required": ["id", "restored"], - "properties": { - "id": { - "type": "string", - "description": "The unique identifier of the restored file.", - "example": "wf_V1StGXR8z5jdHi6BmyT91" - }, - "restored": { - "type": "boolean", - "const": true, - "description": "Always true on a successful restore." - } - } - }, "V2MoveFileItemsResult": { "type": "object", "description": "What the move actually relocated.", "required": ["movedItems"], "properties": { "movedItems": { - "$ref": "#/components/schemas/V2FileItemCounts" - } - } - }, - "V2BulkArchiveFileItemsResult": { - "type": "object", - "description": "What the archive actually soft-deleted, including the cascade.", - "required": ["deletedItems"], - "properties": { - "deletedItems": { - "$ref": "#/components/schemas/V2FileItemCounts" + "type": "object", + "required": ["files"], + "properties": { + "files": { + "type": "integer" + } + } } } }, @@ -2180,16 +2355,6 @@ } } }, - "V2RestoreFileResponse": { - "type": "object", - "description": "The result of restoring a file.", - "required": ["data"], - "properties": { - "data": { - "$ref": "#/components/schemas/V2RestoreFileResult" - } - } - }, "V2MoveFileItemsResponse": { "type": "object", "description": "The result of a move.", @@ -2200,16 +2365,6 @@ } } }, - "V2BulkArchiveFileItemsResponse": { - "type": "object", - "description": "The result of a bulk archive.", - "required": ["data"], - "properties": { - "data": { - "$ref": "#/components/schemas/V2BulkArchiveFileItemsResult" - } - } - }, "V2GetFileShareResponse": { "type": "object", "description": "The file's public share state.", @@ -2229,6 +2384,32 @@ "$ref": "#/components/schemas/V2UpsertFileShareResult" } } + }, + "FilesFolder": { + "type": "object", + "required": ["name", "path", "parentPath", "createdAt", "updatedAt"], + "properties": { + "name": { + "type": "string", + "description": "Folder name." + }, + "path": { + "type": "string", + "description": "Canonical folder path. This is the public folder identifier." + }, + "parentPath": { + "type": "string", + "description": "Canonical parent path; `/` is the root." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } } }, "responses": { diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 4a5924ad949..e3d094a24a3 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -1,8 +1,8 @@ { "openapi": "3.1.0", "info": { - "title": "Sim API v2 \u2014 Knowledge Bases", - "description": "The v2 Knowledge Bases API lets you create and manage knowledge bases, upload and inspect documents, and run vector and tag search over your indexed content.\n\n## Conventions\n\nAll endpoints live under the `/api/v2` base path and share a single set of conventions:\n\n- **Authentication** \u2014 Send your Sim API key in the `X-API-Key` header on every request. Keys are scoped to a workspace (or are personal keys that target a workspace); `workspaceId` is always required so the request can be tenant-scoped and rate-limited.\n- **Single-resource and mutation responses** return `{ \"data\": ... }`.\n- **List responses** use an opaque-cursor envelope: `{ \"data\": [ ... ], \"nextCursor\": string | null }`. Pass the returned `nextCursor` back as the `cursor` query parameter to fetch the next page. When `nextCursor` is `null` there are no more results. Cursors are opaque \u2014 do not parse or construct them.\n- **Errors** use a single envelope: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`. The HTTP status code and the stable `code` field move together (for example `404` \u21c4 `NOT_FOUND`).\n- **Rate limiting** \u2014 Every response carries the current limiter state in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. A throttled request returns `429` with a `Retry-After` header.", + "title": "Sim API v2 — Knowledge Bases", + "description": "The v2 Knowledge Bases API lets you create and manage knowledge bases, upload and inspect documents, and run vector and tag search over your indexed content.\n\n## Conventions\n\nAll endpoints live under the `/api/v2` base path and share a single set of conventions:\n\n- **Authentication** — Send your Sim API key in the `X-API-Key` header on every request. Keys are scoped to a workspace (or are personal keys that target a workspace); `workspaceId` is always required so the request can be tenant-scoped and rate-limited.\n- **Single-resource and mutation responses** return `{ \"data\": ... }`.\n- **List responses** use an opaque-cursor envelope: `{ \"data\": [ ... ], \"nextCursor\": string | null }`. Pass the returned `nextCursor` back as the `cursor` query parameter to fetch the next page. When `nextCursor` is `null` there are no more results. Cursors are opaque — do not parse or construct them.\n- **Errors** use a single envelope: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`. The HTTP status code and the stable `code` field move together (for example `404` ⇄ `NOT_FOUND`).\n- **Rate limiting** — Every response carries the current limiter state in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. A throttled request returns `429` with a `Retry-After` header.", "version": "2.0.0", "contact": { "name": "Sim Support", @@ -50,18 +50,25 @@ "$ref": "#/components/parameters/WorkspaceIdQuery" }, { - "name": "folderId", + "name": "folderPath", "in": "query", "required": false, "description": "Restrict the list to one folder. Omit to list every knowledge base in the workspace.", - "schema": { "type": "string", "minLength": 1 } + "schema": { + "type": "string", + "minLength": 1 + } }, { "name": "search", "in": "query", "required": false, "description": "Case-insensitive substring match against the knowledge base `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", - "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, { "name": "sortBy", @@ -79,7 +86,11 @@ "in": "query", "required": false, "description": "Sort direction.", - "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } } ], "responses": { @@ -663,7 +674,7 @@ "post": { "operationId": "uploadKnowledgeDocument", "summary": "Upload Document", - "description": "Upload a single document to a knowledge base as `multipart/form-data`. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the file body is buffered. The maximum file size is 100 MB. Processing is asynchronous: the document is returned with `processingStatus: \"pending\"` and indexing continues in the background \u2014 poll the Get Document endpoint to observe progress.", + "description": "Upload a single document to a knowledge base as `multipart/form-data`. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the file body is buffered. The maximum file size is 100 MB. Processing is asynchronous: the document is returned with `processingStatus: \"pending\"` and indexing continues in the background — poll the Get Document endpoint to observe progress.", "tags": ["Knowledge Bases"], "x-codeSamples": [ { @@ -821,30 +832,58 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "402": { "$ref": "#/components/responses/UsageLimitExceeded" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "413": { "$ref": "#/components/responses/PayloadTooLarge" }, - "415": { "$ref": "#/components/responses/UnsupportedMediaType" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, "/api/v2/knowledge/{id}/documents/uploads/{uploadId}": { "parameters": [ - { "$ref": "#/components/parameters/KnowledgeBaseId" }, - { "$ref": "#/components/parameters/UploadId" }, - { "$ref": "#/components/parameters/UploadTokenHeader" } + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/UploadId" + }, + { + "$ref": "#/components/parameters/UploadTokenHeader" + } ], "delete": { "operationId": "abortKnowledgeDocumentUpload", "summary": "Abort Document Upload", "description": "Abort an incomplete knowledge-document upload and discard its provider parts. Aborting an already aborted session is safe.", "tags": ["Knowledge Bases"], - "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], "responses": { "200": { "description": "The aborted upload session.", @@ -856,131 +895,536 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, "/api/v2/knowledge/{id}/documents/uploads/{uploadId}/parts": { "parameters": [ - { "$ref": "#/components/parameters/KnowledgeBaseId" }, - { "$ref": "#/components/parameters/UploadId" }, - { "$ref": "#/components/parameters/UploadTokenHeader" } + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/UploadId" + }, + { + "$ref": "#/components/parameters/UploadTokenHeader" + } ], "post": { "operationId": "createKnowledgeDocumentUploadPartUrls", "summary": "Create Document Upload Part URLs", "description": "Issue short-lived signed PUT URLs for up to 100 part numbers. PUT each byte range directly to the returned URL with the returned headers.", "tags": ["Knowledge Bases"], - "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePartUrlsBody" + } + } + } + }, + "responses": { + "200": { + "description": "Signed URLs for the requested parts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartUrlsEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}/documents/uploads/{uploadId}/complete": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/UploadId" + }, + { + "$ref": "#/components/parameters/UploadTokenHeader" + } + ], + "post": { + "operationId": "completeKnowledgeDocumentUpload", + "summary": "Complete Document Upload", + "description": "Verify the single PUT or assemble all multipart parts, record knowledge-base storage ownership, create the knowledge document, and queue asynchronous processing. Repeating the same completion is idempotent and returns the same document. It never registers a general workspace file.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompleteUploadBody" + } + } + } + }, + "responses": { + "200": { + "description": "The completed upload and queued knowledge document.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentUploadEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}/documents/{documentId}": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/DocumentId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "get": { + "operationId": "getKnowledgeDocument", + "summary": "Get Document", + "description": "Retrieve the full detail for a single document, including processing state and connector provenance.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "The document detail.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace that owns the knowledge base." + } + ] + }, + "delete": { + "operationId": "deleteKnowledgeDocument", + "summary": "Delete Document", + "description": "Delete a single document from a knowledge base. Returns a delete acknowledgement with the id of the removed document.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "The document was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace that owns the knowledge base." + } + ] + } + }, + "/api/v2/knowledge/folders": { + "get": { + "operationId": "listKnowledgeFolders", + "summary": "List Folders", + "description": "List active folders for this resource. Omit `parentPath` for the full tree, or pass a canonical path (including `/`) for immediate children only.", + "tags": ["Knowledge Bases"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "parentPath", + "in": "query", + "required": false, + "description": "Canonical parent path. `/` lists root folders; omit for every folder.", + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Name search.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Sort field.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "name" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } + } + ], + "responses": { + "200": { + "description": "Folders.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KnowledgeFolder" + } + }, + "nextCursor": { + "type": ["string", "null"] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createKnowledgeFolder", + "summary": "Create Folder", + "description": "Create exactly one folder leaf. Its parent path must already exist.", + "tags": ["Knowledge Bases"], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreatePartUrlsBody" + "type": "object", + "required": ["workspaceId", "path"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Canonical non-root folder path." + } + } } } } }, "responses": { - "200": { - "description": "Signed URLs for the requested parts.", + "201": { + "description": "Folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PartUrlsEnvelope" + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/KnowledgeFolder" + } + } + } + } } } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } - } - }, - "/api/v2/knowledge/{id}/documents/uploads/{uploadId}/complete": { - "parameters": [ - { "$ref": "#/components/parameters/KnowledgeBaseId" }, - { "$ref": "#/components/parameters/UploadId" }, - { "$ref": "#/components/parameters/UploadTokenHeader" } - ], - "post": { - "operationId": "completeKnowledgeDocumentUpload", - "summary": "Complete Document Upload", - "description": "Verify the single PUT or assemble all multipart parts, record knowledge-base storage ownership, create the knowledge document, and queue asynchronous processing. Repeating the same completion is idempotent and returns the same document. It never registers a general workspace file.", + }, + "patch": { + "operationId": "relocateKnowledgeFolder", + "summary": "Rename or Move Folder", + "description": "Rename, move, or rename and move a folder. Descendant paths change with the folder.", "tags": ["Knowledge Bases"], - "parameters": [{ "$ref": "#/components/parameters/WorkspaceIdQuery" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CompleteUploadBody" - } - } - } - }, - "responses": { - "200": { - "description": "The completed upload and queued knowledge document.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DocumentUploadEnvelope" + "type": "object", + "required": ["workspaceId", "path", "destinationPath"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Current canonical non-root path." + }, + "destinationPath": { + "type": "string", + "description": "New canonical non-root path." + } } } } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "402": { "$ref": "#/components/responses/UsageLimitExceeded" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "413": { "$ref": "#/components/responses/PayloadTooLarge" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } - } - } - }, - "/api/v2/knowledge/{id}/documents/{documentId}": { - "parameters": [ - { - "$ref": "#/components/parameters/KnowledgeBaseId" - }, - { - "$ref": "#/components/parameters/DocumentId" - }, - { - "$ref": "#/components/parameters/WorkspaceIdQuery" - } - ], - "get": { - "operationId": "getKnowledgeDocument", - "summary": "Get Document", - "description": "Retrieve the full detail for a single document, including processing state and connector provenance.", - "tags": ["Knowledge Bases"], - "x-codeSamples": [ - { - "label": "cURL", - "lang": "bash", - "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } - ], + }, "responses": { "200": { - "description": "The document detail.", + "description": "Folder.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -995,7 +1439,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DocumentEnvelope" + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/KnowledgeFolder" + } + } + } + } } } } @@ -1006,44 +1462,54 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "429": { "$ref": "#/components/responses/RateLimited" }, "500": { "$ref": "#/components/responses/InternalError" } - }, + } + }, + "delete": { + "operationId": "deleteKnowledgeFolder", + "summary": "Delete Folder", + "description": "Delete a folder. With `recursive=false`, the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", + "tags": ["Knowledge Bases"], "parameters": [ { - "name": "workspaceId", + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "path", "in": "query", "required": true, + "description": "Canonical non-root folder path.", "schema": { - "type": "string", - "minLength": 1 - }, - "description": "Workspace that owns the knowledge base." - } - ] - }, - "delete": { - "operationId": "deleteKnowledgeDocument", - "summary": "Delete Document", - "description": "Delete a single document from a knowledge base. Returns a delete acknowledgement with the id of the removed document.", - "tags": ["Knowledge Bases"], - "x-codeSamples": [ + "type": "string" + } + }, { - "label": "cURL", - "lang": "bash", - "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + "name": "recursive", + "in": "query", + "required": true, + "description": "Whether to delete the subtree.", + "schema": { + "type": "boolean" + } } ], "responses": { "200": { - "description": "The document was deleted.", + "description": "Deletion result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -1058,7 +1524,35 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteEnvelope" + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["path", "deleted", "deletedItems"], + "properties": { + "path": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "const": true + }, + "deletedItems": { + "type": "object", + "required": ["folders", "knowledgeBases"], + "properties": { + "folders": { + "type": "integer" + }, + "knowledgeBases": { + "type": "integer" + } + } + } + } + } + } } } } @@ -1075,25 +1569,16 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "429": { "$ref": "#/components/responses/RateLimited" }, "500": { "$ref": "#/components/responses/InternalError" } - }, - "parameters": [ - { - "name": "workspaceId", - "in": "query", - "required": true, - "schema": { - "type": "string", - "minLength": 1 - }, - "description": "Workspace that owns the knowledge base." - } - ] + } } } }, @@ -1289,7 +1774,8 @@ "embeddingDimension", "chunkingConfig", "createdAt", - "updatedAt" + "updatedAt", + "folderPath" ], "properties": { "id": { @@ -1349,6 +1835,10 @@ "format": "date-time", "description": "ISO 8601 timestamp when the knowledge base was last modified.", "example": "2025-06-18T16:45:00Z" + }, + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." } } }, @@ -1393,6 +1883,10 @@ }, "chunkingConfig": { "$ref": "#/components/schemas/ChunkingConfigInput" + }, + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." } } }, @@ -1422,6 +1916,10 @@ }, "chunkingConfig": { "$ref": "#/components/schemas/ChunkingConfigInput" + }, + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." } } }, @@ -1453,19 +1951,46 @@ "maximum": 104857600, "description": "Exact file size in bytes." }, - "tag1": { "type": "string", "maxLength": 1000 }, - "tag2": { "type": "string", "maxLength": 1000 }, - "tag3": { "type": "string", "maxLength": 1000 }, - "tag4": { "type": "string", "maxLength": 1000 }, - "tag5": { "type": "string", "maxLength": 1000 }, - "tag6": { "type": "string", "maxLength": 1000 }, - "tag7": { "type": "string", "maxLength": 1000 }, + "tag1": { + "type": "string", + "maxLength": 1000 + }, + "tag2": { + "type": "string", + "maxLength": 1000 + }, + "tag3": { + "type": "string", + "maxLength": 1000 + }, + "tag4": { + "type": "string", + "maxLength": 1000 + }, + "tag5": { + "type": "string", + "maxLength": 1000 + }, + "tag6": { + "type": "string", + "maxLength": 1000 + }, + "tag7": { + "type": "string", + "maxLength": 1000 + }, "processingOptions": { "type": "object", "additionalProperties": false, "properties": { - "recipe": { "type": "string", "maxLength": 255 }, - "lang": { "type": "string", "maxLength": 35 } + "recipe": { + "type": "string", + "maxLength": 255 + }, + "lang": { + "type": "string", + "maxLength": 35 + } }, "description": "Optional processing recipe and language, bound into the signed upload state." } @@ -1515,7 +2040,14 @@ "type": ["string", "null"] }, "document": { - "oneOf": [{ "$ref": "#/components/schemas/DocumentSummary" }, { "type": "null" }], + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentSummary" + }, + { + "type": "null" + } + ], "description": "The queued document after completion; null while uploading or after abort." } } @@ -1534,11 +2066,19 @@ "additionalProperties": false, "required": ["method", "url", "headers"], "properties": { - "method": { "type": "string", "const": "put" }, - "url": { "type": "string", "format": "uri" }, + "method": { + "type": "string", + "const": "put" + }, + "url": { + "type": "string", + "format": "uri" + }, "headers": { "type": "object", - "additionalProperties": { "type": "string" } + "additionalProperties": { + "type": "string" + } } } }, @@ -1547,17 +2087,33 @@ "additionalProperties": false, "required": ["method", "partSize", "partCount"], "properties": { - "method": { "type": "string", "const": "multipart" }, - "partSize": { "type": "integer", "minimum": 1 }, - "partCount": { "type": "integer", "minimum": 1, "maximum": 640 } + "method": { + "type": "string", + "const": "multipart" + }, + "partSize": { + "type": "integer", + "minimum": 1 + }, + "partCount": { + "type": "integer", + "minimum": 1, + "maximum": 640 + } } }, "UploadTransfer": { "oneOf": [ - { "$ref": "#/components/schemas/PutUploadTransfer" }, - { "$ref": "#/components/schemas/MultipartUploadTransfer" } + { + "$ref": "#/components/schemas/PutUploadTransfer" + }, + { + "$ref": "#/components/schemas/MultipartUploadTransfer" + } ], - "discriminator": { "propertyName": "method" } + "discriminator": { + "propertyName": "method" + } }, "CreateDocumentUploadEnvelope": { "type": "object", @@ -1568,9 +2124,16 @@ "additionalProperties": false, "required": ["session", "uploadToken", "transfer"], "properties": { - "session": { "$ref": "#/components/schemas/DocumentUpload" }, - "uploadToken": { "type": "string", "minLength": 1 }, - "transfer": { "$ref": "#/components/schemas/UploadTransfer" } + "session": { + "$ref": "#/components/schemas/DocumentUpload" + }, + "uploadToken": { + "type": "string", + "minLength": 1 + }, + "transfer": { + "$ref": "#/components/schemas/UploadTransfer" + } } } } @@ -2046,7 +2609,7 @@ }, "similarity": { "type": "number", - "description": "Similarity score in the range 0\u20131 for vector search (higher is more similar). 1 for tag-only matches.", + "description": "Similarity score in the range 0–1 for vector search (higher is more similar). 1 for tag-only matches.", "example": 0.8423 } } @@ -2153,6 +2716,32 @@ } } } + }, + "KnowledgeFolder": { + "type": "object", + "required": ["name", "path", "parentPath", "createdAt", "updatedAt"], + "properties": { + "name": { + "type": "string", + "description": "Folder name." + }, + "path": { + "type": "string", + "description": "Canonical folder path. This is the public folder identifier." + }, + "parentPath": { + "type": "string", + "description": "Canonical parent path; `/` is the root." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } } }, "responses": { diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 4631df64376..0fd9b86657c 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -60,9 +60,9 @@ "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36,8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" }, { - "name": "folderIds", + "name": "folderPaths", "in": "query", - "description": "Comma-separated list of folder IDs. Returns logs for all workflows within these folders.", + "description": "Comma-separated list of folder paths. Returns logs for all workflows within these folders.", "schema": { "type": "string" } @@ -353,7 +353,7 @@ "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "name": "Customer Support Agent", "description": "Routes incoming support tickets and drafts responses", - "folderId": null, + "folderPath": "/", "userId": "usr_1a2b3c4d5e", "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", "createdAt": "2025-01-10T09:00:00.000Z", @@ -576,7 +576,7 @@ "id", "name", "description", - "folderId", + "folderPath", "userId", "workspaceId", "createdAt", @@ -599,10 +599,10 @@ "description": "Workflow description, or null if none was set.", "example": "Routes incoming support tickets and drafts responses" }, - "folderId": { - "type": ["string", "null"], - "description": "The folder the workflow belongs to. null if at the workspace root or the workflow is gone.", - "example": null + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root.", + "example": "/Engineering" }, "userId": { "type": ["string", "null"], diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 3e4466ecbcc..be371e32bcc 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -33,10 +33,6 @@ "name": "Custom Tools", "description": "Create and manage the workspace's own code-backed tools that agents can call (v2 API)." }, - { - "name": "Folders", - "description": "Organize workflows, knowledge bases, and tables into folder trees (v2 API)." - }, { "name": "Credentials", "description": "Provision the secrets and connected accounts a workspace's agents authenticate with (v2 API)." @@ -62,13 +58,19 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, { "name": "search", "in": "query", "required": false, "description": "Case-insensitive substring match against the MCP server `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", - "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, { "name": "sortBy", @@ -86,16 +88,26 @@ "in": "query", "required": false, "description": "Sort direction.", - "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } } ], "responses": { "200": { "description": "MCP servers registered in the workspace.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { @@ -106,9 +118,13 @@ "data": { "type": "array", "description": "The MCP servers registered in the workspace.", - "items": { "$ref": "#/components/schemas/McpServer" } + "items": { + "$ref": "#/components/schemas/McpServer" + } }, - "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } } }, "example": { @@ -140,11 +156,21 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "post": { @@ -164,7 +190,9 @@ "description": "The MCP server to register.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateMcpServerBody" }, + "schema": { + "$ref": "#/components/schemas/CreateMcpServerBody" + }, "examples": { "headerAuth": { "summary": "Header-authenticated server", @@ -174,7 +202,9 @@ "description": "Internal documentation tools", "url": "https://mcp.example.com/sse", "authType": "headers", - "headers": { "Authorization": "Bearer YOUR_TOKEN" }, + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + }, "timeout": 30000, "retries": 3 } @@ -198,13 +228,21 @@ "201": { "description": "The MCP server was registered.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/McpServerData" }, + "schema": { + "$ref": "#/components/schemas/McpServerData" + }, "example": { "data": { "mcpServer": { @@ -231,12 +269,24 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -254,20 +304,32 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/McpServerId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/McpServerId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The MCP server.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/McpServerData" }, + "schema": { + "$ref": "#/components/schemas/McpServerData" + }, "example": { "data": { "mcpServer": { @@ -291,12 +353,24 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "patch": { @@ -311,13 +385,19 @@ "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/mcp-servers/mcp-3f7a9c21\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"enabled\": false\n }'" } ], - "parameters": [{ "$ref": "#/components/parameters/McpServerId" }], + "parameters": [ + { + "$ref": "#/components/parameters/McpServerId" + } + ], "requestBody": { "required": true, "description": "The fields to change. `workspaceId` is required so the request is tenant-scoped.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UpdateMcpServerBody" }, + "schema": { + "$ref": "#/components/schemas/UpdateMcpServerBody" + }, "examples": { "disable": { "summary": "Disable a server", @@ -330,7 +410,9 @@ "summary": "Rotate the auth header", "value": { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "headers": { "Authorization": "Bearer NEW_TOKEN" } + "headers": { + "Authorization": "Bearer NEW_TOKEN" + } } } } @@ -341,13 +423,21 @@ "200": { "description": "The updated MCP server.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/McpServerData" }, + "schema": { + "$ref": "#/components/schemas/McpServerData" + }, "example": { "data": { "mcpServer": { @@ -371,12 +461,24 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "delete": { @@ -392,30 +494,59 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/McpServerId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/McpServerId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The MCP server was deleted.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, - "example": { "data": { "id": "mcp-3f7a9c21", "deleted": true } } + "schema": { + "$ref": "#/components/schemas/DeleteAcknowledgement" + }, + "example": { + "data": { + "id": "mcp-3f7a9c21", + "deleted": true + } + } } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -433,13 +564,19 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, { "name": "search", "in": "query", "required": false, "description": "Case-insensitive substring match against the skill `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", - "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, { "name": "sortBy", @@ -457,16 +594,26 @@ "in": "query", "required": false, "description": "Sort direction.", - "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } } ], "responses": { "200": { "description": "Skills available in the workspace.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { @@ -477,9 +624,13 @@ "data": { "type": "array", "description": "The skills available in the workspace, without their bodies.", - "items": { "$ref": "#/components/schemas/SkillSummary" } + "items": { + "$ref": "#/components/schemas/SkillSummary" + } }, - "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } } }, "example": { @@ -506,11 +657,21 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "post": { @@ -530,7 +691,9 @@ "description": "The skill to create.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateSkillBody" }, + "schema": { + "$ref": "#/components/schemas/CreateSkillBody" + }, "examples": { "refundPolicy": { "summary": "A support playbook", @@ -549,13 +712,21 @@ "201": { "description": "The skill was created.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/SkillData" }, + "schema": { + "$ref": "#/components/schemas/SkillData" + }, "example": { "data": { "skill": { @@ -572,12 +743,24 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -595,20 +778,32 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/SkillId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/SkillId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The skill.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/SkillData" }, + "schema": { + "$ref": "#/components/schemas/SkillData" + }, "example": { "data": { "skill": { @@ -625,12 +820,24 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "patch": { @@ -645,13 +852,19 @@ "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/skills/V1StGXR8Z5jdHi6BmyT\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"description\": \"Updated refund guidance\"\n }'" } ], - "parameters": [{ "$ref": "#/components/parameters/SkillId" }], + "parameters": [ + { + "$ref": "#/components/parameters/SkillId" + } + ], "requestBody": { "required": true, "description": "The fields to change. At least one of `name`, `description`, or `content` is required.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UpdateSkillBody" }, + "schema": { + "$ref": "#/components/schemas/UpdateSkillBody" + }, "examples": { "editDescription": { "summary": "Change the description only", @@ -675,13 +888,21 @@ "200": { "description": "The updated skill.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/SkillData" }, + "schema": { + "$ref": "#/components/schemas/SkillData" + }, "example": { "data": { "skill": { @@ -698,13 +919,27 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "delete": { @@ -720,30 +955,59 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/SkillId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/SkillId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The skill was deleted.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, - "example": { "data": { "id": "V1StGXR8Z5jdHi6BmyT", "deleted": true } } + "schema": { + "$ref": "#/components/schemas/DeleteAcknowledgement" + }, + "example": { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true + } + } } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -761,13 +1025,19 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, { "name": "search", "in": "query", "required": false, "description": "Case-insensitive substring match against the custom tool `title`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", - "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, { "name": "sortBy", @@ -785,16 +1055,26 @@ "in": "query", "required": false, "description": "Sort direction.", - "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } } ], "responses": { "200": { "description": "Custom tools defined in the workspace.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { @@ -805,9 +1085,13 @@ "data": { "type": "array", "description": "The custom tools defined in the workspace.", - "items": { "$ref": "#/components/schemas/CustomTool" } + "items": { + "$ref": "#/components/schemas/CustomTool" + } }, - "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } } }, "example": { @@ -822,7 +1106,11 @@ "description": "Look up an order by id", "parameters": { "type": "object", - "properties": { "orderId": { "type": "string" } }, + "properties": { + "orderId": { + "type": "string" + } + }, "required": ["orderId"] } } @@ -837,11 +1125,21 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "post": { @@ -861,7 +1159,9 @@ "description": "The custom tool to create.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateCustomToolBody" }, + "schema": { + "$ref": "#/components/schemas/CreateCustomToolBody" + }, "examples": { "lookupOrder": { "summary": "A tool that calls an internal API", @@ -875,7 +1175,11 @@ "description": "Look up an order by id", "parameters": { "type": "object", - "properties": { "orderId": { "type": "string" } }, + "properties": { + "orderId": { + "type": "string" + } + }, "required": ["orderId"] } } @@ -891,13 +1195,21 @@ "201": { "description": "The custom tool was created.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CustomToolData" }, + "schema": { + "$ref": "#/components/schemas/CustomToolData" + }, "example": { "data": { "customTool": { @@ -909,7 +1221,11 @@ "name": "lookup_order", "parameters": { "type": "object", - "properties": { "orderId": { "type": "string" } }, + "properties": { + "orderId": { + "type": "string" + } + }, "required": ["orderId"] } } @@ -923,12 +1239,24 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, @@ -946,20 +1274,32 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/CustomToolId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/CustomToolId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The custom tool.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CustomToolData" }, + "schema": { + "$ref": "#/components/schemas/CustomToolData" + }, "example": { "data": { "customTool": { @@ -971,7 +1311,11 @@ "name": "lookup_order", "parameters": { "type": "object", - "properties": { "orderId": { "type": "string" } }, + "properties": { + "orderId": { + "type": "string" + } + }, "required": ["orderId"] } } @@ -985,12 +1329,24 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "patch": { @@ -1005,13 +1361,19 @@ "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/custom-tools/V1StGXR8Z5jdHi6BmyT\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"code\": \"return { ok: false }\"\n }'" } ], - "parameters": [{ "$ref": "#/components/parameters/CustomToolId" }], + "parameters": [ + { + "$ref": "#/components/parameters/CustomToolId" + } + ], "requestBody": { "required": true, "description": "The fields to change. At least one of `title`, `schema`, or `code` is required.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UpdateCustomToolBody" }, + "schema": { + "$ref": "#/components/schemas/UpdateCustomToolBody" + }, "examples": { "editCode": { "summary": "Replace the implementation only", @@ -1035,13 +1397,21 @@ "200": { "description": "The updated custom tool.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CustomToolData" }, + "schema": { + "$ref": "#/components/schemas/CustomToolData" + }, "example": { "data": { "customTool": { @@ -1053,7 +1423,11 @@ "name": "lookup_order", "parameters": { "type": "object", - "properties": { "orderId": { "type": "string" } }, + "properties": { + "orderId": { + "type": "string" + } + }, "required": ["orderId"] } } @@ -1067,13 +1441,27 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "delete": { @@ -1089,416 +1477,81 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/CustomToolId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/CustomToolId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The custom tool was deleted.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, - "example": { "data": { "id": "V1StGXR8Z5jdHi6BmyT", "deleted": true } } + "schema": { + "$ref": "#/components/schemas/DeleteAcknowledgement" + }, + "example": { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true + } + } } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } }, - "/api/v2/folders": { + "/api/v2/credentials": { "get": { - "operationId": "listFolders", - "summary": "List Folders", - "description": "List a workspace's folder tree for one resource type. One folder engine serves several trees, so `resourceType` is **required** — it selects which tree you are addressing.\n\nPass `scope=archived` to list folders in Recently Deleted instead of live ones. A workspace's tree for one resource type is small and bounded, so the full set is returned as a single page and `nextCursor` is always `null`. Folders come back in tree order (`sortOrder`, then creation time); build the hierarchy from `parentId`.", - "tags": ["Folders"], + "operationId": "listCredentials", + "summary": "List Credentials", + "description": "List the credentials you can see in a workspace. Visibility is per credential: an explicit membership grant, plus — for workspace admins — every shared credential, plus your own personal environment credentials.\n\n**Secret material is never returned.** A read tells you a secret is configured (`hasServiceAccountKey`) and nothing more. The workspace's credential set is small and bounded, so the full visible set is returned as a single page and `nextCursor` is always `null`.", + "tags": ["Credentials"], "x-codeSamples": [ { "label": "cURL", "lang": "bash", - "source": "curl \\\n \"https://www.sim.ai/api/v2/folders?workspaceId=YOUR_WORKSPACE_ID&resourceType=workflow\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + "source": "curl \\\n \"https://www.sim.ai/api/v2/credentials?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], "parameters": [ - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/FolderResourceTypeQuery" }, { - "name": "scope", - "in": "query", - "required": false, - "description": "`active` (default) lists live folders; `archived` lists Recently Deleted.", - "schema": { "type": "string", "enum": ["active", "archived"], "default": "active" } + "$ref": "#/components/parameters/WorkspaceIdQuery" }, { - "name": "search", - "in": "query", - "required": false, - "description": "Case-insensitive substring match against the folder `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", - "schema": { "type": "string", "minLength": 1, "maxLength": 200 } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field to sort by. `position` is the tree's own manual arrangement, which is the default order.", - "schema": { - "type": "string", - "enum": ["position", "name", "createdAt", "updatedAt"], - "default": "position" - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } - } - ], - "responses": { - "200": { - "description": "Folders in the workspace's tree for the requested resource type.", - "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["data", "nextCursor"], - "properties": { - "data": { - "type": "array", - "description": "The folders in the requested tree.", - "items": { "$ref": "#/components/schemas/Folder" } - }, - "nextCursor": { "$ref": "#/components/schemas/NextCursor" } - } - }, - "example": { - "data": [ - { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "resourceType": "workflow", - "name": "Onboarding", - "parentId": null, - "locked": false, - "sortOrder": 0, - "createdAt": "2025-06-01T09:14:00.000Z", - "updatedAt": "2025-06-20T14:02:11.000Z", - "deletedAt": null - } - ], - "nextCursor": null - } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } - } - }, - "post": { - "operationId": "createFolder", - "summary": "Create Folder", - "description": "Create a folder in one of a workspace's resource trees. Requires `write` permission on the workspace.\n\n`resourceType` is required and selects the tree. Pass `parentId: null` (or omit it) to create the folder at the root; a `parentId` must name a live folder of the same `resourceType` in the same workspace.\n\nA sibling folder with the same name returns `409 CONFLICT`. If the parent is a locked workflow folder, the request returns `423 LOCKED`.", - "tags": ["Folders"], - "x-codeSamples": [ - { - "label": "cURL", - "lang": "bash", - "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/folders\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"resourceType\": \"workflow\",\n \"name\": \"Onboarding\"\n }'" - } - ], - "requestBody": { - "required": true, - "description": "The folder to create.", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateFolderBody" }, - "examples": { - "rootFolder": { - "summary": "A workflow folder at the workspace root", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "resourceType": "workflow", - "name": "Onboarding" - } - }, - "nestedKnowledgeFolder": { - "summary": "A knowledge-base folder nested under another", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "resourceType": "knowledge_base", - "name": "Policies", - "parentId": "7c9e6679-7425-40de-944b-e07fc1f90ae7" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "The folder was created.", - "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } - }, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/FolderData" }, - "example": { - "data": { - "folder": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "resourceType": "workflow", - "name": "Onboarding", - "parentId": null, - "locked": false, - "sortOrder": 0, - "createdAt": "2025-06-20T14:02:11.000Z", - "updatedAt": "2025-06-20T14:02:11.000Z", - "deletedAt": null - } - } - } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "423": { "$ref": "#/components/responses/Locked" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } - } - } - }, - "/api/v2/folders/{id}": { - "get": { - "operationId": "getFolder", - "summary": "Get Folder", - "description": "Fetch a single folder by id. Archived folders resolve too — check `deletedAt` to tell them apart from live ones.", - "tags": ["Folders"], - "x-codeSamples": [ - { - "label": "cURL", - "lang": "bash", - "source": "curl \\\n \"https://www.sim.ai/api/v2/folders/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID&resourceType=workflow\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" - } - ], - "parameters": [ - { "$ref": "#/components/parameters/FolderId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/FolderResourceTypeQuery" } - ], - "responses": { - "200": { - "description": "The folder.", - "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } - }, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/FolderData" }, - "example": { - "data": { - "folder": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "resourceType": "workflow", - "name": "Onboarding", - "parentId": null, - "locked": false, - "sortOrder": 0, - "createdAt": "2025-06-01T09:14:00.000Z", - "updatedAt": "2025-06-20T14:02:11.000Z", - "deletedAt": null - } - } - } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } - } - }, - "patch": { - "operationId": "updateFolder", - "summary": "Update Folder", - "description": "Rename, move, or reorder a folder. Only the fields you send are changed.\n\nMoving is `parentId` — pass `null` to move to the root. A move that would place a folder inside its own subtree is rejected.\n\n`locked` applies to workflow folders only and requires workspace `admin`; sending it for another tree returns `400`. Everything else needs workspace `write`.\n\nArchived folders cannot be updated (`404`), and a mutation lock anywhere on the path returns `423 LOCKED`.", - "tags": ["Folders"], - "x-codeSamples": [ - { - "label": "cURL", - "lang": "bash", - "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/folders/7c9e6679-7425-40de-944b-e07fc1f90ae7\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"resourceType\": \"workflow\",\n \"name\": \"Customer Onboarding\"\n }'" - } - ], - "parameters": [{ "$ref": "#/components/parameters/FolderId" }], - "requestBody": { - "required": true, - "description": "The fields to change. At least one of `name`, `locked`, `parentId`, or `sortOrder` is required.", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/UpdateFolderBody" }, - "examples": { - "rename": { - "summary": "Rename a folder", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "resourceType": "workflow", - "name": "Customer Onboarding" - } - }, - "moveToRoot": { - "summary": "Move a folder to the workspace root", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "resourceType": "workflow", - "parentId": null - } - }, - "lock": { - "summary": "Lock a workflow folder (requires admin)", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "resourceType": "workflow", - "locked": true - } - } - } - } - } - }, - "responses": { - "200": { - "description": "The updated folder.", - "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } - }, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/FolderData" }, - "example": { - "data": { - "folder": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "resourceType": "workflow", - "name": "Customer Onboarding", - "parentId": null, - "locked": false, - "sortOrder": 0, - "createdAt": "2025-06-01T09:14:00.000Z", - "updatedAt": "2025-06-21T08:30:00.000Z", - "deletedAt": null - } - } - } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "423": { "$ref": "#/components/responses/Locked" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } - } - }, - "delete": { - "operationId": "deleteFolder", - "summary": "Delete Folder", - "description": "Archive a folder and everything under it. The cascade moves the subtree — subfolders and the resources filed in them — into Recently Deleted, and `deletedItems` reports how much was archived; only the count matching `resourceType` is populated.\n\nDeleting is idempotent: re-issuing it against an already archived folder retries the cascade onto the same snapshot rather than 404ing, so a run that failed partway can be completed.\n\nA mutation lock anywhere in the subtree returns `423 LOCKED`.", - "tags": ["Folders"], - "x-codeSamples": [ - { - "label": "cURL", - "lang": "bash", - "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/folders/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID&resourceType=workflow\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" - } - ], - "parameters": [ - { "$ref": "#/components/parameters/FolderId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { "$ref": "#/components/parameters/FolderResourceTypeQuery" } - ], - "responses": { - "200": { - "description": "The folder and its subtree were archived.", - "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } - }, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/FolderDeleteAcknowledgement" }, - "example": { - "data": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "deleted": true, - "deletedItems": { "folders": 3, "workflows": 12 } - } - } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "423": { "$ref": "#/components/responses/Locked" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } - } - } - }, - "/api/v2/credentials": { - "get": { - "operationId": "listCredentials", - "summary": "List Credentials", - "description": "List the credentials you can see in a workspace. Visibility is per credential: an explicit membership grant, plus — for workspace admins — every shared credential, plus your own personal environment credentials.\n\n**Secret material is never returned.** A read tells you a secret is configured (`hasServiceAccountKey`) and nothing more. The workspace's credential set is small and bounded, so the full visible set is returned as a single page and `nextCursor` is always `null`.", - "tags": ["Credentials"], - "x-codeSamples": [ - { - "label": "cURL", - "lang": "bash", - "source": "curl \\\n \"https://www.sim.ai/api/v2/credentials?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" - } - ], - "parameters": [ - { "$ref": "#/components/parameters/WorkspaceIdQuery" }, - { - "name": "type", + "name": "type", "in": "query", "required": false, "description": "Only return credentials of this kind.", @@ -1512,14 +1565,22 @@ "in": "query", "required": false, "description": "Only return credentials for this integration.", - "schema": { "type": "string", "minLength": 1, "example": "slack" } + "schema": { + "type": "string", + "minLength": 1, + "example": "slack" + } }, { "name": "search", "in": "query", "required": false, "description": "Case-insensitive substring match against the credential `displayName`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", - "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, { "name": "sortBy", @@ -1537,16 +1598,26 @@ "in": "query", "required": false, "description": "Sort direction.", - "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } } ], "responses": { "200": { "description": "Credentials visible to the caller in the workspace.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { @@ -1557,9 +1628,13 @@ "data": { "type": "array", "description": "The credentials visible to the caller.", - "items": { "$ref": "#/components/schemas/Credential" } + "items": { + "$ref": "#/components/schemas/Credential" + } }, - "nextCursor": { "$ref": "#/components/schemas/NextCursor" } + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } } }, "example": { @@ -1583,11 +1658,21 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "post": { @@ -1607,7 +1692,9 @@ "description": "The credential to create.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CreateCredentialBody" }, + "schema": { + "$ref": "#/components/schemas/CreateCredentialBody" + }, "examples": { "workspaceEnvVar": { "summary": "A workspace-wide environment secret", @@ -1636,13 +1723,21 @@ "201": { "description": "The credential exists with this source. Returned whether it was inserted now or already present.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CredentialData" }, + "schema": { + "$ref": "#/components/schemas/CredentialData" + }, "example": { "data": { "credential": { @@ -1663,13 +1758,27 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" }, - "503": { "$ref": "#/components/responses/ServiceUnavailable" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } } } }, @@ -1687,20 +1796,32 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/CredentialId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/CredentialId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The credential.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CredentialData" }, + "schema": { + "$ref": "#/components/schemas/CredentialData" + }, "example": { "data": { "credential": { @@ -1721,18 +1842,30 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } }, "patch": { "operationId": "updateCredential", "summary": "Update Credential", - "description": "Rename a credential, change its description, or rotate its stored secret. Requires credential admin \u2014 access to the workspace plus admin rights on the credential itself. Workspace `write` is not required: credentials are gated per credential, not per workspace.\n\nSending a secret field rotates that secret in place: it is re-verified against the provider and re-encrypted, and the display name is preserved. Secrets are never returned in the response. If the provider cannot be reached to verify the new secret, the request returns `503`.\n\nA credential you cannot see answers `404` rather than `403`, so its existence is never disclosed.", + "description": "Rename a credential, change its description, or rotate its stored secret. Requires credential admin — access to the workspace plus admin rights on the credential itself. Workspace `write` is not required: credentials are gated per credential, not per workspace.\n\nSending a secret field rotates that secret in place: it is re-verified against the provider and re-encrypted, and the display name is preserved. Secrets are never returned in the response. If the provider cannot be reached to verify the new secret, the request returns `503`.\n\nA credential you cannot see answers `404` rather than `403`, so its existence is never disclosed.", "tags": ["Credentials"], "x-codeSamples": [ { @@ -1741,13 +1874,19 @@ "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/credentials/7c9e6679-7425-40de-944b-e07fc1f90ae7\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"displayName\": \"Zoom (production)\"\n }'" } ], - "parameters": [{ "$ref": "#/components/parameters/CredentialId" }], + "parameters": [ + { + "$ref": "#/components/parameters/CredentialId" + } + ], "requestBody": { "required": true, "description": "The fields to change. At least one field besides `workspaceId` is required.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UpdateCredentialBody" }, + "schema": { + "$ref": "#/components/schemas/UpdateCredentialBody" + }, "examples": { "rename": { "summary": "Rename a credential", @@ -1771,13 +1910,21 @@ "200": { "description": "The updated credential.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/CredentialData" }, + "schema": { + "$ref": "#/components/schemas/CredentialData" + }, "example": { "data": { "credential": { @@ -1798,20 +1945,36 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" }, - "503": { "$ref": "#/components/responses/ServiceUnavailable" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } } }, "delete": { "operationId": "deleteCredential", "summary": "Delete Credential", - "description": "Delete a credential. Requires credential admin \u2014 access to the workspace plus admin rights on the credential itself; workspace `write` is not required. Blocks and workflows configured against it stop authenticating, and any environment variable it backed is removed.\n\nA credential you cannot see answers `404` rather than `403`.", + "description": "Delete a credential. Requires credential admin — access to the workspace plus admin rights on the credential itself; workspace `write` is not required. Blocks and workflows configured against it stop authenticating, and any environment variable it backed is removed.\n\nA credential you cannot see answers `404` rather than `403`.", "tags": ["Credentials"], "x-codeSamples": [ { @@ -1821,32 +1984,59 @@ } ], "parameters": [ - { "$ref": "#/components/parameters/CredentialId" }, - { "$ref": "#/components/parameters/WorkspaceIdQuery" } + { + "$ref": "#/components/parameters/CredentialId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } ], "responses": { "200": { "description": "The credential was deleted.", "headers": { - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/DeleteAcknowledgement" }, + "schema": { + "$ref": "#/components/schemas/DeleteAcknowledgement" + }, "example": { - "data": { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "deleted": true } + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "deleted": true + } } } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "429": { "$ref": "#/components/responses/RateLimited" }, - "500": { "$ref": "#/components/responses/InternalError" } + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } } } @@ -1863,19 +2053,32 @@ "headers": { "RateLimitLimit": { "description": "The maximum number of requests permitted in the current rate-limit window.", - "schema": { "type": "integer", "example": 60 } + "schema": { + "type": "integer", + "example": 60 + } }, "RateLimitRemaining": { "description": "The number of requests remaining in the current rate-limit window.", - "schema": { "type": "integer", "example": 59 } + "schema": { + "type": "integer", + "example": 59 + } }, "RateLimitReset": { "description": "ISO 8601 timestamp at which the current rate-limit window resets.", - "schema": { "type": "string", "format": "date-time", "example": "2025-06-20T14:16:00Z" } + "schema": { + "type": "string", + "format": "date-time", + "example": "2025-06-20T14:16:00Z" + } }, "RetryAfter": { "description": "Number of seconds to wait before retrying the request.", - "schema": { "type": "integer", "example": 30 } + "schema": { + "type": "integer", + "example": 30 + } } }, "parameters": { @@ -1895,21 +2098,33 @@ "in": "path", "required": true, "description": "The unique identifier of the MCP server.", - "schema": { "type": "string", "minLength": 1, "example": "mcp-3f7a9c21" } + "schema": { + "type": "string", + "minLength": 1, + "example": "mcp-3f7a9c21" + } }, "SkillId": { "name": "id", "in": "path", "required": true, "description": "The unique identifier of the skill. Built-in skills use their name as their id.", - "schema": { "type": "string", "minLength": 1, "example": "V1StGXR8Z5jdHi6BmyT" } + "schema": { + "type": "string", + "minLength": 1, + "example": "V1StGXR8Z5jdHi6BmyT" + } }, "CustomToolId": { "name": "id", "in": "path", "required": true, "description": "The unique identifier of the custom tool.", - "schema": { "type": "string", "minLength": 1, "example": "V1StGXR8Z5jdHi6BmyT" } + "schema": { + "type": "string", + "minLength": 1, + "example": "V1StGXR8Z5jdHi6BmyT" + } }, "FolderId": { "name": "id", @@ -1932,17 +2147,6 @@ "minLength": 1, "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" } - }, - "FolderResourceTypeQuery": { - "name": "resourceType", - "in": "query", - "required": true, - "description": "Which resource tree the folder belongs to. Required — folder ids are unique, but addressing the wrong tree would file a folder where its page can never see it.", - "schema": { - "type": "string", - "enum": ["workflow", "knowledge_base", "table"], - "example": "workflow" - } } }, "responses": { @@ -1950,12 +2154,19 @@ "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Error" }, + "schema": { + "$ref": "#/components/schemas/Error" + }, "example": { "error": { "code": "BAD_REQUEST", "message": "Workspace ID is required", - "details": [{ "path": "workspaceId", "message": "Workspace ID is required" }] + "details": [ + { + "path": "workspaceId", + "message": "Workspace ID is required" + } + ] } } } @@ -1965,8 +2176,15 @@ "description": "The API key is missing or invalid. Ensure the X-API-Key header is set with a valid key.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Error" }, - "example": { "error": { "code": "UNAUTHORIZED", "message": "Invalid API key" } } + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } } } }, @@ -1974,8 +2192,15 @@ "description": "The authenticated caller does not have the required permission on the workspace, or the URL was rejected by the server's MCP domain policy.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Error" }, - "example": { "error": { "code": "FORBIDDEN", "message": "Access denied" } } + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } } } }, @@ -1983,8 +2208,15 @@ "description": "The requested resource does not exist or is not accessible from this workspace.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Error" }, - "example": { "error": { "code": "NOT_FOUND", "message": "MCP server not found" } } + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "MCP server not found" + } + } } } }, @@ -1992,7 +2224,9 @@ "description": "The request conflicts with the current state of the workspace — for example a resource with the same identity already exists.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Error" }, + "schema": { + "$ref": "#/components/schemas/Error" + }, "example": { "error": { "code": "CONFLICT", @@ -2006,7 +2240,9 @@ "description": "A mutation lock on the resource (or something inside it) blocks the change. Unlock it and retry.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Error" }, + "schema": { + "$ref": "#/components/schemas/Error" + }, "example": { "error": { "code": "LOCKED", @@ -2019,19 +2255,31 @@ "RateLimited": { "description": "The rate limit has been exceeded. Retry after the period indicated by the Retry-After header.", "headers": { - "Retry-After": { "$ref": "#/components/headers/RetryAfter" }, - "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" }, - "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" }, - "X-RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" } + "Retry-After": { + "$ref": "#/components/headers/RetryAfter" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } }, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Error" }, + "schema": { + "$ref": "#/components/schemas/Error" + }, "example": { "error": { "code": "RATE_LIMITED", "message": "API rate limit exceeded", - "details": { "retryAfter": "2025-06-20T14:16:00Z" } + "details": { + "retryAfter": "2025-06-20T14:16:00Z" + } } } } @@ -2041,8 +2289,15 @@ "description": "An unexpected error occurred on the server.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Error" }, - "example": { "error": { "code": "INTERNAL_ERROR", "message": "Internal server error" } } + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } } } }, @@ -2050,7 +2305,9 @@ "description": "An upstream provider could not be reached to verify the request. Retry shortly.", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/Error" }, + "schema": { + "$ref": "#/components/schemas/Error" + }, "example": { "error": { "code": "SERVICE_UNAVAILABLE", @@ -2115,7 +2372,10 @@ "type": "string", "description": "The identifier of the resource that was deleted." }, - "deleted": { "type": "boolean", "const": true } + "deleted": { + "type": "boolean", + "const": true + } } } } @@ -2139,8 +2399,14 @@ "type": "string", "description": "The server's unique identifier, derived from the workspace and the server URL." }, - "name": { "type": "string", "description": "Display name of the server." }, - "description": { "type": "string", "description": "Optional description." }, + "name": { + "type": "string", + "description": "Display name of the server." + }, + "description": { + "type": "string", + "description": "Optional description." + }, "transport": { "type": "string", "enum": ["streamable-http"], @@ -2151,12 +2417,18 @@ "enum": ["none", "headers", "oauth"], "description": "How Sim authenticates to the server." }, - "url": { "type": "string", "description": "The server's endpoint URL." }, + "url": { + "type": "string", + "description": "The server's endpoint URL." + }, "timeout": { "type": "number", "description": "Per-request timeout in milliseconds." }, - "retries": { "type": "number", "description": "Number of retries per request." }, + "retries": { + "type": "number", + "description": "Number of retries per request." + }, "enabled": { "type": "boolean", "description": "Whether the server's tools are available to workflows." @@ -2184,8 +2456,14 @@ "format": "date-time", "description": "When Sim last connected successfully." }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, "oauthClientId": { "type": "string", "description": "Pre-registered OAuth client id, when the server does not support dynamic client registration." @@ -2196,7 +2474,9 @@ }, "headerNames": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "Names of the configured request headers. Values are never returned." }, "hasOauthClientSecret": { @@ -2212,7 +2492,11 @@ "data": { "type": "object", "required": ["mcpServer"], - "properties": { "mcpServer": { "$ref": "#/components/schemas/McpServer" } } + "properties": { + "mcpServer": { + "$ref": "#/components/schemas/McpServer" + } + } } } }, @@ -2256,7 +2540,9 @@ }, "headers": { "type": "object", - "additionalProperties": { "type": "string" }, + "additionalProperties": { + "type": "string" + }, "description": "Write-only. Request headers sent to the server, e.g. `Authorization`. Never returned on read." }, "timeout": { @@ -2298,25 +2584,53 @@ "minLength": 1, "description": "The workspace that owns the server." }, - "name": { "type": "string", "minLength": 1, "maxLength": 255 }, - "description": { "type": "string", "maxLength": 2000 }, - "transport": { "type": "string", "enum": ["streamable-http"] }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "type": "string", + "maxLength": 2000 + }, + "transport": { + "type": "string", + "enum": ["streamable-http"] + }, "url": { "type": "string", "minLength": 1, "maxLength": 2048, "description": "Immutable. Must equal the server's current URL — a different value returns `400`, because the server's id is derived from its URL." }, - "authType": { "type": "string", "enum": ["none", "headers", "oauth"] }, + "authType": { + "type": "string", + "enum": ["none", "headers", "oauth"] + }, "headers": { "type": "object", - "additionalProperties": { "type": "string" }, + "additionalProperties": { + "type": "string" + }, "description": "Write-only. Replaces the stored header map wholesale." }, - "timeout": { "type": "integer", "minimum": 1000, "maximum": 300000 }, - "retries": { "type": "integer", "minimum": 0, "maximum": 10 }, - "enabled": { "type": "boolean" }, - "oauthClientId": { "type": ["string", "null"], "maxLength": 512 }, + "timeout": { + "type": "integer", + "minimum": 1000, + "maximum": 300000 + }, + "retries": { + "type": "integer", + "minimum": 0, + "maximum": 10 + }, + "enabled": { + "type": "boolean" + }, + "oauthClientId": { + "type": ["string", "null"], + "maxLength": 512 + }, "oauthClientSecret": { "type": ["string", "null"], "maxLength": 2048, @@ -2329,7 +2643,10 @@ "description": "A skill without its body. Fetch the skill by id to read `content`.", "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt"], "properties": { - "id": { "type": "string", "description": "The skill's unique identifier." }, + "id": { + "type": "string", + "description": "The skill's unique identifier." + }, "name": { "type": "string", "description": "Kebab-case name, unique within the workspace. This is what agents reference." @@ -2342,8 +2659,14 @@ "type": "boolean", "description": "True for built-in template skills, which ship with Sim and cannot be modified or deleted." }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } } }, "Skill": { @@ -2351,7 +2674,10 @@ "description": "A skill, including its full body.", "required": ["id", "name", "description", "content", "readOnly", "createdAt", "updatedAt"], "properties": { - "id": { "type": "string", "description": "The skill's unique identifier." }, + "id": { + "type": "string", + "description": "The skill's unique identifier." + }, "name": { "type": "string", "description": "Kebab-case name, unique within the workspace. This is what agents reference." @@ -2368,8 +2694,14 @@ "type": "boolean", "description": "True for built-in template skills, which ship with Sim and cannot be modified or deleted." }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } } }, "SkillData": { @@ -2379,7 +2711,11 @@ "data": { "type": "object", "required": ["skill"], - "properties": { "skill": { "$ref": "#/components/schemas/Skill" } } + "properties": { + "skill": { + "$ref": "#/components/schemas/Skill" + } + } } } }, @@ -2432,8 +2768,16 @@ "maxLength": 64, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, - "description": { "type": "string", "minLength": 1, "maxLength": 1024 }, - "content": { "type": "string", "minLength": 1, "maxLength": 50000 } + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "content": { + "type": "string", + "minLength": 1, + "maxLength": 50000 + } } }, "CustomToolSchema": { @@ -2441,7 +2785,10 @@ "description": "OpenAI-style function declaration describing the tool's callable surface. The parameter properties are caller-defined, so the shape below the function level is open.", "required": ["type", "function"], "properties": { - "type": { "type": "string", "const": "function" }, + "type": { + "type": "string", + "const": "function" + }, "function": { "type": "object", "required": ["name", "parameters"], @@ -2460,7 +2807,10 @@ "description": "JSON Schema for the tool's arguments.", "required": ["type", "properties"], "properties": { - "type": { "type": "string", "description": "Usually `object`." }, + "type": { + "type": "string", + "description": "Usually `object`." + }, "properties": { "type": "object", "additionalProperties": true, @@ -2468,7 +2818,9 @@ }, "required": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "Names of the required arguments." } } @@ -2482,18 +2834,29 @@ "description": "A code-backed tool defined in a workspace that agents can call.", "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], "properties": { - "id": { "type": "string", "description": "The tool's unique identifier." }, + "id": { + "type": "string", + "description": "The tool's unique identifier." + }, "title": { "type": "string", "description": "Display title, unique within the workspace. Tools also resolve by title at call time." }, - "schema": { "$ref": "#/components/schemas/CustomToolSchema" }, + "schema": { + "$ref": "#/components/schemas/CustomToolSchema" + }, "code": { "type": "string", "description": "The tool body, executed in Sim's sandboxed function runtime with the schema's parameters bound as variables." }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } } }, "CustomToolData": { @@ -2503,7 +2866,11 @@ "data": { "type": "object", "required": ["customTool"], - "properties": { "customTool": { "$ref": "#/components/schemas/CustomTool" } } + "properties": { + "customTool": { + "$ref": "#/components/schemas/CustomTool" + } + } } } }, @@ -2524,7 +2891,9 @@ "maxLength": 200, "description": "Display title, unique within the workspace." }, - "schema": { "$ref": "#/components/schemas/CustomToolSchema" }, + "schema": { + "$ref": "#/components/schemas/CustomToolSchema" + }, "code": { "type": "string", "maxLength": 100000, @@ -2543,161 +2912,20 @@ "minLength": 1, "description": "The workspace that owns the tool." }, - "title": { "type": "string", "minLength": 1, "maxLength": 200 }, - "schema": { "$ref": "#/components/schemas/CustomToolSchema" }, - "code": { "type": "string", "maxLength": 100000 } - } - }, - "Folder": { - "type": "object", - "description": "A folder in one of a workspace's resource trees.", - "required": [ - "id", - "resourceType", - "name", - "parentId", - "locked", - "sortOrder", - "createdAt", - "updatedAt", - "deletedAt" - ], - "properties": { - "id": { "type": "string", "description": "The folder's unique identifier." }, - "resourceType": { - "type": "string", - "enum": ["workflow", "file", "knowledge_base", "table"], - "description": "Which resource tree the folder belongs to. Only `workflow`, `knowledge_base`, and `table` are served by this API; `file` folders have their own surface." - }, - "name": { "type": "string", "description": "Display name." }, - "parentId": { - "type": ["string", "null"], - "description": "The containing folder, or null when the folder sits at the workspace root." - }, - "locked": { - "type": "boolean", - "description": "Whether the folder is locked against modification. Workflow folders only; always false elsewhere." - }, - "sortOrder": { - "type": "number", - "description": "Position among its siblings, ascending." - }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" }, - "deletedAt": { - "type": ["string", "null"], - "format": "date-time", - "description": "When the folder was archived into Recently Deleted, or null when it is live." - } - } - }, - "FolderData": { - "type": "object", - "required": ["data"], - "properties": { - "data": { - "type": "object", - "required": ["folder"], - "properties": { "folder": { "$ref": "#/components/schemas/Folder" } } - } - } - }, - "FolderDeleteAcknowledgement": { - "type": "object", - "description": "Acknowledgement that a folder was archived, with what the cascade took with it.", - "required": ["data"], - "properties": { - "data": { - "type": "object", - "required": ["id", "deleted"], - "properties": { - "id": { - "type": "string", - "description": "The identifier of the folder that was archived." - }, - "deleted": { "type": "boolean", "const": true }, - "deletedItems": { - "type": "object", - "description": "How much the cascade archived. Only the count matching the folder's `resourceType` is populated.", - "required": ["folders"], - "properties": { - "folders": { - "type": "integer", - "description": "Subfolders archived, including the folder itself." - }, - "workflows": { "type": "integer" }, - "files": { "type": "integer" }, - "knowledgeBases": { "type": "integer" }, - "tables": { "type": "integer" } - } - } - } - } - } - }, - "CreateFolderBody": { - "type": "object", - "description": "A new folder.", - "additionalProperties": false, - "required": ["workspaceId", "resourceType", "name"], - "properties": { - "workspaceId": { + "title": { "type": "string", "minLength": 1, - "description": "The workspace to create the folder in." + "maxLength": 200 }, - "resourceType": { - "type": "string", - "enum": ["workflow", "knowledge_base", "table"], - "description": "Which resource tree to create the folder in. Required." + "schema": { + "$ref": "#/components/schemas/CustomToolSchema" }, - "name": { + "code": { "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Display name. Must be unique among its siblings." - }, - "parentId": { - "type": ["string", "null"], - "minLength": 1, - "description": "The containing folder. Omit or pass null to create at the workspace root." - }, - "sortOrder": { - "type": "integer", - "minimum": 0, - "description": "Position among its siblings. Defaults to the top of the list." + "maxLength": 100000 } } }, - "UpdateFolderBody": { - "type": "object", - "description": "Fields to change on an existing folder. At least one of `name`, `locked`, `parentId`, or `sortOrder` is required; omitted fields keep their stored values.", - "additionalProperties": false, - "required": ["workspaceId", "resourceType"], - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "The workspace that owns the folder." - }, - "resourceType": { - "type": "string", - "enum": ["workflow", "knowledge_base", "table"], - "description": "Which resource tree the folder belongs to. Required." - }, - "name": { "type": "string", "minLength": 1, "maxLength": 255 }, - "locked": { - "type": "boolean", - "description": "Workflow folders only, and changing it requires workspace `admin`." - }, - "parentId": { - "type": ["string", "null"], - "minLength": 1, - "description": "New parent folder. Pass null to move to the workspace root." - }, - "sortOrder": { "type": "integer", "minimum": 0 } - } - }, "Credential": { "type": "object", "description": "A stored credential. Secret material is write-only and never appears here.", @@ -2715,14 +2943,22 @@ "updatedAt" ], "properties": { - "id": { "type": "string", "description": "The credential's unique identifier." }, + "id": { + "type": "string", + "description": "The credential's unique identifier." + }, "type": { "type": "string", "enum": ["oauth", "env_workspace", "env_personal", "service_account"], "description": "What kind of credential this is." }, - "displayName": { "type": "string", "description": "Display name." }, - "description": { "type": ["string", "null"] }, + "displayName": { + "type": "string", + "description": "Display name." + }, + "description": { + "type": ["string", "null"] + }, "providerId": { "type": ["string", "null"], "description": "The integration this credential authenticates against, when it has one." @@ -2744,8 +2980,14 @@ "enum": ["admin", "member"], "description": "The caller's role on this credential. Only admins can update or delete it." }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } } }, "CredentialData": { @@ -2755,7 +2997,11 @@ "data": { "type": "object", "required": ["credential"], - "properties": { "credential": { "$ref": "#/components/schemas/Credential" } } + "properties": { + "credential": { + "$ref": "#/components/schemas/Credential" + } + } } } }, @@ -2781,7 +3027,10 @@ "maxLength": 255, "description": "Display name. Derived from the env key or the verified provider account when omitted." }, - "description": { "type": "string", "maxLength": 500 }, + "description": { + "type": "string", + "maxLength": 500 + }, "providerId": { "type": "string", "minLength": 1, @@ -2817,14 +3066,22 @@ "minLength": 1, "description": "Atlassian site domain, paired with `apiToken`." }, - "clientId": { "type": "string", "minLength": 1, "maxLength": 512 }, + "clientId": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, "clientSecret": { "type": "string", "minLength": 1, "maxLength": 1024, "description": "Write-only. Client-credentials secret." }, - "orgId": { "type": "string", "minLength": 1, "maxLength": 255 }, + "orgId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, "dataCenter": { "type": "string", "minLength": 1, @@ -2844,7 +3101,11 @@ "minLength": 1, "description": "The workspace that owns the credential." }, - "displayName": { "type": "string", "minLength": 1, "maxLength": 255 }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, "description": { "type": ["string", "null"], "maxLength": 500, @@ -2855,18 +3116,41 @@ "minLength": 1, "description": "Write-only. Replaces the stored service-account JSON key." }, - "signingSecret": { "type": "string", "minLength": 1, "description": "Write-only." }, - "botToken": { "type": "string", "minLength": 1, "description": "Write-only." }, - "apiToken": { "type": "string", "minLength": 1, "description": "Write-only." }, - "domain": { "type": "string", "minLength": 1 }, - "clientId": { "type": "string", "minLength": 1, "maxLength": 512 }, + "signingSecret": { + "type": "string", + "minLength": 1, + "description": "Write-only." + }, + "botToken": { + "type": "string", + "minLength": 1, + "description": "Write-only." + }, + "apiToken": { + "type": "string", + "minLength": 1, + "description": "Write-only." + }, + "domain": { + "type": "string", + "minLength": 1 + }, + "clientId": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, "clientSecret": { "type": "string", "minLength": 1, "maxLength": 1024, "description": "Write-only." }, - "orgId": { "type": "string", "minLength": 1, "maxLength": 255 }, + "orgId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, "dataCenter": { "type": "string", "minLength": 1, diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index a80ff6774b4..5520ec9c1e1 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -51,7 +51,7 @@ "$ref": "#/components/parameters/WorkspaceIdQuery" }, { - "name": "folderId", + "name": "folderPath", "in": "query", "required": false, "description": "Restrict the list to one folder. Omit to list every table in the workspace.", @@ -64,7 +64,7 @@ "name": "search", "in": "query", "required": false, - "description": "Case-insensitive substring match against the table `name`. Matches nothing else \u2014 not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "description": "Case-insensitive substring match against the table `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", "schema": { "type": "string", "minLength": 1, @@ -118,45 +118,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/TableListEnvelope" - }, - "example": { - "data": [ - { - "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", - "name": "contacts", - "description": "Customer contact records", - "schema": { - "columns": [ - { - "id": "col_a1b2c3", - "name": "email", - "type": "string", - "required": true, - "unique": true - }, - { - "id": "col_d4e5f6", - "name": "name", - "type": "string", - "required": true - } - ] - }, - "rowCount": 2, - "maxRows": 100000, - "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-01-15T10:30:00.000Z", - "folderId": null, - "locks": { - "schemaLocked": false, - "insertLocked": false, - "updateLocked": false, - "deleteLocked": false - }, - "job": null - } - ], - "nextCursor": null } } } @@ -220,51 +181,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/TableEnvelope" - }, - "example": { - "data": { - "table": { - "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", - "name": "contacts", - "description": "Customer contacts", - "schema": { - "columns": [ - { - "id": "col_a1b2c3", - "name": "email", - "type": "string", - "required": true, - "unique": true - }, - { - "id": "col_d4e5f6", - "name": "name", - "type": "string", - "required": true - }, - { - "id": "col_g7h8i9", - "name": "age", - "type": "number", - "required": false, - "unique": false - } - ] - }, - "rowCount": 0, - "maxRows": 100000, - "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-01-15T10:30:00.000Z", - "folderId": null, - "locks": { - "schemaLocked": false, - "insertLocked": false, - "updateLocked": false, - "deleteLocked": false - }, - "job": null - } - } } } } @@ -354,7 +270,7 @@ "delete": { "operationId": "deleteTable", "summary": "Delete Table", - "description": "Archive a table. Returns the id of the archived table.", + "description": "Delete a table. Returns the id of the deleted table.", "tags": ["Tables"], "x-codeSamples": [ { @@ -374,7 +290,7 @@ ], "responses": { "200": { - "description": "The table was archived.", + "description": "The table was deleted.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -390,11 +306,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/DeleteTableEnvelope" - }, - "example": { - "data": { - "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" - } } } } @@ -422,7 +333,7 @@ "patch": { "operationId": "updateTable", "summary": "Update Table", - "description": "Rename a table and/or move it between folders. Provide at least one of `name` or `folderId`. Each field is applied independently, so one request can rename and move at once, and the response reflects every applied change.\n\nBoth fields need workspace write.\n\n**Lock flags are read-only here.** A table's `locks` are returned on the table resource and enforced on every write (a locked verb returns 423), but they cannot be changed through the API \u2014 a write-level key must not be able to clear the guard placed there to stop it. Changing a lock is a first-party workspace-admin action. A request carrying `locks` is rejected with 400 rather than silently ignored.\n\n**Partial-success semantics.** The two operations commit independently, so this endpoint is not atomic. Everything that can be *rejected* \u2014 the body shape, folder existence \u2014 is validated before the first write, so a rejected request changes nothing. If a genuine fault (a lost race, the table archived mid-request, a database error) fails the later operation after the earlier one has committed, the response is an error whose `error.details.applied` lists the operations that are nevertheless live (`\"name\"`, `\"folderId\"`). The field is absent when nothing was applied, so its presence always means \"these changes took effect despite the error\" \u2014 re-read the table to confirm before retrying.", + "description": "Rename a table and/or move it between folders. Provide at least one of `name` or `folderPath`. Each field is applied independently, so one request can rename and move at once, and the response reflects every applied change.\n\nBoth fields need workspace write.\n\n**Lock flags are read-only here.** A table's `locks` are returned on the table resource and enforced on every write (a locked verb returns 423), but they cannot be changed through the API — a write-level key must not be able to clear the guard placed there to stop it. Changing a lock is a first-party workspace-admin action. A request carrying `locks` is rejected with 400 rather than silently ignored.\n\n**Partial-success semantics.** The two operations commit independently, so this endpoint is not atomic. Everything that can be *rejected* — the body shape, folder existence — is validated before the first write, so a rejected request changes nothing. If a genuine fault (a lost race, the table deleted mid-request, a database error) fails the later operation after the earlier one has committed, the response is an error whose `error.details.applied` lists the operations that are nevertheless live (`\"name\"`, `\"folderPath\"`). The field is absent when nothing was applied, so its presence always means \"these changes took effect despite the error\" — re-read the table to confirm before retrying.", "tags": ["Tables"], "x-codeSamples": [ { @@ -443,22 +354,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/UpdateTableBody" - }, - "examples": { - "rename": { - "summary": "Rename", - "value": { - "workspaceId": "ws_123", - "name": "customers" - } - }, - "move": { - "summary": "Move to the workspace root", - "value": { - "workspaceId": "ws_123", - "folderId": null - } - } } } } @@ -481,38 +376,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/TableEnvelope" - }, - "example": { - "data": { - "table": { - "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", - "name": "customers", - "description": "Customer contact records", - "schema": { - "columns": [ - { - "id": "col_a1b2c3", - "name": "email", - "type": "string", - "required": true, - "unique": true - } - ] - }, - "rowCount": 42, - "maxRows": 100000, - "folderId": "fld_7a1c3e5d9b2f4068a3c5e7d9f1b3a507", - "locks": { - "schemaLocked": false, - "insertLocked": false, - "updateLocked": false, - "deleteLocked": true - }, - "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-01-16T09:12:00.000Z", - "job": null - } - } } } } @@ -646,7 +509,7 @@ "patch": { "operationId": "updateTableColumn", "summary": "Update Column", - "description": "Update a column by name \u2014 rename it, change its type, or toggle its required/unique constraints. Provide at least one field in `updates`. Returns the table's full column list after the change.", + "description": "Update a column by name — rename it, change its type, or toggle its required/unique constraints. Provide at least one field in `updates`. Returns the table's full column list after the change.", "tags": ["Tables"], "x-codeSamples": [ { @@ -790,7 +653,7 @@ "get": { "operationId": "listTableRows", "summary": "List rows", - "description": "Plain cursor page over the default row order. Filtering and sorting are not part of this surface \u2014 use `POST /api/v2/tables/{tableId}/query` for predicate-filtered, sorted reads. The cursor is opaque; page by passing the previous response's `nextCursor` back as `cursor` and stop when it is `null`.", + "description": "Plain cursor page over the default row order. Filtering and sorting are not part of this surface — use `POST /api/v2/tables/{tableId}/query` for predicate-filtered, sorted reads. The cursor is opaque; page by passing the previous response's `nextCursor` back as `cursor` and stop when it is `null`.", "tags": ["Tables"], "x-codeSamples": [ { @@ -1526,7 +1389,7 @@ "post": { "operationId": "queryTableRows", "summary": "Query Rows", - "description": "Query rows with a typed predicate filter, an ordered sort spec, and opaque cursor pagination. Row `data` is keyed by column NAME; `select` cells return option names, and filter operands on select columns accept option names (resolved case-insensitively).\n\n**Pagination contract:** page by passing the previous response's `nextCursor` back as `cursor`, and stop only when it is `null` \u2014 a page may return fewer than `limit` rows and still have more behind it, so page fullness is never a termination signal. A cursor is bound to the exact query shape it was minted under: keyset cursors to the default row order, offset cursors (sorted views) to that sort. Replaying one under a different `sort` returns 400 `CURSOR_SORT_CONFLICT`.", + "description": "Query rows with a typed predicate filter, an ordered sort spec, and opaque cursor pagination. Row `data` is keyed by column NAME; `select` cells return option names, and filter operands on select columns accept option names (resolved case-insensitively).\n\n**Pagination contract:** page by passing the previous response's `nextCursor` back as `cursor`, and stop only when it is `null` — a page may return fewer than `limit` rows and still have more behind it, so page fullness is never a termination signal. A cursor is bound to the exact query shape it was minted under: keyset cursors to the default row order, offset cursors (sorted views) to that sort. Replaying one under a different `sort` returns 400 `CURSOR_SORT_CONFLICT`.", "tags": ["Tables"], "parameters": [ { @@ -1571,7 +1434,7 @@ "minimum": 0, "maximum": 1000, "default": 100, - "description": "Omitted \u2192 100. `1..1000` \u2192 page size. `0` \u2192 the ENTIRE matching result in one response; fails with 400 `TABLE_QUERY_RESULT_TOO_LARGE` if it exceeds the 5 MB row-data budget (narrow the predicate or page instead)." + "description": "Omitted → 100. `1..1000` → page size. `0` → the ENTIRE matching result in one response; fails with 400 `TABLE_QUERY_RESULT_TOO_LARGE` if it exceeds the 5 MB row-data budget (narrow the predicate or page instead)." }, "cursor": { "type": "string", @@ -1685,116 +1548,6 @@ } } }, - "/api/v2/tables/{tableId}/restore": { - "post": { - "operationId": "restoreTable", - "summary": "Restore Table", - "description": "Un-archive a table archived by `DELETE /api/v2/tables/{tableId}`, along with its rows. Requires workspace write. Returns 409 when a different active table has since taken the archived table\u2019s name \u2014 rename that table first, then retry.", - "tags": ["Tables"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/restore\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\"}'" - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/TableId" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorkspaceScopedBody" - }, - "example": { - "workspaceId": "ws_123" - } - } - } - }, - "responses": { - "200": { - "description": "The restored table.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/RateLimitLimit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/RateLimitRemaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/RateLimitReset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TableEnvelope" - }, - "example": { - "data": { - "table": { - "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", - "name": "customers", - "description": "Customer contact records", - "schema": { - "columns": [ - { - "id": "col_a1b2c3", - "name": "email", - "type": "string", - "required": true, - "unique": true - } - ] - }, - "rowCount": 42, - "maxRows": 100000, - "folderId": "fld_7a1c3e5d9b2f4068a3c5e7d9f1b3a507", - "locks": { - "schemaLocked": false, - "insertLocked": false, - "updateLocked": false, - "deleteLocked": true - }, - "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-01-16T09:12:00.000Z", - "job": null - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, "/api/v2/tables/{tableId}/views": { "get": { "operationId": "listTableViews", @@ -1819,7 +1572,7 @@ ], "responses": { "200": { - "description": "The table\u2019s saved views.", + "description": "The table’s saved views.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -1894,7 +1647,7 @@ "post": { "operationId": "createTableView", "summary": "Create View", - "description": "Save a filter, sort, and column layout as a named view. A view is presentation state, never an access boundary \u2014 rows it hides stay readable through the row and query endpoints.", + "description": "Save a filter, sort, and column layout as a named view. A view is presentation state, never an access boundary — rows it hides stay readable through the row and query endpoints.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2115,7 +1868,7 @@ "patch": { "operationId": "updateTableView", "summary": "Update View", - "description": "Rename a view, replace or merge its config, or promote it to the table\u2019s default. Provide at least one of `name`, `config`, `configPatch`, or `isDefault`.\n\n`config` replaces the stored config wholesale; `configPatch` is shallow-merged server-side so overlapping partial writes cannot clobber each other. The two are mutually exclusive. Setting `isDefault: true` demotes the table\u2019s existing default in the same transaction.", + "description": "Rename a view, replace or merge its config, or promote it to the table’s default. Provide at least one of `name`, `config`, `configPatch`, or `isDefault`.\n\n`config` replaces the stored config wholesale; `configPatch` is shallow-merged server-side so overlapping partial writes cannot clobber each other. The two are mutually exclusive. Setting `isDefault: true` demotes the table’s existing default in the same transaction.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2142,7 +1895,7 @@ }, "examples": { "promote": { - "summary": "Make this the table\u2019s default view", + "summary": "Make this the table’s default view", "value": { "workspaceId": "ws_123", "isDefault": true @@ -2244,7 +1997,7 @@ "delete": { "operationId": "deleteTableView", "summary": "Delete View", - "description": "Remove a saved view. Deleting the table\u2019s default simply leaves the table unfiltered; no rows are affected.", + "description": "Remove a saved view. Deleting the table’s default simply leaves the table unfiltered; no rows are affected.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2317,7 +2070,7 @@ "get": { "operationId": "listTableWorkflowGroups", "summary": "List Workflow Groups", - "description": "The table\u2019s workflow and enrichment groups \u2014 the units the run endpoints dispatch. Read-only: groups are authored in the workflow builder. Bounded per table, so this is a single full page and `nextCursor` is always null.", + "description": "The table’s workflow and enrichment groups — the units the run endpoints dispatch. Read-only: groups are authored in the workflow builder. Bounded per table, so this is a single full page and `nextCursor` is always null.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2337,7 +2090,7 @@ ], "responses": { "200": { - "description": "The table\u2019s workflow groups.", + "description": "The table’s workflow groups.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -2403,7 +2156,7 @@ "post": { "operationId": "addTableWorkflowGroup", "summary": "Add Workflow Group", - "description": "Bind a workflow or enrichment to the table and create the columns its runs populate, in one call.\n\nThe group is the unit that fills columns \u2014 one group can feed several. `group.outputs[].columnName` says where each value lands; `outputColumns` defines the columns to create. Every `outputColumns` entry must be named by an output, or the request is rejected rather than creating a column nothing feeds.\n\n`autoRun` defaults to **false**: enabling it backfills every existing row, which on an API key is a metered fan-out from a single call.", + "description": "Bind a workflow or enrichment to the table and create the columns its runs populate, in one call.\n\nThe group is the unit that fills columns — one group can feed several. `group.outputs[].columnName` says where each value lands; `outputColumns` defines the columns to create. Every `outputColumns` entry must be named by an output, or the request is rejected rather than creating a column nothing feeds.\n\n`autoRun` defaults to **false**: enabling it backfills every existing row, which on an API key is a metered fan-out from a single call.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2772,7 +2525,7 @@ "post": { "operationId": "runTableColumns", "summary": "Run Column Groups", - "description": "Run one or more workflow or enrichment groups and write their outputs into the table.\n\n**Asynchronous.** The response acknowledges the dispatch, not the results: the runner walks the scoped rows and writes cells as runs land. Poll `POST /api/v2/tables/{tableId}/query` for results, and stop an in-flight run with the same group ids and an empty scope.\n\nScope with `rowIds` (an explicit set) or `filter` (every matching row, walked in pages so no id list is materialized) \u2014 never both. Omit both to run every row. Starting a run clears the target groups\u2019 cells to pending, so a read taken immediately after will show them empty.", + "description": "Run one or more workflow or enrichment groups and write their outputs into the table.\n\n**Asynchronous.** The response acknowledges the dispatch, not the results: the runner walks the scoped rows and writes cells as runs land. Poll `POST /api/v2/tables/{tableId}/query` for results, and stop an in-flight run with the same group ids and an empty scope.\n\nScope with `rowIds` (an explicit set) or `filter` (every matching row, walked in pages so no id list is materialized) — never both. Omit both to run every row. Starting a run clears the target groups’ cells to pending, so a read taken immediately after will show them empty.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2882,7 +2635,7 @@ "post": { "operationId": "runRowEnrichment", "summary": "Run Enrichment For One Row", - "description": "The single-cell case of `POST /api/v2/tables/{tableId}/columns/run`: runs one group for one row. Naming a specific cell is an explicit re-run request, so an already-populated cell recomputes rather than being skipped.\n\n**Asynchronous** \u2014 the response acknowledges the dispatch; read the row back for the result.", + "description": "The single-cell case of `POST /api/v2/tables/{tableId}/columns/run`: runs one group for one row. Naming a specific cell is an explicit re-run request, so an already-populated cell recomputes rather than being skipped.\n\n**Asynchronous** — the response acknowledges the dispatch; read the row back for the result.", "tags": ["Tables"], "x-codeSamples": [ { @@ -2971,7 +2724,7 @@ "post": { "operationId": "findTableRows", "summary": "Find Rows", - "description": "Case-insensitive substring search across every cell, narrowed by the same predicate and sort grammar as `POST /api/v2/tables/{tableId}/query`. Select cells match on their option names.\n\nReturns matching **cells**, not rows. Each match carries the row\u2019s ordinal in exactly the view a query with the same `predicate` and `sort` returns, so a caller can page straight to it. Matches are capped server-side and have no cursor \u2014 when `truncated` is true, narrow the predicate rather than paging.", + "description": "Case-insensitive substring search across every cell, narrowed by the same predicate and sort grammar as `POST /api/v2/tables/{tableId}/query`. Select cells match on their option names.\n\nReturns matching **cells**, not rows. Each match carries the row’s ordinal in exactly the view a query with the same `predicate` and `sort` returns, so a caller can page straight to it. Matches are capped server-side and have no cursor — when `truncated` is true, narrow the predicate rather than paging.", "tags": ["Tables"], "x-codeSamples": [ { @@ -3086,7 +2839,7 @@ "x-removed-get": { "operationId": "listTableJobs", "summary": "List Export Jobs", - "description": "Export jobs across a workspace \u2014 running ones plus recently finished ones, so a completed export stays re-downloadable. Poll this after `POST /export-async`, then fetch the file from `GET /export/download` once a job reports `ready`.", + "description": "Export jobs across a workspace — running ones plus recently finished ones, so a completed export stays re-downloadable. Poll this after `POST /export-async`, then fetch the file from `GET /export/download` once a job reports `ready`.", "tags": ["Tables"], "x-codeSamples": [ { @@ -3106,7 +2859,7 @@ ], "responses": { "200": { - "description": "The workspace\u2019s export jobs.", + "description": "The workspace’s export jobs.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -3163,7 +2916,7 @@ "x-removed-post": { "operationId": "importTableCsvAsync", "summary": "Import CSV (Background)", - "description": "Start a background import of a file already uploaded to workspace storage \u2014 the path for files too large for the synchronous import.\n\nReturns as soon as the job is queued. Track it on the table itself \u2014 `GET /api/v2/tables/{tableId}` returns a `job` object with `status`, `rowsProcessed` and `error` while the import runs \u2014 and stop it with `POST /job/cancel`. (`GET /api/v2/tables/jobs` lists exports only: those run concurrently and are not derived onto the table.) `fileKey` must sit under this workspace\u2019s storage prefix. The table\u2019s lock flags are checked before the job slot is claimed, so a locked table answers 423 here rather than failing inside the worker.", + "description": "Start a background import of a file already uploaded to workspace storage — the path for files too large for the synchronous import.\n\nReturns as soon as the job is queued. Track it on the table itself — `GET /api/v2/tables/{tableId}` returns a `job` object with `status`, `rowsProcessed` and `error` while the import runs — and stop it with `POST /job/cancel`. (`GET /api/v2/tables/jobs` lists exports only: those run concurrently and are not derived onto the table.) `fileKey` must sit under this workspace’s storage prefix. The table’s lock flags are checked before the job slot is claimed, so a locked table answers 423 here rather than failing inside the worker.", "tags": ["Tables"], "x-codeSamples": [ { @@ -3418,7 +3171,7 @@ "x-removed-post": { "operationId": "cancelTableJob", "summary": "Cancel Job", - "description": "Stop an in-flight import or delete job. The worker halts at its next ownership check; work already committed (rows inserted or deleted) is left in place \u2014 there is no rollback.\n\nIdempotent: cancelling a job that already finished answers `canceled: false` rather than failing, so a client racing the worker is not an error. To stop workflow or enrichment cell runs instead, use `POST /cancel-runs`.", + "description": "Stop an in-flight import or delete job. The worker halts at its next ownership check; work already committed (rows inserted or deleted) is left in place — there is no rollback.\n\nIdempotent: cancelling a job that already finished answers `canceled: false` rather than failing, so a client racing the worker is not an error. To stop workflow or enrichment cell runs instead, use `POST /cancel-runs`.", "tags": ["Tables"], "x-codeSamples": [ { @@ -3950,7 +3703,7 @@ "post": { "operationId": "cancelTableRuns", "summary": "Cancel Column Runs", - "description": "Stop in-flight and pending workflow or enrichment cell runs \u2014 the counterpart to `POST /columns/run`, and distinct from `POST /job/cancel`, which stops an import or delete.\n\n`scope: \"all\"` cancels every running and pending cell, optionally narrowed to rows matching `filter`; `scope: \"row\"` cancels one row\u2019s cells and requires `rowId`. Cancelling clears the affected cells, so a read taken immediately after will show them empty.", + "description": "Stop in-flight and pending workflow or enrichment cell runs — the counterpart to `POST /columns/run`, and distinct from `POST /job/cancel`, which stops an import or delete.\n\n`scope: \"all\"` cancels every running and pending cell, optionally narrowed to rows matching `filter`; `scope: \"row\"` cancels one row’s cells and requires `rowId`. Cancelling clears the affected cells, so a read taken immediately after will show them empty.", "tags": ["Tables"], "x-codeSamples": [ { @@ -3981,7 +3734,7 @@ } }, "oneRow": { - "summary": "Stop one row\u2019s runs", + "summary": "Stop one row’s runs", "value": { "workspaceId": "ws_123", "scope": "row", @@ -4039,6 +3792,398 @@ } } } + }, + "/api/v2/tables/folders": { + "get": { + "operationId": "listTablesFolders", + "summary": "List Folders", + "description": "List active folders for this resource. Omit `parentPath` for the full tree, or pass a canonical path (including `/`) for immediate children only.", + "tags": ["Tables"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "parentPath", + "in": "query", + "required": false, + "description": "Canonical parent path. `/` lists root folders; omit for every folder.", + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Name search.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Sort field.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "name" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } + } + ], + "responses": { + "200": { + "description": "Folders.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TablesFolder" + } + }, + "nextCursor": { + "type": ["string", "null"] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createTablesFolder", + "summary": "Create Folder", + "description": "Create exactly one folder leaf. Its parent path must already exist.", + "tags": ["Tables"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "path"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Canonical non-root folder path." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/TablesFolder" + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "relocateTablesFolder", + "summary": "Rename or Move Folder", + "description": "Rename, move, or rename and move a folder. Descendant paths change with the folder.", + "tags": ["Tables"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "path", "destinationPath"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Current canonical non-root path." + }, + "destinationPath": { + "type": "string", + "description": "New canonical non-root path." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/TablesFolder" + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTablesFolder", + "summary": "Delete Folder", + "description": "Delete a folder. With `recursive=false`, the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", + "tags": ["Tables"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "path", + "in": "query", + "required": true, + "description": "Canonical non-root folder path.", + "schema": { + "type": "string" + } + }, + { + "name": "recursive", + "in": "query", + "required": true, + "description": "Whether to delete the subtree.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Deletion result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["path", "deleted", "deletedItems"], + "properties": { + "path": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "const": true + }, + "deletedItems": { + "type": "object", + "required": ["folders", "tables"], + "properties": { + "folders": { + "type": "integer" + }, + "tables": { + "type": "integer" + } + } + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } } }, "components": { @@ -4311,7 +4456,7 @@ }, "id": { "type": "string", - "description": "Stable column id. Server-assigned \u2014 normally omit." + "description": "Stable column id. Server-assigned — normally omit." }, "options": { "type": "array", @@ -4342,7 +4487,7 @@ "schema", "rowCount", "maxRows", - "folderId", + "folderPath", "locks", "createdAt", "updatedAt", @@ -4396,9 +4541,9 @@ "format": "date-time", "description": "ISO 8601 timestamp when the table was last modified." }, - "folderId": { - "type": ["string", "null"], - "description": "Folder holding the table, or null when it sits at the workspace root." + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." }, "locks": { "$ref": "#/components/schemas/TableLocks" @@ -4502,9 +4647,9 @@ } } }, - "folderId": { - "type": ["string", "null"], - "description": "Folder to create the table in. Omitted or null creates it at the workspace root." + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." } } }, @@ -4826,7 +4971,7 @@ }, "DeleteTableEnvelope": { "type": "object", - "description": "Confirmation that a table was archived.", + "description": "Confirmation that a table was deleted.", "required": ["data"], "properties": { "data": { @@ -4835,7 +4980,7 @@ "properties": { "id": { "type": "string", - "description": "The id of the archived table." + "description": "The id of the deleted table." } } } @@ -5032,7 +5177,7 @@ } }, "Predicate": { - "description": "A predicate tree: exactly one of `all` (every member must match) or `any` (at least one must). Members are conditions or nested groups; nesting expresses mixed AND/OR logic. Groups must be non-empty (1\u2013100 members), trees at most 10 levels deep and 500 nodes total. Nodes are STRICT: unknown keys, or a node carrying both a group key and condition keys, are rejected rather than ignored.", + "description": "A predicate tree: exactly one of `all` (every member must match) or `any` (at least one must). Members are conditions or nested groups; nesting expresses mixed AND/OR logic. Groups must be non-empty (1–100 members), trees at most 10 levels deep and 500 nodes total. Nodes are STRICT: unknown keys, or a node carrying both a group key and condition keys, are rejected rather than ignored.", "oneOf": [ { "type": "object", @@ -5084,7 +5229,7 @@ "field": { "type": "string", "maxLength": 128, - "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase \u2014 snake_case is treated as a user column and matches nothing)." + "description": "Column name, or a built-in: `id`, `createdAt`, `updatedAt` (camelCase — snake_case is treated as a user column and matches nothing)." }, "op": { "enum": [ @@ -5109,7 +5254,7 @@ "isNull", "isNotNull" ], - "description": "`eq`/`ne`/`in`/`nin` are case-sensitive equality/membership. `contains`/`ncontains`/`startsWith`/`endsWith` are case-insensitive text matches \u2014 except on a multi-select column, where `contains`/`ncontains` mean set membership by option name. `like`/`nlike` are case-sensitive and `ilike`/`nilike` case-insensitive patterns with `*` as the only wildcard (literal `%`/`_` match themselves). `isEmpty`/`isNotEmpty` treat null and empty string as empty; `isNull`/`isNotNull` are strict null checks. The four `is*` operators take no `value`. Negated text matches retain rows where the cell is absent. `in`/`nin` require a non-empty array of at most 1000 values; other value-taking operators reject arrays. Select columns accept only equality/membership operators appropriate to their cardinality (single: eq/ne/in/nin; multi: contains/ncontains; both: the `is*` checks)." + "description": "`eq`/`ne`/`in`/`nin` are case-sensitive equality/membership. `contains`/`ncontains`/`startsWith`/`endsWith` are case-insensitive text matches — except on a multi-select column, where `contains`/`ncontains` mean set membership by option name. `like`/`nlike` are case-sensitive and `ilike`/`nilike` case-insensitive patterns with `*` as the only wildcard (literal `%`/`_` match themselves). `isEmpty`/`isNotEmpty` treat null and empty string as empty; `isNull`/`isNotNull` are strict null checks. The four `is*` operators take no `value`. Negated text matches retain rows where the cell is absent. `in`/`nin` require a non-empty array of at most 1000 values; other value-taking operators reject arrays. Select columns accept only equality/membership operators appropriate to their cardinality (single: eq/ne/in/nin; multi: contains/ncontains; both: the `is*` checks)." }, "value": { "description": "Operand. Omit for the `is*` operators. Ranges on `number` columns require numbers, on `date` columns ISO strings (compared as UTC, independent of any session timezone); ranges on `boolean`/`json` columns are rejected." @@ -5122,7 +5267,7 @@ "properties": { "id": { "type": "string", - "description": "Stable option id \u2014 the value stored in cells." + "description": "Stable option id — the value stored in cells." }, "name": { "type": "string", @@ -5169,9 +5314,9 @@ "minLength": 1, "description": "New table name." }, - "folderId": { - "type": ["string", "null"], - "description": "Folder to move the table into. Pass null to move it to the workspace root; omit to leave the placement untouched." + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." } }, "additionalProperties": false @@ -5207,7 +5352,7 @@ }, "ViewConfig": { "type": "object", - "description": "A view\u2019s saved preset: the row predicate and sort plus the column layout. Column references are stable column ids, so renaming a column never invalidates a view.", + "description": "A view’s saved preset: the row predicate and sort plus the column layout. Column references are stable column ids, so renaming a column never invalidates a view.", "properties": { "columnWidths": { "type": "object", @@ -5236,7 +5381,7 @@ "items": { "type": "string" }, - "description": "Column ids hidden by the view. A deny-list \u2014 a column added later is visible by default. Hiding is presentation only; the data is untouched and reappears intact when unhidden." + "description": "Column ids hidden by the view. A deny-list — a column added later is visible by default. Hiding is presentation only; the data is untouched and reappears intact when unhidden." }, "filter": { "$ref": "#/components/schemas/Predicate" @@ -5248,7 +5393,7 @@ }, "View": { "type": "object", - "description": "A saved view: a named preset of filter, sort, and column layout over a table. Presentation only \u2014 a view narrows what a reader sees by default, it is never an access boundary, and every row it hides stays reachable by reading the table without it.", + "description": "A saved view: a named preset of filter, sort, and column layout over a table. Presentation only — a view narrows what a reader sees by default, it is never an access boundary, and every row it hides stays reachable by reading the table without it.", "required": [ "id", "tableId", @@ -5278,7 +5423,7 @@ }, "isDefault": { "type": "boolean", - "description": "Whether this view is the table\u2019s default. At most one view per table is." + "description": "Whether this view is the table’s default. At most one view per table is." }, "createdBy": { "type": ["string", "null"], @@ -5347,7 +5492,7 @@ }, "isDefault": { "type": "boolean", - "description": "Promote this view to the table\u2019s default. Setting it demotes the table\u2019s existing default in the same transaction." + "description": "Promote this view to the table’s default. Setting it demotes the table’s existing default in the same transaction." } } }, @@ -5380,7 +5525,7 @@ }, "nextCursor": { "type": ["string", "null"], - "description": "Always null \u2014 a table carries a bounded set of views, so the list is a single full page." + "description": "Always null — a table carries a bounded set of views, so the list is a single full page." } } }, @@ -5408,7 +5553,7 @@ "properties": { "id": { "type": "string", - "description": "Group id \u2014 pass to the run endpoints." + "description": "Group id — pass to the run endpoints." }, "workflowId": { "type": "string", @@ -5503,13 +5648,13 @@ }, "nextCursor": { "type": ["string", "null"], - "description": "Always null \u2014 groups are bounded per table, so the list is a single full page." + "description": "Always null — groups are bounded per table, so the list is a single full page." } } }, "RunColumnBody": { "type": "object", - "description": "Run one or more groups. Scope with `rowIds` (an explicit set) or `filter` (every matching row) \u2014 never both; omit both to run every row. `excludeRowIds` applies only to the filter scope.", + "description": "Run one or more groups. Scope with `rowIds` (an explicit set) or `filter` (every matching row) — never both; omit both to run every row. `excludeRowIds` applies only to the filter scope.", "required": ["workspaceId", "groupIds"], "properties": { "workspaceId": { @@ -5616,7 +5761,7 @@ "properties": { "ordinal": { "type": "integer", - "description": "The row\u2019s 0-based index in the same predicate-filtered, sorted view that `POST /api/v2/tables/{tableId}/query` returns for these arguments \u2014 use it to page straight to the match." + "description": "The row’s 0-based index in the same predicate-filtered, sorted view that `POST /api/v2/tables/{tableId}/query` returns for these arguments — use it to page straight to the match." }, "rowId": { "type": "string", @@ -5645,7 +5790,7 @@ }, "truncated": { "type": "boolean", - "description": "True when the search hit the server-side cap and more cells match than were returned. Matches have no cursor \u2014 narrow the predicate instead of paging." + "description": "True when the search hit the server-side cap and more cells match than were returned. Matches have no cursor — narrow the predicate instead of paging." } } } @@ -5665,7 +5810,7 @@ }, "importId": { "type": "string", - "description": "Job id \u2014 pass to `POST /job/cancel` to stop the import." + "description": "Job id — pass to `POST /job/cancel` to stop the import." } } } @@ -5684,7 +5829,7 @@ "fileKey": { "type": "string", "minLength": 1, - "description": "Storage key of the uploaded file. Must sit under this workspace\u2019s `workspace/{workspaceId}/` prefix.", + "description": "Storage key of the uploaded file. Must sit under this workspace’s `workspace/{workspaceId}/` prefix.", "example": "workspace/ws_123/imports/contacts.csv" }, "fileName": { @@ -5698,7 +5843,7 @@ }, "mapping": { "type": "object", - "description": "CSV header \u2192 column name, or null to skip the header.", + "description": "CSV header → column name, or null to skip the header.", "additionalProperties": { "type": ["string", "null"] } @@ -5748,7 +5893,7 @@ }, "jobId": { "type": "string", - "description": "Job id \u2014 poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download`." + "description": "Job id — poll `GET /api/v2/tables/jobs`, then fetch the file from `GET /export/download`." } } } @@ -5765,7 +5910,7 @@ "properties": { "url": { "type": "string", - "description": "Presigned URL. Expires shortly after issue \u2014 fetch it promptly." + "description": "Presigned URL. Expires shortly after issue — fetch it promptly." }, "fileName": { "type": "string", @@ -5832,7 +5977,7 @@ }, "nextCursor": { "type": ["string", "null"], - "description": "Always null \u2014 the listing is bounded server-side to a single page." + "description": "Always null — the listing is bounded server-side to a single page." } } }, @@ -5867,7 +6012,7 @@ }, "canceled": { "type": "boolean", - "description": "False when the job had already finished. Cancelling is idempotent \u2014 a late request is not an error." + "description": "False when the job had already finished. Cancelling is idempotent — a late request is not an error." } } } @@ -5885,7 +6030,7 @@ }, "scope": { "enum": ["all", "row"], - "description": "`all` cancels every running and pending cell; `row` cancels one row\u2019s cells." + "description": "`all` cancels every running and pending cell; `row` cancels one row’s cells." }, "rowId": { "type": "string", @@ -5941,7 +6086,7 @@ }, "rowsProcessed": { "type": "integer", - "description": "Rows handled so far \u2014 progress for a running job." + "description": "Rows handled so far — progress for a running job." }, "error": { "type": ["string", "null"], @@ -5951,7 +6096,7 @@ }, "WorkflowGroupOutputColumnInput": { "type": "object", - "description": "A column the group's runs will populate. `workflowGroupId` is NOT accepted \u2014 the server stamps it from the group being written.", + "description": "A column the group's runs will populate. `workflowGroupId` is NOT accepted — the server stamps it from the group being written.", "required": ["name", "type"], "additionalProperties": false, "properties": { @@ -5985,7 +6130,7 @@ }, "group": { "type": "object", - "description": "The binding. `id` is optional and server-generated. Supply `workflowId` when `type` is `manual` (the default), or `enrichmentId` when it is `enrichment` \u2014 the mismatch is a 400.", + "description": "The binding. `id` is optional and server-generated. Supply `workflowId` when `type` is `manual` (the default), or `enrichmentId` when it is `enrichment` — the mismatch is a 400.", "required": ["outputs"], "properties": { "id": { @@ -6009,7 +6154,7 @@ "type": "string", "enum": ["manual", "enrichment"], "default": "manual", - "description": "`manual` means workflow-backed \u2014 not hand-entered." + "description": "`manual` means workflow-backed — not hand-entered." }, "dependencies": { "type": "object", @@ -6050,13 +6195,13 @@ "autoRun": { "type": "boolean", "default": false, - "description": "Backfill every existing row on creation. Defaults to **false** here (the first-party surface defaults true) \u2014 on an API key this fans out a metered run per row. Prefer POST /columns/run." + "description": "Backfill every existing row on creation. Defaults to **false** here (the first-party surface defaults true) — on an API key this fans out a metered run per row. Prefer POST /columns/run." } } }, "UpdateWorkflowGroupBody": { "type": "object", - "description": "Restructure a group. Omitted fields keep their stored values.\n\n**Removing an output deletes that column and its values** \u2014 the same behavior as DELETE /columns on a bound column. There is no detach.", + "description": "Restructure a group. Omitted fields keep their stored values.\n\n**Removing an output deletes that column and its values** — the same behavior as DELETE /columns on a bound column. There is no detach.", "required": ["workspaceId", "groupId"], "additionalProperties": false, "properties": { @@ -6179,6 +6324,32 @@ } } } + }, + "TablesFolder": { + "type": "object", + "required": ["name", "path", "parentPath", "createdAt", "updatedAt"], + "properties": { + "name": { + "type": "string", + "description": "Folder name." + }, + "path": { + "type": "string", + "description": "Canonical folder path. This is the public folder identifier." + }, + "parentPath": { + "type": "string", + "description": "Canonical parent path; `/` is the root." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } } }, "responses": { @@ -6302,7 +6473,7 @@ } }, "Conflict": { - "description": "The request conflicts with the current state of the resource \u2014 for example a rename to a name another table in the workspace already uses.", + "description": "The request conflicts with the current state of the resource — for example a rename to a name another table in the workspace already uses.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 40984613cbb..59faa644642 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -51,13 +51,12 @@ "$ref": "#/components/parameters/WorkspaceId" }, { - "name": "folderId", + "name": "folderPath", "in": "query", "required": false, "description": "Filter results to only include workflows within this folder.", "schema": { - "type": "string", - "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + "type": "string" } }, { @@ -96,7 +95,11 @@ "in": "query", "required": false, "description": "Case-insensitive substring match against the workflow `name`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", - "schema": { "type": "string", "minLength": 1, "maxLength": 200 } + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, { "name": "sortBy", @@ -114,7 +117,11 @@ "in": "query", "required": false, "description": "Sort direction. The cursor is a keyset over the active sort, so it carries the sort it was minted under. Replaying a cursor after changing `sortBy` or `sortOrder` returns `400`; restart pagination without a cursor instead.", - "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" } + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } } ], "responses": { @@ -150,24 +157,6 @@ "description": "Opaque cursor for fetching the next page. `null` when there are no more results." } } - }, - "example": { - "data": [ - { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer Support Agent", - "description": "Routes incoming support tickets and drafts responses", - "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 142, - "lastRunAt": "2026-06-20T14:15:22.000Z", - "createdAt": "2026-01-10T09:00:00.000Z", - "updatedAt": "2026-06-18T16:45:00.000Z" - } - ], - "nextCursor": "eyJzb3J0T3JkZXIiOjAsImNyZWF0ZWRBdCI6IjIwMjYtMDEtMTBUMDk6MDA6MDAuMDAwWiIsImlkIjoiM2IxZjdjOTIifQ==" } } } @@ -208,24 +197,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/CreateWorkflowBody" - }, - "examples": { - "minimal": { - "summary": "At the workspace root", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "Customer Support Agent" - } - }, - "inFolder": { - "summary": "Inside a folder, with a description", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "Customer Support Agent", - "description": "Routes incoming support tickets and drafts responses", - "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" - } - } } } } @@ -254,21 +225,6 @@ "$ref": "#/components/schemas/WorkflowListItem" } } - }, - "example": { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer Support Agent", - "description": "Routes incoming support tickets and drafts responses", - "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": false, - "deployedAt": null, - "runCount": 0, - "lastRunAt": null, - "createdAt": "2026-06-29T21:30:00.000Z", - "updatedAt": "2026-06-29T21:30:00.000Z" - } } } } @@ -347,36 +303,6 @@ "$ref": "#/components/schemas/WorkflowDetail" } } - }, - "example": { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer Support Agent", - "description": "Routes incoming support tickets and drafts responses", - "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 142, - "lastRunAt": "2026-06-20T14:15:22.000Z", - "variables": { - "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": { - "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60", - "name": "supportEmail", - "type": "string", - "value": "support@example.com" - } - }, - "inputs": [ - { - "name": "ticketBody", - "type": "string", - "description": "The raw text of the incoming support ticket." - } - ], - "createdAt": "2026-01-10T09:00:00.000Z", - "updatedAt": "2026-06-18T16:45:00.000Z" - } } } } @@ -419,20 +345,6 @@ "application/json": { "schema": { "$ref": "#/components/schemas/UpdateWorkflowBody" - }, - "examples": { - "rename": { - "summary": "Rename", - "value": { - "name": "Customer Support Agent v2" - } - }, - "moveToRoot": { - "summary": "Move out of its folder to the workspace root", - "value": { - "folderId": null - } - } } } } @@ -461,21 +373,6 @@ "$ref": "#/components/schemas/WorkflowListItem" } } - }, - "example": { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer Support Agent v2", - "description": "Routes incoming support tickets and drafts responses", - "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": false, - "deployedAt": null, - "runCount": 0, - "lastRunAt": null, - "createdAt": "2026-06-29T21:30:00.000Z", - "updatedAt": "2026-06-30T08:12:00.000Z" - } } } } @@ -513,7 +410,7 @@ "delete": { "operationId": "deleteWorkflowV2", "summary": "Delete Workflow", - "description": "Archive a workflow. The workflow moves to Recently Deleted rather than being dropped, so its execution logs stay attributable, and it stops being returned by the list and detail endpoints. The last remaining workflow in a workspace cannot be deleted (400).", + "description": "Delete a workflow. It stops being returned by list and detail endpoints while its execution logs remain attributable. The last remaining workflow in a workspace cannot be deleted (400).", "tags": ["Workflows"], "x-codeSamples": [ { @@ -530,7 +427,7 @@ ], "responses": { "200": { - "description": "The workflow was archived.", + "description": "The workflow was deleted.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -552,12 +449,6 @@ "$ref": "#/components/schemas/DeleteWorkflowResult" } } - }, - "example": { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "deleted": true - } } } } @@ -1178,9 +1069,6 @@ } } }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, "400": { "$ref": "#/components/responses/BadRequest" }, @@ -1193,12 +1081,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, "409": { "description": "A workflow with the same name already exists and deduplication failed.", "content": { @@ -1209,8 +1091,17 @@ } } }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" } } } @@ -1431,12 +1322,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, "409": { "description": "The `X-Execution-Id` was already used.", "content": { @@ -1450,6 +1335,12 @@ "413": { "description": "Request body exceeds the 10 MB limit." }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, "503": { "description": "Execution infrastructure temporarily unavailable.", "content": { @@ -1766,6 +1657,398 @@ } } } + }, + "/api/v2/workflows/folders": { + "get": { + "operationId": "listWorkflowsFolders", + "summary": "List Folders", + "description": "List active folders for this resource. Omit `parentPath` for the full tree, or pass a canonical path (including `/`) for immediate children only.", + "tags": ["Workflows"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "parentPath", + "in": "query", + "required": false, + "description": "Canonical parent path. `/` lists root folders; omit for every folder.", + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Name search.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Sort field.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "name" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" + } + } + ], + "responses": { + "200": { + "description": "Folders.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowsFolder" + } + }, + "nextCursor": { + "type": ["string", "null"] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/BadRequest" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createWorkflowsFolder", + "summary": "Create Folder", + "description": "Create exactly one folder leaf. Its parent path must already exist.", + "tags": ["Workflows"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "path"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Canonical non-root folder path." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/WorkflowsFolder" + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/BadRequest" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "relocateWorkflowsFolder", + "summary": "Rename or Move Folder", + "description": "Rename, move, or rename and move a folder. Descendant paths change with the folder.", + "tags": ["Workflows"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "path", "destinationPath"], + "properties": { + "workspaceId": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Current canonical non-root path." + }, + "destinationPath": { + "type": "string", + "description": "New canonical non-root path." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["folder"], + "properties": { + "folder": { + "$ref": "#/components/schemas/WorkflowsFolder" + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/BadRequest" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteWorkflowsFolder", + "summary": "Delete Folder", + "description": "Delete a folder. With `recursive=false`, the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", + "tags": ["Workflows"], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "path", + "in": "query", + "required": true, + "description": "Canonical non-root folder path.", + "schema": { + "type": "string" + } + }, + { + "name": "recursive", + "in": "query", + "required": true, + "description": "Whether to delete the subtree.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Deletion result.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["path", "deleted", "deletedItems"], + "properties": { + "path": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "const": true + }, + "deletedItems": { + "type": "object", + "required": ["folders", "workflows"], + "properties": { + "folders": { + "type": "integer" + }, + "workflows": { + "type": "integer" + } + } + } + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/BadRequest" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } } }, "components": { @@ -1880,7 +2163,7 @@ "id", "name", "description", - "folderId", + "folderPath", "workspaceId", "isDeployed", "deployedAt", @@ -1906,11 +2189,10 @@ "description": "Optional description of what the workflow does. `null` when unset.", "example": "Routes incoming support tickets and drafts responses" }, - "folderId": { + "folderPath": { "type": "string", - "nullable": true, - "description": "The folder this workflow belongs to. `null` when at the workspace root.", - "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + "description": "Canonical containing-folder path. `/` is the workspace root.", + "example": "/Engineering" }, "workspaceId": { "type": "string", @@ -1984,7 +2266,7 @@ "id", "name", "description", - "folderId", + "folderPath", "workspaceId", "isDeployed", "deployedAt", @@ -2131,7 +2413,7 @@ }, "workflow": { "type": "object", - "required": ["id", "name", "description", "workspaceId", "folderId"], + "required": ["id", "name", "description", "workspaceId", "folderPath"], "properties": { "id": { "type": "string" @@ -2145,8 +2427,9 @@ "workspaceId": { "type": ["string", "null"] }, - "folderId": { - "type": ["string", "null"] + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." } } }, @@ -2165,10 +2448,10 @@ "type": "string", "minLength": 1 }, - "folderId": { + "folderPath": { "type": "string", "minLength": 1, - "description": "Target folder; must belong to the workspace and be unlocked." + "description": "Canonical containing-folder path. `/` is the workspace root." }, "name": { "type": "string", @@ -2203,7 +2486,7 @@ "name", "description", "workspaceId", - "folderId", + "folderPath", "createdAt", "updatedAt" ], @@ -2221,8 +2504,9 @@ "workspaceId": { "type": "string" }, - "folderId": { - "type": ["string", "null"] + "folderPath": { + "type": "string", + "description": "Canonical containing-folder path. `/` is the workspace root." }, "createdAt": { "type": "string", @@ -2331,12 +2615,11 @@ "description": "Optional description of what the workflow does.", "example": "Routes incoming support tickets and drafts responses" }, - "folderId": { + "folderPath": { "type": "string", "minLength": 1, - "nullable": true, - "description": "Folder to create the workflow in. Omit or send `null` to create it at the workspace root.", - "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + "description": "Canonical containing-folder path. `/` is the workspace root.", + "example": "/Engineering" } } }, @@ -2360,29 +2643,28 @@ "description": "New description. Send `null` to clear it.", "example": "Routes incoming support tickets and drafts responses" }, - "folderId": { + "folderPath": { "type": "string", "minLength": 1, - "nullable": true, - "description": "Destination folder. Send `null` to move the workflow to the workspace root.", - "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + "description": "Canonical containing-folder path. `/` is the workspace root.", + "example": "/Engineering" } } }, "DeleteWorkflowResult": { "type": "object", - "description": "Acknowledgement that a workflow was archived.", + "description": "Acknowledgement that a workflow was deleted.", "required": ["id", "deleted"], "properties": { "id": { "type": "string", - "description": "The archived workflow's identifier.", + "description": "The deleted workflow's identifier.", "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "deleted": { "type": "boolean", "enum": [true], - "description": "Always `true` on a successful archive." + "description": "Always `true` on a successful delete." } } }, @@ -2483,6 +2765,36 @@ "description": "The deployed workflow graph snapshot (blocks, edges, loops, parallels). This is the state that executes while the version is active, and the state a rollback restores." } } + }, + "WorkflowsFolder": { + "type": "object", + "required": ["name", "path", "parentPath", "locked", "createdAt", "updatedAt"], + "properties": { + "name": { + "type": "string", + "description": "Folder name." + }, + "path": { + "type": "string", + "description": "Canonical folder path. This is the public folder identifier." + }, + "parentPath": { + "type": "string", + "description": "Canonical parent path; `/` is the root." + }, + "locked": { + "type": "boolean", + "description": "Whether this workflow folder is locked." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } } }, "responses": { diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 9cd730013ae..0ffbe540e89 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -13,8 +13,8 @@ import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { DbOrTx } from '@/lib/db/types' -import { nextFolderSortOrder } from '@/lib/folders/lifecycle' import { deduplicateFolderName } from '@/lib/folders/naming' +import { nextFolderSortOrder } from '@/lib/folders/orchestration' import { toFolderApi } from '@/lib/folders/queries' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' diff --git a/apps/sim/app/api/folders/[id]/restore/route.ts b/apps/sim/app/api/folders/[id]/restore/route.ts index 0022d3e8c8c..f67b0f38a2c 100644 --- a/apps/sim/app/api/folders/[id]/restore/route.ts +++ b/apps/sim/app/api/folders/[id]/restore/route.ts @@ -5,7 +5,7 @@ import { restoreFolderContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { restoreFolder } from '@/lib/folders/lifecycle' +import { restoreFolder } from '@/lib/folders/orchestration' import { folderMutationStatus } from '@/lib/folders/status' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index 25db5804c3c..035223d827a 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -8,8 +8,8 @@ import { authMockFns, createMockRequest, dbChainMockFns, - foldersLifecycleMock, - foldersLifecycleMockFns, + foldersOrchestrationMock, + foldersOrchestrationMockFns, type MockUser, permissionsMock, permissionsMockFns, @@ -34,8 +34,8 @@ const { mockLogger } = vi.hoisted(() => { } }) -const mockDeleteFolder = foldersLifecycleMockFns.mockDeleteFolder -const mockUpdateFolder = foldersLifecycleMockFns.mockUpdateFolder +const mockDeleteFolder = foldersOrchestrationMockFns.mockDeleteFolder +const mockUpdateFolder = foldersOrchestrationMockFns.mockUpdateFolder /** Parent ids the mocked engine treats as closing a cycle for the folder under test. */ const cyclicParentIds = new Set() @@ -49,7 +49,7 @@ vi.mock('@sim/logger', () => ({ getRequestContext: () => undefined, })) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) -vi.mock('@/lib/folders/lifecycle', () => foldersLifecycleMock) +vi.mock('@/lib/folders/orchestration', () => foldersOrchestrationMock) import { DELETE, PUT } from '@/app/api/folders/[id]/route' diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 176e43a9aba..682f9f40497 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -10,7 +10,7 @@ import { getSession } from '@/lib/auth' import { HttpError } from '@/lib/core/utils/http-error' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { folderResourceConfig } from '@/lib/folders/config' -import { deleteFolder, updateFolder } from '@/lib/folders/lifecycle' +import { deleteFolder, updateFolder } from '@/lib/folders/orchestration' import { toFolderApi } from '@/lib/folders/queries' import { folderMutationStatus } from '@/lib/folders/status' import { captureServerEvent } from '@/lib/posthog/server' diff --git a/apps/sim/app/api/folders/reorder/route.test.ts b/apps/sim/app/api/folders/reorder/route.test.ts index 869aaa89f73..c9803b66fbb 100644 --- a/apps/sim/app/api/folders/reorder/route.test.ts +++ b/apps/sim/app/api/folders/reorder/route.test.ts @@ -31,21 +31,30 @@ describe('PUT /api/folders/reorder', () => { const mockFrom = vi.fn() const mockWhere = vi.fn() const mockTxUpdate = vi.fn() + const mockTxExecute = vi.fn() beforeEach(() => { vi.clearAllMocks() + mockFrom.mockReset() + mockWhere.mockReset() + mockTxUpdate.mockReset() + mockTxExecute.mockReset() + mockDb.transaction.mockReset() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) mockGetUserEntityPermissions.mockResolvedValue('admin') - mockDb.select.mockReturnValue({ from: mockFrom }) mockFrom.mockReturnValue({ where: mockWhere }) mockTxUpdate.mockReturnValue({ set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), }) mockDb.transaction.mockImplementation(async (cb: (tx: unknown) => Promise) => - cb({ update: mockTxUpdate }) + cb({ + execute: mockTxExecute, + select: vi.fn().mockReturnValue({ from: mockFrom }), + update: mockTxUpdate, + }) ) }) @@ -76,8 +85,8 @@ describe('PUT /api/folders/reorder', () => { ]) const uniqueViolation = Object.assign(new Error('duplicate key value'), { code: '23505' }) - mockDb.transaction.mockImplementationOnce(async () => { - throw uniqueViolation + mockTxUpdate.mockReturnValueOnce({ + set: vi.fn().mockReturnValue({ where: vi.fn().mockRejectedValue(uniqueViolation) }), }) const req = createMockRequest('PUT', { @@ -88,6 +97,7 @@ describe('PUT /api/folders/reorder', () => { const response = await PUT(req) + expect(mockTxUpdate).toHaveBeenCalled() expect(response.status).toBe(409) const data = await response.json() expect(data.error).toBe('A folder with this name already exists in this location') @@ -108,7 +118,7 @@ describe('PUT /api/folders/reorder', () => { expect(response.status).toBe(400) const data = await response.json() expect(data.error).toBe('Parent folder not found') - expect(mockDb.transaction).not.toHaveBeenCalled() + expect(mockTxUpdate).not.toHaveBeenCalled() }) it('rejects a batch that would form a cycle', async () => { @@ -139,6 +149,6 @@ describe('PUT /api/folders/reorder', () => { expect(response.status).toBe(400) const data = await response.json() expect(data.error).toBe('Cannot create circular folder reference') - expect(mockDb.transaction).not.toHaveBeenCalled() + expect(mockTxUpdate).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index b27dbdabe37..dd644402927 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -1,4 +1,3 @@ -import { db } from '@sim/db' import { folder as folderTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' @@ -10,7 +9,9 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { withTransactionRetry } from '@/lib/db/transaction' import { folderResourceConfig } from '@/lib/folders/config' +import { acquireFolderMutationLock } from '@/lib/folders/locks' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FolderReorderAPI') @@ -37,136 +38,146 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'Write access required' }, { status: 403 }) } - const folderIds = updates.map((u) => u.id) - /** - * Archived folders are excluded here for the same reason `PUT /api/folders/[id]` excludes - * them: `getFolderLockStatus` skips archived rows, so `assertFolderMutable` below is a - * guaranteed no-op on one — meaning a locked folder becomes freely reparentable the moment - * its parent is deleted. Reordering an archived folder is also a correctness problem in its - * own right: `collectArchivedSubtreeIds` walks the cascade by parent, so moving a branch out - * of an archived subtree silently drops it from that folder's restore. - */ - const existingFolders = await db - .select({ id: folderTable.id, workspaceId: folderTable.workspaceId }) - .from(folderTable) - .where( - and( - inArray(folderTable.id, folderIds), - eq(folderTable.resourceType, resourceType), - isNull(folderTable.deletedAt) + return await withTransactionRetry( + async (tx) => { + await acquireFolderMutationLock(tx, workspaceId, resourceType) + const folderIds = updates.map((u) => u.id) + /** + * Archived folders are excluded here for the same reason `PUT /api/folders/[id]` + * excludes them: lock resolution skips archived rows, so an archived-but-locked + * folder would otherwise become mutable while its cascade is still recoverable. + */ + const existingFolders = await tx + .select({ id: folderTable.id, workspaceId: folderTable.workspaceId }) + .from(folderTable) + .where( + and( + inArray(folderTable.id, folderIds), + eq(folderTable.resourceType, resourceType), + isNull(folderTable.deletedAt) + ) + ) + + const validIds = new Set( + existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id) ) - ) + const validUpdates = updates.filter((u) => validIds.has(u.id)) - const validIds = new Set( - existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id) - ) + if (validUpdates.length === 0) { + return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 }) + } - const validUpdates = updates.filter((u) => validIds.has(u.id)) + const targetParentIds = Array.from( + new Set(validUpdates.map((u) => u.parentId).filter((id): id is string => Boolean(id))) + ) - if (validUpdates.length === 0) { - return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 }) - } + if (targetParentIds.length > 0) { + const parentFolders = await tx + .select({ + id: folderTable.id, + workspaceId: folderTable.workspaceId, + archivedAt: folderTable.deletedAt, + }) + .from(folderTable) + .where( + and( + inArray(folderTable.id, targetParentIds), + eq(folderTable.resourceType, resourceType) + ) + ) - const targetParentIds = Array.from( - new Set(validUpdates.map((u) => u.parentId).filter((id): id is string => Boolean(id))) - ) + const validParentIds = new Set( + parentFolders + .filter((f) => f.workspaceId === workspaceId && !f.archivedAt) + .map((f) => f.id) + ) - if (targetParentIds.length > 0) { - const parentFolders = await db - .select({ - id: folderTable.id, - workspaceId: folderTable.workspaceId, - archivedAt: folderTable.deletedAt, - }) - .from(folderTable) - .where( - and(inArray(folderTable.id, targetParentIds), eq(folderTable.resourceType, resourceType)) - ) + for (const update of validUpdates) { + if (!update.parentId) continue + if (update.parentId === update.id) { + return NextResponse.json( + { error: 'Folder cannot be its own parent' }, + { status: 400 } + ) + } + if (!validParentIds.has(update.parentId)) { + return NextResponse.json({ error: 'Parent folder not found' }, { status: 400 }) + } + } + } - const validParentIds = new Set( - parentFolders.filter((f) => f.workspaceId === workspaceId && !f.archivedAt).map((f) => f.id) - ) + const workspaceFolders = await tx + .select({ id: folderTable.id, parentId: folderTable.parentId }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, resourceType) + ) + ) - for (const update of validUpdates) { - if (!update.parentId) continue - if (update.parentId === update.id) { - return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 }) + const parentById = new Map() + for (const folder of workspaceFolders) { + parentById.set(folder.id, folder.parentId) } - if (!validParentIds.has(update.parentId)) { - return NextResponse.json({ error: 'Parent folder not found' }, { status: 400 }) + for (const update of validUpdates) { + if (update.parentId !== undefined) { + parentById.set(update.id, update.parentId || null) + } } - } - } - - const workspaceFolders = await db - .select({ id: folderTable.id, parentId: folderTable.parentId }) - .from(folderTable) - .where( - and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, resourceType)) - ) - const parentById = new Map() - for (const folder of workspaceFolders) { - parentById.set(folder.id, folder.parentId) - } - for (const update of validUpdates) { - if (update.parentId !== undefined) { - parentById.set(update.id, update.parentId || null) - } - } - - for (const update of validUpdates) { - const visited = new Set() - let cursor: string | null = update.id - while (cursor) { - if (visited.has(cursor)) { - return NextResponse.json( - { error: 'Cannot create circular folder reference' }, - { status: 400 } - ) + for (const update of validUpdates) { + const visited = new Set() + let cursor: string | null = update.id + while (cursor) { + if (visited.has(cursor)) { + return NextResponse.json( + { error: 'Cannot create circular folder reference' }, + { status: 400 } + ) + } + visited.add(cursor) + cursor = parentById.get(cursor) ?? null + } } - visited.add(cursor) - cursor = parentById.get(cursor) ?? null - } - } - // Folder locking is a workflow-only feature; other resource types leave `locked` false. - if (folderResourceConfig(resourceType).supportsLocking) { - for (const update of validUpdates) { - await assertFolderMutable(update.id) - if (update.parentId !== undefined) { - await assertFolderMutable(update.parentId) + if (folderResourceConfig(resourceType).supportsLocking) { + for (const update of validUpdates) { + await assertFolderMutable(update.id) + if (update.parentId !== undefined) { + await assertFolderMutable(update.parentId) + } + } } - } - } - await db.transaction(async (tx) => { - for (const update of validUpdates) { - const updateData: Partial = { - sortOrder: update.sortOrder, - updatedAt: new Date(), - } - if (update.parentId !== undefined) { - updateData.parentId = update.parentId || null - } - await tx - .update(folderTable) - .set(updateData) - .where( - and( - eq(folderTable.id, update.id), - eq(folderTable.resourceType, resourceType), - isNull(folderTable.deletedAt) + for (const update of validUpdates) { + const updateData: Partial = { + sortOrder: update.sortOrder, + updatedAt: new Date(), + } + if (update.parentId !== undefined) { + updateData.parentId = update.parentId || null + } + await tx + .update(folderTable) + .set(updateData) + .where( + and( + eq(folderTable.id, update.id), + eq(folderTable.resourceType, resourceType), + isNull(folderTable.deletedAt) + ) ) - ) - } - }) + } - logger.info( - `[${requestId}] Reordered ${validUpdates.length} ${resourceType} folders in workspace ${workspaceId}` - ) + logger.info( + `[${requestId}] Reordered ${validUpdates.length} ${resourceType} folders in workspace ${workspaceId}` + ) - return NextResponse.json({ success: true, updated: validUpdates.length }) + return NextResponse.json({ success: true, updated: validUpdates.length }) + }, + { label: 'reorder-folders' } + ) } catch (error) { if (error instanceof FolderLockedError) { return NextResponse.json({ error: error.message }, { status: error.status }) diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index bc6e72de1f0..65895a1d9b7 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -55,29 +55,36 @@ interface CapturedFolderValues { function createMockTransaction(mockData: { selectResults?: Array> insertResult?: Array<{ id: string; [key: string]: unknown }> + insertError?: Error onInsertValues?: (values: CapturedFolderValues) => void }) { - const { selectResults = [[], []], insertResult = [], onInsertValues } = mockData + const { selectResults = [[], []], insertResult = [], insertError, onInsertValues } = mockData return async (callback: (tx: unknown) => Promise) => { const where = vi.fn() for (const result of selectResults) { - where.mockReturnValueOnce(result) + const withLimit = result as typeof result & { limit: ReturnType } + withLimit.limit = vi.fn().mockReturnValue(result) + where.mockReturnValueOnce(withLimit) } where.mockReturnValue([]) const tx = { + execute: vi.fn(), select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where, }), }), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockImplementation((values: CapturedFolderValues) => { - onInsertValues?.(values) - return { - returning: vi.fn().mockReturnValue(insertResult), - } - }), + insert: vi.fn().mockImplementation(() => { + if (insertError) throw insertError + return { + values: vi.fn().mockImplementation((values: CapturedFolderValues) => { + onInsertValues?.(values) + return { + returning: vi.fn().mockReturnValue(insertResult), + } + }), + } }), } return await callback(tx) @@ -160,6 +167,7 @@ describe('Folders API Route', () => { mockInsert.mockReturnValue({ values: mockValues }) mockValues.mockReturnValue({ returning: mockReturning }) mockReturning.mockReturnValue([mockFolders[0]]) + mockTransaction.mockImplementation(createMockTransaction({})) mockGetUserEntityPermissions.mockResolvedValue('admin') }) @@ -363,7 +371,7 @@ describe('Folders API Route', () => { mockTransaction.mockImplementationOnce( createMockTransaction({ - selectResults: [[], []], + selectResults: [[{ workspaceId: 'workspace-123', archivedAt: null }], [], []], insertResult: [{ ...mockFolders[1] }], }) ) @@ -530,9 +538,9 @@ describe('Folders API Route', () => { it('should handle database errors gracefully', async () => { mockAuthenticatedUser() - mockInsert.mockImplementationOnce(() => { - throw new Error('Database insert failed') - }) + mockTransaction.mockImplementationOnce( + createMockTransaction({ insertError: new Error('Database insert failed') }) + ) const req = createMockRequest('POST', { name: 'Test Folder', diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index 84eba3c8cab..1ac1c31e9f1 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { folderResourceConfig } from '@/lib/folders/config' -import { createFolder } from '@/lib/folders/lifecycle' +import { createFolder } from '@/lib/folders/orchestration' import { listFoldersForWorkspace, toFolderApi } from '@/lib/folders/queries' import { folderMutationStatus } from '@/lib/folders/status' import { captureServerEvent } from '@/lib/posthog/server' diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 93627bc4105..4182a4ea4a1 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -37,7 +37,6 @@ export type ApiEndpoint = | 'audit-logs' | 'tables' | 'table-detail' - | 'table-restore' | 'table-rows' | 'table-row-detail' | 'table-rows-find' @@ -54,7 +53,7 @@ export type ApiEndpoint = | 'file-share' | 'file-content' | 'file-move' - | 'file-bulk-archive' + | 'file-bulk-delete' | 'knowledge' | 'knowledge-detail' | 'knowledge-search' @@ -66,8 +65,6 @@ export type ApiEndpoint = | 'skill-detail' | 'custom-tools' | 'custom-tool-detail' - | 'folders' - | 'folder-detail' | 'credentials' | 'credential-detail' diff --git a/apps/sim/app/api/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[id]/route.ts index bef7e2d43ce..65a270342fa 100644 --- a/apps/sim/app/api/v2/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v2/audit-logs/[id]/route.ts @@ -9,9 +9,9 @@ import { v2GetAuditLogContract } from '@/lib/api/contracts/v2/audit-logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' -import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' import { checkRateLimit } from '@/app/api/v1/middleware' +import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' @@ -69,7 +69,7 @@ export const GET = withRouteHandler( if (!log) return v2Error('NOT_FOUND', 'Audit log not found') - return v2Data(formatAuditLogEntry(log), { rateLimit }) + return v2Data(formatV2AuditLogEntry(log), { rateLimit }) } catch (error) { logger.error(`[${requestId}] Audit log detail fetch error`, { error: getErrorMessage(error, 'Unknown error'), diff --git a/apps/sim/app/api/v2/audit-logs/format.test.ts b/apps/sim/app/api/v2/audit-logs/format.test.ts new file mode 100644 index 00000000000..cece8dd51df --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/format.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' + +describe('formatV2AuditLogEntry', () => { + it('removes internal folder identifiers recursively from the public projection', () => { + const formatted = formatV2AuditLogEntry({ + id: 'audit-1', + workspaceId: 'workspace-1', + actorId: 'user-1', + actorName: 'Teddy', + actorEmail: 'teddy@example.com', + action: 'folder.moved', + resourceType: 'folder', + resourceId: 'internal-folder-id', + resourceName: 'Reports', + description: 'Moved Reports', + metadata: { + folderId: 'internal-folder-id', + targetFolderId: 'internal-target-id', + nested: { tableImportFolderId: 'internal-import-id', path: '/Reports' }, + }, + createdAt: new Date('2024-01-01T00:00:00Z'), + }) + + expect(formatted.resourceId).toBeNull() + expect(formatted.metadata).toEqual({ nested: { path: '/Reports' } }) + }) +}) diff --git a/apps/sim/app/api/v2/audit-logs/format.ts b/apps/sim/app/api/v2/audit-logs/format.ts new file mode 100644 index 00000000000..b1e7fb88b7c --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/format.ts @@ -0,0 +1,57 @@ +import type { auditLog } from '@sim/db/schema' +import { isRecordLike } from '@sim/utils/object' +import type { InferSelectModel } from 'drizzle-orm' + +type DbAuditLog = Pick< + InferSelectModel, + | 'id' + | 'workspaceId' + | 'actorId' + | 'actorName' + | 'actorEmail' + | 'action' + | 'resourceType' + | 'resourceId' + | 'resourceName' + | 'description' + | 'metadata' + | 'createdAt' +> + +const INTERNAL_FOLDER_ID_KEYS = new Set([ + 'folderId', + 'folderIds', + 'parentId', + 'tableImportFolderId', + 'targetFolderId', +]) + +function sanitizeMetadata(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sanitizeMetadata) + if (!isRecordLike(value)) return value + + const sanitized: Record = {} + for (const [key, child] of Object.entries(value)) { + if (INTERNAL_FOLDER_ID_KEYS.has(key)) continue + sanitized[key] = sanitizeMetadata(child) + } + return sanitized +} + +/** Removes database folder identifiers from the public v2 audit projection. */ +export function formatV2AuditLogEntry(log: DbAuditLog) { + return { + id: log.id, + workspaceId: log.workspaceId, + actorId: log.actorId, + actorName: log.actorName, + actorEmail: log.actorEmail, + action: log.action, + resourceType: log.resourceType, + resourceId: log.resourceType === 'folder' ? null : log.resourceId, + resourceName: log.resourceName, + description: log.description, + metadata: sanitizeMetadata(log.metadata), + createdAt: log.createdAt.toISOString(), + } +} diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts index a264e5f47a4..32ef339a8f9 100644 --- a/apps/sim/app/api/v2/audit-logs/route.ts +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -6,7 +6,6 @@ import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' -import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { buildFilterConditions, buildOrgScopeCondition, @@ -14,6 +13,7 @@ import { queryAuditLogs, } from '@/app/api/v1/audit-logs/query' import { checkRateLimit } from '@/app/api/v1/middleware' +import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CursorList, @@ -97,7 +97,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { params.cursor ) - return v2CursorList(data.map(formatAuditLogEntry), nextCursor ?? null, { rateLimit }) + return v2CursorList(data.map(formatV2AuditLogEntry), nextCursor ?? null, { rateLimit }) } catch (error) { logger.error(`[${requestId}] Audit logs fetch error`, { error: getErrorMessage(error, 'Unknown error'), diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts index 860002a33e5..3ce03cf3cdd 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -176,8 +176,7 @@ describe('PUT /api/v2/files/[fileId]/content', () => { size: 8, type: 'text/csv', key: 'workspace/ws/1-x-data.csv', - folderId: null, - folderPath: null, + folderPath: '/', uploadedBy: 'user-1', uploadedAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-03T00:00:00.000Z', diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts index cfabd3446dd..309bb0b34c1 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts @@ -107,8 +107,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { size: 1024, type: 'text/csv', key: 'workspace/ws/1-x-data.csv', - folderId: null, - folderPath: null, + folderPath: '/', uploadedBy: 'user-1', uploadedAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-02T00:00:00.000Z', diff --git a/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts deleted file mode 100644 index 0610ef9649e..00000000000 --- a/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformRestore } = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformRestore: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) - -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performRestoreWorkspaceFile: mockPerformRestore, -})) - -import { POST } from '@/app/api/v2/files/[fileId]/restore/route' - -const WS = 'workspace-1' -const FILE_ID = 'wf_1' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} - -const callRestore = (body: unknown) => - POST( - new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/restore`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - { params: Promise.resolve({ fileId: FILE_ID }) } - ) - -describe('POST /api/v2/files/[fileId]/restore', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformRestore.mockResolvedValue({ success: true }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callRestore({ workspaceId: WS }) - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockPerformRestore).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callRestore({}) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformRestore).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - const res = await callRestore({ workspaceId: WS }) - expect(res.status).toBe(403) - expect(mockPerformRestore).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callRestore({ workspaceId: WS }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('restores the file and acknowledges', async () => { - const res = await callRestore({ workspaceId: WS }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data).toEqual({ id: FILE_ID, restored: true }) - expect(mockPerformRestore).toHaveBeenCalledWith({ - workspaceId: WS, - fileId: FILE_ID, - userId: 'user-1', - }) - }) - - it('maps a not_found errorCode to 404 rather than a blanket 500', async () => { - mockPerformRestore.mockResolvedValue({ - success: false, - error: 'File not found', - errorCode: 'not_found', - }) - - const res = await callRestore({ workspaceId: WS }) - const body = await res.json() - - expect(res.status).toBe(404) - expect(body.error.code).toBe('NOT_FOUND') - expect(body.error.message).toBe('File not found') - }) -}) diff --git a/apps/sim/app/api/v2/files/[fileId]/restore/route.ts b/apps/sim/app/api/v2/files/[fileId]/restore/route.ts deleted file mode 100644 index 0b559b9ed90..00000000000 --- a/apps/sim/app/api/v2/files/[fileId]/restore/route.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { v2RestoreFileContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performRestoreWorkspaceFile } from '@/lib/workspace-files/orchestration' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FileRestoreAPI') - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -interface FileRouteParams { - params: Promise<{ fileId: string }> -} - -/** - * POST /api/v2/files/[fileId]/restore — Restore an archived file. - * - * Find archived ids with `GET /api/v2/files?scope=archived`. A name collision - * with a live file is resolved by the manager's restore-name suffix, so the - * restored file may come back under a different name. - */ -export const POST = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2RestoreFileContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { fileId } = parsed.data.params - const { workspaceId } = parsed.data.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performRestoreWorkspaceFile({ workspaceId, fileId, userId }) - - if (!result.success) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to restore file') - ) - } - - return v2Data({ id: fileId, restored: true as const }, { rateLimit }) - } catch (error) { - logger.error('Error restoring file', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) 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 1afe6c6b97b..eb0f2b3738a 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -217,8 +217,7 @@ describe('PATCH /api/v2/files/[fileId]', () => { size: 1024, type: 'text/csv', key: 'workspace/ws/1-x-data.csv', - folderId: null, - folderPath: null, + folderPath: '/', uploadedBy: 'user-1', uploadedAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-02T00:00:00.000Z', diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index e85ccd4b923..133d5debeb7 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -129,7 +129,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: File }) /** - * DELETE /api/v2/files/[fileId] — Archive (soft delete) a file. + * DELETE /api/v2/files/[fileId] — Delete a file. * * Delegates to the shared orchestration, which is workspace-scoped and records * its own audit entry (the request is forwarded so that entry captures client @@ -171,7 +171,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil ) } - logger.info(`Archived file ${fileId} from workspace ${workspaceId}`) + logger.info(`Deleted file ${fileId} from workspace ${workspaceId}`) return v2Data({ id: fileId, deleted: true as const }, { rateLimit }) } catch (error) { diff --git a/apps/sim/app/api/v2/files/bulk-archive/route.test.ts b/apps/sim/app/api/v2/files/bulk-delete/route.test.ts similarity index 77% rename from apps/sim/app/api/v2/files/bulk-archive/route.test.ts rename to apps/sim/app/api/v2/files/bulk-delete/route.test.ts index 9d4982abc62..31795501de6 100644 --- a/apps/sim/app/api/v2/files/bulk-archive/route.test.ts +++ b/apps/sim/app/api/v2/files/bulk-delete/route.test.ts @@ -23,7 +23,7 @@ vi.mock('@/lib/workspace-files/orchestration', () => ({ performDeleteWorkspaceFileItems: mockPerformDelete, })) -import { POST } from '@/app/api/v2/files/bulk-archive/route' +import { POST } from '@/app/api/v2/files/bulk-delete/route' const WS = 'workspace-1' @@ -44,16 +44,16 @@ const RATE_LIMIT_DENIED = { retryAfterMs: 1000, } -const callArchive = (body: unknown) => +const callDelete = (body: unknown) => POST( - new NextRequest('http://localhost:3000/api/v2/files/bulk-archive', { + new NextRequest('http://localhost:3000/api/v2/files/bulk-delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) ) -describe('POST /api/v2/files/bulk-archive', () => { +describe('POST /api/v2/files/bulk-delete', () => { beforeEach(() => { vi.clearAllMocks() mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) @@ -66,14 +66,14 @@ describe('POST /api/v2/files/bulk-archive', () => { const { v2Error } = await import('@/app/api/v2/lib/response') vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - const res = await callArchive({ workspaceId: WS, fileIds: ['wf_1'] }) + const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(404) expect(mockPerformDelete).not.toHaveBeenCalled() }) it('400s when the selection is empty', async () => { - const res = await callArchive({ workspaceId: WS, fileIds: [], folderIds: [] }) + const res = await callDelete({ workspaceId: WS, fileIds: [] }) expect(res.status).toBe(400) expect((await res.json()).error.code).toBe('BAD_REQUEST') expect(mockPerformDelete).not.toHaveBeenCalled() @@ -85,29 +85,28 @@ describe('POST /api/v2/files/bulk-archive', () => { code: 'FORBIDDEN', message: 'Access denied', }) - const res = await callArchive({ workspaceId: WS, fileIds: ['wf_1'] }) + const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(403) expect(mockPerformDelete).not.toHaveBeenCalled() }) it('returns the rate-limit response when denied', async () => { mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callArchive({ workspaceId: WS, fileIds: ['wf_1'] }) + const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(429) expect((await res.json()).error.code).toBe('RATE_LIMITED') }) - it('archives the selection and reports the full cascade', async () => { - const res = await callArchive({ workspaceId: WS, fileIds: ['wf_1'], folderIds: ['fold_1'] }) + it('deletes the selection and reports the file count', async () => { + const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) const body = await res.json() expect(res.status).toBe(200) - expect(body.data).toEqual({ deletedItems: { files: 3, folders: 1 } }) + expect(body.data).toEqual({ deletedItems: { files: 3 } }) expect(mockPerformDelete).toHaveBeenCalledWith({ workspaceId: WS, userId: 'user-1', fileIds: ['wf_1'], - folderIds: ['fold_1'], request: expect.anything(), }) }) @@ -119,7 +118,7 @@ describe('POST /api/v2/files/bulk-archive', () => { errorCode: 'not_found', }) - const res = await callArchive({ workspaceId: WS, fileIds: ['wf_missing'] }) + const res = await callDelete({ workspaceId: WS, fileIds: ['wf_missing'] }) expect(res.status).toBe(404) expect((await res.json()).error.code).toBe('NOT_FOUND') diff --git a/apps/sim/app/api/v2/files/bulk-archive/route.ts b/apps/sim/app/api/v2/files/bulk-delete/route.ts similarity index 66% rename from apps/sim/app/api/v2/files/bulk-archive/route.ts rename to apps/sim/app/api/v2/files/bulk-delete/route.ts index 3f8ff8c256f..e0bd99a4b89 100644 --- a/apps/sim/app/api/v2/files/bulk-archive/route.ts +++ b/apps/sim/app/api/v2/files/bulk-delete/route.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { v2BulkArchiveFileItemsContract } from '@/lib/api/contracts/v2/files' +import { v2BulkDeleteFilesContract } from '@/lib/api/contracts/v2/files' import { parseRequest } from '@/lib/api/server' import { messageForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -17,21 +17,18 @@ import { v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2FileBulkArchiveAPI') +const logger = createLogger('V2FileBulkDeleteAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 /** - * POST /api/v2/files/bulk-archive — Archive (soft delete) files and folders. - * - * Archiving a folder cascades to its descendants; `deletedItems` reports the - * totals actually archived, which therefore exceed the selection size. Archived - * items stay listable via `scope=archived` and can be restored. + * POST /api/v2/files/bulk-delete — Delete files. Folder deletion is owned by + * `/api/v2/files/folders` so this resource operation never accepts folder ids. */ export const POST = withRouteHandler(async (request: NextRequest) => { try { - const rateLimit = await checkRateLimit(request, 'file-bulk-archive') + const rateLimit = await checkRateLimit(request, 'file-bulk-delete') if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! @@ -40,14 +37,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (gate) return gate const parsed = await parseRequest( - v2BulkArchiveFileItemsContract, + v2BulkDeleteFilesContract, request, {}, { validationErrorResponse: v2ValidationError } ) if (!parsed.success) return parsed.response - const { workspaceId, fileIds, folderIds } = parsed.data.body + const { workspaceId, fileIds } = parsed.data.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -56,20 +53,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { workspaceId, userId, fileIds, - folderIds, request, }) if (!result.success || !result.deletedItems) { return v2ErrorForOrchestration( result.errorCode, - messageForOrchestrationError(result, 'Failed to archive file items') + messageForOrchestrationError(result, 'Failed to delete files') ) } - return v2Data({ deletedItems: result.deletedItems }, { rateLimit }) + return v2Data({ deletedItems: { files: result.deletedItems.files } }, { rateLimit }) } catch (error) { - logger.error('Error archiving file items', { error: getErrorMessage(error, 'Unknown error') }) + logger.error('Error deleting files', { error: getErrorMessage(error, 'Unknown error') }) return v2Error('INTERNAL_ERROR', 'Internal server error') } }) diff --git a/apps/sim/app/api/v2/files/folders/route.ts b/apps/sim/app/api/v2/files/folders/route.ts new file mode 100644 index 00000000000..12b73d316f1 --- /dev/null +++ b/apps/sim/app/api/v2/files/folders/route.ts @@ -0,0 +1,172 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateFileFolderContract, + v2DeleteFileFolderContract, + v2ListFileFoldersContract, + v2RelocateFileFolderContract, +} from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { toFolderPathView } from '@/lib/folders/paths' +import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { + performCreateWorkspaceFileFolderAtPath, + performDeleteWorkspaceFileFolderByPath, + performRelocateWorkspaceFileFolderByPath, +} from '@/lib/workspace-files/orchestration/file-folder-lifecycle' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + resolveFolderPathId, + toV2PathFolder, + v2FolderPathMutationError, +} from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileFoldersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2ListFileFoldersContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const index = await loadActiveFolderPathIndex(workspaceId, 'file') + const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) + if (parentPath !== undefined && parentId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const rows = await listActiveFolderRows(workspaceId, 'file', { + parentId, + search, + sortBy, + sortOrder, + }) + return v2CursorList( + rows.map((row) => toV2PathFolder(row, index, false)), + null, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error listing file folders`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +export const POST = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2CreateFileFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await performCreateWorkspaceFileFolderAtPath({ workspaceId, userId, path }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') + } + return v2Data( + { folder: toFolderPathView(result.folder, result.path) }, + { rateLimit, status: 201 } + ) +}) + +export const PATCH = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2RelocateFileFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, destinationPath } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await performRelocateWorkspaceFileFolderByPath({ + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') + } + return v2Data({ folder: toFolderPathView(result.folder, result.path) }, { rateLimit }) +}) + +export const DELETE = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2DeleteFileFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, recursive } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await performDeleteWorkspaceFileFolderByPath({ + workspaceId, + userId, + path, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + return v2Data({ path, deleted: true as const, deletedItems: result.deletedItems }, { rateLimit }) +}) diff --git a/apps/sim/app/api/v2/files/move/route.test.ts b/apps/sim/app/api/v2/files/move/route.test.ts index 02c232ba4a7..b6651476b8f 100644 --- a/apps/sim/app/api/v2/files/move/route.test.ts +++ b/apps/sim/app/api/v2/files/move/route.test.ts @@ -101,25 +101,24 @@ describe('POST /api/v2/files/move', () => { const res = await callMove({ workspaceId: WS, fileIds: ['wf_1', 'wf_2'], - targetFolderId: 'fold_1', + targetFolderPath: '/Reports', }) const body = await res.json() expect(res.status).toBe(200) - expect(body.data).toEqual({ movedItems: { files: 2, folders: 0 } }) + expect(body.data).toEqual({ movedItems: { files: 2 } }) expect(mockPerformMove).toHaveBeenCalledWith({ workspaceId: WS, userId: 'user-1', fileIds: ['wf_1', 'wf_2'], - folderIds: [], - targetFolderId: 'fold_1', + targetFolderPath: '/Reports', }) }) - it('treats an omitted targetFolderId as the workspace root', async () => { - await callMove({ workspaceId: WS, folderIds: ['fold_2'] }) + it('treats an omitted targetFolderPath as the workspace root', async () => { + await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) expect(mockPerformMove).toHaveBeenCalledWith( - expect.objectContaining({ folderIds: ['fold_2'], targetFolderId: null }) + expect.objectContaining({ fileIds: ['wf_1'], targetFolderPath: '/' }) ) }) @@ -130,7 +129,11 @@ describe('POST /api/v2/files/move', () => { errorCode: 'conflict', }) - const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'], targetFolderId: 'fold_1' }) + const res = await callMove({ + workspaceId: WS, + fileIds: ['wf_1'], + targetFolderPath: '/Reports', + }) const body = await res.json() expect(res.status).toBe(409) diff --git a/apps/sim/app/api/v2/files/move/route.ts b/apps/sim/app/api/v2/files/move/route.ts index bd48c7c55e7..fa4f8b0053c 100644 --- a/apps/sim/app/api/v2/files/move/route.ts +++ b/apps/sim/app/api/v2/files/move/route.ts @@ -23,9 +23,9 @@ export const dynamic = 'force-dynamic' export const revalidate = 0 /** - * POST /api/v2/files/move — Move files and/or folders into a folder. + * POST /api/v2/files/move — Move files into a folder. * - * `targetFolderId: null` (or an omitted field) moves the selection to the + * An omitted `targetFolderPath` moves the selection to the * workspace root. The whole selection moves under one advisory lock, so a name * collision at the destination fails the request as `CONFLICT` rather than * partially applying. @@ -48,7 +48,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, fileIds, folderIds, targetFolderId } = parsed.data.body + const { workspaceId, fileIds, targetFolderPath } = parsed.data.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -57,8 +57,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { workspaceId, userId, fileIds, - folderIds, - targetFolderId: targetFolderId ?? null, + targetFolderPath: targetFolderPath ?? '/', }) if (!result.success || !result.movedItems) { @@ -68,7 +67,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - return v2Data({ movedItems: result.movedItems }, { rateLimit }) + return v2Data({ movedItems: { files: result.movedItems.files } }, { rateLimit }) } catch (error) { logger.error('Error moving file items', { error: getErrorMessage(error, 'Unknown error') }) return v2Error('INTERNAL_ERROR', 'Internal server error') diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index 31d0eea1b59..d4e4cee44ed 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -1,7 +1,5 @@ /** * @vitest-environment node - * - * Public v2 files list: gate ordering and the `scope` split that makes Recently Deleted reachable. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,12 +10,14 @@ const { mockQueryWorkspaceFiles, mockResolveWorkspaceAccess, mockV2ApiGateError, + mockLoadActiveFolderPathIndex, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockPerformCreateWorkspaceFile: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), mockQueryWorkspaceFiles: vi.fn(), mockV2ApiGateError: vi.fn().mockResolvedValue(null), + mockLoadActiveFolderPathIndex: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -33,6 +33,10 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({ queryWorkspaceFiles: mockQueryWorkspaceFiles, })) +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) + vi.mock('@/lib/workspace-files/orchestration', () => ({ MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, performCreateWorkspaceFile: mockPerformCreateWorkspaceFile, @@ -82,7 +86,6 @@ function buildRecord(overrides: Record = {}) { /** What the route forwards for a bare `?workspaceId=` list. */ const DEFAULT_LIST_ARGS = { - scope: 'active', folderId: undefined, search: undefined, sortBy: 'uploadedAt', @@ -108,6 +111,14 @@ describe('GET /api/v2/files', () => { mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['fold_1', { id: 'fold_1', name: 'Reports', parentId: null }]]), + pathById: new Map([['fold_1', '/Reports']]), + idByPath: new Map([ + ['/Reports', 'fold_1'], + ['/Fixtures', 'fold_1'], + ]), + }) }) it('returns 404 when the v2 API surface flag is off', async () => { @@ -171,8 +182,7 @@ describe('GET /api/v2/files', () => { size: 1024, type: 'text/csv', key: 'workspace/ws/1-x-data.csv', - folderId: FOLDER_ID, - folderPath: 'Reports/Q1', + folderPath: '/Reports/Q1', uploadedBy: 'user-1', uploadedAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-02T00:00:00.000Z', @@ -181,26 +191,17 @@ describe('GET /api/v2/files', () => { expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) }) - it('defaults to the active scope and passes archived through', async () => { + it('lists active files only and rejects the removed archived scope', async () => { await callList(`workspaceId=${WS}`) expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) - const archived = buildRecord({ id: 'wf_gone', name: 'gone.csv' }) - mockQueryWorkspaceFiles.mockResolvedValue({ files: [archived], nextKeys: null }) - const res = await callList(`workspaceId=${WS}&scope=archived`) - const body = await res.json() - - expect(mockQueryWorkspaceFiles).toHaveBeenLastCalledWith(WS, { - ...DEFAULT_LIST_ARGS, - scope: 'archived', - }) - expect(body.data.map((f: { id: string }) => f.id)).toEqual(['wf_gone']) + expect(res.status).toBe(400) }) it('forwards search, folder, and sort into the query rather than filtering the result', async () => { await callList( - `workspaceId=${WS}&search=report&folderId=${FOLDER_ID}&sortBy=name&sortOrder=desc` + `workspaceId=${WS}&search=report&folderPath=${encodeURIComponent('/Reports')}&sortBy=name&sortOrder=desc` ) expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, { @@ -212,6 +213,15 @@ describe('GET /api/v2/files', () => { }) }) + it('treats folderPath=/ as root-only while omission lists every folder', async () => { + await callList(`workspaceId=${WS}&folderPath=%2F`) + + expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, { + ...DEFAULT_LIST_ARGS, + folderId: null, + }) + }) + it('400s on a sort field outside the enum instead of passing it toward the query', async () => { const res = await callList(`workspaceId=${WS}&sortBy=name;DROP TABLE workspace_files`) @@ -322,7 +332,7 @@ describe('POST /api/v2/files', () => { userId: 'user-1', name: 'untitled.md', contentType: 'text/markdown', - folderId: undefined, + folderPath: '/', content: Buffer.alloc(0), exactName: true, request, @@ -344,7 +354,7 @@ describe('POST /api/v2/files', () => { workspaceId: WS, name: 'seed.bin', contentType: 'application/octet-stream', - folderId: FOLDER_ID, + folderPath: '/Fixtures', content: Buffer.from([1, 2, 3]).toString('base64'), encoding: 'base64', }) @@ -357,7 +367,7 @@ describe('POST /api/v2/files', () => { workspaceId: WS, name: 'seed.bin', contentType: 'application/octet-stream', - folderId: FOLDER_ID, + folderPath: '/Fixtures', content: Buffer.from([1, 2, 3]), exactName: true, }) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index cc2e0177832..09ba08501d3 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -9,6 +9,7 @@ import { import { parseRequest } from '@/lib/api/server' import { messageForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { queryWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { @@ -17,6 +18,7 @@ import { } from '@/lib/workspace-files/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2File } from '@/app/api/v2/files/utils' +import { resolveFolderPathId } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { cursorSortKey, @@ -42,9 +44,6 @@ export const revalidate = 0 * GET /api/v2/files — List files in a workspace with search, sort, and cursor * pagination. * - * `scope=archived` reads Recently Deleted, which is what makes the restore - * endpoints usable — a caller can find the id of something it deleted. - * * Filtering, ordering, and the page slice all run inside * {@link queryWorkspaceFiles}' query. The route only translates the validated * params and the opaque cursor, so a `search` never costs a full-workspace read. @@ -69,18 +68,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, scope, folderId, search, sortBy, sortOrder, limit, cursor } = - parsed.data.query + const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file') + const folderId = + folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) + if (folderPath !== undefined && folderId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const sort = cursorSortKey(sortBy, sortOrder) const decoded = decodeSortedCursor(cursor, sort) if (decoded.status === 'invalid') return v2CursorSortError() const { files, nextKeys } = await queryWorkspaceFiles(workspaceId, { - scope, folderId, search, sortBy, @@ -129,7 +133,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { : parsed.response } - const { workspaceId, name, contentType, folderId, content, encoding } = parsed.data.body + const { workspaceId, name, contentType, folderPath, content, encoding } = parsed.data.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -138,7 +142,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId, name, contentType: contentType ?? getMimeTypeFromExtension(getFileExtension(name)), - folderId, + folderPath: folderPath ?? '/', content: Buffer.from(content, encoding), exactName: true, request, diff --git a/apps/sim/app/api/v2/files/uploads/route.test.ts b/apps/sim/app/api/v2/files/uploads/route.test.ts index 8065f99830b..25f2a733ed0 100644 --- a/apps/sim/app/api/v2/files/uploads/route.test.ts +++ b/apps/sim/app/api/v2/files/uploads/route.test.ts @@ -7,13 +7,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckRateLimit, mockResolveWorkspaceAccess, - mockAssertFolder, mockCreateUploadSession, + mockLoadActiveFolderPathIndex, + mockWithFolderTreeLock, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), - mockAssertFolder: vi.fn(), mockCreateUploadSession: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), + mockWithFolderTreeLock: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -25,8 +27,12 @@ vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null), })) -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - assertWorkspaceFileFolderTarget: mockAssertFolder, +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) + +vi.mock('@/lib/folders/locks', () => ({ + withFolderTreeLock: mockWithFolderTreeLock, })) vi.mock('@/lib/uploads/upload-session/service', () => ({ @@ -44,6 +50,41 @@ const RATE_LIMIT = { remaining: 99, resetAt: new Date('2026-08-03T22:00:00.000Z'), } +const UPLOAD_SESSION = { + id: 'upload-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + knowledgeBaseId: null, + workflowId: null, + executionId: null, + purpose: 'workspace_file', + method: 'put', + storageContext: 'workspace', + storageKey: `${WORKSPACE_ID}/file.csv`, + finalKey: `${WORKSPACE_ID}/file.csv`, + stagingKey: 'upload-sessions/upload-1/file.csv', + storageProvider: 's3', + providerUploadId: null, + fileName: 'file.csv', + contentType: 'text/csv', + fileSize: 10, + partSize: null, + partCount: null, + status: 'uploading', + uploadToken: 'signed-upload-token', + metadata: {}, + completedFileId: null, + error: null, + expiresAt: new Date('2026-08-04T21:00:00.000Z'), + createdAt: new Date('2026-08-03T21:00:00.000Z'), + updatedAt: new Date('2026-08-03T21:00:00.000Z'), + completedAt: null, + transfer: { + method: 'put', + url: 'https://storage.example/upload', + headers: { 'content-type': 'text/csv' }, + }, +} function request(body: Record) { return POST( @@ -60,42 +101,15 @@ describe('POST /api/v2/files/uploads', () => { vi.clearAllMocks() mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) mockResolveWorkspaceAccess.mockResolvedValue(null) - mockAssertFolder.mockResolvedValue(null) - mockCreateUploadSession.mockResolvedValue({ - id: 'upload-1', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - knowledgeBaseId: null, - workflowId: null, - executionId: null, - purpose: 'workspace_file', - method: 'put', - storageContext: 'workspace', - storageKey: `${WORKSPACE_ID}/file.csv`, - finalKey: `${WORKSPACE_ID}/file.csv`, - stagingKey: 'upload-sessions/upload-1/file.csv', - storageProvider: 's3', - providerUploadId: null, - fileName: 'file.csv', - contentType: 'text/csv', - fileSize: 10, - partSize: null, - partCount: null, - status: 'uploading', - uploadToken: 'signed-upload-token', - metadata: {}, - completedFileId: null, - error: null, - expiresAt: new Date('2026-08-04T21:00:00.000Z'), - createdAt: new Date('2026-08-03T21:00:00.000Z'), - updatedAt: new Date('2026-08-03T21:00:00.000Z'), - completedAt: null, - transfer: { - method: 'put', - url: 'https://storage.example/upload', - headers: { 'content-type': 'text/csv' }, - }, + mockWithFolderTreeLock.mockImplementation(async (_workspaceId, _resourceType, operation) => + operation({}) + ) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map(), + pathById: new Map(), + idByPath: new Map([['/Reports', 'folder-reports']]), }) + mockCreateUploadSession.mockResolvedValue(UPLOAD_SESSION) }) it('creates one signed PUT session for a small file', async () => { @@ -144,7 +158,7 @@ describe('POST /api/v2/files/uploads', () => { }) expect(response.status).toBe(403) - expect(mockAssertFolder).not.toHaveBeenCalled() + expect(mockLoadActiveFolderPathIndex).not.toHaveBeenCalled() expect(mockCreateUploadSession).not.toHaveBeenCalled() }) @@ -161,4 +175,38 @@ describe('POST /api/v2/files/uploads', () => { expect.objectContaining({ purpose: 'workspace_file', fileSize: 0 }) ) }) + + it('releases the folder tree lock before creating an upload session', async () => { + let lockHeld = false + mockWithFolderTreeLock.mockImplementation(async (_workspaceId, _resourceType, operation) => { + lockHeld = true + try { + return await operation({}) + } finally { + lockHeld = false + } + }) + mockCreateUploadSession.mockImplementationOnce(async () => { + expect(lockHeld).toBe(false) + return UPLOAD_SESSION + }) + + const response = await request({ + workspaceId: WORKSPACE_ID, + name: 'file.csv', + contentType: 'text/csv', + size: 10, + folderPath: '/Reports', + }) + + expect(response.status).toBe(201) + expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith( + WORKSPACE_ID, + 'file', + expect.any(Object) + ) + expect(mockCreateUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ metadata: { folderId: 'folder-reports' } }) + ) + }) }) diff --git a/apps/sim/app/api/v2/files/uploads/route.ts b/apps/sim/app/api/v2/files/uploads/route.ts index 10cc3089ef2..b57f128b19a 100644 --- a/apps/sim/app/api/v2/files/uploads/route.ts +++ b/apps/sim/app/api/v2/files/uploads/route.ts @@ -4,10 +4,10 @@ import type { NextRequest } from 'next/server' import { v2CreateFileUploadContract } from '@/lib/api/contracts/v2/files' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' import { createUploadSession } from '@/lib/uploads/upload-session/service' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' +import { resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CaughtOrchestrationError, @@ -37,11 +37,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } ) if (!parsed.success) return parsed.response - const { workspaceId, name, contentType, size, folderId } = parsed.data.body + const { workspaceId, name, contentType, size, folderPath } = parsed.data.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - const normalizedFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId) - + const resolution = await resolveFolderPathIdentity({ + workspaceId, + resourceType: 'file', + path: folderPath ?? '/', + }) + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') const session = await createUploadSession({ workspaceId, userId, @@ -49,7 +53,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { fileName: name, contentType, fileSize: size, - metadata: { folderId: normalizedFolderId }, + metadata: { folderId: resolution.folderId }, localOrigin: request.nextUrl.origin, }) return v2Data( diff --git a/apps/sim/app/api/v2/files/utils.ts b/apps/sim/app/api/v2/files/utils.ts index c49966ce7f2..f5003680ad4 100644 --- a/apps/sim/app/api/v2/files/utils.ts +++ b/apps/sim/app/api/v2/files/utils.ts @@ -1,4 +1,5 @@ import type { V2File } from '@/lib/api/contracts/v2/files' +import { buildFolderPath } from '@/lib/folders/paths' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' /** Shared serialization for the v2 files surface. */ @@ -8,14 +9,22 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' * supplied it) and the internal storage/versioning columns are not exposed. */ export function toV2File(record: WorkspaceFileRecord): V2File { + const folderPath = record.folderId + ? buildFolderPath( + (() => { + if (!record.folderPath) throw new Error('File references an unresolved folder') + return record.folderPath.split('/') + })() + ) + : '/' + return { id: record.id, name: record.name, size: record.size, type: record.type, key: record.key, - folderId: record.folderId ?? null, - folderPath: record.folderPath ?? null, + folderPath, uploadedBy: record.uploadedBy, uploadedAt: record.uploadedAt.toISOString(), updatedAt: record.updatedAt.toISOString(), diff --git a/apps/sim/app/api/v2/folders/[id]/route.test.ts b/apps/sim/app/api/v2/folders/[id]/route.test.ts deleted file mode 100644 index 6ab9c1d3957..00000000000 --- a/apps/sim/app/api/v2/folders/[id]/route.test.ts +++ /dev/null @@ -1,383 +0,0 @@ -/** - * @vitest-environment node - * - * Public v2 folder detail: the archived-row split between PATCH and DELETE, the - * admin gate on `locked`, and the 423 a mutation lock produces. - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockFindActiveFolder, - mockFindFolderInWorkspace, - mockUpdateFolder, - mockDeleteFolder, - mockAssertFolderMutable, - FolderLockedErrorMock, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockFindActiveFolder: vi.fn(), - mockFindFolderInWorkspace: vi.fn(), - mockUpdateFolder: vi.fn(), - mockDeleteFolder: vi.fn(), - mockAssertFolderMutable: vi.fn(), - FolderLockedErrorMock: class FolderLockedError extends Error { - status = 423 - }, -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) - -vi.mock('@/lib/folders/queries', () => ({ - findActiveFolder: mockFindActiveFolder, - findFolderInWorkspace: mockFindFolderInWorkspace, -})) - -vi.mock('@/lib/folders/lifecycle', () => ({ - updateFolder: mockUpdateFolder, - deleteFolder: mockDeleteFolder, -})) - -vi.mock('@sim/platform-authz/workflow', () => ({ - assertFolderMutable: mockAssertFolderMutable, - FolderLockedError: FolderLockedErrorMock, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) - -import { DELETE, GET, PATCH } from '@/app/api/v2/folders/[id]/route' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} - -const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } - -function buildRow(overrides: Record = {}) { - return { - id: 'fld_abc123', - resourceType: 'workflow', - name: 'Onboarding', - userId: 'user-1', - workspaceId: 'workspace-1', - parentId: null, - locked: false, - sortOrder: 0, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - deletedAt: null, - ...overrides, - } -} - -const routeContext = () => ({ params: Promise.resolve({ id: 'fld_abc123' }) }) -const url = (query = 'workspaceId=workspace-1&resourceType=workflow') => - `http://localhost:3000/api/v2/folders/fld_abc123?${query}` - -const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) -const callDelete = (query?: string) => - DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) - -function callPatch(body: unknown) { - return PATCH( - new NextRequest('http://localhost:3000/api/v2/folders/fld_abc123', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - routeContext() - ) -} - -describe('GET /api/v2/folders/[id]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockFindFolderInWorkspace.mockResolvedValue(buildRow()) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockFindFolderInWorkspace).not.toHaveBeenCalled() - }) - - it('400s when resourceType is missing', async () => { - const res = await callGet('workspaceId=workspace-1') - expect(res.status).toBe(400) - expect(mockFindFolderInWorkspace).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callGet() - expect(res.status).toBe(403) - expect(mockFindFolderInWorkspace).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callGet() - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('404s when the folder is not in this workspace tree', async () => { - mockFindFolderInWorkspace.mockResolvedValue(null) - const res = await callGet() - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - }) - - it('returns the public folder shape', async () => { - const res = await callGet() - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data.folder).toEqual({ - id: 'fld_abc123', - resourceType: 'workflow', - name: 'Onboarding', - parentId: null, - locked: false, - sortOrder: 0, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - deletedAt: null, - }) - expect(mockFindFolderInWorkspace).toHaveBeenCalledWith('fld_abc123', 'workspace-1', 'workflow') - }) -}) - -describe('PATCH /api/v2/folders/[id]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockFindActiveFolder.mockResolvedValue(buildRow()) - mockAssertFolderMutable.mockResolvedValue(undefined) - mockUpdateFolder.mockResolvedValue({ success: true, folder: buildRow({ name: 'Renamed' }) }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callPatch({ - workspaceId: 'workspace-1', - resourceType: 'workflow', - name: 'Renamed', - }) - - expect(res.status).toBe(404) - expect(mockUpdateFolder).not.toHaveBeenCalled() - }) - - it('400s when no field to change is supplied', async () => { - const res = await callPatch({ workspaceId: 'workspace-1', resourceType: 'workflow' }) - expect(res.status).toBe(400) - expect(mockUpdateFolder).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callPatch({ - workspaceId: 'workspace-1', - resourceType: 'workflow', - name: 'Renamed', - }) - expect(res.status).toBe(403) - expect(mockUpdateFolder).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callPatch({ - workspaceId: 'workspace-1', - resourceType: 'workflow', - name: 'Renamed', - }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('requires only write permission for an ordinary rename', async () => { - await callPatch({ workspaceId: 'workspace-1', resourceType: 'workflow', name: 'Renamed' }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.anything(), - 'user-1', - 'workspace-1', - 'write' - ) - }) - - it('escalates to admin when locked is being set', async () => { - await callPatch({ workspaceId: 'workspace-1', resourceType: 'workflow', locked: true }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.anything(), - 'user-1', - 'workspace-1', - 'admin' - ) - }) - - it('400s when locked is sent for a tree that does not support locking', async () => { - const res = await callPatch({ - workspaceId: 'workspace-1', - resourceType: 'table', - locked: true, - }) - expect(res.status).toBe(400) - expect((await res.json()).error.message).toContain('workflow folders') - expect(mockUpdateFolder).not.toHaveBeenCalled() - }) - - it('404s on an archived folder so a locked subtree cannot be edited through it', async () => { - mockFindActiveFolder.mockResolvedValue(null) - const res = await callPatch({ - workspaceId: 'workspace-1', - resourceType: 'workflow', - name: 'Renamed', - }) - expect(res.status).toBe(404) - expect(mockUpdateFolder).not.toHaveBeenCalled() - }) - - it('423s when a mutation lock blocks the change', async () => { - mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) - const res = await callPatch({ - workspaceId: 'workspace-1', - resourceType: 'workflow', - name: 'Renamed', - }) - expect(res.status).toBe(423) - expect((await res.json()).error.code).toBe('LOCKED') - expect(mockUpdateFolder).not.toHaveBeenCalled() - }) - - it('updates the folder and returns the public shape', async () => { - const res = await callPatch({ - workspaceId: 'workspace-1', - resourceType: 'workflow', - name: 'Renamed', - }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data.folder.name).toBe('Renamed') - expect(mockUpdateFolder).toHaveBeenCalledWith( - expect.objectContaining({ - resourceType: 'workflow', - folderId: 'fld_abc123', - workspaceId: 'workspace-1', - userId: 'user-1', - name: 'Renamed', - }) - ) - }) -}) - -describe('DELETE /api/v2/folders/[id]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockFindFolderInWorkspace.mockResolvedValue(buildRow()) - mockAssertFolderMutable.mockResolvedValue(undefined) - mockDeleteFolder.mockResolvedValue({ - success: true, - deletedItems: { folders: 2, workflows: 5 }, - }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callDelete() - - expect(res.status).toBe(404) - expect(mockDeleteFolder).not.toHaveBeenCalled() - }) - - it('400s when resourceType is missing', async () => { - const res = await callDelete('workspaceId=workspace-1') - expect(res.status).toBe(400) - expect(mockDeleteFolder).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callDelete() - expect(res.status).toBe(403) - expect(mockDeleteFolder).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callDelete() - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('404s when the folder is not in this workspace tree', async () => { - mockFindFolderInWorkspace.mockResolvedValue(null) - const res = await callDelete() - expect(res.status).toBe(404) - expect(mockDeleteFolder).not.toHaveBeenCalled() - }) - - it('423s when a mutation lock blocks the delete', async () => { - mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) - const res = await callDelete() - expect(res.status).toBe(423) - expect((await res.json()).error.code).toBe('LOCKED') - expect(mockDeleteFolder).not.toHaveBeenCalled() - }) - - it('deletes the folder and reports the cascade counts', async () => { - const res = await callDelete() - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ - data: { id: 'fld_abc123', deleted: true, deletedItems: { folders: 2, workflows: 5 } }, - }) - expect(mockDeleteFolder).toHaveBeenCalledWith( - expect.objectContaining({ - resourceType: 'workflow', - folderId: 'fld_abc123', - workspaceId: 'workspace-1', - userId: 'user-1', - folderName: 'Onboarding', - }) - ) - }) -}) diff --git a/apps/sim/app/api/v2/folders/[id]/route.ts b/apps/sim/app/api/v2/folders/[id]/route.ts deleted file mode 100644 index b05d090d600..00000000000 --- a/apps/sim/app/api/v2/folders/[id]/route.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { createLogger } from '@sim/logger' -import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { - v2DeleteFolderContract, - v2GetFolderContract, - v2UpdateFolderContract, -} from '@/lib/api/contracts/v2/folders' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { folderResourceConfig } from '@/lib/folders/config' -import { deleteFolder, updateFolder } from '@/lib/folders/lifecycle' -import { findActiveFolder, findFolderInWorkspace } from '@/lib/folders/queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2Folder, v2FolderMutationError } from '@/app/api/v2/folders/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FolderDetailAPI') - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -interface RouteContext { - params: Promise<{ id: string }> -} - -/** GET /api/v2/folders/[id] — Fetch a single folder, archived or live. */ -export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'folder-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetFolderContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId, resourceType } = parsed.data.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const folder = await findFolderInWorkspace(id, workspaceId, resourceType) - if (!folder) return v2Error('NOT_FOUND', 'Folder not found') - - return v2Data({ folder: toV2Folder(folder) }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error fetching folder`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) - -/** PATCH /api/v2/folders/[id] — Rename, move, reorder, or lock a folder. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'folder-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateFolderContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId, resourceType, name, locked, parentId, sortOrder } = parsed.data.body - - /** - * Setting `locked` is an admin capability, matching the UI; every other - * field needs only workspace write. - */ - const access = await resolveWorkspaceAccess( - rateLimit, - userId, - workspaceId, - locked === undefined ? 'write' : 'admin' - ) - if (access) return v2WorkspaceAccessError(access) - - /** - * Archived folders are excluded deliberately: `getFolderLockStatus` skips - * archived rows, so an archived-but-locked folder reports unlocked. Without - * this filter, deleting a folder would make every locked subfolder under it - * freely renameable and reparentable. - */ - const existing = await findActiveFolder(id, workspaceId, resourceType) - if (!existing) return v2Error('NOT_FOUND', 'Folder not found') - - const supportsLocking = Boolean(folderResourceConfig(resourceType).supportsLocking) - if (locked !== undefined && !supportsLocking) { - return v2Error('BAD_REQUEST', 'Folder locking is only supported for workflow folders') - } - - if (supportsLocking) { - const hasNonLockUpdate = - name !== undefined || parentId !== undefined || sortOrder !== undefined - if (hasNonLockUpdate) await assertFolderMutable(id) - if (parentId !== undefined) await assertFolderMutable(parentId) - } - - const result = await updateFolder({ - resourceType, - folderId: id, - workspaceId, - userId, - name, - locked, - parentId, - sortOrder, - }) - - if (!result.success || !result.folder) { - return v2FolderMutationError(result.errorCode, result.error ?? 'Failed to update folder') - } - - return v2Data({ folder: toV2Folder(result.folder) }, { rateLimit }) - } catch (error) { - if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) - - logger.error(`[${requestId}] Error updating folder`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) - -/** DELETE /api/v2/folders/[id] — Archive a folder and cascade to its contents. */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'folder-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteFolderContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId, resourceType } = parsed.data.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - /** - * Archived rows are included on purpose: `deleteFolder` reuses an already - * archived folder's own `deletedAt` so a cascade that failed partway can be - * retried onto the same snapshot. 404ing here would strand those. - */ - const existing = await findFolderInWorkspace(id, workspaceId, resourceType) - if (!existing) return v2Error('NOT_FOUND', 'Folder not found') - - if (folderResourceConfig(resourceType).supportsLocking) { - await assertFolderMutable(id) - } - - const result = await deleteFolder({ - resourceType, - folderId: id, - workspaceId, - userId, - folderName: existing.name, - }) - - if (!result.success) { - return v2FolderMutationError(result.errorCode, result.error ?? 'Failed to delete folder') - } - - return v2Data({ id, deleted: true as const, deletedItems: result.deletedItems }, { rateLimit }) - } catch (error) { - if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) - - logger.error(`[${requestId}] Error deleting folder`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/folders/route.test.ts b/apps/sim/app/api/v2/folders/route.test.ts deleted file mode 100644 index 4f2622ee903..00000000000 --- a/apps/sim/app/api/v2/folders/route.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -/** - * @vitest-environment node - * - * Public v2 folders list/create: gate ordering, the required-`resourceType` - * departure from the internal default, and the lock check on create. - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockListFoldersForWorkspace, - mockCreateFolder, - mockAssertFolderMutable, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockListFoldersForWorkspace: vi.fn(), - mockCreateFolder: vi.fn(), - mockAssertFolderMutable: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) - -vi.mock('@/lib/folders/queries', () => ({ - listFoldersForWorkspace: mockListFoldersForWorkspace, -})) - -vi.mock('@/lib/folders/lifecycle', () => ({ - createFolder: mockCreateFolder, -})) - -vi.mock('@sim/platform-authz/workflow', () => ({ - assertFolderMutable: mockAssertFolderMutable, - FolderLockedError: class FolderLockedError extends Error { - status = 423 - }, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) - -import { GET, POST } from '@/app/api/v2/folders/route' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} - -const FOLDER_API = { - id: 'fld_abc123', - resourceType: 'workflow' as const, - name: 'Onboarding', - userId: 'user-1', - workspaceId: 'workspace-1', - parentId: null, - locked: false, - sortOrder: 0, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - deletedAt: null, -} - -function buildRow(overrides: Record = {}) { - return { - id: 'fld_abc123', - resourceType: 'workflow', - name: 'Onboarding', - userId: 'user-1', - workspaceId: 'workspace-1', - parentId: null, - locked: false, - sortOrder: 0, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - deletedAt: null, - ...overrides, - } -} - -/** What the route forwards for a bare `?workspaceId=` list. */ -const DEFAULT_LIST_ARGS = { - search: undefined, - sortBy: 'position', - sortOrder: 'asc', -} - -const callList = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/folders?${query}`)) - -function callCreate(body: unknown) { - return POST( - new NextRequest('http://localhost:3000/api/v2/folders', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - ) -} - -const VALID_BODY = { - workspaceId: 'workspace-1', - resourceType: 'workflow', - name: 'Onboarding', -} - -describe('GET /api/v2/folders', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockListFoldersForWorkspace.mockResolvedValue([FOLDER_API]) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callList('workspaceId=workspace-1&resourceType=workflow') - - expect(res.status).toBe(404) - expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callList('resourceType=workflow') - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() - }) - - it('400s when resourceType is omitted instead of defaulting to workflow', async () => { - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(400) - expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() - }) - - it('400s on a resourceType outside the served set', async () => { - const res = await callList('workspaceId=workspace-1&resourceType=file') - expect(res.status).toBe(400) - expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - const res = await callList('workspaceId=workspace-1&resourceType=workflow') - expect(res.status).toBe(403) - expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callList('workspaceId=workspace-1&resourceType=workflow') - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('returns the public folder shape without internal scoping columns', async () => { - const res = await callList('workspaceId=workspace-1&resourceType=workflow') - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.nextCursor).toBeNull() - expect(body.data).toEqual([ - { - id: 'fld_abc123', - resourceType: 'workflow', - name: 'Onboarding', - parentId: null, - locked: false, - sortOrder: 0, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - deletedAt: null, - }, - ]) - expect(mockListFoldersForWorkspace).toHaveBeenCalledWith( - 'workspace-1', - 'active', - 'workflow', - DEFAULT_LIST_ARGS - ) - }) - - it('passes the archived scope through', async () => { - await callList('workspaceId=workspace-1&resourceType=table&scope=archived') - expect(mockListFoldersForWorkspace).toHaveBeenCalledWith( - 'workspace-1', - 'archived', - 'table', - DEFAULT_LIST_ARGS - ) - }) - it('400s on a sort field outside the enum instead of letting it reach the query', async () => { - const res = await callList(`workspaceId=workspace-1&resourceType=workflow&sortBy=name);--`) - - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - }) - - it('400s on a sort direction outside the enum', async () => { - const res = await callList(`workspaceId=workspace-1&resourceType=workflow&sortOrder=sideways`) - - expect(res.status).toBe(400) - }) - - it('400s on an empty search rather than treating it as unsearched', async () => { - const res = await callList(`workspaceId=workspace-1&resourceType=workflow&search=`) - - expect(res.status).toBe(400) - }) - - it('forwards search and sort into the query and still terminates pagination', async () => { - const res = await callList( - `workspaceId=workspace-1&resourceType=workflow&search=report&sortBy=name&sortOrder=asc` - ) - - expect(res.status).toBe(200) - expect((await res.json()).nextCursor).toBeNull() - }) -}) - -describe('POST /api/v2/folders', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockAssertFolderMutable.mockResolvedValue(undefined) - mockCreateFolder.mockResolvedValue({ success: true, folder: buildRow() }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callCreate(VALID_BODY) - - expect(res.status).toBe(404) - expect(mockCreateFolder).not.toHaveBeenCalled() - }) - - it('400s when the name is empty', async () => { - const res = await callCreate({ ...VALID_BODY, name: ' ' }) - expect(res.status).toBe(400) - expect(mockCreateFolder).not.toHaveBeenCalled() - }) - - it('400s when resourceType is omitted', async () => { - const res = await callCreate({ workspaceId: 'workspace-1', name: 'Onboarding' }) - expect(res.status).toBe(400) - expect(mockCreateFolder).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - const res = await callCreate(VALID_BODY) - expect(res.status).toBe(403) - expect(mockCreateFolder).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callCreate(VALID_BODY) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('409s when a sibling folder already has the name', async () => { - mockCreateFolder.mockResolvedValue({ - success: false, - error: 'A folder with this name already exists in this location', - errorCode: 'conflict', - }) - const res = await callCreate(VALID_BODY) - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - }) - - it('creates the folder and returns 201', async () => { - const res = await callCreate({ ...VALID_BODY, parentId: null }) - const body = await res.json() - - expect(res.status).toBe(201) - expect(body.data.folder).toMatchObject({ id: 'fld_abc123', name: 'Onboarding' }) - expect(body.data.folder.userId).toBeUndefined() - expect(mockCreateFolder).toHaveBeenCalledWith( - expect.objectContaining({ - resourceType: 'workflow', - userId: 'user-1', - workspaceId: 'workspace-1', - name: 'Onboarding', - }) - ) - }) -}) diff --git a/apps/sim/app/api/v2/folders/route.ts b/apps/sim/app/api/v2/folders/route.ts deleted file mode 100644 index ed74572a617..00000000000 --- a/apps/sim/app/api/v2/folders/route.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { createLogger } from '@sim/logger' -import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { v2CreateFolderContract, v2ListFoldersContract } from '@/lib/api/contracts/v2/folders' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { folderResourceConfig } from '@/lib/folders/config' -import { createFolder } from '@/lib/folders/lifecycle' -import { listFoldersForWorkspace } from '@/lib/folders/queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2Folder, toV2FolderFromApi, v2FolderMutationError } from '@/app/api/v2/folders/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FoldersAPI') - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -/** GET /api/v2/folders — List a workspace's folder tree for one resource type. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'folders') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListFoldersContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, resourceType, scope, search, sortBy, sortOrder } = parsed.data.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const folders = await listFoldersForWorkspace(workspaceId, scope, resourceType, { - search, - sortBy, - sortOrder, - }) - - // One workspace's tree for one resource type is bounded → a single full page. - return v2CursorList(folders.map(toV2FolderFromApi), null, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error listing folders`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) - -/** POST /api/v2/folders — Create a folder in one of a workspace's resource trees. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'folders') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateFolderContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, resourceType, name, parentId, sortOrder } = parsed.data.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - // Locking is a workflow-only feature; other trees leave `locked` false. - if (folderResourceConfig(resourceType).supportsLocking) { - await assertFolderMutable(parentId ?? null) - } - - const result = await createFolder({ - resourceType, - userId, - workspaceId, - name, - parentId, - sortOrder, - }) - - if (!result.success || !result.folder) { - return v2FolderMutationError(result.errorCode, result.error ?? 'Failed to create folder') - } - - return v2Data({ folder: toV2Folder(result.folder) }, { rateLimit, status: 201 }) - } catch (error) { - if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) - - logger.error(`[${requestId}] Error creating folder`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/folders/utils.ts b/apps/sim/app/api/v2/folders/utils.ts deleted file mode 100644 index c041e880013..00000000000 --- a/apps/sim/app/api/v2/folders/utils.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { folder as folderTable } from '@sim/db/schema' -import { omit } from '@sim/utils/object' -import type { NextResponse } from 'next/server' -import type { FolderApi } from '@/lib/api/contracts/folders' -import type { V2Folder } from '@/lib/api/contracts/v2/folders' -import type { FolderMutationErrorCode } from '@/lib/folders/status' -import { v2Error } from '@/app/api/v2/lib/response' - -/** Shared serialization + error mapping for the v2 folders surface. */ - -type FolderRow = typeof folderTable.$inferSelect - -/** - * Narrows an already-serialized {@link FolderApi} (what the shared list query - * returns) to the public projection. - */ -export function toV2FolderFromApi(row: FolderApi): V2Folder { - return omit(row, ['userId', 'workspaceId']) -} - -/** - * Public folder projection. `userId` and `workspaceId` are internal scoping - * columns and are not exposed. - */ -export function toV2Folder(row: FolderRow): V2Folder { - return { - id: row.id, - resourceType: row.resourceType, - name: row.name, - parentId: row.parentId, - locked: row.locked, - sortOrder: row.sortOrder, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - deletedAt: row.deletedAt?.toISOString() ?? null, - } -} - -/** - * Renders a folder mutation failure in the v2 error envelope. `locked` keeps its - * 423, matching what the table domain returns when the same mutation lock blocks - * a single-table delete. - */ -export function v2FolderMutationError( - errorCode: FolderMutationErrorCode | undefined, - message: string -): NextResponse { - switch (errorCode) { - case 'validation': - return v2Error('BAD_REQUEST', message) - case 'not_found': - return v2Error('NOT_FOUND', 'Folder not found') - case 'conflict': - return v2Error('CONFLICT', message) - case 'locked': - return v2Error('LOCKED', message) - default: - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -} diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts index 65364a2b808..abbcbc61e06 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -9,6 +9,8 @@ import { import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { withFolderTreeLock } from '@/lib/folders/locks' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { performDeleteKnowledgeBase, performUpdateKnowledgeBase, @@ -16,6 +18,7 @@ import { import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { formatKnowledgeBase, resolveKnowledgeBase } from '@/app/api/v1/knowledge/utils' import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { folderPathForId, resolveFolderPathId } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, @@ -85,7 +88,20 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Knowle ) if (result instanceof NextResponse) return result - return v2Data({ knowledgeBase: formatKnowledgeBase(result.kb) }, { rateLimit }) + const folderIndex = await loadActiveFolderPathIndex( + parsed.data.query.workspaceId, + 'knowledge_base' + ) + + return v2Data( + { + knowledgeBase: { + ...formatKnowledgeBase(result.kb), + folderPath: folderPathForId(folderIndex, result.kb.folderId), + }, + }, + { rateLimit } + ) } catch (error) { logger.error(`[${requestId}] Error getting knowledge base`, { error: getErrorMessage(error, 'Unknown error'), @@ -113,25 +129,44 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle if (!parsed.success) return parsed.response const { id } = parsed.data.params - const { workspaceId, name, description, chunkingConfig } = parsed.data.body + const { workspaceId, name, description, chunkingConfig, folderPath } = parsed.data.body const result = await resolveKnowledgeBaseScoped(id, workspaceId, userId, rateLimit, 'write') if (result instanceof NextResponse) return result - const outcome = await performUpdateKnowledgeBase({ - knowledgeBaseId: id, - workspaceId, - userId, - source: 'api', - updates: { name, description, chunkingConfig }, - requestId, - request, + const mutation = await withFolderTreeLock(workspaceId, 'knowledge_base', async (tx) => { + const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base', tx) + const folderId = folderPath === undefined ? undefined : resolveFolderPathId(index, folderPath) + if (folderPath !== undefined && folderId === undefined) return { found: false as const } + + const outcome = await performUpdateKnowledgeBase({ + knowledgeBaseId: id, + workspaceId, + userId, + source: 'api', + updates: { name, description, chunkingConfig, folderId }, + requestId, + request, + }) + return { found: true as const, index, outcome } }) + if (!mutation.found) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const { index: folderIndex, outcome } = mutation if (!outcome.success) { return v2ErrorForOrchestration(outcome.errorCode, outcome.error) } - return v2Data({ knowledgeBase: formatKnowledgeBase(outcome.knowledgeBase) }, { rateLimit }) + return v2Data( + { + knowledgeBase: { + ...formatKnowledgeBase(outcome.knowledgeBase), + folderPath: folderPathForId(folderIndex, outcome.knowledgeBase.folderId), + }, + }, + { rateLimit } + ) } catch (error) { logger.error(`[${requestId}] Error updating knowledge base`, { error: getErrorMessage(error, 'Unknown error'), diff --git a/apps/sim/app/api/v2/knowledge/folders/route.ts b/apps/sim/app/api/v2/knowledge/folders/route.ts new file mode 100644 index 00000000000..4995d5cc486 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/folders/route.ts @@ -0,0 +1,187 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateKnowledgeFolderContract, + v2DeleteKnowledgeFolderContract, + v2ListKnowledgeFoldersContract, + v2RelocateKnowledgeFolderContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createFolderAtPath, + deleteFolderByPath, + relocateFolderByPath, +} from '@/lib/folders/orchestration' +import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + resolveFolderPathId, + toV2PathFolder, + v2FolderPathMutationError, +} from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeFoldersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + try { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2ListKnowledgeFoldersContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') + const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) + if (parentPath !== undefined && parentId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const rows = await listActiveFolderRows(workspaceId, 'knowledge_base', { + parentId, + search, + sortBy, + sortOrder, + }) + return v2CursorList( + rows.map((row) => toV2PathFolder(row, index, false)), + null, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error listing knowledge folders`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +export const POST = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2CreateKnowledgeFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await createFolderAtPath({ + resourceType: 'knowledge_base', + workspaceId, + userId, + path, + }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') + return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit, status: 201 }) +}) + +export const PATCH = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2RelocateKnowledgeFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, destinationPath } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await relocateFolderByPath({ + resourceType: 'knowledge_base', + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') + return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit }) +}) + +export const DELETE = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2DeleteKnowledgeFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, recursive } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await deleteFolderByPath({ + resourceType: 'knowledge_base', + workspaceId, + userId, + path, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + return v2Data( + { + path, + deleted: true as const, + deletedItems: { + folders: result.deletedItems.folders, + knowledgeBases: result.deletedItems.knowledgeBases ?? 0, + }, + }, + { rateLimit } + ) +}) diff --git a/apps/sim/app/api/v2/knowledge/route.test.ts b/apps/sim/app/api/v2/knowledge/route.test.ts index 2b6d8f635a6..bdd087b441e 100644 --- a/apps/sim/app/api/v2/knowledge/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/route.test.ts @@ -7,13 +7,17 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockGetKnowledgeBases } = vi.hoisted( - () => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetKnowledgeBases: vi.fn(), - }) -) +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetKnowledgeBases, + mockLoadActiveFolderPathIndex, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetKnowledgeBases: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), +})) vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit, @@ -24,6 +28,10 @@ vi.mock('@/lib/knowledge/service', () => ({ getKnowledgeBases: mockGetKnowledgeBases, })) +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) + vi.mock('@/lib/knowledge/orchestration', () => ({ performCreateKnowledgeBase: vi.fn(), })) @@ -83,11 +91,16 @@ describe('GET /api/v2/knowledge', () => { mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) mockGetKnowledgeBases.mockResolvedValue([buildKnowledgeBase()]) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['fold_1', { id: 'fold_1', name: 'Support', parentId: null }]]), + pathById: new Map([['fold_1', '/Support']]), + idByPath: new Map([['/Support', 'fold_1']]), + }) }) it('forwards search, folder, and sort into the query rather than filtering the result', async () => { const res = await callList( - `workspaceId=${WS}&search=support&folderId=${FOLDER_ID}&sortBy=name&sortOrder=desc` + `workspaceId=${WS}&search=support&folderPath=${encodeURIComponent('/Support')}&sortBy=name&sortOrder=desc` ) expect(res.status).toBe(200) @@ -105,6 +118,15 @@ describe('GET /api/v2/knowledge', () => { expect(mockGetKnowledgeBases).toHaveBeenCalledWith('user-1', WS, 'active', DEFAULT_LIST_ARGS) }) + it('treats folderPath=/ as root-only while omission lists every folder', async () => { + await callList(`workspaceId=${WS}&folderPath=%2F`) + + expect(mockGetKnowledgeBases).toHaveBeenCalledWith('user-1', WS, 'active', { + ...DEFAULT_LIST_ARGS, + folderId: null, + }) + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { const res = await callList(`workspaceId=${WS}&sortBy=name);--`) diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index 01811a343f5..90c1392c40e 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -8,10 +8,16 @@ import { import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { performCreateKnowledgeBase } from '@/lib/knowledge/orchestration' import { getKnowledgeBases } from '@/lib/knowledge/service' import { formatKnowledgeBase } from '@/app/api/v1/knowledge/utils' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + folderPathForId, + resolveFolderPathId, + withResolvedFolderPathMutation, +} from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CursorList, @@ -51,18 +57,28 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, folderId, search, sortBy, sortOrder } = parsed.data.query + const { workspaceId, folderPath, search, sortBy, sortOrder } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') + const folderId = + folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) + if (folderPath !== undefined && folderId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const knowledgeBases = await getKnowledgeBases(userId, workspaceId, 'active', { folderId, search, sortBy, sortOrder, }) - const items = knowledgeBases.map(formatKnowledgeBase) + const items = knowledgeBases.map((knowledgeBase) => ({ + ...formatKnowledgeBase(knowledgeBase), + folderPath: folderPathForId(folderIndex, knowledgeBase.folderId), + })) // `getKnowledgeBases` returns the full bounded workspace set → single page. return v2CursorList(items, null, { rateLimit }) @@ -97,27 +113,41 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, name, description, chunkingConfig } = parsed.data.body + const { workspaceId, name, description, chunkingConfig, folderPath } = parsed.data.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - const outcome = await performCreateKnowledgeBase({ - userId, - source: 'api', + const mutation = await withResolvedFolderPathMutation({ workspaceId, - name, - description, - chunkingConfig, - requestId, - request, + resourceType: 'knowledge_base', + path: folderPath ?? '/', + mutate: (folderId) => + performCreateKnowledgeBase({ + userId, + source: 'api', + workspaceId, + name, + description, + chunkingConfig, + folderId, + requestId, + request, + }), }) + if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found') + const outcome = mutation.value if (!outcome.success) { return v2ErrorForOrchestration(outcome.errorCode, outcome.error) } return v2Data( - { knowledgeBase: formatKnowledgeBase(outcome.knowledgeBase) }, + { + knowledgeBase: { + ...formatKnowledgeBase(outcome.knowledgeBase), + folderPath: folderPathForId(mutation.index, outcome.knowledgeBase.folderId), + }, + }, { rateLimit, status: 201 } ) } catch (error) { diff --git a/apps/sim/app/api/v2/lib/folders.ts b/apps/sim/app/api/v2/lib/folders.ts new file mode 100644 index 00000000000..6d506a58fb3 --- /dev/null +++ b/apps/sim/app/api/v2/lib/folders.ts @@ -0,0 +1,85 @@ +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 ResolvedFolderPathMutation = + | { found: false } + | { found: true; folderId: string | null; index: FolderPathIndex; value: T } + +export type ResolvedFolderPathIdentity = { found: false } | { found: true; folderId: string | null } + +/** Resolves a canonical path to its stable internal identity under the 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 } + }) +} + +/** Resolves a canonical path and keeps that folder tree stable through a resource mutation. */ +export async function withResolvedFolderPathMutation(params: { + workspaceId: string + resourceType: FolderResourceType + path: string + mutate: (folderId: string | null) => Promise +}): Promise> { + return withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => { + const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) + const folderId = resolveFolderPathId(index, params.path) + if (folderId === undefined) return { found: false } + const value = await params.mutate(folderId) + return { found: true, folderId, index, value } + }) +} + +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, + includeLocked: boolean +) { + const path = index.pathById.get(row.id) + if (!path) throw new Error('Folder path index is missing a listed folder') + 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/logs/[id]/route.ts b/apps/sim/app/api/v2/logs/[id]/route.ts index 02593d73ec9..b9307d353f7 100644 --- a/apps/sim/app/api/v2/logs/[id]/route.ts +++ b/apps/sim/app/api/v2/logs/[id]/route.ts @@ -8,6 +8,7 @@ import type { NextRequest } from 'next/server' import { type V2LogDetail, v2GetLogContract } from '@/lib/api/contracts/v2/logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -59,6 +60,7 @@ export const GET = withRouteHandler( workflowWorkspaceId: workflow.workspaceId, workflowCreatedAt: workflow.createdAt, workflowUpdatedAt: workflow.updatedAt, + workflowArchivedAt: workflow.archivedAt, }) .from(workflowExecutionLogs) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) @@ -72,6 +74,8 @@ export const GET = withRouteHandler( const access = await resolveWorkspaceAccess(rateLimit, userId, log.workspaceId) if (access) return v2Error('NOT_FOUND', 'Log not found') + const folderIndex = await loadActiveFolderPathIndex(log.workspaceId, 'workflow') + const executionData = await materializeExecutionData( log.executionData as Record | null, { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } @@ -91,12 +95,14 @@ export const GET = withRouteHandler( id: log.workflowId, name: log.workflowName || 'Deleted Workflow', description: log.workflowDescription, - folderId: log.workflowFolderId, + folderPath: log.workflowFolderId + ? (folderIndex.pathById.get(log.workflowFolderId) ?? null) + : null, userId: log.workflowUserId, workspaceId: log.workflowWorkspaceId, createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null, updatedAt: log.workflowUpdatedAt ? log.workflowUpdatedAt.toISOString() : null, - deleted: !log.workflowName, + deleted: !log.workflowName || log.workflowArchivedAt !== null, }, executionData, cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index c4e4077bdc5..ef1eb76e97b 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -3,15 +3,17 @@ import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { eq, sql } from 'drizzle-orm' +import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { type V2LogListItem, v2ListLogsContract } from '@/lib/api/contracts/v2/logs' import { parseRequest } from '@/lib/api/server' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { resolveFolderPathId } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { decodeCursor, @@ -55,10 +57,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) + const folderPaths = params.folderPaths?.split(',').filter(Boolean) + const folderIndex = folderPaths + ? await loadActiveFolderPathIndex(params.workspaceId, 'workflow') + : null + const resolvedFolderIds = folderPaths?.map((path) => resolveFolderPathId(folderIndex!, path)) + if (resolvedFolderIds?.some((folderId) => folderId === undefined)) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const nonRootFolderIds = resolvedFolderIds?.filter( + (folderId): folderId is string => typeof folderId === 'string' + ) + const includesRoot = resolvedFolderIds?.includes(null) ?? false + const filters = { workspaceId: params.workspaceId, workflowIds: params.workflowIds?.split(',').filter(Boolean), - folderIds: params.folderIds?.split(',').filter(Boolean), + folderIds: nonRootFolderIds, triggers: params.triggers?.split(',').filter(Boolean), level: params.level, startDate: params.startDate ? new Date(params.startDate) : undefined, @@ -76,6 +91,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const conditions = buildLogFilters(filters) + const rootFolderCondition = folderPaths + ? or( + includesRoot ? isNull(workflow.folderId) : undefined, + nonRootFolderIds && nonRootFolderIds.length > 0 + ? inArray(workflow.folderId, nonRootFolderIds) + : undefined + ) + : undefined const orderBy = getOrderBy(params.order) const rows = await db @@ -95,10 +118,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { executionData: params.details === 'full' ? workflowExecutionLogs.executionData : sql`null`, workflowName: workflow.name, workflowDescription: workflow.description, + workflowArchivedAt: workflow.archivedAt, }) .from(workflowExecutionLogs) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .where(conditions) + .where(and(conditions, rootFolderCondition)) .orderBy(...orderBy) .limit(params.limit + 1) @@ -131,7 +155,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { id: log.workflowId, name: log.workflowName || 'Deleted Workflow', description: log.workflowDescription, - deleted: !log.workflowName, + deleted: !log.workflowName || log.workflowArchivedAt !== null, } } return item diff --git a/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts deleted file mode 100644 index 804db7fd7f0..00000000000 --- a/apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * @vitest-environment node - * - * Public v2 table restore. The target is archived by definition, so the route - * resolves it with archived rows included and checks the permission against - * that row's own workspace rather than going through `checkAccess`. - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockGetTableById, - mockGetUserEntityPermissions, - mockPerformRestoreTable, - mockGateError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockGetTableById: vi.fn(), - mockGetUserEntityPermissions: vi.fn(), - mockPerformRestoreTable: vi.fn(), - mockGateError: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, -})) - -vi.mock('@/lib/table', () => ({ getTableById: mockGetTableById })) -vi.mock('@/lib/table/orchestration', () => ({ performRestoreTable: mockPerformRestoreTable })) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, -})) -vi.mock('@/app/api/table/utils', () => ({ - normalizeColumn: (col: Record) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, -})) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - -import { POST } from '@/app/api/v2/tables/[tableId]/restore/route' - -const UNLOCKED = { - schemaLocked: false, - insertLocked: false, - updateLocked: false, - deleteLocked: false, -} -const ARCHIVED_TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } -const RESTORED_TABLE = { - id: 'table-1', - name: 'Tasks', - description: null, - workspaceId: 'ws-1', - schema: { columns: [] }, - rowCount: 7, - maxRows: 1000, - folderId: null, - locks: UNLOCKED, - createdAt: new Date('2026-01-01T00:00:00Z'), - updatedAt: new Date('2026-01-02T00:00:00Z'), -} - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -function callPost(body: unknown) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/restore', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) -} - -describe('POST /api/v2/tables/[tableId]/restore', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockGetTableById.mockResolvedValue(ARCHIVED_TABLE) - mockGetUserEntityPermissions.mockResolvedValue('write') - mockGateError.mockResolvedValue(null) - }) - - it('restores through the orchestration function and returns the table', async () => { - mockPerformRestoreTable.mockResolvedValue({ success: true, table: RESTORED_TABLE }) - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ - table: { - id: 'table-1', - name: 'Tasks', - description: null, - schema: { columns: [] }, - rowCount: 7, - maxRows: 1000, - folderId: null, - locks: UNLOCKED, - job: null, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-02T00:00:00.000Z', - }, - }) - // Archived tables are invisible to `getTableById` by default; without the - // opt-in the route would 404 every restore. - expect(mockGetTableById).toHaveBeenCalledWith('table-1', { includeArchived: true }) - expect(mockPerformRestoreTable).toHaveBeenCalledWith( - expect.objectContaining({ tableId: 'table-1', userId: 'user-1' }) - ) - }) - - it('404s an archived table belonging to another workspace', async () => { - mockGetTableById.mockResolvedValue({ ...ARCHIVED_TABLE, workspaceId: 'ws-other' }) - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(404) - expect(mockPerformRestoreTable).not.toHaveBeenCalled() - }) - - it('403s a read-only member', async () => { - mockGetUserEntityPermissions.mockResolvedValue('read') - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(403) - expect(mockPerformRestoreTable).not.toHaveBeenCalled() - }) - - it('maps a name collision with a live table to 409 CONFLICT', async () => { - mockPerformRestoreTable.mockResolvedValue({ - success: false, - errorCode: 'conflict', - error: 'A table named "Tasks" already exists', - }) - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - }) - - it('400s a body with no workspace', async () => { - const res = await callPost({}) - - expect(res.status).toBe(400) - expect(mockPerformRestoreTable).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(404) - expect(mockGetTableById).not.toHaveBeenCalled() - expect(mockPerformRestoreTable).not.toHaveBeenCalled() - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(429) - expect(mockPerformRestoreTable).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/restore/route.ts b/apps/sim/app/api/v2/tables/[tableId]/restore/route.ts deleted file mode 100644 index 25485da61da..00000000000 --- a/apps/sim/app/api/v2/tables/[tableId]/restore/route.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { v2RestoreTableContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getTableById } from '@/lib/table' -import { performRestoreTable } from '@/lib/table/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { toApiTable } from '@/app/api/v2/tables/utils' - -const logger = createLogger('V2TableRestoreAPI') - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - -/** - * POST /api/v2/tables/[tableId]/restore — Un-archive a table. - * - * The only table endpoint that cannot use `checkAccess`: its target is archived - * by definition, and `checkAccess` resolves active tables only. The permission - * check is therefore done against the archived row's own workspace, which is - * also what makes the workspace-match check an IDOR guard rather than a - * formality. - */ -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-restore') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2RestoreTableContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId } = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const archived = await getTableById(tableId, { includeArchived: true }) - // Mask a missing table and a foreign one alike so archived-table existence - // never leaks across workspaces. - if (!archived || archived.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const permission = await getUserEntityPermissions(userId, 'workspace', archived.workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return v2Error('FORBIDDEN', 'Access denied') - } - - const outcome = await performRestoreTable({ tableId, userId, requestId }) - if (!outcome.success || !outcome.table) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to restore table') - } - - return v2Data({ table: toApiTable(outcome.table) }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error restoring table`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts index 735529f80a0..866165ddb60 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts @@ -19,7 +19,7 @@ const { mockPerformUpdateTableLocks, mockRecordAudit, mockGetTableById, - mockFindActiveFolder, + mockLoadActiveFolderPathIndex, mockGateError, mockSignalSchemaChanged, } = vi.hoisted(() => ({ @@ -32,7 +32,7 @@ const { mockPerformUpdateTableLocks: vi.fn(), mockRecordAudit: vi.fn(), mockGetTableById: vi.fn(), - mockFindActiveFolder: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), mockGateError: vi.fn(), mockSignalSchemaChanged: vi.fn(), })) @@ -66,7 +66,9 @@ vi.mock('@/lib/table', () => ({ vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mockSignalSchemaChanged, })) -vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder })) +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: vi.fn().mockResolvedValue({ organizationId: 'org-1' }), })) @@ -138,7 +140,11 @@ beforeEach(() => { mockResolveWorkspaceScope.mockResolvedValue(null) mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) mockGetTableById.mockResolvedValue(UPDATED_TABLE) - mockFindActiveFolder.mockResolvedValue({ id: 'folder-1' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['folder-1', { id: 'folder-1', name: 'Reports', parentId: null }]]), + pathById: new Map([['folder-1', '/Reports']]), + idByPath: new Map([['/Reports', 'folder-1']]), + }) mockGateError.mockResolvedValue(null) }) @@ -186,7 +192,7 @@ describe('PATCH /api/v2/tables/[tableId]', () => { schema: { columns: [] }, rowCount: 0, maxRows: 1000, - folderId: null, + folderPath: '/', locks: UNLOCKED, job: null, createdAt: '2026-01-01T00:00:00.000Z', @@ -228,19 +234,17 @@ describe('PATCH /api/v2/tables/[tableId]', () => { it('moves the table only after confirming the folder belongs to the workspace', async () => { mockPerformMoveTableToFolder.mockResolvedValue({ success: true }) - const res = await callPatch({ workspaceId: 'ws-1', folderId: 'folder-1' }) + const res = await callPatch({ workspaceId: 'ws-1', folderPath: '/Reports' }) expect(res.status).toBe(200) - expect(mockFindActiveFolder).toHaveBeenCalledWith('folder-1', 'ws-1', 'table') + expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith('ws-1', 'table', expect.any(Object)) expect(mockPerformMoveTableToFolder).toHaveBeenCalledWith( expect.objectContaining({ table: TABLE, folderId: 'folder-1', userId: 'user-1' }) ) }) it('404s a folder from outside the workspace without attempting the move', async () => { - mockFindActiveFolder.mockResolvedValue(null) - - const res = await callPatch({ workspaceId: 'ws-1', folderId: 'folder-elsewhere' }) + const res = await callPatch({ workspaceId: 'ws-1', folderPath: '/Elsewhere' }) expect(res.status).toBe(404) expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() @@ -249,12 +253,10 @@ describe('PATCH /api/v2/tables/[tableId]', () => { it('rejects a bad folder without applying the rename that came with it', async () => { // The three operations are separate transactions, so validation has to run // before the first write — otherwise a rejected PATCH still renames. - mockFindActiveFolder.mockResolvedValue(null) - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', - folderId: 'folder-elsewhere', + folderPath: '/Elsewhere', }) expect(res.status).toBe(404) @@ -274,7 +276,7 @@ describe('PATCH /api/v2/tables/[tableId]', () => { error: 'gone', }) - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderId: 'folder-1' }) + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderPath: '/Reports' }) expect(res.status).toBe(404) expect((await res.json()).error.details).toEqual({ applied: ['name'] }) @@ -288,7 +290,7 @@ describe('PATCH /api/v2/tables/[tableId]', () => { error: 'taken', }) - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderId: 'folder-1' }) + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderPath: '/Reports' }) expect(res.status).toBe(409) expect((await res.json()).error.details).toBeUndefined() @@ -305,7 +307,7 @@ describe('PATCH /api/v2/tables/[tableId]', () => { error: 'gone', }) - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderId: 'folder-1' }) + const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderPath: '/Reports' }) expect(res.status).toBe(404) expect(mockPerformRenameTable).toHaveBeenCalled() @@ -369,7 +371,9 @@ describe('PATCH /api/v2/tables/[tableId]', () => { it('omits applied details when the failure happened before any write', async () => { mockGetTableById.mockRejectedValue(new Error('connection reset')) - const res = await callPatch({ workspaceId: 'ws-1', folderId: 'nope' }) + mockLoadActiveFolderPathIndex.mockRejectedValue(new Error('connection reset')) + + const res = await callPatch({ workspaceId: 'ws-1', folderPath: '/Nope' }) // Absence is meaningful: nothing is live, so a retry is safe. expect((await res.json()).error.details).toBeUndefined() diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index 447bd76e4c2..c5406bae297 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -10,7 +10,8 @@ import { parseRequest } from '@/lib/api/server' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { findActiveFolder } from '@/lib/folders/queries' +import { withFolderTreeLock } from '@/lib/folders/locks' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { getTableById } from '@/lib/table' import { signalTableSchemaChanged } from '@/lib/table/events' import { @@ -20,6 +21,7 @@ import { } from '@/lib/table/orchestration' import { checkAccess } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { folderPathForId, resolveFolderPathId } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, @@ -44,7 +46,7 @@ const logger = createLogger('V2TableDetailAPI') * means "these changes are live despite the error". */ function appliedDetails( - applied: readonly ('name' | 'folderId')[] + applied: readonly ('name' | 'folderPath')[] ): { applied: readonly string[] } | undefined { return applied.length > 0 ? { applied } : undefined } @@ -88,7 +90,11 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR return v2Error('NOT_FOUND', 'Table not found') } - return v2Data({ table: toApiTable(result.table) }, { rateLimit }) + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'table') + return v2Data( + { table: toApiTable(result.table, folderPathForId(folderIndex, result.table.folderId)) }, + { rateLimit } + ) } catch (error) { logger.error(`[${requestId}] Error getting table`, { error: getErrorMessage(error, 'Unknown error'), @@ -119,7 +125,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl * archived. Reporting a bare 500 there tells the caller nothing landed, and * it retries into a duplicate-name conflict or a repeated move. */ - const applied: ('name' | 'folderId')[] = [] + const applied: ('name' | 'folderPath')[] = [] try { const rateLimit = await checkRateLimit(request, 'table-detail') @@ -149,76 +155,75 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl return v2Error('NOT_FOUND', 'Table not found') } - // The two operations are separate transactions, so a rejection discovered - // partway through would leave the earlier one persisted while the response - // reports failure. Everything a request can be rejected for is therefore - // checked up front: a rejected PATCH changes nothing. - if (validated.folderId != null) { - // Scoped to `resourceType: 'table'` so a folder id from another resource's - // tree can't file the table somewhere Tables never lists. - if (!(await findActiveFolder(validated.folderId, table.workspaceId, 'table'))) { + return await withFolderTreeLock(table.workspaceId, 'table', async (tx) => { + const folderIndex = await loadActiveFolderPathIndex(table.workspaceId, 'table', tx) + const folderId = + validated.folderPath === undefined + ? undefined + : resolveFolderPathId(folderIndex, validated.folderPath) + if (validated.folderPath !== undefined && folderId === undefined) { return v2Error('NOT_FOUND', 'Folder not found in this workspace') } - } - // Every deterministic rejection is already behind us, so a failure here is - // a genuine fault (lost race, archived mid-request, database error) rather - // than a bad request. The two operations commit independently — a single - // transaction would have to span two shared service functions that also - // back the first-party route and two copilot tools, and would break their - // per-operation audits — so instead of pretending atomicity the response - // states exactly which operations landed. A caller that gets an error can - // then reconcile rather than having to re-read and diff. - let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null - - if (validated.name !== undefined) { - const outcome = await performRenameTable({ - table, - newName: validated.name, - userId, - requestId, - request, - }) - if (outcome.success) applied.push('name') - else failure = { outcome, fallback: 'Failed to rename table' } - } + // Rename and move retain their shared services' independent transactions and audits. + // Validate deterministic failures first and report `applied` so callers can reconcile + // if a later fault lands after an earlier operation commits. + let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null + + if (validated.name !== undefined) { + const outcome = await performRenameTable({ + table, + newName: validated.name, + userId, + requestId, + request, + }) + if (outcome.success) applied.push('name') + else failure = { outcome, fallback: 'Failed to rename table' } + } - if (!failure && validated.folderId !== undefined) { - const outcome = await performMoveTableToFolder({ - table, - folderId: validated.folderId, - userId, - requestId, - request, - }) - if (outcome.success) { - applied.push('folderId') - } else { - // The move re-asserts workspace and active state, so a miss means the - // table was archived between `checkAccess` and the write. - failure = { - outcome: - outcome.errorCode === 'not_found' ? { ...outcome, error: 'Table not found' } : outcome, - fallback: 'Failed to move table', + if (!failure && validated.folderPath !== undefined) { + const outcome = await performMoveTableToFolder({ + table, + folderId: folderId ?? null, + userId, + requestId, + request, + }) + if (outcome.success) { + applied.push('folderPath') + } else { + // The move re-asserts workspace and active state, so a miss means the + // table was archived between `checkAccess` and the write. + failure = { + outcome: + outcome.errorCode === 'not_found' + ? { ...outcome, error: 'Table not found' } + : outcome, + fallback: 'Failed to move table', + } } } - } - // Live-collab: tell open viewers the definition changed so they refetch. - if (applied.length > 0) signalTableSchemaChanged(tableId) - if (failure) { - return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied)) - } + // Live-collab: tell open viewers the definition changed so they refetch. + if (applied.length > 0) signalTableSchemaChanged(tableId) + if (failure) { + return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied)) + } - // Re-read so the response reflects every applied change at once. A miss - // means the table was archived after the writes committed, so the caller - // still has to be told what landed. - const updated = await getTableById(tableId) - if (!updated) { - return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) }) - } + // Re-read so the response reflects every applied change at once. A miss + // means the table was archived after the writes committed, so the caller + // still has to be told what landed. + const updated = await getTableById(tableId) + if (!updated) { + return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) }) + } - return v2Data({ table: toApiTable(updated) }, { rateLimit }) + return v2Data( + { table: toApiTable(updated, folderPathForId(folderIndex, updated.folderId)) }, + { rateLimit } + ) + }) } catch (error) { const details = appliedDetails(applied) @@ -242,7 +247,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl } }) -/** DELETE /api/v2/tables/[tableId] — Archive a table. */ export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { const requestId = generateRequestId() diff --git a/apps/sim/app/api/v2/tables/folders/route.ts b/apps/sim/app/api/v2/tables/folders/route.ts new file mode 100644 index 00000000000..c9744bf395b --- /dev/null +++ b/apps/sim/app/api/v2/tables/folders/route.ts @@ -0,0 +1,182 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateTableFolderContract, + v2DeleteTableFolderContract, + v2ListTableFoldersContract, + v2RelocateTableFolderContract, +} from '@/lib/api/contracts/v2/tables' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createFolderAtPath, + deleteFolderByPath, + relocateFolderByPath, +} from '@/lib/folders/orchestration' +import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + resolveFolderPathId, + toV2PathFolder, + v2FolderPathMutationError, +} from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2TableFoldersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + try { + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2ListTableFoldersContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const index = await loadActiveFolderPathIndex(workspaceId, 'table') + const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) + if (parentPath !== undefined && parentId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const rows = await listActiveFolderRows(workspaceId, 'table', { + parentId, + search, + sortBy, + sortOrder, + }) + return v2CursorList( + rows.map((row) => toV2PathFolder(row, index, false)), + null, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error listing table folders`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +export const POST = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2CreateTableFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await createFolderAtPath({ resourceType: 'table', workspaceId, userId, path }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'table') + return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit, status: 201 }) +}) + +export const PATCH = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2RelocateTableFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, destinationPath } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await relocateFolderByPath({ + resourceType: 'table', + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'table') + return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit }) +}) + +export const DELETE = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'tables') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2DeleteTableFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, recursive } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await deleteFolderByPath({ + resourceType: 'table', + workspaceId, + userId, + path, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + return v2Data( + { + path, + deleted: true as const, + deletedItems: { + folders: result.deletedItems.folders, + tables: result.deletedItems.tables ?? 0, + }, + }, + { rateLimit } + ) +}) 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 a45e7de4fce..f5c6a5f7541 100644 --- a/apps/sim/app/api/v2/tables/imports/route.test.ts +++ b/apps/sim/app/api/v2/tables/imports/route.test.ts @@ -9,11 +9,13 @@ const { mockResolveWorkspaceScope, mockCreateTableImportResource, mockToV2CreateTableImport, + mockLoadActiveFolderPathIndex, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceScope: vi.fn(), mockCreateTableImportResource: vi.fn(), mockToV2CreateTableImport: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -34,6 +36,10 @@ vi.mock('@/lib/table/orchestration/import-resource', () => ({ toV2CreateTableImport: mockToV2CreateTableImport, })) +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) + import { POST } from '@/app/api/v2/tables/imports/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' @@ -51,6 +57,11 @@ describe('POST /api/v2/tables/imports', () => { vi.clearAllMocks() mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) mockResolveWorkspaceScope.mockResolvedValue(null) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map(), + pathById: new Map(), + idByPath: new Map(), + }) }) it.each([ @@ -94,7 +105,8 @@ describe('POST /api/v2/tables/imports', () => { expect(mockCreateTableImportResource).toHaveBeenCalledWith( requestBody, 'user-1', - 'http://localhost:3000' + 'http://localhost:3000', + null ) expect(mockToV2CreateTableImport).toHaveBeenCalledWith(created) expect(await response.json()).toEqual({ data: responseData }) diff --git a/apps/sim/app/api/v2/tables/imports/route.ts b/apps/sim/app/api/v2/tables/imports/route.ts index ad533a55e58..5908ffd57a0 100644 --- a/apps/sim/app/api/v2/tables/imports/route.ts +++ b/apps/sim/app/api/v2/tables/imports/route.ts @@ -9,6 +9,7 @@ import { toV2CreateTableImport, } from '@/lib/table/orchestration/import-resource' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { withResolvedFolderPathMutation } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CaughtOrchestrationError, @@ -40,11 +41,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.body.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const created = await createTableImportResource( - parsed.data.body, - userId, - request.nextUrl.origin - ) + let created: Awaited> + if (parsed.data.body.target.type === 'new') { + const mutation = await withResolvedFolderPathMutation({ + workspaceId: parsed.data.body.workspaceId, + resourceType: 'table', + path: parsed.data.body.target.folderPath ?? '/', + mutate: (folderId) => + createTableImportResource(parsed.data.body, userId, request.nextUrl.origin, folderId), + }) + if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found') + created = mutation.value + } else { + created = await createTableImportResource(parsed.data.body, userId, request.nextUrl.origin) + } return v2Data(toV2CreateTableImport(created), { rateLimit, status: 201 }) } catch (error) { const lockError = v2TableLockError(error) diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts index 51eff8841ec..0412235b367 100644 --- a/apps/sim/app/api/v2/tables/route.test.ts +++ b/apps/sim/app/api/v2/tables/route.test.ts @@ -14,12 +14,14 @@ const { mockResolveWorkspaceAccess, mockIsFeatureEnabled, mockGetWorkspaceOrganizationId, + mockLoadActiveFolderPathIndex, } = vi.hoisted(() => ({ mockQueryTables: vi.fn(), mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), mockIsFeatureEnabled: vi.fn(), mockGetWorkspaceOrganizationId: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -50,6 +52,10 @@ vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null), })) +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) + import { GET } from '@/app/api/v2/tables/route' const RATE_LIMIT_OK = { @@ -91,6 +97,11 @@ describe('GET /api/v2/tables', () => { mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: null }) mockIsFeatureEnabled.mockResolvedValue(true) mockGetWorkspaceOrganizationId.mockResolvedValue('org-1') + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map(), + pathById: new Map(), + idByPath: new Map(), + }) }) it('returns 404 when the v2 API surface flag is off', async () => { @@ -162,6 +173,15 @@ describe('GET /api/v2/tables', () => { expect((await res.json()).nextCursor).toBeNull() }) + it('treats folderPath=/ as root-only while omission lists every folder', async () => { + await callList('workspaceId=workspace-1&folderPath=%2F') + + expect(mockQueryTables).toHaveBeenCalledWith( + 'workspace-1', + expect.objectContaining({ folderId: null }) + ) + }) + it('passes limit and the decoded cursor through to the query', async () => { mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: null }) diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index a8db12e87f9..f57826e1bc2 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -6,9 +6,15 @@ import { v2CreateTableContract, v2ListTablesContract } from '@/lib/api/contracts import { isZodError, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { createTable, getWorkspaceTableLimits, queryTables, type TableSchema } from '@/lib/table' import { normalizeColumn } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + folderPathForId, + resolveFolderPathId, + withResolvedFolderPathMutation, +} from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { cursorSortKey, @@ -53,11 +59,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, folderId, search, sortBy, sortOrder, limit, cursor } = parsed.data.query + const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'table') + const folderId = + folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) + if (folderPath !== undefined && folderId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const sort = cursorSortKey(sortBy, sortOrder) const decoded = decodeSortedCursor(cursor, sort) if (decoded.status === 'invalid') return v2CursorSortError() @@ -71,7 +84,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { after: decoded.status === 'ok' ? decoded.keys : undefined, }) - const items = tables.map(toApiTable) + const items = tables.map((table) => + toApiTable(table, folderPathForId(folderIndex, table.folderId)) + ) const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null return v2CursorList(items, nextCursor, { rateLimit }) @@ -117,17 +132,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => { columns: params.schema.columns.map(normalizeColumn), } - const table = await createTable( - { - name: params.name, - description: params.description, - schema: normalizedSchema, - workspaceId: params.workspaceId, - userId, - maxTables: planLimits.maxTables, - }, - requestId - ) + const mutation = await withResolvedFolderPathMutation({ + workspaceId: params.workspaceId, + resourceType: 'table', + path: params.folderPath ?? '/', + mutate: (folderId) => + createTable( + { + name: params.name, + description: params.description, + schema: normalizedSchema, + workspaceId: params.workspaceId, + userId, + maxTables: planLimits.maxTables, + folderId, + }, + requestId + ), + }) + if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found') + const table = mutation.value recordAudit({ workspaceId: params.workspaceId, @@ -141,7 +165,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { request, }) - return v2Data({ table: toApiTable(table) }, { rateLimit, status: 201 }) + return v2Data( + { table: toApiTable(table, folderPathForId(mutation.index, table.folderId)) }, + { rateLimit, status: 201 } + ) } catch (error) { if (isZodError(error)) return v2ValidationError(error) diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index bcc780e23ab..d3cd6dfe6e5 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -53,7 +53,7 @@ export function v2BulkPredicateToFilter(predicate: TablePredicate, schema: Table * exposes, with timestamps serialized to ISO strings. Shared by every v2 table * endpoint so the table payload is identical across the surface. */ -export function toApiTable(table: TableDefinition) { +export function toApiTable(table: TableDefinition, folderPath: string) { return { id: table.id, name: table.name, @@ -63,7 +63,7 @@ export function toApiTable(table: TableDefinition) { }, rowCount: table.rowCount, maxRows: table.maxRows, - folderId: table.folderId ?? null, + folderPath, locks: table.locks, // `jobStatus` is the presence signal — the service leaves the whole group // null when the table is idle. Without this an async import could be 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 35ab0d287d6..c4caa0ec7d8 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.ts @@ -7,8 +7,10 @@ import type { NextRequest } from 'next/server' import { v2ExportWorkflowContract } from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { folderPathForId } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' @@ -57,6 +59,8 @@ export const GET = withRouteHandler( const payload = await buildWorkflowExportPayload(workflowData) if (!payload) return v2Error('NOT_FOUND', 'Workflow state not found') + const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') + const folderPath = folderPathForId(folderIndex, workflowData.folderId) recordAudit({ workspaceId: workflowData.workspaceId, @@ -68,14 +72,26 @@ export const GET = withRouteHandler( description: `Exported workflow "${workflowData.name}" via the API`, metadata: { workspaceId: workflowData.workspaceId, - folderId: workflowData.folderId || undefined, + folderPath, blocksCount: Object.keys(payload.state.blocks).length, edgesCount: payload.state.edges.length, }, request, }) - return v2Data(payload, { rateLimit }) + return v2Data( + { + ...payload, + workflow: { + id: payload.workflow.id, + name: payload.workflow.name, + description: payload.workflow.description, + workspaceId: payload.workflow.workspaceId, + folderPath, + }, + }, + { rateLimit } + ) } catch (error) { logger.error(`[${requestId}] Workflow export error`, { error: getErrorMessage(error, 'Unknown error'), diff --git a/apps/sim/app/api/v2/workflows/[id]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/route.test.ts index 432027d8cc9..33de9864022 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.test.ts @@ -17,10 +17,9 @@ const { mockPerformDeleteWorkflow, mockAssertWorkflowMutable, mockAssertFolderMutable, - mockAssertFolderInWorkspace, + mockLoadActiveFolderPathIndex, WorkflowLockedErrorMock, FolderLockedErrorMock, - FolderNotFoundErrorMock, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), @@ -29,16 +28,13 @@ const { mockPerformDeleteWorkflow: vi.fn(), mockAssertWorkflowMutable: vi.fn(), mockAssertFolderMutable: vi.fn(), - mockAssertFolderInWorkspace: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), WorkflowLockedErrorMock: class WorkflowLockedError extends Error { status = 423 }, FolderLockedErrorMock: class FolderLockedError extends Error { status = 423 }, - FolderNotFoundErrorMock: class FolderNotFoundError extends Error { - status = 400 - }, })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -55,10 +51,12 @@ vi.mock('@sim/platform-authz/workflow', () => ({ getActiveWorkflowRecord: mockGetActiveWorkflowRecord, assertWorkflowMutable: mockAssertWorkflowMutable, assertFolderMutable: mockAssertFolderMutable, - assertFolderInWorkspace: mockAssertFolderInWorkspace, WorkflowLockedError: WorkflowLockedErrorMock, FolderLockedError: FolderLockedErrorMock, - FolderNotFoundError: FolderNotFoundErrorMock, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, })) vi.mock('@/lib/workflows/input-format', () => ({ @@ -147,7 +145,11 @@ describe('PATCH /api/v2/workflows/[id]', () => { mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) mockAssertWorkflowMutable.mockResolvedValue(undefined) mockAssertFolderMutable.mockResolvedValue(undefined) - mockAssertFolderInWorkspace.mockResolvedValue(undefined) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['fld-1', { id: 'fld-1', name: 'Locked', parentId: null }]]), + pathById: new Map([['fld-1', '/Locked']]), + idByPath: new Map([['/Locked', 'fld-1']]), + }) mockPerformUpdateWorkflow.mockResolvedValue({ success: true, workflow: UPDATED }) }) @@ -200,43 +202,34 @@ describe('PATCH /api/v2/workflows/[id]', () => { it('423s when the destination folder is locked', async () => { mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) - const res = await callPatch({ folderId: 'fld-1' }) + const res = await callPatch({ folderPath: '/Locked' }) expect(res.status).toBe(423) expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() }) - it('400s a folder outside the workspace without ever reading its lock state', async () => { - mockAssertFolderInWorkspace.mockRejectedValue( - new FolderNotFoundErrorMock('Target folder not found') - ) - const res = await callPatch({ folderId: 'fld-other-workspace' }) + it('404s a path outside the workspace without ever reading its lock state', async () => { + const res = await callPatch({ folderPath: '/Elsewhere' }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - // Containment runs first, so a locked foreign folder cannot be told apart - // from a nonexistent one by its status code. + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') expect(mockAssertFolderMutable).not.toHaveBeenCalled() expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() }) - it('checks folder containment against the workflow workspace before mutability', async () => { - const order: string[] = [] - mockAssertFolderInWorkspace.mockImplementation(async () => { - order.push('containment') - }) - mockAssertFolderMutable.mockImplementation(async () => { - order.push('mutability') - }) - - await callPatch({ folderId: 'fld-1' }) + it('resolves the canonical path against the workflow workspace before mutability', async () => { + await callPatch({ folderPath: '/Locked' }) - expect(order).toEqual(['containment', 'mutability']) - expect(mockAssertFolderInWorkspace).toHaveBeenCalledWith('fld-1', 'workspace-1') + expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith( + 'workspace-1', + 'workflow', + expect.any(Object) + ) + expect(mockAssertFolderMutable).toHaveBeenCalledWith('fld-1') }) it('skips the containment check on a rename that does not move the workflow', async () => { await callPatch({ name: 'Support Agent v2' }) - expect(mockAssertFolderInWorkspace).not.toHaveBeenCalled() + expect(mockAssertFolderMutable).not.toHaveBeenCalled() }) it('409s when the target name is taken in the destination folder', async () => { @@ -260,7 +253,7 @@ describe('PATCH /api/v2/workflows/[id]', () => { id: 'wf-1', name: 'Support Agent v2', description: 'Handles tickets', - folderId: null, + folderPath: '/', workspaceId: 'workspace-1', isDeployed: true, deployedAt: '2024-01-03T00:00:00.000Z', diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index a3b22e05dc0..9123fb7b89e 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -2,11 +2,9 @@ import { db } from '@sim/db' import { workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { - assertFolderInWorkspace, assertFolderMutable, assertWorkflowMutable, FolderLockedError, - FolderNotFoundError, getActiveWorkflowRecord, WorkflowLockedError, } from '@sim/platform-authz/workflow' @@ -23,9 +21,12 @@ import { } from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { withFolderTreeLock } from '@/lib/folders/locks' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { folderPathForId, resolveFolderPathId } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, @@ -70,6 +71,8 @@ export const GET = withRouteHandler( const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) if (access) return v2Error('NOT_FOUND', 'Workflow not found') + const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') + const blockRows = await db .select({ id: workflowBlocks.id, @@ -88,7 +91,7 @@ export const GET = withRouteHandler( id: workflowData.id, name: workflowData.name, description: workflowData.description, - folderId: workflowData.folderId, + folderPath: folderPathForId(folderIndex, workflowData.folderId), workspaceId: workflowData.workspaceId, isDeployed: workflowData.isDeployed, deployedAt: workflowData.deployedAt?.toISOString() ?? null, @@ -129,7 +132,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout if (!parsed.success) return parsed.response const { id } = parsed.data.params - const { name, description, folderId } = parsed.data.body + const { name, description, folderPath } = parsed.data.body const workflowData = await getActiveWorkflowRecord(id) if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') @@ -143,27 +146,31 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout ) if (access) return v2Error('NOT_FOUND', 'Workflow not found') - /** - * Ownership before lock state: `assertFolderMutable` walks the folder's - * ancestor chain without filtering on workspace, so checking it first would - * let a caller distinguish a locked folder in someone else's workspace - * (423) from one that simply does not exist (400). - */ - if (folderId) await assertFolderInWorkspace(folderId, workflowData.workspaceId) - await assertWorkflowMutable(id) - if (folderId !== undefined) await assertFolderMutable(folderId) - - const result = await performUpdateWorkflow({ - workflowId: id, - userId, - workspaceId: workflowData.workspaceId, - currentName: workflowData.name, - currentFolderId: workflowData.folderId, - name, - description, - folderId, - requestId, + const mutation = await withFolderTreeLock(workflowData.workspaceId, 'workflow', async (tx) => { + const index = await loadActiveFolderPathIndex(workflowData.workspaceId!, 'workflow', tx) + const folderId = folderPath === undefined ? undefined : resolveFolderPathId(index, folderPath) + if (folderPath !== undefined && folderId === undefined) return { found: false as const } + + await assertWorkflowMutable(id) + if (folderId !== undefined) await assertFolderMutable(folderId) + + const result = await performUpdateWorkflow({ + workflowId: id, + userId, + workspaceId: workflowData.workspaceId!, + currentName: workflowData.name, + currentFolderId: workflowData.folderId, + name, + description, + folderId, + requestId, + }) + return { found: true as const, index, result } }) + if (!mutation.found) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const { index: folderIndex, result } = mutation if (!result.success || !result.workflow) { return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to update workflow') @@ -178,7 +185,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout id: updated.id, name: updated.name, description: updated.description, - folderId: updated.folderId, + folderPath: folderPathForId(folderIndex, updated.folderId), workspaceId: updated.workspaceId ?? workflowData.workspaceId, isDeployed: workflowData.isDeployed, deployedAt: workflowData.deployedAt?.toISOString() ?? null, @@ -190,7 +197,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout return v2Data(item, { rateLimit }) } catch (error) { - if (error instanceof FolderNotFoundError) return v2Error('BAD_REQUEST', error.message) if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { return v2Error('LOCKED', error.message) } @@ -202,7 +208,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout } }) -/** DELETE /api/v2/workflows/[id] — Archive a workflow into Recently Deleted. */ export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { const requestId = generateId().slice(0, 8) diff --git a/apps/sim/app/api/v2/workflows/folders/route.test.ts b/apps/sim/app/api/v2/workflows/folders/route.test.ts new file mode 100644 index 00000000000..696286333ed --- /dev/null +++ b/apps/sim/app/api/v2/workflows/folders/route.test.ts @@ -0,0 +1,216 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockLoadActiveFolderPathIndex, + mockListActiveFolderRows, + mockCreateFolderAtPath, + mockRelocateFolderByPath, + mockDeleteFolderByPath, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), + mockListActiveFolderRows: vi.fn(), + mockCreateFolderAtPath: vi.fn(), + mockRelocateFolderByPath: vi.fn(), + mockDeleteFolderByPath: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, + listActiveFolderRows: mockListActiveFolderRows, +})) + +vi.mock('@/lib/folders/orchestration', () => ({ + createFolderAtPath: mockCreateFolderAtPath, + relocateFolderByPath: mockRelocateFolderByPath, + deleteFolderByPath: mockDeleteFolderByPath, +})) + +import { DELETE, GET, PATCH, POST } from '@/app/api/v2/workflows/folders/route' + +const WORKSPACE_ID = 'workspace-1' +const FOLDER_ID = 'internal-folder-id' +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const folder = { + id: FOLDER_ID, + resourceType: 'workflow' as const, + name: 'Reports', + userId: 'user-1', + workspaceId: WORKSPACE_ID, + parentId: null, + sortOrder: 0, + locked: false, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + deletedAt: null, +} + +function pathIndex(path = '/Reports') { + return { + rowById: new Map([[FOLDER_ID, folder]]), + pathById: new Map([[FOLDER_ID, path]]), + idByPath: new Map([[path, FOLDER_ID]]), + } +} + +function request(method: string, path: string, body?: Record) { + return new NextRequest(`http://localhost:3000${path}`, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) +} + +describe('/api/v2/workflows/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockLoadActiveFolderPathIndex.mockResolvedValue(pathIndex()) + mockListActiveFolderRows.mockResolvedValue([folder]) + mockCreateFolderAtPath.mockResolvedValue({ + success: true, + folder, + path: '/Reports', + }) + mockRelocateFolderByPath.mockResolvedValue({ + success: true, + folder, + path: '/Reports', + }) + mockDeleteFolderByPath.mockResolvedValue({ + success: true, + path: '/Reports', + deletedItems: { folders: 1, workflows: 2 }, + }) + }) + + it('lists only root children when parentPath is root and never exposes database ids', async () => { + const response = await GET( + request('GET', `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}&parentPath=%2F`) + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(mockListActiveFolderRows).toHaveBeenCalledWith(WORKSPACE_ID, 'workflow', { + parentId: null, + search: undefined, + sortBy: 'name', + sortOrder: 'asc', + }) + expect(body.data).toEqual([ + { + name: 'Reports', + path: '/Reports', + parentPath: '/', + locked: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + }) + + it('omits the parent filter to list folders from the whole tree', async () => { + await GET(request('GET', `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}`)) + + expect(mockListActiveFolderRows).toHaveBeenCalledWith(WORKSPACE_ID, 'workflow', { + parentId: undefined, + search: undefined, + sortBy: 'name', + sortOrder: 'asc', + }) + }) + + it('creates a folder from a canonical path and rejects internal ids', async () => { + const created = await POST( + request('POST', '/api/v2/workflows/folders', { + workspaceId: WORKSPACE_ID, + path: '/Reports', + }) + ) + + expect(created.status).toBe(201) + expect(mockCreateFolderAtPath).toHaveBeenCalledWith({ + resourceType: 'workflow', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + path: '/Reports', + }) + + const rejected = await POST( + request('POST', '/api/v2/workflows/folders', { + workspaceId: WORKSPACE_ID, + path: '/Reports', + folderId: FOLDER_ID, + }) + ) + expect(rejected.status).toBe(400) + }) + + it('relocates one folder by source and destination paths', async () => { + mockLoadActiveFolderPathIndex.mockResolvedValue(pathIndex('/Archive')) + const response = await PATCH( + request('PATCH', '/api/v2/workflows/folders', { + workspaceId: WORKSPACE_ID, + path: '/Reports', + destinationPath: '/Archive', + }) + ) + + expect(response.status).toBe(200) + expect(mockRelocateFolderByPath).toHaveBeenCalledWith({ + resourceType: 'workflow', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + path: '/Reports', + destinationPath: '/Archive', + }) + }) + + it('requires an explicit recursive delete choice', async () => { + const missing = await DELETE( + request('DELETE', `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}&path=%2FReports`) + ) + expect(missing.status).toBe(400) + expect(mockDeleteFolderByPath).not.toHaveBeenCalled() + + const deleted = await DELETE( + request( + 'DELETE', + `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}&path=%2FReports&recursive=true` + ) + ) + expect(deleted.status).toBe(200) + expect(await deleted.json()).toEqual({ + data: { + path: '/Reports', + deleted: true, + deletedItems: { folders: 1, workflows: 2 }, + }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/folders/route.ts b/apps/sim/app/api/v2/workflows/folders/route.ts new file mode 100644 index 00000000000..bb791ec6dce --- /dev/null +++ b/apps/sim/app/api/v2/workflows/folders/route.ts @@ -0,0 +1,186 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateWorkflowFolderContract, + v2DeleteWorkflowFolderContract, + v2ListWorkflowFoldersContract, + v2RelocateWorkflowFolderContract, +} from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createFolderAtPath, + deleteFolderByPath, + relocateFolderByPath, +} from '@/lib/folders/orchestration' +import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + resolveFolderPathId, + toV2PathFolder, + v2FolderPathMutationError, +} from '@/app/api/v2/lib/folders' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowFoldersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + try { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListWorkflowFoldersContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') + const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) + if (parentPath !== undefined && parentId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const rows = await listActiveFolderRows(workspaceId, 'workflow', { + parentId, + search, + sortBy, + sortOrder, + }) + return v2CursorList( + rows.map((row) => toV2PathFolder(row, index, true)), + null, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error listing workflow folders`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +export const POST = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2CreateWorkflowFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await createFolderAtPath({ resourceType: 'workflow', workspaceId, userId, path }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') + return v2Data({ folder: toV2PathFolder(result.folder, index, true) }, { rateLimit, status: 201 }) +}) + +export const PATCH = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2RelocateWorkflowFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, destinationPath } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await relocateFolderByPath({ + resourceType: 'workflow', + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') + return v2Data({ folder: toV2PathFolder(result.folder, index, true) }, { rateLimit }) +}) + +export const DELETE = withRouteHandler(async (request: NextRequest) => { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + const parsed = await parseRequest( + v2DeleteWorkflowFolderContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, path, recursive } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await deleteFolderByPath({ + resourceType: 'workflow', + workspaceId, + userId, + path, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + return v2Data( + { + path, + deleted: true as const, + deletedItems: { + folders: result.deletedItems.folders, + workflows: result.deletedItems.workflows ?? 0, + }, + }, + { rateLimit } + ) +}) diff --git a/apps/sim/app/api/v2/workflows/import/route.ts b/apps/sim/app/api/v2/workflows/import/route.ts index af66621b16c..5338f9c8cb9 100644 --- a/apps/sim/app/api/v2/workflows/import/route.ts +++ b/apps/sim/app/api/v2/workflows/import/route.ts @@ -10,6 +10,7 @@ import { MAX_IMPORT_BODY_BYTES, } from '@/lib/workflows/operations/import-workflow' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { folderPathForId, withResolvedFolderPathMutation } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { type V2ErrorCode, @@ -64,25 +65,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, folderId, name, description } = parsed.data.body + const { workspaceId, folderPath, name, description } = parsed.data.body logger.info(`[${requestId}] Importing workflow into workspace ${workspaceId}`, { userId, - folderId, + folderPath, }) const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - const result = await importWorkflowIntoWorkspace({ + const mutation = await withResolvedFolderPathMutation({ workspaceId, - folderId, - name, - description, - workflow: parsed.data.body.workflow, - userId, - requestId, + resourceType: 'workflow', + path: folderPath ?? '/', + mutate: (folderId) => + importWorkflowIntoWorkspace({ + workspaceId, + folderId: folderId ?? undefined, + name, + description, + workflow: parsed.data.body.workflow, + userId, + requestId, + }), }) + if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found') + const result = mutation.value if (!result.success) { return v2Error(ERROR_CODE_BY_STATUS[result.status] ?? 'INTERNAL_ERROR', result.error, { @@ -97,7 +106,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { name: result.workflow.name, description: result.workflow.description, workspaceId: result.workflow.workspaceId, - folderId: result.workflow.folderId, + folderPath: folderPathForId(mutation.index, result.workflow.folderId), createdAt: result.workflow.createdAt.toISOString(), updatedAt: result.workflow.updatedAt.toISOString(), }, diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts index 6b3e0ee67c2..9ab8c6575ae 100644 --- a/apps/sim/app/api/v2/workflows/route.test.ts +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -21,21 +21,17 @@ const { mockResolveWorkspaceAccess, mockPerformCreateWorkflow, mockAssertFolderMutable, - mockAssertFolderInWorkspace, + mockLoadActiveFolderPathIndex, FolderLockedErrorMock, - FolderNotFoundErrorMock, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), mockPerformCreateWorkflow: vi.fn(), mockAssertFolderMutable: vi.fn(), - mockAssertFolderInWorkspace: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), FolderLockedErrorMock: class FolderLockedError extends Error { status = 423 }, - FolderNotFoundErrorMock: class FolderNotFoundError extends Error { - status = 400 - }, })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -49,9 +45,11 @@ vi.mock('@/lib/workflows/orchestration', () => ({ vi.mock('@sim/platform-authz/workflow', () => ({ assertFolderMutable: mockAssertFolderMutable, - assertFolderInWorkspace: mockAssertFolderInWorkspace, FolderLockedError: FolderLockedErrorMock, - FolderNotFoundError: FolderNotFoundErrorMock, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, })) vi.mock('@/app/api/v2/lib/gate', () => ({ @@ -111,6 +109,11 @@ describe('GET /api/v2/workflows', () => { resetDbChainMock() mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map(), + pathById: new Map(), + idByPath: new Map(), + }) }) it('narrows the query with a case-insensitive substring match on the name', async () => { @@ -141,6 +144,19 @@ describe('GET /api/v2/workflows', () => { expect(lastConditions().some((c) => c.type === 'ilike')).toBe(false) }) + it('treats folderPath=/ as root-only while omission lists every folder', async () => { + queueTableRows(schemaMock.workflow, [buildRow()]) + + await callList(`workspaceId=${WS}&folderPath=%2F`) + + expect( + lastConditions().some( + (condition) => + condition.type === 'isNull' && condition.column === schemaMock.workflow.folderId + ) + ).toBe(true) + }) + it('400s on a sort field outside the enum instead of letting it reach the query', async () => { const res = await callList(`workspaceId=${WS}&sortBy=(select 1)`) @@ -284,7 +300,11 @@ describe('POST /api/v2/workflows', () => { mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) mockAssertFolderMutable.mockResolvedValue(undefined) - mockAssertFolderInWorkspace.mockResolvedValue(undefined) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['fld-1', { id: 'fld-1', name: 'Locked', parentId: null }]]), + pathById: new Map([['fld-1', '/Locked']]), + idByPath: new Map([['/Locked', 'fld-1']]), + }) mockPerformCreateWorkflow.mockResolvedValue({ success: true, workflow: CREATED }) }) @@ -338,44 +358,34 @@ describe('POST /api/v2/workflows', () => { it('423s when the destination folder is locked', async () => { mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) - const res = await callPost({ ...VALID_BODY, folderId: 'fld-1' }) + const res = await callPost({ ...VALID_BODY, folderPath: '/Locked' }) expect(res.status).toBe(423) expect((await res.json()).error.code).toBe('LOCKED') expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() }) - it('400s a folder outside the workspace without ever reading its lock state', async () => { - mockAssertFolderInWorkspace.mockRejectedValue( - new FolderNotFoundErrorMock('Target folder not found') - ) - const res = await callPost({ ...VALID_BODY, folderId: 'fld-other-workspace' }) + it('404s a path outside the workspace without ever reading its lock state', async () => { + const res = await callPost({ ...VALID_BODY, folderPath: '/Elsewhere' }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - // Containment runs first, so a locked foreign folder cannot be told apart - // from a nonexistent one by its status code. + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') expect(mockAssertFolderMutable).not.toHaveBeenCalled() expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() }) - it('checks folder containment before mutability', async () => { - const order: string[] = [] - mockAssertFolderInWorkspace.mockImplementation(async () => { - order.push('containment') - }) - mockAssertFolderMutable.mockImplementation(async () => { - order.push('mutability') - }) - - await callPost({ ...VALID_BODY, folderId: 'fld-1' }) + it('resolves the canonical path before checking mutability', async () => { + await callPost({ ...VALID_BODY, folderPath: '/Locked' }) - expect(order).toEqual(['containment', 'mutability']) - expect(mockAssertFolderInWorkspace).toHaveBeenCalledWith('fld-1', 'workspace-1') + expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith( + 'workspace-1', + 'workflow', + expect.any(Object) + ) + expect(mockAssertFolderMutable).toHaveBeenCalledWith('fld-1') }) it('skips the containment check when no folder is supplied', async () => { await callPost(VALID_BODY) - expect(mockAssertFolderInWorkspace).not.toHaveBeenCalled() expect(mockAssertFolderMutable).toHaveBeenCalledWith(null) }) @@ -400,7 +410,7 @@ describe('POST /api/v2/workflows', () => { id: 'wf-1', name: 'Support Agent', description: 'Handles tickets', - folderId: null, + folderPath: '/', workspaceId: 'workspace-1', isDeployed: false, deployedAt: null, @@ -417,7 +427,7 @@ describe('POST /api/v2/workflows', () => { workspaceId: 'workspace-1', name: 'Support Agent', description: 'Handles tickets', - folderId: undefined, + folderId: null, }) ) }) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index 88d9b457264..896280c7be0 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -1,12 +1,7 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { - assertFolderInWorkspace, - assertFolderMutable, - FolderLockedError, - FolderNotFoundError, -} from '@sim/platform-authz/workflow' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' @@ -30,8 +25,14 @@ import { } from '@/lib/api/list-query' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { performCreateWorkflow } from '@/lib/workflows/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + folderPathForId, + resolveFolderPathId, + withResolvedFolderPathMutation, +} from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { cursorSortKey, @@ -113,6 +114,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) + const folderIndex = await loadActiveFolderPathIndex(params.workspaceId, 'workflow') + const folderId = + params.folderPath === undefined + ? undefined + : resolveFolderPathId(folderIndex, params.folderPath) + if (params.folderPath !== undefined && folderId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') + } + const sortKey = cursorSortKey(params.sortBy, params.sortOrder) const keys: readonly KeysetKey[] = WORKFLOW_SORTS[params.sortBy] const decoded = decodeSortedCursor(params.cursor, sortKey) @@ -126,7 +136,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const conditions = [ eq(workflow.workspaceId, params.workspaceId), isNull(workflow.archivedAt), - params.folderId ? eq(workflow.folderId, params.folderId) : undefined, + params.folderPath === undefined + ? undefined + : folderId === null + ? isNull(workflow.folderId) + : folderId === undefined + ? undefined + : eq(workflow.folderId, folderId), params.deployedOnly ? eq(workflow.isDeployed, true) : undefined, searchFilter(workflow.name, params.search), resumeAfter, @@ -163,7 +179,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { id: w.id, name: w.name, description: w.description, - folderId: w.folderId, + folderPath: folderPathForId(folderIndex, w.folderId), workspaceId: w.workspaceId ?? params.workspaceId, isDeployed: w.isDeployed, deployedAt: w.deployedAt?.toISOString() ?? null, @@ -203,28 +219,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, name, description, folderId } = parsed.data.body + const { workspaceId, name, description, folderPath } = parsed.data.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - /** - * Ownership before lock state: `assertFolderMutable` walks the folder's - * ancestor chain without filtering on workspace, so checking it first would - * let a caller distinguish a locked folder in someone else's workspace - * (423) from one that simply does not exist (400). - */ - if (folderId) await assertFolderInWorkspace(folderId, workspaceId) - await assertFolderMutable(folderId ?? null) - - const result = await performCreateWorkflow({ - userId, + const mutation = await withResolvedFolderPathMutation({ workspaceId, - name, - description, - folderId, - requestId, + resourceType: 'workflow', + path: folderPath ?? '/', + mutate: async (folderId) => { + await assertFolderMutable(folderId) + return performCreateWorkflow({ + userId, + workspaceId, + name, + description, + folderId, + requestId, + }) + }, }) + if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found') + const result = mutation.value if (!result.success || !result.workflow) { return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to create workflow') @@ -235,7 +252,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { id: created.id, name: created.name, description: created.description ?? null, - folderId: created.folderId ?? null, + folderPath: folderPathForId(mutation.index, created.folderId), workspaceId: created.workspaceId, isDeployed: false, deployedAt: null, @@ -247,7 +264,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return v2Data(item, { rateLimit, status: 201 }) } catch (error) { - if (error instanceof FolderNotFoundError) return v2Error('BAD_REQUEST', error.message) if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) logger.error(`[${requestId}] Workflow create error`, { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index ae78045a287..e6292d6accd 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -69,6 +69,7 @@ import { useRenameTable, useTablesList, } from '@/hooks/queries/tables' +import { getCanonicalFolderPath } from '@/hooks/queries/utils/folder-tree' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' @@ -886,7 +887,7 @@ export function Tables() { try { await importCsv.mutateAsync({ workspaceId, - folderId: currentFolderId, + folderPath: getCanonicalFolderPath(currentFolderId, folderById), file, onCreated: (createdImportId) => { importId = createdImportId diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 471e1620a93..c734bc01939 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -1731,7 +1731,7 @@ export function useRestoreTable() { interface ImportCsvAsyncParams { workspaceId: string /** Folder to create the imported table in; omitted imports to the workspace root. */ - folderId?: string | null + folderPath?: string file: File onCreated?: (importId: string) => void onProgress?: (percent: number) => void @@ -1807,7 +1807,7 @@ export function useImportCsv() { return useMutation({ mutationFn: async ({ workspaceId, - folderId, + folderPath, file, onCreated, onProgress, @@ -1826,7 +1826,7 @@ export function useImportCsv() { 0, TABLE_LIMITS.MAX_TABLE_NAME_LENGTH ), - folderId: folderId ?? undefined, + folderPath, }, file, timezone, diff --git a/apps/sim/hooks/queries/utils/folder-tree.ts b/apps/sim/hooks/queries/utils/folder-tree.ts index 656c23fa4eb..efbcd624243 100644 --- a/apps/sim/hooks/queries/utils/folder-tree.ts +++ b/apps/sim/hooks/queries/utils/folder-tree.ts @@ -1,3 +1,4 @@ +import { buildFolderPath } from '@/lib/folders/paths' import type { WorkflowFolder } from '@/stores/folders/types' /** @@ -54,6 +55,31 @@ export function getFolderPath( return segments.length > 0 ? segments.join(separator) : null } +/** Returns the canonical public API path for a folder and rejects corrupt trees. */ +export function getCanonicalFolderPath( + folderId: string | null | undefined, + folders: Record | Map +): string { + if (!folderId) return '/' + + const segments: string[] = [] + const visited = new Set() + let currentFolderId: string | null | undefined = folderId + + while (currentFolderId) { + if (visited.has(currentFolderId)) throw new Error('Folder tree contains a cycle') + visited.add(currentFolderId) + + const folder: WorkflowFolder | undefined = + folders instanceof Map ? folders.get(currentFolderId) : folders[currentFolderId] + if (!folder) throw new Error(`Folder ${currentFolderId} was not found`) + segments.unshift(folder.name) + currentFolderId = folder.parentId + } + + return buildFolderPath(segments) +} + /** * Names that appear more than once in the list, so callers can disambiguate * only the entries that actually collide. diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 48839b9214e..296bfef9ca0 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -1,6 +1,5 @@ import { z } from 'zod' import { - folderIdSchema, isCanonicalBase64, workspaceFileIdSchema, workspaceIdSchema, @@ -8,8 +7,14 @@ import { import { shareAuthTypeSchema, shareRecordSchema } from '@/lib/api/contracts/public-shares' import { defineRouteContract } from '@/lib/api/contracts/types' import { + v2CreateFolderBodySchema, v2CursorListResponse, v2DataResponse, + v2DeleteFolderQuerySchema, + v2FolderPathSchema, + v2FolderSchema, + v2ListFoldersQuerySchema, + v2RelocateFolderBodySchema, v2SearchSchema, v2SortFields, } from '@/lib/api/contracts/v2/shared' @@ -29,14 +34,8 @@ import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' * adds cursor pagination to the list. List and item routes carry the workspace * as a query parameter; upload-session creation carries it in the JSON body. * - * Folders are referenced but not managed here. A file carries `folderId` / - * `folderPath`, and `move` retargets it, but there are deliberately no - * folder-CRUD routes on this surface: file folders already live in the shared - * `folder` table (`resourceType: 'file'`), and the remaining file-specific - * folder machinery is being folded into the generic folder engine. Publishing - * `/api/v2/files/folders/**` would pin a transitional split into a public - * contract; folder management belongs on `/api/v2/folders` once that surface - * serves `resourceType: 'file'`. + * Folder placement is represented only by canonical paths. Database folder ids + * remain an internal storage detail. * * Uploads use a signed stateless control token. The storage provider owns the * multipart part state; completion atomically registers the workspace file. @@ -49,10 +48,8 @@ export const v2FileSchema = z.object({ size: z.number().nonnegative(), type: z.string(), key: z.string(), - /** Containing file folder, or `null` when the file sits at the workspace root. */ - folderId: z.string().nullable(), - /** Slash-joined folder names for {@link v2FileSchema.folderId}; `null` at the root. */ - folderPath: z.string().nullable(), + /** Canonical containing-folder path; `/` means the workspace root. */ + folderPath: v2FolderPathSchema, uploadedBy: z.string(), /** ISO-8601 timestamp. */ uploadedAt: z.string(), @@ -71,7 +68,7 @@ export const v2CreateFileUploadBodySchema = z name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), contentType: z.string().trim().min(1, 'contentType is required').max(255), size: z.number().int().nonnegative().max(MAX_WORKSPACE_FILE_SIZE), - folderId: folderIdSchema.optional(), + folderPath: v2FolderPathSchema.optional(), }) .strict() export type V2CreateFileUploadBody = z.input @@ -100,7 +97,6 @@ export const v2CreateFileUploadDataSchema = z .strict() export type V2CreateFileUploadData = z.output -/** Acknowledgement returned by a successful archive (soft delete). */ export const v2DeleteFileResultSchema = z.object({ id: z.string(), deleted: z.literal(true), @@ -108,25 +104,12 @@ export const v2DeleteFileResultSchema = z.object({ export type V2DeleteFileResult = z.output -/** Counts of what a cascading archive or restore touched. */ -export const v2FileItemCountsSchema = z.object({ - files: z.number().int(), - folders: z.number().int(), -}) - -export type V2FileItemCounts = z.output - export const v2FileParamsSchema = z.object({ fileId: workspaceFileIdSchema, }) export type V2FileParams = z.output -/** `active` lists live items; `archived` lists Recently Deleted. */ -export const v2FileScopeSchema = z.enum(['active', 'archived']) - -export type V2FileScope = z.output - /** * A file-folder name becomes a path segment, so path separators and dot * segments are rejected rather than normalized. Mirrors @@ -152,7 +135,7 @@ export const v2CreateFileBodySchema = z .min(1, 'contentType cannot be empty') .max(255, 'contentType is too long') .optional(), - folderId: folderIdSchema.optional(), + folderPath: v2FolderPathSchema.optional(), content: z.string().max(70_000_000, 'content is too large').default(''), encoding: z.enum(['utf-8', 'base64']).default('utf-8'), }) @@ -183,20 +166,21 @@ export type V2FileSortBy = (typeof v2FileSortFields)[number] * minted under and rejected if the request's sort has since changed. Filtering, * ordering, and the page slice all happen in the query. */ -export const v2ListFilesQuerySchema = z.object({ - workspaceId: workspaceIdSchema, - scope: v2FileScopeSchema.default('active'), - /** Restrict to one file folder. Omit to list the whole workspace. */ - folderId: z.string().min(1, 'folderId cannot be empty').optional(), - search: v2SearchSchema, - ...v2SortFields(v2FileSortFields, { sortBy: 'uploadedAt', sortOrder: 'asc' }), - limit: z.coerce - .number() - .optional() - .default(100) - .transform((v) => Math.min(Math.max(1, Math.trunc(v)), 1000)), - cursor: z.string().min(1).optional(), -}) +export const v2ListFilesQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + /** Restrict to one file folder. Omit to list the whole workspace. */ + folderPath: v2FolderPathSchema.optional(), + search: v2SearchSchema, + ...v2SortFields(v2FileSortFields, { sortBy: 'uploadedAt', sortOrder: 'asc' }), + limit: z.coerce + .number() + .optional() + .default(100) + .transform((v) => Math.min(Math.max(1, Math.trunc(v)), 1000)), + cursor: z.string().min(1).optional(), + }) + .strict() export type V2ListFilesQuery = z.output @@ -216,76 +200,77 @@ export const v2RenameFileBodySchema = z export type V2RenameFileBody = z.input -export const v2WorkspaceScopedBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - }) - .strict() - -export type V2WorkspaceScopedBody = z.input - -/** A restore acknowledgement carries no payload beyond the restored id. */ -export const v2RestoreFileResultSchema = z.object({ - id: z.string(), - restored: z.literal(true), -}) - -export type V2RestoreFileResult = z.output - -const fileItemSelectionSchema = { - fileIds: z.array(z.string().min(1, 'fileIds entries cannot be empty')).max(1000).default([]), - folderIds: z.array(z.string().min(1, 'folderIds entries cannot be empty')).max(1000).default([]), +const fileSelectionSchema = { + fileIds: z.array(z.string().min(1, 'fileIds entries cannot be empty')).min(1).max(1000), } export const v2MoveFileItemsBodySchema = z .object({ workspaceId: workspaceIdSchema, - ...fileItemSelectionSchema, - /** Explicit `null` moves the selection to the workspace root. */ - targetFolderId: z.string().min(1, 'targetFolderId cannot be empty').nullable().optional(), + ...fileSelectionSchema, + /** Omission moves the files to the workspace root. */ + targetFolderPath: v2FolderPathSchema.optional(), }) .strict() - .superRefine((body, ctx) => { - if (body.fileIds.length === 0 && body.folderIds.length === 0) { - ctx.addIssue({ - code: 'custom', - path: ['fileIds'], - message: 'At least one of fileIds or folderIds must be non-empty', - }) - } - }) export type V2MoveFileItemsBody = z.input export const v2MoveFileItemsResultSchema = z.object({ - movedItems: v2FileItemCountsSchema, + movedItems: z.object({ files: z.number().int() }), }) export type V2MoveFileItemsResult = z.output -export const v2BulkArchiveFileItemsBodySchema = z +export const v2BulkDeleteFilesBodySchema = z .object({ workspaceId: workspaceIdSchema, - ...fileItemSelectionSchema, + ...fileSelectionSchema, }) .strict() - .superRefine((body, ctx) => { - if (body.fileIds.length === 0 && body.folderIds.length === 0) { - ctx.addIssue({ - code: 'custom', - path: ['fileIds'], - message: 'At least one of fileIds or folderIds must be non-empty', - }) - } - }) -export type V2BulkArchiveFileItemsBody = z.input +export type V2BulkDeleteFilesBody = z.input + +export const v2BulkDeleteFilesResultSchema = z.object({ + deletedItems: z.object({ files: z.number().int() }), +}) + +export type V2BulkDeleteFilesResult = z.output -export const v2BulkArchiveFileItemsResultSchema = z.object({ - deletedItems: v2FileItemCountsSchema, +export const v2FileFolderDataSchema = z.object({ folder: v2FolderSchema }) + +export const v2DeleteFileFolderDataSchema = z.object({ + path: v2FolderPathSchema, + deleted: z.literal(true), + deletedItems: z.object({ folders: z.number().int(), files: z.number().int() }), }) -export type V2BulkArchiveFileItemsResult = z.output +export const v2ListFileFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/folders', + query: v2ListFoldersQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema) }, +}) + +export const v2CreateFileFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/folders', + body: v2CreateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileFolderDataSchema) }, +}) + +export const v2RelocateFileFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/files/folders', + body: v2RelocateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileFolderDataSchema) }, +}) + +export const v2DeleteFileFolderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/files/folders', + query: v2DeleteFolderQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2DeleteFileFolderDataSchema) }, +}) /** * Public share state. Reuses the internal {@link shareRecordSchema}, which is @@ -457,17 +442,6 @@ export const v2DeleteFileContract = defineRouteContract({ }, }) -export const v2RestoreFileContract = defineRouteContract({ - method: 'POST', - path: '/api/v2/files/[fileId]/restore', - params: v2FileParamsSchema, - body: v2WorkspaceScopedBodySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2RestoreFileResultSchema), - }, -}) - export const v2MoveFileItemsContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/move', @@ -478,13 +452,13 @@ export const v2MoveFileItemsContract = defineRouteContract({ }, }) -export const v2BulkArchiveFileItemsContract = defineRouteContract({ +export const v2BulkDeleteFilesContract = defineRouteContract({ method: 'POST', - path: '/api/v2/files/bulk-archive', - body: v2BulkArchiveFileItemsBodySchema, + path: '/api/v2/files/bulk-delete', + body: v2BulkDeleteFilesBodySchema, response: { mode: 'json', - schema: v2DataResponse(v2BulkArchiveFileItemsResultSchema), + schema: v2DataResponse(v2BulkDeleteFilesResultSchema), }, }) diff --git a/apps/sim/lib/api/contracts/v2/folders.ts b/apps/sim/lib/api/contracts/v2/folders.ts deleted file mode 100644 index e59abdbdab1..00000000000 --- a/apps/sim/lib/api/contracts/v2/folders.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { z } from 'zod' -import { - folderCascadeCountsSchema, - folderResourceTypeSchema, - folderScopeSchema, - servedFolderResourceTypeSchema, -} from '@/lib/api/contracts/folders' -import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' -import { defineRouteContract } from '@/lib/api/contracts/types' -import { - v2CursorListResponse, - v2DataResponse, - v2SearchSchema, - v2SortFields, -} from '@/lib/api/contracts/v2/shared' - -/** - * v2 folder contracts. - * - * One folder engine serves several resource trees (`workflow`, `knowledge_base`, - * `table`), discriminated by `resourceType`. The internal surface defaults that - * field to `workflow` so an old client that never sends it keeps working across - * a deploy; the public surface has no such legacy, and defaulting it would let a - * caller silently file a knowledge-base folder into the workflow tree where the - * Knowledge page can never see it again. So v2 **requires** it on every - * operation, reusing the served enum with its default stripped. - * - * `duplicate`, `restore`, and `reorder` are not part of the public surface. - */ - -/** The served resource types, required rather than defaulted. */ -export const v2FolderResourceTypeSchema = servedFolderResourceTypeSchema.unwrap() -export type V2FolderResourceType = z.output - -/** - * Public folder projection. `userId` (the creator) and `workspaceId` (already - * known to the caller, who supplied it) are internal columns and not exposed. - */ -export const v2FolderSchema = z.object({ - id: z.string(), - resourceType: folderResourceTypeSchema, - name: z.string(), - parentId: z.string().nullable(), - /** Workflow folders only; always `false` for the other resource types. */ - locked: z.boolean(), - sortOrder: z.number(), - createdAt: z.string(), - updatedAt: z.string(), - /** Set when the folder is archived (in Recently Deleted) rather than live. */ - deletedAt: z.string().nullable(), -}) -export type V2Folder = z.output - -/** `{ folder }` payload for single-folder reads and mutations. */ -export const v2FolderDataSchema = z.object({ folder: v2FolderSchema }) -export type V2FolderData = z.output - -/** - * Delete acknowledgement. `deletedItems` reports what the cascade archived - * alongside the folder; only the key matching `resourceType` is populated. - */ -export const v2FolderDeleteDataSchema = z.object({ - id: z.string(), - deleted: z.literal(true), - deletedItems: folderCascadeCountsSchema.optional(), -}) -export type V2FolderDeleteData = z.output - -export const v2FolderParamsSchema = z.object({ - id: nonEmptyIdSchema, -}) -export type V2FolderParams = z.output - -/** Query for the id-keyed reads and the delete. */ -export const v2FolderScopedQuerySchema = z.object({ - workspaceId: workspaceIdSchema, - resourceType: v2FolderResourceTypeSchema, -}) -export type V2FolderScopedQuery = z.output - -/** - * Sortable folder fields. `position` is the tree's manual arrangement (the - * `sort_order` column), kept as the default so a bare list still comes back in - * the order the workspace arranged it. - */ -export const v2FolderSortFields = ['position', 'name', 'createdAt', 'updatedAt'] as const - -export type V2FolderSortBy = (typeof v2FolderSortFields)[number] - -/** - * List query. `search` narrows to folders whose name matches; the result stays - * a flat list either way, so a matching folder is returned without its - * ancestors — reconstruct a tree from `parentId` only on an unsearched list. - */ -export const v2ListFoldersQuerySchema = v2FolderScopedQuerySchema.extend({ - /** `active` (default) lists live folders; `archived` lists Recently Deleted. */ - scope: folderScopeSchema.default('active'), - search: v2SearchSchema, - ...v2SortFields(v2FolderSortFields, { sortBy: 'position', sortOrder: 'asc' }), -}) -export type V2ListFoldersQuery = z.output - -export const v2CreateFolderBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - resourceType: v2FolderResourceTypeSchema, - name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), - /** Explicit `null` creates the folder at the workspace root. */ - parentId: z.string().min(1, 'parentId cannot be empty').nullable().optional(), - sortOrder: z.number().int('sortOrder must be an integer').min(0).optional(), - }) - .strict() -export type V2CreateFolderBody = z.input - -/** Update body. Omitted fields keep their stored values. */ -export const v2UpdateFolderBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - resourceType: v2FolderResourceTypeSchema, - name: z.string().trim().min(1, 'name cannot be empty').max(255, 'name is too long').optional(), - /** Workflow folders only, and changing it requires workspace `admin`. */ - locked: z.boolean().optional(), - parentId: z.string().min(1, 'parentId cannot be empty').nullable().optional(), - sortOrder: z.number().int('sortOrder must be an integer').min(0).optional(), - }) - .strict() - .superRefine((body, ctx) => { - if ( - body.name === undefined && - body.locked === undefined && - body.parentId === undefined && - body.sortOrder === undefined - ) { - ctx.addIssue({ - code: 'custom', - path: ['name'], - message: 'At least one of name, locked, parentId, or sortOrder is required', - }) - } - }) -export type V2UpdateFolderBody = z.input - -/** - * Folder list. A workspace's folder tree for one resource type is small and - * bounded, so the full set is returned as a single page (`nextCursor` is always - * `null`); the canonical cursor envelope keeps the v2 list surface uniform. - */ -export const v2ListFoldersContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/folders', - query: v2ListFoldersQuerySchema, - response: { - mode: 'json', - schema: v2CursorListResponse(v2FolderSchema), - }, -}) - -export const v2CreateFolderContract = defineRouteContract({ - method: 'POST', - path: '/api/v2/folders', - body: v2CreateFolderBodySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2FolderDataSchema), - }, -}) - -export const v2GetFolderContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/folders/[id]', - params: v2FolderParamsSchema, - query: v2FolderScopedQuerySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2FolderDataSchema), - }, -}) - -export const v2UpdateFolderContract = defineRouteContract({ - method: 'PATCH', - path: '/api/v2/folders/[id]', - params: v2FolderParamsSchema, - body: v2UpdateFolderBodySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2FolderDataSchema), - }, -}) - -export const v2DeleteFolderContract = defineRouteContract({ - method: 'DELETE', - path: '/api/v2/folders/[id]', - params: v2FolderParamsSchema, - query: v2FolderScopedQuerySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2FolderDeleteDataSchema), - }, -}) diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 0e7ee1b1967..42e25e51ea0 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -12,13 +12,17 @@ import { v1CreateKnowledgeBaseBodySchema, v1KnowledgeSearchBodySchema, v1KnowledgeWorkspaceQuerySchema, - v1ListKnowledgeBasesQuerySchema, v1ListKnowledgeDocumentsQuerySchema, - v1UpdateKnowledgeBaseBodySchema, } from '@/lib/api/contracts/v1/knowledge' import { + v2CreateFolderBodySchema, v2CursorListResponse, v2DataResponse, + v2DeleteFolderQuerySchema, + v2FolderPathSchema, + v2FolderSchema, + v2ListFoldersQuerySchema, + v2RelocateFolderBodySchema, v2SearchSchema, v2SortFields, } from '@/lib/api/contracts/v2/shared' @@ -54,19 +58,21 @@ import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' * {@link KnowledgeBaseWithCounts}. `userId`, `workspaceId`, and `deletedAt` are * intentionally not exposed on the public surface. */ -export const v2KnowledgeBaseSchema = knowledgeBaseDataSchema.pick({ - id: true, - name: true, - description: true, - tokenCount: true, - embeddingModel: true, - embeddingDimension: true, - chunkingConfig: true, - docCount: true, - connectorTypes: true, - createdAt: true, - updatedAt: true, -}) +export const v2KnowledgeBaseSchema = knowledgeBaseDataSchema + .pick({ + id: true, + name: true, + description: true, + tokenCount: true, + embeddingModel: true, + embeddingDimension: true, + chunkingConfig: true, + docCount: true, + connectorTypes: true, + createdAt: true, + updatedAt: true, + }) + .extend({ folderPath: v2FolderPathSchema }) export type V2KnowledgeBase = z.output /** `{ knowledgeBase }` payload for single-KB reads and mutations. */ @@ -238,15 +244,45 @@ export type V2KnowledgeBaseSortBy = (typeof v2KnowledgeBaseSortFields)[number] * folder filter. v1's own list query stays untouched — it does not implement * these, and advertising a param a route ignores is worse than not having it. */ -export const v2ListKnowledgeBasesQuerySchema = v1ListKnowledgeBasesQuerySchema.extend({ - /** Restrict to one knowledge-base folder. */ - folderId: z.string().min(1, 'folderId cannot be empty').optional(), - search: v2SearchSchema, - ...v2SortFields(v2KnowledgeBaseSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), -}) +export const v2ListKnowledgeBasesQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + folderPath: v2FolderPathSchema.optional(), + search: v2SearchSchema, + ...v2SortFields(v2KnowledgeBaseSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), + }) + .strict() export type V2ListKnowledgeBasesQuery = z.output +export const v2CreateKnowledgeBaseBodySchema = v1CreateKnowledgeBaseBodySchema + .extend({ folderPath: v2FolderPathSchema.optional() }) + .strict() + +export const v2UpdateKnowledgeBaseBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: v1CreateKnowledgeBaseBodySchema.shape.name.optional(), + description: v1CreateKnowledgeBaseBodySchema.shape.description, + chunkingConfig: v1CreateKnowledgeBaseBodySchema.shape.chunkingConfig.optional(), + folderPath: v2FolderPathSchema.optional(), + }) + .strict() + .superRefine((body, ctx) => { + if ( + body.name === undefined && + body.description === undefined && + body.chunkingConfig === undefined && + body.folderPath === undefined + ) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: 'At least one of name, description, chunkingConfig, or folderPath is required', + }) + } + }) + /** * KB list. `getKnowledgeBases` returns the full workspace set (a small, bounded * per-workspace list), so today the cursor list is a single full page @@ -267,7 +303,7 @@ export const v2ListKnowledgeBasesContract = defineRouteContract({ export const v2CreateKnowledgeBaseContract = defineRouteContract({ method: 'POST', path: '/api/v2/knowledge', - body: v1CreateKnowledgeBaseBodySchema, + body: v2CreateKnowledgeBaseBodySchema, response: { mode: 'json', schema: v2DataResponse(v2KnowledgeBaseDataSchema), @@ -289,7 +325,7 @@ export const v2UpdateKnowledgeBaseContract = defineRouteContract({ method: 'PUT', path: '/api/v2/knowledge/[id]', params: knowledgeBaseParamsSchema, - body: v1UpdateKnowledgeBaseBodySchema, + body: v2UpdateKnowledgeBaseBodySchema, response: { mode: 'json', schema: v2DataResponse(v2KnowledgeBaseDataSchema), @@ -307,6 +343,42 @@ export const v2DeleteKnowledgeBaseContract = defineRouteContract({ }, }) +export const v2KnowledgeFolderDataSchema = z.object({ folder: v2FolderSchema }) + +export const v2DeleteKnowledgeFolderDataSchema = z.object({ + path: v2FolderPathSchema, + deleted: z.literal(true), + deletedItems: z.object({ folders: z.number().int(), knowledgeBases: z.number().int() }), +}) + +export const v2ListKnowledgeFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/folders', + query: v2ListFoldersQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema) }, +}) + +export const v2CreateKnowledgeFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/folders', + body: v2CreateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeFolderDataSchema) }, +}) + +export const v2RelocateKnowledgeFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/knowledge/folders', + body: v2RelocateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeFolderDataSchema) }, +}) + +export const v2DeleteKnowledgeFolderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/folders', + query: v2DeleteFolderQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2DeleteKnowledgeFolderDataSchema) }, +}) + export const v2SearchKnowledgeContract = defineRouteContract({ method: 'POST', path: '/api/v2/knowledge/search', diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index 774aceb8794..b5a3d607746 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -5,7 +5,11 @@ import { v1ListLogsQuerySchema, v1LogParamsSchema, } from '@/lib/api/contracts/v1/logs' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2CursorListResponse, + v2DataResponse, + v2FolderPathSchema, +} from '@/lib/api/contracts/v2/shared' /** * v2 logs contracts. The query schemas are reused verbatim from v1 (the request @@ -61,7 +65,7 @@ export const v2LogDetailSchema = z.object({ id: z.string().nullable(), name: z.string(), description: z.string().nullable(), - folderId: z.string().nullable(), + folderPath: v2FolderPathSchema.nullable(), userId: z.string().nullable(), workspaceId: z.string().nullable(), createdAt: z.string().nullable(), @@ -92,10 +96,29 @@ export const v2ExecutionSchema = z.object({ export type V2Execution = z.output +export const v2ListLogsQuerySchema = v1ListLogsQuerySchema + .omit({ folderIds: true }) + .extend({ + folderPaths: z + .string() + .optional() + .superRefine((value, ctx) => { + if (!value) return + const paths = value.split(',').filter(Boolean) + if ( + paths.length === 0 || + paths.some((path) => !v2FolderPathSchema.safeParse(path).success) + ) { + ctx.addIssue({ code: 'custom', message: 'folderPaths must contain canonical paths' }) + } + }), + }) + .strict() + export const v2ListLogsContract = defineRouteContract({ method: 'GET', path: '/api/v2/logs', - query: v1ListLogsQuerySchema, + query: v2ListLogsQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2LogListItemSchema), diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 02a10f692d9..e12fcf50f2c 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -1,4 +1,6 @@ import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { FolderPathError, parseFolderPath, requireNonRootFolderPath } from '@/lib/folders/paths' /** * Shared building blocks for the v2 API contract surface. @@ -40,7 +42,7 @@ import { z } from 'zod' * `sortOrder` *column* on workflows and folders) — it is spelled differently * from the `sortOrder` *param* on purpose. * - **Filters** — resource-specific and enumerated, reusing the names already - * on the surface (`scope`, `folderId`, `deployedOnly`, `type`, `providerId`, + * on the surface (`scope`, `folderPath`, `deployedOnly`, `type`, `providerId`, * `resourceType`). No generic filter expression. * * Every one of these is pushed into SQL. No v2 list fetches a full result set @@ -92,6 +94,79 @@ export const v2SortOrderSchema = z.enum(['asc', 'desc']) export type V2SortOrder = z.output +function canonicalFolderPathSchema(parser: (path: string) => string[]) { + return z.string().superRefine((path, ctx) => { + try { + parser(path) + } catch (error) { + ctx.addIssue({ + code: 'custom', + message: + error instanceof FolderPathError ? error.message : 'Path must be a canonical folder path', + }) + } + }) +} + +/** Canonical slash-prefixed folder path. `/` is the workspace root. */ +export const v2FolderPathSchema = canonicalFolderPathSchema(parseFolderPath) +export type V2FolderPath = z.output + +/** Canonical path that identifies a real folder rather than the virtual root. */ +export const v2NonRootFolderPathSchema = canonicalFolderPathSchema(requireNonRootFolderPath) + +export const v2FolderSchema = z.object({ + name: z.string(), + path: v2NonRootFolderPathSchema, + parentPath: v2FolderPathSchema, + createdAt: z.string(), + updatedAt: z.string(), +}) +export type V2Folder = z.output + +export const v2FolderSortFields = ['name', 'createdAt', 'updatedAt'] as const + +export const v2ListFoldersQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + parentPath: v2FolderPathSchema.optional(), + search: v2SearchSchema, + ...v2SortFields(v2FolderSortFields, { sortBy: 'name', sortOrder: 'asc' }), + }) + .strict() + +export const v2CreateFolderBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + path: v2NonRootFolderPathSchema, + }) + .strict() + +export const v2RelocateFolderBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + path: v2NonRootFolderPathSchema, + destinationPath: v2NonRootFolderPathSchema, + }) + .strict() + .superRefine((body, ctx) => { + if (body.path === body.destinationPath) { + ctx.addIssue({ + code: 'custom', + path: ['destinationPath'], + message: 'destinationPath must differ from path', + }) + } + }) + +export const v2DeleteFolderQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + path: v2NonRootFolderPathSchema, + recursive: z.stringbool(), + }) + .strict() + /** * The `sortBy` + `sortOrder` pair for one resource. `fields` is the closed set * of sortable fields — the value reaches the query as a column, so it can never diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 3add52daf85..8a71da1d539 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 { folderIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { addWorkflowGroupBodySchema, cancelTableRunsBodyBaseSchema, @@ -38,8 +38,14 @@ import { v1ListTablesQuerySchema, } from '@/lib/api/contracts/v1/tables' import { + v2CreateFolderBodySchema, v2CursorListResponse, v2DataResponse, + v2DeleteFolderQuerySchema, + v2FolderPathSchema, + v2FolderSchema, + v2ListFoldersQuerySchema, + v2RelocateFolderBodySchema, v2SearchSchema, v2SortFields, } from '@/lib/api/contracts/v2/shared' @@ -110,8 +116,8 @@ export const v2ApiTableSchema = z.object({ schema: z.object({ columns: z.array(tableColumnSchema) }), rowCount: z.number(), maxRows: z.number(), - /** Owning folder, or `null` when the table sits at the workspace root. */ - folderId: z.string().nullable(), + /** Canonical containing-folder path; `/` means the workspace root. */ + folderPath: v2FolderPathSchema, /** * Governance flags, read-only on the public API. They are enforced on every * write (a locked verb returns 423), but flipping them is a first-party admin @@ -143,7 +149,6 @@ export type V2ApiRow = z.output export const v2TableDataSchema = z.object({ table: v2ApiTableSchema }) export type V2TableData = z.output -/** Archive confirmation — the id of the table that was archived. */ export const v2DeleteTableDataSchema = z.object({ id: z.string() }) export type V2DeleteTableData = z.output @@ -210,21 +215,28 @@ export type V2TableSortBy = (typeof v2TableSortFields)[number] * `v1ListTablesQuerySchema` — the single-table read/delete routes reuse that * schema and have no list params. */ -export const v2ListTablesQuerySchema = v1ListTablesQuerySchema.extend({ - /** Restrict to one table folder. */ - folderId: z.string().min(1, 'folderId cannot be empty').optional(), - search: v2SearchSchema, - ...v2SortFields(v2TableSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), - limit: z.coerce - .number() - .optional() - .default(100) - .transform((v) => Math.min(Math.max(1, Math.trunc(v)), 1000)), - cursor: z.string().min(1).optional(), -}) +export const v2ListTablesQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + folderPath: v2FolderPathSchema.optional(), + search: v2SearchSchema, + ...v2SortFields(v2TableSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), + limit: z.coerce + .number() + .optional() + .default(100) + .transform((v) => Math.min(Math.max(1, Math.trunc(v)), 1000)), + cursor: z.string().min(1).optional(), + }) + .strict() export type V2ListTablesQuery = z.output +export const v2CreateTableBodySchema = v1CreateTableBodySchema + .omit({ folderId: true }) + .extend({ folderPath: v2FolderPathSchema.optional() }) + .strict() + /** * Table list. `listTables` returns every table in the workspace (a small, * bounded per-workspace set), so today the cursor list is a single full page @@ -246,7 +258,7 @@ export const v2ListTablesContract = defineRouteContract({ export const v2CreateTableContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables', - body: v1CreateTableBodySchema, + body: v2CreateTableBodySchema, response: { mode: 'json', schema: v2DataResponse(v2TableDataSchema), @@ -266,8 +278,8 @@ export const v2GetTableContract = defineRouteContract({ /** * Table update. Every field is optional but at least one must be present: - * `name` renames and `folderId` moves the table (explicit `null` moves it to - * the workspace root; omission leaves the placement untouched). + * `name` renames and `folderPath` moves the table. Omission leaves placement + * untouched; `/` moves it to the workspace root. * * `locks` is deliberately **not** accepted here, which is why this body is * declared rather than reusing the first-party `updateTableBodySchema`. The @@ -281,11 +293,11 @@ export const v2UpdateTableBodySchema = z .object({ workspaceId: workspaceIdSchema, name: tableNameSchema.optional(), - folderId: folderIdSchema.nullable().optional(), + folderPath: v2FolderPathSchema.optional(), }) .strict() .superRefine((body, ctx) => { - if (body.name === undefined && body.folderId === undefined) { + if (body.name === undefined && body.folderPath === undefined) { ctx.addIssue({ code: 'custom', message: 'Provide a new name or folder', @@ -306,6 +318,42 @@ export const v2UpdateTableContract = defineRouteContract({ }) export type V2UpdateTableBody = z.input +export const v2TableFolderDataSchema = z.object({ folder: v2FolderSchema }) + +export const v2DeleteTableFolderDataSchema = z.object({ + path: v2FolderPathSchema, + deleted: z.literal(true), + deletedItems: z.object({ folders: z.number().int(), tables: z.number().int() }), +}) + +export const v2ListTableFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tables/folders', + query: v2ListFoldersQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2FolderSchema) }, +}) + +export const v2CreateTableFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/tables/folders', + body: v2CreateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableFolderDataSchema) }, +}) + +export const v2RelocateTableFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/tables/folders', + body: v2RelocateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2TableFolderDataSchema) }, +}) + +export const v2DeleteTableFolderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/tables/folders', + query: v2DeleteFolderQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2DeleteTableFolderDataSchema) }, +}) + export const v2DeleteTableContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/tables/[tableId]', @@ -543,22 +591,6 @@ export const v2UpsertTableRowContract = defineRouteContract({ export const v2WorkspaceScopedBodySchema = z.object({ workspaceId: workspaceIdSchema }) export type V2WorkspaceScopedBody = z.input -/** - * Un-archives a table archived by `DELETE /api/v2/tables/[tableId]`. Resolves - * the table with archived rows included, so it is the one table endpoint whose - * target is expected NOT to be active. - */ -export const v2RestoreTableContract = defineRouteContract({ - method: 'POST', - path: '/api/v2/tables/[tableId]/restore', - params: tableIdParamsSchema, - body: v2WorkspaceScopedBodySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2TableDataSchema), - }, -}) - /** * A saved view: a named preset of `{ filter, sort, column layout }` over a * table. Presentation state only — a view narrows what a reader sees by @@ -953,7 +985,7 @@ export const v2TableImportTargetSchema = z.discriminatedUnion('type', [ .object({ type: z.literal('new'), name: tableNameSchema, - folderId: folderIdSchema.optional(), + folderPath: v2FolderPathSchema.optional(), }) .strict(), z diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 119ae6ba1a0..10da02345c9 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -4,19 +4,25 @@ import { deploymentVersionParamsSchema, deploymentVersionSchema, } from '@/lib/api/contracts/deployments' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { + V1_IMPORT_DESCRIPTION_MAX_LENGTH, + V1_IMPORT_NAME_MAX_LENGTH, v1DeployWorkflowDataSchema, v1ImportWorkflowBodySchema, - v1ImportWorkflowDataSchema, - v1ListWorkflowsQuerySchema, v1RollbackWorkflowDataSchema, v1WorkflowExportPayloadSchema, } from '@/lib/api/contracts/v1/workflows' import { + v2CreateFolderBodySchema, v2CursorListResponse, v2DataResponse, + v2DeleteFolderQuerySchema, + v2FolderPathSchema, + v2FolderSchema, + v2ListFoldersQuerySchema, + v2RelocateFolderBodySchema, v2SearchSchema, v2SortFields, } from '@/lib/api/contracts/v2/shared' @@ -63,10 +69,17 @@ export type V2WorkflowSortBy = (typeof v2WorkflowSortFields)[number] * sort convention. The keyset behind the cursor follows `sortBy`, so the cursor * carries the sort it was minted under and is rejected once that changes. */ -export const v2ListWorkflowsQuerySchema = v1ListWorkflowsQuerySchema.extend({ - search: v2SearchSchema, - ...v2SortFields(v2WorkflowSortFields, { sortBy: 'position', sortOrder: 'asc' }), -}) +export const v2ListWorkflowsQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + folderPath: v2FolderPathSchema.optional(), + deployedOnly: booleanQueryFlagSchema.optional().default(false), + limit: z.coerce.number().min(1).max(100).optional().default(50), + cursor: z.string().optional(), + search: v2SearchSchema, + ...v2SortFields(v2WorkflowSortFields, { sortBy: 'position', sortOrder: 'asc' }), + }) + .strict() export type V2ListWorkflowsQuery = z.output @@ -74,7 +87,7 @@ export const v2WorkflowListItemSchema = z.object({ id: z.string(), name: z.string(), description: z.string().nullable(), - folderId: z.string().nullable(), + folderPath: v2FolderPathSchema, workspaceId: z.string(), isDeployed: z.boolean(), deployedAt: z.string().nullable(), @@ -145,8 +158,8 @@ export const v2CreateWorkflowBodySchema = z workspaceId: workspaceIdSchema, name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), description: z.string().max(50_000, 'description is too long').nullable().optional(), - /** Explicit `null` (or omission) creates the workflow at the workspace root. */ - folderId: z.string().min(1, 'folderId cannot be empty').nullable().optional(), + /** Omission creates the workflow at the workspace root. */ + folderPath: v2FolderPathSchema.optional(), }) .strict() export type V2CreateWorkflowBody = z.input @@ -156,24 +169,24 @@ export const v2UpdateWorkflowBodySchema = z .object({ name: z.string().trim().min(1, 'name cannot be empty').max(255, 'name is too long').optional(), description: z.string().max(50_000, 'description is too long').nullable().optional(), - folderId: z.string().min(1, 'folderId cannot be empty').nullable().optional(), + folderPath: v2FolderPathSchema.optional(), }) .strict() .superRefine((body, ctx) => { - if (body.name === undefined && body.description === undefined && body.folderId === undefined) { + if ( + body.name === undefined && + body.description === undefined && + body.folderPath === undefined + ) { ctx.addIssue({ code: 'custom', path: ['name'], - message: 'At least one of name, description, or folderId is required', + message: 'At least one of name, description, or folderPath is required', }) } }) export type V2UpdateWorkflowBody = z.input -/** - * Delete acknowledgement. Deletion archives the workflow (it lands in Recently - * Deleted) rather than dropping its rows, so runs and logs stay attributable. - */ export const v2DeleteWorkflowDataSchema = z.object({ id: z.string(), deleted: z.literal(true), @@ -211,6 +224,45 @@ export const v2DeleteWorkflowContract = defineRouteContract({ }, }) +export const v2WorkflowFolderSchema = v2FolderSchema.extend({ locked: z.boolean() }) +export type V2WorkflowFolder = z.output + +export const v2WorkflowFolderDataSchema = z.object({ folder: v2WorkflowFolderSchema }) + +export const v2DeleteWorkflowFolderDataSchema = z.object({ + path: v2FolderPathSchema, + deleted: z.literal(true), + deletedItems: z.object({ folders: z.number().int(), workflows: z.number().int() }), +}) + +export const v2ListWorkflowFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/folders', + query: v2ListFoldersQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2WorkflowFolderSchema) }, +}) + +export const v2CreateWorkflowFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/folders', + body: v2CreateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2WorkflowFolderDataSchema) }, +}) + +export const v2RelocateWorkflowFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/workflows/folders', + body: v2RelocateFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2WorkflowFolderDataSchema) }, +}) + +export const v2DeleteWorkflowFolderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/workflows/folders', + query: v2DeleteFolderQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2DeleteWorkflowFolderDataSchema) }, +}) + /** * A deployment version as the public surface sees it: the internal row minus * `createdBy`, which is a raw user id with no public resolution path — @@ -439,27 +491,60 @@ export const v2CancelWorkflowExecutionContract = defineRouteContract({ }, }) -/** - * Export/import reuse the v1 payload and body schemas verbatim — the portable - * envelope must round-trip across both surfaces — with only the response - * envelope upgraded. - */ +export const v2WorkflowExportPayloadSchema = v1WorkflowExportPayloadSchema.extend({ + workflow: v1WorkflowExportPayloadSchema.shape.workflow + .omit({ folderId: true }) + .extend({ folderPath: v2FolderPathSchema }), +}) + +export const v2ImportWorkflowBodySchema = v1ImportWorkflowBodySchema + .omit({ folderId: true, name: true, description: true }) + .extend({ + folderPath: v2FolderPathSchema.optional(), + name: z + .string() + .min(1, 'name cannot be empty') + .max( + V1_IMPORT_NAME_MAX_LENGTH, + `name must be at most ${V1_IMPORT_NAME_MAX_LENGTH} characters` + ) + .optional(), + description: z + .string() + .max( + V1_IMPORT_DESCRIPTION_MAX_LENGTH, + `description must be at most ${V1_IMPORT_DESCRIPTION_MAX_LENGTH} characters` + ) + .optional(), + }) + .strict() + +export const v2ImportWorkflowDataSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + workspaceId: z.string(), + folderPath: v2FolderPathSchema, + createdAt: z.string(), + updatedAt: z.string(), +}) + export const v2ExportWorkflowContract = defineRouteContract({ method: 'GET', path: '/api/v2/workflows/[id]/export', params: workflowIdParamsSchema, response: { mode: 'json', - schema: v2DataResponse(v1WorkflowExportPayloadSchema), + schema: v2DataResponse(v2WorkflowExportPayloadSchema), }, }) export const v2ImportWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/import', - body: v1ImportWorkflowBodySchema, + body: v2ImportWorkflowBodySchema, response: { mode: 'json', - schema: v2DataResponse(v1ImportWorkflowDataSchema), + schema: v2DataResponse(v2ImportWorkflowDataSchema), }, }) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index 602d71664cb..c0fa82f81c6 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -17,6 +17,7 @@ import { encodeVfsPathSegments, } from '@/lib/copilot/vfs/path-utils' import { generateRequestId } from '@/lib/core/utils/request' +import { createFolder, deleteFolder, updateFolder } from '@/lib/folders/orchestration' import { deleteKnowledgeBase, getKnowledgeBases, @@ -34,13 +35,7 @@ import { resolveWorkspaceFileReference, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { - performCreateFolder, - performDeleteFolder, - performDeleteWorkflow, - performUpdateFolder, - performUpdateWorkflow, -} from '@/lib/workflows/orchestration' +import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' import { @@ -503,7 +498,8 @@ function makeWorkflowFolderEnsurer( continue } await assertFolderMutable(parentId) - const created = await performCreateFolder({ + const created = await createFolder({ + resourceType: 'workflow', workspaceId, userId, name: segment, @@ -690,7 +686,8 @@ async function mutateWorkflows( continue } await assertFolderMutable(targetFolderId) - const result = await performUpdateFolder({ + const result = await updateFolder({ + resourceType: 'workflow', folderId: ref.folderId, workspaceId, userId: context.userId, @@ -989,7 +986,12 @@ async function removeWorkflowPath( if (!folderId) return { from: path, kind: 'workflow', error: `Not found: ${path}` } await assertFolderMutable(folderId) - const result = await performDeleteFolder({ folderId, workspaceId, userId: context.userId }) + const result = await deleteFolder({ + resourceType: 'workflow', + folderId, + workspaceId, + userId: context.userId, + }) if (!result.success) { return { from: path, diff --git a/apps/sim/lib/folders/config.ts b/apps/sim/lib/folders/config.ts index e8e3a19f06a..8dea1441f48 100644 --- a/apps/sim/lib/folders/config.ts +++ b/apps/sim/lib/folders/config.ts @@ -57,7 +57,7 @@ export interface FolderDeleteRejection { /** * Everything that differs between the four folder-bearing resource types, expressed as - * data. The folder engine in `lib/folders/lifecycle.ts` and the cascade in + * data. The folder engine in `lib/folders/orchestration.ts` and the cascade in * `lib/folders/cascade.ts` read this instead of branching on `resourceType`, so * create/update/delete/restore/reorder each exist exactly once and adding a fifth * foldered resource means adding one entry here. diff --git a/apps/sim/lib/folders/locks.ts b/apps/sim/lib/folders/locks.ts new file mode 100644 index 00000000000..4942d3aaa68 --- /dev/null +++ b/apps/sim/lib/folders/locks.ts @@ -0,0 +1,36 @@ +import { db } from '@sim/db' +import { sql } from 'drizzle-orm' +import type { FolderResourceType } from '@/lib/api/contracts/folders' +import type { DbOrTx } from '@/lib/db/types' + +const FOLDER_MUTATION_LOCK_TIMEOUT_MS = 5_000 + +/** Serializes every writer for one workspace resource-folder tree. */ +export async function acquireFolderMutationLock( + tx: DbOrTx, + workspaceId: string, + resourceType: FolderResourceType +): Promise { + await tx.execute( + sql`select set_config('lock_timeout', ${`${FOLDER_MUTATION_LOCK_TIMEOUT_MS}ms`}, true)` + ) + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`resource_folders:${resourceType}:${workspaceId}`}, 0))` + ) +} + +/** + * Keeps path resolution stable while a resource mutation commits against the + * resolved folder. The callback may use `tx` for reads; folder writers for the + * same workspace and resource type cannot proceed until it returns. + */ +export async function withFolderTreeLock( + workspaceId: string, + resourceType: FolderResourceType, + operation: (tx: DbOrTx) => Promise +): Promise { + return db.transaction(async (tx) => { + await acquireFolderMutationLock(tx, workspaceId, resourceType) + return operation(tx) + }) +} diff --git a/apps/sim/lib/folders/lifecycle.test.ts b/apps/sim/lib/folders/orchestration.test.ts similarity index 88% rename from apps/sim/lib/folders/lifecycle.test.ts rename to apps/sim/lib/folders/orchestration.test.ts index 8da406145db..1a3d1c9fdb2 100644 --- a/apps/sim/lib/folders/lifecycle.test.ts +++ b/apps/sim/lib/folders/orchestration.test.ts @@ -23,6 +23,7 @@ const { mockRestoreFolderChildren, mockRestoreFolderRows, mockWouldCreateFolderCycle, + mockLoadActiveFolderPathIndex, resourceConfig, } = vi.hoisted(() => ({ mockArchiveFolderCascade: vi.fn(), @@ -35,6 +36,7 @@ const { mockRestoreFolderChildren: vi.fn(), mockRestoreFolderRows: vi.fn(), mockWouldCreateFolderCycle: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), resourceConfig: { current: {} as Record }, })) @@ -58,13 +60,24 @@ vi.mock('@/lib/folders/config', () => ({ vi.mock('@/lib/folders/naming', () => ({ deduplicateFolderName: mockDeduplicateFolderName })) -vi.mock('@/lib/folders/queries', () => ({ wouldCreateFolderCycle: mockWouldCreateFolderCycle })) +vi.mock('@/lib/folders/queries', () => ({ + wouldCreateFolderCycle: mockWouldCreateFolderCycle, + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: mockGetWorkspaceWithOwner, })) -import { createFolder, deleteFolder, restoreFolder, updateFolder } from '@/lib/folders/lifecycle' +import { + createFolder, + createFolderAtPath, + deleteFolder, + deleteFolderByPath, + relocateFolderByPath, + restoreFolder, + updateFolder, +} from '@/lib/folders/orchestration' const CHILD_TABLE = { name: 'child_table' } @@ -124,6 +137,11 @@ beforeEach(() => { resetDbChainMock() setConfig() mockWouldCreateFolderCycle.mockResolvedValue(false) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map(), + pathById: new Map(), + idByPath: new Map(), + }) mockDeduplicateFolderName.mockImplementation( async (_tx: unknown, _ws: string, _parent: string | null, name: string) => name ) @@ -312,6 +330,87 @@ describe('createFolder', () => { }) }) +describe('path-owned folder mutations', () => { + it('creates only the addressed leaf under an existing canonical parent path', async () => { + const parent = folderRow({ id: 'parent-1', name: 'Reports' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['parent-1', parent]]), + pathById: new Map([['parent-1', '/Reports']]), + idByPath: new Map([['/Reports', 'parent-1']]), + }) + queueTableRows(schemaMock.folder, [{ minSortOrder: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([ + folderRow({ id: 'folder-2', name: 'Q1', parentId: 'parent-1', sortOrder: -1 }), + ]) + + const result = await createFolderAtPath({ + resourceType: 'table', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports/Q1', + }) + + expect(result).toMatchObject({ success: true, path: '/Reports/Q1' }) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Q1', parentId: 'parent-1' }) + ) + }) + + it('rejects relocating a folder beneath its own descendant before writing', async () => { + const source = folderRow({ id: 'folder-1', name: 'Reports' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['folder-1', source]]), + pathById: new Map([['folder-1', '/Reports']]), + idByPath: new Map([['/Reports', 'folder-1']]), + }) + + const result = await relocateFolderByPath({ + resourceType: 'table', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports', + destinationPath: '/Reports/Archive', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('requires recursive deletion when the path has descendant folders', async () => { + const source = folderRow({ id: 'folder-1', name: 'Reports' }) + const child = folderRow({ id: 'folder-2', name: 'Q1', parentId: 'folder-1' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([ + ['folder-1', source], + ['folder-2', child], + ]), + pathById: new Map([ + ['folder-1', '/Reports'], + ['folder-2', '/Reports/Q1'], + ]), + idByPath: new Map([ + ['/Reports', 'folder-1'], + ['/Reports/Q1', 'folder-2'], + ]), + }) + + const result = await deleteFolderByPath({ + resourceType: 'table', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports', + recursive: false, + }) + + expect(result).toEqual({ + success: false, + error: 'Folder is not empty', + errorCode: 'conflict', + }) + expect(mockArchiveFolderCascade).not.toHaveBeenCalled() + }) +}) + describe('updateFolder', () => { const baseUpdate = { resourceType: 'table' as const, diff --git a/apps/sim/lib/folders/lifecycle.ts b/apps/sim/lib/folders/orchestration.ts similarity index 57% rename from apps/sim/lib/folders/lifecycle.ts rename to apps/sim/lib/folders/orchestration.ts index 405c8663797..0514a46a31a 100644 --- a/apps/sim/lib/folders/lifecycle.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -2,11 +2,12 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { folder as folderTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getPostgresErrorCode } from '@sim/utils/errors' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull, min } from 'drizzle-orm' import type { FolderCascadeCountsApi, FolderResourceType } from '@/lib/api/contracts/folders' import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { withTransactionRetry } from '@/lib/db/transaction' import type { DbOrTx } from '@/lib/db/types' import { archiveFolderCascade, @@ -17,12 +18,19 @@ import { toCascadeCounts, } from '@/lib/folders/cascade' import { folderResourceConfig } from '@/lib/folders/config' +import { acquireFolderMutationLock, withFolderTreeLock } from '@/lib/folders/locks' import { deduplicateFolderName } from '@/lib/folders/naming' -import { wouldCreateFolderCycle } from '@/lib/folders/queries' +import { + type FolderPathIndex, + folderNameFromPath, + parentFolderPath, + requireNonRootFolderPath, +} from '@/lib/folders/paths' +import { loadActiveFolderPathIndex, wouldCreateFolderCycle } from '@/lib/folders/queries' import type { FolderMutationErrorCode } from '@/lib/folders/status' import { notifyFolderResourceChanged } from '@/lib/realtime/notify' -const logger = createLogger('FolderLifecycle') +const logger = createLogger('FolderOrchestration') const DUPLICATE_NAME_ERROR = 'A folder with this name already exists in this location' @@ -60,6 +68,7 @@ export interface DeleteFolderParams { workspaceId: string userId: string folderName?: string + folderPath?: string } export interface DeleteFolderResult { @@ -84,6 +93,271 @@ export interface RestoreFolderResult { restoredItems?: FolderCascadeCountsApi } +export interface FolderPathMutationResult extends FolderMutationResult { + path?: string +} + +export interface DeleteFolderByPathParams { + resourceType: FolderResourceType + workspaceId: string + userId: string + path: string + recursive: boolean +} + +export interface DeleteFolderByPathResult extends DeleteFolderResult { + path?: string +} + +function validatePathLeafName(path: string): string { + const name = folderNameFromPath(path) + if (name.trim() !== name || name.length === 0 || name.length > 255) { + throw new Error('Folder path leaf must be between 1 and 255 characters without outer spaces') + } + return name +} + +function resolveRequiredFolderId(index: FolderPathIndex, path: string): string { + const folderId = index.idByPath.get(path) + if (!folderId) throw new Error('Folder not found') + return folderId +} + +function isEffectivelyLocked(index: FolderPathIndex, id: string) { + let currentId: string | null = id + while (currentId) { + const row = index.rowById.get(currentId) + if (!row) throw new Error('Folder hierarchy references a missing ancestor') + if (row.locked) return true + currentId = row.parentId + } + return false +} + +function pathMutationError(error: unknown): FolderPathMutationResult { + const message = getErrorMessage(error, 'Internal server error') + if (message === 'Folder not found' || message === 'Parent folder not found') { + return { success: false, error: message, errorCode: 'not_found' } + } + if ( + message === DUPLICATE_NAME_ERROR || + message === 'Folder is not empty' || + getPostgresErrorCode(error) === '23505' + ) { + return { success: false, error: message, errorCode: 'conflict' } + } + if (message === 'Folder is locked') { + return { success: false, error: message, errorCode: 'locked' } + } + if ( + message.includes('Folder path') || + message.includes('root path') || + message.includes('descendant') || + message.includes('canonical folder path') + ) { + return { success: false, error: message, errorCode: 'validation' } + } + logger.error('Folder path mutation failed', { error }) + return { success: false, error: 'Internal server error', errorCode: 'internal' } +} + +/** Creates exactly the leaf identified by `path`; every ancestor must already exist. */ +export async function createFolderAtPath( + params: Omit & { path: string } +): Promise { + try { + requireNonRootFolderPath(params.path) + const name = validatePathLeafName(params.path) + const folder = await withTransactionRetry( + async (tx) => { + await acquireFolderMutationLock(tx, params.workspaceId, params.resourceType) + const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) + if (index.idByPath.has(params.path)) throw new Error(DUPLICATE_NAME_ERROR) + + const parentPath = parentFolderPath(params.path) + const parentId = parentPath === '/' ? null : index.idByPath.get(parentPath) + if (parentPath !== '/' && !parentId) throw new Error('Parent folder not found') + if ( + parentId && + folderResourceConfig(params.resourceType).supportsLocking && + isEffectivelyLocked(index, parentId) + ) { + throw new Error('Folder is locked') + } + + const sortOrder = await nextFolderSortOrder( + params.resourceType, + params.workspaceId, + parentId, + tx + ) + const [created] = await tx + .insert(folderTable) + .values({ + id: generateId(), + resourceType: params.resourceType, + name, + userId: params.userId, + workspaceId: params.workspaceId, + parentId, + sortOrder, + }) + .returning() + return created + }, + { label: 'create-folder-at-path' } + ) + + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + description: `Created ${folderResourceConfig(params.resourceType).label} folder "${params.path}"`, + metadata: { path: params.path, folderResourceType: params.resourceType }, + }) + await notifyFolderResourceChanged(params.resourceType, params.workspaceId) + return { success: true, folder, path: params.path } + } catch (error) { + return pathMutationError(error) + } +} + +/** Renames, moves, or both by replacing one canonical path with another. */ +export async function relocateFolderByPath(params: { + resourceType: FolderResourceType + workspaceId: string + userId: string + path: string + destinationPath: string +}): Promise { + try { + requireNonRootFolderPath(params.path) + requireNonRootFolderPath(params.destinationPath) + const name = validatePathLeafName(params.destinationPath) + + const folder = await withTransactionRetry( + async (tx) => { + await acquireFolderMutationLock(tx, params.workspaceId, params.resourceType) + const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) + const folderId = resolveRequiredFolderId(index, params.path) + if (index.idByPath.has(params.destinationPath)) throw new Error(DUPLICATE_NAME_ERROR) + + const destinationParentPath = parentFolderPath(params.destinationPath) + if ( + destinationParentPath === params.path || + destinationParentPath.startsWith(`${params.path}/`) + ) { + throw new Error('Cannot move a folder into one of its descendants') + } + const resolvedParentId = + destinationParentPath === '/' ? null : index.idByPath.get(destinationParentPath) + if (resolvedParentId === undefined) throw new Error('Parent folder not found') + const parentId = resolvedParentId + + const config = folderResourceConfig(params.resourceType) + if ( + config.supportsLocking && + (isEffectivelyLocked(index, folderId) || + (parentId !== null && isEffectivelyLocked(index, parentId))) + ) { + throw new Error('Folder is locked') + } + + const [updated] = await tx + .update(folderTable) + .set({ name, parentId, updatedAt: new Date() }) + .where( + and( + eq(folderTable.id, folderId), + eq(folderTable.workspaceId, params.workspaceId), + eq(folderTable.resourceType, params.resourceType), + isNull(folderTable.deletedAt) + ) + ) + .returning() + if (!updated) throw new Error('Folder not found') + return updated + }, + { label: 'relocate-folder-by-path' } + ) + + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + description: `Moved ${folderResourceConfig(params.resourceType).label} folder to "${params.destinationPath}"`, + metadata: { + sourcePath: params.path, + destinationPath: params.destinationPath, + folderResourceType: params.resourceType, + }, + }) + await notifyFolderResourceChanged(params.resourceType, params.workspaceId) + return { success: true, folder, path: params.destinationPath } + } catch (error) { + return pathMutationError(error) + } +} + +/** Resolves a public path under the tree lock, then delegates the cascade to the domain engine. */ +export async function deleteFolderByPath( + params: DeleteFolderByPathParams +): Promise { + try { + requireNonRootFolderPath(params.path) + const result = await withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => { + const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) + const folderId = resolveRequiredFolderId(index, params.path) + if ( + folderResourceConfig(params.resourceType).supportsLocking && + isEffectivelyLocked(index, folderId) + ) { + throw new Error('Folder is locked') + } + + if (!params.recursive) { + const hasChildFolder = [...index.pathById.values()].some((candidate) => + candidate.startsWith(`${params.path}/`) + ) + const config = folderResourceConfig(params.resourceType) + const [child] = await tx + .select({ id: config.idColumn }) + .from(config.table) + .where( + and( + eq(config.folderIdColumn, folderId), + eq(config.workspaceColumn, params.workspaceId), + isNull(config.deletedColumn), + config.scope + ) + ) + .limit(1) + if (hasChildFolder || child) throw new Error('Folder is not empty') + } + + const row = index.rowById.get(folderId) + if (!row) throw new Error('Folder not found') + return deleteFolderWithoutTreeLock({ + resourceType: params.resourceType, + folderId, + workspaceId: params.workspaceId, + userId: params.userId, + folderName: row.name, + folderPath: params.path, + }) + }) + return { ...result, path: result.success ? params.path : undefined } + } catch (error) { + return pathMutationError(error) + } +} + /** * Verifies that a prospective parent folder exists, belongs to the target workspace, is of * the same `resourceType`, and is not archived. @@ -95,9 +369,10 @@ export interface RestoreFolderResult { async function assertParentFolderInWorkspace( resourceType: FolderResourceType, parentId: string, - workspaceId: string + workspaceId: string, + tx: DbOrTx = db ): Promise<{ error: string; errorCode: OrchestrationErrorCode } | null> { - const [parent] = await db + const [parent] = await tx .select({ workspaceId: folderTable.workspaceId, archivedAt: folderTable.deletedAt, @@ -177,35 +452,46 @@ export async function createFolder(params: CreateFolderParams): Promise { + await acquireFolderMutationLock(tx, params.workspaceId, params.resourceType) + if (parentId) { + const parentError = await assertParentFolderInWorkspace( + params.resourceType, + parentId, + params.workspaceId, + tx + ) + if (parentError) return { parentError } + } - const [folder] = await db - .insert(folderTable) - .values({ - id: folderId, - resourceType: params.resourceType, - name: params.name.trim(), - userId: params.userId, - workspaceId: params.workspaceId, - parentId, - sortOrder, - }) - .returning() + const sortOrder = + params.sortOrder !== undefined + ? params.sortOrder + : await nextFolderSortOrder(params.resourceType, params.workspaceId, parentId, tx) + + const [folder] = await tx + .insert(folderTable) + .values({ + id: folderId, + resourceType: params.resourceType, + name: params.name.trim(), + userId: params.userId, + workspaceId: params.workspaceId, + parentId, + sortOrder, + }) + .returning() + return { folder } + }, + { label: 'create-folder' } + ) + if ('parentError' in outcome) return { success: false, ...outcome.parentError } + const { folder } = outcome logger.info('Created folder', { folderId, @@ -255,28 +541,6 @@ export async function updateFolder(params: UpdateFolderParams): Promise`: the loose type is what // let `color`/`isExpanded` survive an earlier cutover after the create path dropped them. const updates: Partial = { updatedAt: new Date() } @@ -294,18 +558,52 @@ export async function updateFolder(params: UpdateFolderParams): Promise { + await acquireFolderMutationLock(tx, params.workspaceId, params.resourceType) + if (params.parentId) { + const parentError = await assertParentFolderInWorkspace( + params.resourceType, + params.parentId, + params.workspaceId, + tx + ) + if (parentError) return { parentError } + + const wouldCreateCycle = await wouldCreateFolderCycle( + params.folderId, + params.parentId, + params.resourceType, + tx + ) + if (wouldCreateCycle) { + return { + parentError: { + error: 'Cannot create circular folder reference', + errorCode: 'validation' as const, + }, + } + } + } + + const [folder] = await tx + .update(folderTable) + .set(updates) + .where( + and( + eq(folderTable.id, params.folderId), + eq(folderTable.workspaceId, params.workspaceId), + eq(folderTable.resourceType, params.resourceType), + isNull(folderTable.deletedAt) + ) + ) + .returning() + return { folder } + }, + { label: 'update-folder' } + ) + if ('parentError' in outcome) return { success: false, ...outcome.parentError } + const { folder } = outcome if (!folder) { return { success: false, error: 'Folder not found', errorCode: 'not_found' } @@ -340,7 +638,15 @@ export async function updateFolder(params: UpdateFolderParams): Promise { - const { resourceType, folderId, workspaceId, userId, folderName } = params + return withFolderTreeLock(params.workspaceId, params.resourceType, () => + deleteFolderWithoutTreeLock(params) + ) +} + +async function deleteFolderWithoutTreeLock( + params: DeleteFolderParams +): Promise { + const { resourceType, folderId, workspaceId, userId, folderName, folderPath } = params const config = folderResourceConfig(resourceType) const [existing] = await db @@ -387,9 +693,10 @@ export async function deleteFolder(params: DeleteFolderParams): Promise { +async function restoreFolderWithoutTreeLock( + params: RestoreFolderParams +): Promise { const { resourceType, folderId, workspaceId, userId, folderName } = params const config = folderResourceConfig(resourceType) @@ -563,3 +872,10 @@ export async function restoreFolder(params: RestoreFolderParams): Promise { + return withFolderTreeLock(params.workspaceId, params.resourceType, () => + restoreFolderWithoutTreeLock(params) + ) +} diff --git a/apps/sim/lib/folders/paths.test.ts b/apps/sim/lib/folders/paths.test.ts new file mode 100644 index 00000000000..0048e480251 --- /dev/null +++ b/apps/sim/lib/folders/paths.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { + v2CreateFolderBodySchema, + v2DeleteFolderQuerySchema, + v2ListFoldersQuerySchema, + v2RelocateFolderBodySchema, +} from '@/lib/api/contracts/v2/shared' +import { + buildFolderPath, + buildFolderPathIndex, + encodeFolderPathSegment, + MAX_FOLDER_PATH_SEGMENTS, + parseFolderPath, + ROOT_FOLDER_PATH, +} from '@/lib/folders/paths' + +describe('canonical folder paths', () => { + it('round-trips exact names without case or Unicode normalization', () => { + const segments = ['Reports', 'Q1 / 100%', 'é', '.', '..'] + const path = buildFolderPath(segments) + + expect(path).toBe('/Reports/Q1%20%2F%20100%25/e%CC%81/%2E/%2E%2E') + expect(parseFolderPath(path)).toEqual(segments) + expect(buildFolderPath([])).toBe(ROOT_FOLDER_PATH) + expect(encodeFolderPathSegment('é')).not.toBe(encodeFolderPathSegment('é')) + }) + + it.each([ + '', + 'Reports', + '/Reports/', + '/Reports//Q1', + '/Reports Q1', + '/R%C3%A9sum%c3%a9', + '/%52eports', + '/.', + '/..', + '/%E0%A4%A', + ])('rejects noncanonical path %s', (path) => { + expect(() => parseFolderPath(path)).toThrow() + }) + + it('builds a bidirectional index and rejects corrupt hierarchies', () => { + const rows = [ + { id: 'a', name: 'Reports', parentId: null }, + { id: 'b', name: 'Q1', parentId: 'a' }, + ] + const index = buildFolderPathIndex(rows) + + expect(index.pathById.get('b')).toBe('/Reports/Q1') + expect(index.idByPath.get('/Reports/Q1')).toBe('b') + expect( + buildFolderPathIndex([ + { id: 'upper', name: 'Reports', parentId: null }, + { id: 'lower', name: 'reports', parentId: null }, + ]).idByPath + ).toEqual( + new Map([ + ['/Reports', 'upper'], + ['/reports', 'lower'], + ]) + ) + expect(() => + buildFolderPathIndex([ + { id: 'a', name: 'Reports', parentId: null }, + { id: 'b', name: 'Reports', parentId: null }, + ]) + ).toThrow('duplicate path') + expect(() => buildFolderPathIndex([{ id: 'a', name: 'Reports', parentId: 'missing' }])).toThrow( + 'missing folder' + ) + expect(() => + buildFolderPathIndex([ + { id: 'a', name: 'A', parentId: 'b' }, + { id: 'b', name: 'B', parentId: 'a' }, + ]) + ).toThrow('cycle') + }) + + it('enforces segment and byte limits', () => { + expect(() => + buildFolderPath(Array.from({ length: MAX_FOLDER_PATH_SEGMENTS + 1 }, () => 'x')) + ).toThrow('segments') + expect(() => buildFolderPath(['x'.repeat(4096)])).toThrow('bytes') + }) + + it('keeps public folder mutations path-only and rejects the virtual root', () => { + expect(v2ListFoldersQuerySchema.parse({ workspaceId: 'workspace-1', parentPath: '/' })).toEqual( + { + workspaceId: 'workspace-1', + parentPath: '/', + sortBy: 'name', + sortOrder: 'asc', + } + ) + expect( + v2CreateFolderBodySchema.safeParse({ workspaceId: 'workspace-1', path: '/' }).success + ).toBe(false) + expect( + v2CreateFolderBodySchema.safeParse({ + workspaceId: 'workspace-1', + path: '/Reports', + folderId: 'internal-id', + }).success + ).toBe(false) + expect( + v2RelocateFolderBodySchema.safeParse({ + workspaceId: 'workspace-1', + path: '/Reports', + destinationPath: '/Reports', + }).success + ).toBe(false) + expect( + v2DeleteFolderQuerySchema.parse({ + workspaceId: 'workspace-1', + path: '/Reports', + recursive: 'true', + }).recursive + ).toBe(true) + expect( + v2DeleteFolderQuerySchema.safeParse({ workspaceId: 'workspace-1', path: '/Reports' }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/folders/paths.ts b/apps/sim/lib/folders/paths.ts new file mode 100644 index 00000000000..9a8524e6f42 --- /dev/null +++ b/apps/sim/lib/folders/paths.ts @@ -0,0 +1,184 @@ +import type { folder } from '@sim/db/schema' + +export const ROOT_FOLDER_PATH = '/' +export const MAX_FOLDER_PATH_SEGMENTS = 64 +export const MAX_FOLDER_PATH_BYTES = 4096 + +type FolderPathRow = Pick + +export class FolderPathError extends Error { + constructor(message: string) { + super(message) + this.name = 'FolderPathError' + } +} + +export interface FolderPathIndex { + rowById: ReadonlyMap + pathById: ReadonlyMap + idByPath: ReadonlyMap +} + +export interface FolderPathView { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string +} + +function encodedByteLength(value: string): number { + return new TextEncoder().encode(value).length +} + +/** Encodes one stored folder name without normalizing its case or Unicode form. */ +export function encodeFolderPathSegment(name: string): string { + if (name.length === 0) throw new FolderPathError('Folder names cannot be empty') + + if (name === '.') return '%2E' + if (name === '..') return '%2E%2E' + + try { + return encodeURIComponent(name).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}` + ) + } catch { + throw new FolderPathError('Folder name contains invalid Unicode') + } +} + +/** Builds the canonical public path for a decoded sequence of folder names. */ +export function buildFolderPath(segments: readonly string[]): string { + if (segments.length === 0) return ROOT_FOLDER_PATH + if (segments.length > MAX_FOLDER_PATH_SEGMENTS) { + throw new FolderPathError(`Folder paths cannot exceed ${MAX_FOLDER_PATH_SEGMENTS} segments`) + } + + const path = `/${segments.map(encodeFolderPathSegment).join('/')}` + if (encodedByteLength(path) > MAX_FOLDER_PATH_BYTES) { + throw new FolderPathError(`Folder paths cannot exceed ${MAX_FOLDER_PATH_BYTES} bytes`) + } + return path +} + +/** + * Parses a canonical public folder path. Accepted paths are byte-for-byte canonical: callers + * cannot use alternate escapes, raw reserved characters, or normalization aliases. + */ +export function parseFolderPath(path: string): string[] { + if (path === ROOT_FOLDER_PATH) return [] + if (!path.startsWith('/') || path.endsWith('/') || path.includes('//')) { + throw new FolderPathError('Path must be a canonical folder path') + } + if (encodedByteLength(path) > MAX_FOLDER_PATH_BYTES) { + throw new FolderPathError(`Folder paths cannot exceed ${MAX_FOLDER_PATH_BYTES} bytes`) + } + + const encodedSegments = path.slice(1).split('/') + if (encodedSegments.length > MAX_FOLDER_PATH_SEGMENTS) { + throw new FolderPathError(`Folder paths cannot exceed ${MAX_FOLDER_PATH_SEGMENTS} segments`) + } + + return encodedSegments.map((encodedSegment) => { + let decoded: string + try { + decoded = decodeURIComponent(encodedSegment) + } catch { + throw new FolderPathError('Path must be a canonical folder path') + } + + if (encodeFolderPathSegment(decoded) !== encodedSegment) { + throw new FolderPathError('Path must be a canonical folder path') + } + return decoded + }) +} + +export function requireNonRootFolderPath(path: string): string[] { + const segments = parseFolderPath(path) + if (segments.length === 0) throw new FolderPathError('The root path cannot be mutated') + return segments +} + +export function parentFolderPath(path: string): string { + const segments = requireNonRootFolderPath(path) + return buildFolderPath(segments.slice(0, -1)) +} + +export function folderNameFromPath(path: string): string { + const segments = requireNonRootFolderPath(path) + return segments[segments.length - 1] +} + +/** Builds a lossless, fail-fast bidirectional index over one active resource folder tree. */ +export function buildFolderPathIndex( + rows: readonly Row[] +): FolderPathIndex { + const rowById = new Map() + for (const row of rows) { + if (rowById.has(row.id)) throw new FolderPathError(`Duplicate folder id: ${row.id}`) + rowById.set(row.id, row) + } + + const pathById = new Map() + const idByPath = new Map() + const visiting = new Set() + + const resolvePath = (folderId: string): string => { + const resolved = pathById.get(folderId) + if (resolved) return resolved + if (visiting.has(folderId)) throw new FolderPathError('Folder hierarchy contains a cycle') + + const row = rowById.get(folderId) + if (!row) throw new FolderPathError(`Folder hierarchy references missing folder: ${folderId}`) + + visiting.add(folderId) + const parentPath = row.parentId ? resolvePath(row.parentId) : ROOT_FOLDER_PATH + const path = + parentPath === ROOT_FOLDER_PATH + ? `/${encodeFolderPathSegment(row.name)}` + : `${parentPath}/${encodeFolderPathSegment(row.name)}` + visiting.delete(folderId) + + parseFolderPath(path) + const duplicateId = idByPath.get(path) + if (duplicateId && duplicateId !== folderId) { + throw new FolderPathError(`Folder hierarchy contains duplicate path: ${path}`) + } + pathById.set(folderId, path) + idByPath.set(path, folderId) + return path + } + + for (const row of rows) resolvePath(row.id) + + return { rowById, pathById, idByPath } +} + +export function toFolderPathView( + row: Pick, + path: string +): FolderPathView { + return { + name: row.name, + path, + parentPath: parentFolderPath(path), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +export function isFolderPathEffectivelyLocked( + index: FolderPathIndex, + folderId: string +): boolean { + let currentId: string | null = folderId + while (currentId) { + const row = index.rowById.get(currentId) + if (!row) throw new FolderPathError('Folder hierarchy references a missing ancestor') + if (row.locked) return true + currentId = row.parentId + } + return false +} diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index 98f616e1a21..7811476e82d 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -2,11 +2,14 @@ import { db } from '@sim/db' import { folder } from '@sim/db/schema' import { and, type Column, eq, isNotNull, isNull } from 'drizzle-orm' import type { FolderApi, FolderResourceType } from '@/lib/api/contracts/folders' -import type { V2FolderSortBy } from '@/lib/api/contracts/v2/folders' import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' import { listOrderBy, searchFilter } from '@/lib/api/list-query' +import type { DbOrTx } from '@/lib/db/types' +import { buildFolderPathIndex, type FolderPathIndex, ROOT_FOLDER_PATH } from '@/lib/folders/paths' import type { FolderQueryScope } from '@/hooks/queries/utils/folder-keys' +export type FolderSortBy = 'position' | 'name' | 'createdAt' | 'updatedAt' + /** * Normalizes a `folder` row to the `FolderApi` wire shape (timestamps as ISO strings). * @@ -31,7 +34,8 @@ export function toFolderApi(row: typeof folder.$inferSelect): FolderApi { export async function wouldCreateFolderCycle( folderId: string, parentId: string, - resourceType: FolderResourceType + resourceType: FolderResourceType, + tx: DbOrTx = db ): Promise { let currentParentId: string | null = parentId const visited = new Set() @@ -40,7 +44,7 @@ export async function wouldCreateFolderCycle( if (visited.has(currentParentId) || currentParentId === folderId) return true visited.add(currentParentId) - const [parent] = await db + const [parent] = await tx .select({ parentId: folder.parentId }) .from(folder) .where(and(eq(folder.id, currentParentId), eq(folder.resourceType, resourceType))) @@ -148,15 +152,77 @@ const FOLDER_SORTS = { name: [folder.name, folder.createdAt], createdAt: [folder.createdAt], updatedAt: [folder.updatedAt, folder.createdAt], -} satisfies Record +} satisfies Record interface ListFoldersOptions { /** Case-insensitive substring match on the folder name. */ search?: string - sortBy?: V2FolderSortBy + sortBy?: FolderSortBy sortOrder?: V2SortOrder } +interface ListActiveFolderRowsOptions { + parentId?: string | null + search?: string + sortBy?: Exclude + sortOrder?: V2SortOrder +} + +export async function loadActiveFolderPathIndex( + workspaceId: string, + resourceType: FolderResourceType, + tx: DbOrTx = db +): Promise> { + const rows = await tx + .select() + .from(folder) + .where( + and( + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, resourceType), + isNull(folder.deletedAt) + ) + ) + + return buildFolderPathIndex(rows) +} + +/** Resolves a canonical folder path to its internal id; `/` resolves to the root sentinel. */ +export function resolveFolderPathFromIndex( + index: FolderPathIndex, + path: string +): string | null | undefined { + return path === ROOT_FOLDER_PATH ? null : index.idByPath.get(path) +} + +export async function listActiveFolderRows( + workspaceId: string, + resourceType: FolderResourceType, + options: ListActiveFolderRowsOptions = {}, + tx: DbOrTx = db +): Promise> { + const parentFilter = + options.parentId === undefined + ? undefined + : options.parentId === null + ? isNull(folder.parentId) + : eq(folder.parentId, options.parentId) + + return tx + .select() + .from(folder) + .where( + and( + eq(folder.workspaceId, workspaceId), + eq(folder.resourceType, resourceType), + isNull(folder.deletedAt), + parentFilter, + searchFilter(folder.name, options.search) + ) + ) + .orderBy(...listOrderBy(FOLDER_SORTS[options.sortBy ?? 'name'], options.sortOrder ?? 'asc')) +} + /** * Shared by `GET /api/folders`, the public v2 list, and the sidebar prefetch so * the query never drifts between them. Search and sort are applied in the diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index 6131ae58fd0..0991e17a5f3 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -135,7 +135,8 @@ const KNOWLEDGE_BASE_SORTS = { interface GetKnowledgeBasesOptions { /** Restrict to one knowledge-base folder. */ - folderId?: string + /** `undefined` lists every folder, `null` lists only workspace-root resources. */ + folderId?: string | null /** Case-insensitive substring match on the knowledge base name. */ search?: string sortBy?: V2KnowledgeBaseSortBy @@ -201,7 +202,11 @@ export async function getKnowledgeBases( .where( and( scopeCondition, - folderId ? eq(knowledgeBase.folderId, folderId) : undefined, + folderId === undefined + ? undefined + : folderId === null + ? isNull(knowledgeBase.folderId) + : eq(knowledgeBase.folderId, folderId), searchFilter(knowledgeBase.name, search), workspaceId ? // When filtering by workspace diff --git a/apps/sim/lib/resources/orchestration/restore-resource.ts b/apps/sim/lib/resources/orchestration/restore-resource.ts index ec7055f9c5f..4be469d5ffe 100644 --- a/apps/sim/lib/resources/orchestration/restore-resource.ts +++ b/apps/sim/lib/resources/orchestration/restore-resource.ts @@ -4,6 +4,7 @@ import { generateId } from '@sim/utils/id' import type { FolderResourceType } from '@/lib/api/contracts/folders' import type { MothershipResource } from '@/lib/copilot/resources/types' import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { restoreFolder } from '@/lib/folders/orchestration' import { getRestorableKnowledgeBase, performRestoreKnowledgeBase, @@ -11,7 +12,7 @@ import { import { performRestoreTable } from '@/lib/table/orchestration' import { getTableById } from '@/lib/table/service' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { performRestoreFolder, performRestoreWorkflow } from '@/lib/workflows/orchestration' +import { performRestoreWorkflow } from '@/lib/workflows/orchestration' import { getWorkflowById } from '@/lib/workflows/utils' import { performRestoreWorkspaceFile, @@ -168,7 +169,7 @@ export async function performRestoreResource( return { success: false, error: 'Folder not found' } } - const result = await performRestoreFolder({ + const result = await restoreFolder({ folderId: id, workspaceId, userId, diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts index 2f1e88ef8d1..127058074f5 100644 --- a/apps/sim/lib/table/orchestration/import-resource.ts +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -63,10 +63,11 @@ interface CreateTableImportResult { export async function createTableImportResource( body: V2CreateTableImportBody, userId: string, - localOrigin: string + localOrigin: string, + resolvedFolderId?: string | null ): Promise { await assertWorkspaceWrite(userId, body.workspaceId) - await validateTarget(body.workspaceId, body.target) + await validateTarget(body.workspaceId, body.target, resolvedFolderId) const importId = generateId() const options = importOptions(body) @@ -83,7 +84,7 @@ export async function createTableImportResource( fileName: body.source.name, contentType: body.source.contentType, fileSize: body.source.size, - metadata: { tableImport: body }, + metadata: { tableImport: body, tableImportFolderId: resolvedFolderId ?? null }, localOrigin, }) return { record: resourceFromUpload(upload, body), upload } @@ -98,6 +99,7 @@ export async function createTableImportResource( userId, source: body.source, target: body.target, + folderId: resolvedFolderId, options, fileKey: file.key, fileName: file.name, @@ -119,12 +121,22 @@ export async function startUploadedTableImport( userId: upload.userId, }) if (existing) return existing + const storedFolderId = upload.metadata.tableImportFolderId + let folderId: string | null | undefined + if (body.target.type === 'new') { + if (storedFolderId !== null && typeof storedFolderId !== 'string') { + throw new Error('Table import upload is missing its resolved folder target') + } + folderId = storedFolderId + } + await validateTarget(workspaceId, body.target, folderId) return startTableImport({ id: upload.id, workspaceId, userId: upload.userId, source: body.source, target: body.target, + folderId, options: importOptions(body), fileKey: upload.storageKey, fileName: upload.fileName, @@ -252,6 +264,7 @@ interface StartTableImportParams { userId: string source: V2TableImportSource target: V2TableImportTarget + folderId?: string | null options: TableImportJobPayload['options'] fileKey: string fileName: string @@ -278,7 +291,7 @@ async function startTableImport(params: StartTableImportParams): Promise { +async function validateTarget( + workspaceId: string, + target: V2TableImportTarget, + resolvedFolderId?: string | null +): Promise { if (target.type === 'new') { - if (target.folderId && !(await findActiveFolder(target.folderId, workspaceId, 'table'))) { + if (resolvedFolderId && !(await findActiveFolder(resolvedFolderId, workspaceId, 'table'))) { throw new OrchestrationError('not_found', 'Folder not found in this workspace') } return diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index a94feb4b5fd..24925e41549 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -282,7 +282,8 @@ type TableRowSelection = Awaited< interface ListTablesOptions { scope?: TableScope /** Restrict to one table folder. */ - folderId?: string + /** `undefined` lists every folder, `null` lists only workspace-root tables. */ + folderId?: string | null /** Case-insensitive substring match on the table name. */ search?: string sortBy?: V2TableSortBy @@ -320,7 +321,11 @@ export async function listTables( : scope === 'archived' ? isNotNull(userTableDefinitions.archivedAt) : isNull(userTableDefinitions.archivedAt), - folderId ? eq(userTableDefinitions.folderId, folderId) : undefined, + folderId === undefined + ? undefined + : folderId === null + ? isNull(userTableDefinitions.folderId) + : eq(userTableDefinitions.folderId, folderId), searchFilter(userTableDefinitions.name, search) ) ) @@ -363,7 +368,8 @@ async function hydrateTableRows(rows: TableRowSelection[]): Promise -} - export interface WorkspaceFileArchiveResult { folders: number files: number @@ -121,16 +119,8 @@ function fileFolderCondition(folderId?: string | null) { return normalized ? eq(workspaceFiles.folderId, normalized) : isNull(workspaceFiles.folderId) } -async function acquireWorkspaceFileFolderMutationLock( - tx: WorkspaceFileFolderLockTx, - workspaceId: string -) { - await tx.execute( - sql`SELECT set_config('lock_timeout', ${`${WORKSPACE_FILE_FOLDER_LOCK_TIMEOUT_MS}ms`}, true)` - ) - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtextextended(${`workspace_file_folders:${workspaceId}`}, 0))` - ) +async function acquireWorkspaceFileFolderMutationLock(tx: DbOrTx, workspaceId: string) { + await acquireFolderMutationLock(tx, workspaceId, FILE_FOLDER_RESOURCE_TYPE) } export function buildWorkspaceFileFolderPathMap( @@ -355,10 +345,26 @@ export async function resolveWorkspaceFileFolderTarget( export async function assertWorkspaceFileFolderTarget( workspaceId: string, - folderId?: string | null + folderId?: string | null, + executor: DbOrTx = db ): Promise { - const folder = await resolveWorkspaceFileFolderTarget(workspaceId, folderId) - return folder?.id ?? null + const normalized = normalizeParentId(folderId) + if (!normalized) return null + + const [folder] = await executor + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + eq(folderTable.id, normalized), + eq(folderTable.workspaceId, workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) + ) + ) + .limit(1) + if (!folder) throw new OrchestrationError('not_found', 'Target folder not found') + return folder.id } export async function createWorkspaceFileFolder(params: { @@ -707,14 +713,33 @@ export async function moveWorkspaceFileItems(params: { fileIds?: string[] folderIds?: string[] targetFolderId?: string | null + targetFolderPath?: string }): Promise<{ movedFiles: number; movedFolders: number }> { const fileIds = Array.from(new Set(params.fileIds ?? [])) const folderIds = Array.from(new Set(params.folderIds ?? [])) - const targetFolderId = normalizeParentId(params.targetFolderId) + if (params.targetFolderId !== undefined && params.targetFolderPath !== undefined) { + throw new OrchestrationError('validation', 'Specify a target folder id or path, not both') + } return db.transaction(async (tx) => { await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId) + let targetFolderId = normalizeParentId(params.targetFolderId) + if (params.targetFolderPath !== undefined) { + try { + parseFolderPath(params.targetFolderPath) + } catch (error) { + throw new OrchestrationError('validation', getErrorMessage(error)) + } + const index = await loadActiveFileFolderPathIndex(tx, params.workspaceId) + const resolved = + params.targetFolderPath === '/' ? null : index.idByPath.get(params.targetFolderPath) + if (resolved === undefined) { + throw new OrchestrationError('not_found', 'Target folder not found') + } + targetFolderId = resolved + } + if (targetFolderId) { const [target] = await tx .select({ id: folderTable.id }) @@ -1193,3 +1218,206 @@ export async function bulkArchiveWorkspaceFileItems(params: { } }) } + +async function loadActiveFileFolderPathIndex(tx: DbOrTx, workspaceId: string) { + const rows = await tx + .select() + .from(folderTable) + .where( + and(eq(folderTable.workspaceId, workspaceId), isFileFolder, isNull(folderTable.deletedAt)) + ) + return buildFolderPathIndex(rows) +} + +export interface WorkspaceFileFolderPathMutation { + folder: typeof folderTable.$inferSelect + path: string +} + +/** Creates one file-folder leaf with path resolution inside the file tree's mutation lock. */ +export async function createWorkspaceFileFolderAtPath(params: { + workspaceId: string + userId: string + path: string +}): Promise { + requireNonRootFolderPath(params.path) + const pathName = folderNameFromPath(params.path) + let name: string + try { + name = normalizeWorkspaceFileItemName(pathName, 'Folder') + } catch (error) { + throw new OrchestrationError('validation', getErrorMessage(error)) + } + if (name !== pathName) { + throw new OrchestrationError('validation', 'Folder path leaf cannot have outer spaces') + } + + const folder = await db.transaction(async (tx) => { + await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId) + const index = await loadActiveFileFolderPathIndex(tx, params.workspaceId) + if (index.idByPath.has(params.path)) throw new WorkspaceFileFolderConflictError(name) + + const parentPath = parentFolderPath(params.path) + const parentId = parentPath === '/' ? null : index.idByPath.get(parentPath) + if (parentPath !== '/' && !parentId) { + throw new OrchestrationError('not_found', 'Parent folder not found') + } + + const [sortOrderResult] = await tx + .select({ minSortOrder: min(folderTable.sortOrder) }) + .from(folderTable) + .where( + and( + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + folderParentCondition(parentId), + isNull(folderTable.deletedAt) + ) + ) + + const [created] = await tx + .insert(folderTable) + .values({ + id: generateId(), + resourceType: FILE_FOLDER_RESOURCE_TYPE, + name, + userId: params.userId, + workspaceId: params.workspaceId, + parentId, + sortOrder: sortOrderResult?.minSortOrder != null ? sortOrderResult.minSortOrder - 1 : 0, + }) + .returning() + return created + }) + + return { folder, path: params.path } +} + +/** Relocates one file folder while source and destination paths share the same tree lock. */ +export async function relocateWorkspaceFileFolderByPath(params: { + workspaceId: string + path: string + destinationPath: string +}): Promise { + requireNonRootFolderPath(params.path) + requireNonRootFolderPath(params.destinationPath) + const pathName = folderNameFromPath(params.destinationPath) + let name: string + try { + name = normalizeWorkspaceFileItemName(pathName, 'Folder') + } catch (error) { + throw new OrchestrationError('validation', getErrorMessage(error)) + } + if (name !== pathName) { + throw new OrchestrationError('validation', 'Folder path leaf cannot have outer spaces') + } + + const folder = await db.transaction(async (tx) => { + await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId) + const index = await loadActiveFileFolderPathIndex(tx, params.workspaceId) + const folderId = index.idByPath.get(params.path) + if (!folderId) throw new OrchestrationError('not_found', 'Folder not found') + if (index.idByPath.has(params.destinationPath)) { + throw new WorkspaceFileFolderConflictError(name) + } + + const destinationParentPath = parentFolderPath(params.destinationPath) + if ( + destinationParentPath === params.path || + destinationParentPath.startsWith(`${params.path}/`) + ) { + throw new OrchestrationError('validation', 'Cannot move a folder into one of its descendants') + } + const parentId = + destinationParentPath === '/' ? null : index.idByPath.get(destinationParentPath) + if (destinationParentPath !== '/' && !parentId) { + throw new OrchestrationError('not_found', 'Parent folder not found') + } + + const [updated] = await tx + .update(folderTable) + .set({ name, parentId, updatedAt: new Date() }) + .where( + and( + eq(folderTable.id, folderId), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) + ) + ) + .returning() + if (!updated) throw new OrchestrationError('not_found', 'Folder not found') + return updated + }) + + return { folder, path: params.destinationPath } +} + +/** Deletes a file-folder subtree, or only an empty folder when `recursive` is false. */ +export async function deleteWorkspaceFileFolderByPath(params: { + workspaceId: string + path: string + recursive: boolean +}): Promise { + requireNonRootFolderPath(params.path) + const now = new Date() + + return db.transaction(async (tx) => { + await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId) + const index = await loadActiveFileFolderPathIndex(tx, params.workspaceId) + const folderId = index.idByPath.get(params.path) + if (!folderId) throw new OrchestrationError('not_found', 'Folder not found') + + const folderIds = [ + folderId, + ...[...index.pathById.entries()] + .filter(([, path]) => path.startsWith(`${params.path}/`)) + .map(([id]) => id), + ] + + if (!params.recursive) { + const [file] = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.folderId, folderId), + eq(workspaceFiles.workspaceId, params.workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + .limit(1) + if (folderIds.length > 1 || file) { + throw new OrchestrationError('conflict', 'Folder is not empty') + } + } + + const archivedFiles = await tx + .update(workspaceFiles) + .set({ deletedAt: now, updatedAt: now }) + .where( + and( + inArray(workspaceFiles.folderId, folderIds), + eq(workspaceFiles.workspaceId, params.workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + .returning({ id: workspaceFiles.id }) + const archivedFolders = await tx + .update(folderTable) + .set({ deletedAt: now, updatedAt: now }) + .where( + and( + inArray(folderTable.id, folderIds), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) + ) + ) + .returning({ id: folderTable.id }) + + return { folders: archivedFolders.length, files: archivedFiles.length } + }) +} 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 ebd22f89f83..3a728de58fd 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -38,6 +38,9 @@ import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestrati import { generateRequestId } from '@/lib/core/utils/request' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' +import { acquireFolderMutationLock } from '@/lib/folders/locks' +import { parseFolderPath } from '@/lib/folders/paths' +import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' import { mergeEditIntoLiveFileDoc, notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { getServePathPrefix } from '@/lib/uploads' import { @@ -338,13 +341,30 @@ export async function uploadWorkspaceFile( fileBuffer: Buffer, fileName: string, contentType: string, - options?: { folderId?: string | null; exactName?: boolean } + options?: { folderId?: string | null; folderPath?: string; exactName?: boolean } ): Promise { logger.info(`Uploading workspace file: ${fileName} for workspace ${workspaceId}`) - const folderTarget = await resolveWorkspaceFileFolderTarget(workspaceId, options?.folderId) - const folderId = folderTarget?.id ?? null - const folderPath = folderTarget?.path ?? null + if (options?.folderId !== undefined && options.folderPath !== undefined) { + throw new OrchestrationError('validation', 'Specify either folderId or folderPath, not both') + } + + let folderId: string | null + let folderPath: string | null + if (options?.folderPath !== undefined) { + const folderPathSegments = parseFolderPath(options.folderPath) + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file') + const resolvedFolderId = resolveFolderPathFromIndex(folderIndex, options.folderPath) + if (resolvedFolderId === undefined) { + throw new OrchestrationError('not_found', 'Target folder not found') + } + folderId = resolvedFolderId + folderPath = resolvedFolderId ? folderPathSegments.join('/') : null + } else { + const folderTarget = await resolveWorkspaceFileFolderTarget(workspaceId, options?.folderId) + folderId = folderTarget?.id ?? null + folderPath = folderTarget?.path ?? null + } const normalizedFileName = normalizeWorkspaceFileItemName(fileName, 'File') const exactName = options?.exactName ?? false const storageBillingContext = await resolveStorageBillingContext(workspaceId) @@ -370,7 +390,7 @@ export async function uploadWorkspaceFile( purpose: 'workspace', userId: userId, workspaceId: workspaceId, - ...(folderId ? { folderId } : {}), + ...(folderId && options?.folderPath === undefined ? { folderId } : {}), } const uploadResult = await uploadFile({ @@ -392,12 +412,24 @@ export async function uploadWorkspaceFile( } try { finalized = await db.transaction(async (tx) => { + await acquireFolderMutationLock(tx, workspaceId, 'file') + let activeFolderId: string | null + if (options?.folderPath !== undefined) { + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file', tx) + const resolvedFolderId = resolveFolderPathFromIndex(folderIndex, options.folderPath) + if (resolvedFolderId === undefined) { + throw new OrchestrationError('not_found', 'Target folder not found') + } + activeFolderId = resolvedFolderId + } else { + activeFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId, tx) + } const inserted = await insertWorkspaceFileMetadataInTx(tx, { id: fileId, key: uploadResult.key, userId, workspaceId, - folderId, + folderId: activeFolderId, originalName: uniqueName, contentType, size: fileBuffer.length, @@ -537,7 +569,7 @@ export async function registerUploadedWorkspaceFile(params: { } } - const folderId = await assertWorkspaceFileFolderTarget(workspaceId, params.folderId) + const folderId = params.folderId ?? null const storageBillingContext = await resolveStorageBillingContext(workspaceId) for (let attempt = 0; attempt < MAX_UPLOAD_UNIQUE_RETRIES; attempt++) { @@ -549,12 +581,14 @@ export async function registerUploadedWorkspaceFile(params: { ) const finalized = await db.transaction(async (tx) => { + await acquireFolderMutationLock(tx, workspaceId, 'file') + const activeFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId, tx) const inserted = await insertWorkspaceFileMetadataInTx(tx, { id: fileId, key, userId, workspaceId, - folderId, + folderId: activeFolderId, originalName: displayName, contentType, size: verifiedSize, @@ -1019,7 +1053,8 @@ const WORKSPACE_FILE_SORTS = { export interface QueryWorkspaceFilesOptions { scope?: WorkspaceFileScope /** Restrict to one file folder. */ - folderId?: string + /** `undefined` lists every folder, `null` lists only root files. */ + folderId?: string | null /** Case-insensitive substring match on the file name. */ search?: string sortBy: V2FileSortBy @@ -1064,7 +1099,11 @@ export async function queryWorkspaceFiles( const conditions = [ workspaceFileScopeCondition(workspaceId, scope), - folderId ? eq(workspaceFiles.folderId, folderId) : undefined, + folderId === undefined + ? undefined + : folderId === null + ? isNull(workspaceFiles.folderId) + : eq(workspaceFiles.folderId, folderId), searchFilter(workspaceFiles.originalName, search), resumeAfter, ] diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts index cb101d5455e..311095ba896 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts @@ -10,11 +10,15 @@ const { mockGetWorkspaceWithOwner, mockHasCloudStorage, mockHeadObject, + mockAcquireFolderMutationLock, + mockAssertWorkspaceFileFolderTarget, mockIncrementStorageUsageForBillingContextInTx, + mockLoadActiveFolderPathIndex, mockMaybeNotifyStorageLimitForBillingContext, mockMergeEditIntoLiveFileDoc, mockNotifyWorkspaceFilesChanged, mockResolveStorageBillingContext, + mockResolveFolderPathFromIndex, mockResolveWorkspaceFileFolderTarget, mockUploadFile, } = vi.hoisted(() => ({ @@ -23,11 +27,15 @@ const { mockGetWorkspaceWithOwner: vi.fn(), mockHasCloudStorage: vi.fn(), mockHeadObject: vi.fn(), + mockAcquireFolderMutationLock: vi.fn(), + mockAssertWorkspaceFileFolderTarget: vi.fn(), mockIncrementStorageUsageForBillingContextInTx: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), mockMaybeNotifyStorageLimitForBillingContext: vi.fn(), mockMergeEditIntoLiveFileDoc: vi.fn(), mockNotifyWorkspaceFilesChanged: vi.fn(), mockResolveStorageBillingContext: vi.fn(), + mockResolveFolderPathFromIndex: vi.fn(), mockResolveWorkspaceFileFolderTarget: vi.fn(), mockUploadFile: vi.fn(), })) @@ -57,7 +65,7 @@ vi.mock('@/lib/uploads/core/storage-service', () => ({ })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ - assertWorkspaceFileFolderTarget: vi.fn(async () => null), + assertWorkspaceFileFolderTarget: mockAssertWorkspaceFileFolderTarget, buildWorkspaceFileFolderPathMap: vi.fn(() => new Map()), fileNameExistsInWorkspaceFolder: vi.fn(async () => false), findWorkspaceFileFolderIdByPath: vi.fn(), @@ -67,6 +75,15 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => resolveWorkspaceFileFolderTarget: mockResolveWorkspaceFileFolderTarget, })) +vi.mock('@/lib/folders/locks', () => ({ + acquireFolderMutationLock: mockAcquireFolderMutationLock, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, + resolveFolderPathFromIndex: mockResolveFolderPathFromIndex, +})) + vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceWithOwner: mockGetWorkspaceWithOwner, })) @@ -112,6 +129,7 @@ describe('workspace file metadata and storage accounting', () => { resetDbChainMock() mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT) mockResolveWorkspaceFileFolderTarget.mockResolvedValue(null) + mockAssertWorkspaceFileFolderTarget.mockResolvedValue(null) mockHasCloudStorage.mockReturnValue(false) mockHeadObject.mockResolvedValue({ size: FILE_ROW.size }) mockUploadFile.mockResolvedValue({ key: FILE_ROW.key }) @@ -164,6 +182,46 @@ describe('workspace file metadata and storage accounting', () => { expect(mockResolveWorkspaceFileFolderTarget).toHaveBeenCalledOnce() }) + it('re-resolves a canonical folder path under the tree lock before inserting metadata', async () => { + const initialIndex = { version: 'initial' } + const lockedIndex = { version: 'locked' } + const inserted = { ...FILE_ROW, folderId: 'folder-final' } + mockLoadActiveFolderPathIndex + .mockResolvedValueOnce(initialIndex) + .mockResolvedValueOnce(lockedIndex) + mockResolveFolderPathFromIndex + .mockReturnValueOnce('folder-initial') + .mockReturnValueOnce('folder-final') + dbChainMockFns.returning.mockResolvedValueOnce([inserted]) + + await uploadWorkspaceFile( + FILE_ROW.workspaceId, + FILE_ROW.userId, + Buffer.from('hello'), + FILE_ROW.originalName, + FILE_ROW.contentType, + { folderPath: '/Reports', exactName: true } + ) + + expect(mockAcquireFolderMutationLock).toHaveBeenCalledWith( + expect.any(Object), + FILE_ROW.workspaceId, + 'file' + ) + expect(mockLoadActiveFolderPathIndex).toHaveBeenNthCalledWith( + 2, + FILE_ROW.workspaceId, + 'file', + expect.any(Object) + ) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ folderId: 'folder-final' }) + ) + expect(mockAcquireFolderMutationLock.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.values.mock.invocationCallOrder[0] + ) + }) + it('cleans up a newly uploaded object when atomic metadata finalization rolls back', async () => { dbChainMockFns.returning.mockResolvedValueOnce([FILE_ROW]) mockIncrementStorageUsageForBillingContextInTx.mockRejectedValueOnce( diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts deleted file mode 100644 index bc1c12e8c73..00000000000 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { folder as folderTable } from '@sim/db/schema' -import type { FolderResourceType } from '@/lib/api/contracts/folders' -import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' -import { createFolder, deleteFolder, restoreFolder, updateFolder } from '@/lib/folders/lifecycle' -import type { FolderMutationErrorCode } from '@/lib/folders/status' - -/** - * Workflow-bound entry points into the generic folder engine in `lib/folders/lifecycle.ts`. - * - * The engine is resourceType-driven and owns the actual writes; these wrappers exist so the - * workflow callers that predate it (the folders API, the copilot workflow tools, the - * resource-restore orchestrator) keep a single, workflow-shaped signature. Everything that - * differs for workflows — the last-workflow delete guard, archiving through the workflow - * lifecycle so deployments and webhooks tear down, and restoring schedules/webhooks/chats — - * is declared as data on the `workflow` entry of `FOLDER_RESOURCES`, not branched on here. - */ - -export interface PerformCreateFolderParams { - userId: string - workspaceId: string - name: string - id?: string - parentId?: string | null - sortOrder?: number -} - -export interface PerformCreateFolderResult { - success: boolean - error?: string - errorCode?: OrchestrationErrorCode - folder?: typeof folderTable.$inferSelect -} - -export interface PerformUpdateFolderParams { - folderId: string - workspaceId: string - userId: string - name?: string - locked?: boolean - parentId?: string | null - sortOrder?: number -} - -export interface PerformUpdateFolderResult extends PerformCreateFolderResult {} - -export interface PerformDeleteFolderParams { - folderId: string - workspaceId: string - userId: string - folderName?: string -} - -export interface PerformDeleteFolderResult { - success: boolean - error?: string - errorCode?: FolderMutationErrorCode - deletedItems?: { folders: number; workflows?: number } -} - -export interface PerformRestoreFolderParams extends PerformDeleteFolderParams { - /** - * Folder tree to restore into. Defaults to `'workflow'` so every existing caller — and the - * copilot tool contract — is unchanged; Recently Deleted and the restore tool pass the - * knowledge-base or table tree explicitly. - */ - resourceType?: FolderResourceType -} - -export interface PerformRestoreFolderResult { - success: boolean - error?: string - errorCode?: OrchestrationErrorCode - restoredItems?: { folders: number; workflows?: number } -} - -export function performCreateFolder( - params: PerformCreateFolderParams -): Promise { - return createFolder({ ...params, resourceType: 'workflow' }) -} - -export function performUpdateFolder( - params: PerformUpdateFolderParams -): Promise { - return updateFolder({ ...params, resourceType: 'workflow' }) -} - -export function performDeleteFolder( - params: PerformDeleteFolderParams -): Promise { - return deleteFolder({ ...params, resourceType: 'workflow' }) -} - -export function performRestoreFolder( - params: PerformRestoreFolderParams -): Promise { - return restoreFolder({ ...params, resourceType: params.resourceType ?? 'workflow' }) -} diff --git a/apps/sim/lib/workflows/orchestration/index.ts b/apps/sim/lib/workflows/orchestration/index.ts index b47ab417e3f..dc8d99a1d9f 100644 --- a/apps/sim/lib/workflows/orchestration/index.ts +++ b/apps/sim/lib/workflows/orchestration/index.ts @@ -9,12 +9,6 @@ export { performFullUndeploy, performRevertToVersion, } from './deploy' -export { - performCreateFolder, - performDeleteFolder, - performRestoreFolder, - performUpdateFolder, -} from './folder-lifecycle' export { performCreateWorkflow, performDeleteWorkflow, diff --git a/apps/sim/lib/workspace-files/orchestration/create.ts b/apps/sim/lib/workspace-files/orchestration/create.ts index c1f70cb0d46..ab1992791bf 100644 --- a/apps/sim/lib/workspace-files/orchestration/create.ts +++ b/apps/sim/lib/workspace-files/orchestration/create.ts @@ -22,6 +22,7 @@ export interface PerformCreateWorkspaceFileParams { name: string contentType: string folderId?: string | null + folderPath?: string content?: Buffer exactName?: boolean actorName?: string @@ -53,6 +54,7 @@ export async function performCreateWorkspaceFile( name, contentType, folderId, + folderPath, content = Buffer.alloc(0), exactName = true, actorName, @@ -71,6 +73,7 @@ export async function performCreateWorkspaceFile( try { const file = await uploadWorkspaceFile(workspaceId, userId, content, name, contentType, { folderId, + folderPath, exactName, }) @@ -104,7 +107,7 @@ export async function performCreateWorkspaceFile( return { success: true, file } } catch (error) { - logger.error('Failed to create workspace file', { error, workspaceId, folderId }) + logger.error('Failed to create workspace file', { error, workspaceId, folderId, folderPath }) if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { return { success: false, error: toError(error).message, errorCode: 'conflict' } diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts index 21c8be4f630..019264ed8d1 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts @@ -16,6 +16,9 @@ const { mockRenameWorkspaceFile, mockRestoreWorkspaceFile, mockBulkArchive, + mockCreateWorkspaceFileFolderAtPath, + mockRelocateWorkspaceFileFolderByPath, + mockDeleteWorkspaceFileFolderByPath, } = vi.hoisted(() => ({ mockMoveWorkspaceFileItems: vi.fn(), mockUpdateWorkspaceFileFolder: vi.fn(), @@ -24,6 +27,9 @@ const { mockRenameWorkspaceFile: vi.fn(), mockRestoreWorkspaceFile: vi.fn(), mockBulkArchive: vi.fn(), + mockCreateWorkspaceFileFolderAtPath: vi.fn(), + mockRelocateWorkspaceFileFolderByPath: vi.fn(), + mockDeleteWorkspaceFileFolderByPath: vi.fn(), })) vi.mock('@/lib/uploads/contexts/workspace', () => ({ @@ -34,6 +40,9 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({ renameWorkspaceFile: mockRenameWorkspaceFile, restoreWorkspaceFile: mockRestoreWorkspaceFile, bulkArchiveWorkspaceFileItems: mockBulkArchive, + createWorkspaceFileFolderAtPath: mockCreateWorkspaceFileFolderAtPath, + relocateWorkspaceFileFolderByPath: mockRelocateWorkspaceFileFolderByPath, + deleteWorkspaceFileFolderByPath: mockDeleteWorkspaceFileFolderByPath, moveRenameWorkspaceFile: vi.fn(), FileConflictError: class FileConflictError extends Error {}, WorkspaceFileFolderConflictError: class WorkspaceFileFolderConflictError extends Error {}, @@ -54,7 +63,10 @@ vi.mock('@sim/audit', () => ({ import { OrchestrationError } from '@/lib/core/orchestration/types' import { performCreateWorkspaceFileFolder, + performCreateWorkspaceFileFolderAtPath, + performDeleteWorkspaceFileFolderByPath, performMoveWorkspaceFileItems, + performRelocateWorkspaceFileFolderByPath, performRenameWorkspaceFile, performRestoreWorkspaceFile, performRestoreWorkspaceFileFolder, @@ -216,4 +228,69 @@ describe('workspace file orchestration error classification', () => { expect(result.errorCode).toBe('internal') }) + + it('delegates path folder mutations to the existing file manager orchestration', async () => { + const folder = { + id: 'internal-folder-id', + workspaceId: WS, + userId: USER, + name: 'Reports', + parentId: null, + path: 'Reports', + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + } + mockCreateWorkspaceFileFolderAtPath.mockResolvedValue({ folder, path: '/Reports' }) + mockRelocateWorkspaceFileFolderByPath.mockResolvedValue({ folder, path: '/Archive' }) + mockDeleteWorkspaceFileFolderByPath.mockResolvedValue({ folders: 1, files: 2 }) + + const created = await performCreateWorkspaceFileFolderAtPath({ + workspaceId: WS, + userId: USER, + path: '/Reports', + }) + const relocated = await performRelocateWorkspaceFileFolderByPath({ + workspaceId: WS, + userId: USER, + path: '/Reports', + destinationPath: '/Archive', + }) + const deleted = await performDeleteWorkspaceFileFolderByPath({ + workspaceId: WS, + userId: USER, + path: '/Archive', + recursive: true, + }) + + expect(created).toMatchObject({ success: true, path: '/Reports' }) + expect(relocated).toMatchObject({ success: true, path: '/Archive' }) + expect(deleted).toEqual({ success: true, deletedItems: { folders: 1, files: 2 } }) + expect(mockDeleteWorkspaceFileFolderByPath).toHaveBeenCalledWith({ + workspaceId: WS, + userId: USER, + path: '/Archive', + recursive: true, + }) + }) + + it('classifies a non-empty non-recursive folder delete as a conflict', async () => { + mockDeleteWorkspaceFileFolderByPath.mockRejectedValue( + new OrchestrationError('conflict', 'Folder is not empty') + ) + + const result = await performDeleteWorkspaceFileFolderByPath({ + workspaceId: WS, + userId: USER, + path: '/Reports', + recursive: false, + }) + + expect(result).toEqual({ + success: false, + error: 'Folder is not empty', + errorCode: 'conflict', + }) + }) }) diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index 8057fb8a0c8..489ca8af9b3 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -2,13 +2,17 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getPostgresErrorCode, toError } from '@sim/utils/errors' import { asOrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { FolderPathError } from '@/lib/folders/paths' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { bulkArchiveWorkspaceFileItems, createWorkspaceFileFolder, + createWorkspaceFileFolderAtPath, + deleteWorkspaceFileFolderByPath, FileConflictError, moveRenameWorkspaceFile, moveWorkspaceFileItems, + relocateWorkspaceFileFolderByPath, renameWorkspaceFile, restoreWorkspaceFile, restoreWorkspaceFileFolder, @@ -49,6 +53,7 @@ export interface PerformMoveWorkspaceFileItemsParams { fileIds?: string[] folderIds?: string[] targetFolderId?: string | null + targetFolderPath?: string } export interface PerformMoveWorkspaceFileItemsResult { @@ -128,6 +133,112 @@ export interface PerformRestoreWorkspaceFileFolderResult { restoredItems?: WorkspaceFileArchiveResult } +export interface PerformFileFolderPathMutationResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + folder?: WorkspaceFileFolderRecord + path?: string +} + +export interface PerformDeleteFileFolderByPathResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + deletedItems?: WorkspaceFileArchiveResult +} + +function fileFolderPathError(error: unknown): { + error: string + errorCode: OrchestrationErrorCode +} { + if (error instanceof FolderPathError) { + return { error: error.message, errorCode: 'validation' } + } + if ( + error instanceof WorkspaceFileFolderConflictError || + getPostgresErrorCode(error) === '23505' + ) { + return { error: toError(error).message, errorCode: 'conflict' } + } + const classified = asOrchestrationError(error) + if (classified) return { error: classified.message, errorCode: classified.code } + return { error: toError(error).message, errorCode: 'internal' } +} + +export async function performCreateWorkspaceFileFolderAtPath(params: { + workspaceId: string + userId: string + path: string +}): Promise { + try { + const result = await createWorkspaceFileFolderAtPath(params) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceName: result.folder.name, + description: `Created file folder "${result.folder.name}"`, + metadata: { path: result.path }, + }) + await notifyWorkspaceFilesChanged(params.workspaceId) + return { success: true, folder: { ...result.folder, path: result.path }, path: result.path } + } catch (error) { + logger.error('Failed to create workspace file folder by path', { error }) + return { success: false, ...fileFolderPathError(error) } + } +} + +export async function performRelocateWorkspaceFileFolderByPath(params: { + workspaceId: string + userId: string + path: string + destinationPath: string +}): Promise { + try { + const result = await relocateWorkspaceFileFolderByPath(params) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceName: result.folder.name, + description: `Moved file folder to "${result.path}"`, + metadata: { sourcePath: params.path, destinationPath: result.path }, + }) + await notifyWorkspaceFilesChanged(params.workspaceId) + return { success: true, folder: { ...result.folder, path: result.path }, path: result.path } + } catch (error) { + logger.error('Failed to relocate workspace file folder by path', { error }) + return { success: false, ...fileFolderPathError(error) } + } +} + +export async function performDeleteWorkspaceFileFolderByPath(params: { + workspaceId: string + userId: string + path: string + recursive: boolean +}): Promise { + try { + const deletedItems = await deleteWorkspaceFileFolderByPath(params) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + description: `Deleted file folder "${params.path}"`, + metadata: { path: params.path, affected: deletedItems }, + }) + await notifyWorkspaceFilesChanged(params.workspaceId) + return { success: true, deletedItems } + } catch (error) { + logger.error('Failed to delete workspace file folder by path', { error }) + return { success: false, ...fileFolderPathError(error) } + } +} + export async function performDeleteWorkspaceFileItems( params: PerformDeleteWorkspaceFileItemsParams ): Promise { @@ -204,7 +315,14 @@ export async function performDeleteWorkspaceFileItems( export async function performMoveWorkspaceFileItems( params: PerformMoveWorkspaceFileItemsParams ): Promise { - const { workspaceId, userId, fileIds = [], folderIds = [], targetFolderId } = params + const { + workspaceId, + userId, + fileIds = [], + folderIds = [], + targetFolderId, + targetFolderPath, + } = params if (fileIds.length === 0 && folderIds.length === 0) { return { @@ -220,6 +338,7 @@ export async function performMoveWorkspaceFileItems( fileIds, folderIds, targetFolderId, + targetFolderPath, }) const movedItems = { files: moved.movedFiles, folders: moved.movedFolders } @@ -228,6 +347,7 @@ export async function performMoveWorkspaceFileItems( fileIds, folderIds, targetFolderId, + targetFolderPath, movedItems, }) @@ -237,8 +357,8 @@ export async function performMoveWorkspaceFileItems( actorId: userId, action: AuditAction.FILE_MOVED, resourceType: AuditResourceType.FILE, - description: `Moved ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}${targetFolderId ? ' to folder' : ' to root'}`, - metadata: { fileIds, targetFolderId }, + description: `Moved ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}${targetFolderId || (targetFolderPath && targetFolderPath !== '/') ? ' to folder' : ' to root'}`, + metadata: { fileIds, targetFolderId, targetFolderPath }, }) } @@ -249,8 +369,8 @@ export async function performMoveWorkspaceFileItems( action: AuditAction.FOLDER_MOVED, resourceType: AuditResourceType.FOLDER, resourceId: folderIds.length === 1 ? folderIds[0] : undefined, - description: `Moved ${folderIds.length} file folder${folderIds.length === 1 ? '' : 's'}${targetFolderId ? ' to folder' : ' to root'}`, - metadata: { folderIds, targetFolderId }, + description: `Moved ${folderIds.length} file folder${folderIds.length === 1 ? '' : 's'}${targetFolderId || (targetFolderPath && targetFolderPath !== '/') ? ' to folder' : ' to root'}`, + metadata: { folderIds, targetFolderId, targetFolderPath }, }) } diff --git a/apps/sim/lib/workspace-files/orchestration/index.ts b/apps/sim/lib/workspace-files/orchestration/index.ts index 166870b86b6..1242d5269da 100644 --- a/apps/sim/lib/workspace-files/orchestration/index.ts +++ b/apps/sim/lib/workspace-files/orchestration/index.ts @@ -13,8 +13,10 @@ export { export { type PerformCreateWorkspaceFileFolderParams, type PerformCreateWorkspaceFileFolderResult, + type PerformDeleteFileFolderByPathResult, type PerformDeleteWorkspaceFileItemsParams, type PerformDeleteWorkspaceFileItemsResult, + type PerformFileFolderPathMutationResult, type PerformMoveRenameWorkspaceFileParams, type PerformMoveRenameWorkspaceFileResult, type PerformMoveWorkspaceFileItemsParams, @@ -28,9 +30,12 @@ export { type PerformUpdateWorkspaceFileFolderParams, type PerformUpdateWorkspaceFileFolderResult, performCreateWorkspaceFileFolder, + performCreateWorkspaceFileFolderAtPath, + performDeleteWorkspaceFileFolderByPath, performDeleteWorkspaceFileItems, performMoveRenameWorkspaceFile, performMoveWorkspaceFileItems, + performRelocateWorkspaceFileFolderByPath, performRenameWorkspaceFile, performRestoreWorkspaceFile, performRestoreWorkspaceFileFolder, diff --git a/packages/testing/src/mocks/folders-lifecycle.mock.ts b/packages/testing/src/mocks/folders-lifecycle.mock.ts deleted file mode 100644 index 52a3a281144..00000000000 --- a/packages/testing/src/mocks/folders-lifecycle.mock.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { vi } from 'vitest' - -/** - * Controllable mock functions for `@/lib/folders/lifecycle` — the generic, - * resourceType-driven folder engine behind every `/api/folders` route. - * All defaults are bare `vi.fn()` — configure per-test as needed. - * - * @example - * ```ts - * import { foldersLifecycleMockFns } from '@sim/testing' - * - * foldersLifecycleMockFns.mockCreateFolder.mockResolvedValue({ success: true, folder }) - * ``` - */ -export const foldersLifecycleMockFns = { - mockCreateFolder: vi.fn(), - mockUpdateFolder: vi.fn(), - mockDeleteFolder: vi.fn(), - mockRestoreFolder: vi.fn(), -} - -/** - * Static mock module for `@/lib/folders/lifecycle`. - * - * @example - * ```ts - * vi.mock('@/lib/folders/lifecycle', () => foldersLifecycleMock) - * ``` - */ -export const foldersLifecycleMock = { - createFolder: foldersLifecycleMockFns.mockCreateFolder, - updateFolder: foldersLifecycleMockFns.mockUpdateFolder, - deleteFolder: foldersLifecycleMockFns.mockDeleteFolder, - restoreFolder: foldersLifecycleMockFns.mockRestoreFolder, -} diff --git a/packages/testing/src/mocks/folders-orchestration.mock.ts b/packages/testing/src/mocks/folders-orchestration.mock.ts new file mode 100644 index 00000000000..eec11efad4f --- /dev/null +++ b/packages/testing/src/mocks/folders-orchestration.mock.ts @@ -0,0 +1,35 @@ +import { vi } from 'vitest' + +/** + * Controllable mock functions for `@/lib/folders/orchestration` — the generic, + * resourceType-driven folder engine behind every `/api/folders` route. + * All defaults are bare `vi.fn()` — configure per-test as needed. + * + * @example + * ```ts + * import { foldersOrchestrationMockFns } from '@sim/testing' + * + * foldersOrchestrationMockFns.mockCreateFolder.mockResolvedValue({ success: true, folder }) + * ``` + */ +export const foldersOrchestrationMockFns = { + mockCreateFolder: vi.fn(), + mockUpdateFolder: vi.fn(), + mockDeleteFolder: vi.fn(), + mockRestoreFolder: vi.fn(), +} + +/** + * Static mock module for `@/lib/folders/orchestration`. + * + * @example + * ```ts + * vi.mock('@/lib/folders/orchestration', () => foldersOrchestrationMock) + * ``` + */ +export const foldersOrchestrationMock = { + createFolder: foldersOrchestrationMockFns.mockCreateFolder, + updateFolder: foldersOrchestrationMockFns.mockUpdateFolder, + deleteFolder: foldersOrchestrationMockFns.mockDeleteFolder, + restoreFolder: foldersOrchestrationMockFns.mockRestoreFolder, +} diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index 7ef622c4d61..f5d7f1c0d7d 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -96,8 +96,10 @@ export { mockNextFetchResponse, setupGlobalFetchMock, } from './fetch.mock' -// Generic folder engine mocks (for @/lib/folders/lifecycle) -export { foldersLifecycleMock, foldersLifecycleMockFns } from './folders-lifecycle.mock' +export { + foldersOrchestrationMock, + foldersOrchestrationMockFns, +} from './folders-orchestration.mock' // Hybrid auth mocks export { hybridAuthMock, hybridAuthMockFns } from './hybrid-auth.mock' // Input validation mocks From 03187ab9768c625651db2b564b8888436240649b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 23:37:55 -0700 Subject: [PATCH 068/159] feat(cli): add path-based resource directories --- packages/sim-cli/README.md | 48 +- .../commands/protocol/files-upload.test.ts | 18 +- .../src/commands/protocol/files-upload.ts | 66 +- .../sim-cli/src/commands/protocol/index.ts | 30 +- .../src/commands/protocol/resource-ls.test.ts | 127 +++ .../src/commands/protocol/resource-ls.ts | 171 ++++ .../commands/protocol/tables-import.test.ts | 21 +- .../src/commands/protocol/tables-import.ts | 8 +- packages/sim-cli/src/contract/commands.ts | 160 ++- packages/sim-cli/src/contract/types.ts | 4 + packages/sim-cli/src/generated/v2-api.ts | 948 +++++++++++------- packages/sim-cli/src/runtime/build.test.ts | 69 +- packages/sim-cli/src/runtime/build.ts | 11 +- packages/sim-cli/src/runtime/execute.ts | 14 +- packages/sim-cli/src/runtime/options.ts | 1 + packages/sim-cli/src/runtime/request.ts | 2 +- 16 files changed, 1235 insertions(+), 463 deletions(-) create mode 100644 packages/sim-cli/src/commands/protocol/resource-ls.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/resource-ls.ts diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index bfe618954ed..7f1cc5316cf 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -113,46 +113,72 @@ also accepts its singular form: for example, `sim table list`, spellings. ```bash -sim workflows list [--folder ] [--deployed] [--limit ] +sim workflows ls [--folder ] [--search ] [--limit ] +sim workflows list [--folder ] [--deployed-only] [--limit ] sim workflows get +sim workflows mv --folder sim workflows deploy|undeploy|rollback sim logs list [--level error] [--workflow …] [--trigger …] [--start ] sim logs get sim logs execution -sim tables list +sim tables ls [--folder ] [--search ] [--limit ] +sim tables list [--folder ] sim tables get +sim tables mv --folder sim tables columns sim tables rows list [--limit ] sim tables rows query [--filter ] [--sort ] [--limit ] sim tables upsert --data sim tables rows batch-delete (--row … | --filter ) --yes -sim files list -sim files create --name [--content ] [--encoding utf-8|base64] -sim files upload [--name ] [--folder-id ] +sim files ls [--folder ] [--search ] [--limit ] +sim files list [--folder
] +sim files get +sim files create --name [--folder
] [--content ] [--encoding utf-8|base64] +sim files upload [--name ] [--folder ] sim files download [-o ] -sim files move [--file-ids …] [--folder-ids …] [--target-folder-id ] -sim files batch-archive [--file-ids …] [--folder-ids …] --yes +sim files mv --file-ids … [--to ] +sim files batch-delete --file-ids … --yes sim files delete -sim knowledge list +sim knowledge ls [--folder
] [--search ] [--limit ] +sim knowledge list [--folder
] sim knowledge get +sim knowledge mv --folder
sim knowledge documents [--search ] sim knowledge documents upload [--tag ...] sim knowledge search --query --kb … [--search-mode vector|hybrid] ``` +`ls` is a directory view: it combines the resources at `--folder` with that +folder's direct child folders. Its `ref` column is the resource ID or canonical +folder path to pass to the next command. Use `list` when you want resources only, +or `folders ls` when you want folders only. + +Each folder-backed resource has the same path commands: + +```bash +sim tables folders ls --parent /Reports +sim tables folders create /Reports/Quarterly +sim tables folders mv /Reports/Quarterly /Archive/Quarterly +sim tables folders delete /Archive/Quarterly --recursive false --yes +``` + +Replace `tables` with `files`, `workflows`, or `knowledge`. Paths are canonical, +start with `/`, and use `/` for root. A slash that belongs to a folder name is +percent-encoded as `%2F` rather than treated as a separator. + ### List inputs Primitive lists take space-separated values. Prefix a path with `@` to read one value per line, or use `@-` to read the list from stdin. ```bash -sim files move --file-ids file_1 file_2 --target-folder-id folder_1 -sim files move --file-ids @file-ids.txt --target-folder-id folder_1 -printf 'file_1\nfile_2\n' | sim files move --file-ids @- --target-folder-id folder_1 +sim files mv --file-ids file_1 file_2 --to /Archive +sim files mv --file-ids @file-ids.txt --to /Archive +printf 'file_1\nfile_2\n' | sim files mv --file-ids @- --to /Archive ``` Arrays of objects remain JSON inputs because they cannot be represented as a diff --git a/packages/sim-cli/src/commands/protocol/files-upload.test.ts b/packages/sim-cli/src/commands/protocol/files-upload.test.ts index 9c09fb66207..53ec1f7cbb3 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.test.ts @@ -83,8 +83,7 @@ describe('files upload', () => { size: 5, type: 'text/plain', key: 'workspace/ws_local/notes.txt', - folderId: null, - folderPath: null, + folderPath: '/', uploadedBy: 'user_1', uploadedAt: '2026-08-04T19:00:00.000Z', updatedAt: '2026-08-04T19:00:00.000Z', @@ -96,7 +95,7 @@ describe('files upload', () => { const logged: string[] = [] vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) - await program().parseAsync(['node', 'sim', 'file', 'upload', path]) + await program().parseAsync(['node', 'sim', 'file', 'upload', path, '--folder', '/Reports']) expect(fetchMock).toHaveBeenCalledWith( 'https://storage.example/file', @@ -106,6 +105,19 @@ describe('files upload', () => { body: expect.any(Blob), }) ) + expect(mockRequest.mock.calls[0]).toEqual([ + '/api/v2/files/uploads', + { + method: 'POST', + body: { + workspaceId: 'ws_local', + name: 'notes.txt', + contentType: 'text/plain', + size: 5, + folderPath: '/Reports', + }, + }, + ]) expect(mockRequest.mock.calls[1]).toEqual([ '/api/v2/files/uploads/upload_1/complete', { diff --git a/packages/sim-cli/src/commands/protocol/files-upload.ts b/packages/sim-cli/src/commands/protocol/files-upload.ts index 1a2e5a9b337..627e35b12a5 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -12,43 +12,41 @@ export function attachFileUpload(files: Command): void { files .command('upload ') .description('Upload a file to the workspace') - .option('--folder-id ', 'Target folder (defaults to the workspace root)') + .option('--folder ', 'Canonical destination folder path (defaults to /)') .option('--name ', 'Store it under a different name') - .action( - async (path: string, options: { folderId?: string; name?: string }, command: Command) => { - const { client, profile } = clientFrom(command) - const workspaceId = client.requireWorkspace() - const { name, size } = await localFile(path, options.name) + .action(async (path: string, options: { folder?: string; name?: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const { name, size } = await localFile(path, options.name) - const created = await client.request('/api/v2/files/uploads', { - method: 'POST', - body: { - workspaceId, - name, - contentType: contentTypeFor(name), - size, - ...(options.folderId ? { folderId: options.folderId } : {}), - }, - }) - const { session, uploadToken, transfer } = created.data - const completed = await finishUploadSession( - client, + const created = await client.request('/api/v2/files/uploads', { + method: 'POST', + body: { workspaceId, - { - basePath: `/api/v2/files/uploads/${encodeURIComponent(session.id)}`, - uploadToken, - transfer, - size, - }, - path - ) - - printProtocolResult(profile.output, { - id: completed.file?.id ?? session.id, name, + contentType: contentTypeFor(name), + size, + ...(options.folder ? { folderPath: options.folder } : {}), + }, + }) + const { session, uploadToken, transfer } = created.data + const completed = await finishUploadSession( + client, + workspaceId, + { + basePath: `/api/v2/files/uploads/${encodeURIComponent(session.id)}`, + uploadToken, + transfer, size, - status: 'uploaded', - }) - } - ) + }, + path + ) + + printProtocolResult(profile.output, { + id: completed.file?.id ?? session.id, + name, + size, + status: 'uploaded', + }) + }) } diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index 5ad2647d06a..b939f0a6f74 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -2,6 +2,7 @@ import { Command } from 'commander' import { attachFileDownload } from './files-download.js' import { attachFileUpload } from './files-upload.js' import { attachKnowledgeDocumentUpload } from './knowledge-document-upload.js' +import { attachResourceList } from './resource-ls.js' import { attachTableImport } from './tables-import.js' function group(program: Command, name: string): Command { @@ -17,6 +18,31 @@ export function attachProtocolCommands(program: Command): void { const files = group(program, 'files') attachFileUpload(files) attachFileDownload(files) - attachKnowledgeDocumentUpload(group(group(program, 'knowledge'), 'documents')) - attachTableImport(group(program, 'tables')) + attachResourceList(files, { + kind: 'file', + resources: 'listFiles', + folders: 'listFileFolders', + }) + + const knowledge = group(program, 'knowledge') + attachKnowledgeDocumentUpload(group(knowledge, 'documents')) + attachResourceList(knowledge, { + kind: 'knowledge', + resources: 'listKnowledgeBases', + folders: 'listKnowledgeFolders', + }) + + const tables = group(program, 'tables') + attachTableImport(tables) + attachResourceList(tables, { + kind: 'table', + resources: 'listTables', + folders: 'listTableFolders', + }) + + attachResourceList(group(program, 'workflows'), { + kind: 'workflow', + resources: 'listWorkflows', + folders: 'listWorkflowFolders', + }) } diff --git a/packages/sim-cli/src/commands/protocol/resource-ls.test.ts b/packages/sim-cli/src/commands/protocol/resource-ls.test.ts new file mode 100644 index 00000000000..41bdc81d4cf --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/resource-ls.test.ts @@ -0,0 +1,127 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../../runtime/build.js' +import { attachProtocolCommands } from './index.js' + +const { mockRequest, output } = vi.hoisted(() => ({ + mockRequest: vi.fn(), + output: { format: 'json' }, +})) + +vi.mock('../../context.js', () => ({ + clientFrom: () => ({ + client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, + profile: { + workspaceId: 'ws_local', + output: output.format, + name: 'default', + apiKey: 'k', + endpoint: 'https://sim.example', + }, + }), +})) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachProtocolCommands(root) + return root +} + +beforeEach(() => { + vi.restoreAllMocks() + mockRequest.mockReset() + output.format = 'json' +}) + +describe('resource ls', () => { + it('is available for every folder-backed resource', () => { + for (const resource of ['files', 'knowledge', 'tables', 'workflows']) { + const group = program().commands.find((command) => command.name() === resource) + expect(group?.commands.some((command) => command.name() === 'ls')).toBe(true) + } + }) + + it('combines child folders and resources in one directory listing', async () => { + mockRequest.mockImplementation(async (path: string) => { + if (path === '/api/v2/tables/folders') { + return { + data: [ + { + name: 'Archive', + path: '/Reports/Archive', + parentPath: '/Reports', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-02T00:00:00.000Z', + }, + ], + nextCursor: null, + } + } + if (path === '/api/v2/tables') { + return { + data: [ + { + id: 'tbl_1', + name: 'Revenue', + folderPath: '/Reports', + updatedAt: '2026-08-03T00:00:00.000Z', + }, + ], + nextCursor: null, + } + } + throw new Error(`Unexpected path: ${path}`) + }) + const logged: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + await program().parseAsync([ + 'node', + 'sim', + 'table', + 'ls', + '--folder', + '/Reports', + '--search', + 'r', + ]) + + expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', { + query: { + workspaceId: 'ws_local', + parentPath: '/Reports', + search: 'r', + sortBy: 'name', + sortOrder: 'asc', + }, + }) + expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables', { + query: { + workspaceId: 'ws_local', + folderPath: '/Reports', + search: 'r', + sortBy: 'name', + sortOrder: 'asc', + limit: 100, + cursor: null, + }, + }) + expect(JSON.parse(logged[0])).toEqual([ + { + kind: 'folder', + name: 'Archive', + ref: '/Reports/Archive', + folderPath: '/Reports', + updatedAt: '2026-08-02T00:00:00.000Z', + }, + { + kind: 'table', + name: 'Revenue', + ref: 'tbl_1', + folderPath: '/Reports', + updatedAt: '2026-08-03T00:00:00.000Z', + }, + ]) + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/resource-ls.ts b/packages/sim-cli/src/commands/protocol/resource-ls.ts new file mode 100644 index 00000000000..1bae5dd23e5 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/resource-ls.ts @@ -0,0 +1,171 @@ +import { type Command, Option } from 'commander' +import { clientFrom } from '../../context.js' +import { + type ListFileFoldersResponse, + type ListFilesResponse, + type ListKnowledgeBasesResponse, + type ListKnowledgeFoldersResponse, + type ListTableFoldersResponse, + type ListTablesResponse, + type ListWorkflowFoldersResponse, + type ListWorkflowsResponse, + V2_OPERATIONS, + type V2OperationName, +} from '../../generated/v2-api.js' +import { SimApiError, type SimClient, type V2Page } from '../../http/client.js' +import { type Column, printList, text, timestamp } from '../../output/render.js' +import { DEFAULT_LIMIT } from '../../runtime/options.js' + +type FolderListOperation = + | 'listFileFolders' + | 'listKnowledgeFolders' + | 'listTableFolders' + | 'listWorkflowFolders' + +type DirectoryResource = + | ListFilesResponse['data'][number] + | ListKnowledgeBasesResponse['data'][number] + | ListTablesResponse['data'][number] + | ListWorkflowsResponse['data'][number] + +type DirectoryFolder = + | ListFileFoldersResponse['data'][number] + | ListKnowledgeFoldersResponse['data'][number] + | ListTableFoldersResponse['data'][number] + | ListWorkflowFoldersResponse['data'][number] + +interface DirectoryEntry { + kind: string + name: string + ref: string + folderPath: string + updatedAt: string +} + +type ResourceDirectoryConfig = + | { kind: 'file'; resources: 'listFiles'; folders: 'listFileFolders' } + | { + kind: 'knowledge' + resources: 'listKnowledgeBases' + folders: 'listKnowledgeFolders' + } + | { kind: 'table'; resources: 'listTables'; folders: 'listTableFolders' } + | { kind: 'workflow'; resources: 'listWorkflows'; folders: 'listWorkflowFolders' } + +interface ListOptions { + folder: string + search?: string + limit: string +} + +const COLUMNS: Column[] = [ + { header: 'kind', value: (entry) => text(entry.kind) }, + { header: 'name', value: (entry) => text(entry.name) }, + { header: 'ref', value: (entry) => text(entry.ref) }, + { header: 'folder', value: (entry) => text(entry.folderPath) }, + { header: 'updated', value: (entry) => timestamp(entry.updatedAt) }, +] + +function operationPath(operation: V2OperationName): string { + return V2_OPERATIONS[operation].path +} + +async function listResources( + client: SimClient, + config: ResourceDirectoryConfig, + workspaceId: string, + folderPath: string, + search: string | undefined, + limit: number +): Promise { + const query = { workspaceId, folderPath, search, sortBy: 'name', sortOrder: 'asc' } + const path = operationPath(config.resources) + const paginated = 'cursor' in V2_OPERATIONS[config.resources].query + + if (!paginated) { + const page = await client.request>(path, { query }) + return page.data.slice(0, limit) + } + + const resources: DirectoryResource[] = [] + let cursor: string | null = null + + do { + const remaining = limit - resources.length + const pageSize = Math.min(remaining, DEFAULT_LIMIT) + const page: V2Page = await client.request(path, { + query: { ...query, limit: pageSize, cursor }, + }) + resources.push(...page.data) + cursor = page.nextCursor + } while (cursor && resources.length < limit) + + return resources.slice(0, limit) +} + +async function listFolders( + client: SimClient, + operation: FolderListOperation, + workspaceId: string, + parentPath: string, + search: string | undefined +): Promise { + const page = await client.request>(operationPath(operation), { + query: { workspaceId, parentPath, search, sortBy: 'name', sortOrder: 'asc' }, + }) + return page.data +} + +function entriesFor( + config: ResourceDirectoryConfig, + folders: DirectoryFolder[], + resources: DirectoryResource[] +): DirectoryEntry[] { + return [ + ...folders.map((folder) => ({ + kind: 'folder', + name: folder.name, + ref: folder.path, + folderPath: folder.parentPath, + updatedAt: folder.updatedAt, + })), + ...resources.map((resource) => ({ + kind: config.kind, + name: resource.name, + ref: resource.id, + folderPath: resource.folderPath, + updatedAt: resource.updatedAt, + })), + ].sort( + (left, right) => left.name.localeCompare(right.name) || left.kind.localeCompare(right.kind) + ) +} + +export function attachResourceList(group: Command, config: ResourceDirectoryConfig): void { + group + .command('ls') + .description(`List ${config.kind} resources and child folders together`) + .option('--folder ', 'Canonical folder path to list', '/') + .option('--search ', 'Filter folders and resources by name') + .addOption( + new Option('--limit ', 'Maximum combined items to return (0 for everything)').default( + String(DEFAULT_LIMIT) + ) + ) + .action(async (options: ListOptions, command: Command) => { + const rawLimit = Number(options.limit) + if (!Number.isSafeInteger(rawLimit) || rawLimit < 0) { + throw new SimApiError('--limit must be a non-negative integer', 0) + } + + const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const [folders, resources] = await Promise.all([ + listFolders(client, config.folders, workspaceId, options.folder, options.search), + listResources(client, config, workspaceId, options.folder, options.search, limit), + ]) + const entries = entriesFor(config, folders, resources) + printList(profile.output, entries.slice(0, limit), COLUMNS) + }) +} diff --git a/packages/sim-cli/src/commands/protocol/tables-import.test.ts b/packages/sim-cli/src/commands/protocol/tables-import.test.ts index ba907338272..7dcde00bfd7 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.test.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.test.ts @@ -61,7 +61,7 @@ describe('tables import argument guards', () => { await expect(runImport(['f.csv', '--table-id', 't', '--name', 'x'])).rejects.toThrow( /--table-id already names the destination/ ) - await expect(runImport(['f.csv', '--table-id', 't', '--folder-id', 'f'])).rejects.toThrow( + await expect(runImport(['f.csv', '--table-id', 't', '--folder', '/Reports'])).rejects.toThrow( /--table-id already names the destination/ ) }) @@ -100,7 +100,24 @@ describe('tables import output', () => { const logged: string[] = [] vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) - await runImport(['--file-id', 'file_1', '--name', 'Customers', '--no-wait']) + await runImport([ + '--file-id', + 'file_1', + '--name', + 'Customers', + '--folder', + '/Reports', + '--no-wait', + ]) + + expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/imports', { + method: 'POST', + body: { + workspaceId: 'ws_local', + source: { type: 'workspace_file', fileId: 'file_1' }, + target: { type: 'new', name: 'Customers', folderPath: '/Reports' }, + }, + }) expect(JSON.parse(logged[0])).toEqual({ id: 'import_1', diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts index 4ef2e33b31d..4fa72ec4300 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -19,7 +19,7 @@ interface ImportOptions { name?: string tableId?: string mode?: string - folderId?: string + folder?: string fileId?: string mapping?: string createColumns?: string @@ -71,7 +71,7 @@ function validateTargetOptions(options: ImportOptions): boolean { const misplaced = intoExisting ? ([ ['--name', options.name], - ['--folder-id', options.folderId], + ['--folder', options.folder], ] as const) : ([ ['--mode', options.mode], @@ -106,7 +106,7 @@ export function attachTableImport(tables: Command): void { 'How to write into --table-id (default: append)' ).choices(['append', 'replace']) ) - .option('--folder-id ', 'Folder for the new table') + .option('--folder ', 'Canonical folder path for the new table') .option('--file-id ', 'Import a file already in the workspace instead of a local path') .option('--mapping ', 'Column mapping (--table-id only)') .option('--create-columns ', 'Columns to create (--table-id only)') @@ -139,7 +139,7 @@ export function attachTableImport(tables: Command): void { if (!name) { throw new SimApiError('Pass --name to say what the new table is called', 0) } - target = { type: 'new', name, ...(options.folderId ? { folderId: options.folderId } : {}) } + target = { type: 'new', name, ...(options.folder ? { folderPath: options.folder } : {}) } } const started = await client.request('/api/v2/tables/imports', { diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index c739043a1bf..dc75015f278 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -1,10 +1,20 @@ -import type { CliContract } from './types.js' +import type { CliContract, ColumnSpec } from './types.js' const TABLE_NAME_HELP = 'Identifier: letters, numbers, and underscores; cannot start with a number' const TABLE_FILTER_HELP = 'Predicate tree using all/any and operators eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, or isNotNull' const CUSTOM_TOOL_SCHEMA_HELP = 'OpenAI function schema: {"type":"function","function":{"name":"...","parameters":{"type":"object","properties":{}}}}' +const FOLDER_PATH_FLAG = { + name: 'folder', + describe: 'Canonical folder path, starting with /', +} as const +const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ + { header: 'path' }, + { header: 'name' }, + { header: 'parent', path: 'parentPath' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, +] /** * The CLI contract for the v2 surface. @@ -77,19 +87,12 @@ export const CLI_CONTRACT: CliContract = { { header: 'remaining columns', path: 'columns', format: 'count' }, ], }, - deleteFolder: { - // The route archives the folder *and cascades to its contents*, so this is - // the broadest delete on the surface — the message says so rather than - // reading like a single-item removal. - confirm: 'This archives the folder and everything inside it.', - }, - // ─── Fields whose type misdescribes their meaning ───────────────────────── // `z.string()` that the route splits on commas. No generator can infer this. listLogs: { flags: { workflowIds: { name: 'workflow', list: true }, - folderIds: { name: 'folder', list: true }, + folderPaths: { name: 'folder', list: true }, triggers: { name: 'trigger', list: true }, }, columns: [ @@ -162,35 +165,53 @@ export const CLI_CONTRACT: CliContract = { createTable: { flags: { name: { describe: TABLE_NAME_HELP }, + folderPath: FOLDER_PATH_FLAG, schema: { json: true, describe: 'Table schema: {"columns":[{"name":"email","type":"string"}]}', }, }, }, - updateTable: { flags: { name: { describe: TABLE_NAME_HELP } } }, + updateTable: { + aliases: ['mv'], + flags: { + name: { describe: TABLE_NAME_HELP }, + folderPath: FOLDER_PATH_FLAG, + }, + }, + createFile: { flags: { folderPath: FOLDER_PATH_FLAG } }, + createKnowledgeBase: { flags: { folderPath: FOLDER_PATH_FLAG } }, + updateKnowledgeBase: { aliases: ['mv'], flags: { folderPath: FOLDER_PATH_FLAG } }, + createWorkflow: { flags: { folderPath: FOLDER_PATH_FLAG } }, + updateWorkflow: { aliases: ['mv'], flags: { folderPath: FOLDER_PATH_FLAG } }, + importWorkflow: { flags: { folderPath: FOLDER_PATH_FLAG } }, createCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, updateCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, // ─── Output columns for list commands ───────────────────────────────────── listTables: { + flags: { folderPath: FOLDER_PATH_FLAG }, columns: [ { header: 'id' }, { header: 'name' }, + { header: 'folder', path: 'folderPath' }, { header: 'rows', path: 'rowCount' }, { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ], }, listWorkflows: { + flags: { folderPath: FOLDER_PATH_FLAG }, columns: [ { header: 'id' }, { header: 'name' }, + { header: 'folder', path: 'folderPath' }, { header: 'deployed', path: 'isDeployed', format: 'bool' }, { header: 'runs', path: 'runCount' }, { header: 'last run', path: 'lastRunAt', format: 'timestamp' }, ], }, listFiles: { + flags: { folderPath: FOLDER_PATH_FLAG }, columns: [ { header: 'id' }, { header: 'name' }, @@ -204,9 +225,11 @@ export const CLI_CONTRACT: CliContract = { }, listTableRows: { expand: 'data' }, listKnowledgeBases: { + flags: { folderPath: FOLDER_PATH_FLAG }, columns: [ { header: 'id' }, { header: 'name' }, + { header: 'folder', path: 'folderPath' }, { header: 'docs', path: 'docCount' }, { header: 'tokens', path: 'tokenCount' }, { header: 'model', path: 'embeddingModel' }, @@ -250,14 +273,6 @@ export const CLI_CONTRACT: CliContract = { { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ], }, - listFolders: { - columns: [ - { header: 'id' }, - { header: 'name' }, - { header: 'parent', path: 'parentId' }, - { header: 'updated', path: 'updatedAt', format: 'timestamp' }, - ], - }, listCredentials: { columns: [ { header: 'id' }, @@ -277,26 +292,30 @@ export const CLI_CONTRACT: CliContract = { }, // ─── The expanded files surface ─────────────────────────────────────────── - // Every one of these derives badly. `/files/move` and `/files/bulk-archive` + // Every one of these derives badly. `/files/move` and `/files/bulk-delete` // are verbs sitting where the deriver expects a sub-resource, so it made them // groups holding a lone `create`; and `GET /files/[id]/share` fetches one // share, which the deriver read as a collection and named `list`. - bulkArchiveFileItems: { + bulkDeleteFiles: { // `batch-` for the bulk form, matching `tables rows batch-delete`. - command: 'files batch-archive', - describe: 'Archive several files and folders at once', + command: 'files batch-delete', + describe: 'Delete several files at once', flags: { fileIds: { list: true }, - folderIds: { list: true }, }, - confirm: 'This archives every listed file and folder, and everything inside those folders.', + confirm: 'This deletes every listed file.', + }, + getFile: { + command: 'files get', + describe: 'Show file metadata', }, moveFileItems: { command: 'files move', - describe: 'Move files and folders into another folder', + aliases: ['mv'], + describe: 'Move files into another folder', flags: { fileIds: { list: true }, - folderIds: { list: true }, + targetFolderPath: { name: 'to', describe: 'Destination folder path; omit for root' }, }, }, renameFile: { @@ -304,10 +323,6 @@ export const CLI_CONTRACT: CliContract = { command: 'files rename', describe: 'Rename a file', }, - restoreFile: { - command: 'files restore', - describe: 'Restore an archived file', - }, updateFileContent: { command: 'files set-content', describe: 'Replace a file’s contents', @@ -327,9 +342,89 @@ export const CLI_CONTRACT: CliContract = { }, }, + // ─── Resource-scoped, path-addressed folders ────────────────────────────── + listFileFolders: { + aliases: ['ls'], + flags: { parentPath: { name: 'parent', describe: 'Direct parent folder path' } }, + columns: FOLDER_LIST_COLUMNS, + }, + listKnowledgeFolders: { + aliases: ['ls'], + flags: { parentPath: { name: 'parent', describe: 'Direct parent folder path' } }, + columns: FOLDER_LIST_COLUMNS, + }, + listTableFolders: { + aliases: ['ls'], + flags: { parentPath: { name: 'parent', describe: 'Direct parent folder path' } }, + columns: FOLDER_LIST_COLUMNS, + }, + listWorkflowFolders: { + aliases: ['ls'], + flags: { parentPath: { name: 'parent', describe: 'Direct parent folder path' } }, + columns: FOLDER_LIST_COLUMNS, + }, + createFileFolder: { positionals: ['path'], describe: 'Create a file folder at a path' }, + createKnowledgeFolder: { + positionals: ['path'], + describe: 'Create a knowledge folder at a path', + }, + createTableFolder: { positionals: ['path'], describe: 'Create a table folder at a path' }, + createWorkflowFolder: { + positionals: ['path'], + describe: 'Create a workflow folder at a path', + }, + relocateFileFolder: { + command: 'files folders move', + aliases: ['mv'], + positionals: ['path', 'destinationPath'], + flags: { destinationPath: { name: 'destination' } }, + describe: 'Rename or move a file folder', + }, + relocateKnowledgeFolder: { + command: 'knowledge folders move', + aliases: ['mv'], + positionals: ['path', 'destinationPath'], + flags: { destinationPath: { name: 'destination' } }, + describe: 'Rename or move a knowledge folder', + }, + relocateTableFolder: { + command: 'tables folders move', + aliases: ['mv'], + positionals: ['path', 'destinationPath'], + flags: { destinationPath: { name: 'destination' } }, + describe: 'Rename or move a table folder', + }, + relocateWorkflowFolder: { + command: 'workflows folders move', + aliases: ['mv'], + positionals: ['path', 'destinationPath'], + flags: { destinationPath: { name: 'destination' } }, + describe: 'Rename or move a workflow folder', + }, + deleteFileFolder: { + positionals: ['path'], + flags: { recursive: { choices: ['true', 'false'] } }, + confirm: 'This archives the file folder and, when recursive, everything inside it.', + }, + deleteKnowledgeFolder: { + positionals: ['path'], + flags: { recursive: { choices: ['true', 'false'] } }, + confirm: 'This archives the knowledge folder and, when recursive, everything inside it.', + }, + deleteTableFolder: { + positionals: ['path'], + flags: { recursive: { choices: ['true', 'false'] } }, + confirm: 'This archives the table folder and, when recursive, everything inside it.', + }, + deleteWorkflowFolder: { + positionals: ['path'], + flags: { recursive: { choices: ['true', 'false'] } }, + confirm: 'This archives the workflow folder and, when recursive, everything inside it.', + }, + // ─── The expanded tables surface ────────────────────────────────────────── - // `/cancel-runs`, `/rows/find`, `/restore`, `/columns/run` and the enrichment - // path all put a verb where the deriver expects a sub-resource, so each became + // `/cancel-runs`, `/rows/find`, `/columns/run` and the enrichment path all put + // a verb where the deriver expects a sub-resource, so each became // a group holding a lone `create`. cancelTableRuns: { command: 'tables cancel-runs', @@ -350,7 +445,6 @@ export const CLI_CONTRACT: CliContract = { itemsPath: 'matches', columns: [{ header: 'ordinal' }, { header: 'row', path: 'rowId' }, { header: 'column' }], }, - restoreTable: { command: 'tables restore', describe: 'Restore a deleted table' }, runTableColumn: { command: 'tables columns run', describe: 'Run a column’s workflow', diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 43dbc7cba80..6b0c857b0a0 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -76,6 +76,10 @@ export interface CommandSpec { * ` [sub-resource] ` name. */ command?: string + /** Alternate leaf command names, such as `ls` for `list`. */ + aliases?: readonly string[] + /** Required query/body fields exposed as positional arguments, in order. */ + positionals?: readonly string[] /** One-line help. Falls back to the OpenAPI summary for the operation. */ describe?: string /** Per-field flag overrides, keyed by the contract's field name. */ diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index dc9cd5ad51e..51f82deec62 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -38,8 +38,7 @@ export type AbortFileUploadResponse = { size: number type: string key: string - folderId: string | null - folderPath: string | null + folderPath: string uploadedBy: string uploadedAt: string updatedAt: string @@ -208,18 +207,16 @@ export type AddWorkflowGroupResponse = { } } -/** `POST /api/v2/files/bulk-archive` */ -export type BulkArchiveFileItemsBody = { +/** `POST /api/v2/files/bulk-delete` */ +export type BulkDeleteFilesBody = { workspaceId: string - fileIds?: Array - folderIds?: Array + fileIds: Array } -export type BulkArchiveFileItemsResponse = { +export type BulkDeleteFilesResponse = { data: { deletedItems: { files: number - folders: number } } } @@ -281,7 +278,7 @@ export type CancelTableImportResponse = { | { type: 'new' name: string - folderId?: string + folderPath?: string } | { type: 'existing' @@ -376,8 +373,7 @@ export type CompleteFileUploadResponse = { size: number type: string key: string - folderId: string | null - folderPath: string | null + folderPath: string uploadedBy: string uploadedAt: string updatedAt: string @@ -476,7 +472,7 @@ export type CompleteTableImportResponse = { | { type: 'new' name: string - folderId?: string + folderPath?: string } | { type: 'existing' @@ -577,7 +573,7 @@ export type CreateFileBody = { workspaceId: string name: string contentType?: string - folderId?: string + folderPath?: string content?: string encoding?: 'utf-8' | 'base64' } @@ -589,21 +585,38 @@ export type CreateFileResponse = { size: number type: string key: string - folderId: string | null - folderPath: string | null + folderPath: string uploadedBy: string uploadedAt: string updatedAt: string } } +/** `POST /api/v2/files/folders` */ +export type CreateFileFolderBody = { + workspaceId: string + path: string +} + +export type CreateFileFolderResponse = { + data: { + folder: { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + } + } +} + /** `POST /api/v2/files/uploads` */ export type CreateFileUploadBody = { workspaceId: string name: string contentType: string size: number - folderId?: string + folderPath?: string } export type CreateFileUploadResponse = { @@ -622,8 +635,7 @@ export type CreateFileUploadResponse = { size: number type: string key: string - folderId: string | null - folderPath: string | null + folderPath: string uploadedBy: string uploadedAt: string updatedAt: string @@ -672,31 +684,6 @@ export type CreateFileUploadPartUrlsResponse = { } } -/** `POST /api/v2/folders` */ -export type CreateFolderBody = { - workspaceId: string - resourceType: 'workflow' | 'knowledge_base' | 'table' - name: string - parentId?: string | null - sortOrder?: number -} - -export type CreateFolderResponse = { - data: { - folder: { - id: string - resourceType: 'workflow' | 'file' | 'knowledge_base' | 'table' - name: string - parentId: string | null - locked: boolean - sortOrder: number - createdAt: string - updatedAt: string - deletedAt: string | null - } - } -} - /** `POST /api/v2/knowledge` */ export type CreateKnowledgeBaseBody = { workspaceId: string @@ -707,6 +694,7 @@ export type CreateKnowledgeBaseBody = { minSize?: number overlap?: number } + folderPath?: string } export type CreateKnowledgeBaseResponse = { @@ -734,6 +722,7 @@ export type CreateKnowledgeBaseResponse = { connectorTypes?: Array createdAt: string updatedAt: string + folderPath: string } } } @@ -830,6 +819,24 @@ export type CreateKnowledgeDocumentUploadPartUrlsResponse = { } } +/** `POST /api/v2/knowledge/folders` */ +export type CreateKnowledgeFolderBody = { + workspaceId: string + path: string +} + +export type CreateKnowledgeFolderResponse = { + data: { + folder: { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + } + } +} + /** `POST /api/v2/mcp-servers` */ export type CreateMcpServerBody = { workspaceId: string @@ -916,7 +923,7 @@ export type CreateTableBody = { }> } workspaceId: string - folderId?: string | null + folderPath?: string } export type CreateTableResponse = { @@ -943,7 +950,7 @@ export type CreateTableResponse = { } rowCount: number maxRows: number - folderId: string | null + folderPath: string locks: { schemaLocked: boolean insertLocked: boolean @@ -988,6 +995,24 @@ export type CreateTableExportResponse = { } } +/** `POST /api/v2/tables/folders` */ +export type CreateTableFolderBody = { + workspaceId: string + path: string +} + +export type CreateTableFolderResponse = { + data: { + folder: { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + } + } +} + /** `POST /api/v2/tables/imports` */ export type CreateTableImportBody = { workspaceId: string @@ -1006,7 +1031,7 @@ export type CreateTableImportBody = { | { type: 'new' name: string - folderId?: string + folderPath?: string } | { type: 'existing' @@ -1042,7 +1067,7 @@ export type CreateTableImportResponse = { | { type: 'new' name: string - folderId?: string + folderPath?: string } | { type: 'existing' @@ -1089,7 +1114,7 @@ export type CreateTableImportResponse = { | { type: 'new' name: string - folderId?: string + folderPath?: string } | { type: 'existing' @@ -1289,7 +1314,7 @@ export type CreateWorkflowBody = { workspaceId: string name: string description?: string | null - folderId?: string | null + folderPath?: string } export type CreateWorkflowResponse = { @@ -1297,7 +1322,7 @@ export type CreateWorkflowResponse = { id: string name: string description: string | null - folderId: string | null + folderPath: string workspaceId: string isDeployed: boolean deployedAt: string | null @@ -1308,6 +1333,25 @@ export type CreateWorkflowResponse = { } } +/** `POST /api/v2/workflows/folders` */ +export type CreateWorkflowFolderBody = { + workspaceId: string + path: string +} + +export type CreateWorkflowFolderResponse = { + data: { + folder: { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + locked: boolean + } + } +} + /** `DELETE /api/v2/credentials/[id]` */ export type DeleteCredentialParams = { id: string @@ -1356,26 +1400,20 @@ export type DeleteFileResponse = { } } -/** `DELETE /api/v2/folders/[id]` */ -export type DeleteFolderParams = { - id: string -} - -export type DeleteFolderQuery = { +/** `DELETE /api/v2/files/folders` */ +export type DeleteFileFolderQuery = { workspaceId: string - resourceType: 'workflow' | 'knowledge_base' | 'table' + path: string + recursive: string } -export type DeleteFolderResponse = { +export type DeleteFileFolderResponse = { data: { - id: string + path: string deleted: true - deletedItems?: { + deletedItems: { folders: number - workflows?: number - files?: number - knowledgeBases?: number - tables?: number + files: number } } } @@ -1413,6 +1451,24 @@ export type DeleteKnowledgeDocumentResponse = { } } +/** `DELETE /api/v2/knowledge/folders` */ +export type DeleteKnowledgeFolderQuery = { + workspaceId: string + path: string + recursive: string +} + +export type DeleteKnowledgeFolderResponse = { + data: { + path: string + deleted: true + deletedItems: { + folders: number + knowledgeBases: number + } + } +} + /** `DELETE /api/v2/mcp-servers/[id]` */ export type DeleteMcpServerParams = { id: string @@ -1489,6 +1545,24 @@ export type DeleteTableColumnResponse = { } } +/** `DELETE /api/v2/tables/folders` */ +export type DeleteTableFolderQuery = { + workspaceId: string + path: string + recursive: string +} + +export type DeleteTableFolderResponse = { + data: { + path: string + deleted: true + deletedItems: { + folders: number + tables: number + } + } +} + /** `DELETE /api/v2/tables/[tableId]/rows/[rowId]` */ export type DeleteTableRowParams = { tableId: string @@ -1555,6 +1629,24 @@ export type DeleteWorkflowResponse = { } } +/** `DELETE /api/v2/workflows/folders` */ +export type DeleteWorkflowFolderQuery = { + workspaceId: string + path: string + recursive: string +} + +export type DeleteWorkflowFolderResponse = { + data: { + path: string + deleted: true + deletedItems: { + folders: number + workflows: number + } + } +} + /** `DELETE /api/v2/tables/[tableId]/groups` */ export type DeleteWorkflowGroupParams = { tableId: string @@ -1695,7 +1787,7 @@ export type ExportWorkflowResponse = { name: string description: string | null workspaceId: string | null - folderId: string | null + folderPath: string } state: { blocks: Record< @@ -1935,6 +2027,29 @@ export type GetExecutionResponse = { } } +/** `GET /api/v2/files/[fileId]/metadata` */ +export type GetFileParams = { + fileId: string +} + +export type GetFileQuery = { + workspaceId: string +} + +export type GetFileResponse = { + data: { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedBy: string + uploadedAt: string + updatedAt: string + } +} + /** `GET /api/v2/files/[fileId]/share` */ export type GetFileShareParams = { fileId: string @@ -1960,32 +2075,6 @@ export type GetFileShareResponse = { } } -/** `GET /api/v2/folders/[id]` */ -export type GetFolderParams = { - id: string -} - -export type GetFolderQuery = { - workspaceId: string - resourceType: 'workflow' | 'knowledge_base' | 'table' -} - -export type GetFolderResponse = { - data: { - folder: { - id: string - resourceType: 'workflow' | 'file' | 'knowledge_base' | 'table' - name: string - parentId: string | null - locked: boolean - sortOrder: number - createdAt: string - updatedAt: string - deletedAt: string | null - } - } -} - /** `GET /api/v2/knowledge/[id]` */ export type GetKnowledgeBaseParams = { id: string @@ -2020,6 +2109,7 @@ export type GetKnowledgeBaseResponse = { connectorTypes?: Array createdAt: string updatedAt: string + folderPath: string } } } @@ -2078,7 +2168,7 @@ export type GetLogResponse = { id: string | null name: string description: string | null - folderId: string | null + folderPath: string | null userId: string | null workspaceId: string | null createdAt: string | null @@ -2185,7 +2275,7 @@ export type GetTableResponse = { } rowCount: number maxRows: number - folderId: string | null + folderPath: string locks: { schemaLocked: boolean insertLocked: boolean @@ -2258,7 +2348,7 @@ export type GetTableImportResponse = { | { type: 'new' name: string - folderId?: string + folderPath?: string } | { type: 'existing' @@ -2420,7 +2510,7 @@ export type GetWorkflowResponse = { id: string name: string description: string | null - folderId: string | null + folderPath: string workspaceId: string isDeployed: boolean deployedAt: string | null @@ -2511,10 +2601,10 @@ export type GetWorkflowVersionResponse = { /** `POST /api/v2/workflows/import` */ export type ImportWorkflowBody = { workspaceId: string - folderId?: string + workflow: string | Record + folderPath?: string name?: string description?: string - workflow: string | Record } export type ImportWorkflowResponse = { @@ -2523,7 +2613,7 @@ export type ImportWorkflowResponse = { name: string description: string | null workspaceId: string - folderId: string | null + folderPath: string createdAt: string updatedAt: string } @@ -2619,55 +2709,48 @@ export type ListCustomToolsResponse = { nextCursor: string | null } -/** `GET /api/v2/files` */ -export type ListFilesQuery = { +/** `GET /api/v2/files/folders` */ +export type ListFileFoldersQuery = { workspaceId: string - scope?: 'active' | 'archived' - folderId?: string + parentPath?: string search?: string - sortBy?: 'name' | 'size' | 'uploadedAt' | 'updatedAt' + sortBy?: 'name' | 'createdAt' | 'updatedAt' sortOrder?: 'asc' | 'desc' - limit?: number - cursor?: string } -export type ListFilesResponse = { +export type ListFileFoldersResponse = { data: Array<{ - id: string name: string - size: number - type: string - key: string - folderId: string | null - folderPath: string | null - uploadedBy: string - uploadedAt: string + path: string + parentPath: string + createdAt: string updatedAt: string }> nextCursor: string | null } -/** `GET /api/v2/folders` */ -export type ListFoldersQuery = { +/** `GET /api/v2/files` */ +export type ListFilesQuery = { workspaceId: string - resourceType: 'workflow' | 'knowledge_base' | 'table' - scope?: 'active' | 'archived' + folderPath?: string search?: string - sortBy?: 'position' | 'name' | 'createdAt' | 'updatedAt' + sortBy?: 'name' | 'size' | 'uploadedAt' | 'updatedAt' sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string } -export type ListFoldersResponse = { +export type ListFilesResponse = { data: Array<{ id: string - resourceType: 'workflow' | 'file' | 'knowledge_base' | 'table' name: string - parentId: string | null - locked: boolean - sortOrder: number - createdAt: string + size: number + type: string + key: string + folderPath: string + uploadedBy: string + uploadedAt: string updatedAt: string - deletedAt: string | null }> nextCursor: string | null } @@ -2675,7 +2758,7 @@ export type ListFoldersResponse = { /** `GET /api/v2/knowledge` */ export type ListKnowledgeBasesQuery = { workspaceId: string - folderId?: string + folderPath?: string search?: string sortBy?: 'name' | 'createdAt' | 'updatedAt' sortOrder?: 'asc' | 'desc' @@ -2705,6 +2788,7 @@ export type ListKnowledgeBasesResponse = { connectorTypes?: Array createdAt: string updatedAt: string + folderPath: string }> nextCursor: string | null } @@ -2748,11 +2832,30 @@ export type ListKnowledgeDocumentsResponse = { nextCursor: string | null } +/** `GET /api/v2/knowledge/folders` */ +export type ListKnowledgeFoldersQuery = { + workspaceId: string + parentPath?: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export type ListKnowledgeFoldersResponse = { + data: Array<{ + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + /** `GET /api/v2/logs` */ export type ListLogsQuery = { workspaceId: string workflowIds?: string - folderIds?: string triggers?: string level?: 'info' | 'error' startDate?: string @@ -2769,6 +2872,7 @@ export type ListLogsQuery = { limit?: number cursor?: string order?: 'desc' | 'asc' + folderPaths?: string } export type ListLogsResponse = { @@ -2852,6 +2956,26 @@ export type ListSkillsResponse = { nextCursor: string | null } +/** `GET /api/v2/tables/folders` */ +export type ListTableFoldersQuery = { + workspaceId: string + parentPath?: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export type ListTableFoldersResponse = { + data: Array<{ + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + /** `GET /api/v2/tables/[tableId]/rows` */ export type ListTableRowsParams = { tableId: string @@ -2876,7 +3000,7 @@ export type ListTableRowsResponse = { /** `GET /api/v2/tables` */ export type ListTablesQuery = { workspaceId: string - folderId?: string + folderPath?: string search?: string sortBy?: 'name' | 'createdAt' | 'updatedAt' sortOrder?: 'asc' | 'desc' @@ -2907,7 +3031,7 @@ export type ListTablesResponse = { } rowCount: number maxRows: number - folderId: string | null + folderPath: string locks: { schemaLocked: boolean insertLocked: boolean @@ -3064,6 +3188,27 @@ export type ListUsageLogsResponse = { nextCursor: string | null } +/** `GET /api/v2/workflows/folders` */ +export type ListWorkflowFoldersQuery = { + workspaceId: string + parentPath?: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export type ListWorkflowFoldersResponse = { + data: Array<{ + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + locked: boolean + }> + nextCursor: string | null +} + /** `GET /api/v2/tables/[tableId]/groups` */ export type ListWorkflowGroupsParams = { tableId: string @@ -3102,7 +3247,7 @@ export type ListWorkflowGroupsResponse = { /** `GET /api/v2/workflows` */ export type ListWorkflowsQuery = { workspaceId: string - folderId?: string + folderPath?: string deployedOnly?: boolean limit?: number cursor?: string @@ -3116,7 +3261,7 @@ export type ListWorkflowsResponse = { id: string name: string description: string | null - folderId: string | null + folderPath: string workspaceId: string isDeployed: boolean deployedAt: string | null @@ -3155,16 +3300,14 @@ export type ListWorkflowVersionsResponse = { /** `POST /api/v2/files/move` */ export type MoveFileItemsBody = { workspaceId: string - fileIds?: Array - folderIds?: Array - targetFolderId?: string | null + fileIds: Array + targetFolderPath?: string } export type MoveFileItemsResponse = { data: { movedItems: { files: number - folders: number } } } @@ -3195,100 +3338,107 @@ export type QueryRowsResponse = { nextCursor: string | null } -/** `PATCH /api/v2/files/[fileId]` */ -export type RenameFileParams = { - fileId: string -} - -export type RenameFileBody = { +/** `PATCH /api/v2/files/folders` */ +export type RelocateFileFolderBody = { workspaceId: string - name: string + path: string + destinationPath: string } -export type RenameFileResponse = { +export type RelocateFileFolderResponse = { data: { - id: string - name: string - size: number - type: string - key: string - folderId: string | null - folderPath: string | null - uploadedBy: string - uploadedAt: string - updatedAt: string + folder: { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + } } } -/** `POST /api/v2/files/[fileId]/restore` */ -export type RestoreFileParams = { - fileId: string -} - -export type RestoreFileBody = { +/** `PATCH /api/v2/knowledge/folders` */ +export type RelocateKnowledgeFolderBody = { workspaceId: string + path: string + destinationPath: string } -export type RestoreFileResponse = { +export type RelocateKnowledgeFolderResponse = { data: { - id: string - restored: true - } -} - -/** `POST /api/v2/tables/[tableId]/restore` */ -export type RestoreTableParams = { - tableId: string + folder: { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + } + } } -export type RestoreTableBody = { +/** `PATCH /api/v2/tables/folders` */ +export type RelocateTableFolderBody = { workspaceId: string + path: string + destinationPath: string } -export type RestoreTableResponse = { +export type RelocateTableFolderResponse = { data: { - table: { - id: string + folder: { name: string - description: string | null - schema: { - columns: Array<{ - id?: string - name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' - required: boolean - unique: boolean - workflowGroupId?: string - options?: Array<{ - id: string - name: string - }> - multiple?: boolean - currencyCode?: unknown - }> - } - rowCount: number - maxRows: number - folderId: string | null - locks: { - schemaLocked: boolean - insertLocked: boolean - updateLocked: boolean - deleteLocked: boolean - } - job: { - id: string | null - type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null - status: 'running' | 'ready' | 'failed' | 'canceled' - rowsProcessed: number - error: string | null - } | null + path: string + parentPath: string createdAt: string updatedAt: string } } } +/** `PATCH /api/v2/workflows/folders` */ +export type RelocateWorkflowFolderBody = { + workspaceId: string + path: string + destinationPath: string +} + +export type RelocateWorkflowFolderResponse = { + data: { + folder: { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + locked: boolean + } + } +} + +/** `PATCH /api/v2/files/[fileId]` */ +export type RenameFileParams = { + fileId: string +} + +export type RenameFileBody = { + workspaceId: string + name: string +} + +export type RenameFileResponse = { + data: { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedBy: string + uploadedAt: string + updatedAt: string + } +} + /** `POST /api/v2/workflows/[id]/rollback` */ export type RollbackWorkflowParams = { id: string @@ -3563,44 +3713,13 @@ export type UpdateFileContentResponse = { size: number type: string key: string - folderId: string | null - folderPath: string | null + folderPath: string uploadedBy: string uploadedAt: string updatedAt: string } } -/** `PATCH /api/v2/folders/[id]` */ -export type UpdateFolderParams = { - id: string -} - -export type UpdateFolderBody = { - workspaceId: string - resourceType: 'workflow' | 'knowledge_base' | 'table' - name?: string - locked?: boolean - parentId?: string | null - sortOrder?: number -} - -export type UpdateFolderResponse = { - data: { - folder: { - id: string - resourceType: 'workflow' | 'file' | 'knowledge_base' | 'table' - name: string - parentId: string | null - locked: boolean - sortOrder: number - createdAt: string - updatedAt: string - deletedAt: string | null - } - } -} - /** `PUT /api/v2/knowledge/[id]` */ export type UpdateKnowledgeBaseParams = { id: string @@ -3611,10 +3730,11 @@ export type UpdateKnowledgeBaseBody = { name?: string description?: string chunkingConfig?: { - maxSize: number - minSize: number - overlap: number + maxSize?: number + minSize?: number + overlap?: number } + folderPath?: string } export type UpdateKnowledgeBaseResponse = { @@ -3642,6 +3762,7 @@ export type UpdateKnowledgeBaseResponse = { connectorTypes?: Array createdAt: string updatedAt: string + folderPath: string } } } @@ -3746,7 +3867,7 @@ export type UpdateTableParams = { export type UpdateTableBody = { workspaceId: string name?: string - folderId?: string | null + folderPath?: string } export type UpdateTableResponse = { @@ -3773,7 +3894,7 @@ export type UpdateTableResponse = { } rowCount: number maxRows: number - folderId: string | null + folderPath: string locks: { schemaLocked: boolean insertLocked: boolean @@ -3985,7 +4106,7 @@ export type UpdateWorkflowParams = { export type UpdateWorkflowBody = { name?: string description?: string | null - folderId?: string | null + folderPath?: string } export type UpdateWorkflowResponse = { @@ -3993,7 +4114,7 @@ export type UpdateWorkflowResponse = { id: string name: string description: string | null - folderId: string | null + folderPath: string workspaceId: string isDeployed: boolean deployedAt: string | null @@ -4220,16 +4341,15 @@ export const V2_OPERATIONS = { autoRun: { kind: 'boolean', default: false }, }, }, - bulkArchiveFileItems: { + bulkDeleteFiles: { method: 'POST', - path: '/api/v2/files/bulk-archive', + path: '/api/v2/files/bulk-delete', pathParams: [] as const, responseMode: 'json', - summary: 'Archive Files and Folders', + summary: 'Delete Files', body: { workspaceId: { kind: 'string', required: true }, - fileIds: { kind: 'array', default: [] }, - folderIds: { kind: 'array', default: [] }, + fileIds: { kind: 'array', required: true }, }, }, cancelTableExport: { @@ -4357,11 +4477,22 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, name: { kind: 'string', required: true }, contentType: { kind: 'string' }, - folderId: { kind: 'string' }, + folderPath: { kind: 'string' }, content: { kind: 'string', default: '' }, encoding: { kind: 'enum', values: ['utf-8', 'base64'] as const, default: 'utf-8' }, }, }, + createFileFolder: { + method: 'POST', + path: '/api/v2/files/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + }, + }, createFileUpload: { method: 'POST', path: '/api/v2/files/uploads', @@ -4373,7 +4504,7 @@ export const V2_OPERATIONS = { name: { kind: 'string', required: true }, contentType: { kind: 'string', required: true }, size: { kind: 'integer', required: true }, - folderId: { kind: 'string' }, + folderPath: { kind: 'string' }, }, }, createFileUploadPartUrls: { @@ -4389,24 +4520,6 @@ export const V2_OPERATIONS = { partNumbers: { kind: 'array', required: true }, }, }, - createFolder: { - method: 'POST', - path: '/api/v2/folders', - pathParams: [] as const, - responseMode: 'json', - summary: 'Create Folder', - body: { - workspaceId: { kind: 'string', required: true }, - resourceType: { - kind: 'enum', - required: true, - values: ['workflow', 'knowledge_base', 'table'] as const, - }, - name: { kind: 'string', required: true }, - parentId: { kind: 'string' }, - sortOrder: { kind: 'integer' }, - }, - }, createKnowledgeBase: { method: 'POST', path: '/api/v2/knowledge', @@ -4418,6 +4531,7 @@ export const V2_OPERATIONS = { name: { kind: 'string', required: true }, description: { kind: 'string' }, chunkingConfig: { kind: 'object', default: { maxSize: 1024, minSize: 100, overlap: 200 } }, + folderPath: { kind: 'string' }, }, }, createKnowledgeDocumentUpload: { @@ -4454,6 +4568,17 @@ export const V2_OPERATIONS = { partNumbers: { kind: 'array', required: true }, }, }, + createKnowledgeFolder: { + method: 'POST', + path: '/api/v2/knowledge/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + }, + }, createMcpServer: { method: 'POST', path: '/api/v2/mcp-servers', @@ -4499,7 +4624,7 @@ export const V2_OPERATIONS = { description: { kind: 'string' }, schema: { kind: 'object', required: true }, workspaceId: { kind: 'string', required: true }, - folderId: { kind: 'string' }, + folderPath: { kind: 'string' }, }, }, createTableExport: { @@ -4513,6 +4638,17 @@ export const V2_OPERATIONS = { format: { kind: 'enum', values: ['csv', 'json'] as const, default: 'csv' }, }, }, + createTableFolder: { + method: 'POST', + path: '/api/v2/tables/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + }, + }, createTableImport: { method: 'POST', path: '/api/v2/tables/imports', @@ -4574,7 +4710,18 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, name: { kind: 'string', required: true }, description: { kind: 'string' }, - folderId: { kind: 'string' }, + folderPath: { kind: 'string' }, + }, + }, + createWorkflowFolder: { + method: 'POST', + path: '/api/v2/workflows/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, }, }, deleteCredential: { @@ -4607,19 +4754,16 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, - deleteFolder: { + deleteFileFolder: { method: 'DELETE', - path: '/api/v2/folders/[id]', - pathParams: ['id'] as const, + path: '/api/v2/files/folders', + pathParams: [] as const, responseMode: 'json', summary: 'Delete Folder', query: { workspaceId: { kind: 'string', required: true }, - resourceType: { - kind: 'enum', - required: true, - values: ['workflow', 'knowledge_base', 'table'] as const, - }, + path: { kind: 'string', required: true }, + recursive: { kind: 'string', required: true }, }, }, deleteKnowledgeBase: { @@ -4642,6 +4786,18 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + deleteKnowledgeFolder: { + method: 'DELETE', + path: '/api/v2/knowledge/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Delete Folder', + query: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + recursive: { kind: 'string', required: true }, + }, + }, deleteMcpServer: { method: 'DELETE', path: '/api/v2/mcp-servers/[id]', @@ -4683,6 +4839,18 @@ export const V2_OPERATIONS = { columnName: { kind: 'string', required: true }, }, }, + deleteTableFolder: { + method: 'DELETE', + path: '/api/v2/tables/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Delete Folder', + query: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + recursive: { kind: 'string', required: true }, + }, + }, deleteTableRow: { method: 'DELETE', path: '/api/v2/tables/[tableId]/rows/[rowId]', @@ -4723,6 +4891,18 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Delete Workflow', }, + deleteWorkflowFolder: { + method: 'DELETE', + path: '/api/v2/workflows/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Delete Folder', + query: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + recursive: { kind: 'string', required: true }, + }, + }, deleteWorkflowGroup: { method: 'DELETE', path: '/api/v2/tables/[tableId]/groups', @@ -4822,29 +5002,24 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Execution', }, - getFileShare: { + getFile: { method: 'GET', - path: '/api/v2/files/[fileId]/share', + path: '/api/v2/files/[fileId]/metadata', pathParams: ['fileId'] as const, responseMode: 'json', - summary: 'Get File Share', + summary: 'Get File Metadata', query: { workspaceId: { kind: 'string', required: true }, }, }, - getFolder: { + getFileShare: { method: 'GET', - path: '/api/v2/folders/[id]', - pathParams: ['id'] as const, + path: '/api/v2/files/[fileId]/share', + pathParams: ['fileId'] as const, responseMode: 'json', - summary: 'Get Folder', + summary: 'Get File Share', query: { workspaceId: { kind: 'string', required: true }, - resourceType: { - kind: 'enum', - required: true, - values: ['workflow', 'knowledge_base', 'table'] as const, - }, }, }, getKnowledgeBase: { @@ -4987,10 +5162,10 @@ export const V2_OPERATIONS = { summary: 'Import a workflow', body: { workspaceId: { kind: 'string', required: true }, - folderId: { kind: 'string' }, + workflow: { kind: 'unknown', required: true }, + folderPath: { kind: 'string' }, name: { kind: 'string' }, description: { kind: 'string' }, - workflow: { kind: 'unknown', required: true }, }, }, listAuditLogs: { @@ -5051,48 +5226,42 @@ export const V2_OPERATIONS = { sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, }, }, - listFiles: { + listFileFolders: { method: 'GET', - path: '/api/v2/files', + path: '/api/v2/files/folders', pathParams: [] as const, responseMode: 'json', - summary: 'List Files', + summary: 'List Folders', query: { workspaceId: { kind: 'string', required: true }, - scope: { kind: 'enum', values: ['active', 'archived'] as const, default: 'active' }, - folderId: { kind: 'string' }, + parentPath: { kind: 'string' }, search: { kind: 'string' }, sortBy: { kind: 'enum', - values: ['name', 'size', 'uploadedAt', 'updatedAt'] as const, - default: 'uploadedAt', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'name', }, sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, - limit: { kind: 'number', default: 100 }, - cursor: { kind: 'string' }, }, }, - listFolders: { + listFiles: { method: 'GET', - path: '/api/v2/folders', + path: '/api/v2/files', pathParams: [] as const, responseMode: 'json', - summary: 'List Folders', + summary: 'List Files', query: { workspaceId: { kind: 'string', required: true }, - resourceType: { - kind: 'enum', - required: true, - values: ['workflow', 'knowledge_base', 'table'] as const, - }, - scope: { kind: 'enum', values: ['active', 'archived'] as const, default: 'active' }, + folderPath: { kind: 'string' }, search: { kind: 'string' }, sortBy: { kind: 'enum', - values: ['position', 'name', 'createdAt', 'updatedAt'] as const, - default: 'position', + values: ['name', 'size', 'uploadedAt', 'updatedAt'] as const, + default: 'uploadedAt', }, sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + limit: { kind: 'number', default: 100 }, + cursor: { kind: 'string' }, }, }, listKnowledgeBases: { @@ -5103,7 +5272,7 @@ export const V2_OPERATIONS = { summary: 'List Knowledge Bases', query: { workspaceId: { kind: 'string', required: true }, - folderId: { kind: 'string' }, + folderPath: { kind: 'string' }, search: { kind: 'string' }, sortBy: { kind: 'enum', @@ -5145,6 +5314,24 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + listKnowledgeFolders: { + method: 'GET', + path: '/api/v2/knowledge/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Folders', + query: { + workspaceId: { kind: 'string', required: true }, + parentPath: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'name', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, listLogs: { method: 'GET', path: '/api/v2/logs', @@ -5154,7 +5341,6 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, workflowIds: { kind: 'string' }, - folderIds: { kind: 'string' }, triggers: { kind: 'string' }, level: { kind: 'enum', values: ['info', 'error'] as const }, startDate: { kind: 'string' }, @@ -5171,6 +5357,7 @@ export const V2_OPERATIONS = { limit: { kind: 'number', default: 100 }, cursor: { kind: 'string' }, order: { kind: 'enum', values: ['desc', 'asc'] as const, default: 'desc' }, + folderPaths: { kind: 'string' }, }, }, listMcpServers: { @@ -5207,6 +5394,24 @@ export const V2_OPERATIONS = { sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, }, }, + listTableFolders: { + method: 'GET', + path: '/api/v2/tables/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Folders', + query: { + workspaceId: { kind: 'string', required: true }, + parentPath: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'name', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, listTableRows: { method: 'GET', path: '/api/v2/tables/[tableId]/rows', @@ -5227,7 +5432,7 @@ export const V2_OPERATIONS = { summary: 'List Tables', query: { workspaceId: { kind: 'string', required: true }, - folderId: { kind: 'string' }, + folderPath: { kind: 'string' }, search: { kind: 'string' }, sortBy: { kind: 'enum', @@ -5283,6 +5488,24 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + listWorkflowFolders: { + method: 'GET', + path: '/api/v2/workflows/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Folders', + query: { + workspaceId: { kind: 'string', required: true }, + parentPath: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'name', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, listWorkflowGroups: { method: 'GET', path: '/api/v2/tables/[tableId]/groups', @@ -5301,7 +5524,7 @@ export const V2_OPERATIONS = { summary: 'List Workflows', query: { workspaceId: { kind: 'string', required: true }, - folderId: { kind: 'string' }, + folderPath: { kind: 'string' }, deployedOnly: { kind: 'boolean' }, limit: { kind: 'number', default: 50 }, cursor: { kind: 'string' }, @@ -5333,9 +5556,8 @@ export const V2_OPERATIONS = { summary: 'Move Files and Folders', body: { workspaceId: { kind: 'string', required: true }, - fileIds: { kind: 'array', default: [] }, - folderIds: { kind: 'array', default: [] }, - targetFolderId: { kind: 'string' }, + fileIds: { kind: 'array', required: true }, + targetFolderPath: { kind: 'string' }, }, }, queryRows: { @@ -5352,35 +5574,63 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, - renameFile: { + relocateFileFolder: { method: 'PATCH', - path: '/api/v2/files/[fileId]', - pathParams: ['fileId'] as const, + path: '/api/v2/files/folders', + pathParams: [] as const, responseMode: 'json', - summary: 'Rename File', + summary: 'Rename or Move Folder', body: { workspaceId: { kind: 'string', required: true }, - name: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + destinationPath: { kind: 'string', required: true }, }, }, - restoreFile: { - method: 'POST', - path: '/api/v2/files/[fileId]/restore', - pathParams: ['fileId'] as const, + relocateKnowledgeFolder: { + method: 'PATCH', + path: '/api/v2/knowledge/folders', + pathParams: [] as const, responseMode: 'json', - summary: 'Restore File', + summary: 'Rename or Move Folder', body: { workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + destinationPath: { kind: 'string', required: true }, }, }, - restoreTable: { - method: 'POST', - path: '/api/v2/tables/[tableId]/restore', - pathParams: ['tableId'] as const, + relocateTableFolder: { + method: 'PATCH', + path: '/api/v2/tables/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Rename or Move Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + destinationPath: { kind: 'string', required: true }, + }, + }, + relocateWorkflowFolder: { + method: 'PATCH', + path: '/api/v2/workflows/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Rename or Move Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + destinationPath: { kind: 'string', required: true }, + }, + }, + renameFile: { + method: 'PATCH', + path: '/api/v2/files/[fileId]', + pathParams: ['fileId'] as const, responseMode: 'json', - summary: 'Restore Table', + summary: 'Rename File', body: { workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, }, }, rollbackWorkflow: { @@ -5494,25 +5744,6 @@ export const V2_OPERATIONS = { encoding: { kind: 'enum', values: ['utf-8', 'base64'] as const, default: 'utf-8' }, }, }, - updateFolder: { - method: 'PATCH', - path: '/api/v2/folders/[id]', - pathParams: ['id'] as const, - responseMode: 'json', - summary: 'Update Folder', - body: { - workspaceId: { kind: 'string', required: true }, - resourceType: { - kind: 'enum', - required: true, - values: ['workflow', 'knowledge_base', 'table'] as const, - }, - name: { kind: 'string' }, - locked: { kind: 'boolean' }, - parentId: { kind: 'string' }, - sortOrder: { kind: 'integer' }, - }, - }, updateKnowledgeBase: { method: 'PUT', path: '/api/v2/knowledge/[id]', @@ -5523,7 +5754,8 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, name: { kind: 'string' }, description: { kind: 'string' }, - chunkingConfig: { kind: 'object' }, + chunkingConfig: { kind: 'object', default: { maxSize: 1024, minSize: 100, overlap: 200 } }, + folderPath: { kind: 'string' }, }, }, updateMcpServer: { @@ -5582,7 +5814,7 @@ export const V2_OPERATIONS = { body: { workspaceId: { kind: 'string', required: true }, name: { kind: 'string' }, - folderId: { kind: 'string' }, + folderPath: { kind: 'string' }, }, }, updateTableColumn: { @@ -5631,7 +5863,7 @@ export const V2_OPERATIONS = { body: { name: { kind: 'string' }, description: { kind: 'string' }, - folderId: { kind: 'string' }, + folderPath: { kind: 'string' }, }, }, updateWorkflowGroup: { diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 0b76fd3e1a9..fb3ee4dc17e 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -75,7 +75,6 @@ describe('commands parsed through commander', () => { credentials: 'credential', 'custom-tools': 'custom-tool', files: 'file', - folders: 'folder', logs: 'log', 'mcp-servers': 'mcp-server', skills: 'skill', @@ -90,6 +89,7 @@ describe('commands parsed through commander', () => { ?.alias() ).toBe(alias) } + expect(program().commands.some((command) => command.name() === 'folders')).toBe(false) }) it('dispatches generated commands through their singular resource alias', async () => { @@ -155,24 +155,73 @@ describe('commands parsed through commander', () => { }) }) - it('accepts space-separated file and folder ids', async () => { + it('exposes the v2 file metadata route as files get', async () => { + const [path, options] = await run(['file', 'get', 'file_1'], { data: { id: 'file_1' } }) + expect(path).toBe('/api/v2/files/file_1/metadata') + expect(options.query).toEqual({ workspaceId: 'ws_local' }) + }) + + it('moves space-separated file ids to a folder path', async () => { const [path, options] = await run([ 'file', - 'move', + 'mv', '--file-ids', 'file_1', 'file_2', - '--folder-ids', - 'folder_1', - '--target-folder-id', - 'folder_2', + '--to', + '/Archive', ]) expect(path).toBe('/api/v2/files/move') expect(options.body).toEqual({ workspaceId: 'ws_local', fileIds: ['file_1', 'file_2'], - folderIds: ['folder_1'], - targetFolderId: 'folder_2', + targetFolderPath: '/Archive', + }) + }) + + it('uses mv as the resource move alias', async () => { + const [path, options] = await run(['table', 'mv', 'tbl_1', '--folder', '/Archive']) + expect(path).toBe('/api/v2/tables/tbl_1') + expect(options.body).toEqual({ workspaceId: 'ws_local', folderPath: '/Archive' }) + }) + + it('exposes path-addressed folder commands under each resource', async () => { + const [createPath, createOptions] = await run(['table', 'folders', 'create', '/Reports']) + expect(createPath).toBe('/api/v2/tables/folders') + expect(createOptions.body).toEqual({ workspaceId: 'ws_local', path: '/Reports' }) + + const [movePath, moveOptions] = await run([ + 'table', + 'folders', + 'mv', + '/Reports', + '/Archive/Reports', + ]) + expect(movePath).toBe('/api/v2/tables/folders') + expect(moveOptions.body).toEqual({ + workspaceId: 'ws_local', + path: '/Reports', + destinationPath: '/Archive/Reports', + }) + + const [listPath, listOptions] = await run(['table', 'folders', 'ls', '--parent', '/']) + expect(listPath).toBe('/api/v2/tables/folders') + expect(listOptions.query).toMatchObject({ workspaceId: 'ws_local', parentPath: '/' }) + + const [deletePath, deleteOptions] = await run([ + 'table', + 'folders', + 'delete', + '/Archive/Reports', + '--recursive', + 'false', + '--yes', + ]) + expect(deletePath).toBe('/api/v2/tables/folders') + expect(deleteOptions.query).toEqual({ + workspaceId: 'ws_local', + path: '/Archive/Reports', + recursive: 'false', }) }) @@ -246,7 +295,7 @@ describe('commands parsed through commander', () => { it('documents space-separated and file-backed lists', () => { const help = commandAt('files', 'move').helpInformation() expect(help).toContain('--file-ids ') - expect(help).toMatch(/space-separated.*@path.*one value per line/s) + expect(help).toMatch(/space-separated.*@path.*one\s+value\s+per\s+line/s) }) it('advertises the file-content encoding choices', () => { diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index bbcf7ae6534..6bb6210cd43 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -5,6 +5,7 @@ import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' import { deriveCommandPath } from './derive.js' import { executeOperation } from './execute.js' import { addOperationOptions } from './options.js' +import { flagNameFor } from './request.js' import type { OperationSpec } from './types.js' const GROUP_ALIASES: Readonly> = { @@ -12,7 +13,6 @@ const GROUP_ALIASES: Readonly> = { credentials: 'credential', 'custom-tools': 'custom-tool', files: 'file', - folders: 'folder', logs: 'log', 'mcp-servers': 'mcp-server', skills: 'skill', @@ -24,10 +24,19 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri const operationSpec = V2_OPERATIONS[operation] as OperationSpec const command = new Command(leafName).allowExcessArguments(false) + for (const alias of spec.aliases ?? []) command.alias(alias) + for (const param of operationSpec.pathParams) { command.argument(`<${param}>`) } + for (const field of spec.positionals ?? []) { + const descriptor = operationSpec.query?.[field] ?? operationSpec.body?.[field] + if (!descriptor) throw new Error(`${operation}.${field} is not a request field`) + if (!descriptor.required) throw new Error(`${operation}.${field} is not required`) + command.argument(`<${flagNameFor(operation, field)}>`) + } + command.description( spec.describe ?? operationSpec.summary ?? `${operationSpec.method} ${operationSpec.path}` ) diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 0454c896319..1d03ed4f64b 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -3,8 +3,9 @@ import { clientFrom } from '../context.js' import type { CommandSpec } from '../contract/types.js' import type { V2OperationName } from '../generated/v2-api.js' import { SimApiError, type V2Page } from '../http/client.js' +import { camel } from './derive.js' import { DEFAULT_LIMIT } from './options.js' -import { buildRequest, PROFILE_INJECTED_FIELD } from './request.js' +import { buildRequest, flagNameFor, PROFILE_INJECTED_FIELD } from './request.js' import { renderPage, renderResult } from './result.js' import type { OperationSpec } from './types.js' @@ -24,8 +25,13 @@ export async function executeOperation( const host = invocation[invocation.length - 1] as Command const flags = invocation[invocation.length - 2] as Record const positional = invocation.slice(0, operationSpec.pathParams.length) as string[] + const requestFlags = { ...flags } + for (const [index, field] of (commandSpec.positionals ?? []).entries()) { + requestFlags[camel(flagNameFor(operation, field))] = + invocation[operationSpec.pathParams.length + index] + } - if (commandSpec.confirm && !flags.yes) { + if (commandSpec.confirm && !requestFlags.yes) { throw new SimApiError(`${commandSpec.confirm} Re-run with --yes to confirm.`, 0) } @@ -37,13 +43,13 @@ export async function executeOperation( const request = buildRequest( operation, positional, - flags, + requestFlags, needsWorkspace ? client.requireWorkspace() : profile.workspaceId ) const paging = cursorSlot(operationSpec) if (paging) { - const rawLimit = Number.parseInt(String(flags.limit ?? DEFAULT_LIMIT), 10) + const rawLimit = Number.parseInt(String(requestFlags.limit ?? DEFAULT_LIMIT), 10) if (Number.isNaN(rawLimit) || rawLimit < 0) { throw new SimApiError('--limit must be a non-negative number', 0) } diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index 9c5ab175618..f085b0ee468 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -85,6 +85,7 @@ export function addOperationOptions( ): void { for (const slot of ['query', 'body'] as const) { for (const [field, descriptor] of Object.entries(operationSpec[slot] ?? {})) { + if (commandSpec.positionals?.includes(field)) continue addFieldOption(command, operation, field, descriptor) } } diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 7883cebd5f7..6c805040f93 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -172,7 +172,7 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: * encoding follows the field's own kind, because the two are not the same * question: * - * - `string` — the route splits on commas (`workflowIds`, `folderIds`, + * - `string` — the route splits on commas (`workflowIds`, `folderPaths`, * `triggers`), so the values are joined. * - anything else — the wire genuinely wants an array (`rowIds`, * `selectedOutputs`) or a string-or-array union whose array branch is the From d23349157a806af07063f1fd588b488114ec5f7a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 23:49:07 -0700 Subject: [PATCH 069/159] feat(cli): add resource mkdir commands --- packages/sim-cli/README.md | 8 ++-- .../sim-cli/src/commands/protocol/index.ts | 14 ++++--- ...-ls.test.ts => resource-directory.test.ts} | 27 +++++++++++- .../{resource-ls.ts => resource-directory.ts} | 41 +++++++++++++++++-- 4 files changed, 76 insertions(+), 14 deletions(-) rename packages/sim-cli/src/commands/protocol/{resource-ls.test.ts => resource-directory.test.ts} (78%) rename packages/sim-cli/src/commands/protocol/{resource-ls.ts => resource-directory.ts} (81%) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 7f1cc5316cf..6798ff6a0a4 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -161,14 +161,16 @@ Each folder-backed resource has the same path commands: ```bash sim tables folders ls --parent /Reports +sim tables mkdir /Reports/Quarterly sim tables folders create /Reports/Quarterly sim tables folders mv /Reports/Quarterly /Archive/Quarterly sim tables folders delete /Archive/Quarterly --recursive false --yes ``` -Replace `tables` with `files`, `workflows`, or `knowledge`. Paths are canonical, -start with `/`, and use `/` for root. A slash that belongs to a folder name is -percent-encoded as `%2F` rather than treated as a separator. +`mkdir` is the concise form of `folders create`. Replace `tables` with `files`, +`workflows`, or `knowledge`. Paths are canonical, start with `/`, and use `/` for +root. A slash that belongs to a folder name is percent-encoded as `%2F` rather +than treated as a separator. ### List inputs diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index b939f0a6f74..67df42a865b 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -2,7 +2,7 @@ import { Command } from 'commander' import { attachFileDownload } from './files-download.js' import { attachFileUpload } from './files-upload.js' import { attachKnowledgeDocumentUpload } from './knowledge-document-upload.js' -import { attachResourceList } from './resource-ls.js' +import { attachResourceDirectoryCommands } from './resource-directory.js' import { attachTableImport } from './tables-import.js' function group(program: Command, name: string): Command { @@ -18,31 +18,35 @@ export function attachProtocolCommands(program: Command): void { const files = group(program, 'files') attachFileUpload(files) attachFileDownload(files) - attachResourceList(files, { + attachResourceDirectoryCommands(files, { kind: 'file', resources: 'listFiles', folders: 'listFileFolders', + createFolder: 'createFileFolder', }) const knowledge = group(program, 'knowledge') attachKnowledgeDocumentUpload(group(knowledge, 'documents')) - attachResourceList(knowledge, { + attachResourceDirectoryCommands(knowledge, { kind: 'knowledge', resources: 'listKnowledgeBases', folders: 'listKnowledgeFolders', + createFolder: 'createKnowledgeFolder', }) const tables = group(program, 'tables') attachTableImport(tables) - attachResourceList(tables, { + attachResourceDirectoryCommands(tables, { kind: 'table', resources: 'listTables', folders: 'listTableFolders', + createFolder: 'createTableFolder', }) - attachResourceList(group(program, 'workflows'), { + attachResourceDirectoryCommands(group(program, 'workflows'), { kind: 'workflow', resources: 'listWorkflows', folders: 'listWorkflowFolders', + createFolder: 'createWorkflowFolder', }) } diff --git a/packages/sim-cli/src/commands/protocol/resource-ls.test.ts b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts similarity index 78% rename from packages/sim-cli/src/commands/protocol/resource-ls.test.ts rename to packages/sim-cli/src/commands/protocol/resource-directory.test.ts index 41bdc81d4cf..18a62787ef4 100644 --- a/packages/sim-cli/src/commands/protocol/resource-ls.test.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts @@ -34,11 +34,12 @@ beforeEach(() => { output.format = 'json' }) -describe('resource ls', () => { - it('is available for every folder-backed resource', () => { +describe('resource directory', () => { + it('makes ls and mkdir available for every folder-backed resource', () => { for (const resource of ['files', 'knowledge', 'tables', 'workflows']) { const group = program().commands.find((command) => command.name() === resource) expect(group?.commands.some((command) => command.name() === 'ls')).toBe(true) + expect(group?.commands.some((command) => command.name() === 'mkdir')).toBe(true) } }) @@ -124,4 +125,26 @@ describe('resource ls', () => { }, ]) }) + + it('creates a folder through the generated resource operation', async () => { + mockRequest.mockResolvedValue({ + data: { + folder: { + name: 'Quarterly', + path: '/Reports/Quarterly', + parentPath: '/Reports', + createdAt: '2026-08-04T00:00:00.000Z', + updatedAt: '2026-08-04T00:00:00.000Z', + }, + }, + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program().parseAsync(['node', 'sim', 'table', 'mkdir', '/Reports/Quarterly']) + + expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', { + method: 'POST', + body: { workspaceId: 'ws_local', path: '/Reports/Quarterly' }, + }) + }) }) diff --git a/packages/sim-cli/src/commands/protocol/resource-ls.ts b/packages/sim-cli/src/commands/protocol/resource-directory.ts similarity index 81% rename from packages/sim-cli/src/commands/protocol/resource-ls.ts rename to packages/sim-cli/src/commands/protocol/resource-directory.ts index 1bae5dd23e5..c842d88d460 100644 --- a/packages/sim-cli/src/commands/protocol/resource-ls.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.ts @@ -15,6 +15,7 @@ import { import { SimApiError, type SimClient, type V2Page } from '../../http/client.js' import { type Column, printList, text, timestamp } from '../../output/render.js' import { DEFAULT_LIMIT } from '../../runtime/options.js' +import { renderResult } from '../../runtime/result.js' type FolderListOperation = | 'listFileFolders' @@ -43,14 +44,30 @@ interface DirectoryEntry { } type ResourceDirectoryConfig = - | { kind: 'file'; resources: 'listFiles'; folders: 'listFileFolders' } + | { + kind: 'file' + resources: 'listFiles' + folders: 'listFileFolders' + createFolder: 'createFileFolder' + } | { kind: 'knowledge' resources: 'listKnowledgeBases' folders: 'listKnowledgeFolders' + createFolder: 'createKnowledgeFolder' + } + | { + kind: 'table' + resources: 'listTables' + folders: 'listTableFolders' + createFolder: 'createTableFolder' + } + | { + kind: 'workflow' + resources: 'listWorkflows' + folders: 'listWorkflowFolders' + createFolder: 'createWorkflowFolder' } - | { kind: 'table'; resources: 'listTables'; folders: 'listTableFolders' } - | { kind: 'workflow'; resources: 'listWorkflows'; folders: 'listWorkflowFolders' } interface ListOptions { folder: string @@ -141,7 +158,10 @@ function entriesFor( ) } -export function attachResourceList(group: Command, config: ResourceDirectoryConfig): void { +export function attachResourceDirectoryCommands( + group: Command, + config: ResourceDirectoryConfig +): void { group .command('ls') .description(`List ${config.kind} resources and child folders together`) @@ -168,4 +188,17 @@ export function attachResourceList(group: Command, config: ResourceDirectoryConf const entries = entriesFor(config, folders, resources) printList(profile.output, entries.slice(0, limit), COLUMNS) }) + + group + .command('mkdir ') + .description(`Create a ${config.kind} directory at a canonical path`) + .action(async (path: string, _options: Record, command: Command) => { + const { client, profile } = clientFrom(command) + const operation = V2_OPERATIONS[config.createFolder] + const result = await client.request<{ data?: unknown }>(operation.path, { + method: operation.method, + body: { workspaceId: client.requireWorkspace(), path }, + }) + renderResult(config.createFolder, profile.output, result.data ?? result, {}) + }) } From 936ac603bcd9b5ff9ff543d8e33f1be462384f3e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 23:54:30 -0700 Subject: [PATCH 070/159] fix(cli): accept positional folder paths --- packages/sim-cli/README.md | 61 ++++++++-------- .../commands/protocol/files-upload.test.ts | 2 +- .../src/commands/protocol/files-upload.ts | 5 +- .../protocol/resource-directory.test.ts | 25 ++++--- .../commands/protocol/resource-directory.ts | 16 +++-- .../commands/protocol/tables-import.test.ts | 2 +- .../src/commands/protocol/tables-import.ts | 9 ++- packages/sim-cli/src/contract/commands.ts | 72 ++++++++++++++----- packages/sim-cli/src/contract/types.ts | 2 + packages/sim-cli/src/runtime/build.test.ts | 16 ++--- packages/sim-cli/src/runtime/folder-path.ts | 7 ++ packages/sim-cli/src/runtime/request.ts | 6 +- 12 files changed, 145 insertions(+), 78 deletions(-) create mode 100644 packages/sim-cli/src/runtime/folder-path.ts diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 6798ff6a0a4..d5fbcc96b0f 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -113,64 +113,67 @@ also accepts its singular form: for example, `sim table list`, spellings. ```bash -sim workflows ls [--folder ] [--search ] [--limit ] -sim workflows list [--folder ] [--deployed-only] [--limit ] +sim workflows ls [path] [--search ] [--limit ] +sim workflows list [--folder ] [--deployed-only] [--limit ] sim workflows get -sim workflows mv --folder +sim workflows mv --folder sim workflows deploy|undeploy|rollback sim logs list [--level error] [--workflow …] [--trigger …] [--start ] sim logs get sim logs execution -sim tables ls [--folder ] [--search ] [--limit ] -sim tables list [--folder ] +sim tables ls [path] [--search ] [--limit ] +sim tables list [--folder ] sim tables get -sim tables mv --folder +sim tables mv --folder sim tables columns sim tables rows list [--limit ] sim tables rows query [--filter ] [--sort ] [--limit ] sim tables upsert --data sim tables rows batch-delete (--row … | --filter ) --yes -sim files ls [--folder ] [--search ] [--limit ] -sim files list [--folder ] +sim files ls [path] [--search ] [--limit ] +sim files list [--folder ] sim files get -sim files create --name [--folder ] [--content ] [--encoding utf-8|base64] -sim files upload [--name ] [--folder ] +sim files create --name [--folder ] [--content ] [--encoding utf-8|base64] +sim files upload [--name ] [--folder ] sim files download [-o ] -sim files mv --file-ids … [--to ] +sim files mv --file-ids … [--to ] sim files batch-delete --file-ids … --yes sim files delete -sim knowledge ls [--folder ] [--search ] [--limit ] -sim knowledge list [--folder ] +sim knowledge ls [path] [--search ] [--limit ] +sim knowledge list [--folder ] sim knowledge get -sim knowledge mv --folder +sim knowledge mv --folder sim knowledge documents [--search ] sim knowledge documents upload [--tag ...] sim knowledge search --query --kb … [--search-mode vector|hybrid] ``` -`ls` is a directory view: it combines the resources at `--folder` with that -folder's direct child folders. Its `ref` column is the resource ID or canonical -folder path to pass to the next command. Use `list` when you want resources only, -or `folders ls` when you want folders only. +`ls` is a directory view: it combines the resources at its optional path with +that folder's direct child folders. It never includes deeper descendants. Its +`ref` column is the resource ID or canonical folder path to pass to the next +command. Use `list` when you want resources only, or `folders ls` when you want +folders only. Each folder-backed resource has the same path commands: ```bash -sim tables folders ls --parent /Reports -sim tables mkdir /Reports/Quarterly -sim tables folders create /Reports/Quarterly -sim tables folders mv /Reports/Quarterly /Archive/Quarterly -sim tables folders delete /Archive/Quarterly --recursive false --yes +sim tables ls Reports +sim tables folders ls --parent Reports +sim tables mkdir Reports/Quarterly +sim tables folders create Reports/Quarterly +sim tables folders mv Reports/Quarterly Archive/Quarterly +sim tables folders delete Archive/Quarterly --recursive false --yes ``` `mkdir` is the concise form of `folders create`. Replace `tables` with `files`, -`workflows`, or `knowledge`. Paths are canonical, start with `/`, and use `/` for -root. A slash that belongs to a folder name is percent-encoded as `%2F` rather -than treated as a separator. +`workflows`, or `knowledge`. The leading `/` is optional on CLI inputs; the CLI +adds it before calling the API. Omit the `ls` path to list root. A slash that +belongs to a folder name is percent-encoded as `%2F` rather than treated as a +separator. ### List inputs @@ -178,9 +181,9 @@ Primitive lists take space-separated values. Prefix a path with `@` to read one value per line, or use `@-` to read the list from stdin. ```bash -sim files mv --file-ids file_1 file_2 --to /Archive -sim files mv --file-ids @file-ids.txt --to /Archive -printf 'file_1\nfile_2\n' | sim files mv --file-ids @- --to /Archive +sim files mv --file-ids file_1 file_2 --to Archive +sim files mv --file-ids @file-ids.txt --to Archive +printf 'file_1\nfile_2\n' | sim files mv --file-ids @- --to Archive ``` Arrays of objects remain JSON inputs because they cannot be represented as a diff --git a/packages/sim-cli/src/commands/protocol/files-upload.test.ts b/packages/sim-cli/src/commands/protocol/files-upload.test.ts index 53ec1f7cbb3..8ddffe560a4 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.test.ts @@ -95,7 +95,7 @@ describe('files upload', () => { const logged: string[] = [] vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) - await program().parseAsync(['node', 'sim', 'file', 'upload', path, '--folder', '/Reports']) + await program().parseAsync(['node', 'sim', 'file', 'upload', path, '--folder', 'Reports']) expect(fetchMock).toHaveBeenCalledWith( 'https://storage.example/file', diff --git a/packages/sim-cli/src/commands/protocol/files-upload.ts b/packages/sim-cli/src/commands/protocol/files-upload.ts index 627e35b12a5..e8837c57c09 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -4,6 +4,7 @@ import type { CompleteFileUploadResponse, CreateFileUploadResponse, } from '../../generated/v2-api.js' +import { normalizeFolderPath } from '../../runtime/folder-path.js' import { contentTypeFor, localFile } from '../../transfer/local-file.js' import { finishUploadSession } from '../../transfer/upload-session.js' import { printProtocolResult } from './result.js' @@ -26,7 +27,9 @@ export function attachFileUpload(files: Command): void { name, contentType: contentTypeFor(name), size, - ...(options.folder ? { folderPath: options.folder } : {}), + ...(options.folder !== undefined + ? { folderPath: normalizeFolderPath(options.folder) } + : {}), }, }) const { session, uploadToken, transfer } = created.data diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts index 18a62787ef4..9e2914428da 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts @@ -25,6 +25,11 @@ function program(): Command { const root = new Command('sim').exitOverride() for (const group of buildGeneratedCommands()) root.addCommand(group) attachProtocolCommands(root) + const override = (command: Command) => { + command.exitOverride() + command.commands.forEach(override) + } + override(root) return root } @@ -77,16 +82,7 @@ describe('resource directory', () => { const logged: string[] = [] vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) - await program().parseAsync([ - 'node', - 'sim', - 'table', - 'ls', - '--folder', - '/Reports', - '--search', - 'r', - ]) + await program().parseAsync(['node', 'sim', 'table', 'ls', 'Reports', '--search', 'r']) expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', { query: { @@ -140,11 +136,18 @@ describe('resource directory', () => { }) vi.spyOn(console, 'log').mockImplementation(() => {}) - await program().parseAsync(['node', 'sim', 'table', 'mkdir', '/Reports/Quarterly']) + await program().parseAsync(['node', 'sim', 'table', 'mkdir', 'Reports/Quarterly']) expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', { method: 'POST', body: { workspaceId: 'ws_local', path: '/Reports/Quarterly' }, }) }) + + it('rejects extra directory arguments instead of silently ignoring them', async () => { + await expect( + program().parseAsync(['node', 'sim', 'file', 'ls', 'Reports', 'ignored']) + ).rejects.toThrow(/too many arguments/i) + expect(mockRequest).not.toHaveBeenCalled() + }) }) diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.ts b/packages/sim-cli/src/commands/protocol/resource-directory.ts index c842d88d460..846b9bd71fd 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.ts @@ -14,6 +14,7 @@ import { } from '../../generated/v2-api.js' import { SimApiError, type SimClient, type V2Page } from '../../http/client.js' import { type Column, printList, text, timestamp } from '../../output/render.js' +import { normalizeFolderPath } from '../../runtime/folder-path.js' import { DEFAULT_LIMIT } from '../../runtime/options.js' import { renderResult } from '../../runtime/result.js' @@ -70,7 +71,6 @@ type ResourceDirectoryConfig = } interface ListOptions { - folder: string search?: string limit: string } @@ -163,27 +163,28 @@ export function attachResourceDirectoryCommands( config: ResourceDirectoryConfig ): void { group - .command('ls') + .command('ls [path]') + .allowExcessArguments(false) .description(`List ${config.kind} resources and child folders together`) - .option('--folder ', 'Canonical folder path to list', '/') .option('--search ', 'Filter folders and resources by name') .addOption( new Option('--limit ', 'Maximum combined items to return (0 for everything)').default( String(DEFAULT_LIMIT) ) ) - .action(async (options: ListOptions, command: Command) => { + .action(async (path: string | undefined, options: ListOptions, command: Command) => { const rawLimit = Number(options.limit) if (!Number.isSafeInteger(rawLimit) || rawLimit < 0) { throw new SimApiError('--limit must be a non-negative integer', 0) } const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit + const folderPath = normalizeFolderPath(path ?? '/') const { client, profile } = clientFrom(command) const workspaceId = client.requireWorkspace() const [folders, resources] = await Promise.all([ - listFolders(client, config.folders, workspaceId, options.folder, options.search), - listResources(client, config, workspaceId, options.folder, options.search, limit), + listFolders(client, config.folders, workspaceId, folderPath, options.search), + listResources(client, config, workspaceId, folderPath, options.search, limit), ]) const entries = entriesFor(config, folders, resources) printList(profile.output, entries.slice(0, limit), COLUMNS) @@ -191,13 +192,14 @@ export function attachResourceDirectoryCommands( group .command('mkdir ') + .allowExcessArguments(false) .description(`Create a ${config.kind} directory at a canonical path`) .action(async (path: string, _options: Record, command: Command) => { const { client, profile } = clientFrom(command) const operation = V2_OPERATIONS[config.createFolder] const result = await client.request<{ data?: unknown }>(operation.path, { method: operation.method, - body: { workspaceId: client.requireWorkspace(), path }, + body: { workspaceId: client.requireWorkspace(), path: normalizeFolderPath(path) }, }) renderResult(config.createFolder, profile.output, result.data ?? result, {}) }) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.test.ts b/packages/sim-cli/src/commands/protocol/tables-import.test.ts index 7dcde00bfd7..a6b5a063e16 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.test.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.test.ts @@ -106,7 +106,7 @@ describe('tables import output', () => { '--name', 'Customers', '--folder', - '/Reports', + 'Reports', '--no-wait', ]) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts index 4fa72ec4300..1391176720b 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -8,6 +8,7 @@ import type { GetTableImportResponse, } from '../../generated/v2-api.js' import { SimApiError, type SimClient } from '../../http/client.js' +import { normalizeFolderPath } from '../../runtime/folder-path.js' import { coerce, type FieldSpec } from '../../runtime/request.js' import { contentTypeFor, localFile } from '../../transfer/local-file.js' import { finishUploadSession } from '../../transfer/upload-session.js' @@ -139,7 +140,13 @@ export function attachTableImport(tables: Command): void { if (!name) { throw new SimApiError('Pass --name to say what the new table is called', 0) } - target = { type: 'new', name, ...(options.folder ? { folderPath: options.folder } : {}) } + target = { + type: 'new', + name, + ...(options.folder !== undefined + ? { folderPath: normalizeFolderPath(options.folder) } + : {}), + } } const started = await client.request('/api/v2/tables/imports', { diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index dc75015f278..c12f257bf3c 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -5,9 +5,13 @@ const TABLE_FILTER_HELP = 'Predicate tree using all/any and operators eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, or isNotNull' const CUSTOM_TOOL_SCHEMA_HELP = 'OpenAI function schema: {"type":"function","function":{"name":"...","parameters":{"type":"object","properties":{}}}}' +const FOLDER_PATH_INPUT = { + normalize: 'folder-path', + describe: 'Folder path; the leading / is optional', +} as const const FOLDER_PATH_FLAG = { + ...FOLDER_PATH_INPUT, name: 'folder', - describe: 'Canonical folder path, starting with /', } as const const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ { header: 'path' }, @@ -92,7 +96,7 @@ export const CLI_CONTRACT: CliContract = { listLogs: { flags: { workflowIds: { name: 'workflow', list: true }, - folderPaths: { name: 'folder', list: true }, + folderPaths: { ...FOLDER_PATH_FLAG, list: true }, triggers: { name: 'trigger', list: true }, }, columns: [ @@ -315,7 +319,11 @@ export const CLI_CONTRACT: CliContract = { describe: 'Move files into another folder', flags: { fileIds: { list: true }, - targetFolderPath: { name: 'to', describe: 'Destination folder path; omit for root' }, + targetFolderPath: { + ...FOLDER_PATH_INPUT, + name: 'to', + describe: 'Destination folder path; omit for root', + }, }, }, renameFile: { @@ -345,80 +353,110 @@ export const CLI_CONTRACT: CliContract = { // ─── Resource-scoped, path-addressed folders ────────────────────────────── listFileFolders: { aliases: ['ls'], - flags: { parentPath: { name: 'parent', describe: 'Direct parent folder path' } }, + flags: { + parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, + }, columns: FOLDER_LIST_COLUMNS, }, listKnowledgeFolders: { aliases: ['ls'], - flags: { parentPath: { name: 'parent', describe: 'Direct parent folder path' } }, + flags: { + parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, + }, columns: FOLDER_LIST_COLUMNS, }, listTableFolders: { aliases: ['ls'], - flags: { parentPath: { name: 'parent', describe: 'Direct parent folder path' } }, + flags: { + parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, + }, columns: FOLDER_LIST_COLUMNS, }, listWorkflowFolders: { aliases: ['ls'], - flags: { parentPath: { name: 'parent', describe: 'Direct parent folder path' } }, + flags: { + parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, + }, columns: FOLDER_LIST_COLUMNS, }, - createFileFolder: { positionals: ['path'], describe: 'Create a file folder at a path' }, + createFileFolder: { + positionals: ['path'], + flags: { path: FOLDER_PATH_INPUT }, + describe: 'Create a file folder at a path', + }, createKnowledgeFolder: { positionals: ['path'], + flags: { path: FOLDER_PATH_INPUT }, describe: 'Create a knowledge folder at a path', }, - createTableFolder: { positionals: ['path'], describe: 'Create a table folder at a path' }, + createTableFolder: { + positionals: ['path'], + flags: { path: FOLDER_PATH_INPUT }, + describe: 'Create a table folder at a path', + }, createWorkflowFolder: { positionals: ['path'], + flags: { path: FOLDER_PATH_INPUT }, describe: 'Create a workflow folder at a path', }, relocateFileFolder: { command: 'files folders move', aliases: ['mv'], positionals: ['path', 'destinationPath'], - flags: { destinationPath: { name: 'destination' } }, + flags: { + path: FOLDER_PATH_INPUT, + destinationPath: { ...FOLDER_PATH_INPUT, name: 'destination' }, + }, describe: 'Rename or move a file folder', }, relocateKnowledgeFolder: { command: 'knowledge folders move', aliases: ['mv'], positionals: ['path', 'destinationPath'], - flags: { destinationPath: { name: 'destination' } }, + flags: { + path: FOLDER_PATH_INPUT, + destinationPath: { ...FOLDER_PATH_INPUT, name: 'destination' }, + }, describe: 'Rename or move a knowledge folder', }, relocateTableFolder: { command: 'tables folders move', aliases: ['mv'], positionals: ['path', 'destinationPath'], - flags: { destinationPath: { name: 'destination' } }, + flags: { + path: FOLDER_PATH_INPUT, + destinationPath: { ...FOLDER_PATH_INPUT, name: 'destination' }, + }, describe: 'Rename or move a table folder', }, relocateWorkflowFolder: { command: 'workflows folders move', aliases: ['mv'], positionals: ['path', 'destinationPath'], - flags: { destinationPath: { name: 'destination' } }, + flags: { + path: FOLDER_PATH_INPUT, + destinationPath: { ...FOLDER_PATH_INPUT, name: 'destination' }, + }, describe: 'Rename or move a workflow folder', }, deleteFileFolder: { positionals: ['path'], - flags: { recursive: { choices: ['true', 'false'] } }, + flags: { path: FOLDER_PATH_INPUT, recursive: { choices: ['true', 'false'] } }, confirm: 'This archives the file folder and, when recursive, everything inside it.', }, deleteKnowledgeFolder: { positionals: ['path'], - flags: { recursive: { choices: ['true', 'false'] } }, + flags: { path: FOLDER_PATH_INPUT, recursive: { choices: ['true', 'false'] } }, confirm: 'This archives the knowledge folder and, when recursive, everything inside it.', }, deleteTableFolder: { positionals: ['path'], - flags: { recursive: { choices: ['true', 'false'] } }, + flags: { path: FOLDER_PATH_INPUT, recursive: { choices: ['true', 'false'] } }, confirm: 'This archives the table folder and, when recursive, everything inside it.', }, deleteWorkflowFolder: { positionals: ['path'], - flags: { recursive: { choices: ['true', 'false'] } }, + flags: { path: FOLDER_PATH_INPUT, recursive: { choices: ['true', 'false'] } }, confirm: 'This archives the workflow folder and, when recursive, everything inside it.', }, diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 6b0c857b0a0..bdaac5bb6f8 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -49,6 +49,8 @@ export interface FlagSpec { describe?: string /** Accepted values when the generated descriptor cannot recover an enum. */ choices?: readonly string[] + /** Normalizes a terminal-friendly value into its API wire representation. */ + normalize?: 'folder-path' /** * Never expose this field as a flag, and never send it. * diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index fb3ee4dc17e..c8cb1452f5a 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -169,7 +169,7 @@ describe('commands parsed through commander', () => { 'file_1', 'file_2', '--to', - '/Archive', + 'Archive', ]) expect(path).toBe('/api/v2/files/move') expect(options.body).toEqual({ @@ -180,13 +180,13 @@ describe('commands parsed through commander', () => { }) it('uses mv as the resource move alias', async () => { - const [path, options] = await run(['table', 'mv', 'tbl_1', '--folder', '/Archive']) + const [path, options] = await run(['table', 'mv', 'tbl_1', '--folder', 'Archive']) expect(path).toBe('/api/v2/tables/tbl_1') expect(options.body).toEqual({ workspaceId: 'ws_local', folderPath: '/Archive' }) }) it('exposes path-addressed folder commands under each resource', async () => { - const [createPath, createOptions] = await run(['table', 'folders', 'create', '/Reports']) + const [createPath, createOptions] = await run(['table', 'folders', 'create', 'Reports']) expect(createPath).toBe('/api/v2/tables/folders') expect(createOptions.body).toEqual({ workspaceId: 'ws_local', path: '/Reports' }) @@ -194,8 +194,8 @@ describe('commands parsed through commander', () => { 'table', 'folders', 'mv', - '/Reports', - '/Archive/Reports', + 'Reports', + 'Archive/Reports', ]) expect(movePath).toBe('/api/v2/tables/folders') expect(moveOptions.body).toEqual({ @@ -204,15 +204,15 @@ describe('commands parsed through commander', () => { destinationPath: '/Archive/Reports', }) - const [listPath, listOptions] = await run(['table', 'folders', 'ls', '--parent', '/']) + const [listPath, listOptions] = await run(['table', 'folders', 'ls', '--parent', 'Reports']) expect(listPath).toBe('/api/v2/tables/folders') - expect(listOptions.query).toMatchObject({ workspaceId: 'ws_local', parentPath: '/' }) + expect(listOptions.query).toMatchObject({ workspaceId: 'ws_local', parentPath: '/Reports' }) const [deletePath, deleteOptions] = await run([ 'table', 'folders', 'delete', - '/Archive/Reports', + 'Archive/Reports', '--recursive', 'false', '--yes', diff --git a/packages/sim-cli/src/runtime/folder-path.ts b/packages/sim-cli/src/runtime/folder-path.ts new file mode 100644 index 00000000000..a89433c2ac1 --- /dev/null +++ b/packages/sim-cli/src/runtime/folder-path.ts @@ -0,0 +1,7 @@ +import { SimApiError } from '../http/client.js' + +/** Accepts root-relative folder input while preserving already-canonical paths. */ +export function normalizeFolderPath(path: string): string { + if (!path) throw new SimApiError('Folder path cannot be empty', 0) + return path.startsWith('/') ? path : `/${path}` +} diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 6c805040f93..145cba64750 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -4,6 +4,7 @@ import type { FlagSpec } from '../contract/types.js' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' import { type QueryValue, SimApiError } from '../http/client.js' import { camel, kebab } from './derive.js' +import { normalizeFolderPath } from './folder-path.js' /** One request field, as the generator describes it. */ export interface FieldSpec { @@ -181,7 +182,8 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: */ if (flag.list) { const values = readListValues(raw, flagName) - return field.kind === 'string' ? values.join(',') : values + const normalized = flag.normalize === 'folder-path' ? values.map(normalizeFolderPath) : values + return field.kind === 'string' ? normalized.join(',') : normalized } if (takesJson(field, flag)) { @@ -210,7 +212,7 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: throw new SimApiError(`--${flagName} must be one of: ${choices.join(', ')}`, 0) } - return raw + return flag.normalize === 'folder-path' ? normalizeFolderPath(String(raw)) : raw } export interface BuiltRequest { From ab117112aa7afa618140bde5d82705b01e548487 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 5 Aug 2026 00:43:33 -0700 Subject: [PATCH 071/159] fix(api): normalize folder paths and unblock resource mutations --- apps/sim/app/api/v2/knowledge/[id]/route.ts | 41 +++--- apps/sim/app/api/v2/knowledge/route.ts | 33 +++-- apps/sim/app/api/v2/lib/folders.ts | 26 +--- apps/sim/app/api/v2/tables/[tableId]/route.ts | 118 ++++++++---------- apps/sim/app/api/v2/tables/imports/route.ts | 15 ++- apps/sim/app/api/v2/tables/route.test.ts | 75 ++++++++++- apps/sim/app/api/v2/tables/route.ts | 35 +++--- apps/sim/app/api/v2/workflows/[id]/route.ts | 52 ++++---- apps/sim/app/api/v2/workflows/import/route.ts | 29 +++-- apps/sim/app/api/v2/workflows/route.ts | 30 +++-- .../api/contracts/v2/__tests__/shared.test.ts | 54 ++++++++ apps/sim/lib/api/contracts/v2/files.ts | 9 +- apps/sim/lib/api/contracts/v2/knowledge.ts | 7 +- apps/sim/lib/api/contracts/v2/logs.ts | 26 ++-- apps/sim/lib/api/contracts/v2/shared.ts | 26 +++- apps/sim/lib/api/contracts/v2/tables.ts | 9 +- apps/sim/lib/api/contracts/v2/workflows.ts | 9 +- 17 files changed, 361 insertions(+), 233 deletions(-) create mode 100644 apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts index abbcbc61e06..a98d877d330 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -9,7 +9,6 @@ import { import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { withFolderTreeLock } from '@/lib/folders/locks' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { performDeleteKnowledgeBase, @@ -18,7 +17,7 @@ import { import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { formatKnowledgeBase, resolveKnowledgeBase } from '@/app/api/v1/knowledge/utils' import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' -import { folderPathForId, resolveFolderPathId } from '@/app/api/v2/lib/folders' +import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, @@ -134,30 +133,32 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle const result = await resolveKnowledgeBaseScoped(id, workspaceId, userId, rateLimit, 'write') if (result instanceof NextResponse) return result - const mutation = await withFolderTreeLock(workspaceId, 'knowledge_base', async (tx) => { - const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base', tx) - const folderId = folderPath === undefined ? undefined : resolveFolderPathId(index, folderPath) - if (folderPath !== undefined && folderId === undefined) return { found: false as const } - - const outcome = await performUpdateKnowledgeBase({ - knowledgeBaseId: id, - workspaceId, - userId, - source: 'api', - updates: { name, description, chunkingConfig, folderId }, - requestId, - request, - }) - return { found: true as const, index, outcome } - }) - if (!mutation.found) { + const resolution = + folderPath === undefined + ? undefined + : await resolveFolderPathIdentity({ + workspaceId, + resourceType: 'knowledge_base', + path: folderPath, + }) + if (resolution && !resolution.found) { return v2Error('NOT_FOUND', 'Folder not found') } - const { index: folderIndex, outcome } = mutation + + const outcome = await performUpdateKnowledgeBase({ + knowledgeBaseId: id, + workspaceId, + userId, + source: 'api', + updates: { name, description, chunkingConfig, folderId: resolution?.folderId }, + requestId, + request, + }) if (!outcome.success) { return v2ErrorForOrchestration(outcome.errorCode, outcome.error) } + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') return v2Data( { knowledgeBase: { diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index 90c1392c40e..12720efaa46 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -16,7 +16,7 @@ import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathId, - withResolvedFolderPathMutation, + resolveFolderPathIdentity, } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { @@ -118,25 +118,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - const mutation = await withResolvedFolderPathMutation({ + const resolution = await resolveFolderPathIdentity({ workspaceId, resourceType: 'knowledge_base', path: folderPath ?? '/', - mutate: (folderId) => - performCreateKnowledgeBase({ - userId, - source: 'api', - workspaceId, - name, - description, - chunkingConfig, - folderId, - requestId, - request, - }), }) - if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found') - const outcome = mutation.value + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + + const outcome = await performCreateKnowledgeBase({ + userId, + source: 'api', + workspaceId, + name, + description, + chunkingConfig, + folderId: resolution.folderId, + requestId, + request, + }) if (!outcome.success) { return v2ErrorForOrchestration(outcome.errorCode, outcome.error) } @@ -145,7 +144,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { knowledgeBase: { ...formatKnowledgeBase(outcome.knowledgeBase), - folderPath: folderPathForId(mutation.index, outcome.knowledgeBase.folderId), + folderPath: folderPathForId(resolution.index, outcome.knowledgeBase.folderId), }, }, { rateLimit, status: 201 } diff --git a/apps/sim/app/api/v2/lib/folders.ts b/apps/sim/app/api/v2/lib/folders.ts index 6d506a58fb3..294bb00771e 100644 --- a/apps/sim/app/api/v2/lib/folders.ts +++ b/apps/sim/app/api/v2/lib/folders.ts @@ -21,13 +21,11 @@ export function resolveFolderPathId( return path === ROOT_FOLDER_PATH ? null : index.idByPath.get(path) } -export type ResolvedFolderPathMutation = +export type ResolvedFolderPathIdentity = | { found: false } - | { found: true; folderId: string | null; index: FolderPathIndex; value: T } + | { found: true; folderId: string | null; index: FolderPathIndex } -export type ResolvedFolderPathIdentity = { found: false } | { found: true; folderId: string | null } - -/** Resolves a canonical path to its stable internal identity under the folder tree lock. */ +/** Resolves a path to its stable internal identity under a short-lived folder tree lock. */ export async function resolveFolderPathIdentity(params: { workspaceId: string resourceType: FolderResourceType @@ -36,23 +34,7 @@ export async function resolveFolderPathIdentity(params: { 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 } - }) -} - -/** Resolves a canonical path and keeps that folder tree stable through a resource mutation. */ -export async function withResolvedFolderPathMutation(params: { - workspaceId: string - resourceType: FolderResourceType - path: string - mutate: (folderId: string | null) => Promise -}): Promise> { - return withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => { - const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) - const folderId = resolveFolderPathId(index, params.path) - if (folderId === undefined) return { found: false } - const value = await params.mutate(folderId) - return { found: true, folderId, index, value } + return folderId === undefined ? { found: false } : { found: true, folderId, index } }) } diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index c5406bae297..b595920697d 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -10,7 +10,6 @@ import { parseRequest } from '@/lib/api/server' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { withFolderTreeLock } from '@/lib/folders/locks' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { getTableById } from '@/lib/table' import { signalTableSchemaChanged } from '@/lib/table/events' @@ -21,7 +20,7 @@ import { } from '@/lib/table/orchestration' import { checkAccess } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { folderPathForId, resolveFolderPathId } from '@/app/api/v2/lib/folders' +import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, @@ -155,75 +154,66 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl return v2Error('NOT_FOUND', 'Table not found') } - return await withFolderTreeLock(table.workspaceId, 'table', async (tx) => { - const folderIndex = await loadActiveFolderPathIndex(table.workspaceId, 'table', tx) - const folderId = - validated.folderPath === undefined - ? undefined - : resolveFolderPathId(folderIndex, validated.folderPath) - if (validated.folderPath !== undefined && folderId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found in this workspace') - } + const resolution = + validated.folderPath === undefined + ? undefined + : await resolveFolderPathIdentity({ + workspaceId: table.workspaceId, + resourceType: 'table', + path: validated.folderPath, + }) + if (resolution && !resolution.found) { + return v2Error('NOT_FOUND', 'Folder not found in this workspace') + } - // Rename and move retain their shared services' independent transactions and audits. - // Validate deterministic failures first and report `applied` so callers can reconcile - // if a later fault lands after an earlier operation commits. - let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null - - if (validated.name !== undefined) { - const outcome = await performRenameTable({ - table, - newName: validated.name, - userId, - requestId, - request, - }) - if (outcome.success) applied.push('name') - else failure = { outcome, fallback: 'Failed to rename table' } - } + let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null + + if (validated.name !== undefined) { + const outcome = await performRenameTable({ + table, + newName: validated.name, + userId, + requestId, + request, + }) + if (outcome.success) applied.push('name') + else failure = { outcome, fallback: 'Failed to rename table' } + } - if (!failure && validated.folderPath !== undefined) { - const outcome = await performMoveTableToFolder({ - table, - folderId: folderId ?? null, - userId, - requestId, - request, - }) - if (outcome.success) { - applied.push('folderPath') - } else { - // The move re-asserts workspace and active state, so a miss means the - // table was archived between `checkAccess` and the write. - failure = { - outcome: - outcome.errorCode === 'not_found' - ? { ...outcome, error: 'Table not found' } - : outcome, - fallback: 'Failed to move table', - } + if (!failure && validated.folderPath !== undefined) { + const outcome = await performMoveTableToFolder({ + table, + folderId: resolution?.folderId ?? null, + userId, + requestId, + request, + }) + if (outcome.success) { + applied.push('folderPath') + } else { + failure = { + outcome: + outcome.errorCode === 'not_found' ? { ...outcome, error: 'Table not found' } : outcome, + fallback: 'Failed to move table', } } + } - // Live-collab: tell open viewers the definition changed so they refetch. - if (applied.length > 0) signalTableSchemaChanged(tableId) - if (failure) { - return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied)) - } + if (applied.length > 0) signalTableSchemaChanged(tableId) + if (failure) { + return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied)) + } - // Re-read so the response reflects every applied change at once. A miss - // means the table was archived after the writes committed, so the caller - // still has to be told what landed. - const updated = await getTableById(tableId) - if (!updated) { - return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) }) - } + const updated = await getTableById(tableId) + if (!updated) { + return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) }) + } - return v2Data( - { table: toApiTable(updated, folderPathForId(folderIndex, updated.folderId)) }, - { rateLimit } - ) - }) + const folderIndex = await loadActiveFolderPathIndex(table.workspaceId, 'table') + return v2Data( + { table: toApiTable(updated, folderPathForId(folderIndex, updated.folderId)) }, + { rateLimit } + ) } catch (error) { const details = appliedDetails(applied) diff --git a/apps/sim/app/api/v2/tables/imports/route.ts b/apps/sim/app/api/v2/tables/imports/route.ts index 5908ffd57a0..43ce013fa3b 100644 --- a/apps/sim/app/api/v2/tables/imports/route.ts +++ b/apps/sim/app/api/v2/tables/imports/route.ts @@ -9,7 +9,7 @@ import { toV2CreateTableImport, } from '@/lib/table/orchestration/import-resource' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { withResolvedFolderPathMutation } from '@/app/api/v2/lib/folders' +import { resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CaughtOrchestrationError, @@ -43,15 +43,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (scopeError) return v2WorkspaceAccessError(scopeError) let created: Awaited> if (parsed.data.body.target.type === 'new') { - const mutation = await withResolvedFolderPathMutation({ + const resolution = await resolveFolderPathIdentity({ workspaceId: parsed.data.body.workspaceId, resourceType: 'table', path: parsed.data.body.target.folderPath ?? '/', - mutate: (folderId) => - createTableImportResource(parsed.data.body, userId, request.nextUrl.origin, folderId), }) - if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found') - created = mutation.value + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + created = await createTableImportResource( + parsed.data.body, + userId, + request.nextUrl.origin, + resolution.folderId + ) } else { created = await createTableImportResource(parsed.data.body, userId, request.nextUrl.origin) } diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts index 0412235b367..b5af7c73822 100644 --- a/apps/sim/app/api/v2/tables/route.test.ts +++ b/apps/sim/app/api/v2/tables/route.test.ts @@ -15,6 +15,9 @@ const { mockIsFeatureEnabled, mockGetWorkspaceOrganizationId, mockLoadActiveFolderPathIndex, + mockResolveFolderPathIdentity, + mockCreateTable, + mockGetWorkspaceTableLimits, } = vi.hoisted(() => ({ mockQueryTables: vi.fn(), mockCheckRateLimit: vi.fn(), @@ -22,6 +25,9 @@ const { mockIsFeatureEnabled: vi.fn(), mockGetWorkspaceOrganizationId: vi.fn(), mockLoadActiveFolderPathIndex: vi.fn(), + mockResolveFolderPathIdentity: vi.fn(), + mockCreateTable: vi.fn(), + mockGetWorkspaceTableLimits: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -31,7 +37,12 @@ vi.mock('@/app/api/v1/middleware', () => ({ vi.mock('@/lib/table', async () => { const actual = await import('@/lib/table/column-keys') - return { ...actual, queryTables: mockQueryTables } + return { + ...actual, + queryTables: mockQueryTables, + createTable: mockCreateTable, + getWorkspaceTableLimits: mockGetWorkspaceTableLimits, + } }) vi.mock('@/app/api/table/utils', () => ({ @@ -56,7 +67,17 @@ vi.mock('@/lib/folders/queries', () => ({ loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, })) -import { GET } from '@/app/api/v2/tables/route' +vi.mock('@/app/api/v2/lib/folders', () => ({ + folderPathForId: (_index: unknown, folderId: string | null | undefined) => + folderId ? '/Reports' : '/', + resolveFolderPathId: ( + index: { idByPath: Map }, + path: string + ): string | null | undefined => (path === '/' ? null : index.idByPath.get(path)), + resolveFolderPathIdentity: mockResolveFolderPathIdentity, +})) + +import { GET, POST } from '@/app/api/v2/tables/route' const RATE_LIMIT_OK = { allowed: true, @@ -89,6 +110,16 @@ function callList(query: string) { return GET(req) } +function callCreate(body: Record) { + return POST( + new NextRequest('http://localhost:3000/api/v2/tables', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + ) +} + describe('GET /api/v2/tables', () => { beforeEach(() => { vi.clearAllMocks() @@ -219,3 +250,43 @@ describe('GET /api/v2/tables', () => { expect(first.status).toBe(200) }) }) + +describe('POST /api/v2/tables', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceTableLimits.mockResolvedValue({ maxTables: 100 }) + mockResolveFolderPathIdentity.mockResolvedValue({ + found: true, + folderId: 'folder-1', + index: { + rowById: new Map(), + pathById: new Map([['folder-1', '/Reports']]), + idByPath: new Map([['/Reports', 'folder-1']]), + }, + }) + mockCreateTable.mockResolvedValue({ ...buildTable(), folderId: 'folder-1' }) + }) + + it('resolves a slashless folder path before creating the table outside the folder lock', async () => { + const res = await callCreate({ + workspaceId: 'workspace-1', + name: 'People', + folderPath: 'Reports', + schema: { columns: [{ name: 'email', type: 'string' }] }, + }) + + expect(res.status).toBe(201) + expect(mockResolveFolderPathIdentity).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + resourceType: 'table', + path: '/Reports', + }) + expect(mockCreateTable).toHaveBeenCalledWith( + expect.objectContaining({ folderId: 'folder-1' }), + expect.any(String) + ) + expect((await res.json()).data.table.folderPath).toBe('/Reports') + }) +}) diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index f57826e1bc2..c3d319bbafd 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -13,7 +13,7 @@ import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathId, - withResolvedFolderPathMutation, + resolveFolderPathIdentity, } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { @@ -132,26 +132,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => { columns: params.schema.columns.map(normalizeColumn), } - const mutation = await withResolvedFolderPathMutation({ + const resolution = await resolveFolderPathIdentity({ workspaceId: params.workspaceId, resourceType: 'table', path: params.folderPath ?? '/', - mutate: (folderId) => - createTable( - { - name: params.name, - description: params.description, - schema: normalizedSchema, - workspaceId: params.workspaceId, - userId, - maxTables: planLimits.maxTables, - folderId, - }, - requestId - ), }) - if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found') - const table = mutation.value + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + + const table = await createTable( + { + name: params.name, + description: params.description, + schema: normalizedSchema, + workspaceId: params.workspaceId, + userId, + maxTables: planLimits.maxTables, + folderId: resolution.folderId, + }, + requestId + ) recordAudit({ workspaceId: params.workspaceId, @@ -166,7 +165,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) return v2Data( - { table: toApiTable(table, folderPathForId(mutation.index, table.folderId)) }, + { table: toApiTable(table, folderPathForId(resolution.index, table.folderId)) }, { rateLimit, status: 201 } ) } catch (error) { diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index 9123fb7b89e..f184af03c30 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -21,12 +21,11 @@ import { } from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { withFolderTreeLock } from '@/lib/folders/locks' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { folderPathForId, resolveFolderPathId } from '@/app/api/v2/lib/folders' +import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, @@ -146,37 +145,40 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout ) if (access) return v2Error('NOT_FOUND', 'Workflow not found') - const mutation = await withFolderTreeLock(workflowData.workspaceId, 'workflow', async (tx) => { - const index = await loadActiveFolderPathIndex(workflowData.workspaceId!, 'workflow', tx) - const folderId = folderPath === undefined ? undefined : resolveFolderPathId(index, folderPath) - if (folderPath !== undefined && folderId === undefined) return { found: false as const } - - await assertWorkflowMutable(id) - if (folderId !== undefined) await assertFolderMutable(folderId) - - const result = await performUpdateWorkflow({ - workflowId: id, - userId, - workspaceId: workflowData.workspaceId!, - currentName: workflowData.name, - currentFolderId: workflowData.folderId, - name, - description, - folderId, - requestId, - }) - return { found: true as const, index, result } - }) - if (!mutation.found) { + const resolution = + folderPath === undefined + ? undefined + : await resolveFolderPathIdentity({ + workspaceId: workflowData.workspaceId, + resourceType: 'workflow', + path: folderPath, + }) + if (resolution && !resolution.found) { return v2Error('NOT_FOUND', 'Folder not found') } - const { index: folderIndex, result } = mutation + + const folderId = resolution?.folderId + await assertWorkflowMutable(id) + if (folderId !== undefined) await assertFolderMutable(folderId) + + const result = await performUpdateWorkflow({ + workflowId: id, + userId, + workspaceId: workflowData.workspaceId, + currentName: workflowData.name, + currentFolderId: workflowData.folderId, + name, + description, + folderId, + requestId, + }) if (!result.success || !result.workflow) { return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to update workflow') } const updated = result.workflow + const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') /** * Deployment and run counters are untouched by a metadata update, so they * come from the record read above rather than a second query. diff --git a/apps/sim/app/api/v2/workflows/import/route.ts b/apps/sim/app/api/v2/workflows/import/route.ts index 5338f9c8cb9..e70da75d915 100644 --- a/apps/sim/app/api/v2/workflows/import/route.ts +++ b/apps/sim/app/api/v2/workflows/import/route.ts @@ -10,7 +10,7 @@ import { MAX_IMPORT_BODY_BYTES, } from '@/lib/workflows/operations/import-workflow' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { folderPathForId, withResolvedFolderPathMutation } from '@/app/api/v2/lib/folders' +import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { type V2ErrorCode, @@ -75,23 +75,22 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - const mutation = await withResolvedFolderPathMutation({ + const resolution = await resolveFolderPathIdentity({ workspaceId, resourceType: 'workflow', path: folderPath ?? '/', - mutate: (folderId) => - importWorkflowIntoWorkspace({ - workspaceId, - folderId: folderId ?? undefined, - name, - description, - workflow: parsed.data.body.workflow, - userId, - requestId, - }), }) - if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found') - const result = mutation.value + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + + const result = await importWorkflowIntoWorkspace({ + workspaceId, + folderId: resolution.folderId ?? undefined, + name, + description, + workflow: parsed.data.body.workflow, + userId, + requestId, + }) if (!result.success) { return v2Error(ERROR_CODE_BY_STATUS[result.status] ?? 'INTERNAL_ERROR', result.error, { @@ -106,7 +105,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { name: result.workflow.name, description: result.workflow.description, workspaceId: result.workflow.workspaceId, - folderPath: folderPathForId(mutation.index, result.workflow.folderId), + folderPath: folderPathForId(resolution.index, result.workflow.folderId), createdAt: result.workflow.createdAt.toISOString(), updatedAt: result.workflow.updatedAt.toISOString(), }, diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index 896280c7be0..b314a1500de 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -31,7 +31,7 @@ import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathId, - withResolvedFolderPathMutation, + resolveFolderPathIdentity, } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { @@ -224,24 +224,22 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - const mutation = await withResolvedFolderPathMutation({ + const resolution = await resolveFolderPathIdentity({ workspaceId, resourceType: 'workflow', path: folderPath ?? '/', - mutate: async (folderId) => { - await assertFolderMutable(folderId) - return performCreateWorkflow({ - userId, - workspaceId, - name, - description, - folderId, - requestId, - }) - }, }) - if (!mutation.found) return v2Error('NOT_FOUND', 'Folder not found') - const result = mutation.value + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + + await assertFolderMutable(resolution.folderId) + const result = await performCreateWorkflow({ + userId, + workspaceId, + name, + description, + folderId: resolution.folderId, + requestId, + }) if (!result.success || !result.workflow) { return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to create workflow') @@ -252,7 +250,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { id: created.id, name: created.name, description: created.description ?? null, - folderPath: folderPathForId(mutation.index, created.folderId), + folderPath: folderPathForId(resolution.index, created.folderId), workspaceId: created.workspaceId, isDeployed: false, deployedAt: null, diff --git a/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts new file mode 100644 index 00000000000..91051d1acab --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { v2ListLogsQuerySchema } from '@/lib/api/contracts/v2/logs' +import { + v2FolderPathInputSchema, + v2FolderPathSchema, + v2NonRootFolderPathInputSchema, + v2NonRootFolderPathSchema, + v2RelocateFolderBodySchema, +} from '@/lib/api/contracts/v2/shared' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' + +describe('v2 folder path contracts', () => { + it('accepts slashless paths and normalizes them to the canonical form', () => { + expect(v2FolderPathInputSchema.parse('Reports/Q1')).toBe('/Reports/Q1') + expect(v2FolderPathInputSchema.parse('/Reports/Q1')).toBe('/Reports/Q1') + expect(v2NonRootFolderPathInputSchema.parse('Reports')).toBe('/Reports') + }) + + it('does not treat an empty path as the workspace root', () => { + expect(v2FolderPathInputSchema.safeParse('').success).toBe(false) + expect(v2NonRootFolderPathInputSchema.safeParse('/').success).toBe(false) + }) + + it('still rejects noncanonical path syntax after adding the leading slash', () => { + expect(v2FolderPathInputSchema.safeParse('Reports/').success).toBe(false) + expect(v2FolderPathInputSchema.safeParse('Reports//Q1').success).toBe(false) + expect(v2FolderPathInputSchema.safeParse('Reports/%71').success).toBe(false) + }) + + it('keeps response paths strict and fail-fast', () => { + expect(v2FolderPathSchema.safeParse('Reports').success).toBe(false) + expect(v2NonRootFolderPathSchema.safeParse('Reports').success).toBe(false) + }) + + it('compares normalized paths in cross-field validation', () => { + expect( + v2RelocateFolderBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + path: 'Reports', + destinationPath: '/Reports', + }).success + ).toBe(false) + }) + + it('normalizes every folder path in the logs filter', () => { + const query = v2ListLogsQuerySchema.parse({ + workspaceId: WORKSPACE_ID, + folderPaths: 'Reports/Q1,/Archive', + }) + + expect(query.folderPaths).toBe('/Reports/Q1,/Archive') + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 296bfef9ca0..429dbf00913 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -11,6 +11,7 @@ import { v2CursorListResponse, v2DataResponse, v2DeleteFolderQuerySchema, + v2FolderPathInputSchema, v2FolderPathSchema, v2FolderSchema, v2ListFoldersQuerySchema, @@ -68,7 +69,7 @@ export const v2CreateFileUploadBodySchema = z name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), contentType: z.string().trim().min(1, 'contentType is required').max(255), size: z.number().int().nonnegative().max(MAX_WORKSPACE_FILE_SIZE), - folderPath: v2FolderPathSchema.optional(), + folderPath: v2FolderPathInputSchema.optional(), }) .strict() export type V2CreateFileUploadBody = z.input @@ -135,7 +136,7 @@ export const v2CreateFileBodySchema = z .min(1, 'contentType cannot be empty') .max(255, 'contentType is too long') .optional(), - folderPath: v2FolderPathSchema.optional(), + folderPath: v2FolderPathInputSchema.optional(), content: z.string().max(70_000_000, 'content is too large').default(''), encoding: z.enum(['utf-8', 'base64']).default('utf-8'), }) @@ -170,7 +171,7 @@ export const v2ListFilesQuerySchema = z .object({ workspaceId: workspaceIdSchema, /** Restrict to one file folder. Omit to list the whole workspace. */ - folderPath: v2FolderPathSchema.optional(), + folderPath: v2FolderPathInputSchema.optional(), search: v2SearchSchema, ...v2SortFields(v2FileSortFields, { sortBy: 'uploadedAt', sortOrder: 'asc' }), limit: z.coerce @@ -209,7 +210,7 @@ export const v2MoveFileItemsBodySchema = z workspaceId: workspaceIdSchema, ...fileSelectionSchema, /** Omission moves the files to the workspace root. */ - targetFolderPath: v2FolderPathSchema.optional(), + targetFolderPath: v2FolderPathInputSchema.optional(), }) .strict() diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 42e25e51ea0..9f2f3ec1e94 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -19,6 +19,7 @@ import { v2CursorListResponse, v2DataResponse, v2DeleteFolderQuerySchema, + v2FolderPathInputSchema, v2FolderPathSchema, v2FolderSchema, v2ListFoldersQuerySchema, @@ -247,7 +248,7 @@ export type V2KnowledgeBaseSortBy = (typeof v2KnowledgeBaseSortFields)[number] export const v2ListKnowledgeBasesQuerySchema = z .object({ workspaceId: workspaceIdSchema, - folderPath: v2FolderPathSchema.optional(), + folderPath: v2FolderPathInputSchema.optional(), search: v2SearchSchema, ...v2SortFields(v2KnowledgeBaseSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), }) @@ -256,7 +257,7 @@ export const v2ListKnowledgeBasesQuerySchema = z export type V2ListKnowledgeBasesQuery = z.output export const v2CreateKnowledgeBaseBodySchema = v1CreateKnowledgeBaseBodySchema - .extend({ folderPath: v2FolderPathSchema.optional() }) + .extend({ folderPath: v2FolderPathInputSchema.optional() }) .strict() export const v2UpdateKnowledgeBaseBodySchema = z @@ -265,7 +266,7 @@ export const v2UpdateKnowledgeBaseBodySchema = z name: v1CreateKnowledgeBaseBodySchema.shape.name.optional(), description: v1CreateKnowledgeBaseBodySchema.shape.description, chunkingConfig: v1CreateKnowledgeBaseBodySchema.shape.chunkingConfig.optional(), - folderPath: v2FolderPathSchema.optional(), + folderPath: v2FolderPathInputSchema.optional(), }) .strict() .superRefine((body, ctx) => { diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index b5a3d607746..8f769542b4c 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -8,6 +8,7 @@ import { import { v2CursorListResponse, v2DataResponse, + v2FolderPathInputSchema, v2FolderPathSchema, } from '@/lib/api/contracts/v2/shared' @@ -102,15 +103,24 @@ export const v2ListLogsQuerySchema = v1ListLogsQuerySchema folderPaths: z .string() .optional() - .superRefine((value, ctx) => { - if (!value) return - const paths = value.split(',').filter(Boolean) - if ( - paths.length === 0 || - paths.some((path) => !v2FolderPathSchema.safeParse(path).success) - ) { - ctx.addIssue({ code: 'custom', message: 'folderPaths must contain canonical paths' }) + .transform((value, ctx) => { + if (value === undefined) return undefined + const paths = value.split(',') + if (paths.length === 0 || paths.some((path) => path.length === 0)) { + ctx.addIssue({ code: 'custom', message: 'folderPaths must contain valid paths' }) + return z.NEVER } + + const normalizedPaths: string[] = [] + for (const path of paths) { + const parsed = v2FolderPathInputSchema.safeParse(path) + if (!parsed.success) { + ctx.addIssue({ code: 'custom', message: 'folderPaths must contain valid paths' }) + return z.NEVER + } + normalizedPaths.push(parsed.data) + } + return normalizedPaths.join(',') }), }) .strict() diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index e12fcf50f2c..c5e291c1c0e 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -115,6 +115,22 @@ export type V2FolderPath = z.output /** Canonical path that identifies a real folder rather than the virtual root. */ export const v2NonRootFolderPathSchema = canonicalFolderPathSchema(requireNonRootFolderPath) +function normalizeFolderPathInput(path: string): string { + return path.length === 0 || path.startsWith('/') ? path : `/${path}` +} + +/** Input path that accepts an omitted leading slash and emits the canonical form. */ +export const v2FolderPathInputSchema = z + .string() + .transform(normalizeFolderPathInput) + .pipe(v2FolderPathSchema) + +/** Non-root input path that accepts an omitted leading slash and emits the canonical form. */ +export const v2NonRootFolderPathInputSchema = z + .string() + .transform(normalizeFolderPathInput) + .pipe(v2NonRootFolderPathSchema) + export const v2FolderSchema = z.object({ name: z.string(), path: v2NonRootFolderPathSchema, @@ -129,7 +145,7 @@ export const v2FolderSortFields = ['name', 'createdAt', 'updatedAt'] as const export const v2ListFoldersQuerySchema = z .object({ workspaceId: workspaceIdSchema, - parentPath: v2FolderPathSchema.optional(), + parentPath: v2FolderPathInputSchema.optional(), search: v2SearchSchema, ...v2SortFields(v2FolderSortFields, { sortBy: 'name', sortOrder: 'asc' }), }) @@ -138,15 +154,15 @@ export const v2ListFoldersQuerySchema = z export const v2CreateFolderBodySchema = z .object({ workspaceId: workspaceIdSchema, - path: v2NonRootFolderPathSchema, + path: v2NonRootFolderPathInputSchema, }) .strict() export const v2RelocateFolderBodySchema = z .object({ workspaceId: workspaceIdSchema, - path: v2NonRootFolderPathSchema, - destinationPath: v2NonRootFolderPathSchema, + path: v2NonRootFolderPathInputSchema, + destinationPath: v2NonRootFolderPathInputSchema, }) .strict() .superRefine((body, ctx) => { @@ -162,7 +178,7 @@ export const v2RelocateFolderBodySchema = z export const v2DeleteFolderQuerySchema = z .object({ workspaceId: workspaceIdSchema, - path: v2NonRootFolderPathSchema, + path: v2NonRootFolderPathInputSchema, recursive: z.stringbool(), }) .strict() diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 8a71da1d539..c882f68c938 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -42,6 +42,7 @@ import { v2CursorListResponse, v2DataResponse, v2DeleteFolderQuerySchema, + v2FolderPathInputSchema, v2FolderPathSchema, v2FolderSchema, v2ListFoldersQuerySchema, @@ -218,7 +219,7 @@ export type V2TableSortBy = (typeof v2TableSortFields)[number] export const v2ListTablesQuerySchema = z .object({ workspaceId: workspaceIdSchema, - folderPath: v2FolderPathSchema.optional(), + folderPath: v2FolderPathInputSchema.optional(), search: v2SearchSchema, ...v2SortFields(v2TableSortFields, { sortBy: 'createdAt', sortOrder: 'asc' }), limit: z.coerce @@ -234,7 +235,7 @@ export type V2ListTablesQuery = z.output export const v2CreateTableBodySchema = v1CreateTableBodySchema .omit({ folderId: true }) - .extend({ folderPath: v2FolderPathSchema.optional() }) + .extend({ folderPath: v2FolderPathInputSchema.optional() }) .strict() /** @@ -293,7 +294,7 @@ export const v2UpdateTableBodySchema = z .object({ workspaceId: workspaceIdSchema, name: tableNameSchema.optional(), - folderPath: v2FolderPathSchema.optional(), + folderPath: v2FolderPathInputSchema.optional(), }) .strict() .superRefine((body, ctx) => { @@ -985,7 +986,7 @@ export const v2TableImportTargetSchema = z.discriminatedUnion('type', [ .object({ type: z.literal('new'), name: tableNameSchema, - folderPath: v2FolderPathSchema.optional(), + folderPath: v2FolderPathInputSchema.optional(), }) .strict(), z diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 10da02345c9..10e0faa7c45 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -19,6 +19,7 @@ import { v2CursorListResponse, v2DataResponse, v2DeleteFolderQuerySchema, + v2FolderPathInputSchema, v2FolderPathSchema, v2FolderSchema, v2ListFoldersQuerySchema, @@ -72,7 +73,7 @@ export type V2WorkflowSortBy = (typeof v2WorkflowSortFields)[number] export const v2ListWorkflowsQuerySchema = z .object({ workspaceId: workspaceIdSchema, - folderPath: v2FolderPathSchema.optional(), + folderPath: v2FolderPathInputSchema.optional(), deployedOnly: booleanQueryFlagSchema.optional().default(false), limit: z.coerce.number().min(1).max(100).optional().default(50), cursor: z.string().optional(), @@ -159,7 +160,7 @@ export const v2CreateWorkflowBodySchema = z name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), description: z.string().max(50_000, 'description is too long').nullable().optional(), /** Omission creates the workflow at the workspace root. */ - folderPath: v2FolderPathSchema.optional(), + folderPath: v2FolderPathInputSchema.optional(), }) .strict() export type V2CreateWorkflowBody = z.input @@ -169,7 +170,7 @@ export const v2UpdateWorkflowBodySchema = z .object({ name: z.string().trim().min(1, 'name cannot be empty').max(255, 'name is too long').optional(), description: z.string().max(50_000, 'description is too long').nullable().optional(), - folderPath: v2FolderPathSchema.optional(), + folderPath: v2FolderPathInputSchema.optional(), }) .strict() .superRefine((body, ctx) => { @@ -500,7 +501,7 @@ export const v2WorkflowExportPayloadSchema = v1WorkflowExportPayloadSchema.exten export const v2ImportWorkflowBodySchema = v1ImportWorkflowBodySchema .omit({ folderId: true, name: true, description: true }) .extend({ - folderPath: v2FolderPathSchema.optional(), + folderPath: v2FolderPathInputSchema.optional(), name: z .string() .min(1, 'name cannot be empty') From 3577b08fba5af7f8dcaa15209867d63b0a357f1b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 5 Aug 2026 01:06:07 -0700 Subject: [PATCH 072/159] fix(api): make resource cleanup and metadata consistent --- apps/docs/openapi-v2-tables.json | 18 ++- .../app/api/v2/tables/[tableId]/route.test.ts | 25 ++++ apps/sim/app/api/v2/tables/[tableId]/route.ts | 19 ++- apps/sim/lib/api/contracts/v2/tables.ts | 16 ++- apps/sim/lib/folders/orchestration.test.ts | 45 +++++- apps/sim/lib/folders/orchestration.ts | 130 ++++++++++-------- apps/sim/lib/table/orchestration/index.ts | 1 + apps/sim/lib/table/orchestration/tables.ts | 50 ++++++- apps/sim/lib/table/service.ts | 28 ++++ .../workspace-file-folder-manager.ts | 6 + 10 files changed, 266 insertions(+), 72 deletions(-) diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 5520ec9c1e1..2a992c137e3 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -270,7 +270,7 @@ "delete": { "operationId": "deleteTable", "summary": "Delete Table", - "description": "Delete a table. Returns the id of the deleted table.", + "description": "Delete a table. Returns the id and an explicit deletion confirmation.", "tags": ["Tables"], "x-codeSamples": [ { @@ -333,7 +333,7 @@ "patch": { "operationId": "updateTable", "summary": "Update Table", - "description": "Rename a table and/or move it between folders. Provide at least one of `name` or `folderPath`. Each field is applied independently, so one request can rename and move at once, and the response reflects every applied change.\n\nBoth fields need workspace write.\n\n**Lock flags are read-only here.** A table's `locks` are returned on the table resource and enforced on every write (a locked verb returns 423), but they cannot be changed through the API — a write-level key must not be able to clear the guard placed there to stop it. Changing a lock is a first-party workspace-admin action. A request carrying `locks` is rejected with 400 rather than silently ignored.\n\n**Partial-success semantics.** The two operations commit independently, so this endpoint is not atomic. Everything that can be *rejected* — the body shape, folder existence — is validated before the first write, so a rejected request changes nothing. If a genuine fault (a lost race, the table deleted mid-request, a database error) fails the later operation after the earlier one has committed, the response is an error whose `error.details.applied` lists the operations that are nevertheless live (`\"name\"`, `\"folderPath\"`). The field is absent when nothing was applied, so its presence always means \"these changes took effect despite the error\" — re-read the table to confirm before retrying.", + "description": "Rename a table, edit its description, and/or move it between folders. Provide at least one of `name`, `description`, or `folderPath`. Each field is applied independently, so one request can combine changes and the response reflects every applied change.\n\nAll three fields need workspace write.\n\n**Lock flags are read-only here.** A table's `locks` are returned on the table resource and enforced on every write (a locked verb returns 423), but they cannot be changed through the API — a write-level key must not be able to clear the guard placed there to stop it. Changing a lock is a first-party workspace-admin action. A request carrying `locks` is rejected with 400 rather than silently ignored.\n\n**Partial-success semantics.** The operations commit independently, so this endpoint is not atomic. Everything that can be *rejected* — the body shape, folder existence — is validated before the first write, so a rejected request changes nothing. If a genuine fault (a lost race, the table deleted mid-request, a database error) fails a later operation after an earlier one has committed, the response is an error whose `error.details.applied` lists the operations that are nevertheless live (`\"name\"`, `\"description\"`, `\"folderPath\"`). The field is absent when nothing was applied, so its presence always means \"these changes took effect despite the error\" — re-read the table to confirm before retrying.", "tags": ["Tables"], "x-codeSamples": [ { @@ -4976,11 +4976,16 @@ "properties": { "data": { "type": "object", - "required": ["id"], + "required": ["id", "deleted"], "properties": { "id": { "type": "string", "description": "The id of the deleted table." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Confirms the table was deleted." } } } @@ -5301,7 +5306,7 @@ }, "UpdateTableBody": { "type": "object", - "description": "Rename and/or move a table. Every field beyond `workspaceId` is optional, but at least one must be present. Lock flags are read-only on this API and are not accepted here.", + "description": "Rename, edit the description, and/or move a table. Every field beyond `workspaceId` is optional, but at least one must be present. Lock flags are read-only on this API and are not accepted here.", "required": ["workspaceId"], "properties": { "workspaceId": { @@ -5314,6 +5319,11 @@ "minLength": 1, "description": "New table name." }, + "description": { + "type": ["string", "null"], + "maxLength": 500, + "description": "New table description, or null to clear it." + }, "folderPath": { "type": "string", "description": "Canonical containing-folder path. `/` is the workspace root." diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts index 866165ddb60..6b446248750 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts @@ -15,6 +15,7 @@ const { mockCheckAccess, mockPerformDeleteTable, mockPerformRenameTable, + mockPerformUpdateTableDescription, mockPerformMoveTableToFolder, mockPerformUpdateTableLocks, mockRecordAudit, @@ -28,6 +29,7 @@ const { mockCheckAccess: vi.fn(), mockPerformDeleteTable: vi.fn(), mockPerformRenameTable: vi.fn(), + mockPerformUpdateTableDescription: vi.fn(), mockPerformMoveTableToFolder: vi.fn(), mockPerformUpdateTableLocks: vi.fn(), mockRecordAudit: vi.fn(), @@ -76,6 +78,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ vi.mock('@/lib/table/orchestration', () => ({ performDeleteTable: mockPerformDeleteTable, performRenameTable: mockPerformRenameTable, + performUpdateTableDescription: mockPerformUpdateTableDescription, performMoveTableToFolder: mockPerformMoveTableToFolder, performUpdateTableLocks: mockPerformUpdateTableLocks, })) @@ -158,6 +161,7 @@ describe('DELETE /api/v2/tables/[tableId]', () => { expect(mockPerformDeleteTable).toHaveBeenCalledWith( expect.objectContaining({ table: TABLE, userId: 'user-1' }) ) + expect((await res.json()).data).toEqual({ id: 'table-1', deleted: true }) // The route no longer audits: doing so out here fired TABLE_DELETED even // when the delete was a no-op on an already-archived table. expect(mockRecordAudit).not.toHaveBeenCalled() @@ -206,6 +210,27 @@ describe('PATCH /api/v2/tables/[tableId]', () => { expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() }) + it('updates and clears the table description through orchestration', async () => { + mockPerformUpdateTableDescription.mockResolvedValue({ success: true }) + + const updateResponse = await callPatch({ workspaceId: 'ws-1', description: 'Finance data' }) + + expect(updateResponse.status).toBe(200) + expect(mockPerformUpdateTableDescription).toHaveBeenCalledWith( + expect.objectContaining({ + table: TABLE, + description: 'Finance data', + userId: 'user-1', + }) + ) + + const clearResponse = await callPatch({ workspaceId: 'ws-1', description: null }) + expect(clearResponse.status).toBe(200) + expect(mockPerformUpdateTableDescription).toHaveBeenLastCalledWith( + expect.objectContaining({ description: null }) + ) + }) + it('surfaces a running import so an async job is observable, not just startable', async () => { // `POST /import-async` and `POST /job/cancel` let a caller start and stop an // import; without this the table never reports that it is running, so there diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index b595920697d..4df088ba0ce 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -17,6 +17,7 @@ import { performDeleteTable, performMoveTableToFolder, performRenameTable, + performUpdateTableDescription, } from '@/lib/table/orchestration' import { checkAccess } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' @@ -45,7 +46,7 @@ const logger = createLogger('V2TableDetailAPI') * means "these changes are live despite the error". */ function appliedDetails( - applied: readonly ('name' | 'folderPath')[] + applied: readonly ('name' | 'description' | 'folderPath')[] ): { applied: readonly string[] } | undefined { return applied.length > 0 ? { applied } : undefined } @@ -124,7 +125,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl * archived. Reporting a bare 500 there tells the caller nothing landed, and * it retries into a duplicate-name conflict or a repeated move. */ - const applied: ('name' | 'folderPath')[] = [] + const applied: ('name' | 'description' | 'folderPath')[] = [] try { const rateLimit = await checkRateLimit(request, 'table-detail') @@ -180,6 +181,18 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl else failure = { outcome, fallback: 'Failed to rename table' } } + if (!failure && validated.description !== undefined) { + const outcome = await performUpdateTableDescription({ + table, + description: validated.description, + userId, + requestId, + request, + }) + if (outcome.success) applied.push('description') + else failure = { outcome, fallback: 'Failed to update table description' } + } + if (!failure && validated.folderPath !== undefined) { const outcome = await performMoveTableToFolder({ table, @@ -272,7 +285,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab return v2TableOrchestrationError(outcome, 'Failed to delete table') } - return v2Data({ id: tableId }, { rateLimit }) + return v2Data({ id: tableId, deleted: true }, { rateLimit }) } catch (error) { const lockError = v2TableLockError(error) if (lockError) return lockError diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index c882f68c938..2f499a07afa 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -150,7 +150,7 @@ export type V2ApiRow = z.output export const v2TableDataSchema = z.object({ table: v2ApiTableSchema }) export type V2TableData = z.output -export const v2DeleteTableDataSchema = z.object({ id: z.string() }) +export const v2DeleteTableDataSchema = z.object({ id: z.string(), deleted: z.literal(true) }) export type V2DeleteTableData = z.output /** The table's full column list after a column mutation. */ @@ -279,8 +279,9 @@ export const v2GetTableContract = defineRouteContract({ /** * Table update. Every field is optional but at least one must be present: - * `name` renames and `folderPath` moves the table. Omission leaves placement - * untouched; `/` moves it to the workspace root. + * `name` renames, `description` edits metadata, and `folderPath` moves the + * table. Omission leaves placement untouched; `/` moves it to the workspace + * root. * * `locks` is deliberately **not** accepted here, which is why this body is * declared rather than reusing the first-party `updateTableBodySchema`. The @@ -294,14 +295,19 @@ export const v2UpdateTableBodySchema = z .object({ workspaceId: workspaceIdSchema, name: tableNameSchema.optional(), + description: v1CreateTableBodySchema.shape.description.nullable(), folderPath: v2FolderPathInputSchema.optional(), }) .strict() .superRefine((body, ctx) => { - if (body.name === undefined && body.folderPath === undefined) { + if ( + body.name === undefined && + body.description === undefined && + body.folderPath === undefined + ) { ctx.addIssue({ code: 'custom', - message: 'Provide a new name or folder', + message: 'Provide a new name, description, or folder', path: ['name'], }) } diff --git a/apps/sim/lib/folders/orchestration.test.ts b/apps/sim/lib/folders/orchestration.test.ts index 1a3d1c9fdb2..055bf9caf91 100644 --- a/apps/sim/lib/folders/orchestration.test.ts +++ b/apps/sim/lib/folders/orchestration.test.ts @@ -172,6 +172,8 @@ describe('createFolder', () => { name: 'Reports', workspaceId: 'ws-1', parentId: null, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), }) ) }) @@ -352,8 +354,49 @@ describe('path-owned folder mutations', () => { expect(result).toMatchObject({ success: true, path: '/Reports/Q1' }) expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ name: 'Q1', parentId: 'parent-1' }) + expect.objectContaining({ + name: 'Q1', + parentId: 'parent-1', + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }) + ) + }) + + it('releases the folder transaction before running the domain delete cascade', async () => { + const source = folderRow({ id: 'folder-1', name: 'Reports' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['folder-1', source]]), + pathById: new Map([['folder-1', '/Reports']]), + idByPath: new Map([['/Reports', 'folder-1']]), + }) + queueTableRows(schemaMock.folder, [{ deletedAt: null }]) + + let inFolderTransaction = false + dbChainMockFns.transaction.mockImplementationOnce( + async (operation: (tx: unknown) => Promise) => { + inFolderTransaction = true + try { + return await operation(dbChainMock.db) + } finally { + inFolderTransaction = false + } + } ) + mockArchiveFolderCascade.mockImplementationOnce(async () => { + expect(inFolderTransaction).toBe(false) + return { folders: 1, children: 0 } + }) + + const result = await deleteFolderByPath({ + resourceType: 'table', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports', + recursive: true, + }) + + expect(result).toMatchObject({ success: true, path: '/Reports' }) }) it('rejects relocating a folder beneath its own descendant before writing', async () => { diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index 0514a46a31a..c19ca42d061 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -191,6 +191,7 @@ export async function createFolderAtPath( parentId, tx ) + const now = new Date() const [created] = await tx .insert(folderTable) .values({ @@ -201,6 +202,8 @@ export async function createFolderAtPath( workspaceId: params.workspaceId, parentId, sortOrder, + createdAt: now, + updatedAt: now, }) .returning() return created @@ -311,47 +314,53 @@ export async function deleteFolderByPath( ): Promise { try { requireNonRootFolderPath(params.path) - const result = await withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => { - const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) - const folderId = resolveRequiredFolderId(index, params.path) - if ( - folderResourceConfig(params.resourceType).supportsLocking && - isEffectivelyLocked(index, folderId) - ) { - throw new Error('Folder is locked') - } + const resolved = await withFolderTreeLock( + params.workspaceId, + params.resourceType, + async (tx) => { + const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) + const folderId = resolveRequiredFolderId(index, params.path) + if ( + folderResourceConfig(params.resourceType).supportsLocking && + isEffectivelyLocked(index, folderId) + ) { + throw new Error('Folder is locked') + } - if (!params.recursive) { - const hasChildFolder = [...index.pathById.values()].some((candidate) => - candidate.startsWith(`${params.path}/`) - ) - const config = folderResourceConfig(params.resourceType) - const [child] = await tx - .select({ id: config.idColumn }) - .from(config.table) - .where( - and( - eq(config.folderIdColumn, folderId), - eq(config.workspaceColumn, params.workspaceId), - isNull(config.deletedColumn), - config.scope - ) + if (!params.recursive) { + const hasChildFolder = [...index.pathById.values()].some((candidate) => + candidate.startsWith(`${params.path}/`) ) - .limit(1) - if (hasChildFolder || child) throw new Error('Folder is not empty') + const config = folderResourceConfig(params.resourceType) + const [child] = await tx + .select({ id: config.idColumn }) + .from(config.table) + .where( + and( + eq(config.folderIdColumn, folderId), + eq(config.workspaceColumn, params.workspaceId), + isNull(config.deletedColumn), + config.scope + ) + ) + .limit(1) + if (hasChildFolder || child) throw new Error('Folder is not empty') + } + + const row = index.rowById.get(folderId) + if (!row) throw new Error('Folder not found') + return { + resourceType: params.resourceType, + folderId, + workspaceId: params.workspaceId, + userId: params.userId, + folderName: row.name, + folderPath: params.path, + } } + ) - const row = index.rowById.get(folderId) - if (!row) throw new Error('Folder not found') - return deleteFolderWithoutTreeLock({ - resourceType: params.resourceType, - folderId, - workspaceId: params.workspaceId, - userId: params.userId, - folderName: row.name, - folderPath: params.path, - }) - }) + const result = await deleteFolderWithoutTreeLock(resolved, null) return { ...result, path: result.success ? params.path : undefined } } catch (error) { return pathMutationError(error) @@ -474,6 +483,7 @@ export async function createFolder(params: CreateFolderParams): Promise { - return withFolderTreeLock(params.workspaceId, params.resourceType, () => - deleteFolderWithoutTreeLock(params) - ) + const existing = await withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => { + const [row] = await tx + .select({ deletedAt: folderTable.deletedAt }) + .from(folderTable) + .where( + and( + eq(folderTable.id, params.folderId), + eq(folderTable.workspaceId, params.workspaceId), + eq(folderTable.resourceType, params.resourceType) + ) + ) + .limit(1) + return row + }) + + if (!existing) { + return { success: false, error: 'Folder not found', errorCode: 'not_found' } + } + + return deleteFolderWithoutTreeLock(params, existing.deletedAt) } async function deleteFolderWithoutTreeLock( - params: DeleteFolderParams + params: DeleteFolderParams, + deletedAt: Date | null ): Promise { const { resourceType, folderId, workspaceId, userId, folderName, folderPath } = params const config = folderResourceConfig(resourceType) - const [existing] = await db - .select({ deletedAt: folderTable.deletedAt }) - .from(folderTable) - .where( - and( - eq(folderTable.id, folderId), - eq(folderTable.workspaceId, workspaceId), - eq(folderTable.resourceType, resourceType) - ) - ) - .limit(1) - - if (!existing) { - return { success: false, error: 'Folder not found', errorCode: 'not_found' } - } - // Resolve the timestamp before the subtree, because the subtree walk needs it: on a retry // it is what distinguishes folders this cascade already stamped from folders archived // independently. - const timestamp = existing.deletedAt ?? new Date() + const timestamp = deletedAt ?? new Date() const folderIds = await collectCascadeSubtreeIds( db, workspaceId, diff --git a/apps/sim/lib/table/orchestration/index.ts b/apps/sim/lib/table/orchestration/index.ts index 9fa267d7f33..c65e81b4789 100644 --- a/apps/sim/lib/table/orchestration/index.ts +++ b/apps/sim/lib/table/orchestration/index.ts @@ -6,5 +6,6 @@ export { performDeleteTableRow, performMoveTableToFolder, performRenameTable, + performUpdateTableDescription, performUpdateTableLocks, } from './tables' diff --git a/apps/sim/lib/table/orchestration/tables.ts b/apps/sim/lib/table/orchestration/tables.ts index 3c0abcf6ae0..e3b0024c707 100644 --- a/apps/sim/lib/table/orchestration/tables.ts +++ b/apps/sim/lib/table/orchestration/tables.ts @@ -10,7 +10,13 @@ import { generateRequestId } from '@/lib/core/utils/request' import { captureServerEvent } from '@/lib/posthog/server' import { TableLockedError } from '@/lib/table/mutation-locks' import { deleteRow } from '@/lib/table/rows/service' -import { deleteTable, moveTableToFolder, renameTable, updateTableLocks } from '@/lib/table/service' +import { + deleteTable, + moveTableToFolder, + renameTable, + updateTableDescription, + updateTableLocks, +} from '@/lib/table/service' import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS, @@ -198,6 +204,48 @@ export async function performRenameTable( } } +export interface PerformUpdateTableDescriptionParams { + table: TableDefinition + description: string | null + userId: string + requestId?: string + request?: OrchestrationRequestContext +} + +/** Updates a table description and records the metadata change. */ +export async function performUpdateTableDescription( + params: PerformUpdateTableDescriptionParams +): Promise { + const { table, description, userId, request } = params + const requestId = params.requestId ?? generateRequestId() + if (!table.workspaceId) { + return { success: false, error: 'Table is not in a workspace', errorCode: 'validation' } + } + + try { + const updated = await updateTableDescription( + table.id, + table.workspaceId, + description, + requestId + ) + recordAudit({ + workspaceId: table.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: updated.name, + description: `Updated description for table "${updated.name}"`, + metadata: { op: 'description' }, + ...(request ? { request } : {}), + }) + return { success: true } + } catch (error) { + return classifyTableMutation(error, requestId, table.id) + } +} + export interface PerformMoveTableParams { table: TableDefinition folderId: string | null diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 24925e41549..5fad77a6993 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -798,6 +798,34 @@ export async function renameTable( } } +/** Updates a table description without changing its schema or placement. */ +export async function updateTableDescription( + tableId: string, + workspaceId: string, + description: string | null, + requestId: string +): Promise<{ name: string }> { + const result = await db + .update(userTableDefinitions) + .set({ description, updatedAt: new Date() }) + .where( + and( + eq(userTableDefinitions.id, tableId), + eq(userTableDefinitions.workspaceId, workspaceId), + isNull(userTableDefinitions.archivedAt) + ) + ) + .returning({ name: userTableDefinitions.name }) + + if (result.length === 0) { + throw new OrchestrationError('not_found', `Table ${tableId} not found`) + } + + logger.info(`[${requestId}] Updated description for table ${tableId}`) + await notifyWorkspaceTablesChanged(workspaceId) + return result[0] +} + /** * Moves a table into `folderId`, or to the workspace root when it is `null`. * diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 717e778af26..8ca14de9e32 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -431,6 +431,7 @@ export async function createWorkspaceFileFolder(params: { const id = generateId() try { + const now = new Date() const [inserted] = await tx .insert(folderTable) .values({ @@ -443,6 +444,8 @@ export async function createWorkspaceFileFolder(params: { sortOrder: params.sortOrder ?? (sortOrderResult?.minSortOrder != null ? sortOrderResult.minSortOrder - 1 : 0), + createdAt: now, + updatedAt: now, }) .returning() return inserted @@ -1275,6 +1278,7 @@ export async function createWorkspaceFileFolderAtPath(params: { ) ) + const now = new Date() const [created] = await tx .insert(folderTable) .values({ @@ -1285,6 +1289,8 @@ export async function createWorkspaceFileFolderAtPath(params: { workspaceId: params.workspaceId, parentId, sortOrder: sortOrderResult?.minSortOrder != null ? sortOrderResult.minSortOrder - 1 : 0, + createdAt: now, + updatedAt: now, }) .returning() return created From c0c20bf7bcff541d311a76ec264adae19a8db363 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 5 Aug 2026 01:27:22 -0700 Subject: [PATCH 073/159] improvement(uploads): persist multipart sessions in postgres --- .../platform/self-hosting/object-storage.mdx | 43 +- .../platform/self-hosting/troubleshooting.mdx | 2 +- .../docs/en/platform/self-hosting/verify.mdx | 4 +- apps/docs/openapi-v2-files-audit.json | 8 - apps/docs/openapi-v2-knowledge.json | 45 - apps/docs/openapi-v2-tables.json | 8 - apps/sim/app/api/cron/cleanup-tasks/route.ts | 6 +- .../uploads/[uploadId]/complete/route.ts | 3 +- .../files/uploads/[uploadId]/parts/route.ts | 2 +- .../app/api/files/uploads/[uploadId]/route.ts | 2 +- .../app/api/files/uploads/finalizers.test.ts | 4 + apps/sim/app/api/files/uploads/route.test.ts | 12 +- .../uploads/[uploadId]/complete/route.ts | 3 +- .../uploads/[uploadId]/parts/route.ts | 2 +- .../documents/uploads/[uploadId]/route.ts | 2 +- .../imports/[importId]/complete/route.ts | 9 +- .../table/imports/[importId]/parts/route.ts | 2 +- .../uploads/[uploadId]/complete/route.ts | 3 +- .../files/uploads/[uploadId]/parts/route.ts | 2 +- .../api/v2/files/uploads/[uploadId]/route.ts | 2 +- .../app/api/v2/files/uploads/route.test.ts | 2 +- apps/sim/app/api/v2/files/uploads/utils.ts | 2 + .../uploads/[uploadId]/complete/route.test.ts | 6 +- .../uploads/[uploadId]/complete/route.ts | 3 +- .../uploads/[uploadId]/parts/route.ts | 2 +- .../documents/uploads/[uploadId]/route.ts | 2 +- .../[id]/documents/uploads/utils.test.ts | 2 +- .../knowledge/[id]/documents/uploads/utils.ts | 10 +- .../imports/[importId]/complete/route.test.ts | 32 +- .../imports/[importId]/complete/route.ts | 9 +- .../tables/imports/[importId]/parts/route.ts | 2 +- .../parts/[partNumber]/route.test.ts | 15 + .../[uploadId]/parts/[partNumber]/route.ts | 5 +- .../api/v2/uploads/[uploadId]/route.test.ts | 4 +- .../app/api/v2/uploads/[uploadId]/route.ts | 7 +- apps/sim/hooks/queries/tables.ts | 4 +- .../contracts/knowledge/upload-sessions.ts | 2 - apps/sim/lib/api/contracts/table-transfers.ts | 2 - apps/sim/lib/api/contracts/upload-sessions.ts | 2 - .../contracts/v2/__tests__/uploads.test.ts | 20 +- apps/sim/lib/api/contracts/v2/files.ts | 2 - apps/sim/lib/api/contracts/v2/knowledge.ts | 2 - apps/sim/lib/api/contracts/v2/tables.ts | 2 - apps/sim/lib/api/contracts/v2/uploads.ts | 24 +- .../table/orchestration/import-resource.ts | 8 +- .../lib/uploads/client/session-upload.test.ts | 9 +- apps/sim/lib/uploads/client/session-upload.ts | 14 +- .../lib/uploads/client/upload-session.test.ts | 18 +- apps/sim/lib/uploads/client/upload-session.ts | 34 +- .../sim/lib/uploads/core/upload-token.test.ts | 127 - apps/sim/lib/uploads/core/upload-token.ts | 309 - .../lib/uploads/providers/blob/client.test.ts | 58 +- apps/sim/lib/uploads/providers/blob/client.ts | 47 +- .../lib/uploads/providers/gcs/client.test.ts | 59 +- apps/sim/lib/uploads/providers/gcs/client.ts | 82 +- .../lib/uploads/providers/s3/client.test.ts | 62 +- apps/sim/lib/uploads/providers/s3/client.ts | 71 +- apps/sim/lib/uploads/upload-session/README.md | 29 +- .../uploads/upload-session/cleanup.test.ts | 12 +- .../sim/lib/uploads/upload-session/cleanup.ts | 4 +- .../uploads/upload-session/provider.test.ts | 122 +- .../lib/uploads/upload-session/provider.ts | 264 +- .../uploads/upload-session/service.test.ts | 635 +- .../sim/lib/uploads/upload-session/service.ts | 898 +- apps/sim/lib/uploads/upload-session/types.ts | 22 + .../db/migrations/0283_heavy_firebird.sql | 39 + .../db/migrations/meta/0283_snapshot.json | 18689 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 74 + packages/testing/src/mocks/schema.mock.ts | 31 + 70 files changed, 20278 insertions(+), 1773 deletions(-) delete mode 100644 apps/sim/lib/uploads/core/upload-token.test.ts delete mode 100644 apps/sim/lib/uploads/core/upload-token.ts create mode 100644 apps/sim/lib/uploads/upload-session/types.ts create mode 100644 packages/db/migrations/0283_heavy_firebird.sql create mode 100644 packages/db/migrations/meta/0283_snapshot.json diff --git a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx index 7233cb1bc70..7a6bc9c8a69 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx @@ -78,7 +78,6 @@ cat > /tmp/cors.json <<'EOF' "AllowedOrigins": ["https://sim.yourdomain.com"], "AllowedMethods": ["GET", "PUT"], "AllowedHeaders": ["*"], - "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3600 } ] @@ -91,10 +90,6 @@ for name in workspace-files knowledge-base execution-files chat-files \ done ``` - - `ExposeHeaders` **must** include `ETag`. Files larger than 50 MB use multipart uploads, and the browser reads each part's `ETag` to complete the upload — CORS hides the header otherwise and large uploads fail at the final step. - - Set `AllowedOrigins` to your exact Sim origin (scheme + host, no trailing slash). Add every origin users reach Sim from, including an apex/`www` pair if both are live. @@ -239,15 +234,15 @@ AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME=workspace-logos Direct browser uploads require a Blob service CORS rule on the storage account. Allow your exact Sim origin, `GET` and `PUT`, the `Content-Type` header, and the `x-ms-*` prefix used by signed blob -and metadata headers: +and metadata headers. Small-file uploads also send `If-None-Match` so a signed URL cannot overwrite +an existing final object: ```bash az storage cors add \ --services b \ --methods GET PUT \ --origins https://sim.yourdomain.com \ - --allowed-headers content-type 'x-ms-*' \ - --exposed-headers ETag \ + --allowed-headers content-type if-none-match 'x-ms-*' \ --max-age 3600 \ --account-name mystorageaccount \ --account-key '' @@ -295,7 +290,7 @@ cat > /tmp/cors.json <<'EOF' "method": ["GET", "PUT"], "responseHeader": [ "Content-Type", - "ETag", + "x-goog-if-generation-match", "x-goog-meta-uploadid", "x-goog-meta-originalname", "x-goog-meta-uploadedat", @@ -319,7 +314,7 @@ done ``` - Header names must be listed individually — GCS CORS matches `responseHeader` entries exactly and does not support wildcards like `x-goog-meta-*`. `ETag` is required because large-file multipart uploads read each part's `ETag` from the browser, and CORS hides the header otherwise. + Header names must be listed individually — GCS CORS matches `responseHeader` entries exactly and does not support wildcards like `x-goog-meta-*`. `x-goog-if-generation-match` makes small-file uploads create-only; Sim obtains multipart ETags from GCS during completion rather than exposing them to the browser. @@ -467,25 +462,25 @@ The same browser-reachability and CORS requirements apply. -## Configure temporary upload cleanup +## Configure incomplete multipart cleanup -Sim stages every direct upload under the `upload-sessions/` prefix before promoting it to its final, -immutable object key. Apply the cleanup policy to **every** purpose-specific bucket or container -configured above: +Sim uploads directly to a create-only final object key and keeps upload-session state in PostgreSQL. +The cleanup cron claims expired sessions before deleting an uploaded object or aborting its provider +multipart state. Configure provider lifecycle cleanup as a second line of defense for multipart +state that outlives its database row: -- On AWS S3 and Google Cloud Storage, expire objects under `upload-sessions/` after two days and - abort incomplete multipart uploads after two days. -- On Azure Blob, expire committed blobs under `upload-sessions/` after two days. Azure automatically - removes uncommitted blocks after seven days. -- For an S3-compatible provider, configure both rules when its lifecycle implementation supports - them. Check the provider's documentation because lifecycle feature support varies. +- On AWS S3 and Google Cloud Storage, abort incomplete multipart uploads after two days on every + purpose-specific bucket. +- Azure automatically removes uncommitted blocks after seven days. +- For an S3-compatible provider, configure incomplete-multipart cleanup when its lifecycle + implementation supports it. Check the provider's documentation because support varies. -The two-day window exceeds the 24-hour upload-token lifetime and leaves time to retry completion. -Do not apply this prefix rule to final objects outside `upload-sessions/`. +The provider window should exceed the 24-hour upload-session lifetime so an in-progress completion +can still recover. Do not add an object-expiration rule for final upload keys. - Configure both expiration and incomplete-multipart cleanup where available. Expiring staged - objects alone does not necessarily remove abandoned multipart parts. + Object expiration and incomplete-multipart cleanup are different lifecycle operations. Configure + the incomplete-multipart operation; expiring objects does not remove abandoned multipart parts. ## Verify it works diff --git a/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx b/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx index ea28f8b3a1f..c4b8406ad5d 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx @@ -193,7 +193,7 @@ Both pods must have `REDIS_URL`. On Helm they share one Secret, so setting it un The bucket's CORS policy does not allow your Sim origin. Uploads go directly from the browser to object storage via presigned `PUT`, so server-side configuration being correct is not enough. -If small uploads succeed but files over 50 MB fail at the last step, `ETag` is missing from the CORS exposed headers — multipart uploads read it from the browser. See [Object Storage](/platform/self-hosting/object-storage). +If small uploads succeed but files over 50 MB fail during completion, check the app logs for the provider's part-listing request. The server completes multipart uploads from provider-authoritative state; for S3, its identity needs `s3:ListMultipartUploadParts`. See [Object Storage](/platform/self-hosting/object-storage). ## Agent Output Arrives All at Once diff --git a/apps/docs/content/docs/en/platform/self-hosting/verify.mdx b/apps/docs/content/docs/en/platform/self-hosting/verify.mdx index 77bfb439380..d34e35a2ad3 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/verify.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/verify.mdx @@ -18,7 +18,7 @@ Run this after a first install, after an upgrade, and after a restore. Each step | 4 | Open the same workflow in a second browser window and edit | Cross-replica collaboration | With >1 replica this needs [Redis](/platform/self-hosting/redis) | | 5 | Paste a model API key in settings and run a two-block workflow | Execution engine, credential encryption, outbound network | App logs; check `ENCRYPTION_KEY` is set and outbound egress is allowed | | 6 | Upload a small file in Files | File storage end to end | With object storage configured: presigned URL + bucket CORS. On local disk: the upload proxies through the app | -| 7 | Upload a file larger than 50 MB | Multipart upload path (object storage only) | Confirm `ETag` is in the bucket's CORS exposed headers | +| 7 | Upload a file larger than 50 MB | Multipart upload path (object storage only) | Check app logs for provider part-listing or completion errors | | 8 | Create a knowledge base and upload a PDF | Document parsing, embeddings, pgvector | Needs a hosted embedding provider — see below | | 9 | Invite a teammate from workspace settings | Email delivery | App logs for the mailer; see [Email](/platform/self-hosting/email) | | 10 | Connect an integration account | OAuth configuration | Redirect URI mismatch → see [Integrations & OAuth](/platform/self-hosting/integrations-oauth) | @@ -72,7 +72,7 @@ All six should be present on Compose: `simstudio`, `realtime`, `db`, `redis`, `c **Step 5 fails — execution errors.** Check outbound connectivity to the model provider, then the app logs. If the error is about decrypting a credential, `ENCRYPTION_KEY` differs from the one that encrypted it. -**Step 6 or 7 fails.** With object storage configured, a CORS error in the browser console means the bucket policy does not allow your Sim origin; step 7 failing while step 6 passes specifically means `ETag` is missing from the exposed headers. On local-disk storage there is no CORS involved — uploads proxy through the app, so look at the app logs and the proxy body-size limit instead. +**Step 6 or 7 fails.** With object storage configured, a CORS error in the browser console means the bucket policy does not allow your Sim origin or the signed upload headers. If step 7 fails only during completion, check the app logs and verify the server identity can list multipart parts (for S3, `s3:ListMultipartUploadParts`). On local-disk storage there is no CORS involved — uploads proxy through the app, so look at the app logs and the proxy body-size limit instead. **Step 8 fails — knowledge base upload errors.** Knowledge bases need a hosted embedding provider — OpenAI, Azure OpenAI, or Gemini. There is no local embedding backend. If a key is set, check pgvector is installed on the database. diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 27f2b0f1c8e..fe3d738c2e0 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -435,14 +435,6 @@ "$ref": "#/components/parameters/UploadTokenHeader" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": {} - } - } - }, "responses": { "200": { "description": "The completed upload and registered file.", diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index e3d094a24a3..7275b766e49 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -1008,16 +1008,6 @@ "$ref": "#/components/parameters/WorkspaceIdQuery" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CompleteUploadBody" - } - } - } - }, "responses": { "200": { "description": "The completed upload and queued knowledge document.", @@ -2197,41 +2187,6 @@ } } }, - "CompleteUploadBody": { - "oneOf": [ - { - "type": "object", - "additionalProperties": false, - "required": ["parts"], - "properties": { - "parts": { - "type": "array", - "minItems": 1, - "maxItems": 640, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["partNumber"], - "properties": { - "partNumber": { - "type": "integer", - "minimum": 1 - }, - "etag": { - "type": "string", - "minLength": 1 - } - } - } - } - } - }, - { - "type": "object", - "additionalProperties": false - } - ] - }, "DocumentSummary": { "type": "object", "description": "Summary representation of a document, returned in list operations and as the upload acknowledgement.", diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 2a992c137e3..e626034d61e 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -3468,14 +3468,6 @@ "$ref": "#/components/parameters/UploadTokenHeader" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": {} - } - } - }, "responses": { "200": { "description": "The queued import resource.", diff --git a/apps/sim/app/api/cron/cleanup-tasks/route.ts b/apps/sim/app/api/cron/cleanup-tasks/route.ts index 75b31492a19..184cd6fc637 100644 --- a/apps/sim/app/api/cron/cleanup-tasks/route.ts +++ b/apps/sim/app/api/cron/cleanup-tasks/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { cleanupExpiredUploadSessions } from '@/lib/uploads/upload-session/service' export const dynamic = 'force-dynamic' @@ -13,11 +14,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const authError = verifyCronAuth(request, 'task cleanup') if (authError) return authError + const uploadSessions = await cleanupExpiredUploadSessions() const result = await dispatchCleanupJobs('cleanup-tasks') - logger.info('Task cleanup jobs dispatched', result) + logger.info('Task cleanup jobs dispatched', { ...result, uploadSessions }) - return NextResponse.json({ triggered: true, ...result }) + return NextResponse.json({ triggered: true, ...result, uploadSessions }) } catch (error) { logger.error('Failed to dispatch task cleanup jobs:', { error }) return NextResponse.json({ error: 'Failed to dispatch task cleanup' }, { status: 500 }) diff --git a/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts index 8f8e75574f5..6ed8d6d1e35 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts @@ -22,7 +22,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Uploa if (!parsed.success) return parsed.response try { - const session = getOwnedUploadSession({ + const session = await getOwnedUploadSession({ uploadId: parsed.data.params.uploadId, uploadToken: parsed.data.headers['upload-token'], userId: actor.id, @@ -30,7 +30,6 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Uploa await reauthorizeUploadPurpose(actor.id, session) const completed = await completeUploadSession({ session, - completion: parsed.data.body, finalize: (claimed) => finalizeUploadPurpose({ session: claimed, actor, request }), }) return NextResponse.json({ diff --git a/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts index 5c79744cf75..b707f196567 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts @@ -17,7 +17,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Uploa if (!parsed.success) return parsed.response try { - const session = getOwnedUploadSession({ + const session = await getOwnedUploadSession({ uploadId: parsed.data.params.uploadId, uploadToken: parsed.data.headers['upload-token'], userId: actor.id, diff --git a/apps/sim/app/api/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/route.ts index e1cef9cf378..8887bfbb593 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/route.ts @@ -21,7 +21,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Upl if (!parsed.success) return parsed.response try { - const session = getOwnedUploadSession({ + const session = await getOwnedUploadSession({ uploadId: parsed.data.params.uploadId, uploadToken: parsed.data.headers['upload-token'], userId: actor.id, diff --git a/apps/sim/app/api/files/uploads/finalizers.test.ts b/apps/sim/app/api/files/uploads/finalizers.test.ts index a257244e0ef..8f22cb0b353 100644 --- a/apps/sim/app/api/files/uploads/finalizers.test.ts +++ b/apps/sim/app/api/files/uploads/finalizers.test.ts @@ -99,11 +99,15 @@ const uploadSession = { method: 'put' as const, storageContext: 'workspace-logos' as const, storageKey: metadataRow.key, + finalKey: metadataRow.key, storageProvider: 's3' as const, providerUploadId: null, + providerObjectVersion: null, fileName: 'logo.png', contentType: 'image/png', fileSize: 128, + partSize: null, + partCount: null, status: 'uploading' as const, metadata: {}, uploadToken: 'signed-token', diff --git a/apps/sim/app/api/files/uploads/route.test.ts b/apps/sim/app/api/files/uploads/route.test.ts index 1d6d3e0576f..4ea8c61428a 100644 --- a/apps/sim/app/api/files/uploads/route.test.ts +++ b/apps/sim/app/api/files/uploads/route.test.ts @@ -73,11 +73,15 @@ function session(overrides: Record = {}) { method: 'put', storageContext: 'profile-pictures', storageKey: 'profile-pictures/upload-1-avatar.png', + finalKey: 'profile-pictures/upload-1-avatar.png', storageProvider: 's3', providerUploadId: null, + providerObjectVersion: null, fileName: 'avatar.png', contentType: 'image/png', fileSize: 128, + partSize: null, + partCount: null, status: 'uploading', metadata: {}, uploadToken: 'signed-token', @@ -262,11 +266,7 @@ describe('/api/files/uploads', () => { }) const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'upload-token': 'signed-token', - }, - body: '{}', + headers: { 'upload-token': 'signed-token' }, }) const response = await completeUpload(request, { @@ -277,7 +277,7 @@ describe('/api/files/uploads', () => { expect(response.status).toBe(200) expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(actor.id, 'workspace', 'workspace-1') expect(mockCompleteUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ session: logoSession, completion: {} }) + expect.objectContaining({ session: logoSession }) ) expect(body).toEqual({ data: expect.objectContaining({ diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts index 7426a000fbd..0c9b4be4b23 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts @@ -36,7 +36,7 @@ export const POST = withRouteHandler( if (access instanceof NextResponse) return access const requestId = generateRequestId() try { - const upload = getOwnedKnowledgeDocumentUpload({ + const upload = await getOwnedKnowledgeDocumentUpload({ knowledgeBaseId, uploadId, workspaceId, @@ -45,7 +45,6 @@ export const POST = withRouteHandler( }) const completed = await completeUploadSession({ session: upload, - completion: parsed.data.body, finalize: (claimed) => finalizeKnowledgeDocumentUpload({ claimed, diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts index 38390d4c13e..da327ab4703 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts @@ -33,7 +33,7 @@ export const POST = withRouteHandler( }) if (access instanceof NextResponse) return access try { - const upload = getOwnedKnowledgeDocumentUpload({ + const upload = await getOwnedKnowledgeDocumentUpload({ knowledgeBaseId, uploadId, workspaceId, diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts index bc37c1026c2..6a44d82d895 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts @@ -32,7 +32,7 @@ export const DELETE = withRouteHandler( }) if (access instanceof NextResponse) return access try { - const upload = getOwnedKnowledgeDocumentUpload({ + const upload = await getOwnedKnowledgeDocumentUpload({ knowledgeBaseId, uploadId, workspaceId, diff --git a/apps/sim/app/api/table/imports/[importId]/complete/route.ts b/apps/sim/app/api/table/imports/[importId]/complete/route.ts index 54b93e41b19..0de609c4c0d 100644 --- a/apps/sim/app/api/table/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/complete/route.ts @@ -9,10 +9,7 @@ import { startUploadedTableImport, toV2TableImport, } from '@/lib/table/orchestration/import-resource' -import { - completeUploadSession, - validateUploadCompletion, -} from '@/lib/uploads/upload-session/service' +import { completeUploadSession } from '@/lib/uploads/upload-session/service' import { orchestrationErrorResponse } from '@/app/api/table/utils' interface ImportRouteParams { @@ -27,13 +24,12 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Impor const parsed = await parseRequest(completeTableImportResourceContract, request, context) if (!parsed.success) return parsed.response try { - const upload = getOwnedTableImportUpload({ + const upload = await getOwnedTableImportUpload({ importId: parsed.data.params.importId, workspaceId: parsed.data.query.workspaceId, userId: auth.userId, uploadToken: parsed.data.headers['upload-token'], }) - validateUploadCompletion(upload, parsed.data.body) const existing = await findOwnedTableImport({ importId: upload.id, workspaceId: parsed.data.query.workspaceId, @@ -42,7 +38,6 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Impor if (existing) return NextResponse.json({ data: toV2TableImport(existing) }) const completed = await completeUploadSession({ session: upload, - completion: parsed.data.body, finalize: async () => ({ value: null }), }) return NextResponse.json({ diff --git a/apps/sim/app/api/table/imports/[importId]/parts/route.ts b/apps/sim/app/api/table/imports/[importId]/parts/route.ts index 130fe570a29..f86289bf5a2 100644 --- a/apps/sim/app/api/table/imports/[importId]/parts/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/parts/route.ts @@ -19,7 +19,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Impor const parsed = await parseRequest(createTableImportPartUrlsContract, request, context) if (!parsed.success) return parsed.response try { - const upload = getOwnedTableImportUpload({ + const upload = await getOwnedTableImportUpload({ importId: parsed.data.params.importId, workspaceId: parsed.data.query.workspaceId, userId: auth.userId, diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts index 9739994d62d..1378f35502d 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts @@ -40,7 +40,7 @@ export const POST = withRouteHandler( const { workspaceId } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - const session = getOwnedUploadSession({ + const session = await getOwnedUploadSession({ uploadId, workspaceId, userId, @@ -49,7 +49,6 @@ export const POST = withRouteHandler( }) const result = await completeUploadSession({ session, - completion: parsed.data.body, finalize: async (claimed) => { const finalized = await finalizeWorkspaceFileUpload({ session: claimed, diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts index f27f759faa7..bec4c2dc8fa 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts @@ -38,7 +38,7 @@ export const POST = withRouteHandler( const { workspaceId } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - const session = getOwnedUploadSession({ + const session = await getOwnedUploadSession({ uploadId, workspaceId, userId, diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts index 9561a1abcfa..3ab1354f063 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts @@ -39,7 +39,7 @@ export const DELETE = withRouteHandler( const { workspaceId } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) - const session = getOwnedUploadSession({ + const session = await getOwnedUploadSession({ uploadId, workspaceId, userId, diff --git a/apps/sim/app/api/v2/files/uploads/route.test.ts b/apps/sim/app/api/v2/files/uploads/route.test.ts index 25f2a733ed0..884dfbe87ce 100644 --- a/apps/sim/app/api/v2/files/uploads/route.test.ts +++ b/apps/sim/app/api/v2/files/uploads/route.test.ts @@ -62,9 +62,9 @@ const UPLOAD_SESSION = { storageContext: 'workspace', storageKey: `${WORKSPACE_ID}/file.csv`, finalKey: `${WORKSPACE_ID}/file.csv`, - stagingKey: 'upload-sessions/upload-1/file.csv', storageProvider: 's3', providerUploadId: null, + providerObjectVersion: null, fileName: 'file.csv', contentType: 'text/csv', fileSize: 10, diff --git a/apps/sim/app/api/v2/files/uploads/utils.ts b/apps/sim/app/api/v2/files/uploads/utils.ts index 45cebe61bab..e90c25c1fda 100644 --- a/apps/sim/app/api/v2/files/uploads/utils.ts +++ b/apps/sim/app/api/v2/files/uploads/utils.ts @@ -23,9 +23,11 @@ export function toV2FileUpload( function uploadStatus(status: string): V2UploadStatus { if ( status !== 'uploading' && + status !== 'completing' && status !== 'finalizing' && status !== 'completed' && status !== 'failed' && + status !== 'aborting' && status !== 'aborted' && status !== 'expired' ) { 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 91a07f307f5..62a81397f64 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 @@ -57,9 +57,9 @@ const SESSION = { storageContext: 'knowledge-base', storageKey: 'kb/guide.pdf', finalKey: 'kb/guide.pdf', - stagingKey: 'upload-sessions/upload-1/guide.pdf', storageProvider: 's3', providerUploadId: 'provider-1', + providerObjectVersion: null, fileName: 'guide.pdf', contentType: 'application/pdf', fileSize: 1024, @@ -106,8 +106,7 @@ function request() { `http://localhost:3000/api/v2/knowledge/kb-1/documents/uploads/upload-1/complete?workspaceId=${WORKSPACE_ID}`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'upload-token': 'token' }, - body: JSON.stringify({ parts: [{ partNumber: 1, etag: 'etag-1' }] }), + headers: { 'upload-token': 'token' }, } ), { params: Promise.resolve({ id: 'kb-1', uploadId: 'upload-1' }) } @@ -154,7 +153,6 @@ describe('POST knowledge-document multipart completion', () => { expect(mockCompleteUploadSession).toHaveBeenCalledWith( expect.objectContaining({ session: SESSION, - completion: { parts: [{ partNumber: 1, etag: 'etag-1' }] }, }) ) }) 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 0da551ca60d..92bc7c69b8a 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 @@ -59,7 +59,7 @@ export const POST = withRouteHandler( }) if (access instanceof NextResponse) return access - const session = getOwnedKnowledgeDocumentUpload({ + const session = await getOwnedKnowledgeDocumentUpload({ knowledgeBaseId, uploadId, workspaceId, @@ -68,7 +68,6 @@ export const POST = withRouteHandler( }) const result = await completeUploadSession({ session, - completion: parsed.data.body, finalize: (claimed) => finalizeKnowledgeDocumentUpload({ claimed, 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 e69f1eece4b..2bf941b0eb0 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 @@ -53,7 +53,7 @@ export const POST = withRouteHandler( }) if (access instanceof NextResponse) return access - const session = getOwnedKnowledgeDocumentUpload({ + const session = await getOwnedKnowledgeDocumentUpload({ knowledgeBaseId, uploadId, 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 108f4137573..9fd10cad84e 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 @@ -51,7 +51,7 @@ export const DELETE = withRouteHandler( }) if (access instanceof NextResponse) return access - const session = getOwnedKnowledgeDocumentUpload({ + const session = await getOwnedKnowledgeDocumentUpload({ knowledgeBaseId, uploadId, workspaceId, diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts index 564388df5d2..d7f25983576 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts @@ -54,9 +54,9 @@ const CLAIMED: UploadSessionRecord = { storageContext: 'knowledge-base', storageKey: 'kb/guide.pdf', finalKey: 'kb/guide.pdf', - stagingKey: 'upload-sessions/upload-1/guide.pdf', storageProvider: 's3', providerUploadId: 'provider-1', + providerObjectVersion: null, fileName: 'guide.pdf', contentType: 'application/pdf', fileSize: 1024, 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 4e4122221f0..4ef4bf2d8fa 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 @@ -81,13 +81,13 @@ export async function resolveKnowledgeDocumentUploadBilling(params: { return attribution } -export function getOwnedKnowledgeDocumentUpload(params: { +export async function getOwnedKnowledgeDocumentUpload(params: { knowledgeBaseId: string uploadId: string workspaceId: string userId: string uploadToken: string -}): UploadSessionRecord { +}): Promise { return getOwnedUploadSession({ uploadId: params.uploadId, workspaceId: params.workspaceId, @@ -194,10 +194,8 @@ function knowledgeDocumentInputFor(session: UploadSessionRecord) { /** * Aborts an upload session, refusing once a document is bound to it. * - * Upload sessions are stateless — the signed token always reconstructs as `uploading`, so this - * guard preserves the completed state exposed by the document binding. Provider aborts must - * also remain non-destructive after commit because an in-flight completion is not visible here - * until its document transaction commits. + * The document binding remains the domain-level completion authority while the upload row + * protects the provider object lifecycle. */ export async function abortKnowledgeDocumentUpload( session: UploadSessionRecord, diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts index 637e56f8f99..137221ed511 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts @@ -3,7 +3,6 @@ */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { OrchestrationError } from '@/lib/core/orchestration/types' const { mockCheckRateLimit, @@ -13,7 +12,6 @@ const { mockStartUploadedTableImport, mockToV2TableImport, mockCompleteUploadSession, - mockValidateUploadCompletion, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceScope: vi.fn(), @@ -22,7 +20,6 @@ const { mockStartUploadedTableImport: vi.fn(), mockToV2TableImport: vi.fn(), mockCompleteUploadSession: vi.fn(), - mockValidateUploadCompletion: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -47,7 +44,6 @@ vi.mock('@/lib/table/orchestration/import-resource', () => ({ vi.mock('@/lib/uploads/upload-session/service', () => ({ completeUploadSession: mockCompleteUploadSession, - validateUploadCompletion: mockValidateUploadCompletion, })) import { POST } from '@/app/api/v2/tables/imports/[importId]/complete/route' @@ -67,17 +63,15 @@ const UPLOAD = { userId: 'user-1', } -function request(body: Record = { parts: [{ partNumber: 1, etag: 'etag-1' }] }) { +function request() { return POST( new NextRequest( `http://localhost:3000/api/v2/tables/imports/import-1/complete?workspaceId=${WORKSPACE_ID}`, { method: 'POST', headers: { - 'Content-Type': 'application/json', 'upload-token': 'signed-upload-token', }, - body: JSON.stringify(body), } ), { params: Promise.resolve({ importId: 'import-1' }) } @@ -113,30 +107,11 @@ describe('POST /api/v2/tables/imports/[importId]/complete', () => { workspaceId: WORKSPACE_ID, userId: 'user-1', }) - expect(mockValidateUploadCompletion).toHaveBeenCalledWith(UPLOAD, { - parts: [{ partNumber: 1, etag: 'etag-1' }], - }) expect(mockCompleteUploadSession).not.toHaveBeenCalled() expect(mockStartUploadedTableImport).not.toHaveBeenCalled() }) - it('validates completion shape before returning an existing table job', async () => { - mockFindOwnedTableImport.mockResolvedValue({ id: 'import-1' }) - mockValidateUploadCompletion.mockImplementationOnce(() => { - throw new OrchestrationError('validation', 'Multipart completion requires parts') - }) - - const response = await request({}) - - expect(response.status).toBe(400) - expect(mockFindOwnedTableImport).not.toHaveBeenCalled() - expect(mockCompleteUploadSession).not.toHaveBeenCalled() - }) - - it.each([ - ['PUT', {}], - ['multipart', { parts: [{ partNumber: 1, etag: 'etag-1' }] }], - ])('forwards a %s completion body and starts the import job', async (_method, completion) => { + it('completes by upload id and starts the import job', async () => { const started = { id: 'import-1', tableId: 'table-1', status: 'running' } const responseBody = { id: 'import-1', tableId: 'table-1', status: 'processing' } mockFindOwnedTableImport.mockResolvedValue(null) @@ -148,12 +123,11 @@ describe('POST /api/v2/tables/imports/[importId]/complete', () => { mockStartUploadedTableImport.mockResolvedValue(started) mockToV2TableImport.mockReturnValue(responseBody) - const response = await request(completion) + const response = await request() expect(response.status).toBe(200) expect(mockCompleteUploadSession).toHaveBeenCalledWith({ session: UPLOAD, - completion, finalize: expect.any(Function), }) expect(mockStartUploadedTableImport).toHaveBeenCalledWith(UPLOAD) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts index 17860487743..b001a3a053d 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts @@ -10,10 +10,7 @@ import { startUploadedTableImport, toV2TableImport, } from '@/lib/table/orchestration/import-resource' -import { - completeUploadSession, - validateUploadCompletion, -} from '@/lib/uploads/upload-session/service' +import { completeUploadSession } from '@/lib/uploads/upload-session/service' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { @@ -47,13 +44,12 @@ export const POST = withRouteHandler( const { workspaceId } = parsed.data.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const upload = getOwnedTableImportUpload({ + const upload = await getOwnedTableImportUpload({ importId: parsed.data.params.importId, workspaceId, userId, uploadToken: parsed.data.headers['upload-token'], }) - validateUploadCompletion(upload, parsed.data.body) const existing = await findOwnedTableImport({ importId: upload.id, workspaceId, @@ -62,7 +58,6 @@ export const POST = withRouteHandler( if (existing) return v2Data(toV2TableImport(existing), { rateLimit }) const completed = await completeUploadSession({ session: upload, - completion: parsed.data.body, finalize: async () => ({ value: null }), }) const started = await startUploadedTableImport(completed.session) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts index 1d419975ac5..6481d8ba478 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts @@ -38,7 +38,7 @@ export const POST = withRouteHandler( const { workspaceId } = parsed.data.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const session = getOwnedTableImportUpload({ + const session = await getOwnedTableImportUpload({ importId: parsed.data.params.importId, workspaceId, userId, 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 18fe9a6e23f..f5cc527da7d 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 @@ -36,6 +36,7 @@ const SESSION = { storageProvider: 'local', method: 'multipart', status: 'uploading', + expiresAt: new Date('2999-01-01T00:00:00.000Z'), } as const describe('PUT /api/v2/uploads/[uploadId]/parts/[partNumber]', () => { @@ -82,6 +83,20 @@ describe('PUT /api/v2/uploads/[uploadId]/parts/[partNumber]', () => { expect(mockExpectedUploadPartSize).not.toHaveBeenCalled() expect(mockWriteLocalMultipartPart).not.toHaveBeenCalled() }) + + it('rejects expired upload sessions before writing the part', async () => { + mockVerifyUploadSessionToken.mockReturnValue({ + ...SESSION, + expiresAt: new Date('2000-01-01T00:00:00.000Z'), + }) + + const response = await request() + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toEqual({ error: 'Upload session has expired' }) + expect(mockExpectedUploadPartSize).not.toHaveBeenCalled() + expect(mockWriteLocalMultipartPart).not.toHaveBeenCalled() + }) }) function request(options?: { contentLength?: string | null }) { 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 8e7c8cf4933..934e64d839e 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 @@ -26,7 +26,7 @@ export const PUT = withRouteHandler( const token = request.nextUrl.searchParams.get('token') ?? '' let session: UploadSessionRecord try { - session = verifyUploadSessionToken(token) + session = await verifyUploadSessionToken(token) } catch { return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 }) } @@ -39,6 +39,9 @@ export const PUT = withRouteHandler( if (session.status !== 'uploading') { return NextResponse.json({ error: `Upload session is ${session.status}` }, { status: 409 }) } + if (session.expiresAt.getTime() <= Date.now()) { + return NextResponse.json({ error: 'Upload session has expired' }, { status: 409 }) + } if (session.method !== 'multipart') { return NextResponse.json({ error: 'PUT upload sessions do not have parts' }, { status: 409 }) } 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 22e18916d89..71d92a6d22d 100644 --- a/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts +++ b/apps/sim/app/api/v2/uploads/[uploadId]/route.test.ts @@ -39,9 +39,9 @@ const SESSION = { storageContext: 'workspace', storageKey: 'workspace/workspace-1/file.bin', finalKey: 'workspace/workspace-1/file.bin', - stagingKey: 'upload-sessions/upload-1/file.bin', storageProvider: 'local', providerUploadId: null, + providerObjectVersion: null, fileName: 'file.bin', contentType: 'application/octet-stream', fileSize: 3, @@ -76,7 +76,7 @@ describe('PUT /api/v2/uploads/[uploadId]', () => { }) expect(mockWriteLocalPut).toHaveBeenCalledWith({ uploadId: 'upload-1', - stagingKey: 'upload-sessions/upload-1/file.bin', + key: 'workspace/workspace-1/file.bin', body: expect.any(ReadableStream), expectedSize: 3, contentType: 'application/octet-stream', diff --git a/apps/sim/app/api/v2/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/uploads/[uploadId]/route.ts index c44c33489a2..52ec40a4cdb 100644 --- a/apps/sim/app/api/v2/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/uploads/[uploadId]/route.ts @@ -20,7 +20,7 @@ export const PUT = withRouteHandler( let session try { - session = getOwnedUploadSession({ + session = await getOwnedUploadSession({ uploadId: parsed.data.params.uploadId, uploadToken: parsed.data.headers['upload-token'], }) @@ -31,6 +31,9 @@ export const PUT = withRouteHandler( if (session.storageProvider !== 'local' || session.method !== 'put') { return NextResponse.json({ error: 'Upload URL does not match this session' }, { status: 403 }) } + if (session.status !== 'uploading') { + return NextResponse.json({ error: `Upload session is ${session.status}` }, { status: 409 }) + } if (session.expiresAt.getTime() <= Date.now()) { return NextResponse.json({ error: 'Upload session has expired' }, { status: 409 }) } @@ -56,7 +59,7 @@ export const PUT = withRouteHandler( try { await writeLocalPutObject({ uploadId: session.id, - stagingKey: session.stagingKey, + key: session.finalKey, body: request.body, expectedSize: session.fileSize, contentType: session.contentType, diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index c734bc01939..bafd798b470 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -87,7 +87,6 @@ import { updateWorkflowGroupContract, } from '@/lib/api/contracts/tables' import type { V2TableImportSource, V2TableImportTarget } from '@/lib/api/contracts/v2/tables' -import type { V2CompleteUploadBody } from '@/lib/api/contracts/v2/uploads' import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons' import type { CsvHeaderMapping, @@ -1778,12 +1777,11 @@ async function createAndUploadTableImport(params: { onProgress: params.onProgress ? (event: UploadProgressEvent) => params.onProgress?.(event.percent) : undefined, - complete: async (body: V2CompleteUploadBody) => { + complete: async () => { const response = await requestJson(completeTableImportResourceContract, { params: { importId: session.id }, query: { workspaceId: params.workspaceId }, headers: { 'upload-token': uploadToken }, - body, }) return response.data }, diff --git a/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts b/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts index 805b40a6936..01340182786 100644 --- a/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts +++ b/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts @@ -8,7 +8,6 @@ import { } from '@/lib/api/contracts/v2/knowledge' import { v2DataResponse } from '@/lib/api/contracts/v2/shared' import { - v2CompleteUploadBodySchema, v2PartUrlsBodySchema, v2PartUrlsDataSchema, v2UploadTokenHeadersSchema, @@ -50,6 +49,5 @@ export const completeKnowledgeDocumentUploadContract = defineRouteContract({ params: v2KnowledgeDocumentUploadParamsSchema, query: v2UploadKnowledgeDocumentQuerySchema, headers: v2UploadTokenHeadersSchema, - body: v2CompleteUploadBodySchema, response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentUploadSchema) }, }) diff --git a/apps/sim/lib/api/contracts/table-transfers.ts b/apps/sim/lib/api/contracts/table-transfers.ts index 3b83e074603..a1849b8736c 100644 --- a/apps/sim/lib/api/contracts/table-transfers.ts +++ b/apps/sim/lib/api/contracts/table-transfers.ts @@ -12,7 +12,6 @@ import { v2TableTransferWorkspaceQuerySchema, } from '@/lib/api/contracts/v2/tables' import { - v2CompleteUploadBodySchema, v2OptionalUploadTokenHeadersSchema, v2PartUrlsBodySchema, v2PartUrlsDataSchema, @@ -59,7 +58,6 @@ export const completeTableImportResourceContract = defineRouteContract({ params: v2TableImportParamsSchema, query: v2TableTransferWorkspaceQuerySchema, headers: v2UploadTokenHeadersSchema, - body: v2CompleteUploadBodySchema, response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, }) diff --git a/apps/sim/lib/api/contracts/upload-sessions.ts b/apps/sim/lib/api/contracts/upload-sessions.ts index 4e3a5153f3f..2f4fdbbe1ac 100644 --- a/apps/sim/lib/api/contracts/upload-sessions.ts +++ b/apps/sim/lib/api/contracts/upload-sessions.ts @@ -4,7 +4,6 @@ import { defineRouteContract } from '@/lib/api/contracts/types' import { v2FileSchema } from '@/lib/api/contracts/v2/files' import { v2DataResponse } from '@/lib/api/contracts/v2/shared' import { - v2CompleteUploadBodySchema, v2PartUrlsBodySchema, v2PartUrlsDataSchema, v2UploadStatusSchema, @@ -191,7 +190,6 @@ export const completeInternalFileUploadContract = defineRouteContract({ path: '/api/files/uploads/[uploadId]/complete', params: internalFileUploadParamsSchema, headers: v2UploadTokenHeadersSchema, - body: v2CompleteUploadBodySchema, response: { mode: 'json', schema: v2DataResponse(internalFileUploadSessionSchema) }, }) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/uploads.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/uploads.test.ts index b301ab84ea1..5f879e60980 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/uploads.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/uploads.test.ts @@ -1,25 +1,7 @@ import { describe, expect, it } from 'vitest' -import { v2CompleteUploadBodySchema, v2UploadTransferSchema } from '@/lib/api/contracts/v2/uploads' +import { v2UploadTransferSchema } from '@/lib/api/contracts/v2/uploads' describe('v2 upload transfer contracts', () => { - it('accepts only an empty object for PUT completion', () => { - expect(v2CompleteUploadBodySchema.parse({})).toEqual({}) - expect(v2CompleteUploadBodySchema.safeParse({ method: 'put' }).success).toBe(false) - }) - - it('accepts a strict completed-parts body for multipart completion', () => { - expect( - v2CompleteUploadBodySchema.parse({ parts: [{ partNumber: 1, etag: 'etag-1' }] }) - ).toEqual({ parts: [{ partNumber: 1, etag: 'etag-1' }] }) - expect(v2CompleteUploadBodySchema.safeParse({ parts: [] }).success).toBe(false) - expect( - v2CompleteUploadBodySchema.safeParse({ - parts: [{ partNumber: 1 }], - ignored: true, - }).success - ).toBe(false) - }) - it('discriminates a PUT transfer from multipart geometry', () => { expect( v2UploadTransferSchema.parse({ diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 429dbf00913..4286278193b 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -20,7 +20,6 @@ import { v2SortFields, } from '@/lib/api/contracts/v2/shared' import { - v2CompleteUploadBodySchema, v2PartUrlsBodySchema, v2PartUrlsDataSchema, v2UploadStatusSchema, @@ -396,7 +395,6 @@ export const v2CompleteFileUploadContract = defineRouteContract({ params: v2FileUploadParamsSchema, query: v2FileUploadWorkspaceQuerySchema, headers: v2UploadTokenHeadersSchema, - body: v2CompleteUploadBodySchema, response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, }) diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 9f2f3ec1e94..5ffc9ee7580 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -28,7 +28,6 @@ import { v2SortFields, } from '@/lib/api/contracts/v2/shared' import { - v2CompleteUploadBodySchema, v2PartUrlsBodySchema, v2PartUrlsDataSchema, v2UploadStatusSchema, @@ -458,7 +457,6 @@ export const v2CompleteKnowledgeDocumentUploadContract = defineRouteContract({ params: v2KnowledgeDocumentUploadParamsSchema, query: v2UploadKnowledgeDocumentQuerySchema, headers: v2UploadTokenHeadersSchema, - body: v2CompleteUploadBodySchema, response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentUploadSchema) }, }) diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 2f499a07afa..84e35ef40ef 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -51,7 +51,6 @@ import { v2SortFields, } from '@/lib/api/contracts/v2/shared' import { - v2CompleteUploadBodySchema, v2OptionalUploadTokenHeadersSchema, v2PartUrlsBodySchema, v2PartUrlsDataSchema, @@ -1168,7 +1167,6 @@ export const v2CompleteTableImportContract = defineRouteContract({ params: v2TableImportParamsSchema, query: v2TableTransferWorkspaceQuerySchema, headers: v2UploadTokenHeadersSchema, - body: v2CompleteUploadBodySchema, response: { mode: 'json', schema: v2DataResponse(v2TableImportSchema) }, }) diff --git a/apps/sim/lib/api/contracts/v2/uploads.ts b/apps/sim/lib/api/contracts/v2/uploads.ts index 0e91d768b83..0a23176e249 100644 --- a/apps/sim/lib/api/contracts/v2/uploads.ts +++ b/apps/sim/lib/api/contracts/v2/uploads.ts @@ -2,9 +2,11 @@ import { z } from 'zod' export const v2UploadStatusSchema = z.enum([ 'uploading', + 'completing', 'finalizing', 'completed', 'failed', + 'aborting', 'aborted', 'expired', ]) @@ -19,28 +21,6 @@ export const v2OptionalUploadTokenHeadersSchema = z.object({ 'upload-token': z.string().min(1, 'upload-token header cannot be empty').optional(), }) -export const v2CompletedPartSchema = z - .object({ - partNumber: z.number().int().min(1), - etag: z.string().min(1).optional(), - }) - .strict() -export type V2CompletedPart = z.input - -const v2CompleteMultipartUploadBodySchema = z - .object({ - parts: z.array(v2CompletedPartSchema).min(1).max(640), - }) - .strict() - -const v2CompletePutUploadBodySchema = z.object({}).strict() - -export const v2CompleteUploadBodySchema = z.union([ - v2CompleteMultipartUploadBodySchema, - v2CompletePutUploadBodySchema, -]) -export type V2CompleteUploadBody = z.input - export const v2PutUploadTransferSchema = z .object({ method: z.literal('put'), diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts index 127058074f5..8cbb8cae36c 100644 --- a/apps/sim/lib/table/orchestration/import-resource.ts +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -145,13 +145,13 @@ export async function startUploadedTableImport( }) } -export function getOwnedTableImportUpload(params: { +export async function getOwnedTableImportUpload(params: { importId: string workspaceId: string userId: string uploadToken: string -}): UploadSessionRecord { - const upload = getOwnedUploadSession({ +}): Promise { + const upload = await getOwnedUploadSession({ uploadId: params.importId, workspaceId: params.workspaceId, userId: params.userId, @@ -168,7 +168,7 @@ export async function abortTableImportUpload(params: { userId: string uploadToken: string }): Promise { - const upload = getOwnedTableImportUpload(params) + const upload = await getOwnedTableImportUpload(params) const body = tableImportBodyFromUpload(upload) return resourceFromUpload(await abortUploadSession(upload), body) } diff --git a/apps/sim/lib/uploads/client/session-upload.test.ts b/apps/sim/lib/uploads/client/session-upload.test.ts index 026c3f55a0e..235e6892d36 100644 --- a/apps/sim/lib/uploads/client/session-upload.test.ts +++ b/apps/sim/lib/uploads/client/session-upload.test.ts @@ -2,10 +2,9 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { V2CompleteUploadBody } from '@/lib/api/contracts/v2/uploads' interface UploadClientMockParams { - complete: (body: V2CompleteUploadBody) => Promise + complete: () => Promise } const { mockRequestJson, mockUploadFileSession } = vi.hoisted(() => ({ @@ -57,7 +56,7 @@ describe('session upload domain clients', () => { }) .mockResolvedValueOnce({ data: { document: DOCUMENT } }) mockUploadFileSession.mockImplementation( - async (params: UploadClientMockParams) => params.complete({}) + async (params: UploadClientMockParams) => params.complete() ) const file = { name: 'guide.pdf', type: 'application/pdf', size: 1024 } as File @@ -74,7 +73,7 @@ describe('session upload domain clients', () => { expect(mockRequestJson.mock.calls[1][0].path).toBe( '/api/knowledge/[id]/documents/uploads/[uploadId]/complete' ) - expect(mockRequestJson.mock.calls[1][1].body).toEqual({}) + expect(mockRequestJson.mock.calls[1][1].body).toBeUndefined() expect(mockRequestJson.mock.calls.some(([contract]) => contract.path.endsWith('/parts'))).toBe( false ) @@ -104,7 +103,7 @@ describe('session upload domain clients', () => { data: { id: 'upload-2', purpose: 'workspace_logo', result }, }) mockUploadFileSession.mockImplementation( - async (params: UploadClientMockParams) => params.complete({}) + async (params: UploadClientMockParams) => params.complete() ) await expect( diff --git a/apps/sim/lib/uploads/client/session-upload.ts b/apps/sim/lib/uploads/client/session-upload.ts index dabb59b893e..244703a8dbe 100644 --- a/apps/sim/lib/uploads/client/session-upload.ts +++ b/apps/sim/lib/uploads/client/session-upload.ts @@ -17,11 +17,7 @@ import type { V2KnowledgeDocumentSummary, V2KnowledgeDocumentUploadMetadata, } from '@/lib/api/contracts/v2/knowledge' -import type { - V2CompleteUploadBody, - V2UploadPartUrl, - V2UploadTransfer, -} from '@/lib/api/contracts/v2/uploads' +import type { V2UploadPartUrl, V2UploadTransfer } from '@/lib/api/contracts/v2/uploads' import type { UploadProgressEvent } from '@/lib/uploads/client/types' import { uploadFileSession } from '@/lib/uploads/client/upload-session' import { getFileContentType } from '@/lib/uploads/utils/file-utils' @@ -73,7 +69,7 @@ interface RunCreatedUploadParams { signal?: AbortSignal onProgress?: (event: UploadProgressEvent) => void getPartUrls: (partNumbers: number[]) => Promise - complete: (body: V2CompleteUploadBody) => Promise + complete: () => Promise abort: () => Promise } @@ -127,11 +123,10 @@ export async function uploadInternalFileSession { + complete: async () => { const completed = await requestJson(completeInternalFileUploadContract, { params: { uploadId: session.id }, headers: { 'upload-token': uploadToken }, - body, signal, }) if (completed.data.purpose !== params.purpose) { @@ -216,12 +211,11 @@ export async function uploadKnowledgeDocumentSession( }) return batch.data.parts }, - complete: async (body) => { + complete: async () => { const completed = await requestJson(completeKnowledgeDocumentUploadContract, { params: { id: knowledgeBaseId, uploadId: session.id }, query: { workspaceId }, headers: { 'upload-token': uploadToken }, - body, signal, }) if (!completed.data.document) { diff --git a/apps/sim/lib/uploads/client/upload-session.test.ts b/apps/sim/lib/uploads/client/upload-session.test.ts index 8df72f6b24f..adfe89df232 100644 --- a/apps/sim/lib/uploads/client/upload-session.test.ts +++ b/apps/sim/lib/uploads/client/upload-session.test.ts @@ -2,7 +2,6 @@ * @vitest-environment jsdom */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { V2CompleteUploadBody } from '@/lib/api/contracts/v2/uploads' import { calculateUploadTimeoutMs, uploadFileSession } from '@/lib/uploads/client/upload-session' const MIB = 1024 * 1024 @@ -62,7 +61,7 @@ describe('uploadFileSession', () => { it('uploads an exact-threshold file with PUT and completes with an empty body', async () => { const file = sizedFile(PUT_THRESHOLD) - const complete = vi.fn(async (_body: V2CompleteUploadBody) => 'done') + const complete = vi.fn(async () => 'done') const abort = vi.fn(async () => undefined) const onProgress = vi.fn() @@ -89,7 +88,7 @@ describe('uploadFileSession', () => { ]) ) expect(MockXhr.instances[0].timeout).toBe(calculateUploadTimeoutMs(file.size)) - expect(complete).toHaveBeenCalledWith({}) + expect(complete).toHaveBeenCalledWith() expect(onProgress).toHaveBeenLastCalledWith({ loaded: PUT_THRESHOLD, total: PUT_THRESHOLD, @@ -99,7 +98,7 @@ describe('uploadFileSession', () => { }) it('uploads an empty file with PUT and reports finite completion progress', async () => { - const complete = vi.fn(async (_body: V2CompleteUploadBody) => 'done') + const complete = vi.fn(async () => 'done') const onProgress = vi.fn() await expect( @@ -112,7 +111,7 @@ describe('uploadFileSession', () => { }) ).resolves.toBe('done') - expect(complete).toHaveBeenCalledWith({}) + expect(complete).toHaveBeenCalledWith() expect(onProgress).toHaveBeenLastCalledWith({ loaded: 0, total: 0, percent: 100 }) }) @@ -128,7 +127,7 @@ describe('uploadFileSession', () => { expiresAt: '2026-08-05T00:00:00.000Z', })) ) - const complete = vi.fn(async (_body: V2CompleteUploadBody) => 'done') + const complete = vi.fn(async () => 'done') const abort = vi.fn(async () => undefined) vi.stubGlobal( 'fetch', @@ -146,12 +145,7 @@ describe('uploadFileSession', () => { ).resolves.toBe('done') expect(getPartUrls).toHaveBeenCalledWith(Array.from({ length: partCount }, (_, i) => i + 1)) - expect(complete).toHaveBeenCalledWith({ - parts: Array.from({ length: partCount }, (_, index) => ({ - partNumber: index + 1, - etag: 'part-etag', - })), - }) + expect(complete).toHaveBeenCalledWith() expect(abort).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/uploads/client/upload-session.ts b/apps/sim/lib/uploads/client/upload-session.ts index 7ad7695b3ee..52894634f29 100644 --- a/apps/sim/lib/uploads/client/upload-session.ts +++ b/apps/sim/lib/uploads/client/upload-session.ts @@ -3,8 +3,6 @@ import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' import type { - V2CompletedPart, - V2CompleteUploadBody, V2MultipartUploadTransfer, V2PutUploadTransfer, V2UploadPartUrl, @@ -45,7 +43,7 @@ interface UploadFileSessionCommon { file: File signal?: AbortSignal onProgress?: (event: UploadProgressEvent) => void - complete: (body: V2CompleteUploadBody) => Promise + complete: () => Promise abort: () => Promise } @@ -68,13 +66,11 @@ function isPutFileSession( } export async function uploadFileSession(params: UploadFileSessionParams): Promise { - let completion: V2CompleteUploadBody try { if (isPutFileSession(params)) { await uploadPut(params) - completion = {} } else { - completion = { parts: await uploadMultipart(params) } + await uploadMultipart(params) } } catch (error) { await params.abort().catch((abortError) => { @@ -85,7 +81,7 @@ export async function uploadFileSession(params: UploadFileSessionParams): throw error } - return params.complete(completion) + return params.complete() } async function uploadPut(params: UploadPutFileSession): Promise { @@ -218,9 +214,7 @@ function uploadPutAttempt(params: UploadPutAttemptParams): Promise { }) } -async function uploadMultipart( - params: UploadMultipartFileSession -): Promise { +async function uploadMultipart(params: UploadMultipartFileSession): Promise { const { file, transfer, signal, onProgress } = params const expectedPartCount = Math.ceil(file.size / transfer.partSize) if (expectedPartCount !== transfer.partCount) { @@ -230,7 +224,6 @@ async function uploadMultipart( } const completedBytes = new Array(transfer.partCount).fill(0) - const completedParts: V2CompletedPart[] = [] for (let start = 1; start <= transfer.partCount; start += PART_URL_BATCH_SIZE) { const partNumbers = Array.from( { length: Math.min(PART_URL_BATCH_SIZE, transfer.partCount - start + 1) }, @@ -240,11 +233,11 @@ async function uploadMultipart( const results = await runWithConcurrency( partUrls, PART_UPLOAD_CONCURRENCY, - async (part): Promise => { + async (part): Promise => { const partStart = (part.partNumber - 1) * transfer.partSize const end = Math.min(partStart + transfer.partSize, file.size) const chunk = file.slice(partStart, end) - const etag = await uploadMultipartPart({ part, chunk, fileName: file.name, signal }) + await uploadMultipartPart({ part, chunk, fileName: file.name, signal }) completedBytes[part.partNumber - 1] = end - partStart const loaded = completedBytes.reduce((sum, bytes) => sum + bytes, 0) onProgress?.({ @@ -252,17 +245,12 @@ async function uploadMultipart( total: file.size, percent: Math.min(100, Math.round((loaded / file.size) * 100)), }) - return { partNumber: part.partNumber, ...(etag ? { etag } : {}) } } ) - completedParts.push( - ...results.map((result) => { - if (result.status === 'rejected') throw result.reason - return result.value - }) - ) + for (const result of results) { + if (result.status === 'rejected') throw result.reason + } } - return completedParts } function validatePartUrlBatch(requested: number[], received: V2UploadPartUrl[]): V2UploadPartUrl[] { @@ -288,7 +276,7 @@ async function uploadMultipartPart(params: { chunk: Blob fileName: string signal?: AbortSignal -}): Promise { +}): Promise { for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { // boundary-raw-fetch: signed multipart data-plane URL may target cloud storage or local Sim @@ -306,7 +294,7 @@ async function uploadMultipartPart(params: { isRetryableStatus(response.status) ) } - return response.headers.get('etag')?.replaceAll('"', '') + return } catch (error) { if (isAbortError(error)) throw error const classified = diff --git a/apps/sim/lib/uploads/core/upload-token.test.ts b/apps/sim/lib/uploads/core/upload-token.test.ts deleted file mode 100644 index 85beca8acbc..00000000000 --- a/apps/sim/lib/uploads/core/upload-token.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { signUploadToken, verifyUploadToken } from '@/lib/uploads/core/upload-token' - -const TIMESTAMPS = { - createdAt: '2099-08-03T20:00:00.000Z', - expiresAt: '2099-08-04T20:00:00.000Z', -} as const - -describe('upload token', () => { - it('round-trips strict multipart session state', () => { - const payload = { - uploadId: 'upload-1', - actorId: 'user-1', - workspaceId: 'workspace-1', - purpose: 'knowledge_document', - knowledgeBaseId: 'kb-1', - context: 'knowledge-base', - finalKey: 'kb/final-file.csv', - stagingKey: 'upload-sessions/upload-1/file.csv', - provider: 's3', - providerUploadId: 'provider-upload-1', - method: 'multipart', - fileName: 'file.csv', - contentType: 'text/csv', - fileSize: 12, - partSize: 8, - partCount: 2, - metadata: { tag1: 'product' }, - ...TIMESTAMPS, - } as const - const token = signUploadToken(payload) - - expect(verifyUploadToken(token)).toEqual({ valid: true, payload }) - }) - - it('round-trips a user-scoped PUT without a synthetic workspace', () => { - const payload = { - uploadId: 'upload-2', - actorId: 'user-1', - workspaceId: null, - purpose: 'profile_picture', - context: 'profile-pictures', - finalKey: 'profile-pictures/upload-2-avatar.png', - stagingKey: 'upload-sessions/upload-2/avatar.png', - provider: 'local', - providerUploadId: null, - method: 'put', - fileName: 'avatar.png', - contentType: 'image/png', - fileSize: 12, - metadata: {}, - ...TIMESTAMPS, - } as const - - expect(verifyUploadToken(signUploadToken(payload))).toEqual({ valid: true, payload }) - }) - - it('round-trips an empty workspace-file PUT', () => { - const payload = { - uploadId: 'upload-empty', - actorId: 'user-1', - workspaceId: 'workspace-1', - purpose: 'workspace_file', - context: 'workspace', - finalKey: 'workspace/workspace-1/empty.md', - stagingKey: 'upload-sessions/upload-empty/empty.md', - provider: 'local', - providerUploadId: null, - method: 'put', - fileName: 'empty.md', - contentType: 'text/markdown', - fileSize: 0, - metadata: {}, - ...TIMESTAMPS, - } as const - - expect(verifyUploadToken(signUploadToken(payload))).toEqual({ valid: true, payload }) - }) - - it('rejects an empty PUT for non-workspace-file purposes', () => { - expect(() => - signUploadToken({ - uploadId: 'upload-empty', - actorId: 'user-1', - workspaceId: null, - purpose: 'profile_picture', - context: 'profile-pictures', - finalKey: 'profile-pictures/empty.png', - stagingKey: 'upload-sessions/upload-empty/empty.png', - provider: 'local', - providerUploadId: null, - method: 'put', - fileName: 'empty.png', - contentType: 'image/png', - fileSize: 0, - metadata: {}, - ...TIMESTAMPS, - }) - ).toThrow('Upload token payload has invalid object state') - }) - - it('rejects a modified token', () => { - const token = signUploadToken({ - uploadId: 'upload-1', - actorId: 'user-1', - workspaceId: 'workspace-1', - purpose: 'workspace_file', - context: 'workspace', - finalKey: 'workspace/workspace-1/final.csv', - stagingKey: 'upload-sessions/upload-1/file.csv', - provider: 'local', - providerUploadId: null, - method: 'put', - fileName: 'file.csv', - contentType: 'text/csv', - fileSize: 12, - metadata: {}, - ...TIMESTAMPS, - }) - const [payload, signature] = token.split('.') - - expect(verifyUploadToken(`${payload}x.${signature}`)).toEqual({ valid: false }) - }) -}) diff --git a/apps/sim/lib/uploads/core/upload-token.ts b/apps/sim/lib/uploads/core/upload-token.ts deleted file mode 100644 index 86fba13110e..00000000000 --- a/apps/sim/lib/uploads/core/upload-token.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { safeCompare } from '@sim/security/compare' -import { hmacSha256Base64 } from '@sim/security/hmac' -import { env } from '@/lib/core/config/env' - -export type UploadSessionPurpose = - | 'workspace_file' - | 'table_import' - | 'knowledge_document' - | 'profile_picture' - | 'workspace_logo' - | 'mothership_attachment' - | 'execution_attachment' - -export type UploadStorageProvider = 's3' | 'blob' | 'gcs' | 'local' -export type UploadTransferMethod = 'put' | 'multipart' - -type UploadPurposeScope = - | { - purpose: 'workspace_file' - workspaceId: string - context: 'workspace' - } - | { - purpose: 'table_import' - workspaceId: string - context: 'table-import' - } - | { - purpose: 'knowledge_document' - workspaceId: string - context: 'knowledge-base' - knowledgeBaseId: string - } - | { - purpose: 'profile_picture' - workspaceId: null - context: 'profile-pictures' - } - | { - purpose: 'workspace_logo' - workspaceId: string - context: 'workspace-logos' - } - | { - purpose: 'mothership_attachment' - workspaceId: string - context: 'mothership' - } - | { - purpose: 'execution_attachment' - workspaceId: string - context: 'execution' - workflowId: string - executionId: string - } - -type UploadTransferState = - | { - method: 'put' - providerUploadId: null - } - | { - method: 'multipart' - providerUploadId: string | null - partSize: number - partCount: number - } - -interface UploadTokenBase { - uploadId: string - actorId: string - finalKey: string - stagingKey: string - provider: UploadStorageProvider - fileName: string - contentType: string - fileSize: number - metadata: Record - createdAt: string - expiresAt: string -} - -export type UploadTokenPayload = UploadTokenBase & UploadPurposeScope & UploadTransferState - -type SignedPayload = UploadTokenPayload & { - exp: number - v: 2 -} - -const BASE_KEYS = [ - 'uploadId', - 'actorId', - 'finalKey', - 'stagingKey', - 'provider', - 'providerUploadId', - 'method', - 'purpose', - 'workspaceId', - 'context', - 'fileName', - 'contentType', - 'fileSize', - 'metadata', - 'createdAt', - 'expiresAt', - 'exp', - 'v', -] as const - -const toBase64Url = (input: string): string => Buffer.from(input, 'utf8').toString('base64url') - -const fromBase64Url = (input: string): string => Buffer.from(input, 'base64url').toString('utf8') - -const sign = (payload: string): string => hmacSha256Base64(payload, env.INTERNAL_API_SECRET) - -/** - * Signs the complete, immutable state of one upload session. - * - * Version 2 intentionally has no compatibility parser for legacy multipart tokens. A token must - * carry a purpose-specific scope, transfer method, staging and final keys, provider state, exact - * object identity, and one canonical expiry. - */ -export function signUploadToken(payload: UploadTokenPayload): string { - assertUploadTokenPayload(payload) - const expiresAt = new Date(payload.expiresAt) - const signed: SignedPayload = { - ...payload, - exp: Math.floor(expiresAt.getTime() / 1000), - v: 2, - } - const encoded = toBase64Url(JSON.stringify(signed)) - return `${encoded}.${sign(encoded)}` -} - -export type UploadTokenVerification = - | { valid: true; payload: UploadTokenPayload } - | { valid: false } - -export function verifyUploadToken(token: string): UploadTokenVerification { - if (typeof token !== 'string') return { valid: false } - const parts = token.split('.') - if (parts.length !== 2) return { valid: false } - const [encoded, signature] = parts - if (!encoded || !signature || !safeCompare(signature, sign(encoded))) return { valid: false } - - let parsed: unknown - try { - parsed = JSON.parse(fromBase64Url(encoded)) - } catch { - return { valid: false } - } - - if (!isRecord(parsed) || parsed.v !== 2 || !isSafePositiveInteger(parsed.exp)) { - return { valid: false } - } - if (parsed.exp <= Math.floor(Date.now() / 1000)) return { valid: false } - - try { - assertUploadTokenPayload(parsed) - } catch { - return { valid: false } - } - - if (Math.floor(new Date(parsed.expiresAt).getTime() / 1000) !== parsed.exp) { - return { valid: false } - } - - const { exp: _exp, v: _version, ...payload } = parsed - return { valid: true, payload } -} - -function assertUploadTokenPayload(value: unknown): asserts value is UploadTokenPayload { - if (!isRecord(value)) throw new Error('Upload token payload must be an object') - - const purposeKeys = - value.purpose === 'knowledge_document' - ? ['knowledgeBaseId'] - : value.purpose === 'execution_attachment' - ? ['workflowId', 'executionId'] - : [] - const methodKeys = value.method === 'multipart' ? ['partSize', 'partCount'] : [] - const allowedKeys = new Set([...BASE_KEYS, ...purposeKeys, ...methodKeys]) - if (Object.keys(value).some((key) => !allowedKeys.has(key))) { - throw new Error('Upload token payload contains unexpected state') - } - - if ( - !isNonEmptyString(value.uploadId) || - !isNonEmptyString(value.actorId) || - !isNonEmptyString(value.finalKey) || - !isNonEmptyString(value.stagingKey) || - value.finalKey === value.stagingKey || - !value.stagingKey.startsWith(`upload-sessions/${value.uploadId}/`) || - !isNonEmptyString(value.fileName) || - !isNonEmptyString(value.contentType) || - !isValidFileSize(value.purpose, value.fileSize) || - !isPlainRecord(value.metadata) - ) { - throw new Error('Upload token payload has invalid object state') - } - - if ( - value.provider !== 's3' && - value.provider !== 'blob' && - value.provider !== 'gcs' && - value.provider !== 'local' - ) { - throw new Error('Upload token payload has an invalid provider') - } - - if (value.method === 'put') { - if (value.providerUploadId !== null || 'partSize' in value || 'partCount' in value) { - throw new Error('PUT upload token has multipart state') - } - } else if (value.method === 'multipart') { - if (!isSafePositiveInteger(value.partSize) || !isSafePositiveInteger(value.partCount)) { - throw new Error('Multipart upload token has invalid geometry') - } - if (value.provider === 'local') { - if (value.providerUploadId !== null) { - throw new Error('Local multipart upload token has a provider upload id') - } - } else if (!isNonEmptyString(value.providerUploadId)) { - throw new Error('Cloud multipart upload token is missing its provider upload id') - } - } else { - throw new Error('Upload token payload has an invalid transfer method') - } - - assertPurposeScope(value) - - if (!isNonEmptyString(value.createdAt) || !isNonEmptyString(value.expiresAt)) { - throw new Error('Upload token payload is missing timestamps') - } - const createdAt = new Date(value.createdAt).getTime() - const expiresAt = new Date(value.expiresAt).getTime() - if (!Number.isFinite(createdAt) || !Number.isFinite(expiresAt) || expiresAt <= createdAt) { - throw new Error('Upload token payload has invalid timestamps') - } -} - -function assertPurposeScope(value: Record): void { - switch (value.purpose) { - case 'workspace_file': - assertWorkspacePurpose(value, 'workspace') - break - case 'table_import': - assertWorkspacePurpose(value, 'table-import') - break - case 'knowledge_document': - assertWorkspacePurpose(value, 'knowledge-base') - if (!isNonEmptyString(value.knowledgeBaseId)) { - throw new Error('Knowledge upload token is missing knowledgeBaseId') - } - break - case 'profile_picture': - if (value.workspaceId !== null || value.context !== 'profile-pictures') { - throw new Error('Profile-picture upload token has invalid scope') - } - break - case 'workspace_logo': - assertWorkspacePurpose(value, 'workspace-logos') - break - case 'mothership_attachment': - assertWorkspacePurpose(value, 'mothership') - break - case 'execution_attachment': - assertWorkspacePurpose(value, 'execution') - if (!isNonEmptyString(value.workflowId) || !isNonEmptyString(value.executionId)) { - throw new Error('Execution upload token is missing workflow scope') - } - break - default: - throw new Error('Upload token payload has an invalid purpose') - } -} - -function assertWorkspacePurpose(value: Record, context: string): void { - if (!isNonEmptyString(value.workspaceId) || value.context !== context) { - throw new Error('Upload token payload has invalid workspace scope') - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function isPlainRecord(value: unknown): value is Record { - if (!isRecord(value)) return false - const prototype = Object.getPrototypeOf(value) - return prototype === Object.prototype || prototype === null -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.trim().length > 0 -} - -function isSafePositiveInteger(value: unknown): value is number { - return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 -} - -function isValidFileSize(purpose: unknown, value: unknown): value is number { - return ( - typeof value === 'number' && - Number.isSafeInteger(value) && - (value > 0 || (purpose === 'workspace_file' && value === 0)) - ) -} diff --git a/apps/sim/lib/uploads/providers/blob/client.test.ts b/apps/sim/lib/uploads/providers/blob/client.test.ts index 5b604425474..d8f2fa9e728 100644 --- a/apps/sim/lib/uploads/providers/blob/client.test.ts +++ b/apps/sim/lib/uploads/providers/blob/client.test.ts @@ -10,8 +10,7 @@ const { mockDownload, mockDelete, mockDeleteIfExists, - mockBeginCopyFromURL, - mockPollUntilDone, + mockGetBlockList, mockGetProperties, mockGetBlockBlobClient, mockGetContainerClient, @@ -24,8 +23,7 @@ const { mockDownload: vi.fn(), mockDelete: vi.fn(), mockDeleteIfExists: vi.fn(), - mockBeginCopyFromURL: vi.fn(), - mockPollUntilDone: vi.fn(), + mockGetBlockList: vi.fn(), mockGetProperties: vi.fn(), mockGetBlockBlobClient: vi.fn(), mockGetContainerClient: vi.fn(), @@ -64,8 +62,8 @@ import { getBlobPresignedUploadUrl, getPresignedUrl, headBlobObject, + listMultipartParts, parseConnectionString, - promoteBlobObject, uploadToBlob, } from '@/lib/uploads/providers/blob/client' import { sanitizeFilenameForMetadata } from '@/lib/uploads/utils/file-utils' @@ -81,7 +79,7 @@ describe('Azure Blob Storage Client', () => { download: mockDownload, delete: mockDelete, deleteIfExists: mockDeleteIfExists, - beginCopyFromURL: mockBeginCopyFromURL, + getBlockList: mockGetBlockList, getProperties: mockGetProperties, url: 'https://test.blob.core.windows.net/container/test-file', }) @@ -97,8 +95,6 @@ describe('Azure Blob Storage Client', () => { mockGenerateBlobSASQueryParameters.mockReturnValue({ toString: () => 'sv=2021-06-08&se=2023-01-01T00%3A00%3A00Z&sr=b&sp=r&sig=test', }) - mockBeginCopyFromURL.mockResolvedValue({ pollUntilDone: mockPollUntilDone }) - mockPollUntilDone.mockResolvedValue({ copyStatus: 'success' }) }) describe('uploadToBlob', () => { @@ -150,7 +146,7 @@ describe('Azure Blob Storage Client', () => { }) }) - describe('staged upload primitives', () => { + describe('direct upload primitives', () => { const customConfig = { containerName: 'testcontainer', accountName: 'testaccount', @@ -163,7 +159,7 @@ describe('Azure Blob Storage Client', () => { mockBlobSASPermissionsParse.mockReturnValueOnce('w') const result = await getBlobPresignedUploadUrl({ - key: 'upload-sessions/upload-1/file.bin', + key: 'workspace/workspace-1/file.bin', contentType: 'application/octet-stream', metadata: { uploadId: 'upload-1', purpose: 'workspace_file' }, customConfig, @@ -175,6 +171,7 @@ describe('Azure Blob Storage Client', () => { url: expect.stringContaining('?sv=2021-06-08'), headers: { 'Content-Type': 'application/octet-stream', + 'If-None-Match': '*', 'x-ms-blob-type': 'BlockBlob', 'x-ms-blob-content-type': 'application/octet-stream', 'x-ms-meta-uploadId': 'upload-1', @@ -183,25 +180,7 @@ describe('Azure Blob Storage Client', () => { }) }) - it('pins the source ETag and requires an absent promotion destination', async () => { - await promoteBlobObject({ - sourceKey: 'upload-sessions/upload-1/file.bin', - destinationKey: 'workspace/workspace-1/file.bin', - sourceEtag: '"etag-1"', - customConfig, - }) - - expect(mockBeginCopyFromURL).toHaveBeenCalledWith( - 'https://test.blob.core.windows.net/container/test-file', - { - conditions: { ifNoneMatch: '*' }, - sourceConditions: { ifMatch: '"etag-1"' }, - } - ) - expect(mockPollUntilDone).toHaveBeenCalledOnce() - }) - - it('returns only completed copied objects as usable upload identities', async () => { + it('returns only completed objects as usable upload identities', async () => { mockGetProperties.mockResolvedValueOnce({ contentLength: 3, contentType: 'application/octet-stream', @@ -225,11 +204,28 @@ describe('Azure Blob Storage Client', () => { ) }) - it('deletes staging only when its ETag still matches', async () => { + it('lists provider-authoritative uncommitted blocks', async () => { + mockGetBlockList.mockResolvedValueOnce({ + uncommittedBlocks: [ + { name: Buffer.from('block-000001').toString('base64'), size: 8 }, + { name: Buffer.from('block-000002').toString('base64'), size: 3 }, + ], + }) + + await expect( + listMultipartParts('workspace/workspace-1/file.bin', customConfig) + ).resolves.toEqual([ + { partNumber: 1, size: 8 }, + { partNumber: 2, size: 3 }, + ]) + expect(mockGetBlockList).toHaveBeenCalledWith('uncommitted') + }) + + it('deletes the upload object only when its ETag still matches', async () => { mockDeleteIfExists.mockResolvedValueOnce({}) await deleteBlobObjectVersion({ - key: 'upload-sessions/upload-1/file.bin', + key: 'workspace/workspace-1/file.bin', etag: '"etag-1"', customConfig, }) diff --git a/apps/sim/lib/uploads/providers/blob/client.ts b/apps/sim/lib/uploads/providers/blob/client.ts index 993eeda8c74..a78f98a9b39 100644 --- a/apps/sim/lib/uploads/providers/blob/client.ts +++ b/apps/sim/lib/uploads/providers/blob/client.ts @@ -257,7 +257,7 @@ export async function getPresignedUrlWithConfig( return `${blockBlobClient.url}?${sasToken}` } -/** Generates a SAS-backed single-object PUT for a caller-selected staging key. */ +/** Generates a create-only SAS-backed single-object PUT for a caller-selected final key. */ export async function getBlobPresignedUploadUrl(params: { key: string contentType: string @@ -294,6 +294,7 @@ export async function getBlobPresignedUploadUrl(params: { url: `${client.url}?${sasToken}`, headers: { 'Content-Type': params.contentType, + 'If-None-Match': '*', 'x-ms-blob-type': 'BlockBlob', 'x-ms-blob-content-type': params.contentType, ...Object.fromEntries( @@ -493,36 +494,13 @@ export async function headBlobObject( } } -/** - * Copies one immutable staging version into a destination that must not already exist. - * The asynchronous API supports objects above the synchronous copy operation's 256 MiB limit. - */ -export async function promoteBlobObject(params: { - sourceKey: string - destinationKey: string - sourceEtag: string - customConfig: BlobConfig -}): Promise { - if (!params.sourceEtag) throw new Error('Blob staging object is missing its ETag') - const source = await getBlockBlobClientFor(params.sourceKey, params.customConfig) - const destination = await getBlockBlobClientFor(params.destinationKey, params.customConfig) - const copy = await destination.beginCopyFromURL(source.url, { - conditions: { ifNoneMatch: '*' }, - sourceConditions: { ifMatch: params.sourceEtag }, - }) - const result = await copy.pollUntilDone() - if (result.copyStatus !== 'success') { - throw new Error(`Blob promotion finished with status ${result.copyStatus ?? 'unknown'}`) - } -} - -/** Deletes a staging blob only if it is still the version completion inspected. */ +/** Deletes an upload blob only if it is still the version the caller inspected. */ export async function deleteBlobObjectVersion(params: { key: string etag: string customConfig: BlobConfig }): Promise { - if (!params.etag) throw new Error('Blob staging object is missing its ETag') + if (!params.etag) throw new Error('Blob upload object is missing its ETag') const client = await getBlockBlobClientFor(params.key, params.customConfig) await client.deleteIfExists({ conditions: { ifMatch: params.etag } }) } @@ -676,6 +654,23 @@ export async function getMultipartPartUrls( }) } +/** Lists uncommitted blocks and maps canonical block ids back to part numbers. */ +export async function listMultipartParts( + key: string, + customConfig?: BlobConfig +): Promise> { + const blockBlobClient = await getBlockBlobClientFor(key, customConfig) + const response = await blockBlobClient.getBlockList('uncommitted') + return (response.uncommittedBlocks ?? []).map((block) => { + const decoded = Buffer.from(block.name, 'base64').toString('utf8') + const match = decoded.match(/^block-(\d{6})$/) + if (!match || deriveBlobBlockId(Number(match[1])) !== block.name) { + throw new Error(`Azure returned an invalid block id for ${key}`) + } + return { partNumber: Number(match[1]), size: block.size } + }) +} + async function getBlockBlobClientFor(key: string, customConfig?: BlobConfig) { const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob') let blobServiceClient: BlobServiceClientType diff --git a/apps/sim/lib/uploads/providers/gcs/client.test.ts b/apps/sim/lib/uploads/providers/gcs/client.test.ts index 280066feec3..20464b0821b 100644 --- a/apps/sim/lib/uploads/providers/gcs/client.test.ts +++ b/apps/sim/lib/uploads/providers/gcs/client.test.ts @@ -80,7 +80,7 @@ import { getPresignedUrlWithConfig, headGcsObject, initiateGcsMultipartUpload, - promoteGcsObject, + listGcsMultipartParts, resetGcsClientForTesting, uploadGcsPart, uploadToGcs, @@ -253,6 +253,7 @@ describe('GCS Client', () => { expires: expect.any(Number), contentType: 'text/plain', extensionHeaders: expect.objectContaining({ + 'x-goog-if-generation-match': '0', 'x-goog-meta-originalName': 'file.txt', 'x-goog-meta-workspaceId': 'ws-1', }), @@ -261,6 +262,7 @@ describe('GCS Client', () => { expect(result.signedHeaders).toEqual( expect.objectContaining({ 'Content-Type': 'text/plain', + 'x-goog-if-generation-match': '0', 'x-goog-meta-workspaceId': 'ws-1', }) ) @@ -341,36 +343,17 @@ describe('GCS Client', () => { }) }) - describe('staged upload promotion', () => { - it('pins the source generation and requires an absent destination', async () => { - mockFile.copy.mockResolvedValueOnce(undefined) - - await promoteGcsObject({ - sourceKey: 'upload-sessions/upload-1/file.bin', - destinationKey: 'workspace/workspace-1/file.bin', - sourceGeneration: '42', - customConfig: { bucket: 'test-bucket' }, - }) - - expect(mockBucket.file).toHaveBeenCalledWith('upload-sessions/upload-1/file.bin', { - generation: '42', - }) - expect(mockBucket.file).toHaveBeenCalledWith('workspace/workspace-1/file.bin') - expect(mockFile.copy).toHaveBeenCalledWith(mockFile, { - preconditionOpts: { ifGenerationMatch: 0 }, - }) - }) - - it('deletes staging only at the inspected generation', async () => { + describe('direct upload object lifecycle', () => { + it('deletes the upload object only at the inspected generation', async () => { mockFile.delete.mockResolvedValueOnce(undefined) await deleteGcsObjectVersion({ - key: 'upload-sessions/upload-1/file.bin', + key: 'workspace/workspace-1/file.bin', generation: '42', customConfig: { bucket: 'test-bucket' }, }) - expect(mockBucket.file).toHaveBeenCalledWith('upload-sessions/upload-1/file.bin', { + expect(mockBucket.file).toHaveBeenCalledWith('workspace/workspace-1/file.bin', { generation: '42', }) expect(mockFile.delete).toHaveBeenCalledWith({ ifGenerationMatch: '42' }) @@ -395,6 +378,32 @@ describe('GCS Client', () => { }) describe('multipart uploads (XML API)', () => { + it('lists provider-authoritative parts across pagination', async () => { + mockFetch + .mockResolvedValueOnce( + new Response( + '1"etag-1"8true1', + { status: 200 } + ) + ) + .mockResolvedValueOnce( + new Response( + '2"etag-2"3false', + { status: 200 } + ) + ) + + await expect( + listGcsMultipartParts('workspace/ws-1/file.bin', 'provider-upload-1') + ).resolves.toEqual([ + { partNumber: 1, etag: '"etag-1"', size: 8 }, + { partNumber: 2, etag: '"etag-2"', size: 3 }, + ]) + expect(mockFetch.mock.calls[1][0]).toContain( + 'uploadId=provider-upload-1&part-number-marker=1' + ) + }) + it('should initiate a multipart upload and parse the UploadId', async () => { mockFetch.mockResolvedValueOnce( new Response( @@ -513,7 +522,7 @@ describe('GCS Client', () => { }) }) - it('should restore quotes on ETags stripped by the browser upload client', async () => { + it('should restore quotes on unquoted ETags', async () => { mockFetch.mockResolvedValueOnce(new Response('', { status: 200 })) await completeGcsMultipartUpload('key.csv', 'upload-123', [{ PartNumber: 1, ETag: 'etag-1' }]) diff --git a/apps/sim/lib/uploads/providers/gcs/client.ts b/apps/sim/lib/uploads/providers/gcs/client.ts index 29c45559ef7..b9b3ba3a47f 100644 --- a/apps/sim/lib/uploads/providers/gcs/client.ts +++ b/apps/sim/lib/uploads/providers/gcs/client.ts @@ -251,13 +251,17 @@ export async function getGcsPresignedUploadUrl( action: 'write', expires: Date.now() + expirationSeconds * 1000, contentType, - extensionHeaders: metadataHeaders, + extensionHeaders: { + ...metadataHeaders, + 'x-goog-if-generation-match': '0', + }, }) return { url, signedHeaders: { 'Content-Type': contentType, + 'x-goog-if-generation-match': '0', ...metadataHeaders, }, } @@ -354,28 +358,13 @@ export async function headGcsObject( } } -/** Copies one immutable staging generation into a destination that must not already exist. */ -export async function promoteGcsObject(params: { - sourceKey: string - destinationKey: string - sourceGeneration: string - customConfig: GcsConfig -}): Promise { - if (!params.sourceGeneration) throw new Error('GCS staging object is missing its generation') - const storage = await getGcsClient() - const bucket = storage.bucket(params.customConfig.bucket) - const source = bucket.file(params.sourceKey, { generation: params.sourceGeneration }) - const destination = bucket.file(params.destinationKey) - await source.copy(destination, { preconditionOpts: { ifGenerationMatch: 0 } }) -} - -/** Deletes a staging object only if it is still the generation completion inspected. */ +/** Deletes an upload object only if it is still the generation the caller inspected. */ export async function deleteGcsObjectVersion(params: { key: string generation: string customConfig: GcsConfig }): Promise { - if (!params.generation) throw new Error('GCS staging object is missing its generation') + if (!params.generation) throw new Error('GCS upload object is missing its generation') const storage = await getGcsClient() await storage .bucket(params.customConfig.bucket) @@ -417,8 +406,8 @@ export async function deleteFromGcs(key: string, customConfig?: GcsConfig): Prom /** * Normalize an ETag to the quoted form GCS expects in CompleteMultipartUpload. - * The shared browser upload client strips quotes from part ETags (S3 tolerates - * either form), so quotes are restored here before building the completion XML. + * Accept either form so server-uploaded and provider-listed parts share the same + * completion path. */ function normalizeEtag(etag: string): string { return etag.startsWith('"') ? etag : `"${etag}"` @@ -440,7 +429,7 @@ function escapeXml(value: string): string { * of the SDK. */ async function gcsXmlApiRequest( - method: 'POST' | 'PUT' | 'DELETE', + method: 'GET' | 'POST' | 'PUT' | 'DELETE', bucket: string, key: string, query: string, @@ -469,6 +458,15 @@ async function gcsXmlApiRequest( return response } +function decodeXml(value: string): string { + return value + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') +} + /** * Initiate a multipart upload for GCS (XML API, S3-compatible semantics) */ @@ -539,8 +537,7 @@ export async function uploadGcsPart( /** * Generate presigned URLs for uploading parts to GCS. The URLs sign the - * `partNumber`/`uploadId` query parameters (V4), matching the S3 flow — - * the browser PUTs each chunk and collects the returned ETags. + * `partNumber`/`uploadId` query parameters (V4), matching the S3 flow. */ export async function getGcsMultipartPartUrls( key: string, @@ -568,6 +565,45 @@ export async function getGcsMultipartPartUrls( ) } +/** Lists the provider-authoritative state for an XML API multipart upload. */ +export async function listGcsMultipartParts( + key: string, + uploadId: string, + customConfig?: GcsConfig +): Promise> { + const config = customConfig || { bucket: GCS_CONFIG.bucket } + const parts: Array<{ partNumber: number; etag: string; size: number }> = [] + let marker: number | undefined + + for (;;) { + const query = `uploadId=${encodeURIComponent(uploadId)}${marker === undefined ? '' : `&part-number-marker=${marker}`}` + const response = await gcsXmlApiRequest('GET', config.bucket, key, query) + const xml = await response.text() + for (const match of xml.matchAll(/([\s\S]*?)<\/Part>/g)) { + const partXml = match[1] + const numberMatch = partXml.match(/(\d+)<\/PartNumber>/) + const etagMatch = partXml.match(/([\s\S]*?)<\/ETag>/) + const sizeMatch = partXml.match(/(\d+)<\/Size>/) + if (!numberMatch || !etagMatch || !sizeMatch) { + throw new Error(`GCS returned incomplete part metadata for ${key}`) + } + parts.push({ + partNumber: Number(numberMatch[1]), + etag: decodeXml(etagMatch[1]), + size: Number(sizeMatch[1]), + }) + } + if (!/true<\/IsTruncated>/.test(xml)) break + const nextMarker = xml.match(/(\d+)<\/NextPartNumberMarker>/) + if (!nextMarker) { + throw new Error(`GCS truncated the part listing for ${key} without a continuation marker`) + } + marker = Number(nextMarker[1]) + } + + return parts +} + /** * Complete multipart upload for GCS */ diff --git a/apps/sim/lib/uploads/providers/s3/client.test.ts b/apps/sim/lib/uploads/providers/s3/client.test.ts index 0979c5c874a..8502b2895b5 100644 --- a/apps/sim/lib/uploads/providers/s3/client.test.ts +++ b/apps/sim/lib/uploads/providers/s3/client.test.ts @@ -13,7 +13,7 @@ const { mockGetObjectCommand, mockHeadObjectCommand, mockDeleteObjectCommand, - mockCopyObjectCommand, + mockListPartsCommand, mockCompleteMultipartUploadCommand, mockGetSignedUrl, mockEnv, @@ -55,7 +55,7 @@ const { mockGetObjectCommand: vi.fn().mockImplementation(class {}), mockHeadObjectCommand: vi.fn().mockImplementation(class {}), mockDeleteObjectCommand: vi.fn().mockImplementation(class {}), - mockCopyObjectCommand: vi.fn().mockImplementation(class {}), + mockListPartsCommand: vi.fn().mockImplementation(class {}), mockCompleteMultipartUploadCommand: vi.fn().mockImplementation(class {}), mockGetSignedUrl: vi.fn(), mockEnv, @@ -68,7 +68,7 @@ vi.mock('@aws-sdk/client-s3', () => ({ GetObjectCommand: mockGetObjectCommand, HeadObjectCommand: mockHeadObjectCommand, DeleteObjectCommand: mockDeleteObjectCommand, - CopyObjectCommand: mockCopyObjectCommand, + ListPartsCommand: mockListPartsCommand, CompleteMultipartUploadCommand: mockCompleteMultipartUploadCommand, })) @@ -109,7 +109,7 @@ import { getS3Client, getS3PresignedUploadUrl, headS3Object, - promoteS3Object, + listS3MultipartParts, resetS3ClientForTesting, uploadToS3, } from '@/lib/uploads/providers/s3/client' @@ -246,12 +246,12 @@ describe('S3 Client', () => { }) }) - describe('staged upload primitives', () => { - it('signs metadata without returning duplicate x-amz-meta headers', async () => { + describe('direct upload primitives', () => { + it('signs metadata and a create-only condition without duplicate x-amz-meta headers', async () => { mockGetSignedUrl.mockResolvedValueOnce('https://example.com/signed-put') const result = await getS3PresignedUploadUrl({ - key: 'upload-sessions/upload-1/file.bin', + key: 'workspace/workspace-1/file.bin', contentType: 'application/octet-stream', fileSize: 3, metadata: { uploadId: 'upload-1', purpose: 'workspace_file' }, @@ -261,15 +261,17 @@ describe('S3 Client', () => { expect(mockPutObjectCommand).toHaveBeenCalledWith({ Bucket: 'test-bucket', - Key: 'upload-sessions/upload-1/file.bin', + Key: 'workspace/workspace-1/file.bin', ContentType: 'application/octet-stream', ContentLength: 3, + IfNoneMatch: '*', Metadata: { uploadId: 'upload-1', purpose: 'workspace_file' }, }) expect(result).toEqual({ url: 'https://example.com/signed-put', headers: { 'Content-Type': 'application/octet-stream', + 'If-None-Match': '*', }, }) }) @@ -282,9 +284,7 @@ describe('S3 Client', () => { ETag: '"etag-1"', }) - await expect( - headS3Object('upload-sessions/upload-1/file.bin', mockS3Config) - ).resolves.toEqual({ + await expect(headS3Object('workspace/workspace-1/file.bin', mockS3Config)).resolves.toEqual({ size: 3, contentType: 'application/octet-stream', uploadId: 'upload-1', @@ -292,38 +292,44 @@ describe('S3 Client', () => { }) }) - it('pins the source ETag and requires an absent promotion destination', async () => { - mockSend.mockResolvedValueOnce({}) - - await promoteS3Object({ - sourceKey: 'upload-sessions/upload-1/file.bin', - destinationKey: 'workspace/workspace-1/file.bin', - sourceEtag: '"etag-1"', - customConfig: mockS3Config, - }) + it('lists every provider part across pagination', async () => { + mockSend + .mockResolvedValueOnce({ + Parts: [{ PartNumber: 1, ETag: 'etag-1', Size: 8 }], + IsTruncated: true, + NextPartNumberMarker: 1, + }) + .mockResolvedValueOnce({ + Parts: [{ PartNumber: 2, ETag: 'etag-2', Size: 3 }], + IsTruncated: false, + }) - expect(mockCopyObjectCommand).toHaveBeenCalledWith({ + await expect( + listS3MultipartParts('workspace/workspace-1/file.bin', 'provider-upload-1', mockS3Config) + ).resolves.toEqual([ + { partNumber: 1, etag: 'etag-1', size: 8 }, + { partNumber: 2, etag: 'etag-2', size: 3 }, + ]) + expect(mockListPartsCommand).toHaveBeenLastCalledWith({ Bucket: 'test-bucket', Key: 'workspace/workspace-1/file.bin', - CopySource: 'test-bucket/upload-sessions/upload-1/file.bin', - CopySourceIfMatch: '"etag-1"', - IfNoneMatch: '*', - MetadataDirective: 'COPY', + UploadId: 'provider-upload-1', + PartNumberMarker: '1', }) }) - it('deletes staging only when its ETag still matches', async () => { + it('deletes the upload object only when its ETag still matches', async () => { mockSend.mockResolvedValueOnce({}) await deleteS3ObjectVersion({ - key: 'upload-sessions/upload-1/file.bin', + key: 'workspace/workspace-1/file.bin', etag: '"etag-1"', customConfig: mockS3Config, }) expect(mockDeleteObjectCommand).toHaveBeenCalledWith({ Bucket: 'test-bucket', - Key: 'upload-sessions/upload-1/file.bin', + Key: 'workspace/workspace-1/file.bin', IfMatch: '"etag-1"', }) }) diff --git a/apps/sim/lib/uploads/providers/s3/client.ts b/apps/sim/lib/uploads/providers/s3/client.ts index ecbc8879562..fe7bcdb1a57 100644 --- a/apps/sim/lib/uploads/providers/s3/client.ts +++ b/apps/sim/lib/uploads/providers/s3/client.ts @@ -2,12 +2,12 @@ import type { Readable } from 'node:stream' import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, - CopyObjectCommand, CreateMultipartUploadCommand, DeleteObjectCommand, DeleteObjectsCommand, GetObjectCommand, HeadObjectCommand, + ListPartsCommand, PutObjectCommand, S3Client, UploadPartCommand, @@ -169,7 +169,7 @@ export async function getPresignedUrlWithConfig( } /** - * Generates a signed single-object PUT for a caller-selected staging key. + * Generates a create-only signed single-object PUT for a caller-selected final key. * The AWS presigner hoists `x-amz-meta-*` values into the signed query string, * so only ordinary transfer headers are returned. Repeating that metadata as * request headers makes S3 reject the otherwise-valid signature. @@ -188,6 +188,7 @@ export async function getS3PresignedUploadUrl(params: { Key: params.key, ContentType: params.contentType, ContentLength: params.fileSize, + IfNoneMatch: '*', Metadata: metadata, }) const url = await getSignedUrl(getS3Client(), command, { expiresIn: params.expiresIn }) @@ -195,6 +196,7 @@ export async function getS3PresignedUploadUrl(params: { url, headers: { 'Content-Type': params.contentType, + 'If-None-Match': '*', }, } } @@ -304,39 +306,13 @@ export async function headS3Object( } } -/** - * Copies one immutable staging version into a destination that must not already exist. - */ -export async function promoteS3Object(params: { - sourceKey: string - destinationKey: string - sourceEtag: string - customConfig: S3Config -}): Promise { - if (!params.sourceEtag) throw new Error('S3 staging object is missing its ETag') - const encodedSource = `${params.customConfig.bucket}/${params.sourceKey - .split('/') - .map((segment) => encodeURIComponent(segment)) - .join('/')}` - await getS3Client().send( - new CopyObjectCommand({ - Bucket: params.customConfig.bucket, - Key: params.destinationKey, - CopySource: encodedSource, - CopySourceIfMatch: params.sourceEtag, - IfNoneMatch: '*', - MetadataDirective: 'COPY', - }) - ) -} - -/** Deletes a staging object only if it is still the version completion inspected. */ +/** Deletes an upload object only if it is still the version the caller inspected. */ export async function deleteS3ObjectVersion(params: { key: string etag: string customConfig: S3Config }): Promise { - if (!params.etag) throw new Error('S3 staging object is missing its ETag') + if (!params.etag) throw new Error('S3 upload object is missing its ETag') await getS3Client().send( new DeleteObjectCommand({ Bucket: params.customConfig.bucket, @@ -516,6 +492,41 @@ export async function getS3MultipartPartUrls( return presignedUrls } +/** Lists the provider-authoritative state for a multipart upload. */ +export async function listS3MultipartParts( + key: string, + uploadId: string, + customConfig?: S3Config +): Promise> { + const config = customConfig || { bucket: S3_KB_CONFIG.bucket, region: S3_KB_CONFIG.region } + const parts: Array<{ partNumber: number; etag: string; size: number }> = [] + let partNumberMarker: string | undefined + + for (;;) { + const response = await getS3Client().send( + new ListPartsCommand({ + Bucket: config.bucket, + Key: key, + UploadId: uploadId, + PartNumberMarker: partNumberMarker, + }) + ) + for (const part of response.Parts ?? []) { + if (part.PartNumber === undefined || part.ETag === undefined || part.Size === undefined) { + throw new Error(`S3 returned incomplete part metadata for ${key}`) + } + parts.push({ partNumber: part.PartNumber, etag: part.ETag, size: part.Size }) + } + if (!response.IsTruncated) break + if (response.NextPartNumberMarker === undefined) { + throw new Error(`S3 truncated the part listing for ${key} without a continuation marker`) + } + partNumberMarker = String(response.NextPartNumberMarker) + } + + return parts +} + /** * Build a fallback object URL for when the SDK omits `Location` on multipart * completion. For a custom `S3_CONFIG.endpoint` it matches the configured diff --git a/apps/sim/lib/uploads/upload-session/README.md b/apps/sim/lib/uploads/upload-session/README.md index c29f9a312cc..efa4a2f0236 100644 --- a/apps/sim/lib/uploads/upload-session/README.md +++ b/apps/sim/lib/uploads/upload-session/README.md @@ -1,18 +1,16 @@ # Upload sessions -Upload sessions use a signed, stateless control-plane token and an immutable staging object. Files -up to and including 50 MiB use one signed `PUT`; larger files use multipart upload. Completion -verifies the staged object's upload ID, byte size, and content type before promoting it to a -create-only final key. +Upload sessions keep their control-plane state in PostgreSQL and upload directly to their final +storage key. Files up to and including 50 MiB use one create-only signed `PUT`; larger files use a +provider multipart upload. Completion accepts only the upload ID and token, lists parts from the +storage provider, validates their count and byte sizes, completes the provider upload, and verifies +the final object's upload ID, byte size, and content type before running the domain finalizer. -The `upload-sessions/` prefix is temporary. Production S3 and GCS buckets must expire objects under -that prefix after two days and abort incomplete multipart uploads after two days. Azure containers -must expire committed blobs under that prefix after two days; Azure automatically garbage-collects -uncommitted blocks after seven days. These policies exceed the 24-hour token lifetime, preserve a -retry window, and bound abandoned provider state. Local storage applies the equivalent 25-hour -policy with the bounded cleanup sweep in `cleanup.ts`. The local sweep retains process-local -directory cursors between bounded runs, so a large set of fresh entries cannot indefinitely hide -expired entries later in either directory. +Production S3 and GCS buckets must abort incomplete multipart uploads after two days. Azure +automatically garbage-collects uncommitted blocks after seven days. Local storage keeps multipart +parts under `.multipart/` and applies an equivalent 25-hour bounded cleanup sweep in `cleanup.ts`. +The local sweep retains its process-local directory cursor between bounded runs, so a large set of +fresh entries cannot indefinitely hide expired entries later in the directory. Local cleanup currently runs opportunistically when that same Sim process creates an upload session. The repository has no scheduler that safely reaches every process-local filesystem in a @@ -22,6 +20,7 @@ therefore ensure uploads continue to trigger the sweep on each replica or invoke bounded sweep from their own per-replica maintenance hook. Cloud deployments should use the provider lifecycle rules above instead. -Final objects are not covered by the staging lifecycle. Completion retains staging until the -domain finalizer succeeds, then conditionally deletes only the exact staging version it verified. -Abort is also staging-only and must never delete a promoted final object. +The cron cleanup claims expired database sessions with a lease before aborting provider multipart +state or conditionally deleting an uploaded object that still carries that session's identity. +Sessions already in domain finalization are retained for an idempotent completion retry; cleanup +must not delete an object after its domain resource may have been created. diff --git a/apps/sim/lib/uploads/upload-session/cleanup.test.ts b/apps/sim/lib/uploads/upload-session/cleanup.test.ts index 2a9dad064a2..586b76ea702 100644 --- a/apps/sim/lib/uploads/upload-session/cleanup.test.ts +++ b/apps/sim/lib/uploads/upload-session/cleanup.test.ts @@ -26,20 +26,16 @@ describe('local upload artifact cleanup', () => { await mkdir(testUploadDirectory, { recursive: true }) }) - it('removes expired multipart and staging entries while retaining fresh entries', async () => { + it('removes expired multipart entries while retaining fresh entries', async () => { const now = Date.UTC(2026, 7, 4, 12) await createArtifact('.multipart/expired', now - LOCAL_UPLOAD_ARTIFACT_TTL_MS - 1) - await createArtifact('upload-sessions/expired', now - LOCAL_UPLOAD_ARTIFACT_TTL_MS - 1) - await createArtifact('upload-sessions/fresh', now) + await createArtifact('.multipart/fresh', now) - await expect(sweepLocalUploadArtifacts({ now })).resolves.toEqual({ scanned: 3, removed: 2 }) + await expect(sweepLocalUploadArtifacts({ now })).resolves.toEqual({ scanned: 2, removed: 1 }) await expect(stat(`${testUploadDirectory}/.multipart/expired`)).rejects.toMatchObject({ code: 'ENOENT', }) - await expect(stat(`${testUploadDirectory}/upload-sessions/expired`)).rejects.toMatchObject({ - code: 'ENOENT', - }) - await expect(stat(`${testUploadDirectory}/upload-sessions/fresh`)).resolves.toBeDefined() + await expect(stat(`${testUploadDirectory}/.multipart/fresh`)).resolves.toBeDefined() }) it('bounds each sweep by the requested entry count', async () => { diff --git a/apps/sim/lib/uploads/upload-session/cleanup.ts b/apps/sim/lib/uploads/upload-session/cleanup.ts index bdb39b75cb9..b31cc9558e2 100644 --- a/apps/sim/lib/uploads/upload-session/cleanup.ts +++ b/apps/sim/lib/uploads/upload-session/cleanup.ts @@ -15,7 +15,7 @@ export interface LocalUploadCleanupResult { let activeCleanup: Promise | null = null let lastCleanupAt = 0 -const CLEANUP_ROOTS = ['.multipart', 'upload-sessions'] as const +const CLEANUP_ROOTS = ['.multipart'] as const interface CleanupRootState { directory: Awaited> | null @@ -25,7 +25,7 @@ const cleanupRootStates: CleanupRootState[] = CLEANUP_ROOTS.map(() => ({ directo let nextCleanupRootIndex = 0 /** - * Opportunistically removes expired local multipart and staged-PUT state. + * Opportunistically removes expired local multipart state. * Calls are single-flight and rate-limited; each sweep examines a bounded number of entries. */ export function maybeCleanupLocalUploadArtifacts( diff --git a/apps/sim/lib/uploads/upload-session/provider.test.ts b/apps/sim/lib/uploads/upload-session/provider.test.ts index 7fa9990ea11..7fc8edd9ec6 100644 --- a/apps/sim/lib/uploads/upload-session/provider.test.ts +++ b/apps/sim/lib/uploads/upload-session/provider.test.ts @@ -19,11 +19,11 @@ vi.mock('@/lib/uploads/config', () => ({ getStorageConfig: vi.fn(() => ({})), })) -import { deleteFile } from '@/lib/uploads/core/storage-service' import { + completeMultipartProviderUpload, headProviderObject, LocalUploadBodyError, - promoteProviderObject, + listMultipartProviderParts, writeLocalMultipartPart, writeLocalPutObject, } from '@/lib/uploads/upload-session/provider' @@ -46,20 +46,20 @@ describe('local upload-session provider', () => { it('streams an exact-size PUT and persists its object identity', async () => { await writeLocalPutObject({ uploadId: 'upload-1', - stagingKey: 'upload-sessions/upload-1/file.bin', + key: 'workspace/workspace-1/file.bin', body: byteStream('ab', 'cd'), expectedSize: 4, contentType: 'application/octet-stream', metadata: METADATA, }) - await expect(readFile(localPath('upload-sessions/upload-1/file.bin'), 'utf8')).resolves.toBe( + await expect(readFile(localPath('workspace/workspace-1/file.bin'), 'utf8')).resolves.toBe( 'abcd' ) await expect( headProviderObject({ provider: 'local', - key: 'upload-sessions/upload-1/file.bin', + key: 'workspace/workspace-1/file.bin', context: CONTEXT, }) ).resolves.toMatchObject({ @@ -68,26 +68,26 @@ describe('local upload-session provider', () => { uploadId: 'upload-1', version: expect.any(String), }) - expect(await temporaryFiles('upload-sessions/upload-1')).toEqual([]) + expect(await temporaryFiles('workspace/workspace-1')).toEqual([]) }) it('persists an empty PUT object with its identity metadata', async () => { await writeLocalPutObject({ uploadId: 'upload-1', - stagingKey: 'upload-sessions/upload-1/empty.md', + key: 'workspace/workspace-1/empty.md', body: byteStream(), expectedSize: 0, contentType: 'text/markdown', metadata: METADATA, }) - await expect(stat(localPath('upload-sessions/upload-1/empty.md'))).resolves.toMatchObject({ + await expect(stat(localPath('workspace/workspace-1/empty.md'))).resolves.toMatchObject({ size: 0, }) await expect( headProviderObject({ provider: 'local', - key: 'upload-sessions/upload-1/empty.md', + key: 'workspace/workspace-1/empty.md', context: CONTEXT, }) ).resolves.toMatchObject({ @@ -98,6 +98,20 @@ describe('local upload-session provider', () => { }) }) + it('does not let a replayed PUT overwrite the final object', 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') }) + + await expect(writeLocalPutObject({ ...params, body: byteStream('two') })).rejects.toThrow() + await expect(readFile(localPath(params.key), 'utf8')).resolves.toBe('one') + }) + it.each([ { name: 'short', chunks: ['ab'], expectedSize: 3 }, { name: 'oversized', chunks: ['ab', 'cd'], expectedSize: 3 }, @@ -105,7 +119,7 @@ describe('local upload-session provider', () => { await expect( writeLocalPutObject({ uploadId: 'upload-1', - stagingKey: 'upload-sessions/upload-1/file.bin', + key: 'workspace/workspace-1/file.bin', body: byteStream(...chunks), expectedSize, contentType: 'application/octet-stream', @@ -116,11 +130,11 @@ describe('local upload-session provider', () => { await expect( headProviderObject({ provider: 'local', - key: 'upload-sessions/upload-1/file.bin', + key: 'workspace/workspace-1/file.bin', context: CONTEXT, }) ).resolves.toBeNull() - expect(await temporaryFiles('upload-sessions/upload-1')).toEqual([]) + expect(await temporaryFiles('workspace/workspace-1')).toEqual([]) }) it('publishes a multipart part atomically after exact-size validation', async () => { @@ -146,69 +160,47 @@ describe('local upload-session provider', () => { expect(await temporaryFiles('.multipart/upload-1')).toEqual([]) }) - it('promotes only the inspected source version into a new destination', async () => { - const stagingKey = 'upload-sessions/upload-1/file.bin' - await writeLocalPutObject({ + it('discovers local parts and assembles them directly at the final key', async () => { + await writeLocalMultipartPart({ uploadId: 'upload-1', - stagingKey, - body: byteStream('old'), + partNumber: 1, + body: byteStream('abc'), expectedSize: 3, - contentType: 'application/octet-stream', - metadata: METADATA, }) - const inspected = await requiredLocalHead(stagingKey) + await writeLocalMultipartPart({ + uploadId: 'upload-1', + partNumber: 2, + body: byteStream('de'), + expectedSize: 2, + }) - await promoteProviderObject({ + const parts = await listMultipartProviderParts({ provider: 'local', - sourceKey: stagingKey, - destinationKey: 'workspace/workspace-1/file.bin', - sourceVersion: inspected.version, + providerUploadId: null, + uploadId: 'upload-1', + key: 'workspace/workspace-1/file.bin', context: CONTEXT, }) + expect(parts).toEqual([ + { partNumber: 1, size: 3 }, + { partNumber: 2, size: 2 }, + ]) - await expect(readFile(localPath('workspace/workspace-1/file.bin'), 'utf8')).resolves.toBe('old') - await expect( - promoteProviderObject({ - provider: 'local', - sourceKey: stagingKey, - destinationKey: 'workspace/workspace-1/file.bin', - sourceVersion: inspected.version, - context: CONTEXT, - }) - ).rejects.toMatchObject({ code: 'EEXIST' }) - - await writeLocalPutObject({ + await completeMultipartProviderUpload({ + provider: 'local', + providerUploadId: null, uploadId: 'upload-1', - stagingKey, - body: byteStream('new'), - expectedSize: 3, + key: 'workspace/workspace-1/file.bin', contentType: 'application/octet-stream', + context: CONTEXT, + parts, metadata: METADATA, }) - await expect( - promoteProviderObject({ - provider: 'local', - sourceKey: stagingKey, - destinationKey: 'workspace/workspace-1/changed.bin', - sourceVersion: inspected.version, - context: CONTEXT, - }) - ).rejects.toThrow('Local staging object changed during promotion') - await expect( - headProviderObject({ - provider: 'local', - key: 'workspace/workspace-1/changed.bin', - context: CONTEXT, - }) - ).resolves.toBeNull() - await deleteFile({ key: 'workspace/workspace-1/file.bin', context: CONTEXT }) - await expect(stat(localPath('workspace/workspace-1/file.bin'))).rejects.toMatchObject({ - code: 'ENOENT', - }) - await expect( - stat(localPath('workspace/workspace-1/file.bin.upload-metadata.json')) - ).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(readFile(localPath('workspace/workspace-1/file.bin'), 'utf8')).resolves.toBe( + 'abcde' + ) + await expect(stat(localPath('.multipart/upload-1'))).rejects.toMatchObject({ code: 'ENOENT' }) }) }) @@ -235,9 +227,3 @@ async function temporaryFiles(relativeDirectory: string): Promise { ) return entries.filter((entry) => entry.startsWith('.')) } - -async function requiredLocalHead(key: string) { - const head = await headProviderObject({ provider: 'local', key, context: CONTEXT }) - if (!head) throw new Error(`Missing local test object ${key}`) - return head -} diff --git a/apps/sim/lib/uploads/upload-session/provider.ts b/apps/sim/lib/uploads/upload-session/provider.ts index f40f74ee921..add6cd687d6 100644 --- a/apps/sim/lib/uploads/upload-session/provider.ts +++ b/apps/sim/lib/uploads/upload-session/provider.ts @@ -1,5 +1,16 @@ import { createReadStream, createWriteStream } from 'node:fs' -import { link, mkdir, readFile, rename, rm, rmdir, stat, unlink, writeFile } from 'node:fs/promises' +import { + link, + mkdir, + readdir, + readFile, + rename, + rm, + rmdir, + stat, + unlink, + writeFile, +} from 'node:fs/promises' import { dirname, join } from 'node:path' import { pipeline } from 'node:stream/promises' import { getErrorMessage } from '@sim/utils/errors' @@ -17,15 +28,16 @@ import { createS3Config, LOCAL_UPLOAD_METADATA_SUFFIX, } from '@/lib/uploads/core/storage-service' -import type { UploadStorageProvider } from '@/lib/uploads/core/upload-token' import type { StorageContext } from '@/lib/uploads/shared/types' +import type { UploadStorageProvider } from '@/lib/uploads/upload-session/types' import { sanitizeFileKey } from '@/lib/uploads/utils/file-utils' -export type { UploadStorageProvider } from '@/lib/uploads/core/upload-token' +export type { UploadStorageProvider } from '@/lib/uploads/upload-session/types' export interface CompletedUploadPart { partNumber: number etag?: string + size: number } export interface UploadPartUrl { @@ -63,7 +75,7 @@ export function uploadStorageProvider(): UploadStorageProvider { } export async function initiateMultipartProviderUpload(params: { - stagingKey: string + key: string fileName: string contentType: string fileSize: number @@ -82,7 +94,7 @@ export async function initiateMultipartProviderUpload(params: { contentType: params.contentType, fileSize: params.fileSize, customConfig: createS3Config(config), - customKey: params.stagingKey, + customKey: params.key, purpose: params.context, metadata, }) @@ -95,7 +107,7 @@ export async function initiateMultipartProviderUpload(params: { contentType: params.contentType, fileSize: params.fileSize, customConfig: createBlobConfig(config), - customKey: params.stagingKey, + customKey: params.key, metadata, }) return { provider, providerUploadId: result.uploadId } @@ -107,7 +119,7 @@ export async function initiateMultipartProviderUpload(params: { contentType: params.contentType, fileSize: params.fileSize, customConfig: createGcsConfig(config), - customKey: params.stagingKey, + customKey: params.key, purpose: params.context, metadata, }) @@ -120,7 +132,7 @@ export async function initiateMultipartProviderUpload(params: { export async function createPutProviderTransfer(params: { provider: UploadStorageProvider - stagingKey: string + key: string contentType: string fileSize: number context: StorageContext @@ -152,7 +164,7 @@ export async function createPutProviderTransfer(params: { if (params.provider === 's3') { const { getS3PresignedUploadUrl } = await import('@/lib/uploads/providers/s3/client') const transfer = await getS3PresignedUploadUrl({ - key: params.stagingKey, + key: params.key, contentType: params.contentType, fileSize: params.fileSize, metadata, @@ -164,7 +176,7 @@ export async function createPutProviderTransfer(params: { if (params.provider === 'blob') { const { getBlobPresignedUploadUrl } = await import('@/lib/uploads/providers/blob/client') const transfer = await getBlobPresignedUploadUrl({ - key: params.stagingKey, + key: params.key, contentType: params.contentType, metadata, customConfig: createBlobConfig(config), @@ -174,7 +186,7 @@ export async function createPutProviderTransfer(params: { } const { getGcsPresignedUploadUrl } = await import('@/lib/uploads/providers/gcs/client') const transfer = await getGcsPresignedUploadUrl( - params.stagingKey, + params.key, params.contentType, metadata, createGcsConfig(config), @@ -186,7 +198,7 @@ export async function createPutProviderTransfer(params: { export async function getMultipartProviderPartUrls(params: { provider: UploadStorageProvider providerUploadId: string | null - stagingKey: string + key: string context: StorageContext partNumbers: number[] localUrl: (partNumber: number) => string @@ -206,7 +218,7 @@ export async function getMultipartProviderPartUrls(params: { if (params.provider === 's3') { const { getS3MultipartPartUrls } = await import('@/lib/uploads/providers/s3/client') const urls = await getS3MultipartPartUrls( - params.stagingKey, + params.key, params.providerUploadId, params.partNumbers, createS3Config(config) @@ -221,7 +233,7 @@ export async function getMultipartProviderPartUrls(params: { if (params.provider === 'blob') { const { getMultipartPartUrls } = await import('@/lib/uploads/providers/blob/client') const urls = await getMultipartPartUrls( - params.stagingKey, + params.key, params.partNumbers, createBlobConfig(config) ) @@ -234,7 +246,7 @@ export async function getMultipartProviderPartUrls(params: { } const { getGcsMultipartPartUrls } = await import('@/lib/uploads/providers/gcs/client') const urls = await getGcsMultipartPartUrls( - params.stagingKey, + params.key, params.providerUploadId, params.partNumbers, createGcsConfig(config) @@ -247,11 +259,33 @@ export async function getMultipartProviderPartUrls(params: { })) } +export async function listMultipartProviderParts(params: { + provider: UploadStorageProvider + providerUploadId: string | null + uploadId: string + key: string + context: StorageContext +}): Promise { + if (params.provider === 'local') return listLocalMultipartParts(params.uploadId) + if (!params.providerUploadId) throw new Error(`Missing ${params.provider} multipart upload id`) + const config = getStorageConfig(params.context) + if (params.provider === 's3') { + const { listS3MultipartParts } = await import('@/lib/uploads/providers/s3/client') + return listS3MultipartParts(params.key, params.providerUploadId, createS3Config(config)) + } + if (params.provider === 'blob') { + const { listMultipartParts } = await import('@/lib/uploads/providers/blob/client') + return listMultipartParts(params.key, createBlobConfig(config)) + } + const { listGcsMultipartParts } = await import('@/lib/uploads/providers/gcs/client') + return listGcsMultipartParts(params.key, params.providerUploadId, createGcsConfig(config)) +} + export async function completeMultipartProviderUpload(params: { provider: UploadStorageProvider providerUploadId: string | null uploadId: string - stagingKey: string + key: string contentType: string context: StorageContext parts: CompletedUploadPart[] @@ -260,7 +294,7 @@ export async function completeMultipartProviderUpload(params: { if (params.provider === 'local') { await assembleLocalParts( params.uploadId, - params.stagingKey, + params.key, params.parts, params.contentType, params.metadata @@ -272,7 +306,7 @@ export async function completeMultipartProviderUpload(params: { if (params.provider === 's3') { const { completeS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') await completeS3MultipartUpload( - params.stagingKey, + params.key, params.providerUploadId, params.parts.map((part) => ({ PartNumber: part.partNumber, @@ -287,7 +321,7 @@ export async function completeMultipartProviderUpload(params: { '@/lib/uploads/providers/blob/client' ) await completeMultipartUpload( - params.stagingKey, + params.key, params.parts.map((part) => ({ partNumber: part.partNumber, blockId: deriveBlobBlockId(part.partNumber), @@ -300,7 +334,7 @@ export async function completeMultipartProviderUpload(params: { } const { completeGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') await completeGcsMultipartUpload( - params.stagingKey, + params.key, params.providerUploadId, params.parts.map((part) => ({ PartNumber: part.partNumber, @@ -341,47 +375,6 @@ export async function headProviderObject(params: { } } -export async function promoteProviderObject(params: { - provider: UploadStorageProvider - sourceKey: string - destinationKey: string - sourceVersion: string - context: StorageContext -}): Promise { - if (params.provider === 'local') { - await promoteLocalObject(params.sourceKey, params.destinationKey, params.sourceVersion) - return - } - const config = getStorageConfig(params.context) - if (params.provider === 's3') { - const { promoteS3Object } = await import('@/lib/uploads/providers/s3/client') - await promoteS3Object({ - sourceKey: params.sourceKey, - destinationKey: params.destinationKey, - sourceEtag: params.sourceVersion, - customConfig: createS3Config(config), - }) - return - } - if (params.provider === 'blob') { - const { promoteBlobObject } = await import('@/lib/uploads/providers/blob/client') - await promoteBlobObject({ - sourceKey: params.sourceKey, - destinationKey: params.destinationKey, - sourceEtag: params.sourceVersion, - customConfig: createBlobConfig(config), - }) - return - } - const { promoteGcsObject } = await import('@/lib/uploads/providers/gcs/client') - await promoteGcsObject({ - sourceKey: params.sourceKey, - destinationKey: params.destinationKey, - sourceGeneration: params.sourceVersion, - customConfig: createGcsConfig(config), - }) -} - export async function deleteProviderObjectVersion(params: { provider: UploadStorageProvider key: string @@ -424,12 +417,11 @@ export async function abortProviderUpload(params: { method: 'put' | 'multipart' providerUploadId: string | null uploadId: string - stagingKey: string + key: string context: StorageContext }): Promise { if (params.provider === 'local') { await rm(localPartsDirectory(params.uploadId), { recursive: true, force: true }) - await rm(localUploadDirectory(params.uploadId), { recursive: true, force: true }) return } @@ -440,49 +432,28 @@ export async function abortProviderUpload(params: { } if (params.provider === 's3') { const { abortS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client') - await abortS3MultipartUpload( - params.stagingKey, - params.providerUploadId, - createS3Config(config) - ) + await abortS3MultipartUpload(params.key, params.providerUploadId, createS3Config(config)) } else if (params.provider === 'blob') { const { abortMultipartUpload } = await import('@/lib/uploads/providers/blob/client') - await abortMultipartUpload(params.stagingKey, createBlobConfig(config)) + await abortMultipartUpload(params.key, createBlobConfig(config)) } else { const { abortGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client') - await abortGcsMultipartUpload( - params.stagingKey, - params.providerUploadId, - createGcsConfig(config) - ) + await abortGcsMultipartUpload(params.key, params.providerUploadId, createGcsConfig(config)) } } - - if (params.provider === 's3') { - const { deleteFromS3 } = await import('@/lib/uploads/providers/s3/client') - await deleteFromS3(params.stagingKey, createS3Config(config)) - } else if (params.provider === 'blob') { - const { deleteFromBlob } = await import('@/lib/uploads/providers/blob/client') - await deleteFromBlob(params.stagingKey, createBlobConfig(config)) - } else { - const { deleteFromGcs } = await import('@/lib/uploads/providers/gcs/client') - await deleteFromGcs(params.stagingKey, createGcsConfig(config)) - } } export async function writeLocalPutObject(params: { uploadId: string - stagingKey: string + key: string body: ReadableStream expectedSize: number contentType: string metadata: Record }): Promise { - assertLocalStagingKey(params.stagingKey, params.uploadId) const { Readable, Transform } = await import('node:stream') - const directory = localUploadDirectory(params.uploadId) - const destination = localObjectPath(params.stagingKey) - const temporary = join(directory, `.put-${generateId()}`) + 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 }) let bytes = 0 @@ -511,13 +482,12 @@ export async function writeLocalPutObject(params: { contentType: params.contentType, metadata: { ...params.metadata, uploadId: params.uploadId }, }) - await rename(temporary, destination) - try { - await rename(temporaryMetadata, localMetadataPath(params.stagingKey)) - } catch (error) { - await rm(destination, { force: true }) - throw error - } + await publishLocalObject( + temporary, + temporaryMetadata, + destination, + localMetadataPath(params.key) + ) } catch (error) { await Promise.allSettled([ rm(temporary, { force: true }), @@ -577,10 +547,6 @@ function localPartsDirectory(uploadId: string): string { return join(UPLOAD_DIR_SERVER, '.multipart', uploadId) } -function localUploadDirectory(uploadId: string): string { - return join(UPLOAD_DIR_SERVER, 'upload-sessions', uploadId) -} - function localPartPath(uploadId: string, partNumber: number): string { return join(localPartsDirectory(uploadId), `${partNumber}.part`) } @@ -595,14 +561,13 @@ function localMetadataPath(key: string): string { async function assembleLocalParts( uploadId: string, - stagingKey: string, + key: string, parts: CompletedUploadPart[], contentType: string, metadata: Record ): Promise { - assertLocalStagingKey(stagingKey, uploadId) - const destination = localObjectPath(stagingKey) - const temporary = join(localUploadDirectory(uploadId), `.multipart-${generateId()}`) + const destination = localObjectPath(key) + const temporary = `${destination}.${uploadId}-${generateId()}.tmp` const temporaryMetadata = `${temporary}${LOCAL_UPLOAD_METADATA_SUFFIX}` await mkdir(dirname(destination), { recursive: true }) try { @@ -617,13 +582,7 @@ async function assembleLocalParts( contentType, metadata: { ...metadata, uploadId }, }) - await rename(temporary, destination) - try { - await rename(temporaryMetadata, localMetadataPath(stagingKey)) - } catch (error) { - await rm(destination, { force: true }) - throw error - } + await publishLocalObject(temporary, temporaryMetadata, destination, localMetadataPath(key)) await rm(localPartsDirectory(uploadId), { recursive: true, force: true }) } catch (error) { await Promise.allSettled([ @@ -634,60 +593,59 @@ async function assembleLocalParts( } } -async function headLocalObject(key: string): Promise { - const path = localObjectPath(key) - let file: Awaited> +async function listLocalMultipartParts(uploadId: string): Promise { + const directory = localPartsDirectory(uploadId) + let entries: string[] try { - file = await stat(path) + entries = await readdir(directory, { encoding: 'utf8' }) } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] throw error } - const metadata = await readLocalMetadata(localMetadataPath(key)) - return { - size: file.size, - contentType: metadata.contentType, - uploadId: metadata.uploadId, - version: localVersion(file), + + const parts: CompletedUploadPart[] = [] + for (const entry of entries) { + if (entry.startsWith('.')) continue + const match = entry.match(/^(\d+)\.part$/) + if (!match) throw new Error(`Invalid local multipart artifact ${entry}`) + const file = await stat(join(directory, entry)) + if (!file.isFile()) throw new Error(`Local multipart artifact ${entry} is not a file`) + parts.push({ partNumber: Number(match[1]), size: file.size }) } + return parts } -async function promoteLocalObject( - sourceKey: string, - destinationKey: string, - sourceVersion: string +async function publishLocalObject( + temporary: string, + temporaryMetadata: string, + destination: string, + destinationMetadata: string ): Promise { - const source = localObjectPath(sourceKey) - const sourceMetadata = localMetadataPath(sourceKey) - const destination = localObjectPath(destinationKey) - const destinationMetadata = localMetadataPath(destinationKey) - const metadata = await readLocalMetadata(sourceMetadata) - await mkdir(dirname(destination), { recursive: true }) - - let createdMetadata = false + await link(temporary, destination) try { - await link(sourceMetadata, destinationMetadata) - createdMetadata = true + await link(temporaryMetadata, destinationMetadata) } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error - const existing = await readLocalMetadata(destinationMetadata) - if (existing.uploadId !== metadata.uploadId) throw error + await rm(destination, { force: true }) + throw error } + await Promise.all([rm(temporary), rm(temporaryMetadata)]) +} +async function headLocalObject(key: string): Promise { + const path = localObjectPath(key) + let file: Awaited> try { - await link(source, destination) + file = await stat(path) } catch (error) { - if (createdMetadata) await rm(destinationMetadata, { force: true }) + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null throw error } - - const destinationStat = await stat(destination) - if (localVersion(destinationStat) !== sourceVersion) { - await Promise.allSettled([ - rm(destination, { force: true }), - ...(createdMetadata ? [rm(destinationMetadata, { force: true })] : []), - ]) - throw new Error('Local staging object changed during promotion') + const metadata = await readLocalMetadata(localMetadataPath(key)) + return { + size: file.size, + contentType: metadata.contentType, + uploadId: metadata.uploadId, + version: localVersion(file), } } @@ -742,12 +700,6 @@ function localVersion(file: Awaited>): string { return `${file.dev}:${file.ino}:${file.size}:${file.mtimeMs}` } -function assertLocalStagingKey(stagingKey: string, uploadId: string): void { - if (!stagingKey.startsWith(`upload-sessions/${uploadId}/`)) { - throw new Error('Local staging key does not belong to this upload') - } -} - function requiredEtag(provider: 's3' | 'gcs', part: CompletedUploadPart): string { if (!part.etag) throw new Error(`Missing etag for ${provider} part ${part.partNumber}`) return part.etag diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts index d228c24980a..2b914972a82 100644 --- a/apps/sim/lib/uploads/upload-session/service.test.ts +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -1,25 +1,30 @@ /** * @vitest-environment node */ +import { sha256Hex } from '@sim/security/hash' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { inArray } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' const { + mockAbortProviderUpload, mockCheckStorageQuota, mockCompleteMultipart, mockCreatePutTransfer, mockDeleteObjectVersion, mockHeadObject, mockInitiateMultipart, - mockPromoteObject, + mockListMultipartParts, mockResolveBillingContext, } = vi.hoisted(() => ({ + mockAbortProviderUpload: vi.fn(), mockCheckStorageQuota: vi.fn(), mockCompleteMultipart: vi.fn(), mockCreatePutTransfer: vi.fn(), mockDeleteObjectVersion: vi.fn(), mockHeadObject: vi.fn(), mockInitiateMultipart: vi.fn(), - mockPromoteObject: vi.fn(), + mockListMultipartParts: vi.fn(), mockResolveBillingContext: vi.fn(), })) @@ -39,40 +44,42 @@ vi.mock('@/lib/uploads/upload-session/cleanup', () => ({ })) vi.mock('@/lib/uploads/upload-session/provider', () => ({ - abortProviderUpload: vi.fn(), + abortProviderUpload: mockAbortProviderUpload, completeMultipartProviderUpload: mockCompleteMultipart, createPutProviderTransfer: mockCreatePutTransfer, deleteProviderObjectVersion: mockDeleteObjectVersion, getMultipartProviderPartUrls: vi.fn(), headProviderObject: mockHeadObject, initiateMultipartProviderUpload: mockInitiateMultipart, - promoteProviderObject: mockPromoteObject, + listMultipartProviderParts: mockListMultipartParts, uploadStorageProvider: vi.fn(() => 's3'), })) import { - MAX_WORKSPACE_FILE_SIZE, - MAX_WORKSPACE_FORMDATA_FILE_SIZE, -} from '@/lib/uploads/shared/types' -import { + abortUploadSession, + cleanupExpiredUploadSessions, completeUploadSession, createUploadSession, + getOwnedUploadSession, + UPLOAD_SESSION_PART_SIZE, UPLOAD_SESSION_PUT_MAX_BYTES, - validateUploadCompletion, + type UploadSessionRecord, verifyUploadSessionToken, } from '@/lib/uploads/upload-session/service' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const FINAL_KEY = `workspace/${WORKSPACE_ID}/final-file.bin` describe('upload sessions', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockResolveBillingContext.mockResolvedValue({ workspaceId: WORKSPACE_ID }) mockCheckStorageQuota.mockResolvedValue({ allowed: true }) mockCreatePutTransfer.mockResolvedValue({ method: 'put', url: 'https://storage.example/upload', - headers: { 'Content-Type': 'application/octet-stream' }, + headers: { 'Content-Type': 'application/octet-stream', 'If-None-Match': '*' }, }) mockInitiateMultipart.mockResolvedValue({ provider: 's3', @@ -80,330 +87,265 @@ describe('upload sessions', () => { }) }) - it('selects PUT at exactly 50 MiB', async () => { - const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES) - - expect(created.transfer.method).toBe('put') - expect(created.method).toBe('put') - expect(created.partSize).toBeNull() - expect(created.partCount).toBeNull() - expect(mockInitiateMultipart).not.toHaveBeenCalled() - }) + it('persists a hashed token and signs a create-only PUT at the final key', async () => { + const row = uploadRow({ fileSize: UPLOAD_SESSION_PUT_MAX_BYTES }) + dbChainMockFns.returning.mockResolvedValueOnce([row]) - it('creates a PUT session for an empty workspace file', async () => { - const created = await createWorkspaceUpload(0) + const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES) expect(created).toMatchObject({ - purpose: 'workspace_file', - fileSize: 0, + id: 'upload-1', method: 'put', + finalKey: FINAL_KEY, + storageKey: FINAL_KEY, + partSize: null, + partCount: null, transfer: { method: 'put' }, }) - expect(verifyUploadSessionToken(created.uploadToken)).toMatchObject({ - purpose: 'workspace_file', - fileSize: 0, - method: 'put', - }) - }) - - it('rejects an empty upload for non-workspace-file purposes', async () => { - await expect( - createUploadSession({ - id: 'empty-attachment', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - purpose: 'mothership_attachment', - fileName: 'empty.txt', - contentType: 'text/plain', - fileSize: 0, - }) - ).rejects.toThrow('fileSize must be a positive integer') - }) - - it('rejects a negative workspace-file size', async () => { - await expect(createWorkspaceUpload(-1)).rejects.toThrow( - 'fileSize must be a non-negative integer' + expect(mockInitiateMultipart).not.toHaveBeenCalled() + expect(mockCreatePutTransfer).toHaveBeenCalledWith( + expect.objectContaining({ key: FINAL_KEY, uploadId: 'upload-1' }) ) + const inserted = dbChainMockFns.values.mock.calls[0][0] + expect(inserted.tokenHash).toBe(sha256Hex(created.uploadToken)) + expect(inserted.tokenHash).not.toBe(created.uploadToken) + expect(inserted.finalKey).toBe(FINAL_KEY) }) - it('selects multipart at 50 MiB plus one byte', async () => { - const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) - - expect(created.transfer).toMatchObject({ method: 'multipart', partCount: 7 }) - expect(created.method).toBe('multipart') - expect(mockInitiateMultipart).toHaveBeenCalledOnce() - }) - - it('binds purpose scope, staging, destination, method, and identity into the token', async () => { - const created = await createUploadSession({ - id: 'upload-1', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - knowledgeBaseId: 'kb-1', - purpose: 'knowledge_document', - fileName: 'guide.pdf', - contentType: 'application/pdf', - fileSize: 1024, - }) - - expect(verifyUploadSessionToken(created.uploadToken)).toMatchObject({ - id: 'upload-1', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - knowledgeBaseId: 'kb-1', - purpose: 'knowledge_document', - method: 'put', - storageContext: 'knowledge-base', - storageProvider: 's3', - stagingKey: 'upload-sessions/upload-1/guide.pdf', - fileName: 'guide.pdf', - contentType: 'application/pdf', - fileSize: 1024, + it('initiates multipart storage directly at the final key', async () => { + const fileSize = UPLOAD_SESSION_PUT_MAX_BYTES + 1 + const row = uploadRow({ + fileSize, + method: 'multipart', + providerUploadId: 'provider-upload-1', + partSize: UPLOAD_SESSION_PART_SIZE, + partCount: Math.ceil(fileSize / UPLOAD_SESSION_PART_SIZE), }) - }) + dbChainMockFns.returning.mockResolvedValueOnce([row]) - it('quota-gates durable files while exempting retention-scoped attachments', async () => { - await createWorkspaceUpload(1024) - expect(mockResolveBillingContext).toHaveBeenCalledOnce() - expect(mockCheckStorageQuota).toHaveBeenCalledOnce() - - await createUploadSession({ - id: 'execution-upload', - workspaceId: WORKSPACE_ID, - workflowId: 'workflow-1', - executionId: 'execution-1', - userId: 'user-1', - purpose: 'execution_attachment', - fileName: 'result.txt', - contentType: 'text/plain', - fileSize: 1024, - }) - expect(mockResolveBillingContext).toHaveBeenCalledOnce() - expect(mockCheckStorageQuota).toHaveBeenCalledOnce() - - await createUploadSession({ - id: 'mothership-upload', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - purpose: 'mothership_attachment', - fileName: 'prompt.txt', - contentType: 'text/plain', - fileSize: 1024, - }) - expect(mockResolveBillingContext).toHaveBeenCalledOnce() - expect(mockCheckStorageQuota).toHaveBeenCalledOnce() - }) + const created = await createWorkspaceUpload(fileSize) - it('preserves the 5 GiB mothership limit while bounding execution attachments at 100 MiB', async () => { - await expect( - createUploadSession({ - id: 'mothership-upload', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - purpose: 'mothership_attachment', - fileName: 'archive.zip', - contentType: 'application/zip', - fileSize: MAX_WORKSPACE_FILE_SIZE, - }) - ).resolves.toMatchObject({ + expect(created.transfer).toEqual({ method: 'multipart', - transfer: { method: 'multipart', partCount: 640 }, + partSize: UPLOAD_SESSION_PART_SIZE, + partCount: 7, }) - - await expect( - createUploadSession({ - id: 'oversized-mothership-upload', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - purpose: 'mothership_attachment', - fileName: 'archive.zip', - contentType: 'application/zip', - fileSize: MAX_WORKSPACE_FILE_SIZE + 1, - }) - ).rejects.toThrow(`File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes`) - - await expect( - createUploadSession({ - id: 'execution-upload', - workspaceId: WORKSPACE_ID, - workflowId: 'workflow-1', - executionId: 'execution-1', - userId: 'user-1', - purpose: 'execution_attachment', - fileName: 'result.txt', - contentType: 'text/plain', - fileSize: MAX_WORKSPACE_FORMDATA_FILE_SIZE + 1, - }) - ).rejects.toThrow(`File size exceeds maximum of ${MAX_WORKSPACE_FORMDATA_FILE_SIZE} bytes`) - }) - - it('validates PUT completion input independently of finalization', async () => { - const created = await createWorkspaceUpload(1024) - - expect(validateUploadCompletion(created, {})).toEqual([]) - expect(() => validateUploadCompletion(created, { parts: [] })).toThrow( - 'PUT completion must not include parts' - ) - }) - - it('requires every multipart part and cloud ETag before finalization or replay', async () => { - const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) - const parts = Array.from({ length: created.partCount ?? 0 }, (_, index) => ({ - partNumber: index + 1, - etag: `etag-${index + 1}`, - })) - - expect(validateUploadCompletion(created, { parts })).toBe(parts) - expect(() => validateUploadCompletion(created, {})).toThrow( - 'Multipart completion requires parts' + expect(mockInitiateMultipart).toHaveBeenCalledWith( + expect.objectContaining({ key: FINAL_KEY, uploadId: 'upload-1' }) ) - expect(() => - validateUploadCompletion(created, { parts: parts.map(({ partNumber }) => ({ partNumber })) }) - ).toThrow('etag is required for s3 part 1') + expect(mockCreatePutTransfer).not.toHaveBeenCalled() }) - it('resumes after multipart assembly without consuming the provider upload twice', async () => { - const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) - const identity = objectIdentity(created.id, created.fileSize, created.contentType) - mockHeadObject - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(identity) - .mockResolvedValueOnce(identity) - .mockResolvedValueOnce(identity) + it('loads ownership from PostgreSQL and rejects a mismatched token', async () => { + const token = 'upload-secret' + const row = uploadRow({ tokenHash: sha256Hex(token) }) + queueTableRows(schemaMock.uploadSession, [row]) + queueTableRows(schemaMock.uploadSession, [row]) + queueTableRows(schemaMock.uploadSession, [row]) await expect( - completeUploadSession({ - session: created, - completion: { parts: completedParts(created.partCount) }, - finalize: async () => ({ value: 'file-1', completedFileId: 'file-1' }), + getOwnedUploadSession({ + uploadId: row.id, + uploadToken: token, + userId: row.userId, + workspaceId: row.workspaceId, + purpose: row.purpose, }) - ).resolves.toMatchObject({ value: 'file-1', alreadyCompleted: false }) - - expect(mockCompleteMultipart).not.toHaveBeenCalled() - expect(mockPromoteObject).toHaveBeenCalledOnce() + ).resolves.toMatchObject({ id: row.id, finalKey: row.finalKey }) + await expect( + getOwnedUploadSession({ uploadId: row.id, uploadToken: 'wrong-token' }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect(verifyUploadSessionToken(token)).resolves.toMatchObject({ id: row.id }) }) - it('completes multipart at staging when no assembled object exists yet', async () => { - const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) - const identity = objectIdentity(created.id, created.fileSize, created.contentType) + it('completes multipart from the provider part listing without a client manifest', async () => { + const fileSize = UPLOAD_SESSION_PART_SIZE + 3 + const session = sessionRecord({ + fileSize, + method: 'multipart', + providerUploadId: 'provider-upload-1', + partSize: UPLOAD_SESSION_PART_SIZE, + partCount: 2, + }) + const parts = [ + { partNumber: 1, etag: 'etag-1', size: UPLOAD_SESSION_PART_SIZE }, + { partNumber: 2, etag: 'etag-2', size: 3 }, + ] + mockListMultipartParts.mockResolvedValue(parts) mockHeadObject .mockResolvedValueOnce(null) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(identity) - .mockResolvedValueOnce(identity) - .mockResolvedValueOnce(identity) - - await completeUploadSession({ - session: created, - completion: { parts: completedParts(created.partCount) }, - finalize: async () => ({ value: 'file-1' }), + .mockResolvedValueOnce(providerObject(session, 'version-1')) + queueCompletionRows(session, 'version-1') + const finalize = vi.fn().mockResolvedValue({ value: 'file-1', completedFileId: 'file-1' }) + + await expect(completeUploadSession({ session, finalize })).resolves.toMatchObject({ + value: 'file-1', + alreadyCompleted: false, + session: { status: 'completed', providerObjectVersion: 'version-1' }, }) - - expect(mockCompleteMultipart).toHaveBeenCalledOnce() + expect(mockListMultipartParts).toHaveBeenCalledWith( + expect.objectContaining({ key: FINAL_KEY, providerUploadId: 'provider-upload-1' }) + ) expect(mockCompleteMultipart).toHaveBeenCalledWith( - expect.objectContaining({ stagingKey: created.stagingKey }) + expect.objectContaining({ key: FINAL_KEY, parts }) ) + expect(finalize).toHaveBeenCalledOnce() }) - it('recovers when another completion consumes the provider upload concurrently', async () => { - const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) - const identity = objectIdentity(created.id, created.fileSize, created.contentType) - mockCompleteMultipart.mockRejectedValueOnce(new Error('NoSuchUpload')) - mockHeadObject - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(identity) - .mockResolvedValueOnce(identity) - .mockResolvedValueOnce(identity) - - await expect( - completeUploadSession({ - session: created, - completion: { parts: completedParts(created.partCount) }, - finalize: async () => ({ value: 'file-1' }), - }) - ).resolves.toMatchObject({ value: 'file-1', alreadyCompleted: false }) - - expect(mockCompleteMultipart).toHaveBeenCalledOnce() - expect(mockPromoteObject).toHaveBeenCalledOnce() + it('rejects missing or incorrectly sized provider parts before completion', async () => { + const session = sessionRecord({ + fileSize: UPLOAD_SESSION_PART_SIZE + 3, + method: 'multipart', + providerUploadId: 'provider-upload-1', + partSize: UPLOAD_SESSION_PART_SIZE, + partCount: 2, + }) + mockHeadObject.mockResolvedValueOnce(null) + mockListMultipartParts.mockResolvedValueOnce([ + { partNumber: 1, etag: 'etag-1', size: UPLOAD_SESSION_PART_SIZE - 1 }, + { partNumber: 2, etag: 'etag-2', size: 4 }, + ]) + dbChainMockFns.returning.mockResolvedValueOnce([ + uploadRow({ ...rowGeometry(session), status: 'completing' }), + ]) + + await expect(completeUploadSession({ session, finalize: vi.fn() })).rejects.toThrow( + `Provider part 1 has ${UPLOAD_SESSION_PART_SIZE - 1} bytes` + ) + expect(mockCompleteMultipart).not.toHaveBeenCalled() }) - it('preserves the provider completion error when no staged object was created', async () => { - const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) - mockCompleteMultipart.mockRejectedValueOnce(new Error('NoSuchUpload')) - mockHeadObject - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(null) - - await expect( - completeUploadSession({ - session: created, - completion: { parts: completedParts(created.partCount) }, - finalize: async () => ({ value: 'file-1' }), - }) - ).rejects.toThrow('NoSuchUpload') - - expect(mockPromoteObject).not.toHaveBeenCalled() + it('verifies an uploaded PUT object and retains it when domain finalization fails', async () => { + const session = sessionRecord() + mockHeadObject.mockResolvedValue(providerObject(session, 'version-1')) + dbChainMockFns.returning + .mockResolvedValueOnce([uploadRow({ status: 'completing' })]) + .mockResolvedValueOnce([ + uploadRow({ status: 'finalizing', providerObjectVersion: 'version-1' }), + ]) + const finalize = vi.fn().mockRejectedValue(new Error('domain unavailable')) + + await expect(completeUploadSession({ session, finalize })).rejects.toThrow('domain unavailable') + expect(mockDeleteObjectVersion).not.toHaveBeenCalled() + expect(mockAbortProviderUpload).not.toHaveBeenCalled() + expect(mockCompleteMultipart).not.toHaveBeenCalled() }) - it('rejects a mismatched staged multipart object before provider completion', async () => { - const created = await createWorkspaceUpload(UPLOAD_SESSION_PUT_MAX_BYTES + 1) - mockHeadObject.mockResolvedValueOnce(null).mockResolvedValueOnce({ - ...objectIdentity(created.id, created.fileSize, created.contentType), - uploadId: 'another-upload', + it('allows a finalizing session to recover after its upload TTL', async () => { + const session = sessionRecord({ + status: 'finalizing', + expiresAt: new Date(Date.now() - 1), + providerObjectVersion: 'version-1', }) + mockHeadObject.mockResolvedValue(providerObject(session, 'version-1')) + queueCompletionRows(session, 'version-1') await expect( completeUploadSession({ - session: created, - completion: { parts: completedParts(created.partCount) }, - finalize: async () => ({ value: 'file-1' }), + session, + finalize: async () => ({ value: 'recovered', completedFileId: 'file-1' }), }) - ).rejects.toThrow('Uploaded object belongs to another upload') + ).resolves.toMatchObject({ value: 'recovered', alreadyCompleted: true }) + }) - expect(mockCompleteMultipart).not.toHaveBeenCalled() - expect(mockPromoteObject).not.toHaveBeenCalled() + it('deletes a matching completed provider object without aborting its consumed upload id', async () => { + const session = sessionRecord({ + method: 'multipart', + providerUploadId: 'provider-upload-1', + fileSize: UPLOAD_SESSION_PART_SIZE + 1, + partSize: UPLOAD_SESSION_PART_SIZE, + partCount: 2, + }) + mockHeadObject.mockResolvedValue(providerObject(session, 'version-1')) + dbChainMockFns.returning + .mockResolvedValueOnce([uploadRow({ ...rowGeometry(session), status: 'aborting' })]) + .mockResolvedValueOnce([ + uploadRow({ ...rowGeometry(session), status: 'aborted', completedAt: new Date() }), + ]) + + await expect(abortUploadSession(session)).resolves.toMatchObject({ status: 'aborted' }) + expect(mockAbortProviderUpload).not.toHaveBeenCalled() + expect(mockDeleteObjectVersion).toHaveBeenCalledWith({ + provider: 's3', + key: FINAL_KEY, + version: 'version-1', + context: 'workspace', + }) }) - it('retains staging when finalization fails after promotion', async () => { - const created = await createWorkspaceUpload(1024) - const identity = objectIdentity(created.id, created.fileSize, created.contentType) - mockHeadObject - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(identity) - .mockResolvedValueOnce(identity) + it('aborts multipart provider state when no final object exists', async () => { + const session = sessionRecord({ + method: 'multipart', + providerUploadId: 'provider-upload-1', + fileSize: UPLOAD_SESSION_PART_SIZE + 1, + partSize: UPLOAD_SESSION_PART_SIZE, + partCount: 2, + }) + mockHeadObject.mockResolvedValue(null) + dbChainMockFns.returning + .mockResolvedValueOnce([uploadRow({ ...rowGeometry(session), status: 'aborting' })]) + .mockResolvedValueOnce([ + uploadRow({ ...rowGeometry(session), status: 'aborted', completedAt: new Date() }), + ]) + + await expect(abortUploadSession(session)).resolves.toMatchObject({ status: 'aborted' }) + expect(mockAbortProviderUpload).toHaveBeenCalledWith( + expect.objectContaining({ key: FINAL_KEY, providerUploadId: 'provider-upload-1' }) + ) + expect(mockDeleteObjectVersion).not.toHaveBeenCalled() + }) - await expect( - completeUploadSession({ - session: created, - completion: {}, - finalize: async () => { - throw new Error('database unavailable') - }, - }) - ).rejects.toThrow('database unavailable') + it('refuses to abort once domain finalization may have created a resource', async () => { + const session = sessionRecord({ status: 'finalizing' }) - expect(mockPromoteObject).toHaveBeenCalledOnce() + await expect(abortUploadSession(session)).rejects.toThrow( + 'Finalizing upload sessions cannot be aborted' + ) + expect(mockAbortProviderUpload).not.toHaveBeenCalled() expect(mockDeleteObjectVersion).not.toHaveBeenCalled() }) - it('retries finalization from an exact final object, then removes staging conditionally', async () => { - const created = await createWorkspaceUpload(1024) - const identity = objectIdentity(created.id, created.fileSize, created.contentType) - mockHeadObject.mockResolvedValueOnce(identity).mockResolvedValueOnce(identity) - - await expect( - completeUploadSession({ - session: created, - completion: {}, - finalize: async () => ({ value: 'file-1', completedFileId: 'file-1' }), - }) - ).resolves.toMatchObject({ value: 'file-1', alreadyCompleted: true }) + it('cleans expired upload state without selecting sessions that may be finalizing', async () => { + const expired = uploadRow({ expiresAt: new Date(Date.now() - 1) }) + queueTableRows(schemaMock.uploadSession, [expired]) + queueTableRows(schemaMock.uploadSession, []) + mockHeadObject.mockResolvedValue(providerObject(sessionRecord(), 'version-1')) + dbChainMockFns.returning + .mockResolvedValueOnce([uploadRow({ status: 'aborting', expiresAt: expired.expiresAt })]) + .mockResolvedValueOnce([{ id: 'upload-1' }]) + + await expect(cleanupExpiredUploadSessions()).resolves.toEqual({ + expired: 1, + failed: 0, + purged: 0, + }) + expect(mockDeleteObjectVersion).toHaveBeenCalledWith( + expect.objectContaining({ key: FINAL_KEY, version: 'version-1' }) + ) + const candidateStatuses = vi + .mocked(inArray) + .mock.calls.find(([, values]) => values.includes('uploading'))?.[1] + expect(candidateStatuses).toEqual(['uploading', 'completing', 'aborting']) + expect(candidateStatuses).not.toContain('finalizing') + }) - expect(mockPromoteObject).not.toHaveBeenCalled() + it('deletes a late PUT object before purging an aborted session', async () => { + const completedAt = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000) + const aborted = uploadRow({ status: 'aborted', completedAt }) + queueTableRows(schemaMock.uploadSession, []) + queueTableRows(schemaMock.uploadSession, [aborted]) + mockHeadObject.mockResolvedValue(providerObject(sessionRecord(aborted), 'version-1')) + dbChainMockFns.returning + .mockResolvedValueOnce([aborted]) + .mockResolvedValueOnce([{ id: aborted.id }]) + + await expect(cleanupExpiredUploadSessions()).resolves.toEqual({ + expired: 0, + failed: 0, + purged: 1, + }) expect(mockDeleteObjectVersion).toHaveBeenCalledWith( - expect.objectContaining({ key: created.stagingKey, version: 'version-1' }) + expect.objectContaining({ key: FINAL_KEY, version: 'version-1' }) ) }) }) @@ -417,16 +359,123 @@ async function createWorkspaceUpload(fileSize: number) { fileName: 'file.bin', contentType: 'application/octet-stream', fileSize, + localOrigin: 'http://localhost:3000', }) } -function objectIdentity(uploadId: string, size: number, contentType: string) { - return { uploadId, size, contentType, version: 'version-1' } +function sessionRecord(overrides: Partial = {}): UploadSessionRecord { + const row = uploadRow(overrides) + return { + id: row.id, + workspaceId: row.workspaceId, + userId: row.userId, + knowledgeBaseId: row.knowledgeBaseId, + workflowId: row.workflowId, + executionId: row.executionId, + purpose: row.purpose, + method: row.method, + storageContext: 'workspace', + storageKey: row.finalKey, + finalKey: row.finalKey, + storageProvider: row.storageProvider, + providerUploadId: row.providerUploadId, + providerObjectVersion: row.providerObjectVersion, + fileName: row.fileName, + contentType: row.contentType, + fileSize: row.fileSize, + partSize: row.partSize, + partCount: row.partCount, + status: row.status, + metadata: row.metadata, + uploadToken: 'upload-secret', + createdAt: row.createdAt, + expiresAt: row.expiresAt, + completedFileId: row.completedFileId, + error: row.error, + completedAt: row.completedAt, + updatedAt: row.updatedAt, + } +} + +function uploadRow(overrides: Record = {}) { + const now = new Date('2026-08-05T12:00:00.000Z') + return { + id: 'upload-1', + tokenHash: sha256Hex('upload-secret'), + userId: 'user-1', + workspaceId: WORKSPACE_ID, + knowledgeBaseId: null, + workflowId: null, + executionId: null, + purpose: 'workspace_file' as const, + method: 'put' as const, + storageContext: 'workspace', + finalKey: FINAL_KEY, + storageProvider: 's3' as const, + providerUploadId: null, + providerObjectVersion: null, + fileName: 'file.bin', + contentType: 'application/octet-stream', + fileSize: 4, + partSize: null, + partCount: null, + status: 'uploading' as const, + metadata: {}, + processingLeaseId: null, + processingLeaseExpiresAt: null, + completedFileId: null, + error: null, + createdAt: now, + expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000), + completedAt: null, + updatedAt: now, + ...overrides, + } +} + +function rowGeometry(session: UploadSessionRecord): Record { + return { + method: session.method, + providerUploadId: session.providerUploadId, + providerObjectVersion: session.providerObjectVersion, + fileSize: session.fileSize, + partSize: session.partSize, + partCount: session.partCount, + expiresAt: session.expiresAt, + } +} + +function queueCompletionRows(session: UploadSessionRecord, version: string): void { + dbChainMockFns.returning + .mockResolvedValueOnce([ + uploadRow({ + ...rowGeometry(session), + status: session.status === 'finalizing' ? 'finalizing' : 'completing', + }), + ]) + .mockResolvedValueOnce([ + uploadRow({ + ...rowGeometry(session), + status: 'finalizing', + providerObjectVersion: version, + }), + ]) + .mockResolvedValueOnce([ + uploadRow({ + ...rowGeometry(session), + status: 'completed', + providerObjectVersion: version, + completedFileId: 'file-1', + completedAt: new Date(), + }), + ]) } -function completedParts(partCount: number | null) { - return Array.from({ length: partCount ?? 0 }, (_, index) => ({ - partNumber: index + 1, - etag: `etag-${index + 1}`, - })) +function providerObject(session: UploadSessionRecord, version: string) { + return { + size: session.fileSize, + contentType: session.contentType, + uploadId: session.id, + version, + } } diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index 048ff658c11..325c48f58ec 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -1,4 +1,11 @@ +import { db, dbFor } from '@sim/db' +import { uploadSession } from '@sim/db/schema' +import { safeCompare } from '@sim/security/compare' +import { sha256Hex } from '@sim/security/hash' +import { generateSecureToken } from '@sim/security/tokens' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { and, asc, eq, inArray, isNull, lt, or } from 'drizzle-orm' import { checkStorageQuotaForBillingContext, resolveStorageBillingContext, @@ -7,14 +14,6 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateExecutionFileKey } 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 { - signUploadToken, - type UploadSessionPurpose, - type UploadStorageProvider, - type UploadTokenPayload, - type UploadTransferMethod, - verifyUploadToken, -} from '@/lib/uploads/core/upload-token' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE, MAX_WORKSPACE_FILE_SIZE, @@ -31,10 +30,16 @@ import { getMultipartProviderPartUrls, headProviderObject, initiateMultipartProviderUpload, - promoteProviderObject, + listMultipartProviderParts, type UploadPartUrl, uploadStorageProvider, } from '@/lib/uploads/upload-session/provider' +import type { + UploadSessionPurpose, + UploadSessionStatus, + UploadStorageProvider, + UploadTransferMethod, +} from '@/lib/uploads/upload-session/types' import { sanitizeFileName } from '@/executor/constants' export const UPLOAD_SESSION_PUT_MAX_BYTES = 50 * 1024 * 1024 @@ -43,9 +48,12 @@ export const UPLOAD_SESSION_MAX_PART_URLS = 100 export const UPLOAD_SESSION_TTL_MS = 24 * 60 * 60 * 1000 export const UPLOAD_SESSION_ASSET_MAX_BYTES = 5 * 1024 * 1024 -export type { UploadSessionPurpose, UploadTransferMethod } +const PROCESSING_LEASE_MS = 5 * 60 * 1000 +const CLEANUP_BATCH_SIZE = 100 +const TERMINAL_RETENTION_MS = 7 * 24 * 60 * 60 * 1000 +const cleanupDb = dbFor('cleanup') -export type UploadSessionStatus = 'uploading' | 'completed' | 'aborted' +export type { UploadSessionPurpose, UploadSessionStatus, UploadTransferMethod } export type UploadSessionTransfer = | { method: 'put'; url: string; headers: Record } @@ -61,12 +69,11 @@ export interface UploadSessionRecord { purpose: UploadSessionPurpose method: UploadTransferMethod storageContext: StorageContext - /** Canonical destination key retained for existing domain finalizers. */ storageKey: string finalKey: string - stagingKey: string storageProvider: UploadStorageProvider providerUploadId: string | null + providerObjectVersion: string | null fileName: string contentType: string fileSize: number @@ -87,8 +94,6 @@ export interface CreatedUploadSession extends UploadSessionRecord { transfer: UploadSessionTransfer } -export type UploadCompletion = { parts?: never } | { parts: CompletedUploadPart[] } - export class UploadSessionError extends OrchestrationError { constructor( code: 'validation' | 'not_found' | 'forbidden' | 'conflict' | 'payload_too_large' | 'internal', @@ -123,14 +128,16 @@ export type CreateUploadSessionParams = CreateUploadSessionBaseParams & } ) +type UploadSessionRow = typeof uploadSession.$inferSelect + export async function createUploadSession( params: CreateUploadSessionParams ): Promise { validateFile(params) const id = params.id ?? generateId() + const uploadToken = generateSecureToken(32) const workspaceId = params.purpose === 'profile_picture' ? null : params.workspaceId const { storageContext, finalKey } = resolveUploadStorage(params, id) - const stagingKey = `upload-sessions/${id}/${sanitizeFileName(params.fileName)}` const method: UploadTransferMethod = params.fileSize <= UPLOAD_SESSION_PUT_MAX_BYTES ? 'put' : 'multipart' const partSize = method === 'multipart' ? UPLOAD_SESSION_PART_SIZE : null @@ -161,7 +168,7 @@ export async function createUploadSession( const initiated = method === 'multipart' ? await initiateMultipartProviderUpload({ - stagingKey, + key: finalKey, fileName: params.fileName, contentType: params.contentType, fileSize: params.fileSize, @@ -176,44 +183,80 @@ export async function createUploadSession( const createdAt = new Date() const expiresAt = new Date(createdAt.getTime() + UPLOAD_SESSION_TTL_MS) - const metadata = params.metadata ?? {} - const tokenPayload = createUploadTokenPayload({ - params, - id, - workspaceId, - storageContext, - finalKey, - stagingKey, - provider, - providerUploadId: initiated.providerUploadId, - method, - partSize, - partCount, - metadata, - createdAt, - expiresAt, - }) - const uploadToken = signUploadToken(tokenPayload) - const transfer: UploadSessionTransfer = - method === 'put' - ? await createPutProviderTransfer({ - provider, - stagingKey, - contentType: params.contentType, - fileSize: params.fileSize, - context: storageContext, - uploadId: id, - uploadToken, - localOrigin: params.localOrigin, - expiresAt, - metadata: objectMetadata, - }) - : { method, partSize: requireNumber(partSize), partCount: requireNumber(partCount) } - - return sessionFromPayload(tokenPayload, uploadToken, transfer) + let inserted: UploadSessionRow + try { + const rows = await db + .insert(uploadSession) + .values({ + id, + tokenHash: sha256Hex(uploadToken), + userId: params.userId, + workspaceId, + knowledgeBaseId: params.purpose === 'knowledge_document' ? params.knowledgeBaseId : null, + workflowId: params.purpose === 'execution_attachment' ? params.workflowId : null, + executionId: params.purpose === 'execution_attachment' ? params.executionId : null, + purpose: params.purpose, + method, + storageContext, + finalKey, + storageProvider: provider, + providerUploadId: initiated.providerUploadId, + fileName: params.fileName, + contentType: params.contentType, + fileSize: params.fileSize, + partSize, + partCount, + metadata: params.metadata ?? {}, + createdAt, + expiresAt, + updatedAt: createdAt, + }) + .returning() + inserted = requireRow(rows[0], 'Upload session insert returned no row') + } catch (error) { + await abortProviderUpload({ + provider, + method, + providerUploadId: initiated.providerUploadId, + uploadId: id, + key: finalKey, + context: storageContext, + }) + throw error + } + + try { + const transfer: UploadSessionTransfer = + method === 'put' + ? await createPutProviderTransfer({ + provider, + key: finalKey, + contentType: params.contentType, + fileSize: params.fileSize, + context: storageContext, + uploadId: id, + uploadToken, + localOrigin: params.localOrigin, + expiresAt, + metadata: objectMetadata, + }) + : { method, partSize: requireNumber(partSize), partCount: requireNumber(partCount) } + return { ...sessionFromRow(inserted, uploadToken), transfer } + } catch (error) { + await db.delete(uploadSession).where(eq(uploadSession.id, id)) + await abortProviderUpload({ + provider, + method, + providerUploadId: initiated.providerUploadId, + uploadId: id, + key: finalKey, + context: storageContext, + }) + throw error + } } -export function getOwnedUploadSession(params: { +export async function getOwnedUploadSession(params: { uploadId: string uploadToken: string userId?: string @@ -222,9 +265,14 @@ export function getOwnedUploadSession(params: { knowledgeBaseId?: string workflowId?: string executionId?: string -}): UploadSessionRecord { - const session = verifyUploadSessionToken(params.uploadToken) - if (session.id !== params.uploadId) throw uploadNotFound() +}): Promise { + const [row] = await db + .select() + .from(uploadSession) + .where(eq(uploadSession.id, params.uploadId)) + .limit(1) + if (!row || !safeCompare(sha256Hex(params.uploadToken), row.tokenHash)) throw uploadNotFound() + const session = sessionFromRow(row, params.uploadToken) if (params.userId !== undefined && session.userId !== params.userId) throw uploadNotFound() if (params.workspaceId !== undefined && session.workspaceId !== params.workspaceId) { throw uploadNotFound() @@ -242,10 +290,17 @@ export function getOwnedUploadSession(params: { return session } -export function verifyUploadSessionToken(uploadToken: string): UploadSessionRecord { - const verified = verifyUploadToken(uploadToken) - if (!verified.valid) throw new UploadSessionError('forbidden', 'Invalid or expired upload token') - return sessionFromPayload(verified.payload, uploadToken) +export async function verifyUploadSessionToken(uploadToken: string): Promise { + const tokenHash = sha256Hex(uploadToken) + const [row] = await db + .select() + .from(uploadSession) + .where(eq(uploadSession.tokenHash, tokenHash)) + .limit(1) + if (!row || !safeCompare(tokenHash, row.tokenHash)) { + throw new UploadSessionError('forbidden', 'Invalid or expired upload token') + } + return sessionFromRow(row, uploadToken) } export async function createUploadPartUrls(params: { @@ -279,7 +334,7 @@ export async function createUploadPartUrls(params: { return getMultipartProviderPartUrls({ provider: params.session.storageProvider, providerUploadId: params.session.providerUploadId, - stagingKey: params.session.stagingKey, + key: params.session.finalKey, context: params.session.storageContext, partNumbers: params.partNumbers, localUrl: (partNumber) => @@ -289,118 +344,319 @@ export async function createUploadPartUrls(params: { export async function completeUploadSession(params: { session: UploadSessionRecord - completion: UploadCompletion finalize: (session: UploadSessionRecord) => Promise<{ value: T; completedFileId?: string }> }): Promise<{ session: UploadSessionRecord; value: T; alreadyCompleted: boolean }> { - assertUploadable(params.session) - const parts = validateUploadCompletion(params.session, params.completion) - let existingFinal = await headProviderObject({ - provider: params.session.storageProvider, - key: params.session.finalKey, - context: params.session.storageContext, - }) - const alreadyCompleted = existingFinal !== null - if (existingFinal) assertObjectIdentity(params.session, existingFinal, 'Final') - - if (!existingFinal) { - let staging = await headProviderObject({ - provider: params.session.storageProvider, - key: params.session.stagingKey, - context: params.session.storageContext, + if (params.session.status === 'completed') { + const finalized = await params.finalize(params.session) + return { session: params.session, value: finalized.value, alreadyCompleted: true } + } + if ( + params.session.status !== 'uploading' && + params.session.status !== 'completing' && + params.session.status !== 'finalizing' + ) { + throw new UploadSessionError('conflict', `Upload session is ${params.session.status}`) + } + if (params.session.status === 'uploading') assertNotExpired(params.session) + const leaseId = generateId() + const recoveringFinalization = params.session.status === 'finalizing' + const claimed = { + ...(await claimSession( + params.session.id, + leaseId, + recoveringFinalization ? ['finalizing'] : ['uploading', 'completing'], + recoveringFinalization ? 'finalizing' : 'completing' + )), + uploadToken: params.session.uploadToken, + } + let phase: 'completing' | 'finalizing' = recoveringFinalization ? 'finalizing' : 'completing' + let alreadyCompleted = false + + try { + let finalObject = await headProviderObject({ + provider: claimed.storageProvider, + key: claimed.finalKey, + context: claimed.storageContext, }) - if (staging) assertObjectIdentity(params.session, staging, 'Uploaded') + alreadyCompleted = finalObject !== null + if (finalObject) assertObjectIdentity(claimed, finalObject, 'Final') - if (!staging && params.session.method === 'multipart') { + if (!finalObject) { + if (claimed.method === 'put') { + throw new UploadSessionError('conflict', 'Uploaded object not found') + } + const parts = await listMultipartProviderParts({ + provider: claimed.storageProvider, + providerUploadId: claimed.providerUploadId, + uploadId: claimed.id, + key: claimed.finalKey, + context: claimed.storageContext, + }) + validateProviderParts(claimed, parts) try { await completeMultipartProviderUpload({ - provider: params.session.storageProvider, - providerUploadId: params.session.providerUploadId, - uploadId: params.session.id, - stagingKey: params.session.stagingKey, - contentType: params.session.contentType, - context: params.session.storageContext, + provider: claimed.storageProvider, + providerUploadId: claimed.providerUploadId, + uploadId: claimed.id, + key: claimed.finalKey, + contentType: claimed.contentType, + context: claimed.storageContext, parts, - metadata: uploadSessionObjectMetadata(params.session), + metadata: uploadSessionObjectMetadata(claimed), }) - } catch (completionError) { - staging = await headProviderObject({ - provider: params.session.storageProvider, - key: params.session.stagingKey, - context: params.session.storageContext, + } catch (error) { + finalObject = await headProviderObject({ + provider: claimed.storageProvider, + key: claimed.finalKey, + context: claimed.storageContext, }) - if (!staging) throw completionError - assertObjectIdentity(params.session, staging, 'Uploaded') + if (!finalObject) throw error } + finalObject = + finalObject ?? + (await headProviderObject({ + provider: claimed.storageProvider, + key: claimed.finalKey, + context: claimed.storageContext, + })) + if (!finalObject) throw new Error('Completed upload object not found') + assertObjectIdentity(claimed, finalObject, 'Completed') } - if (!staging) { - staging = await headProviderObject({ - provider: params.session.storageProvider, - key: params.session.stagingKey, - context: params.session.storageContext, + phase = 'finalizing' + const [finalizingRow] = await db + .update(uploadSession) + .set({ + status: 'finalizing', + providerObjectVersion: finalObject.version, + error: null, + updatedAt: new Date(), + }) + .where(and(eq(uploadSession.id, claimed.id), eq(uploadSession.processingLeaseId, leaseId))) + .returning() + const finalizing = sessionFromRow( + requireRow(finalizingRow, 'Upload completion lease was lost'), + claimed.uploadToken + ) + const finalized = await params.finalize(finalizing) + const completedAt = new Date() + const [completedRow] = await db + .update(uploadSession) + .set({ + status: 'completed', + completedFileId: finalized.completedFileId ?? null, + completedAt, + processingLeaseId: null, + processingLeaseExpiresAt: null, + error: null, + updatedAt: completedAt, }) + .where(and(eq(uploadSession.id, claimed.id), eq(uploadSession.processingLeaseId, leaseId))) + .returning() + return { + session: sessionFromRow( + requireRow(completedRow, 'Upload completion lease was lost'), + claimed.uploadToken + ), + value: finalized.value, + alreadyCompleted, } + } catch (error) { + await db + .update(uploadSession) + .set({ + status: phase === 'completing' && error instanceof UploadSessionError ? 'uploading' : phase, + processingLeaseId: null, + processingLeaseExpiresAt: null, + error: getErrorMessage(error), + updatedAt: new Date(), + }) + .where(and(eq(uploadSession.id, claimed.id), eq(uploadSession.processingLeaseId, leaseId))) + throw error + } +} + +export async function abortUploadSession( + session: UploadSessionRecord +): Promise { + if (session.status === 'aborted' || session.status === 'expired') return session + if (session.status === 'completed') { + throw new UploadSessionError('conflict', 'Completed upload sessions cannot be aborted') + } + if (session.status === 'finalizing') { + throw new UploadSessionError('conflict', 'Finalizing upload sessions cannot be aborted') + } + if ( + session.status !== 'uploading' && + session.status !== 'completing' && + session.status !== 'aborting' + ) { + throw new UploadSessionError('conflict', `Upload session is ${session.status}`) + } + const leaseId = generateId() + const claimed = { + ...(await claimSession( + session.id, + leaseId, + ['uploading', 'completing', 'aborting'], + 'aborting' + )), + uploadToken: session.uploadToken, + } + try { + await discardIncompleteProviderState(claimed) + const completedAt = new Date() + const [row] = await db + .update(uploadSession) + .set({ + status: 'aborted', + processingLeaseId: null, + processingLeaseExpiresAt: null, + completedAt, + error: null, + updatedAt: completedAt, + }) + .where(and(eq(uploadSession.id, claimed.id), eq(uploadSession.processingLeaseId, leaseId))) + .returning() + return sessionFromRow(requireRow(row, 'Upload abort lease was lost'), session.uploadToken) + } catch (error) { + await db + .update(uploadSession) + .set({ + status: 'aborting', + processingLeaseId: null, + processingLeaseExpiresAt: null, + error: getErrorMessage(error), + updatedAt: new Date(), + }) + .where(and(eq(uploadSession.id, claimed.id), eq(uploadSession.processingLeaseId, leaseId))) + throw error + } +} - if (!staging) throw new UploadSessionError('conflict', 'Uploaded staging object not found') - assertObjectIdentity(params.session, staging, 'Uploaded') +export async function cleanupExpiredUploadSessions(): Promise<{ + expired: number + failed: number + purged: number +}> { + const now = new Date() + const candidates = await cleanupDb + .select() + .from(uploadSession) + .where( + and( + inArray(uploadSession.status, ['uploading', 'completing', 'aborting']), + lt(uploadSession.expiresAt, now), + or( + isNull(uploadSession.processingLeaseId), + isNull(uploadSession.processingLeaseExpiresAt), + lt(uploadSession.processingLeaseExpiresAt, now) + ) + ) + ) + .orderBy(asc(uploadSession.expiresAt)) + .limit(CLEANUP_BATCH_SIZE) + let expired = 0 + let failed = 0 + for (const candidate of candidates) { + const leaseId = generateId() try { - await promoteProviderObject({ - provider: params.session.storageProvider, - sourceKey: params.session.stagingKey, - destinationKey: params.session.finalKey, - sourceVersion: staging.version, - context: params.session.storageContext, - }) + const claimed = await claimSession( + candidate.id, + leaseId, + ['uploading', 'completing', 'aborting'], + 'aborting', + cleanupDb + ) + await discardIncompleteProviderState(claimed) + const completedAt = new Date() + const updated = await cleanupDb + .update(uploadSession) + .set({ + status: 'expired', + processingLeaseId: null, + processingLeaseExpiresAt: null, + completedAt, + error: null, + updatedAt: completedAt, + }) + .where(and(eq(uploadSession.id, claimed.id), eq(uploadSession.processingLeaseId, leaseId))) + .returning({ id: uploadSession.id }) + if (updated.length !== 1) throw new Error('Upload expiry lease was lost') + expired++ } catch (error) { - existingFinal = await headProviderObject({ - provider: params.session.storageProvider, - key: params.session.finalKey, - context: params.session.storageContext, - }) - if (!existingFinal) throw error - assertObjectIdentity(params.session, existingFinal, 'Final') + failed++ + await cleanupDb + .update(uploadSession) + .set({ + processingLeaseId: null, + processingLeaseExpiresAt: null, + error: getErrorMessage(error), + updatedAt: new Date(), + }) + .where( + and(eq(uploadSession.id, candidate.id), eq(uploadSession.processingLeaseId, leaseId)) + ) } - - const promoted = await headProviderObject({ - provider: params.session.storageProvider, - key: params.session.finalKey, - context: params.session.storageContext, - }) - if (!promoted) throw new Error('Promoted upload object not found') - assertObjectIdentity(params.session, promoted, 'Promoted') } - const finalized = await params.finalize(params.session) - await cleanupStagingObject(params.session) - const completedAt = new Date() - return { - session: { - ...params.session, - status: 'completed', - completedFileId: finalized.completedFileId ?? null, - completedAt, - updatedAt: completedAt, - }, - value: finalized.value, - alreadyCompleted, + const terminalCutoff = new Date(now.getTime() - TERMINAL_RETENTION_MS) + const terminalRows = await cleanupDb + .select() + .from(uploadSession) + .where( + and( + inArray(uploadSession.status, ['completed', 'aborted', 'expired']), + lt(uploadSession.completedAt, terminalCutoff), + or( + isNull(uploadSession.processingLeaseId), + isNull(uploadSession.processingLeaseExpiresAt), + lt(uploadSession.processingLeaseExpiresAt, now) + ) + ) + ) + .orderBy(asc(uploadSession.completedAt)) + .limit(CLEANUP_BATCH_SIZE) + let purged = 0 + for (const candidate of terminalRows) { + const leaseId = generateId() + try { + const claimed = await claimSession( + candidate.id, + leaseId, + [candidate.status], + candidate.status, + cleanupDb + ) + if (claimed.status === 'aborted' || claimed.status === 'expired') { + await deleteOwnedFinalObject(claimed) + } else if (claimed.status !== 'completed') { + throw new Error(`Invalid terminal upload status ${claimed.status}`) + } + const deleted = await cleanupDb + .delete(uploadSession) + .where(and(eq(uploadSession.id, claimed.id), eq(uploadSession.processingLeaseId, leaseId))) + .returning({ id: uploadSession.id }) + if (deleted.length !== 1) throw new Error('Upload retention lease was lost') + purged++ + } catch (error) { + failed++ + await cleanupDb + .update(uploadSession) + .set({ + processingLeaseId: null, + processingLeaseExpiresAt: null, + error: getErrorMessage(error), + updatedAt: new Date(), + }) + .where( + and(eq(uploadSession.id, candidate.id), eq(uploadSession.processingLeaseId, leaseId)) + ) + } } -} -export async function abortUploadSession( - session: UploadSessionRecord -): Promise { - assertUploadable(session) - await abortProviderUpload({ - provider: session.storageProvider, - method: session.method, - providerUploadId: session.providerUploadId, - uploadId: session.id, - stagingKey: session.stagingKey, - context: session.storageContext, - }) - const completedAt = new Date() - return { ...session, status: 'aborted', completedAt, updatedAt: completedAt } + return { expired, failed, purged } } export function expectedUploadPartSize(session: UploadSessionRecord, partNumber: number): number { @@ -439,211 +695,103 @@ export function uploadSessionObjectMetadata( } } -function sessionFromPayload( - payload: UploadTokenPayload, - uploadToken: string, - transfer: UploadSessionTransfer -): CreatedUploadSession -function sessionFromPayload( - payload: UploadTokenPayload, - uploadToken: string, - transfer?: undefined -): UploadSessionRecord -function sessionFromPayload( - payload: UploadTokenPayload, - uploadToken: string, - transfer?: UploadSessionTransfer -): CreatedUploadSession | UploadSessionRecord { - const createdAt = new Date(payload.createdAt) - const expiresAt = new Date(payload.expiresAt) - const session: UploadSessionRecord = { - id: payload.uploadId, - workspaceId: payload.workspaceId, - userId: payload.actorId, - knowledgeBaseId: payload.purpose === 'knowledge_document' ? payload.knowledgeBaseId : null, - workflowId: payload.purpose === 'execution_attachment' ? payload.workflowId : null, - executionId: payload.purpose === 'execution_attachment' ? payload.executionId : null, - purpose: payload.purpose, - method: payload.method, - storageContext: payload.context, - storageKey: payload.finalKey, - finalKey: payload.finalKey, - stagingKey: payload.stagingKey, - storageProvider: payload.provider, - providerUploadId: payload.providerUploadId, - fileName: payload.fileName, - contentType: payload.contentType, - fileSize: payload.fileSize, - partSize: payload.method === 'multipart' ? payload.partSize : null, - partCount: payload.method === 'multipart' ? payload.partCount : null, - status: 'uploading', - metadata: payload.metadata, - uploadToken, - createdAt, - expiresAt, - completedFileId: null, - error: null, - completedAt: null, - updatedAt: new Date(), - } - return transfer ? { ...session, transfer } : session -} - -function createUploadTokenPayload(params: { - params: CreateUploadSessionParams - id: string - workspaceId: string | null - storageContext: StorageContext - finalKey: string - stagingKey: string - provider: UploadStorageProvider - providerUploadId: string | null - method: UploadTransferMethod - partSize: number | null - partCount: number | null - metadata: Record - createdAt: Date - expiresAt: Date -}): UploadTokenPayload { - const base = { - uploadId: params.id, - actorId: params.params.userId, - finalKey: params.finalKey, - stagingKey: params.stagingKey, - provider: params.provider, - fileName: params.params.fileName, - contentType: params.params.contentType, - fileSize: params.params.fileSize, - metadata: params.metadata, - createdAt: params.createdAt.toISOString(), - expiresAt: params.expiresAt.toISOString(), - } - const transfer = - params.method === 'put' - ? ({ method: 'put', providerUploadId: null } as const) - : ({ - method: 'multipart', - providerUploadId: params.providerUploadId, - partSize: requireNumber(params.partSize), - partCount: requireNumber(params.partCount), - } as const) - - switch (params.params.purpose) { - case 'workspace_file': - return { - ...base, - ...transfer, - purpose: params.params.purpose, - workspaceId: params.params.workspaceId, - context: 'workspace', - } - case 'table_import': - return { - ...base, - ...transfer, - purpose: params.params.purpose, - workspaceId: params.params.workspaceId, - context: 'table-import', - } - case 'knowledge_document': - return { - ...base, - ...transfer, - purpose: params.params.purpose, - workspaceId: params.params.workspaceId, - context: 'knowledge-base', - knowledgeBaseId: params.params.knowledgeBaseId, - } - case 'profile_picture': - return { - ...base, - ...transfer, - purpose: params.params.purpose, - workspaceId: null, - context: 'profile-pictures', - } - case 'workspace_logo': - return { - ...base, - ...transfer, - purpose: params.params.purpose, - workspaceId: params.params.workspaceId, - context: 'workspace-logos', - } - case 'mothership_attachment': - return { - ...base, - ...transfer, - purpose: params.params.purpose, - workspaceId: params.params.workspaceId, - context: 'mothership', - } - case 'execution_attachment': - return { - ...base, - ...transfer, - purpose: params.params.purpose, - workspaceId: params.params.workspaceId, - context: 'execution', - workflowId: params.params.workflowId, - executionId: params.params.executionId, - } - } -} - -function assertUploadable(session: UploadSessionRecord): void { - if (session.status !== 'uploading') { - throw new UploadSessionError('conflict', `Upload session is ${session.status}`) +function sessionFromRow(row: UploadSessionRow, uploadToken: string): UploadSessionRecord { + if (!isStorageContext(row.storageContext)) { + throw new Error(`Invalid upload storage context ${row.storageContext}`) } - if (session.expiresAt.getTime() <= Date.now()) { - throw new UploadSessionError('conflict', 'Upload session has expired') + return { + id: row.id, + workspaceId: row.workspaceId, + userId: row.userId, + knowledgeBaseId: row.knowledgeBaseId, + workflowId: row.workflowId, + executionId: row.executionId, + purpose: row.purpose, + method: row.method, + storageContext: row.storageContext, + storageKey: row.finalKey, + finalKey: row.finalKey, + storageProvider: row.storageProvider, + providerUploadId: row.providerUploadId, + providerObjectVersion: row.providerObjectVersion, + fileName: row.fileName, + contentType: row.contentType, + fileSize: row.fileSize, + partSize: row.partSize, + partCount: row.partCount, + status: row.status, + metadata: row.metadata, + uploadToken, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + completedFileId: row.completedFileId, + error: row.error, + completedAt: row.completedAt, + updatedAt: row.updatedAt, } } -/** - * Validates method-specific completion input before any idempotent replay shortcut is taken. - * Callers that can return an already-completed resource must run this first as well. - */ -export function validateUploadCompletion( - session: UploadSessionRecord, - completion: UploadCompletion -): CompletedUploadPart[] { - if (session.method === 'put') { - if ('parts' in completion) { - throw new UploadSessionError('validation', 'PUT completion must not include parts') - } - return [] - } - if (!('parts' in completion) || !completion.parts) { - throw new UploadSessionError('validation', 'Multipart completion requires parts') - } - validateCompletedParts(session, completion.parts) - return completion.parts +async function claimSession( + id: string, + leaseId: string, + statuses: UploadSessionStatus[], + nextStatus: UploadSessionStatus = 'completing', + database: typeof db = db +): Promise { + const now = new Date() + const [row] = await database + .update(uploadSession) + .set({ + status: nextStatus, + processingLeaseId: leaseId, + processingLeaseExpiresAt: new Date(now.getTime() + PROCESSING_LEASE_MS), + error: null, + updatedAt: now, + }) + .where( + and( + eq(uploadSession.id, id), + inArray(uploadSession.status, statuses), + or( + isNull(uploadSession.processingLeaseId), + isNull(uploadSession.processingLeaseExpiresAt), + lt(uploadSession.processingLeaseExpiresAt, now) + ) + ) + ) + .returning() + if (!row) throw new UploadSessionError('conflict', 'Upload session is already being processed') + return sessionFromRow(row, '') } -function validateCompletedParts(session: UploadSessionRecord, parts: CompletedUploadPart[]): void { +function validateProviderParts(session: UploadSessionRecord, parts: CompletedUploadPart[]): void { if (!session.partCount) throw new Error('Multipart upload is missing partCount') if (parts.length !== session.partCount) { throw new UploadSessionError( - 'validation', - `Expected ${session.partCount} completed parts; received ${parts.length}` + 'conflict', + `Expected ${session.partCount} uploaded parts; provider returned ${parts.length}` ) } const sorted = [...parts].sort((a, b) => a.partNumber - b.partNumber) for (let index = 0; index < sorted.length; index++) { - if (sorted[index].partNumber !== index + 1) { + const part = sorted[index] + if (part.partNumber !== index + 1) { throw new UploadSessionError( - 'validation', - 'Completed parts must contain every part exactly once' + 'conflict', + 'Provider parts must contain every part exactly once' ) } - if ( - (session.storageProvider === 's3' || session.storageProvider === 'gcs') && - !sorted[index].etag - ) { + const expectedSize = expectedUploadPartSize(session, part.partNumber) + if (part.size !== expectedSize) { throw new UploadSessionError( - 'validation', - `etag is required for ${session.storageProvider} part ${sorted[index].partNumber}` + 'conflict', + `Provider part ${part.partNumber} has ${part.size} bytes; expected ${expectedSize}` + ) + } + if ((session.storageProvider === 's3' || session.storageProvider === 'gcs') && !part.etag) { + throw new UploadSessionError( + 'conflict', + `${session.storageProvider} part ${part.partNumber} is missing an ETag` ) } } @@ -671,22 +819,61 @@ function assertObjectIdentity( } } -async function cleanupStagingObject(session: UploadSessionRecord): Promise { - const staging = await headProviderObject({ +async function deleteOwnedFinalObject(session: UploadSessionRecord): Promise { + const object = await headProviderObject({ provider: session.storageProvider, - key: session.stagingKey, + key: session.finalKey, context: session.storageContext, }) - if (!staging) return - assertObjectIdentity(session, staging, 'Staging') + if (!object) return + assertObjectIdentity(session, object, 'Final') await deleteProviderObjectVersion({ provider: session.storageProvider, - key: session.stagingKey, - version: staging.version, + key: session.finalKey, + version: object.version, + context: session.storageContext, + }) +} + +async function discardIncompleteProviderState(session: UploadSessionRecord): Promise { + const object = await headProviderObject({ + provider: session.storageProvider, + key: session.finalKey, + context: session.storageContext, + }) + if (object) { + assertObjectIdentity(session, object, 'Final') + await deleteProviderObjectVersion({ + provider: session.storageProvider, + key: session.finalKey, + version: object.version, + context: session.storageContext, + }) + return + } + await abortProviderUpload({ + provider: session.storageProvider, + method: session.method, + providerUploadId: session.providerUploadId, + uploadId: session.id, + key: session.finalKey, context: session.storageContext, }) } +function assertUploadable(session: UploadSessionRecord): void { + if (session.status !== 'uploading') { + throw new UploadSessionError('conflict', `Upload session is ${session.status}`) + } + assertNotExpired(session) +} + +function assertNotExpired(session: UploadSessionRecord): void { + if (session.expiresAt.getTime() <= Date.now()) { + throw new UploadSessionError('conflict', 'Upload session has expired') + } +} + function validateFile(params: CreateUploadSessionParams): void { if (!params.fileName.trim()) { throw new UploadSessionError('validation', 'fileName must not be empty') @@ -722,9 +909,7 @@ function maximumFileSize(purpose: UploadSessionPurpose): number { if (purpose === 'profile_picture' || purpose === 'workspace_logo') { return UPLOAD_SESSION_ASSET_MAX_BYTES } - if (purpose === 'execution_attachment') { - return MAX_WORKSPACE_FORMDATA_FILE_SIZE - } + if (purpose === 'execution_attachment') return MAX_WORKSPACE_FORMDATA_FILE_SIZE return MAX_WORKSPACE_FILE_SIZE } @@ -782,6 +967,18 @@ function resolveUploadStorage( } } +function isStorageContext(value: string): value is StorageContext { + return [ + 'workspace', + 'table-import', + 'knowledge-base', + 'profile-pictures', + 'workspace-logos', + 'mothership', + 'execution', + ].includes(value) +} + function uploadNotFound(): UploadSessionError { return new UploadSessionError('not_found', 'Upload session not found') } @@ -790,3 +987,8 @@ function requireNumber(value: number | null): number { if (value === null) throw new Error('Multipart upload geometry is missing') return value } + +function requireRow(row: T | undefined, message: string): T { + if (!row) throw new Error(message) + return row +} diff --git a/apps/sim/lib/uploads/upload-session/types.ts b/apps/sim/lib/uploads/upload-session/types.ts new file mode 100644 index 00000000000..9468ab2a02e --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/types.ts @@ -0,0 +1,22 @@ +export type UploadSessionPurpose = + | 'workspace_file' + | 'table_import' + | 'knowledge_document' + | 'profile_picture' + | 'workspace_logo' + | 'mothership_attachment' + | 'execution_attachment' + +export type UploadStorageProvider = 'local' | 's3' | 'blob' | 'gcs' + +export type UploadTransferMethod = 'put' | 'multipart' + +export type UploadSessionStatus = + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'aborting' + | 'aborted' + | 'failed' + | 'expired' diff --git a/packages/db/migrations/0283_heavy_firebird.sql b/packages/db/migrations/0283_heavy_firebird.sql new file mode 100644 index 00000000000..290143dc632 --- /dev/null +++ b/packages/db/migrations/0283_heavy_firebird.sql @@ -0,0 +1,39 @@ +CREATE TYPE "public"."upload_session_method" AS ENUM('put', 'multipart');--> statement-breakpoint +CREATE TYPE "public"."upload_session_provider" AS ENUM('local', 's3', 'blob', 'gcs');--> statement-breakpoint +CREATE TYPE "public"."upload_session_purpose" AS ENUM('workspace_file', 'table_import', 'knowledge_document', 'profile_picture', 'workspace_logo', 'mothership_attachment', 'execution_attachment');--> statement-breakpoint +CREATE TYPE "public"."upload_session_status" AS ENUM('uploading', 'completing', 'finalizing', 'completed', 'aborting', 'aborted', 'failed', 'expired');--> statement-breakpoint +CREATE TABLE "upload_session" ( + "id" text PRIMARY KEY NOT NULL, + "token_hash" text NOT NULL, + "user_id" text NOT NULL, + "workspace_id" text, + "knowledge_base_id" text, + "workflow_id" text, + "execution_id" text, + "purpose" "upload_session_purpose" NOT NULL, + "method" "upload_session_method" NOT NULL, + "storage_context" text NOT NULL, + "final_key" text NOT NULL, + "storage_provider" "upload_session_provider" NOT NULL, + "provider_upload_id" text, + "provider_object_version" text, + "file_name" text NOT NULL, + "content_type" text NOT NULL, + "file_size" bigint NOT NULL, + "part_size" integer, + "part_count" integer, + "status" "upload_session_status" DEFAULT 'uploading' NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "processing_lease_id" text, + "processing_lease_expires_at" timestamp, + "completed_file_id" text, + "error" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "expires_at" timestamp NOT NULL, + "completed_at" timestamp, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "upload_session_token_hash_unique" ON "upload_session" USING btree ("token_hash");--> statement-breakpoint +CREATE UNIQUE INDEX "upload_session_final_key_unique" ON "upload_session" USING btree ("final_key");--> statement-breakpoint +CREATE INDEX "upload_session_status_expires_at_idx" ON "upload_session" USING btree ("status","expires_at"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0283_snapshot.json b/packages/db/migrations/meta/0283_snapshot.json new file mode 100644 index 00000000000..3dedbf6fa29 --- /dev/null +++ b/packages/db/migrations/meta/0283_snapshot.json @@ -0,0 +1,18689 @@ +{ + "id": "83190b2a-dc8b-4d5f-8083-30eb14eecbef", + "prevId": "1b640a15-944f-413a-9a2f-fc08f67856bc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index e965b58fe13..293fc04bbf3 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1975,6 +1975,13 @@ "when": 1785813019807, "tag": "0282_real_kang", "breakpoints": true + }, + { + "idx": 283, + "version": "7", + "when": 1785913915302, + "tag": "0283_heavy_firebird", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 45f12034bd8..68058fa0356 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1968,6 +1968,80 @@ export const workspaceFiles = pgTable( }) ) +export const uploadSessionStatusEnum = pgEnum('upload_session_status', [ + 'uploading', + 'completing', + 'finalizing', + 'completed', + 'aborting', + 'aborted', + 'failed', + 'expired', +]) + +export const uploadSessionMethodEnum = pgEnum('upload_session_method', ['put', 'multipart']) + +export const uploadSessionProviderEnum = pgEnum('upload_session_provider', [ + 'local', + 's3', + 'blob', + 'gcs', +]) + +export const uploadSessionPurposeEnum = pgEnum('upload_session_purpose', [ + 'workspace_file', + 'table_import', + 'knowledge_document', + 'profile_picture', + 'workspace_logo', + 'mothership_attachment', + 'execution_attachment', +]) + +/** Durable control-plane state for direct-to-provider PUT and multipart uploads. */ +export const uploadSession = pgTable( + 'upload_session', + { + id: text('id').primaryKey(), + tokenHash: text('token_hash').notNull(), + userId: text('user_id').notNull(), + workspaceId: text('workspace_id'), + knowledgeBaseId: text('knowledge_base_id'), + workflowId: text('workflow_id'), + executionId: text('execution_id'), + purpose: uploadSessionPurposeEnum('purpose').notNull(), + method: uploadSessionMethodEnum('method').notNull(), + storageContext: text('storage_context').notNull(), + finalKey: text('final_key').notNull(), + storageProvider: uploadSessionProviderEnum('storage_provider').notNull(), + providerUploadId: text('provider_upload_id'), + providerObjectVersion: text('provider_object_version'), + fileName: text('file_name').notNull(), + contentType: text('content_type').notNull(), + fileSize: bigint('file_size', { mode: 'number' }).notNull(), + partSize: integer('part_size'), + partCount: integer('part_count'), + status: uploadSessionStatusEnum('status').notNull().default('uploading'), + metadata: jsonb('metadata').$type>().notNull().default({}), + processingLeaseId: text('processing_lease_id'), + processingLeaseExpiresAt: timestamp('processing_lease_expires_at'), + completedFileId: text('completed_file_id'), + error: text('error'), + createdAt: timestamp('created_at').notNull().defaultNow(), + expiresAt: timestamp('expires_at').notNull(), + completedAt: timestamp('completed_at'), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + tokenHashUnique: uniqueIndex('upload_session_token_hash_unique').on(table.tokenHash), + finalKeyUnique: uniqueIndex('upload_session_final_key_unique').on(table.finalKey), + statusExpiresAtIdx: index('upload_session_status_expires_at_idx').on( + table.status, + table.expiresAt + ), + }) +) + /** * Cached collaborative-document state for a workspace markdown file: the last-persisted Yjs binary and * a hash of the markdown it was derived from. On a cold room open the seed loads this binary directly diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index edbb9fa4469..108cd86a6ba 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -595,6 +595,37 @@ export const schemaMock = { deletedAt: 'deletedAt', uploadedAt: 'uploadedAt', }, + uploadSession: { + id: 'id', + tokenHash: 'tokenHash', + userId: 'userId', + workspaceId: 'workspaceId', + knowledgeBaseId: 'knowledgeBaseId', + workflowId: 'workflowId', + executionId: 'executionId', + purpose: 'purpose', + method: 'method', + storageContext: 'storageContext', + finalKey: 'finalKey', + storageProvider: 'storageProvider', + providerUploadId: 'providerUploadId', + providerObjectVersion: 'providerObjectVersion', + fileName: 'fileName', + contentType: 'contentType', + fileSize: 'fileSize', + partSize: 'partSize', + partCount: 'partCount', + status: 'status', + metadata: 'metadata', + processingLeaseId: 'processingLeaseId', + processingLeaseExpiresAt: 'processingLeaseExpiresAt', + completedFileId: 'completedFileId', + error: 'error', + createdAt: 'createdAt', + expiresAt: 'expiresAt', + completedAt: 'completedAt', + updatedAt: 'updatedAt', + }, permissionTypeEnum: 'permissionTypeEnum', workspaceInvitationStatusEnum: 'workspaceInvitationStatusEnum', workspaceInvitation: { From 4b06c45a7334871921b005489a24326eb5e19570 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 5 Aug 2026 01:32:59 -0700 Subject: [PATCH 074/159] fix(db): store table row trigger timestamps in UTC --- .../0284_utc_table_row_count_timestamps.sql | 80 + .../db/migrations/meta/0284_snapshot.json | 18689 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + 3 files changed, 18776 insertions(+) create mode 100644 packages/db/migrations/0284_utc_table_row_count_timestamps.sql create mode 100644 packages/db/migrations/meta/0284_snapshot.json diff --git a/packages/db/migrations/0284_utc_table_row_count_timestamps.sql b/packages/db/migrations/0284_utc_table_row_count_timestamps.sql new file mode 100644 index 00000000000..584d628d56f --- /dev/null +++ b/packages/db/migrations/0284_utc_table_row_count_timestamps.sql @@ -0,0 +1,80 @@ +CREATE OR REPLACE FUNCTION increment_user_table_row_count() +RETURNS TRIGGER AS $$ +DECLARE + updated_count INTEGER; + max_allowed INTEGER; +BEGIN + UPDATE user_table_definitions + SET row_count = row_count + 1, + updated_at = timezone('UTC', now()) + WHERE id = NEW.table_id + AND row_count < max_rows + RETURNING row_count, max_rows INTO updated_count, max_allowed; + + IF NOT FOUND THEN + SELECT max_rows INTO max_allowed + FROM user_table_definitions + WHERE id = NEW.table_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Table % not found', NEW.table_id + USING ERRCODE = 'foreign_key_violation'; + END IF; + + RAISE EXCEPTION 'Maximum row limit (%) reached for table %', + max_allowed, NEW.table_id + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +--> statement-breakpoint + +CREATE OR REPLACE FUNCTION decrement_user_table_row_count() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE user_table_definitions + SET row_count = GREATEST(row_count - 1, 0), + updated_at = timezone('UTC', now()) + WHERE id = OLD.table_id; + + RETURN OLD; +END; +$$ LANGUAGE plpgsql; +--> statement-breakpoint + +CREATE OR REPLACE FUNCTION increment_user_table_row_count_stmt() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE user_table_definitions d + SET row_count = d.row_count + c.n, + updated_at = timezone('UTC', now()) + FROM ( + SELECT table_id, count(*)::int AS n + FROM new_rows + GROUP BY table_id + ) c + WHERE d.id = c.table_id; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; +--> statement-breakpoint + +CREATE OR REPLACE FUNCTION decrement_user_table_row_count_stmt() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE user_table_definitions d + SET row_count = GREATEST(d.row_count - c.n, 0), + updated_at = timezone('UTC', now()) + FROM ( + SELECT table_id, count(*)::int AS n + FROM old_rows + GROUP BY table_id + ) c + WHERE d.id = c.table_id; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; diff --git a/packages/db/migrations/meta/0284_snapshot.json b/packages/db/migrations/meta/0284_snapshot.json new file mode 100644 index 00000000000..754e1b628c2 --- /dev/null +++ b/packages/db/migrations/meta/0284_snapshot.json @@ -0,0 +1,18689 @@ +{ + "id": "565ba1d3-de53-4147-bd97-af0d80603fa1", + "prevId": "83190b2a-dc8b-4d5f-8083-30eb14eecbef", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "columns": ["certificate_number"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "columns": ["key"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "columnsFrom": ["actor_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "columnsFrom": ["run_id"], + "tableTo": "copilot_runs", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "columnsFrom": ["checkpoint_id"], + "tableTo": "copilot_run_checkpoints", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "columnsFrom": ["chat_id"], + "tableTo": "copilot_chats", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "columnsFrom": ["chat_id"], + "tableTo": "copilot_chats", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "columnsFrom": ["run_id"], + "tableTo": "copilot_runs", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "columnsFrom": ["chat_id"], + "tableTo": "copilot_chats", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "columnsFrom": ["chat_id"], + "tableTo": "copilot_chats", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "account_id IS NOT NULL", + "concurrently": false + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "type = 'env_workspace'", + "concurrently": false + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "type = 'env_personal'", + "concurrently": false + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "columnsFrom": ["account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "columnsFrom": ["env_owner_user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "columnsFrom": ["credential_id"], + "tableTo": "credential", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "columnsFrom": ["invited_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "columnsFrom": ["drain_id"], + "tableTo": "data_drains", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "type": "stored", + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "with": { + "m": 16, + "ef_construction": 64 + }, + "method": "hnsw", + "concurrently": false + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "columnsFrom": ["knowledge_base_id"], + "tableTo": "knowledge_base", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "columnsFrom": ["connector_id"], + "tableTo": "knowledge_connector", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "columnsFrom": ["uploaded_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "type": "stored", + "as": "to_tsvector('english', \"embedding\".\"content\")" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "with": { + "m": 16, + "ef_construction": 64 + }, + "method": "hnsw", + "concurrently": false + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "columnsFrom": ["knowledge_base_id"], + "tableTo": "knowledge_base", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "columnsFrom": ["document_id"], + "tableTo": "document", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "columns": ["user_id"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "columnsFrom": ["parent_id"], + "tableTo": "folder", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "columnsFrom": ["inviter_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "columns": ["token"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "columnsFrom": ["invitation_id"], + "tableTo": "invitation", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "columnsFrom": ["schedule_id"], + "tableTo": "workflow_schedule", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "columnsFrom": ["folder_id"], + "tableTo": "folder", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "columnsFrom": ["knowledge_base_id"], + "tableTo": "knowledge_base", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "columnsFrom": ["knowledge_base_id"], + "tableTo": "knowledge_base", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "columnsFrom": ["connector_id"], + "tableTo": "knowledge_connector", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "columnsFrom": ["mcp_server_id"], + "tableTo": "mcp_servers", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "columnsFrom": ["added_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "columnsFrom": ["chat_id"], + "tableTo": "copilot_chats", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "columns": ["workspace_id"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "columnsFrom": ["set_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "columnsFrom": ["credential_id"], + "tableTo": "credential", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "is_default = true", + "concurrently": false + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "columnsFrom": ["permission_group_id"], + "tableTo": "permission_group", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "columnsFrom": ["assigned_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "columnsFrom": ["permission_group_id"], + "tableTo": "permission_group", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "columnsFrom": ["paused_execution_id"], + "tableTo": "paused_executions", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "columnsFrom": ["active_organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "columns": ["user_id"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "columnsFrom": ["skill_id"], + "tableTo": "skill", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "columnsFrom": ["invited_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "status = 'verified'", + "concurrently": false + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "columnsFrom": ["table_id"], + "tableTo": "user_table_definitions", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "columnsFrom": ["table_id"], + "tableTo": "user_table_definitions", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "columnsFrom": ["row_id"], + "tableTo": "user_table_rows", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "columnsFrom": ["table_id"], + "tableTo": "user_table_definitions", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "columnsFrom": ["triggered_by_user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "is_default = true", + "concurrently": false + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "columnsFrom": ["table_id"], + "tableTo": "user_table_definitions", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "nullsNotDistinct": false + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "columns": ["normalized_email"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "columns": ["user_id"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "columnsFrom": ["folder_id"], + "tableTo": "folder", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "columnsFrom": ["table_id"], + "tableTo": "user_table_definitions", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "columns": ["email"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "columnsFrom": ["deployment_version_id"], + "tableTo": "workflow_deployment_version", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "columnsFrom": ["folder_id"], + "tableTo": "folder", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "columnsFrom": ["chat_id"], + "tableTo": "copilot_chats", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "columnsFrom": ["deployment_version_id"], + "tableTo": "workflow_deployment_version", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "columnsFrom": ["previous_active_version_id"], + "tableTo": "workflow_deployment_version", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "columnsFrom": ["source_block_id"], + "tableTo": "workflow_blocks", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "columnsFrom": ["target_block_id"], + "tableTo": "workflow_blocks", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "status = 'running'", + "concurrently": false + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "columnsFrom": ["state_snapshot_id"], + "tableTo": "workflow_execution_snapshots", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "columnsFrom": ["deployment_version_id"], + "tableTo": "workflow_deployment_version", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "columnsFrom": ["server_id"], + "tableTo": "workflow_mcp_server", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "columnsFrom": ["deployment_version_id"], + "tableTo": "workflow_deployment_version", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "columnsFrom": ["deployment_operation_id"], + "tableTo": "workflow_deployment_operation", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "columnsFrom": ["source_user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "columnsFrom": ["source_workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "columnsFrom": ["owner_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "columnsFrom": ["billed_account_user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "columnsFrom": ["uploaded_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "columns": ["key"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "columnsFrom": ["file_id"], + "tableTo": "workspace_files", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "columnsFrom": ["folder_id"], + "tableTo": "folder", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "columnsFrom": ["chat_id"], + "tableTo": "copilot_chats", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "columnsFrom": ["child_workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "columnsFrom": ["child_workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "columnsFrom": ["child_workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "columnsFrom": ["child_workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 293fc04bbf3..c800833b985 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1982,6 +1982,13 @@ "when": 1785913915302, "tag": "0283_heavy_firebird", "breakpoints": true + }, + { + "idx": 284, + "version": "7", + "when": 1785918550201, + "tag": "0284_utc_table_row_count_timestamps", + "breakpoints": true } ] } From edff3479c50a63ebeef49a6c2df17483631dc25f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 5 Aug 2026 01:50:07 -0700 Subject: [PATCH 075/159] improvement(api): default folder deletion to non-recursive --- apps/docs/openapi-v2-files-audit.json | 7 ++++--- apps/docs/openapi-v2-knowledge.json | 7 ++++--- apps/docs/openapi-v2-tables.json | 7 ++++--- apps/docs/openapi-v2-workflows.json | 7 ++++--- apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts | 7 +++++++ apps/sim/lib/api/contracts/v2/shared.ts | 2 +- 6 files changed, 24 insertions(+), 13 deletions(-) diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index fe3d738c2e0..5b314f60a37 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -1810,7 +1810,7 @@ "delete": { "operationId": "deleteFilesFolder", "summary": "Delete Folder", - "description": "Delete a folder. With `recursive=false`, the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", + "description": "Delete a folder. By default the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", "tags": ["Files"], "parameters": [ { @@ -1828,10 +1828,11 @@ { "name": "recursive", "in": "query", - "required": true, + "required": false, "description": "Whether to delete the subtree.", "schema": { - "type": "boolean" + "type": "boolean", + "default": false } } ], diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 7275b766e49..40276925c82 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -1472,7 +1472,7 @@ "delete": { "operationId": "deleteKnowledgeFolder", "summary": "Delete Folder", - "description": "Delete a folder. With `recursive=false`, the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", + "description": "Delete a folder. By default the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1490,10 +1490,11 @@ { "name": "recursive", "in": "query", - "required": true, + "required": false, "description": "Whether to delete the subtree.", "schema": { - "type": "boolean" + "type": "boolean", + "default": false } } ], diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index e626034d61e..11d744c306f 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -4075,7 +4075,7 @@ "delete": { "operationId": "deleteTablesFolder", "summary": "Delete Folder", - "description": "Delete a folder. With `recursive=false`, the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", + "description": "Delete a folder. By default the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", "tags": ["Tables"], "parameters": [ { @@ -4093,10 +4093,11 @@ { "name": "recursive", "in": "query", - "required": true, + "required": false, "description": "Whether to delete the subtree.", "schema": { - "type": "boolean" + "type": "boolean", + "default": false } } ], diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 59faa644642..22fc0bae059 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -1948,7 +1948,7 @@ "delete": { "operationId": "deleteWorkflowsFolder", "summary": "Delete Folder", - "description": "Delete a folder. With `recursive=false`, the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", + "description": "Delete a folder. By default the folder must be empty. With `recursive=true`, its descendant folders and resources are deleted too.", "tags": ["Workflows"], "parameters": [ { @@ -1966,10 +1966,11 @@ { "name": "recursive", "in": "query", - "required": true, + "required": false, "description": "Whether to delete the subtree.", "schema": { - "type": "boolean" + "type": "boolean", + "default": 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 91051d1acab..896fb786ce4 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { v2ListLogsQuerySchema } from '@/lib/api/contracts/v2/logs' import { + v2DeleteFolderQuerySchema, v2FolderPathInputSchema, v2FolderPathSchema, v2NonRootFolderPathInputSchema, @@ -43,6 +44,12 @@ describe('v2 folder path contracts', () => { ).toBe(false) }) + it('defaults folder deletion to non-recursive', () => { + expect(v2DeleteFolderQuerySchema.parse({ workspaceId: WORKSPACE_ID, path: 'Reports' })).toEqual( + { workspaceId: WORKSPACE_ID, path: '/Reports', recursive: false } + ) + }) + it('normalizes every folder path in the logs filter', () => { const query = v2ListLogsQuerySchema.parse({ workspaceId: WORKSPACE_ID, diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index c5e291c1c0e..fbd33c2f396 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -179,7 +179,7 @@ export const v2DeleteFolderQuerySchema = z .object({ workspaceId: workspaceIdSchema, path: v2NonRootFolderPathInputSchema, - recursive: z.stringbool(), + recursive: z.stringbool().optional().default(false), }) .strict() From da611cbeae2452640d2b50f2ed07b469dc8ad396 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 5 Aug 2026 02:10:58 -0700 Subject: [PATCH 076/159] feat(cli): streamline common resource workflows --- packages/sim-cli/README.md | 44 ++++--- .../commands/protocol/files-download.test.ts | 31 ++++- .../src/commands/protocol/files-download.ts | 38 +++++- .../commands/protocol/files-upload.test.ts | 12 +- .../src/commands/protocol/files-upload.ts | 17 +-- .../knowledge-document-upload.test.ts | 6 +- .../protocol/resource-directory.test.ts | 6 +- .../commands/protocol/resource-directory.ts | 7 +- .../commands/protocol/tables-import.test.ts | 2 +- .../src/commands/protocol/tables-import.ts | 7 +- packages/sim-cli/src/config/index.ts | 1 + packages/sim-cli/src/config/profile.test.ts | 23 ++-- packages/sim-cli/src/config/profile.ts | 32 ++--- packages/sim-cli/src/context.ts | 9 +- packages/sim-cli/src/contract/commands.ts | 69 +++++++++-- packages/sim-cli/src/contract/types.ts | 19 ++- packages/sim-cli/src/generated/v2-api.ts | 109 ++++++++++------- packages/sim-cli/src/http/client.ts | 65 ---------- packages/sim-cli/src/index.ts | 13 +- packages/sim-cli/src/runtime/build.test.ts | 115 +++++++++++++++--- packages/sim-cli/src/runtime/build.ts | 33 ++++- packages/sim-cli/src/runtime/folder-path.ts | 7 -- packages/sim-cli/src/runtime/options.ts | 21 +++- packages/sim-cli/src/runtime/request.test.ts | 4 + packages/sim-cli/src/runtime/request.ts | 40 ++++-- .../sim-cli/src/transfer/upload-session.ts | 12 +- 26 files changed, 495 insertions(+), 247 deletions(-) delete mode 100644 packages/sim-cli/src/runtime/folder-path.ts diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index d5fbcc96b0f..9c92d7fcaec 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -3,7 +3,7 @@ Talk to the [Sim](https://sim.ai) API from your terminal. ```bash -npm install -g @sim/cli +bun add --global @sim/cli sim login sim workflows list ``` @@ -53,7 +53,7 @@ Each setting resolves independently, first match wins: | Rank | Source | | --- | --- | -| 1 | Command-line flag (`--endpoint`, `--workspace`) | +| 1 | Command-line flag (`--endpoint`, `--workspace`, `--output`) | | 2 | Environment (`SIM_ENDPOINT`, `SIM_API_KEY`, `SIM_WORKSPACE`, `SIM_OUTPUT`) | | 3 | `~/.sim/config` / `~/.sim/credentials` for the selected profile | | 4 | Built-in default (`https://sim.ai`, `table`) | @@ -112,12 +112,15 @@ also accepts its singular form: for example, `sim table list`, `sim file download`, and `sim workflow get` are equivalent to their plural spellings. +`knowledge` also accepts the shorter `kb` alias. + ```bash sim workflows ls [path] [--search ] [--limit ] sim workflows list [--folder ] [--deployed-only] [--limit ] sim workflows get sim workflows mv --folder sim workflows deploy|undeploy|rollback +sim workflows run [--input ] [--select-output …] sim logs list [--level error] [--workflow …] [--trigger …] [--start ] sim logs get @@ -129,7 +132,10 @@ sim tables get sim tables mv --folder sim tables columns sim tables rows list [--limit ] +sim tables rows create --data +sim tables rows create --rows sim tables rows query [--filter ] [--sort ] [--limit ] +sim tables rows query --filter '{"all":[{"field":"status","op":"eq","value":"active"}]}' sim tables upsert --data sim tables rows batch-delete (--row … | --filter ) --yes @@ -138,10 +144,10 @@ sim files list [--folder ] sim files get sim files create --name [--folder ] [--content ] [--encoding utf-8|base64] sim files upload [--name ] [--folder ] -sim files download [-o ] +sim files download [-o ] sim files mv --file-ids … [--to ] sim files batch-delete --file-ids … --yes -sim files delete +sim files delete --yes sim knowledge ls [path] [--search ] [--limit ] sim knowledge list [--folder ] @@ -150,8 +156,14 @@ sim knowledge mv --folder sim knowledge documents [--search ] sim knowledge documents upload [--tag ...] sim knowledge search --query --kb … [--search-mode vector|hybrid] + +sim billing +sim billing logs [--period 7d] [--limit ] ``` +Workflow output selectors use `blockName.field` syntax, such as +`--select-output agent_1.content`; fields that are not produced are omitted. + `ls` is a directory view: it combines the resources at its optional path with that folder's direct child folders. It never includes deeper descendants. Its `ref` column is the resource ID or canonical folder path to pass to the next @@ -166,14 +178,13 @@ sim tables folders ls --parent Reports sim tables mkdir Reports/Quarterly sim tables folders create Reports/Quarterly sim tables folders mv Reports/Quarterly Archive/Quarterly -sim tables folders delete Archive/Quarterly --recursive false --yes +sim tables folders delete Archive/Quarterly --yes +sim tables folders delete Archive --recursive --yes ``` `mkdir` is the concise form of `folders create`. Replace `tables` with `files`, -`workflows`, or `knowledge`. The leading `/` is optional on CLI inputs; the CLI -adds it before calling the API. Omit the `ls` path to list root. A slash that -belongs to a folder name is percent-encoded as `%2F` rather than treated as a -separator. +`workflows`, or `knowledge`. The leading `/` is optional on API inputs; the API +returns the canonical leading-slash form. Omit the `ls` path to list root. ### List inputs @@ -210,9 +221,9 @@ everything" default. ### Output formats -Output format is a **profile setting**, not a per-command flag — there is no -`--output`. Set it once with `sim configure --set-output `, or override -ambiently with `SIM_OUTPUT` for a one-off or for CI: +Output format can be selected per command with `--output`, saved as a profile +default with `sim configure --set-output `, or set ambiently with +`SIM_OUTPUT` for CI: | Format | For | | --- | --- | @@ -230,7 +241,8 @@ parsing. sim configure --set-output json # for this profile, from now on sim configure --set-output text --profile scripts # a profile dedicated to scripting -SIM_OUTPUT=json sim logs list --level error | jq -r '.[].executionId' +sim --output json logs list --level error | jq -r '.[].executionId' +sim logs list --level error --output json | jq -r '.[].executionId' SIM_OUTPUT=yaml sim logs list --level error > logs.yaml SIM_OUTPUT=text sim files list | while IFS=$'\t' read -r id name size type uploaded; do @@ -241,9 +253,9 @@ done An absent value is an em-dash in `table` and an **empty field** in `text`, so emptiness tests downstream behave. -A bad `SIM_OUTPUT` or `output =` is ignored and falls back to `table`. Both are -ambient — set once, then read by every later command — so one bad value should -not break the CLI outright. +An invalid active `SIM_OUTPUT` or `output =` value fails with the accepted +formats. A valid higher-priority `--output` still overrides a stale lower tier, +so `sim --output table configure --set-output json` can repair a profile. ## How this stays in sync with the API diff --git a/packages/sim-cli/src/commands/protocol/files-download.test.ts b/packages/sim-cli/src/commands/protocol/files-download.test.ts index 11b4f743bce..78b57300345 100644 --- a/packages/sim-cli/src/commands/protocol/files-download.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-download.test.ts @@ -102,6 +102,35 @@ describe('files download', () => { target, ]) - expect(JSON.parse(logged[0])).toEqual({ id: 'file_1', path: target, status: 'saved' }) + expect(JSON.parse(logged[0])).toEqual({ + id: 'file_1', + path: target, + status: 'saved', + }) + }) + + it('streams raw bytes to stdout with the conventional - destination', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('downloaded', { status: 200 }))) + const chunks: Uint8Array[] = [] + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) + return true + }) + const logged = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program().parseAsync(['node', 'sim', 'file', 'download', 'file_1', '-o', '-']) + + expect(Buffer.concat(chunks).toString('utf8')).toBe('downloaded') + expect(logged).not.toHaveBeenCalled() + }) + + it('rejects overwrite semantics for stdout', async () => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + + await expect( + program().parseAsync(['node', 'sim', 'file', 'download', 'file_1', '-o', '-', '--force']) + ).rejects.toThrow(/--force cannot be used/) + expect(fetch).not.toHaveBeenCalled() }) }) diff --git a/packages/sim-cli/src/commands/protocol/files-download.ts b/packages/sim-cli/src/commands/protocol/files-download.ts index a2683bf735a..6aba29862d3 100644 --- a/packages/sim-cli/src/commands/protocol/files-download.ts +++ b/packages/sim-cli/src/commands/protocol/files-download.ts @@ -47,11 +47,28 @@ export async function streamToFile( } } +/** Streams a fetch body to stdout without closing the process-wide stream. */ +export async function streamToStdout( + body: ReadableStream, + output: NodeJS.WriteStream = process.stdout +): Promise { + const reader = body.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) return + if (!output.write(value)) await once(output, 'drain') + } + } finally { + reader.releaseLock() + } +} + export function attachFileDownload(files: Command): void { files .command('download ') .description('Download a file') - .option('-o, --output-file ', 'Where to write it (defaults to the file name)') + .option('-o, --output-file ', 'Where to write it (default: file name; -: stdout)') .option('--force', 'Overwrite the destination if it already exists') .action( async ( @@ -59,6 +76,10 @@ export function attachFileDownload(files: Command): void { options: { outputFile?: string; force?: boolean }, command: Command ) => { + if (options.outputFile === '-' && options.force) { + throw new SimApiError('--force cannot be used when --output-file is -', 0) + } + const { client, profile } = clientFrom(command) const workspaceId = client.requireWorkspace() @@ -70,7 +91,9 @@ export function attachFileDownload(files: Command): void { url.searchParams.set('workspaceId', workspaceId) // boundary-raw-fetch: binary download cannot pass through the JSON client - const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } }) + const response = await fetch(url, { + headers: { 'x-api-key': profile.apiKey }, + }) if (!response.ok || !response.body) { const raw = await response.text().catch(() => '') throw new SimApiError( @@ -79,6 +102,11 @@ export function attachFileDownload(files: Command): void { ) } + if (options.outputFile === '-') { + await streamToStdout(response.body) + return + } + const target = options.outputFile ?? basename( @@ -90,7 +118,11 @@ export function attachFileDownload(files: Command): void { response.body, createWriteStream(target, { flags: options.force ? 'w' : 'wx' }) ) - printProtocolResult(profile.output, { id: fileId, path: target, status: 'saved' }) + printProtocolResult(profile.output, { + id: fileId, + path: target, + status: 'saved', + }) } ) } diff --git a/packages/sim-cli/src/commands/protocol/files-upload.test.ts b/packages/sim-cli/src/commands/protocol/files-upload.test.ts index 8ddffe560a4..5b023be631f 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.test.ts @@ -44,7 +44,7 @@ function program(): Command { } describe('files upload', () => { - it('uses a signed PUT transfer and completes with an empty body', async () => { + it('uses a signed PUT transfer and completes without a request body', async () => { const path = join(dir, 'notes.txt') writeFileSync(path, 'hello') mockRequest @@ -114,7 +114,7 @@ describe('files upload', () => { name: 'notes.txt', contentType: 'text/plain', size: 5, - folderPath: '/Reports', + folderPath: 'Reports', }, }, ]) @@ -124,14 +124,18 @@ describe('files upload', () => { method: 'POST', query: { workspaceId: 'ws_local' }, headers: { 'upload-token': 'secret-token' }, - body: {}, }, ]) expect(JSON.parse(logged[0])).toEqual({ id: 'file_1', name: 'notes.txt', size: 5, - status: 'uploaded', + type: 'text/plain', + key: 'workspace/ws_local/notes.txt', + folderPath: '/', + uploadedBy: 'user_1', + uploadedAt: '2026-08-04T19:00:00.000Z', + updatedAt: '2026-08-04T19:00:00.000Z', }) expect(logged[0]).not.toContain('secret-token') }) diff --git a/packages/sim-cli/src/commands/protocol/files-upload.ts b/packages/sim-cli/src/commands/protocol/files-upload.ts index e8837c57c09..d0c7f3185bf 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -4,7 +4,6 @@ import type { CompleteFileUploadResponse, CreateFileUploadResponse, } from '../../generated/v2-api.js' -import { normalizeFolderPath } from '../../runtime/folder-path.js' import { contentTypeFor, localFile } from '../../transfer/local-file.js' import { finishUploadSession } from '../../transfer/upload-session.js' import { printProtocolResult } from './result.js' @@ -13,7 +12,7 @@ export function attachFileUpload(files: Command): void { files .command('upload ') .description('Upload a file to the workspace') - .option('--folder ', 'Canonical destination folder path (defaults to /)') + .option('--folder ', 'Destination folder path (defaults to /)') .option('--name ', 'Store it under a different name') .action(async (path: string, options: { folder?: string; name?: string }, command: Command) => { const { client, profile } = clientFrom(command) @@ -27,9 +26,7 @@ export function attachFileUpload(files: Command): void { name, contentType: contentTypeFor(name), size, - ...(options.folder !== undefined - ? { folderPath: normalizeFolderPath(options.folder) } - : {}), + ...(options.folder !== undefined ? { folderPath: options.folder } : {}), }, }) const { session, uploadToken, transfer } = created.data @@ -45,11 +42,9 @@ export function attachFileUpload(files: Command): void { path ) - printProtocolResult(profile.output, { - id: completed.file?.id ?? session.id, - name, - size, - status: 'uploaded', - }) + if (!completed.file) { + throw new Error(`File upload ${session.id} completed without a file`) + } + printProtocolResult(profile.output, completed.file) }) } diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts index 525751c58d9..a7f6dff40a8 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts @@ -166,8 +166,10 @@ describe('knowledge documents upload', () => { expect(mockRequest.mock.calls[2][0]).toBe( '/api/v2/knowledge/kb_1/documents/uploads/upload_1/complete' ) - expect(mockRequest.mock.calls[2][1].body).toEqual({ - parts: [{ partNumber: 1, etag: 'etag-1' }], + expect(mockRequest.mock.calls[2][1]).toEqual({ + method: 'POST', + query: { workspaceId: 'ws_local' }, + headers: { 'upload-token': 'secret-token' }, }) expect(JSON.parse(logged[0])).toEqual({ id: 'doc_1', diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts index 9e2914428da..950512efc0b 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts @@ -87,7 +87,7 @@ describe('resource directory', () => { expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', { query: { workspaceId: 'ws_local', - parentPath: '/Reports', + parentPath: 'Reports', search: 'r', sortBy: 'name', sortOrder: 'asc', @@ -96,7 +96,7 @@ describe('resource directory', () => { expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables', { query: { workspaceId: 'ws_local', - folderPath: '/Reports', + folderPath: 'Reports', search: 'r', sortBy: 'name', sortOrder: 'asc', @@ -140,7 +140,7 @@ describe('resource directory', () => { expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', { method: 'POST', - body: { workspaceId: 'ws_local', path: '/Reports/Quarterly' }, + body: { workspaceId: 'ws_local', path: 'Reports/Quarterly' }, }) }) diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.ts b/packages/sim-cli/src/commands/protocol/resource-directory.ts index 846b9bd71fd..e59f50c2200 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.ts @@ -14,7 +14,6 @@ import { } from '../../generated/v2-api.js' import { SimApiError, type SimClient, type V2Page } from '../../http/client.js' import { type Column, printList, text, timestamp } from '../../output/render.js' -import { normalizeFolderPath } from '../../runtime/folder-path.js' import { DEFAULT_LIMIT } from '../../runtime/options.js' import { renderResult } from '../../runtime/result.js' @@ -179,7 +178,7 @@ export function attachResourceDirectoryCommands( } const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit - const folderPath = normalizeFolderPath(path ?? '/') + const folderPath = path ?? '/' const { client, profile } = clientFrom(command) const workspaceId = client.requireWorkspace() const [folders, resources] = await Promise.all([ @@ -193,13 +192,13 @@ export function attachResourceDirectoryCommands( group .command('mkdir ') .allowExcessArguments(false) - .description(`Create a ${config.kind} directory at a canonical path`) + .description(`Create a ${config.kind} directory at a path`) .action(async (path: string, _options: Record, command: Command) => { const { client, profile } = clientFrom(command) const operation = V2_OPERATIONS[config.createFolder] const result = await client.request<{ data?: unknown }>(operation.path, { method: operation.method, - body: { workspaceId: client.requireWorkspace(), path: normalizeFolderPath(path) }, + body: { workspaceId: client.requireWorkspace(), path }, }) renderResult(config.createFolder, profile.output, result.data ?? result, {}) }) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.test.ts b/packages/sim-cli/src/commands/protocol/tables-import.test.ts index a6b5a063e16..0a04990ce1e 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.test.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.test.ts @@ -115,7 +115,7 @@ describe('tables import output', () => { body: { workspaceId: 'ws_local', source: { type: 'workspace_file', fileId: 'file_1' }, - target: { type: 'new', name: 'Customers', folderPath: '/Reports' }, + target: { type: 'new', name: 'Customers', folderPath: 'Reports' }, }, }) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts index 1391176720b..78803a51f9e 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -8,7 +8,6 @@ import type { GetTableImportResponse, } from '../../generated/v2-api.js' import { SimApiError, type SimClient } from '../../http/client.js' -import { normalizeFolderPath } from '../../runtime/folder-path.js' import { coerce, type FieldSpec } from '../../runtime/request.js' import { contentTypeFor, localFile } from '../../transfer/local-file.js' import { finishUploadSession } from '../../transfer/upload-session.js' @@ -107,7 +106,7 @@ export function attachTableImport(tables: Command): void { 'How to write into --table-id (default: append)' ).choices(['append', 'replace']) ) - .option('--folder ', 'Canonical folder path for the new table') + .option('--folder ', 'Folder path for the new table') .option('--file-id ', 'Import a file already in the workspace instead of a local path') .option('--mapping ', 'Column mapping (--table-id only)') .option('--create-columns ', 'Columns to create (--table-id only)') @@ -143,9 +142,7 @@ export function attachTableImport(tables: Command): void { target = { type: 'new', name, - ...(options.folder !== undefined - ? { folderPath: normalizeFolderPath(options.folder) } - : {}), + ...(options.folder !== undefined ? { folderPath: options.folder } : {}), } } diff --git a/packages/sim-cli/src/config/index.ts b/packages/sim-cli/src/config/index.ts index 5a11e311370..50ead0e790d 100644 --- a/packages/sim-cli/src/config/index.ts +++ b/packages/sim-cli/src/config/index.ts @@ -6,6 +6,7 @@ export { listProfiles, OUTPUT_FORMATS, type OutputFormat, + ProfileConfigError, type ProfileOverrides, type ResolvedProfile, readConfigProfile, diff --git a/packages/sim-cli/src/config/profile.test.ts b/packages/sim-cli/src/config/profile.test.ts index 48661750b7c..fee767e474f 100644 --- a/packages/sim-cli/src/config/profile.test.ts +++ b/packages/sim-cli/src/config/profile.test.ts @@ -96,24 +96,31 @@ describe('profile resolution', () => { expect(resolveProfile({ endpoint: 'https://sim.ai///' }).endpoint).toBe('https://sim.ai') }) - it('ignores an unrecognized output format instead of failing the whole resolve', () => { - // Both output sources are ambient — set once, then every later command reads - // them — so a bad value falls back rather than breaking the CLI outright. + it('fails fast on an unrecognized active output format', () => { process.env.SIM_OUTPUT = 'xml' - expect(resolveProfile().output).toBe('table') + expect(() => resolveProfile()).toThrow( + 'Unknown output format "xml" from env. Use one of: table, json, yaml, text' + ) - process.env.SIM_OUTPUT = undefined + Reflect.deleteProperty(process.env, 'SIM_OUTPUT') writeConfigProfile('default', { output: 'xml' }) - expect(resolveProfile().output).toBe('table') + expect(() => resolveProfile()).toThrow( + 'Unknown output format "xml" from config. Use one of: table, json, yaml, text' + ) + expect(resolveProfile({ output: 'json' }).output).toBe('json') }) - it('takes the output format from the profile, and lets the env override it', () => { - // There is deliberately no `--output` flag: format is a profile setting. + it('resolves output from flag, environment, then profile', () => { writeConfigProfile('default', { output: 'yaml' }) expect(resolveProfile()).toMatchObject({ output: 'yaml', sources: { output: 'config' } }) process.env.SIM_OUTPUT = 'json' expect(resolveProfile()).toMatchObject({ output: 'json', sources: { output: 'env' } }) + + expect(resolveProfile({ output: 'text' })).toMatchObject({ + output: 'text', + sources: { output: 'flag' }, + }) }) it('accepts every documented output format from the environment', () => { diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts index 48fca121498..9826b79daa1 100644 --- a/packages/sim-cli/src/config/profile.ts +++ b/packages/sim-cli/src/config/profile.ts @@ -24,6 +24,14 @@ export const DEFAULT_ENDPOINT = 'https://sim.ai' export const OUTPUT_FORMATS = ['table', 'json', 'yaml', 'text'] as const export type OutputFormat = (typeof OUTPUT_FORMATS)[number] +/** An invalid active profile setting that the user can correct. */ +export class ProfileConfigError extends Error { + constructor(message: string) { + super(message) + this.name = 'ProfileConfigError' + } +} + /** Everything a command needs to make a call, after the resolution chain runs. */ export interface ResolvedProfile { name: string @@ -47,6 +55,7 @@ export interface ProfileOverrides { endpoint?: string apiKey?: string workspaceId?: string + output?: OutputFormat } /** @@ -127,12 +136,6 @@ function normalizeEndpoint(endpoint: string): string { return endpoint.replace(/\/+$/, '') } -function parseOutput(value: string | undefined): OutputFormat | null { - return value && (OUTPUT_FORMATS as readonly string[]).includes(value) - ? (value as OutputFormat) - : null -} - /** * Resolves one setting through the precedence chain, reporting where it landed. * Order is flags → environment → files → built-in default, the same order every @@ -185,19 +188,20 @@ export function resolveProfile(overrides: ProfileOverrides = {}): ResolvedProfil 'unset' ) - /** - * No flag tier: output format is a profile setting, not a per-command one. - * `SIM_OUTPUT` stays as the one-off escape hatch (`SIM_OUTPUT=json sim … | jq`) - * and as the file-less path for CI, but there is deliberately no `--output`. - */ - const output = resolve( + const output = resolve( [ - ['env', parseOutput(process.env.SIM_OUTPUT)], - ['config', parseOutput(config.output)], + ['flag', overrides.output], + ['env', process.env.SIM_OUTPUT], + ['config', config.output], ], 'table', 'default' ) + if (!(OUTPUT_FORMATS as readonly string[]).includes(output.value as string)) { + throw new ProfileConfigError( + `Unknown output format "${output.value}" from ${output.source}. Use one of: ${OUTPUT_FORMATS.join(', ')}` + ) + } return { name, diff --git a/packages/sim-cli/src/context.ts b/packages/sim-cli/src/context.ts index 7486100815f..1880f366eac 100644 --- a/packages/sim-cli/src/context.ts +++ b/packages/sim-cli/src/context.ts @@ -1,5 +1,10 @@ import type { Command } from 'commander' -import { type ProfileOverrides, type ResolvedProfile, resolveProfile } from './config/index.js' +import { + type OutputFormat, + type ProfileOverrides, + type ResolvedProfile, + resolveProfile, +} from './config/index.js' import { SimClient } from './http/client.js' /** Global flags, shared by every subcommand. */ @@ -7,6 +12,7 @@ export interface GlobalOptions { profile?: string endpoint?: string workspace?: string + output?: OutputFormat } /** @@ -24,6 +30,7 @@ export function profileFrom(command: Command, extra: ProfileOverrides = {}): Res profile: globals.profile, endpoint: globals.endpoint, workspaceId: globals.workspace, + output: globals.output, ...extra, }) } diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index c12f257bf3c..68d1100fe72 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -2,17 +2,22 @@ import type { CliContract, ColumnSpec } from './types.js' const TABLE_NAME_HELP = 'Identifier: letters, numbers, and underscores; cannot start with a number' const TABLE_FILTER_HELP = - 'Predicate tree using all/any and operators eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, or isNotNull' + 'Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull' +const TABLE_SORT_HELP = + 'Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc)' const CUSTOM_TOOL_SCHEMA_HELP = 'OpenAI function schema: {"type":"function","function":{"name":"...","parameters":{"type":"object","properties":{}}}}' const FOLDER_PATH_INPUT = { - normalize: 'folder-path', describe: 'Folder path; the leading / is optional', } as const const FOLDER_PATH_FLAG = { ...FOLDER_PATH_INPUT, name: 'folder', } as const +const FOLDER_DELETE_FLAGS = { + path: FOLDER_PATH_INPUT, + recursive: { boolean: true, describe: 'Delete the folder and its descendants' }, +} as const const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ { header: 'path' }, { header: 'name' }, @@ -33,6 +38,31 @@ const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ * upsertTableRow → sim tables upsert */ export const CLI_CONTRACT: CliContract = { + getUsageSummary: { + command: 'billing', + groupDefault: true, + describe: 'Show current billing-period usage', + fields: [ + { header: 'plan' }, + { header: 'period start', path: 'period.start', format: 'timestamp' }, + { header: 'period end', path: 'period.end', format: 'timestamp' }, + { header: 'used credits', path: 'totalCredits' }, + { header: 'limit credits', path: 'limitCredits' }, + { header: 'by source', path: 'bySourceCredits' }, + ], + }, + listUsageLogs: { + command: 'billing logs', + describe: 'List credit usage events', + columns: [ + { header: 'at', path: 'createdAt', format: 'timestamp' }, + { header: 'source' }, + { header: 'workflow', path: 'workflowName' }, + { header: 'credits', path: 'creditCost' }, + { header: 'id' }, + ], + }, + // ─── Name collisions: REST overloads one path for single and bulk ───────── // The derived name is identical for both, so the bulk form is renamed. AWS's // `batch-` prefix rather than a `--all` flag: the plural is a different and @@ -160,12 +190,28 @@ export const CLI_CONTRACT: CliContract = { command: 'tables rows query', flags: { predicate: { name: 'filter', json: true, describe: TABLE_FILTER_HELP }, - sort: { json: true }, + sort: { json: true, describe: TABLE_SORT_HELP }, }, // A row's cells live under `data`; without this the table showed an id and // two timestamps per row and none of the content anyone ran the query for. expand: 'data', }, + createTableRows: { + bodyVariants: [ + { + name: 'data', + property: 'data', + kind: 'object', + describe: 'One row keyed by column name', + }, + { + name: 'rows', + property: 'rows', + kind: 'array', + describe: 'Several rows keyed by column name', + }, + ], + }, createTable: { flags: { name: { describe: TABLE_NAME_HELP }, @@ -441,22 +487,22 @@ export const CLI_CONTRACT: CliContract = { }, deleteFileFolder: { positionals: ['path'], - flags: { path: FOLDER_PATH_INPUT, recursive: { choices: ['true', 'false'] } }, + flags: FOLDER_DELETE_FLAGS, confirm: 'This archives the file folder and, when recursive, everything inside it.', }, deleteKnowledgeFolder: { positionals: ['path'], - flags: { path: FOLDER_PATH_INPUT, recursive: { choices: ['true', 'false'] } }, + flags: FOLDER_DELETE_FLAGS, confirm: 'This archives the knowledge folder and, when recursive, everything inside it.', }, deleteTableFolder: { positionals: ['path'], - flags: { path: FOLDER_PATH_INPUT, recursive: { choices: ['true', 'false'] } }, + flags: FOLDER_DELETE_FLAGS, confirm: 'This archives the table folder and, when recursive, everything inside it.', }, deleteWorkflowFolder: { positionals: ['path'], - flags: { path: FOLDER_PATH_INPUT, recursive: { choices: ['true', 'false'] } }, + flags: FOLDER_DELETE_FLAGS, confirm: 'This archives the workflow folder and, when recursive, everything inside it.', }, @@ -478,7 +524,7 @@ export const CLI_CONTRACT: CliContract = { flags: { q: { describe: 'Value to find' }, predicate: { name: 'filter', json: true, describe: TABLE_FILTER_HELP }, - sort: { json: true }, + sort: { json: true, describe: TABLE_SORT_HELP }, }, itemsPath: 'matches', columns: [{ header: 'ordinal' }, { header: 'row', path: 'rowId' }, { header: 'column' }], @@ -530,7 +576,12 @@ export const CLI_CONTRACT: CliContract = { describe: 'Run a deployed workflow and wait for the result', flags: { input: { json: true, describe: 'Trigger input as JSON' }, - selectedOutputs: { name: 'output', list: true }, + selectedOutputs: { + name: 'select-output', + list: true, + describe: + 'Return blockName.field values (e.g. agent_1.content); missing fields are omitted', + }, // SSE, not JSON — the generic client cannot consume it. A `sim workflows // run --follow` that renders the stream is a separate, hand-written // command; advertising a flag that breaks the response is worse than diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index bdaac5bb6f8..c3ad7ce4fee 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -49,8 +49,8 @@ export interface FlagSpec { describe?: string /** Accepted values when the generated descriptor cannot recover an enum. */ choices?: readonly string[] - /** Normalizes a terminal-friendly value into its API wire representation. */ - normalize?: 'folder-path' + /** Expose a string-backed API boolean as a conventional terminal toggle. */ + boolean?: true /** * Never expose this field as a flag, and never send it. * @@ -72,12 +72,25 @@ export interface ColumnSpec { format?: 'auto' | 'timestamp' | 'bytes' | 'duration' | 'bool' | 'cost' | 'count' } +export interface BodyVariantSpec { + /** User-facing flag name, without `--`. */ + name: string + /** Request-body property populated by this variant. */ + property: string + /** JSON shape accepted by this variant. */ + kind: 'object' | 'array' + /** One-line help describing when to use this variant. */ + describe: string +} + export interface CommandSpec { /** * Command path, space-separated. Omit to accept the derived * ` [sub-resource] ` name. */ command?: string + /** Run this operation when its top-level group is invoked without a subcommand. */ + groupDefault?: boolean /** Alternate leaf command names, such as `ls` for `list`. */ aliases?: readonly string[] /** Required query/body fields exposed as positional arguments, in order. */ @@ -86,6 +99,8 @@ export interface CommandSpec { describe?: string /** Per-field flag overrides, keyed by the contract's field name. */ flags?: Record + /** Friendly mutually-exclusive flags for an otherwise opaque union body. */ + bodyVariants?: readonly BodyVariantSpec[] /** Columns for table output. Omit on non-list commands to print a record. */ columns?: ColumnSpec[] /** Fields shown for a single record in human formats. Machine output stays raw. */ diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 51f82deec62..cf0cb692751 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -26,7 +26,15 @@ export type AbortFileUploadHeaders = { export type AbortFileUploadResponse = { data: { id: string - status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' name: string contentType: string size: number @@ -64,7 +72,15 @@ export type AbortKnowledgeDocumentUploadResponse = { data: { id: string knowledgeBaseId: string - status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' name: string contentType: string size: number @@ -345,15 +361,6 @@ export type CompleteFileUploadQuery = { workspaceId: string } -export type CompleteFileUploadBody = - | { - parts: Array<{ - partNumber: number - etag?: string - }> - } - | Record - export type CompleteFileUploadHeaders = { 'upload-token': string } @@ -361,7 +368,15 @@ export type CompleteFileUploadHeaders = { export type CompleteFileUploadResponse = { data: { id: string - status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' name: string contentType: string size: number @@ -391,15 +406,6 @@ export type CompleteKnowledgeDocumentUploadQuery = { workspaceId: string } -export type CompleteKnowledgeDocumentUploadBody = - | { - parts: Array<{ - partNumber: number - etag?: string - }> - } - | Record - export type CompleteKnowledgeDocumentUploadHeaders = { 'upload-token': string } @@ -408,7 +414,15 @@ export type CompleteKnowledgeDocumentUploadResponse = { data: { id: string knowledgeBaseId: string - status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' name: string contentType: string size: number @@ -439,15 +453,6 @@ export type CompleteTableImportQuery = { workspaceId: string } -export type CompleteTableImportBody = - | { - parts: Array<{ - partNumber: number - etag?: string - }> - } - | Record - export type CompleteTableImportHeaders = { 'upload-token': string } @@ -623,7 +628,15 @@ export type CreateFileUploadResponse = { data: { session: { id: string - status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' name: string contentType: string size: number @@ -755,7 +768,15 @@ export type CreateKnowledgeDocumentUploadResponse = { session: { id: string knowledgeBaseId: string - status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' name: string contentType: string size: number @@ -1404,7 +1425,7 @@ export type DeleteFileResponse = { export type DeleteFileFolderQuery = { workspaceId: string path: string - recursive: string + recursive?: string } export type DeleteFileFolderResponse = { @@ -1455,7 +1476,7 @@ export type DeleteKnowledgeDocumentResponse = { export type DeleteKnowledgeFolderQuery = { workspaceId: string path: string - recursive: string + recursive?: string } export type DeleteKnowledgeFolderResponse = { @@ -1513,6 +1534,7 @@ export type DeleteTableQuery = { export type DeleteTableResponse = { data: { id: string + deleted: true } } @@ -1549,7 +1571,7 @@ export type DeleteTableColumnResponse = { export type DeleteTableFolderQuery = { workspaceId: string path: string - recursive: string + recursive?: string } export type DeleteTableFolderResponse = { @@ -1633,7 +1655,7 @@ export type DeleteWorkflowResponse = { export type DeleteWorkflowFolderQuery = { workspaceId: string path: string - recursive: string + recursive?: string } export type DeleteWorkflowFolderResponse = { @@ -3867,6 +3889,7 @@ export type UpdateTableParams = { export type UpdateTableBody = { workspaceId: string name?: string + description?: string | null folderPath?: string } @@ -4402,7 +4425,6 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, }, - opaqueBody: true, }, completeKnowledgeDocumentUpload: { method: 'POST', @@ -4413,7 +4435,6 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, }, - opaqueBody: true, }, completeTableImport: { method: 'POST', @@ -4424,7 +4445,6 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, }, - opaqueBody: true, }, createCredential: { method: 'POST', @@ -4763,7 +4783,7 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, path: { kind: 'string', required: true }, - recursive: { kind: 'string', required: true }, + recursive: { kind: 'string', default: false }, }, }, deleteKnowledgeBase: { @@ -4795,7 +4815,7 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, path: { kind: 'string', required: true }, - recursive: { kind: 'string', required: true }, + recursive: { kind: 'string', default: false }, }, }, deleteMcpServer: { @@ -4848,7 +4868,7 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, path: { kind: 'string', required: true }, - recursive: { kind: 'string', required: true }, + recursive: { kind: 'string', default: false }, }, }, deleteTableRow: { @@ -4900,7 +4920,7 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, path: { kind: 'string', required: true }, - recursive: { kind: 'string', required: true }, + recursive: { kind: 'string', default: false }, }, }, deleteWorkflowGroup: { @@ -5814,6 +5834,7 @@ export const V2_OPERATIONS = { body: { workspaceId: { kind: 'string', required: true }, name: { kind: 'string' }, + description: { kind: 'string' }, folderPath: { kind: 'string' }, }, }, diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index af14b433da7..f195e6364d2 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -1,5 +1,4 @@ import type { ResolvedProfile } from '../config/index.js' -import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' /** * A failure the CLI can explain. Anything thrown as a `SimApiError` is printed @@ -19,11 +18,6 @@ export class SimApiError extends Error { } } -/** `{ data }` — a single resource. */ -interface V2DataEnvelope { - data: T -} - /** `{ data, nextCursor }` — one page of a list. */ export interface V2Page { data: T[] @@ -195,65 +189,6 @@ export class SimClient { if (!raw) return undefined as T return JSON.parse(raw) as T } - - /** Unwraps `{ data }`. */ - async getData(path: string, options: RequestOptions = {}): Promise { - const body = await this.request>(path, options) - return body.data - } - - /** One page of `{ data, nextCursor }`. */ - async getPage(path: string, options: RequestOptions = {}): Promise> { - return this.request>(path, options) - } - - /** - * Walks a cursor list until it is exhausted or `max` items are collected. - * - * `max` is required rather than optional: an unbounded auto-pager against a - * workspace with a million logs will happily fill memory and hammer the rate - * limiter, so the caller always states a ceiling. - */ - async collect(path: string, options: RequestOptions, max: number): Promise { - const items: T[] = [] - let cursor: string | null = null - - do { - const page: V2Page = await this.getPage(path, { - ...options, - query: { ...options.query, cursor }, - }) - items.push(...page.data) - cursor = page.nextCursor - } while (cursor && items.length < max) - - return items.slice(0, max) - } - - /** - * Calls a generated operation by name. - * - * Method and path come from `V2_OPERATIONS`, so a route that moves or changes - * verb in a contract moves here on the next `generate:cli-api` rather than - * failing at runtime against a URL the CLI still remembers. - */ - async call( - operation: K, - options: OperationOptions = {} - ): Promise { - const spec = V2_OPERATIONS[operation] - return this.request(resolvePath(spec.path, options.pathParams), { - method: spec.method as RequestOptions['method'], - query: options.query, - body: options.body, - }) - } -} - -export interface OperationOptions { - pathParams?: Record - query?: Record - body?: unknown } /** diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 85e67d3635c..77aa78911bc 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -1,10 +1,11 @@ #!/usr/bin/env node import chalk from 'chalk' -import { Command } from 'commander' +import { Command, Option } from 'commander' import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth.js' import { configureCommand } from './commands/configure.js' import { attachProtocolCommands } from './commands/protocol/index.js' +import { OUTPUT_FORMATS, ProfileConfigError } from './config/index.js' import { formatApiErrorDetails, SimApiError } from './http/client.js' import { sanitize } from './output/render.js' import { buildGeneratedCommands } from './runtime/build.js' @@ -18,6 +19,9 @@ program .option('-p, --profile ', 'Profile to use (env: SIM_PROFILE)') .option('--endpoint ', 'Sim deployment to talk to (env: SIM_ENDPOINT)') .option('-w, --workspace ', 'Workspace to target (env: SIM_WORKSPACE)') + .addOption( + new Option('--output ', 'Output format for this command').choices([...OUTPUT_FORMATS]) + ) program.addCommand(loginCommand()) program.addCommand(logoutCommand()) @@ -42,7 +46,8 @@ Examples: $ sim login --profile dev --endpoint http://localhost:3000 $ sim workflows list $ sim logs list --level error --limit 20 - $ sim configure --set-output json Output format is a profile setting + $ sim --output json tables get tbl_123 Override output for one command + $ sim configure --set-output json Save a profile output default $ sim knowledge search --query "refund policy" --kb kb_123 $ sim workflows export wf_123 > wf.json JSON flags read files with @ $ sim workflows import --workflow @wf.json @@ -59,6 +64,10 @@ async function main() { try { await program.parseAsync(process.argv) } catch (error) { + if (error instanceof ProfileConfigError) { + console.error(chalk.red(`Error: ${error.message}`)) + process.exit(1) + } if (error instanceof SimApiError) { console.error(chalk.red(`Error: ${error.message}`)) if (error.code) console.error(chalk.dim(` code: ${error.code}`)) diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index c8cb1452f5a..8cdd1c6885f 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -75,6 +75,7 @@ describe('commands parsed through commander', () => { credentials: 'credential', 'custom-tools': 'custom-tool', files: 'file', + knowledge: 'kb', logs: 'log', 'mcp-servers': 'mcp-server', skills: 'skill', @@ -92,12 +93,35 @@ describe('commands parsed through commander', () => { expect(program().commands.some((command) => command.name() === 'folders')).toBe(false) }) + it('describes generated resource and sub-resource groups', () => { + expect(commandAt('tables').description()).toBe('Manage tables') + expect(commandAt('tables', 'rows').description()).toBe('Manage table rows') + }) + it('dispatches generated commands through their singular resource alias', async () => { const [tablePath] = await run(['table', 'list']) expect(tablePath).toBe('/api/v2/tables') const [filePath] = await run(['file', 'list']) expect(filePath).toBe('/api/v2/files') + + const [knowledgePath] = await run(['kb', 'list']) + expect(knowledgePath).toBe('/api/v2/knowledge') + }) + + it('uses billing as the usage summary and keeps detailed events under logs', async () => { + expect(commandAt('billing').commands.map((command) => command.name())).toContain('logs') + expect(commandAt('billing').commands.map((command) => command.name())).not.toContain('usage') + + const [summaryPath, summaryOptions] = await run(['billing'], { + data: { plan: 'pro', totalCredits: 10 }, + }) + expect(summaryPath).toBe('/api/v2/billing/usage') + expect(summaryOptions.query).toEqual({ workspaceId: 'ws_local' }) + + const [logsPath, logsOptions] = await run(['billing', 'logs', '--period', '7d']) + expect(logsPath).toBe('/api/v2/billing/usage/logs') + expect(logsOptions.query).toMatchObject({ workspaceId: 'ws_local', period: '7d' }) }) it('carries every multi-word flag on a command, not just the first', async () => { @@ -175,20 +199,20 @@ describe('commands parsed through commander', () => { expect(options.body).toEqual({ workspaceId: 'ws_local', fileIds: ['file_1', 'file_2'], - targetFolderPath: '/Archive', + targetFolderPath: 'Archive', }) }) it('uses mv as the resource move alias', async () => { const [path, options] = await run(['table', 'mv', 'tbl_1', '--folder', 'Archive']) expect(path).toBe('/api/v2/tables/tbl_1') - expect(options.body).toEqual({ workspaceId: 'ws_local', folderPath: '/Archive' }) + expect(options.body).toEqual({ workspaceId: 'ws_local', folderPath: 'Archive' }) }) it('exposes path-addressed folder commands under each resource', async () => { const [createPath, createOptions] = await run(['table', 'folders', 'create', 'Reports']) expect(createPath).toBe('/api/v2/tables/folders') - expect(createOptions.body).toEqual({ workspaceId: 'ws_local', path: '/Reports' }) + expect(createOptions.body).toEqual({ workspaceId: 'ws_local', path: 'Reports' }) const [movePath, moveOptions] = await run([ 'table', @@ -200,13 +224,13 @@ describe('commands parsed through commander', () => { expect(movePath).toBe('/api/v2/tables/folders') expect(moveOptions.body).toEqual({ workspaceId: 'ws_local', - path: '/Reports', - destinationPath: '/Archive/Reports', + path: 'Reports', + destinationPath: 'Archive/Reports', }) const [listPath, listOptions] = await run(['table', 'folders', 'ls', '--parent', 'Reports']) expect(listPath).toBe('/api/v2/tables/folders') - expect(listOptions.query).toMatchObject({ workspaceId: 'ws_local', parentPath: '/Reports' }) + expect(listOptions.query).toMatchObject({ workspaceId: 'ws_local', parentPath: 'Reports' }) const [deletePath, deleteOptions] = await run([ 'table', @@ -214,15 +238,31 @@ describe('commands parsed through commander', () => { 'delete', 'Archive/Reports', '--recursive', - 'false', '--yes', ]) expect(deletePath).toBe('/api/v2/tables/folders') expect(deleteOptions.query).toEqual({ workspaceId: 'ws_local', - path: '/Archive/Reports', - recursive: 'false', + path: 'Archive/Reports', + recursive: true, + }) + + const [, nonRecursiveOptions] = await run([ + 'table', + 'folders', + 'delete', + 'Archive/Empty', + '--yes', + ]) + expect(nonRecursiveOptions.query).toEqual({ + workspaceId: 'ws_local', + path: 'Archive/Empty', }) + + const help = commandAt('tables', 'folders', 'delete').helpInformation() + expect(help).toContain('--recursive') + expect(help).not.toContain('--recursive ') + expect(help).not.toContain('--no-recursive') }) it('exposes credential data centers added by the v2 credential contract', async () => { @@ -257,6 +297,29 @@ describe('commands parsed through commander', () => { expect(without.query).not.toHaveProperty('deployedOnly') }) + it('runs a workflow without input and keeps output selection distinct from rendering', async () => { + const help = commandAt('workflows', 'run').helpInformation() + expect(help).toContain('--select-output ') + expect(help).toContain('blockName.field') + expect(help).toContain('agent_1.content') + expect(help).not.toContain('--output ') + + const [, withoutInput] = await run(['workflows', 'run', 'wf_1'], { data: { success: true } }) + expect(withoutInput.body).toEqual({}) + + const [, selected] = await run( + ['workflows', 'run', 'wf_1', '--select-output', 'agent.answer', 'save.result'], + { data: { success: true } } + ) + expect(selected.body).toEqual({ selectedOutputs: ['agent.answer', 'save.result'] }) + }) + + it('documents the table predicate and sort wire shapes in help', () => { + const help = commandAt('tables', 'rows', 'query').helpInformation() + expect(help).toContain('{"all":[{"field":"status","op":"eq","value":"active"}]}') + expect(help).toContain('[{"field":"createdAt","direction":"desc"}]') + }) + it('refuses a destructive command without --yes, before any request', async () => { await expect(run(['tables', 'rows', 'batch-delete', 'tbl_1', '--row', 'a'])).rejects.toThrow( /cannot be undone/ @@ -650,8 +713,8 @@ describe('bodies and fields the generator cannot flatten', () => { 'rows', 'create', 'tbl_1', - '--body', - '{"rows":[{"city":"Paris"}]}', + '--rows', + '[{"city":"Paris"}]', ]) expect(path).toBe('/api/v2/tables/tbl_1/rows') @@ -659,24 +722,40 @@ describe('bodies and fields the generator cannot flatten', () => { expect(options.body).toEqual({ workspaceId: 'ws_local', rows: [{ city: 'Paris' }] }) }) - it('lets the caller override a shared field', async () => { + it('offers a direct single-row flag', async () => { const [, options] = await run([ 'tables', 'rows', 'create', 'tbl_1', - '--body', - '{"workspaceId":"ws_other","rows":[]}', + '--data', + '{"city":"Paris"}', ]) - expect(options.body).toMatchObject({ workspaceId: 'ws_other' }) + expect(options.body).toEqual({ workspaceId: 'ws_local', data: { city: 'Paris' } }) + }) + + it('requires exactly one row-body form', async () => { + await expect(run(['tables', 'rows', 'create', 'tbl_1'])).rejects.toThrow( + /exactly one of --data or --rows/ + ) + await expect( + run(['tables', 'rows', 'create', 'tbl_1', '--data', '{}', '--rows', '[]']) + ).rejects.toThrow(/exactly one of --data or --rows/) }) - it('refuses a union body that is not an object', async () => { - await expect(run(['tables', 'rows', 'create', 'tbl_1', '--body', '[1,2]'])).rejects.toThrow( - /--body must be a JSON object/ + it('rejects the wrong JSON shape for a row-body flag', async () => { + await expect(run(['tables', 'rows', 'create', 'tbl_1', '--data', '[1,2]'])).rejects.toThrow( + /--data must be a JSON object/ ) }) + it('explains the single and batch row forms in help', () => { + const help = commandAt('tables', 'rows', 'create').helpInformation() + expect(help).toMatch(/--data.*One row keyed by column name/s) + expect(help).toMatch(/--rows.*Several rows keyed by column name/s) + expect(help).not.toContain('--body') + }) + it('leaves a non-numeric `limit` alone', async () => { // `runTableColumn` takes `limit: { type, max }`. The pager claimed the name // regardless of type, turning it into `--limit ` that defaulted to 100, diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 6bb6210cd43..c26b98931c6 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -13,6 +13,7 @@ const GROUP_ALIASES: Readonly> = { credentials: 'credential', 'custom-tools': 'custom-tool', files: 'file', + knowledge: 'kb', logs: 'log', 'mcp-servers': 'mcp-server', skills: 'skill', @@ -20,9 +21,13 @@ const GROUP_ALIASES: Readonly> = { workflows: 'workflow', } -function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: string): Command { +function configureOperation( + command: Command, + operation: V2OperationName, + spec: CommandSpec +): Command { const operationSpec = V2_OPERATIONS[operation] as OperationSpec - const command = new Command(leafName).allowExcessArguments(false) + command.allowExcessArguments(false) for (const alias of spec.aliases ?? []) command.alias(alias) @@ -47,22 +52,33 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri return command } +function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: string): Command { + return configureOperation(new Command(leafName), operation, spec) +} + function groupFor(groups: Map, name: string): Command { const existing = groups.get(name) if (existing) return existing - const group = new Command(name) + const group = new Command(name).description(`Manage ${name.replaceAll('-', ' ')}`) const alias = GROUP_ALIASES[name] if (alias) group.alias(alias) groups.set(name, group) return group } +function resourceLabel(name: string): string { + const label = name.endsWith('s') ? name.slice(0, -1) : name + return label.replaceAll('-', ' ') +} + function nestedGroup(parent: Command, name: string): Command { const existing = parent.commands.find((candidate) => candidate.name() === name) if (existing) return existing - const created = new Command(name) + const created = new Command(name).description( + `Manage ${resourceLabel(parent.name())} ${name.replaceAll('-', ' ')}` + ) parent.addCommand(created) return created } @@ -81,6 +97,15 @@ export function buildGeneratedCommands(): Command[] { const leafName = rest.join(' ') || 'run' const group = groupFor(groups, groupName) + if (spec.groupDefault) { + if (rest.length > 0) throw new Error(`${operation} groupDefault must name a command group`) + if (operationSpec.pathParams.length > 0 || spec.positionals?.length) { + throw new Error(`${operation} groupDefault cannot require positional arguments`) + } + configureOperation(group, operation, spec) + continue + } + if (rest.length > 1) { const [subName, ...tail] = rest nestedGroup(group, subName).addCommand(buildLeaf(operation, spec, tail.join(' '))) diff --git a/packages/sim-cli/src/runtime/folder-path.ts b/packages/sim-cli/src/runtime/folder-path.ts deleted file mode 100644 index a89433c2ac1..00000000000 --- a/packages/sim-cli/src/runtime/folder-path.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { SimApiError } from '../http/client.js' - -/** Accepts root-relative folder input while preserving already-canonical paths. */ -export function normalizeFolderPath(path: string): string { - if (!path) throw new SimApiError('Folder path cannot be empty', 0) - return path.startsWith('/') ? path : `/${path}` -} diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index f085b0ee468..badffb740f3 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -35,7 +35,7 @@ function addFieldOption( return } - if (descriptor.kind === 'boolean') { + if (descriptor.kind === 'boolean' || flag.boolean) { if (descriptor.required) { command.addOption( new Option( @@ -49,7 +49,7 @@ function addFieldOption( } command.option(`${short}--${name}`, flag.describe ?? `Set ${field}`) - command.option(`--no-${name}`, `Set ${field} to false`) + if (!flag.boolean) command.option(`--no-${name}`, `Set ${field} to false`) return } @@ -91,10 +91,19 @@ export function addOperationOptions( } if (operationSpec.opaqueBody) { - command.requiredOption( - '--body ', - 'Request body as JSON (or @path / @- to read a file or stdin) (required)' - ) + if (commandSpec.bodyVariants) { + for (const variant of commandSpec.bodyVariants) { + command.option( + `--${variant.name} `, + `${variant.describe} (JSON, or @path / @-; choose exactly one body flag)` + ) + } + } else { + command.requiredOption( + '--body ', + 'Request body as JSON (or @path / @- to read a file or stdin) (required)' + ) + } } if (commandSpec.confirm) { diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 88dfa4e3d25..34e3feaa427 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -53,6 +53,10 @@ describe('buildRequest', () => { expect(built.body ?? {}).not.toHaveProperty('stream') }) + it('sends an empty object when a declared body has no provided fields', () => { + expect(buildRequest('executeWorkflow', ['wf_1'], {}, WORKSPACE).body).toEqual({}) + }) + it('percent-encodes path params so an id cannot retarget the request', () => { expect(buildRequest('getTable', ['a/b?c'], {}, WORKSPACE).path).toBe('/api/v2/tables/a%2Fb%3Fc') }) diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 145cba64750..57d1bad5b3b 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -1,10 +1,9 @@ import { existsSync, readFileSync, readSync } from 'node:fs' import { CLI_CONTRACT } from '../contract/commands.js' -import type { FlagSpec } from '../contract/types.js' +import type { CommandSpec, FlagSpec } from '../contract/types.js' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' import { type QueryValue, SimApiError } from '../http/client.js' import { camel, kebab } from './derive.js' -import { normalizeFolderPath } from './folder-path.js' /** One request field, as the generator describes it. */ export interface FieldSpec { @@ -182,8 +181,7 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: */ if (flag.list) { const values = readListValues(raw, flagName) - const normalized = flag.normalize === 'folder-path' ? values.map(normalizeFolderPath) : values - return field.kind === 'string' ? normalized.join(',') : normalized + return field.kind === 'string' ? values.join(',') : values } if (takesJson(field, flag)) { @@ -205,14 +203,14 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: return value } - if (field.kind === 'boolean') return raw === true || raw === 'true' + if (field.kind === 'boolean' || flag.boolean) return raw === true || raw === 'true' const choices = flag.choices ?? field.values if (choices && !choices.includes(String(raw))) { throw new SimApiError(`--${flagName} must be one of: ${choices.join(', ')}`, 0) } - return flag.normalize === 'folder-path' ? normalizeFolderPath(String(raw)) : raw + return raw } export interface BuiltRequest { @@ -246,6 +244,7 @@ export function buildRequest( flags: Record, workspaceId: string | null ): BuiltRequest { + const commandSpec: CommandSpec = CLI_CONTRACT[operation] ?? {} const spec = V2_OPERATIONS[operation] as { method: string path: string @@ -301,6 +300,29 @@ export function buildRequest( // which both branches require, so every insert came back as invalid input. // The caller's JSON still wins on any key it sets. if (spec.opaqueBody) { + if (commandSpec.bodyVariants) { + const provided = commandSpec.bodyVariants.filter( + (variant) => flags[camel(variant.name)] !== undefined + ) + const names = commandSpec.bodyVariants.map((variant) => `--${variant.name}`).join(' or ') + if (provided.length !== 1) { + throw new SimApiError(`Pass exactly one of ${names}`, 0) + } + + const variant = provided[0] + const raw = flags[camel(variant.name)] + if (typeof raw !== 'string') throw new SimApiError(`--${variant.name} is required`, 0) + const parsed = coerce(raw, { kind: variant.kind }, { json: true }, variant.name) + if ( + (variant.kind === 'object' && + (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))) || + (variant.kind === 'array' && !Array.isArray(parsed)) + ) { + throw new SimApiError(`--${variant.name} must be a JSON ${variant.kind}`, 0) + } + return { path, query, body: { ...body, [variant.property]: parsed } } + } + const raw = flags.body if (typeof raw !== 'string') throw new SimApiError('--body is required', 0) const parsed = coerce(raw, { kind: 'object' }, { json: true }, 'body') @@ -313,6 +335,10 @@ export function buildRequest( return { path, query, - body: Object.keys(body).length > 0 ? body : undefined, + /** + * A declared JSON body is still an object when all of its fields are optional. + * Sending no bytes makes the server reject before field defaults can apply. + */ + body: spec.body ? body : undefined, } } diff --git a/packages/sim-cli/src/transfer/upload-session.ts b/packages/sim-cli/src/transfer/upload-session.ts index 959643095f6..c97d02bd2bd 100644 --- a/packages/sim-cli/src/transfer/upload-session.ts +++ b/packages/sim-cli/src/transfer/upload-session.ts @@ -46,7 +46,7 @@ async function uploadParts( session: UploadSession, transfer: Extract, blob: Blob -): Promise> { +): Promise { const expectedPartCount = Math.ceil(session.size / transfer.partSize) if (expectedPartCount !== transfer.partCount) { throw new Error( @@ -54,7 +54,6 @@ async function uploadParts( ) } - const completed: Array<{ partNumber: number; etag?: string }> = [] for (let first = 1; first <= transfer.partCount; first += PART_URL_BATCH) { const partNumbers = [] for (let n = first; n < first + PART_URL_BATCH && n <= transfer.partCount; n++) { @@ -87,12 +86,8 @@ async function uploadParts( response.status ) } - - const etag = response.headers.get('etag')?.replace(/"/g, '') - completed.push(etag ? { partNumber: part.partNumber, etag } : { partNumber: part.partNumber }) } } - return completed } /** Uploads and completes a signed transfer, aborting its session if the transfer fails. */ @@ -104,19 +99,16 @@ export async function finishUploadSession( ): Promise { try { const blob = await openAsBlob(path) - let body: Record if (session.transfer.method === 'put') { await uploadPut(session.transfer, blob) - body = {} } else { - body = { parts: await uploadParts(client, workspaceId, session, session.transfer, blob) } + await uploadParts(client, workspaceId, session, session.transfer, blob) } const completed = await client.request<{ data: T }>(`${session.basePath}/complete`, { method: 'POST', query: { workspaceId }, headers: { 'upload-token': session.uploadToken }, - body, }) return completed.data } catch (error) { From 28698ee731b30ff681d38dced397d58307f3f8ce Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 5 Aug 2026 11:34:40 -0700 Subject: [PATCH 077/159] feat(cli): standardize resource command syntax --- packages/sim-cli/README.md | 19 ++-- .../sim-cli/src/commands/protocol/index.ts | 3 +- .../knowledge-document-upload.test.ts | 28 ++++-- .../protocol/knowledge-document-upload.ts | 91 +++++++++--------- packages/sim-cli/src/contract/commands.ts | 52 +++++++++-- packages/sim-cli/src/contract/types.ts | 36 +++++++- packages/sim-cli/src/runtime/build.test.ts | 79 +++++++++++++++- packages/sim-cli/src/runtime/build.ts | 92 ++++++++++++++++--- packages/sim-cli/src/runtime/execute.ts | 8 +- packages/sim-cli/src/runtime/options.ts | 20 +++- packages/sim-cli/src/runtime/request.test.ts | 14 +++ packages/sim-cli/src/runtime/request.ts | 32 +++++-- 12 files changed, 368 insertions(+), 106 deletions(-) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 9c92d7fcaec..6d21f3de141 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -112,13 +112,15 @@ also accepts its singular form: for example, `sim table list`, `sim file download`, and `sim workflow get` are equivalent to their plural spellings. -`knowledge` also accepts the shorter `kb` alias. +`knowledge` also accepts the shorter `kb` alias, and `documents` accepts +`document`. ```bash sim workflows ls [path] [--search ] [--limit ] sim workflows list [--folder ] [--deployed-only] [--limit ] sim workflows get -sim workflows mv --folder +sim workflows update [--name ] [--description ] [--folder ] +sim workflows mv sim workflows deploy|undeploy|rollback sim workflows run [--input ] [--select-output …] @@ -129,7 +131,8 @@ sim logs execution sim tables ls [path] [--search ] [--limit ] sim tables list [--folder ] sim tables get -sim tables mv --folder +sim tables update [--name ] [--description ] [--folder ] +sim tables mv sim tables columns sim tables rows list [--limit ] sim tables rows create --data @@ -152,11 +155,15 @@ sim files delete --yes sim knowledge ls [path] [--search ] [--limit ] sim knowledge list [--folder ] sim knowledge get -sim knowledge mv --folder -sim knowledge documents [--search ] -sim knowledge documents upload [--tag ...] +sim knowledge update [--name ] [--description ] [--folder ] +sim knowledge mv sim knowledge search --query --kb … [--search-mode vector|hybrid] +sim documents list --kb [--search ] +sim documents get --kb +sim documents upload --kb [--tag ...] +sim documents delete --kb --yes + sim billing sim billing logs [--period 7d] [--limit ] ``` diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index 67df42a865b..33939b7cdde 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -25,8 +25,9 @@ export function attachProtocolCommands(program: Command): void { createFolder: 'createFileFolder', }) + attachKnowledgeDocumentUpload(group(program, 'documents')) + const knowledge = group(program, 'knowledge') - attachKnowledgeDocumentUpload(group(knowledge, 'documents')) attachResourceDirectoryCommands(knowledge, { kind: 'knowledge', resources: 'listKnowledgeBases', diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts index a7f6dff40a8..18f6be45562 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts @@ -62,14 +62,16 @@ function uploadSession() { } } -describe('knowledge documents upload', () => { +describe('documents upload', () => { it('owns the multipart protocol while hiding its low-level operations', () => { - const knowledge = program().commands.find((command) => command.name() === 'knowledge') + const root = program() + const knowledge = root.commands.find((command) => command.name() === 'knowledge') expect(knowledge?.commands.map((command) => command.name())).not.toEqual( - expect.arrayContaining(['uploads', 'parts', 'complete']) + expect.arrayContaining(['documents', 'uploads', 'parts', 'complete']) ) - const documents = knowledge?.commands.find((command) => command.name() === 'documents') + const documents = root.commands.find((command) => command.name() === 'documents') + expect(documents?.alias()).toBe('document') expect(documents?.commands.map((command) => command.name())).toContain('upload') }) @@ -131,11 +133,11 @@ describe('knowledge documents upload', () => { await program().parseAsync([ 'node', 'sim', - 'knowledge', 'documents', 'upload', - 'kb_1', path, + '--kb', + 'kb_1', '--tag', 'customer', 'priority', @@ -189,11 +191,11 @@ describe('knowledge documents upload', () => { program().parseAsync([ 'node', 'sim', - 'knowledge', 'documents', 'upload', - 'kb_1', path, + '--kb', + 'kb_1', '--tag', '1', '2', @@ -207,4 +209,14 @@ describe('knowledge documents upload', () => { ).rejects.toThrow(/at most seven/) expect(mockRequest).not.toHaveBeenCalled() }) + + it('requires an explicit knowledge-base scope before reading the file', async () => { + const path = join(dir, 'notes.txt') + writeFileSync(path, 'hello') + + await expect( + program().parseAsync(['node', 'sim', 'documents', 'upload', path]) + ).rejects.toThrow(/required option '--kb '/) + expect(mockRequest).not.toHaveBeenCalled() + }) }) diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts index 1a459930628..8abf8c60b43 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts @@ -10,6 +10,7 @@ import { finishUploadSession } from '../../transfer/upload-session.js' import { printProtocolResult } from './result.js' interface KnowledgeDocumentUploadOptions { + kb: string name?: string tag?: string[] recipe?: string @@ -37,62 +38,54 @@ function uploadMetadata(options: KnowledgeDocumentUploadOptions): Record ') + .command('upload ') .description('Upload a document to a knowledge base') + .requiredOption('--kb ', 'Knowledge base ID (required)') .option('--name ', 'Store it under a different name') .option('--tag ', 'Document tags, in tag1 through tag7 order') .option('--recipe ', 'Document processing recipe') .option('--lang ', 'Document language code') - .action( - async ( - knowledgeBaseId: string, - path: string, - options: KnowledgeDocumentUploadOptions, - command: Command - ) => { - const { client, profile } = clientFrom(command) - const workspaceId = client.requireWorkspace() - const { name, size } = await localFile(path, options.name) - const created = await client.request( - `/api/v2/knowledge/${encodeURIComponent(knowledgeBaseId)}/documents/uploads`, - { - method: 'POST', - body: { - workspaceId, - name, - contentType: contentTypeFor(name), - size, - ...uploadMetadata(options), - }, - } - ) - const { session, uploadToken, transfer } = created.data - const completed = await finishUploadSession< - CompleteKnowledgeDocumentUploadResponse['data'] - >( - client, - workspaceId, - { - basePath: `/api/v2/knowledge/${encodeURIComponent( - knowledgeBaseId - )}/documents/uploads/${encodeURIComponent(session.id)}`, - uploadToken, - transfer, + .action(async (path: string, options: KnowledgeDocumentUploadOptions, command: Command) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const { name, size } = await localFile(path, options.name) + const created = await client.request( + `/api/v2/knowledge/${encodeURIComponent(options.kb)}/documents/uploads`, + { + method: 'POST', + body: { + workspaceId, + name, + contentType: contentTypeFor(name), size, + ...uploadMetadata(options), }, - path - ) - - if (!completed.document) { - throw new Error(`Knowledge upload ${session.id} completed without a document`) } - printProtocolResult(profile.output, { - id: completed.document.id, - knowledgeBaseId: completed.document.knowledgeBaseId, - name: completed.document.filename, - size: completed.document.fileSize, - status: completed.document.processingStatus, - }) + ) + const { session, uploadToken, transfer } = created.data + const completed = await finishUploadSession( + client, + workspaceId, + { + basePath: `/api/v2/knowledge/${encodeURIComponent( + options.kb + )}/documents/uploads/${encodeURIComponent(session.id)}`, + uploadToken, + transfer, + size, + }, + path + ) + + if (!completed.document) { + throw new Error(`Knowledge upload ${session.id} completed without a document`) } - ) + printProtocolResult(profile.output, { + id: completed.document.id, + knowledgeBaseId: completed.document.knowledgeBaseId, + name: completed.document.filename, + size: completed.document.fileSize, + status: completed.document.processingStatus, + }) + }) } diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 68d1100fe72..72fcb312c67 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -1,4 +1,4 @@ -import type { CliContract, ColumnSpec } from './types.js' +import type { CliContract, ColumnSpec, CommandVariantSpec } from './types.js' const TABLE_NAME_HELP = 'Identifier: letters, numbers, and underscores; cannot start with a number' const TABLE_FILTER_HELP = @@ -18,6 +18,13 @@ const FOLDER_DELETE_FLAGS = { path: FOLDER_PATH_INPUT, recursive: { boolean: true, describe: 'Delete the folder and its descendants' }, } as const +const KNOWLEDGE_DOCUMENT_SCOPE = { + id: { + name: 'kb', + placeholder: 'knowledgeBaseId', + describe: 'Knowledge base ID', + }, +} as const const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ { header: 'path' }, { header: 'name' }, @@ -25,6 +32,15 @@ const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ] +function moveResource(command: string, resource: string): CommandVariantSpec { + return { + command, + positionals: ['folderPath'], + requestFields: ['folderPath'], + describe: `Move a ${resource} to a folder`, + } +} + /** * The CLI contract for the v2 surface. * @@ -34,7 +50,7 @@ const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ * * Derived by default: * listTables → sim tables list - * getKnowledgeDocument → sim knowledge documents get + * getKnowledgeDocument → sim documents get --kb * upsertTableRow → sim tables upsert */ export const CLI_CONTRACT: CliContract = { @@ -54,6 +70,12 @@ export const CLI_CONTRACT: CliContract = { listUsageLogs: { command: 'billing logs', describe: 'List credit usage events', + flags: { + source: { describe: 'Filter by usage source' }, + period: { describe: 'Billing period' }, + startDate: { describe: 'Custom period start (ISO 8601)' }, + endDate: { describe: 'Custom period end (ISO 8601)' }, + }, columns: [ { header: 'at', path: 'createdAt', format: 'timestamp' }, { header: 'source' }, @@ -99,7 +121,11 @@ export const CLI_CONTRACT: CliContract = { fields: [{ header: 'remaining columns', path: 'columns', format: 'count' }], }, deleteKnowledgeBase: { confirm: 'This deletes the knowledge base and every document in it.' }, - deleteKnowledgeDocument: { confirm: 'This deletes the document and its embeddings.' }, + deleteKnowledgeDocument: { + command: 'documents delete', + pathFlags: KNOWLEDGE_DOCUMENT_SCOPE, + confirm: 'This deletes the document and its embeddings.', + }, deleteFile: { confirm: 'This archives the file.' }, deleteSkill: { confirm: 'This deletes the skill.' }, deleteCustomTool: { confirm: 'This deletes the custom tool.' }, @@ -223,7 +249,7 @@ export const CLI_CONTRACT: CliContract = { }, }, updateTable: { - aliases: ['mv'], + variants: [moveResource('tables mv', 'table')], flags: { name: { describe: TABLE_NAME_HELP }, folderPath: FOLDER_PATH_FLAG, @@ -231,9 +257,15 @@ export const CLI_CONTRACT: CliContract = { }, createFile: { flags: { folderPath: FOLDER_PATH_FLAG } }, createKnowledgeBase: { flags: { folderPath: FOLDER_PATH_FLAG } }, - updateKnowledgeBase: { aliases: ['mv'], flags: { folderPath: FOLDER_PATH_FLAG } }, + updateKnowledgeBase: { + variants: [moveResource('knowledge mv', 'knowledge base')], + flags: { folderPath: FOLDER_PATH_FLAG }, + }, createWorkflow: { flags: { folderPath: FOLDER_PATH_FLAG } }, - updateWorkflow: { aliases: ['mv'], flags: { folderPath: FOLDER_PATH_FLAG } }, + updateWorkflow: { + variants: [moveResource('workflows mv', 'workflow')], + flags: { folderPath: FOLDER_PATH_FLAG }, + }, importWorkflow: { flags: { folderPath: FOLDER_PATH_FLAG } }, createCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, updateCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, @@ -285,7 +317,13 @@ export const CLI_CONTRACT: CliContract = { { header: 'model', path: 'embeddingModel' }, ], }, + getKnowledgeDocument: { + command: 'documents get', + pathFlags: KNOWLEDGE_DOCUMENT_SCOPE, + }, listKnowledgeDocuments: { + command: 'documents list', + pathFlags: KNOWLEDGE_DOCUMENT_SCOPE, columns: [ { header: 'id' }, { header: 'filename' }, @@ -601,7 +639,7 @@ export const CLI_CONTRACT: CliContract = { }, // ─── Not a terminal-shaped operation ────────────────────────────────────── - // Multipart upload; `sim knowledge documents upload ` needs its + // Multipart upload; `sim documents upload --kb ` needs its // own file-reading command rather than a generated flag surface. uploadKnowledgeDocument: { hidden: true }, createKnowledgeDocumentUpload: { hidden: true }, diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index c3ad7ce4fee..c21b62326b2 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -6,8 +6,7 @@ import type { V2OperationName } from '../generated/v2-api.js' * Most of a command is derivable and is NOT stated here. Method, path, path * params, field types, enum values, defaults, and required-ness all come from * the generated operation table, which comes from the Zod route contracts. The - * command name itself derives from ` ` for 41 of - * the 44 operations. + * command name itself usually derives from ` `. * * This file carries only what a schema cannot say: * @@ -17,6 +16,8 @@ import type { V2OperationName } from '../generated/v2-api.js' * - `flags` — when a field's *type* misdescribes its *meaning*. `workflowIds` * is `z.string()` that the route splits on commas; nothing in the schema says * "list". Also friendlier aliases (`conflictTarget` → `--on`). + * - `pathFlags` — when a parent path segment is command context rather than the + * resource being acted on (`documents get --kb `). * - `columns` — which of a response's fields belong in a table. Editorial. * - `confirm` — which operations are destructive enough to demand `--yes`. * @@ -62,6 +63,18 @@ export interface FlagSpec { omit?: boolean } +/** How a route path parameter is exposed as a required named option. */ +export interface PathFlagSpec { + /** Flag name, kebab-case, without `--`. Defaults to the kebab-cased path parameter. */ + name?: string + /** Help placeholder without angle brackets. Defaults to `value`. */ + placeholder?: string + /** Short alias, e.g. `k` for `--kb`. */ + short?: string + /** One-line help for the scope selected by this path parameter. */ + describe?: string +} + /** A column in table-mode output. */ export interface ColumnSpec { /** Header, and the default path into the row when `value` is omitted. */ @@ -83,6 +96,17 @@ export interface BodyVariantSpec { describe: string } +export interface CommandVariantSpec { + /** Full alternate command path, such as `workflows mv`. */ + command: string + /** Request fields exposed as required positional arguments. */ + positionals?: readonly string[] + /** Request fields available on this narrower command surface. */ + requestFields?: readonly string[] + /** One-line help for the alternate command. */ + describe?: string +} + export interface CommandSpec { /** * Command path, space-separated. Omit to accept the derived @@ -93,8 +117,14 @@ export interface CommandSpec { groupDefault?: boolean /** Alternate leaf command names, such as `ls` for `list`. */ aliases?: readonly string[] - /** Required query/body fields exposed as positional arguments, in order. */ + /** Route path parameters exposed as required named options instead of positionals. */ + pathFlags?: Record + /** Request fields exposed as required positional arguments, in order. */ positionals?: readonly string[] + /** Restrict this command to these request fields; profile fields remain implicit. */ + requestFields?: readonly string[] + /** Additional command shapes backed by the same API operation. */ + variants?: readonly CommandVariantSpec[] /** One-line help. Falls back to the OpenAPI summary for the operation. */ describe?: string /** Per-field flag overrides, keyed by the contract's field name. */ diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 8cdd1c6885f..625190003f7 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -74,6 +74,7 @@ describe('commands parsed through commander', () => { 'audit-logs': 'audit-log', credentials: 'credential', 'custom-tools': 'custom-tool', + documents: 'document', files: 'file', knowledge: 'kb', logs: 'log', @@ -109,10 +110,64 @@ describe('commands parsed through commander', () => { expect(knowledgePath).toBe('/api/v2/knowledge') }) + it('uses top-level document commands with a named knowledge-base scope', async () => { + expect(commandAt('knowledge').commands.map((command) => command.name())).not.toContain( + 'documents' + ) + + const help = commandAt('documents', 'get').helpInformation() + expect(help).toContain('') + expect(help).toMatch(/--kb .*required/s) + expect(help).not.toContain(' ') + + const [listPath, listOptions] = await run(['documents', 'list', '--kb', 'kb_1']) + expect(listPath).toBe('/api/v2/knowledge/kb_1/documents') + expect(listOptions.query).toMatchObject({ workspaceId: 'ws_local' }) + + const [getPathBefore, getOptionsBefore] = await run([ + 'documents', + 'get', + '--kb', + 'kb_1', + 'doc_1', + ]) + expect(getPathBefore).toBe('/api/v2/knowledge/kb_1/documents/doc_1') + expect(getOptionsBefore.query).toEqual({ workspaceId: 'ws_local' }) + + const [getPathAfter] = await run(['document', 'get', 'doc_1', '--kb', 'kb_1']) + expect(getPathAfter).toBe('/api/v2/knowledge/kb_1/documents/doc_1') + + await expect(run(['documents', 'delete', 'doc_1', '--kb', 'kb_1'])).rejects.toThrow( + /document and its embeddings/ + ) + expect(mockRequest).not.toHaveBeenCalled() + + const [deletePath, deleteOptions] = await run([ + 'documents', + 'delete', + 'doc_1', + '--kb', + 'kb_1', + '--yes', + ]) + expect(deletePath).toBe('/api/v2/knowledge/kb_1/documents/doc_1') + expect(deleteOptions.query).toEqual({ workspaceId: 'ws_local' }) + + await expect(run(['documents', 'get', 'doc_1'])).rejects.toThrow( + /required option '--kb '/ + ) + expect(mockRequest).not.toHaveBeenCalled() + }) + it('uses billing as the usage summary and keeps detailed events under logs', async () => { expect(commandAt('billing').commands.map((command) => command.name())).toContain('logs') expect(commandAt('billing').commands.map((command) => command.name())).not.toContain('usage') + const help = commandAt('billing', 'logs').helpInformation() + expect(help).toContain('--source ') + expect(help).toContain('Filter by usage source (choices:') + expect(help).not.toContain('One of: workflow') + const [summaryPath, summaryOptions] = await run(['billing'], { data: { plan: 'pro', totalCredits: 10 }, }) @@ -203,10 +258,26 @@ describe('commands parsed through commander', () => { }) }) - it('uses mv as the resource move alias', async () => { - const [path, options] = await run(['table', 'mv', 'tbl_1', '--folder', 'Archive']) - expect(path).toBe('/api/v2/tables/tbl_1') - expect(options.body).toEqual({ workspaceId: 'ws_local', folderPath: 'Archive' }) + it('uses Linux-style resource move commands without changing update syntax', async () => { + const [tablePath, tableOptions] = await run(['table', 'mv', 'tbl_1', 'Archive']) + expect(tablePath).toBe('/api/v2/tables/tbl_1') + expect(tableOptions.body).toEqual({ workspaceId: 'ws_local', folderPath: 'Archive' }) + + const [workflowPath, workflowOptions] = await run(['workflow', 'mv', 'wf_1', 'Archive']) + expect(workflowPath).toBe('/api/v2/workflows/wf_1') + expect(workflowOptions.body).toEqual({ folderPath: 'Archive' }) + + const [knowledgePath, knowledgeOptions] = await run(['kb', 'mv', 'kb_1', 'Archive']) + expect(knowledgePath).toBe('/api/v2/knowledge/kb_1') + expect(knowledgeOptions.body).toEqual({ workspaceId: 'ws_local', folderPath: 'Archive' }) + + const [, updateOptions] = await run(['workflow', 'update', 'wf_1', '--description', 'Updated']) + expect(updateOptions.body).toEqual({ description: 'Updated' }) + + const moveHelp = commandAt('workflows', 'mv').helpInformation() + expect(moveHelp).toContain(' ') + expect(moveHelp).not.toContain('--folder') + expect(commandAt('workflows', 'update').helpInformation()).not.toContain('update|mv') }) it('exposes path-addressed folder commands under each resource', async () => { diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index c26b98931c6..1b68242651b 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -1,17 +1,18 @@ import { Command } from 'commander' import { CLI_CONTRACT } from '../contract/commands.js' -import type { CommandSpec } from '../contract/types.js' +import type { CommandSpec, CommandVariantSpec } from '../contract/types.js' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' import { deriveCommandPath } from './derive.js' import { executeOperation } from './execute.js' import { addOperationOptions } from './options.js' -import { flagNameFor } from './request.js' +import { flagNameFor, PROFILE_INJECTED_FIELD } from './request.js' import type { OperationSpec } from './types.js' const GROUP_ALIASES: Readonly> = { 'audit-logs': 'audit-log', credentials: 'credential', 'custom-tools': 'custom-tool', + documents: 'document', files: 'file', knowledge: 'kb', logs: 'log', @@ -31,17 +32,45 @@ function configureOperation( for (const alias of spec.aliases ?? []) command.alias(alias) + for (const param of Object.keys(spec.pathFlags ?? {})) { + if (!operationSpec.pathParams.includes(param)) { + throw new Error(`${operation}.${param} is not a path parameter`) + } + } + for (const param of operationSpec.pathParams) { + if (spec.pathFlags?.[param]) continue command.argument(`<${param}>`) } for (const field of spec.positionals ?? []) { const descriptor = operationSpec.query?.[field] ?? operationSpec.body?.[field] if (!descriptor) throw new Error(`${operation}.${field} is not a request field`) - if (!descriptor.required) throw new Error(`${operation}.${field} is not required`) + if (spec.requestFields && !spec.requestFields.includes(field)) { + throw new Error(`${operation}.${field} is positional but not exposed`) + } command.argument(`<${flagNameFor(operation, field)}>`) } + if (spec.requestFields) { + for (const field of spec.requestFields) { + if (!operationSpec.query?.[field] && !operationSpec.body?.[field]) { + throw new Error(`${operation}.${field} is not a request field`) + } + } + for (const slot of ['query', 'body'] as const) { + for (const [field, descriptor] of Object.entries(operationSpec[slot] ?? {})) { + if ( + descriptor.required && + field !== PROFILE_INJECTED_FIELD && + !spec.requestFields.includes(field) + ) { + throw new Error(`${operation}.${field} is required but not exposed`) + } + } + } + } + command.description( spec.describe ?? operationSpec.summary ?? `${operationSpec.method} ${operationSpec.path}` ) @@ -83,6 +112,38 @@ function nestedGroup(parent: Command, name: string): Command { return created } +function addLeafCommand( + groups: Map, + operation: V2OperationName, + spec: CommandSpec, + segments: string[] +): void { + const [groupName, ...rest] = segments + if (rest.length === 0) throw new Error(`${operation} leaf command must include a verb`) + const group = groupFor(groups, groupName) + + if (rest.length > 1) { + const [subName, ...tail] = rest + nestedGroup(group, subName).addCommand(buildLeaf(operation, spec, tail.join(' '))) + return + } + + group.addCommand(buildLeaf(operation, spec, rest[0])) +} + +function variantCommandSpec(spec: CommandSpec, variant: CommandVariantSpec): CommandSpec { + return { + ...spec, + command: variant.command, + groupDefault: false, + aliases: [], + positionals: variant.positionals, + requestFields: variant.requestFields, + variants: [], + describe: variant.describe ?? spec.describe, + } +} + /** Builds every JSON command described by the generated operation table. */ export function buildGeneratedCommands(): Command[] { const groups = new Map() @@ -93,26 +154,27 @@ export function buildGeneratedCommands(): Command[] { if (spec.hidden || operationSpec.responseMode !== 'json') continue const segments = spec.command ? spec.command.split(' ') : deriveCommandPath(operation) - const [groupName, ...rest] = segments - const leafName = rest.join(' ') || 'run' - const group = groupFor(groups, groupName) - if (spec.groupDefault) { + const [groupName, ...rest] = segments + const group = groupFor(groups, groupName) if (rest.length > 0) throw new Error(`${operation} groupDefault must name a command group`) - if (operationSpec.pathParams.length > 0 || spec.positionals?.length) { + const pathPositionals = operationSpec.pathParams.filter((param) => !spec.pathFlags?.[param]) + if (pathPositionals.length > 0 || spec.positionals?.length) { throw new Error(`${operation} groupDefault cannot require positional arguments`) } configureOperation(group, operation, spec) - continue + } else { + addLeafCommand(groups, operation, spec, segments) } - if (rest.length > 1) { - const [subName, ...tail] = rest - nestedGroup(group, subName).addCommand(buildLeaf(operation, spec, tail.join(' '))) - continue + for (const variant of spec.variants ?? []) { + addLeafCommand( + groups, + operation, + variantCommandSpec(spec, variant), + variant.command.split(' ') + ) } - - group.addCommand(buildLeaf(operation, spec, leafName)) } return [...groups.values()].sort((a, b) => a.name().localeCompare(b.name())) diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 1d03ed4f64b..29fd9e7abba 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -24,11 +24,13 @@ export async function executeOperation( ): Promise { const host = invocation[invocation.length - 1] as Command const flags = invocation[invocation.length - 2] as Record - const positional = invocation.slice(0, operationSpec.pathParams.length) as string[] + const pathPositionalCount = operationSpec.pathParams.filter( + (param) => !commandSpec.pathFlags?.[param] + ).length + const positional = invocation.slice(0, pathPositionalCount) as string[] const requestFlags = { ...flags } for (const [index, field] of (commandSpec.positionals ?? []).entries()) { - requestFlags[camel(flagNameFor(operation, field))] = - invocation[operationSpec.pathParams.length + index] + requestFlags[camel(flagNameFor(operation, field))] = invocation[pathPositionalCount + index] } if (commandSpec.confirm && !requestFlags.yes) { diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index badffb740f3..9b13d33292e 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -6,6 +6,7 @@ import { flagNameFor, flagSpecFor, PROFILE_INJECTED_FIELD, + pathFlagNameFor, takesJson, } from './request.js' import type { OperationSpec } from './types.js' @@ -57,9 +58,7 @@ function addFieldOption( const wantsJson = takesJson(descriptor, flag) const placeholder = takesList ? '' : wantsJson ? '' : '' const choices = flag.choices ?? descriptor.values - const describe = `${ - flag.describe ?? (choices ? `One of: ${choices.join(', ')}` : `Set ${field}`) - }${ + const describe = `${flag.describe ?? `Set ${name.replaceAll('-', ' ')}`}${ takesList ? ' (space-separated, or @path / @- with one value per line)' : wantsJson @@ -83,8 +82,23 @@ export function addOperationOptions( commandSpec: CommandSpec, operationSpec: OperationSpec ): void { + for (const param of operationSpec.pathParams) { + const flag = commandSpec.pathFlags?.[param] + if (!flag) continue + + const name = pathFlagNameFor(commandSpec, param) + const short = flag.short ? `-${flag.short}, ` : '' + command.addOption( + new Option( + `${short}--${name} <${flag.placeholder ?? 'value'}>`, + `${flag.describe ?? `Set ${name.replaceAll('-', ' ')}`} (required)` + ).makeOptionMandatory() + ) + } + for (const slot of ['query', 'body'] as const) { for (const [field, descriptor] of Object.entries(operationSpec[slot] ?? {})) { + if (commandSpec.requestFields && !commandSpec.requestFields.includes(field)) continue if (commandSpec.positionals?.includes(field)) continue addFieldOption(command, operation, field, descriptor) } diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 34e3feaa427..0190a74c289 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -61,6 +61,14 @@ describe('buildRequest', () => { expect(buildRequest('getTable', ['a/b?c'], {}, WORKSPACE).path).toBe('/api/v2/tables/a%2Fb%3Fc') }) + it('combines a named parent scope with a positional resource id in route order', () => { + expect(buildRequest('getKnowledgeDocument', ['doc_1'], { kb: 'kb_1' }, WORKSPACE)).toEqual({ + path: '/api/v2/knowledge/kb_1/documents/doc_1', + query: { workspaceId: WORKSPACE }, + body: undefined, + }) + }) + describe('failures, all before any network call', () => { it('rejects a missing path arg', () => { expect(() => buildRequest('getTable', [], {}, WORKSPACE)).toThrow('Missing ') @@ -72,6 +80,12 @@ describe('buildRequest', () => { ) }) + it('rejects a missing named path scope', () => { + expect(() => buildRequest('getKnowledgeDocument', ['doc_1'], {}, WORKSPACE)).toThrow( + '--kb is required' + ) + }) + it('rejects malformed JSON, naming the flag the caller typed', () => { expect(() => buildRequest('upsertTableRow', ['t'], { data: '{oops' }, WORKSPACE)).toThrow( '--data must be valid JSON' diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 57d1bad5b3b..cf8d6e56e5d 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -34,6 +34,11 @@ export function flagNameFor(operation: V2OperationName, field: string): string { return flagSpecFor(operation, field).name ?? kebab(field) } +/** The named option used for a path parameter that is contextual rather than primary. */ +export function pathFlagNameFor(commandSpec: CommandSpec, param: string): string { + return commandSpec.pathFlags?.[param]?.name ?? kebab(param) +} + export function takesJson(field: FieldSpec, flag: FlagSpec): boolean { return flag.json === true || JSON_KINDS.has(field.kind) } @@ -234,9 +239,11 @@ function asQueryValue(value: unknown): QueryValue { * Assembles one operation's HTTP request from positional args, parsed flags, * and the profile's workspace. * - * Path params come from positional arguments in declared order; every other - * field is looked up by its flag name in the slot the contract declares it in, - * so a field that moved from query to body moves here on the next regeneration. + * Primary path params come from positional arguments in declared order. A + * contextual path param can instead come from a named option declared by the + * CLI contract. Every other field is looked up by its flag name in the slot the + * API contract declares it in, so a field that moved from query to body moves + * here on the next regeneration. */ export function buildRequest( operation: V2OperationName, @@ -255,12 +262,23 @@ export function buildRequest( } let path = spec.path - spec.pathParams.forEach((param, index) => { - const value = positional[index] - if (value === undefined) throw new SimApiError(`Missing <${param}>`, 0) + let positionalIndex = 0 + for (const param of spec.pathParams) { + const pathFlag = commandSpec.pathFlags?.[param] + const flagName = pathFlagNameFor(commandSpec, param) + const value = pathFlag ? flags[camel(flagName)] : positional[positionalIndex++] + if (value === undefined) { + throw new SimApiError(pathFlag ? `--${flagName} is required` : `Missing <${param}>`, 0) + } + if (typeof value !== 'string' || value.length === 0) { + throw new SimApiError( + pathFlag ? `--${flagName} cannot be empty` : `<${param}> cannot be empty`, + 0 + ) + } // Ids are opaque; an unencoded `/` or `?` would silently retarget the request. path = path.replace(`[${param}]`, encodeURIComponent(value)) - }) + } const query: Record = {} const body: Record = {} From e1a8a2457262b26ca8fb16b403fbdb66478bcf64 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 5 Aug 2026 12:51:27 -0700 Subject: [PATCH 078/159] fix(billing): unify chat usage source --- apps/docs/openapi-core.json | 34 +++++- .../users/me/usage-logs/export/route.test.ts | 24 +++- .../api/users/me/usage-logs/export/route.ts | 14 +-- .../app/api/users/me/usage-logs/route.test.ts | 46 ++++++++ apps/sim/app/api/users/me/usage-logs/route.ts | 22 ++-- .../api/users/me/usage-logs/source-labels.ts | 21 ---- .../api/v2/billing/usage/logs/route.test.ts | 23 +++- .../app/api/v2/billing/usage/logs/route.ts | 11 +- .../app/api/v2/billing/usage/route.test.ts | 7 +- apps/sim/app/api/v2/billing/usage/route.ts | 10 +- .../credit-usage/credit-usage-view.tsx | 4 +- apps/sim/lib/api/contracts/subscription.ts | 5 +- apps/sim/lib/api/contracts/user.ts | 15 +-- apps/sim/lib/api/contracts/v2/billing.ts | 2 +- apps/sim/lib/billing/core/usage-log.ts | 33 +++--- apps/sim/lib/billing/usage-sources.test.ts | 50 ++++++++ apps/sim/lib/billing/usage-sources.ts | 109 ++++++++++++++++++ 17 files changed, 333 insertions(+), 97 deletions(-) delete mode 100644 apps/sim/app/api/users/me/usage-logs/source-labels.ts create mode 100644 apps/sim/lib/billing/usage-sources.test.ts create mode 100644 apps/sim/lib/billing/usage-sources.ts diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index d5d3cccd20e..6ed28693188 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -1018,7 +1018,7 @@ "get": { "operationId": "getUsageSummary", "summary": "Get Usage Summary", - "description": "Current-billing-period usage with the per-source credit breakdown (`workflow`, `copilot`, `knowledge-base`, …) — monitor one source's consumption directly instead of estimating it by subtraction. All values are credits (1,000 credits = $5); dollar costs are not part of this surface.", + "description": "Current-billing-period usage with the per-source credit breakdown (`workflow`, `sim-chat`, `knowledge-base`, …) — monitor one source's consumption directly instead of estimating it by subtraction. Sim Chat combines the internal Copilot and workspace-chat ledgers. All values are credits (1,000 credits = $5); dollar costs are not part of this surface.", "tags": ["Usage"], "security": [ { @@ -1098,7 +1098,7 @@ "totalCredits": 512, "bySourceCredits": { "workflow": 380, - "copilot": 120, + "sim-chat": 120, "knowledge-base": 12 }, "limitCredits": 20000, @@ -1140,9 +1140,20 @@ "in": "query", "required": false, "schema": { - "type": "string" + "type": "string", + "enum": [ + "workflow", + "wand", + "sim-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output" + ] }, - "description": "Restrict to one usage source (e.g. `workflow`, `copilot`)." + "description": "Restrict to one usage source (e.g. `workflow`, `sim-chat`). `sim-chat` includes both the internal Copilot and workspace-chat ledgers." }, { "name": "workspaceId", @@ -1225,7 +1236,18 @@ "format": "date-time" }, "source": { - "type": "string" + "type": "string", + "enum": [ + "workflow", + "wand", + "sim-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output" + ] }, "workflowName": { "type": ["string", "null"], @@ -1249,7 +1271,7 @@ { "id": "log_1", "createdAt": "2026-07-29T18:04:11.000Z", - "source": "copilot", + "source": "sim-chat", "workflowName": null, "creditCost": 12 } diff --git a/apps/sim/app/api/users/me/usage-logs/export/route.test.ts b/apps/sim/app/api/users/me/usage-logs/export/route.test.ts index ec82eec301f..87854ca15c7 100644 --- a/apps/sim/app/api/users/me/usage-logs/export/route.test.ts +++ b/apps/sim/app/api/users/me/usage-logs/export/route.test.ts @@ -57,7 +57,7 @@ describe('GET /api/users/me/usage-logs/export', () => { expect(response.headers.get('Content-Disposition')).toContain('attachment; filename=') expect(response.headers.get('X-Export-Truncated')).toBe('0') expect(header).toBe('Date,Type,Credits') - expect(row).toBe('2026-07-01T00:00:00.000Z,Chat,100') + expect(row).toBe('2026-07-01T00:00:00.000Z,Sim Chat,100') }) it('sets X-Export-Truncated when the safety cap is hit with more data remaining', async () => { @@ -92,6 +92,28 @@ describe('GET /api/users/me/usage-logs/export', () => { ) }) + it('filters sim-chat across both internal ledgers', async () => { + mockGetUserUsageLogs.mockResolvedValueOnce({ + logs: [], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: false }, + }) + + const response = await GET( + createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?source=sim-chat') + ) + + expect(response.status).toBe(200) + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ source: ['copilot', 'workspace-chat'] }) + ) + expect(mockGetUsageCreditsByLogId).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ source: ['copilot', 'workspace-chat'] }) + ) + }) + it('names the specific workflow for workflow-sourced rows', async () => { mockGetUserUsageLogs.mockResolvedValueOnce({ logs: [ diff --git a/apps/sim/app/api/users/me/usage-logs/export/route.ts b/apps/sim/app/api/users/me/usage-logs/export/route.ts index d32e6d59837..cc58ebee2b2 100644 --- a/apps/sim/app/api/users/me/usage-logs/export/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/export/route.ts @@ -3,15 +3,15 @@ import { type NextRequest, NextResponse } from 'next/server' import { exportUsageLogsContract } from '@/lib/api/contracts/user' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { getUsageCreditsByLogId, getUserUsageLogs } from '@/lib/billing/core/usage-log' import { - getUsageCreditsByLogId, - getUserUsageLogs, - type UsageLogSource, -} from '@/lib/billing/core/usage-log' + BILLING_USAGE_LOG_SOURCE_LABELS, + toBillingUsageLogSource, + toInternalUsageLogSources, +} from '@/lib/billing/usage-sources' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { formatCsvValue, toCsvRow } from '@/lib/table/export-format' import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' -import { USAGE_LOG_SOURCE_LABELS } from '@/app/api/users/me/usage-logs/source-labels' const logger = createLogger('UsageLogsExportAPI') @@ -44,7 +44,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const dateRange = resolveDateRange(period, startDate, endDate) const filter = { - source: source as UsageLogSource | undefined, + source: source ? toInternalUsageLogSources(source) : undefined, workspaceId, startDate: dateRange.startDate, endDate: dateRange.endDate, @@ -84,7 +84,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const type = log.source === 'workflow' && log.workflowName ? `Workflow: ${log.workflowName}` - : USAGE_LOG_SOURCE_LABELS[log.source] + : BILLING_USAGE_LOG_SOURCE_LABELS[toBillingUsageLogSource(log.source)] return toCsvRow([ formatCsvValue(log.createdAt), formatCsvValue(type), diff --git a/apps/sim/app/api/users/me/usage-logs/route.test.ts b/apps/sim/app/api/users/me/usage-logs/route.test.ts index 32295c7f887..083db554557 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.test.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.test.ts @@ -93,6 +93,52 @@ describe('GET /api/users/me/usage-logs', () => { expect(body.logs[0].workflowName).toBe('ITSM_Prod_main') }) + it('presents copilot and workspace-chat usage as one sim-chat source', async () => { + mockGetUserUsageLogs.mockResolvedValue({ + logs: [ + { + id: 'log-copilot', + createdAt: '2026-07-01T00:00:00.000Z', + category: 'model', + source: 'copilot', + description: 'claude-opus', + cost: 0.4, + }, + { + id: 'log-workspace-chat', + createdAt: '2026-07-01T00:00:00.000Z', + category: 'model', + source: 'workspace-chat', + description: 'claude-opus', + cost: 0.2, + }, + ], + summary: { totalCost: 0.6, bySource: { copilot: 0.4, 'workspace-chat': 0.2 } }, + pagination: { hasMore: false }, + }) + mockGetUsageCreditsByLogId.mockResolvedValue({ + 'log-copilot': 80, + 'log-workspace-chat': 40, + }) + + const body = await (await GET(createMockRequest('GET'))).json() + + expect(body.logs.map((log: { source: string }) => log.source)).toEqual(['sim-chat', 'sim-chat']) + expect(body.summary.bySourceCredits).toEqual({ 'sim-chat': 120 }) + }) + + it('filters sim-chat across both internal ledgers', async () => { + const response = await GET( + createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?source=sim-chat') + ) + + expect(response.status).toBe(200) + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ source: ['copilot', 'workspace-chat'] }) + ) + }) + it('rejects "custom" period without a startDate', async () => { const response = await GET( createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=custom') diff --git a/apps/sim/app/api/users/me/usage-logs/route.ts b/apps/sim/app/api/users/me/usage-logs/route.ts index 9abd381c48c..483ae2bddde 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -3,12 +3,13 @@ import { type NextRequest, NextResponse } from 'next/server' import { getUsageLogsContract } from '@/lib/api/contracts/user' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { - getUsageCreditsByLogId, - getUserUsageLogs, - type UsageLogSource, -} from '@/lib/billing/core/usage-log' +import { getUsageCreditsByLogId, getUserUsageLogs } from '@/lib/billing/core/usage-log' import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { + aggregateBillingUsageBySource, + toBillingUsageLogSource, + toInternalUsageLogSources, +} from '@/lib/billing/usage-sources' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' @@ -33,7 +34,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const dateRange = resolveDateRange(period, startDate, endDate) const filter = { - source: source as UsageLogSource | undefined, + source: source ? toInternalUsageLogSources(source) : undefined, workspaceId, startDate: dateRange.startDate, endDate: dateRange.endDate, @@ -49,17 +50,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const logs = result.logs.map((log) => ({ id: log.id, createdAt: log.createdAt, - source: log.source, + source: toBillingUsageLogSource(log.source), workflowName: log.workflowName ?? null, creditCost: creditsByLogId[log.id] ?? 0, hasCost: log.cost > 0, })) const bySourceCredits = Object.fromEntries( - Object.entries(result.summary.bySource).map(([sourceKey, cost]) => [ - sourceKey, - dollarsToCredits(cost), - ]) + Object.entries(aggregateBillingUsageBySource(result.summary.bySource)).map( + ([sourceKey, cost]) => [sourceKey, dollarsToCredits(cost)] + ) ) logger.debug('Retrieved usage logs', { diff --git a/apps/sim/app/api/users/me/usage-logs/source-labels.ts b/apps/sim/app/api/users/me/usage-logs/source-labels.ts deleted file mode 100644 index 1cf5362b62f..00000000000 --- a/apps/sim/app/api/users/me/usage-logs/source-labels.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { UsageLogSource } from '@/lib/api/contracts/user' - -/** - * Humanized labels for `usage_log.source`, shared by the Credit usage page's - * row rendering and the CSV export so both read identically. Avoids the - * internal "copilot" / "mothership" naming — the agent is always "Sim", the - * surface is "Chat". Pure data, no server-only imports, so it's safe from - * both the client page and the export route. - */ -export const USAGE_LOG_SOURCE_LABELS: Record = { - workflow: 'Workflow', - wand: 'Wand', - copilot: 'Chat', - 'workspace-chat': 'Chat', - mcp_copilot: 'Chat (MCP)', - mothership_block: 'Agent block', - 'knowledge-base': 'Knowledge Base', - 'voice-input': 'Voice input', - enrichment: 'Enrichment', - 'voice-output': 'Voice output', -} diff --git a/apps/sim/app/api/v2/billing/usage/logs/route.test.ts b/apps/sim/app/api/v2/billing/usage/logs/route.test.ts index ed7d0390381..b95206d3ccc 100644 --- a/apps/sim/app/api/v2/billing/usage/logs/route.test.ts +++ b/apps/sim/app/api/v2/billing/usage/logs/route.test.ts @@ -71,7 +71,7 @@ describe('GET /api/v2/billing/usage/logs', () => { { id: 'log-1', createdAt: '2026-07-01T00:00:00.000Z', - source: 'copilot', + source: 'sim-chat', workflowName: null, creditCost: 12, }, @@ -90,6 +90,27 @@ describe('GET /api/v2/billing/usage/logs', () => { expect(body.nextCursor).toBe('log-42') }) + it('filters sim-chat across both internal ledgers', async () => { + const res = await callLogs('?source=sim-chat') + + expect(res.status).toBe(200) + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ source: ['copilot', 'workspace-chat'] }) + ) + expect(mockGetUsageCreditsByLogId).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ source: ['copilot', 'workspace-chat'] }) + ) + }) + + it('rejects internal chat source names', async () => { + const res = await callLogs('?source=copilot') + + expect(res.status).toBe(400) + expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + }) + it('pins a workspace API key to its own workspace', async () => { mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT_OK, diff --git a/apps/sim/app/api/v2/billing/usage/logs/route.ts b/apps/sim/app/api/v2/billing/usage/logs/route.ts index 93621a9606b..fe54242b97c 100644 --- a/apps/sim/app/api/v2/billing/usage/logs/route.ts +++ b/apps/sim/app/api/v2/billing/usage/logs/route.ts @@ -3,11 +3,8 @@ import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { v2ListUsageLogsContract } from '@/lib/api/contracts/v2/billing' import { parseRequest } from '@/lib/api/server' -import { - getUsageCreditsByLogId, - getUserUsageLogs, - type UsageLogSource, -} from '@/lib/billing/core/usage-log' +import { getUsageCreditsByLogId, getUserUsageLogs } from '@/lib/billing/core/usage-log' +import { toBillingUsageLogSource, toInternalUsageLogSources } from '@/lib/billing/usage-sources' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' @@ -59,7 +56,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const dateRange = resolveDateRange(period, startDate, endDate) const filter = { - source: source as UsageLogSource | undefined, + source: source ? toInternalUsageLogSources(source) : undefined, workspaceId: workspaceFilter.workspaceId, startDate: dateRange.startDate, endDate: dateRange.endDate, @@ -73,7 +70,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const items = result.logs.map((log) => ({ id: log.id, createdAt: log.createdAt, - source: log.source, + source: toBillingUsageLogSource(log.source), workflowName: log.workflowName ?? null, creditCost: creditsByLogId[log.id] ?? 0, })) diff --git a/apps/sim/app/api/v2/billing/usage/route.test.ts b/apps/sim/app/api/v2/billing/usage/route.test.ts index 5e2a63f5adf..db51ac8098b 100644 --- a/apps/sim/app/api/v2/billing/usage/route.test.ts +++ b/apps/sim/app/api/v2/billing/usage/route.test.ts @@ -73,7 +73,10 @@ describe('GET /api/v2/billing/usage', () => { }) mockGetUserUsageLogs.mockResolvedValue({ logs: [], - summary: { totalCost: 2.5, bySource: { workflow: 1.9, copilot: 0.6 } }, + summary: { + totalCost: 2.5, + bySource: { workflow: 1.9, copilot: 0.4, 'workspace-chat': 0.2 }, + }, pagination: { hasMore: false }, }) }) @@ -85,7 +88,7 @@ describe('GET /api/v2/billing/usage', () => { expect(body.data).toEqual({ period: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, totalCredits: 500, - bySourceCredits: { workflow: 380, copilot: 120 }, + bySourceCredits: { workflow: 380, 'sim-chat': 120 }, limitCredits: 20000, plan: 'pro', }) diff --git a/apps/sim/app/api/v2/billing/usage/route.ts b/apps/sim/app/api/v2/billing/usage/route.ts index 60d826fc5c8..d7e8fa6715a 100644 --- a/apps/sim/app/api/v2/billing/usage/route.ts +++ b/apps/sim/app/api/v2/billing/usage/route.ts @@ -7,6 +7,7 @@ import { checkServerSideUsageLimits } from '@/lib/billing' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { deriveBillingContext, getUserUsageLogs } from '@/lib/billing/core/usage-log' import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { aggregateBillingUsageBySource } from '@/lib/billing/usage-sources' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkRateLimit } from '@/app/api/v1/middleware' @@ -22,7 +23,7 @@ export const revalidate = 0 /** * GET /api/v2/billing/usage — Current-billing-period usage summary with the * per-source credit breakdown, for external monitoring (e.g. alerting on - * Copilot consumption before an overage). Credits only — dollar costs and + * Sim Chat consumption before an overage). Credits only — dollar costs and * rate-limit internals are not part of this surface. */ export const GET = withRouteHandler(async (request: NextRequest) => { @@ -65,10 +66,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ]) const bySourceCredits = Object.fromEntries( - Object.entries(ledger.summary.bySource).map(([source, cost]) => [ - source, - dollarsToCredits(cost), - ]) + Object.entries(aggregateBillingUsageBySource(ledger.summary.bySource)).map( + ([source, cost]) => [source, dollarsToCredits(cost)] + ) ) const data: V2UsageSummaryData = { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx index c4e815a9597..617707ea924 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx @@ -18,8 +18,8 @@ import { useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import type { UsageLogEntry, UsageLogPeriod } from '@/lib/api/contracts/user' import { formatApportionedCreditCost, formatCreditsLabel } from '@/lib/billing/credits/conversion' +import { BILLING_USAGE_LOG_SOURCE_LABELS } from '@/lib/billing/usage-sources' import { formatDateShort } from '@/lib/core/utils/date-display' -import { USAGE_LOG_SOURCE_LABELS } from '@/app/api/users/me/usage-logs/source-labels' import { creditUsageParsers, creditUsageUrlKeys, @@ -39,7 +39,7 @@ const PERIOD_OPTIONS: ComboboxOption[] = [ /** Workflow-sourced rows name the specific workflow; everything else uses the plain source label. */ function rowLabel(log: UsageLogEntry): string { if (log.source === 'workflow' && log.workflowName) return `Workflow: ${log.workflowName}` - return USAGE_LOG_SOURCE_LABELS[log.source] + return BILLING_USAGE_LOG_SOURCE_LABELS[log.source] } interface UsageLogRowProps { diff --git a/apps/sim/lib/api/contracts/subscription.ts b/apps/sim/lib/api/contracts/subscription.ts index 5c7aef3c46d..8d9831c46b8 100644 --- a/apps/sim/lib/api/contracts/subscription.ts +++ b/apps/sim/lib/api/contracts/subscription.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' +import { INTERNAL_CHAT_BILLING_SOURCES } from '@/lib/billing/usage-sources' import { BILLING_ACCOUNT_DECISION_HEADER, BILLING_ACCOUNT_DECISION_HEADER_MAX_BYTES, @@ -26,9 +27,7 @@ export const billingUpdateCostBodySchema = z.object({ model: z.string().min(1, 'Model is required'), inputTokens: z.number().min(0).default(0), outputTokens: z.number().min(0).default(0), - source: z - .enum(['copilot', 'workspace-chat', 'mcp_copilot', 'mothership_block']) - .default('copilot'), + source: z.enum(INTERNAL_CHAT_BILLING_SOURCES).default('copilot'), idempotencyKey: z.string().min(1, 'Idempotency key is required'), /** * Originating workspace, used for org-workspace cost attribution on hosted diff --git a/apps/sim/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts index 655a69ecd74..5616e738eb1 100644 --- a/apps/sim/lib/api/contracts/user.ts +++ b/apps/sim/lib/api/contracts/user.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { booleanQueryFlagSchema } from '@/lib/api/contracts/primitives' import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types' +import { BILLING_USAGE_LOG_SOURCES } from '@/lib/billing/usage-sources' import { isSameOrigin } from '@/lib/core/utils/validation' export const userProfileSchema = z.object({ @@ -268,18 +269,8 @@ export type UnsubscribeActionResponse = ContractJsonResponse export type UnsubscribeType = NonNullable -export const usageLogSourceSchema = z.enum([ - 'workflow', - 'wand', - 'copilot', - 'workspace-chat', - 'mcp_copilot', - 'mothership_block', - 'knowledge-base', - 'voice-input', - 'enrichment', - 'voice-output', -]) +/** Billing-facing sources collapse both internal chat ledgers into `sim-chat`. */ +export const usageLogSourceSchema = z.enum(BILLING_USAGE_LOG_SOURCES) export const usageLogPeriodSchema = z.enum(['1d', '7d', '30d', 'all', 'custom']) diff --git a/apps/sim/lib/api/contracts/v2/billing.ts b/apps/sim/lib/api/contracts/v2/billing.ts index 6867e587db6..7fd1196b916 100644 --- a/apps/sim/lib/api/contracts/v2/billing.ts +++ b/apps/sim/lib/api/contracts/v2/billing.ts @@ -29,7 +29,7 @@ export const v2UsageSummaryQuerySchema = z.object({ /** * Current-billing-period usage summary. `bySourceCredits` is the source-aware - * breakdown (workflow, copilot, knowledge-base, …) of the account's ledger for + * breakdown (workflow, sim-chat, knowledge-base, …) of the account's ledger for * the period, so a monitor can watch one source's consumption directly instead * of estimating it by subtraction. */ diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index b7628688149..1eb8fd93f54 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -9,6 +9,7 @@ import { defaultBillingPeriod } from '@/lib/billing/core/billing-period' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { apportionCredits } from '@/lib/billing/credits/conversion' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' +import type { InternalUsageLogSource } from '@/lib/billing/usage-sources' import type { DbClient, DbOrTx } from '@/lib/db/types' const logger = createLogger('UsageLog') @@ -21,22 +22,12 @@ export type UsageLogCategory = 'model' | 'fixed' | 'tool' /** * Usage log source types */ -export type UsageLogSource = - | 'workflow' - | 'wand' - | 'copilot' - | 'workspace-chat' - | 'mcp_copilot' - | 'mothership_block' - | 'knowledge-base' - | 'voice-input' - | 'enrichment' - | 'voice-output' +export type UsageLogSource = InternalUsageLogSource /** - * usage_log sources that make up the "copilot" cost breakdown shown in billing - * summaries: the copilot agent, mothership/workspace chat, MCP copilot, and - * mothership blocks. Mirrors the source set billed via /api/billing/update-cost. + * Internal usage_log sources that make up the Sim Chat-family cost breakdown + * used by legacy billing summaries. Mirrors the source set billed via + * /api/billing/update-cost. */ export const COPILOT_USAGE_SOURCES: UsageLogSource[] = [ 'copilot', @@ -616,7 +607,7 @@ export async function recordCumulativeUsage( } interface UsageLogFilter { - source?: UsageLogSource + source?: UsageLogSource | UsageLogSource[] workspaceId?: string startDate?: Date endDate?: Date @@ -624,7 +615,13 @@ interface UsageLogFilter { function buildUsageLogConditions(userId: string, filter: UsageLogFilter) { const conditions = [eq(usageLog.userId, userId)] - if (filter.source) conditions.push(eq(usageLog.source, filter.source)) + if (filter.source) { + conditions.push( + Array.isArray(filter.source) + ? inArray(usageLog.source, filter.source) + : eq(usageLog.source, filter.source) + ) + } if (filter.workspaceId) conditions.push(eq(usageLog.workspaceId, filter.workspaceId)) if (filter.startDate) conditions.push(gte(usageLog.createdAt, filter.startDate)) if (filter.endDate) conditions.push(lte(usageLog.createdAt, filter.endDate)) @@ -659,7 +656,7 @@ export async function getUsageCreditsByLogId( */ export interface GetUsageLogsOptions { /** Filter by source */ - source?: UsageLogSource + source?: UsageLogSource | UsageLogSource[] /** Filter by workspace */ workspaceId?: string /** Start date (inclusive) */ @@ -712,7 +709,7 @@ export interface UsageLogsResult { /** `{ totalCost: 0, bySource: {} }` when `includeSummary` is `false`. */ summary: { totalCost: number - bySource: Record + bySource: Partial> } pagination: { nextCursor?: string diff --git a/apps/sim/lib/billing/usage-sources.test.ts b/apps/sim/lib/billing/usage-sources.test.ts new file mode 100644 index 00000000000..ab8e46c3d64 --- /dev/null +++ b/apps/sim/lib/billing/usage-sources.test.ts @@ -0,0 +1,50 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + aggregateBillingUsageBySource, + BILLING_USAGE_LOG_SOURCE_LABELS, + toBillingUsageLogSource, + toInternalUsageLogSources, +} from '@/lib/billing/usage-sources' + +describe('billing usage sources', () => { + it.each(['copilot', 'workspace-chat'] as const)( + 'presents the internal %s ledger as sim-chat', + (source) => { + expect(toBillingUsageLogSource(source)).toBe('sim-chat') + } + ) + + it('expands the sim-chat filter to both internal ledgers', () => { + expect(toInternalUsageLogSources('sim-chat')).toEqual(['copilot', 'workspace-chat']) + }) + + it('combines both internal ledgers into one sim-chat total', () => { + const result = aggregateBillingUsageBySource({ + workflow: 1.9, + copilot: 0.4, + 'workspace-chat': 0.2, + }) + + expect(result.workflow).toBe(1.9) + expect(result['sim-chat']).toBeCloseTo(0.6) + }) + + it('uses the Sim Chat product name for billing display', () => { + expect(BILLING_USAGE_LOG_SOURCE_LABELS['sim-chat']).toBe('Sim Chat') + }) + + it('fails fast when a new internal ledger source has no public mapping', () => { + expect(() => + Reflect.apply(aggregateBillingUsageBySource, undefined, [{ unexpected: 1 }]) + ).toThrow('Unknown internal usage log source: unexpected') + expect(() => Reflect.apply(toBillingUsageLogSource, undefined, ['unexpected'])).toThrow( + 'Unknown internal usage log source: unexpected' + ) + expect(() => Reflect.apply(toInternalUsageLogSources, undefined, ['unexpected'])).toThrow( + 'Unknown billing usage log source: unexpected' + ) + }) +}) diff --git a/apps/sim/lib/billing/usage-sources.ts b/apps/sim/lib/billing/usage-sources.ts new file mode 100644 index 00000000000..f74bf99730a --- /dev/null +++ b/apps/sim/lib/billing/usage-sources.ts @@ -0,0 +1,109 @@ +export const INTERNAL_USAGE_LOG_SOURCES = [ + 'workflow', + 'wand', + 'copilot', + 'workspace-chat', + 'mcp_copilot', + 'mothership_block', + 'knowledge-base', + 'voice-input', + 'enrichment', + 'voice-output', +] as const + +export type InternalUsageLogSource = (typeof INTERNAL_USAGE_LOG_SOURCES)[number] + +export const INTERNAL_CHAT_BILLING_SOURCES = [ + 'copilot', + 'workspace-chat', + 'mcp_copilot', + 'mothership_block', +] as const satisfies readonly InternalUsageLogSource[] + +export const BILLING_USAGE_LOG_SOURCES = [ + 'workflow', + 'wand', + 'sim-chat', + 'mcp_copilot', + 'mothership_block', + 'knowledge-base', + 'voice-input', + 'enrichment', + 'voice-output', +] as const + +export type BillingUsageLogSource = (typeof BILLING_USAGE_LOG_SOURCES)[number] + +const INTERNAL_TO_BILLING_SOURCE = { + workflow: 'workflow', + wand: 'wand', + copilot: 'sim-chat', + 'workspace-chat': 'sim-chat', + mcp_copilot: 'mcp_copilot', + mothership_block: 'mothership_block', + 'knowledge-base': 'knowledge-base', + 'voice-input': 'voice-input', + enrichment: 'enrichment', + 'voice-output': 'voice-output', +} as const satisfies Record + +const INTERNAL_USAGE_LOG_SOURCE_SET = new Set(INTERNAL_USAGE_LOG_SOURCES) + +const BILLING_TO_INTERNAL_SOURCES = { + workflow: ['workflow'], + wand: ['wand'], + 'sim-chat': ['copilot', 'workspace-chat'], + mcp_copilot: ['mcp_copilot'], + mothership_block: ['mothership_block'], + 'knowledge-base': ['knowledge-base'], + 'voice-input': ['voice-input'], + enrichment: ['enrichment'], + 'voice-output': ['voice-output'], +} as const satisfies Record + +export const BILLING_USAGE_LOG_SOURCE_LABELS = { + workflow: 'Workflow', + wand: 'Wand', + 'sim-chat': 'Sim Chat', + mcp_copilot: 'Sim Chat (MCP)', + mothership_block: 'Agent block', + 'knowledge-base': 'Knowledge Base', + 'voice-input': 'Voice input', + enrichment: 'Enrichment', + 'voice-output': 'Voice output', +} as const satisfies Record + +export function toBillingUsageLogSource(source: InternalUsageLogSource): BillingUsageLogSource { + if (!Object.hasOwn(INTERNAL_TO_BILLING_SOURCE, source)) { + throw new Error(`Unknown internal usage log source: ${source}`) + } + return INTERNAL_TO_BILLING_SOURCE[source] +} + +export function toInternalUsageLogSources(source: BillingUsageLogSource): InternalUsageLogSource[] { + if (!Object.hasOwn(BILLING_TO_INTERNAL_SOURCES, source)) { + throw new Error(`Unknown billing usage log source: ${source}`) + } + return [...BILLING_TO_INTERNAL_SOURCES[source]] +} + +export function aggregateBillingUsageBySource( + usageBySource: Partial> +): Partial> { + const unknownSource = Object.keys(usageBySource).find( + (source) => !INTERNAL_USAGE_LOG_SOURCE_SET.has(source) + ) + if (unknownSource) throw new Error(`Unknown internal usage log source: ${unknownSource}`) + + const billingUsage: Partial> = {} + + for (const internalSource of INTERNAL_USAGE_LOG_SOURCES) { + const value = usageBySource[internalSource] + if (value === undefined) continue + + const billingSource = toBillingUsageLogSource(internalSource) + billingUsage[billingSource] = (billingUsage[billingSource] ?? 0) + value + } + + return billingUsage +} From a2627ccd43f3c10a3e3da60065dffe1aa6180eb8 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 5 Aug 2026 13:38:51 -0700 Subject: [PATCH 079/159] improvement(logs): expose trace spans on log detail --- apps/docs/openapi-v2-logs.json | 16 ++- apps/sim/app/api/v2/logs/[id]/route.test.ts | 122 ++++++++++++++++++++ apps/sim/app/api/v2/logs/[id]/route.ts | 3 + apps/sim/lib/api/contracts/logs.ts | 10 +- apps/sim/lib/api/contracts/v2/logs.ts | 5 +- 5 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 apps/sim/app/api/v2/logs/[id]/route.test.ts diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 0fd9b86657c..2a4f40bc765 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -291,7 +291,7 @@ "get": { "operationId": "getLog", "summary": "Get Log", - "description": "Retrieve a single log entry by its ID, including the workflow metadata captured at execution time, the materialized execution data, and the cost summary. Returns `{ data }`.", + "description": "Retrieve a single log entry by its ID, including workflow metadata, materialized execution data, a top-level `traceSpans` array, and the cost summary. Returns `{ data }`.", "tags": ["Logs"], "x-codeSamples": [ { @@ -315,7 +315,7 @@ ], "responses": { "200": { - "description": "The requested log entry with full execution data and cost summary.", + "description": "The requested log entry with full execution data, trace spans, and cost summary.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -366,6 +366,7 @@ "result": "Hello, world!" } }, + "traceSpans": [], "cost": { "total": 0.0032 }, @@ -733,7 +734,7 @@ }, "LogDetail": { "type": "object", - "description": "Detailed log entry with full workflow metadata, materialized execution data, and cost summary.", + "description": "Detailed log entry with full workflow metadata, materialized execution data, top-level trace spans, and cost summary.", "required": [ "id", "workflowId", @@ -746,6 +747,7 @@ "files", "workflow", "executionData", + "traceSpans", "cost", "createdAt" ], @@ -823,6 +825,14 @@ } } }, + "traceSpans": { + "type": "array", + "description": "Materialized block-level execution trace spans with timing, inputs, and outputs. Empty when the run has no spans.", + "items": { + "type": "object", + "additionalProperties": true + } + }, "cost": { "$ref": "#/components/schemas/Cost" }, diff --git a/apps/sim/app/api/v2/logs/[id]/route.test.ts b/apps/sim/app/api/v2/logs/[id]/route.test.ts new file mode 100644 index 00000000000..191775ad945 --- /dev/null +++ b/apps/sim/app/api/v2/logs/[id]/route.test.ts @@ -0,0 +1,122 @@ +/** + * @vitest-environment node + */ + +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockLoadActiveFolderPathIndex, + mockMaterializeExecutionData, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockLoadActiveFolderPathIndex: vi.fn(), + mockMaterializeExecutionData: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +})) + +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionData: mockMaterializeExecutionData, +})) + +import { GET } from '@/app/api/v2/logs/[id]/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +const LOG_ROW = { + id: 'log-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + level: 'info', + trigger: 'api', + startedAt: new Date('2024-01-01T00:00:00Z'), + endedAt: new Date('2024-01-01T00:00:01Z'), + totalDurationMs: 1000, + executionData: { stored: true }, + costTotal: '0.01', + files: null, + createdAt: new Date('2024-01-01T00:00:00Z'), + workflowName: 'Support Agent', + workflowDescription: 'Handles support requests', + workflowFolderId: null, + workflowUserId: 'user-1', + workflowWorkspaceId: 'workspace-1', + workflowCreatedAt: new Date('2023-12-01T00:00:00Z'), + workflowUpdatedAt: new Date('2023-12-02T00:00:00Z'), + workflowArchivedAt: null, +} + +const routeContext = () => ({ params: Promise.resolve({ id: 'log-1' }) }) + +function callGet() { + return GET(new NextRequest('http://localhost:3000/api/v2/logs/log-1'), routeContext()) +} + +describe('GET /api/v2/logs/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockLoadActiveFolderPathIndex.mockResolvedValue({ pathById: new Map() }) + dbChainMockFns.limit.mockResolvedValue([LOG_ROW]) + }) + + it('returns materialized trace spans as a first-class log detail field', async () => { + const traceSpans = [ + { + id: 'span-1', + name: 'Agent', + type: 'agent', + durationMs: 1000, + status: 'success', + output: { answer: 'done' }, + }, + ] + mockMaterializeExecutionData.mockResolvedValue({ + traceSpans, + finalOutput: { answer: 'done' }, + }) + + const response = await callGet() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data.traceSpans).toEqual(traceSpans) + expect(body.data.executionData.traceSpans).toEqual(traceSpans) + }) + + it('returns an empty trace span array when the execution has no spans', async () => { + mockMaterializeExecutionData.mockResolvedValue({ finalOutput: { answer: 'done' } }) + + const response = await callGet() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data.traceSpans).toEqual([]) + }) +}) diff --git a/apps/sim/app/api/v2/logs/[id]/route.ts b/apps/sim/app/api/v2/logs/[id]/route.ts index b9307d353f7..fac1104f9b1 100644 --- a/apps/sim/app/api/v2/logs/[id]/route.ts +++ b/apps/sim/app/api/v2/logs/[id]/route.ts @@ -5,6 +5,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { traceSpansSchema } from '@/lib/api/contracts/logs' import { type V2LogDetail, v2GetLogContract } from '@/lib/api/contracts/v2/logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -80,6 +81,7 @@ export const GET = withRouteHandler( log.executionData as Record | null, { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } ) + const traceSpans = traceSpansSchema.parse(executionData.traceSpans ?? []) const detail: V2LogDetail = { id: log.id, @@ -105,6 +107,7 @@ export const GET = withRouteHandler( deleted: !log.workflowName || log.workflowArchivedAt !== null, }, executionData, + traceSpans, cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, createdAt: log.createdAt.toISOString(), } diff --git a/apps/sim/lib/api/contracts/logs.ts b/apps/sim/lib/api/contracts/logs.ts index 4e8071a2f6d..db80314bc27 100644 --- a/apps/sim/lib/api/contracts/logs.ts +++ b/apps/sim/lib/api/contracts/logs.ts @@ -161,7 +161,7 @@ const toolCallSchema = z }) .passthrough() -type TraceSpan = { +export type LogTraceSpan = { id: string name: string type: string @@ -176,10 +176,10 @@ type TraceSpan = { tokens?: number | { total?: number; input?: number; output?: number } relativeStartMs?: number toolCalls?: Array> - children?: TraceSpan[] + children?: LogTraceSpan[] } -const traceSpanSchema: z.ZodType = z.lazy(() => +export const traceSpanSchema: z.ZodType = z.lazy(() => z .object({ id: z.string(), @@ -212,11 +212,13 @@ const traceSpanSchema: z.ZodType = z.lazy(() => .passthrough() ) +export const traceSpansSchema = z.array(traceSpanSchema) + const executionDataDetailSchema = z .object({ totalDuration: z.number().nullable().optional(), enhanced: z.literal(true).optional(), - traceSpans: z.array(traceSpanSchema).optional(), + traceSpans: traceSpansSchema.optional(), blockExecutions: z.array(blockExecutionSchema).optional(), finalOutput: z.unknown().optional(), workflowInput: z.unknown().optional(), diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index 8f769542b4c..d9578c4fd2f 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { traceSpansSchema } from '@/lib/api/contracts/logs' import { defineRouteContract } from '@/lib/api/contracts/types' import { v1ExecutionParamsSchema, @@ -47,7 +48,7 @@ export const v2LogListItemSchema = z.object({ /** Present only when `details=full` and `includeFinalOutput=true`. */ finalOutput: z.unknown().optional(), /** Present only when `details=full` and `includeTraceSpans=true`. */ - traceSpans: z.unknown().optional(), + traceSpans: traceSpansSchema.optional(), }) export type V2LogListItem = z.output @@ -75,6 +76,8 @@ export const v2LogDetailSchema = z.object({ }), /** Materialized execution trace (block states, trace spans). */ executionData: z.unknown(), + /** Materialized block-level execution trace spans. */ + traceSpans: traceSpansSchema, cost: v2LogCostSchema, createdAt: z.string(), }) From e4115099af45d8b50f373dc0b06a66291e67fcaa Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 5 Aug 2026 13:41:55 -0700 Subject: [PATCH 080/159] feat(cli): sync unified chat billing source --- packages/sim-cli/README.md | 4 +++- packages/sim-cli/src/contract/commands.ts | 2 +- packages/sim-cli/src/generated/v2-api.ts | 9 +++----- packages/sim-cli/src/runtime/build.test.ts | 27 +++++++++++++++++++--- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 6d21f3de141..4eaca19b47a 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -165,9 +165,11 @@ sim documents upload --kb [--tag ...] sim documents delete --kb --yes sim billing -sim billing logs [--period 7d] [--limit ] +sim billing logs [--period 7d] [--source sim-chat] [--limit ] ``` +The `sim-chat` billing source combines Copilot and workspace chat usage. + Workflow output selectors use `blockName.field` syntax, such as `--select-output agent_1.content`; fields that are not produced are omitted. diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 72fcb312c67..cb59fc30e3a 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -71,7 +71,7 @@ export const CLI_CONTRACT: CliContract = { command: 'billing logs', describe: 'List credit usage events', flags: { - source: { describe: 'Filter by usage source' }, + source: { describe: 'Filter by usage source; sim-chat combines Copilot and workspace chat' }, period: { describe: 'Billing period' }, startDate: { describe: 'Custom period start (ISO 8601)' }, endDate: { describe: 'Custom period end (ISO 8601)' }, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index cf0cb692751..1b851c94dd9 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -3173,8 +3173,7 @@ export type ListUsageLogsQuery = { source?: | 'workflow' | 'wand' - | 'copilot' - | 'workspace-chat' + | 'sim-chat' | 'mcp_copilot' | 'mothership_block' | 'knowledge-base' @@ -3196,8 +3195,7 @@ export type ListUsageLogsResponse = { source: | 'workflow' | 'wand' - | 'copilot' - | 'workspace-chat' + | 'sim-chat' | 'mcp_copilot' | 'mothership_block' | 'knowledge-base' @@ -5486,8 +5484,7 @@ export const V2_OPERATIONS = { values: [ 'workflow', 'wand', - 'copilot', - 'workspace-chat', + 'sim-chat', 'mcp_copilot', 'mothership_block', 'knowledge-base', diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 625190003f7..47f4df2438d 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -165,7 +165,10 @@ describe('commands parsed through commander', () => { const help = commandAt('billing', 'logs').helpInformation() expect(help).toContain('--source ') - expect(help).toContain('Filter by usage source (choices:') + expect(help).toMatch(/sim-chat combines Copilot and\s+workspace chat/) + expect(help).toContain('"sim-chat"') + expect(help).not.toContain('"workspace-chat"') + expect(help).not.toContain('"copilot"') expect(help).not.toContain('One of: workflow') const [summaryPath, summaryOptions] = await run(['billing'], { @@ -174,9 +177,27 @@ describe('commands parsed through commander', () => { expect(summaryPath).toBe('/api/v2/billing/usage') expect(summaryOptions.query).toEqual({ workspaceId: 'ws_local' }) - const [logsPath, logsOptions] = await run(['billing', 'logs', '--period', '7d']) + const [logsPath, logsOptions] = await run([ + 'billing', + 'logs', + '--period', + '7d', + '--source', + 'sim-chat', + ]) expect(logsPath).toBe('/api/v2/billing/usage/logs') - expect(logsOptions.query).toMatchObject({ workspaceId: 'ws_local', period: '7d' }) + expect(logsOptions.query).toMatchObject({ + workspaceId: 'ws_local', + period: '7d', + source: 'sim-chat', + }) + + for (const deprecated of ['copilot', 'workspace-chat']) { + await expect(run(['billing', 'logs', '--source', deprecated])).rejects.toThrow( + /allowed choices.*sim-chat/i + ) + expect(mockRequest).not.toHaveBeenCalled() + } }) it('carries every multi-word flag on a command, not just the first', async () => { From b0a761a2d9fd806aa3ef2feb10c056145b23cdb7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 5 Aug 2026 15:04:06 -0700 Subject: [PATCH 081/159] feat(cli): expose log detail trace spans --- packages/sim-cli/README.md | 11 +++- packages/sim-cli/src/contract/commands.ts | 3 +- packages/sim-cli/src/generated/v2-api.ts | 69 +++++++++++++++++++++- packages/sim-cli/src/runtime/build.test.ts | 32 +++++++++- 4 files changed, 109 insertions(+), 6 deletions(-) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 4eaca19b47a..43c7eee434a 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -125,8 +125,8 @@ sim workflows deploy|undeploy|rollback sim workflows run [--input ] [--select-output …] sim logs list [--level error] [--workflow …] [--trigger …] [--start ] -sim logs get -sim logs execution +sim logs get +sim logs executions get sim tables ls [path] [--search ] [--limit ] sim tables list [--folder ] @@ -170,6 +170,13 @@ sim billing logs [--period 7d] [--source sim-chat] [--limit ] The `sim-chat` billing source combines Copilot and workspace chat usage. +`sim logs get` keeps the default human output concise. Use JSON or YAML to +inspect its complete `executionData` and recursive `traceSpans` tree: + +```bash +sim logs get --output json | jq '.traceSpans' +``` + Workflow output selectors use `blockName.field` syntax, such as `--select-output agent_1.content`; fields that are not produced are omitted. diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index cb59fc30e3a..81b9cc06554 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -166,7 +166,8 @@ export const CLI_CONTRACT: CliContract = { ], }, getLog: { - describe: 'Show a log summary (execution data is available in JSON or YAML output)', + describe: + 'Show a log summary (traceSpans and executionData are included in JSON or YAML output)', fields: [ { header: 'id' }, { header: 'execution', path: 'executionId' }, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 1b851c94dd9..b5cefac4ecc 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -2175,6 +2175,39 @@ export type GetLogParams = { id: string } +type GetLogResponseRef0 = { + id: string + name: string + type: string + duration?: number + durationMs?: number + startTime?: string + endTime?: string + status?: string + blockId?: string + input?: unknown + output?: unknown + tokens?: + | number + | { + total?: number + input?: number + output?: number + } + relativeStartMs?: number + toolCalls?: Array<{ + id?: string + name?: string + arguments?: unknown + result?: unknown + error?: string + startTime?: string + endTime?: string + duration?: number + }> + children?: Array +} + export type GetLogResponse = { data: { id: string @@ -2198,6 +2231,7 @@ export type GetLogResponse = { deleted: boolean } executionData: unknown + traceSpans: Array cost: { total: number } | null @@ -2897,6 +2931,39 @@ export type ListLogsQuery = { folderPaths?: string } +type ListLogsResponseRef0 = { + id: string + name: string + type: string + duration?: number + durationMs?: number + startTime?: string + endTime?: string + status?: string + blockId?: string + input?: unknown + output?: unknown + tokens?: + | number + | { + total?: number + input?: number + output?: number + } + relativeStartMs?: number + toolCalls?: Array<{ + id?: string + name?: string + arguments?: unknown + result?: unknown + error?: string + startTime?: string + endTime?: string + duration?: number + }> + children?: Array +} + export type ListLogsResponse = { data: Array<{ id: string @@ -2919,7 +2986,7 @@ export type ListLogsResponse = { deleted: boolean } finalOutput?: unknown - traceSpans?: unknown + traceSpans?: Array }> nextCursor: string | null } diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 47f4df2438d..51d74c7f5e0 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -458,6 +458,10 @@ describe('commands parsed through commander', () => { /--encoding.*utf-8.*base64/s ) }) + + it('points log detail users to complete trace output', () => { + expect(commandAt('logs', 'get').description()).toMatch(/traceSpans.*JSON or YAML/) + }) }) describe('single-resource rendering', () => { @@ -555,7 +559,7 @@ describe('single-resource rendering', () => { expect(JSON.parse(printed[0])).toEqual({ row: { id: 'r1' }, operation: 'inserted' }) }) - it('keeps sensitive execution data out of human log output', async () => { + it('keeps sensitive execution detail out of human log output', async () => { const log = { id: 'log_1', executionId: 'exec_1', @@ -568,14 +572,38 @@ describe('single-resource rendering', () => { cost: { total: 0.001 }, files: [], executionData: { env: { SECRET_TOKEN: 'encrypted-value' } }, + traceSpans: [ + { + id: 'span_1', + name: 'Workflow Execution', + type: 'workflow', + children: [ + { + id: 'span_2', + name: 'Send email', + type: 'block', + input: { recipient: 'private@example.com' }, + }, + ], + }, + ], } const human = await lines(['logs', 'get', 'log_1'], log, 'text') expect(human.join('\n')).not.toContain('executionData') expect(human.join('\n')).not.toContain('SECRET_TOKEN') + expect(human.join('\n')).not.toContain('traceSpans') + expect(human.join('\n')).not.toContain('private@example.com') const machine = await lines(['logs', 'get', 'log_1'], log, 'json') - expect(JSON.parse(machine[0])).toMatchObject({ executionData: log.executionData }) + expect(JSON.parse(machine[0])).toMatchObject({ + executionData: log.executionData, + traceSpans: log.traceSpans, + }) + + const yaml = await lines(['logs', 'get', 'log_1'], log, 'yaml') + expect(yaml.join('\n')).toContain('traceSpans:') + expect(yaml.join('\n')).toContain('span_2') }) }) From 701400e4867479bdfc76524e9092dced7e7acff9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 5 Aug 2026 15:04:34 -0700 Subject: [PATCH 082/159] fix(logs): parse list trace spans --- apps/sim/app/api/v2/logs/route.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index ef1eb76e97b..b09a0a24e30 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -5,6 +5,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { traceSpansSchema } from '@/lib/api/contracts/logs' import { type V2LogListItem, v2ListLogsContract } from '@/lib/api/contracts/v2/logs' import { parseRequest } from '@/lib/api/server' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' @@ -179,8 +180,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (params.includeFinalOutput && execData.finalOutput) { item.finalOutput = execData.finalOutput } - if (params.includeTraceSpans && execData.traceSpans) { - item.traceSpans = execData.traceSpans + if (params.includeTraceSpans) { + item.traceSpans = traceSpansSchema.parse(execData.traceSpans ?? []) } } return item From f32dc8378808f120f87c8b143fcbf785f3cd9fc4 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 5 Aug 2026 16:25:13 -0700 Subject: [PATCH 083/159] improvement(api): replace workflow jobs with execution resources (#6294) * improvement(api): replace workflow jobs with execution resources * fix(api): preserve legacy jobs while preferring v2 executions * fix(api): make execution polling resume-aware * fix(ui): hide async examples for public workflows * fix(api): bridge resume queue visibility lag * feat(api): add v2 workflow resume endpoint * fix(api): project pending resume attempts * fix(api): prefer terminal logs over stale resumes --- .../docs/en/api-reference/getting-started.mdx | 80 ++-- .../content/docs/en/api-reference/python.mdx | 65 +-- .../docs/en/api-reference/typescript.mdx | 67 ++-- .../en/workflows/blocks/human-in-the-loop.mdx | 74 ++-- .../docs/en/workflows/deployment/api.mdx | 108 ++--- apps/docs/openapi-core.json | 7 +- apps/docs/openapi-v2-workflows.json | 167 ++++++++ apps/docs/openapi.json | 7 +- .../[executionId]/[contextId]/route.test.ts | 95 ++++- .../[executionId]/[contextId]/route.ts | 352 +---------------- apps/sim/app/api/resume/resume-handler.ts | 374 ++++++++++++++++++ .../[executionId]/resume/route.test.ts | 170 ++++++++ .../executions/[executionId]/resume/route.ts | 147 +++++++ .../executions/[executionId]/route.test.ts | 38 +- .../[id]/executions/[executionId]/route.ts | 79 +--- .../[id]/execute/route.async.test.ts | 1 + .../deploy-modal/components/api/api.tsx | 150 +++---- .../components/deploy-modal/deploy-modal.tsx | 18 +- apps/sim/lib/api/contracts/v2/workflows.ts | 25 ++ apps/sim/lib/api/contracts/workflows.ts | 1 + apps/sim/lib/compare/data/sim.ts | 10 +- .../tools/handlers/deployment/deploy.ts | 30 +- .../async-jobs/backends/trigger-dev.test.ts | 76 +++- .../core/async-jobs/backends/trigger-dev.ts | 22 +- .../workflows/executor/enqueue-execution.ts | 1 + .../executor/execution-status.test.ts | 222 +++++++++++ .../workflows/executor/execution-status.ts | 103 ++++- packages/python-sdk/README.md | 39 +- packages/python-sdk/simstudio/__init__.py | 125 ++++-- packages/python-sdk/tests/test_client.py | 150 ++++--- packages/ts-sdk/README.md | 41 +- packages/ts-sdk/src/index.test.ts | 187 +++++---- packages/ts-sdk/src/index.ts | 158 +++++++- 33 files changed, 2269 insertions(+), 920 deletions(-) create mode 100644 apps/sim/app/api/resume/resume-handler.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.ts create mode 100644 apps/sim/lib/workflows/executor/execution-status.test.ts 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 c8093e72c14..2b744d8586d 100644 --- a/apps/docs/content/docs/en/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/en/api-reference/getting-started.mdx @@ -49,28 +49,28 @@ A workflow must be deployed before it can be executed via the API. Click the **D ```bash - curl -X POST https://www.sim.ai/api/workflows/{workflowId}/execute \ + curl -X POST https://www.sim.ai/api/v2/workflows/{workflowId}/execute \ -H "Content-Type: application/json" \ -H "X-API-Key: YOUR_API_KEY" \ - -d '{"inputs": {}}' + -d '{"input": {}}' ``` ```typescript const response = await fetch( - `https://www.sim.ai/api/workflows/${workflowId}/execute`, + `https://www.sim.ai/api/v2/workflows/${workflowId}/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.SIM_API_KEY!, }, - body: JSON.stringify({ inputs: {} }), + body: JSON.stringify({ input: {} }), } ) const data = await response.json() - console.log(data.output) + console.log(data.data.output) ``` @@ -79,16 +79,16 @@ A workflow must be deployed before it can be executed via the API. Click the **D import os response = requests.post( - f"https://www.sim.ai/api/workflows/{workflow_id}/execute", + f"https://www.sim.ai/api/v2/workflows/{workflow_id}/execute", headers={ "Content-Type": "application/json", "X-API-Key": os.environ["SIM_API_KEY"], }, - json={"inputs": {}}, + json={"input": {}}, ) data = response.json() - print(data["output"]) + print(data["data"]["output"]) ``` @@ -103,77 +103,61 @@ By default, workflow executions are **synchronous** — the API blocks until the For long-running workflows, use **asynchronous execution** by passing `async: true`: ```bash -curl -X POST https://www.sim.ai/api/workflows/{workflowId}/execute \ +curl -X POST https://www.sim.ai/api/v2/workflows/{workflowId}/execute \ -H "Content-Type: application/json" \ -H "X-API-Key: YOUR_API_KEY" \ - -d '{"inputs": {}, "async": true}' + -d '{"input": {}, "async": true}' ``` -This returns immediately with a `jobId` and `statusUrl`: +This returns immediately with an `executionId` and `statusUrl`: ```json { - "success": true, - "jobId": "job_abc123", - "statusUrl": "https://www.sim.ai/api/jobs/job_abc123", - "message": "Workflow execution started", - "async": true + "data": { + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "statusUrl": "https://www.sim.ai/api/v2/workflows/{workflowId}/executions/c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74" + } } ``` -Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Execution Status](/api-reference/execution/getWorkflowExecution) endpoint until the status is terminal: ```bash -curl https://www.sim.ai/api/jobs/{jobId} \ +curl https://www.sim.ai/api/v2/workflows/{workflowId}/executions/{executionId}?includeOutput=true \ -H "X-API-Key: YOUR_API_KEY" ``` - Job status transitions follow: `queued` → `processing` → `completed` or `failed`. The `output` field is only present when status is `completed`. + Execution status transitions follow: `queued` → `running` → `completed`, `failed`, `cancelled`, or `paused`. The `data.output` field is populated for completed executions when `includeOutput=true`. ## Response Format -Successful responses include an `output` object with your workflow results and a `limits` object with your current rate limit and usage status: +Successful v2 responses wrap the execution resource in `data`: ```json { - "success": true, - "output": { - "result": "Hello, world!" - }, - "limits": { - "workflowExecutionRateLimit": { - "sync": { - "requestsPerMinute": 60, - "maxBurst": 10, - "remaining": 59, - "resetAt": "2025-01-01T00:01:00Z" - }, - "async": { - "requestsPerMinute": 30, - "maxBurst": 5, - "remaining": 30, - "resetAt": "2025-01-01T00:01:00Z" - } - }, - "usage": { - "currentPeriodCost": 1.25, - "limit": 50.00, - "plan": "pro", - "isExceeded": false - } + "data": { + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "workflowId": "{workflowId}", + "status": "completed", + "output": { "result": "Hello, world!" }, + "error": null, + "durationMs": 842 } } ``` ## Error Handling -The API uses standard HTTP status codes. Error responses include a human-readable `error` message: +The API uses standard HTTP status codes. v2 errors include a stable code and human-readable message: ```json { - "error": "Workflow not found" + "error": { + "code": "NOT_FOUND", + "message": "Workflow not found" + } } ``` @@ -191,7 +175,7 @@ The API uses standard HTTP status codes. Error responses include a human-readabl ## Rate Limits -Rate limits depend on your subscription plan and apply separately to synchronous and asynchronous executions. Every execution response includes a `limits` object showing your current rate limit status. +Rate limits depend on your subscription plan and apply separately to synchronous and asynchronous executions. When rate limited, the API returns a `429` response with a `Retry-After` header indicating how many seconds to wait before retrying. diff --git a/apps/docs/content/docs/en/api-reference/python.mdx b/apps/docs/content/docs/en/api-reference/python.mdx index d70bb50e3aa..b00d4e88eeb 100644 --- a/apps/docs/content/docs/en/api-reference/python.mdx +++ b/apps/docs/content/docs/en/api-reference/python.mdx @@ -80,7 +80,7 @@ result = client.execute_workflow( **Returns:** `WorkflowExecutionResult | AsyncExecutionResult` -When `async_execution=True`, returns immediately with a `job_id` and `status_url` for polling. Otherwise, waits for completion. +When `async_execution=True`, returns immediately with an `execution_id` and `status_url` for polling. Otherwise, waits for completion. ##### get_workflow_status() @@ -112,30 +112,42 @@ if is_ready: **Returns:** `bool` -##### get_job_status() +##### get_workflow_execution() -Get the status of an async job execution. +Get the status and optional outputs of a workflow execution. ```python -status = client.get_job_status("job-id-from-async-execution") -print("Status:", status["status"]) # 'queued', 'processing', 'completed', 'failed' +status = client.get_workflow_execution("workflow-id", "execution-id", include_output=True) +print("Status:", status["status"]) # 'queued', 'running', 'completed', 'failed' if status["status"] == "completed": print("Output:", status["output"]) ``` **Parameters:** -- `task_id` (str): The job ID returned from async execution +- `workflow_id` (str): The workflow ID +- `execution_id` (str): The execution ID returned from async execution +- `include_output` (bool, optional): Include the final output for completed executions +- `selected_outputs` (list[str], optional): Block output selectors to include **Returns:** `Dict[str, Any]` **Response fields:** -- `success` (bool): Whether the request was successful -- `taskId` (str): The job ID -- `status` (str): One of `'queued'`, `'processing'`, `'completed'`, `'failed'`, `'cancelled'` -- `metadata` (dict): Contains `startedAt`, `completedAt`, and `duration` -- `output` (any, optional): The workflow output (when completed) -- `error` (any, optional): Error details (when failed) -- `estimatedDuration` (int, optional): Estimated duration in milliseconds (when processing/queued) +- `executionId` (str): The execution ID +- `workflowId` (str): The workflow ID +- `status` (str): One of `'queued'`, `'pending'`, `'running'`, `'paused'`, `'completed'`, `'failed'`, `'cancelled'` +- `startedAt` / `endedAt` (str): Execution timestamps +- `durationMs` (int, optional): Duration in milliseconds +- `output` (any, optional): The workflow output when requested for a completed execution +- `blockOutputs` (dict, optional): Requested block outputs +- `error` (dict, optional): Structured failure details with `code`, `message`, and optional `details` + +##### get_job_status() + +Get the status of a job created through the legacy async execution endpoint. New integrations should use `get_workflow_execution()` with the execution ID instead. + +```python +status = client.get_job_status("legacy-job-id") +``` ##### execute_with_retry() @@ -270,9 +282,8 @@ class WorkflowExecutionResult: @dataclass class AsyncExecutionResult: success: bool - job_id: str + execution_id: str status_url: str - execution_id: Optional[str] = None message: str = "" async_execution: bool = True ``` @@ -494,22 +505,26 @@ def execute_async(): ) # Check if result is an async execution - if hasattr(result, 'job_id'): - print(f"Job ID: {result.job_id}") + if hasattr(result, 'async_execution') and result.async_execution: + print(f"Execution ID: {result.execution_id}") print(f"Status endpoint: {result.status_url}") # Poll for completion - status = client.get_job_status(result.job_id) + status = client.get_workflow_execution( + "workflow-id", result.execution_id, include_output=True + ) - while status["status"] in ["queued", "processing"]: + while status["status"] in ["queued", "pending", "running"]: print(f"Current status: {status['status']}") time.sleep(2) # Wait 2 seconds - status = client.get_job_status(result.job_id) + status = client.get_workflow_execution( + "workflow-id", result.execution_id, include_output=True + ) if status["status"] == "completed": print("Workflow completed!") print(f"Output: {status['output']}") - print(f"Duration: {status['metadata']['duration']}") + print(f"Duration: {status['durationMs']}") else: print(f"Workflow failed: {status['error']}") @@ -656,13 +671,13 @@ def stream_workflow(): def generate(): response = requests.post( - 'https://sim.ai/api/workflows/WORKFLOW_ID/execute', + 'https://sim.ai/api/v2/workflows/WORKFLOW_ID/execute', headers={ 'Content-Type': 'application/json', 'X-API-Key': os.getenv('SIM_API_KEY') }, json={ - 'message': 'Generate a story', + 'input': {'message': 'Generate a story'}, 'stream': True, 'selectedOutputs': ['agent1.content'] }, @@ -765,9 +780,9 @@ import { FAQ } from '@/components/ui/faq' \ No newline at end of file +]} /> diff --git a/apps/docs/content/docs/en/api-reference/typescript.mdx b/apps/docs/content/docs/en/api-reference/typescript.mdx index 791849f94a5..9f18bbb0d3c 100644 --- a/apps/docs/content/docs/en/api-reference/typescript.mdx +++ b/apps/docs/content/docs/en/api-reference/typescript.mdx @@ -94,7 +94,7 @@ const result = await client.executeWorkflow('workflow-id', { message: 'Hello, wo **Returns:** `Promise` -When `async: true`, returns immediately with a `jobId` and `statusUrl` for polling. Otherwise, waits for completion. +When `async: true`, returns immediately with an `executionId` and `statusUrl` for polling. Otherwise, waits for completion. ##### getWorkflowStatus() @@ -126,31 +126,45 @@ if (isReady) { **Returns:** `Promise` -##### getJobStatus() +##### getWorkflowExecution() -Get the status of an async job execution. +Get the status and optional outputs of a workflow execution. ```typescript -const status = await client.getJobStatus('job-id-from-async-execution'); -console.log('Status:', status.status); // 'queued', 'processing', 'completed', 'failed' +const status = await client.getWorkflowExecution('workflow-id', 'execution-id', { + includeOutput: true +}); +console.log('Status:', status.status); // 'queued', 'running', 'completed', 'failed' if (status.status === 'completed') { console.log('Output:', status.output); } ``` **Parameters:** -- `jobId` (string): The job ID returned from async execution +- `workflowId` (string): The workflow ID +- `executionId` (string): The execution ID returned from async execution +- `options.includeOutput` (boolean, optional): Include the final output for completed executions +- `options.selectedOutputs` (string[], optional): Block output selectors to include -**Returns:** `Promise` +**Returns:** `Promise` **Response fields:** -- `success` (boolean): Whether the request was successful -- `taskId` (string): The job ID -- `status` (string): One of `'queued'`, `'processing'`, `'completed'`, `'failed'`, `'cancelled'` -- `metadata` (object): Contains `startedAt`, `completedAt`, and `duration` -- `output` (any, optional): The workflow output (when completed) -- `error` (any, optional): Error details (when failed) -- `estimatedDuration` (number, optional): Estimated duration in milliseconds (when processing/queued) +- `executionId` (string): The execution ID +- `workflowId` (string): The workflow ID +- `status` (string): One of `'queued'`, `'pending'`, `'running'`, `'paused'`, `'completed'`, `'failed'`, `'cancelled'` +- `startedAt` / `endedAt` (string): Execution timestamps +- `durationMs` (number, nullable): Duration in milliseconds +- `output` (any, nullable): The workflow output when requested for a completed execution +- `blockOutputs` (object, nullable): Requested block outputs +- `error` (object, nullable): Structured failure details with `code`, `message`, and optional `details` + +##### getJobStatus() + +Get the status of a job created through the legacy async execution endpoint. New integrations should use `getWorkflowExecution()` with the execution ID instead. + +```typescript +const status = await client.getJobStatus('legacy-job-id'); +``` ##### executeWithRetry() @@ -278,9 +292,8 @@ interface WorkflowExecutionResult { ```typescript interface AsyncExecutionResult { success: boolean; - jobId: string; + executionId: string; statusUrl: string; - executionId?: string; message: string; async: true; } @@ -766,23 +779,27 @@ async function executeAsync() { }); // Check if result is an async execution - if ('jobId' in result) { - console.log('Job ID:', result.jobId); + if ('async' in result && result.async) { + console.log('Execution ID:', result.executionId); console.log('Status endpoint:', result.statusUrl); // Poll for completion - let status = await client.getJobStatus(result.jobId); + let status = await client.getWorkflowExecution('workflow-id', result.executionId, { + includeOutput: true + }); - while (status.status === 'queued' || status.status === 'processing') { + while (status.status === 'queued' || status.status === 'pending' || status.status === 'running') { console.log('Current status:', status.status); await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2 seconds - status = await client.getJobStatus(result.jobId); + status = await client.getWorkflowExecution('workflow-id', result.executionId, { + includeOutput: true + }); } if (status.status === 'completed') { console.log('Workflow completed!'); console.log('Output:', status.output); - console.log('Duration:', status.metadata.duration); + console.log('Duration:', status.durationMs); } else { console.error('Workflow failed:', status.error); } @@ -931,14 +948,14 @@ function StreamingWorkflow() { // IMPORTANT: Make this API call from your backend server, not the browser // Never expose your API key in client-side code - const response = await fetch('https://sim.ai/api/workflows/WORKFLOW_ID/execute', { + const response = await fetch('https://sim.ai/api/v2/workflows/WORKFLOW_ID/execute', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.SIM_API_KEY! // Server-side environment variable only }, body: JSON.stringify({ - message: 'Generate a story', + input: { message: 'Generate a story' }, stream: true, selectedOutputs: ['agent1.content'] }) @@ -1021,7 +1038,7 @@ import { FAQ } from '@/components/ui/faq' `. ### REST API - Programmatically resume workflows using the resume endpoint. The `contextId` is available from the block's `resumeEndpoint` output or from the `_resume` object in the paused execution response. + Programmatically resume workflows through the v2 execution resource. The `contextId` is available from the block's `resumeEndpoint` output or from the `_resume` object in the paused execution response. ```bash - POST /api/resume/{workflowId}/{executionId}/{contextId} + POST /api/v2/workflows/{workflowId}/executions/{executionId}/resume Content-Type: application/json X-API-Key: your-api-key { + "contextId": "", "input": { "approved": true, "comments": "Looks good to proceed" @@ -109,11 +110,16 @@ Access resume data in downstream blocks using ``. ```json { - "success": true, - "status": "completed", - "executionId": "", - "output": { ... }, - "metadata": { "duration": 1234, "startTime": "...", "endTime": "..." } + "data": { + "executionId": "", + "workflowId": "", + "status": "completed", + "output": { ... }, + "error": null, + "startedAt": "...", + "endedAt": "...", + "durationMs": 1234 + } } ``` @@ -121,16 +127,14 @@ Access resume data in downstream blocks using ``. - **Stream mode** (`stream: true` on the original execute call) — The resume response streams SSE events with `selectedOutputs` chunks, just like the initial execution. - - **Async mode** (`X-Execution-Mode: async` on the original execute call) — The resume dispatches execution to a background worker and returns immediately with `202`, including a `jobId` and `statusUrl` for polling: + - **Async mode** (`async: true` on the original v2 execute call) — The resume dispatches execution to a background worker and returns immediately with `202`, including the resume attempt's `executionId` and v2 `statusUrl` for polling: ```json { - "success": true, - "async": true, - "jobId": "", - "executionId": "", - "message": "Resume execution queued", - "statusUrl": "/api/jobs/" + "data": { + "executionId": "", + "statusUrl": "/api/v2/workflows//executions/" + } } ``` @@ -139,11 +143,19 @@ Access resume data in downstream blocks using ``. Poll the `statusUrl` from the async response to check when the resume completes: ```bash - GET /api/jobs/{jobId} + GET /api/v2/workflows/{workflowId}/executions/{resumeExecutionId}?includeOutput=true X-API-Key: your-api-key ``` - Returns job status and, when completed, the full workflow output. + Returns the execution status and, when completed, the full workflow output. + + The legacy endpoint remains available without behavior changes for existing integrations: + + ```bash + POST /api/resume/{workflowId}/{executionId}/{contextId} + ``` + + Its async response continues to expose `jobId` and the legacy `/api/jobs/{jobId}` polling URL. To check on a paused execution's pause points and resume links: @@ -163,7 +175,7 @@ Access resume data in downstream blocks using ``. ## API Execute Behavior -When triggering a workflow via the execute API (`POST /api/workflows/{id}/execute`), HITL blocks cause the execution to pause and return the `_resume` data in the response: +When triggering a workflow through `POST /api/v2/workflows/{id}/execute`, HITL blocks cause the execution to pause and return the `_resume` data in the v2 response envelope. The legacy `POST /api/workflows/{id}/execute` endpoint remains available for existing integrations. @@ -171,19 +183,23 @@ When triggering a workflow via the execute API (`POST /api/workflows/{id}/execut ```json { - "success": true, - "executionId": "", - "output": { - "data": { - "operation": "human", - "_resume": { - "apiUrl": "/api/resume/{workflowId}/{executionId}/{contextId}", - "uiUrl": "/resume/{workflowId}/{executionId}", - "contextId": "", - "executionId": "", - "workflowId": "" + "data": { + "executionId": "", + "workflowId": "", + "status": "paused", + "output": { + "data": { + "operation": "human", + "_resume": { + "apiUrl": "/api/resume/{workflowId}/{executionId}/{contextId}", + "uiUrl": "/resume/{workflowId}/{executionId}", + "contextId": "", + "executionId": "", + "workflowId": "" + } } - } + }, + "error": null } } ``` diff --git a/apps/docs/content/docs/en/workflows/deployment/api.mdx b/apps/docs/content/docs/en/workflows/deployment/api.mdx index 1e3821d5776..ad35c338f9d 100644 --- a/apps/docs/content/docs/en/workflows/deployment/api.mdx +++ b/apps/docs/content/docs/en/workflows/deployment/api.mdx @@ -26,7 +26,7 @@ Click **Deploy** to publish your workflow for the first time, or **Update** to p Once deployed, your workflow is available at: ``` -POST https://sim.ai/api/workflows/{workflow-id}/execute +POST https://sim.ai/api/v2/workflows/{workflow-id}/execute ``` @@ -96,10 +96,10 @@ At the bottom of the tab, two buttons give you quick access to key settings: By default, API endpoints require an API key passed in the `x-api-key` header. Generate keys in **Settings → Sim Keys** or via the **Generate API Key** button in the API tab. ```bash -curl -X POST https://sim.ai/api/workflows/{workflow-id}/execute \ +curl -X POST https://sim.ai/api/v2/workflows/{workflow-id}/execute \ -H "Content-Type: application/json" \ -H "x-api-key: $SIM_API_KEY" \ - -d '{ "input": "Hello" }' + -d '{ "input": { "message": "Hello" } }' ``` ### API Info and Public Access @@ -128,10 +128,10 @@ The default mode. Send a request and wait for the complete response: ```bash -curl -X POST https://sim.ai/api/workflows/{workflow-id}/execute \ +curl -X POST https://sim.ai/api/v2/workflows/{workflow-id}/execute \ -H "Content-Type: application/json" \ -H "x-api-key: $SIM_API_KEY" \ - -d '{ "input": "Summarize this article" }' + -d '{ "input": { "message": "Summarize this article" } }' ``` @@ -139,25 +139,25 @@ curl -X POST https://sim.ai/api/workflows/{workflow-id}/execute \ import requests, os response = requests.post( - "https://sim.ai/api/workflows/{workflow-id}/execute", + "https://sim.ai/api/v2/workflows/{workflow-id}/execute", headers={ "Content-Type": "application/json", "x-api-key": os.environ["SIM_API_KEY"] }, - json={"input": "Summarize this article"} + json={"input": {"message": "Summarize this article"}} ) print(response.json()) ``` ```typescript -const response = await fetch('https://sim.ai/api/workflows/{workflow-id}/execute', { +const response = await fetch('https://sim.ai/api/v2/workflows/{workflow-id}/execute', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': process.env.SIM_API_KEY! }, - body: JSON.stringify({ input: 'Summarize this article' }) + body: JSON.stringify({ input: { message: 'Summarize this article' } }) }); console.log(await response.json()); ``` @@ -179,11 +179,11 @@ The `selectedOutputs` values in the request body follow the format `blockName.fi ```bash -curl -X POST https://sim.ai/api/workflows/{workflow-id}/execute \ +curl -X POST https://sim.ai/api/v2/workflows/{workflow-id}/execute \ -H "Content-Type: application/json" \ -H "x-api-key: $SIM_API_KEY" \ -d '{ - "input": "Write a long essay", + "input": { "prompt": "Write a long essay" }, "stream": true, "selectedOutputs": ["agent_1.content"] }' @@ -194,13 +194,13 @@ curl -X POST https://sim.ai/api/workflows/{workflow-id}/execute \ import requests, os response = requests.post( - "https://sim.ai/api/workflows/{workflow-id}/execute", + "https://sim.ai/api/v2/workflows/{workflow-id}/execute", headers={ "Content-Type": "application/json", "x-api-key": os.environ["SIM_API_KEY"] }, json={ - "input": "Write a long essay", + "input": {"prompt": "Write a long essay"}, "stream": True, "selectedOutputs": ["agent_1.content"] }, @@ -213,14 +213,14 @@ for line in response.iter_lines(): ```typescript -const response = await fetch('https://sim.ai/api/workflows/{workflow-id}/execute', { +const response = await fetch('https://sim.ai/api/v2/workflows/{workflow-id}/execute', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': process.env.SIM_API_KEY! }, body: JSON.stringify({ - input: 'Write a long essay', + input: { prompt: 'Write a long essay' }, stream: true, selectedOutputs: ['agent_1.content'] }) @@ -242,12 +242,12 @@ while (true) { By default a streaming run carries answer text only. To also receive the Agent block's reasoning and its tool-call lifecycle, set `includeThinking` / `includeToolCalls`: ```bash -curl -N -X POST https://sim.ai/api/workflows/{workflow-id}/execute \ +curl -N -X POST https://sim.ai/api/v2/workflows/{workflow-id}/execute \ -H "Content-Type: application/json" \ -H "x-api-key: $SIM_API_KEY" \ -H "X-Sim-Stream-Protocol: agent-events-v1" \ -d '{ - "input": "Research this topic", + "input": { "prompt": "Research this topic" }, "stream": true, "selectedOutputs": ["agent_1.content"], "includeThinking": true, @@ -280,76 +280,76 @@ The `version` field is part of the external API contract. Treat the reference as ### Asynchronous -For long-running workflows, async mode returns a job ID immediately so you don't need to hold the connection open. Add the `X-Execution-Mode: async` header to your request. The API returns HTTP 202 with a job ID and status URL. Poll the status URL until the job completes. +For long-running workflows, async mode returns an execution ID immediately so you don't need to hold the connection open. Set `"async": true` in the v2 request body. The API returns HTTP 202 with an execution ID and v2 status URL. Poll that execution resource until the run completes. - - + + ```bash -curl -X POST https://sim.ai/api/workflows/{workflow-id}/execute \ +curl -X POST https://sim.ai/api/v2/workflows/{workflow-id}/execute \ -H "Content-Type: application/json" \ -H "x-api-key: $SIM_API_KEY" \ - -H "X-Execution-Mode: async" \ - -d '{ "input": "Process this large dataset" }' + -d '{ "input": { "task": "Process this large dataset" }, "async": true }' ``` **Response** (HTTP 202): ```json { - "success": true, - "async": true, - "jobId": "run_abc123", - "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", - "message": "Workflow execution queued", - "statusUrl": "https://sim.ai/api/jobs/run_abc123" + "data": { + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "statusUrl": "https://sim.ai/api/v2/workflows/{workflow-id}/executions/c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74" + } } ``` ```bash -curl https://sim.ai/api/jobs/{jobId} \ +curl "https://sim.ai/api/v2/workflows/{workflow-id}/executions/{executionId}?includeOutput=true" \ -H "x-api-key: $SIM_API_KEY" ``` **While processing:** ```json { - "success": true, - "taskId": "run_abc123", - "status": "processing", - "metadata": { - "createdAt": "2025-09-10T12:00:00.000Z", - "startedAt": "2025-09-10T12:00:01.000Z" - }, - "estimatedDuration": 300000 + "data": { + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "workflowId": "{workflow-id}", + "status": "running", + "startedAt": "2025-09-10T12:00:01.000Z", + "endedAt": null, + "durationMs": null, + "output": null + } } ``` **When completed:** ```json { - "success": true, - "taskId": "run_abc123", - "status": "completed", - "metadata": { - "createdAt": "2025-09-10T12:00:00.000Z", + "data": { + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "workflowId": "{workflow-id}", + "status": "completed", "startedAt": "2025-09-10T12:00:01.000Z", - "completedAt": "2025-09-10T12:00:05.000Z", - "duration": 4000 - }, - "output": { "result": "..." } + "endedAt": "2025-09-10T12:00:05.000Z", + "durationMs": 4000, + "output": { "result": "..." } + } } ``` -#### Job Status Values +#### Execution Status Values | Status | Description | |--------|-------------| -| `queued` | Job is waiting to be picked up | -| `processing` | Workflow is actively executing | -| `completed` | Finished successfully — `output` field contains the result | +| `queued` | Execution is waiting to be picked up | +| `pending` | The durable execution record exists but has not started | +| `running` | Workflow is actively executing | +| `paused` | Workflow is waiting for a resume condition or input | +| `completed` | Finished successfully — `output` is populated when requested | | `failed` | Execution failed — `error` field contains the message | +| `cancelled` | Execution was cancelled | Poll the `statusUrl` from the initial response until the status is `completed` or `failed`. @@ -360,11 +360,11 @@ Poll the `statusUrl` from the initial response until the status is `completed` o | **Community** | 5 minutes | 90 minutes | | **Pro / Max / Team / Enterprise** | 50 minutes | 90 minutes | -If a job exceeds its time limit it is automatically marked as `failed`. +If an execution exceeds its time limit it is automatically marked as `failed`. -#### Job Retention +#### Execution Retention -Completed and failed job results are retained for **24 hours**. After that, the status endpoint returns `404`. Retrieve and store results on your end if you need them longer. +Completed and failed runs are read from execution logs and follow the workspace's execution-log retention policy. #### Capacity Limits diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index 6ed28693188..ecde7d1730f 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -153,7 +153,7 @@ "get": { "operationId": "getWorkflowExecution", "summary": "Get Execution Status", - "description": "Get the current status of a workflow execution. Returns the run's lifecycle state (`running`, `paused`, `completed`, `failed`, etc.), timing, error, and optionally per-block outputs. Designed for polling — works for any execution, including ones that pause and resume.", + "description": "Get the current status of a workflow execution. Returns `queued` immediately after async dispatch, then the run's durable lifecycle state (`running`, `paused`, `completed`, `failed`, etc.), timing, error, and optionally per-block outputs. This legacy-compatible resource remains available for existing integrations.", "tags": ["Execution"], "x-codeSamples": [ { @@ -1394,6 +1394,7 @@ "AsyncExecutionResult": { "type": "object", "description": "Response returned when a workflow execution is queued for asynchronous processing.", + "required": ["success", "async", "jobId", "executionId", "message", "statusUrl"], "properties": { "success": { "type": "boolean", @@ -1506,8 +1507,8 @@ }, "status": { "type": "string", - "enum": ["pending", "running", "paused", "completed", "failed", "cancelled"], - "description": "Current normalized lifecycle status. `paused` is set when a row exists in pausedExecutions with status `paused` or `partially_resumed`; otherwise the workflowExecutionLogs row's status field is used.", + "enum": ["queued", "pending", "running", "paused", "completed", "failed", "cancelled"], + "description": "Current normalized lifecycle status. `queued` is projected from the async queue before the durable execution log exists; `paused` is set when a row exists in pausedExecutions with status `paused` or `partially_resumed`; otherwise the workflowExecutionLogs row's status field is used.", "example": "completed" }, "trigger": { diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 22fc0bae059..00d943e77a7 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -1536,6 +1536,173 @@ } } }, + "/api/v2/workflows/{id}/executions/{executionId}/resume": { + "post": { + "operationId": "resumeWorkflowExecutionV2", + "summary": "Resume a workflow execution", + "description": "Resumes one human-in-the-loop pause context on the parent execution. The resumed attempt receives a new execution ID. Sync attempts return the execution resource, stream attempts return Server-Sent Events, and async or serialized attempts return a 202 receipt whose `statusUrl` is the v2 execution resource.", + "tags": ["Workflows"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused parent run.", + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "requestBody": { + "required": true, + "description": "The pause context to resume and its optional input. Bodies over 10 MB and unknown keys are rejected.", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["contextId"], + "properties": { + "contextId": { + "type": "string", + "minLength": 1, + "description": "The context ID of the human-in-the-loop pause point." + }, + "input": { + "description": "Input supplied to the paused block." + } + } + }, + "example": { + "contextId": "ctx_123", + "input": { + "approved": true, + "comments": "Looks good to proceed" + } + } + } + } + }, + "responses": { + "200": { + "description": "The completed, failed, paused, or cancelled resume execution resource.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/ExecutionResource" + } + } + }, + "example": { + "data": { + "executionId": "resume_exec_1", + "workflowId": "wf_123", + "status": "completed", + "output": { + "result": "approved" + }, + "error": null, + "durationMs": 420 + } + } + } + } + }, + "202": { + "description": "The resume is queued. Poll `statusUrl` using the returned resume execution ID.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["executionId", "statusUrl"], + "properties": { + "executionId": { + "type": "string" + }, + "statusUrl": { + "type": "string" + }, + "queuePosition": { + "type": "integer", + "minimum": 1 + } + } + } + } + }, + "example": { + "data": { + "executionId": "resume_exec_1", + "statusUrl": "https://www.sim.ai/api/v2/workflows/wf_123/executions/resume_exec_1" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "description": "The pause context cannot be resumed in its current state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "description": "Resume execution infrastructure temporarily unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/api/v2/workflows/{id}/executions/{executionId}/cancel": { "post": { "operationId": "cancelExecutionV2", diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index b2e8ca4c523..6e81a450470 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -173,7 +173,7 @@ "get": { "operationId": "getWorkflowExecution", "summary": "Get Execution Status", - "description": "Get the current status of a workflow execution. Returns the run's lifecycle state (`running`, `paused`, `completed`, `failed`, etc.), timing, error, and optionally per-block outputs. Designed for polling \u2014 works for any execution, including ones that pause and resume.", + "description": "Get the current status of a workflow execution. Returns `queued` immediately after async dispatch, then the run's durable lifecycle state (`running`, `paused`, `completed`, `failed`, etc.), timing, error, and optionally per-block outputs. This legacy-compatible resource remains available for existing integrations.", "tags": ["Workflows"], "x-codeSamples": [ { @@ -6611,6 +6611,7 @@ "AsyncExecutionResult": { "type": "object", "description": "Response returned when a workflow execution is queued for asynchronous processing.", + "required": ["success", "async", "jobId", "executionId", "message", "statusUrl"], "properties": { "success": { "type": "boolean", @@ -7020,8 +7021,8 @@ }, "status": { "type": "string", - "enum": ["pending", "running", "paused", "completed", "failed", "cancelled"], - "description": "Current normalized lifecycle status. `paused` is set when a row exists in pausedExecutions with status `paused` or `partially_resumed`; otherwise the workflowExecutionLogs row's status field is used.", + "enum": ["queued", "pending", "running", "paused", "completed", "failed", "cancelled"], + "description": "Current normalized lifecycle status. `queued` is projected from the async queue before the durable execution log exists; `paused` is set when a row exists in pausedExecutions with status `paused` or `partially_resumed`; otherwise the workflowExecutionLogs row's status field is used.", "example": "completed" }, "trigger": { diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts index ddc55bf4df9..9ba0d765a7e 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts @@ -9,6 +9,7 @@ const { mockGetCurrentPayer, mockGetPauseContextDetail, mockGetPausedExecutionDetail, + mockEnqueueResume, mockPreprocessExecution, mockValidateWorkflowAccess, } = vi.hoisted(() => ({ @@ -16,6 +17,7 @@ const { mockGetCurrentPayer: vi.fn(), mockGetPauseContextDetail: vi.fn(), mockGetPausedExecutionDetail: vi.fn(), + mockEnqueueResume: vi.fn().mockResolvedValue('resume-execution:resume-execution-1'), mockPreprocessExecution: vi.fn(), mockValidateWorkflowAccess: vi.fn(), })) @@ -28,6 +30,14 @@ vi.mock('@/lib/execution/preprocessing', () => ({ preprocessExecution: mockPreprocessExecution, })) +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: vi.fn().mockResolvedValue({ enqueue: mockEnqueueResume }), +})) + +vi.mock('@/lib/workflows/executor/enqueue-execution', () => ({ + RESUME_EXECUTION_JOB_ID_PREFIX: 'resume-execution:', +})) + vi.mock('@sim/utils/id', () => ({ generateId: () => 'resume-preflight-1', })) @@ -48,6 +58,7 @@ vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => ({ })) import { GET, POST } from '@/app/api/resume/[workflowId]/[executionId]/[contextId]/route' +import { handleResumeExecution } from '@/app/api/resume/resume-handler' const WORKFLOW_ID = 'workflow-1' const EXECUTION_ID = 'execution-1' @@ -84,6 +95,7 @@ interface PausedExecutionOverrides { snapshotWorkspaceId?: string snapshotActorUserId?: string billingAttribution?: unknown + executionMode?: 'sync' | 'stream' | 'async' } function createPausedExecution(overrides: PausedExecutionOverrides = {}) { @@ -108,7 +120,7 @@ function createPausedExecution(overrides: PausedExecutionOverrides = {}) { triggerType: 'manual', useDraftState: false, startTime: '2026-07-10T00:00:00.000Z', - executionMode: 'sync', + executionMode: overrides.executionMode ?? 'sync', }, workflow: { version: '1', blocks: [], connections: [] }, input: {}, @@ -229,6 +241,87 @@ describe('POST /api/resume/[workflowId]/[executionId]/[contextId]', () => { }) }) + it('preserves the legacy async job polling response', async () => { + mockGetPausedExecutionDetail.mockResolvedValueOnce( + createPausedExecution({ executionMode: 'async' }) + ) + mockEnqueueOrStartResume.mockResolvedValueOnce({ + status: 'started', + resumeExecutionId: 'resume-execution-1', + resumeEntryId: 'resume-entry-1', + pausedExecution: { id: 'paused-execution-1' }, + contextId: CONTEXT_ID, + resumeInput: { approved: true }, + userId: 'current-api-key-user', + }) + const { request, context } = makeRequest() + + const response = await POST(request, context) + + expect(response.status).toBe(202) + await expect(response.json()).resolves.toEqual({ + success: true, + async: true, + jobId: 'resume-execution:resume-execution-1', + executionId: 'resume-execution-1', + message: 'Resume execution queued', + statusUrl: 'https://test.sim.ai/api/jobs/resume-execution:resume-execution-1', + }) + expect(mockEnqueueResume).toHaveBeenCalledWith( + 'resume-execution', + expect.objectContaining({ resumeExecutionId: 'resume-execution-1' }), + expect.objectContaining({ + metadata: expect.objectContaining({ workflowId: WORKFLOW_ID }), + }) + ) + expect(mockEnqueueResume.mock.calls[0]?.[2]).not.toHaveProperty('jobId') + }) + + it('uses deterministic dispatch and execution polling for the v2 surface', async () => { + mockGetPausedExecutionDetail.mockResolvedValueOnce( + createPausedExecution({ executionMode: 'async' }) + ) + mockEnqueueOrStartResume.mockResolvedValueOnce({ + status: 'started', + resumeExecutionId: 'resume-execution-1', + resumeEntryId: 'resume-entry-1', + pausedExecution: { id: 'paused-execution-1' }, + contextId: CONTEXT_ID, + resumeInput: { approved: true }, + userId: 'current-api-key-user', + }) + const { request } = makeRequest() + + const response = await handleResumeExecution({ + request, + workflowId: WORKFLOW_ID, + executionId: EXECUTION_ID, + contextId: CONTEXT_ID, + workspaceId: WORKSPACE_ID, + userId: 'current-api-key-user', + resumeInput: { approved: true }, + isApiCaller: true, + pollingSurface: 'v2', + }) + + expect(response.status).toBe(202) + await expect(response.json()).resolves.toEqual({ + success: true, + async: true, + executionId: 'resume-execution-1', + message: 'Resume execution queued', + statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/executions/resume-execution-1', + }) + expect(mockEnqueueResume).toHaveBeenCalledWith( + 'resume-execution', + expect.objectContaining({ resumeExecutionId: 'resume-execution-1' }), + expect.objectContaining({ + jobId: 'resume-execution:resume-entry-1', + metadata: expect.objectContaining({ workflowId: WORKFLOW_ID }), + }) + ) + }) + it.each([ { statusCode: 402, message: 'Member usage limit reached', retryable: false }, { statusCode: 429, message: 'Target concurrency full', retryable: true }, diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts index fc87a1237da..80af4959ff9 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -1,106 +1,19 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { isRecordLike } from '@sim/utils/object' -import { type NextRequest, NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' import { getPauseContextDetailContract, resumeWorkflowExecutionContextContract, } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { AuthType } from '@/lib/auth/hybrid' -import { - assertBillingAttributionSnapshot, - type BillingAttributionSnapshot, -} from '@/lib/billing/core/billing-attribution' -import { getJobQueue } from '@/lib/core/async-jobs' -import { generateRequestId } from '@/lib/core/utils/request' -import { SSE_HEADERS } from '@/lib/core/utils/sse' -import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { preprocessExecution } from '@/lib/execution/preprocessing' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' -import { - agentStreamProtocolResponseHeaders, - createStreamingResponse, -} from '@/lib/workflows/streaming/streaming' +import { handleResumeExecution } from '@/app/api/resume/resume-handler' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' -import type { ResumeExecutionPayload } from '@/background/resume-execution' -import { ExecutionSnapshot } from '@/executor/execution/snapshot' - -const logger = createLogger('WorkflowResumeAPI') export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -const INVALID_PAUSED_SNAPSHOT_ERROR = 'Paused execution snapshot is invalid' -const INVALID_PAUSED_ATTRIBUTION_ERROR = - 'Paused execution billing attribution is missing or invalid' -const PAUSED_EXECUTION_BINDING_ERROR = - 'Paused execution snapshot does not match the requested workflow or execution' -const PAUSED_ATTRIBUTION_BINDING_ERROR = - 'Paused execution billing attribution does not match its workspace or actor' - -interface PausedExecutionSnapshotSource { - workflowId: string - executionId: string - executionSnapshot: unknown -} - -interface PausedExecutionSnapshotBinding { - snapshot: ExecutionSnapshot - billingAttribution: BillingAttributionSnapshot -} - -function loadPausedExecutionSnapshot( - pausedExecution: PausedExecutionSnapshotSource, - expected: { workflowId: string; executionId: string; workspaceId: string } -): PausedExecutionSnapshotBinding { - if ( - !isRecordLike(pausedExecution.executionSnapshot) || - typeof pausedExecution.executionSnapshot.snapshot !== 'string' - ) { - throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) - } - - let snapshot: ExecutionSnapshot - try { - snapshot = ExecutionSnapshot.fromJSON(pausedExecution.executionSnapshot.snapshot) - } catch { - throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) - } - - if (!isRecordLike(snapshot.metadata)) { - throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) - } - - let billingAttribution: BillingAttributionSnapshot - try { - billingAttribution = assertBillingAttributionSnapshot(snapshot.metadata.billingAttribution) - } catch { - throw new Error(INVALID_PAUSED_ATTRIBUTION_ERROR) - } - - if ( - pausedExecution.workflowId !== expected.workflowId || - pausedExecution.executionId !== expected.executionId || - snapshot.metadata.workflowId !== expected.workflowId || - snapshot.metadata.executionId !== expected.executionId - ) { - throw new Error(PAUSED_EXECUTION_BINDING_ERROR) - } - - if ( - snapshot.metadata.workspaceId !== expected.workspaceId || - billingAttribution.workspaceId !== expected.workspaceId || - snapshot.metadata.userId !== billingAttribution.actorUserId - ) { - throw new Error(PAUSED_ATTRIBUTION_BINDING_ERROR) - } - - return { snapshot, billingAttribution } -} - export const POST = withRouteHandler( async ( request: NextRequest, @@ -117,11 +30,9 @@ export const POST = withRouteHandler( const parsed = await parseRequest(resumeWorkflowExecutionContextContract, request, context) if (!parsed.success) return parsed.response const { workflowId, executionId, contextId } = parsed.data.params - const requestId = generateRequestId() const workflow = access.workflow if (!workflow?.workspaceId) { - logger.error(`[${requestId}] Authorized workflow has no workspace`, { workflowId }) return NextResponse.json({ error: 'Workflow has no associated workspace' }, { status: 500 }) } const userId = access.auth?.userId @@ -129,269 +40,28 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const pausedExecution = await PauseResumeManager.getPausedExecutionDetail({ - workflowId, - executionId, - }) - if (!pausedExecution) { - return NextResponse.json({ error: 'Paused execution not found' }, { status: 404 }) - } - - let snapshotBinding: PausedExecutionSnapshotBinding - try { - snapshotBinding = loadPausedExecutionSnapshot(pausedExecution, { - workflowId, - executionId, - workspaceId: workflow.workspaceId, - }) - } catch (error) { - const message = toError(error).message - logger.error(`[${requestId}] Failed to validate paused execution snapshot`, { - workflowId, - executionId, - error: message, - }) - return NextResponse.json({ error: message }, { status: 500 }) - } - - const { snapshot: persistedSnapshot, billingAttribution } = snapshotBinding - let payload: unknown = {} try { payload = await request.json() } catch { payload = {} } - const resumeInput = typeof payload === 'object' && payload !== null && 'input' in payload ? payload.input : (payload ?? {}) - const resumeExecutionId = generateId() - logger.info(`[${requestId}] Preprocessing resume execution`, { + return handleResumeExecution({ + request, workflowId, - parentExecutionId: executionId, - resumeExecutionId, - userId, - actorUserId: billingAttribution.actorUserId, - }) - - /** - * This preflight gives synchronous callers current block/usage feedback - * without reserving under a throwaway id. The claimed resume reruns every - * gate and reserves atomically under its persisted resume execution id. - */ - const preprocessResult = await preprocessExecution({ - workflowId, - userId, - triggerType: 'manual', - executionId: resumeExecutionId, - requestId, - checkRateLimit: false, - checkDeployment: false, - skipConcurrencyReservation: true, - logPreprocessingErrors: false, + executionId, + contextId, workspaceId: workflow.workspaceId, - billingAttribution, - }) - - if (!preprocessResult.success) { - logger.warn(`[${requestId}] Preprocessing failed for resume`, { - workflowId, - parentExecutionId: executionId, - error: preprocessResult.error?.message, - statusCode: preprocessResult.error?.statusCode, - }) - - return NextResponse.json( - { - error: - preprocessResult.error?.message || - 'Failed to validate resume execution. Please try again.', - }, - { status: preprocessResult.error?.statusCode || 400 } - ) - } - - logger.info(`[${requestId}] Preprocessing passed, proceeding with resume`, { - workflowId, - parentExecutionId: executionId, - resumeExecutionId, - actorUserId: preprocessResult.actorUserId, + userId, + resumeInput, + isApiCaller: access.auth?.authType === AuthType.API_KEY, + pollingSurface: 'legacy', }) - - try { - const enqueueResult = await PauseResumeManager.enqueueOrStartResume({ - executionId, - workflowId, - contextId, - resumeInput, - userId, - allowedPauseKinds: ['human'], - }) - - if (enqueueResult.status === 'queued') { - return NextResponse.json({ - status: 'queued', - executionId: enqueueResult.resumeExecutionId, - queuePosition: enqueueResult.queuePosition, - message: 'Resume queued. It will run after current resumes finish.', - }) - } - - const resumeArgs = { - resumeEntryId: enqueueResult.resumeEntryId, - resumeExecutionId: enqueueResult.resumeExecutionId, - pausedExecution: enqueueResult.pausedExecution, - contextId: enqueueResult.contextId, - resumeInput: enqueueResult.resumeInput, - userId: enqueueResult.userId, - } - - const isApiCaller = access.auth?.authType === AuthType.API_KEY - const executionMode = isApiCaller - ? (persistedSnapshot.metadata.executionMode ?? 'sync') - : undefined - const includeThinking = persistedSnapshot.metadata.includeThinking === true - const includeToolCalls = persistedSnapshot.metadata.includeToolCalls === true - - if (isApiCaller && executionMode === 'stream') { - const stream = await createStreamingResponse({ - requestId, - streamConfig: { - selectedOutputs: persistedSnapshot.selectedOutputs, - timeoutMs: preprocessResult.executionTimeout?.sync, - includeThinking, - includeToolCalls, - }, - executionId: enqueueResult.resumeExecutionId, - workspaceId: workflow.workspaceId || undefined, - workflowId, - userId: enqueueResult.userId, - allowLargeValueWorkflowScope: true, - requestSignal: request.signal, - requestHeaders: request.headers, - executeFn: async ({ onStream, onBlockComplete, abortSignal }) => - PauseResumeManager.startResumeExecution({ - ...resumeArgs, - onStream, - onBlockComplete, - abortSignal, - }), - }) - - return new NextResponse(stream, { - headers: { - ...SSE_HEADERS, - // Echo the negotiated stream protocol (same as the public chat route). - ...agentStreamProtocolResponseHeaders({ requestHeaders: request.headers }), - 'X-Execution-Id': enqueueResult.resumeExecutionId, - }, - }) - } - - if (isApiCaller && executionMode === 'sync') { - const result = await PauseResumeManager.startResumeExecution(resumeArgs) - - return NextResponse.json({ - success: result.success, - status: result.status ?? (result.success ? 'completed' : 'failed'), - executionId: enqueueResult.resumeExecutionId, - output: result.output, - error: result.error, - metadata: result.metadata - ? { - duration: result.metadata.duration, - startTime: result.metadata.startTime, - endTime: result.metadata.endTime, - } - : undefined, - }) - } - - if (isApiCaller && executionMode === 'async') { - const resumePayload: ResumeExecutionPayload = { - resumeEntryId: enqueueResult.resumeEntryId, - resumeExecutionId: enqueueResult.resumeExecutionId, - pausedExecutionId: enqueueResult.pausedExecution.id, - contextId: enqueueResult.contextId, - resumeInput: enqueueResult.resumeInput, - userId: enqueueResult.userId, - workflowId, - parentExecutionId: executionId, - } - - let jobId: string - try { - const jobQueue = await getJobQueue() - jobId = await jobQueue.enqueue('resume-execution', resumePayload, { - metadata: { workflowId, workspaceId: workflow.workspaceId, userId }, - }) - logger.info('Enqueued async resume execution', { - jobId, - resumeExecutionId: enqueueResult.resumeExecutionId, - }) - } catch (dispatchError) { - logger.error('Failed to dispatch async resume execution', { - error: toError(dispatchError).message, - resumeExecutionId: enqueueResult.resumeExecutionId, - }) - await PauseResumeManager.markResumeAttemptFailed({ - resumeEntryId: enqueueResult.resumeEntryId, - pausedExecutionId: enqueueResult.pausedExecution.id, - parentExecutionId: executionId, - contextId: enqueueResult.contextId, - failureReason: 'Failed to queue async resume execution', - }) - await PauseResumeManager.processQueuedResumes(executionId, workflowId) - return NextResponse.json( - { error: 'Failed to queue resume execution. Please try again.' }, - { status: 503 } - ) - } - - return NextResponse.json( - { - success: true, - async: true, - jobId, - executionId: enqueueResult.resumeExecutionId, - message: 'Resume execution queued', - statusUrl: `${getBaseUrl()}/api/jobs/${jobId}`, - }, - { status: 202 } - ) - } - - PauseResumeManager.startResumeExecution(resumeArgs).catch((error) => { - logger.error('Failed to start resume execution', { - workflowId, - parentExecutionId: executionId, - resumeExecutionId: enqueueResult.resumeExecutionId, - error, - }) - }) - - return NextResponse.json({ - status: 'started', - executionId: enqueueResult.resumeExecutionId, - message: 'Resume execution started.', - }) - } catch (error) { - logger.error('Resume request failed', { - workflowId, - executionId, - contextId, - error, - }) - const statusCode = - isRecordLike(error) && typeof error.statusCode === 'number' ? error.statusCode : 400 - return NextResponse.json( - { error: toError(error).message || 'Failed to queue resume request' }, - { status: statusCode } - ) - } } ) diff --git a/apps/sim/app/api/resume/resume-handler.ts b/apps/sim/app/api/resume/resume-handler.ts new file mode 100644 index 00000000000..ad148ff3d74 --- /dev/null +++ b/apps/sim/app/api/resume/resume-handler.ts @@ -0,0 +1,374 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { type NextRequest, NextResponse } from 'next/server' +import { + assertBillingAttributionSnapshot, + type BillingAttributionSnapshot, +} from '@/lib/billing/core/billing-attribution' +import { getJobQueue } from '@/lib/core/async-jobs' +import { generateRequestId } from '@/lib/core/utils/request' +import { SSE_HEADERS } from '@/lib/core/utils/sse' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { preprocessExecution } from '@/lib/execution/preprocessing' +import { RESUME_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/enqueue-execution' +import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' +import { + agentStreamProtocolResponseHeaders, + createStreamingResponse, +} from '@/lib/workflows/streaming/streaming' +import type { ResumeExecutionPayload } from '@/background/resume-execution' +import { ExecutionSnapshot } from '@/executor/execution/snapshot' + +const logger = createLogger('WorkflowResumeAPI') + +const INVALID_PAUSED_SNAPSHOT_ERROR = 'Paused execution snapshot is invalid' +const INVALID_PAUSED_ATTRIBUTION_ERROR = + 'Paused execution billing attribution is missing or invalid' +const PAUSED_EXECUTION_BINDING_ERROR = + 'Paused execution snapshot does not match the requested workflow or execution' +const PAUSED_ATTRIBUTION_BINDING_ERROR = + 'Paused execution billing attribution does not match its workspace or actor' + +interface PausedExecutionSnapshotSource { + workflowId: string + executionId: string + executionSnapshot: unknown +} + +interface PausedExecutionSnapshotBinding { + snapshot: ExecutionSnapshot + billingAttribution: BillingAttributionSnapshot +} + +interface HandleResumeExecutionOptions { + request: NextRequest + workflowId: string + executionId: string + contextId: string + workspaceId: string + userId: string + resumeInput: unknown + isApiCaller: boolean + pollingSurface: 'legacy' | 'v2' +} + +function loadPausedExecutionSnapshot( + pausedExecution: PausedExecutionSnapshotSource, + expected: { workflowId: string; executionId: string; workspaceId: string } +): PausedExecutionSnapshotBinding { + if ( + !isRecordLike(pausedExecution.executionSnapshot) || + typeof pausedExecution.executionSnapshot.snapshot !== 'string' + ) { + throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) + } + + let snapshot: ExecutionSnapshot + try { + snapshot = ExecutionSnapshot.fromJSON(pausedExecution.executionSnapshot.snapshot) + } catch { + throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) + } + + if (!isRecordLike(snapshot.metadata)) { + throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) + } + + let billingAttribution: BillingAttributionSnapshot + try { + billingAttribution = assertBillingAttributionSnapshot(snapshot.metadata.billingAttribution) + } catch { + throw new Error(INVALID_PAUSED_ATTRIBUTION_ERROR) + } + + if ( + pausedExecution.workflowId !== expected.workflowId || + pausedExecution.executionId !== expected.executionId || + snapshot.metadata.workflowId !== expected.workflowId || + snapshot.metadata.executionId !== expected.executionId + ) { + throw new Error(PAUSED_EXECUTION_BINDING_ERROR) + } + + if ( + snapshot.metadata.workspaceId !== expected.workspaceId || + billingAttribution.workspaceId !== expected.workspaceId || + snapshot.metadata.userId !== billingAttribution.actorUserId + ) { + throw new Error(PAUSED_ATTRIBUTION_BINDING_ERROR) + } + + return { snapshot, billingAttribution } +} + +/** Executes the shared resume flow while preserving each API surface's polling contract. */ +export async function handleResumeExecution({ + request, + workflowId, + executionId, + contextId, + workspaceId, + userId, + resumeInput, + isApiCaller, + pollingSurface, +}: HandleResumeExecutionOptions): Promise { + const requestId = generateRequestId() + const pausedExecution = await PauseResumeManager.getPausedExecutionDetail({ + workflowId, + executionId, + }) + if (!pausedExecution) { + return NextResponse.json({ error: 'Paused execution not found' }, { status: 404 }) + } + + let snapshotBinding: PausedExecutionSnapshotBinding + try { + snapshotBinding = loadPausedExecutionSnapshot(pausedExecution, { + workflowId, + executionId, + workspaceId, + }) + } catch (error) { + const message = toError(error).message + logger.error(`[${requestId}] Failed to validate paused execution snapshot`, { + workflowId, + executionId, + error: message, + }) + return NextResponse.json({ error: message }, { status: 500 }) + } + + const { snapshot: persistedSnapshot, billingAttribution } = snapshotBinding + const resumeExecutionId = generateId() + + logger.info(`[${requestId}] Preprocessing resume execution`, { + workflowId, + parentExecutionId: executionId, + resumeExecutionId, + userId, + actorUserId: billingAttribution.actorUserId, + }) + + /** + * This preflight gives synchronous callers current block/usage feedback + * without reserving under a throwaway id. The claimed resume reruns every + * gate and reserves atomically under its persisted resume execution id. + */ + const preprocessResult = await preprocessExecution({ + workflowId, + userId, + triggerType: 'manual', + executionId: resumeExecutionId, + requestId, + checkRateLimit: false, + checkDeployment: false, + skipConcurrencyReservation: true, + logPreprocessingErrors: false, + workspaceId, + billingAttribution, + }) + + if (!preprocessResult.success) { + logger.warn(`[${requestId}] Preprocessing failed for resume`, { + workflowId, + parentExecutionId: executionId, + error: preprocessResult.error?.message, + statusCode: preprocessResult.error?.statusCode, + }) + + return NextResponse.json( + { + error: + preprocessResult.error?.message || + 'Failed to validate resume execution. Please try again.', + }, + { status: preprocessResult.error?.statusCode || 400 } + ) + } + + logger.info(`[${requestId}] Preprocessing passed, proceeding with resume`, { + workflowId, + parentExecutionId: executionId, + resumeExecutionId, + actorUserId: preprocessResult.actorUserId, + }) + + try { + const enqueueResult = await PauseResumeManager.enqueueOrStartResume({ + executionId, + workflowId, + contextId, + resumeInput, + userId, + allowedPauseKinds: ['human'], + }) + + if (enqueueResult.status === 'queued') { + return NextResponse.json({ + status: 'queued', + executionId: enqueueResult.resumeExecutionId, + queuePosition: enqueueResult.queuePosition, + message: 'Resume queued. It will run after current resumes finish.', + }) + } + + const resumeArgs = { + resumeEntryId: enqueueResult.resumeEntryId, + resumeExecutionId: enqueueResult.resumeExecutionId, + pausedExecution: enqueueResult.pausedExecution, + contextId: enqueueResult.contextId, + resumeInput: enqueueResult.resumeInput, + userId: enqueueResult.userId, + } + + const executionMode = isApiCaller + ? (persistedSnapshot.metadata.executionMode ?? 'sync') + : undefined + const includeThinking = persistedSnapshot.metadata.includeThinking === true + const includeToolCalls = persistedSnapshot.metadata.includeToolCalls === true + + if (isApiCaller && executionMode === 'stream') { + const stream = await createStreamingResponse({ + requestId, + streamConfig: { + selectedOutputs: persistedSnapshot.selectedOutputs, + timeoutMs: preprocessResult.executionTimeout?.sync, + includeThinking, + includeToolCalls, + }, + executionId: enqueueResult.resumeExecutionId, + workspaceId, + workflowId, + userId: enqueueResult.userId, + allowLargeValueWorkflowScope: true, + requestSignal: request.signal, + requestHeaders: request.headers, + executeFn: async ({ onStream, onBlockComplete, abortSignal }) => + PauseResumeManager.startResumeExecution({ + ...resumeArgs, + onStream, + onBlockComplete, + abortSignal, + }), + }) + + return new NextResponse(stream, { + headers: { + ...SSE_HEADERS, + ...agentStreamProtocolResponseHeaders({ requestHeaders: request.headers }), + 'X-Execution-Id': enqueueResult.resumeExecutionId, + }, + }) + } + + if (isApiCaller && executionMode === 'sync') { + const result = await PauseResumeManager.startResumeExecution(resumeArgs) + + return NextResponse.json({ + success: result.success, + status: result.status ?? (result.success ? 'completed' : 'failed'), + executionId: enqueueResult.resumeExecutionId, + output: result.output, + error: result.error, + metadata: result.metadata + ? { + duration: result.metadata.duration, + startTime: result.metadata.startTime, + endTime: result.metadata.endTime, + } + : undefined, + }) + } + + if (isApiCaller && executionMode === 'async') { + const resumePayload: ResumeExecutionPayload = { + resumeEntryId: enqueueResult.resumeEntryId, + resumeExecutionId: enqueueResult.resumeExecutionId, + pausedExecutionId: enqueueResult.pausedExecution.id, + contextId: enqueueResult.contextId, + resumeInput: enqueueResult.resumeInput, + userId: enqueueResult.userId, + workflowId, + parentExecutionId: executionId, + } + + let jobId: string + try { + const jobQueue = await getJobQueue() + jobId = await jobQueue.enqueue('resume-execution', resumePayload, { + ...(pollingSurface === 'v2' + ? { jobId: `${RESUME_EXECUTION_JOB_ID_PREFIX}${enqueueResult.resumeEntryId}` } + : {}), + metadata: { workflowId, workspaceId, userId }, + }) + logger.info('Enqueued async resume execution', { + jobId, + resumeExecutionId: enqueueResult.resumeExecutionId, + }) + } catch (dispatchError) { + logger.error('Failed to dispatch async resume execution', { + error: toError(dispatchError).message, + resumeExecutionId: enqueueResult.resumeExecutionId, + }) + await PauseResumeManager.markResumeAttemptFailed({ + resumeEntryId: enqueueResult.resumeEntryId, + pausedExecutionId: enqueueResult.pausedExecution.id, + parentExecutionId: executionId, + contextId: enqueueResult.contextId, + failureReason: 'Failed to queue async resume execution', + }) + await PauseResumeManager.processQueuedResumes(executionId, workflowId) + return NextResponse.json( + { error: 'Failed to queue resume execution. Please try again.' }, + { status: 503 } + ) + } + + return NextResponse.json( + { + success: true, + async: true, + ...(pollingSurface === 'legacy' ? { jobId } : {}), + executionId: enqueueResult.resumeExecutionId, + message: 'Resume execution queued', + statusUrl: + pollingSurface === 'legacy' + ? `${getBaseUrl()}/api/jobs/${jobId}` + : `${getBaseUrl()}/api/v2/workflows/${workflowId}/executions/${enqueueResult.resumeExecutionId}`, + }, + { status: 202 } + ) + } + + PauseResumeManager.startResumeExecution(resumeArgs).catch((error) => { + logger.error('Failed to start resume execution', { + workflowId, + parentExecutionId: executionId, + resumeExecutionId: enqueueResult.resumeExecutionId, + error, + }) + }) + + return NextResponse.json({ + status: 'started', + executionId: enqueueResult.resumeExecutionId, + message: 'Resume execution started.', + }) + } catch (error) { + logger.error('Resume request failed', { + workflowId, + executionId, + contextId, + error, + }) + const statusCode = + isRecordLike(error) && typeof error.statusCode === 'number' ? error.statusCode : 400 + return NextResponse.json( + { error: toError(error).message || 'Failed to queue resume request' }, + { status: statusCode } + ) + } +} diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.test.ts new file mode 100644 index 00000000000..5e148d3db93 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.test.ts @@ -0,0 +1,170 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockHandleResumeExecution, mockResolveV2WorkflowAccess } = vi.hoisted(() => ({ + mockHandleResumeExecution: vi.fn(), + mockResolveV2WorkflowAccess: vi.fn(), +})) + +vi.mock('@/app/api/resume/resume-handler', () => ({ + handleResumeExecution: mockHandleResumeExecution, +})) + +vi.mock('@/app/api/v2/workflows/lib/access', () => ({ + resolveV2WorkflowAccess: mockResolveV2WorkflowAccess, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://test.sim.ai', +})) + +import { POST } from '@/app/api/v2/workflows/[id]/executions/[executionId]/resume/route' + +const WORKFLOW_ID = 'workflow-1' +const EXECUTION_ID = 'execution-1' + +function makeRequest(body: string) { + return { + request: new NextRequest( + `http://localhost/api/v2/workflows/${WORKFLOW_ID}/executions/${EXECUTION_ID}/resume`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body, + } + ), + context: { params: Promise.resolve({ id: WORKFLOW_ID, executionId: EXECUTION_ID }) }, + } +} + +describe('POST /api/v2/workflows/[id]/executions/[executionId]/resume', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveV2WorkflowAccess.mockResolvedValue({ + ok: true, + userId: 'user-1', + keyType: 'workspace', + workflow: { id: WORKFLOW_ID, workspaceId: 'workspace-1' }, + }) + }) + + it('authenticates before parsing the request body', async () => { + mockResolveV2WorkflowAccess.mockResolvedValueOnce({ + ok: false, + response: NextResponse.json( + { error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } }, + { status: 401 } + ), + }) + const { request, context } = makeRequest('{') + + const response = await POST(request, context) + + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + error: { code: 'UNAUTHORIZED', message: 'Unauthorized' }, + }) + expect(mockResolveV2WorkflowAccess).toHaveBeenCalledWith(request, WORKFLOW_ID, 'write') + expect(mockHandleResumeExecution).not.toHaveBeenCalled() + }) + + it('resumes a pause context through the execution-scoped v2 endpoint', async () => { + mockHandleResumeExecution.mockResolvedValueOnce( + NextResponse.json( + { + success: true, + async: true, + executionId: 'resume-execution-1', + message: 'Resume execution queued', + statusUrl: + 'https://test.sim.ai/api/v2/workflows/workflow-1/executions/resume-execution-1', + }, + { status: 202 } + ) + ) + const { request, context } = makeRequest( + JSON.stringify({ contextId: 'context-1', input: { approved: true } }) + ) + + const response = await POST(request, context) + + expect(response.status).toBe(202) + expect(response.headers.get('X-Execution-Id')).toBe('resume-execution-1') + expect(await response.json()).toEqual({ + data: { + executionId: 'resume-execution-1', + statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/executions/resume-execution-1', + }, + }) + expect(mockHandleResumeExecution).toHaveBeenCalledWith({ + request, + workflowId: WORKFLOW_ID, + executionId: EXECUTION_ID, + contextId: 'context-1', + workspaceId: 'workspace-1', + userId: 'user-1', + resumeInput: { approved: true }, + isApiCaller: true, + pollingSurface: 'v2', + }) + }) + + it('returns queued resumes as a v2 polling receipt', async () => { + mockHandleResumeExecution.mockResolvedValueOnce( + NextResponse.json({ + status: 'queued', + executionId: 'resume-execution-2', + queuePosition: 2, + message: 'Resume queued. It will run after current resumes finish.', + }) + ) + const { request, context } = makeRequest(JSON.stringify({ contextId: 'context-2' })) + + const response = await POST(request, context) + + expect(response.status).toBe(202) + expect(await response.json()).toEqual({ + data: { + executionId: 'resume-execution-2', + statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/executions/resume-execution-2', + queuePosition: 2, + }, + }) + }) + + it('wraps synchronous resume results in the canonical v2 execution shape', async () => { + mockHandleResumeExecution.mockResolvedValueOnce( + NextResponse.json({ + success: true, + status: 'completed', + executionId: 'resume-execution-3', + output: { approved: true }, + metadata: { + startTime: '2026-08-05T00:00:00.000Z', + endTime: '2026-08-05T00:00:01.000Z', + duration: 1000, + }, + }) + ) + const { request, context } = makeRequest(JSON.stringify({ contextId: 'context-3' })) + + const response = await POST(request, context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + executionId: 'resume-execution-3', + workflowId: WORKFLOW_ID, + status: 'completed', + output: { approved: true }, + error: null, + startedAt: '2026-08-05T00:00:00.000Z', + endedAt: '2026-08-05T00:00:01.000Z', + durationMs: 1000, + }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.ts new file mode 100644 index 00000000000..e0afaec0393 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.ts @@ -0,0 +1,147 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import type { NextRequest } from 'next/server' +import { v2ResumeWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { WORKFLOW_EXECUTION_ID_HEADER } from '@/lib/api/contracts/workflows' +import { parseRequest } from '@/lib/api/server' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { handleResumeExecution } from '@/app/api/resume/resume-handler' +import { type V2ErrorCode, v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' +import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' +import { classifyExecutionError } from '@/executor/utils/errors' + +const logger = createLogger('V2WorkflowResumeAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const ERROR_CODE_BY_STATUS: Record = { + 400: 'BAD_REQUEST', + 401: 'UNAUTHORIZED', + 402: 'USAGE_LIMIT_EXCEEDED', + 403: 'FORBIDDEN', + 404: 'NOT_FOUND', + 409: 'CONFLICT', + 413: 'PAYLOAD_TOO_LARGE', + 423: 'LOCKED', + 429: 'RATE_LIMITED', + 503: 'SERVICE_UNAVAILABLE', +} + +const TERMINAL_RESUME_STATUSES = new Set(['completed', 'failed', 'paused', 'cancelled']) + +function errorMessage(payload: Record): string { + return typeof payload.error === 'string' ? payload.error : 'Resume execution failed' +} + +/** + * POST /api/v2/workflows/[id]/executions/[executionId]/resume resumes one pause + * context on the parent execution. The new resume attempt gets its own + * execution ID, which is the only polling handle exposed by v2. + */ +export const POST = withRouteHandler( + async ( + request: NextRequest, + context: { params: Promise<{ id: string; executionId: string }> } + ) => { + const { id: workflowId } = await context.params + const access = await resolveV2WorkflowAccess(request, workflowId, 'write') + if (!access.ok) return access.response + + const parsed = await parseRequest(v2ResumeWorkflowContract, request, context, { + maxBodyBytes: 10 * 1024 * 1024, + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { executionId } = parsed.data.params + const { contextId, input } = parsed.data.body + + if (!access.workflow.workspaceId) { + return v2Error('INTERNAL_ERROR', 'Workflow has no associated workspace') + } + + try { + const response = await handleResumeExecution({ + request, + workflowId, + executionId, + contextId, + workspaceId: access.workflow.workspaceId, + userId: access.userId, + resumeInput: input === undefined ? {} : input, + isApiCaller: true, + pollingSurface: 'v2', + }) + + if (response.headers.get('Content-Type')?.startsWith('text/event-stream')) { + return response + } + + const payload: unknown = await response.json() + if (!isRecordLike(payload)) { + return v2Error('INTERNAL_ERROR', 'Resume execution returned an invalid response') + } + + if (!response.ok) { + return v2Error( + ERROR_CODE_BY_STATUS[response.status] ?? 'INTERNAL_ERROR', + errorMessage(payload), + { status: response.status } + ) + } + + if (typeof payload.executionId !== 'string') { + return v2Error('INTERNAL_ERROR', 'Resume execution did not return an execution ID') + } + + const statusUrl = `${getBaseUrl()}/api/v2/workflows/${workflowId}/executions/${payload.executionId}` + const headers = { [WORKFLOW_EXECUTION_ID_HEADER]: payload.executionId } + + if (response.status === 202 || payload.status === 'queued') { + return v2Data( + { + executionId: payload.executionId, + statusUrl, + ...(typeof payload.queuePosition === 'number' + ? { queuePosition: payload.queuePosition } + : {}), + }, + { status: 202, headers } + ) + } + + if (typeof payload.status !== 'string' || !TERMINAL_RESUME_STATUSES.has(payload.status)) { + return v2Error('INTERNAL_ERROR', 'Resume execution returned an invalid status') + } + + const metadata = isRecordLike(payload.metadata) ? payload.metadata : undefined + return v2Data( + { + executionId: payload.executionId, + workflowId, + status: payload.status as 'completed' | 'failed' | 'paused' | 'cancelled', + output: payload.output ?? null, + error: + typeof payload.error === 'string' + ? classifyExecutionError(new Error(payload.error)) + : null, + startedAt: + metadata && typeof metadata.startTime === 'string' ? metadata.startTime : undefined, + endedAt: metadata && typeof metadata.endTime === 'string' ? metadata.endTime : undefined, + durationMs: + metadata && typeof metadata.duration === 'number' ? metadata.duration : undefined, + }, + { headers } + ) + } catch (error) { + logger.error('Failed to resume workflow execution', { + workflowId, + executionId, + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts index c0e96fc8080..bfd1a3896d8 100644 --- a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts @@ -4,13 +4,13 @@ import { createMockRequest, workflowAuthzMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAuthenticateV1Request, mockGetJob, mockGetWorkflowExecutionStatus, mockCancel } = - vi.hoisted(() => ({ +const { mockAuthenticateV1Request, mockGetWorkflowExecutionStatus, mockCancel } = vi.hoisted( + () => ({ mockAuthenticateV1Request: vi.fn(), - mockGetJob: vi.fn(), mockGetWorkflowExecutionStatus: vi.fn(), mockCancel: vi.fn(), - })) + }) +) vi.mock('@/app/api/v1/auth', () => ({ authenticateV1Request: mockAuthenticateV1Request, @@ -28,14 +28,6 @@ vi.mock('@/lib/execution/cancel-workflow-execution', () => ({ cancelWorkflowExecution: mockCancel, })) -vi.mock('@/lib/core/async-jobs', () => ({ - getJobQueue: vi.fn().mockResolvedValue({ getJob: mockGetJob }), -})) - -vi.mock('@/lib/workflows/executor/enqueue-execution', () => ({ - WORKFLOW_EXECUTION_JOB_ID_PREFIX: 'workflow-execution:', -})) - vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null), })) @@ -100,23 +92,31 @@ describe('v2 executions status + cancel', () => { expect(body.data.durationMs).toBe(5000) }) - it('backfills queued status from the job queue before the log row exists', async () => { - mockGetWorkflowExecutionStatus.mockResolvedValue(null) - mockGetJob.mockResolvedValue({ - status: 'pending', - metadata: { workflowId: 'workflow-1' }, + it('returns the queued execution resource before the log row exists', async () => { + mockGetWorkflowExecutionStatus.mockResolvedValue({ + executionId: 'exec-1', + workflowId: 'workflow-1', + status: 'queued', + trigger: 'api', + level: 'info', + startedAt: '2026-07-31T00:00:00.000Z', + endedAt: null, + totalDurationMs: null, + paused: null, + cost: null, + error: null, + finalOutput: null, + blockOutputs: null, }) const res = await callStatus() expect(res.status).toBe(200) expect((await res.json()).data.status).toBe('queued') - expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:exec-1') }) it('404s when neither a log row nor a matching job exists', async () => { mockGetWorkflowExecutionStatus.mockResolvedValue(null) - mockGetJob.mockResolvedValue(null) const res = await callStatus() diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts index d38da03a1b5..f32da31bfb3 100644 --- a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts @@ -1,18 +1,13 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { - type V2WorkflowExecutionStatus, - v2GetWorkflowExecutionContract, -} from '@/lib/api/contracts/v2/workflows' +import { v2GetWorkflowExecutionContract } from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' -import { getJobQueue } from '@/lib/core/async-jobs' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE, FunctionalOutputsUnavailableError, } from '@/lib/logs/execution/functional-outputs' -import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/enqueue-execution' import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' @@ -22,25 +17,6 @@ const logger = createLogger('V2WorkflowExecutionStatusAPI') export const dynamic = 'force-dynamic' -/** - * Maps the async job's phase onto the execution status enum for the window - * before the worker writes the durable log row. - */ -function jobStatusToExecutionStatus(jobStatus: string): V2WorkflowExecutionStatus['status'] | null { - switch (jobStatus) { - case 'pending': - return 'queued' - case 'processing': - return 'running' - case 'failed': - return 'failed' - case 'completed': - return 'completed' - default: - return null - } -} - /** * GET /api/v2/workflows/[id]/executions/[executionId] — the single status URL * for both sync and async runs. When no log row exists yet, the async job @@ -70,50 +46,23 @@ export const GET = withRouteHandler( selectedOutputs, }) - if (status) { - return v2Data({ - executionId: status.executionId, - workflowId: status.workflowId, - status: status.status, - trigger: status.trigger ?? null, - startedAt: status.startedAt, - endedAt: status.endedAt, - durationMs: status.totalDurationMs, - paused: status.paused, - cost: status.cost, - error: status.error ? classifyExecutionError(new Error(status.error)) : null, - output: status.finalOutput, - blockOutputs: status.blockOutputs, - }) - } - - // No log row yet — a queued/just-started async run. Backfilled from the - // job queue via the deterministic id; authz already ran above. - const jobQueue = await getJobQueue() - const job = await jobQueue.getJob(`${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${executionId}`) - const jobWorkflowId = - job?.metadata && typeof job.metadata === 'object' - ? (job.metadata as { workflowId?: string }).workflowId - : undefined - const mapped = job ? jobStatusToExecutionStatus(job.status) : null - if (!job || jobWorkflowId !== workflowId || !mapped) { + if (!status) { return v2Error('NOT_FOUND', 'Execution not found') } return v2Data({ - executionId, - workflowId, - status: mapped, - trigger: 'api', - startedAt: null, - endedAt: null, - durationMs: null, - paused: null, - cost: null, - error: - mapped === 'failed' && job.error ? classifyExecutionError(new Error(job.error)) : null, - output: null, - blockOutputs: null, + executionId: status.executionId, + workflowId: status.workflowId, + status: status.status, + trigger: status.trigger ?? null, + startedAt: status.startedAt, + endedAt: status.endedAt, + durationMs: status.totalDurationMs, + paused: status.paused, + cost: status.cost, + error: status.error ? classifyExecutionError(new Error(status.error)) : null, + output: status.finalOutput, + blockOutputs: status.blockOutputs, }) } catch (error) { if (error instanceof FunctionalOutputsUnavailableError) { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index 72d0f71e04d..f63a9e90b76 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -807,6 +807,7 @@ describe('workflow execute async route', () => { expect(response.status).toBe(202) expect(body.executionId).toBe('execution-123') expect(body.jobId).toBe('job-123') + expect(body.statusUrl).toBe('http://localhost:3000/api/jobs/job-123') expect(mockClaimExecutionId).toHaveBeenCalledWith('execution-123') expect(mockEnqueue).toHaveBeenCalledWith( 'workflow-execution', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx index a5950e82f60..aab37cbfa44 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx @@ -101,13 +101,9 @@ export function ApiDeploy({ const inputExample = getInputFormatExample ? getInputFormatExample(false) : '' const match = inputExample.match(/-d\s*'([\s\S]*)'/) if (match) { - try { - return JSON.parse(match[1]) as Record - } catch { - return { input: 'your data here' } - } + return JSON.parse(match[1]) as Record } - return { input: 'your data here' } + return { input: {} } } const getStreamPayloadObject = (): Record => { @@ -260,18 +256,23 @@ while (true) { const getAsyncCommand = (): string => { if (!info) return '' + if (info.isPublicApi) throw new Error('Async execution requires an API key') const endpoint = getBaseEndpoint() - const baseUrl = endpoint.split('/api/workflows/')[0] - const payload = getPayloadObject() - const isPublic = info.isPublicApi + const v2WorkflowPrefix = '/api/v2/workflows/' + if (!endpoint.includes(v2WorkflowPrefix) || !endpoint.endsWith('/execute')) { + throw new Error(`Invalid workflow execution endpoint: ${endpoint}`) + } + const baseUrl = endpoint.split(v2WorkflowPrefix)[0] + const statusEndpoint = `${endpoint.slice(0, -'/execute'.length)}/executions/EXECUTION_ID_FROM_EXECUTION` + const payload = { ...getPayloadObject(), async: true } switch (asyncExampleType) { case 'execute': switch (language) { case 'curl': return `curl -X POST \\ -${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\ - -H "X-Execution-Mode: async" \\ + -H "X-API-Key: $SIM_API_KEY" \\ + -H "Content-Type: application/json" \\ -d '${JSON.stringify(payload)}' \\ ${endpoint}` @@ -282,40 +283,40 @@ import requests response = requests.post( "${endpoint}", headers={ -${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json", - "X-Execution-Mode": "async" + "X-API-Key": os.environ.get("SIM_API_KEY"), + "Content-Type": "application/json", }, json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')} ) -job = response.json() -print(job) # Contains jobId and executionId` +execution = response.json()["data"] +print(execution)` case 'javascript': return `const response = await fetch("${endpoint}", { method: "POST", headers: { -${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json", - "X-Execution-Mode": "async" + "X-API-Key": process.env.SIM_API_KEY, + "Content-Type": "application/json", }, body: JSON.stringify(${JSON.stringify(payload)}) }); -const job = await response.json(); -console.log(job); // Contains jobId and executionId` +const { data: execution } = await response.json(); +console.log(execution);` case 'typescript': return `const response = await fetch("${endpoint}", { method: "POST", headers: { -${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json", - "X-Execution-Mode": "async" + "X-API-Key": process.env.SIM_API_KEY, + "Content-Type": "application/json", }, body: JSON.stringify(${JSON.stringify(payload)}) }); -const job: { jobId: string; executionId: string } = await response.json(); -console.log(job); // Contains jobId and executionId` +const { data: execution }: { data: { executionId: string; statusUrl: string } } = await response.json(); +console.log(execution);` default: return '' @@ -325,40 +326,41 @@ console.log(job); // Contains jobId and executionId` switch (language) { case 'curl': return `curl -H "X-API-Key: $SIM_API_KEY" \\ - ${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION` + "${statusEndpoint}?includeOutput=true"` case 'python': return `import os import requests response = requests.get( - "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION", + "${statusEndpoint}", + params={"includeOutput": "true"}, headers={"X-API-Key": os.environ.get("SIM_API_KEY")} ) -status = response.json() +status = response.json()["data"] print(status)` case 'javascript': return `const response = await fetch( - "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION", + "${statusEndpoint}?includeOutput=true", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const status = await response.json(); +const { data: status } = await response.json(); console.log(status);` case 'typescript': return `const response = await fetch( - "${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION", + "${statusEndpoint}?includeOutput=true", { headers: { "X-API-Key": process.env.SIM_API_KEY } } ); -const status: Record = await response.json(); +const { data: status }: { data: Record } = await response.json(); console.log(status);` default: @@ -417,13 +419,13 @@ console.log(limits);` const getAsyncExampleTitle = () => { switch (asyncExampleType) { case 'execute': - return 'Execute Job' + return 'Start Execution' case 'status': return 'Check Status' case 'rate-limits': return 'Usage Limits' default: - return 'Execute Job' + return 'Start Execution' } } @@ -537,49 +539,51 @@ console.log(limits);` /> -
-
- -
- - - - - - {copied.async ? 'Copied' : 'Copy'} - - - setAsyncExampleType(value as AsyncExampleType)} - align='end' - dropdownWidth={160} - /> + {!info.isPublicApi && ( +
+
+ +
+ + + + + + {copied.async ? 'Copied' : 'Copy'} + + + setAsyncExampleType(value as AsyncExampleType)} + align='end' + dropdownWidth={160} + /> +
+
- -
+ )}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index d46e57a420c..9e5e643733d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -226,7 +226,21 @@ export function DeployModal({ workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_PERSONAL_API_KEY' const getInputFormatExample = (includeStreaming = false) => { - return getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs) + const inputFormatExample = getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs) + if (!inputFormatExample) return '' + + const match = inputFormatExample.match(/-d\s*'([\s\S]*)'/) + if (!match) { + throw new Error(`Invalid workflow input example: ${inputFormatExample}`) + } + + const legacyBody = JSON.parse(match[1]) as Record + const { stream, selectedOutputs, ...input } = legacyBody + return ` -d '${JSON.stringify({ + input, + ...(stream === true ? { stream: true } : {}), + ...(Array.isArray(selectedOutputs) ? { selectedOutputs } : {}), + })}'` } const deploymentInfo: WorkflowDeploymentInfoUI | null = (() => { @@ -234,7 +248,7 @@ export function DeployModal({ return null } - const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute` + const endpoint = `${getBaseUrl()}/api/v2/workflows/${workflowId}/execute` const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) const placeholderKey = getApiHeaderPlaceholder() diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 10e0faa7c45..8fcae222c9a 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -437,6 +437,31 @@ export const v2ExecuteWorkflowContract = defineRouteContract({ }, }) +/** Resume input is scoped to one pause context on the parent execution. */ +export const v2ResumeWorkflowBodySchema = z + .object({ + contextId: z.string().min(1, 'contextId cannot be empty'), + input: z.unknown().optional(), + }) + .strict() +export type V2ResumeWorkflowBody = z.input + +export const v2ResumeWorkflowQueuedSchema = v2ExecuteWorkflowQueuedSchema.extend({ + queuePosition: z.number().int().positive().optional(), +}) +export type V2ResumeWorkflowQueued = z.output + +export const v2ResumeWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/executions/[executionId]/resume', + params: workflowExecutionParamsSchema, + body: v2ResumeWorkflowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ExecuteWorkflowDataSchema), + }, +}) + /** * The polled execution resource. `queued` is backfilled from the async job * queue before the worker writes the durable log row — v1's jobs endpoint 404 diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 8847adc6630..a4e5743211d 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -546,6 +546,7 @@ const pausedWorkflowExecutionDetailSchema = pausedWorkflowExecutionSummarySchema }) const workflowExecutionStatusEnum = z.enum([ + 'queued', 'pending', 'running', 'paused', diff --git a/apps/sim/lib/compare/data/sim.ts b/apps/sim/lib/compare/data/sim.ts index b2a46f0b893..b70b87bbc8c 100644 --- a/apps/sim/lib/compare/data/sim.ts +++ b/apps/sim/lib/compare/data/sim.ts @@ -1086,10 +1086,10 @@ export const simProfile: CompetitorProfile = { }, asyncExecution: { value: - 'Yes: a workflow can be triggered in fire-and-forget async mode, returning HTTP 202 with a job ID immediately, then polled via a dedicated jobs endpoint through queued/processing/completed/failed states', + 'Yes: a workflow can be triggered in fire-and-forget async mode, returning HTTP 202 with an execution ID immediately, then polled through the canonical execution resource across queued/running/terminal states', detail: - 'Async jobs are tracked via polling the job endpoint rather than a completion webhook/callback option.', - shortValue: 'Async mode: job ID returned immediately, poll for result', + 'Async runs are tracked by execution ID through the same execution status endpoint used for durable logs rather than a separate queue-job resource.', + shortValue: 'Async mode: execution ID returned immediately, poll for result', confidence: 'verified', sources: [ { @@ -1098,8 +1098,8 @@ export const simProfile: CompetitorProfile = { asOf: '2026-07-02', }, { - url: 'https://github.com/simstudioai/sim/blob/main/apps/sim/app/api/jobs/[jobId]/route.ts', - label: 'Sim codebase: async job status endpoint', + url: 'https://github.com/simstudioai/sim/blob/main/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts', + label: 'Sim codebase: execution status endpoint', asOf: '2026-07-02', }, ], diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index a1db42f3e4c..cb97bedf8e8 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -31,7 +31,21 @@ import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../para import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError } from './context' function buildWorkflowApiEndpoint(baseUrl: string, workflowId: string): string { - return `${baseUrl}/api/workflows/${workflowId}/execute` + return `${baseUrl}/api/v2/workflows/${workflowId}/execute` +} + +function buildWorkflowExecutionStatusEndpoint( + baseUrl: string, + apiEndpoint: string, + executionId: string +): string { + if ( + !apiEndpoint.startsWith(`${baseUrl}/api/v2/workflows/`) || + !apiEndpoint.endsWith('/execute') + ) { + throw new Error(`Invalid workflow execution endpoint: ${apiEndpoint}`) + } + return `${apiEndpoint.slice(0, -'/execute'.length)}/executions/${executionId}` } function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) { @@ -58,9 +72,12 @@ function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) { method: 'POST', transport: 'json', stream: false, - headers: { 'X-Execution-Mode': 'async' }, - body: { input: { key: 'value' } }, - jobStatusEndpointTemplate: `${baseUrl}/api/jobs/{jobId}`, + body: { async: true, input: { key: 'value' } }, + executionStatusEndpointTemplate: buildWorkflowExecutionStatusEndpoint( + baseUrl, + apiEndpoint, + '{executionId}' + ), }, }, } @@ -79,9 +96,8 @@ function buildWorkflowApiExamples(baseUrl: string, apiEndpoint: string) { async: `curl -X POST "${apiEndpoint}" \\ -H "Content-Type: application/json" \\ -H "X-API-Key: YOUR_API_KEY" \\ - -H "X-Execution-Mode: async" \\ - -d '{"input":{"key":"value"}}'`, - poll: `curl "${baseUrl}/api/jobs/JOB_ID" \\ + -d '{"async":true,"input":{"key":"value"}}'`, + poll: `curl "${buildWorkflowExecutionStatusEndpoint(baseUrl, apiEndpoint, 'EXECUTION_ID')}" \\ -H "X-API-Key: YOUR_API_KEY"`, } } diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts index 00642c49321..7e3577dab0a 100644 --- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts +++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts @@ -3,22 +3,25 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { MockApiError, mockResolveTriggerRegion, mockTrigger } = vi.hoisted(() => { - class MockApiError extends Error { - constructor( - readonly status: number | undefined, - message: string - ) { - super(message) +const { MockApiError, mockListRuns, mockResolveTriggerRegion, mockRetrieveRun, mockTrigger } = + vi.hoisted(() => { + class MockApiError extends Error { + constructor( + readonly status: number | undefined, + message: string + ) { + super(message) + } } - } - return { - MockApiError, - mockResolveTriggerRegion: vi.fn(), - mockTrigger: vi.fn(), - } -}) + return { + MockApiError, + mockListRuns: vi.fn(), + mockResolveTriggerRegion: vi.fn(), + mockRetrieveRun: vi.fn(), + mockTrigger: vi.fn(), + } + }) vi.mock('@trigger.dev/core/v3', () => ({ taskContext: { isInsideTask: false }, @@ -28,7 +31,8 @@ vi.mock('@trigger.dev/sdk', () => ({ ApiError: MockApiError, runs: { cancel: vi.fn(), - retrieve: vi.fn(), + list: mockListRuns, + retrieve: mockRetrieveRun, }, tasks: { batchTriggerAndWait: vi.fn(), @@ -63,6 +67,7 @@ describe('TriggerDevJobQueue enqueue', () => { expect.objectContaining({ idempotencyKey: 'workflow:1', idempotencyKeyTTL: '14d', + tags: ['jobId:workflow:1'], }) ) }) @@ -113,3 +118,44 @@ describe('TriggerDevJobQueue enqueue', () => { expect(mockTrigger).not.toHaveBeenCalled() }) }) + +describe('TriggerDevJobQueue getJob', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves a deterministic job ID through its Trigger.dev tag', async () => { + mockRetrieveRun + .mockRejectedValueOnce(new MockApiError(404, 'run not found')) + .mockResolvedValueOnce({ + id: 'run-1', + taskIdentifier: 'workflow-execution', + payload: { workflowId: 'workflow-1' }, + status: 'COMPLETED', + createdAt: '2026-08-05T12:00:00.000Z', + finishedAt: '2026-08-05T12:00:05.000Z', + attemptCount: 1, + output: { output: { answer: 42 } }, + }) + mockListRuns.mockReturnValueOnce( + (async function* () { + yield { id: 'run-1' } + })() + ) + const queue = new TriggerDevJobQueue() + + const job = await queue.getJob('workflow-execution:execution-1') + + expect(mockListRuns).toHaveBeenCalledWith({ + tag: 'jobId:workflow-execution:execution-1', + limit: 1, + }) + expect(mockRetrieveRun).toHaveBeenNthCalledWith(2, 'run-1') + expect(job).toMatchObject({ + id: 'workflow-execution:execution-1', + status: 'completed', + output: { output: { answer: 42 } }, + metadata: { workflowId: 'workflow-1' }, + }) + }) +}) diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts index 12f9f15bc88..4059f3066dc 100644 --- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts +++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts @@ -189,7 +189,26 @@ export class TriggerDevJobQueue implements JobQueueBackend { async getJob(jobId: string): Promise { try { - const run = await runs.retrieve(jobId) + let run: Awaited> + try { + run = await runs.retrieve(jobId) + } catch (error) { + const isNotFound = + (error instanceof Error && error.message.toLowerCase().includes('not found')) || + (error && typeof error === 'object' && 'status' in error && error.status === 404) + if (!isNotFound) throw error + + let runId: string | undefined + for await (const candidate of runs.list({ tag: `jobId:${jobId}`, limit: 1 })) { + runId = candidate.id + break + } + if (!runId) { + logger.debug('Job not found in trigger.dev', { jobId }) + return null + } + run = await runs.retrieve(runId) + } const payload = run.payload as Record const metadata: JobMetadata = { @@ -270,6 +289,7 @@ function buildTags(options?: EnqueueOptions): string[] { const tags: string[] = [] const meta = options?.metadata + if (options?.jobId) tags.push(`jobId:${options.jobId}`) if (meta?.workspaceId) tags.push(`workspaceId:${meta.workspaceId}`) if (meta?.workflowId) tags.push(`workflowId:${meta.workflowId}`) if (meta?.userId) tags.push(`userId:${meta.userId}`) diff --git a/apps/sim/lib/workflows/executor/enqueue-execution.ts b/apps/sim/lib/workflows/executor/enqueue-execution.ts index 949c98be1dc..4e8992bafd4 100644 --- a/apps/sim/lib/workflows/executor/enqueue-execution.ts +++ b/apps/sim/lib/workflows/executor/enqueue-execution.ts @@ -11,6 +11,7 @@ const logger = createLogger('WorkflowEnqueueExecution') const ASYNC_ENQUEUE_ATTEMPTS = 2 export const WORKFLOW_EXECUTION_JOB_ID_PREFIX = 'workflow-execution:' +export const RESUME_EXECUTION_JOB_ID_PREFIX = 'resume-execution:' export interface EnqueueWorkflowExecutionParams { requestId: string diff --git a/apps/sim/lib/workflows/executor/execution-status.test.ts b/apps/sim/lib/workflows/executor/execution-status.test.ts new file mode 100644 index 00000000000..dfbc0719224 --- /dev/null +++ b/apps/sim/lib/workflows/executor/execution-status.test.ts @@ -0,0 +1,222 @@ +/** + * @vitest-environment node + */ +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetJob } = vi.hoisted(() => ({ + mockGetJob: vi.fn(), +})) + +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: vi.fn().mockResolvedValue({ getJob: mockGetJob }), +})) + +vi.mock('@/lib/workflows/executor/enqueue-execution', () => ({ + RESUME_EXECUTION_JOB_ID_PREFIX: 'resume-execution:', + WORKFLOW_EXECUTION_JOB_ID_PREFIX: 'workflow-execution:', +})) + +import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' + +const input = { + workflowId: 'workflow-1', + executionId: 'execution-1', + includeOutput: false, + selectedOutputs: [], +} + +describe('getWorkflowExecutionStatus queue projection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('projects a queued workflow job as an execution resource', async () => { + mockGetJob.mockResolvedValue({ + status: 'pending', + createdAt: new Date('2026-08-05T12:00:00.000Z'), + metadata: { + workflowId: 'workflow-1', + correlation: { triggerType: 'api' }, + }, + }) + + const status = await getWorkflowExecutionStatus(input) + + expect(status).toMatchObject({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'queued', + trigger: 'api', + startedAt: '2026-08-05T12:00:00.000Z', + endedAt: null, + error: null, + }) + expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:execution-1') + }) + + it('uses the resume entry ID when the queued work is a resume attempt', async () => { + queueTableRows(schemaMock.resumeQueue, [{ id: 'resume-entry-1', status: 'claimed' }]) + mockGetJob.mockResolvedValueOnce({ + status: 'processing', + createdAt: new Date('2026-08-05T12:00:00.000Z'), + startedAt: new Date('2026-08-05T12:00:01.000Z'), + metadata: { workflowId: 'workflow-1' }, + }) + + const status = await getWorkflowExecutionStatus(input) + + expect(status).toMatchObject({ + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'running', + startedAt: '2026-08-05T12:00:01.000Z', + }) + expect(mockGetJob).toHaveBeenCalledWith('resume-execution:resume-entry-1') + }) + + it('projects an active resume ahead of the existing paused log', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'paused', + }, + ]) + queueTableRows(schemaMock.resumeQueue, [{ id: 'resume-entry-1', status: 'claimed' }]) + mockGetJob.mockResolvedValueOnce({ + status: 'pending', + createdAt: new Date('2026-08-05T12:00:00.000Z'), + metadata: { workflowId: 'workflow-1' }, + }) + + const status = await getWorkflowExecutionStatus(input) + + expect(status).toMatchObject({ + executionId: 'execution-1', + status: 'queued', + paused: null, + }) + }) + + it('keeps an active resume queued while its background job is not yet visible', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'paused', + trigger: 'api', + }, + ]) + queueTableRows(schemaMock.resumeQueue, [ + { + id: 'resume-entry-1', + status: 'claimed', + queuedAt: new Date('2026-08-05T12:00:00.000Z'), + claimedAt: new Date('2026-08-05T12:00:01.000Z'), + }, + ]) + mockGetJob.mockResolvedValueOnce(null) + + const status = await getWorkflowExecutionStatus(input) + + expect(status).toMatchObject({ + executionId: 'execution-1', + status: 'queued', + trigger: 'api', + startedAt: '2026-08-05T12:00:01.000Z', + paused: null, + }) + }) + + it('projects a pending serialized resume as queued', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'paused', + trigger: 'api', + }, + ]) + queueTableRows(schemaMock.resumeQueue, [ + { + id: 'resume-entry-2', + status: 'pending', + queuedAt: new Date('2026-08-05T12:00:02.000Z'), + claimedAt: null, + }, + ]) + + const status = await getWorkflowExecutionStatus(input) + + expect(status).toMatchObject({ + executionId: 'execution-1', + status: 'queued', + startedAt: '2026-08-05T12:00:02.000Z', + paused: null, + }) + expect(mockGetJob).not.toHaveBeenCalled() + }) + + it('does not let an orphaned pending resume mask a terminal log', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'completed', + level: 'info', + trigger: 'api', + startedAt: new Date('2026-08-05T12:00:00.000Z'), + endedAt: new Date('2026-08-05T12:00:01.000Z'), + totalDurationMs: 1000, + executionData: null, + costTotal: null, + }, + ]) + queueTableRows(schemaMock.resumeQueue, [ + { + id: 'resume-entry-2', + status: 'pending', + queuedAt: new Date('2026-08-05T12:00:02.000Z'), + claimedAt: null, + }, + ]) + + const status = await getWorkflowExecutionStatus(input) + + expect(status).toMatchObject({ + executionId: 'execution-1', + status: 'completed', + }) + expect(mockGetJob).not.toHaveBeenCalled() + }) + + it('returns completed queue output when requested', async () => { + mockGetJob.mockResolvedValueOnce({ + status: 'completed', + createdAt: new Date('2026-08-05T12:00:00.000Z'), + completedAt: new Date('2026-08-05T12:00:05.000Z'), + output: { output: { answer: 42 } }, + metadata: { workflowId: 'workflow-1' }, + }) + + const status = await getWorkflowExecutionStatus({ ...input, includeOutput: true }) + + expect(status).toMatchObject({ + status: 'completed', + finalOutput: { answer: 42 }, + }) + }) + + it('does not expose a queue record belonging to another workflow', async () => { + mockGetJob.mockResolvedValueOnce({ + status: 'pending', + createdAt: new Date('2026-08-05T12:00:00.000Z'), + metadata: { workflowId: 'workflow-2' }, + }) + + await expect(getWorkflowExecutionStatus(input)).resolves.toBeNull() + }) +}) diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index d608dd3d825..f90ef18e1c6 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -1,12 +1,18 @@ import { db } from '@sim/db' -import { pausedExecutions, workflowExecutionLogs } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' +import { pausedExecutions, resumeQueue, workflowExecutionLogs } from '@sim/db/schema' +import { and, eq, inArray, sql } from 'drizzle-orm' import type { WorkflowExecutionStatusResponse } from '@/lib/api/contracts/workflows' +import { getJobQueue } from '@/lib/core/async-jobs' +import type { Job } from '@/lib/core/async-jobs/types' import { collectFunctionalBlockOutputs, type FunctionalExecutionDataSource, } from '@/lib/logs/execution/functional-outputs' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { + RESUME_EXECUTION_JOB_ID_PREFIX, + WORKFLOW_EXECUTION_JOB_ID_PREFIX, +} from '@/lib/workflows/executor/enqueue-execution' import { getAutomaticResumeWaitingMetadata } from '@/lib/workflows/executor/paused-execution-metadata' import type { PausePoint } from '@/executor/types' @@ -14,7 +20,9 @@ import type { PausePoint } from '@/executor/types' * Reads a single execution's status resource — the log row, the paused-state * overlay, and (when requested) materialized outputs. Extracted so the v1 and * v2 status routes render the identical resource from one read path. - * Auth is the caller's responsibility. Returns `null` when no log row exists. + * Auth is the caller's responsibility. Before a worker writes the durable log + * row, the deterministic queue record is projected as the same execution + * resource so callers never need a separate job identifier or endpoint. */ type LogStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' @@ -79,6 +87,38 @@ function extractError(executionData: unknown): string | null { return null } +function extractJobFinalOutput(output: unknown): unknown | null { + if (!output || typeof output !== 'object' || !('output' in output)) return null + return (output as Record).output ?? null +} + +function projectQueueJob( + job: Job, + input: Pick +): WorkflowExecutionStatusResponse { + const status: WorkflowExecutionStatusResponse['status'] = + job.status === 'pending' ? 'queued' : job.status === 'processing' ? 'running' : job.status + const startedAt = job.startedAt ?? job.createdAt + const endedAt = job.completedAt ?? null + + return { + executionId: input.executionId, + workflowId: input.workflowId, + status, + trigger: job.metadata.correlation?.triggerType ?? 'api', + level: status === 'failed' ? 'error' : 'info', + startedAt: startedAt.toISOString(), + endedAt: endedAt?.toISOString() ?? null, + totalDurationMs: endedAt ? endedAt.getTime() - startedAt.getTime() : null, + paused: null, + cost: null, + error: status === 'failed' ? (job.error ?? 'Execution failed') : null, + finalOutput: + input.includeOutput && status === 'completed' ? extractJobFinalOutput(job.output) : null, + blockOutputs: null, + } +} + export interface GetWorkflowExecutionStatusInput { workflowId: string executionId: string @@ -114,6 +154,63 @@ export async function getWorkflowExecutionStatus( ) .limit(1) + const [activeResume] = await db + .select({ + id: resumeQueue.id, + status: resumeQueue.status, + queuedAt: resumeQueue.queuedAt, + claimedAt: resumeQueue.claimedAt, + }) + .from(resumeQueue) + .where( + and( + eq(resumeQueue.parentExecutionId, executionId), + eq(resumeQueue.newExecutionId, executionId), + inArray(resumeQueue.status, ['pending', 'claimed'] as const) + ) + ) + .orderBy(sql`case when ${resumeQueue.status} = 'claimed' then 0 else 1 end`) + .limit(1) + + const hasTerminalLog = + logRow?.status === 'completed' || logRow?.status === 'failed' || logRow?.status === 'cancelled' + const projectedResume = hasTerminalLog ? undefined : activeResume + + const queueJobIds = [ + ...(projectedResume?.status === 'claimed' + ? [`${RESUME_EXECUTION_JOB_ID_PREFIX}${projectedResume.id}`] + : []), + ...(!logRow ? [`${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${executionId}`] : []), + ] + + if (queueJobIds.length > 0) { + const jobQueue = await getJobQueue() + for (const jobId of queueJobIds) { + const job = await jobQueue.getJob(jobId) + if (!job || job.metadata.workflowId !== workflowId) continue + return projectQueueJob(job, { executionId, includeOutput, workflowId }) + } + } + + if (projectedResume) { + const startedAt = projectedResume.claimedAt ?? projectedResume.queuedAt + return { + executionId, + workflowId, + status: 'queued', + trigger: logRow?.trigger ?? 'api', + level: 'info', + startedAt: startedAt.toISOString(), + endedAt: null, + totalDurationMs: null, + paused: null, + cost: null, + error: null, + finalOutput: null, + blockOutputs: null, + } + } + if (!logRow) return null const [pausedRow] = await db diff --git a/packages/python-sdk/README.md b/packages/python-sdk/README.md index 2690f635a17..390649be1cf 100644 --- a/packages/python-sdk/README.md +++ b/packages/python-sdk/README.md @@ -48,10 +48,10 @@ SimStudioClient(api_key: str, base_url: str = "https://sim.ai") Execute a workflow with optional input data. ```python -# With dict input (spread at root level of request body) +# With dict input (sent as the v2 input object) result = client.execute_workflow("workflow-id", {"message": "Hello, world!"}) -# With primitive input (wrapped as { input: value }) +# With primitive input (sent as { input: { input: value } }) result = client.execute_workflow("workflow-id", "NVDA") # With options (keyword-only arguments) @@ -60,7 +60,7 @@ result = client.execute_workflow("workflow-id", {"message": "Hello"}, timeout=60 **Parameters:** - `workflow_id` (str): The ID of the workflow to execute -- `input` (any, optional): Input data to pass to the workflow. Dicts are spread at the root level, primitives/lists are wrapped in `{ input: value }`. File objects are automatically converted to base64. +- `input` (any, optional): Input data to pass to the workflow. Dicts become the v2 `input` object; primitives and lists become `{ input: value }` inside it. File objects are automatically converted to base64. - `timeout` (float, keyword-only): Timeout in seconds (default: 30.0) - `stream` (bool, keyword-only): Enable streaming responses - `selected_outputs` (list, keyword-only): Block outputs to stream (e.g., `["agent1.content"]`) @@ -115,17 +115,35 @@ result = client.execute_workflow_sync("workflow-id", {"data": "some input"}, tim **Returns:** `WorkflowExecutionResult` -##### get_job_status(job_id) +##### get_workflow_execution(workflow_id, execution_id, *, include_output=None, selected_outputs=None) -Get the status of an async job. +Get the status and optional outputs of a workflow execution. Use the execution ID returned by async execution. ```python -status = client.get_job_status("job-id-from-async-execution") -print("Job status:", status) +status = client.get_workflow_execution( + "workflow-id", + "execution-id", + include_output=True, + selected_outputs=["agent.content"] +) +print("Execution status:", status["status"]) ``` **Parameters:** -- `job_id` (str): The job ID returned from async execution +- `workflow_id` (str): The workflow ID +- `execution_id` (str): The execution ID returned from async execution +- `include_output` (bool, keyword-only): Include the final output for completed executions +- `selected_outputs` (list, keyword-only): Block output selectors to include + +**Returns:** `dict` + +##### get_job_status(job_id) + +Get the status of a job created through the legacy async execution endpoint. New integrations should use `get_workflow_execution()` with an execution ID. + +```python +status = client.get_job_status("legacy-job-id") +``` **Returns:** `dict` @@ -248,9 +266,8 @@ class SimStudioError(Exception): @dataclass class AsyncExecutionResult: success: bool - job_id: str + execution_id: str status_url: str - execution_id: Optional[str] = None message: str = "" async_execution: bool = True ``` @@ -527,4 +544,4 @@ isort simstudio/ ## License -Apache-2.0 \ No newline at end of file +Apache-2.0 diff --git a/packages/python-sdk/simstudio/__init__.py b/packages/python-sdk/simstudio/__init__.py index 0e2609e2f26..e930e2467ba 100644 --- a/packages/python-sdk/simstudio/__init__.py +++ b/packages/python-sdk/simstudio/__init__.py @@ -49,9 +49,8 @@ class WorkflowStatus: class AsyncExecutionResult: """Result of an async workflow execution.""" success: bool - job_id: str + execution_id: str status_url: str - execution_id: Optional[str] = None message: str = "" async_execution: bool = True @@ -159,7 +158,7 @@ def execute_workflow( ) -> Union[WorkflowExecutionResult, AsyncExecutionResult]: """ Execute a workflow with optional input data. - If async_execution is True, returns immediately with a task ID. + If async_execution is True, returns immediately with an execution ID. File objects in input will be automatically detected and converted to base64. @@ -179,31 +178,26 @@ def execute_workflow( Raises: SimStudioError: If the workflow execution fails """ - url = f"{self.base_url}/api/workflows/{workflow_id}/execute" - - # Build headers - async execution uses X-Execution-Mode header + url = f"{self.base_url}/api/v2/workflows/{workflow_id}/execute" headers = self._session.headers.copy() - if async_execution: - headers['X-Execution-Mode'] = 'async' try: - # Build JSON body - spread dict inputs at root level, wrap primitives/lists in 'input' field - body = {} + workflow_input = {} if input is not None: if isinstance(input, dict): - # Dict input: spread at root level (matches curl/API behavior) - body = input.copy() + workflow_input = input.copy() else: - # Primitive or list input: wrap in 'input' field - body = {'input': input} + workflow_input = {'input': input} - # Convert any file objects in the input to base64 format - body = self._convert_files_to_base64(body) + workflow_input = self._convert_files_to_base64(workflow_input) + body = {'input': workflow_input} if stream is not None: body['stream'] = stream if selected_outputs is not None: body['selectedOutputs'] = selected_outputs + if async_execution is not None: + body['async'] = async_execution response = self._session.post( url, @@ -227,35 +221,41 @@ def execute_workflow( if not response.ok: try: error_data = response.json() - error_message = error_data.get('error', f'HTTP {response.status_code}: {response.reason}') - error_code = error_data.get('code') + error = error_data.get('error', {}) + error_message = error.get('message', f'HTTP {response.status_code}: {response.reason}') + error_code = error.get('code') except (ValueError, KeyError): error_message = f'HTTP {response.status_code}: {response.reason}' error_code = None raise SimStudioError(error_message, error_code, response.status_code) - result_data = response.json() + result = response.json() + if 'data' not in result: + raise SimStudioError('Invalid v2 workflow execution response', 'EXECUTION_ERROR') + result_data = result['data'] - # Check if this is an async execution response (202 status) - if response.status_code == 202 and 'jobId' in result_data: + if response.status_code == 202: + if 'executionId' not in result_data or 'statusUrl' not in result_data: + raise SimStudioError('Invalid v2 async execution response', 'EXECUTION_ERROR') return AsyncExecutionResult( - success=result_data.get('success', True), - job_id=result_data['jobId'], + success=True, + execution_id=result_data['executionId'], status_url=result_data['statusUrl'], - execution_id=result_data.get('executionId'), - message=result_data.get('message', ''), - async_execution=result_data.get('async', True) + message='Workflow execution queued', + async_execution=True ) + execution_error = result_data.get('error') return WorkflowExecutionResult( - success=result_data['success'], + success=result_data.get('status') != 'failed', output=result_data.get('output'), - error=result_data.get('error'), - logs=result_data.get('logs'), - metadata=result_data.get('metadata'), - trace_spans=result_data.get('traceSpans'), - total_duration=result_data.get('totalDuration') + error=execution_error.get('message') if execution_error else None, + metadata={ + 'duration': result_data.get('durationMs'), + 'executionId': result_data['executionId'] + }, + total_duration=result_data.get('durationMs') ) except requests.Timeout: @@ -378,10 +378,10 @@ def close(self) -> None: def get_job_status(self, job_id: str) -> Dict[str, Any]: """ - Get the status of an async job. + Get the status of a legacy async job. Args: - job_id: The job ID returned from async execution + job_id: The job ID returned from legacy async execution Returns: Dictionary containing the job status @@ -412,6 +412,61 @@ def get_job_status(self, job_id: str) -> Dict[str, Any]: except requests.RequestException as e: raise SimStudioError(f'Failed to get job status: {str(e)}', 'STATUS_ERROR') + def get_workflow_execution( + self, + workflow_id: str, + execution_id: str, + *, + include_output: Optional[bool] = None, + selected_outputs: Optional[list] = None + ) -> Dict[str, Any]: + """ + Get a workflow execution's current status and optional outputs from the v2 API. + + Args: + workflow_id: The workflow ID + execution_id: The execution ID returned from async execution + include_output: Include the final output for completed executions + selected_outputs: Block output selectors to include + + Returns: + Dictionary containing the execution status + + Raises: + SimStudioError: If getting the status fails + """ + url = f"{self.base_url}/api/v2/workflows/{workflow_id}/executions/{execution_id}" + params = {} + if include_output is not None: + params['includeOutput'] = str(include_output).lower() + if selected_outputs: + params['selectedOutputs'] = ','.join(selected_outputs) + + try: + response = self._session.get(url, params=params or None) + + self._update_rate_limit_info(response) + + if not response.ok: + try: + error_data = response.json() + error = error_data.get('error', {}) + error_message = error.get('message', f'HTTP {response.status_code}: {response.reason}') + error_code = error.get('code') + except (ValueError, KeyError): + error_message = f'HTTP {response.status_code}: {response.reason}' + error_code = None + + raise SimStudioError(error_message, error_code, response.status_code) + + result = response.json() + if 'data' not in result: + raise SimStudioError('Invalid v2 workflow execution response', 'STATUS_ERROR') + return result['data'] + + except requests.RequestException as e: + raise SimStudioError(f'Failed to get workflow execution: {str(e)}', 'STATUS_ERROR') + def execute_with_retry( self, workflow_id: str, @@ -565,4 +620,4 @@ def __exit__(self, exc_type, exc_val, exc_tb): # For backward compatibility -Client = SimStudioClient \ No newline at end of file +Client = SimStudioClient diff --git a/packages/python-sdk/tests/test_client.py b/packages/python-sdk/tests/test_client.py index 814ad7610ef..8473758198b 100644 --- a/packages/python-sdk/tests/test_client.py +++ b/packages/python-sdk/tests/test_client.py @@ -7,6 +7,19 @@ from simstudio import SimStudioClient, SimStudioError, WorkflowExecutionResult, WorkflowStatus +def v2_execution_response(output=None): + return { + "data": { + "executionId": "execution-123", + "workflowId": "workflow-id", + "status": "completed", + "output": {} if output is None else output, + "error": None, + "durationMs": 10 + } + } + + def test_simstudio_client_initialization(): """Test SimStudioClient initialization.""" client = SimStudioClient(api_key="test-api-key", base_url="https://test.sim.ai") @@ -95,18 +108,16 @@ def test_context_manager(mock_close): @patch('simstudio.requests.Session.post') -def test_async_execution_returns_job_id(mock_post): +def test_async_execution_returns_execution_id(mock_post): """Test async execution returns AsyncExecutionResult.""" mock_response = Mock() mock_response.ok = True mock_response.status_code = 202 mock_response.json.return_value = { - "success": True, - "jobId": "job-123", - "statusUrl": "https://test.sim.ai/api/jobs/job-123", - "executionId": "execution-123", - "message": "Workflow execution started", - "async": True + "data": { + "executionId": "execution-123", + "statusUrl": "https://sim.ai/api/v2/workflows/workflow-id/executions/execution-123" + } } mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -119,13 +130,17 @@ def test_async_execution_returns_job_id(mock_post): ) assert result.success is True - assert result.job_id == "job-123" - assert result.status_url == "https://test.sim.ai/api/jobs/job-123" assert result.execution_id == "execution-123" + assert result.status_url == "https://sim.ai/api/v2/workflows/workflow-id/executions/execution-123" assert result.async_execution is True call_args = mock_post.call_args - assert call_args[1]["headers"]["X-Execution-Mode"] == "async" + assert call_args.args[0] == "https://sim.ai/api/v2/workflows/workflow-id/execute" + assert "X-Execution-Mode" not in call_args.kwargs["headers"] + assert call_args.kwargs["json"] == { + "input": {"message": "Hello"}, + "async": True + } @patch('simstudio.requests.Session.post') @@ -134,11 +149,7 @@ def test_sync_execution_returns_result(mock_post): mock_response = Mock() mock_response.ok = True mock_response.status_code = 200 - mock_response.json.return_value = { - "success": True, - "output": {"result": "completed"}, - "logs": [] - } + mock_response.json.return_value = v2_execution_response({"result": "completed"}) mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -160,7 +171,7 @@ def test_async_header_not_set_when_false(mock_post): mock_response = Mock() mock_response.ok = True mock_response.status_code = 200 - mock_response.json.return_value = {"success": True, "output": {}} + mock_response.json.return_value = v2_execution_response() mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -173,18 +184,14 @@ def test_async_header_not_set_when_false(mock_post): @patch('simstudio.requests.Session.get') def test_get_job_status_success(mock_get): - """Test getting job status.""" + """Test getting legacy job status.""" mock_response = Mock() mock_response.ok = True mock_response.json.return_value = { "success": True, "taskId": "task-123", "status": "completed", - "metadata": { - "startedAt": "2024-01-01T00:00:00Z", - "completedAt": "2024-01-01T00:01:00Z", - "duration": 60000 - }, + "metadata": {"duration": 60000}, "output": {"result": "done"} } mock_response.headers.get.return_value = None @@ -201,7 +208,7 @@ def test_get_job_status_success(mock_get): @patch('simstudio.requests.Session.get') def test_get_job_status_not_found(mock_get): - """Test job not found error.""" + """Test legacy job not found error.""" mock_response = Mock() mock_response.ok = False mock_response.status_code = 404 @@ -220,6 +227,60 @@ def test_get_job_status_not_found(mock_get): assert "Job not found" in str(exc_info.value) +@patch('simstudio.requests.Session.get') +def test_get_workflow_execution_success(mock_get): + mock_response = Mock() + mock_response.ok = True + mock_response.json.return_value = { + "data": { + "executionId": "execution-123", + "workflowId": "workflow-123", + "status": "completed", + "output": {"result": "done"} + } + } + mock_response.headers.get.return_value = None + mock_get.return_value = mock_response + + client = SimStudioClient(api_key="test-api-key", base_url="https://test.sim.ai") + result = client.get_workflow_execution( + "workflow-123", + "execution-123", + include_output=True, + selected_outputs=["agent.content"] + ) + + assert result["executionId"] == "execution-123" + assert result["status"] == "completed" + assert result["output"]["result"] == "done" + mock_get.assert_called_once_with( + "https://test.sim.ai/api/v2/workflows/workflow-123/executions/execution-123", + params={"includeOutput": "true", "selectedOutputs": "agent.content"} + ) + + +@patch('simstudio.requests.Session.get') +def test_get_workflow_execution_not_found(mock_get): + mock_response = Mock() + mock_response.ok = False + mock_response.status_code = 404 + mock_response.reason = "Not Found" + mock_response.json.return_value = { + "error": { + "code": "NOT_FOUND", + "message": "Execution not found" + } + } + mock_response.headers.get.return_value = None + mock_get.return_value = mock_response + + client = SimStudioClient(api_key="test-api-key") + + with pytest.raises(SimStudioError) as exc_info: + client.get_workflow_execution("workflow-123", "invalid-execution") + assert "Execution not found" in str(exc_info.value) + + @patch('simstudio.requests.Session.post') @patch('simstudio.time.sleep') def test_execute_with_retry_success_first_attempt(mock_sleep, mock_post): @@ -227,10 +288,7 @@ def test_execute_with_retry_success_first_attempt(mock_sleep, mock_post): mock_response = Mock() mock_response.ok = True mock_response.status_code = 200 - mock_response.json.return_value = { - "success": True, - "output": {"result": "success"} - } + mock_response.json.return_value = v2_execution_response({"result": "success"}) mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -264,10 +322,7 @@ def test_execute_with_retry_retries_on_rate_limit(mock_sleep, mock_post): success_response = Mock() success_response.ok = True success_response.status_code = 200 - success_response.json.return_value = { - "success": True, - "output": {"result": "success"} - } + success_response.json.return_value = v2_execution_response({"result": "success"}) success_response.headers.get.return_value = None mock_post.side_effect = [rate_limit_response, success_response] @@ -321,8 +376,10 @@ def test_execute_with_retry_no_retry_on_other_errors(mock_post): mock_response.status_code = 500 mock_response.reason = "Internal Server Error" mock_response.json.return_value = { - "error": "Server error", - "code": "INTERNAL_ERROR" + "error": { + "code": "INTERNAL_ERROR", + "message": "Server error" + } } mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -349,7 +406,7 @@ def test_get_rate_limit_info_after_api_call(mock_post): mock_response = Mock() mock_response.ok = True mock_response.status_code = 200 - mock_response.json.return_value = {"success": True, "output": {}} + mock_response.json.return_value = v2_execution_response() mock_response.headers.get.side_effect = lambda h: { 'x-ratelimit-limit': '100', 'x-ratelimit-remaining': '95', @@ -436,7 +493,7 @@ def test_execute_workflow_with_stream_and_selected_outputs(mock_post): mock_response = Mock() mock_response.ok = True mock_response.status_code = 200 - mock_response.json.return_value = {"success": True, "output": {}} + mock_response.json.return_value = v2_execution_response() mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -451,7 +508,7 @@ def test_execute_workflow_with_stream_and_selected_outputs(mock_post): call_args = mock_post.call_args request_body = call_args[1]["json"] - assert request_body["message"] == "test" + assert request_body["input"] == {"message": "test"} assert request_body["stream"] is True assert request_body["selectedOutputs"] == ["agent1.content", "agent2.content"] @@ -463,7 +520,7 @@ def test_execute_workflow_with_string_input(mock_post): mock_response = Mock() mock_response.ok = True mock_response.status_code = 200 - mock_response.json.return_value = {"success": True, "output": {}} + mock_response.json.return_value = v2_execution_response() mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -473,7 +530,7 @@ def test_execute_workflow_with_string_input(mock_post): call_args = mock_post.call_args request_body = call_args[1]["json"] - assert request_body["input"] == "NVDA" + assert request_body["input"] == {"input": "NVDA"} assert "0" not in request_body # Should not spread string characters @@ -483,7 +540,7 @@ def test_execute_workflow_with_number_input(mock_post): mock_response = Mock() mock_response.ok = True mock_response.status_code = 200 - mock_response.json.return_value = {"success": True, "output": {}} + mock_response.json.return_value = v2_execution_response() mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -493,7 +550,7 @@ def test_execute_workflow_with_number_input(mock_post): call_args = mock_post.call_args request_body = call_args[1]["json"] - assert request_body["input"] == 42 + assert request_body["input"] == {"input": 42} @patch('simstudio.requests.Session.post') @@ -502,7 +559,7 @@ def test_execute_workflow_with_list_input(mock_post): mock_response = Mock() mock_response.ok = True mock_response.status_code = 200 - mock_response.json.return_value = {"success": True, "output": {}} + mock_response.json.return_value = v2_execution_response() mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -512,17 +569,16 @@ def test_execute_workflow_with_list_input(mock_post): call_args = mock_post.call_args request_body = call_args[1]["json"] - assert request_body["input"] == ["NVDA", "AAPL", "GOOG"] + assert request_body["input"] == {"input": ["NVDA", "AAPL", "GOOG"]} assert "0" not in request_body # Should not spread list @patch('simstudio.requests.Session.post') -def test_execute_workflow_with_dict_input_spreads_at_root(mock_post): - """Test execution with dict input spreads at root level.""" +def test_execute_workflow_with_dict_input_uses_v2_input_field(mock_post): mock_response = Mock() mock_response.ok = True mock_response.status_code = 200 - mock_response.json.return_value = {"success": True, "output": {}} + mock_response.json.return_value = v2_execution_response() mock_response.headers.get.return_value = None mock_post.return_value = mock_response @@ -532,6 +588,4 @@ def test_execute_workflow_with_dict_input_spreads_at_root(mock_post): call_args = mock_post.call_args request_body = call_args[1]["json"] - assert request_body["ticker"] == "NVDA" - assert request_body["quantity"] == 100 - assert "input" not in request_body # Should not wrap in input field \ No newline at end of file + assert request_body["input"] == {"ticker": "NVDA", "quantity": 100} diff --git a/packages/ts-sdk/README.md b/packages/ts-sdk/README.md index 0ce547f6e51..2e2831ea93b 100644 --- a/packages/ts-sdk/README.md +++ b/packages/ts-sdk/README.md @@ -52,12 +52,12 @@ new SimStudioClient(config: SimStudioConfig) Execute a workflow with optional input data. ```typescript -// With object input (spread at root level of request body) +// With object input (sent as the v2 input object) const result = await client.executeWorkflow('workflow-id', { message: 'Hello, world!' }); -// With primitive input (wrapped as { input: value }) +// With primitive input (sent as { input: { input: value } }) const result = await client.executeWorkflow('workflow-id', 'NVDA'); // With options @@ -68,7 +68,7 @@ const result = await client.executeWorkflow('workflow-id', { message: 'Hello' }, **Parameters:** - `workflowId` (string): The ID of the workflow to execute -- `input` (any, optional): Input data to pass to the workflow. Objects are spread at the root level, primitives/arrays are wrapped in `{ input: value }`. File objects are automatically converted to base64. +- `input` (any, optional): Input data to pass to the workflow. Objects become the v2 `input` object; primitives and arrays become `{ input: value }` inside it. File objects are automatically converted to base64. - `options` (ExecutionOptions, optional): - `timeout` (number): Timeout in milliseconds (default: 30000) - `stream` (boolean): Enable streaming responses @@ -125,19 +125,35 @@ const result = await client.executeWorkflowSync('workflow-id', { data: 'some inp **Returns:** `Promise` -##### getJobStatus(jobId) +##### getWorkflowExecution(workflowId, executionId, options?) -Get the status of an async job. +Get the status and optional outputs of a workflow execution. Use the `executionId` returned by async execution. ```typescript -const status = await client.getJobStatus('job-id-from-async-execution'); -console.log('Job status:', status); +const status = await client.getWorkflowExecution('workflow-id', 'execution-id', { + includeOutput: true, + selectedOutputs: ['agent.content'] +}); +console.log('Execution status:', status.status); ``` **Parameters:** -- `jobId` (string): The job ID returned from async execution +- `workflowId` (string): The workflow ID +- `executionId` (string): The execution ID returned from async execution +- `options.includeOutput` (boolean, optional): Include the final output for completed executions +- `options.selectedOutputs` (string[], optional): Block output selectors to include + +**Returns:** `Promise` + +##### getJobStatus(jobId) -**Returns:** `Promise` +Get the status of a job created through the legacy async execution endpoint. New integrations should use `getWorkflowExecution()` with an execution ID. + +```typescript +const status = await client.getJobStatus('legacy-job-id'); +``` + +**Returns:** `Promise` ##### executeWithRetry(workflowId, input?, options?, retryOptions?) @@ -228,7 +244,7 @@ interface WorkflowExecutionResult { ### LargeValueRef -Oversized execution values may be returned as a versioned reference inside `output`, `logs`, streaming events, or async job status responses. +Oversized execution values may be returned as a versioned reference inside `output`, `logs`, streaming events, or execution status responses. The `key` field is an opaque execution-scoped server storage pointer, not a client-readable download URL. ```typescript @@ -268,9 +284,8 @@ class SimStudioError extends Error { ```typescript interface AsyncExecutionResult { success: boolean; - jobId: string; + executionId: string; statusUrl: string; - executionId?: string; message: string; async: true; } @@ -533,4 +548,4 @@ bun run dev ## License -Apache-2.0 \ No newline at end of file +Apache-2.0 diff --git a/packages/ts-sdk/src/index.test.ts b/packages/ts-sdk/src/index.test.ts index c5066442f99..95137c9c23e 100644 --- a/packages/ts-sdk/src/index.test.ts +++ b/packages/ts-sdk/src/index.test.ts @@ -4,6 +4,19 @@ import { SimStudioClient, SimStudioError } from './index' const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) +function v2ExecutionResponse(output: unknown = {}) { + return { + data: { + executionId: 'execution-123', + workflowId: 'workflow-id', + status: 'completed', + output, + error: null, + durationMs: 10, + }, + } +} + describe('SimStudioClient', () => { let client: SimStudioClient @@ -100,11 +113,10 @@ describe('SimStudioClient', () => { ok: true, status: 202, json: vi.fn().mockResolvedValue({ - success: true, - jobId: 'job-123', - statusUrl: 'https://test.sim.ai/api/jobs/job-123', - message: 'Workflow execution queued', - async: true, + data: { + executionId: 'execution-123', + statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-id/executions/execution-123', + }, }), headers: { get: vi.fn().mockReturnValue(null), @@ -118,14 +130,19 @@ describe('SimStudioClient', () => { { async: true } ) - expect(result).toHaveProperty('jobId', 'job-123') - expect(result).toHaveProperty('statusUrl', 'https://test.sim.ai/api/jobs/job-123') + expect(result).toHaveProperty('executionId', 'execution-123') + expect(result).toHaveProperty( + 'statusUrl', + 'https://test.sim.ai/api/v2/workflows/workflow-id/executions/execution-123' + ) expect(result).toHaveProperty('async', true) - // Verify headers were set correctly const calls = vi.mocked(mockFetch).mock.calls - expect(calls[0][1]?.headers).toMatchObject({ - 'X-Execution-Mode': 'async', + expect(calls[0][0]).toBe('https://test.sim.ai/api/v2/workflows/workflow-id/execute') + expect(calls[0][1]?.headers).not.toHaveProperty('X-Execution-Mode') + expect(JSON.parse(calls[0][1]?.body as string)).toEqual({ + input: { message: 'Hello' }, + async: true, }) }) @@ -133,11 +150,7 @@ describe('SimStudioClient', () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: { result: 'completed' }, - logs: [], - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse({ result: 'completed' })), headers: { get: vi.fn().mockReturnValue(null), }, @@ -159,10 +172,7 @@ describe('SimStudioClient', () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: {}, - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse()), headers: { get: vi.fn().mockReturnValue(null), }, @@ -177,18 +187,14 @@ describe('SimStudioClient', () => { }) describe('getJobStatus', () => { - it('should fetch job status with correct endpoint', async () => { + it('should fetch legacy job status with the correct endpoint', async () => { const mockResponse = { ok: true, json: vi.fn().mockResolvedValue({ success: true, taskId: 'task-123', status: 'completed', - metadata: { - startedAt: '2024-01-01T00:00:00Z', - completedAt: '2024-01-01T00:01:00Z', - duration: 60000, - }, + metadata: { duration: 60000 }, output: { result: 'done' }, }), headers: { @@ -202,13 +208,10 @@ describe('SimStudioClient', () => { expect(result).toHaveProperty('taskId', 'task-123') expect(result).toHaveProperty('status', 'completed') expect(result).toHaveProperty('output') - - // Verify correct endpoint was called - const calls = vi.mocked(mockFetch).mock.calls - expect(calls[0][0]).toBe('https://test.sim.ai/api/jobs/task-123') + expect(vi.mocked(mockFetch).mock.calls[0][0]).toBe('https://test.sim.ai/api/jobs/task-123') }) - it('should handle job not found error', async () => { + it('should handle legacy job not found errors', async () => { const mockResponse = { ok: false, status: 404, @@ -223,20 +226,75 @@ describe('SimStudioClient', () => { } vi.mocked(mockFetch).mockResolvedValue(mockResponse as any) - await expect(client.getJobStatus('invalid-task')).rejects.toThrow(SimStudioError) await expect(client.getJobStatus('invalid-task')).rejects.toThrow('Job not found') }) }) + describe('getWorkflowExecution', () => { + it('should fetch execution status and outputs from the v2 execution resource', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: { + executionId: 'execution-123', + workflowId: 'workflow-123', + status: 'completed', + output: { result: 'done' }, + }, + }), + headers: { + get: vi.fn().mockReturnValue(null), + }, + } + vi.mocked(mockFetch).mockResolvedValue(mockResponse as any) + + const result = await client.getWorkflowExecution('workflow-123', 'execution-123', { + includeOutput: true, + selectedOutputs: ['agent.content'], + }) + + expect(result).toHaveProperty('executionId', 'execution-123') + expect(result).toHaveProperty('status', 'completed') + expect(result).toHaveProperty('output') + + const calls = vi.mocked(mockFetch).mock.calls + expect(calls[0][0]).toBe( + 'https://test.sim.ai/api/v2/workflows/workflow-123/executions/execution-123?includeOutput=true&selectedOutputs=agent.content' + ) + }) + + it('should handle execution not found errors', async () => { + const mockResponse = { + ok: false, + status: 404, + statusText: 'Not Found', + json: vi.fn().mockResolvedValue({ + error: { + code: 'NOT_FOUND', + message: 'Execution not found', + }, + }), + headers: { + get: vi.fn().mockReturnValue(null), + }, + } + vi.mocked(mockFetch).mockResolvedValue(mockResponse as any) + + await expect( + client.getWorkflowExecution('workflow-123', 'invalid-execution') + ).rejects.toThrow(SimStudioError) + await expect( + client.getWorkflowExecution('workflow-123', 'invalid-execution') + ).rejects.toThrow('Execution not found') + }) + }) + describe('executeWithRetry', () => { it('should succeed on first attempt when no rate limit', async () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: { result: 'success' }, - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse({ result: 'success' })), headers: { get: vi.fn().mockReturnValue(null), }, @@ -273,10 +331,7 @@ describe('SimStudioClient', () => { const successResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: { result: 'success' }, - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse({ result: 'success' })), headers: { get: vi.fn().mockReturnValue(null), }, @@ -334,8 +389,10 @@ describe('SimStudioClient', () => { status: 500, statusText: 'Internal Server Error', json: vi.fn().mockResolvedValue({ - error: 'Server error', - code: 'INTERNAL_ERROR', + error: { + code: 'INTERNAL_ERROR', + message: 'Server error', + }, }), headers: { get: vi.fn().mockReturnValue(null), @@ -362,7 +419,7 @@ describe('SimStudioClient', () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ success: true, output: {} }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse()), headers: { get: vi.fn((header: string) => { if (header === 'x-ratelimit-limit') return '100' @@ -468,10 +525,7 @@ describe('SimStudioClient', () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: {}, - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse()), headers: { get: vi.fn().mockReturnValue(null), }, @@ -488,7 +542,7 @@ describe('SimStudioClient', () => { const calls = vi.mocked(mockFetch).mock.calls const requestBody = JSON.parse(calls[0][1]?.body as string) - expect(requestBody).toHaveProperty('message', 'test') + expect(requestBody.input).toEqual({ message: 'test' }) expect(requestBody).toHaveProperty('stream', true) expect(requestBody).toHaveProperty('selectedOutputs') expect(requestBody.selectedOutputs).toEqual(['agent1.content', 'agent2.content']) @@ -500,10 +554,7 @@ describe('SimStudioClient', () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: {}, - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse()), headers: { get: vi.fn().mockReturnValue(null), }, @@ -516,7 +567,7 @@ describe('SimStudioClient', () => { const calls = vi.mocked(mockFetch).mock.calls const requestBody = JSON.parse(calls[0][1]?.body as string) - expect(requestBody).toHaveProperty('input', 'NVDA') + expect(requestBody.input).toEqual({ input: 'NVDA' }) expect(requestBody).not.toHaveProperty('0') // Should not spread string characters }) @@ -524,10 +575,7 @@ describe('SimStudioClient', () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: {}, - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse()), headers: { get: vi.fn().mockReturnValue(null), }, @@ -540,17 +588,14 @@ describe('SimStudioClient', () => { const calls = vi.mocked(mockFetch).mock.calls const requestBody = JSON.parse(calls[0][1]?.body as string) - expect(requestBody).toHaveProperty('input', 42) + expect(requestBody.input).toEqual({ input: 42 }) }) it('should wrap array input in input field', async () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: {}, - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse()), headers: { get: vi.fn().mockReturnValue(null), }, @@ -563,8 +608,7 @@ describe('SimStudioClient', () => { const calls = vi.mocked(mockFetch).mock.calls const requestBody = JSON.parse(calls[0][1]?.body as string) - expect(requestBody).toHaveProperty('input') - expect(requestBody.input).toEqual(['NVDA', 'AAPL', 'GOOG']) + expect(requestBody.input).toEqual({ input: ['NVDA', 'AAPL', 'GOOG'] }) expect(requestBody).not.toHaveProperty('0') // Should not spread array }) @@ -572,10 +616,7 @@ describe('SimStudioClient', () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: {}, - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse()), headers: { get: vi.fn().mockReturnValue(null), }, @@ -588,19 +629,14 @@ describe('SimStudioClient', () => { const calls = vi.mocked(mockFetch).mock.calls const requestBody = JSON.parse(calls[0][1]?.body as string) - expect(requestBody).toHaveProperty('ticker', 'NVDA') - expect(requestBody).toHaveProperty('quantity', 100) - expect(requestBody).not.toHaveProperty('input') // Should not wrap in input field + expect(requestBody.input).toEqual({ ticker: 'NVDA', quantity: 100 }) }) it('should handle null input as no input (empty body)', async () => { const mockResponse = { ok: true, status: 200, - json: vi.fn().mockResolvedValue({ - success: true, - output: {}, - }), + json: vi.fn().mockResolvedValue(v2ExecutionResponse()), headers: { get: vi.fn().mockReturnValue(null), }, @@ -613,8 +649,7 @@ describe('SimStudioClient', () => { const calls = vi.mocked(mockFetch).mock.calls const requestBody = JSON.parse(calls[0][1]?.body as string) - // null treated as "no input" - sends empty body (consistent with Python SDK) - expect(requestBody).toEqual({}) + expect(requestBody).toEqual({ input: {} }) }) }) }) diff --git a/packages/ts-sdk/src/index.ts b/packages/ts-sdk/src/index.ts index d1538ff5e84..4d8777867f5 100644 --- a/packages/ts-sdk/src/index.ts +++ b/packages/ts-sdk/src/index.ts @@ -45,9 +45,8 @@ export interface ExecutionOptions { export interface AsyncExecutionResult { success: boolean - jobId: string + executionId: string statusUrl: string - executionId?: string message: string async: true } @@ -60,6 +59,32 @@ export interface JobStatusResult { error?: string } +export interface WorkflowExecutionError { + code: string + message: string + details?: unknown +} + +export interface WorkflowExecutionStatus { + executionId: string + workflowId: string + status: 'queued' | 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled' + trigger: string | null + startedAt: string | null + endedAt: string | null + durationMs: number | null + paused: Record | null + cost: { total: number } | null + error: WorkflowExecutionError | null + output: unknown | null + blockOutputs: Record | null +} + +export interface GetWorkflowExecutionOptions { + includeOutput?: boolean + selectedOutputs?: string[] +} + export interface RateLimitInfo { limit: number remaining: number @@ -215,7 +240,7 @@ export class SimStudioClient { input?: any, options: ExecutionOptions = {} ): Promise { - const url = `${this.baseUrl}/api/workflows/${workflowId}/execute` + const url = `${this.baseUrl}/api/v2/workflows/${workflowId}/execute` const { timeout = 30000, stream, selectedOutputs, async } = options try { @@ -227,20 +252,18 @@ export class SimStudioClient { 'Content-Type': 'application/json', 'X-API-Key': this.apiKey, } - if (async) { - headers['X-Execution-Mode'] = 'async' - } - let jsonBody: any = {} + let workflowInput: any = {} if (input !== undefined && input !== null) { if (typeof input === 'object' && input !== null && !Array.isArray(input)) { - jsonBody = { ...input } + workflowInput = { ...input } } else { - jsonBody = { input } + workflowInput = { input } } } - jsonBody = await this.convertFilesToBase64(jsonBody) + workflowInput = await this.convertFilesToBase64(workflowInput) + const jsonBody: Record = { input: workflowInput } if (stream !== undefined) { jsonBody.stream = stream @@ -248,6 +271,9 @@ export class SimStudioClient { if (selectedOutputs !== undefined) { jsonBody.selectedOutputs = selectedOutputs } + if (async !== undefined) { + jsonBody.async = async + } const fetchPromise = fetch(url, { method: 'POST', @@ -269,16 +295,53 @@ export class SimStudioClient { } if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as unknown as any + const errorData = (await response.json().catch(() => ({}))) as { + error?: { code?: string; message?: string } + } throw new SimStudioError( - errorData.error || `HTTP ${response.status}: ${response.statusText}`, - errorData.code, + errorData.error?.message || `HTTP ${response.status}: ${response.statusText}`, + errorData.error?.code, response.status ) } - const result = await response.json() - return result as WorkflowExecutionResult | AsyncExecutionResult + const result = (await response.json()) as { + data?: { + executionId: string + statusUrl?: string + status?: 'completed' | 'failed' | 'paused' | 'cancelled' + output?: unknown + error?: WorkflowExecutionError | null + durationMs?: number + } + } + if (!result.data) { + throw new SimStudioError('Invalid v2 workflow execution response', 'EXECUTION_ERROR') + } + + if (response.status === 202) { + if (!result.data.statusUrl) { + throw new SimStudioError('Invalid v2 async execution response', 'EXECUTION_ERROR') + } + return { + success: true, + executionId: result.data.executionId, + statusUrl: result.data.statusUrl, + message: 'Workflow execution queued', + async: true, + } + } + + return { + success: result.data.status !== 'failed', + output: result.data.output, + error: result.data.error?.message, + metadata: { + duration: result.data.durationMs, + executionId: result.data.executionId, + }, + totalDuration: result.data.durationMs, + } } catch (error: any) { if (error instanceof SimStudioError) { throw error @@ -310,7 +373,10 @@ export class SimStudioClient { }) if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as unknown as any + const errorData = (await response.json().catch(() => ({}))) as { + error?: string + code?: string + } throw new SimStudioError( errorData.error || `HTTP ${response.status}: ${response.statusText}`, errorData.code, @@ -374,8 +440,8 @@ export class SimStudioClient { } /** - * Get the status of an async job - * @param taskId The job ID returned from async execution + * Get the status of a legacy async job. + * @param taskId The job ID returned from legacy async execution */ async getJobStatus(taskId: string): Promise { const url = `${this.baseUrl}/api/jobs/${taskId}` @@ -410,6 +476,62 @@ export class SimStudioClient { } } + /** + * Get a workflow execution's current status and optional outputs from the v2 API. + */ + async getWorkflowExecution( + workflowId: string, + executionId: string, + options: GetWorkflowExecutionOptions = {} + ): Promise { + const query = new URLSearchParams() + if (options.includeOutput !== undefined) { + query.set('includeOutput', String(options.includeOutput)) + } + if (options.selectedOutputs?.length) { + query.set('selectedOutputs', options.selectedOutputs.join(',')) + } + const queryString = query.toString() + const url = `${this.baseUrl}/api/v2/workflows/${workflowId}/executions/${executionId}${queryString ? `?${queryString}` : ''}` + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'X-API-Key': this.apiKey, + }, + }) + + this.updateRateLimitInfo(response) + + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + error?: { code?: string; message?: string } + } + throw new SimStudioError( + errorData.error?.message || `HTTP ${response.status}: ${response.statusText}`, + errorData.error?.code, + response.status + ) + } + + const result = (await response.json()) as { data?: WorkflowExecutionStatus } + if (!result.data) { + throw new SimStudioError('Invalid v2 workflow execution response', 'STATUS_ERROR') + } + return result.data + } catch (error: any) { + if (error instanceof SimStudioError) { + throw error + } + + throw new SimStudioError( + describeError(error) || 'Failed to get workflow execution', + 'STATUS_ERROR' + ) + } + } + /** * Execute workflow with automatic retry on rate limit * @param workflowId - The ID of the workflow to execute From 01c31761e7ab12c0ff250ccaf65c6a1c734471b2 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 6 Aug 2026 10:08:29 -0700 Subject: [PATCH 084/159] improvement(api): unify v2 resource query layers (#6319) * improvement(api): unify v2 resource query layers * fix(api): address v2 review findings * fix(api): preserve cancelled queue status * fix(api): guard cancelled job transitions --- apps/docs/openapi-core.json | 131 ++++++--- apps/docs/openapi-v2-files-audit.json | 30 +- apps/docs/openapi-v2-logs.json | 271 ++++-------------- apps/docs/openapi-v2-workflows.json | 191 ++++++++++++ .../cron/cleanup-stale-executions/route.ts | 7 +- .../app/api/knowledge/search/route.test.ts | 2 +- apps/sim/app/api/knowledge/search/route.ts | 10 +- .../app/api/knowledge/search/utils.test.ts | 4 +- apps/sim/app/api/knowledge/utils.ts | 41 +-- apps/sim/app/api/users/me/usage-logs/route.ts | 2 +- apps/sim/app/api/v1/audit-logs/auth.test.ts | 10 +- apps/sim/app/api/v1/audit-logs/auth.ts | 8 +- .../app/api/v1/knowledge/search/route.test.ts | 2 +- apps/sim/app/api/v1/knowledge/search/route.ts | 8 +- apps/sim/app/api/v1/logs/[id]/route.ts | 35 +-- .../v1/logs/executions/[executionId]/route.ts | 33 +-- apps/sim/app/api/v1/logs/route.ts | 79 ++--- apps/sim/app/api/v1/workflows/utils.ts | 23 +- apps/sim/app/api/v2/audit-logs/[id]/route.ts | 22 +- apps/sim/app/api/v2/audit-logs/route.test.ts | 94 ++++++ apps/sim/app/api/v2/audit-logs/route.ts | 22 +- .../sim/app/api/v2/billing/logs/route.test.ts | 141 +++++++++ .../api/v2/billing/{usage => }/logs/route.ts | 21 +- .../app/api/v2/billing/status/route.test.ts | 171 +++++++++++ apps/sim/app/api/v2/billing/status/route.ts | 118 ++++++++ .../api/v2/billing/usage/logs/route.test.ts | 156 ---------- .../app/api/v2/billing/usage/route.test.ts | 147 ---------- apps/sim/app/api/v2/billing/usage/route.ts | 92 ------ apps/sim/app/api/v2/billing/utils.ts | 55 +++- .../[id]/documents/[documentId]/route.ts | 55 +--- .../api/v2/knowledge/[id]/documents/route.ts | 10 +- apps/sim/app/api/v2/knowledge/search/route.ts | 63 ++-- .../{[id] => [executionId]}/route.test.ts | 29 +- .../v2/logs/{[id] => [executionId]}/route.ts | 58 ++-- .../v2/logs/executions/[executionId]/route.ts | 79 ----- apps/sim/app/api/v2/logs/route.ts | 77 ++--- .../app/api/v2/workflows/[id]/deploy/route.ts | 10 +- .../executions/[executionId]/cancel/route.ts | 8 +- .../workflows/[id]/executions/route.test.ts | 145 ++++++++++ .../api/v2/workflows/[id]/executions/route.ts | 98 +++++++ .../api/v2/workflows/[id]/rollback/route.ts | 6 +- apps/sim/app/api/v2/workflows/[id]/route.ts | 26 +- .../[id]/versions/[version]/route.ts | 12 +- .../api/v2/workflows/[id]/versions/route.ts | 12 +- apps/sim/app/api/v2/workflows/route.ts | 115 ++------ apps/sim/app/api/v2/workflows/utils.ts | 20 ++ .../[executionId]/cancel/route.test.ts | 55 ++++ .../executions/[executionId]/cancel/route.ts | 8 +- apps/sim/app/api/workflows/[id]/route.ts | 14 +- apps/sim/lib/api/contracts/common.ts | 2 +- apps/sim/lib/api/contracts/v2/audit-logs.ts | 18 +- apps/sim/lib/api/contracts/v2/billing.ts | 62 ++-- apps/sim/lib/api/contracts/v2/logs.ts | 52 +--- apps/sim/lib/api/contracts/v2/workflows.ts | 69 ++++- apps/sim/lib/api/list-query.ts | 9 +- .../lib/billing/core/billing-attribution.ts | 4 +- .../server/knowledge/knowledge-base.test.ts | 2 +- .../tools/server/knowledge/knowledge-base.ts | 2 +- .../core/async-jobs/backends/database.test.ts | 47 ++- .../lib/core/async-jobs/backends/database.ts | 59 ++-- .../async-jobs/backends/trigger-dev.test.ts | 19 +- .../core/async-jobs/backends/trigger-dev.ts | 3 +- apps/sim/lib/core/async-jobs/types.ts | 5 +- .../execution/cancel-workflow-execution.ts | 47 ++- apps/sim/lib/execution/cancellation.test.ts | 7 + apps/sim/lib/execution/cancellation.ts | 3 +- apps/sim/lib/knowledge/documents/service.ts | 43 ++- .../sim/lib/knowledge/documents/tag-filter.ts | 2 +- .../knowledge/search/queries.ts} | 2 +- apps/sim/lib/logs/fetch-log-detail.ts | 56 +--- .../filters.ts => lib/logs/public-filters.ts} | 1 + apps/sim/lib/logs/public-queries.test.ts | 29 ++ apps/sim/lib/logs/public-queries.ts | 185 ++++++++++++ apps/sim/lib/workflows/deployments/queries.ts | 15 + .../workflows/executor/enqueue-execution.ts | 8 +- .../workflows/executor/execution-job-ids.ts | 2 + .../executor/execution-queries.test.ts | 51 ++++ .../workflows/executor/execution-queries.ts | 130 +++++++++ .../executor/execution-status.test.ts | 32 ++- .../workflows/executor/execution-status.ts | 2 +- apps/sim/lib/workflows/queries.ts | 144 +++++++++- 81 files changed, 2437 insertions(+), 1501 deletions(-) create mode 100644 apps/sim/app/api/v2/audit-logs/route.test.ts create mode 100644 apps/sim/app/api/v2/billing/logs/route.test.ts rename apps/sim/app/api/v2/billing/{usage => }/logs/route.ts (82%) create mode 100644 apps/sim/app/api/v2/billing/status/route.test.ts create mode 100644 apps/sim/app/api/v2/billing/status/route.ts delete mode 100644 apps/sim/app/api/v2/billing/usage/logs/route.test.ts delete mode 100644 apps/sim/app/api/v2/billing/usage/route.test.ts delete mode 100644 apps/sim/app/api/v2/billing/usage/route.ts rename apps/sim/app/api/v2/logs/{[id] => [executionId]}/route.test.ts (74%) rename apps/sim/app/api/v2/logs/{[id] => [executionId]}/route.ts (63%) delete mode 100644 apps/sim/app/api/v2/logs/executions/[executionId]/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/executions/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/executions/route.ts create mode 100644 apps/sim/app/api/v2/workflows/utils.ts rename apps/sim/{app/api/knowledge/search/utils.ts => lib/knowledge/search/queries.ts} (99%) rename apps/sim/{app/api/v1/logs/filters.ts => lib/logs/public-filters.ts} (98%) create mode 100644 apps/sim/lib/logs/public-queries.test.ts create mode 100644 apps/sim/lib/logs/public-queries.ts create mode 100644 apps/sim/lib/workflows/deployments/queries.ts create mode 100644 apps/sim/lib/workflows/executor/execution-job-ids.ts create mode 100644 apps/sim/lib/workflows/executor/execution-queries.test.ts create mode 100644 apps/sim/lib/workflows/executor/execution-queries.ts diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index ecde7d1730f..b7020ae27f9 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -32,6 +32,10 @@ { "name": "Usage", "description": "Check rate limits and billing usage" + }, + { + "name": "Billing", + "description": "Inspect billing status and credit-denominated ledger events" } ], "security": [ @@ -1014,12 +1018,12 @@ "parameters": [] } }, - "/api/v2/billing/usage": { + "/api/v2/billing/status": { "get": { - "operationId": "getUsageSummary", - "summary": "Get Usage Summary", - "description": "Current-billing-period usage with the per-source credit breakdown (`workflow`, `sim-chat`, `knowledge-base`, …) — monitor one source's consumption directly instead of estimating it by subtraction. Sim Chat combines the internal Copilot and workspace-chat ledgers. All values are credits (1,000 credits = $5); dollar costs are not part of this surface.", - "tags": ["Usage"], + "operationId": "getBillingStatus", + "summary": "Get Billing Status", + "description": "Return the current plan, billing standing, period, and credit allowance. This endpoint never embeds ledger rows or per-source analytics; use `GET /api/v2/billing/logs` for billing history.", + "tags": ["Billing"], "security": [ { "apiKey": [] @@ -1033,12 +1037,12 @@ "schema": { "type": "string" }, - "description": "Restrict to one workspace. A workspace-scoped API key is always pinned to its own workspace; passing a different id returns 403." + "description": "Resolve the status against this workspace's actual payer. A workspace-scoped API key is pinned to its own workspace; passing a different id returns 403." } ], "responses": { "200": { - "description": "The current billing period's usage summary.", + "description": "The current billing status.", "content": { "application/json": { "schema": { @@ -1047,14 +1051,12 @@ "properties": { "data": { "type": "object", - "required": [ - "period", - "totalCredits", - "bySourceCredits", - "limitCredits", - "plan" - ], + "required": ["workspaceId", "period", "plan", "status", "credits"], "properties": { + "workspaceId": { + "type": ["string", "null"], + "description": "The workspace whose payer was resolved, or null for account billing." + }, "period": { "type": "object", "required": ["start", "end"], @@ -1069,21 +1071,27 @@ } } }, - "totalCredits": { - "type": "number" - }, - "bySourceCredits": { - "type": "object", - "additionalProperties": { - "type": "number" - }, - "description": "Credits consumed per usage source over the billing period." - }, - "limitCredits": { - "type": "number" - }, "plan": { "type": "string" + }, + "status": { + "type": "string", + "enum": ["active", "limit_exceeded", "billing_blocked"] + }, + "credits": { + "type": "object", + "required": ["used", "limit", "remaining"], + "properties": { + "used": { + "type": "number" + }, + "limit": { + "type": "number" + }, + "remaining": { + "type": "number" + } + } } } } @@ -1091,18 +1099,18 @@ }, "example": { "data": { + "workspaceId": null, "period": { "start": "2026-07-01T00:00:00.000Z", "end": "2026-08-01T00:00:00.000Z" }, - "totalCredits": 512, - "bySourceCredits": { - "workflow": 380, - "sim-chat": 120, - "knowledge-base": 12 - }, - "limitCredits": 20000, - "plan": "pro" + "plan": "pro", + "status": "active", + "credits": { + "used": 512, + "limit": 20000, + "remaining": 19488 + } } } } @@ -1123,12 +1131,12 @@ } } }, - "/api/v2/billing/usage/logs": { + "/api/v2/billing/logs": { "get": { - "operationId": "listUsageLogs", - "summary": "List Usage Logs", - "description": "Cursor-paged, credit-denominated ledger of the account's usage events. The per-source aggregate lives on `GET /api/v2/billing/usage`; this is the row-level detail. Page by passing `nextCursor` back as `cursor` and stop when it is null.", - "tags": ["Usage"], + "operationId": "listBillingLogs", + "summary": "List Billing Logs", + "description": "Cursor-paged, credit-denominated billing ledger. This endpoint returns history only and never embeds the current billing status. Page by passing `nextCursor` back as `cursor` and stop when it is null.", + "tags": ["Billing"], "security": [ { "apiKey": [] @@ -1226,7 +1234,15 @@ "type": "array", "items": { "type": "object", - "required": ["id", "createdAt", "source", "workflowName", "creditCost"], + "required": [ + "id", + "createdAt", + "source", + "workspaceId", + "workflow", + "executionId", + "creditCost" + ], "properties": { "id": { "type": "string" @@ -1249,9 +1265,30 @@ "voice-output" ] }, - "workflowName": { - "type": ["string", "null"], - "description": "Populated only when `source` is `workflow`." + "workspaceId": { + "type": ["string", "null"] + }, + "workflow": { + "oneOf": [ + { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": ["string", "null"] + } + } + }, + { + "type": "null" + } + ] + }, + "executionId": { + "type": ["string", "null"] }, "creditCost": { "type": "number", @@ -1272,7 +1309,9 @@ "id": "log_1", "createdAt": "2026-07-29T18:04:11.000Z", "source": "sim-chat", - "workflowName": null, + "workspaceId": "ws_1", + "workflow": null, + "executionId": null, "creditCost": 12 } ], @@ -1445,7 +1484,7 @@ }, "status": { "type": "string", - "enum": ["queued", "processing", "completed", "failed"], + "enum": ["queued", "processing", "completed", "failed", "cancelled"], "description": "Current status of the job.", "example": "completed" }, diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 5b314f60a37..1235ef28c4d 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -777,17 +777,28 @@ "get": { "operationId": "listAuditLogs", "summary": "List Audit Logs", - "description": "List audit log entries for the authenticated user's organization with opaque cursor pagination. These are organization-scoped (not workspace-scoped) enterprise endpoints: the caller must belong to an organization with an active Enterprise subscription and hold an admin or owner role — otherwise the request returns `403`. The `ipAddress` and `userAgent` fields are intentionally excluded from entries for privacy.", + "description": "List audit log entries for an explicitly selected organization with opaque cursor pagination. These organization-scoped enterprise endpoints require a personal API key; workspace-scoped keys return `403`. The caller must belong to the selected organization, hold an admin or owner role, and have an active Enterprise subscription. The `ipAddress` and `userAgent` fields are intentionally excluded from entries for privacy.", "tags": ["Audit Logs"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs?limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs?organizationId=org_abc123&limit=50\" \\\n -H \"X-API-Key: YOUR_PERSONAL_API_KEY\"" } ], "parameters": [ + { + "name": "organizationId", + "in": "query", + "required": true, + "description": "Organization to audit. The caller must be an admin or owner of this organization.", + "schema": { + "type": "string", + "minLength": 1, + "example": "org_abc123" + } + }, { "name": "action", "in": "query", @@ -958,14 +969,14 @@ "get": { "operationId": "getAuditLog", "summary": "Get Audit Log", - "description": "Retrieve a single audit log entry by ID, scoped to the authenticated user's organization. Organization-scoped (not workspace-scoped): the caller must belong to an organization with an active Enterprise subscription and hold an admin or owner role — otherwise the request returns `403`. An entry outside your organization returns `404` (existence is not leaked). The `ipAddress` and `userAgent` fields are intentionally excluded for privacy.", + "description": "Retrieve a single audit log entry by ID within an explicitly selected organization. This endpoint requires a personal API key; workspace-scoped keys return `403`. The caller must belong to the selected organization, hold an admin or owner role, and have an active Enterprise subscription. An entry outside that organization returns `404` (existence is not leaked). The `ipAddress` and `userAgent` fields are intentionally excluded for privacy.", "tags": ["Audit Logs"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs/{id}?organizationId=org_abc123\" \\\n -H \"X-API-Key: YOUR_PERSONAL_API_KEY\"" } ], "parameters": [ @@ -979,6 +990,17 @@ "minLength": 1, "example": "audit_2c3d4e5f6g" } + }, + { + "name": "organizationId", + "in": "query", + "required": true, + "description": "Organization that owns the audit entry. The caller must be an admin or owner of this organization.", + "schema": { + "type": "string", + "minLength": 1, + "example": "org_abc123" + } } ], "responses": { diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 2a4f40bc765..13b6fac185b 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -249,10 +249,10 @@ "example": { "data": [ { - "id": "log_7x8y9z0a1b", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "deploymentVersionId": "dep_2c4e6a8b0d1f", + "status": "completed", "level": "info", "trigger": "api", "startedAt": "2026-01-15T10:30:00.000Z", @@ -287,35 +287,35 @@ } } }, - "/api/v2/logs/{id}": { + "/api/v2/logs/{executionId}": { "get": { "operationId": "getLog", "summary": "Get Log", - "description": "Retrieve a single log entry by its ID, including workflow metadata, materialized execution data, a top-level `traceSpans` array, and the cost summary. Returns `{ data }`.", + "description": "Retrieve the diagnostic representation of an execution by its execution ID, including workflow metadata and state, trace spans, final output, and cost. Logs and executions share the same public identity; no separate log ID is exposed.", "tags": ["Logs"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], "parameters": [ { - "name": "id", + "name": "executionId", "in": "path", "required": true, - "description": "The unique identifier of the log entry.", + "description": "The unique execution identifier shared by the lifecycle and diagnostic resources.", "schema": { "type": "string", - "example": "log_7x8y9z0a1b" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" } } ], "responses": { "200": { - "description": "The requested log entry with full execution data, trace spans, and cost summary.", + "description": "The requested diagnostic log representation.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -340,9 +340,10 @@ }, "example": { "data": { - "id": "log_7x8y9z0a1b", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "deploymentVersionId": "dep_2c4e6a8b0d1f", + "status": "completed", "level": "info", "trigger": "api", "startedAt": "2026-01-15T10:30:00.000Z", @@ -360,13 +361,14 @@ "updatedAt": "2025-06-18T16:45:00.000Z", "deleted": false }, - "executionData": { - "traceSpans": [], - "finalOutput": { - "result": "Hello, world!" - } + "workflowState": { + "blocks": {}, + "edges": [] }, "traceSpans": [], + "finalOutput": { + "result": "Hello, world!" + }, "cost": { "total": 0.0032 }, @@ -390,96 +392,6 @@ } } } - }, - "/api/v2/logs/executions/{executionId}": { - "get": { - "operationId": "getExecution", - "summary": "Get Execution", - "description": "Retrieve the full execution state snapshot for a run: the workflow state captured at execution time plus execution metadata (trigger, timing, and cost). Returns `{ data }`.", - "tags": ["Logs"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/executions/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" - } - ], - "parameters": [ - { - "name": "executionId", - "in": "path", - "required": true, - "description": "The unique execution identifier.", - "schema": { - "type": "string", - "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" - } - } - ], - "responses": { - "200": { - "description": "The full execution state snapshot with workflow state and metadata.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["data"], - "properties": { - "data": { - "$ref": "#/components/schemas/Execution" - } - } - }, - "example": { - "data": { - "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "workflowState": { - "blocks": {}, - "edges": [], - "loops": {}, - "parallels": {} - }, - "executionMetadata": { - "trigger": "api", - "startedAt": "2026-01-15T10:30:00.000Z", - "endedAt": "2026-01-15T10:30:01.250Z", - "totalDurationMs": 1250, - "cost": { - "total": 0.0032 - } - } - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } } }, "components": { @@ -638,10 +550,10 @@ "type": "object", "description": "Summary of a single workflow execution log entry returned by the list endpoint.", "required": [ - "id", - "workflowId", "executionId", + "workflowId", "deploymentVersionId", + "status", "level", "trigger", "startedAt", @@ -651,26 +563,26 @@ "files" ], "properties": { - "id": { + "executionId": { "type": "string", - "description": "Unique log entry identifier.", - "example": "log_7x8y9z0a1b" + "description": "The sole public identifier for both the execution and its log representation.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" }, "workflowId": { "type": ["string", "null"], "description": "The workflow that was executed. null if the log is not associated with a workflow.", "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, - "executionId": { - "type": "string", - "description": "Unique execution identifier for this run.", - "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" - }, "deploymentVersionId": { "type": ["string", "null"], "description": "The deployment version that produced this run. null for runs not tied to a deployment.", "example": "dep_2c4e6a8b0d1f" }, + "status": { + "type": "string", + "description": "Durable execution status recorded with this log.", + "example": "completed" + }, "level": { "type": "string", "description": "Log severity. info for successful executions, error for failures.", @@ -734,11 +646,12 @@ }, "LogDetail": { "type": "object", - "description": "Detailed log entry with full workflow metadata, materialized execution data, top-level trace spans, and cost summary.", + "description": "Diagnostic representation of an execution, addressed by the same execution ID as its lifecycle resource.", "required": [ - "id", - "workflowId", "executionId", + "workflowId", + "deploymentVersionId", + "status", "level", "trigger", "startedAt", @@ -746,26 +659,31 @@ "totalDurationMs", "files", "workflow", - "executionData", + "workflowState", "traceSpans", + "finalOutput", "cost", "createdAt" ], "properties": { - "id": { + "executionId": { "type": "string", - "description": "Unique log entry identifier.", - "example": "log_7x8y9z0a1b" + "description": "The sole public identifier for both the execution and its log representation.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" }, "workflowId": { "type": ["string", "null"], "description": "The workflow that was executed. null if the log is not associated with a workflow.", "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, - "executionId": { + "deploymentVersionId": { + "type": ["string", "null"], + "description": "The deployment version that produced this run. null for runs not tied to a deployment." + }, + "status": { "type": "string", - "description": "Unique execution identifier for this run.", - "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + "description": "Durable execution status recorded with this log.", + "example": "completed" }, "level": { "type": "string", @@ -805,25 +723,10 @@ "workflow": { "$ref": "#/components/schemas/LogWorkflowDetail" }, - "executionData": { + "workflowState": { "type": "object", "additionalProperties": true, - "description": "Materialized execution trace for this run (block states, trace spans, and final output). Large blobs stored externally are resolved inline.", - "properties": { - "traceSpans": { - "type": "array", - "description": "Block-level execution traces with timing, inputs, and outputs.", - "items": { - "type": "object", - "additionalProperties": true - } - }, - "finalOutput": { - "type": "object", - "additionalProperties": true, - "description": "The workflow's final output after all blocks completed." - } - } + "description": "Snapshot of the workflow configuration at execution time." }, "traceSpans": { "type": "array", @@ -833,6 +736,9 @@ "additionalProperties": true } }, + "finalOutput": { + "description": "Materialized final output, or null when the execution produced none." + }, "cost": { "$ref": "#/components/schemas/Cost" }, @@ -844,85 +750,6 @@ } } }, - "Execution": { - "type": "object", - "description": "Full execution state snapshot: the workflow state at execution time plus execution metadata.", - "required": ["executionId", "workflowId", "workflowState", "executionMetadata"], - "properties": { - "executionId": { - "type": "string", - "description": "The unique identifier for this execution.", - "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" - }, - "workflowId": { - "type": ["string", "null"], - "description": "The workflow that was executed. null if the log is not associated with a workflow.", - "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" - }, - "workflowState": { - "type": "object", - "additionalProperties": true, - "description": "Snapshot of the workflow configuration at the time of execution.", - "properties": { - "blocks": { - "type": "object", - "additionalProperties": true, - "description": "Map of block IDs to their configuration and state during execution." - }, - "edges": { - "type": "array", - "description": "Connections between blocks defining the execution flow.", - "items": { - "type": "object", - "additionalProperties": true - } - }, - "loops": { - "type": "object", - "additionalProperties": true, - "description": "Loop configurations defining iterative execution patterns." - }, - "parallels": { - "type": "object", - "additionalProperties": true, - "description": "Parallel execution group configurations." - } - } - }, - "executionMetadata": { - "type": "object", - "description": "Metadata about the execution including trigger, timing, and cost.", - "required": ["trigger", "startedAt", "endedAt", "totalDurationMs", "cost"], - "properties": { - "trigger": { - "type": "string", - "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).", - "example": "api" - }, - "startedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when execution started.", - "example": "2026-01-15T10:30:00.000Z" - }, - "endedAt": { - "type": ["string", "null"], - "format": "date-time", - "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.", - "example": "2026-01-15T10:30:01.250Z" - }, - "totalDurationMs": { - "type": ["integer", "null"], - "description": "Total execution duration in milliseconds. null if the run has not finished.", - "example": 1250 - }, - "cost": { - "$ref": "#/components/schemas/Cost" - } - } - } - } - }, "Error": { "type": "object", "description": "Canonical v2 error envelope.", diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 00d943e77a7..32561a872e2 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -1354,6 +1354,149 @@ } } }, + "/api/v2/workflows/{id}/executions": { + "get": { + "operationId": "listWorkflowExecutionsV2", + "summary": "List workflow executions", + "description": "List the durable executions belonging to one workflow. Freshly queued runs are available through their execution status URL but do not enter this history until durable execution logging begins. This lifecycle collection is intentionally lightweight; fetch one execution for output and pause detail, or fetch `/api/v2/logs/{executionId}` for diagnostic trace data.", + "tags": ["Workflows"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["pending", "running", "completed", "failed", "cancelled", "paused"] + } + }, + { + "name": "trigger", + "in": "query", + "required": false, + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "startDate", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "order", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } + } + ], + "responses": { + "200": { + "description": "A page of workflow executions.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowExecutionListItem" + } + }, + "nextCursor": { + "type": ["string", "null"] + } + } + }, + "example": { + "data": [ + { + "executionId": "exec_1", + "workflowId": "wf_123", + "status": "completed", + "trigger": "api", + "startedAt": "2026-07-31T00:00:00.000Z", + "endedAt": "2026-07-31T00:00:01.000Z", + "durationMs": 1000, + "cost": { + "total": 0.02 + } + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, "/api/v2/workflows/{id}/executions/{executionId}": { "get": { "operationId": "getWorkflowExecutionV2", @@ -2717,6 +2860,54 @@ } } }, + "WorkflowExecutionListItem": { + "type": "object", + "required": [ + "executionId", + "workflowId", + "status", + "trigger", + "startedAt", + "endedAt", + "durationMs", + "cost" + ], + "properties": { + "executionId": { + "type": "string" + }, + "workflowId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["pending", "running", "completed", "failed", "cancelled", "paused"] + }, + "trigger": { + "type": "string" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "endedAt": { + "type": ["string", "null"], + "format": "date-time" + }, + "durationMs": { + "type": ["number", "null"] + }, + "cost": { + "type": ["object", "null"], + "required": ["total"], + "properties": { + "total": { + "type": "number" + } + } + } + } + }, "ExecutionResource": { "type": "object", "required": ["executionId", "workflowId", "status", "output", "error"], diff --git a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts index e58d9a037d4..90daf86fd64 100644 --- a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts +++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts @@ -203,7 +203,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } - // Delete completed/failed jobs older than retention period const retentionThreshold = new Date(Date.now() - JOB_RETENTION_HOURS * 60 * 60 * 1000) let asyncJobsDeleted = 0 @@ -212,7 +211,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .delete(asyncJobs) .where( and( - inArray(asyncJobs.status, [JOB_STATUS.COMPLETED, JOB_STATUS.FAILED]), + inArray(asyncJobs.status, [ + JOB_STATUS.COMPLETED, + JOB_STATUS.FAILED, + JOB_STATUS.CANCELLED, + ]), lt(asyncJobs.completedAt, retentionThreshold) ) ) diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index 6fdf4cdfdc6..1a5e57ae334 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -67,7 +67,7 @@ vi.mock('@/lib/knowledge/tags/service', () => ({ getDocumentTagDefinitions: mockGetDocumentTagDefinitions, })) -vi.mock('./utils', () => ({ +vi.mock('@/lib/knowledge/search/queries', () => ({ executeKnowledgeSearch: mockExecuteKnowledgeSearch, generateSearchEmbedding: mockGenerateSearchEmbedding, getDocumentMetadataByIds: mockGetDocumentMetadataByIds, diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index 12a88854571..fcccd247ac4 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -22,16 +22,16 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' import { getEmbeddingModelInfo } from '@/lib/knowledge/embedding-models' import { rerank } from '@/lib/knowledge/reranker' -import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' -import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' -import type { StructuredFilter } from '@/lib/knowledge/types' -import { estimateTokenCount } from '@/lib/tokenization/estimators' import { executeKnowledgeSearch, generateSearchEmbedding, getDocumentMetadataByIds, type SearchResult, -} from '@/app/api/knowledge/search/utils' +} from '@/lib/knowledge/search/queries' +import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' +import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' +import type { StructuredFilter } from '@/lib/knowledge/types' +import { estimateTokenCount } from '@/lib/tokenization/estimators' import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' import { getRerankModelPricing } from '@/providers/models' import { calculateCost } from '@/providers/utils' diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 461289c09d2..641053d867f 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -56,7 +56,7 @@ import { handleVectorOnlySearch, RRF_K, type SearchResult, -} from '@/app/api/knowledge/search/utils' +} from '@/lib/knowledge/search/queries' /** Minimal SearchResult builder — only the fields fusion and ordering read. */ function makeResult(id: string, distance = 0.1): SearchResult { @@ -795,7 +795,7 @@ describe('Knowledge Search Utils', () => { describe('getDocumentMetadataByIds', () => { it('should handle empty input gracefully', async () => { - const { getDocumentMetadataByIds } = await import('./utils') + const { getDocumentMetadataByIds } = await import('@/lib/knowledge/search/queries') const result = await getDocumentMetadataByIds([]) diff --git a/apps/sim/app/api/knowledge/utils.ts b/apps/sim/app/api/knowledge/utils.ts index 13c9079202f..e92dc49f419 100644 --- a/apps/sim/app/api/knowledge/utils.ts +++ b/apps/sim/app/api/knowledge/utils.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' -import { document, embedding, knowledgeBase } from '@sim/db/schema' +import { embedding, knowledgeBase } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' +import { getKnowledgeDocument } from '@/lib/knowledge/documents/service' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' interface KnowledgeBaseData { @@ -243,27 +244,14 @@ async function resolveDocumentAccess( } } - const doc = await db - .select() - .from(document) - .where( - and( - eq(document.id, documentId), - eq(document.knowledgeBaseId, knowledgeBaseId), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) - .limit(1) - - if (doc.length === 0) { + const doc = await getKnowledgeDocument(knowledgeBaseId, documentId) + if (!doc) { return { hasAccess: false, notFound: true, reason: 'Document not found' } } return { hasAccess: true, - document: doc[0] as DocumentData, + document: doc, knowledgeBase: kbAccess.knowledgeBase!, } } @@ -313,25 +301,12 @@ async function resolveChunkAccess( } } - const doc = await db - .select() - .from(document) - .where( - and( - eq(document.id, documentId), - eq(document.knowledgeBaseId, knowledgeBaseId), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) - .limit(1) - - if (doc.length === 0) { + const doc = await getKnowledgeDocument(knowledgeBaseId, documentId) + if (!doc) { return { hasAccess: false, notFound: true, reason: 'Document not found' } } - const docData = doc[0] as DocumentData + const docData = doc // Chunks are only accessible once the document has finished processing. if (docData.processingStatus !== 'completed') { diff --git a/apps/sim/app/api/users/me/usage-logs/route.ts b/apps/sim/app/api/users/me/usage-logs/route.ts index 483ae2bddde..b006b84264a 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -18,7 +18,7 @@ const logger = createLogger('UsageLogsAPI') /** * Lists the authenticated user's credit-consuming usage events (model, tool, * and fixed charges), converted to credits for display in Billing settings. - * Session-only — the API-key-facing equivalent is `GET /api/v2/billing/usage/logs`. + * Session-only — the API-key-facing equivalent is `GET /api/v2/billing/logs`. */ export const GET = withRouteHandler(async (request: NextRequest) => { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) diff --git a/apps/sim/app/api/v1/audit-logs/auth.test.ts b/apps/sim/app/api/v1/audit-logs/auth.test.ts index e8122de36dd..d9aa8f48455 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.test.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.test.ts @@ -99,13 +99,17 @@ describe('enterprise audit access', () => { expect(result.success).toBe(false) }) - it('still requires organization membership', async () => { + it('names the requested organization when target membership is missing', async () => { setEnvFlags({ isAuditLogsEnabled: true }) queueTableRows(schemaMock.member, []) - const result = await validateEnterpriseAuditAccess('viewer') + const result = await validateEnterpriseAuditAccess('viewer', 'organization-route') - expect(result.success).toBe(false) + if (result.success) throw new Error('Expected organization membership to be rejected') + expect(result.response.status).toBe(403) + await expect(result.response.json()).resolves.toEqual({ + error: 'Not a member of the requested organization', + }) }) }) }) diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts index 60c3d61fc5c..7076d5ec7d0 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -65,7 +65,13 @@ export async function resolveEnterpriseAuditAccess( .limit(1) if (!membership) { - return { success: false, status: 403, message: 'Not a member of any organization' } + return { + success: false, + status: 403, + message: targetOrganizationId + ? 'Not a member of the requested organization' + : 'Not a member of any organization', + } } if (membership.role !== 'admin' && membership.role !== 'owner') { diff --git a/apps/sim/app/api/v1/knowledge/search/route.test.ts b/apps/sim/app/api/v1/knowledge/search/route.test.ts index 8f75f565454..7c5cbc2fc23 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.test.ts @@ -44,7 +44,7 @@ const SYSTEM_BILLING_ATTRIBUTION = { payerSubscription: null, } -vi.mock('@/app/api/knowledge/search/utils', () => ({ +vi.mock('@/lib/knowledge/search/queries', () => ({ executeKnowledgeSearch: mockExecuteKnowledgeSearch, generateSearchEmbedding: mockGenerateSearchEmbedding, getDocumentMetadataByIds: mockGetDocumentMetadataByIds, diff --git a/apps/sim/app/api/v1/knowledge/search/route.ts b/apps/sim/app/api/v1/knowledge/search/route.ts index 490c8e88b7b..a54a4685b6b 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.ts @@ -9,15 +9,15 @@ import { import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' -import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' -import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' -import type { StructuredFilter } from '@/lib/knowledge/types' import { executeKnowledgeSearch, generateSearchEmbedding, getDocumentMetadataByIds, type SearchResult, -} from '@/app/api/knowledge/search/utils' +} from '@/lib/knowledge/search/queries' +import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' +import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' +import type { StructuredFilter } from '@/lib/knowledge/types' import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' import { handleError } from '@/app/api/v1/knowledge/utils' import { diff --git a/apps/sim/app/api/v1/logs/[id]/route.ts b/apps/sim/app/api/v1/logs/[id]/route.ts index 108e9fc534e..12066f2f9cc 100644 --- a/apps/sim/app/api/v1/logs/[id]/route.ts +++ b/apps/sim/app/api/v1/logs/[id]/route.ts @@ -1,13 +1,11 @@ -import { db } from '@sim/db' -import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1GetLogContract } from '@/lib/api/contracts/v1/logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +import { getPublicWorkflowLog } from '@/lib/logs/public-queries' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, @@ -38,36 +36,7 @@ export const GET = withRouteHandler( const { id } = parsed.data.params - const rows = await db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - executionId: workflowExecutionLogs.executionId, - stateSnapshotId: workflowExecutionLogs.stateSnapshotId, - level: workflowExecutionLogs.level, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - costTotal: workflowExecutionLogs.costTotal, - files: workflowExecutionLogs.files, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .where(eq(workflowExecutionLogs.id, id)) - .limit(1) - - const log = rows[0] + const log = await getPublicWorkflowLog({ column: 'id', value: id }) if (!log) { return NextResponse.json({ error: 'Log not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts index eefad39bb80..cd2c2e5cead 100644 --- a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts +++ b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts @@ -1,11 +1,9 @@ -import { db } from '@sim/db' -import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1GetExecutionContract } from '@/lib/api/contracts/v1/logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getPublicWorkflowLog } from '@/lib/logs/public-queries' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, @@ -15,6 +13,13 @@ import { const logger = createLogger('V1ExecutionAPI') +function countWorkflowStateBlocks(workflowState: unknown): number { + if (!workflowState || typeof workflowState !== 'object' || Array.isArray(workflowState)) return 0 + const blocks = (workflowState as Record).blocks + if (!blocks || typeof blocks !== 'object' || Array.isArray(blocks)) return 0 + return Object.keys(blocks).length +} + export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => { try { @@ -34,37 +39,25 @@ export const GET = withRouteHandler( logger.debug(`Fetching execution data for: ${executionId}`) - const rows = await db - .select() - .from(workflowExecutionLogs) - .where(eq(workflowExecutionLogs.executionId, executionId)) - .limit(1) + const workflowLog = await getPublicWorkflowLog({ column: 'executionId', value: executionId }) - if (rows.length === 0) { + if (!workflowLog) { return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) } - const workflowLog = rows[0] - const accessError = await validateWorkspaceAccess(rateLimit, userId, workflowLog.workspaceId) if (accessError) { return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) } - const [snapshot] = await db - .select() - .from(workflowExecutionSnapshots) - .where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId)) - .limit(1) - - if (!snapshot) { + if (!workflowLog.workflowState) { return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 }) } const response = { executionId, workflowId: workflowLog.workflowId, - workflowState: snapshot.stateData, + workflowState: workflowLog.workflowState, executionMetadata: { trigger: workflowLog.trigger, startedAt: workflowLog.startedAt.toISOString(), @@ -78,7 +71,7 @@ export const GET = withRouteHandler( logger.debug(`Successfully fetched execution data for: ${executionId}`) logger.debug( - `Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks` + `Workflow state contains ${countWorkflowStateBlocks(workflowLog.workflowState)} blocks` ) // Get user's workflow execution limits and usage diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index e6da3cb0e2a..390e7707300 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -1,15 +1,12 @@ -import { db } from '@sim/db' -import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1ListLogsContract } from '@/lib/api/contracts/v1/logs' import { parseRequest } from '@/lib/api/server' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' -import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters' +import { decodePublicLogCursor, listPublicWorkflowLogs } from '@/lib/logs/public-queries' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, @@ -23,23 +20,6 @@ const logger = createLogger('V1LogsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -interface CursorData { - startedAt: string - id: string -} - -function encodeCursor(data: CursorData): string { - return Buffer.from(JSON.stringify(data)).toString('base64') -} - -function decodeCursor(cursor: string): CursorData | null { - try { - return JSON.parse(Buffer.from(cursor, 'base64').toString()) - } catch { - return null - } -} - export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -74,6 +54,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }, }) + const decodedCursor = params.cursor + ? decodePublicLogCursor(params.cursor, params.order ?? 'desc') + : null + if (params.cursor && !decodedCursor) { + return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 }) + } + const cursor = decodedCursor ?? undefined + const filters = { workspaceId: params.workspaceId, workflowIds: params.workflowIds?.split(',').filter(Boolean), @@ -88,50 +76,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { minCost: params.minCost, maxCost: params.maxCost, model: params.model, - cursor: params.cursor ? decodeCursor(params.cursor) || undefined : undefined, + cursor, order: params.order, } - const conditions = buildLogFilters(filters) - const orderBy = getOrderBy(params.order) - - const baseQuery = db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - executionId: workflowExecutionLogs.executionId, - deploymentVersionId: workflowExecutionLogs.deploymentVersionId, - level: workflowExecutionLogs.level, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - costTotal: workflowExecutionLogs.costTotal, - files: workflowExecutionLogs.files, - executionData: params.details === 'full' ? workflowExecutionLogs.executionData : sql`null`, - workflowName: workflow.name, - workflowDescription: workflow.description, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - - const logs = await baseQuery - .where(conditions) - .orderBy(...orderBy) - .limit(params.limit + 1) - - const hasMore = logs.length > params.limit - const data = logs.slice(0, params.limit) - - let nextCursor: string | undefined - if (hasMore && data.length > 0) { - const lastLog = data[data.length - 1] - nextCursor = encodeCursor({ - startedAt: lastLog.startedAt.toISOString(), - id: lastLog.id, - }) - } + const { data, nextCursor } = await listPublicWorkflowLogs({ + filters, + limit: params.limit, + includeExecutionData: params.details === 'full', + }) const needsMaterialize = params.details === 'full' && (params.includeFinalOutput || params.includeTraceSpans) @@ -192,7 +145,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const response = createApiResponse( { data: formattedLogs, - nextCursor, + nextCursor: nextCursor ?? undefined, }, limits, rateLimit // This is the API endpoint rate limit, not workflow execution limits diff --git a/apps/sim/app/api/v1/workflows/utils.ts b/apps/sim/app/api/v1/workflows/utils.ts index 89186235598..f2cb6d059a9 100644 --- a/apps/sim/app/api/v1/workflows/utils.ts +++ b/apps/sim/app/api/v1/workflows/utils.ts @@ -1,5 +1,8 @@ -import { type ActiveWorkflowRecord, getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { NextResponse } from 'next/server' +import { + type DeploymentWorkflowTarget, + getDeploymentWorkflowTarget, +} from '@/lib/workflows/deployments/queries' import { type RateLimitResult, validateWorkspaceAccess } from '@/app/api/v1/middleware' function workflowNotFoundResponse(): NextResponse { @@ -16,24 +19,16 @@ export async function resolveV1DeploymentWorkflow( rateLimit: RateLimitResult, userId: string, workflowId: string -): Promise< - | { ok: true; workflow: ActiveWorkflowRecord; workspaceId: string } - | { ok: false; response: NextResponse } -> { - const workflow = await getActiveWorkflowRecord(workflowId) - if (!workflow?.workspaceId) { +): Promise<({ ok: true } & DeploymentWorkflowTarget) | { ok: false; response: NextResponse }> { + const target = await getDeploymentWorkflowTarget(workflowId) + if (!target) { return { ok: false, response: workflowNotFoundResponse() } } - const accessError = await validateWorkspaceAccess( - rateLimit, - userId, - workflow.workspaceId, - 'admin' - ) + const accessError = await validateWorkspaceAccess(rateLimit, userId, target.workspaceId, 'admin') if (accessError) { return { ok: false, response: workflowNotFoundResponse() } } - return { ok: true, workflow, workspaceId: workflow.workspaceId } + return { ok: true, ...target } } diff --git a/apps/sim/app/api/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[id]/route.ts index 65a270342fa..6d6f819dd8b 100644 --- a/apps/sim/app/api/v2/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v2/audit-logs/[id]/route.ts @@ -22,12 +22,9 @@ export const revalidate = 0 /** * GET /api/v2/audit-logs/[id] * - * Returns a single audit log entry scoped to the authenticated user's - * organization. Org-scoped (not workspace-scoped). Unlike v1, authorization - * (`checkRateLimit` → `validateEnterpriseAuditAccess`) runs BEFORE the untrusted - * param is parsed, fixing the v1 ordering inconsistency. The org-scope predicate - * is folded into the lookup so a non-org log reads as 404 (existence is not - * leaked). + * Returns a single audit log entry scoped to an explicitly selected + * organization. Audit logs are personal-key-only because a workspace-scoped + * key must never expand into organization-wide visibility. */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { @@ -42,14 +39,21 @@ export const GET = withRouteHandler( const gate = await v2ApiGateError(userId) if (gate) return gate - const authResult = await resolveEnterpriseAuditAccess(userId) - if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) - const parsed = await parseRequest(v2GetAuditLogContract, request, context, { validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response + if (rateLimit.keyType !== 'personal') { + return v2Error('FORBIDDEN', 'Audit logs require a personal API key') + } + + const authResult = await resolveEnterpriseAuditAccess( + userId, + parsed.data.query.organizationId + ) + if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) + const { id } = parsed.data.params const { organizationId, orgMemberIds } = authResult.context diff --git a/apps/sim/app/api/v2/audit-logs/route.test.ts b/apps/sim/app/api/v2/audit-logs/route.test.ts new file mode 100644 index 00000000000..9c15eb5e4b5 --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/route.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveEnterpriseAuditAccess, + mockBuildFilterConditions, + mockBuildOrgScopeCondition, + mockGetOrgWorkspaceIds, + mockQueryAuditLogs, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveEnterpriseAuditAccess: vi.fn(), + mockBuildFilterConditions: vi.fn(), + mockBuildOrgScopeCondition: vi.fn(), + mockGetOrgWorkspaceIds: vi.fn(), + mockQueryAuditLogs: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, +})) + +vi.mock('@/app/api/v1/audit-logs/auth', () => ({ + resolveEnterpriseAuditAccess: mockResolveEnterpriseAuditAccess, +})) + +vi.mock('@/app/api/v1/audit-logs/query', () => ({ + buildFilterConditions: mockBuildFilterConditions, + buildOrgScopeCondition: mockBuildOrgScopeCondition, + getOrgWorkspaceIds: mockGetOrgWorkspaceIds, + queryAuditLogs: mockQueryAuditLogs, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/audit-logs/route' + +const RATE_LIMIT = { + allowed: true, + userId: 'admin-1', + keyType: 'personal', + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-01T00:00:00Z'), +} + +function callGet(query = '') { + return GET(new NextRequest(`http://localhost:3000/api/v2/audit-logs${query}`)) +} + +describe('GET /api/v2/audit-logs', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockResolveEnterpriseAuditAccess.mockResolvedValue({ + success: true, + context: { organizationId: 'org-1', orgMemberIds: ['admin-1'] }, + }) + mockGetOrgWorkspaceIds.mockResolvedValue([]) + mockBuildOrgScopeCondition.mockReturnValue({ type: 'scope' }) + mockBuildFilterConditions.mockReturnValue([]) + mockQueryAuditLogs.mockResolvedValue({ data: [], nextCursor: undefined }) + }) + + it('requires an explicit organization before authorization', async () => { + const response = await callGet() + + expect(response.status).toBe(400) + expect(mockResolveEnterpriseAuditAccess).not.toHaveBeenCalled() + }) + + it('rejects workspace keys before organization-wide access is resolved', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) + + const response = await callGet('?organizationId=org-1') + + expect(response.status).toBe(403) + expect(mockResolveEnterpriseAuditAccess).not.toHaveBeenCalled() + }) + + it('authorizes exactly the requested organization for personal keys', async () => { + const response = await callGet('?organizationId=org-1') + + expect(response.status).toBe(200) + expect(mockResolveEnterpriseAuditAccess).toHaveBeenCalledWith('admin-1', 'org-1') + expect(mockQueryAuditLogs).toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts index 32ef339a8f9..596adff6680 100644 --- a/apps/sim/app/api/v2/audit-logs/route.ts +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -30,11 +30,9 @@ export const revalidate = 0 /** * GET /api/v2/audit-logs * - * Lists audit logs scoped to the authenticated user's organization. Org-scoped - * (not workspace-scoped): `resolveWorkspaceAccess` is intentionally NOT used — - * access is gated by enterprise org admin/owner membership. Auth ordering - * matches v1: `checkRateLimit` → `validateEnterpriseAuditAccess` run before the - * untrusted query is parsed. + * Lists audit logs scoped to an explicitly selected organization. Audit logs + * are personal-key-only because a workspace-scoped key must never expand into + * organization-wide visibility. */ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -48,11 +46,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const gate = await v2ApiGateError(userId) if (gate) return gate - const authResult = await resolveEnterpriseAuditAccess(userId) - if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) - - const { organizationId, orgMemberIds } = authResult.context - const parsed = await parseRequest( v2ListAuditLogsContract, request, @@ -65,6 +58,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const params = parsed.data.query + if (rateLimit.keyType !== 'personal') { + return v2Error('FORBIDDEN', 'Audit logs require a personal API key') + } + + const authResult = await resolveEnterpriseAuditAccess(userId, params.organizationId) + if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) + + const { organizationId, orgMemberIds } = authResult.context + if (params.actorId && !orgMemberIds.includes(params.actorId)) { return v2Error('BAD_REQUEST', 'actorId is not a member of your organization') } diff --git a/apps/sim/app/api/v2/billing/logs/route.test.ts b/apps/sim/app/api/v2/billing/logs/route.test.ts new file mode 100644 index 00000000000..362fc54473a --- /dev/null +++ b/apps/sim/app/api/v2/billing/logs/route.test.ts @@ -0,0 +1,141 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apportionCredits } from '@/lib/billing/credits/conversion' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetUserUsageLogs, + mockGetUsageCreditsByLogId, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetUserUsageLogs: vi.fn(), + mockGetUsageCreditsByLogId: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/billing/core/usage-log', () => ({ + getUserUsageLogs: mockGetUserUsageLogs, + getUsageCreditsByLogId: mockGetUsageCreditsByLogId, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/billing/logs/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'personal', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callLogs(query = '') { + return GET(new NextRequest(`http://localhost:3000/api/v2/billing/logs${query}`)) +} + +describe('GET /api/v2/billing/logs', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetUserUsageLogs.mockResolvedValue({ + logs: [ + { + id: 'log-1', + createdAt: '2026-07-01T00:00:00.000Z', + category: 'model', + source: 'workflow', + description: 'claude-sonnet', + cost: 0.06, + workspaceId: 'ws-1', + workflowId: 'workflow-1', + workflowName: 'Support Agent', + executionId: 'execution-1', + }, + ], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: false }, + }) + mockGetUsageCreditsByLogId.mockResolvedValue( + apportionCredits([{ key: 'log-1', dollars: 0.06 }]) + ) + }) + + it('returns ledger rows without embedding billing status', async () => { + const response = await callLogs() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual({ + data: [ + { + id: 'log-1', + createdAt: '2026-07-01T00:00:00.000Z', + source: 'workflow', + workspaceId: 'ws-1', + workflow: { id: 'workflow-1', name: 'Support Agent' }, + executionId: 'execution-1', + creditCost: 12, + }, + ], + nextCursor: null, + }) + expect(body).not.toHaveProperty('status') + }) + + it('normalizes both internal chat sources to sim-chat', async () => { + const response = await callLogs('?source=sim-chat') + + expect(response.status).toBe(200) + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ source: ['copilot', 'workspace-chat'] }) + ) + }) + + it('forwards the cursor when more rows remain', async () => { + mockGetUserUsageLogs.mockResolvedValue({ + logs: [], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: true, nextCursor: 'log-42' }, + }) + mockGetUsageCreditsByLogId.mockResolvedValue({}) + + const body = await (await callLogs()).json() + + expect(body.nextCursor).toBe('log-42') + }) + + it('rejects custom periods without a start date', async () => { + const response = await callLogs('?period=custom') + + expect(response.status).toBe(400) + expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + }) + + it('authorizes a personal key before reading a workspace ledger', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await callLogs('?workspaceId=ws-2') + + expect(response.status).toBe(403) + expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/billing/usage/logs/route.ts b/apps/sim/app/api/v2/billing/logs/route.ts similarity index 82% rename from apps/sim/app/api/v2/billing/usage/logs/route.ts rename to apps/sim/app/api/v2/billing/logs/route.ts index fe54242b97c..f6a8095466c 100644 --- a/apps/sim/app/api/v2/billing/usage/logs/route.ts +++ b/apps/sim/app/api/v2/billing/logs/route.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { v2ListUsageLogsContract } from '@/lib/api/contracts/v2/billing' +import { v2ListBillingLogsContract } from '@/lib/api/contracts/v2/billing' import { parseRequest } from '@/lib/api/server' import { getUsageCreditsByLogId, getUserUsageLogs } from '@/lib/billing/core/usage-log' import { toBillingUsageLogSource, toInternalUsageLogSources } from '@/lib/billing/usage-sources' @@ -18,16 +18,12 @@ import { v2ValidationError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2BillingUsageLogsAPI') +const logger = createLogger('V2BillingLogsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * GET /api/v2/billing/usage/logs — Cursor-paged, credit-denominated ledger of - * the account's usage events. The per-source aggregate lives on - * `GET /api/v2/billing/usage`; this is the row-level detail. - */ +/** Cursor-paged, credit-denominated billing ledger. */ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -36,12 +32,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!rateLimit.allowed) return v2RateLimitError(rateLimit) const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) if (gate) return gate const parsed = await parseRequest( - v2ListUsageLogsContract, + v2ListBillingLogsContract, request, {}, { @@ -51,7 +46,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { source, workspaceId, period, startDate, endDate, limit, cursor } = parsed.data.query - const workspaceFilter = v2BillingWorkspaceFilter(rateLimit, workspaceId) + const workspaceFilter = await v2BillingWorkspaceFilter(rateLimit, workspaceId) if (!workspaceFilter.ok) return workspaceFilter.response const dateRange = resolveDateRange(period, startDate, endDate) @@ -71,7 +66,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { id: log.id, createdAt: log.createdAt, source: toBillingUsageLogSource(log.source), - workflowName: log.workflowName ?? null, + workspaceId: log.workspaceId ?? null, + workflow: log.workflowId ? { id: log.workflowId, name: log.workflowName ?? null } : null, + executionId: log.executionId ?? null, creditCost: creditsByLogId[log.id] ?? 0, })) @@ -81,7 +78,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { { rateLimit } ) } catch (error) { - logger.error(`[${requestId}] Error listing usage logs`, { + logger.error(`[${requestId}] Error listing billing logs`, { error: getErrorMessage(error, 'Unknown error'), }) return v2Error('INTERNAL_ERROR', 'Internal server error') diff --git a/apps/sim/app/api/v2/billing/status/route.test.ts b/apps/sim/app/api/v2/billing/status/route.test.ts new file mode 100644 index 00000000000..84734542d33 --- /dev/null +++ b/apps/sim/app/api/v2/billing/status/route.test.ts @@ -0,0 +1,171 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockCheckBillingBlocked, + mockCheckUsageStatus, + mockGetHighestPrioritySubscription, + mockDeriveBillingContext, + mockResolveBillingAttribution, + mockCheckAttributedBillingBlocks, + mockToUsageLimitSubscription, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockCheckBillingBlocked: vi.fn(), + mockCheckUsageStatus: vi.fn(), + mockGetHighestPrioritySubscription: vi.fn(), + mockDeriveBillingContext: vi.fn(), + mockResolveBillingAttribution: vi.fn(), + mockCheckAttributedBillingBlocks: vi.fn(), + mockToUsageLimitSubscription: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ + checkBillingBlocked: mockCheckBillingBlocked, + checkBillingEntityBlocked: vi.fn(), + checkUsageStatus: mockCheckUsageStatus, +})) + +vi.mock('@/lib/billing/core/subscription', () => ({ + getHighestPrioritySubscription: mockGetHighestPrioritySubscription, +})) + +vi.mock('@/lib/billing/core/usage-log', () => ({ + deriveBillingContext: mockDeriveBillingContext, +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveBillingAttribution: mockResolveBillingAttribution, + checkAttributedBillingBlocks: mockCheckAttributedBillingBlocks, + toUsageLimitSubscription: mockToUsageLimitSubscription, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/billing/status/route' + +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'personal', + limit: 100, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), +} + +function callStatus(query = '') { + return GET(new NextRequest(`http://localhost:3000/api/v2/billing/status${query}`)) +} + +describe('GET /api/v2/billing/status', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro' }) + mockDeriveBillingContext.mockReturnValue({ + billingEntity: { type: 'user', id: 'user-1' }, + billingPeriod: { + start: new Date('2026-07-01T00:00:00Z'), + end: new Date('2026-08-01T00:00:00Z'), + }, + }) + mockCheckUsageStatus.mockResolvedValue({ + isExceeded: false, + currentUsage: 2.5, + limit: 100, + }) + mockCheckBillingBlocked.mockResolvedValue({ blocked: false }) + mockCheckAttributedBillingBlocks.mockResolvedValue({ blocked: false }) + mockToUsageLimitSubscription.mockReturnValue({ + referenceId: 'org-1', + plan: 'team', + status: 'active', + seats: 5, + periodStart: new Date('2026-07-01T00:00:00Z'), + periodEnd: new Date('2026-08-01T00:00:00Z'), + }) + }) + + it('returns status and allowance without ledger rows or source summaries', async () => { + const response = await callStatus() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual({ + workspaceId: null, + period: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, + plan: 'pro', + status: 'active', + credits: { used: 500, limit: 20000, remaining: 19500 }, + }) + expect(body.data).not.toHaveProperty('bySourceCredits') + }) + + it('reports billing blocks before usage-limit state', async () => { + mockCheckUsageStatus.mockResolvedValue({ isExceeded: true, currentUsage: 100, limit: 100 }) + mockCheckBillingBlocked.mockResolvedValue({ blocked: true }) + + const body = await (await callStatus()).json() + + expect(body.data.status).toBe('billing_blocked') + }) + + it('resolves a workspace billing status against the workspace payer', async () => { + mockResolveBillingAttribution.mockResolvedValue({ + actorUserId: 'user-1', + workspaceId: 'ws-1', + organizationId: 'org-1', + billedAccountUserId: 'owner-1', + billingEntity: { type: 'organization', id: 'org-1' }, + billingPeriod: { + start: '2026-07-01T00:00:00.000Z', + end: '2026-08-01T00:00:00.000Z', + }, + payerSubscription: { + id: 'sub-1', + referenceId: 'org-1', + plan: 'team', + status: 'active', + seats: 5, + periodStart: '2026-07-01T00:00:00.000Z', + periodEnd: '2026-08-01T00:00:00.000Z', + }, + }) + + const body = await (await callStatus('?workspaceId=ws-1')).json() + + expect(body.data.workspaceId).toBe('ws-1') + expect(body.data.plan).toBe('team') + expect(mockCheckUsageStatus).toHaveBeenCalledWith( + 'owner-1', + expect.objectContaining({ referenceId: 'org-1', plan: 'team' }) + ) + }) + + it('403s a workspace API key asking for a different workspace', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + keyType: 'workspace', + workspaceId: 'ws-1', + }) + + const response = await callStatus('?workspaceId=ws-2') + + expect(response.status).toBe(403) + expect(mockCheckUsageStatus).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/billing/status/route.ts b/apps/sim/app/api/v2/billing/status/route.ts new file mode 100644 index 00000000000..8936001c50e --- /dev/null +++ b/apps/sim/app/api/v2/billing/status/route.ts @@ -0,0 +1,118 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + type V2BillingStatusData, + v2GetBillingStatusContract, +} from '@/lib/api/contracts/v2/billing' +import { parseRequest } from '@/lib/api/server' +import { + checkBillingBlocked, + checkBillingEntityBlocked, + checkUsageStatus, +} from '@/lib/billing/calculations/usage-monitor' +import { + checkAttributedBillingBlocks, + resolveBillingAttribution, + toUsageLimitSubscription, +} from '@/lib/billing/core/billing-attribution' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { deriveBillingContext } from '@/lib/billing/core/usage-log' +import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2BillingStatusAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** Current billing standing; ledger events are exposed separately by `/billing/logs`. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'billing-usage') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2GetBillingStatusContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const workspaceFilter = await v2BillingWorkspaceFilter(rateLimit, parsed.data.query.workspaceId) + if (!workspaceFilter.ok) return workspaceFilter.response + + let data: V2BillingStatusData + if (workspaceFilter.workspaceId) { + const attribution = await resolveBillingAttribution({ + actorUserId: userId, + workspaceId: workspaceFilter.workspaceId, + }) + const [usage, block] = await Promise.all([ + checkUsageStatus(attribution.billedAccountUserId, toUsageLimitSubscription(attribution)), + checkAttributedBillingBlocks(attribution), + ]) + data = { + workspaceId: workspaceFilter.workspaceId, + period: attribution.billingPeriod, + plan: attribution.payerSubscription?.plan ?? 'free', + status: block.blocked ? 'billing_blocked' : usage.isExceeded ? 'limit_exceeded' : 'active', + credits: { + used: dollarsToCredits(usage.currentUsage), + limit: dollarsToCredits(usage.limit), + remaining: dollarsToCredits(usage.limit - usage.currentUsage), + }, + } + } else { + const subscription = await getHighestPrioritySubscription(userId) + const { billingEntity, billingPeriod } = deriveBillingContext(userId, subscription) + const [usage, actorBlock, payerBlock] = await Promise.all([ + checkUsageStatus(userId, subscription), + checkBillingBlocked(userId), + billingEntity.type === 'user' && billingEntity.id === userId + ? Promise.resolve({ blocked: false }) + : checkBillingEntityBlocked(billingEntity), + ]) + data = { + workspaceId: null, + period: { + start: billingPeriod.start.toISOString(), + end: billingPeriod.end.toISOString(), + }, + plan: subscription?.plan ?? 'free', + status: + actorBlock.blocked || payerBlock.blocked + ? 'billing_blocked' + : usage.isExceeded + ? 'limit_exceeded' + : 'active', + credits: { + used: dollarsToCredits(usage.currentUsage), + limit: dollarsToCredits(usage.limit), + remaining: dollarsToCredits(usage.limit - usage.currentUsage), + }, + } + } + + return v2Data(data, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error building billing status`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/billing/usage/logs/route.test.ts b/apps/sim/app/api/v2/billing/usage/logs/route.test.ts deleted file mode 100644 index b95206d3ccc..00000000000 --- a/apps/sim/app/api/v2/billing/usage/logs/route.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { apportionCredits } from '@/lib/billing/credits/conversion' - -const { mockCheckRateLimit, mockGetUserUsageLogs, mockGetUsageCreditsByLogId } = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockGetUserUsageLogs: vi.fn(), - mockGetUsageCreditsByLogId: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, -})) - -vi.mock('@/lib/billing/core/usage-log', () => ({ - getUserUsageLogs: mockGetUserUsageLogs, - getUsageCreditsByLogId: mockGetUsageCreditsByLogId, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) - -import { GET } from '@/app/api/v2/billing/usage/logs/route' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'personal', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -function callLogs(query = '') { - return GET(new NextRequest(`http://localhost:3000/api/v2/billing/usage/logs${query}`)) -} - -describe('GET /api/v2/billing/usage/logs', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockGetUserUsageLogs.mockResolvedValue({ - logs: [ - { - id: 'log-1', - createdAt: '2026-07-01T00:00:00.000Z', - category: 'model', - source: 'copilot', - description: 'claude-sonnet', - cost: 0.06, - }, - ], - summary: { totalCost: 0, bySource: {} }, - pagination: { hasMore: false }, - }) - mockGetUsageCreditsByLogId.mockResolvedValue( - apportionCredits([{ key: 'log-1', dollars: 0.06 }]) - ) - }) - - it('returns credit-denominated rows in the cursor envelope, no dollar costs', async () => { - const res = await callLogs() - expect(res.status).toBe(200) - const body = await res.json() - expect(body.nextCursor).toBeNull() - expect(body.data).toEqual([ - { - id: 'log-1', - createdAt: '2026-07-01T00:00:00.000Z', - source: 'sim-chat', - workflowName: null, - creditCost: 12, - }, - ]) - expect(JSON.stringify(body)).not.toContain('ollarCost') - }) - - it('forwards the cursor when more rows remain', async () => { - mockGetUserUsageLogs.mockResolvedValue({ - logs: [], - summary: { totalCost: 0, bySource: {} }, - pagination: { hasMore: true, nextCursor: 'log-42' }, - }) - mockGetUsageCreditsByLogId.mockResolvedValue({}) - const body = await (await callLogs()).json() - expect(body.nextCursor).toBe('log-42') - }) - - it('filters sim-chat across both internal ledgers', async () => { - const res = await callLogs('?source=sim-chat') - - expect(res.status).toBe(200) - expect(mockGetUserUsageLogs).toHaveBeenCalledWith( - 'user-1', - expect.objectContaining({ source: ['copilot', 'workspace-chat'] }) - ) - expect(mockGetUsageCreditsByLogId).toHaveBeenCalledWith( - 'user-1', - expect.objectContaining({ source: ['copilot', 'workspace-chat'] }) - ) - }) - - it('rejects internal chat source names', async () => { - const res = await callLogs('?source=copilot') - - expect(res.status).toBe(400) - expect(mockGetUserUsageLogs).not.toHaveBeenCalled() - }) - - it('pins a workspace API key to its own workspace', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - keyType: 'workspace', - workspaceId: 'ws-1', - }) - const res = await callLogs() - expect(res.status).toBe(200) - expect(mockGetUserUsageLogs).toHaveBeenCalledWith( - 'user-1', - expect.objectContaining({ workspaceId: 'ws-1' }) - ) - }) - - it('403s a workspace API key asking for a different workspace', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - keyType: 'workspace', - workspaceId: 'ws-1', - }) - const res = await callLogs('?workspaceId=ws-2') - expect(res.status).toBe(403) - expect(mockGetUserUsageLogs).not.toHaveBeenCalled() - }) - - it('rejects "custom" period without a startDate', async () => { - const res = await callLogs('?period=custom') - expect(res.status).toBe(400) - expect(mockGetUserUsageLogs).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue({ - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2026-01-01T01:00:00Z'), - retryAfterMs: 1000, - }) - const res = await callLogs() - expect(res.status).toBe(429) - }) -}) diff --git a/apps/sim/app/api/v2/billing/usage/route.test.ts b/apps/sim/app/api/v2/billing/usage/route.test.ts deleted file mode 100644 index db51ac8098b..00000000000 --- a/apps/sim/app/api/v2/billing/usage/route.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckRateLimit, - mockGetUserUsageLogs, - mockCheckServerSideUsageLimits, - mockGetHighestPrioritySubscription, - mockDeriveBillingContext, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockGetUserUsageLogs: vi.fn(), - mockCheckServerSideUsageLimits: vi.fn(), - mockGetHighestPrioritySubscription: vi.fn(), - mockDeriveBillingContext: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, -})) - -vi.mock('@/lib/billing', () => ({ - checkServerSideUsageLimits: mockCheckServerSideUsageLimits, -})) - -vi.mock('@/lib/billing/core/subscription', () => ({ - getHighestPrioritySubscription: mockGetHighestPrioritySubscription, -})) - -vi.mock('@/lib/billing/core/usage-log', () => ({ - deriveBillingContext: mockDeriveBillingContext, - getUserUsageLogs: mockGetUserUsageLogs, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) - -import { GET } from '@/app/api/v2/billing/usage/route' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'personal', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -function callSummary(query = '') { - return GET(new NextRequest(`http://localhost:3000/api/v2/billing/usage${query}`)) -} - -describe('GET /api/v2/billing/usage', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro' }) - mockDeriveBillingContext.mockReturnValue({ - billingEntity: { type: 'user', id: 'user-1' }, - billingPeriod: { - start: new Date('2026-07-01T00:00:00Z'), - end: new Date('2026-08-01T00:00:00Z'), - }, - }) - mockCheckServerSideUsageLimits.mockResolvedValue({ - isExceeded: false, - currentUsage: 2.5, - limit: 100, - }) - mockGetUserUsageLogs.mockResolvedValue({ - logs: [], - summary: { - totalCost: 2.5, - bySource: { workflow: 1.9, copilot: 0.4, 'workspace-chat': 0.2 }, - }, - pagination: { hasMore: false }, - }) - }) - - it('returns the billing-period summary with per-source credits, no dollars', async () => { - const res = await callSummary() - expect(res.status).toBe(200) - const body = await res.json() - expect(body.data).toEqual({ - period: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, - totalCredits: 500, - bySourceCredits: { workflow: 380, 'sim-chat': 120 }, - limitCredits: 20000, - plan: 'pro', - }) - expect(JSON.stringify(body)).not.toContain('dollar') - }) - - it('queries the ledger summary over the derived billing period', async () => { - await callSummary() - expect(mockGetUserUsageLogs).toHaveBeenCalledWith( - 'user-1', - expect.objectContaining({ - startDate: new Date('2026-07-01T00:00:00Z'), - endDate: new Date('2026-08-01T00:00:00Z'), - includeSummary: true, - }) - ) - }) - - it('pins a workspace API key to its own workspace', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - keyType: 'workspace', - workspaceId: 'ws-1', - }) - const res = await callSummary() - expect(res.status).toBe(200) - expect(mockGetUserUsageLogs).toHaveBeenCalledWith( - 'user-1', - expect.objectContaining({ workspaceId: 'ws-1' }) - ) - }) - - it('403s a workspace API key asking for a different workspace', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - keyType: 'workspace', - workspaceId: 'ws-1', - }) - const res = await callSummary('?workspaceId=ws-2') - expect(res.status).toBe(403) - expect((await res.json()).error.code).toBe('FORBIDDEN') - expect(mockGetUserUsageLogs).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue({ - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2026-01-01T01:00:00Z'), - retryAfterMs: 1000, - }) - const res = await callSummary() - expect(res.status).toBe(429) - }) -}) diff --git a/apps/sim/app/api/v2/billing/usage/route.ts b/apps/sim/app/api/v2/billing/usage/route.ts deleted file mode 100644 index d7e8fa6715a..00000000000 --- a/apps/sim/app/api/v2/billing/usage/route.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { type V2UsageSummaryData, v2GetUsageSummaryContract } from '@/lib/api/contracts/v2/billing' -import { parseRequest } from '@/lib/api/server' -import { checkServerSideUsageLimits } from '@/lib/billing' -import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { deriveBillingContext, getUserUsageLogs } from '@/lib/billing/core/usage-log' -import { dollarsToCredits } from '@/lib/billing/credits/conversion' -import { aggregateBillingUsageBySource } from '@/lib/billing/usage-sources' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { checkRateLimit } from '@/app/api/v1/middleware' -import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' - -const logger = createLogger('V2BillingUsageAPI') - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -/** - * GET /api/v2/billing/usage — Current-billing-period usage summary with the - * per-source credit breakdown, for external monitoring (e.g. alerting on - * Sim Chat consumption before an overage). Credits only — dollar costs and - * rate-limit internals are not part of this surface. - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'billing-usage') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2GetUsageSummaryContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const workspaceFilter = v2BillingWorkspaceFilter(rateLimit, parsed.data.query.workspaceId) - if (!workspaceFilter.ok) return workspaceFilter.response - - const subscription = await getHighestPrioritySubscription(userId) - const { billingPeriod } = deriveBillingContext(userId, subscription) - - const [usageCheck, ledger] = await Promise.all([ - checkServerSideUsageLimits(userId, subscription), - getUserUsageLogs(userId, { - workspaceId: workspaceFilter.workspaceId, - startDate: billingPeriod.start, - endDate: billingPeriod.end, - limit: 1, - includeSummary: true, - }), - ]) - - const bySourceCredits = Object.fromEntries( - Object.entries(aggregateBillingUsageBySource(ledger.summary.bySource)).map( - ([source, cost]) => [source, dollarsToCredits(cost)] - ) - ) - - const data: V2UsageSummaryData = { - period: { - start: billingPeriod.start.toISOString(), - end: billingPeriod.end.toISOString(), - }, - totalCredits: dollarsToCredits(ledger.summary.totalCost), - bySourceCredits, - limitCredits: dollarsToCredits(usageCheck.limit), - plan: subscription?.plan || 'free', - } - - return v2Data(data, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error building usage summary`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/billing/utils.ts b/apps/sim/app/api/v2/billing/utils.ts index 3e7eea9ca70..9cf95afcbef 100644 --- a/apps/sim/app/api/v2/billing/utils.ts +++ b/apps/sim/app/api/v2/billing/utils.ts @@ -1,5 +1,5 @@ import type { NextResponse } from 'next/server' -import type { RateLimitResult } from '@/app/api/v1/middleware' +import { type RateLimitResult, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2Error } from '@/app/api/v2/lib/response' type BillingWorkspaceFilter = @@ -8,23 +8,54 @@ type BillingWorkspaceFilter = /** * Resolves the effective `workspaceId` ledger filter for the caller's key. - * Personal keys read the account's full ledger with whatever filter they asked - * for; a workspace-scoped key is pinned to its own workspace — the filter - * defaults to the key's workspace and an explicit mismatch is rejected rather - * than silently ignored. + * Personal keys may read their account-wide ledger without a filter. When any + * key targets a workspace, the caller must have read access and the workspace's + * API-key policy must allow the key type. Workspace-scoped keys remain pinned to + * their own workspace. */ -export function v2BillingWorkspaceFilter( +export async function v2BillingWorkspaceFilter( rateLimit: RateLimitResult, requestedWorkspaceId: string | undefined -): BillingWorkspaceFilter { - if (rateLimit.keyType !== 'workspace') { - return { ok: true, workspaceId: requestedWorkspaceId } - } - if (requestedWorkspaceId && requestedWorkspaceId !== rateLimit.workspaceId) { +): Promise { + if ( + rateLimit.keyType === 'workspace' && + requestedWorkspaceId && + requestedWorkspaceId !== rateLimit.workspaceId + ) { return { ok: false, response: v2Error('FORBIDDEN', 'API key is not authorized for this workspace'), } } - return { ok: true, workspaceId: rateLimit.workspaceId } + + const workspaceId = + rateLimit.keyType === 'workspace' ? rateLimit.workspaceId : requestedWorkspaceId + + if (!workspaceId) { + if (rateLimit.keyType === 'workspace') { + return { + ok: false, + response: v2Error('FORBIDDEN', 'Workspace-scoped API key is missing its workspace'), + } + } + return { ok: true, workspaceId: undefined } + } + + const userId = rateLimit.userId + if (!userId) { + return { + ok: false, + response: v2Error('UNAUTHORIZED', 'Authentication required'), + } + } + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) { + return { + ok: false, + response: v2Error('FORBIDDEN', access.message), + } + } + + return { ok: true, workspaceId } } diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts index ef9318b1dc6..8f80e98e33e 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -1,8 +1,5 @@ -import { db } from '@sim/db' -import { document, knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { type V2KnowledgeDocument, @@ -12,6 +9,7 @@ import { import { parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getKnowledgeDocument } from '@/lib/knowledge/documents/service' import { performDeleteKnowledgeDocument } from '@/lib/knowledge/orchestration' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' @@ -85,40 +83,7 @@ export const GET = withRouteHandler( ) if (result instanceof NextResponse) return result - const docs = await db - .select({ - id: document.id, - knowledgeBaseId: document.knowledgeBaseId, - filename: document.filename, - fileSize: document.fileSize, - mimeType: document.mimeType, - processingStatus: document.processingStatus, - processingError: document.processingError, - processingStartedAt: document.processingStartedAt, - processingCompletedAt: document.processingCompletedAt, - chunkCount: document.chunkCount, - tokenCount: document.tokenCount, - characterCount: document.characterCount, - enabled: document.enabled, - uploadedAt: document.uploadedAt, - connectorId: document.connectorId, - connectorType: knowledgeConnector.connectorType, - sourceUrl: document.sourceUrl, - }) - .from(document) - .leftJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id)) - .where( - and( - eq(document.id, documentId), - eq(document.knowledgeBaseId, knowledgeBaseId), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) - .limit(1) - - const doc = docs[0] + const doc = await getKnowledgeDocument(knowledgeBaseId, documentId) if (!doc) return v2Error('NOT_FOUND', 'Document not found') const documentDetail: V2KnowledgeDocument = { @@ -181,21 +146,7 @@ export const DELETE = withRouteHandler( ) if (result instanceof NextResponse) return result - const docs = await db - .select({ id: document.id, filename: document.filename }) - .from(document) - .where( - and( - eq(document.id, documentId), - eq(document.knowledgeBaseId, knowledgeBaseId), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) - .limit(1) - - const doc = docs[0] + const doc = await getKnowledgeDocument(knowledgeBaseId, documentId) if (!doc) return v2Error('NOT_FOUND', 'Document not found') const outcome = await performDeleteKnowledgeDocument({ 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 b6adfff3c07..54be2ec3882 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -104,8 +104,14 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Docume ) if (result instanceof NextResponse) return result - // Opaque cursor encodes the underlying offset (upgradeable to keyset later). - const offset = cursor ? (decodeCursor<{ offset: number }>(cursor)?.offset ?? 0) : 0 + const decodedCursor = cursor ? decodeCursor<{ offset: number }>(cursor) : null + if ( + cursor && + (!decodedCursor || !Number.isInteger(decodedCursor.offset) || decodedCursor.offset < 0) + ) { + return v2Error('BAD_REQUEST', 'Invalid cursor') + } + const offset = decodedCursor?.offset ?? 0 const documentsResult = await getDocuments( knowledgeBaseId, diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index 005edb3b919..ac8ca1c4842 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -15,18 +15,15 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' -import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' -import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' -import type { StructuredFilter } from '@/lib/knowledge/types' import { + executeKnowledgeSearch, generateSearchEmbedding, getDocumentMetadataByIds, - getQueryStrategy, - handleTagAndVectorSearch, - handleTagOnlySearch, - handleVectorOnlySearch, type SearchResult, -} from '@/app/api/knowledge/search/utils' +} from '@/lib/knowledge/search/queries' +import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' +import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' +import type { StructuredFilter } from '@/lib/knowledge/types' import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -197,50 +194,32 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const queryEmbeddingModel = embeddingModels[0] - let results: SearchResult[] + if (!hasQuery && !hasFilters) { + return v2Error('BAD_REQUEST', 'Either query or tagFilters must be provided') + } + let queryEmbeddingIsBYOK: boolean | null = null + let queryVector: string | undefined - if (!hasQuery && hasFilters) { - results = await handleTagOnlySearch({ - knowledgeBaseIds: accessibleKbIds, - topK, - structuredFilters, - }) - } else if (hasQuery && hasFilters) { - const strategy = getQueryStrategy(accessibleKbIds.length, topK) + if (hasQuery) { const queryEmbeddingResult = await generateSearchEmbedding( query!, queryEmbeddingModel, workspaceId ) queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK - const queryVector = JSON.stringify(queryEmbeddingResult.embedding) - results = await handleTagAndVectorSearch({ - knowledgeBaseIds: accessibleKbIds, - topK, - structuredFilters, - queryVector, - distanceThreshold: strategy.distanceThreshold, - }) - } else if (hasQuery) { - const strategy = getQueryStrategy(accessibleKbIds.length, topK) - const queryEmbeddingResult = await generateSearchEmbedding( - query!, - queryEmbeddingModel, - workspaceId - ) - queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK - const queryVector = JSON.stringify(queryEmbeddingResult.embedding) - results = await handleVectorOnlySearch({ - knowledgeBaseIds: accessibleKbIds, - topK, - queryVector, - distanceThreshold: strategy.distanceThreshold, - }) - } else { - return v2Error('BAD_REQUEST', 'Either query or tagFilters must be provided') + queryVector = JSON.stringify(queryEmbeddingResult.embedding) } + const results: SearchResult[] = await executeKnowledgeSearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + searchMode: 'vector', + query, + queryVector, + structuredFilters, + }) + if (queryEmbeddingIsBYOK !== null) { await recordSearchEmbeddingUsage({ userId: billingActorUserId, diff --git a/apps/sim/app/api/v2/logs/[id]/route.test.ts b/apps/sim/app/api/v2/logs/[executionId]/route.test.ts similarity index 74% rename from apps/sim/app/api/v2/logs/[id]/route.test.ts rename to apps/sim/app/api/v2/logs/[executionId]/route.test.ts index 191775ad945..4c6f12038e4 100644 --- a/apps/sim/app/api/v2/logs/[id]/route.test.ts +++ b/apps/sim/app/api/v2/logs/[executionId]/route.test.ts @@ -35,7 +35,7 @@ vi.mock('@/lib/logs/execution/trace-store', () => ({ materializeExecutionData: mockMaterializeExecutionData, })) -import { GET } from '@/app/api/v2/logs/[id]/route' +import { GET } from '@/app/api/v2/logs/[executionId]/route' const RATE_LIMIT_OK = { allowed: true, @@ -47,10 +47,11 @@ const RATE_LIMIT_OK = { } const LOG_ROW = { - id: 'log-1', workflowId: 'workflow-1', workspaceId: 'workspace-1', executionId: 'execution-1', + deploymentVersionId: 'deployment-1', + status: 'completed', level: 'info', trigger: 'api', startedAt: new Date('2024-01-01T00:00:00Z'), @@ -60,6 +61,7 @@ const LOG_ROW = { costTotal: '0.01', files: null, createdAt: new Date('2024-01-01T00:00:00Z'), + workflowState: { blocks: {}, edges: [] }, workflowName: 'Support Agent', workflowDescription: 'Handles support requests', workflowFolderId: null, @@ -70,13 +72,13 @@ const LOG_ROW = { workflowArchivedAt: null, } -const routeContext = () => ({ params: Promise.resolve({ id: 'log-1' }) }) +const routeContext = () => ({ params: Promise.resolve({ executionId: 'execution-1' }) }) function callGet() { - return GET(new NextRequest('http://localhost:3000/api/v2/logs/log-1'), routeContext()) + return GET(new NextRequest('http://localhost:3000/api/v2/logs/execution-1'), routeContext()) } -describe('GET /api/v2/logs/[id]', () => { +describe('GET /api/v2/logs/[executionId]', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() @@ -86,7 +88,7 @@ describe('GET /api/v2/logs/[id]', () => { dbChainMockFns.limit.mockResolvedValue([LOG_ROW]) }) - it('returns materialized trace spans as a first-class log detail field', async () => { + it('uses executionId as the sole public identity and includes diagnostic data', async () => { const traceSpans = [ { id: 'span-1', @@ -106,17 +108,20 @@ describe('GET /api/v2/logs/[id]', () => { const body = await response.json() expect(response.status).toBe(200) + expect(body.data.executionId).toBe('execution-1') + expect(body.data).not.toHaveProperty('id') + expect(body.data).not.toHaveProperty('executionData') expect(body.data.traceSpans).toEqual(traceSpans) - expect(body.data.executionData.traceSpans).toEqual(traceSpans) + expect(body.data.finalOutput).toEqual({ answer: 'done' }) + expect(body.data.workflowState).toEqual({ blocks: {}, edges: [] }) }) - it('returns an empty trace span array when the execution has no spans', async () => { - mockMaterializeExecutionData.mockResolvedValue({ finalOutput: { answer: 'done' } }) + it('returns empty diagnostic collections when the execution produced none', async () => { + mockMaterializeExecutionData.mockResolvedValue({}) - const response = await callGet() - const body = await response.json() + const body = await (await callGet()).json() - expect(response.status).toBe(200) expect(body.data.traceSpans).toEqual([]) + expect(body.data.finalOutput).toBeNull() }) }) diff --git a/apps/sim/app/api/v2/logs/[id]/route.ts b/apps/sim/app/api/v2/logs/[executionId]/route.ts similarity index 63% rename from apps/sim/app/api/v2/logs/[id]/route.ts rename to apps/sim/app/api/v2/logs/[executionId]/route.ts index fac1104f9b1..8f39bb47fbd 100644 --- a/apps/sim/app/api/v2/logs/[id]/route.ts +++ b/apps/sim/app/api/v2/logs/[executionId]/route.ts @@ -1,16 +1,14 @@ -import { db } from '@sim/db' -import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { traceSpansSchema } from '@/lib/api/contracts/logs' -import { type V2LogDetail, v2GetLogContract } from '@/lib/api/contracts/v2/logs' +import { type V2LogDetail, v2GetLogContract, v2LogStatusSchema } from '@/lib/api/contracts/v2/logs' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { getPublicWorkflowLog } from '@/lib/logs/public-queries' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' @@ -19,8 +17,13 @@ const logger = createLogger('V2LogDetailAPI') export const revalidate = 0 +/** + * Returns the diagnostic representation of an execution. The execution ID is + * the sole public identity; the workflow-execution-log row key remains an + * internal storage and pagination detail. + */ export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => { const requestId = generateId().slice(0, 8) try { @@ -37,56 +40,26 @@ export const GET = withRouteHandler( }) if (!parsed.success) return parsed.response - const { id } = parsed.data.params + const { executionId } = parsed.data.params - const rows = await db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - executionId: workflowExecutionLogs.executionId, - level: workflowExecutionLogs.level, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - costTotal: workflowExecutionLogs.costTotal, - files: workflowExecutionLogs.files, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - workflowArchivedAt: workflow.archivedAt, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .where(eq(workflowExecutionLogs.id, id)) - .limit(1) + const log = await getPublicWorkflowLog({ column: 'executionId', value: executionId }) - const log = rows[0] if (!log) return v2Error('NOT_FOUND', 'Log not found') - // Convert an authorization failure into 404 so existence is not leaked. const access = await resolveWorkspaceAccess(rateLimit, userId, log.workspaceId) if (access) return v2Error('NOT_FOUND', 'Log not found') const folderIndex = await loadActiveFolderPathIndex(log.workspaceId, 'workflow') - const executionData = await materializeExecutionData( log.executionData as Record | null, { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } ) - const traceSpans = traceSpansSchema.parse(executionData.traceSpans ?? []) const detail: V2LogDetail = { - id: log.id, - workflowId: log.workflowId, executionId: log.executionId, + workflowId: log.workflowId, + deploymentVersionId: log.deploymentVersionId, + status: v2LogStatusSchema.parse(log.status), level: log.level, trigger: log.trigger, startedAt: log.startedAt.toISOString(), @@ -106,8 +79,9 @@ export const GET = withRouteHandler( updatedAt: log.workflowUpdatedAt ? log.workflowUpdatedAt.toISOString() : null, deleted: !log.workflowName || log.workflowArchivedAt !== null, }, - executionData, - traceSpans, + workflowState: log.workflowState, + traceSpans: traceSpansSchema.parse(executionData.traceSpans ?? []), + finalOutput: executionData.finalOutput ?? null, cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, createdAt: log.createdAt.toISOString(), } diff --git a/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts deleted file mode 100644 index 5b811960412..00000000000 --- a/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { db } from '@sim/db' -import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' -import { type V2Execution, v2GetExecutionContract } from '@/lib/api/contracts/v2/logs' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' - -const logger = createLogger('V2ExecutionAPI') - -export const revalidate = 0 - -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => { - try { - const rateLimit = await checkRateLimit(request, 'logs-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetExecutionContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { executionId } = parsed.data.params - - const rows = await db - .select() - .from(workflowExecutionLogs) - .where(eq(workflowExecutionLogs.executionId, executionId)) - .limit(1) - - if (rows.length === 0) return v2Error('NOT_FOUND', 'Workflow execution not found') - - const workflowLog = rows[0] - - // Convert an authorization failure into 404 so existence is not leaked. - const access = await resolveWorkspaceAccess(rateLimit, userId, workflowLog.workspaceId) - if (access) return v2Error('NOT_FOUND', 'Workflow execution not found') - - const [snapshot] = await db - .select() - .from(workflowExecutionSnapshots) - .where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId)) - .limit(1) - - if (!snapshot) return v2Error('NOT_FOUND', 'Workflow state snapshot not found') - - const execution: V2Execution = { - executionId, - workflowId: workflowLog.workflowId, - workflowState: snapshot.stateData, - executionMetadata: { - trigger: workflowLog.trigger, - startedAt: workflowLog.startedAt.toISOString(), - endedAt: workflowLog.endedAt ? workflowLog.endedAt.toISOString() : null, - totalDurationMs: workflowLog.totalDurationMs, - cost: workflowLog.costTotal != null ? { total: Number(workflowLog.costTotal) } : null, - }, - } - - return v2Data(execution, { rateLimit }) - } catch (error) { - logger.error('Error fetching execution data', { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index b09a0a24e30..4bebe07842c 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -1,24 +1,23 @@ -import { db } from '@sim/db' -import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { traceSpansSchema } from '@/lib/api/contracts/logs' -import { type V2LogListItem, v2ListLogsContract } from '@/lib/api/contracts/v2/logs' +import { + type V2LogListItem, + v2ListLogsContract, + v2LogStatusSchema, +} from '@/lib/api/contracts/v2/logs' import { parseRequest } from '@/lib/api/server' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' -import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters' +import { decodePublicLogCursor, listPublicWorkflowLogs } from '@/lib/logs/public-queries' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { resolveFolderPathId } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { - decodeCursor, - encodeCursor, v2CursorList, v2Error, v2RateLimitError, @@ -71,6 +70,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) const includesRoot = resolvedFolderIds?.includes(null) ?? false + const decodedCursor = params.cursor + ? decodePublicLogCursor(params.cursor, params.order ?? 'desc') + : null + if (params.cursor && !decodedCursor) return v2Error('BAD_REQUEST', 'Invalid cursor') + const cursor = decodedCursor ?? undefined + const filters = { workspaceId: params.workspaceId, workflowIds: params.workflowIds?.split(',').filter(Boolean), @@ -85,64 +90,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => { minCost: params.minCost, maxCost: params.maxCost, model: params.model, - cursor: params.cursor - ? decodeCursor<{ startedAt: string; id: string }>(params.cursor) || undefined - : undefined, + cursor, order: params.order, } - const conditions = buildLogFilters(filters) - const rootFolderCondition = folderPaths - ? or( - includesRoot ? isNull(workflow.folderId) : undefined, - nonRootFolderIds && nonRootFolderIds.length > 0 - ? inArray(workflow.folderId, nonRootFolderIds) - : undefined - ) - : undefined - const orderBy = getOrderBy(params.order) - - const rows = await db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - executionId: workflowExecutionLogs.executionId, - deploymentVersionId: workflowExecutionLogs.deploymentVersionId, - level: workflowExecutionLogs.level, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - costTotal: workflowExecutionLogs.costTotal, - files: workflowExecutionLogs.files, - executionData: params.details === 'full' ? workflowExecutionLogs.executionData : sql`null`, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowArchivedAt: workflow.archivedAt, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .where(and(conditions, rootFolderCondition)) - .orderBy(...orderBy) - .limit(params.limit + 1) - - const hasMore = rows.length > params.limit - const data = rows.slice(0, params.limit) - - let nextCursor: string | null = null - if (hasMore && data.length > 0) { - const lastLog = data[data.length - 1] - nextCursor = encodeCursor({ startedAt: lastLog.startedAt.toISOString(), id: lastLog.id }) - } + const { data, nextCursor } = await listPublicWorkflowLogs({ + filters, + limit: params.limit, + includeExecutionData: params.details === 'full', + folderScope: folderPaths ? { includesRoot, folderIds: nonRootFolderIds ?? [] } : undefined, + }) type LogRow = (typeof data)[number] const buildItem = (log: LogRow): V2LogListItem => { const item: V2LogListItem = { - id: log.id, - workflowId: log.workflowId, executionId: log.executionId, + workflowId: log.workflowId, deploymentVersionId: log.deploymentVersionId, + status: v2LogStatusSchema.parse(log.status), level: log.level, trigger: log.trigger, startedAt: log.startedAt.toISOString(), diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts index ec2479d09b8..9089d6a47b6 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -13,9 +13,9 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' import { checkRateLimit } from '@/app/api/v1/middleware' -import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' const logger = createLogger('V2WorkflowDeployAPI') @@ -52,8 +52,8 @@ export const POST = withRouteHandler( const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {}) if (!body.success) return v2ValidationError(body.error) - const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) - if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const target = await resolveV2WorkflowTarget(rateLimit, userId, id, 'admin') + if (!target) return v2Error('NOT_FOUND', 'Workflow not found') const { workspaceId } = target await assertWorkflowMutable(id) @@ -130,8 +130,8 @@ export const DELETE = withRouteHandler( const { id } = parsed.data.params - const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) - if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const target = await resolveV2WorkflowTarget(rateLimit, userId, id, 'admin') + if (!target) return v2Error('NOT_FOUND', 'Workflow not found') const { workflow, workspaceId } = target if (!workflow.isDeployed) { diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts index 2d3ebe1166e..9d3ff613cf5 100644 --- a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -4,7 +4,10 @@ import type { NextRequest } from 'next/server' import { v2CancelWorkflowExecutionContract } from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { cancelWorkflowExecution } from '@/lib/execution/cancel-workflow-execution' +import { + cancelWorkflowExecution, + WorkflowExecutionNotFoundError, +} from '@/lib/execution/cancel-workflow-execution' import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' @@ -37,6 +40,9 @@ export const POST = withRouteHandler( return v2Data(result) } catch (error) { + if (error instanceof WorkflowExecutionNotFoundError) { + return v2Error('NOT_FOUND', error.message) + } logger.error('Failed to cancel execution', { workflowId, executionId, diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/executions/route.test.ts new file mode 100644 index 00000000000..e97f6787cc8 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/executions/route.test.ts @@ -0,0 +1,145 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockResolveV2WorkflowAccess } = vi.hoisted(() => ({ + mockResolveV2WorkflowAccess: vi.fn(), +})) + +vi.mock('@/app/api/v2/workflows/lib/access', () => ({ + resolveV2WorkflowAccess: mockResolveV2WorkflowAccess, +})) + +import { GET } from '@/app/api/v2/workflows/[id]/executions/route' + +const routeContext = () => ({ params: Promise.resolve({ id: 'workflow-1' }) }) +const callGet = (query = '') => + GET( + new NextRequest(`http://localhost:3000/api/v2/workflows/workflow-1/executions${query}`), + routeContext() + ) + +const EXECUTIONS = [ + { + rowId: 'row-2', + executionId: 'execution-2', + workflowId: 'workflow-1', + status: 'paused', + trigger: 'api', + startedAt: new Date('2026-08-05T00:02:00Z'), + endedAt: null, + durationMs: null, + costTotal: '0.02', + }, + { + rowId: 'row-1', + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + trigger: 'schedule', + startedAt: new Date('2026-08-05T00:01:00Z'), + endedAt: new Date('2026-08-05T00:01:03Z'), + durationMs: 3000, + costTotal: null, + }, +] + +describe('GET /api/v2/workflows/[id]/executions', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockResolveV2WorkflowAccess.mockResolvedValue({ + ok: true, + userId: 'user-1', + keyType: 'workspace', + workflow: { id: 'workflow-1', workspaceId: 'workspace-1' }, + }) + dbChainMockFns.limit.mockResolvedValue(EXECUTIONS) + }) + + it('lists lightweight execution resources in the cursor envelope', async () => { + const response = await callGet() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.nextCursor).toBeNull() + expect(body.data).toEqual([ + { + executionId: 'execution-2', + workflowId: 'workflow-1', + status: 'paused', + trigger: 'api', + startedAt: '2026-08-05T00:02:00.000Z', + endedAt: null, + durationMs: null, + cost: { total: 0.02 }, + }, + { + executionId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + trigger: 'schedule', + startedAt: '2026-08-05T00:01:00.000Z', + endedAt: '2026-08-05T00:01:03.000Z', + durationMs: 3000, + cost: null, + }, + ]) + }) + + it('returns an opaque cursor when another row exists', async () => { + dbChainMockFns.limit.mockResolvedValue([...EXECUTIONS, { ...EXECUTIONS[1], rowId: 'row-0' }]) + + const body = await (await callGet('?limit=2')).json() + + expect(body.data).toHaveLength(2) + expect(body.nextCursor).toEqual(expect.any(String)) + expect(JSON.parse(Buffer.from(body.nextCursor, 'base64').toString())).toEqual({ + sort: 'startedAt:desc', + keys: ['2026-08-05T00:01:00.000Z', 'row-1'], + }) + }) + + it('rejects an invalid cursor', async () => { + const response = await callGet('?cursor=not-a-cursor') + + expect(response.status).toBe(400) + expect(dbChainMockFns.limit).not.toHaveBeenCalled() + }) + + it('rejects a cursor minted under a different order', async () => { + const cursor = Buffer.from( + JSON.stringify({ + sort: 'startedAt:desc', + keys: ['2026-08-05T00:01:00.000Z', 'row-1'], + }) + ).toString('base64') + + const response = await callGet(`?order=asc&cursor=${encodeURIComponent(cursor)}`) + + expect(response.status).toBe(400) + expect(dbChainMockFns.limit).not.toHaveBeenCalled() + }) + + it('rejects queued as a durable-history filter', async () => { + const response = await callGet('?status=queued') + + expect(response.status).toBe(400) + expect(dbChainMockFns.limit).not.toHaveBeenCalled() + }) + + it('authorizes the workflow before validating filters', async () => { + mockResolveV2WorkflowAccess.mockResolvedValue({ + ok: false, + response: new Response(null, { status: 404 }), + }) + + const response = await callGet('?limit=0') + + expect(response.status).toBe(404) + expect(dbChainMockFns.limit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/route.ts b/apps/sim/app/api/v2/workflows/[id]/executions/route.ts new file mode 100644 index 00000000000..765c58025d0 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/executions/route.ts @@ -0,0 +1,98 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + type V2WorkflowExecutionListItem, + v2ListWorkflowExecutionsContract, + v2WorkflowExecutionListStatusValueSchema, +} from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listWorkflowExecutions } from '@/lib/workflows/executor/execution-queries' +import { + cursorSortKey, + decodeSortedCursor, + encodeSortedCursor, + v2CursorList, + v2CursorSortError, + v2Error, + v2ValidationError, +} from '@/app/api/v2/lib/response' +import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' + +const logger = createLogger('V2WorkflowExecutionsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** List the durable executions belonging to one workflow. */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const { id: workflowId } = await context.params + const access = await resolveV2WorkflowAccess(request, workflowId, 'read') + if (!access.ok) return access.response + + const parsed = await parseRequest(v2ListWorkflowExecutionsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { status, trigger, startDate, endDate, limit, cursor, order } = parsed.data.query + const sort = cursorSortKey('startedAt', order) + const decodedCursor = decodeSortedCursor(cursor, sort) + if (decodedCursor.status === 'invalid') return v2CursorSortError() + const [cursorStartedAt, cursorRowId] = decodedCursor.status === 'ok' ? decodedCursor.keys : [] + const cursorDate = typeof cursorStartedAt === 'string' ? new Date(cursorStartedAt) : null + if ( + decodedCursor.status === 'ok' && + (decodedCursor.keys.length !== 2 || + !cursorDate || + Number.isNaN(cursorDate.getTime()) || + typeof cursorRowId !== 'string') + ) { + return v2CursorSortError() + } + + try { + const result = await listWorkflowExecutions({ + workflowId, + status, + trigger, + startDate: startDate ? new Date(startDate) : undefined, + endDate: endDate ? new Date(endDate) : undefined, + limit, + cursor: + decodedCursor.status === 'ok' && cursorDate && typeof cursorRowId === 'string' + ? { startedAt: cursorDate, rowId: cursorRowId } + : undefined, + order, + }) + + const data: V2WorkflowExecutionListItem[] = result.data.map((row) => ({ + executionId: row.executionId, + workflowId: row.workflowId ?? workflowId, + status: v2WorkflowExecutionListStatusValueSchema.parse(row.status), + trigger: row.trigger, + startedAt: row.startedAt.toISOString(), + endedAt: row.endedAt?.toISOString() ?? null, + durationMs: row.durationMs, + cost: row.costTotal != null ? { total: Number(row.costTotal) } : null, + })) + + const nextCursor = result.nextCursor + ? encodeSortedCursor(sort, [ + result.nextCursor.startedAt.toISOString(), + result.nextCursor.rowId, + ]) + : null + + return v2CursorList(data, nextCursor) + } catch (error) { + logger.error('Failed to list workflow executions', { + workflowId, + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts index 8d1cea75788..d1cd5ec5a21 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -11,9 +11,9 @@ import { captureServerEvent } from '@/lib/posthog/server' import { performActivateVersion } from '@/lib/workflows/orchestration' import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' import { checkRateLimit } from '@/app/api/v1/middleware' -import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' const logger = createLogger('V2WorkflowRollbackAPI') @@ -50,8 +50,8 @@ export const POST = withRouteHandler( const body = v1RollbackWorkflowBodySchema.safeParse(rawBody.data ?? {}) if (!body.success) return v2ValidationError(body.error) - const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) - if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const target = await resolveV2WorkflowTarget(rateLimit, userId, id, 'admin') + if (!target) return v2Error('NOT_FOUND', 'Workflow not found') const { workflow, workspaceId } = target if (!workflow.isDeployed) { diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index f184af03c30..41a1d478044 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -1,5 +1,3 @@ -import { db } from '@sim/db' -import { workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, @@ -10,7 +8,6 @@ import { } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { type V2WorkflowDetail, @@ -24,6 +21,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' +import { loadWorkflowReadSnapshot } from '@/lib/workflows/queries' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -63,28 +61,18 @@ export const GET = withRouteHandler( const { id } = parsed.data.params - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + const snapshot = await loadWorkflowReadSnapshot(id) + const workflowData = snapshot.workflowRecord + if (!workflowData?.workspaceId || workflowData.archivedAt) { + return v2Error('NOT_FOUND', 'Workflow not found') + } // Mask an authorization failure as 404 so existence is not leaked. const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) if (access) return v2Error('NOT_FOUND', 'Workflow not found') const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') - - const blockRows = await db - .select({ - id: workflowBlocks.id, - type: workflowBlocks.type, - subBlocks: workflowBlocks.subBlocks, - }) - .from(workflowBlocks) - .where(eq(workflowBlocks.workflowId, id)) - - const blocksRecord = Object.fromEntries( - blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }]) - ) - const inputs = extractInputFieldsFromBlocks(blocksRecord) + const inputs = extractInputFieldsFromBlocks(snapshot.normalizedData?.blocks ?? {}) const detail: V2WorkflowDetail = { id: workflowData.id, diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts index d8096bf5ea5..1ed021c974b 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import type { NextRequest } from 'next/server' @@ -10,9 +9,10 @@ import { import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { checkRateLimit } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' const logger = createLogger('V2WorkflowVersionDetailAPI') @@ -43,12 +43,8 @@ export const GET = withRouteHandler( const { id, version } = parsed.data.params - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') + const target = await resolveV2WorkflowTarget(rateLimit, userId, id) + if (!target) return v2Error('NOT_FOUND', 'Workflow not found') const row = await getWorkflowDeploymentVersion(id, version) if (!row?.state) return v2Error('NOT_FOUND', 'Deployment version not found') 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 82c26667795..6d50be6db75 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,4 @@ import { createLogger } from '@sim/logger' -import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import type { NextRequest } from 'next/server' @@ -10,7 +9,7 @@ import { import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { listWorkflowVersions } from '@/lib/workflows/persistence/utils' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { checkRateLimit } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { decodeCursor, @@ -20,6 +19,7 @@ import { v2RateLimitError, v2ValidationError, } from '@/app/api/v2/lib/response' +import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' const logger = createLogger('V2WorkflowVersionsAPI') @@ -57,12 +57,8 @@ export const GET = withRouteHandler( const { id } = parsed.data.params const { limit, cursor } = parsed.data.query - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') + const target = await resolveV2WorkflowTarget(rateLimit, userId, id) + if (!target) return v2Error('NOT_FOUND', 'Workflow not found') /** * A cursor that decodes to anything other than a version number is diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index b314a1500de..0d77ccc618d 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -1,32 +1,18 @@ -import { db } from '@sim/db' -import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { type V2WorkflowListItem, - type V2WorkflowSortBy, v2CreateWorkflowContract, v2ListWorkflowsContract, } from '@/lib/api/contracts/v2/workflows' -import { - encodeKeyset, - type KeysetKey, - keysetAfter, - keysetColumns, - listOrderBy, - numberKey, - searchFilter, - textKey, - timestampKey, -} from '@/lib/api/list-query' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { performCreateWorkflow } from '@/lib/workflows/orchestration' +import { InvalidWorkflowListCursorError, listWorkspaceWorkflows } from '@/lib/workflows/queries' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId, @@ -53,40 +39,6 @@ const logger = createLogger('V2WorkflowsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -type WorkflowRow = { - id: string - name: string - sortOrder: number - runCount: number - createdAt: Date - updatedAt: Date -} - -/** - * The keysets behind the sortable workflow fields. `satisfies` makes the map - * total over the contract enum, so a new sortable field cannot ship without an - * ordering. Every key column is `NOT NULL` and each keyset ends in `id`, which - * is what keeps a page boundary inside a run of equal values stable. - * - * `position` keeps its historical three-part ordering: workflows share a - * `sortOrder` freely, and dropping `createdAt` from the tiebreak would reshuffle - * every workspace's default list. - */ -const workflowId = textKey(workflow.id, (row) => row.id) -const workflowCreatedAt = timestampKey(workflow.createdAt, (row) => row.createdAt) - -const WORKFLOW_SORTS = { - position: [ - numberKey(workflow.sortOrder, (row) => row.sortOrder), - workflowCreatedAt, - workflowId, - ], - name: [textKey(workflow.name, (row) => row.name), workflowId], - createdAt: [workflowCreatedAt, workflowId], - updatedAt: [timestampKey(workflow.updatedAt, (row) => row.updatedAt), workflowId], - runCount: [numberKey(workflow.runCount, (row) => row.runCount), workflowId], -} satisfies Record[]> - export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) @@ -124,58 +76,31 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const sortKey = cursorSortKey(params.sortBy, params.sortOrder) - const keys: readonly KeysetKey[] = WORKFLOW_SORTS[params.sortBy] const decoded = decodeSortedCursor(params.cursor, sortKey) if (decoded.status === 'invalid') return v2CursorSortError() - // `null` here is a cursor whose values don't fit this sort — a client error, not an empty page. - const resumeAfter = - decoded.status === 'ok' ? keysetAfter(keys, decoded.keys, params.sortOrder) : undefined - if (resumeAfter === null) return v2CursorSortError() - - const conditions = [ - eq(workflow.workspaceId, params.workspaceId), - isNull(workflow.archivedAt), - params.folderPath === undefined - ? undefined - : folderId === null - ? isNull(workflow.folderId) - : folderId === undefined - ? undefined - : eq(workflow.folderId, folderId), - params.deployedOnly ? eq(workflow.isDeployed, true) : undefined, - searchFilter(workflow.name, params.search), - resumeAfter, - ] - - const rows = await db - .select({ - id: workflow.id, - name: workflow.name, - description: workflow.description, - folderId: workflow.folderId, - workspaceId: workflow.workspaceId, - isDeployed: workflow.isDeployed, - deployedAt: workflow.deployedAt, - runCount: workflow.runCount, - lastRunAt: workflow.lastRunAt, - sortOrder: workflow.sortOrder, - createdAt: workflow.createdAt, - updatedAt: workflow.updatedAt, + let result + try { + result = await listWorkspaceWorkflows({ + workspaceId: params.workspaceId, + folderId, + deployedOnly: params.deployedOnly, + search: params.search, + sortBy: params.sortBy, + sortOrder: params.sortOrder, + cursorKeys: decoded.status === 'ok' ? decoded.keys : undefined, + limit: params.limit, }) - .from(workflow) - .where(and(...conditions)) - .orderBy(...listOrderBy(keysetColumns(keys), params.sortOrder)) - .limit(params.limit + 1) - - const hasMore = rows.length > params.limit - const data = rows.slice(0, params.limit) + } catch (error) { + if (error instanceof InvalidWorkflowListCursorError) return v2CursorSortError() + throw error + } - const last = data.at(-1) - const nextCursor = - hasMore && last ? encodeSortedCursor(sortKey, encodeKeyset(keys, last)) : null + const nextCursor = result.nextCursorKeys + ? encodeSortedCursor(sortKey, result.nextCursorKeys) + : null - const formatted: V2WorkflowListItem[] = data.map((w) => ({ + const formatted: V2WorkflowListItem[] = result.data.map((w) => ({ id: w.id, name: w.name, description: w.description, diff --git a/apps/sim/app/api/v2/workflows/utils.ts b/apps/sim/app/api/v2/workflows/utils.ts new file mode 100644 index 00000000000..450521e2cca --- /dev/null +++ b/apps/sim/app/api/v2/workflows/utils.ts @@ -0,0 +1,20 @@ +import type { PermissionType } from '@sim/platform-authz/workspace' +import { + type DeploymentWorkflowTarget, + getDeploymentWorkflowTarget, +} from '@/lib/workflows/deployments/queries' +import { type RateLimitResult, resolveWorkspaceAccess } from '@/app/api/v1/middleware' + +/** Resolves an authorized active workflow while keeping the v2 response adapter route-local. */ +export async function resolveV2WorkflowTarget( + rateLimit: RateLimitResult, + userId: string, + workflowId: string, + level: PermissionType = 'read' +): Promise { + const target = await getDeploymentWorkflowTarget(workflowId) + if (!target) return null + + const accessError = await resolveWorkspaceAccess(rateLimit, userId, target.workspaceId, level) + return accessError ? null : target +} diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts index 6ee6c71aa7d..9c36ab2617b 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts @@ -24,6 +24,10 @@ const { mockReadExecutionMetaState, mockWriteEvent, mockWriteTerminalEvent, + mockWorkflowExecutionBelongsToWorkflow, + mockGetJobQueue, + mockGetJob, + mockCancelJob, } = vi.hoisted(() => ({ mockMarkExecutionCancelled: vi.fn(), mockAbortManualExecution: vi.fn(), @@ -36,6 +40,19 @@ const { mockReadExecutionMetaState: vi.fn(), mockWriteEvent: vi.fn(), mockWriteTerminalEvent: vi.fn(), + mockWorkflowExecutionBelongsToWorkflow: vi.fn(), + mockGetJobQueue: vi.fn(), + mockGetJob: vi.fn(), + mockCancelJob: vi.fn(), +})) + +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: mockGetJobQueue, +})) + +vi.mock('@/lib/workflows/executor/execution-queries', () => ({ + workflowExecutionBelongsToWorkflow: (...args: unknown[]) => + mockWorkflowExecutionBelongsToWorkflow(...args), })) vi.mock('@/lib/execution/cancellation', () => ({ @@ -98,6 +115,10 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { mockReadExecutionMetaState.mockResolvedValue({ status: 'missing' }) mockWriteEvent.mockResolvedValue({ eventId: 1 }) mockWriteTerminalEvent.mockResolvedValue({ eventId: 1 }) + mockWorkflowExecutionBelongsToWorkflow.mockResolvedValue(true) + mockGetJob.mockResolvedValue(null) + mockCancelJob.mockResolvedValue(undefined) + mockGetJobQueue.mockResolvedValue({ getJob: mockGetJob, cancelJob: mockCancelJob }) }) it('returns success when cancellation was durably recorded', async () => { @@ -140,6 +161,29 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { }) }) + it('durably cancels a queued execution through its queue run', async () => { + mockMarkExecutionCancelled.mockResolvedValue({ + durablyRecorded: false, + reason: 'redis_unavailable', + }) + mockGetJob.mockResolvedValue({ id: 'run-1', status: 'pending' }) + + const response = await POST(makeRequest(), makeParams()) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + executionId: 'ex-1', + redisAvailable: false, + durablyRecorded: true, + locallyAborted: false, + pausedCancelled: false, + reason: 'recorded', + }) + expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:ex-1') + expect(mockCancelJob).toHaveBeenCalledWith('run-1') + }) + it('returns unsuccessful response when Redis persistence fails', async () => { mockMarkExecutionCancelled.mockResolvedValue({ durablyRecorded: false, @@ -288,6 +332,17 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => { expect(response.status).toBe(403) }) + it('returns 404 without mutating when the execution belongs to another workflow', async () => { + mockWorkflowExecutionBelongsToWorkflow.mockResolvedValue(false) + + const response = await POST(makeRequest(), makeParams()) + + expect(response.status).toBe(404) + expect(mockMarkExecutionCancelled).not.toHaveBeenCalled() + expect(mockBeginPausedCancellation).not.toHaveBeenCalled() + expect(databaseMock.db.update).not.toHaveBeenCalled() + }) + it('updates execution log status in DB when durably recorded', async () => { const mockWhere = vi.fn().mockResolvedValue(undefined) const mockSet = vi.fn(() => ({ where: mockWhere })) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts index 6f5656a8a7e..11ce53defcf 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -6,7 +6,10 @@ import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { cancelWorkflowExecution } from '@/lib/execution/cancel-workflow-execution' +import { + cancelWorkflowExecution, + WorkflowExecutionNotFoundError, +} from '@/lib/execution/cancel-workflow-execution' const logger = createLogger('CancelExecutionAPI') @@ -58,6 +61,9 @@ export const POST = withRouteHandler( return NextResponse.json(result) } catch (error) { + if (error instanceof WorkflowExecutionNotFoundError) { + return NextResponse.json({ error: error.message }, { status: 404 }) + } logger.error('Failed to cancel execution', { workflowId, executionId, diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 1a3a35bc105..ec30ce24829 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -1,5 +1,3 @@ -import { db } from '@sim/db' -import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, @@ -8,7 +6,6 @@ import { FolderLockedError, WorkflowLockedError, } from '@sim/platform-authz/workflow' -import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateWorkflowContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' @@ -17,7 +14,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { loadWorkflowReadSnapshot } from '@/lib/workflows/queries' import { getWorkflowById } from '@/lib/workflows/utils' const logger = createLogger('WorkflowByIdAPI') @@ -85,14 +82,7 @@ export const GET = withRouteHandler( } } - const snapshot = await db.transaction(async (tx) => { - await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`) - const [normalizedData, [workflowRecord]] = await Promise.all([ - loadWorkflowFromNormalizedTables(workflowId, tx), - tx.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1), - ]) - return { normalizedData, workflowRecord } - }) + const snapshot = await loadWorkflowReadSnapshot(workflowId) const responseWorkflowData = snapshot.workflowRecord ?? workflowData // Stamp `workflowId` from the path param on each variable so the diff --git a/apps/sim/lib/api/contracts/common.ts b/apps/sim/lib/api/contracts/common.ts index d79833499db..1b4daa14241 100644 --- a/apps/sim/lib/api/contracts/common.ts +++ b/apps/sim/lib/api/contracts/common.ts @@ -106,7 +106,7 @@ export const getStatusContract = defineRouteContract({ }, }) -const jobStatusSchema = z.enum(['pending', 'processing', 'completed', 'failed']) +const jobStatusSchema = z.enum(['pending', 'processing', 'completed', 'failed', 'cancelled']) const jobStatusResponseSchema = z .object({ diff --git a/apps/sim/lib/api/contracts/v2/audit-logs.ts b/apps/sim/lib/api/contracts/v2/audit-logs.ts index 1084d9bbecb..c91d1f04971 100644 --- a/apps/sim/lib/api/contracts/v2/audit-logs.ts +++ b/apps/sim/lib/api/contracts/v2/audit-logs.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { organizationIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v1AuditLogParamsSchema, @@ -8,10 +9,10 @@ import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/sha /** * v2 audit-logs contracts. These are org-scoped enterprise endpoints. The - * request schemas are reused verbatim from v1 (the query/param shape is - * unchanged); only the response envelope is upgraded to the canonical v2 - * shapes. The v1 `limits` body is dropped — usage limits live on the dedicated - * usage endpoint, not inlined into every response. + * filters are inherited from v1, with an explicit organization selector added + * so callers never depend on whichever membership happens to be returned + * first. The response uses the canonical v2 envelope and drops the v1 `limits` + * body — usage limits live on their dedicated endpoint. */ /** @@ -37,10 +38,16 @@ export const v2AuditLogEntrySchema = z.object({ export type V2AuditLogEntry = z.output +export const v2ListAuditLogsQuerySchema = v1ListAuditLogsQuerySchema + .extend({ organizationId: organizationIdSchema }) + .strict() + +export const v2GetAuditLogQuerySchema = z.object({ organizationId: organizationIdSchema }).strict() + export const v2ListAuditLogsContract = defineRouteContract({ method: 'GET', path: '/api/v2/audit-logs', - query: v1ListAuditLogsQuerySchema, + query: v2ListAuditLogsQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2AuditLogEntrySchema), @@ -51,6 +58,7 @@ export const v2GetAuditLogContract = defineRouteContract({ method: 'GET', path: '/api/v2/audit-logs/[id]', params: v1AuditLogParamsSchema, + query: v2GetAuditLogQuerySchema, response: { mode: 'json', schema: v2DataResponse(v2AuditLogEntrySchema), diff --git a/apps/sim/lib/api/contracts/v2/billing.ts b/apps/sim/lib/api/contracts/v2/billing.ts index 7fd1196b916..6922a9cdc0e 100644 --- a/apps/sim/lib/api/contracts/v2/billing.ts +++ b/apps/sim/lib/api/contracts/v2/billing.ts @@ -4,7 +4,7 @@ import { usageLogPeriodSchema, usageLogSourceSchema } from '@/lib/api/contracts/ import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' /** - * v2 billing contracts — the read-only, API-key-facing usage surface. + * v2 billing contracts — separate read-only status and ledger resources. * * Deliberately separate from the session-only `/api/users/me/usage-logs` * endpoints that back the Billing settings UI: the internal surface can evolve @@ -19,43 +19,45 @@ const parseableDateSchema = z .min(1) .refine((value) => !Number.isNaN(Date.parse(value)), { error: 'Invalid date' }) -export const v2UsageSummaryQuerySchema = z.object({ +export const v2BillingStatusQuerySchema = z.object({ /** - * Restrict the breakdown to one workspace. A workspace-scoped API key is - * always pinned to its own workspace; passing a different id returns 403. + * Resolve status against one workspace's payer. A workspace-scoped API key + * is always pinned to its own workspace; passing a different id returns 403. */ workspaceId: z.string().optional(), }) /** - * Current-billing-period usage summary. `bySourceCredits` is the source-aware - * breakdown (workflow, sim-chat, knowledge-base, …) of the account's ledger for - * the period, so a monitor can watch one source's consumption directly instead - * of estimating it by subtraction. + * Current billing standing and credit allowance. Ledger rows and source + * analytics deliberately live outside this status resource. */ -export const v2UsageSummaryDataSchema = z.object({ +export const v2BillingStatusDataSchema = z.object({ + workspaceId: z.string().nullable(), period: z.object({ start: z.string(), end: z.string() }), - totalCredits: z.number(), - bySourceCredits: z.record(z.string(), z.number()), - limitCredits: z.number(), plan: z.string(), + status: z.enum(['active', 'limit_exceeded', 'billing_blocked']), + credits: z.object({ + used: z.number(), + limit: z.number(), + remaining: z.number(), + }), }) -export type V2UsageSummaryData = z.output +export type V2BillingStatusData = z.output -export const v2GetUsageSummaryContract = defineRouteContract({ +export const v2GetBillingStatusContract = defineRouteContract({ method: 'GET', - path: '/api/v2/billing/usage', - query: v2UsageSummaryQuerySchema, + path: '/api/v2/billing/status', + query: v2BillingStatusQuerySchema, response: { mode: 'json', - schema: v2DataResponse(v2UsageSummaryDataSchema), + schema: v2DataResponse(v2BillingStatusDataSchema), }, }) -export const v2UsageLogsQuerySchema = z +export const v2BillingLogsQuerySchema = z .object({ source: usageLogSourceSchema.optional(), - /** See {@link v2UsageSummaryQuerySchema}'s `workspaceId` — same pinning rules. */ + /** See {@link v2BillingStatusQuerySchema}'s `workspaceId` — same pinning rules. */ workspaceId: z.string().optional(), period: usageLogPeriodSchema.optional().default('30d'), /** Required when `period` is `'custom'`. */ @@ -76,22 +78,28 @@ export const v2UsageLogsQuerySchema = z * legitimately be 0 for a sub-credit event once a sibling row absorbs the * shared rounding remainder. */ -export const v2UsageLogEntrySchema = z.object({ +export const v2BillingLogEntrySchema = z.object({ id: z.string(), createdAt: z.string(), source: usageLogSourceSchema, - /** Populated only when `source` is `'workflow'`. */ - workflowName: z.string().nullable(), + workspaceId: z.string().nullable(), + workflow: z + .object({ + id: z.string(), + name: z.string().nullable(), + }) + .nullable(), + executionId: z.string().nullable(), creditCost: z.number(), }) -export type V2UsageLogEntry = z.output +export type V2BillingLogEntry = z.output -export const v2ListUsageLogsContract = defineRouteContract({ +export const v2ListBillingLogsContract = defineRouteContract({ method: 'GET', - path: '/api/v2/billing/usage/logs', - query: v2UsageLogsQuerySchema, + path: '/api/v2/billing/logs', + query: v2BillingLogsQuerySchema, response: { mode: 'json', - schema: v2CursorListResponse(v2UsageLogEntrySchema), + schema: v2CursorListResponse(v2BillingLogEntrySchema), }, }) diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index d9578c4fd2f..c0bfa33603d 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -1,11 +1,7 @@ import { z } from 'zod' import { traceSpansSchema } from '@/lib/api/contracts/logs' import { defineRouteContract } from '@/lib/api/contracts/types' -import { - v1ExecutionParamsSchema, - v1ListLogsQuerySchema, - v1LogParamsSchema, -} from '@/lib/api/contracts/v1/logs' +import { v1ListLogsQuerySchema } from '@/lib/api/contracts/v1/logs' import { v2CursorListResponse, v2DataResponse, @@ -20,6 +16,7 @@ import { */ const v2LogCostSchema = z.object({ total: z.number() }).nullable() +export const v2LogStatusSchema = z.enum(['pending', 'running', 'completed', 'failed', 'cancelled']) /** Execution `files` is a per-run jsonb array of attachment metadata. */ const v2LogFilesSchema = z.array(z.unknown()).nullable() @@ -32,10 +29,10 @@ const v2LogWorkflowSummarySchema = z.object({ }) export const v2LogListItemSchema = z.object({ - id: z.string(), - workflowId: z.string().nullable(), executionId: z.string(), + workflowId: z.string().nullable(), deploymentVersionId: z.string().nullable(), + status: v2LogStatusSchema, level: z.string(), trigger: z.string(), startedAt: z.string(), @@ -54,9 +51,10 @@ export const v2LogListItemSchema = z.object({ export type V2LogListItem = z.output export const v2LogDetailSchema = z.object({ - id: z.string(), - workflowId: z.string().nullable(), executionId: z.string(), + workflowId: z.string().nullable(), + deploymentVersionId: z.string().nullable(), + status: v2LogStatusSchema, level: z.string(), trigger: z.string(), startedAt: z.string(), @@ -74,32 +72,22 @@ export const v2LogDetailSchema = z.object({ updatedAt: z.string().nullable(), deleted: z.boolean(), }), - /** Materialized execution trace (block states, trace spans). */ - executionData: z.unknown(), + /** Workflow state snapshot captured for this execution. */ + workflowState: z.unknown(), /** Materialized block-level execution trace spans. */ traceSpans: traceSpansSchema, + /** Materialized final output, when the execution produced one. */ + finalOutput: z.unknown().nullable(), cost: v2LogCostSchema, createdAt: z.string(), }) export type V2LogDetail = z.output -export const v2ExecutionSchema = z.object({ - executionId: z.string(), - workflowId: z.string().nullable(), - /** Workflow state snapshot at execution time. */ - workflowState: z.unknown(), - executionMetadata: z.object({ - trigger: z.string(), - startedAt: z.string(), - endedAt: z.string().nullable(), - totalDurationMs: z.number().nullable(), - cost: v2LogCostSchema, - }), +export const v2LogParamsSchema = z.object({ + executionId: z.string().min(1, 'executionId cannot be empty'), }) -export type V2Execution = z.output - export const v2ListLogsQuerySchema = v1ListLogsQuerySchema .omit({ folderIds: true }) .extend({ @@ -140,20 +128,10 @@ export const v2ListLogsContract = defineRouteContract({ export const v2GetLogContract = defineRouteContract({ method: 'GET', - path: '/api/v2/logs/[id]', - params: v1LogParamsSchema, + path: '/api/v2/logs/[executionId]', + params: v2LogParamsSchema, response: { mode: 'json', schema: v2DataResponse(v2LogDetailSchema), }, }) - -export const v2GetExecutionContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/logs/executions/[executionId]', - params: v1ExecutionParamsSchema, - response: { - mode: 'json', - schema: v2DataResponse(v2ExecutionSchema), - }, -}) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 8fcae222c9a..aa3efa413bf 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -462,6 +462,73 @@ export const v2ResumeWorkflowContract = defineRouteContract({ }, }) +export const v2WorkflowExecutionStatusValueSchema = z.enum([ + 'queued', + 'pending', + 'running', + 'completed', + 'failed', + 'cancelled', + 'paused', +]) + +export const v2WorkflowExecutionListStatusValueSchema = z.enum([ + 'pending', + 'running', + 'completed', + 'failed', + 'cancelled', + 'paused', +]) + +export const v2ListWorkflowExecutionsQuerySchema = z + .object({ + status: v2WorkflowExecutionListStatusValueSchema.optional(), + trigger: z.string().min(1, 'trigger cannot be empty').optional(), + startDate: z.string().datetime().optional(), + endDate: z.string().datetime().optional(), + limit: z.coerce.number().int().min(1).max(100).optional().default(50), + cursor: z.string().min(1, 'cursor cannot be empty').optional(), + order: z.enum(['asc', 'desc']).optional().default('desc'), + }) + .strict() + .refine( + (query) => + !query.startDate || + !query.endDate || + Date.parse(query.startDate) <= Date.parse(query.endDate), + { + message: 'startDate must be before or equal to endDate', + path: ['startDate'], + } + ) + +export type V2ListWorkflowExecutionsQuery = z.output + +export const v2WorkflowExecutionListItemSchema = z.object({ + executionId: z.string(), + workflowId: z.string(), + status: v2WorkflowExecutionListStatusValueSchema, + trigger: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + durationMs: z.number().nullable(), + cost: z.object({ total: z.number() }).nullable(), +}) + +export type V2WorkflowExecutionListItem = z.output + +export const v2ListWorkflowExecutionsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]/executions', + params: workflowIdParamsSchema, + query: v2ListWorkflowExecutionsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowExecutionListItemSchema), + }, +}) + /** * The polled execution resource. `queued` is backfilled from the async job * queue before the worker writes the durable log row — v1's jobs endpoint 404 @@ -471,7 +538,7 @@ export const v2ResumeWorkflowContract = defineRouteContract({ export const v2WorkflowExecutionStatusSchema = z.object({ executionId: z.string(), workflowId: z.string(), - status: z.enum(['queued', 'pending', 'running', 'completed', 'failed', 'cancelled', 'paused']), + status: v2WorkflowExecutionStatusValueSchema, trigger: z.string().nullable(), startedAt: z.string().nullable(), endedAt: z.string().nullable(), diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts index 8740d11a541..eb0d450705d 100644 --- a/apps/sim/lib/api/list-query.ts +++ b/apps/sim/lib/api/list-query.ts @@ -12,7 +12,8 @@ import { type SQLWrapper, sql, } from 'drizzle-orm' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' + +export type ListSortOrder = 'asc' | 'desc' /** * Runtime half of the v2 list convention declared in @@ -121,7 +122,7 @@ export function timestampKey(column: Column, read: (row: Row) => Date): Key } } -export function sortDirection(order: V2SortOrder): typeof asc { +export function sortDirection(order: ListSortOrder): typeof asc { return order === 'asc' ? asc : desc } @@ -130,7 +131,7 @@ export function sortDirection(order: V2SortOrder): typeof asc { * On a paginated list these are the keyset's keys; on a single-page list they * are just the sort plus its tiebreaker. */ -export function listOrderBy(keys: readonly SQLWrapper[], order: V2SortOrder): SQL[] { +export function listOrderBy(keys: readonly SQLWrapper[], order: ListSortOrder): SQL[] { const direction = sortDirection(order) return keys.map((key) => direction(key)) } @@ -157,7 +158,7 @@ export function encodeKeyset(keys: readonly KeysetKey[], row: Row): Cu export function keysetAfter( keys: readonly KeysetKey[], values: CursorKey[], - order: V2SortOrder + order: ListSortOrder ): SQL | null { if (values.length !== keys.length) return null diff --git a/apps/sim/lib/billing/core/billing-attribution.ts b/apps/sim/lib/billing/core/billing-attribution.ts index 111fb7007a9..3840a867820 100644 --- a/apps/sim/lib/billing/core/billing-attribution.ts +++ b/apps/sim/lib/billing/core/billing-attribution.ts @@ -449,7 +449,7 @@ export function requireAccountBillingDecisionHeader( } } -function toUsageSubscription(attribution: BillingAttributionSnapshot) { +export function toUsageLimitSubscription(attribution: BillingAttributionSnapshot) { const snapshot = attribution.payerSubscription if (!snapshot) { if (!attribution.organizationId) return null @@ -691,7 +691,7 @@ export async function checkAttributedUsageLimits( const payerUsage = await checkUsageStatus( validatedAttribution.billedAccountUserId, - toUsageSubscription(validatedAttribution) + toUsageLimitSubscription(validatedAttribution) ) const payerSnapshot = { currentUsage: payerUsage.currentUsage, diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index caeefc28f9c..f558ba20ff4 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -69,7 +69,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: vi.fn(), })) vi.mock('@/app/api/auth/oauth/utils', () => ({ getCredential: vi.fn() })) -vi.mock('@/app/api/knowledge/search/utils', () => ({ +vi.mock('@/lib/knowledge/search/queries', () => ({ executeKnowledgeSearch: vi.fn(), })) vi.mock('@/app/api/knowledge/utils', () => ({ diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index 89f3e02867a..c44c667f14a 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -35,6 +35,7 @@ import { performUpdateKnowledgeDocument, performUploadKnowledgeDocument, } from '@/lib/knowledge/orchestration' +import { executeKnowledgeSearch } from '@/lib/knowledge/search/queries' import { getKnowledgeBaseById } from '@/lib/knowledge/service' import { createTagDefinition, @@ -48,7 +49,6 @@ import { import { StorageService } from '@/lib/uploads' import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getCredential } from '@/app/api/auth/oauth/utils' -import { executeKnowledgeSearch } from '@/app/api/knowledge/search/utils' import { checkDocumentWriteAccess, checkKnowledgeBaseAccess, diff --git a/apps/sim/lib/core/async-jobs/backends/database.test.ts b/apps/sim/lib/core/async-jobs/backends/database.test.ts index 1d7207031bc..719ddc1c83e 100644 --- a/apps/sim/lib/core/async-jobs/backends/database.test.ts +++ b/apps/sim/lib/core/async-jobs/backends/database.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMock, dbChainMockFns, flattenMockConditions, resetDbChainMock } from '@sim/testing' import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -9,6 +9,7 @@ vi.mock('@sim/db', () => ({ asyncJobs: { attempts: 'attempts', id: 'id', + status: 'status', }, db: dbChainMock.db, })) @@ -112,3 +113,47 @@ describe('DatabaseJobQueue batchEnqueueAndWait', () => { expect(maxInFlight).toBe(2) }) }) + +describe('DatabaseJobQueue cancelJob', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('persists cancellation as its own terminal status', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'workflow:1' }]) + const queue = new DatabaseJobQueue() + + await queue.cancelJob('workflow:1') + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ status: 'cancelled', error: 'Cancelled' }) + ) + }) + + it('does not let worker failure overwrite a terminal cancellation', async () => { + const queue = new DatabaseJobQueue() + + await queue.markJobFailed('workflow:1', 'aborted') + + const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + expect(conditions).toContainEqual({ + type: 'inArray', + column: 'status', + values: ['pending', 'processing'], + }) + }) + + it('does not let worker completion overwrite a terminal cancellation', async () => { + const queue = new DatabaseJobQueue() + + await queue.completeJob('workflow:1', { ok: true }) + + const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + expect(conditions).toContainEqual({ + type: 'inArray', + column: 'status', + values: ['pending', 'processing'], + }) + }) +}) diff --git a/apps/sim/lib/core/async-jobs/backends/database.ts b/apps/sim/lib/core/async-jobs/backends/database.ts index 45feb6ed412..407dde863ae 100644 --- a/apps/sim/lib/core/async-jobs/backends/database.ts +++ b/apps/sim/lib/core/async-jobs/backends/database.ts @@ -2,7 +2,7 @@ import { asyncJobs, db } from '@sim/db' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' -import { eq, sql } from 'drizzle-orm' +import { and, eq, inArray, sql } from 'drizzle-orm' import { AsyncJobEnqueueError, type EnqueueOptions, @@ -261,7 +261,7 @@ export class DatabaseJobQueue implements JobQueueBackend { attempts: sql`${asyncJobs.attempts} + 1`, updatedAt: now, }) - .where(eq(asyncJobs.id, jobId)) + .where(and(eq(asyncJobs.id, jobId), eq(asyncJobs.status, JOB_STATUS.PENDING))) logger.debug('Started job', { jobId }) } @@ -277,7 +277,12 @@ export class DatabaseJobQueue implements JobQueueBackend { output: output as Record, updatedAt: now, }) - .where(eq(asyncJobs.id, jobId)) + .where( + and( + eq(asyncJobs.id, jobId), + inArray(asyncJobs.status, [JOB_STATUS.PENDING, JOB_STATUS.PROCESSING]) + ) + ) logger.debug('Completed job', { jobId }) } @@ -293,33 +298,45 @@ export class DatabaseJobQueue implements JobQueueBackend { error, updatedAt: now, }) - .where(eq(asyncJobs.id, jobId)) + .where( + and( + eq(asyncJobs.id, jobId), + inArray(asyncJobs.status, [JOB_STATUS.PENDING, JOB_STATUS.PROCESSING]) + ) + ) logger.debug('Marked job as failed', { jobId }) } async cancelJob(jobId: string): Promise { - // Abort any in-process inline execution first so the running workflow - // observes the signal and stops mid-flight. Then mark the row failed so - // any future poller skips it. - const controller = inlineAbortControllers.get(jobId) - let aborted = false - if (controller) { - controller.abort('Cancelled') - inlineAbortControllers.delete(jobId) - aborted = true - } - const now = new Date() - await db + const cancelledJobs = await db .update(asyncJobs) .set({ - status: JOB_STATUS.FAILED, + status: JOB_STATUS.CANCELLED, completedAt: now, error: 'Cancelled', updatedAt: now, }) - .where(eq(asyncJobs.id, jobId)) + .where( + and( + eq(asyncJobs.id, jobId), + inArray(asyncJobs.status, [JOB_STATUS.PENDING, JOB_STATUS.PROCESSING]) + ) + ) + .returning({ id: asyncJobs.id }) + + if (cancelledJobs.length === 0) { + logger.debug('Cancel target is no longer active in DB queue', { jobId }) + return + } + + const controller = inlineAbortControllers.get(jobId) + const aborted = Boolean(controller) + if (controller) { + controller.abort('Cancelled') + inlineAbortControllers.delete(jobId) + } logger.debug('Marked job as cancelled (DB queue)', { jobId, abortedInline: aborted }) } @@ -353,10 +370,16 @@ export class DatabaseJobQueue implements JobQueueBackend { await acquireSlot(concurrencyKey, concurrencyLimit) } try { + abortController.signal.throwIfAborted() await this.startJob(jobId) + abortController.signal.throwIfAborted() await runner(payload, abortController.signal) await this.completeJob(jobId, null) } catch (err) { + if (abortController.signal.aborted) { + logger.info(`[${type}] Inline job ${jobId} cancelled`) + return + } const message = toError(err).message logger.error(`[${type}] Inline job ${jobId} failed`, { error: message }) try { diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts index 7e3577dab0a..453f9000436 100644 --- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts +++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.test.ts @@ -152,10 +152,27 @@ describe('TriggerDevJobQueue getJob', () => { }) expect(mockRetrieveRun).toHaveBeenNthCalledWith(2, 'run-1') expect(job).toMatchObject({ - id: 'workflow-execution:execution-1', + id: 'run-1', status: 'completed', output: { output: { answer: 42 } }, metadata: { workflowId: 'workflow-1' }, }) }) + + it('preserves a cancelled Trigger.dev run as cancelled', async () => { + mockRetrieveRun.mockResolvedValueOnce({ + id: 'run-cancelled', + taskIdentifier: 'workflow-execution', + payload: { workflowId: 'workflow-1' }, + status: 'CANCELED', + createdAt: '2026-08-05T12:00:00.000Z', + finishedAt: '2026-08-05T12:00:01.000Z', + attemptCount: 0, + }) + const queue = new TriggerDevJobQueue() + + const job = await queue.getJob('run-cancelled') + + expect(job).toMatchObject({ id: 'run-cancelled', status: 'cancelled' }) + }) }) diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts index 4059f3066dc..d0b0ea1880b 100644 --- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts +++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts @@ -63,6 +63,7 @@ function mapTriggerDevStatus(status: string): JobStatus { case 'COMPLETED': return JOB_STATUS.COMPLETED case 'CANCELED': + return JOB_STATUS.CANCELLED case 'FAILED': case 'CRASHED': case 'INTERRUPTED': @@ -221,7 +222,7 @@ export class TriggerDevJobQueue implements JobQueueBackend { } return { - id: jobId, + id: run.id, type: run.taskIdentifier as JobType, payload: run.payload, status: mapTriggerDevStatus(run.status), diff --git a/apps/sim/lib/core/async-jobs/types.ts b/apps/sim/lib/core/async-jobs/types.ts index 9a1ee04aefa..fb31c200b77 100644 --- a/apps/sim/lib/core/async-jobs/types.ts +++ b/apps/sim/lib/core/async-jobs/types.ts @@ -2,10 +2,10 @@ * Types and constants for the async job queue system */ -/** Retention period for completed/failed jobs (in hours) */ +/** Retention period for terminal jobs (in hours) */ export const JOB_RETENTION_HOURS = 24 -/** Retention period for completed/failed jobs (in seconds, for Redis TTL) */ +/** Retention period for terminal jobs (in seconds, for Redis TTL) */ export const JOB_RETENTION_SECONDS = JOB_RETENTION_HOURS * 60 * 60 /** Max lifetime for jobs in Redis (in seconds) - cleanup for stuck pending/processing jobs */ @@ -16,6 +16,7 @@ export const JOB_STATUS = { PROCESSING: 'processing', COMPLETED: 'completed', FAILED: 'failed', + CANCELLED: 'cancelled', } as const export type JobStatus = (typeof JOB_STATUS)[keyof typeof JOB_STATUS] diff --git a/apps/sim/lib/execution/cancel-workflow-execution.ts b/apps/sim/lib/execution/cancel-workflow-execution.ts index 8a1b35af297..feb2fc95518 100644 --- a/apps/sim/lib/execution/cancel-workflow-execution.ts +++ b/apps/sim/lib/execution/cancel-workflow-execution.ts @@ -3,6 +3,7 @@ import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' import { and, eq } from 'drizzle-orm' +import { getJobQueue } from '@/lib/core/async-jobs' import { type ExecutionCancellationRecordResult, markExecutionCancelled, @@ -10,12 +11,28 @@ import { import { createExecutionEventWriter, readExecutionMetaState } from '@/lib/execution/event-buffer' import { abortManualExecution } from '@/lib/execution/manual-cancellation' import { captureServerEvent } from '@/lib/posthog/server' +import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/execution-job-ids' +import { workflowExecutionBelongsToWorkflow } from '@/lib/workflows/executor/execution-queries' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' const logger = createLogger('CancelWorkflowExecution') const PAUSED_CANCELLATION_DB_ATTEMPTS = 3 const PAUSED_CANCELLATION_DB_RETRY_MS = 200 +async function cancelActiveWorkflowJob(executionId: string): Promise { + try { + const queue = await getJobQueue() + const job = await queue.getJob(`${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${executionId}`) + if (!job || (job.status !== 'pending' && job.status !== 'processing')) return false + await queue.cancelJob(job.id) + logger.info('Cancelled active workflow queue job', { executionId, jobId: job.id }) + return true + } catch (error) { + logger.warn('Failed to cancel active workflow queue job', { executionId, error }) + return false + } +} + /** * Cancellation outcome vocabulary. `recorded`/`redis_unavailable`/ * `redis_write_failed` come from the Redis record step; the two `paused_*` @@ -120,6 +137,13 @@ export interface CancelWorkflowExecutionInput { workspaceId?: string } +export class WorkflowExecutionNotFoundError extends Error { + constructor() { + super('Execution not found') + this.name = 'WorkflowExecutionNotFoundError' + } +} + /** * Cancels a workflow execution across the Redis abort record, the in-process * aborter, and the paused-HITL machinery. The interleaving is order-sensitive @@ -131,6 +155,9 @@ export async function cancelWorkflowExecution( ): Promise { const { executionId, workflowId, userId, workspaceId } = input + const belongsToWorkflow = await workflowExecutionBelongsToWorkflow(executionId, workflowId) + if (!belongsToWorkflow) throw new WorkflowExecutionNotFoundError() + let pausedCancellationStarted = false let pausedCancelled = false try { @@ -153,11 +180,16 @@ export async function cancelWorkflowExecution( ? { durablyRecorded: false, reason: 'redis_unavailable' } : await markExecutionCancelled(executionId) const locallyAborted = isPausedCancellationPath ? false : abortManualExecution(executionId) + const queuedJobCancelled = isPausedCancellationPath + ? false + : await cancelActiveWorkflowJob(executionId) if (pausedCancellationStarted) { logger.info('Paused execution cancellation reserved in database', { executionId }) } else if (cancellation.durablyRecorded) { logger.info('Execution marked as cancelled in Redis', { executionId }) + } else if (queuedJobCancelled) { + logger.info('Execution cancelled in workflow queue', { executionId }) } else if (locallyAborted) { logger.info('Execution cancelled via local in-process fallback', { executionId }) } else if (!pausedCancellationStarted) { @@ -167,7 +199,10 @@ export async function cancelWorkflowExecution( }) } - if (!isPausedCancellationPath && (cancellation.durablyRecorded || locallyAborted)) { + if ( + !isPausedCancellationPath && + (cancellation.durablyRecorded || queuedJobCancelled || locallyAborted) + ) { await PauseResumeManager.blockQueuedResumesForCancellation(executionId, workflowId).catch( (error) => { logger.warn('Failed to block queued paused resumes after cancellation', { @@ -235,7 +270,7 @@ export async function cancelWorkflowExecution( ) } - if ((cancellation.durablyRecorded || locallyAborted) && !pausedCancelled) { + if ((cancellation.durablyRecorded || queuedJobCancelled || locallyAborted) && !pausedCancelled) { try { await db .update(workflowExecutionLogs) @@ -257,7 +292,7 @@ export async function cancelWorkflowExecution( const success = (isPausedCancellationPath ? pausedCancelled && pausedCancellationPublished - : cancellation.durablyRecorded) || locallyAborted + : cancellation.durablyRecorded || queuedJobCancelled) || locallyAborted if (success) { captureServerEvent( @@ -270,7 +305,7 @@ export async function cancelWorkflowExecution( const durablyRecorded = isPausedCancellationPath ? pausedCancellationPublished - : pausedCancelled || cancellation.durablyRecorded + : pausedCancelled || cancellation.durablyRecorded || queuedJobCancelled const reason: CancelWorkflowExecutionReason = pausedCancellationPublishFailed ? 'paused_event_publish_failed' : !pausedCancelled && isPausedCancellationPath @@ -279,7 +314,9 @@ export async function cancelWorkflowExecution( ? 'paused_event_publish_failed' : pausedCancelled || isPausedCancellationPath ? 'recorded' - : cancellation.reason + : queuedJobCancelled + ? 'recorded' + : cancellation.reason return { success, diff --git a/apps/sim/lib/execution/cancellation.test.ts b/apps/sim/lib/execution/cancellation.test.ts index 2a59904326c..a10eec2f8f8 100644 --- a/apps/sim/lib/execution/cancellation.test.ts +++ b/apps/sim/lib/execution/cancellation.test.ts @@ -1,5 +1,6 @@ import { redisConfigMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { JOB_MAX_LIFETIME_SECONDS } from '@/lib/core/async-jobs/types' const { mockRedisSet, mockPublish, mockSubscribe } = vi.hoisted(() => ({ mockRedisSet: vi.fn(), @@ -46,6 +47,12 @@ describe('markExecutionCancelled', () => { durablyRecorded: true, reason: 'recorded', }) + expect(mockRedisSet).toHaveBeenCalledWith( + 'execution:cancel:execution-1', + '1', + 'EX', + JOB_MAX_LIFETIME_SECONDS + ) }) it('returns redis_write_failed when Redis write throws', async () => { diff --git a/apps/sim/lib/execution/cancellation.ts b/apps/sim/lib/execution/cancellation.ts index a08ea280ed4..9911d93f020 100644 --- a/apps/sim/lib/execution/cancellation.ts +++ b/apps/sim/lib/execution/cancellation.ts @@ -1,11 +1,12 @@ import { createLogger } from '@sim/logger' +import { JOB_MAX_LIFETIME_SECONDS } from '@/lib/core/async-jobs/types' import { getRedisClient } from '@/lib/core/config/redis' import { createPubSubChannel, type PubSubChannel } from '@/lib/events/pubsub' const logger = createLogger('ExecutionCancellation') const EXECUTION_CANCEL_PREFIX = 'execution:cancel:' -const EXECUTION_CANCEL_EXPIRY = 60 * 60 +const EXECUTION_CANCEL_EXPIRY = JOB_MAX_LIFETIME_SECONDS const EXECUTION_CANCEL_CHANNEL = 'execution:cancel' export interface ExecutionCancelEvent { diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 70b6dc6c691..15d3af4cca4 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -12,7 +12,18 @@ import { sha256Hex } from '@sim/security/hash' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { tasks } from '@trigger.dev/sdk' -import { and, asc, desc, eq, inArray, isNotNull, isNull, type SQL, sql } from 'drizzle-orm' +import { + and, + asc, + desc, + eq, + getTableColumns, + inArray, + isNotNull, + isNull, + type SQL, + sql, +} from 'drizzle-orm' import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { assertBillingAttributionSnapshot, @@ -1504,6 +1515,36 @@ export async function getDocuments( } } +export type ActiveKnowledgeDocument = typeof document.$inferSelect & { + connectorType: string | null +} + +/** Loads one visible document and its connector metadata for every API adapter. */ +export async function getKnowledgeDocument( + knowledgeBaseId: string, + documentId: string +): Promise { + const [row] = await db + .select({ + ...getTableColumns(document), + connectorType: knowledgeConnector.connectorType, + }) + .from(document) + .leftJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id)) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(1) + + return row ? { ...row, connectorType: row.connectorType ?? null } : null +} + export async function createSingleDocument( documentData: { filename: string diff --git a/apps/sim/lib/knowledge/documents/tag-filter.ts b/apps/sim/lib/knowledge/documents/tag-filter.ts index a41a4cf337e..740e97bf65d 100644 --- a/apps/sim/lib/knowledge/documents/tag-filter.ts +++ b/apps/sim/lib/knowledge/documents/tag-filter.ts @@ -44,7 +44,7 @@ function escapeLikePattern(s: string): string { * * Text comparisons are case-insensitive and date comparisons are evaluated on * the calendar day, matching the semantics of the knowledge base search filter - * (`app/api/knowledge/search/utils.ts`). Returns `undefined` when the slot, + * (`lib/knowledge/search/queries.ts`). Returns `undefined` when the slot, * operator, or value is not usable so the caller can skip the condition. */ export function buildTagFilterCondition(filter: TagFilterCondition): SQL | undefined { diff --git a/apps/sim/app/api/knowledge/search/utils.ts b/apps/sim/lib/knowledge/search/queries.ts similarity index 99% rename from apps/sim/app/api/knowledge/search/utils.ts rename to apps/sim/lib/knowledge/search/queries.ts index 1a3b62fbf87..4a6c29a5a2d 100644 --- a/apps/sim/app/api/knowledge/search/utils.ts +++ b/apps/sim/lib/knowledge/search/queries.ts @@ -5,7 +5,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { and, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm' import type { StructuredFilter } from '@/lib/knowledge/types' -const logger = createLogger('KnowledgeSearch') +const logger = createLogger('KnowledgeSearchQueries') export interface DocumentMetadata { filename: string diff --git a/apps/sim/lib/logs/fetch-log-detail.ts b/apps/sim/lib/logs/fetch-log-detail.ts index 2ad0c43ffa2..b455b72eff0 100644 --- a/apps/sim/lib/logs/fetch-log-detail.ts +++ b/apps/sim/lib/logs/fetch-log-detail.ts @@ -1,12 +1,5 @@ import { db } from '@sim/db' -import { - jobExecutionLogs, - pausedExecutions, - usageLog, - workflow, - workflowDeploymentVersion, - workflowExecutionLogs, -} from '@sim/db/schema' +import { jobExecutionLogs, usageLog } from '@sim/db/schema' import { and, eq, type SQL } from 'drizzle-orm' import type { CostLedger } from '@/lib/api/contracts/logs' import { @@ -16,6 +9,7 @@ import { pickLatestStartedMarker, } from '@/lib/logs/execution/progress-markers' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' +import { getPublicWorkflowLog } from '@/lib/logs/public-queries' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' type LookupColumn = 'id' | 'executionId' @@ -97,51 +91,7 @@ export async function fetchLogDetail({ const access = await checkWorkspaceAccess(workspaceId, userId) if (!access.hasAccess) return null - const workflowMatch: SQL = - lookupColumn === 'id' - ? eq(workflowExecutionLogs.id, lookupValue) - : eq(workflowExecutionLogs.executionId, lookupValue) - - const rows = await db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - executionId: workflowExecutionLogs.executionId, - deploymentVersionId: workflowExecutionLogs.deploymentVersionId, - level: workflowExecutionLogs.level, - status: workflowExecutionLogs.status, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - costTotal: workflowExecutionLogs.costTotal, - files: workflowExecutionLogs.files, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - deploymentVersion: workflowDeploymentVersion.version, - deploymentVersionName: workflowDeploymentVersion.name, - pausedStatus: pausedExecutions.status, - pausedTotalPauseCount: pausedExecutions.totalPauseCount, - pausedResumedCount: pausedExecutions.resumedCount, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .leftJoin( - workflowDeploymentVersion, - eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) - ) - .leftJoin(pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)) - .where(and(workflowMatch, eq(workflowExecutionLogs.workspaceId, workspaceId))) - .limit(1) - - const log = rows[0] + const log = await getPublicWorkflowLog({ column: lookupColumn, value: lookupValue }, workspaceId) if (log) { const workflowSummary = log.workflowId diff --git a/apps/sim/app/api/v1/logs/filters.ts b/apps/sim/lib/logs/public-filters.ts similarity index 98% rename from apps/sim/app/api/v1/logs/filters.ts rename to apps/sim/lib/logs/public-filters.ts index ab540813893..639ba77ca12 100644 --- a/apps/sim/app/api/v1/logs/filters.ts +++ b/apps/sim/lib/logs/public-filters.ts @@ -1,6 +1,7 @@ import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { and, asc, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' +/** Query filters shared by the v1 and v2 public log adapters. */ export interface LogFilters { workspaceId: string workflowIds?: string[] diff --git a/apps/sim/lib/logs/public-queries.test.ts b/apps/sim/lib/logs/public-queries.test.ts new file mode 100644 index 00000000000..8e3e4092a65 --- /dev/null +++ b/apps/sim/lib/logs/public-queries.test.ts @@ -0,0 +1,29 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { decodePublicLogCursor, encodePublicLogCursor } from '@/lib/logs/public-queries' + +describe('public log cursor', () => { + const cursor = { + startedAt: '2026-08-05T00:01:00.000Z', + id: 'log-1', + order: 'desc' as const, + } + + it('round-trips under the order that minted it', () => { + expect(decodePublicLogCursor(encodePublicLogCursor(cursor), 'desc')).toEqual(cursor) + }) + + it('rejects reuse under a different order', () => { + expect(decodePublicLogCursor(encodePublicLogCursor(cursor), 'asc')).toBeNull() + }) + + it('rejects legacy cursors without an order binding', () => { + const legacyCursor = Buffer.from( + JSON.stringify({ startedAt: cursor.startedAt, id: cursor.id }) + ).toString('base64') + + expect(decodePublicLogCursor(legacyCursor, 'desc')).toBeNull() + }) +}) diff --git a/apps/sim/lib/logs/public-queries.ts b/apps/sim/lib/logs/public-queries.ts new file mode 100644 index 00000000000..f372bbf74ab --- /dev/null +++ b/apps/sim/lib/logs/public-queries.ts @@ -0,0 +1,185 @@ +import { db } from '@sim/db' +import { + pausedExecutions, + workflow, + workflowDeploymentVersion, + workflowExecutionLogs, + workflowExecutionSnapshots, +} from '@sim/db/schema' +import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' +import { buildLogFilters, getOrderBy, type LogFilters } from '@/lib/logs/public-filters' + +export interface PublicLogCursor { + startedAt: string + id: string + order: 'asc' | 'desc' +} + +export function encodePublicLogCursor(cursor: PublicLogCursor): string { + return Buffer.from(JSON.stringify(cursor)).toString('base64') +} + +export function decodePublicLogCursor( + cursor: string, + expectedOrder: 'asc' | 'desc' +): PublicLogCursor | null { + try { + const parsed = JSON.parse(Buffer.from(cursor, 'base64').toString()) as Record + if ( + typeof parsed.startedAt !== 'string' || + typeof parsed.id !== 'string' || + (parsed.order !== 'asc' && parsed.order !== 'desc') || + parsed.order !== expectedOrder + ) { + return null + } + const startedAt = new Date(parsed.startedAt) + if (Number.isNaN(startedAt.getTime())) return null + return { startedAt: parsed.startedAt, id: parsed.id, order: parsed.order } + } catch { + return null + } +} + +export interface ListPublicWorkflowLogsInput { + filters: LogFilters + limit: number + includeExecutionData: boolean + folderScope?: { + includesRoot: boolean + folderIds: string[] + } +} + +/** + * Reads the workflow-execution log page shared by the v1 and v2 public + * adapters. Folder path resolution remains an adapter concern; this query takes + * the resulting ids and applies one coherent root/non-root predicate. + */ +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 rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionId: workflowExecutionLogs.executionId, + deploymentVersionId: workflowExecutionLogs.deploymentVersionId, + status: workflowExecutionLogs.status, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + costTotal: workflowExecutionLogs.costTotal, + files: workflowExecutionLogs.files, + executionData: input.includeExecutionData ? workflowExecutionLogs.executionData : sql`null`, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + workflowArchivedAt: workflow.archivedAt, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(and(conditions, folderCondition)) + .orderBy(...getOrderBy(input.filters.order)) + .limit(input.limit + 1) + + const hasMore = rows.length > input.limit + const data = rows.slice(0, input.limit) + const last = data.at(-1) + const nextCursor = + hasMore && last + ? encodePublicLogCursor({ + startedAt: last.startedAt.toISOString(), + id: last.id, + order: input.filters.order ?? 'desc', + }) + : null + + return { data, nextCursor } +} + +export type PublicWorkflowLogLookup = + | { column: 'id'; value: string } + | { column: 'executionId'; value: string } + +/** + * Loads one workflow log and its optional workflow snapshot. The snapshot join + * is deliberately left-sided: a missing snapshot does not make an otherwise + * valid execution disappear from the log resource. + */ +export async function getPublicWorkflowLog(lookup: PublicWorkflowLogLookup, workspaceId?: string) { + const lookupCondition = + lookup.column === 'id' + ? eq(workflowExecutionLogs.id, lookup.value) + : eq(workflowExecutionLogs.executionId, lookup.value) + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionId: workflowExecutionLogs.executionId, + stateSnapshotId: workflowExecutionLogs.stateSnapshotId, + deploymentVersionId: workflowExecutionLogs.deploymentVersionId, + status: workflowExecutionLogs.status, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + costTotal: workflowExecutionLogs.costTotal, + files: workflowExecutionLogs.files, + createdAt: workflowExecutionLogs.createdAt, + workflowState: workflowExecutionSnapshots.stateData, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + workflowArchivedAt: workflow.archivedAt, + deploymentVersion: workflowDeploymentVersion.version, + deploymentVersionName: workflowDeploymentVersion.name, + pausedStatus: pausedExecutions.status, + pausedTotalPauseCount: pausedExecutions.totalPauseCount, + pausedResumedCount: pausedExecutions.resumedCount, + }) + .from(workflowExecutionLogs) + .leftJoin( + workflowExecutionSnapshots, + eq(workflowExecutionLogs.stateSnapshotId, workflowExecutionSnapshots.id) + ) + .leftJoin( + workflowDeploymentVersion, + eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) + ) + .leftJoin(pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where( + and( + lookupCondition, + workspaceId ? eq(workflowExecutionLogs.workspaceId, workspaceId) : undefined + ) + ) + .limit(1) + + return rows[0] ?? null +} diff --git a/apps/sim/lib/workflows/deployments/queries.ts b/apps/sim/lib/workflows/deployments/queries.ts new file mode 100644 index 00000000000..9301cc03784 --- /dev/null +++ b/apps/sim/lib/workflows/deployments/queries.ts @@ -0,0 +1,15 @@ +import { type ActiveWorkflowRecord, getActiveWorkflowRecord } from '@sim/platform-authz/workflow' + +export interface DeploymentWorkflowTarget { + workflow: ActiveWorkflowRecord + workspaceId: string +} + +/** Loads the active workflow facts shared by every deployment adapter. */ +export async function getDeploymentWorkflowTarget( + workflowId: string +): Promise { + const workflow = await getActiveWorkflowRecord(workflowId) + if (!workflow?.workspaceId) return null + return { workflow, workspaceId: workflow.workspaceId } +} diff --git a/apps/sim/lib/workflows/executor/enqueue-execution.ts b/apps/sim/lib/workflows/executor/enqueue-execution.ts index 4e8992bafd4..b55515a1427 100644 --- a/apps/sim/lib/workflows/executor/enqueue-execution.ts +++ b/apps/sim/lib/workflows/executor/enqueue-execution.ts @@ -4,14 +4,18 @@ import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservati import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import { isAsyncJobEnqueueError } from '@/lib/core/async-jobs/types' +import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/execution-job-ids' import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution' import type { CoreTriggerType } from '@/stores/logs/filters/types' const logger = createLogger('WorkflowEnqueueExecution') const ASYNC_ENQUEUE_ATTEMPTS = 2 -export const WORKFLOW_EXECUTION_JOB_ID_PREFIX = 'workflow-execution:' -export const RESUME_EXECUTION_JOB_ID_PREFIX = 'resume-execution:' + +export { + RESUME_EXECUTION_JOB_ID_PREFIX, + WORKFLOW_EXECUTION_JOB_ID_PREFIX, +} from '@/lib/workflows/executor/execution-job-ids' export interface EnqueueWorkflowExecutionParams { requestId: string diff --git a/apps/sim/lib/workflows/executor/execution-job-ids.ts b/apps/sim/lib/workflows/executor/execution-job-ids.ts new file mode 100644 index 00000000000..4198f70ca10 --- /dev/null +++ b/apps/sim/lib/workflows/executor/execution-job-ids.ts @@ -0,0 +1,2 @@ +export const WORKFLOW_EXECUTION_JOB_ID_PREFIX = 'workflow-execution:' +export const RESUME_EXECUTION_JOB_ID_PREFIX = 'resume-execution:' diff --git a/apps/sim/lib/workflows/executor/execution-queries.test.ts b/apps/sim/lib/workflows/executor/execution-queries.test.ts new file mode 100644 index 00000000000..ec3c77a5f77 --- /dev/null +++ b/apps/sim/lib/workflows/executor/execution-queries.test.ts @@ -0,0 +1,51 @@ +/** + * @vitest-environment node + */ +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing/mocks' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetJob, mockGetJobQueue } = vi.hoisted(() => ({ + mockGetJob: vi.fn(), + mockGetJobQueue: vi.fn(), +})) + +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: mockGetJobQueue, +})) + +import { workflowExecutionBelongsToWorkflow } from '@/lib/workflows/executor/execution-queries' + +describe('workflowExecutionBelongsToWorkflow', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetJobQueue.mockResolvedValue({ getJob: mockGetJob }) + }) + + it('accepts a durable execution bound to the requested workflow', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [{ workflowId: 'workflow-1' }]) + + await expect(workflowExecutionBelongsToWorkflow('execution-1', 'workflow-1')).resolves.toBe( + true + ) + expect(mockGetJobQueue).not.toHaveBeenCalled() + }) + + it('rejects a durable execution bound to another workflow', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [{ workflowId: 'workflow-2' }]) + + await expect(workflowExecutionBelongsToWorkflow('execution-1', 'workflow-1')).resolves.toBe( + false + ) + expect(mockGetJobQueue).not.toHaveBeenCalled() + }) + + it('checks deterministic queue metadata before the durable log exists', async () => { + mockGetJob.mockResolvedValue({ metadata: { workflowId: 'workflow-1' } }) + + await expect(workflowExecutionBelongsToWorkflow('execution-1', 'workflow-1')).resolves.toBe( + true + ) + expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:execution-1') + }) +}) diff --git a/apps/sim/lib/workflows/executor/execution-queries.ts b/apps/sim/lib/workflows/executor/execution-queries.ts new file mode 100644 index 00000000000..89053c73153 --- /dev/null +++ b/apps/sim/lib/workflows/executor/execution-queries.ts @@ -0,0 +1,130 @@ +import { db } from '@sim/db' +import { pausedExecutions, workflowExecutionLogs } from '@sim/db/schema' +import { and, asc, desc, eq, gt, gte, lt, lte, or, sql } from 'drizzle-orm' +import { getJobQueue } from '@/lib/core/async-jobs' +import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/execution-job-ids' + +export type WorkflowExecutionStatus = + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'paused' + +export interface WorkflowExecutionCursor { + startedAt: Date + rowId: string +} + +export interface ListWorkflowExecutionsInput { + workflowId: string + status?: WorkflowExecutionStatus + trigger?: string + startDate?: Date + endDate?: Date + limit: number + cursor?: WorkflowExecutionCursor + order: 'asc' | 'desc' +} + +const executionStatus = sql`CASE + WHEN ${pausedExecutions.status} IN ('paused', 'partially_resumed') THEN 'paused' + ELSE ${workflowExecutionLogs.status} +END` + +/** Lists the durable execution projection for a workflow. */ +export async function listWorkflowExecutions(input: ListWorkflowExecutionsInput) { + const cursorCondition = input.cursor + ? input.order === 'desc' + ? or( + lt(workflowExecutionLogs.startedAt, input.cursor.startedAt), + and( + eq(workflowExecutionLogs.startedAt, input.cursor.startedAt), + lt(workflowExecutionLogs.id, input.cursor.rowId) + ) + ) + : or( + gt(workflowExecutionLogs.startedAt, input.cursor.startedAt), + and( + eq(workflowExecutionLogs.startedAt, input.cursor.startedAt), + gt(workflowExecutionLogs.id, input.cursor.rowId) + ) + ) + : undefined + + const rows = await db + .select({ + rowId: workflowExecutionLogs.id, + executionId: workflowExecutionLogs.executionId, + workflowId: workflowExecutionLogs.workflowId, + status: executionStatus, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + durationMs: workflowExecutionLogs.totalDurationMs, + costTotal: workflowExecutionLogs.costTotal, + }) + .from(workflowExecutionLogs) + .leftJoin(pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)) + .where( + and( + eq(workflowExecutionLogs.workflowId, input.workflowId), + input.status ? eq(executionStatus, input.status) : undefined, + input.trigger ? eq(workflowExecutionLogs.trigger, input.trigger) : undefined, + input.startDate ? gte(workflowExecutionLogs.startedAt, input.startDate) : undefined, + input.endDate ? lte(workflowExecutionLogs.startedAt, input.endDate) : undefined, + cursorCondition + ) + ) + .orderBy( + input.order === 'desc' + ? desc(workflowExecutionLogs.startedAt) + : asc(workflowExecutionLogs.startedAt), + input.order === 'desc' ? desc(workflowExecutionLogs.id) : asc(workflowExecutionLogs.id) + ) + .limit(input.limit + 1) + + const hasMore = rows.length > input.limit + const data = rows.slice(0, input.limit) + const last = data.at(-1) + return { + data, + nextCursor: hasMore && last ? { startedAt: last.startedAt, rowId: last.rowId } : null, + } +} + +/** + * Checks the durable and queued execution records without trusting the workflow + * id supplied by an HTTP path. Mutating callers must use this before operating + * on an execution id because execution ids are globally unique, not nested DB + * keys under a workflow. + */ +export async function workflowExecutionBelongsToWorkflow( + executionId: string, + workflowId: string +): Promise { + const [logRows, pausedRows] = await Promise.all([ + db + .select({ workflowId: workflowExecutionLogs.workflowId }) + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.executionId, executionId)) + .limit(1), + db + .select({ workflowId: pausedExecutions.workflowId }) + .from(pausedExecutions) + .where(eq(pausedExecutions.executionId, executionId)) + .limit(1), + ]) + + const durableWorkflowIds = [logRows[0]?.workflowId, pausedRows[0]?.workflowId].filter( + (value): value is string => typeof value === 'string' + ) + if (durableWorkflowIds.length > 0) { + return durableWorkflowIds.every((value) => value === workflowId) + } + + const queue = await getJobQueue() + const job = await queue.getJob(`${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${executionId}`) + return job?.metadata.workflowId === workflowId +} diff --git a/apps/sim/lib/workflows/executor/execution-status.test.ts b/apps/sim/lib/workflows/executor/execution-status.test.ts index dfbc0719224..ff28142f9e5 100644 --- a/apps/sim/lib/workflows/executor/execution-status.test.ts +++ b/apps/sim/lib/workflows/executor/execution-status.test.ts @@ -12,9 +12,16 @@ vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: vi.fn().mockResolvedValue({ getJob: mockGetJob }), })) -vi.mock('@/lib/workflows/executor/enqueue-execution', () => ({ - RESUME_EXECUTION_JOB_ID_PREFIX: 'resume-execution:', - WORKFLOW_EXECUTION_JOB_ID_PREFIX: 'workflow-execution:', +vi.mock('@/lib/logs/execution/functional-outputs', () => ({ + collectFunctionalBlockOutputs: vi.fn().mockReturnValue(new Map()), +})) + +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionData: vi.fn(), +})) + +vi.mock('@/lib/workflows/executor/paused-execution-metadata', () => ({ + getAutomaticResumeWaitingMetadata: vi.fn().mockReturnValue(null), })) import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' @@ -56,6 +63,25 @@ describe('getWorkflowExecutionStatus queue projection', () => { expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:execution-1') }) + it('preserves queue cancellation as a cancelled execution resource', async () => { + mockGetJob.mockResolvedValue({ + status: 'cancelled', + createdAt: new Date('2026-08-05T12:00:00.000Z'), + completedAt: new Date('2026-08-05T12:00:01.000Z'), + metadata: { workflowId: 'workflow-1' }, + }) + + const status = await getWorkflowExecutionStatus(input) + + expect(status).toMatchObject({ + executionId: 'execution-1', + status: 'cancelled', + level: 'info', + endedAt: '2026-08-05T12:00:01.000Z', + error: null, + }) + }) + it('uses the resume entry ID when the queued work is a resume attempt', async () => { queueTableRows(schemaMock.resumeQueue, [{ id: 'resume-entry-1', status: 'claimed' }]) mockGetJob.mockResolvedValueOnce({ diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index f90ef18e1c6..62d0b066045 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -12,7 +12,7 @@ import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { RESUME_EXECUTION_JOB_ID_PREFIX, WORKFLOW_EXECUTION_JOB_ID_PREFIX, -} from '@/lib/workflows/executor/enqueue-execution' +} from '@/lib/workflows/executor/execution-job-ids' import { getAutomaticResumeWaitingMetadata } from '@/lib/workflows/executor/paused-execution-metadata' import type { PausePoint } from '@/executor/types' diff --git a/apps/sim/lib/workflows/queries.ts b/apps/sim/lib/workflows/queries.ts index 4659f0938f9..2d543003747 100644 --- a/apps/sim/lib/workflows/queries.ts +++ b/apps/sim/lib/workflows/queries.ts @@ -1,11 +1,153 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' -import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm' +import { and, asc, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm' import type { WorkflowListItem } from '@/lib/api/contracts/workflows' +import { + type CursorKey, + encodeKeyset, + type KeysetKey, + keysetAfter, + keysetColumns, + listOrderBy, + numberKey, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' type WorkflowListScope = 'active' | 'archived' | 'all' +export type WorkflowSortBy = 'position' | 'name' | 'createdAt' | 'updatedAt' | 'runCount' +export type WorkflowSortOrder = 'asc' | 'desc' + +export interface WorkspaceWorkflowListRow { + id: string + name: string + description: string | null + folderId: string | null + workspaceId: string | null + isDeployed: boolean + deployedAt: Date | null + runCount: number + lastRunAt: Date | null + sortOrder: number + createdAt: Date + updatedAt: Date +} + +const workspaceWorkflowId = textKey(workflow.id, (row) => row.id) +const workspaceWorkflowCreatedAt = timestampKey( + workflow.createdAt, + (row) => row.createdAt +) + +const WORKFLOW_SORTS = { + position: [ + numberKey(workflow.sortOrder, (row) => row.sortOrder), + workspaceWorkflowCreatedAt, + workspaceWorkflowId, + ], + name: [textKey(workflow.name, (row) => row.name), workspaceWorkflowId], + createdAt: [workspaceWorkflowCreatedAt, workspaceWorkflowId], + updatedAt: [ + timestampKey(workflow.updatedAt, (row) => row.updatedAt), + workspaceWorkflowId, + ], + runCount: [ + numberKey(workflow.runCount, (row) => row.runCount), + workspaceWorkflowId, + ], +} satisfies Record[]> + +export class InvalidWorkflowListCursorError extends Error { + constructor() { + super('Cursor does not match the requested workflow sort') + this.name = 'InvalidWorkflowListCursorError' + } +} + +export interface ListWorkspaceWorkflowsInput { + workspaceId: string + folderId?: string | null + deployedOnly: boolean + search?: string + sortBy: WorkflowSortBy + sortOrder: WorkflowSortOrder + cursorKeys?: CursorKey[] + limit: number +} + +/** Cursor-paged active workflow query used by the public workflow adapter. */ +export async function listWorkspaceWorkflows(input: ListWorkspaceWorkflowsInput) { + const keys = WORKFLOW_SORTS[input.sortBy] + const resumeAfter = input.cursorKeys + ? keysetAfter(keys, input.cursorKeys, input.sortOrder) + : undefined + if (resumeAfter === null) throw new InvalidWorkflowListCursorError() + + const folderCondition: SQL | undefined = + input.folderId === undefined + ? undefined + : input.folderId === null + ? isNull(workflow.folderId) + : eq(workflow.folderId, input.folderId) + + const rows = await db + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + folderId: workflow.folderId, + workspaceId: workflow.workspaceId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt, + sortOrder: workflow.sortOrder, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + }) + .from(workflow) + .where( + and( + eq(workflow.workspaceId, input.workspaceId), + isNull(workflow.archivedAt), + folderCondition, + input.deployedOnly ? eq(workflow.isDeployed, true) : undefined, + searchFilter(workflow.name, input.search), + resumeAfter + ) + ) + .orderBy(...listOrderBy(keysetColumns(keys), input.sortOrder)) + .limit(input.limit + 1) + + const hasMore = rows.length > input.limit + const data = rows.slice(0, input.limit) + const last = data.at(-1) + return { + data, + nextCursorKeys: hasMore && last ? encodeKeyset(keys, last) : null, + } +} + +/** + * Loads one consistent workflow record + normalized definition snapshot. Both + * the editor route and public metadata route derive their own response from + * this read rather than issuing independent block/workflow queries. + */ +export async function loadWorkflowReadSnapshot(workflowId: string) { + return db.transaction(async (tx) => { + await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`) + const [normalizedData, [workflowRecord]] = await Promise.all([ + loadWorkflowFromNormalizedTables(workflowId, tx), + tx.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1), + ]) + return { normalizedData, workflowRecord: workflowRecord ?? null } + }) +} + /** * Project only the columns declared in `workflowListItemSchema` so the result * matches the contract wire shape exactly. The full row is larger (`state`, From 6967c60c98e0b72ce6a34e971edbacbede7f8ff9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 6 Aug 2026 10:33:27 -0700 Subject: [PATCH 085/159] fix(api): close v2 resume and log gaps --- apps/docs/openapi-v2-logs.json | 70 +++++++++-- apps/docs/openapi-v2-workflows.json | 29 ++++- .../[executionId]/[contextId]/route.test.ts | 42 +++++++ apps/sim/app/api/resume/resume-handler.ts | 8 +- apps/sim/app/api/v2/logs/route.test.ts | 109 ++++++++++++++++++ apps/sim/app/api/v2/logs/route.ts | 11 +- .../[executionId]/resume/route.test.ts | 10 +- .../executions/[executionId]/resume/route.ts | 5 +- .../executions/[executionId]/route.test.ts | 32 +++++ apps/sim/lib/api/contracts/logs.ts | 16 +++ .../api/contracts/v2/__tests__/shared.test.ts | 22 ++++ apps/sim/lib/api/contracts/v2/logs.ts | 4 +- apps/sim/lib/api/contracts/v2/workflows.ts | 8 +- apps/sim/lib/api/contracts/workflows.ts | 1 + .../executor/execution-status.test.ts | 82 ++++++++++++- .../workflows/executor/execution-status.ts | 15 ++- 16 files changed, 430 insertions(+), 34 deletions(-) create mode 100644 apps/sim/app/api/v2/logs/route.test.ts diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 13b6fac185b..f0b943cdd55 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 filtering and opaque cursor pagination. Returns `{ data, nextCursor }`. By default (`details=basic`) each entry contains summary fields only; pass `details=full` to include the per-execution `workflow` summary, and additionally `includeFinalOutput=true` / `includeTraceSpans=true` to materialize `finalOutput` / `traceSpans` on each entry.", + "description": "List workflow execution logs for a workspace with filtering and opaque cursor pagination. Returns `{ data, nextCursor }`. By default (`details=basic`) each entry contains summary fields only. Pass `details=full` to include the per-execution `workflow` summary. Requesting `includeFinalOutput=true` or `includeTraceSpans=true` automatically enables full detail and materializes the requested field.", "tags": ["Logs"], "x-codeSamples": [ { @@ -158,7 +158,7 @@ { "name": "details", "in": "query", - "description": "Response detail level. basic returns summary fields only. full additionally includes the per-entry workflow summary and enables the includeFinalOutput / includeTraceSpans materialization flags.", + "description": "Response detail level. basic returns summary fields only. full additionally includes the per-entry workflow summary. Requesting includeFinalOutput or includeTraceSpans also enables full detail.", "schema": { "type": "string", "enum": ["basic", "full"], @@ -168,7 +168,7 @@ { "name": "includeTraceSpans", "in": "query", - "description": "When true, includes block-level execution trace spans on each entry. Only applies when details=full.", + "description": "When true, includes block-level execution trace spans on each entry and automatically enables full detail.", "schema": { "type": "boolean", "default": false @@ -177,7 +177,7 @@ { "name": "includeFinalOutput", "in": "query", - "description": "When true, includes the workflow's final output on each entry. Only applies when details=full.", + "description": "When true, includes the workflow's final output on each entry and automatically enables full detail.", "schema": { "type": "boolean", "default": false @@ -455,6 +455,56 @@ } } }, + "TraceSpan": { + "type": "object", + "additionalProperties": true, + "description": "A block, model, tool, or workflow trace span with timing, cost, and failure metadata.", + "required": ["id", "name", "type"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "type": { "type": "string" }, + "duration": { "type": "number" }, + "durationMs": { "type": "number" }, + "startTime": { "type": "string" }, + "endTime": { "type": "string" }, + "status": { "type": "string" }, + "errorHandled": { + "type": "boolean", + "description": "Whether an error handler path handled this span's failure." + }, + "errorType": { + "type": "string", + "description": "Structured failure class such as RateLimitError." + }, + "errorMessage": { + "type": "string", + "description": "Human-readable failure message." + }, + "blockId": { "type": "string" }, + "input": {}, + "output": {}, + "tokens": {}, + "cost": { + "type": "object", + "properties": { + "total": { "type": "number" }, + "input": { "type": "number" }, + "output": { "type": "number" }, + "toolCost": { "type": "number" } + } + }, + "relativeStartMs": { "type": "number" }, + "toolCalls": { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + }, + "children": { + "type": "array", + "items": { "$ref": "#/components/schemas/TraceSpan" } + } + } + }, "LogWorkflowSummary": { "type": "object", "description": "Workflow summary captured at execution time. Present on a list entry only when details=full.", @@ -632,14 +682,13 @@ "finalOutput": { "type": "object", "additionalProperties": true, - "description": "The workflow's final output. The shape depends on the workflow. Present only when details=full and includeFinalOutput=true." + "description": "The workflow's final output. The shape depends on the workflow. Present when includeFinalOutput=true; requesting it automatically enables full detail." }, "traceSpans": { "type": "array", - "description": "Block-level execution trace spans with timing, inputs, and outputs. Present only when details=full and includeTraceSpans=true.", + "description": "Block-level execution trace spans with timing, cost, and failure metadata. Present when includeTraceSpans=true; requesting it automatically enables full detail.", "items": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/TraceSpan" } } } @@ -730,10 +779,9 @@ }, "traceSpans": { "type": "array", - "description": "Materialized block-level execution trace spans with timing, inputs, and outputs. Empty when the run has no spans.", + "description": "Materialized block-level execution trace spans with timing, cost, and failure metadata. Empty when the run has no spans.", "items": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/TraceSpan" } }, "finalOutput": { diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 32561a872e2..957b4da2497 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -1607,7 +1607,32 @@ }, "paused": { "type": ["object", "null"], - "description": "Pause detail for human-in-the-loop runs." + "description": "Pause detail for human-in-the-loop runs, including the context ID required by the resume endpoint.", + "required": [ + "contextId", + "pausedAt", + "resumeAt", + "pauseKind", + "blockedOnBlockId", + "automaticResumeWaitingReason", + "pausedExecutionId", + "pausePointCount", + "resumedCount" + ], + "properties": { + "contextId": { "type": "string" }, + "pausedAt": { "type": "string" }, + "resumeAt": { "type": ["string", "null"] }, + "pauseKind": { + "type": ["string", "null"], + "enum": ["time", "human", null] + }, + "blockedOnBlockId": { "type": ["string", "null"] }, + "automaticResumeWaitingReason": { "type": ["string", "null"] }, + "pausedExecutionId": { "type": "string" }, + "pausePointCount": { "type": "number" }, + "resumedCount": { "type": "number" } + } }, "cost": { "type": ["object", "null"], @@ -1683,7 +1708,7 @@ "post": { "operationId": "resumeWorkflowExecutionV2", "summary": "Resume a workflow execution", - "description": "Resumes one human-in-the-loop pause context on the parent execution. The resumed attempt receives a new execution ID. Sync attempts return the execution resource, stream attempts return Server-Sent Events, and async or serialized attempts return a 202 receipt whose `statusUrl` is the v2 execution resource.", + "description": "Resumes one human-in-the-loop pause context on the parent execution. Responses are always JSON and the resumed attempt receives a new execution ID. Sync attempts return the execution resource. Async, serialized, and inherited stream-mode attempts return a 202 receipt whose `statusUrl` is the v2 execution resource.", "tags": ["Workflows"], "security": [ { diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts index 9ba0d765a7e..27ec0bb6176 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts @@ -322,6 +322,48 @@ describe('POST /api/resume/[workflowId]/[executionId]/[contextId]', () => { ) }) + it('queues inherited stream-mode resumes when the caller requires JSON', async () => { + mockGetPausedExecutionDetail.mockResolvedValueOnce( + createPausedExecution({ executionMode: 'stream' }) + ) + mockEnqueueOrStartResume.mockResolvedValueOnce({ + status: 'started', + resumeExecutionId: 'resume-execution-1', + resumeEntryId: 'resume-entry-1', + pausedExecution: { id: 'paused-execution-1' }, + contextId: CONTEXT_ID, + resumeInput: { approved: true }, + userId: 'current-api-key-user', + }) + const { request } = makeRequest() + + const response = await handleResumeExecution({ + request, + workflowId: WORKFLOW_ID, + executionId: EXECUTION_ID, + contextId: CONTEXT_ID, + workspaceId: WORKSPACE_ID, + userId: 'current-api-key-user', + resumeInput: { approved: true }, + isApiCaller: true, + pollingSurface: 'v2', + allowStreaming: false, + }) + + expect(response.status).toBe(202) + expect(response.headers.get('Content-Type')).toContain('application/json') + await expect(response.json()).resolves.toMatchObject({ + async: true, + executionId: 'resume-execution-1', + statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/executions/resume-execution-1', + }) + expect(mockEnqueueResume).toHaveBeenCalledWith( + 'resume-execution', + expect.objectContaining({ resumeExecutionId: 'resume-execution-1' }), + expect.objectContaining({ jobId: 'resume-execution:resume-entry-1' }) + ) + }) + it.each([ { statusCode: 402, message: 'Member usage limit reached', retryable: false }, { statusCode: 429, message: 'Target concurrency full', retryable: true }, diff --git a/apps/sim/app/api/resume/resume-handler.ts b/apps/sim/app/api/resume/resume-handler.ts index ad148ff3d74..50bff79a22a 100644 --- a/apps/sim/app/api/resume/resume-handler.ts +++ b/apps/sim/app/api/resume/resume-handler.ts @@ -52,6 +52,8 @@ interface HandleResumeExecutionOptions { resumeInput: unknown isApiCaller: boolean pollingSurface: 'legacy' | 'v2' + /** When false, inherited stream-mode resumes use async JSON polling instead of SSE. */ + allowStreaming?: boolean } function loadPausedExecutionSnapshot( @@ -114,6 +116,7 @@ export async function handleResumeExecution({ resumeInput, isApiCaller, pollingSurface, + allowStreaming = true, }: HandleResumeExecutionOptions): Promise { const requestId = generateRequestId() const pausedExecution = await PauseResumeManager.getPausedExecutionDetail({ @@ -224,8 +227,11 @@ export async function handleResumeExecution({ userId: enqueueResult.userId, } + const persistedExecutionMode = persistedSnapshot.metadata.executionMode ?? 'sync' const executionMode = isApiCaller - ? (persistedSnapshot.metadata.executionMode ?? 'sync') + ? persistedExecutionMode === 'stream' && !allowStreaming + ? 'async' + : persistedExecutionMode : undefined const includeThinking = persistedSnapshot.metadata.includeThinking === true const includeToolCalls = persistedSnapshot.metadata.includeToolCalls === true diff --git a/apps/sim/app/api/v2/logs/route.test.ts b/apps/sim/app/api/v2/logs/route.test.ts new file mode 100644 index 00000000000..34e9a475ad9 --- /dev/null +++ b/apps/sim/app/api/v2/logs/route.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockListPublicWorkflowLogs, + mockMaterializeExecutionData, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListPublicWorkflowLogs: vi.fn(), + mockMaterializeExecutionData: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/logs/public-queries', () => ({ + decodePublicLogCursor: vi.fn(), + listPublicWorkflowLogs: mockListPublicWorkflowLogs, +})) + +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionData: mockMaterializeExecutionData, +})) + +import { GET } from '@/app/api/v2/logs/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-06T01:00:00.000Z'), +} +const LOG_ROW = { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: WORKSPACE_ID, + deploymentVersionId: null, + status: 'completed', + level: 'info', + trigger: 'api', + startedAt: new Date('2026-08-06T00:00:00.000Z'), + endedAt: new Date('2026-08-06T00:00:01.000Z'), + totalDurationMs: 1000, + costTotal: null, + files: null, + executionData: { stored: true }, + workflowName: 'Support Agent', + workflowDescription: null, + workflowArchivedAt: null, +} + +function callLogs(query: string) { + return GET( + new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&${query}`) + ) +} + +describe('GET /api/v2/logs materialized fields', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListPublicWorkflowLogs.mockResolvedValue({ data: [LOG_ROW], nextCursor: null }) + }) + + it.each([false, 0, ''])('preserves a requested falsy final output: %j', async (finalOutput) => { + mockMaterializeExecutionData.mockResolvedValue({ finalOutput }) + + const response = await callLogs('includeFinalOutput=true') + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data[0].finalOutput).toBe(finalOutput) + expect(body.data[0].workflow).toMatchObject({ name: 'Support Agent' }) + expect(mockListPublicWorkflowLogs).toHaveBeenCalledWith( + expect.objectContaining({ includeExecutionData: true }) + ) + }) + + it('makes includeTraceSpans imply full detail', async () => { + const traceSpans = [{ id: 'span-1', name: 'Agent', type: 'agent' }] + mockMaterializeExecutionData.mockResolvedValue({ traceSpans }) + + const response = await callLogs('includeTraceSpans=true') + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data[0].traceSpans).toEqual(traceSpans) + expect(body.data[0].workflow).toMatchObject({ name: 'Support Agent' }) + expect(mockListPublicWorkflowLogs).toHaveBeenCalledWith( + expect.objectContaining({ includeExecutionData: true }) + ) + }) +}) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index 4bebe07842c..71ef75280a3 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -75,6 +75,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { : null if (params.cursor && !decodedCursor) return v2Error('BAD_REQUEST', 'Invalid cursor') const cursor = decodedCursor ?? undefined + const includeFullDetails = + params.details === 'full' || params.includeFinalOutput || params.includeTraceSpans const filters = { workspaceId: params.workspaceId, @@ -97,7 +99,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { data, nextCursor } = await listPublicWorkflowLogs({ filters, limit: params.limit, - includeExecutionData: params.details === 'full', + includeExecutionData: includeFullDetails, folderScope: folderPaths ? { includesRoot, folderIds: nonRootFolderIds ?? [] } : undefined, }) @@ -116,7 +118,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, files: (log.files as unknown[] | null) ?? null, } - if (params.details === 'full') { + if (includeFullDetails) { item.workflow = { id: log.workflowId, name: log.workflowName || 'Deleted Workflow', @@ -127,8 +129,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return item } - const needsMaterialize = - params.details === 'full' && (params.includeFinalOutput || params.includeTraceSpans) + const needsMaterialize = params.includeFinalOutput || params.includeTraceSpans const formattedLogs = needsMaterialize ? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => { @@ -142,7 +143,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { executionId: log.executionId, } )) as Record - if (params.includeFinalOutput && execData.finalOutput) { + if (params.includeFinalOutput && execData.finalOutput !== undefined) { item.finalOutput = execData.finalOutput } if (params.includeTraceSpans) { diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.test.ts index 5e148d3db93..b84d87d6afc 100644 --- a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.test.ts @@ -21,6 +21,7 @@ vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://test.sim.ai', })) +import { v2ResumeWorkflowContract } from '@/lib/api/contracts/v2/workflows' import { POST } from '@/app/api/v2/workflows/[id]/executions/[executionId]/resume/route' const WORKFLOW_ID = 'workflow-1' @@ -90,15 +91,17 @@ describe('POST /api/v2/workflows/[id]/executions/[executionId]/resume', () => { ) const response = await POST(request, context) + const body = await response.json() expect(response.status).toBe(202) expect(response.headers.get('X-Execution-Id')).toBe('resume-execution-1') - expect(await response.json()).toEqual({ + expect(body).toEqual({ data: { executionId: 'resume-execution-1', statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/executions/resume-execution-1', }, }) + expect(v2ResumeWorkflowContract.response.schema.parse(body)).toEqual(body) expect(mockHandleResumeExecution).toHaveBeenCalledWith({ request, workflowId: WORKFLOW_ID, @@ -109,6 +112,7 @@ describe('POST /api/v2/workflows/[id]/executions/[executionId]/resume', () => { resumeInput: { approved: true }, isApiCaller: true, pollingSurface: 'v2', + allowStreaming: false, }) }) @@ -152,9 +156,10 @@ describe('POST /api/v2/workflows/[id]/executions/[executionId]/resume', () => { const { request, context } = makeRequest(JSON.stringify({ contextId: 'context-3' })) const response = await POST(request, context) + const body = await response.json() expect(response.status).toBe(200) - expect(await response.json()).toEqual({ + expect(body).toEqual({ data: { executionId: 'resume-execution-3', workflowId: WORKFLOW_ID, @@ -166,5 +171,6 @@ describe('POST /api/v2/workflows/[id]/executions/[executionId]/resume', () => { durationMs: 1000, }, }) + expect(v2ResumeWorkflowContract.response.schema.parse(body)).toEqual(body) }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.ts index e0afaec0393..b7ca76bd664 100644 --- a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.ts @@ -73,12 +73,9 @@ export const POST = withRouteHandler( resumeInput: input === undefined ? {} : input, isApiCaller: true, pollingSurface: 'v2', + allowStreaming: false, }) - if (response.headers.get('Content-Type')?.startsWith('text/event-stream')) { - return response - } - const payload: unknown = await response.json() if (!isRecordLike(payload)) { return v2Error('INTERNAL_ERROR', 'Resume execution returned an invalid response') diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts index bfd1a3896d8..d82b7b4cedb 100644 --- a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts @@ -115,6 +115,38 @@ describe('v2 executions status + cancel', () => { expect((await res.json()).data.status).toBe('queued') }) + it('returns the resume context for a paused execution', async () => { + mockGetWorkflowExecutionStatus.mockResolvedValue({ + executionId: 'exec-1', + workflowId: 'workflow-1', + status: 'paused', + trigger: 'api', + level: 'info', + startedAt: '2026-07-31T00:00:00.000Z', + endedAt: null, + totalDurationMs: null, + paused: { + contextId: 'context-1', + pausedAt: '2026-07-31T00:00:01.000Z', + resumeAt: null, + pauseKind: 'human', + blockedOnBlockId: 'approval-block', + automaticResumeWaitingReason: null, + pausedExecutionId: 'paused-execution-1', + pausePointCount: 1, + resumedCount: 0, + }, + cost: null, + error: null, + finalOutput: null, + blockOutputs: null, + }) + + const body = await (await callStatus()).json() + + expect(body.data.paused.contextId).toBe('context-1') + }) + it('404s when neither a log row nor a matching job exists', async () => { mockGetWorkflowExecutionStatus.mockResolvedValue(null) diff --git a/apps/sim/lib/api/contracts/logs.ts b/apps/sim/lib/api/contracts/logs.ts index db80314bc27..dbb12fb9762 100644 --- a/apps/sim/lib/api/contracts/logs.ts +++ b/apps/sim/lib/api/contracts/logs.ts @@ -170,10 +170,14 @@ export type LogTraceSpan = { startTime?: string endTime?: string status?: string + errorHandled?: boolean + errorType?: string + errorMessage?: string blockId?: string input?: unknown output?: unknown tokens?: number | { total?: number; input?: number; output?: number } + cost?: { total?: number; input?: number; output?: number; toolCost?: number } relativeStartMs?: number toolCalls?: Array> children?: LogTraceSpan[] @@ -190,6 +194,9 @@ export const traceSpanSchema: z.ZodType = z.lazy(() => startTime: z.string().optional(), endTime: z.string().optional(), status: z.string().optional(), + errorHandled: z.boolean().optional(), + errorType: z.string().optional(), + errorMessage: z.string().optional(), blockId: z.string().optional(), input: z.unknown().optional(), output: z.unknown().optional(), @@ -205,6 +212,15 @@ export const traceSpanSchema: z.ZodType = z.lazy(() => .partial(), ]) .optional(), + cost: z + .object({ + total: z.number().optional(), + input: z.number().optional(), + output: z.number().optional(), + toolCost: z.number().optional(), + }) + .partial() + .optional(), relativeStartMs: z.number().optional(), toolCalls: z.array(toolCallSchema).optional(), children: z.array(traceSpanSchema).optional(), 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 896fb786ce4..e440b051abd 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/shared.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { traceSpansSchema } from '@/lib/api/contracts/logs' import { v2ListLogsQuerySchema } from '@/lib/api/contracts/v2/logs' import { v2DeleteFolderQuerySchema, @@ -58,4 +59,25 @@ describe('v2 folder path contracts', () => { expect(query.folderPaths).toBe('/Reports/Q1,/Archive') }) + + it('declares persisted trace cost and error metadata', () => { + const [span] = traceSpansSchema.parse([ + { + id: 'span-1', + name: 'Agent', + type: 'agent', + errorHandled: true, + errorType: 'RateLimitError', + errorMessage: 'Rate limited', + cost: { input: 0.001, output: 0.002, toolCost: 0.01, total: 0.013 }, + }, + ]) + + expect(span).toMatchObject({ + errorHandled: true, + errorType: 'RateLimitError', + errorMessage: 'Rate limited', + cost: { input: 0.001, output: 0.002, toolCost: 0.01, total: 0.013 }, + }) + }) }) diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index c0bfa33603d..ed85438a4b0 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -42,9 +42,9 @@ export const v2LogListItemSchema = z.object({ files: v2LogFilesSchema, /** Present only when `details=full`. */ workflow: v2LogWorkflowSummarySchema.optional(), - /** Present only when `details=full` and `includeFinalOutput=true`. */ + /** Present when `includeFinalOutput=true`; the flag implies full detail. */ finalOutput: z.unknown().optional(), - /** Present only when `details=full` and `includeTraceSpans=true`. */ + /** Present when `includeTraceSpans=true`; the flag implies full detail. */ traceSpans: traceSpansSchema.optional(), }) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index aa3efa413bf..61d05d8c05b 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -451,6 +451,12 @@ export const v2ResumeWorkflowQueuedSchema = v2ExecuteWorkflowQueuedSchema.extend }) export type V2ResumeWorkflowQueued = z.output +export const v2ResumeWorkflowResponseSchema = z.union([ + v2DataResponse(v2ExecuteWorkflowDataSchema), + v2DataResponse(v2ResumeWorkflowQueuedSchema), +]) +export type V2ResumeWorkflowResponse = z.output + export const v2ResumeWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/[id]/executions/[executionId]/resume', @@ -458,7 +464,7 @@ export const v2ResumeWorkflowContract = defineRouteContract({ body: v2ResumeWorkflowBodySchema, response: { mode: 'json', - schema: v2DataResponse(v2ExecuteWorkflowDataSchema), + schema: v2ResumeWorkflowResponseSchema, }, }) diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index a4e5743211d..855d345597c 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -556,6 +556,7 @@ const workflowExecutionStatusEnum = z.enum([ ]) export const workflowExecutionPausedDetailSchema = z.object({ + contextId: z.string(), pausedAt: z.string(), resumeAt: z.string().nullable(), pauseKind: z.enum(['time', 'human']).nullable(), diff --git a/apps/sim/lib/workflows/executor/execution-status.test.ts b/apps/sim/lib/workflows/executor/execution-status.test.ts index ff28142f9e5..b5ab71f2e72 100644 --- a/apps/sim/lib/workflows/executor/execution-status.test.ts +++ b/apps/sim/lib/workflows/executor/execution-status.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { and } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetJob } = vi.hoisted(() => ({ @@ -100,6 +101,37 @@ describe('getWorkflowExecutionStatus queue projection', () => { startedAt: '2026-08-05T12:00:01.000Z', }) expect(mockGetJob).toHaveBeenCalledWith('resume-execution:resume-entry-1') + + type MockPredicate = { type: string; left?: unknown; right?: unknown } + const activeResumePredicates = vi + .mocked(and) + .mock.calls.find((conditions) => + (conditions as MockPredicate[]).some( + (condition) => condition.left === schemaMock.resumeQueue.newExecutionId + ) + ) as MockPredicate[] | undefined + expect(activeResumePredicates).toEqual( + expect.arrayContaining([ + { + type: 'eq', + left: schemaMock.resumeQueue.newExecutionId, + right: input.executionId, + }, + { + type: 'eq', + left: schemaMock.pausedExecutions.workflowId, + right: input.workflowId, + }, + ]) + ) + expect(activeResumePredicates).not.toContainEqual( + expect.objectContaining({ left: schemaMock.resumeQueue.parentExecutionId }) + ) + expect(dbChainMockFns.innerJoin).toHaveBeenCalledWith(schemaMock.pausedExecutions, { + type: 'eq', + left: schemaMock.resumeQueue.pausedExecutionId, + right: schemaMock.pausedExecutions.id, + }) }) it('projects an active resume ahead of the existing paused log', async () => { @@ -245,4 +277,52 @@ describe('getWorkflowExecutionStatus queue projection', () => { await expect(getWorkflowExecutionStatus(input)).resolves.toBeNull() }) + + it('exposes the active pause context required by resume', async () => { + queueTableRows(schemaMock.workflowExecutionLogs, [ + { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + status: 'paused', + level: 'info', + trigger: 'api', + startedAt: new Date('2026-08-05T12:00:00.000Z'), + endedAt: null, + totalDurationMs: null, + executionData: null, + costTotal: null, + }, + ]) + queueTableRows(schemaMock.resumeQueue, []) + queueTableRows(schemaMock.pausedExecutions, [ + { + id: 'paused-execution-1', + status: 'paused', + pausePoints: { + 'context-1': { + contextId: 'context-1', + blockId: 'approval-block', + response: null, + registeredAt: '2026-08-05T12:00:01.000Z', + resumeStatus: 'paused', + snapshotReady: true, + pauseKind: 'human', + }, + }, + metadata: {}, + resumedCount: 0, + pausedAt: new Date('2026-08-05T12:00:01.000Z'), + nextResumeAt: null, + }, + ]) + + const status = await getWorkflowExecutionStatus(input) + + expect(status?.paused).toMatchObject({ + contextId: 'context-1', + pauseKind: 'human', + blockedOnBlockId: 'approval-block', + }) + }) }) diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index 62d0b066045..c3352dd014d 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -162,10 +162,11 @@ export async function getWorkflowExecutionStatus( claimedAt: resumeQueue.claimedAt, }) .from(resumeQueue) + .innerJoin(pausedExecutions, eq(resumeQueue.pausedExecutionId, pausedExecutions.id)) .where( and( - eq(resumeQueue.parentExecutionId, executionId), eq(resumeQueue.newExecutionId, executionId), + eq(pausedExecutions.workflowId, workflowId), inArray(resumeQueue.status, ['pending', 'claimed'] as const) ) ) @@ -241,14 +242,18 @@ export async function getWorkflowExecutionStatus( if (isCurrentlyPaused && pausedRow) { const points = normalizePausePoints(pausedRow.pausePoints) const earliest = pickEarliestPausePoint(points) + if (!earliest) { + throw new Error('Paused execution has no active resume context') + } const automaticResumeWaiting = getAutomaticResumeWaitingMetadata(pausedRow.metadata) paused = { + contextId: earliest.contextId, pausedAt: pausedRow.pausedAt.toISOString(), - resumeAt: pausedRow.nextResumeAt?.toISOString() ?? earliest?.resumeAt ?? null, - pauseKind: earliest?.pauseKind ?? null, - blockedOnBlockId: earliest?.blockId ?? null, + resumeAt: pausedRow.nextResumeAt?.toISOString() ?? earliest.resumeAt ?? null, + pauseKind: earliest.pauseKind, + blockedOnBlockId: earliest.blockId ?? null, automaticResumeWaitingReason: - automaticResumeWaiting?.reason ?? earliest?.automaticResumeWaitingReason ?? null, + automaticResumeWaiting?.reason ?? earliest.automaticResumeWaitingReason ?? null, pausedExecutionId: pausedRow.id, pausePointCount: points.length, resumedCount: pausedRow.resumedCount, From 21e14c77ab2165aa62607c96dc3b74eb3426472a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 6 Aug 2026 16:58:27 -0700 Subject: [PATCH 086/159] feat(cli): improve v2 command workflows --- packages/sim-cli/README.md | 33 +- packages/sim-cli/src/contract/commands.ts | 139 +++- packages/sim-cli/src/contract/types.ts | 6 +- packages/sim-cli/src/generated/v2-api.ts | 688 +++++++++---------- packages/sim-cli/src/http/client.test.ts | 6 +- packages/sim-cli/src/output/trace.ts | 115 ++++ packages/sim-cli/src/runtime/build.test.ts | 236 ++++++- packages/sim-cli/src/runtime/build.ts | 45 +- packages/sim-cli/src/runtime/execute.ts | 24 +- packages/sim-cli/src/runtime/options.ts | 14 + packages/sim-cli/src/runtime/request.test.ts | 5 + packages/sim-cli/src/runtime/request.ts | 8 +- packages/sim-cli/src/runtime/result.ts | 53 +- 13 files changed, 940 insertions(+), 432 deletions(-) create mode 100644 packages/sim-cli/src/output/trace.ts diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 43c7eee434a..fe265772d95 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -122,11 +122,17 @@ sim workflows get sim workflows update [--name ] [--description ] [--folder ] sim workflows mv sim workflows deploy|undeploy|rollback -sim workflows run [--input ] [--select-output …] +sim workflows run [--input ] [--select-output …] [--async] +sim workflows executions list --workflow [--status ] +sim workflows executions get --workflow [--include-output] +sim workflows executions cancel --workflow +sim workflows executions resume --workflow --context [--input ] -sim logs list [--level error] [--workflow …] [--trigger …] [--start ] -sim logs get -sim logs executions get +sim logs list [--level error] [--workflow …] [--trigger …] [--start-date ] +sim logs get + +sim audit-logs list --organization [--all-workspaces] +sim audit-logs get --organization sim tables ls [path] [--search ] [--limit ] sim tables list [--folder ] @@ -164,17 +170,26 @@ sim documents get --kb sim documents upload --kb [--tag ...] sim documents delete --kb --yes -sim billing -sim billing logs [--period 7d] [--source sim-chat] [--limit ] +sim billing status [--all-workspaces] +sim billing logs [--period 7d] [--source sim-chat] [--limit ] [--all-workspaces] ``` The `sim-chat` billing source combines Copilot and workspace chat usage. +Organization audit logs require a personal API key. Commands with +`--all-workspaces` otherwise default to the workspace in the active profile. -`sim logs get` keeps the default human output concise. Use JSON or YAML to -inspect its complete `executionData` and recursive `traceSpans` tree: +`workflows executions get` is the lightweight status and polling resource. +`--workflow` names the parent resource, while the execution ID remains positional. +For a paused execution, its status includes the context ID needed by `resume`. +`logs get` is the full diagnostic resource. It keeps the default human output +concise; add `--trace` for the expanded recursive trace with span inputs, +outputs, errors, timing, and cost. JSON and YAML retain the complete structured +response: ```bash -sim logs get --output json | jq '.traceSpans' +sim logs get --trace +sim logs get --output json | jq '.traceSpans' +sim logs list --include-trace-spans --output json ``` Workflow output selectors use `blockName.field` syntax, such as diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 81b9cc06554..e35b8f7f470 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -25,6 +25,13 @@ const KNOWLEDGE_DOCUMENT_SCOPE = { describe: 'Knowledge base ID', }, } as const +const WORKFLOW_EXECUTION_SCOPE = { + id: { + name: 'workflow', + placeholder: 'workflowId', + describe: 'Workflow ID', + }, +} as const const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ { header: 'path' }, { header: 'name' }, @@ -54,21 +61,24 @@ function moveResource(command: string, resource: string): CommandVariantSpec { * upsertTableRow → sim tables upsert */ export const CLI_CONTRACT: CliContract = { - getUsageSummary: { - command: 'billing', - groupDefault: true, - describe: 'Show current billing-period usage', + getBillingStatus: { + command: 'billing status', + allWorkspaces: true, + describe: 'Show billing status and current-period credit usage', fields: [ { header: 'plan' }, + { header: 'status' }, + { header: 'workspace', path: 'workspaceId' }, { header: 'period start', path: 'period.start', format: 'timestamp' }, { header: 'period end', path: 'period.end', format: 'timestamp' }, - { header: 'used credits', path: 'totalCredits' }, - { header: 'limit credits', path: 'limitCredits' }, - { header: 'by source', path: 'bySourceCredits' }, + { header: 'used credits', path: 'credits.used' }, + { header: 'limit credits', path: 'credits.limit' }, + { header: 'remaining credits', path: 'credits.remaining' }, ], }, - listUsageLogs: { + listBillingLogs: { command: 'billing logs', + allWorkspaces: true, describe: 'List credit usage events', flags: { source: { describe: 'Filter by usage source; sim-chat combines Copilot and workspace chat' }, @@ -78,9 +88,11 @@ export const CLI_CONTRACT: CliContract = { }, columns: [ { header: 'at', path: 'createdAt', format: 'timestamp' }, + { header: 'workspace', path: 'workspaceId' }, { header: 'source' }, - { header: 'workflow', path: 'workflowName' }, + { header: 'workflow', path: 'workflow.name' }, { header: 'credits', path: 'creditCost' }, + { header: 'execution', path: 'executionId' }, { header: 'id' }, ], }, @@ -154,9 +166,19 @@ export const CLI_CONTRACT: CliContract = { workflowIds: { name: 'workflow', list: true }, folderPaths: { ...FOLDER_PATH_FLAG, list: true }, triggers: { name: 'trigger', list: true }, + details: { describe: 'Response detail level' }, + includeTraceSpans: { + boolean: true, + describe: 'Include trace spans in JSON or YAML output (implies full detail)', + }, + includeFinalOutput: { + boolean: true, + describe: 'Include final output in JSON or YAML output (implies full detail)', + }, }, columns: [ { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'status' }, { header: 'level' }, { header: 'trigger' }, { header: 'workflow', path: 'workflow.name' }, @@ -166,12 +188,12 @@ export const CLI_CONTRACT: CliContract = { ], }, getLog: { - describe: - 'Show a log summary (traceSpans and executionData are included in JSON or YAML output)', + describe: 'Show execution diagnostics', + expandedTrace: true, fields: [ - { header: 'id' }, { header: 'execution', path: 'executionId' }, { header: 'workflow', path: 'workflow.name' }, + { header: 'status' }, { header: 'level' }, { header: 'trigger' }, { header: 'started', path: 'startedAt', format: 'timestamp' }, @@ -179,6 +201,7 @@ export const CLI_CONTRACT: CliContract = { { header: 'duration', path: 'totalDurationMs', format: 'duration' }, { header: 'cost', path: 'cost.total', format: 'cost' }, { header: 'files', format: 'count' }, + { header: 'trace', path: 'traceSpans', format: 'trace-count' }, ], }, searchKnowledge: { @@ -372,13 +395,29 @@ export const CLI_CONTRACT: CliContract = { }, listAuditLogs: { + allWorkspaces: true, + flags: { + organizationId: { + name: 'organization', + describe: 'Organization ID (personal API key required)', + }, + }, columns: [ { header: 'at', path: 'createdAt', format: 'timestamp' }, + { header: 'workspace', path: 'workspaceId' }, { header: 'actor', path: 'actorEmail' }, { header: 'action' }, { header: 'resource', path: 'resourceName' }, ], }, + getAuditLog: { + flags: { + organizationId: { + name: 'organization', + describe: 'Organization ID (personal API key required)', + }, + }, + }, // ─── The expanded files surface ─────────────────────────────────────────── // Every one of these derives badly. `/files/move` and `/files/bulk-delete` @@ -612,8 +651,9 @@ export const CLI_CONTRACT: CliContract = { // `workflows execute create` and `workflows cancel create`. executeWorkflow: { command: 'workflows run', - describe: 'Run a deployed workflow and wait for the result', + describe: 'Run a deployed workflow', flags: { + async: { boolean: true, describe: 'Queue the execution and return immediately' }, input: { json: true, describe: 'Trigger input as JSON' }, selectedOutputs: { name: 'select-output', @@ -626,18 +666,89 @@ export const CLI_CONTRACT: CliContract = { // command; advertising a flag that breaks the response is worse than // not offering it yet. stream: { omit: true }, + includeThinking: { omit: true }, + includeToolCalls: { omit: true }, }, }, getWorkflowExecution: { command: 'workflows executions get', - describe: 'Show the status of one execution', + pathFlags: WORKFLOW_EXECUTION_SCOPE, + describe: 'Show execution status (requested outputs are included in JSON or YAML output)', + flags: { + includeOutput: { + boolean: true, + describe: 'Include the final output in JSON or YAML output', + }, + selectedOutputs: { + name: 'select-output', + list: true, + describe: 'Include blockName.field values in JSON or YAML output (e.g. agent_1.content)', + }, + }, + fields: [ + { header: 'execution', path: 'executionId' }, + { header: 'workflow', path: 'workflowId' }, + { header: 'status' }, + { header: 'trigger' }, + { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'ended', path: 'endedAt', format: 'timestamp' }, + { header: 'duration', path: 'durationMs', format: 'duration' }, + { header: 'cost', path: 'cost.total', format: 'cost' }, + { header: 'context', path: 'paused.contextId' }, + { header: 'pause kind', path: 'paused.pauseKind' }, + { header: 'paused at', path: 'paused.pausedAt', format: 'timestamp' }, + { header: 'resume at', path: 'paused.resumeAt', format: 'timestamp' }, + { header: 'blocked on', path: 'paused.blockedOnBlockId' }, + { header: 'pause points', path: 'paused.pausePointCount' }, + { header: 'error', path: 'error.message' }, + ], + }, + listWorkflowExecutions: { + command: 'workflows executions list', + pathFlags: WORKFLOW_EXECUTION_SCOPE, + describe: 'List executions for a workflow', + columns: [ + { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'status' }, + { header: 'trigger' }, + { header: 'duration', path: 'durationMs', format: 'duration' }, + { header: 'cost', path: 'cost.total', format: 'cost' }, + { header: 'execution', path: 'executionId' }, + ], }, cancelWorkflowExecution: { command: 'workflows executions cancel', + pathFlags: WORKFLOW_EXECUTION_SCOPE, describe: 'Cancel a running execution', // Not `confirm`-gated: cancelling is recoverable (re-run it), and the // whole point is to stop something that is already going wrong. }, + resumeWorkflow: { + command: 'workflows executions resume', + pathFlags: WORKFLOW_EXECUTION_SCOPE, + describe: 'Resume a paused execution (output is included in JSON or YAML output)', + flags: { + contextId: { + name: 'context', + describe: 'Pause context ID returned by execution status', + }, + input: { + json: true, + describe: 'Resume input as JSON', + }, + }, + fields: [ + { header: 'execution', path: 'executionId' }, + { header: 'workflow', path: 'workflowId' }, + { header: 'status' }, + { header: 'status URL', path: 'statusUrl' }, + { header: 'queue position', path: 'queuePosition' }, + { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'ended', path: 'endedAt', format: 'timestamp' }, + { header: 'duration', path: 'durationMs', format: 'duration' }, + { header: 'error', path: 'error.message' }, + ], + }, // ─── Not a terminal-shaped operation ────────────────────────────────────── // Multipart upload; `sim documents upload --kb ` needs its diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index c21b62326b2..30838fa081f 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -82,7 +82,7 @@ export interface ColumnSpec { /** Dot path into the row. Defaults to `header`. */ path?: string /** Rendering hint; `auto` inspects the value. */ - format?: 'auto' | 'timestamp' | 'bytes' | 'duration' | 'bool' | 'cost' | 'count' + format?: 'auto' | 'timestamp' | 'bytes' | 'duration' | 'bool' | 'cost' | 'count' | 'trace-count' } export interface BodyVariantSpec { @@ -135,8 +135,12 @@ export interface CommandSpec { columns?: ColumnSpec[] /** Fields shown for a single record in human formats. Machine output stays raw. */ fields?: ColumnSpec[] + /** Add `--trace` to expand recursive trace spans in human-readable output. */ + expandedTrace?: boolean /** Dot path to a nested result array rendered as the command's human list. */ itemsPath?: string + /** Allow an optional workspaceId field to omit the configured workspace filter. */ + allWorkspaces?: boolean /** * Require `--yes`. The message should say what is about to be destroyed — * the point is that the caller can tell whether they meant it. diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index b5cefac4ecc..039261dceec 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -1195,6 +1195,25 @@ export type CreateTableRowsBody = | { workspaceId: string data: unknown + __privateSecretProvenance?: { + version: 1 + complete: boolean + selections: Array<{ + key: string + provenance: { + version: 1 + complete: boolean + entries: Array<{ + encryptedValue: string + name?: string + }> + scope?: { + userId: string + workspaceId?: string + } + } + }> + } afterRowId?: string beforeRowId?: string } @@ -1243,68 +1262,6 @@ export type CreateTableViewBody = { } } -type CreateTableViewResponseRef0 = - | { - all: Array< - | CreateTableViewResponseRef0 - | { - field: string - op: - | 'eq' - | 'ne' - | 'gt' - | 'gte' - | 'lt' - | 'lte' - | 'in' - | 'nin' - | 'contains' - | 'ncontains' - | 'startsWith' - | 'endsWith' - | 'like' - | 'ilike' - | 'nlike' - | 'nilike' - | 'isEmpty' - | 'isNotEmpty' - | 'isNull' - | 'isNotNull' - value?: unknown - } - > - } - | { - any: Array< - | CreateTableViewResponseRef0 - | { - field: string - op: - | 'eq' - | 'ne' - | 'gt' - | 'gte' - | 'lt' - | 'lte' - | 'in' - | 'nin' - | 'contains' - | 'ncontains' - | 'startsWith' - | 'endsWith' - | 'like' - | 'ilike' - | 'nlike' - | 'nilike' - | 'isEmpty' - | 'isNotEmpty' - | 'isNull' - | 'isNotNull' - value?: unknown - } - > - } - export type CreateTableViewResponse = { data: { view: { @@ -1316,7 +1273,7 @@ export type CreateTableViewResponse = { columnOrder?: Array pinnedColumns?: Array hiddenColumns?: Array - filter?: CreateTableViewResponseRef0 | null + filter?: unknown | null sort?: Array<{ field: string direction: 'asc' | 'desc' @@ -1760,6 +1717,7 @@ export type ExecuteWorkflowParams = { export type ExecuteWorkflowBody = { input?: Record async?: boolean + executionTimeoutSeconds?: number stream?: boolean selectedOutputs?: Array includeThinking?: boolean @@ -1950,6 +1908,10 @@ export type GetAuditLogParams = { id: string } +export type GetAuditLogQuery = { + organizationId: string +} + export type GetAuditLogResponse = { data: { id: string @@ -1967,6 +1929,28 @@ export type GetAuditLogResponse = { } } +/** `GET /api/v2/billing/status` */ +export type GetBillingStatusQuery = { + workspaceId?: string +} + +export type GetBillingStatusResponse = { + data: { + workspaceId: string | null + period: { + start: string + end: string + } + plan: string + status: 'active' | 'limit_exceeded' | 'billing_blocked' + credits: { + used: number + limit: number + remaining: number + } + } +} + /** `GET /api/v2/credentials/[id]` */ export type GetCredentialParams = { id: string @@ -2027,28 +2011,6 @@ export type GetCustomToolResponse = { } } -/** `GET /api/v2/logs/executions/[executionId]` */ -export type GetExecutionParams = { - executionId: string -} - -export type GetExecutionResponse = { - data: { - executionId: string - workflowId: string | null - workflowState: unknown - executionMetadata: { - trigger: string - startedAt: string - endedAt: string | null - totalDurationMs: number | null - cost: { - total: number - } | null - } - } -} - /** `GET /api/v2/files/[fileId]/metadata` */ export type GetFileParams = { fileId: string @@ -2170,9 +2132,9 @@ export type GetKnowledgeDocumentResponse = { } } -/** `GET /api/v2/logs/[id]` */ +/** `GET /api/v2/logs/[executionId]` */ export type GetLogParams = { - id: string + executionId: string } type GetLogResponseRef0 = { @@ -2184,6 +2146,9 @@ type GetLogResponseRef0 = { startTime?: string endTime?: string status?: string + errorHandled?: boolean + errorType?: string + errorMessage?: string blockId?: string input?: unknown output?: unknown @@ -2194,6 +2159,12 @@ type GetLogResponseRef0 = { input?: number output?: number } + cost?: { + total?: number + input?: number + output?: number + toolCost?: number + } relativeStartMs?: number toolCalls?: Array<{ id?: string @@ -2210,9 +2181,10 @@ type GetLogResponseRef0 = { export type GetLogResponse = { data: { - id: string - workflowId: string | null executionId: string + workflowId: string | null + deploymentVersionId: string | null + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' level: string trigger: string startedAt: string @@ -2230,8 +2202,9 @@ export type GetLogResponse = { updatedAt: string | null deleted: boolean } - executionData: unknown + workflowState: unknown traceSpans: Array + finalOutput: unknown | null cost: { total: number } | null @@ -2451,68 +2424,6 @@ export type GetTableViewQuery = { workspaceId: string } -type GetTableViewResponseRef0 = - | { - all: Array< - | GetTableViewResponseRef0 - | { - field: string - op: - | 'eq' - | 'ne' - | 'gt' - | 'gte' - | 'lt' - | 'lte' - | 'in' - | 'nin' - | 'contains' - | 'ncontains' - | 'startsWith' - | 'endsWith' - | 'like' - | 'ilike' - | 'nlike' - | 'nilike' - | 'isEmpty' - | 'isNotEmpty' - | 'isNull' - | 'isNotNull' - value?: unknown - } - > - } - | { - any: Array< - | GetTableViewResponseRef0 - | { - field: string - op: - | 'eq' - | 'ne' - | 'gt' - | 'gte' - | 'lt' - | 'lte' - | 'in' - | 'nin' - | 'contains' - | 'ncontains' - | 'startsWith' - | 'endsWith' - | 'like' - | 'ilike' - | 'nlike' - | 'nilike' - | 'isEmpty' - | 'isNotEmpty' - | 'isNull' - | 'isNotNull' - value?: unknown - } - > - } - export type GetTableViewResponse = { data: { view: { @@ -2524,7 +2435,7 @@ export type GetTableViewResponse = { columnOrder?: Array pinnedColumns?: Array hiddenColumns?: Array - filter?: GetTableViewResponseRef0 | null + filter?: unknown | null sort?: Array<{ field: string direction: 'asc' | 'desc' @@ -2538,24 +2449,6 @@ export type GetTableViewResponse = { } } -/** `GET /api/v2/billing/usage` */ -export type GetUsageSummaryQuery = { - workspaceId?: string -} - -export type GetUsageSummaryResponse = { - data: { - period: { - start: string - end: string - } - totalCredits: number - bySourceCredits: Record - limitCredits: number - plan: string - } -} - /** `GET /api/v2/workflows/[id]` */ export type GetWorkflowParams = { id: string @@ -2604,6 +2497,7 @@ export type GetWorkflowExecutionResponse = { endedAt: string | null durationMs: number | null paused: { + contextId: string pausedAt: string resumeAt: string | null pauseKind: 'time' | 'human' | null @@ -2687,6 +2581,7 @@ export type ListAuditLogsQuery = { includeDeparted?: 'true' | 'false' limit?: number cursor?: string + organizationId: string } export type ListAuditLogsResponse = { @@ -2707,6 +2602,51 @@ export type ListAuditLogsResponse = { nextCursor: string | null } +/** `GET /api/v2/billing/logs` */ +export type ListBillingLogsQuery = { + source?: + | 'workflow' + | 'wand' + | 'sim-chat' + | 'mcp_copilot' + | 'mothership_block' + | 'knowledge-base' + | 'voice-input' + | 'enrichment' + | 'voice-output' + workspaceId?: string + period?: '1d' | '7d' | '30d' | 'all' | 'custom' + startDate?: string + endDate?: string + limit?: number + cursor?: string +} + +export type ListBillingLogsResponse = { + data: Array<{ + id: string + createdAt: string + source: + | 'workflow' + | 'wand' + | 'sim-chat' + | 'mcp_copilot' + | 'mothership_block' + | 'knowledge-base' + | 'voice-input' + | 'enrichment' + | 'voice-output' + workspaceId: string | null + workflow: { + id: string + name: string | null + } | null + executionId: string | null + creditCost: number + }> + nextCursor: string | null +} + /** `GET /api/v2/credentials` */ export type ListCredentialsQuery = { workspaceId: string @@ -2940,6 +2880,9 @@ type ListLogsResponseRef0 = { startTime?: string endTime?: string status?: string + errorHandled?: boolean + errorType?: string + errorMessage?: string blockId?: string input?: unknown output?: unknown @@ -2950,6 +2893,12 @@ type ListLogsResponseRef0 = { input?: number output?: number } + cost?: { + total?: number + input?: number + output?: number + toolCost?: number + } relativeStartMs?: number toolCalls?: Array<{ id?: string @@ -2966,10 +2915,10 @@ type ListLogsResponseRef0 = { export type ListLogsResponse = { data: Array<{ - id: string - workflowId: string | null executionId: string + workflowId: string | null deploymentVersionId: string | null + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' level: string trigger: string startedAt: string @@ -3149,68 +3098,6 @@ export type ListTableViewsQuery = { workspaceId: string } -type ListTableViewsResponseRef0 = - | { - all: Array< - | ListTableViewsResponseRef0 - | { - field: string - op: - | 'eq' - | 'ne' - | 'gt' - | 'gte' - | 'lt' - | 'lte' - | 'in' - | 'nin' - | 'contains' - | 'ncontains' - | 'startsWith' - | 'endsWith' - | 'like' - | 'ilike' - | 'nlike' - | 'nilike' - | 'isEmpty' - | 'isNotEmpty' - | 'isNull' - | 'isNotNull' - value?: unknown - } - > - } - | { - any: Array< - | ListTableViewsResponseRef0 - | { - field: string - op: - | 'eq' - | 'ne' - | 'gt' - | 'gte' - | 'lt' - | 'lte' - | 'in' - | 'nin' - | 'contains' - | 'ncontains' - | 'startsWith' - | 'endsWith' - | 'like' - | 'ilike' - | 'nlike' - | 'nilike' - | 'isEmpty' - | 'isNotEmpty' - | 'isNull' - | 'isNotNull' - value?: unknown - } - > - } - export type ListTableViewsResponse = { data: Array<{ id: string @@ -3221,7 +3108,7 @@ export type ListTableViewsResponse = { columnOrder?: Array pinnedColumns?: Array hiddenColumns?: Array - filter?: ListTableViewsResponseRef0 | null + filter?: unknown | null sort?: Array<{ field: string direction: 'asc' | 'desc' @@ -3235,42 +3122,33 @@ export type ListTableViewsResponse = { nextCursor: string | null } -/** `GET /api/v2/billing/usage/logs` */ -export type ListUsageLogsQuery = { - source?: - | 'workflow' - | 'wand' - | 'sim-chat' - | 'mcp_copilot' - | 'mothership_block' - | 'knowledge-base' - | 'voice-input' - | 'enrichment' - | 'voice-output' - workspaceId?: string - period?: '1d' | '7d' | '30d' | 'all' | 'custom' +/** `GET /api/v2/workflows/[id]/executions` */ +export type ListWorkflowExecutionsParams = { + id: string +} + +export type ListWorkflowExecutionsQuery = { + status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused' + trigger?: string startDate?: string endDate?: string limit?: number cursor?: string + order?: 'asc' | 'desc' } -export type ListUsageLogsResponse = { +export type ListWorkflowExecutionsResponse = { data: Array<{ - id: string - createdAt: string - source: - | 'workflow' - | 'wand' - | 'sim-chat' - | 'mcp_copilot' - | 'mothership_block' - | 'knowledge-base' - | 'voice-input' - | 'enrichment' - | 'voice-output' - workflowName: string | null - creditCost: number + executionId: string + workflowId: string + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused' + trigger: string + startedAt: string + endedAt: string | null + durationMs: number | null + cost: { + total: number + } | null }> nextCursor: string | null } @@ -3526,6 +3404,52 @@ export type RenameFileResponse = { } } +/** `POST /api/v2/workflows/[id]/executions/[executionId]/resume` */ +export type ResumeWorkflowParams = { + id: string + executionId: string +} + +export type ResumeWorkflowBody = { + contextId: string + input?: unknown +} + +export type ResumeWorkflowResponse = + | { + data: { + executionId: string + workflowId: string + status: 'completed' | 'failed' | 'paused' | 'cancelled' + output: unknown + error: { + message: string + code: + | 'TIMEOUT' + | 'CANCELLED' + | 'USAGE_LIMIT_EXCEEDED' + | 'INVALID_INPUT' + | 'BLOCK_EXECUTION_FAILED' + | 'CHILD_WORKFLOW_FAILED' + | 'OUTPUT_TOO_LARGE' + | 'EXECUTION_FAILED' + blockId?: string + blockName?: string + blockType?: string + } | null + startedAt?: string + endedAt?: string + durationMs?: number + } + } + | { + data: { + executionId: string + statusUrl: string + queuePosition?: number + } + } + /** `POST /api/v2/workflows/[id]/rollback` */ export type RollbackWorkflowParams = { id: string @@ -3911,6 +3835,25 @@ export type UpdateRowsByFilterBody = { filter: unknown data: unknown limit?: number + __privateSecretProvenance?: { + version: 1 + complete: boolean + selections: Array<{ + key: string + provenance: { + version: 1 + complete: boolean + entries: Array<{ + encryptedValue: string + name?: string + }> + scope?: { + userId: string + workspaceId?: string + } + } + }> + } } export type UpdateRowsByFilterResponse = { @@ -4052,6 +3995,25 @@ export type UpdateTableRowParams = { export type UpdateTableRowBody = { workspaceId: string data: unknown + __privateSecretProvenance?: { + version: 1 + complete: boolean + selections: Array<{ + key: string + provenance: { + version: 1 + complete: boolean + entries: Array<{ + encryptedValue: string + name?: string + }> + scope?: { + userId: string + workspaceId?: string + } + } + }> + } } export type UpdateTableRowResponse = { @@ -4099,68 +4061,6 @@ export type UpdateTableViewBody = { isDefault?: boolean } -type UpdateTableViewResponseRef0 = - | { - all: Array< - | UpdateTableViewResponseRef0 - | { - field: string - op: - | 'eq' - | 'ne' - | 'gt' - | 'gte' - | 'lt' - | 'lte' - | 'in' - | 'nin' - | 'contains' - | 'ncontains' - | 'startsWith' - | 'endsWith' - | 'like' - | 'ilike' - | 'nlike' - | 'nilike' - | 'isEmpty' - | 'isNotEmpty' - | 'isNull' - | 'isNotNull' - value?: unknown - } - > - } - | { - any: Array< - | UpdateTableViewResponseRef0 - | { - field: string - op: - | 'eq' - | 'ne' - | 'gt' - | 'gte' - | 'lt' - | 'lte' - | 'in' - | 'nin' - | 'contains' - | 'ncontains' - | 'startsWith' - | 'endsWith' - | 'like' - | 'ilike' - | 'nlike' - | 'nilike' - | 'isEmpty' - | 'isNotEmpty' - | 'isNull' - | 'isNotNull' - value?: unknown - } - > - } - export type UpdateTableViewResponse = { data: { view: { @@ -4172,7 +4072,7 @@ export type UpdateTableViewResponse = { columnOrder?: Array pinnedColumns?: Array hiddenColumns?: Array - filter?: UpdateTableViewResponseRef0 | null + filter?: unknown | null sort?: Array<{ field: string direction: 'asc' | 'desc' @@ -4358,6 +4258,25 @@ export type UpsertTableRowBody = { workspaceId: string data: unknown conflictTarget?: string + __privateSecretProvenance?: { + version: 1 + complete: boolean + selections: Array<{ + key: string + provenance: { + version: 1 + complete: boolean + entries: Array<{ + encryptedValue: string + name?: string + }> + scope?: { + userId: string + workspaceId?: string + } + } + }> + } } export type UpsertTableRowResponse = { @@ -5025,6 +4944,7 @@ export const V2_OPERATIONS = { body: { input: { kind: 'object' }, async: { kind: 'boolean', default: false }, + executionTimeoutSeconds: { kind: 'integer' }, stream: { kind: 'boolean', default: false }, selectedOutputs: { kind: 'array' }, includeThinking: { kind: 'boolean', default: false }, @@ -5059,6 +4979,19 @@ export const V2_OPERATIONS = { pathParams: ['id'] as const, responseMode: 'json', summary: 'Get Audit Log', + query: { + organizationId: { kind: 'string', required: true }, + }, + }, + getBillingStatus: { + method: 'GET', + path: '/api/v2/billing/status', + pathParams: [] as const, + responseMode: 'json', + summary: 'Get Billing Status', + query: { + workspaceId: { kind: 'string' }, + }, }, getCredential: { method: 'GET', @@ -5080,13 +5013,6 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, - getExecution: { - method: 'GET', - path: '/api/v2/logs/executions/[executionId]', - pathParams: ['executionId'] as const, - responseMode: 'json', - summary: 'Get Execution', - }, getFile: { method: 'GET', path: '/api/v2/files/[fileId]/metadata', @@ -5129,8 +5055,8 @@ export const V2_OPERATIONS = { }, getLog: { method: 'GET', - path: '/api/v2/logs/[id]', - pathParams: ['id'] as const, + path: '/api/v2/logs/[executionId]', + pathParams: ['executionId'] as const, responseMode: 'json', summary: 'Get Log', }, @@ -5204,16 +5130,6 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, - getUsageSummary: { - method: 'GET', - path: '/api/v2/billing/usage', - pathParams: [] as const, - responseMode: 'json', - summary: 'Get Usage Summary', - query: { - workspaceId: { kind: 'string' }, - }, - }, getWorkflow: { method: 'GET', path: '/api/v2/workflows/[id]', @@ -5270,6 +5186,40 @@ export const V2_OPERATIONS = { includeDeparted: { kind: 'enum', values: ['true', 'false'] as const }, limit: { kind: 'number', default: 50 }, cursor: { kind: 'string' }, + organizationId: { kind: 'string', required: true }, + }, + }, + listBillingLogs: { + method: 'GET', + path: '/api/v2/billing/logs', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Billing Logs', + query: { + source: { + kind: 'enum', + values: [ + 'workflow', + 'wand', + 'sim-chat', + 'mcp_copilot', + 'mothership_block', + 'knowledge-base', + 'voice-input', + 'enrichment', + 'voice-output', + ] as const, + }, + workspaceId: { kind: 'string' }, + period: { + kind: 'enum', + values: ['1d', '7d', '30d', 'all', 'custom'] as const, + default: '30d', + }, + startDate: { kind: 'string' }, + endDate: { kind: 'string' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, }, }, listCredentials: { @@ -5539,37 +5489,23 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, - listUsageLogs: { + listWorkflowExecutions: { method: 'GET', - path: '/api/v2/billing/usage/logs', - pathParams: [] as const, + path: '/api/v2/workflows/[id]/executions', + pathParams: ['id'] as const, responseMode: 'json', - summary: 'List Usage Logs', + summary: 'List workflow executions', query: { - source: { + status: { kind: 'enum', - values: [ - 'workflow', - 'wand', - 'sim-chat', - 'mcp_copilot', - 'mothership_block', - 'knowledge-base', - 'voice-input', - 'enrichment', - 'voice-output', - ] as const, - }, - workspaceId: { kind: 'string' }, - period: { - kind: 'enum', - values: ['1d', '7d', '30d', 'all', 'custom'] as const, - default: '30d', + values: ['pending', 'running', 'completed', 'failed', 'cancelled', 'paused'] as const, }, + trigger: { kind: 'string' }, startDate: { kind: 'string' }, endDate: { kind: 'string' }, limit: { kind: 'integer', default: 50 }, cursor: { kind: 'string' }, + order: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, }, }, listWorkflowFolders: { @@ -5717,6 +5653,17 @@ export const V2_OPERATIONS = { name: { kind: 'string', required: true }, }, }, + resumeWorkflow: { + method: 'POST', + path: '/api/v2/workflows/[id]/executions/[executionId]/resume', + pathParams: ['id', 'executionId'] as const, + responseMode: 'json', + summary: 'Resume a workflow execution', + body: { + contextId: { kind: 'string', required: true }, + input: { kind: 'unknown' }, + }, + }, rollbackWorkflow: { method: 'POST', path: '/api/v2/workflows/[id]/rollback', @@ -5874,6 +5821,7 @@ export const V2_OPERATIONS = { filter: { kind: 'unknown', required: true }, data: { kind: 'unknown', required: true }, limit: { kind: 'integer' }, + __privateSecretProvenance: { kind: 'object' }, }, }, updateSkill: { @@ -5923,6 +5871,7 @@ export const V2_OPERATIONS = { body: { workspaceId: { kind: 'string', required: true }, data: { kind: 'unknown', required: true }, + __privateSecretProvenance: { kind: 'object' }, }, }, updateTableView: { @@ -6006,6 +5955,7 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, data: { kind: 'unknown', required: true }, conflictTarget: { kind: 'string' }, + __privateSecretProvenance: { kind: 'object' }, }, }, } as const diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index ebbc07f5d1e..8102090d8f5 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -131,7 +131,11 @@ describe('generated operation table', () => { 'rollbackWorkflow', 'listLogs', 'getLog', - 'getExecution', + 'getBillingStatus', + 'listBillingLogs', + 'listWorkflowExecutions', + 'getWorkflowExecution', + 'resumeWorkflow', 'listFiles', 'deleteFile', 'listKnowledgeBases', diff --git a/packages/sim-cli/src/output/trace.ts b/packages/sim-cli/src/output/trace.ts new file mode 100644 index 00000000000..3661d758780 --- /dev/null +++ b/packages/sim-cli/src/output/trace.ts @@ -0,0 +1,115 @@ +import chalk from 'chalk' +import type { OutputFormat } from '../config/index.js' +import { duration, sanitize } from './render.js' + +type TraceSpan = Record + +function traceSpan(value: unknown): TraceSpan { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Trace contains a malformed span') + } + return value as TraceSpan +} + +function requiredText(span: TraceSpan, field: string): string { + const value = span[field] + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Trace span is missing ${field}`) + } + return sanitize(value).replace(/\s+/g, ' ').trim() +} + +function optionalText(span: TraceSpan, field: string): string | undefined { + const value = span[field] + if (value === undefined) return undefined + if (typeof value !== 'string') throw new Error(`Trace span ${field} must be a string`) + return sanitize(value).replace(/\s+/g, ' ').trim() +} + +function optionalNumber(span: TraceSpan, field: string): number | undefined { + const value = span[field] + if (value === undefined) return undefined + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`Trace span ${field} must be a finite number`) + } + return value +} + +function costTotal(span: TraceSpan): number | undefined { + const value = span.cost + if (value === undefined) return undefined + const cost = traceSpan(value) + return optionalNumber(cost, 'total') +} + +function appendValue(lines: string[], indent: string, label: string, value: unknown): void { + if (value === undefined) return + const encoded = JSON.stringify(value, null, 2) + if (encoded === undefined) throw new Error(`Trace span ${label} cannot be rendered`) + const valueLines = sanitize(encoded).split('\n') + if (valueLines.length === 1) { + lines.push(`${indent}${label}: ${valueLines[0]}`) + return + } + lines.push(`${indent}${label}:`) + lines.push(...valueLines.map((line) => `${indent} ${line}`)) +} + +function renderSpan(value: unknown, depth: number): string[] { + const span = traceSpan(value) + const indent = ' '.repeat(depth) + const detailIndent = `${indent} ` + const name = requiredText(span, 'name') + const type = requiredText(span, 'type') + const status = optionalText(span, 'status') + const elapsed = optionalNumber(span, 'durationMs') ?? optionalNumber(span, 'duration') + const totalCost = costTotal(span) + const summary = [ + `${indent}- ${name}`, + `[${type}]`, + status, + elapsed === undefined ? undefined : duration(Math.round(elapsed)), + totalCost === undefined ? undefined : `$${totalCost.toFixed(4)}`, + ] + .filter((part): part is string => Boolean(part)) + .join(' ') + const lines = [summary, `${detailIndent}id: ${requiredText(span, 'id')}`] + const blockId = optionalText(span, 'blockId') + if (blockId) lines.push(`${detailIndent}block: ${blockId}`) + const startTime = optionalText(span, 'startTime') + const endTime = optionalText(span, 'endTime') + if (startTime || endTime) { + lines.push(`${detailIndent}time: ${startTime ?? '—'} → ${endTime ?? '—'}`) + } + const relativeStartMs = optionalNumber(span, 'relativeStartMs') + if (relativeStartMs !== undefined) { + lines.push(`${detailIndent}relative start: ${duration(Math.round(relativeStartMs))}`) + } + const errorType = optionalText(span, 'errorType') + const errorMessage = optionalText(span, 'errorMessage') + if (errorType || errorMessage) { + lines.push(`${detailIndent}error: ${[errorType, errorMessage].filter(Boolean).join(': ')}`) + } + appendValue(lines, detailIndent, 'tokens', span.tokens) + appendValue(lines, detailIndent, 'input', span.input) + appendValue(lines, detailIndent, 'output', span.output) + appendValue(lines, detailIndent, 'tool calls', span.toolCalls) + + if (span.children !== undefined) { + if (!Array.isArray(span.children)) throw new Error('Trace span children must be an array') + for (const child of span.children) lines.push(...renderSpan(child, depth + 1)) + } + return lines +} + +/** Prints the complete recursive execution trace for an explicitly expanded log. */ +export function printTraceSpans(format: OutputFormat, traceSpans: unknown[]): void { + if (format === 'json' || format === 'yaml') return + console.log('') + console.log(format === 'table' ? chalk.dim('trace:') : 'trace:') + if (traceSpans.length === 0) { + console.log(chalk.dim(' No trace spans.')) + return + } + console.log(traceSpans.flatMap((span) => renderSpan(span, 0)).join('\n')) +} diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 51d74c7f5e0..aaeb8436d5c 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -13,20 +13,32 @@ import { buildGeneratedCommands } from './build.js' * catch that class of bug. */ -const { mockRequest, output } = vi.hoisted(() => ({ +const { mockRequest, output, profileState } = vi.hoisted(() => ({ mockRequest: vi.fn(), output: { format: 'json' }, + profileState: { workspaceId: 'ws_local' as string | null }, })) vi.mock('../context.js', () => ({ clientFrom: () => ({ - client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, - profile: { workspaceId: 'ws_local', output: output.format, name: 'default', apiKey: 'k' }, + client: { + request: mockRequest, + requireWorkspace: () => { + if (!profileState.workspaceId) throw new Error('workspace required') + return profileState.workspaceId + }, + }, + profile: { + workspaceId: profileState.workspaceId, + output: output.format, + name: 'default', + apiKey: 'k', + }, }), })) function program(): Command { - const root = new Command('sim').exitOverride() + const root = new Command('sim').exitOverride().option('--workspace ') for (const group of buildGeneratedCommands()) root.addCommand(group) // Recursively, not just on the root: a parse error raised by a leaf (an // unknown option, an excess argument) exits the process otherwise, which a @@ -60,6 +72,7 @@ async function run(argv: string[], response: unknown = { data: [], nextCursor: n describe('commands parsed through commander', () => { beforeEach(() => { vi.restoreAllMocks() + profileState.workspaceId = 'ws_local' }) it('carries a multi-word flag all the way to the request', async () => { @@ -99,6 +112,27 @@ describe('commands parsed through commander', () => { expect(commandAt('tables', 'rows').description()).toBe('Manage table rows') }) + it('shows the command syntax when a required positional argument is missing', async () => { + const root = program() + const skills = root.commands.find((command) => command.name() === 'skills') + const update = skills?.commands.find((command) => command.name() === 'update') + if (!update) throw new Error('Missing command skills update') + + let errorOutput = '' + update.configureOutput({ + writeErr: (message) => { + errorOutput += message + }, + }) + + await expect(root.parseAsync(['node', 'sim', 'skills', 'update'])).rejects.toMatchObject({ + code: 'commander.missingArgument', + }) + expect(errorOutput).toContain("error: missing required argument 'id'") + expect(errorOutput).toContain('Example: sim skills update ') + expect(errorOutput).not.toContain('--id') + }) + it('dispatches generated commands through their singular resource alias', async () => { const [tablePath] = await run(['table', 'list']) expect(tablePath).toBe('/api/v2/tables') @@ -159,9 +193,12 @@ describe('commands parsed through commander', () => { expect(mockRequest).not.toHaveBeenCalled() }) - it('uses billing as the usage summary and keeps detailed events under logs', async () => { - expect(commandAt('billing').commands.map((command) => command.name())).toContain('logs') - expect(commandAt('billing').commands.map((command) => command.name())).not.toContain('usage') + it('keeps billing status and logs as explicit subcommands', async () => { + expect( + commandAt('billing') + .commands.map((command) => command.name()) + .sort() + ).toEqual(['logs', 'status']) const help = commandAt('billing', 'logs').helpInformation() expect(help).toContain('--source ') @@ -171,12 +208,28 @@ describe('commands parsed through commander', () => { expect(help).not.toContain('"copilot"') expect(help).not.toContain('One of: workflow') - const [summaryPath, summaryOptions] = await run(['billing'], { - data: { plan: 'pro', totalCredits: 10 }, + const [summaryPath, summaryOptions] = await run(['billing', 'status'], { + data: { + plan: 'pro', + status: 'active', + credits: { used: 10, limit: 100, remaining: 90 }, + }, }) - expect(summaryPath).toBe('/api/v2/billing/usage') + expect(summaryPath).toBe('/api/v2/billing/status') expect(summaryOptions.query).toEqual({ workspaceId: 'ws_local' }) + const [, accountOptions] = await run(['billing', 'status', '--all-workspaces']) + expect(accountOptions.query).toEqual({}) + + profileState.workspaceId = null + const [, unconfiguredAccountOptions] = await run(['billing', 'status', '--all-workspaces']) + expect(unconfiguredAccountOptions.query).toEqual({}) + await expect(run(['billing', 'status'])).rejects.toThrow('workspace required') + profileState.workspaceId = 'ws_local' + await expect( + run(['--workspace', 'ws_other', 'billing', 'status', '--all-workspaces']) + ).rejects.toThrow('--all-workspaces cannot be combined with --workspace') + const [logsPath, logsOptions] = await run([ 'billing', 'logs', @@ -185,13 +238,16 @@ describe('commands parsed through commander', () => { '--source', 'sim-chat', ]) - expect(logsPath).toBe('/api/v2/billing/usage/logs') + expect(logsPath).toBe('/api/v2/billing/logs') expect(logsOptions.query).toMatchObject({ workspaceId: 'ws_local', period: '7d', source: 'sim-chat', }) + const [, accountLogsOptions] = await run(['billing', 'logs', '--all-workspaces']) + expect(accountLogsOptions.query).not.toHaveProperty('workspaceId') + for (const deprecated of ['copilot', 'workspace-chat']) { await expect(run(['billing', 'logs', '--source', deprecated])).rejects.toThrow( /allowed choices.*sim-chat/i @@ -459,8 +515,126 @@ describe('commands parsed through commander', () => { ) }) - it('points log detail users to complete trace output', () => { - expect(commandAt('logs', 'get').description()).toMatch(/traceSpans.*JSON or YAML/) + it('offers expanded trace output without changing the default summary', () => { + expect(commandAt('logs', 'get').description()).toBe('Show execution diagnostics') + expect(commandAt('logs', 'get').helpInformation()).toMatch( + /--trace.*inputs, outputs, errors, timing,\s+and cost/s + ) + const listHelp = commandAt('logs', 'list').helpInformation() + expect(listHelp).toMatch(/--include-trace-spans.*implies full detail/s) + expect(listHelp).toMatch(/--include-final-output.*implies full detail/s) + }) + + it('uses a named workflow scope for execution subresources', async () => { + const executions = commandAt('workflows', 'executions') + expect(executions.commands.map((command) => command.name()).sort()).toEqual([ + 'cancel', + 'get', + 'list', + 'resume', + ]) + + const help = commandAt('workflows', 'executions', 'get').helpInformation() + expect(help).toContain('') + expect(help).toMatch(/--workflow .*required/s) + expect(help).toContain('--include-output') + expect(help).toContain('--select-output ') + + const [path, options] = await run([ + 'workflows', + 'executions', + 'get', + 'exec_1', + '--workflow', + 'wf_1', + '--include-output', + '--select-output', + 'agent.content', + 'writer.text', + ]) + expect(path).toBe('/api/v2/workflows/wf_1/executions/exec_1') + expect(options.query).toEqual({ + includeOutput: true, + selectedOutputs: 'agent.content,writer.text', + }) + + const [listPath] = await run(['workflows', 'executions', 'list', '--workflow', 'wf_1']) + expect(listPath).toBe('/api/v2/workflows/wf_1/executions') + + const [cancelPath] = await run([ + 'workflows', + 'executions', + 'cancel', + 'exec_1', + '--workflow', + 'wf_1', + ]) + expect(cancelPath).toBe('/api/v2/workflows/wf_1/executions/exec_1/cancel') + + const resumeHelp = commandAt('workflows', 'executions', 'resume').helpInformation() + expect(resumeHelp).toContain('') + expect(resumeHelp).toMatch(/--workflow .*required/s) + expect(resumeHelp).toMatch(/--context .*required/s) + + const [resumePath, resumeOptions] = await run([ + 'workflows', + 'executions', + 'resume', + 'exec_1', + '--workflow', + 'wf_1', + '--context', + 'ctx_1', + '--input', + '{"approved":true}', + ]) + expect(resumePath).toBe('/api/v2/workflows/wf_1/executions/exec_1/resume') + expect(resumeOptions.body).toEqual({ + contextId: 'ctx_1', + input: { approved: true }, + }) + }) + + it('supports organization-wide audit listing explicitly', async () => { + const help = commandAt('audit-logs', 'list').helpInformation() + expect(help).toMatch(/--organization .*personal API key required.*required/s) + expect(help).toContain('--all-workspaces') + + const [, scopedOptions] = await run(['audit-logs', 'list', '--organization', 'org_1']) + expect(scopedOptions.query).toMatchObject({ + organizationId: 'org_1', + workspaceId: 'ws_local', + }) + + const [, organizationOptions] = await run([ + 'audit-logs', + 'list', + '--organization', + 'org_1', + '--all-workspaces', + ]) + expect(organizationOptions.query).toMatchObject({ + organizationId: 'org_1', + limit: 100, + }) + expect(organizationOptions.query).not.toHaveProperty('workspaceId') + + const [detailPath, detailOptions] = await run([ + 'audit-logs', + 'get', + 'audit_1', + '--organization', + 'org_1', + ]) + expect(detailPath).toBe('/api/v2/audit-logs/audit_1') + expect(detailOptions.query).toEqual({ organizationId: 'org_1' }) + }) + + it('describes asynchronous workflow runs without a contradictory negative flag', () => { + const help = commandAt('workflows', 'run').helpInformation() + expect(commandAt('workflows', 'run').description()).toBe('Run a deployed workflow') + expect(help).toContain('--async') + expect(help).not.toContain('--no-async') }) }) @@ -559,10 +733,10 @@ describe('single-resource rendering', () => { expect(JSON.parse(printed[0])).toEqual({ row: { id: 'r1' }, operation: 'inserted' }) }) - it('keeps sensitive execution detail out of human log output', async () => { + it('keeps sensitive execution detail opt-in for human log output', async () => { const log = { - id: 'log_1', executionId: 'exec_1', + status: 'completed', workflow: { name: 'Billing' }, level: 'info', trigger: 'api', @@ -571,7 +745,8 @@ describe('single-resource rendering', () => { totalDurationMs: 50, cost: { total: 0.001 }, files: [], - executionData: { env: { SECRET_TOKEN: 'encrypted-value' } }, + workflowState: { env: { SECRET_TOKEN: 'encrypted-value' } }, + finalOutput: { recipient: 'private@example.com' }, traceSpans: [ { id: 'span_1', @@ -582,26 +757,41 @@ describe('single-resource rendering', () => { id: 'span_2', name: 'Send email', type: 'block', - input: { recipient: 'private@example.com' }, + status: 'completed', + durationMs: 25, + cost: { total: 0.0005 }, + input: { recipient: 'trace-secret@example.com' }, + output: { delivered: true }, }, ], }, ], } - const human = await lines(['logs', 'get', 'log_1'], log, 'text') - expect(human.join('\n')).not.toContain('executionData') + const human = await lines(['logs', 'get', 'exec_1'], log, 'text') + expect(human.join('\n')).not.toContain('workflowState') expect(human.join('\n')).not.toContain('SECRET_TOKEN') expect(human.join('\n')).not.toContain('traceSpans') expect(human.join('\n')).not.toContain('private@example.com') - - const machine = await lines(['logs', 'get', 'log_1'], log, 'json') + expect(human.join('\n')).not.toContain('trace-secret@example.com') + expect(human.join('\n')).toContain('trace\t2 spans (use --trace)') + + const expanded = await lines(['logs', 'get', 'exec_1', '--trace'], log, 'text') + expect(expanded.join('\n')).toContain('trace\t2 spans') + expect(expanded.join('\n')).not.toContain('(use --trace)') + expect(expanded.join('\n')).toContain('Workflow Execution [workflow]') + expect(expanded.join('\n')).toContain('Send email [block] completed 25ms $0.0005') + expect(expanded.join('\n')).toContain('trace-secret@example.com') + expect(expanded.join('\n')).toContain('"delivered": true') + + const machine = await lines(['logs', 'get', 'exec_1'], log, 'json') expect(JSON.parse(machine[0])).toMatchObject({ - executionData: log.executionData, + workflowState: log.workflowState, traceSpans: log.traceSpans, + finalOutput: log.finalOutput, }) - const yaml = await lines(['logs', 'get', 'log_1'], log, 'yaml') + const yaml = await lines(['logs', 'get', 'exec_1'], log, 'yaml') expect(yaml.join('\n')).toContain('traceSpans:') expect(yaml.join('\n')).toContain('span_2') }) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 1b68242651b..3028419957a 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -22,6 +22,42 @@ const GROUP_ALIASES: Readonly> = { workflows: 'workflow', } +function argumentSyntax(command: Command): string { + return command.registeredArguments + .map((argument) => { + const name = `${argument.name()}${argument.variadic ? '...' : ''}` + return argument.required ? `<${name}>` : `[${name}]` + }) + .join(' ') +} + +function commandPath(command: Command): string { + const names: string[] = [] + let current: Command | null = command + while (current) { + names.unshift(current.name()) + current = current.parent + } + return names.join(' ') +} + +function addMissingArgumentExample(command: Command): Command { + const outputError = command.configureOutput().outputError + if (!outputError) throw new Error('Commander output formatter is not configured') + + command.configureOutput({ + outputError: (message, write) => { + outputError(message, write) + if (!message.startsWith('error: missing required argument ')) return + + const syntax = argumentSyntax(command) + const example = syntax ? `${commandPath(command)} ${syntax}` : commandPath(command) + write(`Example: ${example}\n`) + }, + }) + return command +} + function configureOperation( command: Command, operation: V2OperationName, @@ -43,6 +79,13 @@ function configureOperation( command.argument(`<${param}>`) } + if (spec.allWorkspaces) { + const workspace = operationSpec.query?.workspaceId ?? operationSpec.body?.workspaceId + if (!workspace || workspace.required) { + throw new Error(`${operation}.allWorkspaces requires an optional workspaceId field`) + } + } + for (const field of spec.positionals ?? []) { const descriptor = operationSpec.query?.[field] ?? operationSpec.body?.[field] if (!descriptor) throw new Error(`${operation}.${field} is not a request field`) @@ -82,7 +125,7 @@ function configureOperation( } function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: string): Command { - return configureOperation(new Command(leafName), operation, spec) + return addMissingArgumentExample(configureOperation(new Command(leafName), operation, spec)) } function groupFor(groups: Map, name: string): Command { diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 29fd9e7abba..71acec7bc05 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -23,12 +23,19 @@ export async function executeOperation( invocation: unknown[] ): Promise { const host = invocation[invocation.length - 1] as Command - const flags = invocation[invocation.length - 2] as Record + const inheritedFlags = host.optsWithGlobals() as Record + const flags: Record = { + ...(inheritedFlags.workspace === undefined ? {} : { workspace: inheritedFlags.workspace }), + ...(inheritedFlags.allWorkspaces === undefined + ? {} + : { allWorkspaces: inheritedFlags.allWorkspaces }), + ...(invocation[invocation.length - 2] as Record), + } const pathPositionalCount = operationSpec.pathParams.filter( (param) => !commandSpec.pathFlags?.[param] ).length const positional = invocation.slice(0, pathPositionalCount) as string[] - const requestFlags = { ...flags } + const requestFlags: Record = { ...flags } for (const [index, field] of (commandSpec.positionals ?? []).entries()) { requestFlags[camel(flagNameFor(operation, field))] = invocation[pathPositionalCount + index] } @@ -37,16 +44,21 @@ export async function executeOperation( throw new SimApiError(`${commandSpec.confirm} Re-run with --yes to confirm.`, 0) } + if (commandSpec.allWorkspaces && requestFlags.allWorkspaces && requestFlags.workspace) { + throw new SimApiError('--all-workspaces cannot be combined with --workspace', 0) + } + const { client, profile } = clientFrom(host) - const needsWorkspace = Boolean( + const hasWorkspaceField = Boolean( (operationSpec.query && PROFILE_INJECTED_FIELD in operationSpec.query) || (operationSpec.body && PROFILE_INJECTED_FIELD in operationSpec.body) ) + const omitsWorkspace = commandSpec.allWorkspaces && requestFlags.allWorkspaces === true const request = buildRequest( operation, positional, requestFlags, - needsWorkspace ? client.requireWorkspace() : profile.workspaceId + hasWorkspaceField && !omitsWorkspace ? client.requireWorkspace() : profile.workspaceId ) const paging = cursorSlot(operationSpec) @@ -84,5 +96,7 @@ export async function executeOperation( query: request.query, body: request.body, }) - renderResult(operation, profile.output, result?.data ?? result, commandSpec) + renderResult(operation, profile.output, result?.data ?? result, commandSpec, { + expandedTrace: requestFlags.trace === true, + }) } diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index 9b13d33292e..5ca5757e034 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -104,6 +104,20 @@ export function addOperationOptions( } } + if (commandSpec.allWorkspaces) { + command.option( + '--all-workspaces', + 'Do not filter to the configured workspace (personal API key required for account-wide access)' + ) + } + + if (commandSpec.expandedTrace) { + command.option( + '--trace', + 'Show expanded trace spans with inputs, outputs, errors, timing, and cost' + ) + } + if (operationSpec.opaqueBody) { if (commandSpec.bodyVariants) { for (const variant of commandSpec.bodyVariants) { diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 0190a74c289..329bdcb8404 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -24,6 +24,11 @@ describe('buildRequest', () => { expect(built.body).toBeUndefined() }) + it('omits an optional profile workspace when all workspaces are requested', () => { + const built = buildRequest('listBillingLogs', [], { allWorkspaces: true }, WORKSPACE) + expect(built.query).not.toHaveProperty('workspaceId') + }) + it('maps a contract flag alias back to its field name', () => { const built = buildRequest('upsertTableRow', ['t'], { data: '{}', on: 'email' }, WORKSPACE) expect(built.body).toMatchObject({ conflictTarget: 'email' }) diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index cf8d6e56e5d..398a1c11405 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -291,7 +291,13 @@ export function buildRequest( const flagName = flagNameFor(operation, field) // Commander stores `--min-duration-ms` as `minDurationMs`; reading by the // flag's own name silently finds nothing. - const raw = field === PROFILE_INJECTED_FIELD ? workspaceId : flags[camel(flagName)] + const omitProfileWorkspace = commandSpec.allWorkspaces && flags.allWorkspaces === true + const raw = + field === PROFILE_INJECTED_FIELD + ? omitProfileWorkspace + ? undefined + : workspaceId + : flags[camel(flagName)] const value = coerce(raw ?? undefined, descriptor, flag, flagName) if (value === undefined) { diff --git a/packages/sim-cli/src/runtime/result.ts b/packages/sim-cli/src/runtime/result.ts index e9b990e6f2d..fba5d5dc790 100644 --- a/packages/sim-cli/src/runtime/result.ts +++ b/packages/sim-cli/src/runtime/result.ts @@ -13,6 +13,21 @@ import { text, timestamp, } from '../output/render.js' +import { printTraceSpans } from '../output/trace.js' + +interface RenderResultOptions { + expandedTrace?: boolean +} + +function countTraceSpans(value: unknown): number { + if (!Array.isArray(value)) return 0 + return value.reduce((count, span) => { + if (!span || typeof span !== 'object' || Array.isArray(span)) { + throw new Error('Trace contains a malformed span') + } + return count + 1 + countTraceSpans((span as Record).children) + }, 0) +} function at(row: unknown, path: string): unknown { return path @@ -23,7 +38,11 @@ function at(row: unknown, path: string): unknown { ) } -function renderCell(value: unknown, format: ColumnSpec['format']): string { +function renderCell( + value: unknown, + format: ColumnSpec['format'], + options: RenderResultOptions = {} +): string { switch (format) { case 'timestamp': return timestamp(value as string | null) @@ -37,6 +56,12 @@ function renderCell(value: unknown, format: ColumnSpec['format']): string { return typeof value === 'number' ? `$${value.toFixed(4)}` : text(null) case 'count': return Array.isArray(value) ? String(value.length) : text(null) + case 'trace-count': { + const count = countTraceSpans(value) + return `${count} ${count === 1 ? 'span' : 'spans'}${ + options.expandedTrace ? '' : ' (use --trace)' + }` + } default: if (value === null || value === undefined || value === '') return text(null) return sanitize(typeof value === 'object' ? JSON.stringify(value) : String(value)) @@ -57,11 +82,15 @@ function columnsFrom(specs: ColumnSpec[]): Column[] { })) } -function fieldsFrom(data: unknown, specs: ColumnSpec[]): Array<[string, string]> { - return specs.map((spec) => [ - spec.header, - renderCell(at(data, spec.path ?? spec.header), spec.format), - ]) +function fieldsFrom( + data: unknown, + specs: ColumnSpec[], + options: RenderResultOptions = {} +): Array<[string, string]> { + return specs.flatMap((spec) => { + const value = at(data, spec.path ?? spec.header) + return value === undefined ? [] : [[spec.header, renderCell(value, spec.format, options)]] + }) } function inferColumns(rows: unknown[], expand?: string): Column[] { @@ -118,7 +147,8 @@ export function renderResult( operation: V2OperationName, format: OutputFormat, raw: unknown, - spec: CommandSpec + spec: CommandSpec, + options: RenderResultOptions = {} ): void { if (spec.document) { printDocument(format, raw) @@ -150,10 +180,17 @@ export function renderResult( } const fields = spec.fields - ? fieldsFrom(data, spec.fields) + ? fieldsFrom(data, spec.fields, options) : data && typeof data === 'object' ? Object.entries(data).map<[string, string]>(([key, value]) => [key, recordCell(value)]) : [] printRecord(format, fields, data) + if (spec.expandedTrace && options.expandedTrace) { + const traceSpans = at(data, 'traceSpans') + if (!Array.isArray(traceSpans)) { + throw new Error(`${operation} expected a traceSpans array`) + } + printTraceSpans(format, traceSpans) + } } From fd82f81ee29be651f5c206f6701178e551b20046 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 6 Aug 2026 17:04:30 -0700 Subject: [PATCH 087/159] feat(api): rename v2 executions to runs --- .../(generated)/workflows/meta.json | 4 +- .../docs/en/api-reference/getting-started.mdx | 16 +- .../content/docs/en/api-reference/python.mdx | 26 +-- .../docs/en/api-reference/typescript.mdx | 28 ++-- .../en/workflows/blocks/human-in-the-loop.mdx | 20 +-- .../docs/en/workflows/deployment/api.mdx | 12 +- apps/docs/openapi-core.json | 6 +- apps/docs/openapi-v2-logs.json | 34 ++-- apps/docs/openapi-v2-workflows.json | 156 +++++++++++------- .../[executionId]/[contextId]/route.test.ts | 4 +- apps/sim/app/api/resume/resume-handler.ts | 2 +- .../sim/app/api/v2/billing/logs/route.test.ts | 2 +- apps/sim/app/api/v2/billing/logs/route.ts | 2 +- .../{[executionId] => [runId]}/route.test.ts | 10 +- .../logs/{[executionId] => [runId]}/route.ts | 14 +- apps/sim/app/api/v2/logs/route.test.ts | 1 + apps/sim/app/api/v2/logs/route.ts | 4 +- .../v2/workflows/[id]/execute/route.test.ts | 31 ++-- .../api/v2/workflows/[id]/execute/route.ts | 46 +++--- .../[runId]}/cancel/route.ts | 29 ++-- .../[runId]}/resume/route.test.ts | 31 ++-- .../[runId]}/resume/route.ts | 35 ++-- .../[runId]}/route.test.ts | 18 +- .../[executionId] => runs/[runId]}/route.ts | 30 ++-- .../[id]/{executions => runs}/route.test.ts | 12 +- .../[id]/{executions => runs}/route.ts | 20 +-- .../deploy-modal/components/api/api.tsx | 4 +- apps/sim/lib/api/contracts/v2/billing.ts | 2 +- apps/sim/lib/api/contracts/v2/logs.ts | 11 +- apps/sim/lib/api/contracts/v2/workflows.ts | 104 +++++++----- .../tools/handlers/deployment/deploy.ts | 14 +- apps/sim/proxy.test.ts | 11 +- apps/sim/proxy.ts | 5 +- packages/python-sdk/README.md | 18 +- packages/python-sdk/simstudio/__init__.py | 26 +-- packages/python-sdk/tests/test_client.py | 34 ++-- packages/ts-sdk/README.md | 20 +-- packages/ts-sdk/src/index.test.ts | 42 ++--- packages/ts-sdk/src/index.ts | 37 ++--- 39 files changed, 499 insertions(+), 422 deletions(-) rename apps/sim/app/api/v2/logs/{[executionId] => [runId]}/route.test.ts (90%) rename apps/sim/app/api/v2/logs/{[executionId] => [runId]}/route.ts (90%) rename apps/sim/app/api/v2/workflows/[id]/{executions/[executionId] => runs/[runId]}/cancel/route.ts (64%) rename apps/sim/app/api/v2/workflows/[id]/{executions/[executionId] => runs/[runId]}/resume/route.test.ts (82%) rename apps/sim/app/api/v2/workflows/[id]/{executions/[executionId] => runs/[runId]}/resume/route.ts (83%) rename apps/sim/app/api/v2/workflows/[id]/{executions/[executionId] => runs/[runId]}/route.test.ts (89%) rename apps/sim/app/api/v2/workflows/[id]/{executions/[executionId] => runs/[runId]}/route.ts (74%) rename apps/sim/app/api/v2/workflows/[id]/{executions => runs}/route.test.ts (92%) rename apps/sim/app/api/v2/workflows/[id]/{executions => runs}/route.ts (84%) diff --git a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json index d5e28d23d63..2a0595bf677 100644 --- a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json +++ b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json @@ -8,7 +8,7 @@ "undeployWorkflow", "rollbackWorkflow", "executeWorkflowV2", - "getWorkflowExecutionV2", - "cancelExecutionV2" + "getWorkflowRunV2", + "cancelRunV2" ] } 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 2b744d8586d..25ef0dea20d 100644 --- a/apps/docs/content/docs/en/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/en/api-reference/getting-started.mdx @@ -109,21 +109,23 @@ curl -X POST https://www.sim.ai/api/v2/workflows/{workflowId}/execute \ -d '{"input": {}, "async": true}' ``` -This returns immediately with an `executionId` and `statusUrl`: +Keyed callers can optionally provide `X-Run-Id: my-run-123` to choose the run ID. Run IDs cannot be reused; a duplicate returns `409`. + +This returns immediately with a `runId` and `statusUrl`: ```json { "data": { - "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", - "statusUrl": "https://www.sim.ai/api/v2/workflows/{workflowId}/executions/c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74" + "runId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "statusUrl": "https://www.sim.ai/api/v2/workflows/{workflowId}/runs/c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74" } } ``` -Poll the [Get Execution Status](/api-reference/execution/getWorkflowExecution) endpoint until the status is terminal: +Poll the run status endpoint until the status is terminal: ```bash -curl https://www.sim.ai/api/v2/workflows/{workflowId}/executions/{executionId}?includeOutput=true \ +curl https://www.sim.ai/api/v2/workflows/{workflowId}/runs/{runId}?includeOutput=true \ -H "X-API-Key: YOUR_API_KEY" ``` @@ -133,12 +135,12 @@ curl https://www.sim.ai/api/v2/workflows/{workflowId}/executions/{executionId}?i ## Response Format -Successful v2 responses wrap the execution resource in `data`: +Successful v2 responses wrap the run resource in `data`: ```json { "data": { - "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "runId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", "workflowId": "{workflowId}", "status": "completed", "output": { "result": "Hello, world!" }, diff --git a/apps/docs/content/docs/en/api-reference/python.mdx b/apps/docs/content/docs/en/api-reference/python.mdx index d46928ef65a..a9cb7b37726 100644 --- a/apps/docs/content/docs/en/api-reference/python.mdx +++ b/apps/docs/content/docs/en/api-reference/python.mdx @@ -81,7 +81,7 @@ result = client.execute_workflow( **Returns:** `WorkflowExecutionResult | AsyncExecutionResult` -When `async_execution=True`, returns immediately with an `execution_id` and `status_url` for polling. Otherwise, waits for completion. +When `async_execution=True`, returns immediately with a `run_id` and `status_url` for polling. Otherwise, waits for completion. ##### get_workflow_status() @@ -113,12 +113,12 @@ if is_ready: **Returns:** `bool` -##### get_workflow_execution() +##### get_workflow_run() Get the status and optional outputs of a workflow execution. ```python -status = client.get_workflow_execution("workflow-id", "execution-id", include_output=True) +status = client.get_workflow_run("workflow-id", "run-id", include_output=True) print("Status:", status["status"]) # 'queued', 'running', 'completed', 'failed' if status["status"] == "completed": print("Output:", status["output"]) @@ -126,14 +126,14 @@ if status["status"] == "completed": **Parameters:** - `workflow_id` (str): The workflow ID -- `execution_id` (str): The execution ID returned from async execution +- `run_id` (str): The run ID returned from async execution - `include_output` (bool, optional): Include the final output for completed executions - `selected_outputs` (list[str], optional): Block output selectors to include **Returns:** `Dict[str, Any]` **Response fields:** -- `executionId` (str): The execution ID +- `runId` (str): The run ID - `workflowId` (str): The workflow ID - `status` (str): One of `'queued'`, `'pending'`, `'running'`, `'paused'`, `'completed'`, `'failed'`, `'cancelled'` - `startedAt` / `endedAt` (str): Execution timestamps @@ -144,7 +144,7 @@ if status["status"] == "completed": ##### get_job_status() -Get the status of a job created through the legacy async execution endpoint. New integrations should use `get_workflow_execution()` with the execution ID instead. +Get the status of a job created through the legacy async execution endpoint. New integrations should use `get_workflow_run()` with the run ID instead. ```python status = client.get_job_status("legacy-job-id") @@ -283,7 +283,7 @@ class WorkflowExecutionResult: @dataclass class AsyncExecutionResult: success: bool - execution_id: str + run_id: str status_url: str message: str = "" async_execution: bool = True @@ -507,19 +507,19 @@ def execute_async(): # Check if result is an async execution if hasattr(result, 'async_execution') and result.async_execution: - print(f"Execution ID: {result.execution_id}") + print(f"Run ID: {result.run_id}") print(f"Status endpoint: {result.status_url}") # Poll for completion - status = client.get_workflow_execution( - "workflow-id", result.execution_id, include_output=True + status = client.get_workflow_run( + "workflow-id", result.run_id, include_output=True ) while status["status"] in ["queued", "pending", "running"]: print(f"Current status: {status['status']}") time.sleep(2) # Wait 2 seconds - status = client.get_workflow_execution( - "workflow-id", result.execution_id, include_output=True + status = client.get_workflow_run( + "workflow-id", result.run_id, include_output=True ) if status["status"] == "completed": @@ -781,7 +781,7 @@ import { FAQ } from '@/components/ui/faq' ` -When `async: true`, returns immediately with an `executionId` and `statusUrl` for polling. Otherwise, waits for completion. +When `async: true`, returns immediately with a `runId` and `statusUrl` for polling. Otherwise, waits for completion. ##### getWorkflowStatus() @@ -127,12 +127,12 @@ if (isReady) { **Returns:** `Promise` -##### getWorkflowExecution() +##### getWorkflowRun() -Get the status and optional outputs of a workflow execution. +Get the status and optional outputs of a workflow run. ```typescript -const status = await client.getWorkflowExecution('workflow-id', 'execution-id', { +const status = await client.getWorkflowRun('workflow-id', 'run-id', { includeOutput: true }); console.log('Status:', status.status); // 'queued', 'running', 'completed', 'failed' @@ -143,14 +143,14 @@ if (status.status === 'completed') { **Parameters:** - `workflowId` (string): The workflow ID -- `executionId` (string): The execution ID returned from async execution +- `runId` (string): The run ID returned from async execution - `options.includeOutput` (boolean, optional): Include the final output for completed executions - `options.selectedOutputs` (string[], optional): Block output selectors to include -**Returns:** `Promise` +**Returns:** `Promise` **Response fields:** -- `executionId` (string): The execution ID +- `runId` (string): The run ID - `workflowId` (string): The workflow ID - `status` (string): One of `'queued'`, `'pending'`, `'running'`, `'paused'`, `'completed'`, `'failed'`, `'cancelled'` - `startedAt` / `endedAt` (string): Execution timestamps @@ -161,7 +161,7 @@ if (status.status === 'completed') { ##### getJobStatus() -Get the status of a job created through the legacy async execution endpoint. New integrations should use `getWorkflowExecution()` with the execution ID instead. +Get the status of a job created through the legacy async execution endpoint. New integrations should use `getWorkflowRun()` with the run ID instead. ```typescript const status = await client.getJobStatus('legacy-job-id'); @@ -280,7 +280,7 @@ interface WorkflowExecutionResult { logs?: any[]; metadata?: { duration?: number; - executionId?: string; + runId?: string; [key: string]: any; }; traceSpans?: any[]; @@ -293,7 +293,7 @@ interface WorkflowExecutionResult { ```typescript interface AsyncExecutionResult { success: boolean; - executionId: string; + runId: string; statusUrl: string; message: string; async: true; @@ -781,18 +781,18 @@ async function executeAsync() { // Check if result is an async execution if ('async' in result && result.async) { - console.log('Execution ID:', result.executionId); + console.log('Run ID:', result.runId); console.log('Status endpoint:', result.statusUrl); // Poll for completion - let status = await client.getWorkflowExecution('workflow-id', result.executionId, { + let status = await client.getWorkflowRun('workflow-id', result.runId, { includeOutput: true }); while (status.status === 'queued' || status.status === 'pending' || status.status === 'running') { console.log('Current status:', status.status); await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2 seconds - status = await client.getWorkflowExecution('workflow-id', result.executionId, { + status = await client.getWorkflowRun('workflow-id', result.runId, { includeOutput: true }); } @@ -1039,7 +1039,7 @@ import { FAQ } from '@/components/ui/faq' `. ### REST API - Programmatically resume workflows through the v2 execution resource. The `contextId` is available from the block's `resumeEndpoint` output or from the `_resume` object in the paused execution response. + Programmatically resume workflows through the v2 run resource. The `contextId` is available from the block's `resumeEndpoint` output or from the `_resume` object in the paused run response. ```bash - POST /api/v2/workflows/{workflowId}/executions/{executionId}/resume + POST /api/v2/workflows/{workflowId}/runs/{runId}/resume Content-Type: application/json X-API-Key: your-api-key @@ -110,7 +110,7 @@ Access resume data in downstream blocks using ``. ```json { "data": { - "executionId": "", + "runId": "", "workflowId": "", "status": "completed", "output": { ... }, @@ -126,27 +126,27 @@ Access resume data in downstream blocks using ``. - **Stream mode** (`stream: true` on the original execute call) — The resume response streams SSE events with `selectedOutputs` chunks, just like the initial execution. - - **Async mode** (`async: true` on the original v2 execute call) — The resume dispatches execution to a background worker and returns immediately with `202`, including the resume attempt's `executionId` and v2 `statusUrl` for polling: + - **Async mode** (`async: true` on the original v2 execute call) — The resume dispatches the run to a background worker and returns immediately with `202`, including the resume attempt's `runId` and v2 `statusUrl` for polling: ```json { "data": { - "executionId": "", - "statusUrl": "/api/v2/workflows//executions/" + "runId": "", + "statusUrl": "/api/v2/workflows//runs/" } } ``` - #### Polling execution status + #### Polling run status Poll the `statusUrl` from the async response to check when the resume completes: ```bash - GET /api/v2/workflows/{workflowId}/executions/{resumeExecutionId}?includeOutput=true + GET /api/v2/workflows/{workflowId}/runs/{resumeRunId}?includeOutput=true X-API-Key: your-api-key ``` - Returns the execution status and, when completed, the full workflow output. + Returns the run status and, when completed, the full workflow output. The legacy endpoint remains available without behavior changes for existing integrations: @@ -183,7 +183,7 @@ When triggering a workflow through `POST /api/v2/workflows/{id}/execute`, HITL b ```json { "data": { - "executionId": "", + "runId": "", "workflowId": "", "status": "paused", "output": { diff --git a/apps/docs/content/docs/en/workflows/deployment/api.mdx b/apps/docs/content/docs/en/workflows/deployment/api.mdx index 97ba3ace3d6..ab6cfe44a36 100644 --- a/apps/docs/content/docs/en/workflows/deployment/api.mdx +++ b/apps/docs/content/docs/en/workflows/deployment/api.mdx @@ -280,7 +280,7 @@ The `version` field is part of the external API contract. Treat the reference as ### Asynchronous -For long-running workflows, async mode returns an execution ID immediately so you don't need to hold the connection open. Set `"async": true` in the v2 request body. The API returns HTTP 202 with an execution ID and v2 status URL. Poll that execution resource until the run completes. +For long-running workflows, async mode returns a run ID immediately so you don't need to hold the connection open. Set `"async": true` in the v2 request body. The API returns HTTP 202 with a run ID and v2 status URL. Poll that run resource until it completes. To stop an individual async request sooner than the workspace policy, set `executionTimeoutSeconds` to an integer from `1` to `604800` (seven days) in the v2 request body. The effective limit is the @@ -300,15 +300,15 @@ curl -X POST https://sim.ai/api/v2/workflows/{workflow-id}/execute \ ```json { "data": { - "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", - "statusUrl": "https://sim.ai/api/v2/workflows/{workflow-id}/executions/c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74" + "runId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "statusUrl": "https://sim.ai/api/v2/workflows/{workflow-id}/runs/c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74" } } ``` ```bash -curl "https://sim.ai/api/v2/workflows/{workflow-id}/executions/{executionId}?includeOutput=true" \ +curl "https://sim.ai/api/v2/workflows/{workflow-id}/runs/{runId}?includeOutput=true" \ -H "x-api-key: $SIM_API_KEY" ``` @@ -316,7 +316,7 @@ curl "https://sim.ai/api/v2/workflows/{workflow-id}/executions/{executionId}?inc ```json { "data": { - "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "runId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", "workflowId": "{workflow-id}", "status": "running", "startedAt": "2025-09-10T12:00:01.000Z", @@ -331,7 +331,7 @@ curl "https://sim.ai/api/v2/workflows/{workflow-id}/executions/{executionId}?inc ```json { "data": { - "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "runId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", "workflowId": "{workflow-id}", "status": "completed", "startedAt": "2025-09-10T12:00:01.000Z", diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index b7020ae27f9..2cea955c88e 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -1240,7 +1240,7 @@ "source", "workspaceId", "workflow", - "executionId", + "runId", "creditCost" ], "properties": { @@ -1287,7 +1287,7 @@ } ] }, - "executionId": { + "runId": { "type": ["string", "null"] }, "creditCost": { @@ -1311,7 +1311,7 @@ "source": "sim-chat", "workspaceId": "ws_1", "workflow": null, - "executionId": null, + "runId": null, "creditCost": 12 } ], diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index f0b943cdd55..fcea0f0943b 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -79,7 +79,7 @@ { "name": "level", "in": "query", - "description": "Filter logs by severity level. info for successful executions, error for failed ones.", + "description": "Filter logs by severity level. info for successful runs, error for failed ones.", "schema": { "type": "string", "enum": ["info", "error"] @@ -104,9 +104,9 @@ } }, { - "name": "executionId", + "name": "runId", "in": "query", - "description": "Filter by an exact execution ID. Useful for looking up a specific run.", + "description": "Filter by an exact run ID.", "schema": { "type": "string" } @@ -249,7 +249,7 @@ "example": { "data": [ { - "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "runId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "deploymentVersionId": "dep_2c4e6a8b0d1f", "status": "completed", @@ -287,26 +287,26 @@ } } }, - "/api/v2/logs/{executionId}": { + "/api/v2/logs/{runId}": { "get": { "operationId": "getLog", "summary": "Get Log", - "description": "Retrieve the diagnostic representation of an execution by its execution ID, including workflow metadata and state, trace spans, final output, and cost. Logs and executions share the same public identity; no separate log ID is exposed.", + "description": "Retrieve the diagnostic representation of a run by its run ID, including workflow metadata and state, trace spans, final output, and cost. Logs and runs share the same public identity; no separate log ID is exposed.", "tags": ["Logs"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/{runId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], "parameters": [ { - "name": "executionId", + "name": "runId", "in": "path", "required": true, - "description": "The unique execution identifier shared by the lifecycle and diagnostic resources.", + "description": "The unique run identifier shared by the lifecycle and diagnostic resources.", "schema": { "type": "string", "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" @@ -340,7 +340,7 @@ }, "example": { "data": { - "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "runId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "deploymentVersionId": "dep_2c4e6a8b0d1f", "status": "completed", @@ -600,7 +600,7 @@ "type": "object", "description": "Summary of a single workflow execution log entry returned by the list endpoint.", "required": [ - "executionId", + "runId", "workflowId", "deploymentVersionId", "status", @@ -613,7 +613,7 @@ "files" ], "properties": { - "executionId": { + "runId": { "type": "string", "description": "The sole public identifier for both the execution and its log representation.", "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" @@ -635,7 +635,7 @@ }, "level": { "type": "string", - "description": "Log severity. info for successful executions, error for failures.", + "description": "Log severity. info for successful runs, error for failures.", "example": "info" }, "trigger": { @@ -695,9 +695,9 @@ }, "LogDetail": { "type": "object", - "description": "Diagnostic representation of an execution, addressed by the same execution ID as its lifecycle resource.", + "description": "Diagnostic representation of a run, addressed by the same run ID as its lifecycle resource.", "required": [ - "executionId", + "runId", "workflowId", "deploymentVersionId", "status", @@ -715,7 +715,7 @@ "createdAt" ], "properties": { - "executionId": { + "runId": { "type": "string", "description": "The sole public identifier for both the execution and its log representation.", "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" @@ -736,7 +736,7 @@ }, "level": { "type": "string", - "description": "Log severity. info for successful executions, error for failures.", + "description": "Log severity. info for successful runs, error for failures.", "example": "info" }, "trigger": { diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 957b4da2497..ee12eb685b1 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -1110,7 +1110,7 @@ "post": { "operationId": "executeWorkflowV2", "summary": "Execute a workflow", - "description": "Executes a deployed workflow. Auth: `X-API-Key`, or no key at all for workflows deployed with public API access (sync/stream only). Modes are body-selected — there are no mode headers on v2: `\"async\": true` queues the run and returns a 202 receipt whose `statusUrl` is the executions resource; `\"stream\": true` returns Server-Sent Events (no `{data}` envelope on frames; `includeThinking`/`includeToolCalls` additionally require the `X-Sim-Stream-Protocol: agent-events-v1` header). Sync runs return the execution resource: a failed run is HTTP 200 with `status: \"failed\"` and the structured error (the sync timeout is `status:\"failed\"` + `error.code:\"TIMEOUT\"`). A Response block's declared payload stays inside `output` — workflow authors never control response status or headers. Optional `X-Execution-Id` request header (keyed callers only) makes the run idempotent; a reused id returns 409. Rate limiting uses the workflow execution buckets (async runs debit the larger async bucket) and 429s carry `Retry-After`; execute responses do not carry `X-RateLimit-*` headers.", + "description": "Executes a deployed workflow. Auth: `X-API-Key`, or no key at all for workflows deployed with public API access (sync/stream only). Modes are body-selected — there are no mode headers on v2: `\"async\": true` queues the run and returns a 202 receipt whose `statusUrl` is the runs resource; `\"stream\": true` returns Server-Sent Events (no `{data}` envelope on frames; `includeThinking`/`includeToolCalls` additionally require the `X-Sim-Stream-Protocol: agent-events-v1` header). Sync runs return the run resource: a failed run is HTTP 200 with `status: \"failed\"` and the structured error (the sync timeout is `status:\"failed\"` + `error.code:\"TIMEOUT\"`). A Response block's declared payload stays inside `output` — workflow authors never control response status or headers. Optional `X-Run-Id` request header (keyed callers only) supplies the run ID; a reused ID returns 409. Rate limiting uses the workflow run buckets (async runs debit the larger async bucket) and 429s carry `Retry-After`; execute responses do not carry `X-RateLimit-*` headers.", "tags": ["Workflows"], "security": [ { @@ -1120,6 +1120,18 @@ "parameters": [ { "$ref": "#/components/parameters/WorkflowId" + }, + { + "name": "X-Run-Id", + "in": "header", + "required": false, + "description": "Caller-supplied run ID for keyed callers. The value becomes the run resource ID and cannot be reused.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$" + } } ], "requestBody": { @@ -1141,6 +1153,12 @@ "default": false, "description": "Queue the run; poll the returned statusUrl. Not combinable with stream/output options; requires an API key." }, + "executionTimeoutSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 604800, + "description": "Server-side timeout for an async run, in seconds. Valid only when async is true." + }, "stream": { "type": "boolean", "default": false, @@ -1212,7 +1230,15 @@ }, "responses": { "200": { - "description": "The execution resource. Served with `Cache-Control: private, no-store` and the `X-Execution-Id` header.", + "description": "The run resource. Served with `Cache-Control: private, no-store` and the `X-Run-Id` header.", + "headers": { + "X-Run-Id": { + "description": "The run ID assigned to this workflow run.", + "schema": { + "type": "string" + } + } + }, "content": { "application/json": { "schema": { @@ -1220,13 +1246,13 @@ "required": ["data"], "properties": { "data": { - "$ref": "#/components/schemas/ExecutionResource" + "$ref": "#/components/schemas/RunResource" } } }, "example": { "data": { - "executionId": "8f14e45f-ceea-467f-a", + "runId": "8f14e45f-ceea-467f-a", "workflowId": "wf_123", "status": "completed", "output": { @@ -1243,7 +1269,7 @@ "summary": "Completed run", "value": { "data": { - "executionId": "exec_1", + "runId": "run_1", "workflowId": "wf_123", "status": "completed", "output": { @@ -1258,7 +1284,7 @@ "summary": "Failed run (still HTTP 200)", "value": { "data": { - "executionId": "exec_2", + "runId": "run_2", "workflowId": "wf_123", "status": "failed", "output": { @@ -1281,6 +1307,14 @@ }, "202": { "description": "Queued (async). Poll `statusUrl` until `status` is terminal.", + "headers": { + "X-Run-Id": { + "description": "The run ID assigned to the queued workflow run.", + "schema": { + "type": "string" + } + } + }, "content": { "application/json": { "schema": { @@ -1289,9 +1323,9 @@ "properties": { "data": { "type": "object", - "required": ["executionId", "statusUrl"], + "required": ["runId", "statusUrl"], "properties": { - "executionId": { + "runId": { "type": "string" }, "statusUrl": { @@ -1303,8 +1337,8 @@ }, "example": { "data": { - "executionId": "exec_1", - "statusUrl": "https://www.sim.ai/api/v2/workflows/wf_123/executions/exec_1" + "runId": "run_1", + "statusUrl": "https://www.sim.ai/api/v2/workflows/wf_123/runs/run_1" } } } @@ -1323,7 +1357,15 @@ "$ref": "#/components/responses/NotFound" }, "409": { - "description": "The `X-Execution-Id` was already used.", + "description": "The `X-Run-Id` was already used.", + "headers": { + "X-Run-Id": { + "description": "The conflicting caller-supplied run ID.", + "schema": { + "type": "string" + } + } + }, "content": { "application/json": { "schema": { @@ -1354,11 +1396,11 @@ } } }, - "/api/v2/workflows/{id}/executions": { + "/api/v2/workflows/{id}/runs": { "get": { - "operationId": "listWorkflowExecutionsV2", - "summary": "List workflow executions", - "description": "List the durable executions belonging to one workflow. Freshly queued runs are available through their execution status URL but do not enter this history until durable execution logging begins. This lifecycle collection is intentionally lightweight; fetch one execution for output and pause detail, or fetch `/api/v2/logs/{executionId}` for diagnostic trace data.", + "operationId": "listWorkflowRunsV2", + "summary": "List workflow runs", + "description": "List the durable runs belonging to one workflow. Freshly queued runs are available through their run status URL but do not enter this history until durable logging begins. This lifecycle collection is intentionally lightweight; fetch one run for output and pause detail, or fetch `/api/v2/logs/{runId}` for diagnostic trace data.", "tags": ["Workflows"], "security": [ { @@ -1438,7 +1480,7 @@ ], "responses": { "200": { - "description": "A page of workflow executions.", + "description": "A page of workflow runs.", "content": { "application/json": { "schema": { @@ -1448,7 +1490,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/WorkflowExecutionListItem" + "$ref": "#/components/schemas/WorkflowRunListItem" } }, "nextCursor": { @@ -1459,7 +1501,7 @@ "example": { "data": [ { - "executionId": "exec_1", + "runId": "run_1", "workflowId": "wf_123", "status": "completed", "trigger": "api", @@ -1497,10 +1539,10 @@ } } }, - "/api/v2/workflows/{id}/executions/{executionId}": { + "/api/v2/workflows/{id}/runs/{runId}": { "get": { - "operationId": "getWorkflowExecutionV2", - "summary": "Get execution status", + "operationId": "getWorkflowRunV2", + "summary": "Get run status", "description": "The single status URL for sync and async runs. Freshly queued async runs report `queued` (backfilled from the job queue before the durable record exists), then `running`, then a terminal status. Failed runs carry the structured error. `includeOutput=true` adds the final output on completed runs; `selectedOutputs` extracts specific block outputs.", "tags": ["Workflows"], "security": [ @@ -1513,7 +1555,7 @@ "$ref": "#/components/parameters/WorkflowId" }, { - "name": "executionId", + "name": "runId", "in": "path", "required": true, "schema": { @@ -1541,7 +1583,7 @@ ], "responses": { "200": { - "description": "The execution status resource.", + "description": "The run status resource.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -1562,7 +1604,7 @@ "data": { "type": "object", "required": [ - "executionId", + "runId", "workflowId", "status", "trigger", @@ -1576,7 +1618,7 @@ "blockOutputs" ], "properties": { - "executionId": { + "runId": { "type": "string" }, "workflowId": { @@ -1615,7 +1657,6 @@ "pauseKind", "blockedOnBlockId", "automaticResumeWaitingReason", - "pausedExecutionId", "pausePointCount", "resumedCount" ], @@ -1629,7 +1670,6 @@ }, "blockedOnBlockId": { "type": ["string", "null"] }, "automaticResumeWaitingReason": { "type": ["string", "null"] }, - "pausedExecutionId": { "type": "string" }, "pausePointCount": { "type": "number" }, "resumedCount": { "type": "number" } } @@ -1664,7 +1704,7 @@ }, "example": { "data": { - "executionId": "exec_1", + "runId": "run_1", "workflowId": "wf_123", "status": "completed", "trigger": "api", @@ -1704,11 +1744,11 @@ } } }, - "/api/v2/workflows/{id}/executions/{executionId}/resume": { + "/api/v2/workflows/{id}/runs/{runId}/resume": { "post": { - "operationId": "resumeWorkflowExecutionV2", - "summary": "Resume a workflow execution", - "description": "Resumes one human-in-the-loop pause context on the parent execution. Responses are always JSON and the resumed attempt receives a new execution ID. Sync attempts return the execution resource. Async, serialized, and inherited stream-mode attempts return a 202 receipt whose `statusUrl` is the v2 execution resource.", + "operationId": "resumeWorkflowRunV2", + "summary": "Resume a workflow run", + "description": "Resumes one human-in-the-loop pause context on the parent run. Responses are always JSON and the resumed attempt receives a new run ID. Sync attempts return the run resource. Async, serialized, and inherited stream-mode attempts return a 202 receipt whose `statusUrl` is the v2 run resource.", "tags": ["Workflows"], "security": [ { @@ -1720,10 +1760,10 @@ "$ref": "#/components/parameters/WorkflowId" }, { - "name": "executionId", + "name": "runId", "in": "path", "required": true, - "description": "The execution ID of the paused parent run.", + "description": "The run ID of the paused parent run.", "schema": { "type": "string", "minLength": 1 @@ -1762,7 +1802,7 @@ }, "responses": { "200": { - "description": "The completed, failed, paused, or cancelled resume execution resource.", + "description": "The completed, failed, paused, or cancelled resumed run resource.", "content": { "application/json": { "schema": { @@ -1770,13 +1810,13 @@ "required": ["data"], "properties": { "data": { - "$ref": "#/components/schemas/ExecutionResource" + "$ref": "#/components/schemas/RunResource" } } }, "example": { "data": { - "executionId": "resume_exec_1", + "runId": "resume_exec_1", "workflowId": "wf_123", "status": "completed", "output": { @@ -1790,7 +1830,7 @@ } }, "202": { - "description": "The resume is queued. Poll `statusUrl` using the returned resume execution ID.", + "description": "The resume is queued. Poll `statusUrl` using the returned run ID.", "content": { "application/json": { "schema": { @@ -1799,9 +1839,9 @@ "properties": { "data": { "type": "object", - "required": ["executionId", "statusUrl"], + "required": ["runId", "statusUrl"], "properties": { - "executionId": { + "runId": { "type": "string" }, "statusUrl": { @@ -1817,8 +1857,8 @@ }, "example": { "data": { - "executionId": "resume_exec_1", - "statusUrl": "https://www.sim.ai/api/v2/workflows/wf_123/executions/resume_exec_1" + "runId": "resume_exec_1", + "statusUrl": "https://www.sim.ai/api/v2/workflows/wf_123/runs/resume_exec_1" } } } @@ -1871,11 +1911,11 @@ } } }, - "/api/v2/workflows/{id}/executions/{executionId}/cancel": { + "/api/v2/workflows/{id}/runs/{runId}/cancel": { "post": { - "operationId": "cancelExecutionV2", - "summary": "Cancel an execution", - "description": "Cancels a running or paused execution. `reason` explains how the cancellation was recorded.", + "operationId": "cancelRunV2", + "summary": "Cancel a run", + "description": "Cancels a running or paused run. `reason` explains how the cancellation was recorded.", "tags": ["Workflows"], "security": [ { @@ -1887,7 +1927,7 @@ "$ref": "#/components/parameters/WorkflowId" }, { - "name": "executionId", + "name": "runId", "in": "path", "required": true, "schema": { @@ -1920,7 +1960,7 @@ "type": "object", "required": [ "success", - "executionId", + "runId", "redisAvailable", "durablyRecorded", "locallyAborted", @@ -1930,7 +1970,7 @@ "success": { "type": "boolean" }, - "executionId": { + "runId": { "type": "string" }, "redisAvailable": { @@ -1961,7 +2001,7 @@ "example": { "data": { "success": true, - "executionId": "exec_1", + "runId": "run_1", "redisAvailable": true, "durablyRecorded": true, "locallyAborted": false, @@ -2857,7 +2897,7 @@ "ExecutionError": { "type": "object", "required": ["message", "code"], - "description": "Structured execution error. Route on `code` (append-only enum) instead of matching message text. Block fields identify the failing block when attributable — with the executionId they form the reproducible handle to hand a shared workflow's provider.", + "description": "Structured run error. Route on `code` (append-only enum) instead of matching message text. Block fields identify the failing block when attributable — with the runId they form the reproducible handle to hand a shared workflow's provider.", "properties": { "message": { "type": "string" @@ -2885,10 +2925,10 @@ } } }, - "WorkflowExecutionListItem": { + "WorkflowRunListItem": { "type": "object", "required": [ - "executionId", + "runId", "workflowId", "status", "trigger", @@ -2898,7 +2938,7 @@ "cost" ], "properties": { - "executionId": { + "runId": { "type": "string" }, "workflowId": { @@ -2933,12 +2973,12 @@ } } }, - "ExecutionResource": { + "RunResource": { "type": "object", - "required": ["executionId", "workflowId", "status", "output", "error"], - "description": "The execution result resource. An executionId always means 200/202 with data; only pre-execution failures use the error envelope. In-band run failures are status 'failed' with the structured error — never an HTTP error status.", + "required": ["runId", "workflowId", "status", "output", "error"], + "description": "The run result resource. A runId always means 200/202 with data; only pre-run failures use the error envelope. In-band run failures are status 'failed' with the structured error — never an HTTP error status.", "properties": { - "executionId": { + "runId": { "type": "string" }, "workflowId": { diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts index b9837f9c229..c51c5b9c163 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts @@ -313,7 +313,7 @@ describe('POST /api/resume/[workflowId]/[executionId]/[contextId]', () => { async: true, executionId: 'resume-execution-1', message: 'Resume execution queued', - statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/executions/resume-execution-1', + statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/runs/resume-execution-1', }) expect(mockEnqueueResume).toHaveBeenCalledWith( 'resume-execution', @@ -358,7 +358,7 @@ describe('POST /api/resume/[workflowId]/[executionId]/[contextId]', () => { await expect(response.json()).resolves.toMatchObject({ async: true, executionId: 'resume-execution-1', - statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/executions/resume-execution-1', + statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/runs/resume-execution-1', }) expect(mockEnqueueResume).toHaveBeenCalledWith( 'resume-execution', diff --git a/apps/sim/app/api/resume/resume-handler.ts b/apps/sim/app/api/resume/resume-handler.ts index 9345195e4a7..266bd3bc038 100644 --- a/apps/sim/app/api/resume/resume-handler.ts +++ b/apps/sim/app/api/resume/resume-handler.ts @@ -370,7 +370,7 @@ export async function handleResumeExecution({ statusUrl: pollingSurface === 'legacy' ? `${getBaseUrl()}/api/jobs/${jobId}` - : `${getBaseUrl()}/api/v2/workflows/${workflowId}/executions/${enqueueResult.resumeExecutionId}`, + : `${getBaseUrl()}/api/v2/workflows/${workflowId}/runs/${enqueueResult.resumeExecutionId}`, }, { status: 202 } ) 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 362fc54473a..e3a5c354dbc 100644 --- a/apps/sim/app/api/v2/billing/logs/route.test.ts +++ b/apps/sim/app/api/v2/billing/logs/route.test.ts @@ -87,7 +87,7 @@ describe('GET /api/v2/billing/logs', () => { source: 'workflow', workspaceId: 'ws-1', workflow: { id: 'workflow-1', name: 'Support Agent' }, - executionId: 'execution-1', + runId: 'execution-1', creditCost: 12, }, ], diff --git a/apps/sim/app/api/v2/billing/logs/route.ts b/apps/sim/app/api/v2/billing/logs/route.ts index f6a8095466c..9ba119213c3 100644 --- a/apps/sim/app/api/v2/billing/logs/route.ts +++ b/apps/sim/app/api/v2/billing/logs/route.ts @@ -68,7 +68,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { source: toBillingUsageLogSource(log.source), workspaceId: log.workspaceId ?? null, workflow: log.workflowId ? { id: log.workflowId, name: log.workflowName ?? null } : null, - executionId: log.executionId ?? null, + runId: log.executionId ?? null, creditCost: creditsByLogId[log.id] ?? 0, })) diff --git a/apps/sim/app/api/v2/logs/[executionId]/route.test.ts b/apps/sim/app/api/v2/logs/[runId]/route.test.ts similarity index 90% rename from apps/sim/app/api/v2/logs/[executionId]/route.test.ts rename to apps/sim/app/api/v2/logs/[runId]/route.test.ts index 4c6f12038e4..3ca30af1db9 100644 --- a/apps/sim/app/api/v2/logs/[executionId]/route.test.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.test.ts @@ -35,7 +35,7 @@ vi.mock('@/lib/logs/execution/trace-store', () => ({ materializeExecutionData: mockMaterializeExecutionData, })) -import { GET } from '@/app/api/v2/logs/[executionId]/route' +import { GET } from '@/app/api/v2/logs/[runId]/route' const RATE_LIMIT_OK = { allowed: true, @@ -72,13 +72,13 @@ const LOG_ROW = { workflowArchivedAt: null, } -const routeContext = () => ({ params: Promise.resolve({ executionId: 'execution-1' }) }) +const routeContext = () => ({ params: Promise.resolve({ runId: 'execution-1' }) }) function callGet() { return GET(new NextRequest('http://localhost:3000/api/v2/logs/execution-1'), routeContext()) } -describe('GET /api/v2/logs/[executionId]', () => { +describe('GET /api/v2/logs/[runId]', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() @@ -88,7 +88,7 @@ describe('GET /api/v2/logs/[executionId]', () => { dbChainMockFns.limit.mockResolvedValue([LOG_ROW]) }) - it('uses executionId as the sole public identity and includes diagnostic data', async () => { + it('uses runId as the sole public identity and includes diagnostic data', async () => { const traceSpans = [ { id: 'span-1', @@ -108,7 +108,7 @@ describe('GET /api/v2/logs/[executionId]', () => { const body = await response.json() expect(response.status).toBe(200) - expect(body.data.executionId).toBe('execution-1') + expect(body.data.runId).toBe('execution-1') expect(body.data).not.toHaveProperty('id') expect(body.data).not.toHaveProperty('executionData') expect(body.data.traceSpans).toEqual(traceSpans) diff --git a/apps/sim/app/api/v2/logs/[executionId]/route.ts b/apps/sim/app/api/v2/logs/[runId]/route.ts similarity index 90% rename from apps/sim/app/api/v2/logs/[executionId]/route.ts rename to apps/sim/app/api/v2/logs/[runId]/route.ts index 8f39bb47fbd..4151ec10d93 100644 --- a/apps/sim/app/api/v2/logs/[executionId]/route.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.ts @@ -18,12 +18,12 @@ const logger = createLogger('V2LogDetailAPI') export const revalidate = 0 /** - * Returns the diagnostic representation of an execution. The execution ID is - * the sole public identity; the workflow-execution-log row key remains an - * internal storage and pagination detail. + * Returns the diagnostic representation of a run. The run ID is the sole + * public identity; the workflow-execution-log row key remains an internal + * storage and pagination detail. */ export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => { + async (request: NextRequest, context: { params: Promise<{ runId: string }> }) => { const requestId = generateId().slice(0, 8) try { @@ -40,9 +40,9 @@ export const GET = withRouteHandler( }) if (!parsed.success) return parsed.response - const { executionId } = parsed.data.params + const { runId } = parsed.data.params - const log = await getPublicWorkflowLog({ column: 'executionId', value: executionId }) + const log = await getPublicWorkflowLog({ column: 'executionId', value: runId }) if (!log) return v2Error('NOT_FOUND', 'Log not found') @@ -56,7 +56,7 @@ export const GET = withRouteHandler( ) const detail: V2LogDetail = { - executionId: log.executionId, + runId: log.executionId, workflowId: log.workflowId, deploymentVersionId: log.deploymentVersionId, status: v2LogStatusSchema.parse(log.status), diff --git a/apps/sim/app/api/v2/logs/route.test.ts b/apps/sim/app/api/v2/logs/route.test.ts index 34e9a475ad9..14bd9c03d4c 100644 --- a/apps/sim/app/api/v2/logs/route.test.ts +++ b/apps/sim/app/api/v2/logs/route.test.ts @@ -85,6 +85,7 @@ describe('GET /api/v2/logs materialized fields', () => { const body = await response.json() expect(response.status).toBe(200) + expect(body.data[0].runId).toBe('execution-1') expect(body.data[0].finalOutput).toBe(finalOutput) expect(body.data[0].workflow).toMatchObject({ name: 'Support Agent' }) expect(mockListPublicWorkflowLogs).toHaveBeenCalledWith( diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index 71ef75280a3..1035846846a 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -86,7 +86,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { level: params.level, startDate: params.startDate ? new Date(params.startDate) : undefined, endDate: params.endDate ? new Date(params.endDate) : undefined, - executionId: params.executionId, + executionId: params.runId, minDurationMs: params.minDurationMs, maxDurationMs: params.maxDurationMs, minCost: params.minCost, @@ -106,7 +106,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { type LogRow = (typeof data)[number] const buildItem = (log: LogRow): V2LogListItem => { const item: V2LogListItem = { - executionId: log.executionId, + runId: log.executionId, workflowId: log.workflowId, deploymentVersionId: log.deploymentVersionId, status: v2LogStatusSchema.parse(log.status), diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts index 757b09e7388..ac9cdc87de5 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts @@ -216,14 +216,14 @@ describe('POST /api/v2/workflows/[id]/execute', () => { }) }) - it('runs sync and returns the execution resource in the v2 envelope', async () => { + it('runs sync and returns the run resource in the v2 envelope', async () => { const res = await callExecute({ input: { hello: 'world' } }) expect(res.status).toBe(200) - expect(res.headers.get('X-Execution-Id')).toBe('execution-123') + expect(res.headers.get('X-Run-Id')).toBe('execution-123') const body = await res.json() expect(body.data).toMatchObject({ - executionId: 'execution-123', + runId: 'execution-123', workflowId: 'workflow-1', status: 'completed', output: { result: 'done' }, @@ -247,7 +247,7 @@ describe('POST /api/v2/workflows/[id]/execute', () => { expect(res.status).toBe(200) const body = await res.json() expect(body.data.status).toBe('failed') - expect(body.data.executionId).toBe('execution-123') + expect(body.data.runId).toBe('execution-123') expect(body.data.output).toEqual({ partial: true }) expect(body.data.error).toEqual({ message: 'Invalid credentials', @@ -258,14 +258,14 @@ describe('POST /api/v2/workflows/[id]/execute', () => { }) }) - it('queues async runs and returns a 202 receipt with the v2 executions statusUrl', async () => { + it('queues async runs and returns a 202 receipt with the v2 runs statusUrl', async () => { const res = await callExecute({ input: {}, async: true }) expect(res.status).toBe(202) const body = await res.json() expect(body.data).toEqual({ - executionId: 'execution-123', - statusUrl: 'http://localhost:3000/api/v2/workflows/workflow-1/executions/execution-123', + runId: 'execution-123', + statusUrl: 'http://localhost:3000/api/v2/workflows/workflow-1/runs/execution-123', }) expect(mockPreprocessExecution).toHaveBeenCalledWith( expect.objectContaining({ rateLimitCounter: 'async' }) @@ -326,23 +326,32 @@ describe('POST /api/v2/workflows/[id]/execute', () => { expect(res.status).toBe(403) }) - it('returns 409 CONFLICT for a reused X-Execution-Id', async () => { + it('returns 409 CONFLICT for a reused X-Run-Id', async () => { mockClaimExecutionId.mockResolvedValue(null) const res = await callExecute( { input: {} }, - { 'X-Execution-Id': '11111111-1111-4111-8111-111111111111' } + { 'X-Run-Id': '11111111-1111-4111-8111-111111111111' } ) expect(res.status).toBe(409) const body = await res.json() expect(body.error.code).toBe('CONFLICT') + expect(body.error.message).toBe('Run ID has already been used') expect(body.error.details).toMatchObject({ - code: 'EXECUTION_ID_CONFLICT', - executionId: '11111111-1111-4111-8111-111111111111', + code: 'RUN_ID_CONFLICT', + runId: '11111111-1111-4111-8111-111111111111', }) }) + it('rejects an invalid X-Run-Id', async () => { + const res = await callExecute({ input: {} }, { 'X-Run-Id': 'invalid run id' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPreprocessExecution).not.toHaveBeenCalled() + }) + it('surfaces the rate-limit failure with Retry-After', async () => { mockPreprocessExecution.mockResolvedValue({ success: false, 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 0176f980783..4947f04ba32 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -5,8 +5,10 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/work import { getErrorMessage } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { v2ExecuteWorkflowContract } from '@/lib/api/contracts/v2/workflows' -import { executionIdSchema, WORKFLOW_EXECUTION_ID_HEADER } from '@/lib/api/contracts/workflows' +import { + V2_WORKFLOW_RUN_ID_HEADER, + v2ExecuteWorkflowContract, +} from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' import { tryAdmit } from '@/lib/core/admission/gate' import { ADMISSION_ERROR_DESCRIPTOR } from '@/lib/core/admission/transient-failure' @@ -53,21 +55,23 @@ const FAILURE_CODE_BY_STATUS: Record = { function serviceFailureResponse(failure: ExecuteWorkflowServiceFailure) { const code = FAILURE_CODE_BY_STATUS[failure.statusCode] ?? 'INTERNAL_ERROR' + const isRunIdConflict = failure.code === 'EXECUTION_ID_CONFLICT' + const detailCode = isRunIdConflict ? 'RUN_ID_CONFLICT' : failure.code const headers: Record = {} if (failure.retryAfterMs !== undefined) { headers['Retry-After'] = Math.max(1, Math.ceil(failure.retryAfterMs / 1000)).toString() } if (failure.executionId) { - headers[WORKFLOW_EXECUTION_ID_HEADER] = failure.executionId + headers[V2_WORKFLOW_RUN_ID_HEADER] = failure.executionId } - return v2Error(code, failure.message, { + return v2Error(code, isRunIdConflict ? 'Run ID has already been used' : failure.message, { status: failure.statusCode, headers, details: - failure.code || failure.executionId + detailCode || failure.executionId ? { - ...(failure.code ? { code: failure.code } : {}), - ...(failure.executionId ? { executionId: failure.executionId } : {}), + ...(detailCode ? { code: detailCode } : {}), + ...(failure.executionId ? { runId: failure.executionId } : {}), } : undefined, }) @@ -80,9 +84,9 @@ function serviceFailureResponse(failure: ExecuteWorkflowServiceFailure) { * - Auth: `X-API-Key` (personal/workspace) or the anonymous public-API path for * workflows deployed with `isPublicApi` (actor = owner; sync/stream only). * - `async: true` (body flag — v2 has no mode headers) → 202 - * `{ data: { executionId, statusUrl } }`; poll the v2 executions resource. + * `{ data: { runId, statusUrl } }`; poll the v2 runs resource. * - `stream: true` → SSE passthrough (no `{data}` envelope on event frames). - * - Sync → 200 execution resource with the status enum and structured error; + * - Sync → 200 run resource with the status enum and structured error; * an in-band run failure is `status: 'failed'`, never an HTTP error. A * Response block's declared payload stays inside `output` — v2 never lets a * workflow author control response status or headers on this origin. @@ -193,15 +197,11 @@ export const POST = withRouteHandler( ) } - /** Idempotent execution ids are a keyed-caller feature; anonymous callers must not probe the claim table. */ + /** Caller-supplied run IDs are a keyed-caller feature; anonymous callers must not probe the claim table. */ let requestedExecutionId: string | undefined - const executionIdHeader = req.headers.get(WORKFLOW_EXECUTION_ID_HEADER) - if (executionIdHeader !== null && !isPublicApiAccess) { - const headerValidation = executionIdSchema.safeParse(executionIdHeader) - if (!headerValidation.success) { - return v2Error('BAD_REQUEST', 'Invalid execution ID header') - } - requestedExecutionId = headerValidation.data + const runIdHeader = parsed.data.headers['x-run-id'] + if (runIdHeader !== undefined && !isPublicApiAccess) { + requestedExecutionId = runIdHeader } const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ @@ -258,22 +258,22 @@ export const POST = withRouteHandler( if ('queued' in result) { return v2Data( { - executionId: result.executionId, - statusUrl: `${getBaseUrl()}/api/v2/workflows/${workflowId}/executions/${result.executionId}`, + runId: result.executionId, + statusUrl: `${getBaseUrl()}/api/v2/workflows/${workflowId}/runs/${result.executionId}`, }, - { status: 202, headers: { [WORKFLOW_EXECUTION_ID_HEADER]: result.executionId } } + { status: 202, headers: { [V2_WORKFLOW_RUN_ID_HEADER]: result.executionId } } ) } if (result.aborted === 'client') { return v2Error('CLIENT_CLOSED_REQUEST', 'Client cancelled request', { - details: { executionId: result.executionId }, + details: { runId: result.executionId }, }) } return v2Data( { - executionId: result.executionId, + runId: result.executionId, workflowId: result.workflowId, status: result.status, output: result.output ?? null, @@ -282,7 +282,7 @@ export const POST = withRouteHandler( endedAt: result.endedAt, durationMs: result.durationMs, }, - { headers: { [WORKFLOW_EXECUTION_ID_HEADER]: result.executionId } } + { headers: { [V2_WORKFLOW_RUN_ID_HEADER]: result.executionId } } ) } catch (error) { logger.error(`[${requestId}] v2 execute failed`, { diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts similarity index 64% rename from apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts rename to apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts index 9d3ff613cf5..8e0f76b136f 100644 --- a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { v2CancelWorkflowExecutionContract } from '@/lib/api/contracts/v2/workflows' +import { v2CancelWorkflowRunContract } from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -11,41 +11,48 @@ import { import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' -const logger = createLogger('V2CancelExecutionAPI') +const logger = createLogger('V2CancelRunAPI') export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -/** POST /api/v2/workflows/[id]/executions/[executionId]/cancel */ export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string; executionId: string }> }) => { - const parsed = await parseRequest(v2CancelWorkflowExecutionContract, req, context, { + async (req: NextRequest, context: { params: Promise<{ id: string; runId: string }> }) => { + const parsed = await parseRequest(v2CancelWorkflowRunContract, req, context, { validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response - const { id: workflowId, executionId } = parsed.data.params + const { id: workflowId, runId } = parsed.data.params const access = await resolveV2WorkflowAccess(req, workflowId, 'write') if (!access.ok) return access.response try { - logger.info('Cancel execution requested', { workflowId, executionId, userId: access.userId }) + logger.info('Cancel run requested', { workflowId, runId, userId: access.userId }) const result = await cancelWorkflowExecution({ - executionId, + executionId: runId, workflowId, userId: access.userId, workspaceId: access.workflow.workspaceId ?? undefined, }) - return v2Data(result) + return v2Data({ + success: result.success, + runId: result.executionId, + redisAvailable: result.redisAvailable, + durablyRecorded: result.durablyRecorded, + locallyAborted: result.locallyAborted, + pausedCancelled: result.pausedCancelled, + reason: result.reason, + }) } catch (error) { if (error instanceof WorkflowExecutionNotFoundError) { return v2Error('NOT_FOUND', error.message) } - logger.error('Failed to cancel execution', { + logger.error('Failed to cancel run', { workflowId, - executionId, + runId, error: getErrorMessage(error, 'Unknown error'), }) return v2Error('INTERNAL_ERROR', 'Internal server error') diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts similarity index 82% rename from apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.test.ts rename to apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts index b84d87d6afc..82d3324c6bd 100644 --- a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts @@ -22,26 +22,26 @@ vi.mock('@/lib/core/utils/urls', () => ({ })) import { v2ResumeWorkflowContract } from '@/lib/api/contracts/v2/workflows' -import { POST } from '@/app/api/v2/workflows/[id]/executions/[executionId]/resume/route' +import { POST } from '@/app/api/v2/workflows/[id]/runs/[runId]/resume/route' const WORKFLOW_ID = 'workflow-1' -const EXECUTION_ID = 'execution-1' +const RUN_ID = 'run-1' function makeRequest(body: string) { return { request: new NextRequest( - `http://localhost/api/v2/workflows/${WORKFLOW_ID}/executions/${EXECUTION_ID}/resume`, + `http://localhost/api/v2/workflows/${WORKFLOW_ID}/runs/${RUN_ID}/resume`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, body, } ), - context: { params: Promise.resolve({ id: WORKFLOW_ID, executionId: EXECUTION_ID }) }, + context: { params: Promise.resolve({ id: WORKFLOW_ID, runId: RUN_ID }) }, } } -describe('POST /api/v2/workflows/[id]/executions/[executionId]/resume', () => { +describe('POST /api/v2/workflows/[id]/runs/[runId]/resume', () => { beforeEach(() => { vi.clearAllMocks() mockResolveV2WorkflowAccess.mockResolvedValue({ @@ -72,7 +72,7 @@ describe('POST /api/v2/workflows/[id]/executions/[executionId]/resume', () => { expect(mockHandleResumeExecution).not.toHaveBeenCalled() }) - it('resumes a pause context through the execution-scoped v2 endpoint', async () => { + it('resumes a pause context through the run-scoped v2 endpoint', async () => { mockHandleResumeExecution.mockResolvedValueOnce( NextResponse.json( { @@ -80,8 +80,7 @@ describe('POST /api/v2/workflows/[id]/executions/[executionId]/resume', () => { async: true, executionId: 'resume-execution-1', message: 'Resume execution queued', - statusUrl: - 'https://test.sim.ai/api/v2/workflows/workflow-1/executions/resume-execution-1', + statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/runs/resume-execution-1', }, { status: 202 } ) @@ -94,18 +93,18 @@ describe('POST /api/v2/workflows/[id]/executions/[executionId]/resume', () => { const body = await response.json() expect(response.status).toBe(202) - expect(response.headers.get('X-Execution-Id')).toBe('resume-execution-1') + expect(response.headers.get('X-Run-Id')).toBe('resume-execution-1') expect(body).toEqual({ data: { - executionId: 'resume-execution-1', - statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/executions/resume-execution-1', + runId: 'resume-execution-1', + statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/runs/resume-execution-1', }, }) expect(v2ResumeWorkflowContract.response.schema.parse(body)).toEqual(body) expect(mockHandleResumeExecution).toHaveBeenCalledWith({ request, workflowId: WORKFLOW_ID, - executionId: EXECUTION_ID, + executionId: RUN_ID, contextId: 'context-1', workspaceId: 'workspace-1', userId: 'user-1', @@ -132,14 +131,14 @@ describe('POST /api/v2/workflows/[id]/executions/[executionId]/resume', () => { expect(response.status).toBe(202) expect(await response.json()).toEqual({ data: { - executionId: 'resume-execution-2', - statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/executions/resume-execution-2', + runId: 'resume-execution-2', + statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/runs/resume-execution-2', queuePosition: 2, }, }) }) - it('wraps synchronous resume results in the canonical v2 execution shape', async () => { + it('wraps synchronous resume results in the canonical v2 run shape', async () => { mockHandleResumeExecution.mockResolvedValueOnce( NextResponse.json({ success: true, @@ -161,7 +160,7 @@ describe('POST /api/v2/workflows/[id]/executions/[executionId]/resume', () => { expect(response.status).toBe(200) expect(body).toEqual({ data: { - executionId: 'resume-execution-3', + runId: 'resume-execution-3', workflowId: WORKFLOW_ID, status: 'completed', output: { approved: true }, diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts similarity index 83% rename from apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.ts rename to apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts index b7ca76bd664..0021620ccc6 100644 --- a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/resume/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts @@ -2,8 +2,10 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' import type { NextRequest } from 'next/server' -import { v2ResumeWorkflowContract } from '@/lib/api/contracts/v2/workflows' -import { WORKFLOW_EXECUTION_ID_HEADER } from '@/lib/api/contracts/workflows' +import { + V2_WORKFLOW_RUN_ID_HEADER, + v2ResumeWorkflowContract, +} from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -37,15 +39,12 @@ function errorMessage(payload: Record): string { } /** - * POST /api/v2/workflows/[id]/executions/[executionId]/resume resumes one pause - * context on the parent execution. The new resume attempt gets its own - * execution ID, which is the only polling handle exposed by v2. + * POST /api/v2/workflows/[id]/runs/[runId]/resume resumes one pause context on + * the parent run. The new resume attempt gets its own run ID, which is the only + * polling handle exposed by v2. */ export const POST = withRouteHandler( - async ( - request: NextRequest, - context: { params: Promise<{ id: string; executionId: string }> } - ) => { + async (request: NextRequest, context: { params: Promise<{ id: string; runId: string }> }) => { const { id: workflowId } = await context.params const access = await resolveV2WorkflowAccess(request, workflowId, 'write') if (!access.ok) return access.response @@ -55,7 +54,7 @@ export const POST = withRouteHandler( validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response - const { executionId } = parsed.data.params + const { runId } = parsed.data.params const { contextId, input } = parsed.data.body if (!access.workflow.workspaceId) { @@ -66,7 +65,7 @@ export const POST = withRouteHandler( const response = await handleResumeExecution({ request, workflowId, - executionId, + executionId: runId, contextId, workspaceId: access.workflow.workspaceId, userId: access.userId, @@ -90,16 +89,16 @@ export const POST = withRouteHandler( } if (typeof payload.executionId !== 'string') { - return v2Error('INTERNAL_ERROR', 'Resume execution did not return an execution ID') + return v2Error('INTERNAL_ERROR', 'Resume execution did not return a run ID') } - const statusUrl = `${getBaseUrl()}/api/v2/workflows/${workflowId}/executions/${payload.executionId}` - const headers = { [WORKFLOW_EXECUTION_ID_HEADER]: payload.executionId } + const statusUrl = `${getBaseUrl()}/api/v2/workflows/${workflowId}/runs/${payload.executionId}` + const headers = { [V2_WORKFLOW_RUN_ID_HEADER]: payload.executionId } if (response.status === 202 || payload.status === 'queued') { return v2Data( { - executionId: payload.executionId, + runId: payload.executionId, statusUrl, ...(typeof payload.queuePosition === 'number' ? { queuePosition: payload.queuePosition } @@ -116,7 +115,7 @@ export const POST = withRouteHandler( const metadata = isRecordLike(payload.metadata) ? payload.metadata : undefined return v2Data( { - executionId: payload.executionId, + runId: payload.executionId, workflowId, status: payload.status as 'completed' | 'failed' | 'paused' | 'cancelled', output: payload.output ?? null, @@ -133,9 +132,9 @@ export const POST = withRouteHandler( { headers } ) } catch (error) { - logger.error('Failed to resume workflow execution', { + logger.error('Failed to resume workflow run', { workflowId, - executionId, + runId, error: getErrorMessage(error, 'Unknown error'), }) return v2Error('INTERNAL_ERROR', 'Internal server error') diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts similarity index 89% rename from apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts rename to apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts index d82b7b4cedb..20a8e07fc1a 100644 --- a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts @@ -48,12 +48,12 @@ function callStatus(query = '') { 'GET', undefined, {}, - `http://localhost:3000/api/v2/workflows/workflow-1/executions/exec-1${query}` + `http://localhost:3000/api/v2/workflows/workflow-1/runs/exec-1${query}` ) - return GET(req, { params: Promise.resolve({ id: 'workflow-1', executionId: 'exec-1' }) }) + return GET(req, { params: Promise.resolve({ id: 'workflow-1', runId: 'exec-1' }) }) } -describe('v2 executions status + cancel', () => { +describe('v2 runs status + cancel', () => { beforeEach(() => { vi.clearAllMocks() mockAuthenticateV1Request.mockResolvedValue({ @@ -65,7 +65,7 @@ describe('v2 executions status + cancel', () => { mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord }) }) - it('returns the execution resource with a structured error', async () => { + it('returns the run resource with a structured error', async () => { mockGetWorkflowExecutionStatus.mockResolvedValue({ executionId: 'exec-1', workflowId: 'workflow-1', @@ -87,12 +87,13 @@ describe('v2 executions status + cancel', () => { expect(res.status).toBe(200) const body = await res.json() expect(body.data.status).toBe('failed') + expect(body.data.runId).toBe('exec-1') expect(body.data.error.code).toBe('EXECUTION_FAILED') expect(body.data.error.message).toBe('Send Email: Invalid credentials') expect(body.data.durationMs).toBe(5000) }) - it('returns the queued execution resource before the log row exists', async () => { + it('returns the queued run resource before the log row exists', async () => { mockGetWorkflowExecutionStatus.mockResolvedValue({ executionId: 'exec-1', workflowId: 'workflow-1', @@ -145,6 +146,7 @@ describe('v2 executions status + cancel', () => { const body = await (await callStatus()).json() expect(body.data.paused.contextId).toBe('context-1') + expect(body.data.paused).not.toHaveProperty('pausedExecutionId') }) it('404s when neither a log row nor a matching job exists', async () => { @@ -183,12 +185,12 @@ describe('v2 executions status + cancel', () => { const req = createMockRequest('POST', undefined, {}) const res = await cancelPost(req, { - params: Promise.resolve({ id: 'workflow-1', executionId: 'exec-1' }), + params: Promise.resolve({ id: 'workflow-1', runId: 'exec-1' }), }) expect(res.status).toBe(200) const body = await res.json() - expect(body.data).toMatchObject({ success: true, reason: 'recorded' }) + expect(body.data).toMatchObject({ success: true, runId: 'exec-1', reason: 'recorded' }) expect(mockCancel).toHaveBeenCalledWith({ executionId: 'exec-1', workflowId: 'workflow-1', @@ -197,7 +199,7 @@ describe('v2 executions status + cancel', () => { }) }) - it('401s without an API key (no session/anonymous path on executions)', async () => { + it('401s without an API key (no session/anonymous path on runs)', async () => { mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) const res = await callStatus() diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts similarity index 74% rename from apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts rename to apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts index f32da31bfb3..65793c6307d 100644 --- a/apps/sim/app/api/v2/workflows/[id]/executions/[executionId]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts @@ -1,7 +1,10 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { v2GetWorkflowExecutionContract } from '@/lib/api/contracts/v2/workflows' +import { + v2GetWorkflowRunContract, + v2WorkflowRunStatusSchema, +} from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -13,26 +16,23 @@ import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' import { classifyExecutionError } from '@/executor/utils/errors' -const logger = createLogger('V2WorkflowExecutionStatusAPI') +const logger = createLogger('V2WorkflowRunStatusAPI') export const dynamic = 'force-dynamic' /** - * GET /api/v2/workflows/[id]/executions/[executionId] — the single status URL + * GET /api/v2/workflows/[id]/runs/[runId] — the single status URL * for both sync and async runs. When no log row exists yet, the async job * queue is consulted (deterministic job id) so a freshly-queued run reports * `queued` instead of 404. */ export const GET = withRouteHandler( - async ( - request: NextRequest, - context: { params: Promise<{ id: string; executionId: string }> } - ) => { - const parsed = await parseRequest(v2GetWorkflowExecutionContract, request, context, { + async (request: NextRequest, context: { params: Promise<{ id: string; runId: string }> }) => { + const parsed = await parseRequest(v2GetWorkflowRunContract, request, context, { validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response - const { id: workflowId, executionId } = parsed.data.params + const { id: workflowId, runId } = parsed.data.params const { includeOutput, selectedOutputs } = parsed.data.query const access = await resolveV2WorkflowAccess(request, workflowId, 'read') @@ -41,24 +41,24 @@ export const GET = withRouteHandler( try { const status = await getWorkflowExecutionStatus({ workflowId, - executionId, + executionId: runId, includeOutput, selectedOutputs, }) if (!status) { - return v2Error('NOT_FOUND', 'Execution not found') + return v2Error('NOT_FOUND', 'Run not found') } return v2Data({ - executionId: status.executionId, + runId: status.executionId, workflowId: status.workflowId, status: status.status, trigger: status.trigger ?? null, startedAt: status.startedAt, endedAt: status.endedAt, durationMs: status.totalDurationMs, - paused: status.paused, + paused: status.paused ? v2WorkflowRunStatusSchema.shape.paused.parse(status.paused) : null, cost: status.cost, error: status.error ? classifyExecutionError(new Error(status.error)) : null, output: status.finalOutput, @@ -68,9 +68,9 @@ export const GET = withRouteHandler( if (error instanceof FunctionalOutputsUnavailableError) { return v2Error('CONFLICT', FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE) } - logger.error('Failed to fetch execution status', { + logger.error('Failed to fetch run status', { workflowId, - executionId, + runId, error: getErrorMessage(error, 'Unknown error'), }) return v2Error('INTERNAL_ERROR', 'Internal server error') diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts similarity index 92% rename from apps/sim/app/api/v2/workflows/[id]/executions/route.test.ts rename to apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts index e97f6787cc8..102088826c4 100644 --- a/apps/sim/app/api/v2/workflows/[id]/executions/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts @@ -13,12 +13,12 @@ vi.mock('@/app/api/v2/workflows/lib/access', () => ({ resolveV2WorkflowAccess: mockResolveV2WorkflowAccess, })) -import { GET } from '@/app/api/v2/workflows/[id]/executions/route' +import { GET } from '@/app/api/v2/workflows/[id]/runs/route' const routeContext = () => ({ params: Promise.resolve({ id: 'workflow-1' }) }) const callGet = (query = '') => GET( - new NextRequest(`http://localhost:3000/api/v2/workflows/workflow-1/executions${query}`), + new NextRequest(`http://localhost:3000/api/v2/workflows/workflow-1/runs${query}`), routeContext() ) @@ -47,7 +47,7 @@ const EXECUTIONS = [ }, ] -describe('GET /api/v2/workflows/[id]/executions', () => { +describe('GET /api/v2/workflows/[id]/runs', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() @@ -60,7 +60,7 @@ describe('GET /api/v2/workflows/[id]/executions', () => { dbChainMockFns.limit.mockResolvedValue(EXECUTIONS) }) - it('lists lightweight execution resources in the cursor envelope', async () => { + it('lists lightweight run resources in the cursor envelope', async () => { const response = await callGet() const body = await response.json() @@ -68,7 +68,7 @@ describe('GET /api/v2/workflows/[id]/executions', () => { expect(body.nextCursor).toBeNull() expect(body.data).toEqual([ { - executionId: 'execution-2', + runId: 'execution-2', workflowId: 'workflow-1', status: 'paused', trigger: 'api', @@ -78,7 +78,7 @@ describe('GET /api/v2/workflows/[id]/executions', () => { cost: { total: 0.02 }, }, { - executionId: 'execution-1', + runId: 'execution-1', workflowId: 'workflow-1', status: 'completed', trigger: 'schedule', diff --git a/apps/sim/app/api/v2/workflows/[id]/executions/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts similarity index 84% rename from apps/sim/app/api/v2/workflows/[id]/executions/route.ts rename to apps/sim/app/api/v2/workflows/[id]/runs/route.ts index 765c58025d0..5e9a2c5cec8 100644 --- a/apps/sim/app/api/v2/workflows/[id]/executions/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts @@ -2,9 +2,9 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { - type V2WorkflowExecutionListItem, - v2ListWorkflowExecutionsContract, - v2WorkflowExecutionListStatusValueSchema, + type V2WorkflowRunListItem, + v2ListWorkflowRunsContract, + v2WorkflowRunListStatusValueSchema, } from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -20,19 +20,19 @@ import { } from '@/app/api/v2/lib/response' import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' -const logger = createLogger('V2WorkflowExecutionsAPI') +const logger = createLogger('V2WorkflowRunsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -/** List the durable executions belonging to one workflow. */ +/** List the durable runs belonging to one workflow. */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const { id: workflowId } = await context.params const access = await resolveV2WorkflowAccess(request, workflowId, 'read') if (!access.ok) return access.response - const parsed = await parseRequest(v2ListWorkflowExecutionsContract, request, context, { + const parsed = await parseRequest(v2ListWorkflowRunsContract, request, context, { validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response @@ -68,10 +68,10 @@ export const GET = withRouteHandler( order, }) - const data: V2WorkflowExecutionListItem[] = result.data.map((row) => ({ - executionId: row.executionId, + const data: V2WorkflowRunListItem[] = result.data.map((row) => ({ + runId: row.executionId, workflowId: row.workflowId ?? workflowId, - status: v2WorkflowExecutionListStatusValueSchema.parse(row.status), + status: v2WorkflowRunListStatusValueSchema.parse(row.status), trigger: row.trigger, startedAt: row.startedAt.toISOString(), endedAt: row.endedAt?.toISOString() ?? null, @@ -88,7 +88,7 @@ export const GET = withRouteHandler( return v2CursorList(data, nextCursor) } catch (error) { - logger.error('Failed to list workflow executions', { + logger.error('Failed to list workflow runs', { workflowId, error: getErrorMessage(error, 'Unknown error'), }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx index ce7d8283598..4554fe08a95 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx @@ -263,7 +263,7 @@ while (true) { throw new Error(`Invalid workflow execution endpoint: ${endpoint}`) } const baseUrl = endpoint.split(v2WorkflowPrefix)[0] - const statusEndpoint = `${endpoint.slice(0, -'/execute'.length)}/executions/EXECUTION_ID_FROM_EXECUTION` + const statusEndpoint = `${endpoint.slice(0, -'/execute'.length)}/runs/RUN_ID_FROM_EXECUTION` const payload = { ...getPayloadObject(), async: true } switch (asyncExampleType) { @@ -315,7 +315,7 @@ console.log(execution);` body: JSON.stringify(${JSON.stringify(payload)}) }); -const { data: execution }: { data: { executionId: string; statusUrl: string } } = await response.json(); +const { data: execution }: { data: { runId: string; statusUrl: string } } = await response.json(); console.log(execution);` default: diff --git a/apps/sim/lib/api/contracts/v2/billing.ts b/apps/sim/lib/api/contracts/v2/billing.ts index 6922a9cdc0e..d9979efb08e 100644 --- a/apps/sim/lib/api/contracts/v2/billing.ts +++ b/apps/sim/lib/api/contracts/v2/billing.ts @@ -89,7 +89,7 @@ export const v2BillingLogEntrySchema = z.object({ name: z.string().nullable(), }) .nullable(), - executionId: z.string().nullable(), + runId: z.string().nullable(), creditCost: z.number(), }) export type V2BillingLogEntry = z.output diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index ed85438a4b0..17842d574cf 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -29,7 +29,7 @@ const v2LogWorkflowSummarySchema = z.object({ }) export const v2LogListItemSchema = z.object({ - executionId: z.string(), + runId: z.string(), workflowId: z.string().nullable(), deploymentVersionId: z.string().nullable(), status: v2LogStatusSchema, @@ -51,7 +51,7 @@ export const v2LogListItemSchema = z.object({ export type V2LogListItem = z.output export const v2LogDetailSchema = z.object({ - executionId: z.string(), + runId: z.string(), workflowId: z.string().nullable(), deploymentVersionId: z.string().nullable(), status: v2LogStatusSchema, @@ -85,12 +85,13 @@ export const v2LogDetailSchema = z.object({ export type V2LogDetail = z.output export const v2LogParamsSchema = z.object({ - executionId: z.string().min(1, 'executionId cannot be empty'), + runId: z.string().min(1, 'runId cannot be empty'), }) export const v2ListLogsQuerySchema = v1ListLogsQuerySchema - .omit({ folderIds: true }) + .omit({ executionId: true, folderIds: true }) .extend({ + runId: z.string().min(1, 'runId cannot be empty').optional(), folderPaths: z .string() .optional() @@ -128,7 +129,7 @@ export const v2ListLogsContract = defineRouteContract({ export const v2GetLogContract = defineRouteContract({ method: 'GET', - path: '/api/v2/logs/[executionId]', + path: '/api/v2/logs/[runId]', params: v2LogParamsSchema, response: { mode: 'json', diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index f5153ee8977..36b8c118fde 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -29,12 +29,33 @@ import { } from '@/lib/api/contracts/v2/shared' import { cancelWorkflowExecutionReasonSchema, - workflowExecutionParamsSchema, workflowExecutionPausedDetailSchema, workflowExecutionStatusQuerySchema, workflowIdParamsSchema, } from '@/lib/api/contracts/workflows' +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 v2ExecuteWorkflowHeadersSchema = z.object({ + 'x-run-id': v2WorkflowRunIdSchema.optional(), +}) +export type V2ExecuteWorkflowHeaders = z.input + +export const v2WorkflowRunParamsSchema = z.object({ + id: z.string().min(1, 'Invalid workflow ID'), + runId: v2WorkflowRunIdSchema, +}) +export type V2WorkflowRunParams = z.input + /** * v2 workflows contracts. Request shapes are reused from v1 (the `[id]` param * is unchanged, and the list query extends v1's with the v2 search/sort @@ -368,7 +389,7 @@ export const v2ExecutionErrorSchema = z.object({ 'OUTPUT_TOO_LARGE', 'EXECUTION_FAILED', ]), - /** Failing block, when attributable. Deliberately crosses the workspace boundary for shared/child workflows — the executionId + block context is the reproducible handle a caller hands the workflow provider. */ + /** Failing block, when attributable. Deliberately crosses the workspace boundary for shared/child workflows — the runId + block context is the reproducible handle a caller hands the workflow provider. */ blockId: z.string().optional(), blockName: z.string().optional(), blockType: z.string().optional(), @@ -403,13 +424,13 @@ export const v2ExecuteWorkflowBodySchema = z export type V2ExecuteWorkflowBody = z.input /** - * The execution result resource. In-band run failures are `status: 'failed'` - * with a structured `error` — never an HTTP error: **an `executionId` means - * 200/202 + `data`; no `executionId` means the `v2Error` envelope.** The sync + * The run result resource. In-band run failures are `status: 'failed'` + * with a structured `error` — never an HTTP error: **a `runId` means 200/202 + + * `data`; no `runId` means the `v2Error` envelope.** The sync * timeout is `status:'failed'` + `error.code:'TIMEOUT'` (v1 returned 408). */ export const v2ExecuteWorkflowDataSchema = z.object({ - executionId: z.string(), + runId: v2WorkflowRunIdSchema, workflowId: z.string(), status: z.enum(['completed', 'failed', 'paused', 'cancelled']), output: z.unknown(), @@ -420,9 +441,9 @@ export const v2ExecuteWorkflowDataSchema = z.object({ }) export type V2ExecuteWorkflowData = z.output -/** 202 receipt for `async: true` — poll `statusUrl` (the v2 executions resource). */ +/** 202 receipt for `async: true` — poll `statusUrl` (the v2 runs resource). */ export const v2ExecuteWorkflowQueuedSchema = z.object({ - executionId: z.string(), + runId: v2WorkflowRunIdSchema, statusUrl: z.string(), }) export type V2ExecuteWorkflowQueued = z.output @@ -431,6 +452,7 @@ export const v2ExecuteWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/[id]/execute', params: workflowIdParamsSchema, + headers: v2ExecuteWorkflowHeadersSchema, body: v2ExecuteWorkflowBodySchema, response: { mode: 'json', @@ -438,7 +460,7 @@ export const v2ExecuteWorkflowContract = defineRouteContract({ }, }) -/** Resume input is scoped to one pause context on the parent execution. */ +/** Resume input is scoped to one pause context on the parent run. */ export const v2ResumeWorkflowBodySchema = z .object({ contextId: z.string().min(1, 'contextId cannot be empty'), @@ -460,8 +482,8 @@ export type V2ResumeWorkflowResponse = z.output +export type V2ListWorkflowRunsQuery = z.output -export const v2WorkflowExecutionListItemSchema = z.object({ - executionId: z.string(), +export const v2WorkflowRunListItemSchema = z.object({ + runId: v2WorkflowRunIdSchema, workflowId: z.string(), - status: v2WorkflowExecutionListStatusValueSchema, + status: v2WorkflowRunListStatusValueSchema, trigger: z.string(), startedAt: z.string(), endedAt: z.string().nullable(), @@ -523,71 +545,71 @@ export const v2WorkflowExecutionListItemSchema = z.object({ cost: z.object({ total: z.number() }).nullable(), }) -export type V2WorkflowExecutionListItem = z.output +export type V2WorkflowRunListItem = z.output -export const v2ListWorkflowExecutionsContract = defineRouteContract({ +export const v2ListWorkflowRunsContract = defineRouteContract({ method: 'GET', - path: '/api/v2/workflows/[id]/executions', + path: '/api/v2/workflows/[id]/runs', params: workflowIdParamsSchema, - query: v2ListWorkflowExecutionsQuerySchema, + query: v2ListWorkflowRunsQuerySchema, response: { mode: 'json', - schema: v2CursorListResponse(v2WorkflowExecutionListItemSchema), + schema: v2CursorListResponse(v2WorkflowRunListItemSchema), }, }) /** - * The polled execution resource. `queued` is backfilled from the async job + * The polled run resource. `queued` is backfilled from the async job * queue before the worker writes the durable log row — v1's jobs endpoint 404 * window doesn't exist here. `error` is the same structured object the execute * response carries. */ -export const v2WorkflowExecutionStatusSchema = z.object({ - executionId: z.string(), +export const v2WorkflowRunStatusSchema = z.object({ + runId: v2WorkflowRunIdSchema, workflowId: z.string(), - status: v2WorkflowExecutionStatusValueSchema, + status: v2WorkflowRunStatusValueSchema, trigger: z.string().nullable(), startedAt: z.string().nullable(), endedAt: z.string().nullable(), durationMs: z.number().nullable(), - paused: workflowExecutionPausedDetailSchema.nullable(), + paused: workflowExecutionPausedDetailSchema.omit({ pausedExecutionId: true }).nullable(), cost: z.object({ total: z.number() }).nullable(), error: v2ExecutionErrorSchema.nullable(), /** Populated only with `includeOutput=true` on completed runs. */ output: z.unknown().nullable(), blockOutputs: z.record(z.string(), z.unknown()).nullable(), }) -export type V2WorkflowExecutionStatus = z.output +export type V2WorkflowRunStatus = z.output -export const v2GetWorkflowExecutionContract = defineRouteContract({ +export const v2GetWorkflowRunContract = defineRouteContract({ method: 'GET', - path: '/api/v2/workflows/[id]/executions/[executionId]', - params: workflowExecutionParamsSchema, + path: '/api/v2/workflows/[id]/runs/[runId]', + params: v2WorkflowRunParamsSchema, query: workflowExecutionStatusQuerySchema, response: { mode: 'json', - schema: v2DataResponse(v2WorkflowExecutionStatusSchema), + schema: v2DataResponse(v2WorkflowRunStatusSchema), }, }) -export const v2CancelWorkflowExecutionDataSchema = z.object({ +export const v2CancelWorkflowRunDataSchema = z.object({ success: z.boolean(), - executionId: z.string(), + runId: v2WorkflowRunIdSchema, redisAvailable: z.boolean(), durablyRecorded: z.boolean(), locallyAborted: z.boolean(), pausedCancelled: z.boolean(), reason: cancelWorkflowExecutionReasonSchema.optional(), }) -export type V2CancelWorkflowExecutionData = z.output +export type V2CancelWorkflowRunData = z.output -export const v2CancelWorkflowExecutionContract = defineRouteContract({ +export const v2CancelWorkflowRunContract = defineRouteContract({ method: 'POST', - path: '/api/v2/workflows/[id]/executions/[executionId]/cancel', - params: workflowExecutionParamsSchema, + path: '/api/v2/workflows/[id]/runs/[runId]/cancel', + params: v2WorkflowRunParamsSchema, response: { mode: 'json', - schema: v2DataResponse(v2CancelWorkflowExecutionDataSchema), + schema: v2DataResponse(v2CancelWorkflowRunDataSchema), }, }) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index a19291b1f87..3c7aeb53435 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -34,10 +34,10 @@ function buildWorkflowApiEndpoint(baseUrl: string, workflowId: string): string { return `${baseUrl}/api/v2/workflows/${workflowId}/execute` } -function buildWorkflowExecutionStatusEndpoint( +function buildWorkflowRunStatusEndpoint( baseUrl: string, apiEndpoint: string, - executionId: string + runId: string ): string { if ( !apiEndpoint.startsWith(`${baseUrl}/api/v2/workflows/`) || @@ -45,7 +45,7 @@ function buildWorkflowExecutionStatusEndpoint( ) { throw new Error(`Invalid workflow execution endpoint: ${apiEndpoint}`) } - return `${apiEndpoint.slice(0, -'/execute'.length)}/executions/${executionId}` + return `${apiEndpoint.slice(0, -'/execute'.length)}/runs/${runId}` } function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) { @@ -73,11 +73,7 @@ function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) { transport: 'json', stream: false, body: { async: true, input: { key: 'value' } }, - executionStatusEndpointTemplate: buildWorkflowExecutionStatusEndpoint( - baseUrl, - apiEndpoint, - '{executionId}' - ), + runStatusEndpointTemplate: buildWorkflowRunStatusEndpoint(baseUrl, apiEndpoint, '{runId}'), }, }, } @@ -97,7 +93,7 @@ function buildWorkflowApiExamples(baseUrl: string, apiEndpoint: string) { -H "Content-Type: application/json" \\ -H "X-API-Key: YOUR_API_KEY" \\ -d '{"async":true,"input":{"key":"value"}}'`, - poll: `curl "${buildWorkflowExecutionStatusEndpoint(baseUrl, apiEndpoint, 'EXECUTION_ID')}" \\ + poll: `curl "${buildWorkflowRunStatusEndpoint(baseUrl, apiEndpoint, 'RUN_ID')}" \\ -H "X-API-Key: YOUR_API_KEY"`, } } diff --git a/apps/sim/proxy.test.ts b/apps/sim/proxy.test.ts index aba809f1a22..27ee25c511f 100644 --- a/apps/sim/proxy.test.ts +++ b/apps/sim/proxy.test.ts @@ -97,21 +97,22 @@ describe('resolveApiCorsPolicy', () => { ) expect(policy.origin).toBe('*') expect(policy.credentials).toBe(false) - expect(policy.headers).toContain('X-Execution-Id') + expect(policy.headers).toContain('X-Run-Id') expect(policy.headers).toContain('X-Sim-Stream-Protocol') + expect(policy.headers).not.toContain('X-Execution-Id') // Async is body-selected on v2 — the mode header is deliberately absent. expect(policy.headers).not.toContain('X-Execution-Mode') }) - it('does not match the v2 execute rule for nested or executions paths', () => { + it('does not match the v2 execute rule for nested or runs paths', () => { const nested = resolveApiCorsPolicy( makeRequest('/api/v2/workflows/workflow-123/execute/extra', 'https://other.example') ) expect(nested.origin).toBe('https://app.sim.test') - const executions = resolveApiCorsPolicy( - makeRequest('/api/v2/workflows/workflow-123/executions/e-1', 'https://other.example') + const runs = resolveApiCorsPolicy( + makeRequest('/api/v2/workflows/workflow-123/runs/run-1', 'https://other.example') ) - expect(executions.origin).toBe('https://app.sim.test') + expect(runs.origin).toBe('https://app.sim.test') }) it('does not match the workflow execute rule for nested paths', () => { diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 09c4ec1d2a6..bda4fdf7b5b 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -23,8 +23,9 @@ const DEFAULT_API_ALLOWED_HEADERS = const WORKFLOW_EXECUTE_HEADERS = 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, X-Execution-Id, X-Execution-Mode, X-Execution-Timeout-Seconds' -/** v2 execute: async is body-selected (no X-Execution-Mode) and streaming negotiates X-Sim-Stream-Protocol. */ -const WORKFLOW_EXECUTE_V2_HEADERS = `${WORKFLOW_EXECUTE_HEADERS}, X-Sim-Stream-Protocol` +/** v2 execute: run identity and modes use the v2 wire names while streaming negotiates its protocol. */ +const WORKFLOW_EXECUTE_V2_HEADERS = + 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, X-Run-Id, X-Sim-Stream-Protocol' /** Subpaths under /api/chat/* that serve the workspace UI, not embeds. */ const EMBED_RESERVED_SEGMENTS = new Set(['manage', 'validate']) diff --git a/packages/python-sdk/README.md b/packages/python-sdk/README.md index 47b374ad27e..0b096ccd87f 100644 --- a/packages/python-sdk/README.md +++ b/packages/python-sdk/README.md @@ -70,7 +70,7 @@ result = client.execute_workflow( - `timeout` (float, keyword-only): Timeout in seconds (default: 30.0) - `stream` (bool, keyword-only): Enable streaming responses - `selected_outputs` (list, keyword-only): Block outputs to stream (e.g., `["agent1.content"]`) -- `async_execution` (bool, keyword-only): Execute asynchronously and return execution ID +- `async_execution` (bool, keyword-only): Execute asynchronously and return a run ID - `execution_timeout_seconds` (int, keyword-only): Server-side async execution cap from 1 to 604800 seconds. Requires `async_execution=True` and cannot extend the account policy. **Returns:** `WorkflowExecutionResult` or `AsyncExecutionResult` @@ -122,23 +122,23 @@ result = client.execute_workflow_sync("workflow-id", {"data": "some input"}, tim **Returns:** `WorkflowExecutionResult` -##### get_workflow_execution(workflow_id, execution_id, *, include_output=None, selected_outputs=None) +##### get_workflow_run(workflow_id, run_id, *, include_output=None, selected_outputs=None) -Get the status and optional outputs of a workflow execution. Use the execution ID returned by async execution. +Get the status and optional outputs of a workflow run. Use the run ID returned by async execution. ```python -status = client.get_workflow_execution( +status = client.get_workflow_run( "workflow-id", - "execution-id", + "run-id", include_output=True, selected_outputs=["agent.content"] ) -print("Execution status:", status["status"]) +print("Run status:", status["status"]) ``` **Parameters:** - `workflow_id` (str): The workflow ID -- `execution_id` (str): The execution ID returned from async execution +- `run_id` (str): The run ID returned from async execution - `include_output` (bool, keyword-only): Include the final output for completed executions - `selected_outputs` (list, keyword-only): Block output selectors to include @@ -146,7 +146,7 @@ print("Execution status:", status["status"]) ##### get_job_status(job_id) -Get the status of a job created through the legacy async execution endpoint. New integrations should use `get_workflow_execution()` with an execution ID. +Get the status of a job created through the legacy async execution endpoint. New integrations should use `get_workflow_run()` with a run ID. ```python status = client.get_job_status("legacy-job-id") @@ -273,7 +273,7 @@ class SimStudioError(Exception): @dataclass class AsyncExecutionResult: success: bool - execution_id: str + run_id: str status_url: str message: str = "" async_execution: bool = True diff --git a/packages/python-sdk/simstudio/__init__.py b/packages/python-sdk/simstudio/__init__.py index 09b13b5b381..796c9b00b1c 100644 --- a/packages/python-sdk/simstudio/__init__.py +++ b/packages/python-sdk/simstudio/__init__.py @@ -50,7 +50,7 @@ class WorkflowStatus: class AsyncExecutionResult: """Result of an async workflow execution.""" success: bool - execution_id: str + run_id: str status_url: str message: str = "" async_execution: bool = True @@ -160,7 +160,7 @@ def execute_workflow( ) -> Union[WorkflowExecutionResult, AsyncExecutionResult]: """ Execute a workflow with optional input data. - If async_execution is True, returns immediately with an execution ID. + If async_execution is True, returns immediately with a run ID. File objects in input will be automatically detected and converted to base64. @@ -259,11 +259,11 @@ def execute_workflow( result_data = result['data'] if response.status_code == 202: - if 'executionId' not in result_data or 'statusUrl' not in result_data: + if 'runId' not in result_data or 'statusUrl' not in result_data: raise SimStudioError('Invalid v2 async execution response', 'EXECUTION_ERROR') return AsyncExecutionResult( success=True, - execution_id=result_data['executionId'], + run_id=result_data['runId'], status_url=result_data['statusUrl'], message='Workflow execution queued', async_execution=True @@ -276,7 +276,7 @@ def execute_workflow( error=execution_error.get('message') if execution_error else None, metadata={ 'duration': result_data.get('durationMs'), - 'executionId': result_data['executionId'] + 'runId': result_data['runId'] }, total_duration=result_data.get('durationMs') ) @@ -435,30 +435,30 @@ def get_job_status(self, job_id: str) -> Dict[str, Any]: except requests.RequestException as e: raise SimStudioError(f'Failed to get job status: {str(e)}', 'STATUS_ERROR') - def get_workflow_execution( + def get_workflow_run( self, workflow_id: str, - execution_id: str, + run_id: str, *, include_output: Optional[bool] = None, selected_outputs: Optional[list] = None ) -> Dict[str, Any]: """ - Get a workflow execution's current status and optional outputs from the v2 API. + Get a workflow run's current status and optional outputs from the v2 API. Args: workflow_id: The workflow ID - execution_id: The execution ID returned from async execution + run_id: The run ID returned from async execution include_output: Include the final output for completed executions selected_outputs: Block output selectors to include Returns: - Dictionary containing the execution status + Dictionary containing the run status Raises: SimStudioError: If getting the status fails """ - url = f"{self.base_url}/api/v2/workflows/{workflow_id}/executions/{execution_id}" + url = f"{self.base_url}/api/v2/workflows/{workflow_id}/runs/{run_id}" params = {} if include_output is not None: params['includeOutput'] = str(include_output).lower() @@ -484,11 +484,11 @@ def get_workflow_execution( result = response.json() if 'data' not in result: - raise SimStudioError('Invalid v2 workflow execution response', 'STATUS_ERROR') + raise SimStudioError('Invalid v2 workflow run response', 'STATUS_ERROR') return result['data'] except requests.RequestException as e: - raise SimStudioError(f'Failed to get workflow execution: {str(e)}', 'STATUS_ERROR') + raise SimStudioError(f'Failed to get workflow run: {str(e)}', 'STATUS_ERROR') def execute_with_retry( self, diff --git a/packages/python-sdk/tests/test_client.py b/packages/python-sdk/tests/test_client.py index f9eefdb5005..b1b65a257f5 100644 --- a/packages/python-sdk/tests/test_client.py +++ b/packages/python-sdk/tests/test_client.py @@ -10,7 +10,7 @@ def v2_execution_response(output=None): return { "data": { - "executionId": "execution-123", + "runId": "execution-123", "workflowId": "workflow-id", "status": "completed", "output": {} if output is None else output, @@ -108,15 +108,15 @@ def test_context_manager(mock_close): @patch('simstudio.requests.Session.post') -def test_async_execution_returns_execution_id(mock_post): +def test_async_execution_returns_run_id(mock_post): """Test async execution returns AsyncExecutionResult.""" mock_response = Mock() mock_response.ok = True mock_response.status_code = 202 mock_response.json.return_value = { "data": { - "executionId": "execution-123", - "statusUrl": "https://sim.ai/api/v2/workflows/workflow-id/executions/execution-123" + "runId": "execution-123", + "statusUrl": "https://sim.ai/api/v2/workflows/workflow-id/runs/execution-123" } } mock_response.headers.get.return_value = None @@ -130,8 +130,8 @@ def test_async_execution_returns_execution_id(mock_post): ) assert result.success is True - assert result.execution_id == "execution-123" - assert result.status_url == "https://sim.ai/api/v2/workflows/workflow-id/executions/execution-123" + assert result.run_id == "execution-123" + assert result.status_url == "https://sim.ai/api/v2/workflows/workflow-id/runs/execution-123" assert result.async_execution is True call_args = mock_post.call_args @@ -189,8 +189,8 @@ def test_async_execution_timeout_body(mock_post): mock_response.status_code = 202 mock_response.json.return_value = { "data": { - "executionId": "execution-123", - "statusUrl": "/api/v2/workflows/workflow-id/executions/execution-123", + "runId": "execution-123", + "statusUrl": "/api/v2/workflows/workflow-id/runs/execution-123", } } mock_response.headers.get.return_value = None @@ -301,12 +301,12 @@ def test_get_job_status_not_found(mock_get): @patch('simstudio.requests.Session.get') -def test_get_workflow_execution_success(mock_get): +def test_get_workflow_run_success(mock_get): mock_response = Mock() mock_response.ok = True mock_response.json.return_value = { "data": { - "executionId": "execution-123", + "runId": "execution-123", "workflowId": "workflow-123", "status": "completed", "output": {"result": "done"} @@ -316,24 +316,24 @@ def test_get_workflow_execution_success(mock_get): mock_get.return_value = mock_response client = SimStudioClient(api_key="test-api-key", base_url="https://test.sim.ai") - result = client.get_workflow_execution( + result = client.get_workflow_run( "workflow-123", "execution-123", include_output=True, selected_outputs=["agent.content"] ) - assert result["executionId"] == "execution-123" + assert result["runId"] == "execution-123" assert result["status"] == "completed" assert result["output"]["result"] == "done" mock_get.assert_called_once_with( - "https://test.sim.ai/api/v2/workflows/workflow-123/executions/execution-123", + "https://test.sim.ai/api/v2/workflows/workflow-123/runs/execution-123", params={"includeOutput": "true", "selectedOutputs": "agent.content"} ) @patch('simstudio.requests.Session.get') -def test_get_workflow_execution_not_found(mock_get): +def test_get_workflow_run_not_found(mock_get): mock_response = Mock() mock_response.ok = False mock_response.status_code = 404 @@ -341,7 +341,7 @@ def test_get_workflow_execution_not_found(mock_get): mock_response.json.return_value = { "error": { "code": "NOT_FOUND", - "message": "Execution not found" + "message": "Run not found" } } mock_response.headers.get.return_value = None @@ -350,8 +350,8 @@ def test_get_workflow_execution_not_found(mock_get): client = SimStudioClient(api_key="test-api-key") with pytest.raises(SimStudioError) as exc_info: - client.get_workflow_execution("workflow-123", "invalid-execution") - assert "Execution not found" in str(exc_info.value) + client.get_workflow_run("workflow-123", "invalid-run") + assert "Run not found" in str(exc_info.value) @patch('simstudio.requests.Session.post') diff --git a/packages/ts-sdk/README.md b/packages/ts-sdk/README.md index ef59871cffe..ac01b7c3965 100644 --- a/packages/ts-sdk/README.md +++ b/packages/ts-sdk/README.md @@ -75,7 +75,7 @@ const result = await client.executeWorkflow('workflow-id', { message: 'Hello' }, - `timeout` (number): Timeout in milliseconds (default: 30000) - `stream` (boolean): Enable streaming responses - `selectedOutputs` (string[]): Block outputs to stream (e.g., `["agent1.content"]`) - - `async` (boolean): Execute asynchronously and return execution ID + - `async` (boolean): Execute asynchronously and return a run ID - `executionTimeoutSeconds` (number): Server-side async execution cap from 1 to 604800 seconds. Requires `async: true` and cannot extend the account policy. **Returns:** `Promise` @@ -128,29 +128,29 @@ const result = await client.executeWorkflowSync('workflow-id', { data: 'some inp **Returns:** `Promise` -##### getWorkflowExecution(workflowId, executionId, options?) +##### getWorkflowRun(workflowId, runId, options?) -Get the status and optional outputs of a workflow execution. Use the `executionId` returned by async execution. +Get the status and optional outputs of a workflow run. Use the `runId` returned by async execution. ```typescript -const status = await client.getWorkflowExecution('workflow-id', 'execution-id', { +const status = await client.getWorkflowRun('workflow-id', 'run-id', { includeOutput: true, selectedOutputs: ['agent.content'] }); -console.log('Execution status:', status.status); +console.log('Run status:', status.status); ``` **Parameters:** - `workflowId` (string): The workflow ID -- `executionId` (string): The execution ID returned from async execution +- `runId` (string): The run ID returned from async execution - `options.includeOutput` (boolean, optional): Include the final output for completed executions - `options.selectedOutputs` (string[], optional): Block output selectors to include -**Returns:** `Promise` +**Returns:** `Promise` ##### getJobStatus(jobId) -Get the status of a job created through the legacy async execution endpoint. New integrations should use `getWorkflowExecution()` with an execution ID. +Get the status of a job created through the legacy async execution endpoint. New integrations should use `getWorkflowRun()` with a run ID. ```typescript const status = await client.getJobStatus('legacy-job-id'); @@ -237,7 +237,7 @@ interface WorkflowExecutionResult { logs?: any[]; metadata?: { duration?: number; - executionId?: string; + runId?: string; [key: string]: any; }; traceSpans?: any[]; @@ -287,7 +287,7 @@ class SimStudioError extends Error { ```typescript interface AsyncExecutionResult { success: boolean; - executionId: string; + runId: string; statusUrl: string; message: string; async: true; diff --git a/packages/ts-sdk/src/index.test.ts b/packages/ts-sdk/src/index.test.ts index 5d2bfb25e39..267ad113635 100644 --- a/packages/ts-sdk/src/index.test.ts +++ b/packages/ts-sdk/src/index.test.ts @@ -7,7 +7,7 @@ vi.stubGlobal('fetch', mockFetch) function v2ExecutionResponse(output: unknown = {}) { return { data: { - executionId: 'execution-123', + runId: 'execution-123', workflowId: 'workflow-id', status: 'completed', output, @@ -114,8 +114,8 @@ describe('SimStudioClient', () => { status: 202, json: vi.fn().mockResolvedValue({ data: { - executionId: 'execution-123', - statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-id/executions/execution-123', + runId: 'execution-123', + statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-id/runs/execution-123', }, }), headers: { @@ -130,10 +130,10 @@ describe('SimStudioClient', () => { { async: true } ) - expect(result).toHaveProperty('executionId', 'execution-123') + expect(result).toHaveProperty('runId', 'execution-123') expect(result).toHaveProperty( 'statusUrl', - 'https://test.sim.ai/api/v2/workflows/workflow-id/executions/execution-123' + 'https://test.sim.ai/api/v2/workflows/workflow-id/runs/execution-123' ) expect(result).toHaveProperty('async', true) @@ -191,8 +191,8 @@ describe('SimStudioClient', () => { status: 202, json: vi.fn().mockResolvedValue({ data: { - executionId: 'execution-123', - statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-id/executions/execution-123', + runId: 'execution-123', + statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-id/runs/execution-123', }, }), headers: { get: vi.fn().mockReturnValue(null) }, @@ -275,13 +275,13 @@ describe('SimStudioClient', () => { }) }) - describe('getWorkflowExecution', () => { - it('should fetch execution status and outputs from the v2 execution resource', async () => { + describe('getWorkflowRun', () => { + it('should fetch run status and outputs from the v2 run resource', async () => { const mockResponse = { ok: true, json: vi.fn().mockResolvedValue({ data: { - executionId: 'execution-123', + runId: 'execution-123', workflowId: 'workflow-123', status: 'completed', output: { result: 'done' }, @@ -293,22 +293,22 @@ describe('SimStudioClient', () => { } vi.mocked(mockFetch).mockResolvedValue(mockResponse as any) - const result = await client.getWorkflowExecution('workflow-123', 'execution-123', { + const result = await client.getWorkflowRun('workflow-123', 'execution-123', { includeOutput: true, selectedOutputs: ['agent.content'], }) - expect(result).toHaveProperty('executionId', 'execution-123') + expect(result).toHaveProperty('runId', 'execution-123') expect(result).toHaveProperty('status', 'completed') expect(result).toHaveProperty('output') const calls = vi.mocked(mockFetch).mock.calls expect(calls[0][0]).toBe( - 'https://test.sim.ai/api/v2/workflows/workflow-123/executions/execution-123?includeOutput=true&selectedOutputs=agent.content' + 'https://test.sim.ai/api/v2/workflows/workflow-123/runs/execution-123?includeOutput=true&selectedOutputs=agent.content' ) }) - it('should handle execution not found errors', async () => { + it('should handle run not found errors', async () => { const mockResponse = { ok: false, status: 404, @@ -316,7 +316,7 @@ describe('SimStudioClient', () => { json: vi.fn().mockResolvedValue({ error: { code: 'NOT_FOUND', - message: 'Execution not found', + message: 'Run not found', }, }), headers: { @@ -325,12 +325,12 @@ describe('SimStudioClient', () => { } vi.mocked(mockFetch).mockResolvedValue(mockResponse as any) - await expect( - client.getWorkflowExecution('workflow-123', 'invalid-execution') - ).rejects.toThrow(SimStudioError) - await expect( - client.getWorkflowExecution('workflow-123', 'invalid-execution') - ).rejects.toThrow('Execution not found') + await expect(client.getWorkflowRun('workflow-123', 'invalid-run')).rejects.toThrow( + SimStudioError + ) + await expect(client.getWorkflowRun('workflow-123', 'invalid-run')).rejects.toThrow( + 'Run not found' + ) }) }) diff --git a/packages/ts-sdk/src/index.ts b/packages/ts-sdk/src/index.ts index 7224242538c..c2bdb8f1c62 100644 --- a/packages/ts-sdk/src/index.ts +++ b/packages/ts-sdk/src/index.ts @@ -22,7 +22,7 @@ export interface WorkflowExecutionResult { logs?: any[] metadata?: { duration?: number - executionId?: string + runId?: string [key: string]: any } traceSpans?: any[] @@ -57,7 +57,7 @@ const MAX_EXECUTION_TIMEOUT_SECONDS = 604_800 export interface AsyncExecutionResult { success: boolean - executionId: string + runId: string statusUrl: string message: string async: true @@ -77,8 +77,8 @@ export interface WorkflowExecutionError { details?: unknown } -export interface WorkflowExecutionStatus { - executionId: string +export interface WorkflowRunStatus { + runId: string workflowId: string status: 'queued' | 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled' trigger: string | null @@ -92,7 +92,7 @@ export interface WorkflowExecutionStatus { blockOutputs: Record | null } -export interface GetWorkflowExecutionOptions { +export interface GetWorkflowRunOptions { includeOutput?: boolean selectedOutputs?: string[] } @@ -341,7 +341,7 @@ export class SimStudioClient { const result = (await response.json()) as { data?: { - executionId: string + runId: string statusUrl?: string status?: 'completed' | 'failed' | 'paused' | 'cancelled' output?: unknown @@ -359,7 +359,7 @@ export class SimStudioClient { } return { success: true, - executionId: result.data.executionId, + runId: result.data.runId, statusUrl: result.data.statusUrl, message: 'Workflow execution queued', async: true, @@ -372,7 +372,7 @@ export class SimStudioClient { error: result.data.error?.message, metadata: { duration: result.data.durationMs, - executionId: result.data.executionId, + runId: result.data.runId, }, totalDuration: result.data.durationMs, } @@ -511,13 +511,13 @@ export class SimStudioClient { } /** - * Get a workflow execution's current status and optional outputs from the v2 API. + * Get a workflow run's current status and optional outputs from the v2 API. */ - async getWorkflowExecution( + async getWorkflowRun( workflowId: string, - executionId: string, - options: GetWorkflowExecutionOptions = {} - ): Promise { + runId: string, + options: GetWorkflowRunOptions = {} + ): Promise { const query = new URLSearchParams() if (options.includeOutput !== undefined) { query.set('includeOutput', String(options.includeOutput)) @@ -526,7 +526,7 @@ export class SimStudioClient { query.set('selectedOutputs', options.selectedOutputs.join(',')) } const queryString = query.toString() - const url = `${this.baseUrl}/api/v2/workflows/${workflowId}/executions/${executionId}${queryString ? `?${queryString}` : ''}` + const url = `${this.baseUrl}/api/v2/workflows/${workflowId}/runs/${runId}${queryString ? `?${queryString}` : ''}` try { const response = await fetch(url, { @@ -549,9 +549,9 @@ export class SimStudioClient { ) } - const result = (await response.json()) as { data?: WorkflowExecutionStatus } + const result = (await response.json()) as { data?: WorkflowRunStatus } if (!result.data) { - throw new SimStudioError('Invalid v2 workflow execution response', 'STATUS_ERROR') + throw new SimStudioError('Invalid v2 workflow run response', 'STATUS_ERROR') } return result.data } catch (error: any) { @@ -559,10 +559,7 @@ export class SimStudioClient { throw error } - throw new SimStudioError( - describeError(error) || 'Failed to get workflow execution', - 'STATUS_ERROR' - ) + throw new SimStudioError(describeError(error) || 'Failed to get workflow run', 'STATUS_ERROR') } } From 786e0a48679db5a00c200072afa22dff562c9ac7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 6 Aug 2026 19:14:44 -0700 Subject: [PATCH 088/159] feat(api): split credentials and secrets --- apps/docs/openapi-v2-resources.json | 587 +++++++----------- apps/docs/openapi-v2-tables.json | 25 + apps/sim/app/api/credentials/route.ts | 2 +- apps/sim/app/api/v1/middleware.ts | 3 +- .../app/api/v2/credentials/[id]/route.test.ts | 414 ------------ apps/sim/app/api/v2/credentials/[id]/route.ts | 216 ------- apps/sim/app/api/v2/credentials/route.test.ts | 252 +------- apps/sim/app/api/v2/credentials/route.ts | 84 +-- apps/sim/app/api/v2/credentials/utils.ts | 65 +- .../app/api/v2/secrets/[name]/route.test.ts | 230 +++++++ apps/sim/app/api/v2/secrets/[name]/route.ts | 206 ++++++ apps/sim/app/api/v2/secrets/route.test.ts | 140 +++++ apps/sim/app/api/v2/secrets/route.ts | 68 ++ apps/sim/app/api/v2/secrets/utils.ts | 26 + apps/sim/lib/api/contracts/v2/credentials.ts | 222 +------ apps/sim/lib/api/contracts/v2/secrets.ts | 109 ++++ apps/sim/lib/credentials/environment.ts | 163 ++++- apps/sim/lib/credentials/queries.ts | 7 +- .../sim/lib/credentials/secret-values.test.ts | 159 +++++ apps/sim/lib/credentials/secret-values.ts | 212 +++++++ 20 files changed, 1601 insertions(+), 1589 deletions(-) delete mode 100644 apps/sim/app/api/v2/credentials/[id]/route.test.ts delete mode 100644 apps/sim/app/api/v2/credentials/[id]/route.ts create mode 100644 apps/sim/app/api/v2/secrets/[name]/route.test.ts create mode 100644 apps/sim/app/api/v2/secrets/[name]/route.ts create mode 100644 apps/sim/app/api/v2/secrets/route.test.ts create mode 100644 apps/sim/app/api/v2/secrets/route.ts create mode 100644 apps/sim/app/api/v2/secrets/utils.ts create mode 100644 apps/sim/lib/api/contracts/v2/secrets.ts create mode 100644 apps/sim/lib/credentials/secret-values.test.ts create mode 100644 apps/sim/lib/credentials/secret-values.ts diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index be371e32bcc..771419a420d 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Sim API v2 — Workspace Resources", - "description": "The v2 Workspace Resources API covers the resources a workspace is provisioned with: MCP servers, skills, custom tools, folders, and credentials.\n\n## Conventions\n\nAll endpoints live under the `/api/v2` base path and share a single set of conventions:\n\n- **Authentication** — Send your Sim API key in the `X-API-Key` header on every request. Keys are scoped to a workspace (or are personal keys that target a workspace); `workspaceId` is always required so the request can be tenant-scoped and rate-limited.\n- **Single-resource and mutation responses** return `{ \"data\": ... }`.\n- **List responses** use an opaque-cursor envelope: `{ \"data\": [ ... ], \"nextCursor\": string | null }`. Pass the returned `nextCursor` back as the `cursor` query parameter to fetch the next page. When `nextCursor` is `null` there are no more results. Cursors are opaque — do not parse or construct them.\n- **Errors** use a single envelope: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`. The HTTP status code and the stable `code` field move together (for example `404` ⇄ `NOT_FOUND`).\n- **Rate limiting** — Every response carries the current limiter state in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. A throttled request returns `429` with a `Retry-After` header.\n- **Secrets are write-only** — Fields that carry secret material (MCP request headers, credential values) are accepted on write and never returned on read. Reads expose only whether a secret is configured, and for headers their names.", + "description": "The v2 Workspace Resources API covers the resources a workspace is provisioned with: MCP servers, skills, custom tools, folders, credentials, and secrets.\n\n## Conventions\n\nAll endpoints live under the `/api/v2` base path and share a single set of conventions:\n\n- **Authentication** — Send your Sim API key in the `X-API-Key` header on every request. Keys are scoped to a workspace (or are personal keys that target a workspace); `workspaceId` is always required so the request can be tenant-scoped and rate-limited.\n- **Single-resource and mutation responses** return `{ \"data\": ... }`.\n- **List responses** use an opaque-cursor envelope: `{ \"data\": [ ... ], \"nextCursor\": string | null }`. Pass the returned `nextCursor` back as the `cursor` query parameter to fetch the next page. When `nextCursor` is `null` there are no more results. Cursors are opaque — do not parse or construct them.\n- **Errors** use a single envelope: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`. The HTTP status code and the stable `code` field move together (for example `404` ⇄ `NOT_FOUND`).\n- **Rate limiting** — Every response carries the current limiter state in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. A throttled request returns `429` with a `Retry-After` header.\n- **Secrets are write-only** — Secret values and MCP request-header values are accepted on write and never returned. Secret reads expose metadata only.", "version": "2.0.0", "contact": { "name": "Sim Support", @@ -35,7 +35,11 @@ }, { "name": "Credentials", - "description": "Provision the secrets and connected accounts a workspace's agents authenticate with (v2 API)." + "description": "List OAuth and service-account connections available to a workspace (v2 API)." + }, + { + "name": "Secrets", + "description": "Set and manage write-only workspace and personal secret values (v2 API)." } ], "security": [ @@ -1537,7 +1541,7 @@ "get": { "operationId": "listCredentials", "summary": "List Credentials", - "description": "List the credentials you can see in a workspace. Visibility is per credential: an explicit membership grant, plus — for workspace admins — every shared credential, plus your own personal environment credentials.\n\n**Secret material is never returned.** A read tells you a secret is configured (`hasServiceAccountKey`) and nothing more. The workspace's credential set is small and bounded, so the full visible set is returned as a single page and `nextCursor` is always `null`.", + "description": "List the OAuth and service-account connections visible to the caller in a workspace. Environment secrets are a separate resource under `/api/v2/secrets`. Credential creation, update, deletion, and single-resource reads are intentionally not exposed yet.\n\nSecret material is never returned. `hasServiceAccountKey` reports only whether a service-account payload is configured. The workspace credential set is small and bounded, so `nextCursor` is always `null`.", "tags": ["Credentials"], "x-codeSamples": [ { @@ -1554,10 +1558,10 @@ "name": "type", "in": "query", "required": false, - "description": "Only return credentials of this kind.", + "description": "Only return connections of this kind.", "schema": { "type": "string", - "enum": ["oauth", "env_workspace", "env_personal", "service_account"] + "enum": ["oauth", "service_account"] } }, { @@ -1575,7 +1579,7 @@ "name": "search", "in": "query", "required": false, - "description": "Case-insensitive substring match against the credential `displayName`. Matches nothing else — not ids, descriptions, or content. `%` and `_` are matched literally. Must be non-empty; omit the parameter instead of sending a blank one.", + "description": "Case-insensitive substring match against `displayName`.", "schema": { "type": "string", "minLength": 1, @@ -1607,7 +1611,7 @@ ], "responses": { "200": { - "description": "Credentials visible to the caller in the workspace.", + "description": "OAuth and service-account credentials visible to the caller.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -1627,7 +1631,6 @@ "properties": { "data": { "type": "array", - "description": "The credentials visible to the caller.", "items": { "$ref": "#/components/schemas/Credential" } @@ -1646,7 +1649,6 @@ "description": null, "providerId": "zoom-service-account", "accountId": null, - "envKey": null, "hasServiceAccountKey": true, "role": "admin", "createdAt": "2025-06-01T09:14:00.000Z", @@ -1674,54 +1676,72 @@ "$ref": "#/components/responses/InternalError" } } - }, - "post": { - "operationId": "createCredential", - "summary": "Create Credential", - "description": "Create a workspace credential. Requires `write` permission on the workspace; the creator becomes an admin of the credential.\n\n`oauth` credentials **cannot** be created here — they are minted by the interactive OAuth connect flow and bound to an account you authorized in a browser. The creatable types are:\n\n- `env_workspace` — a secret stored under `envKey`, available to everyone in the workspace.\n- `env_personal` — the same, scoped to you.\n- `service_account` — a provider secret (`serviceAccountJson`, `apiToken` + `domain`, `clientId` + `clientSecret` + `orgId`, …). The secret is verified against the provider before it is stored.\n\nEvery secret field is write-only and is never returned. Creation is idempotent on the credential's source (the account, the env key, or the provider + name), so re-issuing the same create returns the existing credential rather than a duplicate.", - "tags": ["Credentials"], + } + }, + "/api/v2/secrets": { + "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 by this API.", + "tags": ["Secrets"], "x-codeSamples": [ { "label": "cURL", "lang": "bash", - "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/credentials\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"type\": \"env_workspace\",\n \"envKey\": \"STRIPE_API_KEY\"\n }'" + "source": "curl \\\n \"https://www.sim.ai/api/v2/secrets?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], - "requestBody": { - "required": true, - "description": "The credential to create.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateCredentialBody" - }, - "examples": { - "workspaceEnvVar": { - "summary": "A workspace-wide environment secret", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "type": "env_workspace", - "envKey": "STRIPE_API_KEY" - } - }, - "clientCredentialServiceAccount": { - "summary": "A client-credentials service account", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "type": "service_account", - "providerId": "zoom-service-account", - "clientId": "YOUR_CLIENT_ID", - "clientSecret": "YOUR_CLIENT_SECRET", - "orgId": "YOUR_ACCOUNT_ID" - } - } - } + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "`workspace` for a shared workspace secret or `personal` for the caller-owned catalog.", + "schema": { + "type": "string", + "enum": ["workspace", "personal"] + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the secret name.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": ["name", "createdAt", "updatedAt"], + "default": "name" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "asc" } } - }, + ], "responses": { - "201": { - "description": "The credential exists with this source. Returned whether it was inserted now or already present.", + "200": { + "description": "Secret metadata visible to the caller.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -1736,24 +1756,31 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CredentialData" + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Secret" + } + }, + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } + } }, "example": { - "data": { - "credential": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "type": "env_workspace", - "displayName": "STRIPE_API_KEY", - "description": null, - "providerId": null, - "accountId": null, - "envKey": "STRIPE_API_KEY", - "hasServiceAccountKey": false, + "data": [ + { + "name": "STRIPE_API_KEY", + "scope": "workspace", "role": "admin", - "createdAt": "2025-06-20T14:02:11.000Z", + "createdAt": "2025-06-01T09:14:00.000Z", "updatedAt": "2025-06-20T14:02:11.000Z" } - } + ], + "nextCursor": null } } } @@ -1767,45 +1794,62 @@ "403": { "$ref": "#/components/responses/Forbidden" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "429": { "$ref": "#/components/responses/RateLimited" }, "500": { "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" } } } }, - "/api/v2/credentials/{id}": { - "get": { - "operationId": "getCredential", - "summary": "Get Credential", - "description": "Fetch a single credential. Secret material is never returned — `hasServiceAccountKey` tells you whether one is stored.\n\nA credential you have no grant on answers `404`, not `403`, so its existence is never disclosed to someone who cannot use it.", - "tags": ["Credentials"], + "/api/v2/secrets/{name}": { + "put": { + "operationId": "setSecret", + "summary": "Set Secret", + "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest and is never included in the response. Updating an existing workspace secret requires credential-admin access to that secret; creating one requires workspace write access.", + "tags": ["Secrets"], "x-codeSamples": [ { "label": "cURL", "lang": "bash", - "source": "curl \\\n \"https://www.sim.ai/api/v2/credentials/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + "source": "curl -X PUT \\\n \"https://www.sim.ai/api/v2/secrets/STRIPE_API_KEY\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"workspaceId\":\"YOUR_WORKSPACE_ID\",\"scope\":\"workspace\",\"value\":\"YOUR_SECRET_VALUE\"}'" } ], "parameters": [ { - "$ref": "#/components/parameters/CredentialId" - }, - { - "$ref": "#/components/parameters/WorkspaceIdQuery" + "name": "name", + "in": "path", + "required": true, + "description": "Secret name. Letters, numbers, and underscores only.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "example": "STRIPE_API_KEY" + } } ], + "requestBody": { + "required": true, + "description": "The secret value and its scope. `value` is write-only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetSecretBody" + }, + "example": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "scope": "workspace", + "value": "YOUR_SECRET_VALUE" + } + } + } + }, "responses": { "200": { - "description": "The credential.", + "description": "The existing secret value was replaced.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -1820,19 +1864,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CredentialData" + "$ref": "#/components/schemas/SecretData" }, "example": { "data": { - "credential": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "type": "service_account", - "displayName": "Zoom account acct_123", - "description": null, - "providerId": "zoom-service-account", - "accountId": null, - "envKey": null, - "hasServiceAccountKey": true, + "secret": { + "name": "STRIPE_API_KEY", + "scope": "workspace", "role": "admin", "createdAt": "2025-06-01T09:14:00.000Z", "updatedAt": "2025-06-20T14:02:11.000Z" @@ -1842,73 +1880,8 @@ } } }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - }, - "patch": { - "operationId": "updateCredential", - "summary": "Update Credential", - "description": "Rename a credential, change its description, or rotate its stored secret. Requires credential admin — access to the workspace plus admin rights on the credential itself. Workspace `write` is not required: credentials are gated per credential, not per workspace.\n\nSending a secret field rotates that secret in place: it is re-verified against the provider and re-encrypted, and the display name is preserved. Secrets are never returned in the response. If the provider cannot be reached to verify the new secret, the request returns `503`.\n\nA credential you cannot see answers `404` rather than `403`, so its existence is never disclosed.", - "tags": ["Credentials"], - "x-codeSamples": [ - { - "label": "cURL", - "lang": "bash", - "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/credentials/7c9e6679-7425-40de-944b-e07fc1f90ae7\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"displayName\": \"Zoom (production)\"\n }'" - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/CredentialId" - } - ], - "requestBody": { - "required": true, - "description": "The fields to change. At least one field besides `workspaceId` is required.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateCredentialBody" - }, - "examples": { - "rename": { - "summary": "Rename a credential", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "displayName": "Zoom (production)" - } - }, - "rotateSecret": { - "summary": "Rotate an API token", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "apiToken": "YOUR_NEW_TOKEN" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "The updated credential.", + "201": { + "description": "The secret was created.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -1923,24 +1896,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CredentialData" - }, - "example": { - "data": { - "credential": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "type": "service_account", - "displayName": "Zoom (production)", - "description": null, - "providerId": "zoom-service-account", - "accountId": null, - "envKey": null, - "hasServiceAccountKey": true, - "role": "admin", - "createdAt": "2025-06-01T09:14:00.000Z", - "updatedAt": "2025-06-21T08:30:00.000Z" - } - } + "$ref": "#/components/schemas/SecretData" } } } @@ -1954,46 +1910,57 @@ "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "429": { "$ref": "#/components/responses/RateLimited" }, "500": { "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" } } }, "delete": { - "operationId": "deleteCredential", - "summary": "Delete Credential", - "description": "Delete a credential. Requires credential admin — access to the workspace plus admin rights on the credential itself; workspace `write` is not required. Blocks and workflows configured against it stop authenticating, and any environment variable it backed is removed.\n\nA credential you cannot see answers `404` rather than `403`.", - "tags": ["Credentials"], + "operationId": "deleteSecret", + "summary": "Delete Secret", + "description": "Delete a workspace or caller-owned personal secret. The stored value is never read or returned. Deleting an existing workspace secret requires credential-admin access to that secret.", + "tags": ["Secrets"], "x-codeSamples": [ { "label": "cURL", "lang": "bash", - "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/credentials/7c9e6679-7425-40de-944b-e07fc1f90ae7?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/secrets/STRIPE_API_KEY?workspaceId=YOUR_WORKSPACE_ID&scope=workspace\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], "parameters": [ { - "$ref": "#/components/parameters/CredentialId" + "name": "name", + "in": "path", + "required": true, + "description": "Secret name. Letters, numbers, and underscores only.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "example": "STRIPE_API_KEY" + } }, { "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "scope", + "in": "query", + "required": true, + "description": "`workspace` for a shared workspace secret or `personal` for the caller-owned catalog.", + "schema": { + "type": "string", + "enum": ["workspace", "personal"] + } } ], "responses": { "200": { - "description": "The credential was deleted.", + "description": "The secret was deleted.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" @@ -2008,11 +1975,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteAcknowledgement" + "$ref": "#/components/schemas/SecretDeleteData" }, "example": { "data": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "name": "STRIPE_API_KEY", + "scope": "workspace", "deleted": true } } @@ -2136,17 +2104,6 @@ "minLength": 1, "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" } - }, - "CredentialId": { - "name": "id", - "in": "path", - "required": true, - "description": "The unique identifier of the credential.", - "schema": { - "type": "string", - "minLength": 1, - "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" - } } }, "responses": { @@ -2928,7 +2885,7 @@ }, "Credential": { "type": "object", - "description": "A stored credential. Secret material is write-only and never appears here.", + "description": "An OAuth or service-account connection. Secret material is never returned.", "required": [ "id", "type", @@ -2936,7 +2893,6 @@ "description", "providerId", "accountId", - "envKey", "hasServiceAccountKey", "role", "createdAt", @@ -2949,36 +2905,30 @@ }, "type": { "type": "string", - "enum": ["oauth", "env_workspace", "env_personal", "service_account"], - "description": "What kind of credential this is." + "enum": ["oauth", "service_account"], + "description": "The authenticated connection type." }, "displayName": { - "type": "string", - "description": "Display name." + "type": "string" }, "description": { "type": ["string", "null"] }, "providerId": { "type": ["string", "null"], - "description": "The integration this credential authenticates against, when it has one." + "description": "The integration this credential authenticates against." }, "accountId": { "type": ["string", "null"], - "description": "The linked OAuth account, for `oauth` credentials." - }, - "envKey": { - "type": ["string", "null"], - "description": "The environment-variable name, for `env_workspace` / `env_personal` credentials." + "description": "The linked OAuth account for OAuth credentials." }, "hasServiceAccountKey": { "type": "boolean", - "description": "Whether a service-account secret is stored. The secret itself is never returned." + "description": "Whether a service-account payload is stored. Its contents are never returned." }, "role": { "type": "string", - "enum": ["admin", "member"], - "description": "The caller's role on this credential. Only admins can update or delete it." + "enum": ["admin", "member"] }, "createdAt": { "type": "string", @@ -2990,172 +2940,95 @@ } } }, - "CredentialData": { - "type": "object", - "required": ["data"], - "properties": { - "data": { - "type": "object", - "required": ["credential"], - "properties": { - "credential": { - "$ref": "#/components/schemas/Credential" - } - } - } - } - }, - "CreateCredentialBody": { + "Secret": { "type": "object", - "description": "A new credential. Every secret field is write-only and is never returned.", - "additionalProperties": false, - "required": ["workspaceId", "type"], + "description": "Secret metadata. The encrypted value is never returned.", + "required": ["name", "scope", "role", "createdAt", "updatedAt"], "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "The workspace to create the credential in." - }, - "type": { - "type": "string", - "enum": ["env_workspace", "env_personal", "service_account"], - "description": "`oauth` is not creatable here — use the interactive OAuth connect flow." - }, - "displayName": { + "name": { "type": "string", "minLength": 1, "maxLength": 255, - "description": "Display name. Derived from the env key or the verified provider account when omitted." + "pattern": "^[A-Za-z0-9_]+$" }, - "description": { + "scope": { "type": "string", - "maxLength": 500 + "enum": ["workspace", "personal"] }, - "providerId": { - "type": "string", - "minLength": 1, - "description": "Required for `service_account` — the integration the secret belongs to." - }, - "envKey": { - "type": "string", - "minLength": 1, - "description": "Required for env credentials. Letters, numbers, and underscores only; `{{NAME}}` is accepted and unwrapped." - }, - "serviceAccountJson": { - "type": "string", - "minLength": 1, - "description": "Write-only. Google-style service-account JSON key." - }, - "signingSecret": { - "type": "string", - "minLength": 1, - "description": "Write-only. Slack custom-bot signing secret." - }, - "botToken": { - "type": "string", - "minLength": 1, - "description": "Write-only. Slack custom-bot token." - }, - "apiToken": { - "type": "string", - "minLength": 1, - "description": "Write-only. Atlassian API token." - }, - "domain": { - "type": "string", - "minLength": 1, - "description": "Atlassian site domain, paired with `apiToken`." - }, - "clientId": { - "type": "string", - "minLength": 1, - "maxLength": 512 - }, - "clientSecret": { + "role": { "type": "string", - "minLength": 1, - "maxLength": 1024, - "description": "Write-only. Client-credentials secret." + "enum": ["admin", "member"] }, - "orgId": { + "createdAt": { "type": "string", - "minLength": 1, - "maxLength": 255 + "format": "date-time" }, - "dataCenter": { + "updatedAt": { "type": "string", - "minLength": 1, - "maxLength": 32, - "description": "Optional provider region selector, such as a Zoho Desk data center." + "format": "date-time" } } }, - "UpdateCredentialBody": { + "SetSecretBody": { "type": "object", - "description": "Fields to change on an existing credential. At least one field besides `workspaceId` is required. Sending a secret field rotates that secret in place; secrets are never returned.", "additionalProperties": false, - "required": ["workspaceId"], + "required": ["workspaceId", "scope", "value"], "properties": { "workspaceId": { - "type": "string", - "minLength": 1, - "description": "The workspace that owns the credential." - }, - "displayName": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "description": { - "type": ["string", "null"], - "maxLength": 500, - "description": "Pass null to clear the description." - }, - "serviceAccountJson": { - "type": "string", - "minLength": 1, - "description": "Write-only. Replaces the stored service-account JSON key." - }, - "signingSecret": { - "type": "string", - "minLength": 1, - "description": "Write-only." - }, - "botToken": { - "type": "string", - "minLength": 1, - "description": "Write-only." - }, - "apiToken": { - "type": "string", - "minLength": 1, - "description": "Write-only." - }, - "domain": { "type": "string", "minLength": 1 }, - "clientId": { + "scope": { "type": "string", - "minLength": 1, - "maxLength": 512 + "enum": ["workspace", "personal"] }, - "clientSecret": { + "value": { "type": "string", "minLength": 1, - "maxLength": 1024, - "description": "Write-only." - }, - "orgId": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "dataCenter": { - "type": "string", - "minLength": 1, - "maxLength": 32, - "description": "Optional provider region selector, such as a Zoho Desk data center." + "maxLength": 65536, + "writeOnly": true, + "description": "The new secret value. It is never returned." + } + } + }, + "SecretData": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["secret"], + "properties": { + "secret": { + "$ref": "#/components/schemas/Secret" + } + } + } + } + }, + "SecretDeleteData": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["name", "scope", "deleted"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$" + }, + "scope": { + "type": "string", + "enum": ["workspace", "personal"] + }, + "deleted": { + "type": "boolean", + "enum": [true] + } + } } } } diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 11d744c306f..40763aa2d9d 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -4766,6 +4766,11 @@ "data": { "$ref": "#/components/schemas/RowData" }, + "__privateSecretProvenance": { + "type": "object", + "writeOnly": true, + "description": "Opaque Sim-managed encrypted provenance metadata. External callers should omit this field." + }, "afterRowId": { "type": "string", "minLength": 1, @@ -4788,6 +4793,11 @@ "minLength": 1, "description": "The workspace that owns the table." }, + "__privateSecretProvenance": { + "type": "object", + "writeOnly": true, + "description": "Opaque Sim-managed encrypted provenance metadata. External callers should omit this field." + }, "rows": { "type": "array", "minItems": 1, @@ -4826,6 +4836,11 @@ "data": { "$ref": "#/components/schemas/RowData" }, + "__privateSecretProvenance": { + "type": "object", + "writeOnly": true, + "description": "Opaque Sim-managed encrypted provenance metadata. External callers should omit this field." + }, "limit": { "type": "integer", "minimum": 1, @@ -4906,6 +4921,11 @@ }, "data": { "$ref": "#/components/schemas/RowData" + }, + "__privateSecretProvenance": { + "type": "object", + "writeOnly": true, + "description": "Opaque Sim-managed encrypted provenance metadata. External callers should omit this field." } } }, @@ -4922,6 +4942,11 @@ "data": { "$ref": "#/components/schemas/RowData" }, + "__privateSecretProvenance": { + "type": "object", + "writeOnly": true, + "description": "Opaque Sim-managed encrypted provenance metadata. External callers should omit this field." + }, "conflictTarget": { "type": "string", "minLength": 1, diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 3c8a5413f73..a4c05dce002 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -219,7 +219,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { workspaceId, userId: session.user.id, workspaceAccess, - type, + types: type ? [type] : undefined, providerId, }) const credentials = visible.map(({ hasServiceAccountKey: _hasKey, ...rest }) => rest) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 4182a4ea4a1..6e5a2a0e49b 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -66,7 +66,8 @@ export type ApiEndpoint = | 'custom-tools' | 'custom-tool-detail' | 'credentials' - | 'credential-detail' + | 'secrets' + | 'secret-detail' export interface RateLimitResult { allowed: boolean diff --git a/apps/sim/app/api/v2/credentials/[id]/route.test.ts b/apps/sim/app/api/v2/credentials/[id]/route.test.ts deleted file mode 100644 index b76997daad2..00000000000 --- a/apps/sim/app/api/v2/credentials/[id]/route.test.ts +++ /dev/null @@ -1,414 +0,0 @@ -/** - * @vitest-environment node - * - * Public v2 credential detail: workspace scoping of the id, the 404 mask for a - * credential the caller has no membership on, and secret-free reads. - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetWorkspaceCredential, - mockGetCredentialActorContext, - mockPerformUpdateCredential, - mockPerformDeleteCredential, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetWorkspaceCredential: vi.fn(), - mockGetCredentialActorContext: vi.fn(), - mockPerformUpdateCredential: vi.fn(), - mockPerformDeleteCredential: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) - -vi.mock('@/lib/credentials/queries', () => ({ - getWorkspaceCredential: mockGetWorkspaceCredential, -})) - -vi.mock('@/lib/credentials/access', () => ({ - getCredentialActorContext: mockGetCredentialActorContext, -})) - -vi.mock('@/lib/credentials/orchestration', async () => { - const actual = await import('@/lib/credentials/orchestration/credential-create') - return { - isProviderOutageCode: actual.isProviderOutageCode, - performUpdateCredential: mockPerformUpdateCredential, - performDeleteCredential: mockPerformDeleteCredential, - } -}) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) - -import { DELETE, GET, PATCH } from '@/app/api/v2/credentials/[id]/route' - -const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} - -const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } - -function buildRow(overrides: Record = {}) { - return { - id: 'cred_abc123', - workspaceId: WORKSPACE_ID, - type: 'service_account', - displayName: 'Zoom account acct_123', - description: null, - providerId: 'zoom-service-account', - accountId: null, - envKey: null, - envOwnerUserId: null, - encryptedServiceAccountKey: 'encrypted-blob', - createdBy: 'user-1', - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - ...overrides, - } -} - -const routeContext = () => ({ params: Promise.resolve({ id: 'cred_abc123' }) }) -const url = (query = `workspaceId=${WORKSPACE_ID}`) => - `http://localhost:3000/api/v2/credentials/cred_abc123?${query}` - -const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) -const callDelete = (query?: string) => - DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) - -function callPatch(body: unknown) { - return PATCH( - new NextRequest('http://localhost:3000/api/v2/credentials/cred_abc123', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - routeContext() - ) -} - -describe('GET /api/v2/credentials/[id]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetWorkspaceCredential.mockResolvedValue(buildRow()) - mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockGetWorkspaceCredential).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callGet('') - expect(res.status).toBe(400) - expect(mockGetWorkspaceCredential).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callGet() - expect(res.status).toBe(403) - expect(mockGetWorkspaceCredential).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callGet() - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('404s when the credential belongs to another workspace', async () => { - mockGetWorkspaceCredential.mockResolvedValue(null) - const res = await callGet() - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - }) - - it('masks a credential the caller has no membership on as 404', async () => { - mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) - const res = await callGet() - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - }) - - it('returns the public shape with no secret material', async () => { - const res = await callGet() - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data.credential).toEqual({ - id: 'cred_abc123', - type: 'service_account', - displayName: 'Zoom account acct_123', - description: null, - providerId: 'zoom-service-account', - accountId: null, - envKey: null, - hasServiceAccountKey: true, - role: 'admin', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - }) - expect(JSON.stringify(body)).not.toContain('encrypted-blob') - }) -}) - -describe('PATCH /api/v2/credentials/[id]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetWorkspaceCredential.mockResolvedValue(buildRow()) - mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) - mockPerformUpdateCredential.mockResolvedValue({ success: true }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) - - expect(res.status).toBe(404) - expect(mockPerformUpdateCredential).not.toHaveBeenCalled() - }) - - it('400s when no field to change is supplied', async () => { - const res = await callPatch({ workspaceId: WORKSPACE_ID }) - expect(res.status).toBe(400) - expect(mockPerformUpdateCredential).not.toHaveBeenCalled() - }) - - it('400s when the body carries an unknown field', async () => { - const res = await callPatch({ workspaceId: WORKSPACE_ID, bogus: 'x' }) - expect(res.status).toBe(400) - expect(mockPerformUpdateCredential).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) - expect(res.status).toBe(403) - expect(mockPerformUpdateCredential).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('404s when the credential belongs to another workspace', async () => { - mockGetWorkspaceCredential.mockResolvedValue(null) - const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) - expect(res.status).toBe(404) - expect(mockPerformUpdateCredential).not.toHaveBeenCalled() - }) - - it('403s when the caller is not a credential admin', async () => { - mockPerformUpdateCredential.mockResolvedValue({ - success: false, - error: 'Credential admin permission required', - errorCode: 'forbidden', - }) - const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) - expect(res.status).toBe(403) - expect((await res.json()).error.code).toBe('FORBIDDEN') - }) - - it('gates on workspace read, leaving admin rights to the per-credential check', async () => { - await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.anything(), - 'user-1', - WORKSPACE_ID, - 'read' - ) - }) - - it('masks a credential the caller cannot see as 404, not 403', async () => { - mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) - const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) - expect(res.status).toBe(404) - expect(mockPerformUpdateCredential).not.toHaveBeenCalled() - }) - - it('503s when the provider is unreachable during a secret rotation', async () => { - mockPerformUpdateCredential.mockResolvedValue({ - success: false, - error: 'provider_unavailable', - errorCode: 'validation', - providerErrorCode: 'provider_unavailable', - }) - const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' }) - expect(res.status).toBe(503) - expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE') - }) - - it('rejects a displayName rename on an env credential instead of dropping it', async () => { - mockGetWorkspaceCredential.mockResolvedValue( - buildRow({ type: 'env_workspace', envKey: 'STRIPE_API_KEY', displayName: 'STRIPE_API_KEY' }) - ) - const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' }) - - expect(res.status).toBe(400) - expect((await res.json()).error.message).toContain('envKey') - expect(mockPerformUpdateCredential).not.toHaveBeenCalled() - }) - - it('still allows a description change on an env credential', async () => { - mockGetWorkspaceCredential.mockResolvedValue(buildRow({ type: 'env_workspace' })) - const res = await callPatch({ workspaceId: WORKSPACE_ID, description: 'note' }) - - expect(res.status).toBe(200) - expect(mockPerformUpdateCredential).toHaveBeenCalled() - }) - - it('503s on an Atlassian outage too, not just a token-provider one', async () => { - mockPerformUpdateCredential.mockResolvedValue({ - success: false, - error: 'atlassian_unavailable', - errorCode: 'validation', - providerErrorCode: 'atlassian_unavailable', - }) - const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' }) - expect(res.status).toBe(503) - expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE') - }) - - it('keeps a rejected secret a 400, not a 503', async () => { - mockPerformUpdateCredential.mockResolvedValue({ - success: false, - error: 'invalid_credentials', - errorCode: 'validation', - providerErrorCode: 'invalid_credentials', - }) - const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' }) - expect(res.status).toBe(400) - }) - - it('rotates a secret without echoing it back', async () => { - const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'brand-new-token' }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(JSON.stringify(body)).not.toContain('brand-new-token') - expect(mockPerformUpdateCredential).toHaveBeenCalledWith( - expect.objectContaining({ - credentialId: 'cred_abc123', - userId: 'user-1', - apiToken: 'brand-new-token', - }) - ) - }) -}) - -describe('DELETE /api/v2/credentials/[id]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetWorkspaceCredential.mockResolvedValue(buildRow()) - mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) - mockPerformDeleteCredential.mockResolvedValue({ success: true }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callDelete() - - expect(res.status).toBe(404) - expect(mockPerformDeleteCredential).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callDelete('') - expect(res.status).toBe(400) - expect(mockPerformDeleteCredential).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callDelete() - expect(res.status).toBe(403) - expect(mockPerformDeleteCredential).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callDelete() - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('404s when the credential belongs to another workspace', async () => { - mockGetWorkspaceCredential.mockResolvedValue(null) - const res = await callDelete() - expect(res.status).toBe(404) - expect(mockPerformDeleteCredential).not.toHaveBeenCalled() - }) - - it('gates on workspace read, leaving admin rights to the per-credential check', async () => { - await callDelete() - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.anything(), - 'user-1', - WORKSPACE_ID, - 'read' - ) - }) - - it('masks a credential the caller cannot see as 404, not 403', async () => { - mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) - const res = await callDelete() - expect(res.status).toBe(404) - expect(mockPerformDeleteCredential).not.toHaveBeenCalled() - }) - - it('deletes the credential and acknowledges the id', async () => { - const res = await callDelete() - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ data: { id: 'cred_abc123', deleted: true } }) - expect(mockPerformDeleteCredential).toHaveBeenCalledWith( - expect.objectContaining({ credentialId: 'cred_abc123', userId: 'user-1' }) - ) - }) -}) diff --git a/apps/sim/app/api/v2/credentials/[id]/route.ts b/apps/sim/app/api/v2/credentials/[id]/route.ts deleted file mode 100644 index d92c016b284..00000000000 --- a/apps/sim/app/api/v2/credentials/[id]/route.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { - v2DeleteCredentialContract, - v2GetCredentialContract, - v2UpdateCredentialContract, -} from '@/lib/api/contracts/v2/credentials' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredentialActorContext } from '@/lib/credentials/access' -import { - isProviderOutageCode, - performDeleteCredential, - performUpdateCredential, -} from '@/lib/credentials/orchestration' -import { getWorkspaceCredential } from '@/lib/credentials/queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2CredentialRow, v2CredentialOrchestrationError } from '@/app/api/v2/credentials/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2CredentialDetailAPI') - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -interface RouteContext { - params: Promise<{ id: string }> -} - -/** GET /api/v2/credentials/[id] — Fetch a single credential. Secrets are never returned. */ -export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'credential-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetCredentialContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId } = parsed.data.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const credential = await getWorkspaceCredential({ workspaceId, credentialId: id }) - if (!credential) return v2Error('NOT_FOUND', 'Credential not found') - - /** - * Workspace access is not credential access: seeing a credential requires a - * membership row (or workspace admin over a shared type). A caller who has - * neither gets 404 rather than 403 so credential existence never leaks to - * someone who cannot use it. - */ - const actor = await getCredentialActorContext(id, userId) - if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found') - - return v2Data( - { credential: toV2CredentialRow(credential, actor.isAdmin ? 'admin' : 'member') }, - { rateLimit } - ) - } catch (error) { - logger.error(`[${requestId}] Error fetching credential`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) - -/** PATCH /api/v2/credentials/[id] — Rename, re-describe, or rotate a credential's secret. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'credential-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateCredentialContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId, ...changes } = parsed.data.body - - /** - * Credential mutations are gated per credential, not per workspace: - * `performUpdateCredential` requires credential admin, and the internal - * surface applies no workspace-level bar at all. Requiring workspace `write` - * here would lock out a credential admin who only holds `read`. - */ - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - // Tenant-scope the id before the orchestration re-derives access from the - // credential's own workspace. - const existing = await getWorkspaceCredential({ workspaceId, credentialId: id }) - if (!existing) return v2Error('NOT_FOUND', 'Credential not found') - - const actor = await getCredentialActorContext(id, userId) - if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found') - - /** - * An env credential's display name IS its `envKey` — the lib only applies - * `displayName` to `oauth` and `service_account`, so accepting it here would - * either drop the rename silently (when sent alongside `description`) or - * fail with an unrelated environment-editor message (when sent alone). - */ - if ( - changes.displayName !== undefined && - (existing.type === 'env_workspace' || existing.type === 'env_personal') - ) { - return v2Error( - 'BAD_REQUEST', - 'displayName cannot be set on an environment credential — its name is its envKey. Delete it and create one under the new key.' - ) - } - - const result = await performUpdateCredential({ ...changes, credentialId: id, userId, request }) - - if (!result.success) { - return v2CredentialOrchestrationError( - result.errorCode, - result.error ?? 'Failed to update credential', - { providerUnavailable: isProviderOutageCode(result.providerErrorCode) } - ) - } - - const updated = await getWorkspaceCredential({ workspaceId, credentialId: id }) - if (!updated) return v2Error('NOT_FOUND', 'Credential not found') - - return v2Data({ credential: toV2CredentialRow(updated, 'admin') }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error updating credential`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) - -/** DELETE /api/v2/credentials/[id] — Delete a credential and revoke what it backed. */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'credential-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteCredentialContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId } = parsed.data.query - - // Gated per credential by `performDeleteCredential`, same as PATCH above. - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const existing = await getWorkspaceCredential({ workspaceId, credentialId: id }) - if (!existing) return v2Error('NOT_FOUND', 'Credential not found') - - /** - * A credential the caller cannot see answers 404, matching GET, so a - * workspace member cannot tell an inaccessible credential from a missing one - * and enumerate ids. A credential they *can* see but cannot administer still - * gets the orchestration's 403 — that distinction is not a leak, since GET - * already shows them the credential. - */ - const actor = await getCredentialActorContext(id, userId) - if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found') - - const result = await performDeleteCredential({ credentialId: id, userId, request }) - if (!result.success) { - return v2CredentialOrchestrationError( - result.errorCode, - result.error ?? 'Failed to delete credential' - ) - } - - return v2Data({ id, deleted: true as const }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error deleting credential`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index 3833eb52c84..adb94296696 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -1,8 +1,5 @@ /** * @vitest-environment node - * - * Public v2 credentials list/create: gate ordering, the write-only treatment of - * secret material, and the exclusion of `oauth` from the creatable types. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,15 +9,11 @@ const { mockResolveWorkspaceAccess, mockCheckWorkspaceAccess, mockListVisibleWorkspaceCredentials, - mockPerformCreateCredential, - mockGetCredentialActorContext, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), mockCheckWorkspaceAccess: vi.fn(), mockListVisibleWorkspaceCredentials: vi.fn(), - mockPerformCreateCredential: vi.fn(), - mockGetCredentialActorContext: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -36,19 +29,11 @@ vi.mock('@/lib/credentials/queries', () => ({ listVisibleWorkspaceCredentials: mockListVisibleWorkspaceCredentials, })) -vi.mock('@/lib/credentials/orchestration', () => ({ - performCreateCredential: mockPerformCreateCredential, -})) - -vi.mock('@/lib/credentials/access', () => ({ - getCredentialActorContext: mockGetCredentialActorContext, -})) - vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null), })) -import { GET, POST } from '@/app/api/v2/credentials/route' +import { GET } from '@/app/api/v2/credentials/route' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' @@ -89,44 +74,9 @@ function buildVisible(overrides: Record = {}) { } } -function buildRow(overrides: Record = {}) { - return { - id: 'cred_abc123', - workspaceId: WORKSPACE_ID, - type: 'service_account', - displayName: 'Zoom account acct_123', - description: null, - providerId: 'zoom-service-account', - accountId: null, - envKey: null, - envOwnerUserId: null, - encryptedServiceAccountKey: 'encrypted-blob', - createdBy: 'user-1', - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - ...overrides, - } -} - const callList = (query: string) => GET(new NextRequest(`http://localhost:3000/api/v2/credentials?${query}`)) -function callCreate(body: unknown) { - return POST( - new NextRequest('http://localhost:3000/api/v2/credentials', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - ) -} - -const VALID_BODY = { - workspaceId: WORKSPACE_ID, - type: 'env_workspace', - envKey: 'STRIPE_API_KEY', -} - describe('GET /api/v2/credentials', () => { beforeEach(() => { vi.clearAllMocks() @@ -160,19 +110,23 @@ describe('GET /api/v2/credentials', () => { code: 'FORBIDDEN', message: 'Access denied', }) + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + expect(res.status).toBe(403) expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() }) it('returns the rate-limit response when denied', async () => { mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + expect(res.status).toBe(429) expect((await res.json()).error.code).toBe('RATE_LIMITED') }) - it('returns the public credential shape with no secret material', async () => { + it('returns connection metadata without environment-secret fields', async () => { const res = await callList(`workspaceId=${WORKSPACE_ID}`) const body = await res.json() @@ -186,199 +140,39 @@ describe('GET /api/v2/credentials', () => { description: null, providerId: 'zoom-service-account', accountId: null, - envKey: null, hasServiceAccountKey: true, role: 'admin', createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-02T00:00:00.000Z', }, ]) + expect(JSON.stringify(body)).not.toContain('envKey') expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( - expect.objectContaining({ workspaceId: WORKSPACE_ID, userId: 'user-1' }) + expect.objectContaining({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + types: ['oauth', 'service_account'], + }) ) }) - it('passes the type and providerId filters through', async () => { + it('accepts only OAuth and service-account type filters', async () => { await callList(`workspaceId=${WORKSPACE_ID}&type=oauth&providerId=slack`) expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( - expect.objectContaining({ type: 'oauth', providerId: 'slack' }) + expect.objectContaining({ types: ['oauth'], providerId: 'slack' }) ) - }) - it('400s on a sort field outside the enum instead of letting it reach the query', async () => { - const res = await callList(`workspaceId=${WORKSPACE_ID}&sortBy=name);--`) - - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - }) - - it('400s on a sort direction outside the enum', async () => { - const res = await callList(`workspaceId=${WORKSPACE_ID}&sortOrder=sideways`) - - expect(res.status).toBe(400) - }) - - it('400s on an empty search rather than treating it as unsearched', async () => { - const res = await callList(`workspaceId=${WORKSPACE_ID}&search=`) - - expect(res.status).toBe(400) - }) - - it('forwards search and sort into the query and still terminates pagination', async () => { - const res = await callList( - `workspaceId=${WORKSPACE_ID}&search=report&sortBy=displayName&sortOrder=asc` - ) - - expect(res.status).toBe(200) - expect((await res.json()).nextCursor).toBeNull() - }) -}) - -describe('POST /api/v2/credentials', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformCreateCredential.mockResolvedValue({ - success: true, - credential: buildRow(), - created: true, - }) - mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false }) - }) - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callCreate(VALID_BODY) - - expect(res.status).toBe(404) - expect(mockPerformCreateCredential).not.toHaveBeenCalled() - }) - - it('400s when envKey is missing for an env credential', async () => { - const res = await callCreate({ workspaceId: WORKSPACE_ID, type: 'env_workspace' }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformCreateCredential).not.toHaveBeenCalled() - }) - - it('400s when envKey is not a valid environment variable name', async () => { - const res = await callCreate({ ...VALID_BODY, envKey: 'not-a-valid-name' }) - expect(res.status).toBe(400) - expect(mockPerformCreateCredential).not.toHaveBeenCalled() - }) - - it('400s on an oauth create, which requires the interactive connect flow', async () => { - const res = await callCreate({ - workspaceId: WORKSPACE_ID, - type: 'oauth', - providerId: 'slack', - accountId: 'acct_1', - displayName: 'Slack', - }) - expect(res.status).toBe(400) - expect(mockPerformCreateCredential).not.toHaveBeenCalled() + const invalid = await callList(`workspaceId=${WORKSPACE_ID}&type=env_workspace`) + expect(invalid.status).toBe(400) }) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - const res = await callCreate(VALID_BODY) - expect(res.status).toBe(403) - expect(mockPerformCreateCredential).not.toHaveBeenCalled() - }) + it('rejects invalid list controls', async () => { + const invalidSort = await callList(`workspaceId=${WORKSPACE_ID}&sortBy=name);--`) + const invalidDirection = await callList(`workspaceId=${WORKSPACE_ID}&sortOrder=sideways`) + const emptySearch = await callList(`workspaceId=${WORKSPACE_ID}&search=`) - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callCreate(VALID_BODY) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('maps a provider outage to 503 rather than a bad request', async () => { - mockPerformCreateCredential.mockResolvedValue({ - success: false, - error: 'provider_unavailable', - errorCode: 'validation', - providerErrorCode: 'provider_unavailable', - providerUnavailable: true, - }) - const res = await callCreate(VALID_BODY) - expect(res.status).toBe(503) - expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE') - }) - - it('reports the real role when an idempotent create matches a credential the caller only belongs to', async () => { - mockPerformCreateCredential.mockResolvedValue({ - success: true, - credential: buildRow(), - created: false, - }) - mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'member' }, isAdmin: false }) - - const res = await callCreate(VALID_BODY) - const body = await res.json() - - expect(res.status).toBe(201) - expect(body.data.credential.role).toBe('member') - }) - - it('reports admin for a fresh insert without a second access lookup', async () => { - const res = await callCreate(VALID_BODY) - - expect((await res.json()).data.credential.role).toBe('admin') - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) - - it('creates the credential and never echoes the submitted secret', async () => { - const res = await callCreate({ - workspaceId: WORKSPACE_ID, - type: 'service_account', - providerId: 'zoom-service-account', - clientId: 'zoom-client-id', - clientSecret: 'super-secret-value', - orgId: 'acct_123', - }) - const body = await res.json() - - expect(res.status).toBe(201) - expect(body.data.credential).toMatchObject({ - id: 'cred_abc123', - hasServiceAccountKey: true, - role: 'admin', - }) - expect(JSON.stringify(body)).not.toContain('super-secret-value') - expect(JSON.stringify(body)).not.toContain('encrypted-blob') - expect(mockPerformCreateCredential).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: WORKSPACE_ID, - userId: 'user-1', - type: 'service_account', - clientSecret: 'super-secret-value', - }) - ) - }) - - it('accepts and forwards an optional service-account data center', async () => { - const res = await callCreate({ - workspaceId: WORKSPACE_ID, - type: 'service_account', - providerId: 'zoho-desk-service-account', - clientId: 'zoho-client-id', - clientSecret: 'zoho-client-secret', - orgId: '600123456', - dataCenter: 'eu', - }) - - expect(res.status).toBe(201) - expect(mockPerformCreateCredential).toHaveBeenCalledWith( - expect.objectContaining({ dataCenter: 'eu' }) - ) - expect(JSON.stringify(await res.json())).not.toContain('dataCenter') + expect(invalidSort.status).toBe(400) + expect(invalidDirection.status).toBe(400) + expect(emptySearch.status).toBe(400) }) }) diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index 2b710b275bf..9e687641f42 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -1,27 +1,16 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { - v2CreateCredentialContract, - v2ListCredentialsContract, -} from '@/lib/api/contracts/v2/credentials' +import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredentialActorContext } from '@/lib/credentials/access' -import { performCreateCredential } from '@/lib/credentials/orchestration' import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { - toV2Credential, - toV2CredentialRow, - v2CredentialOrchestrationError, -} from '@/app/api/v2/credentials/utils' +import { toV2Credential } from '@/app/api/v2/credentials/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CursorList, - v2Data, v2Error, v2RateLimitError, v2ValidationError, @@ -35,8 +24,6 @@ export const revalidate = 0 /** GET /api/v2/credentials — List the credentials the caller can see in a workspace. */ export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { const rateLimit = await checkRateLimit(request, 'credentials') if (!rateLimit.allowed) return v2RateLimitError(rateLimit) @@ -69,7 +56,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { workspaceId, userId, workspaceAccess, - type, + types: type ? [type] : ['oauth', 'service_account'], providerId, search, sortBy, @@ -79,70 +66,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { // The per-workspace credential set is small and bounded → a single full page. return v2CursorList(credentials.map(toV2Credential), null, { rateLimit }) } catch (error) { - logger.error(`[${requestId}] Error listing credentials`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) - -/** POST /api/v2/credentials — Create a workspace credential. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'credentials') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateCredentialContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - - const { workspaceId } = parsed.data.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performCreateCredential({ ...parsed.data.body, userId, request }) - - if (!result.success || !result.credential) { - return v2CredentialOrchestrationError( - result.errorCode, - result.error ?? 'Failed to create credential', - { providerUnavailable: result.providerUnavailable } - ) - } - - /** - * A fresh insert makes the creator an admin, but an idempotent match against - * an existing source does not — the orchestration admits a caller who is - * only a *member* of that credential. Resolve the real role rather than - * assuming the create case, or the response would advertise administrative - * actions the caller cannot perform. - */ - const actor = result.created - ? { isAdmin: true } - : await getCredentialActorContext(result.credential.id, userId) - const credential = toV2CredentialRow(result.credential, actor.isAdmin ? 'admin' : 'member') - - /** - * Always 201, including when an existing credential already occupied this - * source. Create is idempotent on the source tuple, and the caller's - * post-condition — "a credential with this source exists, here it is" — is - * the same either way. - */ - return v2Data({ credential }, { rateLimit, status: 201 }) - } catch (error) { - logger.error(`[${requestId}] Error creating credential`, { + logger.error('Error listing credentials', { error: getErrorMessage(error, 'Unknown error'), }) return v2Error('INTERNAL_ERROR', 'Internal server error') diff --git a/apps/sim/app/api/v2/credentials/utils.ts b/apps/sim/app/api/v2/credentials/utils.ts index b70903da663..e186a4f1558 100644 --- a/apps/sim/app/api/v2/credentials/utils.ts +++ b/apps/sim/app/api/v2/credentials/utils.ts @@ -1,35 +1,12 @@ -import type { NextResponse } from 'next/server' import type { V2Credential } from '@/lib/api/contracts/v2/credentials' -import type { CredentialOrchestrationErrorCode } from '@/lib/credentials/orchestration' -import type { CredentialRow, VisibleWorkspaceCredential } from '@/lib/credentials/queries' -import { v2Error } from '@/app/api/v2/lib/response' - -/** - * Shared serialization + error mapping for the v2 credentials surface. - * - * Both projections are written field by field on purpose: a credential row - * carries `encryptedServiceAccountKey`, and spreading the row would put it one - * forgotten `omit` away from the wire. - */ +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 { - return { - id: row.id, - type: row.type, - displayName: row.displayName, - description: row.description, - providerId: row.providerId, - accountId: row.accountId, - envKey: row.envKey, - hasServiceAccountKey: row.hasServiceAccountKey, - role: row.role, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), + if (row.type !== 'oauth' && row.type !== 'service_account') { + throw new Error(`Secret credential type ${row.type} reached the credentials API`) } -} -/** Projection for a raw credential row, whose caller-role is resolved separately. */ -export function toV2CredentialRow(row: CredentialRow, role: V2Credential['role']): V2Credential { return { id: row.id, type: row.type, @@ -37,39 +14,9 @@ export function toV2CredentialRow(row: CredentialRow, role: V2Credential['role'] description: row.description, providerId: row.providerId, accountId: row.accountId, - envKey: row.envKey, - hasServiceAccountKey: Boolean(row.encryptedServiceAccountKey), - role, + hasServiceAccountKey: row.hasServiceAccountKey, + role: row.role, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), } } - -/** - * Renders a credential orchestration failure in the v2 error envelope. - * - * `forbidden` from the orchestration means "not an admin of this credential", - * which is a resource-level denial rather than a workspace one; it stays a 403 - * because the caller already proved workspace access to reach it. - */ -export function v2CredentialOrchestrationError( - errorCode: CredentialOrchestrationErrorCode | undefined, - message: string, - options: { providerUnavailable?: boolean } = {} -): NextResponse { - if (options.providerUnavailable) { - return v2Error('SERVICE_UNAVAILABLE', 'The credential provider is unavailable. Try again.') - } - switch (errorCode) { - case 'validation': - return v2Error('BAD_REQUEST', message) - case 'forbidden': - return v2Error('FORBIDDEN', message) - case 'not_found': - return v2Error('NOT_FOUND', 'Credential not found') - case 'conflict': - return v2Error('CONFLICT', message) - default: - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -} diff --git a/apps/sim/app/api/v2/secrets/[name]/route.test.ts b/apps/sim/app/api/v2/secrets/[name]/route.test.ts new file mode 100644 index 00000000000..15db1c86543 --- /dev/null +++ b/apps/sim/app/api/v2/secrets/[name]/route.test.ts @@ -0,0 +1,230 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockCheckWorkspaceAccess, + mockGetWorkspaceEnvKeyAdminAccess, + mockListVisibleWorkspaceCredentials, + mockSetWorkspaceSecret, + mockSetPersonalSecret, + mockDeleteWorkspaceSecret, + mockDeletePersonalSecret, + mockRecordAudit, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockCheckWorkspaceAccess: vi.fn(), + mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), + mockListVisibleWorkspaceCredentials: vi.fn(), + mockSetWorkspaceSecret: vi.fn(), + mockSetPersonalSecret: vi.fn(), + mockDeleteWorkspaceSecret: vi.fn(), + mockDeletePersonalSecret: vi.fn(), + mockRecordAudit: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + ENVIRONMENT_UPDATED: 'environment.updated', + ENVIRONMENT_DELETED: 'environment.deleted', + }, + AuditResourceType: { ENVIRONMENT: 'environment' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + +vi.mock('@/lib/credentials/environment', () => ({ + getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess, +})) + +vi.mock('@/lib/credentials/queries', () => ({ + listVisibleWorkspaceCredentials: mockListVisibleWorkspaceCredentials, +})) + +vi.mock('@/lib/credentials/secret-values', () => ({ + setWorkspaceSecret: mockSetWorkspaceSecret, + setPersonalSecret: mockSetPersonalSecret, + deleteWorkspaceSecret: mockDeleteWorkspaceSecret, + deletePersonalSecret: mockDeletePersonalSecret, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { DELETE, PUT } from '@/app/api/v2/secrets/[name]/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +function secretCredential(scope: 'workspace' | 'personal') { + return { + id: 'secret-1', + workspaceId: WORKSPACE_ID, + type: scope === 'workspace' ? ('env_workspace' as const) : ('env_personal' as const), + displayName: 'STRIPE_API_KEY', + description: null, + providerId: null, + accountId: null, + envKey: 'STRIPE_API_KEY', + envOwnerUserId: scope === 'personal' ? 'user-1' : null, + createdBy: 'user-1', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + hasServiceAccountKey: false, + role: 'admin' as const, + } +} + +const context = { params: Promise.resolve({ name: 'STRIPE_API_KEY' }) } + +function callSet(scope: 'workspace' | 'personal', value = 'super-secret-value') { + mockListVisibleWorkspaceCredentials.mockResolvedValue([secretCredential(scope)]) + return PUT( + new NextRequest('http://localhost:3000/api/v2/secrets/STRIPE_API_KEY', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, scope, value }), + }), + context + ) +} + +function callDelete(scope: 'workspace' | 'personal') { + return DELETE( + new NextRequest( + `http://localhost:3000/api/v2/secrets/STRIPE_API_KEY?workspaceId=${WORKSPACE_ID}&scope=${scope}`, + { method: 'DELETE' } + ), + context + ) +} + +describe('PUT /api/v2/secrets/[name]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) + mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ + adminKeys: new Set(), + knownKeys: new Set(), + }) + mockSetWorkspaceSecret.mockResolvedValue({ created: true, updatedAt: new Date() }) + mockSetPersonalSecret.mockResolvedValue({ created: true, updatedAt: new Date() }) + }) + + it('sets a workspace secret and never echoes its value', async () => { + const res = await callSet('workspace') + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.secret).toMatchObject({ name: 'STRIPE_API_KEY', scope: 'workspace' }) + expect(JSON.stringify(body)).not.toContain('super-secret-value') + expect(mockSetWorkspaceSecret).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + name: 'STRIPE_API_KEY', + value: 'super-secret-value', + userId: 'user-1', + }) + }) + + it('updates an existing workspace secret only for a secret admin', async () => { + mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ + adminKeys: new Set(), + knownKeys: new Set(['STRIPE_API_KEY']), + }) + + const forbidden = await callSet('workspace') + expect(forbidden.status).toBe(403) + expect(mockSetWorkspaceSecret).not.toHaveBeenCalled() + + mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ + adminKeys: new Set(['STRIPE_API_KEY']), + knownKeys: new Set(['STRIPE_API_KEY']), + }) + mockSetWorkspaceSecret.mockResolvedValue({ created: false, updatedAt: new Date() }) + + const updated = await callSet('workspace') + expect(updated.status).toBe(200) + }) + + it('sets only the caller-owned personal secret catalog', async () => { + const res = await callSet('personal') + + expect(res.status).toBe(201) + expect(mockSetPersonalSecret).toHaveBeenCalledWith({ + userId: 'user-1', + name: 'STRIPE_API_KEY', + value: 'super-secret-value', + }) + expect(mockSetWorkspaceSecret).not.toHaveBeenCalled() + }) + + it('rejects invalid names and empty values before storage', async () => { + const invalidContext = { params: Promise.resolve({ name: 'not-valid' }) } + const res = await PUT( + new NextRequest('http://localhost:3000/api/v2/secrets/not-valid', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, scope: 'workspace', value: '' }), + }), + invalidContext + ) + + expect(res.status).toBe(400) + expect(mockSetWorkspaceSecret).not.toHaveBeenCalled() + }) +}) + +describe('DELETE /api/v2/secrets/[name]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: true }) + mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ + adminKeys: new Set(['STRIPE_API_KEY']), + knownKeys: new Set(['STRIPE_API_KEY']), + }) + mockDeleteWorkspaceSecret.mockResolvedValue(true) + mockDeletePersonalSecret.mockResolvedValue(true) + }) + + it('deletes workspace secret metadata without returning a value', async () => { + const res = await callDelete('workspace') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ name: 'STRIPE_API_KEY', scope: 'workspace', deleted: true }) + expect(JSON.stringify(body)).not.toContain('value') + }) + + it('returns 404 when the scoped secret does not exist', async () => { + mockDeletePersonalSecret.mockResolvedValue(false) + + const res = await callDelete('personal') + + expect(res.status).toBe(404) + }) +}) diff --git a/apps/sim/app/api/v2/secrets/[name]/route.ts b/apps/sim/app/api/v2/secrets/[name]/route.ts new file mode 100644 index 00000000000..a54adb9d509 --- /dev/null +++ b/apps/sim/app/api/v2/secrets/[name]/route.ts @@ -0,0 +1,206 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest, NextResponse } from 'next/server' +import { + type V2Secret, + type V2SecretScope, + v2DeleteSecretContract, + v2SetSecretContract, +} from '@/lib/api/contracts/v2/secrets' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getWorkspaceEnvKeyAdminAccess } from '@/lib/credentials/environment' +import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' +import { + deletePersonalSecret, + deleteWorkspaceSecret, + setPersonalSecret, + setWorkspaceSecret, +} from '@/lib/credentials/secret-values' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { secretCredentialTypes, toV2Secret } from '@/app/api/v2/secrets/utils' + +const logger = createLogger('V2SecretAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ name: string }> +} + +/** Enforces the per-secret admin rule used by the existing workspace editor. */ +async function workspaceSecretAccessError(params: { + workspaceId: string + name: string + userId: string + canWrite: boolean + canAdmin: boolean +}): Promise { + const { workspaceId, name, userId, canWrite, canAdmin } = params + const { adminKeys, knownKeys } = await getWorkspaceEnvKeyAdminAccess({ + workspaceId, + envKeys: [name], + userId, + }) + + if (knownKeys.has(name)) { + return canAdmin || adminKeys.has(name) + ? null + : v2Error('FORBIDDEN', 'Credential admin permission required for this secret') + } + return canWrite ? null : v2Error('FORBIDDEN', 'Write permission required to set this secret') +} + +/** Reads metadata from the credential catalog; encrypted value columns are never selected. */ +async function getSecretMetadata(params: { + workspaceId: string + name: string + scope: V2SecretScope + userId: string + workspaceAccess: Awaited> +}): Promise { + const { workspaceId, name, scope, userId, workspaceAccess } = params + const rows = await listVisibleWorkspaceCredentials({ + workspaceId, + userId, + workspaceAccess, + types: [...secretCredentialTypes(scope)], + search: name, + sortBy: 'displayName', + sortOrder: 'asc', + }) + const row = rows.find( + (candidate) => + candidate.envKey === name && + (scope === 'workspace' + ? candidate.type === 'env_workspace' + : candidate.type === 'env_personal' && candidate.envOwnerUserId === userId) + ) + if (!row) throw new Error(`Secret metadata was not created for ${scope}:${name}`) + return toV2Secret(row, userId) +} + +/** PUT /api/v2/secrets/[name] — Create or replace a write-only secret value. */ +export const PUT = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + try { + const rateLimit = await checkRateLimit(request, 'secret-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2SetSecretContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { name } = parsed.data.params + const { workspaceId, scope, value } = parsed.data.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (scope === 'workspace') { + const permissionError = await workspaceSecretAccessError({ + workspaceId, + name, + userId, + canWrite: workspaceAccess.canWrite, + canAdmin: workspaceAccess.canAdmin, + }) + if (permissionError) return permissionError + } + + const result = + scope === 'workspace' + ? await setWorkspaceSecret({ workspaceId, name, value, userId }) + : await setPersonalSecret({ userId, name, value }) + const secret = await getSecretMetadata({ workspaceId, name, scope, userId, workspaceAccess }) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.ENVIRONMENT_UPDATED, + resourceType: AuditResourceType.ENVIRONMENT, + resourceId: `${scope}:${name}`, + resourceName: name, + description: `${result.created ? 'Created' : 'Updated'} ${scope} secret "${name}"`, + metadata: { scope, name }, + request, + }) + + return v2Data({ secret }, { rateLimit, status: result.created ? 201 : 200 }) + } catch (error) { + logger.error('Error setting secret', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/secrets/[name] — Delete a secret without reading its value. */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + try { + const rateLimit = await checkRateLimit(request, 'secret-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteSecretContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { name } = parsed.data.params + const { workspaceId, scope } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (scope === 'workspace') { + const permissionError = await workspaceSecretAccessError({ + workspaceId, + name, + userId, + canWrite: workspaceAccess.canWrite, + canAdmin: workspaceAccess.canAdmin, + }) + if (permissionError) return permissionError + } + + const deleted = + scope === 'workspace' + ? await deleteWorkspaceSecret({ workspaceId, name }) + : await deletePersonalSecret({ userId, name }) + if (!deleted) return v2Error('NOT_FOUND', 'Secret not found') + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.ENVIRONMENT_DELETED, + resourceType: AuditResourceType.ENVIRONMENT, + resourceId: `${scope}:${name}`, + resourceName: name, + description: `Deleted ${scope} secret "${name}"`, + metadata: { scope, name }, + request, + }) + + return v2Data({ name, scope, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error('Error deleting secret', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/secrets/route.test.ts b/apps/sim/app/api/v2/secrets/route.test.ts new file mode 100644 index 00000000000..08b096ec84e --- /dev/null +++ b/apps/sim/app/api/v2/secrets/route.test.ts @@ -0,0 +1,140 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockCheckWorkspaceAccess, + mockListVisibleWorkspaceCredentials, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockCheckWorkspaceAccess: vi.fn(), + mockListVisibleWorkspaceCredentials: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + +vi.mock('@/lib/credentials/queries', () => ({ + listVisibleWorkspaceCredentials: mockListVisibleWorkspaceCredentials, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +import { GET } from '@/app/api/v2/secrets/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), +} + +function secretCredential(overrides: Record = {}) { + return { + id: 'secret-1', + workspaceId: WORKSPACE_ID, + type: 'env_workspace' as const, + displayName: 'STRIPE_API_KEY', + description: null, + providerId: null, + accountId: null, + envKey: 'STRIPE_API_KEY', + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + hasServiceAccountKey: false, + role: 'admin' as const, + ...overrides, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/secrets?${query}`)) + +describe('GET /api/v2/secrets', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: true }) + mockListVisibleWorkspaceCredentials.mockResolvedValue([secretCredential()]) + }) + + it('lists metadata without a value field', async () => { + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body).toEqual({ + data: [ + { + name: 'STRIPE_API_KEY', + scope: 'workspace', + role: 'admin', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ], + nextCursor: null, + }) + expect(JSON.stringify(body)).not.toContain('value') + expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( + expect.objectContaining({ types: ['env_workspace', 'env_personal'] }) + ) + }) + + it('does not expose another user personal secret', async () => { + mockListVisibleWorkspaceCredentials.mockResolvedValue([ + secretCredential({ + id: 'secret-2', + type: 'env_personal', + displayName: 'PRIVATE_KEY', + envKey: 'PRIVATE_KEY', + envOwnerUserId: 'user-2', + }), + ]) + + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + + expect((await res.json()).data).toEqual([]) + }) + + it('maps scope and sort filters to the credential catalog', async () => { + await callList( + `workspaceId=${WORKSPACE_ID}&scope=workspace&search=STRIPE&sortBy=name&sortOrder=desc` + ) + + expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( + expect.objectContaining({ + types: ['env_workspace'], + search: 'STRIPE', + sortBy: 'displayName', + sortOrder: 'desc', + }) + ) + }) + + it('rejects missing workspace context', async () => { + const res = await callList('') + + expect(res.status).toBe(400) + expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/secrets/route.ts b/apps/sim/app/api/v2/secrets/route.ts new file mode 100644 index 00000000000..3b33ee60741 --- /dev/null +++ b/apps/sim/app/api/v2/secrets/route.ts @@ -0,0 +1,68 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2ListSecretsContract } from '@/lib/api/contracts/v2/secrets' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' +import { secretCredentialTypes, toV2Secret } from '@/app/api/v2/secrets/utils' + +const logger = createLogger('V2SecretsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/secrets — List secret names and metadata without reading their values. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'secrets') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListSecretsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, scope, search, sortBy, sortOrder } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + const credentials = await listVisibleWorkspaceCredentials({ + workspaceId, + userId, + workspaceAccess, + types: [...secretCredentialTypes(scope)], + search, + sortBy: sortBy === 'name' ? 'displayName' : sortBy, + sortOrder, + }) + const secrets = credentials + .filter((row) => row.type === 'env_workspace' || row.envOwnerUserId === userId) + .map((row) => toV2Secret(row, userId)) + + return v2CursorList(secrets, null, { rateLimit }) + } catch (error) { + logger.error('Error listing secrets', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/secrets/utils.ts b/apps/sim/app/api/v2/secrets/utils.ts new file mode 100644 index 00000000000..e92a1d472ce --- /dev/null +++ b/apps/sim/app/api/v2/secrets/utils.ts @@ -0,0 +1,26 @@ +import type { V2Secret, V2SecretScope } 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. */ +export function toV2Secret(row: VisibleWorkspaceCredential, userId: string): V2Secret { + if (!row.envKey || (row.type !== 'env_workspace' && row.type !== 'env_personal')) { + throw new Error(`Credential ${row.id} is not a secret`) + } + if (row.type === 'env_personal' && row.envOwnerUserId !== userId) { + throw new Error(`Personal secret ${row.id} is not owned by the caller`) + } + + return { + name: row.envKey, + scope: row.type === 'env_workspace' ? 'workspace' : 'personal', + role: row.role, + createdAt: row.createdAt.toISOString(), + 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/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index aea3f744be0..62c39081804 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -1,193 +1,42 @@ import { z } from 'zod' -import { - normalizeCredentialEnvKey, - workspaceCredentialRoleSchema, - workspaceCredentialTypeSchema, -} from '@/lib/api/contracts/credentials' -import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { workspaceCredentialRoleSchema } from '@/lib/api/contracts/credentials' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -import { - v2CursorListResponse, - v2DataResponse, - v2SearchSchema, - v2SortFields, -} from '@/lib/api/contracts/v2/shared' -import { getServiceAccountRequiredFields } from '@/lib/credentials/service-account-fields' +import { v2CursorListResponse, v2SearchSchema, v2SortFields } from '@/lib/api/contracts/v2/shared' -/** - * v2 credential contracts. - * - * Secret material — service-account JSON, API tokens, signing secrets, bot - * tokens, client secrets — is accepted on write and **never** returned on read, - * the same treatment MCP request headers get. A read exposes only whether a - * secret is stored (`hasServiceAccountKey`). - * - * `oauth` credentials cannot be created here: they are minted by the interactive - * OAuth connect flow and are bound to an `account` row the caller authorized in - * a browser. They are listed, read, updated, and deleted like any other type. - * - * Credential sharing (`/api/credentials/[id]/members`) is not part of this - * surface. - */ +/** Public credentials are authenticated connections, never raw environment secrets. */ +export const v2CredentialTypeSchema = z.enum(['oauth', 'service_account']) +export type V2CredentialType = z.output -const ENV_VAR_NAME_REGEX = /^[A-Za-z0-9_]+$/ - -/** The types a public caller can create. `oauth` requires the browser connect flow. */ -export const v2CreatableCredentialTypeSchema = z.enum( - ['env_workspace', 'env_personal', 'service_account'], - { error: 'type must be one of env_workspace, env_personal, service_account' } -) -export type V2CreatableCredentialType = z.output - -/** - * Public credential projection. `workspaceId` (supplied by the caller), - * `createdBy`, and every encrypted column are omitted. - */ +/** Public credential metadata. No token, key, or service-account payload is returned. */ export const v2CredentialSchema = z.object({ id: z.string(), - type: workspaceCredentialTypeSchema, + type: v2CredentialTypeSchema, displayName: z.string(), description: z.string().nullable(), - /** The integration this credential authenticates against, when it has one. */ providerId: z.string().nullable(), - /** The linked OAuth account, for `oauth` credentials. */ accountId: z.string().nullable(), - /** The environment-variable name, for `env_workspace` / `env_personal` credentials. */ - envKey: z.string().nullable(), - /** Whether a service-account secret is stored. The secret itself is never returned. */ hasServiceAccountKey: z.boolean(), - /** The caller's role on this credential. */ role: workspaceCredentialRoleSchema, - createdAt: z.string(), - updatedAt: z.string(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), }) export type V2Credential = z.output -/** `{ credential }` payload for single-credential reads and mutations. */ -export const v2CredentialDataSchema = z.object({ credential: v2CredentialSchema }) -export type V2CredentialData = z.output - -export const v2CredentialDeleteDataSchema = z.object({ - id: z.string(), - deleted: z.literal(true), -}) -export type V2CredentialDeleteData = z.output - -export const v2CredentialParamsSchema = z.object({ - id: nonEmptyIdSchema, -}) -export type V2CredentialParams = z.output - -export const v2CredentialWorkspaceQuerySchema = z.object({ - workspaceId: workspaceIdSchema, -}) -export type V2CredentialWorkspaceQuery = z.output - /** A credential's natural name field is `displayName`, so that is what `search` matches. */ export const v2CredentialSortFields = ['displayName', 'createdAt', 'updatedAt'] as const - export type V2CredentialSortBy = (typeof v2CredentialSortFields)[number] -export const v2ListCredentialsQuerySchema = v2CredentialWorkspaceQuerySchema.extend({ - type: workspaceCredentialTypeSchema.optional(), +export const v2ListCredentialsQuerySchema = z.object({ + workspaceId: workspaceIdSchema, + type: v2CredentialTypeSchema.optional(), providerId: z.string().min(1, 'providerId cannot be empty').optional(), search: v2SearchSchema, ...v2SortFields(v2CredentialSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), }) export type V2ListCredentialsQuery = z.output -/** Write-only secret fields, shared by create and the reconnect-style update. */ -const credentialSecretFields = { - /** Write-only. Google-style service-account JSON key. */ - serviceAccountJson: z.string().min(1, 'serviceAccountJson cannot be empty').optional(), - /** Write-only. Slack custom-bot signing secret. */ - signingSecret: z.string().trim().min(1, 'signingSecret cannot be empty').optional(), - /** Write-only. Slack custom-bot token. */ - botToken: z.string().trim().min(1, 'botToken cannot be empty').optional(), - /** Write-only. Atlassian API token. */ - apiToken: z.string().trim().min(1, 'apiToken cannot be empty').optional(), - domain: z.string().trim().min(1, 'domain cannot be empty').optional(), - /** Write-only. Client-credentials service-account id/secret pair. */ - clientId: z.string().trim().min(1, 'clientId cannot be empty').max(512).optional(), - clientSecret: z.string().trim().min(1, 'clientSecret cannot be empty').max(1024).optional(), - orgId: z.string().trim().min(1, 'orgId cannot be empty').max(255).optional(), - dataCenter: z.string().trim().min(1, 'dataCenter cannot be empty').max(32).optional(), -} as const - -export const v2CreateCredentialBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - type: v2CreatableCredentialTypeSchema, - displayName: z.string().trim().min(1).max(255).optional(), - description: z.string().trim().max(500).optional(), - providerId: z.string().trim().min(1, 'providerId cannot be empty').optional(), - /** Required for `env_workspace` / `env_personal`. Accepts `NAME` or `{{NAME}}`. */ - envKey: z.string().trim().min(1, 'envKey cannot be empty').optional(), - ...credentialSecretFields, - }) - .strict() - .superRefine((data, ctx) => { - if (data.type === 'service_account') { - for (const field of getServiceAccountRequiredFields(data.providerId)) { - if (!data[field]) { - ctx.addIssue({ - code: 'custom', - path: [field], - message: `${field} is required for ${data.providerId ?? 'service account'} credentials`, - }) - } - } - return - } - - const normalizedEnvKey = data.envKey ? normalizeCredentialEnvKey(data.envKey) : '' - if (!normalizedEnvKey) { - ctx.addIssue({ - code: 'custom', - path: ['envKey'], - message: 'envKey is required for env credentials', - }) - return - } - if (!ENV_VAR_NAME_REGEX.test(normalizedEnvKey)) { - ctx.addIssue({ - code: 'custom', - path: ['envKey'], - message: 'envKey must contain only letters, numbers, and underscores', - }) - } - }) -export type V2CreateCredentialBody = z.input - -/** - * Update body. Renaming and re-describing apply to any type; the secret fields - * rotate a stored secret in place (the provider re-verifies it). - */ -export const v2UpdateCredentialBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - displayName: z.string().trim().min(1).max(255).optional(), - description: z.string().trim().max(500).nullish(), - ...credentialSecretFields, - }) - .strict() - .superRefine((data, ctx) => { - const { workspaceId: _workspaceId, ...changes } = data - if (Object.values(changes).every((value) => value === undefined)) { - ctx.addIssue({ - code: 'custom', - path: ['displayName'], - message: 'At least one field to change is required', - }) - } - }) -export type V2UpdateCredentialBody = z.input - -/** - * Credential list. A workspace's credential set is small and bounded, so the - * full visible set is returned as a single page (`nextCursor` is always `null`); - * the canonical cursor envelope keeps the v2 list surface uniform. - */ +/** Lists OAuth and service-account connections. Credential mutations are intentionally absent. */ export const v2ListCredentialsContract = defineRouteContract({ method: 'GET', path: '/api/v2/credentials', @@ -197,46 +46,3 @@ export const v2ListCredentialsContract = defineRouteContract({ schema: v2CursorListResponse(v2CredentialSchema), }, }) - -export const v2CreateCredentialContract = defineRouteContract({ - method: 'POST', - path: '/api/v2/credentials', - body: v2CreateCredentialBodySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2CredentialDataSchema), - }, -}) - -export const v2GetCredentialContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/credentials/[id]', - params: v2CredentialParamsSchema, - query: v2CredentialWorkspaceQuerySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2CredentialDataSchema), - }, -}) - -export const v2UpdateCredentialContract = defineRouteContract({ - method: 'PATCH', - path: '/api/v2/credentials/[id]', - params: v2CredentialParamsSchema, - body: v2UpdateCredentialBodySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2CredentialDataSchema), - }, -}) - -export const v2DeleteCredentialContract = defineRouteContract({ - method: 'DELETE', - path: '/api/v2/credentials/[id]', - params: v2CredentialParamsSchema, - query: v2CredentialWorkspaceQuerySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2CredentialDeleteDataSchema), - }, -}) diff --git a/apps/sim/lib/api/contracts/v2/secrets.ts b/apps/sim/lib/api/contracts/v2/secrets.ts new file mode 100644 index 00000000000..d5e7298c992 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/secrets.ts @@ -0,0 +1,109 @@ +import { z } from 'zod' +import { workspaceCredentialRoleSchema } from '@/lib/api/contracts/credentials' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CursorListResponse, + v2DataResponse, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' + +const SECRET_NAME_REGEX = /^[A-Za-z0-9_]+$/ + +export const v2SecretScopeSchema = z.enum(['workspace', 'personal']) +export type V2SecretScope = z.output + +export const v2SecretNameSchema = z + .string() + .trim() + .min(1, 'name is required') + .max(255, 'name is too long') + .regex(SECRET_NAME_REGEX, 'name must contain only letters, numbers, and underscores') + +/** Secret metadata. The stored value is intentionally absent from every response schema. */ +export const v2SecretSchema = z.object({ + name: v2SecretNameSchema, + scope: v2SecretScopeSchema, + role: workspaceCredentialRoleSchema, + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}) +export type V2Secret = z.output + +export const v2SecretDataSchema = z.object({ + secret: v2SecretSchema, +}) +export type V2SecretData = z.output + +export const v2SecretDeleteDataSchema = z.object({ + name: v2SecretNameSchema, + scope: v2SecretScopeSchema, + deleted: z.literal(true), +}) +export type V2SecretDeleteData = z.output + +export const v2SecretSortFields = ['name', 'createdAt', 'updatedAt'] as const +export type V2SecretSortBy = (typeof v2SecretSortFields)[number] + +export const v2ListSecretsQuerySchema = z.object({ + workspaceId: workspaceIdSchema, + scope: v2SecretScopeSchema.optional(), + search: v2SearchSchema, + ...v2SortFields(v2SecretSortFields, { sortBy: 'name', sortOrder: 'asc' }), +}) +export type V2ListSecretsQuery = z.output + +export const v2SecretParamsSchema = z.object({ + name: v2SecretNameSchema, +}) +export type V2SecretParams = z.output + +export const v2SetSecretBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + scope: v2SecretScopeSchema, + value: z.string().min(1, 'value is required').max(65_536, 'value is too long'), + }) + .strict() +export type V2SetSecretBody = z.input + +export const v2DeleteSecretQuerySchema = z.object({ + workspaceId: workspaceIdSchema, + scope: v2SecretScopeSchema, +}) +export type V2DeleteSecretQuery = z.output + +/** Lists names and metadata only. There is deliberately no single-secret GET. */ +export const v2ListSecretsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/secrets', + query: v2ListSecretsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2SecretSchema), + }, +}) + +/** Creates or replaces a secret value without returning it. */ +export const v2SetSecretContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/secrets/[name]', + params: v2SecretParamsSchema, + body: v2SetSecretBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SecretDataSchema), + }, +}) + +export const v2DeleteSecretContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/secrets/[name]', + params: v2SecretParamsSchema, + query: v2DeleteSecretQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2SecretDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index e70be39ebff..bce8c01158e 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -24,18 +24,19 @@ export interface WorkspaceMembership { * Credential-admin status is derived from workspace role at access time, so * members are seeded only for use access (the owner plus permission holders). */ -async function getWorkspaceMembership(workspaceId: string): Promise { - const [workspaceRows, permissionRows] = await Promise.all([ - db - .select({ ownerId: workspace.ownerId }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1), - db - .select({ userId: permissions.userId }) - .from(permissions) - .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))), - ]) +async function getWorkspaceMembership( + workspaceId: string, + executor: DbOrTx = db +): Promise { + const workspaceRows = await executor + .select({ ownerId: workspace.ownerId }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + const permissionRows = await executor + .select({ userId: permissions.userId }) + .from(permissions) + .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) const ownerId = workspaceRows[0]?.ownerId ?? null const memberUserIds = new Set(permissionRows.map((row) => row.userId)) @@ -247,11 +248,12 @@ export async function getUserWorkspaceIds( async function ensureWorkspaceCredentialMemberships( credentialId: string, memberUserIds: string[], - invitedBy: string + invitedBy: string, + executor: DbOrTx = db ) { if (!memberUserIds.length) return - const existingMemberships = await db + const existingMemberships = await executor .select({ userId: credentialMember.userId, status: credentialMember.status, @@ -286,7 +288,7 @@ async function ensureWorkspaceCredentialMemberships( // Existing roles (including manual per-secret overrides) are preserved on // conflict; only membership activeness and a missing joinedAt are reconciled. - await db + await executor .insert(credentialMember) .values(values) .onConflictDoUpdate({ @@ -385,18 +387,21 @@ export async function createWorkspaceEnvCredentials(params: { workspaceId: string newKeys: string[] actingUserId: string + updatedAt?: Date + executor?: DbOrTx }): Promise { const { workspaceId, newKeys, actingUserId } = params + const executor = params.executor ?? db const keys = Array.from(new Set(newKeys.filter(Boolean))) if (keys.length === 0) return - const { ownerId, memberUserIds } = await getWorkspaceMembership(workspaceId) + const { ownerId, memberUserIds } = await getWorkspaceMembership(workspaceId, executor) if (!ownerId) return - const now = new Date() + const now = params.updatedAt ?? new Date() - const inserted = await db + const inserted = await executor .insert(credential) .values( keys.map((envKey) => ({ @@ -431,7 +436,7 @@ export async function createWorkspaceEnvCredentials(params: { })) ) - await db.insert(credentialMember).values(membershipValues).onConflictDoNothing() + await executor.insert(credentialMember).values(membershipValues).onConflictDoNothing() } /** @@ -441,12 +446,14 @@ export async function createWorkspaceEnvCredentials(params: { export async function deleteWorkspaceEnvCredentials(params: { workspaceId: string removedKeys: string[] + executor?: DbOrTx }): Promise { const { workspaceId, removedKeys } = params + const executor = params.executor ?? db const keys = removedKeys.filter(Boolean) if (keys.length === 0) return - await db + await executor .delete(credential) .where( and( @@ -457,6 +464,122 @@ export async function deleteWorkspaceEnvCredentials(params: { ) } +/** + * Ensures one caller-owned personal secret has a credential row in every + * workspace the caller currently belongs to. Unlike the full catalog sync, + * this targeted write cannot delete metadata for a concurrently added secret. + */ +export async function upsertPersonalEnvCredentialForUser(params: { + userId: string + envKey: string + updatedAt: Date + executor?: DbOrTx +}): Promise { + const { userId, envKey, updatedAt } = params + + const upsert = async (tx: DbOrTx) => { + await acquireUserBillingIdentityLock(tx, userId) + const workspaceIds = (await getUserWorkspaceIds(userId, tx)).sort() + if (workspaceIds.length === 0) return + + const credentialValues = workspaceIds.map((workspaceId) => ({ + id: generateId(), + workspaceId, + type: 'env_personal' as const, + displayName: envKey, + envKey, + envOwnerUserId: userId, + createdBy: userId, + createdAt: updatedAt, + updatedAt, + })) + for (const values of chunkArray(credentialValues, PERSONAL_ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) { + await tx.insert(credential).values(values).onConflictDoNothing() + } + + const currentCredentials = await tx + .select({ id: credential.id }) + .from(credential) + .where( + and( + inArray(credential.workspaceId, workspaceIds), + eq(credential.type, 'env_personal'), + eq(credential.envOwnerUserId, userId), + eq(credential.envKey, envKey) + ) + ) + + await tx + .update(credential) + .set({ updatedAt }) + .where( + and( + inArray(credential.workspaceId, workspaceIds), + eq(credential.type, 'env_personal'), + eq(credential.envOwnerUserId, userId), + eq(credential.envKey, envKey) + ) + ) + + if (currentCredentials.length === 0) return + + const membershipValues = currentCredentials.map(({ id: credentialId }) => ({ + id: generateId(), + credentialId, + userId, + role: 'admin' as const, + status: 'active' as const, + joinedAt: updatedAt, + invitedBy: userId, + createdAt: updatedAt, + updatedAt, + })) + for (const values of chunkArray(membershipValues, PERSONAL_ENV_CREDENTIAL_WRITE_CHUNK_SIZE)) { + await tx + .insert(credentialMember) + .values(values) + .onConflictDoUpdate({ + target: [credentialMember.credentialId, credentialMember.userId], + set: { role: 'admin', status: 'active', updatedAt }, + }) + } + } + + if (params.executor) { + await upsert(params.executor) + return + } + await db.transaction(upsert) +} + +/** Deletes one caller-owned personal secret's credential metadata in every workspace. */ +export async function deletePersonalEnvCredentialForUser(params: { + userId: string + envKey: string + executor?: DbOrTx +}): Promise { + const { userId, envKey } = params + + const remove = async (tx: DbOrTx) => { + await acquireUserBillingIdentityLock(tx, userId) + await tx + .delete(credential) + .where( + and( + eq(credential.type, 'env_personal'), + eq(credential.envOwnerUserId, userId), + eq(credential.envKey, envKey) + ) + ) + } + + if (params.executor) { + await remove(params.executor) + return + } + await db.transaction(remove) +} + export async function syncPersonalEnvCredentialsForUser(params: { userId: string envKeys: string[] diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index de0dac667d6..db2572e82de 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -1,7 +1,6 @@ import { db } from '@sim/db' import { credential, credentialMember } from '@sim/db/schema' import { and, type Column, eq, inArray, isNotNull, or } from 'drizzle-orm' -import type { WorkspaceCredentialType } from '@/lib/api/contracts/credentials' import type { V2CredentialSortBy } from '@/lib/api/contracts/v2/credentials' import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' import { listOrderBy, searchFilter } from '@/lib/api/list-query' @@ -54,7 +53,7 @@ export async function listVisibleWorkspaceCredentials(params: { workspaceId: string userId: string workspaceAccess: Pick - type?: WorkspaceCredentialType + types?: CredentialRow['type'][] providerId?: string /** Case-insensitive substring match on the credential display name. */ search?: string @@ -65,7 +64,7 @@ export async function listVisibleWorkspaceCredentials(params: { workspaceId, userId, workspaceAccess, - type, + types, providerId, search, sortBy = 'createdAt', @@ -73,7 +72,7 @@ export async function listVisibleWorkspaceCredentials(params: { } = params const whereClauses = [eq(credential.workspaceId, workspaceId)] - if (type) whereClauses.push(eq(credential.type, type)) + if (types?.length) whereClauses.push(inArray(credential.type, types)) if (providerId) whereClauses.push(eq(credential.providerId, providerId)) const isWorkspaceAdmin = workspaceAccess.canAdmin diff --git a/apps/sim/lib/credentials/secret-values.test.ts b/apps/sim/lib/credentials/secret-values.test.ts new file mode 100644 index 00000000000..4ac01d5854e --- /dev/null +++ b/apps/sim/lib/credentials/secret-values.test.ts @@ -0,0 +1,159 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockEncryptSecret, + mockCreateWorkspaceEnvCredentials, + mockDeleteWorkspaceEnvCredentials, + mockUpsertPersonalEnvCredentialForUser, + mockDeletePersonalEnvCredentialForUser, + mockInvalidateEffectiveDecryptedEnvCache, +} = vi.hoisted(() => ({ + mockEncryptSecret: vi.fn(), + mockCreateWorkspaceEnvCredentials: vi.fn(), + mockDeleteWorkspaceEnvCredentials: vi.fn(), + mockUpsertPersonalEnvCredentialForUser: vi.fn(), + mockDeletePersonalEnvCredentialForUser: vi.fn(), + mockInvalidateEffectiveDecryptedEnvCache: vi.fn(), +})) + +vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: mockEncryptSecret })) +vi.mock('@/lib/credentials/environment', () => ({ + createWorkspaceEnvCredentials: mockCreateWorkspaceEnvCredentials, + deleteWorkspaceEnvCredentials: mockDeleteWorkspaceEnvCredentials, + upsertPersonalEnvCredentialForUser: mockUpsertPersonalEnvCredentialForUser, + deletePersonalEnvCredentialForUser: mockDeletePersonalEnvCredentialForUser, +})) +vi.mock('@/lib/environment/utils', () => ({ + invalidateEffectiveDecryptedEnvCache: mockInvalidateEffectiveDecryptedEnvCache, +})) + +import { + deletePersonalSecret, + deleteWorkspaceSecret, + setPersonalSecret, + setWorkspaceSecret, +} from '@/lib/credentials/secret-values' + +describe('secret value storage', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-new-value' }) + }) + + it('merges a workspace value without decrypting or replacing sibling secrets', async () => { + queueTableRows(schemaMock.workspaceEnvironment, [ + { + id: 'env-1', + variables: { EXISTING_KEY: 'encrypted-existing-value' }, + createdAt: new Date('2024-01-01T00:00:00Z'), + }, + ]) + + const result = await setWorkspaceSecret({ + workspaceId: 'workspace-1', + name: 'NEW_KEY', + value: 'plaintext-new-value', + userId: 'user-1', + }) + + expect(result.created).toBe(true) + expect(mockEncryptSecret).toHaveBeenCalledWith('plaintext-new-value') + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + EXISTING_KEY: 'encrypted-existing-value', + NEW_KEY: 'encrypted-new-value', + }, + }) + ) + expect(mockCreateWorkspaceEnvCredentials).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + newKeys: ['NEW_KEY'], + actingUserId: 'user-1', + updatedAt: expect.any(Date), + executor: expect.any(Object), + }) + ) + expect(mockInvalidateEffectiveDecryptedEnvCache).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + }) + }) + + it('reports an existing workspace value as an update', async () => { + queueTableRows(schemaMock.workspaceEnvironment, [ + { + id: 'env-1', + variables: { EXISTING_KEY: 'encrypted-existing-value' }, + createdAt: new Date('2024-01-01T00:00:00Z'), + }, + ]) + + const result = await setWorkspaceSecret({ + workspaceId: 'workspace-1', + name: 'EXISTING_KEY', + value: 'replacement', + userId: 'user-1', + }) + + expect(result.created).toBe(false) + }) + + it('sets a personal value through caller-owned metadata only', async () => { + queueTableRows(schemaMock.environment, []) + + const result = await setPersonalSecret({ + userId: 'user-1', + name: 'PERSONAL_KEY', + value: 'personal-value', + }) + + expect(result.created).toBe(true) + expect(mockUpsertPersonalEnvCredentialForUser).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + envKey: 'PERSONAL_KEY', + executor: expect.any(Object), + }) + ) + expect(mockInvalidateEffectiveDecryptedEnvCache).toHaveBeenCalledWith({ userId: 'user-1' }) + }) + + it('deletes only the requested workspace value', async () => { + queueTableRows(schemaMock.workspaceEnvironment, [ + { variables: { DELETE_ME: 'cipher-1', KEEP_ME: 'cipher-2' } }, + ]) + + const deleted = await deleteWorkspaceSecret({ + workspaceId: 'workspace-1', + name: 'DELETE_ME', + }) + + expect(deleted).toBe(true) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ variables: { KEEP_ME: 'cipher-2' } }) + ) + expect(mockDeleteWorkspaceEnvCredentials).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + removedKeys: ['DELETE_ME'], + executor: expect.any(Object), + }) + ) + }) + + it('does not mutate metadata when a personal value does not exist', async () => { + queueTableRows(schemaMock.environment, [{ variables: { KEEP_ME: 'cipher' } }]) + + const deleted = await deletePersonalSecret({ userId: 'user-1', name: 'MISSING' }) + + expect(deleted).toBe(false) + expect(mockDeletePersonalEnvCredentialForUser).not.toHaveBeenCalled() + expect(mockInvalidateEffectiveDecryptedEnvCache).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/secret-values.ts b/apps/sim/lib/credentials/secret-values.ts new file mode 100644 index 00000000000..197f1e127f8 --- /dev/null +++ b/apps/sim/lib/credentials/secret-values.ts @@ -0,0 +1,212 @@ +import { db } from '@sim/db' +import { credential, environment, workspaceEnvironment } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq, sql } from 'drizzle-orm' +import { encryptSecret } from '@/lib/core/security/encryption' +import { + createWorkspaceEnvCredentials, + deletePersonalEnvCredentialForUser, + deleteWorkspaceEnvCredentials, + upsertPersonalEnvCredentialForUser, +} from '@/lib/credentials/environment' +import type { DbOrTx } from '@/lib/db/types' +import { invalidateEffectiveDecryptedEnvCache } from '@/lib/environment/utils' + +const SECRET_MAP_LOCK_TIMEOUT_MS = 5_000 + +export interface SecretMutationResult { + created: boolean + updatedAt: Date +} + +async function lockSecretMap(tx: DbOrTx, lockKey: string): Promise { + await tx.execute( + sql`SELECT set_config('lock_timeout', ${`${SECRET_MAP_LOCK_TIMEOUT_MS}ms`}, true)` + ) + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`) +} + +/** Stores one workspace secret without decrypting any existing value. */ +export async function setWorkspaceSecret(params: { + workspaceId: string + name: string + value: string + userId: string +}): Promise { + const { workspaceId, name, value, userId } = params + const { encrypted } = await encryptSecret(value) + const updatedAt = new Date() + + const created = await db.transaction(async (tx) => { + await lockSecretMap(tx, workspaceId) + const [row] = await tx + .select({ + id: workspaceEnvironment.id, + variables: workspaceEnvironment.variables, + createdAt: workspaceEnvironment.createdAt, + }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, workspaceId)) + .limit(1) + + const variables = { ...((row?.variables as Record | null) ?? {}) } + const existed = Object.hasOwn(variables, name) + variables[name] = encrypted + + await tx + .insert(workspaceEnvironment) + .values({ + id: row?.id ?? generateId(), + workspaceId, + variables, + createdAt: row?.createdAt ?? updatedAt, + updatedAt, + }) + .onConflictDoUpdate({ + target: [workspaceEnvironment.workspaceId], + set: { variables, updatedAt }, + }) + + await createWorkspaceEnvCredentials({ + workspaceId, + newKeys: [name], + actingUserId: userId, + updatedAt, + executor: tx, + }) + await tx + .update(credential) + .set({ updatedAt }) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'env_workspace'), + eq(credential.envKey, name) + ) + ) + + return !existed + }) + + invalidateEffectiveDecryptedEnvCache({ workspaceId }) + + return { created, updatedAt } +} + +/** Stores one caller-owned personal secret without decrypting any existing value. */ +export async function setPersonalSecret(params: { + userId: string + name: string + value: string +}): Promise { + const { userId, name, value } = params + const { encrypted } = await encryptSecret(value) + const updatedAt = new Date() + + const created = await db.transaction(async (tx) => { + await lockSecretMap(tx, userId) + const [row] = await tx + .select({ id: environment.id, variables: environment.variables }) + .from(environment) + .where(eq(environment.userId, userId)) + .limit(1) + + const variables = { ...((row?.variables as Record | null) ?? {}) } + const existed = Object.hasOwn(variables, name) + variables[name] = encrypted + + await tx + .insert(environment) + .values({ + id: row?.id ?? generateId(), + userId, + variables, + updatedAt, + }) + .onConflictDoUpdate({ + target: [environment.userId], + set: { variables, updatedAt }, + }) + + await upsertPersonalEnvCredentialForUser({ + userId, + envKey: name, + updatedAt, + executor: tx, + }) + + return !existed + }) + + invalidateEffectiveDecryptedEnvCache({ userId }) + + return { created, updatedAt } +} + +/** Removes one workspace secret without reading or decrypting its value. */ +export async function deleteWorkspaceSecret(params: { + workspaceId: string + name: string +}): Promise { + const { workspaceId, name } = params + + const deleted = await db.transaction(async (tx) => { + await lockSecretMap(tx, workspaceId) + const [row] = await tx + .select({ variables: workspaceEnvironment.variables }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, workspaceId)) + .limit(1) + + const variables = { ...((row?.variables as Record | null) ?? {}) } + if (!Object.hasOwn(variables, name)) return false + delete variables[name] + + await tx + .update(workspaceEnvironment) + .set({ variables, updatedAt: new Date() }) + .where(eq(workspaceEnvironment.workspaceId, workspaceId)) + await deleteWorkspaceEnvCredentials({ + workspaceId, + removedKeys: [name], + executor: tx, + }) + return true + }) + + if (!deleted) return false + invalidateEffectiveDecryptedEnvCache({ workspaceId }) + return true +} + +/** Removes one caller-owned personal secret without reading or decrypting its value. */ +export async function deletePersonalSecret(params: { + userId: string + name: string +}): Promise { + const { userId, name } = params + + const deleted = await db.transaction(async (tx) => { + await lockSecretMap(tx, userId) + const [row] = await tx + .select({ variables: environment.variables }) + .from(environment) + .where(eq(environment.userId, userId)) + .limit(1) + + const variables = { ...((row?.variables as Record | null) ?? {}) } + if (!Object.hasOwn(variables, name)) return false + delete variables[name] + + await tx + .update(environment) + .set({ variables, updatedAt: new Date() }) + .where(eq(environment.userId, userId)) + await deletePersonalEnvCredentialForUser({ userId, envKey: name, executor: tx }) + return true + }) + + if (!deleted) return false + invalidateEffectiveDecryptedEnvCache({ userId }) + return true +} From 15efae78574e4c98b8f54f956cc4532093abbff2 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 6 Aug 2026 23:15:01 -0700 Subject: [PATCH 089/159] feat(api): add workspace metadata and email attribution --- apps/docs/openapi-v2-files-audit.json | 29 +- apps/docs/openapi-v2-logs.json | 11 +- apps/docs/openapi-v2-resources.json | 276 ++++++++++++++++++ apps/docs/openapi-v2-tables.json | 15 +- .../app/api/files/uploads/finalizers.test.ts | 4 + apps/sim/app/api/files/uploads/finalizers.ts | 2 +- apps/sim/app/api/v1/middleware.ts | 2 + apps/sim/app/api/v2/audit-logs/format.test.ts | 3 +- apps/sim/app/api/v2/audit-logs/format.ts | 2 - apps/sim/app/api/v2/audit-logs/route.test.ts | 12 + apps/sim/app/api/v2/audit-logs/route.ts | 6 +- .../v2/files/[fileId]/content/route.test.ts | 26 +- .../api/v2/files/[fileId]/content/route.ts | 2 +- .../v2/files/[fileId]/metadata/route.test.ts | 16 +- .../api/v2/files/[fileId]/metadata/route.ts | 2 +- .../app/api/v2/files/[fileId]/route.test.ts | 10 +- apps/sim/app/api/v2/files/[fileId]/route.ts | 2 +- apps/sim/app/api/v2/files/route.test.ts | 11 +- apps/sim/app/api/v2/files/route.ts | 6 +- .../uploads/[uploadId]/complete/route.ts | 2 +- .../api/v2/files/uploads/[uploadId]/route.ts | 2 +- apps/sim/app/api/v2/files/uploads/route.ts | 2 +- apps/sim/app/api/v2/files/uploads/utils.ts | 6 +- apps/sim/app/api/v2/files/utils.ts | 19 +- .../sim/app/api/v2/logs/[runId]/route.test.ts | 3 + apps/sim/app/api/v2/logs/[runId]/route.ts | 5 +- .../[tableId]/views/[viewId]/route.test.ts | 14 +- .../tables/[tableId]/views/[viewId]/route.ts | 13 +- .../v2/tables/[tableId]/views/route.test.ts | 19 +- .../api/v2/tables/[tableId]/views/route.ts | 24 +- apps/sim/app/api/v2/tables/utils.ts | 4 +- .../workspaces/[workspaceId]/members/route.ts | 78 +++++ .../api/v2/workspaces/[workspaceId]/route.ts | 58 ++++ apps/sim/app/api/v2/workspaces/route.test.ts | 154 ++++++++++ apps/sim/lib/api/contracts/v2/audit-logs.ts | 6 +- apps/sim/lib/api/contracts/v2/files.ts | 2 +- apps/sim/lib/api/contracts/v2/logs.ts | 2 +- apps/sim/lib/api/contracts/v2/tables.ts | 4 +- apps/sim/lib/api/contracts/v2/workspaces.ts | 55 ++++ apps/sim/lib/logs/public-queries.ts | 3 + apps/sim/lib/users/queries.test.ts | 47 +++ apps/sim/lib/users/queries.ts | 44 ++- .../sim/lib/workspaces/public-queries.test.ts | 79 +++++ apps/sim/lib/workspaces/public-queries.ts | 214 ++++++++++++++ 44 files changed, 1215 insertions(+), 81 deletions(-) create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/route.test.ts create mode 100644 apps/sim/lib/api/contracts/v2/workspaces.ts create mode 100644 apps/sim/lib/users/queries.test.ts create mode 100644 apps/sim/lib/workspaces/public-queries.test.ts create mode 100644 apps/sim/lib/workspaces/public-queries.ts diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 1235ef28c4d..a2925d24e50 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -836,12 +836,13 @@ } }, { - "name": "actorId", + "name": "actorEmail", "in": "query", "required": false, - "description": "Filter by the user who performed the action. Must be a member of your organization, otherwise the request returns 400.", + "description": "Filter by the email snapshot recorded for the actor who performed the action.", "schema": { - "type": "string" + "type": "string", + "format": "email" } }, { @@ -914,7 +915,6 @@ { "id": "audit_2c3d4e5f6g", "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "actorId": "user_abc123", "actorName": "Jane Smith", "actorEmail": "jane@example.com", "action": "file.uploaded", @@ -935,7 +935,7 @@ } }, "400": { - "description": "The request was malformed: an invalid query parameter, an `actorId` that is not a member of your organization, or a `workspaceId` that does not belong to your organization.", + "description": "The request was malformed: an invalid query parameter or a `workspaceId` that does not belong to your organization.", "content": { "application/json": { "schema": { @@ -944,7 +944,7 @@ "example": { "error": { "code": "BAD_REQUEST", - "message": "actorId is not a member of your organization" + "message": "Invalid query parameter" } } } @@ -1026,7 +1026,6 @@ "data": { "id": "audit_2c3d4e5f6g", "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "actorId": "user_abc123", "actorName": "Jane Smith", "actorEmail": "jane@example.com", "action": "file.uploaded", @@ -2019,7 +2018,7 @@ "key", "folderPath", "folderPath", - "uploadedBy", + "uploadedByEmail", "uploadedAt", "updatedAt" ], @@ -2050,10 +2049,11 @@ "description": "Storage key for the file.", "example": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv" }, - "uploadedBy": { + "uploadedByEmail": { "type": "string", - "description": "User ID of the uploader.", - "example": "user_abc123" + "format": "email", + "description": "Current email address of the uploader.", + "example": "jane@example.com" }, "uploadedAt": { "type": "string", @@ -2097,7 +2097,6 @@ "required": [ "id", "workspaceId", - "actorId", "actorName", "actorEmail", "action", @@ -2118,11 +2117,6 @@ "description": "The workspace where the action occurred, or null for organization-level actions.", "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" }, - "actorId": { - "type": ["string", "null"], - "description": "The user ID of the person who performed the action, or null when not attributable.", - "example": "user_abc123" - }, "actorName": { "type": ["string", "null"], "description": "Display name of the person who performed the action.", @@ -2130,6 +2124,7 @@ }, "actorEmail": { "type": ["string", "null"], + "format": "email", "description": "Email address of the person who performed the action.", "example": "jane@example.com" }, diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index fcea0f0943b..b1686b1b68d 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -355,7 +355,7 @@ "name": "Customer Support Agent", "description": "Routes incoming support tickets and drafts responses", "folderPath": "/", - "userId": "usr_1a2b3c4d5e", + "ownerEmail": "jane@example.com", "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", "createdAt": "2025-01-10T09:00:00.000Z", "updatedAt": "2025-06-18T16:45:00.000Z", @@ -540,7 +540,7 @@ "name", "description", "folderPath", - "userId", + "ownerEmail", "workspaceId", "createdAt", "updatedAt", @@ -567,10 +567,11 @@ "description": "Canonical containing-folder path. `/` is the workspace root.", "example": "/Engineering" }, - "userId": { + "ownerEmail": { "type": ["string", "null"], - "description": "The user that owns the workflow. null if the workflow is gone.", - "example": "usr_1a2b3c4d5e" + "format": "email", + "description": "Current email address of the workflow owner. null if the workflow is gone.", + "example": "jane@example.com" }, "workspaceId": { "type": ["string", "null"], diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 771419a420d..4bd6b17f8c3 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -21,6 +21,10 @@ } ], "tags": [ + { + "name": "Workspaces", + "description": "Read workspace metadata and its effective member roster (v2 API)." + }, { "name": "MCP Servers", "description": "Register and manage the Model Context Protocol servers a workspace connects to (v2 API)." @@ -48,6 +52,193 @@ } ], "paths": { + "/api/v2/workspaces/{workspaceId}": { + "get": { + "operationId": "getWorkspace", + "summary": "Get Workspace", + "description": "Return public metadata for one accessible workspace. Governance identities, billing identities, and internal membership identifiers are intentionally omitted.", + "tags": ["Workspaces"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/workspaces/YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + } + ], + "responses": { + "200": { + "description": "Workspace metadata.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/Workspace" + } + } + }, + "example": { + "data": { + "id": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Engineering", + "color": "#33C482", + "logoUrl": null, + "mode": "organization", + "memberCount": 14, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/members": { + "get": { + "operationId": "listWorkspaceMembers", + "summary": "List Workspace Members", + "description": "List the workspace's effective members ordered by email. Explicit workspace grants and inherited organization-admin grants are merged. User IDs, membership IDs, role provenance, and billing identities are never returned.", + "tags": ["Workspaces"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/workspaces/YOUR_WORKSPACE_ID/members?limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum members to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor returned by the previous page. Do not parse or construct it.", + "schema": { + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "An email-ordered page of effective workspace members.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkspaceMember" + } + }, + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } + } + }, + "example": { + "data": [ + { + "email": "jane@example.com", + "name": "Jane Smith", + "image": null, + "role": "admin", + "isExternal": false, + "joinedAt": "2026-01-15T10:30:00.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, "/api/v2/mcp-servers": { "get": { "operationId": "listMcpServers", @@ -2050,6 +2241,17 @@ } }, "parameters": { + "WorkspaceIdPath": { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "The workspace to retrieve.", + "schema": { + "type": "string", + "minLength": 1, + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + }, "WorkspaceIdQuery": { "name": "workspaceId", "in": "query", @@ -2337,6 +2539,80 @@ } } }, + "Workspace": { + "type": "object", + "description": "Public workspace metadata. Governance and billing identities are intentionally omitted.", + "required": [ + "id", + "name", + "color", + "logoUrl", + "mode", + "memberCount", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + }, + "logoUrl": { + "type": ["string", "null"] + }, + "mode": { + "type": "string", + "enum": ["personal", "organization", "grandfathered_shared"] + }, + "memberCount": { + "type": "integer", + "minimum": 0, + "description": "Number of effective members, including inherited organization admins." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "WorkspaceMember": { + "type": "object", + "description": "An effective workspace member identified publicly by email.", + "required": ["email", "name", "image", "role", "isExternal", "joinedAt"], + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "name": { + "type": "string" + }, + "image": { + "type": ["string", "null"] + }, + "role": { + "type": "string", + "enum": ["admin", "write", "read"] + }, + "isExternal": { + "type": "boolean" + }, + "joinedAt": { + "type": "string", + "format": "date-time" + } + } + }, "McpServer": { "type": "object", "description": "An MCP server registered in a workspace. Request header values and the OAuth client secret are write-only and never appear here.", diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 40763aa2d9d..e837a5aba9b 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -1614,7 +1614,7 @@ ] }, "isDefault": true, - "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248", + "createdByEmail": "jane@example.com", "createdAt": "2026-01-15T10:30:00.000Z", "updatedAt": "2026-01-16T09:12:00.000Z" } @@ -1737,7 +1737,7 @@ ] }, "isDefault": true, - "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248", + "createdByEmail": "jane@example.com", "createdAt": "2026-01-15T10:30:00.000Z", "updatedAt": "2026-01-16T09:12:00.000Z" } @@ -1836,7 +1836,7 @@ ] }, "isDefault": true, - "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248", + "createdByEmail": "jane@example.com", "createdAt": "2026-01-15T10:30:00.000Z", "updatedAt": "2026-01-16T09:12:00.000Z" } @@ -1965,7 +1965,7 @@ ] }, "isDefault": true, - "createdBy": "user_2f8c1a4e6b0d47539ac1e3b5d7f90248", + "createdByEmail": "jane@example.com", "createdAt": "2026-01-15T10:30:00.000Z", "updatedAt": "2026-01-16T09:12:00.000Z" } @@ -5428,7 +5428,7 @@ "name", "config", "isDefault", - "createdBy", + "createdByEmail", "createdAt", "updatedAt" ], @@ -5453,9 +5453,10 @@ "type": "boolean", "description": "Whether this view is the table’s default. At most one view per table is." }, - "createdBy": { + "createdByEmail": { "type": ["string", "null"], - "description": "User who saved the view, or null when that user no longer exists." + "format": "email", + "description": "Current email address of the user who saved the view, or null when that user no longer exists." }, "createdAt": { "type": "string", diff --git a/apps/sim/app/api/files/uploads/finalizers.test.ts b/apps/sim/app/api/files/uploads/finalizers.test.ts index 8f22cb0b353..a5ed437fe66 100644 --- a/apps/sim/app/api/files/uploads/finalizers.test.ts +++ b/apps/sim/app/api/files/uploads/finalizers.test.ts @@ -64,6 +64,10 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({ vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mockNotifyWorkspaceFilesChanged, })) +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: vi.fn(async () => new Map([['user-1', 'ada@example.com']])), + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, +})) import { finalizeUploadPurpose } from '@/app/api/files/uploads/finalizers' diff --git a/apps/sim/app/api/files/uploads/finalizers.ts b/apps/sim/app/api/files/uploads/finalizers.ts index 6673b9a60d4..3c998f1be30 100644 --- a/apps/sim/app/api/files/uploads/finalizers.ts +++ b/apps/sim/app/api/files/uploads/finalizers.ts @@ -107,7 +107,7 @@ async function finalizeInternalWorkspaceFile( ): Promise { const finalized = await finalizeWorkspaceFileUpload({ session, actor, request, source: 'ui' }) return { - value: toV2File(finalized.file), + value: await toV2File(finalized.file), completedFileId: finalized.file.id, } } diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 6e5a2a0e49b..0398b55364a 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -68,6 +68,8 @@ export type ApiEndpoint = | 'credentials' | 'secrets' | 'secret-detail' + | 'workspaces' + | 'workspace-members' export interface RateLimitResult { allowed: boolean diff --git a/apps/sim/app/api/v2/audit-logs/format.test.ts b/apps/sim/app/api/v2/audit-logs/format.test.ts index cece8dd51df..2d5b398e6a6 100644 --- a/apps/sim/app/api/v2/audit-logs/format.test.ts +++ b/apps/sim/app/api/v2/audit-logs/format.test.ts @@ -6,7 +6,6 @@ describe('formatV2AuditLogEntry', () => { const formatted = formatV2AuditLogEntry({ id: 'audit-1', workspaceId: 'workspace-1', - actorId: 'user-1', actorName: 'Teddy', actorEmail: 'teddy@example.com', action: 'folder.moved', @@ -23,6 +22,8 @@ describe('formatV2AuditLogEntry', () => { }) expect(formatted.resourceId).toBeNull() + expect(formatted).not.toHaveProperty('actorId') + expect(formatted.actorEmail).toBe('teddy@example.com') expect(formatted.metadata).toEqual({ nested: { path: '/Reports' } }) }) }) diff --git a/apps/sim/app/api/v2/audit-logs/format.ts b/apps/sim/app/api/v2/audit-logs/format.ts index b1e7fb88b7c..d0342fc9aa1 100644 --- a/apps/sim/app/api/v2/audit-logs/format.ts +++ b/apps/sim/app/api/v2/audit-logs/format.ts @@ -6,7 +6,6 @@ type DbAuditLog = Pick< InferSelectModel, | 'id' | 'workspaceId' - | 'actorId' | 'actorName' | 'actorEmail' | 'action' @@ -43,7 +42,6 @@ export function formatV2AuditLogEntry(log: DbAuditLog) { return { id: log.id, workspaceId: log.workspaceId, - actorId: log.actorId, actorName: log.actorName, actorEmail: log.actorEmail, action: log.action, 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 9c15eb5e4b5..c80f6e7c406 100644 --- a/apps/sim/app/api/v2/audit-logs/route.test.ts +++ b/apps/sim/app/api/v2/audit-logs/route.test.ts @@ -91,4 +91,16 @@ describe('GET /api/v2/audit-logs', () => { expect(mockResolveEnterpriseAuditAccess).toHaveBeenCalledWith('admin-1', 'org-1') expect(mockQueryAuditLogs).toHaveBeenCalled() }) + + it('filters by the public actor email without requiring a user ID', async () => { + const response = await callGet('?organizationId=org-1&actorEmail=ada%40example.com') + + expect(response.status).toBe(200) + expect(mockBuildFilterConditions).toHaveBeenCalledWith( + expect.objectContaining({ actorEmail: 'ada@example.com' }) + ) + expect(mockBuildFilterConditions).toHaveBeenCalledWith( + expect.not.objectContaining({ actorId: expect.anything() }) + ) + }) }) diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts index 596adff6680..c6b730b8087 100644 --- a/apps/sim/app/api/v2/audit-logs/route.ts +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -67,10 +67,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { organizationId, orgMemberIds } = authResult.context - if (params.actorId && !orgMemberIds.includes(params.actorId)) { - return v2Error('BAD_REQUEST', 'actorId is not a member of your organization') - } - const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) if (params.workspaceId && !orgWorkspaceIds.includes(params.workspaceId)) { @@ -88,7 +84,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { resourceType: params.resourceType, resourceId: params.resourceId, workspaceId: params.workspaceId, - actorId: params.actorId, + actorEmail: params.actorEmail, startDate: params.startDate, endDate: params.endDate, }) diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts index 3ce03cf3cdd..5ed44af26fb 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -4,13 +4,22 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformUpdateContent } = vi.hoisted( - () => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformUpdateContent: vi.fn(), - }) -) +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockPerformUpdateContent, + mockGetUserEmailsByIds, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockPerformUpdateContent: vi.fn(), + mockGetUserEmailsByIds: vi.fn(), +})) + +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: mockGetUserEmailsByIds, + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, +})) vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit, @@ -82,6 +91,7 @@ describe('PUT /api/v2/files/[fileId]/content', () => { mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) mockPerformUpdateContent.mockResolvedValue({ success: true, file: RECORD }) + mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) it('returns 404 when the v2 API surface flag is off', async () => { @@ -177,7 +187,7 @@ describe('PUT /api/v2/files/[fileId]/content', () => { type: 'text/csv', key: 'workspace/ws/1-x-data.csv', folderPath: '/', - uploadedBy: 'user-1', + uploadedByEmail: 'ada@example.com', uploadedAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-03T00:00:00.000Z', }) diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts index bb03695aa5f..10fab0ec45a 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -81,7 +81,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: FileRo ) } - return v2Data(toV2File(result.file), { rateLimit }) + return v2Data(await toV2File(result.file), { rateLimit }) } catch (error) { logger.error('Error updating file content', { error: getErrorMessage(error, 'Unknown error') }) return v2Error('INTERNAL_ERROR', 'Internal server error') diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts index 309bb0b34c1..69dd8f2dc7c 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts @@ -4,10 +4,21 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockGetWorkspaceFile } = vi.hoisted(() => ({ +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetWorkspaceFile, + mockGetUserEmailsByIds, +} = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), mockGetWorkspaceFile: vi.fn(), + mockGetUserEmailsByIds: vi.fn(), +})) + +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: mockGetUserEmailsByIds, + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -65,6 +76,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) mockGetWorkspaceFile.mockResolvedValue(buildRecord()) + mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) it('400s when workspaceId is missing', async () => { @@ -108,7 +120,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { type: 'text/csv', key: 'workspace/ws/1-x-data.csv', folderPath: '/', - uploadedBy: 'user-1', + uploadedByEmail: 'ada@example.com', uploadedAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-02T00:00:00.000Z', }, diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts index 69d1b8ce243..2ba48d09070 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts @@ -50,7 +50,7 @@ export const GET = withRouteHandler( const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) if (!file) return v2Error('NOT_FOUND', 'File not found') - return v2Data(toV2File(file), { rateLimit }) + return v2Data(await toV2File(file), { rateLimit }) } catch (error) { logger.error('Error fetching file metadata', { error: getErrorMessage(error, 'Unknown error'), 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 eb0f2b3738a..9707809e2af 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -14,6 +14,7 @@ const { mockFetchWorkspaceFileBuffer, mockPerformRename, mockPerformDelete, + mockGetUserEmailsByIds, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), @@ -21,6 +22,12 @@ const { mockFetchWorkspaceFileBuffer: vi.fn(), mockPerformRename: vi.fn(), mockPerformDelete: vi.fn(), + mockGetUserEmailsByIds: vi.fn(), +})) + +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: mockGetUserEmailsByIds, + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -107,6 +114,7 @@ describe('GET /api/v2/files/[fileId]', () => { mockResolveWorkspaceAccess.mockResolvedValue(null) mockGetWorkspaceFile.mockResolvedValue(buildRecord()) mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('id,name\n')) + mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) it('returns 404 when the v2 API surface flag is off', async () => { @@ -218,7 +226,7 @@ describe('PATCH /api/v2/files/[fileId]', () => { type: 'text/csv', key: 'workspace/ws/1-x-data.csv', folderPath: '/', - uploadedBy: 'user-1', + uploadedByEmail: 'ada@example.com', uploadedAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-02T00:00:00.000Z', }) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index 133d5debeb7..24c9ddc56ee 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -121,7 +121,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: File ) } - return v2Data(toV2File(result.file), { rateLimit }) + return v2Data(await toV2File(result.file), { rateLimit }) } catch (error) { logger.error('Error renaming file', { error: getErrorMessage(error, 'Unknown error') }) return v2Error('INTERNAL_ERROR', 'Internal server error') diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index d4e4cee44ed..7bf7aa9f384 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -11,6 +11,7 @@ const { mockResolveWorkspaceAccess, mockV2ApiGateError, mockLoadActiveFolderPathIndex, + mockGetUserEmailsByIds, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockPerformCreateWorkspaceFile: vi.fn(), @@ -18,6 +19,7 @@ const { mockQueryWorkspaceFiles: vi.fn(), mockV2ApiGateError: vi.fn().mockResolvedValue(null), mockLoadActiveFolderPathIndex: vi.fn(), + mockGetUserEmailsByIds: vi.fn(), })) vi.mock('@/app/api/v1/middleware', () => ({ @@ -42,6 +44,11 @@ vi.mock('@/lib/workspace-files/orchestration', () => ({ performCreateWorkspaceFile: mockPerformCreateWorkspaceFile, })) +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: mockGetUserEmailsByIds, + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, +})) + import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' import { GET, POST } from '@/app/api/v2/files/route' @@ -111,6 +118,7 @@ describe('GET /api/v2/files', () => { mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) mockResolveWorkspaceAccess.mockResolvedValue(null) mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null }) + mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) mockLoadActiveFolderPathIndex.mockResolvedValue({ rowById: new Map([['fold_1', { id: 'fold_1', name: 'Reports', parentId: null }]]), pathById: new Map([['fold_1', '/Reports']]), @@ -183,7 +191,7 @@ describe('GET /api/v2/files', () => { type: 'text/csv', key: 'workspace/ws/1-x-data.csv', folderPath: '/Reports/Q1', - uploadedBy: 'user-1', + uploadedByEmail: 'ada@example.com', uploadedAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-02T00:00:00.000Z', }, @@ -315,6 +323,7 @@ describe('POST /api/v2/files', () => { success: true, file: buildRecord({ name: 'untitled.md', size: 0, type: 'text/markdown' }), }) + mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) it('creates an empty exact-name file with an inferred MIME type', async () => { diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index 09ba08501d3..a87882803af 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -17,7 +17,7 @@ import { performCreateWorkspaceFile, } from '@/lib/workspace-files/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2File } from '@/app/api/v2/files/utils' +import { toV2File, toV2Files } from '@/app/api/v2/files/utils' import { resolveFolderPathId } from '@/app/api/v2/lib/folders' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { @@ -93,7 +93,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { after: decoded.status === 'ok' ? decoded.keys : undefined, }) - const items: V2File[] = files.map(toV2File) + const items: V2File[] = await toV2Files(files) const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null return v2CursorList(items, nextCursor, { rateLimit }) @@ -154,7 +154,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - return v2Data(toV2File(result.file), { rateLimit, status: 201 }) + return v2Data(await toV2File(result.file), { rateLimit, status: 201 }) } catch (error) { logger.error('Error creating file', { error: getErrorMessage(error, 'Unknown error') }) return v2Error('INTERNAL_ERROR', 'Internal server error') diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts index 1378f35502d..c6339746dfb 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts @@ -59,7 +59,7 @@ export const POST = withRouteHandler( return { value: finalized.file, completedFileId: finalized.file.id } }, }) - return v2Data(toV2FileUpload(result.session, result.value), { rateLimit }) + return v2Data(await toV2FileUpload(result.session, result.value), { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts index 3ab1354f063..f06579dcc35 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts @@ -47,7 +47,7 @@ export const DELETE = withRouteHandler( uploadToken: parsed.data.headers['upload-token'], }) const aborted = await abortUploadSession(session) - return v2Data(toV2FileUpload(aborted, null), { rateLimit }) + return v2Data(await toV2FileUpload(aborted, null), { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified diff --git a/apps/sim/app/api/v2/files/uploads/route.ts b/apps/sim/app/api/v2/files/uploads/route.ts index b57f128b19a..b1daf54bcd8 100644 --- a/apps/sim/app/api/v2/files/uploads/route.ts +++ b/apps/sim/app/api/v2/files/uploads/route.ts @@ -58,7 +58,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) return v2Data( { - session: toV2FileUpload(session, null), + session: await toV2FileUpload(session, null), uploadToken: session.uploadToken, transfer: session.transfer, }, diff --git a/apps/sim/app/api/v2/files/uploads/utils.ts b/apps/sim/app/api/v2/files/uploads/utils.ts index e90c25c1fda..dad280cfca8 100644 --- a/apps/sim/app/api/v2/files/uploads/utils.ts +++ b/apps/sim/app/api/v2/files/uploads/utils.ts @@ -4,10 +4,10 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' import { toV2File } from '@/app/api/v2/files/utils' -export function toV2FileUpload( +export async function toV2FileUpload( session: UploadSessionRecord, file: WorkspaceFileRecord | null -): V2FileUpload { +): Promise { return { id: session.id, status: uploadStatus(session.status), @@ -16,7 +16,7 @@ export function toV2FileUpload( size: session.fileSize, expiresAt: session.expiresAt.toISOString(), error: session.error, - file: file ? toV2File(file) : null, + file: file ? await toV2File(file) : null, } } diff --git a/apps/sim/app/api/v2/files/utils.ts b/apps/sim/app/api/v2/files/utils.ts index f5003680ad4..2e11d23f03d 100644 --- a/apps/sim/app/api/v2/files/utils.ts +++ b/apps/sim/app/api/v2/files/utils.ts @@ -1,6 +1,7 @@ import type { V2File } from '@/lib/api/contracts/v2/files' import { buildFolderPath } from '@/lib/folders/paths' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { getUserEmailsByIds, requireResolvedUserEmail } from '@/lib/users/queries' /** Shared serialization for the v2 files surface. */ @@ -8,7 +9,7 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' * Public file projection. `workspaceId` (already known to the caller, who * supplied it) and the internal storage/versioning columns are not exposed. */ -export function toV2File(record: WorkspaceFileRecord): V2File { +function serializeV2File(record: WorkspaceFileRecord, uploadedByEmail: string): V2File { const folderPath = record.folderId ? buildFolderPath( (() => { @@ -25,8 +26,22 @@ export function toV2File(record: WorkspaceFileRecord): V2File { type: record.type, key: record.key, folderPath, - uploadedBy: record.uploadedBy, + uploadedByEmail, uploadedAt: record.uploadedAt.toISOString(), updatedAt: record.updatedAt.toISOString(), } } + +/** Resolves and serializes one public file attribution. */ +export async function toV2File(record: WorkspaceFileRecord): Promise { + const emailByUserId = await getUserEmailsByIds([record.uploadedBy]) + return serializeV2File(record, requireResolvedUserEmail(emailByUserId, record.uploadedBy)) +} + +/** Resolves a file page's attribution in one query before serialization. */ +export async function toV2Files(records: WorkspaceFileRecord[]): Promise { + const emailByUserId = await getUserEmailsByIds(records.map((record) => record.uploadedBy)) + return records.map((record) => + serializeV2File(record, requireResolvedUserEmail(emailByUserId, record.uploadedBy)) + ) +} diff --git a/apps/sim/app/api/v2/logs/[runId]/route.test.ts b/apps/sim/app/api/v2/logs/[runId]/route.test.ts index 3ca30af1db9..df621d35169 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.test.ts @@ -66,6 +66,7 @@ const LOG_ROW = { workflowDescription: 'Handles support requests', workflowFolderId: null, workflowUserId: 'user-1', + workflowOwnerEmail: 'ada@example.com', workflowWorkspaceId: 'workspace-1', workflowCreatedAt: new Date('2023-12-01T00:00:00Z'), workflowUpdatedAt: new Date('2023-12-02T00:00:00Z'), @@ -114,6 +115,8 @@ describe('GET /api/v2/logs/[runId]', () => { expect(body.data.traceSpans).toEqual(traceSpans) expect(body.data.finalOutput).toEqual({ answer: 'done' }) expect(body.data.workflowState).toEqual({ blocks: {}, edges: [] }) + expect(body.data.workflow.ownerEmail).toBe('ada@example.com') + expect(body.data.workflow).not.toHaveProperty('userId') }) it('returns empty diagnostic collections when the execution produced none', async () => { diff --git a/apps/sim/app/api/v2/logs/[runId]/route.ts b/apps/sim/app/api/v2/logs/[runId]/route.ts index 4151ec10d93..a1753f70769 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.ts @@ -54,6 +54,9 @@ export const GET = withRouteHandler( log.executionData as Record | null, { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } ) + if (log.workflowUserId && !log.workflowOwnerEmail) { + throw new Error(`Unable to resolve workflow owner email for ${log.workflowUserId}`) + } const detail: V2LogDetail = { runId: log.executionId, @@ -73,7 +76,7 @@ export const GET = withRouteHandler( folderPath: log.workflowFolderId ? (folderIndex.pathById.get(log.workflowFolderId) ?? null) : null, - userId: log.workflowUserId, + ownerEmail: log.workflowOwnerEmail, workspaceId: log.workflowWorkspaceId, createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null, updatedAt: log.workflowUpdatedAt ? log.workflowUpdatedAt.toISOString() : null, 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 25488f0ad26..be8a5e6bd33 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 @@ -16,6 +16,7 @@ const { mockUpdateTableView, mockDeleteTableView, mockGateError, + mockGetRequiredUserEmail, TableViewValidationError, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), @@ -25,6 +26,7 @@ const { mockUpdateTableView: vi.fn(), mockDeleteTableView: vi.fn(), mockGateError: vi.fn(), + mockGetRequiredUserEmail: vi.fn(), TableViewValidationError: class TableViewValidationError extends Error {}, })) @@ -49,6 +51,10 @@ vi.mock('@/lib/table', () => ({ vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) +vi.mock('@/lib/users/queries', () => ({ + getRequiredUserEmail: mockGetRequiredUserEmail, +})) + import { DELETE, GET, PATCH } from '@/app/api/v2/tables/[tableId]/views/[viewId]/route' const COLUMNS = [{ id: 'col-1', name: 'status', type: 'string' }] @@ -64,7 +70,12 @@ const VIEW = { updatedAt: new Date('2026-01-02T00:00:00Z'), } const API_VIEW = { - ...VIEW, + id: VIEW.id, + tableId: VIEW.tableId, + name: VIEW.name, + config: VIEW.config, + isDefault: VIEW.isDefault, + createdByEmail: 'ada@example.com', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-02T00:00:00.000Z', } @@ -116,6 +127,7 @@ beforeEach(() => { mockResolveWorkspaceScope.mockResolvedValue(null) mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) mockGateError.mockResolvedValue(null) + mockGetRequiredUserEmail.mockResolvedValue('ada@example.com') }) describe('GET /api/v2/tables/[tableId]/views/[viewId]', () => { 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 ba29f7665c0..85e9921266b 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 @@ -16,6 +16,7 @@ import { TableViewValidationError, updateTableView, } from '@/lib/table' +import { getRequiredUserEmail } from '@/lib/users/queries' import { checkAccess } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -70,7 +71,10 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableV const view = await getTableView(viewId, tableId, (result.table.schema as TableSchema).columns) if (!view) return v2Error('NOT_FOUND', 'View not found') - return v2Data({ view: toApiView(view) }, { rateLimit }) + return v2Data( + { view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null) }, + { rateLimit } + ) } catch (error) { logger.error(`[${requestId}] Error getting table view`, { error: getErrorMessage(error, 'Unknown error'), @@ -125,7 +129,12 @@ export const PATCH = withRouteHandler( }) if (!view) return v2Error('NOT_FOUND', 'View not found') - return v2Data({ view: toApiView(view) }, { rateLimit }) + return v2Data( + { + view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null), + }, + { rateLimit } + ) } catch (error) { if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) 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 8a789e0de94..82ca191255e 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 @@ -14,6 +14,8 @@ const { mockListTableViews, mockCreateTableView, mockGateError, + mockGetUserEmailsByIds, + mockGetRequiredUserEmail, TableViewValidationError, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), @@ -22,6 +24,8 @@ const { mockListTableViews: vi.fn(), mockCreateTableView: vi.fn(), mockGateError: vi.fn(), + mockGetUserEmailsByIds: vi.fn(), + mockGetRequiredUserEmail: vi.fn(), TableViewValidationError: class TableViewValidationError extends Error {}, })) @@ -45,6 +49,12 @@ vi.mock('@/lib/table', () => ({ vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: mockGetUserEmailsByIds, + getRequiredUserEmail: mockGetRequiredUserEmail, + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, +})) + import { GET, POST } from '@/app/api/v2/tables/[tableId]/views/route' const COLUMNS = [{ id: 'col-1', name: 'status', type: 'string' }] @@ -60,7 +70,12 @@ const VIEW = { updatedAt: new Date('2026-01-02T00:00:00Z'), } const API_VIEW = { - ...VIEW, + id: VIEW.id, + tableId: VIEW.tableId, + name: VIEW.name, + config: VIEW.config, + isDefault: VIEW.isDefault, + createdByEmail: 'ada@example.com', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-02T00:00:00.000Z', } @@ -98,6 +113,8 @@ beforeEach(() => { mockResolveWorkspaceScope.mockResolvedValue(null) mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) mockGateError.mockResolvedValue(null) + mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) + mockGetRequiredUserEmail.mockResolvedValue('ada@example.com') }) describe('GET /api/v2/tables/[tableId]/views', () => { 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 be0dcbe0fa7..0dacc9e619f 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts @@ -7,6 +7,11 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { TableSchema } from '@/lib/table' import { createTableView, listTableViews, TableViewValidationError } from '@/lib/table' +import { + getRequiredUserEmail, + getUserEmailsByIds, + requireResolvedUserEmail, +} from '@/lib/users/queries' import { checkAccess } from '@/app/api/table/utils' import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' @@ -66,7 +71,19 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR const views = await listTableViews(tableId, (result.table.schema as TableSchema).columns) - return v2CursorList(views.map(toApiView), null, { rateLimit }) + const emailByUserId = await getUserEmailsByIds( + views.flatMap((view) => (view.createdBy ? [view.createdBy] : [])) + ) + return v2CursorList( + views.map((view) => + toApiView( + view, + view.createdBy ? requireResolvedUserEmail(emailByUserId, view.createdBy) : null + ) + ), + null, + { rateLimit } + ) } catch (error) { logger.error(`[${requestId}] Error listing table views`, { error: getErrorMessage(error, 'Unknown error'), @@ -115,7 +132,10 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Table columns: (result.table.schema as TableSchema).columns, }) - return v2Data({ view: toApiView(view) }, { rateLimit, status: 201 }) + return v2Data( + { view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null) }, + { rateLimit, status: 201 } + ) } catch (error) { if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index d3cd6dfe6e5..6f49b8deb77 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -86,14 +86,14 @@ export function toApiTable(table: TableDefinition, folderPath: string) { * Normalized public view shape. Identical to the stored view except that the * timestamps are ISO strings, matching every other v2 payload. */ -export function toApiView(view: TableView) { +export function toApiView(view: TableView, createdByEmail: string | null) { return { id: view.id, tableId: view.tableId, name: view.name, config: view.config, isDefault: view.isDefault, - createdBy: view.createdBy, + createdByEmail, createdAt: toIso(view.createdAt), updatedAt: toIso(view.updatedAt), } diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts new file mode 100644 index 00000000000..856aecf5992 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts @@ -0,0 +1,78 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2ListWorkspaceMembersContract, + v2WorkspaceMemberCursorSchema, +} from '@/lib/api/contracts/v2/workspaces' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { queryPublicWorkspaceMembers } from '@/lib/workspaces/public-queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkspaceMembersAPI') + +interface WorkspaceMembersRouteParams { + params: Promise<{ workspaceId: string }> +} + +/** GET /api/v2/workspaces/[workspaceId]/members — Effective member roster. */ +export const GET = withRouteHandler( + async (request: NextRequest, context: WorkspaceMembersRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'workspace-members') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2ListWorkspaceMembersContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.params + const { cursor, limit } = parsed.data.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const decoded = cursor + ? v2WorkspaceMemberCursorSchema.safeParse(decodeCursor(cursor)) + : undefined + if (decoded && !decoded.success) return v2Error('BAD_REQUEST', 'Invalid cursor') + + const page = await queryPublicWorkspaceMembers(workspaceId, { + limit, + afterEmail: decoded?.data.email, + }) + if (!page) return v2Error('NOT_FOUND', 'Workspace not found') + + return v2CursorList( + page.members.map((member) => ({ + email: member.email, + name: member.name, + image: member.image, + role: member.role, + isExternal: member.isExternal, + joinedAt: member.joinedAt.toISOString(), + })), + page.nextEmail ? encodeCursor({ email: page.nextEmail }) : null, + { rateLimit } + ) + } catch (error) { + logger.error('Failed to list workspace members', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts new file mode 100644 index 00000000000..6776b83b6f7 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts @@ -0,0 +1,58 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2GetWorkspaceContract } from '@/lib/api/contracts/v2/workspaces' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getPublicWorkspaceDetail } from '@/lib/workspaces/public-queries' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkspaceDetailAPI') + +interface WorkspaceRouteParams { + params: Promise<{ workspaceId: string }> +} + +/** GET /api/v2/workspaces/[workspaceId] — Public workspace metadata. */ +export const GET = withRouteHandler(async (request: NextRequest, context: WorkspaceRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'workspaces') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetWorkspaceContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.params + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const workspace = await getPublicWorkspaceDetail(workspaceId) + if (!workspace) return v2Error('NOT_FOUND', 'Workspace not found') + + return v2Data( + { + ...workspace, + createdAt: workspace.createdAt.toISOString(), + updatedAt: workspace.updatedAt.toISOString(), + }, + { rateLimit } + ) + } catch (error) { + logger.error('Failed to get workspace', { error: getErrorMessage(error) }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/workspaces/route.test.ts b/apps/sim/app/api/v2/workspaces/route.test.ts new file mode 100644 index 00000000000..84ffe4c78a4 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/route.test.ts @@ -0,0 +1,154 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockGetPublicWorkspaceDetail, + mockQueryPublicWorkspaceMembers, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetPublicWorkspaceDetail: vi.fn(), + mockQueryPublicWorkspaceMembers: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/workspaces/public-queries', () => ({ + getPublicWorkspaceDetail: mockGetPublicWorkspaceDetail, + queryPublicWorkspaceMembers: mockQueryPublicWorkspaceMembers, +})) + +import { GET as listWorkspaceMembers } from '@/app/api/v2/workspaces/[workspaceId]/members/route' +import { GET as getWorkspace } from '@/app/api/v2/workspaces/[workspaceId]/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const RATE_LIMIT_OK = { + allowed: true, + userId: 'user-1', + workspaceId: WORKSPACE_ID, + keyType: 'workspace', + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-06T01:00:00.000Z'), +} +const context = () => ({ params: Promise.resolve({ workspaceId: WORKSPACE_ID }) }) + +function callWorkspace() { + return getWorkspace( + new NextRequest(`http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}`), + context() + ) +} + +function callMembers(query = '') { + return listWorkspaceMembers( + new NextRequest(`http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}/members${query}`), + context() + ) +} + +beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetPublicWorkspaceDetail.mockResolvedValue({ + id: WORKSPACE_ID, + name: 'Engineering', + color: '#33C482', + logoUrl: null, + mode: 'organization', + memberCount: 2, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + }) + mockQueryPublicWorkspaceMembers.mockResolvedValue({ + members: [ + { + userId: 'user-1', + email: 'ada@example.com', + name: 'Ada', + image: null, + role: 'admin', + isExternal: false, + joinedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ], + nextEmail: 'ada@example.com', + }) +}) + +describe('GET /api/v2/workspaces/[workspaceId]', () => { + it('returns public metadata without governance or billing identities', async () => { + const response = await callWorkspace() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual({ + id: WORKSPACE_ID, + name: 'Engineering', + color: '#33C482', + logoUrl: null, + mode: 'organization', + memberCount: 2, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }) + expect(body.data).not.toHaveProperty('ownerId') + expect(body.data).not.toHaveProperty('billedAccountUserId') + }) + + it('enforces workspace read access before loading metadata', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await callWorkspace() + + expect(response.status).toBe(403) + expect(mockGetPublicWorkspaceDetail).not.toHaveBeenCalled() + }) +}) + +describe('GET /api/v2/workspaces/[workspaceId]/members', () => { + it('returns email-attributed members and keeps user IDs out of data and cursors', async () => { + const response = await callMembers('?limit=1') + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual([ + { + email: 'ada@example.com', + name: 'Ada', + image: null, + role: 'admin', + isExternal: false, + joinedAt: '2026-01-01T00:00:00.000Z', + }, + ]) + expect(body.data[0]).not.toHaveProperty('userId') + expect(JSON.parse(Buffer.from(body.nextCursor, 'base64').toString())).toEqual({ + email: 'ada@example.com', + }) + }) + + it('rejects malformed cursors without querying members', async () => { + const response = await callMembers('?cursor=not-a-cursor') + + expect(response.status).toBe(400) + expect(mockQueryPublicWorkspaceMembers).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/audit-logs.ts b/apps/sim/lib/api/contracts/v2/audit-logs.ts index c91d1f04971..4c67cedc3f5 100644 --- a/apps/sim/lib/api/contracts/v2/audit-logs.ts +++ b/apps/sim/lib/api/contracts/v2/audit-logs.ts @@ -24,9 +24,8 @@ import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/sha export const v2AuditLogEntrySchema = z.object({ id: z.string(), workspaceId: z.string().nullable(), - actorId: z.string().nullable(), actorName: z.string().nullable(), - actorEmail: z.string().nullable(), + actorEmail: z.email().nullable(), action: z.string(), resourceType: z.string(), resourceId: z.string().nullable(), @@ -39,7 +38,8 @@ export const v2AuditLogEntrySchema = z.object({ export type V2AuditLogEntry = z.output export const v2ListAuditLogsQuerySchema = v1ListAuditLogsQuerySchema - .extend({ organizationId: organizationIdSchema }) + .omit({ actorId: true }) + .extend({ organizationId: organizationIdSchema, actorEmail: z.email().optional() }) .strict() export const v2GetAuditLogQuerySchema = z.object({ organizationId: organizationIdSchema }).strict() diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 4286278193b..7305fc9690e 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -50,7 +50,7 @@ export const v2FileSchema = z.object({ key: z.string(), /** Canonical containing-folder path; `/` means the workspace root. */ folderPath: v2FolderPathSchema, - uploadedBy: z.string(), + uploadedByEmail: z.email(), /** ISO-8601 timestamp. */ uploadedAt: z.string(), /** ISO-8601 timestamp; advances on content and metadata writes alike. */ diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index 17842d574cf..f156fb92ba9 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -66,7 +66,7 @@ export const v2LogDetailSchema = z.object({ name: z.string(), description: z.string().nullable(), folderPath: v2FolderPathSchema.nullable(), - userId: z.string().nullable(), + ownerEmail: z.email().nullable(), workspaceId: z.string().nullable(), createdAt: z.string().nullable(), updatedAt: z.string().nullable(), diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 84e35ef40ef..ff4fe06f853 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -609,8 +609,8 @@ export const v2ApiViewSchema = z.object({ name: z.string(), config: tableViewConfigSchema, isDefault: z.boolean(), - /** User who saved the view; `null` for views whose author is gone. */ - createdBy: z.string().nullable(), + /** Current email of the user who saved the view; `null` for a removed author. */ + createdByEmail: z.email().nullable(), createdAt: z.string(), updatedAt: z.string(), }) diff --git a/apps/sim/lib/api/contracts/v2/workspaces.ts b/apps/sim/lib/api/contracts/v2/workspaces.ts new file mode 100644 index 00000000000..fb34338e400 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/workspaces.ts @@ -0,0 +1,55 @@ +import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +export const v2WorkspaceParamsSchema = z.object({ workspaceId: workspaceIdSchema }).strict() +export type V2WorkspaceParams = z.output + +export const v2WorkspaceSchema = z.object({ + id: workspaceIdSchema, + name: z.string(), + color: z.string(), + logoUrl: z.string().nullable(), + mode: z.enum(['personal', 'organization', 'grandfathered_shared']), + memberCount: z.number().int().nonnegative(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}) +export type V2Workspace = z.output + +export const v2WorkspaceMemberSchema = z.object({ + email: z.email(), + name: z.string(), + image: z.string().nullable(), + role: z.enum(['admin', 'write', 'read']), + isExternal: z.boolean(), + joinedAt: z.string().datetime(), +}) +export type V2WorkspaceMember = z.output + +export const v2ListWorkspaceMembersQuerySchema = z + .object({ + limit: z.coerce.number().int().min(1).max(100).optional().default(50), + cursor: z.string().min(1).optional(), + }) + .strict() +export type V2ListWorkspaceMembersQuery = z.output + +export const v2WorkspaceMemberCursorSchema = z.object({ email: z.email() }).strict() +export type V2WorkspaceMemberCursor = z.output + +export const v2GetWorkspaceContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]', + params: v2WorkspaceParamsSchema, + response: { mode: 'json', schema: v2DataResponse(v2WorkspaceSchema) }, +}) + +export const v2ListWorkspaceMembersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/members', + params: v2WorkspaceParamsSchema, + query: v2ListWorkspaceMembersQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2WorkspaceMemberSchema) }, +}) diff --git a/apps/sim/lib/logs/public-queries.ts b/apps/sim/lib/logs/public-queries.ts index bb632ca121b..6c6d7866315 100644 --- a/apps/sim/lib/logs/public-queries.ts +++ b/apps/sim/lib/logs/public-queries.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { pausedExecutions, + user, workflow, workflowDeploymentVersion, workflowExecutionLogs, @@ -153,6 +154,7 @@ export async function getPublicWorkflowLog(lookup: PublicWorkflowLogLookup, work workflowDescription: workflow.description, workflowFolderId: workflow.folderId, workflowUserId: workflow.userId, + workflowOwnerEmail: user.email, workflowWorkspaceId: workflow.workspaceId, workflowCreatedAt: workflow.createdAt, workflowUpdatedAt: workflow.updatedAt, @@ -175,6 +177,7 @@ export async function getPublicWorkflowLog(lookup: PublicWorkflowLogLookup, work ) .leftJoin(pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .leftJoin(user, eq(workflow.userId, user.id)) .where( and( lookupCondition, diff --git a/apps/sim/lib/users/queries.test.ts b/apps/sim/lib/users/queries.test.ts new file mode 100644 index 00000000000..1dad16b477b --- /dev/null +++ b/apps/sim/lib/users/queries.test.ts @@ -0,0 +1,47 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getUserEmailsByIds } from '@/lib/users/queries' + +describe('getUserEmailsByIds', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('deduplicates IDs and resolves the batch in one query', async () => { + dbChainMockFns.where.mockResolvedValue([ + { id: 'user-1', email: 'ada@example.com' }, + { id: 'user-2', email: 'grace@example.com' }, + ]) + + const result = await getUserEmailsByIds(['user-1', 'user-2', 'user-1']) + + expect(result).toEqual( + new Map([ + ['user-1', 'ada@example.com'], + ['user-2', 'grace@example.com'], + ]) + ) + expect(dbChainMockFns.where).toHaveBeenCalledTimes(1) + }) + + it('fails when a stored attribution cannot be resolved', async () => { + dbChainMockFns.where.mockResolvedValue([{ id: 'user-1', email: 'ada@example.com' }]) + + await expect(getUserEmailsByIds(['user-1', 'missing-user'])).rejects.toThrow( + 'Unable to resolve email for user IDs: missing-user' + ) + }) + + it('rejects an unbounded attribution batch before querying', async () => { + const ids = Array.from({ length: 1001 }, (_, index) => `user-${index}`) + + await expect(getUserEmailsByIds(ids)).rejects.toThrow( + 'Cannot resolve more than 1000 user emails at once' + ) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/users/queries.ts b/apps/sim/lib/users/queries.ts index defaf9f6122..5de84e9e5f2 100644 --- a/apps/sim/lib/users/queries.ts +++ b/apps/sim/lib/users/queries.ts @@ -2,10 +2,11 @@ import { db } from '@sim/db' import { settings, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' +import { eq, inArray } from 'drizzle-orm' import type { UserSettingsApi } from '@/lib/api/contracts/user' const logger = createLogger('UserQueries') +const MAX_USER_EMAIL_BATCH = 1000 /** * Default user settings returned for unauthenticated users or when no @@ -110,6 +111,47 @@ export async function getUserEmailById(userId: string): Promise { } } +/** + * Resolves a bounded set of user IDs to current email addresses in one query. + * A non-existent user is an integrity failure for public attribution and is + * never replaced with the raw ID or a placeholder. + */ +export async function getUserEmailsByIds(userIds: readonly string[]): Promise> { + const uniqueIds = Array.from(new Set(userIds)) + if (uniqueIds.length === 0) return new Map() + if (uniqueIds.length > MAX_USER_EMAIL_BATCH) { + throw new Error(`Cannot resolve more than ${MAX_USER_EMAIL_BATCH} user emails at once`) + } + + const rows = await db + .select({ id: user.id, email: user.email }) + .from(user) + .where(inArray(user.id, uniqueIds)) + + const emailByUserId = new Map(rows.map((row) => [row.id, row.email])) + const missingIds = uniqueIds.filter((id) => !emailByUserId.has(id)) + if (missingIds.length > 0) { + throw new Error(`Unable to resolve email for user IDs: ${missingIds.join(', ')}`) + } + + return emailByUserId +} + +/** Returns one previously resolved email or throws on an incomplete projection. */ +export function requireResolvedUserEmail( + emailByUserId: ReadonlyMap, + userId: string +): string { + const email = emailByUserId.get(userId) + if (!email) throw new Error(`Unable to resolve email for user ID: ${userId}`) + return email +} + +/** Resolves one required public attribution email. */ +export async function getRequiredUserEmail(userId: string): Promise { + return requireResolvedUserEmail(await getUserEmailsByIds([userId]), userId) +} + /** * Loads a user's public profile fields, or `null` when no matching user exists. */ diff --git a/apps/sim/lib/workspaces/public-queries.test.ts b/apps/sim/lib/workspaces/public-queries.test.ts new file mode 100644 index 00000000000..b33facca025 --- /dev/null +++ b/apps/sim/lib/workspaces/public-queries.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { queryPublicWorkspaceMembers } from '@/lib/workspaces/public-queries' + +describe('queryPublicWorkspaceMembers', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('deduplicates inherited org admins and promotes their effective role', async () => { + queueTableRows(schemaMock.workspace, [{ ownerId: 'user-1', organizationId: 'org-1' }]) + queueTableRows(schemaMock.permissions, [ + { + userId: 'user-1', + email: 'ada@example.com', + name: 'Ada', + image: null, + role: 'write', + joinedAt: new Date('2026-01-01T00:00:00.000Z'), + userOrganizationId: 'org-1', + }, + { + userId: 'user-2', + email: 'grace@example.com', + name: 'Grace', + image: null, + role: 'read', + joinedAt: new Date('2026-01-02T00:00:00.000Z'), + userOrganizationId: null, + }, + ]) + queueTableRows(schemaMock.member, [ + { + userId: 'user-1', + email: 'ada@example.com', + name: 'Ada', + image: null, + joinedAt: new Date('2025-12-01T00:00:00.000Z'), + }, + ]) + + const page = await queryPublicWorkspaceMembers('workspace-1', { limit: 10 }) + + expect(page?.members).toEqual([ + { + userId: 'user-1', + email: 'ada@example.com', + name: 'Ada', + image: null, + role: 'admin', + isExternal: false, + joinedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + { + userId: 'user-2', + email: 'grace@example.com', + name: 'Grace', + image: null, + role: 'read', + isExternal: true, + joinedAt: new Date('2026-01-02T00:00:00.000Z'), + }, + ]) + expect(page?.nextEmail).toBeNull() + expect(dbChainMockFns.limit).toHaveBeenCalledWith(11) + }) + + it('returns null when the workspace is not active', async () => { + queueTableRows(schemaMock.workspace, []) + + await expect( + queryPublicWorkspaceMembers('missing-workspace', { limit: 10 }) + ).resolves.toBeNull() + }) +}) diff --git a/apps/sim/lib/workspaces/public-queries.ts b/apps/sim/lib/workspaces/public-queries.ts new file mode 100644 index 00000000000..da8094c99ef --- /dev/null +++ b/apps/sim/lib/workspaces/public-queries.ts @@ -0,0 +1,214 @@ +import { db } from '@sim/db' +import { member, permissions, user, workspace } from '@sim/db/schema' +import { ORG_ADMIN_ROLES, type PermissionType } from '@sim/platform-authz/workspace' +import { and, asc, eq, gt, inArray, isNull, sql } from 'drizzle-orm' + +export interface PublicWorkspaceDetail { + id: string + name: string + color: string + logoUrl: string | null + mode: 'personal' | 'organization' | 'grandfathered_shared' + memberCount: number + createdAt: Date + updatedAt: Date +} + +interface WorkspaceMemberRow { + userId: string + email: string + name: string + image: string | null + role: PermissionType + isExternal: boolean + joinedAt: Date +} + +interface QueryWorkspaceMembersOptions { + limit: number + afterEmail?: string +} + +export interface WorkspaceMemberPage { + members: WorkspaceMemberRow[] + nextEmail: string | null +} + +async function countWorkspaceMembers( + workspaceId: string, + organizationId: string | null +): Promise { + const orgAdminRoles = sql.join( + ORG_ADMIN_ROLES.map((role) => sql`${role}`), + sql`, ` + ) + const [row] = await db.execute<{ count: number | string }>(sql` + SELECT COUNT(*)::integer AS count + FROM ( + SELECT ${permissions.userId} AS user_id + FROM ${permissions} + WHERE ${permissions.entityType} = 'workspace' + AND ${permissions.entityId} = ${workspaceId} + UNION + SELECT ${member.userId} AS user_id + FROM ${member} + WHERE ${member.organizationId} = ${organizationId} + AND ${member.role} IN (${orgAdminRoles}) + ) AS effective_members + `) + + const count = Number(row?.count) + if (!Number.isSafeInteger(count) || count < 0) { + throw new Error(`Invalid member count for workspace ${workspaceId}`) + } + return count +} + +/** Public workspace metadata with all governance and billing identities omitted. */ +export async function getPublicWorkspaceDetail( + workspaceId: string +): Promise { + const [row] = await db + .select({ + id: workspace.id, + name: workspace.name, + color: workspace.color, + logoUrl: workspace.logoUrl, + mode: workspace.workspaceMode, + organizationId: workspace.organizationId, + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + if (!row) return null + + return { + id: row.id, + name: row.name, + color: row.color, + logoUrl: row.logoUrl, + mode: row.mode, + memberCount: await countWorkspaceMembers(workspaceId, row.organizationId), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + } +} + +/** + * Returns one email-ordered page of effective workspace members. Explicit + * grants and inherited organization-admin grants are each bounded in SQL, + * then merged so a user present in both sources appears once with admin access. + */ +export async function queryPublicWorkspaceMembers( + workspaceId: string, + options: QueryWorkspaceMembersOptions +): Promise { + const [workspaceRow] = await db + .select({ + ownerId: workspace.ownerId, + organizationId: workspace.organizationId, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + if (!workspaceRow) return null + + const emailOrder = sql`${user.email} COLLATE "C"` + const emailCursor = options.afterEmail ? gt(emailOrder, options.afterEmail) : undefined + const sourceLimit = options.limit + 1 + + const explicitPromise = db + .select({ + userId: user.id, + email: user.email, + name: user.name, + image: user.image, + role: permissions.permissionType, + joinedAt: permissions.createdAt, + userOrganizationId: member.organizationId, + }) + .from(permissions) + .innerJoin(user, eq(permissions.userId, user.id)) + .leftJoin(member, eq(member.userId, user.id)) + .where( + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId), + emailCursor + ) + ) + .orderBy(asc(emailOrder)) + .limit(sourceLimit) + + const orgAdminPromise = workspaceRow.organizationId + ? db + .select({ + userId: user.id, + email: user.email, + name: user.name, + image: user.image, + joinedAt: member.createdAt, + }) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .where( + and( + eq(member.organizationId, workspaceRow.organizationId), + inArray(member.role, [...ORG_ADMIN_ROLES]), + emailCursor + ) + ) + .orderBy(asc(emailOrder)) + .limit(sourceLimit) + : Promise.resolve([]) + + const [explicitRows, orgAdminRows] = await Promise.all([explicitPromise, orgAdminPromise]) + const byUserId = new Map() + + for (const row of explicitRows) { + byUserId.set(row.userId, { + userId: row.userId, + email: row.email, + name: row.name, + image: row.image, + role: row.role, + isExternal: + row.userId !== workspaceRow.ownerId && + row.userOrganizationId !== workspaceRow.organizationId, + joinedAt: row.joinedAt, + }) + } + + for (const row of orgAdminRows) { + const existing = byUserId.get(row.userId) + if (existing) { + existing.role = 'admin' + existing.isExternal = false + continue + } + byUserId.set(row.userId, { + userId: row.userId, + email: row.email, + name: row.name, + image: row.image, + role: 'admin', + isExternal: false, + joinedAt: row.joinedAt, + }) + } + + const sorted = Array.from(byUserId.values()).sort((left, right) => + left.email < right.email ? -1 : left.email > right.email ? 1 : 0 + ) + const hasMore = sorted.length > options.limit + const members = sorted.slice(0, options.limit) + + return { + members, + nextEmail: hasMore ? (members.at(-1)?.email ?? null) : null, + } +} From 804fcc40ddbd87a28afeb109101db6f508444268 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 6 Aug 2026 22:28:46 -0700 Subject: [PATCH 090/159] improvement(api): consolidate public v2 route handling --- .../app/api/public-api-route-handler.test.ts | 272 +++++++++++ apps/sim/app/api/public-api-route-handler.ts | 79 ++++ apps/sim/app/api/v1/middleware.test.ts | 24 + apps/sim/app/api/v1/middleware.ts | 14 +- apps/sim/app/api/v2/audit-logs/[id]/route.ts | 90 ++-- apps/sim/app/api/v2/audit-logs/route.ts | 53 +-- apps/sim/app/api/v2/billing/logs/route.ts | 51 +- apps/sim/app/api/v2/billing/status/route.ts | 47 +- apps/sim/app/api/v2/credentials/route.ts | 50 +- .../sim/app/api/v2/custom-tools/[id]/route.ts | 208 +++------ apps/sim/app/api/v2/custom-tools/route.ts | 166 +++---- .../api/v2/files/[fileId]/content/route.ts | 56 +-- .../api/v2/files/[fileId]/metadata/route.ts | 72 +-- apps/sim/app/api/v2/files/[fileId]/route.ts | 98 +--- .../app/api/v2/files/[fileId]/share/route.ts | 80 +--- .../sim/app/api/v2/files/bulk-delete/route.ts | 49 +- apps/sim/app/api/v2/files/folders/route.ts | 182 +++----- apps/sim/app/api/v2/files/move/route.ts | 49 +- apps/sim/app/api/v2/files/route.ts | 147 +++--- .../uploads/[uploadId]/complete/route.ts | 46 +- .../files/uploads/[uploadId]/parts/route.ts | 48 +- .../api/v2/files/uploads/[uploadId]/route.ts | 46 +- apps/sim/app/api/v2/files/uploads/route.ts | 101 ++-- .../[id]/documents/[documentId]/route.ts | 217 ++++----- .../api/v2/knowledge/[id]/documents/route.ts | 86 +--- .../uploads/[uploadId]/complete/route.ts | 59 +-- .../uploads/[uploadId]/parts/route.ts | 58 +-- .../documents/uploads/[uploadId]/route.ts | 53 +-- .../knowledge/[id]/documents/uploads/route.ts | 51 +- apps/sim/app/api/v2/knowledge/[id]/route.ts | 166 ++----- .../sim/app/api/v2/knowledge/folders/route.ts | 212 +++------ apps/sim/app/api/v2/knowledge/route.ts | 84 +--- apps/sim/app/api/v2/knowledge/search/route.ts | 437 ++++++++---------- apps/sim/app/api/v2/logs/[runId]/route.ts | 134 +++--- apps/sim/app/api/v2/logs/route.ts | 55 +-- apps/sim/app/api/v2/mcp-servers/[id]/route.ts | 116 +---- apps/sim/app/api/v2/mcp-servers/route.ts | 95 +--- apps/sim/app/api/v2/secrets/[name]/route.ts | 74 +-- apps/sim/app/api/v2/secrets/route.ts | 49 +- apps/sim/app/api/v2/skills/[id]/route.ts | 116 +---- apps/sim/app/api/v2/skills/route.ts | 91 +--- .../v2/tables/[tableId]/cancel-runs/route.ts | 121 ++--- .../api/v2/tables/[tableId]/columns/route.ts | 210 +++------ .../v2/tables/[tableId]/columns/run/route.ts | 145 +++--- .../api/v2/tables/[tableId]/exports/route.ts | 83 ++-- .../api/v2/tables/[tableId]/groups/route.ts | 407 +++++++--------- .../api/v2/tables/[tableId]/query/route.ts | 198 ++++---- apps/sim/app/api/v2/tables/[tableId]/route.ts | 349 ++++++-------- .../[rowId]/enrichment/[groupId]/route.ts | 52 +-- .../v2/tables/[tableId]/rows/[rowId]/route.ts | 226 ++++----- .../v2/tables/[tableId]/rows/find/route.ts | 148 +++--- .../app/api/v2/tables/[tableId]/rows/route.ts | 310 +++++-------- .../v2/tables/[tableId]/rows/upsert/route.ts | 122 ++--- .../tables/[tableId]/views/[viewId]/route.ts | 146 ++---- .../api/v2/tables/[tableId]/views/route.ts | 145 ++---- .../exports/[exportId]/download/route.ts | 42 +- .../api/v2/tables/exports/[exportId]/route.ts | 69 +-- apps/sim/app/api/v2/tables/folders/route.ts | 202 +++----- .../imports/[importId]/complete/route.ts | 46 +- .../tables/imports/[importId]/parts/route.ts | 48 +- .../api/v2/tables/imports/[importId]/route.ts | 80 +--- apps/sim/app/api/v2/tables/imports/route.ts | 86 ++-- apps/sim/app/api/v2/tables/route.ts | 178 +++---- .../app/api/v2/workflows/[id]/deploy/route.ts | 75 +-- .../app/api/v2/workflows/[id]/export/route.ts | 119 ++--- .../api/v2/workflows/[id]/rollback/route.ts | 43 +- apps/sim/app/api/v2/workflows/[id]/route.ts | 326 ++++++------- .../[id]/versions/[version]/route.ts | 81 ++-- .../api/v2/workflows/[id]/versions/route.ts | 136 ++---- .../sim/app/api/v2/workflows/folders/route.ts | 207 +++------ apps/sim/app/api/v2/workflows/import/route.ts | 52 +-- apps/sim/app/api/v2/workflows/route.ts | 161 +++---- .../workspaces/[workspaceId]/members/route.ts | 100 ++-- .../api/v2/workspaces/[workspaceId]/route.ts | 48 +- apps/sim/lib/api/server/validation.ts | 11 +- apps/sim/lib/core/utils/with-route-handler.ts | 22 +- scripts/check-api-validation-contracts.ts | 10 + 77 files changed, 3307 insertions(+), 5678 deletions(-) create mode 100644 apps/sim/app/api/public-api-route-handler.test.ts create mode 100644 apps/sim/app/api/public-api-route-handler.ts diff --git a/apps/sim/app/api/public-api-route-handler.test.ts b/apps/sim/app/api/public-api-route-handler.test.ts new file mode 100644 index 00000000000..33757c33864 --- /dev/null +++ b/apps/sim/app/api/public-api-route-handler.test.ts @@ -0,0 +1,272 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { recordRateLimitSnapshot } from '@/lib/api/server/rate-limit-context' + +const { + mockCheckRateLimit, + mockGate, + mockHandler, + mockLoggerError, + mockLoggerInfo, + requestContextState, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockGate: vi.fn(), + mockHandler: vi.fn(), + mockLoggerError: vi.fn(), + mockLoggerInfo: vi.fn(), + requestContextState: { + current: undefined as { requestId: string; method?: string; path?: string } | undefined, + }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ + info: (...arguments_: unknown[]) => + mockLoggerInfo(requestContextState.current?.requestId, ...arguments_), + warn: vi.fn(), + error: (...arguments_: unknown[]) => + mockLoggerError(requestContextState.current?.requestId, ...arguments_), + }), + getRequestContext: () => requestContextState.current, + runWithRequestContext: async ( + context: { requestId: string; method?: string; path?: string }, + callback: () => T | Promise + ): Promise => { + requestContextState.current = context + try { + return await callback() + } finally { + requestContextState.current = undefined + } + }, +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: () => requestContextState.current?.requestId ?? 'outer-request-id', +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: mockGate, +})) + +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' + +const RATE_LIMIT = { + allowed: true, + limit: 400, + remaining: 399, + resetAt: new Date('2026-08-06T20:00:00.000Z'), + userId: 'user-1', + keyType: 'personal' as const, +} + +const queryContract = defineRouteContract({ + method: 'POST', + path: '/api/test/:itemId', + params: z.object({ itemId: z.string().min(1) }), + query: z.object({ limit: z.coerce.number().int().positive() }), + body: z.object({ name: z.string().min(1) }), + response: { mode: 'json', schema: z.object({ ok: z.boolean() }) }, +}) + +const listContract = defineRouteContract({ + method: 'GET', + path: '/api/test', + query: z.object({ workspaceId: z.string().min(1) }), + response: { mode: 'json', schema: z.object({ ok: z.boolean() }) }, +}) + +const POST = withPublicApiRouteHandler({ + contract: queryContract, + rateLimitEndpoint: 'table-rows', + parseOptions: { + maxBodyBytes: 32, + payloadTooLargeResponse: () => + NextResponse.json({ error: 'Custom payload limit response' }, { status: 413 }), + }, + handler: async (arguments_) => { + mockHandler(arguments_) + return NextResponse.json({ ok: true }) + }, +}) + +const GET = withPublicApiRouteHandler({ + contract: listContract, + rateLimitEndpoint: 'tables', + handler: async (arguments_) => { + mockHandler(arguments_) + return NextResponse.json({ ok: true }) + }, +}) + +const FAILING_GET = withPublicApiRouteHandler({ + contract: listContract, + rateLimitEndpoint: 'tables', + handler: async () => { + throw new Error('handler failed') + }, +}) + +function postRequest(body: string): NextRequest { + return new NextRequest('http://localhost:3000/api/test/item-1?limit=10', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }) +} + +function listRequest(query = 'workspaceId=workspace-1'): NextRequest { + return new NextRequest(`http://localhost:3000/api/test?${query}`) +} + +describe('withPublicApiRouteHandler', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGate.mockResolvedValue(null) + mockCheckRateLimit.mockImplementation(async (request: NextRequest) => { + recordRateLimitSnapshot(request, RATE_LIMIT) + return RATE_LIMIT + }) + }) + + it.each([ + ['authentication failure', 401], + ['rate-limit denial', 429], + ])('short-circuits %s before reading or parsing the body', async (_label, status) => { + mockCheckRateLimit.mockImplementation(async (request: NextRequest) => { + if (status === 401) { + return { + allowed: false, + limit: 0, + remaining: 0, + resetAt: new Date('2026-08-06T20:00:00.000Z'), + error: 'API key required', + } + } + + recordRateLimitSnapshot(request, RATE_LIMIT) + return { ...RATE_LIMIT, allowed: false, remaining: 0, retryAfterMs: 30_000 } + }) + const request = postRequest('{not valid json') + + const response = await POST(request, { params: { itemId: 'item-1' } }) + + expect(response.status).toBe(status) + expect(request.bodyUsed).toBe(false) + expect(mockHandler).not.toHaveBeenCalled() + expect(mockCheckRateLimit).toHaveBeenCalledWith(request, 'table-rows') + expect(mockGate).not.toHaveBeenCalled() + if (status === 401) { + expect(response.headers.get('X-RateLimit-Limit')).toBe('0') + } else { + expect(response.headers.get('Retry-After')).toBe('30') + expect(response.headers.get('X-RateLimit-Limit')).toBe('400') + } + }) + + it('checks the v2 rollout gate before reading or parsing the body', async () => { + mockGate.mockResolvedValue(NextResponse.json({ error: 'Not found' }, { status: 404 })) + const request = postRequest('{not valid json') + + const response = await POST(request, { params: { itemId: 'item-1' } }) + + expect(response.status).toBe(404) + expect(request.bodyUsed).toBe(false) + expect(mockGate).toHaveBeenCalledWith('user-1') + expect(mockHandler).not.toHaveBeenCalled() + }) + + it('fails fast when an allowed rate-limit result has no user ID', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, userId: undefined }) + + const response = await GET(listRequest()) + + expect(response.status).toBe(500) + expect(mockGate).not.toHaveBeenCalled() + expect(mockHandler).not.toHaveBeenCalled() + }) + + it('returns a contract validation response after authentication', async () => { + const response = await POST(postRequest(JSON.stringify({ name: '' })), { + params: { itemId: 'item-1' }, + }) + + expect(response.status).toBe(400) + expect(response.headers.get('X-RateLimit-Limit')).toBe('400') + expect(mockHandler).not.toHaveBeenCalled() + }) + + it('forwards the body-size parse option', async () => { + const response = await POST(postRequest(JSON.stringify({ name: 'x'.repeat(40) })), { + params: { itemId: 'item-1' }, + }) + + expect(response.status).toBe(413) + expect(response.headers.get('X-RateLimit-Remaining')).toBe('399') + await expect(response.json()).resolves.toEqual({ error: 'Custom payload limit response' }) + expect(mockHandler).not.toHaveBeenCalled() + }) + + it('provides parsed params, query, body, and auth to the handler', async () => { + const request = postRequest(JSON.stringify({ name: 'Ada' })) + const response = await POST(request, { params: Promise.resolve({ itemId: 'item-1' }) }) + + expect(response.status).toBe(200) + expect(mockHandler).toHaveBeenCalledWith({ + request, + input: { + params: { itemId: 'item-1' }, + query: { limit: 10 }, + body: { name: 'Ada' }, + headers: undefined, + }, + auth: { + requestId: 'outer-request-id', + userId: 'user-1', + rateLimit: RATE_LIMIT, + }, + }) + expect(response.headers.get('x-request-id')).toBe('outer-request-id') + expect(response.headers.get('X-RateLimit-Reset')).toBe(RATE_LIMIT.resetAt.toISOString()) + expect(mockLoggerInfo).toHaveBeenCalledWith( + 'outer-request-id', + 'OK', + expect.objectContaining({ status: 200 }) + ) + }) + + it('supports direct invocation without a route context', async () => { + const request = listRequest() + const response = await GET(request) + + expect(response.status).toBe(200) + expect(mockHandler.mock.calls[0][0].input.query).toEqual({ workspaceId: 'workspace-1' }) + expect(mockCheckRateLimit).toHaveBeenCalledWith(request, 'tables') + }) + + it('keeps rate-limit and request headers on unhandled endpoint errors', async () => { + const response = await FAILING_GET(listRequest()) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) + expect(response.headers.get('x-request-id')).toBe('outer-request-id') + expect(response.headers.get('X-RateLimit-Limit')).toBe('400') + expect(mockLoggerError).toHaveBeenCalledWith( + 'outer-request-id', + 'Unhandled route error', + expect.objectContaining({ error: 'handler failed' }) + ) + }) +}) diff --git a/apps/sim/app/api/public-api-route-handler.ts b/apps/sim/app/api/public-api-route-handler.ts new file mode 100644 index 00000000000..af25a4fe3b4 --- /dev/null +++ b/apps/sim/app/api/public-api-route-handler.ts @@ -0,0 +1,79 @@ +import type { NextRequest, NextResponse } from 'next/server' +import type { AnyApiRouteContract } from '@/lib/api/contracts' +import { type ParsedRequest, type ParseRequestOptions, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { type ApiEndpoint, type AuthorizedRequest, checkRateLimit } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +interface PublicApiRouteContext { + params?: + | Promise> + | Record +} + +interface PublicApiRouteHandlerArguments { + request: NextRequest + input: ParsedRequest + auth: AuthorizedRequest +} + +interface PublicApiRouteHandlerOptions { + contract: C + rateLimitEndpoint: ApiEndpoint + parseOptions?: ParseRequestOptions + handler: ( + arguments_: PublicApiRouteHandlerArguments + ) => Promise | NextResponse | Response +} + +type PublicApiNextRouteHandler = ( + request: NextRequest, + context?: PublicApiRouteContext +) => Promise + +/** + * Wraps an API-key-authenticated public route with request context, rate + * limiting, authentication, and contract parsing before invoking the route's + * authorization and business logic. Unexpected endpoint errors are logged once + * by the shared route handler and rendered as the canonical v2 500 envelope. + */ +export function withPublicApiRouteHandler({ + contract, + rateLimitEndpoint, + parseOptions, + handler, +}: PublicApiRouteHandlerOptions): PublicApiNextRouteHandler { + const wrapped = withRouteHandler( + async (request, context) => { + const requestId = generateRequestId() + const rateLimit = await checkRateLimit(request, rateLimitEndpoint) + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + if (!rateLimit.userId) { + throw new Error('Allowed public API request is missing a user ID') + } + const userId = rateLimit.userId + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(contract, request, context ?? {}, { + validationErrorResponse: v2ValidationError, + ...parseOptions, + }) + if (!parsed.success) return parsed.response + + return handler({ + request, + input: parsed.data, + auth: { requestId, userId, rateLimit }, + }) + }, + { + unhandledErrorResponse: () => v2Error('INTERNAL_ERROR', 'Internal server error'), + } + ) + + return async (request, context) => wrapped(request, context) +} diff --git a/apps/sim/app/api/v1/middleware.test.ts b/apps/sim/app/api/v1/middleware.test.ts index c49850c6a45..94c0790b274 100644 --- a/apps/sim/app/api/v1/middleware.test.ts +++ b/apps/sim/app/api/v1/middleware.test.ts @@ -41,6 +41,7 @@ vi.mock('@/lib/core/rate-limiter', () => ({ })) import { + authenticateRequest, checkRateLimit, createRateLimitResponse, v1ValidationErrorResponse, @@ -107,6 +108,29 @@ describe('checkRateLimit', () => { }) }) +describe('authenticateRequest', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthenticateV1Request.mockResolvedValue({ + authenticated: true, + keyType: 'personal', + }) + mockGetSubscription.mockResolvedValue({ plan: 'team' }) + mockGetRateLimit.mockReturnValue(TEAM_BUCKET) + mockCheckRateLimit.mockResolvedValue({ + allowed: true, + remaining: 399, + resetAt: new Date('2026-07-28T18:28:48.354Z'), + }) + }) + + it('fails fast when an allowed result has no user ID', async () => { + await expect(authenticateRequest(request(), 'workflows')).rejects.toThrow( + 'Allowed public API request is missing a user ID' + ) + }) +}) + describe('createRateLimitResponse', () => { const throttled = { allowed: false, diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 0398b55364a..86e3012a4d6 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -94,6 +94,16 @@ export interface AuthorizedRequest { rateLimit: RateLimitResult } +export function requireRateLimitUserId(rateLimit: RateLimitResult): string { + if (!rateLimit.allowed) { + throw new Error('Cannot authorize a denied public API request') + } + if (!rateLimit.userId) { + throw new Error('Allowed public API request is missing a user ID') + } + return rateLimit.userId +} + export async function checkRateLimit( request: NextRequest, endpoint: ApiEndpoint = 'logs' @@ -170,7 +180,7 @@ export async function checkRateLimit( } /** - * Authenticates and rate-limits a v1 API request. + * Authenticates and rate-limits a public API request. * Returns NextResponse on failure, AuthorizedRequest on success. */ export async function authenticateRequest( @@ -182,7 +192,7 @@ export async function authenticateRequest( if (!rateLimit.allowed) { return createRateLimitResponse(rateLimit) } - return { requestId, userId: rateLimit.userId!, rateLimit } + return { requestId, userId: requireRateLimitUserId(rateLimit), rateLimit } } export function createRateLimitResponse(result: RateLimitResult): NextResponse { diff --git a/apps/sim/app/api/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[id]/route.ts index 6d6f819dd8b..242f07ada89 100644 --- a/apps/sim/app/api/v2/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v2/audit-logs/[id]/route.ts @@ -1,21 +1,12 @@ import { db } from '@sim/db' import { auditLog } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' import { v2GetAuditLogContract } from '@/lib/api/contracts/v2/audit-logs' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' -import { checkRateLimit } from '@/app/api/v1/middleware' import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' - -const logger = createLogger('V2AuditLogDetailAPI') +import { v2Data, v2Error } from '@/app/api/v2/lib/response' export const revalidate = 0 @@ -26,59 +17,36 @@ export const revalidate = 0 * organization. Audit logs are personal-key-only because a workspace-scoped * key must never expand into organization-wide visibility. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'audit-logs') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetAuditLogContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - if (rateLimit.keyType !== 'personal') { - return v2Error('FORBIDDEN', 'Audit logs require a personal API key') - } +export const GET = withPublicApiRouteHandler({ + contract: v2GetAuditLogContract, + rateLimitEndpoint: 'audit-logs', + handler: async ({ input, auth: { userId, rateLimit } }) => { + if (rateLimit.keyType !== 'personal') { + return v2Error('FORBIDDEN', 'Audit logs require a personal API key') + } - const authResult = await resolveEnterpriseAuditAccess( - userId, - parsed.data.query.organizationId - ) - if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) + const authResult = await resolveEnterpriseAuditAccess(userId, input.query.organizationId) + if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) - const { id } = parsed.data.params - const { organizationId, orgMemberIds } = authResult.context + const { id } = input.params + const { organizationId, orgMemberIds } = authResult.context - const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) - const scopeCondition = buildOrgScopeCondition({ - organizationId, - orgWorkspaceIds, - orgMemberIds, - includeDeparted: true, - }) + const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) + const scopeCondition = buildOrgScopeCondition({ + organizationId, + orgWorkspaceIds, + orgMemberIds, + includeDeparted: true, + }) - const [log] = await db - .select() - .from(auditLog) - .where(and(eq(auditLog.id, id), scopeCondition)) - .limit(1) + const [log] = await db + .select() + .from(auditLog) + .where(and(eq(auditLog.id, id), scopeCondition)) + .limit(1) - if (!log) return v2Error('NOT_FOUND', 'Audit log not found') + if (!log) return v2Error('NOT_FOUND', 'Audit log not found') - return v2Data(formatV2AuditLogEntry(log), { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Audit log detail fetch error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) + return v2Data(formatV2AuditLogEntry(log), { rateLimit }) + }, +}) diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts index c6b730b8087..dca2168878e 100644 --- a/apps/sim/app/api/v2/audit-logs/route.ts +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -1,10 +1,5 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { buildFilterConditions, @@ -12,17 +7,8 @@ import { getOrgWorkspaceIds, queryAuditLogs, } from '@/app/api/v1/audit-logs/query' -import { checkRateLimit } from '@/app/api/v1/middleware' import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Error, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2AuditLogsAPI') +import { v2CursorList, v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -34,29 +20,11 @@ export const revalidate = 0 * are personal-key-only because a workspace-scoped key must never expand into * organization-wide visibility. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'audit-logs') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListAuditLogsContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const params = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListAuditLogsContract, + rateLimitEndpoint: 'audit-logs', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const params = input.query if (rateLimit.keyType !== 'personal') { return v2Error('FORBIDDEN', 'Audit logs require a personal API key') @@ -96,10 +64,5 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) return v2CursorList(data.map(formatV2AuditLogEntry), nextCursor ?? null, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Audit logs fetch error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/billing/logs/route.ts b/apps/sim/app/api/v2/billing/logs/route.ts index 9ba119213c3..b2893ec95b7 100644 --- a/apps/sim/app/api/v2/billing/logs/route.ts +++ b/apps/sim/app/api/v2/billing/logs/route.ts @@ -1,50 +1,20 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2ListBillingLogsContract } from '@/lib/api/contracts/v2/billing' -import { parseRequest } from '@/lib/api/server' import { getUsageCreditsByLogId, getUserUsageLogs } from '@/lib/billing/core/usage-log' import { toBillingUsageLogSource, toInternalUsageLogSources } from '@/lib/billing/usage-sources' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' -import { checkRateLimit } from '@/app/api/v1/middleware' import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Error, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2BillingLogsAPI') +import { v2CursorList } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** Cursor-paged, credit-denominated billing ledger. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'billing-usage') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListBillingLogsContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { source, workspaceId, period, startDate, endDate, limit, cursor } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListBillingLogsContract, + rateLimitEndpoint: 'billing-usage', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { source, workspaceId, period, startDate, endDate, limit, cursor } = input.query const workspaceFilter = await v2BillingWorkspaceFilter(rateLimit, workspaceId) if (!workspaceFilter.ok) return workspaceFilter.response @@ -77,10 +47,5 @@ export const GET = withRouteHandler(async (request: NextRequest) => { result.pagination.hasMore ? (result.pagination.nextCursor ?? null) : null, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error listing billing logs`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/billing/status/route.ts b/apps/sim/app/api/v2/billing/status/route.ts index 8936001c50e..868e7dd0c06 100644 --- a/apps/sim/app/api/v2/billing/status/route.ts +++ b/apps/sim/app/api/v2/billing/status/route.ts @@ -1,11 +1,7 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { type V2BillingStatusData, v2GetBillingStatusContract, } from '@/lib/api/contracts/v2/billing' -import { parseRequest } from '@/lib/api/server' import { checkBillingBlocked, checkBillingEntityBlocked, @@ -19,41 +15,19 @@ import { import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { deriveBillingContext } from '@/lib/billing/core/usage-log' import { dollarsToCredits } from '@/lib/billing/credits/conversion' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { checkRateLimit } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' - -const logger = createLogger('V2BillingStatusAPI') +import { v2Data } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** Current billing standing; ledger events are exposed separately by `/billing/logs`. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'billing-usage') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2GetBillingStatusContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const workspaceFilter = await v2BillingWorkspaceFilter(rateLimit, parsed.data.query.workspaceId) +export const GET = withPublicApiRouteHandler({ + contract: v2GetBillingStatusContract, + rateLimitEndpoint: 'billing-usage', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const workspaceFilter = await v2BillingWorkspaceFilter(rateLimit, input.query.workspaceId) if (!workspaceFilter.ok) return workspaceFilter.response let data: V2BillingStatusData @@ -109,10 +83,5 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } return v2Data(data, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error building billing status`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index 9e687641f42..c61307198f1 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -1,47 +1,20 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2Credential } from '@/app/api/v2/credentials/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2CredentialsAPI') +import { v2CursorList, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/credentials — List the credentials the caller can see in a workspace. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'credentials') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListCredentialsContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, type, providerId, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListCredentialsContract, + rateLimitEndpoint: 'credentials', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, type, providerId, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -65,10 +38,5 @@ export const GET = withRouteHandler(async (request: NextRequest) => { // The per-workspace credential set is small and bounded → a single full page. return v2CursorList(credentials.map(toV2Credential), null, { rateLimit }) - } catch (error) { - logger.error('Error listing credentials', { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.ts index e793c31c3ea..a20c9933873 100644 --- a/apps/sim/app/api/v2/custom-tools/[id]/route.ts +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.ts @@ -1,33 +1,19 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2DeleteCustomToolContract, v2GetCustomToolContract, v2UpdateCustomToolContract, } from '@/lib/api/contracts/v2/custom-tools' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteWorkspaceCustomTool, getWorkspaceCustomTool, getWorkspaceCustomToolByTitle, updateWorkspaceCustomTool, } from '@/lib/workflows/custom-tools/operations' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2CustomToolDetailAPI') +import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -37,25 +23,12 @@ interface RouteContext { } /** GET /api/v2/custom-tools/[id] — Fetch a single custom tool. */ -export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'custom-tool-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetCustomToolContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2GetCustomToolContract, + rateLimitEndpoint: 'custom-tool-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { id } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -64,108 +37,76 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RouteC if (!tool) return v2Error('NOT_FOUND', 'Custom tool not found') return v2Data({ customTool: toV2CustomTool(tool) }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error fetching custom tool`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** PATCH /api/v2/custom-tools/[id] — Update a custom tool. Omitted fields keep their values. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'custom-tool-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateCustomToolContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId, title, schema, code } = parsed.data.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const current = await getWorkspaceCustomTool({ workspaceId, toolId: id }) - if (!current) return v2Error('NOT_FOUND', 'Custom tool not found') - - /** - * `upsertCustomTools` replaces title/schema/code wholesale and checks for a - * duplicate title only when inserting, so a rename onto an existing title - * would hit the `custom_tools_workspace_title_unique` index as a 500. Merge - * the partial body against the stored row and check the rename here. - */ - if (title !== undefined && title !== current.title) { - if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) { - return v2Error( - 'CONFLICT', - `A custom tool titled "${title}" already exists in this workspace` - ) +export const PATCH = withPublicApiRouteHandler({ + contract: v2UpdateCustomToolContract, + rateLimitEndpoint: 'custom-tool-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + try { + const { id } = input.params + const { workspaceId, title, schema, code } = input.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const current = await getWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!current) return v2Error('NOT_FOUND', 'Custom tool not found') + + /** + * `upsertCustomTools` replaces title/schema/code wholesale and checks for a + * duplicate title only when inserting, so a rename onto an existing title + * would hit the `custom_tools_workspace_title_unique` index as a 500. Merge + * the partial body against the stored row and check the rename here. + */ + if (title !== undefined && title !== current.title) { + if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) { + return v2Error( + 'CONFLICT', + `A custom tool titled "${title}" already exists in this workspace` + ) + } } - } - const updated = await updateWorkspaceCustomTool({ - workspaceId, - toolId: id, - title: title ?? current.title, - schema: schema ?? current.schema, - code: code ?? current.code, - }) - if (!updated) return v2Error('NOT_FOUND', 'Custom tool not found') - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.CUSTOM_TOOL_UPDATED, - resourceType: AuditResourceType.CUSTOM_TOOL, - resourceId: updated.id, - resourceName: updated.title, - description: `Updated custom tool "${updated.title}" via API`, - request, - }) - - return v2Data({ customTool: toV2CustomTool(updated) }, { rateLimit }) - } catch (error) { - const writeError = v2CustomToolWriteError(error) - if (writeError) return writeError - - logger.error(`[${requestId}] Error updating custom tool`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + const updated = await updateWorkspaceCustomTool({ + workspaceId, + toolId: id, + title: title ?? current.title, + schema: schema ?? current.schema, + code: code ?? current.code, + }) + if (!updated) return v2Error('NOT_FOUND', 'Custom tool not found') + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.CUSTOM_TOOL_UPDATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: updated.id, + resourceName: updated.title, + description: `Updated custom tool "${updated.title}" via API`, + request, + }) + + return v2Data({ customTool: toV2CustomTool(updated) }, { rateLimit }) + } catch (error) { + const writeError = v2CustomToolWriteError(error) + if (writeError) return writeError + + throw error + } + }, }) /** DELETE /api/v2/custom-tools/[id] — Delete a custom tool. */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'custom-tool-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteCustomToolContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId } = parsed.data.query +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteCustomToolContract, + rateLimitEndpoint: 'custom-tool-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { id } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -188,10 +129,5 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou }) return v2Data({ id, deleted: true as const }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error deleting custom tool`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/custom-tools/route.ts b/apps/sim/app/api/v2/custom-tools/route.ts index 96b678a5078..37899f53a00 100644 --- a/apps/sim/app/api/v2/custom-tools/route.ts +++ b/apps/sim/app/api/v2/custom-tools/route.ts @@ -1,58 +1,27 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateCustomToolContract, v2ListCustomToolsContract, } from '@/lib/api/contracts/v2/custom-tools' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getWorkspaceCustomToolByTitle, listWorkspaceCustomTools, upsertCustomTools, } from '@/lib/workflows/custom-tools/operations' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2CustomToolsAPI') +import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/custom-tools — List custom tools in a workspace. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'custom-tools') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListCustomToolsContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListCustomToolsContract, + rateLimitEndpoint: 'custom-tools', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -61,76 +30,59 @@ export const GET = withRouteHandler(async (request: NextRequest) => { // The per-workspace tool set is small and bounded → a single full page. return v2CursorList(rows.map(toV2CustomTool), null, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error listing custom tools`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** POST /api/v2/custom-tools — Create a custom tool. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'custom-tools') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateCustomToolContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, title, schema, code } = parsed.data.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - /** - * Titles are unique per workspace and tools resolve by title at call time, - * so a collision is reported rather than surfacing as a unique-index 500. - */ - if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) { - return v2Error('CONFLICT', `A custom tool titled "${title}" already exists in this workspace`) +export const POST = withPublicApiRouteHandler({ + contract: v2CreateCustomToolContract, + rateLimitEndpoint: 'custom-tools', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + try { + const { workspaceId, title, schema, code } = input.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + /** + * Titles are unique per workspace and tools resolve by title at call time, + * so a collision is reported rather than surfacing as a unique-index 500. + */ + if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) { + return v2Error( + 'CONFLICT', + `A custom tool titled "${title}" already exists in this workspace` + ) + } + + const tools = await upsertCustomTools({ + tools: [{ title, schema, code }], + workspaceId, + userId, + requestId, + }) + const created = tools.find((tool) => tool.title === title) + if (!created) { + throw new Error(`Custom tool "${title}" missing after a successful write`) + } + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.CUSTOM_TOOL_CREATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: created.id, + resourceName: created.title, + description: `Created custom tool "${created.title}" via API`, + request, + }) + + return v2Data({ customTool: toV2CustomTool(created) }, { rateLimit, status: 201 }) + } catch (error) { + const writeError = v2CustomToolWriteError(error) + if (writeError) return writeError + + throw error } - - const tools = await upsertCustomTools({ - tools: [{ title, schema, code }], - workspaceId, - userId, - requestId, - }) - const created = tools.find((tool) => tool.title === title) - if (!created) return v2Error('INTERNAL_ERROR', 'Internal server error') - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.CUSTOM_TOOL_CREATED, - resourceType: AuditResourceType.CUSTOM_TOOL, - resourceId: created.id, - resourceName: created.title, - description: `Created custom tool "${created.title}" via API`, - request, - }) - - return v2Data({ customTool: toV2CustomTool(created) }, { rateLimit, status: 201 }) - } catch (error) { - const writeError = v2CustomToolWriteError(error) - if (writeError) return writeError - - logger.error(`[${requestId}] Error creating custom tool`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts index 10fab0ec45a..3c295bd1306 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -1,35 +1,22 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, performUpdateWorkspaceFileContent, } from '@/lib/workspace-files/orchestration' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2File } from '@/app/api/v2/files/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2FileContentAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface FileRouteParams { - params: Promise<{ fileId: string }> -} - /** * PUT /api/v2/files/[fileId]/content — Replace a file's bytes. * @@ -38,29 +25,17 @@ interface FileRouteParams { * 50 MB and still debits the workspace storage quota, so a write that would push * the payer past its limit fails with 413. */ -export const PUT = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-content') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateFileContentContract, request, context, { - invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), - maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) { - return parsed.response.status === 413 - ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') - : parsed.response - } - - const { fileId } = parsed.data.params - const { workspaceId, content, encoding } = parsed.data.body +export const PUT = withPublicApiRouteHandler({ + contract: v2UpdateFileContentContract, + rateLimitEndpoint: 'file-content', + parseOptions: { + invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), + maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + payloadTooLargeResponse: () => v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large'), + }, + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { fileId } = input.params + const { workspaceId, content, encoding } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -82,8 +57,5 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: FileRo } return v2Data(await toV2File(result.file), { rateLimit }) - } catch (error) { - logger.error('Error updating file content', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts index 2ba48d09070..f74c7fe55be 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts @@ -1,61 +1,27 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2GetFileContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2File } from '@/app/api/v2/files/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FileMetadataAPI') +import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -interface FileMetadataRouteParams { - params: Promise<{ fileId: string }> -} - /** GET /api/v2/files/[fileId]/metadata — Return file metadata without downloading its bytes. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: FileMetadataRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetFileContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { fileId } = parsed.data.params - const { workspaceId } = parsed.data.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) - if (!file) return v2Error('NOT_FOUND', 'File not found') - - return v2Data(await toV2File(file), { rateLimit }) - } catch (error) { - logger.error('Error fetching file metadata', { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) +export const GET = withPublicApiRouteHandler({ + contract: v2GetFileContract, + rateLimitEndpoint: 'file-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { fileId } = input.params + const { workspaceId } = input.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) + if (!file) return v2Error('NOT_FOUND', 'File not found') + + return v2Data(await toV2File(file), { rateLimit }) + }, +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index 24c9ddc56ee..53dec1d5470 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -1,29 +1,23 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2DeleteFileContract, v2DownloadFileContract, v2RenameFileContract, } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { performDeleteWorkspaceFileItems, performRenameWorkspaceFile, } from '@/lib/workspace-files/orchestration' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2File } from '@/app/api/v2/files/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { rateLimitHeaders, v2Data, v2Error, v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' @@ -32,10 +26,6 @@ const logger = createLogger('V2FileDetailAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -interface FileRouteParams { - params: Promise<{ fileId: string }> -} - /** * GET /api/v2/files/[fileId] — Download file content (binary). * @@ -43,23 +33,12 @@ interface FileRouteParams { * `X-RateLimit-*` headers. Errors still render the canonical v2 JSON error body. * Lookups are workspace-scoped (IDOR-safe): a file in another workspace 404s. */ -export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DownloadFileContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { fileId } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2DownloadFileContract, + rateLimitEndpoint: 'file-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { fileId } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -78,10 +57,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo ...rateLimitHeaders(rateLimit), }, }) - } catch (error) { - logger.error('Error downloading file', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** @@ -91,23 +67,12 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo * Names that collide within the destination folder are rejected as `CONFLICT` — * unlike upload, which auto-suffixes on the internal surface. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2RenameFileContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { fileId } = parsed.data.params - const { workspaceId, name } = parsed.data.body +export const PATCH = withPublicApiRouteHandler({ + contract: v2RenameFileContract, + rateLimitEndpoint: 'file-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { fileId } = input.params + const { workspaceId, name } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -122,10 +87,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: File } return v2Data(await toV2File(result.file), { rateLimit }) - } catch (error) { - logger.error('Error renaming file', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** @@ -136,23 +98,12 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: File * IP / user agent). Orchestration `errorCode`s map to specific v2 codes rather * than v1's blanket 500. */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteFileContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { fileId } = parsed.data.params - const { workspaceId } = parsed.data.query +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteFileContract, + rateLimitEndpoint: 'file-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { fileId } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -174,8 +125,5 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil logger.info(`Deleted file ${fileId} from workspace ${workspaceId}`) return v2Data({ id: fileId, deleted: true as const }, { rateLimit }) - } catch (error) { - logger.error('Error deleting file', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.ts index 088672432c5..22ff6225615 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.ts @@ -1,57 +1,28 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2GetFileShareContract, v2UpsertFileShareContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performGetWorkspaceFileShare, performUpsertWorkspaceFileShare, } from '@/lib/workspace-files/orchestration' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FileShareAPI') +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2ErrorForOrchestration, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -interface FileRouteParams { - params: Promise<{ fileId: string }> -} - /** * GET /api/v2/files/[fileId]/share — Read a file's public share state. * * `null` means the file has never been shared. `hasPassword` is the only signal * carried for a password-gated share; the ciphertext is never exposed. */ -export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-share') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetFileShareContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { fileId } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2GetFileShareContract, + rateLimitEndpoint: 'file-share', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { fileId } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -66,10 +37,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo } return v2Data({ share: result.share ?? null }, { rateLimit }) - } catch (error) { - logger.error('Error fetching file share', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** @@ -83,23 +51,12 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo * `isActive: false` disables, it does not revoke — the token and the stored * password / allow-list survive, so re-enabling resurrects the same URL. */ -export const PUT = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-share') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpsertFileShareContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { fileId } = parsed.data.params - const { workspaceId, isActive, authType, password, allowedEmails } = parsed.data.body +export const PUT = withPublicApiRouteHandler({ + contract: v2UpsertFileShareContract, + rateLimitEndpoint: 'file-share', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { fileId } = input.params + const { workspaceId, isActive, authType, password, allowedEmails } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -123,8 +80,5 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: FileRo } return v2Data({ share: result.share }, { rateLimit }) - } catch (error) { - logger.error('Error updating file share', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/files/bulk-delete/route.ts b/apps/sim/app/api/v2/files/bulk-delete/route.ts index e0bd99a4b89..accd962d9dc 100644 --- a/apps/sim/app/api/v2/files/bulk-delete/route.ts +++ b/apps/sim/app/api/v2/files/bulk-delete/route.ts @@ -1,23 +1,9 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2BulkDeleteFilesContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FileBulkDeleteAPI') +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2ErrorForOrchestration, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -26,25 +12,11 @@ export const revalidate = 0 * POST /api/v2/files/bulk-delete — Delete files. Folder deletion is owned by * `/api/v2/files/folders` so this resource operation never accepts folder ids. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'file-bulk-delete') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2BulkDeleteFilesContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, fileIds } = parsed.data.body +export const POST = withPublicApiRouteHandler({ + contract: v2BulkDeleteFilesContract, + rateLimitEndpoint: 'file-bulk-delete', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { workspaceId, fileIds } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -64,8 +36,5 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } return v2Data({ deletedItems: { files: result.deletedItems.files } }, { rateLimit }) - } catch (error) { - logger.error('Error deleting files', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/files/folders/route.ts b/apps/sim/app/api/v2/files/folders/route.ts index 12b73d316f1..7ed1d0b4aee 100644 --- a/apps/sim/app/api/v2/files/folders/route.ts +++ b/apps/sim/app/api/v2/files/folders/route.ts @@ -1,15 +1,9 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateFileFolderContract, v2DeleteFileFolderContract, v2ListFileFoldersContract, v2RelocateFileFolderContract, } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { toFolderPathView } from '@/lib/folders/paths' import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' import { @@ -17,45 +11,23 @@ import { performDeleteWorkspaceFileFolderByPath, performRelocateWorkspaceFileFolderByPath, } from '@/lib/workspace-files/orchestration/file-folder-lifecycle' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { resolveFolderPathId, toV2PathFolder, v2FolderPathMutationError, } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FileFoldersAPI') +import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2ListFileFoldersContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListFileFoldersContract, + rateLimitEndpoint: 'files', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, parentPath, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -75,98 +47,66 @@ export const GET = withRouteHandler(async (request: NextRequest) => { null, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error listing file folders`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) -export const POST = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2CreateFileFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, +export const POST = withPublicApiRouteHandler({ + contract: v2CreateFileFolderContract, + rateLimitEndpoint: 'files', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path } = input.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await performCreateWorkspaceFileFolderAtPath({ workspaceId, userId, path }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await performCreateWorkspaceFileFolderAtPath({ workspaceId, userId, path }) - if (!result.success || !result.folder || !result.path) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') - } - return v2Data( - { folder: toFolderPathView(result.folder, result.path) }, - { rateLimit, status: 201 } - ) + return v2Data( + { folder: toFolderPathView(result.folder, result.path) }, + { rateLimit, status: 201 } + ) + }, }) -export const PATCH = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2RelocateFileFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, +export const PATCH = withPublicApiRouteHandler({ + contract: v2RelocateFileFolderContract, + rateLimitEndpoint: 'files', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path, destinationPath } = input.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await performRelocateWorkspaceFileFolderByPath({ + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path, destinationPath } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await performRelocateWorkspaceFileFolderByPath({ - workspaceId, - userId, - path, - destinationPath, - }) - if (!result.success || !result.folder || !result.path) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') - } - return v2Data({ folder: toFolderPathView(result.folder, result.path) }, { rateLimit }) + return v2Data({ folder: toFolderPathView(result.folder, result.path) }, { rateLimit }) + }, }) -export const DELETE = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2DeleteFileFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteFileFolderContract, + rateLimitEndpoint: 'files', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path, recursive } = input.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await performDeleteWorkspaceFileFolderByPath({ + workspaceId, + userId, + path, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path, recursive } = parsed.data.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await performDeleteWorkspaceFileFolderByPath({ - workspaceId, - userId, - path, - recursive, - }) - if (!result.success || !result.deletedItems) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') - } - return v2Data({ path, deleted: true as const, deletedItems: result.deletedItems }, { rateLimit }) + return v2Data( + { path, deleted: true as const, deletedItems: result.deletedItems }, + { rateLimit } + ) + }, }) diff --git a/apps/sim/app/api/v2/files/move/route.ts b/apps/sim/app/api/v2/files/move/route.ts index fa4f8b0053c..9d306d1269b 100644 --- a/apps/sim/app/api/v2/files/move/route.ts +++ b/apps/sim/app/api/v2/files/move/route.ts @@ -1,23 +1,9 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2MoveFileItemsContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FileMoveAPI') +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2ErrorForOrchestration, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -30,25 +16,11 @@ export const revalidate = 0 * collision at the destination fails the request as `CONFLICT` rather than * partially applying. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'file-move') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2MoveFileItemsContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, fileIds, targetFolderPath } = parsed.data.body +export const POST = withPublicApiRouteHandler({ + contract: v2MoveFileItemsContract, + rateLimitEndpoint: 'file-move', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, fileIds, targetFolderPath } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -68,8 +40,5 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } return v2Data({ movedItems: { files: result.movedItems.files } }, { rateLimit }) - } catch (error) { - logger.error('Error moving file items', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index a87882803af..bd8fa79e89a 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -1,14 +1,9 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { type V2File, v2CreateFileContract, v2ListFilesContract, } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { queryWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' @@ -16,10 +11,10 @@ import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, performCreateWorkspaceFile, } from '@/lib/workspace-files/orchestration' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2File, toV2Files } from '@/app/api/v2/files/utils' import { resolveFolderPathId } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { cursorSortKey, decodeSortedCursor, @@ -30,13 +25,9 @@ import { v2Data, v2Error, v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2FilesAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -48,92 +39,61 @@ export const revalidate = 0 * {@link queryWorkspaceFiles}' query. The route only translates the validated * params and the opaque cursor, so a `search` never costs a full-workspace read. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListFilesContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, +export const GET = withPublicApiRouteHandler({ + contract: v2ListFilesContract, + rateLimitEndpoint: 'files', + handler: async ({ input, auth: { userId, rateLimit } }) => { + try { + const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = input.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file') + const folderId = + folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) + if (folderPath !== undefined && folderId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') } - ) - if (!parsed.success) return parsed.response - const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = parsed.data.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file') - const folderId = - folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) - if (folderPath !== undefined && folderId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found') + const sort = cursorSortKey(sortBy, sortOrder) + const decoded = decodeSortedCursor(cursor, sort) + if (decoded.status === 'invalid') return v2CursorSortError() + + const { files, nextKeys } = await queryWorkspaceFiles(workspaceId, { + folderId, + search, + sortBy, + sortOrder, + limit, + after: decoded.status === 'ok' ? decoded.keys : undefined, + }) + + const items: V2File[] = await toV2Files(files) + const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null + + return v2CursorList(items, nextCursor, { rateLimit }) + } catch (error) { + // A cursor that doesn't fit the requested sort arrives classified as `validation` → 400. + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + throw error } - - const sort = cursorSortKey(sortBy, sortOrder) - const decoded = decodeSortedCursor(cursor, sort) - if (decoded.status === 'invalid') return v2CursorSortError() - - const { files, nextKeys } = await queryWorkspaceFiles(workspaceId, { - folderId, - search, - sortBy, - sortOrder, - limit, - after: decoded.status === 'ok' ? decoded.keys : undefined, - }) - - const items: V2File[] = await toV2Files(files) - const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null - - return v2CursorList(items, nextCursor, { rateLimit }) - } catch (error) { - // A cursor that doesn't fit the requested sort arrives classified as `validation` → 400. - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - logger.error('Error listing files', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** POST /api/v2/files — Create an authored workspace file, optionally with initial content. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateFileContract, - request, - {}, - { - invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), - maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) { - return parsed.response.status === 413 - ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') - : parsed.response - } - - const { workspaceId, name, contentType, folderPath, content, encoding } = parsed.data.body +export const POST = withPublicApiRouteHandler({ + contract: v2CreateFileContract, + rateLimitEndpoint: 'files', + parseOptions: { + invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), + maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + payloadTooLargeResponse: () => v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large'), + }, + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { workspaceId, name, contentType, folderPath, content, encoding } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -155,8 +115,5 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } return v2Data(await toV2File(result.file), { rateLimit, status: 201 }) - } catch (error) { - logger.error('Error creating file', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts index c6339746dfb..87d477083ab 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts @@ -1,43 +1,22 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CompleteFileUploadContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { completeUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' import { finalizeWorkspaceFileUpload } from '@/app/api/files/uploads/finalizers' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CaughtOrchestrationError, v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2CompleteFileUploadAPI') - -interface FileUploadRouteParams { - params: Promise<{ uploadId: string }> -} - -export const POST = withRouteHandler( - async (request: NextRequest, context: FileUploadRouteParams) => { +export const POST = withPublicApiRouteHandler({ + contract: v2CompleteFileUploadContract, + rateLimitEndpoint: 'files', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2CompleteFileUploadContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { uploadId } = parsed.data.params - const { workspaceId } = parsed.data.query + const { uploadId } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) const session = await getOwnedUploadSession({ @@ -45,7 +24,7 @@ export const POST = withRouteHandler( workspaceId, userId, purpose: 'workspace_file', - uploadToken: parsed.data.headers['upload-token'], + uploadToken: input.headers['upload-token'], }) const result = await completeUploadSession({ session, @@ -63,8 +42,7 @@ export const POST = withRouteHandler( } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to complete file upload', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts index bec4c2dc8fa..926feab0e97 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts @@ -1,41 +1,20 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateFileUploadPartUrlsContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createUploadPartUrls, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2FileUploadPartsAPI') - -interface FileUploadRouteParams { - params: Promise<{ uploadId: string }> -} - -export const POST = withRouteHandler( - async (request: NextRequest, context: FileUploadRouteParams) => { +export const POST = withPublicApiRouteHandler({ + contract: v2CreateFileUploadPartUrlsContract, + rateLimitEndpoint: 'files', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2CreateFileUploadPartUrlsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { uploadId } = parsed.data.params - const { workspaceId } = parsed.data.query + const { uploadId } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) const session = await getOwnedUploadSession({ @@ -43,19 +22,18 @@ export const POST = withRouteHandler( workspaceId, userId, purpose: 'workspace_file', - uploadToken: parsed.data.headers['upload-token'], + uploadToken: input.headers['upload-token'], }) const parts = await createUploadPartUrls({ session, - partNumbers: parsed.data.body.partNumbers, + partNumbers: input.body.partNumbers, localOrigin: request.nextUrl.origin, }) return v2Data({ parts }, { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to create file upload part URLs', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts index f06579dcc35..49d085ffe68 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts @@ -1,42 +1,21 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2AbortFileUploadContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CaughtOrchestrationError, v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2FileUploadAPI') - -interface FileUploadRouteParams { - params: Promise<{ uploadId: string }> -} - -export const DELETE = withRouteHandler( - async (request: NextRequest, context: FileUploadRouteParams) => { +export const DELETE = withPublicApiRouteHandler({ + contract: v2AbortFileUploadContract, + rateLimitEndpoint: 'files', + handler: async ({ input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2AbortFileUploadContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { uploadId } = parsed.data.params - const { workspaceId } = parsed.data.query + const { uploadId } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) const session = await getOwnedUploadSession({ @@ -44,15 +23,14 @@ export const DELETE = withRouteHandler( workspaceId, userId, purpose: 'workspace_file', - uploadToken: parsed.data.headers['upload-token'], + uploadToken: input.headers['upload-token'], }) const aborted = await abortUploadSession(session) return v2Data(await toV2FileUpload(aborted, null), { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to abort file upload session', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/files/uploads/route.ts b/apps/sim/app/api/v2/files/uploads/route.ts index b1daf54bcd8..60ef13d4bd0 100644 --- a/apps/sim/app/api/v2/files/uploads/route.ts +++ b/apps/sim/app/api/v2/files/uploads/route.ts @@ -1,73 +1,52 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateFileUploadContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createUploadSession } from '@/lib/uploads/upload-session/service' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' import { resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2FileUploadsAPI') - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateFileUploadContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, name, contentType, size, folderPath } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const resolution = await resolveFolderPathIdentity({ - workspaceId, - resourceType: 'file', - path: folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - const session = await createUploadSession({ - workspaceId, - userId, - purpose: 'workspace_file', - fileName: name, - contentType, - fileSize: size, - metadata: { folderId: resolution.folderId }, - localOrigin: request.nextUrl.origin, - }) - return v2Data( - { - session: await toV2FileUpload(session, null), - uploadToken: session.uploadToken, - transfer: session.transfer, - }, - { rateLimit, status: 201 } - ) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - logger.error('Failed to create file upload session', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } +export const POST = withPublicApiRouteHandler({ + contract: v2CreateFileUploadContract, + rateLimitEndpoint: 'files', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + try { + const { workspaceId, name, contentType, size, folderPath } = input.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const resolution = await resolveFolderPathIdentity({ + workspaceId, + resourceType: 'file', + path: folderPath ?? '/', + }) + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + const session = await createUploadSession({ + workspaceId, + userId, + purpose: 'workspace_file', + fileName: name, + contentType, + fileSize: size, + metadata: { folderId: resolution.folderId }, + localOrigin: request.nextUrl.origin, + }) + return v2Data( + { + session: await toV2FileUpload(session, null), + uploadToken: session.uploadToken, + transfer: session.transfer, + }, + { rateLimit, status: 201 } + ) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + throw error + } + }, }) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts index 8f80e98e33e..079e4d18311 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -1,37 +1,20 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { type V2KnowledgeDocument, v2DeleteKnowledgeDocumentContract, v2GetKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getKnowledgeDocument } from '@/lib/knowledge/documents/service' import { performDeleteKnowledgeDocument } from '@/lib/knowledge/orchestration' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' -import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2KnowledgeDocumentDetailAPI') +import type { RateLimitResult } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -interface DocumentDetailRouteParams { - params: Promise<{ id: string; documentId: string }> -} - /** * Resolves a knowledge base via the shared v1 ownership invariant * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A @@ -54,123 +37,83 @@ async function resolveKnowledgeBaseScoped( } /** GET /api/v2/knowledge/[id]/documents/[documentId] — Get document details. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: DocumentDetailRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetKnowledgeDocumentContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id: knowledgeBaseId, documentId } = parsed.data.params - - const result = await resolveKnowledgeBaseScoped( - knowledgeBaseId, - parsed.data.query.workspaceId, - userId, - rateLimit, - 'read' - ) - if (result instanceof NextResponse) return result - - const doc = await getKnowledgeDocument(knowledgeBaseId, documentId) - if (!doc) return v2Error('NOT_FOUND', 'Document not found') - - const documentDetail: V2KnowledgeDocument = { - id: doc.id, - knowledgeBaseId: doc.knowledgeBaseId, - filename: doc.filename, - fileSize: doc.fileSize, - mimeType: doc.mimeType, - processingStatus: doc.processingStatus as V2KnowledgeDocument['processingStatus'], - processingError: doc.processingError, - processingStartedAt: serializeDate(doc.processingStartedAt), - processingCompletedAt: serializeDate(doc.processingCompletedAt), - chunkCount: doc.chunkCount, - tokenCount: doc.tokenCount, - characterCount: doc.characterCount, - enabled: doc.enabled, - connectorId: doc.connectorId, - connectorType: doc.connectorType ?? null, - sourceUrl: doc.sourceUrl, - createdAt: serializeDate(doc.uploadedAt), - } - - return v2Data({ document: documentDetail }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error getting document`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') +export const GET = withPublicApiRouteHandler({ + contract: v2GetKnowledgeDocumentContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { id: knowledgeBaseId, documentId } = input.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + input.query.workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + const doc = await getKnowledgeDocument(knowledgeBaseId, documentId) + if (!doc) return v2Error('NOT_FOUND', 'Document not found') + + const documentDetail: V2KnowledgeDocument = { + id: doc.id, + knowledgeBaseId: doc.knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus as V2KnowledgeDocument['processingStatus'], + processingError: doc.processingError, + processingStartedAt: serializeDate(doc.processingStartedAt), + processingCompletedAt: serializeDate(doc.processingCompletedAt), + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + connectorId: doc.connectorId, + connectorType: doc.connectorType ?? null, + sourceUrl: doc.sourceUrl, + createdAt: serializeDate(doc.uploadedAt), } - } -) - -/** DELETE /api/v2/knowledge/[id]/documents/[documentId] — Delete a document. */ -export const DELETE = withRouteHandler( - async (request: NextRequest, context: DocumentDetailRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2DeleteKnowledgeDocumentContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response + return v2Data({ document: documentDetail }, { rateLimit }) + }, +}) - const { id: knowledgeBaseId, documentId } = parsed.data.params - - const result = await resolveKnowledgeBaseScoped( - knowledgeBaseId, - parsed.data.query.workspaceId, - userId, - rateLimit, - 'write' - ) - if (result instanceof NextResponse) return result - - const doc = await getKnowledgeDocument(knowledgeBaseId, documentId) - if (!doc) return v2Error('NOT_FOUND', 'Document not found') - - const outcome = await performDeleteKnowledgeDocument({ - knowledgeBase: { - id: knowledgeBaseId, - name: result.kb.name, - workspaceId: parsed.data.query.workspaceId, - }, - document: { id: documentId, filename: doc.filename }, - userId, - source: 'api', - requestId, - request, - }) - if (!outcome.success) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error) - } - - return v2Data({ id: documentId, deleted: true as const }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error deleting document`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') +/** DELETE /api/v2/knowledge/[id]/documents/[documentId] — Delete a document. */ +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteKnowledgeDocumentContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + const { id: knowledgeBaseId, documentId } = input.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + input.query.workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + const doc = await getKnowledgeDocument(knowledgeBaseId, documentId) + if (!doc) return v2Error('NOT_FOUND', 'Document not found') + + const outcome = await performDeleteKnowledgeDocument({ + knowledgeBase: { + id: knowledgeBaseId, + name: result.kb.name, + workspaceId: input.query.workspaceId, + }, + document: { id: documentId, filename: doc.filename }, + userId, + source: 'api', + requestId, + request, + }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) } - } -) + + return v2Data({ id: documentId, deleted: true as const }, { rateLimit }) + }, +}) 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 54be2ec3882..c189de04389 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -1,24 +1,19 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { type V2KnowledgeDocumentSummary, v2ListKnowledgeDocumentsContract, v2UploadKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' import { checkAttributedUsageLimits, resolveBillingAttribution, resolveSystemBillingAttribution, } from '@/lib/billing/core/billing-attribution' -import { generateRequestId } from '@/lib/core/utils/request' import { isPayloadSizeLimitError, readFileToBufferWithLimit, readFormDataWithLimit, } from '@/lib/core/utils/stream-limits' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getDocuments } from '@/lib/knowledge/documents/service' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration' @@ -26,9 +21,9 @@ import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' import { validateFileType } from '@/lib/uploads/utils/validation' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' -import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import type { RateLimitResult } from '@/app/api/v1/middleware' import { decodeCursor, encodeCursor, @@ -36,22 +31,14 @@ import { v2Data, v2Error, v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2KnowledgeDocumentsAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 const MAX_FILE_SIZE = MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 -interface DocumentsRouteParams { - params: Promise<{ id: string }> -} - /** * Resolves a knowledge base via the shared v1 ownership invariant * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A @@ -74,26 +61,12 @@ async function resolveKnowledgeBaseScoped( } /** GET /api/v2/knowledge/[id]/documents — List documents in a knowledge base. */ -export const GET = withRouteHandler(async (request: NextRequest, context: DocumentsRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2ListKnowledgeDocumentsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { workspaceId, limit, cursor, search, enabledFilter, sortBy, sortOrder } = - parsed.data.query - const { id: knowledgeBaseId } = parsed.data.params +export const GET = withPublicApiRouteHandler({ + contract: v2ListKnowledgeDocumentsContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + const { workspaceId, limit, cursor, search, enabledFilter, sortBy, sortOrder } = input.query + const { id: knowledgeBaseId } = input.params const result = await resolveKnowledgeBaseScoped( knowledgeBaseId, @@ -144,12 +117,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Docume ? encodeCursor({ offset: offset + limit }) : null return v2CursorList(documents, nextCursor, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error listing documents`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** @@ -160,26 +128,13 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Docume * unauthorized caller never streams a file into memory. Order: rate limit → * KB ownership (write) → usage gate → buffered multipart read. */ -export const POST = withRouteHandler( - async (request: NextRequest, context: DocumentsRouteParams) => { - const requestId = generateRequestId() - +export const POST = withPublicApiRouteHandler({ + contract: v2UploadKnowledgeDocumentContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UploadKnowledgeDocumentContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id: knowledgeBaseId } = parsed.data.params - const { workspaceId } = parsed.data.query + const { id: knowledgeBaseId } = input.params + const { workspaceId } = input.query const result = await resolveKnowledgeBaseScoped( knowledgeBaseId, @@ -292,10 +247,7 @@ export const POST = withRouteHandler( return v2Error('PAYLOAD_TOO_LARGE', error.message) } - logger.error(`[${requestId}] Error uploading document`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) 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 92bc7c69b8a..05b111c7cbf 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,13 +1,7 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { v2CompleteKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { completeUploadSession } from '@/lib/uploads/upload-session/service' -import { checkRateLimit } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { finalizeKnowledgeDocumentUpload, getOwnedKnowledgeDocumentUpload, @@ -15,41 +9,15 @@ import { resolveKnowledgeDocumentUploadAttribution, toV2KnowledgeDocumentUpload, } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2CompleteKnowledgeDocumentUploadAPI') - -interface KnowledgeDocumentUploadRouteParams { - params: Promise<{ id: string; uploadId: string }> -} - -export const POST = withRouteHandler( - async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { - const requestId = generateRequestId() +import { v2CaughtOrchestrationError, v2Data } from '@/app/api/v2/lib/response' +export const POST = withPublicApiRouteHandler({ + contract: v2CompleteKnowledgeDocumentUploadContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CompleteKnowledgeDocumentUploadContract, - request, - context, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - const { id: knowledgeBaseId, uploadId } = parsed.data.params - const { workspaceId } = parsed.data.query + const { id: knowledgeBaseId, uploadId } = input.params + const { workspaceId } = input.query const access = await resolveKnowledgeDocumentUploadAccess({ knowledgeBaseId, @@ -64,7 +32,7 @@ export const POST = withRouteHandler( uploadId, workspaceId, userId, - uploadToken: parsed.data.headers['upload-token'], + uploadToken: input.headers['upload-token'], }) const result = await completeUploadSession({ session, @@ -87,10 +55,7 @@ export const POST = withRouteHandler( } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error(`[${requestId}] Failed to complete knowledge-document upload`, { - error: getErrorMessage(error), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) 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 2bf941b0eb0..8b7320f397a 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,49 +1,20 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { v2CreateKnowledgeDocumentUploadPartUrlsContract } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createUploadPartUrls } from '@/lib/uploads/upload-session/service' -import { checkRateLimit } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { getOwnedKnowledgeDocumentUpload, resolveKnowledgeDocumentUploadAccess, } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2KnowledgeDocumentUploadPartsAPI') - -interface KnowledgeDocumentUploadRouteParams { - params: Promise<{ id: string; uploadId: string }> -} +import { v2CaughtOrchestrationError, v2Data } from '@/app/api/v2/lib/response' -export const POST = withRouteHandler( - async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { +export const POST = withPublicApiRouteHandler({ + contract: v2CreateKnowledgeDocumentUploadPartUrlsContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateKnowledgeDocumentUploadPartUrlsContract, - request, - context, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - const { id: knowledgeBaseId, uploadId } = parsed.data.params - const { workspaceId } = parsed.data.query + const { id: knowledgeBaseId, uploadId } = input.params + const { workspaceId } = input.query const access = await resolveKnowledgeDocumentUploadAccess({ knowledgeBaseId, @@ -58,21 +29,18 @@ export const POST = withRouteHandler( uploadId, workspaceId, userId, - uploadToken: parsed.data.headers['upload-token'], + uploadToken: input.headers['upload-token'], }) const parts = await createUploadPartUrls({ session, - partNumbers: parsed.data.body.partNumbers, + partNumbers: input.body.partNumbers, localOrigin: request.nextUrl.origin, }) return v2Data({ parts }, { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to create knowledge-document upload part URLs', { - error: getErrorMessage(error), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) 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 9fd10cad84e..a7ba8567f2a 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,47 +1,21 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { v2AbortKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { checkRateLimit } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { abortKnowledgeDocumentUpload, getOwnedKnowledgeDocumentUpload, resolveKnowledgeDocumentUploadAccess, toV2KnowledgeDocumentUpload, } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2KnowledgeDocumentUploadAPI') - -interface KnowledgeDocumentUploadRouteParams { - params: Promise<{ id: string; uploadId: string }> -} +import { v2CaughtOrchestrationError, v2Data } from '@/app/api/v2/lib/response' -export const DELETE = withRouteHandler( - async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { +export const DELETE = withPublicApiRouteHandler({ + contract: v2AbortKnowledgeDocumentUploadContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2AbortKnowledgeDocumentUploadContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { id: knowledgeBaseId, uploadId } = parsed.data.params - const { workspaceId } = parsed.data.query + const { id: knowledgeBaseId, uploadId } = input.params + const { workspaceId } = input.query const access = await resolveKnowledgeDocumentUploadAccess({ knowledgeBaseId, @@ -56,17 +30,14 @@ export const DELETE = withRouteHandler( uploadId, workspaceId, userId, - uploadToken: parsed.data.headers['upload-token'], + uploadToken: input.headers['upload-token'], }) const aborted = await abortKnowledgeDocumentUpload(session, knowledgeBaseId) return v2Data(toV2KnowledgeDocumentUpload(aborted, null), { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to abort knowledge-document upload session', { - error: getErrorMessage(error), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) 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 e736548fd98..509a01f6de0 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,48 +1,22 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { v2CreateKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { validateFileType } from '@/lib/uploads/utils/validation' -import { checkRateLimit } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { createKnowledgeDocumentUploadSession, resolveKnowledgeDocumentUploadAccess, resolveKnowledgeDocumentUploadBilling, toV2KnowledgeDocumentUpload, } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2KnowledgeDocumentUploadsAPI') - -interface KnowledgeDocumentUploadsRouteParams { - params: Promise<{ id: string }> -} +import { v2CaughtOrchestrationError, v2Data, v2Error } from '@/app/api/v2/lib/response' -export const POST = withRouteHandler( - async (request: NextRequest, context: KnowledgeDocumentUploadsRouteParams) => { +export const POST = withPublicApiRouteHandler({ + contract: v2CreateKnowledgeDocumentUploadContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2CreateKnowledgeDocumentUploadContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { id: knowledgeBaseId } = parsed.data.params - const { workspaceId, name, contentType, size, ...metadata } = parsed.data.body + const { id: knowledgeBaseId } = input.params + const { workspaceId, name, contentType, size, ...metadata } = input.body const access = await resolveKnowledgeDocumentUploadAccess({ knowledgeBaseId, @@ -85,10 +59,7 @@ export const POST = withRouteHandler( } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to create knowledge-document upload session', { - error: getErrorMessage(error), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts index a98d877d330..f2da8d298cb 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -1,41 +1,24 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { v2DeleteKnowledgeBaseContract, v2GetKnowledgeBaseContract, v2UpdateKnowledgeBaseContract, } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { performDeleteKnowledgeBase, performUpdateKnowledgeBase, } from '@/lib/knowledge/orchestration' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { formatKnowledgeBase, resolveKnowledgeBase } from '@/app/api/v1/knowledge/utils' -import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import type { RateLimitResult } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2KnowledgeDetailAPI') +import { v2Data, v2Error, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -interface KnowledgeRouteParams { - params: Promise<{ id: string }> -} - /** * Resolves a knowledge base via the shared v1 ownership invariant * ({@link resolveKnowledgeBase}: workspace access + KB-belongs-to-workspace) and @@ -60,37 +43,21 @@ async function resolveKnowledgeBaseScoped( } /** GET /api/v2/knowledge/[id] — Get knowledge base details. */ -export const GET = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetKnowledgeBaseContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params +export const GET = withPublicApiRouteHandler({ + contract: v2GetKnowledgeBaseContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { id } = input.params const result = await resolveKnowledgeBaseScoped( id, - parsed.data.query.workspaceId, + input.query.workspaceId, userId, rateLimit, 'read' ) if (result instanceof NextResponse) return result - const folderIndex = await loadActiveFolderPathIndex( - parsed.data.query.workspaceId, - 'knowledge_base' - ) + const folderIndex = await loadActiveFolderPathIndex(input.query.workspaceId, 'knowledge_base') return v2Data( { @@ -101,34 +68,16 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Knowle }, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error getting knowledge base`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** PUT /api/v2/knowledge/[id] — Update a knowledge base. */ -export const PUT = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateKnowledgeBaseContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId, name, description, chunkingConfig, folderPath } = parsed.data.body +export const PUT = withPublicApiRouteHandler({ + contract: v2UpdateKnowledgeBaseContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + const { id } = input.params + const { workspaceId, name, description, chunkingConfig, folderPath } = input.body const result = await resolveKnowledgeBaseScoped(id, workspaceId, userId, rateLimit, 'write') if (result instanceof NextResponse) return result @@ -168,60 +117,35 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle }, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error updating knowledge base`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** DELETE /api/v2/knowledge/[id] — Delete a knowledge base. */ -export const DELETE = withRouteHandler( - async (request: NextRequest, context: KnowledgeRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteKnowledgeBaseContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const result = await resolveKnowledgeBaseScoped( - id, - parsed.data.query.workspaceId, - userId, - rateLimit, - 'write' - ) - if (result instanceof NextResponse) return result - - const outcome = await performDeleteKnowledgeBase({ - knowledgeBase: { id, name: result.kb.name, workspaceId: parsed.data.query.workspaceId }, - userId, - source: 'api', - requestId, - request, - }) - if (!outcome.success) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error) - } +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteKnowledgeBaseContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + const { id } = input.params + const result = await resolveKnowledgeBaseScoped( + id, + input.query.workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result - return v2Data({ id, deleted: true as const }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error deleting knowledge base`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + const outcome = await performDeleteKnowledgeBase({ + knowledgeBase: { id, name: result.kb.name, workspaceId: input.query.workspaceId }, + userId, + source: 'api', + requestId, + request, + }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) } - } -) + + return v2Data({ id, deleted: true as const }, { rateLimit }) + }, +}) diff --git a/apps/sim/app/api/v2/knowledge/folders/route.ts b/apps/sim/app/api/v2/knowledge/folders/route.ts index 4995d5cc486..3d5ff54a79a 100644 --- a/apps/sim/app/api/v2/knowledge/folders/route.ts +++ b/apps/sim/app/api/v2/knowledge/folders/route.ts @@ -1,60 +1,32 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateKnowledgeFolderContract, v2DeleteKnowledgeFolderContract, v2ListKnowledgeFoldersContract, v2RelocateKnowledgeFolderContract, } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createFolderAtPath, deleteFolderByPath, relocateFolderByPath, } from '@/lib/folders/orchestration' import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { resolveFolderPathId, toV2PathFolder, v2FolderPathMutationError, } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2KnowledgeFoldersAPI') +import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const rateLimit = await checkRateLimit(request, 'knowledge') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2ListKnowledgeFoldersContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListKnowledgeFoldersContract, + rateLimitEndpoint: 'knowledge', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, parentPath, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -74,114 +46,82 @@ export const GET = withRouteHandler(async (request: NextRequest) => { null, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error listing knowledge folders`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) -export const POST = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'knowledge') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2CreateKnowledgeFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, +export const POST = withPublicApiRouteHandler({ + contract: v2CreateKnowledgeFolderContract, + rateLimitEndpoint: 'knowledge', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path } = input.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await createFolderAtPath({ + resourceType: 'knowledge_base', + workspaceId, + userId, + path, + }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await createFolderAtPath({ - resourceType: 'knowledge_base', - workspaceId, - userId, - path, - }) - if (!result.success || !result.folder) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') - return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit, status: 201 }) + const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') + return v2Data( + { folder: toV2PathFolder(result.folder, index, false) }, + { rateLimit, status: 201 } + ) + }, }) -export const PATCH = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'knowledge') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2RelocateKnowledgeFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, +export const PATCH = withPublicApiRouteHandler({ + contract: v2RelocateKnowledgeFolderContract, + rateLimitEndpoint: 'knowledge', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path, destinationPath } = input.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await relocateFolderByPath({ + resourceType: 'knowledge_base', + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path, destinationPath } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await relocateFolderByPath({ - resourceType: 'knowledge_base', - workspaceId, - userId, - path, - destinationPath, - }) - if (!result.success || !result.folder) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') - return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit }) + const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') + return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit }) + }, }) -export const DELETE = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'knowledge') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2DeleteKnowledgeFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path, recursive } = parsed.data.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await deleteFolderByPath({ - resourceType: 'knowledge_base', - workspaceId, - userId, - path, - recursive, - }) - if (!result.success || !result.deletedItems) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') - } - return v2Data( - { +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteKnowledgeFolderContract, + rateLimitEndpoint: 'knowledge', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path, recursive } = input.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await deleteFolderByPath({ + resourceType: 'knowledge_base', + workspaceId, + userId, path, - deleted: true as const, - deletedItems: { - folders: result.deletedItems.folders, - knowledgeBases: result.deletedItems.knowledgeBases ?? 0, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + return v2Data( + { + path, + deleted: true as const, + deletedItems: { + folders: result.deletedItems.folders, + knowledgeBases: result.deletedItems.knowledgeBases ?? 0, + }, }, - }, - { rateLimit } - ) + { rateLimit } + ) + }, }) diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index 12720efaa46..8b20a6b7d3d 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -1,63 +1,35 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateKnowledgeBaseContract, v2ListKnowledgeBasesContract, } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { performCreateKnowledgeBase } from '@/lib/knowledge/orchestration' import { getKnowledgeBases } from '@/lib/knowledge/service' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { formatKnowledgeBase } from '@/app/api/v1/knowledge/utils' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathId, resolveFolderPathIdentity, } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CursorList, v2Data, v2Error, v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2KnowledgeAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/knowledge — List knowledge bases in a workspace. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'knowledge') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListKnowledgeBasesContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, folderPath, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListKnowledgeBasesContract, + rateLimitEndpoint: 'knowledge', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, folderPath, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -82,38 +54,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { // `getKnowledgeBases` returns the full bounded workspace set → single page. return v2CursorList(items, null, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error listing knowledge bases`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** POST /api/v2/knowledge — Create a new knowledge base. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'knowledge') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateKnowledgeBaseContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, name, description, chunkingConfig, folderPath } = parsed.data.body +export const POST = withPublicApiRouteHandler({ + contract: v2CreateKnowledgeBaseContract, + rateLimitEndpoint: 'knowledge', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + const { workspaceId, name, description, chunkingConfig, folderPath } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -149,10 +98,5 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, { rateLimit, status: 201 } ) - } catch (error) { - logger.error(`[${requestId}] Error creating knowledge base`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index ac8ca1c4842..dc6f3fdc829 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -1,18 +1,13 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { type V2KnowledgeSearchResult, v2SearchKnowledgeContract, } from '@/lib/api/contracts/v2/knowledge' -import { isZodError, parseRequest } from '@/lib/api/server' +import { isZodError } from '@/lib/api/server' import { checkAttributedUsageLimits, resolveBillingAttribution, resolveSystemBillingAttribution, } from '@/lib/billing/core/billing-attribution' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' import { @@ -25,274 +20,252 @@ import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' import type { StructuredFilter } from '@/lib/knowledge/types' import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2Data, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2KnowledgeSearchAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 /** POST /api/v2/knowledge/search — Vector / tag search across knowledge bases. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'knowledge-search') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) +export const POST = withPublicApiRouteHandler({ + contract: v2SearchKnowledgeContract, + rateLimitEndpoint: 'knowledge-search', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { workspaceId, topK, query, tagFilters } = input.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + /** + * A query incurs hosted embedding (+ optional rerank) cost — gate the + * actor's usage before spending; tag-only search is free. Workspace keys + * resolve their system actor and immutable payer from one workspace read. + */ + const hasBillableQuery = Boolean(query?.trim()) + const billingAttribution = hasBillableQuery + ? rateLimit.keyType === 'workspace' + ? await resolveSystemBillingAttribution(workspaceId) + : await resolveBillingAttribution({ actorUserId: userId, workspaceId }) + : undefined + const billingActorUserId = billingAttribution?.actorUserId ?? userId + if (billingAttribution) { + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + } - const userId = rateLimit.userId! + const knowledgeBaseIds = Array.isArray(input.body.knowledgeBaseIds) + ? input.body.knowledgeBaseIds + : [input.body.knowledgeBaseIds] - const gate = await v2ApiGateError(userId) - if (gate) return gate + const accessChecks = await Promise.all( + knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId)) + ) + const accessibleKbs = accessChecks + .filter( + (ac): ac is KnowledgeBaseAccessResult => + ac.hasAccess === true && ac.knowledgeBase.workspaceId === workspaceId + ) + .map((ac) => ac.knowledgeBase) + const accessibleKbIds = accessibleKbs.map((kb) => kb.id) - const parsed = await parseRequest( - v2SearchKnowledgeContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, + if (accessibleKbIds.length === 0) { + return v2Error('NOT_FOUND', 'Knowledge base not found or access denied') } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, topK, query, tagFilters } = parsed.data.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - /** - * A query incurs hosted embedding (+ optional rerank) cost — gate the - * actor's usage before spending; tag-only search is free. Workspace keys - * resolve their system actor and immutable payer from one workspace read. - */ - const hasBillableQuery = Boolean(query?.trim()) - const billingAttribution = hasBillableQuery - ? rateLimit.keyType === 'workspace' - ? await resolveSystemBillingAttribution(workspaceId) - : await resolveBillingAttribution({ actorUserId: userId, workspaceId }) - : undefined - const billingActorUserId = billingAttribution?.actorUserId ?? userId - if (billingAttribution) { - const usage = await checkAttributedUsageLimits(billingAttribution) - if (usage.isExceeded) { + const inaccessibleKbIds = knowledgeBaseIds.filter((id) => !accessibleKbIds.includes(id)) + if (inaccessibleKbIds.length > 0) { return v2Error( - 'USAGE_LIMIT_EXCEEDED', - usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + 'NOT_FOUND', + `Knowledge bases not found or access denied: ${inaccessibleKbIds.join(', ')}` ) } - } - - const knowledgeBaseIds = Array.isArray(parsed.data.body.knowledgeBaseIds) - ? parsed.data.body.knowledgeBaseIds - : [parsed.data.body.knowledgeBaseIds] - const accessChecks = await Promise.all( - knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId)) - ) - const accessibleKbs = accessChecks - .filter( - (ac): ac is KnowledgeBaseAccessResult => - ac.hasAccess === true && ac.knowledgeBase.workspaceId === workspaceId - ) - .map((ac) => ac.knowledgeBase) - const accessibleKbIds = accessibleKbs.map((kb) => kb.id) + let structuredFilters: StructuredFilter[] = [] + const tagDefsCache = new Map>>() - if (accessibleKbIds.length === 0) { - return v2Error('NOT_FOUND', 'Knowledge base not found or access denied') - } - - const inaccessibleKbIds = knowledgeBaseIds.filter((id) => !accessibleKbIds.includes(id)) - if (inaccessibleKbIds.length > 0) { - return v2Error( - 'NOT_FOUND', - `Knowledge bases not found or access denied: ${inaccessibleKbIds.join(', ')}` - ) - } - - let structuredFilters: StructuredFilter[] = [] - const tagDefsCache = new Map>>() - - if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 1) { - return v2Error( - 'BAD_REQUEST', - 'Tag filters are only supported when searching a single knowledge base' - ) - } + if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 1) { + return v2Error( + 'BAD_REQUEST', + 'Tag filters are only supported when searching a single knowledge base' + ) + } - if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 0) { - const kbId = accessibleKbIds[0] - const tagDefs = await getDocumentTagDefinitions(kbId) - tagDefsCache.set(kbId, tagDefs) + if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 0) { + const kbId = accessibleKbIds[0] + const tagDefs = await getDocumentTagDefinitions(kbId) + tagDefsCache.set(kbId, tagDefs) + + const displayNameToTagDef: Record = {} + tagDefs.forEach((def) => { + displayNameToTagDef[def.displayName] = { + tagSlot: def.tagSlot, + fieldType: def.fieldType, + } + }) + + const undefinedTags: string[] = [] + const typeErrors: string[] = [] + + for (const filter of tagFilters) { + const tagDef = displayNameToTagDef[filter.tagName] + if (!tagDef) { + undefinedTags.push(filter.tagName) + continue + } + const validationError = validateTagValue( + filter.tagName, + String(filter.value), + tagDef.fieldType + ) + if (validationError) { + typeErrors.push(validationError) + } + } - const displayNameToTagDef: Record = {} - tagDefs.forEach((def) => { - displayNameToTagDef[def.displayName] = { - tagSlot: def.tagSlot, - fieldType: def.fieldType, + if (undefinedTags.length > 0 || typeErrors.length > 0) { + const errorParts: string[] = [] + if (undefinedTags.length > 0) { + errorParts.push(buildUndefinedTagsError(undefinedTags)) + } + if (typeErrors.length > 0) { + errorParts.push(...typeErrors) + } + return v2Error('BAD_REQUEST', errorParts.join('\n')) } - }) - const undefinedTags: string[] = [] - const typeErrors: string[] = [] + structuredFilters = tagFilters.map((filter) => { + const tagDef = displayNameToTagDef[filter.tagName]! + return { + tagSlot: tagDef.tagSlot, + fieldType: tagDef.fieldType, + operator: filter.operator, + value: filter.value, + valueTo: filter.valueTo, + } + }) + } - for (const filter of tagFilters) { - const tagDef = displayNameToTagDef[filter.tagName] - if (!tagDef) { - undefinedTags.push(filter.tagName) - continue - } - const validationError = validateTagValue( - filter.tagName, - String(filter.value), - tagDef.fieldType + const hasQuery = Boolean(query && query.trim().length > 0) + const hasFilters = structuredFilters.length > 0 + + const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel))) + if (hasQuery && embeddingModels.length > 1) { + return v2Error( + 'BAD_REQUEST', + 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.' ) - if (validationError) { - typeErrors.push(validationError) - } } + const queryEmbeddingModel = embeddingModels[0] - if (undefinedTags.length > 0 || typeErrors.length > 0) { - const errorParts: string[] = [] - if (undefinedTags.length > 0) { - errorParts.push(buildUndefinedTagsError(undefinedTags)) - } - if (typeErrors.length > 0) { - errorParts.push(...typeErrors) - } - return v2Error('BAD_REQUEST', errorParts.join('\n')) + if (!hasQuery && !hasFilters) { + return v2Error('BAD_REQUEST', 'Either query or tagFilters must be provided') } - structuredFilters = tagFilters.map((filter) => { - const tagDef = displayNameToTagDef[filter.tagName]! - return { - tagSlot: tagDef.tagSlot, - fieldType: tagDef.fieldType, - operator: filter.operator, - value: filter.value, - valueTo: filter.valueTo, - } - }) - } - - const hasQuery = Boolean(query && query.trim().length > 0) - const hasFilters = structuredFilters.length > 0 + let queryEmbeddingIsBYOK: boolean | null = null + let queryVector: string | undefined - const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel))) - if (hasQuery && embeddingModels.length > 1) { - return v2Error( - 'BAD_REQUEST', - 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.' - ) - } - const queryEmbeddingModel = embeddingModels[0] + if (hasQuery) { + const queryEmbeddingResult = await generateSearchEmbedding( + query!, + queryEmbeddingModel, + workspaceId + ) + queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK + queryVector = JSON.stringify(queryEmbeddingResult.embedding) + } - if (!hasQuery && !hasFilters) { - return v2Error('BAD_REQUEST', 'Either query or tagFilters must be provided') - } + const results: SearchResult[] = await executeKnowledgeSearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + searchMode: 'vector', + query, + queryVector, + structuredFilters, + }) - let queryEmbeddingIsBYOK: boolean | null = null - let queryVector: string | undefined + if (queryEmbeddingIsBYOK !== null) { + await recordSearchEmbeddingUsage({ + userId: billingActorUserId, + workspaceId, + embeddingModel: queryEmbeddingModel, + query: query!, + isBYOK: queryEmbeddingIsBYOK, + sourceReference: `v2-kb-search:${requestId}`, + billingAttribution, + }) + } - if (hasQuery) { - const queryEmbeddingResult = await generateSearchEmbedding( - query!, - queryEmbeddingModel, - workspaceId + const tagDefsResults = await Promise.all( + accessibleKbIds.map(async (kbId) => { + try { + const tagDefs = tagDefsCache.get(kbId) ?? (await getDocumentTagDefinitions(kbId)) + const map: Record = {} + tagDefs.forEach((def) => { + map[def.tagSlot] = def.displayName + }) + return { kbId, map } + } catch { + return { kbId, map: {} as Record } + } + }) ) - queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK - queryVector = JSON.stringify(queryEmbeddingResult.embedding) - } - - const results: SearchResult[] = await executeKnowledgeSearch({ - knowledgeBaseIds: accessibleKbIds, - topK, - searchMode: 'vector', - query, - queryVector, - structuredFilters, - }) - - if (queryEmbeddingIsBYOK !== null) { - await recordSearchEmbeddingUsage({ - userId: billingActorUserId, - workspaceId, - embeddingModel: queryEmbeddingModel, - query: query!, - isBYOK: queryEmbeddingIsBYOK, - sourceReference: `v2-kb-search:${requestId}`, - billingAttribution, + const tagDefinitionsMap: Record> = {} + tagDefsResults.forEach(({ kbId, map }) => { + tagDefinitionsMap[kbId] = map }) - } - const tagDefsResults = await Promise.all( - accessibleKbIds.map(async (kbId) => { - try { - const tagDefs = tagDefsCache.get(kbId) ?? (await getDocumentTagDefinitions(kbId)) - const map: Record = {} - tagDefs.forEach((def) => { - map[def.tagSlot] = def.displayName - }) - return { kbId, map } - } catch { - return { kbId, map: {} as Record } - } - }) - ) - const tagDefinitionsMap: Record> = {} - tagDefsResults.forEach(({ kbId, map }) => { - tagDefinitionsMap[kbId] = map - }) + const documentIds = results.map((r) => r.documentId) + const documentMetadataMap = await getDocumentMetadataByIds(documentIds) - const documentIds = results.map((r) => r.documentId) - const documentMetadataMap = await getDocumentMetadataByIds(documentIds) + const searchResults: V2KnowledgeSearchResult[] = results.map((result) => { + const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} + const metadata: Record = {} - const searchResults: V2KnowledgeSearchResult[] = results.map((result) => { - const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} - const metadata: Record = {} + ALL_TAG_SLOTS.forEach((slot) => { + const tagValue = result[slot as keyof SearchResult] + if (tagValue !== null && tagValue !== undefined) { + const displayName = kbTagMap[slot] || slot + metadata[displayName] = tagValue + } + }) - ALL_TAG_SLOTS.forEach((slot) => { - const tagValue = result[slot as keyof SearchResult] - if (tagValue !== null && tagValue !== undefined) { - const displayName = kbTagMap[slot] || slot - metadata[displayName] = tagValue + const docMeta = documentMetadataMap[result.documentId] + return { + documentId: result.documentId, + documentName: docMeta?.filename ?? null, + sourceUrl: docMeta?.sourceUrl ?? null, + content: result.content, + chunkIndex: result.chunkIndex, + metadata, + similarity: hasQuery ? 1 - result.distance : 1, } }) - const docMeta = documentMetadataMap[result.documentId] - return { - documentId: result.documentId, - documentName: docMeta?.filename ?? null, - sourceUrl: docMeta?.sourceUrl ?? null, - content: result.content, - chunkIndex: result.chunkIndex, - metadata, - similarity: hasQuery ? 1 - result.distance : 1, - } - }) - - return v2Data( - { - results: searchResults, - query: query || '', - knowledgeBaseIds: accessibleKbIds, - topK, - totalResults: results.length, - }, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - logger.error(`[${requestId}] Knowledge search error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + return v2Data( + { + results: searchResults, + query: query || '', + knowledgeBaseIds: accessibleKbIds, + topK, + totalResults: results.length, + }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + throw error + } + }, }) diff --git a/apps/sim/app/api/v2/logs/[runId]/route.ts b/apps/sim/app/api/v2/logs/[runId]/route.ts index a1753f70769..811637abd8f 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.ts @@ -1,19 +1,11 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { traceSpansSchema } from '@/lib/api/contracts/logs' import { type V2LogDetail, v2GetLogContract, v2LogStatusSchema } from '@/lib/api/contracts/v2/logs' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { getPublicWorkflowLog } from '@/lib/logs/public-queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' - -const logger = createLogger('V2LogDetailAPI') +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error } from '@/app/api/v2/lib/response' export const revalidate = 0 @@ -22,79 +14,59 @@ export const revalidate = 0 * public identity; the workflow-execution-log row key remains an internal * storage and pagination detail. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ runId: string }> }) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'logs-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetLogContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response +export const GET = withPublicApiRouteHandler({ + contract: v2GetLogContract, + rateLimitEndpoint: 'logs-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { runId } = input.params - const { runId } = parsed.data.params + const log = await getPublicWorkflowLog({ column: 'executionId', value: runId }) - const log = await getPublicWorkflowLog({ column: 'executionId', value: runId }) + if (!log) return v2Error('NOT_FOUND', 'Log not found') - if (!log) return v2Error('NOT_FOUND', 'Log not found') + const access = await resolveWorkspaceAccess(rateLimit, userId, log.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Log not found') - const access = await resolveWorkspaceAccess(rateLimit, userId, log.workspaceId) - if (access) return v2Error('NOT_FOUND', 'Log not found') - - const folderIndex = await loadActiveFolderPathIndex(log.workspaceId, 'workflow') - const executionData = await materializeExecutionData( - log.executionData as Record | null, - { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } - ) - if (log.workflowUserId && !log.workflowOwnerEmail) { - throw new Error(`Unable to resolve workflow owner email for ${log.workflowUserId}`) - } - - const detail: V2LogDetail = { - runId: log.executionId, - workflowId: log.workflowId, - deploymentVersionId: log.deploymentVersionId, - status: v2LogStatusSchema.parse(log.status), - level: log.level, - trigger: log.trigger, - startedAt: log.startedAt.toISOString(), - endedAt: log.endedAt ? log.endedAt.toISOString() : null, - totalDurationMs: log.totalDurationMs, - files: (log.files as unknown[] | null) ?? null, - workflow: { - id: log.workflowId, - name: log.workflowName || 'Deleted Workflow', - description: log.workflowDescription, - folderPath: log.workflowFolderId - ? (folderIndex.pathById.get(log.workflowFolderId) ?? null) - : null, - ownerEmail: log.workflowOwnerEmail, - workspaceId: log.workflowWorkspaceId, - createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null, - updatedAt: log.workflowUpdatedAt ? log.workflowUpdatedAt.toISOString() : null, - deleted: !log.workflowName || log.workflowArchivedAt !== null, - }, - workflowState: log.workflowState, - traceSpans: traceSpansSchema.parse(executionData.traceSpans ?? []), - finalOutput: executionData.finalOutput ?? null, - cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, - createdAt: log.createdAt.toISOString(), - } + const folderIndex = await loadActiveFolderPathIndex(log.workspaceId, 'workflow') + const executionData = await materializeExecutionData( + log.executionData as Record | null, + { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } + ) + if (log.workflowUserId && !log.workflowOwnerEmail) { + throw new Error(`Unable to resolve workflow owner email for ${log.workflowUserId}`) + } - return v2Data(detail, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Log detail fetch error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + const detail: V2LogDetail = { + runId: log.executionId, + workflowId: log.workflowId, + deploymentVersionId: log.deploymentVersionId, + status: v2LogStatusSchema.parse(log.status), + level: log.level, + trigger: log.trigger, + startedAt: log.startedAt.toISOString(), + endedAt: log.endedAt ? log.endedAt.toISOString() : null, + totalDurationMs: log.totalDurationMs, + files: (log.files as unknown[] | null) ?? null, + workflow: { + id: log.workflowId, + name: log.workflowName || 'Deleted Workflow', + description: log.workflowDescription, + folderPath: log.workflowFolderId + ? (folderIndex.pathById.get(log.workflowFolderId) ?? null) + : null, + ownerEmail: log.workflowOwnerEmail, + workspaceId: log.workflowWorkspaceId, + createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null, + updatedAt: log.workflowUpdatedAt ? log.workflowUpdatedAt.toISOString() : null, + deleted: !log.workflowName || log.workflowArchivedAt !== null, + }, + workflowState: log.workflowState, + traceSpans: traceSpansSchema.parse(executionData.traceSpans ?? []), + finalOutput: executionData.finalOutput ?? null, + cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, + createdAt: log.createdAt.toISOString(), } - } -) + + return v2Data(detail, { rateLimit }) + }, +}) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index 1035846846a..5e65d643894 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -1,58 +1,26 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { traceSpansSchema } from '@/lib/api/contracts/logs' import { type V2LogListItem, v2ListLogsContract, v2LogStatusSchema, } from '@/lib/api/contracts/v2/logs' -import { parseRequest } from '@/lib/api/server' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { decodePublicLogCursor, listPublicWorkflowLogs } from '@/lib/logs/public-queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { resolveFolderPathId } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2LogsAPI') +import { v2CursorList, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'logs') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListLogsContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const params = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListLogsContract, + rateLimitEndpoint: 'logs', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const params = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -155,10 +123,5 @@ export const GET = withRouteHandler(async (request: NextRequest) => { : data.map(buildItem) return v2CursorList(formattedLogs, nextCursor, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Logs fetch error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts index 22231ef8eba..4e61d698811 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts @@ -1,29 +1,15 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2DeleteMcpServerContract, v2GetMcpServerContract, v2UpdateMcpServerContract, } from '@/lib/api/contracts/v2/mcp-servers' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performDeleteMcpServer, performUpdateMcpServer } from '@/lib/mcp/orchestration' import { getWorkspaceMcpServer } from '@/lib/mcp/queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { toV2McpServer, v2McpOrchestrationError } from '@/app/api/v2/mcp-servers/utils' -const logger = createLogger('V2McpServerDetailAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -32,25 +18,12 @@ interface RouteContext { } /** GET /api/v2/mcp-servers/[id] — Fetch a single MCP server. */ -export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'mcp-server-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetMcpServerContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2GetMcpServerContract, + rateLimitEndpoint: 'mcp-server-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { id } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -59,34 +32,16 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RouteC if (!server) return v2Error('NOT_FOUND', 'MCP server not found') return v2Data({ mcpServer: toV2McpServer(server) }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error fetching MCP server`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** PATCH /api/v2/mcp-servers/[id] — Update an MCP server's configuration. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'mcp-server-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateMcpServerContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId, ...body } = parsed.data.body +export const PATCH = withPublicApiRouteHandler({ + contract: v2UpdateMcpServerContract, + rateLimitEndpoint: 'mcp-server-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { id } = input.params + const { workspaceId, ...body } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -134,34 +89,16 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout } return v2Data({ mcpServer: toV2McpServer(result.server) }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error updating MCP server`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** DELETE /api/v2/mcp-servers/[id] — Remove an MCP server from the workspace. */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'mcp-server-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteMcpServerContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId } = parsed.data.query +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteMcpServerContract, + rateLimitEndpoint: 'mcp-server-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { id } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -172,10 +109,5 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou } return v2Data({ id, deleted: true as const }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error deleting MCP server`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts index e9a32f7251f..c14c89233c7 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.ts @@ -1,13 +1,7 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateMcpServerContract, v2ListMcpServersContract, } from '@/lib/api/contracts/v2/mcp-servers' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performCreateMcpServer } from '@/lib/mcp/orchestration' import { getMcpServerIdState, @@ -15,47 +9,20 @@ import { listWorkspaceMcpServers, } from '@/lib/mcp/queries' import { generateMcpServerId } from '@/lib/mcp/utils' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { toV2McpServer, v2McpOrchestrationError } from '@/app/api/v2/mcp-servers/utils' -const logger = createLogger('V2McpServersAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/mcp-servers — List MCP servers in a workspace. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'mcp-servers') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListMcpServersContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListMcpServersContract, + rateLimitEndpoint: 'mcp-servers', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -64,38 +31,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { // The per-workspace server set is small and bounded → a single full page. return v2CursorList(rows.map(toV2McpServer), null, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error listing MCP servers`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** POST /api/v2/mcp-servers — Register a new MCP server. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'mcp-servers') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateMcpServerContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, ...body } = parsed.data.body +export const POST = withPublicApiRouteHandler({ + contract: v2CreateMcpServerContract, + rateLimitEndpoint: 'mcp-servers', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { workspaceId, ...body } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -155,13 +99,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const created = await getWorkspaceMcpServer({ workspaceId, serverId: result.serverId }) - if (!created) return v2Error('INTERNAL_ERROR', 'Internal server error') + if (!created) { + throw new Error(`MCP server ${result.serverId} missing after a successful registration`) + } return v2Data({ mcpServer: toV2McpServer(created) }, { rateLimit, status: 201 }) - } catch (error) { - logger.error(`[${requestId}] Error creating MCP server`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/secrets/[name]/route.ts b/apps/sim/app/api/v2/secrets/[name]/route.ts index a54adb9d509..ca559713c46 100644 --- a/apps/sim/app/api/v2/secrets/[name]/route.ts +++ b/apps/sim/app/api/v2/secrets/[name]/route.ts @@ -1,15 +1,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest, NextResponse } from 'next/server' +import type { NextResponse } from 'next/server' import { type V2Secret, type V2SecretScope, v2DeleteSecretContract, v2SetSecretContract, } from '@/lib/api/contracts/v2/secrets' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getWorkspaceEnvKeyAdminAccess } from '@/lib/credentials/environment' import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' import { @@ -19,19 +15,11 @@ import { setWorkspaceSecret, } from '@/lib/credentials/secret-values' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { secretCredentialTypes, toV2Secret } from '@/app/api/v2/secrets/utils' -const logger = createLogger('V2SecretAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -92,22 +80,12 @@ async function getSecretMetadata(params: { } /** PUT /api/v2/secrets/[name] — Create or replace a write-only secret value. */ -export const PUT = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - try { - const rateLimit = await checkRateLimit(request, 'secret-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2SetSecretContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { name } = parsed.data.params - const { workspaceId, scope, value } = parsed.data.body +export const PUT = withPublicApiRouteHandler({ + contract: v2SetSecretContract, + rateLimitEndpoint: 'secret-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { name } = input.params + const { workspaceId, scope, value } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -142,29 +120,16 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: RouteC }) return v2Data({ secret }, { rateLimit, status: result.created ? 201 : 200 }) - } catch (error) { - logger.error('Error setting secret', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** DELETE /api/v2/secrets/[name] — Delete a secret without reading its value. */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - try { - const rateLimit = await checkRateLimit(request, 'secret-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteSecretContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { name } = parsed.data.params - const { workspaceId, scope } = parsed.data.query +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteSecretContract, + rateLimitEndpoint: 'secret-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { name } = input.params + const { workspaceId, scope } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -199,8 +164,5 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou }) return v2Data({ name, scope, deleted: true as const }, { rateLimit }) - } catch (error) { - logger.error('Error deleting secret', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/secrets/route.ts b/apps/sim/app/api/v2/secrets/route.ts index 3b33ee60741..a2ca345b892 100644 --- a/apps/sim/app/api/v2/secrets/route.ts +++ b/apps/sim/app/api/v2/secrets/route.ts @@ -1,48 +1,20 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2ListSecretsContract } from '@/lib/api/contracts/v2/secrets' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2CursorList, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { secretCredentialTypes, toV2Secret } from '@/app/api/v2/secrets/utils' -const logger = createLogger('V2SecretsAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/secrets — List secret names and metadata without reading their values. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'secrets') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListSecretsContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, scope, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListSecretsContract, + rateLimitEndpoint: 'secrets', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, scope, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -61,8 +33,5 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .map((row) => toV2Secret(row, userId)) return v2CursorList(secrets, null, { rateLimit }) - } catch (error) { - logger.error('Error listing secrets', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/skills/[id]/route.ts b/apps/sim/app/api/v2/skills/[id]/route.ts index 2cb7d1f2017..00dae3b6bce 100644 --- a/apps/sim/app/api/v2/skills/[id]/route.ts +++ b/apps/sim/app/api/v2/skills/[id]/route.ts @@ -1,29 +1,15 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2DeleteSkillContract, v2GetSkillContract, v2UpdateSkillContract, } from '@/lib/api/contracts/v2/skills' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performDeleteSkill, performUpdateSkill } from '@/lib/skills/orchestration' import { getSkillById } from '@/lib/workflows/skills/operations' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { toV2Skill, v2SkillOrchestrationError } from '@/app/api/v2/skills/utils' -const logger = createLogger('V2SkillDetailAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -32,25 +18,12 @@ interface RouteContext { } /** GET /api/v2/skills/[id] — Fetch a single skill, including its body. */ -export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'skill-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetSkillContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2GetSkillContract, + rateLimitEndpoint: 'skill-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { id } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -59,34 +32,16 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RouteC if (!skill) return v2Error('NOT_FOUND', 'Skill not found') return v2Data({ skill: toV2Skill(skill) }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error fetching skill`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** PATCH /api/v2/skills/[id] — Update a skill. Omitted fields keep their values. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'skill-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateSkillContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId, name, description, content } = parsed.data.body +export const PATCH = withPublicApiRouteHandler({ + contract: v2UpdateSkillContract, + rateLimitEndpoint: 'skill-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { id } = input.params + const { workspaceId, name, description, content } = input.body /** * Editing an existing skill is gated per skill, not per workspace: an @@ -114,34 +69,16 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout } return v2Data({ skill: toV2Skill(result.skill) }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error updating skill`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** DELETE /api/v2/skills/[id] — Delete a skill. */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'skill-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteSkillContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId } = parsed.data.query +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteSkillContract, + rateLimitEndpoint: 'skill-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { id } = input.params + const { workspaceId } = input.query // Gated per skill by `performDeleteSkill`, same as PATCH above. const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') @@ -160,10 +97,5 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou } return v2Data({ id, deleted: true as const }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error deleting skill`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/skills/route.ts b/apps/sim/app/api/v2/skills/route.ts index 1541ca2be7f..868c1c54979 100644 --- a/apps/sim/app/api/v2/skills/route.ts +++ b/apps/sim/app/api/v2/skills/route.ts @@ -1,53 +1,20 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateSkillContract, v2ListSkillsContract } from '@/lib/api/contracts/v2/skills' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performCreateSkill } from '@/lib/skills/orchestration' import { listSkills } from '@/lib/workflows/skills/operations' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2CursorList, v2Data, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { toV2Skill, toV2SkillSummary, v2SkillOrchestrationError } from '@/app/api/v2/skills/utils' -const logger = createLogger('V2SkillsAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/skills — List skills in a workspace, built-ins included. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'skills') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListSkillsContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListSkillsContract, + rateLimitEndpoint: 'skills', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -56,38 +23,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { // The per-workspace skill set is small and bounded → a single full page. return v2CursorList(skills.map(toV2SkillSummary), null, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error listing skills`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** POST /api/v2/skills — Create a skill. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'skills') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateSkillContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, name, description, content } = parsed.data.body +export const POST = withPublicApiRouteHandler({ + contract: v2CreateSkillContract, + rateLimitEndpoint: 'skills', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { workspaceId, name, description, content } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -107,10 +51,5 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } return v2Data({ skill: toV2Skill(result.skill) }, { rateLimit, status: 201 }) - } catch (error) { - logger.error(`[${requestId}] Error creating skill`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts index 69fe9094e67..1ef1f1f6e09 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts @@ -1,21 +1,16 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CancelTableRunsContract } from '@/lib/api/contracts/v2/tables' -import { isZodError, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isZodError } from '@/lib/api/server' import type { Filter, TableSchema } from '@/lib/table' import { TableQueryValidationError } from '@/lib/table/errors' import { signalTableRowsChanged } from '@/lib/table/events' import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2Data, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' @@ -26,10 +21,6 @@ const logger = createLogger('V2TableCancelRunsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - /** * POST /api/v2/tables/[tableId]/cancel-runs — Stop in-flight cell runs. * @@ -38,63 +29,53 @@ interface TableRouteParams { * every running and pending cell (optionally narrowed by `filter`); `row` * cancels one row's cells. */ -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-enrichment') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2CancelTableRunsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId, scope, rowId, filter, excludeRowIds } = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const access = await checkAccess(tableId, userId, 'write') - if (!access.ok) return v2TableAccessError(access) - - if (access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - // The public predicate is column-NAME keyed; the runners compile the - // storage-keyed legacy filter. Translating up front makes an unknown field - // a 400 rather than a cancel that silently matches nothing. - let legacyFilter: Filter | undefined - if (filter) { - legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) +export const POST = withPublicApiRouteHandler({ + contract: v2CancelTableRunsContract, + rateLimitEndpoint: 'table-enrichment', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const { workspaceId, scope, rowId, filter, excludeRowIds } = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + if (access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // The public predicate is column-NAME keyed; the runners compile the + // storage-keyed legacy filter. Translating up front makes an unknown field + // a 400 rather than a cancel that silently matches nothing. + let legacyFilter: Filter | undefined + if (filter) { + legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) + } + + const cancelled = await cancelWorkflowGroupRuns( + tableId, + scope === 'row' ? rowId : undefined, + { + filter: legacyFilter, + excludeRowIds, + } + ) + + // Cancelling clears the affected rows' exec state, so open readers must + // refetch to pick up the cleared cells. + signalTableRowsChanged(tableId) + + logger.info(`[${requestId}] Cancelled table runs`, { tableId, scope, rowId, cancelled }) + + return v2Data({ cancelled }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + throw error } - - const cancelled = await cancelWorkflowGroupRuns(tableId, scope === 'row' ? rowId : undefined, { - filter: legacyFilter, - excludeRowIds, - }) - - // Cancelling clears the affected rows' exec state, so open readers must - // refetch to pick up the cleared cells. - signalTableRowsChanged(tableId) - - logger.info(`[${requestId}] Cancelled table runs`, { tableId, scope, rowId, cancelled }) - - return v2Data({ cancelled }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - logger.error(`[${requestId}] Error cancelling table runs`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts index 77a3f1f5e1c..1c6d1092e17 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts @@ -1,174 +1,121 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2AddTableColumnContract, v2DeleteTableColumnContract, v2UpdateTableColumnContract, } from '@/lib/api/contracts/v2/tables' -import { isZodError, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isZodError } from '@/lib/api/server' import { addTableColumn, deleteColumn } from '@/lib/table' import { performUpdateTableColumn } from '@/lib/table/orchestration' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess, normalizeColumn } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' import { v2TableAccessError, v2TableOrchestrationError } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableColumnsAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface ColumnsRouteParams { - params: Promise<{ tableId: string }> -} - /** POST /api/v2/tables/[tableId]/columns — Add a column to the table schema. */ -export const POST = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => { - const requestId = generateRequestId() +export const POST = withPublicApiRouteHandler({ + contract: v2AddTableColumnContract, + rateLimitEndpoint: 'table-columns', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const validated = input.body - try { - const rateLimit = await checkRateLimit(request, 'table-columns') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) - const userId = rateLimit.userId! + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) - const gate = await v2ApiGateError(userId) - if (gate) return gate + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } - const parsed = await parseRequest(v2AddTableColumnContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response + const updatedTable = await addTableColumn(tableId, validated.column, requestId) - const { tableId } = parsed.data.params - const validated = parsed.data.body + recordAudit({ + workspaceId: validated.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Added column "${validated.column.name}" to table "${table.name}"`, + metadata: { column: validated.column }, + request, + }) - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) + return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') + throw error } - - const updatedTable = await addTableColumn(tableId, validated.column, requestId) - - recordAudit({ - workspaceId: validated.workspaceId, - actorId: userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Added column "${validated.column.name}" to table "${table.name}"`, - metadata: { column: validated.column }, - request, - }) - - return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - logger.error(`[${requestId}] Error adding column to table`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** PATCH /api/v2/tables/[tableId]/columns — Update a column (rename, type change, constraints). */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-columns') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateTableColumnContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response +export const PATCH = withPublicApiRouteHandler({ + contract: v2UpdateTableColumnContract, + rateLimitEndpoint: 'table-columns', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const validated = input.body - const { tableId } = parsed.data.params - const validated = parsed.data.body + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } + const outcome = await performUpdateTableColumn({ + table, + columnName: validated.columnName, + userId, + updates: validated.updates, + requestId, + request, + }) + if (!outcome.success || !outcome.table) { + return v2TableOrchestrationError(outcome, 'Failed to update column') + } - const outcome = await performUpdateTableColumn({ - table, - columnName: validated.columnName, - userId, - updates: validated.updates, - requestId, - request, - }) - if (!outcome.success || !outcome.table) { - return v2TableOrchestrationError(outcome, 'Failed to update column') + return v2Data({ columns: outcome.table.schema.columns.map(normalizeColumn) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + throw error } - - return v2Data({ columns: outcome.table.schema.columns.map(normalizeColumn) }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - logger.error(`[${requestId}] Error updating column in table`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** DELETE /api/v2/tables/[tableId]/columns — Delete a column from the table schema. */ -export const DELETE = withRouteHandler( - async (request: NextRequest, context: ColumnsRouteParams) => { - const requestId = generateRequestId() - +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteTableColumnContract, + rateLimitEndpoint: 'table-columns', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-columns') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteTableColumnContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const validated = parsed.data.body + const { tableId } = input.params + const validated = input.body const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -205,10 +152,7 @@ export const DELETE = withRouteHandler( const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error(`[${requestId}] Error deleting column from table`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts index f534f57f5fd..7f2345f73be 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts @@ -1,22 +1,16 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2RunTableColumnContract } from '@/lib/api/contracts/v2/tables' -import { isZodError, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isZodError } from '@/lib/api/server' import type { Filter, TableSchema } from '@/lib/table' import { TableQueryValidationError } from '@/lib/table/errors' import { signalTableRowsChanged } from '@/lib/table/events' import { runWorkflowColumn } from '@/lib/table/workflow-columns' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' @@ -26,15 +20,9 @@ import { v2TableLockError, } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableRunColumnAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - /** * POST /api/v2/tables/[tableId]/columns/run — Run workflow/enrichment groups. * @@ -43,76 +31,61 @@ interface TableRouteParams { * poll the row endpoints. `dispatchId` is `null` where no background runner is * configured and cells execute inline. */ -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-enrichment') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2RunTableColumnContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId, groupIds, runMode, rowIds, filter, excludeRowIds, limit } = - parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const access = await checkAccess(tableId, userId, 'write') - if (!access.ok) return v2TableAccessError(access) - - if (access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - // The public predicate is column-NAME keyed; the dispatcher compiles the - // storage-keyed legacy filter. Translating up front also makes an unknown - // field a 400 here rather than a dispatch that silently matches nothing. - let legacyFilter: Filter | undefined - if (filter) { - legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) +export const POST = withPublicApiRouteHandler({ + contract: v2RunTableColumnContract, + rateLimitEndpoint: 'table-enrichment', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const { workspaceId, groupIds, runMode, rowIds, filter, excludeRowIds, limit } = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + if (access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // The public predicate is column-NAME keyed; the dispatcher compiles the + // storage-keyed legacy filter. Translating up front also makes an unknown + // field a 400 here rather than a dispatch that silently matches nothing. + let legacyFilter: Filter | undefined + if (filter) { + legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) + } + + const { dispatchId } = await runWorkflowColumn({ + tableId, + workspaceId, + groupIds, + mode: runMode, + rowIds, + filter: legacyFilter, + excludeRowIds, + limit, + requestId, + triggeredByUserId: userId, + }) + + // Starting a run clears the target groups' cells to pending — a row change + // open readers must pick up. + signalTableRowsChanged(tableId) + + return v2Data({ dispatchId }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + const lockError = v2TableLockError(error) + if (lockError) return lockError + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + throw error } - - const { dispatchId } = await runWorkflowColumn({ - tableId, - workspaceId, - groupIds, - mode: runMode, - rowIds, - filter: legacyFilter, - excludeRowIds, - limit, - requestId, - triggeredByUserId: userId, - }) - - // Starting a run clears the target groups' cells to pending — a row change - // open readers must pick up. - signalTableRowsChanged(tableId) - - return v2Data({ dispatchId }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - const lockError = v2TableLockError(error) - if (lockError) return lockError - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - logger.error(`[${requestId}] Error running table columns`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts index 63bf970f013..1582877d704 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts @@ -1,67 +1,48 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateTableExportContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createTableExportResource, toV2TableExport, } from '@/lib/table/orchestration/export-resource' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2TableExportsAPI') - -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'table-export') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2CreateTableExportContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { workspaceId, format } = parsed.data.body - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - const access = await checkAccess(parsed.data.params.tableId, userId, 'read') - if (!access.ok || access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') +export const POST = withPublicApiRouteHandler({ + contract: v2CreateTableExportContract, + rateLimitEndpoint: 'table-export', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + try { + const { workspaceId, format } = input.body + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const access = await checkAccess(input.params.tableId, userId, 'read') + if (!access.ok || access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + const record = await createTableExportResource({ table: access.table, format }) + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.TABLE_EXPORTED, + resourceType: AuditResourceType.TABLE, + resourceId: access.table.id, + resourceName: access.table.name, + description: `Exported table "${access.table.name}" as ${format.toUpperCase()}`, + metadata: { format, rowCount: access.table.rowCount }, + request, + }) + return v2Data(toV2TableExport(record, true), { rateLimit, status: 201 }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + throw error } - const record = await createTableExportResource({ table: access.table, format }) - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.TABLE_EXPORTED, - resourceType: AuditResourceType.TABLE, - resourceId: access.table.id, - resourceName: access.table.name, - description: `Exported table "${access.table.name}" as ${format.toUpperCase()}`, - metadata: { format, rowCount: access.table.rowCount }, - request, - }) - return v2Data(toV2TableExport(record, true), { rateLimit, status: 201 }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - logger.error('Failed to create table export', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) 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 2d96bf9149a..66d06b07f0c 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts @@ -1,17 +1,11 @@ -import { createLogger } from '@sim/logger' import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { v2AddWorkflowGroupContract, v2DeleteWorkflowGroupContract, v2ListWorkflowGroupsContract, v2UpdateWorkflowGroupContract, } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { TableDefinition, TableSchema } from '@/lib/table' import { signalTableSchemaChanged } from '@/lib/table/events' import { @@ -19,28 +13,15 @@ import { deleteWorkflowGroup, updateWorkflowGroup, } from '@/lib/table/workflow-groups/service' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess, normalizeColumn } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { v2TableLockError } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableGroupsAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - /** * GET /api/v2/tables/[tableId]/groups — The table's workflow/enrichment groups. * @@ -50,25 +31,12 @@ interface TableRouteParams { * the already-loaded definition rather than a second query, and the set is * bounded per table — one full page, `nextCursor` always `null`. */ -export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-groups') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2ListWorkflowGroupsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListWorkflowGroupsContract, + rateLimitEndpoint: 'table-groups', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { tableId } = input.params + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -82,21 +50,16 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR const groups = (result.table.schema as TableSchema).workflowGroups ?? [] return v2CursorList(groups, null, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error listing workflow groups`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** - * Renders a group-service failure in the v2 envelope. The service signals - * through thrown `Error` messages rather than classified codes, so the string - * matching mirrors the first-party mapper — the two surfaces must agree on - * which failures are the caller's fault. + * Maps expected group-service failures into the v2 envelope. The service + * signals through thrown `Error` messages rather than classified codes, so the + * string matching mirrors the first-party mapper. Unexpected errors keep + * bubbling to the public route wrapper for centralized logging and rendering. */ -function groupMutationError(error: unknown, requestId: string, fallback: string) { +function groupMutationError(error: unknown) { const lockError = v2TableLockError(error) if (lockError) return lockError @@ -115,8 +78,7 @@ function groupMutationError(error: unknown, requestId: string, fallback: string) } } - logger.error(`[${requestId}] ${fallback}`, { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } /** @@ -154,80 +116,69 @@ function groupResponse(table: TableDefinition, groupId: string) { * POST /api/v2/tables/[tableId]/groups — Bind a workflow or enrichment to the * table and create the columns its runs populate, in one call. */ -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-groups') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2AddWorkflowGroupContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const validated = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok || result.table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - if (validated.group.workflowId) { - const workflowError = await assertWorkflowInWorkspace( - validated.group.workflowId, - result.table.workspaceId +export const POST = withPublicApiRouteHandler({ + contract: v2AddWorkflowGroupContract, + rateLimitEndpoint: 'table-groups', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const validated = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok || result.table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + if (validated.group.workflowId) { + const workflowError = await assertWorkflowInWorkspace( + validated.group.workflowId, + result.table.workspaceId + ) + if (workflowError) return workflowError + } + + /** + * `outputs` and `outputColumns` are two arrays joined by column name, so a + * typo in either silently creates a column nothing feeds. The first-party + * client builds both from one picker and can't desync; a public caller can, + * so the mismatch is rejected rather than persisted. + */ + const outputNames = new Set(validated.group.outputs.map((output) => output.columnName)) + const orphan = validated.outputColumns.find((column) => !outputNames.has(column.name)) + if (orphan) { + return v2Error( + 'BAD_REQUEST', + `outputColumns entry "${orphan.name}" has no matching group.outputs[].columnName` + ) + } + + const groupId = validated.group.id ?? generateId() + + const updatedTable = await addWorkflowGroup( + { + tableId, + group: { ...validated.group, id: groupId }, + // Stamped from the resolved group rather than trusted from the caller. + outputColumns: validated.outputColumns.map((column) => ({ + ...column, + workflowGroupId: groupId, + })), + autoRun: validated.autoRun, + actorUserId: userId, + }, + requestId ) - if (workflowError) return workflowError - } - /** - * `outputs` and `outputColumns` are two arrays joined by column name, so a - * typo in either silently creates a column nothing feeds. The first-party - * client builds both from one picker and can't desync; a public caller can, - * so the mismatch is rejected rather than persisted. - */ - const outputNames = new Set(validated.group.outputs.map((output) => output.columnName)) - const orphan = validated.outputColumns.find((column) => !outputNames.has(column.name)) - if (orphan) { - return v2Error( - 'BAD_REQUEST', - `outputColumns entry "${orphan.name}" has no matching group.outputs[].columnName` - ) - } + signalTableSchemaChanged(tableId) - const groupId = validated.group.id ?? generateId() - - const updatedTable = await addWorkflowGroup( - { - tableId, - group: { ...validated.group, id: groupId }, - // Stamped from the resolved group rather than trusted from the caller. - outputColumns: validated.outputColumns.map((column) => ({ - ...column, - workflowGroupId: groupId, - })), - autoRun: validated.autoRun, - actorUserId: userId, - }, - requestId - ) - - signalTableSchemaChanged(tableId) - - return v2Data(groupResponse(updatedTable, groupId), { rateLimit, status: 201 }) - } catch (error) { - return groupMutationError(error, requestId, 'Failed to add workflow group') - } + return v2Data(groupResponse(updatedTable, groupId), { rateLimit, status: 201 }) + } catch (error) { + return groupMutationError(error) + } + }, }) /** @@ -237,80 +188,69 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Table * Removing an output **deletes that column and its values** — the same * behavior as `DELETE /columns` on a bound column. There is no detach. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-groups') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateWorkflowGroupContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const validated = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) +export const PATCH = withPublicApiRouteHandler({ + contract: v2UpdateWorkflowGroupContract, + rateLimitEndpoint: 'table-groups', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const validated = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok || result.table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + if (validated.workflowId !== undefined) { + const workflowError = await assertWorkflowInWorkspace( + validated.workflowId, + result.table.workspaceId + ) + if (workflowError) return workflowError + } + + const updatedTable = await updateWorkflowGroup( + { + tableId, + groupId: validated.groupId, + actorUserId: userId, + ...(validated.workflowId !== undefined ? { workflowId: validated.workflowId } : {}), + ...(validated.name !== undefined ? { name: validated.name } : {}), + ...(validated.dependencies !== undefined ? { dependencies: validated.dependencies } : {}), + ...(validated.outputs !== undefined ? { outputs: validated.outputs } : {}), + ...(validated.newOutputColumns !== undefined + ? { + newOutputColumns: validated.newOutputColumns.map((column) => ({ + ...column, + workflowGroupId: validated.groupId, + })), + } + : {}), + ...(validated.mappingUpdates !== undefined + ? { mappingUpdates: validated.mappingUpdates } + : {}), + ...(validated.inputMappings !== undefined + ? { inputMappings: validated.inputMappings } + : {}), + ...(validated.deploymentMode !== undefined + ? { deploymentMode: validated.deploymentMode } + : {}), + ...(validated.type !== undefined ? { type: validated.type } : {}), + ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}), + }, + requestId + ) - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok || result.table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } + signalTableSchemaChanged(tableId) - if (validated.workflowId !== undefined) { - const workflowError = await assertWorkflowInWorkspace( - validated.workflowId, - result.table.workspaceId - ) - if (workflowError) return workflowError + return v2Data(groupResponse(updatedTable, validated.groupId), { rateLimit }) + } catch (error) { + return groupMutationError(error) } - - const updatedTable = await updateWorkflowGroup( - { - tableId, - groupId: validated.groupId, - actorUserId: userId, - ...(validated.workflowId !== undefined ? { workflowId: validated.workflowId } : {}), - ...(validated.name !== undefined ? { name: validated.name } : {}), - ...(validated.dependencies !== undefined ? { dependencies: validated.dependencies } : {}), - ...(validated.outputs !== undefined ? { outputs: validated.outputs } : {}), - ...(validated.newOutputColumns !== undefined - ? { - newOutputColumns: validated.newOutputColumns.map((column) => ({ - ...column, - workflowGroupId: validated.groupId, - })), - } - : {}), - ...(validated.mappingUpdates !== undefined - ? { mappingUpdates: validated.mappingUpdates } - : {}), - ...(validated.inputMappings !== undefined - ? { inputMappings: validated.inputMappings } - : {}), - ...(validated.deploymentMode !== undefined - ? { deploymentMode: validated.deploymentMode } - : {}), - ...(validated.type !== undefined ? { type: validated.type } : {}), - ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}), - }, - requestId - ) - - signalTableSchemaChanged(tableId) - - return v2Data(groupResponse(updatedTable, validated.groupId), { rateLimit }) - } catch (error) { - return groupMutationError(error, requestId, 'Failed to update workflow group') - } + }, }) /** @@ -318,50 +258,39 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl * fed**, along with their values. The surviving column list comes back so a * caller does not have to re-read the table to see what is left. */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-groups') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteWorkflowGroupContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const validated = parsed.data.body +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteWorkflowGroupContract, + rateLimitEndpoint: 'table-groups', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const validated = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok || result.table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const updatedTable = await deleteWorkflowGroup( + { tableId, groupId: validated.groupId }, + requestId + ) - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) + signalTableSchemaChanged(tableId) - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok || result.table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') + return v2Data( + { + id: validated.groupId, + deleted: true as const, + columns: (updatedTable.schema as TableSchema).columns.map(normalizeColumn), + }, + { rateLimit } + ) + } catch (error) { + return groupMutationError(error) } - - const updatedTable = await deleteWorkflowGroup( - { tableId, groupId: validated.groupId }, - requestId - ) - - signalTableSchemaChanged(tableId) - - return v2Data( - { - id: validated.groupId, - deleted: true as const, - columns: (updatedTable.schema as TableSchema).columns.map(normalizeColumn), - }, - { rateLimit } - ) - } catch (error) { - return groupMutationError(error, requestId, 'Failed to delete workflow group') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts index 495c8aeff48..a38f876dfbb 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts @@ -1,11 +1,6 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { TABLE_QUERY_MAX_BODY_BYTES } from '@/lib/api/contracts/tables' import { V2_DEFAULT_ROW_LIMIT, v2QueryRowsContract } from '@/lib/api/contracts/v2/tables' -import { isZodError, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isZodError } from '@/lib/api/server' import type { Sort, TablePredicate, TableSchema } from '@/lib/table' import { buildIdByName, sortSpecNamesToIds } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' @@ -14,127 +9,108 @@ import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/v import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' import { queryRows } from '@/lib/table/rows/service' import { predicateToStorage } from '@/lib/table/select-values' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CursorList, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' import { toApiRow } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableQueryAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface QueryRouteParams { - params: Promise<{ tableId: string }> -} - /** * POST /api/v2/tables/[tableId]/query — public row query. Typed `predicate`/`sort` * objects + opaque cursor pagination. Default page {@link V2_DEFAULT_ROW_LIMIT}; * `limit=0` = unbounded (whole result or 400). */ -export const POST = withRouteHandler(async (request: NextRequest, context: QueryRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2QueryRowsContract, request, context, { - maxBodyBytes: TABLE_QUERY_MAX_BODY_BYTES, - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId, sort, cursor: cursorToken, limit } = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const accessResult = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') - - const { table } = accessResult - if (workspaceId !== table.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') +export const POST = withPublicApiRouteHandler({ + contract: v2QueryRowsContract, + rateLimitEndpoint: 'table-rows', + parseOptions: { + maxBodyBytes: TABLE_QUERY_MAX_BODY_BYTES, + }, + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const { workspaceId, sort, cursor: cursorToken, limit } = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') + + const { table } = accessResult + if (workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const schema = table.schema as TableSchema + const cursor = cursorToken ? decodeCursor(cursorToken) : undefined + + const idByName = buildIdByName(schema) + // Fuses the id→name key remap with select-cell value formatting, so a select + // cell surfaces its option NAME rather than the stored option id. + const toNamedRow = namedRowMapper(schema.columns) + let predicate: TablePredicate | undefined = input.body.predicate + if (predicate) { + validatePredicate(predicate, schema.columns) + predicate = predicateToStorage(predicate, schema) + } + let sortSpec = sort + if (sortSpec?.length) { + validateSortSpec(sortSpec, schema.columns) + sortSpec = sortSpecNamesToIds(sortSpec, idByName) + } + const sortObj: Sort | undefined = sortSpec?.length + ? Object.fromEntries(sortSpec.map((s) => [s.field, s.direction])) + : undefined + + // A cursor is only valid for the query shape it was minted under: keyset + // cursors bind to the default order, offset cursors to their sort. Runs on + // the STORAGE-keyed sort so the fingerprint matches what queryRows stamped. + if (cursor) assertCursorSortBinding(cursor, sortObj) + + // Public default is a bounded page (unlike the internal surface's unbounded + // omit). `limit=0` is the explicit unbounded opt-in. + const effectiveLimit = + limit === undefined ? V2_DEFAULT_ROW_LIMIT : limit === 0 ? undefined : limit + + const result = await queryRows( + table, + { + predicate, + sort: sortObj, + limit: effectiveLimit, + after: cursor?.after, + offset: cursor?.offset, + includeTotal: false, + withExecutions: false, + }, + requestId + ) + + return v2CursorList( + result.rows.map((r) => toApiRow(r, toNamedRow)), + result.nextCursor, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof TableQueryValidationError) { + return v2Error('BAD_REQUEST', error.message, { + details: error.code ? { code: error.code } : undefined, + }) + } + + throw error } - - const schema = table.schema as TableSchema - const cursor = cursorToken ? decodeCursor(cursorToken) : undefined - - const idByName = buildIdByName(schema) - // Fuses the id→name key remap with select-cell value formatting, so a select - // cell surfaces its option NAME rather than the stored option id. - const toNamedRow = namedRowMapper(schema.columns) - let predicate: TablePredicate | undefined = parsed.data.body.predicate - if (predicate) { - validatePredicate(predicate, schema.columns) - predicate = predicateToStorage(predicate, schema) - } - let sortSpec = sort - if (sortSpec?.length) { - validateSortSpec(sortSpec, schema.columns) - sortSpec = sortSpecNamesToIds(sortSpec, idByName) - } - const sortObj: Sort | undefined = sortSpec?.length - ? Object.fromEntries(sortSpec.map((s) => [s.field, s.direction])) - : undefined - - // A cursor is only valid for the query shape it was minted under: keyset - // cursors bind to the default order, offset cursors to their sort. Runs on - // the STORAGE-keyed sort so the fingerprint matches what queryRows stamped. - if (cursor) assertCursorSortBinding(cursor, sortObj) - - // Public default is a bounded page (unlike the internal surface's unbounded - // omit). `limit=0` is the explicit unbounded opt-in. - const effectiveLimit = - limit === undefined ? V2_DEFAULT_ROW_LIMIT : limit === 0 ? undefined : limit - - const result = await queryRows( - table, - { - predicate, - sort: sortObj, - limit: effectiveLimit, - after: cursor?.after, - offset: cursor?.offset, - includeTotal: false, - withExecutions: false, - }, - requestId - ) - - return v2CursorList( - result.rows.map((r) => toApiRow(r, toNamedRow)), - result.nextCursor, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - if (error instanceof TableQueryValidationError) { - return v2Error('BAD_REQUEST', error.message, { - details: error.code ? { code: error.code } : undefined, - }) - } - - logger.error(`[${requestId}] Error querying rows (v2 public)`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index 4df088ba0ce..2f41f7ff910 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -1,15 +1,11 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2DeleteTableContract, v2GetTableContract, v2UpdateTableContract, } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { getTableById } from '@/lib/table' import { signalTableSchemaChanged } from '@/lib/table/events' @@ -19,17 +15,11 @@ import { performRenameTable, performUpdateTableDescription, } from '@/lib/table/orchestration' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import type { OrchestrationOutcome } from '@/app/api/v2/tables/utils' import { toApiTable, @@ -54,30 +44,13 @@ function appliedDetails( export const dynamic = 'force-dynamic' export const revalidate = 0 -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - /** GET /api/v2/tables/[tableId] — Get table details. */ -export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetTableContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2GetTableContract, + rateLimitEndpoint: 'table-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { tableId } = input.params + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -95,12 +68,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR { table: toApiTable(result.table, folderPathForId(folderIndex, result.table.folderId)) }, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error getting table`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** @@ -115,183 +83,160 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR * a first-party admin action. The contract body is `.strict()`, so a request * carrying `locks` is rejected rather than silently ignored. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - /** - * Hoisted above the `try` so every exit path can report it. Once a write has - * committed, the response must say so even when the failure came *after* the - * writes — a throw in the final re-read, or the re-read finding the table - * archived. Reporting a bare 500 there tells the caller nothing landed, and - * it retries into a duplicate-name conflict or a repeated move. - */ - const applied: ('name' | 'description' | 'folderPath')[] = [] - - try { - const rateLimit = await checkRateLimit(request, 'table-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateTableContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const validated = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const resolution = - validated.folderPath === undefined - ? undefined - : await resolveFolderPathIdentity({ - workspaceId: table.workspaceId, - resourceType: 'table', - path: validated.folderPath, - }) - if (resolution && !resolution.found) { - return v2Error('NOT_FOUND', 'Folder not found in this workspace') - } +export const PATCH = withPublicApiRouteHandler({ + contract: v2UpdateTableContract, + rateLimitEndpoint: 'table-detail', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + /** + * Hoisted above the `try` so every exit path can report it. Once a write has + * committed, the response must say so even when the failure came *after* the + * writes — a throw in the final re-read, or the re-read finding the table + * archived. Reporting a bare 500 there tells the caller nothing landed, and + * it retries into a duplicate-name conflict or a repeated move. + */ + const applied: ('name' | 'description' | 'folderPath')[] = [] + + try { + const { tableId } = input.params + const validated = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } - let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null + const resolution = + validated.folderPath === undefined + ? undefined + : await resolveFolderPathIdentity({ + workspaceId: table.workspaceId, + resourceType: 'table', + path: validated.folderPath, + }) + if (resolution && !resolution.found) { + return v2Error('NOT_FOUND', 'Folder not found in this workspace') + } - if (validated.name !== undefined) { - const outcome = await performRenameTable({ - table, - newName: validated.name, - userId, - requestId, - request, - }) - if (outcome.success) applied.push('name') - else failure = { outcome, fallback: 'Failed to rename table' } - } + let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null + + if (validated.name !== undefined) { + const outcome = await performRenameTable({ + table, + newName: validated.name, + userId, + requestId, + request, + }) + if (outcome.success) applied.push('name') + else failure = { outcome, fallback: 'Failed to rename table' } + } - if (!failure && validated.description !== undefined) { - const outcome = await performUpdateTableDescription({ - table, - description: validated.description, - userId, - requestId, - request, - }) - if (outcome.success) applied.push('description') - else failure = { outcome, fallback: 'Failed to update table description' } - } + if (!failure && validated.description !== undefined) { + const outcome = await performUpdateTableDescription({ + table, + description: validated.description, + userId, + requestId, + request, + }) + if (outcome.success) applied.push('description') + else failure = { outcome, fallback: 'Failed to update table description' } + } - if (!failure && validated.folderPath !== undefined) { - const outcome = await performMoveTableToFolder({ - table, - folderId: resolution?.folderId ?? null, - userId, - requestId, - request, - }) - if (outcome.success) { - applied.push('folderPath') - } else { - failure = { - outcome: - outcome.errorCode === 'not_found' ? { ...outcome, error: 'Table not found' } : outcome, - fallback: 'Failed to move table', + if (!failure && validated.folderPath !== undefined) { + const outcome = await performMoveTableToFolder({ + table, + folderId: resolution?.folderId ?? null, + userId, + requestId, + request, + }) + if (outcome.success) { + applied.push('folderPath') + } else { + failure = { + outcome: + outcome.errorCode === 'not_found' + ? { ...outcome, error: 'Table not found' } + : outcome, + fallback: 'Failed to move table', + } } } - } - - if (applied.length > 0) signalTableSchemaChanged(tableId) - if (failure) { - return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied)) - } - - const updated = await getTableById(tableId) - if (!updated) { - return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) }) - } - const folderIndex = await loadActiveFolderPathIndex(table.workspaceId, 'table') - return v2Data( - { table: toApiTable(updated, folderPathForId(folderIndex, updated.folderId)) }, - { rateLimit } - ) - } catch (error) { - const details = appliedDetails(applied) + if (applied.length > 0) signalTableSchemaChanged(tableId) + if (failure) { + return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied)) + } - const lockError = v2TableLockError(error, details) - if (lockError) return lockError + const updated = await getTableById(tableId) + if (!updated) { + return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) }) + } - const classified = asOrchestrationError(error) - if (classified) { - return v2TableOrchestrationError( - { errorCode: classified.code, error: classified.message }, - 'Failed to update table', - details + const folderIndex = await loadActiveFolderPathIndex(table.workspaceId, 'table') + return v2Data( + { table: toApiTable(updated, folderPathForId(folderIndex, updated.folderId)) }, + { rateLimit } ) - } + } catch (error) { + const details = appliedDetails(applied) + + const lockError = v2TableLockError(error, details) + if (lockError) return lockError + + const classified = asOrchestrationError(error) + if (classified) { + return v2TableOrchestrationError( + { errorCode: classified.code, error: classified.message }, + 'Failed to update table', + details + ) + } - logger.error(`[${requestId}] Error updating table`, { - error: getErrorMessage(error, 'Unknown error'), - applied, - }) - return v2Error('INTERNAL_ERROR', 'Internal server error', { details }) - } + logger.error(`[${requestId}] Error updating table`, { + error: getErrorMessage(error, 'Unknown error'), + applied, + }) + return v2Error('INTERNAL_ERROR', 'Internal server error', { details }) + } + }, }) -export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteTableContract, + rateLimitEndpoint: 'table-detail', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const { workspaceId } = input.query - const gate = await v2ApiGateError(userId) - if (gate) return gate + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) - const parsed = await parseRequest(v2DeleteTableContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId } = parsed.data.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } + const outcome = await performDeleteTable({ table: result.table, userId, requestId, request }) + if (!outcome.success) { + return v2TableOrchestrationError(outcome, 'Failed to delete table') + } - const outcome = await performDeleteTable({ table: result.table, userId, requestId, request }) - if (!outcome.success) { - return v2TableOrchestrationError(outcome, 'Failed to delete table') + return v2Data({ id: tableId, deleted: true }, { rateLimit }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + throw error } - - return v2Data({ id: tableId, deleted: true }, { rateLimit }) - } catch (error) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - logger.error(`[${requestId}] Error deleting table`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts index 9f3e7a27b69..121c88c7e42 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts @@ -1,34 +1,20 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2RunRowEnrichmentContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { signalTableRowsChanged } from '@/lib/table/events' import { runWorkflowColumn } from '@/lib/table/workflow-columns' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' import { v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableRowEnrichmentAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface RowEnrichmentRouteParams { - params: Promise<{ tableId: string; rowId: string; groupId: string }> -} - /** * POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId] * @@ -36,26 +22,13 @@ interface RowEnrichmentRouteParams { * `mode: 'all'` because naming a specific cell is an explicit re-run request — * an already-populated cell must recompute rather than be skipped. */ -export const POST = withRouteHandler( - async (request: NextRequest, context: RowEnrichmentRouteParams) => { - const requestId = generateRequestId() - +export const POST = withPublicApiRouteHandler({ + contract: v2RunRowEnrichmentContract, + rateLimitEndpoint: 'table-enrichment', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-enrichment') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2RunRowEnrichmentContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId, rowId, groupId } = parsed.data.params - const { workspaceId } = parsed.data.body + const { tableId, rowId, groupId } = input.params + const { workspaceId } = input.body const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -87,10 +60,7 @@ export const POST = withRouteHandler( const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error(`[${requestId}] Error running row enrichment`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) 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 026348e69f9..8834f457a9e 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 @@ -1,29 +1,23 @@ import { db } from '@sim/db' import { userTableRows } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' import { v2DeleteTableRowContract, v2GetTableRowContract, v2UpdateTableRowContract, } from '@/lib/api/contracts/v2/tables' -import { isZodError, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isZodError } from '@/lib/api/server' import type { RowData, TableSchema } from '@/lib/table' import { buildIdByName, rowDataNameToId, updateRow } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' import { performDeleteTableRow } from '@/lib/table/orchestration' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' @@ -34,35 +28,16 @@ import { v2TableOrchestrationError, } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableRowAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface RowRouteParams { - params: Promise<{ tableId: string; rowId: string }> -} - /** GET /api/v2/tables/[tableId]/rows/[rowId] — Get a single row. */ -export const GET = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-row-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetTableRowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId, rowId } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2GetTableRowContract, + rateLimitEndpoint: 'table-row-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { tableId, rowId } = input.params + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -109,123 +84,90 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou }, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error getting row`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** PATCH /api/v2/tables/[tableId]/rows/[rowId] — Partial update a single row. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-row-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateTableRowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId, rowId } = parsed.data.params - const validated = parsed.data.body +export const PATCH = withPublicApiRouteHandler({ + contract: v2UpdateTableRowContract, + rateLimitEndpoint: 'table-row-detail', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId, rowId } = input.params + const validated = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) + const updatedRow = await updateRow( + { + tableId, + rowId, + data: rowDataNameToId(validated.data as RowData, idByName), + workspaceId: validated.workspaceId, + actorUserId: userId, + }, + table, + requestId + ) + // No `cancellationGuard` is passed, so `updateRow` can't return null here. + // Defensive narrowing for TypeScript. + if (!updatedRow) return v2Error('NOT_FOUND', 'Row not found') - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) + return v2Data({ row: toApiRow(updatedRow, toNamedRow) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') + throw error } - - const idByName = buildIdByName(table.schema as TableSchema) - const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) - const updatedRow = await updateRow( - { - tableId, - rowId, - data: rowDataNameToId(validated.data as RowData, idByName), - workspaceId: validated.workspaceId, - actorUserId: userId, - }, - table, - requestId - ) - // No `cancellationGuard` is passed, so `updateRow` can't return null here. - // Defensive narrowing for TypeScript. - if (!updatedRow) return v2Error('NOT_FOUND', 'Row not found') - - return v2Data({ row: toApiRow(updatedRow, toNamedRow) }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - logger.error(`[${requestId}] Error updating row`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** DELETE /api/v2/tables/[tableId]/rows/[rowId] — Delete a single row. */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-row-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteTableRowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId, rowId } = parsed.data.params - const { workspaceId } = parsed.data.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const outcome = await performDeleteTableRow({ table: result.table, rowId, requestId }) - if (!outcome.success) { - return v2TableOrchestrationError(outcome, 'Failed to delete row') +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteTableRowContract, + rateLimitEndpoint: 'table-row-detail', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId, rowId } = input.params + const { workspaceId } = input.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const outcome = await performDeleteTableRow({ table: result.table, rowId, requestId }) + if (!outcome.success) { + return v2TableOrchestrationError(outcome, 'Failed to delete row') + } + + // v2 mirrors the bulk delete shape: always returns `deletedRowIds`. + return v2Data({ deletedCount: 1, deletedRowIds: [rowId] }, { rateLimit }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + throw error } - - // v2 mirrors the bulk delete shape: always returns `deletedRowIds`. - return v2Data({ deletedCount: 1, deletedRowIds: [rowId] }, { rateLimit }) - } catch (error) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - logger.error(`[${requestId}] Error deleting row`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts index 68d86f3dea5..18ebe72ea1f 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts @@ -1,36 +1,24 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2FindTableRowsContract } from '@/lib/api/contracts/v2/tables' -import { isZodError, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isZodError } from '@/lib/api/server' import type { Filter, Sort, TableSchema } from '@/lib/table' import { buildIdByName, sortSpecNamesToIds } from '@/lib/table' import { TableQueryValidationError } from '@/lib/table/errors' import { validateSortSpec } from '@/lib/table/query-builder/validate' import { findRowMatches } from '@/lib/table/rows/service' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2Data, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' import { columnNameById, v2BulkPredicateToFilter } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableRowsFindAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - /** * POST /api/v2/tables/[tableId]/rows/find — Case-insensitive substring search * across every cell, narrowed by the same predicate/sort grammar as @@ -40,77 +28,63 @@ interface TableRouteParams { * same filtered+sorted view a `POST /query` with these arguments would return, * so a caller can jump straight to the page holding it. */ -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-rows-find') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2FindTableRowsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId, q, predicate, sort } = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const accessResult = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!accessResult.ok || accessResult.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') +export const POST = withPublicApiRouteHandler({ + contract: v2FindTableRowsContract, + rateLimitEndpoint: 'table-rows-find', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const { workspaceId, q, predicate, sort } = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!accessResult.ok || accessResult.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const { table } = accessResult + const schema = table.schema as TableSchema + + // The public wire is column-NAME keyed both ways: translate the predicate + // and sort down to storage ids on the way in, and the matched column id + // back to its name on the way out. + let filter: Filter | undefined + if (predicate) filter = v2BulkPredicateToFilter(predicate, schema) + + let sortObj: Sort | undefined + if (sort?.length) { + validateSortSpec(sort, schema.columns) + const storageSort = sortSpecNamesToIds(sort, buildIdByName(schema)) + sortObj = Object.fromEntries(storageSort.map((s) => [s.field, s.direction])) + } + + const { matches, truncated } = await findRowMatches( + table, + { q, filter, sort: sortObj }, + requestId + ) + + const toColumnName = columnNameById(schema) + + return v2Data( + { + matches: matches.map((match) => ({ + ordinal: match.ordinal, + rowId: match.rowId, + column: toColumnName(match.column), + })), + truncated, + }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + throw error } - - const { table } = accessResult - const schema = table.schema as TableSchema - - // The public wire is column-NAME keyed both ways: translate the predicate - // and sort down to storage ids on the way in, and the matched column id - // back to its name on the way out. - let filter: Filter | undefined - if (predicate) filter = v2BulkPredicateToFilter(predicate, schema) - - let sortObj: Sort | undefined - if (sort?.length) { - validateSortSpec(sort, schema.columns) - const storageSort = sortSpecNamesToIds(sort, buildIdByName(schema)) - sortObj = Object.fromEntries(storageSort.map((s) => [s.field, s.direction])) - } - - const { matches, truncated } = await findRowMatches( - table, - { q, filter, sort: sortObj }, - requestId - ) - - const toColumnName = columnNameById(schema) - - return v2Data( - { - matches: matches.map((match) => ({ - ordinal: match.ordinal, - rowId: match.rowId, - column: toColumnName(match.column), - })), - truncated, - }, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - logger.error(`[${requestId}] Error finding rows`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) 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 a2677af979c..0be13bffdb4 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts @@ -1,6 +1,4 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest, NextResponse } from 'next/server' +import type { NextResponse } from 'next/server' import type { V1BatchInsertTableRowsBody } from '@/lib/api/contracts/v1/tables' import { v2CreateTableRowsContract, @@ -8,9 +6,7 @@ import { v2ListTableRowsContract, v2UpdateRowsByFilterContract, } from '@/lib/api/contracts/v2/tables' -import { isZodError, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isZodError } from '@/lib/api/server' import type { RowData, TableSchema } from '@/lib/table' import { batchInsertRows, @@ -27,20 +23,15 @@ import { import { namedRowMapper } from '@/lib/table/cell-format' import { TableQueryValidationError } from '@/lib/table/errors' import { queryRows } from '@/lib/table/rows/service' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { - checkRateLimit, - type RateLimitResult, - resolveWorkspaceScope, -} from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { type RateLimitResult, resolveWorkspaceScope } from '@/app/api/v1/middleware' import { decodeCursor, encodeCursor, v2CursorList, v2Data, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' @@ -52,15 +43,9 @@ import { v2TableAccessError, } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableRowsAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface TableRowsRouteParams { - params: Promise<{ tableId: string }> -} - /** * Inserts a validated batch of rows. Authorizes against the table's own * workspace (IDOR guard) before any write, translates name-keyed row data to @@ -111,10 +96,7 @@ async function handleBatchInsert( const response = v2RowWriteError(error) if (response) return response - logger.error(`[${requestId}] Error batch inserting rows`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } } @@ -122,107 +104,80 @@ async function handleBatchInsert( * GET /api/v2/tables/[tableId]/rows — Plain cursor page over the default row * order. Filtered/sorted reads go through `POST /query`. */ -export const GET = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2ListTableRowsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const validated = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListTableRowsContract, + rateLimitEndpoint: 'table-rows', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const validated = input.query - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) - const accessResult = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') + const accessResult = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') - const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } - const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) - // Cursor-uniform v2 pagination: the opaque cursor encodes the underlying - // offset (upgradeable to keyset later without an interface change). Total row - // count is intentionally omitted here — it's available as `rowCount` on the table. - const offset = validated.cursor - ? (decodeCursor<{ offset: number }>(validated.cursor)?.offset ?? 0) - : 0 + // Cursor-uniform v2 pagination: the opaque cursor encodes the underlying + // offset (upgradeable to keyset later without an interface change). Total row + // count is intentionally omitted here — it's available as `rowCount` on the table. + const offset = validated.cursor + ? (decodeCursor<{ offset: number }>(validated.cursor)?.offset ?? 0) + : 0 - const result = await queryRows( - table, - { - limit: validated.limit, - offset, - includeTotal: true, - withExecutions: false, - }, - requestId - ) + const result = await queryRows( + table, + { + limit: validated.limit, + offset, + includeTotal: true, + withExecutions: false, + }, + requestId + ) - const total = result.totalCount ?? 0 - const hasMore = offset + result.rowCount < total - const nextCursor = hasMore ? encodeCursor({ offset: offset + validated.limit }) : null + const total = result.totalCount ?? 0 + const hasMore = offset + result.rowCount < total + const nextCursor = hasMore ? encodeCursor({ offset: offset + validated.limit }) : null - return v2CursorList( - result.rows.map((r) => toApiRow(r, toNamedRow)), - nextCursor, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + return v2CursorList( + result.rows.map((r) => toApiRow(r, toNamedRow)), + nextCursor, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - logger.error(`[${requestId}] Error querying rows`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + throw error + } + }, }) /** POST /api/v2/tables/[tableId]/rows — Insert row(s). Supports single or batch. */ -export const POST = withRouteHandler( - async (request: NextRequest, context: TableRowsRouteParams) => { - const requestId = generateRequestId() - +export const POST = withPublicApiRouteHandler({ + contract: v2CreateTableRowsContract, + rateLimitEndpoint: 'table-rows', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2CreateTableRowsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response + const { tableId } = input.params - const { tableId } = parsed.data.params - - if ('rows' in parsed.data.body) { - const batchValidated = parsed.data.body + if ('rows' in input.body) { + const batchValidated = input.body const scopeError = await resolveWorkspaceScope(rateLimit, batchValidated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) return handleBatchInsert(requestId, tableId, batchValidated, userId, rateLimit) } - const validated = parsed.data.body + const validated = input.body const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -258,106 +213,76 @@ export const POST = withRouteHandler( const response = v2RowWriteError(error) if (response) return response - logger.error(`[${requestId}] Error inserting row`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) /** PUT /api/v2/tables/[tableId]/rows — Bulk update rows by predicate filter. */ -export const PUT = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) +export const PUT = withPublicApiRouteHandler({ + contract: v2UpdateRowsByFilterContract, + rateLimitEndpoint: 'table-rows', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const validated = input.body - const userId = rateLimit.userId! + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) - const gate = await v2ApiGateError(userId) - if (gate) return gate + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return v2TableAccessError(accessResult) - const parsed = await parseRequest(v2UpdateRowsByFilterContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } - const { tableId } = parsed.data.params - const validated = parsed.data.body + const idByName = buildIdByName(table.schema as TableSchema) + const patchData = rowDataNameToId(validated.data as RowData, idByName) - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) + const sizeValidation = validateRowSize(patchData) + if (!sizeValidation.valid) { + return v2Error('BAD_REQUEST', 'Invalid row data', { details: sizeValidation.errors }) + } - const accessResult = await checkAccess(tableId, userId, 'write') - if (!accessResult.ok) return v2TableAccessError(accessResult) + const result = await updateRowsByFilter( + table, + { + filter: v2BulkPredicateToFilter(validated.filter, table.schema as TableSchema), + data: patchData, + limit: validated.limit, + actorUserId: userId, + }, + requestId + ) - const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } + // v2 always returns `updatedRowIds` ([] when nothing matched); v1 dropped it + // on the zero-match branch. + return v2Data( + { updatedCount: result.affectedCount, updatedRowIds: result.affectedRowIds }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - const idByName = buildIdByName(table.schema as TableSchema) - const patchData = rowDataNameToId(validated.data as RowData, idByName) + const response = v2RowWriteError(error) + if (response) return response - const sizeValidation = validateRowSize(patchData) - if (!sizeValidation.valid) { - return v2Error('BAD_REQUEST', 'Invalid row data', { details: sizeValidation.errors }) + throw error } - - const result = await updateRowsByFilter( - table, - { - filter: v2BulkPredicateToFilter(validated.filter, table.schema as TableSchema), - data: patchData, - limit: validated.limit, - actorUserId: userId, - }, - requestId - ) - - // v2 always returns `updatedRowIds` ([] when nothing matched); v1 dropped it - // on the zero-match branch. - return v2Data( - { updatedCount: result.affectedCount, updatedRowIds: result.affectedRowIds }, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - const response = v2RowWriteError(error) - if (response) return response - - logger.error(`[${requestId}] Error updating rows by filter`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** DELETE /api/v2/tables/[tableId]/rows — Delete rows by predicate filter or IDs. */ -export const DELETE = withRouteHandler( - async (request: NextRequest, context: TableRowsRouteParams) => { - const requestId = generateRequestId() - +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteTableRowsContract, + rateLimitEndpoint: 'table-rows', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteTableRowsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const validated = parsed.data.body + const { tableId } = input.params + const validated = input.body const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -411,10 +336,7 @@ export const DELETE = withRouteHandler( const response = v2RowWriteError(error) if (response) return response - logger.error(`[${requestId}] Error deleting rows`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) 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 a8b4c21593b..346b8a74b15 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 @@ -1,94 +1,68 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2UpsertTableRowContract } from '@/lib/api/contracts/v2/tables' -import { isZodError, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isZodError } from '@/lib/api/server' import type { RowData, TableSchema } from '@/lib/table' import { buildIdByName, rowDataNameToId, upsertRow } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' import { toApiRow, v2TableAccessError } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableUpsertAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface UpsertRouteParams { - params: Promise<{ tableId: string }> -} - /** POST /api/v2/tables/[tableId]/rows/upsert — Insert or update a row based on unique columns. */ -export const POST = withRouteHandler(async (request: NextRequest, context: UpsertRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpsertTableRowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const validated = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') +export const POST = withPublicApiRouteHandler({ + contract: v2UpsertTableRowContract, + rateLimitEndpoint: 'table-rows', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const validated = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) + const upsertResult = await upsertRow( + { + tableId, + workspaceId: validated.workspaceId, + data: rowDataNameToId(validated.data as RowData, idByName), + userId, + conflictTarget: validated.conflictTarget, + }, + table, + requestId + ) + + return v2Data( + { row: toApiRow(upsertResult.row, toNamedRow), operation: upsertResult.operation }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + throw error } - - const idByName = buildIdByName(table.schema as TableSchema) - const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) - const upsertResult = await upsertRow( - { - tableId, - workspaceId: validated.workspaceId, - data: rowDataNameToId(validated.data as RowData, idByName), - userId, - conflictTarget: validated.conflictTarget, - }, - table, - requestId - ) - - return v2Data( - { row: toApiRow(upsertResult.row, toNamedRow), operation: upsertResult.operation }, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - logger.error(`[${requestId}] Error upserting row`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) 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 85e9921266b..0df7c7243c9 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 @@ -1,14 +1,8 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2DeleteTableViewContract, v2GetTableViewContract, v2UpdateTableViewContract, } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { TableSchema } from '@/lib/table' import { deleteTableView, @@ -17,47 +11,22 @@ import { updateTableView, } from '@/lib/table' import { getRequiredUserEmail } from '@/lib/users/queries' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { toApiView, v2TableAccessError } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableViewDetailAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface TableViewRouteParams { - params: Promise<{ tableId: string; viewId: string }> -} - /** GET /api/v2/tables/[tableId]/views/[viewId] — One saved view. */ -export const GET = withRouteHandler(async (request: NextRequest, context: TableViewRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-view-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetTableViewContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId, viewId } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2GetTableViewContract, + rateLimitEndpoint: 'table-view-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { tableId, viewId } = input.params + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -75,38 +44,20 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableV { view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null) }, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error getting table view`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** * PATCH /api/v2/tables/[tableId]/views/[viewId] — Rename, replace or merge the * config, or promote the view to the table's default. */ -export const PATCH = withRouteHandler( - async (request: NextRequest, context: TableViewRouteParams) => { - const requestId = generateRequestId() - +export const PATCH = withPublicApiRouteHandler({ + contract: v2UpdateTableViewContract, + rateLimitEndpoint: 'table-view-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-view-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateTableViewContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId, viewId } = parsed.data.params - const { workspaceId, name, config, configPatch, isDefault } = parsed.data.body + const { tableId, viewId } = input.params + const { workspaceId, name, config, configPatch, isDefault } = input.body const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -138,55 +89,32 @@ export const PATCH = withRouteHandler( } catch (error) { if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) - logger.error(`[${requestId}] Error updating table view`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) /** DELETE /api/v2/tables/[tableId]/views/[viewId] — Remove a saved view. */ -export const DELETE = withRouteHandler( - async (request: NextRequest, context: TableViewRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-view-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteTableViewContract, + rateLimitEndpoint: 'table-view-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { tableId, viewId } = input.params + const { workspaceId } = input.query - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteTableViewContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId, viewId } = parsed.data.params - const { workspaceId } = parsed.data.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } - const deleted = await deleteTableView(viewId, tableId) - if (!deleted) return v2Error('NOT_FOUND', 'View not found') + const deleted = await deleteTableView(viewId, tableId) + if (!deleted) return v2Error('NOT_FOUND', 'View not found') - return v2Data({ id: viewId }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error deleting table view`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) + return v2Data({ id: viewId }, { rateLimit }) + }, +}) 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 0dacc9e619f..841a5c8947e 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts @@ -1,10 +1,4 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateTableViewContract, v2ListTableViewsContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { TableSchema } from '@/lib/table' import { createTableView, listTableViews, TableViewValidationError } from '@/lib/table' import { @@ -12,53 +6,27 @@ import { getUserEmailsByIds, requireResolvedUserEmail, } from '@/lib/users/queries' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { toApiView, v2TableAccessError } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableViewsAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - /** * GET /api/v2/tables/[tableId]/views — Every saved view on the table. * * A table carries a bounded set of views, so this is one full page and * `nextCursor` is always `null`. */ -export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-views') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2ListTableViewsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListTableViewsContract, + rateLimitEndpoint: 'table-views', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { tableId } = input.params + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -84,64 +52,47 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR null, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error listing table views`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** POST /api/v2/tables/[tableId]/views — Save a filter/sort/layout as a named view. */ -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-views') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2CreateTableViewContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId, name, config } = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') +export const POST = withPublicApiRouteHandler({ + contract: v2CreateTableViewContract, + rateLimitEndpoint: 'table-views', + handler: async ({ input, auth: { userId, rateLimit } }) => { + try { + const { tableId } = input.params + const { workspaceId, name, config } = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const view = await createTableView({ + tableId, + workspaceId, + name, + config, + userId, + columns: (result.table.schema as TableSchema).columns, + }) + + return v2Data( + { + view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null), + }, + { rateLimit, status: 201 } + ) + } catch (error) { + if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) + + throw error } - - const view = await createTableView({ - tableId, - workspaceId, - name, - config, - userId, - columns: (result.table.schema as TableSchema).columns, - }) - - return v2Data( - { view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null) }, - { rateLimit, status: 201 } - ) - } catch (error) { - if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) - - logger.error(`[${requestId}] Error creating table view`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts index 87268ab070f..d6902239d8f 100644 --- a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts @@ -1,46 +1,27 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2TableExportDownloadContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { requireTableExport, tableExportResult } from '@/lib/table/orchestration/export-resource' import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2TableExportDownloadAPI') const DOWNLOAD_TTL_SECONDS = 60 * 60 -interface TableExportRouteParams { - params: Promise<{ exportId: string }> -} - -export const GET = withRouteHandler( - async (request: NextRequest, context: TableExportRouteParams) => { +export const GET = withPublicApiRouteHandler({ + contract: v2TableExportDownloadContract, + rateLimitEndpoint: 'table-export', + handler: async ({ input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-export') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2TableExportDownloadContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const record = await requireTableExport(parsed.data.params.exportId, workspaceId) + const record = await requireTableExport(input.params.exportId, workspaceId) const access = await checkAccess(record.tableId, userId, 'read') if (!access.ok || access.table.workspaceId !== workspaceId) { return v2Error('NOT_FOUND', 'Table export not found') @@ -62,8 +43,7 @@ export const GET = withRouteHandler( } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to issue table export download', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts index 4fa1032e782..721652921f4 100644 --- a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts @@ -1,35 +1,22 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CancelTableExportContract, v2GetTableExportContract, } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { cancelTableExportResource, requireTableExport, toV2TableExport, } from '@/lib/table/orchestration/export-resource' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2TableExportAPI') - -interface TableExportRouteParams { - params: Promise<{ exportId: string }> -} - async function authorizeExport(exportId: string, workspaceId: string, userId: string) { const record = await requireTableExport(exportId, workspaceId) const access = await checkAccess(record.tableId, userId, 'read') @@ -37,56 +24,40 @@ async function authorizeExport(exportId: string, workspaceId: string, userId: st return record } -export const GET = withRouteHandler( - async (request: NextRequest, context: TableExportRouteParams) => { +export const GET = withPublicApiRouteHandler({ + contract: v2GetTableExportContract, + rateLimitEndpoint: 'table-export', + handler: async ({ input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-export') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2GetTableExportContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const record = await authorizeExport(parsed.data.params.exportId, workspaceId, userId) + const record = await authorizeExport(input.params.exportId, workspaceId, userId) if (!record) return v2Error('NOT_FOUND', 'Table export not found') return v2Data(toV2TableExport(record), { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to read table export', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) -export const DELETE = withRouteHandler( - async (request: NextRequest, context: TableExportRouteParams) => { +export const DELETE = withPublicApiRouteHandler({ + contract: v2CancelTableExportContract, + rateLimitEndpoint: 'table-export', + handler: async ({ input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-export') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2CancelTableExportContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const record = await authorizeExport(parsed.data.params.exportId, workspaceId, userId) + const record = await authorizeExport(input.params.exportId, workspaceId, userId) if (!record) return v2Error('NOT_FOUND', 'Table export not found') return v2Data(toV2TableExport(await cancelTableExportResource(record)), { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to cancel table export', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/tables/folders/route.ts b/apps/sim/app/api/v2/tables/folders/route.ts index c9744bf395b..3e885727a91 100644 --- a/apps/sim/app/api/v2/tables/folders/route.ts +++ b/apps/sim/app/api/v2/tables/folders/route.ts @@ -1,60 +1,32 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateTableFolderContract, v2DeleteTableFolderContract, v2ListTableFoldersContract, v2RelocateTableFolderContract, } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createFolderAtPath, deleteFolderByPath, relocateFolderByPath, } from '@/lib/folders/orchestration' import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { resolveFolderPathId, toV2PathFolder, v2FolderPathMutationError, } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2TableFoldersAPI') +import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const rateLimit = await checkRateLimit(request, 'tables') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2ListTableFoldersContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListTableFoldersContract, + rateLimitEndpoint: 'tables', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, parentPath, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -74,109 +46,77 @@ export const GET = withRouteHandler(async (request: NextRequest) => { null, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error listing table folders`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) -export const POST = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'tables') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2CreateTableFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, +export const POST = withPublicApiRouteHandler({ + contract: v2CreateTableFolderContract, + rateLimitEndpoint: 'tables', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path } = input.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await createFolderAtPath({ resourceType: 'table', workspaceId, userId, path }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await createFolderAtPath({ resourceType: 'table', workspaceId, userId, path }) - if (!result.success || !result.folder) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'table') - return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit, status: 201 }) + const index = await loadActiveFolderPathIndex(workspaceId, 'table') + return v2Data( + { folder: toV2PathFolder(result.folder, index, false) }, + { rateLimit, status: 201 } + ) + }, }) -export const PATCH = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'tables') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2RelocateTableFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, +export const PATCH = withPublicApiRouteHandler({ + contract: v2RelocateTableFolderContract, + rateLimitEndpoint: 'tables', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path, destinationPath } = input.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await relocateFolderByPath({ + resourceType: 'table', + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path, destinationPath } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await relocateFolderByPath({ - resourceType: 'table', - workspaceId, - userId, - path, - destinationPath, - }) - if (!result.success || !result.folder) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'table') - return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit }) + const index = await loadActiveFolderPathIndex(workspaceId, 'table') + return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit }) + }, }) -export const DELETE = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'tables') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2DeleteTableFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path, recursive } = parsed.data.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await deleteFolderByPath({ - resourceType: 'table', - workspaceId, - userId, - path, - recursive, - }) - if (!result.success || !result.deletedItems) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') - } - return v2Data( - { +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteTableFolderContract, + rateLimitEndpoint: 'tables', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path, recursive } = input.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await deleteFolderByPath({ + resourceType: 'table', + workspaceId, + userId, path, - deleted: true as const, - deletedItems: { - folders: result.deletedItems.folders, - tables: result.deletedItems.tables ?? 0, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + return v2Data( + { + path, + deleted: true as const, + deletedItems: { + folders: result.deletedItems.folders, + tables: result.deletedItems.tables ?? 0, + }, }, - }, - { rateLimit } - ) + { rateLimit } + ) + }, }) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts index b001a3a053d..626b4cc2f8b 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts @@ -1,9 +1,4 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CompleteTableImportContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { findOwnedTableImport, getOwnedTableImportUpload, @@ -11,44 +6,28 @@ import { toV2TableImport, } from '@/lib/table/orchestration/import-resource' import { completeUploadSession } from '@/lib/uploads/upload-session/service' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' import { v2TableLockError } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2CompleteTableImportAPI') - -interface TableImportRouteParams { - params: Promise<{ importId: string }> -} - -export const POST = withRouteHandler( - async (request: NextRequest, context: TableImportRouteParams) => { +export const POST = withPublicApiRouteHandler({ + contract: v2CompleteTableImportContract, + rateLimitEndpoint: 'table-import', + handler: async ({ input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-import') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2CompleteTableImportContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) const upload = await getOwnedTableImportUpload({ - importId: parsed.data.params.importId, + importId: input.params.importId, workspaceId, userId, - uploadToken: parsed.data.headers['upload-token'], + uploadToken: input.headers['upload-token'], }) const existing = await findOwnedTableImport({ importId: upload.id, @@ -67,8 +46,7 @@ export const POST = withRouteHandler( if (lockError) return lockError const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to complete table import upload', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts index 6481d8ba478..779c90acb5e 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts @@ -1,60 +1,38 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateTableImportPartUrlsContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getOwnedTableImportUpload } from '@/lib/table/orchestration/import-resource' import { createUploadPartUrls } from '@/lib/uploads/upload-session/service' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2TableImportPartsAPI') - -interface TableImportRouteParams { - params: Promise<{ importId: string }> -} - -export const POST = withRouteHandler( - async (request: NextRequest, context: TableImportRouteParams) => { +export const POST = withPublicApiRouteHandler({ + contract: v2CreateTableImportPartUrlsContract, + rateLimitEndpoint: 'table-import', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-import') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2CreateTableImportPartUrlsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) const session = await getOwnedTableImportUpload({ - importId: parsed.data.params.importId, + importId: input.params.importId, workspaceId, userId, - uploadToken: parsed.data.headers['upload-token'], + uploadToken: input.headers['upload-token'], }) const parts = await createUploadPartUrls({ session, - partNumbers: parsed.data.body.partNumbers, + partNumbers: input.body.partNumbers, localOrigin: request.nextUrl.origin, }) return v2Data({ parts }, { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to create table import part URLs', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) 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 22005ef907e..7b5587a3ff0 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts @@ -1,90 +1,61 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CancelTableImportContract, v2GetTableImportContract, } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { abortTableImportUpload, cancelTableImportResource, getOwnedTableImport, toV2TableImport, } from '@/lib/table/orchestration/import-resource' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2TableImportAPI') - -interface TableImportRouteParams { - params: Promise<{ importId: string }> -} - -export const GET = withRouteHandler( - async (request: NextRequest, context: TableImportRouteParams) => { +export const GET = withPublicApiRouteHandler({ + contract: v2GetTableImportContract, + rateLimitEndpoint: 'table-import', + handler: async ({ input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-import') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2GetTableImportContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.query.workspaceId) + const scopeError = await resolveWorkspaceScope(rateLimit, input.query.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) const record = await getOwnedTableImport({ - importId: parsed.data.params.importId, - workspaceId: parsed.data.query.workspaceId, + importId: input.params.importId, + workspaceId: input.query.workspaceId, userId, }) return v2Data(await toV2TableImport(record), { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to read table import', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) -export const DELETE = withRouteHandler( - async (request: NextRequest, context: TableImportRouteParams) => { +export const DELETE = withPublicApiRouteHandler({ + contract: v2CancelTableImportContract, + rateLimitEndpoint: 'table-import', + handler: async ({ input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-import') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2CancelTableImportContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.query.workspaceId) + const scopeError = await resolveWorkspaceScope(rateLimit, input.query.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const uploadToken = parsed.data.headers['upload-token'] + const uploadToken = input.headers['upload-token'] const record = uploadToken ? await abortTableImportUpload({ - importId: parsed.data.params.importId, - workspaceId: parsed.data.query.workspaceId, + importId: input.params.importId, + workspaceId: input.query.workspaceId, userId, uploadToken, }) : await cancelTableImportResource( await getOwnedTableImport({ - importId: parsed.data.params.importId, - workspaceId: parsed.data.query.workspaceId, + importId: input.params.importId, + workspaceId: input.query.workspaceId, userId, }) ) @@ -92,8 +63,7 @@ export const DELETE = withRouteHandler( } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to cancel table import', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/tables/imports/route.ts b/apps/sim/app/api/v2/tables/imports/route.ts index 43ce013fa3b..8a97f9f36ad 100644 --- a/apps/sim/app/api/v2/tables/imports/route.ts +++ b/apps/sim/app/api/v2/tables/imports/route.ts @@ -1,70 +1,50 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateTableImportContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createTableImportResource, toV2CreateTableImport, } from '@/lib/table/orchestration/import-resource' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' import { v2TableLockError } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableImportsAPI') - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'table-import') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2CreateTableImportContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, +export const POST = withPublicApiRouteHandler({ + contract: v2CreateTableImportContract, + rateLimitEndpoint: 'table-import', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + try { + const scopeError = await resolveWorkspaceScope(rateLimit, input.body.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + let created: Awaited> + if (input.body.target.type === 'new') { + const resolution = await resolveFolderPathIdentity({ + workspaceId: input.body.workspaceId, + resourceType: 'table', + path: input.body.target.folderPath ?? '/', + }) + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + created = await createTableImportResource( + input.body, + userId, + request.nextUrl.origin, + resolution.folderId + ) + } else { + created = await createTableImportResource(input.body, userId, request.nextUrl.origin) } - ) - if (!parsed.success) return parsed.response - const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.body.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - let created: Awaited> - if (parsed.data.body.target.type === 'new') { - const resolution = await resolveFolderPathIdentity({ - workspaceId: parsed.data.body.workspaceId, - resourceType: 'table', - path: parsed.data.body.target.folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - created = await createTableImportResource( - parsed.data.body, - userId, - request.nextUrl.origin, - resolution.folderId - ) - } else { - created = await createTableImportResource(parsed.data.body, userId, request.nextUrl.origin) + return v2Data(toV2CreateTableImport(created), { rateLimit, status: 201 }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + throw error } - return v2Data(toV2CreateTableImport(created), { rateLimit, status: 201 }) - } catch (error) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - logger.error('Failed to create table import', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index c3d319bbafd..7caef0960a8 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -1,21 +1,16 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateTableContract, v2ListTablesContract } from '@/lib/api/contracts/v2/tables' -import { isZodError, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isZodError } from '@/lib/api/server' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { createTable, getWorkspaceTableLimits, queryTables, type TableSchema } from '@/lib/table' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { normalizeColumn } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathId, resolveFolderPathIdentity, } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { cursorSortKey, decodeSortedCursor, @@ -25,41 +20,20 @@ import { v2CursorSortError, v2Data, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' import { toApiTable } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TablesAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/tables — List all tables in a workspace. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'tables') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListTablesContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListTablesContract, + rateLimitEndpoint: 'tables', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -90,93 +64,69 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null return v2CursorList(items, nextCursor, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error listing tables`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** POST /api/v2/tables — Create a new table. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'tables') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) +export const POST = withPublicApiRouteHandler({ + contract: v2CreateTableContract, + rateLimitEndpoint: 'tables', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + try { + const params = input.body - const userId = rateLimit.userId! + const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) - const gate = await v2ApiGateError(userId) - if (gate) return gate + const planLimits = await getWorkspaceTableLimits(params.workspaceId) - const parsed = await parseRequest( - v2CreateTableContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, + const normalizedSchema: TableSchema = { + columns: params.schema.columns.map(normalizeColumn), } - ) - if (!parsed.success) return parsed.response - - const params = parsed.data.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const planLimits = await getWorkspaceTableLimits(params.workspaceId) - const normalizedSchema: TableSchema = { - columns: params.schema.columns.map(normalizeColumn), - } - - const resolution = await resolveFolderPathIdentity({ - workspaceId: params.workspaceId, - resourceType: 'table', - path: params.folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - - const table = await createTable( - { - name: params.name, - description: params.description, - schema: normalizedSchema, + const resolution = await resolveFolderPathIdentity({ workspaceId: params.workspaceId, - userId, - maxTables: planLimits.maxTables, - folderId: resolution.folderId, - }, - requestId - ) - - recordAudit({ - workspaceId: params.workspaceId, - actorId: userId, - action: AuditAction.TABLE_CREATED, - resourceType: AuditResourceType.TABLE, - resourceId: table.id, - resourceName: table.name, - description: `Created table "${table.name}" via API`, - metadata: { columnCount: params.schema.columns.length }, - request, - }) - - return v2Data( - { table: toApiTable(table, folderPathForId(resolution.index, table.folderId)) }, - { rateLimit, status: 201 } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - logger.error(`[${requestId}] Error creating table`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + resourceType: 'table', + path: params.folderPath ?? '/', + }) + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + + const table = await createTable( + { + name: params.name, + description: params.description, + schema: normalizedSchema, + workspaceId: params.workspaceId, + userId, + maxTables: planLimits.maxTables, + folderId: resolution.folderId, + }, + requestId + ) + + recordAudit({ + workspaceId: params.workspaceId, + actorId: userId, + action: AuditAction.TABLE_CREATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: table.name, + description: `Created table "${table.name}" via API`, + metadata: { columnCount: params.schema.columns.length }, + request, + }) + + return v2Data( + { table: toApiTable(table, folderPathForId(resolution.index, table.folderId)) }, + { rateLimit, status: 201 } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + throw error + } + }, }) diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts index 9089d6a47b6..0bf14474706 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -1,20 +1,15 @@ import { createLogger } from '@sim/logger' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v1DeployWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' import { v2DeployWorkflowContract, v2UndeployWorkflowContract, } from '@/lib/api/contracts/v2/workflows' -import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { parseOptionalJsonBody } from '@/lib/api/server' import { captureServerEvent } from '@/lib/posthog/server' import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' -import { checkRateLimit } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' const logger = createLogger('V2WorkflowDeployAPI') @@ -23,25 +18,12 @@ export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 120 -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - +export const POST = withPublicApiRouteHandler({ + contract: v2DeployWorkflowContract, + rateLimitEndpoint: 'workflow-deploy', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'workflow-deploy') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeployWorkflowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params + const { id } = input.params const rawBody = await parseOptionalJsonBody(request) if (!rawBody.success) { @@ -102,33 +84,17 @@ export const POST = withRouteHandler( if (error instanceof WorkflowLockedError) { return v2Error('LOCKED', error.message) } - logger.error(`[${requestId}] Workflow deploy error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) - -export const DELETE = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() + }, +}) +export const DELETE = withPublicApiRouteHandler({ + contract: v2UndeployWorkflowContract, + rateLimitEndpoint: 'workflow-deploy', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'workflow-deploy') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UndeployWorkflowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params + const { id } = input.params const target = await resolveV2WorkflowTarget(rateLimit, userId, id, 'admin') if (!target) return v2Error('NOT_FOUND', 'Workflow not found') @@ -167,10 +133,7 @@ export const DELETE = withRouteHandler( if (error instanceof WorkflowLockedError) { return v2Error('LOCKED', error.message) } - logger.error(`[${requestId}] Workflow undeploy error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) 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 c4caa0ec7d8..113b19fd3fa 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.ts @@ -1,18 +1,13 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { v2ExportWorkflowContract } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { v2Data, v2Error } from '@/app/api/v2/lib/response' const logger = createLogger('V2WorkflowExportAPI') @@ -28,75 +23,55 @@ export const revalidate = 0 * {@link buildWorkflowExportPayload}; this route authenticates and renders the * v2 envelope. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) +export const GET = withPublicApiRouteHandler({ + contract: v2ExportWorkflowContract, + rateLimitEndpoint: 'workflow-export', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + const { id } = input.params - try { - const rateLimit = await checkRateLimit(request, 'workflow-export') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + logger.info(`[${requestId}] Exporting workflow ${id}`, { userId }) - const userId = rateLimit.userId! + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - const gate = await v2ApiGateError(userId) - if (gate) return gate + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') - const parsed = await parseRequest(v2ExportWorkflowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response + const payload = await buildWorkflowExportPayload(workflowData) + if (!payload) return v2Error('NOT_FOUND', 'Workflow state not found') + const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') + const folderPath = folderPathForId(folderIndex, workflowData.folderId) - const { id } = parsed.data.params - - logger.info(`[${requestId}] Exporting workflow ${id}`, { userId }) - - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') - - const payload = await buildWorkflowExportPayload(workflowData) - if (!payload) return v2Error('NOT_FOUND', 'Workflow state not found') - const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') - const folderPath = folderPathForId(folderIndex, workflowData.folderId) - - recordAudit({ + recordAudit({ + workspaceId: workflowData.workspaceId, + actorId: userId, + action: AuditAction.WORKFLOW_EXPORTED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowData.id, + resourceName: workflowData.name, + description: `Exported workflow "${workflowData.name}" via the API`, + metadata: { workspaceId: workflowData.workspaceId, - actorId: userId, - action: AuditAction.WORKFLOW_EXPORTED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowData.id, - resourceName: workflowData.name, - description: `Exported workflow "${workflowData.name}" via the API`, - metadata: { - workspaceId: workflowData.workspaceId, - folderPath, - blocksCount: Object.keys(payload.state.blocks).length, - edgesCount: payload.state.edges.length, - }, - request, - }) + folderPath, + blocksCount: Object.keys(payload.state.blocks).length, + edgesCount: payload.state.edges.length, + }, + request, + }) - return v2Data( - { - ...payload, - workflow: { - id: payload.workflow.id, - name: payload.workflow.name, - description: payload.workflow.description, - workspaceId: payload.workflow.workspaceId, - folderPath, - }, + return v2Data( + { + ...payload, + workflow: { + id: payload.workflow.id, + name: payload.workflow.name, + description: payload.workflow.description, + workspaceId: payload.workflow.workspaceId, + folderPath, }, - { rateLimit } - ) - } catch (error) { - logger.error(`[${requestId}] Workflow export error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) + }, + { rateLimit } + ) + }, +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts index d1cd5ec5a21..09fc344c878 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -1,18 +1,13 @@ import { createLogger } from '@sim/logger' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v1RollbackWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows' -import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { parseOptionalJsonBody } from '@/lib/api/server' import { captureServerEvent } from '@/lib/posthog/server' import { performActivateVersion } from '@/lib/workflows/orchestration' import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' -import { checkRateLimit } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' const logger = createLogger('V2WorkflowRollbackAPI') @@ -21,25 +16,12 @@ export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 120 -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - +export const POST = withPublicApiRouteHandler({ + contract: v2RollbackWorkflowContract, + rateLimitEndpoint: 'workflow-rollback', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'workflow-rollback') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2RollbackWorkflowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params + const { id } = input.params const rawBody = await parseOptionalJsonBody(request) if (!rawBody.success) { @@ -116,10 +98,7 @@ export const POST = withRouteHandler( if (error instanceof WorkflowLockedError) { return v2Error('LOCKED', error.message) } - logger.error(`[${requestId}] Workflow rollback error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index 41a1d478044..f2f77b0f8e7 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@sim/logger' import { assertFolderMutable, assertWorkflowMutable, @@ -6,9 +5,6 @@ import { getActiveWorkflowRecord, WorkflowLockedError, } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { type V2WorkflowDetail, type V2WorkflowListItem, @@ -16,24 +12,14 @@ import { v2GetWorkflowContract, v2UpdateWorkflowContract, } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { loadWorkflowReadSnapshot } from '@/lib/workflows/queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2WorkflowDetailAPI') +import { v2Data, v2Error, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' export const revalidate = 0 @@ -41,208 +27,166 @@ interface RouteContext { params: Promise<{ id: string }> } -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) +export const GET = withPublicApiRouteHandler({ + contract: v2GetWorkflowContract, + rateLimitEndpoint: 'workflow-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { id } = input.params - try { - const rateLimit = await checkRateLimit(request, 'workflow-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const snapshot = await loadWorkflowReadSnapshot(id) + const workflowData = snapshot.workflowRecord + if (!workflowData?.workspaceId || workflowData.archivedAt) { + return v2Error('NOT_FOUND', 'Workflow not found') + } - const userId = rateLimit.userId! + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') - const gate = await v2ApiGateError(userId) - if (gate) return gate + const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') + const inputs = extractInputFieldsFromBlocks(snapshot.normalizedData?.blocks ?? {}) - const parsed = await parseRequest(v2GetWorkflowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response + const detail: V2WorkflowDetail = { + id: workflowData.id, + name: workflowData.name, + description: workflowData.description, + folderPath: folderPathForId(folderIndex, workflowData.folderId), + workspaceId: workflowData.workspaceId, + isDeployed: workflowData.isDeployed, + deployedAt: workflowData.deployedAt?.toISOString() ?? null, + runCount: workflowData.runCount, + lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, + variables: (workflowData.variables as Record | null) ?? {}, + inputs, + createdAt: workflowData.createdAt.toISOString(), + updatedAt: workflowData.updatedAt.toISOString(), + } - const { id } = parsed.data.params + return v2Data(detail, { rateLimit }) + }, +}) - const snapshot = await loadWorkflowReadSnapshot(id) - const workflowData = snapshot.workflowRecord - if (!workflowData?.workspaceId || workflowData.archivedAt) { - return v2Error('NOT_FOUND', 'Workflow not found') - } +/** PATCH /api/v2/workflows/[id] — Rename, re-describe, or move a workflow. */ +export const PATCH = withPublicApiRouteHandler({ + contract: v2UpdateWorkflowContract, + rateLimitEndpoint: 'workflow-detail', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { id } = input.params + const { name, description, folderPath } = input.body + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + const access = await resolveWorkspaceAccess( + rateLimit, + userId, + workflowData.workspaceId, + 'write' + ) if (access) return v2Error('NOT_FOUND', 'Workflow not found') - const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') - const inputs = extractInputFieldsFromBlocks(snapshot.normalizedData?.blocks ?? {}) + const resolution = + folderPath === undefined + ? undefined + : await resolveFolderPathIdentity({ + workspaceId: workflowData.workspaceId, + resourceType: 'workflow', + path: folderPath, + }) + if (resolution && !resolution.found) { + return v2Error('NOT_FOUND', 'Folder not found') + } + + const folderId = resolution?.folderId + await assertWorkflowMutable(id) + if (folderId !== undefined) await assertFolderMutable(folderId) - const detail: V2WorkflowDetail = { - id: workflowData.id, - name: workflowData.name, - description: workflowData.description, - folderPath: folderPathForId(folderIndex, workflowData.folderId), + const result = await performUpdateWorkflow({ + workflowId: id, + userId, workspaceId: workflowData.workspaceId, + currentName: workflowData.name, + currentFolderId: workflowData.folderId, + name, + description, + folderId, + requestId, + }) + + if (!result.success || !result.workflow) { + return v2ErrorForOrchestration( + result.errorCode, + result.error ?? 'Failed to update workflow' + ) + } + + const updated = result.workflow + const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') + /** + * Deployment and run counters are untouched by a metadata update, so they + * come from the record read above rather than a second query. + */ + const item: V2WorkflowListItem = { + id: updated.id, + name: updated.name, + description: updated.description, + folderPath: folderPathForId(folderIndex, updated.folderId), + workspaceId: updated.workspaceId ?? workflowData.workspaceId, isDeployed: workflowData.isDeployed, deployedAt: workflowData.deployedAt?.toISOString() ?? null, runCount: workflowData.runCount, lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, - variables: (workflowData.variables as Record | null) ?? {}, - inputs, - createdAt: workflowData.createdAt.toISOString(), - updatedAt: workflowData.updatedAt.toISOString(), + createdAt: updated.createdAt.toISOString(), + updatedAt: updated.updatedAt.toISOString(), } - return v2Data(detail, { rateLimit }) + return v2Data(item, { rateLimit }) } catch (error) { - logger.error(`[${requestId}] Workflow details fetch error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) - -/** PATCH /api/v2/workflows/[id] — Rename, re-describe, or move a workflow. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'workflow-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateWorkflowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { name, description, folderPath } = parsed.data.body - - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess( - rateLimit, - userId, - workflowData.workspaceId, - 'write' - ) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') - - const resolution = - folderPath === undefined - ? undefined - : await resolveFolderPathIdentity({ - workspaceId: workflowData.workspaceId, - resourceType: 'workflow', - path: folderPath, - }) - if (resolution && !resolution.found) { - return v2Error('NOT_FOUND', 'Folder not found') - } - - const folderId = resolution?.folderId - await assertWorkflowMutable(id) - if (folderId !== undefined) await assertFolderMutable(folderId) - - const result = await performUpdateWorkflow({ - workflowId: id, - userId, - workspaceId: workflowData.workspaceId, - currentName: workflowData.name, - currentFolderId: workflowData.folderId, - name, - description, - folderId, - requestId, - }) - - if (!result.success || !result.workflow) { - return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to update workflow') - } - - const updated = result.workflow - const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') - /** - * Deployment and run counters are untouched by a metadata update, so they - * come from the record read above rather than a second query. - */ - const item: V2WorkflowListItem = { - id: updated.id, - name: updated.name, - description: updated.description, - folderPath: folderPathForId(folderIndex, updated.folderId), - workspaceId: updated.workspaceId ?? workflowData.workspaceId, - isDeployed: workflowData.isDeployed, - deployedAt: workflowData.deployedAt?.toISOString() ?? null, - runCount: workflowData.runCount, - lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, - createdAt: updated.createdAt.toISOString(), - updatedAt: updated.updatedAt.toISOString(), - } + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + return v2Error('LOCKED', error.message) + } - return v2Data(item, { rateLimit }) - } catch (error) { - if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { - return v2Error('LOCKED', error.message) + throw error } - - logger.error(`[${requestId}] Workflow update error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'workflow-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteWorkflowContract, + rateLimitEndpoint: 'workflow-detail', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { id } = input.params - const parsed = await parseRequest(v2DeleteWorkflowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - const { id } = parsed.data.params + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess( + rateLimit, + userId, + workflowData.workspaceId, + 'write' + ) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + await assertWorkflowMutable(id) - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess( - rateLimit, - userId, - workflowData.workspaceId, - 'write' - ) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') + const result = await performDeleteWorkflow({ workflowId: id, userId, requestId }) + if (!result.success) { + return v2ErrorForOrchestration( + result.errorCode, + result.error ?? 'Failed to delete workflow' + ) + } - await assertWorkflowMutable(id) + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + if (error instanceof WorkflowLockedError) return v2Error('LOCKED', error.message) - const result = await performDeleteWorkflow({ workflowId: id, userId, requestId }) - if (!result.success) { - return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to delete workflow') + throw error } - - return v2Data({ id, deleted: true as const }, { rateLimit }) - } catch (error) { - if (error instanceof WorkflowLockedError) return v2Error('LOCKED', error.message) - - logger.error(`[${requestId}] Workflow delete error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts index 1ed021c974b..f1fe2633758 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts @@ -1,21 +1,12 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { type V2WorkflowVersionDetail, v2GetWorkflowVersionContract, } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils' -import { checkRateLimit } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { v2Data, v2Error } from '@/app/api/v2/lib/response' import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' -const logger = createLogger('V2WorkflowVersionDetailAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -23,48 +14,28 @@ export const revalidate = 0 * GET /api/v2/workflows/[id]/versions/[version] — Fetch one deployment version * and the workflow state it pins. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; version: string }> }) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'workflow-version-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetWorkflowVersionContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id, version } = parsed.data.params - - const target = await resolveV2WorkflowTarget(rateLimit, userId, id) - if (!target) return v2Error('NOT_FOUND', 'Workflow not found') - - const row = await getWorkflowDeploymentVersion(id, version) - if (!row?.state) return v2Error('NOT_FOUND', 'Deployment version not found') - - const detail: V2WorkflowVersionDetail = { - id: row.id, - version: row.version, - name: row.name, - description: row.description, - isActive: row.isActive, - createdAt: row.createdAt.toISOString(), - state: row.state as V2WorkflowVersionDetail['state'], - } - - return v2Data(detail, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Workflow version fetch error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') +export const GET = withPublicApiRouteHandler({ + contract: v2GetWorkflowVersionContract, + rateLimitEndpoint: 'workflow-version-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { id, version } = input.params + + const target = await resolveV2WorkflowTarget(rateLimit, userId, id) + if (!target) return v2Error('NOT_FOUND', 'Workflow not found') + + const row = await getWorkflowDeploymentVersion(id, version) + if (!row?.state) return v2Error('NOT_FOUND', 'Deployment version not found') + + const detail: V2WorkflowVersionDetail = { + id: row.id, + version: row.version, + name: row.name, + description: row.description, + isActive: row.isActive, + createdAt: row.createdAt.toISOString(), + state: row.state as V2WorkflowVersionDetail['state'], } - } -) + + return v2Data(detail, { rateLimit }) + }, +}) 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 6d50be6db75..1a75e45cd16 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts @@ -1,28 +1,12 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { type V2WorkflowVersion, v2ListWorkflowVersionsContract, } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { listWorkflowVersions } from '@/lib/workflows/persistence/utils' -import { checkRateLimit } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - decodeCursor, - encodeCursor, - v2CursorList, - v2Error, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { decodeCursor, encodeCursor, v2CursorList, v2Error } from '@/app/api/v2/lib/response' import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' -const logger = createLogger('V2WorkflowVersionsAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -36,72 +20,52 @@ interface WorkflowVersionCursor { * newest first. These are the versions `POST /api/v2/workflows/[id]/rollback` * accepts, so a caller no longer has to guess a version number. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'workflow-versions') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2ListWorkflowVersionsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { limit, cursor } = parsed.data.query - - const target = await resolveV2WorkflowTarget(rateLimit, userId, id) - if (!target) return v2Error('NOT_FOUND', 'Workflow not found') - - /** - * A cursor that decodes to anything other than a version number is - * rejected rather than ignored: comparing every row against a missing - * `version` yields an empty page with `nextCursor: null`, which reads to - * the caller as a clean end-of-list while versions are still pending. - */ - const after = cursor ? decodeCursor(cursor) : null - if (cursor && (!after || !Number.isInteger(after.version) || after.version < 1)) { - return v2Error('BAD_REQUEST', 'Invalid cursor') - } - - // One extra row is the has-more probe, matching the other v2 cursor lists. - const { versions: rows } = await listWorkflowVersions(id, { - limit: limit + 1, - afterVersion: after?.version, - }) - - const hasMore = rows.length > limit - const page = rows.slice(0, limit) - - const data: V2WorkflowVersion[] = page.map((row) => ({ - id: row.id, - version: row.version, - name: row.name, - description: row.description, - isActive: row.isActive, - createdAt: row.createdAt.toISOString(), - deployedBy: row.deployedByName, - // The shared helper widens the operation-status pg enum to `string`. - latestOperationStatus: - row.latestOperationStatus as V2WorkflowVersion['latestOperationStatus'], - })) - - const nextCursor = - hasMore && data.length > 0 ? encodeCursor({ version: data[data.length - 1].version }) : null - - return v2CursorList(data, nextCursor, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Workflow versions fetch error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') +export const GET = withPublicApiRouteHandler({ + contract: v2ListWorkflowVersionsContract, + rateLimitEndpoint: 'workflow-versions', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { id } = input.params + const { limit, cursor } = input.query + + const target = await resolveV2WorkflowTarget(rateLimit, userId, id) + if (!target) return v2Error('NOT_FOUND', 'Workflow not found') + + /** + * A cursor that decodes to anything other than a version number is + * rejected rather than ignored: comparing every row against a missing + * `version` yields an empty page with `nextCursor: null`, which reads to + * the caller as a clean end-of-list while versions are still pending. + */ + const after = cursor ? decodeCursor(cursor) : null + if (cursor && (!after || !Number.isInteger(after.version) || after.version < 1)) { + return v2Error('BAD_REQUEST', 'Invalid cursor') } - } -) + + // One extra row is the has-more probe, matching the other v2 cursor lists. + const { versions: rows } = await listWorkflowVersions(id, { + limit: limit + 1, + afterVersion: after?.version, + }) + + const hasMore = rows.length > limit + const page = rows.slice(0, limit) + + const data: V2WorkflowVersion[] = page.map((row) => ({ + id: row.id, + version: row.version, + name: row.name, + description: row.description, + isActive: row.isActive, + createdAt: row.createdAt.toISOString(), + deployedBy: row.deployedByName, + // The shared helper widens the operation-status pg enum to `string`. + latestOperationStatus: + row.latestOperationStatus as V2WorkflowVersion['latestOperationStatus'], + })) + + const nextCursor = + hasMore && data.length > 0 ? encodeCursor({ version: data[data.length - 1].version }) : null + + return v2CursorList(data, nextCursor, { rateLimit }) + }, +}) diff --git a/apps/sim/app/api/v2/workflows/folders/route.ts b/apps/sim/app/api/v2/workflows/folders/route.ts index bb791ec6dce..81fb20fa35a 100644 --- a/apps/sim/app/api/v2/workflows/folders/route.ts +++ b/apps/sim/app/api/v2/workflows/folders/route.ts @@ -1,61 +1,32 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateWorkflowFolderContract, v2DeleteWorkflowFolderContract, v2ListWorkflowFoldersContract, v2RelocateWorkflowFolderContract, } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createFolderAtPath, deleteFolderByPath, relocateFolderByPath, } from '@/lib/folders/orchestration' import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { resolveFolderPathId, toV2PathFolder, v2FolderPathMutationError, } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2WorkflowFoldersAPI') +import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const rateLimit = await checkRateLimit(request, 'workflows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListWorkflowFoldersContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListWorkflowFoldersContract, + rateLimitEndpoint: 'workflows', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, parentPath, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -75,112 +46,80 @@ export const GET = withRouteHandler(async (request: NextRequest) => { null, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error listing workflow folders`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) -export const POST = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'workflows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2CreateWorkflowFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) +export const POST = withPublicApiRouteHandler({ + contract: v2CreateWorkflowFolderContract, + rateLimitEndpoint: 'workflows', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path } = input.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) - const result = await createFolderAtPath({ resourceType: 'workflow', workspaceId, userId, path }) - if (!result.success || !result.folder || !result.path) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') - return v2Data({ folder: toV2PathFolder(result.folder, index, true) }, { rateLimit, status: 201 }) + const result = await createFolderAtPath({ resourceType: 'workflow', workspaceId, userId, path }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') + return v2Data( + { folder: toV2PathFolder(result.folder, index, true) }, + { rateLimit, status: 201 } + ) + }, }) -export const PATCH = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'workflows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2RelocateWorkflowFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path, destinationPath } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) +export const PATCH = withPublicApiRouteHandler({ + contract: v2RelocateWorkflowFolderContract, + rateLimitEndpoint: 'workflows', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path, destinationPath } = input.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) - const result = await relocateFolderByPath({ - resourceType: 'workflow', - workspaceId, - userId, - path, - destinationPath, - }) - if (!result.success || !result.folder || !result.path) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') - return v2Data({ folder: toV2PathFolder(result.folder, index, true) }, { rateLimit }) + const result = await relocateFolderByPath({ + resourceType: 'workflow', + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') + return v2Data({ folder: toV2PathFolder(result.folder, index, true) }, { rateLimit }) + }, }) -export const DELETE = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'workflows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2DeleteWorkflowFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path, recursive } = parsed.data.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteWorkflowFolderContract, + rateLimitEndpoint: 'workflows', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path, recursive } = input.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) - const result = await deleteFolderByPath({ - resourceType: 'workflow', - workspaceId, - userId, - path, - recursive, - }) - if (!result.success || !result.deletedItems) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') - } - return v2Data( - { + const result = await deleteFolderByPath({ + resourceType: 'workflow', + workspaceId, + userId, path, - deleted: true as const, - deletedItems: { - folders: result.deletedItems.folders, - workflows: result.deletedItems.workflows ?? 0, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + return v2Data( + { + path, + deleted: true as const, + deletedItems: { + folders: result.deletedItems.folders, + workflows: result.deletedItems.workflows ?? 0, + }, }, - }, - { rateLimit } - ) + { rateLimit } + ) + }, }) diff --git a/apps/sim/app/api/v2/workflows/import/route.ts b/apps/sim/app/api/v2/workflows/import/route.ts index e70da75d915..8ddd288d6a3 100644 --- a/apps/sim/app/api/v2/workflows/import/route.ts +++ b/apps/sim/app/api/v2/workflows/import/route.ts @@ -1,23 +1,16 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { v2ImportWorkflowContract } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { importWorkflowIntoWorkspace, MAX_IMPORT_BODY_BYTES, } from '@/lib/workflows/operations/import-workflow' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { type V2ErrorCode, v2Data, v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' @@ -42,30 +35,14 @@ const ERROR_CODE_BY_STATUS: Record = { * {@link importWorkflowIntoWorkspace} pipeline does the heavy lifting; this * route authenticates and renders the v2 envelope. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'workflow-import') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ImportWorkflowContract, - request, - {}, - { - maxBodyBytes: MAX_IMPORT_BODY_BYTES, - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, folderPath, name, description } = parsed.data.body +export const POST = withPublicApiRouteHandler({ + contract: v2ImportWorkflowContract, + rateLimitEndpoint: 'workflow-import', + parseOptions: { + maxBodyBytes: MAX_IMPORT_BODY_BYTES, + }, + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + const { workspaceId, folderPath, name, description } = input.body logger.info(`[${requestId}] Importing workflow into workspace ${workspaceId}`, { userId, @@ -87,7 +64,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { folderId: resolution.folderId ?? undefined, name, description, - workflow: parsed.data.body.workflow, + workflow: input.body.workflow, userId, requestId, }) @@ -111,10 +88,5 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, { rateLimit, status: 201 } ) - } catch (error) { - logger.error(`[${requestId}] Workflow import error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index 0d77ccc618d..3e0a42c8171 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -1,25 +1,19 @@ -import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { type V2WorkflowListItem, v2CreateWorkflowContract, v2ListWorkflowsContract, } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { performCreateWorkflow } from '@/lib/workflows/orchestration' import { InvalidWorkflowListCursorError, listWorkspaceWorkflows } from '@/lib/workflows/queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathId, resolveFolderPathIdentity, } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { cursorSortKey, decodeSortedCursor, @@ -29,39 +23,17 @@ import { v2Data, v2Error, v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2WorkflowsAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'workflows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListWorkflowsContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const params = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListWorkflowsContract, + rateLimitEndpoint: 'workflows', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const params = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -115,83 +87,64 @@ export const GET = withRouteHandler(async (request: NextRequest) => { })) return v2CursorList(formatted, nextCursor, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Workflows fetch error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** POST /api/v2/workflows — Create an empty workflow in a workspace. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'workflows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! +export const POST = withPublicApiRouteHandler({ + contract: v2CreateWorkflowContract, + rateLimitEndpoint: 'workflows', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { workspaceId, name, description, folderPath } = input.body - const gate = await v2ApiGateError(userId) - if (gate) return gate + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) - const parsed = await parseRequest( - v2CreateWorkflowContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response + const resolution = await resolveFolderPathIdentity({ + workspaceId, + resourceType: 'workflow', + path: folderPath ?? '/', + }) + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + + await assertFolderMutable(resolution.folderId) + const result = await performCreateWorkflow({ + userId, + workspaceId, + name, + description, + folderId: resolution.folderId, + requestId, + }) - const { workspaceId, name, description, folderPath } = parsed.data.body + if (!result.success || !result.workflow) { + return v2ErrorForOrchestration( + result.errorCode, + result.error ?? 'Failed to create workflow' + ) + } - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) + const created = result.workflow + const item: V2WorkflowListItem = { + id: created.id, + name: created.name, + description: created.description ?? null, + folderPath: folderPathForId(resolution.index, created.folderId), + workspaceId: created.workspaceId, + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + createdAt: created.createdAt.toISOString(), + updatedAt: created.updatedAt.toISOString(), + } - const resolution = await resolveFolderPathIdentity({ - workspaceId, - resourceType: 'workflow', - path: folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - - await assertFolderMutable(resolution.folderId) - const result = await performCreateWorkflow({ - userId, - workspaceId, - name, - description, - folderId: resolution.folderId, - requestId, - }) - - if (!result.success || !result.workflow) { - return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to create workflow') - } + return v2Data(item, { rateLimit, status: 201 }) + } catch (error) { + if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) - const created = result.workflow - const item: V2WorkflowListItem = { - id: created.id, - name: created.name, - description: created.description ?? null, - folderPath: folderPathForId(resolution.index, created.folderId), - workspaceId: created.workspaceId, - isDeployed: false, - deployedAt: null, - runCount: 0, - lastRunAt: null, - createdAt: created.createdAt.toISOString(), - updatedAt: created.updatedAt.toISOString(), + throw error } - - return v2Data(item, { rateLimit, status: 201 }) - } catch (error) { - if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) - - logger.error(`[${requestId}] Workflow create error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) 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 856aecf5992..ba109669a1a 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts @@ -1,78 +1,50 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2ListWorkspaceMembersContract, v2WorkspaceMemberCursorSchema, } from '@/lib/api/contracts/v2/workspaces' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { queryPublicWorkspaceMembers } from '@/lib/workspaces/public-queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { decodeCursor, encodeCursor, v2CursorList, v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2WorkspaceMembersAPI') - -interface WorkspaceMembersRouteParams { - params: Promise<{ workspaceId: string }> -} - /** GET /api/v2/workspaces/[workspaceId]/members — Effective member roster. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: WorkspaceMembersRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'workspace-members') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2ListWorkspaceMembersContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { workspaceId } = parsed.data.params - const { cursor, limit } = parsed.data.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const decoded = cursor - ? v2WorkspaceMemberCursorSchema.safeParse(decodeCursor(cursor)) - : undefined - if (decoded && !decoded.success) return v2Error('BAD_REQUEST', 'Invalid cursor') - - const page = await queryPublicWorkspaceMembers(workspaceId, { - limit, - afterEmail: decoded?.data.email, - }) - if (!page) return v2Error('NOT_FOUND', 'Workspace not found') - - return v2CursorList( - page.members.map((member) => ({ - email: member.email, - name: member.name, - image: member.image, - role: member.role, - isExternal: member.isExternal, - joinedAt: member.joinedAt.toISOString(), - })), - page.nextEmail ? encodeCursor({ email: page.nextEmail }) : null, - { rateLimit } - ) - } catch (error) { - logger.error('Failed to list workspace members', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) +export const GET = withPublicApiRouteHandler({ + contract: v2ListWorkspaceMembersContract, + rateLimitEndpoint: 'workspace-members', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId } = input.params + const { cursor, limit } = input.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const decoded = cursor + ? v2WorkspaceMemberCursorSchema.safeParse(decodeCursor(cursor)) + : undefined + if (decoded && !decoded.success) return v2Error('BAD_REQUEST', 'Invalid cursor') + + const page = await queryPublicWorkspaceMembers(workspaceId, { + limit, + afterEmail: decoded?.data.email, + }) + if (!page) return v2Error('NOT_FOUND', 'Workspace not found') + + return v2CursorList( + page.members.map((member) => ({ + email: member.email, + name: member.name, + image: member.image, + role: member.role, + isExternal: member.isExternal, + joinedAt: member.joinedAt.toISOString(), + })), + page.nextEmail ? encodeCursor({ email: page.nextEmail }) : null, + { rateLimit } + ) + }, +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts index 6776b83b6f7..54272638421 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts @@ -1,42 +1,15 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2GetWorkspaceContract } from '@/lib/api/contracts/v2/workspaces' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getPublicWorkspaceDetail } from '@/lib/workspaces/public-queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2WorkspaceDetailAPI') - -interface WorkspaceRouteParams { - params: Promise<{ workspaceId: string }> -} +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' /** GET /api/v2/workspaces/[workspaceId] — Public workspace metadata. */ -export const GET = withRouteHandler(async (request: NextRequest, context: WorkspaceRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'workspaces') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetWorkspaceContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { workspaceId } = parsed.data.params +export const GET = withPublicApiRouteHandler({ + contract: v2GetWorkspaceContract, + rateLimitEndpoint: 'workspaces', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId } = input.params const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -51,8 +24,5 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Worksp }, { rateLimit } ) - } catch (error) { - logger.error('Failed to get workspace', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/lib/api/server/validation.ts b/apps/sim/lib/api/server/validation.ts index 6d5799de4ae..06b433c7652 100644 --- a/apps/sim/lib/api/server/validation.ts +++ b/apps/sim/lib/api/server/validation.ts @@ -51,6 +51,7 @@ interface RouteContextWithParams { export interface ParseRequestOptions { validationErrorResponse?: (error: z.ZodError) => NextResponse invalidJsonResponse?: () => NextResponse + payloadTooLargeResponse?: () => NextResponse invalidJson?: 'response' | 'throw' /** * Maximum number of bytes to read for the JSON body before rejecting with a @@ -245,9 +246,13 @@ export async function parseRequest( if (shouldReadJsonBody(contract)) { const parsedBody = await parseJsonBody(request, options?.invalidJson, options?.maxBodyBytes) if (!parsedBody.success) { - return options?.invalidJsonResponse && parsedBody.reason === 'invalid_json' - ? { success: false, response: options.invalidJsonResponse() } - : parsedBody + if (options?.invalidJsonResponse && parsedBody.reason === 'invalid_json') { + return { success: false, response: options.invalidJsonResponse() } + } + if (options?.payloadTooLargeResponse && parsedBody.reason === 'too_large') { + return { success: false, response: options.payloadTooLargeResponse() } + } + return parsedBody } body = parsedBody.data } diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index 2c4bc973ce2..32ccc7fc788 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -13,6 +13,15 @@ type RouteHandler = ( context: T ) => Promise | NextResponse | Response +interface RouteHandlerErrorContext { + error: unknown + requestId: string +} + +interface RouteHandlerOptions { + unhandledErrorResponse?: (context: RouteHandlerErrorContext) => NextResponse | Response +} + /** * Reads a numeric `statusCode` (4xx or 5xx) off an `HttpError` so typed domain * errors (e.g. `WorkspaceAccessDeniedError`, `InvalidFieldError`) map to the @@ -63,10 +72,14 @@ function applyResponseHeaders( * logger in the request lifecycle automatically includes it * - Logs all 4xx and 5xx responses with method, path, status, duration * - Catches unhandled errors, logs them, and returns a 500 with the request ID + * - Supports a route-family-specific unhandled-error response envelope * - Attaches `x-request-id`, plus the rate-limit headers when the route * recorded a snapshot for the request */ -export function withRouteHandler(handler: RouteHandler): RouteHandler { +export function withRouteHandler( + handler: RouteHandler, + options: RouteHandlerOptions = {} +): RouteHandler { return async (request: NextRequest, context: T) => { const requestId = generateRequestId() const startTime = Date.now() @@ -81,6 +94,13 @@ export function withRouteHandler(handler: RouteHandler): RouteHandler { } catch (error) { const duration = Date.now() - startTime const message = getErrorMessage(error, 'Unknown error') + if (options.unhandledErrorResponse) { + logger.error('Unhandled route error', { duration, error: message }) + response = options.unhandledErrorResponse({ error, requestId }) + applyResponseHeaders(response, request, requestId) + return response + } + const typedStatus = readTypedErrorStatus(error) if (typedStatus !== undefined) { if (typedStatus >= 500) { diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 001621c4f3a..156960f1a5a 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -147,6 +147,9 @@ const RAW_JSON_BASELINE_ROUTES = new Set([ ]) const CONTRACT_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/contracts(?:\/[^'"]*)?['"]/ +const PUBLIC_API_ROUTE_HANDLER_IMPORT_PATTERN = + /\bimport\s*\{[^}]*\bwithPublicApiRouteHandler\b[^}]*\}\s*from\s*['"]@\/app\/api\/public-api-route-handler['"]/ +const PUBLIC_API_ROUTE_HANDLER_USAGE_PATTERN = /\bwithPublicApiRouteHandler\s*\(/ const SERVER_VALIDATION_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/server(?:\/validation)?['"]/ const SCHEMA_PARSE_PATTERN = /\b\w+Schema\.(?:safeParse|parse)\(/ const CONTRACT_SERVER_HELPER_PATTERN = /\bparseToolRequest\(/ @@ -717,6 +720,13 @@ function hasZodUsage(relativePath: string, content: string): boolean { ) { return true } + if ( + CONTRACT_IMPORT_PATTERN.test(content) && + PUBLIC_API_ROUTE_HANDLER_IMPORT_PATTERN.test(content) && + PUBLIC_API_ROUTE_HANDLER_USAGE_PATTERN.test(content) + ) { + return true + } if ( CONTRACT_IMPORT_PATTERN.test(content) && (SCHEMA_PARSE_PATTERN.test(content) || CONTRACT_MAP_PARSE_PATTERN.test(content)) From dc6f7fbf33de7e7be0436c0f94e8bcf7051eb4f7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 7 Aug 2026 20:25:07 -0700 Subject: [PATCH 091/159] feat(cli): sync v2 API and personal login defaults --- apps/sim/app/cli/auth/cli-auth-view.test.tsx | 19 +- apps/sim/app/cli/auth/cli-auth-view.tsx | 44 +- packages/sim-cli/README.md | 24 +- packages/sim-cli/src/contract/commands.ts | 90 ++- packages/sim-cli/src/generated/v2-api.ts | 566 +++++++++---------- packages/sim-cli/src/http/client.test.ts | 4 +- packages/sim-cli/src/output/trace.ts | 2 +- packages/sim-cli/src/runtime/build.test.ts | 132 +++-- packages/sim-cli/src/runtime/build.ts | 2 + 9 files changed, 460 insertions(+), 423 deletions(-) diff --git a/apps/sim/app/cli/auth/cli-auth-view.test.tsx b/apps/sim/app/cli/auth/cli-auth-view.test.tsx index 01d908daf05..c2f4e9b6005 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.test.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.test.tsx @@ -81,22 +81,22 @@ describe('CliAuthView workspace loading', () => { }) it('blocks Connect until the workspace list resolves', () => { - // The regression: while pending, the picker falls back to the personal - // option, so an early click approved a personal key when the same click a - // moment later would have bound the key to the user's workspace. + // The regression: while pending, the picker falls back to no default, so an + // early click saved no workspace when the same click a moment later would + // have saved the user's last active workspace. mockUseWorkspaces.mockReturnValue({ isPending: true, isError: false, data: undefined }) render() expect(connectButton().disabled).toBe(true) expect(container.textContent).toContain('Loading workspaces') - expect(container.textContent).not.toContain('No workspace (personal key)') + expect(container.textContent).not.toContain('No default workspace') }) - it('does not present the personal-key wording as the answer while loading', () => { + it('does not present a workspace choice as final while loading', () => { mockUseWorkspaces.mockReturnValue({ isPending: true, isError: false, data: undefined }) render() - expect(container.textContent).toContain('Checking which workspaces') + expect(container.textContent).toContain('Loading your workspace options') expect(container.textContent).not.toContain('Issues a personal key') }) @@ -106,10 +106,11 @@ describe('CliAuthView workspace loading', () => { expect(connectButton().disabled).toBe(false) expect(container.textContent).toContain('Acme') - expect(container.textContent).toContain('only reach Acme') + expect(container.textContent).toContain('personal key') + expect(container.textContent).toContain('makes Acme the CLI default') }) - it('binds the key to the workspace when the approver is an admin', () => { + it('issues a personal key even when the approver is a workspace admin', () => { mockUseWorkspaces.mockReturnValue(LOADED) render() act(() => { @@ -120,7 +121,7 @@ describe('CliAuthView workspace loading', () => { expect.objectContaining({ scope: 'platform', workspaceId: 'ws_admin', - bindKeyToWorkspace: true, + bindKeyToWorkspace: false, }), expect.anything() ) diff --git a/apps/sim/app/cli/auth/cli-auth-view.tsx b/apps/sim/app/cli/auth/cli-auth-view.tsx index 080b872f297..f7865af95da 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.tsx @@ -11,8 +11,8 @@ import { cliAuthParsers } from '@/app/cli/auth/search-params' import { useApproveCliAuth } from '@/hooks/queries/cli-auth' import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace' -/** Sentinel for the "not bound to a workspace" row; an empty string reads as unselected. */ -const PERSONAL_VALUE = '__personal__' +/** Sentinel for the "no default workspace" row; an empty string reads as unselected. */ +const NO_DEFAULT_WORKSPACE_VALUE = '__no_default_workspace__' /** * The signed-in half of the CLI key handoff: a consent card that records the @@ -40,7 +40,7 @@ export function CliAuthView() { label: workspace.name, value: workspace.id, })) - return [...rows, { label: 'No workspace (personal key)', value: PERSONAL_VALUE }] + return [...rows, { label: 'No default workspace', value: NO_DEFAULT_WORKSPACE_VALUE }] }, [workspaces.data]) if (!resolution.valid) { @@ -63,11 +63,10 @@ export function CliAuthView() { * Approval must wait for the workspace list. * * Until it arrives there is no selection to show, and the fallback would read - * as "No workspace (personal key)" — a real answer, not a pending one. Leaving - * Connect live through that window let a fast click approve a personal key - * with no default workspace, when a moment later the same click would have - * bound the key to the user's workspace. Blocking is the only way the card - * can promise what it is about to do. + * as "No default workspace" — a real answer, not a pending one. Leaving + * Connect live through that window let a fast click save no default when a + * moment later the same click would have saved the user's workspace. Blocking + * is the only way the card can promise what it is about to configure. */ const loadingWorkspaces = isPlatform && workspaces.isPending @@ -88,11 +87,6 @@ export function CliAuthView() { const workspaceId = selected ?? suggested ?? workspaces.data?.lastActiveWorkspaceId ?? null const chosen = workspaces.data?.workspaces.find((w) => w.id === workspaceId) - // Only an admin can bind a key to a workspace. Anything less still gets a - // usable credential — a personal key — but the card says which one before the - // click rather than after, so nothing unexpected lands in the config file. - const bindsToWorkspace = chosen?.permissions === 'admin' - return (
Default workspace

{loadingWorkspaces - ? 'Checking which workspaces you can issue a key for…' + ? 'Loading your workspace options…' : workspaces.isError ? 'Could not load your workspaces. Connecting still works and issues a personal key; reload to pick a default workspace.' - : bindsToWorkspace - ? `Issues a key that can only reach ${chosen.name}.` - : chosen - ? 'Issues a personal key tied to your account, defaulting to this workspace. Workspace-scoped keys need admin.' - : // No workspace picked, so none is sent and none becomes the - // profile default — promising one here would describe a - // grant that Connect is not about to make. - 'Issues a personal key tied to your account, with no default workspace.'} + : chosen + ? `Issues a personal key tied to your account and makes ${chosen.name} the CLI default.` + : // No workspace picked, so none is sent and none becomes the + // profile default — promising one here would describe a + // grant that Connect is not about to make. + 'Issues a personal key tied to your account, with no default workspace.'}

)} @@ -151,10 +143,10 @@ export function CliAuthView() { request: request.request, challenge: request.challenge, scope: request.scope, - // The picked workspace travels either way — it is the terminal's - // default. Only `bindKeyToWorkspace` narrows the key itself. + // The picked workspace is only the terminal's default. Login + // always mints a personal key so the profile can switch workspaces. ...(isPlatform && chosen ? { workspaceId: chosen.id } : {}), - bindKeyToWorkspace: isPlatform && bindsToWorkspace, + bindKeyToWorkspace: false, }, { onSuccess: () => router.push('/cli/auth/done') } ) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index fe265772d95..55625cea447 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -123,13 +123,13 @@ sim workflows update [--name ] [--description ] [--folder sim workflows deploy|undeploy|rollback sim workflows run [--input ] [--select-output …] [--async] -sim workflows executions list --workflow [--status ] -sim workflows executions get --workflow [--include-output] -sim workflows executions cancel --workflow -sim workflows executions resume --workflow --context [--input ] +sim workflows runs list --workflow [--status ] +sim workflows runs get --workflow [--include-output] +sim workflows runs cancel --workflow +sim workflows runs resume --workflow --context [--input ] sim logs list [--level error] [--workflow …] [--trigger …] [--start-date ] -sim logs get +sim logs get sim audit-logs list --organization [--all-workspaces] sim audit-logs get --organization @@ -178,17 +178,17 @@ The `sim-chat` billing source combines Copilot and workspace chat usage. Organization audit logs require a personal API key. Commands with `--all-workspaces` otherwise default to the workspace in the active profile. -`workflows executions get` is the lightweight status and polling resource. -`--workflow` names the parent resource, while the execution ID remains positional. -For a paused execution, its status includes the context ID needed by `resume`. +`workflows runs get` is the lightweight status and polling resource. +`--workflow` names the parent resource, while the run ID remains positional. +For a paused run, its status includes the context ID needed by `resume`. `logs get` is the full diagnostic resource. It keeps the default human output concise; add `--trace` for the expanded recursive trace with span inputs, outputs, errors, timing, and cost. JSON and YAML retain the complete structured response: ```bash -sim logs get --trace -sim logs get --output json | jq '.traceSpans' +sim logs get --trace +sim logs get --output json | jq '.traceSpans' sim logs list --include-trace-spans --output json ``` @@ -272,8 +272,8 @@ parsing. sim configure --set-output json # for this profile, from now on sim configure --set-output text --profile scripts # a profile dedicated to scripting -sim --output json logs list --level error | jq -r '.[].executionId' -sim logs list --level error --output json | jq -r '.[].executionId' +sim --output json logs list --level error | jq -r '.[].runId' +sim logs list --level error --output json | jq -r '.[].runId' SIM_OUTPUT=yaml sim logs list --level error > logs.yaml SIM_OUTPUT=text sim files list | while IFS=$'\t' read -r id name size type uploaded; do diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index e35b8f7f470..31fab68ccb4 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -25,7 +25,7 @@ const KNOWLEDGE_DOCUMENT_SCOPE = { describe: 'Knowledge base ID', }, } as const -const WORKFLOW_EXECUTION_SCOPE = { +const WORKFLOW_RUN_SCOPE = { id: { name: 'workflow', placeholder: 'workflowId', @@ -92,7 +92,7 @@ export const CLI_CONTRACT: CliContract = { { header: 'source' }, { header: 'workflow', path: 'workflow.name' }, { header: 'credits', path: 'creditCost' }, - { header: 'execution', path: 'executionId' }, + { header: 'run', path: 'runId' }, { header: 'id' }, ], }, @@ -124,6 +124,10 @@ export const CLI_CONTRACT: CliContract = { command: 'workflows undeploy', describe: 'Take a workflow out of deployment', }, + setSecret: { + command: 'secrets set', + describe: 'Create or replace a named secret', + }, // ─── Destructive single-resource operations ─────────────────────────────── deleteTable: { confirm: 'This deletes the table and all of its rows.' }, @@ -144,8 +148,8 @@ export const CLI_CONTRACT: CliContract = { deleteMcpServer: { confirm: 'This removes the MCP server and the tools it provides.', }, - deleteCredential: { - confirm: 'This deletes the credential; anything authenticating with it stops working.', + deleteSecret: { + confirm: 'This deletes the secret; anything using it may stop working.', }, deleteWorkflow: { confirm: 'This deletes the workflow and its run history.' }, deleteTableView: { confirm: 'This deletes the saved view and its filters.' }, @@ -184,14 +188,14 @@ export const CLI_CONTRACT: CliContract = { { header: 'workflow', path: 'workflow.name' }, { header: 'duration', path: 'totalDurationMs', format: 'duration' }, { header: 'cost', path: 'cost.total', format: 'cost' }, - { header: 'execution', path: 'executionId' }, + { header: 'run', path: 'runId' }, ], }, getLog: { - describe: 'Show execution diagnostics', + describe: 'Show run diagnostics', expandedTrace: true, fields: [ - { header: 'execution', path: 'executionId' }, + { header: 'run', path: 'runId' }, { header: 'workflow', path: 'workflow.name' }, { header: 'status' }, { header: 'level' }, @@ -326,6 +330,7 @@ export const CLI_CONTRACT: CliContract = { { header: 'folder', path: 'folderPath' }, { header: 'size', format: 'bytes' }, { header: 'type' }, + { header: 'uploaded by', path: 'uploadedByEmail' }, { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, ], }, @@ -393,6 +398,35 @@ export const CLI_CONTRACT: CliContract = { { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ], }, + listSecrets: { + columns: [ + { header: 'name' }, + { header: 'scope' }, + { header: 'role' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + getWorkspace: { + fields: [ + { header: 'id' }, + { header: 'name' }, + { header: 'mode' }, + { header: 'members', path: 'memberCount' }, + { header: 'created', path: 'createdAt', format: 'timestamp' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listWorkspaceMembers: { + command: 'workspaces members', + describe: 'List workspace members', + columns: [ + { header: 'email' }, + { header: 'name' }, + { header: 'role' }, + { header: 'external', path: 'isExternal', format: 'bool' }, + { header: 'joined', path: 'joinedAt', format: 'timestamp' }, + ], + }, listAuditLogs: { allWorkspaces: true, @@ -645,7 +679,7 @@ export const CLI_CONTRACT: CliContract = { document: true, }, - // ─── Execution ──────────────────────────────────────────────────────────── + // ─── Runs ───────────────────────────────────────────────────────────────── // The derived names land badly here: `/execute` and `/cancel` are verbs in // the path, but neither is in the action list, so POST would derive // `workflows execute create` and `workflows cancel create`. @@ -653,7 +687,7 @@ export const CLI_CONTRACT: CliContract = { command: 'workflows run', describe: 'Run a deployed workflow', flags: { - async: { boolean: true, describe: 'Queue the execution and return immediately' }, + async: { boolean: true, describe: 'Queue the run and return immediately' }, input: { json: true, describe: 'Trigger input as JSON' }, selectedOutputs: { name: 'select-output', @@ -670,10 +704,10 @@ export const CLI_CONTRACT: CliContract = { includeToolCalls: { omit: true }, }, }, - getWorkflowExecution: { - command: 'workflows executions get', - pathFlags: WORKFLOW_EXECUTION_SCOPE, - describe: 'Show execution status (requested outputs are included in JSON or YAML output)', + getWorkflowRun: { + command: 'workflows runs get', + pathFlags: WORKFLOW_RUN_SCOPE, + describe: 'Show run status (requested outputs are included in JSON or YAML output)', flags: { includeOutput: { boolean: true, @@ -686,7 +720,7 @@ export const CLI_CONTRACT: CliContract = { }, }, fields: [ - { header: 'execution', path: 'executionId' }, + { header: 'run', path: 'runId' }, { header: 'workflow', path: 'workflowId' }, { header: 'status' }, { header: 'trigger' }, @@ -703,34 +737,34 @@ export const CLI_CONTRACT: CliContract = { { header: 'error', path: 'error.message' }, ], }, - listWorkflowExecutions: { - command: 'workflows executions list', - pathFlags: WORKFLOW_EXECUTION_SCOPE, - describe: 'List executions for a workflow', + listWorkflowRuns: { + command: 'workflows runs list', + pathFlags: WORKFLOW_RUN_SCOPE, + describe: 'List runs for a workflow', columns: [ { header: 'started', path: 'startedAt', format: 'timestamp' }, { header: 'status' }, { header: 'trigger' }, { header: 'duration', path: 'durationMs', format: 'duration' }, { header: 'cost', path: 'cost.total', format: 'cost' }, - { header: 'execution', path: 'executionId' }, + { header: 'run', path: 'runId' }, ], }, - cancelWorkflowExecution: { - command: 'workflows executions cancel', - pathFlags: WORKFLOW_EXECUTION_SCOPE, - describe: 'Cancel a running execution', + cancelWorkflowRun: { + command: 'workflows runs cancel', + pathFlags: WORKFLOW_RUN_SCOPE, + describe: 'Cancel a running workflow run', // Not `confirm`-gated: cancelling is recoverable (re-run it), and the // whole point is to stop something that is already going wrong. }, resumeWorkflow: { - command: 'workflows executions resume', - pathFlags: WORKFLOW_EXECUTION_SCOPE, - describe: 'Resume a paused execution (output is included in JSON or YAML output)', + command: 'workflows runs resume', + pathFlags: WORKFLOW_RUN_SCOPE, + describe: 'Resume a paused run (output is included in JSON or YAML output)', flags: { contextId: { name: 'context', - describe: 'Pause context ID returned by execution status', + describe: 'Pause context ID returned by run status', }, input: { json: true, @@ -738,7 +772,7 @@ export const CLI_CONTRACT: CliContract = { }, }, fields: [ - { header: 'execution', path: 'executionId' }, + { header: 'run', path: 'runId' }, { header: 'workflow', path: 'workflowId' }, { header: 'status' }, { header: 'status URL', path: 'statusUrl' }, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 039261dceec..105429fb9d1 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -47,7 +47,7 @@ export type AbortFileUploadResponse = { type: string key: string folderPath: string - uploadedBy: string + uploadedByEmail: string uploadedAt: string updatedAt: string } | null @@ -329,16 +329,16 @@ export type CancelTableRunsResponse = { } } -/** `POST /api/v2/workflows/[id]/executions/[executionId]/cancel` */ -export type CancelWorkflowExecutionParams = { +/** `POST /api/v2/workflows/[id]/runs/[runId]/cancel` */ +export type CancelWorkflowRunParams = { id: string - executionId: string + runId: string } -export type CancelWorkflowExecutionResponse = { +export type CancelWorkflowRunResponse = { data: { success: boolean - executionId: string + runId: string redisAvailable: boolean durablyRecorded: boolean locallyAborted: boolean @@ -389,7 +389,7 @@ export type CompleteFileUploadResponse = { type: string key: string folderPath: string - uploadedBy: string + uploadedByEmail: string uploadedAt: string updatedAt: string } | null @@ -493,43 +493,6 @@ export type CompleteTableImportResponse = { } } -/** `POST /api/v2/credentials` */ -export type CreateCredentialBody = { - workspaceId: string - type: 'env_workspace' | 'env_personal' | 'service_account' - displayName?: string - description?: string - providerId?: string - envKey?: string - serviceAccountJson?: string - signingSecret?: string - botToken?: string - apiToken?: string - domain?: string - clientId?: string - clientSecret?: string - orgId?: string - dataCenter?: string -} - -export type CreateCredentialResponse = { - data: { - credential: { - id: string - type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' - displayName: string - description: string | null - providerId: string | null - accountId: string | null - envKey: string | null - hasServiceAccountKey: boolean - role: 'admin' | 'member' - createdAt: string - updatedAt: string - } - } -} - /** `POST /api/v2/custom-tools` */ export type CreateCustomToolBody = { workspaceId: string @@ -591,7 +554,7 @@ export type CreateFileResponse = { type: string key: string folderPath: string - uploadedBy: string + uploadedByEmail: string uploadedAt: string updatedAt: string } @@ -649,7 +612,7 @@ export type CreateFileUploadResponse = { type: string key: string folderPath: string - uploadedBy: string + uploadedByEmail: string uploadedAt: string updatedAt: string } | null @@ -1280,7 +1243,7 @@ export type CreateTableViewResponse = { }> | null } isDefault: boolean - createdBy: string | null + createdByEmail: string | null createdAt: string updatedAt: string } @@ -1330,22 +1293,6 @@ export type CreateWorkflowFolderResponse = { } } -/** `DELETE /api/v2/credentials/[id]` */ -export type DeleteCredentialParams = { - id: string -} - -export type DeleteCredentialQuery = { - workspaceId: string -} - -export type DeleteCredentialResponse = { - data: { - id: string - deleted: true - } -} - /** `DELETE /api/v2/custom-tools/[id]` */ export type DeleteCustomToolParams = { id: string @@ -1463,6 +1410,24 @@ export type DeleteMcpServerResponse = { } } +/** `DELETE /api/v2/secrets/[name]` */ +export type DeleteSecretParams = { + name: string +} + +export type DeleteSecretQuery = { + workspaceId: string + scope: 'workspace' | 'personal' +} + +export type DeleteSecretResponse = { + data: { + name: string + scope: 'workspace' | 'personal' + deleted: true + } +} + /** `DELETE /api/v2/skills/[id]` */ export type DeleteSkillParams = { id: string @@ -1726,9 +1691,13 @@ export type ExecuteWorkflowBody = { base64MaxBytes?: number } +export type ExecuteWorkflowHeaders = { + 'x-run-id'?: string +} + export type ExecuteWorkflowResponse = { data: { - executionId: string + runId: string workflowId: string status: 'completed' | 'failed' | 'paused' | 'cancelled' output: unknown @@ -1916,7 +1885,6 @@ export type GetAuditLogResponse = { data: { id: string workspaceId: string | null - actorId: string | null actorName: string | null actorEmail: string | null action: string @@ -1951,33 +1919,6 @@ export type GetBillingStatusResponse = { } } -/** `GET /api/v2/credentials/[id]` */ -export type GetCredentialParams = { - id: string -} - -export type GetCredentialQuery = { - workspaceId: string -} - -export type GetCredentialResponse = { - data: { - credential: { - id: string - type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' - displayName: string - description: string | null - providerId: string | null - accountId: string | null - envKey: string | null - hasServiceAccountKey: boolean - role: 'admin' | 'member' - createdAt: string - updatedAt: string - } - } -} - /** `GET /api/v2/custom-tools/[id]` */ export type GetCustomToolParams = { id: string @@ -2028,7 +1969,7 @@ export type GetFileResponse = { type: string key: string folderPath: string - uploadedBy: string + uploadedByEmail: string uploadedAt: string updatedAt: string } @@ -2132,9 +2073,9 @@ export type GetKnowledgeDocumentResponse = { } } -/** `GET /api/v2/logs/[executionId]` */ +/** `GET /api/v2/logs/[runId]` */ export type GetLogParams = { - executionId: string + runId: string } type GetLogResponseRef0 = { @@ -2181,7 +2122,7 @@ type GetLogResponseRef0 = { export type GetLogResponse = { data: { - executionId: string + runId: string workflowId: string | null deploymentVersionId: string | null status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' @@ -2196,7 +2137,7 @@ export type GetLogResponse = { name: string description: string | null folderPath: string | null - userId: string | null + ownerEmail: string | null workspaceId: string | null createdAt: string | null updatedAt: string | null @@ -2442,7 +2383,7 @@ export type GetTableViewResponse = { }> | null } isDefault: boolean - createdBy: string | null + createdByEmail: string | null createdAt: string updatedAt: string } @@ -2476,20 +2417,20 @@ export type GetWorkflowResponse = { } } -/** `GET /api/v2/workflows/[id]/executions/[executionId]` */ -export type GetWorkflowExecutionParams = { +/** `GET /api/v2/workflows/[id]/runs/[runId]` */ +export type GetWorkflowRunParams = { id: string - executionId: string + runId: string } -export type GetWorkflowExecutionQuery = { +export type GetWorkflowRunQuery = { includeOutput?: 'true' | 'false' selectedOutputs?: string } -export type GetWorkflowExecutionResponse = { +export type GetWorkflowRunResponse = { data: { - executionId: string + runId: string workflowId: string status: 'queued' | 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused' trigger: string | null @@ -2503,7 +2444,6 @@ export type GetWorkflowExecutionResponse = { pauseKind: 'time' | 'human' | null blockedOnBlockId: string | null automaticResumeWaitingReason: string | null - pausedExecutionId: string pausePointCount: number resumedCount: number } | null @@ -2548,6 +2488,24 @@ export type GetWorkflowVersionResponse = { } } +/** `GET /api/v2/workspaces/[workspaceId]` */ +export type GetWorkspaceParams = { + workspaceId: string +} + +export type GetWorkspaceResponse = { + data: { + id: string + name: string + color: string + logoUrl: string | null + mode: 'personal' | 'organization' | 'grandfathered_shared' + memberCount: number + createdAt: string + updatedAt: string + } +} + /** `POST /api/v2/workflows/import` */ export type ImportWorkflowBody = { workspaceId: string @@ -2575,20 +2533,19 @@ export type ListAuditLogsQuery = { resourceType?: string resourceId?: string workspaceId?: string - actorId?: string startDate?: string endDate?: string includeDeparted?: 'true' | 'false' limit?: number cursor?: string organizationId: string + actorEmail?: string } export type ListAuditLogsResponse = { data: Array<{ id: string workspaceId: string | null - actorId: string | null actorName: string | null actorEmail: string | null action: string @@ -2641,7 +2598,7 @@ export type ListBillingLogsResponse = { id: string name: string | null } | null - executionId: string | null + runId: string | null creditCost: number }> nextCursor: string | null @@ -2650,7 +2607,7 @@ export type ListBillingLogsResponse = { /** `GET /api/v2/credentials` */ export type ListCredentialsQuery = { workspaceId: string - type?: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + type?: 'oauth' | 'service_account' providerId?: string search?: string sortBy?: 'displayName' | 'createdAt' | 'updatedAt' @@ -2660,12 +2617,11 @@ export type ListCredentialsQuery = { export type ListCredentialsResponse = { data: Array<{ id: string - type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + type: 'oauth' | 'service_account' displayName: string description: string | null providerId: string | null accountId: string | null - envKey: string | null hasServiceAccountKey: boolean role: 'admin' | 'member' createdAt: string @@ -2744,7 +2700,7 @@ export type ListFilesResponse = { type: string key: string folderPath: string - uploadedBy: string + uploadedByEmail: string uploadedAt: string updatedAt: string }> @@ -2856,7 +2812,6 @@ export type ListLogsQuery = { level?: 'info' | 'error' startDate?: string endDate?: string - executionId?: string minDurationMs?: number maxDurationMs?: number minCost?: number @@ -2868,6 +2823,7 @@ export type ListLogsQuery = { limit?: number cursor?: string order?: 'desc' | 'asc' + runId?: string folderPaths?: string } @@ -2915,7 +2871,7 @@ type ListLogsResponseRef0 = { export type ListLogsResponse = { data: Array<{ - executionId: string + runId: string workflowId: string | null deploymentVersionId: string | null status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' @@ -2974,6 +2930,26 @@ export type ListMcpServersResponse = { nextCursor: string | null } +/** `GET /api/v2/secrets` */ +export type ListSecretsQuery = { + workspaceId: string + scope?: 'workspace' | 'personal' + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export type ListSecretsResponse = { + data: Array<{ + name: string + scope: 'workspace' | 'personal' + role: 'admin' | 'member' + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + /** `GET /api/v2/skills` */ export type ListSkillsQuery = { workspaceId: string @@ -3115,44 +3091,13 @@ export type ListTableViewsResponse = { }> | null } isDefault: boolean - createdBy: string | null + createdByEmail: string | null createdAt: string updatedAt: string }> nextCursor: string | null } -/** `GET /api/v2/workflows/[id]/executions` */ -export type ListWorkflowExecutionsParams = { - id: string -} - -export type ListWorkflowExecutionsQuery = { - status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused' - trigger?: string - startDate?: string - endDate?: string - limit?: number - cursor?: string - order?: 'asc' | 'desc' -} - -export type ListWorkflowExecutionsResponse = { - data: Array<{ - executionId: string - workflowId: string - status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused' - trigger: string - startedAt: string - endedAt: string | null - durationMs: number | null - cost: { - total: number - } | null - }> - nextCursor: string | null -} - /** `GET /api/v2/workflows/folders` */ export type ListWorkflowFoldersQuery = { workspaceId: string @@ -3209,6 +3154,37 @@ export type ListWorkflowGroupsResponse = { nextCursor: string | null } +/** `GET /api/v2/workflows/[id]/runs` */ +export type ListWorkflowRunsParams = { + id: string +} + +export type ListWorkflowRunsQuery = { + status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused' + trigger?: string + startDate?: string + endDate?: string + limit?: number + cursor?: string + order?: 'asc' | 'desc' +} + +export type ListWorkflowRunsResponse = { + data: Array<{ + runId: string + workflowId: string + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused' + trigger: string + startedAt: string + endedAt: string | null + durationMs: number | null + cost: { + total: number + } | null + }> + nextCursor: string | null +} + /** `GET /api/v2/workflows` */ export type ListWorkflowsQuery = { workspaceId: string @@ -3262,6 +3238,28 @@ export type ListWorkflowVersionsResponse = { nextCursor: string | null } +/** `GET /api/v2/workspaces/[workspaceId]/members` */ +export type ListWorkspaceMembersParams = { + workspaceId: string +} + +export type ListWorkspaceMembersQuery = { + limit?: number + cursor?: string +} + +export type ListWorkspaceMembersResponse = { + data: Array<{ + email: string + name: string + image: string | null + role: 'admin' | 'write' | 'read' + isExternal: boolean + joinedAt: string + }> + nextCursor: string | null +} + /** `POST /api/v2/files/move` */ export type MoveFileItemsBody = { workspaceId: string @@ -3398,16 +3396,16 @@ export type RenameFileResponse = { type: string key: string folderPath: string - uploadedBy: string + uploadedByEmail: string uploadedAt: string updatedAt: string } } -/** `POST /api/v2/workflows/[id]/executions/[executionId]/resume` */ +/** `POST /api/v2/workflows/[id]/runs/[runId]/resume` */ export type ResumeWorkflowParams = { id: string - executionId: string + runId: string } export type ResumeWorkflowBody = { @@ -3418,7 +3416,7 @@ export type ResumeWorkflowBody = { export type ResumeWorkflowResponse = | { data: { - executionId: string + runId: string workflowId: string status: 'completed' | 'failed' | 'paused' | 'cancelled' output: unknown @@ -3444,7 +3442,7 @@ export type ResumeWorkflowResponse = } | { data: { - executionId: string + runId: string statusUrl: string queuePosition?: number } @@ -3565,6 +3563,29 @@ export type SearchKnowledgeResponse = { } } +/** `PUT /api/v2/secrets/[name]` */ +export type SetSecretParams = { + name: string +} + +export type SetSecretBody = { + workspaceId: string + scope: 'workspace' | 'personal' + value: string +} + +export type SetSecretResponse = { + data: { + secret: { + name: string + scope: 'workspace' | 'personal' + role: 'admin' | 'member' + createdAt: string + updatedAt: string + } + } +} + /** `GET /api/v2/tables/exports/[exportId]/download` */ export type TableExportDownloadParams = { exportId: string @@ -3621,44 +3642,6 @@ export type UndeployWorkflowResponse = { } } -/** `PATCH /api/v2/credentials/[id]` */ -export type UpdateCredentialParams = { - id: string -} - -export type UpdateCredentialBody = { - workspaceId: string - displayName?: string - description?: string | null - serviceAccountJson?: string - signingSecret?: string - botToken?: string - apiToken?: string - domain?: string - clientId?: string - clientSecret?: string - orgId?: string - dataCenter?: string -} - -export type UpdateCredentialResponse = { - data: { - credential: { - id: string - type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' - displayName: string - description: string | null - providerId: string | null - accountId: string | null - envKey: string | null - hasServiceAccountKey: boolean - role: 'admin' | 'member' - createdAt: string - updatedAt: string - } - } -} - /** `PATCH /api/v2/custom-tools/[id]` */ export type UpdateCustomToolParams = { id: string @@ -3725,7 +3708,7 @@ export type UpdateFileContentResponse = { type: string key: string folderPath: string - uploadedBy: string + uploadedByEmail: string uploadedAt: string updatedAt: string } @@ -4079,7 +4062,7 @@ export type UpdateTableViewResponse = { }> | null } isDefault: boolean - createdBy: string | null + createdByEmail: string | null createdAt: string updatedAt: string } @@ -4393,12 +4376,12 @@ export const V2_OPERATIONS = { excludeRowIds: { kind: 'array' }, }, }, - cancelWorkflowExecution: { + cancelWorkflowRun: { method: 'POST', - path: '/api/v2/workflows/[id]/executions/[executionId]/cancel', - pathParams: ['id', 'executionId'] as const, + path: '/api/v2/workflows/[id]/runs/[runId]/cancel', + pathParams: ['id', 'runId'] as const, responseMode: 'json', - summary: 'Cancel an execution', + summary: 'Cancel a run', }, completeFileUpload: { method: 'POST', @@ -4430,34 +4413,6 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, - createCredential: { - method: 'POST', - path: '/api/v2/credentials', - pathParams: [] as const, - responseMode: 'json', - summary: 'Create Credential', - body: { - workspaceId: { kind: 'string', required: true }, - type: { - kind: 'enum', - required: true, - values: ['env_workspace', 'env_personal', 'service_account'] as const, - }, - displayName: { kind: 'string' }, - description: { kind: 'string' }, - providerId: { kind: 'string' }, - envKey: { kind: 'string' }, - serviceAccountJson: { kind: 'string' }, - signingSecret: { kind: 'string' }, - botToken: { kind: 'string' }, - apiToken: { kind: 'string' }, - domain: { kind: 'string' }, - clientId: { kind: 'string' }, - clientSecret: { kind: 'string' }, - orgId: { kind: 'string' }, - dataCenter: { kind: 'string' }, - }, - }, createCustomTool: { method: 'POST', path: '/api/v2/custom-tools', @@ -4728,16 +4683,6 @@ export const V2_OPERATIONS = { path: { kind: 'string', required: true }, }, }, - deleteCredential: { - method: 'DELETE', - path: '/api/v2/credentials/[id]', - pathParams: ['id'] as const, - responseMode: 'json', - summary: 'Delete Credential', - query: { - workspaceId: { kind: 'string', required: true }, - }, - }, deleteCustomTool: { method: 'DELETE', path: '/api/v2/custom-tools/[id]', @@ -4812,6 +4757,17 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + deleteSecret: { + method: 'DELETE', + path: '/api/v2/secrets/[name]', + pathParams: ['name'] as const, + responseMode: 'json', + summary: 'Delete Secret', + query: { + workspaceId: { kind: 'string', required: true }, + scope: { kind: 'enum', required: true, values: ['workspace', 'personal'] as const }, + }, + }, deleteSkill: { method: 'DELETE', path: '/api/v2/skills/[id]', @@ -4993,16 +4949,6 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string' }, }, }, - getCredential: { - method: 'GET', - path: '/api/v2/credentials/[id]', - pathParams: ['id'] as const, - responseMode: 'json', - summary: 'Get Credential', - query: { - workspaceId: { kind: 'string', required: true }, - }, - }, getCustomTool: { method: 'GET', path: '/api/v2/custom-tools/[id]', @@ -5055,8 +5001,8 @@ export const V2_OPERATIONS = { }, getLog: { method: 'GET', - path: '/api/v2/logs/[executionId]', - pathParams: ['executionId'] as const, + path: '/api/v2/logs/[runId]', + pathParams: ['runId'] as const, responseMode: 'json', summary: 'Get Log', }, @@ -5137,12 +5083,12 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Workflow', }, - getWorkflowExecution: { + getWorkflowRun: { method: 'GET', - path: '/api/v2/workflows/[id]/executions/[executionId]', - pathParams: ['id', 'executionId'] as const, + path: '/api/v2/workflows/[id]/runs/[runId]', + pathParams: ['id', 'runId'] as const, responseMode: 'json', - summary: 'Get execution status', + summary: 'Get run status', query: { includeOutput: { kind: 'enum', values: ['true', 'false'] as const }, selectedOutputs: { kind: 'string' }, @@ -5155,6 +5101,13 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Workflow Version', }, + getWorkspace: { + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]', + pathParams: ['workspaceId'] as const, + responseMode: 'json', + summary: 'Get Workspace', + }, importWorkflow: { method: 'POST', path: '/api/v2/workflows/import', @@ -5180,13 +5133,13 @@ export const V2_OPERATIONS = { resourceType: { kind: 'string' }, resourceId: { kind: 'string' }, workspaceId: { kind: 'string' }, - actorId: { kind: 'string' }, startDate: { kind: 'string' }, endDate: { kind: 'string' }, includeDeparted: { kind: 'enum', values: ['true', 'false'] as const }, limit: { kind: 'number', default: 50 }, cursor: { kind: 'string' }, organizationId: { kind: 'string', required: true }, + actorEmail: { kind: 'string' }, }, }, listBillingLogs: { @@ -5230,10 +5183,7 @@ export const V2_OPERATIONS = { summary: 'List Credentials', query: { workspaceId: { kind: 'string', required: true }, - type: { - kind: 'enum', - values: ['oauth', 'env_workspace', 'env_personal', 'service_account'] as const, - }, + type: { kind: 'enum', values: ['oauth', 'service_account'] as const }, providerId: { kind: 'string' }, search: { kind: 'string' }, sortBy: { @@ -5380,7 +5330,6 @@ export const V2_OPERATIONS = { level: { kind: 'enum', values: ['info', 'error'] as const }, startDate: { kind: 'string' }, endDate: { kind: 'string' }, - executionId: { kind: 'string' }, minDurationMs: { kind: 'number' }, maxDurationMs: { kind: 'number' }, minCost: { kind: 'number' }, @@ -5392,6 +5341,7 @@ export const V2_OPERATIONS = { limit: { kind: 'number', default: 100 }, cursor: { kind: 'string' }, order: { kind: 'enum', values: ['desc', 'asc'] as const, default: 'desc' }, + runId: { kind: 'string' }, folderPaths: { kind: 'string' }, }, }, @@ -5412,6 +5362,24 @@ export const V2_OPERATIONS = { sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, }, }, + listSecrets: { + method: 'GET', + path: '/api/v2/secrets', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Secrets', + query: { + workspaceId: { kind: 'string', required: true }, + scope: { kind: 'enum', values: ['workspace', 'personal'] as const }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'name', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, listSkills: { method: 'GET', path: '/api/v2/skills', @@ -5489,25 +5457,6 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, - listWorkflowExecutions: { - method: 'GET', - path: '/api/v2/workflows/[id]/executions', - pathParams: ['id'] as const, - responseMode: 'json', - summary: 'List workflow executions', - query: { - status: { - kind: 'enum', - values: ['pending', 'running', 'completed', 'failed', 'cancelled', 'paused'] as const, - }, - trigger: { kind: 'string' }, - startDate: { kind: 'string' }, - endDate: { kind: 'string' }, - limit: { kind: 'integer', default: 50 }, - cursor: { kind: 'string' }, - order: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, - }, - }, listWorkflowFolders: { method: 'GET', path: '/api/v2/workflows/folders', @@ -5536,6 +5485,25 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + listWorkflowRuns: { + method: 'GET', + path: '/api/v2/workflows/[id]/runs', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'List workflow runs', + query: { + status: { + kind: 'enum', + values: ['pending', 'running', 'completed', 'failed', 'cancelled', 'paused'] as const, + }, + trigger: { kind: 'string' }, + startDate: { kind: 'string' }, + endDate: { kind: 'string' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + order: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, + }, + }, listWorkflows: { method: 'GET', path: '/api/v2/workflows', @@ -5568,6 +5536,17 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + listWorkspaceMembers: { + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/members', + pathParams: ['workspaceId'] as const, + responseMode: 'json', + summary: 'List Workspace Members', + query: { + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + }, + }, moveFileItems: { method: 'POST', path: '/api/v2/files/move', @@ -5655,10 +5634,10 @@ export const V2_OPERATIONS = { }, resumeWorkflow: { method: 'POST', - path: '/api/v2/workflows/[id]/executions/[executionId]/resume', - pathParams: ['id', 'executionId'] as const, + path: '/api/v2/workflows/[id]/runs/[runId]/resume', + pathParams: ['id', 'runId'] as const, responseMode: 'json', - summary: 'Resume a workflow execution', + summary: 'Resume a workflow run', body: { contextId: { kind: 'string', required: true }, input: { kind: 'unknown' }, @@ -5712,6 +5691,18 @@ export const V2_OPERATIONS = { searchMode: { kind: 'enum', default: 'vector' }, }, }, + setSecret: { + method: 'PUT', + path: '/api/v2/secrets/[name]', + pathParams: ['name'] as const, + responseMode: 'json', + summary: 'Set Secret', + body: { + workspaceId: { kind: 'string', required: true }, + scope: { kind: 'enum', required: true, values: ['workspace', 'personal'] as const }, + value: { kind: 'string', required: true }, + }, + }, tableExportDownload: { method: 'GET', path: '/api/v2/tables/exports/[exportId]/download', @@ -5729,27 +5720,6 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Undeploy Workflow', }, - updateCredential: { - method: 'PATCH', - path: '/api/v2/credentials/[id]', - pathParams: ['id'] as const, - responseMode: 'json', - summary: 'Update Credential', - body: { - workspaceId: { kind: 'string', required: true }, - displayName: { kind: 'string' }, - description: { kind: 'string' }, - serviceAccountJson: { kind: 'string' }, - signingSecret: { kind: 'string' }, - botToken: { kind: 'string' }, - apiToken: { kind: 'string' }, - domain: { kind: 'string' }, - clientId: { kind: 'string' }, - clientSecret: { kind: 'string' }, - orgId: { kind: 'string' }, - dataCenter: { kind: 'string' }, - }, - }, updateCustomTool: { method: 'PATCH', path: '/api/v2/custom-tools/[id]', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 8102090d8f5..17d3f235ebf 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -133,8 +133,8 @@ describe('generated operation table', () => { 'getLog', 'getBillingStatus', 'listBillingLogs', - 'listWorkflowExecutions', - 'getWorkflowExecution', + 'listWorkflowRuns', + 'getWorkflowRun', 'resumeWorkflow', 'listFiles', 'deleteFile', diff --git a/packages/sim-cli/src/output/trace.ts b/packages/sim-cli/src/output/trace.ts index 3661d758780..3e4380519fd 100644 --- a/packages/sim-cli/src/output/trace.ts +++ b/packages/sim-cli/src/output/trace.ts @@ -102,7 +102,7 @@ function renderSpan(value: unknown, depth: number): string[] { return lines } -/** Prints the complete recursive execution trace for an explicitly expanded log. */ +/** Prints the complete recursive run trace for an explicitly expanded log. */ export function printTraceSpans(format: OutputFormat, traceSpans: unknown[]): void { if (format === 'json' || format === 'yaml') return console.log('') diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index aaeb8436d5c..47baa706220 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -92,9 +92,11 @@ describe('commands parsed through commander', () => { knowledge: 'kb', logs: 'log', 'mcp-servers': 'mcp-server', + secrets: 'secret', skills: 'skill', tables: 'table', workflows: 'workflow', + workspaces: 'workspace', } for (const [name, alias] of Object.entries(aliases)) { @@ -266,14 +268,14 @@ describe('commands parsed through commander', () => { '20', '--min-cost', '1', - '--execution-id', - 'exec_1', + '--run-id', + 'run_1', ]) expect(options.query).toMatchObject({ minDurationMs: 10, maxDurationMs: 20, minCost: 1, - executionId: 'exec_1', + runId: 'run_1', }) }) @@ -413,18 +415,34 @@ describe('commands parsed through commander', () => { expect(help).not.toContain('--no-recursive') }) - it('exposes credential data centers added by the v2 credential contract', async () => { - const [, options] = await run([ - 'credential', - 'create', - '--type', - 'service_account', - '--display-name', - 'Zoho', - '--data-center', - 'eu', + it('exposes named secrets separately from connected credentials', async () => { + const [path, options] = await run([ + 'secret', + 'set', + 'ZOHO_API_KEY', + '--scope', + 'workspace', + '--value', + 'test-secret', ]) - expect(options.body).toMatchObject({ dataCenter: 'eu' }) + expect(path).toBe('/api/v2/secrets/ZOHO_API_KEY') + expect(options.body).toEqual({ + workspaceId: 'ws_local', + scope: 'workspace', + value: 'test-secret', + }) + expect(commandAt('credentials').commands.map((command) => command.name())).toEqual(['list']) + }) + + it('exposes workspace metadata and email-attributed members', async () => { + const [workspacePath] = await run(['workspace', 'get', 'ws_target'], { + data: { id: 'ws_target' }, + }) + expect(workspacePath).toBe('/api/v2/workspaces/ws_target') + + const [membersPath, membersOptions] = await run(['workspace', 'members', 'ws_target']) + expect(membersPath).toBe('/api/v2/workspaces/ws_target/members') + expect(membersOptions.query).toEqual({ limit: 100, cursor: null }) }) it('comma-joins a repeated list flag', async () => { @@ -516,7 +534,7 @@ describe('commands parsed through commander', () => { }) it('offers expanded trace output without changing the default summary', () => { - expect(commandAt('logs', 'get').description()).toBe('Show execution diagnostics') + expect(commandAt('logs', 'get').description()).toBe('Show run diagnostics') expect(commandAt('logs', 'get').helpInformation()).toMatch( /--trace.*inputs, outputs, errors, timing,\s+and cost/s ) @@ -525,26 +543,29 @@ describe('commands parsed through commander', () => { expect(listHelp).toMatch(/--include-final-output.*implies full detail/s) }) - it('uses a named workflow scope for execution subresources', async () => { - const executions = commandAt('workflows', 'executions') - expect(executions.commands.map((command) => command.name()).sort()).toEqual([ + it('uses a named workflow scope for run subresources', async () => { + expect(commandAt('workflows').commands.map((command) => command.name())).not.toContain( + 'executions' + ) + const runs = commandAt('workflows', 'runs') + expect(runs.commands.map((command) => command.name()).sort()).toEqual([ 'cancel', 'get', 'list', 'resume', ]) - const help = commandAt('workflows', 'executions', 'get').helpInformation() - expect(help).toContain('') + const help = commandAt('workflows', 'runs', 'get').helpInformation() + expect(help).toContain('') expect(help).toMatch(/--workflow .*required/s) expect(help).toContain('--include-output') expect(help).toContain('--select-output ') const [path, options] = await run([ 'workflows', - 'executions', + 'runs', 'get', - 'exec_1', + 'run_1', '--workflow', 'wf_1', '--include-output', @@ -552,35 +573,28 @@ describe('commands parsed through commander', () => { 'agent.content', 'writer.text', ]) - expect(path).toBe('/api/v2/workflows/wf_1/executions/exec_1') + expect(path).toBe('/api/v2/workflows/wf_1/runs/run_1') expect(options.query).toEqual({ includeOutput: true, selectedOutputs: 'agent.content,writer.text', }) - const [listPath] = await run(['workflows', 'executions', 'list', '--workflow', 'wf_1']) - expect(listPath).toBe('/api/v2/workflows/wf_1/executions') + const [listPath] = await run(['workflows', 'runs', 'list', '--workflow', 'wf_1']) + expect(listPath).toBe('/api/v2/workflows/wf_1/runs') - const [cancelPath] = await run([ - 'workflows', - 'executions', - 'cancel', - 'exec_1', - '--workflow', - 'wf_1', - ]) - expect(cancelPath).toBe('/api/v2/workflows/wf_1/executions/exec_1/cancel') + const [cancelPath] = await run(['workflows', 'runs', 'cancel', 'run_1', '--workflow', 'wf_1']) + expect(cancelPath).toBe('/api/v2/workflows/wf_1/runs/run_1/cancel') - const resumeHelp = commandAt('workflows', 'executions', 'resume').helpInformation() - expect(resumeHelp).toContain('') + const resumeHelp = commandAt('workflows', 'runs', 'resume').helpInformation() + expect(resumeHelp).toContain('') expect(resumeHelp).toMatch(/--workflow .*required/s) expect(resumeHelp).toMatch(/--context .*required/s) const [resumePath, resumeOptions] = await run([ 'workflows', - 'executions', + 'runs', 'resume', - 'exec_1', + 'run_1', '--workflow', 'wf_1', '--context', @@ -588,7 +602,7 @@ describe('commands parsed through commander', () => { '--input', '{"approved":true}', ]) - expect(resumePath).toBe('/api/v2/workflows/wf_1/executions/exec_1/resume') + expect(resumePath).toBe('/api/v2/workflows/wf_1/runs/run_1/resume') expect(resumeOptions.body).toEqual({ contextId: 'ctx_1', input: { approved: true }, @@ -599,11 +613,21 @@ describe('commands parsed through commander', () => { const help = commandAt('audit-logs', 'list').helpInformation() expect(help).toMatch(/--organization .*personal API key required.*required/s) expect(help).toContain('--all-workspaces') + expect(help).toContain('--actor-email') + expect(help).not.toContain('--actor-id') - const [, scopedOptions] = await run(['audit-logs', 'list', '--organization', 'org_1']) + const [, scopedOptions] = await run([ + 'audit-logs', + 'list', + '--organization', + 'org_1', + '--actor-email', + 'owner@example.com', + ]) expect(scopedOptions.query).toMatchObject({ organizationId: 'org_1', workspaceId: 'ws_local', + actorEmail: 'owner@example.com', }) const [, organizationOptions] = await run([ @@ -733,9 +757,9 @@ describe('single-resource rendering', () => { expect(JSON.parse(printed[0])).toEqual({ row: { id: 'r1' }, operation: 'inserted' }) }) - it('keeps sensitive execution detail opt-in for human log output', async () => { + it('keeps sensitive run detail opt-in for human log output', async () => { const log = { - executionId: 'exec_1', + runId: 'run_1', status: 'completed', workflow: { name: 'Billing' }, level: 'info', @@ -768,7 +792,7 @@ describe('single-resource rendering', () => { ], } - const human = await lines(['logs', 'get', 'exec_1'], log, 'text') + const human = await lines(['logs', 'get', 'run_1'], log, 'text') expect(human.join('\n')).not.toContain('workflowState') expect(human.join('\n')).not.toContain('SECRET_TOKEN') expect(human.join('\n')).not.toContain('traceSpans') @@ -776,7 +800,7 @@ describe('single-resource rendering', () => { expect(human.join('\n')).not.toContain('trace-secret@example.com') expect(human.join('\n')).toContain('trace\t2 spans (use --trace)') - const expanded = await lines(['logs', 'get', 'exec_1', '--trace'], log, 'text') + const expanded = await lines(['logs', 'get', 'run_1', '--trace'], log, 'text') expect(expanded.join('\n')).toContain('trace\t2 spans') expect(expanded.join('\n')).not.toContain('(use --trace)') expect(expanded.join('\n')).toContain('Workflow Execution [workflow]') @@ -784,14 +808,14 @@ describe('single-resource rendering', () => { expect(expanded.join('\n')).toContain('trace-secret@example.com') expect(expanded.join('\n')).toContain('"delivered": true') - const machine = await lines(['logs', 'get', 'exec_1'], log, 'json') + const machine = await lines(['logs', 'get', 'run_1'], log, 'json') expect(JSON.parse(machine[0])).toMatchObject({ workflowState: log.workflowState, traceSpans: log.traceSpans, finalOutput: log.finalOutput, }) - const yaml = await lines(['logs', 'get', 'exec_1'], log, 'yaml') + const yaml = await lines(['logs', 'get', 'run_1'], log, 'yaml') expect(yaml.join('\n')).toContain('traceSpans:') expect(yaml.join('\n')).toContain('span_2') }) @@ -838,7 +862,7 @@ describe('contract-selected list rendering', () => { expect(printed).toEqual(['3\trow_1\temail']) }) - it('maps custom-tool and credential fields to their actual response paths', async () => { + it('maps custom-tool, credential, and secret fields to their actual response paths', async () => { const tools = await lines( ['custom-tools', 'list'], [ @@ -866,6 +890,20 @@ describe('contract-selected list rendering', () => { ) expect(credentials[0]).toContain('Production Stripe') expect(credentials[0]).toContain('stripe') + + const secrets = await lines( + ['secrets', 'list'], + [ + { + name: 'STRIPE_API_KEY', + scope: 'workspace', + role: 'admin', + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ] + ) + expect(secrets[0]).toContain('STRIPE_API_KEY') + expect(secrets[0]).toContain('workspace') }) }) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 3028419957a..f716a7ca649 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -17,9 +17,11 @@ const GROUP_ALIASES: Readonly> = { knowledge: 'kb', logs: 'log', 'mcp-servers': 'mcp-server', + secrets: 'secret', skills: 'skill', tables: 'table', workflows: 'workflow', + workspaces: 'workspace', } function argumentSyntax(command: Command): string { From f4b654728c155a563cf23e68ceef1d32b4d86c2c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 00:32:35 -0700 Subject: [PATCH 092/159] fix(cli): use profile workspace for workspace get --- packages/sim-cli/README.md | 3 +++ packages/sim-cli/src/contract/commands.ts | 1 + packages/sim-cli/src/contract/types.ts | 4 ++++ packages/sim-cli/src/runtime/build.test.ts | 15 ++++++++++++--- packages/sim-cli/src/runtime/build.ts | 17 ++++++++++++++--- packages/sim-cli/src/runtime/execute.ts | 9 +++++++-- packages/sim-cli/src/runtime/request.test.ts | 12 ++++++++++++ packages/sim-cli/src/runtime/request.ts | 20 ++++++++++++++++++-- 8 files changed, 71 insertions(+), 10 deletions(-) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 55625cea447..e84dd8061e9 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -134,6 +134,9 @@ sim logs get sim audit-logs list --organization [--all-workspaces] sim audit-logs get --organization +sim workspaces get +sim workspaces members + sim tables ls [path] [--search ] [--limit ] sim tables list [--folder ] sim tables get diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 31fab68ccb4..5f6b4706ed5 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -407,6 +407,7 @@ export const CLI_CONTRACT: CliContract = { ], }, getWorkspace: { + profileWorkspacePath: true, fields: [ { header: 'id' }, { header: 'name' }, diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 30838fa081f..d009ed630aa 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -18,6 +18,8 @@ import type { V2OperationName } from '../generated/v2-api.js' * "list". Also friendlier aliases (`conflictTarget` → `--on`). * - `pathFlags` — when a parent path segment is command context rather than the * resource being acted on (`documents get --kb `). + * - `profileWorkspacePath` — when `[workspaceId]` is the active profile target, + * not a resource argument (`workspaces get`). * - `columns` — which of a response's fields belong in a table. Editorial. * - `confirm` — which operations are destructive enough to demand `--yes`. * @@ -119,6 +121,8 @@ export interface CommandSpec { aliases?: readonly string[] /** Route path parameters exposed as required named options instead of positionals. */ pathFlags?: Record + /** Fill a `[workspaceId]` route segment from the active profile instead of an argument. */ + profileWorkspacePath?: boolean /** Request fields exposed as required positional arguments, in order. */ positionals?: readonly string[] /** Restrict this command to these request fields; profile fields remain implicit. */ diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 47baa706220..7b9cccc06e7 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -435,10 +435,19 @@ describe('commands parsed through commander', () => { }) it('exposes workspace metadata and email-attributed members', async () => { - const [workspacePath] = await run(['workspace', 'get', 'ws_target'], { - data: { id: 'ws_target' }, + const getHelp = commandAt('workspaces', 'get').helpInformation() + expect(getHelp).not.toContain('') + + const [workspacePath] = await run(['workspace', 'get'], { + data: { id: 'ws_local' }, }) - expect(workspacePath).toBe('/api/v2/workspaces/ws_target') + expect(workspacePath).toBe('/api/v2/workspaces/ws_local') + + profileState.workspaceId = null + await expect(run(['workspace', 'get'])).rejects.toThrow( + 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ' + ) + profileState.workspaceId = 'ws_local' const [membersPath, membersOptions] = await run(['workspace', 'members', 'ws_target']) expect(membersPath).toBe('/api/v2/workspaces/ws_target/members') diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index f716a7ca649..d080060f135 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -5,7 +5,7 @@ import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' import { deriveCommandPath } from './derive.js' import { executeOperation } from './execute.js' import { addOperationOptions } from './options.js' -import { flagNameFor, PROFILE_INJECTED_FIELD } from './request.js' +import { flagNameFor, isProfileWorkspacePath, PROFILE_INJECTED_FIELD } from './request.js' import type { OperationSpec } from './types.js' const GROUP_ALIASES: Readonly> = { @@ -76,8 +76,17 @@ function configureOperation( } } + if (spec.profileWorkspacePath) { + if (!operationSpec.pathParams.includes(PROFILE_INJECTED_FIELD)) { + throw new Error(`${operation}.profileWorkspacePath requires a workspaceId path parameter`) + } + if (spec.pathFlags?.[PROFILE_INJECTED_FIELD]) { + throw new Error(`${operation}.workspaceId cannot be both profile-injected and a path flag`) + } + } + for (const param of operationSpec.pathParams) { - if (spec.pathFlags?.[param]) continue + if (spec.pathFlags?.[param] || isProfileWorkspacePath(spec, param)) continue command.argument(`<${param}>`) } @@ -203,7 +212,9 @@ export function buildGeneratedCommands(): Command[] { const [groupName, ...rest] = segments const group = groupFor(groups, groupName) if (rest.length > 0) throw new Error(`${operation} groupDefault must name a command group`) - const pathPositionals = operationSpec.pathParams.filter((param) => !spec.pathFlags?.[param]) + const pathPositionals = operationSpec.pathParams.filter( + (param) => !spec.pathFlags?.[param] && !isProfileWorkspacePath(spec, param) + ) if (pathPositionals.length > 0 || spec.positionals?.length) { throw new Error(`${operation} groupDefault cannot require positional arguments`) } diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 71acec7bc05..73d6adbe6c6 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -5,7 +5,12 @@ import type { V2OperationName } from '../generated/v2-api.js' import { SimApiError, type V2Page } from '../http/client.js' import { camel } from './derive.js' import { DEFAULT_LIMIT } from './options.js' -import { buildRequest, flagNameFor, PROFILE_INJECTED_FIELD } from './request.js' +import { + buildRequest, + flagNameFor, + isProfileWorkspacePath, + PROFILE_INJECTED_FIELD, +} from './request.js' import { renderPage, renderResult } from './result.js' import type { OperationSpec } from './types.js' @@ -32,7 +37,7 @@ export async function executeOperation( ...(invocation[invocation.length - 2] as Record), } const pathPositionalCount = operationSpec.pathParams.filter( - (param) => !commandSpec.pathFlags?.[param] + (param) => !commandSpec.pathFlags?.[param] && !isProfileWorkspacePath(commandSpec, param) ).length const positional = invocation.slice(0, pathPositionalCount) as string[] const requestFlags: Record = { ...flags } diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 329bdcb8404..816cdec5374 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -66,6 +66,12 @@ describe('buildRequest', () => { expect(buildRequest('getTable', ['a/b?c'], {}, WORKSPACE).path).toBe('/api/v2/tables/a%2Fb%3Fc') }) + it('fills a configured workspace path segment from the profile', () => { + expect(buildRequest('getWorkspace', [], {}, WORKSPACE).path).toBe( + `/api/v2/workspaces/${WORKSPACE}` + ) + }) + it('combines a named parent scope with a positional resource id in route order', () => { expect(buildRequest('getKnowledgeDocument', ['doc_1'], { kb: 'kb_1' }, WORKSPACE)).toEqual({ path: '/api/v2/knowledge/kb_1/documents/doc_1', @@ -79,6 +85,12 @@ describe('buildRequest', () => { expect(() => buildRequest('getTable', [], {}, WORKSPACE)).toThrow('Missing ') }) + it('rejects a profile-backed workspace path when no workspace is configured', () => { + expect(() => buildRequest('getWorkspace', [], {}, null)).toThrow( + 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ' + ) + }) + it('rejects a missing required flag', () => { expect(() => buildRequest('upsertTableRow', ['t'], {}, WORKSPACE)).toThrow( '--data is required' diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 398a1c11405..92a8bbe1bba 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -22,6 +22,11 @@ export interface FieldSpec { */ export const PROFILE_INJECTED_FIELD = 'workspaceId' +/** Whether this path segment comes from the active profile's workspace. */ +export function isProfileWorkspacePath(commandSpec: CommandSpec, param: string): boolean { + return commandSpec.profileWorkspacePath === true && param === PROFILE_INJECTED_FIELD +} + /** Kinds the CLI can only accept as a JSON string. */ const JSON_KINDS = new Set(['object', 'array', 'unknown']) @@ -265,9 +270,20 @@ export function buildRequest( let positionalIndex = 0 for (const param of spec.pathParams) { const pathFlag = commandSpec.pathFlags?.[param] + const profileWorkspacePath = isProfileWorkspacePath(commandSpec, param) const flagName = pathFlagNameFor(commandSpec, param) - const value = pathFlag ? flags[camel(flagName)] : positional[positionalIndex++] - if (value === undefined) { + const value = profileWorkspacePath + ? workspaceId + : pathFlag + ? flags[camel(flagName)] + : positional[positionalIndex++] + if (value === undefined || value === null) { + if (profileWorkspacePath) { + throw new SimApiError( + 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ', + 0 + ) + } throw new SimApiError(pathFlag ? `--${flagName} is required` : `Missing <${param}>`, 0) } if (typeof value !== 'string' || value.length === 0) { From d84ac1248e2f4a91e77593eba1386f4932cee966 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 00:40:46 -0700 Subject: [PATCH 093/159] fix(cli): use profile workspace for member listing --- packages/sim-cli/README.md | 2 +- packages/sim-cli/src/contract/commands.ts | 1 + packages/sim-cli/src/runtime/build.test.ts | 7 +++++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index e84dd8061e9..f502d857e6f 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -135,7 +135,7 @@ sim audit-logs list --organization [--all-workspaces] sim audit-logs get --organization sim workspaces get -sim workspaces members +sim workspaces members sim tables ls [path] [--search ] [--limit ] sim tables list [--folder ] diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 5f6b4706ed5..0ac23315923 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -420,6 +420,7 @@ export const CLI_CONTRACT: CliContract = { listWorkspaceMembers: { command: 'workspaces members', describe: 'List workspace members', + profileWorkspacePath: true, columns: [ { header: 'email' }, { header: 'name' }, diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 7b9cccc06e7..7f0fe794e4f 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -449,8 +449,11 @@ describe('commands parsed through commander', () => { ) profileState.workspaceId = 'ws_local' - const [membersPath, membersOptions] = await run(['workspace', 'members', 'ws_target']) - expect(membersPath).toBe('/api/v2/workspaces/ws_target/members') + const membersHelp = commandAt('workspaces', 'members').helpInformation() + expect(membersHelp).not.toContain('') + + const [membersPath, membersOptions] = await run(['workspace', 'members']) + expect(membersPath).toBe('/api/v2/workspaces/ws_local/members') expect(membersOptions.query).toEqual({ limit: 100, cursor: null }) }) From b177b3afd934ab5d3400c0186d755b04dd3a6ac9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 02:16:27 -0700 Subject: [PATCH 094/159] improvement(files): centralize operations across APIs and Copilot (#6392) * improvement(files): unify rename authorization * chore(skills): add file operation migration guide * improvement(files): consolidate file operation authorization * improvement(files): extract shared operation foundation * improvement(api): simplify internal route declarations * improvement(files): centralize application authorization * refactor(api): share workspace file name validation * refactor(files): centralize copilot application calls * docs(skills): generalize application operation migration --- .../migrate-application-operation/SKILL.md | 291 +++++++++ .../agents/openai.yaml | 4 + .agents/skills/ship/SKILL.md | 5 +- .../commands/migrate-application-operation.md | 290 +++++++++ .claude/commands/ship.md | 5 +- .../commands/migrate-application-operation.md | 286 +++++++++ .cursor/commands/ship.md | 5 +- .../uploads/[uploadId]/complete/route.ts | 23 +- .../files/uploads/[uploadId]/parts/route.ts | 25 +- .../app/api/files/uploads/[uploadId]/route.ts | 8 +- .../app/api/files/uploads/finalizers.test.ts | 59 +- apps/sim/app/api/files/uploads/finalizers.ts | 97 ++- apps/sim/app/api/files/uploads/purposes.ts | 90 ++- apps/sim/app/api/files/uploads/route.test.ts | 60 +- apps/sim/app/api/files/uploads/route.ts | 8 +- apps/sim/app/api/files/uploads/utils.ts | 13 +- apps/sim/app/api/files/utils.ts | 2 +- .../app/api/function/execute/route.test.ts | 57 +- apps/sim/app/api/function/execute/route.ts | 69 +- .../app/api/tools/file/manage/route.test.ts | 122 +++- apps/sim/app/api/tools/file/manage/route.ts | 460 ++++++++------ .../v2/files/[fileId]/content/route.test.ts | 281 ++++---- .../api/v2/files/[fileId]/content/route.ts | 70 +- .../v2/files/[fileId]/metadata/route.test.ts | 119 ++-- .../api/v2/files/[fileId]/metadata/route.ts | 33 +- .../app/api/v2/files/[fileId]/route.test.ts | 436 +++++-------- apps/sim/app/api/v2/files/[fileId]/route.ts | 144 ++--- .../api/v2/files/[fileId]/share/route.test.ts | 357 +++++------ .../app/api/v2/files/[fileId]/share/route.ts | 105 +-- .../api/v2/files/bulk-delete/route.test.ts | 115 ++-- .../sim/app/api/v2/files/bulk-delete/route.ts | 45 +- .../app/api/v2/files/folders/route.test.ts | 223 +++++++ apps/sim/app/api/v2/files/folders/route.ts | 162 +++-- apps/sim/app/api/v2/files/move/route.test.ts | 132 ++-- apps/sim/app/api/v2/files/move/route.ts | 53 +- apps/sim/app/api/v2/files/route.test.ts | 601 +++++------------- apps/sim/app/api/v2/files/route.ts | 141 ++-- .../uploads/[uploadId]/complete/route.ts | 62 +- .../files/uploads/[uploadId]/parts/route.ts | 52 +- .../api/v2/files/uploads/[uploadId]/route.ts | 48 +- .../app/api/v2/files/uploads/route.test.ts | 250 +++----- apps/sim/app/api/v2/files/uploads/route.ts | 72 +-- apps/sim/app/api/v2/files/uploads/utils.ts | 18 + apps/sim/app/api/v2/lib/gate.ts | 10 +- .../files/[fileId]/compiled-check/route.ts | 137 +--- .../[id]/files/[fileId]/content/route.test.ts | 68 +- .../[id]/files/[fileId]/content/route.ts | 107 +--- .../[id]/files/[fileId]/csv-preview/route.ts | 66 +- .../files/[fileId]/dimensions/route.test.ts | 125 ++-- .../[id]/files/[fileId]/dimensions/route.ts | 78 +-- .../[id]/files/[fileId]/download/route.ts | 120 +--- .../[id]/files/[fileId]/restore/route.ts | 83 +-- .../[id]/files/[fileId]/route.test.ts | 146 +++++ .../workspaces/[id]/files/[fileId]/route.ts | 192 ++---- .../[id]/files/[fileId]/share/route.test.ts | 256 ++++---- .../[id]/files/[fileId]/share/route.ts | 138 ++-- .../[id]/files/[fileId]/style/route.ts | 103 +-- .../[id]/files/bulk-archive/route.test.ts | 79 +++ .../[id]/files/bulk-archive/route.ts | 93 +-- .../files/download/route.integration.test.ts | 27 +- .../[id]/files/download/route.test.ts | 341 ++-------- .../workspaces/[id]/files/download/route.ts | 284 ++------- .../files/folders/[folderId]/restore/route.ts | 84 +-- .../files/folders/[folderId]/route.test.ts | 143 +++++ .../[id]/files/folders/[folderId]/route.ts | 155 ++--- .../[id]/files/folders/route.test.ts | 114 ++++ .../workspaces/[id]/files/folders/route.ts | 125 ++-- .../[id]/files/inline/route.test.ts | 82 +-- .../api/workspaces/[id]/files/inline/route.ts | 94 ++- .../workspaces/[id]/files/move/route.test.ts | 86 +++ .../api/workspaces/[id]/files/move/route.ts | 100 +-- .../api/workspaces/[id]/files/route.test.ts | 282 +++----- .../app/api/workspaces/[id]/files/route.ts | 220 ++----- apps/sim/lib/api/contracts/primitives.test.ts | 19 + apps/sim/lib/api/contracts/primitives.ts | 14 + apps/sim/lib/api/contracts/v2/files.ts | 27 +- apps/sim/lib/api/contracts/v2/shared.ts | 2 + .../api/contracts/workspace-file-folders.ts | 21 +- apps/sim/lib/api/contracts/workspace-files.ts | 35 +- .../lib/api/server/routes/definition.test.ts | 112 ++++ apps/sim/lib/api/server/routes/definition.ts | 55 ++ apps/sim/lib/api/server/routes/index.ts | 23 + .../server/routes/internal-binary-route.ts | 127 ++++ .../server/routes/internal-json-route.test.ts | 85 +++ .../api/server/routes/internal-json-route.ts | 277 ++++++++ apps/sim/lib/api/server/routes/types.ts | 68 ++ .../api/server/routes/v2-api-key-auth.test.ts | 156 +++++ .../lib/api/server/routes/v2-api-key-auth.ts | 132 ++++ .../lib/api/server/routes/v2-binary-route.ts | 97 +++ .../lib/api/server/routes/v2-json-route.ts | 246 +++++++ apps/sim/lib/auth/principal.test.ts | 118 ++++ .../application/execute-file-use-case.test.ts | 87 +++ .../application/execute-file-use-case.ts | 45 ++ .../lib/copilot/auth/file-delegation.test.ts | 100 +++ apps/sim/lib/copilot/auth/file-delegation.ts | 88 +++ .../lib/copilot/chat/process-contents.test.ts | 12 + apps/sim/lib/copilot/chat/process-contents.ts | 42 +- .../sim/lib/copilot/chat/workspace-context.ts | 24 +- .../request/go/file-preview-adapter.ts | 27 +- .../sim/lib/copilot/request/go/stream.test.ts | 56 +- .../lib/copilot/request/tools/files.test.ts | 6 +- apps/sim/lib/copilot/request/tools/files.ts | 5 +- .../handlers/deployment/custom-block.test.ts | 62 +- .../tools/handlers/deployment/custom-block.ts | 36 +- .../tools/handlers/function-execute.test.ts | 72 ++- .../tools/handlers/function-execute.ts | 61 +- .../tools/handlers/materialize-file.test.ts | 47 +- .../tools/handlers/materialize-file.ts | 40 +- .../copilot/tools/handlers/resources.test.ts | 77 ++- .../lib/copilot/tools/handlers/resources.ts | 31 +- .../copilot/tools/handlers/vfs-mutate.test.ts | 226 +++++-- .../lib/copilot/tools/handlers/vfs-mutate.ts | 271 +++++--- apps/sim/lib/copilot/tools/handlers/vfs.ts | 27 +- .../tools/handlers/workflow/queries.ts | 8 +- .../registry/server-tool-adapter.test.ts | 45 ++ .../tools/registry/server-tool-adapter.ts | 6 +- .../sim/lib/copilot/tools/server/base-tool.ts | 5 + .../copilot/tools/server/files/create-file.ts | 83 +-- .../copilot/tools/server/files/doc-compile.ts | 49 +- .../copilot/tools/server/files/doc-recalc.ts | 3 + .../files/download-to-workspace-file.ts | 37 +- .../tools/server/files/edit-content.ts | 37 +- .../server/files/file-folder-application.ts | 46 ++ .../tools/server/files/file-folders.ts | 124 ++-- .../tools/server/files/file-preview.ts | 22 +- .../copilot/tools/server/files/rename-file.ts | 57 +- .../copilot/tools/server/files/share-file.ts | 187 +++--- .../tools/server/files/workspace-file.ts | 224 +++++-- .../tools/server/image/generate-image.ts | 64 +- .../server/knowledge/knowledge-base.test.ts | 15 +- .../tools/server/knowledge/knowledge-base.ts | 16 +- .../lib/copilot/tools/server/media/ffmpeg.ts | 42 +- .../tools/server/media/generate-audio.ts | 35 +- .../tools/server/media/generate-video.ts | 39 +- .../server/media/model-boundaries.test.ts | 18 +- .../tools/server/table/user-table.test.ts | 20 + .../copilot/tools/server/table/user-table.ts | 72 ++- apps/sim/lib/copilot/vfs/file-reader.ts | 20 +- .../lib/copilot/vfs/resource-writer.test.ts | 79 ++- apps/sim/lib/copilot/vfs/resource-writer.ts | 188 +++--- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 162 +++-- .../authorized-workspace-use-case.test.ts | 306 +++++++++ .../authorized-workspace-use-case.ts | 152 +++++ apps/sim/lib/core/application/index.ts | 26 + apps/sim/lib/core/application/operation.ts | 15 + .../application/workspace-authorization.ts | 117 ++++ .../core/application/workspace-operation.ts | 54 ++ .../core/rate-limiter/rate-limiter.test.ts | 31 + .../sim/lib/core/rate-limiter/rate-limiter.ts | 97 ++- apps/sim/lib/uploads/archive.test.ts | 113 ++-- apps/sim/lib/uploads/archive.ts | 67 +- .../workspace-file-folder-manager.ts | 191 +++++- .../workspace-file-manager-errors.test.ts | 34 +- .../workspace/workspace-file-manager.ts | 138 +++- .../upload-session/application.test.ts | 117 ++++ .../lib/uploads/upload-session/application.ts | 350 ++++++++++ .../uploads/upload-session/service.test.ts | 174 ++++- .../sim/lib/uploads/upload-session/service.ts | 235 ++++++- apps/sim/lib/workspace-files/api/index.ts | 7 + .../workspace-files/api/internal-analytics.ts | 151 +++++ .../api/internal-error-policies.test.ts | 35 + .../api/internal-error-policies.ts | 87 +++ .../api/internal-presenters.ts | 29 + .../api/route-policies.test.ts | 71 +++ .../lib/workspace-files/api/route-policies.ts | 35 + .../archive-workspace-file-items.test.ts | 228 +++++++ .../archive-workspace-file-items.ts | 127 ++++ .../application/authorization.test.ts | 152 +++++ .../application/authorization.ts | 40 ++ .../authorized-workspace-file-use-case.ts | 28 + .../compiled-check-workspace-file.test.ts | 235 +++++++ .../compiled-check-workspace-file.ts | 114 ++++ .../application/create-workspace-file.ts | 150 +++++ .../application/csv-preview-workspace-file.ts | 36 ++ .../application/delegated-principal.ts | 39 ++ .../application/delete-workspace-file.test.ts | 85 +++ .../application/delete-workspace-file.ts | 56 ++ .../download-workspace-file-items.test.ts | 198 ++++++ .../download-workspace-file-items.ts | 183 ++++++ .../download-workspace-file.test.ts | 104 +++ .../application/download-workspace-file.ts | 87 +++ .../application/list-workspace-files.ts | 75 +++ .../move-workspace-file-items.test.ts | 168 +++++ .../application/move-workspace-file-items.ts | 127 ++++ .../application/operations.test.ts | 56 ++ .../workspace-files/application/operations.ts | 152 +++++ .../read-workspace-file-content.test.ts | 87 +++ .../read-workspace-file-content.ts | 49 ++ .../read-workspace-file-metadata.test.ts | 85 +++ .../read-workspace-file-metadata.ts | 42 ++ .../read-workspace-file-record.test.ts | 69 ++ .../application/read-workspace-file-record.ts | 46 ++ .../read-workspace-inline-file.test.ts | 91 +++ .../application/read-workspace-inline-file.ts | 62 ++ .../application/rename-workspace-file.test.ts | 152 +++++ .../application/rename-workspace-file.ts | 58 ++ .../resolve-workspace-file-reference.test.ts | 107 ++++ .../resolve-workspace-file-reference.ts | 125 ++++ .../restore-workspace-file.test.ts | 82 +++ .../application/restore-workspace-file.ts | 54 ++ .../application/share-workspace-file.ts | 126 ++++ .../application/style-workspace-file.ts | 57 ++ .../update-workspace-file-content.ts | 152 +++++ .../update-workspace-file-dimensions.test.ts | 57 ++ .../update-workspace-file-dimensions.ts | 42 ++ .../application/workspace-file-context.ts | 41 ++ .../workspace-file-folders.test.ts | 174 +++++ .../application/workspace-file-folders.ts | 299 +++++++++ .../workspace-operation-context.ts | 31 + .../write-workspace-file-by-path.ts | 227 +++++++ apps/sim/lib/workspace-files/limits.ts | 2 + .../workspace-files/workspace-file-path.ts | 29 + packages/auth/package.json | 4 + packages/auth/src/principal.ts | 132 ++++ packages/platform-authz/src/workspace.ts | 15 +- scripts/check-api-validation-contracts.ts | 12 +- 216 files changed, 15944 insertions(+), 5832 deletions(-) create mode 100644 .agents/skills/migrate-application-operation/SKILL.md create mode 100644 .agents/skills/migrate-application-operation/agents/openai.yaml create mode 100644 .claude/commands/migrate-application-operation.md create mode 100644 .cursor/commands/migrate-application-operation.md create mode 100644 apps/sim/app/api/v2/files/folders/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/[fileId]/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/folders/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/move/route.test.ts create mode 100644 apps/sim/lib/api/server/routes/definition.test.ts create mode 100644 apps/sim/lib/api/server/routes/definition.ts create mode 100644 apps/sim/lib/api/server/routes/index.ts create mode 100644 apps/sim/lib/api/server/routes/internal-binary-route.ts create mode 100644 apps/sim/lib/api/server/routes/internal-json-route.test.ts create mode 100644 apps/sim/lib/api/server/routes/internal-json-route.ts create mode 100644 apps/sim/lib/api/server/routes/types.ts create mode 100644 apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts create mode 100644 apps/sim/lib/api/server/routes/v2-api-key-auth.ts create mode 100644 apps/sim/lib/api/server/routes/v2-binary-route.ts create mode 100644 apps/sim/lib/api/server/routes/v2-json-route.ts create mode 100644 apps/sim/lib/auth/principal.test.ts create mode 100644 apps/sim/lib/copilot/application/execute-file-use-case.test.ts create mode 100644 apps/sim/lib/copilot/application/execute-file-use-case.ts create mode 100644 apps/sim/lib/copilot/auth/file-delegation.test.ts create mode 100644 apps/sim/lib/copilot/auth/file-delegation.ts create mode 100644 apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts create mode 100644 apps/sim/lib/copilot/tools/server/files/file-folder-application.ts create mode 100644 apps/sim/lib/core/application/authorized-workspace-use-case.test.ts create mode 100644 apps/sim/lib/core/application/authorized-workspace-use-case.ts create mode 100644 apps/sim/lib/core/application/index.ts create mode 100644 apps/sim/lib/core/application/operation.ts create mode 100644 apps/sim/lib/core/application/workspace-authorization.ts create mode 100644 apps/sim/lib/core/application/workspace-operation.ts create mode 100644 apps/sim/lib/uploads/upload-session/application.test.ts create mode 100644 apps/sim/lib/uploads/upload-session/application.ts create mode 100644 apps/sim/lib/workspace-files/api/index.ts create mode 100644 apps/sim/lib/workspace-files/api/internal-analytics.ts create mode 100644 apps/sim/lib/workspace-files/api/internal-error-policies.test.ts create mode 100644 apps/sim/lib/workspace-files/api/internal-error-policies.ts create mode 100644 apps/sim/lib/workspace-files/api/internal-presenters.ts create mode 100644 apps/sim/lib/workspace-files/api/route-policies.test.ts create mode 100644 apps/sim/lib/workspace-files/api/route-policies.ts create mode 100644 apps/sim/lib/workspace-files/application/archive-workspace-file-items.test.ts create mode 100644 apps/sim/lib/workspace-files/application/archive-workspace-file-items.ts create mode 100644 apps/sim/lib/workspace-files/application/authorization.test.ts create mode 100644 apps/sim/lib/workspace-files/application/authorization.ts create mode 100644 apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts create mode 100644 apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts create mode 100644 apps/sim/lib/workspace-files/application/compiled-check-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/create-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/csv-preview-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/delegated-principal.ts create mode 100644 apps/sim/lib/workspace-files/application/delete-workspace-file.test.ts create mode 100644 apps/sim/lib/workspace-files/application/delete-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts create mode 100644 apps/sim/lib/workspace-files/application/download-workspace-file-items.ts create mode 100644 apps/sim/lib/workspace-files/application/download-workspace-file.test.ts create mode 100644 apps/sim/lib/workspace-files/application/download-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/list-workspace-files.ts create mode 100644 apps/sim/lib/workspace-files/application/move-workspace-file-items.test.ts create mode 100644 apps/sim/lib/workspace-files/application/move-workspace-file-items.ts create mode 100644 apps/sim/lib/workspace-files/application/operations.test.ts create mode 100644 apps/sim/lib/workspace-files/application/operations.ts create mode 100644 apps/sim/lib/workspace-files/application/read-workspace-file-content.test.ts create mode 100644 apps/sim/lib/workspace-files/application/read-workspace-file-content.ts create mode 100644 apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts create mode 100644 apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts create mode 100644 apps/sim/lib/workspace-files/application/read-workspace-file-record.test.ts create mode 100644 apps/sim/lib/workspace-files/application/read-workspace-file-record.ts create mode 100644 apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts create mode 100644 apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts create mode 100644 apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts create mode 100644 apps/sim/lib/workspace-files/application/rename-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts create mode 100644 apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts create mode 100644 apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts create mode 100644 apps/sim/lib/workspace-files/application/restore-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/share-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/style-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/update-workspace-file-content.ts create mode 100644 apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts create mode 100644 apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.ts create mode 100644 apps/sim/lib/workspace-files/application/workspace-file-context.ts create mode 100644 apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts create mode 100644 apps/sim/lib/workspace-files/application/workspace-file-folders.ts create mode 100644 apps/sim/lib/workspace-files/application/workspace-operation-context.ts create mode 100644 apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts create mode 100644 apps/sim/lib/workspace-files/limits.ts create mode 100644 apps/sim/lib/workspace-files/workspace-file-path.ts create mode 100644 packages/auth/src/principal.ts diff --git a/.agents/skills/migrate-application-operation/SKILL.md b/.agents/skills/migrate-application-operation/SKILL.md new file mode 100644 index 00000000000..fa7923be6a7 --- /dev/null +++ b/.agents/skills/migrate-application-operation/SKILL.md @@ -0,0 +1,291 @@ +--- +name: migrate-application-operation +description: Migrate one existing Sim resource operation into the shared Principal and application-use-case architecture across internal APIs, public or versioned APIs, Copilot, and other trusted tool adapters. Use when removing route- or tool-local authorization and business logic, consolidating resource reads or writes behind semantic operation policies, or adding another surface to an existing application operation while preserving contracts, identity, errors, rate limits, audit, analytics, and compatibility behavior. Treat v1, uploads, streams, large bodies, bulk recursion, and polymorphic tools as explicitly scoped special cases. +--- + +# Migrate Application Operation + +Migrate one bounded semantic operation at a time. Share authorization and business behavior without forcing internal APIs, public APIs, Copilot, and other tools to share authentication, input schemas, or response shapes. + +## Enforce the application boundary + +Apply this invariant: + +> Every real operation on persisted or protected data enters through an authorized application use case. + +This includes mutations, content and metadata reads, canonical resource lookup, and reference-to-resource resolution when the lookup is authorization-sensitive. + +Surface helpers may: + +- Normalize an already-authenticated surface context into a `Principal`. +- Translate aliases or wire arguments into application input. +- Select a code-defined operation and application use case. +- Call the application use case. +- Translate typed results and errors into the surface contract. + +Surface helpers must not: + +- Query databases or storage. +- Decide workspace or resource authorization. +- Implement business transactions. +- Record semantic audit or shared domain notifications. +- Infer authoritative identity, workspace, audience, or scope from untrusted arguments. +- Substitute billing attribution for identity. + +A helper that resolves a path is valid only when the actual protected lookup runs through an authorized application resolver. If a helper begins doing real data work, move that work into an application use case. + +## Read the foundation first + +Read these files completely before editing: + +- `packages/auth/src/principal.ts` +- `apps/sim/lib/core/application/operation.ts` +- `apps/sim/lib/core/application/workspace-operation.ts` +- `apps/sim/lib/core/application/workspace-authorization.ts` +- `apps/sim/lib/core/application/authorized-workspace-use-case.ts` +- `apps/sim/lib/api/server/routes/internal-json-route.ts` +- `apps/sim/lib/api/server/routes/v2-json-route.ts` + +Use the file domain only as a representative golden slice: + +- `apps/sim/lib/workspace-files/application/operations.ts` +- `apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts` +- `apps/sim/lib/workspace-files/application/rename-workspace-file.ts` +- `apps/sim/lib/copilot/application/execute-file-use-case.ts` +- `apps/sim/lib/copilot/auth/file-delegation.ts` + +Then read the target domain's operation registry, application code, repositories, contracts, adapters, aliases, resume paths, and focused tests. Fail immediately if the shared foundation is absent. Do not recreate it inside the domain. + +## Bound the migration + +Inventory every entry point for the behavior before editing: + +- Internal HTTP routes and contracts. +- Public or versioned API routes and contracts. +- Copilot tools, aliases, resume paths, and polymorphic branches. +- Other tool servers, workflow executors, jobs, or service callers. +- Current authentication, authorization, workspace assertions, and concealment. +- Manager or orchestration call chains. +- Audit, notification, analytics, and billing side effects. +- Error/status/result behavior. +- Rate-limit identity, rollout gates, quota, and concurrency admission. + +Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent operations merely because they share a module. Do not modify v1 unless the request explicitly includes it. + +Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. + +## Keep the layers distinct + +Use these responsibilities: + +1. Authentication adapter: verify the surface credential or trusted execution context and construct a `Principal`. +2. Route or tool adapter: select rate policy, parse its contract, translate input, call the application use case, and render its own result. +3. Application use case: load canonical context, compare asserted scope, authorize the semantic operation, execute business behavior, project semantic audit, and trigger shared domain effects. +4. Manager or repository: perform database and storage reads or writes using canonical identifiers and scope. Never accept credentials or principals. +5. Presenter: return only the surface success body or typed binary descriptor. Never construct auth, rate, or error behavior. + +For ordinary public JSON routes, preserve this order: + +```text +IP abuse limit + -> authenticate + -> build Principal + -> operation rate limit + -> parse surface contract + -> application use case + -> canonical load + -> asserted-scope concealment + -> current authorization + -> manager read or mutation + -> semantic audit + -> shared domain effects + -> surface presenter +``` + +Internal routes may omit the IP bucket or operation limit only through an explicit policy with a reason. Usage billing, storage quota, cost admission, and concurrency are separate from request-rate limiting. + +Never query API keys or sessions from the application layer. Never add fallback identity or authorization behavior. Propagate infrastructure failures instead of turning them into not-found or forbidden results. + +## Define the semantic operation once + +Add one stable entry to the target domain's operation registry: + +```ts +rename: defineWorkspaceOperation({ + id: 'widgets.rename', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], +}) +``` + +Do not create internal-, public-, or Copilot-specific versions of the same semantic operation. If two callers have materially different business or transactional semantics, define separate use cases and explain the distinction. + +Choose principal kinds from actual behavior. Do not accept every principal merely because the use case is shared. Workspace API keys have a write ceiling and cannot satisfy admin operations. The operation definition must fail fast when its role, workspace-key policy, and principal kinds disagree. + +Route declarations, tool adapters, and use cases must use the same literal operation. Runtime operation selection is permitted only from a trusted, code-defined registry. Never accept an operation ID or permission tag from an HTTP body, model argument, or other untrusted input. + +## Implement the application use case + +Use `defineAuthorizedWorkspaceUseCase` directly or a thin domain binding that supplies domain-specific authorization options: + +```ts +export const renameWidget = defineAuthorizedWorkspaceUseCase({ + operation: widgetOperations.rename, + resolveContext: ({ input }: { input: RenameWidgetInput }) => + loadCanonicalWidgetContext(input.id, input.assertedWorkspaceId), + authorizationOptions: { delegation: widgetDelegationPolicy }, + execute: async ({ input, context }) => renameWidgetRecord({ + workspaceId: context.workspaceId, + widgetId: context.resourceId, + name: input.name, + }), + projectAudit: ({ result }) => ({ + action: AuditAction.WIDGET_UPDATED, + resourceType: AuditResourceType.WIDGET, + resourceId: result.id, + resourceName: result.name, + }), + afterSuccess: ({ context }) => notifyWidgetsChanged(context.workspaceId), +}) +``` + +Adapt the example to the domain's real authorization options; do not copy invented field names. + +The wrapper must own this lifecycle: + +1. Reject disallowed principal kinds before protected loading. +2. Load canonical context and conceal asserted-scope mismatches as required. +3. Authorize the operation using current policy state. +4. Execute the manager or repository primitive. +5. Project audit from authoritative results. +6. Await shared post-success effects. + +Do not call shared authorization, principal audit attribution, or `recordAudit` manually from an ordinary migrated use-case body. Use `projectAudit` only when the operation has semantic audit. Return no audit entries for authoritative no-ops. Keep product analytics such as `captureServerEvent` surface-specific through the adapter's success hook. + +Inspect legacy orchestration before reusing it. If it already authorizes, audits, notifies, or captures analytics, call a lower-level primitive or remove duplicate responsibility for migrated callers. + +## Adapt internal APIs + +Use `defineInternalJsonRoute` for ordinary JSON routes. Explicitly declare the contract, session authentication policy, semantic operation, rate policy, error policy, input mapping, use case, and presenter when the wire result differs. + +The internal adapter owns session authentication and internal response envelopes. It must not implement workspace authorization. Preserve internal-only analytics through `onSuccess` after application success. + +Keep the route module declarative. If several internal routes repeat authentication, parsing, error rendering, or response construction, improve the shared internal route builder instead of adding a domain-specific route wrapper. + +## Adapt public or versioned APIs + +Use the appropriate public/versioned route builder, such as `defineV2JsonRoute`, with API-key authentication, explicit operation rate policy, rollout policy, external error projection, and an external presenter. + +Authentication and HTTP formatting may differ from internal APIs; authorization and business behavior must not. Rate-limit using the credential or principal subject, never a billed owner. Resolve billing attribution only for billing, quota, or legacy required-user fields. + +Keep surface contracts separate when their wire shapes differ. Reuse shared primitive schemas and domain validators for invariants such as IDs, names, bounds, and formats. Do not maintain duplicate internal and external schemas merely because the routes are separate; import the same schema when the wire shape is genuinely identical. Never cast one surface response into another. + +Keep v1 middleware and routes unchanged unless explicitly included. + +## Adapt Copilot + +Create one domain-level Copilot application adapter instead of constructing delegated principals in every tool: + +```ts +executeCopilotWidgetUseCase(context, renameWidget, input, { resourceId }) +``` + +That adapter must: + +- Require a trusted server-authored Copilot execution marker. +- Require the authenticated subject, canonical workspace, tool-call or execution identity, and required audience or lifecycle scope. +- Construct the shared delegated `Principal` in one place. +- Optionally bind the canonical resource scope after trusted resolution. +- Verify that the use case exposes a registered code-defined operation. +- Call the application use case directly. + +Never construct authoritative delegation from model-provided workspace IDs, user IDs, operation IDs, resource scope, or permission tags. Model arguments are requested targets only and must be checked against trusted execution context and canonical data. + +Tool handlers own argument aliases, resumable legacy names, abort checks, and tool-specific presentation. They must not query managers directly for protected operations or manually authorize. + +A Copilot reference helper may translate a path to a resource only by calling an authorized application resolver under the intended semantic operation. Passing a code-defined operation object is acceptable; passing a model-provided operation string is not. Reauthorizing during later execution is safe but redundant. When resolution and execution form one business operation, need a consistent snapshot, or appear repeatedly together, prefer a top-level application use case such as `renameWidgetByReference`. + +Special composition roots may resolve one principal and deliberately thread it through several application calls or lower-level admission stages. Keep this exceptional and explicit; ordinary tools should use the shared execution adapter. + +Map expected typed errors to safe tool results. Unknown errors must become generic system/retryable messages while retaining full causes in server logs. Never return raw database or storage errors to the model. + +## Adapt other internal or external tools + +Treat every tool runtime as a surface adapter: + +- Normalize its already-authenticated execution context into an existing `Principal` through one shared adapter for that runtime or domain. +- Call the same application use case used by HTTP and Copilot surfaces. +- Preserve the tool protocol's input, output, retry, and cancellation semantics. +- Keep authoritative workspace and subject scope server-authored. + +An internal caller is not automatically trusted to bypass authorization. It must supply an explicit principal or use a deliberately designed service/delegation principal. If the current principal model cannot express its authority, stop and extend the identity model intentionally; do not fall back to an owner, uploader, creator, or arbitrary user ID. + +External tool endpoints authenticate at their adapter exactly like public APIs. Do not authenticate again inside the application use case. + +## Preserve identity and attribution + +- Session and personal-key principals authorize through current human workspace permission. +- Personal API keys also respect the workspace's personal-key policy. +- Workspace keys authorize as the workspace under explicit operation policy and the write ceiling, independent of creator membership. +- Delegated principals re-check the current subject and their workspace, audience, expiry, execution, and resource scope. +- Billing owners are attribution for billing or legacy required columns only, never authorization, rate identity, delegated identity, audit actor, or human analytics identity. +- Preserve structured `PrincipalActor` metadata in semantic audit. + +If a required legacy user column cannot represent the real actor, label the compatibility attribution explicitly. Never pretend it is the acting human. + +## Handle special operations explicitly + +Do not force these through an ordinary JSON migration: + +- Upload or multipart lifecycles: bind immutable credential identity, reauthorize control legs and finalization, and make durable completion idempotent. +- Large bodies: authenticate and perform cheap admission before bounded buffering. +- Binary or streaming responses: use binary/stream builders and typed descriptors. +- Bulk or recursive operations: deduplicate and cap inputs and expansion, load all resources canonically, and define atomic versus best-effort behavior. +- Polymorphic tools: select the semantic operation only after trusted target-kind resolution; do not route unrelated branches through one domain registry. +- Multi-resource transactions: keep canonical scope predicates and derive audit from authoritative affected rows. + +Stop and report a missing design rather than weakening identity, authorization, limits, or errors. + +## Test the complete matrix + +Add focused tests for every migrated surface and principal kind allowed by the operation: + +- Application: allowed and disallowed roles, principal-kind rejection before canonical loading, workspace assertion mismatch, delegated scope, not found, conflict, no-op, and infrastructure propagation. +- Repository: canonical active lookup, workspace-predicated writes, archived resources, authoritative affected rows, and database error propagation. +- Internal API: authentication before parsing, exact contract, typed errors, and surface analytics only after success. +- Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. +- Copilot or tools: trusted context, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. +- Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. + +Run at minimum: + +```bash +bunx vitest run +bunx biome check +bunx turbo run type-check --filter=sim --filter=@sim/auth +bun run check:api-validation:strict +git diff --check +``` + +Do not claim a check passed unless it completed successfully. + +## Work safely in parallel + +- Assign non-overlapping route modules and caller sets. Two methods in one route file are one ownership unit. +- Treat operation registries, contract families, route policies, and shared surface adapters as merge hotspots. +- Keep shared core foundations owned by one task; ordinary domain migrations should consume them without modifying them. +- Preserve unrelated working-tree changes. Never stage proposal docs, lockfile drift, or another agent's edits. +- Do not commit, push, or open a PR unless requested. + +## Hand off + +Report: + +1. Semantic operation, role, workspace-key policy, and principal kinds. +2. Migrated, deferred, and non-goal entry points. +3. Behavior preserved per internal, public, Copilot, and other tool surface. +4. Identity construction and authoritative scope source for each surface. +5. Files changed and shared merge hotspots. +6. Tests and checks run with results. +7. Remaining risks or blockers. Fail fast when an invariant could not be implemented. diff --git a/.agents/skills/migrate-application-operation/agents/openai.yaml b/.agents/skills/migrate-application-operation/agents/openai.yaml new file mode 100644 index 00000000000..625ce6954ca --- /dev/null +++ b/.agents/skills/migrate-application-operation/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Migrate Application Operation" + short_description: "Share one operation across API and tool surfaces" + default_prompt: "Use $migrate-application-operation to migrate one resource operation across internal APIs, public APIs, Copilot, and other tools." diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 8f144a04aa6..946d235643a 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -66,11 +66,12 @@ When the user runs `/ship`: exit 1 } rm -f /tmp/ship-audit-results - for s in check:boundaries check:api-validation:strict check:desktop-bridge check:desktop-ipc \ + for s in check:boundaries check:api-validation:strict check:openapi \ + check:desktop-bridge check:desktop-ipc \ check:utils check:zustand-v5 \ check:react-query check:client-boundary check:bare-icons check:icon-paths \ check:realtime-prune check:tool-registry-boundary check:tool-request-boundary \ - tool-metadata:check \ + check:sql-date-binding tool-metadata:check \ integration-catalog:check skills:check agent-stream-docs:check; do ( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) & done diff --git a/.claude/commands/migrate-application-operation.md b/.claude/commands/migrate-application-operation.md new file mode 100644 index 00000000000..6411a56f3ad --- /dev/null +++ b/.claude/commands/migrate-application-operation.md @@ -0,0 +1,290 @@ +--- +description: Migrate one existing Sim resource operation into the shared Principal and application-use-case architecture across internal APIs, public or versioned APIs, Copilot, and other trusted tool adapters. Use when removing route- or tool-local authorization and business logic, consolidating resource reads or writes behind semantic operation policies, or adding another surface to an existing application operation while preserving contracts, identity, errors, rate limits, audit, analytics, and compatibility behavior. Treat v1, uploads, streams, large bodies, bulk recursion, and polymorphic tools as explicitly scoped special cases. +--- + +# Migrate Application Operation + +Migrate one bounded semantic operation at a time. Share authorization and business behavior without forcing internal APIs, public APIs, Copilot, and other tools to share authentication, input schemas, or response shapes. + +## Enforce the application boundary + +Apply this invariant: + +> Every real operation on persisted or protected data enters through an authorized application use case. + +This includes mutations, content and metadata reads, canonical resource lookup, and reference-to-resource resolution when the lookup is authorization-sensitive. + +Surface helpers may: + +- Normalize an already-authenticated surface context into a `Principal`. +- Translate aliases or wire arguments into application input. +- Select a code-defined operation and application use case. +- Call the application use case. +- Translate typed results and errors into the surface contract. + +Surface helpers must not: + +- Query databases or storage. +- Decide workspace or resource authorization. +- Implement business transactions. +- Record semantic audit or shared domain notifications. +- Infer authoritative identity, workspace, audience, or scope from untrusted arguments. +- Substitute billing attribution for identity. + +A helper that resolves a path is valid only when the actual protected lookup runs through an authorized application resolver. If a helper begins doing real data work, move that work into an application use case. + +## Read the foundation first + +Read these files completely before editing: + +- `packages/auth/src/principal.ts` +- `apps/sim/lib/core/application/operation.ts` +- `apps/sim/lib/core/application/workspace-operation.ts` +- `apps/sim/lib/core/application/workspace-authorization.ts` +- `apps/sim/lib/core/application/authorized-workspace-use-case.ts` +- `apps/sim/lib/api/server/routes/internal-json-route.ts` +- `apps/sim/lib/api/server/routes/v2-json-route.ts` + +Use the file domain only as a representative golden slice: + +- `apps/sim/lib/workspace-files/application/operations.ts` +- `apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts` +- `apps/sim/lib/workspace-files/application/rename-workspace-file.ts` +- `apps/sim/lib/copilot/application/execute-file-use-case.ts` +- `apps/sim/lib/copilot/auth/file-delegation.ts` + +Then read the target domain's operation registry, application code, repositories, contracts, adapters, aliases, resume paths, and focused tests. Fail immediately if the shared foundation is absent. Do not recreate it inside the domain. + +## Bound the migration + +Inventory every entry point for the behavior before editing: + +- Internal HTTP routes and contracts. +- Public or versioned API routes and contracts. +- Copilot tools, aliases, resume paths, and polymorphic branches. +- Other tool servers, workflow executors, jobs, or service callers. +- Current authentication, authorization, workspace assertions, and concealment. +- Manager or orchestration call chains. +- Audit, notification, analytics, and billing side effects. +- Error/status/result behavior. +- Rate-limit identity, rollout gates, quota, and concurrency admission. + +Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent operations merely because they share a module. Do not modify v1 unless the request explicitly includes it. + +Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. + +## Keep the layers distinct + +Use these responsibilities: + +1. Authentication adapter: verify the surface credential or trusted execution context and construct a `Principal`. +2. Route or tool adapter: select rate policy, parse its contract, translate input, call the application use case, and render its own result. +3. Application use case: load canonical context, compare asserted scope, authorize the semantic operation, execute business behavior, project semantic audit, and trigger shared domain effects. +4. Manager or repository: perform database and storage reads or writes using canonical identifiers and scope. Never accept credentials or principals. +5. Presenter: return only the surface success body or typed binary descriptor. Never construct auth, rate, or error behavior. + +For ordinary public JSON routes, preserve this order: + +```text +IP abuse limit + -> authenticate + -> build Principal + -> operation rate limit + -> parse surface contract + -> application use case + -> canonical load + -> asserted-scope concealment + -> current authorization + -> manager read or mutation + -> semantic audit + -> shared domain effects + -> surface presenter +``` + +Internal routes may omit the IP bucket or operation limit only through an explicit policy with a reason. Usage billing, storage quota, cost admission, and concurrency are separate from request-rate limiting. + +Never query API keys or sessions from the application layer. Never add fallback identity or authorization behavior. Propagate infrastructure failures instead of turning them into not-found or forbidden results. + +## Define the semantic operation once + +Add one stable entry to the target domain's operation registry: + +```ts +rename: defineWorkspaceOperation({ + id: 'widgets.rename', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], +}) +``` + +Do not create internal-, public-, or Copilot-specific versions of the same semantic operation. If two callers have materially different business or transactional semantics, define separate use cases and explain the distinction. + +Choose principal kinds from actual behavior. Do not accept every principal merely because the use case is shared. Workspace API keys have a write ceiling and cannot satisfy admin operations. The operation definition must fail fast when its role, workspace-key policy, and principal kinds disagree. + +Route declarations, tool adapters, and use cases must use the same literal operation. Runtime operation selection is permitted only from a trusted, code-defined registry. Never accept an operation ID or permission tag from an HTTP body, model argument, or other untrusted input. + +## Implement the application use case + +Use `defineAuthorizedWorkspaceUseCase` directly or a thin domain binding that supplies domain-specific authorization options: + +```ts +export const renameWidget = defineAuthorizedWorkspaceUseCase({ + operation: widgetOperations.rename, + resolveContext: ({ input }: { input: RenameWidgetInput }) => + loadCanonicalWidgetContext(input.id, input.assertedWorkspaceId), + authorizationOptions: { delegation: widgetDelegationPolicy }, + execute: async ({ input, context }) => renameWidgetRecord({ + workspaceId: context.workspaceId, + widgetId: context.resourceId, + name: input.name, + }), + projectAudit: ({ result }) => ({ + action: AuditAction.WIDGET_UPDATED, + resourceType: AuditResourceType.WIDGET, + resourceId: result.id, + resourceName: result.name, + }), + afterSuccess: ({ context }) => notifyWidgetsChanged(context.workspaceId), +}) +``` + +Adapt the example to the domain's real authorization options; do not copy invented field names. + +The wrapper must own this lifecycle: + +1. Reject disallowed principal kinds before protected loading. +2. Load canonical context and conceal asserted-scope mismatches as required. +3. Authorize the operation using current policy state. +4. Execute the manager or repository primitive. +5. Project audit from authoritative results. +6. Await shared post-success effects. + +Do not call shared authorization, principal audit attribution, or `recordAudit` manually from an ordinary migrated use-case body. Use `projectAudit` only when the operation has semantic audit. Return no audit entries for authoritative no-ops. Keep product analytics such as `captureServerEvent` surface-specific through the adapter's success hook. + +Inspect legacy orchestration before reusing it. If it already authorizes, audits, notifies, or captures analytics, call a lower-level primitive or remove duplicate responsibility for migrated callers. + +## Adapt internal APIs + +Use `defineInternalJsonRoute` for ordinary JSON routes. Explicitly declare the contract, session authentication policy, semantic operation, rate policy, error policy, input mapping, use case, and presenter when the wire result differs. + +The internal adapter owns session authentication and internal response envelopes. It must not implement workspace authorization. Preserve internal-only analytics through `onSuccess` after application success. + +Keep the route module declarative. If several internal routes repeat authentication, parsing, error rendering, or response construction, improve the shared internal route builder instead of adding a domain-specific route wrapper. + +## Adapt public or versioned APIs + +Use the appropriate public/versioned route builder, such as `defineV2JsonRoute`, with API-key authentication, explicit operation rate policy, rollout policy, external error projection, and an external presenter. + +Authentication and HTTP formatting may differ from internal APIs; authorization and business behavior must not. Rate-limit using the credential or principal subject, never a billed owner. Resolve billing attribution only for billing, quota, or legacy required-user fields. + +Keep surface contracts separate when their wire shapes differ. Reuse shared primitive schemas and domain validators for invariants such as IDs, names, bounds, and formats. Do not maintain duplicate internal and external schemas merely because the routes are separate; import the same schema when the wire shape is genuinely identical. Never cast one surface response into another. + +Keep v1 middleware and routes unchanged unless explicitly included. + +## Adapt Copilot + +Create one domain-level Copilot application adapter instead of constructing delegated principals in every tool: + +```ts +executeCopilotWidgetUseCase(context, renameWidget, input, { resourceId }) +``` + +That adapter must: + +- Require a trusted server-authored Copilot execution marker. +- Require the authenticated subject, canonical workspace, tool-call or execution identity, and required audience or lifecycle scope. +- Construct the shared delegated `Principal` in one place. +- Optionally bind the canonical resource scope after trusted resolution. +- Verify that the use case exposes a registered code-defined operation. +- Call the application use case directly. + +Never construct authoritative delegation from model-provided workspace IDs, user IDs, operation IDs, resource scope, or permission tags. Model arguments are requested targets only and must be checked against trusted execution context and canonical data. + +Tool handlers own argument aliases, resumable legacy names, abort checks, and tool-specific presentation. They must not query managers directly for protected operations or manually authorize. + +A Copilot reference helper may translate a path to a resource only by calling an authorized application resolver under the intended semantic operation. Passing a code-defined operation object is acceptable; passing a model-provided operation string is not. Reauthorizing during later execution is safe but redundant. When resolution and execution form one business operation, need a consistent snapshot, or appear repeatedly together, prefer a top-level application use case such as `renameWidgetByReference`. + +Special composition roots may resolve one principal and deliberately thread it through several application calls or lower-level admission stages. Keep this exceptional and explicit; ordinary tools should use the shared execution adapter. + +Map expected typed errors to safe tool results. Unknown errors must become generic system/retryable messages while retaining full causes in server logs. Never return raw database or storage errors to the model. + +## Adapt other internal or external tools + +Treat every tool runtime as a surface adapter: + +- Normalize its already-authenticated execution context into an existing `Principal` through one shared adapter for that runtime or domain. +- Call the same application use case used by HTTP and Copilot surfaces. +- Preserve the tool protocol's input, output, retry, and cancellation semantics. +- Keep authoritative workspace and subject scope server-authored. + +An internal caller is not automatically trusted to bypass authorization. It must supply an explicit principal or use a deliberately designed service/delegation principal. If the current principal model cannot express its authority, stop and extend the identity model intentionally; do not fall back to an owner, uploader, creator, or arbitrary user ID. + +External tool endpoints authenticate at their adapter exactly like public APIs. Do not authenticate again inside the application use case. + +## Preserve identity and attribution + +- Session and personal-key principals authorize through current human workspace permission. +- Personal API keys also respect the workspace's personal-key policy. +- Workspace keys authorize as the workspace under explicit operation policy and the write ceiling, independent of creator membership. +- Delegated principals re-check the current subject and their workspace, audience, expiry, execution, and resource scope. +- Billing owners are attribution for billing or legacy required columns only, never authorization, rate identity, delegated identity, audit actor, or human analytics identity. +- Preserve structured `PrincipalActor` metadata in semantic audit. + +If a required legacy user column cannot represent the real actor, label the compatibility attribution explicitly. Never pretend it is the acting human. + +## Handle special operations explicitly + +Do not force these through an ordinary JSON migration: + +- Upload or multipart lifecycles: bind immutable credential identity, reauthorize control legs and finalization, and make durable completion idempotent. +- Large bodies: authenticate and perform cheap admission before bounded buffering. +- Binary or streaming responses: use binary/stream builders and typed descriptors. +- Bulk or recursive operations: deduplicate and cap inputs and expansion, load all resources canonically, and define atomic versus best-effort behavior. +- Polymorphic tools: select the semantic operation only after trusted target-kind resolution; do not route unrelated branches through one domain registry. +- Multi-resource transactions: keep canonical scope predicates and derive audit from authoritative affected rows. + +Stop and report a missing design rather than weakening identity, authorization, limits, or errors. + +## Test the complete matrix + +Add focused tests for every migrated surface and principal kind allowed by the operation: + +- Application: allowed and disallowed roles, principal-kind rejection before canonical loading, workspace assertion mismatch, delegated scope, not found, conflict, no-op, and infrastructure propagation. +- Repository: canonical active lookup, workspace-predicated writes, archived resources, authoritative affected rows, and database error propagation. +- Internal API: authentication before parsing, exact contract, typed errors, and surface analytics only after success. +- Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. +- Copilot or tools: trusted context, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. +- Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. + +Run at minimum: + +```bash +bunx vitest run +bunx biome check +bunx turbo run type-check --filter=sim --filter=@sim/auth +bun run check:api-validation:strict +git diff --check +``` + +Do not claim a check passed unless it completed successfully. + +## Work safely in parallel + +- Assign non-overlapping route modules and caller sets. Two methods in one route file are one ownership unit. +- Treat operation registries, contract families, route policies, and shared surface adapters as merge hotspots. +- Keep shared core foundations owned by one task; ordinary domain migrations should consume them without modifying them. +- Preserve unrelated working-tree changes. Never stage proposal docs, lockfile drift, or another agent's edits. +- Do not commit, push, or open a PR unless requested. + +## Hand off + +Report: + +1. Semantic operation, role, workspace-key policy, and principal kinds. +2. Migrated, deferred, and non-goal entry points. +3. Behavior preserved per internal, public, Copilot, and other tool surface. +4. Identity construction and authoritative scope source for each surface. +5. Files changed and shared merge hotspots. +6. Tests and checks run with results. +7. Remaining risks or blockers. Fail fast when an invariant could not be implemented. diff --git a/.claude/commands/ship.md b/.claude/commands/ship.md index 6b673b18f8e..c4bb336288b 100644 --- a/.claude/commands/ship.md +++ b/.claude/commands/ship.md @@ -65,11 +65,12 @@ When the user runs `/ship`: exit 1 } rm -f /tmp/ship-audit-results - for s in check:boundaries check:api-validation:strict check:desktop-bridge check:desktop-ipc \ + for s in check:boundaries check:api-validation:strict check:openapi \ + check:desktop-bridge check:desktop-ipc \ check:utils check:zustand-v5 \ check:react-query check:client-boundary check:bare-icons check:icon-paths \ check:realtime-prune check:tool-registry-boundary check:tool-request-boundary \ - tool-metadata:check \ + check:sql-date-binding tool-metadata:check \ integration-catalog:check skills:check agent-stream-docs:check; do ( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) & done diff --git a/.cursor/commands/migrate-application-operation.md b/.cursor/commands/migrate-application-operation.md new file mode 100644 index 00000000000..f8c67ba42cd --- /dev/null +++ b/.cursor/commands/migrate-application-operation.md @@ -0,0 +1,286 @@ +# Migrate Application Operation + +Migrate one bounded semantic operation at a time. Share authorization and business behavior without forcing internal APIs, public APIs, Copilot, and other tools to share authentication, input schemas, or response shapes. + +## Enforce the application boundary + +Apply this invariant: + +> Every real operation on persisted or protected data enters through an authorized application use case. + +This includes mutations, content and metadata reads, canonical resource lookup, and reference-to-resource resolution when the lookup is authorization-sensitive. + +Surface helpers may: + +- Normalize an already-authenticated surface context into a `Principal`. +- Translate aliases or wire arguments into application input. +- Select a code-defined operation and application use case. +- Call the application use case. +- Translate typed results and errors into the surface contract. + +Surface helpers must not: + +- Query databases or storage. +- Decide workspace or resource authorization. +- Implement business transactions. +- Record semantic audit or shared domain notifications. +- Infer authoritative identity, workspace, audience, or scope from untrusted arguments. +- Substitute billing attribution for identity. + +A helper that resolves a path is valid only when the actual protected lookup runs through an authorized application resolver. If a helper begins doing real data work, move that work into an application use case. + +## Read the foundation first + +Read these files completely before editing: + +- `packages/auth/src/principal.ts` +- `apps/sim/lib/core/application/operation.ts` +- `apps/sim/lib/core/application/workspace-operation.ts` +- `apps/sim/lib/core/application/workspace-authorization.ts` +- `apps/sim/lib/core/application/authorized-workspace-use-case.ts` +- `apps/sim/lib/api/server/routes/internal-json-route.ts` +- `apps/sim/lib/api/server/routes/v2-json-route.ts` + +Use the file domain only as a representative golden slice: + +- `apps/sim/lib/workspace-files/application/operations.ts` +- `apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts` +- `apps/sim/lib/workspace-files/application/rename-workspace-file.ts` +- `apps/sim/lib/copilot/application/execute-file-use-case.ts` +- `apps/sim/lib/copilot/auth/file-delegation.ts` + +Then read the target domain's operation registry, application code, repositories, contracts, adapters, aliases, resume paths, and focused tests. Fail immediately if the shared foundation is absent. Do not recreate it inside the domain. + +## Bound the migration + +Inventory every entry point for the behavior before editing: + +- Internal HTTP routes and contracts. +- Public or versioned API routes and contracts. +- Copilot tools, aliases, resume paths, and polymorphic branches. +- Other tool servers, workflow executors, jobs, or service callers. +- Current authentication, authorization, workspace assertions, and concealment. +- Manager or orchestration call chains. +- Audit, notification, analytics, and billing side effects. +- Error/status/result behavior. +- Rate-limit identity, rollout gates, quota, and concurrency admission. + +Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent operations merely because they share a module. Do not modify v1 unless the request explicitly includes it. + +Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. + +## Keep the layers distinct + +Use these responsibilities: + +1. Authentication adapter: verify the surface credential or trusted execution context and construct a `Principal`. +2. Route or tool adapter: select rate policy, parse its contract, translate input, call the application use case, and render its own result. +3. Application use case: load canonical context, compare asserted scope, authorize the semantic operation, execute business behavior, project semantic audit, and trigger shared domain effects. +4. Manager or repository: perform database and storage reads or writes using canonical identifiers and scope. Never accept credentials or principals. +5. Presenter: return only the surface success body or typed binary descriptor. Never construct auth, rate, or error behavior. + +For ordinary public JSON routes, preserve this order: + +```text +IP abuse limit + -> authenticate + -> build Principal + -> operation rate limit + -> parse surface contract + -> application use case + -> canonical load + -> asserted-scope concealment + -> current authorization + -> manager read or mutation + -> semantic audit + -> shared domain effects + -> surface presenter +``` + +Internal routes may omit the IP bucket or operation limit only through an explicit policy with a reason. Usage billing, storage quota, cost admission, and concurrency are separate from request-rate limiting. + +Never query API keys or sessions from the application layer. Never add fallback identity or authorization behavior. Propagate infrastructure failures instead of turning them into not-found or forbidden results. + +## Define the semantic operation once + +Add one stable entry to the target domain's operation registry: + +```ts +rename: defineWorkspaceOperation({ + id: 'widgets.rename', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], +}) +``` + +Do not create internal-, public-, or Copilot-specific versions of the same semantic operation. If two callers have materially different business or transactional semantics, define separate use cases and explain the distinction. + +Choose principal kinds from actual behavior. Do not accept every principal merely because the use case is shared. Workspace API keys have a write ceiling and cannot satisfy admin operations. The operation definition must fail fast when its role, workspace-key policy, and principal kinds disagree. + +Route declarations, tool adapters, and use cases must use the same literal operation. Runtime operation selection is permitted only from a trusted, code-defined registry. Never accept an operation ID or permission tag from an HTTP body, model argument, or other untrusted input. + +## Implement the application use case + +Use `defineAuthorizedWorkspaceUseCase` directly or a thin domain binding that supplies domain-specific authorization options: + +```ts +export const renameWidget = defineAuthorizedWorkspaceUseCase({ + operation: widgetOperations.rename, + resolveContext: ({ input }: { input: RenameWidgetInput }) => + loadCanonicalWidgetContext(input.id, input.assertedWorkspaceId), + authorizationOptions: { delegation: widgetDelegationPolicy }, + execute: async ({ input, context }) => renameWidgetRecord({ + workspaceId: context.workspaceId, + widgetId: context.resourceId, + name: input.name, + }), + projectAudit: ({ result }) => ({ + action: AuditAction.WIDGET_UPDATED, + resourceType: AuditResourceType.WIDGET, + resourceId: result.id, + resourceName: result.name, + }), + afterSuccess: ({ context }) => notifyWidgetsChanged(context.workspaceId), +}) +``` + +Adapt the example to the domain's real authorization options; do not copy invented field names. + +The wrapper must own this lifecycle: + +1. Reject disallowed principal kinds before protected loading. +2. Load canonical context and conceal asserted-scope mismatches as required. +3. Authorize the operation using current policy state. +4. Execute the manager or repository primitive. +5. Project audit from authoritative results. +6. Await shared post-success effects. + +Do not call shared authorization, principal audit attribution, or `recordAudit` manually from an ordinary migrated use-case body. Use `projectAudit` only when the operation has semantic audit. Return no audit entries for authoritative no-ops. Keep product analytics such as `captureServerEvent` surface-specific through the adapter's success hook. + +Inspect legacy orchestration before reusing it. If it already authorizes, audits, notifies, or captures analytics, call a lower-level primitive or remove duplicate responsibility for migrated callers. + +## Adapt internal APIs + +Use `defineInternalJsonRoute` for ordinary JSON routes. Explicitly declare the contract, session authentication policy, semantic operation, rate policy, error policy, input mapping, use case, and presenter when the wire result differs. + +The internal adapter owns session authentication and internal response envelopes. It must not implement workspace authorization. Preserve internal-only analytics through `onSuccess` after application success. + +Keep the route module declarative. If several internal routes repeat authentication, parsing, error rendering, or response construction, improve the shared internal route builder instead of adding a domain-specific route wrapper. + +## Adapt public or versioned APIs + +Use the appropriate public/versioned route builder, such as `defineV2JsonRoute`, with API-key authentication, explicit operation rate policy, rollout policy, external error projection, and an external presenter. + +Authentication and HTTP formatting may differ from internal APIs; authorization and business behavior must not. Rate-limit using the credential or principal subject, never a billed owner. Resolve billing attribution only for billing, quota, or legacy required-user fields. + +Keep surface contracts separate when their wire shapes differ. Reuse shared primitive schemas and domain validators for invariants such as IDs, names, bounds, and formats. Do not maintain duplicate internal and external schemas merely because the routes are separate; import the same schema when the wire shape is genuinely identical. Never cast one surface response into another. + +Keep v1 middleware and routes unchanged unless explicitly included. + +## Adapt Copilot + +Create one domain-level Copilot application adapter instead of constructing delegated principals in every tool: + +```ts +executeCopilotWidgetUseCase(context, renameWidget, input, { resourceId }) +``` + +That adapter must: + +- Require a trusted server-authored Copilot execution marker. +- Require the authenticated subject, canonical workspace, tool-call or execution identity, and required audience or lifecycle scope. +- Construct the shared delegated `Principal` in one place. +- Optionally bind the canonical resource scope after trusted resolution. +- Verify that the use case exposes a registered code-defined operation. +- Call the application use case directly. + +Never construct authoritative delegation from model-provided workspace IDs, user IDs, operation IDs, resource scope, or permission tags. Model arguments are requested targets only and must be checked against trusted execution context and canonical data. + +Tool handlers own argument aliases, resumable legacy names, abort checks, and tool-specific presentation. They must not query managers directly for protected operations or manually authorize. + +A Copilot reference helper may translate a path to a resource only by calling an authorized application resolver under the intended semantic operation. Passing a code-defined operation object is acceptable; passing a model-provided operation string is not. Reauthorizing during later execution is safe but redundant. When resolution and execution form one business operation, need a consistent snapshot, or appear repeatedly together, prefer a top-level application use case such as `renameWidgetByReference`. + +Special composition roots may resolve one principal and deliberately thread it through several application calls or lower-level admission stages. Keep this exceptional and explicit; ordinary tools should use the shared execution adapter. + +Map expected typed errors to safe tool results. Unknown errors must become generic system/retryable messages while retaining full causes in server logs. Never return raw database or storage errors to the model. + +## Adapt other internal or external tools + +Treat every tool runtime as a surface adapter: + +- Normalize its already-authenticated execution context into an existing `Principal` through one shared adapter for that runtime or domain. +- Call the same application use case used by HTTP and Copilot surfaces. +- Preserve the tool protocol's input, output, retry, and cancellation semantics. +- Keep authoritative workspace and subject scope server-authored. + +An internal caller is not automatically trusted to bypass authorization. It must supply an explicit principal or use a deliberately designed service/delegation principal. If the current principal model cannot express its authority, stop and extend the identity model intentionally; do not fall back to an owner, uploader, creator, or arbitrary user ID. + +External tool endpoints authenticate at their adapter exactly like public APIs. Do not authenticate again inside the application use case. + +## Preserve identity and attribution + +- Session and personal-key principals authorize through current human workspace permission. +- Personal API keys also respect the workspace's personal-key policy. +- Workspace keys authorize as the workspace under explicit operation policy and the write ceiling, independent of creator membership. +- Delegated principals re-check the current subject and their workspace, audience, expiry, execution, and resource scope. +- Billing owners are attribution for billing or legacy required columns only, never authorization, rate identity, delegated identity, audit actor, or human analytics identity. +- Preserve structured `PrincipalActor` metadata in semantic audit. + +If a required legacy user column cannot represent the real actor, label the compatibility attribution explicitly. Never pretend it is the acting human. + +## Handle special operations explicitly + +Do not force these through an ordinary JSON migration: + +- Upload or multipart lifecycles: bind immutable credential identity, reauthorize control legs and finalization, and make durable completion idempotent. +- Large bodies: authenticate and perform cheap admission before bounded buffering. +- Binary or streaming responses: use binary/stream builders and typed descriptors. +- Bulk or recursive operations: deduplicate and cap inputs and expansion, load all resources canonically, and define atomic versus best-effort behavior. +- Polymorphic tools: select the semantic operation only after trusted target-kind resolution; do not route unrelated branches through one domain registry. +- Multi-resource transactions: keep canonical scope predicates and derive audit from authoritative affected rows. + +Stop and report a missing design rather than weakening identity, authorization, limits, or errors. + +## Test the complete matrix + +Add focused tests for every migrated surface and principal kind allowed by the operation: + +- Application: allowed and disallowed roles, principal-kind rejection before canonical loading, workspace assertion mismatch, delegated scope, not found, conflict, no-op, and infrastructure propagation. +- Repository: canonical active lookup, workspace-predicated writes, archived resources, authoritative affected rows, and database error propagation. +- Internal API: authentication before parsing, exact contract, typed errors, and surface analytics only after success. +- Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. +- Copilot or tools: trusted context, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. +- Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. + +Run at minimum: + +```bash +bunx vitest run +bunx biome check +bunx turbo run type-check --filter=sim --filter=@sim/auth +bun run check:api-validation:strict +git diff --check +``` + +Do not claim a check passed unless it completed successfully. + +## Work safely in parallel + +- Assign non-overlapping route modules and caller sets. Two methods in one route file are one ownership unit. +- Treat operation registries, contract families, route policies, and shared surface adapters as merge hotspots. +- Keep shared core foundations owned by one task; ordinary domain migrations should consume them without modifying them. +- Preserve unrelated working-tree changes. Never stage proposal docs, lockfile drift, or another agent's edits. +- Do not commit, push, or open a PR unless requested. + +## Hand off + +Report: + +1. Semantic operation, role, workspace-key policy, and principal kinds. +2. Migrated, deferred, and non-goal entry points. +3. Behavior preserved per internal, public, Copilot, and other tool surface. +4. Identity construction and authoritative scope source for each surface. +5. Files changed and shared merge hotspots. +6. Tests and checks run with results. +7. Remaining risks or blockers. Fail fast when an invariant could not be implemented. diff --git a/.cursor/commands/ship.md b/.cursor/commands/ship.md index 77ec67d04a8..c5d92d97ae4 100644 --- a/.cursor/commands/ship.md +++ b/.cursor/commands/ship.md @@ -60,11 +60,12 @@ When the user runs `/ship`: exit 1 } rm -f /tmp/ship-audit-results - for s in check:boundaries check:api-validation:strict check:desktop-bridge check:desktop-ipc \ + for s in check:boundaries check:api-validation:strict check:openapi \ + check:desktop-bridge check:desktop-ipc \ check:utils check:zustand-v5 \ check:react-query check:client-boundary check:bare-icons check:icon-paths \ check:realtime-prune check:tool-registry-boundary check:tool-request-boundary \ - tool-metadata:check \ + check:sql-date-binding tool-metadata:check \ integration-catalog:check skills:check agent-stream-docs:check; do ( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) & done diff --git a/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts index 6ed8d6d1e35..38cd89b45c8 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts @@ -2,9 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { completeInternalFileUploadContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { completeUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { finalizeUploadPurpose } from '@/app/api/files/uploads/finalizers' -import { reauthorizeUploadPurpose } from '@/app/api/files/uploads/purposes' +import { completeInternalUploadSession } from '@/lib/uploads/upload-session/application' import { requireUploadUser, toInternalUploadSession, @@ -22,16 +20,15 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Uploa if (!parsed.success) return parsed.response try { - const session = await getOwnedUploadSession({ - uploadId: parsed.data.params.uploadId, - uploadToken: parsed.data.headers['upload-token'], - userId: actor.id, - }) - await reauthorizeUploadPurpose(actor.id, session) - const completed = await completeUploadSession({ - session, - finalize: (claimed) => finalizeUploadPurpose({ session: claimed, actor, request }), - }) + const completed = await completeInternalUploadSession( + actor.principal, + { + uploadId: parsed.data.params.uploadId, + uploadToken: parsed.data.headers['upload-token'], + actor, + }, + request + ) return NextResponse.json({ data: toInternalUploadSession(completed.session, completed.value), }) diff --git a/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts index b707f196567..29491528450 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts @@ -2,8 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { createInternalFileUploadPartUrlsContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createUploadPartUrls, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { reauthorizeUploadPurpose } from '@/app/api/files/uploads/purposes' +import { issueInternalUploadPartUrls } from '@/lib/uploads/upload-session/application' import { requireUploadUser, uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' interface UploadRouteParams { @@ -17,18 +16,16 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Uploa if (!parsed.success) return parsed.response try { - const session = await getOwnedUploadSession({ - uploadId: parsed.data.params.uploadId, - uploadToken: parsed.data.headers['upload-token'], - userId: actor.id, - }) - await reauthorizeUploadPurpose(actor.id, session) - const parts = await createUploadPartUrls({ - session, - partNumbers: parsed.data.body.partNumbers, - localOrigin: request.nextUrl.origin, - }) - return NextResponse.json({ data: { parts } }) + const parts = await issueInternalUploadPartUrls( + actor.principal, + { + uploadId: parsed.data.params.uploadId, + uploadToken: parsed.data.headers['upload-token'], + partNumbers: parsed.data.body.partNumbers, + }, + request + ) + return NextResponse.json({ data: parts }) } catch (error) { const classified = uploadSessionErrorResponse(error) if (classified) return classified diff --git a/apps/sim/app/api/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/route.ts index 8887bfbb593..162620470fe 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/route.ts @@ -2,8 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { abortInternalFileUploadContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { reauthorizeUploadPurpose } from '@/app/api/files/uploads/purposes' +import { abortInternalUploadSession } from '@/lib/uploads/upload-session/application' import { requireUploadUser, toInternalUploadSession, @@ -21,13 +20,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Upl if (!parsed.success) return parsed.response try { - const session = await getOwnedUploadSession({ + const aborted = await abortInternalUploadSession(actor.principal, { uploadId: parsed.data.params.uploadId, uploadToken: parsed.data.headers['upload-token'], - userId: actor.id, }) - await reauthorizeUploadPurpose(actor.id, session) - const aborted = await abortUploadSession(session) return NextResponse.json({ data: toInternalUploadSession(aborted, null) }) } catch (error) { const classified = uploadSessionErrorResponse(error) diff --git a/apps/sim/app/api/files/uploads/finalizers.test.ts b/apps/sim/app/api/files/uploads/finalizers.test.ts index a5ed437fe66..37434dbac84 100644 --- a/apps/sim/app/api/files/uploads/finalizers.test.ts +++ b/apps/sim/app/api/files/uploads/finalizers.test.ts @@ -73,6 +73,7 @@ import { finalizeUploadPurpose } from '@/app/api/files/uploads/finalizers' const now = new Date('2026-08-04T12:00:00.000Z') const actor = { id: 'user-1', name: 'Ada', email: 'ada@example.com' } +const principal = { kind: 'session' as const, userId: actor.id, sessionId: 'session-1' } const metadataRow = { id: 'file-1', key: 'workspace-logos/upload-1-logo.png', @@ -147,8 +148,8 @@ describe('upload purpose finalizers', () => { mockInsertReturning.mockResolvedValueOnce([metadataRow]) const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete') - const first = await finalizeUploadPurpose({ session: uploadSession, actor, request }) - const retry = await finalizeUploadPurpose({ session: uploadSession, actor, request }) + const first = await finalizeUploadPurpose({ session: uploadSession, actor, principal, request }) + const retry = await finalizeUploadPurpose({ session: uploadSession, actor, principal, request }) expect(first.value).toEqual({ path: `/api/files/serve/s3/${encodeURIComponent(metadataRow.key)}?context=workspace-logos`, @@ -169,6 +170,7 @@ describe('upload purpose finalizers', () => { finalizeUploadPurpose({ session: uploadSession, actor, + principal, request: new NextRequest('http://localhost/api/files/uploads/upload-1/complete'), }) ).rejects.toMatchObject({ code: 'conflict' }) @@ -185,6 +187,7 @@ describe('upload purpose finalizers', () => { finalizeUploadPurpose({ session: uploadSession, actor, + principal, request: new NextRequest('http://localhost/api/files/uploads/upload-1/complete'), }) ).rejects.toMatchObject({ code: 'conflict' }) @@ -208,10 +211,23 @@ describe('upload purpose finalizers', () => { mockGetWorkspaceFile.mockResolvedValue(workspaceFile) const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete') - const first = await finalizeUploadPurpose({ session: workspaceSession, actor, request }) - const retry = await finalizeUploadPurpose({ session: workspaceSession, actor, request }) + const first = await finalizeUploadPurpose({ + session: workspaceSession, + actor, + principal, + request, + }) + const retry = await finalizeUploadPurpose({ + session: workspaceSession, + actor, + principal, + request, + }) expect(retry.value).toEqual(first.value) + expect(mockRegisterUploadedWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ uploadSessionId: workspaceSession.id }) + ) expect(mockNotifyWorkspaceFilesChanged).toHaveBeenCalledTimes(1) expect(mockRecordAudit).toHaveBeenCalledTimes(1) expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1) @@ -236,6 +252,7 @@ describe('upload purpose finalizers', () => { finalizeUploadPurpose({ session: workspaceSession, actor, + principal, request: new NextRequest('http://localhost/api/files/uploads/upload-1/complete'), }) ).rejects.toMatchObject({ code: 'conflict' }) @@ -243,4 +260,38 @@ describe('upload purpose finalizers', () => { expect(mockRecordAudit).not.toHaveBeenCalled() expect(mockCaptureServerEvent).not.toHaveBeenCalled() }) + + it('uses the current billing owner only for workspace-key legacy attribution', async () => { + const workspaceSession = { + ...uploadSession, + purpose: 'workspace_file' as const, + storageContext: 'workspace' as const, + storageKey: workspaceFile.key, + finalKey: workspaceFile.key, + fileName: workspaceFile.name, + contentType: workspaceFile.type, + } + mockRegisterUploadedWorkspaceFile.mockResolvedValueOnce({ + file: { id: workspaceFile.id }, + created: true, + }) + mockGetWorkspaceFile.mockResolvedValue(workspaceFile) + const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete') + + await finalizeUploadPurpose({ + session: workspaceSession, + actor: { id: 'current-owner' }, + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + request, + }) + + expect(mockRegisterUploadedWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'current-owner' }) + ) + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/files/uploads/finalizers.ts b/apps/sim/app/api/files/uploads/finalizers.ts index 3c998f1be30..a59eec3beb0 100644 --- a/apps/sim/app/api/files/uploads/finalizers.ts +++ b/apps/sim/app/api/files/uploads/finalizers.ts @@ -1,10 +1,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' import { db } from '@sim/db' import { workspaceFiles } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { eq, sql } from 'drizzle-orm' -import type { NextRequest } from 'next/server' import type { V2File } from '@/lib/api/contracts/v2/files' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' import { captureServerEvent } from '@/lib/posthog/server' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { getServeStoragePrefix } from '@/lib/uploads/config' @@ -51,7 +52,9 @@ interface FinalizedWorkspaceFile { interface FinalizeUploadPurposeParams { session: UploadSessionRecord actor: UploadActor - request: NextRequest + request: OrchestrationRequestContext + principal: Principal + authorizeBeforeRegistration?: () => Promise } interface FinalizedUploadPurpose { @@ -79,10 +82,18 @@ export async function finalizeUploadPurpose({ session, actor, request, + principal, + authorizeBeforeRegistration, }: FinalizeUploadPurposeParams): Promise { switch (session.purpose) { case 'workspace_file': - return finalizeInternalWorkspaceFile(session, actor, request) + return finalizeInternalWorkspaceFile( + session, + actor, + request, + principal, + authorizeBeforeRegistration + ) case 'profile_picture': return { value: storedAssetResult(session, 'profile-pictures') } case 'workspace_logo': @@ -100,12 +111,30 @@ export async function finalizeUploadPurpose({ } } +export async function loadCompletedUploadPurpose( + session: UploadSessionRecord +): Promise { + if (session.purpose !== 'workspace_file') { + throw new Error(`Upload purpose ${session.purpose} has no durable file result`) + } + return toV2File(await loadCompletedWorkspaceFileUpload(session)) +} + async function finalizeInternalWorkspaceFile( session: UploadSessionRecord, actor: UploadActor, - request: NextRequest + request: OrchestrationRequestContext, + principal: Principal, + authorizeBeforeRegistration?: () => Promise ): Promise { - const finalized = await finalizeWorkspaceFileUpload({ session, actor, request, source: 'ui' }) + const finalized = await finalizeWorkspaceFileUpload({ + session, + actor, + request, + source: 'ui', + principal, + authorizeBeforeRegistration, + }) return { value: await toV2File(finalized.file), completedFileId: finalized.file.id, @@ -119,15 +148,24 @@ async function finalizeInternalWorkspaceFile( export async function finalizeWorkspaceFileUpload(params: { session: UploadSessionRecord actor: UploadActor - request: NextRequest + request: OrchestrationRequestContext source: 'api' | 'ui' + principal: Principal + authorizeBeforeRegistration?: () => Promise }): Promise { - const { session, actor, request, source } = params + const { session, actor, request, source, principal, authorizeBeforeRegistration } = params const workspaceId = requireWorkspaceId(session) const metadata = session.metadata as { folderId?: string | null } + if (session.completedFileId) { + await authorizeBeforeRegistration?.() + return { file: await loadCompletedWorkspaceFileUpload(session), created: false } + } + await authorizeBeforeRegistration?.() + const legacyAttributionUserId = principal.kind === 'workspace_api_key' ? actor.id : session.userId const registered = await registerUploadedWorkspaceFile({ workspaceId, - userId: session.userId, + userId: legacyAttributionUserId, + uploadSessionId: session.id, key: session.storageKey, originalName: session.fileName, contentType: session.contentType, @@ -145,33 +183,56 @@ export async function finalizeWorkspaceFileUpload(params: { } if (registered.created) { await notifyWorkspaceFilesChanged(workspaceId) - captureServerEvent( - actor.id, - 'file_uploaded', - { workspace_id: workspaceId, file_type: session.contentType }, - { groups: { workspace: workspaceId } } - ) + if (principal.kind !== 'workspace_api_key') { + captureServerEvent( + actor.id, + 'file_uploaded', + { workspace_id: workspaceId, file_type: session.contentType }, + { groups: { workspace: workspaceId } } + ) + } + const auditAttribution = resolvePrincipalAuditAttribution(principal) recordAudit({ workspaceId, - actorId: actor.id, - actorName: actor.name, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName ?? actor.name, actorEmail: actor.email, action: AuditAction.FILE_UPLOADED, resourceType: AuditResourceType.FILE, resourceId: file.id, resourceName: file.name, description: `Uploaded file "${file.name}"${source === 'api' ? ' via API' : ''}`, - metadata: { fileSize: file.size, fileType: file.type }, + metadata: { + fileSize: file.size, + fileType: file.type, + actor: auditAttribution.actor, + }, request, }) } return { file, created: registered.created } } +export async function loadCompletedWorkspaceFileUpload( + session: UploadSessionRecord +): Promise { + const workspaceId = requireWorkspaceId(session) + if (!session.completedFileId) { + throw new Error('Workspace upload session has no completed file marker') + } + const durable = await getWorkspaceFile(workspaceId, session.completedFileId, { + includeDeleted: true, + throwOnError: true, + }) + if (!durable) throw new UploadSessionError('conflict', 'Completed workspace file not found') + if (durable.deletedAt) throw new UploadSessionError('conflict', 'Upload result was deleted') + return durable +} + async function finalizeWorkspaceLogo( session: UploadSessionRecord, actor: UploadActor, - request: NextRequest + request: OrchestrationRequestContext ): Promise { const workspaceId = requireWorkspaceId(session) const finalized = await insertOrLoadFileMetadata({ diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts index a67d2e3298c..9c8810563a4 100644 --- a/apps/sim/app/api/files/uploads/purposes.ts +++ b/apps/sim/app/api/files/uploads/purposes.ts @@ -1,13 +1,21 @@ +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' +import { and, eq, isNull } from 'drizzle-orm' import type { CreateInternalFileUploadBody } from '@/lib/api/contracts/upload-sessions' +import type { WorkspaceOperation } from '@/lib/core/application' import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' import { + assertUploadSessionAuthBinding, createUploadSession, UploadSessionError, type UploadSessionRecord, } from '@/lib/uploads/upload-session/service' import { isImageFileType } from '@/lib/uploads/utils/file-utils' import { validateAttachmentFileType } from '@/lib/uploads/utils/validation' +import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export type InternalUploadPurpose = CreateInternalFileUploadBody['purpose'] @@ -21,15 +29,21 @@ const INTERNAL_UPLOAD_PURPOSES = new Set([ ]) export async function createPurposeUploadSession( - userId: string, + principal: Principal, body: CreateInternalFileUploadBody, localOrigin: string ) { + const userId = await principalUserId( + principal, + 'workspaceId' in body ? body.workspaceId : undefined + ) validatePurposeFile(body) switch (body.purpose) { case 'workspace_file': { - await requireWorkspacePermission(userId, body.workspaceId, 'write') + const context = await loadWorkspaceAuthorizationContext(body.workspaceId) + if (!context) throw new UploadSessionError('not_found', 'Workspace not found') + await authorizeWorkspaceFileAccess(principal, fileOperations.uploadCreate, context) const folderId = await assertWorkspaceFileFolderTarget(body.workspaceId, body.folderId) return createUploadSession({ purpose: body.purpose, @@ -39,6 +53,7 @@ export async function createPurposeUploadSession( contentType: body.contentType, fileSize: body.size, metadata: { folderId }, + principal, localOrigin, }) } @@ -120,10 +135,41 @@ export async function reauthorizeUploadPurpose( } } +/** + * Re-authorizes a workspace-file control leg against the current principal and + * current workspace policy. Session metadata is only accepted after the + * immutable, server-authored credential binding matches. + */ +export async function reauthorizeWorkspaceUploadPurpose( + principal: Principal, + session: UploadSessionRecord, + operation: WorkspaceOperation = fileOperations.uploadComplete +): Promise { + if (session.purpose !== 'workspace_file' || !session.workspaceId) { + throw new UploadSessionError('not_found', 'Upload session not found') + } + assertUploadSessionAuthBinding(session, principal) + const context = await loadWorkspaceAuthorizationContext(session.workspaceId) + if (!context) throw new UploadSessionError('not_found', 'Upload session not found') + await authorizeWorkspaceFileAccess(principal, operation, { + workspaceId: context.workspaceId, + workspaceOrganizationId: context.workspaceOrganizationId, + allowPersonalApiKeys: context.allowPersonalApiKeys, + }) +} + export function isInternalUploadPurpose(purpose: string): purpose is InternalUploadPurpose { return INTERNAL_UPLOAD_PURPOSES.has(purpose as InternalUploadPurpose) } +/** Resolves the current billing owner only for legacy upload attribution fields. */ +export async function resolveUploadAttributionUserId( + principal: Principal, + workspaceId: string +): Promise { + return principalUserId(principal, workspaceId) +} + function validatePurposeFile(body: CreateInternalFileUploadBody): void { if (body.purpose === 'profile_picture' || body.purpose === 'workspace_logo') { if (!isImageFileType(body.contentType)) { @@ -186,3 +232,43 @@ function requireSessionScope(value: string | null, label = 'scope'): string { } return value } + +async function principalUserId(principal: Principal, workspaceId?: string): Promise { + switch (principal.kind) { + case 'session': + case 'personal_api_key': + return principal.userId + case 'workspace_api_key': + if (!workspaceId || principal.workspaceId !== workspaceId) { + throw new UploadSessionError('forbidden', 'Workspace API key cannot access this workspace') + } + { + const context = await loadWorkspaceAuthorizationContext(workspaceId) + if (!context?.billedAccountUserId) { + throw new Error('Workspace upload attribution requires a billing owner') + } + return context.billedAccountUserId + } + case 'delegated': + throw new UploadSessionError('forbidden', 'Delegated principals cannot create uploads') + } +} + +async function loadWorkspaceAuthorizationContext(workspaceId: string): Promise<{ + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} | null> { + const [row] = await db + .select({ + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + return row ?? null +} diff --git a/apps/sim/app/api/files/uploads/route.test.ts b/apps/sim/app/api/files/uploads/route.test.ts index 4ea8c61428a..f36e29862d1 100644 --- a/apps/sim/app/api/files/uploads/route.test.ts +++ b/apps/sim/app/api/files/uploads/route.test.ts @@ -7,6 +7,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetSession, mockCreateUploadSession, + mockCreateInternalPurposeUploadSession, + mockCompleteInternalUploadSession, mockGetOwnedUploadSession, mockCompleteUploadSession, mockGetUserEntityPermissions, @@ -14,6 +16,8 @@ const { } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockCreateUploadSession: vi.fn(), + mockCreateInternalPurposeUploadSession: vi.fn(), + mockCompleteInternalUploadSession: vi.fn(), mockGetOwnedUploadSession: vi.fn(), mockCompleteUploadSession: vi.fn(), mockGetUserEntityPermissions: vi.fn(), @@ -38,6 +42,13 @@ vi.mock('@/lib/uploads/upload-session/service', () => ({ abortUploadSession: vi.fn(), })) +vi.mock('@/lib/uploads/upload-session/application', () => ({ + createInternalPurposeUploadSession: mockCreateInternalPurposeUploadSession, + completeInternalUploadSession: mockCompleteInternalUploadSession, + issueInternalUploadPartUrls: vi.fn(), + abortInternalUploadSession: vi.fn(), +})) + vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) @@ -98,12 +109,12 @@ function session(overrides: Record = {}) { describe('/api/files/uploads', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: actor }) + mockGetSession.mockResolvedValue({ user: actor, session: { id: 'session-1' } }) mockGetUserEntityPermissions.mockResolvedValue('admin') }) it('creates a purpose-scoped PUT session without exposing write capability in the session', async () => { - mockCreateUploadSession.mockResolvedValue({ + mockCreateInternalPurposeUploadSession.mockResolvedValue({ ...session(), transfer: { method: 'put', @@ -126,12 +137,10 @@ describe('/api/files/uploads', () => { const body = await response.json() expect(response.status).toBe(201) - expect(mockCreateUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ - purpose: 'profile_picture', - userId: actor.id, - localOrigin: 'http://localhost', - }) + expect(mockCreateInternalPurposeUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'session', userId: actor.id }), + expect.objectContaining({ purpose: 'profile_picture' }), + request ) expect(body.data).toMatchObject({ session: { @@ -148,7 +157,7 @@ describe('/api/files/uploads', () => { }) it('creates a PUT session for an empty workspace file', async () => { - mockCreateUploadSession.mockResolvedValue({ + mockCreateInternalPurposeUploadSession.mockResolvedValue({ ...session({ workspaceId: 'workspace-1', purpose: 'workspace_file', @@ -179,8 +188,10 @@ describe('/api/files/uploads', () => { const response = await createUpload(request) expect(response.status).toBe(201) - expect(mockCreateUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ purpose: 'workspace_file', fileSize: 0 }) + expect(mockCreateInternalPurposeUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'session' }), + expect.objectContaining({ purpose: 'workspace_file', size: 0 }), + request ) await expect(response.json()).resolves.toMatchObject({ data: { session: { purpose: 'workspace_file', size: 0 } }, @@ -188,7 +199,7 @@ describe('/api/files/uploads', () => { }) it('preserves the 5 GiB direct-to-storage limit for mothership attachments', async () => { - mockCreateUploadSession.mockResolvedValue({ + mockCreateInternalPurposeUploadSession.mockResolvedValue({ ...session({ workspaceId: 'workspace-1', purpose: 'mothership_attachment', @@ -216,11 +227,10 @@ describe('/api/files/uploads', () => { const response = await createUpload(request) expect(response.status).toBe(201) - expect(mockCreateUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ - purpose: 'mothership_attachment', - fileSize: MAX_WORKSPACE_FILE_SIZE, - }) + expect(mockCreateInternalPurposeUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'session' }), + expect.objectContaining({ purpose: 'mothership_attachment', size: MAX_WORKSPACE_FILE_SIZE }), + request ) }) @@ -240,7 +250,7 @@ describe('/api/files/uploads', () => { const response = await createUpload(request) expect(response.status).toBe(400) - expect(mockCreateUploadSession).not.toHaveBeenCalled() + expect(mockCreateInternalPurposeUploadSession).not.toHaveBeenCalled() }) it('reauthorizes a terminal request and returns only the terminal-safe session', async () => { @@ -259,7 +269,7 @@ describe('/api/files/uploads', () => { type: 'image/png', } mockGetOwnedUploadSession.mockReturnValue(logoSession) - mockCompleteUploadSession.mockResolvedValue({ + mockCompleteInternalUploadSession.mockResolvedValue({ session: { ...logoSession, status: 'completed', completedAt: now }, value: result, alreadyCompleted: false, @@ -275,9 +285,13 @@ describe('/api/files/uploads', () => { const body = await response.json() expect(response.status).toBe(200) - expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(actor.id, 'workspace', 'workspace-1') - expect(mockCompleteUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ session: logoSession }) + expect(mockCompleteInternalUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'session' }), + expect.objectContaining({ + uploadId: 'upload-1', + actor: expect.objectContaining({ id: actor.id }), + }), + request ) expect(body).toEqual({ data: expect.objectContaining({ @@ -302,6 +316,6 @@ describe('/api/files/uploads', () => { const response = await createUpload(request) expect(response.status).toBe(401) - expect(mockCreateUploadSession).not.toHaveBeenCalled() + expect(mockCreateInternalPurposeUploadSession).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/files/uploads/route.ts b/apps/sim/app/api/files/uploads/route.ts index 85752ea6e82..c7e4e0a56d9 100644 --- a/apps/sim/app/api/files/uploads/route.ts +++ b/apps/sim/app/api/files/uploads/route.ts @@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { createInternalFileUploadContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createPurposeUploadSession } from '@/app/api/files/uploads/purposes' +import { createInternalPurposeUploadSession } from '@/lib/uploads/upload-session/application' import { requireUploadUser, toInternalUploadSession, @@ -16,10 +16,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response try { - const created = await createPurposeUploadSession( - actor.id, + const created = await createInternalPurposeUploadSession( + actor.principal, parsed.data.body, - request.nextUrl.origin + request ) return NextResponse.json( { diff --git a/apps/sim/app/api/files/uploads/utils.ts b/apps/sim/app/api/files/uploads/utils.ts index ca8e9356cb3..e19ea1a9210 100644 --- a/apps/sim/app/api/files/uploads/utils.ts +++ b/apps/sim/app/api/files/uploads/utils.ts @@ -1,3 +1,4 @@ +import type { SessionPrincipal } from '@sim/auth/principal' import { NextResponse } from 'next/server' import { type InternalFileUploadSession, @@ -8,15 +9,25 @@ import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/or import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' import type { UploadActor, UploadPurposeResult } from '@/app/api/files/uploads/finalizers' -export async function requireUploadUser(): Promise { +export type AuthenticatedUploadActor = UploadActor & { principal: SessionPrincipal } + +export async function requireUploadUser(): Promise { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') + const principal: SessionPrincipal = { + kind: 'session', + userId: session.user.id, + sessionId, + } return { id: session.user.id, name: session.user.name, email: session.user.email, + principal, } } diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index a5f97e4b431..96e865cfaf6 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -167,7 +167,7 @@ const SAFE_INLINE_TYPES = new Set([ const FORCE_ATTACHMENT_EXTENSIONS = new Set(['html', 'htm', 'js', 'css', 'xml']) -function getSecureFileHeaders(filename: string, originalContentType: string) { +export function getSecureFileHeaders(filename: string, originalContentType: string) { const extension = filename.split('.').pop()?.toLowerCase() || '' if (FORCE_ATTACHMENT_EXTENSIONS.has(extension)) { diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 351d9f8d7d4..f22e484ee2c 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -125,6 +125,16 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ uploadWorkspaceFile: vi.fn(), })) +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, +})) + +vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ + readWorkspaceFileContent: { + execute: vi.fn(async () => ({ content: await mockFetchWorkspaceFileBuffer() })), + }, +})) + vi.mock('@/lib/uploads', () => ({ StorageService: { uploadFile: mockUploadFile, @@ -183,7 +193,13 @@ describe('Function Execute API Route', () => { url: '/api/files/view/existing', key: 'workspace/existing.png', }) - mockResolveWorkspaceFileReference.mockResolvedValue(null) + mockResolveWorkspaceFileReference.mockResolvedValue({ + id: 'wf_existing', + workspaceId: 'workspace-1', + name: 'existing.txt', + size: 0, + key: 'workspace/existing.txt', + }) mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.alloc(0)) mockValidateWorkspaceFileWriteTarget.mockImplementation(async ({ target }) => ({ mode: target.mode, @@ -1234,6 +1250,45 @@ describe('Function Execute API Route', () => { expect(data.output.result.message).toContain('/home/user/doc.md') }) + it('continues an overwrite when the advisory comparison fails', async () => { + envFlagsMock.isRemoteSandboxEnabled = true + const newContent = '# doc\nnew content\n' + mockExecuteInSandbox.mockResolvedValueOnce({ + result: 'done', + stdout: 'ok', + sandboxId: 'sandbox-123', + exportedFiles: { '/home/user/doc.md': newContent }, + }) + mockResolveWorkspaceFileReference.mockRejectedValueOnce( + new Error('comparison storage unavailable') + ) + + const response = await POST( + createMockRequest('POST', { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/doc.md', + mode: 'overwrite', + sandboxPath: '/home/user/doc.md', + mimeType: 'text/markdown', + }, + ], + }, + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1) + expect(data.output.result).toMatchObject({ unchanged: false }) + expect(data.output.result).not.toHaveProperty('previousSize') + }) + it('reports size, previousSize, and sha256 receipts on a successful overwrite export', async () => { envFlagsMock.isRemoteSandboxEnabled = true const newContent = '# doc\nnew content\n' diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 67e80f7cd7d..c8adac49106 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -1,3 +1,4 @@ +import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' @@ -78,15 +79,15 @@ import { MAX_SANDBOX_OUTPUT_BYTES, } from '@/lib/execution/remote-sandbox/output-limits' import { isExecutionResourceLimitError } from '@/lib/execution/resource-errors' -import { - fetchWorkspaceFileBuffer, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { mergeWorkspaceFileSecretProvenance, type WorkspaceFileSecretProvenance, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { getWorkflowById } from '@/lib/workflows/utils' +import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' import { @@ -1319,24 +1320,41 @@ async function appendPrivateResolvedSecretNames( * either a legitimately idempotent regeneration, or the incident signature of * code that never wrote to the declared sandboxPath (the file still holds the * mounted input). Only the model can tell those apart, so callers surface the - * fact loudly in the receipt instead of failing the write. Comparison failures - * never block the write; the current content is only downloaded when the sizes - * already match. + * fact loudly in the receipt instead of failing the write. Comparison is + * advisory and never blocks the authoritative write; the current content is + * only downloaded when the sizes already match. */ async function checkOverwriteTarget( + principal: Principal, workspaceId: string, targetPath: string, buffer: Buffer ): Promise<{ previousSize?: number; identical: boolean }> { try { - const existing = await resolveWorkspaceFileReference(workspaceId, targetPath) - if (!existing) return { identical: false } + const existing = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.updateContent, + workspaceId, + reference: targetPath, + }) if (existing.size !== buffer.length) { return { previousSize: existing.size, identical: false } } - const current = await fetchWorkspaceFileBuffer(existing) + const { content: current } = await readWorkspaceFileContent.execute({ + principal, + input: { + fileId: existing.id, + assertedWorkspaceId: workspaceId, + maxBytes: buffer.length, + }, + }) return { previousSize: existing.size, identical: current.equals(buffer) } - } catch { + } catch (error) { + logger.warn('Unable to compare workspace overwrite target before export', { + workspaceId, + targetPath, + error: getErrorMessage(error), + }) return { identical: false } } } @@ -1455,11 +1473,18 @@ async function maybeExportSandboxFileToWorkspace(args: { const mode = outputMode ?? (overwriteFileId ? 'overwrite' : 'create') const targetPath = mode === 'create' ? outputPath : overwriteFileId || outputPath + const principal = createWorkspaceFileDelegatedPrincipal({ + serviceId: 'executor', + subjectUserId: authUserId, + workspaceId: resolvedWorkspaceId, + delegationId: `function-execute:${routeContext.requestId}`, + executionId: routeContext.executionId, + }) let previousSize: number | undefined let unchanged = false if (mode === 'overwrite') { - const check = await checkOverwriteTarget(resolvedWorkspaceId, targetPath, fileBuffer) + const check = await checkOverwriteTarget(principal, resolvedWorkspaceId, targetPath, fileBuffer) previousSize = check.previousSize unchanged = check.identical } @@ -1468,7 +1493,7 @@ async function maybeExportSandboxFileToWorkspace(args: { const sha256 = sha256Hex(fileBuffer) const written = await writeWorkspaceFileByPath({ workspaceId: resolvedWorkspaceId, - userId: authUserId, + principal, target: { path: targetPath, mode, @@ -1656,13 +1681,20 @@ async function maybeExportSandboxFilesToWorkspace(args: { }) } + const principal = createWorkspaceFileDelegatedPrincipal({ + serviceId: 'executor', + subjectUserId: args.authUserId, + workspaceId: resolvedWorkspaceId, + delegationId: `function-execute:${args.routeContext.requestId}`, + executionId: args.routeContext.executionId, + }) let validationPaths: string[] try { const validations = await Promise.all( preparedFiles.map((prepared) => validateWorkspaceFileWriteTarget({ workspaceId: resolvedWorkspaceId, - userId: args.authUserId, + principal, target: prepared.target, }) ) @@ -1709,14 +1741,19 @@ async function maybeExportSandboxFilesToWorkspace(args: { let previousSize: number | undefined let unchanged = false if (prepared.target.mode === 'overwrite') { - const check = await checkOverwriteTarget(resolvedWorkspaceId, prepared.target.path, buffer) + const check = await checkOverwriteTarget( + principal, + resolvedWorkspaceId, + prepared.target.path, + buffer + ) previousSize = check.previousSize unchanged = check.identical } const sha256 = sha256Hex(buffer) const written = await writeWorkspaceFileByPath({ workspaceId: resolvedWorkspaceId, - userId: args.authUserId, + principal, target: prepared.target, buffer, inferredMimeType: prepared.resolvedMimeType, diff --git a/apps/sim/app/api/tools/file/manage/route.test.ts b/apps/sim/app/api/tools/file/manage/route.test.ts index 27fbdb18858..e7a4625a089 100644 --- a/apps/sim/app/api/tools/file/manage/route.test.ts +++ b/apps/sim/app/api/tools/file/manage/route.test.ts @@ -13,6 +13,9 @@ const { mockEnsureWorkspaceFileFolderPath, mockFetchWorkspaceFileBuffer, mockGetBoundWorkspaceFileSecretProvenance, + mockLoadActiveWorkspaceContext, + mockLoadActiveWorkspaceFileContext, + mockResolveEffectiveWorkspacePermission, mockGetFileMetadataByKey, mockGetWorkspaceFile, mockResolveWorkspaceFileReference, @@ -26,6 +29,9 @@ const { mockEnsureWorkspaceFileFolderPath: vi.fn(), mockFetchWorkspaceFileBuffer: vi.fn(), mockGetBoundWorkspaceFileSecretProvenance: vi.fn(), + mockLoadActiveWorkspaceContext: vi.fn(), + mockLoadActiveWorkspaceFileContext: vi.fn(), + mockResolveEffectiveWorkspacePermission: vi.fn(), mockGetFileMetadataByKey: vi.fn(), mockGetWorkspaceFile: vi.fn(), mockResolveWorkspaceFileReference: vi.fn(), @@ -38,16 +44,50 @@ vi.mock('@/lib/file-parsers', () => ({ parseBuffer: vi.fn(), })) +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_UPLOADED: 'file_uploaded', FILE_UPDATED: 'file_updated' }, + AuditResourceType: { FILE: 'file' }, + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkspaceFilesChanged: vi.fn(async () => undefined), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || + permission === required || + (permission === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: (...args: unknown[]) => + mockResolveEffectiveWorkspacePermission(...args), +})) + vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ fetchWorkspaceFileBuffer: (...args: unknown[]) => mockFetchWorkspaceFileBuffer(...args), getWorkspaceFile: (...args: unknown[]) => mockGetWorkspaceFile(...args), + loadActiveWorkspaceContext: (...args: unknown[]) => mockLoadActiveWorkspaceContext(...args), + loadActiveWorkspaceFileContext: (...args: unknown[]) => + mockLoadActiveWorkspaceFileContext(...args), resolveWorkspaceFileReference: (...args: unknown[]) => mockResolveWorkspaceFileReference(...args), updateWorkspaceFileContent: (...args: unknown[]) => mockUpdateWorkspaceFileContent(...args), uploadWorkspaceFile: (...args: unknown[]) => mockUploadWorkspaceFile(...args), })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ - ensureWorkspaceFileFolderPath: (...args: unknown[]) => mockEnsureWorkspaceFileFolderPath(...args), +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + FileConflictError: class FileConflictError extends Error {}, + ContentVersionConflictError: class ContentVersionConflictError extends Error {}, + fetchWorkspaceFileBuffer: (...args: unknown[]) => mockFetchWorkspaceFileBuffer(...args), + getWorkspaceFile: (...args: unknown[]) => mockGetWorkspaceFile(...args), + loadActiveWorkspaceContext: (...args: unknown[]) => mockLoadActiveWorkspaceContext(...args), + updateWorkspaceFileContent: (...args: unknown[]) => mockUpdateWorkspaceFileContent(...args), + uploadWorkspaceFile: (...args: unknown[]) => mockUploadWorkspaceFile(...args), +})) + +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + createWorkspaceFileFolderOperation: { + execute: (...args: unknown[]) => mockEnsureWorkspaceFileFolderPath(...args), + }, })) vi.mock('@/lib/core/config/redis', () => ({ @@ -56,6 +96,7 @@ vi.mock('@/lib/core/config/redis', () => ({ })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ + EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE: { status: 'exact', entries: [] }, getBoundWorkspaceFileSecretProvenance: (...args: unknown[]) => mockGetBoundWorkspaceFileSecretProvenance(...args), mergeWorkspaceFileSecretProvenance: ( @@ -131,12 +172,30 @@ describe('POST /api/tools/file/manage content provenance', () => { authType: 'internal_jwt', }) mockAssertActiveWorkspaceAccess.mockResolvedValue(undefined) + mockResolveEffectiveWorkspacePermission.mockResolvedValue('write') + mockGetWorkspaceFile.mockImplementation(async (_workspaceId: string, fileId: string) => + workspaceFile(fileId) + ) + mockLoadActiveWorkspaceContext.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', + }) + mockLoadActiveWorkspaceFileContext.mockImplementation(async (fileId: string) => ({ + fileId, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', + })) mockAssertToolFileAccess.mockResolvedValue(undefined) - mockEnsureWorkspaceFileFolderPath.mockResolvedValue(null) + mockEnsureWorkspaceFileFolderPath.mockResolvedValue({ folder: { id: 'folder-1' } }) mockDownloadServableFileFromStorage.mockImplementation(async (file: { name: string }) => ({ buffer: Buffer.from(`content:${file.name}`), })) mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('before')) + mockUpdateWorkspaceFileContent.mockResolvedValue({ file: workspaceFile('file-1') }) mockUploadWorkspaceFile.mockResolvedValue({ id: 'new-file', name: 'new.txt', @@ -146,9 +205,6 @@ describe('POST /api/tools/file/manage content provenance', () => { }) it('returns a scoped, deduplicated union of exact canonical file provenance', async () => { - mockGetWorkspaceFile.mockImplementation(async (_workspaceId: string, fileId: string) => - workspaceFile(fileId) - ) mockGetBoundWorkspaceFileSecretProvenance.mockImplementation( async (_workspaceId: string, identity: { fileId: string }) => identity.fileId === 'file-1' @@ -229,7 +285,9 @@ describe('POST /api/tools/file/manage content provenance', () => { 'new.txt', 'text/plain', { + exactName: false, folderId: null, + folderPath: undefined, secretProvenance: { status: 'exact', entries: [ @@ -308,11 +366,12 @@ describe('POST /api/tools/file/manage content provenance', () => { ) expect(response.status).toBe(200) - expect(mockEnsureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - userId: 'user-1', - pathSegments: ['Reports'], - }) + expect(mockEnsureWorkspaceFileFolderPath).toHaveBeenCalledWith( + expect.objectContaining({ + principal: expect.objectContaining({ kind: 'delegated', subjectUserId: 'user-1' }), + input: { workspaceId: 'workspace-1', path: 'Reports' }, + }) + ) expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( 'workspace-1', 'user-1', @@ -320,7 +379,9 @@ describe('POST /api/tools/file/manage content provenance', () => { 'secret-value.txt', 'text/plain', { - folderId: null, + exactName: false, + folderId: 'folder-1', + folderPath: undefined, secretProvenance: { status: 'exact', entries: [] }, } ) @@ -335,7 +396,6 @@ describe('POST /api/tools/file/manage content provenance', () => { content: 'ordinary text', }) ) - expect(response.status).toBe(200) expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( 'workspace-1', @@ -343,7 +403,12 @@ describe('POST /api/tools/file/manage content provenance', () => { Buffer.from('ordinary text'), 'new.txt', 'text/plain', - { folderId: null } + { + exactName: false, + folderId: null, + folderPath: undefined, + secretProvenance: { status: 'exact', entries: [] }, + } ) }) @@ -471,19 +536,20 @@ describe('POST /api/tools/file/manage content provenance', () => { ) expect(response.status).toBe(200) + expect(Buffer.isBuffer(mockUploadWorkspaceFile.mock.calls[0]?.[2])).toBe(true) expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( 'workspace-1', 'user-1', - expect.any(Buffer), + expect.anything(), 'bundle.zip', 'application/zip', - { + expect.objectContaining({ folderId: null, secretProvenance: { status: 'exact', entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }], }, - } + }) ) }) @@ -607,4 +673,26 @@ describe('POST /api/tools/file/manage content provenance', () => { expect(body).not.toHaveProperty('__resolvedSecretTraceProvenance') expect(mockGetBoundWorkspaceFileSecretProvenance).not.toHaveBeenCalled() }) + + it('never uses query.userId as the authorization identity', async () => { + mockGetWorkspaceFile.mockResolvedValue(workspaceFile('file-1')) + + const response = await POST( + createMockRequest( + 'POST', + { operation: 'get', workspaceId: 'workspace-1', fileId: 'file-1' }, + {}, + 'http://localhost:3000/api/tools/file/manage?userId=attacker' + ) + ) + + expect(response.status).toBe(200) + expect(mockResolveEffectiveWorkspacePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + null, + undefined, + { forUpdate: undefined } + ) + }) }) diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 1af9a5f4c70..e3595aa6fec 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -1,5 +1,4 @@ import { Buffer, isUtf8 } from 'buffer' -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' @@ -10,6 +9,7 @@ import { parseRequest } from '@/lib/api/server' import { AuthType, type AuthTypeValue, checkInternalAuth } from '@/lib/auth/hybrid' import { splitWorkspaceFilePath } from '@/lib/copilot/tools/server/files/workspace-file' import { acquireLock, releaseLock } from '@/lib/core/config/redis' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { ensureAbsoluteUrl } from '@/lib/core/utils/urls' @@ -26,26 +26,14 @@ import { requestsPrivateToolMetadata, } from '@/lib/execution/private-tool-metadata' import { isSupportedFileType, parseBuffer } from '@/lib/file-parsers' -import { - getShareForResource, - getSharesForResources, - ShareValidationError, - upsertFileShare, -} from '@/lib/public-shares/share-manager' +import { getSharesForResources, ShareValidationError } from '@/lib/public-shares/share-manager' import { ArchiveError, type DecompressResult, decompressArchiveBufferToWorkspaceFiles, MAX_ARCHIVE_BYTES, } from '@/lib/uploads/archive' -import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - fetchWorkspaceFileBuffer, - getWorkspaceFile, - resolveWorkspaceFileReference, - updateWorkspaceFileContent, - uploadWorkspaceFile, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import type { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getBoundWorkspaceFileSecretProvenance, mergeWorkspaceFileSecretProvenance, @@ -60,17 +48,24 @@ import { } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' -import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' import { - assertActiveWorkspaceAccess, - getUserEntityPermissions, - isWorkspaceAccessDeniedError, -} from '@/lib/workspaces/permissions/utils' + admitCreateWorkspaceFile, + createWorkspaceFile, + createWorkspaceFileFromBuffer, +} from '@/lib/workspace-files/application/create-workspace-file' +import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' +import { downloadWorkspaceFileRecord } from '@/lib/workspace-files/application/read-workspace-file-record' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' +import { updateWorkspaceFileShare } from '@/lib/workspace-files/application/share-workspace-file' +import { updateWorkspaceFileContent } from '@/lib/workspace-files/application/update-workspace-file-content' +import { createWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' +import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration' +import { isWorkspaceAccessDeniedError } from '@/lib/workspaces/permissions/utils' import { assertToolFileAccess } from '@/app/api/files/authorization' -import { - PublicFileSharingNotAllowedError, - validatePublicFileSharing, -} from '@/ee/access-control/utils/permission-check' import type { UserFile } from '@/executor/types' import { ResolvedSecretTraceProvenanceAccumulator, @@ -82,6 +77,16 @@ export const dynamic = 'force-dynamic' const logger = createLogger('FileManageAPI') +function requireInternalPrincipal(auth: { userId?: string }, workspaceId: string) { + if (!auth.userId) throw new Error('Authenticated internal file operation is missing its user ID') + return createWorkspaceFileDelegatedPrincipal({ + serviceId: 'executor', + subjectUserId: auth.userId, + workspaceId, + delegationId: `internal-file-tool:${auth.userId}`, + }) +} + const workspaceFileToUserFile = (file: Awaited>) => { if (!file) return null @@ -440,15 +445,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { query, body } = parsed.data - const userId = auth.userId || query.userId - if (!userId) { - return NextResponse.json({ success: false, error: 'userId is required' }, { status: 400 }) - } + if (!auth.userId) throw new Error('Authenticated internal file operation is missing its user ID') + const userId = auth.userId const workspaceId = body.workspaceId || query.workspaceId if (!workspaceId) { return NextResponse.json({ success: false, error: 'workspaceId is required' }, { status: 400 }) } + const principal = requireInternalPrincipal(auth, workspaceId) const includePrivateContentProvenance = body.operation === 'content' && requestsPrivateToolMetadata(request.headers, RESOLVED_SECRET_PROVENANCE_METADATA_V1) @@ -459,8 +463,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) => fileContentJsonResponse(responseBody, includePrivateContentProvenance, init, provenance) try { - await assertActiveWorkspaceAccess(workspaceId, userId) - switch (body.operation) { case 'get': { const { fileId, fileInput } = body @@ -481,12 +483,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) } - const file = await getWorkspaceFile(workspaceId, selectedFileId) - if (!file) { - return NextResponse.json( - { success: false, error: `File not found: "${selectedFileId}"` }, - { status: 404 } - ) + let file: Awaited> + try { + file = ( + await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId: selectedFileId, assertedWorkspaceId: workspaceId }, + request, + }) + ).file + } catch (error) { + if (error instanceof OrchestrationError && error.code === 'not_found') { + return NextResponse.json( + { success: false, error: `File not found: "${selectedFileId}"` }, + { status: 404 } + ) + } + throw error } logger.info('File retrieved', { @@ -515,15 +528,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) } - const files = await Promise.all( - selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) - ) - const missingFileId = selectedFileIds.find((_, index) => !files[index]) - if (missingFileId) { - return NextResponse.json( - { success: false, error: `File not found: "${missingFileId}"` }, - { status: 404 } - ) + const files = [] as Array>>> + for (const id of selectedFileIds) { + try { + files.push( + ( + await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId: id, assertedWorkspaceId: workspaceId }, + request, + }) + ).file + ) + } catch (error) { + if (error instanceof OrchestrationError && error.code === 'not_found') { + return NextResponse.json( + { success: false, error: `File not found: "${id}"` }, + { status: 404 } + ) + } + throw error + } } const shares = await getSharesForResources('file', selectedFileIds) @@ -580,15 +605,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return contentResponse({ success: false, error: 'File is required' }, { status: 400 }) } - const workspaceFiles = await Promise.all( - selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) - ) - const missingFileId = selectedFileIds.find((_, index) => !workspaceFiles[index]) - if (missingFileId) { - return contentResponse( - { success: false, error: `File not found: "${missingFileId}"` }, - { status: 404 } - ) + const workspaceFiles = [] as Array< + NonNullable>> + > + for (const id of selectedFileIds) { + try { + workspaceFiles.push( + ( + await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId: id, assertedWorkspaceId: workspaceId }, + request, + }) + ).file + ) + } catch (error) { + if (error instanceof OrchestrationError && error.code === 'not_found') { + return contentResponse( + { success: false, error: `File not found: "${id}"` }, + { status: 404 } + ) + } + throw error + } } const canonicalSources: FileContentSource[] = workspaceFiles.flatMap((file) => { @@ -660,29 +699,38 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } const { folderSegments, leafName } = splitWorkspaceFilePath(fileName) - const folderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId, - pathSegments: folderSegments, - }) + await admitCreateWorkspaceFile(principal, workspaceId) + const folderId = + folderSegments.length === 0 + ? null + : ( + await createWorkspaceFileFolderOperation.execute({ + principal, + input: { workspaceId, path: folderSegments.join('/') }, + request, + }) + ).folder.id const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(leafName)) - const fileBuffer = Buffer.from(content ?? '', 'utf-8') - const result = await uploadWorkspaceFile( - workspaceId, - userId, - fileBuffer, - leafName, - mimeType, - { + const result = await createWorkspaceFile.execute({ + principal, + input: { + workspaceId, + name: leafName, + contentType: mimeType, + content: content ?? '', + encoding: 'utf-8', folderId, + exactName: false, ...(provenanceResolution.contentProvenance ? { secretProvenance: provenanceResolution.contentProvenance } : {}), - } - ) + }, + request, + }) + const fileBuffer = Buffer.from(content ?? '', 'utf-8') logger.info('File created', { - fileId: result.id, + fileId: result.file.id, name: fileName, size: fileBuffer.length, }) @@ -690,10 +738,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, data: { - id: result.id, - name: result.name, + id: result.file.id, + name: result.file.name, size: fileBuffer.length, - url: ensureAbsoluteUrl(result.url), + url: ensureAbsoluteUrl(result.file.url ?? result.file.path), }, }) } @@ -707,30 +755,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { .map((s) => s.trim()) .filter(Boolean) : [] - const targetFolderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId, - pathSegments, - }) - const moveResult = await performMoveWorkspaceFileItems({ - workspaceId, - userId, - fileIds: [fileId], - targetFolderId, + await moveWorkspaceFileItemsOperation.execute({ + principal, + input: { + workspaceId, + fileIds: [fileId], + targetFolderPath: pathSegments.join('/'), + }, + request, }) - if (!moveResult.success) { - return NextResponse.json( - { success: false, error: moveResult.error }, - { - status: - moveResult.errorCode === 'conflict' - ? 409 - : moveResult.errorCode === 'not_found' - ? 404 - : 400, - } - ) - } logger.info('File moved', { fileId, targetFolder: targetFolder || '(root)' }) return NextResponse.json({ success: true, @@ -741,18 +774,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { case 'manage_sharing': { const { fileId, fileInput, isActive, authType, password, allowedEmails } = body - // Check permission before probing file existence so a read-only caller - // can't distinguish 404 from 403 as a file-existence side channel. - // Publishing is more sensitive than the other mutating ops, so it - // requires write/admin (not just workspace access) like the share route. - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json( - { success: false, error: 'Insufficient permissions' }, - { status: 403 } - ) - } - // Resolve the canonical file id. The basic file picker provides an object // with a storage `key` but no id, so map the key to the workspace file row. let resolvedFileId = typeof fileId === 'string' ? fileId : undefined @@ -776,52 +797,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const file = await getWorkspaceFile(workspaceId, resolvedFileId) - if (!file) { - return NextResponse.json( - { success: false, error: `File not found: "${resolvedFileId}"` }, - { status: 404 } - ) - } - - // Enabling a share is gated by the org's access-control policy; disabling - // is always allowed so users can un-share after the policy is turned on. - if (isActive) { - // Resolve the auth type the same way upsertFileShare will (falling back - // to the existing share's type) so the policy gate can't be bypassed by - // re-enabling a pre-existing restricted share without an explicit authType. - const existingShare = await getShareForResource('file', resolvedFileId) - const resolvedAuthType = authType ?? existingShare?.authType ?? 'public' - try { - await validatePublicFileSharing(userId, workspaceId, resolvedAuthType) - } catch (error) { - if (error instanceof PublicFileSharingNotAllowedError) { - return NextResponse.json({ success: false, error: error.message }, { status: 403 }) - } - throw error - } - } - - const share = await upsertFileShare({ - workspaceId, - fileId: resolvedFileId, - userId, - isActive, - authType, - password, - allowedEmails, - }) - - recordAudit({ - workspaceId, - actorId: userId, - action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, - resourceType: AuditResourceType.FILE, - resourceId: resolvedFileId, - resourceName: file.name, - description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`, - request, - }) + const share = ( + await updateWorkspaceFileShare.execute({ + principal, + input: { + fileId: resolvedFileId, + assertedWorkspaceId: workspaceId, + isActive, + authType, + password, + allowedEmails, + }, + request, + }) + ).share logger.info('File sharing updated', { fileId: resolvedFileId, @@ -837,13 +826,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { case 'append': { const { fileName, content } = body - const existing = await resolveWorkspaceFileReference(workspaceId, fileName) - if (!existing) { - return NextResponse.json( - { success: false, error: `File not found: "${fileName}"` }, - { status: 404 } - ) - } + const existing = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.updateContent, + workspaceId, + reference: fileName, + }) const lockKey = `file-append:${workspaceId}:${existing.id}` const lockValue = `${Date.now()}-${generateShortId()}` @@ -887,22 +875,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => { : appendedProvenance ? mergeWorkspaceFileSecretProvenance(existingProvenance, appendedProvenance) : undefined - const existingBuffer = await fetchWorkspaceFileBuffer(existing) + const { content: existingBuffer } = await readWorkspaceFileContent.execute({ + principal, + input: { + fileId: existing.id, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_WORKSPACE_FILE_CONTENT_BYTES, + }, + }) const finalContent = existingBuffer.toString('utf-8') + content const fileBuffer = Buffer.from(finalContent, 'utf-8') - await updateWorkspaceFileContent( - workspaceId, - existing.id, - userId, - fileBuffer, - undefined, - { - expectedUpdatedAt: existing.contentUpdatedAt, - secretProvenancePolicy: secretProvenance - ? { mode: 'replace', provenance: secretProvenance } - : { mode: 'preserve' }, - } - ) + await updateWorkspaceFileContent.execute({ + principal, + input: { + fileId: existing.id, + assertedWorkspaceId: workspaceId, + content: finalContent, + encoding: 'utf-8', + expectedUpdatedAt: existing.contentUpdatedAt ?? undefined, + provenanceMode: secretProvenance ? undefined : 'preserve', + ...(secretProvenance ? { secretProvenance } : {}), + }, + request, + }) logger.info('File appended', { fileId: existing.id, @@ -938,16 +933,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (selectedFileIds.length === 0 && selectedInputFiles.length === 0) { return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) } + await admitCreateWorkspaceFile(principal, workspaceId) - const workspaceFiles = await Promise.all( - selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) - ) - const missingFileId = selectedFileIds.find((_, index) => !workspaceFiles[index]) - if (missingFileId) { - return NextResponse.json( - { success: false, error: `File not found: "${missingFileId}"` }, - { status: 404 } - ) + const workspaceFiles = [] as Array< + NonNullable>> + > + for (const id of selectedFileIds) { + try { + workspaceFiles.push( + ( + await downloadWorkspaceFileRecord.execute({ + principal, + input: { fileId: id, assertedWorkspaceId: workspaceId }, + request, + }) + ).file + ) + } catch (error) { + if (error instanceof OrchestrationError && error.code === 'not_found') { + return NextResponse.json( + { success: false, error: `File not found: "${id}"` }, + { status: 404 } + ) + } + throw error + } } const workspaceEntries: ArchiveEntry[] = workspaceFiles.flatMap((file) => { @@ -1028,29 +1038,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ? stripExtension(toFlatFileName(userFiles[0].name, 'archive')) : 'archive' const leafName = ensureZipExtension(baseName) - const folderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId, - pathSegments: [], + const result = await createWorkspaceFileFromBuffer.execute({ + principal, + input: { + workspaceId, + name: leafName, + contentType: 'application/zip', + content: zipBuffer, + folderId: null, + exactName: false, + secretProvenance: archiveProvenance, + }, + request, }) - const result = await uploadWorkspaceFile( - workspaceId, - userId, - zipBuffer, - leafName, - 'application/zip', - { folderId, secretProvenance: archiveProvenance } - ) const compressedFile: UserFile = { - ...result, - url: ensureAbsoluteUrl(result.url), + ...result.file, + url: ensureAbsoluteUrl(result.file.url ?? result.file.path), size: zipBuffer.length, } logger.info('Files compressed', { - fileId: result.id, - name: result.name, + fileId: result.file.id, + name: result.file.name, fileCount: userFiles.length, size: zipBuffer.length, }) @@ -1083,16 +1093,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } + await admitCreateWorkspaceFile(principal, workspaceId) - const workspaceFiles = await Promise.all( - selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) - ) - const missingFileId = selectedFileIds.find((_, index) => !workspaceFiles[index]) - if (missingFileId) { - return NextResponse.json( - { success: false, error: `File not found: "${missingFileId}"` }, - { status: 404 } - ) + const workspaceFiles = [] as Array< + NonNullable>> + > + for (const id of selectedFileIds) { + try { + workspaceFiles.push( + ( + await downloadWorkspaceFileRecord.execute({ + principal, + input: { fileId: id, assertedWorkspaceId: workspaceId }, + request, + }) + ).file + ) + } catch (error) { + if (error instanceof OrchestrationError && error.code === 'not_found') { + return NextResponse.json( + { success: false, error: `File not found: "${id}"` }, + { status: 404 } + ) + } + throw error + } } const archive = workspaceFiles @@ -1147,7 +1172,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { result = await decompressArchiveBufferToWorkspaceFiles(archiveBuffer, { workspaceId, - userId, + principal, secretProvenance: archiveProvenance, }) } catch (archiveError) { @@ -1202,6 +1227,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (isWorkspaceAccessDeniedError(error)) { return contentResponse({ success: false, error: 'Workspace access denied' }, { status: 403 }) } + if (error instanceof OrchestrationError) { + const status = + error.code === 'forbidden' + ? 403 + : error.code === 'not_found' + ? 404 + : error.code === 'conflict' + ? 409 + : error.code === 'payload_too_large' + ? 413 + : error.code === 'validation' + ? 400 + : 500 + return contentResponse({ success: false, error: error.message }, { status }) + } const notReady = docNotReadyResponse(error) if (notReady) { if (!includePrivateContentProvenance) return notReady diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts index 5ed44af26fb..989a7ef434b 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -4,62 +4,68 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockPerformUpdateContent, - mockGetUserEmailsByIds, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformUpdateContent: vi.fn(), - mockGetUserEmailsByIds: vi.fn(), +const mocks = vi.hoisted(() => ({ + admit: vi.fn(), + updateContent: vi.fn(), + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + getUserEmailsByIds: vi.fn(), })) -vi.mock('@/lib/users/queries', () => ({ - getUserEmailsByIds: mockGetUserEmailsByIds, - requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, +vi.mock('@/lib/workspace-files/orchestration', () => ({ + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, +})) + +vi.mock('@/lib/workspace-files/application/update-workspace-file-content', () => ({ + admitUpdateWorkspaceFileContent: mocks.admit, + updateWorkspaceFileContent: { + operation: { id: 'files.update_content', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateContent, + }, })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +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: vi.fn().mockResolvedValue(null), })) -vi.mock('@/lib/workspace-files/orchestration', () => ({ - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, - performUpdateWorkspaceFileContent: mockPerformUpdateContent, +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: mocks.getUserEmailsByIds, + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { PUT } from '@/app/api/v2/files/[fileId]/content/route' -const WS = 'workspace-1' +const WORKSPACE_ID = 'workspace-1' const FILE_ID = 'wf_1' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -const RECORD = { +const record = { id: FILE_ID, - workspaceId: WS, + workspaceId: WORKSPACE_ID, name: 'data.csv', key: 'workspace/ws/1-x-data.csv', path: '/api/files/serve/x', @@ -67,7 +73,6 @@ const RECORD = { type: 'text/csv', uploadedBy: 'user-1', folderId: null, - folderPath: null, uploadedAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-03T00:00:00Z'), } @@ -88,151 +93,99 @@ const callPut = (body: unknown, contentLength?: number) => describe('PUT /api/v2/files/[fileId]/content', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformUpdateContent.mockResolvedValue({ success: true, file: RECORD }) - mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) - - expect(res.status).toBe(404) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() - }) - - it('400s when content is missing', async () => { - const res = await callPut({ workspaceId: WS }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformUpdateContent).not.toHaveBeenCalled() - }) - - it('400s on an encoding outside the enum', async () => { - const res = await callPut({ workspaceId: WS, content: 'x', encoding: 'latin1' }) - expect(res.status).toBe(400) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2024-01-01T01:00:00Z'), + }) + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), + }) + mocks.admit.mockResolvedValue(undefined) + mocks.updateContent.mockResolvedValue({ file: record }) + mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) - it('400s malformed base64 in the v2 error envelope', async () => { - const res = await callPut({ workspaceId: WS, content: 'not-base64!', encoding: 'base64' }) - const body = await res.json() - - expect(res.status).toBe(400) - expect(body.error.code).toBe('BAD_REQUEST') - expect(body.error.message).toBe('content must be valid base64') - expect(mockPerformUpdateContent).not.toHaveBeenCalled() - }) + it('performs authenticated admission before parsing a large or malformed body', async () => { + mocks.admit.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) - it('accepts empty base64 as a zero-byte replacement', async () => { - const res = await callPut({ workspaceId: WS, content: '', encoding: 'base64' }) + const response = await callPut('{not-json') - expect(res.status).toBe(200) - expect(mockPerformUpdateContent).toHaveBeenCalledWith( - expect.objectContaining({ content: '', encoding: 'base64' }) - ) + expect(response.status).toBe(404) + expect(mocks.admit).toHaveBeenCalledWith(auth.principal, FILE_ID) + expect(mocks.updateContent).not.toHaveBeenCalled() }) - it('allows JSON bodies above the default 50 MiB cap for base64 expansion', async () => { - const res = await callPut( - { workspaceId: WS, content: 'TQ==', encoding: 'base64' }, - 60 * 1024 * 1024 - ) + it('validates body fields after admission', async () => { + const response = await callPut({ workspaceId: WORKSPACE_ID }) - expect(res.status).toBe(200) - expect(mockPerformUpdateContent).toHaveBeenCalled() + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.updateContent).not.toHaveBeenCalled() }) - it('returns an oversized JSON body in the canonical v2 413 envelope', async () => { - const res = await callPut({ workspaceId: WS, content: '' }, 70 * 1024 * 1024 + 1) + it('returns an oversized body in the canonical v2 envelope', async () => { + const response = await callPut({ workspaceId: WORKSPACE_ID, content: '' }, 70 * 1024 * 1024 + 1) - expect(res.status).toBe(413) - await expect(res.json()).resolves.toEqual({ + expect(response.status).toBe(413) + await expect(response.json()).resolves.toEqual({ error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' }, }) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() + expect(mocks.admit).toHaveBeenCalled() + expect(mocks.updateContent).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + it('replaces content through the shared use case and returns the v2 projection', async () => { + const request = new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/content`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, content: 'id,name\n' }), }) - const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) - expect(res.status).toBe(403) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('replaces the content and returns the updated file', async () => { - const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data).toEqual({ - id: FILE_ID, - name: 'data.csv', - size: 8, - type: 'text/csv', - key: 'workspace/ws/1-x-data.csv', - folderPath: '/', - uploadedByEmail: 'ada@example.com', - uploadedAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-03T00:00:00.000Z', + const response = await PUT(request, { params: Promise.resolve({ fileId: FILE_ID }) }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: FILE_ID, + name: 'data.csv', + size: 8, + type: 'text/csv', + key: 'workspace/ws/1-x-data.csv', + folderPath: '/', + uploadedByEmail: 'ada@example.com', + uploadedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-03T00:00:00.000Z', + }, }) - expect(mockPerformUpdateContent).toHaveBeenCalledWith({ - workspaceId: WS, - fileId: FILE_ID, - userId: 'user-1', - content: 'id,name\n', - encoding: 'utf-8', - request: expect.anything(), + expect(mocks.updateContent).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + fileId: FILE_ID, + assertedWorkspaceId: WORKSPACE_ID, + content: 'id,name\n', + encoding: 'utf-8', + }, + request, }) - }) - - it('forwards base64 encoding through to the orchestration', async () => { - await callPut({ workspaceId: WS, content: 'aWQsbmFtZQo=', encoding: 'base64' }) - expect(mockPerformUpdateContent).toHaveBeenCalledWith( - expect.objectContaining({ encoding: 'base64' }) + expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledWith( + 'v2:files.update_content:api-key:key-1', + expect.anything() ) }) - it('maps a payload_too_large errorCode to 413 rather than string-sniffing', async () => { - mockPerformUpdateContent.mockResolvedValue({ - success: false, - error: 'Storage limit exceeded. Used: 5.10GB, Limit: 5GB', - errorCode: 'payload_too_large', - }) - - const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) - const body = await res.json() - - expect(res.status).toBe(413) - expect(body.error.code).toBe('PAYLOAD_TOO_LARGE') - expect(body.error.message).toContain('Storage limit exceeded') - }) - - it('maps a not_found errorCode to 404', async () => { - mockPerformUpdateContent.mockResolvedValue({ - success: false, - error: 'File not found', - errorCode: 'not_found', - }) + it('maps typed quota failures to 413', async () => { + mocks.updateContent.mockRejectedValue( + new OrchestrationError('payload_too_large', 'Storage limit exceeded') + ) - const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) + const response = await callPut({ workspaceId: WORKSPACE_ID, content: 'id,name\n' }) - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(response.status).toBe(413) + expect((await response.json()).error.code).toBe('PAYLOAD_TOO_LARGE') }) }) diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts index 3c295bd1306..c85c5251366 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -1,61 +1,41 @@ import { v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - performUpdateWorkspaceFileContent, -} from '@/lib/workspace-files/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' + admitUpdateWorkspaceFileContent, + updateWorkspaceFileContent, +} from '@/lib/workspace-files/application/update-workspace-file-content' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' import { toV2File } from '@/app/api/v2/files/utils' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * PUT /api/v2/files/[fileId]/content — Replace a file's bytes. - * - * A full replace, not an append: `content` becomes the entire body of the file. - * `encoding: 'base64'` carries non-UTF-8 bytes. The decoded body is capped at - * 50 MB and still debits the workspace storage quota, so a write that would push - * the payer past its limit fails with 413. - */ -export const PUT = withPublicApiRouteHandler({ +/** PUT /api/v2/files/[fileId]/content — Replace a file's bytes. */ +export const PUT = defineV2JsonRoute({ contract: v2UpdateFileContentContract, - rateLimitEndpoint: 'file-content', + auth: v2ApiKeyAuth, + operation: fileOperations.updateContent, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, parseOptions: { invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, payloadTooLargeResponse: () => v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large'), }, - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId, content, encoding } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performUpdateWorkspaceFileContent({ - workspaceId, - fileId, - userId, - content, - encoding, - request, - }) - - if (!result.success || !result.file) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to update file content') - ) + beforeParse: async ({ principal, params }) => { + if (typeof params.fileId === 'string') { + await admitUpdateWorkspaceFileContent(principal, params.fileId) } - - return v2Data(await toV2File(result.file), { rateLimit }) }, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: body.workspaceId, + content: body.content, + encoding: body.encoding, + }), + useCase: updateWorkspaceFileContent, + present: async ({ file }) => ({ data: await toV2File(file) }), }) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts index 69dd8f2dc7c..e838e936fd0 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts @@ -4,52 +4,57 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetWorkspaceFile, - mockGetUserEmailsByIds, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetWorkspaceFile: vi.fn(), - mockGetUserEmailsByIds: vi.fn(), +const mocks = vi.hoisted(() => ({ + readMetadata: vi.fn(), + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + getUserEmailsByIds: vi.fn(), })) -vi.mock('@/lib/users/queries', () => ({ - getUserEmailsByIds: mockGetUserEmailsByIds, - requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, +vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({ + readWorkspaceFileMetadata: { + operation: { id: 'files.read_metadata', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.readMetadata, + }, })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +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: vi.fn().mockResolvedValue(null), })) -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - getWorkspaceFile: mockGetWorkspaceFile, +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: mocks.getUserEmailsByIds, + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET } from '@/app/api/v2/files/[fileId]/metadata/route' const WORKSPACE_ID = 'workspace-1' const FILE_ID = 'wf_1' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), +const context = { params: Promise.resolve({ fileId: FILE_ID }) } +const auth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } -const ctx = { params: Promise.resolve({ fileId: FILE_ID }) } - function buildRecord() { return { id: FILE_ID, @@ -68,39 +73,39 @@ function buildRecord() { } const callGet = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/metadata?${query}`), ctx) + GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/metadata?${query}`), context) describe('GET /api/v2/files/[fileId]/metadata', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetWorkspaceFile.mockResolvedValue(buildRecord()) - mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2024-01-01T01:00:00Z'), + }) + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), + }) + mocks.readMetadata.mockResolvedValue({ file: buildRecord() }) + mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) - it('400s when workspaceId is missing', async () => { + it('authenticates and charges before rejecting a missing workspaceId', async () => { const response = await callGet('') expect(response.status).toBe(400) - expect(mockGetWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.authenticateV2ApiKey).toHaveBeenCalled() + expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(mocks.readMetadata).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - - const response = await callGet(`workspaceId=${WORKSPACE_ID}`) - - expect(response.status).toBe(403) - expect(mockGetWorkspaceFile).not.toHaveBeenCalled() - }) - - it('404s when the workspace-scoped file does not exist', async () => { - mockGetWorkspaceFile.mockResolvedValue(null) + it('conceals an authorization failure as not found', async () => { + mocks.readMetadata.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) const response = await callGet(`workspaceId=${WORKSPACE_ID}`) @@ -108,7 +113,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { expect((await response.json()).error.code).toBe('NOT_FOUND') }) - it('returns the public metadata projection without loading content', async () => { + it('returns the v2 metadata projection through the shared use case', async () => { const response = await callGet(`workspaceId=${WORKSPACE_ID}`) expect(response.status).toBe(200) @@ -125,14 +130,10 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { updatedAt: '2024-01-02T00:00:00.000Z', }, }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.anything(), - 'user-1', - WORKSPACE_ID, - 'read' - ) - expect(mockGetWorkspaceFile).toHaveBeenCalledWith(WORKSPACE_ID, FILE_ID, { - throwOnError: true, + expect(mocks.readMetadata).toHaveBeenCalledWith({ + principal: auth.principal, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request: expect.anything(), }) }) }) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts index f74c7fe55be..a76a4862229 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts @@ -1,27 +1,24 @@ import { v2GetFileContract } from '@/lib/api/contracts/v2/files' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { toV2File } from '@/app/api/v2/files/utils' -import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/files/[fileId]/metadata — Return file metadata without downloading its bytes. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetFileContract, - rateLimitEndpoint: 'file-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) - if (!file) return v2Error('NOT_FOUND', 'File not found') - - return v2Data(await toV2File(file), { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.readMetadata, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: readWorkspaceFileMetadata, + present: async ({ file }) => ({ data: await toV2File(file) }), }) 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 9707809e2af..4168341cf70 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -1,340 +1,210 @@ /** * @vitest-environment node - * - * Public v2 file detail: download, rename, archive. Covers the orchestration - * error mapping that replaced the route-local status switch. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetWorkspaceFile, - mockFetchWorkspaceFileBuffer, - mockPerformRename, - mockPerformDelete, - mockGetUserEmailsByIds, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetWorkspaceFile: vi.fn(), - mockFetchWorkspaceFileBuffer: vi.fn(), - mockPerformRename: vi.fn(), - mockPerformDelete: vi.fn(), - mockGetUserEmailsByIds: vi.fn(), +const mocks = vi.hoisted(() => ({ + download: vi.fn(), + rename: vi.fn(), + deleteFile: vi.fn(), + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + getUserEmailsByIds: vi.fn(), })) -vi.mock('@/lib/users/queries', () => ({ - getUserEmailsByIds: mockGetUserEmailsByIds, - requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, +vi.mock('@/lib/workspace-files/application/download-workspace-file', () => ({ + downloadWorkspaceFileStream: { + operation: { id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.download, + }, })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/workspace-files/application/rename-workspace-file', () => ({ + renameWorkspaceFile: { + operation: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.rename, + }, })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/workspace-files/application/delete-workspace-file', () => ({ + deleteWorkspaceFileOperation: { + operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.deleteFile, + }, })) -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - getWorkspaceFile: mockGetWorkspaceFile, - fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performRenameWorkspaceFile: mockPerformRename, - performDeleteWorkspaceFileItems: mockPerformDelete, +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkRateLimitDirect + checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow + }, })) -import { DELETE, GET, PATCH } from '@/app/api/v2/files/[fileId]/route' +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null) })) -const WS = 'workspace-1' -const FILE_ID = 'wf_1' +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: mocks.getUserEmailsByIds, + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, +})) -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { DELETE, GET, PATCH } from '@/app/api/v2/files/[fileId]/route' -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, +const WORKSPACE_ID = 'workspace-1' +const FILE_ID = 'wf_1' +const context = { params: Promise.resolve({ fileId: FILE_ID }) } +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } -function buildRecord(overrides: Record = {}) { +function fileRecord(overrides: Record = {}) { return { id: FILE_ID, - workspaceId: WS, + workspaceId: WORKSPACE_ID, name: 'data.csv', key: 'workspace/ws/1-x-data.csv', path: '/api/files/serve/x', - size: 1024, + size: 8, type: 'text/csv', uploadedBy: 'user-1', folderId: null, - folderPath: null, uploadedAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), + updatedAt: new Date('2024-01-03T00:00:00Z'), ...overrides, } } -const ctx = { params: Promise.resolve({ fileId: FILE_ID }) } - -const callDownload = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?${query}`), ctx) - -const callRename = (body: unknown) => - PATCH( - new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - ctx - ) - -const callDelete = (query: string) => - DELETE(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?${query}`), ctx) - -describe('GET /api/v2/files/[fileId]', () => { +describe('v2 single-file routes', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetWorkspaceFile.mockResolvedValue(buildRecord()) - mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('id,name\n')) - mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callDownload(`workspaceId=${WS}`) - - expect(res.status).toBe(404) - expect(mockGetWorkspaceFile).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callDownload('') - expect(res.status).toBe(400) - expect(mockGetWorkspaceFile).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2024-01-01T01:00:00Z'), }) - const res = await callDownload(`workspaceId=${WS}`) - expect(res.status).toBe(403) - expect(mockGetWorkspaceFile).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callDownload(`workspaceId=${WS}`) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('streams the bytes with rate-limit headers', async () => { - const res = await callDownload(`workspaceId=${WS}`) - expect(res.status).toBe(200) - expect(res.headers.get('Content-Type')).toBe('text/csv') - expect(res.headers.get('X-RateLimit-Remaining')).toBe('99') - expect(await res.text()).toBe('id,name\n') - }) -}) - -describe('PATCH /api/v2/files/[fileId]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformRename.mockResolvedValue({ - success: true, - file: buildRecord({ name: 'renamed.csv' }), + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - - expect(res.status).toBe(404) - expect(mockPerformRename).not.toHaveBeenCalled() - }) - - it('400s on a name containing a path separator', async () => { - const res = await callRename({ workspaceId: WS, name: 'nested/renamed.csv' }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformRename).not.toHaveBeenCalled() - }) - - it('400s on an unknown body field', async () => { - const res = await callRename({ workspaceId: WS, name: 'renamed.csv', folderId: 'fold_1' }) - expect(res.status).toBe(400) - expect(mockPerformRename).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + mocks.download.mockResolvedValue({ + file: fileRecord(), + stream: new Blob(['id,name\n']).stream(), }) - const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - expect(res.status).toBe(403) - expect(mockPerformRename).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('renames and returns the public file shape', async () => { - const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data).toEqual({ + mocks.rename.mockResolvedValue({ file: fileRecord({ name: 'renamed.csv' }) }) + mocks.deleteFile.mockResolvedValue({ id: FILE_ID, - name: 'renamed.csv', - size: 1024, - type: 'text/csv', - key: 'workspace/ws/1-x-data.csv', - folderPath: '/', - uploadedByEmail: 'ada@example.com', - uploadedAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', + workspaceId: WORKSPACE_ID, + deleted: true, }) - expect(mockPerformRename).toHaveBeenCalledWith({ - workspaceId: WS, - fileId: FILE_ID, - name: 'renamed.csv', - userId: 'user-1', - }) - }) - - it('maps a conflict errorCode to 409 through the shared mapper', async () => { - mockPerformRename.mockResolvedValue({ - success: false, - error: 'A file named "renamed.csv" already exists in this workspace', - errorCode: 'conflict', - }) - - const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - const body = await res.json() - - expect(res.status).toBe(409) - expect(body.error.code).toBe('CONFLICT') - expect(body.error.message).toContain('already exists') - }) - - it('hides an unclassified failure behind a generic 500', async () => { - mockPerformRename.mockResolvedValue({ - success: false, - error: 'update "workspace_files" set ... failed', - errorCode: 'internal', + mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) + }) + + it('downloads bytes through the binary adapter with operation rate headers', async () => { + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`), + context + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/csv') + expect(response.headers.get('Content-Disposition')).toContain('data.csv') + expect(response.headers.get('X-RateLimit-Remaining')).toBe('99') + expect(await response.text()).toBe('id,name\n') + expect(mocks.download).toHaveBeenCalledWith({ + principal: auth.principal, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request: expect.anything(), }) - - const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - const body = await res.json() - - expect(res.status).toBe(500) - expect(body.error.message).toBe('Internal server error') - }) -}) - -describe('DELETE /api/v2/files/[fileId]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformDelete.mockResolvedValue({ success: true, deletedItems: { files: 1, folders: 0 } }) }) - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + it('conceals download authorization failures', async () => { + mocks.download.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) - const res = await callDelete(`workspaceId=${WS}`) + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`), + context + ) - expect(res.status).toBe(404) - expect(mockPerformDelete).not.toHaveBeenCalled() + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') }) - it('400s when workspaceId is missing', async () => { - const res = await callDelete('') - expect(res.status).toBe(400) - expect(mockPerformDelete).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + it('renames through the shared use case and v2 presenter', async () => { + const request = new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, name: 'renamed.csv' }), }) - const res = await callDelete(`workspaceId=${WS}`) - expect(res.status).toBe(403) - expect(mockPerformDelete).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callDelete(`workspaceId=${WS}`) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('archives the file and acknowledges', async () => { - const res = await callDelete(`workspaceId=${WS}`) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data).toEqual({ id: FILE_ID, deleted: true }) - expect(mockPerformDelete).toHaveBeenCalledWith({ - workspaceId: WS, - userId: 'user-1', - fileIds: [FILE_ID], - request: expect.anything(), + const response = await PATCH(request, context) + + expect(response.status).toBe(200) + expect((await response.json()).data.name).toBe('renamed.csv') + expect(mocks.rename).toHaveBeenCalledWith({ + principal: auth.principal, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, name: 'renamed.csv' }, + request, }) }) - it('maps a not_found errorCode to 404', async () => { - mockPerformDelete.mockResolvedValue({ - success: false, - error: 'File not found', - errorCode: 'not_found', + it('maps rename conflicts and conceals authorization failures', async () => { + mocks.rename.mockRejectedValueOnce(new OrchestrationError('conflict', 'Name exists')) + const conflict = await PATCH( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, name: 'renamed.csv' }), + }), + context + ) + expect(conflict.status).toBe(409) + + mocks.rename.mockRejectedValueOnce( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) + const concealed = await PATCH( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, name: 'renamed.csv' }), + }), + context + ) + expect(concealed.status).toBe(404) + }) + + it('archives through the same principal and operation pipeline', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`, + { method: 'DELETE' } + ) + const response = await DELETE(request, context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: FILE_ID, deleted: true } }) + expect(mocks.deleteFile).toHaveBeenCalledWith({ + principal: auth.principal, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request, }) - - const res = await callDelete(`workspaceId=${WS}`) - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') }) }) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index 53dec1d5470..5db1ff967de 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -1,27 +1,20 @@ -import { createLogger } from '@sim/logger' import { v2DeleteFileContract, v2DownloadFileContract, v2RenameFileContract, } from '@/lib/api/contracts/v2/files' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { - performDeleteWorkspaceFileItems, - performRenameWorkspaceFile, -} from '@/lib/workspace-files/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' + defineV2BinaryRoute, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' +import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' import { toV2File } from '@/app/api/v2/files/utils' -import { - rateLimitHeaders, - v2Data, - v2Error, - v2ErrorForOrchestration, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FileDetailAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -33,31 +26,23 @@ export const revalidate = 0 * `X-RateLimit-*` headers. Errors still render the canonical v2 JSON error body. * Lookups are workspace-scoped (IDOR-safe): a file in another workspace 404s. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2BinaryRoute({ contract: v2DownloadFileContract, - rateLimitEndpoint: 'file-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const fileRecord = await getWorkspaceFile(workspaceId, fileId) - if (!fileRecord) return v2Error('NOT_FOUND', 'File not found') - - const buffer = await fetchWorkspaceFileBuffer(fileRecord) - - return new Response(new Uint8Array(buffer), { - status: 200, - headers: { - 'Content-Type': fileRecord.type || 'application/octet-stream', - 'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`, - 'Content-Length': String(buffer.length), - ...rateLimitHeaders(rateLimit), - }, - }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.download, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: downloadWorkspaceFileStream, + present: ({ file, stream }) => ({ + body: stream, + contentType: file.type || 'application/octet-stream', + contentDisposition: `attachment; filename="${file.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(file.name)}`, + contentLength: file.size, + }), }) /** @@ -67,63 +52,38 @@ export const GET = withPublicApiRouteHandler({ * Names that collide within the destination folder are rejected as `CONFLICT` — * unlike upload, which auto-suffixes on the internal surface. */ -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2RenameFileContract, - rateLimitEndpoint: 'file-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId, name } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performRenameWorkspaceFile({ workspaceId, fileId, name, userId }) - - if (!result.success || !result.file) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to rename file') - ) - } - - return v2Data(await toV2File(result.file), { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.rename, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: body.workspaceId, + name: body.name, + }), + useCase: renameWorkspaceFile, + present: async ({ file }) => ({ data: await toV2File(file) }), }) /** * DELETE /api/v2/files/[fileId] — Delete a file. * - * Delegates to the shared orchestration, which is workspace-scoped and records - * its own audit entry (the request is forwarded so that entry captures client - * IP / user agent). Orchestration `errorCode`s map to specific v2 codes rather - * than v1's blanket 500. + * Uses the shared workspace-file application operation, which canonicalizes the + * resource, authorizes the API-key principal, archives it, and records the + * semantic audit/notification side effects once. */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteFileContract, - rateLimitEndpoint: 'file-detail', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId, - fileIds: [fileId], - request, - }) - - if (!result.success) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to delete file') - ) - } - - logger.info(`Deleted file ${fileId} from workspace ${workspaceId}`) - - return v2Data({ id: fileId, deleted: true as const }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.delete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: deleteWorkspaceFileOperation, + present: ({ id, deleted }) => ({ data: { id, deleted } }), }) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts index 25b15d2f47b..78123770c87 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts @@ -1,57 +1,90 @@ /** * @vitest-environment node - * - * Public v2 file share. The two decisions that separate it from the internal - * route are pinned here: the caller-supplied `token` is rejected, and a bare - * re-enable keeps the token the orchestration already stored. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformGetShare, mockPerformUpsert } = - vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformGetShare: vi.fn(), - mockPerformUpsert: vi.fn(), - })) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { + class MockV2ApiKeyUnauthenticatedError extends Error { + constructor(message = 'Invalid API key') { + super(message) + this.name = 'V2ApiKeyUnauthenticatedError' + } + } + + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + getShare: vi.fn(), + updateShare: vi.fn(), + }, + MockV2ApiKeyUnauthenticatedError, + } +}) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: vi.fn().mockReturnValue({ + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 60_000, + }), })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), })) -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performGetWorkspaceFileShare: mockPerformGetShare, - performUpsertWorkspaceFileShare: mockPerformUpsert, +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) + +vi.mock('@/lib/workspace-files/application/share-workspace-file', () => ({ + getWorkspaceFileShare: { + operation: { id: 'files.share.read', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.getShare, + }, + updateWorkspaceFileShare: { + operation: { id: 'files.share.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateShare, + }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET, PUT } from '@/app/api/v2/files/[fileId]/share/route' -const WS = 'workspace-1' +const WORKSPACE_ID = 'workspace-1' const FILE_ID = 'wf_1' - +const PRINCIPAL = { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' } +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} const RATE_LIMIT_OK = { allowed: true, - userId: 'user-1', - keyType: 'workspace', limit: 100, remaining: 99, resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 0, } - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} - const SHARE = { id: 'shr_1', token: 'existing-token-abcd', @@ -63,209 +96,177 @@ const SHARE = { hasPassword: false, allowedEmails: [] as string[], } +const context = { params: Promise.resolve({ fileId: FILE_ID }) } -const ctx = { params: Promise.resolve({ fileId: FILE_ID }) } - -const callGet = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share?${query}`), ctx) +function callGet(query = `workspaceId=${WORKSPACE_ID}`) { + return GET( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share?${query}`, { + headers: { 'x-api-key': 'key' }, + }), + context + ) +} -const callPut = (body: unknown) => - PUT( +function callPut(body: unknown) { + return PUT( new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'x-api-key': 'key' }, body: JSON.stringify(body), }), - ctx + context ) +} describe('GET /api/v2/files/[fileId]/share', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformGetShare.mockResolvedValue({ success: true, share: SHARE }) + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.getShare.mockResolvedValue({ share: SHARE }) + }) + + it('authenticates and rate-limits before parsing or executing', async () => { + mocks.authenticate.mockRejectedValueOnce( + new MockV2ApiKeyUnauthenticatedError('API key required') + ) + + const response = await callGet() + + expect(response.status).toBe(401) + expect(mocks.getShare).not.toHaveBeenCalled() + expect(mocks.operationRate).not.toHaveBeenCalled() }) it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + mocks.gate.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - const res = await callGet(`workspaceId=${WS}`) + const response = await callGet() - expect(res.status).toBe(404) - expect(mockPerformGetShare).not.toHaveBeenCalled() + expect(response.status).toBe(404) + expect(mocks.getShare).not.toHaveBeenCalled() }) - it('400s when workspaceId is missing', async () => { - const res = await callGet('') - expect(res.status).toBe(400) - expect(mockPerformGetShare).not.toHaveBeenCalled() + it('validates the asserted workspace before executing the use case', async () => { + const response = await callGet('') + + expect(response.status).toBe(400) + expect(mocks.getShare).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + it('conceals authorization failures as not found', async () => { + mocks.getShare.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Access denied')) + + const response = await callGet() + const body = await response.json() + + expect(response.status).toBe(404) + expect(body.error.code).toBe('NOT_FOUND') + expect(mocks.getShare).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request: expect.anything(), }) - const res = await callGet(`workspaceId=${WS}`) - expect(res.status).toBe(403) - expect(mockPerformGetShare).not.toHaveBeenCalled() }) - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callGet(`workspaceId=${WS}`) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') + it('returns the share through the v2 envelope', async () => { + const response = await callGet() + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual({ share: SHARE }) }) - it('reads at workspace read level and returns the share', async () => { - const res = await callGet(`workspaceId=${WS}`) - const body = await res.json() + it('returns the rate-limit response when denied', async () => { + mocks.operationRate.mockResolvedValueOnce({ ...RATE_LIMIT_OK, allowed: false, remaining: 0 }) - expect(res.status).toBe(200) - expect(body.data).toEqual({ share: SHARE }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(expect.anything(), 'user-1', WS, 'read') - expect(mockPerformGetShare).toHaveBeenCalledWith({ workspaceId: WS, fileId: FILE_ID }) - }) + const response = await callGet() - it('returns a null share for a file that was never shared', async () => { - mockPerformGetShare.mockResolvedValue({ success: true, share: null }) - const res = await callGet(`workspaceId=${WS}`) - expect((await res.json()).data).toEqual({ share: null }) + expect(response.status).toBe(429) + expect((await response.json()).error.code).toBe('RATE_LIMITED') + expect(mocks.getShare).not.toHaveBeenCalled() }) }) describe('PUT /api/v2/files/[fileId]/share', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformUpsert.mockResolvedValue({ success: true, share: SHARE }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callPut({ workspaceId: WS, isActive: true }) - - expect(res.status).toBe(404) - expect(mockPerformUpsert).not.toHaveBeenCalled() - }) - - it('400s when isActive is missing', async () => { - const res = await callPut({ workspaceId: WS }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformUpsert).not.toHaveBeenCalled() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.updateShare.mockResolvedValue({ share: SHARE }) }) - it('rejects a caller-supplied token instead of minting a predictable URL', async () => { - const res = await callPut({ - workspaceId: WS, + it('rejects a caller-supplied token at the v2 boundary', async () => { + const response = await callPut({ + workspaceId: WORKSPACE_ID, isActive: true, token: 'attacker-chosen-token', }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformUpsert).not.toHaveBeenCalled() + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.updateShare).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - const res = await callPut({ workspaceId: WS, isActive: true }) - expect(res.status).toBe(403) - expect(mockPerformUpsert).not.toHaveBeenCalled() - }) + it('renders typed validation failures in the v2 envelope', async () => { + mocks.updateShare.mockRejectedValueOnce( + new OrchestrationError('validation', 'Password is required for password-protected shares') + ) - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callPut({ workspaceId: WS, isActive: true }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') + const response = await callPut({ workspaceId: WORKSPACE_ID, isActive: true }) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error).toEqual({ + code: 'BAD_REQUEST', + message: 'Password is required for password-protected shares', + }) }) - it('enables the share at workspace write level and never forwards a token', async () => { - const res = await callPut({ - workspaceId: WS, + it('passes the shared principal and canonical workspace assertion to the use case', async () => { + const response = await callPut({ + workspaceId: WORKSPACE_ID, isActive: true, authType: 'password', password: 'hunter2hunter2', }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data).toEqual({ share: SHARE }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.anything(), - 'user-1', - WS, - 'write' - ) - expect(mockPerformUpsert).toHaveBeenCalledWith({ - workspaceId: WS, - fileId: FILE_ID, - userId: 'user-1', - isActive: true, - authType: 'password', - password: 'hunter2hunter2', - allowedEmails: undefined, + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual({ share: SHARE }) + expect(mocks.updateShare).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + fileId: FILE_ID, + assertedWorkspaceId: WORKSPACE_ID, + isActive: true, + authType: 'password', + password: 'hunter2hunter2', + allowedEmails: undefined, + }, request: expect.anything(), }) - expect(mockPerformUpsert.mock.calls[0][0]).not.toHaveProperty('token') }) - it('preserves the existing token on a bare re-enable', async () => { - const res = await callPut({ workspaceId: WS, isActive: true }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data.share.token).toBe('existing-token-abcd') - expect(body.data.share.url).toBe('https://www.sim.ai/f/existing-token-abcd') - // No authType either: the orchestration resolves the stored one, so the - // access-control gate is evaluated against the real mode, not 'public'. - expect(mockPerformUpsert).toHaveBeenCalledWith( - expect.objectContaining({ isActive: true, authType: undefined }) - ) - }) - - it('maps a forbidden errorCode from the access-control policy to 403', async () => { - mockPerformUpsert.mockResolvedValue({ - success: false, - error: 'Public file sharing is not allowed based on your permission group settings', - errorCode: 'forbidden', - }) + it('conceals forbidden updates as not found', async () => { + mocks.updateShare.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Access denied')) - const res = await callPut({ workspaceId: WS, isActive: true }) - const body = await res.json() + const response = await callPut({ workspaceId: WORKSPACE_ID, isActive: true }) - expect(res.status).toBe(403) - expect(body.error.code).toBe('FORBIDDEN') - expect(body.error.message).toContain('not allowed') + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') }) - it('maps a validation errorCode to 400', async () => { - mockPerformUpsert.mockResolvedValue({ - success: false, - error: 'Password is required for password-protected shares', - errorCode: 'validation', - }) + it('returns the rate-limit response when denied', async () => { + mocks.operationRate.mockResolvedValueOnce({ ...RATE_LIMIT_OK, allowed: false, remaining: 0 }) - const res = await callPut({ workspaceId: WS, isActive: true, authType: 'password' }) + const response = await callPut({ workspaceId: WORKSPACE_ID, isActive: true }) - expect(res.status).toBe(400) - expect((await res.json()).error.message).toBe( - 'Password is required for password-protected shares' - ) + expect(response.status).toBe(429) + expect((await response.json()).error.code).toBe('RATE_LIMITED') + expect(mocks.updateShare).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.ts index 22ff6225615..5b6e5a6a015 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.ts @@ -1,84 +1,43 @@ import { v2GetFileShareContract, v2UpsertFileShareContract } from '@/lib/api/contracts/v2/files' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { - performGetWorkspaceFileShare, - performUpsertWorkspaceFileShare, -} from '@/lib/workspace-files/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2Data, v2ErrorForOrchestration, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' + getWorkspaceFileShare, + updateWorkspaceFileShare, +} from '@/lib/workspace-files/application/share-workspace-file' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * GET /api/v2/files/[fileId]/share — Read a file's public share state. - * - * `null` means the file has never been shared. `hasPassword` is the only signal - * carried for a password-gated share; the ciphertext is never exposed. - */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetFileShareContract, - rateLimitEndpoint: 'file-share', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const result = await performGetWorkspaceFileShare({ workspaceId, fileId }) - - if (!result.success) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to fetch share') - ) - } - - return v2Data({ share: result.share ?? null }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.readShare, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: getWorkspaceFileShare, + present: ({ share }) => ({ data: { share } }), }) -/** - * PUT /api/v2/files/[fileId]/share — Enable or disable a file's public share. - * - * Requires workspace `write`, matching the UI. The share token is always - * server-generated: the internal surface accepts a caller-supplied one so the UI - * can render a link before saving, but over an API key that would mint - * predictable public URLs and collide with the token unique index. - * - * `isActive: false` disables, it does not revoke — the token and the stored - * password / allow-list survive, so re-enabling resurrects the same URL. - */ -export const PUT = withPublicApiRouteHandler({ +export const PUT = defineV2JsonRoute({ contract: v2UpsertFileShareContract, - rateLimitEndpoint: 'file-share', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId, isActive, authType, password, allowedEmails } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performUpsertWorkspaceFileShare({ - workspaceId, - fileId, - userId, - isActive, - authType, - password, - allowedEmails, - request, - }) - - if (!result.success || !result.share) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to update share') - ) - } - - return v2Data({ share: result.share }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.updateShare, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: body.workspaceId, + isActive: body.isActive, + authType: body.authType, + password: body.password, + allowedEmails: body.allowedEmails, + }), + useCase: updateWorkspaceFileShare, + present: ({ share }) => ({ data: { share } }), }) diff --git a/apps/sim/app/api/v2/files/bulk-delete/route.test.ts b/apps/sim/app/api/v2/files/bulk-delete/route.test.ts index 31795501de6..a66f490f6c2 100644 --- a/apps/sim/app/api/v2/files/bulk-delete/route.test.ts +++ b/apps/sim/app/api/v2/files/bulk-delete/route.test.ts @@ -4,51 +4,64 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformDelete } = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformDelete: vi.fn(), +const { mockPreauth, mockOperationRate, mockGate, mockExecute } = vi.hoisted(() => ({ + mockPreauth: vi.fn(), + mockOperationRate: vi.fn(), + mockGate: vi.fn(), + mockExecute: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: vi.fn().mockResolvedValue({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['workspace:workspace-1'], + rateLimitSubscription: null, + keyType: 'workspace', + }), + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mockPreauth + checkRateLimitDirectOrThrow = mockOperationRate + }, + getRateLimit: vi + .fn() + .mockReturnValue({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performDeleteWorkspaceFileItems: mockPerformDelete, +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGate })) +vi.mock('@/lib/workspace-files/application/archive-workspace-file-items', () => ({ + archiveWorkspaceFileItemsOperation: { + operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mockExecute, + }, })) import { POST } from '@/app/api/v2/files/bulk-delete/route' const WS = 'workspace-1' - const RATE_LIMIT_OK = { allowed: true, - userId: 'user-1', - keyType: 'workspace', limit: 100, remaining: 99, resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, + retryAfterMs: 0, } const callDelete = (body: unknown) => POST( new NextRequest('http://localhost:3000/api/v2/files/bulk-delete', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'x-api-key': 'key' }, body: JSON.stringify(body), }) ) @@ -56,42 +69,36 @@ const callDelete = (body: unknown) => describe('POST /api/v2/files/bulk-delete', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformDelete.mockResolvedValue({ success: true, deletedItems: { files: 3, folders: 1 } }) + mockPreauth.mockResolvedValue(RATE_LIMIT_OK) + mockOperationRate.mockResolvedValue(RATE_LIMIT_OK) + mockGate.mockResolvedValue(null) + mockExecute.mockResolvedValue({ deletedItems: { files: 3, folders: 0 } }) }) it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - + mockGate.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) - expect(res.status).toBe(404) - expect(mockPerformDelete).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() }) it('400s when the selection is empty', async () => { const res = await callDelete({ workspaceId: WS, fileIds: [] }) expect(res.status).toBe(400) expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformDelete).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) + it('surfaces a forbidden collection operation', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + mockExecute.mockRejectedValue(new OrchestrationError('forbidden', 'Access denied')) const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(403) - expect(mockPerformDelete).not.toHaveBeenCalled() }) it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + mockPreauth.mockResolvedValue({ ...RATE_LIMIT_OK, allowed: false, remaining: 0 }) const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(429) expect((await res.json()).error.code).toBe('RATE_LIMITED') @@ -99,27 +106,17 @@ describe('POST /api/v2/files/bulk-delete', () => { it('deletes the selection and reports the file count', async () => { const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) - const body = await res.json() - expect(res.status).toBe(200) - expect(body.data).toEqual({ deletedItems: { files: 3 } }) - expect(mockPerformDelete).toHaveBeenCalledWith({ - workspaceId: WS, - userId: 'user-1', - fileIds: ['wf_1'], - request: expect.anything(), - }) + expect((await res.json()).data).toEqual({ deletedItems: { files: 3 } }) + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ input: { workspaceId: WS, fileIds: ['wf_1'] } }) + ) }) - it('maps a not_found errorCode to 404', async () => { - mockPerformDelete.mockResolvedValue({ - success: false, - error: 'File not found', - errorCode: 'not_found', - }) - + it('maps a not-found failure to 404', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + mockExecute.mockRejectedValue(new OrchestrationError('not_found', 'File not found')) const res = await callDelete({ workspaceId: WS, fileIds: ['wf_missing'] }) - expect(res.status).toBe(404) expect((await res.json()).error.code).toBe('NOT_FOUND') }) diff --git a/apps/sim/app/api/v2/files/bulk-delete/route.ts b/apps/sim/app/api/v2/files/bulk-delete/route.ts index accd962d9dc..35ae13cfe31 100644 --- a/apps/sim/app/api/v2/files/bulk-delete/route.ts +++ b/apps/sim/app/api/v2/files/bulk-delete/route.ts @@ -1,40 +1,19 @@ import { v2BulkDeleteFilesContract } from '@/lib/api/contracts/v2/files' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2Data, v2ErrorForOrchestration, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { archiveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/archive-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * POST /api/v2/files/bulk-delete — Delete files. Folder deletion is owned by - * `/api/v2/files/folders` so this resource operation never accepts folder ids. - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2BulkDeleteFilesContract, - rateLimitEndpoint: 'file-bulk-delete', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { workspaceId, fileIds } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId, - fileIds, - request, - }) - - if (!result.success || !result.deletedItems) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to delete files') - ) - } - - return v2Data({ deletedItems: { files: result.deletedItems.files } }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.delete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ body }) => ({ workspaceId: body.workspaceId, fileIds: body.fileIds }), + useCase: archiveWorkspaceFileItemsOperation, + present: ({ deletedItems }) => ({ data: { deletedItems: { files: deletedItems.files } } }), }) diff --git a/apps/sim/app/api/v2/files/folders/route.test.ts b/apps/sim/app/api/v2/files/folders/route.test.ts new file mode 100644 index 00000000000..f255960ce04 --- /dev/null +++ b/apps/sim/app/api/v2/files/folders/route.test.ts @@ -0,0 +1,223 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { + class MockV2ApiKeyUnauthenticatedError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + listFolders: vi.fn(), + createFolder: vi.fn(), + updateFolder: vi.fn(), + deleteFolder: vi.fn(), + }, + MockV2ApiKeyUnauthenticatedError, + } +}) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: vi.fn().mockReturnValue({ + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 60_000, + }), +})) +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + listWorkspaceFileFoldersOperation: { + operation: { id: 'files.folders.list', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.listFolders, + }, + createWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.create', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.createFolder, + }, + updateWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateFolder, + }, + deleteWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.deleteFolder, + }, +})) + +import { DELETE, GET, PATCH, POST } from '@/app/api/v2/files/folders/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' } +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE_LIMIT_OK = { + allowed: true, + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 0, +} +const folder = { + id: 'folder-1', + workspaceId: WORKSPACE_ID, + userId: 'owner-1', + name: 'Reports', + parentId: null, + path: '/Reports', + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} +const context = undefined + +function request(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', url: string, body?: unknown) { + return new NextRequest(`http://localhost:3000${url}`, { + method, + headers: { + 'x-api-key': 'key', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) +} + +describe('/api/v2/files/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.listFolders.mockResolvedValue({ folders: [folder] }) + mocks.createFolder.mockResolvedValue({ folder }) + mocks.updateFolder.mockResolvedValue({ folder }) + mocks.deleteFolder.mockResolvedValue({ + deletedItems: { folders: 1, files: 2 }, + path: '/Reports', + }) + }) + + it('lists folders through the shared operation and v2 presenter', async () => { + const response = await GET( + request('GET', `/api/v2/files/folders?workspaceId=${WORKSPACE_ID}`), + context + ) + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual([ + { + name: 'Reports', + path: '/Reports', + parentPath: '/', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ]) + expect(mocks.listFolders).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + parentPath: undefined, + search: undefined, + sortBy: 'name', + sortOrder: 'asc', + }, + request: expect.anything(), + }) + }) + + it('creates a folder from its canonical path', async () => { + const response = await POST( + request('POST', '/api/v2/files/folders', { workspaceId: WORKSPACE_ID, path: '/Reports' }), + context + ) + + expect(response.status).toBe(200) + expect((await response.json()).data.folder).toEqual({ + name: 'Reports', + path: '/Reports', + parentPath: '/', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }) + expect(mocks.createFolder).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, path: '/Reports' }, + request: expect.anything(), + }) + }) + + it('relocates a folder through the shared operation', async () => { + const response = await PATCH( + request('PATCH', '/api/v2/files/folders', { + workspaceId: WORKSPACE_ID, + path: '/Reports', + destinationPath: '/Archive/Reports', + }), + context + ) + + expect(response.status).toBe(200) + expect(mocks.updateFolder).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + path: '/Reports', + destinationPath: '/Archive/Reports', + }, + request: expect.anything(), + }) + }) + + it('deletes a folder and returns the v2 deletion result', async () => { + const response = await DELETE( + request( + 'DELETE', + `/api/v2/files/folders?workspaceId=${WORKSPACE_ID}&path=%2FReports&recursive=true` + ), + context + ) + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual({ + path: '/Reports', + deleted: true, + deletedItems: { folders: 1, files: 2 }, + }) + }) + + it('authenticates before parsing folder input', async () => { + mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await POST(request('POST', '/api/v2/files/folders', {}), context) + + expect(response.status).toBe(401) + expect(mocks.createFolder).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/files/folders/route.ts b/apps/sim/app/api/v2/files/folders/route.ts index 7ed1d0b4aee..1bbcbe91fd4 100644 --- a/apps/sim/app/api/v2/files/folders/route.ts +++ b/apps/sim/app/api/v2/files/folders/route.ts @@ -4,109 +4,91 @@ import { v2ListFileFoldersContract, v2RelocateFileFolderContract, } from '@/lib/api/contracts/v2/files' -import { toFolderPathView } from '@/lib/folders/paths' -import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { - performCreateWorkspaceFileFolderAtPath, - performDeleteWorkspaceFileFolderByPath, - performRelocateWorkspaceFileFolderByPath, -} from '@/lib/workspace-files/orchestration/file-folder-lifecycle' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { - resolveFolderPathId, - toV2PathFolder, - v2FolderPathMutationError, -} from '@/app/api/v2/lib/folders' -import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' + createWorkspaceFileFolderOperation, + deleteWorkspaceFileFolderOperation, + listWorkspaceFileFoldersOperation, + updateWorkspaceFileFolderOperation, +} from '@/lib/workspace-files/application/workspace-file-folders' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withPublicApiRouteHandler({ - contract: v2ListFileFoldersContract, - rateLimitEndpoint: 'files', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, parentPath, search, sortBy, sortOrder } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) +function toV2Folder(folder: { name: string; path: string; createdAt: Date; updatedAt: Date }) { + const path = folder.path.startsWith('/') ? folder.path : `/${folder.path}` + const parentPath = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) || '/' : '/' + return { + name: folder.name, + path, + parentPath, + createdAt: folder.createdAt.toISOString(), + updatedAt: folder.updatedAt.toISOString(), + } +} - const index = await loadActiveFolderPathIndex(workspaceId, 'file') - const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) - if (parentPath !== undefined && parentId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found') - } - const rows = await listActiveFolderRows(workspaceId, 'file', { - parentId, - search, - sortBy, - sortOrder, - }) - return v2CursorList( - rows.map((row) => toV2PathFolder(row, index, false)), - null, - { rateLimit } - ) - }, +export const GET = defineV2JsonRoute({ + contract: v2ListFileFoldersContract, + auth: v2ApiKeyAuth, + operation: fileOperations.listFolders, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + parentPath: query.parentPath, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + }), + useCase: listWorkspaceFileFoldersOperation, + present: ({ folders }) => ({ data: folders.map(toV2Folder), nextCursor: null }), }) -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateFileFolderContract, - rateLimitEndpoint: 'files', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await performCreateWorkspaceFileFolderAtPath({ workspaceId, userId, path }) - if (!result.success || !result.folder || !result.path) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') - } - return v2Data( - { folder: toFolderPathView(result.folder, result.path) }, - { rateLimit, status: 201 } - ) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.createFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ body }) => ({ workspaceId: body.workspaceId, path: body.path }), + useCase: createWorkspaceFileFolderOperation, + present: ({ folder }) => ({ data: { folder: toV2Folder(folder) } }), }) -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2RelocateFileFolderContract, - rateLimitEndpoint: 'files', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path, destinationPath } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await performRelocateWorkspaceFileFolderByPath({ - workspaceId, - userId, - path, - destinationPath, - }) - if (!result.success || !result.folder || !result.path) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') - } - return v2Data({ folder: toFolderPathView(result.folder, result.path) }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.updateFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + path: body.path, + destinationPath: body.destinationPath, + }), + useCase: updateWorkspaceFileFolderOperation, + present: ({ folder }) => ({ data: { folder: toV2Folder(folder) } }), }) -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteFileFolderContract, - rateLimitEndpoint: 'files', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path, recursive } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await performDeleteWorkspaceFileFolderByPath({ - workspaceId, - userId, - path, - recursive, - }) - if (!result.success || !result.deletedItems) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') - } - return v2Data( - { path, deleted: true as const, deletedItems: result.deletedItems }, - { rateLimit } - ) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.deleteFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + path: query.path, + recursive: query.recursive, + }), + useCase: deleteWorkspaceFileFolderOperation, + present: ({ deletedItems, path }) => ({ + data: { + path: path ?? '/', + deleted: true as const, + deletedItems, + }, + }), }) diff --git a/apps/sim/app/api/v2/files/move/route.test.ts b/apps/sim/app/api/v2/files/move/route.test.ts index b6651476b8f..d192bf356b1 100644 --- a/apps/sim/app/api/v2/files/move/route.test.ts +++ b/apps/sim/app/api/v2/files/move/route.test.ts @@ -4,51 +4,65 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformMove } = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformMove: vi.fn(), +const { mockPreauth, mockOperationRate, mockGate, mockExecute } = vi.hoisted(() => ({ + mockPreauth: vi.fn(), + mockOperationRate: vi.fn(), + mockGate: vi.fn(), + mockExecute: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: vi.fn().mockResolvedValue({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['workspace:workspace-1'], + rateLimitSubscription: null, + keyType: 'workspace', + }), + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mockPreauth + checkRateLimitDirectOrThrow = mockOperationRate + }, + getRateLimit: vi + .fn() + .mockReturnValue({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performMoveWorkspaceFileItems: mockPerformMove, +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGate })) +vi.mock('@/lib/workspace-files/application/move-workspace-file-items', () => ({ + moveWorkspaceFileItemsOperation: { + operation: { id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mockExecute, + }, })) import { POST } from '@/app/api/v2/files/move/route' const WS = 'workspace-1' - const RATE_LIMIT_OK = { allowed: true, - userId: 'user-1', - keyType: 'workspace', limit: 100, remaining: 99, resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 0, } - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} +const RATE_LIMIT_DENIED = { ...RATE_LIMIT_OK, allowed: false, remaining: 0, retryAfterMs: 1000 } const callMove = (body: unknown) => POST( new NextRequest('http://localhost:3000/api/v2/files/move', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'x-api-key': 'key' }, body: JSON.stringify(body), }) ) @@ -56,42 +70,37 @@ const callMove = (body: unknown) => describe('POST /api/v2/files/move', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformMove.mockResolvedValue({ success: true, movedItems: { files: 2, folders: 0 } }) + mockPreauth.mockResolvedValue(RATE_LIMIT_OK) + mockOperationRate.mockResolvedValue(RATE_LIMIT_OK) + mockGate.mockResolvedValue(null) + mockExecute.mockResolvedValue({ movedItems: { files: 2, folders: 0 } }) }) it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - + mockGate.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) - expect(res.status).toBe(404) - expect(mockPerformMove).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() }) it('400s when the selection is empty', async () => { const res = await callMove({ workspaceId: WS }) expect(res.status).toBe(400) expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformMove).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) + it('surfaces a forbidden collection operation', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + mockExecute.mockRejectedValue(new OrchestrationError('forbidden', 'Access denied')) const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(403) - expect(mockPerformMove).not.toHaveBeenCalled() + expect(mockExecute).toHaveBeenCalledOnce() }) it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + mockPreauth.mockResolvedValue(RATE_LIMIT_DENIED) const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(429) expect((await res.json()).error.code).toBe('RATE_LIMITED') @@ -103,40 +112,27 @@ describe('POST /api/v2/files/move', () => { fileIds: ['wf_1', 'wf_2'], targetFolderPath: '/Reports', }) - const body = await res.json() - expect(res.status).toBe(200) - expect(body.data).toEqual({ movedItems: { files: 2 } }) - expect(mockPerformMove).toHaveBeenCalledWith({ - workspaceId: WS, - userId: 'user-1', - fileIds: ['wf_1', 'wf_2'], - targetFolderPath: '/Reports', - }) + expect((await res.json()).data).toEqual({ movedItems: { files: 2 } }) + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ + input: { workspaceId: WS, fileIds: ['wf_1', 'wf_2'], targetFolderPath: '/Reports' }, + }) + ) }) it('treats an omitted targetFolderPath as the workspace root', async () => { await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) - expect(mockPerformMove).toHaveBeenCalledWith( - expect.objectContaining({ fileIds: ['wf_1'], targetFolderPath: '/' }) + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ targetFolderPath: '/' }) }) ) }) - it('maps a conflict errorCode to 409 without partially applying', async () => { - mockPerformMove.mockResolvedValue({ - success: false, - error: 'A file named "data.csv" already exists in the destination folder', - errorCode: 'conflict', - }) - - const res = await callMove({ - workspaceId: WS, - fileIds: ['wf_1'], - targetFolderPath: '/Reports', - }) - const body = await res.json() - + it('maps a conflict error to 409', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + mockExecute.mockRejectedValue(new OrchestrationError('conflict', 'Name collision')) + const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(409) - expect(body.error.code).toBe('CONFLICT') + expect((await res.json()).error.code).toBe('CONFLICT') }) }) diff --git a/apps/sim/app/api/v2/files/move/route.ts b/apps/sim/app/api/v2/files/move/route.ts index 9d306d1269b..2efa115b366 100644 --- a/apps/sim/app/api/v2/files/move/route.ts +++ b/apps/sim/app/api/v2/files/move/route.ts @@ -1,44 +1,23 @@ import { v2MoveFileItemsContract } from '@/lib/api/contracts/v2/files' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2Data, v2ErrorForOrchestration, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * POST /api/v2/files/move — Move files into a folder. - * - * An omitted `targetFolderPath` moves the selection to the - * workspace root. The whole selection moves under one advisory lock, so a name - * collision at the destination fails the request as `CONFLICT` rather than - * partially applying. - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2MoveFileItemsContract, - rateLimitEndpoint: 'file-move', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, fileIds, targetFolderPath } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performMoveWorkspaceFileItems({ - workspaceId, - userId, - fileIds, - targetFolderPath: targetFolderPath ?? '/', - }) - - if (!result.success || !result.movedItems) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to move file items') - ) - } - - return v2Data({ movedItems: { files: result.movedItems.files } }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.move, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + fileIds: body.fileIds, + targetFolderPath: body.targetFolderPath ?? '/', + }), + useCase: moveWorkspaceFileItemsOperation, + present: ({ movedItems }) => ({ data: { movedItems: { files: movedItems.files } } }), }) diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index 7bf7aa9f384..4b6508d5c20 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -4,521 +4,220 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockPerformCreateWorkspaceFile, - mockQueryWorkspaceFiles, - mockResolveWorkspaceAccess, - mockV2ApiGateError, - mockLoadActiveFolderPathIndex, - mockGetUserEmailsByIds, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockPerformCreateWorkspaceFile: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockQueryWorkspaceFiles: vi.fn(), - mockV2ApiGateError: vi.fn().mockResolvedValue(null), - mockLoadActiveFolderPathIndex: vi.fn(), - mockGetUserEmailsByIds: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + createFile: vi.fn(), + queryFiles: vi.fn(), + getUserEmailsByIds: vi.fn(), + gate: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({ + createWorkspaceFile: { + operation: { id: 'files.create', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.createFile, + }, })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: mockV2ApiGateError, +vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ + queryWorkspaceFilePage: { + operation: { id: 'files.list', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.queryFiles, + }, })) -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - queryWorkspaceFiles: mockQueryWorkspaceFiles, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +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('@/lib/workspace-files/orchestration', () => ({ - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, - performCreateWorkspaceFile: mockPerformCreateWorkspaceFile, -})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) vi.mock('@/lib/users/queries', () => ({ - getUserEmailsByIds: mockGetUserEmailsByIds, + getUserEmailsByIds: mocks.getUserEmailsByIds, requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, })) import { OrchestrationError } from '@/lib/core/orchestration/types' -import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' import { GET, POST } from '@/app/api/v2/files/route' -const WS = 'workspace-1' -const FOLDER_ID = 'fold_1' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, +const WORKSPACE_ID = 'workspace-1' +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -function buildRecord(overrides: Record = {}) { - return { - id: 'wf_1', - workspaceId: WS, - name: 'data.csv', - key: 'workspace/ws/1-x-data.csv', - path: '/api/files/serve/x', - size: 1024, - type: 'text/csv', - uploadedBy: 'user-1', - folderId: null, - folderPath: null, - uploadedAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - ...overrides, - } -} - -/** What the route forwards for a bare `?workspaceId=` list. */ -const DEFAULT_LIST_ARGS = { - folderId: undefined, - search: undefined, - sortBy: 'uploadedAt', - sortOrder: 'asc', - limit: 100, - after: undefined, +const FILE = { + id: 'wf_1', + workspaceId: WORKSPACE_ID, + name: 'notes.md', + key: `workspace/${WORKSPACE_ID}/notes.md`, + path: '/api/files/serve/notes.md?context=workspace', + size: 0, + type: 'text/markdown', + uploadedBy: 'user-1', + folderId: null, + folderPath: null, + uploadedAt: new Date('2026-08-04T00:00:00.000Z'), + updatedAt: new Date('2026-08-05T00:00:00.000Z'), } -const callList = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/files?${query}`)) - -function createRequest(body: Record) { +function createRequest(body: unknown): NextRequest { return new NextRequest('http://localhost:3000/api/v2/files', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: typeof body === 'string' ? body : JSON.stringify(body), }) } -describe('GET /api/v2/files', () => { +describe('/api/v2/files', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null }) - mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map([['fold_1', { id: 'fold_1', name: 'Reports', parentId: null }]]), - pathById: new Map([['fold_1', '/Reports']]), - idByPath: new Map([ - ['/Reports', 'fold_1'], - ['/Fixtures', 'fold_1'], - ]), + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-04T01:00:00.000Z'), }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callList(`workspaceId=${WS}`) - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callList('limit=10') - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('400s on a scope outside the enum', async () => { - const res = await callList(`workspaceId=${WS}&scope=everything`) - expect(res.status).toBe(400) - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-04T01:00:00.000Z'), }) - const res = await callList(`workspaceId=${WS}`) - expect(res.status).toBe(403) - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callList(`workspaceId=${WS}`) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('returns the public file shape including folder and updatedAt', async () => { - mockQueryWorkspaceFiles.mockResolvedValue({ - files: [buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' })], - nextKeys: null, + mocks.queryFiles.mockResolvedValue({ + files: [FILE], + nextKeys: undefined, + cursorSort: 'name:asc', }) - - const res = await callList(`workspaceId=${WS}`) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.nextCursor).toBeNull() - expect(body.data).toEqual([ - { - id: 'wf_1', - name: 'data.csv', - size: 1024, - type: 'text/csv', - key: 'workspace/ws/1-x-data.csv', - folderPath: '/Reports/Q1', - uploadedByEmail: 'ada@example.com', - uploadedAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - }, - ]) - expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) + mocks.createFile.mockResolvedValue({ file: FILE }) + mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) - it('lists active files only and rejects the removed archived scope', async () => { - await callList(`workspaceId=${WS}`) - expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) + it('authenticates and charges before validating list input', async () => { + const response = await GET(new NextRequest('http://localhost:3000/api/v2/files')) - const res = await callList(`workspaceId=${WS}&scope=archived`) - expect(res.status).toBe(400) + expect(response.status).toBe(400) + expect(mocks.authenticateV2ApiKey).toHaveBeenCalled() + expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(mocks.queryFiles).not.toHaveBeenCalled() }) - it('forwards search, folder, and sort into the query rather than filtering the result', async () => { - await callList( - `workspaceId=${WS}&search=report&folderPath=${encodeURIComponent('/Reports')}&sortBy=name&sortOrder=desc` + it('lists through the shared use case and v2 presenter', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}&sortBy=name` ) - - expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, { - ...DEFAULT_LIST_ARGS, - folderId: FOLDER_ID, - search: 'report', - sortBy: 'name', - sortOrder: 'desc', - }) - }) - - it('treats folderPath=/ as root-only while omission lists every folder', async () => { - await callList(`workspaceId=${WS}&folderPath=%2F`) - - expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, { - ...DEFAULT_LIST_ARGS, - folderId: null, - }) - }) - - it('400s on a sort field outside the enum instead of passing it toward the query', async () => { - const res = await callList(`workspaceId=${WS}&sortBy=name;DROP TABLE workspace_files`) - - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('400s on an empty search rather than treating it as unsearched', async () => { - const res = await callList(`workspaceId=${WS}&search=`) - - expect(res.status).toBe(400) - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('emits a cursor stamped with the sort and resumes from its keys', async () => { - mockQueryWorkspaceFiles.mockResolvedValue({ - files: [buildRecord()], - nextKeys: ['data.csv', 'wf_1'], - }) - - const first = await callList(`workspaceId=${WS}&sortBy=name`) - const { nextCursor } = await first.json() - expect(nextCursor).not.toBeNull() - - await callList(`workspaceId=${WS}&sortBy=name&cursor=${encodeURIComponent(nextCursor)}`) - - expect(mockQueryWorkspaceFiles).toHaveBeenLastCalledWith(WS, { - ...DEFAULT_LIST_ARGS, - sortBy: 'name', - after: ['data.csv', 'wf_1'], - }) - }) - - it('400s when a cursor is replayed under a different sort', async () => { - mockQueryWorkspaceFiles.mockResolvedValue({ - files: [buildRecord()], - nextKeys: ['data.csv', 'wf_1'], + const response = await GET(request) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: [ + { + id: FILE.id, + name: 'notes.md', + size: 0, + type: 'text/markdown', + key: FILE.key, + folderPath: '/', + uploadedByEmail: 'ada@example.com', + uploadedAt: '2026-08-04T00:00:00.000Z', + updatedAt: '2026-08-05T00:00:00.000Z', + }, + ], + nextCursor: null, + }) + expect(mocks.queryFiles).toHaveBeenCalledWith({ + principal: auth.principal, + input: expect.objectContaining({ + workspaceId: WORKSPACE_ID, + sortBy: 'name', + sortOrder: 'asc', + limit: 100, + }), + request, }) - - const first = await callList(`workspaceId=${WS}&sortBy=name`) - const { nextCursor } = await first.json() - mockQueryWorkspaceFiles.mockClear() - - const res = await callList( - `workspaceId=${WS}&sortBy=size&cursor=${encodeURIComponent(nextCursor)}` - ) - - expect(res.status).toBe(400) - expect((await res.json()).error.message).toMatch(/cursor does not match/i) - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('400s on a malformed cursor instead of silently restarting from page one', async () => { - const res = await callList(`workspaceId=${WS}&cursor=not-a-cursor`) - - expect(res.status).toBe(400) - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() }) - it('400s when the cursor carries values the sort cannot hold', async () => { - mockQueryWorkspaceFiles.mockRejectedValue( - new OrchestrationError('validation', 'cursor does not match the requested sortBy/sortOrder.') + it('rejects malformed cursors before the application service', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}&cursor=not-a-cursor` + ) ) - const cursor = Buffer.from( - JSON.stringify({ sort: 'uploadedAt:asc', keys: ['not-a-date', 'wf_1'] }) - ).toString('base64') - - const res = await callList(`workspaceId=${WS}&cursor=${encodeURIComponent(cursor)}`) - - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - }) - - it('terminates pagination when the query reports no further keys', async () => { - mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null }) - - const res = await callList(`workspaceId=${WS}&search=data`) - - expect((await res.json()).nextCursor).toBeNull() - }) -}) - -describe('POST /api/v2/files', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockV2ApiGateError.mockResolvedValue(null) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformCreateWorkspaceFile.mockResolvedValue({ - success: true, - file: buildRecord({ name: 'untitled.md', size: 0, type: 'text/markdown' }), - }) - mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) - }) - - it('creates an empty exact-name file with an inferred MIME type', async () => { - const request = createRequest({ workspaceId: WS, name: 'untitled.md' }) - - const response = await POST(request) - expect(response.status).toBe(201) - await expect(response.json()).resolves.toMatchObject({ - data: { id: 'wf_1', name: 'untitled.md', size: 0, type: 'text/markdown' }, - }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(RATE_LIMIT_OK, 'user-1', WS, 'write') - expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith({ - workspaceId: WS, - userId: 'user-1', - name: 'untitled.md', - contentType: 'text/markdown', - folderPath: '/', - content: Buffer.alloc(0), - exactName: true, - request, - }) + expect(response.status).toBe(400) + expect(mocks.queryFiles).not.toHaveBeenCalled() }) - it('decodes initialized base64 content before orchestration', async () => { - mockPerformCreateWorkspaceFile.mockResolvedValue({ - success: true, - file: buildRecord({ - name: 'seed.bin', - size: 3, - type: 'application/octet-stream', - folderId: FOLDER_ID, - folderPath: 'Fixtures', - }), - }) + it('creates through the workspace-key principal without human analytics', async () => { const request = createRequest({ - workspaceId: WS, - name: 'seed.bin', - contentType: 'application/octet-stream', - folderPath: '/Fixtures', - content: Buffer.from([1, 2, 3]).toString('base64'), + workspaceId: WORKSPACE_ID, + name: 'notes.md', + content: 'TQ==', encoding: 'base64', }) - const response = await POST(request) expect(response.status).toBe(201) - expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: WS, - name: 'seed.bin', - contentType: 'application/octet-stream', - folderPath: '/Fixtures', - content: Buffer.from([1, 2, 3]), + expect((await response.json()).data.name).toBe('notes.md') + expect(mocks.createFile).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: WORKSPACE_ID, + name: 'notes.md', + contentType: 'text/markdown', + content: 'TQ==', + encoding: 'base64', + folderPath: '/', exactName: true, - }) - ) + }, + request, + }) }) - it('rejects malformed base64 before workspace access or orchestration', async () => { + it('rejects malformed base64 after authentication and rate limiting', async () => { const response = await POST( createRequest({ - workspaceId: WS, - name: 'seed.bin', + workspaceId: WORKSPACE_ID, + name: 'notes.md', content: 'not-base64!', encoding: 'base64', }) ) expect(response.status).toBe(400) - await expect(response.json()).resolves.toMatchObject({ - error: { code: 'BAD_REQUEST' }, - }) - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.authenticateV2ApiKey).toHaveBeenCalled() + expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(mocks.createFile).not.toHaveBeenCalled() }) - it('accepts empty base64 as a zero-byte file', async () => { - const response = await POST( - createRequest({ workspaceId: WS, name: 'empty.bin', content: '', encoding: 'base64' }) - ) - - expect(response.status).toBe(201) - expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( - expect.objectContaining({ content: Buffer.alloc(0) }) - ) - }) + it('renders typed conflicts and hides unknown errors', async () => { + mocks.createFile.mockRejectedValueOnce(new OrchestrationError('conflict', 'Name exists')) + const conflict = await POST(createRequest({ workspaceId: WORKSPACE_ID, name: 'notes.md' })) + expect(conflict.status).toBe(409) + expect((await conflict.json()).error.code).toBe('CONFLICT') - it('returns the canonical v2 envelope when the JSON body exceeds the inline limit', async () => { - const request = new NextRequest('http://localhost:3000/api/v2/files', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': String(MAX_WORKSPACE_FILE_INLINE_BODY_BYTES + 1), - }, - body: '{}', + mocks.createFile.mockRejectedValueOnce(new Error('database details')) + const unexpected = await POST(createRequest({ workspaceId: WORKSPACE_ID, name: 'notes.md' })) + expect(unexpected.status).toBe(500) + expect(await unexpected.json()).toMatchObject({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, }) - - const response = await POST(request) - - expect(response.status).toBe(413) - await expect(response.json()).resolves.toMatchObject({ - error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' }, - }) - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() - }) - - it('returns the canonical v2 envelope for malformed JSON', async () => { - const request = new NextRequest('http://localhost:3000/api/v2/files', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: '{not-json', - }) - - const response = await POST(request) - - expect(response.status).toBe(400) - await expect(response.json()).resolves.toMatchObject({ - error: { code: 'BAD_REQUEST', message: 'Request body must be valid JSON' }, - }) - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() - }) - - it.each([ - { - label: 'name conflict', - result: { - success: false, - error: 'A file with this name already exists', - errorCode: 'conflict', - }, - status: 409, - code: 'CONFLICT', - message: 'A file with this name already exists', - }, - { - label: 'internal orchestration failure', - result: { success: false, error: 'database connection details', errorCode: 'internal' }, - status: 500, - code: 'INTERNAL_ERROR', - message: 'Internal server error', - }, - ])('maps a $label into the v2 error envelope', async ({ result, status, code, message }) => { - mockPerformCreateWorkspaceFile.mockResolvedValue(result) - - const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) - - expect(response.status).toBe(status) - await expect(response.json()).resolves.toMatchObject({ error: { code, message } }) - }) - - it('returns the auth failure before gating, access checks, or orchestration', async () => { - mockCheckRateLimit.mockResolvedValue({ - allowed: false, - error: 'Invalid API key', - limit: 100, - remaining: 0, - resetAt: RATE_LIMIT_OK.resetAt, - }) - - const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) - - expect(response.status).toBe(401) - await expect(response.json()).resolves.toMatchObject({ - error: { code: 'UNAUTHORIZED', message: 'Invalid API key' }, - }) - expect(mockV2ApiGateError).not.toHaveBeenCalled() - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() - }) - - it('returns the v2 gate failure before access checks or orchestration', async () => { - const { v2Error } = await import('@/app/api/v2/lib/response') - mockV2ApiGateError.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) - - expect(response.status).toBe(404) - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() - }) - - it('requires workspace write access before orchestration', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - - const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) - - expect(response.status).toBe(403) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(RATE_LIMIT_OK, 'user-1', WS, 'write') - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index bd8fa79e89a..bba97991356 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -3,117 +3,78 @@ import { v2CreateFileContract, v2ListFilesContract, } from '@/lib/api/contracts/v2/files' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { queryWorkspaceFiles } from '@/lib/uploads/contexts/workspace' +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 { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' -import { - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - performCreateWorkspaceFile, -} from '@/lib/workspace-files/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { createWorkspaceFile } from '@/lib/workspace-files/application/create-workspace-file' +import { queryWorkspaceFilePage } from '@/lib/workspace-files/application/list-workspace-files' +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 { resolveFolderPathId } from '@/app/api/v2/lib/folders' import { cursorSortKey, decodeSortedCursor, encodeSortedCursor, - v2CaughtOrchestrationError, - v2CursorList, - v2CursorSortError, - v2Data, v2Error, - v2ErrorForOrchestration, - v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * GET /api/v2/files — List files in a workspace with search, sort, and cursor - * pagination. - * - * Filtering, ordering, and the page slice all run inside - * {@link queryWorkspaceFiles}' query. The route only translates the validated - * params and the opaque cursor, so a `search` never costs a full-workspace read. - */ -export const GET = withPublicApiRouteHandler({ +/** GET /api/v2/files — List files with search, sort, and cursor pagination. */ +export const GET = defineV2JsonRoute({ contract: v2ListFilesContract, - rateLimitEndpoint: 'files', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file') - const folderId = - folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) - if (folderPath !== undefined && folderId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found') - } - - const sort = cursorSortKey(sortBy, sortOrder) - const decoded = decodeSortedCursor(cursor, sort) - if (decoded.status === 'invalid') return v2CursorSortError() - - const { files, nextKeys } = await queryWorkspaceFiles(workspaceId, { - folderId, - search, - sortBy, - sortOrder, - limit, - after: decoded.status === 'ok' ? decoded.keys : undefined, - }) - - const items: V2File[] = await toV2Files(files) - const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null - - return v2CursorList(items, nextCursor, { rateLimit }) - } catch (error) { - // A cursor that doesn't fit the requested sort arrives classified as `validation` → 400. - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - throw error + auth: v2ApiKeyAuth, + operation: fileOperations.list, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ query }) => { + const cursorSort = cursorSortKey(query.sortBy, query.sortOrder) + const decoded = decodeSortedCursor(query.cursor, cursorSort) + if (decoded.status === 'invalid') { + throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + } + return { + workspaceId: query.workspaceId, + folderPath: query.folderPath, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, + after: decoded.status === 'ok' ? decoded.keys : undefined, + cursorSort, } }, + useCase: queryWorkspaceFilePage, + present: async ({ files, nextKeys, cursorSort }) => { + const items: V2File[] = await toV2Files(files) + return { data: items, nextCursor: nextKeys ? encodeSortedCursor(cursorSort, nextKeys) : null } + }, }) -/** POST /api/v2/files — Create an authored workspace file, optionally with initial content. */ -export const POST = withPublicApiRouteHandler({ +/** POST /api/v2/files — Create an authored workspace file. */ +export const POST = defineV2JsonRoute({ contract: v2CreateFileContract, - rateLimitEndpoint: 'files', + auth: v2ApiKeyAuth, + operation: fileOperations.create, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, parseOptions: { invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, payloadTooLargeResponse: () => v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large'), }, - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { workspaceId, name, contentType, folderPath, content, encoding } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performCreateWorkspaceFile({ - workspaceId, - userId, - name, - contentType: contentType ?? getMimeTypeFromExtension(getFileExtension(name)), - folderPath: folderPath ?? '/', - content: Buffer.from(content, encoding), - exactName: true, - request, - }) - if (!result.success || !result.file) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to create file') - ) - } - - return v2Data(await toV2File(result.file), { rateLimit, status: 201 }) - }, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + name: body.name, + contentType: body.contentType ?? getMimeTypeFromExtension(getFileExtension(body.name)), + content: body.content, + encoding: body.encoding, + folderPath: body.folderPath ?? '/', + exactName: true, + }), + useCase: createWorkspaceFile, + present: async ({ file }) => ({ data: await toV2File(file) }), }) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts index 87d477083ab..42cee5756b3 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts @@ -1,48 +1,22 @@ import { v2CompleteFileUploadContract } from '@/lib/api/contracts/v2/files' -import { completeUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { finalizeWorkspaceFileUpload } from '@/app/api/files/uploads/finalizers' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' -import { - v2CaughtOrchestrationError, - v2Data, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { completeWorkspaceFileUploadOperation } from '@/lib/uploads/upload-session/application' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { toV2FileUpload, v2UploadControlError } from '@/app/api/v2/files/uploads/utils' -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CompleteFileUploadContract, - rateLimitEndpoint: 'files', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - try { - const { uploadId } = input.params - const { workspaceId } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const session = await getOwnedUploadSession({ - uploadId, - workspaceId, - userId, - purpose: 'workspace_file', - uploadToken: input.headers['upload-token'], - }) - const result = await completeUploadSession({ - session, - finalize: async (claimed) => { - const finalized = await finalizeWorkspaceFileUpload({ - session: claimed, - actor: { id: userId }, - request, - source: 'api', - }) - return { value: finalized.file, completedFileId: finalized.file.id } - }, - }) - return v2Data(await toV2FileUpload(result.session, result.value), { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + auth: v2ApiKeyAuth, + operation: fileOperations.uploadComplete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: { render: v2UploadControlError }, + mapInput: ({ params, query, headers }) => ({ + uploadId: params.uploadId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + }), + useCase: completeWorkspaceFileUploadOperation, + present: async (result) => ({ + data: await toV2FileUpload(result.session, result.value), + }), }) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts index 926feab0e97..9261a7fd471 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts @@ -1,39 +1,21 @@ import { v2CreateFileUploadPartUrlsContract } from '@/lib/api/contracts/v2/files' -import { createUploadPartUrls, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { issueWorkspaceFileUploadPartsOperation } from '@/lib/uploads/upload-session/application' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { v2UploadControlError } from '@/app/api/v2/files/uploads/utils' -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateFileUploadPartUrlsContract, - rateLimitEndpoint: 'files', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - try { - const { uploadId } = input.params - const { workspaceId } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const session = await getOwnedUploadSession({ - uploadId, - workspaceId, - userId, - purpose: 'workspace_file', - uploadToken: input.headers['upload-token'], - }) - const parts = await createUploadPartUrls({ - session, - partNumbers: input.body.partNumbers, - localOrigin: request.nextUrl.origin, - }) - return v2Data({ parts }, { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + auth: v2ApiKeyAuth, + operation: fileOperations.uploadParts, + rateLimit: v2RateLimits.publicApi, + errorPolicy: { render: v2UploadControlError }, + mapInput: ({ params, query, headers, body }) => ({ + uploadId: params.uploadId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + partNumbers: body.partNumbers, + }), + useCase: issueWorkspaceFileUploadPartsOperation, + present: ({ parts }) => ({ data: { parts } }), }) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts index 49d085ffe68..f55e531f199 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts @@ -1,36 +1,20 @@ import { v2AbortFileUploadContract } from '@/lib/api/contracts/v2/files' -import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' -import { - v2CaughtOrchestrationError, - v2Data, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { abortWorkspaceFileUploadOperation } from '@/lib/uploads/upload-session/application' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { toV2FileUpload, v2UploadControlError } from '@/app/api/v2/files/uploads/utils' -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2AbortFileUploadContract, - rateLimitEndpoint: 'files', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { uploadId } = input.params - const { workspaceId } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const session = await getOwnedUploadSession({ - uploadId, - workspaceId, - userId, - purpose: 'workspace_file', - uploadToken: input.headers['upload-token'], - }) - const aborted = await abortUploadSession(session) - return v2Data(await toV2FileUpload(aborted, null), { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + auth: v2ApiKeyAuth, + operation: fileOperations.uploadCancel, + rateLimit: v2RateLimits.publicApi, + errorPolicy: { render: v2UploadControlError }, + mapInput: ({ params, query, headers }) => ({ + uploadId: params.uploadId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + }), + useCase: abortWorkspaceFileUploadOperation, + present: async (session) => ({ data: await toV2FileUpload(session, null) }), }) diff --git a/apps/sim/app/api/v2/files/uploads/route.test.ts b/apps/sim/app/api/v2/files/uploads/route.test.ts index 884dfbe87ce..27fdc5fca91 100644 --- a/apps/sim/app/api/v2/files/uploads/route.test.ts +++ b/apps/sim/app/api/v2/files/uploads/route.test.ts @@ -4,209 +4,151 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockCreateUploadSession, - mockLoadActiveFolderPathIndex, - mockWithFolderTreeLock, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockCreateUploadSession: vi.fn(), - mockLoadActiveFolderPathIndex: vi.fn(), - mockWithFolderTreeLock: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + createUpload: vi.fn(), + gate: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/uploads/upload-session/application', () => ({ + createWorkspaceFileUploadOperation: { + operation: { id: 'files.upload.create', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.createUpload, + }, })) -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/lib/folders/locks', () => ({ - withFolderTreeLock: mockWithFolderTreeLock, +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('@/lib/uploads/upload-session/service', () => ({ - createUploadSession: mockCreateUploadSession, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) + +vi.mock('@/app/api/v2/files/uploads/utils', () => ({ + toV2FileUpload: vi.fn(async () => ({ + id: 'upload-1', + status: 'uploading', + name: 'file.csv', + contentType: 'text/csv', + size: 10, + expiresAt: '2026-08-04T21:00:00.000Z', + error: null, + file: null, + })), })) import { POST } from '@/app/api/v2/files/uploads/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' -const RATE_LIMIT = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2026-08-03T22:00:00.000Z'), +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } const UPLOAD_SESSION = { id: 'upload-1', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - knowledgeBaseId: null, - workflowId: null, - executionId: null, - purpose: 'workspace_file', - method: 'put', - storageContext: 'workspace', - storageKey: `${WORKSPACE_ID}/file.csv`, - finalKey: `${WORKSPACE_ID}/file.csv`, - storageProvider: 's3', - providerUploadId: null, - providerObjectVersion: null, - fileName: 'file.csv', - contentType: 'text/csv', - fileSize: 10, - partSize: null, - partCount: null, - status: 'uploading', uploadToken: 'signed-upload-token', - metadata: {}, - completedFileId: null, - error: null, - expiresAt: new Date('2026-08-04T21:00:00.000Z'), - createdAt: new Date('2026-08-03T21:00:00.000Z'), - updatedAt: new Date('2026-08-03T21:00:00.000Z'), - completedAt: null, transfer: { - method: 'put', + method: 'put' as const, url: 'https://storage.example/upload', headers: { 'content-type': 'text/csv' }, }, } function request(body: Record) { - return POST( - new NextRequest('http://localhost:3000/api/v2/files/uploads', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - ) + const request = new NextRequest('http://localhost:3000/api/v2/files/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), + }) + return { request, response: POST(request) } } describe('POST /api/v2/files/uploads', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockWithFolderTreeLock.mockImplementation(async (_workspaceId, _resourceType, operation) => - operation({}) - ) - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map(), - pathById: new Map(), - idByPath: new Map([['/Reports', 'folder-reports']]), + mocks.authenticateV2ApiKey.mockResolvedValue(AUTH) + mocks.gate.mockResolvedValue(null) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-04T21:00:00.000Z'), }) - mockCreateUploadSession.mockResolvedValue(UPLOAD_SESSION) + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-04T21:00:00.000Z'), + }) + mocks.createUpload.mockResolvedValue(UPLOAD_SESSION) }) - it('creates one signed PUT session for a small file', async () => { - const response = await request({ + it('creates a signed upload through the workspace principal pipeline', async () => { + const call = request({ workspaceId: WORKSPACE_ID, name: 'file.csv', contentType: 'text/csv', size: 10, }) + const response = await call.response expect(response.status).toBe(201) - const { data } = await response.json() - expect(data).toMatchObject({ - session: { id: 'upload-1', status: 'uploading', file: null }, - uploadToken: 'signed-upload-token', - transfer: { method: 'put', url: 'https://storage.example/upload' }, + expect(await response.json()).toMatchObject({ + data: { + session: { id: 'upload-1', status: 'uploading', file: null }, + uploadToken: 'signed-upload-token', + transfer: { method: 'put', url: 'https://storage.example/upload' }, + }, }) - expect(data.session).not.toHaveProperty('uploadToken') - expect(data.session).not.toHaveProperty('transfer') - expect(data.session).not.toHaveProperty('partSize') - expect(data.session).not.toHaveProperty('partCount') - expect(mockCreateUploadSession).toHaveBeenCalledWith({ - workspaceId: WORKSPACE_ID, - userId: 'user-1', - purpose: 'workspace_file', - fileName: 'file.csv', - contentType: 'text/csv', - fileSize: 10, - metadata: { folderId: null }, - localOrigin: 'http://localhost:3000', + expect(mocks.createUpload).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + name: 'file.csv', + contentType: 'text/csv', + size: 10, + folderPath: '/', + }, + request: call.request, }) }) - it('authorizes workspace write access before creating provider state', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - - const response = await request({ - workspaceId: WORKSPACE_ID, - name: 'file.csv', - contentType: 'text/csv', - size: 10, - }) + it('authenticates and rate limits before request validation', async () => { + const response = await request({ workspaceId: WORKSPACE_ID }).response - expect(response.status).toBe(403) - expect(mockLoadActiveFolderPathIndex).not.toHaveBeenCalled() - expect(mockCreateUploadSession).not.toHaveBeenCalled() + expect(response.status).toBe(400) + expect(mocks.authenticateV2ApiKey).toHaveBeenCalledTimes(1) + expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(mocks.createUpload).not.toHaveBeenCalled() }) - it('creates an upload session for an empty workspace file', async () => { - const response = await request({ + it('does not run a second creator-based authentication path', async () => { + await request({ workspaceId: WORKSPACE_ID, - name: 'file.csv', - contentType: 'text/csv', + name: 'empty.txt', + contentType: 'text/plain', size: 0, - }) - - expect(response.status).toBe(201) - expect(mockCreateUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ purpose: 'workspace_file', fileSize: 0 }) - ) - }) + }).response - it('releases the folder tree lock before creating an upload session', async () => { - let lockHeld = false - mockWithFolderTreeLock.mockImplementation(async (_workspaceId, _resourceType, operation) => { - lockHeld = true - try { - return await operation({}) - } finally { - lockHeld = false - } - }) - mockCreateUploadSession.mockImplementationOnce(async () => { - expect(lockHeld).toBe(false) - return UPLOAD_SESSION - }) - - const response = await request({ - workspaceId: WORKSPACE_ID, - name: 'file.csv', - contentType: 'text/csv', - size: 10, - folderPath: '/Reports', - }) - - expect(response.status).toBe(201) - expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith( - WORKSPACE_ID, - 'file', - expect.any(Object) - ) - expect(mockCreateUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ metadata: { folderId: 'folder-reports' } }) + expect(mocks.authenticateV2ApiKey).toHaveBeenCalledTimes(1) + expect(mocks.createUpload).toHaveBeenCalledWith( + expect.objectContaining({ principal: PRINCIPAL }) ) }) }) diff --git a/apps/sim/app/api/v2/files/uploads/route.ts b/apps/sim/app/api/v2/files/uploads/route.ts index 60ef13d4bd0..61194e27633 100644 --- a/apps/sim/app/api/v2/files/uploads/route.ts +++ b/apps/sim/app/api/v2/files/uploads/route.ts @@ -1,52 +1,30 @@ import { v2CreateFileUploadContract } from '@/lib/api/contracts/v2/files' -import { createUploadSession } from '@/lib/uploads/upload-session/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { createWorkspaceFileUploadOperation } from '@/lib/uploads/upload-session/application' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' -import { resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateFileUploadContract, - rateLimitEndpoint: 'files', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - try { - const { workspaceId, name, contentType, size, folderPath } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const resolution = await resolveFolderPathIdentity({ - workspaceId, - resourceType: 'file', - path: folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - const session = await createUploadSession({ - workspaceId, - userId, - purpose: 'workspace_file', - fileName: name, - contentType, - fileSize: size, - metadata: { folderId: resolution.folderId }, - localOrigin: request.nextUrl.origin, - }) - return v2Data( - { - session: await toV2FileUpload(session, null), - uploadToken: session.uploadToken, - transfer: session.transfer, - }, - { rateLimit, status: 201 } - ) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + auth: v2ApiKeyAuth, + operation: fileOperations.uploadCreate, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + name: body.name, + contentType: body.contentType, + size: body.size, + folderPath: body.folderPath ?? ROOT_FOLDER_PATH, + }), + useCase: createWorkspaceFileUploadOperation, + present: async (session) => ({ + data: { + session: await toV2FileUpload(session, null), + uploadToken: session.uploadToken, + transfer: session.transfer, + }, + }), }) diff --git a/apps/sim/app/api/v2/files/uploads/utils.ts b/apps/sim/app/api/v2/files/uploads/utils.ts index dad280cfca8..45d60afd0c5 100644 --- a/apps/sim/app/api/v2/files/uploads/utils.ts +++ b/apps/sim/app/api/v2/files/uploads/utils.ts @@ -35,3 +35,21 @@ function uploadStatus(status: string): V2UploadStatus { } return status } + +import type { Principal } from '@sim/auth/principal' +import type { NextRequest, NextResponse } from 'next/server' +import { authenticateV2ApiKey } from '@/lib/api/server/routes/v2-api-key-auth' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +/** Re-authenticates the API key for each upload control leg. */ +export async function authenticateUploadPrincipal(request: NextRequest): Promise { + const auth = await authenticateV2ApiKey(request.headers.get('x-api-key')) + return auth.principal +} + +/** Resource-ID upload controls conceal authorization failures as absence. */ +export function v2UploadControlError(error: unknown): NextResponse | null { + const response = v2CaughtOrchestrationError(error) + if (!response) return null + return response.status === 403 ? v2Error('NOT_FOUND', 'Upload session not found') : response +} diff --git a/apps/sim/app/api/v2/lib/gate.ts b/apps/sim/app/api/v2/lib/gate.ts index d9bf214eece..8f14be2f6c1 100644 --- a/apps/sim/app/api/v2/lib/gate.ts +++ b/apps/sim/app/api/v2/lib/gate.ts @@ -10,12 +10,10 @@ import { v2Error } from '@/app/api/v2/lib/response' * answers 404 as if it did not exist, so an ungated caller cannot distinguish * "not in the rollout cohort" from "no such endpoint". * - * Deliberately keyed on `userId` only. A workspace- or org-keyed gate would - * have to read membership for a caller-supplied id before authorization has - * run, and its 404-vs-403 split would then leak whether that workspace's org - * is in the cohort — the trap the per-domain table gate has to work around by - * running late. Keyed on the authenticated user, the check is safe to run - * first and is uniform across every v2 route. + * Deliberately keyed on a server-resolved user rollout subject, never a + * caller-supplied workspace. Personal keys use their authenticated user; + * workspace keys use the canonical workspace billing owner as rollout-only + * context. That billing owner is not an authorization principal. */ export async function v2ApiGateError(userId: string): Promise { if (await isFeatureEnabled('v2-api', { userId })) return null diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts index da54edf8957..79860777852 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts @@ -1,124 +1,23 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' import { workspaceFileCompiledCheckContract } from '@/lib/api/contracts/workspace-files' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile' -import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc' -import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' -import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' -import { validateMermaidSource } from '@/lib/mermaid/validate' -import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { compiledCheckWorkspaceFile } from '@/lib/workspace-files/application/compiled-check-workspace-file' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -const logger = createLogger('WorkspaceFileCompiledCheckAPI') - -/** - * GET /api/workspaces/[id]/files/[fileId]/compiled-check - * - * Compiles or validates the saved source for generated document-like files and - * returns whether it succeeds. Used by the file agent to self-verify generated - * code or diagram syntax before finalising an edit. - * - * Returns: - * 200 { ok: true } - * 200 { ok: false, error: string, errorName: string } — user code error - * 4xx on auth / missing file / unsupported extension - * 500 on system (sandbox infra) failure - */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const parsed = await parseRequest(workspaceFileCompiledCheckContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const membership = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!membership) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const fileRecord = await getWorkspaceFile(workspaceId, fileId) - if (!fileRecord) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } - - const ext = fileRecord.name.split('.').pop()?.toLowerCase() ?? '' - // In the E2B regime ALL four formats compile in the doc sandbox (Node for - // pptx/docx, Python for pdf/xlsx). Gate on the flag (not the stored MIME) so - // a stale file can't trigger an E2B compile when the sandbox is disabled. - const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(fileRecord.name) : null - const taskId = BINARY_DOC_TASKS[ext] - const isMermaidFile = ext === 'mmd' || ext === 'mermaid' - if (!e2bFmt && !taskId && !isMermaidFile) { - return NextResponse.json( - { error: `Compiled check only supports .docx, .pptx, .pdf, .xlsx, and .mmd files` }, - { status: 422 } - ) - } - - let buffer: Buffer - try { - buffer = await fetchWorkspaceFileBuffer(fileRecord) - } catch (err) { - logger.error('Failed to download file for compiled check', { - fileId, - error: toError(err).message, - }) - return NextResponse.json({ error: 'Failed to read file' }, { status: 500 }) - } - - const code = buffer.toString('utf-8') - - if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { - return NextResponse.json({ error: 'File source exceeds maximum size' }, { status: 413 }) - } - - if (isMermaidFile) { - return NextResponse.json(await validateMermaidSource(code)) - } - - if (e2bFmt) { - // Loads the compile-once artifact if present, else compiles via E2B once - // (and recalc-scans xlsx formulas). Only a script error is { ok: false }; - // infra failures rethrow → 500, so the agent isn't told to "fix its script" - // during an E2B/S3 outage. - const result = await runE2BCompiledCheck({ - source: code, - fileName: fileRecord.name, - workspaceId, - ext, - }) - return NextResponse.json(result) - } - - try { - if (!taskId) { - return NextResponse.json({ error: 'Unsupported compiled check target' }, { status: 422 }) - } - await runSandboxTask(taskId, { code, workspaceId }, { ownerKey: `user:${session.user.id}` }) - return NextResponse.json({ ok: true }) - } catch (err) { - if (err instanceof SandboxUserCodeError) { - logger.info('Compiled check failed with user code error', { - fileId, - taskId, - error: toError(err).message, - errorName: err.name, - }) - return NextResponse.json({ ok: false, error: toError(err).message, errorName: err.name }) - } - throw err - } - } -) +export const GET = defineInternalJsonRoute({ + contract: workspaceFileCompiledCheckContract, + auth: internalSessionAuth, + operation: compiledCheckWorkspaceFile.operation, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal compiled-check behavior', + }), + errorPolicy: internalFileErrorPolicies.compiledCheck, + mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), + useCase: compiledCheckWorkspaceFile, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts index 78f5cdb124f..14104079f62 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts @@ -5,25 +5,27 @@ import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetUserEntityPermissions, mockPerformUpdateContent } = vi.hoisted(() => ({ - mockGetUserEntityPermissions: vi.fn(), - mockPerformUpdateContent: vi.fn(), -})) +const mocks = vi.hoisted(() => ({ admit: vi.fn(), updateContent: vi.fn() })) vi.mock('@/lib/workspace-files/orchestration', () => ({ MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, - performUpdateWorkspaceFileContent: mockPerformUpdateContent, })) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, +vi.mock('@/lib/workspace-files/application/update-workspace-file-content', () => ({ + admitUpdateWorkspaceFileContent: mocks.admit, + updateWorkspaceFileContent: { + operation: { id: 'files.update_content', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateContent, + }, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { PUT } from '@/app/api/workspaces/[id]/files/[fileId]/content/route' const WORKSPACE_ID = 'workspace-1' const FILE_ID = 'wf_1' const USER = { id: 'user-1', name: 'Test User', email: 'test@sim.ai' } +const PRINCIPAL = { kind: 'session' as const, userId: USER.id, sessionId: 'session-1' } const RECORD = { id: FILE_ID, workspaceId: WORKSPACE_ID, @@ -34,7 +36,6 @@ const RECORD = { type: 'text/markdown', uploadedBy: USER.id, folderId: null, - folderPath: null, uploadedAt: new Date('2026-08-04T00:00:00.000Z'), updatedAt: new Date('2026-08-04T00:00:00.000Z'), } @@ -58,9 +59,9 @@ function createRequest(body: unknown, contentLength?: number): NextRequest { describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { beforeEach(() => { vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: USER }) - mockGetUserEntityPermissions.mockResolvedValue('write') - mockPerformUpdateContent.mockResolvedValue({ success: true, file: RECORD }) + authMockFns.mockGetSession.mockResolvedValue({ user: USER, session: { id: 'session-1' } }) + mocks.admit.mockResolvedValue(undefined) + mocks.updateContent.mockResolvedValue({ file: RECORD }) }) it('authenticates before parsing an invalid request body', async () => { @@ -70,22 +71,23 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { expect(response.status).toBe(401) await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }) - expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() - expect(mockPerformUpdateContent).not.toHaveBeenCalled() + expect(mocks.admit).not.toHaveBeenCalled() + expect(mocks.updateContent).not.toHaveBeenCalled() }) - it('authorizes the workspace before parsing the request body', async () => { - mockGetUserEntityPermissions.mockResolvedValue('read') + it('performs cheap file admission before buffering the request body', async () => { + mocks.admit.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) const response = await PUT(createRequest('{not-json'), routeContext) expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) - expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() + expect(mocks.admit).toHaveBeenCalledWith(PRINCIPAL, FILE_ID) + expect(mocks.updateContent).not.toHaveBeenCalled() }) - it('rejects malformed base64 after authorization', async () => { + it('rejects malformed base64 after admission', async () => { const response = await PUT( createRequest({ content: 'not-base64!', encoding: 'base64' }), routeContext @@ -93,7 +95,7 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { expect(response.status).toBe(400) await expect(response.json()).resolves.toMatchObject({ error: 'Validation error' }) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() + expect(mocks.updateContent).not.toHaveBeenCalled() }) it('accepts empty base64 as a zero-byte replacement', async () => { @@ -101,14 +103,14 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { const response = await PUT(request, routeContext) expect(response.status).toBe(200) - expect(mockPerformUpdateContent).toHaveBeenCalledWith({ - workspaceId: WORKSPACE_ID, - fileId: FILE_ID, - userId: USER.id, - content: '', - encoding: 'base64', - actorName: USER.name, - actorEmail: USER.email, + expect(mocks.updateContent).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + fileId: FILE_ID, + assertedWorkspaceId: WORKSPACE_ID, + content: '', + encoding: 'base64', + }, request, }) }) @@ -120,16 +122,14 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { ) expect(response.status).toBe(200) - expect(mockPerformUpdateContent).toHaveBeenCalled() + expect(mocks.updateContent).toHaveBeenCalled() }) - it('rejects a JSON body above the inline-content cap', async () => { + it('rejects a JSON body above the inline-content cap after admission', async () => { const response = await PUT(createRequest({ content: '' }, 70 * 1024 * 1024 + 1), routeContext) expect(response.status).toBe(413) - await expect(response.json()).resolves.toEqual({ - error: `Request body exceeds the maximum allowed size of ${70 * 1024 * 1024} bytes`, - }) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() + expect(mocks.admit).toHaveBeenCalled() + expect(mocks.updateContent).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts index a7d4934c783..c588f4e04f5 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts @@ -1,79 +1,40 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' +import { updateWorkspaceFileContentContract } from '@/lib/api/contracts/workspace-files' import { - updateWorkspaceFileContentContract, - workspaceFileParamsSchema, -} from '@/lib/api/contracts/workspace-files' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies, internalFilePresenters } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { - messageForOrchestrationError, - statusForOrchestrationError, -} from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - performUpdateWorkspaceFileContent, -} from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + admitUpdateWorkspaceFileContent, + updateWorkspaceFileContent, +} from '@/lib/workspace-files/application/update-workspace-file-content' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceFileContentAPI') - -/** - * PUT /api/workspaces/[id]/files/[fileId]/content - * Update a workspace file's text content (requires write permission) - */ -export const PUT = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const paramsResult = workspaceFileParamsSchema.safeParse(await context.params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId, fileId } = paramsResult.data - - const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) +/** PUT /api/workspaces/[id]/files/[fileId]/content — Replace a file's bytes. */ +export const PUT = defineInternalJsonRoute({ + contract: updateWorkspaceFileContentContract, + auth: internalSessionAuth, + operation: fileOperations.updateContent, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal content-update behavior', + }), + errorPolicy: internalFileErrorPolicies.default, + parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, + beforeParse: async ({ principal, params }) => { + if (typeof params.fileId === 'string') { + await admitUpdateWorkspaceFileContent(principal, params.fileId) } - - const parsed = await parseRequest(updateWorkspaceFileContentContract, request, context, { - maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - }) - if (!parsed.success) return parsed.response - const { content, encoding } = parsed.data.body - - const result = await performUpdateWorkspaceFileContent({ - workspaceId, - fileId, - userId: session.user.id, - content, - encoding: encoding === 'base64' ? 'base64' : 'utf-8', - actorName: session.user.name, - actorEmail: session.user.email, - request, - }) - - if (!result.success || !result.file) { - return NextResponse.json( - { - success: false, - error: messageForOrchestrationError(result, 'Failed to update file content'), - }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - - return NextResponse.json({ success: true, file: result.file }) - } -) + }, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + content: body.content, + encoding: body.encoding === 'base64' ? ('base64' as const) : ('utf-8' as const), + }), + useCase: updateWorkspaceFileContent, + present: internalFilePresenters.successFile, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts index 0fd9553a4c4..100a7fadca0 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts @@ -1,56 +1,30 @@ import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { getWorkspaceCsvPreviewContract } from '@/lib/api/contracts/workspace-file-table' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCsvPreviewSlice } from '@/lib/file-parsers/csv-preview-slice' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { internalFileErrorPolicies, internalSessionOrServiceAuth } from '@/lib/workspace-files/api' +import { csvPreviewWorkspaceFile } from '@/lib/workspace-files/application/csv-preview-workspace-file' const logger = createLogger('WorkspaceCsvPreviewAPI') export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const userId = authResult.userId - - const parsed = await parseRequest(getWorkspaceCsvPreviewContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const { key } = parsed.data.query - - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!permission) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - - // Resolve the file record (active, in this workspace) and read from its authoritative key — - // never the client-supplied one. This rejects archived/deleted files and keys with no live - // row, matching the access guarantees of /api/files/serve. - const record = await getWorkspaceFile(workspaceId, fileId) - if (!record || record.key !== key) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } - - const slice = await getCsvPreviewSlice({ - key: record.key, - context: 'workspace', - signal: request.signal, - }) - +export const GET = defineInternalJsonRoute({ + contract: getWorkspaceCsvPreviewContract, + auth: internalSessionOrServiceAuth, + operation: csvPreviewWorkspaceFile.operation, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal CSV preview behavior' }), + errorPolicy: internalFileErrorPolicies.plain, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + key: query.key, + }), + useCase: csvPreviewWorkspaceFile, + onSuccess: ({ result }) => { logger.info('CSV preview served', { - workspaceId, - rows: slice.rows.length, - truncated: slice.truncated, + rows: result.rows.length, + truncated: result.truncated, }) - - return NextResponse.json({ success: true, ...slice }) - } -) + }, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.test.ts index b6413ea6818..52db9ebbf0f 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.test.ts @@ -1,93 +1,96 @@ /** * @vitest-environment node */ -import { authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' +import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockUpdateWorkspaceFileDimensions } = vi.hoisted(() => ({ - mockUpdateWorkspaceFileDimensions: vi.fn(), -})) +const mocks = vi.hoisted(() => ({ updateDimensions: vi.fn() })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - updateWorkspaceFileDimensions: mockUpdateWorkspaceFileDimensions, +vi.mock('@/lib/workspace-files/application/update-workspace-file-dimensions', () => ({ + updateWorkspaceFileDimensionsOperation: { + operation: { id: 'files.update_metadata', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateDimensions, + }, })) -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) - -const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' -const FILE = 'wf_abc123' -const KEY = 'workspace/7727ef3f/screenshot.png' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { PATCH } from '@/app/api/workspaces/[id]/files/[fileId]/dimensions/route' -const routeContext = { params: Promise.resolve({ id: WS, fileId: FILE }) } +const WORKSPACE_ID = '7727ef3f-8cf6-4686-b063-2bb006a10785' +const FILE_ID = 'wf_abc123' +const KEY = 'workspace/7727ef3f/screenshot.png' +const USER = { id: 'user-1' } +const PRINCIPAL = { kind: 'session' as const, userId: USER.id, sessionId: 'session-1' } +const context = { params: Promise.resolve({ id: WORKSPACE_ID, fileId: FILE_ID }) } -function buildRequest(body: unknown): NextRequest { - return new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE}/dimensions`, { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }) +function request(body: unknown): NextRequest { + return new NextRequest( + `http://localhost/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}/dimensions`, + { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + } + ) } describe('PATCH /api/workspaces/[id]/files/[fileId]/dimensions', () => { beforeEach(() => { vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mockUpdateWorkspaceFileDimensions.mockResolvedValue(true) + authMockFns.mockGetSession.mockResolvedValue({ user: USER, session: { id: 'session-1' } }) + mocks.updateDimensions.mockResolvedValue({ success: true }) }) - it('stores dimensions for a writer, keyed to the content version', async () => { - const res = await PATCH(buildRequest({ key: KEY, width: 1600, height: 900 }), routeContext) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ success: true }) - expect(mockUpdateWorkspaceFileDimensions).toHaveBeenCalledWith(WS, FILE, { - key: KEY, - width: 1600, - height: 900, + it('updates dimensions through the shared operation', async () => { + const req = request({ key: KEY, width: 1600, height: 900 }) + const response = await PATCH(req, context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true }) + expect(mocks.updateDimensions).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + fileId: FILE_ID, + assertedWorkspaceId: WORKSPACE_ID, + key: KEY, + width: 1600, + height: 900, + }, + request: req, }) }) - it('allows an admin', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') - const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 20 }), routeContext) - expect(res.status).toBe(200) - expect(mockUpdateWorkspaceFileDimensions).toHaveBeenCalledOnce() - }) + it('preserves the stale content-version result', async () => { + mocks.updateDimensions.mockResolvedValue({ success: false }) + const response = await PATCH(request({ key: KEY, width: 10, height: 20 }), context) - it('reports success:false when the content-version guard rejects the write (key changed)', async () => { - mockUpdateWorkspaceFileDimensions.mockResolvedValue(false) - const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 20 }), routeContext) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ success: false }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: false }) }) - it('rejects an unauthenticated caller before touching the DB', async () => { + it('authenticates before parsing or dispatching', async () => { authMockFns.mockGetSession.mockResolvedValue(null) - const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 10 }), routeContext) - expect(res.status).toBe(401) - expect(mockUpdateWorkspaceFileDimensions).not.toHaveBeenCalled() + const response = await PATCH(request({ key: KEY, width: 10, height: 10 }), context) + + expect(response.status).toBe(401) + expect(mocks.updateDimensions).not.toHaveBeenCalled() }) - it('rejects a read-only member (backfill requires write)', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read') - const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 10 }), routeContext) - expect(res.status).toBe(403) - expect(mockUpdateWorkspaceFileDimensions).not.toHaveBeenCalled() + it('rejects invalid dimensions after authentication', async () => { + const response = await PATCH(request({ key: KEY, width: 0, height: 10 }), context) + + expect(response.status).toBe(400) + expect(mocks.updateDimensions).not.toHaveBeenCalled() }) - it('rejects a missing key or non-positive / non-integer dimensions', async () => { - for (const body of [ - { width: 10, height: 10 }, // missing key - { key: KEY, width: 0, height: 10 }, - { key: KEY, width: 10, height: -5 }, - { key: KEY, width: 10.5, height: 10 }, - { key: KEY, width: 10 }, - ]) { - const res = await PATCH(buildRequest(body), routeContext) - expect(res.status).toBe(400) - } - expect(mockUpdateWorkspaceFileDimensions).not.toHaveBeenCalled() + it('renders typed authorization errors', async () => { + mocks.updateDimensions.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) + const response = await PATCH(request({ key: KEY, width: 10, height: 10 }), context) + + expect(response.status).toBe(403) + expect(mocks.updateDimensions).toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts index 0d91d38563e..96a1c73ca53 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts @@ -1,57 +1,25 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' import { updateWorkspaceFileDimensionsContract } from '@/lib/api/contracts/workspace-files' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { updateWorkspaceFileDimensions } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { updateWorkspaceFileDimensionsOperation } from '@/lib/workspace-files/application/update-workspace-file-dimensions' -const logger = createLogger('WorkspaceFileDimensionsAPI') - -/** - * PATCH /api/workspaces/[id]/files/[fileId]/dimensions - * - * Store an image file's intrinsic pixel dimensions — a pure rendering hint the editor uses to reserve - * layout space before the image loads. Requires write permission. The write commits whenever the row - * still holds the measured storage key, overwriting any stale value so a wrong size self-corrects; the - * client reports only on a real mismatch, so this is not storm-y despite not being a backfill-once no-op. - */ -export const PATCH = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(updateWorkspaceFileDimensionsContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const { key, width, height } = parsed.data.body - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - // `written` is false when the content-version guard rejected the write (the row's storage key no - // longer matches the key the client measured — the content was replaced since). That is not an - // error; the client's next measurement, once its file list has the new key, persists correctly. - const written = await updateWorkspaceFileDimensions(workspaceId, fileId, { - key, - width, - height, - }) - return NextResponse.json({ success: written }) - } catch (error) { - logger.error('Failed to store workspace file dimensions', { - workspaceId, - fileId, - error: getErrorMessage(error), - }) - return NextResponse.json({ error: 'Failed to update dimensions' }, { status: 500 }) - } - } -) +export const PATCH = defineInternalJsonRoute({ + contract: updateWorkspaceFileDimensionsContract, + auth: internalSessionAuth, + operation: fileOperations.updateMetadata, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal dimensions behavior' }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + key: body.key, + width: body.width, + height: body.height, + }), + useCase: updateWorkspaceFileDimensionsOperation, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts index 77c8900a718..d4916e2260c 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts @@ -1,96 +1,30 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' +import { downloadWorkspaceFileUrlContract } from '@/lib/api/contracts/workspace-files' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + internalFileAnalytics, + internalFileErrorPolicies, + internalFilePresenters, +} from '@/lib/workspace-files/api' +import { downloadWorkspaceFile } from '@/lib/workspace-files/application/download-workspace-file' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceFileDownloadAPI') - -/** - * POST /api/workspaces/[id]/files/[fileId]/download - * Return authenticated file serve URL (requires read permission) - * Uses /api/files/serve endpoint which enforces authentication and context - */ -export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - const paramsResult = workspaceFileParamsSchema.safeParse(await params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId, fileId } = paramsResult.data - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userPermission = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!userPermission) { - logger.warn( - `[${requestId}] User ${session.user.id} lacks permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const fileRecord = await getWorkspaceFile(workspaceId, fileId) - if (!fileRecord) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } - - const { getBaseUrl } = await import('@/lib/core/utils/urls') - const serveUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(fileRecord.key)}?context=workspace` - const viewerUrl = `${getBaseUrl()}/workspace/${workspaceId}/files/${fileId}` - - logger.info(`[${requestId}] Generated download URL for workspace file: ${fileRecord.name}`) - - recordAudit({ - workspaceId, - actorId: session.user.id, - action: AuditAction.FILE_DOWNLOADED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: fileRecord.name, - description: `Downloaded file "${fileRecord.name}"`, - metadata: { fileId, fileName: fileRecord.name, bytes: fileRecord.size }, - request, - }) - captureServerEvent( - session.user.id, - 'file_downloaded', - { workspace_id: workspaceId, is_bulk: false, file_count: 1 }, - { groups: { workspace: workspaceId } } - ) - - return NextResponse.json({ - success: true, - downloadUrl: serveUrl, - viewerUrl: viewerUrl, - fileName: fileRecord.name, - expiresIn: null, - }) - } catch (error) { - logger.error(`[${requestId}] Error generating download URL:`, error) - return NextResponse.json( - { - success: false, - error: getErrorMessage(error, 'Failed to generate download URL'), - }, - { status: 500 } - ) - } - } -) +/** POST /api/workspaces/[id]/files/[fileId]/download — Create an authenticated serve URL. */ +export const POST = defineInternalJsonRoute({ + contract: downloadWorkspaceFileUrlContract, + auth: internalSessionAuth, + operation: downloadWorkspaceFile.operation, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal download behavior' }), + errorPolicy: internalFileErrorPolicies.downloadUrl, + mapInput: ({ params }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + }), + useCase: downloadWorkspaceFile, + onSuccess: internalFileAnalytics.downloaded, + present: internalFilePresenters.downloadUrl, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts index 0d41810c77e..f286697997a 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts @@ -1,64 +1,21 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performRestoreWorkspaceFile } from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { restoreWorkspaceFileContract } from '@/lib/api/contracts/workspace-files' +import { + defineInternalJsonRoute, + internalJsonPresenters, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { restoreWorkspaceFileOperation } from '@/lib/workspace-files/application/restore-workspace-file' -const logger = createLogger('RestoreWorkspaceFileAPI') - -export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - const paramsResult = workspaceFileParamsSchema.safeParse(await params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId, fileId } = paramsResult.data - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - if (userPermission !== 'admin' && userPermission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const result = await performRestoreWorkspaceFile({ - workspaceId, - fileId, - userId: session.user.id, - }) - if (!result.success) { - return NextResponse.json( - { error: result.error }, - { status: result.errorCode === 'conflict' ? 409 : 500 } - ) - } - - logger.info(`[${requestId}] Restored workspace file ${fileId}`) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error(`[${requestId}] Error restoring workspace file ${fileId}`, error) - return NextResponse.json( - { error: getErrorMessage(error, 'Internal server error') }, - { status: 500 } - ) - } - } -) +export const POST = defineInternalJsonRoute({ + contract: restoreWorkspaceFileContract, + auth: internalSessionAuth, + operation: fileOperations.restore, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal restore behavior' }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), + useCase: restoreWorkspaceFileOperation, + present: internalJsonPresenters.successFrom('restored'), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.test.ts new file mode 100644 index 00000000000..da7a2a60614 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.test.ts @@ -0,0 +1,146 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + rename: vi.fn(), + deleteItems: vi.fn(), + getUserEntityPermissions: vi.fn(), + captureServerEvent: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) + +vi.mock('@/lib/workspace-files/application/rename-workspace-file', () => ({ + renameWorkspaceFile: { + operation: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.rename, + }, +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performDeleteWorkspaceFileItems: mocks.deleteItems, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mocks.getUserEntityPermissions, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { PATCH } from '@/app/api/workspaces/[id]/files/[fileId]/route' + +const WORKSPACE_ID = 'workspace-1' +const FILE_ID = 'wf_1' +const context = { params: Promise.resolve({ id: WORKSPACE_ID, fileId: FILE_ID }) } + +function callRename(body: unknown) { + return PATCH( + new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + context + ) +} + +function fileRecord() { + return { + id: FILE_ID, + workspaceId: WORKSPACE_ID, + name: 'renamed.csv', + key: 'workspace/ws/file.csv', + path: '/api/files/serve/file.csv', + size: 42, + type: 'text/csv', + uploadedBy: 'user-1', + folderId: undefined, + uploadedAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + } +} + +describe('PATCH /api/workspaces/[id]/files/[fileId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.rename.mockResolvedValue({ file: fileRecord() }) + }) + + it('authenticates before parsing the request', async () => { + mocks.getSession.mockResolvedValue(null) + + const response = await callRename({ name: 'nested/invalid.csv' }) + + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ error: 'Unauthorized' }) + expect(mocks.rename).not.toHaveBeenCalled() + }) + + it('rejects invalid rename input before the use case', async () => { + const response = await callRename({ name: 'nested/invalid.csv' }) + + expect(response.status).toBe(400) + expect(mocks.rename).not.toHaveBeenCalled() + }) + + it('passes a session principal and canonical assertion to the shared use case', async () => { + const response = await callRename({ name: 'renamed.csv' }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + file: expect.objectContaining({ id: FILE_ID, name: 'renamed.csv', folderId: null }), + }) + expect(mocks.rename).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + fileId: FILE_ID, + assertedWorkspaceId: WORKSPACE_ID, + name: 'renamed.csv', + }, + request: expect.anything(), + }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_renamed', + { workspace_id: WORKSPACE_ID }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it('renders typed authorization errors in the internal envelope', async () => { + mocks.rename.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) + + const response = await callRename({ name: 'renamed.csv' }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + success: false, + error: 'Insufficient workspace permissions', + }) + expect(mocks.captureServerEvent).not.toHaveBeenCalled() + }) + + it('hides unexpected failures behind the internal 500 envelope', async () => { + mocks.rename.mockRejectedValue(new Error('update workspace_files failed')) + + const response = await callRename({ name: 'renamed.csv' }) + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ + success: false, + error: 'Internal server error', + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts index 1826988ea08..2f5bb14e2d1 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts @@ -1,168 +1,56 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' import { + deleteWorkspaceFileContract, renameWorkspaceFileContract, - workspaceFileParamsSchema, } from '@/lib/api/contracts/workspace-files' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' import { - performDeleteWorkspaceFileItems, - performRenameWorkspaceFile, -} from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + defineInternalJsonRoute, + internalJsonPresenters, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + internalFileAnalytics, + internalFileErrorPolicies, + internalFilePresenters, +} from '@/lib/workspace-files/api' +import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceFileAPI') - /** * PATCH /api/workspaces/[id]/files/[fileId] * Rename a workspace file (requires write permission) */ -export const PATCH = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(renameWorkspaceFileContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const { name } = parsed.data.body - - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const result = await performRenameWorkspaceFile({ - workspaceId, - fileId, - name, - userId: session.user.id, - }) - if (!result.success || !result.file) { - return NextResponse.json( - { success: false, error: result.error }, - { status: result.errorCode === 'conflict' ? 409 : 500 } - ) - } - - logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${result.file.name}"`) - - captureServerEvent( - session.user.id, - 'file_renamed', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json({ - success: true, - file: result.file, - }) - } catch (error) { - logger.error(`[${requestId}] Error renaming workspace file:`, error) - return NextResponse.json( - { - success: false, - error: getErrorMessage(error, 'Failed to rename file'), - }, - { status: 500 } - ) - } - } -) +export const PATCH = defineInternalJsonRoute({ + contract: renameWorkspaceFileContract, + auth: internalSessionAuth, + operation: fileOperations.rename, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal rename behavior' }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + name: body.name, + }), + useCase: renameWorkspaceFile, + onSuccess: internalFileAnalytics.renamed, + present: internalFilePresenters.successFile, +}) /** * DELETE /api/workspaces/[id]/files/[fileId] * Archive a workspace file (requires write permission) */ -export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - const paramsResult = workspaceFileParamsSchema.safeParse(await params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId, fileId } = paramsResult.data - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check workspace permissions (requires write) - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId: session.user.id, - fileIds: [fileId], - }) - if (!result.success) { - return NextResponse.json( - { success: false, error: result.error }, - { - status: - result.errorCode === 'validation' - ? 400 - : result.errorCode === 'not_found' - ? 404 - : 500, - } - ) - } - - logger.info(`[${requestId}] Archived workspace file: ${fileId}`) - - captureServerEvent( - session.user.id, - 'file_deleted', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json({ - success: true, - }) - } catch (error) { - logger.error(`[${requestId}] Error deleting workspace file:`, error) - return NextResponse.json( - { - success: false, - error: getErrorMessage(error, 'Failed to delete file'), - }, - { status: 500 } - ) - } - } -) +export const DELETE = defineInternalJsonRoute({ + contract: deleteWorkspaceFileContract, + auth: internalSessionAuth, + operation: fileOperations.delete, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal delete behavior' }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), + useCase: deleteWorkspaceFileOperation, + onSuccess: internalFileAnalytics.deleted, + present: internalJsonPresenters.successFrom('deleted'), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts index 9865aee2651..2fe55b10776 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts @@ -1,171 +1,163 @@ /** * @vitest-environment node */ -import { auditMock, authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetWorkspaceFile, mockGetShareForResource, mockUpsertFileShare, mockValidateSharing } = - vi.hoisted(() => ({ - mockGetWorkspaceFile: vi.fn(), - mockGetShareForResource: vi.fn(), - mockUpsertFileShare: vi.fn(), - mockValidateSharing: vi.fn(), - })) - -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - getWorkspaceFile: mockGetWorkspaceFile, +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + getShare: vi.fn(), + updateShare: vi.fn(), })) -vi.mock('@/lib/public-shares/share-manager', () => { - class ShareValidationError extends Error { - constructor(message: string) { - super(message) - this.name = 'ShareValidationError' - } - } - return { - getShareForResource: mockGetShareForResource, - upsertFileShare: mockUpsertFileShare, - ShareValidationError, - } -}) - -vi.mock('@/ee/access-control/utils/permission-check', () => { - class PublicFileSharingNotAllowedError extends Error { - constructor() { - super('Public file sharing is not allowed based on your permission group settings') - this.name = 'PublicFileSharingNotAllowedError' - } - } - return { validatePublicFileSharing: mockValidateSharing, PublicFileSharingNotAllowedError } -}) - -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) -vi.mock('@sim/audit', () => auditMock) - -const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' -const FILE_ID = 'wf_abc' +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) + +vi.mock('@/lib/workspace-files/application/share-workspace-file', () => ({ + getWorkspaceFileShare: { + operation: { id: 'files.share.read', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.getShare, + }, + updateWorkspaceFileShare: { + operation: { id: 'files.share.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateShare, + }, +})) -import { ShareValidationError } from '@/lib/public-shares/share-manager' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET, PUT } from '@/app/api/workspaces/[id]/files/[fileId]/share/route' -const params = (id = WS, fileId = FILE_ID) => ({ params: Promise.resolve({ id, fileId }) }) - -const putRequest = (body: unknown) => - new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - -const getRequest = () => - new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`) - +const WORKSPACE_ID = 'workspace-1' +const FILE_ID = 'wf_1' +const PRINCIPAL = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } const SHARE = { - id: 'sh_1', + id: 'shr_1', token: 'tok_1', url: 'https://sim.ai/f/tok_1', isActive: true, resourceType: 'file' as const, resourceId: FILE_ID, + authType: 'public' as const, + hasPassword: false, + allowedEmails: [] as string[], +} +const context = { + params: Promise.resolve({ id: WORKSPACE_ID, fileId: FILE_ID }), +} + +function getRequest() { + return new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}/share` + ) +} + +function putRequest(body: unknown) { + return new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}/share`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + ) } -describe('share route', () => { +describe('/api/workspaces/[id]/files/[fileId]/share', () => { beforeEach(() => { vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-1', name: 'User One', email: 'u@example.com' }, - }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mockGetWorkspaceFile.mockResolvedValue({ id: FILE_ID, name: 'report.pdf' }) - mockGetShareForResource.mockResolvedValue(SHARE) - mockUpsertFileShare.mockResolvedValue(SHARE) - mockValidateSharing.mockResolvedValue(undefined) // policy allows by default + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mocks.getShare.mockResolvedValue({ share: SHARE }) + mocks.updateShare.mockResolvedValue({ share: SHARE }) }) - describe('GET', () => { - it('returns 401 when unauthenticated', async () => { - authMockFns.mockGetSession.mockResolvedValueOnce(null) - const res = await GET(getRequest(), params()) - expect(res.status).toBe(401) - }) + it('authenticates before parsing or executing', async () => { + mocks.getSession.mockResolvedValueOnce(null) - it('returns 403 when the caller has no workspace access', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce(null) - const res = await GET(getRequest(), params()) - expect(res.status).toBe(403) - }) + const response = await GET(getRequest(), context) - it('returns the share for a member', async () => { - const res = await GET(getRequest(), params()) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ share: SHARE }) - }) + expect(response.status).toBe(401) + expect(mocks.getShare).not.toHaveBeenCalled() }) - describe('PUT', () => { - it('returns 403 for a read-only member', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read') - const res = await PUT(putRequest({ isActive: true }), params()) - expect(res.status).toBe(403) - expect(mockUpsertFileShare).not.toHaveBeenCalled() - }) + it('returns the share through the internal envelope', async () => { + const response = await GET(getRequest(), context) - it('maps a ShareValidationError to 400, not 500', async () => { - mockUpsertFileShare.mockRejectedValueOnce( - new ShareValidationError('Password is required for password-protected shares') - ) - const res = await PUT(putRequest({ isActive: true, authType: 'password' }), params()) - expect(res.status).toBe(400) - expect((await res.json()).error).toBe('Password is required for password-protected shares') + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ share: SHARE }) + expect(mocks.getShare).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request: expect.anything(), }) + }) - it('returns 404 when the file is not in the workspace', async () => { - mockGetWorkspaceFile.mockResolvedValueOnce(null) - const res = await PUT(putRequest({ isActive: true }), params()) - expect(res.status).toBe(404) - expect(mockUpsertFileShare).not.toHaveBeenCalled() - }) + it('renders authorization failures as 403', async () => { + mocks.getShare.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Access denied')) + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ success: false, error: 'Access denied' }) + }) + + it('renders resource absence as 404', async () => { + mocks.getShare.mockRejectedValueOnce(new OrchestrationError('not_found', 'File not found')) + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(404) + }) + + it('rejects malformed update input before the use case', async () => { + const response = await PUT(putRequest({}), context) + + expect(response.status).toBe(400) + expect(mocks.updateShare).not.toHaveBeenCalled() + }) - it('enables the share for a writer', async () => { - const res = await PUT(putRequest({ isActive: true }), params()) - expect(res.status).toBe(200) - expect(mockUpsertFileShare).toHaveBeenCalledWith({ - workspaceId: WS, + it('passes the session principal and asserted workspace to the shared update use case', async () => { + const response = await PUT(putRequest({ isActive: true, authType: 'password' }), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ share: SHARE }) + expect(mocks.updateShare).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { fileId: FILE_ID, - userId: 'user-1', + assertedWorkspaceId: WORKSPACE_ID, isActive: true, - }) - expect(await res.json()).toEqual({ share: SHARE }) + authType: 'password', + password: undefined, + allowedEmails: undefined, + token: undefined, + }, + request: expect.anything(), }) + }) - it('returns 403 when org access-control disables public sharing (enable)', async () => { - const { PublicFileSharingNotAllowedError } = await import( - '@/ee/access-control/utils/permission-check' - ) - mockValidateSharing.mockRejectedValueOnce(new PublicFileSharingNotAllowedError()) - const res = await PUT(putRequest({ isActive: true }), params()) - expect(res.status).toBe(403) - expect(mockUpsertFileShare).not.toHaveBeenCalled() - }) + it('renders typed update failures without exposing a 500', async () => { + mocks.updateShare.mockRejectedValueOnce( + new OrchestrationError('validation', 'Password is required') + ) - it('allows disabling a share even when policy disallows enabling', async () => { - mockValidateSharing.mockRejectedValue(new Error('should not be called for disable')) - const res = await PUT(putRequest({ isActive: false }), params()) - expect(res.status).toBe(200) - expect(mockValidateSharing).not.toHaveBeenCalled() - expect(mockUpsertFileShare).toHaveBeenCalledWith({ - workspaceId: WS, - fileId: FILE_ID, - userId: 'user-1', - isActive: false, - }) - }) + const response = await PUT(putRequest({ isActive: true }), context) - it('rejects a missing isActive body', async () => { - const res = await PUT(putRequest({}), params()) - expect(res.status).toBe(400) - }) + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ success: false, error: 'Password is required' }) + }) + + it('preserves the internal caller-supplied token field for compatibility', async () => { + await PUT( + putRequest({ + isActive: true, + token: 'client-reserved-token', + }), + context + ) + + expect(mocks.updateShare).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ token: 'client-reserved-token' }), + }) + ) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts index f5810627a1b..10629599374 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts @@ -1,106 +1,44 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { getFileShareContract, upsertFileShareContract } from '@/lib/api/contracts/public-shares' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' import { - messageForOrchestrationError, - statusForOrchestrationError, -} from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { - performGetWorkspaceFileShare, - performUpsertWorkspaceFileShare, -} from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + getWorkspaceFileShare, + updateWorkspaceFileShare, +} from '@/lib/workspace-files/application/share-workspace-file' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceFileShareAPI') - -/** - * GET /api/workspaces/[id]/files/[fileId]/share - * Fetch the public share state for a file (requires workspace membership). - */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getFileShareContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission === null) { - logger.warn(`[${requestId}] User ${session.user.id} lacks access to workspace ${workspaceId}`) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const result = await performGetWorkspaceFileShare({ workspaceId, fileId }) - if (!result.success) { - return NextResponse.json( - { error: messageForOrchestrationError(result, 'Failed to fetch share') }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - - return NextResponse.json({ share: result.share ?? null }) - } -) - -/** - * PUT /api/workspaces/[id]/files/[fileId]/share - * Enable or disable the public share for a file (requires write permission). - */ -export const PUT = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(upsertFileShareContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const { isActive, authType, password, allowedEmails, token } = parsed.data.body - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const result = await performUpsertWorkspaceFileShare({ - workspaceId, - fileId, - userId: session.user.id, - isActive, - authType, - password, - allowedEmails, - token, - actorName: session.user.name, - actorEmail: session.user.email, - request, - }) - - if (!result.success || !result.share) { - return NextResponse.json( - { error: messageForOrchestrationError(result, 'Failed to update share') }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - - return NextResponse.json({ share: result.share }) - } -) +export const GET = defineInternalJsonRoute({ + contract: getFileShareContract, + auth: internalSessionAuth, + operation: fileOperations.readShare, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal share-read behavior' }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), + useCase: getWorkspaceFileShare, +}) + +export const PUT = defineInternalJsonRoute({ + contract: upsertFileShareContract, + auth: internalSessionAuth, + operation: fileOperations.updateShare, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal share-update behavior', + }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + isActive: body.isActive, + authType: body.authType, + password: body.password, + allowedEmails: body.allowedEmails, + token: body.token, + }), + useCase: updateWorkspaceFileShare, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts index cc68e4dc348..c9355e47a56 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts @@ -1,90 +1,23 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' import { workspaceFileStyleContract } from '@/lib/api/contracts/workspace-files' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies, internalFilePresenters } from '@/lib/workspace-files/api' +import { styleWorkspaceFile } from '@/lib/workspace-files/application/style-workspace-file' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -const logger = createLogger('WorkspaceFileStyleAPI') - -/** - * GET /api/workspaces/[id]/files/[fileId]/style - * Extract a compact JSON style summary from an uploaded .docx, .pptx, or .pdf file. - * OOXML files return theme colors, font pair, and named styles. - * PDF files return page dimensions and embedded font names. - */ -const MAX_STYLE_FILE_BYTES = 100 * 1024 * 1024 // 100 MB - -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(workspaceFileStyleContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - - const membership = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!membership) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const fileRecord = await getWorkspaceFile(workspaceId, fileId) - if (!fileRecord) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } - - const rawExt = fileRecord.name.split('.').pop()?.toLowerCase() - if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') { - return NextResponse.json( - { error: 'Style extraction supports .docx, .pptx, and .pdf files' }, - { status: 422 } - ) - } - const ext: 'docx' | 'pptx' | 'pdf' = rawExt - - if (fileRecord.size > MAX_STYLE_FILE_BYTES) { - return NextResponse.json( - { error: 'File is too large for style extraction (limit: 100 MB)' }, - { status: 422 } - ) - } - - let buffer: Buffer - try { - buffer = await fetchWorkspaceFileBuffer(fileRecord) - } catch (err) { - logger.error('Failed to download file for style extraction', { - fileId, - error: toError(err).message, - }) - return NextResponse.json({ error: 'Failed to read file' }, { status: 500 }) - } - - const summary = await extractDocumentStyle(buffer, ext) - if (!summary) { - return NextResponse.json( - { - error: - 'Could not extract style — file may be encrypted, corrupt, image-only, or contain no parseable style information', - }, - { status: 422 } - ) - } - - logger.info('Extracted style summary via API', { fileId, format: ext }) - - return NextResponse.json(summary, { - headers: { 'Cache-Control': 'private, max-age=300' }, - }) - } -) +export const GET = defineInternalJsonRoute({ + contract: workspaceFileStyleContract, + auth: internalSessionAuth, + operation: styleWorkspaceFile.operation, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal style behavior' }), + errorPolicy: internalFileErrorPolicies.style, + mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), + useCase: styleWorkspaceFile, + present: internalFilePresenters.style, + responseHeaders: () => ({ 'Cache-Control': 'private, max-age=300' }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.test.ts new file mode 100644 index 00000000000..7c973596afc --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + execute: vi.fn(), + captureServerEvent: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) +vi.mock('@/lib/workspace-files/application/archive-workspace-file-items', () => ({ + archiveWorkspaceFileItemsOperation: { + operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.execute, + }, +})) + +import { POST } from '@/app/api/workspaces/[id]/files/bulk-archive/route' + +const WORKSPACE_ID = 'workspace-1' +const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } + +function request(body: unknown) { + return new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/bulk-archive`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + ) +} + +describe('/api/workspaces/[id]/files/bulk-archive', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mocks.execute.mockResolvedValue({ deletedItems: { files: 2, folders: 1 } }) + }) + + it('archives selected files and folders through the shared operation', async () => { + const response = await POST( + request({ fileIds: ['wf_1', 'wf_2'], folderIds: ['folder-1'] }), + context + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + deletedItems: { files: 2, folders: 1 }, + }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: WORKSPACE_ID, + fileIds: ['wf_1', 'wf_2'], + folderIds: ['folder-1'], + }, + request: expect.anything(), + }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_bulk_deleted', + { workspace_id: WORKSPACE_ID, file_count: 2, folder_count: 1 }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it('rejects an empty selection before the use case', async () => { + const response = await POST(request({}), context) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts index 1f47f650bd6..b4d529d1945 100644 --- a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts @@ -1,70 +1,27 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { bulkArchiveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { archiveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/archive-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' -const logger = createLogger('WorkspaceFileBulkArchiveAPI') - -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(bulkArchiveWorkspaceFileItemsContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { fileIds, folderIds } = parsed.data.body - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId: session.user.id, - fileIds, - folderIds, - }) - if (!result.success) { - return NextResponse.json( - { success: false, error: result.error }, - { - status: - result.errorCode === 'validation' - ? 400 - : result.errorCode === 'not_found' - ? 404 - : 500, - } - ) - } - if (!result.deletedItems) { - return NextResponse.json( - { success: false, error: 'Failed to delete workspace file items' }, - { status: 500 } - ) - } - - captureServerEvent( - session.user.id, - 'file_bulk_deleted', - { workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length }, - { groups: { workspace: workspaceId } } - ) - - return NextResponse.json({ success: true, deletedItems: result.deletedItems }) - } catch (error) { - logger.error('Failed to bulk archive workspace file items:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) +export const POST = defineInternalJsonRoute({ + contract: bulkArchiveWorkspaceFileItemsContract, + auth: internalSessionAuth, + operation: fileOperations.delete, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal bulk archive behavior', + }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + fileIds: body.fileIds, + folderIds: body.folderIds, + }), + useCase: archiveWorkspaceFileItemsOperation, + onSuccess: internalFileAnalytics.bulkDeleted, + present: ({ deletedItems }) => ({ success: true, deletedItems }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts index d7a871fbcbf..2636bde5ac1 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts @@ -20,13 +20,15 @@ const run = promisify(execFile) const { mockGetSession, - mockVerifyWorkspaceMembership, + mockLoadWorkspaceFileOperationContext, + mockResolvePermission, mockListWorkspaceFiles, mockListFolders, mockDownloadFileStream, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), - mockVerifyWorkspaceMembership: vi.fn(), + mockLoadWorkspaceFileOperationContext: vi.fn(), + mockResolvePermission: vi.fn(), mockListWorkspaceFiles: vi.fn(), mockListFolders: vi.fn(), mockDownloadFileStream: vi.fn(), @@ -36,8 +38,14 @@ vi.mock('@/lib/auth', () => ({ auth: { api: { getSession: vi.fn() } }, getSession: mockGetSession, })) -vi.mock('@/app/api/workflows/utils', () => ({ - verifyWorkspaceMembership: mockVerifyWorkspaceMembership, +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mockResolvePermission, })) vi.mock('@/lib/uploads/contexts/workspace', () => ({ listWorkspaceFiles: mockListWorkspaceFiles, @@ -45,6 +53,7 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({ buildWorkspaceFileFolderPathMap: (folders: Array<{ id: string; name: string }>) => new Map(folders.map((folder) => [folder.id, folder.name])), fetchServableWorkspaceFileBuffer: vi.fn(), + loadWorkspaceFileOperationContext: mockLoadWorkspaceFileOperationContext, })) vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFileStream: mockDownloadFileStream, @@ -88,8 +97,14 @@ afterAll(async () => { describe('workspace files download — real archive', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - mockVerifyWorkspaceMembership.mockResolvedValue({ role: 'member' }) + mockGetSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mockResolvePermission.mockResolvedValue('admin') + mockLoadWorkspaceFileOperationContext.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', + }) mockListFolders.mockResolvedValue([{ id: 'folder-1', name: 'Reports', parentId: null }]) // A real fs stream, not a single pre-made buffer. mockDownloadFileStream.mockImplementation(async () => createReadStream(bigPath)) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.test.ts index 41dc0680b7b..538c3aa196e 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.test.ts @@ -1,80 +1,41 @@ /** * @vitest-environment node */ -import { Readable } from 'stream' +import { Readable } from 'node:stream' import { createMockRequest } from '@sim/testing' import JSZip from 'jszip' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockGetSession, - mockVerifyWorkspaceMembership, - mockListWorkspaceFiles, - mockListWorkspaceFileFolders, - mockFetchServableWorkspaceFileBuffer, - mockDownloadFileStream, -} = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockVerifyWorkspaceMembership: vi.fn(), - mockListWorkspaceFiles: vi.fn(), - mockListWorkspaceFileFolders: vi.fn(), - mockFetchServableWorkspaceFileBuffer: vi.fn(), - mockDownloadFileStream: vi.fn(), -})) +const { mockGetSession, mockDownloadItems, mockDownloadFileStream, mockCaptureServerEvent } = + vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockDownloadItems: vi.fn(), + mockDownloadFileStream: vi.fn(), + mockCaptureServerEvent: vi.fn(), + })) vi.mock('@/lib/auth', () => ({ auth: { api: { getSession: vi.fn() } }, getSession: mockGetSession, })) -vi.mock('@/app/api/workflows/utils', () => ({ - verifyWorkspaceMembership: mockVerifyWorkspaceMembership, -})) - -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - listWorkspaceFiles: mockListWorkspaceFiles, - listWorkspaceFileFolders: mockListWorkspaceFileFolders, - buildWorkspaceFileFolderPathMap: (folders: Array<{ id: string; name: string }>) => - new Map(folders.map((folder) => [folder.id, folder.name])), - fetchServableWorkspaceFileBuffer: mockFetchServableWorkspaceFileBuffer, +vi.mock('@/lib/workspace-files/application/download-workspace-file-items', () => ({ + downloadWorkspaceFileItems: { + operation: { id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mockDownloadItems, + }, })) vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFileStream: mockDownloadFileStream, })) -vi.mock('@sim/audit', () => ({ - recordAudit: vi.fn(), - AuditAction: { FILE_DOWNLOADED: 'file.downloaded' }, - AuditResourceType: { FILE: 'file' }, -})) - -vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) -import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile' -import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { GET } from '@/app/api/workspaces/[id]/files/download/route' const WORKSPACE_ID = 'ws-1' const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } -const MB = 1024 * 1024 - -function workspaceFile(id: string, name: string, folderId: string | null = 'folder-1') { - return { - id, - name, - key: `workspace/${WORKSPACE_ID}/${id}`, - path: `/serve/${id}`, - size: 100, - type: 'application/octet-stream', - folderId, - } -} - -/** A file whose stored bytes are a generator source, so it must be resolved. */ -function generatedDocument(id: string, name: string, folderId: string | null = 'folder-1') { - return { ...workspaceFile(id, name, folderId), type: 'text/x-docxjs' } -} function requestFor(query: string) { return createMockRequest( @@ -85,265 +46,85 @@ function requestFor(query: string) { ) } +function result(files: Array<{ id: string; name: string; folderId: string | null }>) { + return { + filesToZip: files.map((file) => ({ + ...file, + key: `workspace/${WORKSPACE_ID}/${file.id}`, + size: 5, + storageContext: 'workspace', + })), + folderPaths: new Map([['folder-1', 'Reports']]), + renderedDocuments: new Map(), + declaredBytes: files.length * 5, + } +} + async function zipFrom(response: Response) { return JSZip.loadAsync(Buffer.from(await response.arrayBuffer())) } -describe('workspace files download route', () => { +describe('GET /api/workspaces/[id]/files/download', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - mockVerifyWorkspaceMembership.mockResolvedValue({ role: 'member' }) - mockListWorkspaceFileFolders.mockResolvedValue([ - { id: 'folder-1', name: 'Reports', parentId: null }, - ]) + mockGetSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mockDownloadItems.mockResolvedValue(result([{ id: 'f1', name: 'clip.mp4', folderId: null }])) mockDownloadFileStream.mockImplementation(async () => Readable.from([Buffer.from('plain')])) }) - it('zips the rendered bytes for a generated doc, not its stored source', async () => { - mockListWorkspaceFiles.mockResolvedValue([generatedDocument('f1', 'overview.docx')]) - // A real .docx is a ZIP; the stored source would be plain JS text. - const rendered = Buffer.from('PKrendered-docx') - mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ - buffer: rendered, - contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - }) - + it('streams a zip assembled from the shared download use case result', async () => { const response = await GET(requestFor('fileIds=f1'), context) expect(response.status).toBe(200) - const entry = (await zipFrom(response)).file('Reports/overview.docx') - expect(entry).not.toBeNull() - expect(Buffer.from(await entry!.async('uint8array'))).toEqual(rendered) - }) - - it('streams ordinary files instead of materializing them', async () => { - mockListWorkspaceFiles.mockResolvedValue([workspaceFile('f1', 'clip.mp4')]) - - const response = await GET(requestFor('fileIds=f1'), context) - - expect(response.status).toBe(200) - // Nothing has been read yet: the entry opens its storage read only once the - // consumer pulls the archive, which is what keeps peak memory to one entry. - expect(mockDownloadFileStream).not.toHaveBeenCalled() - + expect(response.headers.get('Content-Type')).toBe('application/zip') + expect(response.headers.get('Content-Disposition')).toBe( + 'attachment; filename="workspace-files.zip"' + ) + expect(response.headers.get('Cache-Control')).toBe('no-store') const zip = await zipFrom(response) - - expect(mockDownloadFileStream).toHaveBeenCalledTimes(1) - // Never routed through the buffering document reader. - expect(mockFetchServableWorkspaceFileBuffer).not.toHaveBeenCalled() - - const entry = zip.file('Reports/clip.mp4') - expect(entry).not.toBeNull() - expect(await entry!.async('string')).toBe('plain') + expect(await zip.file('clip.mp4')?.async('string')).toBe('plain') }) - it('preserves nested folder paths across both entry kinds', async () => { - mockListWorkspaceFileFolders.mockResolvedValue([ - { id: 'folder-1', name: 'Reports', parentId: null }, - { id: 'folder-2', name: 'visuals', parentId: 'folder-1' }, - ]) - mockListWorkspaceFiles.mockResolvedValue([ - generatedDocument('f1', 'summary.docx', 'folder-1'), - workspaceFile('f2', 'hero.png', 'folder-2'), + it('preserves rendered entries and folder paths supplied by the use case', async () => { + const archiveResult = result([ + { id: 'f1', name: 'overview.docx', folderId: 'folder-1' }, + { id: 'f2', name: 'hero.png', folderId: null }, ]) - mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ - buffer: Buffer.from('PKdoc'), - contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - }) + archiveResult.renderedDocuments.set('f1', Buffer.from('rendered')) + mockDownloadItems.mockResolvedValue(archiveResult) const zip = await zipFrom(await GET(requestFor('fileIds=f1&fileIds=f2'), context)) - expect(zip.file('Reports/summary.docx')).not.toBeNull() - expect(zip.file('visuals/hero.png')).not.toBeNull() - }) - - it('returns 409 naming the documents whose artifacts are still compiling', async () => { - mockListWorkspaceFiles.mockResolvedValue([ - generatedDocument('f1', 'ready.docx'), - generatedDocument('f2', 'pending.docx'), - ]) - mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => { - if (file.name === 'pending.docx') - throw new DocCompileUserError('Document is still being generated') - return { buffer: Buffer.from('PKok'), contentType: 'application/octet-stream' } - }) - - const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context) - - expect(response.status).toBe(409) - const body = await response.json() - expect(body.error).toContain('pending.docx') - expect(body.error).not.toContain('ready.docx') - }) - - it('rejects with 400, not 500, when a document blows its own allowance', async () => { - mockListWorkspaceFiles.mockResolvedValue([generatedDocument('f1', 'huge.docx')]) - mockFetchServableWorkspaceFileBuffer.mockRejectedValue( - new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 }) - ) - - const response = await GET(requestFor('fileIds=f1'), context) - - expect(response.status).toBe(400) - const body = await response.json() - expect(body.error).toContain('huge.docx') - expect(body.error).not.toContain('Selected files total') + expect(await zip.file('Reports/overview.docx')?.async('string')).toBe('rendered') + expect(await zip.file('hero.png')?.async('string')).toBe('plain') }) - it('counts streamed files against the same budget as rendered documents', async () => { - // 200 MB of ordinary files leaves 50 MB of the 250 MB budget for documents. - mockListWorkspaceFiles.mockResolvedValue([ - { ...workspaceFile('f1', 'clip.mp4'), size: 200 * MB }, - generatedDocument('f2', 'report.docx'), - ]) - mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ - buffer: Buffer.from('PKdoc'), - contentType: 'application/octet-stream', - }) - - await zipFrom(await GET(requestFor('fileIds=f1&fileIds=f2'), context)) - - // Without reserving the streamed bytes the document would get the full 50 MB - // ceiling, letting the archive ship 250 MB of documents on top of 200 MB of video. - expect(mockFetchServableWorkspaceFileBuffer.mock.calls[0][1].maxBytes).toBe(50 * MB) - }) + it('preserves the internal product analytics event after authorization and selection', async () => { + await GET(requestFor('fileIds=f1'), context) - it('rejects when streamed files leave no budget for a document', async () => { - mockListWorkspaceFiles.mockResolvedValue([ - { ...workspaceFile('f1', 'clip.mp4'), size: 249 * MB }, - generatedDocument('f2', 'report.docx'), - ]) - mockFetchServableWorkspaceFileBuffer.mockRejectedValue( - new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 }) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_downloaded', + { workspace_id: WORKSPACE_ID, is_bulk: true, file_count: 1 }, + { groups: { workspace: WORKSPACE_ID } } ) - - const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context) - - expect(response.status).toBe(400) - const body = await response.json() - expect(body.error).toContain('once documents are rendered') - // No byte count: the rendered total is not knowable, so quoting one would mislead. - expect(body.error).not.toContain('Selected files total') }) - it('blames the shared budget once earlier documents have consumed it', async () => { - mockListWorkspaceFiles.mockResolvedValue([ - generatedDocument('f1', 'first.docx'), - generatedDocument('f2', 'second.docx'), - ]) - mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => { - // The first document eats the whole budget, so the second's cap is the remainder. - if (file.name === 'first.docx') { - return { buffer: Buffer.alloc(240 * MB), contentType: 'application/octet-stream' } - } - throw new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 }) - }) - - const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context) - - expect(response.status).toBe(400) - const body = await response.json() - expect(body.error).toContain('once documents are rendered') - expect(body.error).not.toContain('second.docx') - }) - - it.each([ - ['text/x-docxjs', 'report.docx'], - ['text/x-pptxgenjs', 'deck.pptx'], - ['text/x-pdflibjs', 'isolated.pdf'], - ['text/x-python-pdf', 'sandboxed.pdf'], - ['text/x-python-xlsx', 'sheet.xlsx'], - ])('resolves %s rather than streaming its source', async (type, name) => { - // Both PDF generators must be covered: the isolated-vm path stores pdf-lib JS and - // the E2B path stores Python, and either one streamed raw is the corruption bug. - mockListWorkspaceFiles.mockResolvedValue([{ ...workspaceFile('f1', name), type }]) - mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ - buffer: Buffer.from('PKrendered'), - contentType: 'application/octet-stream', - }) - - await zipFrom(await GET(requestFor('fileIds=f1'), context)) - - expect(mockFetchServableWorkspaceFileBuffer).toHaveBeenCalledTimes(1) - expect(mockDownloadFileStream).not.toHaveBeenCalled() - }) - - it('streams an uploaded office file rather than resolving it', async () => { - // A real upload serves exactly its stored bytes, so it must not take the buffered - // path — otherwise a selection of large decks is held in memory for nothing. - const upload = { - ...workspaceFile('f1', 'deck.pptx'), - size: 80 * MB, - type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - } - mockListWorkspaceFiles.mockResolvedValue([upload]) + it('authenticates before parsing and dispatching the use case', async () => { + mockGetSession.mockResolvedValue(null) const response = await GET(requestFor('fileIds=f1'), context) - await zipFrom(response) - expect(response.status).toBe(200) - expect(mockFetchServableWorkspaceFileBuffer).not.toHaveBeenCalled() - expect(mockDownloadFileStream).toHaveBeenCalledTimes(1) + expect(response.status).toBe(401) + expect(mockDownloadItems).not.toHaveBeenCalled() }) - it('caps a generated document at the render headroom', async () => { - mockListWorkspaceFiles.mockResolvedValue([generatedDocument('f1', 'report.docx')]) - mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ - buffer: Buffer.from('PKdoc'), - contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - }) + it('keeps application validation errors in the legacy error envelope', async () => { + mockDownloadItems.mockRejectedValue(new Error('should not be raw')) - await zipFrom(await GET(requestFor('fileIds=f1'), context)) - - expect(mockFetchServableWorkspaceFileBuffer.mock.calls[0][1].maxBytes).toBe(50 * MB) - }) - - it('surfaces a storage failure as a 500 even when another document is pending', async () => { - mockListWorkspaceFiles.mockResolvedValue([ - generatedDocument('f1', 'pending.docx'), - generatedDocument('f2', 'broken.docx'), - ]) - mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => { - if (file.name === 'pending.docx') - throw new DocCompileUserError('Document is still being generated') - throw new Error('storage down') - }) - - const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context) - - // A 409 would tell the client to retry something that can never succeed. - expect(response.status).toBe(500) - }) - - it('stops resolving documents once one hard-fails', async () => { - const files = Array.from({ length: 20 }, (_, index) => - generatedDocument(`f${index}`, `doc${index}.docx`) - ) - mockListWorkspaceFiles.mockResolvedValue(files) - mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => { - if (file.name === 'doc0.docx') throw new Error('storage down') - return { buffer: Buffer.from('PKok'), contentType: 'application/octet-stream' } - }) - - const response = await GET( - requestFor(files.map((file) => `fileIds=${file.id}`).join('&')), - context - ) + const response = await GET(requestFor('fileIds=f1'), context) expect(response.status).toBe(500) - expect(mockFetchServableWorkspaceFileBuffer.mock.calls.length).toBeLessThan(files.length) - }) - - it('rejects a selection whose declared sizes already exceed the limit', async () => { - mockListWorkspaceFiles.mockResolvedValue([ - { ...workspaceFile('f1', 'a.mp4'), size: 200 * MB }, - { ...workspaceFile('f2', 'b.mp4'), size: 200 * MB }, - ]) - - const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context) - - expect(response.status).toBe(400) - expect(mockDownloadFileStream).not.toHaveBeenCalled() + expect(await response.json()).toEqual({ error: 'Internal server error' }) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.ts index 67b070f303f..6d4220fd1a2 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.ts @@ -1,57 +1,21 @@ import { Readable } from 'node:stream' -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { ZipArchive } from 'archiver' -import { type NextRequest, NextResponse } from 'next/server' import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { + defineInternalBinaryRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' -import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' -import { - buildWorkspaceFileFolderPathMap, - fetchServableWorkspaceFileBuffer, - listWorkspaceFileFolders, - listWorkspaceFiles, -} from '@/lib/uploads/contexts/workspace' import { downloadFileStream } from '@/lib/uploads/core/storage-service' -import { - formatFileSize, - isGeneratedDocumentSourceType, - isRenderableDocumentName, - MAX_RENDERED_DOCUMENT_BYTES, -} from '@/lib/uploads/utils/file-utils' -import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/servable-file-response' import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' -import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' +import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items' const logger = createLogger('WorkspaceFilesDownloadAPI') -const MAX_ZIP_DOWNLOAD_FILES = 100 -const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024 - -/** - * Whether this entry's stored bytes are a generation source that has to be resolved - * before it can go in the archive. An ordinary uploaded `.docx` serves exactly what is - * stored, so it streams like anything else — routing every office extension through the - * buffered path would hold a whole selection of real documents in memory for a check - * the resolver settles on the first few magic bytes. Metadata without a type falls back - * to the extension: better to resolve and pass through than to stream source text under - * a document name. - */ -function needsRendering(file: WorkspaceFileRecord): boolean { - return file.type ? isGeneratedDocumentSourceType(file.type) : isRenderableDocumentName(file.name) -} -/** - * A `Readable` that opens its storage read on first pull rather than up front. The - * archiver works through entries sequentially, so handing it an open stream per entry - * would hold a connection per selected file — more than the storage client pools — with - * most sitting idle until their turn. The generator body does not run until the first - * read, and a failure to open surfaces as the stream's `error` event. - */ function lazyWorkspaceFileStream(file: WorkspaceFileRecord): Readable { return Readable.from( (async function* () { @@ -60,204 +24,48 @@ function lazyWorkspaceFileStream(file: WorkspaceFileRecord): Readable { context: file.storageContext ?? 'workspace', }) })(), - // `Readable.from` defaults to object mode; these are bytes headed for an archive. { objectMode: false } ) } -function selectionTooLargeResponse(bytes: number): NextResponse { - return NextResponse.json( - { - error: `Selected files total ${formatFileSize(bytes)}, which exceeds the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit.`, - }, - { status: 400 } - ) -} - -/** - * The rendered archive would exceed the limit even though the declared sizes did not — - * generated documents render to more than the source they declared, so no accurate byte - * count exists to quote here. - */ -function archiveTooLargeResponse(): NextResponse { - return NextResponse.json( - { - error: `The selected files exceed the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit once documents are rendered. Select fewer files.`, - }, - { status: 400 } - ) -} - -function collectDescendantFolderIds( - selectedFolderIds: string[], - folders: Array<{ id: string; parentId: string | null }> -): Set { - const folderIds = new Set(selectedFolderIds) - let changed = true - while (changed) { - changed = false - for (const folder of folders) { - if (folder.parentId && folderIds.has(folder.parentId) && !folderIds.has(folder.id)) { - folderIds.add(folder.id) - changed = true - } - } - } - return folderIds -} - -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(downloadWorkspaceFileItemsContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { fileIds, folderIds } = parsed.data.query - - const permission = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!permission) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const [files, folders] = await Promise.all([ - listWorkspaceFiles(workspaceId, { hydrateFolderPaths: false }), - listWorkspaceFileFolders(workspaceId), - ]) - const folderPaths = buildWorkspaceFileFolderPathMap(folders) - const selectedFolderIds = collectDescendantFolderIds(folderIds, folders) - const requestedFileIds = new Set(fileIds) - const filesToZip = files.filter( - (file) => - requestedFileIds.has(file.id) || (file.folderId && selectedFolderIds.has(file.folderId)) - ) - - if (filesToZip.length === 0) { - return NextResponse.json({ error: 'No files selected for download' }, { status: 400 }) - } - - if (filesToZip.length > MAX_ZIP_DOWNLOAD_FILES) { - return NextResponse.json( - { - error: `Too many files selected for download. Select ${MAX_ZIP_DOWNLOAD_FILES} or fewer files.`, - }, - { status: 400 } - ) - } - - const declaredBytes = filesToZip.reduce((sum, file) => sum + file.size, 0) - if (declaredBytes > MAX_ZIP_DOWNLOAD_BYTES) { - return selectionTooLargeResponse(declaredBytes) - } - - // Streamed entries ship exactly what they declared, so their share of the budget is - // known up front and is reserved here. Documents then draw from what is left — - // one budget across both kinds, or the archive could ship two full limits' worth. - const reservedForStreamed = filesToZip - .filter((file) => !needsRendering(file)) - .reduce((sum, file) => sum + file.size, 0) - - // Generated documents are resolved before the archive starts: once the first byte - // is written the status code is committed, so anything that can still fail the - // request has to fail here. Their buffers are held until the archive is assembled, - // bounded by what is left of the request's byte budget. - const renderedDocuments = new Map() - const pendingNames: string[] = [] - let renderedBytes = 0 - - for (const file of filesToZip) { - if (!needsRendering(file)) continue - - const remaining = Math.max(0, MAX_ZIP_DOWNLOAD_BYTES - reservedForStreamed - renderedBytes) - // A source's declared size says nothing about what it renders to, so the cap is - // the per-document ceiling, bounded by what is left of the budget. - const allowance = Math.min(remaining, MAX_RENDERED_DOCUMENT_BYTES) - - try { - const { buffer } = await fetchServableWorkspaceFileBuffer(file, { maxBytes: allowance }) - renderedBytes += buffer.length - renderedDocuments.set(file.id, buffer) - } catch (error) { - if (error instanceof PayloadSizeLimitError) { - // Blamed on the entry when its own ceiling was the binding cap; otherwise the - // documents ahead of it have consumed the budget. - return allowance === MAX_RENDERED_DOCUMENT_BYTES - ? NextResponse.json( - { - error: `"${file.name}" renders to more than ${formatFileSize(MAX_RENDERED_DOCUMENT_BYTES)} and is too large to include in a zip; download it on its own instead.`, - }, - { status: 400 } - ) - : archiveTooLargeResponse() - } - // Pending artifacts are collected so the 409 can name all of them; anything - // else dooms the request and waiting cannot fix it. - if (!isDocNotReadyError(error)) throw error - pendingNames.push(file.name) - } - } - - if (pendingNames.length > 0) { - return NextResponse.json({ error: docNotReadyMessage(pendingNames) }, { status: 409 }) - } - - // Entry paths stay workspace-root-relative so a mixed selection of folders and - // loose files keeps the layout the user sees in the files list. - const entryPaths = buildZipEntryPaths( - filesToZip.map((file) => ({ - name: file.name, - folderPath: file.folderId ? folderPaths.get(file.folderId) : null, - })) - ) - - // Ordinary files are never materialized: each entry opens its storage read only - // when the archiver reaches it, so one entry is resident rather than the archive. - const archive = new ZipArchive({ store: true }) - archive.on('warning', (error: Error) => { - logger.warn('Archive warning while streaming workspace files', { error }) - }) - - filesToZip.forEach((file, index) => { - const rendered = renderedDocuments.get(file.id) - archive.append(rendered ?? lazyWorkspaceFileStream(file), { name: entryPaths[index] }) - }) - archive.finalize().catch((error) => { - // The archive's `error` event already fails the response stream; this keeps the - // same failure from also surfacing as an unhandled rejection. - logger.error('Failed to finalize workspace file archive', { error }) - }) - - recordAudit({ - workspaceId, - actorId: session.user.id, - action: AuditAction.FILE_DOWNLOADED, - resourceType: AuditResourceType.FILE, - description: `Downloaded ${filesToZip.length} file${filesToZip.length === 1 ? '' : 's'} as zip`, - metadata: { fileCount: filesToZip.length, totalBytes: declaredBytes }, - request, - }) - captureServerEvent( - session.user.id, - 'file_downloaded', - { workspace_id: workspaceId, is_bulk: true, file_count: filesToZip.length }, - { groups: { workspace: workspaceId } } - ) - - // No Content-Length: the archive size is not known until it has been produced. - return new NextResponse(nodeReadableToWebStream(archive), { - headers: { - 'Content-Type': 'application/zip', - 'Content-Disposition': 'attachment; filename="workspace-files.zip"', - 'Cache-Control': 'no-store', - }, +export const GET = defineInternalBinaryRoute({ + contract: downloadWorkspaceFileItemsContract, + auth: internalSessionAuth, + operation: downloadWorkspaceFileItems.operation, + rateLimit: internalRateLimits.none({ reason: 'Internal workspace zip download' }), + errorPolicy: internalFileErrorPolicies.downloadArchive, + mapInput: ({ params, query }) => ({ + workspaceId: params.id, + fileIds: query.fileIds, + folderIds: query.folderIds, + }), + useCase: downloadWorkspaceFileItems, + onSuccess: internalFileAnalytics.bulkDownloaded, + present: ({ filesToZip, folderPaths, renderedDocuments }) => { + const entryPaths = buildZipEntryPaths( + filesToZip.map((file) => ({ + name: file.name, + folderPath: file.folderId ? folderPaths.get(file.folderId) : null, + })) + ) + const archive = new ZipArchive({ store: true }) + archive.on('warning', (error: Error) => { + logger.warn('Archive warning while streaming workspace files', { error }) + }) + filesToZip.forEach((file, index) => { + archive.append(renderedDocuments.get(file.id) ?? lazyWorkspaceFileStream(file), { + name: entryPaths[index], }) - } catch (error) { - logger.error('Failed to download workspace file selection:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + }) + archive.finalize().catch((error) => { + logger.error('Failed to finalize workspace file archive', { error }) + }) + + return { + body: nodeReadableToWebStream(archive), + contentType: 'application/zip', + contentDisposition: 'attachment; filename="workspace-files.zip"', + headers: { 'Cache-Control': 'no-store' }, } - } -) + }, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts index ecf7b17b281..9c492438d8d 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts @@ -1,64 +1,24 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { restoreWorkspaceFileFolderContract } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { statusForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { performRestoreWorkspaceFileFolder } from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + defineInternalJsonRoute, + internalJsonPresenters, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { restoreWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' -const logger = createLogger('WorkspaceFileFolderRestoreAPI') - -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(restoreWorkspaceFileFolderContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, folderId } = parsed.data.params - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performRestoreWorkspaceFileFolder({ - workspaceId, - folderId, - userId: session.user.id, - }) - if (!result.success) { - return NextResponse.json( - { success: false, error: result.error }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - const { folder, restoredItems } = result - if (!folder || !restoredItems) { - return NextResponse.json( - { success: false, error: 'Failed to restore workspace file folder' }, - { status: 500 } - ) - } - - logger.info(`Restored workspace file folder: ${folderId}`) - - captureServerEvent( - session.user.id, - 'folder_restored', - { folder_id: folderId, workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json({ success: true, folder, restoredItems }) - } catch (error) { - logger.error('Failed to restore workspace file folder:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) +export const POST = defineInternalJsonRoute({ + contract: restoreWorkspaceFileFolderContract, + auth: internalSessionAuth, + operation: fileOperations.restoreFolder, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal folder restore behavior', + }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params }) => ({ workspaceId: params.id, folderId: params.folderId }), + useCase: restoreWorkspaceFileFolderOperation, + onSuccess: internalFileAnalytics.folderRestored, + present: internalJsonPresenters.withSuccess, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.test.ts new file mode 100644 index 00000000000..8a9873a8eb0 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.test.ts @@ -0,0 +1,143 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + updateFolder: vi.fn(), + deleteFolder: vi.fn(), + restoreFolder: vi.fn(), + captureServerEvent: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + updateWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateFolder, + }, + deleteWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.deleteFolder, + }, + restoreWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.restore', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.restoreFolder, + }, +})) + +import { POST as RESTORE } from '@/app/api/workspaces/[id]/files/folders/[folderId]/restore/route' +import { DELETE, PATCH } from '@/app/api/workspaces/[id]/files/folders/[folderId]/route' + +const WORKSPACE_ID = 'workspace-1' +const FOLDER_ID = 'folder-1' +const PRINCIPAL = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +const context = { + params: Promise.resolve({ id: WORKSPACE_ID, folderId: FOLDER_ID }), +} +const folder = { + id: FOLDER_ID, + workspaceId: WORKSPACE_ID, + userId: 'user-1', + name: 'Reports', + parentId: null, + path: '/Reports', + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} +const serializedFolder = { + ...folder, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +} + +function request(method: 'PATCH' | 'DELETE' | 'POST', body?: unknown) { + return new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/folders/${FOLDER_ID}${method === 'POST' ? '/restore' : ''}`, + { + method, + ...(body === undefined + ? {} + : { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + } + ) +} + +describe('/api/workspaces/[id]/files/folders/[folderId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mocks.updateFolder.mockResolvedValue({ folder }) + mocks.deleteFolder.mockResolvedValue({ deletedItems: { folders: 1, files: 2 } }) + mocks.restoreFolder.mockResolvedValue({ + folder, + restoredItems: { folders: 1, files: 2 }, + }) + }) + + it('updates a folder through the shared use case', async () => { + const response = await PATCH(request('PATCH', { name: 'Reports' }), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true, folder: serializedFolder }) + expect(mocks.updateFolder).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, folderId: FOLDER_ID, name: 'Reports' }, + request: expect.anything(), + }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'folder_renamed', + { workspace_id: WORKSPACE_ID }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it('deletes a folder through the shared use case', async () => { + const response = await DELETE(request('DELETE'), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + deletedItems: { folders: 1, files: 2 }, + }) + expect(mocks.deleteFolder).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, folderId: FOLDER_ID }, + request: expect.anything(), + }) + }) + + it('restores a folder through the shared use case', async () => { + const response = await RESTORE(request('POST'), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + folder: serializedFolder, + restoredItems: { folders: 1, files: 2 }, + }) + expect(mocks.restoreFolder).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, folderId: FOLDER_ID }, + request: expect.anything(), + }) + }) + + it('authenticates before parsing a folder mutation', async () => { + mocks.getSession.mockResolvedValueOnce(null) + + const response = await PATCH(request('PATCH', { name: 'Reports' }), context) + + expect(response.status).toBe(401) + expect(mocks.updateFolder).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts index 079f25e8459..c5873d2fdfa 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts @@ -1,121 +1,44 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { deleteWorkspaceFileFolderContract, updateWorkspaceFileFolderContract, } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { statusForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' import { - performDeleteWorkspaceFileItems, - performUpdateWorkspaceFileFolder, -} from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('WorkspaceFileFolderAPI') - -async function assertWritePermission(userId: string, workspaceId: string) { - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - return permission === 'admin' || permission === 'write' -} - -export const PATCH = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(updateWorkspaceFileFolderContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, folderId } = parsed.data.params - - if (!(await assertWritePermission(session.user.id, workspaceId))) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performUpdateWorkspaceFileFolder({ - workspaceId, - folderId, - userId: session.user.id, - ...parsed.data.body, - }) - if (!result.success || !result.folder) { - return NextResponse.json( - { success: false, error: result.error }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - captureServerEvent( - session.user.id, - 'folder_renamed', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json({ success: true, folder: result.folder }) - } catch (error) { - logger.error('Failed to update workspace file folder:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) - -export const DELETE = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(deleteWorkspaceFileFolderContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, folderId } = parsed.data.params - - if (!(await assertWritePermission(session.user.id, workspaceId))) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId: session.user.id, - folderIds: [folderId], - }) - if (!result.success) { - return NextResponse.json( - { success: false, error: result.error }, - { - status: - result.errorCode === 'validation' - ? 400 - : result.errorCode === 'not_found' - ? 404 - : 500, - } - ) - } - if (!result.deletedItems) { - return NextResponse.json( - { success: false, error: 'Failed to delete workspace file folder' }, - { status: 500 } - ) - } - - captureServerEvent( - session.user.id, - 'folder_deleted', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - - return NextResponse.json({ success: true, deletedItems: result.deletedItems }) - } catch (error) { - logger.error('Failed to delete workspace file folder:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) + defineInternalJsonRoute, + internalJsonPresenters, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { + deleteWorkspaceFileFolderOperation, + updateWorkspaceFileFolderOperation, +} from '@/lib/workspace-files/application/workspace-file-folders' + +export const PATCH = defineInternalJsonRoute({ + contract: updateWorkspaceFileFolderContract, + auth: internalSessionAuth, + operation: fileOperations.updateFolder, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal folder update behavior', + }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, body }) => ({ workspaceId: params.id, folderId: params.folderId, ...body }), + useCase: updateWorkspaceFileFolderOperation, + onSuccess: internalFileAnalytics.folderRenamed, + present: internalJsonPresenters.withSuccess, +}) + +export const DELETE = defineInternalJsonRoute({ + contract: deleteWorkspaceFileFolderContract, + auth: internalSessionAuth, + operation: fileOperations.deleteFolder, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal folder deletion behavior', + }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params }) => ({ workspaceId: params.id, folderId: params.folderId }), + useCase: deleteWorkspaceFileFolderOperation, + onSuccess: internalFileAnalytics.folderDeleted, + present: internalJsonPresenters.withSuccess, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/folders/route.test.ts new file mode 100644 index 00000000000..e9645fe19ba --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/route.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + listFolders: vi.fn(), + createFolder: vi.fn(), + captureServerEvent: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + listWorkspaceFileFoldersOperation: { + operation: { id: 'files.folders.list', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.listFolders, + }, + createWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.create', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.createFolder, + }, +})) + +import { GET, POST } from '@/app/api/workspaces/[id]/files/folders/route' + +const WORKSPACE_ID = 'workspace-1' +const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } +const folder = { + id: 'folder-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + name: 'Reports', + parentId: null, + path: '/Reports', + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} +const serializedFolder = { + ...folder, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +} + +function request(method: 'GET' | 'POST', body?: unknown) { + return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/folders`, { + method, + ...(body === undefined + ? {} + : { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + }) +} + +describe('/api/workspaces/[id]/files/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mocks.listFolders.mockResolvedValue({ folders: [folder] }) + mocks.createFolder.mockResolvedValue({ folder }) + }) + + it('authenticates before listing folders', async () => { + mocks.getSession.mockResolvedValueOnce(null) + + const response = await GET(request('GET'), context) + + expect(response.status).toBe(401) + expect(mocks.listFolders).not.toHaveBeenCalled() + }) + + it('lists folders through the shared use case', async () => { + const response = await GET(request('GET'), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true, folders: [serializedFolder] }) + expect(mocks.listFolders).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: WORKSPACE_ID, scope: 'active' }, + request: expect.anything(), + }) + }) + + it('creates a folder and preserves the internal success event', async () => { + const response = await POST(request('POST', { name: 'Reports' }), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true, folder: serializedFolder }) + expect(mocks.createFolder).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: WORKSPACE_ID, name: 'Reports', parentId: undefined }, + request: expect.anything(), + }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'folder_created', + { workspace_id: WORKSPACE_ID }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it('rejects an invalid folder name before the use case', async () => { + const response = await POST(request('POST', { name: 'nested/name' }), context) + + expect(response.status).toBe(400) + expect(mocks.createFolder).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts index ba3180cb609..4165d8f42d7 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts @@ -1,86 +1,47 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { createWorkspaceFileFolderContract, listWorkspaceFileFoldersContract, } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { statusForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace' -import { performCreateWorkspaceFileFolder } from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('WorkspaceFileFoldersAPI') - -async function getWorkspacePermission(userId: string, workspaceId: string) { - return getUserEntityPermissions(userId, 'workspace', workspaceId) -} - -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(listWorkspaceFileFoldersContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { scope } = parsed.data.query - - const permission = await getWorkspacePermission(session.user.id, workspaceId) - if (!permission) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const folders = await listWorkspaceFileFolders(workspaceId, { scope }) - return NextResponse.json({ success: true, folders }) - } -) - -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(createWorkspaceFileFolderContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { name, parentId } = parsed.data.body - - const permission = await getWorkspacePermission(session.user.id, workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performCreateWorkspaceFileFolder({ - workspaceId, - userId: session.user.id, - name, - parentId, - }) - if (!result.success || !result.folder) { - return NextResponse.json( - { success: false, error: result.error }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - captureServerEvent( - session.user.id, - 'folder_created', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json({ success: true, folder: result.folder }) - } catch (error) { - logger.error('Failed to create workspace file folder:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) +import { + defineInternalJsonRoute, + internalJsonPresenters, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { + createWorkspaceFileFolderOperation, + listWorkspaceFileFoldersOperation, +} from '@/lib/workspace-files/application/workspace-file-folders' + +export const GET = defineInternalJsonRoute({ + contract: listWorkspaceFileFoldersContract, + auth: internalSessionAuth, + operation: fileOperations.listFolders, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal folder listing behavior', + }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, query }) => ({ workspaceId: params.id, scope: query.scope }), + useCase: listWorkspaceFileFoldersOperation, + present: internalJsonPresenters.withSuccess, +}) + +export const POST = defineInternalJsonRoute({ + contract: createWorkspaceFileFolderContract, + auth: internalSessionAuth, + operation: fileOperations.createFolder, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal folder creation behavior', + }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + name: body.name, + parentId: body.parentId, + }), + useCase: createWorkspaceFileFolderOperation, + onSuccess: internalFileAnalytics.folderCreated, + present: internalJsonPresenters.withSuccess, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts index 11726424281..495044427bb 100644 --- a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts @@ -4,77 +4,83 @@ import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' -const { mockGetPerms, mockResolveImage, mockDownloadFile } = vi.hoisted(() => ({ - mockGetPerms: vi.fn(), - mockResolveImage: vi.fn(), - mockDownloadFile: vi.fn(), -})) +const { mockReadInline } = vi.hoisted(() => ({ mockReadInline: vi.fn() })) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetPerms })) -vi.mock('@/lib/uploads/server/inline-image', () => ({ - resolveWorkspaceInlineImage: mockResolveImage, +vi.mock('@/lib/workspace-files/application/read-workspace-inline-file', () => ({ + readWorkspaceInlineFile: { + operation: { id: 'files.read_content', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mockReadInline, + }, })) -vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile })) import { GET } from '@/app/api/workspaces/[id]/files/inline/route' const mockGetSession = authMockFns.mockGetSession - const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]) const params = { params: Promise.resolve({ id: 'ws-1' }) } -const req = (q: string) => new NextRequest(`http://localhost/api/workspaces/ws-1/files/inline?${q}`) +const req = (q: string) => + new NextRequest(`http://localhost/api/workspaces/ws-1/files/inline${q ? `?${q}` : ''}`) describe('GET /api/workspaces/[id]/files/inline', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: 'u1' } }) - mockGetPerms.mockResolvedValue('read') - mockResolveImage.mockResolvedValue({ - key: 'workspace/ws-1/x-photo.png', - contentType: 'image/png', - filename: 'photo.png', + mockGetSession.mockResolvedValue({ user: { id: 'u1' }, session: { id: 's1' } }) + mockReadInline.mockResolvedValue({ + file: { name: 'photo.png', type: 'image/png', size: PNG.length }, + stream: new Blob([new Uint8Array(PNG)]).stream(), }) - mockDownloadFile.mockResolvedValue(PNG) }) - it('serves a workspace-scoped image by fileId, always revalidating', async () => { + it('serves authenticated workspace-scoped content by file id', async () => { const res = await GET(req('fileId=wf_abc'), params) + expect(res.status).toBe(200) - expect(mockResolveImage).toHaveBeenCalledWith('ws-1', { fileId: 'wf_abc' }) - // Authenticated content: always revalidate so a deletion/revocation is enforced on the next request. + expect(mockReadInline).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'u1', sessionId: 's1' }, + input: { workspaceId: 'ws-1', fileId: 'wf_abc' }, + }) + ) expect(res.headers.get('Cache-Control')).toBe('private, no-cache, must-revalidate') + expect(res.headers.get('Content-Disposition')).toBe('inline; filename="photo.png"') }) - it('serves a workspace-scoped image by key, always revalidating', async () => { - const res = await GET(req(`key=${encodeURIComponent('workspace/ws-1/x-photo.png')}`), params) + it('passes key references to the shared read use case', async () => { + const res = await GET(req('key=workspace%2Fws-1%2Fphoto.png'), params) + expect(res.status).toBe(200) - // Same policy as fileId: authenticated content never cached past a revalidation, so a deleted or - // access-revoked image drops out immediately rather than lingering in a private browser cache. - expect(res.headers.get('Cache-Control')).toBe('private, no-cache, must-revalidate') + expect(mockReadInline.mock.calls[0][0].input).toEqual({ + workspaceId: 'ws-1', + key: 'workspace/ws-1/photo.png', + fileId: undefined, + }) }) - it('404s when the reference does not resolve in the workspace (cross-workspace)', async () => { - mockResolveImage.mockResolvedValue(null) + it('returns the concealed 404 response for an unauthorized or missing file', async () => { + mockReadInline.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient permissions') + ) + const res = await GET(req('fileId=wf_other'), params) - expect(res.status).toBe(404) - }) - it('404s without workspace membership, before resolving the file', async () => { - mockGetPerms.mockResolvedValue(null) - const res = await GET(req('fileId=wf_abc'), params) expect(res.status).toBe(404) - expect(mockResolveImage).not.toHaveBeenCalled() + expect(await res.json()).toEqual({ error: 'FileNotFoundError', message: 'Not found' }) }) - it('401s without a session', async () => { + it('authenticates before parsing invalid input', async () => { mockGetSession.mockResolvedValue(null) - const res = await GET(req('fileId=wf_abc'), params) + + const res = await GET(req(''), params) + expect(res.status).toBe(401) + expect(mockReadInline).not.toHaveBeenCalled() }) - it('400s when neither key nor fileId is provided', async () => { - const res = await GET(req(''), params) + it('returns a validation response when both references are supplied', async () => { + const res = await GET(req('key=k&fileId=f'), params) expect(res.status).toBe(400) + expect(mockReadInline).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts index 245fb5731d8..ad3780eb4be 100644 --- a/apps/sim/app/api/workspaces/[id]/files/inline/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts @@ -1,59 +1,53 @@ -import { createLogger } from '@sim/logger' -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' import { getInlineWorkspaceFileContract } from '@/lib/api/contracts/workspace-files' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { serveInlineImage } from '@/app/api/files/serve-inline-image' -import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils' +import { + defineInternalBinaryRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { readWorkspaceInlineFile } from '@/lib/workspace-files/application/read-workspace-inline-file' +import { encodeFilenameForHeader, getSecureFileHeaders } from '@/app/api/files/utils' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceInlineFileAPI') - /** * GET /api/workspaces/[id]/files/inline?key=|fileId= * - * Serves an image embedded in a workspace markdown document, **scoped to the workspace in the path**. - * The markdown editor rewrites its embedded `/api/files/serve/` and `/api/files/view/` srcs to - * this route so a referenced file resolves only within the document's workspace — a cross-workspace - * reference returns 404 and does not render, even for a viewer who belongs to the other workspace. Read - * access to the workspace is required; disposition/content-type handling mirrors the serve route. + * Serves an authenticated workspace-scoped image. Authentication and the + * `files.read_content` authorization check happen before resolving or reading + * the referenced object, preserving cross-workspace concealment. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - try { - const parsed = await parseRequest(getInlineWorkspaceFileContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const ref = parsed.data.query - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Authorize before disclosing anything; deny with 404 so a non-member can't probe existence. - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (!permission) { - throw new FileNotFoundError('Not found') - } - - const image = await resolveWorkspaceInlineImage(workspaceId, ref) - if (!image) { - throw new FileNotFoundError('Not found') - } - - return await serveInlineImage(image, { sniff: false }) - } catch (error) { - if (error instanceof FileNotFoundError) { - return createErrorResponse(error) - } - logger.error('Error serving workspace inline image:', error) - return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file')) +export const GET = defineInternalBinaryRoute({ + contract: getInlineWorkspaceFileContract, + auth: internalSessionAuth, + operation: readWorkspaceInlineFile.operation, + rateLimit: internalRateLimits.none({ reason: 'Internal workspace inline image delivery' }), + errorPolicy: internalFileErrorPolicies.inline, + mapInput: ({ params, query }) => ({ + workspaceId: params.id, + key: query.key, + fileId: query.fileId, + }), + useCase: readWorkspaceInlineFile, + present: ({ file, stream }) => { + const secure = getSecureFileHeaders(file.name, file.type) + const headers = new Headers({ + 'Content-Type': secure.contentType, + 'Content-Disposition': `${secure.disposition}; ${encodeFilenameForHeader(file.name)}`, + 'Cache-Control': 'private, no-cache, must-revalidate', + 'X-Content-Type-Options': 'nosniff', + }) + if (secure.contentType === 'image/svg+xml') { + headers.set( + 'Content-Security-Policy', + "default-src 'none'; style-src 'unsafe-inline'; sandbox;" + ) + } + return { + body: stream, + contentType: secure.contentType, + contentLength: file.size, + headers, } - } -) + }, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/move/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/move/route.test.ts new file mode 100644 index 00000000000..46c81932a2c --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/move/route.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + execute: vi.fn(), + captureServerEvent: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) +vi.mock('@/lib/workspace-files/application/move-workspace-file-items', () => ({ + moveWorkspaceFileItemsOperation: { + operation: { id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.execute, + }, +})) + +import { POST } from '@/app/api/workspaces/[id]/files/move/route' + +const WORKSPACE_ID = 'workspace-1' +const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } + +function request(body: unknown) { + return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/move`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('/api/workspaces/[id]/files/move', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mocks.execute.mockResolvedValue({ movedItems: { files: 2, folders: 1 } }) + }) + + it('moves selected files and folders through the shared operation', async () => { + const response = await POST( + request({ fileIds: ['wf_1', 'wf_2'], folderIds: ['folder-1'], targetFolderId: null }), + context + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + movedItems: { files: 2, folders: 1 }, + }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: WORKSPACE_ID, + fileIds: ['wf_1', 'wf_2'], + folderIds: ['folder-1'], + targetFolderId: null, + }, + request: expect.anything(), + }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_moved', + { workspace_id: WORKSPACE_ID, file_count: 2, folder_count: 1 }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it('rejects an empty selection before the use case', async () => { + const response = await POST(request({}), context) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('authenticates before parsing the selection', async () => { + mocks.getSession.mockResolvedValueOnce(null) + + const response = await POST(request({ fileIds: ['wf_1'] }), context) + + expect(response.status).toBe(401) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/move/route.ts b/apps/sim/app/api/workspaces/[id]/files/move/route.ts index 81861789eee..bc219539eaf 100644 --- a/apps/sim/app/api/workspaces/[id]/files/move/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/move/route.ts @@ -1,78 +1,26 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { moveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' -const logger = createLogger('WorkspaceFileMoveAPI') - -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(moveWorkspaceFileItemsContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { fileIds, folderIds, targetFolderId } = parsed.data.body - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performMoveWorkspaceFileItems({ - workspaceId, - userId: session.user.id, - fileIds, - folderIds, - targetFolderId, - }) - if (!result.success || !result.movedItems) { - return NextResponse.json( - { success: false, error: result.error }, - { - status: - result.errorCode === 'conflict' - ? 409 - : result.errorCode === 'not_found' - ? 404 - : result.errorCode === 'validation' - ? 400 - : 500, - } - ) - } - if (fileIds.length > 0) { - captureServerEvent( - session.user.id, - 'file_moved', - { workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length }, - { groups: { workspace: workspaceId } } - ) - } - if (folderIds.length > 0) { - captureServerEvent( - session.user.id, - 'folder_moved', - { workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length }, - { groups: { workspace: workspaceId } } - ) - } - return NextResponse.json({ - success: true, - movedItems: result.movedItems, - }) - } catch (error) { - logger.error('Failed to move workspace file items:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) +export const POST = defineInternalJsonRoute({ + contract: moveWorkspaceFileItemsContract, + auth: internalSessionAuth, + operation: fileOperations.move, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal file move behavior' }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + fileIds: body.fileIds, + folderIds: body.folderIds, + targetFolderId: body.targetFolderId, + }), + useCase: moveWorkspaceFileItemsOperation, + onSuccess: internalFileAnalytics.moved, + present: ({ movedItems }) => ({ success: true, movedItems }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/route.test.ts index 7ce4f9f1a4c..5add87b4660 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.test.ts @@ -5,59 +5,50 @@ import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockGetUserEntityPermissions, - mockGetWorkspaceShares, - mockListWorkspaceFiles, - mockPerformCreateWorkspaceFile, -} = vi.hoisted(() => ({ - mockGetUserEntityPermissions: vi.fn(), - mockGetWorkspaceShares: vi.fn(), - mockListWorkspaceFiles: vi.fn(), - mockPerformCreateWorkspaceFile: vi.fn(), +const mocks = vi.hoisted(() => ({ + admitCreate: vi.fn(), + createFile: vi.fn(), + listFiles: vi.fn(), + captureServerEvent: vi.fn(), })) -vi.mock('@/lib/public-shares/share-manager', () => ({ - getWorkspaceShares: mockGetWorkspaceShares, +vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({ + admitCreateWorkspaceFile: mocks.admitCreate, + createWorkspaceFile: { + operation: { id: 'files.create', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.createFile, + }, })) -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - listWorkspaceFiles: mockListWorkspaceFiles, +vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ + listAllWorkspaceFiles: { + operation: { id: 'files.list', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.listFiles, + }, })) -vi.mock('@/lib/workspace-files/orchestration', () => ({ - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, - performCreateWorkspaceFile: mockPerformCreateWorkspaceFile, -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, -})) -vi.mock('@/app/api/workflows/utils', () => ({ - verifyWorkspaceMembership: vi.fn().mockResolvedValue('write'), -})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) -import { POST } from '@/app/api/workspaces/[id]/files/route' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET, POST } from '@/app/api/workspaces/[id]/files/route' -const WORKSPACE_ID = '7727ef3f-8cf6-4686-b063-2bb006a10785' +const WORKSPACE_ID = 'workspace-1' const USER = { id: 'user-1', name: 'Test User', email: 'test@sim.ai' } -const CREATED_FILE = { - id: 'wf_created', +const PRINCIPAL = { kind: 'session' as const, userId: USER.id, sessionId: 'session-1' } +const FILE = { + id: 'wf_1', workspaceId: WORKSPACE_ID, - name: 'untitled.md', - key: `workspace/${WORKSPACE_ID}/untitled.md`, - path: '/api/files/serve/untitled.md?context=workspace', + name: 'notes.md', + key: `workspace/${WORKSPACE_ID}/notes.md`, + path: '/api/files/serve/notes.md?context=workspace', size: 0, type: 'text/markdown', uploadedBy: USER.id, folderId: null, - folderPath: null, - deletedAt: null, uploadedAt: new Date('2026-08-04T00:00:00.000Z'), updatedAt: new Date('2026-08-04T00:00:00.000Z'), } - -const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) } +const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } function createRequest(body: unknown): NextRequest { return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files`, { @@ -67,187 +58,102 @@ function createRequest(body: unknown): NextRequest { }) } -describe('POST /api/workspaces/[id]/files', () => { +describe('/api/workspaces/[id]/files', () => { beforeEach(() => { vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: USER }) - mockGetUserEntityPermissions.mockResolvedValue('write') - mockGetWorkspaceShares.mockResolvedValue(new Map()) - mockListWorkspaceFiles.mockResolvedValue([]) - mockPerformCreateWorkspaceFile.mockResolvedValue({ success: true, file: CREATED_FILE }) + authMockFns.mockGetSession.mockResolvedValue({ user: USER, session: { id: 'session-1' } }) + mocks.admitCreate.mockResolvedValue(undefined) + mocks.createFile.mockResolvedValue({ file: FILE }) + mocks.listFiles.mockResolvedValue({ files: [FILE] }) + }) + + it('lists files through the shared read operation', async () => { + const request = new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files?scope=archived` + ) + const response = await GET(request, context) + + expect(response.status).toBe(200) + expect((await response.json()).files).toHaveLength(1) + expect(mocks.listFiles).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, scope: 'archived' }, + request, + }) }) - it('authenticates before parsing an invalid request body', async () => { + it('authenticates before create admission or body parsing', async () => { authMockFns.mockGetSession.mockResolvedValue(null) - const response = await POST(createRequest('{not-json'), routeContext) + const response = await POST(createRequest('{not-json'), context) expect(response.status).toBe(401) - await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }) - expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.admitCreate).not.toHaveBeenCalled() + expect(mocks.createFile).not.toHaveBeenCalled() }) - it('authorizes the workspace before parsing the request body', async () => { - mockGetUserEntityPermissions.mockResolvedValue('read') + it('authorizes the asserted workspace before buffering the create body', async () => { + mocks.admitCreate.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) - const response = await POST(createRequest({ content: 'missing a name' }), routeContext) + const response = await POST(createRequest('{not-json'), context) expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) - expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.admitCreate).toHaveBeenCalledWith(PRINCIPAL, WORKSPACE_ID) + expect(mocks.createFile).not.toHaveBeenCalled() }) - it('rejects an invalid body after workspace authorization', async () => { - const response = await POST(createRequest({ content: 'missing a name' }), routeContext) - const body = await response.json() + it('rejects malformed base64 after admission', async () => { + const response = await POST( + createRequest({ name: 'notes.md', content: 'not-base64!', encoding: 'base64' }), + context + ) expect(response.status).toBe(400) - expect(body.error).toBe('Validation error') - expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.admitCreate).toHaveBeenCalled() + expect(mocks.createFile).not.toHaveBeenCalled() }) - it.each(['read', null])( - 'requires write or admin permission (%s is rejected)', - async (permission) => { - mockGetUserEntityPermissions.mockResolvedValue(permission) - - const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) - - expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() - } - ) - - it.each(['write', 'admin'])( - 'creates an empty file with defaults for %s users', - async (permission) => { - mockGetUserEntityPermissions.mockResolvedValue(permission) - const request = createRequest({ name: 'untitled.md' }) - - const response = await POST(request, routeContext) - const body = await response.json() - - expect(response.status).toBe(201) - expect(body).toMatchObject({ success: true, file: { id: CREATED_FILE.id } }) - expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledTimes(1) - const params = mockPerformCreateWorkspaceFile.mock.calls[0][0] - expect(params).toMatchObject({ + it('creates through the shared use case and preserves internal analytics', async () => { + const request = createRequest({ name: 'notes.md', content: 'TQ==', encoding: 'base64' }) + const response = await POST(request, context) + + expect(response.status).toBe(201) + expect((await response.json()).file.id).toBe(FILE.id) + expect(mocks.createFile).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, - userId: USER.id, - actorName: USER.name, - actorEmail: USER.email, - name: 'untitled.md', + name: 'notes.md', contentType: 'text/markdown', + content: 'TQ==', + encoding: 'base64', + folderId: undefined, exactName: false, - }) - expect(params.folderId).toBeUndefined() - expect(params.content).toEqual(Buffer.alloc(0)) - expect(params.request).toBe(request) - } - ) - - it('decodes initialized base64 content and preserves folder and content type', async () => { - const content = Buffer.from([0, 1, 2, 255]) - const request = createRequest({ - name: 'data.bin', - contentType: 'application/octet-stream', - folderId: 'folder-1', - content: content.toString('base64'), - encoding: 'base64', - }) - mockPerformCreateWorkspaceFile.mockResolvedValue({ - success: true, - file: { - ...CREATED_FILE, - name: 'data.bin', - type: 'application/octet-stream', - size: content.length, - folderId: 'folder-1', }, + request, }) - - const response = await POST(request, routeContext) - - expect(response.status).toBe(201) - expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: WORKSPACE_ID, - name: 'data.bin', - contentType: 'application/octet-stream', - folderId: 'folder-1', - content, - exactName: false, - }) - ) - }) - - it('rejects malformed base64 after authorization and before orchestration', async () => { - const response = await POST( - createRequest({ name: 'data.bin', content: 'not-base64!', encoding: 'base64' }), - routeContext - ) - - expect(response.status).toBe(400) - await expect(response.json()).resolves.toMatchObject({ error: 'Validation error' }) - expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() - }) - - it('accepts empty base64 as a zero-byte file', async () => { - const response = await POST( - createRequest({ name: 'empty.bin', content: '', encoding: 'base64' }), - routeContext - ) - - expect(response.status).toBe(201) - expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( - expect.objectContaining({ content: Buffer.alloc(0) }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + USER.id, + 'file_uploaded', + { workspace_id: WORKSPACE_ID, file_type: 'text/markdown' }, + { groups: { workspace: WORKSPACE_ID } } ) }) - it.each([ - ['validation', 400, 'Invalid file name'], - ['not_found', 404, 'Target folder not found'], - ['conflict', 409, 'A file with this name already exists'], - ['payload_too_large', 413, 'File size exceeds 50MB limit'], - ] as const)('maps a %s orchestration failure to %i', async (errorCode, expectedStatus, error) => { - mockPerformCreateWorkspaceFile.mockResolvedValue({ success: false, error, errorCode }) - - const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) - - expect(response.status).toBe(expectedStatus) - await expect(response.json()).resolves.toEqual({ success: false, error }) - }) - - it('does not expose an internal orchestration error', async () => { - mockPerformCreateWorkspaceFile.mockResolvedValue({ - success: false, - error: 'update workspace_files set ... failed', - errorCode: 'internal', - }) - - const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) - - expect(response.status).toBe(500) - await expect(response.json()).resolves.toEqual({ - success: false, - error: 'Failed to create file', - }) - }) - - it('maps an unexpected throw to a 500 response', async () => { - mockPerformCreateWorkspaceFile.mockRejectedValue(new Error('storage unavailable')) - - const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) + it('renders typed create conflicts without exposing unknown errors', async () => { + mocks.createFile.mockRejectedValueOnce(new OrchestrationError('conflict', 'Name exists')) + const conflict = await POST(createRequest({ name: 'notes.md' }), context) + expect(conflict.status).toBe(409) + expect(await conflict.json()).toEqual({ success: false, error: 'Name exists' }) - expect(response.status).toBe(500) - await expect(response.json()).resolves.toEqual({ + mocks.createFile.mockRejectedValueOnce(new Error('database details')) + const unexpected = await POST(createRequest({ name: 'notes.md' }), context) + expect(unexpected.status).toBe(500) + expect(await unexpected.json()).toEqual({ success: false, - error: 'Failed to create file', + error: 'Internal server error', }) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index 9a370fb4f94..2d29ddfad10 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -1,177 +1,61 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' import { createWorkspaceFileContract, - listWorkspaceFilesQuerySchema, - workspaceFilesParamsSchema, + listWorkspaceFilesContract, } from '@/lib/api/contracts/workspace-files' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' import { - messageForOrchestrationError, - statusForOrchestrationError, -} from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getWorkspaceShares } from '@/lib/public-shares/share-manager' -import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - performCreateWorkspaceFile, -} from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' + internalFileAnalytics, + internalFileErrorPolicies, + internalFilePresenters, +} from '@/lib/workspace-files/api' +import { + admitCreateWorkspaceFile, + createWorkspaceFile, +} from '@/lib/workspace-files/application/create-workspace-file' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceFilesAPI') - -/** - * GET /api/workspaces/[id]/files - * List all files for a workspace (requires read permission) - */ -export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const paramsResult = workspaceFilesParamsSchema.safeParse(await params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId } = paramsResult.data - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check workspace permissions (requires read) - const userPermission = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!userPermission) { - logger.warn( - `[${requestId}] User ${session.user.id} lacks permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const queryResult = listWorkspaceFilesQuerySchema.safeParse( - Object.fromEntries(request.nextUrl.searchParams.entries()) - ) - if (!queryResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(queryResult.error, 'Invalid scope') }, - { status: 400 } - ) - } - const { scope } = queryResult.data - - const files = await listWorkspaceFiles(workspaceId, { scope }) - - const shares = await getWorkspaceShares('file', workspaceId) - const filesWithShares = files.map((file) => ({ - ...file, - share: shares.get(file.id) ?? null, - })) - - logger.info(`[${requestId}] Listed ${files.length} files for workspace ${workspaceId}`) - - return NextResponse.json({ - success: true, - files: filesWithShares, - }) - } catch (error) { - logger.error(`[${requestId}] Error listing workspace files:`, error) - return NextResponse.json( - { - success: false, - error: getErrorMessage(error, 'Failed to list files'), - }, - { status: 500 } - ) - } - } -) - -/** - * POST /api/workspaces/[id]/files - * Create an authored workspace file (requires write permission) - */ -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const paramsResult = workspaceFilesParamsSchema.safeParse(await context.params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId } = paramsResult.data - - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const parsed = await parseRequest(createWorkspaceFileContract, request, context, { - maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - }) - if (!parsed.success) return parsed.response - const { name, contentType, folderId, content, encoding } = parsed.data.body - - const result = await performCreateWorkspaceFile({ - workspaceId, - userId: session.user.id, - name, - contentType: contentType ?? getMimeTypeFromExtension(getFileExtension(name)), - folderId, - content: Buffer.from(content, encoding), - exactName: false, - actorName: session.user.name, - actorEmail: session.user.email, - request, - }) - if (!result.success || !result.file) { - return NextResponse.json( - { - success: false, - error: messageForOrchestrationError(result, 'Failed to create file'), - }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - - logger.info(`[${requestId}] Created workspace file: ${result.file.name}`) - return NextResponse.json({ success: true, file: result.file }, { status: 201 }) - } catch (error) { - logger.error(`[${requestId}] Error creating workspace file:`, error) - - return NextResponse.json( - { - success: false, - error: 'Failed to create file', - }, - { status: 500 } - ) - } - } -) +/** GET /api/workspaces/[id]/files — List workspace files. */ +export const GET = defineInternalJsonRoute({ + contract: listWorkspaceFilesContract, + auth: internalSessionAuth, + operation: fileOperations.list, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal file-list behavior' }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, query }) => ({ workspaceId: params.id, scope: query.scope }), + useCase: listAllWorkspaceFiles, + present: internalFilePresenters.successFiles, +}) + +/** POST /api/workspaces/[id]/files — Create an authored workspace file. */ +export const POST = defineInternalJsonRoute({ + contract: createWorkspaceFileContract, + auth: internalSessionAuth, + operation: fileOperations.create, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal file-create behavior' }), + errorPolicy: internalFileErrorPolicies.default, + parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, + beforeParse: async ({ principal, params }) => { + if (typeof params.id === 'string') await admitCreateWorkspaceFile(principal, params.id) + }, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + name: body.name, + contentType: body.contentType ?? getMimeTypeFromExtension(getFileExtension(body.name)), + content: body.content, + encoding: body.encoding, + folderId: body.folderId, + exactName: false, + }), + useCase: createWorkspaceFile, + onSuccess: internalFileAnalytics.uploaded, + present: internalFilePresenters.successFile, +}) diff --git a/apps/sim/lib/api/contracts/primitives.test.ts b/apps/sim/lib/api/contracts/primitives.test.ts index 0a5534d50e9..4e8a605a98f 100644 --- a/apps/sim/lib/api/contracts/primitives.test.ts +++ b/apps/sim/lib/api/contracts/primitives.test.ts @@ -12,9 +12,28 @@ import { resolvedSecretTraceProvenanceSchema, workflowIdSchema, workspaceFileIdSchema, + workspaceFileNameSchema, workspaceIdSchema, } from '@/lib/api/contracts/primitives' +describe('workspaceFileNameSchema', () => { + it('trims and accepts one bounded file name', () => { + expect(workspaceFileNameSchema.parse(' report.pdf ')).toBe('report.pdf') + expect(workspaceFileNameSchema.safeParse('a'.repeat(255)).success).toBe(true) + }) + + it.each([undefined, '', ' ', '.', '..', 'folder/report.pdf', 'folder\\report.pdf'])( + 'rejects invalid file name %j', + (name) => { + expect(workspaceFileNameSchema.safeParse(name).success).toBe(false) + } + ) + + it('rejects names longer than 255 characters', () => { + expect(workspaceFileNameSchema.safeParse('a'.repeat(256)).success).toBe(false) + }) +}) + describe('isCanonicalBase64', () => { it.each(['', 'TQ==', 'TWE=', 'TWFu', 'AAEC/w=='])('accepts canonical base64 %j', (value) => { expect(isCanonicalBase64(value)).toBe(true) diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index 97c7e814109..fa96521db31 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -199,6 +199,20 @@ export function requiredFieldSchema(message: string) { /** Non-empty `workspaceId` field with a stable, human-readable message. */ export const workspaceIdSchema = requiredFieldSchema('Workspace ID is required') +/** + * A single workspace-file name, not a path. Folder placement is carried by a + * separate folder id or path field, so separators and dot segments are invalid. + */ +export const workspaceFileNameSchema = z + .string({ error: 'Name is required' }) + .trim() + .min(1, 'Name is required') + .max(255, 'Name is too long') + .refine( + (name) => name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\'), + 'Name cannot contain path separators or dot segments' + ) + /** Non-empty `organizationId` field with a stable, human-readable message. */ export const organizationIdSchema = requiredFieldSchema('Organization ID is required') diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 7305fc9690e..607f582d695 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { isCanonicalBase64, workspaceFileIdSchema, + workspaceFileNameSchema, workspaceIdSchema, } from '@/lib/api/contracts/primitives' import { shareAuthTypeSchema, shareRecordSchema } from '@/lib/api/contracts/public-shares' @@ -11,6 +12,7 @@ import { v2CursorListResponse, v2DataResponse, v2DeleteFolderQuerySchema, + v2ErrorResponseSchema, v2FolderPathInputSchema, v2FolderPathSchema, v2FolderSchema, @@ -65,7 +67,7 @@ export type V2FileUploadParams = z.output export const v2CreateFileUploadBodySchema = z .object({ workspaceId: workspaceIdSchema, - name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), + name: workspaceFileNameSchema, contentType: z.string().trim().min(1, 'contentType is required').max(255), size: z.number().int().nonnegative().max(MAX_WORKSPACE_FILE_SIZE), folderPath: v2FolderPathInputSchema.optional(), @@ -110,25 +112,10 @@ export const v2FileParamsSchema = z.object({ export type V2FileParams = z.output -/** - * A file-folder name becomes a path segment, so path separators and dot - * segments are rejected rather than normalized. Mirrors - * `normalizeWorkspaceFileItemName`, which enforces the same rule in the manager. - */ -const v2FileItemNameSchema = z - .string() - .trim() - .min(1, 'name is required') - .max(255, 'name is too long') - .refine( - (name) => name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\'), - 'name cannot contain path separators or dot segments' - ) - export const v2CreateFileBodySchema = z .object({ workspaceId: workspaceIdSchema, - name: v2FileItemNameSchema, + name: workspaceFileNameSchema, contentType: z .string() .trim() @@ -194,7 +181,7 @@ export type V2FileWorkspaceQuery = z.output export const v2RenameFileBodySchema = z .object({ workspaceId: workspaceIdSchema, - name: v2FileItemNameSchema, + name: workspaceFileNameSchema, }) .strict() @@ -360,6 +347,7 @@ export const v2CreateFileContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2FileSchema), + status: 201, }, }) @@ -367,7 +355,7 @@ export const v2CreateFileUploadContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/uploads', body: v2CreateFileUploadBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2CreateFileUploadDataSchema) }, + response: { mode: 'json', schema: v2DataResponse(v2CreateFileUploadDataSchema), status: 201 }, }) export const v2AbortFileUploadContract = defineRouteContract({ @@ -428,6 +416,7 @@ export const v2RenameFileContract = defineRouteContract({ mode: 'json', schema: v2DataResponse(v2FileSchema), }, + error: v2ErrorResponseSchema, }) export const v2DeleteFileContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index fbd33c2f396..653ef97c0bc 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -67,6 +67,8 @@ export const v2ErrorResponseSchema = z.object({ }), }) +export type V2ErrorResponse = z.output + /** `{ data: T }` */ export const v2DataResponse = (dataSchema: T) => z.object({ data: dataSchema }) diff --git a/apps/sim/lib/api/contracts/workspace-file-folders.ts b/apps/sim/lib/api/contracts/workspace-file-folders.ts index 11fad5227ab..66620f36363 100644 --- a/apps/sim/lib/api/contracts/workspace-file-folders.ts +++ b/apps/sim/lib/api/contracts/workspace-file-folders.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' +import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits' export const workspaceFileFolderScopeSchema = z.enum(['active', 'archived', 'all']) @@ -56,8 +57,14 @@ export const updateWorkspaceFileFolderBodySchema = z.object({ export const moveWorkspaceFileItemsBodySchema = z .object({ - fileIds: z.array(z.string()).default([]), - folderIds: z.array(z.string()).default([]), + fileIds: z + .array(z.string()) + .max(MAX_WORKSPACE_FILE_BULK_REQUEST_IDS, 'Too many file IDs') + .default([]), + folderIds: z + .array(z.string()) + .max(MAX_WORKSPACE_FILE_BULK_REQUEST_IDS, 'Too many folder IDs') + .default([]), targetFolderId: z.string().nullable().optional(), }) .refine((body) => body.fileIds.length > 0 || body.folderIds.length > 0, { @@ -66,8 +73,14 @@ export const moveWorkspaceFileItemsBodySchema = z export const bulkArchiveWorkspaceFileItemsBodySchema = z .object({ - fileIds: z.array(z.string()).default([]), - folderIds: z.array(z.string()).default([]), + fileIds: z + .array(z.string()) + .max(MAX_WORKSPACE_FILE_BULK_REQUEST_IDS, 'Too many file IDs') + .default([]), + folderIds: z + .array(z.string()) + .max(MAX_WORKSPACE_FILE_BULK_REQUEST_IDS, 'Too many folder IDs') + .default([]), }) .refine((body) => body.fileIds.length > 0 || body.folderIds.length > 0, { message: 'At least one file or folder must be selected', diff --git a/apps/sim/lib/api/contracts/workspace-files.ts b/apps/sim/lib/api/contracts/workspace-files.ts index 625fc161eb4..3540f4b4db0 100644 --- a/apps/sim/lib/api/contracts/workspace-files.ts +++ b/apps/sim/lib/api/contracts/workspace-files.ts @@ -3,6 +3,7 @@ import { folderIdSchema, inlineFileRefQuerySchema, isCanonicalBase64, + workspaceFileNameSchema, workspaceIdSchema, } from '@/lib/api/contracts/primitives' import { shareRecordSchema } from '@/lib/api/contracts/public-shares' @@ -43,20 +44,16 @@ export const getInlineWorkspaceFileContract = defineRouteContract({ }, }) -export const workspaceFileNameSchema = z - .string({ error: 'Name is required' }) - .trim() - .min(1, 'Name is required') - .max(255, 'Name is too long') - .refine( - (name) => name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\'), - 'Name cannot contain path separators or dot segments' - ) - export const renameWorkspaceFileBodySchema = z.object({ name: workspaceFileNameSchema, }) +export const renameWorkspaceFileErrorSchema = z.union([ + z.object({ error: z.string() }), + z.object({ error: z.string(), details: z.array(z.unknown()) }), + z.object({ success: z.literal(false), error: z.string() }), +]) + export const updateWorkspaceFileContentBodySchema = z .object({ content: z.string().max(70_000_000, 'Content is too large'), @@ -169,6 +166,7 @@ export const createWorkspaceFileContract = defineRouteContract({ schema: workspaceFileSuccessSchema.extend({ file: workspaceFileRecordSchema, }), + status: 201, }, }) @@ -183,6 +181,7 @@ export const renameWorkspaceFileContract = defineRouteContract({ file: workspaceFileRecordSchema, }), }, + error: renameWorkspaceFileErrorSchema, }) export const updateWorkspaceFileDimensionsContract = defineRouteContract({ @@ -231,6 +230,22 @@ export const updateWorkspaceFileContentContract = defineRouteContract({ }, }) +export const downloadWorkspaceFileUrlContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/files/[fileId]/download', + params: workspaceFileParamsSchema, + response: { + mode: 'json', + schema: z.object({ + success: z.literal(true), + downloadUrl: z.string().min(1), + viewerUrl: z.string().min(1), + fileName: z.string().min(1), + expiresIn: z.null(), + }), + }, +}) + const documentStyleSummarySchema = z .object({ format: z.enum(['docx', 'pptx', 'pdf']), diff --git a/apps/sim/lib/api/server/routes/definition.test.ts b/apps/sim/lib/api/server/routes/definition.test.ts new file mode 100644 index 00000000000..20653b5d36c --- /dev/null +++ b/apps/sim/lib/api/server/routes/definition.test.ts @@ -0,0 +1,112 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { + requireBinaryRouteDefinition, + requireJsonRouteDefinition, +} from '@/lib/api/server/routes/definition' + +const renameOperation = { + id: 'files.rename', + minimumRole: 'write', + workspaceApiKey: 'allow', +} as const + +describe('declarative route definition invariants', () => { + it('accepts one successful JSON response status', () => { + const contract = defineRouteContract({ + method: 'PATCH', + path: '/files/[fileId]', + response: { mode: 'json', schema: z.object({ ok: z.literal(true) }), status: 202 }, + }) + + expect(requireJsonRouteDefinition(contract, renameOperation, renameOperation)).toEqual({ + successStatus: 202, + }) + }) + + it('fails immediately when route and use-case operations differ', () => { + expect(() => + requireJsonRouteDefinition( + defineRouteContract({ + method: 'PATCH', + path: '/files/[fileId]', + response: { mode: 'json', schema: z.object({ ok: z.literal(true) }) }, + }), + renameOperation, + { ...renameOperation, id: 'files.delete' } + ) + ).toThrow('does not match') + }) + + it('fails immediately for binary mode or ambiguous success statuses', () => { + expect(() => + requireJsonRouteDefinition( + defineRouteContract({ + method: 'GET', + path: '/files/[fileId]', + response: { mode: 'binary' }, + }), + renameOperation, + renameOperation + ) + ).toThrow('requires a JSON response contract') + + expect(() => + requireJsonRouteDefinition( + defineRouteContract({ + method: 'PATCH', + path: '/files/[fileId]', + response: { + mode: 'json', + schema: z.object({ ok: z.literal(true) }), + status: [200, 202], + }, + }), + renameOperation, + renameOperation + ) + ).toThrow('must declare one success status') + }) + + it('accepts binary contracts and rejects JSON contracts at the binary boundary', () => { + const binary = defineRouteContract({ + method: 'GET', + path: '/files/[fileId]', + response: { mode: 'binary' }, + }) + expect(requireBinaryRouteDefinition(binary, renameOperation, renameOperation)).toEqual({ + successStatus: 200, + }) + + expect(() => + requireBinaryRouteDefinition( + defineRouteContract({ + method: 'GET', + path: '/files/[fileId]', + response: { mode: 'json', schema: z.object({ ok: z.literal(true) }) }, + }), + renameOperation, + renameOperation + ) + ).toThrow('requires a binary response contract') + }) + + it('rejects operation mismatches at the binary boundary', () => { + expect(() => + requireBinaryRouteDefinition( + defineRouteContract({ + method: 'GET', + path: '/files/[fileId]', + response: { mode: 'binary' }, + }), + renameOperation, + { ...renameOperation, id: 'files.download' } + ) + ).toThrow('does not match') + }) +}) diff --git a/apps/sim/lib/api/server/routes/definition.ts b/apps/sim/lib/api/server/routes/definition.ts new file mode 100644 index 00000000000..580653ad9e3 --- /dev/null +++ b/apps/sim/lib/api/server/routes/definition.ts @@ -0,0 +1,55 @@ +import type { AnyApiRouteContract } from '@/lib/api/contracts' +import type { ApplicationOperation } from '@/lib/core/application' + +export interface JsonRouteDefinitionMetadata { + successStatus: number +} + +export function requireJsonRouteDefinition( + contract: AnyApiRouteContract, + declaredOperation: ApplicationOperation, + useCaseOperation: ApplicationOperation +): JsonRouteDefinitionMetadata { + if (contract.response.mode !== 'json') { + throw new Error(`${contract.method} ${contract.path} requires a JSON response contract`) + } + if (declaredOperation.id !== useCaseOperation.id) { + throw new Error( + `Route operation ${declaredOperation.id} does not match use case ${useCaseOperation.id}` + ) + } + + const configuredStatus = contract.response.status + if (configuredStatus !== undefined && typeof configuredStatus !== 'number') { + throw new Error(`${contract.method} ${contract.path} must declare one success status`) + } + const successStatus = configuredStatus ?? 200 + if (successStatus < 200 || successStatus >= 300) { + throw new Error(`${contract.method} ${contract.path} has a non-success response status`) + } + return { successStatus } +} + +export function requireBinaryRouteDefinition( + contract: AnyApiRouteContract, + declaredOperation: ApplicationOperation, + useCaseOperation: ApplicationOperation +): JsonRouteDefinitionMetadata { + if (contract.response.mode !== 'binary') { + throw new Error(`${contract.method} ${contract.path} requires a binary response contract`) + } + if (declaredOperation.id !== useCaseOperation.id) { + throw new Error( + `Route operation ${declaredOperation.id} does not match use case ${useCaseOperation.id}` + ) + } + const configuredStatus = contract.response.status + if (configuredStatus !== undefined && typeof configuredStatus !== 'number') { + throw new Error(`${contract.method} ${contract.path} must declare one success status`) + } + const successStatus = configuredStatus ?? 200 + if (successStatus < 200 || successStatus >= 300) { + throw new Error(`${contract.method} ${contract.path} has a non-success response status`) + } + return { successStatus } +} diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts new file mode 100644 index 00000000000..f9fdcfd39c1 --- /dev/null +++ b/apps/sim/lib/api/server/routes/index.ts @@ -0,0 +1,23 @@ +export { defineInternalBinaryRoute } from '@/lib/api/server/routes/internal-binary-route' +export { + createInternalSessionOrServiceAuth, + defineInternalJsonRoute, + extendInternalErrorPolicy, + type InternalAuthPolicy, + type InternalErrorPolicy, + InternalUnauthenticatedError, + internalErrorResponse, + internalJsonPresenters, + internalOrchestrationErrorPolicy, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes/internal-json-route' +export { defineV2BinaryRoute } from '@/lib/api/server/routes/v2-binary-route' +export { + defineV2JsonRoute, + type V2ErrorPolicy, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes/v2-json-route' diff --git a/apps/sim/lib/api/server/routes/internal-binary-route.ts b/apps/sim/lib/api/server/routes/internal-binary-route.ts new file mode 100644 index 00000000000..5e40e070dc2 --- /dev/null +++ b/apps/sim/lib/api/server/routes/internal-binary-route.ts @@ -0,0 +1,127 @@ +import type { Principal, SessionPrincipal } from '@sim/auth/principal' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { requireBinaryRouteDefinition } from '@/lib/api/server/routes/definition' +import { + type InternalErrorPolicy, + InternalUnauthenticatedError, + type internalSessionAuth, +} from '@/lib/api/server/routes/internal-json-route' +import type { + BinaryApiRouteContract, + BinaryResponseDescriptor, + JsonErrorResponseDescriptor, + JsonNextRouteHandler, + JsonRouteContext, +} from '@/lib/api/server/routes/types' +import type { ParsedRequest } from '@/lib/api/server/validation' +import { parseRequest } from '@/lib/api/server/validation' +import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +interface InternalBinaryRateLimitPolicy { + readonly kind: 'none' + readonly reason: string + enforce(request: NextRequest, principal: Principal): Promise +} + +interface InternalBinaryRouteDefinition< + C extends BinaryApiRouteContract, + O extends ApplicationOperation, + I, + R, +> { + contract: C + operation: O + mapInput(input: ParsedRequest): I + useCase: OperationUseCase, I, R> + present(result: R): BinaryResponseDescriptor | Promise +} + +interface InternalBinaryRouteOptions< + C extends BinaryApiRouteContract, + O extends ApplicationOperation, + I, + R, +> extends InternalBinaryRouteDefinition { + auth: typeof internalSessionAuth + rateLimit: InternalBinaryRateLimitPolicy + errorPolicy: InternalErrorPolicy + onSuccess?(args: { principal: SessionPrincipal; input: I; result: R }): void | Promise +} + +/** + * Defines an authenticated internal binary route, including streamed responses. + * + * The descriptor keeps storage and archive details out of the route handler while + * allowing a use-case presenter to return a Web Stream without buffering it. + */ +export function defineInternalBinaryRoute< + C extends BinaryApiRouteContract, + O extends ApplicationOperation, + I, + R, +>(options: InternalBinaryRouteOptions): JsonNextRouteHandler { + const { successStatus } = requireBinaryRouteDefinition( + options.contract, + options.operation, + options.useCase.operation + ) + + const wrapped = withRouteHandler( + async (request, context) => { + if (request.method !== options.contract.method) { + throw new Error( + `Route received ${request.method} for ${options.contract.method} contract ${options.contract.path}` + ) + } + + let principal: SessionPrincipal + try { + principal = await options.auth.authenticate() + } catch (error) { + if (error instanceof InternalUnauthenticatedError) { + return NextResponse.json({ error: error.message }, { status: 401 }) + } + throw error + } + + await options.rateLimit.enforce(request, principal) + const parsed = await parseRequest(options.contract, request, context ?? {}) + if (!parsed.success) return parsed.response + + try { + const input = options.mapInput(parsed.data) + const result = await options.useCase.execute({ principal, input, request }) + const descriptor = await options.present(result) + await options.onSuccess?.({ principal, input, result }) + const headers = new Headers(descriptor.headers) + headers.set('Content-Type', descriptor.contentType) + if (descriptor.contentDisposition) { + headers.set('Content-Disposition', descriptor.contentDisposition) + } + if (descriptor.contentLength !== undefined) { + headers.set('Content-Length', String(descriptor.contentLength)) + } + return new NextResponse(descriptor.body, { status: successStatus, headers }) + } catch (error) { + const response = options.errorPolicy.project(error) + if (response) return createJsonErrorResponse(response) + throw error + } + }, + { + unhandledErrorResponse: () => + NextResponse.json({ error: 'Internal server error' }, { status: 500 }), + } + ) + + return async (request, context) => wrapped(request, context) +} + +function createJsonErrorResponse(descriptor: JsonErrorResponseDescriptor): NextResponse { + return NextResponse.json(descriptor.body, { + status: descriptor.status, + headers: descriptor.headers, + }) +} diff --git a/apps/sim/lib/api/server/routes/internal-json-route.test.ts b/apps/sim/lib/api/server/routes/internal-json-route.test.ts new file mode 100644 index 00000000000..c295caec6d4 --- /dev/null +++ b/apps/sim/lib/api/server/routes/internal-json-route.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { + defineInternalJsonRoute, + internalErrorResponse, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes/internal-json-route' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const operation = { id: 'test.read' } as const +const auth = { + authenticate: vi.fn(async () => ({ + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', + })), +} + +const contract = defineRouteContract({ + method: 'GET', + path: '/api/test/internal-json-route', + response: { + mode: 'json', + schema: z.object({ value: z.string() }), + }, +}) + +describe('defineInternalJsonRoute', () => { + it('uses the use-case result directly when it already matches the contract', async () => { + const handler = defineInternalJsonRoute({ + contract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 'ok' } + }, + }, + }) + + const response = await handler(new NextRequest('http://localhost/api/test/internal-json-route')) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ value: 'ok' }) + expect(response.headers.get('x-request-id')).toBeTruthy() + }) + + it('renders typed error descriptors through the shared builder', async () => { + const handler = defineInternalJsonRoute({ + contract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute(): Promise<{ value: string }> { + throw new OrchestrationError('conflict', 'Already exists') + }, + }, + }) + + const response = await handler(new NextRequest('http://localhost/api/test/internal-json-route')) + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toEqual({ error: 'Already exists' }) + }) + + it('rejects invalid error statuses immediately', () => { + expect(() => internalErrorResponse(200, { error: 'Invalid' })).toThrow( + 'Internal error responses require a 4xx or 5xx status' + ) + }) +}) diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts new file mode 100644 index 00000000000..8191e353794 --- /dev/null +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -0,0 +1,277 @@ +import type { DelegatedPrincipal, Principal, SessionPrincipal } from '@sim/auth/principal' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import type { ContractJsonResponse } from '@/lib/api/contracts' +import { requireJsonRouteDefinition } from '@/lib/api/server/routes/definition' +import type { + JsonApiRouteContract, + JsonErrorResponseDescriptor, + JsonNextRouteHandler, + JsonRouteContext, +} from '@/lib/api/server/routes/types' +import { + type ParsedRequest, + type ParseRequestOptions, + parseRequest, +} from '@/lib/api/server/validation' +import { getSession } from '@/lib/auth' +import { verifyInternalToken } from '@/lib/auth/internal' +import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export class InternalUnauthenticatedError extends Error { + constructor(message = 'Unauthorized') { + super(message) + this.name = 'InternalUnauthenticatedError' + } +} + +export const internalSessionAuth = { + async authenticate(): Promise { + const session = await getSession() + if (!session?.user?.id) throw new InternalUnauthenticatedError() + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') + return { kind: 'session', userId: session.user.id, sessionId } + }, +} as const + +export function createInternalSessionOrServiceAuth

( + bindDelegation: (args: { + subjectUserId: string + params: Record + }) => P +): InternalAuthPolicy { + return { + async authenticate(request, params) { + if (request.headers.has('x-api-key')) { + throw new InternalUnauthenticatedError('Authentication required') + } + + const authorization = request.headers.get('authorization') + if (!authorization?.startsWith('Bearer ')) return internalSessionAuth.authenticate() + + const verification = await verifyInternalToken(authorization.slice('Bearer '.length)) + if (!verification.valid || !verification.userId) { + throw new InternalUnauthenticatedError('Authentication required') + } + return bindDelegation({ subjectUserId: verification.userId, params }) + }, + } +} + +interface InternalRateLimitPolicy { + readonly kind: 'none' + readonly reason: string + enforce(request: NextRequest, principal: Principal): Promise +} + +export const internalRateLimits = { + none({ reason }: { reason: string }): InternalRateLimitPolicy { + if (!reason.trim()) throw new Error('A rate-limit exemption reason is required') + return { + kind: 'none', + reason, + async enforce() {}, + } + }, +} as const + +export interface InternalErrorPolicy { + project(error: unknown): JsonErrorResponseDescriptor | null + unhandled?(): JsonErrorResponseDescriptor +} + +export const internalOrchestrationErrorPolicy: InternalErrorPolicy = { + project(error) { + const classified = asOrchestrationError(error) + if (!classified) return null + return internalErrorResponse(statusForOrchestrationError(classified.code), { + success: false, + error: classified.message, + }) + }, +} + +export const internalPlainOrchestrationErrorPolicy: InternalErrorPolicy = { + project(error) { + const classified = asOrchestrationError(error) + if (!classified) return null + return internalErrorResponse(statusForOrchestrationError(classified.code), { + error: classified.message, + }) + }, + unhandled() { + return internalErrorResponse(500, { error: 'Internal server error' }) + }, +} + +export function internalErrorResponse( + status: number, + body: unknown, + headers?: HeadersInit +): JsonErrorResponseDescriptor { + if (!Number.isInteger(status) || status < 400 || status >= 600) { + throw new Error(`Internal error responses require a 4xx or 5xx status, received ${status}`) + } + return { body, status, headers } +} + +export function extendInternalErrorPolicy( + base: InternalErrorPolicy, + project: (error: unknown) => JsonErrorResponseDescriptor | null +): InternalErrorPolicy { + return { + project(error) { + return project(error) ?? base.project(error) + }, + unhandled: base.unhandled, + } +} + +export const internalJsonPresenters = { + withSuccess(result: R) { + return { ...result, success: true as const } + }, + successFrom(key: K) { + return >(result: R) => ({ success: result[key] }) + }, +} as const + +export interface InternalAuthPolicy

{ + authenticate( + request: NextRequest, + params: Record + ): Promise

+} + +type InternalJsonPresenter = [R] extends [ + ContractJsonResponse, +] + ? { + present?(result: NoInfer): ContractJsonResponse | Promise> + } + : { + present(result: NoInfer): ContractJsonResponse | Promise> + } + +type InternalJsonRouteOptions< + C extends JsonApiRouteContract, + O extends ApplicationOperation, + I, + R, + P extends Principal, +> = { + contract: C + operation: O + mapInput(input: ParsedRequest): I + useCase: OperationUseCase, I, R> + auth: InternalAuthPolicy

+ rateLimit: InternalRateLimitPolicy + errorPolicy: InternalErrorPolicy + parseOptions?: Omit + beforeParse?(args: { + request: NextRequest + principal: P + params: Record + }): void | Promise + onSuccess?(args: { principal: P; input: NoInfer; result: NoInfer }): void | Promise + responseHeaders?(args: { principal: P; input: NoInfer; result: NoInfer }): HeadersInit +} & InternalJsonPresenter + +function createJsonErrorResponse(descriptor: JsonErrorResponseDescriptor): NextResponse { + return NextResponse.json(descriptor.body, { + status: descriptor.status, + headers: descriptor.headers, + }) +} + +export function defineInternalJsonRoute< + C extends JsonApiRouteContract, + O extends ApplicationOperation, + I, + R, + P extends Principal, +>(options: InternalJsonRouteOptions): JsonNextRouteHandler { + const { successStatus } = requireJsonRouteDefinition( + options.contract, + options.operation, + options.useCase.operation + ) + + const wrapped = withRouteHandler( + async (request, context) => { + if (request.method !== options.contract.method) { + throw new Error( + `Route received ${request.method} for ${options.contract.method} contract ${options.contract.path}` + ) + } + + const rawParams = context?.params ? await context.params : {} + let principal: P + try { + principal = await options.auth.authenticate(request, rawParams) + } catch (error) { + if (error instanceof InternalUnauthenticatedError) { + return NextResponse.json({ error: error.message }, { status: 401 }) + } + throw error + } + + await options.rateLimit.enforce(request, principal) + if (options.beforeParse) { + try { + await options.beforeParse({ request, principal, params: rawParams }) + } catch (error) { + const response = options.errorPolicy.project(error) + if (response) return createJsonErrorResponse(response) + throw error + } + } + const parsed = await parseRequest( + options.contract, + request, + context ?? {}, + options.parseOptions + ) + if (!parsed.success) return parsed.response + + try { + const input = options.mapInput(parsed.data) + const result = await options.useCase.execute({ + principal, + input, + request, + }) + await options.onSuccess?.({ principal, input, result }) + const body = options.present ? await options.present(result) : result + const responseSchema = options.contract.response + if (responseSchema.mode !== 'json') { + throw new Error('Internal JSON route response mode changed after initialization') + } + const validatedBody = responseSchema.schema.parse(body) + return NextResponse.json(validatedBody, { + status: successStatus, + headers: options.responseHeaders?.({ principal, input, result }), + }) + } catch (error) { + const response = options.errorPolicy.project(error) + if (response) return createJsonErrorResponse(response) + throw error + } + }, + { + unhandledErrorResponse: () => + createJsonErrorResponse( + options.errorPolicy.unhandled?.() ?? + internalErrorResponse(500, { + success: false, + error: 'Internal server error', + }) + ), + } + ) + + return async (request, context) => wrapped(request, context) +} diff --git a/apps/sim/lib/api/server/routes/types.ts b/apps/sim/lib/api/server/routes/types.ts new file mode 100644 index 00000000000..f2411298f38 --- /dev/null +++ b/apps/sim/lib/api/server/routes/types.ts @@ -0,0 +1,68 @@ +import type { NextRequest } from 'next/server' +import type { + AnyApiRouteContract, + BinaryResponseMode, + ContractJsonResponse, + JsonResponseMode, +} from '@/lib/api/contracts' +import type { ParsedRequest } from '@/lib/api/server/validation' +import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' + +export interface JsonRouteContext { + params?: + | Promise> + | Record +} + +export type JsonApiRouteContract = AnyApiRouteContract & { + response: JsonResponseMode +} + +export type BinaryApiRouteContract = AnyApiRouteContract & { + response: BinaryResponseMode +} + +export interface BinaryResponseDescriptor { + body: BodyInit + contentType: string + contentDisposition?: string + contentLength?: number + headers?: HeadersInit +} + +export interface JsonErrorResponseDescriptor { + body: unknown + status: number + headers?: HeadersInit +} + +export interface JsonRouteDefinition< + C extends JsonApiRouteContract, + O extends ApplicationOperation, + I, + R, +> { + contract: C + operation: O + mapInput(input: ParsedRequest): I + useCase: OperationUseCase, I, R> + present(result: R): ContractJsonResponse | Promise> +} + +export type JsonNextRouteHandler = ( + request: NextRequest, + context?: JsonRouteContext +) => Promise + +export interface BinaryRouteDefinition< + C extends BinaryApiRouteContract, + O extends ApplicationOperation, + I, + R, +> { + contract: C + operation: O + mapInput(input: ParsedRequest): I + useCase: OperationUseCase, I, R> + present(result: R): BinaryResponseDescriptor | Promise +} diff --git a/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts b/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts new file mode 100644 index 00000000000..1ffe79bbf4b --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts @@ -0,0 +1,156 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + updateLastUsed: vi.fn(), + resolveWorkspaceBillingPayer: vi.fn(), + getHighestPrioritySubscription: vi.fn(), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ isAuthDisabled: false })) +vi.mock('@/lib/api-key/crypto', () => ({ hashApiKey: (value: string) => `hash:${value}` })) +vi.mock('@/lib/api-key/service', () => ({ updateApiKeyLastUsed: mocks.updateLastUsed })) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveWorkspaceBillingPayer: mocks.resolveWorkspaceBillingPayer, +})) +vi.mock('@/lib/billing/core/subscription', () => ({ + getHighestPrioritySubscription: mocks.getHighestPrioritySubscription, +})) + +import { + authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError, +} from '@/lib/api/server/routes/v2-api-key-auth' + +describe('v2 API key authentication', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.updateLastUsed.mockResolvedValue(undefined) + mocks.getHighestPrioritySubscription.mockResolvedValue(null) + }) + + it('normalizes a personal key without exposing loose optional identity fields', async () => { + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-1', + userId: 'user-1', + workspaceId: null, + type: 'personal', + expiresAt: null, + userBanned: false, + }, + ]) + + const result = await authenticateV2ApiKey('secret') + + expect(result).toEqual({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + expect(mocks.getHighestPrioritySubscription).toHaveBeenCalledWith('user-1', { + onError: 'throw', + }) + }) + + it('normalizes a workspace key as the workspace, not its creator', async () => { + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-1', + userId: 'creator-1', + workspaceId: 'workspace-1', + type: 'workspace', + expiresAt: null, + userBanned: false, + }, + ]) + mocks.resolveWorkspaceBillingPayer.mockResolvedValue({ + billedAccountUserId: 'billing-owner-1', + organizationId: 'organization-1', + payerSubscription: { + plan: 'team', + referenceId: 'organization-1', + }, + }) + + const result = await authenticateV2ApiKey('secret') + + expect(result).toEqual({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'], + rateLimitSubscription: { plan: 'team', referenceId: 'organization-1' }, + keyType: 'workspace', + }) + expect(mocks.getHighestPrioritySubscription).not.toHaveBeenCalled() + }) + + it('does not couple a workspace key to its creator ban state', async () => { + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-1', + userId: 'creator-1', + workspaceId: 'workspace-1', + type: 'workspace', + expiresAt: null, + userBanned: true, + }, + ]) + mocks.resolveWorkspaceBillingPayer.mockResolvedValue({ + billedAccountUserId: 'billing-owner-1', + organizationId: null, + payerSubscription: null, + }) + + await expect(authenticateV2ApiKey('secret')).resolves.toMatchObject({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + }) + }) + + it('treats missing, banned, and expired credentials as unauthenticated', async () => { + await expect(authenticateV2ApiKey('missing')).rejects.toBeInstanceOf( + V2ApiKeyUnauthenticatedError + ) + + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-1', + userId: 'user-1', + workspaceId: null, + type: 'personal', + expiresAt: null, + userBanned: true, + }, + ]) + await expect(authenticateV2ApiKey('banned')).rejects.toBeInstanceOf( + V2ApiKeyUnauthenticatedError + ) + + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-2', + userId: 'user-1', + workspaceId: null, + type: 'personal', + expiresAt: new Date(Date.now() - 1), + userBanned: false, + }, + ]) + await expect(authenticateV2ApiKey('expired')).rejects.toBeInstanceOf( + V2ApiKeyUnauthenticatedError + ) + }) + + it('propagates auth-store failures instead of converting them to invalid credentials', async () => { + const failure = new Error('database unavailable') + dbChainMockFns.limit.mockRejectedValueOnce(failure) + + await expect(authenticateV2ApiKey('secret')).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/api/server/routes/v2-api-key-auth.ts b/apps/sim/lib/api/server/routes/v2-api-key-auth.ts new file mode 100644 index 00000000000..d647ec9a778 --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-api-key-auth.ts @@ -0,0 +1,132 @@ +import type { PersonalApiKeyPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { apiKey, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { hashApiKey } from '@/lib/api-key/crypto' +import { updateApiKeyLastUsed } from '@/lib/api-key/service' +import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' +import { resolveWorkspaceBillingPayer } from '@/lib/billing/core/billing-attribution' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { isAuthDisabled } from '@/lib/core/config/env-flags' + +const logger = createLogger('V2ApiKeyAuth') + +export type V2ApiKeyPrincipal = PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal + +interface RateLimitSubscription { + plan: string + referenceId: string +} + +export interface V2ApiKeyAuthContext { + principal: V2ApiKeyPrincipal + rolloutUserId: string + rateLimitSubjectIds: readonly [string, ...string[]] + rateLimitSubscription: RateLimitSubscription | null + keyType: 'personal' | 'workspace' +} + +export class V2ApiKeyUnauthenticatedError extends Error { + constructor(message = 'Invalid API key') { + super(message) + this.name = 'V2ApiKeyUnauthenticatedError' + } +} + +interface ApiKeyRow { + id: string + userId: string + workspaceId: string | null + type: string + expiresAt: Date | null + userBanned: boolean | null +} + +function requireValidRow(row: ApiKeyRow | undefined): ApiKeyRow { + if (!row || (row.expiresAt && row.expiresAt < new Date())) { + throw new V2ApiKeyUnauthenticatedError() + } + if (row.type === 'personal' && row.workspaceId === null) { + if (row.userBanned === null) { + throw new Error(`Personal API key ${row.id} is missing its credential owner`) + } + if (row.userBanned) throw new V2ApiKeyUnauthenticatedError() + return row + } + if (row.type === 'workspace' && row.workspaceId) return row + throw new Error(`API key ${row.id} has an invalid persisted type/workspace combination`) +} + +export async function authenticateV2ApiKey( + apiKeyHeader: string | null +): Promise { + if (isAuthDisabled) { + return { + principal: { + kind: 'personal_api_key', + userId: ANONYMOUS_USER_ID, + keyId: 'auth-disabled', + }, + rolloutUserId: ANONYMOUS_USER_ID, + rateLimitSubjectIds: [`user:${ANONYMOUS_USER_ID}`], + rateLimitSubscription: null, + keyType: 'personal', + } + } + if (!apiKeyHeader) { + throw new V2ApiKeyUnauthenticatedError('API key required') + } + + const [candidate] = await db + .select({ + id: apiKey.id, + userId: apiKey.userId, + workspaceId: apiKey.workspaceId, + type: apiKey.type, + expiresAt: apiKey.expiresAt, + userBanned: user.banned, + }) + .from(apiKey) + .leftJoin(user, eq(apiKey.userId, user.id)) + .where(eq(apiKey.keyHash, hashApiKey(apiKeyHeader))) + .limit(1) + const row = requireValidRow(candidate) + + await updateApiKeyLastUsed(row.id) + logger.debug('Authenticated v2 API key', { keyId: row.id, keyType: row.type }) + + if (row.type === 'personal') { + const subscription = await getHighestPrioritySubscription(row.userId, { onError: 'throw' }) + return { + principal: { kind: 'personal_api_key', userId: row.userId, keyId: row.id }, + rolloutUserId: row.userId, + rateLimitSubjectIds: [`api-key:${row.id}`, `user:${row.userId}`], + rateLimitSubscription: subscription + ? { plan: subscription.plan, referenceId: subscription.referenceId } + : null, + keyType: 'personal', + } + } + + const workspaceId = row.workspaceId + if (!workspaceId) { + throw new Error(`Workspace API key ${row.id} is missing its workspace scope`) + } + const payer = await resolveWorkspaceBillingPayer(workspaceId) + if (!payer) { + throw new Error(`Workspace ${workspaceId} is missing its billing owner`) + } + return { + principal: { kind: 'workspace_api_key', workspaceId, keyId: row.id }, + rolloutUserId: payer.billedAccountUserId, + rateLimitSubjectIds: [`api-key:${row.id}`, `workspace:${workspaceId}`], + rateLimitSubscription: payer.payerSubscription + ? { + plan: payer.payerSubscription.plan, + referenceId: payer.payerSubscription.referenceId, + } + : null, + keyType: 'workspace', + } +} diff --git a/apps/sim/lib/api/server/routes/v2-binary-route.ts b/apps/sim/lib/api/server/routes/v2-binary-route.ts new file mode 100644 index 00000000000..eaf1a76ef60 --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-binary-route.ts @@ -0,0 +1,97 @@ +import type { NextRequest } from 'next/server' +import { requireBinaryRouteDefinition } from '@/lib/api/server/routes/definition' +import type { + BinaryApiRouteContract, + BinaryRouteDefinition, + JsonNextRouteHandler, + JsonRouteContext, +} from '@/lib/api/server/routes/types' +import { + admitV2Request, + type V2ErrorPolicy, + type V2RateLimitPolicy, + V2RouteInfrastructureError, + type v2ApiKeyAuth, +} from '@/lib/api/server/routes/v2-json-route' +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, v2ValidationError } from '@/app/api/v2/lib/response' + +interface V2BinaryRouteOptions< + C extends BinaryApiRouteContract, + O extends ApplicationOperation, + I, + R, +> extends BinaryRouteDefinition { + auth: typeof v2ApiKeyAuth + rateLimit: V2RateLimitPolicy + errorPolicy: V2ErrorPolicy +} + +export function defineV2BinaryRoute< + C extends BinaryApiRouteContract, + O extends ApplicationOperation, + I, + R, +>(options: V2BinaryRouteOptions): JsonNextRouteHandler { + const { successStatus } = requireBinaryRouteDefinition( + options.contract, + options.operation, + options.useCase.operation + ) + + const wrapped = withRouteHandler( + async (request: NextRequest, context) => { + if (request.method !== options.contract.method) { + throw new Error( + `Route received ${request.method} for ${options.contract.method} contract ${options.contract.path}` + ) + } + + const admission = await admitV2Request( + request, + options.operation, + options.auth, + options.rateLimit + ) + if (!admission.success) return admission.response + + const parsed = await parseRequest(options.contract, request, context ?? {}, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + try { + const result = await options.useCase.execute({ + principal: admission.auth.principal, + input: options.mapInput(parsed.data), + request, + }) + const descriptor = await options.present(result) + const headers = new Headers(descriptor.headers) + headers.set('Content-Type', descriptor.contentType) + headers.set('Cache-Control', 'private, no-store') + if (descriptor.contentDisposition) { + headers.set('Content-Disposition', descriptor.contentDisposition) + } + if (descriptor.contentLength !== undefined) { + headers.set('Content-Length', String(descriptor.contentLength)) + } + return new Response(descriptor.body, { status: successStatus, headers }) + } catch (error) { + const response = options.errorPolicy.render(error) + if (response) return response + throw error + } + }, + { + unhandledErrorResponse: ({ error }) => + error instanceof V2RouteInfrastructureError + ? v2Error('SERVICE_UNAVAILABLE', 'Service temporarily unavailable') + : v2Error('INTERNAL_ERROR', 'Internal server error'), + } + ) + + return async (request, context) => wrapped(request, context) +} diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts new file mode 100644 index 00000000000..62a0c572814 --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -0,0 +1,246 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { recordRateLimitSnapshot } from '@/lib/api/server/rate-limit-context' +import { requireJsonRouteDefinition } from '@/lib/api/server/routes/definition' +import type { + JsonApiRouteContract, + JsonNextRouteHandler, + JsonRouteContext, + JsonRouteDefinition, +} from '@/lib/api/server/routes/types' +import { + authenticateV2ApiKey, + type V2ApiKeyAuthContext, + 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 { 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' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const rateLimiter = new RateLimiter() +const V2_PREAUTH_IP_LIMIT = { + maxTokens: 600, + refillRate: 300, + refillIntervalMs: 60_000, +} as const + +export class V2RouteInfrastructureError extends Error { + constructor(stage: 'authentication' | 'rollout_gate' | 'rate_limit', cause: unknown) { + super(`V2 ${stage} infrastructure failed`, { cause }) + this.name = 'V2RouteInfrastructureError' + } +} + +export const v2ApiKeyAuth = { + authenticate(request: NextRequest) { + return authenticateV2ApiKey(request.headers.get('x-api-key')) + }, +} as const + +export interface V2RateLimitPolicy { + readonly kind: 'public_api' + enforce( + request: NextRequest, + auth: V2ApiKeyAuthContext, + operation: ApplicationOperation + ): Promise +} + +export const v2RateLimits = { + publicApi: { + kind: 'public_api', + async enforce(request, auth, operation) { + const plan = (auth.rateLimitSubscription?.plan ?? 'free') as SubscriptionPlan + const config = getRateLimit(plan, 'api-endpoint') + const buckets = await Promise.all( + auth.rateLimitSubjectIds.map(async (subjectId) => { + try { + return await rateLimiter.checkRateLimitDirectOrThrow( + `v2:${operation.id}:${subjectId}`, + config + ) + } catch (error) { + throw new V2RouteInfrastructureError('rate_limit', error) + } + }) + ) + const rateLimit = buckets.reduce((mostRestrictive, candidate) => { + if (!candidate.allowed && mostRestrictive.allowed) return candidate + if (candidate.allowed === mostRestrictive.allowed) { + if (candidate.remaining < mostRestrictive.remaining) return candidate + if ( + candidate.remaining === mostRestrictive.remaining && + candidate.resetAt > mostRestrictive.resetAt + ) { + return candidate + } + } + return mostRestrictive + }) + const snapshot = { + allowed: rateLimit.allowed, + limit: config.maxTokens, + remaining: rateLimit.remaining, + resetAt: rateLimit.resetAt, + retryAfterMs: rateLimit.retryAfterMs, + keyType: auth.keyType, + } + recordRateLimitSnapshot(request, snapshot) + return rateLimit.allowed ? null : v2RateLimitError(snapshot) + }, + } satisfies V2RateLimitPolicy, +} as const + +export interface V2ErrorPolicy { + render(error: unknown): NextResponse | null +} + +export const v2OrchestrationErrorPolicy = { + render(error) { + return v2CaughtOrchestrationError(error) + }, +} satisfies V2ErrorPolicy + +export async function admitV2Request( + request: NextRequest, + operation: ApplicationOperation, + authPolicy: typeof v2ApiKeyAuth, + rateLimitPolicy: V2RateLimitPolicy +): Promise< + { success: true; auth: V2ApiKeyAuthContext } | { success: false; response: NextResponse } +> { + const ip = getClientIp(request) + const abuseLimit = await rateLimiter.checkRateLimitDirect( + `v2:preauth:ip:${ip}`, + V2_PREAUTH_IP_LIMIT, + { failClosed: true } + ) + if (!abuseLimit.allowed) { + return { + success: false, + response: v2RateLimitError({ ...abuseLimit, limit: V2_PREAUTH_IP_LIMIT.maxTokens }), + } + } + + let auth: V2ApiKeyAuthContext + try { + auth = await authPolicy.authenticate(request) + } catch (error) { + if (error instanceof V2ApiKeyUnauthenticatedError) { + return { success: false, response: v2Error('UNAUTHORIZED', error.message) } + } + throw new V2RouteInfrastructureError('authentication', error) + } + + let gate + try { + gate = await v2ApiGateError(auth.rolloutUserId) + } catch (error) { + throw new V2RouteInfrastructureError('rollout_gate', error) + } + if (gate) return { success: false, response: gate } + + const limited = await rateLimitPolicy.enforce(request, auth, operation) + return limited ? { success: false, response: limited } : { success: true, auth } +} + +interface V2JsonRouteOptions + extends JsonRouteDefinition { + auth: typeof v2ApiKeyAuth + rateLimit: V2RateLimitPolicy + errorPolicy: V2ErrorPolicy + parseOptions?: Omit + beforeParse?(args: { + request: NextRequest + principal: V2ApiKeyAuthContext['principal'] + params: Record + }): void | Promise +} + +export function defineV2JsonRoute< + C extends JsonApiRouteContract, + O extends ApplicationOperation, + I, + R, +>(options: V2JsonRouteOptions): JsonNextRouteHandler { + const { successStatus } = requireJsonRouteDefinition( + options.contract, + options.operation, + options.useCase.operation + ) + + const wrapped = withRouteHandler( + async (request, context) => { + if (request.method !== options.contract.method) { + throw new Error( + `Route received ${request.method} for ${options.contract.method} contract ${options.contract.path}` + ) + } + + const admission = await admitV2Request( + request, + options.operation, + options.auth, + options.rateLimit + ) + if (!admission.success) return admission.response + const { auth } = admission + + if (options.beforeParse) { + const rawParams = context?.params ? await context.params : {} + try { + await options.beforeParse({ request, principal: auth.principal, params: rawParams }) + } catch (error) { + const response = options.errorPolicy.render(error) + if (response) return response + throw error + } + } + + const parsed = await parseRequest(options.contract, request, context ?? {}, { + ...options.parseOptions, + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + try { + const result = await options.useCase.execute({ + principal: auth.principal, + input: options.mapInput(parsed.data), + request, + }) + const body = await options.present(result) + const responseSchema = options.contract.response + if (responseSchema.mode !== 'json') { + throw new Error('V2 JSON route response mode changed after initialization') + } + const validatedBody = responseSchema.schema.parse(body) + return NextResponse.json(validatedBody, { + status: successStatus, + headers: { 'Cache-Control': 'private, no-store' }, + }) + } catch (error) { + const response = options.errorPolicy.render(error) + if (response) return response + throw error + } + }, + { + unhandledErrorResponse: ({ error }) => + error instanceof V2RouteInfrastructureError + ? v2Error('SERVICE_UNAVAILABLE', 'Service temporarily unavailable') + : v2Error('INTERNAL_ERROR', 'Internal server error'), + } + ) + + return async (request, context) => wrapped(request, context) +} diff --git a/apps/sim/lib/auth/principal.test.ts b/apps/sim/lib/auth/principal.test.ts new file mode 100644 index 00000000000..27248919ee8 --- /dev/null +++ b/apps/sim/lib/auth/principal.test.ts @@ -0,0 +1,118 @@ +/** + * @vitest-environment node + */ +import { + resolvePrincipalAttribution, + resolvePrincipalAuditAttribution, + toPrincipalActor, +} from '@sim/auth/principal' +import { describe, expect, it } from 'vitest' + +describe('principal actors', () => { + it('maps every principal to an audit actor without billing-owner substitution', () => { + expect( + resolvePrincipalAuditAttribution({ + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + }) + ).toEqual({ + actor: { kind: 'session', userId: 'user-1' }, + actorId: 'user-1', + }) + expect( + resolvePrincipalAuditAttribution({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-2', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2026-01-01T00:05:00Z'), + }) + ).toMatchObject({ actorId: 'user-2' }) + expect( + resolvePrincipalAuditAttribution({ + kind: 'workspace_api_key', + keyId: 'key-1', + workspaceId: 'workspace-1', + }) + ).toEqual({ + actor: { kind: 'workspace_api_key', keyId: 'key-1', workspaceId: 'workspace-1' }, + actorId: null, + actorName: 'Workspace API key', + }) + }) + + it('projects principals into their shared actor identity', () => { + expect( + toPrincipalActor({ kind: 'personal_api_key', keyId: 'key-1', userId: 'user-1' }) + ).toEqual({ kind: 'personal_api_key', keyId: 'key-1', userId: 'user-1' }) + + expect( + toPrincipalActor({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2026-01-01T00:05:00Z'), + }) + ).toEqual({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + delegationId: 'delegation-1', + }) + }) + + it('uses the workspace billing owner for workspace-key attribution', () => { + expect( + resolvePrincipalAttribution( + { kind: 'workspace_api_key', keyId: 'key-1', workspaceId: 'workspace-1' }, + { workspaceBillingOwnerUserId: 'billing-owner-1' } + ) + ).toEqual({ + actor: { kind: 'workspace_api_key', keyId: 'key-1', workspaceId: 'workspace-1' }, + attributedUserId: 'billing-owner-1', + }) + }) + + it('attributes user-backed principals to their human subject', () => { + expect( + resolvePrincipalAttribution({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + ).toMatchObject({ attributedUserId: 'user-1' }) + expect( + resolvePrincipalAttribution({ + kind: 'personal_api_key', + keyId: 'key-1', + userId: 'user-2', + }) + ).toMatchObject({ attributedUserId: 'user-2' }) + expect( + resolvePrincipalAttribution({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-3', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2026-01-01T00:05:00Z'), + }) + ).toMatchObject({ attributedUserId: 'user-3' }) + }) + + it('fails fast when workspace-key attribution has no billing owner', () => { + expect(() => + resolvePrincipalAttribution({ + kind: 'workspace_api_key', + keyId: 'key-1', + workspaceId: 'workspace-1', + }) + ).toThrow('Workspace API key attribution requires a workspace billing owner') + }) +}) diff --git a/apps/sim/lib/copilot/application/execute-file-use-case.test.ts b/apps/sim/lib/copilot/application/execute-file-use-case.test.ts new file mode 100644 index 00000000000..7e8d87fead1 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-file-use-case.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +const { resolveWorkspaceFileReference } = vi.hoisted(() => ({ + resolveWorkspaceFileReference: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference, +})) + +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +const trustedContext = { + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} + +describe('executeCopilotFileUseCase', () => { + it('normalizes a file-scoped principal and calls the application use case', async () => { + const execute = vi.fn().mockResolvedValue({ fileId: 'file-1' }) + const useCase = { operation: fileOperations.rename, execute } + + await expect( + executeCopilotFileUseCase( + trustedContext, + useCase, + { fileId: 'file-1', name: 'renamed.txt' }, + { fileId: 'file-1' } + ) + ).resolves.toEqual({ fileId: 'file-1' }) + expect(execute).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-call-1', + resourceScope: expect.objectContaining({ fileId: 'file-1' }), + }), + input: { fileId: 'file-1', name: 'renamed.txt' }, + }) + }) + + it('fails before application execution for an untrusted context', () => { + const execute = vi.fn() + const useCase = { operation: fileOperations.readMetadata, execute } + + expect(() => + executeCopilotFileUseCase({ ...trustedContext, copilotToolExecution: false }, useCase, { + fileId: 'file-1', + }) + ).toThrow('trusted Copilot execution context') + expect(execute).not.toHaveBeenCalled() + }) + + it('normalizes path reference resolution through the same boundary', async () => { + resolveWorkspaceFileReference.mockResolvedValue({ id: 'file-1' }) + + await expect( + resolveCopilotWorkspaceFileReference(trustedContext, fileOperations.readContent, { + workspaceId: 'workspace-1', + reference: 'files/report.txt', + }) + ).resolves.toEqual({ id: 'file-1' }) + expect(resolveWorkspaceFileReference).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-call-1', + }), + operation: fileOperations.readContent, + workspaceId: 'workspace-1', + reference: 'files/report.txt', + }) + }) +}) diff --git a/apps/sim/lib/copilot/application/execute-file-use-case.ts b/apps/sim/lib/copilot/application/execute-file-use-case.ts new file mode 100644 index 00000000000..2532fe7c370 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-file-use-case.ts @@ -0,0 +1,45 @@ +import { + type CopilotFileDelegationContext, + resolveCopilotFilePrincipal, +} from '@/lib/copilot/auth/file-delegation' +import type { OperationUseCase } from '@/lib/core/application' +import { type FileOperation, fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' + +const registeredFileOperationIds = new Set( + Object.values(fileOperations).map((operation) => operation.id) +) + +interface ExecuteCopilotFileUseCaseOptions { + fileId?: string +} + +/** Normalizes trusted Copilot authentication before entering a file application use case. */ +export function executeCopilotFileUseCase( + context: CopilotFileDelegationContext | undefined, + useCase: OperationUseCase, + input: I, + options: ExecuteCopilotFileUseCaseOptions = {} +): Promise { + if (!registeredFileOperationIds.has(useCase.operation.id)) { + throw new Error(`Unregistered Copilot file operation: ${useCase.operation.id}`) + } + + return useCase.execute({ + principal: resolveCopilotFilePrincipal(context, options.fileId), + input, + }) +} + +/** Resolves a model-supplied VFS reference under a trusted Copilot delegation. */ +export function resolveCopilotWorkspaceFileReference( + context: CopilotFileDelegationContext | undefined, + operation: FileOperation, + input: { workspaceId: string; reference: string } +) { + return resolveWorkspaceFileReference({ + principal: resolveCopilotFilePrincipal(context), + operation, + ...input, + }) +} diff --git a/apps/sim/lib/copilot/auth/file-delegation.test.ts b/apps/sim/lib/copilot/auth/file-delegation.test.ts new file mode 100644 index 00000000000..7ced2fecb1d --- /dev/null +++ b/apps/sim/lib/copilot/auth/file-delegation.test.ts @@ -0,0 +1,100 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + createCopilotChatFilePrincipal, + createCopilotWorkspaceContextFilePrincipal, + messageForCopilotFileError, + resolveCopilotFilePrincipal, +} from '@/lib/copilot/auth/file-delegation' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const trustedContext = { + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} + +describe('Copilot file delegation', () => { + it('creates a short-lived principal scoped to the trusted workspace and file', () => { + const principal = resolveCopilotFilePrincipal(trustedContext, 'file-1') + + expect(principal).toMatchObject({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-call-1', + audience: 'sim:workspace-files', + resourceScope: { + fileId: 'file-1', + chatId: 'chat-1', + executionId: 'execution-1', + }, + }) + expect(principal.expiresAt.getTime()).toBeGreaterThan(principal.issuedAt.getTime()) + }) + + it('creates a workspace-scoped principal for file creation', () => { + const principal = resolveCopilotFilePrincipal(trustedContext) + + expect(principal.resourceScope).toEqual({ + chatId: 'chat-1', + executionId: 'execution-1', + }) + }) + + it('rejects contexts that were not issued by the Copilot execution pipeline', () => { + expect(() => + resolveCopilotFilePrincipal({ ...trustedContext, copilotToolExecution: false }, 'file-1') + ).toThrow('trusted Copilot execution context') + expect(() => + resolveCopilotFilePrincipal({ ...trustedContext, toolCallId: undefined }, 'file-1') + ).toThrow('tool call ID') + }) + + it('rejects a missing execution workspace', () => { + expect(() => + resolveCopilotFilePrincipal({ ...trustedContext, workspaceId: undefined }, 'file-1') + ).toThrow('workspace ID') + }) + + it('normalizes chat and workspace-index identities without caller-built delegation fields', () => { + expect( + createCopilotChatFilePrincipal({ + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + }) + ).toMatchObject({ + delegationId: 'copilot-chat:chat-1', + resourceScope: { chatId: 'chat-1' }, + }) + expect( + createCopilotWorkspaceContextFilePrincipal({ + userId: 'user-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + }) + ).toMatchObject({ + delegationId: 'copilot-workspace-context:execution-1', + resourceScope: { executionId: 'execution-1' }, + }) + }) + + it('projects only typed domain messages to Copilot', () => { + expect(messageForCopilotFileError(new OrchestrationError('conflict', 'Name exists'))).toBe( + 'Name exists' + ) + expect( + messageForCopilotFileError( + new Error('update workspace_files set ...'), + 'Failed to rename file' + ) + ).toBe('Failed to rename file') + }) +}) diff --git a/apps/sim/lib/copilot/auth/file-delegation.ts b/apps/sim/lib/copilot/auth/file-delegation.ts new file mode 100644 index 00000000000..8f673083aae --- /dev/null +++ b/apps/sim/lib/copilot/auth/file-delegation.ts @@ -0,0 +1,88 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' + +export interface CopilotFileDelegationContext { + userId: string + workspaceId?: string + chatId?: string + executionId?: string + toolCallId?: string + copilotToolExecution?: boolean +} + +export interface CopilotChatFileDelegationContext { + userId: string + workspaceId: string + chatId?: string +} + +export interface CopilotWorkspaceContextFileDelegationContext + extends CopilotChatFileDelegationContext { + executionId?: string +} + +/** Normalizes a trusted Copilot tool context into the shared file principal. */ +export function resolveCopilotFilePrincipal( + context: CopilotFileDelegationContext | undefined, + fileId?: string +): DelegatedPrincipal { + if (!context) { + throw new Error('File delegation requires a Copilot execution context') + } + if (!context.copilotToolExecution) { + throw new Error('File delegation requires a trusted Copilot execution context') + } + if (!context.toolCallId) { + throw new Error('File delegation requires a tool call ID') + } + if (!context.workspaceId) { + throw new Error('File delegation requires a workspace ID') + } + + return createWorkspaceFileDelegatedPrincipal({ + serviceId: 'copilot', + subjectUserId: context.userId, + workspaceId: context.workspaceId, + delegationId: `copilot-tool:${context.toolCallId}`, + fileId, + chatId: context.chatId, + executionId: context.executionId, + }) +} + +/** Creates the principal used while resolving user-supplied chat file context. */ +export function createCopilotChatFilePrincipal( + context: CopilotChatFileDelegationContext +): DelegatedPrincipal { + return createWorkspaceFileDelegatedPrincipal({ + serviceId: 'copilot', + subjectUserId: context.userId, + workspaceId: context.workspaceId, + delegationId: `copilot-chat:${context.chatId ?? context.workspaceId}`, + chatId: context.chatId, + }) +} + +/** Creates the principal used while materializing the Copilot workspace index. */ +export function createCopilotWorkspaceContextFilePrincipal( + context: CopilotWorkspaceContextFileDelegationContext +): DelegatedPrincipal { + return createWorkspaceFileDelegatedPrincipal({ + serviceId: 'copilot', + subjectUserId: context.userId, + workspaceId: context.workspaceId, + delegationId: `copilot-workspace-context:${context.chatId ?? context.executionId ?? context.workspaceId}`, + chatId: context.chatId, + executionId: context.executionId, + }) +} + +export function messageForCopilotFileError( + error: unknown, + fallback = 'File operation failed' +): string { + const classified = asOrchestrationError(error) + if (classified && classified.code !== 'internal') return classified.message + return fallback +} diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 0e9b85a26f3..0c1307fd4a5 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -17,6 +17,7 @@ const { getSkillById, getUserPermissionConfig, getWorkspaceFile, + readWorkspaceFileMetadata, getTableById, getRowsByIds, getBlockVisibilityForCopilot, @@ -28,6 +29,7 @@ const { getSkillById: vi.fn(), getUserPermissionConfig: vi.fn(), getWorkspaceFile: vi.fn(), + readWorkspaceFileMetadata: vi.fn(), getTableById: vi.fn(), getRowsByIds: vi.fn(), getBlockVisibilityForCopilot: vi.fn(async () => null), @@ -43,6 +45,9 @@ vi.mock('@/lib/integrations/availability.server', () => ({ vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById })) vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools } })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFile })) +vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({ + readWorkspaceFileMetadata: { execute: readWorkspaceFileMetadata }, +})) vi.mock('@/lib/table/service', () => ({ getTableById })) vi.mock('@/lib/table/rows/service', () => ({ getRowsByIds })) @@ -444,6 +449,13 @@ describe('processContextsServer - logs contexts', () => { describe('processContextsServer - file_selection contexts', () => { beforeEach(() => { vi.clearAllMocks() + readWorkspaceFileMetadata.mockImplementation( + async ({ input }: { input: { fileId: string } }) => { + const file = await getWorkspaceFile('ws-1', input.fileId) + if (!file) throw new Error('File not found') + return { file } + } + ) }) it('inlines the selected passage with its line range and a path pointer', async () => { diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 28f6543841d..7dd0e3ac386 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -6,6 +6,7 @@ import { getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' import { and, eq, isNull } from 'drizzle-orm' +import { createCopilotChatFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { MAX_TABLE_SELECTION_CONTENT_LENGTH, @@ -37,9 +38,9 @@ import { getRowsByIds } from '@/lib/table/rows/service' import { getTableById } from '@/lib/table/service' import type { ColumnDefinition } from '@/lib/table/types' import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getSkillById } from '@/lib/workflows/skills/operations' import { listFolders } from '@/lib/workflows/utils' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { escapeRegExp } from '@/executor/constants' @@ -233,7 +234,7 @@ export async function processContextsServer( } } if (ctx.kind === 'file' && ctx.fileId && currentWorkspaceId) { - const result = await resolveFileResource(ctx.fileId, currentWorkspaceId) + const result = await resolveFileResource(ctx.fileId, currentWorkspaceId, userId, chatId) if (!result) return null return { type: 'file', @@ -249,7 +250,9 @@ export async function processContextsServer( ctx.text ?? '', ctx.label, ctx.startLine, - ctx.endLine + ctx.endLine, + userId, + chatId ) } if ( @@ -847,7 +850,7 @@ export async function resolveActiveResourceContext( return await resolveTableResource(resourceId, workspaceId) } case 'file': { - return await resolveFileResource(resourceId, workspaceId) + return await resolveFileResource(resourceId, workspaceId, userId, chatId) } case 'folder': { return await resolveFolderResource(resourceId, workspaceId) @@ -880,10 +883,19 @@ async function resolveTableResource( async function resolveFileResource( fileId: string, - workspaceId: string + workspaceId: string, + userId: string, + chatId?: string ): Promise { - const record = await getWorkspaceFile(workspaceId, fileId) - if (!record) return null + const principal = createCopilotChatFilePrincipal({ + userId, + workspaceId, + chatId, + }) + const { file: record } = await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId, assertedWorkspaceId: workspaceId }, + }) return { type: 'active_resource', tag: '@active_resource', @@ -919,10 +931,20 @@ async function resolveFileSelectionResource( text: string, label: string, startLine?: number, - endLine?: number + endLine?: number, + userId?: string, + chatId?: string ): Promise { - const record = await getWorkspaceFile(workspaceId, fileId) - if (!record) return null + if (!userId) throw new Error('File selection context requires a user ID') + const principal = createCopilotChatFilePrincipal({ + userId, + workspaceId, + chatId, + }) + const { file: record } = await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId, assertedWorkspaceId: workspaceId }, + }) const path = canonicalWorkspaceFilePath({ folderPath: record.folderPath, name: record.name }) const snippet = truncateSelectionText(text) const lineRange = diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index d2144ab844a..3652b14547f 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -11,6 +11,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, inArray, isNull } from 'drizzle-orm' import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' +import { createCopilotWorkspaceContextFilePrincipal } from '@/lib/copilot/auth/file-delegation' import type { VfsSnapshotV1, VfsSnapshotV1Workflow } from '@/lib/copilot/generated/vfs-snapshot-v1' import { filterSecretNamesByMountPolicy, @@ -23,10 +24,10 @@ import { getAccessibleOAuthCredentials, } from '@/lib/credentials/environment' import { listWorkspaceSandboxes } from '@/lib/execution/remote-sandbox/workspace-sandboxes' -import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { listCustomBlockSummariesForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { listCustomTools } from '@/lib/workflows/custom-tools/operations' import { listSkillsForUser } from '@/lib/workflows/skills/operations' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' import { assertActiveWorkspaceAccess, getUsersWithPermissions, @@ -329,7 +330,7 @@ export function buildWorkspaceContextMd(data: WorkspaceMdData): string { async function buildWorkspaceMdData( workspaceId: string, userId: string, - options?: { workspaceAccess?: WorkspaceAccess } + options?: { workspaceAccess?: WorkspaceAccess; chatId?: string; executionId?: string } ): Promise { try { // Reuse the caller's already-asserted access when provided (hot chat path); @@ -409,7 +410,17 @@ async function buildWorkspaceMdData( ) ), - listWorkspaceFiles(workspaceId), + listAllWorkspaceFiles + .execute({ + principal: createCopilotWorkspaceContextFilePrincipal({ + userId, + workspaceId, + chatId: options?.chatId, + executionId: options?.executionId, + }), + input: { workspaceId, scope: 'active' }, + }) + .then(({ files }) => files), getAccessibleOAuthCredentials(workspaceId, userId), @@ -549,7 +560,12 @@ const WORKSPACE_CONTEXT_UNAVAILABLE_MD = export async function generateWorkspaceContext( workspaceId: string, userId: string, - options?: { workspaceAccess?: WorkspaceAccess; secretMountPolicy?: SecretMountPolicy } + options?: { + workspaceAccess?: WorkspaceAccess + secretMountPolicy?: SecretMountPolicy + chatId?: string + executionId?: string + } ): Promise { const data = await buildWorkspaceMdData(workspaceId, userId, options) if (!data) return WORKSPACE_CONTEXT_UNAVAILABLE_MD diff --git a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts index 3d16cff4bb1..feaa0822416 100644 --- a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts +++ b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1' import { createFilePreviewSession, @@ -25,7 +26,8 @@ import { loadWorkspaceFileTextForPreview, type WorkspaceFilePreviewBase, } from '@/lib/copilot/tools/server/files/file-preview' -import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { findWorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' const logger = createLogger('CopilotFilePreviewAdapter') @@ -65,14 +67,22 @@ function toPreviewTargetKind(kind: string | undefined): FilePreviewTargetKind | } async function resolvePreviewTarget(args: { + context: ExecutionContext workspaceId?: string target: FileIntent['target'] }): Promise { if (args.target.kind !== 'path' || !args.workspaceId || !args.target.path) { return args.target } + if (!args.context.copilotToolExecution || !args.context.toolCallId) { + throw new Error('Workspace file preview requires a trusted Copilot execution context') + } - const file = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) + const { files } = await executeCopilotFileUseCase(args.context, listAllWorkspaceFiles, { + workspaceId: args.workspaceId, + scope: 'active', + }) + const file = findWorkspaceFileRecord(files, args.target.path) if (!file) { return args.target } @@ -366,6 +376,7 @@ export async function processFilePreviewStreamEvent(input: { if (toolCallId && parsedArgs) { const { operation, title, contentType, edit } = parsedArgs const target = await resolvePreviewTarget({ + context: execContext, workspaceId: execContext.workspaceId, target: parsedArgs.target, }) @@ -393,7 +404,11 @@ export async function processFilePreviewStreamEvent(input: { fileId && (operation === 'append' || operation === 'patch') ) { - previewBase = await loadWorkspaceFileTextForPreview(execContext.workspaceId, fileId) + previewBase = await loadWorkspaceFileTextForPreview( + execContext, + execContext.workspaceId, + fileId + ) } let session = buildPreviewSessionFromIntent(streamId, intent) @@ -464,7 +479,11 @@ export async function processFilePreviewStreamEvent(input: { execContext.workspaceId && (intent.operation === 'append' || intent.operation === 'patch') ) { - previewBase = await loadWorkspaceFileTextForPreview(execContext.workspaceId, result.fileId) + previewBase = await loadWorkspaceFileTextForPreview( + execContext, + execContext.workspaceId, + result.fileId + ) } let session = buildPreviewSessionFromIntent(streamId, intent) diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index efa9d8ef7d8..32d6a664353 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -23,9 +23,22 @@ vi.mock('@/lib/copilot/request/session', async () => { }) const resolveWorkspaceFileReferenceMock = vi.hoisted(() => vi.fn()) +const listAllWorkspaceFilesMock = vi.hoisted(() => vi.fn()) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock, + findWorkspaceFileRecord: ( + files: Array<{ name: string; folderPath?: string | null }>, + path: string + ) => + files.find((file) => { + const normalized = path.replace(/^files\//, '').replaceAll('%20', ' ') + const filePath = file.folderPath ? `${file.folderPath}/${file.name}` : file.name + return filePath === normalized + }) ?? null, +})) +vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ + listAllWorkspaceFiles: { execute: listAllWorkspaceFilesMock }, })) vi.mock('@/lib/copilot/tools/server/files/file-preview', async () => { @@ -121,6 +134,8 @@ describe('copilot go stream helpers', () => { vi.stubGlobal('fetch', vi.fn()) resolveWorkspaceFileReferenceMock.mockReset() resolveWorkspaceFileReferenceMock.mockResolvedValue(null) + listAllWorkspaceFilesMock.mockReset() + listAllWorkspaceFilesMock.mockResolvedValue({ files: [] }) }) afterEach(() => { @@ -169,9 +184,8 @@ describe('copilot go stream helpers', () => { }) it('hydrates path-based workspace_file edits into file preview events before edit_content streams', async () => { - resolveWorkspaceFileReferenceMock.mockResolvedValue({ - id: 'file-1', - name: 'notes.md', + listAllWorkspaceFilesMock.mockResolvedValue({ + files: [{ id: 'file-1', name: 'notes.md', folderPath: null }], }) const workspaceFileCall = createEvent({ @@ -274,6 +288,8 @@ describe('copilot go stream helpers', () => { workflowId: 'workflow-1', workspaceId: 'workspace-1', messageId: 'msg-1', + copilotToolExecution: true, + toolCallId: 'stream-tool-1', } await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { @@ -308,13 +324,24 @@ describe('copilot go stream helpers', () => { previewPhase: 'file_preview_complete', fileId: 'file-1', }) - expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith('workspace-1', 'files/notes.md') + expect(listAllWorkspaceFilesMock).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + workspaceId: 'workspace-1', + }), + input: { workspaceId: 'workspace-1', scope: 'active' }, + }) }) it('resolves workflow alias paths to the backing file before streaming previews', async () => { - resolveWorkspaceFileReferenceMock.mockResolvedValue({ - id: 'changelog-file-1', - name: 'workflow-1.md', + listAllWorkspaceFilesMock.mockResolvedValue({ + files: [ + { + id: 'changelog-file-1', + name: 'changelog.md', + folderPath: 'workflows/My Workflow', + }, + ], }) const workspaceFileCall = createEvent({ @@ -392,6 +419,8 @@ describe('copilot go stream helpers', () => { workflowId: 'workflow-1', workspaceId: 'workspace-1', messageId: 'msg-1', + copilotToolExecution: true, + toolCallId: 'stream-tool-2', } await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { @@ -414,7 +443,7 @@ describe('copilot go stream helpers', () => { ]) expect(previewEvents[1].payload).toMatchObject({ previewPhase: 'file_preview_target', - target: { kind: 'file_id', fileId: 'changelog-file-1', fileName: 'workflow-1.md' }, + target: { kind: 'file_id', fileId: 'changelog-file-1', fileName: 'changelog.md' }, }) expect(previewEvents[2].payload).toMatchObject({ previewPhase: 'file_preview_content', @@ -426,10 +455,13 @@ describe('copilot go stream helpers', () => { previewPhase: 'file_preview_complete', fileId: 'changelog-file-1', }) - expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith( - 'workspace-1', - 'workflows/My%20Workflow/changelog.md' - ) + expect(listAllWorkspaceFilesMock).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + workspaceId: 'workspace-1', + }), + input: { workspaceId: 'workspace-1', scope: 'active' }, + }) }) it('drops duplicate tool_result events before forwarding them', async () => { diff --git a/apps/sim/lib/copilot/request/tools/files.test.ts b/apps/sim/lib/copilot/request/tools/files.test.ts index f09a7c396d6..facdd2b6f41 100644 --- a/apps/sim/lib/copilot/request/tools/files.test.ts +++ b/apps/sim/lib/copilot/request/tools/files.test.ts @@ -8,7 +8,7 @@ const { mockWriteWorkspaceFileByPath } = vi.hoisted(() => ({ })) vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ - writeWorkspaceFileByPath: mockWriteWorkspaceFileByPath, + writeCopilotWorkspaceFileByPath: mockWriteWorkspaceFileByPath, })) vi.mock('@/lib/copilot/request/otel', () => ({ @@ -113,6 +113,8 @@ describe('maybeWriteOutputToFile', () => { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'workspace-1', + toolCallId: 'tool-1', + copilotToolExecution: true, userPermission: 'write', resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), ...overrides, @@ -195,7 +197,7 @@ describe('maybeWriteOutputToFile', () => { ) expect(result.success).toBe(true) - const persisted = mockWriteWorkspaceFileByPath.mock.calls[0][0].buffer.toString('utf8') + const persisted = mockWriteWorkspaceFileByPath.mock.calls[0][1].buffer.toString('utf8') expect(JSON.parse(persisted)).toEqual({ token: '{{OUTPUT_SECRET}}', publicLabel: 'true', diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts index 43ffdcc9c27..d093342703d 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -13,7 +13,7 @@ import { } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' const logger = createLogger('CopilotToolResultFiles') @@ -285,9 +285,8 @@ export async function maybeWriteOutputToFile( throw new Error('Request aborted before tool mutation could be applied') } - const written = await writeWorkspaceFileByPath({ + const written = await writeCopilotWorkspaceFileByPath(context, { workspaceId: context.workspaceId!, - userId: context.userId!, target: { path: outputFile.path, mode: outputFile.mode ?? 'create', diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts index 54817fffbc3..916149e9b04 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts @@ -15,8 +15,8 @@ const { updateCustomBlockMock, deleteCustomBlockMock, getCustomBlockWithInputsByWorkflowIdMock, - listWorkspaceFilesMock, - fetchWorkspaceFileBufferMock, + resolveWorkspaceFileReferenceMock, + readWorkspaceFileContentMock, uploadFileMock, } = vi.hoisted(() => ({ ensureWorkflowAccessMock: vi.fn(), @@ -27,8 +27,8 @@ const { updateCustomBlockMock: vi.fn(), deleteCustomBlockMock: vi.fn(), getCustomBlockWithInputsByWorkflowIdMock: vi.fn(), - listWorkspaceFilesMock: vi.fn(), - fetchWorkspaceFileBufferMock: vi.fn(), + resolveWorkspaceFileReferenceMock: vi.fn(), + readWorkspaceFileContentMock: vi.fn(), uploadFileMock: vi.fn(), })) @@ -51,9 +51,14 @@ vi.mock('@/lib/billing', () => ({ isOrganizationOnEnterprisePlan: isOrganizationOnEnterprisePlanMock, })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - listWorkspaceFiles: listWorkspaceFilesMock, - fetchWorkspaceFileBuffer: fetchWorkspaceFileBufferMock, +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock, +})) +vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ + readWorkspaceFileContent: { + operation: { id: 'files.read_content' }, + execute: readWorkspaceFileContentMock, + }, })) vi.mock('@/lib/uploads/core/storage-service', () => ({ @@ -77,7 +82,13 @@ vi.mock('@/lib/workflows/custom-blocks/operations', () => { import { executeDeployCustomBlock } from './custom-block' -const context = { userId: 'user-1', workflowId: 'wf-1' } as ExecutionContext +const context = { + userId: 'user-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + toolCallId: 'tool-1', + copilotToolExecution: true, +} as ExecutionContext const publishedBlock = { id: 'cb-1', @@ -327,16 +338,18 @@ describe('executeDeployCustomBlock', () => { }) it('ingests a workspace-file icon into public icon storage', async () => { - listWorkspaceFilesMock.mockResolvedValue([ - { - name: 'icon.png', - folderPath: null, - type: 'image/png', - size: 1024, - key: 'workspace/ws-1/123-abc-icon.png', - }, - ]) - fetchWorkspaceFileBufferMock.mockResolvedValue(Buffer.from('png-bytes')) + resolveWorkspaceFileReferenceMock.mockResolvedValue({ + id: 'file-1', + name: 'icon.png', + folderPath: null, + type: 'image/png', + size: 1024, + key: 'workspace/ws-1/123-abc-icon.png', + }) + readWorkspaceFileContentMock.mockResolvedValue({ + file: { id: 'file-1', name: 'icon.png' }, + content: Buffer.from('png-bytes'), + }) uploadFileMock.mockResolvedValue({ path: '/api/files/serve/s3/workspace-logos%2Ficon.png' }) publishCustomBlockMock.mockResolvedValue(publishedBlock) @@ -384,7 +397,7 @@ describe('executeDeployCustomBlock', () => { }) it('fails when the icon workspace file does not exist', async () => { - listWorkspaceFilesMock.mockResolvedValue([]) + resolveWorkspaceFileReferenceMock.mockRejectedValue(new Error('File not found')) const result = await executeDeployCustomBlock( { @@ -436,9 +449,14 @@ describe('executeDeployCustomBlock', () => { }) it('fails when the icon workspace file is not an image', async () => { - listWorkspaceFilesMock.mockResolvedValue([ - { name: 'notes.pdf', folderPath: null, type: 'application/pdf', size: 1024, key: 'k' }, - ]) + resolveWorkspaceFileReferenceMock.mockResolvedValue({ + id: 'file-2', + name: 'notes.pdf', + folderPath: null, + type: 'application/pdf', + size: 1024, + key: 'k', + }) const result = await executeDeployCustomBlock( { 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 8b2182b6abe..ca51f6bafcd 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -3,13 +3,13 @@ import { toError } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { isAllowedCustomBlockIconUrl } from '@/lib/api/contracts/custom-blocks' import { isOrganizationOnEnterprisePlan } from '@/lib/billing' +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { canonicalizeVfsPath, canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { canonicalizeVfsPath } from '@/lib/copilot/vfs/path-utils' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' -import { - fetchWorkspaceFileBuffer, - listWorkspaceFiles, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { uploadFile } from '@/lib/uploads/core/storage-service' import { isImageFileType } from '@/lib/uploads/utils/file-utils' import { @@ -20,6 +20,8 @@ import { publishCustomBlock, updateCustomBlock, } from '@/lib/workflows/custom-blocks/operations' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { ensureWorkflowAccess } from '../access' import type { DeployCustomBlockParams } from '../param-types' @@ -38,7 +40,7 @@ const MAX_OUTPUT_ENTRIES = 50 */ async function resolveIconUrl( raw: string | undefined, - userId: string, + context: ExecutionContext, workspaceId: string ): Promise { const value = raw?.trim() @@ -53,13 +55,12 @@ async function resolveIconUrl( } const canonical = canonicalizeVfsPath(value) - const files = await listWorkspaceFiles(workspaceId, { hydrateFolderPaths: true }) - const record = files.find( - (f) => canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }) === canonical - ) - if (!record) { + const record = await resolveCopilotWorkspaceFileReference(context, fileOperations.readContent, { + workspaceId, + reference: canonical, + }).catch(() => { throw new CustomBlockValidationError(`Icon file not found in this workspace: ${value}`) - } + }) if (!isImageFileType(record.type)) { throw new CustomBlockValidationError( 'Icon file must be an image (PNG, JPEG, GIF, WebP, or SVG)' @@ -69,7 +70,12 @@ async function resolveIconUrl( throw new CustomBlockValidationError('Icon file must be 5MB or smaller') } - const buffer = await fetchWorkspaceFileBuffer(record) + const { content: buffer } = await executeCopilotFileUseCase( + context, + readWorkspaceFileContent, + { 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, @@ -78,7 +84,7 @@ async function resolveIconUrl( context: 'workspace-logos', customKey: `workspace-logos/${Date.now()}-${generateShortId()}-${safeFileName}`, preserveKey: true, - metadata: { workspaceId, userId, originalName: record.name }, + metadata: { workspaceId, userId: context.userId, originalName: record.name }, }) return uploaded.path } @@ -218,7 +224,7 @@ export async function executeDeployCustomBlock( if (params.exposedOutputs?.some((entry) => entry.name.length > 60)) { return { success: false, error: 'exposed output names must be 60 characters or fewer' } } - const iconUrl = await resolveIconUrl(params.iconUrl, context.userId, workspaceId) + const iconUrl = await resolveIconUrl(params.iconUrl, context, workspaceId) if (existing) { await updateCustomBlock(existing.id, { diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts index 8257c9cbcd3..15f33a0ba4a 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -26,6 +26,10 @@ const { mockFetchServableWorkspaceFileBuffer, mockGetSandboxWorkspaceFilePath, mockListWorkspaceFileFolders, + mockListAllWorkspaceFiles, + mockListWorkspaceFileFoldersOperation, + mockDownloadWorkspaceFileRecord, + mockReadWorkspaceFileContent, mockMaterializeCopilotCodeSecrets, mockHasWorkspaceSandboxAccess, mockImportWorkspaceFileSecretProvenanceForRuntime, @@ -47,6 +51,10 @@ const { mockFetchServableWorkspaceFileBuffer: vi.fn(), mockGetSandboxWorkspaceFilePath: vi.fn(), mockListWorkspaceFileFolders: vi.fn(), + mockListAllWorkspaceFiles: vi.fn(), + mockListWorkspaceFileFoldersOperation: vi.fn(), + mockDownloadWorkspaceFileRecord: vi.fn(), + mockReadWorkspaceFileContent: vi.fn(), mockMaterializeCopilotCodeSecrets: vi.fn(), mockHasWorkspaceSandboxAccess: vi.fn(), mockImportWorkspaceFileSecretProvenanceForRuntime: vi.fn(), @@ -85,6 +93,18 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ listWorkspaceFileFolders: mockListWorkspaceFileFolders, })) +vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ + listAllWorkspaceFiles: { execute: mockListAllWorkspaceFiles }, +})) +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + listWorkspaceFileFoldersOperation: { execute: mockListWorkspaceFileFoldersOperation }, +})) +vi.mock('@/lib/workspace-files/application/read-workspace-file-record', () => ({ + downloadWorkspaceFileRecord: { execute: mockDownloadWorkspaceFileRecord }, +})) +vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ + readWorkspaceFileContent: { execute: mockReadWorkspaceFileContent }, +})) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ importWorkspaceFileSecretProvenanceForRuntime: mockImportWorkspaceFileSecretProvenanceForRuntime, })) @@ -115,7 +135,12 @@ const table = { schema: { columns: [{ id: 'col_name', name: 'name', type: 'string' }] }, } -const context = { workspaceId: 'ws_1', userId: 'u1' } +const context = { + workspaceId: 'ws_1', + userId: 'u1', + copilotToolExecution: true, + toolCallId: 'function-execute-test', +} function mountedFiles() { const params = mockExecuteTool.mock.calls[0][1] as { @@ -138,6 +163,37 @@ function resetExecutionMocks(): void { entries: [], }) mockIsTableSnapshotSafeForModelMount.mockResolvedValue(true) + mockListWorkspaceFiles.mockResolvedValue([]) + mockListWorkspaceFileFolders.mockResolvedValue([]) + mockListAllWorkspaceFiles.mockImplementation(async () => { + const files = await mockListWorkspaceFiles() + if (files.length > 0) return { files } + const fallback = mockFindWorkspaceFileRecord() + return { files: fallback ? [fallback] : [] } + }) + mockListWorkspaceFileFoldersOperation.mockImplementation(async () => ({ + folders: await mockListWorkspaceFileFolders(), + })) + mockDownloadWorkspaceFileRecord.mockImplementation( + async ({ input }: { input: { fileId: string } }) => { + const files = await mockListWorkspaceFiles() + const file = + files.find((candidate: { id: string }) => candidate.id === input.fileId) ?? + mockFindWorkspaceFileRecord() + if (!file) throw new Error('File not found') + return { file } + } + ) + mockReadWorkspaceFileContent.mockImplementation( + async ({ input }: { input: { fileId: string } }) => { + const files = await mockListWorkspaceFiles() + const file = + files.find((candidate: { id: string }) => candidate.id === input.fileId) ?? + mockFindWorkspaceFileRecord() + if (!file) throw new Error('File not found') + return { file, content: await mockFetchWorkspaceFileBuffer(file) } + } + ) } describe('executeFunctionExecute trace-secret provenance', () => { @@ -891,7 +947,9 @@ describe('executeFunctionExecute file mounts', () => { }) it('cloud storage: throws when a file exceeds the per-file URL mount limit', async () => { - mockFindWorkspaceFileRecord.mockReturnValue({ ...fileRecord, size: 600 * 1024 * 1024 }) + const oversized = { ...fileRecord, size: 600 * 1024 * 1024 } + mockFindWorkspaceFileRecord.mockReturnValue(oversized) + mockListWorkspaceFiles.mockResolvedValue([oversized]) await expect( executeFunctionExecute({ inputFiles: ['files/data.csv'] }, context as never) @@ -901,7 +959,15 @@ describe('executeFunctionExecute file mounts', () => { it('cloud storage: throws when mounts exceed the aggregate URL mount limit', async () => { // Each file is at the 500MB per-file cap; the 5th pushes the running total past 2GB. - mockFindWorkspaceFileRecord.mockReturnValue({ ...fileRecord, size: 500 * 1024 * 1024 }) + const oversized = { ...fileRecord, size: 500 * 1024 * 1024 } + mockFindWorkspaceFileRecord.mockReturnValue(oversized) + mockListWorkspaceFiles.mockResolvedValue( + Array.from({ length: 5 }, (_, i) => ({ + ...oversized, + id: `file_${i}`, + name: `big-${i}.csv`, + })) + ) const paths = Array.from({ length: 5 }, (_, i) => `files/big-${i}.csv`) await expect(executeFunctionExecute({ inputFiles: paths }, context as never)).rejects.toThrow( diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 067a41f07ca..8b07b401248 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -1,6 +1,8 @@ +import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { omit } from '@sim/utils/object' import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' +import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { applySecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' import { @@ -27,13 +29,10 @@ import { import { queryRows } from '@/lib/table/rows/service' import { getTableById, listTables } from '@/lib/table/service' import { getOrCreateTableSnapshot, SNAPSHOT_MAX_BYTES } from '@/lib/table/snapshot-cache' -import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { fetchServableWorkspaceFileBuffer, - fetchWorkspaceFileBuffer, findWorkspaceFileRecord, getSandboxWorkspaceFilePath, - listWorkspaceFiles, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { importWorkspaceFileSecretProvenanceForRuntime } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' @@ -43,6 +42,10 @@ import { hasCloudStorage, } from '@/lib/uploads/core/storage-service' import { isGeneratedDocumentSourceType } from '@/lib/uploads/utils/file-utils' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { downloadWorkspaceFileRecord } from '@/lib/workspace-files/application/read-workspace-file-record' +import { listWorkspaceFileFoldersOperation } from '@/lib/workspace-files/application/workspace-file-folders' import { extractCodeSecretNames } from '@/executor/utils/code-secret-references' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { executeTool as executeAppTool } from '@/tools' @@ -132,8 +135,15 @@ async function pushWorkspaceFileMount( mountPath: string, mounted: MountedBytes, workspaceId: string, + principal: Principal, registry?: ResolvedSecretTraceRegistry ): Promise { + record = ( + await downloadWorkspaceFileRecord.execute({ + principal, + input: { fileId: record.id, assertedWorkspaceId: workspaceId }, + }) + ).file await importMountedWorkspaceFileProvenance({ workspaceId, record, mountPath, registry }) // A generated document stores its generator source, so a presigned URL for @@ -191,7 +201,19 @@ async function pushWorkspaceFileMount( `Input file "${mountPath}" renders to more than the ${MAX_FILE_SIZE / 1024 / 1024}MB per-file mount limit, or than the mount budget left. Mount fewer or smaller files.` ) }) - : { buffer: await fetchWorkspaceFileBuffer(record), contentType: record.type } + : { + buffer: ( + await readWorkspaceFileContent.execute({ + principal, + input: { + fileId: record.id, + assertedWorkspaceId: workspaceId, + maxBytes: Math.min(MAX_FILE_SIZE, remainingBudget), + }, + }) + ).content, + contentType: record.type, + } // Keyed off the resolved type: a rendered document's source MIME is `text/x-…`, and // decoding the binary as UTF-8 would corrupt it just as surely as shipping the source. const isText = /^text\/|application\/json|application\/xml|application\/csv/.test( @@ -286,18 +308,25 @@ export async function resolveInputFiles( inputTables?: unknown[], inputDirectories?: unknown[], provenanceUserId?: string, - resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry + resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry, + filePrincipal?: Principal ): Promise { const sandboxFiles: SandboxFile[] = [] const mounted: MountedBytes = { buffered: 0, url: 0 } if (inputFiles?.length && workspaceId) { + if (!filePrincipal) { + throw new Error('Workspace file mounts require a trusted Copilot principal') + } if (inputFiles.length > MAX_MOUNTED_FILES) { throw new Error( `Too many input files (${inputFiles.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount fewer files.` ) } - const allFiles = await listWorkspaceFiles(workspaceId) + const { files: allFiles } = await listAllWorkspaceFiles.execute({ + principal: filePrincipal, + input: { workspaceId, scope: 'active' }, + }) for (const fileRef of inputFiles) { const filePath = typeof fileRef === 'string' @@ -327,14 +356,24 @@ export async function resolveInputFiles( mountPath, mounted, workspaceId, + filePrincipal, resolvedSecretTraceRegistry ) } } if (inputDirectories?.length && workspaceId) { - const folders = await listWorkspaceFileFolders(workspaceId) - const allFiles = await listWorkspaceFiles(workspaceId, { folders }) + if (!filePrincipal) { + throw new Error('Workspace directory mounts require a trusted Copilot principal') + } + const { folders } = await listWorkspaceFileFoldersOperation.execute({ + principal: filePrincipal, + input: { workspaceId }, + }) + const { files: allFiles } = await listAllWorkspaceFiles.execute({ + principal: filePrincipal, + input: { workspaceId, scope: 'active' }, + }) for (const dirRef of inputDirectories) { const dirPath = typeof dirRef === 'string' @@ -405,6 +444,7 @@ export async function resolveInputFiles( `${mountRoot}/${relativePath}`, mounted, workspaceId, + filePrincipal, resolvedSecretTraceRegistry ) } @@ -634,7 +674,10 @@ export async function executeFunctionExecute( inputTables, inputDirectories, secretActorUserId ?? context.userId, - mountedRegistry + mountedRegistry, + inputFiles.length > 0 || inputDirectories.length > 0 + ? resolveCopilotFilePrincipal(context) + : undefined ) if (resolved.length > 0) { const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || [] diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts index b789a2360a5..69848850802 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockAllocateUniqueWorkspaceFileName, + mockAdmitCreateWorkspaceFile, mockCheckStorageQuotaForBillingContext, mockDecompress, mockFetchBuffer, @@ -17,9 +18,11 @@ const { mockHeadObject, mockIncrementStorageUsageForBillingContextInTx, mockMaybeNotifyStorageLimitForBillingContext, + mockReadWorkspaceFileMetadata, mockResolveStorageBillingContext, } = vi.hoisted(() => ({ mockAllocateUniqueWorkspaceFileName: vi.fn(), + mockAdmitCreateWorkspaceFile: vi.fn(), mockCheckStorageQuotaForBillingContext: vi.fn(), mockDecompress: vi.fn(), mockFetchBuffer: vi.fn(), @@ -31,6 +34,7 @@ const { mockHeadObject: vi.fn(), mockIncrementStorageUsageForBillingContextInTx: vi.fn(), mockMaybeNotifyStorageLimitForBillingContext: vi.fn(), + mockReadWorkspaceFileMetadata: vi.fn(), mockResolveStorageBillingContext: vi.fn(), })) @@ -52,6 +56,14 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFile: mockGetWorkspaceFile, })) +vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({ + readWorkspaceFileMetadata: { execute: mockReadWorkspaceFileMetadata }, +})) + +vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({ + admitCreateWorkspaceFile: mockAdmitCreateWorkspaceFile, +})) + vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ getBoundWorkspaceFileSecretProvenance: mockGetBoundWorkspaceFileSecretProvenance, })) @@ -119,8 +131,22 @@ const context = { workspaceId: 'ws-1', userId: 'user-1', workflowId: 'wf-1', + copilotToolExecution: true, + toolCallId: 'materialize-file-test', } as ExecutionContext +mockReadWorkspaceFileMetadata.mockImplementation( + async ({ input }: { input: { fileId: string; assertedWorkspaceId?: string } }) => ({ + file: await mockGetWorkspaceFile( + input.assertedWorkspaceId ?? context.workspaceId, + input.fileId, + { + throwOnError: true, + } + ), + }) +) + const STORAGE_CONTEXT = { workspaceId: 'ws-1', billedAccountUserId: 'workspace-owner', @@ -156,9 +182,12 @@ describe('executeMaterializeFile - workspace write gate', () => { 'refuses %s without workspace write access and touches no upload', async (operation) => { const { ensureWorkspaceAccess } = await import('@/lib/copilot/tools/handlers/access') - vi.mocked(ensureWorkspaceAccess).mockRejectedValueOnce( - new Error('Write access required for this workspace') - ) + const denial = new Error('Write access required for this workspace') + if (operation === 'import') { + vi.mocked(ensureWorkspaceAccess).mockRejectedValueOnce(denial) + } else { + mockAdmitCreateWorkspaceFile.mockRejectedValueOnce(denial) + } const result = await executeMaterializeFile({ fileNames: ['a.json'], operation }, context) @@ -169,10 +198,12 @@ describe('executeMaterializeFile - workspace write gate', () => { ) it('requires write, not merely read, access', async () => { - const { ensureWorkspaceAccess } = await import('@/lib/copilot/tools/handlers/access') await executeMaterializeFile({ fileNames: ['a.json'], operation: 'save' }, context) - expect(ensureWorkspaceAccess).toHaveBeenCalledWith(context.workspaceId, context.userId, 'write') + expect(mockAdmitCreateWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'delegated', subjectUserId: context.userId }), + context.workspaceId + ) }) }) @@ -578,7 +609,11 @@ describe('executeMaterializeFile - extract operation', () => { expect.any(Buffer), expect.objectContaining({ workspaceId: 'ws-1', - userId: 'user-1', + principal: expect.objectContaining({ + kind: 'delegated', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + }), rootFolderSegments: ['bundle'], skipNoiseEntries: true, secretProvenance: { status: 'exact', entries: [] }, diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index 3c6f05e17e0..25dc2a51685 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' import { db } from '@sim/db' import { folder as folderTable, workflow, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -16,6 +17,7 @@ import { maybeNotifyStorageLimitForBillingContext, resolveStorageBillingContext, } from '@/lib/billing/storage' +import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader' @@ -31,7 +33,6 @@ import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspac import { allocateUniqueWorkspaceFileName, fetchWorkspaceFileBuffer, - getWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { hasCloudStorage, headObject } from '@/lib/uploads/core/storage-service' @@ -39,6 +40,8 @@ import { isArchiveFileName } from '@/lib/uploads/utils/file-utils' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { deduplicateWorkflowName } from '@/lib/workflows/utils' +import { admitCreateWorkspaceFile } from '@/lib/workspace-files/application/create-workspace-file' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { extractWorkflowMetadata } from '@/app/api/v1/admin/types' const logger = createLogger('MaterializeFile') @@ -80,7 +83,8 @@ function uploadBelongsToWorkspace( async function executeSave( fileName: string, chatId: string, - workspaceId: string + workspaceId: string, + principal: Principal ): Promise { const row = await findMothershipUploadRowByChatAndName(chatId, fileName) if (!row) { @@ -183,7 +187,12 @@ async function executeSave( const replayedFile = transition ? null - : await getWorkspaceFile(workspaceId, row.id, { throwOnError: true }) + : ( + await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId: row.id, assertedWorkspaceId: workspaceId }, + }) + ).file const updated = transition?.updated ?? (replayedFile ? { id: replayedFile.id, originalName: replayedFile.name } : null) @@ -371,7 +380,8 @@ async function executeExtract( fileName: string, chatId: string, workspaceId: string, - userId: string + userId: string, + principal: Principal ): Promise { const row = await findMothershipUploadRowByChatAndName(chatId, fileName) if (!row) { @@ -461,7 +471,7 @@ async function executeExtract( }) result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId, - userId, + principal, rootFolderSegments: [baseName], // The agent-facing extract drops macOS/Windows filesystem cruft so the // unpacked files/ tree only contains meaningful entries. @@ -543,6 +553,8 @@ export async function executeMaterializeFile( return { success: false, error: 'No workspace context available for materialize_file' } } + const principal = resolveCopilotFilePrincipal(context) + const operation = (params.operation as string | undefined) || 'save' // save (promote upload → workspace file), import (JSON → workflow), and extract // (decompress a .zip upload → workspace files/) are implemented. Reject anything @@ -554,10 +566,12 @@ export async function executeMaterializeFile( } } - // Every operation writes: save/extract create files, import creates a workflow. - // The handler-map path has no central permission gate. try { - await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write') + if (operation === 'import') { + await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write') + } else { + await admitCreateWorkspaceFile(principal, context.workspaceId) + } } catch (error) { return { success: false, error: getErrorMessage(error, 'Workspace write access required') } } @@ -572,9 +586,15 @@ export async function executeMaterializeFile( if (operation === 'import') { result = await executeImport(fileName, context.chatId, context.workspaceId, context.userId) } else if (operation === 'extract') { - result = await executeExtract(fileName, context.chatId, context.workspaceId, context.userId) + result = await executeExtract( + fileName, + context.chatId, + context.workspaceId, + context.userId, + principal + ) } else { - result = await executeSave(fileName, context.chatId, context.workspaceId) + result = await executeSave(fileName, context.chatId, context.workspaceId, principal) } if (result.success) { diff --git a/apps/sim/lib/copilot/tools/handlers/resources.test.ts b/apps/sim/lib/copilot/tools/handlers/resources.test.ts index 8e69e1dce8f..f470d47e6da 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.test.ts @@ -4,14 +4,29 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { getWorkspaceFileMock, resolveWorkspaceFileReferenceMock } = vi.hoisted(() => ({ - getWorkspaceFileMock: vi.fn(), - resolveWorkspaceFileReferenceMock: vi.fn(), +const { listAllWorkspaceFilesMock, readWorkspaceFileMetadataMock } = vi.hoisted(() => ({ + listAllWorkspaceFilesMock: vi.fn(), + readWorkspaceFileMetadataMock: vi.fn(), })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - getWorkspaceFile: getWorkspaceFileMock, - resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock, + findWorkspaceFileRecord: ( + files: Array<{ id: string; name: string; folderPath: string | null }> + ) => files[0] ?? null, +})) + +vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ + listAllWorkspaceFiles: { + operation: { id: 'files.list' }, + execute: listAllWorkspaceFilesMock, + }, +})) + +vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({ + readWorkspaceFileMetadata: { + operation: { id: 'files.read_metadata' }, + execute: readWorkspaceFileMetadataMock, + }, })) vi.mock('@/lib/workflows/utils', () => ({ @@ -38,20 +53,35 @@ describe('executeOpenResource', () => { }) it('opens workspace files with canonical non-UUID file ids', async () => { - getWorkspaceFileMock.mockResolvedValue({ - id: 'wf_qL_cfff-FskMsXtOdm599', - name: 'MAC_Brand_Guidelines_May_2021 (1).docx', - folderPath: null, + readWorkspaceFileMetadataMock.mockResolvedValue({ + file: { + id: 'wf_qL_cfff-FskMsXtOdm599', + name: 'MAC_Brand_Guidelines_May_2021 (1).docx', + folderPath: null, + }, }) const result = await executeOpenResource( { resources: [{ type: 'file', id: 'wf_qL_cfff-FskMsXtOdm599' }], }, - { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-1', + copilotToolExecution: true, + } ) - expect(getWorkspaceFileMock).toHaveBeenCalledWith('workspace-1', 'wf_qL_cfff-FskMsXtOdm599') + expect(readWorkspaceFileMetadataMock).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + fileId: 'wf_qL_cfff-FskMsXtOdm599', + assertedWorkspaceId: 'workspace-1', + }, + }) + ) expect(result).toMatchObject({ success: true, output: { opened: 1, errors: [] }, @@ -67,22 +97,31 @@ describe('executeOpenResource', () => { }) it('opens workspace files by canonical VFS path', async () => { - resolveWorkspaceFileReferenceMock.mockResolvedValue({ - id: 'wf_qL_cfff-FskMsXtOdm599', - name: 'MAC_Brand_Guidelines_May_2021 (1).docx', - folderPath: 'Docs', + listAllWorkspaceFilesMock.mockResolvedValue({ + files: [ + { + id: 'wf_qL_cfff-FskMsXtOdm599', + name: 'MAC_Brand_Guidelines_May_2021 (1).docx', + folderPath: 'Docs', + }, + ], }) const result = await executeOpenResource( { resources: [{ type: 'file', path: 'files/Docs/MAC_Brand_Guidelines.docx' }], }, - { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-1', + copilotToolExecution: true, + } ) - expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith( - 'workspace-1', - 'files/Docs/MAC_Brand_Guidelines.docx' + expect(listAllWorkspaceFilesMock).toHaveBeenCalledWith( + expect.objectContaining({ input: { workspaceId: 'workspace-1', scope: 'active' } }) ) expect(result).toMatchObject({ success: true, diff --git a/apps/sim/lib/copilot/tools/handlers/resources.ts b/apps/sim/lib/copilot/tools/handlers/resources.ts index 52ea670cf74..6a5e5556d4b 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.ts @@ -1,3 +1,4 @@ +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { type MothershipResource, MothershipResourceType } from '@/lib/copilot/resources/types' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' @@ -5,10 +6,12 @@ import { getKnowledgeBaseById } from '@/lib/knowledge/service' import { getLogById } from '@/lib/logs/service' import { getTableById } from '@/lib/table/service' import { - getWorkspaceFile, - resolveWorkspaceFileReference, + findWorkspaceFileRecord, + type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getWorkflowById } from '@/lib/workflows/utils' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import type { OpenResourceItem, OpenResourceParams, ValidOpenResourceParams } from './param-types' const VALID_OPEN_RESOURCE_TYPES = new Set(Object.values(MothershipResourceType)) @@ -25,11 +28,25 @@ async function resolveResource( if (!context.workspaceId) return { error: 'Opening a workspace file requires workspace context.' } const fileRef = item.path || item.id || '' - const record = item.path - ? await resolveWorkspaceFileReference(context.workspaceId, item.path) - : item.id - ? await getWorkspaceFile(context.workspaceId, item.id) - : null + let record: WorkspaceFileRecord | null + if (item.path) { + const { files } = await executeCopilotFileUseCase(context, listAllWorkspaceFiles, { + workspaceId: context.workspaceId, + scope: 'active', + }) + record = findWorkspaceFileRecord(files, item.path) + } else if (item.id) { + record = ( + await executeCopilotFileUseCase( + context, + readWorkspaceFileMetadata, + { fileId: item.id, assertedWorkspaceId: context.workspaceId }, + { fileId: item.id } + ) + ).file + } else { + record = null + } if (!record) return { error: `No workspace file found for "${fileRef}".` } resourceId = record.id title = record.name diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index 322c5524e5d..9e3665e809a 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -15,8 +15,14 @@ const mocks = vi.hoisted(() => ({ ensureWorkflowAccess: vi.fn(), getDefaultWorkspaceId: vi.fn(), getWorkspaceFileByName: vi.fn(), + resolveWorkspaceFileReference: vi.fn(), findWorkspaceFileFolderIdByPath: vi.fn(), ensureWorkspaceFileFolderPath: vi.fn(), + ensureCopilotFileFolderPath: vi.fn(), + moveWorkspaceFileItems: vi.fn(), + updateWorkspaceFileFolder: vi.fn(), + deleteWorkspaceFile: vi.fn(), + renameWorkspaceFile: vi.fn(), performMoveRenameWorkspaceFile: vi.fn(), performUpdateWorkspaceFileFolder: vi.fn(), performCreateFolder: vi.fn(), @@ -44,15 +50,85 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFileByName: mocks.getWorkspaceFileByName, })) +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference: mocks.resolveWorkspaceFileReference, +})) + vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ findWorkspaceFileFolderIdByPath: mocks.findWorkspaceFileFolderIdByPath, - ensureWorkspaceFileFolderPath: mocks.ensureWorkspaceFileFolderPath, normalizeWorkspaceFileItemName: vi.fn((name: string) => name.trim()), })) -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performMoveRenameWorkspaceFile: mocks.performMoveRenameWorkspaceFile, - performUpdateWorkspaceFileFolder: mocks.performUpdateWorkspaceFileFolder, +vi.mock('@/lib/copilot/tools/server/files/file-folder-application', () => ({ + resolveCopilotFilePrincipal: vi.fn((context, workspaceId, fileId) => ({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: context.userId, + workspaceId, + delegationId: `copilot-tool:${context.toolCallId}`, + audience: 'sim:workspace-files', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 300_000), + ...(fileId ? { resourceScope: { fileId } } : {}), + })), + ensureCopilotFileFolderPath: mocks.ensureCopilotFileFolderPath, + requireCopilotWorkspace: vi.fn((context) => context.workspaceId), +})) + +vi.mock('@/lib/workspace-files/application/move-workspace-file-items', () => ({ + moveWorkspaceFileItemsOperation: { + operation: { id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.moveWorkspaceFileItems, + }, +})) + +vi.mock('@/lib/workspace-files/application/operations', () => ({ + fileOperations: { + move: { id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow' }, + rename: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' }, + delete: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + updateFolder: { + id: 'files.folders.update', + minimumRole: 'write', + workspaceApiKey: 'allow', + }, + }, +})) + +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + updateWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateWorkspaceFileFolder, + }, +})) + +vi.mock('@/lib/workspace-files/application/delete-workspace-file', () => ({ + deleteWorkspaceFileOperation: { + operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.deleteWorkspaceFile, + }, +})) + +vi.mock('@/lib/workspace-files/application/archive-workspace-file-items', () => ({ + archiveWorkspaceFileItemsOperation: { + operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.deleteWorkspaceFile, + }, +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({})) + +vi.mock('@/lib/workspace-files/application/rename-workspace-file', () => ({ + renameWorkspaceFile: { + operation: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.renameWorkspaceFile, + }, +})) + +vi.mock('@/lib/folders/orchestration', () => ({ + createFolder: mocks.performCreateFolder, + deleteFolder: vi.fn(), + updateFolder: mocks.performUpdateFolder, })) vi.mock('@/lib/workflows/orchestration', () => ({ @@ -87,7 +163,12 @@ vi.mock('@/app/api/knowledge/utils', () => ({ import type { ExecutionContext } from '@/lib/copilot/request/types' import { executeVfsCp, executeVfsMkdir, executeVfsMv } from './vfs-mutate' -const context = { userId: 'user-1', workspaceId: 'ws-1' } as ExecutionContext +const context = { + userId: 'user-1', + workspaceId: 'ws-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} as ExecutionContext describe('vfs mv/cp', () => { beforeEach(() => { @@ -100,8 +181,29 @@ describe('vfs mv/cp', () => { mocks.verifyFolderWorkspace.mockResolvedValue(true) mocks.listFolders.mockResolvedValue([]) mocks.getWorkspaceFileByName.mockResolvedValue(null) + mocks.resolveWorkspaceFileReference.mockImplementation(async ({ reference }) => { + const segments = reference.split('/').slice(1) + const folderSegments = segments.slice(0, -1) + if (folderSegments.length > 0) { + const folderId = await mocks.findWorkspaceFileFolderIdByPath('ws-1', folderSegments) + if (!folderId) return null + return mocks.getWorkspaceFileByName('ws-1', segments.at(-1), { folderId }) + } + return mocks.getWorkspaceFileByName('ws-1', segments.at(-1), { folderId: null }) + }) mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('ensured-folder') + mocks.ensureCopilotFileFolderPath.mockResolvedValue('ensured-folder') + mocks.moveWorkspaceFileItems.mockResolvedValue({ movedItems: { files: 1, folders: 0 } }) + mocks.updateWorkspaceFileFolder.mockResolvedValue({ folder: { name: 'Reports 2025' } }) + mocks.deleteWorkspaceFile.mockResolvedValue({ + id: 'file-1', + workspaceId: 'ws-1', + deleted: true, + }) + mocks.renameWorkspaceFile.mockResolvedValue({ + file: { id: 'file-1', name: 'renamed.md' }, + }) }) afterAll(() => { @@ -150,15 +252,50 @@ describe('vfs mv/cp', () => { ) expect(result.success).toBe(false) expect(result.error).toContain('aborted') - expect(mocks.performMoveRenameWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.moveWorkspaceFileItems).not.toHaveBeenCalled() }) }) describe('files', () => { + it('routes a same-folder rename through the delegated file use case', async () => { + mocks.getWorkspaceFileByName.mockResolvedValue({ + id: 'file-1', + name: 'draft.md', + folderId: null, + }) + mocks.renameWorkspaceFile.mockResolvedValue({ + file: { id: 'file-1', name: 'final.md' }, + }) + + const result = await executeVfsMv( + { sources: ['files/draft.md'], destination: 'files/final.md' }, + context + ) + + expect(mocks.renameWorkspaceFile).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'copilot-tool:tool-call-1', + resourceScope: expect.objectContaining({ fileId: 'file-1' }), + }), + input: { + fileId: 'file-1', + assertedWorkspaceId: 'ws-1', + name: 'final.md', + }, + }) + expect(mocks.moveWorkspaceFileItems).not.toHaveBeenCalled() + expect(result).toMatchObject({ + success: true, + output: { results: [{ to: 'files/final.md', id: 'file-1' }] }, + }) + }) + it('moves and renames a file in one call, auto-creating destination folders', async () => { mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'draft.md' }) - mocks.performMoveRenameWorkspaceFile.mockResolvedValue({ - success: true, + mocks.renameWorkspaceFile.mockResolvedValue({ file: { id: 'file-1', name: 'final.md' }, }) @@ -170,18 +307,15 @@ describe('vfs mv/cp', () => { expect(mocks.getWorkspaceFileByName).toHaveBeenCalledWith('ws-1', 'draft.md', { folderId: null, }) - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - userId: 'user-1', - pathSegments: ['Reports', '2026'], - }) - expect(mocks.performMoveRenameWorkspaceFile).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - userId: 'user-1', - fileId: 'file-1', - targetFolderId: 'ensured-folder', - newName: 'final.md', - }) + expect(mocks.ensureCopilotFileFolderPath).toHaveBeenCalledWith(context, 'ws-1', [ + 'Reports', + '2026', + ]) + expect(mocks.moveWorkspaceFileItems).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ targetFolderId: 'ensured-folder' }), + }) + ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ from: 'files/draft.md', to: 'files/Reports/2026/final.md', kind: 'file' }], @@ -191,20 +325,18 @@ describe('vfs mv/cp', () => { it('moves into an existing folder keeping the name without creating anything', async () => { mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue('folder-images') mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'a.png' }) - mocks.performMoveRenameWorkspaceFile.mockResolvedValue({ - success: true, - file: { id: 'file-1', name: 'a.png' }, - }) const result = await executeVfsMv( { sources: ['files/a.png'], destination: 'files/Images' }, context ) - expect(mocks.performMoveRenameWorkspaceFile).toHaveBeenCalledWith( - expect.objectContaining({ targetFolderId: 'folder-images', newName: 'a.png' }) + expect(mocks.moveWorkspaceFileItems).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ targetFolderId: 'folder-images' }), + }) ) - expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ to: 'files/Images/a.png' }] }) }) @@ -229,8 +361,8 @@ describe('vfs mv/cp', () => { expect(result.success).toBe(false) expect(result.error).toContain('Not found') - expect(mocks.performMoveRenameWorkspaceFile).not.toHaveBeenCalled() - expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(mocks.moveWorkspaceFileItems).not.toHaveBeenCalled() + expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() }) it('rejects copying workspace files — cp is workflows-only', async () => { @@ -243,30 +375,28 @@ describe('vfs mv/cp', () => { expect(result.success).toBe(false) expect(result.error).toContain('cp only duplicates workflows') - expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() }) - it('moves and renames a file folder via performUpdateWorkspaceFileFolder', async () => { + it('moves and renames a file folder via the shared folder operation', async () => { mocks.findWorkspaceFileFolderIdByPath .mockResolvedValueOnce(null) // destination is not an existing folder .mockResolvedValueOnce('folder-src') // source resolves as folder - mocks.performUpdateWorkspaceFileFolder.mockResolvedValue({ - success: true, - folder: { name: 'Reports 2025' }, - }) const result = await executeVfsMv( { sources: ['files/Reports'], destination: 'files/Archive/Reports 2025' }, context ) - expect(mocks.performUpdateWorkspaceFileFolder).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - folderId: 'folder-src', - userId: 'user-1', - name: 'Reports 2025', - parentId: 'ensured-folder', - }) + expect(mocks.updateWorkspaceFileFolder).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + folderId: 'folder-src', + name: 'Reports 2025', + parentId: 'ensured-folder', + }), + }) + ) expect(result.success).toBe(true) }) }) @@ -380,11 +510,10 @@ describe('vfs mv/cp', () => { it('creates a nested file folder chain', async () => { const result = await executeVfsMkdir({ paths: ['files/Reports/2026'] }, context) - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - userId: 'user-1', - pathSegments: ['Reports', '2026'], - }) + expect(mocks.ensureCopilotFileFolderPath).toHaveBeenCalledWith(context, 'ws-1', [ + 'Reports', + '2026', + ]) expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ from: 'files/Reports/2026', to: 'files/Reports/2026', kind: 'file_folder' }], @@ -398,6 +527,7 @@ describe('vfs mv/cp', () => { const result = await executeVfsMkdir({ paths: ['workflows/Archive'] }, context) expect(mocks.performCreateFolder).toHaveBeenCalledWith({ + resourceType: 'workflow', workspaceId: 'ws-1', userId: 'user-1', name: 'Archive', @@ -415,7 +545,7 @@ describe('vfs mv/cp', () => { expect(result.output).toMatchObject({ results: [{ from: 'tables/CRM', error: expect.stringContaining('flat namespace') }], }) - expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() }) it('rejects creation inside a locked workflow folder', async () => { diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index c0fa82f81c6..d0a4779348c 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -3,12 +3,17 @@ import { createLogger } from '@sim/logger' import { assertFolderMutable, assertWorkflowMutable } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' +import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { ensureWorkflowAccess, ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { - ensureWorkflowAccess, - ensureWorkspaceAccess, - getDefaultWorkspaceId, -} from '@/lib/copilot/tools/handlers/access' + ensureCopilotFileFolderPath, + requireCopilotWorkspace, +} from '@/lib/copilot/tools/server/files/file-folder-application' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { buildVfsFolderPathMap, @@ -16,6 +21,7 @@ import { decodeVfsPathSegments, encodeVfsPathSegments, } from '@/lib/copilot/vfs/path-utils' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { createFolder, deleteFolder, updateFolder } from '@/lib/folders/orchestration' import { @@ -25,24 +31,17 @@ import { } from '@/lib/knowledge/service' import { performDeleteTable, performRenameTable } from '@/lib/table/orchestration' import { listTables } from '@/lib/table/service' -import { - ensureWorkspaceFileFolderPath, - findWorkspaceFileFolderIdByPath, - normalizeWorkspaceFileItemName, -} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - getWorkspaceFileByName, - resolveWorkspaceFileReference, - type WorkspaceFileRecord, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' -import { - performDeleteWorkspaceFileItems, - performMoveRenameWorkspaceFile, - performUpdateWorkspaceFileFolder, -} from '@/lib/workspace-files/orchestration' +import { archiveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/archive-workspace-file-items' +import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' +import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' +import { updateWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('VfsMutateTools') @@ -157,7 +156,7 @@ export async function executeVfsMkdir( return { success: false, error: 'paths is required (an array of folder VFS paths)' } } - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + const workspaceId = requireCopilotWorkspace(context) await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertMutationNotAborted(context) @@ -186,11 +185,7 @@ export async function executeVfsMkdir( assertMutationNotAborted(context) let folderId: string | null if (top === 'files') { - folderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId: context.userId, - pathSegments: segments, - }) + folderId = await ensureCopilotFileFolderPath(context, workspaceId, segments) } else { ensureWorkflowFolder ??= makeWorkflowFolderEnsurer( workspaceId, @@ -206,13 +201,25 @@ export async function executeVfsMkdir( id: folderId ?? undefined, }) } catch (error) { - outcomes.push({ from: path, kind, error: toError(error).message }) + outcomes.push({ + from: path, + kind, + error: + top === 'files' + ? messageForCopilotFileError(error, 'File folder creation failed') + : toError(error).message, + }) } } return buildResult('mkdir', outcomes) } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: context.abortSignal?.aborted + ? 'Request aborted before the mutation could be applied.' + : 'Mutation failed', + } } } @@ -231,7 +238,7 @@ async function executeVfsMutate( return { success: false, error: 'destination is required' } } - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + const workspaceId = requireCopilotWorkspace(context) await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertMutationNotAborted(context) @@ -267,7 +274,12 @@ async function executeVfsMutate( return await renameFlatResource(verb, category, sources, destination, context, workspaceId) } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: context.abortSignal?.aborted + ? 'Request aborted before the mutation could be applied.' + : 'Mutation failed', + } } } @@ -340,15 +352,19 @@ async function planDestination(args: { */ async function resolveFileAtExactPath( workspaceId: string, - segments: string[] + segments: string[], + context: ExecutionContext ): Promise { - const fileName = normalizeWorkspaceFileItemName(segments.at(-1) ?? '', 'File') - if (segments.length === 1) { - return getWorkspaceFileByName(workspaceId, fileName, { folderId: null }) + try { + return await resolveCopilotWorkspaceFileReference(context, fileOperations.move, { + workspaceId, + reference: `files/${encodeVfsPathSegments(segments)}`, + }) + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code !== 'not_found') throw error + return null } - const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments.slice(0, -1)) - if (!folderId) return null - return getWorkspaceFileByName(workspaceId, fileName, { folderId }) } async function mutateWorkspaceFiles( @@ -368,12 +384,7 @@ async function mutateWorkspaceFiles( destination, sourceCount: sources.length, lookupFolder: (segments) => findWorkspaceFileFolderIdByPath(workspaceId, segments), - ensureFolderPath: (segments) => - ensureWorkspaceFileFolderPath({ - workspaceId, - userId: context.userId, - pathSegments: segments, - }), + ensureFolderPath: (segments) => ensureCopilotFileFolderPath(context, workspaceId, segments), }) if ('error' in dest) return { success: false, error: dest.error } @@ -390,7 +401,7 @@ async function mutateWorkspaceFiles( refs.push({ source, error: 'Source must name a file or folder under files/' }) continue } - const file = await resolveFileAtExactPath(workspaceId, segments) + const file = await resolveFileAtExactPath(workspaceId, segments, context) if (file) { refs.push({ source, file }) continue @@ -411,23 +422,67 @@ async function mutateWorkspaceFiles( assertMutationNotAborted(context) const targetName = dest.dirMode ? ref.file.name : (dest.leafName as string) const targetFolderId = await dest.ensureFolderId() - const result = await performMoveRenameWorkspaceFile({ - workspaceId, - userId: context.userId, - fileId: ref.file.id, - targetFolderId, - newName: targetName, - }) - outcomes.push( - result.success && result.file - ? { - from: ref.source, - to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.file.name])}`, - kind: 'file', - id: ref.file.id, - } - : { from: ref.source, kind: 'file', error: result.error || 'Failed to move file' } - ) + if (targetFolderId === ref.file.folderId) { + try { + const result = await executeCopilotFileUseCase( + context, + renameWorkspaceFile, + { + fileId: ref.file.id, + assertedWorkspaceId: workspaceId, + name: targetName, + }, + { fileId: ref.file.id } + ) + outcomes.push({ + from: ref.source, + to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.file.name])}`, + kind: 'file', + id: ref.file.id, + }) + } catch (error) { + outcomes.push({ + from: ref.source, + kind: 'file', + error: messageForCopilotFileError(error), + }) + } + continue + } + try { + await executeCopilotFileUseCase( + context, + moveWorkspaceFileItemsOperation, + { workspaceId, fileIds: [ref.file.id], targetFolderId }, + { fileId: ref.file.id } + ) + let finalName = ref.file.name + if (targetName !== ref.file.name) { + const renamed = await executeCopilotFileUseCase( + context, + renameWorkspaceFile, + { + fileId: ref.file.id, + assertedWorkspaceId: workspaceId, + name: targetName, + }, + { fileId: ref.file.id } + ) + finalName = renamed.file.name + } + outcomes.push({ + from: ref.source, + to: `files/${encodeVfsPathSegments([...dest.folderSegments, finalName])}`, + kind: 'file', + id: ref.file.id, + }) + } catch (error) { + outcomes.push({ + from: ref.source, + kind: 'file', + error: messageForCopilotFileError(error, 'Failed to move file'), + }) + } continue } @@ -441,23 +496,26 @@ async function mutateWorkspaceFiles( }) continue } - const result = await performUpdateWorkspaceFileFolder({ - workspaceId, - folderId: ref.folderId, - userId: context.userId, - name: dest.dirMode ? undefined : dest.leafName, - parentId: targetFolderId, - }) - outcomes.push( - result.success && result.folder - ? { - from: ref.source, - to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.folder.name])}`, - kind: 'file_folder', - id: ref.folderId, - } - : { from: ref.source, kind: 'file_folder', error: result.error || 'Failed to move folder' } - ) + try { + const result = await executeCopilotFileUseCase(context, updateWorkspaceFileFolderOperation, { + workspaceId, + folderId: ref.folderId, + name: dest.dirMode ? undefined : dest.leafName, + parentId: targetFolderId, + }) + outcomes.push({ + from: ref.source, + to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.folder.name])}`, + kind: 'file_folder', + id: ref.folderId, + }) + } catch (error) { + outcomes.push({ + from: ref.source, + kind: 'file_folder', + error: messageForCopilotFileError(error, 'Failed to move folder'), + }) + } } return buildResult(verb, outcomes) @@ -818,7 +876,7 @@ export async function executeVfsRm( return { success: false, error: 'paths is required (an array of VFS paths to delete)' } } - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + const workspaceId = requireCopilotWorkspace(context) await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertMutationNotAborted(context) @@ -839,13 +897,25 @@ export async function executeVfsRm( await removeOne(classified.category, path, context, workspaceId, getWorkflowIndex) ) } catch (error) { - outcomes.push({ from: path, kind: defaultKindFor(path), error: toError(error).message }) + outcomes.push({ + from: path, + kind: defaultKindFor(path), + error: + classified.category === 'files' + ? messageForCopilotFileError(error, 'File deletion failed') + : toError(error).message, + }) } } return buildResult('rm', outcomes) } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: context.abortSignal?.aborted + ? 'Request aborted before the mutation could be applied.' + : 'Delete failed', + } } } @@ -893,16 +963,23 @@ async function removeWorkspaceFilePath( context: ExecutionContext, workspaceId: string ): Promise { - const file = await resolveWorkspaceFileReference(workspaceId, path) - if (file) { - const result = await performDeleteWorkspaceFileItems({ + let file: WorkspaceFileRecord | undefined + try { + file = await resolveCopilotWorkspaceFileReference(context, fileOperations.delete, { workspaceId, - userId: context.userId, - fileIds: [file.id], + reference: path, }) - if (!result.success) { - return { from: path, kind: 'file', id: file.id, error: result.error || 'Failed to delete' } - } + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code !== 'not_found') throw error + } + if (file) { + await executeCopilotFileUseCase( + context, + deleteWorkspaceFileOperation, + { fileId: file.id, assertedWorkspaceId: workspaceId }, + { fileId: file.id } + ) logger.info('Deleted workspace file via rm', { fileId: file.id, workspaceId }) return { from: path, kind: 'file', id: file.id } } @@ -914,21 +991,21 @@ async function removeWorkspaceFilePath( const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) if (!folderId) return { from: path, kind: 'file', error: `Not found: ${path}` } - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId: context.userId, - folderIds: [folderId], - }) - if (!result.success) { + try { + const result = await executeCopilotFileUseCase(context, archiveWorkspaceFileItemsOperation, { + workspaceId, + folderIds: [folderId], + }) + logger.info('Deleted file folder via rm', { folderId, workspaceId }) + return { from: path, kind: 'file_folder', id: folderId } + } catch (error) { return { from: path, kind: 'file_folder', id: folderId, - error: result.error || 'Failed to delete', + error: messageForCopilotFileError(error, 'Failed to delete'), } } - logger.info('Deleted file folder via rm', { folderId, workspaceId }) - return { from: path, kind: 'file_folder', id: folderId } } interface WorkflowRemoveIndex { diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index 8593b714946..3aade59a519 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -1,9 +1,9 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { getOrMaterializeVFS } from '@/lib/copilot/vfs' import type { GrepCountEntry, GrepMatch } from '@/lib/copilot/vfs/operations' import { WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations' @@ -27,14 +27,19 @@ const logger = createLogger('VfsTools') * viewer (unrevealed previews, kill-switched types). Visibility is memoized per * (userId, workspaceId), so repeated tool calls in one turn resolve once. */ -async function getGatedVFS( - workspaceId: string, - userId: string, - secretMountPolicy?: SecretMountPolicy -) { - const vis = await getBlockVisibilityForCopilot(userId, workspaceId) +async function getGatedVFS(context: ExecutionContext) { + const workspaceId = context.workspaceId + if (!workspaceId) throw new Error('No workspace context available') + const vis = await getBlockVisibilityForCopilot(context.userId, workspaceId) + const filePrincipal = + context.copilotToolExecution && context.toolCallId + ? resolveCopilotFilePrincipal(context) + : undefined return withBlockVisibility(vis, () => - getOrMaterializeVFS(workspaceId, userId, { secretMountPolicy }) + getOrMaterializeVFS(workspaceId, context.userId, { + secretMountPolicy: context.secretMountPolicy, + filePrincipal, + }) ) } @@ -170,7 +175,7 @@ export async function executeVfsGrep( result = envelope.value provenanceFile = envelope.file } else { - const vfs = await getGatedVFS(workspaceId, context.userId, context.secretMountPolicy) + const vfs = await getGatedVFS(context) if (isWorkspaceFileGrepPath(rawPath)) { const envelope = await vfs.grepFileWithProvenance(rawPath, pattern, grepOptions) result = envelope.value @@ -238,7 +243,7 @@ export async function executeVfsGlob( } try { - const vfs = await getGatedVFS(workspaceId, context.userId, context.secretMountPolicy) + const vfs = await getGatedVFS(context) let files = vfs.glob(pattern) if (context.chatId && (pattern === 'uploads/*' || pattern.startsWith('uploads/'))) { @@ -354,7 +359,7 @@ export async function executeVfsRead( } } - const vfs = await getGatedVFS(workspaceId, context.userId, context.secretMountPolicy) + const vfs = await getGatedVFS(context) // Plain canonical file leaves are metadata resources. Dynamic file content // and inspection paths use explicit suffixes like /content, /style, diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts index 41e1de535c3..fb250666e14 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts @@ -1,9 +1,9 @@ import { toError } from '@sim/utils/errors' import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { formatNormalizedWorkflowForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' import { mcpService } from '@/lib/mcp/service' -import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { getEffectiveBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs' import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator' import { getBlockReferenceTags } from '@/lib/workflows/blocks/block-reference-tags' @@ -16,6 +16,7 @@ import { import { resolveTriggerRunOptions, toPublicRunOption } from '@/lib/workflows/triggers/run-options' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { getWorkflowById } from '@/lib/workflows/utils' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' import { listUserWorkspaces } from '@/lib/workspaces/utils' import { getBlock } from '@/blocks/registry' import { normalizeName } from '@/executor/constants' @@ -191,7 +192,10 @@ export async function executeGetWorkflowData( if (!workspaceId) { return { success: false, error: 'workspaceId is required' } } - const files = await listWorkspaceFiles(workspaceId) + const { files } = await executeCopilotFileUseCase(context, listAllWorkspaceFiles, { + workspaceId, + scope: 'active', + }) const fileResults = files.map((file) => ({ id: String(file.id || ''), name: String(file.name || ''), diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts new file mode 100644 index 00000000000..3b205556cce --- /dev/null +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts @@ -0,0 +1,45 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const routeExecution = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/copilot/tools/server/router', () => ({ routeExecution })) + +import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' + +describe('server tool adapter authority boundary', () => { + beforeEach(() => { + vi.clearAllMocks() + routeExecution.mockResolvedValue({ success: true }) + }) + + it('overwrites model-supplied workspace scope and forwards trusted delegation context', async () => { + const handler = createServerToolHandler('workspace_file') + + await handler( + { workspaceId: 'attacker-workspace', operation: 'rename' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, + } + ) + + expect(routeExecution).toHaveBeenCalledWith( + 'workspace_file', + expect.objectContaining({ workspaceId: 'workspace-1', operation: 'rename' }), + expect.objectContaining({ + userId: 'user-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, + }) + ) + }) +}) diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts index 359ed2a4023..552ed7a7b32 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts @@ -11,13 +11,15 @@ export function createServerToolHandler(toolId: string): ToolHandler { const enrichedParams = { ...params } if (!enrichedParams.workflowId && context.workflowId) enrichedParams.workflowId = context.workflowId - if (!enrichedParams.workspaceId && context.workspaceId) - enrichedParams.workspaceId = context.workspaceId + if (context.workspaceId) enrichedParams.workspaceId = context.workspaceId try { const result = await routeExecution(toolId, enrichedParams, { userId: context.userId, workspaceId: context.workspaceId, + executionId: context.executionId, + toolCallId: context.toolCallId, + copilotToolExecution: context.copilotToolExecution, billingAttribution: context.billingAttribution, userPermission: context.userPermission ?? undefined, chatId: context.chatId, diff --git a/apps/sim/lib/copilot/tools/server/base-tool.ts b/apps/sim/lib/copilot/tools/server/base-tool.ts index b1db4a7482d..2af97ba7490 100644 --- a/apps/sim/lib/copilot/tools/server/base-tool.ts +++ b/apps/sim/lib/copilot/tools/server/base-tool.ts @@ -5,6 +5,11 @@ import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secr export interface ServerToolContext { userId: string workspaceId?: string + executionId?: string + /** Stable, server-issued identity of the tool call currently executing. */ + toolCallId?: string + /** True only for contexts built by the authenticated Copilot execution pipeline. */ + copilotToolExecution?: boolean billingAttribution?: BillingAttributionSnapshot userPermission?: string chatId?: string diff --git a/apps/sim/lib/copilot/tools/server/files/create-file.ts b/apps/sim/lib/copilot/tools/server/files/create-file.ts index cd950eb3406..430545f8b0a 100644 --- a/apps/sim/lib/copilot/tools/server/files/create-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/create-file.ts @@ -1,12 +1,16 @@ import { createLogger } from '@sim/logger' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' +import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' -import { inferContentType } from './workspace-file' +import { inferContentType } from '@/lib/copilot/tools/server/files/workspace-file' +import { + createWorkspaceFileByPath, + updateWorkspaceFileContentByPath, +} from '@/lib/workspace-files/application/write-workspace-file-by-path' const logger = createLogger('CreateFileServerTool') const CREATE_FILE_TOOL_ID = 'create_file' @@ -39,8 +43,6 @@ export const createFileServerTool: BaseServerTool { return ids } -async function stageReferencedImages(source: string, workspaceId: string): Promise { +async function stageReferencedImages( + source: string, + workspaceId: string, + principal: Principal +): Promise { const ids = collectReferencedFileIds(source) if (ids.size > MAX_STAGED_INPUTS) { throw new Error( @@ -150,9 +153,13 @@ async function stageReferencedImages(source: string, workspaceId: string): Promi const files: SandboxFile[] = [] let totalBytes = 0 for (const fileId of ids) { - let record: Awaited> + let record: Awaited>['file'] try { - record = await getWorkspaceFile(workspaceId, fileId) + const metadata = await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId, assertedWorkspaceId: workspaceId }, + }) + record = metadata.file } catch (err) { logger.warn('Failed to resolve referenced image for doc compile', { workspaceId, @@ -177,7 +184,15 @@ async function stageReferencedImages(source: string, workspaceId: string): Promi } let buffer: Buffer try { - buffer = await fetchWorkspaceFileBuffer(record) + const content = await readWorkspaceFileContent.execute({ + principal, + input: { + fileId: record.id, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_STAGED_FILE_BYTES, + }, + }) + buffer = content.content } catch (err) { logger.warn('Failed to stage referenced image for doc compile', { workspaceId, @@ -241,6 +256,7 @@ interface CompileArgs { source: string fileName: string workspaceId: string + filePrincipal: Principal } /** @@ -250,10 +266,10 @@ interface CompileArgs { * Internal — callers use compileDoc (load-or-build + store). */ async function compileDocViaE2BPython( - { source, workspaceId }: CompileArgs, + { source, workspaceId, filePrincipal }: CompileArgs, fmt: E2BDocFormat ): Promise { - const sandboxFiles = await stageReferencedImages(source, workspaceId) + const sandboxFiles = await stageReferencedImages(source, workspaceId, filePrincipal) const outputSandboxPath = `/home/user/output.${fmt.ext}` // openpyxl writes formula strings but no cached values, so a web viewer (SheetJS) @@ -331,10 +347,10 @@ fs.writeFileSync('/home/user/output.docx', __buf); * engines. Throws DocCompileUserError on a script error. */ async function compileDocViaE2BNode( - { source, fileName, workspaceId }: CompileArgs, + { source, fileName, workspaceId, filePrincipal }: CompileArgs, ext: 'pptx' | 'docx' ): Promise { - const sandboxFiles = await stageReferencedImages(source, workspaceId) + const sandboxFiles = await stageReferencedImages(source, workspaceId, filePrincipal) const outputSandboxPath = `/home/user/output.${ext}` const preamble = ext === 'pptx' ? PPTX_NODE_PREAMBLE : DOCX_NODE_PREAMBLE const finalize = ext === 'pptx' ? PPTX_NODE_FINALIZE : DOCX_NODE_FINALIZE @@ -394,7 +410,7 @@ ${finalize} export async function compileDoc( args: CompileArgs ): Promise<{ buffer: Buffer; contentType: string }> { - const { source, fileName, workspaceId } = args + const { source, fileName, workspaceId, filePrincipal } = args const fmt = await getE2BDocFormat(fileName) if (!fmt) throw new Error(`Unsupported document format: ${fileName}`) @@ -403,8 +419,11 @@ export async function compileDoc( const buffer = fmt.engine === 'node' - ? await compileDocViaE2BNode({ source, fileName, workspaceId }, fmt.ext as 'pptx' | 'docx') - : await compileDocViaE2BPython({ source, fileName, workspaceId }, fmt) + ? await compileDocViaE2BNode( + { source, fileName, workspaceId, filePrincipal }, + fmt.ext as 'pptx' | 'docx' + ) + : await compileDocViaE2BPython({ source, fileName, workspaceId, filePrincipal }, fmt) await storeCompiledDoc(workspaceId, source, fmt.ext, fmt.contentType, buffer) return { buffer, contentType: fmt.contentType } } diff --git a/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts b/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts index c85b9249110..08dc72e16d4 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts @@ -1,3 +1,4 @@ +import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { CodeLanguage } from '@/lib/execution/languages' import { executeInSandbox } from '@/lib/execution/remote-sandbox' @@ -101,12 +102,14 @@ export async function runE2BCompiledCheck(args: { fileName: string workspaceId: string ext: string + principal: Principal }): Promise { try { const compiled = await compileDoc({ source: args.source, fileName: args.fileName, workspaceId: args.workspaceId, + filePrincipal: args.principal, }) if (args.ext === 'xlsx') { const recalc = await recalcXlsx({ binary: compiled.buffer, workspaceId: args.workspaceId }) diff --git a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts index d5ec0650171..ff1f7b10f26 100644 --- a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts @@ -1,20 +1,24 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { z } from 'zod' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' +import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' import { DownloadToWorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { getExtensionFromMimeType, getFileExtension, getMimeTypeFromExtension, } from '@/lib/uploads/utils/file-utils' +import { + createWorkspaceFileByPath, + updateWorkspaceFileContentByPath, +} from '@/lib/workspace-files/application/write-workspace-file-by-path' const logger = createLogger('DownloadToWorkspaceFileTool') @@ -152,8 +156,6 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< if (!workspaceId) { return { success: false, message: 'Workspace ID is required' } } - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - try { assertServerToolNotAborted(context) @@ -189,17 +191,19 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< } assertServerToolNotAborted(context) - const written = await writeWorkspaceFileByPath({ + const mode = outputFile?.mode ?? 'create' + const writeInput = { workspaceId, - userId: context.userId, - target: { - path: outputPath, - mode: outputFile?.mode ?? 'create', - mimeType: outputFile?.mimeType, - }, - buffer: fileBuffer, - inferredMimeType: outputFile?.mimeType ?? mimeType, - }) + path: outputPath, + mode, + content: fileBuffer.toString('base64'), + encoding: 'base64' as const, + contentType: outputFile?.mimeType ?? mimeType, + } + const written = + mode === 'overwrite' + ? await executeCopilotFileUseCase(context, updateWorkspaceFileContentByPath, writeInput) + : await executeCopilotFileUseCase(context, createWorkspaceFileByPath, writeInput) logger.info('Downloaded remote file to workspace', { sourceUrl: params.url, @@ -224,7 +228,10 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< url: params.url, error: msg, }) - return { success: false, message: `Failed to download file: ${msg}` } + return { + success: false, + message: `Failed to download file: ${messageForCopilotFileError(error, 'Unable to write downloaded file')}`, + } } }, } diff --git a/apps/sim/lib/copilot/tools/server/files/edit-content.ts b/apps/sim/lib/copilot/tools/server/files/edit-content.ts index c09a39beb9d..0f453ebd678 100644 --- a/apps/sim/lib/copilot/tools/server/files/edit-content.ts +++ b/apps/sim/lib/copilot/tools/server/files/edit-content.ts @@ -1,12 +1,17 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' +import { + messageForCopilotFileError, + resolveCopilotFilePrincipal, +} from '@/lib/copilot/auth/file-delegation' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' -import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { updateWorkspaceFileContent } from '@/lib/workspace-files/application/update-workspace-file-content' import { getE2BDocFormat } from './doc-compile' import { buildEmbeddedImageRefWarning } from './embedded-image-refs' import { consumeLatestFileIntent } from './file-intent-store' @@ -219,10 +224,12 @@ export const editContentServerTool: BaseServerTool { + let parentId: string | null = null + for (const [index, name] of pathSegments.entries()) { + const existing = await findWorkspaceFileFolderIdByPath( + workspaceId, + pathSegments.slice(0, index + 1) + ) + if (existing) { + parentId = existing + continue + } + const result: Awaited> = + await executeCopilotFileUseCase(context, createWorkspaceFileFolderOperation, { + workspaceId, + name, + parentId, + }) + parentId = result.folder.id + } + return parentId +} diff --git a/apps/sim/lib/copilot/tools/server/files/file-folders.ts b/apps/sim/lib/copilot/tools/server/files/file-folders.ts index 22c26d4b7b3..c9357a6d796 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-folders.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-folders.ts @@ -1,25 +1,32 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' +import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { + ensureCopilotFileFolderPath, + requireCopilotWorkspace, +} from '@/lib/copilot/tools/server/files/file-folder-application' import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { - ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath, getWorkspaceFileFolder, - listWorkspaceFileFolders, type WorkspaceFileFolderRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { - performCreateWorkspaceFileFolder, - performMoveWorkspaceFileItems, - performUpdateWorkspaceFileFolder, -} from '@/lib/workspace-files/orchestration' + createWorkspaceFileFolderOperation, + listWorkspaceFileFoldersOperation, + updateWorkspaceFileFolderOperation, +} from '@/lib/workspace-files/application/workspace-file-folders' const logger = createLogger('FileFolderServerTools') @@ -134,7 +141,8 @@ async function resolveOptionalFolderId( async function resolveFileIdsFromPaths( workspaceId: string, - paths: string[] + paths: string[], + context: ServerToolContext ): Promise<{ fileIds: string[] failed: string[] @@ -142,33 +150,34 @@ async function resolveFileIdsFromPaths( const fileIds: string[] = [] const failed: string[] = [] for (const path of paths) { - const file = await resolveWorkspaceFileReference(workspaceId, path) - if (!file) { + try { + const file = await resolveCopilotWorkspaceFileReference(context, fileOperations.move, { + workspaceId, + reference: path, + }) + fileIds.push(file.id) + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code !== 'not_found') throw error failed.push(path) - continue } - fileIds.push(file.id) } return { fileIds, failed } } async function resolveWorkspaceId( params: WorkspaceScopedArgs, - context: ServerToolContext | undefined, - permission: 'read' | 'write' + context: ServerToolContext | undefined ): Promise { if (!context?.userId) { throw new Error('Authentication required') } const payload = nested(params) - const workspaceId = - stringValue(params.workspaceId) || stringValue(payload?.workspaceId) || context.workspaceId - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } + const assertedWorkspaceId = + stringValue(params.workspaceId) || stringValue(payload?.workspaceId) || undefined + const workspaceId = requireCopilotWorkspace(context, assertedWorkspaceId) - await ensureWorkspaceAccess(workspaceId, context.userId, permission) return workspaceId } @@ -183,10 +192,13 @@ export const listFileFoldersServerTool: BaseServerTool { try { - const workspaceId = await resolveWorkspaceId(params, context, 'read') + const workspaceId = await resolveWorkspaceId(params, context) if (typeof workspaceId !== 'string') return workspaceId - const folders = await listWorkspaceFileFolders(workspaceId) + const result = await executeCopilotFileUseCase(context, listWorkspaceFileFoldersOperation, { + workspaceId, + }) + const folders = result.folders return { success: true, message: @@ -194,7 +206,10 @@ export const listFileFoldersServerTool: BaseServerTool { try { - const workspaceId = await resolveWorkspaceId(params, context, 'write') + const workspaceId = await resolveWorkspaceId(params, context) if (typeof workspaceId !== 'string') return workspaceId if (!context?.userId) throw new Error('Authentication required') @@ -226,23 +241,19 @@ export const createFileFolderServerTool: BaseServerTool 1) { - parentId = await ensureWorkspaceFileFolderPath({ + parentId = await ensureCopilotFileFolderPath( + context, workspaceId, - userId: context.userId, - pathSegments: pathSegments.slice(0, -1), - }) + pathSegments.slice(0, -1) + ) } assertServerToolNotAborted(context) - const result = await performCreateWorkspaceFileFolder({ + const result = await executeCopilotFileUseCase(context, createWorkspaceFileFolderOperation, { workspaceId, - userId: context.userId, name, parentId, }) - if (!result.success || !result.folder) { - return { success: false, message: result.error || 'Failed to create file folder' } - } const { folder } = result logger.info('File folder created via create_file_folder', { @@ -258,7 +269,10 @@ export const createFileFolderServerTool: BaseServerTool { try { - const workspaceId = await resolveWorkspaceId(params, context, 'write') + const workspaceId = await resolveWorkspaceId(params, context) if (typeof workspaceId !== 'string') return workspaceId if (!context?.userId) throw new Error('Authentication required') @@ -289,15 +303,11 @@ export const renameFileFolderServerTool: BaseServerTool { try { - const workspaceId = await resolveWorkspaceId(params, context, 'write') + const workspaceId = await resolveWorkspaceId(params, context) if (typeof workspaceId !== 'string') return workspaceId if (!context?.userId) throw new Error('Authentication required') @@ -347,15 +360,11 @@ export const moveFileFolderServerTool: BaseServerTool name: 'move_file', async execute(params: MoveFileArgs, context?: ServerToolContext): Promise { try { - const workspaceId = await resolveWorkspaceId(params, context, 'write') + const workspaceId = await resolveWorkspaceId(params, context) if (typeof workspaceId !== 'string') return workspaceId if (!context?.userId) throw new Error('Authentication required') const payload = nested(params) const paths = stringListFromValues(params.paths, payload?.paths, params.path, payload?.path) const resolvedByPath = - paths.length > 0 ? await resolveFileIdsFromPaths(workspaceId, paths) : undefined + paths.length > 0 ? await resolveFileIdsFromPaths(workspaceId, paths, context) : undefined if (resolvedByPath?.failed.length) { return { success: false, @@ -412,15 +424,11 @@ export const moveFileServerTool: BaseServerTool null assertServerToolNotAborted(context) - const result = await performMoveWorkspaceFileItems({ + const result = await executeCopilotFileUseCase(context, moveWorkspaceFileItemsOperation, { workspaceId, - userId: context.userId, fileIds, targetFolderId: folderId, }) - if (!result.success || !result.movedItems) { - return { success: false, message: result.error || 'Failed to move files' } - } logger.info('Files moved via move_file', { workspaceId, @@ -438,7 +446,7 @@ export const moveFileServerTool: BaseServerTool data: result.movedItems, } } catch (error) { - return { success: false, message: toError(error).message } + return { success: false, message: messageForCopilotFileError(error, 'Failed to move files') } } }, } diff --git a/apps/sim/lib/copilot/tools/server/files/file-preview.ts b/apps/sim/lib/copilot/tools/server/files/file-preview.ts index 11b09b61c3a..d99f219021b 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-preview.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-preview.ts @@ -1,11 +1,11 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { - fetchWorkspaceFileBuffer, - getWorkspaceFile, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' const logger = createLogger('CopilotFilePreview') +const MAX_PREVIEW_SOURCE_BYTES = 5 * 1024 * 1024 type FilePreviewEdit = { strategy?: string @@ -147,13 +147,21 @@ export interface WorkspaceFilePreviewBase { } export async function loadWorkspaceFileTextForPreview( + context: ExecutionContext, workspaceId: string, fileId: string ): Promise { try { - const record = await getWorkspaceFile(workspaceId, fileId) - if (!record) return undefined - const buffer = await fetchWorkspaceFileBuffer(record) + const { content: buffer } = await executeCopilotFileUseCase( + context, + readWorkspaceFileContent, + { + fileId, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_PREVIEW_SOURCE_BYTES, + }, + { fileId } + ) return { text: buffer.toString('utf-8'), } diff --git a/apps/sim/lib/copilot/tools/server/files/rename-file.ts b/apps/sim/lib/copilot/tools/server/files/rename-file.ts index a6f9e9f63c8..807e42e1a39 100644 --- a/apps/sim/lib/copilot/tools/server/files/rename-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/rename-file.ts @@ -1,15 +1,18 @@ import { createLogger } from '@sim/logger' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' +import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { - getWorkspaceFile, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { performRenameWorkspaceFile } from '@/lib/workspace-files/orchestration' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' +import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' import { validateFlatWorkspaceFileName } from './workspace-file' const logger = createLogger('RenameFileServerTool') @@ -45,8 +48,6 @@ export const renameFileServerTool: BaseServerTool if (!workspaceId) { return { success: false, message: 'Workspace ID is required' } } - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - const nested = params.args const path = params.path || (nested?.path as string) || '' const legacyFileId = params.fileId || (nested?.fileId as string) || '' @@ -72,86 +67,80 @@ export const shareFileServerTool: BaseServerTool const targetRef = path || legacyFileId if (!targetRef) return { success: false, message: 'path is required' } - const existingFile = path - ? await resolveWorkspaceFileReference(workspaceId, path) - : await getWorkspaceFile(workspaceId, legacyFileId) - if (!existingFile) { + let existingFile + try { + existingFile = path + ? await resolveCopilotWorkspaceFileReference(context, fileOperations.updateShare, { + workspaceId, + reference: path, + }) + : ( + await executeCopilotFileUseCase( + context, + readWorkspaceFileMetadata, + { fileId: legacyFileId, assertedWorkspaceId: workspaceId }, + { fileId: legacyFileId } + ) + ).file + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code !== 'not_found') throw error return { success: false, message: `File not found: ${targetRef}` } } - const fileId = existingFile.id - const isActive = action !== 'unshare' - const existingShare = await getShareForResource('file', fileId) - - // Unsharing a file that was never shared (or is already disabled) is a no-op: - // never insert an inactive row, emit a FILE_SHARE_DISABLED audit, or return a - // link claiming a share was revoked when none existed. - if (!isActive && !existingShare?.isActive) { - return { - success: true, - message: `"${existingFile.name}" isn't shared — nothing to unshare.`, - } - } - - // Enabling a share is gated by the org's access-control policy (both the - // master on/off and the per-auth-type allow-list); disabling is always - // allowed so users can still un-share after the policy is turned on. - if (isActive) { - // Validate the auth type that will ACTUALLY be persisted. upsertFileShare - // falls back to the existing share's authType when none is passed, so a bare - // re-enable must be checked against that stored mode — not 'public' — or a - // now-disallowed password/email/sso share could be silently reactivated. - const effectiveAuthType = authType ?? existingShare?.authType ?? 'public' - try { - await validatePublicFileSharing(context.userId, workspaceId, effectiveAuthType) - } catch (error) { - if (error instanceof PublicFileSharingNotAllowedError) { - return { success: false, message: error.message } - } - throw error - } + if (!existingFile) { + return { success: false, message: `File not found: ${targetRef}` } } - assertServerToolNotAborted(context) - - let share + const isActive = action !== 'unshare' try { - share = await upsertFileShare({ + const result = await executeCopilotFileUseCase( + context, + updateWorkspaceFileShare, + { + fileId: existingFile.id, + assertedWorkspaceId: workspaceId, + isActive, + authType, + password, + allowedEmails, + noOpIfInactive: !isActive, + }, + { fileId: existingFile.id } + ) + const share = result.share + logger.info(`${isActive ? 'Enabled' : 'Disabled'} share for file via share_file`, { + fileId: existingFile.id, workspaceId, - fileId, + authType: share.authType, userId: context.userId, - isActive, - authType, - password, - allowedEmails, }) - } catch (error) { - if (error instanceof ShareValidationError) { - return { success: false, message: error.message } - } - throw error - } - logger.info(`${isActive ? 'Enabled' : 'Disabled'} share for file via share_file`, { - fileId, - workspaceId, - authType: share.authType, - userId: context.userId, - }) + if (!isActive) { + return { + success: true, + message: `Stopped sharing "${existingFile.name}". The previous link no longer works.`, + data: { + url: share.url, + token: share.token, + authType: share.authType, + hasPassword: share.hasPassword, + isActive: share.isActive, + }, + } + } - recordAudit({ - workspaceId, - actorId: context.userId, - action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: existingFile.name, - description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${existingFile.name}"`, - }) + const authNote = + share.authType === 'password' + ? ' (password-protected — share the password separately)' + : share.authType === 'email' + ? ' (restricted to allowed emails via one-time code)' + : share.authType === 'sso' + ? ' (restricted to allowed emails via SSO)' + : '' - if (!isActive) { return { success: true, - message: `Stopped sharing "${existingFile.name}". The previous link no longer works.`, + message: `Shared "${existingFile.name}"${authNote}: ${share.url}`, data: { url: share.url, token: share.token, @@ -160,27 +149,17 @@ export const shareFileServerTool: BaseServerTool isActive: share.isActive, }, } - } - - const authNote = - share.authType === 'password' - ? ' (password-protected — share the password separately)' - : share.authType === 'email' - ? ' (restricted to allowed emails via one-time code)' - : share.authType === 'sso' - ? ' (restricted to allowed emails via SSO)' - : '' - - return { - success: true, - message: `Shared "${existingFile.name}"${authNote}: ${share.url}`, - data: { - url: share.url, - token: share.token, - authType: share.authType, - hasPassword: share.hasPassword, - isActive: share.isActive, - }, + } catch (error) { + if (error instanceof WorkspaceFileShareNoopError) { + return { + success: true, + message: `"${existingFile.name}" isn't shared — nothing to unshare.`, + } + } + return { + success: false, + message: messageForCopilotFileError(error, 'Unable to update file sharing'), + } } }, } diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 2215e414f39..9f9f32a5a1f 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -1,29 +1,34 @@ +import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { truncate } from '@sim/utils/string' +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' +import { + messageForCopilotFileError, + resolveCopilotFilePrincipal, +} from '@/lib/copilot/auth/file-delegation' import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { runSandboxTask } from '@/lib/execution/sandbox/run-task' -import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - fetchWorkspaceFileBuffer as downloadWsFile, - getWorkspaceFile, - getWorkspaceFileByName, - resolveWorkspaceFileReference, - uploadWorkspaceFile, - type WorkspaceFileRecord, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { - performDeleteWorkspaceFileItems, - performRenameWorkspaceFile, -} from '@/lib/workspace-files/orchestration' + admitCreateWorkspaceFile, + createWorkspaceFile, +} from '@/lib/workspace-files/application/create-workspace-file' +import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' +import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' import type { SandboxTaskId } from '@/sandbox-tasks/registry' import { compileDoc, @@ -33,6 +38,7 @@ import { PPTXGENJS_SOURCE_MIME, } from './doc-compile' import { buildEmbeddedImageRefWarning } from './embedded-image-refs' +import { ensureCopilotFileFolderPath } from './file-folder-application' import { storeFileIntent } from './file-intent-store' const logger = createLogger('WorkspaceFileServerTool') @@ -40,7 +46,7 @@ const logger = createLogger('WorkspaceFileServerTool') const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation' const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' const PDF_MIME = 'application/pdf' -// Single source of the JS source MIMEs is doc-compile.ts; reuse to avoid drift. +/** Document source MIME aliases stay anchored to the compiler definitions. */ const PPTX_SOURCE_MIME = PPTXGENJS_SOURCE_MIME const DOCX_SOURCE_MIME = DOCXJS_SOURCE_MIME const PDF_SOURCE_MIME = 'text/x-pdflibjs' @@ -196,11 +202,12 @@ export async function compileDocForWrite(args: { source: string fileName: string workspaceId: string + principal: Principal ownerKey: string signal?: AbortSignal fallbackMime: string }): Promise { - const { source, fileName, workspaceId, ownerKey, signal, fallbackMime } = args + const { source, fileName, workspaceId, principal, ownerKey, signal, fallbackMime } = args const docInfo = getDocumentFormatInfo(fileName) const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(fileName) : null @@ -216,7 +223,7 @@ export async function compileDocForWrite(args: { // compileDoc is load-or-build, so an identical re-write reuses the cached // binary instead of re-running E2B. try { - await compileDoc({ source, fileName, workspaceId }) + await compileDoc({ source, fileName, workspaceId, filePrincipal: principal }) } catch (err) { if (err instanceof DocCompileUserError) { return { @@ -280,6 +287,11 @@ export const workspaceFileServerTool: BaseServerTool> + try { + result = await executeCopilotFileUseCase(context, createWorkspaceFile, { + workspaceId, + name: fileName, + contentType, + content, + encoding: 'utf-8', + folderId, + exactName: false, + }) + } catch (error) { + return { + success: false, + message: messageForCopilotFileError(error, 'Failed to create file'), + } + } logger.info('Workspace file created via copilot', { - fileId: result.id, + fileId: result.file.id, name: fileName, size: fileBuffer.length, contentType, @@ -378,11 +426,11 @@ export const workspaceFileServerTool: BaseServerTool ({ vi.mock('@/lib/knowledge/secret-provenance', () => ({ importKnowledgeSearchResultSecretProvenance: mockImportKnowledgeSearchResultSecretProvenance, })) +vi.mock('@/lib/knowledge/documents/service', () => ({ + createSingleDocument: vi.fn(), +})) vi.mock('@/lib/knowledge/tags/service', () => ({ createTagDefinition: vi.fn(), deleteTagDefinition: vi.fn(), @@ -72,7 +75,7 @@ vi.mock('@/lib/knowledge/tags/service', () => ({ updateTagDefinition: vi.fn(), })) vi.mock('@/lib/uploads', () => ({ StorageService: {} })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ resolveWorkspaceFileReference: vi.fn(), })) vi.mock('@/app/api/auth/oauth/utils', () => ({ getCredential: vi.fn() })) @@ -95,7 +98,7 @@ import { createSingleDocument } from '@/lib/knowledge/documents/service' import { generateSearchEmbedding, recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' import { executeKnowledgeSearch } from '@/lib/knowledge/search/queries' import { getKnowledgeBaseById } from '@/lib/knowledge/service' -import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -294,6 +297,8 @@ describe('knowledge base query model boundary', () => { { userId: 'external-admin', workspaceId: 'workspace-paid', + toolCallId: 'tool-1', + copilotToolExecution: true, billingAttribution: BILLING_ATTRIBUTION, resolvedSecretTraceRegistry: registry, } @@ -358,6 +363,8 @@ describe('knowledge base query model boundary', () => { { userId: 'external-admin', workspaceId: 'workspace-paid', + toolCallId: 'tool-1', + copilotToolExecution: true, billingAttribution: BILLING_ATTRIBUTION, resolvedSecretTraceRegistry: registry, } @@ -400,6 +407,8 @@ describe('knowledge base query model boundary', () => { { userId: 'external-admin', workspaceId: 'workspace-paid', + toolCallId: 'tool-1', + copilotToolExecution: true, billingAttribution: BILLING_ATTRIBUTION, resolvedSecretTraceRegistry: registry, } @@ -440,6 +449,8 @@ describe('knowledge base add_file usage gate', () => { { userId: 'external-admin', workspaceId: 'workspace-paid', + toolCallId: 'tool-1', + copilotToolExecution: true, billingAttribution: BILLING_ATTRIBUTION, } ) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index e7271c7e5af..c79a4c18da7 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -12,6 +12,7 @@ import { type BillingAttributionSnapshot, checkAttributedUsageLimits, } from '@/lib/billing/core/billing-attribution' +import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { KnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { @@ -50,8 +51,9 @@ import { updateTagDefinition, } from '@/lib/knowledge/tags/service' import { StorageService } from '@/lib/uploads' -import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import { getCredential } from '@/app/api/auth/oauth/utils' import { checkDocumentWriteAccess, @@ -395,10 +397,18 @@ export const knowledgeBaseServerTool: BaseServerTool = [] const failedFiles: string[] = [] + const filePrincipal = resolveCopilotFilePrincipal(context) for (const fileRef of fileRefs) { - const fileRecord = await resolveWorkspaceFileReference(kbWorkspaceId, fileRef) - if (!fileRecord) { + let fileRecord + try { + fileRecord = await resolveWorkspaceFileReference({ + principal: filePrincipal, + operation: fileOperations.readContent, + workspaceId: kbWorkspaceId, + reference: fileRef, + }) + } catch { failedFiles.push(fileRef) continue } diff --git a/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts b/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts index fa2e1381221..61f12590d97 100644 --- a/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts +++ b/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts @@ -1,17 +1,20 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' import { Ffmpeg } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { MAX_MEDIA_BYTES } from '@/lib/media/falai' import { type FfmpegOperation, type MediaFile, runFfmpegOperation } from '@/lib/media/ffmpeg' -import { - fetchWorkspaceFileBuffer, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' const logger = createLogger('FfmpegTool') @@ -82,12 +85,30 @@ export const ffmpegServerTool: BaseServerTool = { try { const mediaFiles: MediaFile[] = [] + let totalInputBytes = 0 for (const filePath of inputPaths) { - const fileRecord = await resolveWorkspaceFileReference(workspaceId, filePath) - if (!fileRecord) { - return { success: false, message: `Input file not found: ${filePath}` } + const fileRecord = await resolveCopilotWorkspaceFileReference( + context, + fileOperations.readContent, + { + workspaceId, + reference: filePath, + } + ) + const { content: buffer } = await executeCopilotFileUseCase( + context, + readWorkspaceFileContent, + { + fileId: fileRecord.id, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_MEDIA_BYTES, + }, + { fileId: fileRecord.id } + ) + totalInputBytes += buffer.length + if (totalInputBytes > MAX_MEDIA_BYTES) { + throw new Error(`Input files exceed the ${MAX_MEDIA_BYTES} byte limit`) } - const buffer = await fetchWorkspaceFileBuffer(fileRecord) mediaFiles.push({ buffer, mimeType: fileRecord.type || 'application/octet-stream', @@ -128,9 +149,8 @@ export const ffmpegServerTool: BaseServerTool = { const mode = outputFile?.mode ?? 'create' assertServerToolNotAborted(context) - const written = await writeWorkspaceFileByPath({ + const written = await writeCopilotWorkspaceFileByPath(context, { workspaceId, - userId: context.userId, target: { path: outputPath, mode, mimeType: outputFile?.mimeType }, buffer: result.buffer, inferredMimeType: result.contentType || 'application/octet-stream', diff --git a/apps/sim/lib/copilot/tools/server/media/generate-audio.ts b/apps/sim/lib/copilot/tools/server/media/generate-audio.ts index d13a6669019..b0faecef2dd 100644 --- a/apps/sim/lib/copilot/tools/server/media/generate-audio.ts +++ b/apps/sim/lib/copilot/tools/server/media/generate-audio.ts @@ -1,5 +1,9 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' import { GenerateAudio } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, @@ -10,12 +14,11 @@ import { assertOpaqueWorkspaceFileModelSafe, projectServerToolModelInput, } from '@/lib/copilot/tools/server/model-input' -import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { MAX_MEDIA_BYTES } from '@/lib/media/falai' import { type AudioType, generateFalAudio } from '@/lib/media/falai-audio' -import { - fetchWorkspaceFileBuffer, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' const logger = createLogger('GenerateAudioTool') @@ -94,12 +97,21 @@ export const generateAudioServerTool: BaseServerTool ({ - mockFetchWorkspaceFileBuffer: vi.fn(), mockGenerateContent: vi.fn(), mockGenerateFalAudio: vi.fn(), mockGenerateFalVideo: vi.fn(), mockImportWorkspaceFileSecretProvenanceForValue: vi.fn(), mockResolveWorkspaceFileReference: vi.fn(), + mockReadWorkspaceFileContent: vi.fn(), mockWriteWorkspaceFileByPath: vi.fn(), })) @@ -28,14 +28,16 @@ vi.mock('@google/genai', () => ({ })) vi.mock('@/lib/core/config/api-keys', () => ({ getRotatingApiKey: vi.fn(() => 'api-key') })) vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ - writeWorkspaceFileByPath: mockWriteWorkspaceFileByPath, + writeCopilotWorkspaceFileByPath: mockWriteWorkspaceFileByPath, })) vi.mock('@/lib/media/falai-audio', () => ({ generateFalAudio: mockGenerateFalAudio })) vi.mock('@/lib/media/falai-video', () => ({ generateFalVideo: mockGenerateFalVideo })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer, +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, })) +vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ + readWorkspaceFileContent: { execute: mockReadWorkspaceFileContent }, +})) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ importWorkspaceFileSecretProvenanceForValue: mockImportWorkspaceFileSecretProvenanceForValue, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE: @@ -72,6 +74,8 @@ function contextWithSecrets( return { userId: 'user-1', workspaceId: 'workspace-1', + toolCallId: 'tool-1', + copilotToolExecution: true, resolvedSecretTraceRegistry: registry, } } @@ -81,7 +85,7 @@ describe('Mothership media model boundaries', () => { vi.clearAllMocks() mockImportWorkspaceFileSecretProvenanceForValue.mockResolvedValue(true) mockResolveWorkspaceFileReference.mockResolvedValue(file) - mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('opaque-media')) + mockReadWorkspaceFileContent.mockResolvedValue({ file, content: Buffer.from('opaque-media') }) mockWriteWorkspaceFileByPath.mockResolvedValue({ id: 'output-1', name: 'output.bin', @@ -196,7 +200,7 @@ describe('Mothership media model boundaries', () => { }) ) - expect(mockFetchWorkspaceFileBuffer).not.toHaveBeenCalled() + expect(mockReadWorkspaceFileContent).not.toHaveBeenCalled() expect(mockGenerateContent).not.toHaveBeenCalled() expect(mockGenerateFalVideo).not.toHaveBeenCalled() expect(mockGenerateFalAudio).not.toHaveBeenCalled() diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 1162535b7a0..6395f4ddf44 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -71,6 +71,26 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, fetchWorkspaceFileBuffer: mockDownloadWorkspaceFile, })) +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, +})) +vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ + readWorkspaceFileContent: { + execute: async () => ({ content: await mockDownloadWorkspaceFile() }), + }, +})) +vi.mock('@/lib/copilot/auth/file-delegation', () => ({ + resolveCopilotFilePrincipal: vi.fn(() => ({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'test-tool', + audience: 'sim:workspace-files', + issuedAt: new Date(0), + expiresAt: new Date(Date.now() + 60_000), + })), +})) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ getBoundWorkspaceFileSecretProvenance: mockGetBoundWorkspaceFileSecretProvenance, 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 894bb88a171..0a119295f69 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, @@ -95,16 +96,15 @@ import { deleteWorkflowGroupOutput, updateWorkflowGroup, } from '@/lib/table/workflow-groups/service' -import { - fetchWorkspaceFileBuffer, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { type FlattenedBlockOutput, flattenWorkflowOutputs, } from '@/lib/workflows/blocks/flatten-outputs' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' const logger = createLogger('UserTableServerTool') @@ -120,10 +120,22 @@ type UserTableResult = { } const MAX_BATCH_SIZE = CSV_MAX_BATCH_SIZE +const MAX_INLINE_FILE_BYTES = 50 * 1024 * 1024 -async function resolveWorkspaceFileRecordOrThrow(fileReference: string, workspaceId: string) { - const record = await resolveWorkspaceFileReference(workspaceId, fileReference) - if (!record) { +async function resolveWorkspaceFileRecordOrThrow( + fileReference: string, + workspaceId: string, + principal: ReturnType +) { + let record + try { + record = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.readContent, + workspaceId, + reference: fileReference, + }) + } catch { // Only workspace files resolve here. A chat upload is a real, correctly-copied // path, so pointing it at glob("files/**") would send the agent looking for a // file that is not in that tree until materialize_file moves it there. @@ -136,6 +148,16 @@ async function resolveWorkspaceFileRecordOrThrow(fileReference: string, workspac `File not found: "${fileReference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.` ) } + if (!record) { + if (fileReference.replace(/^\/+/, '').startsWith('uploads/')) { + throw new Error( + `Cannot import "${fileReference}": chat uploads are not workspace files. Use materialize_file to save it to a files/... path first, then pass that canonical path.` + ) + } + throw new Error( + `File not found: "${fileReference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.` + ) + } const provenance = await getBoundWorkspaceFileSecretProvenance(workspaceId, { fileId: record.id, @@ -1250,7 +1272,12 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const record = await resolveWorkspaceFileRecordOrThrow(fileReference, workspaceId) + const filePrincipal = resolveCopilotFilePrincipal(context) + const record = await resolveWorkspaceFileRecordOrThrow( + fileReference, + workspaceId, + filePrincipal + ) // Large CSV/TSV: create a placeholder table whose creation claims the // job slot, then let the streaming import worker infer the schema and @@ -1311,7 +1338,16 @@ export const userTableServerTool: BaseServerTool } const file = { - buffer: await fetchWorkspaceFileBuffer(record), + buffer: ( + await readWorkspaceFileContent.execute({ + principal: filePrincipal, + input: { + fileId: record.id, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_INLINE_FILE_BYTES, + }, + }) + ).content, name: record.name, type: record.type, } @@ -1438,7 +1474,12 @@ export const userTableServerTool: BaseServerTool return { success: false, message: `Table is archived: ${tableId}` } } - const record = await resolveWorkspaceFileRecordOrThrow(fileReference, workspaceId) + const filePrincipal = resolveCopilotFilePrincipal(context) + const record = await resolveWorkspaceFileRecordOrThrow( + fileReference, + workspaceId, + filePrincipal + ) // Large CSV/TSV: claim the table's one-write-job slot and hand the // file to the streaming import worker (mirrors @@ -1481,7 +1522,16 @@ export const userTableServerTool: BaseServerTool } try { const file = { - buffer: await fetchWorkspaceFileBuffer(record), + buffer: ( + await readWorkspaceFileContent.execute({ + principal: filePrincipal, + input: { + fileId: record.id, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_INLINE_FILE_BYTES, + }, + }) + ).content, name: record.name, type: record.type, } diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts index 30ad2848c0d..02181e88d9d 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.ts @@ -30,6 +30,7 @@ const logger = createLogger('FileReader') /** Inline text-read cap — exported so callers can align their own byte-sniff budgets with what read() can actually display. */ export const MAX_TEXT_READ_BYTES = 5 * 1024 * 1024 // 5 MB const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB +const MAX_IMAGE_SOURCE_BYTES = 50 * 1024 * 1024 // 50 MB // Parseable-document byte cap. Large office/PDF files can still // produce huge extracted text; reject up front to avoid wasting a // download + parse only to blow past the tool-result budget. @@ -286,7 +287,11 @@ export interface FileReadResult { * binary), and any size rejection. The `prepareImageForVision` span * nests underneath for the image-resize path. */ -export async function readFileRecord(record: WorkspaceFileRecord): Promise { +export async function readFileRecord( + record: WorkspaceFileRecord, + /** Pre-authorized workspace bytes; omitted only for chat-upload records in the mothership store. */ + authorizedContent?: Buffer +): Promise { const startedAt = Date.now() const result = await getVfsTracer().startActiveSpan( TraceSpan.CopilotVfsReadFile, @@ -302,7 +307,14 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise MAX_IMAGE_SOURCE_BYTES) { + span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge) + return { + content: `[Image too large to process: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB, source limit 50MB)]`, + totalLines: 1, + } + } + const originalBuffer = authorizedContent ?? (await fetchWorkspaceFileBuffer(record)) const prepared = await prepareImageForVision(originalBuffer, record.type) if (!prepared) { span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge) @@ -344,7 +356,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise { resolveWorkspaceFileReference: vi.fn(), updateWorkspaceFileContent: vi.fn(), uploadWorkspaceFile: vi.fn(), + createWorkspaceFileBufferByPath: { execute: vi.fn() }, + updateWorkspaceFileContentBufferByPath: { execute: vi.fn() }, } }) @@ -26,11 +28,18 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ FileConflictError: mocks.FileConflictError, getWorkspaceFileByName: mocks.getWorkspaceFileByName, - resolveWorkspaceFileReference: mocks.resolveWorkspaceFileReference, updateWorkspaceFileContent: mocks.updateWorkspaceFileContent, uploadWorkspaceFile: mocks.uploadWorkspaceFile, })) +vi.mock('@/lib/workspace-files/application/write-workspace-file-by-path', () => ({ + createWorkspaceFileBufferByPath: mocks.createWorkspaceFileBufferByPath, + updateWorkspaceFileContentBufferByPath: mocks.updateWorkspaceFileContentBufferByPath, +})) +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference: mocks.resolveWorkspaceFileReference, +})) + import { validateWorkspaceFileWriteTarget, writeWorkspaceFileByPath } from './resource-writer' describe('resource writer', () => { @@ -41,18 +50,19 @@ describe('resource writer', () => { it('auto-creates missing parent folders for plain workspace file creates', async () => { mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('folder-nested') - mocks.getWorkspaceFileByName.mockResolvedValue(null) - mocks.uploadWorkspaceFile.mockResolvedValue({ + mocks.createWorkspaceFileBufferByPath.execute.mockResolvedValue({ id: 'file-report', name: 'summary.csv', size: 7, - type: 'text/csv', - url: '/download', + contentType: 'text/csv', + downloadUrl: '/download', + vfsPath: 'files/Reports/2026/summary.csv', + mode: 'create', }) const result = await writeWorkspaceFileByPath({ workspaceId: 'workspace-1', - userId: 'user-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, target: { path: 'files/Reports/2026/summary.csv', mode: 'create', @@ -61,23 +71,15 @@ describe('resource writer', () => { inferredMimeType: 'text/csv', }) - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - userId: 'user-1', - pathSegments: ['Reports', '2026'], + expect(mocks.createWorkspaceFileBufferByPath.execute).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: expect.objectContaining({ + workspaceId: 'workspace-1', + path: 'files/Reports/2026/summary.csv', + content: Buffer.from('content'), + contentType: 'text/csv', + }), }) - expect(mocks.findWorkspaceFileFolderIdByPath).not.toHaveBeenCalled() - expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith( - 'workspace-1', - 'user-1', - Buffer.from('content'), - 'summary.csv', - 'text/csv', - { - folderId: 'folder-nested', - secretProvenance: { status: 'exact', entries: [] }, - } - ) expect(result).toMatchObject({ id: 'file-report', vfsPath: 'files/Reports/2026/summary.csv', @@ -91,7 +93,7 @@ describe('resource writer', () => { const validation = await validateWorkspaceFileWriteTarget({ workspaceId: 'workspace-1', - userId: 'user-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, target: { path: 'files/Reports/2026/summary.csv', mode: 'create', @@ -112,7 +114,7 @@ describe('resource writer', () => { const validation = await validateWorkspaceFileWriteTarget({ workspaceId: 'workspace-1', - userId: 'user-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, target: { path: 'files/Reports/2026/summary.csv', mode: 'create', @@ -128,4 +130,33 @@ describe('resource writer', () => { folderId: null, }) }) + + it('authorizes overwrite target resolution through the shared application resolver', async () => { + mocks.resolveWorkspaceFileReference.mockResolvedValue({ + id: 'file-report', + name: 'summary.csv', + size: 7, + type: 'text/csv', + folderPath: 'Reports/2026', + }) + + const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + const validation = await validateWorkspaceFileWriteTarget({ + workspaceId: 'workspace-1', + principal, + target: { path: 'files/Reports/2026/summary.csv', mode: 'overwrite' }, + }) + + expect(mocks.resolveWorkspaceFileReference).toHaveBeenCalledWith({ + principal, + operation: expect.objectContaining({ id: 'files.update_content' }), + workspaceId: 'workspace-1', + reference: 'files/Reports/2026/summary.csv', + }) + expect(validation).toMatchObject({ + mode: 'overwrite', + existingFileId: 'file-report', + vfsPath: 'files/Reports/2026/summary.csv', + }) + }) }) diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts index 9dfa24141f2..1d9108a7d3b 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -1,20 +1,22 @@ -import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import type { Principal } from '@sim/auth/principal' import { - ensureWorkspaceFileFolderPath, - findWorkspaceFileFolderIdByPath, - normalizeWorkspaceFileItemName, -} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' + type CopilotFileDelegationContext, + resolveCopilotFilePrincipal, +} from '@/lib/copilot/auth/file-delegation' +import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFileByName, - resolveWorkspaceFileReference, - updateWorkspaceFileContent, - uploadWorkspaceFile, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import { - EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, - type WorkspaceFileSecretProvenance, -} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' + createWorkspaceFileBufferByPath, + updateWorkspaceFileContentBufferByPath, +} from '@/lib/workspace-files/application/write-workspace-file-by-path' +import { parseWorkspaceFileCreatePath } from '@/lib/workspace-files/workspace-file-path' export type WorkspaceFileWriteMode = 'create' | 'overwrite' @@ -54,68 +56,20 @@ export type WorkspaceFileWriteValidation = existingFileId: string } -function displayFolderPath(segments: string[]): string { - return segments.length > 0 ? `files/${segments.join('/')}` : 'files/' -} - -export function parseWorkspaceFileCreatePath(path: string): { - folderSegments: string[] - fileName: string - vfsPath: string -} { - const trimmed = path.trim().replace(/^\/+/, '') - if (!trimmed.startsWith('files/')) { - throw new Error('Workspace file paths must start with "files/"') - } - - const decoded = decodeVfsPathSegments(trimmed.slice('files/'.length)) - if (decoded.length === 0) { - throw new Error('Workspace file path must include a file name') - } - - const fileName = normalizeWorkspaceFileItemName(decoded.at(-1) ?? '', 'File') - const folderSegments = decoded - .slice(0, -1) - .map((segment) => normalizeWorkspaceFileItemName(segment, 'Folder')) - - return { - folderSegments, - fileName, - vfsPath: canonicalWorkspaceFilePath({ folderPath: folderSegments.join('/'), name: fileName }), - } -} - -/** - * Resolve a create-mode write target. Pass `createFolders` (the write path) to - * create missing parent folders; without it (the validation path) resolution - * is read-only — a missing parent chain yields `folderId: null`, since the - * folders are created at write time and nothing can conflict there yet. - */ +/** Resolves a create-mode target without mutating missing parent folders. */ async function resolveCreateTarget( workspaceId: string, - path: string, - createFolders?: { userId: string } + path: string ): Promise { const parsed = parseWorkspaceFileCreatePath(path) let folderId: string | null = null if (parsed.folderSegments.length > 0) { - if (createFolders) { - folderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId: createFolders.userId, - pathSegments: parsed.folderSegments, - }) - if (!folderId) { - throw new Error(`Failed to create directory: ${displayFolderPath(parsed.folderSegments)}`) - } - } else { - folderId = await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments) - if (!folderId) { - return { - fileName: parsed.fileName, - folderId: null, - vfsPath: parsed.vfsPath, - } + folderId = await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments) + if (!folderId) { + return { + fileName: parsed.fileName, + folderId: null, + vfsPath: parsed.vfsPath, } } } @@ -138,14 +92,16 @@ function vfsPathForRecord(record: WorkspaceFileRecord): string { export async function validateWorkspaceFileWriteTarget(args: { workspaceId: string - userId?: string + principal: Principal target: WorkspaceFileWriteTarget }): Promise { if (args.target.mode === 'overwrite') { - const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) - if (!existing) { - throw new Error(`File not found for overwrite: ${args.target.path}`) - } + const existing = await resolveWorkspaceFileReference({ + principal: args.principal, + operation: fileOperations.updateContent, + workspaceId: args.workspaceId, + reference: args.target.path, + }) return { mode: 'overwrite', vfsPath: vfsPathForRecord(existing), @@ -164,7 +120,7 @@ export async function validateWorkspaceFileWriteTarget(args: { export async function writeWorkspaceFileByPath(args: { workspaceId: string - userId: string + principal: Principal target: WorkspaceFileWriteTarget buffer: Buffer inferredMimeType: string @@ -179,59 +135,65 @@ export async function writeWorkspaceFileByPath(args: { }): Promise { const contentType = args.target.mimeType || args.inferredMimeType if (args.target.mode === 'overwrite') { - const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) - if (!existing) { - throw new Error(`File not found for overwrite: ${args.target.path}`) - } - - const updated = await updateWorkspaceFileContent( - args.workspaceId, - existing.id, - args.userId, - args.buffer, - contentType || existing.type, - { + const updated = await updateWorkspaceFileContentBufferByPath.execute({ + principal: args.principal, + input: { + workspaceId: args.workspaceId, + path: args.target.path, + mode: 'overwrite', + content: args.buffer, + contentType, syncLiveDoc: args.syncLiveDoc, - secretProvenancePolicy: { - mode: 'replace', - provenance: args.secretProvenance ?? { status: 'exact', entries: [] }, - }, - } - ) + secretProvenance: args.secretProvenance, + }, + }) return { id: updated.id, name: updated.name, size: updated.size, - contentType: updated.type, - downloadUrl: updated.url, - vfsPath: vfsPathForRecord(updated), + contentType: updated.contentType, + downloadUrl: updated.downloadUrl, + vfsPath: updated.vfsPath, mode: 'overwrite', } } - const createTarget = await resolveCreateTarget(args.workspaceId, args.target.path, { - userId: args.userId, + const created = await createWorkspaceFileBufferByPath.execute({ + principal: args.principal, + input: { + workspaceId: args.workspaceId, + path: args.target.path, + mode: 'create', + content: args.buffer, + contentType, + exactName: true, + secretProvenance: args.secretProvenance, + }, }) - const uploaded = await uploadWorkspaceFile( - args.workspaceId, - args.userId, - args.buffer, - createTarget.fileName, - contentType, - { - folderId: createTarget.folderId, - secretProvenance: args.secretProvenance ?? EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, - } - ) return { - id: uploaded.id, - name: uploaded.name, - size: uploaded.size, - contentType: uploaded.type, - downloadUrl: uploaded.url, - vfsPath: createTarget.vfsPath, + id: created.id, + name: created.name, + size: created.size, + contentType: created.contentType, + downloadUrl: created.downloadUrl, + vfsPath: created.vfsPath, mode: 'create', } } + +type CopilotWorkspaceFileWriteArgs = Omit< + Parameters[0], + 'principal' +> + +export function writeCopilotWorkspaceFileByPath( + context: CopilotFileDelegationContext | undefined, + args: CopilotWorkspaceFileWriteArgs +) { + return writeWorkspaceFileByPath({ + ...args, + principal: resolveCopilotFilePrincipal(context), + }) +} diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 4a7b340a07c..b451518ff56 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -1,4 +1,5 @@ import { trace } from '@opentelemetry/api' +import type { Principal } from '@sim/auth/principal' import { db } from '@sim/db' import { chat as chatTable, @@ -48,7 +49,11 @@ import { } from '@/lib/copilot/tools/server/workflow/edit-workflow/lint' import { UNRESOLVABLE_AT_LINT_NOTE } from '@/lib/copilot/tools/server/workflow/edit-workflow/validation' import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' -import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader' +import { + type FileReadResult, + MAX_TEXT_READ_BYTES, + readFileRecord, +} from '@/lib/copilot/vfs/file-reader' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import type { GrepMatch, GrepOptions, ReadResult } from '@/lib/copilot/vfs/operations' import * as ops from '@/lib/copilot/vfs/operations' @@ -117,15 +122,9 @@ import { getKnowledgeBases } from '@/lib/knowledge/service' import { validateMermaidSource } from '@/lib/mermaid/validate' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' -import { getWorkspaceShares } from '@/lib/public-shares/share-manager' import { listTables } from '@/lib/table/service' -import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - fetchWorkspaceFileBuffer, - findWorkspaceFileRecord, - listWorkspaceFiles, - type WorkspaceFileRecord, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { findWorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import type { WorkspaceFileSecretProvenanceEnvelope } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' @@ -133,6 +132,9 @@ import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/ut import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { getSkillById } from '@/lib/workflows/skills/operations' import { listFolders, listWorkflows } from '@/lib/workflows/utils' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { listWorkspaceFileFoldersOperation } from '@/lib/workspace-files/application/workspace-file-folders' import { assertActiveWorkspaceAccess, getUsersWithPermissions, @@ -563,6 +565,7 @@ function getStaticComponentFiles(): Map { * components/triggers/{provider}/{id}.json (external triggers: github, slack, etc.) */ export class WorkspaceVFS { + private readonly filePrincipal?: Principal // Eagerly-materialized, cheap content (structure + metadata): folder markers, // per-resource meta.json, WORKSPACE.md/WORKSPACE_CONTEXT.md, static components. private files: Map = new Map() @@ -595,6 +598,10 @@ export class WorkspaceVFS { */ private _customBlockTypes: Set | null = null + constructor(filePrincipal?: Principal) { + this.filePrincipal = filePrincipal + } + get workspaceId(): string { return this._workspaceId } @@ -1035,10 +1042,23 @@ export class WorkspaceVFS { const canonicalMatch = path.match(new RegExp(`^files/(.+)/${suffix}$`)) if (!canonicalMatch?.[1]) return null - const files = await listWorkspaceFiles(this._workspaceId) + if (!this.filePrincipal) { + throw new Error('Workspace file reads require a trusted Copilot principal') + } + const { files } = await listAllWorkspaceFiles.execute({ + principal: this.filePrincipal, + input: { workspaceId: this._workspaceId, scope: 'active' }, + }) return findWorkspaceFileRecord(files, `files/${canonicalMatch[1]}`) } + private requireFilePrincipal(): Principal { + if (!this.filePrincipal) { + throw new Error('Workspace file reads require a trusted Copilot principal') + } + return this.filePrincipal + } + /** * Renders a renderable doc (pptx/docx/pdf) record to a contact-sheet image and * returns it as a model readable JPEG attachment. Shared by the `/render` and @@ -1059,7 +1079,14 @@ export class WorkspaceVFS { totalLines: 1, } } - const buffer = await fetchWorkspaceFileBuffer(record) + const { content: buffer } = await readWorkspaceFileContent.execute({ + principal: this.requireFilePrincipal(), + input: { + fileId: record.id, + assertedWorkspaceId: this._workspaceId, + maxBytes: MAX_DOC_READ_INPUT_BYTES, + }, + }) if (buffer.length > MAX_DOC_READ_INPUT_BYTES) { return { content: JSON.stringify({ ok: false, error: 'File is too large to render' }), @@ -1082,7 +1109,12 @@ export class WorkspaceVFS { } if (isDocSandboxEnabled && (await getE2BDocFormat(record.name))) { bin = ( - await compileDoc({ source: code, fileName: record.name, workspaceId: this._workspaceId }) + await compileDoc({ + source: code, + fileName: record.name, + workspaceId: this._workspaceId, + filePrincipal: this.requireFilePrincipal(), + }) ).buffer } else { const taskId = BINARY_DOC_TASKS[ext] @@ -1172,7 +1204,14 @@ export class WorkspaceVFS { }) } - const buffer = await fetchWorkspaceFileBuffer(record) + const { content: buffer } = await readWorkspaceFileContent.execute({ + principal: this.requireFilePrincipal(), + input: { + fileId: record.id, + assertedWorkspaceId: this._workspaceId, + maxBytes: MAX_DOC_READ_INPUT_BYTES, + }, + }) const code = buffer.toString('utf-8') if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { return bindWorkspaceFileResult(record, { @@ -1186,6 +1225,7 @@ export class WorkspaceVFS { source: code, fileName: record.name, workspaceId: this._workspaceId, + filePrincipal: this.requireFilePrincipal(), }) ).buffer : await runSandboxTask(taskId, { code, workspaceId: this._workspaceId }) @@ -1296,7 +1336,14 @@ export class WorkspaceVFS { totalLines: 1, }) } - const buffer = await fetchWorkspaceFileBuffer(record) + const { content: buffer } = await readWorkspaceFileContent.execute({ + principal: this.requireFilePrincipal(), + input: { + fileId: record.id, + assertedWorkspaceId: this._workspaceId, + maxBytes: MAX_DOC_READ_INPUT_BYTES, + }, + }) if (buffer.length > MAX_DOC_READ_INPUT_BYTES) { return bindWorkspaceFileResult(record, { content: JSON.stringify({ ok: false, error: 'File is too large to extract' }), @@ -1348,7 +1395,14 @@ export class WorkspaceVFS { const taskId = BINARY_DOC_TASKS[ext] const isMermaidFile = ext === 'mmd' || ext === 'mermaid' if (!e2bFmt && !taskId && !isMermaidFile) return null - const buffer = await fetchWorkspaceFileBuffer(record) + const { content: buffer } = await readWorkspaceFileContent.execute({ + principal: this.requireFilePrincipal(), + input: { + fileId: record.id, + assertedWorkspaceId: this._workspaceId, + maxBytes: MAX_DOC_READ_INPUT_BYTES, + }, + }) const code = buffer.toString('utf-8') if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { return bindWorkspaceFileResult(record, { @@ -1371,6 +1425,7 @@ export class WorkspaceVFS { fileName: record.name, workspaceId: this._workspaceId, ext, + principal: this.requireFilePrincipal(), }) } else { try { @@ -1407,7 +1462,20 @@ export class WorkspaceVFS { const rawExt = record.name.split('.').pop()?.toLowerCase() if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') return null const ext: 'docx' | 'pptx' | 'pdf' = rawExt - const buffer = await fetchWorkspaceFileBuffer(record) + if (typeof record.size === 'number' && record.size > MAX_DOC_READ_INPUT_BYTES) { + return bindWorkspaceFileResult(record, { + content: JSON.stringify({ ok: false, error: 'File is too large to extract style' }), + totalLines: 1, + }) + } + const { content: buffer } = await readWorkspaceFileContent.execute({ + principal: this.requireFilePrincipal(), + input: { + fileId: record.id, + assertedWorkspaceId: this._workspaceId, + maxBytes: MAX_DOC_READ_INPUT_BYTES, + }, + }) const summary = await extractDocumentStyle(buffer, ext) if (!summary) return null const json = JSON.stringify(summary, null, 2) @@ -1440,11 +1508,23 @@ export class WorkspaceVFS { const scope = deletedMatch ? 'archived' : 'active' try { - const files = await listWorkspaceFiles(this._workspaceId, { scope }) + const { files } = await listAllWorkspaceFiles.execute({ + principal: this.requireFilePrincipal(), + input: { workspaceId: this._workspaceId, scope }, + }) const record = findWorkspaceFileRecord(files, fileReference) if (!record) return null - const result = await readFileRecord(record) - return result ? bindWorkspaceFileResult(record, result) : null + const { file, content } = await readWorkspaceFileContent.execute({ + principal: this.requireFilePrincipal(), + input: { + fileId: record.id, + assertedWorkspaceId: this._workspaceId, + includeDeleted: scope === 'archived', + maxBytes: MAX_TEXT_READ_BYTES, + }, + }) + const result = await readFileRecord(file, content) + return result ? bindWorkspaceFileResult(file, result) : null } catch (err) { logger.warn('Failed to list workspace files for readFileContent', { workspaceId: this._workspaceId, @@ -1861,22 +1941,14 @@ export class WorkspaceVFS { */ private async materializeFiles(workspaceId: string): Promise { try { - const folders = await listWorkspaceFileFolders(workspaceId) - const files = await listWorkspaceFiles(workspaceId, { folders, throwOnError: true }) - // Batch-load public share state so each file's metadata carries an ambient - // `shared` flag (mirrors how the files-list UI enriches rows) — no N+1. - // Fail soft: share state is only metadata enrichment, so a lookup failure - // must not drop the whole file tree (the outer catch returns []) — fall back - // to no shares, and files still materialize with `shared: false`. - let shareByFileId: Awaited> = new Map() - try { - shareByFileId = await getWorkspaceShares('file', workspaceId) - } catch (error) { - logger.warn('Failed to load file share state; file metadata will show shared: false', { - workspaceId, - error: toError(error).message, - }) - } + const principal = this.requireFilePrincipal() + const [{ folders }, { files }] = await Promise.all([ + listWorkspaceFileFoldersOperation.execute({ + principal, + input: { workspaceId, scope: 'active' }, + }), + listAllWorkspaceFiles.execute({ principal, input: { workspaceId, scope: 'active' } }), + ]) for (const folder of folders) { this.files.set(`files/${encodeVfsPathSegments(folder.path.split('/'))}/.folder`, '') } @@ -1886,7 +1958,7 @@ export class WorkspaceVFS { folderPath: file.folderPath, name: file.name, }) - const share = shareByFileId.get(file.id) + const share = file.share const shared = share?.isActive ?? false this.files.set( filePath, @@ -2259,8 +2331,18 @@ export class WorkspaceVFS { ) ), listTables(workspaceId, { scope: 'archived' }), - listWorkspaceFiles(workspaceId, { scope: 'archived' }), - listWorkspaceFileFolders(workspaceId, { scope: 'archived' }), + listAllWorkspaceFiles + .execute({ + principal: this.requireFilePrincipal(), + input: { workspaceId, scope: 'archived' }, + }) + .then(({ files }) => files), + listWorkspaceFileFoldersOperation + .execute({ + principal: this.requireFilePrincipal(), + input: { workspaceId, scope: 'archived' }, + }) + .then(({ folders }) => folders), getKnowledgeBases(userId, workspaceId, 'archived'), ]) @@ -2488,10 +2570,10 @@ export class WorkspaceVFS { export async function getOrMaterializeVFS( workspaceId: string, userId: string, - options?: { secretMountPolicy?: SecretMountPolicy } + options?: { secretMountPolicy?: SecretMountPolicy; filePrincipal?: Principal } ): Promise { await assertActiveWorkspaceAccess(workspaceId, userId) - const vfs = new WorkspaceVFS() + const vfs = new WorkspaceVFS(options?.filePrincipal) await vfs.materialize(workspaceId, userId, options) return vfs } diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts new file mode 100644 index 00000000000..6c72bb0d3e5 --- /dev/null +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts @@ -0,0 +1,306 @@ +/** + * @vitest-environment node + */ +import type { + DelegatedPrincipal, + PersonalApiKeyPrincipal, + SessionPrincipal, + WorkspaceApiKeyPrincipal, +} from '@sim/auth/principal' +import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + events: [] as string[], + recordAudit: vi.fn(() => mocks.events.push('audit')), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_UPDATED: 'file.updated' }, + AuditResourceType: { FILE: 'file' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +import { AuditAction, AuditResourceType } from '@sim/audit' +import { defineAuthorizedWorkspaceUseCase, defineWorkspaceOperation } from '@/lib/core/application' +import type { OrchestrationError } from '@/lib/core/orchestration/types' + +const operation = defineWorkspaceOperation({ + id: 'test.rename', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], +}) + +const delegatedOperation = defineWorkspaceOperation({ + id: 'test.delegated_read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], +}) + +const workspaceKeyOperation = defineWorkspaceOperation({ + id: 'test.workspace_key_read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['workspace_api_key'], +}) + +interface TestInput { + resourceId: string +} + +interface TestContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + canonicalResourceId: string +} + +const canonicalContext: TestContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + canonicalResourceId: 'resource-1', +} + +const sessionPrincipal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', +} + +describe('defineAuthorizedWorkspaceUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.events.length = 0 + mocks.resolvePermission.mockResolvedValue('write') + }) + + it('narrows definition callbacks while keeping public execution principal-safe', async () => { + const resolveContext = vi.fn( + async ({ principal }: { principal: SessionPrincipal; input: TestInput }) => { + expectTypeOf(principal).toEqualTypeOf() + return canonicalContext + } + ) + const useCase = defineAuthorizedWorkspaceUseCase({ + operation, + resolveContext, + authorizationOptions: {}, + async execute({ principal, context }) { + expectTypeOf(principal).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf() + return { resource: { id: context.canonicalResourceId, name: 'Renamed' } } + }, + }) + + const disallowedPrincipal: PersonalApiKeyPrincipal = { + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'key-1', + } + await expect( + useCase.execute({ principal: disallowedPrincipal, input: { resourceId: 'resource-1' } }) + ).rejects.toMatchObject>({ code: 'forbidden' }) + + expect(resolveContext).not.toHaveBeenCalled() + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('authorizes canonical context, enriches one audit entry, then runs afterSuccess', async () => { + const request = { headers: new Headers({ 'user-agent': 'vitest' }) } + const useCase = defineAuthorizedWorkspaceUseCase({ + operation, + resolveContext: async ({ input }: { principal: SessionPrincipal; input: TestInput }) => ({ + ...canonicalContext, + canonicalResourceId: input.resourceId, + }), + authorizationOptions: {}, + async execute({ context }) { + mocks.events.push('execute') + return { resource: { id: context.canonicalResourceId, name: 'Renamed' } } + }, + projectAudit({ result }) { + return { + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: result.resource.id, + resourceName: result.resource.name, + metadata: { operation: 'spoofed', actor: 'spoofed', retained: true }, + } + }, + async afterSuccess() { + mocks.events.push('afterSuccess') + }, + }) + + await expect( + useCase.execute({ + principal: sessionPrincipal, + input: { resourceId: 'resource-1' }, + request, + }) + ).resolves.toEqual({ resource: { id: 'resource-1', name: 'Renamed' } }) + + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + expect(mocks.recordAudit).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + actorId: 'user-1', + actorName: undefined, + action: 'file.updated', + resourceType: 'file', + resourceId: 'resource-1', + resourceName: 'Renamed', + description: undefined, + metadata: { + retained: true, + operation: 'test.rename', + actor: { kind: 'session', userId: 'user-1' }, + }, + request, + }) + expect(mocks.events).toEqual(['execute', 'audit', 'afterSuccess']) + }) + + it('supports zero or many semantic audit entries', async () => { + const buildUseCase = (auditCount: number) => + defineAuthorizedWorkspaceUseCase({ + operation, + resolveContext: async (_args: { principal: SessionPrincipal; input: TestInput }) => + canonicalContext, + authorizationOptions: {}, + async execute() { + return { auditCount } + }, + projectAudit({ result }) { + return Array.from({ length: result.auditCount }, (_, index) => ({ + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: `resource-${index}`, + })) + }, + }) + + await buildUseCase(0).execute({ + principal: sessionPrincipal, + input: { resourceId: 'resource-1' }, + }) + expect(mocks.recordAudit).not.toHaveBeenCalled() + + await buildUseCase(2).execute({ + principal: sessionPrincipal, + input: { resourceId: 'resource-1' }, + }) + expect(mocks.recordAudit).toHaveBeenCalledTimes(2) + }) + + it('resolves domain-specific delegation options against canonical context', async () => { + const scopeCheck = vi.fn( + (principal: DelegatedPrincipal, context: TestContext) => + principal.resourceScope?.fileId === context.canonicalResourceId + ) + const useCase = defineAuthorizedWorkspaceUseCase({ + operation: delegatedOperation, + resolveContext: async (_args: { principal: DelegatedPrincipal; input: TestInput }) => + canonicalContext, + authorizationOptions: ({ principal }) => { + expectTypeOf(principal).toEqualTypeOf() + return { + delegation: { + audience: 'test:files', + isWithinScope: scopeCheck, + }, + } + }, + async execute({ principal }) { + expectTypeOf(principal).toEqualTypeOf() + return { ok: true as const } + }, + }) + const principal: DelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'test:files', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { fileId: 'resource-1' }, + } + + await expect( + useCase.execute({ principal, input: { resourceId: 'resource-1' } }) + ).resolves.toEqual({ ok: true }) + expect(scopeCheck).toHaveBeenCalledWith(principal, canonicalContext) + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + }) + + it('records workspace API keys as non-human audit actors', async () => { + const useCase = defineAuthorizedWorkspaceUseCase({ + operation: workspaceKeyOperation, + resolveContext: async (_args: { principal: WorkspaceApiKeyPrincipal; input: TestInput }) => + canonicalContext, + authorizationOptions: {}, + async execute() { + return { id: 'resource-1' } + }, + projectAudit({ result }) { + return { + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: result.id, + } + }, + }) + + await useCase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + input: { resourceId: 'resource-1' }, + }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: null, + actorName: 'Workspace API key', + metadata: { + operation: 'test.workspace_key_read', + actor: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + }, + }) + ) + }) +}) diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.ts new file mode 100644 index 00000000000..ff0fc8faecb --- /dev/null +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.ts @@ -0,0 +1,152 @@ +import { type AuditActionType, type AuditResourceTypeValue, recordAudit } from '@sim/audit' +import type { PrincipalAuditAttribution } from '@sim/auth/principal' +import { resolvePrincipalAuditAttribution } from '@sim/auth/principal' +import type { OperationUseCase } from '@/lib/core/application/operation' +import { + authorizeWorkspaceOperation, + requireAllowedWorkspacePrincipal, + type WorkspaceAuthorizationContext, + type WorkspaceAuthorizationOptions, +} from '@/lib/core/application/workspace-authorization' +import type { + PrincipalForOperation, + WorkspaceOperation, +} from '@/lib/core/application/workspace-operation' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' + +export interface WorkspaceUseCaseAuditEntry { + action: AuditActionType + resourceType: AuditResourceTypeValue + resourceId?: string + resourceName?: string + description?: string + metadata?: Record +} + +export interface AuthorizedWorkspaceUseCaseContext< + O extends WorkspaceOperation, + I, + C extends WorkspaceAuthorizationContext, +> { + principal: PrincipalForOperation + input: I + context: C + request?: OrchestrationRequestContext +} + +export interface AuthorizedWorkspaceUseCaseResultContext< + O extends WorkspaceOperation, + I, + C extends WorkspaceAuthorizationContext, + R, +> extends AuthorizedWorkspaceUseCaseContext { + result: R +} + +export interface AuthorizedWorkspaceUseCaseDefinition< + O extends WorkspaceOperation, + I, + C extends WorkspaceAuthorizationContext, + R, +> { + operation: O + resolveContext(args: { principal: PrincipalForOperation; input: I }): C | Promise + authorizationOptions: + | WorkspaceAuthorizationOptions + | (( + args: AuthorizedWorkspaceUseCaseContext + ) => WorkspaceAuthorizationOptions | Promise>) + execute(args: AuthorizedWorkspaceUseCaseContext): Promise + projectAudit?( + args: AuthorizedWorkspaceUseCaseResultContext + ): WorkspaceUseCaseAuditEntry | WorkspaceUseCaseAuditEntry[] + afterSuccess?(args: AuthorizedWorkspaceUseCaseResultContext): void | Promise +} + +function isAuthorizationOptionsResolver< + O extends WorkspaceOperation, + I, + C extends WorkspaceAuthorizationContext, +>( + options: AuthorizedWorkspaceUseCaseDefinition['authorizationOptions'] +): options is ( + args: AuthorizedWorkspaceUseCaseContext +) => WorkspaceAuthorizationOptions | Promise> { + return typeof options === 'function' +} + +function recordProjectedAuditEntries( + operation: O, + context: WorkspaceAuthorizationContext, + attribution: PrincipalAuditAttribution, + request: OrchestrationRequestContext | undefined, + entries: readonly WorkspaceUseCaseAuditEntry[] +): void { + for (const entry of entries) { + recordAudit({ + workspaceId: context.workspaceId, + actorId: attribution.actorId, + actorName: attribution.actorName, + action: entry.action, + resourceType: entry.resourceType, + resourceId: entry.resourceId, + resourceName: entry.resourceName, + description: entry.description, + metadata: { + ...entry.metadata, + operation: operation.id, + actor: attribution.actor, + }, + request, + }) + } +} + +export function defineAuthorizedWorkspaceUseCase< + const O extends WorkspaceOperation, + I, + C extends WorkspaceAuthorizationContext, + R, +>(definition: AuthorizedWorkspaceUseCaseDefinition): OperationUseCase { + 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 + ) + const result = await definition.execute(executionContext) + const resultContext = { ...executionContext, result } + const projectedAudit = definition.projectAudit?.(resultContext) + if (projectedAudit !== undefined) { + const auditEntries = Array.isArray(projectedAudit) ? projectedAudit : [projectedAudit] + if (auditEntries.length > 0) { + const auditAttribution = resolvePrincipalAuditAttribution(principal) + recordProjectedAuditEntries( + definition.operation, + context, + auditAttribution, + request, + auditEntries + ) + } + } + await definition.afterSuccess?.(resultContext) + return result + }, + } +} diff --git a/apps/sim/lib/core/application/index.ts b/apps/sim/lib/core/application/index.ts new file mode 100644 index 00000000000..9c28f293d70 --- /dev/null +++ b/apps/sim/lib/core/application/index.ts @@ -0,0 +1,26 @@ +export { + type AuthorizedWorkspaceUseCaseContext, + type AuthorizedWorkspaceUseCaseDefinition, + type AuthorizedWorkspaceUseCaseResultContext, + defineAuthorizedWorkspaceUseCase, + type WorkspaceUseCaseAuditEntry, +} from '@/lib/core/application/authorized-workspace-use-case' +export type { + ApplicationOperation, + OperationUseCase, +} from '@/lib/core/application/operation' +export type { + WorkspaceAuthorizationContext, + WorkspaceAuthorizationOptions, + WorkspaceDelegationPolicy, +} from '@/lib/core/application/workspace-authorization' +export { + authorizeWorkspaceOperation, + requireAllowedWorkspacePrincipal, +} from '@/lib/core/application/workspace-authorization' +export { + defineWorkspaceOperation, + type PrincipalForOperation, + type PrincipalKind, + type WorkspaceOperation, +} from '@/lib/core/application/workspace-operation' diff --git a/apps/sim/lib/core/application/operation.ts b/apps/sim/lib/core/application/operation.ts new file mode 100644 index 00000000000..618a0aa3757 --- /dev/null +++ b/apps/sim/lib/core/application/operation.ts @@ -0,0 +1,15 @@ +import type { Principal } from '@sim/auth/principal' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' + +export interface ApplicationOperation { + readonly id: Id +} + +export interface OperationUseCase { + readonly operation: O + execute(args: { + principal: Principal + input: I + request?: OrchestrationRequestContext + }): Promise +} diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts new file mode 100644 index 00000000000..01d5e904e31 --- /dev/null +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -0,0 +1,117 @@ +import type { DelegatedPrincipal, Principal } from '@sim/auth/principal' +import type { db } from '@sim/db' +import { + type PermissionType, + permissionSatisfies, + resolveEffectiveWorkspacePermission, +} from '@sim/platform-authz/workspace' +import type { + PrincipalForOperation, + WorkspaceOperation, +} from '@/lib/core/application/workspace-operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +export interface WorkspaceAuthorizationContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean +} + +export interface WorkspaceDelegationPolicy { + audience: string + isWithinScope(principal: DelegatedPrincipal, context: C): boolean +} + +export interface WorkspaceAuthorizationOptions { + executor?: Pick + forUpdate?: boolean + delegation?: WorkspaceDelegationPolicy +} + +export function requireAllowedWorkspacePrincipal( + principal: Principal, + operation: O +): asserts principal is PrincipalForOperation { + if (!operation.principalKinds.some((kind) => kind === principal.kind)) { + throw new OrchestrationError( + 'forbidden', + `Principal kind ${principal.kind} cannot perform operation ${operation.id}` + ) + } +} + +function requirePermission(permission: PermissionType | null, required: PermissionType): void { + if (!permissionSatisfies(permission, required)) { + throw new OrchestrationError('forbidden', 'Insufficient workspace permissions') + } +} + +async function requireCurrentHumanPermission( + userId: string, + context: C, + required: PermissionType, + options?: WorkspaceAuthorizationOptions +): Promise { + const permission = await resolveEffectiveWorkspacePermission( + userId, + context.workspaceId, + context.workspaceOrganizationId, + options?.executor, + { forUpdate: options?.forUpdate } + ) + requirePermission(permission, required) +} + +export async function authorizeWorkspaceOperation( + principal: Principal, + operation: WorkspaceOperation, + context: C, + options?: WorkspaceAuthorizationOptions +): Promise { + requireAllowedWorkspacePrincipal(principal, operation) + + switch (principal.kind) { + case 'session': + await requireCurrentHumanPermission(principal.userId, context, operation.minimumRole, options) + return + case 'personal_api_key': + if (!context.allowPersonalApiKeys) { + throw new OrchestrationError( + 'forbidden', + 'Personal API keys are disabled for this workspace' + ) + } + await requireCurrentHumanPermission(principal.userId, context, operation.minimumRole, options) + return + case 'workspace_api_key': + if ( + principal.workspaceId !== context.workspaceId || + operation.workspaceApiKey !== 'allow' || + !permissionSatisfies('write', operation.minimumRole) + ) { + throw new OrchestrationError('forbidden', 'Workspace API key cannot perform this operation') + } + return + case 'delegated': { + const delegation = options?.delegation + if (!delegation) { + throw new Error(`Operation ${operation.id} requires an explicit delegation policy`) + } + if ( + principal.audience !== delegation.audience || + principal.expiresAt.getTime() <= Date.now() || + principal.workspaceId !== context.workspaceId || + !delegation.isWithinScope(principal, context) + ) { + throw new OrchestrationError('forbidden', 'Delegated workspace access is no longer valid') + } + await requireCurrentHumanPermission( + principal.subjectUserId, + context, + operation.minimumRole, + options + ) + return + } + } +} diff --git a/apps/sim/lib/core/application/workspace-operation.ts b/apps/sim/lib/core/application/workspace-operation.ts new file mode 100644 index 00000000000..ec6d731adfb --- /dev/null +++ b/apps/sim/lib/core/application/workspace-operation.ts @@ -0,0 +1,54 @@ +import type { Principal } from '@sim/auth/principal' +import type { PermissionType } from '@sim/platform-authz/workspace' +import type { ApplicationOperation } from '@/lib/core/application/operation' + +type WorkspaceApiKeyPolicy = R extends 'admin' ? 'deny' : 'allow' | 'deny' + +export type PrincipalKind = Principal['kind'] + +export type PrincipalForOperation = + Extract + +export interface WorkspaceOperation< + Id extends string = string, + Role extends PermissionType = PermissionType, + PrincipalKinds extends readonly PrincipalKind[] = readonly PrincipalKind[], +> extends ApplicationOperation { + readonly minimumRole: Role + readonly workspaceApiKey: WorkspaceApiKeyPolicy + readonly principalKinds: PrincipalKinds +} + +type WorkspaceApiKeyPrincipalConsistency< + Role extends PermissionType, + PrincipalKinds extends readonly PrincipalKind[], +> = 'workspace_api_key' extends PrincipalKinds[number] + ? { readonly workspaceApiKey: Role extends 'admin' ? never : 'allow' } + : { readonly workspaceApiKey: 'deny' } + +export function defineWorkspaceOperation< + const Id extends string, + const Role extends PermissionType, + const PrincipalKinds extends readonly PrincipalKind[], +>( + operation: WorkspaceOperation & + WorkspaceApiKeyPrincipalConsistency +): WorkspaceOperation { + if (operation.principalKinds.length === 0) { + throw new Error(`Operation ${operation.id} must allow at least one principal kind`) + } + if (new Set(operation.principalKinds).size !== operation.principalKinds.length) { + throw new Error(`Operation ${operation.id} declares duplicate principal kinds`) + } + + const allowsWorkspaceApiKey = operation.principalKinds.includes('workspace_api_key') + if (allowsWorkspaceApiKey !== (operation.workspaceApiKey === 'allow')) { + throw new Error(`Operation ${operation.id} has inconsistent workspace API key policy`) + } + if (allowsWorkspaceApiKey && !['read', 'write'].includes(operation.minimumRole)) { + throw new Error(`Operation ${operation.id} exceeds the workspace API key write ceiling`) + } + + Object.freeze(operation.principalKinds) + return Object.freeze(operation) +} diff --git a/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts b/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts index 67a3f332719..5283d85285f 100644 --- a/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts +++ b/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts @@ -215,6 +215,37 @@ describe('RateLimiter', () => { expect(result.remaining).toBe(1) }) + it('should propagate storage errors for declarative API operation buckets', async () => { + const failure = new Error('Storage error') + mockAdapter.consumeTokens.mockRejectedValue(failure) + + await expect( + rateLimiter.checkRateLimitWithSubscriptionOrThrow( + testUserId, + freeSubscription, + 'api-endpoint', + false + ) + ).rejects.toBe(failure) + }) + + it('should consume an explicit namespaced subject without rewriting its key', async () => { + const config = RATE_LIMITS.free.apiEndpoint + mockAdapter.consumeTokens.mockResolvedValue({ + allowed: true, + tokensRemaining: config.maxTokens - 1, + resetAt: new Date(Date.now() + 60_000), + }) + + await rateLimiter.checkRateLimitDirectOrThrow('v2:files.rename:api-key:key-1', config) + + expect(mockAdapter.consumeTokens).toHaveBeenCalledWith( + 'v2:files.rename:api-key:key-1', + 1, + config + ) + }) + it('should work for all non-manual trigger types', async () => { const triggerTypes = ['api', 'webhook', 'schedule', 'chat'] as const const mockResult: ConsumeResult = { diff --git a/apps/sim/lib/core/rate-limiter/rate-limiter.ts b/apps/sim/lib/core/rate-limiter/rate-limiter.ts index 9e274839d86..d2e132ecf1e 100644 --- a/apps/sim/lib/core/rate-limiter/rate-limiter.ts +++ b/apps/sim/lib/core/rate-limiter/rate-limiter.ts @@ -66,6 +66,40 @@ export class RateLimiter { } } + private async consumeWithSubscription( + subjectId: string, + subscription: SubscriptionInfo | null, + triggerType: TriggerType, + isAsync: boolean + ): Promise { + if (triggerType === 'manual') { + return this.createUnlimitedResult() + } + + const plan = (subscription?.plan || 'free') as SubscriptionPlan + const rateLimitKey = this.getRateLimitKey(subjectId, subscription) + const counterType = this.getCounterType(triggerType, isAsync) + const config = getRateLimit(plan, counterType) + const storageKey = this.buildStorageKey(rateLimitKey, counterType) + const result = await this.storage.consumeTokens(storageKey, 1, config) + + if (!result.allowed) { + logger.info('Rate limit exceeded', { + rateLimitKey, + counterType, + plan, + tokensRemaining: result.tokensRemaining, + }) + } + + return { + allowed: result.allowed, + remaining: result.tokensRemaining, + resetAt: result.resetAt, + retryAfterMs: result.retryAfterMs, + } + } + async checkRateLimitWithSubscription( userId: string, subscription: SubscriptionInfo | null, @@ -73,33 +107,7 @@ export class RateLimiter { isAsync = false ): Promise { try { - if (triggerType === 'manual') { - return this.createUnlimitedResult() - } - - const plan = (subscription?.plan || 'free') as SubscriptionPlan - const rateLimitKey = this.getRateLimitKey(userId, subscription) - const counterType = this.getCounterType(triggerType, isAsync) - const config = getRateLimit(plan, counterType) - const storageKey = this.buildStorageKey(rateLimitKey, counterType) - - const result = await this.storage.consumeTokens(storageKey, 1, config) - - if (!result.allowed) { - logger.info('Rate limit exceeded', { - rateLimitKey, - counterType, - plan, - tokensRemaining: result.tokensRemaining, - }) - } - - return { - allowed: result.allowed, - remaining: result.tokensRemaining, - resetAt: result.resetAt, - retryAfterMs: result.retryAfterMs, - } + return await this.consumeWithSubscription(userId, subscription, triggerType, isAsync) } catch (error) { logger.error('Rate limit storage error - failing open (allowing request)', { error: toError(error).message, @@ -115,6 +123,20 @@ export class RateLimiter { } } + /** + * Consumes an authenticated request token and propagates storage failures. + * Security-sensitive adapters use this instead of the compatibility method + * above so an unavailable limiter cannot silently admit traffic. + */ + async checkRateLimitWithSubscriptionOrThrow( + subjectId: string, + subscription: SubscriptionInfo | null, + triggerType: TriggerType = 'manual', + isAsync = false + ): Promise { + return this.consumeWithSubscription(subjectId, subscription, triggerType, isAsync) + } + async getRateLimitStatusWithSubscription( userId: string, subscription: SubscriptionInfo | null, @@ -204,6 +226,27 @@ export class RateLimiter { } } + /** + * Consume one token from an already-namespaced bucket and propagate storage + * failures. Declarative API adapters use this for credential/workspace + * buckets so an unavailable limiter is never mistaken for spare capacity. + */ + async checkRateLimitDirectOrThrow( + storageKey: string, + config: { maxTokens: number; refillRate: number; refillIntervalMs: number } + ): Promise { + const result = await this.storage.consumeTokens(storageKey, 1, config) + if (!result.allowed) { + logger.info('Rate limit exceeded', { storageKey, tokensRemaining: result.tokensRemaining }) + } + return { + allowed: result.allowed, + remaining: result.tokensRemaining, + resetAt: result.resetAt, + retryAfterMs: result.retryAfterMs, + } + } + async resetRateLimit(rateLimitKey: string): Promise { try { await Promise.all([ diff --git a/apps/sim/lib/uploads/archive.test.ts b/apps/sim/lib/uploads/archive.test.ts index 2f9ad86e98d..d0da45fe10d 100644 --- a/apps/sim/lib/uploads/archive.test.ts +++ b/apps/sim/lib/uploads/archive.test.ts @@ -10,12 +10,20 @@ const { mockEnsureFolder, mockUpload, mockDelete } = vi.hoisted(() => ({ mockUpload: vi.fn(), mockDelete: vi.fn(), })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ - ensureWorkspaceFileFolderPath: mockEnsureFolder, +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + createWorkspaceFileFolderOperation: { + execute: mockEnsureFolder, + }, })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - uploadWorkspaceFile: mockUpload, - deleteWorkspaceFile: mockDelete, +vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({ + createWorkspaceFileFromBuffer: { + execute: mockUpload, + }, +})) +vi.mock('@/lib/workspace-files/application/delete-workspace-file', () => ({ + deleteWorkspaceFileOperation: { + execute: mockDelete, + }, })) import { @@ -25,6 +33,12 @@ import { MAX_ARCHIVE_ENTRY_BYTES, } from '@/lib/uploads/archive' +const TEST_PRINCIPAL = { + kind: 'session', + userId: 'u', + sessionId: 'session-1', +} as const + async function buildZip( files: Record, opts?: { symlinks?: string[] } @@ -71,16 +85,20 @@ function craftCentralDirectory(records: number, extraPerRecord: number): Buffer beforeEach(() => { vi.clearAllMocks() - mockEnsureFolder.mockResolvedValue('folder_1') + mockEnsureFolder.mockResolvedValue({ folder: { id: 'folder_1' } }) mockDelete.mockResolvedValue(undefined) - mockUpload.mockImplementation(async (_ws: string, _uid: string, buf: Buffer, name: string) => ({ - id: `f_${name}`, - name, - url: `/api/files/serve/${name}`, - key: `workspace/ws/${name}`, - size: buf.length, - type: 'text/plain', - })) + mockUpload.mockImplementation( + async ({ input }: { input: { content: Buffer; name: string } }) => ({ + file: { + id: `f_${input.name}`, + name: input.name, + url: `/api/files/serve/${input.name}`, + key: `workspace/ws/${input.name}`, + size: input.content.length, + type: 'text/plain', + }, + }) + ) }) describe('decompressArchiveBufferToWorkspaceFiles', () => { @@ -89,21 +107,21 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, rootFolderSegments: ['bundle'], }) expect(result.extracted).toHaveLength(2) expect(result.skippedUnsafePaths).toEqual([]) expect(mockUpload).toHaveBeenCalledTimes(2) - const leafNames = mockUpload.mock.calls.map((c) => c[3]).sort() + const leafNames = mockUpload.mock.calls.map(([args]) => args.input.name).sort() expect(leafNames).toEqual(['report.txt', 'sheet.csv']) // Entries are rooted under the archive's folder; nested paths are preserved. expect(mockEnsureFolder).toHaveBeenCalledWith( - expect.objectContaining({ pathSegments: ['bundle'] }) + expect.objectContaining({ input: { workspaceId: 'ws', path: 'bundle' } }) ) expect(mockEnsureFolder).toHaveBeenCalledWith( - expect.objectContaining({ pathSegments: ['bundle', 'data'] }) + expect.objectContaining({ input: { workspaceId: 'ws', path: 'bundle/data' } }) ) }) @@ -116,13 +134,13 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, secretProvenance, }) expect(mockUpload).toHaveBeenCalledTimes(2) for (const call of mockUpload.mock.calls) { - expect(call[5]).toEqual(expect.objectContaining({ secretProvenance })) + expect(call[0].input).toEqual(expect.objectContaining({ secretProvenance })) } }) @@ -134,7 +152,10 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const buffer = craftCentralDirectory(MAX_ARCHIVE_CENTRAL_DIR_RECORDS + 1, 0) await expect( - decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', userId: 'u' }) + decompressArchiveBufferToWorkspaceFiles(buffer, { + workspaceId: 'ws', + principal: TEST_PRINCIPAL, + }) ).rejects.toMatchObject({ name: 'ArchiveError', reason: 'central_dir_too_large', @@ -155,7 +176,10 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const buffer = craftCentralDirectory(records, EXTRA_PER_RECORD) await expect( - decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', userId: 'u' }) + decompressArchiveBufferToWorkspaceFiles(buffer, { + workspaceId: 'ws', + principal: TEST_PRINCIPAL, + }) ).rejects.toMatchObject({ name: 'ArchiveError', reason: 'central_dir_too_large' }) expect(mockUpload).not.toHaveBeenCalled() }) @@ -175,7 +199,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, }) expect(result.extracted).toHaveLength(1) @@ -201,7 +225,10 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { buffer.fill(0xff, nameOffset + 'bad.bin'.length, nameOffset + 'bad.bin'.length + 256) await expect( - decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', userId: 'u' }) + decompressArchiveBufferToWorkspaceFiles(buffer, { + workspaceId: 'ws', + principal: TEST_PRINCIPAL, + }) ).rejects.toMatchObject({ name: 'ArchiveError', reason: 'invalid' }) expect(mockUpload).not.toHaveBeenCalled() }) @@ -212,17 +239,28 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { // must be deleted so callers and retries never observe a partial tree. const buffer = await buildZip({ 'a.txt': 'first', 'b.txt': 'second', 'c.txt': 'third' }) mockUpload - .mockResolvedValueOnce({ id: 'f_a', name: 'a.txt', url: '/a', key: 'k/a', size: 5 }) - .mockResolvedValueOnce({ id: 'f_b', name: 'b.txt', url: '/b', key: 'k/b', size: 6 }) + .mockResolvedValueOnce({ + file: { id: 'f_a', name: 'a.txt', url: '/a', key: 'k/a', size: 5 }, + }) + .mockResolvedValueOnce({ + file: { id: 'f_b', name: 'b.txt', url: '/b', key: 'k/b', size: 6 }, + }) .mockRejectedValueOnce(new Error('storage quota exceeded')) await expect( - decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', userId: 'u' }) + decompressArchiveBufferToWorkspaceFiles(buffer, { + workspaceId: 'ws', + principal: TEST_PRINCIPAL, + }) ).rejects.toThrow('storage quota exceeded') expect(mockDelete).toHaveBeenCalledTimes(2) - expect(mockDelete).toHaveBeenCalledWith('ws', 'f_a') - expect(mockDelete).toHaveBeenCalledWith('ws', 'f_b') + expect(mockDelete).toHaveBeenCalledWith( + expect.objectContaining({ input: { fileId: 'f_a', assertedWorkspaceId: 'ws' } }) + ) + expect(mockDelete).toHaveBeenCalledWith( + expect.objectContaining({ input: { fileId: 'f_b', assertedWorkspaceId: 'ws' } }) + ) }) it('does not count noise entries toward the extraction cap when they are being skipped', async () => { @@ -239,7 +277,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, skipNoiseEntries: true, }) @@ -251,7 +289,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { await expect( decompressArchiveBufferToWorkspaceFiles(Buffer.from('not a zip at all'), { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, }) ).rejects.toMatchObject({ name: 'ArchiveError', reason: 'invalid' }) expect(mockUpload).not.toHaveBeenCalled() @@ -269,7 +307,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, }) // Only the traversal entry counts toward `skipped`; the symlink is filtered @@ -279,7 +317,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { expect(result.skipped).toBe(1) expect(result.skippedUnsafePaths).toEqual(['..\\evil.txt']) expect(mockUpload).toHaveBeenCalledTimes(1) - expect(mockUpload.mock.calls[0][3]).toBe('safe.txt') + expect(mockUpload.mock.calls[0][0].input.name).toBe('safe.txt') }) it('extracts macOS/Windows filesystem-noise entries by default (skipNoiseEntries unset)', async () => { @@ -287,7 +325,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, }) // Parity with the HTTP decompress route, which extracts these verbatim. @@ -301,7 +339,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, skipNoiseEntries: true, }) @@ -320,7 +358,10 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { ) await expect( - decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', userId: 'u' }) + decompressArchiveBufferToWorkspaceFiles(buffer, { + workspaceId: 'ws', + principal: TEST_PRINCIPAL, + }) ).rejects.toMatchObject({ name: 'ArchiveError', reason: 'entry_too_large' }) expect(mockUpload).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/uploads/archive.ts b/apps/sim/lib/uploads/archive.ts index 89c118f41f7..856c1a10e40 100644 --- a/apps/sim/lib/uploads/archive.ts +++ b/apps/sim/lib/uploads/archive.ts @@ -1,14 +1,13 @@ import { Buffer } from 'buffer' import type { Readable } from 'stream' +import type { Principal } from '@sim/auth/principal' import JSZip from 'jszip' import { readZipCentralDirectoryStats } from '@/lib/file-parsers/zip-guard' -import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - deleteWorkspaceFile, - uploadWorkspaceFile, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { createWorkspaceFileFromBuffer } from '@/lib/workspace-files/application/create-workspace-file' +import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' +import { createWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' import type { UserFile } from '@/executor/types' /** @@ -261,7 +260,7 @@ export async function decompressArchiveBufferToWorkspaceFiles( buffer: Buffer, opts: { workspaceId: string - userId: string + principal: Principal rootFolderSegments?: string[] skipNoiseEntries?: boolean secretProvenance?: WorkspaceFileSecretProvenance @@ -269,7 +268,7 @@ export async function decompressArchiveBufferToWorkspaceFiles( ): Promise { const { workspaceId, - userId, + principal, rootFolderSegments = [], skipNoiseEntries = false, secretProvenance = { status: 'unknown' }, @@ -357,32 +356,50 @@ export async function decompressArchiveBufferToWorkspaceFiles( const folderKey = folderSegments.join('/') let folderId = folderIdCache.get(folderKey) if (folderId === undefined) { - folderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId, - pathSegments: folderSegments, - }) + if (folderSegments.length === 0) { + folderId = null + } else { + const result = await createWorkspaceFileFolderOperation.execute({ + principal, + input: { workspaceId, path: folderSegments.join('/') }, + }) + folderId = result.folder.id + } folderIdCache.set(folderKey, folderId) } const mimeType = getMimeTypeFromExtension(getFileExtension(leafName)) - const uploaded = await uploadWorkspaceFile( - workspaceId, - userId, - entryBuffer, - leafName, - mimeType, - { - folderId, - secretProvenance, - } - ) - extracted.push(uploaded) + const uploaded = ( + await createWorkspaceFileFromBuffer.execute({ + principal, + input: { + workspaceId, + content: entryBuffer, + name: leafName, + contentType: mimeType, + folderId, + exactName: true, + secretProvenance, + }, + }) + ).file + extracted.push({ + id: uploaded.id, + name: uploaded.name, + url: uploaded.url ?? uploaded.path, + size: uploaded.size, + type: uploaded.type, + key: uploaded.key, + context: 'workspace', + }) } } catch (error) { for (const file of extracted) { try { - await deleteWorkspaceFile(workspaceId, file.id) + await deleteWorkspaceFileOperation.execute({ + principal, + input: { fileId: file.id, assertedWorkspaceId: workspaceId }, + }) } catch { // Best-effort: a file whose cleanup fails is still soft-deletable by hand; // the original error is what the caller needs to see. diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 8ca14de9e32..b0a18325452 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { folder as folderTable, workspaceFiles } from '@sim/db/schema' +import { folder as folderTable, workspaceFiles, workspace as workspaceTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -16,6 +16,7 @@ import { requireNonRootFolderPath, } from '@/lib/folders/paths' import { collectDescendantFolderIds } from '@/lib/folders/subtree' +import { MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS } from '@/lib/workspace-files/limits' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceFileFolders') @@ -72,6 +73,36 @@ export interface WorkspaceFileFolderRecord { updatedAt: Date } +export interface WorkspaceFileOperationContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} + +/** + * Loads the active workspace authorization context for folder and bulk-file operations. + * The workspace row is the canonical scope; callers must not authorize from a caller-supplied + * folder or file workspace id. + */ +export async function loadWorkspaceFileOperationContext( + workspaceId: string +): Promise { + const workspace = await getWorkspaceWithOwner(workspaceId) + if (!workspace) return null + const [settings] = await db + .select({ allowPersonalApiKeys: workspaceTable.allowPersonalApiKeys }) + .from(workspaceTable) + .where(eq(workspaceTable.id, workspaceId)) + .limit(1) + return { + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: settings?.allowPersonalApiKeys ?? false, + billedAccountUserId: workspace.billedAccountUserId, + } +} + interface RawWorkspaceFileFolder { id: string workspaceId: string @@ -89,6 +120,69 @@ export interface WorkspaceFileArchiveResult { files: number } +export interface WorkspaceFileBulkArchiveResult extends WorkspaceFileArchiveResult { + folderIds: string[] + fileIds: string[] +} + +function assertBulkAffectedItemsWithinLimit(count: number): void { + if (count > MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS) { + throw new OrchestrationError( + 'validation', + `File operation affects more than ${MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS} items` + ) + } +} + +/** + * Verifies every requested active file/folder belongs to this workspace before a bulk mutation. + * This prevents the bulk archive primitive's workspace predicate from silently turning an + * out-of-scope id into a successful zero-row operation. + */ +export async function assertWorkspaceFileItemsBelongToWorkspace(params: { + workspaceId: string + fileIds?: string[] + folderIds?: string[] +}): Promise { + const fileIds = Array.from(new Set(params.fileIds ?? [])) + const folderIds = Array.from(new Set(params.folderIds ?? [])) + const [files, folders] = await Promise.all([ + fileIds.length === 0 + ? Promise.resolve([]) + : db + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + inArray(workspaceFiles.id, fileIds), + eq(workspaceFiles.workspaceId, params.workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ), + folderIds.length === 0 + ? Promise.resolve([]) + : db + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + inArray(folderTable.id, folderIds), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) + ) + ), + ]) + const foundFiles = new Set(files.map((file) => file.id)) + const foundFolders = new Set(folders.map((folder) => folder.id)) + const missingFiles = fileIds.filter((id) => !foundFiles.has(id)) + const missingFolders = folderIds.filter((id) => !foundFolders.has(id)) + if (missingFiles.length > 0 || missingFolders.length > 0) { + throw new WorkspaceFileItemsNotFoundError(missingFiles, missingFolders) + } +} + export interface WorkspaceFileFolderRestoreResult { folder: WorkspaceFileFolderRecord restoredItems: WorkspaceFileArchiveResult @@ -717,7 +811,12 @@ export async function moveWorkspaceFileItems(params: { folderIds?: string[] targetFolderId?: string | null targetFolderPath?: string -}): Promise<{ movedFiles: number; movedFolders: number }> { +}): Promise<{ + movedFiles: number + movedFolders: number + movedFileIds: string[] + movedFolderIds: string[] +}> { const fileIds = Array.from(new Set(params.fileIds ?? [])) const folderIds = Array.from(new Set(params.folderIds ?? [])) if (params.targetFolderId !== undefined && params.targetFolderPath !== undefined) { @@ -777,9 +876,16 @@ export async function moveWorkspaceFileItems(params: { isNull(folderTable.deletedAt) ) ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + + assertBulkAffectedItemsWithinLimit(activeFolders.length) + + const affectedFolderIds = new Set() for (const folderId of folderIds) { const descendants = collectDescendantFolderIds(activeFolders, folderId) + affectedFolderIds.add(folderId) + for (const descendantId of descendants) affectedFolderIds.add(descendantId) if (targetFolderId && descendants.includes(targetFolderId)) { throw new OrchestrationError( 'validation', @@ -787,6 +893,24 @@ export async function moveWorkspaceFileItems(params: { ) } } + + assertBulkAffectedItemsWithinLimit(affectedFolderIds.size + fileIds.length) + if (affectedFolderIds.size > 0) { + const descendantFiles = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + inArray(workspaceFiles.folderId, [...affectedFolderIds]), + eq(workspaceFiles.workspaceId, params.workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + const affectedFileIds = new Set([...fileIds, ...descendantFiles.map((file) => file.id)]) + assertBulkAffectedItemsWithinLimit(affectedFolderIds.size + affectedFileIds.size) + } } const movingFiles = @@ -909,7 +1033,12 @@ export async function moveWorkspaceFileItems(params: { .returning({ id: folderTable.id }) : [] - return { movedFiles: movedFiles.length, movedFolders: movedFolders.length } + return { + movedFiles: movedFiles.length, + movedFolders: movedFolders.length, + movedFileIds: movedFiles.map((file) => file.id), + movedFolderIds: movedFolders.map((folder) => folder.id), + } }) } @@ -943,7 +1072,24 @@ export async function archiveWorkspaceFileFolderRecursive( .where( and(eq(folderTable.workspaceId, workspaceId), isFileFolder, isNull(folderTable.deletedAt)) ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + assertBulkAffectedItemsWithinLimit(activeFolders.length) const folderIds = [folderId, ...collectDescendantFolderIds(activeFolders, folderId)] + assertBulkAffectedItemsWithinLimit(folderIds.length) + + const affectedFiles = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + inArray(workspaceFiles.folderId, folderIds), + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + assertBulkAffectedItemsWithinLimit(folderIds.length + affectedFiles.length) const archivedFiles = await tx .update(workspaceFiles) @@ -1069,6 +1215,7 @@ export async function restoreWorkspaceFileFolder( ) .returning({ id: workspaceFiles.id }) stats.files += restoredFiles.length + assertBulkAffectedItemsWithinLimit(stats.files + stats.folders) const archivedChildren = await tx .select({ id: folderTable.id }) @@ -1081,6 +1228,8 @@ export async function restoreWorkspaceFileFolder( eq(folderTable.deletedAt, folderDeletedAt) ) ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + assertBulkAffectedItemsWithinLimit(stats.files + stats.folders + archivedChildren.length) for (const child of archivedChildren) { const [restoredChild] = await tx @@ -1098,6 +1247,7 @@ export async function restoreWorkspaceFileFolder( if (!restoredChild) continue stats.folders += 1 + assertBulkAffectedItemsWithinLimit(stats.files + stats.folders) await restoreFolderSubtree(child.id) } } @@ -1116,6 +1266,7 @@ export async function restoreWorkspaceFileFolder( .returning() stats.folders += 1 + assertBulkAffectedItemsWithinLimit(stats.files + stats.folders) await restoreFolderSubtree(folderId) return { restored: row, restoredItems: stats } @@ -1140,7 +1291,7 @@ export async function bulkArchiveWorkspaceFileItems(params: { workspaceId: string fileIds?: string[] folderIds?: string[] -}): Promise { +}): Promise { const now = new Date() const explicitFileIds = Array.from(new Set(params.fileIds ?? [])) const explicitFolderIds = Array.from(new Set(params.folderIds ?? [])) @@ -1160,11 +1311,32 @@ export async function bulkArchiveWorkspaceFileItems(params: { isNull(folderTable.deletedAt) ) ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) : [] + assertBulkAffectedItemsWithinLimit(activeFolders.length) const descendantFolderIds = explicitFolderIds.flatMap((folderId) => collectDescendantFolderIds(activeFolders, folderId) ) const allFolderIds = Array.from(new Set([...explicitFolderIds, ...descendantFolderIds])) + assertBulkAffectedItemsWithinLimit(allFolderIds.length + explicitFileIds.length) + + const descendantFiles = + allFolderIds.length > 0 + ? await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + inArray(workspaceFiles.folderId, allFolderIds), + eq(workspaceFiles.workspaceId, params.workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + : [] + const affectedFileIds = new Set([...explicitFileIds, ...descendantFiles.map((file) => file.id)]) + assertBulkAffectedItemsWithinLimit(allFolderIds.length + affectedFileIds.size) const archivedExplicitFiles = explicitFileIds.length > 0 @@ -1214,10 +1386,15 @@ export async function bulkArchiveWorkspaceFileItems(params: { .returning({ id: folderTable.id }) : [] + const archivedFileIds = Array.from( + new Set([...archivedExplicitFiles, ...archivedDescendantFiles].map((file) => file.id)) + ) + const archivedFolderIds = archivedFolders.map((folder) => folder.id) return { - folders: archivedFolders.length, - files: new Set([...archivedExplicitFiles, ...archivedDescendantFiles].map((file) => file.id)) - .size, + folders: archivedFolderIds.length, + files: archivedFileIds.length, + folderIds: archivedFolderIds, + fileIds: archivedFileIds, } }) } diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts index 1f51abd4b37..04300f1765a 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts @@ -3,7 +3,7 @@ */ import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { listWorkspaceFiles } from './workspace-file-manager' +import { listWorkspaceFiles, loadActiveWorkspaceFileContext } from './workspace-file-manager' afterAll(resetDbChainMock) @@ -24,3 +24,35 @@ describe('listWorkspaceFiles error handling', () => { ) }) }) + +describe('loadActiveWorkspaceFileContext', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns the canonical workspace authorization context', async () => { + const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + } + dbChainMockFns.limit.mockResolvedValueOnce([context]) + + await expect(loadActiveWorkspaceFileContext('file-1')).resolves.toEqual(context) + }) + + it('returns null when the active file does not exist', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + await expect(loadActiveWorkspaceFileContext('missing-file')).resolves.toBeNull() + }) + + it('propagates database failures', async () => { + dbChainMockFns.limit.mockRejectedValueOnce(new Error('database unavailable')) + + await expect(loadActiveWorkspaceFileContext('file-1')).rejects.toThrow('database unavailable') + }) +}) 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 3cb689a7af2..bd5075549cd 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -5,7 +5,7 @@ import { randomBytes } from 'crypto' import { db } from '@sim/db' -import { workspaceFiles } from '@sim/db/schema' +import { uploadSession, workspace, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { describeError, @@ -135,6 +135,25 @@ export interface UploadedWorkspaceFileRecord extends WorkspaceFileRecord { deletedAt: Date | null } +export interface ActiveWorkspaceFileContext { + fileId: string + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} + +export interface WorkspaceFileLifecycleContext extends ActiveWorkspaceFileContext { + deletedAt: Date | null +} + +export interface ActiveWorkspaceContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} + interface ListWorkspaceFilesOptions { scope?: WorkspaceFileScope folders?: WorkspaceFileFolderRecord[] @@ -551,6 +570,7 @@ export async function registerUploadedWorkspaceFile(params: { originalName: string contentType: string folderId?: string | null + uploadSessionId?: string }): Promise { const { workspaceId, userId, key, originalName, contentType } = params const normalizedOriginalName = normalizeWorkspaceFileItemName(originalName, 'File') @@ -592,6 +612,7 @@ export async function registerUploadedWorkspaceFile(params: { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE ) } + await markUploadSessionFileRegistered(tx, params.uploadSessionId, workspaceId, found.id) return found }) if (existing) { @@ -650,6 +671,12 @@ export async function registerUploadedWorkspaceFile(params: { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE ) } + await markUploadSessionFileRegistered( + tx, + params.uploadSessionId, + workspaceId, + raceWinner.id + ) return { kind: 'existing', file: raceWinner } as const } @@ -664,6 +691,7 @@ export async function registerUploadedWorkspaceFile(params: { inserted.contentUpdatedAt, EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE ) + await markUploadSessionFileRegistered(tx, params.uploadSessionId, workspaceId, inserted.id) return { kind: 'created', file: inserted, updatedUsage } as const }) @@ -696,6 +724,29 @@ export async function registerUploadedWorkspaceFile(params: { throw new FileConflictError(normalizedOriginalName) } +async function markUploadSessionFileRegistered( + tx: DbOrTx, + uploadSessionId: string | undefined, + workspaceId: string, + fileId: string +): Promise { + if (!uploadSessionId) return + const [marked] = await tx + .update(uploadSession) + .set({ completedFileId: fileId, updatedAt: new Date() }) + .where( + and( + eq(uploadSession.id, uploadSessionId), + eq(uploadSession.workspaceId, workspaceId), + eq(uploadSession.purpose, 'workspace_file'), + eq(uploadSession.status, 'finalizing'), + or(isNull(uploadSession.completedFileId), eq(uploadSession.completedFileId, fileId)) + ) + ) + .returning({ id: uploadSession.id }) + if (!marked) throw new Error('Workspace upload registration marker could not be persisted') +} + function assertActiveWorkspaceFileRegistration(file: typeof workspaceFiles.$inferSelect): void { if (file.deletedAt) { throw new OrchestrationError('conflict', 'Upload result was deleted') @@ -1325,7 +1376,7 @@ export async function resolveWorkspaceFileReference( ): Promise { const normalizedReference = normalizeWorkspaceFileReference(fileReference) if (normalizedReference.startsWith('wf_')) { - const file = await getWorkspaceFile(workspaceId, normalizedReference) + const file = await getWorkspaceFile(workspaceId, normalizedReference, { throwOnError: true }) if (file) return file } @@ -1339,6 +1390,85 @@ export async function resolveWorkspaceFileReference( return findWorkspaceFileRecord(files, fileReference) } +/** + * Load the canonical authorization context for an active workspace file by resource ID. + * Database failures propagate so callers never confuse unavailable state with a missing file. + */ +export async function loadActiveWorkspaceFileContext( + fileId: string, + options?: { includeDeleted?: boolean } +): Promise { + const [context] = await db + .select({ + fileId: workspaceFiles.id, + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workspaceFiles) + .innerJoin(workspace, eq(workspaceFiles.workspaceId, workspace.id)) + .where( + and( + eq(workspaceFiles.id, fileId), + eq(workspaceFiles.context, 'workspace'), + ...(options?.includeDeleted ? [] : [isNull(workspaceFiles.deletedAt)]), + isNull(workspace.archivedAt) + ) + ) + .limit(1) + + return context ?? null +} + +/** + * Load a workspace file for a lifecycle transition, including archived files. + * The workspace archive state is returned by the canonical workspace record and is enforced by + * the operation's manager primitive where the transition requires an active workspace. + */ +export async function loadWorkspaceFileLifecycleContext( + fileId: string +): Promise { + const [context] = await db + .select({ + fileId: workspaceFiles.id, + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + deletedAt: workspaceFiles.deletedAt, + }) + .from(workspaceFiles) + .innerJoin(workspace, eq(workspaceFiles.workspaceId, workspace.id)) + .where(and(eq(workspaceFiles.id, fileId), eq(workspaceFiles.context, 'workspace'))) + .limit(1) + + return context ?? null +} + +/** + * Load the canonical authorization context for an active workspace. + * + * The query deliberately throws database failures so callers cannot mistake an unavailable + * workspace for a missing one. Authentication and authorization remain the caller's concern. + */ +export async function loadActiveWorkspaceContext( + workspaceId: string +): Promise { + const [context] = await db + .select({ + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + return context ?? null +} + /** * Get a specific workspace file. * @@ -1732,7 +1862,7 @@ export async function renameWorkspaceFile( const trimmedName = newName.trim() const normalizedName = normalizeWorkspaceFileItemName(trimmedName, 'File') - const fileRecord = await getWorkspaceFile(workspaceId, fileId) + const fileRecord = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) if (!fileRecord) { throw new OrchestrationError('not_found', 'File not found') } @@ -1881,7 +2011,7 @@ export async function deleteWorkspaceFile(workspaceId: string, fileId: string): logger.info(`Successfully archived workspace file: ${archived.originalName}`) } catch (error) { logger.error(`Failed to delete workspace file ${fileId}:`, error) - throw new Error(`Failed to delete file: ${getErrorMessage(error, 'Unknown error')}`) + throw error } } diff --git a/apps/sim/lib/uploads/upload-session/application.test.ts b/apps/sim/lib/uploads/upload-session/application.test.ts new file mode 100644 index 00000000000..bdcde448e22 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/application.test.ts @@ -0,0 +1,117 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + assertAuthBinding: vi.fn(), + completeSession: vi.fn(), + finalizePurpose: vi.fn(), + getOwnedSession: vi.fn(), + reauthorizeWorkspacePurpose: vi.fn(), +})) + +vi.mock('@/lib/uploads/upload-session/service', () => ({ + abortUploadSession: vi.fn(), + assertUploadSessionAuthBinding: mocks.assertAuthBinding, + completeUploadSession: mocks.completeSession, + createUploadPartUrls: vi.fn(), + createUploadSession: vi.fn(), + getOwnedUploadSession: mocks.getOwnedSession, + getPrincipalUploadSession: vi.fn(), +})) + +vi.mock('@/app/api/files/uploads/finalizers', () => ({ + finalizeUploadPurpose: mocks.finalizePurpose, + finalizeWorkspaceFileUpload: vi.fn(), + loadCompletedUploadPurpose: vi.fn(), + loadCompletedWorkspaceFileUpload: vi.fn(), +})) + +vi.mock('@/app/api/files/uploads/purposes', () => ({ + createPurposeUploadSession: vi.fn(), + reauthorizeUploadPurpose: vi.fn(), + reauthorizeWorkspaceUploadPurpose: mocks.reauthorizeWorkspacePurpose, + resolveUploadAttributionUserId: vi.fn(), +})) + +import { completeInternalUploadSession } from '@/lib/uploads/upload-session/application' +import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' + +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const actor = { id: 'user-1', name: 'Ada', email: 'ada@example.com' } + +describe('upload session application', () => { + beforeEach(() => { + vi.clearAllMocks() + const session = workspaceUploadSession() + mocks.getOwnedSession.mockResolvedValue(session) + mocks.finalizePurpose.mockResolvedValue({ + value: { id: 'file-1' }, + completedFileId: 'file-1', + }) + mocks.completeSession.mockImplementation(async ({ session: claimed, finalize }) => { + const finalized = await finalize(claimed) + return { + session: { ...claimed, status: 'completed', completedFileId: finalized.completedFileId }, + value: finalized.value, + alreadyCompleted: false, + } + }) + }) + + it('preserves the authenticated actor metadata through internal finalization', async () => { + const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete', { + method: 'POST', + }) + + await completeInternalUploadSession( + principal, + { uploadId: 'upload-1', uploadToken: 'upload-token', actor }, + request + ) + + expect(mocks.finalizePurpose).toHaveBeenCalledWith( + expect.objectContaining({ actor, principal, request }) + ) + }) +}) + +function workspaceUploadSession(): UploadSessionRecord { + const now = new Date('2026-08-08T00:00:00.000Z') + return { + id: 'upload-1', + workspaceId: 'workspace-1', + userId: principal.userId, + knowledgeBaseId: null, + workflowId: null, + executionId: null, + purpose: 'workspace_file', + method: 'put', + storageContext: 'workspace', + storageKey: 'workspace/workspace-1/file.txt', + finalKey: 'workspace/workspace-1/file.txt', + storageProvider: 's3', + providerUploadId: null, + providerObjectVersion: 'version-1', + fileName: 'file.txt', + contentType: 'text/plain', + fileSize: 4, + partSize: null, + partCount: null, + status: 'finalizing', + metadata: {}, + uploadToken: 'upload-token', + createdAt: now, + expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000), + completedFileId: null, + error: null, + completedAt: null, + updatedAt: now, + } +} diff --git a/apps/sim/lib/uploads/upload-session/application.ts b/apps/sim/lib/uploads/upload-session/application.ts new file mode 100644 index 00000000000..e423b79dd85 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/application.ts @@ -0,0 +1,350 @@ +import type { Principal } from '@sim/auth/principal' +import type { CreateInternalFileUploadBody } from '@/lib/api/contracts/upload-sessions' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { + abortUploadSession, + assertUploadSessionAuthBinding, + completeUploadSession, + createUploadPartUrls, + createUploadSession, + getOwnedUploadSession, + getPrincipalUploadSession, + type UploadSessionRecord, + type UploadSessionTransfer, +} from '@/lib/uploads/upload-session/service' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { authorizeWorkspaceFileOperation } from '@/lib/workspace-files/application/workspace-operation-context' +import { + finalizeUploadPurpose, + finalizeWorkspaceFileUpload, + loadCompletedUploadPurpose, + loadCompletedWorkspaceFileUpload, + type UploadActor, +} from '@/app/api/files/uploads/finalizers' +import { + createPurposeUploadSession, + reauthorizeUploadPurpose, + reauthorizeWorkspaceUploadPurpose, + resolveUploadAttributionUserId, +} from '@/app/api/files/uploads/purposes' + +export interface WorkspaceFileUploadCreateInput { + workspaceId: string + name: string + contentType: string + size: number + folderId?: string | null + localOrigin: string +} + +export interface UploadSessionControlInput { + uploadId: string + uploadToken: string + workspaceId?: string + localOrigin?: string + partNumbers?: number[] + actor?: UploadActor +} + +export interface InternalUploadSessionControlInput extends UploadSessionControlInput { + partNumbers?: number[] +} + +export interface UploadSessionCreateResult { + session: Awaited> +} + +/** Creates a workspace-file session after current principal authorization. */ +export async function createWorkspaceFileUploadSession( + principal: Principal, + input: WorkspaceFileUploadCreateInput +): Promise>> { + const userId = await resolveUploadAttributionUserId(principal, input.workspaceId) + return createUploadSession({ + purpose: 'workspace_file', + workspaceId: input.workspaceId, + userId, + principal, + fileName: input.name, + contentType: input.contentType, + fileSize: input.size, + metadata: { folderId: input.folderId ?? null }, + localOrigin: input.localOrigin, + }) +} + +/** Internal purpose-aware create use case; authentication remains at the route adapter. */ +export async function createInternalPurposeUploadSession( + principal: Principal, + body: CreateInternalFileUploadBody, + request: OrchestrationRequestContext +): Promise>> { + return createPurposeUploadSession(principal, body, requestOrigin(request)) +} + +/** Loads an internal session while binding workspace-file sessions to the principal. */ +export async function loadAuthorizedInternalUploadSession( + principal: Principal, + input: UploadSessionControlInput +): Promise { + if (input.workspaceId !== undefined) return loadAuthorizedWorkspaceUploadSession(principal, input) + const session = await getOwnedUploadSession({ + uploadId: input.uploadId, + uploadToken: input.uploadToken, + userId: principalUserId(principal), + }) + if (session.purpose === 'workspace_file') assertUploadSessionAuthBinding(session, principal) + return session +} + +export async function issueInternalUploadPartUrls( + principal: Principal, + input: InternalUploadSessionControlInput, + request: OrchestrationRequestContext +): Promise<{ parts: Awaited> }> { + const session = await loadAuthorizedInternalUploadSession(principal, input) + if (session.purpose === 'workspace_file') { + await reauthorizeWorkspaceUploadPurpose(principal, session, fileOperations.uploadParts) + } else { + await reauthorizeUploadPurpose(principalUserId(principal), session) + } + return { + parts: await createUploadPartUrls({ + session, + partNumbers: input.partNumbers ?? [], + localOrigin: requestOrigin(request), + }), + } +} + +export async function abortInternalUploadSession( + principal: Principal, + input: UploadSessionControlInput +): Promise { + const session = await loadAuthorizedInternalUploadSession(principal, input) + if (session.purpose === 'workspace_file') { + await reauthorizeWorkspaceUploadPurpose(principal, session, fileOperations.uploadCancel) + } else { + await reauthorizeUploadPurpose(principalUserId(principal), session) + } + return abortUploadSession(session) +} + +export async function completeInternalUploadSession( + principal: Principal, + input: UploadSessionControlInput, + request: OrchestrationRequestContext +): Promise<{ + session: UploadSessionRecord + value: import('@/app/api/files/uploads/finalizers').UploadPurposeResult + alreadyCompleted: boolean +}> { + const session = await loadAuthorizedInternalUploadSession(principal, input) + const authorize = async (claimed: UploadSessionRecord) => { + if (claimed.purpose === 'workspace_file') { + await reauthorizeWorkspaceUploadPurpose(principal, claimed, fileOperations.uploadComplete) + } else { + await reauthorizeUploadPurpose(principalUserId(principal), claimed) + } + } + await authorize(session) + const actor: UploadActor = input.actor ?? { id: principalUserId(principal) } + return completeUploadSession({ + session, + loadCompleted: async (claimed) => { + await authorize(claimed) + return loadCompletedUploadPurpose(claimed) + }, + finalize: async (claimed) => { + await authorize(claimed) + const finalized = await finalizeUploadPurpose({ + session: claimed, + actor, + request, + principal, + authorizeBeforeRegistration: () => authorize(claimed), + }) + return { value: finalized.value, completedFileId: finalized.completedFileId } + }, + }) +} + +/** Loads and binds a workspace-file session to the fresh authenticated principal. */ +export async function loadAuthorizedWorkspaceUploadSession( + principal: Principal, + input: UploadSessionControlInput +): Promise { + return getPrincipalUploadSession({ + uploadId: input.uploadId, + uploadToken: input.uploadToken, + principal, + workspaceId: input.workspaceId, + }) +} + +/** Issues multipart URLs after current workspace authorization. */ +export async function issueWorkspaceUploadPartUrls( + principal: Principal, + input: UploadSessionControlInput +): Promise<{ parts: Awaited> }> { + const session = await loadAuthorizedWorkspaceUploadSession(principal, input) + await reauthorizeWorkspaceUploadPurpose(principal, session, fileOperations.uploadParts) + if (!input.localOrigin) throw new Error('Upload part URL issuance requires a local origin') + const parts = await createUploadPartUrls({ + session, + partNumbers: input.partNumbers ?? [], + localOrigin: input.localOrigin, + }) + return { parts } +} + +/** Aborts an upload after current workspace authorization. */ +export async function abortWorkspaceUploadSession( + principal: Principal, + input: UploadSessionControlInput +): Promise { + const session = await loadAuthorizedWorkspaceUploadSession(principal, input) + await reauthorizeWorkspaceUploadPurpose(principal, session, fileOperations.uploadCancel) + return abortUploadSession(session) +} + +/** + * Completes an upload and re-checks authorization immediately before durable + * workspace-file registration. Completed retries use the durable session/file + * result through the finalizer rather than registering a second file. + */ +export async function completeWorkspaceUploadSession( + principal: Principal, + input: UploadSessionControlInput, + request: OrchestrationRequestContext +): Promise<{ + session: UploadSessionRecord + value: WorkspaceFileRecord + alreadyCompleted: boolean +}> { + const session = await loadAuthorizedWorkspaceUploadSession(principal, input) + await reauthorizeWorkspaceUploadPurpose(principal, session, fileOperations.uploadComplete) + const actor: UploadActor = input.actor ?? { + id: await resolveUploadAttributionUserId(principal, session.workspaceId ?? ''), + } + return completeUploadSession({ + session, + loadCompleted: async (claimed) => { + await reauthorizeWorkspaceUploadPurpose(principal, claimed, fileOperations.uploadComplete) + return loadCompletedWorkspaceFileUpload(claimed) + }, + finalize: async (claimed) => { + await reauthorizeWorkspaceUploadPurpose(principal, claimed, fileOperations.uploadComplete) + const finalized = await finalizeWorkspaceFileUpload({ + session: claimed, + actor, + request, + source: 'api', + principal, + authorizeBeforeRegistration: async () => { + await reauthorizeWorkspaceUploadPurpose(principal, claimed, fileOperations.uploadComplete) + }, + }) + return { value: finalized.file, completedFileId: finalized.file.id } + }, + }) +} + +export interface CreateWorkspaceFileUploadOperationInput { + workspaceId: string + name: string + contentType: string + size: number + folderPath: string +} + +export const createWorkspaceFileUploadOperation = { + operation: fileOperations.uploadCreate, + async execute({ + principal, + input, + request, + }: { + principal: Principal + input: CreateWorkspaceFileUploadOperationInput + request?: OrchestrationRequestContext + }) { + if (!request) throw new Error('Workspace upload creation requires a request context') + await authorizeWorkspaceFileOperation(principal, fileOperations.uploadCreate, input.workspaceId) + const folderIndex = await loadActiveFolderPathIndex(input.workspaceId, 'file') + const folderId = resolveFolderPathFromIndex(folderIndex, input.folderPath) + if (folderId === undefined) throw new OrchestrationError('not_found', 'Folder not found') + return createWorkspaceFileUploadSession(principal, { + workspaceId: input.workspaceId, + name: input.name, + contentType: input.contentType, + size: input.size, + folderId, + localOrigin: requestOrigin(request), + }) + }, +} as const + +export const issueWorkspaceFileUploadPartsOperation = { + operation: fileOperations.uploadParts, + async execute({ + principal, + input, + request, + }: { + principal: Principal + input: UploadSessionControlInput + request?: OrchestrationRequestContext + }) { + if (!request) throw new Error('Upload part URL issuance requires a request context') + return issueWorkspaceUploadPartUrls(principal, { + ...input, + localOrigin: requestOrigin(request), + }) + }, +} as const + +export const completeWorkspaceFileUploadOperation = { + operation: fileOperations.uploadComplete, + async execute({ + principal, + input, + request, + }: { + principal: Principal + input: UploadSessionControlInput + request?: OrchestrationRequestContext + }) { + if (!request) throw new Error('Upload completion requires a request context') + return completeWorkspaceUploadSession(principal, input, request) + }, +} as const + +export const abortWorkspaceFileUploadOperation = { + operation: fileOperations.uploadCancel, + async execute({ principal, input }: { principal: Principal; input: UploadSessionControlInput }) { + return abortWorkspaceUploadSession(principal, input) + }, +} as const + +function principalUserId(principal: Principal): string { + if (principal.kind === 'session' || principal.kind === 'personal_api_key') { + return principal.userId + } + throw new Error('Workspace upload attribution must be resolved from the current workspace owner') +} + +export function requestOrigin(request: OrchestrationRequestContext & { nextUrl?: URL }): string { + if (request.nextUrl instanceof URL) return request.nextUrl.origin + const origin = request.headers.get('origin') + if (origin) return origin + const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') + const protocol = request.headers.get('x-forwarded-proto') ?? 'https' + if (!host) throw new Error('Upload signing requires a request origin') + return `${protocol}://${host}` +} + +export type { UploadSessionTransfer } diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts index 1e7b16256c6..ea5901633c2 100644 --- a/apps/sim/lib/uploads/upload-session/service.test.ts +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -3,7 +3,7 @@ */ import { sha256Hex } from '@sim/security/hash' import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' -import { inArray } from 'drizzle-orm' +import { eq, inArray, isNull } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -57,6 +57,7 @@ vi.mock('@/lib/uploads/upload-session/provider', () => ({ import { abortUploadSession, + assertUploadSessionAuthBinding, cleanupExpiredUploadSessions, completeUploadSession, createUploadSession, @@ -110,6 +111,85 @@ describe('upload sessions', () => { expect(inserted.tokenHash).toBe(sha256Hex(created.uploadToken)) expect(inserted.tokenHash).not.toBe(created.uploadToken) expect(inserted.finalKey).toBe(FINAL_KEY) + expect(inserted.metadata.authBinding).toEqual({ + version: 1, + workspaceId: WORKSPACE_ID, + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + }) + }) + + it('rejects workspace control access without the matching immutable credential binding', async () => { + const row = uploadRow({ + metadata: { + authBinding: { + version: 1, + workspaceId: WORKSPACE_ID, + principal: { kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'key-1' }, + }, + }, + }) + queueTableRows(schemaMock.uploadSession, [row]) + + await expect( + getOwnedUploadSession({ + uploadId: row.id, + uploadToken: 'upload-secret', + principal: { kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'key-2' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('preserves legacy unbound sessions under their prior ownership rules', () => { + const legacy = sessionRecord({ metadata: {} }) + + expect(() => + assertUploadSessionAuthBinding(legacy, { + kind: 'session', + userId: legacy.userId, + sessionId: 'current-session', + }) + ).not.toThrow() + expect(() => + assertUploadSessionAuthBinding(legacy, { + kind: 'personal_api_key', + userId: legacy.userId, + keyId: 'current-key', + }) + ).not.toThrow() + expect(() => + assertUploadSessionAuthBinding(legacy, { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'current-workspace-key', + }) + ).not.toThrow() + + expect(() => + assertUploadSessionAuthBinding(legacy, { + kind: 'session', + userId: 'different-user', + sessionId: 'current-session', + }) + ).toThrow('Upload session not found') + expect(() => + assertUploadSessionAuthBinding(legacy, { + kind: 'workspace_api_key', + workspaceId: 'different-workspace', + keyId: 'current-workspace-key', + }) + ).toThrow('Upload session not found') + }) + + it('never treats a malformed credential binding as a legacy session', () => { + const malformed = sessionRecord({ metadata: { authBinding: { version: 1 } } }) + + expect(() => + assertUploadSessionAuthBinding(malformed, { + kind: 'session', + userId: malformed.userId, + sessionId: 'current-session', + }) + ).toThrow('Upload session not found') }) it('initiates multipart storage directly at the final key', async () => { @@ -248,6 +328,52 @@ describe('upload sessions', () => { ).resolves.toMatchObject({ value: 'recovered', alreadyCompleted: true }) }) + it('loads a durable finalizing result without re-running the finalizer', async () => { + const session = sessionRecord({ + status: 'finalizing', + expiresAt: new Date(Date.now() - 1), + providerObjectVersion: 'version-1', + completedFileId: 'file-1', + }) + dbChainMockFns.returning + .mockResolvedValueOnce([ + uploadRow({ + ...rowGeometry(session), + status: 'finalizing', + completedFileId: 'file-1', + }), + ]) + .mockResolvedValueOnce([ + uploadRow({ + ...rowGeometry(session), + status: 'completed', + completedFileId: 'file-1', + completedAt: new Date(), + }), + ]) + const finalize = vi.fn() + const loadCompleted = vi.fn().mockResolvedValue('recovered-file') + + await expect( + completeUploadSession({ session, finalize, loadCompleted }) + ).resolves.toMatchObject({ value: 'recovered-file', alreadyCompleted: true }) + expect(loadCompleted).toHaveBeenCalledOnce() + expect(finalize).not.toHaveBeenCalled() + expect(mockHeadObject).not.toHaveBeenCalled() + }) + + it('loads an already-completed durable result without re-running the finalizer', async () => { + const session = sessionRecord({ status: 'completed', completedFileId: 'file-1' }) + const finalize = vi.fn() + const loadCompleted = vi.fn().mockResolvedValue('completed-file') + + await expect( + completeUploadSession({ session, finalize, loadCompleted }) + ).resolves.toMatchObject({ value: 'completed-file', alreadyCompleted: true }) + expect(loadCompleted).toHaveBeenCalledOnce() + expect(finalize).not.toHaveBeenCalled() + }) + it('deletes a matching completed provider object without aborting its consumed upload id', async () => { const session = sessionRecord({ method: 'multipart', @@ -295,17 +421,47 @@ describe('upload sessions', () => { expect(mockDeleteObjectVersion).not.toHaveBeenCalled() }) - it('refuses to abort once domain finalization may have created a resource', async () => { - const session = sessionRecord({ status: 'finalizing' }) + it('refuses to abort once domain finalization has registered a resource', async () => { + const session = sessionRecord({ status: 'finalizing', completedFileId: 'file-1' }) await expect(abortUploadSession(session)).rejects.toThrow( - 'Finalizing upload sessions cannot be aborted' + 'Finalizing upload sessions with a registered file cannot be aborted' ) expect(mockAbortProviderUpload).not.toHaveBeenCalled() expect(mockDeleteObjectVersion).not.toHaveBeenCalled() }) - it('cleans expired upload state without selecting sessions that may be finalizing', async () => { + it('aborts a finalizing session whose durable registration never committed', async () => { + const session = sessionRecord({ + status: 'finalizing', + providerObjectVersion: 'version-1', + completedFileId: null, + }) + mockHeadObject.mockResolvedValue(providerObject(session, 'version-1')) + dbChainMockFns.returning + .mockResolvedValueOnce([ + uploadRow({ + ...rowGeometry(session), + status: 'aborting', + providerObjectVersion: 'version-1', + }), + ]) + .mockResolvedValueOnce([ + uploadRow({ + ...rowGeometry(session), + status: 'aborted', + providerObjectVersion: 'version-1', + completedAt: new Date(), + }), + ]) + + await expect(abortUploadSession(session)).resolves.toMatchObject({ status: 'aborted' }) + expect(mockDeleteObjectVersion).toHaveBeenCalledWith( + expect.objectContaining({ key: FINAL_KEY, version: 'version-1' }) + ) + }) + + it('cleans expired upload state including unregistered finalizing sessions', async () => { const expired = uploadRow({ expiresAt: new Date(Date.now() - 1) }) queueTableRows(schemaMock.uploadSession, [expired]) queueTableRows(schemaMock.uploadSession, []) @@ -326,7 +482,8 @@ describe('upload sessions', () => { .mocked(inArray) .mock.calls.find(([, values]) => values.includes('uploading'))?.[1] expect(candidateStatuses).toEqual(['uploading', 'completing', 'aborting']) - expect(candidateStatuses).not.toContain('finalizing') + expect(vi.mocked(eq)).toHaveBeenCalledWith(schemaMock.uploadSession.status, 'finalizing') + expect(vi.mocked(isNull)).toHaveBeenCalledWith(schemaMock.uploadSession.completedFileId) }) it('deletes a late PUT object before purging an aborted session', async () => { @@ -355,6 +512,11 @@ async function createWorkspaceUpload(fileSize: number) { id: 'upload-1', workspaceId: WORKSPACE_ID, userId: 'user-1', + principal: { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + }, purpose: 'workspace_file', fileName: 'file.bin', contentType: 'application/octet-stream', diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index 325c48f58ec..fa6f44e20bf 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -1,3 +1,4 @@ +import type { Principal } from '@sim/auth/principal' import { db, dbFor } from '@sim/db' import { uploadSession } from '@sim/db/schema' import { safeCompare } from '@sim/security/compare' @@ -90,6 +91,23 @@ export interface UploadSessionRecord { updatedAt: Date } +/** + * The credential that was authorized to create a workspace-file upload. + * + * This is deliberately kept in the existing JSON metadata column. It is + * server-authored and immutable for the lifetime of the session; the upload + * token only proves possession of the byte-plane capability and never grants + * workspace access by itself. + */ +export interface UploadSessionAuthBinding { + version: 1 + workspaceId: string + principal: + | { kind: 'session'; userId: string; sessionId: string } + | { kind: 'personal_api_key'; userId: string; keyId: string } + | { kind: 'workspace_api_key'; workspaceId: string; keyId: string } +} + export interface CreatedUploadSession extends UploadSessionRecord { transfer: UploadSessionTransfer } @@ -112,6 +130,7 @@ interface CreateUploadSessionBaseParams { fileSize: number metadata?: Record localOrigin?: string + principal?: Principal } export type CreateUploadSessionParams = CreateUploadSessionBaseParams & @@ -137,6 +156,14 @@ export async function createUploadSession( const id = params.id ?? generateId() const uploadToken = generateSecureToken(32) const workspaceId = params.purpose === 'profile_picture' ? null : params.workspaceId + const metadata = { ...(params.metadata ?? {}) } + if (params.purpose === 'workspace_file') { + if (!workspaceId) throw new Error('Workspace-file upload is missing workspaceId') + if (!params.principal) { + throw new Error('Workspace-file upload requires an authenticated principal') + } + metadata.authBinding = createUploadSessionAuthBinding(params.principal, workspaceId) + } const { storageContext, finalKey } = resolveUploadStorage(params, id) const method: UploadTransferMethod = params.fileSize <= UPLOAD_SESSION_PUT_MAX_BYTES ? 'put' : 'multipart' @@ -206,7 +233,7 @@ export async function createUploadSession( fileSize: params.fileSize, partSize, partCount, - metadata: params.metadata ?? {}, + metadata, createdAt, expiresAt, updatedAt: createdAt, @@ -265,6 +292,7 @@ export async function getOwnedUploadSession(params: { knowledgeBaseId?: string workflowId?: string executionId?: string + principal?: Principal }): Promise { const [row] = await db .select() @@ -287,9 +315,113 @@ export async function getOwnedUploadSession(params: { if (params.executionId !== undefined && session.executionId !== params.executionId) { throw uploadNotFound() } + if (params.principal && session.purpose === 'workspace_file') { + assertUploadSessionAuthBinding(session, params.principal) + } return session } +/** + * Loads a session using the signed token and verifies the immutable principal + * binding for workspace-file control-plane requests. + */ +export async function getPrincipalUploadSession(params: { + uploadId: string + uploadToken: string + principal: Principal + workspaceId?: string +}): Promise { + const session = await getOwnedUploadSession({ + uploadId: params.uploadId, + uploadToken: params.uploadToken, + workspaceId: params.workspaceId, + purpose: 'workspace_file', + principal: params.principal, + }) + return session +} + +export function createUploadSessionAuthBinding( + principal: Principal, + workspaceId: string +): UploadSessionAuthBinding { + switch (principal.kind) { + case 'session': + return { + version: 1, + workspaceId, + principal: { + kind: principal.kind, + userId: principal.userId, + sessionId: principal.sessionId, + }, + } + case 'personal_api_key': + return { + version: 1, + workspaceId, + principal: { kind: principal.kind, userId: principal.userId, keyId: principal.keyId }, + } + case 'workspace_api_key': + if (principal.workspaceId !== workspaceId) { + throw new UploadSessionError('forbidden', 'Workspace API key cannot access this workspace') + } + return { + version: 1, + workspaceId, + principal: { kind: principal.kind, workspaceId, keyId: principal.keyId }, + } + case 'delegated': + throw new UploadSessionError('forbidden', 'Delegated principals cannot create uploads') + } +} + +export function assertUploadSessionAuthBinding( + session: UploadSessionRecord, + principal: Principal +): void { + if (session.purpose !== 'workspace_file') return + const candidate = session.metadata.authBinding + if (candidate === undefined) { + assertLegacyUploadSessionOwner(session, principal) + return + } + if (!isUploadSessionAuthBinding(candidate) || candidate.workspaceId !== session.workspaceId) { + throw uploadNotFound() + } + const bound = candidate.principal + const matches = + bound.kind === principal.kind && + (bound.kind === 'session' + ? principal.kind === 'session' && + bound.userId === principal.userId && + bound.sessionId === principal.sessionId + : bound.kind === 'personal_api_key' + ? principal.kind === 'personal_api_key' && + bound.userId === principal.userId && + bound.keyId === principal.keyId + : principal.kind === 'workspace_api_key' && + bound.workspaceId === principal.workspaceId && + bound.keyId === principal.keyId) + if (!matches) throw uploadNotFound() +} + +/** + * Preserves control access for the bounded set of sessions created before + * immutable credential bindings shipped. New workspace-file sessions always + * persist `authBinding`, and malformed bindings never enter this compatibility + * path. The upload token and current workspace authorization are still checked + * by the calling control-plane use case. + */ +function assertLegacyUploadSessionOwner(session: UploadSessionRecord, principal: Principal): void { + const matches = + principal.kind === 'workspace_api_key' + ? principal.workspaceId === session.workspaceId + : (principal.kind === 'session' || principal.kind === 'personal_api_key') && + principal.userId === session.userId + if (!matches) throw uploadNotFound() +} + export async function verifyUploadSessionToken(uploadToken: string): Promise { const tokenHash = sha256Hex(uploadToken) const [row] = await db @@ -345,8 +477,16 @@ export async function createUploadPartUrls(params: { export async function completeUploadSession(params: { session: UploadSessionRecord finalize: (session: UploadSessionRecord) => Promise<{ value: T; completedFileId?: string }> + loadCompleted?: (session: UploadSessionRecord) => Promise }): Promise<{ session: UploadSessionRecord; value: T; alreadyCompleted: boolean }> { if (params.session.status === 'completed') { + if (params.loadCompleted && params.session.completedFileId) { + return { + session: params.session, + value: await params.loadCompleted(params.session), + alreadyCompleted: true, + } + } const finalized = await params.finalize(params.session) return { session: params.session, value: finalized.value, alreadyCompleted: true } } @@ -373,6 +513,12 @@ export async function completeUploadSession(params: { let alreadyCompleted = false try { + if (recoveringFinalization && claimed.completedFileId && params.loadCompleted) { + const value = await params.loadCompleted(claimed) + const completed = await markUploadSessionCompleted(claimed, leaseId, claimed.completedFileId) + return { session: completed, value, alreadyCompleted: true } + } + let finalObject = await headProviderObject({ provider: claimed.storageProvider, key: claimed.finalKey, @@ -439,25 +585,13 @@ export async function completeUploadSession(params: { claimed.uploadToken ) const finalized = await params.finalize(finalizing) - const completedAt = new Date() - const [completedRow] = await db - .update(uploadSession) - .set({ - status: 'completed', - completedFileId: finalized.completedFileId ?? null, - completedAt, - processingLeaseId: null, - processingLeaseExpiresAt: null, - error: null, - updatedAt: completedAt, - }) - .where(and(eq(uploadSession.id, claimed.id), eq(uploadSession.processingLeaseId, leaseId))) - .returning() + const completed = await markUploadSessionCompleted( + claimed, + leaseId, + finalized.completedFileId ?? null + ) return { - session: sessionFromRow( - requireRow(completedRow, 'Upload completion lease was lost'), - claimed.uploadToken - ), + session: completed, value: finalized.value, alreadyCompleted, } @@ -476,6 +610,31 @@ export async function completeUploadSession(params: { } } +async function markUploadSessionCompleted( + session: UploadSessionRecord, + leaseId: string, + completedFileId: string | null +): Promise { + const completedAt = new Date() + const [completedRow] = await db + .update(uploadSession) + .set({ + status: 'completed', + completedFileId, + completedAt, + processingLeaseId: null, + processingLeaseExpiresAt: null, + error: null, + updatedAt: completedAt, + }) + .where(and(eq(uploadSession.id, session.id), eq(uploadSession.processingLeaseId, leaseId))) + .returning() + return sessionFromRow( + requireRow(completedRow, 'Upload completion lease was lost'), + session.uploadToken + ) +} + export async function abortUploadSession( session: UploadSessionRecord ): Promise { @@ -483,13 +642,17 @@ export async function abortUploadSession( if (session.status === 'completed') { throw new UploadSessionError('conflict', 'Completed upload sessions cannot be aborted') } - if (session.status === 'finalizing') { - throw new UploadSessionError('conflict', 'Finalizing upload sessions cannot be aborted') + if (session.status === 'finalizing' && session.completedFileId) { + throw new UploadSessionError( + 'conflict', + 'Finalizing upload sessions with a registered file cannot be aborted' + ) } if ( session.status !== 'uploading' && session.status !== 'completing' && - session.status !== 'aborting' + session.status !== 'aborting' && + session.status !== 'finalizing' ) { throw new UploadSessionError('conflict', `Upload session is ${session.status}`) } @@ -498,7 +661,7 @@ export async function abortUploadSession( ...(await claimSession( session.id, leaseId, - ['uploading', 'completing', 'aborting'], + ['uploading', 'completing', 'aborting', 'finalizing'], 'aborting' )), uploadToken: session.uploadToken, @@ -545,7 +708,10 @@ export async function cleanupExpiredUploadSessions(): Promise<{ .from(uploadSession) .where( and( - inArray(uploadSession.status, ['uploading', 'completing', 'aborting']), + or( + inArray(uploadSession.status, ['uploading', 'completing', 'aborting']), + and(eq(uploadSession.status, 'finalizing'), isNull(uploadSession.completedFileId)) + ), lt(uploadSession.expiresAt, now), or( isNull(uploadSession.processingLeaseId), @@ -565,7 +731,7 @@ export async function cleanupExpiredUploadSessions(): Promise<{ const claimed = await claimSession( candidate.id, leaseId, - ['uploading', 'completing', 'aborting'], + ['uploading', 'completing', 'aborting', 'finalizing'], 'aborting', cleanupDb ) @@ -979,6 +1145,25 @@ function isStorageContext(value: string): value is StorageContext { ].includes(value) } +function isUploadSessionAuthBinding(value: unknown): value is UploadSessionAuthBinding { + if (!value || typeof value !== 'object') return false + const binding = value as Record + if (binding.version !== 1 || typeof binding.workspaceId !== 'string') return false + if (!binding.principal || typeof binding.principal !== 'object') return false + const principal = binding.principal as Record + if (principal.kind === 'session') { + return typeof principal.userId === 'string' && typeof principal.sessionId === 'string' + } + if (principal.kind === 'personal_api_key') { + return typeof principal.userId === 'string' && typeof principal.keyId === 'string' + } + return ( + principal.kind === 'workspace_api_key' && + typeof principal.workspaceId === 'string' && + typeof principal.keyId === 'string' + ) +} + function uploadNotFound(): UploadSessionError { return new UploadSessionError('not_found', 'Upload session not found') } diff --git a/apps/sim/lib/workspace-files/api/index.ts b/apps/sim/lib/workspace-files/api/index.ts new file mode 100644 index 00000000000..2b23ed91382 --- /dev/null +++ b/apps/sim/lib/workspace-files/api/index.ts @@ -0,0 +1,7 @@ +export { internalFileAnalytics } from '@/lib/workspace-files/api/internal-analytics' +export { internalFileErrorPolicies } from '@/lib/workspace-files/api/internal-error-policies' +export { internalFilePresenters } from '@/lib/workspace-files/api/internal-presenters' +export { + internalSessionOrServiceAuth, + v2FileErrorPolicies, +} from '@/lib/workspace-files/api/route-policies' diff --git a/apps/sim/lib/workspace-files/api/internal-analytics.ts b/apps/sim/lib/workspace-files/api/internal-analytics.ts new file mode 100644 index 00000000000..840c6d5044d --- /dev/null +++ b/apps/sim/lib/workspace-files/api/internal-analytics.ts @@ -0,0 +1,151 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import { captureServerEvent } from '@/lib/posthog/server' +import type { + ArchiveWorkspaceFileItemsInput, + ArchiveWorkspaceFileItemsResult, +} from '@/lib/workspace-files/application/archive-workspace-file-items' +import type { CreateWorkspaceFileResult } from '@/lib/workspace-files/application/create-workspace-file' +import type { DeleteWorkspaceFileResult } from '@/lib/workspace-files/application/delete-workspace-file' +import type { DownloadWorkspaceFileResult } from '@/lib/workspace-files/application/download-workspace-file' +import type { + DownloadWorkspaceFileItemsInput, + DownloadWorkspaceFileItemsResult, +} from '@/lib/workspace-files/application/download-workspace-file-items' +import type { MoveWorkspaceFileItemsInput } from '@/lib/workspace-files/application/move-workspace-file-items' +import type { RenameWorkspaceFileResult } from '@/lib/workspace-files/application/rename-workspace-file' +import type { + CreateWorkspaceFileFolderInput, + DeleteWorkspaceFileFolderInput, + RestoreWorkspaceFileFolderInput, + UpdateWorkspaceFileFolderInput, +} from '@/lib/workspace-files/application/workspace-file-folders' + +interface InternalSuccessArgs { + principal: SessionPrincipal + input: I + result: R +} + +export const internalFileAnalytics = { + renamed({ principal, result }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'file_renamed', + { workspace_id: result.file.workspaceId }, + { groups: { workspace: result.file.workspaceId } } + ) + }, + deleted({ principal, result }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'file_deleted', + { workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } + ) + }, + downloaded({ principal, result }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'file_downloaded', + { workspace_id: result.file.workspaceId, is_bulk: false, file_count: 1 }, + { groups: { workspace: result.file.workspaceId } } + ) + }, + bulkDownloaded({ + principal, + input, + result, + }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'file_downloaded', + { + workspace_id: input.workspaceId, + is_bulk: true, + file_count: result.filesToZip.length, + }, + { groups: { workspace: input.workspaceId } } + ) + }, + uploaded({ principal, result }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'file_uploaded', + { workspace_id: result.file.workspaceId, file_type: result.file.type }, + { groups: { workspace: result.file.workspaceId } } + ) + }, + bulkDeleted({ + principal, + input, + }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'file_bulk_deleted', + { + workspace_id: input.workspaceId, + file_count: input.fileIds?.length ?? 0, + folder_count: input.folderIds?.length ?? 0, + }, + { groups: { workspace: input.workspaceId } } + ) + }, + folderRestored({ principal, input }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'folder_restored', + { folder_id: input.folderId, workspace_id: input.workspaceId }, + { groups: { workspace: input.workspaceId } } + ) + }, + folderRenamed({ principal, input }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'folder_renamed', + { workspace_id: input.workspaceId }, + { groups: { workspace: input.workspaceId } } + ) + }, + folderDeleted({ principal, input }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'folder_deleted', + { workspace_id: input.workspaceId }, + { groups: { workspace: input.workspaceId } } + ) + }, + folderCreated({ principal, input }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'folder_created', + { workspace_id: input.workspaceId }, + { groups: { workspace: input.workspaceId } } + ) + }, + moved({ principal, input }: InternalSuccessArgs) { + if (input.fileIds && input.fileIds.length > 0) { + captureServerEvent( + principal.userId, + 'file_moved', + { + workspace_id: input.workspaceId, + file_count: input.fileIds.length, + folder_count: input.folderIds?.length ?? 0, + }, + { groups: { workspace: input.workspaceId } } + ) + } + if (input.folderIds && input.folderIds.length > 0) { + captureServerEvent( + principal.userId, + 'folder_moved', + { + workspace_id: input.workspaceId, + file_count: input.fileIds?.length ?? 0, + folder_count: input.folderIds.length, + }, + { groups: { workspace: input.workspaceId } } + ) + } + }, +} as const diff --git a/apps/sim/lib/workspace-files/api/internal-error-policies.test.ts b/apps/sim/lib/workspace-files/api/internal-error-policies.test.ts new file mode 100644 index 00000000000..641514f0ef7 --- /dev/null +++ b/apps/sim/lib/workspace-files/api/internal-error-policies.test.ts @@ -0,0 +1,35 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api/internal-error-policies' +import { + CompiledCheckTooLargeError, + CompiledCheckUnsupportedError, +} from '@/lib/workspace-files/application/compiled-check-workspace-file' +import { StyleExtractionUnsupportedError } from '@/lib/workspace-files/application/style-workspace-file' + +describe('internal file error policies', () => { + it('projects style and compiled-check failures without constructing responses', () => { + expect( + internalFileErrorPolicies.style.project(new StyleExtractionUnsupportedError('Unsupported')) + ).toEqual({ status: 422, body: { error: 'Unsupported' }, headers: undefined }) + expect( + internalFileErrorPolicies.compiledCheck.project(new CompiledCheckUnsupportedError()) + ).toMatchObject({ status: 422 }) + expect( + internalFileErrorPolicies.compiledCheck.project(new CompiledCheckTooLargeError()) + ).toMatchObject({ status: 413 }) + }) + + it('conceals forbidden inline resources with the legacy not-found envelope', () => { + expect( + internalFileErrorPolicies.inline.project(new OrchestrationError('forbidden', 'Forbidden')) + ).toEqual({ + status: 404, + body: { error: 'FileNotFoundError', message: 'Not found' }, + headers: undefined, + }) + }) +}) diff --git a/apps/sim/lib/workspace-files/api/internal-error-policies.ts b/apps/sim/lib/workspace-files/api/internal-error-policies.ts new file mode 100644 index 00000000000..c5814cde15a --- /dev/null +++ b/apps/sim/lib/workspace-files/api/internal-error-policies.ts @@ -0,0 +1,87 @@ +import { createLogger } from '@sim/logger' +import { + extendInternalErrorPolicy, + type InternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, + internalPlainOrchestrationErrorPolicy, +} from '@/lib/api/server/routes' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { + CompiledCheckTooLargeError, + CompiledCheckUnsupportedError, +} from '@/lib/workspace-files/application/compiled-check-workspace-file' +import { StyleExtractionUnsupportedError } from '@/lib/workspace-files/application/style-workspace-file' + +const logger = createLogger('InternalWorkspaceFileErrors') + +const style = extendInternalErrorPolicy(internalPlainOrchestrationErrorPolicy, (error) => { + if (!(error instanceof StyleExtractionUnsupportedError)) return null + return internalErrorResponse(422, { error: error.message }) +}) + +const compiledCheck = extendInternalErrorPolicy(internalPlainOrchestrationErrorPolicy, (error) => { + if (error instanceof CompiledCheckUnsupportedError) { + return internalErrorResponse(422, { error: error.message }) + } + if (error instanceof CompiledCheckTooLargeError) { + return internalErrorResponse(413, { error: error.message }) + } + return null +}) + +const downloadUrl: InternalErrorPolicy = { + project(error) { + const typed = internalOrchestrationErrorPolicy.project(error) + if (typed) return typed + logger.error('Failed to generate workspace file download URL', { error }) + return internalErrorResponse(500, { + success: false, + error: 'Failed to generate download URL', + }) + }, +} + +const downloadArchive: InternalErrorPolicy = { + project(error) { + const classified = asOrchestrationError(error) + if (classified) { + return internalErrorResponse(statusForOrchestrationError(classified.code), { + error: classified.message, + }) + } + logger.error('Failed to download workspace file selection', { error }) + return internalErrorResponse(500, { error: 'Internal server error' }) + }, +} + +const inline: InternalErrorPolicy = { + project(error) { + const classified = asOrchestrationError(error) + if (classified) { + if (classified.code === 'not_found' || classified.code === 'forbidden') { + return internalErrorResponse(404, { error: 'FileNotFoundError', message: 'Not found' }) + } + return internalErrorResponse(statusForOrchestrationError(classified.code), { + error: 'Error', + message: classified.message, + }) + } + if (error instanceof Error) { + logger.error('Error serving workspace inline image', { error }) + return internalErrorResponse(500, { error: error.name, message: error.message }) + } + logger.error('Error serving workspace inline image', { error }) + return internalErrorResponse(500, { error: 'Error', message: 'Failed to serve file' }) + }, +} + +export const internalFileErrorPolicies = { + default: internalOrchestrationErrorPolicy, + plain: internalPlainOrchestrationErrorPolicy, + style, + compiledCheck, + downloadUrl, + downloadArchive, + inline, +} as const diff --git a/apps/sim/lib/workspace-files/api/internal-presenters.ts b/apps/sim/lib/workspace-files/api/internal-presenters.ts new file mode 100644 index 00000000000..7413b44e911 --- /dev/null +++ b/apps/sim/lib/workspace-files/api/internal-presenters.ts @@ -0,0 +1,29 @@ +import { workspaceFileStyleContract } from '@/lib/api/contracts/workspace-files' +import { getBaseUrl } from '@/lib/core/utils/urls' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import type { DownloadWorkspaceFileResult } from '@/lib/workspace-files/application/download-workspace-file' + +export const internalFilePresenters = { + successFile({ file }: { file: WorkspaceFileRecord }) { + return { success: true as const, file: { ...file, folderId: file.folderId ?? null } } + }, + successFiles({ files }: { files: WorkspaceFileRecord[] }) { + return { + success: true as const, + files: files.map((file) => ({ ...file, folderId: file.folderId ?? null })), + } + }, + downloadUrl({ file }: DownloadWorkspaceFileResult) { + const baseUrl = getBaseUrl() + return { + success: true as const, + downloadUrl: `${baseUrl}/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`, + viewerUrl: `${baseUrl}/workspace/${file.workspaceId}/files/${file.id}`, + fileName: file.name, + expiresIn: null, + } + }, + style(result: Parameters[0]) { + return workspaceFileStyleContract.response.schema.parse(result) + }, +} as const diff --git a/apps/sim/lib/workspace-files/api/route-policies.test.ts b/apps/sim/lib/workspace-files/api/route-policies.test.ts new file mode 100644 index 00000000000..3fd91c612bb --- /dev/null +++ b/apps/sim/lib/workspace-files/api/route-policies.test.ts @@ -0,0 +1,71 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockVerifyInternalToken } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockVerifyInternalToken: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/auth/internal', () => ({ verifyInternalToken: mockVerifyInternalToken })) + +import { InternalUnauthenticatedError } from '@/lib/api/server/routes' +import { internalSessionOrServiceAuth } from '@/lib/workspace-files/api' + +describe('internal file route authentication', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue(null) + }) + + it('binds a verified internal user to an executor file principal', async () => { + mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-1' }) + + const principal = await internalSessionOrServiceAuth.authenticate( + new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1', { + headers: { authorization: 'Bearer signed-token' }, + }), + { id: 'ws-1', fileId: 'file-1' } + ) + + expect(principal).toMatchObject({ + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + audience: 'sim:workspace-files', + resourceScope: { fileId: 'file-1' }, + }) + expect(mockGetSession).not.toHaveBeenCalled() + }) + + it('rejects internal tokens that do not carry a human subject', async () => { + mockVerifyInternalToken.mockResolvedValue({ valid: true }) + + await expect( + internalSessionOrServiceAuth.authenticate( + new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1', { + headers: { authorization: 'Bearer signed-token' }, + }), + { id: 'ws-1', fileId: 'file-1' } + ) + ).rejects.toBeInstanceOf(InternalUnauthenticatedError) + }) + + it('preserves browser session principals when no service token is supplied', async () => { + mockGetSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + + await expect( + internalSessionOrServiceAuth.authenticate( + new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1'), + { id: 'ws-1', fileId: 'file-1' } + ) + ).resolves.toEqual({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + }) +}) diff --git a/apps/sim/lib/workspace-files/api/route-policies.ts b/apps/sim/lib/workspace-files/api/route-policies.ts new file mode 100644 index 00000000000..c287586ddd2 --- /dev/null +++ b/apps/sim/lib/workspace-files/api/route-policies.ts @@ -0,0 +1,35 @@ +import { + createInternalSessionOrServiceAuth, + type V2ErrorPolicy, + v2OrchestrationErrorPolicy, +} from '@/lib/api/server/routes' +import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +export const internalSessionOrServiceAuth = createInternalSessionOrServiceAuth( + ({ subjectUserId, params }) => { + const workspaceId = params.id + if (typeof workspaceId !== 'string' || !workspaceId) { + throw new Error('Internal file delegation requires a workspace route parameter') + } + return createWorkspaceFileDelegatedPrincipal({ + serviceId: 'executor', + subjectUserId, + workspaceId, + delegationId: `internal-file:${subjectUserId}`, + fileId: typeof params.fileId === 'string' ? params.fileId : undefined, + }) + } +) + +export const v2FileErrorPolicies = { + default: v2OrchestrationErrorPolicy, + concealResourceAuthorization: { + render(error) { + const response = v2CaughtOrchestrationError(error) + if (!response) return null + if (response.status === 403) return v2Error('NOT_FOUND', 'File not found') + return response + }, + } satisfies V2ErrorPolicy, +} as const diff --git a/apps/sim/lib/workspace-files/application/archive-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/archive-workspace-file-items.test.ts new file mode 100644 index 00000000000..d7ecfb67b56 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/archive-workspace-file-items.test.ts @@ -0,0 +1,228 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + events, + mockLoadContext, + mockResolvePermission, + mockAssertItems, + mockArchive, + mockAudit, + mockNotify, +} = vi.hoisted(() => ({ + events: [] as string[], + mockLoadContext: vi.fn(), + mockResolvePermission: vi.fn(), + mockAssertItems: vi.fn(), + mockArchive: vi.fn(), + mockAudit: vi.fn(), + mockNotify: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + assertWorkspaceFileItemsBelongToWorkspace: mockAssertItems, + bulkArchiveWorkspaceFileItems: mockArchive, + loadWorkspaceFileOperationContext: mockLoadContext, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mockResolvePermission, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_DELETED: 'file.deleted', FOLDER_DELETED: 'folder.deleted' }, + AuditResourceType: { FILE: 'file', FOLDER: 'folder' }, + recordAudit: mockAudit, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mockNotify })) + +import { archiveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/archive-workspace-file-items' + +describe('archiveWorkspaceFileItemsOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + events.length = 0 + mockLoadContext.mockImplementation(async () => { + events.push('resolve') + return { + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', + } + }) + mockResolvePermission.mockImplementation(async () => { + events.push('authorize') + return 'write' + }) + mockAssertItems.mockImplementation(async () => { + events.push('execute') + }) + mockArchive.mockImplementation(async () => ({ + files: 1, + folders: 0, + fileIds: ['file-1'], + folderIds: [], + })) + }) + + it('preserves atomic bulk archive results and emits side effects once', async () => { + const result = await archiveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', fileIds: ['file-1'] }, + }) + + expect(result).toMatchObject({ deletedItems: { files: 1, folders: 0 } }) + expect(events).toEqual(['resolve', 'authorize', 'execute']) + expect(mockArchive).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + fileIds: ['file-1'], + folderIds: [], + }) + expect(mockAssertItems).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + fileIds: ['file-1'], + folderIds: [], + }) + expect(mockAudit).toHaveBeenCalledOnce() + expect(mockNotify).toHaveBeenCalledOnce() + }) + + it('classifies a single missing file without notifying', async () => { + mockArchive.mockResolvedValue({ files: 0, folders: 0, fileIds: [], folderIds: [] }) + await expect( + archiveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', fileIds: ['missing'] }, + }) + ).rejects.toThrow('File not found') + expect(mockAudit).not.toHaveBeenCalled() + expect(mockNotify).not.toHaveBeenCalled() + }) + + it('authorizes a delegated bulk selection without borrowing the first file scope', async () => { + await expect( + archiveWorkspaceFileItemsOperation.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { fileId: 'file-1' }, + }, + input: { workspaceId: 'ws-1', fileIds: ['file-1', 'file-2'] }, + }) + ).rejects.toThrow('Delegated workspace access is no longer valid') + + expect(mockLoadContext).toHaveBeenCalledWith('ws-1') + expect(mockResolvePermission).not.toHaveBeenCalled() + expect(mockAssertItems).not.toHaveBeenCalled() + expect(mockArchive).not.toHaveBeenCalled() + }) + + it('allows a file-scoped delegated principal to archive its one explicit file', async () => { + const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { fileId: 'file-1' }, + } + + await archiveWorkspaceFileItemsOperation.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: ['file-1'] }, + }) + + expect(mockResolvePermission).toHaveBeenCalledOnce() + }) + + it('denies a file-scoped delegated principal selecting a folder before validation', async () => { + await expect( + archiveWorkspaceFileItemsOperation.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { fileId: 'file-1' }, + }, + input: { workspaceId: 'ws-1', folderIds: ['folder-1'] }, + }) + ).rejects.toThrow('Delegated workspace access is no longer valid') + + expect(mockResolvePermission).not.toHaveBeenCalled() + expect(mockAssertItems).not.toHaveBeenCalled() + expect(mockArchive).not.toHaveBeenCalled() + }) + + it('audits and notifies only authoritative rows returned by the mutation', async () => { + mockArchive.mockResolvedValue({ + files: 1, + folders: 1, + fileIds: ['file-2'], + folderIds: ['folder-2'], + }) + + const result = await archiveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'ws-1', + fileIds: ['file-1', 'file-2'], + folderIds: ['folder-1'], + }, + }) + + expect(result).toMatchObject({ deletedItems: { files: 1, folders: 1 } }) + expect(mockAudit).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ metadata: expect.objectContaining({ fileIds: ['file-2'] }) }) + ) + expect(mockAudit).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ metadata: expect.objectContaining({ folderIds: ['folder-2'] }) }) + ) + expect(mockNotify).toHaveBeenCalledOnce() + }) + + it('does not claim or notify a zero-row bulk mutation', async () => { + mockArchive.mockResolvedValue({ files: 0, folders: 0, fileIds: [], folderIds: [] }) + + const result = await archiveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', fileIds: ['file-1', 'file-2'] }, + }) + + expect(result).toMatchObject({ deletedItems: { files: 0, folders: 0 } }) + expect(mockAudit).not.toHaveBeenCalled() + expect(mockNotify).not.toHaveBeenCalled() + }) + + it('rejects oversized selections after authorization and before storage', async () => { + await expect( + archiveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'ws-1', + fileIds: Array.from({ length: 1_001 }, (_, index) => `file-${index}`), + }, + }) + ).rejects.toThrow('accept at most 1000') + expect(events).toEqual(['resolve', 'authorize']) + expect(mockArchive).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/archive-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/archive-workspace-file-items.ts new file mode 100644 index 00000000000..30e7011c8a9 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/archive-workspace-file-items.ts @@ -0,0 +1,127 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + assertWorkspaceFileItemsBelongToWorkspace, + bulkArchiveWorkspaceFileItems, + loadWorkspaceFileOperationContext, +} from '@/lib/uploads/contexts/workspace' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits' + +const logger = createLogger('ArchiveWorkspaceFileItems') + +export interface ArchiveWorkspaceFileItemsInput { + workspaceId: string + fileIds?: string[] + folderIds?: string[] +} + +export interface ArchiveWorkspaceFileItemsResult { + deletedItems: { files: number; folders: number } + affectedIds: { fileIds: string[]; folderIds: string[] } +} + +function normalizeSelection(input: ArchiveWorkspaceFileItemsInput) { + return { + fileIds: [...new Set(input.fileIds ?? [])], + folderIds: [...new Set(input.folderIds ?? [])], + } +} + +async function executeArchiveWorkspaceFileItems({ + input, + context, +}: { + input: ArchiveWorkspaceFileItemsInput + context: Awaited> +}): Promise { + const { fileIds, folderIds } = normalizeSelection(input) + if (fileIds.length === 0 && folderIds.length === 0) { + throw new OrchestrationError('validation', 'At least one file or folder must be selected') + } + if ( + fileIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS || + folderIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS + ) { + throw new OrchestrationError( + 'validation', + `Bulk file operations accept at most ${MAX_WORKSPACE_FILE_BULK_REQUEST_IDS} file and folder IDs` + ) + } + + await assertWorkspaceFileItemsBelongToWorkspace({ + workspaceId: context.workspaceId, + fileIds, + folderIds, + }) + const archived = await bulkArchiveWorkspaceFileItems({ + workspaceId: context.workspaceId, + fileIds, + folderIds, + }) + const deletedItems = { files: archived.fileIds.length, folders: archived.folderIds.length } + + if (fileIds.length === 1 && folderIds.length === 0 && archived.fileIds.length === 0) { + throw new OrchestrationError('not_found', 'File not found') + } + if (folderIds.length === 1 && fileIds.length === 0 && archived.folderIds.length === 0) { + throw new OrchestrationError('not_found', 'Folder not found') + } + + logger.info('Archived workspace file items', { workspaceId: context.workspaceId, deletedItems }) + return { + deletedItems, + affectedIds: { fileIds: archived.fileIds, folderIds: archived.folderIds }, + } +} + +async function resolveArchiveContext({ input }: { input: ArchiveWorkspaceFileItemsInput }) { + const context = await loadWorkspaceFileOperationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + const { fileIds, folderIds } = normalizeSelection(input) + return { + ...context, + fileId: fileIds.length === 1 && folderIds.length === 0 ? fileIds[0] : undefined, + } +} + +export const archiveWorkspaceFileItemsOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.delete, + resolveContext: resolveArchiveContext, + execute: executeArchiveWorkspaceFileItems, + projectAudit({ result }) { + const entries = [] + if (result.affectedIds.fileIds.length > 0) { + entries.push({ + action: AuditAction.FILE_DELETED, + resourceType: AuditResourceType.FILE, + description: `Deleted ${result.affectedIds.fileIds.length} file${result.affectedIds.fileIds.length === 1 ? '' : 's'}`, + metadata: { + fileIds: result.affectedIds.fileIds, + }, + }) + } + if (result.affectedIds.folderIds.length > 0) { + entries.push({ + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: + result.affectedIds.folderIds.length === 1 ? result.affectedIds.folderIds[0] : undefined, + description: `Deleted ${result.affectedIds.folderIds.length} file folder${result.affectedIds.folderIds.length === 1 ? '' : 's'}`, + metadata: { + folderIds: result.affectedIds.folderIds, + affected: result.deletedItems, + }, + }) + } + return entries + }, + async afterSuccess({ context, result }) { + if (result.affectedIds.fileIds.length > 0 || result.affectedIds.folderIds.length > 0) { + await notifyWorkspaceFilesChanged(context.workspaceId) + } + }, +}) diff --git a/apps/sim/lib/workspace-files/application/authorization.test.ts b/apps/sim/lib/workspace-files/application/authorization.test.ts new file mode 100644 index 00000000000..524fd4aed21 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/authorization.test.ts @@ -0,0 +1,152 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const resolvePermission = vi.hoisted(() => vi.fn()) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: resolvePermission, +})) + +import type { OrchestrationError } from '@/lib/core/orchestration/types' +import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +const authorizationContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + fileId: 'file-1', +} + +async function expectForbidden(principal: Principal) { + await expect( + authorizeWorkspaceFileAccess(principal, fileOperations.rename, authorizationContext) + ).rejects.toMatchObject>({ code: 'forbidden' }) +} + +describe('file operation authorization', () => { + beforeEach(() => { + vi.clearAllMocks() + resolvePermission.mockResolvedValue('write') + }) + + it('uses the current workspace permission for sessions', async () => { + await authorizeWorkspaceFileAccess( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + fileOperations.rename, + authorizationContext + ) + + expect(resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + }) + + it('rejects a reader for a write operation', async () => { + resolvePermission.mockResolvedValue('read') + await expectForbidden({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + }) + + it('lets a workspace key use its fixed write ceiling only in its own workspace', async () => { + await authorizeWorkspaceFileAccess( + { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + fileOperations.rename, + authorizationContext + ) + expect(resolvePermission).not.toHaveBeenCalled() + + await expectForbidden({ + kind: 'workspace_api_key', + workspaceId: 'workspace-2', + keyId: 'key-2', + }) + }) + + it('fails a personal key immediately when the workspace disables it', async () => { + await expect( + authorizeWorkspaceFileAccess( + { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + fileOperations.rename, + { ...authorizationContext, allowPersonalApiKeys: false } + ) + ).rejects.toMatchObject>({ code: 'forbidden' }) + expect(resolvePermission).not.toHaveBeenCalled() + }) + + it('rejects a disallowed principal kind before principal-specific authorization', async () => { + await expect( + authorizeWorkspaceFileAccess( + { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + fileOperations.compiledCheck, + { ...authorizationContext, allowPersonalApiKeys: false } + ) + ).rejects.toMatchObject>({ + code: 'forbidden', + message: 'Principal kind personal_api_key cannot perform operation files.compiled_check', + }) + expect(resolvePermission).not.toHaveBeenCalled() + }) + + it('reauthorizes a valid file-scoped Copilot delegation as its human subject', async () => { + await authorizeWorkspaceFileAccess( + { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { fileId: 'file-1', chatId: 'chat-1' }, + }, + fileOperations.rename, + authorizationContext + ) + + expect(resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + }) + + it('rejects expired or wrong-file delegations before permission lookup', async () => { + const base = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date(Date.now() - 10_000), + } + + await expectForbidden({ + ...base, + expiresAt: new Date(Date.now() - 1), + resourceScope: { fileId: 'file-1' }, + }) + await expectForbidden({ + ...base, + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { fileId: 'file-2' }, + }) + expect(resolvePermission).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/authorization.ts b/apps/sim/lib/workspace-files/application/authorization.ts new file mode 100644 index 00000000000..dca73ebab1e --- /dev/null +++ b/apps/sim/lib/workspace-files/application/authorization.ts @@ -0,0 +1,40 @@ +import type { Principal } from '@sim/auth/principal' +import type { WorkspaceOperation } from '@/lib/core/application' +import { + authorizeWorkspaceOperation, + type WorkspaceAuthorizationContext, + type WorkspaceAuthorizationOptions, +} from '@/lib/core/application' + +export const WORKSPACE_FILES_DELEGATION_AUDIENCE = 'sim:workspace-files' + +export interface WorkspaceFileAuthorizationContext extends WorkspaceAuthorizationContext { + fileId?: string +} + +export type WorkspaceFileAuthorizationOptions = Omit< + WorkspaceAuthorizationOptions, + 'delegation' +> + +export const workspaceFileDelegationPolicy = { + audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, + isWithinScope: ( + delegated: Extract, + canonicalContext: WorkspaceFileAuthorizationContext + ) => + delegated.resourceScope?.fileId === undefined || + delegated.resourceScope.fileId === canonicalContext.fileId, +} as const + +export async function authorizeWorkspaceFileAccess( + principal: Principal, + operation: WorkspaceOperation, + context: WorkspaceFileAuthorizationContext, + options?: WorkspaceFileAuthorizationOptions +): Promise { + await authorizeWorkspaceOperation(principal, operation, context, { + ...options, + delegation: workspaceFileDelegationPolicy, + }) +} diff --git a/apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts b/apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts new file mode 100644 index 00000000000..46ecccbc243 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts @@ -0,0 +1,28 @@ +import { + type AuthorizedWorkspaceUseCaseDefinition, + defineAuthorizedWorkspaceUseCase, + type WorkspaceOperation, +} from '@/lib/core/application' +import { + type WorkspaceFileAuthorizationContext, + workspaceFileDelegationPolicy, +} from '@/lib/workspace-files/application/authorization' + +type AuthorizedWorkspaceFileUseCaseDefinition< + O extends WorkspaceOperation, + I, + C extends WorkspaceFileAuthorizationContext, + R, +> = Omit, 'authorizationOptions'> + +export function defineAuthorizedWorkspaceFileUseCase< + const O extends WorkspaceOperation, + I, + C extends WorkspaceFileAuthorizationContext, + R, +>(definition: AuthorizedWorkspaceFileUseCaseDefinition) { + return defineAuthorizedWorkspaceUseCase({ + ...definition, + authorizationOptions: { delegation: workspaceFileDelegationPolicy }, + }) +} 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 new file mode 100644 index 00000000000..04172b19904 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts @@ -0,0 +1,235 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getE2BDocFormat: vi.fn(), + getFile: vi.fn(), + loadContext: vi.fn(), + resolvePermission: vi.fn(), + fetchBuffer: vi.fn(), + runE2BCompiledCheck: vi.fn(), + runSandboxTask: vi.fn(), + validateMermaidSource: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({ + getE2BDocFormat: mocks.getE2BDocFormat, +})) + +vi.mock('@/lib/copilot/tools/server/files/doc-recalc', () => ({ + runE2BCompiledCheck: mocks.runE2BCompiledCheck, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ isDocSandboxEnabled: true })) + +vi.mock('@/lib/execution/constants', () => ({ + BINARY_DOC_TASKS: { pptx: 'document-pptx' }, + MAX_DOCUMENT_PREVIEW_CODE_BYTES: 1_000, +})) + +vi.mock('@/lib/execution/sandbox/run-task', () => ({ + runSandboxTask: mocks.runSandboxTask, + SandboxUserCodeError: class SandboxUserCodeError extends Error { + constructor(message: string, name: string) { + super(message) + this.name = name + } + }, +})) + +vi.mock('@/lib/mermaid/validate', () => ({ + validateMermaidSource: mocks.validateMermaidSource, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + fetchWorkspaceFileBuffer: mocks.fetchBuffer, + getWorkspaceFile: mocks.getFile, +})) + +import { SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' +import { compiledCheckWorkspaceFile } from '@/lib/workspace-files/application/compiled-check-workspace-file' + +const sessionPrincipal = { + kind: 'session' as const, + userId: 'current-user', + sessionId: 'session-1', +} + +function mockFile(name: string) { + mocks.getFile.mockResolvedValue({ + id: 'file-1', + workspaceId: 'workspace-1', + name, + size: 20, + uploadedBy: 'original-uploader', + }) + mocks.fetchBuffer.mockResolvedValue(Buffer.from('source code')) +} + +describe('compiledCheckWorkspaceFile', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getE2BDocFormat.mockResolvedValue(null) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.loadContext.mockResolvedValue({ + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: false, + billedAccountUserId: 'billing-owner', + }) + mocks.runSandboxTask.mockResolvedValue(Buffer.from('compiled')) + }) + + it('rejects API-key principals before canonical loading or business execution', async () => { + const unsupportedPrincipals = [ + { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'personal-key' }, + { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'workspace-key', + }, + ] + + for (const principal of unsupportedPrincipals) { + await expect( + compiledCheckWorkspaceFile.execute({ + principal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + message: `Principal kind ${principal.kind} cannot perform operation files.compiled_check`, + }) + } + + expect(compiledCheckWorkspaceFile.operation).toMatchObject({ + id: 'files.compiled_check', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }) + expect(mocks.loadContext).not.toHaveBeenCalled() + expect(mocks.getFile).not.toHaveBeenCalled() + expect(mocks.fetchBuffer).not.toHaveBeenCalled() + }) + + it('uses the current session user as the legacy sandbox owner, never the uploader', async () => { + mockFile('report.pptx') + + await expect( + compiledCheckWorkspaceFile.execute({ + principal: sessionPrincipal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ ok: true }) + + expect(mocks.runSandboxTask).toHaveBeenCalledWith( + 'document-pptx', + { code: 'source code', workspaceId: 'workspace-1' }, + { ownerKey: 'user:current-user' } + ) + expect(mocks.runSandboxTask).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), { + ownerKey: 'user:original-uploader', + }) + expect(mocks.loadContext).toHaveBeenCalledTimes(1) + expect(mocks.resolvePermission).toHaveBeenCalledTimes(1) + expect(mocks.getFile).toHaveBeenCalledTimes(1) + expect(mocks.fetchBuffer).toHaveBeenCalledTimes(1) + }) + + it('preserves legacy sandbox user-code failures in the successful response envelope', async () => { + mockFile('report.pptx') + mocks.runSandboxTask.mockRejectedValue( + new SandboxUserCodeError('Presentation source is invalid', 'SyntaxError') + ) + + await expect( + compiledCheckWorkspaceFile.execute({ + principal: sessionPrincipal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ + ok: false, + error: 'Presentation source is invalid', + errorName: 'SyntaxError', + }) + }) + + it('keeps Mermaid validation on the in-process path', async () => { + mockFile('diagram.mmd') + mocks.validateMermaidSource.mockResolvedValue({ + ok: false, + error: 'Unexpected token', + errorName: 'MermaidError', + }) + + await expect( + compiledCheckWorkspaceFile.execute({ + principal: sessionPrincipal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ + ok: false, + error: 'Unexpected token', + errorName: 'MermaidError', + }) + + expect(mocks.validateMermaidSource).toHaveBeenCalledWith('source code') + expect(mocks.runE2BCompiledCheck).not.toHaveBeenCalled() + expect(mocks.runSandboxTask).not.toHaveBeenCalled() + }) + + it('keeps E2B user-code failures in the successful response envelope', async () => { + mockFile('report.pptx') + mocks.getE2BDocFormat.mockResolvedValue({ ext: 'pptx' }) + mocks.runE2BCompiledCheck.mockResolvedValue({ ok: false, error: 'Script failed' }) + + await expect( + compiledCheckWorkspaceFile.execute({ + principal: sessionPrincipal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ + ok: false, + error: 'Script failed', + errorName: 'CompiledCheckError', + }) + + expect(mocks.runE2BCompiledCheck).toHaveBeenCalledWith({ + source: 'source code', + fileName: 'report.pptx', + workspaceId: 'workspace-1', + ext: 'pptx', + principal: sessionPrincipal, + }) + expect(mocks.runSandboxTask).not.toHaveBeenCalled() + }) + + it('propagates E2B infrastructure failures', async () => { + mockFile('report.pptx') + mocks.getE2BDocFormat.mockResolvedValue({ ext: 'pptx' }) + const failure = new Error('E2B unavailable') + mocks.runE2BCompiledCheck.mockRejectedValue(failure) + + await expect( + compiledCheckWorkspaceFile.execute({ + principal: sessionPrincipal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).rejects.toBe(failure) + + expect(mocks.runSandboxTask).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.ts b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.ts new file mode 100644 index 00000000000..f10d806d9a4 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.ts @@ -0,0 +1,114 @@ +import { getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile' +import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' +import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' +import { validateMermaidSource } from '@/lib/mermaid/validate' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import type { ActiveWorkspaceFileContext } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' + +export class CompiledCheckUnsupportedError extends Error { + constructor() { + super('Compiled check only supports .docx, .pptx, .pdf, .xlsx, and .mmd files') + this.name = 'CompiledCheckUnsupportedError' + } +} + +export class CompiledCheckTooLargeError extends Error { + constructor() { + super('File source exceeds maximum size') + this.name = 'CompiledCheckTooLargeError' + } +} + +export interface CompiledCheckWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string +} + +export type CompiledCheckWorkspaceFileResult = + | { ok: true } + | { ok: false; error: string; errorName: string } + +function normalizeCompiledCheckResult(result: { + ok: boolean + error?: string + errorName?: string +}): CompiledCheckWorkspaceFileResult { + if (result.ok) return { ok: true } + return { + ok: false, + error: result.error ?? 'Compiled check failed', + errorName: result.errorName ?? 'CompiledCheckError', + } +} + +async function executeCompiledCheckWorkspaceFile({ + principal, + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.compiledCheck, + CompiledCheckWorkspaceFileInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + const ext = file.name.split('.').pop()?.toLowerCase() ?? '' + const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(file.name) : null + const taskId = BINARY_DOC_TASKS[ext] + const isMermaidFile = ext === 'mmd' || ext === 'mermaid' + if (!e2bFmt && !taskId && !isMermaidFile) throw new CompiledCheckUnsupportedError() + + if (file.size > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { + throw new CompiledCheckTooLargeError() + } + + const content = await fetchWorkspaceFileBuffer(file, { + maxBytes: MAX_DOCUMENT_PREVIEW_CODE_BYTES, + }) + + const code = content.toString('utf-8') + if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { + throw new CompiledCheckTooLargeError() + } + if (isMermaidFile) return normalizeCompiledCheckResult(await validateMermaidSource(code)) + if (e2bFmt) { + return normalizeCompiledCheckResult( + await runE2BCompiledCheck({ + source: code, + fileName: file.name, + workspaceId: file.workspaceId, + ext, + principal, + }) + ) + } + + try { + if (!taskId) throw new CompiledCheckUnsupportedError() + await runSandboxTask( + taskId, + { code, workspaceId: file.workspaceId }, + { ownerKey: `user:${principal.userId}` } + ) + return { ok: true } + } catch (error) { + if (error instanceof SandboxUserCodeError) { + return { ok: false, error: error.message, errorName: error.name } + } + throw error + } +} + +export const compiledCheckWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.compiledCheck, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeCompiledCheckWorkspaceFile, +}) diff --git a/apps/sim/lib/workspace-files/application/create-workspace-file.ts b/apps/sim/lib/workspace-files/application/create-workspace-file.ts new file mode 100644 index 00000000000..edcbc830a91 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/create-workspace-file.ts @@ -0,0 +1,150 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { getPostgresErrorCode } from '@sim/utils/errors' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + FileConflictError, + loadActiveWorkspaceContext, + uploadWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration' + +const logger = createLogger('CreateWorkspaceFile') + +export interface CreateWorkspaceFileInput { + workspaceId: string + name: string + contentType: string + content: string + encoding: 'utf-8' | 'base64' + folderId?: string | null + folderPath?: string + exactName: boolean + secretProvenance?: WorkspaceFileSecretProvenance +} + +export interface CreateWorkspaceFileResult { + file: WorkspaceFileRecord +} + +export interface CreateWorkspaceFileBufferInput + extends Omit { + content: Buffer +} + +async function resolveCreateWorkspaceFileContext(workspaceId: string) { + const workspace = await loadActiveWorkspaceContext(workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return workspace +} + +async function createAuthorizedWorkspaceFile({ + principal, + input, + content, + workspace, +}: { + principal: Principal + input: Omit + content: Buffer + workspace: Awaited> +}): Promise { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: workspace.billedAccountUserId, + }) + let file: WorkspaceFileRecord + try { + file = await uploadWorkspaceFile( + workspace.workspaceId, + attribution.attributedUserId, + content, + input.name, + input.contentType, + { + folderId: input.folderId, + folderPath: input.folderPath, + exactName: input.exactName, + secretProvenance: input.secretProvenance ?? EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, + } + ) + } catch (error) { + if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { + throw new OrchestrationError('conflict', 'File already exists') + } + throw error + } + + logger.info('Created workspace file', { + workspaceId: workspace.workspaceId, + fileId: file.id, + folderId: file.folderId, + size: file.size, + principalKind: principal.kind, + }) + return { file } +} + +function projectCreateWorkspaceFileAudit(result: CreateWorkspaceFileResult) { + return { + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Uploaded file "${result.file.name}"`, + metadata: { + fileSize: result.file.size, + fileType: result.file.type, + }, + } as const +} + +const admitCreateWorkspaceFileUseCase = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.create, + resolveContext: ({ input }: { input: { workspaceId: string } }) => + resolveCreateWorkspaceFileContext(input.workspaceId), + async execute() {}, +}) + +export async function admitCreateWorkspaceFile( + principal: Principal, + workspaceId: string +): Promise { + await admitCreateWorkspaceFileUseCase.execute({ principal, input: { workspaceId } }) +} + +export const createWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.create, + resolveContext: ({ input }: { input: CreateWorkspaceFileInput }) => + resolveCreateWorkspaceFileContext(input.workspaceId), + async execute({ principal, input, context }): Promise { + const content = Buffer.from(input.content, input.encoding === 'base64' ? 'base64' : 'utf-8') + if (content.length > MAX_WORKSPACE_FILE_CONTENT_BYTES) { + throw new OrchestrationError( + 'payload_too_large', + `File size exceeds ${MAX_WORKSPACE_FILE_CONTENT_BYTES / 1024 / 1024}MB limit` + ) + } + return createAuthorizedWorkspaceFile({ principal, input, content, workspace: context }) + }, + projectAudit: ({ result }) => projectCreateWorkspaceFileAudit(result), +}) + +export const createWorkspaceFileFromBuffer = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.create, + resolveContext: ({ input }: { input: CreateWorkspaceFileBufferInput }) => + resolveCreateWorkspaceFileContext(input.workspaceId), + execute: ({ principal, input, context }) => + createAuthorizedWorkspaceFile({ + principal, + input, + content: input.content, + workspace: context, + }), + projectAudit: ({ result }) => projectCreateWorkspaceFileAudit(result), +}) diff --git a/apps/sim/lib/workspace-files/application/csv-preview-workspace-file.ts b/apps/sim/lib/workspace-files/application/csv-preview-workspace-file.ts new file mode 100644 index 00000000000..da862465443 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/csv-preview-workspace-file.ts @@ -0,0 +1,36 @@ +import type { Principal } from '@sim/auth/principal' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getCsvPreviewSlice } from '@/lib/file-parsers/csv-preview-slice' +import { readWorkspaceFileContentRecord } from '@/lib/workspace-files/application/read-workspace-file-record' + +export interface CsvPreviewWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string + key: string + signal?: AbortSignal +} + +async function executeCsvPreviewWorkspaceFile({ + principal, + input, + request, +}: { + principal: Principal + input: CsvPreviewWorkspaceFileInput + request?: OrchestrationRequestContext +}) { + const { file } = await readWorkspaceFileContentRecord.execute({ principal, input, request }) + if (file.key !== input.key) throw new OrchestrationError('not_found', 'File not found') + const slice = await getCsvPreviewSlice({ + key: file.key, + context: 'workspace', + signal: input.signal, + }) + return { success: true as const, ...slice } +} + +export const csvPreviewWorkspaceFile = { + operation: readWorkspaceFileContentRecord.operation, + execute: executeCsvPreviewWorkspaceFile, +} as const diff --git a/apps/sim/lib/workspace-files/application/delegated-principal.ts b/apps/sim/lib/workspace-files/application/delegated-principal.ts new file mode 100644 index 00000000000..7cb5040c86c --- /dev/null +++ b/apps/sim/lib/workspace-files/application/delegated-principal.ts @@ -0,0 +1,39 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' + +const WORKSPACE_FILE_DELEGATION_TTL_MS = 5 * 60 * 1000 + +export interface WorkspaceFileDelegationInput { + serviceId: DelegatedPrincipal['serviceId'] + subjectUserId: string + workspaceId: string + delegationId: string + fileId?: string + chatId?: string + executionId?: string +} + +/** Binds a trusted service execution to the shared workspace-file principal shape. */ +export function createWorkspaceFileDelegatedPrincipal( + input: WorkspaceFileDelegationInput +): DelegatedPrincipal { + if (!input.subjectUserId || !input.workspaceId || !input.delegationId) { + throw new Error('Workspace file delegation requires subject, workspace, and delegation IDs') + } + const issuedAt = new Date() + return { + kind: 'delegated', + serviceId: input.serviceId, + subjectUserId: input.subjectUserId, + workspaceId: input.workspaceId, + delegationId: input.delegationId, + audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, + issuedAt, + expiresAt: new Date(issuedAt.getTime() + WORKSPACE_FILE_DELEGATION_TTL_MS), + resourceScope: { + ...(input.fileId ? { fileId: input.fileId } : {}), + ...(input.chatId ? { chatId: input.chatId } : {}), + ...(input.executionId ? { executionId: input.executionId } : {}), + }, + } +} diff --git a/apps/sim/lib/workspace-files/application/delete-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/delete-workspace-file.test.ts new file mode 100644 index 00000000000..21058040263 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/delete-workspace-file.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadContext: vi.fn(), + deleteStored: vi.fn(), + resolvePermission: vi.fn(), + recordAudit: vi.fn(), + notify: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_DELETED: 'FILE_DELETED' }, + AuditResourceType: { FILE: 'FILE' }, + recordAudit: mocks.recordAudit, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mocks.notify })) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + deleteWorkspaceFile: mocks.deleteStored, + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' + +const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} + +describe('deleteWorkspaceFileOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.deleteStored.mockResolvedValue(undefined) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.notify.mockResolvedValue(undefined) + }) + + it('authorizes, archives, audits, and notifies once', async () => { + const result = await deleteWorkspaceFileOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + + expect(result).toEqual({ id: 'file-1', workspaceId: 'workspace-1', deleted: true }) + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: undefined }) + expect(mocks.resolvePermission).toHaveBeenCalled() + expect(mocks.deleteStored).toHaveBeenCalledWith('workspace-1', 'file-1') + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + actorId: 'user-1', + metadata: expect.objectContaining({ operation: 'files.delete' }), + }) + ) + expect(mocks.notify).toHaveBeenCalledWith('workspace-1') + expect(mocks.recordAudit.mock.invocationCallOrder[0]).toBeLessThan( + mocks.notify.mock.invocationCallOrder[0] + ) + }) + + it('does not emit side effects when storage fails', async () => { + const failure = new Error('storage unavailable') + mocks.deleteStored.mockRejectedValueOnce(failure) + + await expect( + deleteWorkspaceFileOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1' }, + }) + ).rejects.toBe(failure) + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/delete-workspace-file.ts b/apps/sim/lib/workspace-files/application/delete-workspace-file.ts new file mode 100644 index 00000000000..1e29b040c83 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/delete-workspace-file.ts @@ -0,0 +1,56 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { createLogger } from '@sim/logger' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + type ActiveWorkspaceFileContext, + deleteWorkspaceFile, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' + +const logger = createLogger('DeleteWorkspaceFile') + +export interface DeleteWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string +} + +export interface DeleteWorkspaceFileResult { + id: string + workspaceId: string + deleted: true +} + +async function executeDeleteWorkspaceFile({ + principal, + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.delete, + DeleteWorkspaceFileInput, + ActiveWorkspaceFileContext +>): Promise { + await deleteWorkspaceFile(context.workspaceId, context.fileId) + return { id: context.fileId, workspaceId: context.workspaceId, deleted: true } +} + +export const deleteWorkspaceFileOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.delete, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeDeleteWorkspaceFile, + projectAudit: ({ result }) => ({ + action: AuditAction.FILE_DELETED, + resourceType: AuditResourceType.FILE, + resourceId: result.id, + description: `Deleted workspace file ${result.id}`, + }), + async afterSuccess({ principal, result }) { + await notifyWorkspaceFilesChanged(result.workspaceId) + logger.info('Deleted workspace file', { + workspaceId: result.workspaceId, + fileId: result.id, + principalKind: principal.kind, + }) + }, +}) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts new file mode 100644 index 00000000000..459baa164cb --- /dev/null +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts @@ -0,0 +1,198 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + events, + mockLoadContext, + mockResolvePermission, + mockListFiles, + mockListFolders, + mockFetchServable, + mockRecordAudit, + mockIsGenerated, + mockIsRenderable, + mockIsDocNotReady, +} = vi.hoisted(() => ({ + events: [] as string[], + mockLoadContext: vi.fn(), + mockResolvePermission: vi.fn(), + mockListFiles: vi.fn(), + mockListFolders: vi.fn(), + mockFetchServable: vi.fn(), + mockRecordAudit: vi.fn(), + mockIsGenerated: vi.fn(), + mockIsRenderable: vi.fn(), + mockIsDocNotReady: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + buildWorkspaceFileFolderPathMap: (folders: Array<{ id: string; path?: string; name: string }>) => + new Map(folders.map((folder) => [folder.id, folder.path ?? folder.name])), + fetchServableWorkspaceFileBuffer: mockFetchServable, + listWorkspaceFileFolders: mockListFolders, + listWorkspaceFiles: mockListFiles, + loadWorkspaceFileOperationContext: mockLoadContext, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mockResolvePermission, +})) +vi.mock('@/lib/uploads/utils/file-utils', () => ({ + formatFileSize: (bytes: number) => `${bytes} bytes`, + isGeneratedDocumentSourceType: mockIsGenerated, + isRenderableDocumentName: mockIsRenderable, + MAX_RENDERED_DOCUMENT_BYTES: 50 * 1024 * 1024, +})) +vi.mock('@/lib/uploads/utils/servable-file-response', () => ({ + docNotReadyMessage: (names: string[]) => `Pending: ${names.join(', ')}`, + isDocNotReadyError: mockIsDocNotReady, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_DOWNLOADED: 'file.downloaded' }, + AuditResourceType: { FILE: 'file' }, + recordAudit: mockRecordAudit, +})) + +import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items' + +const principal = { kind: 'session' as const, userId: 'u1', sessionId: 's1' } +const delegatedPrincipal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'u1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { fileId: 'f1' }, +} +const workspace = { + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} + +function file(id: string, name: string, folderId: string | null = null, size = 10) { + return { id, name, folderId, size, type: 'application/octet-stream', key: `key-${id}` } +} + +describe('downloadWorkspaceFileItems', () => { + beforeEach(() => { + vi.clearAllMocks() + events.length = 0 + mockLoadContext.mockImplementation(async () => { + events.push('resolve') + return workspace + }) + mockResolvePermission.mockImplementation(async () => { + events.push('authorize') + return 'read' + }) + mockListFiles.mockImplementation(async () => { + events.push('execute') + return [file('f1', 'clip.mp4')] + }) + mockListFolders.mockResolvedValue([]) + mockIsGenerated.mockReturnValue(false) + mockIsRenderable.mockReturnValue(false) + mockIsDocNotReady.mockReturnValue(false) + }) + + it('authorizes the workspace once and returns the bounded selection', async () => { + const result = await downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: ['f1'], folderIds: [] }, + }) + + expect(events).toEqual(['resolve', 'authorize', 'execute']) + expect(result.filesToZip).toHaveLength(1) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ actorId: 'u1', workspaceId: 'ws-1' }) + ) + }) + + it('allows a file-scoped delegated principal to download its one explicit file', async () => { + const result = await downloadWorkspaceFileItems.execute({ + principal: delegatedPrincipal, + input: { workspaceId: 'ws-1', fileIds: ['f1'], folderIds: [] }, + }) + + expect(result.filesToZip.map((item) => item.id)).toEqual(['f1']) + expect(mockResolvePermission).toHaveBeenCalledOnce() + }) + + it.each([ + { label: 'multiple files', fileIds: ['f1', 'f2'], folderIds: [] }, + { label: 'a folder', fileIds: [], folderIds: ['folder-1'] }, + { label: 'a file and folder', fileIds: ['f1'], folderIds: ['folder-1'] }, + ])('denies a file-scoped delegated principal selecting $label before listing', async (input) => { + await expect( + downloadWorkspaceFileItems.execute({ + principal: delegatedPrincipal, + input: { workspaceId: 'ws-1', ...input }, + }) + ).rejects.toThrow('Delegated workspace access is no longer valid') + + expect(mockResolvePermission).not.toHaveBeenCalled() + expect(mockListFiles).not.toHaveBeenCalled() + expect(mockListFolders).not.toHaveBeenCalled() + }) + + it('expands selected folders and renders generated documents before returning', async () => { + mockListFolders.mockResolvedValue([ + { id: 'folder-1', name: 'Reports', path: 'Reports', parentId: null }, + { id: 'folder-2', name: 'Drafts', path: 'Reports/Drafts', parentId: 'folder-1' }, + ]) + mockListFiles.mockResolvedValue([file('f1', 'report.docx', 'folder-2')]) + mockIsGenerated.mockReturnValue(true) + mockFetchServable.mockResolvedValue({ buffer: Buffer.from('rendered') }) + + const result = await downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: [], folderIds: ['folder-1'] }, + }) + + expect(result.filesToZip[0].id).toBe('f1') + expect(result.renderedDocuments.get('f1')).toEqual(Buffer.from('rendered')) + expect(mockFetchServable).toHaveBeenCalledWith(expect.objectContaining({ id: 'f1' }), { + maxBytes: 50 * 1024 * 1024, + }) + }) + + it('returns typed validation and conflict failures without recording audit', async () => { + await expect( + downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: [], folderIds: [] }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(events).toEqual(['resolve', 'authorize']) + + mockListFiles.mockResolvedValue([file('f1', 'pending.docx')]) + mockIsGenerated.mockReturnValue(true) + mockIsDocNotReady.mockReturnValue(true) + mockFetchServable.mockRejectedValue(new Error('pending')) + await expect( + downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: ['f1'], folderIds: [] }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('rejects unknown selected IDs rather than exposing another workspace selection', async () => { + mockListFiles.mockResolvedValue([]) + await expect( + downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: ['other-workspace-file'], folderIds: [] }, + }) + ).rejects.toEqual(expect.objectContaining({ code: 'not_found' })) + }) +}) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts new file mode 100644 index 00000000000..4509350875a --- /dev/null +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts @@ -0,0 +1,183 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { + buildWorkspaceFileFolderPathMap, + fetchServableWorkspaceFileBuffer, + listWorkspaceFileFolders, + listWorkspaceFiles, + loadWorkspaceFileOperationContext, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { + formatFileSize, + isGeneratedDocumentSourceType, + isRenderableDocumentName, + MAX_RENDERED_DOCUMENT_BYTES, +} from '@/lib/uploads/utils/file-utils' +import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/servable-file-response' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export const MAX_ZIP_DOWNLOAD_FILES = 100 +export const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024 +const MAX_REQUESTED_FILE_IDS = 1_000 +const MAX_REQUESTED_FOLDER_IDS = 1_000 + +export interface DownloadWorkspaceFileItemsInput { + workspaceId: string + fileIds: string[] + folderIds: string[] +} + +export interface DownloadWorkspaceFileItemsResult { + filesToZip: WorkspaceFileRecord[] + folderPaths: Map + renderedDocuments: Map + declaredBytes: number +} + +function needsRendering(file: WorkspaceFileRecord): boolean { + return file.type ? isGeneratedDocumentSourceType(file.type) : isRenderableDocumentName(file.name) +} + +function collectDescendantFolderIds( + selectedFolderIds: string[], + folders: Array<{ id: string; parentId: string | null }> +): Set { + const folderIds = new Set(selectedFolderIds) + let changed = true + while (changed) { + changed = false + for (const folder of folders) { + if (folder.parentId && folderIds.has(folder.parentId) && !folderIds.has(folder.id)) { + folderIds.add(folder.id) + changed = true + } + } + } + return folderIds +} + +function validationError(message: string): never { + throw new OrchestrationError('validation', message) +} + +async function executeDownloadWorkspaceFileItems({ + input, + context, +}: { + input: DownloadWorkspaceFileItemsInput + context: Awaited> +}): Promise { + const fileIds = [...new Set(input.fileIds)] + const folderIds = [...new Set(input.folderIds)] + if (fileIds.length > MAX_REQUESTED_FILE_IDS) { + validationError(`Too many file IDs selected. Select ${MAX_REQUESTED_FILE_IDS} or fewer files.`) + } + if (folderIds.length > MAX_REQUESTED_FOLDER_IDS) { + validationError( + `Too many folder IDs selected. Select ${MAX_REQUESTED_FOLDER_IDS} or fewer folders.` + ) + } + if (fileIds.length === 0 && folderIds.length === 0) { + validationError('No files selected for download') + } + + const [files, folders] = await Promise.all([ + listWorkspaceFiles(context.workspaceId, { hydrateFolderPaths: false, throwOnError: true }), + listWorkspaceFileFolders(context.workspaceId), + ]) + const folderPaths = buildWorkspaceFileFolderPathMap(folders) + const knownFileIds = new Set(files.map((file) => file.id)) + const knownFolderIds = new Set(folders.map((folder) => folder.id)) + if ( + fileIds.some((fileId) => !knownFileIds.has(fileId)) || + folderIds.some((folderId) => !knownFolderIds.has(folderId)) + ) { + throw new OrchestrationError('not_found', 'File selection not found') + } + const selectedFolderIds = collectDescendantFolderIds(folderIds, folders) + const requestedFileIds = new Set(fileIds) + const filesToZip = files.filter( + (file) => + requestedFileIds.has(file.id) || + (file.folderId != null && selectedFolderIds.has(file.folderId)) + ) + + if (filesToZip.length === 0) validationError('No files selected for download') + if (filesToZip.length > MAX_ZIP_DOWNLOAD_FILES) { + validationError( + `Too many files selected for download. Select ${MAX_ZIP_DOWNLOAD_FILES} or fewer files.` + ) + } + + const declaredBytes = filesToZip.reduce((sum, file) => sum + file.size, 0) + if (declaredBytes > MAX_ZIP_DOWNLOAD_BYTES) { + validationError( + `Selected files total ${formatFileSize(declaredBytes)}, which exceeds the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit.` + ) + } + + const reservedForStreamed = filesToZip + .filter((file) => !needsRendering(file)) + .reduce((sum, file) => sum + file.size, 0) + const renderedDocuments = new Map() + const pendingNames: string[] = [] + let renderedBytes = 0 + + for (const file of filesToZip) { + if (!needsRendering(file)) continue + const remaining = Math.max(0, MAX_ZIP_DOWNLOAD_BYTES - reservedForStreamed - renderedBytes) + const allowance = Math.min(remaining, MAX_RENDERED_DOCUMENT_BYTES) + try { + const { buffer } = await fetchServableWorkspaceFileBuffer(file, { maxBytes: allowance }) + renderedBytes += buffer.length + renderedDocuments.set(file.id, buffer) + } catch (error) { + if (error instanceof PayloadSizeLimitError) { + validationError( + allowance === MAX_RENDERED_DOCUMENT_BYTES + ? `"${file.name}" renders to more than ${formatFileSize(MAX_RENDERED_DOCUMENT_BYTES)} and is too large to include in a zip; download it on its own instead.` + : `The selected files exceed the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit once documents are rendered. Select fewer files.` + ) + } + if (!isDocNotReadyError(error)) throw error + pendingNames.push(file.name) + } + } + + if (pendingNames.length > 0) { + throw new OrchestrationError('conflict', docNotReadyMessage(pendingNames)) + } + + return { filesToZip, folderPaths, renderedDocuments, declaredBytes } +} + +async function resolveDownloadContext({ input }: { input: DownloadWorkspaceFileItemsInput }) { + const context = await loadWorkspaceFileOperationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + const fileIds = [...new Set(input.fileIds)] + const folderIds = [...new Set(input.folderIds)] + return { + ...context, + fileId: fileIds.length === 1 && folderIds.length === 0 ? fileIds[0] : undefined, + } +} + +export const downloadWorkspaceFileItems = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.download, + resolveContext: resolveDownloadContext, + execute: executeDownloadWorkspaceFileItems, + projectAudit({ result }) { + return { + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + description: `Downloaded ${result.filesToZip.length} file${result.filesToZip.length === 1 ? '' : 's'} as zip`, + metadata: { + fileCount: result.filesToZip.length, + totalBytes: result.declaredBytes, + }, + } + }, +}) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts new file mode 100644 index 00000000000..37e932ca15f --- /dev/null +++ b/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts @@ -0,0 +1,104 @@ +/** + * @vitest-environment node + */ +import { Readable } from 'node:stream' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + downloadStream: vi.fn(), + getFile: vi.fn(), + loadContext: vi.fn(), + recordAudit: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_DOWNLOADED: 'FILE_DOWNLOADED' }, + AuditResourceType: { FILE: 'FILE' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: mocks.getFile, + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFileStream: mocks.downloadStream, +})) + +import { + downloadWorkspaceFile, + downloadWorkspaceFileStream, +} from '@/lib/workspace-files/application/download-workspace-file' + +const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} + +const file = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'report.pdf', + key: 'workspace/workspace-1/report.pdf', + size: 42, + storageContext: 'workspace', +} + +describe('workspace file downloads', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.getFile.mockResolvedValue(file) + mocks.downloadStream.mockResolvedValue(Readable.from(Buffer.from('pdf'))) + }) + + it('returns the authoritative file and records its semantic download audit', async () => { + await expect( + downloadWorkspaceFile.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ file }) + + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + actorId: 'user-1', + action: 'FILE_DOWNLOADED', + resourceId: 'file-1', + resourceName: 'report.pdf', + metadata: expect.objectContaining({ + operation: 'files.download', + fileId: 'file-1', + fileName: 'report.pdf', + bytes: 42, + }), + }) + ) + }) + + it('does not audit a streaming download when storage acquisition fails', async () => { + const failure = new Error('storage unavailable') + mocks.downloadStream.mockRejectedValueOnce(failure) + + await expect( + downloadWorkspaceFileStream.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1' }, + }) + ).rejects.toBe(failure) + + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file.ts b/apps/sim/lib/workspace-files/application/download-workspace-file.ts new file mode 100644 index 00000000000..70be04fbe93 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/download-workspace-file.ts @@ -0,0 +1,87 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' +import { + type ActiveWorkspaceFileContext, + getWorkspaceFile, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { downloadFileStream } from '@/lib/uploads/core/storage-service' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' + +export interface DownloadWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string +} + +export interface DownloadWorkspaceFileResult { + file: NonNullable>> +} + +export interface DownloadWorkspaceFileStreamResult extends DownloadWorkspaceFileResult { + stream: ReadableStream +} + +function projectDownloadAudit(file: DownloadWorkspaceFileResult['file']) { + return { + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.name, + description: `Downloaded file "${file.name}"`, + metadata: { + fileId: file.id, + fileName: file.name, + bytes: file.size, + }, + } +} + +async function executeDownloadWorkspaceFile({ + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.download, + DownloadWorkspaceFileInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { file } +} + +export const downloadWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.download, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeDownloadWorkspaceFile, + projectAudit: ({ result }) => projectDownloadAudit(result.file), +}) + +async function executeDownloadWorkspaceFileStream({ + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.download, + DownloadWorkspaceFileInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + const stream = await downloadFileStream({ + key: file.key, + context: file.storageContext ?? 'workspace', + }) + return { file, stream: nodeReadableToWebStream(stream) } +} + +/** Authorized and audited binary download without materializing the file in memory. */ +export const downloadWorkspaceFileStream = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.download, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeDownloadWorkspaceFileStream, + projectAudit: ({ result }) => projectDownloadAudit(result.file), +}) diff --git a/apps/sim/lib/workspace-files/application/list-workspace-files.ts b/apps/sim/lib/workspace-files/application/list-workspace-files.ts new file mode 100644 index 00000000000..bbf9924e47c --- /dev/null +++ b/apps/sim/lib/workspace-files/application/list-workspace-files.ts @@ -0,0 +1,75 @@ +import type { CursorKey } from '@/lib/api/list-query' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { getWorkspaceShares } from '@/lib/public-shares/share-manager' +import { + listWorkspaceFiles, + loadActiveWorkspaceContext, + queryWorkspaceFiles, +} from '@/lib/uploads/contexts/workspace' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export interface ListAllWorkspaceFilesInput { + workspaceId: string + scope: 'active' | 'archived' | 'all' +} + +export interface QueryWorkspaceFilePageInput { + workspaceId: string + folderPath?: string + search?: string + sortBy: 'name' | 'size' | 'uploadedAt' | 'updatedAt' + sortOrder: 'asc' | 'desc' + limit: number + after?: CursorKey[] + cursorSort: string +} + +async function resolveListWorkspaceFileContext(workspaceId: string) { + const workspace = await loadActiveWorkspaceContext(workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return workspace +} + +export const listAllWorkspaceFiles = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.list, + resolveContext: ({ input }: { input: ListAllWorkspaceFilesInput }) => + resolveListWorkspaceFileContext(input.workspaceId), + async execute({ input, context }) { + const files = await listWorkspaceFiles(context.workspaceId, { scope: input.scope }) + const shares = await getWorkspaceShares('file', context.workspaceId) + return { + files: files.map((file) => ({ ...file, share: shares.get(file.id) ?? null })), + } + }, +}) + +export const queryWorkspaceFilePage = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.list, + resolveContext: ({ input }: { input: QueryWorkspaceFilePageInput }) => + resolveListWorkspaceFileContext(input.workspaceId), + async execute({ input, context }) { + const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'file') + 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 { files, nextKeys } = await queryWorkspaceFiles(context.workspaceId, { + folderId, + search: input.search, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + limit: input.limit, + after: input.after, + }) + return { files, nextKeys, cursorSort: input.cursorSort } + }, +}) diff --git a/apps/sim/lib/workspace-files/application/move-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/move-workspace-file-items.test.ts new file mode 100644 index 00000000000..61ced7919d4 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/move-workspace-file-items.test.ts @@ -0,0 +1,168 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + events, + mockLoadContext, + mockResolvePermission, + mockAssertItems, + mockMove, + mockAudit, + mockNotify, +} = vi.hoisted(() => ({ + events: [] as string[], + mockLoadContext: vi.fn(), + mockResolvePermission: vi.fn(), + mockAssertItems: vi.fn(), + mockMove: vi.fn(), + mockAudit: vi.fn(), + mockNotify: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + assertWorkspaceFileItemsBelongToWorkspace: mockAssertItems, + loadWorkspaceFileOperationContext: mockLoadContext, + moveWorkspaceFileItems: mockMove, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mockResolvePermission, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_MOVED: 'file.moved', FOLDER_MOVED: 'folder.moved' }, + AuditResourceType: { FILE: 'file', FOLDER: 'folder' }, + recordAudit: mockAudit, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mockNotify })) + +import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' + +describe('moveWorkspaceFileItemsOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + events.length = 0 + mockLoadContext.mockImplementation(async () => { + events.push('resolve') + return { + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', + } + }) + mockResolvePermission.mockImplementation(async () => { + events.push('authorize') + return 'write' + }) + mockAssertItems.mockImplementation(async () => { + events.push('execute') + }) + mockMove.mockImplementation(async () => ({ + movedFiles: 2, + movedFolders: 1, + movedFileIds: ['file-1', 'file-2'], + movedFolderIds: ['folder-1'], + })) + }) + + it('uses the atomic manager primitive and records each semantic category once', async () => { + const result = await moveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'ws-1', + fileIds: ['file-1', 'file-2'], + folderIds: ['folder-1'], + targetFolderId: null, + }, + }) + + expect(result).toMatchObject({ movedItems: { files: 2, folders: 1 } }) + expect(events).toEqual(['resolve', 'authorize', 'execute']) + expect(mockAssertItems).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + fileIds: ['file-1', 'file-2'], + folderIds: ['folder-1'], + }) + expect(mockMove).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + fileIds: ['file-1', 'file-2'], + folderIds: ['folder-1'], + targetFolderId: null, + targetFolderPath: undefined, + }) + expect(mockAudit).toHaveBeenCalledTimes(2) + expect(mockNotify).toHaveBeenCalledOnce() + }) + + it('allows authorization to carry a resource ID only for one explicit file', async () => { + mockMove.mockResolvedValue({ + movedFiles: 1, + movedFolders: 0, + movedFileIds: ['file-1'], + movedFolderIds: [], + }) + + await moveWorkspaceFileItemsOperation.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { fileId: 'file-1' }, + }, + input: { workspaceId: 'ws-1', fileIds: ['file-1'], targetFolderId: null }, + }) + + expect(mockResolvePermission).toHaveBeenCalledOnce() + }) + + it('does not audit or notify requested IDs absent from the mutation result', async () => { + mockMove.mockResolvedValue({ + movedFiles: 0, + movedFolders: 0, + movedFileIds: [], + movedFolderIds: [], + }) + + const result = await moveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', fileIds: ['file-1'], targetFolderId: null }, + }) + + expect(result).toMatchObject({ movedItems: { files: 0, folders: 0 } }) + expect(mockAudit).not.toHaveBeenCalled() + expect(mockNotify).not.toHaveBeenCalled() + }) + + it('authorizes before rejecting an empty selection without touching storage', async () => { + await expect( + moveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1' }, + }) + ).rejects.toThrow('At least one file or folder must be selected') + expect(events).toEqual(['resolve', 'authorize']) + expect(mockMove).not.toHaveBeenCalled() + }) + + it('rejects oversized selections after authorization', async () => { + await expect( + moveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'ws-1', + folderIds: Array.from({ length: 1_001 }, (_, index) => `folder-${index}`), + }, + }) + ).rejects.toThrow('accept at most 1000') + expect(events).toEqual(['resolve', 'authorize']) + expect(mockMove).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/move-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/move-workspace-file-items.ts new file mode 100644 index 00000000000..5e8a998e335 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/move-workspace-file-items.ts @@ -0,0 +1,127 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + assertWorkspaceFileItemsBelongToWorkspace, + loadWorkspaceFileOperationContext, + moveWorkspaceFileItems, +} from '@/lib/uploads/contexts/workspace' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits' + +const logger = createLogger('MoveWorkspaceFileItems') + +export interface MoveWorkspaceFileItemsInput { + workspaceId: string + fileIds?: string[] + folderIds?: string[] + targetFolderId?: string | null + targetFolderPath?: string +} + +export interface MoveWorkspaceFileItemsResult { + movedItems: { files: number; folders: number } + affectedIds: { fileIds: string[]; folderIds: string[] } +} + +function normalizeSelection(input: MoveWorkspaceFileItemsInput) { + return { + fileIds: [...new Set(input.fileIds ?? [])], + folderIds: [...new Set(input.folderIds ?? [])], + } +} + +async function executeMoveWorkspaceFileItems({ + input, + context, +}: { + input: MoveWorkspaceFileItemsInput + context: Awaited> +}): Promise { + const { fileIds, folderIds } = normalizeSelection(input) + if (fileIds.length === 0 && folderIds.length === 0) { + throw new OrchestrationError('validation', 'At least one file or folder must be selected') + } + if ( + fileIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS || + folderIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS + ) { + throw new OrchestrationError( + 'validation', + `Bulk file operations accept at most ${MAX_WORKSPACE_FILE_BULK_REQUEST_IDS} file and folder IDs` + ) + } + + await assertWorkspaceFileItemsBelongToWorkspace({ + workspaceId: context.workspaceId, + fileIds, + folderIds, + }) + const moved = await moveWorkspaceFileItems({ + workspaceId: context.workspaceId, + fileIds, + folderIds, + targetFolderId: input.targetFolderId, + targetFolderPath: input.targetFolderPath, + }) + const movedItems = { files: moved.movedFileIds.length, folders: moved.movedFolderIds.length } + + logger.info('Moved workspace file items', { workspaceId: context.workspaceId, movedItems }) + return { + movedItems, + affectedIds: { fileIds: moved.movedFileIds, folderIds: moved.movedFolderIds }, + } +} + +async function resolveMoveContext({ input }: { input: MoveWorkspaceFileItemsInput }) { + const context = await loadWorkspaceFileOperationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + const { fileIds, folderIds } = normalizeSelection(input) + return { + ...context, + fileId: fileIds.length === 1 && folderIds.length === 0 ? fileIds[0] : undefined, + } +} + +export const moveWorkspaceFileItemsOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.move, + resolveContext: resolveMoveContext, + execute: executeMoveWorkspaceFileItems, + projectAudit({ input, result }) { + const entries = [] + if (result.affectedIds.fileIds.length > 0) { + entries.push({ + action: AuditAction.FILE_MOVED, + resourceType: AuditResourceType.FILE, + description: `Moved ${result.affectedIds.fileIds.length} file${result.affectedIds.fileIds.length === 1 ? '' : 's'}`, + metadata: { + fileIds: result.affectedIds.fileIds, + targetFolderId: input.targetFolderId, + targetFolderPath: input.targetFolderPath, + }, + }) + } + if (result.affectedIds.folderIds.length > 0) { + entries.push({ + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: + result.affectedIds.folderIds.length === 1 ? result.affectedIds.folderIds[0] : undefined, + description: `Moved ${result.affectedIds.folderIds.length} file folder${result.affectedIds.folderIds.length === 1 ? '' : 's'}`, + metadata: { + folderIds: result.affectedIds.folderIds, + targetFolderId: input.targetFolderId, + targetFolderPath: input.targetFolderPath, + }, + }) + } + return entries + }, + async afterSuccess({ context, result }) { + if (result.affectedIds.fileIds.length > 0 || result.affectedIds.folderIds.length > 0) { + await notifyWorkspaceFilesChanged(context.workspaceId) + } + }, +}) diff --git a/apps/sim/lib/workspace-files/application/operations.test.ts b/apps/sim/lib/workspace-files/application/operations.test.ts new file mode 100644 index 00000000000..82b5bdbd55c --- /dev/null +++ b/apps/sim/lib/workspace-files/application/operations.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment node + */ + +import { permissionSatisfies } from '@sim/platform-authz/workspace' +import { describe, expect, it } from 'vitest' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +describe('file operation registry', () => { + it('keeps every workspace-key operation at or below the fixed write ceiling', () => { + for (const operation of Object.values(fileOperations)) { + expect( + operation.principalKinds.length, + `${operation.id} has no allowed principals` + ).toBeGreaterThan(0) + expect( + new Set(operation.principalKinds).size, + `${operation.id} repeats a principal kind` + ).toBe(operation.principalKinds.length) + expect( + operation.principalKinds.includes('workspace_api_key'), + `${operation.id} has inconsistent workspace API-key declarations` + ).toBe(operation.workspaceApiKey === 'allow') + + if (operation.workspaceApiKey === 'allow') { + expect( + permissionSatisfies('write', operation.minimumRole), + `${operation.id} exceeds the workspace API-key write ceiling` + ).toBe(true) + } + } + }) + + it('uses unique stable operation IDs', () => { + const ids = Object.values(fileOperations).map((operation) => operation.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('keeps external sharing policy changes human-delegated', () => { + expect(fileOperations.updateShare.workspaceApiKey).toBe('deny') + expect(fileOperations.updateShare.principalKinds).toEqual([ + 'session', + 'personal_api_key', + 'delegated', + ]) + }) + + it('restricts compiled checks to authenticated sessions', () => { + expect(fileOperations.compiledCheck).toMatchObject({ + id: 'files.compiled_check', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }) + }) +}) diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts new file mode 100644 index 00000000000..cd4f6179b23 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -0,0 +1,152 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const ALL_PRINCIPAL_KINDS = [ + 'session', + 'personal_api_key', + 'workspace_api_key', + 'delegated', +] as const +const HUMAN_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'delegated'] as const + +export const fileOperations = { + list: defineWorkspaceOperation({ + id: 'files.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + readMetadata: defineWorkspaceOperation({ + id: 'files.read_metadata', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + readContent: defineWorkspaceOperation({ + id: 'files.read_content', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + download: defineWorkspaceOperation({ + id: 'files.download', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + compiledCheck: defineWorkspaceOperation({ + id: 'files.compiled_check', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + create: defineWorkspaceOperation({ + id: 'files.create', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + rename: defineWorkspaceOperation({ + id: 'files.rename', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + updateContent: defineWorkspaceOperation({ + id: 'files.update_content', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + updateMetadata: defineWorkspaceOperation({ + id: 'files.update_metadata', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + move: defineWorkspaceOperation({ + id: 'files.move', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + delete: defineWorkspaceOperation({ + id: 'files.delete', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + restore: defineWorkspaceOperation({ + id: 'files.restore', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + readShare: defineWorkspaceOperation({ + id: 'files.share.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + updateShare: defineWorkspaceOperation({ + id: 'files.share.update', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: HUMAN_PRINCIPAL_KINDS, + }), + listFolders: defineWorkspaceOperation({ + id: 'files.folders.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + createFolder: defineWorkspaceOperation({ + id: 'files.folders.create', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + updateFolder: defineWorkspaceOperation({ + id: 'files.folders.update', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + deleteFolder: defineWorkspaceOperation({ + id: 'files.folders.delete', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + restoreFolder: defineWorkspaceOperation({ + id: 'files.folders.restore', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + uploadCreate: defineWorkspaceOperation({ + id: 'files.upload.create', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + uploadParts: defineWorkspaceOperation({ + id: 'files.upload.parts', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + uploadComplete: defineWorkspaceOperation({ + id: 'files.upload.complete', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + uploadCancel: defineWorkspaceOperation({ + id: 'files.upload.cancel', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), +} as const + +export type FileOperation = (typeof fileOperations)[keyof typeof fileOperations] diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-content.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-content.test.ts new file mode 100644 index 00000000000..71a666fa392 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-content.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + fetchBuffer: vi.fn(), + getFile: vi.fn(), + loadContext: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + fetchWorkspaceFileBuffer: mocks.fetchBuffer, + getWorkspaceFile: mocks.getFile, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' + +const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} + +const file = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'source.txt', + key: 'workspace/workspace-1/source.txt', + size: 12, +} + +describe('readWorkspaceFileContent', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.getFile.mockResolvedValue(file) + mocks.fetchBuffer.mockResolvedValue(Buffer.from('source')) + }) + + it('authorizes the canonical file before performing a bounded content read', async () => { + await expect( + readWorkspaceFileContent.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + fileId: 'file-1', + assertedWorkspaceId: 'workspace-1', + includeDeleted: true, + maxBytes: 512, + }, + }) + ).resolves.toEqual({ file, content: Buffer.from('source') }) + + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: true }) + expect(mocks.getFile).toHaveBeenCalledWith('workspace-1', 'file-1', { + includeDeleted: true, + throwOnError: true, + }) + expect(mocks.fetchBuffer).toHaveBeenCalledWith(file, { maxBytes: 512 }) + }) + + it('conceals an asserted-workspace mismatch before authorization or storage reads', async () => { + await expect( + readWorkspaceFileContent.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-2' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'File not found' }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.getFile).not.toHaveBeenCalled() + expect(mocks.fetchBuffer).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-content.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-content.ts new file mode 100644 index 00000000000..7ac5d641d13 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-content.ts @@ -0,0 +1,49 @@ +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ActiveWorkspaceFileContext, + fetchWorkspaceFileBuffer, + getWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' + +export interface ReadWorkspaceFileContentInput { + fileId: string + assertedWorkspaceId?: string + /** Optional post-authorization storage ceiling for bounded binary reads. */ + maxBytes?: number + includeDeleted?: boolean +} + +export interface ReadWorkspaceFileContentResult { + file: WorkspaceFileRecord + content: Buffer +} + +async function executeReadWorkspaceFileContent({ + input, + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.readContent, + ReadWorkspaceFileContentInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + includeDeleted: input.includeDeleted, + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { + file, + content: await fetchWorkspaceFileBuffer(file, { maxBytes: input.maxBytes }), + } +} + +export const readWorkspaceFileContent = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.readContent, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeReadWorkspaceFileContent, +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts new file mode 100644 index 00000000000..888869e4918 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadContext: vi.fn(), + getWorkspaceFile: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: mocks.getWorkspaceFile, + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' + +const canonical = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const file = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'data.csv', + key: 'workspace/ws/data.csv', + path: '/api/files/serve/data.csv', + size: 42, + type: 'text/csv', + uploadedBy: 'user-1', + folderId: null, + uploadedAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} + +describe('readWorkspaceFileMetadata', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(canonical) + mocks.getWorkspaceFile.mockResolvedValue(file) + mocks.resolvePermission.mockResolvedValue('admin') + }) + + it('returns the canonical active file without side effects', async () => { + const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + + await expect( + readWorkspaceFileMetadata.execute({ + principal, + input: { + fileId: 'file-1', + assertedWorkspaceId: 'workspace-1', + }, + }) + ).resolves.toEqual({ file }) + + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { + includeDeleted: undefined, + }) + expect(mocks.getWorkspaceFile).toHaveBeenCalledWith('workspace-1', 'file-1', { + throwOnError: true, + }) + }) + + it('fails fast if the authorized file disappears before projection', async () => { + mocks.getWorkspaceFile.mockResolvedValueOnce(null) + + await expect( + readWorkspaceFileMetadata.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts new file mode 100644 index 00000000000..9fbc52ff850 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts @@ -0,0 +1,42 @@ +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ActiveWorkspaceFileContext, + getWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' + +export interface ReadWorkspaceFileMetadataInput { + fileId: string + assertedWorkspaceId?: string + includeDeleted?: boolean +} + +export interface ReadWorkspaceFileMetadataResult { + file: WorkspaceFileRecord +} + +async function executeReadWorkspaceFileMetadata({ + input, + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.readMetadata, + ReadWorkspaceFileMetadataInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + includeDeleted: input.includeDeleted, + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { file } +} + +export const readWorkspaceFileMetadata = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.readMetadata, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeReadWorkspaceFileMetadata, +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-record.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-record.test.ts new file mode 100644 index 00000000000..4173a836a39 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-record.test.ts @@ -0,0 +1,69 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getFile: vi.fn(), + loadContext: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: mocks.getFile, + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +import { + downloadWorkspaceFileRecord, + readWorkspaceFileContentRecord, +} from '@/lib/workspace-files/application/read-workspace-file-record' + +const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} + +const file = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'report.pdf', +} + +describe('workspace file record reads', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.getFile.mockResolvedValue(file) + }) + + it.each([ + [readWorkspaceFileContentRecord, 'files.read_content'], + [downloadWorkspaceFileRecord, 'files.download'], + ] as const)( + 'uses the %s operation for its canonical record read', + async (useCase, operationId) => { + await expect( + useCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ file }) + + expect(useCase.operation.id).toBe(operationId) + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: undefined }) + expect(mocks.getFile).toHaveBeenCalledWith('workspace-1', 'file-1', { + throwOnError: true, + }) + } + ) +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts new file mode 100644 index 00000000000..f9570ad679a --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts @@ -0,0 +1,46 @@ +import type { AuthorizedWorkspaceUseCaseContext, WorkspaceOperation } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ActiveWorkspaceFileContext, + getWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' + +export interface ReadWorkspaceFileRecordInput { + fileId: string + assertedWorkspaceId?: string +} + +export interface ReadWorkspaceFileRecordResult { + file: WorkspaceFileRecord +} + +function createReadWorkspaceFileRecord(operation: O) { + return defineAuthorizedWorkspaceFileUseCase({ + operation, + resolveContext: ({ input }: { input: ReadWorkspaceFileRecordInput }) => + resolveActiveWorkspaceFileContext(input), + async execute({ + context, + }: AuthorizedWorkspaceUseCaseContext< + O, + ReadWorkspaceFileRecordInput, + ActiveWorkspaceFileContext + >): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { file } + }, + }) +} + +export const readWorkspaceFileContentRecord = createReadWorkspaceFileRecord( + fileOperations.readContent +) + +export const downloadWorkspaceFileRecord = createReadWorkspaceFileRecord(fileOperations.download) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts new file mode 100644 index 00000000000..f1012bc3c12 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment node + */ +import { Readable } from 'node:stream' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockLoadContext, mockGetWorkspaceFile, mockGetMetadataByKey, mockDownloadFileStream } = + vi.hoisted(() => ({ + mockLoadContext: vi.fn(), + mockGetWorkspaceFile: vi.fn(), + mockGetMetadataByKey: vi.fn(), + mockDownloadFileStream: vi.fn(), + })) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: vi.fn().mockResolvedValue('admin'), +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: mockGetWorkspaceFile, + loadActiveWorkspaceFileContext: mockLoadContext, +})) +vi.mock('@/lib/uploads/server/metadata', () => ({ getFileMetadataByKey: mockGetMetadataByKey })) +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFileStream: mockDownloadFileStream, +})) + +import { readWorkspaceInlineFile } from '@/lib/workspace-files/application/read-workspace-inline-file' + +const principal = { kind: 'session' as const, userId: 'u1', sessionId: 's1' } +const file = { + id: 'f1', + workspaceId: 'ws-1', + key: 'workspace/ws-1/photo.png', + name: 'photo.png', + type: 'image/png', +} + +describe('readWorkspaceInlineFile', () => { + beforeEach(() => { + vi.clearAllMocks() + mockLoadContext.mockResolvedValue({ + fileId: 'f1', + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', + }) + mockGetWorkspaceFile.mockResolvedValue(file) + mockDownloadFileStream.mockResolvedValue(Readable.from(Buffer.from('png'))) + }) + + it('authorizes a file-id reference against the asserted workspace before reading bytes', async () => { + const result = await readWorkspaceInlineFile.execute({ + principal, + input: { workspaceId: 'ws-1', fileId: 'f1' }, + }) + + expect(mockLoadContext).toHaveBeenCalledWith('f1') + expect(mockDownloadFileStream).toHaveBeenCalledWith({ + key: file.key, + context: 'workspace', + }) + expect(Buffer.from(await new Response(result.stream).arrayBuffer())).toEqual(Buffer.from('png')) + }) + + it('resolves a storage-key reference to its canonical file id before authorizing', async () => { + mockGetMetadataByKey.mockResolvedValue({ id: 'f1', workspaceId: 'ws-1' }) + + await readWorkspaceInlineFile.execute({ + principal, + input: { workspaceId: 'ws-1', key: file.key }, + }) + + expect(mockGetMetadataByKey).toHaveBeenCalledWith(file.key, 'workspace') + expect(mockLoadContext).toHaveBeenCalledWith('f1') + }) + + it('conceals a key belonging to another workspace before authorization', async () => { + mockGetMetadataByKey.mockResolvedValue({ id: 'other', workspaceId: 'ws-other' }) + + await expect( + readWorkspaceInlineFile.execute({ + principal, + input: { workspaceId: 'ws-1', key: 'workspace/ws-other/photo.png' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mockLoadContext).not.toHaveBeenCalled() + expect(mockDownloadFileStream).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts new file mode 100644 index 00000000000..1dba3799607 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts @@ -0,0 +1,62 @@ +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' +import { + type ActiveWorkspaceFileContext, + getWorkspaceFile, + loadActiveWorkspaceFileContext, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { downloadFileStream } from '@/lib/uploads/core/storage-service' +import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export interface ReadWorkspaceInlineFileInput { + workspaceId: string + key?: string + fileId?: string +} + +export interface ReadWorkspaceInlineFileResult { + file: WorkspaceFileRecord + stream: ReadableStream +} + +async function executeReadWorkspaceInlineFile({ + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.readContent, + ReadWorkspaceInlineFileInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'Not found') + + const stream = await downloadFileStream({ key: file.key, context: 'workspace' }) + return { file, stream: nodeReadableToWebStream(stream) } +} + +export const readWorkspaceInlineFile = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.readContent, + async resolveContext({ input }) { + let fileId = input.fileId + if (!fileId && input.key) { + const metadata = await getFileMetadataByKey(input.key, 'workspace') + if (!metadata || metadata.workspaceId !== input.workspaceId) { + throw new OrchestrationError('not_found', 'Not found') + } + fileId = metadata.id + } + if (!fileId) throw new OrchestrationError('validation', 'Provide exactly one file reference') + + const canonical = await loadActiveWorkspaceFileContext(fileId) + if (!canonical || canonical.workspaceId !== input.workspaceId) { + throw new OrchestrationError('not_found', 'Not found') + } + return canonical + }, + execute: executeReadWorkspaceInlineFile, +}) diff --git a/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts new file mode 100644 index 00000000000..1cd669986f4 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts @@ -0,0 +1,152 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadContext: vi.fn(), + renameStored: vi.fn(), + resolvePermission: vi.fn(), + recordAudit: vi.fn(), + notify: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_UPDATED: 'FILE_UPDATED' }, + AuditResourceType: { FILE: 'FILE' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mocks.notify })) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + loadActiveWorkspaceFileContext: mocks.loadContext, + renameWorkspaceFile: mocks.renameStored, +})) + +import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' + +const canonical = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const mappedFile = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'new.csv', + key: 'workspace/ws/file.csv', + path: '/api/files/serve/file.csv', + size: 42, + type: 'text/csv', + uploadedBy: 'uploader-1', + folderId: null, + uploadedAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} + +describe('renameWorkspaceFile application service', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(canonical) + mocks.renameStored.mockResolvedValue(mappedFile) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.notify.mockResolvedValue(undefined) + }) + + it('loads, authorizes, renames, then emits side effects', async () => { + const result = await renameWorkspaceFile.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1', name: 'new.csv' }, + }) + + expect(result).toEqual({ file: mappedFile }) + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: undefined }) + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + expect(mocks.renameStored).toHaveBeenCalledWith('workspace-1', 'file-1', 'new.csv') + expect(mocks.renameStored.mock.invocationCallOrder[0]).toBeLessThan( + mocks.recordAudit.mock.invocationCallOrder[0] + ) + expect(mocks.renameStored.mock.invocationCallOrder[0]).toBeLessThan( + mocks.notify.mock.invocationCallOrder[0] + ) + expect(mocks.recordAudit.mock.invocationCallOrder[0]).toBeLessThan( + mocks.notify.mock.invocationCallOrder[0] + ) + }) + + it('conceals an asserted-workspace mismatch before authorization or mutation', async () => { + await expect( + renameWorkspaceFile.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-2', name: 'new.csv' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'File not found' }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.renameStored).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('keeps workspace-key audit attribution non-human', async () => { + await renameWorkspaceFile.execute({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1', name: 'new.csv' }, + }) + + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: null, + actorName: 'Workspace API key', + metadata: expect.objectContaining({ + actor: { + kind: 'workspace_api_key', + keyId: 'key-1', + workspaceId: 'workspace-1', + }, + }), + }) + ) + }) + + it('propagates a typed rename conflict', async () => { + const failure = Object.assign(new Error('File already exists'), { code: 'conflict' }) + mocks.renameStored.mockRejectedValueOnce(failure) + + await expect( + renameWorkspaceFile.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1', name: 'new.csv' }, + }) + ).rejects.toBe(failure) + + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) + + it('propagates an infrastructure read failure without classifying it as not found', async () => { + const failure = new Error('database unavailable') + mocks.loadContext.mockRejectedValueOnce(failure) + + await expect( + renameWorkspaceFile.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', name: 'new.csv' }, + }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/workspace-files/application/rename-workspace-file.ts b/apps/sim/lib/workspace-files/application/rename-workspace-file.ts new file mode 100644 index 00000000000..72bb001ac39 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/rename-workspace-file.ts @@ -0,0 +1,58 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { createLogger } from '@sim/logger' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + type ActiveWorkspaceFileContext, + renameWorkspaceFile as renameStoredWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' + +const logger = createLogger('RenameWorkspaceFile') + +export interface RenameWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string + name: string +} + +export interface RenameWorkspaceFileResult { + file: WorkspaceFileRecord +} + +async function executeRenameWorkspaceFile({ + principal, + input, + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.rename, + RenameWorkspaceFileInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await renameStoredWorkspaceFile(context.workspaceId, context.fileId, input.name) + + logger.info('Renamed workspace file', { + workspaceId: context.workspaceId, + fileId: context.fileId, + name: file.name, + principalKind: principal.kind, + }) + return { file } +} + +export const renameWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.rename, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeRenameWorkspaceFile, + projectAudit: ({ result }) => ({ + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Renamed file to "${result.file.name}"`, + }), + afterSuccess: ({ context }) => notifyWorkspaceFilesChanged(context.workspaceId), +}) diff --git a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts new file mode 100644 index 00000000000..cd69a123118 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts @@ -0,0 +1,107 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + fetchBuffer: vi.fn(), + loadContext: vi.fn(), + resolvePermission: vi.fn(), + resolveStoredReference: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + fetchWorkspaceFileBuffer: mocks.fetchBuffer, + loadActiveWorkspaceFileContext: mocks.loadContext, + resolveWorkspaceFileReference: mocks.resolveStoredReference, +})) + +import { defineWorkspaceOperation } from '@/lib/core/application' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { + readWorkspaceFileReference, + resolveWorkspaceFileReference, +} from '@/lib/workspace-files/application/resolve-workspace-file-reference' + +const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +const file = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'source.txt', + key: 'workspace/workspace-1/source.txt', + size: 12, +} +const context = { + fileId: file.id, + workspaceId: file.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} + +describe('workspace file reference application service', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveStoredReference.mockResolvedValue(file) + mocks.loadContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.fetchBuffer.mockResolvedValue(Buffer.from('source')) + }) + + it('uses one fixed semantic use case for an authorized reference lookup', async () => { + await expect( + resolveWorkspaceFileReference({ + principal, + operation: fileOperations.rename, + workspaceId: 'workspace-1', + reference: 'files/source.txt', + }) + ).resolves.toBe(file) + + expect(mocks.resolveStoredReference).toHaveBeenCalledTimes(1) + expect(mocks.loadContext).toHaveBeenCalledTimes(1) + expect(mocks.resolvePermission).toHaveBeenCalledTimes(1) + }) + + it('reads a referenced file with one canonical load and authorization', async () => { + await expect( + readWorkspaceFileReference({ + principal, + workspaceId: 'workspace-1', + reference: 'files/source.txt', + maxBytes: 512, + }) + ).resolves.toEqual({ file, content: Buffer.from('source') }) + + expect(mocks.resolveStoredReference).toHaveBeenCalledTimes(1) + expect(mocks.loadContext).toHaveBeenCalledTimes(1) + expect(mocks.resolvePermission).toHaveBeenCalledTimes(1) + expect(mocks.fetchBuffer).toHaveBeenCalledWith(file, { maxBytes: 512 }) + }) + + it('fails before canonical loading for an unregistered operation object', async () => { + const duplicateOperation = defineWorkspaceOperation({ + id: fileOperations.rename.id, + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }) + + await expect( + resolveWorkspaceFileReference({ + principal, + operation: duplicateOperation, + workspaceId: 'workspace-1', + reference: 'files/source.txt', + }) + ).rejects.toThrow('No workspace file reference resolver is defined for files.rename') + + expect(mocks.resolveStoredReference).not.toHaveBeenCalled() + expect(mocks.loadContext).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts new file mode 100644 index 00000000000..89962635c5c --- /dev/null +++ b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts @@ -0,0 +1,125 @@ +import type { Principal } from '@sim/auth/principal' +import type { OperationUseCase, WorkspaceOperation } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + fetchWorkspaceFileBuffer, + loadActiveWorkspaceFileContext, + resolveWorkspaceFileReference as resolveStoredWorkspaceFileReference, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export interface ResolveWorkspaceFileReferenceInput { + principal: Principal + operation: WorkspaceOperation + workspaceId: string + reference: string +} + +interface WorkspaceFileReferenceInput { + workspaceId: string + reference: string +} + +interface WorkspaceFileReferenceResult { + file: WorkspaceFileRecord +} + +interface WorkspaceFileReferenceReadInput extends WorkspaceFileReferenceInput { + maxBytes: number +} + +async function resolveWorkspaceFileReferenceContext({ + input, +}: { + input: WorkspaceFileReferenceInput +}) { + const file = await resolveStoredWorkspaceFileReference(input.workspaceId, input.reference) + if (!file) throw new OrchestrationError('not_found', 'File not found') + const canonical = await loadActiveWorkspaceFileContext(file.id) + if (!canonical || canonical.workspaceId !== input.workspaceId) { + throw new OrchestrationError('not_found', 'File not found') + } + return { ...canonical, file } +} + +function defineWorkspaceFileReferenceUseCase(operation: O) { + return defineAuthorizedWorkspaceFileUseCase({ + operation, + resolveContext: resolveWorkspaceFileReferenceContext, + async execute({ context }): Promise { + return { file: context.file } + }, + }) +} + +type WorkspaceFileReferenceUseCase = OperationUseCase< + WorkspaceOperation, + WorkspaceFileReferenceInput, + WorkspaceFileReferenceResult +> + +const workspaceFileReferenceUseCases = { + [fileOperations.readContent.id]: defineWorkspaceFileReferenceUseCase(fileOperations.readContent), + [fileOperations.create.id]: defineWorkspaceFileReferenceUseCase(fileOperations.create), + [fileOperations.rename.id]: defineWorkspaceFileReferenceUseCase(fileOperations.rename), + [fileOperations.updateContent.id]: defineWorkspaceFileReferenceUseCase( + fileOperations.updateContent + ), + [fileOperations.move.id]: defineWorkspaceFileReferenceUseCase(fileOperations.move), + [fileOperations.delete.id]: defineWorkspaceFileReferenceUseCase(fileOperations.delete), + [fileOperations.updateShare.id]: defineWorkspaceFileReferenceUseCase(fileOperations.updateShare), +} satisfies Record + +function getWorkspaceFileReferenceUseCase(operation: WorkspaceOperation) { + const operationId = operation.id as keyof typeof workspaceFileReferenceUseCases + const useCase: WorkspaceFileReferenceUseCase | undefined = + workspaceFileReferenceUseCases[operationId] + if (!useCase || useCase.operation !== operation) { + throw new Error(`No workspace file reference resolver is defined for ${operation.id}`) + } + return useCase +} + +/** Resolve one workspace-file reference under an explicit semantic operation policy. */ +export async function resolveWorkspaceFileReference({ + principal, + operation, + workspaceId, + reference, +}: ResolveWorkspaceFileReferenceInput): Promise { + const useCase = getWorkspaceFileReferenceUseCase(operation) + const result = await useCase.execute({ principal, input: { workspaceId, reference } }) + return result.file +} + +export interface ReadWorkspaceFileReferenceInput + extends Omit { + maxBytes: number +} + +const readWorkspaceFileReferenceUseCase = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.readContent, + resolveContext: ({ input }: { input: WorkspaceFileReferenceReadInput }) => + resolveWorkspaceFileReferenceContext({ input }), + async execute({ input, context }): Promise<{ file: WorkspaceFileRecord; content: Buffer }> { + return { + file: context.file, + content: await fetchWorkspaceFileBuffer(context.file, { maxBytes: input.maxBytes }), + } + }, +}) + +/** Resolve one trusted workspace-file reference and read it under the shared file policy. */ +export async function readWorkspaceFileReference({ + principal, + workspaceId, + reference, + maxBytes, +}: ReadWorkspaceFileReferenceInput): Promise<{ file: WorkspaceFileRecord; content: Buffer }> { + return readWorkspaceFileReferenceUseCase.execute({ + principal, + input: { workspaceId, reference, maxBytes }, + }) +} diff --git a/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts new file mode 100644 index 00000000000..99389ff56b8 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts @@ -0,0 +1,82 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadLifecycle: vi.fn(), + restoreStored: vi.fn(), + recordAudit: vi.fn(), + notify: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_RESTORED: 'FILE_RESTORED' }, + AuditResourceType: { FILE: 'FILE' }, + recordAudit: mocks.recordAudit, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mocks.notify })) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + loadWorkspaceFileLifecycleContext: mocks.loadLifecycle, + restoreWorkspaceFile: mocks.restoreStored, +})) + +import { restoreWorkspaceFileOperation } from '@/lib/workspace-files/application/restore-workspace-file' + +const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', + deletedAt: new Date('2026-01-01T00:00:00Z'), +} + +describe('restoreWorkspaceFileOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadLifecycle.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.restoreStored.mockResolvedValue(undefined) + mocks.notify.mockResolvedValue(undefined) + }) + + it('authorizes, restores, audits, and notifies once', async () => { + const result = await restoreWorkspaceFileOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + + expect(result).toEqual({ restored: true }) + expect(mocks.resolvePermission).toHaveBeenCalled() + expect(mocks.restoreStored).toHaveBeenCalledWith('workspace-1', 'file-1') + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + actorId: 'user-1', + metadata: expect.objectContaining({ operation: 'files.restore' }), + }) + ) + expect(mocks.notify).toHaveBeenCalledWith('workspace-1') + expect(mocks.recordAudit.mock.invocationCallOrder[0]).toBeLessThan( + mocks.notify.mock.invocationCallOrder[0] + ) + }) + + it('conceals an asserted-workspace mismatch before authorization', async () => { + await expect( + restoreWorkspaceFileOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-2' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.restoreStored).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/restore-workspace-file.ts b/apps/sim/lib/workspace-files/application/restore-workspace-file.ts new file mode 100644 index 00000000000..918fb2c746e --- /dev/null +++ b/apps/sim/lib/workspace-files/application/restore-workspace-file.ts @@ -0,0 +1,54 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { createLogger } from '@sim/logger' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + restoreWorkspaceFile, + type WorkspaceFileLifecycleContext, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveWorkspaceFileLifecycleContext } from '@/lib/workspace-files/application/workspace-file-context' + +const logger = createLogger('RestoreWorkspaceFile') + +export interface RestoreWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string +} + +export interface RestoreWorkspaceFileResult { + restored: true +} + +async function executeRestoreWorkspaceFile({ + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.restore, + RestoreWorkspaceFileInput, + WorkspaceFileLifecycleContext +>): Promise { + await restoreWorkspaceFile(context.workspaceId, context.fileId) + return { restored: true } +} + +export const restoreWorkspaceFileOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.restore, + resolveContext: ({ input }) => resolveWorkspaceFileLifecycleContext(input), + execute: executeRestoreWorkspaceFile, + projectAudit: ({ context }) => ({ + action: AuditAction.FILE_RESTORED, + resourceType: AuditResourceType.FILE, + resourceId: context.fileId, + resourceName: context.fileId, + description: `Restored workspace file ${context.fileId}`, + }), + async afterSuccess({ principal, context }) { + await notifyWorkspaceFilesChanged(context.workspaceId) + logger.info('Restored workspace file', { + workspaceId: context.workspaceId, + fileId: context.fileId, + principalKind: 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 new file mode 100644 index 00000000000..599d3c3ed09 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/share-workspace-file.ts @@ -0,0 +1,126 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + getShareForResource, + ShareValidationError, + upsertFileShare, +} from '@/lib/public-shares/share-manager' +import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' +import { + PublicFileSharingNotAllowedError, + validatePublicFileSharing, +} from '@/ee/access-control/utils/permission-check' + +const logger = createLogger('WorkspaceFileShare') + +export interface GetWorkspaceFileShareInput { + fileId: string + assertedWorkspaceId?: string +} + +export interface GetWorkspaceFileShareResult { + share: ShareRecord | null +} + +export interface UpdateWorkspaceFileShareInput { + fileId: string + assertedWorkspaceId?: string + isActive: boolean + authType?: ShareAuthType + password?: string + allowedEmails?: string[] + token?: string + noOpIfInactive?: boolean +} + +export interface UpdateWorkspaceFileShareResult { + share: ShareRecord +} + +export class WorkspaceFileShareNoopError extends Error { + constructor() { + super('Workspace file is not currently shared') + this.name = 'WorkspaceFileShareNoopError' + } +} + +export const getWorkspaceFileShare = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.readShare, + resolveContext: ({ input }: { input: GetWorkspaceFileShareInput }) => + resolveActiveWorkspaceFileContext(input), + async execute({ context }): Promise { + const share = await getShareForResource('file', context.fileId) + return { share } + }, +}) + +export const updateWorkspaceFileShare = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.updateShare, + async resolveContext({ input }: { input: UpdateWorkspaceFileShareInput }) { + const canonical = await resolveActiveWorkspaceFileContext(input) + const file = await getWorkspaceFile(canonical.workspaceId, canonical.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { ...canonical, file } + }, + async execute({ principal, input, context }): Promise { + const subjectUserId = resolvePrincipalAttribution(principal).attributedUserId + + const existingShare = await getShareForResource('file', context.fileId) + if (input.noOpIfInactive && !input.isActive && !existingShare?.isActive) { + throw new WorkspaceFileShareNoopError() + } + + if (input.isActive) { + const effectiveAuthType = input.authType ?? existingShare?.authType ?? 'public' + try { + await validatePublicFileSharing(subjectUserId, context.workspaceId, effectiveAuthType) + } catch (error) { + if (error instanceof PublicFileSharingNotAllowedError) + throw new OrchestrationError('forbidden', error.message) + throw error + } + } + + let share: ShareRecord + try { + share = await upsertFileShare({ + workspaceId: context.workspaceId, + fileId: context.fileId, + userId: subjectUserId, + isActive: input.isActive, + authType: input.authType, + password: input.password, + allowedEmails: input.allowedEmails, + token: input.token, + }) + } catch (error) { + if (error instanceof ShareValidationError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + if (!share) throw new Error('Updating workspace file share returned no share') + + logger.info(`${input.isActive ? 'Enabled' : 'Disabled'} share for workspace file`, { + workspaceId: context.workspaceId, + fileId: context.fileId, + principalKind: principal.kind, + }) + return { share } + }, + projectAudit: ({ input, context }) => ({ + action: input.isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, + resourceType: AuditResourceType.FILE, + resourceId: context.fileId, + resourceName: context.file.name, + description: `${input.isActive ? 'Enabled' : 'Disabled'} public share for "${context.file.name}"`, + }), +}) diff --git a/apps/sim/lib/workspace-files/application/style-workspace-file.ts b/apps/sim/lib/workspace-files/application/style-workspace-file.ts new file mode 100644 index 00000000000..73b3d99a803 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/style-workspace-file.ts @@ -0,0 +1,57 @@ +import type { Principal } from '@sim/auth/principal' +import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' + +const MAX_STYLE_FILE_BYTES = 100 * 1024 * 1024 + +export class StyleExtractionUnsupportedError extends Error { + constructor(message: string) { + super(message) + this.name = 'StyleExtractionUnsupportedError' + } +} + +export interface StyleWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string +} + +async function executeStyleWorkspaceFile({ + principal, + input, +}: { + principal: Principal + input: StyleWorkspaceFileInput + request?: OrchestrationRequestContext +}) { + const { file } = await readWorkspaceFileMetadata.execute({ principal, input }) + const rawExt = file.name.split('.').pop()?.toLowerCase() + if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') { + throw new StyleExtractionUnsupportedError( + 'Style extraction supports .docx, .pptx, and .pdf files' + ) + } + if (file.size > MAX_STYLE_FILE_BYTES) { + throw new StyleExtractionUnsupportedError( + 'File is too large for style extraction (limit: 100 MB)' + ) + } + const { content } = await readWorkspaceFileContent.execute({ + principal, + input: { ...input, maxBytes: MAX_STYLE_FILE_BYTES }, + }) + const summary = await extractDocumentStyle(content, rawExt) + if (!summary) { + throw new StyleExtractionUnsupportedError( + 'Could not extract style — file may be encrypted, corrupt, image-only, or contain no parseable style information' + ) + } + return summary +} + +export const styleWorkspaceFile = { + operation: readWorkspaceFileContent.operation, + execute: executeStyleWorkspaceFile, +} as const diff --git a/apps/sim/lib/workspace-files/application/update-workspace-file-content.ts b/apps/sim/lib/workspace-files/application/update-workspace-file-content.ts new file mode 100644 index 00000000000..56d096c7a2a --- /dev/null +++ b/apps/sim/lib/workspace-files/application/update-workspace-file-content.ts @@ -0,0 +1,152 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + ContentVersionConflictError, + updateWorkspaceFileContent as updateStoredWorkspaceFileContent, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { + EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' +import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration' + +const logger = createLogger('UpdateWorkspaceFileContent') + +export interface UpdateWorkspaceFileContentInput { + fileId: string + assertedWorkspaceId?: string + content: string + encoding: 'utf-8' | 'base64' + contentType?: string + provenanceMode?: 'replace_empty' | 'preserve' + secretProvenance?: WorkspaceFileSecretProvenance + syncLiveDoc?: boolean + expectedUpdatedAt?: Date +} + +export interface UpdateWorkspaceFileContentResult { + file: WorkspaceFileRecord +} + +export interface UpdateWorkspaceFileContentBufferInput + extends Omit { + content: Buffer +} + +async function updateAuthorizedWorkspaceFileContent({ + principal, + input, + content, + canonical, +}: { + principal: Principal + input: Omit + content: Buffer + canonical: Awaited> +}): Promise { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: canonical.billedAccountUserId, + }) + let file: WorkspaceFileRecord + try { + file = await updateStoredWorkspaceFileContent( + canonical.workspaceId, + canonical.fileId, + attribution.attributedUserId, + content, + input.contentType, + { + ...(input.expectedUpdatedAt ? { expectedUpdatedAt: input.expectedUpdatedAt } : {}), + syncLiveDoc: input.syncLiveDoc, + secretProvenancePolicy: { + ...(input.provenanceMode === 'preserve' + ? { mode: 'preserve' as const } + : { + mode: 'replace' as const, + provenance: input.secretProvenance ?? EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, + }), + }, + } + ) + } catch (error) { + if (error instanceof ContentVersionConflictError) { + throw new OrchestrationError('conflict', error.message) + } + throw error + } + + logger.info('Updated workspace file content', { + workspaceId: canonical.workspaceId, + fileId: canonical.fileId, + size: content.length, + principalKind: principal.kind, + }) + return { file } +} + +function projectUpdateWorkspaceFileContentAudit(result: UpdateWorkspaceFileContentResult) { + return { + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Updated content of file "${result.file.name}"`, + metadata: { contentSize: result.file.size }, + } as const +} + +const admitUpdateWorkspaceFileContentUseCase = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.updateContent, + resolveContext: ({ input }: { input: { fileId: string } }) => + resolveActiveWorkspaceFileContext(input), + async execute() {}, +}) + +export async function admitUpdateWorkspaceFileContent( + principal: Principal, + fileId: string +): Promise { + await admitUpdateWorkspaceFileContentUseCase.execute({ principal, input: { fileId } }) +} + +export const updateWorkspaceFileContent = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.updateContent, + resolveContext: ({ input }: { input: UpdateWorkspaceFileContentInput }) => + resolveActiveWorkspaceFileContext(input), + async execute({ principal, input, context }): Promise { + const content = Buffer.from(input.content, input.encoding === 'base64' ? 'base64' : 'utf-8') + if (content.length > MAX_WORKSPACE_FILE_CONTENT_BYTES) { + throw new OrchestrationError( + 'payload_too_large', + `File size exceeds ${MAX_WORKSPACE_FILE_CONTENT_BYTES / 1024 / 1024}MB limit` + ) + } + return updateAuthorizedWorkspaceFileContent({ + principal, + input, + content, + canonical: context, + }) + }, + projectAudit: ({ result }) => projectUpdateWorkspaceFileContentAudit(result), +}) + +export const updateWorkspaceFileContentFromBuffer = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.updateContent, + resolveContext: ({ input }: { input: UpdateWorkspaceFileContentBufferInput }) => + resolveActiveWorkspaceFileContext(input), + execute: ({ principal, input, context }) => + updateAuthorizedWorkspaceFileContent({ + principal, + input, + content: input.content, + canonical: context, + }), + projectAudit: ({ result }) => projectUpdateWorkspaceFileContentAudit(result), +}) diff --git a/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts b/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts new file mode 100644 index 00000000000..5800c0b42c1 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadContext: vi.fn(), + updateDimensions: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + loadActiveWorkspaceFileContext: mocks.loadContext, + updateWorkspaceFileDimensions: mocks.updateDimensions, +})) + +import { updateWorkspaceFileDimensionsOperation } from '@/lib/workspace-files/application/update-workspace-file-dimensions' + +describe('updateWorkspaceFileDimensionsOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue({ + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', + }) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.updateDimensions.mockResolvedValue(false) + }) + + it('preserves a stale-key write as a successful false result', async () => { + const result = await updateWorkspaceFileDimensionsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + fileId: 'file-1', + assertedWorkspaceId: 'workspace-1', + key: 'workspace/workspace-1/current-key', + width: 800, + height: 600, + }, + }) + + expect(result).toEqual({ success: false }) + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: undefined }) + expect(mocks.updateDimensions).toHaveBeenCalledWith('workspace-1', 'file-1', { + key: 'workspace/workspace-1/current-key', + width: 800, + height: 600, + }) + }) +}) diff --git a/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.ts b/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.ts new file mode 100644 index 00000000000..aff7c3d22e1 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.ts @@ -0,0 +1,42 @@ +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { + type ActiveWorkspaceFileContext, + updateWorkspaceFileDimensions, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' + +export interface UpdateWorkspaceFileDimensionsInput { + fileId: string + assertedWorkspaceId?: string + key: string + width: number + height: number +} + +export interface UpdateWorkspaceFileDimensionsResult { + success: boolean +} + +async function executeUpdateWorkspaceFileDimensions({ + input, + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.updateMetadata, + UpdateWorkspaceFileDimensionsInput, + ActiveWorkspaceFileContext +>): Promise { + const success = await updateWorkspaceFileDimensions(context.workspaceId, context.fileId, { + key: input.key, + width: input.width, + height: input.height, + }) + return { success } +} + +export const updateWorkspaceFileDimensionsOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.updateMetadata, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeUpdateWorkspaceFileDimensions, +}) diff --git a/apps/sim/lib/workspace-files/application/workspace-file-context.ts b/apps/sim/lib/workspace-files/application/workspace-file-context.ts new file mode 100644 index 00000000000..4dba54c77e3 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/workspace-file-context.ts @@ -0,0 +1,41 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ActiveWorkspaceFileContext, + loadActiveWorkspaceFileContext, + loadWorkspaceFileLifecycleContext, + type WorkspaceFileLifecycleContext, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +export interface WorkspaceFileContextInput { + fileId: string + assertedWorkspaceId?: string + includeDeleted?: boolean +} + +export async function resolveActiveWorkspaceFileContext( + input: WorkspaceFileContextInput +): Promise { + const canonical = await loadActiveWorkspaceFileContext(input.fileId, { + includeDeleted: input.includeDeleted, + }) + if ( + !canonical || + (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== canonical.workspaceId) + ) { + throw new OrchestrationError('not_found', 'File not found') + } + return canonical +} + +export async function resolveWorkspaceFileLifecycleContext( + input: WorkspaceFileContextInput +): Promise { + const canonical = await loadWorkspaceFileLifecycleContext(input.fileId) + if ( + !canonical || + (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== canonical.workspaceId) + ) { + throw new OrchestrationError('not_found', 'File not found') + } + return canonical +} diff --git a/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts b/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts new file mode 100644 index 00000000000..0608deaedbe --- /dev/null +++ b/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts @@ -0,0 +1,174 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + events, + mockLoadContext, + mockResolvePermission, + mockAssertItems, + mockArchive, + mockCreate, + mockRelocate, + mockRestore, + mockAudit, + mockNotify, +} = vi.hoisted(() => ({ + events: [] as string[], + mockLoadContext: vi.fn(), + mockResolvePermission: vi.fn(), + mockAssertItems: vi.fn(), + mockArchive: vi.fn(), + mockCreate: vi.fn(), + mockRelocate: vi.fn(), + mockRestore: vi.fn(), + mockAudit: vi.fn(), + mockNotify: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + assertWorkspaceFileItemsBelongToWorkspace: mockAssertItems, + bulkArchiveWorkspaceFileItems: mockArchive, + createWorkspaceFileFolderAtPath: mockCreate, + loadWorkspaceFileOperationContext: mockLoadContext, + relocateWorkspaceFileFolderByPath: mockRelocate, + restoreWorkspaceFileFolder: mockRestore, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mockResolvePermission, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { + FOLDER_CREATED: 'folder.created', + FOLDER_DELETED: 'folder.deleted', + FOLDER_MOVED: 'folder.moved', + FOLDER_RESTORED: 'folder.restored', + }, + AuditResourceType: { FOLDER: 'folder' }, + recordAudit: mockAudit, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mockNotify })) + +import { + createWorkspaceFileFolderOperation, + deleteWorkspaceFileFolderOperation, + restoreWorkspaceFileFolderOperation, + updateWorkspaceFileFolderOperation, +} from '@/lib/workspace-files/application/workspace-file-folders' + +const folder = { + id: 'folder-1', + workspaceId: 'ws-1', + userId: 'owner-1', + name: 'Reports', + parentId: null, + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} + +describe('workspace file folder operations', () => { + beforeEach(() => { + vi.clearAllMocks() + events.length = 0 + mockLoadContext.mockImplementation(async () => { + events.push('resolve') + return { + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', + } + }) + mockResolvePermission.mockImplementation(async () => { + events.push('authorize') + return 'write' + }) + mockAssertItems.mockResolvedValue(undefined) + mockArchive.mockResolvedValue({ files: 0, folders: 1, fileIds: [], folderIds: ['folder-1'] }) + }) + + it('creates a canonical path folder through the manager primitive', async () => { + mockCreate.mockImplementation(async () => { + events.push('execute') + return { folder, path: '/Reports' } + }) + const result = await createWorkspaceFileFolderOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', path: '/Reports' }, + }) + + expect(result.folder.path).toBe('/Reports') + expect(events).toEqual(['resolve', 'authorize', 'execute']) + expect(mockCreate).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports', + }) + expect(mockAudit).toHaveBeenCalledOnce() + expect(mockNotify).toHaveBeenCalledOnce() + }) + + it('relocates a canonical path folder without invoking legacy orchestration', async () => { + mockRelocate.mockResolvedValue({ folder, path: '/Archive/Reports' }) + const result = await updateWorkspaceFileFolderOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'ws-1', + path: '/Reports', + destinationPath: '/Archive/Reports', + }, + }) + + expect(result.folder.path).toBe('/Archive/Reports') + expect(mockRelocate).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + path: '/Reports', + destinationPath: '/Archive/Reports', + }) + expect(mockAudit).toHaveBeenCalledOnce() + expect(mockNotify).toHaveBeenCalledOnce() + }) + + it('does not audit or notify when a folder archive updates no rows', async () => { + mockArchive.mockResolvedValue({ files: 0, folders: 0, fileIds: [], folderIds: [] }) + + await expect( + deleteWorkspaceFileFolderOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', folderId: 'folder-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mockAudit).not.toHaveBeenCalled() + expect(mockNotify).not.toHaveBeenCalled() + }) + + it('does not authorize a folder restore as though its ID were a delegated file scope', async () => { + const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { fileId: 'folder-1' }, + } + await expect( + restoreWorkspaceFileFolderOperation.execute({ + principal, + input: { workspaceId: 'ws-1', folderId: 'folder-1' }, + }) + ).rejects.toThrow('Delegated workspace access is no longer valid') + + expect(mockLoadContext).toHaveBeenCalledWith('ws-1') + expect(mockResolvePermission).not.toHaveBeenCalled() + expect(mockRestore).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/workspace-file-folders.ts b/apps/sim/lib/workspace-files/application/workspace-file-folders.ts new file mode 100644 index 00000000000..c8fb8f9d2ff --- /dev/null +++ b/apps/sim/lib/workspace-files/application/workspace-file-folders.ts @@ -0,0 +1,299 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + assertWorkspaceFileItemsBelongToWorkspace, + bulkArchiveWorkspaceFileItems, + createWorkspaceFileFolder, + createWorkspaceFileFolderAtPath, + deleteWorkspaceFileFolderByPath, + listWorkspaceFileFolders, + loadWorkspaceFileOperationContext, + relocateWorkspaceFileFolderByPath, + restoreWorkspaceFileFolder, + updateWorkspaceFileFolder, + type WorkspaceFileArchiveResult, + type WorkspaceFileFolderRecord, +} from '@/lib/uploads/contexts/workspace' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +const logger = createLogger('WorkspaceFileFolders') + +export interface ListWorkspaceFileFoldersInput { + workspaceId: string + scope?: 'active' | 'archived' | 'all' + parentPath?: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export interface ListWorkspaceFileFoldersResult { + folders: WorkspaceFileFolderRecord[] +} + +export interface CreateWorkspaceFileFolderInput { + workspaceId: string + name?: string + parentId?: string | null + path?: string +} + +export interface CreateWorkspaceFileFolderResult { + folder: WorkspaceFileFolderRecord +} + +export interface UpdateWorkspaceFileFolderInput { + workspaceId: string + folderId?: string + name?: string + parentId?: string | null + sortOrder?: number + path?: string + destinationPath?: string +} + +export interface UpdateWorkspaceFileFolderResult { + folder: WorkspaceFileFolderRecord +} + +export interface DeleteWorkspaceFileFolderInput { + workspaceId: string + folderId?: string + path?: string + recursive?: boolean +} + +export interface DeleteWorkspaceFileFolderResult { + deletedItems: WorkspaceFileArchiveResult + path?: string +} + +export interface RestoreWorkspaceFileFolderInput { + workspaceId: string + folderId: string +} + +export interface RestoreWorkspaceFileFolderResult { + folder: WorkspaceFileFolderRecord + restoredItems: WorkspaceFileArchiveResult +} + +async function resolveFolderContext({ input }: { input: { workspaceId: string } }) { + const context = await loadWorkspaceFileOperationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} + +type FolderOperationContext = Awaited> + +async function executeListWorkspaceFileFolders(args: { + input: ListWorkspaceFileFoldersInput + context: FolderOperationContext +}): Promise { + let folders = await listWorkspaceFileFolders(args.context.workspaceId, { + scope: args.input.scope, + }) + if (args.input.parentPath !== undefined) { + const parentPath = args.input.parentPath === '/' ? '' : args.input.parentPath.replace(/^\//, '') + folders = folders.filter((folder) => { + const parent = folder.path.includes('/') + ? folder.path.slice(0, folder.path.lastIndexOf('/')) + : '' + return parent === parentPath + }) + } + if (args.input.search) { + const search = args.input.search.toLowerCase() + folders = folders.filter((folder) => folder.name.toLowerCase().includes(search)) + } + const sortBy = args.input.sortBy ?? 'name' + const sortOrder = args.input.sortOrder ?? 'asc' + folders.sort((left, right) => { + const leftValue = left[sortBy] + const rightValue = right[sortBy] + const comparison = leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0 + return sortOrder === 'asc' ? comparison : -comparison + }) + return { folders } +} + +async function executeCreateWorkspaceFileFolder(args: { + principal: Parameters[0] + input: CreateWorkspaceFileFolderInput + context: FolderOperationContext +}): Promise { + const attribution = resolvePrincipalAttribution(args.principal, { + workspaceBillingOwnerUserId: args.context.billedAccountUserId, + }) + const result = + args.input.path !== undefined + ? await createWorkspaceFileFolderAtPath({ + workspaceId: args.context.workspaceId, + userId: attribution.attributedUserId, + path: args.input.path, + }) + : { + folder: await createWorkspaceFileFolder({ + workspaceId: args.context.workspaceId, + userId: attribution.attributedUserId, + name: args.input.name ?? '', + parentId: args.input.parentId, + }), + } + const folder = 'path' in result ? { ...result.folder, path: result.path } : result.folder + return { folder } +} + +async function executeUpdateWorkspaceFileFolder(args: { + input: UpdateWorkspaceFileFolderInput + context: FolderOperationContext +}): Promise { + let folder: WorkspaceFileFolderRecord + if (args.input.path !== undefined || args.input.destinationPath !== undefined) { + if (!args.input.path || !args.input.destinationPath) { + throw new OrchestrationError('validation', 'path and destinationPath are required') + } + const result = await relocateWorkspaceFileFolderByPath({ + workspaceId: args.context.workspaceId, + path: args.input.path, + destinationPath: args.input.destinationPath, + }) + folder = { ...result.folder, path: result.path } + } else { + if (!args.input.folderId) throw new OrchestrationError('validation', 'Folder ID is required') + folder = await updateWorkspaceFileFolder({ + workspaceId: args.context.workspaceId, + folderId: args.input.folderId, + name: args.input.name, + parentId: args.input.parentId, + sortOrder: args.input.sortOrder, + }) + } + logger.info('Updated workspace file folder', { + workspaceId: args.context.workspaceId, + folderId: folder.id, + }) + return { folder } +} + +async function executeDeleteWorkspaceFileFolder(args: { + input: DeleteWorkspaceFileFolderInput + context: FolderOperationContext +}): Promise { + let deletedItems: WorkspaceFileArchiveResult + if (args.input.path !== undefined) { + deletedItems = await deleteWorkspaceFileFolderByPath({ + workspaceId: args.context.workspaceId, + path: args.input.path, + recursive: args.input.recursive ?? false, + }) + } else { + if (!args.input.folderId) throw new OrchestrationError('validation', 'Folder ID is required') + await assertWorkspaceFileItemsBelongToWorkspace({ + workspaceId: args.context.workspaceId, + folderIds: [args.input.folderId], + }) + const archived = await bulkArchiveWorkspaceFileItems({ + workspaceId: args.context.workspaceId, + folderIds: [args.input.folderId], + }) + deletedItems = { files: archived.fileIds.length, folders: archived.folderIds.length } + } + if (deletedItems.files === 0 && deletedItems.folders === 0) { + throw new OrchestrationError('not_found', 'Folder not found') + } + return { deletedItems, path: args.input.path } +} + +async function executeRestoreWorkspaceFileFolder(args: { + input: RestoreWorkspaceFileFolderInput + context: FolderOperationContext +}): Promise { + return restoreWorkspaceFileFolder(args.context.workspaceId, args.input.folderId) +} + +export const listWorkspaceFileFoldersOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.listFolders, + resolveContext: (args: { input: ListWorkspaceFileFoldersInput }) => resolveFolderContext(args), + execute: executeListWorkspaceFileFolders, +}) + +export const createWorkspaceFileFolderOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.createFolder, + resolveContext: (args: { input: CreateWorkspaceFileFolderInput }) => resolveFolderContext(args), + execute: executeCreateWorkspaceFileFolder, + projectAudit({ result }) { + return { + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Created file folder "${result.folder.name}"`, + } + }, + async afterSuccess({ context }) { + await notifyWorkspaceFilesChanged(context.workspaceId) + }, +}) + +export const updateWorkspaceFileFolderOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.updateFolder, + resolveContext: (args: { input: UpdateWorkspaceFileFolderInput }) => resolveFolderContext(args), + execute: executeUpdateWorkspaceFileFolder, + projectAudit({ input, result }) { + return { + action: input.path !== undefined ? AuditAction.FOLDER_MOVED : AuditAction.FOLDER_UPDATED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Updated file folder "${result.folder.name}"`, + } + }, + async afterSuccess({ context }) { + await notifyWorkspaceFilesChanged(context.workspaceId) + }, +}) + +export const deleteWorkspaceFileFolderOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.deleteFolder, + resolveContext: (args: { input: DeleteWorkspaceFileFolderInput }) => resolveFolderContext(args), + execute: executeDeleteWorkspaceFileFolder, + projectAudit({ input, result }) { + return { + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: input.folderId, + description: 'Deleted file folder', + metadata: { + path: input.path, + deletedItems: result.deletedItems, + }, + } + }, + async afterSuccess({ context }) { + await notifyWorkspaceFilesChanged(context.workspaceId) + }, +}) + +export const restoreWorkspaceFileFolderOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.restoreFolder, + resolveContext: (args: { input: RestoreWorkspaceFileFolderInput }) => resolveFolderContext(args), + execute: executeRestoreWorkspaceFileFolder, + projectAudit({ input, result }) { + return { + action: AuditAction.FOLDER_RESTORED, + resourceType: AuditResourceType.FOLDER, + resourceId: input.folderId, + resourceName: result.folder.name, + description: `Restored file folder "${result.folder.name}"`, + metadata: { restoredItems: result.restoredItems }, + } + }, + async afterSuccess({ context }) { + await notifyWorkspaceFilesChanged(context.workspaceId) + }, +}) diff --git a/apps/sim/lib/workspace-files/application/workspace-operation-context.ts b/apps/sim/lib/workspace-files/application/workspace-operation-context.ts new file mode 100644 index 00000000000..6796c9c70ee --- /dev/null +++ b/apps/sim/lib/workspace-files/application/workspace-operation-context.ts @@ -0,0 +1,31 @@ +import type { Principal } from '@sim/auth/principal' +import type { WorkspaceOperation } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + loadWorkspaceFileOperationContext, + type WorkspaceFileOperationContext, +} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' + +export interface AuthorizedWorkspaceOperationContext { + context: WorkspaceFileOperationContext +} + +export async function authorizeWorkspaceFileOperation( + principal: Principal, + operation: WorkspaceOperation, + workspaceId: string, + fileId?: string +): Promise { + const context = await loadWorkspaceFileOperationContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + + await authorizeWorkspaceFileAccess(principal, operation, { + workspaceId: context.workspaceId, + workspaceOrganizationId: context.workspaceOrganizationId, + allowPersonalApiKeys: context.allowPersonalApiKeys, + fileId, + }) + + return { context } +} diff --git a/apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts b/apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts new file mode 100644 index 00000000000..51601a3a33c --- /dev/null +++ b/apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts @@ -0,0 +1,227 @@ +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { loadActiveWorkspaceContext } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { + admitCreateWorkspaceFile, + createWorkspaceFile, + createWorkspaceFileFromBuffer, +} from '@/lib/workspace-files/application/create-workspace-file' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' +import { + updateWorkspaceFileContent, + updateWorkspaceFileContentFromBuffer, +} from '@/lib/workspace-files/application/update-workspace-file-content' +import { parseWorkspaceFileCreatePath } from '@/lib/workspace-files/workspace-file-path' + +export interface WriteWorkspaceFileByPathInput { + workspaceId: string + path: string + content: string + encoding: 'utf-8' | 'base64' + contentType: string + mode: 'create' | 'overwrite' + exactName?: boolean + syncLiveDoc?: boolean + secretProvenance?: WorkspaceFileSecretProvenance +} + +export interface WriteWorkspaceFileBufferByPathInput + extends Omit { + content: Buffer +} + +export interface WriteWorkspaceFileByPathResult { + id: string + name: string + size: number + contentType: string + downloadUrl?: string + vfsPath: string + mode: WriteWorkspaceFileByPathInput['mode'] +} + +function toResult( + file: { + id: string + name: string + size: number + type: string + url?: string + folderPath?: string | null + }, + mode: WriteWorkspaceFileByPathInput['mode'] +): WriteWorkspaceFileByPathResult { + const folderPath = file.folderPath ?? '' + const encodedFolderPath = folderPath + ? folderPath + .split('/') + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join('/') + : '' + return { + id: file.id, + name: file.name, + size: file.size, + contentType: file.type, + downloadUrl: file.url, + vfsPath: `files/${encodedFolderPath ? `${encodedFolderPath}/` : ''}${encodeURIComponent(file.name)}`, + mode, + } +} + +async function executeCreate({ + principal, + input, +}: { + principal: Principal + input: WriteWorkspaceFileByPathInput +}): Promise { + const parsed = parseWorkspaceFileCreatePath(input.path) + await admitCreateWorkspaceFile(principal, input.workspaceId) + + const folderUserId = await resolveFolderAttributionUserId(principal, input.workspaceId) + + const folderId = await ensureWorkspaceFileFolderPath({ + workspaceId: input.workspaceId, + userId: folderUserId, + pathSegments: parsed.folderSegments, + }) + const result = await createWorkspaceFile.execute({ + principal, + input: { + workspaceId: input.workspaceId, + name: parsed.fileName, + contentType: input.contentType, + content: input.content, + encoding: input.encoding, + folderId, + exactName: input.exactName ?? true, + secretProvenance: input.secretProvenance, + }, + }) + return toResult(result.file, 'create') +} + +async function executeOverwrite({ + principal, + input, +}: { + principal: Principal + input: WriteWorkspaceFileByPathInput +}): Promise { + const existing = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.updateContent, + workspaceId: input.workspaceId, + reference: input.path, + }) + const result = await updateWorkspaceFileContent.execute({ + principal, + input: { + fileId: existing.id, + assertedWorkspaceId: input.workspaceId, + content: input.content, + encoding: input.encoding, + contentType: input.contentType, + provenanceMode: 'replace_empty', + syncLiveDoc: input.syncLiveDoc, + secretProvenance: input.secretProvenance, + }, + }) + return toResult(result.file, 'overwrite') +} + +async function executeCreateBuffer({ + principal, + input, +}: { + principal: Principal + input: WriteWorkspaceFileBufferByPathInput +}): Promise { + const parsed = parseWorkspaceFileCreatePath(input.path) + await admitCreateWorkspaceFile(principal, input.workspaceId) + const folderUserId = await resolveFolderAttributionUserId(principal, input.workspaceId) + const folderId = await ensureWorkspaceFileFolderPath({ + workspaceId: input.workspaceId, + userId: folderUserId, + pathSegments: parsed.folderSegments, + }) + const result = await createWorkspaceFileFromBuffer.execute({ + principal, + input: { + workspaceId: input.workspaceId, + name: parsed.fileName, + contentType: input.contentType, + content: input.content, + folderId, + exactName: input.exactName ?? true, + secretProvenance: input.secretProvenance, + }, + }) + return toResult(result.file, 'create') +} + +async function executeOverwriteBuffer({ + principal, + input, +}: { + principal: Principal + input: WriteWorkspaceFileBufferByPathInput +}): Promise { + const existing = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.updateContent, + workspaceId: input.workspaceId, + reference: input.path, + }) + const result = await updateWorkspaceFileContentFromBuffer.execute({ + principal, + input: { + fileId: existing.id, + assertedWorkspaceId: input.workspaceId, + content: input.content, + contentType: input.contentType, + provenanceMode: 'replace_empty', + syncLiveDoc: input.syncLiveDoc, + secretProvenance: input.secretProvenance, + }, + }) + return toResult(result.file, 'overwrite') +} + +async function resolveFolderAttributionUserId( + principal: Principal, + workspaceId: string +): Promise { + let workspaceBillingOwnerUserId: string | undefined + if (principal.kind === 'workspace_api_key') { + const workspace = await loadActiveWorkspaceContext(workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + workspaceBillingOwnerUserId = workspace.billedAccountUserId + } + return resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId }).attributedUserId +} + +export const createWorkspaceFileByPath = { + operation: fileOperations.create, + execute: executeCreate, +} as const + +export const updateWorkspaceFileContentByPath = { + operation: fileOperations.updateContent, + execute: executeOverwrite, +} as const + +export const createWorkspaceFileBufferByPath = { + operation: fileOperations.create, + execute: executeCreateBuffer, +} as const + +export const updateWorkspaceFileContentBufferByPath = { + operation: fileOperations.updateContent, + execute: executeOverwriteBuffer, +} as const diff --git a/apps/sim/lib/workspace-files/limits.ts b/apps/sim/lib/workspace-files/limits.ts new file mode 100644 index 00000000000..5f1f72ff630 --- /dev/null +++ b/apps/sim/lib/workspace-files/limits.ts @@ -0,0 +1,2 @@ +export const MAX_WORKSPACE_FILE_BULK_REQUEST_IDS = 1_000 +export const MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS = 5_000 diff --git a/apps/sim/lib/workspace-files/workspace-file-path.ts b/apps/sim/lib/workspace-files/workspace-file-path.ts new file mode 100644 index 00000000000..f50763cc9fb --- /dev/null +++ b/apps/sim/lib/workspace-files/workspace-file-path.ts @@ -0,0 +1,29 @@ +import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { normalizeWorkspaceFileItemName } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' + +export function parseWorkspaceFileCreatePath(path: string): { + folderSegments: string[] + fileName: string + vfsPath: string +} { + const trimmed = path.trim().replace(/^\/+/, '') + if (!trimmed.startsWith('files/')) { + throw new Error('Workspace file paths must start with "files/"') + } + + const decoded = decodeVfsPathSegments(trimmed.slice('files/'.length)) + if (decoded.length === 0) { + throw new Error('Workspace file path must include a file name') + } + + const fileName = normalizeWorkspaceFileItemName(decoded.at(-1) ?? '', 'File') + const folderSegments = decoded + .slice(0, -1) + .map((segment) => normalizeWorkspaceFileItemName(segment, 'Folder')) + + return { + folderSegments, + fileName, + vfsPath: canonicalWorkspaceFilePath({ folderPath: folderSegments.join('/'), name: fileName }), + } +} diff --git a/packages/auth/package.json b/packages/auth/package.json index b6b88fb8fc8..2a6bbed626b 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -10,6 +10,10 @@ "node": ">=20.0.0" }, "exports": { + "./principal": { + "types": "./src/principal.ts", + "default": "./src/principal.ts" + }, "./verify": { "types": "./src/verify.ts", "default": "./src/verify.ts" diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts new file mode 100644 index 00000000000..18a138fa9a9 --- /dev/null +++ b/packages/auth/src/principal.ts @@ -0,0 +1,132 @@ +export type Principal = + | SessionPrincipal + | PersonalApiKeyPrincipal + | WorkspaceApiKeyPrincipal + | DelegatedPrincipal + +export interface SessionPrincipal { + kind: 'session' + userId: string + sessionId: string +} + +export interface PersonalApiKeyPrincipal { + kind: 'personal_api_key' + userId: string + keyId: string +} + +export interface WorkspaceApiKeyPrincipal { + kind: 'workspace_api_key' + workspaceId: string + keyId: string +} + +export interface DelegatedPrincipal { + kind: 'delegated' + serviceId: 'copilot' | 'executor' | 'realtime' + subjectUserId: string + workspaceId: string + delegationId: string + audience: string + issuedAt: Date + expiresAt: Date + resourceScope?: { + fileId?: string + chatId?: string + executionId?: string + } +} + +export type PrincipalActor = + | { kind: 'session'; userId: string } + | { kind: 'personal_api_key'; keyId: string; userId: string } + | { kind: 'workspace_api_key'; keyId: string; workspaceId: string } + | { + kind: 'delegated' + serviceId: DelegatedPrincipal['serviceId'] + subjectUserId: string + delegationId: string + } + +export interface PrincipalAttribution { + actor: PrincipalActor + attributedUserId: string +} + +/** + * The audit actor for an authenticated operation. + * + * `actorId` is only populated when the principal represents a real user. A + * workspace API key is deliberately actor-less in the audit table: its key and + * workspace identity remain available in `actor`, while `actorName` keeps the + * row readable without pretending the billing owner performed the action. + */ +export interface PrincipalAuditAttribution { + actor: PrincipalActor + actorId: string | null + actorName?: string +} + +export interface PrincipalAttributionContext { + workspaceBillingOwnerUserId?: string +} + +export function toPrincipalActor(principal: Principal): PrincipalActor { + switch (principal.kind) { + case 'session': + return { kind: principal.kind, userId: principal.userId } + case 'personal_api_key': + return { kind: principal.kind, keyId: principal.keyId, userId: principal.userId } + case 'workspace_api_key': + return { + kind: principal.kind, + keyId: principal.keyId, + workspaceId: principal.workspaceId, + } + case 'delegated': + return { + kind: principal.kind, + serviceId: principal.serviceId, + subjectUserId: principal.subjectUserId, + delegationId: principal.delegationId, + } + } +} + +export function resolvePrincipalAuditAttribution(principal: Principal): PrincipalAuditAttribution { + const actor = toPrincipalActor(principal) + + switch (actor.kind) { + case 'session': + return { actor, actorId: actor.userId } + case 'personal_api_key': + return { actor, actorId: actor.userId } + case 'delegated': + return { actor, actorId: actor.subjectUserId } + case 'workspace_api_key': + return { actor, actorId: null, actorName: 'Workspace API key' } + } +} + +export function resolvePrincipalAttribution( + principal: Principal, + context: PrincipalAttributionContext = {} +): PrincipalAttribution { + const actor = toPrincipalActor(principal) + + switch (actor.kind) { + case 'session': + case 'personal_api_key': + return { actor, attributedUserId: actor.userId } + case 'workspace_api_key': { + const attributedUserId = context.workspaceBillingOwnerUserId + if (!attributedUserId) { + throw new Error('Workspace API key attribution requires a workspace billing owner') + } + return { actor, attributedUserId } + } + case 'delegated': + return { actor, attributedUserId: actor.subjectUserId } + } +} diff --git a/packages/platform-authz/src/workspace.ts b/packages/platform-authz/src/workspace.ts index 0e377bd1e35..63d6671aab3 100644 --- a/packages/platform-authz/src/workspace.ts +++ b/packages/platform-authz/src/workspace.ts @@ -25,9 +25,10 @@ export async function resolveEffectiveWorkspacePermission( userId: string, workspaceId: string, workspaceOrganizationId: string | null, - executor: Pick = db + executor: Pick = db, + options?: { forUpdate?: boolean } ): Promise { - const [permissionRow] = await executor + const permissionQuery = executor .select({ permissionType: permissions.permissionType }) .from(permissions) .where( @@ -37,16 +38,20 @@ export async function resolveEffectiveWorkspacePermission( eq(permissions.entityId, workspaceId) ) ) - .limit(1) + const [permissionRow] = options?.forUpdate + ? await permissionQuery.for('update').limit(1) + : await permissionQuery.limit(1) const explicit = (permissionRow?.permissionType as PermissionType | undefined) ?? null if (workspaceOrganizationId && explicit !== 'admin') { - const [memberRow] = await executor + const memberQuery = executor .select({ role: member.role }) .from(member) .where(and(eq(member.userId, userId), eq(member.organizationId, workspaceOrganizationId))) - .limit(1) + const [memberRow] = options?.forUpdate + ? await memberQuery.for('update').limit(1) + : await memberQuery.limit(1) if (isOrgAdminRole(memberRow?.role)) { return 'admin' } diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 156960f1a5a..e27939a569e 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -142,7 +142,6 @@ const RAW_JSON_BASELINE_ROUTES = new Set([ 'apps/sim/app/api/tools/file/manage/route.ts', 'apps/sim/app/api/workspaces/invitations/batch/route.ts', 'apps/sim/app/api/workspaces/[id]/route.ts', - 'apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts', 'apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts', ]) @@ -150,6 +149,10 @@ const CONTRACT_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/contracts(?:\/[^'"]*) const PUBLIC_API_ROUTE_HANDLER_IMPORT_PATTERN = /\bimport\s*\{[^}]*\bwithPublicApiRouteHandler\b[^}]*\}\s*from\s*['"]@\/app\/api\/public-api-route-handler['"]/ const PUBLIC_API_ROUTE_HANDLER_USAGE_PATTERN = /\bwithPublicApiRouteHandler\s*\(/ +const DECLARATIVE_ROUTE_BUILDER_IMPORT_PATTERN = + /\bimport\s*\{[^}]*(?:\bdefineInternalJsonRoute\b|\bdefineV2JsonRoute\b|\bdefineInternalBinaryRoute\b|\bdefineV2BinaryRoute\b)[^}]*\}\s*from\s*['"]@\/lib\/api\/server\/routes['"]/ +const DECLARATIVE_ROUTE_BUILDER_USAGE_PATTERN = + /\b(?:defineInternalJsonRoute|defineV2JsonRoute|defineInternalBinaryRoute|defineV2BinaryRoute)\s*\(/ const SERVER_VALIDATION_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/server(?:\/validation)?['"]/ const SCHEMA_PARSE_PATTERN = /\b\w+Schema\.(?:safeParse|parse)\(/ const CONTRACT_SERVER_HELPER_PATTERN = /\bparseToolRequest\(/ @@ -727,6 +730,13 @@ function hasZodUsage(relativePath: string, content: string): boolean { ) { return true } + if ( + CONTRACT_IMPORT_PATTERN.test(content) && + DECLARATIVE_ROUTE_BUILDER_IMPORT_PATTERN.test(content) && + DECLARATIVE_ROUTE_BUILDER_USAGE_PATTERN.test(content) + ) { + return true + } if ( CONTRACT_IMPORT_PATTERN.test(content) && (SCHEMA_PARSE_PATTERN.test(content) || CONTRACT_MAP_PARSE_PATTERN.test(content)) From 416f600d65a3736e5a947ea2ab8da4b6d35e9e71 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 02:18:10 -0700 Subject: [PATCH 095/159] fix(cli): restore nested knowledge document commands --- packages/sim-cli/README.md | 11 +-- .../sim-cli/src/commands/protocol/index.ts | 3 +- .../knowledge-document-upload.test.ts | 29 +++--- .../protocol/knowledge-document-upload.ts | 91 ++++++++++--------- packages/sim-cli/src/contract/commands.ts | 23 ++--- packages/sim-cli/src/contract/types.ts | 6 +- packages/sim-cli/src/runtime/build.test.ts | 41 +++------ packages/sim-cli/src/runtime/build.ts | 12 ++- packages/sim-cli/src/runtime/request.test.ts | 10 +- packages/sim-cli/src/runtime/request.ts | 5 +- 10 files changed, 113 insertions(+), 118 deletions(-) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index f502d857e6f..12d00ee0f42 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -112,8 +112,7 @@ also accepts its singular form: for example, `sim table list`, `sim file download`, and `sim workflow get` are equivalent to their plural spellings. -`knowledge` also accepts the shorter `kb` alias, and `documents` accepts -`document`. +`knowledge` also accepts the shorter `kb` alias. ```bash sim workflows ls [path] [--search ] [--limit ] @@ -168,10 +167,10 @@ sim knowledge update [--name ] [--description ] [--folder sim knowledge search --query --kb … [--search-mode vector|hybrid] -sim documents list --kb [--search ] -sim documents get --kb -sim documents upload --kb [--tag ...] -sim documents delete --kb --yes +sim knowledge documents list [--search ] +sim knowledge documents get +sim knowledge documents upload [--tag ...] +sim knowledge documents delete --yes sim billing status [--all-workspaces] sim billing logs [--period 7d] [--source sim-chat] [--limit ] [--all-workspaces] diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index 33939b7cdde..67df42a865b 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -25,9 +25,8 @@ export function attachProtocolCommands(program: Command): void { createFolder: 'createFileFolder', }) - attachKnowledgeDocumentUpload(group(program, 'documents')) - const knowledge = group(program, 'knowledge') + attachKnowledgeDocumentUpload(group(knowledge, 'documents')) attachResourceDirectoryCommands(knowledge, { kind: 'knowledge', resources: 'listKnowledgeBases', diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts index 18f6be45562..74eb2bdea35 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts @@ -62,17 +62,20 @@ function uploadSession() { } } -describe('documents upload', () => { +describe('knowledge documents upload', () => { it('owns the multipart protocol while hiding its low-level operations', () => { const root = program() const knowledge = root.commands.find((command) => command.name() === 'knowledge') - expect(knowledge?.commands.map((command) => command.name())).not.toEqual( - expect.arrayContaining(['documents', 'uploads', 'parts', 'complete']) - ) + expect(root.commands.map((command) => command.name())).not.toContain('documents') - const documents = root.commands.find((command) => command.name() === 'documents') - expect(documents?.alias()).toBe('document') + const documents = knowledge?.commands.find((command) => command.name() === 'documents') expect(documents?.commands.map((command) => command.name())).toContain('upload') + expect(documents?.commands.map((command) => command.name())).not.toEqual( + expect.arrayContaining(['uploads', 'parts', 'complete']) + ) + expect( + documents?.commands.find((command) => command.name() === 'upload')?.helpInformation() + ).toContain(' ') }) it('uploads a local document and prints the created document without transfer secrets', async () => { @@ -133,11 +136,11 @@ describe('documents upload', () => { await program().parseAsync([ 'node', 'sim', + 'kb', 'documents', 'upload', - path, - '--kb', 'kb_1', + path, '--tag', 'customer', 'priority', @@ -191,11 +194,11 @@ describe('documents upload', () => { program().parseAsync([ 'node', 'sim', + 'kb', 'documents', 'upload', - path, - '--kb', 'kb_1', + path, '--tag', '1', '2', @@ -210,13 +213,13 @@ describe('documents upload', () => { expect(mockRequest).not.toHaveBeenCalled() }) - it('requires an explicit knowledge-base scope before reading the file', async () => { + it('requires the knowledge-base argument before reading the file', async () => { const path = join(dir, 'notes.txt') writeFileSync(path, 'hello') await expect( - program().parseAsync(['node', 'sim', 'documents', 'upload', path]) - ).rejects.toThrow(/required option '--kb '/) + program().parseAsync(['node', 'sim', 'kb', 'documents', 'upload']) + ).rejects.toThrow(/missing required argument 'knowledgeBaseId'/) expect(mockRequest).not.toHaveBeenCalled() }) }) diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts index 8abf8c60b43..1a459930628 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts @@ -10,7 +10,6 @@ import { finishUploadSession } from '../../transfer/upload-session.js' import { printProtocolResult } from './result.js' interface KnowledgeDocumentUploadOptions { - kb: string name?: string tag?: string[] recipe?: string @@ -38,54 +37,62 @@ function uploadMetadata(options: KnowledgeDocumentUploadOptions): Record') + .command('upload ') .description('Upload a document to a knowledge base') - .requiredOption('--kb ', 'Knowledge base ID (required)') .option('--name ', 'Store it under a different name') .option('--tag ', 'Document tags, in tag1 through tag7 order') .option('--recipe ', 'Document processing recipe') .option('--lang ', 'Document language code') - .action(async (path: string, options: KnowledgeDocumentUploadOptions, command: Command) => { - const { client, profile } = clientFrom(command) - const workspaceId = client.requireWorkspace() - const { name, size } = await localFile(path, options.name) - const created = await client.request( - `/api/v2/knowledge/${encodeURIComponent(options.kb)}/documents/uploads`, - { - method: 'POST', - body: { - workspaceId, - name, - contentType: contentTypeFor(name), + .action( + async ( + knowledgeBaseId: string, + path: string, + options: KnowledgeDocumentUploadOptions, + command: Command + ) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const { name, size } = await localFile(path, options.name) + const created = await client.request( + `/api/v2/knowledge/${encodeURIComponent(knowledgeBaseId)}/documents/uploads`, + { + method: 'POST', + body: { + workspaceId, + name, + contentType: contentTypeFor(name), + size, + ...uploadMetadata(options), + }, + } + ) + const { session, uploadToken, transfer } = created.data + const completed = await finishUploadSession< + CompleteKnowledgeDocumentUploadResponse['data'] + >( + client, + workspaceId, + { + basePath: `/api/v2/knowledge/${encodeURIComponent( + knowledgeBaseId + )}/documents/uploads/${encodeURIComponent(session.id)}`, + uploadToken, + transfer, size, - ...uploadMetadata(options), }, - } - ) - const { session, uploadToken, transfer } = created.data - const completed = await finishUploadSession( - client, - workspaceId, - { - basePath: `/api/v2/knowledge/${encodeURIComponent( - options.kb - )}/documents/uploads/${encodeURIComponent(session.id)}`, - uploadToken, - transfer, - size, - }, - path - ) + path + ) - if (!completed.document) { - throw new Error(`Knowledge upload ${session.id} completed without a document`) + if (!completed.document) { + throw new Error(`Knowledge upload ${session.id} completed without a document`) + } + printProtocolResult(profile.output, { + id: completed.document.id, + knowledgeBaseId: completed.document.knowledgeBaseId, + name: completed.document.filename, + size: completed.document.fileSize, + status: completed.document.processingStatus, + }) } - printProtocolResult(profile.output, { - id: completed.document.id, - knowledgeBaseId: completed.document.knowledgeBaseId, - name: completed.document.filename, - size: completed.document.fileSize, - status: completed.document.processingStatus, - }) - }) + ) } diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 0ac23315923..fab31bd615b 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -18,13 +18,7 @@ const FOLDER_DELETE_FLAGS = { path: FOLDER_PATH_INPUT, recursive: { boolean: true, describe: 'Delete the folder and its descendants' }, } as const -const KNOWLEDGE_DOCUMENT_SCOPE = { - id: { - name: 'kb', - placeholder: 'knowledgeBaseId', - describe: 'Knowledge base ID', - }, -} as const +const KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS = { id: 'knowledgeBaseId' } as const const WORKFLOW_RUN_SCOPE = { id: { name: 'workflow', @@ -57,7 +51,7 @@ function moveResource(command: string, resource: string): CommandVariantSpec { * * Derived by default: * listTables → sim tables list - * getKnowledgeDocument → sim documents get --kb + * getKnowledgeDocument → sim knowledge documents get * upsertTableRow → sim tables upsert */ export const CLI_CONTRACT: CliContract = { @@ -138,8 +132,7 @@ export const CLI_CONTRACT: CliContract = { }, deleteKnowledgeBase: { confirm: 'This deletes the knowledge base and every document in it.' }, deleteKnowledgeDocument: { - command: 'documents delete', - pathFlags: KNOWLEDGE_DOCUMENT_SCOPE, + pathArgumentNames: KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS, confirm: 'This deletes the document and its embeddings.', }, deleteFile: { confirm: 'This archives the file.' }, @@ -346,13 +339,9 @@ export const CLI_CONTRACT: CliContract = { { header: 'model', path: 'embeddingModel' }, ], }, - getKnowledgeDocument: { - command: 'documents get', - pathFlags: KNOWLEDGE_DOCUMENT_SCOPE, - }, + getKnowledgeDocument: { pathArgumentNames: KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS }, listKnowledgeDocuments: { - command: 'documents list', - pathFlags: KNOWLEDGE_DOCUMENT_SCOPE, + pathArgumentNames: KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS, columns: [ { header: 'id' }, { header: 'filename' }, @@ -787,7 +776,7 @@ export const CLI_CONTRACT: CliContract = { }, // ─── Not a terminal-shaped operation ────────────────────────────────────── - // Multipart upload; `sim documents upload --kb ` needs its + // Multipart upload; `sim knowledge documents upload ` needs its // own file-reading command rather than a generated flag surface. uploadKnowledgeDocument: { hidden: true }, createKnowledgeDocumentUpload: { hidden: true }, diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index d009ed630aa..d7df31c341e 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -17,7 +17,9 @@ import type { V2OperationName } from '../generated/v2-api.js' * is `z.string()` that the route splits on commas; nothing in the schema says * "list". Also friendlier aliases (`conflictTarget` → `--on`). * - `pathFlags` — when a parent path segment is command context rather than the - * resource being acted on (`documents get --kb `). + * resource being acted on (`workflows runs get --workflow `). + * - `pathArgumentNames` — when a route's generic `[id]` needs a clearer CLI + * placeholder (``). * - `profileWorkspacePath` — when `[workspaceId]` is the active profile target, * not a resource argument (`workspaces get`). * - `columns` — which of a response's fields belong in a table. Editorial. @@ -121,6 +123,8 @@ export interface CommandSpec { aliases?: readonly string[] /** Route path parameters exposed as required named options instead of positionals. */ pathFlags?: Record + /** Friendly placeholders for route path parameters that remain positional. */ + pathArgumentNames?: Record /** Fill a `[workspaceId]` route segment from the active profile instead of an argument. */ profileWorkspacePath?: boolean /** Request fields exposed as required positional arguments, in order. */ diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 7f0fe794e4f..1299292d08c 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -87,7 +87,6 @@ describe('commands parsed through commander', () => { 'audit-logs': 'audit-log', credentials: 'credential', 'custom-tools': 'custom-tool', - documents: 'document', files: 'file', knowledge: 'kb', logs: 'log', @@ -146,52 +145,38 @@ describe('commands parsed through commander', () => { expect(knowledgePath).toBe('/api/v2/knowledge') }) - it('uses top-level document commands with a named knowledge-base scope', async () => { - expect(commandAt('knowledge').commands.map((command) => command.name())).not.toContain( - 'documents' - ) + it('nests document commands under their knowledge base', async () => { + expect(program().commands.map((command) => command.name())).not.toContain('documents') - const help = commandAt('documents', 'get').helpInformation() - expect(help).toContain('') - expect(help).toMatch(/--kb .*required/s) - expect(help).not.toContain(' ') + const help = commandAt('knowledge', 'documents', 'get').helpInformation() + expect(help).toContain(' ') + expect(help).not.toContain('--kb') - const [listPath, listOptions] = await run(['documents', 'list', '--kb', 'kb_1']) + const [listPath, listOptions] = await run(['kb', 'documents', 'list', 'kb_1']) expect(listPath).toBe('/api/v2/knowledge/kb_1/documents') expect(listOptions.query).toMatchObject({ workspaceId: 'ws_local' }) - const [getPathBefore, getOptionsBefore] = await run([ - 'documents', - 'get', - '--kb', - 'kb_1', - 'doc_1', - ]) - expect(getPathBefore).toBe('/api/v2/knowledge/kb_1/documents/doc_1') - expect(getOptionsBefore.query).toEqual({ workspaceId: 'ws_local' }) - - const [getPathAfter] = await run(['document', 'get', 'doc_1', '--kb', 'kb_1']) - expect(getPathAfter).toBe('/api/v2/knowledge/kb_1/documents/doc_1') + const [getPath, getOptions] = await run(['kb', 'documents', 'get', 'kb_1', 'doc_1']) + expect(getPath).toBe('/api/v2/knowledge/kb_1/documents/doc_1') + expect(getOptions.query).toEqual({ workspaceId: 'ws_local' }) - await expect(run(['documents', 'delete', 'doc_1', '--kb', 'kb_1'])).rejects.toThrow( + await expect(run(['kb', 'documents', 'delete', 'kb_1', 'doc_1'])).rejects.toThrow( /document and its embeddings/ ) expect(mockRequest).not.toHaveBeenCalled() const [deletePath, deleteOptions] = await run([ + 'kb', 'documents', 'delete', - 'doc_1', - '--kb', 'kb_1', + 'doc_1', '--yes', ]) expect(deletePath).toBe('/api/v2/knowledge/kb_1/documents/doc_1') expect(deleteOptions.query).toEqual({ workspaceId: 'ws_local' }) - await expect(run(['documents', 'get', 'doc_1'])).rejects.toThrow( - /required option '--kb '/ - ) + await expect(run(['kb', 'documents', 'get', 'kb_1'])).rejects.toThrow(/documentId/) expect(mockRequest).not.toHaveBeenCalled() }) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index d080060f135..8acb4a84fc0 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -12,7 +12,6 @@ const GROUP_ALIASES: Readonly> = { 'audit-logs': 'audit-log', credentials: 'credential', 'custom-tools': 'custom-tool', - documents: 'document', files: 'file', knowledge: 'kb', logs: 'log', @@ -76,6 +75,15 @@ function configureOperation( } } + for (const param of Object.keys(spec.pathArgumentNames ?? {})) { + if (!operationSpec.pathParams.includes(param)) { + throw new Error(`${operation}.${param} is not a path parameter`) + } + if (spec.pathFlags?.[param]) { + throw new Error(`${operation}.${param} cannot be both a path argument and a path flag`) + } + } + if (spec.profileWorkspacePath) { if (!operationSpec.pathParams.includes(PROFILE_INJECTED_FIELD)) { throw new Error(`${operation}.profileWorkspacePath requires a workspaceId path parameter`) @@ -87,7 +95,7 @@ function configureOperation( for (const param of operationSpec.pathParams) { if (spec.pathFlags?.[param] || isProfileWorkspacePath(spec, param)) continue - command.argument(`<${param}>`) + command.argument(`<${spec.pathArgumentNames?.[param] ?? param}>`) } if (spec.allWorkspaces) { diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 816cdec5374..771f7f42708 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -72,8 +72,8 @@ describe('buildRequest', () => { ) }) - it('combines a named parent scope with a positional resource id in route order', () => { - expect(buildRequest('getKnowledgeDocument', ['doc_1'], { kb: 'kb_1' }, WORKSPACE)).toEqual({ + it('combines nested resource path arguments in route order', () => { + expect(buildRequest('getKnowledgeDocument', ['kb_1', 'doc_1'], {}, WORKSPACE)).toEqual({ path: '/api/v2/knowledge/kb_1/documents/doc_1', query: { workspaceId: WORKSPACE }, body: undefined, @@ -97,9 +97,9 @@ describe('buildRequest', () => { ) }) - it('rejects a missing named path scope', () => { - expect(() => buildRequest('getKnowledgeDocument', ['doc_1'], {}, WORKSPACE)).toThrow( - '--kb is required' + it('names a missing nested parent path argument clearly', () => { + expect(() => buildRequest('getKnowledgeDocument', [], {}, WORKSPACE)).toThrow( + 'Missing ' ) }) diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 92a8bbe1bba..430348170b4 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -272,6 +272,7 @@ export function buildRequest( const pathFlag = commandSpec.pathFlags?.[param] const profileWorkspacePath = isProfileWorkspacePath(commandSpec, param) const flagName = pathFlagNameFor(commandSpec, param) + const argumentName = commandSpec.pathArgumentNames?.[param] ?? param const value = profileWorkspacePath ? workspaceId : pathFlag @@ -284,11 +285,11 @@ export function buildRequest( 0 ) } - throw new SimApiError(pathFlag ? `--${flagName} is required` : `Missing <${param}>`, 0) + throw new SimApiError(pathFlag ? `--${flagName} is required` : `Missing <${argumentName}>`, 0) } if (typeof value !== 'string' || value.length === 0) { throw new SimApiError( - pathFlag ? `--${flagName} cannot be empty` : `<${param}> cannot be empty`, + pathFlag ? `--${flagName} cannot be empty` : `<${argumentName}> cannot be empty`, 0 ) } From d4bdb87d0479cdd9621495e887bf45805d279c70 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 8 Aug 2026 10:21:27 -0700 Subject: [PATCH 096/159] feat(cli): add interactive Sim chat --- apps/docs/openapi-core.json | 743 ++++- apps/docs/openapi-v2-workflows.json | 6 + apps/sim/AGENTS.md | 10 + apps/sim/app/api/knowledge/utils.test.ts | 10 + apps/sim/app/api/knowledge/utils.ts | 37 +- apps/sim/app/api/v1/middleware.ts | 1 + apps/sim/app/api/v2/chat/activity.test.ts | 380 +++ apps/sim/app/api/v2/chat/activity.ts | 605 ++++ apps/sim/app/api/v2/chat/route.test.ts | 1549 ++++++++++ apps/sim/app/api/v2/chat/route.ts | 691 +++++ .../app/api/v2/chats/[chatId]/route.test.ts | 372 +++ apps/sim/app/api/v2/chats/[chatId]/route.ts | 158 + apps/sim/app/api/v2/chats/route.test.ts | 225 ++ apps/sim/app/api/v2/chats/route.ts | 139 + .../api/v2/workspaces/[workspaceId]/route.ts | 87 + apps/sim/app/cli/auth/cli-auth-view.test.tsx | 16 +- apps/sim/app/cli/auth/cli-auth-view.tsx | 45 +- .../app/workspace/[workspaceId]/home/types.ts | 22 +- apps/sim/blocks/blocks/browser_use.ts | 2 + apps/sim/blocks/blocks/codepipeline.ts | 1 + apps/sim/blocks/blocks/discord.ts | 1 + apps/sim/blocks/blocks/pi.ts | 1 + apps/sim/blocks/blocks/secrets_manager.ts | 1 + apps/sim/blocks/blocks/sftp.ts | 1 + apps/sim/blocks/blocks/ssh.ts | 1 + apps/sim/blocks/blocks/sts.ts | 3 + apps/sim/blocks/blocks/zoom.ts | 2 + .../lib/api/contracts/v1/tables/index.test.ts | 24 + apps/sim/lib/api/contracts/v1/tables/index.ts | 21 +- .../api/contracts/v2/__tests__/tables.test.ts | 51 +- apps/sim/lib/api/contracts/v2/chat.test.ts | 146 + apps/sim/lib/api/contracts/v2/chat.ts | 200 ++ apps/sim/lib/api/contracts/v2/chats.test.ts | 101 + apps/sim/lib/api/contracts/v2/chats.ts | 108 + apps/sim/lib/api/contracts/v2/tables.ts | 12 +- apps/sim/lib/api/contracts/v2/workspaces.ts | 41 + .../lib/copilot/async-runs/repository.test.ts | 22 + apps/sim/lib/copilot/async-runs/repository.ts | 5 +- apps/sim/lib/copilot/chat/lifecycle.test.ts | 54 + apps/sim/lib/copilot/chat/lifecycle.ts | 68 +- .../copilot/chat/persisted-message.test.ts | 28 + .../sim/lib/copilot/chat/persisted-message.ts | 30 + apps/sim/lib/copilot/chat/post.ts | 300 +- apps/sim/lib/copilot/chat/turn-persistence.ts | 246 ++ .../sim/lib/copilot/chat/workspace-context.ts | 34 +- .../lib/copilot/headless/attachments.test.ts | 239 ++ apps/sim/lib/copilot/headless/attachments.ts | 181 ++ .../headless/continuation-token.test.ts | 111 + .../copilot/headless/continuation-token.ts | 140 + .../copilot/headless/workspace-chat.test.ts | 544 ++++ .../lib/copilot/headless/workspace-chat.ts | 262 ++ .../request/context/request-context.ts | 1 + .../sim/lib/copilot/request/go/stream.test.ts | 36 + apps/sim/lib/copilot/request/go/stream.ts | 4 + .../copilot/request/handlers/handlers.test.ts | 113 +- .../lib/copilot/request/lifecycle/headless.ts | 9 +- .../lifecycle/resume-leg-context.test.ts | 1 + .../lib/copilot/request/lifecycle/run.test.ts | 300 ++ apps/sim/lib/copilot/request/lifecycle/run.ts | 236 +- .../copilot/request/lifecycle/start.test.ts | 134 +- .../lib/copilot/request/lifecycle/start.ts | 24 +- .../copilot/request/session/abort-reason.ts | 2 + .../lib/copilot/request/session/abort.test.ts | 65 +- apps/sim/lib/copilot/request/session/abort.ts | 116 +- .../request/session/explicit-abort.test.ts | 24 +- .../copilot/request/session/explicit-abort.ts | 6 +- .../copilot/request/tools/executor.test.ts | 95 +- .../sim/lib/copilot/request/tools/executor.ts | 320 +- .../copilot/request/tools/permission.test.ts | 38 + .../lib/copilot/request/tools/permission.ts | 6 +- .../lib/copilot/request/tools/tables.test.ts | 14 + apps/sim/lib/copilot/request/tools/tables.ts | 16 + .../request/tools/workflow-context.test.ts | 16 + .../copilot/request/tools/workflow-context.ts | 5 +- apps/sim/lib/copilot/request/types.ts | 14 + .../copilot/tool-executor/executor.test.ts | 203 ++ .../sim/lib/copilot/tool-executor/executor.ts | 95 +- apps/sim/lib/copilot/tool-executor/types.ts | 4 + .../lib/copilot/tools/client/store-utils.ts | 84 +- .../lib/copilot/tools/handlers/access.test.ts | 72 + apps/sim/lib/copilot/tools/handlers/access.ts | 21 +- .../handlers/deployment/custom-block.test.ts | 2 +- .../tools/handlers/deployment/custom-block.ts | 2 +- .../tools/handlers/deployment/deploy.ts | 14 +- .../tools/handlers/deployment/manage.test.ts | 92 +- .../tools/handlers/deployment/manage.ts | 49 +- .../tools/handlers/deployment/state-refs.ts | 6 +- .../tools/handlers/function-execute.test.ts | 20 + .../tools/handlers/function-execute.ts | 4 +- .../management/manage-custom-tool.test.ts | 111 + .../handlers/management/manage-custom-tool.ts | 63 +- .../management/manage-mcp-tool.test.ts | 88 + .../handlers/management/manage-mcp-tool.ts | 2 +- .../tools/handlers/materialize-file.test.ts | 2 +- .../tools/handlers/materialize-file.ts | 2 +- apps/sim/lib/copilot/tools/handlers/oauth.ts | 6 +- .../copilot/tools/handlers/vfs-mutate.test.ts | 2 +- .../lib/copilot/tools/handlers/vfs-mutate.ts | 12 +- .../lib/copilot/tools/handlers/vfs.test.ts | 27 + apps/sim/lib/copilot/tools/handlers/vfs.ts | 43 +- .../tools/handlers/workflow/mutations.test.ts | 152 +- .../tools/handlers/workflow/mutations.ts | 70 +- .../tools/handlers/workflow/queries.test.ts | 65 +- .../tools/handlers/workflow/queries.ts | 41 +- .../registry/server-tool-adapter.test.ts | 40 + .../tools/registry/server-tool-adapter.ts | 3 + .../sim/lib/copilot/tools/server/base-tool.ts | 2 + .../server/docs/search-documentation.test.ts | 18 +- .../tools/server/docs/search-documentation.ts | 12 +- .../copilot/tools/server/files/create-file.ts | 2 +- .../files/download-to-workspace-file.ts | 2 +- .../tools/server/files/file-folders.ts | 4 +- .../copilot/tools/server/files/rename-file.ts | 2 +- .../copilot/tools/server/files/share-file.ts | 2 +- .../tools/server/files/workspace-file.ts | 2 +- .../server/knowledge/knowledge-base.test.ts | 107 +- .../tools/server/knowledge/knowledge-base.ts | 88 +- .../tools/server/table/user-table.test.ts | 182 +- .../copilot/tools/server/table/user-table.ts | 75 +- .../user/set-environment-variables.test.ts | 6 +- .../server/user/set-environment-variables.ts | 13 +- .../workflow/edit-workflow/index.test.ts | 186 ++ .../server/workflow/edit-workflow/index.ts | 27 +- .../tools/server/workflow/query-logs.ts | 2 +- .../tools/shared/workflow-utils.test.ts | 41 + .../copilot/tools/shared/workflow-utils.ts | 22 +- .../sim/lib/copilot/tools/subagent-display.ts | 29 + .../lib/copilot/tools/tool-display.test.ts | 15 + apps/sim/lib/copilot/tools/tool-display.ts | 66 +- apps/sim/lib/copilot/vfs/serializers.test.ts | 15 + apps/sim/lib/copilot/vfs/serializers.ts | 2 +- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 68 +- apps/sim/lib/table/types.ts | 12 +- .../lib/table/workflow-groups/service.test.ts | 192 ++ apps/sim/lib/table/workflow-groups/service.ts | 13 +- .../lib/workflows/credentials/constants.ts | 8 + .../credential-extractor.secretless.test.ts | 293 ++ .../credentials/credential-extractor.ts | 242 +- .../lib/workflows/custom-tools/operations.ts | 24 + apps/sim/lib/workflows/persistence/utils.ts | 9 +- bun.lock | 1 + packages/sim-cli/README.md | 119 +- packages/sim-cli/package.json | 1 + .../protocol/chat-attachment-tag.test.ts | 64 + .../protocol/chat-attachments.test.ts | 134 + .../src/commands/protocol/chat-attachments.ts | 324 ++ .../commands/protocol/chat-markdown.test.ts | 99 + .../src/commands/protocol/chat-markdown.ts | 244 ++ .../commands/protocol/chat-mentions.test.ts | 121 + .../src/commands/protocol/chat-paste.test.ts | 78 + .../commands/protocol/chat-structured.test.ts | 352 +++ .../src/commands/protocol/chat-structured.ts | 748 +++++ .../protocol/chat-suggestions.test.ts | 179 ++ .../src/commands/protocol/chat-suggestions.ts | 223 ++ .../commands/protocol/chat-terminal.test.ts | 2148 +++++++++++++ .../src/commands/protocol/chat-terminal.ts | 2682 +++++++++++++++++ .../src/commands/protocol/chat-wrap.test.ts | 81 + .../src/commands/protocol/chat.test.ts | 2646 ++++++++++++++++ .../sim-cli/src/commands/protocol/chat.ts | 1564 ++++++++++ .../commands/protocol/files-download.test.ts | 19 +- .../src/commands/protocol/files-download.ts | 26 +- .../src/commands/protocol/files-upload.ts | 24 +- .../sim-cli/src/commands/protocol/index.ts | 3 + .../commands/protocol/resource-directory.ts | 21 +- .../src/commands/protocol/tables-import.ts | 30 +- packages/sim-cli/src/generated/v2-api.ts | 268 +- packages/sim-cli/src/http/client.test.ts | 121 +- packages/sim-cli/src/http/client.ts | 87 +- packages/sim-cli/src/index.ts | 11 +- packages/sim-cli/src/output/render.test.ts | 8 + packages/sim-cli/src/output/render.ts | 33 +- packages/sim-cli/src/output/terminal-text.ts | 106 + packages/sim-cli/src/runtime/types.ts | 2 +- packages/sim-cli/src/transfer/local-file.ts | 2 +- scripts/check-openapi-specs.ts | 4 +- 175 files changed, 24179 insertions(+), 1246 deletions(-) create mode 100644 apps/sim/app/api/v2/chat/activity.test.ts create mode 100644 apps/sim/app/api/v2/chat/activity.ts create mode 100644 apps/sim/app/api/v2/chat/route.test.ts create mode 100644 apps/sim/app/api/v2/chat/route.ts create mode 100644 apps/sim/app/api/v2/chats/[chatId]/route.test.ts create mode 100644 apps/sim/app/api/v2/chats/[chatId]/route.ts create mode 100644 apps/sim/app/api/v2/chats/route.test.ts create mode 100644 apps/sim/app/api/v2/chats/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts create mode 100644 apps/sim/lib/api/contracts/v1/tables/index.test.ts create mode 100644 apps/sim/lib/api/contracts/v2/chat.test.ts create mode 100644 apps/sim/lib/api/contracts/v2/chat.ts create mode 100644 apps/sim/lib/api/contracts/v2/chats.test.ts create mode 100644 apps/sim/lib/api/contracts/v2/chats.ts create mode 100644 apps/sim/lib/api/contracts/v2/workspaces.ts create mode 100644 apps/sim/lib/copilot/chat/turn-persistence.ts create mode 100644 apps/sim/lib/copilot/headless/attachments.test.ts create mode 100644 apps/sim/lib/copilot/headless/attachments.ts create mode 100644 apps/sim/lib/copilot/headless/continuation-token.test.ts create mode 100644 apps/sim/lib/copilot/headless/continuation-token.ts create mode 100644 apps/sim/lib/copilot/headless/workspace-chat.test.ts create mode 100644 apps/sim/lib/copilot/headless/workspace-chat.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/access.test.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.test.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.test.ts create mode 100644 apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts create mode 100644 apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.test.ts create mode 100644 apps/sim/lib/copilot/tools/shared/workflow-utils.test.ts create mode 100644 apps/sim/lib/copilot/tools/subagent-display.ts create mode 100644 apps/sim/lib/table/workflow-groups/service.test.ts create mode 100644 apps/sim/lib/workflows/credentials/constants.ts create mode 100644 apps/sim/lib/workflows/credentials/credential-extractor.secretless.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-attachment-tag.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-attachments.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-attachments.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-markdown.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-markdown.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-mentions.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-paste.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-structured.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-structured.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-suggestions.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-suggestions.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-terminal.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-terminal.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-wrap.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat.ts create mode 100644 packages/sim-cli/src/output/terminal-text.ts diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index b7020ae27f9..a7acbed3cde 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -1,8 +1,8 @@ { "openapi": "3.1.0", "info": { - "title": "Sim API — Execution & Usage", - "description": "Run workflows, poll and cancel executions, resume Human-in-the-Loop pauses, and check usage limits.", + "title": "Sim API — Execution, Chat & Usage", + "description": "Run workflows, chat with a workspace, poll and cancel executions, resume Human-in-the-Loop pauses, and check usage limits.", "version": "1.0.0", "contact": { "name": "Sim Support", @@ -36,6 +36,14 @@ { "name": "Billing", "description": "Inspect billing status and credit-denominated ledger events" + }, + { + "name": "Chat", + "description": "Chat with a workspace through Mothership" + }, + { + "name": "Workspaces", + "description": "Resolve workspace metadata available to the authenticated credential" } ], "security": [ @@ -1018,6 +1026,677 @@ "parameters": [] } }, + "/api/v2/workspaces/{workspaceId}": { + "get": { + "operationId": "getWorkspace", + "summary": "Get Workspace", + "description": "Resolve a workspace ID to the display metadata available to the authenticated credential. The credential must have read access to the workspace.", + "tags": ["Workspaces"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace to resolve." + } + ], + "responses": { + "200": { + "description": "The workspace's display metadata.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["workspace"], + "properties": { + "workspace": { + "type": "object", + "required": ["id", "name", "color", "logoUrl", "createdAt", "updatedAt"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "color": { "type": "string" }, + "logoUrl": { "type": ["string", "null"] }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + } + } + } + } + } + }, + "example": { + "data": { + "workspace": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Product Operations", + "color": "#7C3AED", + "logoUrl": null, + "createdAt": "2026-08-07T18:00:00.000Z", + "updatedAt": "2026-08-07T18:30:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "404": { + "$ref": "#/components/responses/V2NotFound" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + } + } + } + }, + "/api/v2/chats": { + "get": { + "operationId": "listChats", + "summary": "List Sim Chats", + "description": "List a bounded page of the authenticated user's active workspace chats in the same pinned-first, recently-updated order used by the Sim Home UI. This personal history surface requires a personal API key; shared workspace keys cannot read their creator's private chats. Pass `nextCursor` back as `cursor` to load another page.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { "type": "string" }, + "description": "Workspace whose chats should be listed." + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { "type": "string", "maxLength": 200 }, + "description": "Case-insensitive title substring." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 30 }, + "description": "Maximum chats to return." + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { "type": "string" }, + "description": "Opaque cursor returned by the previous page." + } + ], + "responses": { + "200": { + "description": "A bounded page of chat summaries.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title", "updatedAt", "pinned", "active"], + "properties": { + "id": { "type": "string" }, + "title": { "type": ["string", "null"] }, + "updatedAt": { "type": "string", "format": "date-time" }, + "pinned": { "type": "boolean" }, + "active": { "type": "boolean" } + } + } + }, + "nextCursor": { "type": ["string", "null"] } + } + }, + "example": { + "data": [ + { + "id": "80a47295-040e-46f9-9ea8-ad78eff3bcab", + "title": "Review release workflow", + "updatedAt": "2026-08-07T18:30:00.000Z", + "pinned": true, + "active": false + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + } + }, + "/api/v2/chats/{chatId}": { + "get": { + "operationId": "getChat", + "summary": "Open Sim Chat", + "description": "Load one owned workspace chat as a display-safe user/assistant transcript and mint a fresh opaque continuation token for the requested safety mode. Internal tool payloads, stream IDs, resources, and replay metadata are not exposed. The subsequent chat POST still accepts only the continuation token, never this resource ID.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "chatId", + "in": "path", + "required": true, + "schema": { "type": "string" }, + "description": "Chat resource ID returned by List Sim Chats." + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { "type": "string" }, + "description": "Workspace the chat must belong to." + }, + { + "name": "readOnly", + "in": "query", + "required": false, + "schema": { "type": "boolean", "default": false }, + "description": "Mint a continuation token for the secretless read-only chat mode." + } + ], + "responses": { + "200": { + "description": "The chat transcript and a fresh continuation token.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "title", "messages", "continuationToken", "active"], + "properties": { + "id": { "type": "string" }, + "title": { "type": ["string", "null"] }, + "messages": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "role", "content", "timestamp"], + "properties": { + "id": { "type": "string" }, + "role": { "type": "string", "enum": ["user", "assistant"] }, + "content": { "type": "string" }, + "timestamp": { "type": "string", "format": "date-time" } + } + } + }, + "continuationToken": { "type": "string" }, + "active": { "type": "boolean" } + } + } + } + }, + "example": { + "data": { + "id": "80a47295-040e-46f9-9ea8-ad78eff3bcab", + "title": "Review release workflow", + "messages": [ + { + "id": "msg_1", + "role": "user", + "content": "Review the release workflow", + "timestamp": "2026-08-07T18:29:00.000Z" + }, + { + "id": "msg_2", + "role": "assistant", + "content": "The workflow is ready to release.", + "timestamp": "2026-08-07T18:30:00.000Z" + } + ], + "continuationToken": "sim-v2-chat-v1.opaque.refreshed", + "active": false + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "404": { + "$ref": "#/components/responses/V2NotFound" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + }, + "patch": { + "operationId": "renameChat", + "summary": "Rename Sim Chat", + "description": "Rename an owned Sim Chat and synchronize the new title with the Sim Home chat list. This private history operation requires a personal API key; shared workspace keys cannot rename a creator's chats.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "chatId", + "in": "path", + "required": true, + "schema": { "type": "string" }, + "description": "Chat resource ID returned by List Sim Chats." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["workspaceId", "title"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace the chat must belong to." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "New chat title. Leading and trailing whitespace is removed." + } + } + }, + "example": { + "workspaceId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "title": "Incident investigation" + } + } + } + }, + "responses": { + "200": { + "description": "The renamed chat.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "title"], + "properties": { + "id": { "type": "string" }, + "title": { "type": "string", "minLength": 1, "maxLength": 200 } + } + } + } + }, + "example": { + "data": { + "id": "80a47295-040e-46f9-9ea8-ad78eff3bcab", + "title": "Incident investigation" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "404": { + "$ref": "#/components/responses/V2NotFound" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + } + }, + "/api/v2/chat": { + "post": { + "operationId": "chat", + "summary": "Ask Sim Chat", + "description": "Chat with the Mothership agent for a workspace. Personal API keys use their owner's current workspace permission, integrations, credentials, environment context, and memory, and their conversations are synchronized with the Sim Home chat history. Shared workspace keys retain normal workspace capabilities but do not inherit a human owner's personal integrations, secrets, environment, memory, or private chat history. Set `readOnly` to select the subtractive, secretless workspace-query policy. Omit `continuationToken` for a one-shot or first interactive turn, then send the latest opaque token returned by the stream to continue the same conversation. Tokens are bound to the workspace, authorization principal, credential type, and read-only mode, and expire on a rolling 24-hour window; this chat POST never accepts a raw chat ID. The response is a Server-Sent Events stream. `text` events contain incremental assistant output and `complete` contains the authoritative final result. Comment frames are heartbeats and `data: [DONE]` closes a successful stream. The caller's Sim API key selects and authorizes the local workspace but is never forwarded to Mothership. Workspace keys use the workspace billing account as their system actor while local tool authorization remains bound to the key owner; personal keys use their owner. Inline attachments are base64-only: paths and URLs are not accepted or resolved.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["workspaceId", "prompt"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace Sim Chat should operate in." + }, + "prompt": { + "type": "string", + "maxLength": 10485760, + "x-maxUtf8Bytes": 10485760, + "description": "The instruction or question for Sim Chat. UTF-8 input is limited to 10 MiB. It may be empty or whitespace only when at least one attachment is present; the server supplies a neutral inspect-the-attachments instruction in that case." + }, + "continuationToken": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Latest opaque continuation token returned by a prior `session` or `complete` event. Never send a raw chat or conversation ID." + }, + "readOnly": { + "type": "boolean", + "default": false, + "description": "Use the secretless, read-only workspace-query policy. The default keeps normal workspace capabilities; shared workspace credentials still exclude personal integrations, secrets, environment, and persistent memory." + }, + "attachments": { + "type": "array", + "maxItems": 5, + "description": "Optional inline attachments, accepted on initial and continuation turns. Decoded aggregate size is limited to 10 MiB. Images and PDFs are limited to 5 MiB each; UTF-8 text is limited to 200 KiB each. Each image may be at most 8192 pixels on either axis and 16,000,000 total pixels; all images in one request may total at most 32,000,000 decoded pixels.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "mediaType", "data"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "File basename only. Directory separators and control characters are rejected." + }, + "mediaType": { + "type": "string", + "enum": [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "application/pdf", + "text/plain", + "text/markdown", + "text/csv", + "text/tab-separated-values", + "text/html", + "text/css", + "text/javascript", + "text/typescript", + "text/xml", + "text/yaml", + "application/json", + "application/jsonl", + "application/x-ndjson", + "application/xml", + "application/yaml", + "application/x-yaml", + "application/toml" + ], + "description": "Declared MIME type. Image and PDF bytes are sniffed; text must decode as UTF-8." + }, + "data": { + "type": "string", + "minLength": 4, + "maxLength": 13981016, + "contentEncoding": "base64", + "description": "Canonical standard base64 bytes. Data URLs and base64url are not accepted." + } + } + } + }, + "contexts": { + "type": "array", + "maxItems": 50, + "description": "Optional identity-bearing workspace resources, skills, and MCP servers to inject for this turn. Resource kinds correspond to `@` tags; `skill` and `mcp` correspond to `/` tags. MCP contexts are ignored for read-only requests and shared workspace API keys.", + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "workflowId", "label"], + "properties": { + "kind": { "type": "string", "const": "workflow" }, + "workflowId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "tableId", "label"], + "properties": { + "kind": { "type": "string", "const": "table" }, + "tableId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "fileId", "label"], + "properties": { + "kind": { "type": "string", "const": "file" }, + "fileId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "knowledgeId", "label"], + "properties": { + "kind": { "type": "string", "const": "knowledge" }, + "knowledgeId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "executionId", "label"], + "properties": { + "kind": { "type": "string", "const": "logs" }, + "executionId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "skillId", "label"], + "properties": { + "kind": { "type": "string", "const": "skill" }, + "skillId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "serverId", "label"], + "properties": { + "kind": { "type": "string", "const": "mcp" }, + "serverId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + } + ] + } + } + } + }, + "example": { + "workspaceId": "ws_abc123", + "prompt": "Summarize the attached notes and compare them with this workspace.", + "attachments": [ + { + "name": "notes.md", + "mediaType": "text/markdown", + "data": "IyBOb3Rlcwo=" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "A Sim Chat SSE stream.", + "headers": { + "X-RateLimit-Limit": { + "description": "API request bucket capacity.", + "schema": { "type": "integer" } + }, + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current bucket.", + "schema": { "type": "integer" } + }, + "X-RateLimit-Reset": { + "description": "When the current API request bucket resets.", + "schema": { "type": "string", "format": "date-time" } + } + }, + "content": { + "text/event-stream": { + "schema": { "type": "string" }, + "example": "data: {\"type\":\"session\",\"continuationToken\":\"sim-v2-chat-v1.opaque.refreshed\",\"requestId\":\"req_123\",\"chatId\":\"80a47295-040e-46f9-9ea8-ad78eff3bcab\"}\n\ndata: {\"type\":\"text\",\"delta\":\"Two workflows...\"}\n\ndata: {\"type\":\"complete\",\"data\":{\"content\":\"Two workflows...\",\"continuationToken\":\"sim-v2-chat-v1.opaque.refreshed\",\"usage\":{\"prompt\":120,\"completion\":18,\"total\":138}}}\n\ndata: [DONE]\n\n" + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "402": { + "$ref": "#/components/responses/V2UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "404": { + "$ref": "#/components/responses/V2NotFound" + }, + "409": { + "$ref": "#/components/responses/V2Conflict" + }, + "413": { + "$ref": "#/components/responses/V2PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/V2UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + }, + "503": { + "$ref": "#/components/responses/V2ServiceUnavailable" + } + } + } + }, "/api/v2/billing/status": { "get": { "operationId": "getBillingStatus", @@ -2248,6 +2927,56 @@ } } }, + "V2NotFound": { + "description": "The requested resource does not exist or is not visible to the credential.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2Conflict": { + "description": "The chat already has a response in progress. Retry after that response finishes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2UsageLimitExceeded": { + "description": "The resolved workspace payer or organization member has reached a usage limit.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2PayloadTooLarge": { + "description": "The request body or decoded attachment limits were exceeded.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2UnsupportedMediaType": { + "description": "An attachment media type or its decoded bytes are unsupported.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, "V2RateLimited": { "description": "Rate limit exceeded; retry after the window resets.", "content": { @@ -2257,6 +2986,16 @@ } } } + }, + "V2ServiceUnavailable": { + "description": "Sim Chat is not configured or temporarily unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } } } } diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 957b4da2497..fbcc75766c1 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -1141,6 +1141,12 @@ "default": false, "description": "Queue the run; poll the returned statusUrl. Not combinable with stream/output options; requires an API key." }, + "executionTimeoutSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 604800, + "description": "Optional server-side timeout for an async run, in seconds. Requires async=true and cannot extend the account policy." + }, "stream": { "type": "boolean", "default": false, diff --git a/apps/sim/AGENTS.md b/apps/sim/AGENTS.md index 6c52c2df02d..6366615da3c 100644 --- a/apps/sim/AGENTS.md +++ b/apps/sim/AGENTS.md @@ -229,3 +229,13 @@ export function useEntityList(workspaceId?: string) { - **Check existing sources** before duplicating (`lib/` has many utilities) - **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use) + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index d7d0ea2999d..df3dc0b9c40 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -234,6 +234,16 @@ describe('Knowledge Utils', () => { expect(result.hasAccess).toBe(false) expect('notFound' in result && result.notFound).toBe(true) }) + + it('treats a knowledge base outside the trusted workspace as not found', async () => { + queueTableRows(schemaMock.knowledgeBase, [ + { id: 'kb1', userId: 'user1', workspaceId: 'workspace-2' }, + ]) + + const result = await checkKnowledgeBaseAccess('kb1', 'user1', 'workspace-1') + + expect(result).toEqual({ hasAccess: false, notFound: true }) + }) }) describe('checkDocumentAccess', () => { diff --git a/apps/sim/app/api/knowledge/utils.ts b/apps/sim/app/api/knowledge/utils.ts index e92dc49f419..11fac039123 100644 --- a/apps/sim/app/api/knowledge/utils.ts +++ b/apps/sim/app/api/knowledge/utils.ts @@ -163,7 +163,8 @@ export type ChunkAccessCheck = ChunkAccessResult | ChunkAccessDenied async function resolveKnowledgeBaseAccess( knowledgeBaseId: string, userId: string, - requireWrite: boolean + requireWrite: boolean, + workspaceId?: string ): Promise { const kb = await db .select({ @@ -183,6 +184,10 @@ async function resolveKnowledgeBaseAccess( const kbData = kb[0] + if (workspaceId && kbData.workspaceId !== workspaceId) { + return { hasAccess: false, notFound: true } + } + if (kbData.workspaceId) { // Workspace KB: use workspace permissions only const userPermission = await getUserEntityPermissions(userId, 'workspace', kbData.workspaceId) @@ -205,9 +210,10 @@ async function resolveKnowledgeBaseAccess( */ export async function checkKnowledgeBaseAccess( knowledgeBaseId: string, - userId: string + userId: string, + workspaceId?: string ): Promise { - return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, false) + return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, false, workspaceId) } /** @@ -219,9 +225,10 @@ export async function checkKnowledgeBaseAccess( */ export async function checkKnowledgeBaseWriteAccess( knowledgeBaseId: string, - userId: string + userId: string, + workspaceId?: string ): Promise { - return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, true) + return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, true, workspaceId) } /** @@ -232,9 +239,15 @@ async function resolveDocumentAccess( knowledgeBaseId: string, documentId: string, userId: string, - requireWrite: boolean + requireWrite: boolean, + workspaceId?: string ): Promise { - const kbAccess = await resolveKnowledgeBaseAccess(knowledgeBaseId, userId, requireWrite) + const kbAccess = await resolveKnowledgeBaseAccess( + knowledgeBaseId, + userId, + requireWrite, + workspaceId + ) if (!kbAccess.hasAccess) { return { @@ -262,9 +275,10 @@ async function resolveDocumentAccess( export async function checkDocumentAccess( knowledgeBaseId: string, documentId: string, - userId: string + userId: string, + workspaceId?: string ): Promise { - return resolveDocumentAccess(knowledgeBaseId, documentId, userId, false) + return resolveDocumentAccess(knowledgeBaseId, documentId, userId, false, workspaceId) } /** @@ -274,9 +288,10 @@ export async function checkDocumentAccess( export async function checkDocumentWriteAccess( knowledgeBaseId: string, documentId: string, - userId: string + userId: string, + workspaceId?: string ): Promise { - return resolveDocumentAccess(knowledgeBaseId, documentId, userId, true) + return resolveDocumentAccess(knowledgeBaseId, documentId, userId, true, workspaceId) } /** diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 4182a4ea4a1..d9d795b618c 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -34,6 +34,7 @@ export type ApiEndpoint = | 'workflow-version-detail' | 'workflow-export' | 'workflow-import' + | 'workspace' | 'audit-logs' | 'tables' | 'table-detail' diff --git a/apps/sim/app/api/v2/chat/activity.test.ts b/apps/sim/app/api/v2/chat/activity.test.ts new file mode 100644 index 00000000000..feed95c1305 --- /dev/null +++ b/apps/sim/app/api/v2/chat/activity.test.ts @@ -0,0 +1,380 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import type { MothershipStreamV1StreamScope } from '@/lib/copilot/generated/mothership-stream-v1' +import type { StreamEvent } from '@/lib/copilot/request/types' +import { ChatActivityProjector } from '@/app/api/v2/chat/activity' + +vi.mock('@/lib/copilot/tools/client/read-block', () => ({ + getReadTargetBlock: vi.fn((path: string | undefined) => + path?.startsWith('components/') ? { name: 'Gmail' } : undefined + ), +})) + +const call = (over: Record = {}) => ({ + toolCallId: 'private-call-id', + toolName: 'read', + phase: 'call', + arguments: { secret: 'never-forward-me' }, + executor: 'go', + mode: 'sync', + ...over, +}) + +const result = (over: Record = {}) => + call({ + phase: 'result', + success: true, + output: { secret: 'never-forward-me' }, + arguments: undefined, + ...over, + }) + +const tool = (payload: Record, scope?: MothershipStreamV1StreamScope) => + ({ type: 'tool', payload, ...(scope ? { scope } : {}) }) as StreamEvent + +const span = ( + event: 'start' | 'end', + scope: MothershipStreamV1StreamScope, + over: Record = {} +) => + ({ + type: 'span', + scope, + payload: { kind: 'subagent', event, agent: scope.agentId, ...over }, + }) as StreamEvent + +const text = ( + channel: 'assistant' | 'thinking', + value: string, + scope?: MothershipStreamV1StreamScope +) => + ({ + type: 'text', + payload: { channel, text: value }, + ...(scope ? { scope } : {}), + }) as StreamEvent + +const researchScope: MothershipStreamV1StreamScope = { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-dispatch-id', + spanId: 'private-research-span', + parentSpanId: 'main', +} + +describe('ChatActivityProjector', () => { + it('correlates a visible root call and result without exposing their raw payload', () => { + const projector = new ChatActivityProjector() + + const [running] = projector.project(tool(call())) + const [complete] = projector.project(tool(result())) + + expect(running).toEqual({ + kind: 'tool', + id: 'tool-1', + label: 'Reading file', + state: 'running', + }) + expect(complete).toEqual({ ...running, label: 'Read file', state: 'complete' }) + expect(JSON.stringify([running, complete])).not.toContain('private-call-id') + expect(JSON.stringify([running, complete])).not.toContain('never-forward-me') + }) + + it.each([ + ['workflows/forceful-arm/state.json', 'forceful-arm'], + ['components/blocks/gmail_v2.json', 'Gmail'], + ['components/integrations/gmail/send.json', 'Gmail'], + ])('uses the web read label for %s without forwarding arguments', (path, target) => { + const projector = new ChatActivityProjector() + const activities = [ + ...projector.project(tool(call({ arguments: { path, secret: 'never-forward-me' } }))), + ...projector.project(tool(result())), + ] + + expect(activities).toEqual([ + { kind: 'tool', id: 'tool-1', label: `Reading ${target}`, state: 'running' }, + { kind: 'tool', id: 'tool-1', label: `Read ${target}`, state: 'complete' }, + ]) + expect(JSON.stringify(activities)).not.toContain(path) + expect(JSON.stringify(activities)).not.toContain('never-forward-me') + }) + + it('maps failed and skipped terminal outcomes', () => { + const failed = new ChatActivityProjector() + failed.project(tool(call())) + expect(failed.project(tool(result({ success: false, error: 'private failure' })))).toEqual([ + expect.objectContaining({ label: 'Reading file', state: 'error' }), + ]) + + expect( + new ChatActivityProjector().project(tool(call({ status: 'skipped', success: false }))) + ).toEqual([expect.objectContaining({ label: 'Reading file', state: 'complete' })]) + + for (const status of ['cancelled', 'rejected']) { + const projector = new ChatActivityProjector() + projector.project(tool(call())) + expect(projector.project(tool(result({ status, success: true })))[0]).toMatchObject({ + state: 'error', + }) + } + }) + + it('waits for an authoritative call and holds an early result', () => { + const generating = new ChatActivityProjector() + expect(generating.project(tool(call({ partial: true, status: 'generating' })))).toEqual([]) + expect(generating.project(tool(call({ partial: false, status: 'executing' })))).toEqual([ + { + kind: 'tool', + id: 'tool-1', + label: 'Reading file', + state: 'running', + }, + ]) + + const reordered = new ChatActivityProjector() + expect(reordered.project(tool(result()))).toEqual([]) + expect(reordered.project(tool(call()))).toEqual([ + { + kind: 'tool', + id: 'tool-1', + label: 'Read file', + state: 'complete', + }, + ]) + }) + + it('suppresses hidden, internal, and internal-result calls without id gaps', () => { + const projector = new ChatActivityProjector() + + for (const payload of [ + call({ toolCallId: 'hidden', ui: { hidden: true } }), + call({ toolCallId: 'internal', ui: { internal: true } }), + call({ toolCallId: 'legacy', toolName: 'load_skill' }), + call({ + toolCallId: 'tool-result-read', + arguments: { path: 'internal/tool-results/private' }, + }), + ]) { + expect(projector.project(tool(payload))).toEqual([]) + } + + expect(projector.project(tool(call({ toolCallId: 'visible' })))[0]).toMatchObject({ + id: 'tool-1', + }) + }) + + it('provisions root and nested subagent lanes from dispatch calls before span start', () => { + const root = new ChatActivityProjector() + expect( + root.project(tool(call({ toolCallId: 'workflow-dispatch', toolName: 'workflow' }))) + ).toEqual([ + { + kind: 'subagent', + id: 'agent-1', + label: 'Workflow Agent', + state: 'running', + }, + ]) + const workflowScope = { + lane: 'subagent' as const, + agentId: 'workflow', + spanId: 'workflow-span', + parentSpanId: 'main', + parentToolCallId: 'workflow-dispatch', + } + expect(root.project(span('start', workflowScope))).toEqual([]) + + const nested = new ChatActivityProjector() + nested.project(span('start', researchScope)) + expect( + nested.project( + tool(call({ toolCallId: 'deploy-dispatch', toolName: 'deploy' }), researchScope) + ) + ).toEqual([ + { + kind: 'subagent', + id: 'agent-2', + parentId: 'agent-1', + label: 'Deploy Agent', + state: 'running', + }, + ]) + expect( + nested.project( + span('start', { + lane: 'subagent', + agentId: 'deploy', + spanId: 'deploy-span', + parentSpanId: researchScope.spanId, + parentToolCallId: 'deploy-dispatch', + }) + ) + ).toEqual([]) + }) + + it('projects subagent lifecycle, scoped tools, and narration as an opaque tree', () => { + const projector = new ChatActivityProjector() + const activities = [ + ...projector.project(span('start', researchScope)), + ...projector.project(tool(call({ toolCallId: 'private-child-tool' }), researchScope)), + ...projector.project(text('assistant', 'I found the answer.', researchScope)), + ...projector.project(text('thinking', 'private chain of thought', researchScope)), + // Sim/client tool results are synthesized without their original scope. + ...projector.project(tool(result({ toolCallId: 'private-child-tool' }))), + ...projector.project(span('end', researchScope)), + ] + + expect(activities).toEqual([ + { + kind: 'subagent', + id: 'agent-1', + label: 'Research Agent', + state: 'running', + }, + { + kind: 'tool', + id: 'tool-1', + parentId: 'agent-1', + label: 'Reading file', + state: 'running', + }, + { kind: 'narration', parentId: 'agent-1', delta: 'I found the answer.' }, + { + kind: 'tool', + id: 'tool-1', + parentId: 'agent-1', + label: 'Read file', + state: 'complete', + }, + { + kind: 'subagent', + id: 'agent-1', + label: 'Research Agent', + state: 'complete', + }, + ]) + const serialized = JSON.stringify(activities) + for (const privateValue of [ + 'private-child-tool', + 'private-dispatch-id', + 'private-research-span', + 'never-forward-me', + 'private chain of thought', + ]) { + expect(serialized).not.toContain(privateValue) + } + }) + + it('nests subagents by opaque span parent ids and keeps parallel same-name runs distinct', () => { + const projector = new ChatActivityProjector() + const parent = { ...researchScope, spanId: 'parent', parentToolCallId: 'parent-call' } + const child = { + ...researchScope, + spanId: 'child', + parentSpanId: 'parent', + parentToolCallId: 'child-call', + } + const sibling = { + ...researchScope, + spanId: 'sibling', + parentToolCallId: 'sibling-call', + } + + expect(projector.project(span('start', parent))).toEqual([ + expect.objectContaining({ id: 'agent-1', label: 'Research Agent' }), + ]) + expect(projector.project(span('start', child))).toEqual([ + expect.objectContaining({ id: 'agent-2', parentId: 'agent-1' }), + ]) + expect(projector.project(span('start', sibling))).toEqual([ + expect.objectContaining({ id: 'agent-3', label: 'Research Agent' }), + ]) + }) + + it('reconciles a pre-start lane to the authoritative agent without changing its id', () => { + const projector = new ChatActivityProjector() + const provisional = { ...researchScope, agentId: 'superagent' } + + expect(projector.project(text('assistant', 'Starting.', provisional))).toEqual([ + expect.objectContaining({ kind: 'subagent', id: 'agent-1', label: 'Superagent' }), + { kind: 'narration', parentId: 'agent-1', delta: 'Starting.' }, + ]) + expect(projector.project(span('start', provisional, { agent: 'file' }))).toEqual([ + expect.objectContaining({ kind: 'subagent', id: 'agent-1', label: 'File Agent' }), + ]) + }) + + it('keeps pending span ends open and exposes terminal errors without their details', () => { + const projector = new ChatActivityProjector() + projector.project(span('start', researchScope)) + + expect(projector.project(span('end', researchScope, { data: { pending: true } }))).toEqual([]) + const terminal = projector.project( + span('end', researchScope, { data: { error: 'private backend failure' } }) + ) + expect(terminal).toEqual([ + expect.objectContaining({ id: 'agent-1', state: 'error', label: 'Research Agent' }), + ]) + expect(JSON.stringify(terminal)).not.toContain('private backend failure') + }) + + it('settles open tools and agents, using past tense only on success', () => { + const successful = new ChatActivityProjector() + successful.project(span('start', researchScope)) + successful.project(tool(call(), researchScope)) + expect(successful.finish('complete')).toEqual([ + expect.objectContaining({ kind: 'tool', label: 'Read file', state: 'complete' }), + expect.objectContaining({ kind: 'subagent', state: 'complete' }), + ]) + expect(successful.finish('complete')).toEqual([]) + + const failed = new ChatActivityProjector() + failed.project(span('start', researchScope)) + failed.project(tool(call(), researchScope)) + expect(failed.finish('error')).toEqual([ + expect.objectContaining({ kind: 'tool', label: 'Reading file', state: 'error' }), + expect.objectContaining({ kind: 'subagent', state: 'error' }), + ]) + }) + + it('absorbs a workspace_file dispatch into its matching file subagent', () => { + const projector = new ChatActivityProjector() + const workspaceCall = call({ toolCallId: 'workspace-dispatch', toolName: 'workspace_file' }) + const fileScope = { + lane: 'subagent' as const, + agentId: 'file', + spanId: 'file-span', + parentSpanId: 'main', + parentToolCallId: 'workspace-dispatch', + } + + expect(projector.project(tool(workspaceCall))).toEqual([]) + expect( + projector.project( + tool(result({ toolCallId: 'workspace-dispatch', toolName: 'workspace_file' })) + ) + ).toEqual([]) + expect(projector.project(span('start', fileScope))).toEqual([ + { + kind: 'subagent', + id: 'agent-1', + label: 'File Agent', + state: 'running', + }, + ]) + expect(projector.project(tool(call({ toolCallId: 'visible-root' })))[0]).toMatchObject({ + id: 'tool-1', + }) + }) + + it('drops argument deltas, synthetic preview frames, and malformed events', () => { + const projector = new ChatActivityProjector() + + expect(projector.project(tool(call({ phase: 'args_delta' })))).toEqual([]) + expect(projector.project(tool(call({ phase: undefined })))).toEqual([]) + expect(projector.project(tool(call({ toolCallId: '' })))).toEqual([]) + expect(projector.project(tool(call({ toolName: undefined })))).toEqual([]) + }) +}) diff --git a/apps/sim/app/api/v2/chat/activity.ts b/apps/sim/app/api/v2/chat/activity.ts new file mode 100644 index 00000000000..fde09002790 --- /dev/null +++ b/apps/sim/app/api/v2/chat/activity.ts @@ -0,0 +1,605 @@ +import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome' +import { + MothershipStreamV1EventType, + MothershipStreamV1SpanLifecycleEvent, + MothershipStreamV1SpanPayloadKind, + type MothershipStreamV1StreamScope, + MothershipStreamV1TextChannel, + MothershipStreamV1ToolOutcome, + MothershipStreamV1ToolPhase, + MothershipStreamV1ToolStatus, +} from '@/lib/copilot/generated/mothership-stream-v1' +import type { StreamEvent } from '@/lib/copilot/request/types' +import { getToolEntry } from '@/lib/copilot/tool-executor/router' +import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' +import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' +import { getSubagentDisplayTitle } from '@/lib/copilot/tools/subagent-display' +import { getToolDisplayTitle, getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display' + +type ActivityState = 'running' | 'complete' | 'error' + +/** A display-safe node in the public v2 chat activity tree. */ +export interface V2ChatNodeActivity { + kind: 'subagent' | 'tool' + id: string + parentId?: string + label: string + state: ActivityState +} + +/** Display-safe assistant narration authored inside a subagent lane. */ +export interface V2ChatNarrationActivity { + kind: 'narration' + parentId: string + delta: string +} + +export type V2ChatActivity = V2ChatNodeActivity | V2ChatNarrationActivity + +interface ToolEventPayload { + toolCallId?: unknown + toolName?: unknown + arguments?: unknown + output?: unknown + partial?: unknown + phase?: unknown + status?: unknown + success?: unknown + ui?: { hidden?: unknown; internal?: unknown } | null +} + +interface ToolProjection { + id?: string + label?: string + parentId?: string + state?: ActivityState + status?: string + visibility: 'pending' | 'visible' | 'hidden' + pendingState?: ActivityState + pendingStatus?: string +} + +interface AgentProjection { + id: string + label: string + parentId?: string + state: ActivityState + emitted: boolean +} + +interface ProjectedToolState { + state: ActivityState + status: string +} + +interface DeferredWorkspaceFile { + call: ToolEventPayload + result?: ToolEventPayload +} + +const ERROR_STATUSES = new Set([ + MothershipStreamV1ToolStatus.error, + MothershipStreamV1ToolStatus.cancelled, + MothershipStreamV1ToolStatus.rejected, +]) +const MAIN_SPAN = 'main' +const WORKSPACE_FILE_TOOL = 'workspace_file' +const FILE_SUBAGENT = 'file' + +/** + * Request-local projection of the private Mothership stream onto the public + * activity tree. Raw span/tool ids, arguments, results, errors, and thinking + * never cross this boundary. + */ +export class ChatActivityProjector { + private readonly calls = new Map() + private readonly agentsByKey = new Map() + private readonly agents: AgentProjection[] = [] + private deferredWorkspaceFile?: DeferredWorkspaceFile + private nextToolId = 1 + private nextAgentId = 1 + + project(event: StreamEvent): V2ChatActivity[] { + const activities: V2ChatActivity[] = [] + + if (this.captureDeferredWorkspaceFileResult(event)) return activities + + const absorbsWorkspaceFile = this.absorbsDeferredWorkspaceFile(event) + if (this.deferredWorkspaceFile && !absorbsWorkspaceFile && this.breaksDeferral(event)) { + activities.push(...this.flushDeferredWorkspaceFile()) + } + if (absorbsWorkspaceFile) this.hideDeferredWorkspaceFile() + + if (this.deferWorkspaceFileCall(event)) return activities + + switch (event.type) { + case MothershipStreamV1EventType.span: + activities.push(...this.projectSpan(event.payload, event.scope)) + break + case MothershipStreamV1EventType.text: + activities.push(...this.projectText(event.payload, event.scope)) + break + case MothershipStreamV1EventType.tool: + activities.push(...this.projectTool(event.payload, event.scope)) + break + } + + return activities + } + + /** Settle every public row before the route sends its terminal envelope. */ + finish(outcome: 'complete' | 'error'): V2ChatActivity[] { + const activities: V2ChatActivity[] = [] + + if (this.deferredWorkspaceFile) { + const deferred = this.flushDeferredWorkspaceFile() + const last = deferred.at(-1) + // A deferred call was never visible. If it already completed, expose only + // its terminal snapshot; otherwise the normal settlement below closes it. + if (last?.kind === 'tool' && last.state !== 'running') activities.push(last) + } + + for (const projection of this.calls.values()) { + if ( + projection.visibility !== 'visible' || + !projection.id || + !projection.label || + projection.state !== 'running' + ) { + continue + } + activities.push( + this.toolActivity(projection, { + state: outcome === 'complete' ? 'complete' : 'error', + status: + outcome === 'complete' + ? MothershipStreamV1ToolOutcome.success + : MothershipStreamV1ToolOutcome.error, + }) + ) + } + + // Children close before their parents, matching the visible activity tree. + for (const agent of [...this.agents].reverse()) { + if (!agent.emitted || agent.state !== 'running') continue + agent.state = outcome + activities.push(this.agentActivity(agent)) + } + + return activities + } + + private projectSpan(payload: unknown, scope?: MothershipStreamV1StreamScope): V2ChatActivity[] { + const span = record(payload) + if (span?.kind !== MothershipStreamV1SpanPayloadKind.subagent) return [] + if ( + span.event !== MothershipStreamV1SpanLifecycleEvent.start && + span.event !== MothershipStreamV1SpanLifecycleEvent.end + ) { + return [] + } + + const data = record(span.data) + const triggerToolCallId = + stringValue(scope?.parentToolCallId) ?? + stringValue(data?.tool_call_id) ?? + stringValue(data?.toolCallId) + const authoritativeAgent = stringValue(span.agent) + const resolved = this.ensureAgent(scope, authoritativeAgent, triggerToolCallId, false) + if (!resolved) return [] + const { agent, changed } = resolved + + if (span.event === MothershipStreamV1SpanLifecycleEvent.start) { + const stateChanged = agent.state !== 'running' + agent.state = 'running' + if (!agent.emitted || changed || stateChanged) { + agent.emitted = true + return [this.agentActivity(agent)] + } + return [] + } + + // A checkpoint pause is resumable, not a completed subagent run. + if (data?.pending === true) return [] + agent.state = stringValue(data?.error) ? 'error' : 'complete' + agent.emitted = true + return [this.agentActivity(agent)] + } + + private projectText(payload: unknown, scope?: MothershipStreamV1StreamScope): V2ChatActivity[] { + const text = record(payload) + if ( + !scope || + text?.channel !== MothershipStreamV1TextChannel.assistant || + typeof text.text !== 'string' || + !text.text + ) { + return [] + } + + const resolved = this.ensureAgent(scope, undefined, undefined, true) + if (!resolved) return [] + return [ + ...resolved.activities, + { kind: 'narration', parentId: resolved.agent.id, delta: text.text }, + ] + } + + private projectTool(payload: unknown, scope?: MothershipStreamV1StreamScope): V2ChatActivity[] { + if (!payload || typeof payload !== 'object') return [] + const tool = payload as ToolEventPayload + if (tool.phase === MothershipStreamV1ToolPhase.args_delta) return [] + if ( + tool.phase !== MothershipStreamV1ToolPhase.call && + tool.phase !== MothershipStreamV1ToolPhase.result + ) { + return [] + } + + const callId = stringValue(tool.toolCallId) + const toolName = stringValue(tool.toolName) + if (!callId || !toolName) return [] + + const catalog = getToolEntry(toolName) + if (catalog?.route === 'subagent') { + this.calls.set(callId, { visibility: 'hidden' }) + if ( + tool.phase !== MothershipStreamV1ToolPhase.call || + tool.partial === true || + tool.status === MothershipStreamV1ToolStatus.generating + ) { + return [] + } + return this.projectSubagentDispatch(callId, catalog.subagentId ?? toolName, scope) + } + + const existing = this.calls.get(callId) + if (existing?.visibility === 'hidden') return [] + + if (this.isHidden(toolName, tool)) { + this.calls.set(callId, { visibility: 'hidden' }) + return [] + } + + const activities: V2ChatActivity[] = [] + let parentId = existing?.parentId + if (scope) { + const resolved = this.ensureAgent(scope, undefined, undefined, true) + if (!resolved) { + this.calls.set(callId, { visibility: 'hidden' }) + return [] + } + activities.push(...resolved.activities) + parentId = resolved.agent.id + } + + if (tool.phase === MothershipStreamV1ToolPhase.result) { + const projectedState = toolState(tool) + if (!existing || existing.visibility !== 'visible' || !existing.label) { + this.calls.set(callId, { + label: existing?.label, + parentId, + visibility: 'pending', + pendingState: projectedState.state, + pendingStatus: projectedState.status, + }) + return activities + } + existing.parentId ??= parentId + existing.pendingState = projectedState.state + existing.pendingStatus = projectedState.status + activities.push(this.toolActivity(existing, projectedState)) + return activities + } + + const projection = existing ?? { visibility: 'pending' as const } + const toolArguments = record(tool.arguments) + const resolvedReadTargetName = + toolName === 'read' ? getReadTargetBlock(stringValue(toolArguments?.path))?.name : undefined + projection.label = getToolDisplayTitle(toolName, toolArguments, resolvedReadTargetName) + projection.parentId ??= parentId + + // Generating calls can later resolve to a hidden/internal tool. Wait for + // the authoritative call so the terminal never paints an orphan row. + if (tool.partial === true || tool.status === MothershipStreamV1ToolStatus.generating) { + this.calls.set(callId, projection) + return activities + } + + projection.visibility = 'visible' + projection.id ??= this.publicToolId() + this.calls.set(callId, projection) + const projectedState = toolState(tool) + activities.push( + this.toolActivity(projection, { + state: projection.pendingState ?? projectedState.state, + status: projection.pendingStatus ?? projectedState.status, + }) + ) + return activities + } + + private ensureAgent( + scope: MothershipStreamV1StreamScope | undefined, + authoritativeAgent?: string, + triggerToolCallId?: string, + emit = true + ): + | { + agent: AgentProjection + activities: V2ChatActivity[] + changed: boolean + } + | undefined { + if (!scope || scope.lane !== 'subagent') return undefined + const spanId = stringValue(scope.spanId) + const triggerId = triggerToolCallId ?? stringValue(scope.parentToolCallId) + const spanKey = spanId ? `span:${spanId}` : undefined + const callKey = triggerId ? `call:${triggerId}` : undefined + if (!spanKey && !callKey) return undefined + + let agent = + (spanKey ? this.agentsByKey.get(spanKey) : undefined) ?? + (callKey ? this.agentsByKey.get(callKey) : undefined) + if (!agent) { + agent = { + id: this.publicAgentId(), + label: getSubagentDisplayTitle(authoritativeAgent ?? scope.agentId ?? ''), + parentId: this.parentAgentId(scope, spanId), + state: 'running', + emitted: false, + } + this.agents.push(agent) + } + if (spanKey) this.agentsByKey.set(spanKey, agent) + if (callKey) this.agentsByKey.set(callKey, agent) + + let changed = false + if (authoritativeAgent) { + const label = getSubagentDisplayTitle(authoritativeAgent) + if (label !== agent.label) { + agent.label = label + changed = true + } + } + const parentId = this.parentAgentId(scope, spanId) + if (parentId && parentId !== agent.parentId) { + agent.parentId = parentId + changed = true + } + + const activities: V2ChatActivity[] = [] + if (emit && (!agent.emitted || changed)) { + agent.emitted = true + activities.push(this.agentActivity(agent)) + } + return { agent, activities, changed } + } + + private projectSubagentDispatch( + callId: string, + agentId: string, + scope?: MothershipStreamV1StreamScope + ): V2ChatActivity[] { + const activities: V2ChatActivity[] = [] + let parentId: string | undefined + if (scope) { + const parent = this.ensureAgent(scope, undefined, undefined, true) + if (parent) { + activities.push(...parent.activities) + parentId = parent.agent.id + } + } + + const key = `call:${callId}` + let agent = this.agentsByKey.get(key) + const label = getSubagentDisplayTitle(agentId) + if (!agent) { + agent = { + id: this.publicAgentId(), + label, + ...(parentId ? { parentId } : {}), + state: 'running', + emitted: false, + } + this.agentsByKey.set(key, agent) + this.agents.push(agent) + } + const changed = agent.label !== label || (!!parentId && agent.parentId !== parentId) + agent.label = label + agent.parentId ??= parentId + agent.state = 'running' + if (!agent.emitted || changed) { + agent.emitted = true + activities.push(this.agentActivity(agent)) + } + return activities + } + + private parentAgentId( + scope: MothershipStreamV1StreamScope, + ownSpanId?: string + ): string | undefined { + const parentSpanId = stringValue(scope.parentSpanId) + if (!parentSpanId || parentSpanId === MAIN_SPAN || parentSpanId === ownSpanId) return undefined + const key = `span:${parentSpanId}` + let parent = this.agentsByKey.get(key) + if (!parent) { + parent = { + id: this.publicAgentId(), + label: getSubagentDisplayTitle(''), + state: 'running', + emitted: false, + } + this.agentsByKey.set(key, parent) + this.agents.push(parent) + } + return parent.id + } + + private isHidden(toolName: string, tool: ToolEventPayload): boolean { + const catalog = getToolEntry(toolName) + return ( + tool.ui?.hidden === true || + tool.ui?.internal === true || + catalog?.hidden === true || + catalog?.internal === true || + isToolHiddenInUi(toolName) || + (toolName === 'read' && + stringValue(record(tool.arguments)?.path)?.startsWith('internal/tool-results/') === true) + ) + } + + private deferWorkspaceFileCall(event: StreamEvent): boolean { + if (event.type !== MothershipStreamV1EventType.tool || event.scope) return false + const tool = event.payload as ToolEventPayload + if ( + tool.phase !== MothershipStreamV1ToolPhase.call || + tool.toolName !== WORKSPACE_FILE_TOOL || + tool.partial === true || + tool.status === MothershipStreamV1ToolStatus.generating || + this.isHidden(WORKSPACE_FILE_TOOL, tool) + ) { + return false + } + this.deferredWorkspaceFile = { call: tool } + return true + } + + private captureDeferredWorkspaceFileResult(event: StreamEvent): boolean { + const deferred = this.deferredWorkspaceFile + if (!deferred || event.type !== MothershipStreamV1EventType.tool) return false + const tool = event.payload as ToolEventPayload + if ( + tool.phase !== MothershipStreamV1ToolPhase.result || + tool.toolName !== WORKSPACE_FILE_TOOL || + tool.toolCallId !== deferred.call.toolCallId + ) { + return false + } + deferred.result = tool + return true + } + + private absorbsDeferredWorkspaceFile(event: StreamEvent): boolean { + const deferred = this.deferredWorkspaceFile + if ( + !deferred || + event.type !== MothershipStreamV1EventType.span || + event.payload.kind !== MothershipStreamV1SpanPayloadKind.subagent || + event.payload.event !== MothershipStreamV1SpanLifecycleEvent.start + ) { + return false + } + const data = record(event.payload.data) + const agent = stringValue(event.payload.agent) ?? stringValue(event.scope?.agentId) + const triggerId = + stringValue(event.scope?.parentToolCallId) ?? + stringValue(data?.tool_call_id) ?? + stringValue(data?.toolCallId) + return agent === FILE_SUBAGENT && triggerId === deferred.call.toolCallId + } + + private hideDeferredWorkspaceFile(): void { + const deferred = this.deferredWorkspaceFile + if (!deferred) return + const callId = stringValue(deferred.call.toolCallId) + if (callId) this.calls.set(callId, { visibility: 'hidden' }) + this.deferredWorkspaceFile = undefined + } + + private flushDeferredWorkspaceFile(): V2ChatActivity[] { + const deferred = this.deferredWorkspaceFile + if (!deferred) return [] + this.deferredWorkspaceFile = undefined + return [ + ...this.projectTool(deferred.call), + ...(deferred.result ? this.projectTool(deferred.result) : []), + ] + } + + private breaksDeferral(event: StreamEvent): boolean { + if (event.type === MothershipStreamV1EventType.tool) { + const tool = event.payload as ToolEventPayload + return tool.phase !== MothershipStreamV1ToolPhase.args_delta + } + if (event.type === MothershipStreamV1EventType.text) { + return ( + event.payload.channel === MothershipStreamV1TextChannel.assistant && !!event.payload.text + ) + } + if (event.type === MothershipStreamV1EventType.span) { + return event.payload.kind === MothershipStreamV1SpanPayloadKind.subagent + } + return ( + event.type === MothershipStreamV1EventType.error || + event.type === MothershipStreamV1EventType.complete + ) + } + + private publicToolId(): string { + return `tool-${this.nextToolId++}` + } + + private publicAgentId(): string { + return `agent-${this.nextAgentId++}` + } + + private agentActivity(agent: AgentProjection): V2ChatNodeActivity { + return { + kind: 'subagent', + id: agent.id, + ...(agent.parentId ? { parentId: agent.parentId } : {}), + label: agent.label, + state: agent.state, + } + } + + private toolActivity( + projection: ToolProjection, + projectedState: ProjectedToolState + ): V2ChatNodeActivity { + projection.state = projectedState.state + projection.status = projectedState.status + return { + kind: 'tool', + id: projection.id!, + ...(projection.parentId ? { parentId: projection.parentId } : {}), + label: getToolStatusDisplayTitle(projection.label!, projectedState.status), + state: projectedState.state, + } + } +} + +function record(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value ? value : undefined +} + +function toolState(tool: ToolEventPayload): ProjectedToolState { + if (tool.phase === MothershipStreamV1ToolPhase.result) { + const outcome = resolveStreamToolOutcome({ + output: tool.output, + ...(typeof tool.status === 'string' ? { status: tool.status } : {}), + ...(typeof tool.success === 'boolean' ? { success: tool.success } : {}), + }) + return { + state: + outcome === MothershipStreamV1ToolOutcome.success || + outcome === MothershipStreamV1ToolOutcome.skipped + ? 'complete' + : 'error', + status: outcome, + } + } + const status = typeof tool.status === 'string' ? tool.status : 'running' + if (tool.status === MothershipStreamV1ToolStatus.success) return { state: 'complete', status } + if (tool.status === MothershipStreamV1ToolStatus.skipped) return { state: 'complete', status } + if (ERROR_STATUSES.has(status)) return { state: 'error', status } + return { state: 'running', status } +} diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts new file mode 100644 index 00000000000..09fd941b55a --- /dev/null +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -0,0 +1,1549 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAcquirePendingChatStream, + mockCheckAttributedUsageLimits, + mockCheckRateLimit, + mockClearFilePreviewSessions, + mockCleanupAbortMarker, + mockCreateRunSegment, + mockEnv, + mockEnvFlags, + mockFinalizeStream, + mockFireTitleGeneration, + mockGenerateId, + mockGetAccessibleCopilotChatContinuationMetadata, + mockIssueV2ChatContinuationToken, + mockPersistCopilotUserMessage, + mockPrepareV2ChatAttachments, + mockPublishStatusChanged, + mockPublisherClose, + mockPublisherFlush, + mockPublisherPublish, + mockRegisterActiveStream, + mockReleasePendingChatStream, + mockResetBuffer, + mockResolveOrCreateChat, + mockRequestExplicitStreamAbort, + mockResolveBillingAttribution, + mockResolveSystemBillingAttribution, + mockResolveWorkspaceAccess, + mockRunWorkspaceChat, + mockScheduleBufferCleanup, + mockScheduleFilePreviewSessionCleanup, + mockStartAbortPoller, + mockStreamWriter, + mockTurnOnComplete, + mockTurnOnError, + mockUnregisterActiveStream, + mockVerifyV2ChatContinuationToken, + mockV2ApiGateError, +} = vi.hoisted(() => ({ + mockAcquirePendingChatStream: vi.fn(), + mockCheckAttributedUsageLimits: vi.fn(), + mockCheckRateLimit: vi.fn(), + mockClearFilePreviewSessions: vi.fn(), + mockCleanupAbortMarker: vi.fn(), + mockCreateRunSegment: vi.fn(), + mockEnv: { COPILOT_API_KEY: 'deployment-mothership-key' as string | undefined }, + mockEnvFlags: { isAuthDisabled: false }, + mockFinalizeStream: vi.fn(), + mockFireTitleGeneration: vi.fn(), + mockGenerateId: vi.fn(), + mockGetAccessibleCopilotChatContinuationMetadata: vi.fn(), + mockIssueV2ChatContinuationToken: vi.fn(), + mockPersistCopilotUserMessage: vi.fn(), + mockPrepareV2ChatAttachments: vi.fn(), + mockPublishStatusChanged: vi.fn(), + mockPublisherClose: vi.fn(), + mockPublisherFlush: vi.fn(), + mockPublisherPublish: vi.fn(), + mockRegisterActiveStream: vi.fn(), + mockReleasePendingChatStream: vi.fn(), + mockResetBuffer: vi.fn(), + mockResolveOrCreateChat: vi.fn(), + mockRequestExplicitStreamAbort: vi.fn(), + mockResolveBillingAttribution: vi.fn(), + mockResolveSystemBillingAttribution: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockRunWorkspaceChat: vi.fn(), + mockScheduleBufferCleanup: vi.fn(), + mockScheduleFilePreviewSessionCleanup: vi.fn(), + mockStartAbortPoller: vi.fn(), + mockStreamWriter: vi.fn(), + mockTurnOnComplete: vi.fn(), + mockTurnOnError: vi.fn(), + mockUnregisterActiveStream: vi.fn(), + mockVerifyV2ChatContinuationToken: vi.fn(), + mockV2ApiGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: mockV2ApiGateError, +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + checkAttributedUsageLimits: mockCheckAttributedUsageLimits, + resolveBillingAttribution: mockResolveBillingAttribution, + resolveSystemBillingAttribution: mockResolveSystemBillingAttribution, +})) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + createRunSegment: mockCreateRunSegment, +})) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + getAccessibleCopilotChatContinuationMetadata: mockGetAccessibleCopilotChatContinuationMetadata, + resolveOrCreateChat: mockResolveOrCreateChat, +})) + +vi.mock('@/lib/copilot/chat/turn-persistence', () => ({ + buildCopilotTurnOnComplete: () => mockTurnOnComplete, + buildCopilotTurnOnError: () => mockTurnOnError, + persistCopilotUserMessage: mockPersistCopilotUserMessage, +})) + +vi.mock('@/lib/copilot/chat-status', () => ({ + chatPubSub: { publishStatusChanged: mockPublishStatusChanged }, +})) + +vi.mock('@/lib/copilot/headless/workspace-chat', () => ({ + runWorkspaceChat: mockRunWorkspaceChat, + publicChatUsageLimitMessage: (content: string) => { + const match = /^(.+)<\/usage_upgrade>$/.exec(content) + if (!match) return null + return (JSON.parse(match[1]) as { message: string }).message + }, + toPublicChatResult: ( + result: { content: string; usage?: { prompt: number; completion: number } }, + continuationToken: string + ) => ({ + content: result.content, + continuationToken, + usage: result.usage + ? { + prompt: result.usage.prompt, + completion: result.usage.completion, + total: result.usage.prompt + result.usage.completion, + } + : {}, + }), +})) + +vi.mock('@/lib/copilot/headless/attachments', () => ({ + prepareV2ChatAttachments: mockPrepareV2ChatAttachments, +})) + +vi.mock('@/lib/copilot/headless/continuation-token', () => ({ + issueV2ChatContinuationToken: mockIssueV2ChatContinuationToken, + verifyV2ChatContinuationToken: mockVerifyV2ChatContinuationToken, +})) + +vi.mock('@/lib/copilot/request/session/explicit-abort', () => ({ + requestExplicitStreamAbort: mockRequestExplicitStreamAbort, +})) + +vi.mock('@/lib/copilot/request/lifecycle/finalize', () => ({ + finalizeStream: mockFinalizeStream, +})) + +vi.mock('@/lib/copilot/request/lifecycle/start', () => ({ + fireTitleGeneration: mockFireTitleGeneration, +})) + +vi.mock('@/lib/copilot/request/session', () => ({ + AbortReason: { UserStop: 'user_stop:abortActiveStream' }, + StreamWriter: mockStreamWriter, + acquirePendingChatStream: mockAcquirePendingChatStream, + clearFilePreviewSessions: mockClearFilePreviewSessions, + cleanupAbortMarker: mockCleanupAbortMarker, + encodeSSEComment: (comment: string) => new TextEncoder().encode(`: ${comment}\n\n`), + encodeSSEEnvelope: (value: unknown) => + new TextEncoder().encode(`data: ${JSON.stringify(value)}\n\n`), + registerActiveStream: mockRegisterActiveStream, + releasePendingChatStream: mockReleasePendingChatStream, + resetBuffer: mockResetBuffer, + scheduleBufferCleanup: mockScheduleBufferCleanup, + scheduleFilePreviewSessionCleanup: mockScheduleFilePreviewSessionCleanup, + SSE_RESPONSE_HEADERS: { 'Content-Type': 'text/event-stream' }, + startAbortPoller: mockStartAbortPoller, + unregisterActiveStream: mockUnregisterActiveStream, +})) + +vi.mock('@/lib/core/config/env', () => ({ env: mockEnv })) +vi.mock('@/lib/core/config/env-flags', () => mockEnvFlags) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) + +vi.mock('@sim/utils/id', () => ({ generateId: mockGenerateId })) + +import { MAX_V2_CHAT_BODY_BYTES } from '@/lib/api/contracts/v2/chat' +import { POST } from '@/app/api/v2/chat/route' + +const RATE_LIMIT = { + allowed: true, + userId: 'key-owner-1', + keyType: 'personal' as const, + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-05T12:00:00.000Z'), +} + +const personalAttribution = { + actorUserId: 'key-owner-1', + workspaceId: 'workspace-1', + billedAccountUserId: 'payer-1', + organizationId: null, + billingEntity: { type: 'user' as const, id: 'payer-1' }, + billingPeriod: { + start: '2026-08-01T00:00:00.000Z', + end: '2026-09-01T00:00:00.000Z', + }, + payerSubscription: null, +} + +const systemAttribution = { + ...personalAttribution, + actorUserId: 'workspace-billed-account', +} + +function callChat(body: Record, headers: Record = {}) { + return POST( + createMockRequest( + 'POST', + body, + { 'Content-Type': 'application/json', 'x-api-key': 'caller-platform-key', ...headers }, + 'http://localhost:3000/api/v2/chat' + ) + ) +} + +function parseSse(stream: string): Record[] { + return stream + .split('\n') + .filter((line) => line.startsWith('data: ') && line !== 'data: [DONE]') + .map((line) => JSON.parse(line.slice('data: '.length)) as Record) +} + +describe('POST /api/v2/chat', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnv.COPILOT_API_KEY = 'deployment-mothership-key' + mockEnvFlags.isAuthDisabled = false + mockGenerateId + .mockReset() + .mockReturnValueOnce('message-1') + .mockReturnValueOnce('execution-1') + .mockReturnValueOnce('run-1') + .mockReturnValue('generated-extra') + mockResolveOrCreateChat.mockResolvedValue({ + chatId: 'chat-1', + chat: { id: 'chat-1', type: 'mothership', title: null }, + conversationHistory: [], + isNew: true, + }) + mockStreamWriter.mockImplementation(function MockStreamWriter() { + return { + close: mockPublisherClose, + flush: mockPublisherFlush, + publish: mockPublisherPublish, + sawComplete: false, + } + }) + mockIssueV2ChatContinuationToken.mockReturnValue('continuation-new') + mockGetAccessibleCopilotChatContinuationMetadata.mockResolvedValue(null) + mockVerifyV2ChatContinuationToken.mockReturnValue({ valid: false }) + mockPrepareV2ChatAttachments.mockReturnValue({ success: true, attachments: [] }) + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockV2ApiGateError.mockResolvedValue(null) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockResolveBillingAttribution.mockResolvedValue(personalAttribution) + mockResolveSystemBillingAttribution.mockResolvedValue(systemAttribution) + mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false }) + mockAcquirePendingChatStream.mockResolvedValue(true) + mockClearFilePreviewSessions.mockResolvedValue(undefined) + mockCleanupAbortMarker.mockResolvedValue(undefined) + mockCreateRunSegment.mockResolvedValue({ id: 'run-1' }) + mockFinalizeStream.mockResolvedValue(undefined) + mockPersistCopilotUserMessage.mockResolvedValue(undefined) + mockPublisherClose.mockResolvedValue(undefined) + mockPublisherFlush.mockResolvedValue(undefined) + mockReleasePendingChatStream.mockResolvedValue(undefined) + mockResetBuffer.mockResolvedValue(undefined) + mockRequestExplicitStreamAbort.mockResolvedValue(undefined) + mockScheduleBufferCleanup.mockResolvedValue(undefined) + mockScheduleFilePreviewSessionCleanup.mockResolvedValue(undefined) + mockStartAbortPoller.mockReturnValue(0) + mockRunWorkspaceChat.mockImplementation(async (input) => { + input.onInitialStreamAccepted?.() + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: 'Hello from Sim' }, + }) + return { + success: true, + content: 'Hello from Sim', + contentBlocks: [], + toolCalls: [], + usage: { prompt: 8, completion: 3 }, + } + }) + }) + + it('streams a personal-key chat and bills its authenticated actor', async () => { + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'What is here?' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('text/event-stream') + expect(response.headers.get('x-ratelimit-remaining')).toBe('99') + expect(stream).toContain('"type":"session"') + expect(stream).toContain('"continuationToken":"continuation-new"') + expect(stream).toContain('"chatId":"chat-1"') + expect(stream).toContain('"delta":"Hello from Sim"') + expect(stream).toContain('"type":"complete"') + expect(stream).toContain('data: [DONE]') + + expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ + actorUserId: 'key-owner-1', + workspaceId: 'workspace-1', + }) + expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUserId: 'key-owner-1', + actorUserId: 'key-owner-1', + workspaceId: 'workspace-1', + billingAttribution: personalAttribution, + readOnly: false, + }) + ) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( + expect.objectContaining({ + credentialType: 'personal', + readOnly: false, + persistence: 'sim', + }) + ) + expect(mockRunWorkspaceChat.mock.calls[0][0]).not.toHaveProperty('apiKey') + expect(mockAcquirePendingChatStream).toHaveBeenCalledWith('chat-1', 'message-1') + expect(mockRegisterActiveStream).toHaveBeenCalledWith( + 'message-1', + expect.any(AbortController), + expect.any(AbortController) + ) + expect(mockStartAbortPoller).toHaveBeenCalledWith('message-1', expect.any(AbortController), { + requestId: 'request-1', + chatId: 'chat-1', + userStopController: expect.any(AbortController), + }) + expect(mockUnregisterActiveStream).toHaveBeenCalledWith('message-1') + expect(mockReleasePendingChatStream).toHaveBeenCalledWith('chat-1', 'message-1') + expect(mockCleanupAbortMarker).toHaveBeenCalledWith('message-1') + expect(mockResolveOrCreateChat).toHaveBeenCalledWith({ + userId: 'key-owner-1', + workspaceId: 'workspace-1', + model: 'claude-opus-4-8', + type: 'mothership', + }) + expect(mockPublishStatusChanged).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + chatId: 'chat-1', + type: 'created', + }) + expect(mockCreateRunSegment).toHaveBeenCalledWith({ + id: 'run-1', + executionId: 'execution-1', + chatId: 'chat-1', + userId: 'key-owner-1', + workspaceId: 'workspace-1', + streamId: 'message-1', + model: null, + requestContext: { requestId: 'request-1', source: 'v2_chat' }, + }) + expect(mockResetBuffer).toHaveBeenCalledWith('message-1') + expect(mockClearFilePreviewSessions).toHaveBeenCalledWith('message-1') + expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith({ + chatId: 'chat-1', + userMessageId: 'message-1', + message: 'What is here?', + contexts: undefined, + workspaceId: 'workspace-1', + notifyWorkspaceStatus: true, + }) + expect(mockPublisherPublish).toHaveBeenCalledWith({ + type: 'session', + payload: { kind: 'chat', chatId: 'chat-1' }, + }) + expect(mockPublisherPublish).toHaveBeenCalledWith({ + type: 'text', + payload: { channel: 'assistant', text: 'Hello from Sim' }, + }) + expect(mockFinalizeStream).toHaveBeenCalledWith( + expect.objectContaining({ success: true, content: 'Hello from Sim' }), + expect.any(Object), + 'run-1', + 'success', + 'request-1' + ) + expect(mockFireTitleGeneration).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'chat-1', + isNewChat: true, + message: 'What is here?', + workspaceId: 'workspace-1', + }) + ) + expect(mockPublisherClose).toHaveBeenCalledTimes(1) + expect(mockScheduleBufferCleanup).toHaveBeenCalledWith('message-1') + expect(mockScheduleFilePreviewSessionCleanup).toHaveBeenCalledWith('message-1') + }) + + it('passes validated resource and slash contexts to workspace chat', async () => { + const contexts = [ + { kind: 'workflow', workflowId: 'workflow-1', label: 'Release' }, + { kind: 'skill', skillId: 'skill-1', label: 'review' }, + { kind: 'mcp', serverId: 'mcp-1', label: 'Docs' }, + ] + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Use @Release and /review with /Docs', + contexts, + }) + await response.text() + + expect(response.status).toBe(200) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith(expect.objectContaining({ contexts })) + }) + + it('rejects malformed or unsupported public context variants', async () => { + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Use this', + contexts: [{ kind: 'folder', folderId: 'folder-1', label: 'Private folder' }], + }) + + expect(response.status).toBe(400) + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('fails with a retryable conflict before exposing a session when the chat lease is busy', async () => { + mockAcquirePendingChatStream.mockResolvedValueOnce(false) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: { + code: 'CONFLICT', + message: 'A response is already in progress for this chat', + }, + }) + expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() + expect(mockRegisterActiveStream).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + expect(mockReleasePendingChatStream).not.toHaveBeenCalled() + }) + + it('does not issue a session token or start Mothership before the chat lease is acquired', async () => { + let acquire!: (value: boolean) => void + mockAcquirePendingChatStream.mockReturnValueOnce( + new Promise((resolve) => { + acquire = resolve + }) + ) + + const pendingResponse = callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + + acquire(true) + const response = await pendingResponse + const stream = await response.text() + expect(stream).toContain('"type":"session"') + expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1) + }) + + it('does not expose the continuation token until Go accepts the initial stream', async () => { + let accept!: () => void + let settle!: () => void + mockFireTitleGeneration.mockImplementationOnce( + ({ publisher }: { publisher: { publish: (event: unknown) => void } }) => { + publisher.publish({ + type: 'session', + payload: { kind: 'title', title: 'Release investigation' }, + }) + } + ) + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + accept = () => input.onInitialStreamAccepted?.() + settle = () => + resolve({ + success: true, + content: 'Done', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) + const reader = response.body!.getReader() + let firstReadSettled = false + const firstRead = reader.read().then((result) => { + firstReadSettled = true + return result + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(firstReadSettled).toBe(false) + + accept() + const first = await firstRead + const acceptedSession = new TextDecoder().decode(first.value) + expect(acceptedSession).toContain('"type":"session"') + expect(acceptedSession).toContain('"continuationToken":"continuation-new"') + expect(acceptedSession).toContain('"title":"Release investigation"') + expect(mockPublisherPublish).toHaveBeenCalledWith({ + type: 'session', + payload: { kind: 'title', title: 'Release investigation' }, + }) + + settle() + while (!(await reader.read()).done) { + // Drain the completion so route cleanup can release its lease. + } + await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) + }) + + it('projects a title generated after session acceptance onto the public stream', async () => { + let publishTitle!: (event: unknown) => void + mockFireTitleGeneration.mockImplementationOnce( + ({ publisher }: { publisher: { publish: (event: unknown) => void } }) => { + publishTitle = publisher.publish + } + ) + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + input.onInitialStreamAccepted?.() + publishTitle({ + type: 'session', + payload: { kind: 'title', title: 'Deployment failure' }, + }) + return { + success: true, + content: 'Done', + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'What failed?' }) + const events = parseSse(await response.text()) + + expect(events).toContainEqual({ + type: 'session', + chatId: 'chat-1', + title: 'Deployment failure', + }) + }) + + it('does not hold the Go leg on run-segment creation but waits before finalizing it', async () => { + let resolveRunSegment!: () => void + let resolveChat!: () => void + mockCreateRunSegment.mockReturnValueOnce( + new Promise((resolve) => { + resolveRunSegment = () => resolve({ id: 'run-1' }) + }) + ) + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + input.onInitialStreamAccepted?.() + resolveChat = () => + resolve({ + success: true, + content: 'Done', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Continue' }) + await vi.waitFor(() => expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1)) + + resolveChat() + await new Promise((resolve) => setImmediate(resolve)) + expect(mockFinalizeStream).not.toHaveBeenCalled() + + resolveRunSegment() + expect(await response.text()).toContain('"type":"complete"') + expect(mockFinalizeStream).toHaveBeenCalledTimes(1) + }) + + it('keeps a synced turn working when run-segment creation fails', async () => { + mockCreateRunSegment.mockRejectedValueOnce(new Error('run table unavailable')) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Continue' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(stream).toContain('"type":"complete"') + expect(mockFinalizeStream).toHaveBeenCalledTimes(1) + }) + + it('surfaces a pre-acceptance failure without exposing a continuation token', async () => { + mockRunWorkspaceChat.mockResolvedValueOnce({ + success: false, + content: '', + contentBlocks: [], + toolCalls: [], + error: 'workspace setup failed', + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) + const stream = await response.text() + + expect(stream).not.toContain('"type":"session"') + expect(stream).toContain('"code":"INTERNAL_ERROR"') + }) + + it('enables the subtractive query policy only when explicitly requested', async () => { + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Only inspect this workspace', + readOnly: true, + }) + await response.text() + + expect(response.status).toBe(200) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith(expect.objectContaining({ readOnly: true })) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( + expect.objectContaining({ credentialType: 'personal', readOnly: true }) + ) + }) + + it('continues a legacy Go-only chat without exposing or partially persisting it', async () => { + mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ + valid: true, + chatId: 'private-chat-id', + }) + mockIssueV2ChatContinuationToken.mockReturnValueOnce('continuation-refreshed') + mockGenerateId.mockReset().mockReturnValue('message-followup') + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Tell me more', + continuationToken: 'continuation-old', + }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(mockVerifyV2ChatContinuationToken).toHaveBeenCalledWith('continuation-old', { + workspaceId: 'workspace-1', + authorizationUserId: 'key-owner-1', + credentialType: 'personal', + readOnly: false, + }) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith({ + chatId: 'private-chat-id', + workspaceId: 'workspace-1', + authorizationUserId: 'key-owner-1', + credentialType: 'personal', + readOnly: false, + }) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'private-chat-id', + messageId: 'message-followup', + }) + ) + expect(stream).toContain('"continuationToken":"continuation-refreshed"') + expect(stream).not.toContain('private-chat-id') + expect(mockGetAccessibleCopilotChatContinuationMetadata).toHaveBeenCalledWith( + 'private-chat-id', + 'key-owner-1' + ) + expect(mockStreamWriter).not.toHaveBeenCalled() + expect(mockPersistCopilotUserMessage).not.toHaveBeenCalled() + expect(mockPublishStatusChanged).not.toHaveBeenCalled() + }) + + it('continues an existing persisted personal chat with UI replay enabled', async () => { + mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ + valid: true, + chatId: 'shared-chat-1', + }) + mockIssueV2ChatContinuationToken.mockReturnValueOnce('continuation-refreshed') + mockGetAccessibleCopilotChatContinuationMetadata.mockResolvedValueOnce({ + id: 'shared-chat-1', + userId: 'key-owner-1', + workflowId: null, + workspaceId: 'workspace-1', + type: 'mothership', + title: 'Existing chat', + hasMessages: true, + mcpServerIds: ['mcp-history'], + }) + mockGenerateId + .mockReset() + .mockReturnValueOnce('message-followup') + .mockReturnValueOnce('execution-followup') + .mockReturnValueOnce('run-followup') + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Continue', + continuationToken: 'continuation-old', + contexts: [{ kind: 'mcp', serverId: 'mcp-current', label: 'Current' }], + }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(stream).toContain('"chatId":"shared-chat-1"') + expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'shared-chat-1', + userMessageId: 'message-followup', + message: 'Continue', + contexts: [{ kind: 'mcp', serverId: 'mcp-current', label: 'Current' }], + }) + ) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + mcpServerIds: ['mcp-history'], + contexts: [{ kind: 'mcp', serverId: 'mcp-current', label: 'Current' }], + }) + ) + expect(mockCreateRunSegment).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'run-followup', + executionId: 'execution-followup', + chatId: 'shared-chat-1', + }) + ) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( + expect.objectContaining({ chatId: 'shared-chat-1', persistence: 'sim' }) + ) + }) + + it.each([ + ['missing or deleted', null], + [ + 'the wrong type', + { + id: 'synced-chat-1', + userId: 'key-owner-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + type: 'copilot', + title: 'Workflow chat', + hasMessages: true, + }, + ], + [ + 'from another workspace', + { + id: 'synced-chat-1', + userId: 'key-owner-1', + workflowId: null, + workspaceId: 'workspace-2', + type: 'mothership', + title: 'Other workspace', + hasMessages: true, + }, + ], + ])('rejects an explicitly Sim-persisted continuation when its row is %s', async (_case, chat) => { + mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ + valid: true, + chatId: 'synced-chat-1', + persistence: 'sim', + }) + mockGetAccessibleCopilotChatContinuationMetadata.mockResolvedValueOnce(chat) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Continue', + continuationToken: 'continuation-sim', + }) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Chat not found' }, + }) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('fails closed before billing or Mothership for an invalid continuation token', async () => { + mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ valid: false }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'steal history', + continuationToken: 'tampered-or-cross-owner-token', + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: { code: 'BAD_REQUEST', message: 'Invalid or expired continuation token' }, + }) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('validates inline attachments and forwards only the server-mapped Mothership shape', async () => { + const publicAttachment = { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'aGk=', + } + const mothershipAttachment = { + type: 'document', + filename: 'notes.txt', + source: { type: 'base64', media_type: 'text/plain', data: 'aGk=' }, + } + mockPrepareV2ChatAttachments.mockReturnValueOnce({ + success: true, + attachments: [mothershipAttachment], + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Read this', + attachments: [publicAttachment], + }) + await response.text() + + expect(response.status).toBe(200) + expect(mockPrepareV2ChatAttachments).toHaveBeenCalledWith([publicAttachment]) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ fileAttachments: [mothershipAttachment] }) + ) + }) + + it('normalizes an attachment-only turn to a neutral upstream prompt', async () => { + mockPrepareV2ChatAttachments.mockReturnValueOnce({ + success: true, + attachments: [ + { + type: 'document', + filename: 'notes.txt', + source: { type: 'base64', media_type: 'text/plain', data: 'aGk=' }, + }, + ], + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: ' ', + attachments: [{ name: 'notes.txt', mediaType: 'text/plain', data: 'aGk=' }], + }) + await response.text() + + expect(response.status).toBe(200) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ prompt: 'Please inspect the attached file(s).' }) + ) + expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Please inspect the attached file(s).' }) + ) + }) + + it('returns a typed HTTP error before billing when attachment validation fails', async () => { + mockPrepareV2ChatAttachments.mockReturnValueOnce({ + success: false, + error: { + code: 'UNSUPPORTED_MEDIA_TYPE', + message: 'Attachment "clip.mp4" has unsupported media type video/mp4', + }, + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Watch this', + attachments: [{ name: 'clip.mp4', mediaType: 'video/mp4', data: 'AAAA' }], + }) + + expect(response.status).toBe(415) + expect((await response.json()).error.code).toBe('UNSUPPORTED_MEDIA_TYPE') + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('returns the v2 payload-too-large envelope for an oversized raw body', async () => { + const response = await callChat( + { workspaceId: 'workspace-1', prompt: 'hello' }, + { 'Content-Length': String(MAX_V2_CHAT_BODY_BYTES + 1) } + ) + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ + error: { + code: 'PAYLOAD_TOO_LARGE', + message: `Request body exceeds the ${MAX_V2_CHAT_BODY_BYTES}-byte limit`, + }, + }) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('forwards Mothership text events as deltas without prefix guessing', async () => { + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + input.onInitialStreamAccepted?.() + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: 'a' }, + }) + // This delta starts with all prior output. Treating events as possibly + // cumulative would incorrectly emit only "bc" here. + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: 'abc' }, + }) + return { + success: true, + content: 'aabc', + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + + expect(stream).toContain('"delta":"a"') + expect(stream).toContain('"delta":"abc"') + expect(stream).not.toContain('"delta":"bc"') + }) + + it('projects scoped assistant narration without merging it into the public answer', async () => { + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + input.onInitialStreamAccepted?.() + await input.onEvent?.({ + type: 'span', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-dispatch', + spanId: 'private-span', + parentSpanId: 'main', + }, + payload: { kind: 'subagent', event: 'start', agent: 'research' }, + }) + await input.onEvent?.({ + type: 'text', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-dispatch', + spanId: 'private-span', + parentSpanId: 'main', + }, + payload: { channel: 'assistant', text: 'Scoped progress.' }, + }) + await input.onEvent?.({ + type: 'span', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-dispatch', + spanId: 'private-span', + parentSpanId: 'main', + }, + payload: { kind: 'subagent', event: 'end', agent: 'research' }, + }) + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: 'public final delta' }, + }) + return { + success: true, + content: 'public final delta', + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + const events = parseSse(stream) + const activities = events.filter((event) => event.type === 'activity') + const answerText = events.filter((event) => event.type === 'text') + + expect(answerText).toEqual([{ type: 'text', delta: 'public final delta' }]) + expect(activities).toEqual([ + { + type: 'activity', + data: { + kind: 'subagent', + id: 'agent-1', + label: 'Research Agent', + state: 'running', + }, + }, + { + type: 'activity', + data: { kind: 'narration', parentId: 'agent-1', delta: 'Scoped progress.' }, + }, + { + type: 'activity', + data: { + kind: 'subagent', + id: 'agent-1', + label: 'Research Agent', + state: 'complete', + }, + }, + ]) + expect(stream).not.toContain('private-dispatch') + expect(stream).not.toContain('private-span') + }) + + it('projects a display-safe nested activity tree without private stream data', async () => { + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + input.onInitialStreamAccepted?.() + await input.onEvent?.({ + type: 'text', + payload: { channel: 'thinking', text: 'Inspecting the workspace' }, + }) + await input.onEvent?.({ + type: 'tool', + payload: { + phase: 'call', + toolCallId: 'private-tool-id', + toolName: 'read', + arguments: { secret: 'never-forward-me' }, + executor: 'sim', + mode: 'async', + }, + }) + await input.onEvent?.({ + type: 'tool', + payload: { + phase: 'call', + toolCallId: 'hidden-tool-id', + toolName: 'private_hidden_tool', + arguments: { secret: 'hidden-call-secret' }, + executor: 'sim', + mode: 'async', + ui: { hidden: true }, + }, + }) + await input.onEvent?.({ + type: 'tool', + payload: { + phase: 'result', + toolCallId: 'hidden-tool-id', + toolName: 'private_hidden_tool', + output: { secret: 'hidden-result-secret' }, + success: true, + executor: 'sim', + mode: 'async', + }, + }) + await input.onEvent?.({ + type: 'tool', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { + phase: 'call', + toolCallId: 'scoped-tool-id', + toolName: 'private_scoped_tool', + arguments: { secret: 'scoped-secret' }, + executor: 'sim', + mode: 'async', + }, + }) + await input.onEvent?.({ + type: 'tool', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { + phase: 'result', + toolCallId: 'scoped-tool-id', + toolName: 'private_scoped_tool', + output: { secret: 'scoped-result-secret' }, + success: true, + executor: 'sim', + mode: 'async', + }, + }) + await input.onEvent?.({ + type: 'span', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { kind: 'subagent', event: 'start', agent: 'research' }, + }) + await input.onEvent?.({ + type: 'text', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { channel: 'thinking', text: 'private subagent reasoning' }, + }) + await input.onEvent?.({ + type: 'span', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { kind: 'subagent', event: 'end', agent: 'research' }, + }) + await input.onEvent?.({ + type: 'tool', + payload: { + phase: 'result', + toolCallId: 'private-tool-id', + toolName: 'read', + output: { secret: 'never-forward-me' }, + success: true, + executor: 'sim', + mode: 'async', + }, + }) + return { + success: true, + content: 'Done', + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + + expect(stream).toContain('"type":"complete"') + expect(stream).toContain('Done') + expect(stream).toContain('"type":"activity"') + expect(stream).toContain('"label":"Reading file"') + expect(stream).toContain('"label":"Read file"') + expect(stream).toContain('"label":"Research Agent"') + expect(stream).toContain('"label":"Private Scoped Tool"') + expect(stream).toContain('"parentId":"agent-1"') + expect(stream).toContain('"state":"running"') + expect(stream).toContain('"state":"complete"') + expect(stream.match(/"type":"activity"/g)).toHaveLength(6) + expect(stream).not.toContain('Inspecting the workspace') + expect(stream).not.toContain('private-tool-id') + expect(stream).not.toContain('private_hidden_tool') + expect(stream).not.toContain('private_scoped_tool') + expect(stream).not.toContain('private-research-span') + expect(stream).not.toContain('never-forward-me') + expect(stream).not.toContain('scoped-secret') + expect(stream).not.toContain('scoped-result-secret') + expect(stream).not.toContain('private subagent reasoning') + }) + + it('authorizes a workspace key as its creator but executes and bills as the system actor', async () => { + mockGenerateId + .mockReset() + .mockReturnValueOnce('chat-1') + .mockReturnValueOnce('message-1') + .mockReturnValue('generated-extra') + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT, + keyType: 'workspace', + workspaceId: 'workspace-1', + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Summarize it' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'key-owner-1', keyType: 'workspace' }), + 'key-owner-1', + 'workspace-1', + 'read' + ) + expect(mockResolveSystemBillingAttribution).toHaveBeenCalledWith('workspace-1') + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( + expect.objectContaining({ credentialType: 'workspace', readOnly: false }) + ) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUserId: 'key-owner-1', + actorUserId: 'workspace-billed-account', + billingAttribution: systemAttribution, + sharedWorkspaceCredential: true, + }) + ) + expect(stream).not.toContain('"chatId":"chat-1"') + expect(mockResolveOrCreateChat).not.toHaveBeenCalled() + expect(mockStreamWriter).not.toHaveBeenCalled() + expect(mockPersistCopilotUserMessage).not.toHaveBeenCalled() + expect(mockPublishStatusChanged).not.toHaveBeenCalled() + }) + + it('routes a workspace-key abort by its owner while preserving the billing actor body', async () => { + mockGenerateId + .mockReset() + .mockReturnValueOnce('chat-1') + .mockReturnValueOnce('message-1') + .mockReturnValue('generated-extra') + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT, + keyType: 'workspace', + workspaceId: 'workspace-1', + }) + mockRunWorkspaceChat.mockResolvedValueOnce({ + success: false, + content: '', + contentBlocks: [], + toolCalls: [], + error: 'upstream failed', + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Summarize it' }) + await response.text() + + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUserId: 'key-owner-1', + actorUserId: 'workspace-billed-account', + billingAttribution: systemAttribution, + }) + ) + expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith({ + streamId: 'message-1', + userId: 'workspace-billed-account', + routingUserId: 'key-owner-1', + chatId: 'chat-1', + workspaceId: 'workspace-1', + }) + }) + + it('supports the auth-disabled self-host principal while keeping upstream auth server-owned', async () => { + const anonymousAttribution = { + ...personalAttribution, + actorUserId: 'anonymous', + } + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT, + userId: 'anonymous', + keyType: 'personal', + }) + mockEnvFlags.isAuthDisabled = true + mockResolveBillingAttribution.mockResolvedValue(anonymousAttribution) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'What is here?' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'anonymous', keyType: undefined }), + 'anonymous', + 'workspace-1', + 'read' + ) + expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ + actorUserId: 'anonymous', + workspaceId: 'workspace-1', + }) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUserId: 'anonymous', + actorUserId: 'anonymous', + billingAttribution: anonymousAttribution, + }) + ) + expect(stream).toContain('"chatId":"chat-1"') + expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ chatId: 'chat-1', message: 'What is here?' }) + ) + }) + + it('returns 402 before opening a stream or calling Mothership when usage is exhausted', async () => { + mockCheckAttributedUsageLimits.mockResolvedValue({ + isExceeded: true, + message: 'Organization usage limit exceeded', + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + + expect(response.status).toBe(402) + expect(await response.json()).toEqual({ + error: { code: 'USAGE_LIMIT_EXCEEDED', message: 'Organization usage limit exceeded' }, + }) + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('surfaces a raced or self-hosted upstream 402 as a structured stream error', async () => { + const upgrade = + '{"reason":"usage_limit","action":"increase_limit","message":"Ask an org admin."}' + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: upgrade }, + }) + return { + success: true, + content: upgrade, + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(stream).toContain('"code":"USAGE_LIMIT_EXCEEDED"') + expect(stream).toContain('Ask an org admin.') + expect(stream).not.toContain('') + expect(stream).not.toContain('"type":"complete"') + }) + + it('rejects a cross-workspace key before resolving a payer', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'API key is not authorized for this workspace', + }) + + const response = await callChat({ workspaceId: 'workspace-2', prompt: 'hello' }) + + expect(response.status).toBe(403) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('returns a clear 503 when the deployment has no Mothership key', async () => { + mockEnv.COPILOT_API_KEY = undefined + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + + expect(response.status).toBe(503) + expect(await response.json()).toEqual({ + error: { + code: 'SERVICE_UNAVAILABLE', + message: 'Sim Chat is not configured on this deployment', + }, + }) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + }) + + it('does not leak an upstream failure body and explicitly stops detached generation', async () => { + mockRunWorkspaceChat.mockResolvedValueOnce({ + success: false, + content: '', + contentBlocks: [], + toolCalls: [], + error: 'upstream secret response body', + errors: ['provider internal detail'], + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + + expect(stream).toContain('"code":"INTERNAL_ERROR"') + expect(stream).toContain('"message":"Chat request failed"') + expect(stream).not.toContain('upstream secret response body') + expect(stream).not.toContain('provider internal detail') + expect(mockRequestExplicitStreamAbort).toHaveBeenCalledTimes(1) + expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith({ + streamId: 'message-1', + userId: 'key-owner-1', + routingUserId: 'key-owner-1', + chatId: 'chat-1', + workspaceId: 'workspace-1', + }) + }) + + it('rejects caller-controlled identity, model, and provider fields', async () => { + for (const forbidden of [ + { userId: 'forged-user' }, + { model: 'caller-model' }, + { provider: 'caller-provider' }, + { chatId: 'raw-private-chat-id' }, + { conversationId: 'raw-private-chat-id' }, + ]) { + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'hello', + ...forbidden, + }) + expect(response.status).toBe(400) + } + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('returns the shared v2 auth error before parsing the body', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 0, + remaining: 0, + resetAt: new Date(), + error: 'Invalid API key', + }) + + const response = await callChat({}) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + expect(mockV2ApiGateError).not.toHaveBeenCalled() + }) + + it('stops local work, marks Go once, and retains the lease until the lifecycle settles', async () => { + const teardownOrder: string[] = [] + let settle!: () => void + let lifecycleSignal: AbortSignal | undefined + let userStopSignal: AbortSignal | undefined + mockRequestExplicitStreamAbort.mockImplementationOnce(async () => { + teardownOrder.push('go-abort') + }) + mockReleasePendingChatStream.mockImplementationOnce(async () => { + teardownOrder.push('release') + }) + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + lifecycleSignal = input.abortSignal + userStopSignal = input.userStopSignal + input.onInitialStreamAccepted?.() + settle = () => + resolve({ + success: false, + cancelled: true, + content: '', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + const request = new NextRequest('http://localhost:3000/api/v2/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'caller-platform-key', + }, + body: JSON.stringify({ workspaceId: 'workspace-1', prompt: 'keep going' }), + }) + + const response = await POST(request) + const reader = response.body!.getReader() + await reader.read() + await reader.cancel('test_disconnect') + + await vi.waitFor(() => expect(mockRequestExplicitStreamAbort).toHaveBeenCalledTimes(1)) + expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith({ + streamId: 'message-1', + userId: 'key-owner-1', + routingUserId: 'key-owner-1', + chatId: 'chat-1', + workspaceId: 'workspace-1', + }) + expect(lifecycleSignal?.aborted).toBe(false) + expect(userStopSignal?.aborted).toBe(true) + expect(userStopSignal?.reason).toBe('user_stop:abortActiveStream') + expect(mockReleasePendingChatStream).not.toHaveBeenCalled() + + settle() + await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) + expect(teardownOrder).toEqual(['go-abort', 'release']) + expect(mockUnregisterActiveStream).toHaveBeenCalledTimes(1) + expect(mockCleanupAbortMarker).toHaveBeenCalledWith('message-1') + }) + + it('does not start a lifecycle when Stop wins before workspace chat begins', async () => { + mockRegisterActiveStream.mockImplementationOnce( + ( + _streamId: string, + _lifecycleController: AbortController, + userStopController: AbortController + ) => userStopController.abort('user_stop:abortActiveStream') + ) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'keep going' }) + expect(await response.text()).toBe('') + + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + expect(mockUnregisterActiveStream).toHaveBeenCalledWith('message-1') + expect(mockReleasePendingChatStream).toHaveBeenCalledWith('chat-1', 'message-1') + expect(mockCleanupAbortMarker).toHaveBeenCalledWith('message-1') + }) + + it('still stops local work and retains the lease when the Go abort marker fails', async () => { + let settle!: () => void + let lifecycleSignal: AbortSignal | undefined + let userStopSignal: AbortSignal | undefined + mockRequestExplicitStreamAbort.mockRejectedValueOnce(new Error('marker unavailable')) + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + lifecycleSignal = input.abortSignal + userStopSignal = input.userStopSignal + input.onInitialStreamAccepted?.() + settle = () => + resolve({ + success: true, + content: 'settled naturally', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + + const request = new NextRequest('http://localhost:3000/api/v2/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'caller-platform-key', + }, + body: JSON.stringify({ workspaceId: 'workspace-1', prompt: 'keep going' }), + }) + const response = await POST(request) + const reader = response.body!.getReader() + await reader.read() + await reader.cancel('test_disconnect') + + await vi.waitFor(() => expect(mockRequestExplicitStreamAbort).toHaveBeenCalledTimes(1)) + expect(lifecycleSignal?.aborted).toBe(false) + expect(userStopSignal?.aborted).toBe(true) + expect(userStopSignal?.reason).toBe('user_stop:abortActiveStream') + expect(mockReleasePendingChatStream).not.toHaveBeenCalled() + + settle() + await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) + }) +}) diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts new file mode 100644 index 00000000000..cb99ea7ab07 --- /dev/null +++ b/apps/sim/app/api/v2/chat/route.ts @@ -0,0 +1,691 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { MAX_V2_CHAT_BODY_BYTES, v2ChatContract } from '@/lib/api/contracts/v2/chat' +import { parseRequest } from '@/lib/api/server' +import { + checkAttributedUsageLimits, + resolveBillingAttribution, + resolveSystemBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import { createRunSegment } from '@/lib/copilot/async-runs/repository' +import { + getAccessibleCopilotChatContinuationMetadata, + resolveOrCreateChat, +} from '@/lib/copilot/chat/lifecycle' +import { + buildCopilotTurnOnComplete, + buildCopilotTurnOnError, + persistCopilotUserMessage, +} from '@/lib/copilot/chat/turn-persistence' +import { chatPubSub } from '@/lib/copilot/chat-status' +import { + MothershipStreamV1EventType, + MothershipStreamV1SessionKind, + MothershipStreamV1TextChannel, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { RequestTraceV1Outcome } from '@/lib/copilot/generated/request-trace-v1' +import { prepareV2ChatAttachments } from '@/lib/copilot/headless/attachments' +import { + issueV2ChatContinuationToken, + verifyV2ChatContinuationToken, +} from '@/lib/copilot/headless/continuation-token' +import { + publicChatUsageLimitMessage, + runWorkspaceChat, + toPublicChatResult, +} from '@/lib/copilot/headless/workspace-chat' +import { finalizeStream } from '@/lib/copilot/request/lifecycle/finalize' +import { fireTitleGeneration } from '@/lib/copilot/request/lifecycle/start' +import { + AbortReason, + acquirePendingChatStream, + cleanupAbortMarker, + clearFilePreviewSessions, + encodeSSEComment, + encodeSSEEnvelope, + registerActiveStream, + releasePendingChatStream, + resetBuffer, + SSE_RESPONSE_HEADERS, + StreamWriter, + scheduleBufferCleanup, + scheduleFilePreviewSessionCleanup, + startAbortPoller, + unregisterActiveStream, +} from '@/lib/copilot/request/session' +import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' +import type { OrchestratorResult } from '@/lib/copilot/request/types' +import { env } from '@/lib/core/config/env' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { ChatActivityProjector, type V2ChatActivity } from '@/app/api/v2/chat/activity' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + rateLimitHeaders, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +export const maxDuration = 3600 +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const logger = createLogger('V2ChatAPI') +const encoder = new TextEncoder() +const HEARTBEAT_INTERVAL_MS = 15_000 +const ATTACHMENT_ONLY_PROMPT = 'Please inspect the attached file(s).' +const V2_CHAT_TITLE_MODEL = 'claude-opus-4-8' + +interface SyncedChat { + chat: { title?: string | null } | null + isNewChat: boolean + mcpServerIds: string[] +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError' +} + +/** POST /api/v2/chat — normal workspace chat with opaque continuation over SSE. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + let acquiredChatId: string | undefined + let acquiredStreamId: string | undefined + let streamOwnsLock = false + + try { + const rateLimit = await checkRateLimit(request, 'copilot-chat') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const authenticatedUserId = rateLimit.userId! + const gate = await v2ApiGateError(authenticatedUserId) + if (gate) return gate + + const parsed = await parseRequest( + v2ChatContract, + request, + {}, + { + maxBodyBytes: MAX_V2_CHAT_BODY_BYTES, + validationErrorResponse: v2ValidationError, + invalidJsonResponse: () => + v2Error('BAD_REQUEST', 'Request body must be valid JSON', { + headers: rateLimitHeaders(rateLimit), + }), + } + ) + if (!parsed.success) { + return parsed.response.status === 413 + ? v2Error( + 'PAYLOAD_TOO_LARGE', + `Request body exceeds the ${MAX_V2_CHAT_BODY_BYTES}-byte limit`, + { headers: rateLimitHeaders(rateLimit) } + ) + : parsed.response + } + + const { workspaceId, prompt, continuationToken, readOnly, attachments, contexts } = + parsed.data.body + const credentialType = rateLimit.keyType === 'workspace' ? 'workspace' : 'personal' + const effectivePrompt = prompt.trim() ? prompt : ATTACHMENT_ONLY_PROMPT + // DISABLE_AUTH produces an anonymous pseudo-personal principal. It is not + // an API key, so the workspace's personal-key toggle must not reject it. + // Real personal keys retain the normal toggle on every hosted path. + const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit + const access = await resolveWorkspaceAccess( + accessPrincipal, + authenticatedUserId, + workspaceId, + 'read' + ) + if (access) return v2WorkspaceAccessError(access) + + // Sim API keys authenticate this public boundary only. Every Sim -> Go + // request uses the deployment-owned key so hosted and self-hosted billing + // semantics cannot be changed by a caller-controlled credential. + if (!env.COPILOT_API_KEY?.trim()) { + return v2Error('SERVICE_UNAVAILABLE', 'Sim Chat is not configured on this deployment', { + headers: rateLimitHeaders(rateLimit), + }) + } + + const continuation = continuationToken + ? await verifyV2ChatContinuationToken(continuationToken, { + workspaceId, + authorizationUserId: authenticatedUserId, + credentialType, + readOnly, + }) + : null + if (continuation && !continuation.valid) { + return v2Error('BAD_REQUEST', 'Invalid or expired continuation token', { + headers: rateLimitHeaders(rateLimit), + }) + } + + const shouldSyncChat = rateLimit.keyType === 'personal' + let continuedSyncedChat: SyncedChat | null = null + if (continuation?.valid) { + if (continuation.persistence === 'sim' && !shouldSyncChat) { + return v2Error('NOT_FOUND', 'Chat not found', { + headers: rateLimitHeaders(rateLimit), + }) + } + if (shouldSyncChat) { + const existing = await getAccessibleCopilotChatContinuationMetadata( + continuation.chatId, + authenticatedUserId + ) + const matchesPersistedChat = + existing?.type === 'mothership' && existing.workspaceId === workspaceId + /** + * Tokens issued before Sim-side persistence can point at a Go-only + * chat. A deleted/missing row follows the same path: keep the valid + * continuation working, but do not create a partial UI transcript + * without its earlier turns. + */ + if (matchesPersistedChat && existing) { + continuedSyncedChat = { + chat: { title: existing.title }, + isNewChat: !existing.hasMessages, + mcpServerIds: existing.mcpServerIds, + } + } else if (continuation.persistence === 'sim') { + return v2Error('NOT_FOUND', 'Chat not found', { + headers: rateLimitHeaders(rateLimit), + }) + } + } + } + + const preparedAttachments = prepareV2ChatAttachments(attachments) + if (!preparedAttachments.success) { + return v2Error(preparedAttachments.error.code, preparedAttachments.error.message, { + headers: rateLimitHeaders(rateLimit), + }) + } + + /** + * Match public workflow execution: a personal key identifies its human + * actor; a shared workspace key uses the atomically resolved system actor + * and payer. Authorization above always remains bound to the key owner. + */ + const billingAttribution = + rateLimit.keyType === 'workspace' + ? await resolveSystemBillingAttribution(workspaceId) + : await resolveBillingAttribution({ actorUserId: authenticatedUserId, workspaceId }) + const actorUserId = billingAttribution.actorUserId + + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.', + { headers: rateLimitHeaders(rateLimit) } + ) + } + + let syncedChat = continuedSyncedChat + let chatId: string + + if (continuation?.valid) { + chatId = continuation.chatId + } else if (shouldSyncChat) { + const created = await resolveOrCreateChat({ + userId: authenticatedUserId, + workspaceId, + model: V2_CHAT_TITLE_MODEL, + type: 'mothership', + }) + if (!created.chat || !created.chatId) { + throw new Error('Failed to create persisted v2 chat') + } + syncedChat = { + chat: created.chat, + isNewChat: created.conversationHistory.length === 0, + mcpServerIds: [], + } + chatId = created.chatId + chatPubSub?.publishStatusChanged({ workspaceId, chatId, type: 'created' }) + } else { + chatId = generateId() + } + + const messageId = generateId() + const executionId = syncedChat ? generateId() : undefined + const runId = syncedChat ? generateId() : undefined + const replayPublisher = syncedChat + ? new StreamWriter({ streamId: messageId, chatId, requestId }) + : null + const onTurnComplete = syncedChat + ? buildCopilotTurnOnComplete({ + chatId, + userMessageId: messageId, + requestId, + workspaceId, + notifyWorkspaceStatus: true, + }) + : undefined + const onTurnError = syncedChat + ? buildCopilotTurnOnError({ + chatId, + userMessageId: messageId, + requestId, + workspaceId, + notifyWorkspaceStatus: true, + }) + : undefined + const lifecycleAbortController = new AbortController() + const userStopController = new AbortController() + const chatStreamLockAcquired = await acquirePendingChatStream(chatId, messageId) + if (!chatStreamLockAcquired) { + return v2Error('CONFLICT', 'A response is already in progress for this chat', { + headers: rateLimitHeaders(rateLimit), + }) + } + acquiredChatId = chatId + acquiredStreamId = messageId + if (request.signal.aborted) { + await releasePendingChatStream(chatId, messageId) + acquiredChatId = undefined + acquiredStreamId = undefined + return v2Error('CLIENT_CLOSED_REQUEST', 'Chat request cancelled', { + headers: rateLimitHeaders(rateLimit), + }) + } + + const refreshedContinuationToken = await issueV2ChatContinuationToken({ + chatId, + workspaceId, + authorizationUserId: authenticatedUserId, + credentialType, + readOnly, + ...(syncedChat ? { persistence: 'sim' as const } : {}), + }) + if (request.signal.aborted) { + await releasePendingChatStream(chatId, messageId) + acquiredChatId = undefined + acquiredStreamId = undefined + return v2Error('CLIENT_CLOSED_REQUEST', 'Chat request cancelled', { + headers: rateLimitHeaders(rateLimit), + }) + } + let cancelled = false + let publicStreamOpen = false + let lifecycleStarted = false + let abortRequested = false + let allowExplicitAbort = true + let explicitAbortRequest: Promise | undefined + + const requestExplicitAbortOnce = () => { + if (!lifecycleStarted || !allowExplicitAbort) return undefined + if (!explicitAbortRequest) { + explicitAbortRequest = requestExplicitStreamAbort({ + streamId: messageId, + // Go scopes the live stream to its execution/billing actor, while Sim + // must choose the upstream environment from the API-key owner. Keeping + // those identities separate prevents an actor override from rerouting + // Stop without breaking Go's owner-scoped abort marker. + userId: actorUserId, + routingUserId: authenticatedUserId, + chatId, + workspaceId, + }).catch((error) => { + logger.warn(`[${requestId}] Failed to send explicit abort for v2 chat`, { + error: toError(error).message, + }) + }) + } + return explicitAbortRequest + } + + /** + * A disconnected public reader is an explicit stop request. Match the web + * UI's Stop path: stop Sim-side work, mark the detached Go execution, and + * keep draining the active Go leg so persistence settles before cleanup. + * The route owns the chat lease until that lifecycle has unwound. + */ + const abortLifecycle = () => { + abortRequested = true + requestExplicitAbortOnce() + if (allowExplicitAbort && !userStopController.signal.aborted) { + userStopController.abort(AbortReason.UserStop) + } + } + const onRequestAbort = () => abortLifecycle() + + if (request.signal.aborted) onRequestAbort() + else request.signal.addEventListener('abort', onRequestAbort, { once: true }) + + let heartbeatId: ReturnType | undefined + const stream = new ReadableStream({ + start(controller) { + publicStreamOpen = true + registerActiveStream(messageId, lifecycleAbortController, userStopController) + const abortPoller = startAbortPoller(messageId, lifecycleAbortController, { + requestId, + chatId, + userStopController, + }) + const send = (data: unknown): boolean => { + if (cancelled || !publicStreamOpen) return false + controller.enqueue(encodeSSEEnvelope(data)) + return true + } + const activityProjector = new ChatActivityProjector() + const sendActivities = (activities: V2ChatActivity[]) => { + for (const activity of activities) send({ type: 'activity', data: activity }) + } + + let sessionSent = false + let pendingTitle = syncedChat?.chat?.title?.trim() || undefined + let publishedTitle: string | undefined + let replayFinalized = false + let runSegmentPromise: Promise | undefined + const publishTitle = (title: string) => { + const next = title.trim() + if (!next) return + pendingTitle = next + if (!sessionSent || next === publishedTitle) return + if (send({ type: 'session', chatId, title: next })) publishedTitle = next + } + const sendSession = () => { + if (sessionSent) return + sessionSent = true + const sent = send({ + type: 'session', + continuationToken: refreshedContinuationToken, + requestId, + ...(syncedChat ? { chatId } : {}), + ...(pendingTitle ? { title: pendingTitle } : {}), + }) + if (sent && pendingTitle) publishedTitle = pendingTitle + } + heartbeatId = setInterval(() => { + if (!cancelled && publicStreamOpen) { + controller.enqueue(encodeSSEComment(`heartbeat ${new Date().toISOString()}`)) + } + }, HEARTBEAT_INTERVAL_MS) + + void (async () => { + try { + if (lifecycleAbortController.signal.aborted || userStopController.signal.aborted) { + return + } + + if (replayPublisher && syncedChat && executionId && runId) { + await Promise.all([resetBuffer(messageId), clearFilePreviewSessions(messageId)]) + runSegmentPromise = createRunSegment({ + id: runId, + executionId, + chatId, + userId: authenticatedUserId, + workspaceId, + streamId: messageId, + model: null, + requestContext: { requestId, source: 'v2_chat' }, + }).catch((error) => { + logger.warn(`[${requestId}] Failed to create v2 chat run segment`, { + error: getErrorMessage(error), + }) + }) + replayPublisher.publish({ + type: MothershipStreamV1EventType.session, + payload: { kind: MothershipStreamV1SessionKind.chat, chatId }, + }) + await replayPublisher.flush() + await persistCopilotUserMessage({ + chatId, + userMessageId: messageId, + message: effectivePrompt, + contexts, + workspaceId, + notifyWorkspaceStatus: true, + }) + fireTitleGeneration({ + chatId, + currentChat: syncedChat.chat, + isNewChat: syncedChat.isNewChat, + userId: authenticatedUserId, + message: effectivePrompt, + titleModel: V2_CHAT_TITLE_MODEL, + workspaceId, + billingAttribution, + requestId, + publisher: { + publish(event) { + replayPublisher.publish(event) + if ( + event.type === MothershipStreamV1EventType.session && + event.payload.kind === MothershipStreamV1SessionKind.title + ) { + publishTitle(event.payload.title) + } + }, + }, + }) + } + + lifecycleStarted = true + if (abortRequested) requestExplicitAbortOnce() + const result = await runWorkspaceChat({ + prompt: effectivePrompt, + authorizationUserId: authenticatedUserId, + actorUserId, + workspaceId, + chatId, + messageId, + requestId, + executionId, + runId, + billingAttribution, + readOnly, + sharedWorkspaceCredential: credentialType === 'workspace', + fileAttachments: preparedAttachments.attachments, + contexts, + mcpServerIds: syncedChat?.mcpServerIds, + abortSignal: lifecycleAbortController.signal, + userStopSignal: userStopController.signal, + onInitialStreamAccepted: sendSession, + onEvent: async (event) => { + replayPublisher?.publish(event) + sendActivities(activityProjector.project(event)) + if ( + event.type === MothershipStreamV1EventType.text && + event.payload.channel === MothershipStreamV1TextChannel.assistant && + !event.scope && + event.payload.text + ) { + const text = event.payload.text + if (!publicChatUsageLimitMessage(text)) { + send({ type: 'text', delta: text }) + } + } + }, + onComplete: onTurnComplete, + onError: onTurnError, + }) + + if (replayPublisher && runId) { + await runSegmentPromise + const replayOutcome = result.success + ? RequestTraceV1Outcome.success + : result.cancelled || + lifecycleAbortController.signal.aborted || + userStopController.signal.aborted || + request.signal.aborted + ? RequestTraceV1Outcome.cancelled + : RequestTraceV1Outcome.error + await finalizeStream(result, replayPublisher, runId, replayOutcome, requestId) + replayFinalized = true + } + + const upstreamUsageLimit = publicChatUsageLimitMessage(result.content) + if (upstreamUsageLimit) { + allowExplicitAbort = false + sendActivities(activityProjector.finish('error')) + send({ + type: 'error', + error: { + code: 'USAGE_LIMIT_EXCEEDED', + message: upstreamUsageLimit, + }, + }) + return + } + + if (!sessionSent) { + throw new Error('Mothership did not acknowledge the initial chat stream') + } + if ( + lifecycleAbortController.signal.aborted || + userStopController.signal.aborted || + request.signal.aborted || + result.cancelled + ) { + requestExplicitAbortOnce() + sendActivities(activityProjector.finish('error')) + send({ + type: 'error', + error: { code: 'CLIENT_CLOSED_REQUEST', message: 'Chat request cancelled' }, + }) + return + } + + if (!result.success) { + requestExplicitAbortOnce() + logger.error(`[${requestId}] V2 chat failed`, { + workspaceId, + error: result.error, + errors: result.errors, + }) + sendActivities(activityProjector.finish('error')) + send({ + type: 'error', + error: { + code: 'INTERNAL_ERROR', + message: 'Chat request failed', + }, + }) + return + } + + allowExplicitAbort = false + + sendActivities(activityProjector.finish('complete')) + send({ + type: 'complete', + data: toPublicChatResult(result, refreshedContinuationToken), + }) + if (!cancelled) controller.enqueue(encoder.encode('data: [DONE]\n\n')) + publicStreamOpen = false + } catch (error) { + const aborted = + lifecycleAbortController.signal.aborted || + userStopController.signal.aborted || + request.signal.aborted || + isAbortError(error) + const terminalResult: OrchestratorResult = { + success: false, + cancelled: aborted, + content: '', + contentBlocks: [], + toolCalls: [], + error: toError(error).message, + } + if (!replayFinalized) { + if (aborted) { + await onTurnComplete?.(terminalResult) + } else { + await onTurnError?.(toError(error), terminalResult) + } + if (replayPublisher && runId) { + try { + await runSegmentPromise + await finalizeStream( + terminalResult, + replayPublisher, + runId, + aborted ? RequestTraceV1Outcome.cancelled : RequestTraceV1Outcome.error, + requestId + ) + replayFinalized = true + } catch (finalizeError) { + logger.warn(`[${requestId}] Failed to finalize v2 replay stream`, { + error: getErrorMessage(finalizeError), + }) + } + } + } + if (!aborted) { + logger.error(`[${requestId}] V2 chat error`, { + workspaceId, + error: getErrorMessage(error, 'Unknown error'), + }) + } + requestExplicitAbortOnce() + sendActivities(activityProjector.finish('error')) + send({ + type: 'error', + error: { + code: aborted ? 'CLIENT_CLOSED_REQUEST' : 'INTERNAL_ERROR', + message: aborted ? 'Chat request cancelled' : 'Chat request failed', + }, + }) + } finally { + publicStreamOpen = false + allowExplicitAbort = false + if (heartbeatId) clearInterval(heartbeatId) + request.signal.removeEventListener('abort', onRequestAbort) + await explicitAbortRequest + clearInterval(abortPoller) + unregisterActiveStream(messageId) + await releasePendingChatStream(chatId, messageId) + await cleanupAbortMarker(messageId) + if (replayPublisher) { + try { + await replayPublisher.close() + } catch (error) { + logger.warn(`[${requestId}] Failed to flush v2 replay stream`, { + error: getErrorMessage(error), + }) + } + await scheduleBufferCleanup(messageId) + await scheduleFilePreviewSessionCleanup(messageId) + } + if (!cancelled) controller.close() + } + })() + }, + cancel(reason) { + cancelled = true + publicStreamOpen = false + if (heartbeatId) clearInterval(heartbeatId) + abortLifecycle() + }, + }) + streamOwnsLock = true + + return new Response(stream, { + headers: { + ...SSE_RESPONSE_HEADERS, + 'Cache-Control': 'private, no-store, no-transform', + ...rateLimitHeaders(rateLimit), + }, + }) + } catch (error) { + if (!streamOwnsLock && acquiredChatId && acquiredStreamId) { + await releasePendingChatStream(acquiredChatId, acquiredStreamId) + } + logger.error(`[${requestId}] Failed to start v2 chat`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/chats/[chatId]/route.test.ts b/apps/sim/app/api/v2/chats/[chatId]/route.test.ts new file mode 100644 index 00000000000..997739fa9a0 --- /dev/null +++ b/apps/sim/app/api/v2/chats/[chatId]/route.test.ts @@ -0,0 +1,372 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, flattenMockConditions, resetDbChainMock, schemaMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockEnvFlags, + mockGetAccessibleCopilotChatWithMessages, + mockIssueV2ChatContinuationToken, + mockPublishStatusChanged, + mockCaptureServerEvent, + mockReconcileChatStreamMarkers, + mockResolveWorkspaceAccess, + mockV2ApiGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockEnvFlags: { isAuthDisabled: false }, + mockGetAccessibleCopilotChatWithMessages: vi.fn(), + mockIssueV2ChatContinuationToken: vi.fn(), + mockPublishStatusChanged: vi.fn(), + mockCaptureServerEvent: vi.fn(), + mockReconcileChatStreamMarkers: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockV2ApiGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: mockV2ApiGateError, +})) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + getAccessibleCopilotChatWithMessages: mockGetAccessibleCopilotChatWithMessages, +})) + +vi.mock('@/lib/copilot/chat/stream-liveness', () => ({ + reconcileChatStreamMarkers: mockReconcileChatStreamMarkers, +})) + +vi.mock('@/lib/copilot/headless/continuation-token', () => ({ + issueV2ChatContinuationToken: mockIssueV2ChatContinuationToken, +})) + +vi.mock('@/lib/copilot/chat-status', () => ({ + chatPubSub: { publishStatusChanged: mockPublishStatusChanged }, +})) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: mockCaptureServerEvent, +})) + +vi.mock('@/lib/core/config/env-flags', () => mockEnvFlags) + +import { GET, PATCH } from '@/app/api/v2/chats/[chatId]/route' + +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'personal' as const, + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-07T13:00:00.000Z'), +} + +function buildChat(overrides: Record = {}) { + return { + id: 'chat-1', + userId: 'user-1', + workflowId: null, + workspaceId: 'workspace-1', + type: 'mothership', + title: 'Release plan', + conversationId: 'stream-stale', + resources: null, + createdAt: new Date('2026-08-07T11:00:00.000Z'), + updatedAt: new Date('2026-08-07T12:00:00.000Z'), + messages: [], + ...overrides, + } +} + +function callDetail(query = 'workspaceId=workspace-1') { + return GET(new NextRequest(`http://localhost:3000/api/v2/chats/chat-1?${query}`), { + params: Promise.resolve({ chatId: 'chat-1' }), + }) +} + +function callRename(body: Record) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/chats/chat-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ chatId: 'chat-1' }) } + ) +} + +describe('GET /api/v2/chats/[chatId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnvFlags.isAuthDisabled = false + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockV2ApiGateError.mockResolvedValue(null) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetAccessibleCopilotChatWithMessages.mockResolvedValue(buildChat()) + mockReconcileChatStreamMarkers.mockResolvedValue( + new Map([['chat-1', { chatId: 'chat-1', streamId: null, status: 'inactive' }]]) + ) + mockIssueV2ChatContinuationToken.mockResolvedValue('continuation-token') + }) + + it('rejects workspace keys before loading private chat history', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) + + const response = await callDetail() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Chat history requires a personal API key', + }, + }) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockGetAccessibleCopilotChatWithMessages).not.toHaveBeenCalled() + }) + + it('returns the workspace-access failure without loading the chat', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await callDetail() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { code: 'FORBIDDEN', message: 'Access denied' }, + }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + RATE_LIMIT, + 'user-1', + 'workspace-1', + 'read' + ) + expect(mockGetAccessibleCopilotChatWithMessages).not.toHaveBeenCalled() + }) + + it('treats the auth-disabled principal like a session principal for workspace access', async () => { + mockEnvFlags.isAuthDisabled = true + + const response = await callDetail() + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ keyType: undefined }), + 'user-1', + 'workspace-1', + 'read' + ) + }) + + it.each([ + ['an inaccessible chat', null], + ['a workflow-scoped chat', buildChat({ type: 'copilot' })], + ['a chat from another workspace', buildChat({ workspaceId: 'workspace-2' })], + ])('masks %s as the same not-found response', async (_case, chat) => { + mockGetAccessibleCopilotChatWithMessages.mockResolvedValue(chat) + + const response = await callDetail() + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Chat not found' }, + }) + expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() + expect(mockReconcileChatStreamMarkers).not.toHaveBeenCalled() + }) + + it('projects display-safe messages and reports the reconciled active marker', async () => { + mockGetAccessibleCopilotChatWithMessages.mockResolvedValue( + buildChat({ + messages: [ + { + id: 'message-user', + role: 'user', + content: 'Ship it', + timestamp: '2026-08-07T11:30:00.000Z', + contexts: [{ kind: 'workflow', label: 'Release', workflowId: 'workflow-1' }], + }, + { + id: 'message-assistant', + role: 'assistant', + content: 'Done', + timestamp: '2026-08-07T11:31:00.000Z', + requestId: 'request-private', + contentBlocks: [{ type: 'text', content: 'Done' }], + }, + { + id: 'message-system', + role: 'system', + content: 'private instructions', + timestamp: '2026-08-07T11:29:00.000Z', + }, + null, + ], + }) + ) + mockReconcileChatStreamMarkers.mockResolvedValueOnce( + new Map([['chat-1', { chatId: 'chat-1', streamId: 'stream-live', status: 'active' }]]) + ) + + const response = await callDetail('workspaceId=workspace-1&readOnly=true') + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual({ + id: 'chat-1', + title: 'Release plan', + active: true, + continuationToken: 'continuation-token', + messages: [ + { + id: 'message-user', + role: 'user', + content: 'Ship it', + timestamp: '2026-08-07T11:30:00.000Z', + }, + { + id: 'message-assistant', + role: 'assistant', + content: 'Done', + timestamp: '2026-08-07T11:31:00.000Z', + }, + ], + }) + expect(mockReconcileChatStreamMarkers).toHaveBeenCalledWith( + [{ chatId: 'chat-1', streamId: 'stream-stale' }], + { repairVerifiedStaleMarkers: true } + ) + }) + + it.each([ + ['true', true], + ['false', false], + ])('binds readOnly=%s into the minted continuation token', async (raw, expected) => { + const response = await callDetail(`workspaceId=workspace-1&readOnly=${raw}`) + + expect(response.status).toBe(200) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith({ + chatId: 'chat-1', + workspaceId: 'workspace-1', + authorizationUserId: 'user-1', + credentialType: 'personal', + readOnly: expected, + persistence: 'sim', + }) + }) +}) + +describe('PATCH /api/v2/chats/[chatId]', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockEnvFlags.isAuthDisabled = false + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockV2ApiGateError.mockResolvedValue(null) + mockResolveWorkspaceAccess.mockResolvedValue(null) + }) + + it('renames an owned chat and notifies the synchronized Home list', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'chat-1', workspaceId: 'workspace-1' }]) + + const response = await callRename({ + workspaceId: 'workspace-1', + title: 'Incident investigation', + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { id: 'chat-1', title: 'Incident investigation' }, + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + title: 'Incident investigation', + updatedAt: expect.any(Date), + lastSeenAt: expect.any(Date), + }) + const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + expect(conditions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'eq', + left: schemaMock.copilotChats.id, + right: 'chat-1', + }), + expect.objectContaining({ + type: 'eq', + left: schemaMock.copilotChats.userId, + right: 'user-1', + }), + expect.objectContaining({ + type: 'eq', + left: schemaMock.copilotChats.workspaceId, + right: 'workspace-1', + }), + expect.objectContaining({ + type: 'eq', + left: schemaMock.copilotChats.type, + right: 'mothership', + }), + expect.objectContaining({ + type: 'isNull', + column: schemaMock.copilotChats.deletedAt, + }), + ]) + ) + expect(mockPublishStatusChanged).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + chatId: 'chat-1', + type: 'renamed', + }) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'task_renamed', + { workspace_id: 'workspace-1' }, + { groups: { workspace: 'workspace-1' } } + ) + }) + + it('rejects workspace keys before touching private chat data', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) + + const response = await callRename({ workspaceId: 'workspace-1', title: 'Private title' }) + + expect(response.status).toBe(403) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('returns the workspace-access failure before updating the chat', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await callRename({ workspaceId: 'workspace-1', title: 'Private title' }) + + expect(response.status).toBe(403) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('masks missing, deleted, foreign, and non-mothership chats as not found', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const response = await callRename({ workspaceId: 'workspace-1', title: 'Private title' }) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Chat not found' }, + }) + expect(mockPublishStatusChanged).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/chats/[chatId]/route.ts b/apps/sim/app/api/v2/chats/[chatId]/route.ts new file mode 100644 index 00000000000..16b09ac2bf8 --- /dev/null +++ b/apps/sim/app/api/v2/chats/[chatId]/route.ts @@ -0,0 +1,158 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, isNull } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { v2GetChatContract, v2RenameChatContract } from '@/lib/api/contracts/v2/chats' +import { parseRequest } from '@/lib/api/server' +import { getAccessibleCopilotChatWithMessages } from '@/lib/copilot/chat/lifecycle' +import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' +import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' +import { chatPubSub } from '@/lib/copilot/chat-status' +import { issueV2ChatContinuationToken } from '@/lib/copilot/headless/continuation-token' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2ChatDetailAPI') +type ChatRouteContext = { params: Promise<{ chatId: string }> } + +/** GET /api/v2/chats/[chatId] — open one owned chat and mint a fresh resume token. */ +export const GET = withRouteHandler(async (request: NextRequest, context: ChatRouteContext) => { + try { + const rateLimit = await checkRateLimit(request, 'copilot-chat') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + if (rateLimit.keyType === 'workspace') { + return v2Error('FORBIDDEN', 'Chat history requires a personal API key') + } + + const parsed = await parseRequest(v2GetChatContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { chatId } = parsed.data.params + const { workspaceId, readOnly } = parsed.data.query + + const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit + const access = await resolveWorkspaceAccess(accessPrincipal, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const chat = await getAccessibleCopilotChatWithMessages(chatId, userId) + if (!chat || chat.type !== 'mothership' || chat.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Chat not found') + } + + const streamMarkers = await reconcileChatStreamMarkers( + [{ chatId: chat.id, streamId: chat.conversationId }], + { repairVerifiedStaleMarkers: true } + ) + const active = Boolean(streamMarkers.get(chat.id)?.streamId) + const continuationToken = await issueV2ChatContinuationToken({ + chatId: chat.id, + workspaceId, + authorizationUserId: userId, + credentialType: 'personal', + readOnly, + persistence: 'sim', + }) + const messages = (Array.isArray(chat.messages) ? chat.messages : []) + .filter((message): message is Record => Boolean(message)) + .map(normalizeMessage) + .filter((message) => message.role === 'user' || message.role === 'assistant') + .map(({ id, role, content, timestamp }) => ({ id, role, content, timestamp })) + + return v2Data( + { + id: chat.id, + title: chat.title, + messages, + continuationToken, + active, + }, + { rateLimit } + ) + } catch (error) { + logger.error('Failed to open v2 chat', { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/chats/[chatId] — rename one owned workspace chat. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: ChatRouteContext) => { + try { + const rateLimit = await checkRateLimit(request, 'copilot-chat') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + if (rateLimit.keyType === 'workspace') { + return v2Error('FORBIDDEN', 'Renaming chats requires a personal API key') + } + + const parsed = await parseRequest(v2RenameChatContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { chatId } = parsed.data.params + const { workspaceId, title } = parsed.data.body + + const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit + const access = await resolveWorkspaceAccess(accessPrincipal, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const now = new Date() + const [updated] = await db + .update(copilotChats) + .set({ title, updatedAt: now, lastSeenAt: now }) + .where( + and( + eq(copilotChats.id, chatId), + eq(copilotChats.userId, userId), + eq(copilotChats.workspaceId, workspaceId), + eq(copilotChats.type, 'mothership'), + isNull(copilotChats.deletedAt) + ) + ) + .returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId }) + + if (!updated) return v2Error('NOT_FOUND', 'Chat not found') + + if (updated.workspaceId) { + chatPubSub?.publishStatusChanged({ + workspaceId: updated.workspaceId, + chatId: updated.id, + type: 'renamed', + }) + captureServerEvent( + userId, + 'task_renamed', + { workspace_id: updated.workspaceId }, + { groups: { workspace: updated.workspaceId } } + ) + } + + return v2Data({ id: updated.id, title }, { rateLimit }) + } catch (error) { + logger.error('Failed to rename v2 chat', { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/chats/route.test.ts b/apps/sim/app/api/v2/chats/route.test.ts new file mode 100644 index 00000000000..b59eed21878 --- /dev/null +++ b/apps/sim/app/api/v2/chats/route.test.ts @@ -0,0 +1,225 @@ +/** + * @vitest-environment node + */ +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockEnvFlags, + mockReconcileChatStreamMarkers, + mockResolveWorkspaceAccess, + mockV2ApiGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockEnvFlags: { isAuthDisabled: false }, + mockReconcileChatStreamMarkers: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockV2ApiGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: mockV2ApiGateError, +})) + +vi.mock('@/lib/copilot/chat/stream-liveness', () => ({ + reconcileChatStreamMarkers: mockReconcileChatStreamMarkers, +})) + +vi.mock('@/lib/core/config/env-flags', () => mockEnvFlags) + +import { GET } from '@/app/api/v2/chats/route' + +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'personal' as const, + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-07T13:00:00.000Z'), +} + +function buildChat(overrides: Record = {}) { + return { + id: 'chat-1', + title: 'Release plan', + updatedAt: new Date('2026-08-07T12:00:00.000Z'), + pinned: true, + activeStreamId: 'stream-stale', + ...overrides, + } +} + +function callList(query = 'workspaceId=workspace-1') { + return GET(new NextRequest(`http://localhost:3000/api/v2/chats?${query}`)) +} + +describe('GET /api/v2/chats', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockEnvFlags.isAuthDisabled = false + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockV2ApiGateError.mockResolvedValue(null) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockReconcileChatStreamMarkers.mockImplementation( + async (candidates: Array<{ chatId: string; streamId: string | null }>) => + new Map( + candidates.map((candidate) => [ + candidate.chatId, + { + chatId: candidate.chatId, + streamId: candidate.streamId, + status: candidate.streamId ? 'active' : 'inactive', + }, + ]) + ) + ) + }) + + it('rejects workspace keys before reading private chat history', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) + + const response = await callList() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Chat history requires a personal API key', + }, + }) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('returns the workspace-access failure without querying chats', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await callList() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { code: 'FORBIDDEN', message: 'Access denied' }, + }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + RATE_LIMIT, + 'user-1', + 'workspace-1', + 'read' + ) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('treats the auth-disabled principal like a session principal for workspace access', async () => { + mockEnvFlags.isAuthDisabled = true + queueTableRows(schemaMock.copilotChats, []) + + const response = await callList() + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ keyType: undefined }), + 'user-1', + 'workspace-1', + 'read' + ) + }) + + it('bounds the SQL page, maps summaries, and derives active state from the live marker', async () => { + queueTableRows(schemaMock.copilotChats, [ + buildChat(), + buildChat({ + id: 'chat-2', + title: null, + updatedAt: new Date('2026-08-06T12:00:00.000Z'), + pinned: false, + activeStreamId: 'stream-live', + }), + buildChat({ id: 'chat-3' }), + ]) + mockReconcileChatStreamMarkers.mockResolvedValueOnce( + new Map([ + ['chat-1', { chatId: 'chat-1', streamId: null, status: 'inactive' }], + ['chat-2', { chatId: 'chat-2', streamId: 'stream-live', status: 'active' }], + ]) + ) + + const response = await callList('workspaceId=workspace-1&limit=2') + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual([ + { + id: 'chat-1', + title: 'Release plan', + updatedAt: '2026-08-07T12:00:00.000Z', + pinned: true, + active: false, + }, + { + id: 'chat-2', + title: null, + updatedAt: '2026-08-06T12:00:00.000Z', + pinned: false, + active: true, + }, + ]) + expect(body.nextCursor).toEqual(expect.any(String)) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(3) + expect(mockReconcileChatStreamMarkers).toHaveBeenCalledWith( + [ + { chatId: 'chat-1', streamId: 'stream-stale' }, + { chatId: 'chat-2', streamId: 'stream-live' }, + ], + { repairVerifiedStaleMarkers: true } + ) + }) + + it('replays its opaque cursor as a keyset bound', async () => { + queueTableRows(schemaMock.copilotChats, [buildChat(), buildChat({ id: 'chat-2' })]) + const first = await callList('workspaceId=workspace-1&limit=1') + const { nextCursor } = await first.json() + + queueTableRows(schemaMock.copilotChats, [ + buildChat({ + id: 'chat-2', + title: 'Older chat', + updatedAt: new Date('2026-08-06T12:00:00.000Z'), + pinned: false, + activeStreamId: null, + }), + ]) + const second = await callList( + `workspaceId=workspace-1&limit=1&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(second.status).toBe(200) + const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + expect(conditions.some((condition) => condition?.type === 'or')).toBe(true) + }) + + it('rejects a malformed cursor instead of restarting at the first page', async () => { + const response = await callList('workspaceId=workspace-1&cursor=not-a-cursor') + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toMatch(/cursor does not match/i) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/chats/route.ts b/apps/sim/app/api/v2/chats/route.ts new file mode 100644 index 00000000000..667f5c788f4 --- /dev/null +++ b/apps/sim/app/api/v2/chats/route.ts @@ -0,0 +1,139 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, isNull, sql } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2ChatSummary, v2ListChatsContract } from '@/lib/api/contracts/v2/chats' +import { + encodeKeyset, + keysetAfter, + keysetColumns, + listOrderBy, + numberKey, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' +import { parseRequest } from '@/lib/api/server' +import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + decodeSortedCursor, + encodeSortedCursor, + v2CursorList, + v2CursorSortError, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2ChatsAPI') +const CHAT_SORT = 'pinned:desc,updatedAt:desc' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +type ChatRow = { + id: string + title: string | null + updatedAt: Date + pinned: boolean + activeStreamId: string | null +} + +const pinnedRank = sql`case when ${copilotChats.pinned} then 1 else 0 end` +const CHAT_KEYS = [ + numberKey(pinnedRank, (row) => (row.pinned ? 1 : 0)), + timestampKey(copilotChats.updatedAt, (row) => row.updatedAt), + textKey(copilotChats.id, (row) => row.id), +] + +/** GET /api/v2/chats — bounded personal chat history for the terminal picker. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'copilot-chat') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + // A workspace key can be held by people other than its creator. Its + // creator's UI chats are private and must never become shared-key data. + if (rateLimit.keyType === 'workspace') { + return v2Error('FORBIDDEN', 'Chat history requires a personal API key') + } + + const parsed = await parseRequest( + v2ListChatsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, search, limit, cursor } = parsed.data.query + + const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit + const access = await resolveWorkspaceAccess(accessPrincipal, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const decoded = decodeSortedCursor(cursor, CHAT_SORT) + if (decoded.status === 'invalid') return v2CursorSortError() + const resumeAfter = + decoded.status === 'ok' ? keysetAfter(CHAT_KEYS, decoded.keys, 'desc') : undefined + if (resumeAfter === null) return v2CursorSortError() + + const rows = await db + .select({ + id: copilotChats.id, + title: copilotChats.title, + updatedAt: copilotChats.updatedAt, + pinned: copilotChats.pinned, + activeStreamId: copilotChats.conversationId, + }) + .from(copilotChats) + .where( + and( + eq(copilotChats.userId, userId), + eq(copilotChats.workspaceId, workspaceId), + eq(copilotChats.type, 'mothership'), + isNull(copilotChats.deletedAt), + searchFilter(copilotChats.title, search), + resumeAfter + ) + ) + .orderBy(...listOrderBy(keysetColumns(CHAT_KEYS), 'desc')) + .limit(limit + 1) + + const page = rows.slice(0, limit) + const streamMarkers = await reconcileChatStreamMarkers( + page.map((chat) => ({ chatId: chat.id, streamId: chat.activeStreamId })), + { repairVerifiedStaleMarkers: true } + ) + const data: V2ChatSummary[] = page.map((chat) => ({ + id: chat.id, + title: chat.title, + updatedAt: chat.updatedAt.toISOString(), + pinned: chat.pinned, + active: Boolean(streamMarkers.get(chat.id)?.streamId), + })) + + const last = page.at(-1) + const nextCursor = + rows.length > limit && last + ? encodeSortedCursor(CHAT_SORT, encodeKeyset(CHAT_KEYS, last)) + : null + + return v2CursorList(data, nextCursor, { rateLimit }) + } catch (error) { + logger.error('Failed to list v2 chats', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts new file mode 100644 index 00000000000..27cdb0a18a9 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts @@ -0,0 +1,87 @@ +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { v2GetWorkspaceContract } from '@/lib/api/contracts/v2/workspaces' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkspacesAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ workspaceId: string }> +} + +/** GET /api/v2/workspaces/[workspaceId] — Resolve a workspace id to its name. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workspace') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetWorkspaceContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.params + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const [row] = await db + .select({ + id: workspace.id, + name: workspace.name, + color: workspace.color, + logoUrl: workspace.logoUrl, + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + + if (!row) return v2Error('NOT_FOUND', 'Workspace not found') + + return v2Data( + { + workspace: { + id: row.id, + name: row.name, + color: row.color, + logoUrl: row.logoUrl, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }, + }, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error fetching workspace`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/cli/auth/cli-auth-view.test.tsx b/apps/sim/app/cli/auth/cli-auth-view.test.tsx index 01d908daf05..c4cd52dc5a3 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.test.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.test.tsx @@ -81,22 +81,22 @@ describe('CliAuthView workspace loading', () => { }) it('blocks Connect until the workspace list resolves', () => { - // The regression: while pending, the picker falls back to the personal - // option, so an early click approved a personal key when the same click a - // moment later would have bound the key to the user's workspace. + // The regression: while pending, the picker falls back to no default, so an + // early click approved a profile without the workspace that would appear a + // moment later. mockUseWorkspaces.mockReturnValue({ isPending: true, isError: false, data: undefined }) render() expect(connectButton().disabled).toBe(true) expect(container.textContent).toContain('Loading workspaces') - expect(container.textContent).not.toContain('No workspace (personal key)') + expect(container.textContent).not.toContain('No default workspace') }) it('does not present the personal-key wording as the answer while loading', () => { mockUseWorkspaces.mockReturnValue({ isPending: true, isError: false, data: undefined }) render() - expect(container.textContent).toContain('Checking which workspaces') + expect(container.textContent).toContain('Loading your workspaces') expect(container.textContent).not.toContain('Issues a personal key') }) @@ -106,10 +106,10 @@ describe('CliAuthView workspace loading', () => { expect(connectButton().disabled).toBe(false) expect(container.textContent).toContain('Acme') - expect(container.textContent).toContain('only reach Acme') + expect(container.textContent).toContain('personal key tied to your account') }) - it('binds the key to the workspace when the approver is an admin', () => { + it("keeps an admin's key personal while saving the selected workspace as its default", () => { mockUseWorkspaces.mockReturnValue(LOADED) render() act(() => { @@ -120,7 +120,7 @@ describe('CliAuthView workspace loading', () => { expect.objectContaining({ scope: 'platform', workspaceId: 'ws_admin', - bindKeyToWorkspace: true, + bindKeyToWorkspace: false, }), expect.anything() ) diff --git a/apps/sim/app/cli/auth/cli-auth-view.tsx b/apps/sim/app/cli/auth/cli-auth-view.tsx index 080b872f297..004057bd47d 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.tsx @@ -11,8 +11,8 @@ import { cliAuthParsers } from '@/app/cli/auth/search-params' import { useApproveCliAuth } from '@/hooks/queries/cli-auth' import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace' -/** Sentinel for the "not bound to a workspace" row; an empty string reads as unselected. */ -const PERSONAL_VALUE = '__personal__' +/** Sentinel for a profile without a default workspace; an empty string reads as unselected. */ +const NO_WORKSPACE_VALUE = '__no_workspace__' /** * The signed-in half of the CLI key handoff: a consent card that records the @@ -40,7 +40,7 @@ export function CliAuthView() { label: workspace.name, value: workspace.id, })) - return [...rows, { label: 'No workspace (personal key)', value: PERSONAL_VALUE }] + return [...rows, { label: 'No default workspace', value: NO_WORKSPACE_VALUE }] }, [workspaces.data]) if (!resolution.valid) { @@ -63,11 +63,10 @@ export function CliAuthView() { * Approval must wait for the workspace list. * * Until it arrives there is no selection to show, and the fallback would read - * as "No workspace (personal key)" — a real answer, not a pending one. Leaving - * Connect live through that window let a fast click approve a personal key - * with no default workspace, when a moment later the same click would have - * bound the key to the user's workspace. Blocking is the only way the card - * can promise what it is about to do. + * as "No default workspace" — a real answer, not a pending one. Leaving + * Connect live through that window let a fast click approve a key with no + * default workspace when the picker was about to select the user's workspace. + * Blocking is the only way the card can promise what it is about to do. */ const loadingWorkspaces = isPlatform && workspaces.isPending @@ -88,11 +87,6 @@ export function CliAuthView() { const workspaceId = selected ?? suggested ?? workspaces.data?.lastActiveWorkspaceId ?? null const chosen = workspaces.data?.workspaces.find((w) => w.id === workspaceId) - // Only an admin can bind a key to a workspace. Anything less still gets a - // usable credential — a personal key — but the card says which one before the - // click rather than after, so nothing unexpected lands in the config file. - const bindsToWorkspace = chosen?.permissions === 'admin' - return (

Default workspace

{loadingWorkspaces - ? 'Checking which workspaces you can issue a key for…' + ? 'Loading your workspaces…' : workspaces.isError ? 'Could not load your workspaces. Connecting still works and issues a personal key; reload to pick a default workspace.' - : bindsToWorkspace - ? `Issues a key that can only reach ${chosen.name}.` - : chosen - ? 'Issues a personal key tied to your account, defaulting to this workspace. Workspace-scoped keys need admin.' - : // No workspace picked, so none is sent and none becomes the - // profile default — promising one here would describe a - // grant that Connect is not about to make. - 'Issues a personal key tied to your account, with no default workspace.'} + : chosen + ? 'Issues a personal key tied to your account, defaulting to this workspace.' + : // No workspace picked, so none is sent and none becomes the + // profile default — promising one here would describe a + // grant that Connect is not about to make. + 'Issues a personal key tied to your account, with no default workspace.'}

)} @@ -151,10 +143,11 @@ export function CliAuthView() { request: request.request, challenge: request.challenge, scope: request.scope, - // The picked workspace travels either way — it is the terminal's - // default. Only `bindKeyToWorkspace` narrows the key itself. + // The picked workspace is the terminal profile's default. CLI + // login represents the signed-in person, so its key remains + // personal even when that person administers the workspace. ...(isPlatform && chosen ? { workspaceId: chosen.id } : {}), - bindKeyToWorkspace: isPlatform && bindsToWorkspace, + bindKeyToWorkspace: false, }, { onSuccess: () => router.push('/cli/auth/done') } ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 96e131e3ea2..7061cf5a76b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -8,6 +8,7 @@ export type { MothershipResource, MothershipResourceType, } from '@/lib/copilot/resources/types' +export { SUBAGENT_LABELS } from '@/lib/copilot/tools/subagent-display' /** Union of all valid context kind strings, derived from {@link ChatContext}. */ export type ChatContextKind = ChatContext['kind'] @@ -176,24 +177,3 @@ export interface ChatMessage { contexts?: ChatMessageContext[] requestId?: string } - -export const SUBAGENT_LABELS: Record = { - workflow: 'Workflow Agent', - debug: 'Debug Agent', - deploy: 'Deploy Agent', - auth: 'Auth Agent', - research: 'Research Agent', - knowledge: 'Knowledge Agent', - table: 'Table Agent', - custom_tool: 'Custom Tool Agent', - scout: 'Scout Agent', - search: 'Search Agent', - superagent: 'Superagent', - run: 'Run Agent', - agent: 'Tools Agent', - // `job` retained as a backward-compat alias so historical transcripts still render a label. - job: 'Job Agent', - file: 'File Agent', - media: 'Media Agent', - browser: 'Browser Agent', -} as const diff --git a/apps/sim/blocks/blocks/browser_use.ts b/apps/sim/blocks/blocks/browser_use.ts index afec00da53f..602829f7202 100644 --- a/apps/sim/blocks/blocks/browser_use.ts +++ b/apps/sim/blocks/blocks/browser_use.ts @@ -32,6 +32,8 @@ export const BrowserUseBlock: BlockConfig = { id: 'variables', title: 'Variables (Secrets)', type: 'table', + password: true, + required: false, columns: ['Key', 'Value'], }, { diff --git a/apps/sim/blocks/blocks/codepipeline.ts b/apps/sim/blocks/blocks/codepipeline.ts index 04f54231134..7b7d3f5603e 100644 --- a/apps/sim/blocks/blocks/codepipeline.ts +++ b/apps/sim/blocks/blocks/codepipeline.ts @@ -230,6 +230,7 @@ export const CodePipelineBlock: BlockConfig< id: 'approvalToken', title: 'Approval Token', type: 'short-input', + password: true, placeholder: 'Token from Get Pipeline State', condition: { field: 'operation', value: 'put_approval_result' }, required: { field: 'operation', value: 'put_approval_result' }, diff --git a/apps/sim/blocks/blocks/discord.ts b/apps/sim/blocks/blocks/discord.ts index 82c90160505..8f54fd5e6bb 100644 --- a/apps/sim/blocks/blocks/discord.ts +++ b/apps/sim/blocks/blocks/discord.ts @@ -297,6 +297,7 @@ export const DiscordBlock: BlockConfig = { id: 'webhookToken', title: 'Webhook Token', type: 'short-input', + password: true, placeholder: 'Enter webhook token', required: true, condition: { diff --git a/apps/sim/blocks/blocks/pi.ts b/apps/sim/blocks/blocks/pi.ts index a7eb65d60ae..bd9ad22b945 100644 --- a/apps/sim/blocks/blocks/pi.ts +++ b/apps/sim/blocks/blocks/pi.ts @@ -461,6 +461,7 @@ export const PiBlock: BlockConfig = { id: 'privateKey', title: 'Private Key', type: 'code', + password: true, paramVisibility: 'user-only', placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', required: { diff --git a/apps/sim/blocks/blocks/secrets_manager.ts b/apps/sim/blocks/blocks/secrets_manager.ts index 19fda449390..c3c00de9259 100644 --- a/apps/sim/blocks/blocks/secrets_manager.ts +++ b/apps/sim/blocks/blocks/secrets_manager.ts @@ -100,6 +100,7 @@ export const SecretsManagerBlock: BlockConfig = { id: 'secretValue', title: 'Secret Value', type: 'code', + password: true, placeholder: '{"username":"admin","password":"secret123"}', condition: { field: 'operation', value: ['create_secret', 'update_secret'] }, required: { field: 'operation', value: ['create_secret', 'update_secret'] }, diff --git a/apps/sim/blocks/blocks/sftp.ts b/apps/sim/blocks/blocks/sftp.ts index dc2fc15e224..453bf11c775 100644 --- a/apps/sim/blocks/blocks/sftp.ts +++ b/apps/sim/blocks/blocks/sftp.ts @@ -81,6 +81,7 @@ export const SftpBlock: BlockConfig = { id: 'privateKey', title: 'Private Key', type: 'code', + password: true, placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', condition: { field: 'authMethod', value: 'privateKey' }, dependsOn: ['authMethod'], diff --git a/apps/sim/blocks/blocks/ssh.ts b/apps/sim/blocks/blocks/ssh.ts index 11dfdbfdcf1..501d0c46288 100644 --- a/apps/sim/blocks/blocks/ssh.ts +++ b/apps/sim/blocks/blocks/ssh.ts @@ -91,6 +91,7 @@ export const SSHBlock: BlockConfig = { id: 'privateKey', title: 'Private Key', type: 'code', + password: true, placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', condition: { field: 'authMethod', value: 'privateKey' }, dependsOn: ['authMethod'], diff --git a/apps/sim/blocks/blocks/sts.ts b/apps/sim/blocks/blocks/sts.ts index 38b711b0c95..1abab52f5f9 100644 --- a/apps/sim/blocks/blocks/sts.ts +++ b/apps/sim/blocks/blocks/sts.ts @@ -103,6 +103,7 @@ export const STSBlock: BlockConfig = { id: 'webIdentityToken', title: 'Web Identity Token', type: 'long-input', + password: true, placeholder: 'OIDC/OAuth 2.0 token from the identity provider', condition: { field: 'operation', value: 'assume_role_with_web_identity' }, required: { field: 'operation', value: 'assume_role_with_web_identity' }, @@ -128,6 +129,7 @@ export const STSBlock: BlockConfig = { id: 'samlAssertion', title: 'SAML Assertion', type: 'long-input', + password: true, placeholder: 'Base64-encoded SAML authentication response', condition: { field: 'operation', value: 'assume_role_with_saml' }, required: { field: 'operation', value: 'assume_role_with_saml' }, @@ -213,6 +215,7 @@ export const STSBlock: BlockConfig = { id: 'tokenCode', title: 'MFA Token Code', type: 'short-input', + password: true, placeholder: '123456', condition: { field: 'operation', value: ['assume_role', 'get_session_token'] }, required: false, diff --git a/apps/sim/blocks/blocks/zoom.ts b/apps/sim/blocks/blocks/zoom.ts index 42df3956fb2..3c1ff2564b2 100644 --- a/apps/sim/blocks/blocks/zoom.ts +++ b/apps/sim/blocks/blocks/zoom.ts @@ -222,6 +222,8 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, id: 'password', title: 'Password', type: 'short-input', + password: true, + required: false, placeholder: 'Meeting password', mode: 'advanced', condition: { diff --git a/apps/sim/lib/api/contracts/v1/tables/index.test.ts b/apps/sim/lib/api/contracts/v1/tables/index.test.ts new file mode 100644 index 00000000000..78eaca5f422 --- /dev/null +++ b/apps/sim/lib/api/contracts/v1/tables/index.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { + v1CreateTableRowContract, + v1UpdateRowsByFilterContract, + v1UpdateTableRowContract, + v1UpsertTableRowContract, +} from '@/lib/api/contracts/v1/tables' +import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata' + +describe('v1 public table row contracts', () => { + it('never expose private secret provenance', () => { + for (const contract of [ + v1CreateTableRowContract, + v1UpdateRowsByFilterContract, + v1UpdateTableRowContract, + v1UpsertTableRowContract, + ]) { + expect( + JSON.stringify(z.toJSONSchema(contract.body, { unrepresentable: 'any' })) + ).not.toContain(PRIVATE_SECRET_PROVENANCE_FIELD) + } + }) +}) diff --git a/apps/sim/lib/api/contracts/v1/tables/index.ts b/apps/sim/lib/api/contracts/v1/tables/index.ts index 4491b8840be..aa4490889f7 100644 --- a/apps/sim/lib/api/contracts/v1/tables/index.ts +++ b/apps/sim/lib/api/contracts/v1/tables/index.ts @@ -18,6 +18,7 @@ import { upsertTableRowBodySchema, } from '@/lib/api/contracts/tables' import { defineRouteContract } from '@/lib/api/contracts/types' +import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata' import type { Filter, Sort } from '@/lib/table' import { TABLE_LIMITS } from '@/lib/table/constants' @@ -61,7 +62,7 @@ export const v1CreateTableBodySchema = createTableBodySchema.omit({ * new rows at the tail; ordering by index is an in-app affordance only. */ export const v1InsertTableRowBodySchema = insertTableRowBodyBaseSchema - .omit({ position: true }) + .omit({ position: true, [PRIVATE_SECRET_PROVENANCE_FIELD]: true }) .refine(...rowAnchorMutexRefine) /** @@ -83,6 +84,18 @@ export const v1CreateTableRowsBodySchema = z.union([ v1InsertTableRowBodySchema, ]) +export const v1UpdateRowsByFilterBodySchema = updateRowsByFilterBodySchema.omit({ + [PRIVATE_SECRET_PROVENANCE_FIELD]: true, +}) + +export const v1UpdateTableRowBodySchema = updateTableRowBodySchema.omit({ + [PRIVATE_SECRET_PROVENANCE_FIELD]: true, +}) + +export const v1UpsertTableRowBodySchema = upsertTableRowBodySchema.omit({ + [PRIVATE_SECRET_PROVENANCE_FIELD]: true, +}) + export type V1ListTablesQuery = z.output export type V1TableRowsQuery = z.output export type V1InsertTableRowBody = z.output @@ -209,7 +222,7 @@ export const v1UpdateRowsByFilterContract = defineRouteContract({ method: 'PUT', path: '/api/v1/tables/[tableId]/rows', params: tableIdParamsSchema, - body: updateRowsByFilterBodySchema, + body: v1UpdateRowsByFilterBodySchema, response: { mode: 'json', schema: v1TableApiResponseSchema, @@ -242,7 +255,7 @@ export const v1UpdateTableRowContract = defineRouteContract({ method: 'PATCH', path: '/api/v1/tables/[tableId]/rows/[rowId]', params: tableRowParamsSchema, - body: updateTableRowBodySchema, + body: v1UpdateTableRowBodySchema, response: { mode: 'json', schema: v1TableApiResponseSchema, @@ -264,7 +277,7 @@ export const v1UpsertTableRowContract = defineRouteContract({ method: 'POST', path: '/api/v1/tables/[tableId]/rows/upsert', params: tableIdParamsSchema, - body: upsertTableRowBodySchema, + body: v1UpsertTableRowBodySchema, response: { mode: 'json', schema: v1TableApiResponseSchema, 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 9e9dca55d01..fd126450553 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts @@ -1,14 +1,35 @@ import { describe, expect, it } from 'vitest' +import { z } from 'zod' import { V2_TABLE_IMPORT_OPTIONS_MAX_BYTES, v2CreateTableImportBodySchema, + v2CreateTableRowsContract, v2TableUploadImportSourceSchema, + v2UpdateRowsByFilterContract, + v2UpdateTableRowContract, + v2UpsertTableRowContract, } from '@/lib/api/contracts/v2/tables' +import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata' import { TABLE_LIMITS } from '@/lib/table/constants' import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +describe('v2 table row contracts', () => { + it('never expose private secret provenance on the public API', () => { + for (const contract of [ + v2CreateTableRowsContract, + v2UpdateRowsByFilterContract, + v2UpdateTableRowContract, + v2UpsertTableRowContract, + ]) { + expect( + JSON.stringify(z.toJSONSchema(contract.body, { unrepresentable: 'any' })) + ).not.toContain(PRIVATE_SECRET_PROVENANCE_FIELD) + } + }) +}) + function uploadSource(size: number) { return { type: 'upload' as const, @@ -59,17 +80,11 @@ describe('v2 table import contracts', () => { ).toBe(false) }) - it('caps mapping entries and createColumns items at the table column limit', () => { + it('accepts bounded metadata and rejects collections over the table column limit', () => { const mapping = Object.fromEntries( - Array.from({ length: TABLE_LIMITS.MAX_COLUMNS_PER_TABLE }, (_, index) => [ - `header_${index}`, - `column_${index}`, - ]) - ) - const createColumns = Array.from( - { length: TABLE_LIMITS.MAX_COLUMNS_PER_TABLE }, - (_, index) => `header_${index}` + Array.from({ length: 10 }, (_, index) => [`h${index}`, `c${index}`]) ) + const createColumns = Array.from({ length: 10 }, (_, index) => `c${index}`) expect(v2CreateTableImportBodySchema.safeParse(existingTableImport({ mapping })).success).toBe( true @@ -77,14 +92,24 @@ describe('v2 table import contracts', () => { expect( v2CreateTableImportBodySchema.safeParse(existingTableImport({ createColumns })).success ).toBe(true) + + const mappingOverLimit = Object.fromEntries( + Array.from({ length: TABLE_LIMITS.MAX_COLUMNS_PER_TABLE + 1 }, (_, index) => [ + String(index), + 'c', + ]) + ) + const columnsOverLimit = Array.from( + { length: TABLE_LIMITS.MAX_COLUMNS_PER_TABLE + 1 }, + (_, index) => String(index) + ) expect( - v2CreateTableImportBodySchema.safeParse( - existingTableImport({ mapping: { ...mapping, overflow: 'overflow' } }) - ).success + v2CreateTableImportBodySchema.safeParse(existingTableImport({ mapping: mappingOverLimit })) + .success ).toBe(false) expect( v2CreateTableImportBodySchema.safeParse( - existingTableImport({ createColumns: [...createColumns, 'overflow'] }) + existingTableImport({ createColumns: columnsOverLimit }) ).success ).toBe(false) }) diff --git a/apps/sim/lib/api/contracts/v2/chat.test.ts b/apps/sim/lib/api/contracts/v2/chat.test.ts new file mode 100644 index 00000000000..c72f89de194 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/chat.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_V2_CHAT_ATTACHMENTS, + MAX_V2_CHAT_CONTEXTS, + MAX_V2_CHAT_PROMPT_LENGTH, + v2ChatBodySchema, +} from '@/lib/api/contracts/v2/chat' + +describe('v2ChatBodySchema', () => { + it('enforces the prompt limit in UTF-8 bytes', () => { + const overLimit = 'é'.repeat(MAX_V2_CHAT_PROMPT_LENGTH / 2 + 1) + + const result = v2ChatBodySchema.safeParse({ + workspaceId: 'workspace-1', + prompt: overLimit, + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0]?.message).toBe('Prompt cannot exceed 10 MiB') + } + }) + + it('accepts an opaque continuation token and inline base64 attachment', () => { + expect( + v2ChatBodySchema.parse({ + workspaceId: 'workspace-1', + prompt: 'Read this', + continuationToken: 'opaque-token', + attachments: [{ name: 'Notes.MD', mediaType: 'TEXT/MARKDOWN', data: 'aGk=' }], + }) + ).toEqual({ + workspaceId: 'workspace-1', + prompt: 'Read this', + continuationToken: 'opaque-token', + readOnly: false, + attachments: [{ name: 'Notes.MD', mediaType: 'text/markdown', data: 'aGk=' }], + }) + }) + + it('accepts only the identity-bearing contexts supported by public resource lists', () => { + const contexts = [ + { kind: 'workflow', workflowId: 'workflow-1', label: 'Release' }, + { kind: 'table', tableId: 'table-1', label: 'Leads' }, + { kind: 'file', fileId: 'file-1', label: 'Brief.md' }, + { kind: 'knowledge', knowledgeId: 'kb-1', label: 'Handbook' }, + { kind: 'logs', executionId: 'execution-1', label: 'Release log' }, + { kind: 'skill', skillId: 'skill-1', label: 'review' }, + { kind: 'mcp', serverId: 'mcp-1', label: 'Docs' }, + ] + + expect( + v2ChatBodySchema.parse({ workspaceId: 'workspace-1', prompt: 'Use these', contexts }).contexts + ).toEqual(contexts) + expect( + v2ChatBodySchema.safeParse({ + workspaceId: 'workspace-1', + prompt: 'Use this', + contexts: [{ kind: 'folder', folderId: 'folder-1', label: 'Folder' }], + }).success + ).toBe(false) + expect( + v2ChatBodySchema.safeParse({ + workspaceId: 'workspace-1', + prompt: 'Use these', + contexts: Array.from({ length: MAX_V2_CHAT_CONTEXTS + 1 }, (_, index) => ({ + kind: 'skill', + skillId: `skill-${index}`, + label: `skill-${index}`, + })), + }).success + ).toBe(false) + }) + + it('allows an attachment-only turn but still rejects an entirely empty turn', () => { + expect( + v2ChatBodySchema.safeParse({ + workspaceId: 'workspace-1', + prompt: ' ', + attachments: [{ name: 'image.png', mediaType: 'image/png', data: 'AAAA' }], + }).success + ).toBe(true) + expect(v2ChatBodySchema.safeParse({ workspaceId: 'workspace-1', prompt: ' ' }).success).toBe( + false + ) + }) + + it('accepts only file basenames and a bounded attachment count', () => { + for (const name of ['/tmp/secret.txt', '../secret.txt', 'folder\\secret.txt', 'bad\0.txt']) { + expect( + v2ChatBodySchema.safeParse({ + workspaceId: 'workspace-1', + prompt: 'Read this', + attachments: [{ name, mediaType: 'text/plain', data: 'aGk=' }], + }).success + ).toBe(false) + } + + expect( + v2ChatBodySchema.safeParse({ + workspaceId: 'workspace-1', + prompt: 'Read these', + attachments: Array.from({ length: MAX_V2_CHAT_ATTACHMENTS + 1 }, (_, index) => ({ + name: `${index}.txt`, + mediaType: 'text/plain', + data: 'aGk=', + })), + }).success + ).toBe(false) + }) + + it('continues to reject raw caller-controlled chat ids and attachment URLs or paths', () => { + for (const extra of [ + { chatId: 'raw-chat-id' }, + { conversationId: 'raw-chat-id' }, + { + attachments: [ + { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'aGk=', + path: '/tmp/notes.txt', + }, + ], + }, + { + attachments: [ + { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'aGk=', + url: 'https://example.com/notes.txt', + }, + ], + }, + ]) { + expect( + v2ChatBodySchema.safeParse({ + workspaceId: 'workspace-1', + prompt: 'hello', + ...extra, + }).success + ).toBe(false) + } + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/chat.ts b/apps/sim/lib/api/contracts/v2/chat.ts new file mode 100644 index 00000000000..65b9238cca8 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/chat.ts @@ -0,0 +1,200 @@ +import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' + +/** Bounds both non-interactive output and persistent interactive CLI chat. */ +export const MAX_V2_CHAT_PROMPT_LENGTH = 10 * 1024 * 1024 +export const MAX_V2_CHAT_ATTACHMENTS = 5 +export const MAX_V2_CHAT_ATTACHMENT_BYTES = 5 * 1024 * 1024 +export const MAX_V2_CHAT_TEXT_ATTACHMENT_BYTES = 200 * 1024 +export const MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES = 10 * 1024 * 1024 +export const MAX_V2_CHAT_ATTACHMENT_NAME_LENGTH = 255 +export const MAX_V2_CHAT_CONTINUATION_TOKEN_LENGTH = 4096 +export const MAX_V2_CHAT_CONTEXTS = 50 +export const MAX_V2_CHAT_CONTEXT_LABEL_LENGTH = 255 +export const MAX_V2_CHAT_CONTEXT_ID_LENGTH = 255 +/** Prevent small compressed inputs from expanding into unbounded image allocations. */ +export const MAX_V2_CHAT_IMAGE_DIMENSION = 8192 +/** Caps one 4-byte decoded image surface at roughly 64 MiB before resize overhead. */ +export const MAX_V2_CHAT_IMAGE_PIXELS = 16_000_000 +/** Caps all decoded image surfaces in one request at roughly 128 MiB. */ +export const MAX_V2_CHAT_IMAGES_TOTAL_PIXELS = 32_000_000 + +const MAX_V2_CHAT_ATTACHMENT_BASE64_LENGTH = Math.ceil(MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES / 3) * 4 +const MAX_V2_CHAT_JSON_OVERHEAD_BYTES = 64 * 1024 + +/** + * A prompt byte may occupy six transport bytes as a JSON `\u00XX` escape; + * attachment base64 is already ASCII. This cap is deliberately a transport + * bound, while the decoded prompt/file limits are enforced below and at the + * route's attachment-validation boundary. + */ +export const MAX_V2_CHAT_BODY_BYTES = + MAX_V2_CHAT_PROMPT_LENGTH * 6 + + MAX_V2_CHAT_ATTACHMENT_BASE64_LENGTH + + MAX_V2_CHAT_JSON_OVERHEAD_BYTES + +export const V2_CHAT_IMAGE_MEDIA_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', +] as const + +export const V2_CHAT_TEXT_MEDIA_TYPES = [ + 'text/plain', + 'text/markdown', + 'text/csv', + 'text/tab-separated-values', + 'text/html', + 'text/css', + 'text/javascript', + 'text/typescript', + 'text/xml', + 'text/yaml', + 'application/json', + 'application/jsonl', + 'application/x-ndjson', + 'application/xml', + 'application/yaml', + 'application/x-yaml', + 'application/toml', +] as const + +export const V2_CHAT_DOCUMENT_MEDIA_TYPES = ['application/pdf'] as const + +const v2ChatContextIdSchema = z.string().trim().min(1).max(MAX_V2_CHAT_CONTEXT_ID_LENGTH) +const v2ChatContextLabelSchema = z.string().trim().min(1).max(MAX_V2_CHAT_CONTEXT_LABEL_LENGTH) + +/** + * Identity-bearing tags supported by the public CLI surface. The home client + * uses the same context kinds; this deliberately exposes only resources whose + * stable ids are already available from public v2 list endpoints. + */ +export const v2ChatContextSchema = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('workflow'), + workflowId: v2ChatContextIdSchema, + label: v2ChatContextLabelSchema, + }) + .strict(), + z + .object({ + kind: z.literal('table'), + tableId: v2ChatContextIdSchema, + label: v2ChatContextLabelSchema, + }) + .strict(), + z + .object({ + kind: z.literal('file'), + fileId: v2ChatContextIdSchema, + label: v2ChatContextLabelSchema, + }) + .strict(), + z + .object({ + kind: z.literal('knowledge'), + knowledgeId: v2ChatContextIdSchema, + label: v2ChatContextLabelSchema, + }) + .strict(), + z + .object({ + kind: z.literal('logs'), + executionId: v2ChatContextIdSchema, + label: v2ChatContextLabelSchema, + }) + .strict(), + z + .object({ + kind: z.literal('skill'), + skillId: v2ChatContextIdSchema, + label: v2ChatContextLabelSchema, + }) + .strict(), + z + .object({ + kind: z.literal('mcp'), + serverId: v2ChatContextIdSchema, + label: v2ChatContextLabelSchema, + }) + .strict(), +]) + +export type V2ChatContext = z.output + +const textEncoder = new TextEncoder() + +const v2ChatAttachmentSchema = z + .object({ + // Basenames only: local paths belong to the CLI process and must never + // cross the API boundary. + name: z + .string() + .trim() + .min(1, 'Attachment name is required') + .max(MAX_V2_CHAT_ATTACHMENT_NAME_LENGTH) + .refine( + (value) => value !== '.' && value !== '..' && !/[\\/\u0000-\u001f\u007f]/.test(value), + 'Attachment name must be a file basename' + ), + mediaType: z + .string() + .trim() + .toLowerCase() + .max(127) + .regex( + /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/, + 'Attachment mediaType must be a MIME type without parameters' + ), + // Semantic validation performs strict canonical-base64 decoding, byte + // sniffing, and the type-specific decoded limits after workspace auth. + data: z.string().min(4).max(MAX_V2_CHAT_ATTACHMENT_BASE64_LENGTH), + }) + .strict() + +export type V2ChatAttachment = z.output + +export const v2ChatBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + prompt: z + .string() + .max(MAX_V2_CHAT_PROMPT_LENGTH, 'Prompt cannot exceed 10 MiB') + .refine( + (value) => textEncoder.encode(value).byteLength <= MAX_V2_CHAT_PROMPT_LENGTH, + 'Prompt cannot exceed 10 MiB' + ), + continuationToken: z.string().min(1).max(MAX_V2_CHAT_CONTINUATION_TOKEN_LENGTH).optional(), + /** Normal Mothership is the default; this explicitly selects its read-only projection. */ + readOnly: z.boolean().optional().default(false), + attachments: z.array(v2ChatAttachmentSchema).max(MAX_V2_CHAT_ATTACHMENTS).optional(), + contexts: z.array(v2ChatContextSchema).max(MAX_V2_CHAT_CONTEXTS).optional(), + }) + .strict() + .superRefine((value, context) => { + if (!value.prompt.trim() && !value.attachments?.length) { + context.addIssue({ + code: 'custom', + path: ['prompt'], + message: 'Prompt or at least one attachment is required', + }) + } + }) +export type V2ChatBody = z.input + +/** + * A normal workspace Mothership turn. Omit `continuationToken` for a one-shot + * or the first interactive turn; pass the latest server-issued token to + * continue the same private conversation. `readOnly` explicitly selects the + * secretless query projection. Successful responses are SSE so proxies stay + * alive during long agent turns and callers can cancel the run. + */ +export const v2ChatContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/chat', + body: v2ChatBodySchema, + response: { mode: 'stream' }, +}) diff --git a/apps/sim/lib/api/contracts/v2/chats.test.ts b/apps/sim/lib/api/contracts/v2/chats.test.ts new file mode 100644 index 00000000000..e648db545a1 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/chats.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { + v2ChatDetailSchema, + v2GetChatQuerySchema, + v2ListChatsQuerySchema, + v2RenameChatBodySchema, +} from '@/lib/api/contracts/v2/chats' + +describe('v2ListChatsQuerySchema', () => { + it('defaults to a bounded page and clamps caller-provided limits', () => { + expect(v2ListChatsQuerySchema.parse({ workspaceId: 'workspace-1' }).limit).toBe(30) + expect(v2ListChatsQuerySchema.parse({ workspaceId: 'workspace-1', limit: '0' }).limit).toBe(1) + expect(v2ListChatsQuerySchema.parse({ workspaceId: 'workspace-1', limit: '500' }).limit).toBe( + 100 + ) + expect(v2ListChatsQuerySchema.parse({ workspaceId: 'workspace-1', limit: '2.9' }).limit).toBe(2) + }) + + it('rejects empty search and cursor values', () => { + expect( + v2ListChatsQuerySchema.safeParse({ workspaceId: 'workspace-1', search: '' }).success + ).toBe(false) + expect( + v2ListChatsQuerySchema.safeParse({ workspaceId: 'workspace-1', cursor: '' }).success + ).toBe(false) + }) +}) + +describe('v2GetChatQuerySchema', () => { + it('parses text booleans without treating "false" as truthy', () => { + expect(v2GetChatQuerySchema.parse({ workspaceId: 'workspace-1' }).readOnly).toBe(false) + expect( + v2GetChatQuerySchema.parse({ workspaceId: 'workspace-1', readOnly: false }).readOnly + ).toBe(false) + expect( + v2GetChatQuerySchema.parse({ workspaceId: 'workspace-1', readOnly: true }).readOnly + ).toBe(true) + expect( + v2GetChatQuerySchema.parse({ workspaceId: 'workspace-1', readOnly: 'false' }).readOnly + ).toBe(false) + expect( + v2GetChatQuerySchema.parse({ workspaceId: 'workspace-1', readOnly: 'true' }).readOnly + ).toBe(true) + }) +}) + +describe('v2ChatDetailSchema', () => { + it('accepts only the display-safe transcript projection', () => { + const detail = { + id: 'chat-1', + title: 'Release plan', + active: false, + continuationToken: 'opaque-token', + messages: [ + { + id: 'message-1', + role: 'assistant', + content: 'Ready', + timestamp: '2026-08-07T12:00:00.000Z', + contentBlocks: [{ type: 'tool', result: 'private' }], + }, + ], + } + + expect(v2ChatDetailSchema.parse(detail)).toEqual({ + ...detail, + messages: [ + { + id: 'message-1', + role: 'assistant', + content: 'Ready', + timestamp: '2026-08-07T12:00:00.000Z', + }, + ], + }) + }) +}) + +describe('v2RenameChatBodySchema', () => { + it('trims a bounded title and rejects empty or unknown input', () => { + expect( + v2RenameChatBodySchema.parse({ workspaceId: 'workspace-1', title: ' Release plan ' }) + ).toEqual({ workspaceId: 'workspace-1', title: 'Release plan' }) + expect( + v2RenameChatBodySchema.safeParse({ workspaceId: 'workspace-1', title: ' ' }).success + ).toBe(false) + expect( + v2RenameChatBodySchema.safeParse({ + workspaceId: 'workspace-1', + title: 'Release plan', + extra: true, + }).success + ).toBe(false) + expect( + v2RenameChatBodySchema.safeParse({ + workspaceId: 'workspace-1', + title: 'x'.repeat(201), + }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/chats.ts b/apps/sim/lib/api/contracts/v2/chats.ts new file mode 100644 index 00000000000..4a27771a919 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/chats.ts @@ -0,0 +1,108 @@ +import { z } from 'zod' +import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse, v2SearchSchema } from '@/lib/api/contracts/v2/shared' + +/** A bounded, display-safe chat summary for the public CLI history picker. */ +export const v2ChatSummarySchema = z.object({ + id: z.string().min(1), + title: z.string().nullable(), + updatedAt: z.string().datetime(), + pinned: z.boolean(), + /** True while another client owns the chat's single active response stream. */ + active: z.boolean(), +}) + +export type V2ChatSummary = z.output + +/** The intentionally small transcript shape needed to repaint a terminal chat. */ +export const v2ChatMessageSchema = z.object({ + id: z.string().min(1), + role: z.enum(['user', 'assistant']), + content: z.string(), + timestamp: z.string().datetime(), +}) + +export type V2ChatMessage = z.output + +export const v2ChatDetailSchema = z.object({ + id: z.string().min(1), + title: z.string().nullable(), + messages: z.array(v2ChatMessageSchema), + continuationToken: z.string().min(1), + active: z.boolean(), +}) + +export type V2ChatDetail = z.output + +export const v2RenameChatBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + title: z + .string() + .trim() + .min(1, 'Chat title is required') + .max(200, 'Chat title must be at most 200 characters'), + }) + .strict() + +export type V2RenameChatBody = z.input + +export const v2RenamedChatSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1).max(200), +}) + +export type V2RenamedChat = z.output + +/** + * Recent chats use their Home ordering (pinned first, then most recently + * updated) with a fixed keyset cursor. The modest default keeps `/chats` + * cheap even for workspaces with years of chat history. + */ +export const v2ListChatsQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + search: v2SearchSchema, + limit: z.coerce + .number() + .optional() + .default(30) + .transform((value) => Math.min(Math.max(1, Math.trunc(value)), 100)), + cursor: z.string().min(1).optional(), + }) + .strict() + +export type V2ListChatsQuery = z.output + +export const v2ChatParamsSchema = z.object({ chatId: z.string().min(1) }).strict() + +export const v2GetChatQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + readOnly: booleanQueryFlagSchema.optional().default(false), + }) + .strict() + +export const v2ListChatsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/chats', + query: v2ListChatsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2ChatSummarySchema) }, +}) + +export const v2GetChatContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/chats/[chatId]', + params: v2ChatParamsSchema, + query: v2GetChatQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2ChatDetailSchema) }, +}) + +export const v2RenameChatContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/chats/[chatId]', + params: v2ChatParamsSchema, + body: v2RenameChatBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2RenamedChatSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 84e35ef40ef..ec0f0fbbb86 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -22,12 +22,9 @@ import { tableRowsQueryBaseSchema, tableViewConfigSchema, tableViewParamsSchema, - updateRowsByFilterBodySchema, updateTableColumnBodySchema, - updateTableRowBodySchema, updateTableViewBodySchema, updateWorkflowGroupBodySchema, - upsertTableRowBodySchema, workflowGroupOutputColumnSchema, } from '@/lib/api/contracts/tables' import { defineRouteContract } from '@/lib/api/contracts/types' @@ -36,6 +33,9 @@ import { v1CreateTableBodySchema, v1CreateTableRowsBodySchema, v1ListTablesQuerySchema, + v1UpdateRowsByFilterBodySchema, + v1UpdateTableRowBodySchema, + v1UpsertTableRowBodySchema, } from '@/lib/api/contracts/v1/tables' import { v2CreateFolderBodySchema, @@ -490,7 +490,7 @@ export const v2CreateTableRowsContract = defineRouteContract({ }) /** Bulk update body — v2 accepts ONLY the predicate tree as the filter. */ -export const v2UpdateRowsByPredicateBodySchema = updateRowsByFilterBodySchema.extend({ +export const v2UpdateRowsByPredicateBodySchema = v1UpdateRowsByFilterBodySchema.extend({ filter: predicateSchema, }) export type V2UpdateRowsByPredicateBody = z.input @@ -560,7 +560,7 @@ export const v2UpdateTableRowContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/tables/[tableId]/rows/[rowId]', params: tableRowParamsSchema, - body: updateTableRowBodySchema, + body: v1UpdateTableRowBodySchema, response: { mode: 'json', schema: v2DataResponse(v2TableRowDataSchema), @@ -582,7 +582,7 @@ export const v2UpsertTableRowContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/rows/upsert', params: tableIdParamsSchema, - body: upsertTableRowBodySchema, + body: v1UpsertTableRowBodySchema, response: { mode: 'json', schema: v2DataResponse(v2UpsertRowDataSchema), diff --git a/apps/sim/lib/api/contracts/v2/workspaces.ts b/apps/sim/lib/api/contracts/v2/workspaces.ts new file mode 100644 index 00000000000..6297755cdf8 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/workspaces.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 workspace contracts. + * + * Read-only. Clients hold a workspace id (from a profile, a flag, or an env + * var) and need a human-readable name to show beside it; without this they can + * only ever display the raw uuid. + */ + +const v2WorkspaceParamsSchema = z.object({ + workspaceId: workspaceIdSchema, +}) + +const v2WorkspaceSchema = z.object({ + id: z.string(), + name: z.string(), + color: z.string(), + logoUrl: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +const v2WorkspaceDataSchema = z.object({ + workspace: v2WorkspaceSchema, +}) + +export type V2Workspace = z.output + +export const v2GetWorkspaceContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]', + params: v2WorkspaceParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkspaceDataSchema), + }, +}) diff --git a/apps/sim/lib/copilot/async-runs/repository.test.ts b/apps/sim/lib/copilot/async-runs/repository.test.ts index fcd9c01a4e7..1d5563b03a3 100644 --- a/apps/sim/lib/copilot/async-runs/repository.test.ts +++ b/apps/sim/lib/copilot/async-runs/repository.test.ts @@ -11,6 +11,7 @@ import { completeAsyncToolCall, detachAsyncToolCall, getClaimedWorkflowExecutionId, + markAsyncToolRunning, recordToolPermissionDecision, releaseWorkflowToolExecutionClaim, replaceTerminalAsyncToolCallResult, @@ -132,6 +133,27 @@ describe('async tool repository single-row semantics', () => { ) }) + it('marks a Sim tool running only while its durable row is still live', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'sim-tool', + status: 'running', + claimedBy: 'sim-stream', + }, + ]) + + await markAsyncToolRunning('sim-tool', 'sim-stream') + + const predicate = dbChainMockFns.where.mock.calls.at(-1)?.[0] + expect(predicate).toEqual({ + type: 'and', + conditions: [ + expect.objectContaining({ type: 'eq', right: 'sim-tool' }), + expect.objectContaining({ type: 'inArray', values: ['pending', 'running'] }), + ], + }) + }) + it('atomically binds an eligible workflow tool to one execution', async () => { dbChainMockFns.returning.mockResolvedValueOnce([ { diff --git a/apps/sim/lib/copilot/async-runs/repository.ts b/apps/sim/lib/copilot/async-runs/repository.ts index 1cff09c4a63..465c3da1afa 100644 --- a/apps/sim/lib/copilot/async-runs/repository.ts +++ b/apps/sim/lib/copilot/async-runs/repository.ts @@ -380,7 +380,10 @@ async function markAsyncToolStatus( } export async function markAsyncToolRunning(toolCallId: string, claimedBy: string) { - return markAsyncToolStatus(toolCallId, 'running', { claimedBy }) + return markAsyncToolStatus(toolCallId, ASYNC_TOOL_STATUS.running, { claimedBy }, [ + ASYNC_TOOL_STATUS.pending, + ASYNC_TOOL_STATUS.running, + ]) } export function getClaimedWorkflowExecutionId(claimedBy: string | null | undefined) { diff --git a/apps/sim/lib/copilot/chat/lifecycle.test.ts b/apps/sim/lib/copilot/chat/lifecycle.test.ts index 46e5c63dc31..fc4fa740fe9 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.test.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.test.ts @@ -21,6 +21,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ import { getAccessibleCopilotChat, + getAccessibleCopilotChatContinuationMetadata, getAccessibleCopilotChatWithMessages, resolveOrCreateChat, } from '@/lib/copilot/chat/lifecycle' @@ -106,6 +107,59 @@ describe('lifecycle copilot chat reads (cutover to copilot_messages)', () => { expect(result?.messages).toEqual([]) }) + it('loads continuation metadata with a one-row probe and contexts-only MCP projection', async () => { + const continuationRow = { + id: chatRow.id, + userId: chatRow.userId, + workflowId: chatRow.workflowId, + workspaceId: chatRow.workspaceId, + type: chatRow.type, + title: chatRow.title, + } + dbChainMockFns.limit + .mockResolvedValueOnce([continuationRow]) + .mockResolvedValueOnce([{ id: 'message-1' }]) + dbChainMockFns.orderBy.mockResolvedValueOnce([ + { + contexts: [ + { kind: 'mcp', serverId: 'mcp-docs', label: 'Docs' }, + { kind: 'skill', skillId: 'skill-review', label: 'Review' }, + ], + }, + { contexts: [{ kind: 'mcp', serverId: 'mcp-docs', label: 'Docs again' }] }, + { contexts: [{ kind: 'mcp', serverId: 'mcp-issues', label: 'Issues' }] }, + ]) + + const result = await getAccessibleCopilotChatContinuationMetadata(CHAT_ID, USER_ID) + + expect(result).toEqual({ + ...continuationRow, + hasMessages: true, + mcpServerIds: ['mcp-docs', 'mcp-issues'], + }) + expect(dbChainMockFns.limit).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.orderBy).toHaveBeenCalledTimes(1) + const contextsProjection = dbChainMockFns.select.mock.calls[2]?.[0] as Record + expect(Object.keys(contextsProjection)).toEqual(['contexts']) + }) + + it('skips the MCP projection for an empty persisted chat', async () => { + const continuationRow = { + id: chatRow.id, + userId: chatRow.userId, + workflowId: chatRow.workflowId, + workspaceId: chatRow.workspaceId, + type: chatRow.type, + title: chatRow.title, + } + dbChainMockFns.limit.mockResolvedValueOnce([continuationRow]).mockResolvedValueOnce([]) + + const result = await getAccessibleCopilotChatContinuationMetadata(CHAT_ID, USER_ID) + + expect(result).toEqual({ ...continuationRow, hasMessages: false, mcpServerIds: [] }) + expect(dbChainMockFns.orderBy).not.toHaveBeenCalled() + }) + it('returns null and does NOT query messages when the chat is not found', async () => { dbChainMockFns.limit.mockResolvedValueOnce([]) diff --git a/apps/sim/lib/copilot/chat/lifecycle.ts b/apps/sim/lib/copilot/chat/lifecycle.ts index 69b577a31e9..381a227ed75 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.ts @@ -6,7 +6,11 @@ import { getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' import { and, asc, eq, isNull, sql } from 'drizzle-orm' -import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message' +import { + collectChatMcpServerIds, + type PersistedMessage, + stripToolResultOutput, +} from '@/lib/copilot/chat/persisted-message' import { assertActiveWorkspaceAccess, checkWorkspaceAccess, @@ -35,6 +39,11 @@ const copilotChatAuthColumns = { type: copilotChats.type, } as const +const copilotChatContinuationColumns = { + ...copilotChatAuthColumns, + title: copilotChats.title, +} as const + /** * Column set for chat-detail callers that need chat metadata. The conversation * transcript is no longer selected from `copilot_chats.messages` (JSONB) — @@ -103,6 +112,12 @@ type CopilotChatAuthRow = Pick< 'id' | 'userId' | 'workflowId' | 'workspaceId' | 'type' > +export type CopilotChatContinuationMetadata = CopilotChatAuthRow & { + title: string | null + hasMessages: boolean + mcpServerIds: string[] +} + export type CopilotChatDetailRow = Pick< typeof copilotChats.$inferSelect, | 'id' @@ -181,6 +196,57 @@ export async function getAccessibleCopilotChatAuth( return authorizeCopilotChatRow(chat, chatId, userId) } +/** + * Loads only the authorized metadata needed to continue a persisted chat. The + * one-row existence probe preserves first-turn title behavior, while the MCP + * query projects only user-message context arrays. Assistant/tool content is + * never loaded or normalized. + */ +export async function getAccessibleCopilotChatContinuationMetadata( + chatId: string, + userId: string +): Promise { + const [chat] = await db + .select(copilotChatContinuationColumns) + .from(copilotChats) + .where(ownedLiveChatWhere(chatId, userId)) + .limit(1) + + const authorized = await authorizeCopilotChatRow(chat, chatId, userId) + if (!authorized) return null + + const [message] = await db + .select({ id: copilotMessages.id }) + .from(copilotMessages) + .where(and(eq(copilotMessages.chatId, chatId), isNull(copilotMessages.deletedAt))) + .limit(1) + + if (!message) return { ...authorized, hasMessages: false, mcpServerIds: [] } + + const contextRows = await db + .select({ contexts: sql`${copilotMessages.content} -> 'contexts'` }) + .from(copilotMessages) + .where( + and( + eq(copilotMessages.chatId, chatId), + eq(copilotMessages.role, 'user'), + isNull(copilotMessages.deletedAt), + sql`${copilotMessages.content} ? 'contexts'` + ) + ) + .orderBy( + sql`${copilotMessages.seq} asc nulls last`, + asc(copilotMessages.createdAt), + asc(copilotMessages.id) + ) + + return { + ...authorized, + hasMessages: true, + mcpServerIds: collectChatMcpServerIds(contextRows), + } +} + /** * Load a copilot chat row for the legacy chat detail endpoint, including the * transcript plus `model` and `config`. Drops `previewYaml` diff --git a/apps/sim/lib/copilot/chat/persisted-message.test.ts b/apps/sim/lib/copilot/chat/persisted-message.test.ts index d600f6ca5b1..1ce27c6e880 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.test.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.test.ts @@ -7,12 +7,40 @@ import type { OrchestratorResult } from '@/lib/copilot/request/types' import { buildPersistedAssistantMessage, buildPersistedUserMessage, + collectChatMcpServerIds, normalizeMessage, type PersistedMessage, stripToolResultOutput, } from './persisted-message' describe('persisted-message', () => { + it('collects append-only MCP ids from persisted and current contexts', () => { + expect( + collectChatMcpServerIds( + [ + { + contexts: [ + { kind: 'mcp', serverId: 'mcp-docs', label: 'Docs' }, + { kind: 'skill', skillId: 'skill-review', label: 'Review' }, + ], + }, + null, + { + contexts: [ + { kind: 'mcp', serverId: 'mcp-docs', label: 'Docs again' }, + { kind: 'mcp', serverId: '', label: 'Invalid' }, + { kind: 'mcp', serverId: 'mcp-issues', label: 'Issues' }, + ], + }, + ], + [ + { kind: 'mcp', serverId: 'mcp-issues', label: 'Issues again' }, + { kind: 'mcp', serverId: 'mcp-search', label: 'Search' }, + ] + ) + ).toEqual(['mcp-docs', 'mcp-issues', 'mcp-search']) + }) + it('round-trips canonical tool blocks through normalizeMessage', () => { const blockTimestamp = 1_700_000_000_000 const result: OrchestratorResult = { diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index a48841f135f..a60961f3d5d 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -125,6 +125,36 @@ export interface PersistedMessage { contexts?: PersistedMessageContext[] } +/** + * Collect the append-only MCP enablement carried by explicitly tagged user + * message contexts. Only ids move between turns: inherited contexts are not + * re-expanded into the prompt or persisted again as chips on later messages. + */ +export function collectChatMcpServerIds( + conversationHistory: readonly unknown[], + currentContexts?: unknown +): string[] { + const serverIds = new Set() + + const collect = (contexts: unknown) => { + if (!Array.isArray(contexts)) return + for (const context of contexts) { + if (!context || typeof context !== 'object') continue + const { kind, serverId } = context as { kind?: unknown; serverId?: unknown } + if (kind === 'mcp' && typeof serverId === 'string' && serverId) { + serverIds.add(serverId) + } + } + } + + for (const message of conversationHistory) { + collect((message as { contexts?: unknown } | null)?.contexts) + } + collect(currentContexts) + + return Array.from(serverIds) +} + /** * Drop the `output` of every persisted tool result, keeping `success` and * `error`. Tool outputs are never rendered (the chat thread shows only the tool diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 24da5a81104..5eafbf77e1e 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -1,24 +1,16 @@ -import { type Context as OtelContext, context as otelContextApi } from '@opentelemetry/api' -import { db } from '@sim/db' -import { copilotChats } from '@sim/db/schema' +import { context as otelContextApi } from '@opentelemetry/api' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { isPlainRecord } from '@sim/utils/object' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { isZodError, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' import { type ChatLoadResult, resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' -import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' import { buildCopilotRequestPayload } from '@/lib/copilot/chat/payload' -import { - buildPersistedAssistantMessage, - buildPersistedUserMessage, - withStoppedContentBlock, -} from '@/lib/copilot/chat/persisted-message' +import { collectChatMcpServerIds } from '@/lib/copilot/chat/persisted-message' import { processContextsServer, resolveActiveResourceContext, @@ -29,17 +21,16 @@ import { MAX_TABLE_SELECTION_ROWS, safeBrowserSelectionUrl, } from '@/lib/copilot/chat/selection-context' -import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state' +import { + buildCopilotTurnOnComplete, + buildCopilotTurnOnError, + persistCopilotUserMessage, +} from '@/lib/copilot/chat/turn-persistence' import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context' -import { chatPubSub } from '@/lib/copilot/chat-status' import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants' import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' -import { - CopilotChatFinalizeOutcome, - CopilotChatPersistOutcome, - CopilotTransport, -} from '@/lib/copilot/generated/trace-attribute-values-v1' +import { CopilotTransport } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1' @@ -51,7 +42,7 @@ import { getPendingChatStreamId, releasePendingChatStream, } from '@/lib/copilot/request/session' -import type { ExecutionContext, OrchestratorResult } from '@/lib/copilot/request/types' +import type { ExecutionContext } from '@/lib/copilot/request/types' import { persistChatResources } from '@/lib/copilot/resources/persistence' import { canonicalizeDesktopSessionResources, @@ -397,44 +388,6 @@ function normalizeContexts(contexts: UnifiedChatRequest['contexts']) { }) } -/** - * An MCP server tagged with `/name` stays enabled for the rest of the chat, not - * just the turn it was tagged on. Persisted user messages already carry their - * `mcp` contexts, so the transcript is the source of truth — enablement survives - * reloads and reopened chats with no extra state to keep in sync. There is - * deliberately no off switch: history is append-only. - * - * Only the ids travel forward, not the contexts themselves. The tools ride the - * tool array on every turn, so the model always sees their names and schemas; - * re-expanding the prompt listing each turn would just duplicate that. Keeping - * inherited servers out of the persisted contexts also keeps the `/name` chips - * on a sent message showing only what the user actually typed that turn. - */ -function collectChatMcpServerIds( - conversationHistory: unknown[], - currentContexts: UnifiedChatRequest['contexts'] -): string[] { - const serverIds = new Set() - - const collect = (contexts: unknown) => { - if (!Array.isArray(contexts)) return - for (const ctx of contexts) { - if (!ctx || typeof ctx !== 'object') continue - const { kind, serverId } = ctx as { kind?: unknown; serverId?: unknown } - if (kind === 'mcp' && typeof serverId === 'string' && serverId) { - serverIds.add(serverId) - } - } - } - - for (const message of conversationHistory) { - collect((message as { contexts?: unknown } | null)?.contexts) - } - collect(currentContexts) - - return Array.from(serverIds) -} - async function resolveAgentContexts(params: { contexts?: UnifiedChatRequest['contexts'] resourceAttachments?: UnifiedChatRequest['resourceAttachments'] @@ -534,96 +487,6 @@ function projectAgentContextInputs( })), } } - -async function persistUserMessage(params: { - chatId?: string - userMessageId: string - message: string - fileAttachments?: UnifiedChatRequest['fileAttachments'] - contexts?: UnifiedChatRequest['contexts'] - workspaceId?: string - notifyWorkspaceStatus: boolean - /** - * Root context for the mothership request. When present the persist - * span is created explicitly under it, which avoids relying on - * AsyncLocalStorage propagation — some upstream awaits (Next.js - * framework frames, Turbopack-instrumented I/O) can swap the active - * store out from under us in dev, which would otherwise leave this - * span parented to the about-to-be-dropped Next.js HTTP span. - */ - parentOtelContext?: OtelContext -}): Promise { - const { - chatId, - userMessageId, - message, - fileAttachments, - contexts, - workspaceId, - notifyWorkspaceStatus, - parentOtelContext, - } = params - if (!chatId) return - - return withCopilotSpan( - TraceSpan.CopilotChatPersistUserMessage, - { - [TraceAttr.DbSystem]: 'postgresql', - [TraceAttr.DbSqlTable]: 'copilot_chats', - [TraceAttr.ChatId]: chatId, - [TraceAttr.ChatUserMessageId]: userMessageId, - [TraceAttr.ChatMessageBytes]: message.length, - [TraceAttr.ChatFileAttachmentCount]: fileAttachments?.length ?? 0, - [TraceAttr.ChatContextCount]: contexts?.length ?? 0, - ...(workspaceId ? { [TraceAttr.WorkspaceId]: workspaceId } : {}), - }, - async (span) => { - const userMsg = buildPersistedUserMessage({ - id: userMessageId, - content: message, - fileAttachments, - contexts, - }) - - const updated = await db.transaction(async (tx) => { - const [row] = await tx - .update(copilotChats) - .set({ - conversationId: userMessageId, - updatedAt: new Date(), - }) - .where(eq(copilotChats.id, chatId)) - .returning({ model: copilotChats.model }) - - if (!row) return null - - await appendCopilotChatMessages( - chatId, - [userMsg], - { streamId: userMessageId, chatModel: row.model ?? null }, - tx - ) - return row - }) - - span.setAttribute( - TraceAttr.ChatPersistOutcome, - updated ? CopilotChatPersistOutcome.Appended : CopilotChatPersistOutcome.ChatNotFound - ) - - if (notifyWorkspaceStatus && updated && workspaceId) { - chatPubSub?.publishStatusChanged({ - workspaceId, - chatId, - type: 'started', - streamId: userMessageId, - }) - } - }, - parentOtelContext - ) -} - async function buildInitialExecutionContext(params: { userId: string workflowId?: string @@ -666,145 +529,6 @@ async function buildInitialExecutionContext(params: { } } -function buildOnComplete(params: { - chatId?: string - userMessageId: string - requestId: string - workspaceId?: string - notifyWorkspaceStatus: boolean - /** - * Root agent span for this request. When present, the final - * assistant message + invoked tool calls are recorded as - * `gen_ai.output.messages` on it before persistence runs. Keeps - * the Honeycomb Gen AI view complete across both the Sim root - * span and the Go-side `llm.stream` spans. - */ - otelRoot?: { - setOutputMessages: (output: { - assistantText?: string - toolCalls?: Array<{ id: string; name: string; arguments?: Record }> - }) => void - } -}) { - const { chatId, userMessageId, requestId, workspaceId, notifyWorkspaceStatus, otelRoot } = params - - return async (result: OrchestratorResult) => { - if (otelRoot && result.success) { - otelRoot.setOutputMessages({ - assistantText: result.content, - toolCalls: result.toolCalls?.map((tc) => ({ - id: tc.id, - name: tc.name, - arguments: tc.params, - })), - }) - } - - if (!chatId) return - - try { - if (result.cancelled) { - const finalization = await finalizeAssistantTurn({ - chatId, - userMessageId, - assistantMessage: withStoppedContentBlock( - buildPersistedAssistantMessage(result, requestId) - ), - streamMarkerPolicy: 'active-or-cleared', - }) - const shouldPublishCompletion = - finalization.updated || - finalization.outcome === CopilotChatFinalizeOutcome.AssistantAlreadyPersisted - - if (notifyWorkspaceStatus && workspaceId && shouldPublishCompletion) { - chatPubSub?.publishStatusChanged({ - workspaceId, - chatId, - type: 'completed', - streamId: userMessageId, - }) - } - return - } - - // On a non-success terminal (e.g. a transient provider error like - // "overloaded"), persist whatever streamed before the failure — same as - // the cancelled path — instead of dropping the partial assistant output. - const assistantMessage = buildPersistedAssistantMessage(result, requestId) - const hasPartial = - !!assistantMessage.content?.trim() || (assistantMessage.contentBlocks?.length ?? 0) > 0 - await finalizeAssistantTurn({ - chatId, - userMessageId, - ...(result.success || hasPartial ? { assistantMessage } : {}), - // Match the cancelled path so the partial still persists if onError - // raced ahead and already cleared the stream marker. - ...(result.success ? {} : { streamMarkerPolicy: 'active-or-cleared' as const }), - }) - - if (notifyWorkspaceStatus && workspaceId) { - chatPubSub?.publishStatusChanged({ - workspaceId, - chatId, - type: 'completed', - streamId: userMessageId, - }) - } - } catch (error) { - logger.error(`[${requestId}] Failed to persist chat messages`, { - chatId, - error: getErrorMessage(error, 'Unknown error'), - }) - } - } -} - -function buildOnError(params: { - chatId?: string - userMessageId: string - requestId: string - workspaceId?: string - notifyWorkspaceStatus: boolean -}) { - const { chatId, userMessageId, requestId, workspaceId, notifyWorkspaceStatus } = params - - return async (_error: Error, result?: OrchestratorResult) => { - if (!chatId) return - - try { - // Persist whatever streamed before a thrown backend error, mirroring the - // cancelled / non-success completion path, so the partial assistant turn - // (text + tool calls + subagent work) survives the refetch instead of the - // chat collapsing to an empty assistant row. - const assistantMessage = result - ? buildPersistedAssistantMessage(result, requestId) - : undefined - const hasPartial = - !!assistantMessage?.content?.trim() || (assistantMessage?.contentBlocks?.length ?? 0) > 0 - await finalizeAssistantTurn({ - chatId, - userMessageId, - ...(hasPartial ? { assistantMessage } : {}), - streamMarkerPolicy: 'active-or-cleared', - }) - - if (notifyWorkspaceStatus && workspaceId) { - chatPubSub?.publishStatusChanged({ - workspaceId, - chatId, - type: 'completed', - streamId: userMessageId, - }) - } - } catch (error) { - logger.error(`[${requestId}] Failed to finalize errored chat stream`, { - chatId, - error: getErrorMessage(error, 'Unknown error'), - }) - } - } -} - async function resolveBranch(params: { authenticatedUserId: string workflowId?: string @@ -1216,7 +940,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { activeOtelRoot.context ) }) - const persistUserMessagePromise = persistUserMessage({ + const persistUserMessagePromise = persistCopilotUserMessage({ chatId: actualChatId, userMessageId, message: body.message, @@ -1343,7 +1067,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { autoExecuteTools: true, interactive: true, executionContext, - onComplete: buildOnComplete({ + onComplete: buildCopilotTurnOnComplete({ chatId: actualChatId, userMessageId, requestId, @@ -1351,7 +1075,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { notifyWorkspaceStatus: branch.notifyWorkspaceStatus, otelRoot, }), - onError: buildOnError({ + onError: buildCopilotTurnOnError({ chatId: actualChatId, userMessageId, requestId, diff --git a/apps/sim/lib/copilot/chat/turn-persistence.ts b/apps/sim/lib/copilot/chat/turn-persistence.ts new file mode 100644 index 00000000000..ded0eb531bd --- /dev/null +++ b/apps/sim/lib/copilot/chat/turn-persistence.ts @@ -0,0 +1,246 @@ +import type { Context as OtelContext } from '@opentelemetry/api' +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' +import { + buildPersistedAssistantMessage, + buildPersistedUserMessage, + type UserMessageParams, + withStoppedContentBlock, +} from '@/lib/copilot/chat/persisted-message' +import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state' +import { chatPubSub } from '@/lib/copilot/chat-status' +import { + CopilotChatFinalizeOutcome, + CopilotChatPersistOutcome, +} from '@/lib/copilot/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' +import { withCopilotSpan } from '@/lib/copilot/request/otel' +import type { OrchestratorResult } from '@/lib/copilot/request/types' + +const logger = createLogger('CopilotTurnPersistence') + +export interface PersistCopilotUserMessageParams { + chatId?: string + userMessageId: string + message: string + fileAttachments?: UserMessageParams['fileAttachments'] + contexts?: UserMessageParams['contexts'] + workspaceId?: string + notifyWorkspaceStatus: boolean + /** + * Root context for the mothership request. When present the persist span is + * created explicitly under it instead of relying on ambient propagation. + */ + parentOtelContext?: OtelContext +} + +/** Persists the user half of a chat turn and marks that turn as active. */ +export async function persistCopilotUserMessage({ + chatId, + userMessageId, + message, + fileAttachments, + contexts, + workspaceId, + notifyWorkspaceStatus, + parentOtelContext, +}: PersistCopilotUserMessageParams): Promise { + if (!chatId) return + + return withCopilotSpan( + TraceSpan.CopilotChatPersistUserMessage, + { + [TraceAttr.DbSystem]: 'postgresql', + [TraceAttr.DbSqlTable]: 'copilot_chats', + [TraceAttr.ChatId]: chatId, + [TraceAttr.ChatUserMessageId]: userMessageId, + [TraceAttr.ChatMessageBytes]: message.length, + [TraceAttr.ChatFileAttachmentCount]: fileAttachments?.length ?? 0, + [TraceAttr.ChatContextCount]: contexts?.length ?? 0, + ...(workspaceId ? { [TraceAttr.WorkspaceId]: workspaceId } : {}), + }, + async (span) => { + const userMessage = buildPersistedUserMessage({ + id: userMessageId, + content: message, + fileAttachments, + contexts, + }) + + const updated = await db.transaction(async (tx) => { + const [row] = await tx + .update(copilotChats) + .set({ + conversationId: userMessageId, + updatedAt: new Date(), + }) + .where(eq(copilotChats.id, chatId)) + .returning({ model: copilotChats.model }) + + if (!row) return null + + await appendCopilotChatMessages( + chatId, + [userMessage], + { streamId: userMessageId, chatModel: row.model ?? null }, + tx + ) + return row + }) + + span.setAttribute( + TraceAttr.ChatPersistOutcome, + updated ? CopilotChatPersistOutcome.Appended : CopilotChatPersistOutcome.ChatNotFound + ) + + if (notifyWorkspaceStatus && updated && workspaceId) { + chatPubSub?.publishStatusChanged({ + workspaceId, + chatId, + type: 'started', + streamId: userMessageId, + }) + } + }, + parentOtelContext + ) +} + +interface CopilotTurnTerminalParams { + chatId?: string + userMessageId: string + requestId: string + workspaceId?: string + notifyWorkspaceStatus: boolean +} + +export interface BuildCopilotTurnOnCompleteParams extends CopilotTurnTerminalParams { + /** Records the terminal model output on an optional caller-owned root span. */ + otelRoot?: { + setOutputMessages: (output: { + assistantText?: string + toolCalls?: Array<{ id: string; name: string; arguments?: Record }> + }) => void + } +} + +/** Builds the shared successful/cancelled turn persistence callback. */ +export function buildCopilotTurnOnComplete({ + chatId, + userMessageId, + requestId, + workspaceId, + notifyWorkspaceStatus, + otelRoot, +}: BuildCopilotTurnOnCompleteParams) { + return async (result: OrchestratorResult): Promise => { + if (otelRoot && result.success) { + otelRoot.setOutputMessages({ + assistantText: result.content, + toolCalls: result.toolCalls?.map((toolCall) => ({ + id: toolCall.id, + name: toolCall.name, + arguments: toolCall.params, + })), + }) + } + + if (!chatId) return + + try { + if (result.cancelled) { + const finalization = await finalizeAssistantTurn({ + chatId, + userMessageId, + assistantMessage: withStoppedContentBlock( + buildPersistedAssistantMessage(result, requestId) + ), + streamMarkerPolicy: 'active-or-cleared', + }) + const shouldPublishCompletion = + finalization.updated || + finalization.outcome === CopilotChatFinalizeOutcome.AssistantAlreadyPersisted + + if (notifyWorkspaceStatus && workspaceId && shouldPublishCompletion) { + chatPubSub?.publishStatusChanged({ + workspaceId, + chatId, + type: 'completed', + streamId: userMessageId, + }) + } + return + } + + const assistantMessage = buildPersistedAssistantMessage(result, requestId) + const hasPartial = + !!assistantMessage.content?.trim() || (assistantMessage.contentBlocks?.length ?? 0) > 0 + await finalizeAssistantTurn({ + chatId, + userMessageId, + ...(result.success || hasPartial ? { assistantMessage } : {}), + ...(result.success ? {} : { streamMarkerPolicy: 'active-or-cleared' as const }), + }) + + if (notifyWorkspaceStatus && workspaceId) { + chatPubSub?.publishStatusChanged({ + workspaceId, + chatId, + type: 'completed', + streamId: userMessageId, + }) + } + } catch (error) { + logger.error(`[${requestId}] Failed to persist chat messages`, { + chatId, + error: getErrorMessage(error, 'Unknown error'), + }) + } + } +} + +/** Builds the shared thrown-error turn persistence callback. */ +export function buildCopilotTurnOnError({ + chatId, + userMessageId, + requestId, + workspaceId, + notifyWorkspaceStatus, +}: CopilotTurnTerminalParams) { + return async (_error: Error, result?: OrchestratorResult): Promise => { + if (!chatId) return + + try { + const assistantMessage = result + ? buildPersistedAssistantMessage(result, requestId) + : undefined + const hasPartial = + !!assistantMessage?.content?.trim() || (assistantMessage?.contentBlocks?.length ?? 0) > 0 + await finalizeAssistantTurn({ + chatId, + userMessageId, + ...(hasPartial ? { assistantMessage } : {}), + streamMarkerPolicy: 'active-or-cleared', + }) + + if (notifyWorkspaceStatus && workspaceId) { + chatPubSub?.publishStatusChanged({ + workspaceId, + chatId, + type: 'completed', + streamId: userMessageId, + }) + } + } catch (error) { + logger.error(`[${requestId}] Failed to finalize errored chat stream`, { + chatId, + error: getErrorMessage(error, 'Unknown error'), + }) + } + } +} diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index d2144ab844a..2b55e2d6f8a 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -25,7 +25,7 @@ import { import { listWorkspaceSandboxes } from '@/lib/execution/remote-sandbox/workspace-sandboxes' import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { listCustomBlockSummariesForWorkspace } from '@/lib/workflows/custom-blocks/operations' -import { listCustomTools } from '@/lib/workflows/custom-tools/operations' +import { listCustomToolSummaries } from '@/lib/workflows/custom-tools/operations' import { listSkillsForUser } from '@/lib/workflows/skills/operations' import { assertActiveWorkspaceAccess, @@ -329,7 +329,7 @@ export function buildWorkspaceContextMd(data: WorkspaceMdData): string { async function buildWorkspaceMdData( workspaceId: string, userId: string, - options?: { workspaceAccess?: WorkspaceAccess } + options?: { workspaceAccess?: WorkspaceAccess; secretless?: boolean } ): Promise { try { // Reuse the caller's already-asserted access when provided (hot chat path); @@ -411,11 +411,17 @@ async function buildWorkspaceMdData( listWorkspaceFiles(workspaceId), - getAccessibleOAuthCredentials(workspaceId, userId), + options?.secretless + ? Promise.resolve([]) + : getAccessibleOAuthCredentials(workspaceId, userId), - getAccessibleEnvCredentials(workspaceId, userId), + options?.secretless ? Promise.resolve([]) : getAccessibleEnvCredentials(workspaceId, userId), - listCustomTools({ userId, workspaceId }), + listCustomToolSummaries({ + userId, + workspaceId, + workspaceOnly: options?.secretless, + }), db .select({ @@ -515,7 +521,12 @@ async function buildWorkspaceMdData( ), customTools: customTools.map((t) => ({ id: t.id, name: t.title })), customBlocks: customBlockSummaries, - mcpServers: mcpServerRows, + mcpServers: mcpServerRows.map((server) => ({ + id: server.id, + name: server.name, + enabled: server.enabled, + ...(options?.secretless ? {} : { url: server.url }), + })), skills: skillRows.map((s) => ({ id: s.id, name: s.name, description: s.description })), ...(sandboxResult.entitled ? { @@ -549,7 +560,11 @@ const WORKSPACE_CONTEXT_UNAVAILABLE_MD = export async function generateWorkspaceContext( workspaceId: string, userId: string, - options?: { workspaceAccess?: WorkspaceAccess; secretMountPolicy?: SecretMountPolicy } + options?: { + workspaceAccess?: WorkspaceAccess + secretless?: boolean + secretMountPolicy?: SecretMountPolicy + } ): Promise { const data = await buildWorkspaceMdData(workspaceId, userId, options) if (!data) return WORKSPACE_CONTEXT_UNAVAILABLE_MD @@ -568,9 +583,10 @@ export async function generateWorkspaceContext( */ export async function generateWorkspaceSnapshot( workspaceId: string, - userId: string + userId: string, + options?: { workspaceAccess?: WorkspaceAccess; secretless?: boolean } ): Promise<{ markdown: string; snapshot: VfsSnapshotV1 } | null> { - const data = await buildWorkspaceMdData(workspaceId, userId) + const data = await buildWorkspaceMdData(workspaceId, userId, options) if (!data) return null return { markdown: buildWorkspaceMd(data), snapshot: buildVfsSnapshot(data) } } diff --git a/apps/sim/lib/copilot/headless/attachments.test.ts b/apps/sim/lib/copilot/headless/attachments.test.ts new file mode 100644 index 00000000000..173b63d6656 --- /dev/null +++ b/apps/sim/lib/copilot/headless/attachments.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_V2_CHAT_ATTACHMENT_BYTES, + MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES, + MAX_V2_CHAT_IMAGE_DIMENSION, + MAX_V2_CHAT_IMAGE_PIXELS, + MAX_V2_CHAT_IMAGES_TOTAL_PIXELS, + MAX_V2_CHAT_TEXT_ATTACHMENT_BYTES, +} from '@/lib/api/contracts/v2/chat' +import { prepareV2ChatAttachments } from '@/lib/copilot/headless/attachments' + +function attachment(name: string, mediaType: string, bytes: Buffer) { + return { name, mediaType, data: bytes.toString('base64') } +} + +function pngHeader(width: number, height: number): Buffer { + const buffer = Buffer.alloc(24) + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buffer) + buffer.writeUInt32BE(13, 8) + buffer.write('IHDR', 12, 'ascii') + buffer.writeUInt32BE(width, 16) + buffer.writeUInt32BE(height, 20) + return buffer +} + +function gifHeader(width: number, height: number): Buffer { + const buffer = Buffer.alloc(10) + buffer.write('GIF89a', 0, 'ascii') + buffer.writeUInt16LE(width, 6) + buffer.writeUInt16LE(height, 8) + return buffer +} + +describe('prepareV2ChatAttachments', () => { + it('maps byte-sniffed images, PDFs, and UTF-8 text to Mothership attachments', () => { + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+X2b6WQAAAABJRU5ErkJggg==', + 'base64' + ) + const result = prepareV2ChatAttachments([ + attachment('screenshot.png', 'image/png', png), + attachment('report.pdf', 'application/pdf', Buffer.from('%PDF-1.7\nexample')), + attachment('notes.md', 'text/markdown', Buffer.from('# Notes\n', 'utf8')), + ]) + + expect(result).toEqual({ + success: true, + attachments: [ + { + type: 'image', + filename: 'screenshot.png', + source: { type: 'base64', media_type: 'image/png', data: png.toString('base64') }, + }, + { + type: 'document', + filename: 'report.pdf', + source: { + type: 'base64', + media_type: 'application/pdf', + data: Buffer.from('%PDF-1.7\nexample').toString('base64'), + }, + }, + { + type: 'document', + filename: 'notes.md', + source: { + type: 'base64', + media_type: 'text/markdown', + data: Buffer.from('# Notes\n', 'utf8').toString('base64'), + }, + }, + ], + }) + }) + + it('preserves each supported raster image format', () => { + const images = [ + { + name: 'photo.jpg', + mediaType: 'image/jpeg', + data: '/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AJUAB//Z', + }, + { + name: 'animation.gif', + mediaType: 'image/gif', + data: 'R0lGODlhAQABAIAAAExpcQAAACH5BAUAAAAALAAAAAABAAEAAAICRAEAOw==', + }, + { + name: 'image.webp', + mediaType: 'image/webp', + data: 'UklGRkAAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAAAAFZQOCAYAAAAMAEAnQEqAQABAAFAJiWkAANwAP79NmgA', + }, + ] + + for (const image of images) { + expect( + prepareV2ChatAttachments([ + attachment(image.name, image.mediaType, Buffer.from(image.data, 'base64')), + ]) + ).toMatchObject({ + success: true, + attachments: [{ type: 'image', source: { media_type: image.mediaType } }], + }) + } + }) + + it('rejects non-canonical base64 before forwarding it', () => { + expect( + prepareV2ChatAttachments([{ name: 'notes.txt', mediaType: 'text/plain', data: 'YQ= ' }]) + ).toEqual({ + success: false, + error: { + code: 'BAD_REQUEST', + message: 'Attachment "notes.txt" data must be canonical base64', + }, + }) + }) + + it('rejects unsupported types and declared image types that do not match the bytes', () => { + expect( + prepareV2ChatAttachments([ + attachment('archive.zip', 'application/zip', Buffer.from('PK\x03\x04')), + ]) + ).toMatchObject({ success: false, error: { code: 'UNSUPPORTED_MEDIA_TYPE' } }) + + expect( + prepareV2ChatAttachments([ + attachment('fake.png', 'image/png', Buffer.from('')), + ]) + ).toMatchObject({ success: false, error: { code: 'UNSUPPORTED_MEDIA_TYPE' } }) + }) + + it('rejects malformed images even when their magic bytes match the declared type', () => { + expect( + prepareV2ChatAttachments([ + attachment( + 'truncated.png', + 'image/png', + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + ), + ]) + ).toEqual({ + success: false, + error: { + code: 'UNSUPPORTED_MEDIA_TYPE', + message: 'Attachment "truncated.png" is not a readable image', + }, + }) + }) + + it('rejects compressed images with an oversized axis before forwarding them', () => { + expect( + prepareV2ChatAttachments([ + attachment('wide.png', 'image/png', pngHeader(MAX_V2_CHAT_IMAGE_DIMENSION + 1, 1)), + ]) + ).toMatchObject({ success: false, error: { code: 'PAYLOAD_TOO_LARGE' } }) + + expect( + prepareV2ChatAttachments([ + attachment('tall.gif', 'image/gif', gifHeader(1, MAX_V2_CHAT_IMAGE_DIMENSION + 1)), + ]) + ).toMatchObject({ success: false, error: { code: 'PAYLOAD_TOO_LARGE' } }) + }) + + it('rejects compressed images over the total decoded-pixel limit', () => { + const width = 5000 + const height = Math.floor(MAX_V2_CHAT_IMAGE_PIXELS / width) + 1 + expect(width).toBeLessThanOrEqual(MAX_V2_CHAT_IMAGE_DIMENSION) + expect(height).toBeLessThanOrEqual(MAX_V2_CHAT_IMAGE_DIMENSION) + + expect( + prepareV2ChatAttachments([ + attachment('too-many-pixels.png', 'image/png', pngHeader(width, height)), + ]) + ).toMatchObject({ success: false, error: { code: 'PAYLOAD_TOO_LARGE' } }) + }) + + it('enforces an aggregate decoded-pixel limit across images', () => { + const width = 4000 + const height = 4000 + const pixelsPerImage = width * height + expect(pixelsPerImage).toBe(MAX_V2_CHAT_IMAGE_PIXELS) + expect(pixelsPerImage * 2).toBe(MAX_V2_CHAT_IMAGES_TOTAL_PIXELS) + + expect( + prepareV2ChatAttachments([ + attachment('one.png', 'image/png', pngHeader(width, height)), + attachment('two.gif', 'image/gif', gifHeader(width, height)), + attachment('three.png', 'image/png', pngHeader(1, 1)), + ]) + ).toEqual({ + success: false, + error: { + code: 'PAYLOAD_TOO_LARGE', + message: `Images exceed the ${MAX_V2_CHAT_IMAGES_TOTAL_PIXELS}-pixel aggregate limit`, + }, + }) + }) + + it('enforces text and binary per-file byte limits', () => { + expect( + prepareV2ChatAttachments([ + attachment( + 'large.txt', + 'text/plain', + Buffer.alloc(MAX_V2_CHAT_TEXT_ATTACHMENT_BYTES + 1, 0x61) + ), + ]) + ).toMatchObject({ success: false, error: { code: 'PAYLOAD_TOO_LARGE' } }) + + const oversizedPng = Buffer.alloc(MAX_V2_CHAT_ATTACHMENT_BYTES + 1) + pngHeader(1, 1).copy(oversizedPng) + expect( + prepareV2ChatAttachments([attachment('large.png', 'image/png', oversizedPng)]) + ).toMatchObject({ success: false, error: { code: 'PAYLOAD_TOO_LARGE' } }) + }) + + it('enforces the decoded aggregate byte limit across attachments', () => { + const imageBytes = Buffer.alloc(4 * 1024 * 1024) + pngHeader(1, 1).copy(imageBytes) + + expect(imageBytes.byteLength * 3).toBeGreaterThan(MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES) + expect( + prepareV2ChatAttachments([ + attachment('one.png', 'image/png', imageBytes), + attachment('two.png', 'image/png', imageBytes), + attachment('three.png', 'image/png', imageBytes), + ]) + ).toMatchObject({ success: false, error: { code: 'PAYLOAD_TOO_LARGE' } }) + }) + + it('rejects binary data mislabeled as text', () => { + expect( + prepareV2ChatAttachments([ + attachment('binary.txt', 'text/plain', Buffer.from([0xff, 0xfe, 0xfd])), + ]) + ).toMatchObject({ success: false, error: { code: 'UNSUPPORTED_MEDIA_TYPE' } }) + }) +}) diff --git a/apps/sim/lib/copilot/headless/attachments.ts b/apps/sim/lib/copilot/headless/attachments.ts new file mode 100644 index 00000000000..7cbd863c31d --- /dev/null +++ b/apps/sim/lib/copilot/headless/attachments.ts @@ -0,0 +1,181 @@ +import { imageSize } from 'image-size' +import { isCanonicalBase64 } from '@/lib/api/contracts/primitives' +import { + MAX_V2_CHAT_ATTACHMENT_BYTES, + MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES, + MAX_V2_CHAT_IMAGE_DIMENSION, + MAX_V2_CHAT_IMAGE_PIXELS, + MAX_V2_CHAT_IMAGES_TOTAL_PIXELS, + MAX_V2_CHAT_TEXT_ATTACHMENT_BYTES, + V2_CHAT_DOCUMENT_MEDIA_TYPES, + V2_CHAT_IMAGE_MEDIA_TYPES, + V2_CHAT_TEXT_MEDIA_TYPES, + type V2ChatAttachment, +} from '@/lib/api/contracts/v2/chat' +import { sniffImageContentType } from '@/lib/uploads/utils/validation' + +export interface MothershipInlineFileAttachment { + type: 'image' | 'document' + filename: string + source: { + type: 'base64' + media_type: string + data: string + } +} + +type AttachmentValidationErrorCode = 'BAD_REQUEST' | 'PAYLOAD_TOO_LARGE' | 'UNSUPPORTED_MEDIA_TYPE' + +export type PreparedV2ChatAttachments = + | { success: true; attachments: MothershipInlineFileAttachment[] } + | { + success: false + error: { code: AttachmentValidationErrorCode; message: string } + } + +type AttachmentValidationFailure = Extract + +const IMAGE_MEDIA_TYPES = new Set(V2_CHAT_IMAGE_MEDIA_TYPES) +const DOCUMENT_MEDIA_TYPES = new Set(V2_CHAT_DOCUMENT_MEDIA_TYPES) +const TEXT_MEDIA_TYPES = new Set(V2_CHAT_TEXT_MEDIA_TYPES) +const utf8Decoder = new TextDecoder('utf-8', { fatal: true }) + +function decodeCanonicalBase64(data: string): Buffer | null { + return data.length > 0 && isCanonicalBase64(data) ? Buffer.from(data, 'base64') : null +} + +function isPdf(buffer: Buffer): boolean { + // Match the existing workspace VFS behavior: PDFs may have a BOM or leading + // whitespace, but the signature must appear near the beginning. + return buffer.subarray(0, 1024).toString('latin1').includes('%PDF') +} + +function invalidAttachment(message: string): AttachmentValidationFailure { + return { success: false, error: { code: 'BAD_REQUEST', message } } +} + +function unsupportedAttachment(message: string): AttachmentValidationFailure { + return { success: false, error: { code: 'UNSUPPORTED_MEDIA_TYPE', message } } +} + +function oversizedAttachment(message: string): AttachmentValidationFailure { + return { success: false, error: { code: 'PAYLOAD_TOO_LARGE', message } } +} + +function validateImageDimensions( + name: string, + buffer: Buffer +): { success: true; pixels: number } | AttachmentValidationFailure { + let dimensions: ReturnType + try { + dimensions = imageSize(buffer) + } catch { + return unsupportedAttachment(`Attachment "${name}" is not a readable image`) + } + + const { width, height } = dimensions + if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) { + return unsupportedAttachment(`Attachment "${name}" has invalid image dimensions`) + } + + if ( + width > MAX_V2_CHAT_IMAGE_DIMENSION || + height > MAX_V2_CHAT_IMAGE_DIMENSION || + width > MAX_V2_CHAT_IMAGE_PIXELS / height + ) { + return oversizedAttachment( + `Attachment "${name}" dimensions ${width}x${height} exceed the ${MAX_V2_CHAT_IMAGE_DIMENSION}-pixel axis or ${MAX_V2_CHAT_IMAGE_PIXELS}-pixel image limit` + ) + } + + return { success: true, pixels: width * height } +} + +/** + * Validates the public inline-file boundary and maps it to Mothership's + * existing base64 attachment contract. No path or URL is accepted or resolved. + */ +export function prepareV2ChatAttachments( + input: V2ChatAttachment[] | undefined +): PreparedV2ChatAttachments { + if (!input?.length) return { success: true, attachments: [] } + + const prepared: MothershipInlineFileAttachment[] = [] + let totalBytes = 0 + let totalImagePixels = 0 + + for (const attachment of input) { + const isImage = IMAGE_MEDIA_TYPES.has(attachment.mediaType) + const isPdfDocument = DOCUMENT_MEDIA_TYPES.has(attachment.mediaType) + const isTextDocument = TEXT_MEDIA_TYPES.has(attachment.mediaType) + + if (!isImage && !isPdfDocument && !isTextDocument) { + return unsupportedAttachment( + `Attachment "${attachment.name}" has unsupported media type ${attachment.mediaType}` + ) + } + + const decoded = decodeCanonicalBase64(attachment.data) + if (!decoded) { + return invalidAttachment(`Attachment "${attachment.name}" data must be canonical base64`) + } + + const perFileLimit = isTextDocument + ? MAX_V2_CHAT_TEXT_ATTACHMENT_BYTES + : MAX_V2_CHAT_ATTACHMENT_BYTES + if (decoded.byteLength > perFileLimit) { + return oversizedAttachment( + `Attachment "${attachment.name}" exceeds the ${perFileLimit}-byte limit for ${attachment.mediaType}` + ) + } + + totalBytes += decoded.byteLength + if (totalBytes > MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES) { + return oversizedAttachment( + `Attachments exceed the ${MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES}-byte aggregate limit` + ) + } + + if (isImage) { + const sniffedMediaType = sniffImageContentType(decoded) + if (sniffedMediaType !== attachment.mediaType) { + return unsupportedAttachment( + `Attachment "${attachment.name}" bytes do not match ${attachment.mediaType}` + ) + } + const dimensions = validateImageDimensions(attachment.name, decoded) + if (!dimensions.success) return dimensions + totalImagePixels += dimensions.pixels + if (totalImagePixels > MAX_V2_CHAT_IMAGES_TOTAL_PIXELS) { + return oversizedAttachment( + `Images exceed the ${MAX_V2_CHAT_IMAGES_TOTAL_PIXELS}-pixel aggregate limit` + ) + } + } else if (isPdfDocument) { + if (!isPdf(decoded)) { + return unsupportedAttachment(`Attachment "${attachment.name}" is not a valid PDF`) + } + } else { + try { + const text = utf8Decoder.decode(decoded) + if (text.includes('\0')) { + return unsupportedAttachment(`Attachment "${attachment.name}" is not UTF-8 text`) + } + } catch { + return unsupportedAttachment(`Attachment "${attachment.name}" is not UTF-8 text`) + } + } + + prepared.push({ + type: isImage ? 'image' : 'document', + filename: attachment.name, + source: { + type: 'base64', + media_type: attachment.mediaType, + data: attachment.data, + }, + }) + } + + return { success: true, attachments: prepared } +} diff --git a/apps/sim/lib/copilot/headless/continuation-token.test.ts b/apps/sim/lib/copilot/headless/continuation-token.test.ts new file mode 100644 index 00000000000..299a6b67656 --- /dev/null +++ b/apps/sim/lib/copilot/headless/continuation-token.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEnv } = vi.hoisted(() => ({ + mockEnv: { BETTER_AUTH_SECRET: 'test-v2-chat-secret-that-is-at-least-32-characters' }, +})) + +vi.mock('@/lib/core/config/env', () => ({ env: mockEnv })) + +import { + issueV2ChatContinuationToken, + V2_CHAT_CONTINUATION_TTL_SECONDS, + verifyV2ChatContinuationToken, +} from './continuation-token' + +const NOW = 1_800_000_000 +const binding = { + workspaceId: 'workspace-1', + authorizationUserId: 'key-owner-1', + credentialType: 'personal' as const, + readOnly: false, +} + +describe('v2 chat continuation tokens', () => { + beforeEach(() => { + mockEnv.BETTER_AUTH_SECRET = 'test-v2-chat-secret-that-is-at-least-32-characters' + }) + + it('round-trips the private chat id only for its bound principal and workspace', async () => { + const token = await issueV2ChatContinuationToken({ + ...binding, + chatId: 'chat-private-1', + now: NOW, + }) + + await expect(verifyV2ChatContinuationToken(token, binding, NOW + 1)).resolves.toEqual({ + valid: true, + chatId: 'chat-private-1', + }) + await expect( + verifyV2ChatContinuationToken(token, { ...binding, workspaceId: 'workspace-2' }, NOW + 1) + ).resolves.toEqual({ valid: false }) + await expect( + verifyV2ChatContinuationToken( + token, + { ...binding, authorizationUserId: 'other-user' }, + NOW + 1 + ) + ).resolves.toEqual({ valid: false }) + await expect( + verifyV2ChatContinuationToken(token, { ...binding, readOnly: true }, NOW + 1) + ).resolves.toEqual({ valid: false }) + await expect( + verifyV2ChatContinuationToken(token, { ...binding, credentialType: 'workspace' }, NOW + 1) + ).resolves.toEqual({ valid: false }) + }) + + it('authenticates the optional Sim persistence claim without changing legacy tokens', async () => { + const syncedToken = await issueV2ChatContinuationToken({ + ...binding, + chatId: 'chat-synced-1', + persistence: 'sim', + now: NOW, + }) + const legacyToken = await issueV2ChatContinuationToken({ + ...binding, + chatId: 'chat-legacy-1', + now: NOW, + }) + + await expect(verifyV2ChatContinuationToken(syncedToken, binding, NOW + 1)).resolves.toEqual({ + valid: true, + chatId: 'chat-synced-1', + persistence: 'sim', + }) + await expect(verifyV2ChatContinuationToken(legacyToken, binding, NOW + 1)).resolves.toEqual({ + valid: true, + chatId: 'chat-legacy-1', + }) + }) + + it('rejects tampering and expiry', async () => { + const token = await issueV2ChatContinuationToken({ + ...binding, + chatId: 'chat-private-1', + now: NOW, + }) + const tampered = `${token.slice(0, -1)}${token.endsWith('a') ? 'b' : 'a'}` + + await expect(verifyV2ChatContinuationToken(tampered, binding, NOW + 1)).resolves.toEqual({ + valid: false, + }) + await expect( + verifyV2ChatContinuationToken(token, binding, NOW + V2_CHAT_CONTINUATION_TTL_SECONDS) + ).resolves.toEqual({ valid: false }) + }) + + it('encrypts the claims with a fresh nonce so decoding token segments cannot reveal the chat id', async () => { + const chatId = 'chat-private-1' + const token = await issueV2ChatContinuationToken({ ...binding, chatId, now: NOW }) + const nextToken = await issueV2ChatContinuationToken({ ...binding, chatId, now: NOW }) + const [prefix, ...encodedSegments] = token.split('.') + + expect(prefix).toBe('sim-v2-chat-v1') + expect(encodedSegments).toHaveLength(1) + expect(nextToken).not.toBe(token) + expect(token).not.toContain(chatId) + for (const segment of encodedSegments) { + expect(Buffer.from(segment, 'base64url').toString('utf8')).not.toContain(chatId) + } + }) +}) diff --git a/apps/sim/lib/copilot/headless/continuation-token.ts b/apps/sim/lib/copilot/headless/continuation-token.ts new file mode 100644 index 00000000000..46e646cbe0b --- /dev/null +++ b/apps/sim/lib/copilot/headless/continuation-token.ts @@ -0,0 +1,140 @@ +import { createHmac } from 'node:crypto' +import { decrypt, encrypt } from '@sim/security/encryption' +import { env } from '@/lib/core/config/env' + +const TOKEN_PREFIX = 'sim-v2-chat-v1' +const TOKEN_MAX_LENGTH = 4096 + +/** Interactive CLI sessions may refresh this rolling expiry on every turn. */ +export const V2_CHAT_CONTINUATION_TTL_SECONDS = 24 * 60 * 60 + +interface ContinuationClaims { + version: 1 + chatId: string + workspaceId: string + authorizationUserId: string + credentialType: 'personal' | 'workspace' + readOnly: boolean + /** Present only when the chat is backed by Sim's persisted chat tables. */ + persistence?: 'sim' + issuedAt: number + expiresAt: number +} + +export interface ContinuationBinding { + workspaceId: string + authorizationUserId: string + credentialType: 'personal' | 'workspace' + readOnly: boolean +} + +export interface IssueContinuationTokenInput extends ContinuationBinding { + chatId: string + persistence?: 'sim' + /** Unix seconds; exposed only to keep expiry behavior deterministic in tests. */ + now?: number +} + +export type VerifiedContinuationToken = + | { valid: true; chatId: string; persistence?: 'sim' } + | { valid: false } + +function encryptionKey(): Buffer { + // Derive a dedicated 256-bit key instead of using BETTER_AUTH_SECRET + // directly. The purpose string prevents ciphertexts from another feature + // backed by the same deployment secret from being valid here. + return createHmac('sha256', env.BETTER_AUTH_SECRET) + .update(`${TOKEN_PREFIX}:aes-256-gcm-encryption-key`, 'utf8') + .digest() +} + +function decodeCanonicalBase64Url(segment: string): string | null { + if (!segment || !/^[A-Za-z0-9_-]+$/.test(segment)) return null + const decoded = Buffer.from(segment, 'base64url') + return decoded.toString('base64url') === segment ? decoded.toString('utf8') : null +} + +function isContinuationClaims(value: unknown): value is ContinuationClaims { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const claims = value as Partial + return ( + claims.version === 1 && + typeof claims.chatId === 'string' && + claims.chatId.length > 0 && + claims.chatId.length <= 255 && + typeof claims.workspaceId === 'string' && + claims.workspaceId.length > 0 && + claims.workspaceId.length <= 255 && + typeof claims.authorizationUserId === 'string' && + claims.authorizationUserId.length > 0 && + claims.authorizationUserId.length <= 255 && + (claims.credentialType === 'personal' || claims.credentialType === 'workspace') && + typeof claims.readOnly === 'boolean' && + (claims.persistence === undefined || claims.persistence === 'sim') && + Number.isSafeInteger(claims.issuedAt) && + Number.isSafeInteger(claims.expiresAt) && + (claims.expiresAt as number) > (claims.issuedAt as number) + ) +} + +/** Issues an opaque, authenticated handle for one private Mothership chat. */ +export async function issueV2ChatContinuationToken( + input: IssueContinuationTokenInput +): Promise { + const issuedAt = input.now ?? Math.floor(Date.now() / 1000) + const claims: ContinuationClaims = { + version: 1, + chatId: input.chatId, + workspaceId: input.workspaceId, + authorizationUserId: input.authorizationUserId, + credentialType: input.credentialType, + readOnly: input.readOnly, + ...(input.persistence ? { persistence: input.persistence } : {}), + issuedAt, + expiresAt: issuedAt + V2_CHAT_CONTINUATION_TTL_SECONDS, + } + + const { encrypted } = await encrypt(JSON.stringify(claims), encryptionKey()) + return `${TOKEN_PREFIX}.${Buffer.from(encrypted, 'utf8').toString('base64url')}` +} + +/** + * Authenticates/decrypts the handle, then verifies expiry and the request's + * ownership tuple. Every failure is intentionally indistinguishable to callers. + */ +export async function verifyV2ChatContinuationToken( + token: string, + binding: ContinuationBinding, + now: number = Math.floor(Date.now() / 1000) +): Promise { + if (!token || token.length > TOKEN_MAX_LENGTH) return { valid: false } + + const [prefix, encodedCiphertext, ...extra] = token.split('.') + if (prefix !== TOKEN_PREFIX || !encodedCiphertext || extra.length > 0) { + return { valid: false } + } + + try { + const ciphertext = decodeCanonicalBase64Url(encodedCiphertext) + if (!ciphertext) return { valid: false } + const { decrypted } = await decrypt(ciphertext, encryptionKey()) + const parsed = JSON.parse(decrypted) as unknown + if (!isContinuationClaims(parsed)) return { valid: false } + if (parsed.expiresAt <= now || parsed.issuedAt > now + 60) return { valid: false } + if ( + parsed.workspaceId !== binding.workspaceId || + parsed.authorizationUserId !== binding.authorizationUserId || + parsed.credentialType !== binding.credentialType || + parsed.readOnly !== binding.readOnly + ) { + return { valid: false } + } + return { + valid: true, + chatId: parsed.chatId, + ...(parsed.persistence ? { persistence: parsed.persistence } : {}), + } + } catch { + return { valid: false } + } +} diff --git a/apps/sim/lib/copilot/headless/workspace-chat.test.ts b/apps/sim/lib/copilot/headless/workspace-chat.test.ts new file mode 100644 index 00000000000..154efb7da25 --- /dev/null +++ b/apps/sim/lib/copilot/headless/workspace-chat.test.ts @@ -0,0 +1,544 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAssertActiveWorkspaceAccess, + mockBuildIntegrationToolSchemas, + mockBuildTaggedMcpToolSchemas, + mockComputeWorkspaceEntitlements, + mockCreateCopilotEnvironmentContext, + mockGenerateWorkspaceSnapshot, + mockPrepareCopilotEnvironmentContext, + mockProcessContextsServer, + mockRunHeadlessCopilotLifecycle, +} = vi.hoisted(() => ({ + mockAssertActiveWorkspaceAccess: vi.fn(), + mockBuildIntegrationToolSchemas: vi.fn(), + mockBuildTaggedMcpToolSchemas: vi.fn(), + mockComputeWorkspaceEntitlements: vi.fn(), + mockCreateCopilotEnvironmentContext: vi.fn(), + mockGenerateWorkspaceSnapshot: vi.fn(), + mockPrepareCopilotEnvironmentContext: vi.fn(), + mockProcessContextsServer: vi.fn(), + mockRunHeadlessCopilotLifecycle: vi.fn(), +})) + +vi.mock('@/lib/copilot/chat/payload', () => ({ + buildIntegrationToolSchemas: mockBuildIntegrationToolSchemas, +})) + +vi.mock('@/lib/copilot/chat/process-contents', () => ({ + processContextsServer: mockProcessContextsServer, +})) + +vi.mock('@/lib/copilot/mcp-tools', () => ({ + buildTaggedMcpToolSchemas: mockBuildTaggedMcpToolSchemas, +})) + +vi.mock('@/lib/copilot/chat/workspace-context', () => ({ + generateWorkspaceSnapshot: mockGenerateWorkspaceSnapshot, +})) + +vi.mock('@/lib/copilot/entitlements', () => ({ + computeWorkspaceEntitlements: mockComputeWorkspaceEntitlements, +})) + +vi.mock('@/lib/copilot/environment-context', () => ({ + createCopilotEnvironmentContext: mockCreateCopilotEnvironmentContext, + prepareCopilotEnvironmentContext: mockPrepareCopilotEnvironmentContext, +})) + +vi.mock('@/lib/copilot/request/lifecycle/headless', () => ({ + runHeadlessCopilotLifecycle: mockRunHeadlessCopilotLifecycle, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + isDocSandboxEnabled: false, + isHosted: false, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + assertActiveWorkspaceAccess: mockAssertActiveWorkspaceAccess, +})) + +import { publicChatUsageLimitMessage, runWorkspaceChat, toPublicChatResult } from './workspace-chat' + +const billingAttribution = { + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + organizationId: 'organization-1', + billedAccountUserId: 'billed-account-1', + billingEntity: { type: 'organization' as const, id: 'organization-1' }, + billingPeriod: { + start: '2026-08-01T00:00:00.000Z', + end: '2026-09-01T00:00:00.000Z', + }, + payerSubscription: null, +} + +describe('runWorkspaceChat', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAssertActiveWorkspaceAccess.mockResolvedValue({ permission: 'admin' }) + mockGenerateWorkspaceSnapshot.mockResolvedValue({ + markdown: 'workspace markdown', + snapshot: { workspace: { id: 'workspace-1', name: 'Acme', ownerId: 'owner-1' } }, + }) + mockComputeWorkspaceEntitlements.mockResolvedValue(['custom-blocks']) + mockCreateCopilotEnvironmentContext.mockResolvedValue({ + resolvedSecretTraceRegistry: { kind: 'empty-registry' }, + }) + mockPrepareCopilotEnvironmentContext.mockResolvedValue({ + resolvedSecretTraceRegistry: { kind: 'full-registry' }, + }) + mockBuildIntegrationToolSchemas.mockResolvedValue([ + { name: 'slack_send', description: 'Send Slack message', input_schema: {} }, + ]) + mockBuildTaggedMcpToolSchemas.mockResolvedValue([]) + mockProcessContextsServer.mockResolvedValue([]) + mockRunHeadlessCopilotLifecycle.mockResolvedValue({ + success: true, + content: 'answer', + contentBlocks: [], + toolCalls: [], + usage: { prompt: 12, completion: 3 }, + }) + }) + + it('uses normal Mothership permissions, integrations, memory, and secrets by default', async () => { + const userStopController = new AbortController() + const onComplete = vi.fn() + const onError = vi.fn() + await runWorkspaceChat({ + prompt: 'Fix the workflow', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + executionId: 'execution-1', + runId: 'run-1', + billingAttribution, + userStopSignal: userStopController.signal, + onComplete, + onError, + }) + + expect(mockAssertActiveWorkspaceAccess).toHaveBeenCalledWith('workspace-1', 'key-owner-1') + expect(mockGenerateWorkspaceSnapshot).toHaveBeenCalledWith('workspace-1', 'key-owner-1', { + workspaceAccess: { permission: 'admin' }, + secretless: false, + }) + expect(mockPrepareCopilotEnvironmentContext).toHaveBeenCalledWith('key-owner-1', 'workspace-1') + expect(mockCreateCopilotEnvironmentContext).not.toHaveBeenCalled() + expect(mockBuildIntegrationToolSchemas).toHaveBeenCalledWith( + 'key-owner-1', + 'message-1', + { schemaSurface: 'copilot' }, + 'workspace-1' + ) + + const [payload, options] = mockRunHeadlessCopilotLifecycle.mock.calls[0] + expect(payload).toMatchObject({ + message: 'Fix the workflow', + userId: 'billing-actor', + userPermission: 'admin', + integrationTools: [ + { name: 'slack_send', description: 'Send Slack message', input_schema: {} }, + ], + }) + expect(payload).not.toHaveProperty('queryOnly') + expect(payload).not.toHaveProperty('disableUserMemory') + expect(options).toMatchObject({ + userId: 'billing-actor', + authorizationUserId: 'key-owner-1', + executionId: 'execution-1', + runId: 'run-1', + autoCreateRunIdentity: false, + userPermission: 'admin', + secretActorUserId: 'key-owner-1', + environmentContext: { resolvedSecretTraceRegistry: { kind: 'full-registry' } }, + billingAttribution, + userStopSignal: userStopController.signal, + onComplete, + onError, + }) + expect(options).not.toHaveProperty('secretMountPolicy') + }) + + it('uses the workspace-chat route with a read-only, secretless server policy', async () => { + await runWorkspaceChat({ + prompt: 'What is deployed?', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + billingAttribution, + readOnly: true, + }) + + expect(mockCreateCopilotEnvironmentContext).toHaveBeenCalledWith('key-owner-1', 'workspace-1', { + personalEncrypted: {}, + workspaceEncrypted: {}, + personalDecrypted: {}, + workspaceDecrypted: {}, + personalOwners: {}, + conflicts: [], + decryptionFailures: [], + }) + expect(mockGenerateWorkspaceSnapshot).toHaveBeenCalledWith('workspace-1', 'key-owner-1', { + workspaceAccess: { permission: 'admin' }, + secretless: true, + }) + expect(mockComputeWorkspaceEntitlements).toHaveBeenCalledWith('workspace-1', 'key-owner-1') + + const [payload, options] = mockRunHeadlessCopilotLifecycle.mock.calls[0] + expect(payload).toEqual({ + message: 'What is deployed?', + userId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + mode: 'agent', + queryOnly: true, + disableUserMemory: true, + workspaceContext: 'workspace markdown', + vfs: { workspace: { id: 'workspace-1', name: 'Acme', ownerId: 'owner-1' } }, + userPermission: 'read', + entitlements: ['custom-blocks'], + isHosted: false, + }) + expect(payload).not.toHaveProperty('model') + expect(payload).not.toHaveProperty('provider') + expect(payload).not.toHaveProperty('integrationTools') + expect(payload).not.toHaveProperty('mothershipTools') + + expect(options).toMatchObject({ + userId: 'billing-actor', + authorizationUserId: 'key-owner-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + autoCreateRunIdentity: false, + simRequestId: 'request-1', + goRoute: '/api/mothership/v2-chat', + resumeRoute: '/api/tools/v2-chat/resume', + autoExecuteTools: true, + interactive: false, + billingAttribution, + userPermission: 'read', + secretActorUserId: null, + secretMountPolicy: { secretScope: 'selected', mountedSecrets: [] }, + environmentContext: { resolvedSecretTraceRegistry: { kind: 'empty-registry' } }, + }) + }) + + it('resolves structured tags and exposes only explicitly tagged MCP tools', async () => { + const contexts = [ + { kind: 'workflow' as const, workflowId: 'workflow-1', label: 'Release' }, + { kind: 'skill' as const, skillId: 'skill-1', label: 'review' }, + { kind: 'mcp' as const, serverId: 'mcp-1', label: 'Docs' }, + ] + mockProcessContextsServer.mockResolvedValueOnce([ + { type: 'workflow', content: '', path: 'workflows/release', tag: '@Release' }, + { type: 'skill', content: 'Review carefully', tag: '/review' }, + ]) + mockBuildTaggedMcpToolSchemas.mockResolvedValueOnce([ + { name: 'mcp_docs_search', description: 'Search docs', input_schema: {} }, + ]) + + await runWorkspaceChat({ + prompt: 'Use @Release and /review with /Docs', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + billingAttribution, + contexts, + }) + + expect(mockProcessContextsServer).toHaveBeenCalledWith( + contexts, + 'key-owner-1', + 'Use @Release and /review with /Docs', + 'workspace-1', + 'chat-1' + ) + expect(mockBuildTaggedMcpToolSchemas).toHaveBeenCalledWith('key-owner-1', 'workspace-1', [ + 'mcp-1', + ]) + expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).toEqual( + expect.objectContaining({ + context: [ + { type: 'workflow', content: '', path: 'workflows/release', tag: '@Release' }, + { type: 'skill', content: 'Review carefully', tag: '/review' }, + ], + mothershipTools: [ + { name: 'mcp_docs_search', description: 'Search docs', input_schema: {} }, + ], + }) + ) + }) + + it('unions inherited MCP ids with this turn while expanding only explicit contexts', async () => { + const contexts = [ + { kind: 'skill' as const, skillId: 'skill-1', label: 'review' }, + { kind: 'mcp' as const, serverId: 'mcp-current', label: 'Current' }, + ] + + await runWorkspaceChat({ + prompt: 'Continue with /review and /Current', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + billingAttribution, + contexts, + mcpServerIds: ['mcp-history', 'mcp-current'], + }) + + expect(mockProcessContextsServer).toHaveBeenCalledWith( + contexts, + 'key-owner-1', + 'Continue with /review and /Current', + 'workspace-1', + 'chat-1' + ) + expect(mockBuildTaggedMcpToolSchemas).toHaveBeenCalledWith('key-owner-1', 'workspace-1', [ + 'mcp-history', + 'mcp-current', + ]) + }) + + it('drops MCP contexts and tools from secretless requests', async () => { + const workflow = { + kind: 'workflow' as const, + workflowId: 'workflow-1', + label: 'Release', + } + await runWorkspaceChat({ + prompt: 'Inspect @Release with /Docs', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + billingAttribution, + readOnly: true, + contexts: [workflow, { kind: 'mcp', serverId: 'mcp-1', label: 'Docs' }], + mcpServerIds: ['mcp-history'], + }) + + expect(mockProcessContextsServer).toHaveBeenCalledWith( + [workflow], + 'key-owner-1', + 'Inspect @Release with /Docs', + 'workspace-1', + 'chat-1' + ) + expect(mockBuildTaggedMcpToolSchemas).not.toHaveBeenCalled() + expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).not.toHaveProperty('mothershipTools') + }) + + it('keeps shared workspace credentials out of personal environment, integrations, and memory', async () => { + await runWorkspaceChat({ + prompt: 'Fix the workflow', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + billingAttribution, + sharedWorkspaceCredential: true, + }) + + expect(mockPrepareCopilotEnvironmentContext).not.toHaveBeenCalled() + expect(mockCreateCopilotEnvironmentContext).toHaveBeenCalledWith('key-owner-1', 'workspace-1', { + personalEncrypted: {}, + workspaceEncrypted: {}, + personalDecrypted: {}, + workspaceDecrypted: {}, + personalOwners: {}, + conflicts: [], + decryptionFailures: [], + }) + expect(mockBuildIntegrationToolSchemas).not.toHaveBeenCalled() + expect(mockGenerateWorkspaceSnapshot).toHaveBeenCalledWith('workspace-1', 'key-owner-1', { + workspaceAccess: { permission: 'admin' }, + secretless: true, + }) + + const [payload, options] = mockRunHeadlessCopilotLifecycle.mock.calls[0] + expect(payload).toMatchObject({ + userId: 'billing-actor', + userPermission: 'admin', + disableUserMemory: true, + }) + expect(payload).not.toHaveProperty('queryOnly') + expect(payload).not.toHaveProperty('integrationTools') + expect(options).toMatchObject({ + userId: 'billing-actor', + authorizationUserId: 'key-owner-1', + secretActorUserId: null, + secretMountPolicy: { secretScope: 'selected', mountedSecrets: [] }, + environmentContext: { resolvedSecretTraceRegistry: { kind: 'empty-registry' } }, + }) + }) + + it('authorizes before reading workspace context or resolving runtime state', async () => { + let resolveAccess: ((value: { permission: string }) => void) | undefined + mockAssertActiveWorkspaceAccess.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveAccess = resolve + }) + ) + + const pending = runWorkspaceChat({ + prompt: 'Fix the workflow', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + billingAttribution, + }) + + expect(mockGenerateWorkspaceSnapshot).not.toHaveBeenCalled() + expect(mockComputeWorkspaceEntitlements).not.toHaveBeenCalled() + expect(mockPrepareCopilotEnvironmentContext).not.toHaveBeenCalled() + expect(mockBuildIntegrationToolSchemas).not.toHaveBeenCalled() + + resolveAccess?.({ permission: 'admin' }) + await pending + }) + + it('does not start the Go leg when cancellation wins during workspace preparation', async () => { + const abortController = new AbortController() + let resolveSnapshot!: (value: { + markdown: string + snapshot: { workspace: { id: string; name: string; ownerId: string } } + }) => void + mockGenerateWorkspaceSnapshot.mockReturnValueOnce( + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + + const pending = runWorkspaceChat({ + prompt: 'Fix the workflow', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + billingAttribution, + abortSignal: abortController.signal, + }) + await vi.waitFor(() => expect(mockGenerateWorkspaceSnapshot).toHaveBeenCalledTimes(1)) + + abortController.abort('test cancellation') + resolveSnapshot({ + markdown: 'workspace markdown', + snapshot: { workspace: { id: 'workspace-1', name: 'Acme', ownerId: 'owner-1' } }, + }) + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() + }) + + it('fails rather than asking without a workspace snapshot', async () => { + mockGenerateWorkspaceSnapshot.mockResolvedValueOnce(null) + + await expect( + runWorkspaceChat({ + prompt: 'hello', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + billingAttribution, + readOnly: true, + }) + ).rejects.toThrow('Workspace context is unavailable') + expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() + }) + + it('passes validated inline attachments without exposing storage paths or URLs', async () => { + const fileAttachments = [ + { + type: 'document' as const, + filename: 'notes.txt', + source: { + type: 'base64' as const, + media_type: 'text/plain', + data: 'aGk=', + }, + }, + ] + + await runWorkspaceChat({ + prompt: 'Read this', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + billingAttribution, + readOnly: true, + fileAttachments, + }) + + expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).toEqual( + expect.objectContaining({ fileAttachments }) + ) + }) +}) + +describe('toPublicChatResult', () => { + it('exposes only final content, opaque continuation token, and token usage', () => { + expect( + toPublicChatResult( + { + success: true, + content: 'answer', + contentBlocks: [{ type: 'thinking', content: 'private', timestamp: 1 }], + toolCalls: [{ id: 'tool-1', name: 'read', status: 'success' }], + usage: { prompt: 12, completion: 3 }, + cost: { input: 1, output: 2, total: 3 }, + }, + 'continuation-token-1' + ) + ).toEqual({ + content: 'answer', + continuationToken: 'continuation-token-1', + usage: { prompt: 12, completion: 3, total: 15 }, + }) + }) +}) + +describe('publicChatUsageLimitMessage', () => { + it('turns the interactive upgrade tag back into a public error message', () => { + expect( + publicChatUsageLimitMessage( + '{"reason":"usage_limit","action":"increase_limit","message":"Ask an org admin."}' + ) + ).toBe('Ask an org admin.') + expect(publicChatUsageLimitMessage('bad json')).toBe( + 'Usage limit exceeded' + ) + expect(publicChatUsageLimitMessage('ordinary answer')).toBeNull() + }) +}) diff --git a/apps/sim/lib/copilot/headless/workspace-chat.ts b/apps/sim/lib/copilot/headless/workspace-chat.ts new file mode 100644 index 00000000000..ba1f3cd9f43 --- /dev/null +++ b/apps/sim/lib/copilot/headless/workspace-chat.ts @@ -0,0 +1,262 @@ +import type { V2ChatContext } from '@/lib/api/contracts/v2/chat' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' +import { processContextsServer } from '@/lib/copilot/chat/process-contents' +import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context' +import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' +import { + createCopilotEnvironmentContext, + prepareCopilotEnvironmentContext, +} from '@/lib/copilot/environment-context' +import type { MothershipInlineFileAttachment } from '@/lib/copilot/headless/attachments' +import { buildTaggedMcpToolSchemas } from '@/lib/copilot/mcp-tools' +import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' +import type { OrchestratorResult, StreamEvent } from '@/lib/copilot/request/types' +import { isDocSandboxEnabled, isHosted } from '@/lib/core/config/env-flags' +import type { EnvironmentResolutionSnapshot } from '@/lib/environment/utils' +import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const EMPTY_ENVIRONMENT: EnvironmentResolutionSnapshot = { + personalEncrypted: {}, + workspaceEncrypted: {}, + personalDecrypted: {}, + workspaceDecrypted: {}, + personalOwners: {}, + conflicts: [], + decryptionFailures: [], +} + +function throwIfWorkspaceChatAborted( + input: Pick +) { + if (!input.abortSignal?.aborted && !input.userStopSignal?.aborted) return + const error = new Error('Chat request cancelled') + error.name = 'AbortError' + throw error +} + +export interface WorkspaceChatInput { + prompt: string + authorizationUserId: string + actorUserId: string + workspaceId: string + chatId: string + messageId: string + requestId: string + executionId?: string + runId?: string + billingAttribution: BillingAttributionSnapshot + /** Explicit safety mode. Normal Mothership capabilities are the default. */ + readOnly?: boolean + /** Shared workspace credentials never inherit their creator's personal runtime state. */ + sharedWorkspaceCredential?: boolean + fileAttachments?: MothershipInlineFileAttachment[] + /** Identity-bearing `@` resources and `/` skill/MCP tags for this turn. */ + contexts?: V2ChatContext[] + /** MCP servers explicitly tagged on earlier persisted turns. */ + mcpServerIds?: string[] + abortSignal?: AbortSignal + /** Stops local Sim work without cancelling the active Go stream transport. */ + userStopSignal?: AbortSignal + /** Signals that Go accepted and early-persisted the initial turn. */ + onInitialStreamAccepted?: () => void + onEvent?: (event: StreamEvent) => void | Promise + onComplete?: (result: OrchestratorResult) => void | Promise + onError?: (error: Error, result?: OrchestratorResult) => void | Promise +} + +/** + * Runs the public CLI workspace-chat surface with the normal Mothership + * capability set, or its explicit query-only projection. + * + * The caller has already authenticated and authorized the requested workspace. + * `authorizationUserId` remains the principal whose current membership governs + * workspace access. `actorUserId` is deliberately separate: personal keys use + * that same principal while workspace keys use the workspace billing account as + * the system actor. Local tool execution is projected back onto the + * authorization principal while billing remains frozen to `actorUserId`. + * + * Query-only is opt-in and gets an empty secret catalog plus the subtractive Go + * tool policy. Personal credentials in normal mode mirror workspace Mothership. + * Shared workspace credentials remain fully workspace-authorized but cannot + * inherit their creator's personal environment, integrations, or memory. + */ +export async function runWorkspaceChat(input: WorkspaceChatInput): Promise { + throwIfWorkspaceChatAborted(input) + const readOnly = input.readOnly === true + const secretless = readOnly || input.sharedWorkspaceCredential === true + // MCP execution depends on user-held credentials, which read-only and shared + // workspace credentials deliberately cannot inherit. + const contexts = (input.contexts ?? []).filter((context) => !secretless || context.kind !== 'mcp') + const mcpServerIds = secretless + ? [] + : Array.from( + new Set([ + ...(input.mcpServerIds ?? []), + ...contexts.flatMap((context) => (context.kind === 'mcp' ? [context.serverId] : [])), + ]) + ) + + /** + * Keep this authorization barrier ahead of every workspace/context read. The + * route also checks access, but this helper must fail closed on its own. + */ + const workspaceAccess = await assertActiveWorkspaceAccess( + input.workspaceId, + input.authorizationUserId + ) + throwIfWorkspaceChatAborted(input) + const [ + workspaceSnapshot, + entitlements, + environmentContext, + integrationTools, + agentContexts, + mothershipTools, + ] = await Promise.all([ + generateWorkspaceSnapshot(input.workspaceId, input.authorizationUserId, { + workspaceAccess, + secretless, + }), + computeWorkspaceEntitlements(input.workspaceId, input.authorizationUserId), + secretless + ? createCopilotEnvironmentContext( + input.authorizationUserId, + input.workspaceId, + EMPTY_ENVIRONMENT + ) + : prepareCopilotEnvironmentContext(input.authorizationUserId, input.workspaceId), + secretless + ? Promise.resolve([]) + : buildIntegrationToolSchemas( + input.authorizationUserId, + input.messageId, + { schemaSurface: 'copilot' }, + input.workspaceId + ), + processContextsServer( + contexts, + input.authorizationUserId, + input.prompt, + input.workspaceId, + input.chatId + ), + secretless + ? Promise.resolve([]) + : buildTaggedMcpToolSchemas(input.authorizationUserId, input.workspaceId, mcpServerIds), + ]) + throwIfWorkspaceChatAborted(input) + + if (!workspaceSnapshot) { + throw new Error('Workspace context is unavailable') + } + const userPermission = readOnly ? 'read' : workspaceAccess.permission + if (!userPermission) { + // `assertActiveWorkspaceAccess` should make this unreachable, but fail + // closed if its access/permission invariants ever drift apart. + throw new Error('Workspace permission is unavailable') + } + + const requestPayload: Record = { + message: input.prompt, + userId: input.actorUserId, + workspaceId: input.workspaceId, + chatId: input.chatId, + messageId: input.messageId, + mode: 'agent', + ...(readOnly ? { queryOnly: true } : {}), + ...(secretless ? { disableUserMemory: true } : {}), + ...(input.fileAttachments?.length ? { fileAttachments: input.fileAttachments } : {}), + ...(agentContexts.length ? { context: agentContexts } : {}), + workspaceContext: workspaceSnapshot.markdown, + vfs: workspaceSnapshot.snapshot, + userPermission, + ...(entitlements.length > 0 ? { entitlements } : {}), + ...(integrationTools.length > 0 ? { integrationTools } : {}), + ...(mothershipTools.length > 0 ? { mothershipTools } : {}), + ...(isDocSandboxEnabled ? { docCompiler: 'python' } : {}), + isHosted, + } + + return runHeadlessCopilotLifecycle(requestPayload, { + userId: input.actorUserId, + authorizationUserId: input.authorizationUserId, + workspaceId: input.workspaceId, + chatId: input.chatId, + executionId: input.executionId, + runId: input.runId, + // This wrapper owns Sim run creation. Synced calls arrive with route-created + // ids; Go-only/workspace-key chats intentionally have no Sim parent row. + autoCreateRunIdentity: false, + simRequestId: input.requestId, + // This policy-aware route intentionally fails closed against an older Go + // task that would ignore queryOnly/disableUserMemory during a mixed deploy. + goRoute: '/api/mothership/v2-chat', + resumeRoute: '/api/tools/v2-chat/resume', + autoExecuteTools: true, + interactive: false, + abortSignal: input.abortSignal, + userStopSignal: input.userStopSignal, + billingAttribution: input.billingAttribution, + userPermission, + ...(secretless + ? { + secretActorUserId: null, + secretMountPolicy: { secretScope: 'selected' as const, mountedSecrets: [] }, + } + : { secretActorUserId: input.authorizationUserId }), + environmentContext, + ...(input.onInitialStreamAccepted + ? { onInitialStreamAccepted: input.onInitialStreamAccepted } + : {}), + onEvent: input.onEvent, + onComplete: input.onComplete, + onError: input.onError, + }) +} + +export interface PublicChatResult { + content: string + continuationToken: string + usage: { + prompt?: number + completion?: number + total?: number + } +} + +/** + * The lifecycle turns an upstream 402 into the UI's synthetic usage tag so an + * interactive browser can render an upgrade card. A public stream has no such + * renderer; recover the message and expose it as a normal v2 stream error. + */ +export function publicChatUsageLimitMessage(content: string): string | null { + const match = /^\s*([\s\S]+)<\/usage_upgrade>\s*$/.exec(content) + if (!match) return null + try { + const payload = JSON.parse(match[1]) as { message?: unknown } + return typeof payload.message === 'string' && payload.message.trim() + ? payload.message + : 'Usage limit exceeded' + } catch { + return 'Usage limit exceeded' + } +} + +/** Projects the internal result onto the intentionally small public surface. */ +export function toPublicChatResult( + result: OrchestratorResult, + continuationToken: string +): PublicChatResult { + return { + content: result.content, + continuationToken, + usage: result.usage + ? { + prompt: result.usage.prompt, + completion: result.usage.completion, + total: result.usage.prompt + result.usage.completion, + } + : {}, + } +} diff --git a/apps/sim/lib/copilot/request/context/request-context.ts b/apps/sim/lib/copilot/request/context/request-context.ts index 1fd556a76bf..ceefb46bfb1 100644 --- a/apps/sim/lib/copilot/request/context/request-context.ts +++ b/apps/sim/lib/copilot/request/context/request-context.ts @@ -17,6 +17,7 @@ export function createStreamingContext(overrides?: Partial): S contentBlocks: [], toolCalls: new Map(), pendingToolPromises: new Map(), + inFlightToolExecutions: new Map(), currentThinkingBlock: null, subagentThinkingBlocks: new Map(), isInThinkingBlock: false, diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index efa9d8ef7d8..cdf051e5933 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -519,6 +519,42 @@ describe('copilot go stream helpers', () => { expect(fetch).toHaveBeenCalledTimes(1) }) + it('reports acceptance only after an OK response exposes its stream body', async () => { + const complete = createEvent({ + streamId: 'stream-1', + cursor: '1', + seq: 1, + requestId: 'req-1', + type: MothershipStreamV1EventType.complete, + payload: { status: MothershipStreamV1CompletionStatus.complete }, + }) + const onAccepted = vi.fn() + vi.mocked(fetch).mockResolvedValueOnce(createSseResponse([complete])) + + await runStreamLoop( + 'https://example.com/mothership/stream', + {}, + createStreamingContext(), + { userId: 'user-1', workflowId: 'workflow-1' }, + { timeout: 1000, onAccepted } + ) + + expect(onAccepted).toHaveBeenCalledOnce() + + onAccepted.mockClear() + vi.mocked(fetch).mockResolvedValueOnce(new Response('bad gateway', { status: 502 })) + await expect( + runStreamLoop( + 'https://example.com/mothership/stream', + {}, + createStreamingContext(), + { userId: 'user-1', workflowId: 'workflow-1' }, + { timeout: 1000, onAccepted } + ) + ).rejects.toThrow('Copilot backend error') + expect(onAccepted).not.toHaveBeenCalled() + }) + it('does not retry non-transient backend statuses before the SSE stream opens', async () => { vi.mocked(fetch).mockResolvedValueOnce(new Response('limit reached', { status: 402 })) diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts index 471904c16fe..833c490df8c 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -134,6 +134,8 @@ export interface StreamLoopOptions extends OrchestratorOptions { * Called when the Go backend's trace ID (go_trace_id) is first received via SSE. */ onGoTraceId?: (goTraceId: string) => void + /** Called once the upstream accepted this leg and exposed an SSE body. */ + onAccepted?: () => void otelContext?: Context } @@ -209,6 +211,8 @@ export async function runStreamLoop( throw new CopilotBackendError('Copilot backend response missing body') } + options.onAccepted?.() + context.trace.endSpan(fetchSpan) const bodySpan = context.trace.startSpan(`SSE Body → ${pathname}`, 'sim.http.stream_body', { diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index b98cbb79438..d8adfb16857 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -4,6 +4,7 @@ import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TOOL_WATCHDOG_DEFAULT_MS } from '@/lib/copilot/constants' import { TraceCollector } from '@/lib/copilot/request/trace' const { isSimExecuted, executeTool, ensureHandlersRegistered, toolRequiresApproval } = vi.hoisted( @@ -15,11 +16,13 @@ const { isSimExecuted, executeTool, ensureHandlersRegistered, toolRequiresApprov }) ) -const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall } = vi.hoisted(() => ({ - upsertAsyncToolCall: vi.fn(), - markAsyncToolRunning: vi.fn(), - completeAsyncToolCall: vi.fn(), -})) +const { upsertAsyncToolCall, getAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall } = + vi.hoisted(() => ({ + upsertAsyncToolCall: vi.fn(), + getAsyncToolCall: vi.fn(), + markAsyncToolRunning: vi.fn(), + completeAsyncToolCall: vi.fn(), + })) const { waitForClientToolCompletion, waitForToolCompletion, waitForWorkflowToolCompletion } = vi.hoisted(() => ({ @@ -47,7 +50,7 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({ getLatestRunForStream: vi.fn(), getRunSegment: vi.fn(), createRunCheckpoint: vi.fn(), - getAsyncToolCall: vi.fn(), + getAsyncToolCall, markAsyncToolStatus: vi.fn(), listAsyncToolCallsForRun: vi.fn(), getAsyncToolCalls: vi.fn(), @@ -86,6 +89,7 @@ import { sseHandlers, subAgentHandlers, } from '@/lib/copilot/request/handlers' +import { cancelToolCallAndReport, executeToolAndReport } from '@/lib/copilot/request/tools/executor' import type { ExecutionContext, StreamEvent, StreamingContext } from '@/lib/copilot/request/types' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -97,6 +101,7 @@ describe('sse-handlers tool lifecycle', () => { vi.clearAllMocks() isSimExecuted.mockReturnValue(true) upsertAsyncToolCall.mockResolvedValue(null) + getAsyncToolCall.mockResolvedValue(null) markAsyncToolRunning.mockResolvedValue(null) completeAsyncToolCall.mockResolvedValue(null) waitForToolCompletion.mockResolvedValue(null) @@ -1281,6 +1286,102 @@ describe('sse-handlers tool lifecycle', () => { expect(updated?.error).toBe('Request aborted during tool execution') }) + it('creates and terminalizes the durable row when Stop wins before normal persistence', async () => { + context.runId = 'run-stop' + context.toolCalls.set('tool-stop', { + id: 'tool-stop', + name: ReadTool.id, + params: { path: 'WORKSPACE.md' }, + status: 'executing', + }) + + await cancelToolCallAndReport('tool-stop', context) + + expect(upsertAsyncToolCall).toHaveBeenCalledWith({ + runId: 'run-stop', + toolCallId: 'tool-stop', + toolName: ReadTool.id, + args: { path: 'WORKSPACE.md' }, + }) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-stop', + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: 'Stopped by user', + }) + expect(context.toolCalls.get('tool-stop')).toEqual( + expect.objectContaining({ + status: MothershipStreamV1ToolOutcome.cancelled, + result: { success: false }, + error: 'Stopped by user', + endTime: expect.any(Number), + }) + ) + }) + + it('does not execute after a durable cancellation wins the running transition', async () => { + context.runId = 'run-stop' + context.toolCalls.set('tool-stop', { + id: 'tool-stop', + name: ReadTool.id, + params: { path: 'WORKSPACE.md' }, + status: 'pending', + }) + getAsyncToolCall.mockResolvedValueOnce({ + toolCallId: 'tool-stop', + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: 'Stopped by user', + }) + + const completion = await executeToolAndReport('tool-stop', context, execContext) + + expect(executeTool).not.toHaveBeenCalled() + expect(completion.status).toBe(MothershipStreamV1ToolOutcome.cancelled) + expect(context.toolCalls.get('tool-stop')).toEqual( + expect.objectContaining({ + status: MothershipStreamV1ToolOutcome.cancelled, + error: 'Stopped by user', + }) + ) + }) + + it('keeps a watchdog-timed-out raw handler tracked until it actually settles', async () => { + vi.useFakeTimers() + try { + let settleRawExecution!: (value: { success: boolean; output: { ok: boolean } }) => void + executeTool.mockReturnValueOnce( + new Promise((resolve) => { + settleRawExecution = resolve + }) + ) + markAsyncToolRunning.mockResolvedValueOnce({ + toolCallId: 'tool-timeout', + status: MothershipStreamV1AsyncToolRecordStatus.running, + }) + context.toolCalls.set('tool-timeout', { + id: 'tool-timeout', + name: ReadTool.id, + params: { path: 'WORKSPACE.md' }, + status: 'pending', + }) + + const reported = executeToolAndReport('tool-timeout', context, execContext) + await vi.waitFor(() => expect(context.inFlightToolExecutions?.has('tool-timeout')).toBe(true)) + + await vi.advanceTimersByTimeAsync(TOOL_WATCHDOG_DEFAULT_MS) + await reported + expect(context.inFlightToolExecutions?.has('tool-timeout')).toBe(true) + + settleRawExecution({ success: true, output: { ok: true } }) + await vi.waitFor(() => + expect(context.inFlightToolExecutions?.has('tool-timeout')).toBe(false) + ) + } finally { + vi.useRealTimers() + } + }) + it('does not replace an in-flight pending promise on duplicate tool_call', async () => { let resolveTool: ((value: { success: boolean; output: { ok: boolean } }) => void) | undefined executeTool.mockImplementationOnce( diff --git a/apps/sim/lib/copilot/request/lifecycle/headless.ts b/apps/sim/lib/copilot/request/lifecycle/headless.ts index 0e5172280a9..28a0f1b4f0c 100644 --- a/apps/sim/lib/copilot/request/lifecycle/headless.ts +++ b/apps/sim/lib/copilot/request/lifecycle/headless.ts @@ -55,14 +55,15 @@ export async function runHeadlessCopilotLifecycle( }) outcome = result.success ? RequestTraceV1Outcome.success - : options.abortSignal?.aborted || result.cancelled + : options.userStopSignal?.aborted || options.abortSignal?.aborted || result.cancelled ? RequestTraceV1Outcome.cancelled : RequestTraceV1Outcome.error return result } catch (error) { - outcome = options.abortSignal?.aborted - ? RequestTraceV1Outcome.cancelled - : RequestTraceV1Outcome.error + outcome = + options.userStopSignal?.aborted || options.abortSignal?.aborted + ? RequestTraceV1Outcome.cancelled + : RequestTraceV1Outcome.error throw error } finally { trace.endSpan( diff --git a/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts b/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts index 68fb457f076..a29590fd441 100644 --- a/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts @@ -40,6 +40,7 @@ describe('resume leg context isolate/merge contract', () => { expect(leg.contentBlocks).toBe(base.contentBlocks) expect(leg.toolCalls).toBe(base.toolCalls) expect(leg.pendingToolPromises).toBe(base.pendingToolPromises) + expect(leg.inFlightToolExecutions).toBe(base.inFlightToolExecutions) expect(leg.subAgentContent).toBe(base.subAgentContent) }) diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 15c328f8904..c05fa7a76f5 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -11,6 +11,7 @@ afterAll(resetEnvironmentUtilsMock) const { mockCreateRunSegment, + mockCancelToolCallAndReport, mockForceFailHungToolCall, mockGetMothershipBaseURL, mockGetMothershipSourceEnvHeaders, @@ -24,6 +25,7 @@ const { mockEnv, } = vi.hoisted(() => ({ mockCreateRunSegment: vi.fn(), + mockCancelToolCallAndReport: vi.fn(), mockForceFailHungToolCall: vi.fn(), mockGetMothershipBaseURL: vi.fn(), mockGetMothershipSourceEnvHeaders: vi.fn(), @@ -124,6 +126,7 @@ vi.mock('@/lib/copilot/request/tools/billing', () => ({ })) vi.mock('@/lib/copilot/request/tools/executor', () => ({ + cancelToolCallAndReport: mockCancelToolCallAndReport, executeToolAndReport: vi.fn(), forceFailHungToolCall: mockForceFailHungToolCall, pendingToolWaitBudgetMs: mockPendingToolWaitBudgetMs, @@ -165,6 +168,78 @@ describe('runCopilotLifecycle', () => { mockPrepareCopilotEnvironmentContext.mockResolvedValue({ resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), }) + mockCancelToolCallAndReport.mockImplementation( + async (toolCallId: string, context: StreamingContext, message = 'Stopped by user') => { + const tool = context.toolCalls.get(toolCallId) + if (!tool) return + tool.status = MothershipStreamV1ToolOutcome.cancelled + tool.endTime = Date.now() + tool.result = { success: false } + tool.error = message + } + ) + }) + + it('does not create a Sim run for a transport-only chat', async () => { + let captured: StreamingContext | undefined + mockRunStreamLoop.mockImplementationOnce(async (_url, _request, context) => { + captured = context + }) + + await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-go-only' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'go-only-chat', + autoCreateRunIdentity: false, + executionContext: { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'go-only-chat', + }, + } + ) + + expect(mockCreateRunSegment).not.toHaveBeenCalled() + expect(captured).toMatchObject({ chatId: 'go-only-chat' }) + expect(captured?.executionId).toBeUndefined() + expect(captured?.runId).toBeUndefined() + }) + + it('still creates a run identity by default for a persisted headless chat', async () => { + let captured: StreamingContext | undefined + mockCreateRunSegment.mockResolvedValueOnce({ id: 'created' }) + mockRunStreamLoop.mockImplementationOnce(async (_url, _request, context) => { + captured = context + }) + + await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-persisted' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'persisted-chat', + executionContext: { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'persisted-chat', + }, + } + ) + + expect(mockCreateRunSegment).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'persisted-chat', + streamId: 'stream-persisted', + requestContext: { source: 'headless_lifecycle' }, + }) + ) + const created = mockCreateRunSegment.mock.calls[0][0] + expect(captured?.executionId).toBe(created.executionId) + expect(captured?.runId).toBe(created.id) }) it('threads trace provenance through server execution context only', async () => { @@ -204,6 +279,39 @@ describe('runCopilotLifecycle', () => { expect(executionContext).not.toHaveProperty('resolvedSecretTraceRegistry') }) + it('routes projected workspace actors through the authorization user environment', async () => { + let requestBody: Record | undefined + mockRunStreamLoop.mockImplementationOnce( + async (_url: string, request: RequestInit): Promise => { + requestBody = JSON.parse(String(request.body)) + } + ) + + await runCopilotLifecycle( + { + message: 'hello', + messageId: 'stream-workspace-key', + userId: 'workspace-billing-actor', + }, + { + userId: 'workspace-billing-actor', + authorizationUserId: 'workspace-key-owner', + workspaceId: 'ws-1', + executionContext: { + userId: 'workspace-billing-actor', + authorizationUserId: 'workspace-key-owner', + workflowId: '', + workspaceId: 'ws-1', + }, + } + ) + + expect(mockGetMothershipBaseURL).toHaveBeenCalledWith({ + userId: 'workspace-key-owner', + }) + expect(requestBody?.userId).toBe('workspace-billing-actor') + }) + it.each([ { goRoute: undefined, expected: 'mothership' }, { goRoute: '/api/copilot', expected: 'mothership' }, @@ -1742,6 +1850,98 @@ describe('runCopilotLifecycle', () => { ) }) + it('uses a caller-supplied resume route for async tool checkpoints', async () => { + const fetchUrls: string[] = [] + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + } + + mockRunStreamLoop.mockImplementationOnce( + async (fetchUrl: string, _fetchOptions: RequestInit, context: StreamingContext) => { + fetchUrls.push(fetchUrl) + context.toolCalls.set('tool-1', { + id: 'tool-1', + name: 'read', + status: MothershipStreamV1ToolOutcome.success, + result: { success: true, output: { content: 'file contents' } }, + }) + context.awaitingAsyncContinuation = { + checkpointId: 'ckpt-1', + pendingToolCallIds: ['tool-1'], + } + } + ) + mockRunStreamLoop.mockImplementationOnce(async (fetchUrl: string) => { + fetchUrls.push(fetchUrl) + }) + + await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + workflowId: 'workflow-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + executionContext, + resumeRoute: '/api/tools/v2-chat/resume', + } + ) + + expect(fetchUrls[1]).toBe('http://mothership.test/api/tools/v2-chat/resume') + }) + + it('reports initial stream acceptance once and never from resume legs', async () => { + const onInitialStreamAccepted = vi.fn() + + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext, + _execContext: ExecutionContext, + options: { onAccepted?: () => void } + ) => { + options.onAccepted?.() + options.onAccepted?.() + context.awaitingAsyncContinuation = { + checkpointId: 'ckpt-1', + pendingToolCallIds: [], + } + } + ) + mockRunStreamLoop.mockResolvedValueOnce(undefined) + + await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + workflowId: 'workflow-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + executionContext: { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + }, + onInitialStreamAccepted, + } + ) + + expect(mockRunStreamLoop.mock.calls[0]?.[4]).toEqual( + expect.objectContaining({ onAccepted: expect.any(Function) }) + ) + expect(onInitialStreamAccepted).toHaveBeenCalledOnce() + expect(mockRunStreamLoop.mock.calls[1]?.[4]).not.toHaveProperty('onAccepted') + }) + it('finalizes as success when a resume fails with a retryable error then the retry succeeds', async () => { const executionContext: ExecutionContext = { userId: 'user-1', @@ -2050,6 +2250,106 @@ describe('runCopilotLifecycle', () => { expect(result.errors).toEqual(['The provider is overloaded']) }) + it('stops a pending Sim tool without aborting the active Go transport or resuming', async () => { + const transportController = new AbortController() + const userStopController = new AbortController() + const onComplete = vi.fn() + const onError = vi.fn() + let capturedContext: StreamingContext | undefined + let capturedExecutionContext: ExecutionContext | undefined + let settleTool!: () => void + let settleRawExecution!: () => void + const pendingTool = new Promise((resolve) => { + settleTool = resolve + }) + const rawExecution = new Promise((resolve) => { + settleRawExecution = resolve + }) + + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext, + executionContext: ExecutionContext + ): Promise => { + capturedContext = context + capturedExecutionContext = executionContext + context.toolCalls.set('tool-running', { + id: 'tool-running', + name: 'read', + status: 'executing', + }) + context.pendingToolPromises.set('tool-running', pendingTool) + context.inFlightToolExecutions = new Map([['tool-running', rawExecution]]) + context.awaitingAsyncContinuation = { + checkpointId: 'ckpt-1', + pendingToolCallIds: ['tool-running'], + } + } + ) + + const lifecycle = runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + abortSignal: transportController.signal, + userStopSignal: userStopController.signal, + executionContext: { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'chat-1', + }, + onComplete, + onError, + } + ) + + await vi.waitFor(() => expect(mockRunStreamLoop).toHaveBeenCalledTimes(1)) + await new Promise((resolve) => setImmediate(resolve)) + let lifecycleSettled = false + void lifecycle.finally(() => { + lifecycleSettled = true + }) + userStopController.abort('submit') + await new Promise((resolve) => setImmediate(resolve)) + + // Stop reaches the tool immediately, but the turn keeps its lease until + // that handler has actually unwound. + expect(capturedExecutionContext?.abortSignal?.aborted).toBe(true) + expect(lifecycleSettled).toBe(false) + settleTool() + await new Promise((resolve) => setImmediate(resolve)) + expect(lifecycleSettled).toBe(false) + settleRawExecution() + + const result = await lifecycle + + expect(transportController.signal.aborted).toBe(false) + expect(capturedExecutionContext?.abortSignal?.aborted).toBe(true) + expect(capturedExecutionContext?.userStopSignal).toBe(userStopController.signal) + expect(mockRunStreamLoop).toHaveBeenCalledTimes(1) + expect(mockCancelToolCallAndReport).toHaveBeenCalledWith('tool-running', capturedContext) + expect(capturedContext?.awaitingAsyncContinuation).toBeUndefined() + expect(capturedContext?.toolCalls.get('tool-running')).toEqual( + expect.objectContaining({ + status: MothershipStreamV1ToolOutcome.cancelled, + error: 'Stopped by user', + result: { success: false }, + }) + ) + expect(result).toEqual(expect.objectContaining({ success: false, cancelled: true })) + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ success: false, cancelled: true }) + ) + expect(onError).not.toHaveBeenCalled() + }) + it('force-fails a hung tool promise and resumes with an error result instead of wedging', async () => { vi.useFakeTimers() try { diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 42160ffe4e7..60fcf011885 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -54,10 +54,10 @@ import { import { getToolCallTerminalData, requireToolCallStateResult, - setTerminalToolCallState, } from '@/lib/copilot/request/tool-call-state' import { handleBillingLimitResponse } from '@/lib/copilot/request/tools/billing' import { + cancelToolCallAndReport, executeToolAndReport, forceFailHungToolCall, pendingToolWaitBudgetMs, @@ -757,6 +757,13 @@ function nonBlankString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined } +function combineAbortSignals(...signals: Array): AbortSignal | undefined { + const activeSignals = [...new Set(signals.filter((signal): signal is AbortSignal => !!signal))] + if (activeSignals.length === 0) return undefined + if (activeSignals.length === 1) return activeSignals[0] + return AbortSignal.any(activeSignals) +} + function resultContent(context: StreamingContext, options: CopilotLifecycleOptions): string { if (options.interactive === false && context.sawMainToolCall) { return context.finalAssistantContent @@ -766,16 +773,26 @@ function resultContent(context: StreamingContext, options: CopilotLifecycleOptio export interface CopilotLifecycleOptions extends OrchestratorOptions { userId: string + authorizationUserId?: string workflowId?: string workspaceId?: string chatId?: string executionId?: string runId?: string + /** + * Defaults to true. Set false when `chatId` is transport-only and has no + * parent row in Sim's `copilot_chats` table, or when the caller owns run + * creation and supplies any persisted identity itself. + */ + autoCreateRunIdentity?: boolean goRoute?: string + resumeRoute?: string trace?: TraceCollector simRequestId?: string otelContext?: Context onGoTraceId?: (goTraceId: string) => void + /** Fires after Go accepts the initial stream, before any resume legs. */ + onInitialStreamAccepted?: () => void executionContext?: ExecutionContext billingAttribution?: BillingAttributionSnapshot resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry @@ -831,10 +848,12 @@ export async function runCopilotLifecycle( chatId, executionId, runId, + autoCreateRunIdentity: options.autoCreateRunIdentity, messageId: payloadMsgId, }) const resolvedExecutionId = runIdentity.executionId ?? executionId const resolvedRunId = runIdentity.runId ?? runId + const toolAbortSignal = combineAbortSignals(options.abortSignal, options.userStopSignal) const lifecycleOptions: CopilotLifecycleOptions = { ...options, executionId: resolvedExecutionId, @@ -843,10 +862,14 @@ export async function runCopilotLifecycle( ? { executionContext: { ...options.executionContext, + ...(options.authorizationUserId + ? { authorizationUserId: options.authorizationUserId } + : {}), messageId: payloadMsgId, executionId: resolvedExecutionId, runId: resolvedRunId, - abortSignal: options.abortSignal, + abortSignal: toolAbortSignal, + userStopSignal: options.userStopSignal, billingAttribution: options.billingAttribution ?? options.executionContext.billingAttribution, ...(options.userPermission ? { userPermission: options.userPermission } : {}), @@ -866,12 +889,14 @@ export async function runCopilotLifecycle( lifecycleOptions.executionContext ?? (await buildExecutionContext(requestPayload, { userId, + authorizationUserId: lifecycleOptions.authorizationUserId, workflowId, workspaceId, chatId, executionId: resolvedExecutionId, runId: resolvedRunId, - abortSignal: lifecycleOptions.abortSignal, + abortSignal: toolAbortSignal, + userStopSignal: lifecycleOptions.userStopSignal, billingAttribution: lifecycleOptions.billingAttribution, resolvedSecretTraceRegistry: lifecycleOptions.resolvedSecretTraceRegistry, environmentContext: lifecycleOptions.environmentContext, @@ -971,12 +996,14 @@ export async function runCopilotLifecycle( return result } catch (error) { const err = toError(error) + const wasCancelled = isAborted(lifecycleOptions, context) // A CopilotBackendError carries the upstream HTTP status + body (e.g. a 5xx // from /api/tools/resume when an oversized tool result — a rendered-doc // image — is posted back). Log those so a client-side "Stream error" that // originates from a thrown backend leg (vs an `error` SSE event) is // explained, not just reduced to a message string. - logger.error('Copilot orchestration failed', { + const logFailure = wasCancelled ? logger.warn : logger.error + logFailure.call(logger, 'Copilot orchestration failed', { error: err.message, name: err.name, ...(error instanceof CopilotBackendError @@ -993,7 +1020,6 @@ export async function runCopilotLifecycle( // partial content can be appended. // Return `cancelled: true` so upstream classification stays // consistent with the success-path cancel result. - const wasCancelled = lifecycleOptions.abortSignal?.aborted ?? false // Preserve whatever streamed before the throw for both terminals. A thrown // backend error (as opposed to an `error` SSE event that lets the loop finish // normally) must still carry the partial assistant turn so onError can @@ -1079,7 +1105,7 @@ function mothershipRequestHeaders( // lockstep: every field reset here is folded back there, and nothing else on // StreamingContext is per-leg. Everything not listed is shared BY REFERENCE // across all concurrent legs (the one merged chat: contentBlocks, toolCalls, -// pendingToolPromises, subagent maps, etc.). The per-leg ISOLATED set: +// pendingToolPromises, inFlightToolExecutions, subagent maps, etc.). The per-leg ISOLATED set: // - streamComplete / awaitingAsyncContinuation: stream-control flags, so a // finished leg can't stop a sibling's read loop (reset only; not merged). // - accumulatedContent / finalAssistantContent / usage / cost: join-leg @@ -1123,13 +1149,62 @@ export function mergeResumeLegOutputs(context: StreamingContext, leg: StreamingC if (leg.completionStatus) context.completionStatus = leg.completionStatus } -async function waitForToolIds(context: StreamingContext, toolIds: string[]): Promise { +type PendingToolWaitOutcome = 'settled' | 'aborted' | 'timed_out' + +/** + * Waits for tool work to stop before the lifecycle releases its chat lease. + * Abort is remembered immediately, but the promise settles only after the + * in-flight handlers have unwound. This is the same stop barrier the web UI + * relies on: a queued turn must never overlap mutations from the stopped turn. + */ +function waitForPendingToolPromises( + promises: Iterable>, + abortSignal?: AbortSignal, + timeoutMs?: number +): Promise { + const pending = Array.from(promises) + if (pending.length === 0) return Promise.resolve('settled') + + return new Promise((resolve) => { + let finished = false + let abortObserved = abortSignal?.aborted ?? false + let timeoutId: ReturnType | undefined + const finish = (outcome: PendingToolWaitOutcome) => { + if (finished) return + finished = true + if (timeoutId !== undefined) clearTimeout(timeoutId) + abortSignal?.removeEventListener('abort', onAbort) + resolve(outcome) + } + const onAbort = () => { + abortObserved = true + // Once Stop fires, safety wins over the ordinary resume watchdog: keep + // the lease until the cancellation-aware handler has actually unwound. + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + timeoutId = undefined + } + } + + abortSignal?.addEventListener('abort', onAbort, { once: true }) + void Promise.allSettled(pending).then(() => finish(abortObserved ? 'aborted' : 'settled')) + if (timeoutMs !== undefined && !abortObserved) { + timeoutId = setTimeout(() => finish('timed_out'), timeoutMs) + } + }) +} + +async function waitForToolIds( + context: StreamingContext, + toolIds: string[], + abortSignal?: AbortSignal +): Promise { const promises: Promise[] = [] for (const id of toolIds) { const p = context.pendingToolPromises.get(id) if (p) promises.push(p) } - if (promises.length > 0) await Promise.allSettled(promises) + return waitForPendingToolPromises(promises, abortSignal) } function collectResultsForToolIds( @@ -1175,6 +1250,7 @@ async function runResumeLegWithRetry( hostedBillingRequest?: AttributedBillingRequestEnvelope ): Promise { let attempt = 0 + const stopSignal = combineAbortSignals(options.abortSignal, options.userStopSignal) for (;;) { const errorsBeforeAttempt = leg.errors.length try { @@ -1191,6 +1267,7 @@ async function runResumeLegWithRetry( ) return } catch (error) { + if (isAborted(options, leg)) throw error if (isRetryableStreamError(error) && attempt < MAX_RESUME_ATTEMPTS - 1) { leg.errors.length = errorsBeforeAttempt attempt++ @@ -1201,7 +1278,8 @@ async function runResumeLegWithRetry( backoffMs: backoff, error: toError(error).message, }) - await sleepWithAbort(backoff, options.abortSignal) + await sleepWithAbort(backoff, stopSignal) + if (isAborted(options, leg)) return continue } throw error @@ -1233,18 +1311,20 @@ async function driveOneChildChain( if (!frame.checkpointId) return null let checkpointId = frame.checkpointId let toolIds = frame.pendingToolIds + const stopSignal = combineAbortSignals(options.abortSignal, options.userStopSignal) for (;;) { if (isAborted(options, context)) return null - await waitForToolIds(context, toolIds) + const waitOutcome = await waitForToolIds(context, toolIds, stopSignal) + if (waitOutcome === 'aborted' || isAborted(options, context)) return null const registry = execContext.resolvedSecretTraceRegistry if (!registry) throw new CopilotModelContentProjectionError() const results = collectResultsForToolIds(context, toolIds, checkpointId, registry) const leg = makeResumeLegContext(context) await runResumeLegWithRetry( - `${baseURL}/api/tools/resume`, + `${baseURL}${options.resumeRoute ?? '/api/tools/resume'}`, { streamId: context.messageId, checkpointId, @@ -1348,6 +1428,10 @@ async function driveSubagentChains( }) ) ) + if (isAborted(options, context)) { + await cancelCheckpointWork(context) + return null + } if (firstError !== undefined) throw firstError return followOns.find((c): c is AsyncContinuation => !!c) ?? null } finally { @@ -1368,10 +1452,19 @@ async function runCheckpointLoop( hostedBillingRequest?: AttributedBillingRequestEnvelope ): Promise { let route = initialRoute + const resumeRoute = options.resumeRoute ?? '/api/tools/resume' let payload: Record = initialPayload let resumeAttempt = 0 const callerOnEvent = options.onEvent - const mothershipBaseURL = await getMothershipBaseURL({ userId: options.userId }) + let initialStreamAccepted = false + const stopSignal = combineAbortSignals(options.abortSignal, options.userStopSignal) + // Route by the identity that authorized this request, not by a projected + // billing actor. Workspace API keys deliberately execute under a system + // billing actor, but that actor's admin environment override must never + // redirect another key owner's Mothership traffic. + const mothershipBaseURL = await getMothershipBaseURL({ + userId: options.authorizationUserId ?? options.userId, + }) const lifecycleWorkspaceId = nonBlankString(options.workspaceId) // Go's auth middleware re-validates every Sim -> Go request by reading @@ -1390,11 +1483,10 @@ async function runCheckpointLoop( for (;;) { context.streamComplete = false - const isResume = route === '/api/tools/resume' + const isResume = route === resumeRoute if (isResume && isAborted(options, context)) { - cancelPendingTools(context) - context.awaitingAsyncContinuation = undefined + await cancelCheckpointWork(context) break } @@ -1456,7 +1548,18 @@ async function runCheckpointLoop( }, context, execContext, - loopOptions + { + ...loopOptions, + ...(!isResume && !initialStreamAccepted && options.onInitialStreamAccepted + ? { + onAccepted: () => { + if (initialStreamAccepted) return + initialStreamAccepted = true + options.onInitialStreamAccepted?.() + }, + } + : {}), + } ) const streamStatus = isAborted(options, context) ? RequestTraceV1SpanStatus.cancelled @@ -1469,6 +1572,10 @@ async function runCheckpointLoop( } catch (streamError) { context.trace.endSpan(streamSpan, RequestTraceV1SpanStatus.error) context.trace.setActiveSpan(undefined) + if (isAborted(options, context)) { + await cancelCheckpointWork(context) + throw streamError + } if (streamError instanceof BillingLimitError) { await handleBillingLimitResponse(streamError.userId, context, execContext, options) break @@ -1489,7 +1596,7 @@ async function runCheckpointLoop( backoffMs: backoff, error: toError(streamError).message, }) - await sleepWithAbort(backoff, options.abortSignal) + await sleepWithAbort(backoff, stopSignal) continue } throw streamError @@ -1507,8 +1614,7 @@ async function runCheckpointLoop( }) if (isAborted(options, context)) { - cancelPendingTools(context) - context.awaitingAsyncContinuation = undefined + await cancelCheckpointWork(context) break } @@ -1526,11 +1632,16 @@ async function runCheckpointLoop( let next: AsyncContinuation | null = continuation while (next && isPerSubagentContinuation(next)) { if (isAborted(options, context)) { - cancelPendingTools(context) + await cancelCheckpointWork(context) + next = null + break + } + const waitOutcome = await waitForToolIds(context, next.pendingToolCallIds, stopSignal) + if (waitOutcome === 'aborted') { + await cancelCheckpointWork(context) next = null break } - await waitForToolIds(context, next.pendingToolCallIds) next = await driveSubagentChains( next, context, @@ -1567,10 +1678,18 @@ async function runCheckpointLoop( pendingCount: context.pendingToolPromises.size, waitBudgetMs, }) - const settledInTime = await Promise.race([ - Promise.allSettled(context.pendingToolPromises.values()).then(() => true), - sleep(waitBudgetMs).then(() => false), - ]) + const waitOutcome = await waitForPendingToolPromises( + context.pendingToolPromises.values(), + stopSignal, + waitBudgetMs + ) + const settledInTime = waitOutcome === 'settled' + if (waitOutcome === 'aborted') { + waitSpan.attributes = { ...waitSpan.attributes, settledInTime: false, aborted: true } + context.trace.endSpan(waitSpan, RequestTraceV1SpanStatus.cancelled) + await cancelCheckpointWork(context) + break + } if (!settledInTime) { const hungToolCallIds = Array.from(context.pendingToolPromises.keys()) logger.error('Pending tool executions exceeded the resume wait budget; force-failing', { @@ -1592,8 +1711,7 @@ async function runCheckpointLoop( } if (isAborted(options, context)) { - cancelPendingTools(context) - context.awaitingAsyncContinuation = undefined + await cancelCheckpointWork(context) break } @@ -1613,16 +1731,20 @@ async function runCheckpointLoop( checkpointId: continuation.checkpointId, toolCallIds: undispatchedToolIds, }) - await Promise.allSettled( + const waitOutcome = await waitForPendingToolPromises( undispatchedToolIds.map((toolCallId) => executeToolAndReport(toolCallId, context, execContext, options) - ) + ), + stopSignal ) + if (waitOutcome === 'aborted') { + await cancelCheckpointWork(context) + break + } } if (isAborted(options, context)) { - cancelPendingTools(context) - context.awaitingAsyncContinuation = undefined + await cancelCheckpointWork(context) break } @@ -1634,8 +1756,7 @@ async function runCheckpointLoop( }> = [] for (const toolCallId of continuation.pendingToolCallIds) { if (isAborted(options, context)) { - cancelPendingTools(context) - context.awaitingAsyncContinuation = undefined + await cancelCheckpointWork(context) break } const tool = context.toolCalls.get(toolCallId) @@ -1663,8 +1784,7 @@ async function runCheckpointLoop( } if (isAborted(options, context)) { - cancelPendingTools(context) - context.awaitingAsyncContinuation = undefined + await cancelCheckpointWork(context) break } @@ -1677,7 +1797,7 @@ async function runCheckpointLoop( }) context.awaitingAsyncContinuation = undefined - route = '/api/tools/resume' + route = resumeRoute payload = { streamId: context.messageId, checkpointId: continuation.checkpointId, @@ -1687,8 +1807,7 @@ async function runCheckpointLoop( } if (isAborted(options, context)) { - cancelPendingTools(context) - context.awaitingAsyncContinuation = undefined + await cancelCheckpointWork(context) break } @@ -1709,12 +1828,14 @@ async function buildExecutionContext( requestPayload: Record, params: { userId: string + authorizationUserId?: string workflowId?: string workspaceId?: string chatId?: string executionId?: string runId?: string abortSignal?: AbortSignal + userStopSignal?: AbortSignal billingAttribution?: BillingAttributionSnapshot resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry environmentContext?: CopilotEnvironmentContext @@ -1725,12 +1846,14 @@ async function buildExecutionContext( ): Promise { const { userId, + authorizationUserId, workflowId, workspaceId, chatId, executionId, runId, abortSignal, + userStopSignal, billingAttribution, resolvedSecretTraceRegistry, environmentContext, @@ -1741,6 +1864,7 @@ async function buildExecutionContext( const userTimezone = typeof requestPayload?.userTimezone === 'string' ? requestPayload.userTimezone : undefined const requestMode = typeof requestPayload?.mode === 'string' ? requestPayload.mode : undefined + const queryOnly = requestPayload?.queryOnly === true let execContext: ExecutionContext if (workflowId) { @@ -1763,7 +1887,9 @@ async function buildExecutionContext( } if (userTimezone) execContext.userTimezone = userTimezone + if (authorizationUserId) execContext.authorizationUserId = authorizationUserId execContext.copilotToolExecution = true + if (queryOnly) execContext.queryOnly = true if (requestMode) execContext.requestMode = requestMode if (userPermission) execContext.userPermission = userPermission execContext.messageId = @@ -1771,6 +1897,7 @@ async function buildExecutionContext( execContext.executionId = executionId execContext.runId = runId execContext.abortSignal = abortSignal + execContext.userStopSignal = userStopSignal if (billingAttribution) execContext.billingAttribution = billingAttribution if (resolvedSecretTraceRegistry) { execContext.resolvedSecretTraceRegistry = resolvedSecretTraceRegistry @@ -1788,9 +1915,10 @@ async function ensureHeadlessRunIdentity(input: { chatId?: string executionId?: string runId?: string + autoCreateRunIdentity?: boolean messageId: string }): Promise<{ executionId?: string; runId?: string }> { - if (!input.chatId || input.executionId || input.runId) { + if (input.autoCreateRunIdentity === false || !input.chatId || input.executionId || input.runId) { return { executionId: input.executionId, runId: input.runId, @@ -1861,22 +1989,36 @@ async function withByokEligibilityHint( } function isAborted(options: CopilotLifecycleOptions, context: StreamingContext): boolean { - return !!(options.abortSignal?.aborted || context.wasAborted) + return !!(options.abortSignal?.aborted || options.userStopSignal?.aborted || context.wasAborted) } -function cancelPendingTools(context: StreamingContext): void { - for (const [, toolCall] of context.toolCalls) { +async function cancelCheckpointWork(context: StreamingContext): Promise { + context.wasAborted = true + context.awaitingAsyncContinuation = undefined + + // The stop signal has already reached every tool context. Keep the chat + // lease until those handlers observe it and unwind, then durably terminalize + // any call that never reached its normal cancellation branch. + await Promise.allSettled([ + ...context.pendingToolPromises.values(), + ...(context.inFlightToolExecutions?.values() ?? []), + ]) + await cancelPendingTools(context) +} + +async function cancelPendingTools(context: StreamingContext): Promise { + const cancellations: Promise[] = [] + for (const [toolCallId, toolCall] of context.toolCalls) { if ( toolCall.status === 'pending' || toolCall.status === 'executing' || - toolCall.status === 'awaiting_approval' + toolCall.status === 'awaiting_approval' || + toolCall.status === MothershipStreamV1ToolOutcome.cancelled ) { - setTerminalToolCallState(toolCall, { - status: MothershipStreamV1ToolOutcome.cancelled, - error: 'Stopped by user', - }) + cancellations.push(cancelToolCallAndReport(toolCallId, context)) } } + await Promise.allSettled(cancellations) } /** diff --git a/apps/sim/lib/copilot/request/lifecycle/start.test.ts b/apps/sim/lib/copilot/request/lifecycle/start.test.ts index c3dcfcb02d8..6d77df3af4f 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/start.test.ts @@ -5,7 +5,14 @@ import { propagation, trace } from '@opentelemetry/api' import { W3CTraceContextPropagator } from '@opentelemetry/core' import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base' -import { resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { + dbChainMockFns, + flattenMockConditions, + resetDbChainMock, + resetEnvFlagsMock, + schemaMock, + setEnvFlags, +} from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MothershipStreamV1CompletionStatus, @@ -26,6 +33,8 @@ const { cleanupAbortMarker, hasAbortMarker, releasePendingChatStream, + registerActiveStream, + startAbortPoller, fetchGo, } = vi.hoisted(() => ({ runCopilotLifecycle: vi.fn(), @@ -40,6 +49,8 @@ const { cleanupAbortMarker: vi.fn(), hasAbortMarker: vi.fn(), releasePendingChatStream: vi.fn(), + registerActiveStream: vi.fn(), + startAbortPoller: vi.fn().mockReturnValue(setInterval(() => {}, 999999)), fetchGo: vi.fn(), })) @@ -77,9 +88,9 @@ vi.mock('@/lib/copilot/request/session', () => ({ cleanupAbortMarker, hasAbortMarker, releasePendingChatStream, - registerActiveStream: vi.fn(), + registerActiveStream, unregisterActiveStream: vi.fn(), - startAbortPoller: vi.fn().mockReturnValue(setInterval(() => {}, 999999)), + startAbortPoller, isExplicitStopReason: vi.fn().mockReturnValue(false), SSE_RESPONSE_HEADERS: {}, StreamWriter: vi.fn().mockImplementation( @@ -127,7 +138,7 @@ vi.mock('@/lib/copilot/server/agent-url', () => ({ getMothershipSourceEnvHeaders: vi.fn().mockReturnValue({}), })) -import { createSSEStream, requestChatTitle } from './start' +import { createSSEStream, fireTitleGeneration, requestChatTitle } from './start' async function drainStream(stream: ReadableStream) { const reader = stream.getReader() @@ -290,6 +301,48 @@ describe('createSSEStream terminal error handling', () => { ) }) + it('registers and forwards distinct transport and explicit-stop signals', async () => { + runCopilotLifecycle.mockResolvedValue({ + success: true, + content: 'OK', + contentBlocks: [], + toolCalls: [], + }) + + const stream = createSSEStream({ + requestPayload: { message: 'hello' }, + userId: 'user-1', + streamId: 'stream-signals', + executionId: 'exec-signals', + runId: 'run-signals', + currentChat: null, + isNewChat: false, + message: 'hello', + titleModel: 'gpt-5.4', + requestId: 'req-signals', + orchestrateOptions: {}, + }) + + const [, transportController, userStopController] = registerActiveStream.mock.calls[0] + expect(transportController).toBeInstanceOf(AbortController) + expect(userStopController).toBeInstanceOf(AbortController) + expect(userStopController).not.toBe(transportController) + + await drainStream(stream) + + expect(startAbortPoller).toHaveBeenCalledWith( + 'stream-signals', + transportController, + expect.objectContaining({ userStopController }) + ) + expect(runCopilotLifecycle.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + abortSignal: transportController.signal, + userStopSignal: userStopController.signal, + }) + ) + }) + it('passes an OTel context into the streaming lifecycle', async () => { let lifecycleTraceparent = '' runCopilotLifecycle.mockImplementation(async (_payload, options) => { @@ -424,3 +477,76 @@ describe('requestChatTitle billing protocol', () => { expect(headers['x-sim-billing-request-id']).toBeUndefined() }) }) + +describe('fireTitleGeneration rename ordering', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + setEnvFlags({ isHosted: true }) + setEnvFlags({ isCopilotBillingAttributionV1Enabled: true }) + fetchGo.mockResolvedValue( + new Response(JSON.stringify({ title: 'Generated title' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + }) + + it('does not overwrite or publish over a title renamed while generation was running', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + const publish = vi.fn() + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry() + + fireTitleGeneration({ + chatId: 'chat-1', + currentChat: null, + isNewChat: true, + userId: 'user-1', + message: 'Investigate the incident', + titleModel: 'claude-opus-4.8', + workspaceId: 'workspace-1', + billingAttribution: BILLING_ATTRIBUTION, + requestId: 'request-1', + publisher: { publish }, + resolvedSecretTraceRegistry, + }) + + await vi.waitFor(() => expect(dbChainMockFns.returning).toHaveBeenCalledTimes(1)) + await new Promise((resolve) => setImmediate(resolve)) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ title: 'Generated title' }) + expect( + flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).some( + (condition) => + condition.type === 'isNull' && condition.column === schemaMock.copilotChats.title + ) + ).toBe(true) + expect(publish).not.toHaveBeenCalled() + }) + + it('publishes the generated title when the null-title update wins', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'chat-1' }]) + const publish = vi.fn() + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry() + + fireTitleGeneration({ + chatId: 'chat-1', + currentChat: null, + isNewChat: true, + userId: 'user-1', + message: 'Investigate the incident', + titleModel: 'claude-opus-4.8', + workspaceId: 'workspace-1', + billingAttribution: BILLING_ATTRIBUTION, + requestId: 'request-1', + publisher: { publish }, + resolvedSecretTraceRegistry, + }) + + await vi.waitFor(() => + expect(publish).toHaveBeenCalledWith({ + type: 'session', + payload: { kind: 'title', title: 'Generated title' }, + }) + ) + }) +}) diff --git a/apps/sim/lib/copilot/request/lifecycle/start.ts b/apps/sim/lib/copilot/request/lifecycle/start.ts index fcda495a529..cdadeb0fcda 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.ts +++ b/apps/sim/lib/copilot/request/lifecycle/start.ts @@ -3,7 +3,7 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { assertBillingAttributionSnapshot, type BillingAttributionSnapshot, @@ -60,7 +60,7 @@ export { SSE_RESPONSE_HEADERS } const logger = createLogger('CopilotChatStreaming') -type CurrentChatSummary = { +export type CurrentChatSummary = { title?: string | null } | null @@ -120,7 +120,8 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS }) const abortController = new AbortController() - registerActiveStream(streamId, abortController) + const userStopController = new AbortController() + registerActiveStream(streamId, abortController, userStopController) const publisher = new StreamWriter({ streamId, chatId, requestId }) @@ -225,6 +226,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS const abortPoller = startAbortPoller(streamId, abortController, { requestId, chatId, + userStopController, }) publisher.startKeepalive() @@ -265,10 +267,14 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS simRequestId: requestId, otelContext, abortSignal: abortController.signal, + userStopSignal: userStopController.signal, onEvent: async (event) => { await publisher.publish(event) }, onAbortObserved: (reason) => { + if (isExplicitStopReason(reason) && !userStopController.signal.aborted) { + userStopController.abort(reason) + } if (!abortController.signal.aborted) { abortController.abort(reason) } @@ -429,7 +435,8 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS // Title generation (fire-and-forget side effect) // --------------------------------------------------------------------------- -function fireTitleGeneration(params: { +/** Starts the shared chat-title side effect without delaying the response stream. */ +export function fireTitleGeneration(params: { chatId?: string currentChat: CurrentChatSummary isNewChat: boolean @@ -440,7 +447,7 @@ function fireTitleGeneration(params: { workspaceId?: string billingAttribution?: BillingAttributionSnapshot requestId: string - publisher: StreamWriter + publisher: Pick otelContext?: Context resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry }): void { @@ -478,7 +485,12 @@ function fireTitleGeneration(params: { }) .then(async (title) => { if (!title) return - await db.update(copilotChats).set({ title }).where(eq(copilotChats.id, chatId)) + const [updated] = await db + .update(copilotChats) + .set({ title }) + .where(and(eq(copilotChats.id, chatId), isNull(copilotChats.title))) + .returning({ id: copilotChats.id }) + if (!updated) return await publisher.publish({ type: MothershipStreamV1EventType.session, payload: { kind: MothershipStreamV1SessionKind.title, title }, diff --git a/apps/sim/lib/copilot/request/session/abort-reason.ts b/apps/sim/lib/copilot/request/session/abort-reason.ts index 8a6b281e2c0..791b92b711e 100644 --- a/apps/sim/lib/copilot/request/session/abort-reason.ts +++ b/apps/sim/lib/copilot/request/session/abort-reason.ts @@ -30,6 +30,8 @@ export const AbortReason = { MarkerObservedAtBodyClose: 'redis_abort_marker:body_close', /** Internal timeout on the outbound explicit-abort fetch to Go. */ ExplicitAbortFetchTimeout: 'timeout:go_explicit_abort_fetch', + /** This handler no longer owns the per-chat lease and must stop writing. */ + LockOwnershipLost: 'chat_stream_lock:ownership_lost', } as const export type AbortReasonValue = (typeof AbortReason)[keyof typeof AbortReason] diff --git a/apps/sim/lib/copilot/request/session/abort.test.ts b/apps/sim/lib/copilot/request/session/abort.test.ts index 2404b12dc5c..788eef3e833 100644 --- a/apps/sim/lib/copilot/request/session/abort.test.ts +++ b/apps/sim/lib/copilot/request/session/abort.test.ts @@ -22,12 +22,38 @@ vi.mock('@/lib/copilot/request/otel', () => ({ })) import { + abortActiveStream, acquirePendingChatStream, getChatStreamLockOwners, + registerActiveStream, releasePendingChatStream, startAbortPoller, } from '@/lib/copilot/request/session/abort' +describe('active stream cancellation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fires both the transport and explicit-stop controllers', async () => { + const transportController = new AbortController() + const userStopController = new AbortController() + + registerActiveStream('stream-stop', transportController, userStopController) + + await expect(abortActiveStream('stream-stop')).resolves.toBe(true) + expect(mockWriteAbortMarker).toHaveBeenCalledWith('stream-stop') + expect(transportController.signal).toMatchObject({ + aborted: true, + reason: 'user_stop:abortActiveStream', + }) + expect(userStopController.signal).toMatchObject({ + aborted: true, + reason: 'user_stop:abortActiveStream', + }) + }) +}) + describe('startAbortPoller heartbeat', () => { beforeEach(() => { vi.clearAllMocks() @@ -104,6 +130,7 @@ describe('startAbortPoller heartbeat', () => { it('aborts the controller before clearing the marker so the marker is never observable as cleared while the signal is still unaborted', async () => { const controller = new AbortController() + const userStopController = new AbortController() const streamId = 'stream-order-1' let signalAbortedWhenMarkerCleared: boolean | null = null @@ -112,7 +139,7 @@ describe('startAbortPoller heartbeat', () => { }) mockHasAbortMarker.mockResolvedValueOnce(true) - const interval = startAbortPoller(streamId, controller, {}) + const interval = startAbortPoller(streamId, controller, { userStopController }) try { await vi.advanceTimersByTimeAsync(300) @@ -120,6 +147,10 @@ describe('startAbortPoller heartbeat', () => { expect(mockClearAbortMarker).toHaveBeenCalledWith(streamId) expect(signalAbortedWhenMarkerCleared).toBe(true) expect(controller.signal.aborted).toBe(true) + expect(userStopController.signal).toMatchObject({ + aborted: true, + reason: 'redis_abort_marker:poller', + }) } finally { clearInterval(interval) } @@ -143,18 +174,22 @@ describe('startAbortPoller heartbeat', () => { } }) - it('stops heartbeating after ownership is lost', async () => { + it('aborts the stale lifecycle and stops heartbeating after ownership is lost', async () => { const controller = new AbortController() + const userStopController = new AbortController() const streamId = 'stream-lost' const chatId = 'chat-lost' redisConfigMockFns.mockExtendLock.mockResolvedValueOnce(false) - const interval = startAbortPoller(streamId, controller, { chatId }) + const interval = startAbortPoller(streamId, controller, { chatId, userStopController }) try { await vi.advanceTimersByTimeAsync(21_000) expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) + expect(controller.signal.aborted).toBe(true) + expect(controller.signal.reason).toBe('chat_stream_lock:ownership_lost') + expect(userStopController.signal.reason).toBe('chat_stream_lock:ownership_lost') await vi.advanceTimersByTimeAsync(60_000) expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) @@ -162,6 +197,30 @@ describe('startAbortPoller heartbeat', () => { clearInterval(interval) } }) + + it('does not overlap heartbeat extensions when Redis is slow', async () => { + const controller = new AbortController() + let resolveExtend!: (owned: boolean) => void + redisConfigMockFns.mockExtendLock.mockReturnValueOnce( + new Promise((resolve) => { + resolveExtend = resolve + }) + ) + + const interval = startAbortPoller('stream-slow', controller, { chatId: 'chat-slow' }) + try { + await vi.advanceTimersByTimeAsync(21_000) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(5_000) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) + + resolveExtend(true) + await vi.advanceTimersByTimeAsync(1) + } finally { + clearInterval(interval) + } + }) }) describe('getChatStreamLockOwners', () => { diff --git a/apps/sim/lib/copilot/request/session/abort.ts b/apps/sim/lib/copilot/request/session/abort.ts index b081044f8eb..5483ddaf7ad 100644 --- a/apps/sim/lib/copilot/request/session/abort.ts +++ b/apps/sim/lib/copilot/request/session/abort.ts @@ -11,7 +11,12 @@ import { clearAbortMarker, hasAbortMarker, writeAbortMarker } from './buffer' const logger = createLogger('SessionAbort') -const activeStreams = new Map() +interface ActiveStreamEntry { + abortController: AbortController + userStopController: AbortController +} + +const activeStreams = new Map() const pendingChatStreams = new Map< string, { promise: Promise; resolve: () => void; streamId: string } @@ -60,8 +65,12 @@ function getChatStreamLockKey(chatId: string): string { return `copilot:chat-stream-lock:${chatId}` } -export function registerActiveStream(streamId: string, controller: AbortController): void { - activeStreams.set(streamId, controller) +export function registerActiveStream( + streamId: string, + abortController: AbortController, + userStopController: AbortController +): void { + activeStreams.set(streamId, { abortController, userStopController }) } export function unregisterActiveStream(streamId: string): void { @@ -285,12 +294,13 @@ export async function abortActiveStream(streamId: string): Promise { async (span) => { await writeAbortMarker(streamId) span.setAttribute(TraceAttr.CopilotAbortMarkerWritten, true) - const controller = activeStreams.get(streamId) - if (!controller) { + const entry = activeStreams.get(streamId) + if (!entry) { span.setAttribute(TraceAttr.CopilotAbortControllerFired, false) return false } - controller.abort(AbortReason.UserStop) + entry.userStopController.abort(AbortReason.UserStop) + entry.abortController.abort(AbortReason.UserStop) activeStreams.delete(streamId) span.setAttribute(TraceAttr.CopilotAbortControllerFired, true) return true @@ -326,11 +336,17 @@ const pollingStreams = new Set() export function startAbortPoller( streamId: string, abortController: AbortController, - options?: { pollMs?: number; requestId?: string; chatId?: string } + options?: { + pollMs?: number + requestId?: string + chatId?: string + userStopController?: AbortController + } ): ReturnType { const pollMs = options?.pollMs ?? DEFAULT_ABORT_POLL_MS const requestId = options?.requestId const chatId = options?.chatId + const userStopController = options?.userStopController let lastHeartbeatAt = Date.now() let heartbeatOwnershipLost = false @@ -341,46 +357,60 @@ export function startAbortPoller( void (async () => { try { - const shouldAbort = await hasAbortMarker(streamId) - if (shouldAbort && !abortController.signal.aborted) { - abortController.abort(AbortReason.RedisPoller) - await clearAbortMarker(streamId) - } - } catch (error) { - logger.warn('Failed to poll stream abort marker', { - streamId, - ...(requestId ? { requestId } : {}), - error: toError(error).message, - }) - } finally { - pollingStreams.delete(streamId) - } - - if (!chatId || heartbeatOwnershipLost) return - if (Date.now() - lastHeartbeatAt < CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS) return - - try { - const owned = await extendLock( - getChatStreamLockKey(chatId), - streamId, - CHAT_STREAM_LOCK_TTL_SECONDS - ) - lastHeartbeatAt = Date.now() - if (!owned) { - heartbeatOwnershipLost = true - logger.warn('Lost ownership of chat stream lock — stopping heartbeat', { - chatId, + try { + const shouldAbort = await hasAbortMarker(streamId) + if (shouldAbort && !abortController.signal.aborted) { + userStopController?.abort(AbortReason.RedisPoller) + abortController.abort(AbortReason.RedisPoller) + await clearAbortMarker(streamId) + } + } catch (error) { + logger.warn('Failed to poll stream abort marker', { streamId, ...(requestId ? { requestId } : {}), + error: toError(error).message, }) } - } catch (error) { - logger.warn('Failed to extend chat stream lock TTL', { - chatId, - streamId, - ...(requestId ? { requestId } : {}), - error: toError(error).message, - }) + + if ( + chatId && + !heartbeatOwnershipLost && + Date.now() - lastHeartbeatAt >= CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS + ) { + try { + const owned = await extendLock( + getChatStreamLockKey(chatId), + streamId, + CHAT_STREAM_LOCK_TTL_SECONDS + ) + lastHeartbeatAt = Date.now() + if (!owned) { + heartbeatOwnershipLost = true + if (!userStopController?.signal.aborted) { + userStopController?.abort(AbortReason.LockOwnershipLost) + } + if (!abortController.signal.aborted) { + abortController.abort(AbortReason.LockOwnershipLost) + } + logger.warn('Lost ownership of chat stream lock — aborting stale stream', { + chatId, + streamId, + ...(requestId ? { requestId } : {}), + }) + } + } catch (error) { + logger.warn('Failed to extend chat stream lock TTL', { + chatId, + streamId, + ...(requestId ? { requestId } : {}), + error: toError(error).message, + }) + } + } + } finally { + // Cover both marker polling and the (potentially slower) lock EVAL so + // the 250ms timer cannot overlap heartbeats for one stream. + pollingStreams.delete(streamId) } })() }, pollMs) diff --git a/apps/sim/lib/copilot/request/session/explicit-abort.test.ts b/apps/sim/lib/copilot/request/session/explicit-abort.test.ts index 5cfcd9efadf..bd01932fab2 100644 --- a/apps/sim/lib/copilot/request/session/explicit-abort.test.ts +++ b/apps/sim/lib/copilot/request/session/explicit-abort.test.ts @@ -10,8 +10,9 @@ beforeAll(() => { afterAll(resetEnvMock) -const { mockFetchGo } = vi.hoisted(() => ({ +const { mockFetchGo, mockGetMothershipBaseURL } = vi.hoisted(() => ({ mockFetchGo: vi.fn(), + mockGetMothershipBaseURL: vi.fn().mockResolvedValue('https://copilot.test'), })) vi.mock('@/lib/copilot/request/go/fetch', () => ({ @@ -19,7 +20,7 @@ vi.mock('@/lib/copilot/request/go/fetch', () => ({ })) vi.mock('@/lib/copilot/server/agent-url', () => ({ - getMothershipBaseURL: vi.fn().mockResolvedValue('https://copilot.test'), + getMothershipBaseURL: mockGetMothershipBaseURL, getMothershipSourceEnvHeaders: vi.fn().mockReturnValue({ 'X-Sim-Source-Env': 'test' }), })) @@ -48,4 +49,23 @@ describe('requestExplicitStreamAbort', () => { }) ) }) + + it('routes separately from the execution owner stamped into the abort body', async () => { + await requestExplicitStreamAbort({ + streamId: 'stream-1', + userId: 'workspace-billing-actor', + routingUserId: 'workspace-key-owner', + workspaceId: 'workspace-1', + }) + + expect(mockGetMothershipBaseURL).toHaveBeenCalledWith({ + userId: 'workspace-key-owner', + }) + const request = mockFetchGo.mock.calls[0]?.[1] as RequestInit + expect(JSON.parse(String(request.body))).toEqual({ + messageId: 'stream-1', + userId: 'workspace-billing-actor', + workspaceId: 'workspace-1', + }) + }) }) diff --git a/apps/sim/lib/copilot/request/session/explicit-abort.ts b/apps/sim/lib/copilot/request/session/explicit-abort.ts index 37fe00f1343..b6021e1053b 100644 --- a/apps/sim/lib/copilot/request/session/explicit-abort.ts +++ b/apps/sim/lib/copilot/request/session/explicit-abort.ts @@ -13,7 +13,10 @@ export const DEFAULT_EXPLICIT_ABORT_TIMEOUT_MS = 3000 export async function requestExplicitStreamAbort(params: { streamId: string + /** Authenticated execution/billing owner stamped into the Go request body. */ userId: string + /** Sim principal whose environment override selects the Mothership URL. */ + routingUserId?: string chatId?: string workspaceId?: string timeoutMs?: number @@ -22,6 +25,7 @@ export async function requestExplicitStreamAbort(params: { const { streamId, userId, + routingUserId, chatId, workspaceId, timeoutMs = DEFAULT_EXPLICIT_ABORT_TIMEOUT_MS, @@ -44,7 +48,7 @@ export async function requestExplicitStreamAbort(params: { ) try { - const mothershipBaseURL = await getMothershipBaseURL({ userId }) + const mothershipBaseURL = await getMothershipBaseURL({ userId: routingUserId ?? userId }) const response = await fetchGo(`${mothershipBaseURL}/api/streams/explicit-abort`, { method: 'POST', headers, diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts index 5078e2377fe..57a7860c8df 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -1,12 +1,15 @@ import '@sim/testing/mocks/executor' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' const { executeTool, completeAsyncToolCall, + getAsyncToolCall, markAsyncToolRunning, upsertAsyncToolCall, + publishToolConfirmation, onEvent, recordSimToolMetric, setAttribute, @@ -16,8 +19,10 @@ const { return { executeTool: vi.fn(), completeAsyncToolCall: vi.fn(), + getAsyncToolCall: vi.fn().mockResolvedValue(null), markAsyncToolRunning: vi.fn(), upsertAsyncToolCall: vi.fn(), + publishToolConfirmation: vi.fn(), onEvent: vi.fn(), recordSimToolMetric: vi.fn(), setAttribute, @@ -35,12 +40,13 @@ vi.mock('@/lib/copilot/tool-executor', () => ({ vi.mock('@/lib/copilot/async-runs/repository', () => ({ completeAsyncToolCall, + getAsyncToolCall, markAsyncToolRunning, upsertAsyncToolCall, })) vi.mock('@/lib/copilot/persistence/tool-confirm', () => ({ - publishToolConfirmation: vi.fn(), + publishToolConfirmation, })) vi.mock('@/lib/copilot/request/metrics', () => ({ @@ -77,6 +83,7 @@ import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothershi import { createStreamingContext } from '@/lib/copilot/request/context/request-context' import { buildToolExecutionContext, + cancelToolCallAndReport, executeToolAndReport, pendingToolWaitBudgetMs, toolWatchdogTimeoutMs, @@ -394,3 +401,89 @@ describe('executeToolAndReport metrics', () => { } ) }) + +describe('buildToolExecutionContext authorization and stop signals', () => { + it('projects the authorization principal while retaining the immutable billing actor', () => { + const billingAttribution: BillingAttributionSnapshot = { + actorUserId: 'workspace-billed-account', + workspaceId: 'workspace-1', + organizationId: null, + billedAccountUserId: 'workspace-billed-account', + billingEntity: { type: 'user', id: 'workspace-billed-account' }, + billingPeriod: { start: '2026-07-01', end: '2026-08-01' }, + payerSubscription: null, + } + const executionContext: ExecutionContext = { + userId: 'workspace-billed-account', + authorizationUserId: 'workspace-key-owner', + workflowId: '', + workspaceId: 'workspace-1', + billingAttribution, + } + + const toolContext = buildToolExecutionContext({ id: 'call-1' }, executionContext) + expect(toolContext).toMatchObject({ + userId: 'workspace-key-owner', + workspaceId: 'workspace-1', + toolCallId: 'call-1', + }) + expect(toolContext.billingAttribution).toBe(billingAttribution) + expect(toolContext).not.toHaveProperty('authorizationUserId') + expect(toolContext).not.toHaveProperty('billingActorUserId') + expect(executionContext.userId).toBe('workspace-billed-account') + expect(executionContext).not.toHaveProperty('billingActorUserId') + }) + + it('preserves the explicit user-stop signal in the per-tool context', () => { + const userStopController = new AbortController() + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + userStopSignal: userStopController.signal, + } + + const toolContext = buildToolExecutionContext({ id: 'call-1' }, executionContext) + + expect(toolContext.userStopSignal).toBe(userStopController.signal) + }) +}) + +describe('cancelToolCallAndReport', () => { + beforeEach(() => { + vi.clearAllMocks() + upsertAsyncToolCall.mockResolvedValue({ toolCallId: 'tool-stop' }) + }) + + it('publishes cancellation only when its durable terminal transition wins', async () => { + const losingContext = createStreamingContext({ runId: 'run-1' }) + losingContext.toolCalls.set('tool-lost-race', { + id: 'tool-lost-race', + name: 'read', + status: 'executing', + }) + completeAsyncToolCall.mockResolvedValueOnce(null) + + await cancelToolCallAndReport('tool-lost-race', losingContext) + expect(publishToolConfirmation).not.toHaveBeenCalled() + + const winningContext = createStreamingContext({ runId: 'run-1' }) + winningContext.toolCalls.set('tool-won-race', { + id: 'tool-won-race', + name: 'read', + status: 'executing', + }) + completeAsyncToolCall.mockResolvedValueOnce({ + toolCallId: 'tool-won-race', + status: 'cancelled', + }) + + await cancelToolCallAndReport('tool-won-race', winningContext) + expect(publishToolConfirmation).toHaveBeenCalledOnce() + expect(publishToolConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ + toolCallId: 'tool-won-race', + status: MothershipStreamV1ToolOutcome.cancelled, + }) + ) + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index 2d550c7ff82..ccaf638aaa9 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -5,8 +5,10 @@ import type { AsyncCompletionEnvelope, AsyncCompletionSignal, } from '@/lib/copilot/async-runs/lifecycle' +import { isTerminalAsyncStatus } from '@/lib/copilot/async-runs/lifecycle' import { completeAsyncToolCall, + getAsyncToolCall, markAsyncToolRunning, upsertAsyncToolCall, } from '@/lib/copilot/async-runs/repository' @@ -76,6 +78,7 @@ import { type ToolCallState, } from '@/lib/copilot/request/types' import { ensureHandlersRegistered, executeTool } from '@/lib/copilot/tool-executor' +import type { ToolExecutionContext } from '@/lib/copilot/tool-executor/types' import { isMcpTool } from '@/executor/constants' export { waitForToolCompletion } from '@/lib/copilot/request/tools/client' @@ -196,7 +199,11 @@ function abortRequested( options?: OrchestratorOptions ): boolean { return Boolean( - options?.abortSignal?.aborted || execContext.abortSignal?.aborted || context.wasAborted + options?.userStopSignal?.aborted || + execContext.userStopSignal?.aborted || + options?.abortSignal?.aborted || + execContext.abortSignal?.aborted || + context.wasAborted ) } @@ -273,9 +280,13 @@ class ToolExecutionTimeoutError extends Error { export function buildToolExecutionContext( toolCall: Pick, execContext: ExecutionContext -): ExecutionContext { +): ToolExecutionContext { + const { authorizationUserId, ...toolContext } = execContext return { - ...execContext, + ...toolContext, + ...(authorizationUserId && authorizationUserId !== execContext.userId + ? { userId: authorizationUserId } + : {}), toolCallId: toolCall.id, resolvedSecretTraceRegistry: execContext.resolvedSecretTraceRegistry?.forkForToolInput( toolCall.params @@ -289,12 +300,31 @@ export function buildToolExecutionContext( * resolves nor rejects within the tool's watchdog cap, throw a timeout error * so the standard failure path (persist failed row, publish terminal * confirmation, resume Go with an error result) runs and the chat never - * wedges behind a hung await. The losing promise keeps running detached; its - * eventual settlement is ignored. + * wedges behind a hung await. The losing promise's result is ignored, but the + * raw execution remains tracked so an explicit Stop keeps the chat lease until + * any still-mutating handler has actually unwound. */ -async function executeToolWithWatchdog(toolCall: ToolCallState, toolContext: ExecutionContext) { +async function executeToolWithWatchdog( + toolCall: ToolCallState, + context: StreamingContext, + toolContext: ToolExecutionContext +) { const timeoutMs = toolWatchdogTimeoutMs(toolCall.name) const execution = executeTool(toolCall.name, toolCall.params || {}, toolContext) + const inFlightToolExecutions = (context.inFlightToolExecutions ??= new Map()) + inFlightToolExecutions.set(toolCall.id, execution) + void execution.then( + () => { + if (inFlightToolExecutions.get(toolCall.id) === execution) { + inFlightToolExecutions.delete(toolCall.id) + } + }, + () => { + if (inFlightToolExecutions.get(toolCall.id) === execution) { + inFlightToolExecutions.delete(toolCall.id) + } + } + ) let timer: ReturnType | undefined try { return await Promise.race([ @@ -314,6 +344,77 @@ async function executeToolWithWatchdog(toolCall: ToolCallState, toolContext: Exe } } +/** + * Durably terminalizes a Sim-owned tool call when its turn is stopped. + * + * The upsert closes the narrow race where cancellation wins before the normal + * executor creates its row. `markAsyncToolRunning` is terminal-safe, so a late + * executor cannot resurrect this cancellation back to `running`. + */ +export async function cancelToolCallAndReport( + toolCallId: string, + context: StreamingContext, + message = 'Stopped by user' +): Promise { + const toolCall = context.toolCalls.get(toolCallId) + if (!toolCall) return + + const alreadyCancelled = toolCall.status === MothershipStreamV1ToolOutcome.cancelled + if ( + !alreadyCancelled && + (toolCall.endTime !== undefined || isTerminalToolCallStatus(toolCall.status)) + ) { + return + } + + if (!alreadyCancelled) { + setTerminalToolCallState(toolCall, { + status: MothershipStreamV1ToolOutcome.cancelled, + error: message, + }) + } + markToolResultSeen(toolCallId) + + if (context.runId) { + await upsertAsyncToolCall({ + runId: context.runId, + toolCallId, + toolName: toolCall.name, + args: toolCall.params, + }).catch((err) => { + logger.warn('Failed to persist async tool row before cancellation', { + toolCallId, + error: toError(err).message, + }) + }) + } + + const persisted = await completeAsyncToolCall({ + toolCallId, + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: message, + }).catch((err) => { + logger.warn('Failed to persist async tool cancellation', { + toolCallId, + error: toError(err).message, + }) + return null + }) + + // Only the winner of the pending/running -> cancelled transition publishes. + // A null row means another terminal outcome already won and must not be + // overwritten by a late stop notification. + if (persisted) { + publishTerminalToolConfirmation({ + toolCallId, + status: MothershipStreamV1ToolOutcome.cancelled, + message, + data: { cancelled: true }, + }) + } +} + /** * Last-resort settlement for a tool whose promise never settled (a hang the * per-tool watchdog could not see, e.g. in post-processing or persistence). @@ -484,6 +585,9 @@ async function executeToolAndReportInner( }) } if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) { + if (toolCall.status === MothershipStreamV1ToolOutcome.cancelled) { + await cancelToolCallAndReport(toolCall.id, context, requireToolCallError(toolCall)) + } return terminalCompletionFromToolCall(toolCall) } @@ -495,26 +599,9 @@ async function executeToolAndReportInner( } if (abortRequested(context, execContext, options)) { - markToolCallCancelled('Request aborted before tool execution') - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Request aborted before tool execution', - }).catch((err) => { - logger.warn('Failed to persist async tool status', { - toolCallId: toolCall.id, - error: toError(err).message, - }) - }) - publishTerminalToolConfirmation({ - toolCallId: toolCall.id, - status: MothershipStreamV1ToolOutcome.cancelled, - message: 'Request aborted before tool execution', - data: { cancelled: true }, - }) - return cancelledCompletion('Request aborted before tool execution') + const message = 'Request aborted before tool execution' + await cancelToolCallAndReport(toolCall.id, context, message) + return cancelledCompletion(message) } toolCall.status = 'executing' @@ -529,15 +616,53 @@ async function executeToolAndReportInner( error: toError(err).message, }) }) - await markAsyncToolRunning(toolCall.id, 'sim-stream').catch((err) => { + const runningToolCall = await markAsyncToolRunning(toolCall.id, 'sim-stream').catch((err) => { logger.warn('Failed to mark async tool running', { toolCallId: toolCall.id, error: toError(err).message, }) + return null }) - if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) { - return terminalCompletionFromToolCall(toolCall) + if (!runningToolCall) { + const durableToolCall = await getAsyncToolCall(toolCall.id).catch((err) => { + logger.warn('Failed to inspect async tool state after running transition lost', { + toolCallId: toolCall.id, + error: toError(err).message, + }) + return null + }) + if (durableToolCall && isTerminalAsyncStatus(durableToolCall.status)) { + const terminalStatus = + durableToolCall.status === MothershipStreamV1AsyncToolRecordStatus.completed + ? MothershipStreamV1ToolOutcome.success + : durableToolCall.status === MothershipStreamV1AsyncToolRecordStatus.cancelled + ? MothershipStreamV1ToolOutcome.cancelled + : MothershipStreamV1ToolOutcome.error + setTerminalToolCallState(toolCall, { + status: terminalStatus, + ...(durableToolCall.result !== null && durableToolCall.result !== undefined + ? { output: durableToolCall.result } + : {}), + ...(terminalStatus === MothershipStreamV1ToolOutcome.success + ? {} + : { error: durableToolCall.error || 'Tool execution was already terminalized' }), + }) + markToolResultSeen(toolCall.id) + return terminalCompletionFromToolCall(toolCall) + } + } + + const persistedToolCall = context.toolCalls.get(toolCall.id) ?? toolCall + if (persistedToolCall.endTime || isTerminalToolCallStatus(persistedToolCall.status)) { + if (persistedToolCall.status === MothershipStreamV1ToolOutcome.cancelled) { + await cancelToolCallAndReport( + persistedToolCall.id, + context, + requireToolCallError(persistedToolCall) + ) + } + return terminalCompletionFromToolCall(persistedToolCall) } const argsPreview = toolCall.params ? JSON.stringify(toolCall.params).slice(0, 200) : undefined @@ -546,6 +671,7 @@ async function executeToolAndReportInner( toolName: toolCall.name, argsPreview, abortSignalAborted: execContext.abortSignal?.aborted ?? false, + userStopSignalAborted: execContext.userStopSignal?.aborted ?? false, }) const endToolSpan = ( @@ -560,6 +686,13 @@ async function executeToolAndReportInner( if (options?.abortSignal?.aborted) { abortDetail.optionsAbortReason = String(options.abortSignal.reason ?? 'unknown') } + if (execContext.userStopSignal?.aborted) { + abortDetail.userStopSignalAborted = true + abortDetail.userStopReason = String(execContext.userStopSignal.reason ?? 'unknown') + } + if (options?.userStopSignal?.aborted) { + abortDetail.optionsUserStopReason = String(options.userStopSignal.reason ?? 'unknown') + } if (context.wasAborted) { abortDetail.wasAborted = true } @@ -598,40 +731,31 @@ async function executeToolAndReportInner( try { ensureHandlersRegistered() - let result = await executeToolWithWatchdog(toolCall, toolExecutionContext) - if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) { + let result = await executeToolWithWatchdog(toolCall, context, toolExecutionContext) + const currentToolCall = context.toolCalls.get(toolCall.id) ?? toolCall + if (currentToolCall.endTime || isTerminalToolCallStatus(currentToolCall.status)) { + if (currentToolCall.status === MothershipStreamV1ToolOutcome.cancelled) { + await cancelToolCallAndReport( + currentToolCall.id, + context, + requireToolCallError(currentToolCall) + ) + } endToolSpanFromTerminalState() - return terminalCompletionFromToolCall(toolCall) + return terminalCompletionFromToolCall(currentToolCall) } if (abortRequested(context, execContext, options)) { const copilotResult = inspectToolResultForCopilot( result, toolExecutionContext.resolvedSecretTraceRegistry ).result - markToolCallCancelled('Request aborted during tool execution') - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Request aborted during tool execution', - }).catch((err) => { - logger.warn('Failed to persist async tool status', { - toolCallId: toolCall.id, - error: toError(err).message, - }) - }) - publishTerminalToolConfirmation({ - toolCallId: toolCall.id, - status: MothershipStreamV1ToolOutcome.cancelled, - message: 'Request aborted during tool execution', - data: { cancelled: true }, - }) + const message = 'Request aborted during tool execution' + await cancelToolCallAndReport(toolCall.id, context, message) endToolSpan('cancelled', { cancelReason: 'abort_during_execution', error: copilotResult.success === false ? copilotResult.error : undefined, }) - return cancelledCompletion('Request aborted during tool execution') + return cancelledCompletion(message) } result = await maybeWriteOutputToFile( toolCall.name, @@ -640,27 +764,10 @@ async function executeToolAndReportInner( toolExecutionContext ) if (abortRequested(context, execContext, options)) { - markToolCallCancelled('Request aborted during tool post-processing') - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Request aborted during tool post-processing', - }).catch((err) => { - logger.warn('Failed to persist async tool status', { - toolCallId: toolCall.id, - error: toError(err).message, - }) - }) - publishTerminalToolConfirmation({ - toolCallId: toolCall.id, - status: MothershipStreamV1ToolOutcome.cancelled, - message: 'Request aborted during tool post-processing', - data: { cancelled: true }, - }) + const message = 'Request aborted during tool post-processing' + await cancelToolCallAndReport(toolCall.id, context, message) endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_file' }) - return cancelledCompletion('Request aborted during tool post-processing') + return cancelledCompletion(message) } result = await maybeWriteOutputToTable( toolCall.name, @@ -669,27 +776,10 @@ async function executeToolAndReportInner( toolExecutionContext ) if (abortRequested(context, execContext, options)) { - markToolCallCancelled('Request aborted during tool post-processing') - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Request aborted during tool post-processing', - }).catch((err) => { - logger.warn('Failed to persist async tool status', { - toolCallId: toolCall.id, - error: toError(err).message, - }) - }) - publishTerminalToolConfirmation({ - toolCallId: toolCall.id, - status: MothershipStreamV1ToolOutcome.cancelled, - message: 'Request aborted during tool post-processing', - data: { cancelled: true }, - }) + const message = 'Request aborted during tool post-processing' + await cancelToolCallAndReport(toolCall.id, context, message) endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_table' }) - return cancelledCompletion('Request aborted during tool post-processing') + return cancelledCompletion(message) } result = await maybeWriteReadCsvToTable( toolCall.name, @@ -698,27 +788,10 @@ async function executeToolAndReportInner( toolExecutionContext ) if (abortRequested(context, execContext, options)) { - markToolCallCancelled('Request aborted during tool post-processing') - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Request aborted during tool post-processing', - }).catch((err) => { - logger.warn('Failed to persist async tool status', { - toolCallId: toolCall.id, - error: toError(err).message, - }) - }) - publishTerminalToolConfirmation({ - toolCallId: toolCall.id, - status: MothershipStreamV1ToolOutcome.cancelled, - message: 'Request aborted during tool post-processing', - data: { cancelled: true }, - }) + const message = 'Request aborted during tool post-processing' + await cancelToolCallAndReport(toolCall.id, context, message) endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_csv' }) - return cancelledCompletion('Request aborted during tool post-processing') + return cancelledCompletion(message) } const projection = inspectToolResultForCopilot( result, @@ -859,30 +932,13 @@ async function executeToolAndReportInner( mergeToolRegistry(projection.safe) const safeThrownMessage = copilotError.error || 'Tool failed' if (abortRequested(context, execContext, options)) { - markToolCallCancelled('Request aborted during tool execution') - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Request aborted during tool execution', - }).catch((err) => { - logger.warn('Failed to persist async tool status', { - toolCallId: toolCall.id, - error: toError(err).message, - }) - }) - publishTerminalToolConfirmation({ - toolCallId: toolCall.id, - status: MothershipStreamV1ToolOutcome.cancelled, - message: 'Request aborted during tool execution', - data: { cancelled: true }, - }) + const message = 'Request aborted during tool execution' + await cancelToolCallAndReport(toolCall.id, context, message) endToolSpan('cancelled', { cancelReason: 'abort_during_execution_catch', error: safeThrownMessage, }) - return cancelledCompletion('Request aborted during tool execution') + return cancelledCompletion(message) } setTerminalToolCallState(toolCall, { status: MothershipStreamV1ToolOutcome.error, diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts index 5c75b645730..23af59650ee 100644 --- a/apps/sim/lib/copilot/request/tools/permission.test.ts +++ b/apps/sim/lib/copilot/request/tools/permission.test.ts @@ -375,6 +375,44 @@ describe('runGatedToolExecution', () => { expect(signal.status).toBe('error') }) + it('lets an explicit user stop cancel a permission wait independently of transport', async () => { + const context = makeContext() + const toolCall = makeToolCall() + const transportController = new AbortController() + const userStopController = new AbortController() + let permissionSignal: AbortSignal | undefined + waitForToolPermissionDecision.mockImplementationOnce( + (_toolCallId: string, _timeoutMs: number, signal?: AbortSignal) => { + permissionSignal = signal + return new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(null), { once: true }) + }) + } + ) + + const pending = runGatedToolExecution( + toolCall, + toolCall.id, + toolCall.name, + toolCall.params, + MothershipStreamV1ToolExecutor.client, + context, + { + abortSignal: transportController.signal, + userStopSignal: userStopController.signal, + }, + vi.fn() as () => Promise + ) + + await vi.waitFor(() => expect(permissionSignal).toBeDefined()) + userStopController.abort('stop') + await pending + + expect(permissionSignal?.aborted).toBe(true) + expect(transportController.signal.aborted).toBe(false) + expect(toolCall.status).toBe('cancelled') + }) + it('refuses to run a gated tool whose row is hidden, rather than hanging the turn', async () => { const context = makeContext() const toolCall = makeToolCall() diff --git a/apps/sim/lib/copilot/request/tools/permission.ts b/apps/sim/lib/copilot/request/tools/permission.ts index 08e751dc204..9001eb38725 100644 --- a/apps/sim/lib/copilot/request/tools/permission.ts +++ b/apps/sim/lib/copilot/request/tools/permission.ts @@ -240,10 +240,14 @@ export function runGatedToolExecution( return { status: MothershipStreamV1ToolOutcome.success, message: output.message } } + const stopSignal = + options.abortSignal && options.userStopSignal + ? AbortSignal.any([options.abortSignal, options.userStopSignal]) + : (options.userStopSignal ?? options.abortSignal) const decision = await waitForToolPermissionDecision( toolCallId, PERMISSION_WAIT_TIMEOUT_MS, - options.abortSignal + stopSignal ) if (!decision) { diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index 55ad05c06e7..510232b97fc 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -327,6 +327,20 @@ describe('maybeWriteReadCsvToTable', () => { expect(mockReplaceTableRows).not.toHaveBeenCalled() }) + it('denies outputTable in query-only mode even when the principal can write', async () => { + const result = await maybeWriteReadCsvToTable( + ReadTool.id, + { outputTable: 'tbl_1', path: 'files/people.csv' }, + { success: true, output: { content: 'name,age\nAlice,30' } }, + buildContext({ userPermission: 'admin', queryOnly: true }) + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('query-only') + expect(mockGetTableById).not.toHaveBeenCalled() + expect(mockReplaceTableRows).not.toHaveBeenCalled() + }) + it('imports CSV content through the service with id-keyed rows', async () => { const result = await maybeWriteReadCsvToTable( ReadTool.id, diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index b8158308986..1cdf967fc9a 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -108,6 +108,14 @@ export async function maybeWriteOutputToTable( const outputTable = params?.outputTable as string | undefined if (!outputTable) return result + if (context.queryOnly) { + return { + success: false, + error: + 'function_execute is query-only: outputTable (workspace table overwrite) is not available; return the data and report it instead', + } + } + const denied = denyOutputWriteWithoutWritePermission(context) if (denied) return denied @@ -231,6 +239,14 @@ export async function maybeWriteReadCsvToTable( const outputTable = params?.outputTable as string | undefined if (!outputTable) return result + if (context.queryOnly) { + return { + success: false, + error: + 'read is query-only: outputTable (workspace table overwrite) is not available; inspect the file and report its contents instead', + } + } + const denied = denyOutputWriteWithoutWritePermission(context) if (denied) return denied diff --git a/apps/sim/lib/copilot/request/tools/workflow-context.test.ts b/apps/sim/lib/copilot/request/tools/workflow-context.test.ts index abcd14d5ef9..879d2ccd0c6 100644 --- a/apps/sim/lib/copilot/request/tools/workflow-context.test.ts +++ b/apps/sim/lib/copilot/request/tools/workflow-context.test.ts @@ -189,6 +189,22 @@ describe('create_workflow execution context', () => { expect(Object.isFrozen(attribution)).toBe(true) expect(context.billingAttribution).toBe(billingAttribution) }) + + it('uses the retained billing actor after a tool context projects its authorization user', async () => { + const context = { + ...createContext(), + userId: 'workspace-key-owner', + } + resolveBillingAttributionMock.mockResolvedValue(childBillingAttribution) + + const attribution = await resolveWorkflowExecutionBillingAttribution(context, 'workspace-2') + + expect(resolveBillingAttributionMock).toHaveBeenCalledWith({ + actorUserId: 'user-1', + workspaceId: 'workspace-2', + }) + expect(attribution).toBe(childBillingAttribution) + }) }) describe('prepareWorkflowExecutionAdmission', () => { diff --git a/apps/sim/lib/copilot/request/tools/workflow-context.ts b/apps/sim/lib/copilot/request/tools/workflow-context.ts index 0e8fa1523d6..f5474594372 100644 --- a/apps/sim/lib/copilot/request/tools/workflow-context.ts +++ b/apps/sim/lib/copilot/request/tools/workflow-context.ts @@ -73,12 +73,13 @@ export async function resolveWorkflowExecutionBillingAttribution( return rootAttribution } + const billingActorUserId = context.billingAttribution?.actorUserId ?? context.userId const childAttribution = await resolveBillingAttribution({ - actorUserId: context.userId, + actorUserId: billingActorUserId, workspaceId: targetWorkspaceId, }) if ( - childAttribution.actorUserId !== context.userId || + childAttribution.actorUserId !== billingActorUserId || childAttribution.workspaceId !== targetWorkspaceId ) { throw new Error('Resolved workflow billing attribution does not match its actor and workspace') diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index ed76e5cd505..1391290f966 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -137,6 +137,12 @@ export interface StreamingContext { contentBlocks: ContentBlock[] toolCalls: Map pendingToolPromises: Map> + /** + * Raw handler executions beneath the timeout wrapper. Stop waits for these + * too, so a watchdog timeout cannot detach a still-mutating stopped tool from + * the chat lease. + */ + inFlightToolExecutions?: Map> awaitingAsyncContinuation?: ResumeContinuation currentThinkingBlock: ContentBlock | null /** @@ -216,6 +222,8 @@ export interface OrchestratorOptions { onComplete?: (result: OrchestratorResult) => void | Promise onError?: (error: Error, result?: OrchestratorResult) => void | Promise abortSignal?: AbortSignal + /** Fires only on explicit user stop, never on passive transport disconnect. */ + userStopSignal?: AbortSignal onAbortObserved?: (reason: string) => void interactive?: boolean } @@ -245,5 +253,11 @@ export interface ToolCallSummary { } export interface ExecutionContext extends ToolExecutionContext { + /** + * Turn-scoped authorization principal. It is projected onto `userId` before + * tool dispatch and never enters the generic tool context; billing remains + * frozen in `billingAttribution.actorUserId`. + */ + authorizationUserId?: string messageId?: string } diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index 30b5d8d4f17..b4fe3fa1587 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -89,6 +89,188 @@ describe('copilot tool executor fallback', () => { expect(handler).toHaveBeenCalledOnce() }) + it('rejects a top-level workspaceId outside the trusted workspace before dispatch', async () => { + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const handler = vi.fn().mockResolvedValue({ success: true }) + registerHandler('manage_workspace_resource', handler) + + await expect( + executeTool( + 'manage_workspace_resource', + { workspaceId: 'ws-2' }, + { userId: 'user-1', workflowId: '', workspaceId: 'ws-1' } + ) + ).resolves.toEqual({ + success: false, + error: 'Tool denied: requested workspace does not match the current workspace.', + }) + expect(handler).not.toHaveBeenCalled() + }) + + it('rejects a nested payload workspaceId outside the trusted workspace', async () => { + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const handler = vi.fn().mockResolvedValue({ success: true }) + registerHandler('manage_workspace_resource', handler) + + await expect( + executeTool( + 'manage_workspace_resource', + { payload: { workspaceId: 'ws-2' } }, + { userId: 'user-1', workflowId: '', workspaceId: 'ws-1' } + ) + ).resolves.toEqual({ + success: false, + error: 'Tool denied: requested workspace does not match the current workspace.', + }) + expect(handler).not.toHaveBeenCalled() + }) + + it('preserves workspaceId parameters owned by dynamic integrations', async () => { + isKnownTool.mockReturnValue(false) + isSimExecuted.mockReturnValue(false) + isClientExecuted.mockReturnValue(false) + executeAppTool.mockResolvedValue({ success: true, output: { ok: true } }) + + await expect( + executeTool( + 'external_integration_action', + { workspaceId: 'external-service-workspace' }, + { userId: 'user-1', workflowId: '', workspaceId: 'ws-1' } + ) + ).resolves.toEqual({ success: true, output: { ok: true } }) + expect(executeAppTool).toHaveBeenCalledWith( + 'external_integration_action', + expect.objectContaining({ + workspaceId: 'external-service-workspace', + _context: expect.objectContaining({ workspaceId: 'ws-1' }), + }) + ) + }) + + it('allows explicit workspaceIds that match the trusted workspace', async () => { + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const handler = vi.fn().mockResolvedValue({ success: true }) + registerHandler('manage_workspace_resource', handler) + const context = { userId: 'user-1', workflowId: '', workspaceId: 'ws-1' } + + await expect( + executeTool( + 'manage_workspace_resource', + { workspaceId: 'ws-1', payload: { workspace_id: 'ws-1' } }, + context + ) + ).resolves.toEqual({ success: true }) + expect(handler).toHaveBeenCalledWith( + { workspaceId: 'ws-1', payload: { workspace_id: 'ws-1' } }, + context + ) + }) + + it('fails closed to the reviewed local tool set in query-only mode', async () => { + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const readHandler = vi.fn().mockResolvedValue({ success: true, output: 'ok' }) + const mutationHandler = vi.fn().mockResolvedValue({ success: true }) + registerHandler('read', readHandler) + registerHandler('create_workflow', mutationHandler) + const context = { userId: 'user-1', workflowId: '', queryOnly: true } + + await expect(executeTool('read', { path: 'WORKSPACE.md' }, context)).resolves.toEqual({ + success: true, + output: 'ok', + }) + await expect(executeTool('create_workflow', { name: 'Nope' }, context)).resolves.toEqual({ + success: false, + error: 'Tool denied: create_workflow is not available in query-only mode.', + }) + expect(readHandler).toHaveBeenCalledOnce() + expect(mutationHandler).not.toHaveBeenCalled() + }) + + it('denies private credential controls and dynamic integrations when credentialless', async () => { + isKnownTool.mockImplementation((toolId: string) => toolId !== 'gmail_read') + + for (const toolId of [ + 'generate_api_key', + 'list_user_workspaces', + 'manage_credential', + 'oauth_get_auth_link', + 'oauth_request_access', + 'gmail_read', + ]) { + await expect( + executeTool(toolId, {}, { userId: 'user-1', workflowId: '', secretActorUserId: null }) + ).resolves.toEqual({ + success: false, + error: `Tool denied: ${toolId} is not available without credential access.`, + }) + } + expect(executeAppTool).not.toHaveBeenCalled() + }) + + it('keeps workspace environment writes and workflow runs in credentialless mode', async () => { + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const envHandler = vi.fn().mockResolvedValue({ success: true }) + const runHandler = vi.fn().mockResolvedValue({ success: true, output: { ran: true } }) + registerHandler('set_environment_variables', envHandler) + registerHandler('run_workflow', runHandler) + const context = { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + secretActorUserId: null, + } + + await expect( + executeTool('set_environment_variables', { scope: 'personal', variables: [] }, context) + ).resolves.toEqual({ + success: false, + error: + 'Tool denied: personal environment variables are not available without credential access.', + }) + await expect( + executeTool('set_environment_variables', { scope: 'workspace', variables: [] }, context) + ).resolves.toEqual({ success: true }) + await expect(executeTool('run_workflow', {}, context)).resolves.toEqual({ + success: true, + output: { ran: true }, + }) + expect(envHandler).toHaveBeenCalledOnce() + expect(runHandler).toHaveBeenCalledOnce() + }) + + it('keeps workspace custom tools but denies MCP execution in credentialless mode', async () => { + isKnownTool.mockReturnValue(false) + isSimExecuted.mockReturnValue(false) + isClientExecuted.mockReturnValue(false) + executeAppTool.mockResolvedValue({ success: true, output: { ok: true } }) + const context = { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + secretActorUserId: null, + } + + await expect(executeTool('custom_tool-1', {}, context)).resolves.toEqual({ + success: true, + output: { ok: true }, + }) + await expect(executeTool('mcp-server-1-search', {}, context)).resolves.toEqual({ + success: false, + error: 'Tool denied: mcp-server-1-search is not available without credential access.', + }) + expect(executeAppTool).toHaveBeenCalledOnce() + }) + it('projects resolved secrets before logging registered handler failures', async () => { const secret = 'mounted-secret-value' const registry = new ResolvedSecretTraceRegistry([ @@ -143,6 +325,27 @@ describe('copilot tool executor fallback', () => { expect(result).toEqual({ success: true, output: { emails: [] } }) }) + it('forwards the active cancellation signal to dynamic app tools', async () => { + isKnownTool.mockReturnValue(false) + isSimExecuted.mockReturnValue(false) + executeAppTool.mockResolvedValue({ success: true, output: {} }) + const controller = new AbortController() + + await executeTool( + 'gmail_read', + {}, + { + userId: 'user-1', + workflowId: 'workflow-1', + abortSignal: controller.signal, + } + ) + + expect(executeAppTool).toHaveBeenCalledWith('gmail_read', expect.any(Object), { + signal: controller.signal, + }) + }) + it('threads billing attribution into _context for dynamic tools (MCP)', async () => { isKnownTool.mockReturnValue(false) isSimExecuted.mockReturnValue(false) diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index 6488b695f25..adc0ecea93e 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -3,6 +3,7 @@ import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/wo import { toError } from '@sim/utils/errors' import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' +import { isCustomTool, isMcpTool } from '@/executor/constants' import { executeTool as executeAppTool } from '@/tools' import { getToolEntry, isClientExecuted, isKnownTool, isSimExecuted } from './router' import type { @@ -16,6 +17,24 @@ const logger = createLogger('ToolExecutor') const FUNCTION_EXECUTE_TOOL_ID = 'function_execute' const DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS = 10 const MILLISECONDS_PER_SECOND = 1000 +const QUERY_ONLY_TOOL_IDS = new Set([ + 'grep', + 'glob', + 'read', + 'get_block_outputs', + 'get_block_upstream_references', + 'get_deployed_workflow_state', + 'search_knowledge_base', + 'query_user_table', + 'get_platform_actions', +]) +const CREDENTIALLESS_DENIED_TOOL_IDS = new Set([ + 'generate_api_key', + 'list_user_workspaces', + 'manage_credential', + 'oauth_get_auth_link', + 'oauth_request_access', +]) const handlerRegistry = new Map() @@ -46,6 +65,48 @@ export async function executeTool( params: Record, context: ToolExecutionContext ): Promise { + if ( + context.workspaceId && + isKnownTool(toolId) && + hasWorkspaceScopeMismatch(params, context.workspaceId) + ) { + return { + success: false, + error: 'Tool denied: requested workspace does not match the current workspace.', + } + } + + if (context.queryOnly && !QUERY_ONLY_TOOL_IDS.has(toolId)) { + return { + success: false, + error: `Tool denied: ${toolId} is not available in query-only mode.`, + } + } + + if ( + context.secretActorUserId === null && + (CREDENTIALLESS_DENIED_TOOL_IDS.has(toolId) || + isMcpTool(toolId) || + (!isKnownTool(toolId) && !isCustomTool(toolId))) + ) { + return { + success: false, + error: `Tool denied: ${toolId} is not available without credential access.`, + } + } + + if ( + context.secretActorUserId === null && + toolId === 'set_environment_variables' && + params.scope === 'personal' + ) { + return { + success: false, + error: + 'Tool denied: personal environment variables are not available without credential access.', + } + } + const requiredPermission = getToolEntry(toolId)?.requiredPermission if ( requiredPermission && @@ -71,10 +132,15 @@ export async function executeTool( (isSimExecuted(toolId) || (isClientExecuted(toolId) && hasHandler(toolId))) if (!canUseRegisteredHandler) { const appParams = buildAppToolParams(normalizedParams, context) - return context.resolvedSecretTraceRegistry - ? executeAppTool(toolId, appParams, { - resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry, - }) + const signal = context.abortSignal ?? context.userStopSignal + const executionOptions = { + ...(signal ? { signal } : {}), + ...(context.resolvedSecretTraceRegistry + ? { resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry } + : {}), + } + return Object.keys(executionOptions).length > 0 + ? executeAppTool(toolId, appParams, executionOptions) : executeAppTool(toolId, appParams) } @@ -105,6 +171,27 @@ export async function executeTool( } } +function hasWorkspaceScopeMismatch(params: Record, workspaceId: string): boolean { + const payload = + typeof params.payload === 'object' && params.payload !== null + ? (params.payload as Record) + : undefined + const suppliedContext = + typeof params._context === 'object' && params._context !== null + ? (params._context as Record) + : undefined + const candidates = [ + params.workspaceId, + params.workspace_id, + payload?.workspaceId, + payload?.workspace_id, + suppliedContext?.workspaceId, + suppliedContext?.workspace_id, + ] + + return candidates.some((candidate) => typeof candidate === 'string' && candidate !== workspaceId) +} + function normalizeToolParams( toolId: string, params: Record, diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index 17c233e2550..8cc38af972a 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -17,6 +17,8 @@ export interface ToolExecutionContext { copilotToolExecution?: boolean /** Server-owned base image selected from the fixed Go route for this turn. */ sandboxProfile?: 'mothership' + /** Trusted server policy: workspace inspection only, with every write sink disabled. */ + queryOnly?: boolean requestMode?: string currentAgentId?: string /** @@ -27,6 +29,8 @@ export interface ToolExecutionContext { */ parentToolCallId?: string abortSignal?: AbortSignal + /** Fires only on explicit user stop, never on passive transport disconnect. */ + userStopSignal?: AbortSignal userTimezone?: string userPermission?: string secretMountPolicy?: SecretMountPolicy diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index 343c9e2712d..6f32827bcc4 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -2,12 +2,14 @@ import type { ComponentType } from 'react' import { Loader } from '@sim/emcn' import { FileText } from '@sim/emcn/icons' import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' -import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state' -import { humanizeDisplayIdentifier, humanizeToolName } from '@/lib/copilot/tools/tool-display' -import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils' +import { + describeReadTarget, + humanizeDisplayIdentifier, + humanizeToolName, +} from '@/lib/copilot/tools/tool-display' /** Respond tools are internal handoff tools shown with a friendly generic label. */ const HIDDEN_TOOL_SUFFIX = '_respond' @@ -45,7 +47,8 @@ function specialToolDisplay( } if (toolName === ReadTool.id) { - const target = describeReadTarget(readStringParam(params, 'path')) + const path = readStringParam(params, 'path') + const target = describeReadTarget(path, getReadTargetBlock(path)?.name) return { text: formatReadingLabel(target, state), icon: FileText, @@ -83,79 +86,6 @@ function formatReadingLabel(target: string | undefined, state: ClientToolCallSta } } -function describeReadTarget(path: string | undefined): string | undefined { - if (!path) return undefined - - const block = getReadTargetBlock(path) - if (block) return block.name - - const segments = path - .split('/') - .map((segment) => segment.trim()) - .filter(Boolean) - .map(decodeVfsSegmentSafe) - - if (segments.length === 0) return undefined - - const resourceType = VFS_DIR_TO_RESOURCE[segments[0]] - if (!resourceType) { - return humanizeDisplayIdentifier(stripExtension(segments[segments.length - 1]), 'sentence') - } - - if (resourceType === 'file') { - return describeFileReadTarget(segments) - } - - if (resourceType === 'workflow') { - return stripExtension(getLeafResourceSegment(segments)) - } - - const resourceName = segments[1] || segments[segments.length - 1] - return stripExtension(resourceName) -} - -// A workspace file is addressed as a directory of facets in the VFS -// (files/{...path}/{name}/{facet}). `content` is the default facet — reading a -// file means reading its content — so it carries no qualifier, matching a bare -// `files/{...path}/{name}` read. The remaining facets are genuinely distinct, so -// they keep a descriptive label. -const FILE_FACET_LABELS: Record = { - content: '', - 'meta.json': 'metadata for', - style: 'style details for', - 'compiled-check': 'the final file check for', -} - -function describeFileReadTarget(segments: string[]): string { - const lastSegment = segments[segments.length - 1] || '' - const facetLabel = FILE_FACET_LABELS[lastSegment] - // Treat the suffix as a facet only when a real file name precedes it; otherwise - // the leaf is the file itself (e.g. a file literally named "content"). - if (facetLabel !== undefined && segments.length > 2) { - const fileName = segments[segments.length - 2] - return facetLabel ? `${facetLabel} ${fileName}` : fileName - } - // Show just the file name, not the folder path — these are glanceable status - // lines, and the other resource types already render the leaf only. - return lastSegment -} - -function getLeafResourceSegment(segments: string[]): string { - const lastSegment = segments[segments.length - 1] || '' - if (hasFileExtension(lastSegment) && segments.length > 1) { - return segments[segments.length - 2] || lastSegment - } - return lastSegment -} - -function hasFileExtension(value: string): boolean { - return /\.[^/.]+$/.test(value) -} - -function stripExtension(value: string): string { - return value.replace(/\.[^/.]+$/, '') -} - function humanizedFallback( toolName: string, state: ClientToolCallState diff --git a/apps/sim/lib/copilot/tools/handlers/access.test.ts b/apps/sim/lib/copilot/tools/handlers/access.test.ts new file mode 100644 index 00000000000..47a38664729 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/access.test.ts @@ -0,0 +1,72 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { authorizeWorkflow, checkWorkspaceAccess } = vi.hoisted(() => ({ + authorizeWorkflow: vi.fn(), + checkWorkspaceAccess: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + authorizeWorkflowByWorkspacePermission: authorizeWorkflow, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + listAccessibleWorkspaceRowsForUser: vi.fn(), +})) + +import { ensureWorkflowAccess, ensureWorkspaceAccess } from './access' + +describe('Copilot access scope', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('allows a workflow in the trusted workspace', async () => { + const workflow = { id: 'wf-1', workspaceId: 'ws-1' } + authorizeWorkflow.mockResolvedValue({ allowed: true, workflow }) + + await expect( + ensureWorkflowAccess('wf-1', { userId: 'user-1', workspaceId: 'ws-1' }) + ).resolves.toEqual({ workflow, workspaceId: 'ws-1' }) + }) + + it('hides a workflow outside the trusted workspace', async () => { + authorizeWorkflow.mockResolvedValue({ + allowed: true, + workflow: { id: 'wf-2', workspaceId: 'ws-2' }, + }) + + await expect( + ensureWorkflowAccess('wf-2', { userId: 'user-1', workspaceId: 'ws-1' }) + ).rejects.toThrow('Workflow wf-2 not found') + }) + + it('rejects a workspace outside the trusted scope before its membership lookup', async () => { + await expect( + ensureWorkspaceAccess('ws-2', { userId: 'user-1', workspaceId: 'ws-1' }) + ).rejects.toThrow('Workspace ws-2 not found') + expect(checkWorkspaceAccess).not.toHaveBeenCalled() + }) + + it('preserves normal permission checks inside the trusted workspace', async () => { + const access = { + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: false, + } + checkWorkspaceAccess.mockResolvedValue(access) + + await expect( + ensureWorkspaceAccess('ws-1', { userId: 'user-1', workspaceId: 'ws-1' }, 'write') + ).resolves.toBe(access) + expect(checkWorkspaceAccess).toHaveBeenCalledWith('ws-1', 'user-1') + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/access.ts b/apps/sim/lib/copilot/tools/handlers/access.ts index 2f5d592269e..9ea9fbc4b4d 100644 --- a/apps/sim/lib/copilot/tools/handlers/access.ts +++ b/apps/sim/lib/copilot/tools/handlers/access.ts @@ -5,9 +5,14 @@ import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' type WorkflowRecord = NonNullable>> +export interface CopilotAccessContext { + userId: string + workspaceId?: string +} + export async function ensureWorkflowAccess( workflowId: string, - userId: string, + context: CopilotAccessContext, action: 'read' | 'write' | 'admin' = 'read' ): Promise<{ workflow: WorkflowRecord @@ -15,7 +20,7 @@ export async function ensureWorkflowAccess( }> { const result = await authorizeWorkflowByWorkspacePermission({ workflowId, - userId, + userId: context.userId, action, }) @@ -27,6 +32,10 @@ export async function ensureWorkflowAccess( throw new Error(result.message || 'Unauthorized workflow access') } + if (context.workspaceId && result.workflow.workspaceId !== context.workspaceId) { + throw new Error(`Workflow ${workflowId} not found`) + } + return { workflow: result.workflow, workspaceId: result.workflow.workspaceId } } @@ -45,10 +54,14 @@ export async function getDefaultWorkspaceId(userId: string): Promise { export async function ensureWorkspaceAccess( workspaceId: string, - userId: string, + context: CopilotAccessContext, level: 'read' | 'write' | 'admin' = 'read' ): Promise { - const access = await checkWorkspaceAccess(workspaceId, userId) + if (context.workspaceId && workspaceId !== context.workspaceId) { + throw new Error(`Workspace ${workspaceId} not found`) + } + + const access = await checkWorkspaceAccess(workspaceId, context.userId) if (!access.exists || !access.hasAccess) { throw new Error(`Workspace ${workspaceId} not found`) } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts index 54817fffbc3..1c7c26ba711 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts @@ -119,7 +119,7 @@ describe('executeDeployCustomBlock', () => { context ) - expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin') + expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', context, 'admin') expect(publishCustomBlockMock).toHaveBeenCalledWith({ organizationId: 'org-1', workspaceId: 'ws-1', 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 8b2182b6abe..108e9583cdb 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -129,7 +129,7 @@ export async function executeDeployCustomBlock( let workflowRecord: Awaited>['workflow'] try { - workflowRecord = (await ensureWorkflowAccess(workflowId, context.userId, 'admin')).workflow + workflowRecord = (await ensureWorkflowAccess(workflowId, context, 'admin')).workflow } catch (error) { const message = toError(error).message if (message.includes('not found')) { diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index a19291b1f87..392fabe7eda 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -166,11 +166,7 @@ export async function executeDeployApi( return { success: false, error: 'workflowId is required' } } const action = params.action === 'undeploy' ? 'undeploy' : 'deploy' - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'admin' - ) + const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context, 'admin') if (action === 'undeploy') { const result = await performFullUndeploy({ workflowId, userId: context.userId }) @@ -586,11 +582,7 @@ export async function executeDeployMcp( return { success: false, error: 'workflowId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'admin' - ) + const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context, 'admin') const workspaceId = workflowRecord.workspaceId if (!workspaceId) { return { success: false, error: 'workspaceId is required' } @@ -869,7 +861,7 @@ export async function executeRedeploy( 'versionName is required. Provide a short human-readable label for this deployment version.', } } - await ensureWorkflowAccess(workflowId, context.userId, 'admin') + await ensureWorkflowAccess(workflowId, context, 'admin') const result = await performFullDeploy({ workflowId, diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts index 55ec5455a9e..87654dba84a 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts @@ -102,7 +102,11 @@ describe('executeLoadDeployment', () => { workflowId: 'wf-1', } as ExecutionContext) - expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin') + expect(ensureWorkflowAccessMock).toHaveBeenCalledWith( + 'wf-1', + expect.objectContaining({ userId: 'user-1', workflowId: 'wf-1' }), + 'admin' + ) expect(performRevertToVersionMock).toHaveBeenCalledWith({ workflowId: 'wf-1', version: 7, @@ -195,7 +199,11 @@ describe('executePromoteToLive', () => { toolCallId: 'call-1', } as ExecutionContext) - expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin') + expect(ensureWorkflowAccessMock).toHaveBeenCalledWith( + 'wf-1', + expect.objectContaining({ userId: 'user-1', workflowId: 'wf-1' }), + 'admin' + ) expect(performActivateVersionMock).toHaveBeenCalledWith({ workflowId: 'wf-1', version: 3, @@ -340,8 +348,16 @@ describe('executeDiffWorkflows', () => { workflowId: 'wf-1', } as ExecutionContext) - expect(resolveWorkflowStateRefMock).toHaveBeenCalledWith('wf-1', 1, 'user-1') - expect(resolveWorkflowStateRefMock).toHaveBeenCalledWith('wf-1', 'live', 'user-1') + expect(resolveWorkflowStateRefMock).toHaveBeenCalledWith( + 'wf-1', + 1, + expect.objectContaining({ userId: 'user-1', workflowId: 'wf-1' }) + ) + expect(resolveWorkflowStateRefMock).toHaveBeenCalledWith( + 'wf-1', + 'live', + expect.objectContaining({ userId: 'user-1', workflowId: 'wf-1' }) + ) // ref1 = base/previous, ref2 = target/current. expect(generateWorkflowDiffSummaryMock).toHaveBeenCalledWith({ target: true }, { base: true }) expect(result.success).toBe(true) @@ -352,6 +368,74 @@ describe('executeDiffWorkflows', () => { diff: { hasChanges: false }, }) }) + + it('removes credentials before diffing in secretless mode', async () => { + const state = (apiKey: string) => ({ + blocks: { + request: { + id: 'request', + type: 'unknown-integration', + subBlocks: { + apiKey: { id: 'apiKey', type: 'short-input', value: apiKey }, + path: { id: 'path', type: 'short-input', value: '/users' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }) + resolveWorkflowStateRefMock + .mockResolvedValueOnce({ state: state('SENTINEL_OLD_SECRET'), ref: '1', version: 1 }) + .mockResolvedValueOnce({ state: state('SENTINEL_NEW_SECRET'), ref: '2', version: 2 }) + generateWorkflowDiffSummaryMock.mockReturnValue({ + addedBlocks: [], + removedBlocks: [], + modifiedBlocks: [], + edgeChanges: { added: 0, removed: 0, addedDetails: [], removedDetails: [] }, + loopChanges: { added: 0, removed: 0, modified: 0 }, + parallelChanges: { added: 0, removed: 0, modified: 0 }, + variableChanges: { + added: 0, + removed: 0, + modified: 0, + addedNames: [], + removedNames: [], + modifiedNames: [], + }, + hasChanges: false, + }) + + await executeDiffWorkflows({ workflowId: 'wf-1', ref1: 1, ref2: 2 }, { + userId: 'key-creator', + secretActorUserId: null, + workflowId: 'wf-1', + } as ExecutionContext) + + expect(generateWorkflowDiffSummaryMock).toHaveBeenCalledWith( + expect.objectContaining({ + blocks: expect.objectContaining({ + request: expect.objectContaining({ + subBlocks: expect.objectContaining({ + apiKey: expect.objectContaining({ value: null }), + path: expect.objectContaining({ value: '/users' }), + }), + }), + }), + }), + expect.objectContaining({ + blocks: expect.objectContaining({ + request: expect.objectContaining({ + subBlocks: expect.objectContaining({ + apiKey: expect.objectContaining({ value: null }), + path: expect.objectContaining({ value: '/users' }), + }), + }), + }), + }) + ) + expect(JSON.stringify(generateWorkflowDiffSummaryMock.mock.calls)).not.toContain('SENTINEL_') + }) }) describe('executeCheckDeploymentStatus', () => { diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index c2e13b77b14..5d146a6ddef 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -3,6 +3,7 @@ import { chat, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/sche import { toError } from '@sim/utils/errors' import { and, eq, inArray, isNull } from 'drizzle-orm' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { projectWorkflowStateForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' import { performCreateWorkflowMcpServer, performDeleteWorkflowMcpServer, @@ -44,7 +45,7 @@ export async function executeCheckDeploymentStatus( if (!workflowId) { return { success: false, error: 'workflowId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) + const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context) const workspaceId = workflowRecord.workspaceId const [apiDeploy, chatDeploy, deploymentSummary] = await Promise.all([ @@ -163,18 +164,18 @@ export async function executeListWorkspaceMcpServers( context: ExecutionContext ): Promise { try { - let workspaceId = params.workspaceId || context.workspaceId + let workspaceId = context.workspaceId || params.workspaceId const workflowId = context.workflowId if (!workspaceId && workflowId) { - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) + const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context) workspaceId = workflowRecord.workspaceId ?? undefined } if (!workspaceId) { return { success: false, error: 'workspaceId is required' } } - await ensureWorkspaceAccess(workspaceId, context.userId, 'read') + await ensureWorkspaceAccess(workspaceId, context, 'read') const servers = await db .select({ @@ -226,22 +227,18 @@ export async function executeCreateWorkspaceMcpServer( context: ExecutionContext ): Promise { try { - let workspaceId = params.workspaceId || context.workspaceId + let workspaceId = context.workspaceId || params.workspaceId const workflowId = context.workflowId if (!workspaceId && workflowId) { - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) + const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context, 'write') workspaceId = workflowRecord.workspaceId ?? undefined } if (!workspaceId) { return { success: false, error: 'workspaceId is required' } } - await ensureWorkspaceAccess(workspaceId, context.userId, 'admin') + await ensureWorkspaceAccess(workspaceId, context, 'admin') const name = params.name?.trim() if (!name) { @@ -306,7 +303,7 @@ export async function executeUpdateWorkspaceMcpServer( return { success: false, error: 'MCP server not found' } } - await ensureWorkspaceAccess(existing.workspaceId, context.userId, 'write') + await ensureWorkspaceAccess(existing.workspaceId, context, 'write') const result = await performUpdateWorkflowMcpServer({ serverId, @@ -348,7 +345,7 @@ export async function executeDeleteWorkspaceMcpServer( return { success: false, error: 'MCP server not found' } } - await ensureWorkspaceAccess(existing.workspaceId, context.userId, 'admin') + await ensureWorkspaceAccess(existing.workspaceId, context, 'admin') const result = await performDeleteWorkflowMcpServer({ serverId, @@ -374,7 +371,7 @@ export async function executeGetDeploymentLog( if (!workflowId) { return { success: false, error: 'workflowId is required' } } - await ensureWorkflowAccess(workflowId, context.userId) + await ensureWorkflowAccess(workflowId, context) const { versions: rows } = await listWorkflowVersions(workflowId) @@ -426,12 +423,16 @@ export async function executeDiffWorkflows( // resolveWorkflowStateRef enforces read access on the workflow. const [side1, side2] = await Promise.all([ - resolveWorkflowStateRef(workflowId, params.ref1, context.userId), - resolveWorkflowStateRef(workflowId, params.ref2, context.userId), + resolveWorkflowStateRef(workflowId, params.ref1, context), + resolveWorkflowStateRef(workflowId, params.ref2, context), ]) + const projection = { secretless: context.secretActorUserId === null } + const state1 = projectWorkflowStateForCopilot(side1.state, projection) + const state2 = projectWorkflowStateForCopilot(side2.state, projection) + // ref1 = base/previous, ref2 = target/current: added = present in ref2 only. - const summary = generateWorkflowDiffSummary(side2.state, side1.state) + const summary = generateWorkflowDiffSummary(state2, state1) const diff = { ...summary, modifiedBlocks: summary.modifiedBlocks.map((block) => ({ @@ -496,11 +497,7 @@ export async function executeLoadDeployment( return { success: false, error: target.error } } - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'admin' - ) + const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context, 'admin') const result = await performRevertToVersion({ workflowId, version: target.version, @@ -553,11 +550,7 @@ export async function executePromoteToLive( } } - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'admin' - ) + const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context, 'admin') const result = await performActivateVersion({ workflowId, version, @@ -629,7 +622,7 @@ export async function executeUpdateDeploymentVersion( return { success: false, error: 'Provide a name and/or description to update' } } - await ensureWorkflowAccess(workflowId, context.userId, 'write') + await ensureWorkflowAccess(workflowId, context, 'write') const updated = await updateDeploymentVersionMetadata({ workflowId, diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts b/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts index 8dde36ba6f0..ae84d55e59f 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts @@ -3,7 +3,7 @@ import { workflowDeploymentVersion } from '@sim/db/schema' import { and, eq } from 'drizzle-orm' import { loadWorkflowDeploymentSnapshot } from '@/lib/workflows/persistence/utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { ensureWorkflowAccess } from '../access' +import { type CopilotAccessContext, ensureWorkflowAccess } from '../access' /** Canonical workflow-state selector: a deployment version number, the live * (active) deployment, or the current draft. */ @@ -42,10 +42,10 @@ export function parseWorkflowRef(raw: unknown): WorkflowRef { export async function resolveWorkflowStateRef( workflowId: string, rawRef: unknown, - userId: string + context: CopilotAccessContext ): Promise { const ref = parseWorkflowRef(rawRef) - await ensureWorkflowAccess(workflowId, userId, 'read') + await ensureWorkflowAccess(workflowId, context, 'read') if (ref === 'draft') { const state = await loadWorkflowDeploymentSnapshot(workflowId) diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts index 8257c9cbcd3..a737afc598d 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -335,6 +335,26 @@ describe('executeFunctionExecute trace-secret provenance', () => { expect(mockExecuteTool).not.toHaveBeenCalled() }) + it('forwards cancellation to the nested function executor', async () => { + const controller = new AbortController() + + await executeFunctionExecute( + { code: 'return 1' }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + abortSignal: controller.signal, + } + ) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'function_execute', + expect.any(Object), + expect.objectContaining({ signal: controller.signal }) + ) + }) + it('returns the raw runtime result when provenance import fails', async () => { mockMaterializeCopilotCodeSecrets.mockResolvedValue({ envVars: { API_KEY: 'secret-value' }, diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 067a41f07ca..1b4af0d6773 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -668,7 +668,9 @@ export async function executeFunctionExecute( try { const result = await executeAppTool('function_execute', enrichedParams, { resolvedSecretTraceRegistry: mountedRegistry, - ...(context.abortSignal ? { signal: context.abortSignal } : {}), + ...((context.abortSignal ?? context.userStopSignal) + ? { signal: context.abortSignal ?? context.userStopSignal } + : {}), ...(context.sandboxProfile ? { internalSandboxProfile: context.sandboxProfile } : {}), }) crossingValue = result diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.test.ts new file mode 100644 index 00000000000..ee5625247d4 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.test.ts @@ -0,0 +1,111 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + deleteCustomTool, + deleteWorkspaceCustomTool, + getCustomToolById, + getWorkspaceCustomTool, + listCustomTools, + listWorkspaceCustomTools, + updateWorkspaceCustomTool, + upsertCustomTools, +} = vi.hoisted(() => ({ + deleteCustomTool: vi.fn(), + deleteWorkspaceCustomTool: vi.fn(), + getCustomToolById: vi.fn(), + getWorkspaceCustomTool: vi.fn(), + listCustomTools: vi.fn(), + listWorkspaceCustomTools: vi.fn(), + updateWorkspaceCustomTool: vi.fn(), + upsertCustomTools: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + CUSTOM_TOOL_CREATED: 'created', + CUSTOM_TOOL_UPDATED: 'updated', + CUSTOM_TOOL_DELETED: 'deleted', + }, + AuditResourceType: { CUSTOM_TOOL: 'custom_tool' }, + recordAudit: vi.fn(), +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) +vi.mock('@/lib/copilot/tools/permissions', () => ({ + copilotToolCanWrite: vi.fn(() => true), + copilotWriteDeniedMessage: vi.fn(), +})) +vi.mock('@/lib/workflows/custom-tools/operations', () => ({ + deleteCustomTool, + deleteWorkspaceCustomTool, + getCustomToolById, + getWorkspaceCustomTool, + listCustomTools, + listWorkspaceCustomTools, + updateWorkspaceCustomTool, + upsertCustomTools, +})) + +import { executeManageCustomTool } from './manage-custom-tool' + +const CREDENTIALLESS_CONTEXT = { + userId: 'key-owner', + workflowId: '', + workspaceId: 'ws-1', + userPermission: 'admin', + secretActorUserId: null, +} + +describe('manage_custom_tool credentialless workspace scope', () => { + beforeEach(() => vi.clearAllMocks()) + + it('lists workspace tools without including legacy personal tools', async () => { + listWorkspaceCustomTools.mockResolvedValue([{ id: 'tool-1', title: 'Shared tool' }]) + + const result = await executeManageCustomTool({ operation: 'list' }, CREDENTIALLESS_CONTEXT) + + expect(result.success).toBe(true) + expect(listWorkspaceCustomTools).toHaveBeenCalledWith({ workspaceId: 'ws-1' }) + expect(listCustomTools).not.toHaveBeenCalled() + }) + + it('edits and deletes through workspace-scoped operations', async () => { + const existing = { + id: 'tool-1', + title: 'Shared tool', + schema: { type: 'function', function: { name: 'shared_tool', parameters: {} } }, + code: 'return 1', + } + getWorkspaceCustomTool.mockResolvedValue(existing) + updateWorkspaceCustomTool.mockResolvedValue(existing) + deleteWorkspaceCustomTool.mockResolvedValue(true) + + const edit = await executeManageCustomTool( + { operation: 'edit', toolId: 'tool-1', code: 'return 2' }, + CREDENTIALLESS_CONTEXT + ) + const remove = await executeManageCustomTool( + { operation: 'delete', toolId: 'tool-1' }, + CREDENTIALLESS_CONTEXT + ) + + expect(edit.success).toBe(true) + expect(remove.success).toBe(true) + expect(getWorkspaceCustomTool).toHaveBeenCalledWith({ + toolId: 'tool-1', + workspaceId: 'ws-1', + }) + expect(updateWorkspaceCustomTool).toHaveBeenCalledWith( + expect.objectContaining({ toolId: 'tool-1', workspaceId: 'ws-1', code: 'return 2' }) + ) + expect(deleteWorkspaceCustomTool).toHaveBeenCalledWith({ + toolId: 'tool-1', + workspaceId: 'ws-1', + }) + expect(getCustomToolById).not.toHaveBeenCalled() + expect(deleteCustomTool).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts index d1491ff9af9..48f3ea79273 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts @@ -6,8 +6,12 @@ import { copilotToolCanWrite, copilotWriteDeniedMessage } from '@/lib/copilot/to import { captureServerEvent } from '@/lib/posthog/server' import { deleteCustomTool, + deleteWorkspaceCustomTool, getCustomToolById, + getWorkspaceCustomTool, listCustomTools, + listWorkspaceCustomTools, + updateWorkspaceCustomTool, upsertCustomTools, } from '@/lib/workflows/custom-tools/operations' @@ -63,10 +67,13 @@ export async function executeManageCustomTool( try { if (operation === 'list') { - const toolsForUser = await listCustomTools({ - userId: context.userId, - workspaceId, - }) + if (context.secretActorUserId === null && !workspaceId) { + return { success: false, error: "workspaceId is required for operation 'list'" } + } + const toolsForUser = + context.secretActorUserId === null + ? await listWorkspaceCustomTools({ workspaceId: workspaceId! }) + : await listCustomTools({ userId: context.userId, workspaceId }) return { success: true, @@ -158,11 +165,14 @@ export async function executeManageCustomTool( } } - const existing = await getCustomToolById({ - toolId: params.toolId, - userId: context.userId, - workspaceId, - }) + const existing = + context.secretActorUserId === null + ? await getWorkspaceCustomTool({ toolId: params.toolId, workspaceId }) + : await getCustomToolById({ + toolId: params.toolId, + userId: context.userId, + workspaceId, + }) if (!existing) { return { success: false, error: `Custom tool not found: ${params.toolId}` } } @@ -171,11 +181,24 @@ export async function executeManageCustomTool( const mergedCode = params.code || existing.code const title = params.title || mergedSchema.function?.name || existing.title - await upsertCustomTools({ - tools: [{ id: params.toolId, title, schema: mergedSchema, code: mergedCode }], - workspaceId, - userId: context.userId, - }) + if (context.secretActorUserId === null) { + const updated = await updateWorkspaceCustomTool({ + toolId: params.toolId, + title, + schema: mergedSchema, + code: mergedCode, + workspaceId, + }) + if (!updated) { + return { success: false, error: `Custom tool not found: ${params.toolId}` } + } + } else { + await upsertCustomTools({ + tools: [{ id: params.toolId, title, schema: mergedSchema, code: mergedCode }], + workspaceId, + userId: context.userId, + }) + } recordAudit({ workspaceId, @@ -212,6 +235,9 @@ export async function executeManageCustomTool( } if (operation === 'delete') { + if (context.secretActorUserId === null && !workspaceId) { + return { success: false, error: "workspaceId is required for operation 'delete'" } + } const toolIds: string[] = params.toolIds ?? (params.toolId ? [params.toolId] : []) if (toolIds.length === 0) { return { success: false, error: "'toolId' or 'toolIds' is required for operation 'delete'" } @@ -221,11 +247,10 @@ export async function executeManageCustomTool( const notFound: string[] = [] for (const toolId of toolIds) { - const result = await deleteCustomTool({ - toolId, - userId: context.userId, - workspaceId, - }) + const result = + context.secretActorUserId === null + ? await deleteWorkspaceCustomTool({ toolId, workspaceId: workspaceId! }) + : await deleteCustomTool({ toolId, userId: context.userId, workspaceId }) if (result) { deleted.push(toolId) } else { diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.test.ts new file mode 100644 index 00000000000..cf175bfd8c4 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.test.ts @@ -0,0 +1,88 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { select, from, where } = vi.hoisted(() => { + const where = vi.fn() + const from = vi.fn(() => ({ where })) + const select = vi.fn(() => ({ from })) + return { select, from, where } +}) + +vi.mock('@sim/db', () => ({ db: { select } })) +vi.mock('@sim/db/schema', () => ({ + mcpServers: { + workspaceId: 'workspaceId', + deletedAt: 'deletedAt', + }, +})) +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => conditions), + eq: vi.fn((left: unknown, right: unknown) => [left, right]), + isNull: vi.fn((value: unknown) => [value, null]), +})) +vi.mock('@/lib/copilot/tools/permissions', () => ({ + copilotToolCanWrite: vi.fn(() => true), + copilotWriteDeniedMessage: vi.fn(), +})) +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateMcpServer: vi.fn(), + performDeleteMcpServer: vi.fn(), + performUpdateMcpServer: vi.fn(), +})) + +import { executeManageMcpTool } from './manage-mcp-tool' + +const SERVER = { + id: 'server-1', + name: 'Private MCP', + url: 'https://user:secret@example.com/mcp?token=sentinel', + transport: 'streamable-http', + enabled: true, + connectionStatus: 'connected', +} + +const CONTEXT = { + userId: 'user-1', + workflowId: '', + workspaceId: 'workspace-1', + userPermission: 'admin', +} + +describe('manage_mcp_tool list projection', () => { + beforeEach(() => { + vi.clearAllMocks() + where.mockResolvedValue([SERVER]) + }) + + it('omits raw URLs from secretless workspace chat', async () => { + const result = await executeManageMcpTool( + { operation: 'list' }, + { ...CONTEXT, secretActorUserId: null } + ) + + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + servers: [ + { + id: 'server-1', + name: 'Private MCP', + transport: 'streamable-http', + enabled: true, + connectionStatus: 'connected', + }, + ], + }) + expect(JSON.stringify(result.output)).not.toContain('sentinel') + }) + + it('keeps URLs for normal user-backed chat', async () => { + const result = await executeManageMcpTool({ operation: 'list' }, CONTEXT) + + expect(result.output).toMatchObject({ servers: [{ url: SERVER.url }] }) + expect(select).toHaveBeenCalledOnce() + expect(from).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts index c13ba02c76e..9b710dff9ca 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts @@ -69,7 +69,7 @@ export async function executeManageMcpTool( servers: servers.map((s) => ({ id: s.id, name: s.name, - url: s.url, + ...(context.secretActorUserId === null ? {} : { url: s.url }), transport: s.transport, enabled: s.enabled, connectionStatus: s.connectionStatus, diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts index b789a2360a5..f61885dda67 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts @@ -172,7 +172,7 @@ describe('executeMaterializeFile - workspace write gate', () => { const { ensureWorkspaceAccess } = await import('@/lib/copilot/tools/handlers/access') await executeMaterializeFile({ fileNames: ['a.json'], operation: 'save' }, context) - expect(ensureWorkspaceAccess).toHaveBeenCalledWith(context.workspaceId, context.userId, 'write') + expect(ensureWorkspaceAccess).toHaveBeenCalledWith(context.workspaceId, context, 'write') }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index 3c6f05e17e0..edc6a9934aa 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -557,7 +557,7 @@ export async function executeMaterializeFile( // Every operation writes: save/extract create files, import creates a workflow. // The handler-map path has no central permission gate. try { - await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write') + await ensureWorkspaceAccess(context.workspaceId, context, 'write') } catch (error) { return { success: false, error: getErrorMessage(error, 'Workspace write access required') } } diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index 1c7936d8980..dd840c425c7 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -43,11 +43,7 @@ export async function executeOAuthGetAuthLink( if (!context.workspaceId || !context.userId) { throw new Error('workspaceId and userId are required to generate an OAuth link') } - const workspaceAccess = await ensureWorkspaceAccess( - context.workspaceId, - context.userId, - 'write' - ) + const workspaceAccess = await ensureWorkspaceAccess(context.workspaceId, context, 'write') const permissionConfig = await getUserPermissionConfig(context.userId, context.workspaceId) const configuredAllowedIntegrations = intersectIntegrationAllowlists( permissionConfig?.allowedIntegrations ?? null, diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index 322c5524e5d..a60c1875777 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -474,7 +474,7 @@ describe('vfs mv/cp', () => { context ) - expect(mocks.checkKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-1', 'user-1') + expect(mocks.checkKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-1', 'user-1', 'ws-1') expect(mocks.updateKnowledgeBase).toHaveBeenCalledWith( 'kb-1', { name: 'Product Docs' }, diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index c0fa82f81c6..7ce738a8885 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -158,7 +158,7 @@ export async function executeVfsMkdir( } const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + await ensureWorkspaceAccess(workspaceId, context, 'write') assertMutationNotAborted(context) let ensureWorkflowFolder: ((segments: string[]) => Promise) | undefined @@ -232,7 +232,7 @@ async function executeVfsMutate( } const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + await ensureWorkspaceAccess(workspaceId, context, 'write') assertMutationNotAborted(context) const classified = classifyCategory(sources[0]) @@ -623,7 +623,7 @@ async function mutateWorkflows( id: duplicated.id, }) } else { - await ensureWorkflowAccess(wf.id, context.userId, 'write') + await ensureWorkflowAccess(wf.id, context, 'write') await assertWorkflowMutable(wf.id) const targetFolderId = await dest.ensureFolderId() await assertFolderMutable(targetFolderId) @@ -784,7 +784,7 @@ async function renameFlatResource( if (!match) { return { success: false, error: `Knowledge base not found at ${sources[0]}` } } - const access = await checkKnowledgeBaseWriteAccess(match.id, context.userId) + const access = await checkKnowledgeBaseWriteAccess(match.id, context.userId, workspaceId) if (!access.hasAccess) { return { success: false, @@ -819,7 +819,7 @@ export async function executeVfsRm( } const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + await ensureWorkspaceAccess(workspaceId, context, 'write') assertMutationNotAborted(context) // Loaded at most once, and only when a workflows/ path in this call needs it. @@ -1068,7 +1068,7 @@ async function removeKnowledgeBasePath( if (!match) return { from: path, kind: 'knowledge_base', error: `Knowledge base not found at ${path}` } - const access = await checkKnowledgeBaseWriteAccess(match.id, context.userId) + const access = await checkKnowledgeBaseWriteAccess(match.id, context.userId, workspaceId) if (!access.hasAccess) { return { from: path, diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts index 3d2d82ed9bd..3d492bb7ede 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -306,6 +306,33 @@ describe('vfs handlers oversize policy', () => { expect(result.success).toBe(false) expect(result.error).toContain('cannot be shared safely') }) + + it.each(['compiled', 'compiled-check', 'extract', 'render'])( + 'rejects /%s document execution paths in query-only mode', + async (suffix) => { + const vfs = makeVfs() + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead( + { path: `files/reports/brief.pdf/${suffix}` }, + { ...GREP_CTX, queryOnly: true } + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('query-only') + expect(vfs.readFileContent).not.toHaveBeenCalled() + expect(getOrMaterializeVFS).not.toHaveBeenCalled() + } + ) + + it('requests a secretless VFS for credentialless execution contexts', async () => { + const vfs = makeVfs() + getOrMaterializeVFS.mockResolvedValue(vfs) + + await executeVfsGlob({ pattern: 'workflows/**' }, { ...GREP_CTX, secretActorUserId: null }) + + expect(getOrMaterializeVFS).toHaveBeenCalledWith('ws-1', 'user-1', { secretless: true }) + }) }) describe('vfs grep workspace-file routing', () => { diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index 8593b714946..6b2ca3b4c3f 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -30,12 +30,15 @@ const logger = createLogger('VfsTools') async function getGatedVFS( workspaceId: string, userId: string, - secretMountPolicy?: SecretMountPolicy + secretMountPolicy?: SecretMountPolicy, + secretless = false ) { const vis = await getBlockVisibilityForCopilot(userId, workspaceId) - return withBlockVisibility(vis, () => - getOrMaterializeVFS(workspaceId, userId, { secretMountPolicy }) - ) + const options = { + ...(secretMountPolicy ? { secretMountPolicy } : {}), + ...(secretless ? { secretless: true } : {}), + } + return withBlockVisibility(vis, () => getOrMaterializeVFS(workspaceId, userId, options)) } /** @@ -170,7 +173,12 @@ export async function executeVfsGrep( result = envelope.value provenanceFile = envelope.file } else { - const vfs = await getGatedVFS(workspaceId, context.userId, context.secretMountPolicy) + const vfs = await getGatedVFS( + workspaceId, + context.userId, + context.secretMountPolicy, + context.secretActorUserId === null + ) if (isWorkspaceFileGrepPath(rawPath)) { const envelope = await vfs.grepFileWithProvenance(rawPath, pattern, grepOptions) result = envelope.value @@ -238,7 +246,12 @@ export async function executeVfsGlob( } try { - const vfs = await getGatedVFS(workspaceId, context.userId, context.secretMountPolicy) + const vfs = await getGatedVFS( + workspaceId, + context.userId, + context.secretMountPolicy, + context.secretActorUserId === null + ) let files = vfs.glob(pattern) if (context.chatId && (pattern === 'uploads/*' || pattern.startsWith('uploads/'))) { @@ -274,6 +287,17 @@ export async function executeVfsRead( return { success: false, error: 'No workspace context available' } } + if ( + context.queryOnly && + /\/(?:compiled|compiled-check|extract|render)\/?$/.test(path.trim().replace(/^\/+/, '')) + ) { + return { + success: false, + error: + 'read is query-only: document compilation, extraction, and rendering paths are not available; read the file content or metadata instead', + } + } + try { const parseOptionalNumber = (value: unknown): number | undefined => { if (typeof value === 'number' && Number.isFinite(value)) return value @@ -354,7 +378,12 @@ export async function executeVfsRead( } } - const vfs = await getGatedVFS(workspaceId, context.userId, context.secretMountPolicy) + const vfs = await getGatedVFS( + workspaceId, + context.userId, + context.secretMountPolicy, + context.secretActorUserId === null + ) // Plain canonical file leaves are metadata resources. Dynamic file content // and inspection paths use explicit suffixes like /content, /style, diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts index fa9dc2465e7..359dd4fa7fa 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts @@ -44,6 +44,7 @@ const { reserveExecutionSlotMock, releaseExecutionSlotMock, decryptSecretMock, + saveWorkflowToNormalizedTablesMock, } = vi.hoisted(() => ({ ensureWorkflowAccessMock: vi.fn(), ensureWorkspaceAccessMock: vi.fn(), @@ -60,6 +61,7 @@ const { reserveExecutionSlotMock: vi.fn(), releaseExecutionSlotMock: vi.fn(), decryptSecretMock: vi.fn(), + saveWorkflowToNormalizedTablesMock: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -110,7 +112,7 @@ vi.mock('@/lib/workflows/orchestration', () => ({ vi.mock('@/lib/workflows/persistence/utils', () => ({ loadWorkflowFromNormalizedTables: loadWorkflowFromNormalizedTablesMock, - saveWorkflowToNormalizedTables: vi.fn(), + saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, })) vi.mock('@/lib/workflows/sanitization/json-sanitizer', () => ({ @@ -149,6 +151,7 @@ import { executeRunFromBlock, executeRunWorkflow, executeRunWorkflowUntilBlock, + executeSetBlockEnabled, executeSetGlobalWorkflowVariables, } from './mutations' @@ -280,6 +283,80 @@ describe('lock enforcement', () => { }) }) +describe('executeSetBlockEnabled secretless projection', () => { + const normalizedState = (enabled: boolean) => ({ + blocks: { + request: { + id: 'request', + type: 'unknown-integration', + name: 'Request', + enabled, + subBlocks: { + apiKey: { id: 'apiKey', type: 'short-input', value: 'SENTINEL_API_KEY' }, + path: { id: 'path', type: 'short-input', value: '/users' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }) + + beforeEach(() => { + vi.clearAllMocks() + global.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) as typeof fetch + ensureWorkflowAccessMock.mockResolvedValue({ + workflow: { id: 'workflow-1', name: 'Workflow' }, + }) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) + saveWorkflowToNormalizedTablesMock.mockResolvedValue({ success: true }) + }) + + it('omits raw state and credentials when the block already has the requested state', async () => { + loadWorkflowFromNormalizedTablesMock.mockResolvedValue(normalizedState(false)) + + const result = await executeSetBlockEnabled( + { workflowId: 'workflow-1', blockId: 'request', enabled: false }, + { userId: 'key-creator', secretActorUserId: null } as ExecutionContext + ) + + expect(result.success).toBe(true) + expect(result.output).not.toHaveProperty('workflowState') + expect(result.output).toHaveProperty( + 'copilotSanitizedWorkflowState.blocks.request.subBlocks.apiKey.value', + null + ) + expect(result.output).toHaveProperty( + 'copilotSanitizedWorkflowState.blocks.request.subBlocks.path.value', + '/users' + ) + expect(JSON.stringify(result.output)).not.toContain('SENTINEL_API_KEY') + expect(saveWorkflowToNormalizedTablesMock).not.toHaveBeenCalled() + }) + + it('omits raw state and credentials after persisting a state change', async () => { + loadWorkflowFromNormalizedTablesMock.mockResolvedValue(normalizedState(true)) + + const result = await executeSetBlockEnabled( + { workflowId: 'workflow-1', blockId: 'request', enabled: false }, + { userId: 'key-creator', secretActorUserId: null } as ExecutionContext + ) + + expect(result.success).toBe(true) + expect(result.output).not.toHaveProperty('workflowState') + expect(result.output).toHaveProperty( + 'copilotSanitizedWorkflowState.blocks.request.subBlocks.apiKey.value', + null + ) + expect(result.output).toHaveProperty( + 'copilotSanitizedWorkflowState.blocks.request.enabled', + false + ) + expect(JSON.stringify(result.output)).not.toContain('SENTINEL_API_KEY') + expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() + }) +}) + describe('executeCreateWorkflow billing attribution', () => { beforeEach(() => { vi.clearAllMocks() @@ -473,26 +550,17 @@ describe('executeCreateWorkflow billing attribution', () => { expect(resolveBillingAttributionMock).not.toHaveBeenCalled() }) - it('keeps cross-workspace creation scoped while allowing explicit subsequent execution', async () => { + it('keeps creation in the trusted workspace when params name another workspace', async () => { const context: ExecutionContext = { ...executionContext, workflowId: '' } performCreateWorkflowMock.mockResolvedValue({ success: true, workflow: { id: 'created-workflow', - name: 'Other Workspace Workflow', - workspaceId: 'workspace-2', + name: 'Workspace Workflow', + workspaceId: 'workspace-1', folderId: null, }, }) - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'created-workflow', - userId: 'owner-2', - workspaceId: 'workspace-2', - variables: {}, - }, - }) - resolveBillingAttributionMock.mockResolvedValue(childBillingAttribution) const createResult = await executeCreateWorkflow( { name: 'Other Workspace Workflow', workspaceId: 'workspace-2' }, @@ -500,51 +568,10 @@ describe('executeCreateWorkflow billing attribution', () => { ) expect(createResult.success).toBe(true) - applyCreateWorkflowOutputToContext(createResult.output, context) - expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith('workspace-2', 'user-1', 'write') + expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith('workspace-1', context, 'write') expect(performCreateWorkflowMock).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'user-1', workspaceId: 'workspace-2' }) - ) - expect(context).toMatchObject({ - userId: 'user-1', - workflowId: '', - workspaceId: 'workspace-1', - billingAttribution, - }) - expect(context.billingAttribution).toBe(billingAttribution) - const createOutput = createResult.output as { workflowId: string; workspaceId: string } - expect(createOutput).toEqual( - expect.objectContaining({ workflowId: 'created-workflow', workspaceId: 'workspace-2' }) - ) - - const runResult = await executeRunWorkflow( - { workflowId: createOutput.workflowId, useMockPayload: true }, - context - ) - - expect(runResult.success).toBe(true) - expect(resolveBillingAttributionMock).toHaveBeenCalledOnce() - expect(resolveBillingAttributionMock).toHaveBeenCalledWith({ - actorUserId: 'user-1', - workspaceId: 'workspace-2', - }) - expect(executeWorkflowMock.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ id: 'created-workflow', workspaceId: 'workspace-2' }) - ) - expect(executeWorkflowMock.mock.calls[0]?.[3]).toBe('user-1') - expect(executeWorkflowMock.mock.calls[0]?.[4]).toEqual( - expect.objectContaining({ billingAttribution: childBillingAttribution }) - ) - expect(checkAttributedUsageLimitsMock).toHaveBeenCalledOnce() - expect(checkAttributedUsageLimitsMock).toHaveBeenCalledWith(childBillingAttribution) - expect(reserveExecutionSlotMock).toHaveBeenCalledOnce() - expect(reserveExecutionSlotMock).toHaveBeenCalledWith( - expect.objectContaining({ - billingEntity: childBillingAttribution.billingEntity, - executionId: executeWorkflowMock.mock.calls[0]?.[5], - }) + expect.objectContaining({ userId: 'user-1', workspaceId: 'workspace-1' }) ) - expect(context.billingAttribution).toBe(billingAttribution) }) }) @@ -616,6 +643,19 @@ describe('Copilot workflow execution billing attribution', () => { ) }) + it('forwards cancellation to headless workflow execution', async () => { + const controller = new AbortController() + + await executeRunWorkflow( + { workflowId: 'workflow-1', useMockPayload: true }, + { ...executionContext, abortSignal: controller.signal } + ) + + expect(executeWorkflowMock.mock.calls[0]?.[4]).toEqual( + expect.objectContaining({ abortSignal: controller.signal }) + ) + }) + it('passes only input-crossing parent provenance to the child execution', async () => { const registry = new ResolvedSecretTraceRegistry( [ diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 260bf73fbd2..a1219d2b30c 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -10,6 +10,7 @@ import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration' import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' import { prepareWorkflowExecutionAdmission } from '@/lib/copilot/request/tools/workflow-context' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { projectWorkflowStateForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' import { buildVfsFolderPathMap, decodeVfsPathSegments, @@ -108,6 +109,7 @@ async function executeCopilotWorkflowTarget(params: { params.context.userId, { ...params.options, + abortSignal: params.context.abortSignal, billingAttribution: admission.billingAttribution, ...(trustedInitialResolvedSecretTraceProvenance ? { trustedInitialResolvedSecretTraceProvenance } @@ -358,6 +360,16 @@ function findDescendants(containerId: string, blocksById: Record ({ ensureWorkflowAccessMock: vi.fn(), getEffectiveBlockOutputPathsMock: vi.fn(), hasTriggerCapabilityMock: vi.fn(), getBlockMock: vi.fn(), + listCustomToolsMock: vi.fn(), + listWorkspaceCustomToolsMock: vi.fn(), + discoverMcpToolsMock: vi.fn(), })) const loadWorkflowFromNormalizedTablesMock = @@ -44,13 +50,23 @@ vi.mock('@/blocks/registry', () => ({ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -import { executeGetBlockOutputs } from './queries' +vi.mock('@/lib/workflows/custom-tools/operations', () => ({ + listCustomTools: listCustomToolsMock, + listWorkspaceCustomTools: listWorkspaceCustomToolsMock, +})) + +vi.mock('@/lib/mcp/service', () => ({ + mcpService: { discoverTools: discoverMcpToolsMock }, +})) + +import { executeGetBlockOutputs, executeGetWorkflowData } from './queries' describe('executeGetBlockOutputs', () => { beforeEach(() => { vi.clearAllMocks() ensureWorkflowAccessMock.mockResolvedValue({ workflow: { id: 'wf-1', userId: 'user-1', workspaceId: 'ws-1' }, + workspaceId: 'ws-1', }) getWorkflowByIdMock.mockResolvedValue({ variables: {} }) getBlockMock.mockReturnValue({ category: 'core' }) @@ -111,4 +127,51 @@ describe('executeGetBlockOutputs', () => { variables: [], }) }) + + it('lists only workspace custom tools for a credentialless context', async () => { + listWorkspaceCustomToolsMock.mockResolvedValue([ + { + id: 'tool-workspace', + title: 'Workspace tool', + schema: { function: { name: 'workspace_tool', description: 'Shared', parameters: {} } }, + }, + ]) + + const result = await executeGetWorkflowData({ workflowId: 'wf-1', data_type: 'custom_tools' }, { + workflowId: 'wf-1', + userId: 'user-1', + workspaceId: 'ws-1', + secretActorUserId: null, + } as any) + + expect(result.success).toBe(true) + expect(listWorkspaceCustomToolsMock).toHaveBeenCalledWith({ workspaceId: 'ws-1' }) + expect(listCustomToolsMock).not.toHaveBeenCalled() + expect(result.output).toEqual({ + customTools: [ + { + id: 'tool-workspace', + title: 'Workspace tool', + functionName: 'workspace_tool', + description: 'Shared', + parameters: {}, + }, + ], + }) + }) + + it('does not discover MCP tools for a credentialless context', async () => { + const result = await executeGetWorkflowData({ workflowId: 'wf-1', data_type: 'mcp_tools' }, { + workflowId: 'wf-1', + userId: 'key-creator', + workspaceId: 'ws-1', + secretActorUserId: null, + } as any) + + expect(result).toEqual({ + success: false, + error: 'MCP tools are not available without credential access.', + }) + expect(discoverMcpToolsMock).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts index 41e1de535c3..a04b28eeb3c 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts @@ -7,7 +7,7 @@ import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { getEffectiveBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs' import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator' import { getBlockReferenceTags } from '@/lib/workflows/blocks/block-reference-tags' -import { listCustomTools } from '@/lib/workflows/custom-tools/operations' +import { listCustomTools, listWorkspaceCustomTools } from '@/lib/workflows/custom-tools/operations' import { loadDeployedWorkflowState, loadWorkflowFromNormalizedTables, @@ -51,7 +51,7 @@ export async function executeGetWorkflowRunOptions( return { success: false, error: 'workflowId is required' } } - await ensureWorkflowAccess(workflowId, context.userId) + await ensureWorkflowAccess(workflowId, context) const normalized = await loadWorkflowFromNormalizedTables(workflowId) if (!normalized) { @@ -132,7 +132,7 @@ export async function executeGetWorkflowData( const { workflow: workflowRecord, workspaceId } = await ensureWorkflowAccess( workflowId, - context.userId + context ) if (dataType === 'global_variables') { @@ -152,10 +152,10 @@ export async function executeGetWorkflowData( if (!workspaceId) { return { success: false, error: 'workspaceId is required' } } - const toolsRows = await listCustomTools({ - userId: context.userId, - workspaceId, - }) + const toolsRows = + context.secretActorUserId === null + ? await listWorkspaceCustomTools({ workspaceId }) + : await listCustomTools({ userId: context.userId, workspaceId }) const customToolsData = toolsRows.map((tool) => { const schema = tool.schema as Record | null @@ -176,6 +176,12 @@ export async function executeGetWorkflowData( if (!workspaceId) { return { success: false, error: 'workspaceId is required' } } + if (context.secretActorUserId === null) { + return { + success: false, + error: 'MCP tools are not available without credential access.', + } + } const tools = await mcpService.discoverTools(context.userId, workspaceId, false) const mcpTools = tools.map((tool) => ({ name: String(tool.name || ''), @@ -219,7 +225,7 @@ export async function executeGetBlockOutputs( if (!workflowId) { return { success: false, error: 'workflowId is required' } } - await ensureWorkflowAccess(workflowId, context.userId) + await ensureWorkflowAccess(workflowId, context) const normalized = await loadWorkflowFromNormalizedTables(workflowId) if (!normalized) { @@ -307,7 +313,7 @@ export async function executeGetBlockUpstreamReferences( if (!Array.isArray(params.blockIds) || params.blockIds.length === 0) { return { success: false, error: 'blockIds array is required' } } - await ensureWorkflowAccess(workflowId, context.userId) + await ensureWorkflowAccess(workflowId, context) const normalized = await loadWorkflowFromNormalizedTables(workflowId) if (!normalized) { @@ -503,16 +509,19 @@ export async function executeGetDeployedWorkflowState( return { success: false, error: 'workflowId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) + const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context) try { const deployedState = await loadDeployedWorkflowState(workflowId) - const formatted = formatNormalizedWorkflowForCopilot({ - blocks: deployedState.blocks, - edges: deployedState.edges, - loops: deployedState.loops as Record, - parallels: deployedState.parallels as Record, - }) + const formatted = formatNormalizedWorkflowForCopilot( + { + blocks: deployedState.blocks, + edges: deployedState.edges, + loops: deployedState.loops as Record, + parallels: deployedState.parallels as Record, + }, + { secretless: context.secretActorUserId === null } + ) return { success: true, diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts new file mode 100644 index 00000000000..b53645a2ad3 --- /dev/null +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts @@ -0,0 +1,40 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it, vi } from 'vitest' + +const { routeExecutionMock } = vi.hoisted(() => ({ routeExecutionMock: vi.fn() })) + +vi.mock('@/lib/copilot/tools/server/router', () => ({ routeExecution: routeExecutionMock })) + +import { createServerToolHandler } from './server-tool-adapter' + +describe('createServerToolHandler', () => { + it('propagates the secretless actor policy to server tools', async () => { + routeExecutionMock.mockResolvedValue({ success: true }) + const userStopController = new AbortController() + + await createServerToolHandler('edit_workflow')( + { workflowId: 'workflow-1' }, + { + userId: 'key-creator', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + secretActorUserId: null, + userStopSignal: userStopController.signal, + } + ) + + expect(routeExecutionMock).toHaveBeenCalledWith( + 'edit_workflow', + { workflowId: 'workflow-1', workspaceId: 'workspace-1' }, + expect.objectContaining({ + userId: 'key-creator', + workspaceId: 'workspace-1', + secretActorUserId: null, + userStopSignal: userStopController.signal, + }) + ) + }) +}) diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts index 359ed2a4023..9ca193819d2 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts @@ -20,11 +20,13 @@ export function createServerToolHandler(toolId: string): ToolHandler { workspaceId: context.workspaceId, billingAttribution: context.billingAttribution, userPermission: context.userPermission ?? undefined, + secretActorUserId: context.secretActorUserId, chatId: context.chatId, messageId: context.messageId, parentToolCallId: context.parentToolCallId, abortSignal: context.abortSignal, resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry, + userStopSignal: context.userStopSignal, }) const rec = @@ -45,6 +47,7 @@ export function createServerToolHandler(toolId: string): ToolHandler { toolId, error: projectToolErrorMessageForCopilot(message, context.resolvedSecretTraceRegistry), abortSignalAborted: context.abortSignal?.aborted ?? false, + userStopSignalAborted: context.userStopSignal?.aborted ?? false, }) return { success: false, diff --git a/apps/sim/lib/copilot/tools/server/base-tool.ts b/apps/sim/lib/copilot/tools/server/base-tool.ts index b1db4a7482d..7b0585545f4 100644 --- a/apps/sim/lib/copilot/tools/server/base-tool.ts +++ b/apps/sim/lib/copilot/tools/server/base-tool.ts @@ -7,6 +7,8 @@ export interface ServerToolContext { workspaceId?: string billingAttribution?: BillingAttributionSnapshot userPermission?: string + /** Undefined uses the execution actor; null explicitly disables raw secret access. */ + secretActorUserId?: string | null chatId?: string messageId?: string /** diff --git a/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts index f61622fafff..ebb22736e81 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts @@ -15,9 +15,25 @@ vi.mock('@/lib/knowledge/embeddings', () => ({ generateSearchEmbedding: mockGenerateSearchEmbedding, })) -import { searchDocumentationServerTool } from '@/lib/copilot/tools/server/docs/search-documentation' +import { + normalizeDocsTopK, + searchDocumentationServerTool, +} from '@/lib/copilot/tools/server/docs/search-documentation' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +describe('documentation search result limit', () => { + it.each([ + { input: undefined, expected: 10 }, + { input: 0, expected: 10 }, + { input: -1, expected: 10 }, + { input: 1.5, expected: 10 }, + { input: 12, expected: 12 }, + { input: 10_000, expected: 50 }, + ])('normalizes $input to $expected', ({ input, expected }) => { + expect(normalizeDocsTopK(input)).toBe(expected) + }) +}) + describe('documentation search model boundary', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts b/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts index 9226e27b369..bdeb2ba4f52 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts @@ -14,14 +14,24 @@ interface DocsSearchParams { } const DEFAULT_DOCS_SIMILARITY_THRESHOLD = 0.3 +const DEFAULT_DOCS_TOP_K = 10 +const MAX_DOCS_TOP_K = 50 + +export function normalizeDocsTopK(value: unknown): number { + return typeof value === 'number' && Number.isInteger(value) && value >= 1 + ? Math.min(value, MAX_DOCS_TOP_K) + : DEFAULT_DOCS_TOP_K +} export const searchDocumentationServerTool: BaseServerTool = { name: SearchDocumentation.id, async execute(params: DocsSearchParams, context?: ServerToolContext): Promise { const logger = createLogger('SearchDocumentationServerTool') - const { query, topK = 10, threshold } = params + const { query, threshold } = params if (!query || typeof query !== 'string') throw new Error('query is required') + const topK = normalizeDocsTopK(params.topK) + logger.info('Executing docs search', { queryLength: query.length, topK }) const similarityThreshold = threshold ?? DEFAULT_DOCS_SIMILARITY_THRESHOLD diff --git a/apps/sim/lib/copilot/tools/server/files/create-file.ts b/apps/sim/lib/copilot/tools/server/files/create-file.ts index cd950eb3406..05dcca16886 100644 --- a/apps/sim/lib/copilot/tools/server/files/create-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/create-file.ts @@ -39,7 +39,7 @@ export const createFileServerTool: BaseServerTool if (!workspaceId) { return { success: false, message: 'Workspace ID is required' } } - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + await ensureWorkspaceAccess(workspaceId, context, 'write') const nested = params.args const path = params.path || (nested?.path as string) || '' diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 2215e414f39..52b7042e949 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -312,7 +312,7 @@ export const workspaceFileServerTool: BaseServerTool ({ generateSearchEmbedding: vi.fn(), recordSearchEmbeddingUsage: vi.fn(), })) +vi.mock('@/lib/knowledge/documents/service', () => ({ + createSingleDocument: vi.fn(), +})) vi.mock('@/lib/knowledge/orchestration', () => ({ performCreateKnowledgeBase: vi.fn(), performDeleteKnowledgeBase: mockPerformDeleteKnowledgeBase, @@ -90,7 +93,10 @@ vi.mock('@/app/api/knowledge/utils', () => ({ import { checkAttributedUsageLimits } from '@/lib/billing/core/billing-attribution' import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' -import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' +import { + knowledgeBaseServerTool, + normalizeKnowledgeQueryTopK, +} from '@/lib/copilot/tools/server/knowledge/knowledge-base' import { createSingleDocument } from '@/lib/knowledge/documents/service' import { generateSearchEmbedding, recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' import { executeKnowledgeSearch } from '@/lib/knowledge/search/queries' @@ -123,6 +129,19 @@ const CONTEXT = { billingAttribution: BILLING_ATTRIBUTION, } +describe('knowledge query result limit', () => { + it.each([ + { input: undefined, expected: 5 }, + { input: 0, expected: 5 }, + { input: -1, expected: 5 }, + { input: 1.5, expected: 5 }, + { input: 12, expected: 12 }, + { input: 10_000, expected: 50 }, + ])('normalizes $input to $expected', ({ input, expected }) => { + expect(normalizeKnowledgeQueryTopK(input)).toBe(expected) + }) +}) + describe('knowledge base connector Copilot operations', () => { afterAll(() => { resetDbChainMock() @@ -266,6 +285,10 @@ describe('knowledge base query model boundary', () => { isBYOK: false, }) vi.mocked(executeKnowledgeSearch).mockResolvedValue([]) + mockImportKnowledgeSearchResultSecretProvenance.mockResolvedValue({ + imported: true, + documentMetadata: {}, + }) vi.mocked(recordSearchEmbeddingUsage).mockResolvedValue(undefined) mockImportKnowledgeSearchResultSecretProvenance.mockResolvedValue({ imported: true, @@ -431,17 +454,19 @@ describe('knowledge base add_file usage gate', () => { }) }) - function addFile() { + function addFile( + context: Parameters[1] = { + userId: 'external-admin', + workspaceId: 'workspace-paid', + billingAttribution: BILLING_ATTRIBUTION, + } + ) { return knowledgeBaseServerTool.execute( { operation: 'add_file', args: { knowledgeBaseId: 'knowledge-base-1', filePaths: ['files/report.pdf'] }, }, - { - userId: 'external-admin', - workspaceId: 'workspace-paid', - billingAttribution: BILLING_ATTRIBUTION, - } + context ) } @@ -497,4 +522,72 @@ describe('knowledge base add_file usage gate', () => { }) expect(createSingleDocument).not.toHaveBeenCalled() }) + + it('keeps billing on the retained actor when authorization uses the workspace-key owner', async () => { + vi.mocked(checkAttributedUsageLimits).mockResolvedValue({ + isExceeded: false, + } as Awaited>) + vi.mocked(resolveWorkspaceFileReference).mockResolvedValue(null) + + const result = await addFile({ + userId: 'workspace-key-owner', + workspaceId: 'workspace-paid', + billingAttribution: BILLING_ATTRIBUTION, + }) + + expect(result.success).toBe(false) + expect(checkAttributedUsageLimits).toHaveBeenCalledWith(BILLING_ATTRIBUTION) + }) +}) + +describe('knowledge base query billing identity', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockAssertBillingAttributionSnapshot.mockReturnValue(BILLING_ATTRIBUTION) + vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true }) + vi.mocked(getKnowledgeBaseById).mockResolvedValue({ + id: 'knowledge-base-1', + name: 'Paid KB', + workspaceId: 'workspace-paid', + embeddingModel: 'text-embedding-3-small', + } as Awaited>) + vi.mocked(checkAttributedUsageLimits).mockResolvedValue({ isExceeded: false } as Awaited< + ReturnType + >) + vi.mocked(generateSearchEmbedding).mockResolvedValue({ + embedding: [0.1, 0.2], + isBYOK: false, + }) + vi.mocked(executeKnowledgeSearch).mockResolvedValue([]) + }) + + it('authorizes as the key owner but meters the frozen workspace billing actor', async () => { + const result = await knowledgeBaseServerTool.execute( + { + operation: 'query', + args: { knowledgeBaseId: 'knowledge-base-1', query: 'refund policy' }, + }, + { + userId: 'workspace-key-owner', + workspaceId: 'workspace-paid', + billingAttribution: BILLING_ATTRIBUTION, + resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), + } + ) + + expect(result.success).toBe(true) + expect(checkKnowledgeBaseAccess).toHaveBeenCalledWith( + 'knowledge-base-1', + 'workspace-key-owner', + 'workspace-paid' + ) + expect(recordSearchEmbeddingUsage).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'external-admin', + workspaceId: 'workspace-paid', + billingAttribution: BILLING_ATTRIBUTION, + }) + ) + }) }) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index e7271c7e5af..1a3ae9afdb2 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -60,6 +60,14 @@ import { } from '@/app/api/knowledge/utils' const logger = createLogger('KnowledgeBaseServerTool') +const DEFAULT_KNOWLEDGE_QUERY_TOP_K = 5 +const MAX_KNOWLEDGE_QUERY_TOP_K = 50 + +export function normalizeKnowledgeQueryTopK(value: unknown): number { + return typeof value === 'number' && Number.isInteger(value) && value >= 1 + ? Math.min(value, MAX_KNOWLEDGE_QUERY_TOP_K) + : DEFAULT_KNOWLEDGE_QUERY_TOP_K +} function requireKnowledgeBillingAttribution( context: ServerToolContext, @@ -69,7 +77,8 @@ function requireKnowledgeBillingAttribution( throw new Error('Billing attribution is required for knowledge operations') } const attribution = assertBillingAttributionSnapshot(context.billingAttribution) - if (attribution.actorUserId !== context.userId || attribution.workspaceId !== workspaceId) { + const billingActorUserId = context.billingAttribution?.actorUserId ?? context.userId + if (attribution.actorUserId !== billingActorUserId || attribution.workspaceId !== workspaceId) { throw new Error('Knowledge billing attribution does not match its actor and workspace') } return attribution @@ -120,6 +129,7 @@ export const knowledgeBaseServerTool: BaseServerTool).workspaceId as string | undefined) + const billingActorUserId = context.billingAttribution?.actorUserId ?? context.userId const assertNotAborted = () => assertServerToolNotAborted( context, @@ -193,7 +203,11 @@ export const knowledgeBaseServerTool: BaseServerTool = [] for (const kbId of kbIds) { - const writeAccess = await checkKnowledgeBaseWriteAccess(kbId, context.userId) + const writeAccess = await checkKnowledgeBaseWriteAccess( + kbId, + context.userId, + workspaceId + ) if (!writeAccess.hasAccess) { notFound.push(kbId) continue @@ -618,7 +646,8 @@ export const knowledgeBaseServerTool: BaseServerTool ({ mockUpdateColumnType: vi.fn(), @@ -37,6 +44,11 @@ const { mockBatchInsertRows: vi.fn(), mockReplaceTableRows: vi.fn(), mockAddWorkflowGroup: vi.fn(), + mockUpdateWorkflowGroup: vi.fn(), + mockRunWorkflowColumn: vi.fn(), + mockCancelWorkflowGroupRuns: vi.fn(), + mockLoadWorkflowFromNormalizedTables: vi.fn(), + mockFlattenWorkflowOutputs: vi.fn(), mockCreateTable: vi.fn(), mockDeleteTable: vi.fn(), mockGetWorkspaceTableLimits: vi.fn(), @@ -48,6 +60,7 @@ const { mockRunTableImport: vi.fn(), mockRunTableDelete: vi.fn(), mockRunTableUpdate: vi.fn(), + mockEnsureWorkflowAccess: vi.fn(), fakeEnrichment: { id: 'work-email', name: 'Work Email', @@ -62,6 +75,10 @@ const { }, })) +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + ensureWorkflowAccess: mockEnsureWorkflowAccess, +})) + vi.mock('@sim/utils/id', () => ({ generateId: vi.fn().mockReturnValue('deadbeefcafef00d'), generateShortId: vi.fn().mockReturnValue('short-id'), @@ -93,7 +110,20 @@ vi.mock('@/lib/table/workflow-groups/service', () => ({ addWorkflowGroupOutput: vi.fn(), deleteWorkflowGroup: vi.fn(), deleteWorkflowGroupOutput: vi.fn(), - updateWorkflowGroup: vi.fn(), + updateWorkflowGroup: mockUpdateWorkflowGroup, +})) + +vi.mock('@/lib/table/workflow-columns', () => ({ + cancelWorkflowGroupRuns: mockCancelWorkflowGroupRuns, + runWorkflowColumn: mockRunWorkflowColumn, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadWorkflowFromNormalizedTables: mockLoadWorkflowFromNormalizedTables, +})) + +vi.mock('@/lib/workflows/blocks/flatten-outputs', () => ({ + flattenWorkflowOutputs: mockFlattenWorkflowOutputs, })) vi.mock('@/lib/table/columns/service', () => ({ @@ -169,6 +199,16 @@ function buildTable(overrides: Partial = {}): TableDefinition { } } +const WORKSPACE_KEY_BILLING_ATTRIBUTION: BillingAttributionSnapshot = { + actorUserId: 'workspace-system-actor', + workspaceId: 'workspace-1', + organizationId: null, + billedAccountUserId: 'workspace-system-actor', + billingEntity: { type: 'user', id: 'workspace-system-actor' }, + billingPeriod: { start: '2026-07-01', end: '2026-08-01' }, + payerSubscription: null, +} + /** Lets a runDetached microtask chain run before asserting on the work it dispatched. */ async function flushDetached(): Promise { await Promise.resolve() @@ -629,6 +669,138 @@ describe('userTableServerTool.list_enrichments', () => { }) }) +describe('userTableServerTool workspace-key execution billing', () => { + const context = { + userId: 'workspace-key-owner', + workspaceId: 'workspace-1', + billingAttribution: WORKSPACE_KEY_BILLING_ATTRIBUTION, + } + + beforeEach(() => { + vi.clearAllMocks() + mockGetTableById.mockResolvedValue( + buildTable({ + schema: { + columns: [ + { name: 'name', type: 'string', required: true }, + { name: 'age', type: 'number' }, + ], + workflowGroups: [ + { + id: 'group-1', + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-1', path: 'content', columnName: 'result' }], + }, + ], + }, + }) + ) + mockEnsureWorkflowAccess.mockResolvedValue({ + workflow: { id: 'workflow-1', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + }) + mockAddWorkflowGroup.mockResolvedValue(buildTable()) + mockUpdateWorkflowGroup.mockResolvedValue(buildTable()) + mockRunWorkflowColumn.mockResolvedValue({ dispatchId: 'dispatch-1' }) + mockLoadWorkflowFromNormalizedTables.mockResolvedValue({ + blocks: { + 'block-1': { id: 'block-1', type: 'agent', name: 'Agent', subBlocks: {} }, + }, + edges: [], + }) + mockFlattenWorkflowOutputs.mockReturnValue([ + { + blockId: 'block-1', + blockName: 'Agent', + path: 'content', + leafType: 'string', + }, + ]) + }) + + it('charges manual workflow-column dispatch to the frozen billing actor', async () => { + const result = await userTableServerTool.execute( + { + operation: 'run_column', + args: { tableId: 'tbl_1', groupIds: ['group-1'], runMode: 'incomplete' }, + }, + context + ) + + expect(result.success).toBe(true) + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: 'tbl_1', + workspaceId: 'workspace-1', + triggeredByUserId: 'workspace-system-actor', + }) + ) + }) + + it('does not run a table workflow group outside the trusted workspace', async () => { + mockEnsureWorkflowAccess.mockRejectedValueOnce(new Error('Workflow workflow-1 not found')) + + const result = await userTableServerTool.execute( + { + operation: 'run_column', + args: { tableId: 'tbl_1', groupIds: ['group-1'], runMode: 'incomplete' }, + }, + context + ) + + expect(result).toEqual({ + success: false, + message: 'Operation failed: Workflow workflow-1 not found', + }) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('keeps group mutation ownership separate from auto-run billing', async () => { + const result = await userTableServerTool.execute( + { + operation: 'add_workflow_group', + args: { + tableId: 'tbl_1', + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-1', path: 'content', columnName: 'result' }], + autoRun: true, + }, + }, + context + ) + + expect(result.success).toBe(true) + expect(mockAddWorkflowGroup).toHaveBeenCalledWith( + expect.objectContaining({ + actorUserId: 'workspace-key-owner', + billingActorUserId: 'workspace-system-actor', + autoRun: true, + }), + expect.any(String) + ) + }) + + it('preserves separate mutation and billing actors when enabling auto-run', async () => { + const result = await userTableServerTool.execute( + { + operation: 'update_workflow_group', + args: { tableId: 'tbl_1', groupId: 'group-1', autoRun: true }, + }, + context + ) + + expect(result.success).toBe(true) + expect(mockUpdateWorkflowGroup).toHaveBeenCalledWith( + expect.objectContaining({ + actorUserId: 'workspace-key-owner', + billingActorUserId: 'workspace-system-actor', + autoRun: true, + }), + expect.any(String) + ) + }) +}) + describe('userTableServerTool.add_enrichment', () => { beforeEach(() => { vi.clearAllMocks() @@ -703,7 +875,11 @@ describe('userTableServerTool.add_enrichment', () => { autoRun: true, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + { + userId: 'workspace-key-owner', + workspaceId: 'workspace-1', + billingAttribution: WORKSPACE_KEY_BILLING_ATTRIBUTION, + } ) expect(result.success).toBe(true) @@ -711,6 +887,8 @@ describe('userTableServerTool.add_enrichment', () => { const call = mockAddWorkflowGroup.mock.calls[0][0] expect(call.autoRun).toBe(true) expect(call.group.autoRun).toBe(true) + expect(call.actorUserId).toBe('workspace-key-owner') + expect(call.billingActorUserId).toBe('workspace-system-actor') }) it('rejects an unknown enrichment id', async () => { 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 894bb88a171..5fb555d4ee0 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' +import { ensureWorkflowAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, type BaseServerTool, @@ -280,8 +281,10 @@ async function dispatchUpdateJob(params: { * so the AI can discover valid picks instead of guessing. */ async function loadFlattenedWorkflowOutputs( - workflowId: string + workflowId: string, + context: ServerToolContext ): Promise { + await ensureWorkflowAccess(workflowId, context) const normalized = await loadWorkflowFromNormalizedTables(workflowId) if (!normalized) return null const blocks = Object.values(normalized.blocks ?? {}).map((b) => ({ @@ -294,6 +297,21 @@ async function loadFlattenedWorkflowOutputs( return flattenWorkflowOutputs(blocks, normalized.edges ?? []) } +async function ensureWorkflowGroupAccess( + table: TableDefinition, + groupId: string, + context: ServerToolContext +): Promise { + const group = table.schema.workflowGroups?.find((candidate) => candidate.id === groupId) + if (!group) { + throw new Error(`Workflow group not found: ${groupId}`) + } + if (group.workflowId) { + await ensureWorkflowAccess(group.workflowId, context) + } + return group +} + /** * Validates a list of `(blockId, path)` outputs against the live workflow. * Returns `null` on success; on failure returns an error message that lists @@ -426,6 +444,7 @@ export const userTableServerTool: BaseServerTool const { operation, args = {} } = params const workspaceId = context.workspaceId || ((args as Record).workspaceId as string | undefined) + const billingActorUserId = context.billingAttribution?.actorUserId ?? context.userId const assertNotAborted = () => assertServerToolNotAborted(context, 'Request aborted before table mutation could be applied.') @@ -1824,7 +1843,7 @@ export const userTableServerTool: BaseServerTool message: 'workflowId is required for list_workflow_outputs', } } - const flattened = await loadFlattenedWorkflowOutputs(workflowId) + const flattened = await loadFlattenedWorkflowOutputs(workflowId, context) if (!flattened) { return { success: false, @@ -1873,7 +1892,7 @@ export const userTableServerTool: BaseServerTool } } - const flattened = await loadFlattenedWorkflowOutputs(workflowId) + const flattened = await loadFlattenedWorkflowOutputs(workflowId, context) if (!flattened) { return { success: false, @@ -1927,7 +1946,14 @@ export const userTableServerTool: BaseServerTool // can opt in by passing `autoRun: true`. const autoRun = args.autoRun === true const updated = await addWorkflowGroup( - { tableId: args.tableId, group, outputColumns, autoRun, actorUserId: context.userId }, + { + tableId: args.tableId, + group, + outputColumns, + autoRun, + actorUserId: context.userId, + billingActorUserId, + }, requestId ) signalTableSchemaChanged(args.tableId) @@ -1952,22 +1978,17 @@ export const userTableServerTool: BaseServerTool if (!tableForUpdate || tableForUpdate.workspaceId !== workspaceId) { return { success: false, message: `Table not found: ${args.tableId}` } } + const requestedWorkflowId = args.workflowId as string | undefined + if (requestedWorkflowId) { + await ensureWorkflowAccess(requestedWorkflowId, context) + } + const existingGroup = await ensureWorkflowGroupAccess(tableForUpdate, groupId, context) const updateOutputs = args.outputs as WorkflowGroupOutput[] | undefined if (updateOutputs && updateOutputs.length > 0) { // Resolve which workflow these outputs apply to: explicit override // wins, else the existing group's workflowId. - const existingGroup = tableForUpdate.schema.workflowGroups?.find( - (g) => g.id === groupId - ) - const targetWorkflowId = - (args.workflowId as string | undefined) ?? existingGroup?.workflowId - if (!targetWorkflowId) { - return { - success: false, - message: `Cannot validate outputs — workflow group ${groupId} not found and no workflowId provided`, - } - } - const flattened = await loadFlattenedWorkflowOutputs(targetWorkflowId) + const targetWorkflowId = requestedWorkflowId ?? existingGroup.workflowId + const flattened = await loadFlattenedWorkflowOutputs(targetWorkflowId, context) if (!flattened) { return { success: false, @@ -1990,7 +2011,8 @@ export const userTableServerTool: BaseServerTool tableId: args.tableId, groupId, actorUserId: context.userId, - workflowId: args.workflowId as string | undefined, + billingActorUserId, + workflowId: requestedWorkflowId, name: args.name as string | undefined, dependencies: args.dependencies as WorkflowGroupDependencies | undefined, outputs: updateOutputs, @@ -2050,6 +2072,7 @@ export const userTableServerTool: BaseServerTool if (!tableForAdd || tableForAdd.workspaceId !== workspaceId) { return { success: false, message: `Table not found: ${args.tableId}` } } + await ensureWorkflowGroupAccess(tableForAdd, groupId, context) const requestId = generateId().slice(0, 8) assertNotAborted() const updated = await addWorkflowGroupOutput( @@ -2122,6 +2145,13 @@ export const userTableServerTool: BaseServerTool message: `Invalid runMode "${runMode}". Must be "all" or "incomplete"`, } } + const tableForRun = await getTableById(args.tableId) + if (!tableForRun || tableForRun.workspaceId !== workspaceId) { + return { success: false, message: `Table not found: ${args.tableId}` } + } + await Promise.all( + groupIds.map((groupId) => ensureWorkflowGroupAccess(tableForRun, groupId, context)) + ) const rawRowIds = args.rowIds as unknown let rowIds: string[] | undefined if (rawRowIds !== undefined) { @@ -2146,7 +2176,7 @@ export const userTableServerTool: BaseServerTool mode: runMode, rowIds, requestId, - triggeredByUserId: context.userId, + triggeredByUserId: billingActorUserId, }) const scopeLabel = rowIds ? `${rowIds.length} row(s) by id` : runMode return { @@ -2303,7 +2333,14 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() const updated = await addWorkflowGroup( - { tableId: args.tableId, group, outputColumns, autoRun, actorUserId: context.userId }, + { + tableId: args.tableId, + group, + outputColumns, + autoRun, + actorUserId: context.userId, + billingActorUserId, + }, requestId ) signalTableSchemaChanged(args.tableId) diff --git a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts index e6b159a3da4..50a7c86e59a 100644 --- a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts +++ b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts @@ -50,7 +50,11 @@ describe('setEnvironmentVariablesServerTool', () => { } ) - expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith('ws-1', 'user-1', 'write') + expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith( + 'ws-1', + { userId: 'user-1', workspaceId: 'ws-1' }, + 'write' + ) expect(upsertWorkspaceEnvVarsMock).toHaveBeenCalledWith('ws-1', { API_KEY: 'secret' }, 'user-1') expect(upsertPersonalEnvVarsMock).not.toHaveBeenCalled() expect(result.scope).toBe('workspace') diff --git a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts index daa8c19fe80..357a814c040 100644 --- a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts +++ b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts @@ -57,24 +57,23 @@ function normalizeVariables( async function resolveWorkspaceId( params: SetEnvironmentVariablesParams, - context: ServerToolContext | undefined, - userId: string + context: ServerToolContext ): Promise { if (params.workflowId) { - const { workflow } = await ensureWorkflowAccess(params.workflowId, userId, 'write') + const { workflow } = await ensureWorkflowAccess(params.workflowId, context, 'write') if (!workflow.workspaceId) { throw new Error(`Workflow ${params.workflowId} is not associated with a workspace`) } return workflow.workspaceId } - const workspaceId = params.workspaceId ?? context?.workspaceId + const workspaceId = context.workspaceId ?? params.workspaceId if (workspaceId) { - await ensureWorkspaceAccess(workspaceId, userId, 'write') + await ensureWorkspaceAccess(workspaceId, context, 'write') return workspaceId } - return getDefaultWorkspaceId(userId) + return getDefaultWorkspaceId(context.userId) } export const setEnvironmentVariablesServerTool: BaseServerTool< @@ -108,7 +107,7 @@ export const setEnvironmentVariablesServerTool: BaseServerTool< let resolvedWorkspaceId: string | undefined if (scope === 'workspace') { - resolvedWorkspaceId = await resolveWorkspaceId(params, context, authenticatedUserId) + resolvedWorkspaceId = await resolveWorkspaceId(params, context) workspaceUpdated = await upsertWorkspaceEnvVars( resolvedWorkspaceId, validatedVariables, diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.test.ts new file mode 100644 index 00000000000..f3b1bc2d217 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.test.ts @@ -0,0 +1,186 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + ensureWorkflowAccessMock, + applyOperationsToWorkflowStateMock, + saveWorkflowToNormalizedTablesMock, + assertWorkflowMutableMock, + validateWorkflowStateMock, + dbUpdateMock, + dbSetMock, + dbWhereMock, +} = vi.hoisted(() => { + const dbWhereMock = vi.fn() + const dbSetMock = vi.fn(() => ({ where: dbWhereMock })) + const dbUpdateMock = vi.fn(() => ({ set: dbSetMock })) + return { + ensureWorkflowAccessMock: vi.fn(), + applyOperationsToWorkflowStateMock: vi.fn(), + saveWorkflowToNormalizedTablesMock: vi.fn(), + assertWorkflowMutableMock: vi.fn(), + validateWorkflowStateMock: vi.fn(), + dbUpdateMock, + dbSetMock, + dbWhereMock, + } +}) + +vi.mock('@sim/db', () => ({ db: { update: dbUpdateMock } })) +vi.mock('@sim/db/schema', () => ({ + workflow: { id: 'id', lastSynced: 'lastSynced', updatedAt: 'updatedAt' }, +})) +vi.mock('drizzle-orm', () => ({ eq: vi.fn((left, right) => [left, right]) })) +vi.mock('@sim/platform-authz/workflow', () => ({ + assertWorkflowMutable: assertWorkflowMutableMock, +})) +vi.mock('@/lib/billing/core/subscription', () => ({ + hasWorkspaceSandboxAccess: vi.fn(async () => true), +})) +vi.mock('@/lib/copilot/block-visibility', () => ({ + getBlockVisibilityForCopilot: vi.fn(async () => null), +})) +vi.mock('@/lib/copilot/sim-sandbox-projection', () => ({ + operationsReferenceSimSandbox: vi.fn(() => false), +})) +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + ensureWorkflowAccess: ensureWorkflowAccessMock, +})) +vi.mock('@/lib/core/config/env', () => ({ env: { INTERNAL_API_SECRET: 'internal-secret' } })) +vi.mock('@/lib/core/utils/urls', () => ({ getSocketServerUrl: () => 'http://socket.test' })) +vi.mock('@/lib/execution/remote-sandbox/workspace-sandboxes', () => ({ + MAX_PLAN_REQUIRED: 'Upgrade required', +})) +vi.mock('@/lib/workflows/autolayout', () => ({ + applyTargetedLayout: vi.fn((blocks) => blocks), + getTargetedLayoutImpact: vi.fn(() => ({ + layoutBlockIds: [], + resizedBlockIds: [], + shiftSourceBlockIds: [], + })), + transferBlockHeights: vi.fn(), +})) +vi.mock('@/lib/workflows/persistence/custom-tools-persistence', () => ({ + extractAndPersistCustomTools: vi.fn(async () => ({ saved: 0, errors: [] })), +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadWorkflowFromNormalizedTables: vi.fn(), + saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, +})) +vi.mock('@/lib/workflows/sanitization/validation', () => ({ + validateWorkflowState: validateWorkflowStateMock, +})) +vi.mock('@/blocks/visibility/server-context', () => ({ + withBlockVisibility: vi.fn(async (_visibility, execute) => execute()), +})) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: vi.fn(async () => null), +})) +vi.mock('@/stores/workflows/workflow/utils', () => ({ + generateLoopBlocks: vi.fn(() => ({})), + generateParallelBlocks: vi.fn(() => ({})), +})) +vi.mock('@/stores/workflows/workflow/validation', () => ({ normalizeWorkflowState: vi.fn() })) +vi.mock('./engine', () => ({ + applyOperationsToWorkflowState: applyOperationsToWorkflowStateMock, +})) +vi.mock('./lint', () => ({ + collectWorkflowFieldIssues: vi.fn(() => []), + formatWorkflowLintMessage: vi.fn(() => ''), + hasWorkflowLintIssues: vi.fn(() => false), + lintEditedWorkflowState: vi.fn(() => ({ + sources: [], + sinks: [], + orphanBlocks: [], + emptyOutgoingPorts: [], + invalidBranchPorts: [], + invalidConnectionTargets: [], + })), +})) +vi.mock('./validation', () => ({ + collectUnresolvedAgentToolReferences: vi.fn(async () => []), + collectUnresolvedReferences: vi.fn(async () => []), + preValidateCredentialInputs: vi.fn(async (operations) => ({ + filteredOperations: operations, + errors: [], + })), + UNRESOLVABLE_AT_LINT_NOTE: 'unresolvable', +})) + +vi.unmock('@/blocks/registry') + +import { editWorkflowServerTool } from './index' + +const workflowState = { + blocks: { + request: { + id: 'request', + type: 'unknown-integration', + name: 'Request', + enabled: true, + subBlocks: { + apiKey: { id: 'apiKey', type: 'short-input', value: 'SENTINEL_API_KEY' }, + path: { id: 'path', type: 'short-input', value: '/users' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, +} + +describe('editWorkflowServerTool secretless projection', () => { + beforeEach(() => { + vi.clearAllMocks() + global.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) as typeof fetch + ensureWorkflowAccessMock.mockResolvedValue({ + workflow: { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + }) + assertWorkflowMutableMock.mockResolvedValue(undefined) + applyOperationsToWorkflowStateMock.mockImplementation((state) => ({ + state, + validationErrors: [], + skippedItems: [], + })) + validateWorkflowStateMock.mockImplementation((state) => ({ + valid: true, + errors: [], + warnings: [], + sanitizedState: state, + })) + saveWorkflowToNormalizedTablesMock.mockResolvedValue({ success: true }) + dbWhereMock.mockResolvedValue(undefined) + }) + + async function execute(secretActorUserId?: string | null) { + return editWorkflowServerTool.execute( + { + workflowId: 'workflow-1', + currentUserWorkflow: JSON.stringify(workflowState), + operations: [{ operation_type: 'edit', block_id: 'request', params: {} }], + }, + { + userId: 'key-creator', + workspaceId: 'workspace-1', + secretActorUserId, + } + ) as Promise> + } + + it('redacts credentials from the returned state in secretless mode', async () => { + const result = await execute(null) + + expect(result.workflowState.blocks.request.subBlocks.apiKey.value).toBeNull() + expect(result.workflowState.blocks.request.subBlocks.path.value).toBe('/users') + expect(JSON.stringify(result)).not.toContain('SENTINEL_API_KEY') + }) + + it('preserves the existing returned state for a user-backed chat', async () => { + const result = await execute('user-1') + + expect(result.workflowState.blocks.request.subBlocks.apiKey.value).toBe('SENTINEL_API_KEY') + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts index 24c1510833b..f1b08cc1795 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts @@ -1,21 +1,20 @@ import { db } from '@sim/db' import { workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { - assertWorkflowMutable, - authorizeWorkflowByWorkspacePermission, -} from '@sim/platform-authz/workflow' +import { assertWorkflowMutable } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { EditWorkflow } from '@/lib/copilot/generated/tool-catalog-v1' import { operationsReferenceSimSandbox } from '@/lib/copilot/sim-sandbox-projection' +import { ensureWorkflowAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { projectWorkflowStateForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' import { env } from '@/lib/core/config/env' import { getSocketServerUrl } from '@/lib/core/utils/urls' import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/workspace-sandboxes' @@ -106,19 +105,12 @@ export const editWorkflowServerTool: BaseServerTool throw new Error('Unauthorized workflow access') } - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId: context.userId, - action: 'write', - }) - if (!authorization.allowed) { - throw new Error(authorization.message || 'Unauthorized workflow access') - } + const { workflow } = await ensureWorkflowAccess(workflowId, context, 'write') await assertWorkflowMutable(workflowId) - const workspaceId = authorization.workflow?.workspaceId ?? undefined - const workflowName = authorization.workflow?.name ?? undefined + const workspaceId = workflow.workspaceId ?? undefined + const workflowName = workflow.name ?? undefined if ( operationsReferenceSimSandbox(operations) && @@ -403,11 +395,16 @@ export const editWorkflowServerTool: BaseServerTool const sanitizationWarnings = validation.warnings.length > 0 ? validation.warnings : undefined + const outputWorkflowState = projectWorkflowStateForCopilot( + { ...finalWorkflowState, blocks: layoutedBlocks }, + { secretless: context.secretActorUserId === null } + ) + return { success: true, workflowId, workflowName: workflowName ?? 'Workflow', - workflowState: { ...finalWorkflowState, blocks: layoutedBlocks }, + workflowState: outputWorkflowState, workflowLint, ...(workflowLintMessage && { workflowLintMessage }), ...(inputErrors && { diff --git a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts index 9ab073f7dd8..804d86eccf4 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts @@ -69,7 +69,7 @@ const queryLogsArgsSchema = z.discriminatedUnion('view', [ type QueryLogsArgs = z.infer function resolveWorkspaceId(args: QueryLogsArgs, context?: ServerToolContext): string { - const workspaceId = args.workspaceId ?? context?.workspaceId + const workspaceId = context?.workspaceId ?? args.workspaceId if (!workspaceId) { throw new Error('workspaceId is required') } diff --git a/apps/sim/lib/copilot/tools/shared/workflow-utils.test.ts b/apps/sim/lib/copilot/tools/shared/workflow-utils.test.ts new file mode 100644 index 00000000000..ed1a15f8c7e --- /dev/null +++ b/apps/sim/lib/copilot/tools/shared/workflow-utils.test.ts @@ -0,0 +1,41 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it, vi } from 'vitest' +import { formatNormalizedWorkflowForCopilot } from './workflow-utils' + +vi.unmock('@/blocks/registry') + +describe('formatNormalizedWorkflowForCopilot', () => { + it('redacts credentials from secretless deployed-state projections', () => { + const formatted = formatNormalizedWorkflowForCopilot( + { + blocks: { + slack: { + id: 'slack', + type: 'slack', + name: 'Slack', + enabled: true, + subBlocks: { + credential: { id: 'credential', type: 'oauth-input', value: 'cred-private' }, + manualCredential: { + id: 'manualCredential', + type: 'short-input', + value: 'cred-private-advanced', + }, + message: { id: 'message', type: 'long-input', value: 'hello' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }, + { secretless: true } + ) + + expect(formatted).not.toContain('cred-private') + expect(formatted).toContain('hello') + }) +}) diff --git a/apps/sim/lib/copilot/tools/shared/workflow-utils.ts b/apps/sim/lib/copilot/tools/shared/workflow-utils.ts index 07c1d8f54c8..e119a074b4b 100644 --- a/apps/sim/lib/copilot/tools/shared/workflow-utils.ts +++ b/apps/sim/lib/copilot/tools/shared/workflow-utils.ts @@ -1,3 +1,4 @@ +import { sanitizeWorkflowForSharing } from '@/lib/workflows/credentials/credential-extractor' import { type CopilotSanitizationOptions, sanitizeForCopilot, @@ -10,9 +11,23 @@ type CopilotWorkflowState = { parallels?: Record } +type CopilotWorkflowProjectionOptions = CopilotSanitizationOptions & { secretless?: boolean } + +export function projectWorkflowStateForCopilot( + state: T, + options?: CopilotWorkflowProjectionOptions +): T { + return options?.secretless + ? (sanitizeWorkflowForSharing(state, { + preserveEnvVars: false, + preserveWorkspaceReferences: true, + }) as T) + : state +} + export function formatWorkflowStateForCopilot( state: CopilotWorkflowState, - options?: CopilotSanitizationOptions + options?: CopilotWorkflowProjectionOptions ): string { const workflowState = { blocks: state.blocks || {}, @@ -20,13 +35,14 @@ export function formatWorkflowStateForCopilot( loops: state.loops || {}, parallels: state.parallels || {}, } - const sanitized = sanitizeForCopilot(workflowState, options) + const credentialSafeState = projectWorkflowStateForCopilot(workflowState, options) + const sanitized = sanitizeForCopilot(credentialSafeState as typeof workflowState, options) return JSON.stringify(sanitized, null, 2) } export function formatNormalizedWorkflowForCopilot( normalized: CopilotWorkflowState | null | undefined, - options?: CopilotSanitizationOptions + options?: CopilotWorkflowProjectionOptions ): string | null { if (!normalized) return null return formatWorkflowStateForCopilot(normalized, options) diff --git a/apps/sim/lib/copilot/tools/subagent-display.ts b/apps/sim/lib/copilot/tools/subagent-display.ts new file mode 100644 index 00000000000..d074f6878fb --- /dev/null +++ b/apps/sim/lib/copilot/tools/subagent-display.ts @@ -0,0 +1,29 @@ +import { humanizeToolName } from '@/lib/copilot/tools/tool-display' + +/** Canonical user-facing labels for Mothership subagent lanes. */ +export const SUBAGENT_LABELS: Readonly> = { + workflow: 'Workflow Agent', + debug: 'Debug Agent', + deploy: 'Deploy Agent', + auth: 'Auth Agent', + research: 'Research Agent', + knowledge: 'Knowledge Agent', + table: 'Table Agent', + custom_tool: 'Custom Tool Agent', + scout: 'Scout Agent', + search: 'Search Agent', + superagent: 'Superagent', + run: 'Run Agent', + agent: 'Tools Agent', + scheduled_task: 'Scheduled Task Agent', + /** Backward-compatible label for historical transcripts. */ + job: 'Job Agent', + file: 'File Agent', + media: 'Media Agent', + browser: 'Browser Agent', +} as const + +/** Resolves a server-owned subagent id without exposing raw identifier casing. */ +export function getSubagentDisplayTitle(agentId: string): string { + return SUBAGENT_LABELS[agentId] ?? humanizeToolName(agentId || 'subagent') +} diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 9fd352aff15..3f33f9df299 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -71,6 +71,21 @@ describe('humanizeToolName', () => { }) describe('getToolDisplayTitle natural-language coverage', () => { + it('uses the same glanceable target names as the web read row', () => { + expect(getToolDisplayTitle('read', { path: 'workflows/Folder/forceful-arm/state.json' })).toBe( + 'Reading forceful-arm' + ) + expect(getToolDisplayTitle('read', { path: 'files/Reports/Q4%20Report.pdf/content' })).toBe( + 'Reading Q4 Report.pdf' + ) + expect(getToolDisplayTitle('read', { path: 'components/blocks/gmail_v2.json' }, 'Gmail')).toBe( + 'Reading Gmail' + ) + expect( + getToolDisplayTitle('read', { path: 'components/integrations/gmail/send.json' }, 'Gmail') + ).toBe('Reading Gmail') + }) + it('gives gerund titles to tools that previously fell through to humanize', () => { expect(getToolDisplayTitle('deploy_api')).toBe('Deploying API') expect(getToolDisplayTitle('list_workspace_mcp_servers')).toBe('Listing MCP servers') diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index e81ab9a54e2..92c1eb5e262 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -1,4 +1,6 @@ import { stripVersionSuffix } from '@sim/utils/string' +import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' +import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils' /** * Single source of truth for copilot tool-call display titles. @@ -433,6 +435,62 @@ function workspaceFileTitle(args: ToolArgs): string { return `${verb} ${title}` } +const READ_FILE_FACET_LABELS: Record = { + content: '', + 'meta.json': 'metadata for', + style: 'style details for', + 'compiled-check': 'the final file check for', +} + +function stripReadTargetExtension(value: string): string { + return value.replace(/\.[^/.]+$/, '') +} + +function readResourceLeaf(segments: string[]): string { + const lastSegment = segments.at(-1) ?? '' + if (/\.[^/.]+$/.test(lastSegment) && segments.length > 1) { + return segments.at(-2) ?? lastSegment + } + return lastSegment +} + +function describeFileReadTarget(segments: string[]): string { + const lastSegment = segments.at(-1) ?? '' + const facetLabel = READ_FILE_FACET_LABELS[lastSegment] + if (facetLabel !== undefined && segments.length > 2) { + const fileName = segments.at(-2) ?? '' + return facetLabel ? `${facetLabel} ${fileName}` : fileName + } + return lastSegment +} + +/** Resolves the glanceable VFS target shared by web and public chat tool rows. */ +export function describeReadTarget( + path: string | undefined, + resolvedBlockName?: string +): string | undefined { + if (!path) return undefined + if (resolvedBlockName) return resolvedBlockName + + const segments = path + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean) + .map(decodeVfsSegmentSafe) + if (segments.length === 0) return undefined + + const resourceType = VFS_DIR_TO_RESOURCE[segments[0]] + if (!resourceType) { + return humanizeDisplayIdentifier(stripReadTargetExtension(segments.at(-1) ?? ''), 'sentence') + } + if (resourceType === 'file') return describeFileReadTarget(segments) + if (resourceType === 'workflow') { + return stripReadTargetExtension(readResourceLeaf(segments)) + } + + return stripReadTargetExtension(segments[1] ?? segments.at(-1) ?? '') +} + /** Static fallback titles for tools without an argument-aware title. */ const TOOL_TITLES: Record = { // Gateway rows brand from the streamed toolId as soon as it resolves; this @@ -664,7 +722,11 @@ function terminalTitle(args: ToolArgs): string { * cases come first, then the static map, then a humanized fallback. This never * returns an empty string. */ -export function getToolDisplayTitle(name: string, args?: Record): string { +export function getToolDisplayTitle( + name: string, + args?: Record, + resolvedReadTargetName?: string +): string { const mcpToolMatch = name.match(/^mcp-[^-]+-(.+)$/) if (mcpToolMatch?.[1]) { return humanizeToolName(mcpToolMatch[1]) @@ -945,6 +1007,8 @@ export function getToolDisplayTitle(name: string, args?: Record if (isWorkflowArtifactPath(stringArg(args, 'path'), 'lint.json')) { return 'Validating workflow state' } + const target = describeReadTarget(stringArg(args, 'path'), resolvedReadTargetName) + if (target) return `Reading ${target}` break } case 'workspace_file': diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index 5e86b445895..3544b5ef5fd 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -18,6 +18,7 @@ import { serializeFileMeta, serializeIntegrationSchema, serializeKBMeta, + serializeMcpServer, serializeSandbox, serializeSandboxCatalog, serializeTableMeta, @@ -66,6 +67,20 @@ describe('VFS metadata serializers', () => { expect(deployment).toEqual({ api: { isDeployed: false } }) }) + it('omits an MCP URL when the caller projects a secretless server', () => { + const server = JSON.parse( + serializeMcpServer({ + id: 'mcp-1', + name: 'Private MCP', + transport: 'sse', + enabled: true, + connectionStatus: 'connected', + }) + ) + + expect(server).not.toHaveProperty('url') + }) + it('includes the authoritative file update timestamp', () => { const metadata = JSON.parse( serializeFileMeta({ diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 0fc76e89926..f4924f01f90 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -972,7 +972,7 @@ export function serializeCustomTool(tool: { export function serializeMcpServer(server: { id: string name: string - url: string | null + url?: string | null transport: string | null enabled: boolean connectionStatus: string | null diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 4a7b340a07c..7a9a67e876d 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -2,7 +2,6 @@ import { trace } from '@opentelemetry/api' import { db } from '@sim/db' import { chat as chatTable, - customTools as customToolsTable, document, folder as folderTable, knowledgeBaseTagDefinitions, @@ -16,7 +15,7 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { and, desc, eq, inArray, isNotNull, isNull, or } from 'drizzle-orm' +import { and, desc, eq, inArray, isNotNull, isNull } from 'drizzle-orm' import { listApiKeys } from '@/lib/api-key/service' import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' import { @@ -47,6 +46,7 @@ import { lintEditedWorkflowState, } from '@/lib/copilot/tools/server/workflow/edit-workflow/lint' import { UNRESOLVABLE_AT_LINT_NOTE } from '@/lib/copilot/tools/server/workflow/edit-workflow/validation' +import { formatWorkflowStateForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' @@ -128,9 +128,12 @@ import { } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import type { WorkspaceFileSecretProvenanceEnvelope } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' -import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' +import { + getCustomToolById, + getWorkspaceCustomTool, + listCustomToolSummaries, +} from '@/lib/workflows/custom-tools/operations' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' -import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { getSkillById } from '@/lib/workflows/skills/operations' import { listFolders, listWorkflows } from '@/lib/workflows/utils' import { @@ -582,6 +585,7 @@ export class WorkspaceVFS { >() private deploymentCache = new Map>() private _workspaceId = '' + private _secretless = false /** * Types of the org's CURRENT custom blocks (enabled + disabled — a disabled block * still resolves/renders). Populated by {@link materializeCustomBlocks}; used to @@ -739,7 +743,7 @@ export class WorkspaceVFS { async materialize( workspaceId: string, userId: string, - options?: { secretMountPolicy?: SecretMountPolicy } + options?: { secretMountPolicy?: SecretMountPolicy; secretless?: boolean } ): Promise { const start = Date.now() this.files = new Map() @@ -748,6 +752,7 @@ export class WorkspaceVFS { this.deploymentCache = new Map() this._customBlockTypes = null this._workspaceId = workspaceId + this._secretless = options?.secretless === true // Per-phase wall-clock, stamped on the span so a slow materialize in a // trace names its bottleneck instead of showing up as unattributed dead @@ -1577,15 +1582,15 @@ export class WorkspaceVFS { // loadWorkflowFromNormalizedTables returns null for a zero-block // workflow; it still exists and must be readable, so emit an // empty-but-valid state.json rather than a 404. - const sanitized = normalized - ? sanitizeForCopilot({ + const state = normalized + ? { blocks: normalized.blocks, edges: normalized.edges, loops: normalized.loops, parallels: normalized.parallels, - } as any) - : sanitizeForCopilot({ blocks: {}, edges: [], loops: {}, parallels: {} } as any) - return JSON.stringify(sanitized, null, 2) + } + : { blocks: {}, edges: [], loops: {}, parallels: {} } + return formatWorkflowStateForCopilot(state, { secretless: this._secretless }) }) this.registerLazy(`${prefix}lint.json`, async () => { @@ -2024,26 +2029,21 @@ export class WorkspaceVFS { ): Promise> { try { // Metadata only — tool code can be large; keep it out of the eager map. - // Visibility matches listCustomTools: workspace tools + legacy user-owned. - const toolRows = await db - .select({ - id: customToolsTable.id, - title: customToolsTable.title, - }) - .from(customToolsTable) - .where( - or( - eq(customToolsTable.workspaceId, workspaceId), - and(isNull(customToolsTable.workspaceId), eq(customToolsTable.userId, userId)) - ) - ) - .orderBy(desc(customToolsTable.createdAt)) + // Normal chats retain legacy user-owned tools. Secretless projections are + // workspace-only so a shared key cannot inherit its creator's private code. + const toolRows = await listCustomToolSummaries({ + userId, + workspaceId, + workspaceOnly: this._secretless, + }) for (const tool of toolRows) { const safeName = sanitizeName(tool.title) const toolId = tool.id const load = async () => { - const full = await getCustomToolById({ toolId, userId, workspaceId }) + const full = this._secretless + ? await getWorkspaceCustomTool({ toolId, workspaceId }) + : await getCustomToolById({ toolId, userId, workspaceId }) if (!full) return null return serializeCustomTool({ id: full.id, @@ -2134,7 +2134,7 @@ export class WorkspaceVFS { serializeMcpServer({ id: server.id, name: server.name, - url: server.url, + ...(this._secretless ? {} : { url: server.url }), transport: server.transport, enabled: server.enabled, connectionStatus: server.connectionStatus, @@ -2142,7 +2142,12 @@ export class WorkspaceVFS { ) } - return servers.map((s) => ({ id: s.id, name: s.name, url: s.url, enabled: s.enabled })) + return servers.map((server) => ({ + id: server.id, + name: server.name, + enabled: server.enabled, + ...(this._secretless ? {} : { url: server.url }), + })) } catch (err) { logger.warn('Failed to materialize MCP servers', { workspaceId, @@ -2389,6 +2394,13 @@ export class WorkspaceVFS { oauthIntegrations: WorkspaceMdData['oauthIntegrations'] envVariables: WorkspaceMdData['envVariables'] }> { + if (this._secretless) { + this.files.set('environment/credentials.json', serializeCredentials([])) + this.files.set('environment/api-keys.json', serializeApiKeys([])) + this.files.set('environment/variables.json', serializeEnvironmentVariables([], [])) + return { oauthIntegrations: [], envVariables: [] } + } + try { const isWorkspaceAdmin = await hasWorkspaceAdminAccess(userId, workspaceId) const [envCredentials, oauthCredentials, apiKeyRows, envData, permissionConfig] = @@ -2488,7 +2500,7 @@ export class WorkspaceVFS { export async function getOrMaterializeVFS( workspaceId: string, userId: string, - options?: { secretMountPolicy?: SecretMountPolicy } + options?: { secretMountPolicy?: SecretMountPolicy; secretless?: boolean } ): Promise { await assertActiveWorkspaceAccess(workspaceId, userId) const vfs = new WorkspaceVFS() diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index cc67764b07e..c1a554a036d 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -911,8 +911,14 @@ export interface AddWorkflowGroupData { * `true` (UI behavior). Mothership passes `false` so groups can be staged * without firing every dep-satisfied row. */ autoRun?: boolean - /** The member adding the group — billed/gated for the auto-run enrichment pass. */ + /** The member adding the group, retained for authorization/audit work. */ actorUserId?: string | null + /** + * Frozen billing actor for the post-add auto-run. This differs from + * `actorUserId` when a workspace API key authorizes tools as its owner but + * charges executions to the workspace system account. + */ + billingActorUserId?: string | null } /** Payload for `updateWorkflowGroup` — diffs outputs and writes columns. */ @@ -941,8 +947,10 @@ export interface UpdateWorkflowGroupData { type?: WorkflowGroupType /** Toggle the group's auto-run flag. Omit to leave it unchanged. */ autoRun?: boolean - /** The member updating the group — billed/gated for any triggered re-run. */ + /** The member updating the group, retained for authorization/audit/backfill work. */ actorUserId?: string | null + /** Frozen billing actor for a false -> true auto-run transition. */ + billingActorUserId?: string | null } export interface DeleteWorkflowGroupData { 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..c34bfd300f5 --- /dev/null +++ b/apps/sim/lib/table/workflow-groups/service.test.ts @@ -0,0 +1,192 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const { mockRunWorkflowColumn, mockWithLockedTable } = vi.hoisted(() => ({ + mockRunWorkflowColumn: vi.fn(), + mockWithLockedTable: vi.fn(), +})) + +vi.mock('@/lib/table/service', () => ({ + getTableById: vi.fn(), + withLockedTable: mockWithLockedTable, +})) + +vi.mock('@/lib/table/workflow-columns', () => ({ + assertValidSchema: vi.fn(), + runWorkflowColumn: mockRunWorkflowColumn, + stripGroupDeps: vi.fn((group: unknown) => group), +})) + +vi.mock('@/lib/table/backfill-runner', () => ({ + maybeBackfillGroupOutputs: vi.fn(), +})) + +import { addWorkflowGroup, updateWorkflowGroup } from '@/lib/table/workflow-groups/service' + +function table(autoRun?: boolean): TableDefinition { + return { + id: 'table-1', + name: 'People', + description: null, + schema: { + columns: [], + ...(autoRun === undefined + ? {} + : { + workflowGroups: [ + { + id: 'group-1', + workflowId: 'workflow-1', + outputs: [], + autoRun, + }, + ], + }), + }, + metadata: null, + rowCount: 0, + maxRows: 100, + workspaceId: 'workspace-1', + createdBy: 'workspace-key-owner', + archivedAt: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + } +} + +describe('workflow-group auto-run billing actor', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRunWorkflowColumn.mockResolvedValue({ dispatchId: 'dispatch-1' }) + mockWithLockedTable.mockImplementation( + async ( + _tableId: string, + callback: ( + value: TableDefinition, + transaction: { + update: () => { + set: () => { where: () => Promise } + } + execute: () => Promise + } + ) => Promise + ) => + callback(table(), { + update: () => ({ + set: () => ({ where: async () => undefined }), + }), + execute: async () => undefined, + }) + ) + }) + + it('uses the frozen billing actor without replacing mutation ownership', async () => { + await addWorkflowGroup( + { + tableId: 'table-1', + group: { + id: 'group-1', + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-1', path: 'content', columnName: 'result' }], + }, + outputColumns: [ + { + name: 'result', + type: 'string', + required: false, + unique: false, + workflowGroupId: 'group-1', + }, + ], + autoRun: true, + actorUserId: 'workspace-key-owner', + billingActorUserId: 'workspace-system-actor', + }, + 'request-1' + ) + + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: 'table-1', + workspaceId: 'workspace-1', + triggeredByUserId: 'workspace-system-actor', + }) + ) + }) + + it('preserves the existing member-attribution fallback for ordinary callers', async () => { + await addWorkflowGroup( + { + tableId: 'table-1', + group: { + id: 'group-1', + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-1', path: 'content', columnName: 'result' }], + }, + outputColumns: [ + { + name: 'result', + type: 'string', + required: false, + unique: false, + workflowGroupId: 'group-1', + }, + ], + autoRun: true, + actorUserId: 'interactive-member', + }, + 'request-2' + ) + + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ triggeredByUserId: 'interactive-member' }) + ) + }) + + it('uses the frozen billing actor when enabling auto-run on an existing group', async () => { + mockWithLockedTable.mockImplementationOnce( + async ( + _tableId: string, + callback: ( + value: TableDefinition, + transaction: { + update: () => { + set: () => { where: () => Promise } + } + execute: () => Promise + } + ) => Promise + ) => + callback(table(false), { + update: () => ({ + set: () => ({ where: async () => undefined }), + }), + execute: async () => undefined, + }) + ) + + await updateWorkflowGroup( + { + tableId: 'table-1', + groupId: 'group-1', + autoRun: true, + actorUserId: 'workspace-key-owner', + billingActorUserId: 'workspace-system-actor', + }, + 'request-3' + ) + + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: 'table-1', + workspaceId: 'workspace-1', + groupIds: ['group-1'], + triggeredByUserId: 'workspace-system-actor', + }) + ) + }) +}) diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index f15421c4c66..35617f8590a 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -38,6 +38,15 @@ import type { import { assertValidSchema, runWorkflowColumn, stripGroupDeps } from '@/lib/table/workflow-columns' const logger = createLogger('TableWorkflowGroupsService') + +/** Keeps mutation ownership separate from the account charged for an auto-run. */ +function resolveTriggerBillingActor(data: { + actorUserId?: string | null + billingActorUserId?: string | null +}): string | null | undefined { + return data.billingActorUserId === undefined ? data.actorUserId : data.billingActorUserId +} + /** * Drops references to deleted blocks from every workflow group on every table * that targets the just-deployed workflow. Called from the workflow deploy @@ -213,7 +222,7 @@ export async function addWorkflowGroup( isManualRun: false, groupIds: [data.group.id], requestId, - triggeredByUserId: data.actorUserId, + triggeredByUserId: resolveTriggerBillingActor(data), }).catch((err) => logger.error(`[${requestId}] auto-dispatch (addWorkflowGroup) failed:`, err)) } @@ -591,7 +600,7 @@ export async function updateWorkflowGroup( isManualRun: false, groupIds: [data.groupId], requestId, - triggeredByUserId: data.actorUserId, + triggeredByUserId: resolveTriggerBillingActor(data), }).catch((err) => logger.error(`[${requestId}] auto-dispatch (updateWorkflowGroup autoRun=true) failed:`, err) ) diff --git a/apps/sim/lib/workflows/credentials/constants.ts b/apps/sim/lib/workflows/credentials/constants.ts new file mode 100644 index 00000000000..df9cac1f370 --- /dev/null +++ b/apps/sim/lib/workflows/credentials/constants.ts @@ -0,0 +1,8 @@ +/** Legacy and current subblock IDs that persist credential references. */ +export const CREDENTIAL_SUBBLOCK_IDS = new Set([ + 'credential', + 'manualCredential', + 'triggerCredentials', + 'customBotCredential', + 'manualBotCredential', +]) diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.secretless.test.ts b/apps/sim/lib/workflows/credentials/credential-extractor.secretless.test.ts new file mode 100644 index 00000000000..60b027033f8 --- /dev/null +++ b/apps/sim/lib/workflows/credentials/credential-extractor.secretless.test.ts @@ -0,0 +1,293 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it, vi } from 'vitest' +import { sanitizeWorkflowForSharing } from '@/lib/workflows/credentials/credential-extractor' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +vi.unmock('@/blocks/registry') + +function workflowState(): WorkflowState { + return { + blocks: { + slack: { + id: 'slack', + type: 'slack', + name: 'Slack', + enabled: true, + subBlocks: { + credential: { id: 'credential', type: 'oauth-input', value: 'cred-basic' }, + manualCredential: { + id: 'manualCredential', + type: 'short-input', + value: 'cred-advanced', + }, + botToken: { id: 'botToken', type: 'short-input', value: 'xoxb-private' }, + channel: { id: 'channel', type: 'channel-selector', value: 'C_PRIVATE' }, + manualChannel: { + id: 'manualChannel', + type: 'short-input', + value: 'C_PRIVATE_ADVANCED', + }, + message: { id: 'message', type: 'long-input', value: 'hello' }, + }, + }, + agent: { + id: 'agent', + type: 'agent', + name: 'Agent', + enabled: true, + subBlocks: { + tools: { + id: 'tools', + type: 'tool-input', + value: [ + { + type: 'slack', + params: { + credential: 'cred-tool', + oauthCredential: 'cred-tool-canonical', + channel: 'C_TOOL_PRIVATE', + message: 'keep me', + knowledgeBaseId: 'kb-workspace', + paginationToken: 'not-a-credential', + }, + }, + ], + }, + }, + }, + knowledge: { + id: 'knowledge', + type: 'knowledge', + name: 'Knowledge', + enabled: true, + subBlocks: { + knowledgeBaseId: { + id: 'knowledgeBaseId', + type: 'knowledge-base-selector', + value: 'kb-workspace', + }, + }, + }, + googleDocs: { + id: 'googleDocs', + type: 'google_docs', + name: 'Google Docs', + enabled: true, + subBlocks: { + credential: { id: 'credential', type: 'oauth-input', value: 'google-credential' }, + documentId: { id: 'documentId', type: 'file-selector', value: 'google-document' }, + manualDocumentId: { + id: 'manualDocumentId', + type: 'short-input', + value: 'google-document-manual', + }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } as unknown as WorkflowState +} + +describe('sanitizeWorkflowForSharing credential projection', () => { + it('clears canonical credential groups and credential-scoped selectors', () => { + const sanitized = sanitizeWorkflowForSharing(workflowState(), { + preserveWorkspaceReferences: true, + }) + const slack = sanitized.blocks?.slack + + expect(slack?.subBlocks.credential?.value).toBeNull() + expect(slack?.subBlocks.manualCredential?.value).toBeNull() + expect(slack?.subBlocks.botToken?.value).toBeNull() + expect(slack?.subBlocks.channel?.value).toBeNull() + expect(slack?.subBlocks.manualChannel?.value).toBeNull() + expect(slack?.subBlocks.message?.value).toBe('hello') + expect(sanitized.blocks?.knowledge.subBlocks.knowledgeBaseId?.value).toBe('kb-workspace') + expect(sanitized.blocks?.googleDocs.subBlocks.documentId?.value).toBeNull() + expect(sanitized.blocks?.googleDocs.subBlocks.manualDocumentId?.value).toBeNull() + }) + + it('removes credentials and account-scoped selectors from stored Agent tools', () => { + const sanitized = sanitizeWorkflowForSharing(workflowState(), { + preserveWorkspaceReferences: true, + }) + const tools = sanitized.blocks?.agent.subBlocks.tools?.value as Array<{ + params: Record + }> + + expect(tools[0].params).toEqual({ + message: 'keep me', + knowledgeBaseId: 'kb-workspace', + paginationToken: 'not-a-credential', + }) + }) + + it('uses registered metadata instead of treating non-secret GitLab switches as passwords', () => { + const state = { + blocks: { + gitlab: { + id: 'gitlab', + type: 'gitlab', + name: 'GitLab', + enabled: true, + subBlocks: { + userAdminPassword: { + id: 'userAdminPassword', + type: 'short-input', + value: 'SENTINEL_GITLAB_PASSWORD', + }, + resetPassword: { id: 'resetPassword', type: 'switch', value: true }, + forceRandomPassword: { id: 'forceRandomPassword', type: 'switch', value: true }, + unknownPassword: { + id: 'unknownPassword', + type: 'short-input', + value: 'SENTINEL_UNKNOWN_PASSWORD', + }, + }, + data: { + userAdminPassword: 'SENTINEL_GITLAB_DATA_PASSWORD', + resetPassword: true, + forceRandomPassword: true, + }, + }, + agent: { + id: 'agent', + type: 'agent', + name: 'Agent', + enabled: true, + subBlocks: { + tools: { + id: 'tools', + type: 'tool-input', + value: [ + { + type: 'gitlab', + params: { + userAdminPassword: 'SENTINEL_STORED_GITLAB_PASSWORD', + resetPassword: true, + forceRandomPassword: true, + }, + }, + ], + }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } as unknown as WorkflowState + + const sanitized = sanitizeWorkflowForSharing(state) + const gitlab = sanitized.blocks?.gitlab + const tools = sanitized.blocks?.agent.subBlocks.tools?.value as Array<{ + params: Record + }> + + expect(gitlab?.subBlocks.userAdminPassword?.value).toBeNull() + expect(gitlab?.subBlocks.unknownPassword?.value).toBeNull() + expect(gitlab?.subBlocks.resetPassword?.value).toBe(true) + expect(gitlab?.subBlocks.forceRandomPassword?.value).toBe(true) + expect(gitlab?.data).toEqual({ + userAdminPassword: null, + resetPassword: true, + forceRandomPassword: true, + }) + expect(tools[0].params).toEqual({ resetPassword: true, forceRandomPassword: true }) + }) + + it('redacts registered raw-secret fields and reactive credential dependents', () => { + const secretFields: Array<[string, string, unknown]> = [ + ['ssh', 'privateKey', 'SENTINEL_SSH_PRIVATE_KEY'], + ['sftp', 'privateKey', 'SENTINEL_SFTP_PRIVATE_KEY'], + ['pi', 'privateKey', 'SENTINEL_PI_PRIVATE_KEY'], + ['zoom', 'password', 'SENTINEL_ZOOM_PASSWORD'], + ['secrets_manager', 'secretValue', 'SENTINEL_SECRET_VALUE'], + ['browser_use', 'variables', [['API_KEY', 'SENTINEL_BROWSER_VARIABLE']]], + ['sts', 'webIdentityToken', 'SENTINEL_WEB_IDENTITY_TOKEN'], + ['sts', 'samlAssertion', 'SENTINEL_SAML_ASSERTION'], + ['sts', 'tokenCode', 'SENTINEL_TOKEN_CODE'], + ['discord', 'webhookToken', 'SENTINEL_WEBHOOK_TOKEN'], + ['codepipeline', 'approvalToken', 'SENTINEL_APPROVAL_TOKEN'], + ] + const blocks: Record = {} + for (const [index, [type, field, value]] of secretFields.entries()) { + const id = `${type}-${index}` + blocks[id] = { + id, + type, + name: type, + enabled: true, + subBlocks: { [field]: { id: field, type: 'short-input', value } }, + } + } + blocks.google = { + id: 'google', + type: 'google_docs', + name: 'Google Docs', + enabled: true, + subBlocks: { + credential: { id: 'credential', type: 'oauth-input', value: 'SENTINEL_CREDENTIAL' }, + impersonateUserEmail: { + id: 'impersonateUserEmail', + type: 'short-input', + value: 'SENTINEL_IMPERSONATED_EMAIL', + }, + }, + } + + const sanitized = sanitizeWorkflowForSharing({ + blocks, + edges: [], + loops: {}, + parallels: {}, + } as unknown as WorkflowState) + + expect(JSON.stringify(sanitized)).not.toContain('SENTINEL_') + expect(sanitized.blocks?.google.subBlocks.impersonateUserEmail?.value).toBeNull() + }) + + it('removes Function secret-mount policy from stored Agent tools', () => { + const state = { + blocks: { + agent: { + id: 'agent', + type: 'agent', + name: 'Agent', + enabled: true, + subBlocks: { + tools: { + id: 'tools', + type: 'tool-input', + value: [ + { + type: 'function', + params: { + code: 'return 1', + language: 'javascript', + secretScope: 'all', + mountedSecrets: ['PRIVATE_API_KEY'], + }, + }, + ], + }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } as unknown as WorkflowState + + const sanitized = sanitizeWorkflowForSharing(state) + const tools = sanitized.blocks?.agent.subBlocks.tools?.value as Array<{ + params: Record + }> + + expect(tools[0].params).toEqual({ code: 'return 1', language: 'javascript' }) + }) +}) diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.ts b/apps/sim/lib/workflows/credentials/credential-extractor.ts index 8e9e99bd9c1..178d477784b 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.ts @@ -1,3 +1,4 @@ +import { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/credentials/constants' import { WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES } from '@/lib/workflows/search-replace/resources/registry' import { buildCanonicalIndex, @@ -77,6 +78,18 @@ const WORKSPACE_SPECIFIC_FIELDS = new Set([ 'folderId', ]) +// Internal secretless Copilot views may retain resources owned by the current +// workspace. Provider-scoped selectors (channels, projects, external files and +// folders) stay redacted because they belong to a credential/account context. +const PRESERVABLE_WORKSPACE_TYPES = new Set([ + 'knowledge-base-selector', + 'knowledge-tag-filters', + 'document-selector', + 'document-tag-entry', + 'file-upload', + 'mcp-server-selector', +]) + /** * Extract required credentials from a workflow state * This analyzes all blocks and their subblocks to identify credential requirements @@ -248,6 +261,155 @@ interface SanitizedWorkflowState { [key: string]: unknown } +function dependencyFields(config: SubBlockConfig): string[] { + const { dependsOn } = config + const staticDependencies = !dependsOn + ? [] + : Array.isArray(dependsOn) + ? dependsOn + : [...(dependsOn.all ?? []), ...(dependsOn.any ?? [])] + return [...staticDependencies, ...(config.reactiveCondition?.watchFields ?? [])] +} + +function isCredentialKey(key: string): boolean { + const normalized = key.replace(/[_-]/g, '').replace(/\d+$/, '').toLowerCase() + return ( + normalized === 'auth' || + normalized === 'authorization' || + normalized.endsWith('credential') || + normalized.endsWith('credentialid') || + normalized.endsWith('apikey') || + normalized.endsWith('accesstoken') || + normalized.endsWith('refreshtoken') || + normalized.endsWith('idtoken') || + normalized.endsWith('authtoken') || + normalized.endsWith('bottoken') || + normalized.endsWith('bearertoken') || + normalized.endsWith('secret') || + normalized.endsWith('password') + ) +} + +/** + * Resolve credential fields and every credential-scoped dependent (for example + * a Slack channel selected under one OAuth account). Canonical groups are + * cleared as a unit so dormant advanced/manual values cannot survive. + */ +function credentialSensitiveSubBlockIds(subBlocks: SubBlockConfig[]): Set { + const sensitive = new Set() + const canonicalIndex = buildCanonicalIndex(subBlocks) + + const addCanonicalGroup = (subBlockId: string) => { + sensitive.add(subBlockId) + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlockId] + if (!canonicalId) return + sensitive.add(canonicalId) + const group = canonicalIndex.groupsById[canonicalId] + if (group?.basicId) sensitive.add(group.basicId) + for (const advancedId of group?.advancedIds ?? []) sensitive.add(advancedId) + } + + for (const config of subBlocks) { + if (config.type === 'oauth-input' || config.password === true) { + addCanonicalGroup(config.id) + } + } + + // Dependents can chain (credential -> project -> folder), so close over the + // dependency graph rather than clearing only the first level. + let changed = true + while (changed) { + changed = false + for (const config of subBlocks) { + if (sensitive.has(config.id)) continue + if (dependencyFields(config).some((field) => sensitive.has(field))) { + addCanonicalGroup(config.id) + changed = true + } + } + } + + return sensitive +} + +/** + * Resolve workspace-owned selectors and their canonical basic/advanced peers. + * Matching by field name alone is unsafe: common IDs such as `documentId` + * also identify provider-owned resources (for example Google Docs). + */ +function preservableWorkspaceSubBlockIds(subBlocks: SubBlockConfig[]): Set { + const preservable = new Set() + const canonicalIndex = buildCanonicalIndex(subBlocks) + + for (const config of subBlocks) { + if (!PRESERVABLE_WORKSPACE_TYPES.has(config.type)) continue + preservable.add(config.id) + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[config.id] + if (!canonicalId) continue + preservable.add(canonicalId) + const group = canonicalIndex.groupsById[canonicalId] + if (group?.basicId) preservable.add(group.basicId) + for (const advancedId of group?.advancedIds ?? []) preservable.add(advancedId) + } + + return preservable +} + +function registeredSubBlockIds(subBlocks: SubBlockConfig[]): Set { + const registered = new Set() + for (const config of subBlocks) { + registered.add(config.id) + if (config.canonicalParamId) registered.add(config.canonicalParamId) + } + return registered +} + +function sanitizeStoredToolCredentials(value: unknown): unknown { + let tools: unknown[] + let wasJson = false + if (Array.isArray(value)) { + tools = value + } else if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) as unknown + if (!Array.isArray(parsed)) return value + tools = parsed + wasJson = true + } catch { + return value + } + } else { + return value + } + + const sanitized = tools.map((tool) => { + if (!tool || typeof tool !== 'object' || Array.isArray(tool)) return tool + const record = tool as Record + if (!record.params || typeof record.params !== 'object' || Array.isArray(record.params)) { + return tool + } + + const toolConfig = typeof record.type === 'string' ? getBlock(record.type) : undefined + const toolSubBlocks = toolConfig?.subBlocks ?? [] + const registeredParams = registeredSubBlockIds(toolSubBlocks) + const sensitive = credentialSensitiveSubBlockIds(toolSubBlocks) + const params = record.params as Record + const nextParams = Object.fromEntries( + Object.entries(params).filter(([key]) => { + if (record.type === 'function' && (key === 'secretScope' || key === 'mountedSecrets')) { + return false + } + if (sensitive.has(key)) return false + if (registeredParams.has(key)) return true + return !CREDENTIAL_SUBBLOCK_IDS.has(key) && !isCredentialKey(key) + }) + ) + return { ...record, params: nextParams } + }) + + return wasJson ? JSON.stringify(sanitized) : sanitized +} + /** * Sanitize workflow state by removing all credentials and workspace-specific data * This is used for both template creation and workflow export to ensure consistency @@ -259,6 +421,7 @@ export function sanitizeWorkflowForSharing( state: Partial | null | undefined, options: { preserveEnvVars?: boolean // Keep {{VAR}} references for export + preserveWorkspaceReferences?: boolean // Keep workspace-owned resource IDs for internal views } = {} ): SanitizedWorkflowState { const sanitized = structuredClone(state) as SanitizedWorkflowState @@ -274,6 +437,16 @@ export function sanitizeWorkflowForSharing( removeMalformedSubBlocks(block) const blockConfig = getBlock(block.type) + const blockConfigById = new Map( + (blockConfig?.subBlocks ?? []).map((subBlock) => [subBlock.id, subBlock]) + ) + const registeredIds = registeredSubBlockIds(blockConfig?.subBlocks ?? []) + const credentialSensitiveIds = blockConfig + ? credentialSensitiveSubBlockIds(blockConfig.subBlocks ?? []) + : new Set() + const preservableWorkspaceIds = blockConfig + ? preservableWorkspaceSubBlockIds(blockConfig.subBlocks ?? []) + : new Set() // Process subBlocks with config if (blockConfig) { @@ -281,12 +454,28 @@ export function sanitizeWorkflowForSharing( if (block.subBlocks?.[subBlockConfig.id]) { const subBlock = block.subBlocks[subBlockConfig.id] - // Clear OAuth credentials (type: 'oauth-input') - if (subBlockConfig.type === 'oauth-input') { + const preserveWorkspaceReference = + options.preserveWorkspaceReferences === true && + preservableWorkspaceIds.has(subBlockConfig.id) + const preserveSecretEnvRef = + subBlockConfig.password === true && + options.preserveEnvVars === true && + typeof subBlock?.value === 'string' && + subBlock.value.startsWith('{{') && + subBlock.value.endsWith('}}') + + // Clear credentials, their canonical peers, and selectors scoped to + // those credentials. Workspace-owned references may be retained for + // internal secretless Copilot projections. + if ( + credentialSensitiveIds.has(subBlockConfig.id) && + !preserveWorkspaceReference && + !preserveSecretEnvRef + ) { block.subBlocks[subBlockConfig.id]!.value = null } - // Clear secret fields (password: true) + // Secret fields may preserve an env reference only for explicit export. else if (subBlockConfig.password === true) { // Preserve environment variable references if requested if ( @@ -302,12 +491,18 @@ export function sanitizeWorkflowForSharing( } // Clear workspace-specific selectors - else if (WORKSPACE_SPECIFIC_TYPES.has(subBlockConfig.type)) { + else if ( + WORKSPACE_SPECIFIC_TYPES.has(subBlockConfig.type) && + !preserveWorkspaceReference + ) { block.subBlocks[subBlockConfig.id]!.value = null } // Clear workspace-specific fields by ID - else if (WORKSPACE_SPECIFIC_FIELDS.has(subBlockConfig.id)) { + else if ( + WORKSPACE_SPECIFIC_FIELDS.has(subBlockConfig.id) && + !preserveWorkspaceReference + ) { block.subBlocks[subBlockConfig.id]!.value = null } } @@ -317,8 +512,32 @@ export function sanitizeWorkflowForSharing( // Process subBlocks without config (fallback) if (block.subBlocks) { Object.entries(block.subBlocks).forEach(([key, subBlock]) => { + if (!subBlock) return + + if (key === 'tools' || subBlock.type === 'tool-input') { + subBlock.value = sanitizeStoredToolCredentials(subBlock.value) as SubBlockState['value'] + } + + const preserveSecretEnvRef = + blockConfigById.get(key)?.password === true && + options.preserveEnvVars === true && + typeof subBlock.value === 'string' && + subBlock.value.startsWith('{{') && + subBlock.value.endsWith('}}') + const isRegistered = registeredIds.has(key) + if ( + (credentialSensitiveIds.has(key) || + (!isRegistered && (CREDENTIAL_SUBBLOCK_IDS.has(key) || isCredentialKey(key)))) && + !preserveSecretEnvRef + ) { + subBlock.value = null + } + // Clear workspace-specific fields by key name - if (WORKSPACE_SPECIFIC_FIELDS.has(key) && subBlock) { + if ( + WORKSPACE_SPECIFIC_FIELDS.has(key) && + !(options.preserveWorkspaceReferences && preservableWorkspaceIds.has(key)) + ) { subBlock.value = null } }) @@ -327,12 +546,17 @@ export function sanitizeWorkflowForSharing( // Clear data field (for backward compatibility) if (block.data) { Object.entries(block.data).forEach(([key]) => { - // Clear anything that looks like credentials - if (/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key)) { + const isSensitive = registeredIds.has(key) + ? credentialSensitiveIds.has(key) + : CREDENTIAL_SUBBLOCK_IDS.has(key) || isCredentialKey(key) + if (isSensitive) { block.data![key] = null } // Clear workspace-specific data - if (WORKSPACE_SPECIFIC_FIELDS.has(key)) { + if ( + WORKSPACE_SPECIFIC_FIELDS.has(key) && + !(options.preserveWorkspaceReferences && preservableWorkspaceIds.has(key)) + ) { block.data![key] = null } }) diff --git a/apps/sim/lib/workflows/custom-tools/operations.ts b/apps/sim/lib/workflows/custom-tools/operations.ts index 32e2ba8bf3c..e77f2651fec 100644 --- a/apps/sim/lib/workflows/custom-tools/operations.ts +++ b/apps/sim/lib/workflows/custom-tools/operations.ts @@ -131,6 +131,30 @@ export async function listCustomTools(params: { userId: string; workspaceId?: st .orderBy(desc(customTools.createdAt)) } +/** + * List only the metadata needed by workspace inventories. Normal views include + * the viewer's legacy personal tools; secretless views opt into workspace-only + * rows so a shared credential cannot reveal private tool metadata. + */ +export async function listCustomToolSummaries(params: { + userId: string + workspaceId: string + workspaceOnly?: boolean +}) { + const ownership = params.workspaceOnly + ? eq(customTools.workspaceId, params.workspaceId) + : or( + eq(customTools.workspaceId, params.workspaceId), + and(isNull(customTools.workspaceId), eq(customTools.userId, params.userId)) + ) + + return db + .select({ id: customTools.id, title: customTools.title }) + .from(customTools) + .where(ownership) + .orderBy(desc(customTools.createdAt), desc(customTools.id)) +} + /** * Workspace-scoped reads and deletes. * diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index fff063f8ab3..04ed157fc14 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -23,6 +23,7 @@ import { LRUCache } from 'lru-cache' import type { Edge } from 'reactflow' import { releaseWebhookPathClaims } from '@/lib/webhooks/path-claims' import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' +import { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/credentials/constants' import { isDynamicHandleSubblock } from '@/lib/workflows/dynamic-handle-topology' import { backfillCanonicalModes, @@ -366,13 +367,7 @@ export function migrateAgentBlocksToMessagesFormat( ) } -export const CREDENTIAL_SUBBLOCK_IDS = new Set([ - 'credential', - 'manualCredential', - 'triggerCredentials', - 'customBotCredential', - 'manualBotCredential', -]) +export { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/credentials/constants' async function migrateCredentialIds( blocks: Record, diff --git a/bun.lock b/bun.lock index 4d0bbc62d43..c2c5543f6e9 100644 --- a/bun.lock +++ b/bun.lock @@ -596,6 +596,7 @@ "@sim/tsconfig": "workspace:*", "@types/js-yaml": "4.0.9", "@types/node": "24.2.1", + "@xterm/headless": "6.0.0", "typescript": "^7.0.2", "vitest": "^3.2.4", }, diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index fe265772d95..6d3e0bebafd 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -11,7 +11,7 @@ sim workflows list ## Profiles Profiles work like the AWS CLI: one identity and one set of defaults per named -profile, selected with `--profile` or `SIM_PROFILE`. This is what lets you keep +profile, selected with `-P`, `--profile`, or `SIM_PROFILE`. This is what lets you keep production and a local dev stack side by side without re-authenticating. Non-secret settings live in `~/.sim/config`: @@ -84,20 +84,14 @@ http://localhost:3000/cli/auth?request=…&scope=platform Waiting for approval… ✓ Logged in. Key stored in /Users/you/.sim/credentials - Workspace-scoped key, pinned to ws_local. + Personal key, defaulting to ws_local. Override per command with --workspace. ``` The approval page is where you pick the workspace — the terminal has no key yet, -so it cannot list them for you. Whichever you pick becomes the profile's default -`workspace`, so you never have to go look up its id. - -What the key itself can reach depends on your role in that workspace, and the -page says which you are about to get before you approve: - -| Your role | Key issued | Reach | -| --- | --- | --- | -| Workspace admin | Workspace-scoped | That workspace only | -| Anything else | Personal | Every workspace you can access; `--workspace` overrides the default | +so it cannot list them for you. `sim login` issues a personal key, and whichever +workspace you pick becomes only the profile's default `workspace`; it does not +limit the key to that workspace. Use `--workspace` to target another workspace +the key can access. `sim login --workspace ` preselects a workspace in the picker, and an existing profile's workspace preselects itself on re-login. @@ -116,6 +110,9 @@ spellings. `document`. ```bash +sim chat [prompt...] [-f ...] [--read-only] +sim chat -p [prompt...] [-f ...] [--read-only] + sim workflows ls [path] [--search ] [--limit ] sim workflows list [--folder ] [--deployed-only] [--limit ] sim workflows get @@ -184,7 +181,103 @@ For a paused execution, its status includes the context ID needed by `resume`. `logs get` is the full diagnostic resource. It keeps the default human output concise; add `--trace` for the expanded recursive trace with span inputs, outputs, errors, timing, and cost. JSON and YAML retain the complete structured -response: +response. + +### Ask Sim Chat + +`sim chat` opens a terminal conversation about the workspace saved by `sim +login`. It streams answers with a compact working indicator, keeps the +conversation across turns, and provides input history. In a real TTY, the +transcript and current activity stay in the upper viewport while the +free-form `❯` composer remains pinned at the bottom. Use the global +`--workspace` flag to target another workspace the active key can access. +Structured questions use a separate compact panel: Up/Down moves, Enter selects, +Space toggles multi-select items, typing supplies a custom answer, and Esc returns +to the ordinary composer. Suggested follow-up metadata is omitted. + +The composer stays editable while Sim is working. Press Enter with a follow-up +to queue it and immediately steer the active turn (the TUI performs the web +chat's queue-then-send-now handoff in one step); additional submitted prompts +remain FIFO. Press Up on an empty composer to recall the newest queued prompt. +Shift+Enter, Option/Meta+Enter, or a trailing `\` followed by Enter inserts a +newline instead of submitting. + +Type `@` at the start of a token to tag a workspace workflow, table, file, or +knowledge base. The latest 50 execution logs appear after those primary +resources instead of expanding an unbounded logs tree. Past chats never enter +the `@` list; use `/chats` to open their searchable picker. Type `/` to invoke a +workspace skill or an enabled MCP server; read-only chat omits MCP servers, +and CLI control commands remain in that menu at the start of the composer. +These are structured tags, not decorative prompt text: Sim receives the +selected resource id, and a tagged MCP server remains enabled for later turns +in the same terminal conversation. + +```bash +sim chat +sim chat "Start by explaining this workspace" +sim chat --file screenshot.png "What is failing here?" +sim chat --read-only "Summarize this workspace without changing it" +``` + +Inside the chat, `/attach ` attaches up to five local images, PDFs, or +UTF-8 text files to the next turn. A pasted or dragged file path preloads an +`/attach` command; review it and press Enter before the CLI reads the file. On +macOS, press Ctrl+V or use `/paste-image` to attach a clipboard image; +any draft text remains in the prompt. `/chats` loads the chat history and opens +a searchable picker. Selecting one restores its transcript and continues it +with a fresh opaque token. The header shows the active chat title and keeps the +`/chats` switch hint visible; a new chat's generated title appears there as soon +as the server publishes it. `/rename ` retitles the active synced chat in +both the terminal and Sim Home. `/clear` clears the visible transcript and +starts a new conversation, `/help` lists commands, and `/exit` or Ctrl+D exits. +Ctrl+C clears idle input or cancels the active generation and returns to the +prompt. + +Chats sent with the personal API key issued by `sim login` use the same history +as Sim Home, so a CLI conversation appears in the web UI and a web conversation +can be resumed in the terminal. Shared workspace keys intentionally do not +expose their creator's private chat history. Profiles created by an older login +flow may still contain a workspace-scoped key; run `sim login` again for that +profile to replace it with a personal key and enable synchronized history and +`/chats`. + +Chat uses the full Mothership toolset by default. Add `--read-only` in either +interactive or print mode when the conversation must be restricted to +workspace-reading tools. + +`sim chat -p` is the non-interactive form. It never opens a prompt: the +completed, terminal-safe answer is the only thing written to stdout, so it +composes cleanly with shell tools. Bare `sim chat` requires a real terminal; +pipelines and redirected output must use `-p`. + +```bash +sim chat -p "Which workflows handle support tickets?" +cat incident.txt | sim chat -p "Which workflow is most likely involved?" +sim chat -p < question.txt +sim chat -p --file report.pdf "Summarize this in workspace context" +``` + +When both a positional prompt and stdin are present, the positional prompt comes +first and the piped content follows on the next line. This matches Claude Code's +print-mode input behavior. Combined input is limited to 10 MiB of UTF-8 text. +Files are sent inline by basename only: local paths never cross the API +boundary. Images and PDFs are limited to 5 MiB each, text files to 200 KiB, and +all attachments in a turn to 10 MiB total. + +On an auth-disabled self-hosted Sim deployment, configure the endpoint and +workspace without logging in locally: + +```bash +sim configure --set-endpoint http://localhost:3000 --set-workspace ws_local +sim chat -p "What is in this workspace?" +``` + +That deployment must enable `V2_API=true` and set `COPILOT_API_KEY` server-side. +A CLI API key, when one is present, authenticates only the public Sim request +and is never reused as the deployment's Mothership key. + +`sim logs get` keeps the default human output concise. Use JSON or YAML to +inspect its complete `executionData` and recursive `traceSpans` tree: ```bash sim logs get <executionId> --trace diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json index 15f721ae031..d65ac913894 100644 --- a/packages/sim-cli/package.json +++ b/packages/sim-cli/package.json @@ -40,6 +40,7 @@ "@sim/tsconfig": "workspace:*", "@types/js-yaml": "4.0.9", "@types/node": "24.2.1", + "@xterm/headless": "6.0.0", "typescript": "^7.0.2", "vitest": "^3.2.4" } diff --git a/packages/sim-cli/src/commands/protocol/chat-attachment-tag.test.ts b/packages/sim-cli/src/commands/protocol/chat-attachment-tag.test.ts new file mode 100644 index 00000000000..ee98e5cc066 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-attachment-tag.test.ts @@ -0,0 +1,64 @@ +import { PassThrough } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { ReadlineChatTerminal } from './chat-terminal.js' + +const ESC = String.fromCharCode(27) +const TAG = `${ESC}[38;2;51;196;130m` + +function harness() { + const input = new PassThrough() as PassThrough & { isTTY: boolean; setRawMode?: () => void } + const output = new PassThrough() as PassThrough & { + isTTY: boolean + columns: number + rows: number + } + input.isTTY = true + input.setRawMode = () => {} + output.isTTY = true + output.columns = 80 + output.rows = 20 + output.on('data', () => {}) + const terminal = new ReadlineChatTerminal(input as never, output as never) + const probe = terminal as never as { + draft: string + buildPanel(rows: number): { lines: string[] } + } + return { + input, + terminal, + draft: () => probe.draft, + row: () => probe.buildPanel(20).lines.join('\n'), + } +} + +describe('pasted image tag', () => { + it('inserts a numbered tag at the cursor and highlights it', () => { + const { input, terminal, draft, row } = harness() + void terminal.read('> ') + input.write('look at') + terminal.noteAttachment() + expect(draft()).toBe('look at [Image #1] ') + expect(row()).toContain(`${TAG}[Image #1]`) + terminal.close() + }) + + it('numbers successive attachments', () => { + const { terminal, draft } = harness() + void terminal.read('> ') + terminal.noteAttachment() + terminal.noteAttachment() + expect(draft()).toBe('[Image #1] [Image #2] ') + terminal.close() + }) + + it('stops highlighting once the tag is deleted', () => { + const { input, terminal, row } = harness() + void terminal.read('> ') + terminal.noteAttachment() + expect(row()).toContain(TAG) + const BACKSPACE = String.fromCharCode(127) + for (let i = 0; i < 12; i++) input.write(BACKSPACE) + expect(row()).not.toContain(TAG) + terminal.close() + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat-attachments.test.ts b/packages/sim-cli/src/commands/protocol/chat-attachments.test.ts new file mode 100644 index 00000000000..d0de466d569 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-attachments.test.ts @@ -0,0 +1,134 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + combineChatAttachments, + existingAttachmentPaths, + loadChatAttachment, + loadChatAttachments, + parseAttachmentPaths, +} from './chat-attachments.js' + +const temporaryDirectories: string[] = [] + +function pngBytes(size: number): Buffer { + const bytes = Buffer.alloc(size) + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(bytes) + return bytes +} + +async function fixture(name: string, value: Uint8Array | string): Promise<string> { + const directory = await mkdtemp(join(tmpdir(), 'sim-cli-chat-test-')) + temporaryDirectories.push(directory) + const path = join(directory, name) + await writeFile(path, value) + return path +} + +afterEach(async () => { + for (const path of temporaryDirectories.splice(0)) await rm(path, { recursive: true }) +}) + +describe('chat attachments', () => { + it('infers media types from bytes and sends only the basename', async () => { + const png = await fixture( + 'renamed.dat', + Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + ) + const markdown = await fixture('notes.md', '# hello') + + await expect(loadChatAttachment(png)).resolves.toEqual({ + name: 'renamed.dat', + mediaType: 'image/png', + data: 'iVBORw0KGgo=', + }) + await expect(loadChatAttachment(markdown)).resolves.toEqual({ + name: 'notes.md', + mediaType: 'text/markdown', + data: 'IyBoZWxsbw==', + }) + }) + + it('rejects binary and oversized text locally', async () => { + const binary = await fixture('payload.bin', Uint8Array.from([0xff, 0x00, 0xfe])) + const large = await fixture('large.txt', 'x'.repeat(200 * 1024 + 1)) + const tooLargeForAnyType = await fixture('huge.png', Buffer.alloc(5 * 1024 * 1024 + 1)) + + await expect(loadChatAttachment(binary)).rejects.toThrow(/Unsupported attachment/) + await expect(loadChatAttachment(large)).rejects.toThrow(/200 KiB/) + await expect(loadChatAttachment(tooLargeForAnyType)).rejects.toThrow(/5 MiB/) + }) + + it('enforces count and aggregate limits', async () => { + const small = { name: 'a.txt', mediaType: 'text/plain', data: 'eA==' } + expect(() => + combineChatAttachments( + [], + Array.from({ length: 6 }, () => small) + ) + ).toThrow(/at most 5/) + + const fiveMiB = Buffer.alloc(5 * 1024 * 1024).toString('base64') + expect(() => + combineChatAttachments( + [], + [ + { name: 'a.png', mediaType: 'image/png', data: fiveMiB }, + { name: 'b.png', mediaType: 'image/png', data: fiveMiB }, + { name: 'c.txt', mediaType: 'text/plain', data: 'eA==' }, + ] + ) + ).toThrow(/aggregate limit/) + }) + + it('loads multiple attachments and rejects missing paths', async () => { + const one = await fixture('one.txt', 'one') + const two = await fixture('two.json', '{}') + await expect(loadChatAttachments([one, two])).resolves.toHaveLength(2) + await expect(loadChatAttachment(join(tmpdir(), 'definitely-missing-sim-file'))).rejects.toThrow( + /Could not read attachment/ + ) + }) + + it('rejects too many paths before attempting to open any of them', async () => { + const missing = join(tmpdir(), 'definitely-missing-sim-file') + + await expect(loadChatAttachments(Array.from({ length: 6 }, () => missing))).rejects.toThrow( + /at most 5/ + ) + }) + + it('stops loading as soon as the aggregate byte limit is exceeded', async () => { + const first = await fixture('first.png', pngBytes(5 * 1024 * 1024)) + const second = await fixture('second.png', pngBytes(5 * 1024 * 1024)) + const third = await fixture('third.png', pngBytes(8)) + const missing = join(tmpdir(), 'missing-after-aggregate-limit.png') + + await expect(loadChatAttachments([first, second, third, missing])).rejects.toThrow( + /aggregate limit/ + ) + }) +}) + +describe('attachment path parsing', () => { + it('supports quoted and terminal-escaped paths', () => { + expect(parseAttachmentPaths("'/tmp/one two.md' /tmp/three\\ four.png")).toEqual([ + '/tmp/one two.md', + '/tmp/three four.png', + ]) + expect(() => parseAttachmentPaths("'/tmp/open")).toThrow(/Unclosed quote/) + }) + + it('recognizes a pasted path containing spaces before trying shell splitting', async () => { + const path = await fixture('a file.txt', 'hello') + await expect(existingAttachmentPaths(path)).resolves.toEqual([path]) + await expect(existingAttachmentPaths('this is a normal question')).resolves.toBeNull() + }) + + it('rejects pasted candidate lists beyond the per-turn limit', async () => { + const candidates = Array.from({ length: 6 }, (_, index) => `/tmp/sim-file-${index}`).join(' ') + + await expect(existingAttachmentPaths(candidates)).resolves.toBeNull() + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat-attachments.ts b/packages/sim-cli/src/commands/protocol/chat-attachments.ts new file mode 100644 index 00000000000..7e313c74bc9 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-attachments.ts @@ -0,0 +1,324 @@ +import { execFile } from 'node:child_process' +import { type FileHandle, mkdtemp, open, rmdir, stat, unlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, extname, join } from 'node:path' +import { promisify } from 'node:util' +import { SimApiError } from '../../http/client.js' + +export interface ChatAttachment { + name: string + mediaType: string + data: string +} + +export const MAX_CHAT_ATTACHMENTS = 5 +export const MAX_CHAT_ATTACHMENT_BYTES = 5 * 1024 * 1024 +export const MAX_CHAT_TEXT_ATTACHMENT_BYTES = 200 * 1024 +export const MAX_CHAT_ATTACHMENTS_TOTAL_BYTES = 10 * 1024 * 1024 + +const execFileAsync = promisify(execFile) +const utf8Decoder = new TextDecoder('utf-8', { fatal: true }) + +const TEXT_MEDIA_TYPES_BY_EXTENSION: Record<string, string> = { + '.css': 'text/css', + '.csv': 'text/csv', + '.htm': 'text/html', + '.html': 'text/html', + '.js': 'text/javascript', + '.json': 'application/json', + '.jsonl': 'application/jsonl', + '.jsx': 'text/javascript', + '.log': 'text/plain', + '.markdown': 'text/markdown', + '.md': 'text/markdown', + '.mjs': 'text/javascript', + '.ndjson': 'application/x-ndjson', + '.toml': 'application/toml', + '.ts': 'text/typescript', + '.tsv': 'text/tab-separated-values', + '.tsx': 'text/typescript', + '.txt': 'text/plain', + '.xml': 'application/xml', + '.yaml': 'application/yaml', + '.yml': 'application/yaml', +} + +function attachmentError(message: string): SimApiError { + return new SimApiError(message, 0) +} + +function sniffImageMediaType(bytes: Uint8Array): string | null { + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return 'image/png' + } + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'image/jpeg' + } + if (bytes.length >= 6) { + const signature = Buffer.from(bytes.subarray(0, 6)).toString('ascii') + if (signature === 'GIF87a' || signature === 'GIF89a') return 'image/gif' + } + if ( + bytes.length >= 12 && + Buffer.from(bytes.subarray(0, 4)).toString('ascii') === 'RIFF' && + Buffer.from(bytes.subarray(8, 12)).toString('ascii') === 'WEBP' + ) { + return 'image/webp' + } + return null +} + +function isPdf(bytes: Uint8Array): boolean { + return Buffer.from(bytes.subarray(0, 1024)).toString('latin1').includes('%PDF') +} + +function assertAttachmentName(name: string): void { + if ( + !name || + name === '.' || + name === '..' || + name.length > 255 || + /[\\/\u0000-\u001f\u007f]/.test(name) + ) { + throw attachmentError(`Attachment name ${JSON.stringify(name)} must be a safe file basename.`) + } +} + +function textMediaType(path: string): string { + return TEXT_MEDIA_TYPES_BY_EXTENSION[extname(path).toLowerCase()] ?? 'text/plain' +} + +function inspectAttachment(path: string, bytes: Uint8Array): { mediaType: string; limit: number } { + const imageMediaType = sniffImageMediaType(bytes) + if (imageMediaType) return { mediaType: imageMediaType, limit: MAX_CHAT_ATTACHMENT_BYTES } + if (isPdf(bytes)) return { mediaType: 'application/pdf', limit: MAX_CHAT_ATTACHMENT_BYTES } + + try { + const value = utf8Decoder.decode(bytes) + if (value.includes('\0')) throw new Error('NUL byte') + } catch { + throw attachmentError( + `Unsupported attachment ${JSON.stringify(basename(path))}. Use PNG, JPEG, GIF, WebP, PDF, or UTF-8 text.` + ) + } + return { mediaType: textMediaType(path), limit: MAX_CHAT_TEXT_ATTACHMENT_BYTES } +} + +async function readBounded(handle: FileHandle, limit: number): Promise<Buffer> { + const bytes = Buffer.allocUnsafe(limit + 1) + let offset = 0 + while (offset < bytes.byteLength) { + const result = await handle.read(bytes, offset, bytes.byteLength - offset, offset) + if (result.bytesRead === 0) break + offset += result.bytesRead + } + return bytes.subarray(0, offset) +} + +/** Reads and validates one local file without ever putting its path on the wire. */ +export async function loadChatAttachment(path: string): Promise<ChatAttachment> { + let handle: FileHandle + try { + handle = await open(path, 'r') + } catch { + throw attachmentError(`Could not read attachment ${JSON.stringify(path)}.`) + } + + const name = basename(path) + try { + const info = await handle.stat() + if (!info.isFile()) throw attachmentError(`Attachment ${JSON.stringify(path)} is not a file.`) + // Metadata rejects obvious mistakes without allocating for them. The read + // itself is independently capped because a file can grow after fstat. + if (info.size > MAX_CHAT_ATTACHMENT_BYTES) { + throw attachmentError(`Attachment ${JSON.stringify(name)} exceeds the 5 MiB limit.`) + } + + assertAttachmentName(name) + const bytes = await readBounded(handle, MAX_CHAT_ATTACHMENT_BYTES) + if (bytes.byteLength > MAX_CHAT_ATTACHMENT_BYTES) { + throw attachmentError(`Attachment ${JSON.stringify(name)} exceeds the 5 MiB limit.`) + } + if (bytes.byteLength === 0) { + throw attachmentError(`Attachment ${JSON.stringify(name)} is empty.`) + } + const { mediaType, limit } = inspectAttachment(path, bytes) + if (bytes.byteLength > limit) { + const label = limit === MAX_CHAT_TEXT_ATTACHMENT_BYTES ? '200 KiB' : '5 MiB' + throw attachmentError(`Attachment ${JSON.stringify(name)} exceeds the ${label} limit.`) + } + + return { name, mediaType, data: bytes.toString('base64') } + } catch (error) { + if (error instanceof SimApiError) throw error + throw attachmentError(`Could not read attachment ${JSON.stringify(path)}.`) + } finally { + await handle.close().catch(() => {}) + } +} + +export function decodedAttachmentBytes(attachment: ChatAttachment): number { + return Buffer.from(attachment.data, 'base64').byteLength +} + +/** Enforces count and aggregate limits whenever pending attachments are combined. */ +export function combineChatAttachments( + current: ChatAttachment[], + additions: ChatAttachment[] +): ChatAttachment[] { + const combined = [...current, ...additions] + if (combined.length > MAX_CHAT_ATTACHMENTS) { + throw attachmentError(`Sim Chat accepts at most ${MAX_CHAT_ATTACHMENTS} attachments per turn.`) + } + const bytes = combined.reduce((total, item) => total + decodedAttachmentBytes(item), 0) + if (bytes > MAX_CHAT_ATTACHMENTS_TOTAL_BYTES) { + throw attachmentError('Sim Chat attachments exceed the 10 MiB aggregate limit.') + } + return combined +} + +export async function loadChatAttachments(paths: string[]): Promise<ChatAttachment[]> { + if (paths.length > MAX_CHAT_ATTACHMENTS) { + throw attachmentError(`Sim Chat accepts at most ${MAX_CHAT_ATTACHMENTS} attachments per turn.`) + } + + const attachments: ChatAttachment[] = [] + let totalBytes = 0 + for (const path of paths) { + const attachment = await loadChatAttachment(path) + totalBytes += decodedAttachmentBytes(attachment) + if (totalBytes > MAX_CHAT_ATTACHMENTS_TOTAL_BYTES) { + throw attachmentError('Sim Chat attachments exceed the 10 MiB aggregate limit.') + } + attachments.push(attachment) + } + return attachments +} + +/** + * Splits `/attach` input using the subset terminals produce for dragged paths: + * whitespace separation, single/double quotes, and backslash escapes. + */ +export function parseAttachmentPaths(input: string): string[] { + const paths: string[] = [] + let value = '' + let quote: 'single' | 'double' | null = null + let escaped = false + + const push = () => { + if (value) paths.push(value) + value = '' + } + + for (const character of input.trim()) { + if (escaped) { + value += character + escaped = false + continue + } + if (character === '\\' && quote !== 'single') { + escaped = true + continue + } + if (character === "'" && quote !== 'double') { + quote = quote === 'single' ? null : 'single' + continue + } + if (character === '"' && quote !== 'single') { + quote = quote === 'double' ? null : 'double' + continue + } + if (/\s/.test(character) && quote === null) { + push() + continue + } + value += character + } + + if (escaped) value += '\\' + if (quote !== null) throw attachmentError('Unclosed quote in attachment path.') + push() + return paths +} + +/** + * Writes the clipboard image to a path given as argv[1]. + * + * Deliberately performs no size check: AppleScript cannot take `length of` raw + * data ("Can't make length of «data PNGf…»"), so a guard here throws for every + * image and the whole read fails. `loadChatAttachment` caps the size on fstat + * and again on read, which is where the limit belongs anyway. + */ +const APPLE_SCRIPT = [ + 'on run argv', + 'set outputPath to item 1 of argv', + 'try', + 'set imageData to the clipboard as «class PNGf»', + 'set outputFile to open for access POSIX file outputPath with write permission', + 'set eof outputFile to 0', + 'write imageData to outputFile', + 'close access outputFile', + 'on error', + 'try', + 'close access POSIX file outputPath', + 'end try', + 'error number -1700', + 'end try', + 'end run', +] + +/** Best-effort macOS clipboard image extraction, used by the paste keystroke. */ +export async function readClipboardImage(): Promise<ChatAttachment | null> { + if (process.platform !== 'darwin') return null + + const directory = await mkdtemp(join(tmpdir(), 'sim-chat-clipboard-')) + const path = join(directory, 'clipboard.png') + try { + const args = APPLE_SCRIPT.flatMap((line) => ['-e', line]) + args.push(path) + await execFileAsync('osascript', args, { timeout: 5_000 }) + return await loadChatAttachment(path) + } catch { + return null + } finally { + await unlink(path).catch(() => {}) + await rmdir(directory).catch(() => {}) + } +} + +/** True when every parsed path names an existing regular file. */ +export async function existingAttachmentPaths(input: string): Promise<string[] | null> { + let wholePath + try { + wholePath = await stat(input.trim()) + } catch { + wholePath = null + } + if (wholePath?.isFile()) return [input.trim()] + + let paths: string[] + try { + paths = parseAttachmentPaths(input) + } catch { + return null + } + if (paths.length === 0 || paths.length > MAX_CHAT_ATTACHMENTS) return null + for (const path of paths) { + try { + if (!(await stat(path)).isFile()) return null + } catch { + return null + } + } + return paths +} diff --git a/packages/sim-cli/src/commands/protocol/chat-markdown.test.ts b/packages/sim-cli/src/commands/protocol/chat-markdown.test.ts new file mode 100644 index 00000000000..c6a8f533451 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-markdown.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { ChatMarkdownStream } from './chat-markdown.js' + +const ESC = String.fromCharCode(27) + +describe('ChatMarkdownStream', () => { + it('styles headings, emphasis, lists, quotes, inline code, and fences across chunks', () => { + const stream = new ChatMarkdownStream(true) + const output = [ + stream.push('## Work'), + stream.push('space\n- **default'), + stream.push('-agent** with `code`\n> note\n```ts\nconst x = 1\n```'), + stream.finish(), + ].join('') + + expect(output).toContain(`${ESC}[1mWorkspace`) + expect(output).toContain(`${ESC}[2m•${ESC}[0m `) + expect(output).toContain('default-agent') + expect(output).not.toContain('**') + expect(output).not.toContain('`code`') + expect(output).toContain(`${ESC}[2m│${ESC}[0m note`) + expect(output).toContain(`${ESC}[2m┌─ ts${ESC}[0m`) + expect(output).toContain(`${ESC}[2mconst x = 1${ESC}[0m`) + expect(output).toContain(`${ESC}[2m└─${ESC}[0m`) + }) + + it('renders workspace summaries without exposing Markdown or styling identifier underscores', () => { + const stream = new ChatMarkdownStream(true) + const output = [ + stream.push("Here's what's in your workspace:\n\n**Workflows (3)**\n- forceful-arm\n"), + stream.push( + '- Table: cobalt_cloud\n- File: Mothership_Capability_Overview.pptx\n- **default-agent**' + ), + stream.finish(), + ].join('') + + expect(output).toContain(`${ESC}[1mWorkflows (3)${ESC}[0m`) + expect(output).toContain('cobalt_cloud') + expect(output).toContain('Mothership_Capability_Overview.pptx') + expect(output).toContain('default-agent') + expect(output).not.toContain('**') + expect(output).not.toContain(`${ESC}[3mcloud`) + expect(output).not.toContain(`${ESC}[3mCapability`) + }) + + it('renders Markdown links as visible labels without terminal hyperlinks or destinations', () => { + const stream = new ChatMarkdownStream(true) + expect(stream.push('[Sim](https://sim.ai/work')).toBe('') + expect(stream.push('space)')).toBe('Sim') + + const misleading = new ChatMarkdownStream(true) + const misleadingOutput = misleading.push('[notexample.com](https://example.com/)') + expect(misleadingOutput).toBe('notexample.com') + + const unsafe = new ChatMarkdownStream(true) + const unsafeOutput = unsafe.push('[bad](javascript:alert(1))') + expect(unsafeOutput).toBe('bad') + + const userInfo = new ChatMarkdownStream(true) + const userInfoOutput = userInfo.push('[login](https://trusted.example@evil.example/)') + expect(userInfoOutput).toBe('login') + expect(`${misleadingOutput}${unsafeOutput}${userInfoOutput}`).not.toContain(`${ESC}]8;;`) + }) + + it('never prefixes streamed list items with an undefined renderer value', () => { + const stream = new ChatMarkdownStream(true) + const output = `${stream.push('- ')}${stream.flushInline()}default-agent${stream.finish()}` + + expect(output).toContain('default-agent') + expect(output).not.toContain('undefined') + }) + + it('bounds incomplete link candidates and does not hide multiline prose', () => { + const longLabel = `[${'x'.repeat(300)}` + const labelStream = new ChatMarkdownStream(true) + expect(labelStream.push(longLabel)).toBe(longLabel) + + const longDestination = `[label](https://example.com/${'x'.repeat(2_100)}` + const destinationStream = new ChatMarkdownStream(true) + expect(destinationStream.push(longDestination)).toBe(longDestination) + + const multiline = new ChatMarkdownStream(true) + expect(multiline.push('[not a link\nnext line')).toBe('[not a link\nnext line') + }) + + it('sanitizes model controls before applying renderer-owned terminal styling', () => { + const stream = new ChatMarkdownStream(true) + const output = stream.push(`**safe${ESC}]0;owned\u0007**`) + expect(output).toContain('safe') + expect(output).not.toContain('owned') + expect(output).not.toContain(`${ESC}]0;`) + }) + + it('is a sanitized byte-preserving stream when terminal styling is disabled', () => { + const stream = new ChatMarkdownStream(false) + expect(stream.push('**plain**\n')).toBe('**plain**\n') + expect(stream.finish()).toBe('') + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat-markdown.ts b/packages/sim-cli/src/commands/protocol/chat-markdown.ts new file mode 100644 index 00000000000..1070f9ad7fa --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-markdown.ts @@ -0,0 +1,244 @@ +import { sanitize } from '../../output/render.js' + +const ESC = String.fromCharCode(27) +const RESET = `${ESC}[0m` +const BOLD = `${ESC}[1m` +const DIM = `${ESC}[2m` +const ITALIC = `${ESC}[3m` +const CYAN = `${ESC}[36m` +const MAX_LINK_LABEL_LENGTH = 256 +const MAX_LINK_DESTINATION_LENGTH = 2_048 + +interface InlineStyle { + bold: boolean + italic: boolean + code: boolean +} + +/** + * A deliberately small streaming Markdown renderer for interactive chat. + * + * It does not parse HTML and it never accepts terminal escapes from the model: + * input is sanitized first, then the renderer adds its own fixed SGR + * sequences. Markdown links intentionally render as their visible label only; + * terminal hyperlink support varies and hidden destinations are surprising in + * a CLI transcript. Unlike a whole-document Markdown parser, this keeps ordinary prose + * streaming as soon as it arrives. Only a possible Markdown link is buffered + * until its closing `)` makes the URL safe to validate. + */ +export class ChatMarkdownStream { + private readonly style: InlineStyle = { bold: false, italic: false, code: false } + private pending = '' + private atLineStart = true + private inFence = false + + constructor(private readonly enabled: boolean) {} + + push(fragment: string): string { + const safe = sanitize(fragment) + if (!this.enabled) return safe + this.pending += safe + return this.drain(false) + } + + /** Flushes an inline prefix before a trusted structured tag is written. */ + flushInline(): string { + if (!this.enabled || !this.pending) return '' + return this.drain(true) + } + + finish(): string { + if (!this.enabled) return '' + const rendered = this.drain(true) + return rendered + (this.hasStyle() ? this.resetStyles() : '') + } + + private drain(final: boolean): string { + let output = '' + + while (this.pending) { + if (this.atLineStart) { + const prefix = this.consumeLinePrefix(final) + if (prefix === null) break + output += prefix + if (!this.pending) break + } + + if (this.pending.startsWith('\n')) { + this.pending = this.pending.slice(1) + output += this.resetStyles() + output += '\n' + this.atLineStart = true + continue + } + + if (this.inFence) { + const newline = this.pending.indexOf('\n') + const amount = newline === -1 ? (final ? this.pending.length : 0) : newline + if (amount === 0) break + output += `${DIM}${this.pending.slice(0, amount)}${RESET}` + this.pending = this.pending.slice(amount) + continue + } + + const link = this.tryMarkdownLink(final) + if (link.kind === 'wait') break + if (link.kind === 'rendered') { + output += link.value + continue + } + + if (this.pending.startsWith('`')) { + this.pending = this.pending.slice(1) + this.style.code = !this.style.code + output += this.applyStyles() + continue + } + // Workspace identifiers and file names commonly contain underscores, so + // only asterisks act as emphasis delimiters in this compact renderer. + if (!this.style.code && this.pending.startsWith('**')) { + this.pending = this.pending.slice(2) + this.style.bold = !this.style.bold + output += this.applyStyles() + continue + } + if (!this.style.code && this.pending.startsWith('*')) { + if (!final && this.pending.length === 1) break + this.pending = this.pending.slice(1) + this.style.italic = !this.style.italic + output += this.applyStyles() + continue + } + + // A trailing marker may be the first half of a delimiter in the next SSE + // chunk. Hold it for one beat instead of briefly printing raw Markdown. + if (!final && this.pending.length === 1 && /[[\]*`]/u.test(this.pending)) break + + output += this.pending[0] + this.pending = this.pending.slice(1) + } + + return output + } + + private consumeLinePrefix(final: boolean): string | null { + const newline = this.pending.indexOf('\n') + const candidate = newline === -1 ? this.pending : this.pending.slice(0, newline) + if (!final && newline === -1 && candidate.length < 4 && /^[#>*+\-\d. `]*$/u.test(candidate)) { + return null + } + + const fence = candidate.match(/^\s*```\s*([^\s`]*)\s*$/u) + if (fence) { + this.pending = this.pending.slice(candidate.length) + this.inFence = !this.inFence + this.atLineStart = false + return this.inFence ? `${DIM}┌─${fence[1] ? ` ${fence[1]}` : ''}${RESET}` : `${DIM}└─${RESET}` + } + + if (this.inFence) { + this.atLineStart = false + return '' + } + + const heading = candidate.match(/^\s{0,3}#{1,6}\s+/u) + if (heading) { + this.pending = this.pending.slice(heading[0].length) + this.style.bold = true + this.atLineStart = false + return BOLD + } + + const bullet = candidate.match(/^(\s{0,8})[-+*]\s+/u) + if (bullet) { + this.pending = this.pending.slice(bullet[0].length) + this.atLineStart = false + return `${bullet[1]}${DIM}•${RESET} ${this.applyStyles(false)}` + } + + const quote = candidate.match(/^(\s{0,3})>\s?/u) + if (quote) { + this.pending = this.pending.slice(quote[0].length) + this.atLineStart = false + return `${quote[1]}${DIM}│${RESET} ` + } + + const rule = candidate.match(/^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/u) + if (rule) { + this.pending = this.pending.slice(candidate.length) + this.atLineStart = false + return `${DIM}${'─'.repeat(24)}${RESET}` + } + + this.atLineStart = false + return '' + } + + private tryMarkdownLink( + final: boolean + ): { kind: 'none' } | { kind: 'wait' } | { kind: 'rendered'; value: string } { + if (!this.pending.startsWith('[') || this.style.code || this.inFence) return { kind: 'none' } + + const labelEnd = this.pending.indexOf('](') + if (labelEnd === -1) { + const couldStillBeLink = + !final && !this.pending.includes('\n') && this.pending.length <= MAX_LINK_LABEL_LENGTH + 2 + return couldStillBeLink ? { kind: 'wait' } : { kind: 'none' } + } + const label = this.pending.slice(1, labelEnd) + if (!label || label.includes('\n') || label.length > MAX_LINK_LABEL_LENGTH) { + return { kind: 'none' } + } + + let depth = 1 + let escaped = false + let end = labelEnd + 2 + for (; end < this.pending.length; end += 1) { + if (end - labelEnd - 2 > MAX_LINK_DESTINATION_LENGTH) return { kind: 'none' } + const character = this.pending[end] + if (escaped) { + escaped = false + continue + } + if (character === '\\') { + escaped = true + continue + } + if (character === '(') depth += 1 + if (character === ')') { + depth -= 1 + if (depth === 0) break + } + if (character === '\n') return { kind: 'none' } + } + if (end >= this.pending.length) { + const destinationLength = this.pending.length - labelEnd - 2 + return !final && destinationLength <= MAX_LINK_DESTINATION_LENGTH + ? { kind: 'wait' } + : { kind: 'none' } + } + + this.pending = this.pending.slice(end + 1) + return { kind: 'rendered', value: label } + } + + private hasStyle(): boolean { + return this.style.bold || this.style.italic || this.style.code + } + + private resetStyles(): string { + const hadStyle = this.hasStyle() + this.style.bold = false + this.style.italic = false + this.style.code = false + return hadStyle ? RESET : '' + } + + private applyStyles(reset = true): string { + let output = reset ? RESET : '' + if (this.style.bold) output += BOLD + if (this.style.italic) output += ITALIC + if (this.style.code) output += `${CYAN}${DIM}` + return output + } +} diff --git a/packages/sim-cli/src/commands/protocol/chat-mentions.test.ts b/packages/sim-cli/src/commands/protocol/chat-mentions.test.ts new file mode 100644 index 00000000000..8e857695ab7 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-mentions.test.ts @@ -0,0 +1,121 @@ +import { PassThrough } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { ReadlineChatTerminal } from './chat-terminal.js' + +const ESC = String.fromCharCode(27) +const MENTION = `${ESC}[38;2;51;196;130m` +const BODY_TEXT = `${ESC}[38;2;242;242;242m` +const BACKSPACE = String.fromCharCode(127) + +function harness() { + const input = new PassThrough() as PassThrough & { isTTY: boolean; setRawMode?: () => void } + const output = new PassThrough() as PassThrough & { + isTTY: boolean + columns: number + rows: number + } + input.isTTY = true + input.setRawMode = () => {} + output.isTTY = true + output.columns = 80 + output.rows = 20 + const terminal = new ReadlineChatTerminal(input as never, output as never) + terminal.setSuggestionCandidates({ + resources: [ + { + id: 'w1', + value: 'code-review', + displayText: 'code-review', + tag: 'workflow', + context: { + kind: 'workflow', + workflowId: 'w1', + label: 'code-review', + }, + }, + { + id: 'w2', + value: 'release notes', + displayText: 'release notes', + tag: 'workflow', + context: { + kind: 'workflow', + workflowId: 'w2', + label: 'release notes', + }, + }, + ], + slash: [ + { + id: 's1', + value: 'review', + displayText: '/review', + tag: 'skill', + context: { kind: 'skill', skillId: 's1', label: 'review' }, + }, + ], + }) + const probe = terminal as never as { buildPanel(rows: number): { lines: string[] } } + return { + input, + terminal, + draftRow: () => probe.buildPanel(20).lines.find((line) => line.includes('>')) ?? '', + } +} + +describe('mention highlighting', () => { + it('lights a mention that resolves to a candidate', () => { + const { input, terminal, draftRow } = harness() + void terminal.read('> ') + input.write('run @code\tnow') + expect(draftRow()).toContain(`${MENTION}@code-review${BODY_TEXT}`) + terminal.close() + }) + + it('goes plain once the mention is half-deleted', () => { + const { input, terminal, draftRow } = harness() + void terminal.read('> ') + input.write('run @code\t') + expect(draftRow()).toContain(MENTION) + for (let i = 0; i < 3; i++) input.write(BACKSPACE) + expect(draftRow()).not.toContain(MENTION) + terminal.close() + }) + + it('does not light an unknown mention or an email address', () => { + const { input, terminal, draftRow } = harness() + void terminal.read('> ') + input.write('ping @nobody and me@example.com') + expect(draftRow()).not.toContain(MENTION) + terminal.close() + }) + + it('lights the client-style literal mention containing a space', () => { + const { input, terminal, draftRow } = harness() + void terminal.read('> ') + input.write('draft @release\tplease') + expect(draftRow()).toContain(`${MENTION}@release notes${BODY_TEXT}`) + terminal.close() + }) + + it('lights a typed exact slash skill once it resolves', () => { + const { input, terminal, draftRow } = harness() + void terminal.read('> ') + input.write('use /review now') + expect(draftRow()).toContain(`${MENTION}/review${BODY_TEXT}`) + terminal.close() + }) + + it('closes the style at a row break so it cannot leak', () => { + const { input, terminal } = harness() + void terminal.read('> ') + input.write(`${'x'.repeat(75)} @code\ttail`) + const probe = terminal as never as { buildPanel(rows: number): { lines: string[] } } + for (const line of probe.buildPanel(20).lines) { + const opens = line.split(MENTION).length - 1 + const closes = line.split(`${ESC}[0m`).length - 1 + expect(closes).toBeGreaterThanOrEqual(opens) + } + terminal.close() + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat-paste.test.ts b/packages/sim-cli/src/commands/protocol/chat-paste.test.ts new file mode 100644 index 00000000000..a1e993f8f42 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-paste.test.ts @@ -0,0 +1,78 @@ +import { PassThrough } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { ReadlineChatTerminal } from './chat-terminal.js' + +const ESC = String.fromCharCode(27) +const PASTE_START = `${ESC}[200~` +const PASTE_END = `${ESC}[201~` + +function harness() { + const input = new PassThrough() as PassThrough & { isTTY: boolean; setRawMode?: () => void } + const output = new PassThrough() as PassThrough & { + isTTY: boolean + columns: number + rows: number + } + input.isTTY = true + input.setRawMode = () => {} + output.isTTY = true + output.columns = 80 + output.rows = 20 + const terminal = new ReadlineChatTerminal(input as never, output as never) + return { input, terminal, draft: () => (terminal as never as { draft: string }).draft } +} + +describe('bracketed paste', () => { + it('inserts a short single-line paste literally', () => { + const { input, terminal, draft } = harness() + void terminal.read('> ') + input.write(`${PASTE_START}hello world${PASTE_END}`) + expect(draft()).toBe('hello world') + terminal.close() + }) + + it('collapses a multi-line paste to a placeholder and expands it on submit', async () => { + const { input, terminal, draft } = harness() + const result = terminal.read('> ') + const body = 'line one\nline two\nline three\nline four' + input.write(`${PASTE_START}${body}${PASTE_END}`) + expect(draft()).toBe('[Pasted text #1 +3 lines]') + input.write('\r') + await expect(result).resolves.toEqual({ + kind: 'line', + value: body, + display: '[Pasted text #1 +3 lines]', + pastes: new Map([[1, body]]), + }) + terminal.close() + }) + + it('collapses a long single-line paste', () => { + const { input, terminal, draft } = harness() + void terminal.read('> ') + input.write(`${PASTE_START}${'x'.repeat(900)}${PASTE_END}`) + expect(draft()).toBe('[Pasted text #1]') + terminal.close() + }) + + it('drops a stashed body when its placeholder is deleted', async () => { + const { input, terminal, draft } = harness() + const result = terminal.read('> ') + input.write(`${PASTE_START}a\nb\nc\nd${PASTE_END}`) + const BACKSPACE = String.fromCharCode(127) + let guard = 200 + while (draft().length > 0 && guard-- > 0) input.write(BACKSPACE) + input.write('plain') + input.write('\r') + await expect(result).resolves.toEqual({ kind: 'line', value: 'plain' }) + terminal.close() + }) + + it('routes an empty paste to the clipboard, for macOS cmd+v of an image', async () => { + const { input, terminal } = harness() + const result = terminal.read('> ') + input.write(`${PASTE_START}${PASTE_END}`) + await expect(result).resolves.toEqual({ kind: 'clipboard', value: '' }) + terminal.close() + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat-structured.test.ts b/packages/sim-cli/src/commands/protocol/chat-structured.test.ts new file mode 100644 index 00000000000..1c95e3f826e --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-structured.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it } from 'vitest' +import { + ChatStructuredParser, + parseChatStructured, + renderChatStructured, +} from './chat-structured.js' + +const ESC = String.fromCharCode(27) + +function parseChunks(chunks: string[]) { + const parser = new ChatStructuredParser() + return [...chunks.flatMap((chunk) => parser.push(chunk)), ...parser.finish()] +} + +describe('ChatStructuredParser', () => { + it('parses every official tag when wrappers are split across chunks', () => { + const content = [ + 'Answer ', + '<thinking>private</thinking>', + '<options>{"1":{"title":"Next","description":"Continue"}}</options>', + '<question>{"type":"single_select","prompt":"Choose","options":[{"id":"a","label":"A"}]}</question>', + '<credential>{"type":"link","provider":"Slack","value":"https://sim.ai/connect?id=1"}</credential>', + '<workspace_resource>{"type":"workflow","id":"wf_1","title":"Daily sync"}</workspace_resource>', + '<usage_upgrade>{"reason":"quota","action":"upgrade_plan","message":"Upgrade now"}</usage_upgrade>', + '<mothership-error>{"message":"Unavailable","code":"MODEL_DOWN"}</mothership-error>', + ].join('') + + const segments = parseChunks([...content]) + + expect(segments.map((segment) => segment.kind).filter((kind) => kind !== 'text')).toEqual([ + 'thinking', + 'options', + 'question', + 'credential', + 'workspace_resource', + 'usage_upgrade', + 'mothership-error', + ]) + }) + + it('does not treat a closing marker inside a JSON string as the tag boundary', () => { + const segments = parseChunks([ + '<opt', + 'ions>{"1":{"title":"Show </options> literally","description":"escaped \\\"quote\\\""}}</opt', + 'ions>', + ]) + + expect(segments).toEqual([ + { + kind: 'options', + choices: [ + { + value: 'Show </options> literally', + label: 'Show </options> literally', + description: 'escaped "quote"', + }, + ], + }, + ]) + }) + + it('preserves valid-looking structured examples inside inline and fenced code', () => { + const inline = '`<options>{"1":{"title":"A","description":"B"}}</options>`' + const fenced = + '```json\n<question>{"type":"single_select","prompt":"P","options":[{"id":"a","label":"A"}]}</question>\n```' + const content = `${inline}\n${fenced}` + + expect(renderChatStructured(parseChunks([...content])).text).toBe(content) + }) + + it('strips malformed options and preserves unknown tags as sanitized text', () => { + const content = `before <options>{bad${ESC}[2A</options> <future>${ESC}]0;pwned\u0007ok</future>` + + const result = renderChatStructured(parseChatStructured(content)) + + expect(result.text).toContain('before<future>') + expect(result.text).toContain('<future>ok</future>') + expect(result.text).not.toContain('interactive response') + expect(result.text).not.toContain(ESC) + }) + + it('holds incomplete wrappers until finish and then preserves them', () => { + const parser = new ChatStructuredParser() + + expect(parser.push('answer <quest')).toEqual([{ kind: 'text', text: 'answer ' }]) + expect(parser.push('ion>{"type":"single_select"')).toEqual([]) + expect(parser.finish()).toEqual([ + { kind: 'text', text: 'Sim Chat requested an interactive response.' }, + ]) + }) + + it('drops an unclosed thinking wrapper when the stream finishes', () => { + const parser = new ChatStructuredParser() + + expect(parser.push('answer <thinking>still reasoning about')).toEqual([ + { kind: 'text', text: 'answer ' }, + ]) + expect(parser.finish()).toEqual([]) + }) + + it('recovers useful prompts from invalid but parseable question payloads', () => { + const segments = parseChatStructured( + '<question>[{"type":"single_select","prompt":"Which\\nservice?","options":[]},{"prompt":"Deploy where?"}]</question>' + ) + + expect(segments).toEqual([{ kind: 'text', text: 'Which service?\n\nDeploy where?' }]) + }) + + it('recovers a prompt from an otherwise complete question missing its closing tag', () => { + const parser = new ChatStructuredParser() + + expect( + parser.push( + '<question>{"type":"single_select","prompt":"Continue?","options":[{"id":"yes","label":"Yes"}]}' + ) + ).toEqual([]) + expect(parser.finish()).toEqual([{ kind: 'text', text: 'Continue?' }]) + }) + + it('rejects question payloads beyond the interaction bounds and bounds prompt recovery', () => { + const fourQuestions = Array.from({ length: 4 }, (_, index) => ({ + type: 'single_select', + prompt: `Question ${index + 1}`, + options: [{ id: 'yes', label: 'Yes' }], + })) + const tooManyOptions = { + type: 'single_select', + prompt: 'Pick one', + options: Array.from({ length: 21 }, (_, index) => ({ + id: `option-${index}`, + label: `Option ${index}`, + })), + } + + expect(parseChatStructured(`<question>${JSON.stringify(fourQuestions)}</question>`)).toEqual([ + { kind: 'text', text: 'Question 1\n\nQuestion 2\n\nQuestion 3' }, + ]) + expect(parseChatStructured(`<question>${JSON.stringify(tooManyOptions)}</question>`)).toEqual([ + { kind: 'text', text: 'Pick one' }, + ]) + }) + + it('accepts question values at their limits and rejects overlong prompt, id, and label fields', () => { + const boundedQuestion = { + type: 'multi_select', + prompt: 'p'.repeat(1024), + options: Array.from({ length: 20 }, (_, index) => ({ + id: `${index}-${'i'.repeat(157)}`, + label: 'l'.repeat(160), + })), + } + const atLimits = parseChatStructured( + `<question>${JSON.stringify([boundedQuestion, boundedQuestion, boundedQuestion])}</question>` + ) + + expect(atLimits).toHaveLength(1) + expect(atLimits[0]?.kind).toBe('question') + if (atLimits[0]?.kind !== 'question') throw new Error('Expected a question segment') + expect(atLimits[0].questions).toHaveLength(3) + expect(atLimits[0].questions[0]?.options).toHaveLength(20) + + for (const invalid of [ + { ...boundedQuestion, prompt: 'p'.repeat(1025) }, + { ...boundedQuestion, options: [{ id: 'i'.repeat(161), label: 'Valid' }] }, + { ...boundedQuestion, options: [{ id: 'valid', label: 'l'.repeat(161) }] }, + ]) { + const segments = parseChatStructured(`<question>${JSON.stringify(invalid)}</question>`) + expect(segments.some((segment) => segment.kind === 'question')).toBe(false) + } + }) + + it('strips an incomplete options wrapper at end of stream', () => { + const parser = new ChatStructuredParser() + + expect(parser.push('answer <options>{"1":{"title":"Next"')).toEqual([ + { kind: 'text', text: 'answer ' }, + ]) + expect(parser.finish()).toEqual([{ kind: 'options', choices: [] }]) + }) + + it('keeps a CRLF stable when its bytes arrive in separate string fragments', () => { + expect(renderChatStructured(parseChunks(['first\r', '\nsecond'])).text).toBe('first\nsecond') + }) + + it('strips options even when their decoded values contain controls', () => { + const result = renderChatStructured( + '<options>{"1":{"title":"Safe\\u001b[2A title","description":"D"}}</options>' + ) + + expect(result).toMatchObject({ text: '', interactions: [] }) + }) + + it('sanitizes directly supplied segments as a defense-in-depth boundary', () => { + const result = renderChatStructured([ + { kind: 'text', text: `safe${ESC}[2A text` }, + { + kind: 'options', + choices: [{ value: `next${ESC}c`, label: `Next${ESC}]0;x\u0007`, description: 'D' }], + }, + ]) + + expect(result).toMatchObject({ text: 'safe text', interactions: [] }) + }) + + it('flattens interactive prompts, labels, and descriptions onto terminal-safe lines', () => { + const result = renderChatStructured( + [ + '<options>{"1":{"title":"Inspect\\nlogs","description":"Find\\t recent\\nerrors"}}</options>', + '<question>{"type":"single_select","prompt":"Which\\nservice?","options":[{"id":"a\\nb","label":"API\\nworker"}]}</question>', + ].join(''), + { printMode: false } + ) + + expect(result.interactions).toEqual([ + { + kind: 'question', + questions: [ + { + type: 'single_select', + prompt: 'Which service?', + options: [{ id: 'a b', label: 'API worker' }], + }, + ], + }, + ]) + }) +}) + +describe('renderChatStructured', () => { + it('strips options while preserving question interactions and print text', () => { + const result = renderChatStructured( + [ + '<options>{"1":{"title":"Inspect logs","description":"Find errors"}}</options>', + '<question>[{"type":"multi_select","prompt":"Pick services","options":[{"id":"api","label":"API"},{"id":"other","label":"Something else"}]}]</question>', + ].join('\n') + ) + + expect(result.text).toBe('Pick services') + expect(result.interactions).toEqual([ + { + kind: 'question', + questions: [ + { + type: 'multi_select', + prompt: 'Pick services', + options: [{ id: 'api', label: 'API' }], + }, + ], + }, + ]) + }) + + it('strips options without producing an interaction outside print mode', () => { + const result = renderChatStructured( + 'before<options>{"1":{"title":"Next","description":"Continue"}}</options>after', + { printMode: false } + ) + + expect(result.text).toBe('beforeafter') + expect(result.interactions).toEqual([]) + }) + + it('removes whitespace surrounding hidden options at the end of print output', () => { + const options = '<options>{"1":{"title":"Next","description":"Continue"}}</options>' + + expect(renderChatStructured(`Answer\n\n${options}\n\n`).text).toBe('Answer') + expect(renderChatStructured(`before \n${options}\n after`).text).toBe('beforeafter') + expect(renderChatStructured('Answer\n\n<options>{"1":{"title":"Next"').text).toBe('Answer') + }) + + it('renders workspace resources as names without terminal links or URL suffixes', () => { + const result = renderChatStructured( + '<workspace_resource>{"type":"workflow","id":"wf /1","title":"My workflow"}</workspace_resource>' + ) + + expect(result.text).toBe('My workflow') + expect(result.text).not.toContain(ESC) + expect(result.text).not.toContain('https://') + }) + + it('shows a path-only file title without resolving or appending its VFS path', () => { + const result = renderChatStructured( + '<workspace_resource>{"type":"file","path":"files/Reports/Q4%20Report.csv","title":"Q4"}</workspace_resource>' + ) + + expect(result.text).toBe('Q4') + }) + + it('rejects unsafe credential protocols and control-bearing links', () => { + const unsafeProtocol = renderChatStructured( + '<credential>{"type":"link","provider":"Slack","value":"javascript:alert(1)"}</credential>' + ) + const controlBearing = renderChatStructured( + '<credential>{"type":"link","provider":"Slack","value":"https://safe.test/\\u001b]8;;https://evil.test"}</credential>' + ) + + expect(unsafeProtocol.text).toBe('Open Sim to connect Slack.') + expect(unsafeProtocol.text).not.toContain(ESC) + expect(controlBearing.text).toBe('Open Sim to complete the requested credential action.') + expect(controlBearing.text).not.toContain(ESC) + }) + + it('renders credential links as a plain action without exposing the destination', () => { + const content = + '<credential>{"type":"link","provider":"Slack","value":"https://sim.example.evil.test/connect"}</credential>' + const result = renderChatStructured(content) + + expect(result.text).toBe('Open Sim to connect Slack.') + expect(result.text).not.toContain('sim.example.evil.test') + expect(result.text).not.toContain(ESC) + }) + + it('sanitizes workspace titles before rendering a plain resource name', () => { + const result = renderChatStructured( + '<workspace_resource>{"type":"table","id":"table_1","title":"Orders\\u001b]0;owned\\u0007 safe"}</workspace_resource>' + ) + + expect(result.text).toBe('Orders safe') + expect(result.text).not.toContain(ESC) + }) + + it('never renders credential secret values', () => { + const result = renderChatStructured( + '<credential>{"type":"sim_key","provider":"Sim","value":"secret-value"}</credential>' + ) + + expect(result.text).toBe('Open Sim to configure a Sim API key.') + expect(result.text).not.toContain('secret-value') + }) + + it.each([ + ['env_key', 'Open Sim to configure Slack environment credentials.'], + ['oauth_key', 'Open Sim to connect Slack with OAuth.'], + ['credential_id', 'Open Sim to select Slack credentials.'], + ])('renders %s as a safe action without its value', (type, expected) => { + const result = renderChatStructured( + `<credential>{"type":"${type}","provider":"Slack","value":"never-print-me"}</credential>` + ) + + expect(result.text).toBe(expected) + expect(result.text).not.toContain('never-print-me') + }) + + it('hides thinking and safely renders usage and mothership errors', () => { + const result = renderChatStructured( + 'Answer<thinking>secret reasoning</thinking><usage_upgrade>{"reason":"quota","action":"increase_limit","message":"Increase limit"}</usage_upgrade><mothership-error>{"message":"Retry later","code":"BUSY","provider":"x"}</mothership-error>' + ) + + expect(result.text).toBe('Answer\n\nUsage limit reached: Increase limit\n\nRetry later (BUSY)') + expect(result.text).not.toContain('secret reasoning') + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat-structured.ts b/packages/sim-cli/src/commands/protocol/chat-structured.ts new file mode 100644 index 00000000000..999de64e7d1 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-structured.ts @@ -0,0 +1,748 @@ +import { sanitize } from '../../output/render.js' + +export const OFFICIAL_CHAT_TAG_NAMES = [ + 'thinking', + 'options', + 'question', + 'credential', + 'workspace_resource', + 'usage_upgrade', + 'mothership-error', +] as const + +export type OfficialChatTagName = (typeof OFFICIAL_CHAT_TAG_NAMES)[number] + +export interface ChatChoice { + value: string + label: string + description: string +} + +export interface ChatQuestionOption { + id: string + label: string +} + +export interface ChatQuestion { + type: 'single_select' | 'multi_select' + prompt: string + options: ChatQuestionOption[] +} + +export interface ChatOptionsInteraction { + kind: 'options' + choices: ChatChoice[] +} + +export interface ChatQuestionInteraction { + kind: 'question' + questions: ChatQuestion[] +} + +export type ChatInteraction = ChatOptionsInteraction | ChatQuestionInteraction + +export type ChatCredentialType = + | 'env_key' + | 'oauth_key' + | 'sim_key' + | 'credential_id' + | 'link' + | 'secret_input' + | 'folder_access' + | 'browser_takeover' + | 'terminal_handoff' + | 'service_account' + +export interface ChatCredential { + type: ChatCredentialType + provider?: string + value?: string + name?: string + scope?: 'personal' | 'workspace' + credentialId?: string +} + +export interface ChatWorkspaceResource { + type: 'workflow' | 'table' | 'file' + id?: string + path?: string + title?: string +} + +export interface ChatUsageUpgrade { + reason: string + action: 'upgrade_plan' | 'increase_limit' + message: string +} + +export interface ChatMothershipError { + message: string + code?: string + provider?: string +} + +export type ChatStructuredSegment = + | { kind: 'text'; text: string } + | { kind: 'thinking'; content: string } + | { kind: 'options'; choices: ChatChoice[] } + | { kind: 'question'; questions: ChatQuestion[] } + | { kind: 'credential'; credential: ChatCredential } + | { kind: 'workspace_resource'; resource: ChatWorkspaceResource } + | { kind: 'usage_upgrade'; upgrade: ChatUsageUpgrade } + | { kind: 'mothership-error'; error: ChatMothershipError } + +export interface ChatStructuredRenderOptions { + printMode?: boolean +} + +export interface ChatStructuredRenderResult { + text: string + interactions: ChatInteraction[] + /** + * The parts `text` was joined from, each tagged block or inline. + * + * Exposed so an incremental renderer can reuse this classification instead of + * re-deriving it per segment kind — two copies of that rule drift apart and + * nothing catches it, since only one of them is exercised by the one-shot path. + */ + parts: readonly RenderPart[] +} + +interface OpeningTagMatch { + index: number + name: OfficialChatTagName +} + +export interface RenderPart { + block: boolean + value: string +} + +type JsonRecord = Record<string, unknown> + +const OPENING_TAGS = OFFICIAL_CHAT_TAG_NAMES.map((name) => ({ + marker: `<${name}>`, + name, +})) + +const QUESTION_TYPES = new Set(['single_select', 'multi_select']) +const MAX_QUESTIONS = 3 +const MAX_QUESTION_OPTIONS = 20 +const MAX_QUESTION_PROMPT_LENGTH = 1024 +const MAX_QUESTION_OPTION_FIELD_LENGTH = 160 +const CREDENTIAL_TYPES = new Set<ChatCredentialType>([ + 'env_key', + 'oauth_key', + 'sim_key', + 'credential_id', + 'link', + 'secret_input', + 'folder_access', + 'browser_takeover', + 'terminal_handoff', + 'service_account', +]) +const QUESTION_CATCH_ALL_LABELS = new Set([ + 'other', + 'others', + 'something else', + 'none of the above', + 'none of these', +]) +const TERMINAL_CONTROL_PATTERN = /[\u0000-\u001f\u007f-\u009f]/u + +/** Sanitizes one server-owned fragment before it enters parser state. */ +function sanitizeServerString(value: string): string { + return sanitize(value).replace(/\r/g, '') +} + +function oneLine(value: string): string { + return sanitizeServerString(value) + .replace(/[\n\t]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function cleanOptionalString(value: unknown): string | undefined { + return typeof value === 'string' ? sanitizeServerString(value) : undefined +} + +function parseJson(body: string): unknown | undefined { + try { + return JSON.parse(body) as unknown + } catch { + return undefined + } +} + +function parseChoices(value: unknown): ChatChoice[] | null { + if (value === null || typeof value !== 'object') return null + + const choices: ChatChoice[] = [] + for (const item of Object.values(value)) { + if (!isRecord(item) || typeof item.title !== 'string' || typeof item.description !== 'string') { + return null + } + const title = oneLine(item.title) + choices.push({ + value: title, + label: title, + description: oneLine(item.description), + }) + } + return choices +} + +function parseQuestion(value: unknown): ChatQuestion | null { + if (!isRecord(value) || !QUESTION_TYPES.has(String(value.type))) return null + if (typeof value.prompt !== 'string') return null + + const prompt = oneLine(value.prompt) + if ( + !prompt || + prompt.length > MAX_QUESTION_PROMPT_LENGTH || + !Array.isArray(value.options) || + value.options.length === 0 || + value.options.length > MAX_QUESTION_OPTIONS + ) { + return null + } + + const options: ChatQuestionOption[] = [] + for (const option of value.options) { + if (!isRecord(option) || typeof option.id !== 'string' || typeof option.label !== 'string') { + return null + } + const id = oneLine(option.id) + const label = oneLine(option.label) + if ( + !id || + !label || + id.length > MAX_QUESTION_OPTION_FIELD_LENGTH || + label.length > MAX_QUESTION_OPTION_FIELD_LENGTH + ) { + return null + } + if (QUESTION_CATCH_ALL_LABELS.has(label.trim().toLowerCase())) continue + options.push({ id, label }) + } + if (options.length === 0) return null + + return { + type: value.type as ChatQuestion['type'], + prompt, + options, + } +} + +function parseQuestions(value: unknown): ChatQuestion[] | null { + const values = Array.isArray(value) ? value : [value] + if (values.length === 0 || values.length > MAX_QUESTIONS) return null + + const questions: ChatQuestion[] = [] + for (const candidate of values) { + const question = parseQuestion(candidate) + if (!question) return null + questions.push(question) + } + return questions +} + +function recoverQuestionPrompts(body: string): string | null { + const payload = parseJson(body) + if (payload === undefined) return null + + const values = (Array.isArray(payload) ? payload : [payload]).slice(0, MAX_QUESTIONS) + const prompts: string[] = [] + for (const value of values) { + if (!isRecord(value) || typeof value.prompt !== 'string') continue + const prompt = oneLine(value.prompt) + if (prompt && prompt.length <= MAX_QUESTION_PROMPT_LENGTH) prompts.push(prompt) + } + return prompts.length > 0 ? prompts.join('\n\n') : null +} + +function parseCredential(value: unknown): ChatCredential | null { + if (!isRecord(value) || typeof value.type !== 'string') return null + if (!CREDENTIAL_TYPES.has(value.type as ChatCredentialType)) return null + if (value.provider !== undefined && typeof value.provider !== 'string') return null + + const type = value.type as ChatCredentialType + const provider = cleanOptionalString(value.provider) + + if (type === 'secret_input') { + if (typeof value.name !== 'string' || !sanitizeServerString(value.name).trim()) return null + if (value.scope !== undefined && value.scope !== 'personal' && value.scope !== 'workspace') { + return null + } + return { + type, + provider, + name: sanitizeServerString(value.name), + scope: value.scope as ChatCredential['scope'], + } + } + + if (type === 'folder_access' || type === 'browser_takeover' || type === 'terminal_handoff') { + if (value.name !== undefined && typeof value.name !== 'string') return null + return { type, provider, name: cleanOptionalString(value.name) } + } + + if (type === 'service_account') { + if (!provider?.trim()) return null + if ( + value.credentialId !== undefined && + (typeof value.credentialId !== 'string' || !sanitizeServerString(value.credentialId).trim()) + ) { + return null + } + return { + type, + provider, + credentialId: cleanOptionalString(value.credentialId), + } + } + + if (type === 'sim_key') return { type, provider } + if (typeof value.value !== 'string') return null + if (type === 'link' && TERMINAL_CONTROL_PATTERN.test(value.value)) return null + return { type, provider, value: sanitizeServerString(value.value) } +} + +function cleanLinkIdentifier(value: unknown): string | undefined { + if (typeof value !== 'string' || TERMINAL_CONTROL_PATTERN.test(value)) return undefined + const cleaned = sanitizeServerString(value).trim() + return cleaned || undefined +} + +function parseWorkspaceResource(value: unknown): ChatWorkspaceResource | null { + if ( + !isRecord(value) || + (value.type !== 'workflow' && value.type !== 'table' && value.type !== 'file') + ) { + return null + } + if (value.id !== undefined && typeof value.id !== 'string') return null + if (value.path !== undefined && typeof value.path !== 'string') return null + if (value.title !== undefined && typeof value.title !== 'string') return null + + const id = cleanLinkIdentifier(value.id) + const path = cleanLinkIdentifier(value.path) + if ((value.type === 'workflow' || value.type === 'table') && !id) return null + if (value.type === 'file' && !id && !path) return null + + return { + type: value.type, + id, + path, + title: cleanOptionalString(value.title), + } +} + +function parseUsageUpgrade(value: unknown): ChatUsageUpgrade | null { + if (!isRecord(value)) return null + if (typeof value.reason !== 'string' || typeof value.message !== 'string') return null + if (value.action !== 'upgrade_plan' && value.action !== 'increase_limit') return null + return { + reason: sanitizeServerString(value.reason), + action: value.action, + message: sanitizeServerString(value.message), + } +} + +function parseMothershipError(value: unknown): ChatMothershipError | null { + if (!isRecord(value) || typeof value.message !== 'string') return null + if (value.code !== undefined && typeof value.code !== 'string') return null + if (value.provider !== undefined && typeof value.provider !== 'string') return null + return { + message: sanitizeServerString(value.message), + code: cleanOptionalString(value.code), + provider: cleanOptionalString(value.provider), + } +} + +function parseTag(name: OfficialChatTagName, body: string): ChatStructuredSegment | null { + if (name === 'thinking') { + return body.trim() ? { kind: 'thinking', content: sanitizeServerString(body) } : null + } + + const payload = parseJson(body) + if (payload === undefined) return null + + if (name === 'options') { + const choices = parseChoices(payload) + return choices ? { kind: 'options', choices } : null + } + if (name === 'question') { + const questions = parseQuestions(payload) + return questions ? { kind: 'question', questions } : null + } + if (name === 'credential') { + const credential = parseCredential(payload) + return credential ? { kind: 'credential', credential } : null + } + if (name === 'workspace_resource') { + const resource = parseWorkspaceResource(payload) + return resource ? { kind: 'workspace_resource', resource } : null + } + if (name === 'usage_upgrade') { + const upgrade = parseUsageUpgrade(payload) + return upgrade ? { kind: 'usage_upgrade', upgrade } : null + } + + const error = parseMothershipError(payload) + return error ? { kind: 'mothership-error', error } : null +} + +function invalidTagFallback( + name: OfficialChatTagName, + body?: string +): ChatStructuredSegment | null { + if (name === 'thinking') return null + if (name === 'credential') { + return { kind: 'text', text: 'Open Sim to complete the requested credential action.' } + } + // Keep an internal empty marker so renderers can discard whitespace that was + // emitted before a malformed or incomplete suggestions wrapper. The marker + // itself still renders as nothing and never becomes an interaction. + if (name === 'options') return { kind: 'options', choices: [] } + if (name === 'question') { + return { + kind: 'text', + text: + (body === undefined ? null : recoverQuestionPrompts(body)) ?? + 'Sim Chat requested an interactive response.', + } + } + if (name === 'workspace_resource') { + return { kind: 'text', text: 'Sim Chat referenced a workspace resource.' } + } + if (name === 'usage_upgrade') return { kind: 'text', text: 'Usage limit reached.' } + return { kind: 'text', text: 'Sim Chat reported an error.' } +} + +function nextMarkdownDelimiter(value: string, initialDelimiter: number): number { + let delimiter = initialDelimiter + let index = 0 + while (index < value.length) { + if (value[index] !== '`') { + index += 1 + continue + } + let end = index + 1 + while (end < value.length && value[end] === '`') end += 1 + const runLength = end - index + if (delimiter === 0) delimiter = runLength + else if (runLength >= delimiter) delimiter = 0 + index = end + } + return delimiter +} + +function findOpeningTag(value: string, initialDelimiter: number): OpeningTagMatch | null { + let delimiter = initialDelimiter + let index = 0 + while (index < value.length) { + if (value[index] === '`') { + let end = index + 1 + while (end < value.length && value[end] === '`') end += 1 + const runLength = end - index + if (delimiter === 0) delimiter = runLength + else if (runLength >= delimiter) delimiter = 0 + index = end + continue + } + + if (delimiter === 0 && value[index] === '<') { + for (const opening of OPENING_TAGS) { + if (value.startsWith(opening.marker, index)) return { index, name: opening.name } + } + } + index += 1 + } + return null +} + +function findClosingTag(value: string, start: number, name: OfficialChatTagName): number { + const closing = `</${name}>` + if (name === 'thinking') return value.indexOf(closing, start) + + let inString = false + let escaped = false + for (let index = start; index < value.length; index += 1) { + const character = value[index] + if (inString) { + if (escaped) escaped = false + else if (character === '\\') escaped = true + else if (character === '"') inString = false + continue + } + if (character === '"') { + inString = true + continue + } + if (value.startsWith(closing, index)) return index + } + return -1 +} + +function trailingBacktickRun(value: string): number { + let index = value.length + while (index > 0 && value[index - 1] === '`') index -= 1 + return value.length - index +} + +function partialOpeningSuffix(value: string): number { + let longest = 0 + for (const { marker } of OPENING_TAGS) { + const limit = Math.min(marker.length - 1, value.length) + for (let length = limit; length > longest; length -= 1) { + if (value.endsWith(marker.slice(0, length))) { + longest = length + break + } + } + } + return longest +} + +function appendText(segments: ChatStructuredSegment[], text: string): void { + if (!text) return + const previous = segments[segments.length - 1] + if (previous?.kind === 'text') previous.text += text + else segments.push({ kind: 'text', text }) +} + +/** + * Incrementally parses structured Sim Chat tags while retaining possible openers + * and incomplete wrappers across arbitrary transport chunk boundaries. + */ +export class ChatStructuredParser { + private buffer = '' + private markdownDelimiter = 0 + private finished = false + + push(fragment: string): ChatStructuredSegment[] { + if (this.finished) throw new Error('Cannot push to a finished chat parser.') + this.buffer += sanitizeServerString(fragment) + return this.drain(false) + } + + finish(): ChatStructuredSegment[] { + if (this.finished) return [] + this.finished = true + return this.drain(true) + } + + private consumeText(length: number, segments: ChatStructuredSegment[]): void { + const text = this.buffer.slice(0, length) + this.buffer = this.buffer.slice(length) + this.markdownDelimiter = nextMarkdownDelimiter(text, this.markdownDelimiter) + appendText(segments, text) + } + + private drain(final: boolean): ChatStructuredSegment[] { + const segments: ChatStructuredSegment[] = [] + + while (this.buffer) { + const opening = findOpeningTag(this.buffer, this.markdownDelimiter) + if (!opening) { + const retained = final + ? 0 + : Math.max(partialOpeningSuffix(this.buffer), trailingBacktickRun(this.buffer)) + const consumable = this.buffer.length - retained + if (consumable > 0) this.consumeText(consumable, segments) + break + } + + if (opening.index > 0) { + this.consumeText(opening.index, segments) + continue + } + + const openingMarker = `<${opening.name}>` + const closingMarker = `</${opening.name}>` + const closingIndex = findClosingTag(this.buffer, openingMarker.length, opening.name) + if (closingIndex === -1) { + if (final) { + if (opening.name === 'thinking') { + // Thinking is intentionally hidden from terminal output. If the stream + // ends before the wrapper closes, fail closed instead of exposing its + // potentially private contents as ordinary text. + this.buffer = '' + } else { + const body = this.buffer.slice(openingMarker.length) + this.buffer = '' + const fallback = invalidTagFallback(opening.name, body) + if (fallback) segments.push(fallback) + } + } + break + } + + const end = closingIndex + closingMarker.length + const body = this.buffer.slice(openingMarker.length, closingIndex) + const parsed = parseTag(opening.name, body) + if (!parsed) { + this.buffer = this.buffer.slice(end) + const fallback = invalidTagFallback(opening.name, body) + if (fallback) segments.push(fallback) + continue + } + + this.buffer = this.buffer.slice(end) + segments.push(parsed) + } + + return segments + } +} + +/** Parses a completed Sim Chat response into sanitized structured segments. */ +export function parseChatStructured(content: string): ChatStructuredSegment[] { + const parser = new ChatStructuredParser() + return [...parser.push(content), ...parser.finish()] +} + +function resourceLabel(resource: ChatWorkspaceResource): string { + const title = oneLine(resource.title ?? '') + if (title) return title + if (resource.type === 'file') return oneLine(resource.path ?? resource.id ?? 'File') || 'File' + return resource.type === 'workflow' ? 'Workflow' : 'Table' +} + +function renderResource(resource: ChatWorkspaceResource): string { + return resourceLabel(resource) +} + +function renderCredential(credential: ChatCredential): string { + const provider = oneLine(credential.provider ?? '') || 'account' + const name = oneLine(credential.name ?? '') + + if (credential.type === 'link' && credential.value) { + return `Open Sim to connect ${provider}.` + } + if (credential.type === 'service_account') { + return `Open Sim to connect ${provider} with a service account.` + } + if (credential.type === 'secret_input') { + return `Open Sim to provide ${name || 'the requested secret'}.` + } + if (credential.type === 'folder_access') { + return `Open Sim Desktop to grant access to ${name || 'the requested folder'}.` + } + if (credential.type === 'browser_takeover') { + return `Open Sim Desktop to continue ${name || 'the browser task'}.` + } + if (credential.type === 'terminal_handoff') { + return `Open Sim Desktop to continue ${name || 'the terminal task'}.` + } + if (credential.type === 'env_key') { + return `Open Sim to configure ${provider} environment credentials.` + } + if (credential.type === 'oauth_key') return `Open Sim to connect ${provider} with OAuth.` + if (credential.type === 'credential_id') return `Open Sim to select ${provider} credentials.` + if (credential.type === 'sim_key') return 'Open Sim to configure a Sim API key.' + return `Open Sim to configure ${provider} credentials.` +} + +function renderQuestions(questions: ChatQuestion[]): string { + return questions.map((question) => oneLine(question.prompt)).join('\n\n') +} + +function addPart(parts: RenderPart[], value: string, block: boolean): void { + if (value) parts.push({ block, value: sanitizeServerString(value) }) +} + +function trimRenderedEnd(parts: RenderPart[]): void { + while (parts.length > 0) { + const last = parts[parts.length - 1] + last.value = last.value.trimEnd() + if (last.value) return + parts.pop() + } +} + +function joinRenderParts(parts: RenderPart[]): string { + let output = '' + let previous: RenderPart | undefined + for (const part of parts) { + if (output && (part.block || previous?.block)) { + const trailing = output.match(/\n*$/u)?.[0].length ?? 0 + const leading = part.value.match(/^\n*/u)?.[0].length ?? 0 + output += '\n'.repeat(Math.max(0, 2 - trailing - leading)) + } + output += part.value + previous = part + } + return output +} + +/** + * Renders structured chat as deterministic terminal-safe text. + */ +export function renderChatStructured( + input: string | readonly ChatStructuredSegment[], + options: ChatStructuredRenderOptions = {} +): ChatStructuredRenderResult { + const segments = typeof input === 'string' ? parseChatStructured(input) : input + const printMode = options.printMode !== false + const parts: RenderPart[] = [] + const interactions: ChatInteraction[] = [] + let strippedOptions = false + + for (const segment of segments) { + if (segment.kind === 'text') { + const text = strippedOptions ? segment.text.replace(/^\s+/u, '') : segment.text + if (strippedOptions && !text) continue + strippedOptions = false + addPart(parts, text, false) + continue + } + if (segment.kind === 'thinking') continue + if (segment.kind === 'workspace_resource') { + addPart(parts, renderResource(segment.resource), false) + continue + } + if (segment.kind === 'options') { + // Follow-up suggestions are browser UI metadata, not answer text. The + // terminal composer stays ordinary free-form input, so omit them fully. + trimRenderedEnd(parts) + strippedOptions = true + continue + } + strippedOptions = false + if (segment.kind === 'question') { + const questions = segment.questions.map((question) => ({ + type: question.type, + prompt: oneLine(question.prompt), + options: question.options.map((option) => ({ + id: oneLine(option.id), + label: oneLine(option.label), + })), + })) + interactions.push({ kind: 'question', questions }) + if (printMode) addPart(parts, renderQuestions(questions), true) + continue + } + if (segment.kind === 'credential') { + addPart(parts, renderCredential(segment.credential), true) + continue + } + if (segment.kind === 'usage_upgrade') { + addPart(parts, `Usage limit reached: ${oneLine(segment.upgrade.message)}`, true) + continue + } + addPart( + parts, + `${oneLine(segment.error.message)}${segment.error.code ? ` (${oneLine(segment.error.code)})` : ''}`, + true + ) + } + + return { text: joinRenderParts(parts), interactions, parts } +} diff --git a/packages/sim-cli/src/commands/protocol/chat-suggestions.test.ts b/packages/sim-cli/src/commands/protocol/chat-suggestions.test.ts new file mode 100644 index 00000000000..f271ff071dd --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-suggestions.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest' +import { + applySuggestion, + type ChatContext, + contextSpans, + extractCompletionToken, + formatMention, + presentContexts, + rankSuggestions, + resolveSlashContexts, + SLASH_COMMANDS, + type SuggestionItem, + suggestionWindow, +} from './chat-suggestions.js' + +const item = (value: string, description?: string): SuggestionItem => ({ + id: value, + value, + displayText: value, + description, +}) + +describe('slash commands', () => { + it('offers chat switching and renaming without requiring arguments to open the menu', () => { + expect(SLASH_COMMANDS).toEqual( + expect.arrayContaining([ + expect.objectContaining({ value: '/chats', displayText: '/chats' }), + expect.objectContaining({ value: '/rename', displayText: '/rename <title>' }), + ]) + ) + }) +}) + +describe('extractCompletionToken', () => { + it('opens a slash context at the start of any token', () => { + expect(extractCompletionToken('/att', 4)).toMatchObject({ trigger: '/', query: 'att' }) + expect(extractCompletionToken('hi /att', 7)).toMatchObject({ + trigger: '/', + query: 'att', + startPos: 3, + }) + }) + + it('closes the slash context once an argument is typed', () => { + expect(extractCompletionToken('/attach ', 8)).toBeNull() + }) + + it('opens a mention at the start or after whitespace', () => { + expect(extractCompletionToken('@rev', 4)).toMatchObject({ + trigger: '@', + query: 'rev', + startPos: 0, + }) + expect(extractCompletionToken('use @rev', 8)).toMatchObject({ + trigger: '@', + query: 'rev', + startPos: 4, + }) + }) + + it('closes a mention once a slash is typed, matching the client editor', () => { + expect(extractCompletionToken('@logs/incident', 14)).toBeNull() + }) + + it('does not treat an email address as a mention', () => { + expect(extractCompletionToken('mail foo@bar.com', 16)).toBeNull() + }) + + it('reads from the cursor, not the end of the draft', () => { + expect(extractCompletionToken('@rev trailing', 4)).toMatchObject({ query: 'rev' }) + }) + + it('returns null for a bare draft', () => { + expect(extractCompletionToken('hello world', 11)).toBeNull() + }) +}) + +describe('rankSuggestions', () => { + const candidates = [ + item('attach'), + item('clear'), + item('help'), + item('paste-image'), + item('chat'), + ] + + it('returns everything for an empty query', () => { + expect(rankSuggestions('', candidates)).toHaveLength(5) + }) + + it('preserves source order while filtering by substring', () => { + const filtered = rankSuggestions('c', [item('clear'), item('c'), item('chat')]) + expect(filtered.map((entry) => entry.value)).toEqual(['clear', 'c', 'chat']) + }) + + it('matches substrings but not fuzzy subsequences', () => { + expect(rankSuggestions('image', candidates)[0]?.value).toBe('paste-image') + expect(rankSuggestions('pti', candidates)).toEqual([]) + }) + + it('does not search descriptions', () => { + expect( + rankSuggestions('clipboard', [item('paste-image', 'attach from the clipboard')]) + ).toEqual([]) + }) + + it('drops non-matches', () => { + expect(rankSuggestions('zzz', candidates)).toEqual([]) + }) +}) + +describe('suggestionWindow', () => { + it('shows everything when the list fits', () => { + expect(suggestionWindow(3, 0, 5)).toEqual({ start: 0, end: 3 }) + }) + + it('centres the window on the selection', () => { + expect(suggestionWindow(20, 10, 5)).toEqual({ start: 8, end: 13 }) + }) + + it('clamps at both ends', () => { + expect(suggestionWindow(20, 0, 5)).toEqual({ start: 0, end: 5 }) + expect(suggestionWindow(20, 19, 5)).toEqual({ start: 15, end: 20 }) + }) +}) + +describe('applySuggestion', () => { + it('replaces the trigger token and leaves a trailing space', () => { + const token = extractCompletionToken('/att', 4) + expect(token).not.toBeNull() + expect(applySuggestion('/att', token!, '/attach')).toEqual({ draft: '/attach ', cursor: 8 }) + }) + + it('preserves text after the cursor without doubling the separator', () => { + const token = extractCompletionToken('use @rev and go', 8) + expect(applySuggestion('use @rev and go', token!, '@reviewer')).toEqual({ + draft: 'use @reviewer and go', + cursor: 13, + }) + }) +}) + +describe('formatMention', () => { + it('matches the client literal insertion for single and multiword labels', () => { + expect(formatMention('reviewer')).toBe('@reviewer') + expect(formatMention('code reviewer')).toBe('@code reviewer') + }) +}) + +describe('structured tag contexts', () => { + const workflow: ChatContext = { + kind: 'workflow', + workflowId: 'workflow-1', + label: 'Release notes', + } + const skill: ChatContext = { kind: 'skill', skillId: 'skill-1', label: 'review' } + const mcp: ChatContext = { kind: 'mcp', serverId: 'mcp-1', label: 'review' } + + it('finds literal multiword resource and slash spans', () => { + expect(contextSpans('use @Release notes with /review', [workflow, skill])).toEqual([ + { start: 4, end: 18 }, + { start: 24, end: 31 }, + ]) + }) + + it('drops a selected context when its exact token is gone', () => { + expect(presentContexts('use @Release notes', [workflow])).toEqual([workflow]) + expect(presentContexts('use @Release note', [workflow])).toEqual([]) + }) + + it('auto-resolves typed slash tags with skill precedence over a same-name MCP', () => { + const candidates: SuggestionItem[] = [ + { id: 'skill', value: 'review', displayText: '/review', context: skill }, + { id: 'mcp', value: 'review', displayText: '/review', context: mcp }, + ] + expect(resolveSlashContexts('please /REVIEW this', candidates)).toEqual([skill]) + expect(resolveSlashContexts('path/to/review', candidates)).toEqual([]) + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat-suggestions.ts b/packages/sim-cli/src/commands/protocol/chat-suggestions.ts new file mode 100644 index 00000000000..c4ccbf17cf5 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-suggestions.ts @@ -0,0 +1,223 @@ +/** + * Composer autocomplete: trigger detection, ranking and windowing. + * + * Pure and ANSI-free so it can be unit tested without a terminal; the caller + * owns painting. Tags stay plain draft text while their selected identities + * travel beside the draft. Submit keeps an identity only while its exact tag + * remains, so a half-deleted tag degrades to literal text instead of a dangling + * resource reference. + */ + +import type { ChatBody } from '../../generated/v2-api.js' + +export type ChatContext = NonNullable<ChatBody['contexts']>[number] + +/** One row in the suggestion list. */ +export interface SuggestionItem { + /** Stable across refreshes — selection is tracked by id, never by index. */ + id: string + /** What the user picks, and what gets written into the draft. */ + value: string + displayText: string + description?: string + tag?: string + /** Exact identity sent beside the prompt when this tag remains present. */ + context?: ChatContext +} + +export interface ChatSuggestionCandidates { + resources: SuggestionItem[] + slash: SuggestionItem[] +} + +export interface CompletionToken { + /** Includes the trigger character. */ + token: string + /** Index of the trigger character within the draft. */ + startPos: number + /** Text after the trigger, i.e. what to filter on. */ + query: string + trigger: '/' | '@' +} + +const MENTION_QUERY_BOUNDARY = /[\s.,;:!?(){}[\]"'`/\\<>]/u +const SLASH_QUERY_BOUNDARY = /[\s.,;:!?(){}[\]"'`\\<>]/u +const TAG_END_BOUNDARY = /^[\s.,;:!?(){}[\]"'`/\\<>]/u + +/** + * Walk backwards from the cursor to find an open completion context. + * + * A trigger only counts at the start of the draft or after whitespace, so an + * email address in prose cannot open a mention. Returns null when the cursor is + * not inside a completion. + */ +export function extractCompletionToken(text: string, cursor: number): CompletionToken | null { + const before = text.slice(0, Math.max(0, Math.min(cursor, text.length))) + for (let index = before.length - 1; index >= 0; index -= 1) { + const trigger = before[index] + if (trigger !== '@' && trigger !== '/') continue + if (index > 0 && !/\s/u.test(before[index - 1] as string)) continue + + const query = before.slice(index + 1) + const boundary = trigger === '@' ? MENTION_QUERY_BOUNDARY : SLASH_QUERY_BOUNDARY + if (boundary.test(query)) return null + return { token: before.slice(index), startPos: index, query, trigger } + } + return null +} + +/** + * Filter like the home composer: a case-insensitive name substring while + * preserving source order. A leading trigger on local CLI commands is ignored. + */ +export function rankSuggestions(query: string, candidates: SuggestionItem[]): SuggestionItem[] { + const needle = query.trim().toLowerCase() + if (!needle) return [...candidates] + return candidates.filter((item) => + item.value.replace(/^[/@]/u, '').toLowerCase().includes(needle) + ) +} + +/** + * Visible slice for a list taller than the panel, centred on the selection so + * the cursor sits mid-list rather than only scrolling at the edges. + */ +export function suggestionWindow( + total: number, + selected: number, + maxVisible: number +): { start: number; end: number } { + if (total <= maxVisible) return { start: 0, end: total } + const start = Math.max(0, Math.min(selected - Math.floor(maxVisible / 2), total - maxVisible)) + return { start, end: start + maxVisible } +} + +/** The home composer inserts literal multiword labels and carries identity separately. */ +export function formatMention(value: string): string { + return `@${value}` +} + +/** Splice an accepted suggestion over the trigger token. */ +export function applySuggestion( + draft: string, + token: CompletionToken, + replacement: string +): { draft: string; cursor: number } { + const head = draft.slice(0, token.startPos) + const tail = draft.slice(token.startPos + token.token.length) + /* Only add the separating space when the draft does not already have one. */ + const inserted = /^\s/.test(tail) ? replacement : `${replacement} ` + return { draft: `${head}${inserted}${tail}`, cursor: head.length + inserted.length } +} + +export function contextToken(context: ChatContext): string { + return `${context.kind === 'skill' || context.kind === 'mcp' ? '/' : '@'}${context.label}` +} + +function hasContextToken(text: string, context: ChatContext): boolean { + const token = contextToken(context).toLowerCase() + const haystack = text.toLowerCase() + let start = haystack.indexOf(token) + while (start >= 0) { + const before = start === 0 ? '' : text[start - 1] + const after = text[start + token.length] + if ((!before || /\s/u.test(before)) && (!after || TAG_END_BOUNDARY.test(after))) return true + start = haystack.indexOf(token, start + 1) + } + return false +} + +/** Keeps selected identities only while their exact visible tag remains. */ +export function presentContexts(text: string, contexts: ChatContext[]): ChatContext[] { + return contexts.filter((context) => hasContextToken(text, context)) +} + +/** + * Mirrors the client's typed/pasted `/name` auto-registration. Candidate order + * is significant: skills precede MCP servers, so a same-name skill wins. + */ +export function resolveSlashContexts(text: string, candidates: SuggestionItem[]): ChatContext[] { + const contexts: ChatContext[] = [] + const seenLabels = new Set<string>() + for (const candidate of candidates) { + const context = candidate.context + if (!context || (context.kind !== 'skill' && context.kind !== 'mcp')) continue + const label = context.label.toLowerCase() + if (seenLabels.has(label)) continue + seenLabels.add(label) + if (hasContextToken(text, context)) contexts.push(context) + } + return contexts +} + +/** Exact context-backed tags to paint as chips, longest first for overlaps. */ +export function contextSpans( + text: string, + contexts: ChatContext[] +): Array<{ start: number; end: number }> { + const tokens = [...new Set(contexts.map(contextToken))].sort( + (left, right) => right.length - left.length + ) + const ranges: Array<{ start: number; end: number }> = [] + const lower = text.toLowerCase() + for (const token of tokens) { + const needle = token.toLowerCase() + let start = lower.indexOf(needle) + while (start >= 0) { + const before = start === 0 ? '' : text[start - 1] + const after = text[start + token.length] + const overlaps = ranges.some( + (range) => start < range.end && start + token.length > range.start + ) + if ( + (!before || /\s/u.test(before)) && + (!after || TAG_END_BOUNDARY.test(after)) && + !overlaps + ) { + ranges.push({ start, end: start + token.length }) + } + start = lower.indexOf(needle, start + 1) + } + } + return ranges.sort((left, right) => left.start - right.start) +} + +/** Composer slash commands, the source for the `/` menu. */ +export const SLASH_COMMANDS: SuggestionItem[] = [ + { + id: 'attach', + value: '/attach', + displayText: '/attach <paths>', + description: 'attach local files to the next turn', + tag: 'command', + }, + { + id: 'clear', + value: '/clear', + displayText: '/clear', + description: 'start a new conversation', + tag: 'command', + }, + { + id: 'chats', + value: '/chats', + displayText: '/chats', + description: 'view and switch chats', + tag: 'command', + }, + { + id: 'rename', + value: '/rename', + displayText: '/rename <title>', + description: 'rename the active chat', + tag: 'command', + }, + { id: 'help', value: '/help', displayText: '/help', description: 'show help', tag: 'command' }, + { + id: 'exit', + value: '/exit', + displayText: '/exit', + description: 'leave Sim Chat (alias: /quit)', + tag: 'command', + }, +] diff --git a/packages/sim-cli/src/commands/protocol/chat-terminal.test.ts b/packages/sim-cli/src/commands/protocol/chat-terminal.test.ts new file mode 100644 index 00000000000..089ffc54601 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-terminal.test.ts @@ -0,0 +1,2148 @@ +import { PassThrough } from 'node:stream' +import { Terminal as HeadlessTerminal } from '@xterm/headless' +import { describe, expect, it, vi } from 'vitest' +import { ReadlineChatTerminal } from './chat-terminal.js' + +interface TTYInput extends PassThrough { + isTTY: boolean + isRaw: boolean + setRawMode: ReturnType<typeof vi.fn<(mode: boolean) => void>> +} + +interface TTYOutput extends PassThrough { + isTTY: boolean + columns: number + rows: number +} + +function terminalStreams( + columns = 80, + rows = 24 +): { + input: TTYInput + output: TTYOutput + chunks: string[] +} { + const input = new PassThrough() as TTYInput + input.isTTY = true + input.isRaw = false + input.setRawMode = vi.fn((mode: boolean) => { + input.isRaw = mode + }) + + const output = new PassThrough() as TTYOutput + output.isTTY = true + output.columns = columns + output.rows = rows + const chunks: string[] = [] + output.on('data', (chunk) => chunks.push(String(chunk))) + return { input, output, chunks } +} + +function key(input: TTYInput, character: string, value: Record<string, unknown>): void { + input.emit('keypress', character, value) +} + +function mirrorToHeadless( + output: TTYOutput, + columns: number, + rows: number +): { terminal: HeadlessTerminal; flush: () => Promise<void> } { + const terminal = new HeadlessTerminal({ cols: columns, rows, allowProposedApi: true }) + let writes = Promise.resolve() + output.on('data', (chunk) => { + writes = writes.then( + () => new Promise<void>((resolve) => terminal.write(String(chunk), resolve)) + ) + }) + return { terminal, flush: () => writes } +} + +function paintedPayloads(frame: string): string[] { + const starts = [...frame.matchAll(/\u001b\[\d+;1H\u001b\[2K/gu)] + return starts.map((start, index) => { + const contentStart = (start.index ?? 0) + start[0].length + const nextPaint = starts[index + 1]?.index ?? frame.length + const remainder = frame.slice(contentStart, nextPaint) + const nextControl = remainder.search(/\u001b\[\d+;\d+H|\u001b\[\?25[hl]|\u001b\[\?2026l/u) + return remainder.slice(0, nextControl < 0 ? remainder.length : nextControl) + }) +} + +function payloadDisplayWidth(value: string): number { + const plain = value.replace(/\u001b\[[0-9;:]*m/gu, '') + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + let width = 0 + for (const { segment } of segmenter.segment(plain)) { + if (/^\p{Mark}+$/u.test(segment)) continue + const codePoint = segment.codePointAt(0) ?? 0 + const wide = + segment.includes('\u200d') || + /\p{Extended_Pictographic}/u.test(segment) || + (codePoint >= 0x1100 && + (codePoint <= 0x115f || + (codePoint >= 0x2e80 && codePoint <= 0xa4cf) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xff00 && codePoint <= 0xff60) || + (codePoint >= 0x1f300 && codePoint <= 0x1faff))) + width += wide ? 2 : 1 + } + return width +} + +function plainTerminalText(value: string): string { + return value.replace(/\u001b\[[0-9;:]*m/gu, '') +} + +function visibleTerminalLines(terminal: HeadlessTerminal, rows: number): string[] { + return Array.from( + { length: rows }, + (_, row) => + terminal.buffer.active + .getLine(row) + ?.translateToString(true) + .replace(/\u00a0/gu, ' ') + .trimEnd() ?? '' + ) +} + +function expectUserPanelRow(terminal: HeadlessTerminal, row: number, columns: number): void { + const line = terminal.buffer.active.getLine(row) + expect(line).toBeDefined() + expect(line?.getCell(0)?.isBgDefault()).toBe(true) + for (let column = 1; column < columns - 2; column += 1) { + const cell = line?.getCell(column) + expect(cell?.isBgRGB()).toBe(true) + expect(cell?.getBgColor()).toBe(0x3a3c46) + } + expect(line?.getCell(columns - 2)?.isBgDefault()).toBe(true) + expect(line?.getCell(columns - 1)?.isBgDefault()).toBe(true) +} + +describe('ReadlineChatTerminal', () => { + it('opens with the active chat and switch hint, then reflows for narrow terminals', () => { + const { input, output, chunks } = terminalStreams(80, 16) + const terminal = new ReadlineChatTerminal(input, output) + + terminal.welcome({ chatTitle: 'New chat\u001b]0;owned\u0007' }) + + const wideFrame = chunks.at(-1) ?? '' + expect(wideFrame).toContain('\u001b[97m') + expect(wideFrame).toContain('⠤⠶⠶⠮⣤⣽⠤⠴⡋ ⢘⡦⠤⢤⡏ ⠹⣄⣀⣀⡴⠋⠉⢹⢺⡄') + expect(wideFrame).toContain(' ⠈⠓⠤⢄⣹⡤⠤⢜ ⠳⣄⣀⣀⡞ ⢙⣦⣖⠾⠋') + expect(wideFrame).not.toContain('▐██▄███████████▌') + expect(wideFrame).not.toContain('\u001b[38;2;128;47;222m') + expect(wideFrame).toContain('\u001b[1mSim Chat\u001b[0m') + expect(wideFrame).toContain('╭') + expect(wideFrame).toContain('╰') + expect(wideFrame).toContain('chat: New chat') + expect(wideFrame).not.toContain('workspace') + expect(wideFrame).not.toContain('owned') + const welcomeRows = paintedPayloads(wideFrame).map(plainTerminalText) + expect(welcomeRows.findIndex((row) => row.includes('profile:'))).toBe( + welcomeRows.findIndex((row) => row.includes('Sim Chat')) + 1 + ) + + terminal.setChatTitle('Release investigation') + expect(chunks.at(-1) ?? '').toContain('chat: Release investigation') + + output.columns = 30 + output.emit('resize') + const narrowFrame = chunks.at(-1) ?? '' + expect(narrowFrame).toContain(' ⠈⠉⠉⣝⣀⣀⣚⣀⡔⠒⠛⠓⠊⠉⠉') + expect(narrowFrame).not.toContain('▐██▄███████████▌') + expect(narrowFrame).toContain('Sim Chat') + expect(narrowFrame).toContain('chat Release investigation') + expect(narrowFrame).not.toContain('ws_local') + expect(narrowFrame).not.toContain('╭') + terminal.close() + }) + + it('pins a balanced padded composer to the bottom of an alternate-screen viewport', async () => { + const columns = 80 + const rows = 14 + const { input, output, chunks } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + const result = terminal.read('❯ ') + + input.write('hello') + await screen.flush() + + const buffer = screen.terminal.buffer.active + const lines = visibleTerminalLines(screen.terminal, rows) + expect(lines[rows - 3]).toBe(' ❯ hello') + expect(lines[rows - 1]).toBe('') + for (const row of [rows - 4, rows - 3, rows - 2]) { + expectUserPanelRow(screen.terminal, row, columns) + } + expect( + buffer + .getLine(rows - 3) + ?.getCell(0) + ?.getChars() + ).toBe(' ') + expect( + buffer + .getLine(rows - 3) + ?.getCell(1) + ?.getChars() + ).toBe('❯') + expect( + buffer + .getLine(rows - 3) + ?.getCell(1) + ?.getFgColor() + ).toBe(0xa0a0a0) + expect( + buffer + .getLine(rows - 3) + ?.getCell(3) + ?.getFgColor() + ).toBe(0xf2f2f2) + expect( + buffer + .getLine(rows - 3) + ?.getCell(columns - 3) + ?.getChars() + ).toBe(' ') + expect( + buffer + .getLine(rows - 1) + ?.getCell(0) + ?.isBgDefault() + ).toBe(true) + expect(buffer.cursorY).toBe(rows - 3) + expect(buffer.cursorX).toBe(8) + + input.write('\r') + + await expect(result).resolves.toEqual({ kind: 'line', value: 'hello' }) + const rendered = chunks.join('') + expect(rendered).toContain('\u001b[?1049h') + + terminal.close() + await screen.flush() + expect(chunks.join('')).toContain('\u001b[?1049l') + expect(input.setRawMode).toHaveBeenNthCalledWith(1, true) + expect(input.setRawMode).toHaveBeenLastCalledWith(false) + expect(input.isPaused()).toBe(true) + screen.terminal.dispose() + }) + + it('submits an exact slash command with one Enter', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + const result = terminal.read('❯ ') + + input.write('/exit\r') + + await expect(result).resolves.toEqual({ kind: 'line', value: '/exit' }) + terminal.close() + }) + + it('keeps the composer background continuous across a highlighted mention', async () => { + const columns = 50 + const rows = 12 + const { input, output } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + terminal.setSuggestionCandidates({ + resources: [ + { + id: 'workflow:workflow-1', + value: 'Release', + displayText: 'Release', + context: { + kind: 'workflow', + workflowId: 'workflow-1', + label: 'Release', + }, + }, + ], + slash: [], + }) + void terminal.read('❯ ') + + input.write('@rel\tthen') + await screen.flush() + + const composerRow = visibleTerminalLines(screen.terminal, rows).findIndex((line) => + line?.startsWith(' ❯ @Release then') + ) + expect(composerRow).toBeGreaterThanOrEqual(0) + expectUserPanelRow(screen.terminal, composerRow, columns) + expect(screen.terminal.buffer.active.getLine(composerRow)?.getCell(3)?.isFgRGB()).toBe(true) + expect(screen.terminal.buffer.active.getLine(composerRow)?.getCell(12)?.getFgColor()).toBe( + 0xf2f2f2 + ) + expect(screen.terminal.buffer.active.getLine(composerRow)?.getCell(12)?.isFgRGB()).toBe(true) + + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('submits the exact resource identity selected from @ with literal client text', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + terminal.setSuggestionCandidates({ + resources: [ + { + id: 'workflow:workflow-1', + value: 'Release notes', + displayText: 'Release notes', + context: { + kind: 'workflow', + workflowId: 'workflow-1', + label: 'Release notes', + }, + }, + ], + slash: [], + }) + const result = terminal.read('❯ ') + + input.write('@rel\t\r') + + await expect(result).resolves.toEqual({ + kind: 'line', + value: '@Release notes ', + contexts: [ + { + kind: 'workflow', + workflowId: 'workflow-1', + label: 'Release notes', + }, + ], + }) + terminal.close() + }) + + it('sanitizes server-provided suggestion text before rendering or submitting it', async () => { + const { input, output, chunks } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + const injected = '\u001b]2;suggestion-owned\u0007' + terminal.setSuggestionCandidates({ + resources: [ + { + id: 'workflow:workflow-1', + value: `Release${injected}\nnotes`, + displayText: `Release${injected}\nnotes`, + description: `workflow${injected}`, + context: { + kind: 'workflow', + workflowId: 'workflow-1', + label: `Release${injected}\nnotes`, + }, + }, + ], + slash: [], + }) + const result = terminal.read('❯ ') + + input.write('@rel\t\r') + + await expect(result).resolves.toEqual({ + kind: 'line', + value: '@Release notes ', + contexts: [ + { + kind: 'workflow', + workflowId: 'workflow-1', + label: 'Release notes', + }, + ], + }) + expect(chunks.join('')).not.toContain('suggestion-owned') + terminal.close() + }) + + it('clips long suggestion labels before the description column', () => { + const { input, output } = terminalStreams(50, 14) + const terminal = new ReadlineChatTerminal(input, output) + terminal.setSuggestionCandidates({ + resources: [ + { + id: 'logs:execution-1', + value: 'x'.repeat(80), + displayText: 'x'.repeat(80), + description: 'log', + tag: 'logs', + context: { + kind: 'logs', + executionId: 'execution-1', + label: 'x'.repeat(80), + }, + }, + ], + slash: [], + }) + void terminal.read('❯ ') + input.write('@') + + const probe = terminal as never as { buildPanel(rows: number): { lines: string[] } } + const row = probe + .buildPanel(14) + .lines.find((line) => line.replace(/\u001b\[[0-9;:]*m/gu, '').includes('log')) + expect(row?.replace(/\u001b\[[0-9;:]*m/gu, '')).toMatch(/… {2}log/u) + terminal.close() + }) + + it('shows recent logs at the top level after the other @ resources', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + terminal.setSuggestionCandidates({ + resources: [ + { + id: 'workflow:workflow-1', + value: 'Release workflow', + displayText: 'Release workflow', + tag: 'workflow', + context: { + kind: 'workflow', + workflowId: 'workflow-1', + label: 'Release workflow', + }, + }, + { + id: 'logs:execution-1', + value: 'Incident run', + displayText: 'Incident run', + description: 'log', + tag: 'logs', + context: { + kind: 'logs', + executionId: 'execution-1', + label: 'Incident run', + }, + }, + ], + slash: [], + }) + const result = terminal.read('❯ ') + const probe = terminal as never as { buildPanel(rows: number): { lines: string[] } } + + input.write('@') + const bare = probe.buildPanel(14).lines.map((line) => line.replace(/\u001b\[[0-9;:]*m/gu, '')) + const workflowRow = bare.findIndex((line) => line.includes('Release workflow')) + const logRow = bare.findIndex((line) => line.includes('Incident run')) + expect(workflowRow).toBeGreaterThanOrEqual(0) + expect(logRow).toBeGreaterThan(workflowRow) + expect(bare.some((line) => line.includes('logs/'))).toBe(false) + + key(input, '', { name: 'down', sequence: '\u001b[B' }) + input.write('\t\r') + await expect(result).resolves.toMatchObject({ + kind: 'line', + value: '@Incident run ', + contexts: [ + { + kind: 'logs', + executionId: 'execution-1', + label: 'Incident run', + }, + ], + }) + terminal.close() + }) + + it('reopens @ suggestions after the trigger is removed and retyped', () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + terminal.setSuggestionCandidates({ + resources: [ + { + id: 'workflow:workflow-1', + value: 'Release workflow', + displayText: 'Release workflow', + context: { + kind: 'workflow', + workflowId: 'workflow-1', + label: 'Release workflow', + }, + }, + ], + slash: [], + }) + void terminal.read('❯ ') + const probe = terminal as never as { buildPanel(rows: number): { lines: string[] } } + const hasReleaseSuggestion = () => + probe + .buildPanel(14) + .lines.map(plainTerminalText) + .some((line) => line.includes('Release workflow')) + + input.write('@') + expect(hasReleaseSuggestion()).toBe(true) + + key(input, '', { name: 'escape', sequence: '\u001b' }) + expect(hasReleaseSuggestion()).toBe(false) + key(input, '\u007f', { name: 'backspace', sequence: '\u007f' }) + input.write('@') + + expect(hasReleaseSuggestion()).toBe(true) + terminal.close() + }) + + it('renders suggestions above the status and bottom-pinned composer', async () => { + const columns = 80 + const rows = 14 + const { input, output } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + + const probe = terminal as never as { + buildPanel(rows: number): { lines: string[]; cursor?: { row: number } } + } + const closedPanel = probe.buildPanel(rows) + const closedCursorRow = rows - closedPanel.lines.length + (closedPanel.cursor?.row ?? 0) + + input.write('/') + const panel = probe.buildPanel(rows) + const lines = panel.lines.map((line) => line.replace(/\u001b\[[0-9;:]*m/gu, '')) + const suggestion = lines.findIndex((line) => line.includes('/help')) + const thinking = lines.findIndex((line) => line.includes('Thinking…')) + const composer = lines.findIndex((line) => line.startsWith(' ❯')) + const openCursorRow = rows - panel.lines.length + (panel.cursor?.row ?? 0) + + expect(suggestion).toBeGreaterThanOrEqual(0) + expect(thinking).toBeGreaterThan(suggestion) + expect(thinking).toBe(composer - 2) + expect(lines[composer - 1]).toBe(' ') + expect(lines[composer + 1]).toBe(' ') + expect(composer + 1).toBe(lines.length - 2) + expect(panel.cursor?.row).toBe(composer) + expect(openCursorRow).toBe(closedCursorRow) + expect(lines.at(-1)).toContain('enter to steer · esc to interrupt') + + await screen.flush() + const renderedLines = visibleTerminalLines(screen.terminal, rows) + const renderedThinking = renderedLines.findIndex((line) => line?.includes('Thinking…')) + const renderedComposer = renderedLines.findIndex((line) => line?.startsWith(' ❯ /')) + expect(renderedThinking).toBeGreaterThanOrEqual(0) + expect(renderedComposer).toBe(renderedThinking + 2) + expectUserPanelRow(screen.terminal, renderedComposer - 1, columns) + expectUserPanelRow(screen.terminal, renderedComposer, columns) + expectUserPanelRow(screen.terminal, renderedComposer + 1, columns) + expect(screen.terminal.buffer.active.cursorY).toBe(renderedComposer) + + activity.stop() + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('keeps autocomplete inactive when the terminal is too short to show an option', async () => { + const { input, output } = terminalStreams(80, 4) + const terminal = new ReadlineChatTerminal(input, output) + const result = terminal.read('❯ ') + + input.write('/r\r') + + await expect(result).resolves.toEqual({ kind: 'line', value: '/r' }) + terminal.close() + }) + + it('filters a single-choice menu above a fixed bottom search composer', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + const selected = terminal.select({ + prompt: 'Choose a chat', + options: [ + { id: 'new', label: 'New chat', description: 'start blank' }, + { id: 'release', label: 'Release investigation', description: 'pinned' }, + { id: 'deploy', label: 'Deployment failure', description: 'updated yesterday' }, + ], + }) + const probe = terminal as never as { + buildPanel(rows: number): { lines: string[]; cursor?: { row: number } } + } + const initial = probe.buildPanel(14) + const initialCursorRow = 14 - initial.lines.length + (initial.cursor?.row ?? 0) + + input.write('deploy') + const filtered = probe.buildPanel(14) + const lines = filtered.lines.map((line) => line.replace(/\u001b\[[0-9;:]*m/gu, '')) + const filteredCursorRow = 14 - filtered.lines.length + (filtered.cursor?.row ?? 0) + + expect(lines.some((line) => line.includes('Deployment failure'))).toBe(true) + expect(lines.some((line) => line.includes('Release investigation'))).toBe(false) + const searchRow = lines.indexOf(' Search › deploy') + expect(lines.findIndex((line) => line.includes('Deployment failure'))).toBeLessThan(searchRow) + expect(lines[searchRow - 1]).toBe(' ') + expect(lines[searchRow + 1]).toBe(' ') + expect(lines.some((line) => line.startsWith('─'))).toBe(false) + expect(filteredCursorRow).toBe(initialCursorRow) + + input.write('\r') + await expect(selected).resolves.toEqual({ kind: 'selected', id: 'deploy' }) + terminal.close() + }) + + it('keeps chat options beyond the first hundred searchable', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + const selected = terminal.select({ + prompt: 'Choose a chat', + options: Array.from({ length: 150 }, (_, index) => ({ + id: `chat-${index + 1}`, + label: index === 149 ? 'Needle investigation' : `Chat ${index + 1}`, + })), + }) + + input.write('needle\r') + + await expect(selected).resolves.toEqual({ kind: 'selected', id: 'chat-150' }) + terminal.close() + }) + + it('clears prior transcript content without rebuilding the terminal viewport', () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + terminal.userMessage('Old question') + terminal.write('Old answer\n') + + terminal.clearTranscript() + + expect((terminal as never as { transcript: string }).transcript).toBe('') + terminal.userMessage('New question') + expect((terminal as never as { transcript: string }).transcript).toContain('New question') + expect((terminal as never as { transcript: string }).transcript).not.toContain('Old question') + terminal.close() + }) + + it('opens / after whitespace and carries a selected skill identity', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + terminal.setSuggestionCandidates({ + resources: [], + slash: [ + { + id: 'skill:skill-1', + value: 'review', + displayText: '/review', + tag: 'skill', + context: { kind: 'skill', skillId: 'skill-1', label: 'review' }, + }, + ], + }) + const result = terminal.read('❯ ') + + input.write('please /rev\tthis\r') + + await expect(result).resolves.toEqual({ + kind: 'line', + value: 'please /review this', + contexts: [{ kind: 'skill', skillId: 'skill-1', label: 'review' }], + }) + terminal.close() + }) + + it('resets autocomplete selection to the first match when the token query changes', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + terminal.setSuggestionCandidates({ + resources: ['Apple', 'Apricot', 'Banana'].map((label, index) => ({ + id: `workflow:${index}`, + value: label, + displayText: label, + context: { + kind: 'workflow' as const, + workflowId: `workflow-${index}`, + label, + }, + })), + slash: [], + }) + const result = terminal.read('❯ ') + + input.write('@') + key(input, '', { name: 'down', sequence: '\u001b[B' }) + input.write('a\t\r') + + await expect(result).resolves.toMatchObject({ + kind: 'line', + value: '@Apple ', + contexts: [ + { + kind: 'workflow', + workflowId: 'workflow-0', + label: 'Apple', + }, + ], + }) + terminal.close() + }) + + it('preserves the highlighted autocomplete item when async candidates arrive', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + const tables = ['Customers', 'Orders'].map((label, index) => ({ + id: `table:${index}`, + value: label, + displayText: label, + context: { + kind: 'table' as const, + tableId: `table-${index}`, + label, + }, + })) + terminal.setSuggestionCandidates({ resources: tables, slash: [] }) + const result = terminal.read('❯ ') + + input.write('@') + key(input, '', { name: 'down', sequence: '\u001b[B' }) + terminal.setSuggestionCandidates({ + resources: [ + { + id: 'workflow:0', + value: 'Billing', + displayText: 'Billing', + context: { + kind: 'workflow', + workflowId: 'workflow-0', + label: 'Billing', + }, + }, + ...tables, + ], + slash: [], + }) + input.write('\t\r') + + await expect(result).resolves.toEqual({ + kind: 'line', + value: '@Orders ', + contexts: [ + { + kind: 'table', + tableId: 'table-1', + label: 'Orders', + }, + ], + }) + terminal.close() + }) + + it('auto-resolves a manually typed slash tag with skill precedence', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + terminal.setSuggestionCandidates({ + resources: [], + slash: [ + { + id: 'skill:skill-1', + value: 'review', + displayText: '/review', + tag: 'skill', + context: { kind: 'skill', skillId: 'skill-1', label: 'review' }, + }, + { + id: 'mcp:mcp-1', + value: 'review', + displayText: '/review', + tag: 'mcp', + context: { kind: 'mcp', serverId: 'mcp-1', label: 'review' }, + }, + ], + }) + const result = terminal.read('❯ ') + + input.write('/REVIEW this\r') + + await expect(result).resolves.toEqual({ + kind: 'line', + value: '/REVIEW this', + contexts: [{ kind: 'skill', skillId: 'skill-1', label: 'review' }], + }) + terminal.close() + }) + + it('preserves selected context identity through a queued priority preload', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + const contexts = [{ kind: 'workflow' as const, workflowId: 'workflow-1', label: 'Release' }] + + expect(terminal.preload('@Release', { queued: true, contexts })).toBe(true) + const result = terminal.read('❯ ') + input.write('\r') + + await expect(result).resolves.toEqual({ + kind: 'line', + value: '@Release', + queued: true, + display: '@Release', + contexts, + }) + terminal.close() + }) + + it('leaves a caller-owned flowing input flowing after close', () => { + const { input, output } = terminalStreams(80, 14) + input.resume() + expect(input.readableFlowing).toBe(true) + + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + terminal.close() + + expect(input.isPaused()).toBe(false) + }) + + it('does not change caller-owned raw mode when closed before opening the viewport', () => { + const { input, output } = terminalStreams(80, 14) + input.isRaw = true + + const terminal = new ReadlineChatTerminal(input, output) + terminal.close() + + expect(input.setRawMode).not.toHaveBeenCalled() + }) + + it('commits sent prompts with the same balanced panel as the composer', async () => { + const columns = 80 + const rows = 14 + const { input, output } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + const result = terminal.read('❯ ') + + input.write('first line\\\rsecond line\r') + await expect(result).resolves.toEqual({ kind: 'line', value: 'first line\nsecond line' }) + await screen.flush() + + const buffer = screen.terminal.buffer.active + const lines = visibleTerminalLines(screen.terminal, rows) + const firstRow = lines.indexOf(' ❯ first line') + const secondRow = lines.indexOf(' second line') + expect(firstRow).toBeGreaterThan(0) + expect(secondRow).toBe(firstRow + 1) + for (const row of [firstRow - 1, firstRow, secondRow, secondRow + 1]) { + expectUserPanelRow(screen.terminal, row, columns) + } + expect(buffer.getLine(firstRow)?.getCell(0)?.getChars()).toBe(' ') + expect(buffer.getLine(firstRow)?.getCell(1)?.getChars()).toBe('❯') + expect(buffer.getLine(firstRow)?.getCell(1)?.getFgColor()).toBe(0xa0a0a0) + expect( + buffer + .getLine(firstRow) + ?.getCell(columns - 3) + ?.getChars() + ).toBe(' ') + expect( + buffer + .getLine(secondRow + 2) + ?.getCell(0) + ?.isBgDefault() + ).toBe(true) + + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('wraps committed user-card words within the shaded content width', async () => { + const columns = 21 + const rows = 14 + const { input, output } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + const result = terminal.read('❯ ') + + input.write('abcdef 1234567890\r') + await expect(result).resolves.toEqual({ kind: 'line', value: 'abcdef 1234567890' }) + await screen.flush() + + const lines = visibleTerminalLines(screen.terminal, rows) + const firstRow = lines.indexOf(' ❯ abcdef') + expect(firstRow).toBeGreaterThan(0) + expect(lines[firstRow + 1]).toBe(' 1234567890') + expectUserPanelRow(screen.terminal, firstRow, columns) + expectUserPanelRow(screen.terminal, firstRow + 1, columns) + + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('renders padded user-turn cells while keeping the composer in the physical bottom rows', async () => { + const columns = 67 + const rows = 12 + const { input, output } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + terminal.welcome({ chatTitle: 'New chat' }) + const submitted = terminal.read('❯ ') + + input.write('whats in my workspace\r') + await expect(submitted).resolves.toEqual({ + kind: 'line', + value: 'whats in my workspace', + }) + const activity = terminal.activity('Thinking…') + activity.clear() + terminal.write('Here is your workspace.') + activity.complete() + void terminal.read('❯ ') + await screen.flush() + + const buffer = screen.terminal.buffer.active + const lines = visibleTerminalLines(screen.terminal, rows) + expect(lines).toContain(' ❯ whats in my workspace') + expect(lines).toContain('● Here is your workspace.') + expect(lines).toContain('✻ Worked for 1s') + expect(lines[rows - 3]).toBe(' ❯') + expect(lines[rows - 2]).toBe('') + expect(lines[11]).toBe(' ? for shortcuts') + expectUserPanelRow(screen.terminal, rows - 4, columns) + expectUserPanelRow(screen.terminal, rows - 3, columns) + expectUserPanelRow(screen.terminal, rows - 2, columns) + + const userRowIndex = lines.indexOf(' ❯ whats in my workspace') + const assistantRowIndex = lines.indexOf('● Here is your workspace.') + expectUserPanelRow(screen.terminal, userRowIndex - 1, columns) + expectUserPanelRow(screen.terminal, userRowIndex, columns) + expectUserPanelRow(screen.terminal, userRowIndex + 1, columns) + expect( + buffer + .getLine(userRowIndex + 2) + ?.getCell(0) + ?.isBgDefault() + ).toBe(true) + expect(buffer.getLine(assistantRowIndex)?.getCell(0)?.getChars()).toBe('●') + expect(buffer.getLine(assistantRowIndex)?.getCell(0)?.isBgDefault()).toBe(true) + expect(buffer.cursorY).toBe(rows - 3) + expect(buffer.baseY).toBe(0) + + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('expands user and composer panels across wide terminal viewports', async () => { + const columns = 240 + const rows = 14 + const { input, output } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + const submitted = terminal.read('❯ ') + + input.write('wide terminal\r') + await expect(submitted).resolves.toEqual({ kind: 'line', value: 'wide terminal' }) + void terminal.read('❯ ') + await screen.flush() + + const buffer = screen.terminal.buffer.active + expectUserPanelRow(screen.terminal, 0, columns) + expectUserPanelRow(screen.terminal, 1, columns) + expectUserPanelRow(screen.terminal, 2, columns) + expectUserPanelRow(screen.terminal, rows - 4, columns) + expectUserPanelRow(screen.terminal, rows - 3, columns) + expectUserPanelRow(screen.terminal, rows - 2, columns) + expect( + buffer + .getLine(0) + ?.getCell(columns - 3) + ?.getChars() + ).toBe(' ') + expect( + buffer + .getLine(rows - 1) + ?.getCell(0) + ?.isBgDefault() + ).toBe(true) + + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('buffers and removes leading whitespace so assistant text shares the prefix row', async () => { + const { input, output, chunks } = terminalStreams(30, 10) + const screen = mirrorToHeadless(output, 30, 10) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + terminal.userMessage('question') + const chunksBeforeWhitespace = chunks.length + + terminal.write('\u001b[1m') + terminal.write('\n ') + expect(chunks).toHaveLength(chunksBeforeWhitespace) + + terminal.write('answer\u001b[0m') + await screen.flush() + const answerFrame = chunks.at(-1) ?? '' + expect(answerFrame).toContain('● \u001b[1manswer\u001b[0m') + expect(answerFrame).not.toContain('● \u001b[0m') + const visibleLines = Array.from({ length: 10 }, (_, row) => + screen.terminal.buffer.active.getLine(row)?.translateToString(true).trimEnd() + ).filter(Boolean) + expect(visibleLines).toContain('● answer') + expect(visibleLines).not.toContain('●') + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('keeps explicit and soft-wrapped assistant rows in a hanging gutter', async () => { + const columns = 16 + const rows = 14 + const { input, output } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + terminal.userMessage('question') + const activity = terminal.activity('Thinking…') + activity.clear() + + terminal.write('alpha beta gamma delta\n') + terminal.write('\u001b[1mHeading\u001b[0m\n') + terminal.write('\u001b[2m•\u001b[0m nested item') + + await screen.flush() + const visibleLines = Array.from({ length: rows }, (_, row) => + screen.terminal.buffer.active.getLine(row)?.translateToString(true).trimEnd() + ) + expect(visibleLines).toContain('● alpha beta') + expect(visibleLines).toContain(' gamma delta') + expect(visibleLines).toContain(' Heading') + expect(visibleLines).toContain(' • nested item') + expect(visibleLines).not.toContain('Heading') + expect(visibleLines).not.toContain('• nested item') + + activity.stop() + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('starts an assistant turn for attachment-only requests without a text prompt', () => { + const { input, output, chunks } = terminalStreams(30, 10) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + + activity.clear() + terminal.write('I inspected the attachment.') + + expect(chunks.at(-1)).toContain('● I inspected the attachment.') + activity.stop() + terminal.close() + }) + + it('coordinates streaming transcript writes without moving the busy composer from the bottom', async () => { + const columns = 50 + const rows = 12 + const { input, output, chunks } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + const submitted = terminal.read('❯ ') + input.write('question\r') + await submitted + const activity = terminal.activity('Thinking…') + activity.clear() + + terminal.write('Hello ') + terminal.write('\u001b[1mworld\u001b[0m') + await screen.flush() + + const latestFrame = chunks.at(-1) ?? '' + const rendered = chunks.join('') + expect(latestFrame).toContain('Hello \u001b[1mworld\u001b[0m') + expect(rendered).toContain('esc to interrupt') + expect(latestFrame).not.toContain('\u001b[2J') + expect(latestFrame).not.toContain('\n') + expectUserPanelRow(screen.terminal, rows - 4, columns) + expectUserPanelRow(screen.terminal, rows - 3, columns) + expectUserPanelRow(screen.terminal, rows - 2, columns) + expect(visibleTerminalLines(screen.terminal, rows)[rows - 3]).toBe(' ❯') + expect(screen.terminal.buffer.active.cursorY).toBe(rows - 3) + + activity.stop() + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('keeps the busy composer editable and drains steering prompts in FIFO order', async () => { + const { input, output, chunks } = terminalStreams(60, 14) + const terminal = new ReadlineChatTerminal(input, output) + const initial = terminal.read('❯ ') + input.write('original request\r') + await initial + + const interruptions: string[] = [] + terminal.onInterrupt((reason) => interruptions.push(reason)) + const activity = terminal.activity('Thinking…') + + input.write('first steer') + await new Promise((resolve) => setImmediate(resolve)) + expect(plainTerminalText(chunks.at(-1) ?? '')).toContain(' ❯ first steer') + expect(chunks.at(-1)).toContain('\u001b[?25h') + + input.write('\rsecond steer\r') + await new Promise((resolve) => setImmediate(resolve)) + expect(interruptions).toEqual(['submit', 'submit']) + expect(chunks.at(-1)).toContain('2 queued · enter to steer · esc to interrupt') + + activity.stop() + await expect(terminal.read('❯ ')).resolves.toEqual({ + kind: 'line', + value: 'first steer', + queued: true, + display: 'first steer', + }) + await expect(terminal.read('❯ ')).resolves.toEqual({ + kind: 'line', + value: 'second steer', + queued: true, + display: 'second steer', + }) + terminal.close() + }) + + it('treats blank busy Enter as a no-op', async () => { + const { input, output, chunks } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const interruptions: string[] = [] + terminal.onInterrupt((reason) => interruptions.push(reason)) + const activity = terminal.activity('Thinking…') + + key(input, '\r', { name: 'return', sequence: '\r' }) + + expect(interruptions).toEqual([]) + expect((terminal as never as { queued: unknown[] }).queued).toHaveLength(0) + expect(chunks.at(-1)).not.toContain('queued') + activity.stop() + terminal.close() + }) + + it('reports busy submissions without duplicating chat command or path semantics', async () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const interruptions: string[] = [] + terminal.onInterrupt((reason) => interruptions.push(reason)) + const activity = terminal.activity('Thinking…') + + input.write('/help \r/private/tmp/report.txt\r') + await new Promise((resolve) => setImmediate(resolve)) + + expect(interruptions).toEqual(['submit', 'submit']) + activity.stop() + await expect(terminal.read('❯ ')).resolves.toMatchObject({ value: '/help ', queued: true }) + await expect(terminal.read('❯ ')).resolves.toMatchObject({ + value: '/private/tmp/report.txt', + queued: true, + }) + terminal.close() + }) + + it('prioritizes an explicit preload without losing queued turns or the live draft', async () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + + input.write('/private/tmp/report.txt\rinspect it\runfinished') + await new Promise((resolve) => setImmediate(resolve)) + activity.stop() + + await expect(terminal.read('❯ ')).resolves.toMatchObject({ + value: '/private/tmp/report.txt', + queued: true, + }) + expect(terminal.preload('/attach "/private/tmp/report.txt"')).toBe(true) + const confirmation = terminal.read('❯ ') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(confirmation).resolves.toEqual({ + kind: 'line', + value: '/attach "/private/tmp/report.txt"', + }) + + await expect(terminal.read('❯ ')).resolves.toMatchObject({ + value: 'inspect it', + queued: true, + }) + const restoredDraft = terminal.read('❯ ') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(restoredDraft).resolves.toEqual({ kind: 'line', value: 'unfinished' }) + terminal.close() + }) + + it('consumes a preload submitted while clipboard work is between reads', async () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + input.write('live draft') + activity.stop() + + expect(terminal.preload('/attach "/private/tmp/report.txt"')).toBe(true) + const clipboard = terminal.read('❯ ') + key(input, '\u0016', { ctrl: true, name: 'v', sequence: '\u0016' }) + await expect(clipboard).resolves.toEqual({ + kind: 'clipboard', + value: '/attach "/private/tmp/report.txt"', + }) + + // Clipboard inspection is asynchronous in chat.ts. Enter can arrive before + // it asks the terminal for another input, and must consume this preload. + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(terminal.read('❯ ')).resolves.toMatchObject({ + kind: 'line', + value: '/attach "/private/tmp/report.txt"', + queued: true, + }) + + const restoredDraft = terminal.read('❯ ') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(restoredDraft).resolves.toEqual({ kind: 'line', value: 'live draft' }) + terminal.close() + }) + + it('preserves a large pasted draft while a priority preload is submitted', async () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const pasted = 'p'.repeat(900) + const activity = terminal.activity('Thinking…') + input.write('before ') + key(input, '', { name: 'paste-start', sequence: '\u001b[200~' }) + key(input, pasted, { sequence: pasted }) + key(input, '', { name: 'paste-end', sequence: '\u001b[201~' }) + activity.stop() + + expect(terminal.preload('/attach "/private/tmp/report.txt"')).toBe(true) + const confirmation = terminal.read('❯ ') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(confirmation).resolves.toMatchObject({ + kind: 'line', + value: '/attach "/private/tmp/report.txt"', + }) + + const restoredDraft = terminal.read('❯ ') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(restoredDraft).resolves.toMatchObject({ + kind: 'line', + value: `before ${pasted}`, + }) + terminal.close() + }) + + it('retains queued paste bodies across later input and a priority retry', async () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const pasted = 'q'.repeat(900) + const activity = terminal.activity('Thinking…') + key(input, '', { name: 'paste-start', sequence: '\u001b[200~' }) + key(input, pasted, { sequence: pasted }) + key(input, '', { name: 'paste-end', sequence: '\u001b[201~' }) + key(input, '\r', { name: 'return', sequence: '\r' }) + activity.stop() + + const queued = await terminal.read('❯ ') + expect(queued).toMatchObject({ kind: 'line', value: pasted, queued: true }) + if (queued.kind !== 'line' || !queued.display) throw new Error('Expected queued pasted line') + + const laterActivity = terminal.activity('Thinking…') + input.write('later\r') + laterActivity.stop() + expect(terminal.preload(queued.display, { queued: true, pastes: queued.pastes })).toBe(true) + + const retry = terminal.read('❯ ') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(retry).resolves.toMatchObject({ kind: 'line', value: pasted, queued: true }) + terminal.close() + }) + + it('keeps a deferred retry ahead of queued turns without duplicating its transcript row', async () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + input.write('retry me\r') + activity.stop() + + await expect(terminal.read('❯ ')).resolves.toMatchObject({ value: 'retry me', queued: true }) + const laterActivity = terminal.activity('Thinking…') + input.write('later\r') + laterActivity.stop() + expect(terminal.preload('retry me', { queued: true })).toBe(true) + + const clipboard = terminal.read('❯ ') + key(input, '\u0016', { ctrl: true, name: 'v', sequence: '\u0016' }) + await expect(clipboard).resolves.toMatchObject({ kind: 'clipboard' }) + + // Enter can land before clipboard inspection asks for the next input. The + // retry remains the priority item even though another turn is queued. + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(terminal.read('❯ ')).resolves.toMatchObject({ + value: 'retry me', + queued: true, + }) + await expect(terminal.read('❯ ')).resolves.toMatchObject({ value: 'later', queued: true }) + + const transcript = (terminal as never as { transcript: string }).transcript + expect(transcript.match(/retry me/gu)).toHaveLength(1) + terminal.close() + }) + + it('retries a normally submitted prompt without duplicating its transcript row', async () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const firstAttempt = terminal.read('❯ ') + input.write('retry me\r') + await expect(firstAttempt).resolves.toEqual({ kind: 'line', value: 'retry me' }) + + const activity = terminal.activity('Thinking…') + activity.stop() + expect(terminal.preload('retry me', { queued: true })).toBe(true) + const retry = terminal.read('❯ ') + input.write('\r') + + await expect(retry).resolves.toMatchObject({ + kind: 'line', + value: 'retry me', + queued: true, + }) + const transcript = (terminal as never as { transcript: string }).transcript + expect(transcript.match(/retry me/gu)).toHaveLength(1) + terminal.close() + }) + + it('keeps an unchanged committed retry deduplicated after queue recall', async () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + input.write('retry me\r') + activity.stop() + + await expect(terminal.read('❯ ')).resolves.toMatchObject({ value: 'retry me', queued: true }) + expect(terminal.preload('retry me', { queued: true })).toBe(true) + key(input, '\r', { name: 'return', sequence: '\r' }) + key(input, '', { name: 'up', sequence: '\u001b[A' }) + key(input, '\r', { name: 'return', sequence: '\r' }) + + await expect(terminal.read('❯ ')).resolves.toMatchObject({ value: 'retry me', queued: true }) + const transcript = (terminal as never as { transcript: string }).transcript + expect(transcript.match(/retry me/gu)).toHaveLength(1) + terminal.close() + }) + + it('keeps clipboard draft edits terminal-owned while a turn is active', async () => { + const { input, output, chunks } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + + input.write('draft') + key(input, '\u0016', { ctrl: true, name: 'v', sequence: '\u0016' }) + for (let index = 0; index < 5; index += 1) { + key(input, '\u007f', { name: 'backspace', sequence: '\u007f' }) + } + expect(chunks.at(-1)).not.toContain('queued') + activity.stop() + + await expect(terminal.read('❯ ')).resolves.toEqual({ kind: 'clipboard', value: 'draft' }) + const empty = terminal.read('❯ ') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(empty).resolves.toEqual({ kind: 'line', value: '' }) + terminal.close() + }) + + it('dismisses busy suggestions before Escape interrupts generation', () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const interruptions: string[] = [] + terminal.onInterrupt((reason) => interruptions.push(reason)) + const activity = terminal.activity('Thinking…') + input.write('/he') + + key(input, '', { name: 'escape', sequence: '\u001b' }) + expect(interruptions).toEqual([]) + expect((terminal as never as { draft: string }).draft).toBe('/he') + + key(input, '', { name: 'escape', sequence: '\u001b' }) + expect(interruptions).toEqual(['manual']) + activity.stop() + terminal.close() + }) + + it('recalls the newest queued steering prompt with Up', async () => { + const { input, output, chunks } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + + input.write('first\rsecond\r') + await new Promise((resolve) => setImmediate(resolve)) + key(input, '', { name: 'up', sequence: '\u001b[A' }) + + expect(plainTerminalText(chunks.at(-1) ?? '')).toContain(' ❯ second') + expect(chunks.at(-1)).toContain('1 queued · enter to steer · esc to interrupt') + activity.stop() + terminal.close() + }) + + it('reinserts a recalled prompt ahead of controls that arrived after it', async () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + + input.write('first\rsecond\r') + await new Promise((resolve) => setImmediate(resolve)) + key(input, '\u0016', { ctrl: true, name: 'v', sequence: '\u0016' }) + key(input, '', { name: 'up', sequence: '\u001b[A' }) + input.write(' edited\r') + activity.stop() + + await expect(terminal.read('❯ ')).resolves.toMatchObject({ value: 'first', queued: true }) + await expect(terminal.read('❯ ')).resolves.toMatchObject({ + value: 'second edited', + queued: true, + }) + await expect(terminal.read('❯ ')).resolves.toEqual({ kind: 'clipboard', value: '' }) + terminal.close() + }) + + it('preserves a mid-stream draft across a structured question', async () => { + const { input, output } = terminalStreams(60, 14) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + input.write('unfinished follow-up') + activity.stop() + + const answer = terminal.askQuestion({ + prompt: 'Which service?', + multi: false, + options: [{ id: 'api', label: 'API' }], + }) + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(answer).resolves.toEqual({ kind: 'answer', values: ['API'] }) + + const followUp = terminal.read('❯ ') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(followUp).resolves.toEqual({ kind: 'line', value: 'unfinished follow-up' }) + terminal.close() + }) + + it('paints only visible transcript rows without terminal scrolling during repeated redraws', () => { + const { input, output, chunks } = terminalStreams(16, 10) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + + terminal.write(`${Array.from({ length: 100 }, (_, index) => `line-${index}`).join('\n')}\n`) + const transcriptFrame = chunks.at(-1) ?? '' + const transcriptPaints = [...transcriptFrame.matchAll(/\u001b\[(\d+);1H\u001b\[2K/gu)] + expect(transcriptPaints.length).toBeLessThanOrEqual(output.rows) + expect(transcriptFrame).not.toContain('line-0') + expect(transcriptFrame).toContain('line-99') + expect(transcriptFrame).not.toContain('\n') + expect(transcriptFrame).not.toContain('\r') + expect(transcriptFrame).not.toMatch(/\u001b\[\d+;\d+r/u) + + for (let redraw = 0; redraw < 10; redraw += 1) output.emit('resize') + for (const frame of chunks.slice(-10)) { + const paintedRows = [...frame.matchAll(/\u001b\[(\d+);1H\u001b\[2K/gu)] + expect(paintedRows).toHaveLength(0) + expect(frame).not.toMatch(/\u001b\[\d+;\d+r/u) + expect(frame).not.toContain('\n') + expect(frame).not.toContain('\r') + expect(frame).not.toContain('line-99') + expect(frame).not.toContain('\u001b[2J') + } + + terminal.close() + }) + + it('owns transcript scrollback while keeping the composer fixed and sticky', async () => { + const { input, output, chunks } = terminalStreams(32, 10) + const terminal = new ReadlineChatTerminal(input, output) + const submitted = terminal.read('❯ ') + + terminal.write(`${Array.from({ length: 20 }, (_, index) => `line-${index}`).join('\n')}\n`) + expect(chunks.at(-1)).toContain('line-19') + + key(input, '', { name: 'pageup', sequence: '\u001b[5~' }) + const historyFrame = chunks.at(-1) ?? '' + expect(historyFrame).toContain('line-11') + expect(historyFrame).not.toContain('line-19') + expect(historyFrame).toContain('\u001b[8;4H\u001b[?25h') + + terminal.write('line-20\nline-21\n') + const anchoredFrame = chunks.at(-1) ?? '' + expect(anchoredFrame).not.toContain('line-20') + expect(anchoredFrame).not.toContain('line-21') + expect(anchoredFrame).not.toMatch(/\u001b\[\d+;1H\u001b\[2K/u) + + key(input, '', { ctrl: true, name: 'home', sequence: '\u001b[1;5H' }) + expect(chunks.at(-1)).toContain('line-0') + key(input, '', { ctrl: true, name: 'end', sequence: '\u001b[1;5F' }) + expect(chunks.at(-1)).toContain('line-21') + + key(input, '', { name: 'pageup', sequence: '\u001b[5~' }) + output.rows = 12 + output.emit('resize') + const resizedFrame = chunks.at(-1) ?? '' + expect(resizedFrame).not.toContain('line-21') + expect(resizedFrame).not.toContain('\n') + expect(resizedFrame).not.toContain('\r') + + input.write('new question\r') + await expect(submitted).resolves.toEqual({ kind: 'line', value: 'new question' }) + const submittedFrame = chunks.at(-1) ?? '' + expect(submittedFrame).toContain('new question') + expect(submittedFrame).toContain('\u001b[10;1H\u001b[2K') + expect(plainTerminalText(submittedFrame)).toContain(' ❯ ') + expect(submittedFrame).not.toContain('\n') + expect(submittedFrame).not.toContain('\r') + terminal.close() + }) + + it('wraps ANSI-styled wide graphemes into bounded absolute rows', () => { + const { input, output, chunks } = terminalStreams(10, 8) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + + terminal.write('\u001b[31m12345678界Z\u001b[0m') + + const latestFrame = chunks.at(-1) ?? '' + expect(latestFrame).toContain('\u001b[31m12345678\u001b[0m') + expect(latestFrame).toContain('\u001b[31m界Z\u001b[0m') + expect(latestFrame).not.toContain('\n') + expect(latestFrame).not.toMatch(/\u001b\[\d+;\d+r/u) + terminal.close() + }) + + it('reflows streamed prose at word boundaries instead of splitting ordinary words', () => { + const { input, output, chunks } = terminalStreams(21, 8) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + + terminal.write('happy to build somet') + expect(chunks.at(-1)).toContain('happy to build somet') + + terminal.write('hing') + const reflowedFrame = chunks.at(-1) ?? '' + expect(reflowedFrame).toContain('\u001b[1;1H\u001b[2Khappy to build \u001b[0m') + expect(reflowedFrame).toContain('\u001b[2;1H\u001b[2Ksomething\u001b[0m') + expect(reflowedFrame).not.toContain('somet\u001b[0m') + expect(reflowedFrame).not.toContain('\u001b[2;1H\u001b[2Khing') + terminal.close() + }) + + it('reopens the user panel on word-wrapped rows without leaking into assistant output', async () => { + const columns = 21 + const rows = 8 + const { input, output } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + + terminal.userMessage('happy to build something') + await screen.flush() + + const userLines = visibleTerminalLines(screen.terminal, rows) + const firstRow = userLines.indexOf(' ❯ happy to build') + const continuationRow = userLines.indexOf(' something') + expect(firstRow).toBeGreaterThanOrEqual(0) + expect(continuationRow).toBe(firstRow + 1) + expectUserPanelRow(screen.terminal, firstRow, columns) + expectUserPanelRow(screen.terminal, continuationRow, columns) + + terminal.write('assistant') + await screen.flush() + const assistantRow = visibleTerminalLines(screen.terminal, rows).indexOf('● assistant') + expect(assistantRow).toBeGreaterThanOrEqual(0) + expect(screen.terminal.buffer.active.getLine(assistantRow)?.getCell(0)?.isBgDefault()).toBe( + true + ) + + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('supports multiline input, grapheme deletion, and history recall', async () => { + const { input, output } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + + const multiline = terminal.read('❯ ') + input.write('hello\\\rworld\r') + await expect(multiline).resolves.toEqual({ kind: 'line', value: 'hello\nworld' }) + + const edited = terminal.read('❯ ') + input.write('A😀B') + key(input, '', { name: 'left', sequence: '\u001b[D' }) + key(input, '', { name: 'backspace', sequence: '\u007f' }) + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(edited).resolves.toEqual({ kind: 'line', value: 'AB' }) + + const recalled = terminal.read('❯ ') + key(input, '', { name: 'up', sequence: '\u001b[A' }) + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(recalled).resolves.toEqual({ kind: 'line', value: 'AB' }) + terminal.close() + }) + + it('preserves the live draft cursor when a streamed turn settles', async () => { + const { input, output } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const initial = terminal.read('❯ ') + input.write('original\r') + await initial + + const activity = terminal.activity('Thinking…') + input.write('abcdef') + key(input, '', { name: 'left', sequence: '\u001b[D' }) + key(input, '', { name: 'left', sequence: '\u001b[D' }) + activity.stop() + + const followUp = terminal.read('❯ ') + input.write('X\r') + await expect(followUp).resolves.toEqual({ kind: 'line', value: 'abcdXef' }) + terminal.close() + }) + + it('returns eof after close even when deferred input remains queued', async () => { + const { input, output } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + input.write('stale\r') + await new Promise((resolve) => setImmediate(resolve)) + + terminal.close() + + await expect(terminal.read('❯ ')).resolves.toEqual({ kind: 'eof' }) + activity.stop() + }) + + it('redraws the balanced composer across narrow terminal resizes', async () => { + const { input, output } = terminalStreams(12, 10) + const screen = mirrorToHeadless(output, 12, 10) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + await screen.flush() + + expectUserPanelRow(screen.terminal, 6, 12) + expectUserPanelRow(screen.terminal, 7, 12) + expectUserPanelRow(screen.terminal, 8, 12) + expect(screen.terminal.buffer.active.cursorY).toBe(7) + + screen.terminal.resize(40, 16) + output.columns = 40 + output.rows = 16 + output.emit('resize') + await screen.flush() + + expectUserPanelRow(screen.terminal, 12, 40) + expectUserPanelRow(screen.terminal, 13, 40) + expectUserPanelRow(screen.terminal, 14, 40) + expect(visibleTerminalLines(screen.terminal, 16)[13]).toBe(' ❯') + expect(screen.terminal.buffer.active.cursorY).toBe(13) + + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('never paints beyond the physical terminal during extreme row resizes', () => { + const { input, output, chunks } = terminalStreams(20, 14) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + terminal.write('one\ntwo\nthree\nfour') + + for (const rows of [2, 1, 20]) { + output.rows = rows + output.emit('resize') + const frame = chunks.at(-1) ?? '' + const cursorPositions = [...frame.matchAll(/\u001b\[(\d+);(\d+)H/gu)] + expect(cursorPositions.length).toBeGreaterThan(0) + for (const position of cursorPositions) { + expect(Number(position[1])).toBeLessThanOrEqual(rows) + expect(Number(position[2])).toBeLessThanOrEqual(output.columns) + } + expect(frame).not.toContain('\n') + expect(frame).not.toContain('\r') + } + + terminal.close() + }) + + it('respects the physical column count and leaves a no-wrap safety column', () => { + const { input, output, chunks } = terminalStreams(14, 8) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + terminal.write('alpha beta 界界 gamma') + + for (const columns of [2, 1, 20]) { + output.columns = columns + output.emit('resize') + const frame = chunks.at(-1) ?? '' + const cursorPositions = [...frame.matchAll(/\u001b\[(\d+);(\d+)H/gu)] + expect(cursorPositions.length).toBeGreaterThan(0) + for (const position of cursorPositions) { + expect(Number(position[1])).toBeLessThanOrEqual(output.rows) + expect(Number(position[2])).toBeLessThanOrEqual(columns) + } + for (const payload of paintedPayloads(frame)) { + expect(payloadDisplayWidth(payload)).toBeLessThanOrEqual(Math.max(0, columns - 1)) + } + expect(frame).not.toContain('\n') + expect(frame).not.toContain('\r') + } + + terminal.close() + }) + + it('keeps the meaningful composer and question row focused at one terminal row', async () => { + const { input, output, chunks } = terminalStreams(40, 1) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + + const busyFrame = chunks.at(-1) ?? '' + expect(busyFrame).toContain('❯ ') + expect(busyFrame).not.toContain('esc to interrupt') + expect(paintedPayloads(busyFrame)).toHaveLength(1) + activity.stop() + + const answer = terminal.askQuestion({ + prompt: 'Which service?', + multi: false, + options: [ + { id: 'api', label: 'API' }, + { id: 'worker', label: 'Worker' }, + ], + }) + const questionFrame = chunks.at(-1) ?? '' + expect(questionFrame).toContain('❯ 1. API') + expect(questionFrame).not.toContain('navigate') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(answer).resolves.toEqual({ kind: 'answer', values: ['API'] }) + terminal.close() + }) + + it('clips the balanced composer around its input on tiny terminal heights', async () => { + const columns = 40 + const { input, output } = terminalStreams(columns, 6) + const screen = mirrorToHeadless(output, columns, 6) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + const expectedCursorRows = new Map([ + [5, 2], + [4, 1], + [3, 1], + [2, 0], + [1, 0], + ]) + + for (const rows of [5, 4, 3, 2, 1]) { + screen.terminal.resize(columns, rows) + output.rows = rows + output.emit('resize') + await screen.flush() + + const cursorRow = expectedCursorRows.get(rows) + if (cursorRow === undefined) throw new Error(`Missing cursor expectation for ${rows} rows`) + expect(screen.terminal.buffer.active.cursorY).toBe(cursorRow) + expect(screen.terminal.buffer.active.cursorX).toBe(3) + expect(visibleTerminalLines(screen.terminal, rows)[cursorRow]).toBe(' ❯') + expectUserPanelRow(screen.terminal, cursorRow, columns) + if (cursorRow > 0) expectUserPanelRow(screen.terminal, cursorRow - 1, columns) + if (cursorRow + 1 < rows) expectUserPanelRow(screen.terminal, cursorRow + 1, columns) + if (rows >= 4) { + expect( + screen.terminal.buffer.active + .getLine(rows - 1) + ?.getCell(0) + ?.isBgDefault() + ).toBe(true) + } + } + + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('returns Ctrl+V with the current draft and implements clear-aware Ctrl+C', async () => { + const { input, output } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + + const clipboard = terminal.read('❯ ') + input.write('explain this') + key(input, '\u0016', { ctrl: true, name: 'v', sequence: '\u0016' }) + await expect(clipboard).resolves.toEqual({ kind: 'clipboard', value: 'explain this' }) + + const withDraft = terminal.read('❯ ') + input.write('discard me') + key(input, '\u0003', { ctrl: true, name: 'c', sequence: '\u0003' }) + await expect(withDraft).resolves.toEqual({ kind: 'interrupt', empty: false }) + + const empty = terminal.read('❯ ') + key(input, '\u0003', { ctrl: true, name: 'c', sequence: '\u0003' }) + await expect(empty).resolves.toEqual({ kind: 'interrupt', empty: true }) + terminal.close() + }) + + it('renders questions in the bottom panel with focus, custom answers, and cancellation', async () => { + const { input, output, chunks } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const question = { + prompt: 'Which service?', + multi: false, + options: [ + { id: 'api', label: 'API' }, + { id: 'worker', label: 'Worker' }, + ], + } + + const selected = terminal.askQuestion(question) + key(input, '', { name: 'down', sequence: '\u001b[B' }) + expect(chunks.at(-1)).toContain('❯ 2. Worker') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(selected).resolves.toEqual({ kind: 'answer', values: ['Worker'] }) + + const custom = terminal.askQuestion(question) + input.write('my service\r') + await expect(custom).resolves.toEqual({ kind: 'answer', values: ['my service'] }) + + const cancelled = terminal.askQuestion(question) + key(input, '', { name: 'escape', sequence: '\u001b' }) + await expect(cancelled).resolves.toEqual({ kind: 'cancel' }) + expect(chunks.join('')).not.toContain('Choose an option:') + expect(chunks.join('')).not.toContain('Selected:') + terminal.close() + }) + + it('keeps multi-select state in place and submits it explicitly', async () => { + const { input, output, chunks } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const result = terminal.askQuestion({ + prompt: 'Which services?', + multi: true, + options: [ + { id: 'api', label: 'API' }, + { id: 'worker', label: 'Worker' }, + ], + }) + + key(input, ' ', { name: 'space', sequence: ' ' }) + key(input, '', { name: 'down', sequence: '\u001b[B' }) + key(input, ' ', { name: 'space', sequence: ' ' }) + key(input, '', { name: 'down', sequence: '\u001b[B' }) + key(input, '', { name: 'down', sequence: '\u001b[B' }) + expect(chunks.at(-1)).toContain('❯ \u001b[2mSubmit answers') + key(input, '\r', { name: 'return', sequence: '\r' }) + + await expect(result).resolves.toEqual({ kind: 'answer', values: ['API', 'Worker'] }) + expect(chunks.join('')).toContain('[✓] API') + expect(chunks.join('')).toContain('[✓] Worker') + expect(chunks.join('')).not.toContain('Selected:') + terminal.close() + }) + + it('keeps completed tool rows in the transcript after transient activity clears', () => { + const { input, output, chunks } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + activity.thinking('Inspecting\nworkflows…') + activity.event({ + kind: 'tool', + id: 'tool-1', + label: 'Read\nworkspace', + state: 'running', + }) + activity.event({ kind: 'tool', id: 'tool-1', label: 'Read workspace', state: 'complete' }) + activity.event({ + kind: 'subagent', + id: 'agent-1', + label: 'Research\u001b]0;owned\u0007 agent', + state: 'running', + }) + activity.event({ + kind: 'narration', + parentId: 'agent-1', + delta: 'Found the relevant workflow', + }) + activity.event({ + kind: 'subagent', + id: 'agent-1', + label: 'Research\u001b]0;owned\u0007 agent', + state: 'error', + }) + activity.clear() + activity.stop() + + const rendered = chunks.join('') + expect(rendered).toContain('\u001b[32m●\u001b[0m Read workspace') + expect(rendered).toContain('\u001b[31m●\u001b[0m Research agent') + expect(rendered).toContain(' \u001b[2mFound the relevant workflow\u001b[0m') + expect(rendered).not.toContain('Research agent \u001b[2mfailed') + expect(rendered).not.toContain(' \u001b[32m●\u001b[0m Read workspace') + expect(rendered).not.toContain(' \u001b[31m●\u001b[0m Research agent') + expect(rendered).not.toContain('owned') + expect(rendered).not.toContain('✗') + terminal.close() + }) + + it('renders nested lanes in wire order with verbatim adjacent narration and structural seams', () => { + const { input, output } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + activity.event({ + kind: 'subagent', + id: 'agent-root', + label: 'Workflow Agent', + state: 'running', + }) + activity.event({ kind: 'narration', parentId: 'agent-root', delta: 'First ' }) + activity.event({ kind: 'narration', parentId: 'agent-root', delta: 'step\n\ncontinues' }) + activity.event({ + kind: 'tool', + id: 'tool-read', + parentId: 'agent-root', + label: 'Read file', + state: 'complete', + }) + activity.event({ kind: 'narration', parentId: 'agent-root', delta: 'After tool' }) + activity.event({ + kind: 'subagent', + id: 'agent-child', + parentId: 'agent-root', + label: 'Deploy Agent', + state: 'running', + }) + activity.event({ kind: 'narration', parentId: 'agent-child', delta: 'Shipping now' }) + + const probe = terminal as never as { activityEventsDisplay(): string } + expect(plainTerminalText(probe.activityEventsDisplay()).split('\n')).toEqual([ + '● Workflow Agent', + ' First step', + ' ', + ' continues', + ' ● Read file', + ' After tool', + ' ● Deploy Agent', + ' Shipping now', + ]) + + activity.stop() + terminal.close() + }) + + it('keeps parallel same-name subagents separate by id', () => { + const { input, output } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + for (const [id, narration] of [ + ['agent-a', 'First lane'], + ['agent-b', 'Second lane'], + ] as const) { + activity.event({ kind: 'subagent', id, label: 'Research Agent', state: 'running' }) + activity.event({ kind: 'narration', parentId: id, delta: narration }) + activity.event({ kind: 'subagent', id, label: 'Research Agent', state: 'complete' }) + } + + const probe = terminal as never as { activityEventsDisplay(): string } + expect(plainTerminalText(probe.activityEventsDisplay()).split('\n')).toEqual([ + '● Research Agent', + ' First lane', + '● Research Agent', + ' Second lane', + ]) + + activity.stop() + terminal.close() + }) + + it('commits only whole settled roots and prunes closed empty subagent groups', () => { + const { input, output } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + activity.event({ + kind: 'subagent', + id: 'agent-root', + label: 'Build Agent', + state: 'complete', + }) + activity.event({ + kind: 'tool', + id: 'tool-child', + parentId: 'agent-root', + label: 'Editing workflow', + state: 'running', + }) + activity.event({ + kind: 'subagent', + id: 'agent-empty', + label: 'Empty Agent', + state: 'complete', + }) + + const probe = terminal as never as { + activityEventsDisplay(): string + transcript: string + } + activity.clear() + expect(plainTerminalText(probe.transcript)).toBe('') + expect(plainTerminalText(probe.activityEventsDisplay())).toContain('Build Agent') + expect(plainTerminalText(probe.activityEventsDisplay())).not.toContain('Empty Agent') + + activity.event({ + kind: 'tool', + id: 'tool-child', + parentId: 'agent-root', + label: 'Edited workflow', + state: 'complete', + }) + activity.clear() + expect(plainTerminalText(probe.transcript).trim().split('\n')).toEqual([ + '● Build Agent', + ' ● Edited workflow', + ]) + expect(probe.activityEventsDisplay()).toBe('') + + activity.stop() + terminal.close() + }) + + it('pins the UI thinking label while tool activity remains in the transcript tail', () => { + const { input, output } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + activity.thinking('Planning next step') + activity.event({ kind: 'tool', id: 'tool-1', label: 'Reading file', state: 'running' }) + + const probe = terminal as never as { + activityEventsDisplay(): string + activityStatusLine(): string + buildPanel(rows: number): { lines: string[] } + } + const events = probe + .activityEventsDisplay() + .split('\n') + .map((line) => line.replace(/\u001b\[[0-9;:]*m/gu, '')) + const status = probe.activityStatusLine().replace(/\u001b\[[0-9;:]*m/gu, '') + const panel = probe.buildPanel(24).lines.map((line) => line.replace(/\u001b\[[0-9;:]*m/gu, '')) + const statusRow = panel.findIndex((line) => line.includes('Thinking…')) + const composerRow = panel.findIndex((line) => line.startsWith(' ❯')) + + expect(events).toEqual(['● Reading file…']) + expect(status).toMatch(/^[·•●] Thinking…$/u) + expect(status).not.toContain('Planning next step') + expect(statusRow).toBeGreaterThanOrEqual(0) + expect(statusRow).toBe(composerRow - 2) + expect(panel[composerRow - 1]).toBe(' ') + expect(panel[composerRow + 1]).toBe(' ') + + activity.clear() + expect(probe.activityStatusLine().replace(/\u001b\[[0-9;:]*m/gu, '')).toMatch( + /^[·•●] Thinking…$/u + ) + activity.stop() + terminal.close() + }) + + it('commits one settled work duration without showing a live time counter', () => { + const { input, output } = terminalStreams() + const now = vi.spyOn(Date, 'now').mockReturnValue(10_000) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + const probe = terminal as never as { + activityStatusLine(): string + transcript: string + } + + expect(probe.activityStatusLine()).not.toContain('Worked for') + expect(probe.activityStatusLine()).not.toContain('1m') + terminal.write('Done') + now.mockReturnValue(75_000) + activity.complete() + activity.complete() + + const transcript = probe.transcript.replace(/\u001b\[[0-9;:]*m/gu, '') + expect(transcript.match(/✻ Worked for 1m 5s/gu)).toHaveLength(1) + expect(probe.activityStatusLine()).toBe('') + + terminal.close() + now.mockRestore() + }) + + it('preserves every completed row when a turn exceeds the live activity window', () => { + const { input, output } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + const labels = Array.from({ length: 30 }, (_, index) => `Tool ${index}`) + + for (const [index, label] of labels.entries()) { + activity.event({ kind: 'tool', id: `tool-${index}`, label, state: 'complete' }) + } + activity.stop() + + const transcript = ( + terminal as never as { + transcript: string + } + ).transcript + .replace(/\u001b\[[0-9;:]*m/gu, '') + .trim() + .split('\n') + expect(transcript).toEqual(labels.map((label) => `● ${label}`)) + + terminal.close() + }) + + it('ignores stale activity handles and settles an empty successful turn once', () => { + const { input, output } = terminalStreams() + const now = vi.spyOn(Date, 'now').mockReturnValue(10_000) + const terminal = new ReadlineChatTerminal(input, output) + const stale = terminal.activity('Thinking…') + const current = terminal.activity('Thinking…') + const probe = terminal as never as { + activityEventsDisplay(): string + activityStatusLine(): string + transcript: string + } + + stale.update('Stale') + stale.event({ kind: 'tool', id: 'stale-tool', label: 'Stale tool', state: 'complete' }) + stale.complete() + expect(probe.activityStatusLine()).toContain('Thinking…') + expect(probe.activityEventsDisplay()).not.toContain('Stale tool') + + now.mockReturnValue(12_000) + current.complete() + current.complete() + expect( + probe.transcript.replace(/\u001b\[[0-9;:]*m/gu, '').match(/Worked for 2s/gu) + ).toHaveLength(1) + + terminal.close() + now.mockRestore() + }) + + it('queues rapid non-TTY lines and leaves non-interactive output free of screen controls', async () => { + const input = new PassThrough() + const output = new PassThrough() + const chunks: string[] = [] + output.on('data', (chunk) => chunks.push(String(chunk))) + const terminal = new ReadlineChatTerminal(input, output) + + input.write('first\nsecond\n') + await new Promise((resolve) => setImmediate(resolve)) + + await expect(terminal.read('> ')).resolves.toEqual({ + kind: 'line', + value: 'first', + queued: true, + display: 'first', + }) + await expect(terminal.read('> ')).resolves.toEqual({ + kind: 'line', + value: 'second', + queued: true, + display: 'second', + }) + terminal.write('plain output') + expect(chunks.join('')).toBe('plain output') + expect(chunks.join('')).not.toContain('\u001b[') + terminal.close() + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat-terminal.ts b/packages/sim-cli/src/commands/protocol/chat-terminal.ts new file mode 100644 index 00000000000..e4afac55352 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-terminal.ts @@ -0,0 +1,2682 @@ +import { emitKeypressEvents, type Key } from 'node:readline' +import type { Readable, Writable } from 'node:stream' +import { safeOneLine, sanitize } from '../../output/render.js' +import { + artPad, + displayWidth, + firstGrapheme, + graphemes, + graphemeWidth, + lineEnd, + lineStart, + nextGraphemeIndex, + previousGraphemeIndex, + tailToWidth, + truncateDisplay, +} from '../../output/terminal-text.js' + +export type ChatTerminalInput = + | { + kind: 'line' + value: string + queued?: boolean + display?: string + /** Large-paste bodies retained only so a failed queued turn can be retried losslessly. */ + pastes?: ReadonlyMap<number, string> + /** Identity-bearing `@` and `/` tags present in this submitted line. */ + contexts?: ChatContext[] + } + | { kind: 'clipboard'; value: string } + | { kind: 'selection'; values: string[] } + | { kind: 'interrupt'; empty?: boolean } + | { kind: 'eof' } + +type ChatActivityState = 'running' | 'complete' | 'error' + +export type ChatActivityUpdate = + | { + kind: 'tool' | 'subagent' + id: string + label: string + state: ChatActivityState + /** Opaque public id of the subagent lane that owns this row. */ + parentId?: string + } + | { + kind: 'narration' + /** Opaque public id of the subagent lane that owns this text. */ + parentId: string + delta: string + } + +export interface ChatActivity { + update(message: string): void + thinking(delta: string): void + event(update: ChatActivityUpdate): void + clear(): void + complete(): void + stop(): void +} + +export interface ChatTerminalQuestion { + prompt: string + multi: boolean + options: Array<{ id: string; label: string }> +} + +export interface ChatTerminalSelect { + prompt: string + options: Array<{ id: string; label: string; description?: string }> +} + +export interface ChatTerminalWelcome { + chatTitle: string + profile?: string + workspaceName?: string +} + +export type ChatTerminalQuestionResult = + | { kind: 'answer'; values: string[] } + | { kind: 'cancel' } + | { kind: 'eof' } + +export type ChatTerminalSelectResult = + | { kind: 'selected'; id: string } + | { kind: 'cancel' } + | { kind: 'eof' } + +export type ChatTerminalInterruptReason = 'manual' | 'submit' +export type ChatTerminalInterruptListener = ( + reason: ChatTerminalInterruptReason, + input?: ChatTerminalInput +) => void + +export interface ChatTerminal { + welcome(context: ChatTerminalWelcome): void + /** Updates the active conversation title after resume or server-side title generation. */ + setChatTitle(title: string): void + /** Fills in the workspace name once the lookup resolves. */ + setWorkspaceName(name: string): void + /** Inserts an `[Image #N]` tag at the cursor for a just-attached image. */ + noteAttachment(): void + /** Supplies the home-composer `@` resource and `/` skill/MCP pools. */ + setSuggestionCandidates?(candidates: ChatSuggestionCandidates): void + /** Clears the visible conversation while preserving the active terminal session. */ + clearTranscript(): void + userMessage(message: string): void + read(prompt: string): Promise<ChatTerminalInput> + /** Whether deferred input, a control, or a priority preload is waiting to be consumed. */ + hasQueuedInput(): boolean + /** Temporarily stages text ahead of queued turns without discarding the live draft. */ + preload( + value: string, + options?: { + queued?: boolean + pastes?: ReadonlyMap<number, string> + contexts?: ChatContext[] + } + ): boolean + status(message: string): void + /** Writes trusted, already-rendered assistant output into the coordinated transcript viewport. */ + write(content: string): void + activity(message: string): ChatActivity + askQuestion(question: ChatTerminalQuestion): Promise<ChatTerminalQuestionResult> + /** Opens a searchable, single-choice menu above the bottom-pinned search composer. */ + select(menu: ChatTerminalSelect): Promise<ChatTerminalSelectResult> + onInterrupt(listener: ChatTerminalInterruptListener): () => void + close(): void +} + +interface TerminalInput extends Readable { + isTTY?: boolean + isRaw?: boolean + setRawMode?: (mode: boolean) => void +} + +interface TerminalOutput extends Writable { + isTTY?: boolean + columns?: number + rows?: number +} + +interface CursorPoint { + index: number + row: number + column: number +} + +interface DraftLayout { + rows: string[] + points: CursorPoint[] + cursor: CursorPoint +} + +interface DraftLayoutOptions { + continuationPrefix?: string + normalTextStyle?: string +} + +interface RenderPanel { + lines: string[] + focusRow?: number + centerFocus?: boolean + cursor?: { row: number; column: number } +} + +interface QuestionState { + question: ChatTerminalQuestion + active: number + selected: Set<number> + previousDraft: string + previousCursor: number + previousContexts: ChatContext[] + resolve: (result: ChatTerminalQuestionResult) => void +} + +interface SelectState { + menu: ChatTerminalSelect + active: number + previousDraft: string + previousCursor: number + previousContexts: ChatContext[] + resolve: (result: ChatTerminalSelectResult) => void +} + +interface QueuedTerminalInput { + input: ChatTerminalInput + /** Composer text that has not already been committed to the transcript. */ + display?: string +} + +interface PreloadState { + initialDraft: string + previousDraft: string + previousCursor: number + previousPastes: Map<number, string> + previousContexts: ChatContext[] + queued: boolean +} + +interface RecalledQueueState { + index: number + initialDraft: string + /** Undefined when the original queue row was already committed. */ + commitDisplay?: string +} + +type ChatActivityStatusUpdate = Exclude<ChatActivityUpdate, { kind: 'narration' }> + +type ActivityTreeChild = { kind: 'node'; id: string } | { kind: 'narration'; content: string } + +interface ActivityTreeNode extends ChatActivityStatusUpdate { + children: ActivityTreeChild[] +} + +import { + applySuggestion, + type ChatContext, + type ChatSuggestionCandidates, + type CompletionToken, + contextSpans, + extractCompletionToken, + formatMention, + presentContexts, + rankSuggestions, + resolveSlashContexts, + SLASH_COMMANDS, + type SuggestionItem, + suggestionWindow, +} from './chat-suggestions.js' + +const ESC = '\u001b' +const RESET = `${ESC}[0m` +const DIM = `${ESC}[2m` +const HIDE_CURSOR = `${ESC}[?25l` +const SHOW_CURSOR = `${ESC}[?25h` +const ENTER_ALTERNATE_SCREEN = `${ESC}[?1049h` +const EXIT_ALTERNATE_SCREEN = `${ESC}[?1049l` +const ENABLE_BRACKETED_PASTE = `${ESC}[?2004h` +const DISABLE_BRACKETED_PASTE = `${ESC}[?2004l` +const BEGIN_SYNCHRONIZED_OUTPUT = `${ESC}[?2026h` +const END_SYNCHRONIZED_OUTPUT = `${ESC}[?2026l` +const CLEAR_SCREEN = `${ESC}[2J` +const RESET_SCROLL_REGION = `${ESC}[r` +const BOLD = `${ESC}[1m` +const BRIGHT_WHITE = `${ESC}[97m` +/** Sim green — marks a mention that currently resolves to a candidate. */ +const MENTION_TEXT = `${ESC}[38;2;51;196;130m` +const USER_MESSAGE_BACKGROUND = `${ESC}[48;2;58;60;70m` +const USER_MESSAGE_TEXT = `${ESC}[38;2;242;242;242m` +const USER_MESSAGE_POINTER = `${ESC}[38;2;160;160;160m` +const USER_PANEL_OUTER_MARGIN = ' ' +const USER_TURN_PREFIX = `${USER_PANEL_OUTER_MARGIN}❯ ` +const ASSISTANT_TURN_PREFIX = '● ' +const DEFAULT_CHAT_TITLE = 'New chat' +const CONTINUATION_PREFIX = ' ' +const MAX_TRANSCRIPT_CHARACTERS = 256 * 1024 +const MAX_HISTORY_ENTRIES = 500 +const MAX_DRAFT_CHARACTERS = 10 * 1024 * 1024 +/** Above this, or across multiple lines, a paste collapses to a placeholder. */ +const PASTE_PLACEHOLDER_CHARACTERS = 800 +const PASTE_PLACEHOLDER_LINES = 3 +const PASTED_TEXT_REF = /\[Pasted text #(\d+)(?: \+\d+ lines)?\]/g +const BLIMP_ART = [ + ' ⣀⣀⣀', + ' ⡇ ⢳⡀⣀⣀⣀⠤⢤⣤⣤⣤⠤⠤⠤⣀⣀⣀', + ' ⢻⣀⡴⠂⠉⢹⠤⠤⣜⠁ ⢘⡦⠤⠤⡞⠉⠉⠙⠻⡖⠢⢄⡀', + '⠤⠶⠶⠮⣤⣽⠤⠴⡋ ⢘⡦⠤⢤⡏ ⠹⣄⣀⣀⡴⠋⠉⢹⢺⡄', + ' ⠈⠓⠤⢄⣹⡤⠤⢜ ⠳⣄⣀⣀⡞ ⢙⣦⣖⠾⠋', + ' ⠈⠉⠉⣝⣀⣀⣚⣀⡔⠒⠛⠓⠊⠉⠉', +] as const + +/** Frames and cadence for the airship sliding in from the left on first paint. */ +const WELCOME_FLY_IN_FRAMES = 20 +const WELCOME_FLY_IN_INTERVAL_MS = 22 +/** Columns the detail box needs beside the art before it is worth drawing. */ +const WELCOME_MIN_BOX_COLUMNS = 26 +/** Blank columns between the detail box and the airship. */ +const WELCOME_GUTTER = 2 + +function formatActivityDuration(elapsedMs: number): string { + let seconds = Math.max(1, Math.round(Math.max(0, elapsedMs) / 1000)) + const hours = Math.floor(seconds / 3600) + seconds %= 3600 + const minutes = Math.floor(seconds / 60) + seconds %= 60 + return [hours ? `${hours}h` : '', minutes ? `${minutes}m` : '', seconds ? `${seconds}s` : ''] + .filter(Boolean) + .join(' ') +} + +/** Shared row treatment for the editable composer and its committed user turn. */ +function userPanelRow(content = ''): string { + return `${USER_PANEL_OUTER_MARGIN}${USER_MESSAGE_BACKGROUND}${content}${RESET}` +} + +/** A fullscreen terminal chat with a durable transcript and a bottom-pinned composer. */ +export class ReadlineChatTerminal implements ChatTerminal { + private pending: ((input: ChatTerminalInput) => void) | null = null + private readonly queued: QueuedTerminalInput[] = [] + private recalledQueue: RecalledQueueState | null = null + private readonly interruptListeners = new Set<ChatTerminalInterruptListener>() + private readonly history: string[] = [] + private historyIndex = 0 + private historyDraft = '' + private preferredColumn: number | null = null + private prompt = '❯ ' + private draft = '' + private cursor = 0 + private preloadState: PreloadState | null = null + private composerVisible = false + private busy = false + private questionState: QuestionState | null = null + private selectState: SelectState | null = null + private welcomeVisible = false + private welcomeProfile: string | null = null + private welcomeChatTitle = DEFAULT_CHAT_TITLE + private welcomeWorkspaceName: string | null = null + private suggestionIndex = 0 + private suggestionQueryKey: string | null = null + private suggestionDismissed: string | null = null + private resourceCandidates: SuggestionItem[] = [] + private slashCandidates: SuggestionItem[] = [] + private selectedContexts: ChatContext[] = [] + private nextAttachmentNumber = 1 + private pasting = false + private pasteBuffer = '' + private pastedText = new Map<number, string>() + private nextPasteId = 1 + private transcriptEpoch = 0 + private wrapCache: { + width: number + epoch: number + consumed: number + rows: string[] + state: WrapState + } | null = null + private welcomeRevealFrame = WELCOME_FLY_IN_FRAMES + private welcomeTimer: ReturnType<typeof setInterval> | null = null + private transcript = '' + private assistantPrefixPending = false + private assistantPrefixBuffer = '' + private assistantTurnActive = false + private assistantContinuationPending = false + private transcriptScrollTopRow: number | null = null + private viewportActive = false + private renderedScreen: string[] | null = null + private renderedColumns = 0 + private renderedRows = 0 + private restoredRawMode = false + private readonly inputWasRaw: boolean + private readonly inputWasFlowing: boolean + private ended = false + private closed = false + private activityActive = false + private activityThinking = '' + private activityStartedAt = 0 + private activityGeneration = 0 + private readonly activityNodes = new Map<string, ActivityTreeNode>() + private readonly activityRoots: string[] = [] + private readonly committedActivityRoots = new Set<string>() + private activityFrame = 0 + private activityTimer: ReturnType<typeof setInterval> | null = null + + constructor( + private readonly input: Readable = process.stdin, + private readonly output: Writable = process.stdout + ) { + this.inputWasFlowing = input.readableFlowing === true + this.inputWasRaw = Boolean((input as TerminalInput).isRaw) + emitKeypressEvents(input) + input.on('keypress', this.handleKeypress) + input.once('end', this.handleInputEnd) + output.on('resize', this.handleResize) + } + + welcome(context: ChatTerminalWelcome): void { + if (!this.isInteractiveTTY() || this.closed) return + this.welcomeVisible = true + this.welcomeProfile = context.profile ? safeOneLine(context.profile).slice(0, 80) : null + this.welcomeChatTitle = safeOneLine(context.chatTitle).slice(0, 160) || DEFAULT_CHAT_TITLE + this.welcomeWorkspaceName = context.workspaceName + ? safeOneLine(context.workspaceName).slice(0, 80) + : null + this.startWelcomeFlyIn() + this.ensureViewport() + this.renderScreen() + } + + userMessage(message: string): void { + if (!message.trim()) return + this.commitUserLine(message) + this.renderScreen() + } + + clearTranscript(): void { + this.stopActivity() + this.transcript = '' + this.transcriptEpoch += 1 + this.wrapCache = null + this.assistantPrefixPending = false + this.assistantPrefixBuffer = '' + this.assistantTurnActive = false + this.assistantContinuationPending = false + this.transcriptScrollTopRow = null + this.history.length = 0 + this.historyIndex = 0 + this.historyDraft = '' + this.renderScreen() + } + + read(prompt: string): Promise<ChatTerminalInput> { + if (this.closed) return Promise.resolve({ kind: 'eof' }) + const queued = this.preloadState ? undefined : this.queued.shift() + if (queued) { + if (this.recalledQueue && this.recalledQueue.index > 0) { + this.recalledQueue.index-- + } + const { input } = queued + if (input.kind === 'line') { + for (const [id, body] of input.pastes ?? []) this.pastedText.set(id, body) + if (queued.display?.trim()) this.commitUserLine(queued.display) + } + this.renderScreen() + return Promise.resolve(input) + } + if (this.ended) return Promise.resolve({ kind: 'eof' }) + if (this.pending || this.questionState || this.selectState) { + throw new Error('Chat terminal already has a pending read') + } + + this.prompt = sanitize(prompt) + .replace(/[\n\r\t]+/gu, ' ') + .slice(0, 80) + this.draft = sanitize(this.draft).slice(0, MAX_DRAFT_CHARACTERS) + this.cursor = Math.min(this.cursor, this.draft.length) + this.preferredColumn = null + this.historyIndex = this.history.length + this.historyDraft = this.draft + this.composerVisible = true + this.busy = false + this.ensureViewport() + + if (!this.isInteractiveTTY()) this.output.write(this.prompt) + this.renderScreen() + return new Promise((resolve) => { + this.pending = resolve + this.renderScreen() + }) + } + + hasQueuedInput(): boolean { + return this.preloadState !== null || this.queued.length > 0 + } + + preload( + value: string, + options: { + queued?: boolean + pastes?: ReadonlyMap<number, string> + contexts?: ChatContext[] + } = {} + ): boolean { + if ( + this.closed || + this.ended || + this.pending || + this.busy || + this.questionState || + this.selectState || + this.preloadState + ) { + return false + } + const next = sanitize(value).slice(0, MAX_DRAFT_CHARACTERS) + if (!next) return false + + this.preloadState = { + initialDraft: next, + previousDraft: this.draft, + previousCursor: this.cursor, + previousPastes: this.pastesFor(this.draft), + previousContexts: this.selectedContexts, + queued: options.queued === true, + } + for (const [id, body] of options.pastes ?? []) this.pastedText.set(id, body) + this.draft = next + this.cursor = next.length + this.selectedContexts = [...(options.contexts ?? [])] + this.preferredColumn = null + this.composerVisible = true + this.renderScreen() + return true + } + + status(message: string): void { + const safe = sanitize(message) + if (!this.isInteractiveTTY()) { + this.output.write(safe) + if (!safe.endsWith('\n')) this.output.write('\n') + return + } + + this.ensureViewport() + this.assistantTurnActive = false + this.assistantContinuationPending = false + this.appendTranscript(safe) + if (!safe.endsWith('\n')) this.appendTranscript('\n') + this.renderScreen() + } + + write(content: string): void { + if (!content) return + if (!this.isInteractiveTTY()) { + this.output.write(content) + return + } + + this.ensureViewport() + let rendered = content.replace(/\r/gu, '') + if (this.assistantPrefixPending) { + this.assistantPrefixBuffer += rendered + const prefixed = prefixAssistantTurn(this.assistantPrefixBuffer) + if (prefixed === null) return + rendered = prefixed + this.assistantPrefixPending = false + this.assistantPrefixBuffer = '' + this.assistantTurnActive = true + this.assistantContinuationPending = rendered.endsWith('\n') + } else if (this.assistantTurnActive) { + rendered = indentAssistantFragment(rendered, this.assistantContinuationPending) + this.assistantContinuationPending = rendered.endsWith('\n') + } + this.appendTranscript(rendered) + this.renderScreen() + } + + activity(message: string): ChatActivity { + this.stopActivity() + this.assistantPrefixPending = true + this.assistantPrefixBuffer = '' + this.assistantTurnActive = false + this.assistantContinuationPending = false + this.activityActive = true + this.activityThinking = safeOneLine(message) || 'Thinking…' + this.activityStartedAt = Date.now() + const generation = ++this.activityGeneration + this.activityNodes.clear() + this.activityRoots.length = 0 + this.committedActivityRoots.clear() + this.activityFrame = 0 + this.busy = true + this.composerVisible = true + this.ensureViewport() + this.renderScreen() + + if (this.isInteractiveTTY()) { + this.activityTimer = setInterval(() => { + this.activityFrame += 1 + this.renderScreen() + }, 90) + this.activityTimer.unref() + } + + let stopped = false + const isCurrent = () => + !stopped && this.activityActive && generation === this.activityGeneration + const finish = (completed: boolean) => { + if (!isCurrent()) return + stopped = true + this.stopActivity(completed) + } + return { + update: (next) => { + if (!isCurrent()) return + this.activityThinking = safeOneLine(next) || this.activityThinking + this.renderScreen() + }, + thinking: (_delta) => { + if (!isCurrent()) return + // Match the web client: raw reasoning is not rendered; the stable + // turn-level label remains visible in the tail instead. + }, + event: (update) => { + if (!isCurrent()) return + this.recordActivityEvent(update) + this.renderScreen() + }, + clear: () => { + if (!isCurrent()) return + this.commitActivityEvents(false) + this.renderScreen() + }, + complete: () => finish(true), + stop: () => finish(false), + } + } + + askQuestion(question: ChatTerminalQuestion): Promise<ChatTerminalQuestionResult> { + if (this.pending || this.questionState || this.selectState) { + throw new Error('Chat terminal already has a pending read') + } + if (this.ended || this.closed) return Promise.resolve({ kind: 'eof' }) + + const safeQuestion: ChatTerminalQuestion = { + prompt: safeOneLine(question.prompt).slice(0, 500), + multi: question.multi, + options: question.options.slice(0, 20).map((option) => ({ + id: safeOneLine(option.id).slice(0, 160), + label: safeOneLine(option.label).slice(0, 160), + })), + } + const previousDraft = this.draft + const previousCursor = this.cursor + const previousContexts = this.selectedContexts + this.draft = '' + this.cursor = 0 + this.selectedContexts = [] + this.preferredColumn = null + this.composerVisible = true + this.busy = false + this.ensureViewport() + + return new Promise((resolve) => { + this.questionState = { + question: safeQuestion, + active: 0, + selected: new Set(), + previousDraft, + previousCursor, + previousContexts, + resolve, + } + this.renderScreen() + }) + } + + select(menu: ChatTerminalSelect): Promise<ChatTerminalSelectResult> { + if (this.pending || this.questionState || this.selectState) { + throw new Error('Chat terminal already has a pending read') + } + if (this.ended || this.closed) return Promise.resolve({ kind: 'eof' }) + + const safeMenu: ChatTerminalSelect = { + prompt: safeOneLine(menu.prompt).slice(0, 500), + options: menu.options.map((option) => ({ + id: safeOneLine(option.id).slice(0, 160), + label: safeOneLine(option.label).slice(0, 255), + ...(option.description + ? { description: safeOneLine(option.description).slice(0, 255) } + : {}), + })), + } + const previousDraft = this.draft + const previousCursor = this.cursor + const previousContexts = this.selectedContexts + this.draft = '' + this.cursor = 0 + this.selectedContexts = [] + this.preferredColumn = null + this.composerVisible = true + this.busy = false + this.ensureViewport() + + return new Promise((resolve) => { + this.selectState = { + menu: safeMenu, + active: 0, + previousDraft, + previousCursor, + previousContexts, + resolve, + } + this.renderScreen() + }) + } + + onInterrupt(listener: ChatTerminalInterruptListener): () => void { + this.interruptListeners.add(listener) + return () => this.interruptListeners.delete(listener) + } + + close(): void { + if (this.closed) return + this.stopActivity() + this.stopWelcomeFlyIn() + this.closed = true + + const pending = this.pending + this.pending = null + pending?.({ kind: 'eof' }) + const question = this.questionState + this.questionState = null + question?.resolve({ kind: 'eof' }) + const select = this.selectState + this.selectState = null + select?.resolve({ kind: 'eof' }) + + this.input.removeListener('keypress', this.handleKeypress) + this.input.removeListener('end', this.handleInputEnd) + this.output.removeListener('resize', this.handleResize) + + if (this.viewportActive) { + this.output.write( + `${BEGIN_SYNCHRONIZED_OUTPUT}${RESET}${RESET_SCROLL_REGION}${DISABLE_BRACKETED_PASTE}${SHOW_CURSOR}${EXIT_ALTERNATE_SCREEN}${END_SYNCHRONIZED_OUTPUT}` + ) + this.viewportActive = false + } + this.restoreInputMode() + } + + private readonly handleResize = (): void => { + this.renderScreen() + } + + private readonly handleInputEnd = (): void => { + this.ended = true + const pending = this.pending + this.pending = null + pending?.({ kind: 'eof' }) + const question = this.questionState + this.questionState = null + question?.resolve({ kind: 'eof' }) + const select = this.selectState + this.selectState = null + select?.resolve({ kind: 'eof' }) + this.renderScreen() + } + + private readonly handleKeypress = (character: string, key: Key | undefined): void => { + if (this.closed) return + if (key?.name === 'paste-start') { + this.pasting = true + this.pasteBuffer = '' + return + } + if (key?.name === 'paste-end') { + const pasted = this.pasteBuffer + this.pasting = false + this.pasteBuffer = '' + this.commitPaste(pasted) + return + } + if (this.pasting) { + if (character) this.pasteBuffer += character + return + } + + if (this.selectState) { + this.handleSelectKey(character, key) + return + } + + if (this.handleTranscriptNavigationKey(key)) return + + if (key?.ctrl && key.name === 'v') { + this.resolveClipboard() + return + } + if (this.questionState) { + this.handleQuestionKey(character, key) + return + } + + this.handleEditorKey(character, key) + } + + private handleEditorKey(character: string, key: Key | undefined): void { + if (!this.isComposerEditable() && this.isInteractiveTTY()) return + if (key?.ctrl && key.name === 'c') { + if (!this.pending && this.busy) { + for (const listener of this.interruptListeners) listener('manual') + return + } + const wasEmpty = this.draft.length === 0 + this.draft = '' + this.cursor = 0 + this.preferredColumn = null + this.resolveInput({ kind: 'interrupt', empty: wasEmpty }) + return + } + if (key?.ctrl && key.name === 'd' && this.draft.length === 0) { + this.resolveInput({ kind: 'eof' }) + return + } + const open = this.openSuggestions() + if (open) { + if (key?.name === 'up' || (key?.ctrl && key.name === 'p')) { + this.moveSuggestion(open.items.length, -1) + return + } + if (key?.name === 'down' || (key?.ctrl && key.name === 'n')) { + this.moveSuggestion(open.items.length, 1) + return + } + if (key?.name === 'escape') { + this.suggestionDismissed = this.draft + this.renderScreen() + return + } + if (key?.name === 'tab' || isEnter(key)) { + const chosen = open.items[Math.min(this.suggestionIndex, open.items.length - 1)] + const submitExactSlash = + isEnter(key) && + chosen?.tag === 'command' && + open.token.trigger === '/' && + chosen.value === open.token.token + if (!submitExactSlash) { + this.acceptSuggestion(open) + return + } + } + } + if (key?.name === 'escape') { + if (!this.pending && this.busy) { + for (const listener of this.interruptListeners) listener('manual') + return + } + const wasEmpty = this.draft.length === 0 + this.draft = '' + this.cursor = 0 + this.resolveInput({ kind: 'interrupt', empty: wasEmpty }) + return + } + + if (isEnter(key)) { + const beforeCursor = this.draft.slice(0, this.cursor) + if (key?.shift || key?.meta || beforeCursor.endsWith('\\')) { + if (beforeCursor.endsWith('\\')) { + this.draft = `${beforeCursor.slice(0, -1)}\n${this.draft.slice(this.cursor)}` + this.cursor = beforeCursor.length + } else { + this.insertText('\n') + } + this.renderScreen() + return + } + this.submitDraft() + return + } + if (key?.name === 'backspace') { + this.deleteBackward() + return + } + if (key?.name === 'delete') { + this.deleteForward() + return + } + if (key?.name === 'left' || (key?.ctrl && key.name === 'b')) { + this.cursor = previousGraphemeIndex(this.draft, this.cursor) + this.preferredColumn = null + this.renderScreen() + return + } + if (key?.name === 'right' || (key?.ctrl && key.name === 'f')) { + this.cursor = nextGraphemeIndex(this.draft, this.cursor) + this.preferredColumn = null + this.renderScreen() + return + } + if (key?.name === 'up' || (key?.ctrl && key.name === 'p')) { + if (this.draft.length === 0 && this.recallQueuedDraft()) return + this.moveVertically(-1) + return + } + if (key?.name === 'down' || (key?.ctrl && key.name === 'n')) { + this.moveVertically(1) + return + } + if (key?.name === 'home' || (key?.ctrl && key.name === 'a')) { + this.cursor = lineStart(this.draft, this.cursor) + this.preferredColumn = null + this.renderScreen() + return + } + if (key?.name === 'end' || (key?.ctrl && key.name === 'e')) { + this.cursor = lineEnd(this.draft, this.cursor) + this.preferredColumn = null + this.renderScreen() + return + } + if (key?.ctrl && key.name === 'u') { + this.draft = this.draft.slice(this.cursor) + this.cursor = 0 + this.preferredColumn = null + this.renderScreen() + return + } + if (key?.ctrl && key.name === 'k') { + this.draft = this.draft.slice(0, this.cursor) + this.preferredColumn = null + this.renderScreen() + return + } + if (key?.ctrl && key.name === 'w') { + const before = this.draft.slice(0, this.cursor) + const start = before.search(/\S+\s*$/u) + if (start >= 0) { + this.draft = `${before.slice(0, start)}${this.draft.slice(this.cursor)}` + this.cursor = start + } + this.preferredColumn = null + this.renderScreen() + return + } + + const printable = printableText(character, key) + if (printable) { + this.insertText(printable) + this.renderScreen() + } + } + + private handleQuestionKey(character: string, key: Key | undefined): void { + const state = this.questionState + if (!state) return + const otherIndex = state.question.options.length + const submitIndex = state.question.multi ? otherIndex + 1 : otherIndex + const choiceCount = submitIndex + 1 + + if (key?.name === 'escape' || (key?.ctrl && key.name === 'c')) { + this.finishQuestion({ kind: 'cancel' }) + return + } + if (key?.name === 'up' || (key?.ctrl && key.name === 'p')) { + state.active = (state.active - 1 + choiceCount) % choiceCount + this.renderScreen() + return + } + if (key?.name === 'down' || key?.name === 'tab' || (key?.ctrl && key.name === 'n')) { + state.active = (state.active + 1) % choiceCount + this.renderScreen() + return + } + if (state.question.multi && key?.name === 'space' && state.active < otherIndex) { + this.toggleQuestionSelection(state.active) + return + } + if (/^[1-9]$/u.test(character) && this.draft.length === 0) { + const index = Number(character) - 1 + if (index < state.question.options.length) { + state.active = index + this.renderScreen() + return + } + } + if (isEnter(key)) { + if (state.active < otherIndex) { + if (state.question.multi) this.toggleQuestionSelection(state.active) + else { + const selected = state.question.options[state.active] + if (selected) this.finishQuestion({ kind: 'answer', values: [selected.label] }) + } + return + } + + const custom = safeOneLine(this.draft) + if (state.active === otherIndex && custom) { + const values = state.question.multi ? [...this.selectedQuestionLabels(), custom] : [custom] + this.finishQuestion({ kind: 'answer', values: [...new Set(values)] }) + return + } + if (state.question.multi && state.active === submitIndex) { + const values = this.selectedQuestionLabels() + if (custom) values.push(custom) + if (values.length > 0) { + this.finishQuestion({ kind: 'answer', values: [...new Set(values)] }) + } + } + return + } + + if (state.active === otherIndex) { + if (key?.name === 'backspace') { + this.deleteBackward() + return + } + if (key?.name === 'delete') { + this.deleteForward() + return + } + if (key?.name === 'left') { + this.cursor = previousGraphemeIndex(this.draft, this.cursor) + this.renderScreen() + return + } + if (key?.name === 'right') { + this.cursor = nextGraphemeIndex(this.draft, this.cursor) + this.renderScreen() + return + } + } + + const printable = printableText(character, key) + if (printable) { + state.active = otherIndex + this.insertText(printable) + this.renderScreen() + } + } + + private handleSelectKey(character: string, key: Key | undefined): void { + const state = this.selectState + if (!state) return + const options = this.filteredSelectOptions() + + if (key?.name === 'escape' || (key?.ctrl && key.name === 'c')) { + this.finishSelect({ kind: 'cancel' }) + return + } + if (key?.name === 'up' || (key?.ctrl && key.name === 'p')) { + if (options.length > 0) state.active = (state.active - 1 + options.length) % options.length + this.renderScreen() + return + } + if (key?.name === 'down' || key?.name === 'tab' || (key?.ctrl && key.name === 'n')) { + if (options.length > 0) state.active = (state.active + 1) % options.length + this.renderScreen() + return + } + if (isEnter(key)) { + if (this.selectOptionCapacity() <= 0) return + const selected = options[Math.min(state.active, options.length - 1)] + if (selected) this.finishSelect({ kind: 'selected', id: selected.id }) + return + } + if (key?.name === 'backspace') { + state.active = 0 + this.deleteBackward() + return + } + if (key?.name === 'delete') { + state.active = 0 + this.deleteForward() + return + } + if (key?.name === 'left' || (key?.ctrl && key.name === 'b')) { + this.cursor = previousGraphemeIndex(this.draft, this.cursor) + this.renderScreen() + return + } + if (key?.name === 'right' || (key?.ctrl && key.name === 'f')) { + this.cursor = nextGraphemeIndex(this.draft, this.cursor) + this.renderScreen() + return + } + if (key?.name === 'home' || (key?.ctrl && key.name === 'a')) { + this.cursor = 0 + this.renderScreen() + return + } + if (key?.name === 'end' || (key?.ctrl && key.name === 'e')) { + this.cursor = this.draft.length + this.renderScreen() + return + } + if (key?.ctrl && key.name === 'u') { + this.draft = this.draft.slice(this.cursor) + this.cursor = 0 + state.active = 0 + this.renderScreen() + return + } + if (key?.ctrl && key.name === 'k') { + this.draft = this.draft.slice(0, this.cursor) + state.active = 0 + this.renderScreen() + return + } + if (key?.ctrl && key.name === 'w') { + const before = this.draft.slice(0, this.cursor) + const start = before.search(/\S+\s*$/u) + if (start >= 0) { + this.draft = `${before.slice(0, start)}${this.draft.slice(this.cursor)}` + this.cursor = start + } + state.active = 0 + this.renderScreen() + return + } + + const printable = printableText(character, key) + if (printable) { + this.insertText(printable) + state.active = 0 + this.renderScreen() + } + } + + /** + * Recomputed each render rather than tracked on every draft mutation, so the + * menu can never disagree with the text it is completing. + */ + private openSuggestions(): { + token: CompletionToken + items: SuggestionItem[] + pool: SuggestionItem[] + } | null { + if ( + !this.isComposerEditable() || + this.questionState || + this.selectState || + this.terminalRows() < 5 + ) { + this.suggestionIndex = 0 + this.suggestionQueryKey = null + return null + } + if (this.suggestionDismissed !== null) { + if (this.suggestionDismissed === this.draft) return null + this.suggestionDismissed = null + } + const token = extractCompletionToken(this.draft, this.cursor) + if (!token) { + this.suggestionIndex = 0 + this.suggestionQueryKey = null + return null + } + const queryKey = `${token.startPos}:${token.trigger}:${token.query}` + if (queryKey !== this.suggestionQueryKey) { + this.suggestionIndex = 0 + this.suggestionQueryKey = queryKey + } + const commandPosition = this.draft.slice(0, token.startPos).trim().length === 0 + const pool = + token.trigger === '/' + ? [...(commandPosition ? SLASH_COMMANDS : []), ...this.slashCandidates] + : this.resourceCandidates + if (!pool.length) return null + const items = rankSuggestions(token.query, pool) + return items.length ? { token, items, pool } : null + } + + setSuggestionCandidates(candidates: ChatSuggestionCandidates): void { + const open = this.openSuggestions() + const selectedId = open?.items[Math.min(this.suggestionIndex, open.items.length - 1)]?.id + this.resourceCandidates = candidates.resources + .map(sanitizeSuggestionItem) + .filter((item): item is SuggestionItem => item !== null) + this.slashCandidates = candidates.slash + .map(sanitizeSuggestionItem) + .filter((item): item is SuggestionItem => item !== null) + if (selectedId) { + const refreshed = this.openSuggestions() + const refreshedIndex = refreshed?.items.findIndex((item) => item.id === selectedId) ?? -1 + this.suggestionIndex = refreshedIndex >= 0 ? refreshedIndex : 0 + } + if (!this.closed && this.isComposerEditable()) this.renderScreen() + } + + private moveSuggestion(total: number, delta: number): void { + this.suggestionIndex = (this.suggestionIndex + delta + total) % total + this.renderScreen() + } + + private acceptSuggestion(open: { token: CompletionToken; items: SuggestionItem[] }): void { + const chosen = open.items[Math.min(this.suggestionIndex, open.items.length - 1)] + if (!chosen) return + const replacement = + open.token.trigger === '@' + ? formatMention(chosen.value) + : chosen.tag === 'command' + ? chosen.value + : `/${chosen.value}` + const next = applySuggestion(this.draft, open.token, replacement) + this.draft = next.draft + this.cursor = next.cursor + if ( + chosen.context && + !this.selectedContexts.some((context) => context.label === chosen.context?.label) + ) { + this.selectedContexts.push(chosen.context) + } + this.suggestionIndex = 0 + this.suggestionDismissed = this.draft + this.renderScreen() + } + + /** + * Re-derived every render rather than stored, so a mention the user + * half-deletes simply stops lighting up instead of leaving stale state. + */ + private liveMentionSpans(): Array<{ start: number; end: number }> { + const selected = presentContexts(this.draft, this.selectedContexts) + const occupied = new Set(selected.map((context) => context.label.toLowerCase())) + const typedSlash = resolveSlashContexts(this.draft, this.slashCandidates).filter( + (context) => !occupied.has(context.label.toLowerCase()) + ) + return [ + ...contextSpans(this.draft, [...selected, ...typedSlash]), + ...attachmentSpans(this.draft), + ].sort((left, right) => left.start - right.start) + } + + private suggestionRows(width: number, rows: number): string[] { + const open = this.openSuggestions() + if (!open) return [] + const maxVisible = Math.max(1, Math.min(5, rows - 6)) + const selected = Math.min(this.suggestionIndex, open.items.length - 1) + const { start, end } = suggestionWindow(open.items.length, selected, maxVisible) + /* Width comes from the whole pool, not the filtered slice, so the column + does not jump while the user narrows the list. */ + const labelWidth = Math.min( + Math.floor(width * 0.4), + Math.max(...open.pool.map((entry) => displayWidth(entry.displayText))) + 2 + ) + return open.items.slice(start, end).map((entry) => { + const active = entry.id === open.items[selected]?.id + const label = truncateDisplay(entry.displayText, Math.max(1, labelWidth - 2)) + const padding = ' '.repeat(Math.max(2, labelWidth - displayWidth(label))) + const line = truncateDisplay(` ${label}${padding}${entry.description ?? ''}`, width) + return active ? `${BRIGHT_WHITE}${line}${RESET}` : `${DIM}${line}${RESET}` + }) + } + + /** + * Turns one bracketed paste into a single edit. + * + * An empty paste is macOS Cmd+V of an image — the terminal sends the markers + * with nothing between them — so it routes to the same clipboard path as + * ctrl+v rather than being discarded. + */ + private commitPaste(text: string): void { + if (!this.isComposerEditable()) return + const normalized = text.replace(/\r\n?/gu, '\n') + if (!normalized) { + if (this.selectState) return + this.resolveClipboard() + return + } + if (this.selectState) { + this.insertText(normalized.replace(/\s+/gu, ' ')) + this.selectState.active = 0 + this.renderScreen() + return + } + const lines = normalized.split('\n').length - 1 + if (normalized.length > PASTE_PLACEHOLDER_CHARACTERS || lines >= PASTE_PLACEHOLDER_LINES) { + const id = this.nextPasteId++ + this.pastedText.set(id, normalized) + this.insertText(lines ? `[Pasted text #${id} +${lines} lines]` : `[Pasted text #${id}]`) + } else { + this.insertText(normalized) + } + this.renderScreen() + } + + /** Splices stashed paste bodies back in, and drops any the user deleted. */ + private pastesFor(value: string): Map<number, string> { + const pastes = new Map<number, string>() + for (const match of value.matchAll(PASTED_TEXT_REF)) { + const id = Number(match[1]) + const body = this.pastedText.get(id) + if (body !== undefined) pastes.set(id, body) + } + return pastes + } + + private expandPastes(value: string): string { + const referenced = new Set<number>() + const expanded = value.replace(PASTED_TEXT_REF, (match, id: string) => { + const body = this.pastedText.get(Number(id)) + if (body === undefined) return match + referenced.add(Number(id)) + return body + }) + for (const id of this.pastedText.keys()) if (!referenced.has(id)) this.pastedText.delete(id) + return expanded + } + + private submitDraft(): void { + /* The placeholder is what the user sees and recalls; only the wire value + carries the expanded body, so a large paste never floods the transcript. */ + const display = this.draft + const preload = this.preloadState + const deferred = !this.pending + if (deferred && !display.trim()) { + this.draft = '' + this.cursor = 0 + this.preferredColumn = null + this.recalledQueue = null + this.renderScreen() + return + } + const pastes = this.pastesFor(display) + const value = this.expandPastes(display) + const selected = presentContexts(value, this.selectedContexts) + const occupied = new Set(selected.map((context) => context.label.toLowerCase())) + const contexts = [ + ...selected, + ...resolveSlashContexts(value, this.slashCandidates).filter( + (context) => !occupied.has(context.label.toLowerCase()) + ), + ] + this.transcriptScrollTopRow = null + if (display.trim()) { + if (this.history.at(-1) !== display) this.history.push(display) + if (this.history.length > MAX_HISTORY_ENTRIES) this.history.shift() + // A queued retry was already committed when it first left the queue. + // Repaint it only if the user edited the staged retry. + const unchangedCommittedRecall = + this.recalledQueue?.commitDisplay === undefined && + display === this.recalledQueue?.initialDraft + if ( + !deferred && + !(preload?.queued && display === preload.initialDraft) && + !unchangedCommittedRecall + ) { + this.commitUserLine(display) + } + } + this.draft = '' + this.cursor = 0 + this.selectedContexts = [] + this.preferredColumn = null + this.historyIndex = this.history.length + const input: ChatTerminalInput = { + kind: 'line', + value, + ...(display !== value ? { display } : {}), + ...(pastes.size ? { pastes } : {}), + ...(contexts.length ? { contexts } : {}), + } + this.resolveInput(input, display) + if (deferred && this.busy && display.trim()) { + for (const listener of this.interruptListeners) listener('submit', input) + } + } + + private resolveClipboard(): void { + if (!this.isComposerEditable()) return + this.resolveInput({ kind: 'clipboard', value: this.draft }) + } + + private resolveInput(value: ChatTerminalInput, display?: string): void { + const pending = this.pending + this.pending = null + let resolved = value + const preload = value.kind === 'clipboard' ? null : this.preloadState + if (preload && value.kind !== 'clipboard') { + this.preloadState = null + if (value.kind === 'line' && preload.queued) { + resolved = { + ...value, + queued: true, + ...(display === undefined ? {} : { display }), + } + } + this.draft = preload.previousDraft + this.cursor = preload.previousCursor + this.selectedContexts = preload.previousContexts + for (const [id, body] of preload.previousPastes) this.pastedText.set(id, body) + } + const recalled = !preload && resolved.kind === 'line' ? this.recalledQueue : null + if (!preload && resolved.kind !== 'clipboard') this.recalledQueue = null + + if (pending) { + pending(resolved) + } else { + if (resolved.kind === 'line') { + resolved = { + ...resolved, + queued: true, + ...(display === undefined ? {} : { display }), + } + } + const entry = { + input: resolved, + ...(!( + (preload?.queued && display === preload.initialDraft) || + (recalled?.commitDisplay === undefined && display === recalled?.initialDraft) + ) + ? { display } + : {}), + } + if (preload) { + this.queued.unshift(entry) + if (this.recalledQueue) this.recalledQueue.index++ + } else if (recalled) { + this.queued.splice(Math.min(recalled.index, this.queued.length), 0, entry) + } else { + this.queued.push(entry) + } + } + this.renderScreen() + } + + private finishQuestion(result: ChatTerminalQuestionResult): void { + const state = this.questionState + if (!state) return + this.questionState = null + this.draft = state.previousDraft + this.cursor = state.previousCursor + this.selectedContexts = state.previousContexts + if (result.kind === 'answer') this.commitUserLine(result.values.join(', ')) + this.renderScreen() + state.resolve(result) + } + + private finishSelect(result: ChatTerminalSelectResult): void { + const state = this.selectState + if (!state) return + this.selectState = null + this.draft = state.previousDraft + this.cursor = state.previousCursor + this.selectedContexts = state.previousContexts + this.preferredColumn = null + this.renderScreen() + state.resolve(result) + } + + private filteredSelectOptions(): ChatTerminalSelect['options'] { + const state = this.selectState + if (!state) return [] + const query = safeOneLine(this.draft).trim().toLocaleLowerCase() + if (!query) return state.menu.options + return state.menu.options.filter((option) => + `${option.label}\n${option.description ?? ''}`.toLocaleLowerCase().includes(query) + ) + } + + private selectOptionCapacity(): number { + return Math.max(0, Math.min(8, this.terminalRows() - 5)) + } + + private selectedQuestionLabels(): string[] { + const state = this.questionState + if (!state) return [] + return [...state.selected] + .sort((left, right) => left - right) + .map((index) => state.question.options[index]?.label) + .filter((label): label is string => Boolean(label)) + } + + private toggleQuestionSelection(index: number): void { + const state = this.questionState + if (!state) return + if (state.selected.has(index)) state.selected.delete(index) + else state.selected.add(index) + this.renderScreen() + } + + private isComposerEditable(): boolean { + return Boolean(this.composerVisible && !this.questionState && !this.closed && !this.ended) + } + + /** Recalls the newest deferred line without disturbing earlier FIFO entries. */ + private recallQueuedDraft(): boolean { + for (let index = this.queued.length - 1; index >= 0; index -= 1) { + const queued = this.queued[index] + if (queued.input.kind !== 'line' || queued.input.display === undefined) continue + this.queued.splice(index, 1) + this.recalledQueue = { + index, + initialDraft: queued.input.display, + ...(queued.display === undefined ? {} : { commitDisplay: queued.display }), + } + for (const [id, body] of queued.input.pastes ?? []) this.pastedText.set(id, body) + this.selectedContexts = [...(queued.input.contexts ?? [])] + this.draft = queued.input.display + this.cursor = this.draft.length + this.preferredColumn = null + this.historyIndex = this.history.length + this.renderScreen() + return true + } + return false + } + + private insertText(value: string): void { + const safe = sanitize(value).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '') + if (!safe) return + const room = MAX_DRAFT_CHARACTERS - this.draft.length + if (room <= 0) return + const inserted = safe.slice(0, room) + this.draft = `${this.draft.slice(0, this.cursor)}${inserted}${this.draft.slice(this.cursor)}` + this.cursor += inserted.length + this.preferredColumn = null + this.historyIndex = this.history.length + } + + private deleteBackward(): void { + if (this.cursor === 0) return + const previous = previousGraphemeIndex(this.draft, this.cursor) + this.draft = `${this.draft.slice(0, previous)}${this.draft.slice(this.cursor)}` + this.cursor = previous + this.preferredColumn = null + this.renderScreen() + } + + private deleteForward(): void { + if (this.cursor >= this.draft.length) return + const next = nextGraphemeIndex(this.draft, this.cursor) + this.draft = `${this.draft.slice(0, this.cursor)}${this.draft.slice(next)}` + this.preferredColumn = null + this.renderScreen() + } + + private moveVertically(direction: -1 | 1): void { + const layout = this.composerDraftLayout() + const targetRow = layout.cursor.row + direction + if (targetRow < 0 || targetRow >= layout.rows.length) { + this.navigateHistory(direction) + return + } + + const desiredColumn = this.preferredColumn ?? layout.cursor.column + this.preferredColumn = desiredColumn + const candidates = layout.points.filter((point) => point.row === targetRow) + const best = candidates.reduce<CursorPoint | null>((current, candidate) => { + if (!current) return candidate + return Math.abs(candidate.column - desiredColumn) < Math.abs(current.column - desiredColumn) + ? candidate + : current + }, null) + if (best) this.cursor = best.index + this.renderScreen() + } + + private navigateHistory(direction: -1 | 1): void { + if (this.history.length === 0) return + if (direction < 0) { + if (this.historyIndex === this.history.length) this.historyDraft = this.draft + if (this.historyIndex === 0) return + this.historyIndex -= 1 + this.draft = this.history[this.historyIndex] ?? '' + } else { + if (this.historyIndex >= this.history.length) return + this.historyIndex += 1 + this.draft = + this.historyIndex === this.history.length + ? this.historyDraft + : (this.history[this.historyIndex] ?? '') + } + this.cursor = this.draft.length + this.preferredColumn = null + this.renderScreen() + } + + private handleTranscriptNavigationKey(key: Key | undefined): boolean { + if (key?.name === 'pageup') { + this.scrollTranscript(-1) + return true + } + if (key?.name === 'pagedown') { + this.scrollTranscript(1) + return true + } + if (key?.ctrl && key.name === 'home') { + this.jumpTranscript('oldest') + return true + } + if (key?.ctrl && key.name === 'end') { + this.jumpTranscript('latest') + return true + } + return false + } + + private scrollTranscript(direction: -1 | 1): void { + const metrics = this.transcriptViewportMetrics() + if (metrics.capacity <= 0 || metrics.maxTop <= 0) { + this.transcriptScrollTopRow = null + return + } + + const page = Math.max(1, metrics.capacity - 1) + const currentTop = this.transcriptScrollTopRow ?? metrics.maxTop + const nextTop = Math.max(0, Math.min(metrics.maxTop, currentTop + direction * page)) + const nextScrollTop = nextTop >= metrics.maxTop ? null : nextTop + if (nextScrollTop === this.transcriptScrollTopRow) return + this.transcriptScrollTopRow = nextScrollTop + this.renderScreen() + } + + private jumpTranscript(destination: 'oldest' | 'latest'): void { + if (destination === 'latest') { + if (this.transcriptScrollTopRow === null) return + this.transcriptScrollTopRow = null + this.renderScreen() + return + } + + const metrics = this.transcriptViewportMetrics() + if (metrics.capacity <= 0 || metrics.maxTop <= 0 || this.transcriptScrollTopRow === 0) return + this.transcriptScrollTopRow = 0 + this.renderScreen() + } + + private transcriptViewportMetrics(): { capacity: number; maxTop: number } { + const rows = this.terminalRows() + const panel = this.buildPanel(rows) + const capacity = Math.max(0, rows - Math.min(rows, panel.lines.length)) + const totalRows = this.wrappedBody(this.panelWidth()).length + return { capacity, maxTop: Math.max(0, totalRows - capacity) } + } + + private ensureViewport(): void { + if (!this.isInteractiveTTY() || this.viewportActive || this.closed) return + const input = this.input as TerminalInput + if (input.setRawMode && !input.isRaw) input.setRawMode(true) + input.resume() + this.viewportActive = true + const rows = this.terminalRows() + const columns = this.terminalColumns() + this.output.write( + `${BEGIN_SYNCHRONIZED_OUTPUT}${ENTER_ALTERNATE_SCREEN}${ENABLE_BRACKETED_PASTE}${HIDE_CURSOR}${CLEAR_SCREEN}${ESC}[H${END_SYNCHRONIZED_OUTPUT}` + ) + this.renderedScreen = Array<string>(rows).fill('') + this.renderedColumns = columns + this.renderedRows = rows + } + + private restoreInputMode(): void { + if (this.restoredRawMode) return + this.restoredRawMode = true + const input = this.input as TerminalInput + if (input.setRawMode && input.isRaw !== this.inputWasRaw) input.setRawMode(this.inputWasRaw) + if (!this.inputWasFlowing) input.pause() + } + + private isInteractiveTTY(): boolean { + return Boolean((this.input as TerminalInput).isTTY && (this.output as TerminalOutput).isTTY) + } + + private terminalColumns(): number { + return Math.max(1, (this.output as TerminalOutput).columns ?? 80) + } + + private terminalRows(): number { + return Math.max(1, (this.output as TerminalOutput).rows ?? 24) + } + + private panelWidth(): number { + return Math.max(0, this.terminalColumns() - 1) + } + + private userPanelDraftLayout( + prompt: string, + highlights: Array<{ start: number; end: number }> = [] + ): DraftLayout { + return layoutDraft( + `${USER_MESSAGE_POINTER}${prompt}${USER_MESSAGE_TEXT}`, + this.draft, + Math.max(1, this.panelWidth() - 3), + this.cursor, + highlights, + { + continuationPrefix: CONTINUATION_PREFIX, + normalTextStyle: USER_MESSAGE_TEXT, + } + ) + } + + private composerDraftLayout(): DraftLayout { + return this.userPanelDraftLayout(this.prompt, this.liveMentionSpans()) + } + + private renderScreen(): void { + if (!this.viewportActive || this.closed) return + const rows = this.terminalRows() + const width = this.panelWidth() + const panel = this.buildPanel(rows) + const panelCapacity = Math.min(rows, panel.lines.length) + const panelFocusRow = panel.focusRow ?? panel.cursor?.row + const panelFirst = + panelFocusRow !== undefined + ? Math.max( + 0, + Math.min( + panel.centerFocus + ? panelFocusRow - Math.floor((panelCapacity - 1) / 2) + : panelFocusRow - panelCapacity + 1, + Math.max(0, panel.lines.length - panelCapacity) + ) + ) + : Math.max(0, panel.lines.length - panelCapacity) + const panelLines = panel.lines + .slice(panelFirst, panelFirst + panelCapacity) + .map((line) => layoutAnsiRows(line, width)[0] ?? '') + const panelTop = rows - panelLines.length + 1 + const transcriptCapacity = Math.max(0, panelTop - 1) + const allTranscriptRows = this.wrappedBody(width) + const maxTranscriptTop = Math.max(0, allTranscriptRows.length - transcriptCapacity) + if (this.transcriptScrollTopRow !== null) { + const clamped = Math.max(0, Math.min(this.transcriptScrollTopRow, maxTranscriptTop)) + this.transcriptScrollTopRow = clamped >= maxTranscriptTop ? null : clamped + } + const transcriptTop = this.transcriptScrollTopRow ?? maxTranscriptTop + const transcriptRows = transcriptCapacity + ? allTranscriptRows.slice(transcriptTop, transcriptTop + transcriptCapacity) + : [] + const screen = Array<string>(rows).fill('') + for (const [index, line] of transcriptRows.entries()) screen[index] = line + for (const [index, line] of panelLines.entries()) screen[panelTop + index - 1] = line + const columns = this.terminalColumns() + const fullRepaint = + !this.renderedScreen || this.renderedColumns !== columns || this.renderedRows !== rows + + let frame = `${BEGIN_SYNCHRONIZED_OUTPUT}${HIDE_CURSOR}${RESET}${RESET_SCROLL_REGION}` + if (fullRepaint) frame += CLEAR_SCREEN + for (const [index, line] of screen.entries()) { + if ((!fullRepaint && line === this.renderedScreen?.[index]) || (fullRepaint && !line)) + continue + frame += `${cursorTo(index + 1, 1)}${ESC}[2K${line}${RESET}` + } + + if (panel.cursor && this.isComposerEditable()) { + const clippedPanelCursorRow = Math.max(0, panel.cursor.row - panelFirst) + frame += `${cursorTo( + Math.min(rows, panelTop + clippedPanelCursorRow), + Math.min(columns, panel.cursor.column) + )}${SHOW_CURSOR}` + } else if (panel.cursor && this.questionState) { + const clippedPanelCursorRow = Math.max(0, panel.cursor.row - panelFirst) + frame += `${cursorTo( + Math.min(rows, panelTop + clippedPanelCursorRow), + Math.min(columns, panel.cursor.column) + )}${SHOW_CURSOR}` + } else { + frame += HIDE_CURSOR + } + frame += END_SYNCHRONIZED_OUTPUT + this.renderedScreen = screen + this.renderedColumns = columns + this.renderedRows = rows + this.output.write(frame) + } + + private buildPanel(rows: number): RenderPanel { + if (this.selectState) return this.buildSelectPanel(rows) + if (this.questionState) return this.buildQuestionPanel(rows) + if (!this.composerVisible) return { lines: [] } + + const layout = this.composerDraftLayout() + const topMargin = rows >= 13 ? [''] : [] + const maxInputRows = Math.max(1, Math.min(6, Math.floor(rows / 3))) + const firstVisible = Math.max( + 0, + Math.min(layout.cursor.row - maxInputRows + 1, layout.rows.length - maxInputRows) + ) + const visibleRows = layout.rows.slice(firstVisible, firstVisible + maxInputRows) + const queuedTurns = this.queued.filter( + ({ input }) => input.kind === 'line' && input.value.trim() + ).length + const queued = queuedTurns > 0 ? `${queuedTurns} queued · ` : '' + const footer = this.busy + ? ` ${queued}enter to steer · esc to interrupt` + : this.pending && !this.draft + ? ' ? for shortcuts' + : '' + const activityStatus = this.activityStatusLine() + const activityRows = activityStatus ? [activityStatus] : [] + const suggestionRows = this.suggestionRows(this.panelWidth(), rows) + /* Keep the suggestion menu visually separate from the activity line. The + composer's shaded top row already separates activity from input. */ + const suggestionGap = suggestionRows.length ? [''] : [] + const composerCursor = { + row: + topMargin.length + + suggestionRows.length + + suggestionGap.length + + activityRows.length + + 1 + + layout.cursor.row - + firstVisible, + column: Math.min(this.panelWidth() + 1, layout.cursor.column + 2), + } + return { + lines: [ + ...topMargin, + ...suggestionRows, + ...suggestionGap, + ...activityRows, + userPanelRow(), + ...visibleRows.map((line) => userPanelRow(line)), + userPanelRow(), + `${DIM}${footer}${RESET}`, + ], + focusRow: composerCursor.row, + centerFocus: true, + cursor: this.isComposerEditable() ? composerCursor : undefined, + } + } + + private buildSelectPanel(rows: number): RenderPanel { + const state = this.selectState + if (!state) return { lines: [] } + + const width = this.panelWidth() + const options = this.filteredSelectOptions() + const capacity = Math.max(0, Math.min(8, rows - 5)) + state.active = Math.max(0, Math.min(state.active, options.length - 1)) + const { start, end } = suggestionWindow(options.length, state.active, capacity) + const visible = options.slice(start, end) + const labelWidth = Math.min( + Math.floor(width * 0.55), + Math.max(0, ...visible.map((option) => displayWidth(option.label))) + 3 + ) + const optionRows = visible.map((option) => { + const active = option.id === options[state.active]?.id + const pointer = active ? '❯' : ' ' + const label = truncateDisplay(option.label, Math.max(1, labelWidth - 3)) + const padding = ' '.repeat(Math.max(2, labelWidth - displayWidth(label) - 1)) + const line = truncateDisplay( + `${pointer} ${label}${padding}${option.description ?? ''}`, + width + ) + return active ? `${BRIGHT_WHITE}${line}${RESET}` : `${DIM}${line}${RESET}` + }) + if (capacity > 0 && optionRows.length === 0) { + optionRows.push(`${DIM} No matching chats${RESET}`) + } + + const layout = this.userPanelDraftLayout('Search › ') + const searchRow = + layout.rows[layout.cursor.row] ?? `${USER_MESSAGE_POINTER}Search › ${USER_MESSAGE_TEXT}` + const header = rows >= 5 ? [`${BOLD}? ${state.menu.prompt}${RESET}`] : [] + const cursor = { + row: header.length + optionRows.length + 1, + column: Math.min(width + 1, layout.cursor.column + 2), + } + return { + lines: [ + ...header, + ...optionRows, + userPanelRow(), + userPanelRow(searchRow), + userPanelRow(), + `${DIM} ↑/↓ navigate · enter open · esc cancel${RESET}`, + ], + focusRow: cursor.row, + cursor, + } + } + + private buildQuestionPanel(rows: number): RenderPanel { + const state = this.questionState + if (!state) return { lines: [] } + const otherIndex = state.question.options.length + const choices: Array<{ line: string; cursorColumn?: number }> = state.question.options.map( + (option, index) => { + const active = state.active === index + const pointer = active ? '❯' : ' ' + const marker = state.question.multi + ? `[${state.selected.has(index) ? '✓' : ' '}]` + : `${index + 1}.` + return { + line: truncateDisplay(`${pointer} ${marker} ${option.label}`, this.panelWidth()), + } + } + ) + + const otherActive = state.active === otherIndex + const otherLead = `${otherActive ? '❯' : ' '} Other › ` + const otherRoom = Math.max(1, this.panelWidth() - displayWidth(otherLead)) + const otherValue = this.draft + ? tailToWidth(this.draft.replace(/\n/gu, ' '), otherRoom) + : `${DIM}Type something…${RESET}` + choices.push({ + line: `${otherLead}${otherValue}`, + cursorColumn: otherActive + ? Math.min( + this.panelWidth() + 1, + this.draft ? displayWidth(`${otherLead}${otherValue}`) + 1 : displayWidth(otherLead) + 1 + ) + : undefined, + }) + if (state.question.multi) { + choices.push({ + line: `${state.active === otherIndex + 1 ? '❯' : ' '} ${DIM}Submit answers${RESET}`, + }) + } + + const maxChoices = Math.max(1, Math.min(choices.length, Math.floor(rows / 2))) + const firstVisible = Math.max( + 0, + Math.min(state.active - maxChoices + 1, choices.length - maxChoices) + ) + const visibleChoices = choices.slice(firstVisible, firstVisible + maxChoices) + const footer = state.question.multi + ? '↑/↓ navigate · Space select · Enter submit · Esc cancel' + : '↑/↓ navigate · Enter select · Esc cancel' + const lines = [ + `${ESC}[1m${truncateDisplay(`? ${state.question.prompt}`, this.panelWidth())}${RESET}`, + ...visibleChoices.map((choice) => choice.line), + `${DIM}${truncateDisplay(footer, this.panelWidth())}${RESET}`, + ] + const activeChoice = choices[state.active] + const focusRow = 1 + state.active - firstVisible + return { + lines, + focusRow, + cursor: + activeChoice?.cursorColumn && state.active >= firstVisible + ? { row: focusRow, column: activeChoice.cursorColumn } + : undefined, + } + } + + private appendTranscript(value: string): void { + this.transcript += value + if (this.transcript.length <= MAX_TRANSCRIPT_CHARACTERS) return + + const preferredCut = this.transcript.length - MAX_TRANSCRIPT_CHARACTERS + const nextLine = this.transcript.indexOf('\n', preferredCut) + if (nextLine >= 0) { + this.transcriptEpoch += 1 + this.transcript = this.transcript.slice(nextLine + 1) + return + } + if (this.transcript.length > MAX_TRANSCRIPT_CHARACTERS * 2) { + this.transcriptEpoch += 1 + this.transcript = `…${sanitize(this.transcript.slice(-MAX_TRANSCRIPT_CHARACTERS))}` + } + } + + /** + * Wrapped rows for the whole viewport body, reusing the rows already computed + * for the immutable part of the transcript. + * + * Everything up to the transcript's last newline can never change, so it is + * wrapped once and kept; only the partial final line is re-wrapped per token. + * That turns an O(transcript) cost per streamed chunk into O(one line). + */ + private wrappedBody(width: number): string[] { + const welcome = this.welcomeVisible ? this.renderWelcome() : '' + const activity = this.activityEventsDisplay() + const text = this.transcript + const boundary = text.lastIndexOf('\n') + 1 + + let cache = this.wrapCache + if ( + !cache || + cache.width !== width || + cache.epoch !== this.transcriptEpoch || + cache.consumed > boundary + ) { + cache = { + width, + epoch: this.transcriptEpoch, + consumed: 0, + rows: [], + state: { sgr: '', userBackground: false }, + } + } + if (cache.consumed < boundary) { + const state: WrapState = { ...cache.state } + const added = layoutAnsiRows(text.slice(cache.consumed, boundary), width, state) + cache = { + width, + epoch: this.transcriptEpoch, + consumed: boundary, + rows: cache.rows.concat(added), + state, + } + } + this.wrapCache = cache + + /* The welcome block always ends with a reset and a blank line, so it cannot + leak style into the transcript and is wrapped independently. */ + const rows = welcome ? layoutAnsiRows(welcome, width) : [] + rows.push(...cache.rows) + const tail = text.slice(cache.consumed) + if (tail) rows.push(...layoutAnsiRows(tail, width, { ...cache.state })) + if (activity) rows.push(...layoutAnsiRows(activity, width)) + return rows + } + + private commitUserLine(value: string): void { + if (!this.isInteractiveTTY()) return + this.transcriptScrollTopRow = null + this.assistantPrefixPending = true + this.assistantPrefixBuffer = '' + this.assistantTurnActive = false + this.assistantContinuationPending = false + if (this.transcript && !this.transcript.endsWith('\n')) this.appendTranscript('\n') + if (this.transcript && !this.transcript.endsWith('\n\n')) this.appendTranscript('\n') + + this.appendTranscript(`${userPanelRow()}\n`) + const lines = sanitize(value).replace(/\t/gu, CONTINUATION_PREFIX).split('\n') + for (const [index, line] of lines.entries()) { + const pointer = + index === 0 + ? `${USER_MESSAGE_POINTER}❯ ${USER_MESSAGE_TEXT}` + : `${USER_MESSAGE_TEXT}${CONTINUATION_PREFIX}` + this.appendTranscript(`${userPanelRow(`${pointer}${line}`)}\n`) + } + this.appendTranscript(`${userPanelRow()}\n`) + this.appendTranscript('\n') + } + + private renderWelcome(): string { + const width = this.panelWidth() + const chat = `chat ${this.welcomeChatTitle}` + const artWidth = Math.max(...BLIMP_ART.map(displayWidth)) + const progress = this.welcomeRevealFrame / WELCOME_FLY_IN_FRAMES + const eased = progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2 + const trailing = Math.max(0, artWidth - Math.round(artWidth * eased)) + const art = BLIMP_ART.map((line) => `${line}${artPad(line, artWidth)}`.slice(trailing)) + const lead = ' '.repeat(trailing) + + const boxColumns = width - artWidth - WELCOME_GUTTER + if (boxColumns >= WELCOME_MIN_BOX_COLUMNS) { + const rows = this.welcomeDetailBox(boxColumns) + const gutter = ' '.repeat(WELCOME_GUTTER) + const lines: string[] = [] + /* Centre the shorter column against the taller one. Top-aligning leaves + the airship and the box visibly out of register whenever they differ + in height, which they usually do. */ + const height = Math.max(art.length, rows.length) + const artTop = Math.round((height - art.length) / 2) + const boxTop = Math.round((height - rows.length) / 2) + for (let index = 0; index < height; index++) { + const line = art[index - artTop] + const column = + line === undefined ? ' '.repeat(artWidth) : `${BRIGHT_WHITE}${line}${RESET}${lead}` + lines.push(`${column}${gutter}${rows[index - boxTop] ?? ''}`.trimEnd()) + } + return `${lines.join('\n')}\n\n` + } + + if (width >= artWidth) { + const title = `${BOLD}${truncateDisplay('Sim Chat', width)}${RESET}` + const scope = `${DIM}${truncateDisplay(chat, width)}${RESET}` + const rendered = art + .map((line) => `${BRIGHT_WHITE}${truncateDisplay(line.trimEnd(), width)}${RESET}`) + .join('\n') + return `${rendered}\n${title}\n${scope}\n\n` + } + + return `${BOLD}${truncateDisplay('Sim Chat', width)}${RESET}\n${DIM}${truncateDisplay( + chat, + width + )}${RESET}\n\n` + } + + noteAttachment(): void { + const token = `[Image #${this.nextAttachmentNumber++}]` + const before = this.draft.slice(0, this.cursor) + const separator = !before || /\s$/u.test(before) ? '' : ' ' + this.insertText(`${separator}${token} `) + this.renderScreen() + } + + setWorkspaceName(name: string): void { + const next = safeOneLine(name).slice(0, 80) + if (!next || next === this.welcomeWorkspaceName) return + this.welcomeWorkspaceName = next + if (!this.closed) this.renderScreen() + } + + setChatTitle(title: string): void { + const next = safeOneLine(title).slice(0, 160) + if (!next || next === this.welcomeChatTitle) return + this.welcomeChatTitle = next + if (this.welcomeVisible && !this.closed) this.renderScreen() + } + + /** Rounded detail box drawn to the right of the art, with aligned labels. */ + private welcomeDetailBox(columns: number): string[] { + const details: Array<[string, string]> = [ + ['profile', this.welcomeProfile ?? 'default'], + ...(this.welcomeWorkspaceName + ? ([['workspace', this.welcomeWorkspaceName]] as Array<[string, string]>) + : []), + ['chat', this.welcomeChatTitle], + ] + const labelWidth = Math.max(...details.map(([label]) => label.length)) + 2 + const content = [ + { text: 'Sim Chat', style: BOLD }, + ...details.map(([label, value]) => ({ + text: `${`${label}:`.padEnd(labelWidth)}${value}`, + style: DIM, + })), + ] + const widest = Math.max(...content.map((entry) => displayWidth(entry.text))) + const inner = Math.max(1, Math.min(columns - 4, widest)) + const rule = '\u2500'.repeat(inner + 2) + const rows = [`${DIM}\u256d${rule}\u256e${RESET}`] + for (const { text, style } of content) { + const clipped = truncateDisplay(text, inner) + const padding = ' '.repeat(Math.max(0, inner - displayWidth(clipped))) + const painted = style ? `${style}${clipped}${RESET}` : clipped + rows.push(`${DIM}\u2502${RESET} ${painted}${padding} ${DIM}\u2502${RESET}`) + } + rows.push(`${DIM}\u2570${rule}\u256f${RESET}`) + return rows + } + + /** + * Slides the airship in from the left edge, repainting on a timer. Skipped for + * non-interactive output and under CI/test runners, where a partially drawn + * frame would make the header nondeterministic. + */ + private startWelcomeFlyIn(): void { + this.stopWelcomeFlyIn() + if (!this.isInteractiveTTY()) return + if (process.env.CI || process.env.VITEST) return + this.welcomeRevealFrame = 0 + this.welcomeTimer = setInterval(() => { + this.welcomeRevealFrame += 1 + if (this.welcomeRevealFrame >= WELCOME_FLY_IN_FRAMES) this.stopWelcomeFlyIn() + this.renderScreen() + }, WELCOME_FLY_IN_INTERVAL_MS) + this.welcomeTimer.unref() + } + + private stopWelcomeFlyIn(): void { + if (this.welcomeTimer) clearInterval(this.welcomeTimer) + this.welcomeTimer = null + this.welcomeRevealFrame = WELCOME_FLY_IN_FRAMES + } + + private activityEventsDisplay(): string { + if (!this.activityActive) return '' + const lines: string[] = [] + for (const id of this.activityRoots) { + if (this.committedActivityRoots.has(id)) continue + const node = this.activityNodes.get(id) + if (node) lines.push(...this.activityNodeLines(node, true)) + } + return lines.join('\n') + } + + private activityStatusLine(): string { + if (!this.activityActive) return '' + const pulseFrames = ['·', '•', '●', '•'] + const pulse = pulseFrames[this.activityFrame % pulseFrames.length] + const label = tailToWidth( + safeOneLine(this.activityThinking) || 'Thinking…', + Math.max(1, this.panelWidth() - 2) + ) + return `${DIM}${ESC}[3m${pulse} ${label}${RESET}` + } + + private activityEventLine(event: ChatActivityStatusUpdate, live: boolean, depth = 0): string { + const icon = + event.state === 'complete' + ? `${ESC}[32m●${RESET}` + : event.state === 'error' + ? `${ESC}[31m●${RESET}` + : `${DIM}●${RESET}` + const indent = CONTINUATION_PREFIX.repeat(depth) + const label = live + ? truncateDisplay(event.label, Math.max(1, this.panelWidth() - displayWidth(indent) - 8)) + : event.label + // A subagent's public label is its stable lane header. Its dot carries the + // state, while tool labels may use the familiar live/error suffixes. + const suffix = event.kind === 'tool' && live && event.state === 'running' ? '…' : '' + const failed = event.kind === 'tool' && event.state === 'error' ? ` ${DIM}failed${RESET}` : '' + return `${indent}${icon} ${label}${suffix}${failed}` + } + + private recordActivityEvent(update: ChatActivityUpdate): void { + if (update.kind === 'narration') { + const parentId = safeOneLine(update.parentId).slice(0, 160) + const parent = this.activityNodes.get(parentId) + if (!parent || parent.kind !== 'subagent') return + const delta = update.delta.replace(/\r/gu, '') + if (!delta) return + const last = parent.children[parent.children.length - 1] + if (last?.kind === 'narration') last.content += delta + else parent.children.push({ kind: 'narration', content: delta }) + return + } + + const id = safeOneLine(update.id).slice(0, 160) + const label = safeOneLine(update.label).slice(0, 160) + if (!id || !label) return + const parentId = update.parentId ? safeOneLine(update.parentId).slice(0, 160) : undefined + const safeParentId = parentId && parentId !== id ? parentId : undefined + const existing = this.activityNodes.get(id) + const node: ActivityTreeNode = { + kind: update.kind, + id, + label, + state: update.state, + ...(safeParentId ? { parentId: safeParentId } : {}), + children: existing?.children ?? [], + } + this.activityNodes.set(id, node) + if (!existing || existing.parentId !== node.parentId) this.attachActivityNode(node) + + if (node.kind === 'subagent') { + for (const child of this.activityNodes.values()) { + if (child.parentId === node.id) this.attachActivityNode(child) + } + } + } + + private commitActivityEvents(includeRunning: boolean): void { + if (!this.isInteractiveTTY()) return + for (const id of this.activityRoots) { + if (this.committedActivityRoots.has(id)) continue + const node = this.activityNodes.get(id) + if (!node || (!includeRunning && !this.activityNodeSettled(node))) continue + this.commitActivityRoot(node) + } + } + + private commitActivityRoot(node: ActivityTreeNode): void { + if (!this.isInteractiveTTY() || this.committedActivityRoots.has(node.id)) return + this.committedActivityRoots.add(node.id) + const lines = this.activityNodeLines(node, false) + if (lines.length === 0) return + if (this.transcript && !this.transcript.endsWith('\n')) this.appendTranscript('\n') + this.appendTranscript(`${lines.join('\n')}\n`) + } + + private attachActivityNode(node: ActivityTreeNode): void { + const rootIndex = this.activityRoots.indexOf(node.id) + if (rootIndex >= 0) this.activityRoots.splice(rootIndex, 1) + for (const candidate of this.activityNodes.values()) { + if (candidate.kind !== 'subagent') continue + candidate.children = candidate.children.filter( + (child) => child.kind !== 'node' || child.id !== node.id + ) + } + + if (node.parentId) { + const parent = this.activityNodes.get(node.parentId) + if (parent?.kind === 'subagent') parent.children.push({ kind: 'node', id: node.id }) + return + } + this.activityRoots.push(node.id) + } + + private activityNodeSettled(node: ActivityTreeNode, seen = new Set<string>()): boolean { + if (node.state === 'running' || seen.has(node.id)) return false + seen.add(node.id) + for (const child of node.children) { + if (child.kind !== 'node') continue + const nested = this.activityNodes.get(child.id) + if (nested && !this.activityNodeSettled(nested, seen)) return false + } + return true + } + + private activityNodeLines( + node: ActivityTreeNode, + live: boolean, + depth = 0, + seen = new Set<string>() + ): string[] { + if (seen.has(node.id)) return [] + seen.add(node.id) + + const children: string[] = [] + for (const child of node.children) { + if (child.kind === 'node') { + const nested = this.activityNodes.get(child.id) + if (nested) children.push(...this.activityNodeLines(nested, live, depth + 1, seen)) + continue + } + if (!child.content.trim()) continue + const indent = CONTINUATION_PREFIX.repeat(depth + 1) + // Only trim to decide whether the lane has visible work. The original + // text (including leading/trailing blank lines) is the ordered stream. + for (const line of child.content.split('\n')) { + children.push(`${indent}${DIM}${line}${RESET}`) + } + } + + // Match the web lane projection: a closed lane with no visible work leaves + // no orphan header, while an open empty lane still explains what is running. + if (node.kind === 'subagent' && node.state !== 'running' && children.length === 0) return [] + return [this.activityEventLine(node, live, depth), ...children] + } + + private stopActivity(completed = false): void { + if (this.activityTimer) clearInterval(this.activityTimer) + this.activityTimer = null + if (this.activityActive) { + this.commitActivityEvents(true) + if (completed) { + if (this.transcript && !this.transcript.endsWith('\n')) this.appendTranscript('\n') + if (this.transcript && !this.transcript.endsWith('\n\n')) this.appendTranscript('\n') + this.appendTranscript( + `${DIM}✻ Worked for ${formatActivityDuration(Date.now() - this.activityStartedAt)}${RESET}\n` + ) + } + } + this.activityActive = false + this.activityThinking = '' + this.activityStartedAt = 0 + this.activityNodes.clear() + this.activityRoots.length = 0 + this.committedActivityRoots.clear() + this.assistantTurnActive = false + this.assistantContinuationPending = false + this.busy = false + this.renderScreen() + } +} + +function prefixAssistantTurn(value: string): string | null { + let offset = 0 + let leadingSgr = '' + while (offset < value.length) { + if (value[offset] === ESC) { + const sgr = value.slice(offset).match(/^\u001b\[[0-9;:]*m/u)?.[0] + if (sgr) { + leadingSgr += sgr + offset += sgr.length + continue + } + } + + const part = firstGrapheme(value.slice(offset)) + if (!part) break + if (!/^\p{White_Space}+$/u.test(part)) { + return `${ASSISTANT_TURN_PREFIX}${indentAssistantFragment( + `${leadingSgr}${value.slice(offset)}`, + false + )}` + } + offset += part.length + } + return null +} + +/** Materializes the assistant gutter on explicit line breaks across streamed chunks. */ +function indentAssistantFragment(value: string, continuationPending: boolean): string { + const prefixed = continuationPending ? `${CONTINUATION_PREFIX}${value}` : value + return prefixed.replace(/\n(?=.)/gu, `\n${CONTINUATION_PREFIX}`) +} + +interface WrapState { + sgr: string + userBackground: boolean +} + +/** + * `carry` resumes the state a previous call ended in, and receives the state + * this call ends in — the two things that survive a row break. Threading them + * explicitly is what makes it safe to wrap a transcript in pieces. + */ +function layoutAnsiRows(value: string, width: number, carry?: WrapState): string[] { + if (!value || width <= 0) return [] + + type LayoutToken = + | { kind: 'sgr'; value: string } + | { kind: 'grapheme'; value: string; width: number } + + const rows: string[] = [] + /* Resumed styling must reopen on the first row, exactly as finishRow() + reopens it on every subsequent row. */ + let row = carry?.userBackground ? `${USER_PANEL_OUTER_MARGIN}${carry.sgr}` : (carry?.sgr ?? '') + let column = carry?.userBackground ? displayWidth(USER_PANEL_OUTER_MARGIN) : 0 + let activeSgr = carry?.sgr ?? '' + let userBackgroundActive = carry?.userBackground ?? false + let hangingIndent = 0 + let logicalLinePrefix = '' + let logicalLinePrefixRejected = false + let pendingWord: LayoutToken[] = [] + let pendingWordWidth = 0 + + const contentWidth = (): number => (userBackgroundActive && width > 2 ? width - 2 : width) + + const fillUserMessageRow = (): void => { + if (!userBackgroundActive) return + const target = width > 1 ? width - 1 : width + if (column >= target) return + row += ' '.repeat(target - column) + column = target + } + + const finishRow = (continueLogicalLine = true): void => { + fillUserMessageRow() + rows.push(row) + const outerMargin = userBackgroundActive ? displayWidth(USER_PANEL_OUTER_MARGIN) : 0 + const continuationIndent = continueLogicalLine + ? Math.min(hangingIndent, Math.max(0, contentWidth() - 1)) + : 0 + if (continuationIndent > 0) { + const indent = ' '.repeat(Math.max(0, continuationIndent - outerMargin)) + row = userBackgroundActive + ? `${USER_PANEL_OUTER_MARGIN}${activeSgr}${indent}` + : `${indent}${activeSgr}` + } else { + row = userBackgroundActive ? `${USER_PANEL_OUTER_MARGIN}${activeSgr}` : activeSgr + } + column = Math.max(continuationIndent, outerMargin) + if (!continueLogicalLine) { + hangingIndent = 0 + logicalLinePrefix = '' + logicalLinePrefixRejected = false + } + } + + const observeLogicalLinePrefix = (segment: string, segmentWidth: number): void => { + if (logicalLinePrefixRejected) return + if (segmentWidth !== 1) { + logicalLinePrefixRejected = true + return + } + + // Activity trees can be nested more deeply than the assistant's two-column + // gutter. Preserve every explicit leading space on soft wraps so a nested + // tool or narration row never jumps back toward its parent. + if (segment === ' ' && /^ *$/u.test(logicalLinePrefix)) { + logicalLinePrefix += segment + hangingIndent = displayWidth(logicalLinePrefix) + return + } + + const candidate = `${logicalLinePrefix}${segment}` + const knownPrefix = [ASSISTANT_TURN_PREFIX, USER_TURN_PREFIX].find((prefix) => + prefix.startsWith(candidate) + ) + if (knownPrefix) { + logicalLinePrefix = candidate + if (candidate === knownPrefix) hangingIndent = displayWidth(knownPrefix) + return + } + logicalLinePrefixRejected = true + } + + const appendVisible = (segment: string): void => { + if (segment === '\t') { + const spaces = Math.max(1, 8 - (column % 8)) + for (let index = 0; index < spaces; index += 1) appendVisible(' ') + return + } + if (/[\u0000-\u001f\u007f-\u009f]/u.test(segment)) return + + const segmentWidth = graphemeWidth(segment) + observeLogicalLinePrefix(segment, segmentWidth) + const availableWidth = contentWidth() + if (segmentWidth > availableWidth) { + if (column > 0) finishRow() + row += '…' + column = 1 + return + } + if (column > 0 && column + segmentWidth > availableWidth) finishRow() + row += segment + column += segmentWidth + } + + const appendToken = (token: LayoutToken): void => { + if (token.kind === 'sgr') { + if (userBackgroundActive && token.value === RESET) fillUserMessageRow() + row += token.value + activeSgr = updateActiveSgr(activeSgr, token.value) + if (token.value === USER_MESSAGE_BACKGROUND) userBackgroundActive = true + else if (token.value === RESET) userBackgroundActive = false + return + } + appendVisible(token.value) + } + + const flushWord = (): void => { + if (pendingWord.length === 0) return + + const availableWidth = contentWidth() + const outerMargin = userBackgroundActive ? displayWidth(USER_PANEL_OUTER_MARGIN) : 0 + const continuationIndent = Math.min(hangingIndent, Math.max(0, availableWidth - 1)) + const freshColumn = Math.max(continuationIndent, outerMargin) + + /** + * Matches Ink's default wrap behavior: ordinary words move intact when they fit on a fresh + * row, while overlong tokens hard-wrap through the remaining space. Styling tokens flush with + * their word so absolute continuation rows can safely reopen the active SGR state. + */ + if ( + pendingWordWidth > 0 && + freshColumn + pendingWordWidth <= availableWidth && + column > freshColumn && + column + pendingWordWidth > availableWidth + ) { + finishRow() + } + + for (const token of pendingWord) appendToken(token) + pendingWord = [] + pendingWordWidth = 0 + } + + const bufferWordToken = (token: LayoutToken): void => { + pendingWord.push(token) + if (token.kind === 'grapheme') pendingWordWidth += token.width + } + + let offset = 0 + while (offset < value.length) { + if (value[offset] === ESC) { + const match = value.slice(offset).match(/^\u001b\[[0-9;:]*m/u) + if (match) { + const sequence = match[0] + bufferWordToken({ kind: 'sgr', value: sequence }) + offset += sequence.length + continue + } + offset += 1 + continue + } + if (value[offset] === '\n') { + flushWord() + finishRow(false) + offset += 1 + continue + } + + const nextControl = [value.indexOf(ESC, offset), value.indexOf('\n', offset)] + .filter((index) => index >= 0) + .reduce((closest, index) => Math.min(closest, index), value.length) + const text = value.slice(offset, nextControl) + for (const part of graphemes(text)) { + const breakableWhitespace = + part.segment !== '\u00a0' && + part.segment !== '\u202f' && + /^\p{White_Space}+$/u.test(part.segment) + if (breakableWhitespace) { + flushWord() + appendVisible(part.segment) + } else { + bufferWordToken({ + kind: 'grapheme', + value: part.segment, + width: graphemeWidth(part.segment), + }) + } + } + offset = nextControl + } + + flushWord() + fillUserMessageRow() + rows.push(row) + if (value.endsWith('\n')) rows.pop() + if (carry) { + carry.sgr = activeSgr + carry.userBackground = userBackgroundActive + } + return rows +} + +function updateActiveSgr(active: string, sequence: string): string { + const rawParameters = sequence.slice(2, -1) + const parameters = rawParameters ? rawParameters.split(';') : ['0'] + let lastReset = -1 + for (let index = 0; index < parameters.length; index += 1) { + const parameter = parameters[index] ?? '' + const code = Number(parameter.split(':', 1)[0]) + if (code === 0) lastReset = index + if ((code === 38 || code === 48 || code === 58) && !parameter.includes(':')) { + const mode = Number(parameters[index + 1]) + if (mode === 2) index += 4 + else if (mode === 5) index += 2 + } + } + if (lastReset < 0) return `${active}${sequence}` + + const remaining = parameters.slice(lastReset + 1) + return remaining.length > 0 ? `${ESC}[${remaining.join(';')}m` : '' +} + +function cursorTo(row: number, column: number): string { + return `${ESC}[${Math.max(1, row)};${Math.max(1, column)}H` +} + +/** + * Spans of `[Image #N]` tags, so a pasted attachment reads as a tag rather than + * loose text. Derived per render like context spans, so deleting the tag stops + * the highlight with no bookkeeping. + */ +const ATTACHMENT_TOKEN = /\[Image #\d+\]/gu + +function attachmentSpans(text: string): Array<{ start: number; end: number }> { + return [...text.matchAll(ATTACHMENT_TOKEN)].map((match) => ({ + start: match.index ?? 0, + end: (match.index ?? 0) + match[0].length, + })) +} + +function isEnter(key: Key | undefined): boolean { + return key?.name === 'return' || key?.name === 'enter' +} + +function printableText(character: string, key: Key | undefined): string { + if (!character || key?.ctrl || key?.meta) return '' + if (key?.name === 'return' || key?.name === 'enter' || key?.name === 'tab') return '' + return sanitize(character).replace(/[\u0000-\u001f\u007f]/gu, '') +} + +/** Normalizes server-provided menu text before it can enter the terminal draft or renderer. */ +function sanitizeSuggestionItem(item: SuggestionItem): SuggestionItem | null { + const value = safeOneLine(item.value).slice(0, 255) + const displayText = safeOneLine(item.displayText).slice(0, 255) + if (!value || !displayText) return null + + const description = item.description ? safeOneLine(item.description).slice(0, 500) : undefined + const sanitized = { + ...item, + value, + displayText, + ...(description ? { description } : {}), + } + if (!item.context) return sanitized + + const contextLabel = safeOneLine(item.context.label).slice(0, 255) + if (!contextLabel) return null + return { ...sanitized, context: { ...item.context, label: contextLabel } } +} + +function layoutDraft( + prompt: string, + draft: string, + width: number, + cursor: number, + highlights: Array<{ start: number; end: number }> = [], + options: DraftLayoutOptions = {} +): DraftLayout { + const continuationPrefix = options.continuationPrefix ?? CONTINUATION_PREFIX + const normalTextStyle = options.normalTextStyle ?? RESET + const rows = [prompt] + const points: CursorPoint[] = [{ index: 0, row: 0, column: displayWidth(prompt) }] + let row = 0 + let column = displayWidth(prompt) + let styled = false + + const setPoint = (index: number): void => { + const previous = points.at(-1) + if (previous?.index === index) { + previous.row = row + previous.column = column + } else { + points.push({ index, row, column }) + } + } + + for (const part of graphemes(draft)) { + setPoint(part.index) + const end = part.index + part.segment.length + if (part.segment === '\n') { + if (styled) rows[row] += normalTextStyle + row += 1 + column = displayWidth(continuationPrefix) + rows.push( + styled + ? `${continuationPrefix}${MENTION_TEXT}` + : `${continuationPrefix}${options.normalTextStyle ?? ''}` + ) + setPoint(end) + continue + } + + const segmentWidth = displayWidth(part.segment) + if (column + segmentWidth > width && column > displayWidth(continuationPrefix)) { + if (styled) rows[row] += normalTextStyle + row += 1 + column = displayWidth(continuationPrefix) + rows.push( + styled + ? `${continuationPrefix}${MENTION_TEXT}` + : `${continuationPrefix}${options.normalTextStyle ?? ''}` + ) + setPoint(part.index) + } + /* ANSI has zero display width, so styling here cannot disturb the wrap or + cursor arithmetic above. Runs are coalesced rather than wrapping every + grapheme, and closed/reopened around a row break so no style leaks. */ + const lit = highlights.some((span) => part.index >= span.start && part.index < span.end) + if (lit && !styled) { + rows[row] += MENTION_TEXT + styled = true + } else if (!lit && styled) { + rows[row] += normalTextStyle + styled = false + } + rows[row] += part.segment + column += segmentWidth + setPoint(end) + } + + if (styled) rows[row] += normalTextStyle + + const fallback = points.at(-1) ?? { index: 0, row: 0, column: displayWidth(prompt) } + const cursorPoint = points.find((point) => point.index === cursor) ?? fallback + return { rows, points, cursor: cursorPoint } +} diff --git a/packages/sim-cli/src/commands/protocol/chat-wrap.test.ts b/packages/sim-cli/src/commands/protocol/chat-wrap.test.ts new file mode 100644 index 00000000000..92de4c1af5d --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-wrap.test.ts @@ -0,0 +1,81 @@ +import { PassThrough } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { ReadlineChatTerminal } from './chat-terminal.js' + +const ESC = String.fromCharCode(27) + +function harness(columns = 60) { + const input = new PassThrough() as PassThrough & { isTTY: boolean; setRawMode?: () => void } + const output = new PassThrough() as PassThrough & { + isTTY: boolean + columns: number + rows: number + } + input.isTTY = true + input.setRawMode = () => {} + output.isTTY = true + output.columns = columns + output.rows = 30 + output.on('data', () => {}) + const terminal = new ReadlineChatTerminal(input as never, output as never) + return { + terminal, + probe: terminal as never as { + wrappedBody(width: number): string[] + panelWidth(): number + wrapCache: unknown + }, + } +} + +/** + * The cached path must be byte-identical to wrapping the concatenated body in + * one pass — otherwise a streamed frame would differ from a repainted one. + */ +describe('incremental transcript wrapping', () => { + const cases: Array<[string, string[]]> = [ + ['plain lines', ['hello world\n', 'second line\n']], + ['partial final line', ['complete\n', 'partial without newline']], + ['long wrapping line', [`${'word '.repeat(40)}\n`]], + ['styled text', [`${ESC}[1mbold${ESC}[0m plain\n`, `${ESC}[31mred\n`, `still red${ESC}[0m\n`]], + ['blank lines', ['a\n', '\n', '\n', 'b\n']], + ['token by token', ['no newline yet', ' more', ' and more', '\n', 'next\n']], + ['unicode', ['héllo wörld ☃\n', '日本語のテキスト\n']], + ] + + for (const [name, chunks] of cases) { + it(`matches a single-pass wrap: ${name}`, () => { + const { terminal, probe } = harness() + const oneShot = harness() + for (const chunk of chunks) { + terminal.write(chunk) + oneShot.terminal.write(chunk) + oneShot.probe.wrapCache = null + const width = probe.panelWidth() + expect(probe.wrappedBody(width)).toEqual(oneShot.probe.wrappedBody(width)) + } + terminal.close() + oneShot.terminal.close() + }) + } + + it('rebuilds when the width changes', () => { + const { terminal, probe } = harness() + terminal.write(`${'alpha beta '.repeat(20)}\n`) + const narrow = probe.wrappedBody(40) + const wide = probe.wrappedBody(100) + expect(narrow).not.toEqual(wide) + expect(probe.wrappedBody(40)).toEqual(narrow) + terminal.close() + }) + + it('stays correct after the transcript is trimmed from the front', () => { + const { terminal, probe } = harness() + for (let i = 0; i < 400; i++) terminal.write(`line ${i} ${'x'.repeat(200)}\n`) + const width = probe.panelWidth() + const cached = probe.wrappedBody(width) + probe.wrapCache = null + expect(cached).toEqual(probe.wrappedBody(width)) + terminal.close() + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat.test.ts b/packages/sim-cli/src/commands/protocol/chat.test.ts new file mode 100644 index 00000000000..b01fc1d2db3 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat.test.ts @@ -0,0 +1,2646 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SimApiError } from '../../http/client.js' +import { + type ChatDependencies, + chatCommand, + composeChatPrompt, + readChatResponse, + readChatTurn, +} from './chat.js' +import type { ChatAttachment } from './chat-attachments.js' +import type { ChatContext, ChatSuggestionCandidates } from './chat-suggestions.js' +import type { + ChatActivity, + ChatActivityUpdate, + ChatTerminal, + ChatTerminalInput, + ChatTerminalInterruptListener, + ChatTerminalInterruptReason, + ChatTerminalQuestion, + ChatTerminalQuestionResult, + ChatTerminalSelect, + ChatTerminalSelectResult, + ChatTerminalWelcome, +} from './chat-terminal.js' + +const mocks = vi.hoisted(() => ({ + request: vi.fn(), + requestRaw: vi.fn(), + requireWorkspace: vi.fn(() => 'ws_local'), +})) + +vi.mock('../../context.js', () => ({ + clientFrom: () => ({ client: mocks, profile: { endpoint: 'https://sim.example' } }), +})) + +beforeEach(() => { + mocks.request.mockReset().mockResolvedValue({ data: [], nextCursor: null }) + mocks.requestRaw.mockReset() + mocks.requireWorkspace.mockClear() +}) + +function sse(chunks: string[]): Response { + const body = new ReadableStream<Uint8Array>({ + start(controller) { + for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)) + controller.close() + }, + }) + return new Response(body, { headers: { 'content-type': 'text/event-stream' } }) +} + +function completed(content: string, token = 'continuation-1', deltas: string[] = []): Response { + return sse([ + `event: session\ndata: ${JSON.stringify({ + type: 'session', + continuationToken: token, + requestId: 'req_1', + })}\n\n`, + ...deltas.map((delta) => `event: text\ndata: ${JSON.stringify({ type: 'text', delta })}\n\n`), + `event: complete\ndata: ${JSON.stringify({ + type: 'complete', + data: { content, continuationToken: token }, + })}\n\n`, + 'data: [DONE]\n\n', + ]) +} + +function openSse(chunk: string): { response: Response; cancel: ReturnType<typeof vi.fn> } { + const cancel = vi.fn() + const body = new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue(new TextEncoder().encode(chunk)) + }, + cancel, + }) + return { + response: new Response(body, { headers: { 'content-type': 'text/event-stream' } }), + cancel, + } +} + +function program( + readInput: () => Promise<string>, + writeOutput = vi.fn(), + overrides: Partial<ChatDependencies> = {} +): Command { + const root = new Command('sim').exitOverride() + root.option('-P, --profile <name>') + root.addCommand( + chatCommand({ + readInput, + writeOutput, + isInteractive: () => false, + ...overrides, + }) + ) + return root +} + +class FakeTerminal implements ChatTerminal { + readonly welcomes: string[] = [] + readonly workspaceNames: string[] = [] + attachmentNotes = 0 + readonly chatTitles: string[] = [] + readonly userMessages: string[] = [] + readonly statuses: string[] = [] + readonly thinking: string[] = [] + readonly activities: ChatActivityUpdate[] = [] + readonly questions: ChatTerminalQuestion[] = [] + readonly selections: ChatTerminalSelect[] = [] + readonly reads: Array<{ prompt: string; initialValue: string }> = [] + readonly preloads: Array<{ + value: string + queued: boolean + pastes?: ReadonlyMap<number, string> + contexts?: ChatContext[] + }> = [] + readonly writes: string[] = [] + readonly suggestionUpdates: ChatSuggestionCandidates[] = [] + suggestionCandidates: ChatSuggestionCandidates | null = null + clearedTranscripts = 0 + readonly listeners = new Set<ChatTerminalInterruptListener>() + closed = false + private stagedPreload = '' + + constructor( + readonly inputs: ChatTerminalInput[], + readonly questionResults: ChatTerminalQuestionResult[] = [], + readonly selectionResults: ChatTerminalSelectResult[] = [] + ) {} + + welcome({ chatTitle }: ChatTerminalWelcome): void { + this.welcomes.push(chatTitle) + } + + setChatTitle(title: string): void { + this.chatTitles.push(title) + } + + setWorkspaceName(name: string): void { + this.workspaceNames.push(name) + } + + noteAttachment(): void { + this.attachmentNotes += 1 + } + + userMessage(message: string): void { + this.userMessages.push(message) + } + + clearTranscript(): void { + this.clearedTranscripts += 1 + } + + read(prompt: string): Promise<ChatTerminalInput> { + this.reads.push({ prompt, initialValue: this.stagedPreload }) + this.stagedPreload = '' + return Promise.resolve(this.inputs.shift() ?? { kind: 'eof' }) + } + + hasQueuedInput(): boolean { + return ( + Boolean(this.stagedPreload) || + this.inputs.some((input) => input.kind === 'line' && input.queued === true) + ) + } + + preload( + value: string, + options: { + queued?: boolean + pastes?: ReadonlyMap<number, string> + contexts?: ChatContext[] + } = {} + ): boolean { + this.preloads.push({ + value, + queued: options.queued === true, + ...(options.pastes ? { pastes: options.pastes } : {}), + ...(options.contexts ? { contexts: options.contexts } : {}), + }) + this.stagedPreload = value + return true + } + + setSuggestionCandidates(candidates: ChatSuggestionCandidates): void { + this.suggestionCandidates = candidates + this.suggestionUpdates.push(candidates) + } + + status(message: string): void { + this.statuses.push(message) + } + + write(content: string): void { + this.writes.push(content) + } + + activity(_message: string): ChatActivity { + return { + update: () => {}, + thinking: (delta) => this.thinking.push(delta), + event: (update) => this.activities.push(update), + clear: () => {}, + complete: () => {}, + stop: () => {}, + } + } + + askQuestion(question: ChatTerminalQuestion): Promise<ChatTerminalQuestionResult> { + this.questions.push(question) + return Promise.resolve(this.questionResults.shift() ?? { kind: 'cancel' }) + } + + select(menu: ChatTerminalSelect): Promise<ChatTerminalSelectResult> { + this.selections.push(menu) + return Promise.resolve(this.selectionResults.shift() ?? { kind: 'cancel' }) + } + + onInterrupt(listener: ChatTerminalInterruptListener): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + interrupt(reason: ChatTerminalInterruptReason = 'manual', input?: ChatTerminalInput): void { + const submitted = + input ?? + (reason === 'submit' ? this.inputs.find((entry) => entry.kind === 'line') : undefined) + for (const listener of this.listeners) listener(reason, submitted) + } + + close(): void { + this.closed = true + } +} + +describe('chat print mode', () => { + it('posts to the selected workspace and prints only the completed answer', async () => { + const wire = [ + ': keepalive\n\n', + `event: session\ndata: ${JSON.stringify({ + type: 'session', + continuationToken: 'opaque-token', + requestId: 'req_1', + })}\n\n`, + `event: text\ndata: ${JSON.stringify({ type: 'text', delta: 'Hello ' })}\n\n`, + `event: text\ndata: ${JSON.stringify({ type: 'text', delta: 'world' })}\n\n`, + `event: complete\ndata: ${JSON.stringify({ + type: 'complete', + data: { content: 'Hello world', continuationToken: 'opaque-token' }, + })}\n\n`, + 'data: [DONE]\n\n', + ].join('') + mocks.requestRaw.mockResolvedValue( + sse([wire.slice(0, 41), wire.slice(41, 137), wire.slice(137)]) + ) + const writeOutput = vi.fn() + + await program(async () => '', writeOutput).parseAsync([ + 'node', + 'sim', + 'chat', + '-p', + 'What', + 'is', + 'here?', + ]) + + expect(mocks.requireWorkspace).toHaveBeenCalledWith(undefined, { auth: 'optional' }) + expect(mocks.requestRaw).toHaveBeenCalledWith('/api/v2/chat', { + method: 'POST', + headers: { accept: 'text/event-stream' }, + body: { workspaceId: 'ws_local', prompt: 'What is here?' }, + signal: expect.any(AbortSignal), + auth: 'optional', + }) + expect(writeOutput).toHaveBeenCalledOnce() + expect(writeOutput).toHaveBeenCalledWith('Hello world') + }) + + it('keeps the profile shorthand distinct from chat -p', async () => { + mocks.requestRaw.mockResolvedValue(completed('answer')) + + await program(async () => '').parseAsync(['node', 'sim', '-P', 'dev', 'chat', '-p', 'question']) + + expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'question', + }) + }) + + it('opts into query-only chat only when --read-only is passed', async () => { + mocks.requestRaw.mockResolvedValue(completed('answer')) + + await program(async () => '').parseAsync([ + 'node', + 'sim', + 'chat', + '-p', + '--read-only', + 'question', + ]) + + expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'question', + readOnly: true, + }) + }) + + it('combines positional and piped input in Claude Code order', async () => { + mocks.requestRaw.mockResolvedValue(completed('answer')) + + await program(async () => 'piped context\n').parseAsync([ + 'node', + 'sim', + 'chat', + '--print', + 'Explain', + 'this', + ]) + + expect(mocks.requestRaw.mock.calls[0][1].body.prompt).toBe('Explain this\npiped context\n') + }) + + it('accepts piped input without a positional prompt', async () => { + mocks.requestRaw.mockResolvedValue(completed('answer')) + + await program(async () => 'question from stdin\n').parseAsync(['node', 'sim', 'chat', '-p']) + + expect(mocks.requestRaw.mock.calls[0][1].body.prompt).toBe('question from stdin\n') + }) + + it('accepts attachment-only turns and never sends local paths', async () => { + const attachment: ChatAttachment = { + name: 'notes.md', + mediaType: 'text/markdown', + data: 'IyBub3Rlcw==', + } + const loadAttachments = vi.fn(async (paths: string[]) => (paths.length ? [attachment] : [])) + mocks.requestRaw.mockResolvedValue(completed('Inspected')) + + await program(async () => '', vi.fn(), { loadAttachments }).parseAsync([ + 'node', + 'sim', + 'chat', + '-p', + '--file', + '/private/local/notes.md', + ]) + + expect(loadAttachments).toHaveBeenCalledWith(['/private/local/notes.md']) + expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: '', + attachments: [attachment], + }) + expect(JSON.stringify(mocks.requestRaw.mock.calls[0][1].body)).not.toContain('/private/local') + }) + + it('requires a prompt, attachment, or stdin', async () => { + await expect(program(async () => '').parseAsync(['node', 'sim', 'chat', '-p'])).rejects.toThrow( + /Provide a prompt, attach a file, or pipe input/ + ) + expect(mocks.requestRaw).not.toHaveBeenCalled() + }) + + it('caps the combined prompt by UTF-8 bytes', async () => { + const justOverTenMebibytes = 'é'.repeat(5 * 1024 * 1024 + 1) + + const result = program(async () => justOverTenMebibytes).parseAsync([ + 'node', + 'sim', + 'chat', + '-p', + ]) + + await expect(result).rejects.toMatchObject({ + message: 'Chat input exceeds the 10 MiB limit.', + status: 0, + }) + expect(mocks.requestRaw).not.toHaveBeenCalled() + }) + + it('fails clearly instead of blocking when bare chat has no interactive terminal', async () => { + await expect( + program(async () => '').parseAsync(['node', 'sim', 'chat', 'question']) + ).rejects.toThrow(/Use sim chat -p/) + expect(mocks.requestRaw).not.toHaveBeenCalled() + }) + + it('never constructs a terminal prompt in -p mode', async () => { + mocks.requestRaw.mockResolvedValue(completed('answer')) + const createTerminal = vi.fn(() => { + throw new Error('must not prompt') + }) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal, + }).parseAsync(['node', 'sim', 'chat', '-p', 'question']) + + expect(createTerminal).not.toHaveBeenCalled() + }) + + it('sanitizes final plain text and strips suggested follow-up options', async () => { + const terminalEscape = String.fromCharCode(27) + mocks.requestRaw.mockResolvedValue( + completed( + `Safe${terminalEscape}]0;owned\u0007 text<options>{"1":{"title":"Next${terminalEscape}[2A","description":"Continue"}}</options>` + ) + ) + const writeOutput = vi.fn() + + await program(async () => '', writeOutput).parseAsync(['node', 'sim', 'chat', '-p', 'question']) + + expect(writeOutput).toHaveBeenCalledWith('Safe text') + expect(writeOutput.mock.calls[0][0]).not.toContain(terminalEscape) + }) + + it('trims whitespace owned by hidden options in print mode', async () => { + const options = '<options>{"1":{"title":"Next","description":"Continue"}}</options>' + mocks.requestRaw.mockResolvedValue(completed(`Answer\n\n${options}\n\n`)) + const writeOutput = vi.fn() + + await program(async () => '', writeOutput).parseAsync(['node', 'sim', 'chat', '-p', 'question']) + + expect(writeOutput).toHaveBeenCalledOnce() + expect(writeOutput).toHaveBeenCalledWith('Answer') + }) + + it('renders a path-only file resource as its plain title without another API request', async () => { + mocks.requestRaw.mockResolvedValue( + completed( + '<workspace_resource>{"type":"file","path":"files/Reports/Q4%20Report.csv","title":"Q4 report"}</workspace_resource>' + ) + ) + const writeOutput = vi.fn() + + await program(async () => '', writeOutput).parseAsync([ + 'node', + 'sim', + 'chat', + '-p', + 'find file', + ]) + + expect(mocks.request).not.toHaveBeenCalled() + expect(writeOutput).toHaveBeenCalledWith('Q4 report') + }) + + it('omits a trailing standalone workspace link in print mode', async () => { + const resource = + '<workspace_resource>{"type":"workflow","id":"wf-forceful","title":"forceful-arm"}</workspace_resource>' + mocks.requestRaw.mockResolvedValue(completed(`Summary.\n\n${resource}`)) + const writeOutput = vi.fn() + + await program(async () => '', writeOutput).parseAsync([ + 'node', + 'sim', + 'chat', + '-p', + 'inspect forceful-arm', + ]) + + expect(writeOutput).toHaveBeenCalledWith('Summary.') + }) + + it('does not print a partial answer when the stream fails', async () => { + mocks.requestRaw.mockResolvedValue( + sse([ + 'event: text\ndata: {"type":"text","delta":"partial"}\n\n', + 'event: error\ndata: {"type":"error","error":{"code":"FAILED","message":"No answer"}}\n\n', + ]) + ) + const writeOutput = vi.fn() + + await expect( + program(async () => '', writeOutput).parseAsync(['node', 'sim', 'chat', '-p', 'question']) + ).rejects.toThrow('No answer') + expect(writeOutput).not.toHaveBeenCalled() + }) + + it('keeps thinking and activity events silent in print mode', async () => { + mocks.requestRaw.mockResolvedValue( + sse([ + 'event: thinking\ndata: {"type":"thinking","delta":"Checking the workspace"}\n\n', + 'event: activity\ndata: {"type":"activity","data":{"kind":"subagent","id":"agent-1","label":"Build Agent","state":"running"}}\n\n', + 'event: activity\ndata: {"type":"activity","data":{"kind":"narration","parentId":"agent-1","delta":"Inspecting files"}}\n\n', + 'event: activity\ndata: {"type":"activity","data":{"kind":"tool","id":"tool-1","label":"Read workflows","state":"running"}}\n\n', + 'event: text\ndata: {"type":"text","delta":"Answer"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Answer","continuationToken":"token-1"}}\n\n', + ]) + ) + const writeOutput = vi.fn() + await program(async () => '', writeOutput).parseAsync(['node', 'sim', 'chat', '-p', 'question']) + + expect(writeOutput).toHaveBeenCalledWith('Answer') + }) +}) + +describe('interactive chat', () => { + it('renders Markdown in the fullscreen TUI when TERM is dumb', async () => { + const originalTerm = process.env.TERM + const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY') + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }) + process.env.TERM = 'dumb' + + try { + const content = '**Workflows (3)**\n- cobalt_cloud' + mocks.requestRaw.mockResolvedValue(completed(content, 'token-1', [content])) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'list workspace']) + + const esc = String.fromCharCode(27) + const rendered = terminal.writes.join('') + expect(rendered).toContain(`${esc}[1mWorkflows (3)`) + expect(rendered).toContain(`${esc}[2m•${esc}[0m`) + expect(rendered).not.toContain('**') + expect(rendered).toContain('cobalt_cloud') + } finally { + if (originalIsTTY) { + Object.defineProperty(process.stdout, 'isTTY', originalIsTTY) + } else { + Reflect.deleteProperty(process.stdout, 'isTTY') + } + if (originalTerm === undefined) Reflect.deleteProperty(process.env, 'TERM') + else process.env.TERM = originalTerm + } + }) + + it('aborts every background suggestion request when the terminal session closes', async () => { + const signals: AbortSignal[] = [] + mocks.request.mockImplementation( + (_path: string, options: { signal?: AbortSignal } = {}) => + new Promise((_resolve, reject) => { + if (!options.signal) return + signals.push(options.signal) + options.signal.addEventListener('abort', () => reject(new Error('aborted')), { + once: true, + }) + }) + ) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(signals).toHaveLength(7) + expect(signals.every((signal) => signal === signals[0])).toBe(true) + expect(signals[0]?.aborted).toBe(true) + expect(terminal.closed).toBe(true) + }) + + it('loads workspace resources under @ and skills plus enabled MCP servers under /', async () => { + mocks.request.mockImplementation((path: string) => { + if (path === '/api/v2/workflows') { + return Promise.resolve({ data: [{ id: 'wf-1', name: 'Release' }], nextCursor: null }) + } + if (path === '/api/v2/tables') { + return Promise.resolve({ data: [{ id: 'table-1', name: 'Leads' }], nextCursor: null }) + } + if (path === '/api/v2/files') { + return Promise.resolve({ data: [{ id: 'file-1', name: 'Brief.md' }], nextCursor: null }) + } + if (path === '/api/v2/knowledge') { + return Promise.resolve({ data: [{ id: 'kb-1', name: 'Handbook' }], nextCursor: null }) + } + if (path === '/api/v2/logs') { + return Promise.resolve({ + data: Array.from({ length: 55 }, (_, index) => ({ + id: `log-row-${index + 1}`, + executionId: `execution-${index + 1}`, + workflowId: 'wf-1', + startedAt: '2026-08-07T12:00:00.000Z', + })), + nextCursor: 'more-logs', + }) + } + if (path === '/api/v2/skills') { + return Promise.resolve({ + data: [{ id: 'skill-1', name: 'review', description: 'Review the work' }], + nextCursor: null, + }) + } + if (path === '/api/v2/mcp-servers') { + return Promise.resolve({ + data: [ + { id: 'mcp-1', name: 'Docs', enabled: true }, + { id: 'mcp-2', name: 'Disabled', enabled: false }, + ], + nextCursor: null, + }) + } + return Promise.resolve({ data: [], nextCursor: null }) + }) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + await vi.waitFor(() => { + expect(terminal.suggestionCandidates?.resources).toHaveLength(54) + expect(terminal.suggestionCandidates?.slash).toHaveLength(2) + }) + + const resources = terminal.suggestionCandidates?.resources ?? [] + expect(resources.slice(0, 4).map((item) => item.context?.kind)).toEqual([ + 'workflow', + 'table', + 'file', + 'knowledge', + ]) + expect(resources.slice(4)).toHaveLength(50) + expect(resources.slice(4).every((item) => item.context?.kind === 'logs')).toBe(true) + expect(resources.at(-1)?.context).toMatchObject({ + kind: 'logs', + executionId: 'execution-50', + label: expect.stringContaining('Release'), + }) + expect(terminal.suggestionCandidates?.slash.map((item) => item.context?.kind)).toEqual([ + 'skill', + 'mcp', + ]) + expect(terminal.suggestionCandidates?.slash.map((item) => item.displayText)).toEqual([ + '/review', + '/Docs', + ]) + expect( + terminal.suggestionUpdates.some( + (update) => + update.resources.length + update.slash.length > 0 && update.resources.length < 54 + ) + ).toBe(true) + const logRequests = mocks.request.mock.calls.filter(([path]) => path === '/api/v2/logs') + expect(logRequests).toHaveLength(1) + expect(logRequests[0]?.[1]).toMatchObject({ + query: { + workspaceId: 'ws_local', + details: 'basic', + order: 'desc', + limit: 50, + }, + }) + }) + + it('publishes skills but does not fetch or suggest MCP servers in read-only chat', async () => { + mocks.request.mockImplementation((path: string) => { + if (path === '/api/v2/skills') { + return Promise.resolve({ + data: [{ id: 'skill-1', name: 'review', description: 'Review the work' }], + nextCursor: null, + }) + } + if (path === '/api/v2/mcp-servers') { + return Promise.resolve({ + data: [{ id: 'mcp-1', name: 'Docs', enabled: true }], + nextCursor: null, + }) + } + return Promise.resolve({ data: [], nextCursor: null }) + }) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', '--read-only']) + await vi.waitFor(() => expect(terminal.suggestionCandidates?.slash).toHaveLength(1)) + + expect(terminal.suggestionCandidates?.slash[0]?.context?.kind).toBe('skill') + expect(mocks.request.mock.calls.some(([path]) => path === '/api/v2/mcp-servers')).toBe(false) + }) + + it('publishes each suggestion family without waiting for a slower list', async () => { + let resolveWorkflows: + | ((page: { data: Array<{ id: string; name: string }>; nextCursor: null }) => void) + | undefined + const workflows = new Promise<{ data: Array<{ id: string; name: string }>; nextCursor: null }>( + (resolve) => { + resolveWorkflows = resolve + } + ) + mocks.request.mockImplementation((path: string) => { + if (path === '/api/v2/workflows') return workflows + if (path === '/api/v2/files') { + return Promise.resolve({ data: [{ id: 'file-1', name: 'Ready.md' }], nextCursor: null }) + } + return Promise.resolve({ data: [], nextCursor: null }) + }) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + await vi.waitFor(() => + expect(terminal.suggestionCandidates?.resources.map((item) => item.displayText)).toContain( + 'Ready.md' + ) + ) + expect(terminal.suggestionCandidates?.resources.map((item) => item.displayText)).not.toContain( + 'Later workflow' + ) + + resolveWorkflows?.({ data: [{ id: 'workflow-1', name: 'Later workflow' }], nextCursor: null }) + await vi.waitFor(() => + expect(terminal.suggestionCandidates?.resources.map((item) => item.displayText)).toContain( + 'Later workflow' + ) + ) + }) + + it('sends selected resource and slash identities beside the prompt', async () => { + const contexts: ChatContext[] = [ + { kind: 'workflow', workflowId: 'workflow-1', label: 'Release' }, + { kind: 'skill', skillId: 'skill-1', label: 'review' }, + { kind: 'mcp', serverId: 'mcp-1', label: 'Docs' }, + ] + const terminal = new FakeTerminal([ + { + kind: 'line', + value: 'Use @Release with /review and /Docs', + contexts, + }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw.mockResolvedValueOnce(completed('Done', 'token-1')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(mocks.requestRaw.mock.calls[0][1].body).toMatchObject({ + prompt: 'Use @Release with /review and /Docs', + contexts, + }) + }) + + it('lists every chat page and refreshes an active choice before sending', async () => { + let detailRequests = 0 + mocks.request.mockImplementation((path: string, options?: { query?: unknown }) => { + if (path === '/api/v2/chats') { + const cursor = (options?.query as { cursor?: string | null } | undefined)?.cursor + if (cursor === 'older-chats') { + return Promise.resolve({ + data: [ + { + id: 'chat-older', + title: 'Older investigation', + updatedAt: '2026-07-01T12:00:00.000Z', + pinned: false, + active: false, + }, + ], + nextCursor: null, + }) + } + return Promise.resolve({ + data: [ + { + id: 'chat-2', + title: 'Release investigation', + updatedAt: '2026-08-07T12:00:00.000Z', + pinned: true, + active: true, + }, + ], + nextCursor: 'older-chats', + }) + } + if (path === '/api/v2/chats/chat-2') { + detailRequests += 1 + const active = detailRequests === 1 + return Promise.resolve({ + data: { + id: 'chat-2', + title: 'Release investigation', + messages: [ + { + id: 'message-1', + role: 'user', + content: 'What failed?', + timestamp: '2026-08-07T11:59:00.000Z', + }, + { + id: 'message-2', + role: 'assistant', + content: active + ? 'The **release** is still running.' + : 'The **release** finished.\n\n<workspace_resource>{"type":"workflow","id":"wf-forceful","title":"forceful-arm"}</workspace_resource>', + timestamp: '2026-08-07T12:00:00.000Z', + }, + ], + continuationToken: active ? 'resume-token' : 'refreshed-token', + active, + }, + }) + } + return Promise.resolve({ data: [], nextCursor: null, options }) + }) + mocks.requestRaw.mockResolvedValueOnce(completed('Continuing', 'next-token')) + const terminal = new FakeTerminal( + [ + { kind: 'line', value: '/chats' }, + { kind: 'line', value: 'Continue here' }, + { kind: 'line', value: '/exit' }, + ], + [], + [{ kind: 'selected', id: 'chat-2' }] + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + formatMarkdown: () => false, + }).parseAsync(['node', 'sim', 'chat']) + + expect(terminal.selections).toHaveLength(1) + expect(terminal.selections[0]?.options).toEqual([ + { + id: 'sim-cli:new-chat', + label: 'New chat', + description: 'start a blank conversation', + }, + expect.objectContaining({ + id: 'chat-2', + label: 'Release investigation', + description: expect.stringContaining('pinned'), + }), + expect.objectContaining({ + id: 'chat-older', + label: 'Older investigation', + }), + ]) + expect(detailRequests).toBe(2) + expect(terminal.clearedTranscripts).toBe(2) + expect(terminal.statuses).toContain( + 'Opened Release investigation. This chat is currently active elsewhere.' + ) + expect(terminal.statuses).toContain('Resumed Release investigation.') + expect(terminal.chatTitles).toContain('Release investigation') + expect(terminal.userMessages).toContain('What failed?') + expect(terminal.userMessages).toContain('Continue here') + expect(terminal.writes.join('')).toContain('The **release** finished.\n') + expect(terminal.writes.join('')).not.toContain('forceful-arm') + expect(mocks.requestRaw.mock.calls[0][1].body).toMatchObject({ + workspaceId: 'ws_local', + prompt: 'Continue here', + continuationToken: 'refreshed-token', + }) + const listRequests = mocks.request.mock.calls.filter(([path]) => path === '/api/v2/chats') + expect(listRequests).toHaveLength(2) + expect(listRequests[0]?.[1]).toMatchObject({ + query: { workspaceId: 'ws_local', limit: 100, cursor: null }, + }) + expect(listRequests[1]?.[1]).toMatchObject({ + query: { workspaceId: 'ws_local', limit: 100, cursor: 'older-chats' }, + }) + }) + + it('refreshes a resumed chat before retrying after a send races with remote activity', async () => { + let detailRequests = 0 + mocks.request.mockImplementation((path: string) => { + if (path === '/api/v2/chats') { + return Promise.resolve({ + data: [ + { + id: 'chat-race', + title: 'Race investigation', + updatedAt: '2026-08-07T12:00:00.000Z', + pinned: false, + active: false, + }, + ], + nextCursor: null, + }) + } + if (path === '/api/v2/chats/chat-race') { + detailRequests += 1 + return Promise.resolve({ + data: { + id: 'chat-race', + title: 'Race investigation', + messages: [ + { + id: `message-${detailRequests}`, + role: 'assistant', + content: detailRequests === 1 ? 'Ready.' : 'The remote response finished.', + timestamp: '2026-08-07T12:00:00.000Z', + }, + ], + continuationToken: detailRequests === 1 ? 'initial-token' : 'refreshed-token', + active: false, + }, + }) + } + return Promise.resolve({ data: [], nextCursor: null }) + }) + mocks.requestRaw + .mockRejectedValueOnce( + new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') + ) + .mockResolvedValueOnce(completed('Retried', 'next-token')) + const terminal = new FakeTerminal( + [ + { kind: 'line', value: '/chats' }, + { kind: 'line', value: 'Retry this turn' }, + { kind: 'line', value: 'Retry this turn' }, + { kind: 'line', value: '/exit' }, + ], + [], + [{ kind: 'selected', id: 'chat-race' }] + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(detailRequests).toBe(2) + expect(terminal.clearedTranscripts).toBe(2) + expect(terminal.preloads).toContainEqual({ value: 'Retry this turn', queued: true }) + expect(terminal.statuses).toContain( + 'Previous response is still settling. Press Enter to retry.' + ) + expect(terminal.writes.join('')).toContain('The remote response finished.\n') + expect(mocks.requestRaw).toHaveBeenCalledTimes(2) + expect(mocks.requestRaw.mock.calls[1][1].body).toMatchObject({ + workspaceId: 'ws_local', + prompt: 'Retry this turn', + continuationToken: 'refreshed-token', + }) + }) + + it('repaints and restores the exact turn while a resumed chat remains active elsewhere', async () => { + const attachment: ChatAttachment = { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'bm90ZXM=', + } + const pasted = 'p'.repeat(900) + const display = 'Retry @Release [Pasted text #1]' + const prompt = `Retry @Release ${pasted}` + const pastes = new Map([[1, pasted]]) + const contexts: ChatContext[] = [ + { kind: 'workflow', workflowId: 'workflow-1', label: 'Release' }, + ] + let detailRequests = 0 + mocks.request.mockImplementation((path: string) => { + if (path === '/api/v2/chats') { + return Promise.resolve({ + data: [ + { + id: 'chat-active', + title: 'Active investigation', + updatedAt: '2026-08-07T12:00:00.000Z', + pinned: false, + active: true, + }, + ], + nextCursor: null, + }) + } + if (path === '/api/v2/chats/chat-active') { + detailRequests += 1 + const active = detailRequests < 3 + return Promise.resolve({ + data: { + id: 'chat-active', + title: 'Active investigation', + messages: [ + { + id: 'message-1', + role: 'assistant', + content: active ? 'Still working.' : 'Finished now.', + timestamp: '2026-08-07T12:00:00.000Z', + }, + ], + continuationToken: `resume-token-${detailRequests}`, + active, + }, + }) + } + return Promise.resolve({ data: [], nextCursor: null }) + }) + mocks.requestRaw.mockResolvedValueOnce(completed('Retried', 'next-token')) + const terminal = new FakeTerminal( + [ + { kind: 'line', value: '/attach "/private/tmp/notes.txt"' }, + { kind: 'line', value: '/chats' }, + { kind: 'line', value: prompt, display, pastes, contexts }, + { kind: 'line', value: prompt, display, pastes, contexts }, + { kind: 'line', value: '/exit' }, + ], + [], + [{ kind: 'selected', id: 'chat-active' }] + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + loadAttachments: async (paths) => (paths.length ? [attachment] : []), + pastedAttachmentPaths: async () => null, + }).parseAsync(['node', 'sim', 'chat']) + + expect(detailRequests).toBe(3) + expect(terminal.statuses).toContain( + 'Refreshed Active investigation. This chat remains active elsewhere.' + ) + expect(terminal.preloads).toContainEqual({ + value: display, + queued: true, + pastes, + contexts, + }) + expect(terminal.userMessages).toContain(display) + expect(mocks.requestRaw).toHaveBeenCalledTimes(1) + expect(mocks.requestRaw.mock.calls[0][1].body).toMatchObject({ + workspaceId: 'ws_local', + prompt, + continuationToken: 'resume-token-3', + attachments: [attachment], + contexts, + }) + }) + + it('visibly resets the transcript and continuation identity with /clear', async () => { + mocks.requestRaw + .mockResolvedValueOnce( + sse([ + 'event: session\ndata: {"type":"session","chatId":"chat-1","continuationToken":"token-1"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"First","continuationToken":"token-1"}}\n\n', + ]) + ) + .mockResolvedValueOnce(completed('Second', 'token-2')) + const terminal = new FakeTerminal([ + { kind: 'line', value: '/clear' }, + { kind: 'line', value: 'Fresh question' }, + { kind: 'line', value: '/exit' }, + ]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'Original question']) + + expect(terminal.clearedTranscripts).toBe(1) + expect(terminal.statuses).toContain('Started a new conversation.') + expect(terminal.chatTitles).toContain('New chat') + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'Fresh question', + }) + }) + + it('updates the welcome header when the server generates a chat title', async () => { + mocks.requestRaw.mockResolvedValueOnce( + sse([ + 'event: session\ndata: {"type":"session","chatId":"chat-1","continuationToken":"token-1"}\n\n', + 'event: session\ndata: {"type":"session","title":"Release investigation"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"token-1"}}\n\n', + ]) + ) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'Investigate the release']) + + expect(terminal.welcomes).toEqual(['New chat']) + expect(terminal.chatTitles).toContain('Release investigation') + }) + + it('renames the active synced chat and updates the terminal header', async () => { + mocks.requestRaw.mockResolvedValueOnce( + sse([ + 'event: session\ndata: {"type":"session","chatId":"chat-1","continuationToken":"token-1"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"token-1"}}\n\n', + ]) + ) + mocks.request.mockImplementation((path: string) => { + if (path === '/api/v2/chats/chat-1') { + return Promise.resolve({ + data: { id: 'chat-1', title: 'Incident investigation' }, + }) + } + return Promise.resolve({ data: [], nextCursor: null }) + }) + const terminal = new FakeTerminal([ + { kind: 'line', value: '/rename Incident investigation' }, + { kind: 'line', value: '/exit' }, + ]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'Investigate the incident']) + + const renameRequest = mocks.request.mock.calls.find( + ([path, options]) => path === '/api/v2/chats/chat-1' && options?.method === 'PATCH' + ) + expect(renameRequest?.[1]).toEqual({ + method: 'PATCH', + body: { workspaceId: 'ws_local', title: 'Incident investigation' }, + auth: 'optional', + }) + expect(terminal.chatTitles).toContain('Incident investigation') + expect(terminal.statuses).toContain('Renamed chat to Incident investigation.') + }) + + it('requires a synced chat before renaming', async () => { + const terminal = new FakeTerminal([ + { kind: 'line', value: '/rename Draft title' }, + { kind: 'line', value: '/exit' }, + ]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(terminal.statuses).toContain('Send a message before renaming this chat.') + expect(mocks.request.mock.calls.some(([, options]) => options?.method === 'PATCH')).toBe(false) + }) + + it('validates rename titles locally', async () => { + const terminal = new FakeTerminal([ + { kind: 'line', value: '/rename' }, + { kind: 'line', value: `/rename ${'x'.repeat(201)}` }, + { kind: 'line', value: '/exit' }, + ]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(terminal.statuses).toContain('Usage: /rename <title>') + expect(terminal.statuses).toContain('Error: Chat title cannot exceed 200 characters.') + expect(mocks.request.mock.calls.some(([, options]) => options?.method === 'PATCH')).toBe(false) + }) + + it('keeps the current title when rename fails', async () => { + mocks.requestRaw.mockResolvedValueOnce( + sse([ + 'event: session\ndata: {"type":"session","chatId":"chat-1","title":"Current title","continuationToken":"token-1"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"token-1"}}\n\n', + ]) + ) + mocks.request.mockImplementation((path: string) => { + if (path === '/api/v2/chats/chat-1') return Promise.reject(new Error('Rename failed')) + return Promise.resolve({ data: [], nextCursor: null }) + }) + const terminal = new FakeTerminal([ + { kind: 'line', value: '/rename New title' }, + { kind: 'line', value: '/exit' }, + ]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'Start']) + + expect(terminal.chatTitles).toEqual(['Current title']) + expect(terminal.statuses).toContain('Error: Rename failed') + }) + + it('sends only MCP contexts explicitly tagged on each turn', async () => { + const mcp: ChatContext = { kind: 'mcp', serverId: 'mcp-1', label: 'Docs' } + const terminal = new FakeTerminal([ + { kind: 'line', value: '/Docs search', contexts: [mcp] }, + { kind: 'line', value: 'Search again' }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockResolvedValueOnce(completed('First', 'token-1')) + .mockResolvedValueOnce(completed('Second', 'token-2')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(mocks.requestRaw.mock.calls[0][1].body.contexts).toEqual([mcp]) + expect(mocks.requestRaw.mock.calls[1][1].body.contexts).toBeUndefined() + }) + + it('quietly clears on Ctrl+C and exits on a second empty Ctrl+C', async () => { + const terminal = new FakeTerminal([ + { kind: 'interrupt', empty: true }, + { kind: 'interrupt', empty: true }, + ]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(terminal.statuses).toEqual([]) + expect(terminal.welcomes).toEqual(['New chat']) + expect(mocks.requestRaw).not.toHaveBeenCalled() + expect(terminal.closed).toBe(true) + }) + + it('strips suggested follow-ups and keeps the next composer message free-form', async () => { + const options = + '<options>{"1":{"title":"First","description":"A"},"2":{"title":"Second","description":"B"}}</options>' + mocks.requestRaw + .mockResolvedValueOnce( + completed(options, 'token-1', [options.slice(0, 31), options.slice(31)]) + ) + .mockResolvedValueOnce(completed('Done', 'token-2', ['Do', 'ne'])) + const terminal = new FakeTerminal([ + { kind: 'line', value: 'A different request' }, + { kind: 'line', value: '/exit' }, + ]) + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + formatMarkdown: () => false, + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(mocks.requestRaw).toHaveBeenCalledTimes(2) + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'A different request', + continuationToken: 'token-1', + }) + expect(terminal.writes.join('')).toBe('Done\n') + expect(terminal.statuses.join('\n')).not.toContain('Suggested follow-ups') + expect(terminal.statuses.join('\n')).not.toContain('First') + expect(terminal.reads[0]).toEqual({ prompt: '❯ ', initialValue: '' }) + expect(terminal.userMessages).toEqual(['start']) + expect(terminal.closed).toBe(true) + }) + + it.each([ + ['plain trailing whitespace', 'Answer\n\n'], + [ + 'whitespace before hidden options', + 'Answer\n\n<options>{"1":{"title":"Next","description":"Continue"}}</options>\n\n', + ], + ])('hands %s to the next composer with exactly one newline', async (_name, content) => { + mocks.requestRaw.mockResolvedValue(completed(content, 'token-1', [content])) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(terminal.writes.join('')).toBe('Answer\n') + expect(terminal.reads).toEqual([{ prompt: '❯ ', initialValue: '' }]) + }) + + it('renders tagged resource bullets as plain names without links or undefined prefixes', async () => { + const content = [ + 'Workflows\n', + '- <workspace_resource>{"type":"workflow","id":"wf-1","title":"default-agent"}</workspace_resource>\n', + '- <workspace_resource>{"type":"workflow","id":"wf-2","title":"forceful-arm"}</workspace_resource>', + ].join('') + mocks.requestRaw.mockResolvedValue(completed(content, 'token-1', [content])) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + formatMarkdown: () => true, + }).parseAsync(['node', 'sim', 'chat', 'list resources']) + + const rendered = terminal.writes.join('') + expect(rendered).toContain('default-agent') + expect(rendered).toContain('forceful-arm') + expect(rendered).not.toContain('undefined') + expect(rendered).not.toContain('https://') + expect(rendered).not.toContain(`${String.fromCharCode(27)}]8;;`) + }) + + it('omits a trailing standalone workspace link that has no terminal action', async () => { + const resource = + '<workspace_resource>{"type":"workflow","id":"wf-forceful","title":"forceful-arm"}</workspace_resource>' + const content = [ + 'Three blocks, mostly a stub:\n\n', + '- Start — manual trigger.\n', + '- Router 1 — always routes hi.\n', + '- Agent 1 — replies to hi.\n\n', + resource, + ].join('') + mocks.requestRaw.mockResolvedValue( + completed(content, 'token-1', [ + content.slice(0, content.indexOf('<workspace_resource>') + 12), + content.slice(content.indexOf('<workspace_resource>') + 12, -8), + content.slice(-8), + ]) + ) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + formatMarkdown: () => false, + }).parseAsync(['node', 'sim', 'chat', 'inspect forceful-arm']) + + const rendered = terminal.writes.join('') + expect(rendered).toContain('Three blocks, mostly a stub:') + expect(rendered).toContain('- Agent 1 — replies to hi.') + expect(rendered).not.toContain('forceful-arm') + expect(rendered.endsWith('\n')).toBe(true) + }) + + it('restores a deferred workspace link when a later chunk continues the answer', async () => { + const resource = + '<workspace_resource>{"type":"workflow","id":"wf-forceful","title":"forceful-arm"}</workspace_resource>' + const first = `Summary.\n\n${resource}` + const content = `${first}\nThen continue.` + mocks.requestRaw.mockResolvedValue(completed(content, 'token-1', [first, '\nThen continue.'])) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + formatMarkdown: () => false, + }).parseAsync(['node', 'sim', 'chat', 'inspect forceful-arm']) + + expect(terminal.writes.join('')).toBe('Summary.\n\nforceful-arm\nThen continue.\n') + }) + + it('uses the dedicated question panel and sends its answer with the continuation token', async () => { + const question = + '<question>{"type":"single_select","prompt":"Which service should I inspect?","options":[{"id":"api","label":"API"},{"id":"worker","label":"Worker"}]}</question>' + mocks.requestRaw + .mockResolvedValueOnce(completed(question, 'token-1')) + .mockResolvedValueOnce(completed('Done', 'token-2')) + const terminal = new FakeTerminal( + [{ kind: 'line', value: '/exit' }], + [{ kind: 'answer', values: ['Worker'] }] + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(terminal.questions).toEqual([ + { + prompt: 'Which service should I inspect?', + multi: false, + options: [ + { id: 'api', label: 'API' }, + { id: 'worker', label: 'Worker' }, + ], + }, + ]) + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'Which service should I inspect? — Worker', + continuationToken: 'token-1', + }) + }) + + it('runs queued local commands before presenting a retained structured question', async () => { + const question = + '<question>{"type":"single_select","prompt":"Proceed?","options":[{"id":"yes","label":"Yes"}]}</question>' + mocks.requestRaw + .mockResolvedValueOnce(completed(question, 'token-1')) + .mockResolvedValueOnce(completed('Done', 'token-2')) + const terminal = new FakeTerminal( + [ + { kind: 'line', value: '/help', queued: true, display: '/help' }, + { kind: 'line', value: '/exit' }, + ], + [{ kind: 'answer', values: ['Yes'] }] + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(terminal.statuses.join('\n')).toContain('Commands:') + expect(terminal.questions).toHaveLength(1) + expect(mocks.requestRaw.mock.calls.map(([, options]) => options.body.prompt)).toEqual([ + 'start', + 'Proceed? — Yes', + ]) + }) + + it('waits for queued path confirmation before answering a retained question', async () => { + const question = + '<question>{"type":"single_select","prompt":"Proceed?","options":[{"id":"yes","label":"Yes"}]}</question>' + const attachment: ChatAttachment = { + name: 'report.txt', + mediaType: 'text/plain', + data: 'cmVwb3J0', + } + mocks.requestRaw + .mockResolvedValueOnce(completed(question, 'token-1')) + .mockResolvedValueOnce(completed('Done', 'token-2')) + const terminal = new FakeTerminal( + [ + { + kind: 'line', + value: '/private/tmp/report.txt', + queued: true, + display: '/private/tmp/report.txt', + }, + { kind: 'line', value: '/attach "/private/tmp/report.txt"' }, + { kind: 'line', value: '/exit' }, + ], + [{ kind: 'answer', values: ['Yes'] }] + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + pastedAttachmentPaths: async (value) => + value === '/private/tmp/report.txt' ? ['/private/tmp/report.txt'] : null, + loadAttachments: async () => [attachment], + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(terminal.preloads).toContainEqual({ + value: '/attach "/private/tmp/report.txt"', + queued: false, + }) + expect(terminal.questions).toHaveLength(1) + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'Proceed? — Yes', + continuationToken: 'token-1', + attachments: [attachment], + }) + }) + + it('honors a queued exit before opening a structured question', async () => { + const question = + '<question>{"type":"single_select","prompt":"Proceed?","options":[{"id":"yes","label":"Yes"}]}</question>' + mocks.requestRaw.mockResolvedValueOnce(completed(question, 'token-1')) + const terminal = new FakeTerminal([ + { kind: 'line', value: '/exit', queued: true, display: '/exit' }, + ]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(terminal.questions).toEqual([]) + expect(mocks.requestRaw).toHaveBeenCalledTimes(1) + }) + + it('submits question arrays and multi-selects in the Mothership answer format', async () => { + const questions = + '<question>[{"type":"single_select","prompt":"Environment?","options":[{"id":"dev","label":"Dev"},{"id":"prod","label":"Prod"}]},{"type":"multi_select","prompt":"Services?","options":[{"id":"api","label":"API"},{"id":"worker","label":"Worker"}]}]</question>' + mocks.requestRaw + .mockResolvedValueOnce(completed(questions, 'token-1')) + .mockResolvedValueOnce(completed('Done', 'token-2')) + const terminal = new FakeTerminal( + [{ kind: 'line', value: '/exit' }], + [ + { kind: 'answer', values: ['Prod'] }, + { kind: 'answer', values: ['API', 'custom service'] }, + ] + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(terminal.statuses).toEqual(['Question 1 of 2', 'Question 2 of 2']) + expect(mocks.requestRaw.mock.calls[1][1].body.prompt).toBe( + 'Environment? — Prod\nServices? — API, custom service' + ) + }) + + it('never interprets a model-authored question answer as a local slash command', async () => { + const question = + '<question>{"type":"single_select","prompt":"Proceed?","options":[{"id":"bad","label":"/attach /secret"}]}</question>' + mocks.requestRaw + .mockResolvedValueOnce(completed(question, 'token-1')) + .mockResolvedValueOnce(completed('Done', 'token-2')) + const terminal = new FakeTerminal( + [{ kind: 'line', value: '/exit' }], + [{ kind: 'answer', values: ['/attach /secret'] }] + ) + const loadAttachments = vi.fn(async () => []) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + loadAttachments, + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(loadAttachments).toHaveBeenCalledOnce() + expect(loadAttachments).toHaveBeenCalledWith([]) + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'Proceed? — /attach /secret', + continuationToken: 'token-1', + }) + }) + + it('submits arbitrary composer text unchanged after stripped options', async () => { + const options = '<options>{"1":{"title":"Inspect logs","description":"Find errors"}}</options>' + mocks.requestRaw + .mockResolvedValueOnce(completed(options, 'token-1')) + .mockResolvedValueOnce(completed('Done', 'token-2')) + const terminal = new FakeTerminal([ + { kind: 'line', value: 'Ask a completely different question' }, + { kind: 'line', value: '/exit' }, + ]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(terminal.reads[0].prompt).toBe('❯ ') + expect(mocks.requestRaw.mock.calls[1][1].body.prompt).toBe( + 'Ask a completely different question' + ) + }) + + it('requires an explicit Enter on a preloaded /attach command for pasted paths', async () => { + const attachment: ChatAttachment = { + name: 'report.txt', + mediaType: 'text/plain', + data: 'cmVwb3J0', + } + const absolutePath = '/private/tmp/report.txt' + const terminal = new FakeTerminal([ + { kind: 'line', value: absolutePath }, + { kind: 'line', value: `/attach "${absolutePath}"` }, + { kind: 'line', value: 'Inspect this file' }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw.mockResolvedValue(completed('Done')) + const pastedAttachmentPaths = vi.fn(async (value: string) => + value === absolutePath ? [absolutePath] : null + ) + const loadAttachments = vi.fn(async (paths: string[]) => (paths.length ? [attachment] : [])) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + pastedAttachmentPaths, + loadAttachments, + }).parseAsync(['node', 'sim', 'chat']) + + expect(loadAttachments).toHaveBeenCalledWith([absolutePath]) + expect(terminal.reads[1].initialValue).toBe(`/attach "${absolutePath}"`) + expect(terminal.statuses).toContain( + 'File path detected. Press Enter to attach it, or edit the command.' + ) + expect(terminal.statuses.some((status) => status.startsWith('Unknown command:'))).toBe(false) + expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'Inspect this file', + attachments: [attachment], + }) + }) + + it('does not read or upload a detected path when confirmation is cancelled', async () => { + const absolutePath = '/private/tmp/private.txt' + const terminal = new FakeTerminal([ + { kind: 'line', value: absolutePath }, + { kind: 'line', value: '/exit' }, + ]) + const pastedAttachmentPaths = vi.fn(async (value: string) => + value === absolutePath ? [absolutePath] : null + ) + const loadAttachments = vi.fn(async () => []) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + pastedAttachmentPaths, + loadAttachments, + }).parseAsync(['node', 'sim', 'chat']) + + expect(terminal.reads[1].initialValue).toBe(`/attach "${absolutePath}"`) + expect(loadAttachments).toHaveBeenCalledTimes(1) + expect(loadAttachments).toHaveBeenCalledWith([]) + expect(mocks.requestRaw).not.toHaveBeenCalled() + }) + + it('preserves draft text when Ctrl+V attaches a clipboard image', async () => { + const attachment: ChatAttachment = { + name: 'clipboard.png', + mediaType: 'image/png', + data: 'iVBORw0KGgo=', + } + const terminal = new FakeTerminal([ + { kind: 'clipboard', value: 'explain this' }, + { kind: 'line', value: 'explain this' }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw.mockResolvedValue(completed('Done')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + clipboardImage: async () => attachment, + pastedAttachmentPaths: async () => null, + }).parseAsync(['node', 'sim', 'chat']) + + expect(terminal.reads[1].initialValue).toBe('') + expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'explain this', + attachments: [attachment], + }) + }) + + it('aborts an active HTTP turn on Ctrl+C and returns to the prompt', async () => { + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + let requestSignal: AbortSignal | undefined + mocks.requestRaw.mockImplementation( + (_path: string, options: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + requestSignal = options.signal + options.signal.addEventListener('abort', () => reject(new Error('aborted')), { + once: true, + }) + queueMicrotask(() => terminal.interrupt()) + }) + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'long request']) + + expect(requestSignal?.aborted).toBe(true) + expect(terminal.statuses).toContain('Generation cancelled.') + expect(terminal.reads.at(-1)?.prompt).toBe('❯ ') + }) + + it('steers an active turn with the early continuation token and no attachment replay', async () => { + const attachment: ChatAttachment = { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'bm90ZXM=', + } + const terminal = new FakeTerminal([ + { + kind: 'line', + value: 'change direction', + queued: true, + display: 'change direction', + }, + { kind: 'line', value: '/exit' }, + ]) + const order: string[] = [] + const interrupt = vi.spyOn(terminal, 'interrupt') + let firstRequestSignal: AbortSignal | undefined + + mocks.requestRaw + .mockImplementationOnce((_path: string, options: { signal: AbortSignal }) => { + firstRequestSignal = options.signal + return Promise.resolve( + new Response( + new ReadableStream<Uint8Array>({ + start(controller) { + order.push('session') + controller.enqueue( + new TextEncoder().encode( + 'event: session\ndata: {"type":"session","continuationToken":"token-before-complete"}\n\n' + ) + ) + options.signal.addEventListener( + 'abort', + () => { + order.push('abort') + controller.error(new Error('aborted')) + }, + { once: true } + ) + setImmediate(() => { + order.push('submit') + terminal.interrupt('submit') + }) + }, + }) + ) + ) + }) + .mockImplementationOnce(async () => { + order.push('follow-up') + return completed('Redirected', 'token-2') + }) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + loadAttachments: async () => [attachment], + }).parseAsync(['node', 'sim', 'chat', '--file', '/local/notes.txt', 'inspect']) + + expect(mocks.requestRaw).toHaveBeenCalledTimes(2) + expect(interrupt).toHaveBeenCalledTimes(1) + expect(interrupt).toHaveBeenCalledWith('submit') + expect(firstRequestSignal?.aborted).toBe(true) + expect(order).toEqual(['session', 'submit', 'abort', 'follow-up']) + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'change direction', + continuationToken: 'token-before-complete', + }) + expect(terminal.statuses).not.toContain('Generation cancelled.') + expect(terminal.preloads).toEqual([]) + }) + + it('leaves the active turn running for a queued path recognized by normal chat input', async () => { + const pathInput = { + kind: 'line' as const, + value: 'report.txt', + queued: true, + display: 'report.txt', + } + const terminal = new FakeTerminal([pathInput, { kind: 'line', value: '/exit' }]) + let requestSignal: AbortSignal | undefined + const pastedAttachmentPaths = vi.fn(async (value: string) => + value === 'report.txt' ? ['report.txt'] : null + ) + mocks.requestRaw.mockImplementationOnce( + async (_path: string, options: { signal: AbortSignal }) => { + requestSignal = options.signal + terminal.interrupt('submit', pathInput) + await new Promise((resolve) => setImmediate(resolve)) + return completed('Finished normally', 'token-1') + } + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + pastedAttachmentPaths, + }).parseAsync(['node', 'sim', 'chat', 'original']) + + expect(requestSignal?.aborted).toBe(false) + expect(terminal.preloads).toContainEqual({ + value: '/attach "report.txt"', + queued: false, + }) + expect(mocks.requestRaw).toHaveBeenCalledTimes(1) + }) + + it('queues /chats without interrupting the active stream', async () => { + const chatsInput = { + kind: 'line' as const, + value: '/chats', + queued: true, + display: '/chats', + } + const terminal = new FakeTerminal( + [chatsInput, { kind: 'line', value: '/exit' }], + [], + [{ kind: 'cancel' }] + ) + let requestSignal: AbortSignal | undefined + mocks.requestRaw.mockImplementationOnce( + async (_path: string, options: { signal: AbortSignal }) => { + requestSignal = options.signal + terminal.interrupt('submit', chatsInput) + await new Promise((resolve) => setImmediate(resolve)) + return completed('Finished normally', 'token-1') + } + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'original']) + + expect(requestSignal?.aborted).toBe(false) + expect(terminal.selections).toHaveLength(1) + expect(mocks.requestRaw).toHaveBeenCalledTimes(1) + const listRequest = mocks.request.mock.calls.find(([path]) => path === '/api/v2/chats') + expect(listRequest?.[1]).toMatchObject({ + query: { workspaceId: 'ws_local', limit: 100, cursor: null }, + }) + expect(listRequest?.[1]?.query).not.toHaveProperty('search') + }) + + it('waits for the first session token before interrupting a fast queued steer', async () => { + const attachment: ChatAttachment = { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'bm90ZXM=', + } + const terminal = new FakeTerminal([ + { + kind: 'line', + value: 'change direction', + queued: true, + display: 'change direction', + }, + { kind: 'line', value: '/exit' }, + ]) + let abortedBeforeSession = false + + mocks.requestRaw + .mockImplementationOnce( + (_path: string, options: { signal: AbortSignal }) => + new Promise((resolve) => { + queueMicrotask(() => { + terminal.interrupt('submit') + abortedBeforeSession = options.signal.aborted + resolve( + new Response( + new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"type":"session","continuationToken":"first-token"}\n\n' + ) + ) + options.signal.addEventListener( + 'abort', + () => controller.error(new Error('aborted')), + { once: true } + ) + }, + }) + ) + ) + }) + }) + ) + .mockResolvedValueOnce(completed('Redirected', 'token-2')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + loadAttachments: async () => [attachment], + }).parseAsync(['node', 'sim', 'chat', '--file', '/local/notes.txt', 'inspect']) + + expect(abortedBeforeSession).toBe(false) + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'change direction', + continuationToken: 'first-token', + }) + expect(terminal.statuses).not.toContain('Generation cancelled.') + }) + + it('keeps the original attachments when setup fails before a session is accepted', async () => { + const attachment: ChatAttachment = { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'bm90ZXM=', + } + const followUp = { + kind: 'line' as const, + value: 'retry with context', + queued: true, + display: 'retry with context', + } + const terminal = new FakeTerminal([followUp, { kind: 'line', value: '/exit' }]) + mocks.requestRaw + .mockImplementationOnce(() => + Promise.resolve( + new Response( + new ReadableStream<Uint8Array>({ + start(controller) { + terminal.interrupt('submit', followUp) + controller.enqueue( + new TextEncoder().encode( + 'data: {"type":"error","error":{"code":"INTERNAL_ERROR","message":"Chat request failed"}}\n\n' + ) + ) + controller.close() + }, + }) + ) + ) + ) + .mockResolvedValueOnce(completed('Retried', 'token-2')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + loadAttachments: async () => [attachment], + }).parseAsync(['node', 'sim', 'chat', '--file', '/local/notes.txt', 'inspect']) + + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'retry with context', + attachments: [attachment], + }) + expect(terminal.statuses).toContain('Error: Chat request failed (INTERNAL_ERROR)') + }) + + it('does not replay attachments after an accepted turn fails', async () => { + const attachment: ChatAttachment = { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'bm90ZXM=', + } + const terminal = new FakeTerminal([ + { kind: 'line', value: 'continue without replaying it' }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockResolvedValueOnce( + sse([ + 'data: {"type":"session","continuationToken":"token-1"}\n\n', + 'data: {"type":"error","error":{"code":"INTERNAL_ERROR","message":"Chat request failed"}}\n\n', + ]) + ) + .mockResolvedValueOnce(completed('Continued', 'token-2')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + loadAttachments: async () => [attachment], + }).parseAsync(['node', 'sim', 'chat', '--file', '/local/notes.txt', 'inspect']) + + expect(mocks.requestRaw.mock.calls[0][1].body.attachments).toEqual([attachment]) + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'continue without replaying it', + continuationToken: 'token-1', + }) + expect(terminal.preloads).toEqual([]) + }) + + it('drains already-submitted turns before presenting an earlier turn question', async () => { + const question = + '<question>{"type":"single_select","prompt":"Pause for this?","options":[{"id":"yes","label":"Yes"}]}</question>' + const firstQueued = { + kind: 'line' as const, + value: 'first queued', + queued: true, + display: 'first queued', + } + const terminal = new FakeTerminal([ + firstQueued, + { kind: 'line', value: 'second queued', queued: true, display: 'second queued' }, + { kind: 'line', value: '/exit' }, + ]) + + mocks.requestRaw + .mockImplementationOnce((_path: string, options: { signal: AbortSignal }) => + Promise.resolve( + new Response( + new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"type":"session","continuationToken":"token-1"}\n\n' + ) + ) + options.signal.addEventListener( + 'abort', + () => controller.error(new Error('aborted')), + { once: true } + ) + setImmediate(() => terminal.interrupt('submit', firstQueued)) + }, + }) + ) + ) + ) + .mockResolvedValueOnce(completed(question, 'token-2')) + .mockResolvedValueOnce(completed('Done', 'token-3')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'original']) + + expect(mocks.requestRaw.mock.calls.map(([, options]) => options.body.prompt)).toEqual([ + 'original', + 'first queued', + 'second queued', + ]) + expect(terminal.questions).toEqual([]) + }) + + it('does not move queued prompts into another conversation', async () => { + const terminal = new FakeTerminal([ + { kind: 'line', value: 'first' }, + { kind: 'line', value: '/clear', queued: true, display: '/clear' }, + { kind: 'line', value: 'second', queued: true, display: 'second' }, + { kind: 'line', value: '/chats', queued: true, display: '/chats' }, + { kind: 'line', value: 'third', queued: true, display: 'third' }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockResolvedValueOnce(completed('First', 'token-1')) + .mockResolvedValueOnce(completed('Second', 'token-2')) + .mockResolvedValueOnce(completed('Third', 'token-3')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(mocks.requestRaw.mock.calls.map(([, options]) => options.body)).toEqual([ + { workspaceId: 'ws_local', prompt: 'first' }, + { workspaceId: 'ws_local', prompt: 'second', continuationToken: 'token-1' }, + { workspaceId: 'ws_local', prompt: 'third', continuationToken: 'token-2' }, + ]) + expect(terminal.statuses).toEqual([ + 'Finish queued prompts before changing conversations.', + 'Finish queued prompts before changing conversations.', + ]) + expect(mocks.request.mock.calls.some(([path]) => path === '/api/v2/chats')).toBe(false) + }) + + it('restores a queued head ahead of later input when the handoff lease is still busy', async () => { + const terminal = new FakeTerminal([ + { kind: 'line', value: 'retry me', queued: true, display: 'retry me' }, + { kind: 'line', value: 'retry me' }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockRejectedValueOnce( + new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') + ) + .mockResolvedValueOnce(completed('Retried', 'token-2')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(terminal.preloads).toContainEqual({ value: 'retry me', queued: true }) + expect(terminal.statuses).toContain( + 'Previous response is still settling. Press Enter to retry.' + ) + expect(mocks.requestRaw).toHaveBeenCalledTimes(2) + expect(mocks.requestRaw.mock.calls[1][1].body.prompt).toBe('retry me') + }) + + it('restores a normally submitted prompt after a pre-session conflict', async () => { + const terminal = new FakeTerminal([ + { kind: 'line', value: 'retry me' }, + { kind: 'line', value: 'retry me' }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockRejectedValueOnce( + new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') + ) + .mockResolvedValueOnce(completed('Retried', 'token-2')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(terminal.preloads).toContainEqual({ value: 'retry me', queued: true }) + expect(terminal.statuses).toContain( + 'Previous response is still settling. Press Enter to retry.' + ) + expect(mocks.requestRaw).toHaveBeenCalledTimes(2) + }) + + it('automatically retries one queued continuation conflict', async () => { + const contexts: ChatContext[] = [ + { kind: 'workflow', workflowId: 'workflow-1', label: 'Release' }, + ] + const terminal = new FakeTerminal([ + { + kind: 'line', + value: 'retry @Release', + queued: true, + display: 'retry @Release', + contexts, + }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockResolvedValueOnce(completed('Original', 'token-1')) + .mockRejectedValueOnce( + new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') + ) + .mockResolvedValueOnce(completed('Retried', 'token-2')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'original']) + + expect(mocks.requestRaw.mock.calls.map(([, options]) => options.body.prompt)).toEqual([ + 'original', + 'retry @Release', + 'retry @Release', + ]) + expect(mocks.requestRaw.mock.calls[2][1].body).toMatchObject({ + continuationToken: 'token-1', + contexts, + }) + expect(terminal.preloads).toEqual([]) + expect(terminal.statuses).toContain('Previous response is still settling. Retrying…') + expect(terminal.statuses).not.toContain( + 'Previous response is still settling. Press Enter to retry.' + ) + }) + + it('bounds queued continuation conflict retries and restores the exact tagged input', async () => { + const contexts: ChatContext[] = [{ kind: 'skill', skillId: 'skill-1', label: 'review' }] + const terminal = new FakeTerminal([ + { + kind: 'line', + value: '/review this', + queued: true, + display: '/review this', + contexts, + }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockResolvedValueOnce(completed('Original', 'token-1')) + .mockRejectedValueOnce( + new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') + ) + .mockRejectedValueOnce( + new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'original']) + + expect(mocks.requestRaw).toHaveBeenCalledTimes(3) + expect(terminal.preloads).toContainEqual({ + value: '/review this', + queued: true, + contexts, + }) + expect(terminal.statuses).toContain( + 'Previous response is still settling. Press Enter to retry.' + ) + }) + + it('carries queued large-paste bodies into a conflict retry', async () => { + const pasted = 'p'.repeat(900) + const pastes = new Map([[1, pasted]]) + const terminal = new FakeTerminal([ + { + kind: 'line', + value: pasted, + queued: true, + display: '[Pasted text #1]', + pastes, + }, + { kind: 'line', value: pasted, queued: true, display: '[Pasted text #1]', pastes }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockRejectedValueOnce( + new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') + ) + .mockResolvedValueOnce(completed('Retried', 'token-2')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(terminal.preloads[0]).toMatchObject({ + value: '[Pasted text #1]', + queued: true, + pastes, + }) + expect(mocks.requestRaw.mock.calls[1][1].body.prompt).toBe(pasted) + }) + + it('restores pending attachments after Ctrl+C so a retry can send them', async () => { + const attachment: ChatAttachment = { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'bm90ZXM=', + } + const terminal = new FakeTerminal([ + { kind: 'line', value: 'retry' }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockImplementationOnce( + (_path: string, options: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(new Error('aborted')), { + once: true, + }) + queueMicrotask(() => terminal.interrupt()) + }) + ) + .mockResolvedValueOnce(completed('Retried')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + loadAttachments: async () => [attachment], + }).parseAsync(['node', 'sim', 'chat', '--file', '/local/notes.txt', 'inspect']) + + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'retry', + attachments: [attachment], + }) + }) + + it('reports a failed turn and restores its attachments for the next prompt', async () => { + const attachment: ChatAttachment = { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'bm90ZXM=', + } + const terminal = new FakeTerminal([ + { kind: 'line', value: 'retry' }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockRejectedValueOnce(new SimApiError('Temporarily\nunavailable', 503, 'UNAVAILABLE')) + .mockResolvedValueOnce(completed('Retried')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + loadAttachments: async () => [attachment], + }).parseAsync(['node', 'sim', 'chat', '--file', '/local/notes.txt', 'inspect']) + + expect(terminal.statuses).toContain('Error: Temporarily unavailable (UNAVAILABLE)') + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'retry', + attachments: [attachment], + }) + }) + + it('parses an authoritative completion suffix omitted from text deltas', async () => { + const options = '<options>{"1":{"title":"Continue","description":"Go"}}</options>' + mocks.requestRaw + .mockResolvedValueOnce(completed(`Hello${options}`, 'token-1', ['Hello'])) + .mockResolvedValueOnce(completed('Done', 'token-2')) + const terminal = new FakeTerminal([ + { kind: 'line', value: 'Continue' }, + { kind: 'line', value: '/exit' }, + ]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'Continue', + continuationToken: 'token-1', + }) + }) + + it('sanitizes streamed plain deltas before stdout', async () => { + const terminalEscape = String.fromCharCode(27) + mocks.requestRaw.mockResolvedValue( + completed(`Safe${terminalEscape}]0;owned\u0007 answer`, 'token', [ + `Safe${terminalEscape}]0;`, + 'owned\u0007 answer', + ]) + ) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'question']) + + expect(terminal.writes.join('')).toBe('Safeowned answer\n') + expect(terminal.writes.join('')).not.toContain(terminalEscape) + }) + + it('forwards sanitized thinking and ordered activity transitions to the terminal', async () => { + const terminalEscape = String.fromCharCode(27) + mocks.requestRaw.mockResolvedValue( + sse([ + `event: thinking\ndata: ${JSON.stringify({ + type: 'thinking', + delta: `Inspect${terminalEscape}]0;owned\u0007 workspace`, + })}\n\n`, + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { + kind: 'subagent', + id: 'agent-1', + label: 'Research\nagent', + state: 'running', + }, + })}\n\n`, + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { + kind: 'subagent', + id: 'agent-1', + label: 'Research agent', + state: 'complete', + }, + })}\n\n`, + 'event: text\ndata: {"type":"text","delta":"Done"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"token"}}\n\n', + ]) + ) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'question']) + + expect(terminal.thinking).toEqual(['Inspect workspace']) + expect(terminal.activities).toEqual([ + { + kind: 'subagent', + id: 'agent-1', + label: 'Research agent', + state: 'running', + }, + { + kind: 'subagent', + id: 'agent-1', + label: 'Research agent', + state: 'complete', + }, + ]) + }) + + it('forwards nested subagent narration and tool seams without mixing them into the answer', async () => { + const terminalEscape = String.fromCharCode(27) + mocks.requestRaw.mockResolvedValue( + sse([ + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { + kind: 'subagent', + id: 'agent-1', + label: 'Build Agent', + state: 'running', + }, + })}\n\n`, + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { + kind: 'narration', + parentId: 'agent-1', + delta: `Inspect${terminalEscape}]0;owned\u0007ing `, + }, + })}\n\n`, + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { kind: 'narration', parentId: 'agent-1', delta: 'workspace' }, + })}\n\n`, + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { + kind: 'tool', + id: 'tool-1', + parentId: 'agent-1', + label: 'Read file', + state: 'complete', + }, + })}\n\n`, + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { kind: 'narration', parentId: 'agent-1', delta: 'After tool' }, + })}\n\n`, + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { + kind: 'subagent', + id: 'agent-1', + label: 'Build Agent', + state: 'complete', + }, + })}\n\n`, + 'event: text\ndata: {"type":"text","delta":"Final answer"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Final answer","continuationToken":"token"}}\n\n', + ]) + ) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + formatMarkdown: () => false, + }).parseAsync(['node', 'sim', 'chat', 'question']) + + expect(terminal.activities).toEqual([ + { + kind: 'subagent', + id: 'agent-1', + label: 'Build Agent', + state: 'running', + }, + { kind: 'narration', parentId: 'agent-1', delta: 'Inspecting ' }, + { kind: 'narration', parentId: 'agent-1', delta: 'workspace' }, + { + kind: 'tool', + id: 'tool-1', + parentId: 'agent-1', + label: 'Read file', + state: 'complete', + }, + { kind: 'narration', parentId: 'agent-1', delta: 'After tool' }, + { + kind: 'subagent', + id: 'agent-1', + label: 'Build Agent', + state: 'complete', + }, + ]) + expect(terminal.writes.join('')).toBe('Final answer\n') + expect(terminal.writes.join('')).not.toContain('Inspecting') + }) +}) + +describe('chat SSE reader', () => { + it('delivers thinking, activity, and text callbacks in wire order', async () => { + const callbacks: string[] = [] + const response = sse([ + 'event: thinking\ndata: {"type":"thinking","delta":"Planning"}\n\n', + 'event: activity\ndata: {"type":"activity","data":{"kind":"tool","id":"tool-1","label":"Read\\nworkflow","state":"running"}}\n\n', + 'event: activity\ndata: {"type":"activity","data":{"kind":"tool","id":"tool-1","label":"Read workflow","state":"complete"}}\n\n', + 'event: text\ndata: {"type":"text","delta":"Answer"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Answer","continuationToken":"token"}}\n\n', + ]) + + const result = await readChatTurn(response, { + onThinking: (delta) => { + callbacks.push(`thinking:${delta}`) + }, + onActivity: (activity) => { + callbacks.push( + activity.kind === 'narration' + ? `${activity.kind}:${activity.parentId}:${activity.delta}` + : `${activity.kind}:${activity.label}:${activity.state}` + ) + }, + onDelta: (delta) => { + callbacks.push(`text:${delta}`) + }, + }) + + expect(callbacks).toEqual([ + 'thinking:Planning', + 'tool:Read workflow:running', + 'tool:Read workflow:complete', + 'text:Answer', + ]) + expect(result.content).toBe('Answer') + }) + + it('parses parented narration and nested tools without adding scoped text to content', async () => { + const terminalEscape = String.fromCharCode(27) + const activities: ChatActivityUpdate[] = [] + const response = sse([ + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { + kind: 'subagent', + id: 'agent-1', + label: 'Build\nAgent', + state: 'running', + }, + })}\n\n`, + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { + kind: 'narration', + parentId: 'agent-1', + delta: `Line one\n\nLine${terminalEscape}]0;owned\u0007 two`, + }, + })}\n\n`, + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { + kind: 'tool', + id: 'tool-1', + parentId: 'agent-1', + label: 'Read file', + state: 'complete', + }, + })}\n\n`, + 'event: text\ndata: {"type":"text","delta":"Answer"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Answer","continuationToken":"token"}}\n\n', + ]) + + const result = await readChatTurn(response, { + onActivity: (activity) => { + activities.push(activity) + }, + }) + + expect(activities).toEqual([ + { + kind: 'subagent', + id: 'agent-1', + label: 'Build Agent', + state: 'running', + }, + { + kind: 'narration', + parentId: 'agent-1', + delta: 'Line one\n\nLine two', + }, + { + kind: 'tool', + id: 'tool-1', + parentId: 'agent-1', + label: 'Read file', + state: 'complete', + }, + ]) + expect(result).toEqual({ + content: 'Answer', + streamedContent: 'Answer', + continuationToken: 'token', + }) + }) + + it('falls back to text deltas and uses the completion continuation token', async () => { + const response = sse([ + 'event: session\ndata: {"type":"session","continuationToken":"session-token"}\n\n', + 'event: text\r\ndata: {"type":"text","delta":"one"}\r\n\r\n', + 'event: text\ndata: {"type":"text","delta":" two"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"continuationToken":"complete-token"}}\n\n', + 'data: [DONE]\n\n', + ]) + + await expect(readChatTurn(response)).resolves.toEqual({ + content: 'one two', + streamedContent: 'one two', + continuationToken: 'complete-token', + }) + }) + + it('exposes the session continuation token before completion', async () => { + const tokens: string[] = [] + const response = sse([ + 'event: session\ndata: {"type":"session","continuationToken":"session-token"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"complete-token"}}\n\n', + ]) + + await readChatTurn(response, { + onContinuationToken: (token) => { + tokens.push(token) + }, + }) + + expect(tokens).toEqual(['session-token']) + }) + + it('exposes the shared chat id from the session event', async () => { + const chatIds: string[] = [] + const response = sse([ + 'event: session\ndata: {"type":"session","chatId":"chat-1","continuationToken":"session-token"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"complete-token"}}\n\n', + ]) + + await readChatTurn(response, { + onChatId: (chatId) => { + chatIds.push(chatId) + }, + }) + + expect(chatIds).toEqual(['chat-1']) + }) + + it('exposes a sanitized generated title from session events', async () => { + const titles: string[] = [] + const response = sse([ + 'event: session\ndata: {"type":"session","title":"Release\\u001b]0;owned\\u0007 investigation"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"complete-token"}}\n\n', + ]) + + await readChatTurn(response, { + onTitle: (title) => { + titles.push(title) + }, + }) + + expect(titles).toEqual(['Release investigation']) + }) + + it('turns a streamed error into a sanitized structured CLI error', async () => { + const terminalEscape = String.fromCharCode(27) + const response = sse([ + `event: error\ndata: ${JSON.stringify({ + type: 'error', + error: { + code: `CHAT${terminalEscape}[2A_FAILED`, + message: `Model${terminalEscape}]0;x\u0007 unavailable`, + }, + })}\n\n`, + ]) + + const result = readChatResponse(response) + await expect(result).rejects.toBeInstanceOf(SimApiError) + await expect(result).rejects.toMatchObject({ + message: 'Model unavailable', + code: 'CHAT_FAILED', + }) + }) + + it('rejects malformed and incomplete streams', async () => { + await expect(readChatResponse(sse(['data: not-json\n\n']))).rejects.toThrow( + /malformed streaming data/ + ) + await expect( + readChatResponse(sse(['data: {"type":"text","delta":"partial"}\n\ndata: [DONE]\n\n'])) + ).rejects.toThrow(/ended before completing/) + }) + + it.each([ + [ + 'an error event', + 'event: error\ndata: {"type":"error","error":{"code":"FAILED","message":"No answer"}}\n\n', + ], + ['malformed data', 'data: not-json\n\n'], + ])('cancels the response body after %s', async (_name, wire) => { + const { response, cancel } = openSse(wire) + + await expect(readChatResponse(response)).rejects.toBeInstanceOf(SimApiError) + expect(cancel).toHaveBeenCalledOnce() + }) +}) + +describe('composeChatPrompt', () => { + it('does not add a separator when only one source is present', () => { + expect(composeChatPrompt(['hello'], '')).toBe('hello') + expect(composeChatPrompt([], 'hello\n')).toBe('hello\n') + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat.ts b/packages/sim-cli/src/commands/protocol/chat.ts new file mode 100644 index 00000000000..81c1ccfb275 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat.ts @@ -0,0 +1,1564 @@ +import { Command } from 'commander' +import { clientFrom } from '../../context.js' +import type { + ChatBody, + GetChatResponse, + GetWorkspaceResponse, + ListChatsResponse, + ListFilesResponse, + ListKnowledgeBasesResponse, + ListLogsResponse, + ListMcpServersResponse, + ListSkillsResponse, + ListTablesResponse, + ListWorkflowsResponse, + RenameChatBody, + RenameChatResponse, +} from '../../generated/v2-api.js' +import { V2_OPERATIONS } from '../../generated/v2-api.js' +import { requestAllPages, resolvePath, SimApiError, type SimClient } from '../../http/client.js' +import { safeOneLine, sanitize } from '../../output/render.js' +import { + type ChatAttachment, + combineChatAttachments, + existingAttachmentPaths, + loadChatAttachments, + parseAttachmentPaths, + readClipboardImage, +} from './chat-attachments.js' +import { ChatMarkdownStream } from './chat-markdown.js' +import { + type ChatQuestion, + ChatStructuredParser, + type ChatStructuredSegment, + parseChatStructured, + type RenderPart, + renderChatStructured, +} from './chat-structured.js' +import type { ChatContext, ChatSuggestionCandidates, SuggestionItem } from './chat-suggestions.js' +import { + type ChatActivityUpdate, + type ChatTerminal, + type ChatTerminalInput, + type ChatTerminalSelectResult, + ReadlineChatTerminal, +} from './chat-terminal.js' + +export interface ChatDependencies { + readInput: (maxBytes: number) => Promise<string> + writeOutput: (content: string) => void + isInteractive: () => boolean + createTerminal: () => ChatTerminal + loadAttachments: (paths: string[]) => Promise<ChatAttachment[]> + clipboardImage: () => Promise<ChatAttachment | null> + pastedAttachmentPaths: (input: string) => Promise<string[] | null> + formatMarkdown: () => boolean +} + +interface ChatEvent { + type?: unknown + delta?: unknown + data?: unknown + error?: unknown + continuationToken?: unknown + chatId?: unknown + title?: unknown +} + +type ChatSummary = ListChatsResponse['data'][number] +type ChatHistoryMessage = GetChatResponse['data']['messages'][number] + +export interface ChatTurn { + content: string + streamedContent: string + continuationToken: string | null +} + +export interface ReadChatTurnOptions { + onDelta?: (delta: string) => void | Promise<void> + onThinking?: (delta: string) => void | Promise<void> + onActivity?: (activity: ChatActivityUpdate) => void | Promise<void> + /** The opaque token arrives after turn acceptance and before assistant output. */ + onContinuationToken?: (token: string) => void | Promise<void> + /** The shared chat identity arrives with the session event when available. */ + onChatId?: (chatId: string) => void | Promise<void> + /** The persisted chat title may arrive with either session acceptance or title generation. */ + onTitle?: (title: string) => void | Promise<void> +} + +type ChatRequest = ChatBody + +const MAX_CHAT_PROMPT_BYTES = 10 * 1024 * 1024 +const MAX_LOG_SUGGESTIONS = 50 + +function inputTooLarge(): SimApiError { + return new SimApiError('Chat input exceeds the 10 MiB limit.', 0) +} + +function utf8Bytes(value: string): number { + return Buffer.byteLength(value, 'utf8') +} + +/** Reads stdin only when the command is part of a pipe or redirection. */ +async function readPipedInput(maxBytes: number): Promise<string> { + if (process.stdin.isTTY) return '' + + process.stdin.setEncoding('utf8') + let input = '' + let inputBytes = 0 + for await (const chunk of process.stdin) { + inputBytes += utf8Bytes(chunk) + if (inputBytes > maxBytes) throw inputTooLarge() + input += chunk + } + return input +} + +/** Writes one completed answer, preserving its contents and adding a shell-friendly newline. */ +function writeCompletedAnswer(content: string): void { + if (!content) return + process.stdout.write(content) + if (!content.endsWith('\n')) process.stdout.write('\n') +} + +/** + * Matches Claude Code's print-mode input ordering: command-line prompt first, + * then piped context separated by one newline. + */ +export function composeChatPrompt(promptParts: string[], pipedInput: string): string { + return [promptParts.join(' '), pipedInput].filter(Boolean).join('\n') +} + +async function* linesOf(body: ReadableStream<Uint8Array>): AsyncGenerator<string> { + const reader = body.getReader() + const decoder = new TextDecoder() + let buffered = '' + let reachedEnd = false + + try { + while (true) { + const { done, value } = await reader.read() + if (done) { + reachedEnd = true + break + } + buffered += decoder.decode(value, { stream: true }) + + let newline = buffered.indexOf('\n') + while (newline !== -1) { + const raw = buffered.slice(0, newline) + buffered = buffered.slice(newline + 1) + yield raw.endsWith('\r') ? raw.slice(0, -1) : raw + newline = buffered.indexOf('\n') + } + } + + buffered += decoder.decode() + if (buffered) yield buffered.endsWith('\r') ? buffered.slice(0, -1) : buffered + } finally { + if (!reachedEnd) await reader.cancel().catch(() => {}) + reader.releaseLock() + } +} + +function dataFromEvent(lines: string[]): string | null { + const data: string[] = [] + for (const line of lines) { + if (!line || line.startsWith(':')) continue + const separator = line.indexOf(':') + const field = separator === -1 ? line : line.slice(0, separator) + if (field !== 'data') continue + + const raw = separator === -1 ? '' : line.slice(separator + 1) + data.push(raw.startsWith(' ') ? raw.slice(1) : raw) + } + return data.length > 0 ? data.join('\n') : null +} + +function streamError(event: ChatEvent): SimApiError { + const detail = event.error + if (!detail || typeof detail !== 'object') { + return new SimApiError('Sim Chat failed.', 0) + } + + const error = detail as { code?: unknown; message?: unknown } + return new SimApiError( + typeof error.message === 'string' ? sanitize(error.message) : 'Sim Chat failed.', + 0, + typeof error.code === 'string' ? sanitize(error.code) : null + ) +} + +function tokenFrom(value: unknown): string | null { + if (!value || typeof value !== 'object') return null + const token = (value as { continuationToken?: unknown }).continuationToken + return typeof token === 'string' && token ? token : null +} + +function activityFrom(value: unknown): ChatActivityUpdate | null { + if (!value || typeof value !== 'object') return null + const data = value as Record<string, unknown> + if (data.kind === 'narration') { + if (typeof data.parentId !== 'string' || typeof data.delta !== 'string') return null + const parentId = safeOneLine(data.parentId).slice(0, 160) + const delta = sanitize(data.delta) + return parentId && delta ? { kind: 'narration', parentId, delta } : null + } + if (data.kind !== 'tool' && data.kind !== 'subagent') return null + if (data.state !== 'running' && data.state !== 'complete' && data.state !== 'error') return null + if (typeof data.id !== 'string' || typeof data.label !== 'string') return null + + const id = safeOneLine(data.id).slice(0, 160) + const label = safeOneLine(data.label).slice(0, 160) + const parentId = typeof data.parentId === 'string' ? safeOneLine(data.parentId).slice(0, 160) : '' + return id && label + ? { + kind: data.kind, + id, + label, + state: data.state, + ...(parentId && parentId !== id ? { parentId } : {}), + } + : null +} + +/** Reads one public chat turn, optionally forwarding raw text deltas to a safe renderer. */ +export async function readChatTurn( + response: Response, + options: ReadChatTurnOptions = {} +): Promise<ChatTurn> { + if (!response.body) throw new SimApiError('Sim Chat returned an empty response.', 0) + + let deltas = '' + let completedContent: string | null = null + let continuationToken: string | null = null + let sawComplete = false + let eventLines: string[] = [] + + const consume = async (): Promise<void> => { + const raw = dataFromEvent(eventLines) + eventLines = [] + if (raw === null || raw === '[DONE]') return + + let parsed: ChatEvent + try { + parsed = JSON.parse(raw) as ChatEvent + } catch { + throw new SimApiError('Sim Chat returned malformed streaming data.', 0) + } + + if (parsed.type === 'session') { + if (typeof parsed.chatId === 'string' && parsed.chatId) { + await options.onChatId?.(parsed.chatId) + } + if (typeof parsed.title === 'string') { + const title = safeOneLine(parsed.title).slice(0, 160) + if (title) await options.onTitle?.(title) + } + const token = tokenFrom(parsed) + if (token) { + continuationToken = token + await options.onContinuationToken?.(token) + } + return + } + if (parsed.type === 'text' && typeof parsed.delta === 'string') { + deltas += parsed.delta + await options.onDelta?.(parsed.delta) + return + } + if (parsed.type === 'thinking' && typeof parsed.delta === 'string') { + await options.onThinking?.(sanitize(parsed.delta)) + return + } + if (parsed.type === 'activity') { + const activity = activityFrom(parsed.data) + if (activity) await options.onActivity?.(activity) + return + } + if (parsed.type === 'error') throw streamError(parsed) + if (parsed.type !== 'complete') return + + sawComplete = true + if (parsed.data && typeof parsed.data === 'object') { + const content = (parsed.data as { content?: unknown }).content + if (typeof content === 'string') completedContent = content + continuationToken = tokenFrom(parsed.data) ?? continuationToken + } + } + + try { + for await (const line of linesOf(response.body)) { + if (line === '') await consume() + else eventLines.push(line) + } + if (eventLines.length > 0) await consume() + } catch (error) { + if (error instanceof SimApiError) throw error + const message = error instanceof Error ? error.message : String(error) + throw new SimApiError(`Sim Chat stream failed: ${sanitize(message)}`, 0) + } + + if (!sawComplete) throw new SimApiError('Sim Chat ended before completing.', 0) + return { + content: completedContent ?? deltas, + streamedContent: deltas, + continuationToken, + } +} + +/** Buffers the public chat SSE protocol and returns only the final assistant answer. */ +export async function readChatResponse(response: Response): Promise<string> { + return (await readChatTurn(response)).content +} + +function requestChat(client: SimClient, body: ChatRequest, signal: AbortSignal): Promise<Response> { + return client.requestRaw(V2_OPERATIONS.chat.path, { + method: 'POST', + headers: { accept: 'text/event-stream' }, + body, + signal, + auth: 'optional', + }) +} + +function renderContext(interactive: boolean) { + return { printMode: !interactive } +} + +async function runOneShot( + client: SimClient, + workspaceId: string, + prompt: string, + attachments: ChatAttachment[], + readOnly: boolean, + dependencies: ChatDependencies +): Promise<void> { + const controller = new AbortController() + const cancel = () => controller.abort() + process.once('SIGINT', cancel) + + try { + const response = await requestChat( + client, + { + workspaceId, + prompt, + ...(readOnly ? { readOnly: true } : {}), + ...(attachments.length ? { attachments } : {}), + }, + controller.signal + ) + const result = await readChatTurn(response) + const segments = withoutTrailingStandaloneResource(parseChatStructured(result.content)) + const rendered = renderChatStructured(segments, renderContext(false)) + // Print mode deliberately has no ANSI/OSC of its own, so a final defense at + // the stdout boundary is safe and preserves shell composability. + dependencies.writeOutput(sanitize(rendered.text)) + } catch (error) { + if (controller.signal.aborted) throw new SimApiError('Sim Chat cancelled.', 0) + throw error + } finally { + process.removeListener('SIGINT', cancel) + } +} + +type UserTurnResult = + | { + kind: 'turn' + prompt: string + attachments: ChatAttachment[] + queued: boolean + display?: string + pastes?: ReadonlyMap<number, string> + contexts?: ChatContext[] + } + | { kind: 'clear'; attachments: ChatAttachment[] } + | { kind: 'chats'; attachments: ChatAttachment[] } + | { kind: 'rename'; title: string; attachments: ChatAttachment[] } + | { kind: 'idle'; attachments: ChatAttachment[] } + | { kind: 'exit' } + +function explainInteractiveCommands(terminal: ChatTerminal): void { + terminal.status( + [ + 'Commands:', + ' /attach <paths> attach local files to the next turn', + ' ctrl+v attach an image from the clipboard (or cmd+v on macOS)', + ' /clear start a new conversation', + ' /chats view and switch chats', + ' /rename <title> rename the active chat', + ' /help show this help', + ' /exit leave Sim Chat (alias: /quit)', + ].join('\n') + ) +} + +function attachmentStatus(attachments: ChatAttachment[]): string { + const names = attachments.map((attachment) => attachment.name).join(', ') + return `Attached for the next turn (${attachments.length}/${5}): ${names}` +} + +function attachmentCommand(paths: string[]): string | null { + if (paths.some((path) => /[\u0000-\u001f\u007f]/u.test(path))) return null + const quoted = paths.map((path) => `"${path.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`) + return `/attach ${quoted.join(' ')}` +} + +async function addPaths( + current: ChatAttachment[], + paths: string[], + terminal: ChatTerminal, + dependencies: ChatDependencies +): Promise<ChatAttachment[]> { + try { + const additions = await dependencies.loadAttachments(paths) + const combined = combineChatAttachments(current, additions) + terminal.status(attachmentStatus(combined)) + return combined + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + terminal.status(`Error: ${message}`) + return current + } +} + +async function addClipboardImage( + current: ChatAttachment[], + terminal: ChatTerminal, + dependencies: ChatDependencies +): Promise<ChatAttachment[]> { + const image = await dependencies.clipboardImage() + /* Paste feedback is the `[Image #N]` tag in the composer, not a transcript + line: the tag says what was attached and disappears when it is deleted. */ + if (!image) return current + try { + const combined = combineChatAttachments(current, [image]) + terminal.noteAttachment() + return combined + } catch { + return current + } +} + +async function readUserTurn( + terminal: ChatTerminal, + initialAttachments: ChatAttachment[], + dependencies: ChatDependencies, + queuedOnly = false +): Promise<UserTurnResult> { + let attachments = initialAttachments + let lastEmptyInterrupt = 0 + + while (true) { + if (queuedOnly && !terminal.hasQueuedInput()) return { kind: 'idle', attachments } + const input = await terminal.read('❯ ') + if (input.kind === 'eof') return { kind: 'exit' } + if (input.kind === 'interrupt') { + const now = Date.now() + if (input.empty && now - lastEmptyInterrupt < 1_200) return { kind: 'exit' } + lastEmptyInterrupt = input.empty ? now : 0 + continue + } + if (input.kind === 'clipboard') { + attachments = await addClipboardImage(attachments, terminal, dependencies) + continue + } + if (input.kind === 'selection') continue + + const trimmed = input.value.trim() + if (trimmed === '/exit' || trimmed === '/quit') return { kind: 'exit' } + if (trimmed === '/help') { + explainInteractiveCommands(terminal) + continue + } + if (trimmed === '/clear') return { kind: 'clear', attachments } + if (trimmed === '/chats') { + return { kind: 'chats', attachments } + } + if (trimmed.startsWith('/chats ')) { + terminal.status('Usage: /chats (search inside the chat list).') + continue + } + if (trimmed === '/rename' || trimmed.startsWith('/rename ')) { + const title = safeOneLine(trimmed.slice('/rename'.length).trim()) + if (!title) { + terminal.status('Usage: /rename <title>') + continue + } + if (title.length > 200) { + terminal.status('Error: Chat title cannot exceed 200 characters.') + continue + } + return { kind: 'rename', title, attachments } + } + if (trimmed === '/attach' || trimmed.startsWith('/attach ')) { + const rawPaths = trimmed.slice('/attach'.length).trim() + if (!rawPaths) { + terminal.status('Usage: /attach <path> [more paths]') + continue + } + try { + attachments = await addPaths( + attachments, + parseAttachmentPaths(rawPaths), + terminal, + dependencies + ) + } catch (error) { + terminal.status(`Error: ${error instanceof Error ? error.message : String(error)}`) + } + continue + } + if (trimmed) { + const pastedPaths = await dependencies.pastedAttachmentPaths(input.value) + if (pastedPaths) { + // A dragged path is still just user input. Preload an explicit command + // so the next Enter is the user's confirmation before any bytes are read. + const command = attachmentCommand(pastedPaths) + if (!command) { + terminal.status('The detected path cannot be safely preloaded. Use /attach manually.') + continue + } + if (!terminal.preload(command)) { + terminal.status( + 'File path detected, but newer composer input took priority. Use /attach to add it.' + ) + continue + } + terminal.status('File path detected. Press Enter to attach it, or edit the command.') + continue + } + } + if (trimmed.startsWith('/')) { + const taggedSlash = input.contexts?.some( + (context) => context.kind === 'skill' || context.kind === 'mcp' + ) + if (!taggedSlash) { + terminal.status(`Unknown command: ${trimmed.split(/\s/, 1)[0]}. Use /help.`) + continue + } + } + if (!trimmed && attachments.length === 0) continue + if (utf8Bytes(input.value) > MAX_CHAT_PROMPT_BYTES) { + terminal.status('Error: Chat input exceeds the 10 MiB limit.') + continue + } + return { + kind: 'turn', + prompt: input.value, + attachments, + queued: input.queued === true, + ...(input.display === undefined ? {} : { display: input.display }), + ...(input.pastes === undefined ? {} : { pastes: input.pastes }), + ...(input.contexts?.length ? { contexts: input.contexts } : {}), + } + } +} + +type QuestionAnswers = { kind: 'answer'; value: string } | { kind: 'cancel' } | { kind: 'exit' } + +async function answerQuestions( + terminal: ChatTerminal, + questions: ChatQuestion[] +): Promise<QuestionAnswers> { + const answers: string[] = [] + for (const [index, question] of questions.entries()) { + if (questions.length > 1) terminal.status(`Question ${index + 1} of ${questions.length}`) + const result = await terminal.askQuestion({ + prompt: question.prompt, + multi: question.type === 'multi_select', + options: question.options, + }) + if (result.kind === 'eof') return { kind: 'exit' } + if (result.kind === 'cancel') return { kind: 'cancel' } + answers.push(`${safeOneLine(question.prompt)} — ${result.values.map(safeOneLine).join(', ')}`) + } + return { kind: 'answer', value: answers.join('\n') } +} + +async function isChatTurnInput( + input: Extract<ChatTerminalInput, { kind: 'line' }>, + dependencies: Pick<ChatDependencies, 'pastedAttachmentPaths'> +): Promise<boolean> { + const trimmed = input.value.trim() + if (!trimmed) return false + if ( + trimmed.startsWith('/') && + !input.contexts?.some((context) => context.kind === 'skill' || context.kind === 'mcp') + ) { + return false + } + return !(await dependencies.pastedAttachmentPaths(input.value)) +} + +function logSuggestionLabel( + log: ListLogsResponse['data'][number], + workflowNames: ReadonlyMap<string, string> +): string { + const workflow = + log.workflow?.name || + (log.workflowId ? workflowNames.get(log.workflowId) : undefined) || + log.workflowId || + 'Unknown workflow' + const started = new Date(log.startedAt) + const time = Number.isNaN(started.getTime()) ? log.startedAt : started.toLocaleString() + return `${workflow} · ${time}`.slice(0, 255) +} + +/** + * Builds the same two pools as the home composer from existing public lists: + * workspace resources under `@`, then skills and enabled MCP servers under `/`. + * Each request fails independently so one unavailable resource family does not + * disable the rest of the composer. + */ +function loadSuggestionCandidates( + client: SimClient, + workspaceId: string, + readOnly: boolean, + signal: AbortSignal, + publish: (candidates: ChatSuggestionCandidates) => void +): void { + const query = { workspaceId } + const resourceGroups = { + workflows: [] as SuggestionItem[], + tables: [] as SuggestionItem[], + files: [] as SuggestionItem[], + knowledge: [] as SuggestionItem[], + logs: [] as SuggestionItem[], + } + const slashGroups = { + skills: [] as SuggestionItem[], + mcp: [] as SuggestionItem[], + } + let workflowsForLogs: ListWorkflowsResponse['data'] = [] + let loadedLogs: ListLogsResponse['data'] | null = null + const publishCurrent = () => { + publish({ + resources: [ + ...resourceGroups.workflows, + ...resourceGroups.tables, + ...resourceGroups.files, + ...resourceGroups.knowledge, + ...resourceGroups.logs, + ], + slash: [...slashGroups.skills, ...slashGroups.mcp], + }) + } + const publishLogs = () => { + if (!loadedLogs) return + const workflowNames = new Map(workflowsForLogs.map((workflow) => [workflow.id, workflow.name])) + resourceGroups.logs = loadedLogs.slice(0, MAX_LOG_SUGGESTIONS).map((log) => { + const label = logSuggestionLabel(log, workflowNames) + return { + id: `logs:${log.executionId}`, + value: label, + displayText: label, + description: 'log', + tag: 'logs', + context: { kind: 'logs' as const, executionId: log.executionId, label }, + } + }) + publishCurrent() + } + + const workflowsRequest = requestAllPages<ListWorkflowsResponse['data'][number]>( + client, + V2_OPERATIONS.listWorkflows.path, + { + query, + pageSize: 50, + signal, + auth: 'optional', + } + ).catch(() => []) + void workflowsRequest.then((workflows) => { + workflowsForLogs = workflows + resourceGroups.workflows = workflows.map((workflow) => ({ + id: `workflow:${workflow.id}`, + value: workflow.name, + displayText: workflow.name, + description: 'workflow', + tag: 'workflow', + context: { + kind: 'workflow' as const, + workflowId: workflow.id, + label: workflow.name, + }, + })) + publishCurrent() + publishLogs() + }) + + void requestAllPages<ListTablesResponse['data'][number]>(client, V2_OPERATIONS.listTables.path, { + query, + pageSize: 100, + signal, + auth: 'optional', + }) + .catch(() => []) + .then((tables) => { + resourceGroups.tables = tables.map((table) => ({ + id: `table:${table.id}`, + value: table.name, + displayText: table.name, + description: 'table', + tag: 'table', + context: { kind: 'table' as const, tableId: table.id, label: table.name }, + })) + publishCurrent() + }) + + void requestAllPages<ListFilesResponse['data'][number]>(client, V2_OPERATIONS.listFiles.path, { + query, + pageSize: 100, + signal, + auth: 'optional', + }) + .catch(() => []) + .then((files) => { + resourceGroups.files = files.map((file) => ({ + id: `file:${file.id}`, + value: file.name, + displayText: file.name, + description: 'file', + tag: 'file', + context: { kind: 'file' as const, fileId: file.id, label: file.name }, + })) + publishCurrent() + }) + + void client + .request<ListKnowledgeBasesResponse>(V2_OPERATIONS.listKnowledgeBases.path, { + query, + signal, + auth: 'optional', + }) + .then((page) => page.data) + .catch(() => []) + .then((knowledge) => { + resourceGroups.knowledge = knowledge.map((base) => ({ + id: `knowledge:${base.id}`, + value: base.name, + displayText: base.name, + description: 'knowledge base', + tag: 'knowledge', + context: { kind: 'knowledge' as const, knowledgeId: base.id, label: base.name }, + })) + publishCurrent() + }) + + const logsRequest = client + .request<ListLogsResponse>(V2_OPERATIONS.listLogs.path, { + query: { workspaceId, details: 'basic', order: 'desc', limit: MAX_LOG_SUGGESTIONS }, + signal, + auth: 'optional', + }) + .then((page) => page.data) + .catch(() => []) + void logsRequest.then((logs) => { + loadedLogs = logs + publishLogs() + }) + + void client + .request<ListSkillsResponse>(V2_OPERATIONS.listSkills.path, { query, signal, auth: 'optional' }) + .catch(() => null) + .then((skills) => { + slashGroups.skills = (skills?.data ?? []).map((skill) => ({ + id: `skill:${skill.id}`, + value: skill.name, + displayText: `/${skill.name}`, + description: skill.description, + tag: 'skill', + context: { kind: 'skill' as const, skillId: skill.id, label: skill.name }, + })) + publishCurrent() + }) + + if (!readOnly) { + void client + .request<ListMcpServersResponse>(V2_OPERATIONS.listMcpServers.path, { + query, + signal, + auth: 'optional', + }) + .catch(() => null) + .then((servers) => { + slashGroups.mcp = (servers?.data ?? []) + .filter((server) => server.enabled !== false) + .map((server) => ({ + id: `mcp:${server.id}`, + value: server.name, + displayText: `/${server.name}`, + description: server.description ?? 'MCP server', + tag: 'mcp', + context: { kind: 'mcp' as const, serverId: server.id, label: server.name }, + })) + publishCurrent() + }) + } +} + +const NEW_CHAT_SELECTION_ID = 'sim-cli:new-chat' +const NEW_CHAT_TITLE = 'New chat' + +function chatMenuDescription(chat: ChatSummary, currentChatId?: string): string { + const labels: string[] = [] + if (chat.id === currentChatId) labels.push('current') + if (chat.pinned) labels.push('pinned') + if (chat.active) labels.push('active') + const updated = new Date(chat.updatedAt) + labels.push( + Number.isNaN(updated.getTime()) + ? `updated ${safeOneLine(chat.updatedAt)}` + : updated.toLocaleString() + ) + return labels.join(' · ') +} + +async function selectChat( + client: SimClient, + terminal: ChatTerminal, + workspaceId: string, + currentChatId?: string +): Promise<ChatTerminalSelectResult> { + const chats = await requestAllPages<ChatSummary>(client, V2_OPERATIONS.listChats.path, { + query: { workspaceId }, + pageSize: 100, + auth: 'optional', + }) + return terminal.select({ + prompt: 'Choose a chat', + options: [ + { + id: NEW_CHAT_SELECTION_ID, + label: NEW_CHAT_TITLE, + description: 'start a blank conversation', + }, + ...chats.map((chat) => ({ + id: chat.id, + label: chat.title?.trim() || 'Untitled chat', + description: chatMenuDescription(chat, currentChatId), + })), + ], + }) +} + +async function loadChat( + client: SimClient, + workspaceId: string, + chatId: string, + readOnly: boolean +): Promise<GetChatResponse['data']> { + const response = await client.request<GetChatResponse>( + resolvePath(V2_OPERATIONS.getChat.path, { chatId }), + { + query: { workspaceId, ...(readOnly ? { readOnly: true } : {}) }, + auth: 'optional', + } + ) + return response.data +} + +async function renameChat( + client: SimClient, + workspaceId: string, + chatId: string, + title: string +): Promise<string> { + const body: RenameChatBody = { workspaceId, title } + const response = await client.request<RenameChatResponse>( + resolvePath(V2_OPERATIONS.renameChat.path, { chatId }), + { method: 'PATCH', body, auth: 'optional' } + ) + return response.data.title +} + +function renderStoredAssistantMessage(content: string, formatMarkdown: boolean): string { + const rendered = renderChatStructured( + withoutTrailingStandaloneResource(parseChatStructured(content)), + renderContext(false) + ) + const markdown = new ChatMarkdownStream(formatMarkdown) + return `${markdown.push(rendered.text)}${markdown.finish()}` +} + +/** Removes a terminal-dead resource pointer that the web UI renders as a clickable panel link. */ +function withoutTrailingStandaloneResource( + segments: readonly ChatStructuredSegment[] +): ChatStructuredSegment[] { + let index = segments.length - 1 + let foundResource = false + + while (index >= 0) { + const segment = segments[index] + if (segment.kind === 'workspace_resource') { + foundResource = true + index -= 1 + continue + } + if (segment.kind === 'thinking' || segment.kind === 'options') { + index -= 1 + continue + } + if (segment.kind === 'text' && !segment.text.trim()) { + index -= 1 + continue + } + break + } + + const boundary = segments[index] + if ( + !foundResource || + boundary?.kind !== 'text' || + !boundary.text.trim() || + !/\n[^\S\n]*$/u.test(boundary.text) + ) { + return [...segments] + } + + return [ + ...segments.slice(0, index), + { ...boundary, text: boundary.text.trimEnd() }, + ...segments.slice(index + 1).filter((segment) => { + if (segment.kind === 'workspace_resource') return false + return segment.kind !== 'text' || Boolean(segment.text.trim()) + }), + ] +} + +function showChatHistory( + terminal: ChatTerminal, + title: string | null, + messages: ChatHistoryMessage[], + formatMarkdown: boolean, + status: 'resumed' | 'active' | 'still-active' +): void { + terminal.clearTranscript() + const name = safeOneLine(title ?? '') || 'Untitled chat' + terminal.setChatTitle(name) + const message = + status === 'active' + ? `Opened ${name}. This chat is currently active elsewhere.` + : status === 'still-active' + ? `Refreshed ${name}. This chat remains active elsewhere.` + : `Resumed ${name}.` + terminal.status(message) + for (const message of messages) { + if (message.role === 'user') { + terminal.userMessage(message.content) + continue + } + const rendered = renderStoredAssistantMessage(message.content, formatMarkdown) + if (!rendered) continue + terminal.write(rendered) + if (!rendered.endsWith('\n')) terminal.write('\n') + } +} + +/** + * Best-effort workspace name lookup, unawaited so the header paints + * immediately; a failure just leaves the row out. + */ +async function resolveWorkspaceName( + client: SimClient, + workspaceId: string +): Promise<string | null> { + try { + const response = await client.request<GetWorkspaceResponse>( + resolvePath(V2_OPERATIONS.getWorkspace.path, { workspaceId }), + { auth: 'optional' } + ) + return response.data.workspace.name || null + } catch { + return null + } +} + +async function runInteractive( + client: SimClient, + workspaceId: string, + initialPrompt: string, + initialAttachments: ChatAttachment[], + readOnly: boolean, + dependencies: ChatDependencies, + profileName?: string +): Promise<void> { + const terminal = dependencies.createTerminal() + const suggestionController = new AbortController() + let continuationToken: string | undefined + let currentChatId: string | undefined + let resumedChatActive = false + let pendingAttachments = initialAttachments + let nextPrompt: string | null = initialPrompt || (initialAttachments.length ? '' : null) + let nextPromptQueued = false + let nextPromptDisplay: string | undefined + let nextPromptPastes: ReadonlyMap<number, string> | undefined + let nextPromptContexts: ChatContext[] = [] + let nextPromptConflictRetries = 0 + let pendingQuestions: ChatQuestion[] = [] + + const startNewConversation = () => { + pendingQuestions = [] + continuationToken = undefined + currentChatId = undefined + resumedChatActive = false + terminal.clearTranscript() + terminal.setChatTitle(NEW_CHAT_TITLE) + terminal.status('Started a new conversation.') + } + + try { + terminal.welcome({ chatTitle: NEW_CHAT_TITLE, profile: profileName }) + void resolveWorkspaceName(client, workspaceId).then((name) => { + if (name) terminal.setWorkspaceName(name) + }) + loadSuggestionCandidates( + client, + workspaceId, + readOnly, + suggestionController.signal, + (candidates) => { + terminal.setSuggestionCandidates?.(candidates) + } + ) + if (initialPrompt.trim()) terminal.userMessage(initialPrompt) + while (true) { + if (nextPrompt === null) { + const input = await readUserTurn( + terminal, + pendingAttachments, + dependencies, + pendingQuestions.length > 0 + ) + if (input.kind === 'exit') return + pendingAttachments = input.attachments + if (input.kind === 'idle') { + const questions = pendingQuestions + pendingQuestions = [] + const questionAnswers = await answerQuestions(terminal, questions) + if (questionAnswers.kind === 'exit') return + if (questionAnswers.kind === 'cancel') continue + nextPrompt = questionAnswers.value + nextPromptQueued = false + nextPromptDisplay = undefined + nextPromptPastes = undefined + nextPromptContexts = [] + nextPromptConflictRetries = 0 + continue + } + if ((input.kind === 'clear' || input.kind === 'chats') && terminal.hasQueuedInput()) { + terminal.status('Finish queued prompts before changing conversations.') + continue + } + if (input.kind === 'clear') { + startNewConversation() + continue + } + if (input.kind === 'chats') { + pendingQuestions = [] + let selection: ChatTerminalSelectResult + try { + selection = await selectChat(client, terminal, workspaceId, currentChatId) + } catch (error) { + terminal.status( + `Error: ${safeOneLine(error instanceof Error ? error.message : String(error))}` + ) + continue + } + if (selection.kind === 'eof') return + if (selection.kind === 'cancel') continue + if (selection.id === NEW_CHAT_SELECTION_ID) { + startNewConversation() + continue + } + try { + const chat = await loadChat(client, workspaceId, selection.id, readOnly) + if (!chat.continuationToken) { + throw new SimApiError('Sim Chat did not return a continuation token.', 0) + } + continuationToken = chat.continuationToken + currentChatId = chat.id + resumedChatActive = chat.active + showChatHistory( + terminal, + chat.title, + chat.messages, + dependencies.formatMarkdown(), + chat.active ? 'active' : 'resumed' + ) + } catch (error) { + terminal.status( + `Error: ${safeOneLine(error instanceof Error ? error.message : String(error))}` + ) + } + continue + } + if (input.kind === 'rename') { + if (!currentChatId) { + terminal.status('Send a message before renaming this chat.') + continue + } + try { + const title = await renameChat(client, workspaceId, currentChatId, input.title) + terminal.setChatTitle(title) + terminal.status(`Renamed chat to ${title}.`) + } catch (error) { + terminal.status( + `Error: ${safeOneLine(error instanceof Error ? error.message : String(error))}` + ) + } + continue + } + pendingQuestions = [] + nextPrompt = input.prompt + nextPromptQueued = input.queued + nextPromptDisplay = input.display + nextPromptPastes = input.pastes + nextPromptContexts = input.contexts ?? [] + nextPromptConflictRetries = 0 + } + + if (resumedChatActive && currentChatId) { + const retryDisplay = nextPromptDisplay ?? nextPrompt + const restorePrompt = (): boolean => + terminal.preload(retryDisplay, { + queued: true, + pastes: nextPromptPastes, + ...(nextPromptContexts.length ? { contexts: nextPromptContexts } : {}), + }) + try { + const chat = await loadChat(client, workspaceId, currentChatId, readOnly) + continuationToken = chat.continuationToken + currentChatId = chat.id + resumedChatActive = chat.active + showChatHistory( + terminal, + chat.title, + chat.messages, + dependencies.formatMarkdown(), + chat.active ? 'still-active' : 'resumed' + ) + if (!chat.active) { + if (retryDisplay.trim()) terminal.userMessage(retryDisplay) + } else { + if (!restorePrompt()) { + terminal.status('The pending prompt could not be restored. Please enter it again.') + } + nextPrompt = null + nextPromptQueued = false + nextPromptDisplay = undefined + nextPromptPastes = undefined + nextPromptContexts = [] + nextPromptConflictRetries = 0 + continue + } + } catch (error) { + const restored = restorePrompt() + const message = safeOneLine(error instanceof Error ? error.message : String(error)) + terminal.status( + restored + ? `Error: ${message}. Press Enter to retry.` + : `Error: ${message}. Please enter the prompt again.` + ) + nextPrompt = null + nextPromptQueued = false + nextPromptDisplay = undefined + nextPromptPastes = undefined + nextPromptContexts = [] + nextPromptConflictRetries = 0 + continue + } + } + + const sentPrompt = nextPrompt + const sentPromptQueued = nextPromptQueued + const sentPromptDisplay = nextPromptDisplay + const sentPromptPastes = nextPromptPastes + const sentContexts = nextPromptContexts + const sentConflictRetries = nextPromptConflictRetries + const sentAttachments = pendingAttachments + pendingAttachments = [] + const controller = new AbortController() + let sessionReady = false + let submitRequested = false + let submitChecks = Promise.resolve() + const stopListening = terminal.onInterrupt((reason, input) => { + if (reason === 'manual') { + if (!controller.signal.aborted) controller.abort(reason) + return + } + if (input?.kind !== 'line') return + submitChecks = submitChecks.then(async () => { + if (!(await isChatTurnInput(input, dependencies))) return + if (!submitRequested) { + submitRequested = true + if (sessionReady && !controller.signal.aborted) controller.abort(reason) + } + }) + }) + const activity = terminal.activity('Thinking…') + const parser = new ChatStructuredParser() + const markdownEnabled = dependencies.formatMarkdown() + const markdown = new ChatMarkdownStream(markdownEnabled) + const narrationMarkdown = new Map<string, ChatMarkdownStream>() + const questions: ChatQuestion[] = [] + let wroteOutput = false + let pendingWhitespace = '' + let previousWasBlock = false + let outputFinalized = false + let strippedOptions = false + let deferredTrailingResourceParts: RenderPart[] | null = null + + const writePart = (value: string, block: boolean) => { + if (!value) return + let separator = '' + if (wroteOutput && (block || previousWasBlock)) { + const trailingNewlines = pendingWhitespace.match(/\n*$/u)?.[0].length ?? 0 + const leadingNewlines = value.match(/^\n*/u)?.[0].length ?? 0 + separator = '\n'.repeat(Math.max(0, 2 - trailingNewlines - leadingNewlines)) + } + const output = `${pendingWhitespace}${separator}${value}` + const trailing = output.match(/\s+$/u)?.[0] ?? '' + const ready = trailing ? output.slice(0, -trailing.length) : output + if (ready) { + terminal.write(ready) + wroteOutput = true + } + pendingWhitespace = trailing + previousWasBlock = block + } + + const flushDeferredTrailingResource = () => { + if (!deferredTrailingResourceParts) return + for (const part of deferredTrailingResourceParts) writePart(part.value, part.block) + deferredTrailingResourceParts = null + } + + const finishOutput = () => { + if (outputFinalized) return + outputFinalized = true + if (deferredTrailingResourceParts) { + if (wroteOutput) { + deferredTrailingResourceParts = null + pendingWhitespace = '' + } else { + flushDeferredTrailingResource() + } + } + writePart(markdown.finish(), false) + pendingWhitespace = '' + if (wroteOutput) terminal.write('\n') + } + + const finishNarration = (parentId: string) => { + const stream = narrationMarkdown.get(parentId) + if (!stream) return + const delta = stream.finish() + if (delta) activity.event({ kind: 'narration', parentId, delta }) + narrationMarkdown.delete(parentId) + } + + const finishNarrations = () => { + for (const parentId of [...narrationMarkdown.keys()]) finishNarration(parentId) + } + + const renderActivity = (update: ChatActivityUpdate) => { + if (update.kind === 'narration') { + let stream = narrationMarkdown.get(update.parentId) + if (!stream) { + stream = new ChatMarkdownStream(markdownEnabled) + narrationMarkdown.set(update.parentId, stream) + } + const delta = stream.push(update.delta) + if (delta) activity.event({ ...update, delta }) + return + } + + if (update.parentId) finishNarration(update.parentId) + if (update.kind === 'subagent' && update.state !== 'running') finishNarration(update.id) + activity.event(update) + } + + const renderSegments = async (segments: Parameters<typeof renderChatStructured>[0]) => { + const list = typeof segments === 'string' ? parseChatStructured(segments) : [...segments] + for (const segment of list) { + let displaySegment = segment + if (segment.kind === 'options') { + // Suggestions are hidden terminal metadata. Any whitespace the + // model emitted immediately before them belongs to that hidden UI, + // so do not leak it into the transcript or the next composer. + if (!deferredTrailingResourceParts) pendingWhitespace = '' + strippedOptions = true + } else if (segment.kind === 'text' && strippedOptions) { + const text = segment.text.replace(/^\s+/u, '') + if (!text) continue + displaySegment = { ...segment, text } + strippedOptions = false + } else if (segment.kind !== 'thinking') { + strippedOptions = false + } + const rendered = renderChatStructured([displaySegment], renderContext(true)) + if (displaySegment.kind === 'text') { + const value = markdown.push(rendered.text) + if (deferredTrailingResourceParts && !rendered.text.trim()) { + if (value) deferredTrailingResourceParts.push({ value, block: false }) + continue + } + flushDeferredTrailingResource() + writePart(value, false) + } else { + const inline = markdown.flushInline() + if (deferredTrailingResourceParts && !inline.trim()) { + if (inline) deferredTrailingResourceParts.push({ value: inline, block: false }) + } else { + flushDeferredTrailingResource() + writePart(inline, false) + } + /* Reuse the renderer's own block classification rather than + re-deriving it by segment kind, so the streaming and one-shot + paths cannot disagree about spacing. */ + if (displaySegment.kind === 'workspace_resource') { + if (deferredTrailingResourceParts) { + deferredTrailingResourceParts.push(...rendered.parts) + } else if (wroteOutput && pendingWhitespace.includes('\n')) { + deferredTrailingResourceParts = [...rendered.parts] + } else { + for (const part of rendered.parts) writePart(part.value, part.block) + } + } else { + if ( + deferredTrailingResourceParts && + (rendered.parts.length > 0 || rendered.interactions.length > 0) + ) { + flushDeferredTrailingResource() + } + for (const part of rendered.parts) writePart(part.value, part.block) + } + } + for (const interaction of rendered.interactions) { + if (interaction.kind === 'question') questions.push(...interaction.questions) + } + } + } + + try { + const response = await requestChat( + client, + { + workspaceId, + prompt: nextPrompt, + ...(readOnly ? { readOnly: true } : {}), + ...(continuationToken ? { continuationToken } : {}), + ...(sentAttachments.length ? { attachments: sentAttachments } : {}), + ...(sentContexts.length ? { contexts: sentContexts } : {}), + }, + controller.signal + ) + const result = await readChatTurn(response, { + onDelta: (delta) => { + finishNarrations() + activity.clear() + return renderSegments(parser.push(delta)) + }, + onThinking: (delta) => activity.thinking(delta), + onActivity: renderActivity, + onContinuationToken: (token) => { + continuationToken = token + sessionReady = true + if (submitRequested && !controller.signal.aborted) controller.abort('submit') + }, + onChatId: (chatId) => { + currentChatId = chatId + }, + onTitle: (title) => terminal.setChatTitle(title), + }) + if (result.streamedContent) { + // The completion is authoritative. Upstream normally mirrors every + // byte as a delta, but a proxy can omit the last buffered suffix; feed + // that suffix through the same parser before finalizing its state. + if ( + result.content.length > result.streamedContent.length && + result.content.startsWith(result.streamedContent) + ) { + await renderSegments(parser.push(result.content.slice(result.streamedContent.length))) + } + await renderSegments(parser.finish()) + } else { + finishNarrations() + activity.clear() + await renderSegments(result.content) + } + if (!result.continuationToken) { + throw new SimApiError('Sim Chat did not return a continuation token.', 0) + } + finishNarrations() + finishOutput() + activity.complete() + continuationToken = result.continuationToken + nextPromptQueued = false + nextPromptDisplay = undefined + nextPromptPastes = undefined + nextPromptContexts = [] + nextPromptConflictRetries = 0 + await submitChecks + if (submitRequested || questions.length === 0) { + pendingQuestions = [] + nextPrompt = null + } else if (terminal.hasQueuedInput()) { + pendingQuestions = questions + nextPrompt = null + } else { + const questionAnswers = await answerQuestions(terminal, questions) + if (questionAnswers.kind === 'exit') return + nextPrompt = questionAnswers.kind === 'answer' ? questionAnswers.value : null + } + } catch (error) { + await submitChecks + const queuedSubmit = controller.signal.aborted && controller.signal.reason === 'submit' + if (!queuedSubmit && !sessionReady) { + pendingAttachments = combineChatAttachments(sentAttachments, pendingAttachments) + } + finishNarrations() + finishOutput() + activity.stop() + if (controller.signal.aborted) { + if (!queuedSubmit) terminal.status('Generation cancelled.') + } else { + const message = error instanceof Error ? error.message : String(error) + const code = + error instanceof SimApiError && error.code ? ` (${safeOneLine(error.code)})` : '' + const conflict = + error instanceof SimApiError && error.status === 409 && error.code === 'CONFLICT' + if (conflict && currentChatId) resumedChatActive = true + if ( + conflict && + !sessionReady && + sentPromptQueued && + continuationToken && + sentConflictRetries < 1 + ) { + nextPrompt = sentPrompt + nextPromptQueued = true + nextPromptDisplay = sentPromptDisplay + nextPromptPastes = sentPromptPastes + nextPromptContexts = sentContexts + nextPromptConflictRetries = sentConflictRetries + 1 + terminal.status('Previous response is still settling. Retrying…') + continue + } + const restored = + !sessionReady && + (sentPromptQueued || conflict) && + terminal.preload(sentPromptDisplay ?? sentPrompt, { + queued: true, + pastes: sentPromptPastes, + ...(sentContexts.length ? { contexts: sentContexts } : {}), + }) + if (conflict && restored) { + terminal.status('Previous response is still settling. Press Enter to retry.') + } else { + terminal.status(`Error: ${safeOneLine(message)}${code}`) + } + } + nextPrompt = null + nextPromptQueued = false + nextPromptDisplay = undefined + nextPromptPastes = undefined + nextPromptContexts = [] + nextPromptConflictRetries = 0 + } finally { + activity.stop() + stopListening() + } + } + } finally { + suggestionController.abort() + terminal.close() + } +} + +function collectFile(value: string, previous: string[] = []): string[] { + return [...previous, value] +} + +/** Creates print-mode and interactive workspace chat. */ +export function chatCommand(overrides: Partial<ChatDependencies> = {}): Command { + const dependencies: ChatDependencies = { + readInput: readPipedInput, + writeOutput: writeCompletedAnswer, + isInteractive: () => + Boolean(process.stdin.isTTY && process.stdout.isTTY && process.stderr.isTTY), + createTerminal: () => new ReadlineChatTerminal(), + loadAttachments: loadChatAttachments, + clipboardImage: readClipboardImage, + pastedAttachmentPaths: existingAttachmentPaths, + // The fullscreen chat already requires a TTY and uses ANSI throughout. A + // propagated TERM=dumb value must not leave model Markdown visible inside + // an otherwise fully rendered TUI. + formatMarkdown: () => Boolean(process.stdout.isTTY), + ...overrides, + } + + return new Command('chat') + .description('Ask Sim Chat about the active workspace') + .argument('[prompt...]', 'Question to ask') + .option('-p, --print', 'Print the final response and exit') + .option('-f, --file <path>', 'Attach a local file (repeatable)', collectFile, []) + .option('--read-only', 'Restrict Sim Chat to read-only workspace tools') + .action( + async ( + promptParts: string[], + options: { print?: boolean; file: string[]; readOnly?: boolean }, + command: Command + ) => { + const positionalPrompt = promptParts.join(' ') + const positionalBytes = utf8Bytes(positionalPrompt) + if (positionalBytes > MAX_CHAT_PROMPT_BYTES) throw inputTooLarge() + + const interactive = !options.print && dependencies.isInteractive() + if (!options.print && !interactive) { + throw new SimApiError( + 'Interactive Sim Chat requires a terminal. Use sim chat -p for pipelines or redirected output.', + 0 + ) + } + const separatorBytes = positionalPrompt ? 1 : 0 + const pipedInput = interactive + ? '' + : await dependencies.readInput(MAX_CHAT_PROMPT_BYTES - positionalBytes - separatorBytes) + const prompt = composeChatPrompt(promptParts, pipedInput) + if (utf8Bytes(prompt) > MAX_CHAT_PROMPT_BYTES) throw inputTooLarge() + + const attachments = await dependencies.loadAttachments(options.file ?? []) + if (!interactive && !prompt.trim() && attachments.length === 0) { + throw new SimApiError('Provide a prompt, attach a file, or pipe input to sim chat -p.', 0) + } + + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace(undefined, { auth: 'optional' }) + if (interactive) { + await runInteractive( + client, + workspaceId, + prompt, + attachments, + options.readOnly === true, + dependencies, + profile.name + ) + return + } + await runOneShot( + client, + workspaceId, + prompt, + attachments, + options.readOnly === true, + dependencies + ) + } + ) +} diff --git a/packages/sim-cli/src/commands/protocol/files-download.test.ts b/packages/sim-cli/src/commands/protocol/files-download.test.ts index 78b57300345..7dcda706b52 100644 --- a/packages/sim-cli/src/commands/protocol/files-download.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-download.test.ts @@ -7,13 +7,14 @@ import { buildGeneratedCommands } from '../../runtime/build.js' import { streamToFile } from './files-download.js' import { attachProtocolCommands } from './index.js' -const { output } = vi.hoisted(() => ({ +const { output, requestRaw } = vi.hoisted(() => ({ output: { format: 'json' }, + requestRaw: vi.fn(), })) vi.mock('../../context.js', () => ({ clientFrom: () => ({ - client: { request: vi.fn(), requireWorkspace: () => 'ws_local' }, + client: { requestRaw, requireWorkspace: () => 'ws_local' }, profile: { workspaceId: 'ws_local', output: output.format, @@ -29,6 +30,7 @@ let dir: string beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'sim-dl-')) output.format = 'json' + requestRaw.mockReset() }) afterEach(() => { @@ -88,7 +90,7 @@ describe('streamToFile', () => { describe('files download', () => { it('prints a normalized machine-readable result', async () => { const target = join(dir, 'download.txt') - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('downloaded', { status: 200 }))) + requestRaw.mockResolvedValue(new Response('downloaded', { status: 200 })) const logged: string[] = [] vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) @@ -107,10 +109,14 @@ describe('files download', () => { path: target, status: 'saved', }) + expect(requestRaw).toHaveBeenCalledWith('/api/v2/files/file_1', { + method: 'GET', + query: { workspaceId: 'ws_local' }, + }) }) it('streams raw bytes to stdout with the conventional - destination', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('downloaded', { status: 200 }))) + requestRaw.mockResolvedValue(new Response('downloaded', { status: 200 })) const chunks: Uint8Array[] = [] vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) @@ -125,12 +131,9 @@ describe('files download', () => { }) it('rejects overwrite semantics for stdout', async () => { - const fetch = vi.fn() - vi.stubGlobal('fetch', fetch) - await expect( program().parseAsync(['node', 'sim', 'file', 'download', 'file_1', '-o', '-', '--force']) ).rejects.toThrow(/--force cannot be used/) - expect(fetch).not.toHaveBeenCalled() + expect(requestRaw).not.toHaveBeenCalled() }) }) diff --git a/packages/sim-cli/src/commands/protocol/files-download.ts b/packages/sim-cli/src/commands/protocol/files-download.ts index 6aba29862d3..9a4999c2184 100644 --- a/packages/sim-cli/src/commands/protocol/files-download.ts +++ b/packages/sim-cli/src/commands/protocol/files-download.ts @@ -3,7 +3,8 @@ import { createWriteStream, type WriteStream } from 'node:fs' import { basename } from 'node:path' import type { Command } from 'commander' import { clientFrom } from '../../context.js' -import { SimApiError } from '../../http/client.js' +import { V2_OPERATIONS } from '../../generated/v2-api.js' +import { resolvePath, SimApiError } from '../../http/client.js' import { printProtocolResult } from './result.js' /** Streams a fetch body to disk while honoring write-stream backpressure. */ @@ -82,24 +83,13 @@ export function attachFileDownload(files: Command): void { const { client, profile } = clientFrom(command) const workspaceId = client.requireWorkspace() - - if (!profile.apiKey) { - throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0) - } - - const url = new URL(`${profile.endpoint}/api/v2/files/${encodeURIComponent(fileId)}`) - url.searchParams.set('workspaceId', workspaceId) - - // boundary-raw-fetch: binary download cannot pass through the JSON client - const response = await fetch(url, { - headers: { 'x-api-key': profile.apiKey }, + const operation = V2_OPERATIONS.downloadFile + const response = await client.requestRaw(resolvePath(operation.path, { fileId }), { + method: operation.method, + query: { workspaceId }, }) - if (!response.ok || !response.body) { - const raw = await response.text().catch(() => '') - throw new SimApiError( - raw || `Download failed with status ${response.status}`, - response.status - ) + if (!response.body) { + throw new SimApiError('Download returned an empty response.', response.status) } if (options.outputFile === '-') { diff --git a/packages/sim-cli/src/commands/protocol/files-upload.ts b/packages/sim-cli/src/commands/protocol/files-upload.ts index d0c7f3185bf..ca0d928e59d 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -4,6 +4,7 @@ import type { CompleteFileUploadResponse, CreateFileUploadResponse, } from '../../generated/v2-api.js' +import { V2_OPERATIONS } from '../../generated/v2-api.js' import { contentTypeFor, localFile } from '../../transfer/local-file.js' import { finishUploadSession } from '../../transfer/upload-session.js' import { printProtocolResult } from './result.js' @@ -19,16 +20,19 @@ export function attachFileUpload(files: Command): void { const workspaceId = client.requireWorkspace() const { name, size } = await localFile(path, options.name) - const created = await client.request<CreateFileUploadResponse>('/api/v2/files/uploads', { - method: 'POST', - body: { - workspaceId, - name, - contentType: contentTypeFor(name), - size, - ...(options.folder !== undefined ? { folderPath: options.folder } : {}), - }, - }) + const created = await client.request<CreateFileUploadResponse>( + V2_OPERATIONS.createFileUpload.path, + { + method: 'POST', + body: { + workspaceId, + name, + contentType: contentTypeFor(name), + size, + ...(options.folder !== undefined ? { folderPath: options.folder } : {}), + }, + } + ) const { session, uploadToken, transfer } = created.data const completed = await finishUploadSession<CompleteFileUploadResponse['data']>( client, diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index 33939b7cdde..b3520937f4d 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -1,4 +1,5 @@ import { Command } from 'commander' +import { chatCommand } from './chat.js' import { attachFileDownload } from './files-download.js' import { attachFileUpload } from './files-upload.js' import { attachKnowledgeDocumentUpload } from './knowledge-document-upload.js' @@ -15,6 +16,8 @@ function group(program: Command, name: string): Command { /** Attaches commands whose multi-request or binary protocols cannot be generated. */ export function attachProtocolCommands(program: Command): void { + program.addCommand(chatCommand()) + const files = group(program, 'files') attachFileUpload(files) attachFileDownload(files) diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.ts b/packages/sim-cli/src/commands/protocol/resource-directory.ts index e59f50c2200..c2e9d64f8bc 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.ts @@ -12,7 +12,7 @@ import { V2_OPERATIONS, type V2OperationName, } from '../../generated/v2-api.js' -import { SimApiError, type SimClient, type V2Page } from '../../http/client.js' +import { requestAllPages, SimApiError, type SimClient, type V2Page } from '../../http/client.js' import { type Column, printList, text, timestamp } from '../../output/render.js' import { DEFAULT_LIMIT } from '../../runtime/options.js' import { renderResult } from '../../runtime/result.js' @@ -103,20 +103,11 @@ async function listResources( return page.data.slice(0, limit) } - const resources: DirectoryResource[] = [] - let cursor: string | null = null - - do { - const remaining = limit - resources.length - const pageSize = Math.min(remaining, DEFAULT_LIMIT) - const page: V2Page<DirectoryResource> = await client.request(path, { - query: { ...query, limit: pageSize, cursor }, - }) - resources.push(...page.data) - cursor = page.nextCursor - } while (cursor && resources.length < limit) - - return resources.slice(0, limit) + return requestAllPages<DirectoryResource>(client, path, { + query, + pageSize: DEFAULT_LIMIT, + limit, + }) } async function listFolders( diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts index 78803a51f9e..fd6d4581b84 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -7,6 +7,7 @@ import type { CreateTableImportResponse, GetTableImportResponse, } from '../../generated/v2-api.js' +import { V2_OPERATIONS } from '../../generated/v2-api.js' import { SimApiError, type SimClient } from '../../http/client.js' import { coerce, type FieldSpec } from '../../runtime/request.js' import { contentTypeFor, localFile } from '../../transfer/local-file.js' @@ -146,19 +147,22 @@ export function attachTableImport(tables: Command): void { } } - const started = await client.request<CreateTableImportResponse>('/api/v2/tables/imports', { - method: 'POST', - body: { - workspaceId, - source, - target, - ...(options.mapping ? { mapping: jsonFlag(options.mapping, 'mapping', 'object') } : {}), - ...(options.createColumns - ? { createColumns: jsonFlag(options.createColumns, 'create-columns', 'array') } - : {}), - ...(options.timezone ? { timezone: options.timezone } : {}), - }, - }) + const started = await client.request<CreateTableImportResponse>( + V2_OPERATIONS.createTableImport.path, + { + method: 'POST', + body: { + workspaceId, + source, + target, + ...(options.mapping ? { mapping: jsonFlag(options.mapping, 'mapping', 'object') } : {}), + ...(options.createColumns + ? { createColumns: jsonFlag(options.createColumns, 'create-columns', 'array') } + : {}), + ...(options.timezone ? { timezone: options.timezone } : {}), + }, + } + ) let job: TableImport = started.data.session if (path) { diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 039261dceec..7ac3725d632 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -352,6 +352,59 @@ export type CancelWorkflowExecutionResponse = { } } +/** `POST /api/v2/chat` */ +export type ChatBody = { + workspaceId: string + prompt: string + continuationToken?: string + readOnly?: boolean + attachments?: Array<{ + name: string + mediaType: string + data: string + }> + contexts?: Array< + | { + kind: 'workflow' + workflowId: string + label: string + } + | { + kind: 'table' + tableId: string + label: string + } + | { + kind: 'file' + fileId: string + label: string + } + | { + kind: 'knowledge' + knowledgeId: string + label: string + } + | { + kind: 'logs' + executionId: string + label: string + } + | { + kind: 'skill' + skillId: string + label: string + } + | { + kind: 'mcp' + serverId: string + label: string + } + > +} + +/** Non-JSON response (`stream`). */ +export type ChatResponse = never + /** `POST /api/v2/files/uploads/[uploadId]/complete` */ export type CompleteFileUploadParams = { uploadId: string @@ -1195,25 +1248,6 @@ export type CreateTableRowsBody = | { workspaceId: string data: unknown - __privateSecretProvenance?: { - version: 1 - complete: boolean - selections: Array<{ - key: string - provenance: { - version: 1 - complete: boolean - entries: Array<{ - encryptedValue: string - name?: string - }> - scope?: { - userId: string - workspaceId?: string - } - } - }> - } afterRowId?: string beforeRowId?: string } @@ -1951,6 +1985,31 @@ export type GetBillingStatusResponse = { } } +/** `GET /api/v2/chats/[chatId]` */ +export type GetChatParams = { + chatId: string +} + +export type GetChatQuery = { + workspaceId: string + readOnly?: boolean +} + +export type GetChatResponse = { + data: { + id: string + title: string | null + messages: Array<{ + id: string + role: 'user' | 'assistant' + content: string + timestamp: string + }> + continuationToken: string + active: boolean + } +} + /** `GET /api/v2/credentials/[id]` */ export type GetCredentialParams = { id: string @@ -2548,6 +2607,24 @@ export type GetWorkflowVersionResponse = { } } +/** `GET /api/v2/workspaces/[workspaceId]` */ +export type GetWorkspaceParams = { + workspaceId: string +} + +export type GetWorkspaceResponse = { + data: { + workspace: { + id: string + name: string + color: string + logoUrl: string | null + createdAt: string + updatedAt: string + } + } +} + /** `POST /api/v2/workflows/import` */ export type ImportWorkflowBody = { workspaceId: string @@ -2647,6 +2724,25 @@ export type ListBillingLogsResponse = { nextCursor: string | null } +/** `GET /api/v2/chats` */ +export type ListChatsQuery = { + workspaceId: string + search?: string + limit?: number + cursor?: string +} + +export type ListChatsResponse = { + data: Array<{ + id: string + title: string | null + updatedAt: string + pinned: boolean + active: boolean + }> + nextCursor: string | null +} + /** `GET /api/v2/credentials` */ export type ListCredentialsQuery = { workspaceId: string @@ -3380,6 +3476,23 @@ export type RelocateWorkflowFolderResponse = { } } +/** `PATCH /api/v2/chats/[chatId]` */ +export type RenameChatParams = { + chatId: string +} + +export type RenameChatBody = { + workspaceId: string + title: string +} + +export type RenameChatResponse = { + data: { + id: string + title: string + } +} + /** `PATCH /api/v2/files/[fileId]` */ export type RenameFileParams = { fileId: string @@ -3835,25 +3948,6 @@ export type UpdateRowsByFilterBody = { filter: unknown data: unknown limit?: number - __privateSecretProvenance?: { - version: 1 - complete: boolean - selections: Array<{ - key: string - provenance: { - version: 1 - complete: boolean - entries: Array<{ - encryptedValue: string - name?: string - }> - scope?: { - userId: string - workspaceId?: string - } - } - }> - } } export type UpdateRowsByFilterResponse = { @@ -3995,25 +4089,6 @@ export type UpdateTableRowParams = { export type UpdateTableRowBody = { workspaceId: string data: unknown - __privateSecretProvenance?: { - version: 1 - complete: boolean - selections: Array<{ - key: string - provenance: { - version: 1 - complete: boolean - entries: Array<{ - encryptedValue: string - name?: string - }> - scope?: { - userId: string - workspaceId?: string - } - } - }> - } } export type UpdateTableRowResponse = { @@ -4258,25 +4333,6 @@ export type UpsertTableRowBody = { workspaceId: string data: unknown conflictTarget?: string - __privateSecretProvenance?: { - version: 1 - complete: boolean - selections: Array<{ - key: string - provenance: { - version: 1 - complete: boolean - entries: Array<{ - encryptedValue: string - name?: string - }> - scope?: { - userId: string - workspaceId?: string - } - } - }> - } } export type UpsertTableRowResponse = { @@ -4400,6 +4456,21 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Cancel an execution', }, + chat: { + method: 'POST', + path: '/api/v2/chat', + pathParams: [] as const, + responseMode: 'stream', + summary: 'Ask Sim Chat', + body: { + workspaceId: { kind: 'string', required: true }, + prompt: { kind: 'string', required: true }, + continuationToken: { kind: 'string' }, + readOnly: { kind: 'boolean', default: false }, + attachments: { kind: 'array' }, + contexts: { kind: 'array' }, + }, + }, completeFileUpload: { method: 'POST', path: '/api/v2/files/uploads/[uploadId]/complete', @@ -4993,6 +5064,17 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string' }, }, }, + getChat: { + method: 'GET', + path: '/api/v2/chats/[chatId]', + pathParams: ['chatId'] as const, + responseMode: 'json', + summary: 'Open Sim Chat', + query: { + workspaceId: { kind: 'string', required: true }, + readOnly: { kind: 'boolean' }, + }, + }, getCredential: { method: 'GET', path: '/api/v2/credentials/[id]', @@ -5155,6 +5237,13 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Workflow Version', }, + getWorkspace: { + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]', + pathParams: ['workspaceId'] as const, + responseMode: 'json', + summary: 'Get Workspace', + }, importWorkflow: { method: 'POST', path: '/api/v2/workflows/import', @@ -5222,6 +5311,19 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + listChats: { + method: 'GET', + path: '/api/v2/chats', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Sim Chats', + query: { + workspaceId: { kind: 'string', required: true }, + search: { kind: 'string' }, + limit: { kind: 'number', default: 30 }, + cursor: { kind: 'string' }, + }, + }, listCredentials: { method: 'GET', path: '/api/v2/credentials', @@ -5642,6 +5744,17 @@ export const V2_OPERATIONS = { destinationPath: { kind: 'string', required: true }, }, }, + renameChat: { + method: 'PATCH', + path: '/api/v2/chats/[chatId]', + pathParams: ['chatId'] as const, + responseMode: 'json', + summary: 'Rename Sim Chat', + body: { + workspaceId: { kind: 'string', required: true }, + title: { kind: 'string', required: true }, + }, + }, renameFile: { method: 'PATCH', path: '/api/v2/files/[fileId]', @@ -5821,7 +5934,6 @@ export const V2_OPERATIONS = { filter: { kind: 'unknown', required: true }, data: { kind: 'unknown', required: true }, limit: { kind: 'integer' }, - __privateSecretProvenance: { kind: 'object' }, }, }, updateSkill: { @@ -5871,7 +5983,6 @@ export const V2_OPERATIONS = { body: { workspaceId: { kind: 'string', required: true }, data: { kind: 'unknown', required: true }, - __privateSecretProvenance: { kind: 'object' }, }, }, updateTableView: { @@ -5955,7 +6066,6 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, data: { kind: 'unknown', required: true }, conflictTarget: { kind: 'string' }, - __privateSecretProvenance: { kind: 'object' }, }, }, } as const diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 8102090d8f5..5b52cab2b14 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -1,12 +1,44 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { CLI_CONTRACT } from '../contract/commands.js' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' -import { formatApiErrorDetails, resolvePath, SimApiError, SimClient } from './client.js' +import { + formatApiErrorDetails, + requestAllPages, + resolvePath, + SimApiError, + SimClient, +} from './client.js' afterEach(() => { vi.unstubAllGlobals() }) +describe('cursor pagination', () => { + it('follows v2 cursors through the requested item limit', async () => { + const request = vi + .fn() + .mockResolvedValueOnce({ data: ['a', 'b'], nextCursor: 'next' }) + .mockResolvedValueOnce({ data: ['c'], nextCursor: null }) + + await expect( + requestAllPages<string>({ request } as Pick<SimClient, 'request'>, '/api/v2/items', { + query: { workspaceId: 'workspace-1' }, + pageSize: 2, + limit: 3, + auth: 'optional', + }) + ).resolves.toEqual(['a', 'b', 'c']) + expect(request).toHaveBeenNthCalledWith(1, '/api/v2/items', { + query: { workspaceId: 'workspace-1', limit: 2, cursor: null }, + auth: 'optional', + }) + expect(request).toHaveBeenNthCalledWith(2, '/api/v2/items', { + query: { workspaceId: 'workspace-1', limit: 1, cursor: 'next' }, + auth: 'optional', + }) + }) +}) + describe('API errors', () => { it('keeps structured details and does not misdiagnose an ordinary 404', async () => { vi.stubGlobal( @@ -82,6 +114,93 @@ describe('API errors', () => { }) }) +describe('raw requests', () => { + function client(options: { apiKey?: string } = { apiKey: 'key' }): SimClient { + return new SimClient({ + name: 'default', + endpoint: 'https://sim.example', + apiKey: options.apiKey ?? null, + workspaceId: 'ws_1', + output: 'json', + sources: { + endpoint: 'default', + apiKey: 'env', + workspaceId: 'env', + output: 'default', + }, + }) + } + + it('returns an unconsumed response and forwards an abort signal', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('stream body')) + vi.stubGlobal('fetch', fetch) + const controller = new AbortController() + + const response = await client().requestRaw('/api/v2/chat', { + method: 'POST', + headers: { accept: 'text/event-stream' }, + body: { workspaceId: 'ws_1', prompt: 'hello' }, + signal: controller.signal, + }) + + expect(response.bodyUsed).toBe(false) + expect(await response.text()).toBe('stream body') + expect(fetch).toHaveBeenCalledWith( + 'https://sim.example/api/v2/chat', + expect.objectContaining({ + method: 'POST', + signal: controller.signal, + headers: expect.objectContaining({ + accept: 'text/event-stream', + 'content-type': 'application/json', + 'x-api-key': 'key', + }), + }) + ) + }) + + it('turns an aborted fetch into a clean CLI error', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('aborted', 'AbortError'))) + const controller = new AbortController() + controller.abort() + + await expect( + client().requestRaw('/api/v2/chat', { signal: controller.signal }) + ).rejects.toMatchObject({ + message: 'Request cancelled.', + status: 0, + }) + }) + + it('allows auth-disabled self-hosted chat without sending an API key', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('stream body')) + vi.stubGlobal('fetch', fetch) + const unauthenticated = client({}) + const workspaceId = unauthenticated.requireWorkspace(undefined, { auth: 'optional' }) + + await unauthenticated.requestRaw('/api/v2/chat', { + method: 'POST', + body: { workspaceId, prompt: 'hello' }, + auth: 'optional', + }) + + expect(workspaceId).toBe('ws_1') + expect(fetch).toHaveBeenCalledOnce() + const headers = fetch.mock.calls[0][1].headers as Record<string, string> + expect(headers).not.toHaveProperty('x-api-key') + }) + + it('keeps authentication required by default for every other command', async () => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + const unauthenticated = client({}) + + expect(() => unauthenticated.requireWorkspace()).toThrow(/Not logged in/) + await expect(unauthenticated.requestRaw('/api/v2/workflows')).rejects.toThrow(/Not logged in/) + expect(fetch).not.toHaveBeenCalled() + }) +}) + describe('resolvePath', () => { it('substitutes a path parameter', () => { expect(resolvePath('/api/v2/tables/[tableId]/rows', { tableId: 'tbl_1' })).toBe( diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index f195e6364d2..ccecd13ecdf 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -24,7 +24,16 @@ export interface V2Page<T> { nextCursor: string | null } +export interface RequestAllPagesOptions extends Omit<RequestOptions, 'query'> { + query?: Record<string, QueryValue> + /** Server page size; callers choose one accepted by the endpoint contract. */ + pageSize: number + /** Maximum items to return. Omit to follow the cursor through the full list. */ + limit?: number +} + export type QueryValue = string | number | boolean | null | undefined +export type AuthRequirement = 'required' | 'optional' export interface RequestOptions { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' @@ -32,6 +41,14 @@ export interface RequestOptions { body?: unknown /** Contract-declared headers, e.g. the `upload-token` a transfer is bound to. */ headers?: Record<string, string> + /** Cancels both the initial request and any subsequent streaming body read. */ + signal?: AbortSignal + /** Self-hosted, auth-disabled routes may deliberately omit a local API key. */ + auth?: AuthRequirement +} + +export interface WorkspaceOptions { + auth?: AuthRequirement } function buildUrl(endpoint: string, path: string, query?: Record<string, QueryValue>): string { @@ -122,8 +139,9 @@ export function formatApiErrorDetails(details: unknown): string[] { export class SimClient { constructor(private readonly profile: ResolvedProfile) {} - private requireAuth(): string { + private resolveApiKey(auth: AuthRequirement = 'required'): string | undefined { if (!this.profile.apiKey) { + if (auth === 'optional') return undefined throw new SimApiError( `Not logged in on profile "${this.profile.name}". Run: sim login --profile ${this.profile.name}`, 0 @@ -135,12 +153,13 @@ export class SimClient { /** * The workspace every workspace-scoped command defaults to. * - * Checks the key first even though it does not need one: commands resolve the - * workspace while building their query, so without this a brand-new install - * is told to set a workspace when the actual first step is logging in. + * By default this checks the key first even though it does not need one: + * commands resolve the workspace while building their query, so without this + * a brand-new install is told to set a workspace when the actual first step + * is logging in. Auth-disabled self-hosted protocols opt out explicitly. */ - requireWorkspace(explicit?: string): string { - this.requireAuth() + requireWorkspace(explicit?: string, options: WorkspaceOptions = {}): string { + this.resolveApiKey(options.auth) const workspaceId = explicit ?? this.profile.workspaceId if (!workspaceId) { throw new SimApiError( @@ -151,8 +170,16 @@ export class SimClient { return workspaceId } - async request<T>(path: string, options: RequestOptions = {}): Promise<T> { - const apiKey = this.requireAuth() + /** + * Makes a request without consuming its body. Authentication is required + * unless a self-hosted protocol explicitly opts out. + * + * JSON commands use {@link request}; streaming and binary protocols keep the + * raw response so they can process bytes incrementally. HTTP failures still + * become the same structured `SimApiError` either way. + */ + async requestRaw(path: string, options: RequestOptions = {}): Promise<Response> { + const apiKey = this.resolveApiKey(options.auth) const url = buildUrl(this.profile.endpoint, path, options.query) const hasBody = options.body !== undefined @@ -162,23 +189,26 @@ export class SimClient { response = await fetch(url, { method: options.method ?? 'GET', headers: { - 'x-api-key': apiKey, + ...(apiKey ? { 'x-api-key': apiKey } : {}), accept: 'application/json', ...(hasBody ? { 'content-type': 'application/json' } : {}), ...options.headers, }, body: hasBody ? JSON.stringify(options.body) : undefined, + signal: options.signal, }) } catch (cause) { + if (options.signal?.aborted) { + throw new SimApiError('Request cancelled.', 0) + } throw new SimApiError( `Could not reach ${this.profile.endpoint}: ${(cause as Error).message}`, 0 ) } - const raw = await response.text() - if (!response.ok) { + const raw = await response.text() const error = toApiError(response.status, raw) if (response.status === 401) { error.message = `${error.message} — run: sim login --profile ${this.profile.name}` @@ -186,11 +216,46 @@ export class SimClient { throw error } + return response + } + + async request<T>(path: string, options: RequestOptions = {}): Promise<T> { + const response = await this.requestRaw(path, options) + const raw = await response.text() + if (!raw) return undefined as T return JSON.parse(raw) as T } } +/** Follows a standard v2 cursor envelope without duplicating pagination loops. */ +export async function requestAllPages<T>( + client: Pick<SimClient, 'request'>, + path: string, + options: RequestAllPagesOptions +): Promise<T[]> { + const { query, pageSize, limit: requestedLimit, ...requestOptions } = options + const limit = requestedLimit ?? Number.POSITIVE_INFINITY + if (limit <= 0) return [] + + const items: T[] = [] + let cursor: string | null = null + do { + const page: V2Page<T> = await client.request<V2Page<T>>(path, { + ...requestOptions, + query: { + ...query, + limit: Math.min(pageSize, limit - items.length), + cursor, + }, + }) + items.push(...page.data) + cursor = page.nextCursor + } while (cursor && items.length < limit) + + return items.slice(0, limit) +} + /** * Substitutes `[id]`-style path segments. * diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 77aa78911bc..4fedd5db663 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -16,7 +16,7 @@ program .name('sim') .description('Talk to the Sim API from your terminal') .version('0.1.0') - .option('-p, --profile <name>', 'Profile to use (env: SIM_PROFILE)') + .option('-P, --profile <name>', 'Profile to use (env: SIM_PROFILE)') .option('--endpoint <url>', 'Sim deployment to talk to (env: SIM_ENDPOINT)') .option('-w, --workspace <id>', 'Workspace to target (env: SIM_WORKSPACE)') .addOption( @@ -39,11 +39,12 @@ program.addHelpText( 'after', ` Profiles work like the AWS CLI: settings live in ~/.sim/config, keys in -~/.sim/credentials (0600). Select one with --profile or SIM_PROFILE. +~/.sim/credentials (0600). Select one with -P, --profile, or SIM_PROFILE. Examples: $ sim login Authorize the default profile $ sim login --profile dev --endpoint http://localhost:3000 + $ sim chat -p "Which workflows handle support tickets?" $ sim workflows list $ sim logs list --level error --limit 20 $ sim --output json tables get tbl_123 Override output for one command @@ -65,12 +66,12 @@ async function main() { await program.parseAsync(process.argv) } catch (error) { if (error instanceof ProfileConfigError) { - console.error(chalk.red(`Error: ${error.message}`)) + console.error(chalk.red(`Error: ${sanitize(error.message)}`)) process.exit(1) } if (error instanceof SimApiError) { - console.error(chalk.red(`Error: ${error.message}`)) - if (error.code) console.error(chalk.dim(` code: ${error.code}`)) + console.error(chalk.red(`Error: ${sanitize(error.message)}`)) + if (error.code) console.error(chalk.dim(` code: ${sanitize(error.code)}`)) if (error.details !== undefined) { for (const line of formatApiErrorDetails(error.details)) { console.error(chalk.dim(sanitize(line))) diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts index ce554b63b5c..41b7ac06f56 100644 --- a/packages/sim-cli/src/output/render.test.ts +++ b/packages/sim-cli/src/output/render.test.ts @@ -231,6 +231,10 @@ describe('sanitize', () => { expect(sanitize('a\u0000b\u0008c\u009bd')).toBe('abcd') }) + it('removes bidi formatting controls while preserving ordinary RTL text', () => { + expect(sanitize('safe\u202eevil\u202c \u2066host\u2069 مرحبا')).toBe('safeevil host مرحبا') + }) + it('takes the following byte with a bare ESC, since ESC + printable is a sequence', () => { expect(sanitize('a\u001bdb')).toBe('ab') }) @@ -239,6 +243,10 @@ describe('sanitize', () => { expect(sanitize('a\tb\nc')).toBe('a\tb\nc') }) + it('normalizes CRLF and removes a lone carriage return that could overwrite a line', () => { + expect(sanitize('first\r\nsecond\roverwrite')).toBe('first\nsecondoverwrite') + }) + it('leaves ordinary text untouched', () => { expect(sanitize('refund policy — 30 days')).toBe('refund policy — 30 days') }) diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index 5235356e2e5..46cc97008d9 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -1,6 +1,7 @@ import chalk from 'chalk' import { dump } from 'js-yaml' import type { OutputFormat } from '../config/index.js' +import { displayWidth } from './terminal-text.js' export interface Column<T> { header: string @@ -38,11 +39,16 @@ const CONTROL_PATTERN = new RegExp( // matched above, so they win at the same position. `${ESC}[ -~]`, `${ESC}`, // a lone ESC with nothing valid after it - '[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f]', // C0/C1, keeping \t and \n + '[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f]', // C0/C1; CR is normalized below ].join('|'), 'g' ) +// Directional formatting marks can visually reorder an otherwise safe label +// or URL without changing its underlying bytes. Remove only the explicit +// controls; ordinary Hebrew, Arabic, and other right-to-left text is preserved. +const BIDI_CONTROL_PATTERN = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu + /** * Removes terminal control sequences from a server-supplied string. * @@ -51,7 +57,21 @@ const CONTROL_PATTERN = new RegExp( * formatting too. */ export function sanitize(value: string): string { - return value.replace(CONTROL_PATTERN, '') + // Preserve normal Windows line endings without leaving a lone carriage + // return capable of moving the cursor back over already-rendered text. + return value + .replace(/\r\n/g, '\n') + .replace(/\r/g, '') + .replace(CONTROL_PATTERN, '') + .replace(BIDI_CONTROL_PATTERN, '') +} + +/** Flattens untrusted terminal text into one compact, display-safe line. */ +export function safeOneLine(value: string): string { + return sanitize(value) + .replace(/[\n\t]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() } export function text(value: unknown): string { @@ -111,8 +131,15 @@ const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') * skew every coloured column, so widths are measured on the stripped text while * the coloured text is what gets printed. */ +/** + * Visible width of a cell. + * + * Delegates to the grapheme-aware measurement: the previous implementation + * counted stripped string length, so emoji and East Asian characters measured + * as one column and mis-aligned every table containing them. + */ export function visibleWidth(value: string): number { - return value.replace(ANSI_PATTERN, '').length + return displayWidth(value) } /** diff --git a/packages/sim-cli/src/output/terminal-text.ts b/packages/sim-cli/src/output/terminal-text.ts new file mode 100644 index 00000000000..734f18b61d7 --- /dev/null +++ b/packages/sim-cli/src/output/terminal-text.ts @@ -0,0 +1,106 @@ +/** + * Grapheme-aware terminal text primitives. + * + * Extracted from the chat terminal because they are pure and have no dependency + * on it: width, truncation, padding and cursor-index arithmetic that correctly + * handle combining marks, emoji and East Asian wide characters. `output/render` + * previously carried weaker copies that measured by string length. + */ +const RESET = `${String.fromCharCode(27)}[0m` + +const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) +export function graphemes(value: string): Array<{ segment: string; index: number }> { + return [...GRAPHEME_SEGMENTER.segment(value)].map(({ segment, index }) => ({ segment, index })) +} +/** First grapheme cluster of a string, or null when it is empty. */ +export function firstGrapheme(value: string): string | null { + return GRAPHEME_SEGMENTER.segment(value)[Symbol.iterator]().next().value?.segment ?? null +} + +export function previousGraphemeIndex(value: string, cursor: number): number { + let previous = 0 + for (const part of graphemes(value)) { + if (part.index >= cursor) break + previous = part.index + } + return previous +} +export function nextGraphemeIndex(value: string, cursor: number): number { + for (const part of graphemes(value)) { + if (part.index > cursor) return part.index + if (part.index === cursor) return part.index + part.segment.length + } + return value.length +} +export function lineStart(value: string, cursor: number): number { + const newline = value.lastIndexOf('\n', Math.max(0, cursor - 1)) + return newline < 0 ? 0 : newline + 1 +} +export function lineEnd(value: string, cursor: number): number { + const newline = value.indexOf('\n', cursor) + return newline < 0 ? value.length : newline +} +export function displayWidth(value: string): number { + let width = 0 + for (const part of graphemes(value.replace(/\u001b\[[0-9;:]*m/gu, ''))) { + width += graphemeWidth(part.segment) + } + return width +} +export function graphemeWidth(value: string): number { + if (!value || value === '\n') return 0 + if (/^\p{Mark}+$/u.test(value)) return 0 + if (value.includes('\u200d') || /\p{Extended_Pictographic}/u.test(value)) return 2 + const codePoint = value.codePointAt(0) ?? 0 + if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) return 0 + return isWideCodePoint(codePoint) ? 2 : 1 +} +export function isWideCodePoint(codePoint: number): boolean { + return ( + codePoint >= 0x1100 && + (codePoint <= 0x115f || + codePoint === 0x2329 || + codePoint === 0x232a || + (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe19) || + (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || + (codePoint >= 0xff00 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || + (codePoint >= 0x1f300 && codePoint <= 0x1faff) || + (codePoint >= 0x20000 && codePoint <= 0x3fffd)) + ) +} +export function truncateDisplay(value: string, width: number): string { + if (displayWidth(value) <= width) return value + const target = Math.max(0, width - 1) + let result = '' + let used = 0 + for (const part of graphemes(value)) { + const partWidth = displayWidth(part.segment) + if (used + partWidth > target) break + result += part.segment + used += partWidth + } + return `${result}…${RESET}` +} +export function tailToWidth(value: string, width: number): string { + if (displayWidth(value) <= width) return value + const target = Math.max(0, width - 1) + const parts = graphemes(value) + let result = '' + let used = 0 + for (let index = parts.length - 1; index >= 0; index -= 1) { + const part = parts[index] + const partWidth = displayWidth(part.segment) + if (used + partWidth > target) break + result = `${part.segment}${result}` + used += partWidth + } + return `…${result}` +} +/** Squares off a ragged art line so every box border starts at the same column. */ +export function artPad(line: string, width: number): string { + return ' '.repeat(Math.max(0, width - displayWidth(line))) +} diff --git a/packages/sim-cli/src/runtime/types.ts b/packages/sim-cli/src/runtime/types.ts index c9d98db84fc..2d352c73a3f 100644 --- a/packages/sim-cli/src/runtime/types.ts +++ b/packages/sim-cli/src/runtime/types.ts @@ -9,5 +9,5 @@ export interface OperationSpec { body?: Record<string, FieldSpec> opaqueBody?: boolean summary?: string - responseMode?: 'json' | 'binary' + responseMode?: 'json' | 'binary' | 'stream' } diff --git a/packages/sim-cli/src/transfer/local-file.ts b/packages/sim-cli/src/transfer/local-file.ts index b9056243cb0..e2647dbec24 100644 --- a/packages/sim-cli/src/transfer/local-file.ts +++ b/packages/sim-cli/src/transfer/local-file.ts @@ -46,7 +46,7 @@ export async function localFile(path: string, override?: string): Promise<LocalF let size: number try { const stats = await stat(path) - if (stats.isDirectory()) throw new SimApiError(`${path} is a directory`, 0) + if (!stats.isFile()) throw new SimApiError(`${path} is not a regular file`, 0) size = stats.size } catch (error) { if (error instanceof SimApiError) throw error diff --git a/scripts/check-openapi-specs.ts b/scripts/check-openapi-specs.ts index 5fa5ad826a5..4a6aaf4386e 100644 --- a/scripts/check-openapi-specs.ts +++ b/scripts/check-openapi-specs.ts @@ -76,7 +76,9 @@ const contractKey = (c: ContractLike) => async function loadContracts(): Promise<Map<string, { name: string; contract: ContractLike }>> { const registry = new Map<string, { name: string; contract: ContractLike }>() const files = readdirSync(V2_CONTRACTS_DIR) - .filter((f) => f.endsWith('.ts') && f !== 'shared.ts') + .filter( + (f) => f.endsWith('.ts') && !f.endsWith('.test.ts') && f !== 'index.ts' && f !== 'shared.ts' + ) .map((f) => path.join(V2_CONTRACTS_DIR, f)) for (const file of [...files, ...EXTRA_CONTRACT_MODULES]) { const mod = (await import(file)) as Record<string, unknown> From 5de62697966bddf15a27b8e0479999a1556e6306 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 8 Aug 2026 10:34:45 -0700 Subject: [PATCH 097/159] improvement(api): centralize remaining v2 resource operations (#6412) * improvement(api): centralize v2 resource operations * fix(api): preserve custom tool conflict errors --- .../api/v2/custom-tools/[id]/route.test.ts | 393 +++++--------- .../sim/app/api/v2/custom-tools/[id]/route.ts | 158 ++---- .../sim/app/api/v2/custom-tools/route.test.ts | 384 +++++--------- apps/sim/app/api/v2/custom-tools/route.ts | 99 +--- .../app/api/v2/mcp-servers/[id]/route.test.ts | 453 ++++++----------- apps/sim/app/api/v2/mcp-servers/[id]/route.ts | 145 ++---- apps/sim/app/api/v2/mcp-servers/route.test.ts | 478 ++++++------------ apps/sim/app/api/v2/mcp-servers/route.ts | 131 ++--- .../app/api/v2/secrets/[name]/route.test.ts | 318 +++++------- apps/sim/app/api/v2/secrets/[name]/route.ts | 182 +------ apps/sim/app/api/v2/secrets/route.test.ts | 189 ++++--- apps/sim/app/api/v2/secrets/route.ts | 49 +- apps/sim/app/api/v2/skills/[id]/route.test.ts | 417 +++++---------- apps/sim/app/api/v2/skills/[id]/route.ts | 152 +++--- apps/sim/app/api/v2/skills/route.test.ts | 382 +++++--------- apps/sim/app/api/v2/skills/route.ts | 81 ++- apps/sim/lib/api/contracts/v2/custom-tools.ts | 1 + apps/sim/lib/api/contracts/v2/mcp-servers.ts | 1 + apps/sim/lib/api/contracts/v2/skills.ts | 1 + .../lib/api/server/routes/v2-json-route.ts | 16 +- .../execute-custom-tool-use-case.ts | 8 + .../execute-mcp-server-use-case.ts | 8 + .../application/execute-skill-use-case.ts | 8 + .../execute-workspace-use-case.test.ts | 99 ++++ .../application/execute-workspace-use-case.ts | 34 ++ .../auth/workspace-application-delegation.ts | 46 ++ .../manage-application-use-cases.test.ts | 160 ++++++ .../handlers/management/manage-custom-tool.ts | 197 +++----- .../handlers/management/manage-mcp-tool.ts | 87 ++-- .../tools/handlers/management/manage-skill.ts | 96 ++-- .../custom-tools/application/authorization.ts | 13 + .../custom-tools/application/operations.ts | 68 +++ .../application/use-cases.test.ts | 187 +++++++ .../lib/custom-tools/application/use-cases.ts | 385 ++++++++++++++ apps/sim/lib/mcp/application/authorization.ts | 13 + apps/sim/lib/mcp/application/operations.ts | 55 ++ .../sim/lib/mcp/application/use-cases.test.ts | 163 ++++++ apps/sim/lib/mcp/application/use-cases.ts | 372 ++++++++++++++ apps/sim/lib/mcp/orchestration/index.ts | 5 + .../orchestration/server-lifecycle.test.ts | 11 + .../lib/mcp/orchestration/server-lifecycle.ts | 303 +++++++---- apps/sim/lib/mcp/queries.ts | 11 +- .../sim/lib/secrets/application/operations.ts | 26 + .../lib/secrets/application/use-cases.test.ts | 146 ++++++ apps/sim/lib/secrets/application/use-cases.ts | 232 +++++++++ .../lib/skills/application/authorization.ts | 13 + apps/sim/lib/skills/application/operations.ts | 50 ++ .../lib/skills/application/use-cases.test.ts | 140 +++++ apps/sim/lib/skills/application/use-cases.ts | 204 ++++++++ apps/sim/lib/skills/orchestration/index.ts | 3 + .../skills/orchestration/skill-lifecycle.ts | 156 ++++-- .../lib/workflows/custom-tools/operations.ts | 42 +- apps/sim/lib/workflows/skills/operations.ts | 13 +- 53 files changed, 4376 insertions(+), 3008 deletions(-) create mode 100644 apps/sim/lib/copilot/application/execute-custom-tool-use-case.ts create mode 100644 apps/sim/lib/copilot/application/execute-mcp-server-use-case.ts create mode 100644 apps/sim/lib/copilot/application/execute-skill-use-case.ts create mode 100644 apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts create mode 100644 apps/sim/lib/copilot/application/execute-workspace-use-case.ts create mode 100644 apps/sim/lib/copilot/auth/workspace-application-delegation.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts create mode 100644 apps/sim/lib/custom-tools/application/authorization.ts create mode 100644 apps/sim/lib/custom-tools/application/operations.ts create mode 100644 apps/sim/lib/custom-tools/application/use-cases.test.ts create mode 100644 apps/sim/lib/custom-tools/application/use-cases.ts create mode 100644 apps/sim/lib/mcp/application/authorization.ts create mode 100644 apps/sim/lib/mcp/application/operations.ts create mode 100644 apps/sim/lib/mcp/application/use-cases.test.ts create mode 100644 apps/sim/lib/mcp/application/use-cases.ts create mode 100644 apps/sim/lib/secrets/application/operations.ts create mode 100644 apps/sim/lib/secrets/application/use-cases.test.ts create mode 100644 apps/sim/lib/secrets/application/use-cases.ts create mode 100644 apps/sim/lib/skills/application/authorization.ts create mode 100644 apps/sim/lib/skills/application/operations.ts create mode 100644 apps/sim/lib/skills/application/use-cases.test.ts create mode 100644 apps/sim/lib/skills/application/use-cases.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 5619a64b1cd..269daa147be 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 @@ -1,308 +1,163 @@ /** * @vitest-environment node - * - * Public v2 custom tool detail: the per-id get/update/delete the internal - * surface never had, and the rename guard that keeps a duplicate title from - * reaching the unique index. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetWorkspaceCustomTool, - mockGetWorkspaceCustomToolByTitle, - mockDeleteWorkspaceCustomTool, - mockUpdateWorkspaceCustomTool, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetWorkspaceCustomTool: vi.fn(), - mockGetWorkspaceCustomToolByTitle: vi.fn(), - mockDeleteWorkspaceCustomTool: vi.fn(), - mockUpdateWorkspaceCustomTool: vi.fn(), -})) +const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { + class MockV2ApiKeyUnauthenticatedError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + get: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + }, + MockV2ApiKeyUnauthenticatedError, + } +}) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, })) - -vi.mock('@/lib/workflows/custom-tools/operations', () => ({ - getWorkspaceCustomTool: mockGetWorkspaceCustomTool, - getWorkspaceCustomToolByTitle: mockGetWorkspaceCustomToolByTitle, - deleteWorkspaceCustomTool: mockDeleteWorkspaceCustomTool, - updateWorkspaceCustomTool: mockUpdateWorkspaceCustomTool, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: vi.fn().mockReturnValue({ + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 60_000, + }), })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/custom-tools/application/use-cases', () => ({ + getWorkspaceCustomToolUseCase: { operation: { id: 'custom_tools.read' }, execute: mocks.get }, + updateWorkspaceCustomToolUseCase: { + operation: { id: 'custom_tools.update' }, + execute: mocks.update, + }, + deleteWorkspaceCustomToolUseCase: { + operation: { id: 'custom_tools.delete' }, + execute: mocks.remove, + }, })) import { DELETE, GET, PATCH } from '@/app/api/v2/custom-tools/[id]/route' +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' } +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} const RATE_LIMIT_OK = { allowed: true, - userId: 'user-1', - keyType: 'workspace', limit: 100, remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, + resetAt: new Date('2026-01-01T00:00:00Z'), + retryAfterMs: 0, } - -const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } - -const TOOL_SCHEMA = { - type: 'function', - function: { - name: 'lookup_order', - parameters: { type: 'object', properties: { orderId: { type: 'string' } } }, +const tool = { + id: 'tool-1', + workspaceId: WORKSPACE_ID, + userId: 'owner-1', + title: 'lookup_order', + schema: { + type: 'function', + function: { name: 'lookup_order', parameters: { type: 'object', properties: {} } }, }, + code: 'return { ok: true }', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), } - -function buildTool(overrides: Record<string, unknown> = {}) { - return { - id: 'tool_abc123', - workspaceId: 'workspace-1', - userId: 'user-1', - title: 'lookup_order', - schema: TOOL_SCHEMA, - code: 'return { ok: true }', - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - ...overrides, - } -} - -const routeContext = () => ({ params: Promise.resolve({ id: 'tool_abc123' }) }) -const url = (query = 'workspaceId=workspace-1') => - `http://localhost:3000/api/v2/custom-tools/tool_abc123?${query}` - -const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) -const callDelete = (query?: string) => - DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) - -function callPatch(body: unknown) { - return PATCH( - new NextRequest('http://localhost:3000/api/v2/custom-tools/tool_abc123', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - routeContext() +const context = { params: Promise.resolve({ id: tool.id }) } + +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) }), + } ) } -describe('GET /api/v2/custom-tools/[id]', () => { +describe('/api/v2/custom-tools/[id]', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetWorkspaceCustomTool.mockResolvedValue(buildTool()) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockGetWorkspaceCustomTool).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callGet('') - expect(res.status).toBe(400) - expect(mockGetWorkspaceCustomTool).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callGet() - expect(res.status).toBe(403) - expect(mockGetWorkspaceCustomTool).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callGet() - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('404s when the tool is not in the workspace', async () => { - mockGetWorkspaceCustomTool.mockResolvedValue(null) - const res = await callGet() - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - }) - - it('returns the public tool shape without internal scoping columns', async () => { - const res = await callGet() - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data.customTool).toEqual({ - id: 'tool_abc123', - title: 'lookup_order', - schema: TOOL_SCHEMA, - code: 'return { ok: true }', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - }) - expect(mockGetWorkspaceCustomTool).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - toolId: 'tool_abc123', + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.get.mockResolvedValue({ tool }) + mocks.update.mockResolvedValue({ tool }) + mocks.remove.mockResolvedValue({ tool }) + }) + + it('gets a custom tool through its semantic read operation', async () => { + const response = await GET(request('GET'), context) + + expect(response.status).toBe(200) + expect(mocks.get).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, toolId: tool.id }, + request: expect.anything(), }) }) -}) - -describe('PATCH /api/v2/custom-tools/[id]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetWorkspaceCustomTool.mockResolvedValue(buildTool()) - mockGetWorkspaceCustomToolByTitle.mockResolvedValue(null) - mockUpdateWorkspaceCustomTool.mockResolvedValue(buildTool()) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) - - expect(res.status).toBe(404) - expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() - }) - - it('400s when no field to change is supplied', async () => { - const res = await callPatch({ workspaceId: 'workspace-1' }) - expect(res.status).toBe(400) - expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) - expect(res.status).toBe(403) - expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - it('404s when the tool is not in the workspace', async () => { - mockGetWorkspaceCustomTool.mockResolvedValue(null) - const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 1' }) - expect(res.status).toBe(404) - expect(mockUpdateWorkspaceCustomTool).not.toHaveBeenCalled() - }) - - it('409s when renaming onto an existing title', async () => { - mockGetWorkspaceCustomToolByTitle.mockResolvedValue(buildTool({ id: 'tool_other' })) - - const res = await callPatch({ workspaceId: 'workspace-1', title: 'taken' }) - - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - expect(mockUpdateWorkspaceCustomTool).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' }), + context + ) - it('merges the partial body against the stored tool', async () => { - const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 2' }) - - expect(res.status).toBe(200) - expect(mockUpdateWorkspaceCustomTool).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - toolId: 'tool_abc123', - title: 'lookup_order', - schema: TOOL_SCHEMA, - code: 'return 2', + expect(response.status).toBe(200) + expect(mocks.update).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, toolId: tool.id, code: 'return 2', source: 'api' }, + request: expect.anything(), }) }) - it('404s rather than orphaning a tool deleted between the read and the write', async () => { - mockUpdateWorkspaceCustomTool.mockResolvedValue(null) - - const res = await callPatch({ workspaceId: 'workspace-1', code: 'return 2' }) - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - }) -}) - -describe('DELETE /api/v2/custom-tools/[id]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetWorkspaceCustomTool.mockResolvedValue(buildTool()) - mockDeleteWorkspaceCustomTool.mockResolvedValue(true) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + it('deletes a custom tool through its semantic delete operation', async () => { + const response = await DELETE(request('DELETE'), context) - const res = await callDelete() - - expect(res.status).toBe(404) - expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callDelete('') - expect(res.status).toBe(400) - expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callDelete() - expect(res.status).toBe(403) - expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: tool.id, deleted: true } }) + expect(mocks.remove).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, toolId: tool.id, source: 'api' }, + request: expect.anything(), + }) }) - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callDelete() - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) + it('authenticates before validating an empty patch body', async () => { + mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) - it('404s when the tool is not in the workspace', async () => { - mockGetWorkspaceCustomTool.mockResolvedValue(null) - const res = await callDelete() - expect(res.status).toBe(404) - expect(mockDeleteWorkspaceCustomTool).not.toHaveBeenCalled() - }) + const response = await PATCH(request('PATCH', {}), context) - it('deletes the tool and acknowledges the id', async () => { - const res = await callDelete() - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ data: { id: 'tool_abc123', deleted: true } }) - expect(mockDeleteWorkspaceCustomTool).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - toolId: 'tool_abc123', - }) + expect(response.status).toBe(401) + expect(mocks.update).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.ts index a20c9933873..6e04d53abc7 100644 --- a/apps/sim/app/api/v2/custom-tools/[id]/route.ts +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.ts @@ -1,133 +1,65 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { v2DeleteCustomToolContract, v2GetCustomToolContract, v2UpdateCustomToolContract, } from '@/lib/api/contracts/v2/custom-tools' import { - deleteWorkspaceCustomTool, - getWorkspaceCustomTool, - getWorkspaceCustomToolByTitle, - updateWorkspaceCustomTool, -} from '@/lib/workflows/custom-tools/operations' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils' -import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { customToolOperations } from '@/lib/custom-tools/application/operations' +import { + deleteWorkspaceCustomToolUseCase, + getWorkspaceCustomToolUseCase, + updateWorkspaceCustomToolUseCase, +} from '@/lib/custom-tools/application/use-cases' +import { toV2CustomTool } from '@/app/api/v2/custom-tools/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -interface RouteContext { - params: Promise<{ id: string }> -} - /** GET /api/v2/custom-tools/[id] — Fetch a single custom tool. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetCustomToolContract, - rateLimitEndpoint: 'custom-tool-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { id } = input.params - const { workspaceId } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const tool = await getWorkspaceCustomTool({ workspaceId, toolId: id }) - if (!tool) return v2Error('NOT_FOUND', 'Custom tool not found') - - return v2Data({ customTool: toV2CustomTool(tool) }, { rateLimit }) - }, + operation: customToolOperations.read, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, toolId: params.id }), + useCase: getWorkspaceCustomToolUseCase, + present: ({ tool }) => ({ data: { customTool: toV2CustomTool(tool) } }), }) -/** PATCH /api/v2/custom-tools/[id] — Update a custom tool. Omitted fields keep their values. */ -export const PATCH = withPublicApiRouteHandler({ +/** PATCH /api/v2/custom-tools/[id] — Update a custom tool. */ +export const PATCH = defineV2JsonRoute({ contract: v2UpdateCustomToolContract, - rateLimitEndpoint: 'custom-tool-detail', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - try { - const { id } = input.params - const { workspaceId, title, schema, code } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const current = await getWorkspaceCustomTool({ workspaceId, toolId: id }) - if (!current) return v2Error('NOT_FOUND', 'Custom tool not found') - - /** - * `upsertCustomTools` replaces title/schema/code wholesale and checks for a - * duplicate title only when inserting, so a rename onto an existing title - * would hit the `custom_tools_workspace_title_unique` index as a 500. Merge - * the partial body against the stored row and check the rename here. - */ - if (title !== undefined && title !== current.title) { - if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) { - return v2Error( - 'CONFLICT', - `A custom tool titled "${title}" already exists in this workspace` - ) - } - } - - const updated = await updateWorkspaceCustomTool({ - workspaceId, - toolId: id, - title: title ?? current.title, - schema: schema ?? current.schema, - code: code ?? current.code, - }) - if (!updated) return v2Error('NOT_FOUND', 'Custom tool not found') - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.CUSTOM_TOOL_UPDATED, - resourceType: AuditResourceType.CUSTOM_TOOL, - resourceId: updated.id, - resourceName: updated.title, - description: `Updated custom tool "${updated.title}" via API`, - request, - }) - - return v2Data({ customTool: toV2CustomTool(updated) }, { rateLimit }) - } catch (error) { - const writeError = v2CustomToolWriteError(error) - if (writeError) return writeError - - throw error - } - }, + operation: customToolOperations.update, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + ...body, + toolId: params.id, + source: 'api' as const, + }), + useCase: updateWorkspaceCustomToolUseCase, + present: ({ tool }) => ({ data: { customTool: toV2CustomTool(tool) } }), }) /** DELETE /api/v2/custom-tools/[id] — Delete a custom tool. */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteCustomToolContract, - rateLimitEndpoint: 'custom-tool-detail', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { id } = input.params - const { workspaceId } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const tool = await getWorkspaceCustomTool({ workspaceId, toolId: id }) - if (!tool) return v2Error('NOT_FOUND', 'Custom tool not found') - - const deleted = await deleteWorkspaceCustomTool({ workspaceId, toolId: id }) - if (!deleted) return v2Error('NOT_FOUND', 'Custom tool not found') - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.CUSTOM_TOOL_DELETED, - resourceType: AuditResourceType.CUSTOM_TOOL, - resourceId: id, - resourceName: tool.title, - description: `Deleted custom tool "${tool.title}" via API`, - request, - }) - - return v2Data({ id, deleted: true as const }, { rateLimit }) - }, + operation: customToolOperations.delete, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + workspaceId: query.workspaceId, + toolId: params.id, + source: 'api' as const, + }), + useCase: deleteWorkspaceCustomToolUseCase, + present: ({ tool }) => ({ data: { id: tool.id, deleted: true as const } }), }) 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 7ca46e81b0c..a3ee031338f 100644 --- a/apps/sim/app/api/v2/custom-tools/route.test.ts +++ b/apps/sim/app/api/v2/custom-tools/route.test.ts @@ -1,302 +1,178 @@ /** * @vitest-environment node - * - * Public v2 custom tools list/create: gate ordering, contract validation, and - * the workspace-scoped single-resource create that replaced the bulk upsert. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockListWorkspaceCustomTools, - mockGetWorkspaceCustomToolByTitle, - mockUpsertCustomTools, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockListWorkspaceCustomTools: vi.fn(), - mockGetWorkspaceCustomToolByTitle: vi.fn(), - mockUpsertCustomTools: vi.fn(), -})) +const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { + class MockV2ApiKeyUnauthenticatedError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + list: vi.fn(), + create: vi.fn(), + }, + MockV2ApiKeyUnauthenticatedError, + } +}) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, })) - -vi.mock('@/lib/workflows/custom-tools/operations', () => ({ - listWorkspaceCustomTools: mockListWorkspaceCustomTools, - getWorkspaceCustomToolByTitle: mockGetWorkspaceCustomToolByTitle, - upsertCustomTools: mockUpsertCustomTools, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: vi.fn().mockReturnValue({ + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 60_000, + }), })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/custom-tools/application/use-cases', () => ({ + listWorkspaceCustomToolsUseCase: { + operation: { id: 'custom_tools.list' }, + execute: mocks.list, + }, + createWorkspaceCustomToolUseCase: { + operation: { id: 'custom_tools.create' }, + execute: mocks.create, + }, })) import { GET, POST } from '@/app/api/v2/custom-tools/route' +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' } +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} const RATE_LIMIT_OK = { allowed: true, - userId: 'user-1', - keyType: 'workspace', limit: 100, remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, + resetAt: new Date('2026-01-01T00:00:00Z'), + retryAfterMs: 0, } - const TOOL_SCHEMA = { type: 'function', function: { name: 'lookup_order', - description: 'Look up an order by id', - parameters: { - type: 'object', - properties: { orderId: { type: 'string' } }, - required: ['orderId'], - }, + parameters: { type: 'object', properties: {} }, }, } - -function buildTool(overrides: Record<string, unknown> = {}) { - return { - id: 'tool_abc123', - workspaceId: 'workspace-1', - userId: 'user-1', - title: 'lookup_order', - schema: TOOL_SCHEMA, - code: 'return { ok: true }', - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - ...overrides, - } -} - -/** What the route forwards for a bare `?workspaceId=` list. */ -const DEFAULT_LIST_ARGS = { - search: undefined, - sortBy: 'createdAt', - sortOrder: 'desc', -} - -const callList = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/custom-tools?${query}`)) - -function callCreate(body: unknown) { - return POST( - new NextRequest('http://localhost:3000/api/v2/custom-tools', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - ) -} - -const VALID_BODY = { - workspaceId: 'workspace-1', +const tool = { + id: 'tool-1', + workspaceId: WORKSPACE_ID, + userId: 'owner-1', title: 'lookup_order', schema: TOOL_SCHEMA, code: 'return { ok: true }', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), } -describe('GET /api/v2/custom-tools', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockListWorkspaceCustomTools.mockResolvedValue([buildTool()]) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callList('workspaceId=workspace-1') - - expect(res.status).toBe(404) - expect(mockListWorkspaceCustomTools).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callList('') - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockListWorkspaceCustomTools).not.toHaveBeenCalled() +function request(method: 'GET' | 'POST', url: string, body?: unknown) { + return new NextRequest(`http://localhost:3000${url}`, { + method, + headers: { + 'x-api-key': 'key', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), }) +} - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', +describe('/api/v2/custom-tools', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.list.mockResolvedValue({ tools: [tool] }) + mocks.create.mockResolvedValue({ tool }) + }) + + it('lists custom tools through the authorized application use case', async () => { + const response = await GET(request('GET', `/api/v2/custom-tools?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(200) + expect((await response.json()).data[0]).toMatchObject({ id: 'tool-1', title: 'lookup_order' }) + expect(mocks.list).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + search: undefined, + sortBy: 'createdAt', + sortOrder: 'desc', + }, + request: expect.anything(), }) - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(403) - expect(mockListWorkspaceCustomTools).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') + expect(mocks.operationRate).toHaveBeenCalledWith( + 'v2:custom_tools.list:workspace:workspace-1', + expect.objectContaining({ maxTokens: 100 }) + ) }) - it('returns the public tool shape in the cursor envelope, workspace-scoped', async () => { - const res = await callList('workspaceId=workspace-1') - const body = await res.json() + it('creates exactly one custom tool with the v2 source and status', async () => { + const response = await POST( + request('POST', '/api/v2/custom-tools', { + workspaceId: WORKSPACE_ID, + title: tool.title, + schema: TOOL_SCHEMA, + code: tool.code, + }) + ) - expect(res.status).toBe(200) - expect(body.nextCursor).toBeNull() - expect(body.data).toEqual([ - { - id: 'tool_abc123', - title: 'lookup_order', + expect(response.status).toBe(201) + expect((await response.json()).data.customTool.id).toBe('tool-1') + expect(mocks.create).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + title: tool.title, schema: TOOL_SCHEMA, - code: 'return { ok: true }', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', + code: tool.code, + source: 'api', }, - ]) - expect(mockListWorkspaceCustomTools).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - ...DEFAULT_LIST_ARGS, - }) - }) - it('400s on a sort field outside the enum instead of letting it reach the query', async () => { - const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) - - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - }) - - it('400s on a sort direction outside the enum', async () => { - const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) - - expect(res.status).toBe(400) - }) - - it('400s on an empty search rather than treating it as unsearched', async () => { - const res = await callList(`workspaceId=workspace-1&search=`) - - expect(res.status).toBe(400) - }) - - it('forwards search and sort into the query and still terminates pagination', async () => { - const res = await callList(`workspaceId=workspace-1&search=report&sortBy=title&sortOrder=asc`) - - expect(res.status).toBe(200) - expect((await res.json()).nextCursor).toBeNull() - }) -}) - -describe('POST /api/v2/custom-tools', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetWorkspaceCustomToolByTitle.mockResolvedValue(null) - mockUpsertCustomTools.mockResolvedValue([buildTool()]) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callCreate(VALID_BODY) - - expect(res.status).toBe(404) - expect(mockUpsertCustomTools).not.toHaveBeenCalled() - }) - - it('400s when the schema is not an OpenAI function declaration', async () => { - const res = await callCreate({ ...VALID_BODY, schema: { type: 'nonsense' } }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockUpsertCustomTools).not.toHaveBeenCalled() - }) - - it('400s when the body carries an unknown field', async () => { - const res = await callCreate({ ...VALID_BODY, bogus: true }) - expect(res.status).toBe(400) - expect(mockUpsertCustomTools).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + request: expect.anything(), }) - const res = await callCreate(VALID_BODY) - expect(res.status).toBe(403) - expect(mockUpsertCustomTools).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callCreate(VALID_BODY) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') }) - it('409s on a duplicate title instead of hitting the unique index', async () => { - mockGetWorkspaceCustomToolByTitle.mockResolvedValue(buildTool()) + it('authenticates before validating a malformed create body', async () => { + mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) - const res = await callCreate(VALID_BODY) + const response = await POST(request('POST', '/api/v2/custom-tools', {})) - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - expect(mockUpsertCustomTools).not.toHaveBeenCalled() + expect(response.status).toBe(401) + expect(mocks.create).not.toHaveBeenCalled() }) - it('409s when a concurrent create loses the title race inside the lib', async () => { - mockUpsertCustomTools.mockRejectedValue( - new Error('A tool with the title "v2_smoke_tool" already exists in this workspace') + it('rejects invalid list sort fields before application execution', async () => { + const response = await GET( + request('GET', `/api/v2/custom-tools?workspaceId=${WORKSPACE_ID}&sortBy=invalid`) ) - const res = await callCreate(VALID_BODY) - - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - }) - - it('409s when the unique index rejects the loser of a title race', async () => { - const pgError = Object.assign(new Error('duplicate key value violates unique constraint'), { - code: '23505', - }) - mockUpsertCustomTools.mockRejectedValue(pgError) - - const res = await callCreate(VALID_BODY) - - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - }) - - it('creates the tool and returns 201 with the single tool', async () => { - const res = await callCreate(VALID_BODY) - const body = await res.json() - - expect(res.status).toBe(201) - expect(body.data.customTool).toMatchObject({ id: 'tool_abc123', title: 'lookup_order' }) - expect(mockUpsertCustomTools).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: 'workspace-1', - userId: 'user-1', - tools: [{ title: 'lookup_order', schema: TOOL_SCHEMA, code: 'return { ok: true }' }], - }) - ) + expect(response.status).toBe(400) + expect(mocks.list).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/custom-tools/route.ts b/apps/sim/app/api/v2/custom-tools/route.ts index 37899f53a00..688aeba700e 100644 --- a/apps/sim/app/api/v2/custom-tools/route.ts +++ b/apps/sim/app/api/v2/custom-tools/route.ts @@ -1,88 +1,43 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { v2CreateCustomToolContract, v2ListCustomToolsContract, } from '@/lib/api/contracts/v2/custom-tools' import { - getWorkspaceCustomToolByTitle, - listWorkspaceCustomTools, - upsertCustomTools, -} from '@/lib/workflows/custom-tools/operations' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils' -import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { customToolOperations } from '@/lib/custom-tools/application/operations' +import { + createWorkspaceCustomToolUseCase, + listWorkspaceCustomToolsUseCase, +} from '@/lib/custom-tools/application/use-cases' +import { toV2CustomTool } from '@/app/api/v2/custom-tools/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/custom-tools — List custom tools in a workspace. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListCustomToolsContract, - rateLimitEndpoint: 'custom-tools', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, search, sortBy, sortOrder } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const rows = await listWorkspaceCustomTools({ workspaceId, search, sortBy, sortOrder }) - - // The per-workspace tool set is small and bounded → a single full page. - return v2CursorList(rows.map(toV2CustomTool), null, { rateLimit }) - }, + operation: customToolOperations.list, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => query, + useCase: listWorkspaceCustomToolsUseCase, + present: ({ tools }) => ({ data: tools.map(toV2CustomTool), nextCursor: null }), }) /** POST /api/v2/custom-tools — Create a custom tool. */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateCustomToolContract, - rateLimitEndpoint: 'custom-tools', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - try { - const { workspaceId, title, schema, code } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - /** - * Titles are unique per workspace and tools resolve by title at call time, - * so a collision is reported rather than surfacing as a unique-index 500. - */ - if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) { - return v2Error( - 'CONFLICT', - `A custom tool titled "${title}" already exists in this workspace` - ) - } - - const tools = await upsertCustomTools({ - tools: [{ title, schema, code }], - workspaceId, - userId, - requestId, - }) - const created = tools.find((tool) => tool.title === title) - if (!created) { - throw new Error(`Custom tool "${title}" missing after a successful write`) - } - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.CUSTOM_TOOL_CREATED, - resourceType: AuditResourceType.CUSTOM_TOOL, - resourceId: created.id, - resourceName: created.title, - description: `Created custom tool "${created.title}" via API`, - request, - }) - - return v2Data({ customTool: toV2CustomTool(created) }, { rateLimit, status: 201 }) - } catch (error) { - const writeError = v2CustomToolWriteError(error) - if (writeError) return writeError - - throw error - } - }, + operation: customToolOperations.create, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ body }) => ({ ...body, source: 'api' as const }), + useCase: createWorkspaceCustomToolUseCase, + present: ({ tool }) => ({ data: { customTool: toV2CustomTool(tool) } }), }) 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 1d5b93a53de..7aba50ef58a 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 @@ -1,350 +1,181 @@ /** * @vitest-environment node - * - * Public v2 MCP server detail: gate ordering, contract validation, workspace - * access, and the thin-wrapper mapping onto `lib/mcp/orchestration`. */ +import type { mcpServers } from '@sim/db/schema' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { McpServerRow } from '@/lib/mcp/queries' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetWorkspaceMcpServer, - mockPerformUpdateMcpServer, - mockPerformDeleteMcpServer, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetWorkspaceMcpServer: vi.fn(), - mockPerformUpdateMcpServer: vi.fn(), - mockPerformDeleteMcpServer: vi.fn(), -})) +const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { + class MockV2ApiKeyUnauthenticatedError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + get: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + capture: vi.fn(), + }, + MockV2ApiKeyUnauthenticatedError, + } +}) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, })) - -vi.mock('@/lib/mcp/queries', () => ({ - getWorkspaceMcpServer: mockGetWorkspaceMcpServer, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: vi.fn().mockReturnValue({ + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 60_000, + }), })) - -vi.mock('@/lib/mcp/orchestration', () => ({ - performUpdateMcpServer: mockPerformUpdateMcpServer, - performDeleteMcpServer: mockPerformDeleteMcpServer, +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) +vi.mock('@/lib/mcp/application/use-cases', () => ({ + getMcpServerUseCase: { operation: { id: 'mcp_servers.read' }, execute: mocks.get }, + updateMcpServerUseCase: { operation: { id: 'mcp_servers.update' }, execute: mocks.update }, + deleteMcpServerUseCase: { operation: { id: 'mcp_servers.delete' }, execute: mocks.remove }, })) import { DELETE, GET, PATCH } from '@/app/api/v2/mcp-servers/[id]/route' +type McpServerRow = typeof mcpServers.$inferSelect +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' } +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} const RATE_LIMIT_OK = { allowed: true, - userId: 'user-1', - keyType: 'workspace', limit: 100, remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, + resetAt: new Date('2026-01-01T00:00:00Z'), + retryAfterMs: 0, } - -const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } - -function buildRow(overrides: Partial<McpServerRow> = {}): McpServerRow { - return { - id: 'mcp-abc12345', - workspaceId: 'workspace-1', - createdBy: 'user-1', - name: 'Docs server', - description: null, - transport: 'streamable-http', - url: 'https://mcp.example.com/sse', - authType: 'headers', - oauthClientId: null, - oauthClientSecret: 'encrypted-secret', - headers: { Authorization: 'Bearer super-secret-token' }, - timeout: 30000, - retries: 3, - enabled: true, - lastConnected: null, - connectionStatus: 'disconnected', - lastError: null, - statusConfig: {}, - toolCount: 0, - lastToolsRefresh: null, - totalRequests: 0, - lastUsed: null, - deletedAt: null, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - ...overrides, - } as McpServerRow -} - -const routeContext = () => ({ params: Promise.resolve({ id: 'mcp-abc12345' }) }) - -const url = (query = 'workspaceId=workspace-1') => - `http://localhost:3000/api/v2/mcp-servers/mcp-abc12345?${query}` - -function callGet(query?: string) { - return GET(new NextRequest(url(query)), routeContext()) -} - -function callPatch(body: unknown) { - return PATCH( - new NextRequest('http://localhost:3000/api/v2/mcp-servers/mcp-abc12345', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - routeContext() +const server = { + id: 'mcp-server-1', + workspaceId: WORKSPACE_ID, + createdBy: 'owner-1', + name: 'Docs server', + description: null, + transport: 'streamable-http', + url: 'https://mcp.example.com/sse', + authType: 'headers', + oauthClientId: null, + oauthClientSecret: null, + headers: {}, + timeout: 30_000, + retries: 3, + enabled: true, + lastConnected: null, + connectionStatus: 'connected', + lastError: null, + statusConfig: {}, + toolCount: 0, + lastToolsRefresh: null, + totalRequests: 0, + lastUsed: null, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} as McpServerRow +const context = { params: Promise.resolve({ id: server.id }) } + +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) }), + } ) } -function callDelete(query?: string) { - return DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) -} - -describe('GET /api/v2/mcp-servers/[id]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callGet() - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockGetWorkspaceMcpServer).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callGet('') - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockGetWorkspaceMcpServer).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callGet() - expect(res.status).toBe(403) - expect(mockGetWorkspaceMcpServer).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callGet() - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('404s when the server does not exist in the workspace', async () => { - mockGetWorkspaceMcpServer.mockResolvedValue(null) - const res = await callGet() - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - }) - - it('returns the public server shape without header values', async () => { - const res = await callGet() - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data.mcpServer).toMatchObject({ - id: 'mcp-abc12345', - hasHeaders: true, - headerNames: ['Authorization'], - hasOauthClientSecret: true, - }) - expect(JSON.stringify(body)).not.toContain('super-secret-token') - expect(JSON.stringify(body)).not.toContain('encrypted-secret') - expect(mockGetWorkspaceMcpServer).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - serverId: 'mcp-abc12345', - }) - }) -}) - -describe('PATCH /api/v2/mcp-servers/[id]', () => { +describe('/api/v2/mcp-servers/[id]', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformUpdateMcpServer.mockResolvedValue({ success: true, server: buildRow() }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) - - expect(res.status).toBe(404) - expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() - }) - - it('400s when the body has an unknown field', async () => { - const res = await callPatch({ workspaceId: 'workspace-1', bogus: true }) - expect(res.status).toBe(400) - expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() - }) - - it('400s when the url carries an environment-variable template', async () => { - const res = await callPatch({ workspaceId: 'workspace-1', url: 'https://{{HOST}}/sse' }) - expect(res.status).toBe(400) - expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) - expect(res.status).toBe(403) - expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('maps a not_found orchestration failure to 404', async () => { - mockPerformUpdateMcpServer.mockResolvedValue({ - success: false, - error: 'Server not found', - errorCode: 'not_found', + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.get.mockResolvedValue({ server }) + mocks.update.mockResolvedValue({ server }) + mocks.remove.mockResolvedValue({ server }) + }) + + it('gets an MCP server through the semantic read operation', async () => { + const response = await GET(request('GET'), context) + + expect(response.status).toBe(200) + expect(mocks.get).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, serverId: server.id }, + request: expect.anything(), }) - const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed' }) - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') }) - it('400s when the url is changed, since the id is derived from it', async () => { - mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) + it('updates an MCP server through the strict semantic update operation', async () => { + const response = await PATCH( + request('PATCH', { workspaceId: WORKSPACE_ID, name: 'New docs' }), + context + ) - const res = await callPatch({ - workspaceId: 'workspace-1', - url: 'https://different.example.com/sse', + expect(response.status).toBe(200) + expect(mocks.update).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + serverId: server.id, + name: 'New docs', + source: 'api', + }, + request: expect.anything(), }) - - expect(res.status).toBe(400) - expect((await res.json()).error.message).toContain('url cannot be changed') - expect(mockPerformUpdateMcpServer).not.toHaveBeenCalled() }) - it('allows a url that matches the stored one, so a full-object PATCH still works', async () => { - mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) + it('deletes an MCP server without product analytics for workspace keys', async () => { + const response = await DELETE(request('DELETE'), context) - const res = await callPatch({ - workspaceId: 'workspace-1', - url: 'https://mcp.example.com/sse', - enabled: false, + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: server.id, deleted: true } }) + expect(mocks.remove).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, serverId: server.id, source: 'api' }, + request: expect.anything(), }) - - expect(res.status).toBe(200) - expect(mockPerformUpdateMcpServer).toHaveBeenCalled() + expect(mocks.capture).not.toHaveBeenCalled() }) - it('updates the server and returns the public shape', async () => { - const res = await callPatch({ workspaceId: 'workspace-1', name: 'Renamed', enabled: false }) - const body = await res.json() + it('authenticates before parsing an invalid update body', async () => { + mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) - expect(res.status).toBe(200) - expect(body.data.mcpServer.id).toBe('mcp-abc12345') - expect(body.data.mcpServer.headers).toBeUndefined() - expect(mockPerformUpdateMcpServer).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: 'workspace-1', - userId: 'user-1', - serverId: 'mcp-abc12345', - name: 'Renamed', - enabled: false, - }) - ) - }) -}) - -describe('DELETE /api/v2/mcp-servers/[id]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformDeleteMcpServer.mockResolvedValue({ success: true, server: buildRow() }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callDelete() - - expect(res.status).toBe(404) - expect(mockPerformDeleteMcpServer).not.toHaveBeenCalled() - }) + const response = await PATCH(request('PATCH', {}), context) - it('400s when workspaceId is missing', async () => { - const res = await callDelete('') - expect(res.status).toBe(400) - expect(mockPerformDeleteMcpServer).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callDelete() - expect(res.status).toBe(403) - expect(mockPerformDeleteMcpServer).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callDelete() - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('maps a not_found orchestration failure to 404', async () => { - mockPerformDeleteMcpServer.mockResolvedValue({ - success: false, - error: 'Server not found', - errorCode: 'not_found', - }) - const res = await callDelete() - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - }) - - it('deletes the server and acknowledges the id', async () => { - const res = await callDelete() - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ data: { id: 'mcp-abc12345', deleted: true } }) - expect(mockPerformDeleteMcpServer).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: 'workspace-1', - userId: 'user-1', - serverId: 'mcp-abc12345', - }) - ) + expect(response.status).toBe(401) + expect(mocks.update).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts index 4e61d698811..3fcb02503bf 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts @@ -3,111 +3,72 @@ import { v2GetMcpServerContract, v2UpdateMcpServerContract, } from '@/lib/api/contracts/v2/mcp-servers' -import { performDeleteMcpServer, performUpdateMcpServer } from '@/lib/mcp/orchestration' -import { getWorkspaceMcpServer } from '@/lib/mcp/queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' -import { toV2McpServer, v2McpOrchestrationError } from '@/app/api/v2/mcp-servers/utils' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { mcpServerOperations } from '@/lib/mcp/application/operations' +import { + deleteMcpServerUseCase, + getMcpServerUseCase, + updateMcpServerUseCase, +} from '@/lib/mcp/application/use-cases' +import { captureServerEvent } from '@/lib/posthog/server' +import { toV2McpServer } from '@/app/api/v2/mcp-servers/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -interface RouteContext { - params: Promise<{ id: string }> -} - /** GET /api/v2/mcp-servers/[id] — Fetch a single MCP server. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetMcpServerContract, - rateLimitEndpoint: 'mcp-server-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { id } = input.params - const { workspaceId } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const server = await getWorkspaceMcpServer({ workspaceId, serverId: id }) - if (!server) return v2Error('NOT_FOUND', 'MCP server not found') - - return v2Data({ mcpServer: toV2McpServer(server) }, { rateLimit }) - }, + operation: mcpServerOperations.read, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, serverId: params.id }), + useCase: getMcpServerUseCase, + present: ({ server }) => ({ data: { mcpServer: toV2McpServer(server) } }), }) /** PATCH /api/v2/mcp-servers/[id] — Update an MCP server's configuration. */ -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2UpdateMcpServerContract, - rateLimitEndpoint: 'mcp-server-detail', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { id } = input.params - const { workspaceId, ...body } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - /** - * A server's id is the hash of its workspace + URL, and this surface promises - * that identity. The lib will happily move `url` while the id keeps hashing - * the old one, which both breaks that promise and defeats the duplicate - * check on create (id-keyed, so it would not see the moved URL) — leaving two - * rows on one URL. Re-pointing a server at a different URL is a new server. - */ - if (body.url !== undefined) { - const current = await getWorkspaceMcpServer({ workspaceId, serverId: id }) - if (!current) return v2Error('NOT_FOUND', 'MCP server not found') - if (current.url !== body.url) { - return v2Error( - 'BAD_REQUEST', - 'url cannot be changed: an MCP server’s id is derived from its URL. Delete this server and create one at the new URL.' - ) - } - } - - const result = await performUpdateMcpServer({ - workspaceId, - userId, - serverId: id, - name: body.name, - description: body.description, - transport: body.transport, - url: body.url, - headers: body.headers, - timeout: body.timeout, - retries: body.retries, - enabled: body.enabled, - authType: body.authType, - oauthClientId: body.oauthClientId ?? null, - oauthClientIdProvided: body.oauthClientId !== undefined, - oauthClientSecret: body.oauthClientSecret, - oauthClientSecretProvided: body.oauthClientSecret !== undefined, - request, - }) - - if (!result.success || !result.server) { - return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to update server') - } - - return v2Data({ mcpServer: toV2McpServer(result.server) }, { rateLimit }) - }, + operation: mcpServerOperations.update, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ ...body, serverId: params.id, source: 'api' as const }), + useCase: updateMcpServerUseCase, + present: ({ server }) => ({ data: { mcpServer: toV2McpServer(server) } }), }) /** DELETE /api/v2/mcp-servers/[id] — Remove an MCP server from the workspace. */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteMcpServerContract, - rateLimitEndpoint: 'mcp-server-detail', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { id } = input.params - const { workspaceId } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performDeleteMcpServer({ workspaceId, userId, serverId: id, request }) - if (!result.success) { - return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to delete server') - } - - return v2Data({ id, deleted: true as const }, { rateLimit }) + operation: mcpServerOperations.delete, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + workspaceId: query.workspaceId, + serverId: params.id, + source: 'api' as const, + }), + useCase: deleteMcpServerUseCase, + onSuccess: ({ principal, input, result }) => { + if (principal.kind !== 'personal_api_key') return + captureServerEvent( + principal.userId, + 'mcp_server_disconnected', + { + workspace_id: input.workspaceId, + server_name: result.server.name, + }, + { groups: { workspace: input.workspaceId } } + ) }, + present: ({ server }) => ({ data: { id: server.id, deleted: true as const } }), }) 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 cb0df3ac683..cf158e4cec2 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.test.ts @@ -1,365 +1,197 @@ /** * @vitest-environment node - * - * Public v2 MCP servers list/create: gate ordering, contract validation, the - * write-only `headers` projection, and the 409-on-duplicate-URL departure from - * the internal upsert. */ +import type { mcpServers } from '@sim/db/schema' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { McpServerRow } from '@/lib/mcp/queries' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockListWorkspaceMcpServers, - mockGetWorkspaceMcpServer, - mockGetMcpServerIdState, - mockPerformCreateMcpServer, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockListWorkspaceMcpServers: vi.fn(), - mockGetWorkspaceMcpServer: vi.fn(), - mockGetMcpServerIdState: vi.fn(), - mockPerformCreateMcpServer: vi.fn(), -})) +const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { + class MockV2ApiKeyUnauthenticatedError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + list: vi.fn(), + create: vi.fn(), + capture: vi.fn(), + }, + MockV2ApiKeyUnauthenticatedError, + } +}) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, })) - -vi.mock('@/lib/mcp/queries', () => ({ - listWorkspaceMcpServers: mockListWorkspaceMcpServers, - getWorkspaceMcpServer: mockGetWorkspaceMcpServer, - getMcpServerIdState: mockGetMcpServerIdState, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: vi.fn().mockReturnValue({ + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 60_000, + }), })) - -vi.mock('@/lib/mcp/orchestration', () => ({ - performCreateMcpServer: mockPerformCreateMcpServer, +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) +vi.mock('@/lib/mcp/application/use-cases', () => ({ + listMcpServersUseCase: { operation: { id: 'mcp_servers.list' }, execute: mocks.list }, + createMcpServerUseCase: { operation: { id: 'mcp_servers.create' }, execute: mocks.create }, })) import { GET, POST } from '@/app/api/v2/mcp-servers/route' +type McpServerRow = typeof mcpServers.$inferSelect +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' } +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} const RATE_LIMIT_OK = { allowed: true, - userId: 'user-1', - keyType: 'workspace', limit: 100, remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -function buildRow(overrides: Partial<McpServerRow> = {}): McpServerRow { - return { - id: 'mcp-abc12345', - workspaceId: 'workspace-1', - createdBy: 'user-1', - name: 'Docs server', - description: 'Internal docs', - transport: 'streamable-http', - url: 'https://mcp.example.com/sse', - authType: 'headers', - oauthClientId: null, - oauthClientSecret: null, - headers: { Authorization: 'Bearer super-secret-token' }, - timeout: 30000, - retries: 3, - enabled: true, - lastConnected: new Date('2024-01-02T00:00:00Z'), - connectionStatus: 'connected', - lastError: null, - statusConfig: {}, - toolCount: 4, - lastToolsRefresh: new Date('2024-01-02T00:00:00Z'), - totalRequests: 0, - lastUsed: null, - deletedAt: null, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - ...overrides, - } as McpServerRow + resetAt: new Date('2026-01-01T00:00:00Z'), + retryAfterMs: 0, } - -function callList(query: string) { - return GET(new NextRequest(`http://localhost:3000/api/v2/mcp-servers?${query}`)) -} - -function callCreate(body: unknown) { - return POST( - new NextRequest('http://localhost:3000/api/v2/mcp-servers', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - ) -} - -/** What the route forwards for a bare `?workspaceId=` list. */ -const DEFAULT_LIST_ARGS = { - search: undefined, - sortBy: 'createdAt', - sortOrder: 'desc', -} - -const VALID_BODY = { - workspaceId: 'workspace-1', +const server = { + id: 'mcp-server-1', + workspaceId: WORKSPACE_ID, + createdBy: 'owner-1', name: 'Docs server', + description: 'Internal docs', + transport: 'streamable-http', url: 'https://mcp.example.com/sse', + authType: 'headers', + oauthClientId: null, + oauthClientSecret: null, + headers: { Authorization: 'secret' }, + timeout: 30_000, + retries: 3, + enabled: true, + lastConnected: new Date('2026-01-02T00:00:00Z'), + connectionStatus: 'connected', + lastError: null, + statusConfig: {}, + toolCount: 4, + lastToolsRefresh: new Date('2026-01-02T00:00:00Z'), + totalRequests: 0, + lastUsed: null, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} as McpServerRow + +function request(method: 'GET' | 'POST', url: string, body?: unknown) { + return new NextRequest(`http://localhost:3000${url}`, { + method, + headers: { + 'x-api-key': 'key', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) } -describe('GET /api/v2/mcp-servers', () => { +describe('/api/v2/mcp-servers', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockListWorkspaceMcpServers.mockResolvedValue([buildRow()]) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callList('workspaceId=workspace-1') - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockListWorkspaceMcpServers).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callList('') - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockListWorkspaceMcpServers).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(403) - expect((await res.json()).error).toMatchObject({ code: 'FORBIDDEN', message: 'Access denied' }) - expect(mockListWorkspaceMcpServers).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue({ - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, - }) - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('returns the public server shape in the cursor envelope', async () => { - const res = await callList('workspaceId=workspace-1') - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.nextCursor).toBeNull() - expect(body.data).toEqual([ - { - id: 'mcp-abc12345', - name: 'Docs server', - description: 'Internal docs', - transport: 'streamable-http', - authType: 'headers', - url: 'https://mcp.example.com/sse', - timeout: 30000, - retries: 3, - enabled: true, - connectionStatus: 'connected', - lastError: null, - toolCount: 4, - lastToolsRefresh: '2024-01-02T00:00:00.000Z', - lastConnected: '2024-01-02T00:00:00.000Z', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - hasHeaders: true, - headerNames: ['Authorization'], - hasOauthClientSecret: false, + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.list.mockResolvedValue({ servers: [server] }) + mocks.create.mockResolvedValue({ server, updated: false }) + }) + + it('lists MCP servers without exposing secret header values', async () => { + const response = await GET(request('GET', `/api/v2/mcp-servers?workspaceId=${WORKSPACE_ID}`)) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data[0]).toMatchObject({ id: server.id, hasHeaders: true }) + expect(JSON.stringify(body)).not.toContain('secret') + expect(mocks.list).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + search: undefined, + sortBy: 'createdAt', + sortOrder: 'desc', }, - ]) - expect(mockListWorkspaceMcpServers).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - ...DEFAULT_LIST_ARGS, - }) - }) - - it('never returns configured header values', async () => { - const res = await callList('workspaceId=workspace-1') - const raw = JSON.stringify(await res.json()) - - expect(raw).not.toContain('super-secret-token') - expect(raw).not.toContain('"headers":') - }) - it('400s on a sort field outside the enum instead of letting it reach the query', async () => { - const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) - - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - }) - - it('400s on a sort direction outside the enum', async () => { - const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) - - expect(res.status).toBe(400) - }) - - it('400s on an empty search rather than treating it as unsearched', async () => { - const res = await callList(`workspaceId=workspace-1&search=`) - - expect(res.status).toBe(400) - }) - - it('forwards search and sort into the query and still terminates pagination', async () => { - const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`) - - expect(res.status).toBe(200) - expect((await res.json()).nextCursor).toBeNull() - }) -}) - -describe('POST /api/v2/mcp-servers', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetMcpServerIdState.mockResolvedValue(null) - mockPerformCreateMcpServer.mockResolvedValue({ - success: true, - serverId: 'mcp-abc12345', - updated: false, + request: expect.anything(), }) - mockGetWorkspaceMcpServer.mockResolvedValue(buildRow()) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callCreate(VALID_BODY) - - expect(res.status).toBe(404) - expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() - }) - - it('400s when the body is missing a required field', async () => { - const res = await callCreate({ workspaceId: 'workspace-1', name: 'Docs server' }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() - }) - - it('400s when the url carries an environment-variable template', async () => { - const res = await callCreate({ ...VALID_BODY, url: 'https://{{MCP_HOST}}/sse' }) - expect(res.status).toBe(400) - expect((await res.json()).error.message).toContain('{{ENV_VAR}}') - expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() }) - it('400s when the url is not an absolute http(s) URL', async () => { - const res = await callCreate({ ...VALID_BODY, url: 'file:///etc/passwd' }) - expect(res.status).toBe(400) - expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - const res = await callCreate(VALID_BODY) - expect(res.status).toBe(403) - expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() - }) + it('strictly creates an MCP server with the v2 source and status', async () => { + const response = await POST( + request('POST', '/api/v2/mcp-servers', { + workspaceId: WORKSPACE_ID, + name: server.name, + url: server.url, + }) + ) - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue({ - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, + expect(response.status).toBe(201) + expect(mocks.create).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + name: server.name, + url: server.url, + source: 'api', + }, + request: expect.anything(), }) - const res = await callCreate(VALID_BODY) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('409s on a duplicate URL without letting the lib upsert', async () => { - mockGetMcpServerIdState.mockResolvedValue({ deleted: false }) - - const res = await callCreate(VALID_BODY) - - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - expect(mockPerformCreateMcpServer).not.toHaveBeenCalled() + expect(mocks.capture).not.toHaveBeenCalled() }) - it('409s when a concurrent create made the lib upsert instead of insert', async () => { - mockPerformCreateMcpServer.mockResolvedValue({ - success: true, - serverId: 'mcp-abc12345', - updated: true, + it('keeps product analytics surface-specific for personal API keys', async () => { + mocks.authenticate.mockResolvedValueOnce({ + ...AUTH, + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-personal' }, + keyType: 'personal', }) - const res = await callCreate(VALID_BODY) + const response = await POST( + request('POST', '/api/v2/mcp-servers', { + workspaceId: WORKSPACE_ID, + name: server.name, + url: server.url, + }) + ) - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') + expect(response.status).toBe(201) + expect(mocks.capture).toHaveBeenCalledWith( + 'user-1', + 'mcp_server_connected', + expect.objectContaining({ workspace_id: WORKSPACE_ID }), + expect.anything() + ) }) - it('revives a soft-deleted URL instead of stranding it behind a 409', async () => { - mockGetMcpServerIdState.mockResolvedValue({ deleted: true }) - mockPerformCreateMcpServer.mockResolvedValue({ - success: true, - serverId: 'mcp-abc12345', - updated: true, - }) - - const res = await callCreate(VALID_BODY) + it('authenticates before parsing create input', async () => { + mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) - expect(res.status).toBe(201) - expect(mockPerformCreateMcpServer).toHaveBeenCalled() - }) - - it('creates the server and returns 201 with the public shape', async () => { - const res = await callCreate({ ...VALID_BODY, headers: { Authorization: 'Bearer tok' } }) - const body = await res.json() + const response = await POST(request('POST', '/api/v2/mcp-servers', {})) - expect(res.status).toBe(201) - expect(body.data.mcpServer).toMatchObject({ - id: 'mcp-abc12345', - name: 'Docs server', - hasHeaders: true, - headerNames: ['Authorization'], - }) - expect(body.data.mcpServer.headers).toBeUndefined() - expect(mockPerformCreateMcpServer).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: 'workspace-1', - userId: 'user-1', - name: 'Docs server', - url: 'https://mcp.example.com/sse', - headers: { Authorization: 'Bearer tok' }, - }) - ) + expect(response.status).toBe(401) + expect(mocks.create).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts index c14c89233c7..7639b4d3122 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.ts @@ -2,107 +2,56 @@ import { v2CreateMcpServerContract, v2ListMcpServersContract, } from '@/lib/api/contracts/v2/mcp-servers' -import { performCreateMcpServer } from '@/lib/mcp/orchestration' import { - getMcpServerIdState, - getWorkspaceMcpServer, - listWorkspaceMcpServers, -} from '@/lib/mcp/queries' -import { generateMcpServerId } from '@/lib/mcp/utils' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' -import { toV2McpServer, v2McpOrchestrationError } from '@/app/api/v2/mcp-servers/utils' + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { mcpServerOperations } from '@/lib/mcp/application/operations' +import { createMcpServerUseCase, listMcpServersUseCase } from '@/lib/mcp/application/use-cases' +import { captureServerEvent } from '@/lib/posthog/server' +import { toV2McpServer } from '@/app/api/v2/mcp-servers/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/mcp-servers — List MCP servers in a workspace. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListMcpServersContract, - rateLimitEndpoint: 'mcp-servers', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, search, sortBy, sortOrder } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const rows = await listWorkspaceMcpServers({ workspaceId, search, sortBy, sortOrder }) - - // The per-workspace server set is small and bounded → a single full page. - return v2CursorList(rows.map(toV2McpServer), null, { rateLimit }) - }, + operation: mcpServerOperations.list, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => query, + useCase: listMcpServersUseCase, + present: ({ servers }) => ({ data: servers.map(toV2McpServer), nextCursor: null }), }) /** POST /api/v2/mcp-servers — Register a new MCP server. */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateMcpServerContract, - rateLimitEndpoint: 'mcp-servers', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { workspaceId, ...body } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - /** - * The server id is a deterministic hash of workspace + normalized URL, and - * `performCreateMcpServer` upserts onto it — a second registration of the - * same URL silently overwrites the first. The internal surface and the - * copilot rely on that; a public create must not, so the collision is - * detected here, before the lib is given a chance to clobber the row. - * - * Only a *live* row is a conflict. A soft-deleted one is revived by the lib - * rather than inserted alongside, and reporting it as a duplicate would - * strand that URL for good: the detail routes resolve live rows only, so it - * could be neither fetched, patched, nor re-created. - */ - const serverId = generateMcpServerId(workspaceId, body.url) - const idState = await getMcpServerIdState({ workspaceId, serverId }) - if (idState && !idState.deleted) { - return v2Error( - 'CONFLICT', - 'An MCP server with this URL already exists in this workspace. Update it with PATCH /api/v2/mcp-servers/{id}.' - ) - } - const revivingSoftDeleted = idState?.deleted === true - - const result = await performCreateMcpServer({ - workspaceId, - userId, - name: body.name, - description: body.description, - transport: body.transport, - url: body.url, - headers: body.headers, - timeout: body.timeout, - retries: body.retries, - enabled: body.enabled, - authType: body.authType, - oauthClientId: body.oauthClientId ?? null, - oauthClientIdProvided: body.oauthClientId !== undefined, - oauthClientSecret: body.oauthClientSecret, - oauthClientSecretProvided: body.oauthClientSecret !== undefined, - request, - }) - - if (!result.success || !result.serverId) { - return v2McpOrchestrationError(result.errorCode, result.error ?? 'Failed to register server') - } - - /** - * `updated` means the lib wrote onto an existing row. Reviving the - * soft-deleted row we already saw is the intended outcome; otherwise a - * concurrent create won the id race between the check above and the write. - */ - if (result.updated && !revivingSoftDeleted) { - return v2Error('CONFLICT', 'An MCP server with this URL already exists in this workspace.') - } - - const created = await getWorkspaceMcpServer({ workspaceId, serverId: result.serverId }) - if (!created) { - throw new Error(`MCP server ${result.serverId} missing after a successful registration`) - } - - return v2Data({ mcpServer: toV2McpServer(created) }, { rateLimit, status: 201 }) + operation: mcpServerOperations.create, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ body }) => ({ ...body, source: 'api' as const }), + useCase: createMcpServerUseCase, + onSuccess: ({ principal, input, result }) => { + if (principal.kind !== 'personal_api_key' || result.updated) return + captureServerEvent( + principal.userId, + 'mcp_server_connected', + { + workspace_id: input.workspaceId, + server_name: result.server.name, + transport: result.server.transport, + }, + { + groups: { workspace: input.workspaceId }, + setOnce: { first_mcp_connected_at: new Date().toISOString() }, + } + ) }, + present: ({ server }) => ({ data: { mcpServer: toV2McpServer(server) } }), }) 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 15db1c86543..d77c511e782 100644 --- a/apps/sim/app/api/v2/secrets/[name]/route.test.ts +++ b/apps/sim/app/api/v2/secrets/[name]/route.test.ts @@ -4,227 +4,187 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockCheckWorkspaceAccess, - mockGetWorkspaceEnvKeyAdminAccess, - mockListVisibleWorkspaceCredentials, - mockSetWorkspaceSecret, - mockSetPersonalSecret, - mockDeleteWorkspaceSecret, - mockDeletePersonalSecret, - mockRecordAudit, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockCheckWorkspaceAccess: vi.fn(), - mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), - mockListVisibleWorkspaceCredentials: vi.fn(), - mockSetWorkspaceSecret: vi.fn(), - mockSetPersonalSecret: vi.fn(), - mockDeleteWorkspaceSecret: vi.fn(), - mockDeletePersonalSecret: vi.fn(), - mockRecordAudit: vi.fn(), -})) - -vi.mock('@sim/audit', () => ({ - AuditAction: { - ENVIRONMENT_UPDATED: 'environment.updated', - ENVIRONMENT_DELETED: 'environment.deleted', - }, - AuditResourceType: { ENVIRONMENT: 'environment' }, - recordAudit: mockRecordAudit, -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) +const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { + class MockV2ApiKeyUnauthenticatedError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + set: vi.fn(), + remove: vi.fn(), + }, + MockV2ApiKeyUnauthenticatedError, + } +}) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - checkWorkspaceAccess: mockCheckWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, })) - -vi.mock('@/lib/credentials/environment', () => ({ - getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: vi.fn().mockReturnValue({ + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 60_000, + }), })) - -vi.mock('@/lib/credentials/queries', () => ({ - listVisibleWorkspaceCredentials: mockListVisibleWorkspaceCredentials, +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), })) - -vi.mock('@/lib/credentials/secret-values', () => ({ - setWorkspaceSecret: mockSetWorkspaceSecret, - setPersonalSecret: mockSetPersonalSecret, - deleteWorkspaceSecret: mockDeleteWorkspaceSecret, - deletePersonalSecret: mockDeletePersonalSecret, +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/secrets/application/use-cases', () => ({ + setSecretUseCase: { operation: { id: 'secrets.set' }, execute: mocks.set }, + deleteSecretUseCase: { operation: { id: 'secrets.delete' }, execute: mocks.remove }, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { DELETE, PUT } from '@/app/api/v2/secrets/[name]/route' -const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const WORKSPACE_ID = 'workspace-1' +const SECRET_NAME = 'STRIPE_API_KEY' +const PRINCIPAL = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-personal' } +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} const RATE_LIMIT_OK = { allowed: true, - userId: 'user-1', - keyType: 'workspace', limit: 100, remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -function secretCredential(scope: 'workspace' | 'personal') { - return { - id: 'secret-1', - workspaceId: WORKSPACE_ID, - type: scope === 'workspace' ? ('env_workspace' as const) : ('env_personal' as const), - displayName: 'STRIPE_API_KEY', - description: null, - providerId: null, - accountId: null, - envKey: 'STRIPE_API_KEY', - envOwnerUserId: scope === 'personal' ? 'user-1' : null, - createdBy: 'user-1', - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - hasServiceAccountKey: false, - role: 'admin' as const, - } + resetAt: new Date('2026-01-01T00:00:00Z'), + retryAfterMs: 0, } - -const context = { params: Promise.resolve({ name: 'STRIPE_API_KEY' }) } - -function callSet(scope: 'workspace' | 'personal', value = 'super-secret-value') { - mockListVisibleWorkspaceCredentials.mockResolvedValue([secretCredential(scope)]) - return PUT( - new NextRequest('http://localhost:3000/api/v2/secrets/STRIPE_API_KEY', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspaceId: WORKSPACE_ID, scope, value }), - }), - context - ) +const secret = { + id: 'secret-1', + workspaceId: WORKSPACE_ID, + type: 'env_workspace' as const, + displayName: SECRET_NAME, + description: null, + providerId: null, + accountId: null, + envKey: SECRET_NAME, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + hasServiceAccountKey: false, + role: 'admin' as const, } - -function callDelete(scope: 'workspace' | 'personal') { - return DELETE( - new NextRequest( - `http://localhost:3000/api/v2/secrets/STRIPE_API_KEY?workspaceId=${WORKSPACE_ID}&scope=${scope}`, - { method: 'DELETE' } - ), - context +const context = { params: Promise.resolve({ name: SECRET_NAME }) } + +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) }), + } ) } -describe('PUT /api/v2/secrets/[name]', () => { +describe('/api/v2/secrets/[name]', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) - mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ - adminKeys: new Set<string>(), - knownKeys: new Set<string>(), - }) - mockSetWorkspaceSecret.mockResolvedValue({ created: true, updatedAt: new Date() }) - mockSetPersonalSecret.mockResolvedValue({ created: true, updatedAt: new Date() }) + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.set.mockResolvedValue({ secret, userId: 'user-1', created: true }) + mocks.remove.mockResolvedValue({ name: SECRET_NAME, scope: 'workspace' }) }) - it('sets a workspace secret and never echoes its value', async () => { - const res = await callSet('workspace') - const body = await res.json() - - expect(res.status).toBe(201) - expect(body.data.secret).toMatchObject({ name: 'STRIPE_API_KEY', scope: 'workspace' }) - expect(JSON.stringify(body)).not.toContain('super-secret-value') - expect(mockSetWorkspaceSecret).toHaveBeenCalledWith({ - workspaceId: WORKSPACE_ID, - name: 'STRIPE_API_KEY', - value: 'super-secret-value', - userId: 'user-1', - }) - }) + it('creates a write-only secret with a dynamic 201 status', async () => { + const response = await PUT( + request('PUT', { workspaceId: WORKSPACE_ID, scope: 'workspace', value: 'secret-value' }), + context + ) - it('updates an existing workspace secret only for a secret admin', async () => { - mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ - adminKeys: new Set<string>(), - knownKeys: new Set(['STRIPE_API_KEY']), + expect(response.status).toBe(201) + expect(JSON.stringify(await response.json())).not.toContain('secret-value') + expect(mocks.set).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + name: SECRET_NAME, + scope: 'workspace', + value: 'secret-value', + }, + request: expect.anything(), }) + }) - const forbidden = await callSet('workspace') - expect(forbidden.status).toBe(403) - expect(mockSetWorkspaceSecret).not.toHaveBeenCalled() + it('returns 200 when replacing an existing secret', async () => { + mocks.set.mockResolvedValueOnce({ secret, userId: 'user-1', created: false }) - mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ - adminKeys: new Set(['STRIPE_API_KEY']), - knownKeys: new Set(['STRIPE_API_KEY']), - }) - mockSetWorkspaceSecret.mockResolvedValue({ created: false, updatedAt: new Date() }) + const response = await PUT( + request('PUT', { workspaceId: WORKSPACE_ID, scope: 'workspace', value: 'replacement' }), + context + ) - const updated = await callSet('workspace') - expect(updated.status).toBe(200) + expect(response.status).toBe(200) }) - it('sets only the caller-owned personal secret catalog', async () => { - const res = await callSet('personal') + it('deletes a secret through the semantic delete operation', async () => { + const response = await DELETE(request('DELETE'), context) - expect(res.status).toBe(201) - expect(mockSetPersonalSecret).toHaveBeenCalledWith({ - userId: 'user-1', - name: 'STRIPE_API_KEY', - value: 'super-secret-value', + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { name: SECRET_NAME, scope: 'workspace', deleted: true }, + }) + expect(mocks.remove).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, name: SECRET_NAME, scope: 'workspace' }, + request: expect.anything(), }) - expect(mockSetWorkspaceSecret).not.toHaveBeenCalled() }) - it('rejects invalid names and empty values before storage', async () => { - const invalidContext = { params: Promise.resolve({ name: 'not-valid' }) } - const res = await PUT( - new NextRequest('http://localhost:3000/api/v2/secrets/not-valid', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspaceId: WORKSPACE_ID, scope: 'workspace', value: '' }), - }), - invalidContext - ) + it('renders typed application errors without leaking raw errors', async () => { + mocks.remove.mockRejectedValueOnce(new OrchestrationError('not_found', 'stored detail')) - expect(res.status).toBe(400) - expect(mockSetWorkspaceSecret).not.toHaveBeenCalled() - }) -}) + const response = await DELETE(request('DELETE'), context) -describe('DELETE /api/v2/secrets/[name]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: true }) - mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ - adminKeys: new Set(['STRIPE_API_KEY']), - knownKeys: new Set(['STRIPE_API_KEY']), + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'stored detail' }, }) - mockDeleteWorkspaceSecret.mockResolvedValue(true) - mockDeletePersonalSecret.mockResolvedValue(true) }) - it('deletes workspace secret metadata without returning a value', async () => { - const res = await callDelete('workspace') - const body = await res.json() + it('conceals unclassified application errors', async () => { + mocks.remove.mockRejectedValueOnce(new Error('database connection detail')) + + const response = await DELETE(request('DELETE'), context) + const body = await response.json() - expect(res.status).toBe(200) - expect(body.data).toEqual({ name: 'STRIPE_API_KEY', scope: 'workspace', deleted: true }) - expect(JSON.stringify(body)).not.toContain('value') + expect(response.status).toBe(500) + expect(body).toEqual({ error: { code: 'INTERNAL_ERROR', message: 'Internal server error' } }) + expect(JSON.stringify(body)).not.toContain('database connection detail') }) - it('returns 404 when the scoped secret does not exist', async () => { - mockDeletePersonalSecret.mockResolvedValue(false) + it('authenticates before parsing a malformed set request', async () => { + mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) - const res = await callDelete('personal') + const response = await PUT(request('PUT', {}), context) - expect(res.status).toBe(404) + expect(response.status).toBe(401) + expect(mocks.set).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/secrets/[name]/route.ts b/apps/sim/app/api/v2/secrets/[name]/route.ts index ca559713c46..1eb74ae39da 100644 --- a/apps/sim/app/api/v2/secrets/[name]/route.ts +++ b/apps/sim/app/api/v2/secrets/[name]/route.ts @@ -1,168 +1,38 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import type { NextResponse } from 'next/server' +import { v2DeleteSecretContract, v2SetSecretContract } from '@/lib/api/contracts/v2/secrets' import { - type V2Secret, - type V2SecretScope, - v2DeleteSecretContract, - v2SetSecretContract, -} from '@/lib/api/contracts/v2/secrets' -import { getWorkspaceEnvKeyAdminAccess } from '@/lib/credentials/environment' -import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' -import { - deletePersonalSecret, - deleteWorkspaceSecret, - setPersonalSecret, - setWorkspaceSecret, -} from '@/lib/credentials/secret-values' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' -import { secretCredentialTypes, toV2Secret } from '@/app/api/v2/secrets/utils' + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { secretOperations } from '@/lib/secrets/application/operations' +import { deleteSecretUseCase, setSecretUseCase } from '@/lib/secrets/application/use-cases' +import { toV2Secret } from '@/app/api/v2/secrets/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -interface RouteContext { - params: Promise<{ name: string }> -} - -/** Enforces the per-secret admin rule used by the existing workspace editor. */ -async function workspaceSecretAccessError(params: { - workspaceId: string - name: string - userId: string - canWrite: boolean - canAdmin: boolean -}): Promise<NextResponse | null> { - const { workspaceId, name, userId, canWrite, canAdmin } = params - const { adminKeys, knownKeys } = await getWorkspaceEnvKeyAdminAccess({ - workspaceId, - envKeys: [name], - userId, - }) - - if (knownKeys.has(name)) { - return canAdmin || adminKeys.has(name) - ? null - : v2Error('FORBIDDEN', 'Credential admin permission required for this secret') - } - return canWrite ? null : v2Error('FORBIDDEN', 'Write permission required to set this secret') -} - -/** Reads metadata from the credential catalog; encrypted value columns are never selected. */ -async function getSecretMetadata(params: { - workspaceId: string - name: string - scope: V2SecretScope - userId: string - workspaceAccess: Awaited<ReturnType<typeof checkWorkspaceAccess>> -}): Promise<V2Secret> { - const { workspaceId, name, scope, userId, workspaceAccess } = params - const rows = await listVisibleWorkspaceCredentials({ - workspaceId, - userId, - workspaceAccess, - types: [...secretCredentialTypes(scope)], - search: name, - sortBy: 'displayName', - sortOrder: 'asc', - }) - const row = rows.find( - (candidate) => - candidate.envKey === name && - (scope === 'workspace' - ? candidate.type === 'env_workspace' - : candidate.type === 'env_personal' && candidate.envOwnerUserId === userId) - ) - if (!row) throw new Error(`Secret metadata was not created for ${scope}:${name}`) - return toV2Secret(row, userId) -} - /** PUT /api/v2/secrets/[name] — Create or replace a write-only secret value. */ -export const PUT = withPublicApiRouteHandler({ +export const PUT = defineV2JsonRoute({ contract: v2SetSecretContract, - rateLimitEndpoint: 'secret-detail', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { name } = input.params - const { workspaceId, scope, value } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) - if (scope === 'workspace') { - const permissionError = await workspaceSecretAccessError({ - workspaceId, - name, - userId, - canWrite: workspaceAccess.canWrite, - canAdmin: workspaceAccess.canAdmin, - }) - if (permissionError) return permissionError - } - - const result = - scope === 'workspace' - ? await setWorkspaceSecret({ workspaceId, name, value, userId }) - : await setPersonalSecret({ userId, name, value }) - const secret = await getSecretMetadata({ workspaceId, name, scope, userId, workspaceAccess }) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.ENVIRONMENT_UPDATED, - resourceType: AuditResourceType.ENVIRONMENT, - resourceId: `${scope}:${name}`, - resourceName: name, - description: `${result.created ? 'Created' : 'Updated'} ${scope} secret "${name}"`, - metadata: { scope, name }, - request, - }) - - return v2Data({ secret }, { rateLimit, status: result.created ? 201 : 200 }) - }, + operation: secretOperations.set, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ ...body, name: params.name }), + useCase: setSecretUseCase, + statusForResult: ({ created }) => (created ? 201 : 200), + present: ({ secret, userId }) => ({ data: { secret: toV2Secret(secret, userId) } }), }) /** DELETE /api/v2/secrets/[name] — Delete a secret without reading its value. */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteSecretContract, - rateLimitEndpoint: 'secret-detail', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { name } = input.params - const { workspaceId, scope } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) - if (scope === 'workspace') { - const permissionError = await workspaceSecretAccessError({ - workspaceId, - name, - userId, - canWrite: workspaceAccess.canWrite, - canAdmin: workspaceAccess.canAdmin, - }) - if (permissionError) return permissionError - } - - const deleted = - scope === 'workspace' - ? await deleteWorkspaceSecret({ workspaceId, name }) - : await deletePersonalSecret({ userId, name }) - if (!deleted) return v2Error('NOT_FOUND', 'Secret not found') - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.ENVIRONMENT_DELETED, - resourceType: AuditResourceType.ENVIRONMENT, - resourceId: `${scope}:${name}`, - resourceName: name, - description: `Deleted ${scope} secret "${name}"`, - metadata: { scope, name }, - request, - }) - - return v2Data({ name, scope, deleted: true as const }, { rateLimit }) - }, + operation: secretOperations.delete, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ ...query, name: params.name }), + useCase: deleteSecretUseCase, + present: ({ name, scope }) => ({ data: { name, scope, deleted: true as const } }), }) diff --git a/apps/sim/app/api/v2/secrets/route.test.ts b/apps/sim/app/api/v2/secrets/route.test.ts index 08b096ec84e..5c173b4d883 100644 --- a/apps/sim/app/api/v2/secrets/route.test.ts +++ b/apps/sim/app/api/v2/secrets/route.test.ts @@ -4,137 +4,134 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockCheckWorkspaceAccess, - mockListVisibleWorkspaceCredentials, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockCheckWorkspaceAccess: vi.fn(), - mockListVisibleWorkspaceCredentials: vi.fn(), -})) +const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { + class MockV2ApiKeyUnauthenticatedError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + list: vi.fn(), + }, + MockV2ApiKeyUnauthenticatedError, + } +}) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, })) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - checkWorkspaceAccess: mockCheckWorkspaceAccess, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: vi.fn().mockReturnValue({ + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 60_000, + }), })) - -vi.mock('@/lib/credentials/queries', () => ({ - listVisibleWorkspaceCredentials: mockListVisibleWorkspaceCredentials, +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/secrets/application/use-cases', () => ({ + listSecretsUseCase: { operation: { id: 'secrets.list' }, execute: mocks.list }, })) import { GET } from '@/app/api/v2/secrets/route' -const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-personal' } +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} const RATE_LIMIT_OK = { allowed: true, - userId: 'user-1', - keyType: 'workspace', limit: 100, remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), + resetAt: new Date('2026-01-01T00:00:00Z'), + retryAfterMs: 0, } - -function secretCredential(overrides: Record<string, unknown> = {}) { - return { - id: 'secret-1', - workspaceId: WORKSPACE_ID, - type: 'env_workspace' as const, - displayName: 'STRIPE_API_KEY', - description: null, - providerId: null, - accountId: null, - envKey: 'STRIPE_API_KEY', - envOwnerUserId: null, - createdBy: 'user-1', - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - hasServiceAccountKey: false, - role: 'admin' as const, - ...overrides, - } +const secret = { + id: 'secret-1', + workspaceId: WORKSPACE_ID, + type: 'env_workspace' as const, + displayName: 'STRIPE_API_KEY', + description: null, + providerId: null, + accountId: null, + envKey: 'STRIPE_API_KEY', + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + hasServiceAccountKey: false, + role: 'admin' as const, } -const callList = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/secrets?${query}`)) - describe('GET /api/v2/secrets', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: true }) - mockListVisibleWorkspaceCredentials.mockResolvedValue([secretCredential()]) + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.list.mockResolvedValue({ secrets: [secret], userId: 'user-1' }) }) - it('lists metadata without a value field', async () => { - const res = await callList(`workspaceId=${WORKSPACE_ID}`) - const body = await res.json() + it('lists secret metadata without exposing values', async () => { + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/secrets?workspaceId=${WORKSPACE_ID}`, { + headers: { 'x-api-key': 'key' }, + }) + ) + const body = await response.json() - expect(res.status).toBe(200) + expect(response.status).toBe(200) expect(body).toEqual({ data: [ { name: 'STRIPE_API_KEY', scope: 'workspace', role: 'admin', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', }, ], nextCursor: null, }) expect(JSON.stringify(body)).not.toContain('value') - expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( - expect.objectContaining({ types: ['env_workspace', 'env_personal'] }) - ) - }) - - it('does not expose another user personal secret', async () => { - mockListVisibleWorkspaceCredentials.mockResolvedValue([ - secretCredential({ - id: 'secret-2', - type: 'env_personal', - displayName: 'PRIVATE_KEY', - envKey: 'PRIVATE_KEY', - envOwnerUserId: 'user-2', - }), - ]) - - const res = await callList(`workspaceId=${WORKSPACE_ID}`) - - expect((await res.json()).data).toEqual([]) + expect(mocks.list).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + scope: undefined, + search: undefined, + sortBy: 'name', + sortOrder: 'asc', + }, + request: expect.anything(), + }) }) - it('maps scope and sort filters to the credential catalog', async () => { - await callList( - `workspaceId=${WORKSPACE_ID}&scope=workspace&search=STRIPE&sortBy=name&sortOrder=desc` - ) - - expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( - expect.objectContaining({ - types: ['env_workspace'], - search: 'STRIPE', - sortBy: 'displayName', - sortOrder: 'desc', - }) - ) - }) + it('authenticates before validating list input', async () => { + mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) - it('rejects missing workspace context', async () => { - const res = await callList('') + const response = await GET(new NextRequest('http://localhost:3000/api/v2/secrets')) - expect(res.status).toBe(400) - expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() + expect(response.status).toBe(401) + expect(mocks.list).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/secrets/route.ts b/apps/sim/app/api/v2/secrets/route.ts index a2ca345b892..62a1685dc88 100644 --- a/apps/sim/app/api/v2/secrets/route.ts +++ b/apps/sim/app/api/v2/secrets/route.ts @@ -1,37 +1,28 @@ import { v2ListSecretsContract } from '@/lib/api/contracts/v2/secrets' -import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2CursorList, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' -import { secretCredentialTypes, toV2Secret } from '@/app/api/v2/secrets/utils' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { secretOperations } from '@/lib/secrets/application/operations' +import { listSecretsUseCase } from '@/lib/secrets/application/use-cases' +import { toV2Secret } from '@/app/api/v2/secrets/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/secrets — List secret names and metadata without reading their values. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListSecretsContract, - rateLimitEndpoint: 'secrets', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, scope, search, sortBy, sortOrder } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) - const credentials = await listVisibleWorkspaceCredentials({ - workspaceId, - userId, - workspaceAccess, - types: [...secretCredentialTypes(scope)], - search, - sortBy: sortBy === 'name' ? 'displayName' : sortBy, - sortOrder, - }) - const secrets = credentials - .filter((row) => row.type === 'env_workspace' || row.envOwnerUserId === userId) - .map((row) => toV2Secret(row, userId)) - - return v2CursorList(secrets, null, { rateLimit }) - }, + operation: secretOperations.list, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => query, + useCase: listSecretsUseCase, + present: ({ secrets, userId }) => ({ + data: secrets.map((secret) => toV2Secret(secret, userId)), + nextCursor: null, + }), }) 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 834191497ff..9444c0d799f 100644 --- a/apps/sim/app/api/v2/skills/[id]/route.test.ts +++ b/apps/sim/app/api/v2/skills/[id]/route.test.ts @@ -1,331 +1,168 @@ /** * @vitest-environment node - * - * Public v2 skill detail: the get-by-id that has no internal equivalent, plus - * the per-id update/delete that replaced the bulk upsert. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetSkillById, - mockPerformUpdateSkill, - mockPerformDeleteSkill, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetSkillById: vi.fn(), - mockPerformUpdateSkill: vi.fn(), - mockPerformDeleteSkill: vi.fn(), -})) +const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { + class MockV2ApiKeyUnauthenticatedError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + get: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + capture: vi.fn(), + }, + MockV2ApiKeyUnauthenticatedError, + } +}) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, })) - -vi.mock('@/lib/workflows/skills/operations', () => ({ - getSkillById: mockGetSkillById, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: vi.fn().mockReturnValue({ + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 60_000, + }), })) - -vi.mock('@/lib/skills/orchestration', () => ({ - performUpdateSkill: mockPerformUpdateSkill, - performDeleteSkill: mockPerformDeleteSkill, +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) +vi.mock('@/lib/skills/application/use-cases', () => ({ + getSkillUseCase: { operation: { id: 'skills.read' }, execute: mocks.get }, + updateSkillUseCase: { operation: { id: 'skills.update' }, execute: mocks.update }, + deleteSkillUseCase: { operation: { id: 'skills.delete' }, execute: mocks.remove }, })) import { DELETE, GET, PATCH } from '@/app/api/v2/skills/[id]/route' +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-personal' } +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} const RATE_LIMIT_OK = { allowed: true, - userId: 'user-1', - keyType: 'workspace', limit: 100, remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, + resetAt: new Date('2026-01-01T00:00:00Z'), + retryAfterMs: 0, } - -const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } - -function buildSkill(overrides: Record<string, unknown> = {}) { - return { - id: 'skl_abc123', - workspaceId: 'workspace-1', - userId: 'user-1', - name: 'refund-policy', - description: 'How to handle refunds', - content: '# Refund policy', - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - ...overrides, - } +const skill = { + id: 'skill-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + name: 'refund-policy', + description: 'How to handle refunds', + content: '# Refund policy', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), } - -const routeContext = () => ({ params: Promise.resolve({ id: 'skl_abc123' }) }) -const url = (query = 'workspaceId=workspace-1') => - `http://localhost:3000/api/v2/skills/skl_abc123?${query}` - -const callGet = (query?: string) => GET(new NextRequest(url(query)), routeContext()) -const callDelete = (query?: string) => - DELETE(new NextRequest(url(query), { method: 'DELETE' }), routeContext()) - -function callPatch(body: unknown) { - return PATCH( - new NextRequest('http://localhost:3000/api/v2/skills/skl_abc123', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - routeContext() +const context = { params: Promise.resolve({ id: skill.id }) } + +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) }), + } ) } -describe('GET /api/v2/skills/[id]', () => { +describe('/api/v2/skills/[id]', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetSkillById.mockResolvedValue(buildSkill()) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockGetSkillById).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callGet('') - expect(res.status).toBe(400) - expect(mockGetSkillById).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callGet() - expect(res.status).toBe(403) - expect(mockGetSkillById).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callGet() - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('404s when the skill is not in the workspace', async () => { - mockGetSkillById.mockResolvedValue(null) - const res = await callGet() - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - }) - - it('returns the single skill including its body', async () => { - const res = await callGet() - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data).toEqual({ - skill: { - id: 'skl_abc123', - name: 'refund-policy', - description: 'How to handle refunds', - content: '# Refund policy', - readOnly: false, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - }, - }) - expect(mockGetSkillById).toHaveBeenCalledWith({ - skillId: 'skl_abc123', - workspaceId: 'workspace-1', + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.get.mockResolvedValue({ skill }) + mocks.update.mockResolvedValue({ skill }) + mocks.remove.mockResolvedValue({ skill }) + }) + + it('gets a skill through the semantic read operation', async () => { + const response = await GET(request('GET'), context) + + expect(response.status).toBe(200) + expect((await response.json()).data.skill.content).toBe(skill.content) + expect(mocks.get).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, skillId: skill.id }, + request: expect.anything(), }) }) -}) -describe('PATCH /api/v2/skills/[id]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformUpdateSkill.mockResolvedValue({ - success: true, - skill: buildSkill({ description: 'Updated' }), - }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) - - expect(res.status).toBe(404) - expect(mockPerformUpdateSkill).not.toHaveBeenCalled() - }) - - it('400s when no field to change is supplied', async () => { - const res = await callPatch({ workspaceId: 'workspace-1' }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformUpdateSkill).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) - expect(res.status).toBe(403) - expect(mockPerformUpdateSkill).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('403s when the caller is not a skill editor', async () => { - mockPerformUpdateSkill.mockResolvedValue({ - success: false, - error: 'Skill editor access required to modify "refund-policy"', - errorCode: 'forbidden', - }) - const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) - expect(res.status).toBe(403) - expect((await res.json()).error.code).toBe('FORBIDDEN') - }) - - it('400s when the orchestration rejects a built-in skill', async () => { - mockPerformUpdateSkill.mockResolvedValue({ - success: false, - error: 'Built-in skills are read-only and cannot be modified', - errorCode: 'validation', - }) - const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) - expect(res.status).toBe(400) - expect((await res.json()).error.message).toContain('Built-in') - }) - - it('gates on workspace read, leaving edit rights to the per-skill editor check', async () => { - await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.anything(), - 'user-1', - 'workspace-1', - 'read' + it('updates a skill and emits only surface analytics', async () => { + const response = await PATCH( + request('PATCH', { workspaceId: WORKSPACE_ID, content: '# Updated' }), + context ) - }) - - it('updates the skill and returns the single skill', async () => { - const res = await callPatch({ workspaceId: 'workspace-1', description: 'Updated' }) - const body = await res.json() - expect(res.status).toBe(200) - expect(body.data.skill.description).toBe('Updated') - expect(Array.isArray(body.data)).toBe(false) - expect(mockPerformUpdateSkill).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: 'workspace-1', - userId: 'user-1', - skillId: 'skl_abc123', - description: 'Updated', + expect(response.status).toBe(200) + expect(mocks.update).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + skillId: skill.id, + content: '# Updated', source: 'api', - }) + }, + request: expect.anything(), + }) + expect(mocks.capture).toHaveBeenCalledWith( + 'user-1', + 'skill_updated', + expect.objectContaining({ skill_id: skill.id }), + expect.anything() ) }) -}) - -describe('DELETE /api/v2/skills/[id]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformDeleteSkill.mockResolvedValue({ success: true, skill: buildSkill() }) - }) - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callDelete() - - expect(res.status).toBe(404) - expect(mockPerformDeleteSkill).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callDelete('') - expect(res.status).toBe(400) - expect(mockPerformDeleteSkill).not.toHaveBeenCalled() - }) + it('deletes a skill through the semantic delete operation', async () => { + const response = await DELETE(request('DELETE'), context) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callDelete() - expect(res.status).toBe(403) - expect(mockPerformDeleteSkill).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callDelete() - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('400s when the skill is a read-only built-in', async () => { - mockPerformDeleteSkill.mockResolvedValue({ - success: false, - error: 'Built-in skills are read-only and cannot be modified', - errorCode: 'validation', + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: skill.id, deleted: true } }) + expect(mocks.remove).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, skillId: skill.id, source: 'api' }, + request: expect.anything(), }) - const res = await callDelete() - expect(res.status).toBe(400) }) - it('gates on workspace read, leaving delete rights to the per-skill editor check', async () => { - await callDelete() - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.anything(), - 'user-1', - 'workspace-1', - 'read' - ) - }) + it('authenticates before parsing an empty update body', async () => { + mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) - it('deletes the skill and acknowledges the id', async () => { - const res = await callDelete() - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ data: { id: 'skl_abc123', deleted: true } }) - expect(mockPerformDeleteSkill).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: 'workspace-1', - userId: 'user-1', - skillId: 'skl_abc123', - source: 'api', - }) - ) + const response = await PATCH(request('PATCH', {}), context) + + expect(response.status).toBe(401) + expect(mocks.update).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/skills/[id]/route.ts b/apps/sim/app/api/v2/skills/[id]/route.ts index 00dae3b6bce..eb896725050 100644 --- a/apps/sim/app/api/v2/skills/[id]/route.ts +++ b/apps/sim/app/api/v2/skills/[id]/route.ts @@ -3,99 +3,91 @@ import { v2GetSkillContract, v2UpdateSkillContract, } from '@/lib/api/contracts/v2/skills' -import { performDeleteSkill, performUpdateSkill } from '@/lib/skills/orchestration' -import { getSkillById } from '@/lib/workflows/skills/operations' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' -import { toV2Skill, v2SkillOrchestrationError } from '@/app/api/v2/skills/utils' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { captureServerEvent } from '@/lib/posthog/server' +import { skillOperations } from '@/lib/skills/application/operations' +import { + deleteSkillUseCase, + getSkillUseCase, + updateSkillUseCase, +} from '@/lib/skills/application/use-cases' +import { toV2Skill } from '@/app/api/v2/skills/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -interface RouteContext { - params: Promise<{ id: string }> -} - /** GET /api/v2/skills/[id] — Fetch a single skill, including its body. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetSkillContract, - rateLimitEndpoint: 'skill-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { id } = input.params - const { workspaceId } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const skill = await getSkillById({ skillId: id, workspaceId }) - if (!skill) return v2Error('NOT_FOUND', 'Skill not found') - - return v2Data({ skill: toV2Skill(skill) }, { rateLimit }) - }, + operation: skillOperations.read, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, skillId: params.id }), + useCase: getSkillUseCase, + present: ({ skill }) => ({ data: { skill: toV2Skill(skill) } }), }) -/** PATCH /api/v2/skills/[id] — Update a skill. Omitted fields keep their values. */ -export const PATCH = withPublicApiRouteHandler({ +/** PATCH /api/v2/skills/[id] — Update a skill. */ +export const PATCH = defineV2JsonRoute({ contract: v2UpdateSkillContract, - rateLimitEndpoint: 'skill-detail', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { id } = input.params - const { workspaceId, name, description, content } = input.body - - /** - * Editing an existing skill is gated per skill, not per workspace: an - * explicit editor grant (or workspace admin) is the authority, and - * `performUpdateSkill` enforces it. Requiring workspace `write` here would - * reject a legitimate skill editor who only holds `read` — stricter than the - * UI and than what this endpoint documents. Creating still needs `write`. - */ - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const result = await performUpdateSkill({ - workspaceId, - userId, - skillId: id, - name, - description, - content, - source: 'api', - request, - }) - - if (!result.success || !result.skill) { - return v2SkillOrchestrationError(result.errorCode, result.error ?? 'Failed to update skill') - } - - return v2Data({ skill: toV2Skill(result.skill) }, { rateLimit }) + operation: skillOperations.update, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + ...body, + skillId: params.id, + source: 'api' as const, + }), + useCase: updateSkillUseCase, + onSuccess: ({ principal, input, result }) => { + if (principal.kind !== 'personal_api_key') return + captureServerEvent( + principal.userId, + 'skill_updated', + { + skill_id: result.skill.id, + skill_name: result.skill.name, + workspace_id: input.workspaceId, + source: 'api', + }, + { groups: { workspace: input.workspaceId } } + ) }, + present: ({ skill }) => ({ data: { skill: toV2Skill(skill) } }), }) /** DELETE /api/v2/skills/[id] — Delete a skill. */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteSkillContract, - rateLimitEndpoint: 'skill-detail', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { id } = input.params - const { workspaceId } = input.query - - // Gated per skill by `performDeleteSkill`, same as PATCH above. - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const result = await performDeleteSkill({ - workspaceId, - userId, - skillId: id, - source: 'api', - request, - }) - - if (!result.success) { - return v2SkillOrchestrationError(result.errorCode, result.error ?? 'Failed to delete skill') - } - - return v2Data({ id, deleted: true as const }, { rateLimit }) + operation: skillOperations.delete, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + workspaceId: query.workspaceId, + skillId: params.id, + source: 'api' as const, + }), + useCase: deleteSkillUseCase, + onSuccess: ({ principal, input, result }) => { + if (principal.kind !== 'personal_api_key') return + captureServerEvent( + principal.userId, + 'skill_deleted', + { + skill_id: result.skill.id, + workspace_id: input.workspaceId, + source: 'api', + }, + { groups: { workspace: input.workspaceId } } + ) }, + present: ({ skill }) => ({ data: { id: skill.id, deleted: true as const } }), }) diff --git a/apps/sim/app/api/v2/skills/route.test.ts b/apps/sim/app/api/v2/skills/route.test.ts index 8e1c5131c2e..9f7f634a2e5 100644 --- a/apps/sim/app/api/v2/skills/route.test.ts +++ b/apps/sim/app/api/v2/skills/route.test.ts @@ -1,290 +1,180 @@ /** * @vitest-environment node - * - * Public v2 skills list/create: gate ordering, contract validation, and the - * single-resource create that replaced the internal bulk upsert. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockListSkills, mockPerformCreateSkill } = - vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockListSkills: vi.fn(), - mockPerformCreateSkill: vi.fn(), - })) +const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { + class MockV2ApiKeyUnauthenticatedError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + list: vi.fn(), + create: vi.fn(), + capture: vi.fn(), + }, + MockV2ApiKeyUnauthenticatedError, + } +}) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, })) - -vi.mock('@/lib/workflows/skills/operations', () => ({ - listSkills: mockListSkills, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: vi.fn().mockReturnValue({ + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 60_000, + }), })) - -vi.mock('@/lib/skills/orchestration', () => ({ - performCreateSkill: mockPerformCreateSkill, +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) +vi.mock('@/lib/skills/application/use-cases', () => ({ + listSkillsUseCase: { operation: { id: 'skills.list' }, execute: mocks.list }, + createSkillUseCase: { operation: { id: 'skills.create' }, execute: mocks.create }, })) import { GET, POST } from '@/app/api/v2/skills/route' +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' } +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} const RATE_LIMIT_OK = { allowed: true, - userId: 'user-1', - keyType: 'workspace', limit: 100, remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} - -function buildSkill(overrides: Record<string, unknown> = {}) { - return { - id: 'skl_abc123', - workspaceId: 'workspace-1', - userId: 'user-1', - name: 'refund-policy', - description: 'How to handle refunds', - content: '# Refund policy\n\nAlways be kind.', - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - ...overrides, - } -} - -function callList(query: string) { - return GET(new NextRequest(`http://localhost:3000/api/v2/skills?${query}`)) -} - -function callCreate(body: unknown) { - return POST( - new NextRequest('http://localhost:3000/api/v2/skills', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - ) + resetAt: new Date('2026-01-01T00:00:00Z'), + retryAfterMs: 0, } - -const VALID_BODY = { - workspaceId: 'workspace-1', +const skill = { + id: 'skill-1', + workspaceId: WORKSPACE_ID, + userId: 'owner-1', name: 'refund-policy', description: 'How to handle refunds', content: '# Refund policy', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), } -describe('GET /api/v2/skills', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockListSkills.mockResolvedValue([buildSkill()]) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callList('workspaceId=workspace-1') - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockListSkills).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callList('') - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockListSkills).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(403) - expect(mockListSkills).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('returns summaries without skill bodies in the cursor envelope', async () => { - const res = await callList('workspaceId=workspace-1') - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.nextCursor).toBeNull() - expect(body.data).toEqual([ - { - id: 'skl_abc123', - name: 'refund-policy', - description: 'How to handle refunds', - readOnly: false, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - }, - ]) - expect(mockListSkills).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - search: undefined, - sort: { sortBy: 'createdAt', sortOrder: 'desc' }, - }) - }) - it('400s on a sort field outside the enum instead of letting it reach the query', async () => { - const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) - - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - }) - - it('400s on a sort direction outside the enum', async () => { - const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) - - expect(res.status).toBe(400) +function request(method: 'GET' | 'POST', url: string, body?: unknown) { + return new NextRequest(`http://localhost:3000${url}`, { + method, + headers: { + 'x-api-key': 'key', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), }) +} - it('400s on an empty search rather than treating it as unsearched', async () => { - const res = await callList(`workspaceId=workspace-1&search=`) - - expect(res.status).toBe(400) - }) - - it('forwards search and sort into the query and still terminates pagination', async () => { - const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`) - - expect(res.status).toBe(200) - expect((await res.json()).nextCursor).toBeNull() - }) -}) - -describe('POST /api/v2/skills', () => { +describe('/api/v2/skills', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformCreateSkill.mockResolvedValue({ success: true, skill: buildSkill() }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callCreate(VALID_BODY) - - expect(res.status).toBe(404) - expect(mockPerformCreateSkill).not.toHaveBeenCalled() - }) - - it('400s when the body is missing content', async () => { - const res = await callCreate({ - workspaceId: 'workspace-1', - name: 'refund-policy', - description: 'How to handle refunds', + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.list.mockResolvedValue({ skills: [skill] }) + mocks.create.mockResolvedValue({ skill }) + }) + + it('lists skill summaries through the authorized application use case', async () => { + const response = await GET(request('GET', `/api/v2/skills?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(200) + expect((await response.json()).data[0]).not.toHaveProperty('content') + expect(mocks.list).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + search: undefined, + sortBy: 'createdAt', + sortOrder: 'desc', + }, + request: expect.anything(), }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformCreateSkill).not.toHaveBeenCalled() }) - it('400s when the name is not kebab-case', async () => { - const res = await callCreate({ ...VALID_BODY, name: 'Refund Policy' }) - expect(res.status).toBe(400) - expect(mockPerformCreateSkill).not.toHaveBeenCalled() - }) + it('creates a skill with the v2 source and status', async () => { + const response = await POST( + request('POST', '/api/v2/skills', { + workspaceId: WORKSPACE_ID, + name: skill.name, + description: skill.description, + content: skill.content, + }) + ) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + expect(response.status).toBe(201) + expect((await response.json()).data.skill.id).toBe(skill.id) + expect(mocks.create).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + name: skill.name, + description: skill.description, + content: skill.content, + source: 'api', + }, + request: expect.anything(), }) - const res = await callCreate(VALID_BODY) - expect(res.status).toBe(403) - expect(mockPerformCreateSkill).not.toHaveBeenCalled() + expect(mocks.capture).not.toHaveBeenCalled() }) - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callCreate(VALID_BODY) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('400s when the orchestration rejects a built-in skill name', async () => { - mockPerformCreateSkill.mockResolvedValue({ - success: false, - error: 'The skill name "deploy-workflow" is reserved by a built-in skill', - errorCode: 'validation', + it('keeps skill analytics on the personal-key v2 surface', async () => { + mocks.authenticate.mockResolvedValueOnce({ + ...AUTH, + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-personal' }, + keyType: 'personal', }) - const res = await callCreate({ ...VALID_BODY, name: 'deploy-workflow' }) - const body = await res.json() + const response = await POST( + request('POST', '/api/v2/skills', { + workspaceId: WORKSPACE_ID, + name: skill.name, + description: skill.description, + content: skill.content, + }) + ) - expect(res.status).toBe(400) - expect(body.error.code).toBe('BAD_REQUEST') - expect(body.error.message).toContain('built-in') + expect(response.status).toBe(201) + expect(mocks.capture).toHaveBeenCalledWith( + 'user-1', + 'skill_created', + expect.objectContaining({ skill_id: skill.id, source: 'api' }), + expect.anything() + ) }) - it('409s when the skill name is already taken', async () => { - mockPerformCreateSkill.mockResolvedValue({ - success: false, - error: 'The skill name "refund-policy" is unavailable in this workspace', - errorCode: 'conflict', - }) - const res = await callCreate(VALID_BODY) - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - }) + it('authenticates before parsing skill input', async () => { + mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) - it('creates the skill and returns 201 with the single skill, not the workspace list', async () => { - const res = await callCreate(VALID_BODY) - const body = await res.json() + const response = await POST(request('POST', '/api/v2/skills', {})) - expect(res.status).toBe(201) - expect(body.data).toEqual({ - skill: { - id: 'skl_abc123', - name: 'refund-policy', - description: 'How to handle refunds', - content: '# Refund policy\n\nAlways be kind.', - readOnly: false, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - }, - }) - expect(mockPerformCreateSkill).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: 'workspace-1', - userId: 'user-1', - name: 'refund-policy', - description: 'How to handle refunds', - content: '# Refund policy', - source: 'api', - }) - ) + expect(response.status).toBe(401) + expect(mocks.create).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/skills/route.ts b/apps/sim/app/api/v2/skills/route.ts index 868c1c54979..effbf7f93e0 100644 --- a/apps/sim/app/api/v2/skills/route.ts +++ b/apps/sim/app/api/v2/skills/route.ts @@ -1,55 +1,52 @@ import { v2CreateSkillContract, v2ListSkillsContract } from '@/lib/api/contracts/v2/skills' -import { performCreateSkill } from '@/lib/skills/orchestration' -import { listSkills } from '@/lib/workflows/skills/operations' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2CursorList, v2Data, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' -import { toV2Skill, toV2SkillSummary, v2SkillOrchestrationError } from '@/app/api/v2/skills/utils' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { captureServerEvent } from '@/lib/posthog/server' +import { skillOperations } from '@/lib/skills/application/operations' +import { createSkillUseCase, listSkillsUseCase } from '@/lib/skills/application/use-cases' +import { toV2Skill, toV2SkillSummary } from '@/app/api/v2/skills/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/skills — List skills in a workspace, built-ins included. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListSkillsContract, - rateLimitEndpoint: 'skills', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, search, sortBy, sortOrder } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const skills = await listSkills({ workspaceId, search, sort: { sortBy, sortOrder } }) - - // The per-workspace skill set is small and bounded → a single full page. - return v2CursorList(skills.map(toV2SkillSummary), null, { rateLimit }) - }, + operation: skillOperations.list, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => query, + useCase: listSkillsUseCase, + present: ({ skills }) => ({ data: skills.map(toV2SkillSummary), nextCursor: null }), }) /** POST /api/v2/skills — Create a skill. */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateSkillContract, - rateLimitEndpoint: 'skills', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { workspaceId, name, description, content } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performCreateSkill({ - workspaceId, - userId, - name, - description, - content, - source: 'api', - request, - }) - - if (!result.success || !result.skill) { - return v2SkillOrchestrationError(result.errorCode, result.error ?? 'Failed to create skill') - } - - return v2Data({ skill: toV2Skill(result.skill) }, { rateLimit, status: 201 }) + operation: skillOperations.create, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ body }) => ({ ...body, source: 'api' as const }), + useCase: createSkillUseCase, + onSuccess: ({ principal, input, result }) => { + if (principal.kind !== 'personal_api_key') return + captureServerEvent( + principal.userId, + 'skill_created', + { + skill_id: result.skill.id, + skill_name: result.skill.name, + workspace_id: input.workspaceId, + source: 'api', + }, + { groups: { workspace: input.workspaceId } } + ) }, + present: ({ skill }) => ({ data: { skill: toV2Skill(skill) } }), }) diff --git a/apps/sim/lib/api/contracts/v2/custom-tools.ts b/apps/sim/lib/api/contracts/v2/custom-tools.ts index c2e6221e776..248a505857f 100644 --- a/apps/sim/lib/api/contracts/v2/custom-tools.ts +++ b/apps/sim/lib/api/contracts/v2/custom-tools.ts @@ -128,6 +128,7 @@ export const v2CreateCustomToolContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2CustomToolDataSchema), + status: 201, }, }) diff --git a/apps/sim/lib/api/contracts/v2/mcp-servers.ts b/apps/sim/lib/api/contracts/v2/mcp-servers.ts index 96b40e38b13..181942bfbd8 100644 --- a/apps/sim/lib/api/contracts/v2/mcp-servers.ts +++ b/apps/sim/lib/api/contracts/v2/mcp-servers.ts @@ -196,6 +196,7 @@ export const v2CreateMcpServerContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2McpServerDataSchema), + status: 201, }, }) diff --git a/apps/sim/lib/api/contracts/v2/skills.ts b/apps/sim/lib/api/contracts/v2/skills.ts index 003151aef9f..50f50671a02 100644 --- a/apps/sim/lib/api/contracts/v2/skills.ts +++ b/apps/sim/lib/api/contracts/v2/skills.ts @@ -135,6 +135,7 @@ export const v2CreateSkillContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2SkillDataSchema), + status: 201, }, }) 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 62a0c572814..283a1c6d64b 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -164,6 +164,12 @@ interface V2JsonRouteOptions<C extends JsonApiRouteContract, O extends Applicati principal: V2ApiKeyAuthContext['principal'] params: Record<string, string | string[] | undefined> }): void | Promise<void> + onSuccess?(args: { + principal: V2ApiKeyAuthContext['principal'] + input: NoInfer<I> + result: NoInfer<R> + }): void | Promise<void> + statusForResult?(result: NoInfer<R>): number } export function defineV2JsonRoute< @@ -213,9 +219,10 @@ export function defineV2JsonRoute< if (!parsed.success) return parsed.response try { + const input = options.mapInput(parsed.data) const result = await options.useCase.execute({ principal: auth.principal, - input: options.mapInput(parsed.data), + input, request, }) const body = await options.present(result) @@ -224,8 +231,13 @@ export function defineV2JsonRoute< throw new Error('V2 JSON route response mode changed after initialization') } const validatedBody = responseSchema.schema.parse(body) + const responseStatus = options.statusForResult?.(result) ?? successStatus + if (!Number.isInteger(responseStatus) || responseStatus < 200 || responseStatus >= 300) { + throw new Error(`V2 JSON route produced invalid success status ${responseStatus}`) + } + await options.onSuccess?.({ principal: auth.principal, input, result }) return NextResponse.json(validatedBody, { - status: successStatus, + status: responseStatus, headers: { 'Cache-Control': 'private, no-store' }, }) } catch (error) { diff --git a/apps/sim/lib/copilot/application/execute-custom-tool-use-case.ts b/apps/sim/lib/copilot/application/execute-custom-tool-use-case.ts new file mode 100644 index 00000000000..0ba46365102 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-custom-tool-use-case.ts @@ -0,0 +1,8 @@ +import { createCopilotWorkspaceUseCaseExecutor } from '@/lib/copilot/application/execute-workspace-use-case' +import { CUSTOM_TOOL_DELEGATION_AUDIENCE } from '@/lib/custom-tools/application/authorization' +import { customToolOperations } from '@/lib/custom-tools/application/operations' + +export const executeCopilotCustomToolUseCase = createCopilotWorkspaceUseCaseExecutor({ + audience: CUSTOM_TOOL_DELEGATION_AUDIENCE, + operations: customToolOperations, +}) diff --git a/apps/sim/lib/copilot/application/execute-mcp-server-use-case.ts b/apps/sim/lib/copilot/application/execute-mcp-server-use-case.ts new file mode 100644 index 00000000000..7744e0e0b2a --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-mcp-server-use-case.ts @@ -0,0 +1,8 @@ +import { createCopilotWorkspaceUseCaseExecutor } from '@/lib/copilot/application/execute-workspace-use-case' +import { MCP_SERVER_DELEGATION_AUDIENCE } from '@/lib/mcp/application/authorization' +import { mcpServerOperations } from '@/lib/mcp/application/operations' + +export const executeCopilotMcpServerUseCase = createCopilotWorkspaceUseCaseExecutor({ + audience: MCP_SERVER_DELEGATION_AUDIENCE, + operations: mcpServerOperations, +}) diff --git a/apps/sim/lib/copilot/application/execute-skill-use-case.ts b/apps/sim/lib/copilot/application/execute-skill-use-case.ts new file mode 100644 index 00000000000..8acce8e1e12 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-skill-use-case.ts @@ -0,0 +1,8 @@ +import { createCopilotWorkspaceUseCaseExecutor } from '@/lib/copilot/application/execute-workspace-use-case' +import { SKILL_DELEGATION_AUDIENCE } from '@/lib/skills/application/authorization' +import { skillOperations } from '@/lib/skills/application/operations' + +export const executeCopilotSkillUseCase = createCopilotWorkspaceUseCaseExecutor({ + audience: SKILL_DELEGATION_AUDIENCE, + operations: skillOperations, +}) diff --git a/apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts b/apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts new file mode 100644 index 00000000000..e2aaa045163 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts @@ -0,0 +1,99 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createCopilotWorkspaceUseCaseExecutor } from '@/lib/copilot/application/execute-workspace-use-case' + +const operation = { + id: 'skills.update', + minimumRole: 'read' as const, + workspaceApiKey: 'deny' as const, + principalKinds: ['delegated'] as const, +} + +describe('Copilot workspace application delegation', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('builds a bounded principal from trusted runtime context, never tool input identity', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + const execute = vi.fn().mockResolvedValue({ ok: true }) + const executeCopilotUseCase = createCopilotWorkspaceUseCaseExecutor({ + audience: 'sim:skills', + operations: { update: operation }, + }) + + await executeCopilotUseCase( + { + userId: 'trusted-user', + workspaceId: 'workspace-1', + chatId: 'chat-1', + executionId: 'execution-1', + toolCallId: 'call-1', + copilotToolExecution: true, + }, + { operation, execute }, + { userId: 'model-supplied-user', workspaceId: 'workspace-1' } + ) + + expect(execute).toHaveBeenCalledWith({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'trusted-user', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:call-1', + audience: 'sim:skills', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2026-01-01T00:05:00Z'), + resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, + }, + input: { userId: 'model-supplied-user', workspaceId: 'workspace-1' }, + }) + }) + + it('fails fast for an untrusted execution context', async () => { + const execute = vi.fn() + const executeCopilotUseCase = createCopilotWorkspaceUseCaseExecutor({ + audience: 'sim:skills', + operations: { update: operation }, + }) + + expect(() => + executeCopilotUseCase( + { + userId: 'user-1', + workspaceId: 'workspace-1', + toolCallId: 'call-1', + copilotToolExecution: false, + }, + { operation, execute }, + { workspaceId: 'workspace-1' } + ) + ).toThrow('trusted Copilot execution context') + expect(execute).not.toHaveBeenCalled() + }) + + it('fails fast when a tool adapter tries an unregistered operation', () => { + const executeCopilotUseCase = createCopilotWorkspaceUseCaseExecutor({ + audience: 'sim:skills', + operations: { update: operation }, + }) + const unregistered = { ...operation, id: 'skills.unregistered' } + + expect(() => + executeCopilotUseCase( + { + userId: 'user-1', + workspaceId: 'workspace-1', + toolCallId: 'call-1', + copilotToolExecution: true, + }, + { operation: unregistered, execute: vi.fn() }, + { workspaceId: 'workspace-1' } + ) + ).toThrow('Unregistered Copilot workspace operation') + }) +}) diff --git a/apps/sim/lib/copilot/application/execute-workspace-use-case.ts b/apps/sim/lib/copilot/application/execute-workspace-use-case.ts new file mode 100644 index 00000000000..03774dee872 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-workspace-use-case.ts @@ -0,0 +1,34 @@ +import { + type CopilotWorkspaceDelegationContext, + createCopilotWorkspacePrincipal, +} from '@/lib/copilot/auth/workspace-application-delegation' +import type { OperationUseCase, WorkspaceOperation } from '@/lib/core/application' + +interface CopilotWorkspaceUseCaseExecutorOptions<O extends WorkspaceOperation> { + audience: string + operations: Readonly<Record<string, O>> +} + +/** Binds a domain registry to the trusted Copilot workspace execution runtime. */ +export function createCopilotWorkspaceUseCaseExecutor<O extends WorkspaceOperation>( + options: CopilotWorkspaceUseCaseExecutorOptions<O> +) { + const registeredOperationIds = new Set( + Object.values(options.operations).map((operation) => operation.id) + ) + + return function executeCopilotWorkspaceUseCase<Selected extends O, I, R>( + context: CopilotWorkspaceDelegationContext | undefined, + useCase: OperationUseCase<Selected, I, R>, + input: I + ): Promise<R> { + if (!registeredOperationIds.has(useCase.operation.id)) { + throw new Error(`Unregistered Copilot workspace operation: ${useCase.operation.id}`) + } + + return useCase.execute({ + principal: createCopilotWorkspacePrincipal(context, { audience: options.audience }), + input, + }) + } +} diff --git a/apps/sim/lib/copilot/auth/workspace-application-delegation.ts b/apps/sim/lib/copilot/auth/workspace-application-delegation.ts new file mode 100644 index 00000000000..d2c40aae739 --- /dev/null +++ b/apps/sim/lib/copilot/auth/workspace-application-delegation.ts @@ -0,0 +1,46 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' + +const COPILOT_WORKSPACE_DELEGATION_TTL_MS = 5 * 60 * 1000 + +export interface CopilotWorkspaceDelegationContext { + userId: string + workspaceId?: string + chatId?: string + executionId?: string + toolCallId?: string + copilotToolExecution?: boolean +} + +interface CreateCopilotWorkspacePrincipalOptions { + audience: string +} + +/** Creates a delegated principal exclusively from server-authored Copilot execution context. */ +export function createCopilotWorkspacePrincipal( + context: CopilotWorkspaceDelegationContext | undefined, + options: CreateCopilotWorkspacePrincipalOptions +): DelegatedPrincipal { + if (!context) throw new Error('Workspace delegation requires a Copilot execution context') + if (!context.copilotToolExecution) { + throw new Error('Workspace delegation requires a trusted Copilot execution context') + } + if (!context.toolCallId) throw new Error('Workspace delegation requires a tool call ID') + if (!context.workspaceId) throw new Error('Workspace delegation requires a workspace ID') + if (!options.audience) throw new Error('Workspace delegation requires an audience') + + const issuedAt = new Date() + return { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: context.userId, + workspaceId: context.workspaceId, + delegationId: `copilot-tool:${context.toolCallId}`, + audience: options.audience, + issuedAt, + expiresAt: new Date(issuedAt.getTime() + COPILOT_WORKSPACE_DELEGATION_TTL_MS), + resourceScope: { + ...(context.chatId ? { chatId: context.chatId } : {}), + ...(context.executionId ? { executionId: context.executionId } : {}), + }, + } +} diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts new file mode 100644 index 00000000000..e7ea463273b --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts @@ -0,0 +1,160 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks, useCases } = vi.hoisted(() => ({ + mocks: { + custom: vi.fn(), + mcp: vi.fn(), + skill: vi.fn(), + capture: vi.fn(), + }, + useCases: { + saveCustom: { operation: { id: 'custom_tools.save' } }, + deleteCustom: { operation: { id: 'custom_tools.delete_available' } }, + listCustom: { operation: { id: 'custom_tools.list_available' } }, + updateCustom: { operation: { id: 'custom_tools.update_available' } }, + deleteMcp: { operation: { id: 'mcp_servers.delete' } }, + listMcp: { operation: { id: 'mcp_servers.list' } }, + reconfigureMcp: { operation: { id: 'mcp_servers.reconfigure' } }, + registerMcp: { operation: { id: 'mcp_servers.register' } }, + createSkill: { operation: { id: 'skills.create' } }, + deleteSkill: { operation: { id: 'skills.delete' } }, + listSkill: { operation: { id: 'skills.list_available' } }, + updateSkill: { operation: { id: 'skills.update' } }, + }, +})) + +vi.mock('@/lib/copilot/application/execute-custom-tool-use-case', () => ({ + executeCopilotCustomToolUseCase: mocks.custom, +})) +vi.mock('@/lib/copilot/application/execute-mcp-server-use-case', () => ({ + executeCopilotMcpServerUseCase: mocks.mcp, +})) +vi.mock('@/lib/copilot/application/execute-skill-use-case', () => ({ + executeCopilotSkillUseCase: mocks.skill, +})) +vi.mock('@/lib/custom-tools/application/use-cases', () => ({ + deleteAvailableCustomToolUseCase: useCases.deleteCustom, + listAvailableCustomToolsUseCase: useCases.listCustom, + saveWorkspaceCustomToolUseCase: useCases.saveCustom, + updateAvailableCustomToolUseCase: useCases.updateCustom, +})) +vi.mock('@/lib/mcp/application/use-cases', () => ({ + deleteMcpServerUseCase: useCases.deleteMcp, + listMcpServersUseCase: useCases.listMcp, + reconfigureMcpServerUseCase: useCases.reconfigureMcp, + registerMcpServerUseCase: useCases.registerMcp, +})) +vi.mock('@/lib/skills/application/use-cases', () => ({ + createSkillUseCase: useCases.createSkill, + deleteSkillUseCase: useCases.deleteSkill, + listAvailableSkillsUseCase: useCases.listSkill, + updateSkillUseCase: useCases.updateSkill, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeManageCustomTool } from '@/lib/copilot/tools/handlers/management/manage-custom-tool' +import { executeManageMcpTool } from '@/lib/copilot/tools/handlers/management/manage-mcp-tool' +import { executeManageSkill } from '@/lib/copilot/tools/handlers/management/manage-skill' + +const context: ExecutionContext = { + userId: 'user-1', + workflowId: '', + workspaceId: 'workspace-1', + chatId: 'chat-1', + toolCallId: 'call-1', + copilotToolExecution: true, + userPermission: 'admin', +} + +describe('Copilot management application boundaries', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('creates custom tools through the shared use case using server workspace context', async () => { + mocks.custom.mockResolvedValue({ + tool: { id: 'tool-1', title: 'lookup_order' }, + }) + + const result = await executeManageCustomTool( + { + operation: 'add', + workspaceId: 'model-workspace', + title: 'lookup_order', + schema: { + type: 'function', + function: { name: 'lookup_order', parameters: {} }, + }, + code: 'return 1', + }, + context + ) + + expect(result).toMatchObject({ success: true, output: { toolId: 'tool-1' } }) + expect(mocks.custom).toHaveBeenCalledWith(context, useCases.saveCustom, { + workspaceId: context.workspaceId, + title: 'lookup_order', + schema: { + type: 'function', + function: { name: 'lookup_order', parameters: {} }, + }, + code: 'return 1', + source: 'tool_input', + }) + }) + + it('keeps Copilot MCP registration compatibility behind its semantic operation', async () => { + mocks.mcp.mockResolvedValue({ + serverId: 'legacy-result-id', + server: { + id: 'mcp-server-1', + name: 'Docs', + transport: 'streamable-http', + }, + updated: true, + }) + + const result = await executeManageMcpTool( + { + operation: 'add', + config: { name: 'Docs', url: 'https://mcp.example.com/sse' }, + }, + context + ) + + expect(result).toMatchObject({ success: true, output: { serverId: 'mcp-server-1' } }) + expect(mocks.mcp).toHaveBeenCalledWith( + context, + useCases.registerMcp, + expect.objectContaining({ workspaceId: context.workspaceId, source: 'tool_input' }) + ) + expect(mocks.capture).not.toHaveBeenCalled() + }) + + it('delegates skill-specific edit authorization to the shared application use case', async () => { + mocks.skill.mockResolvedValue({ + skill: { id: 'skill-1', name: 'refund-policy' }, + }) + + const result = await executeManageSkill( + { operation: 'edit', skillId: 'skill-1', content: '# Updated' }, + { ...context, userPermission: 'read' } + ) + + expect(result).toMatchObject({ success: true, output: { skillId: 'skill-1' } }) + expect(mocks.skill).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: context.workspaceId }), + useCases.updateSkill, + { + workspaceId: context.workspaceId, + skillId: 'skill-1', + content: '# Updated', + source: 'tool_input', + } + ) + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts index d1491ff9af9..a0c5cbb9089 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts @@ -1,15 +1,15 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' +import { toError } from '@sim/utils/errors' +import { executeCopilotCustomToolUseCase } from '@/lib/copilot/application/execute-custom-tool-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { copilotToolCanWrite, copilotWriteDeniedMessage } from '@/lib/copilot/tools/permissions' -import { captureServerEvent } from '@/lib/posthog/server' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { - deleteCustomTool, - getCustomToolById, - listCustomTools, - upsertCustomTools, -} from '@/lib/workflows/custom-tools/operations' + deleteAvailableCustomToolUseCase, + listAvailableCustomToolsUseCase, + saveWorkspaceCustomToolUseCase, + updateAvailableCustomToolUseCase, +} from '@/lib/custom-tools/application/use-cases' +import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CopilotToolExecutor') @@ -53,20 +53,14 @@ export async function executeManageCustomTool( return { success: false, error: "Missing required 'operation' argument" } } - const writeOps: string[] = ['add', 'edit', 'delete'] - if (writeOps.includes(operation) && !copilotToolCanWrite(context.userPermission)) { - return { - success: false, - error: copilotWriteDeniedMessage('manage_custom_tool', operation, context.userPermission), - } - } - try { if (operation === 'list') { - const toolsForUser = await listCustomTools({ - userId: context.userId, - workspaceId, - }) + if (!workspaceId) return { success: false, error: 'workspaceId is required' } + const { tools: toolsForUser } = await executeCopilotCustomToolUseCase( + context, + listAvailableCustomToolsUseCase, + { workspaceId } + ) return { success: true, @@ -98,45 +92,37 @@ export async function executeManageCustomTool( return { success: false, error: "Missing tool title or schema.function.name for 'add'" } } - const resultTools = await upsertCustomTools({ - tools: [{ title, schema: params.schema, code: params.code }], - workspaceId, - userId: context.userId, - }) - const created = resultTools.find((tool) => tool.title === title) - - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.CUSTOM_TOOL_CREATED, - resourceType: AuditResourceType.CUSTOM_TOOL, - resourceId: created?.id, - resourceName: title, - description: `Created custom tool "${title}"`, - metadata: { source: 'tool_input' }, - }) - if (created?.id) { - captureServerEvent( - context.userId, - 'custom_tool_saved', - { - tool_id: created.id, - workspace_id: workspaceId, - tool_name: title, - source: 'tool_input', - }, - { groups: { workspace: workspaceId } } - ) - } + const { tool: created } = await executeCopilotCustomToolUseCase( + context, + saveWorkspaceCustomToolUseCase, + { + title, + schema: params.schema, + code: params.code, + source: 'tool_input', + workspaceId, + } + ) + captureServerEvent( + context.userId, + 'custom_tool_saved', + { + tool_id: created.id, + workspace_id: workspaceId, + tool_name: created.title, + source: 'tool_input', + }, + { groups: { workspace: workspaceId } } + ) return { success: true, output: { success: true, operation, - toolId: created?.id, - title, - message: `Created custom tool "${title}"`, + toolId: created.id, + title: created.title, + message: `Created custom tool "${created.title}"`, }, } } @@ -158,42 +144,25 @@ export async function executeManageCustomTool( } } - const existing = await getCustomToolById({ - toolId: params.toolId, - userId: context.userId, - workspaceId, - }) - if (!existing) { - return { success: false, error: `Custom tool not found: ${params.toolId}` } - } - - const mergedSchema = params.schema || (existing.schema as ManageCustomToolSchema) - const mergedCode = params.code || existing.code - const title = params.title || mergedSchema.function?.name || existing.title - - await upsertCustomTools({ - tools: [{ id: params.toolId, title, schema: mergedSchema, code: mergedCode }], - workspaceId, - userId: context.userId, - }) - - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.CUSTOM_TOOL_UPDATED, - resourceType: AuditResourceType.CUSTOM_TOOL, - resourceId: params.toolId, - resourceName: title, - description: `Updated custom tool "${title}"`, - metadata: { source: 'tool_input' }, - }) + const { tool } = await executeCopilotCustomToolUseCase( + context, + updateAvailableCustomToolUseCase, + { + workspaceId, + toolId: params.toolId, + title: params.title || params.schema?.function?.name, + schema: params.schema, + code: params.code, + source: 'tool_input', + } + ) captureServerEvent( context.userId, 'custom_tool_saved', { - tool_id: params.toolId, + tool_id: tool.id, workspace_id: workspaceId, - tool_name: title, + tool_name: tool.title, source: 'tool_input', }, { groups: { workspace: workspaceId } } @@ -204,9 +173,9 @@ export async function executeManageCustomTool( output: { success: true, operation, - toolId: params.toolId, - title, - message: `Updated custom tool "${title}"`, + toolId: tool.id, + title: tool.title, + message: `Updated custom tool "${tool.title}"`, }, } } @@ -216,41 +185,35 @@ export async function executeManageCustomTool( if (toolIds.length === 0) { return { success: false, error: "'toolId' or 'toolIds' is required for operation 'delete'" } } - + if (!workspaceId) return { success: false, error: 'workspaceId is required' } const deleted: string[] = [] const notFound: string[] = [] for (const toolId of toolIds) { - const result = await deleteCustomTool({ - toolId, - userId: context.userId, - workspaceId, - }) - if (result) { + try { + await executeCopilotCustomToolUseCase(context, deleteAvailableCustomToolUseCase, { + toolId, + workspaceId, + source: 'tool_input', + }) deleted.push(toolId) - } else { - notFound.push(toolId) + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code === 'not_found') { + notFound.push(toolId) + continue + } + throw error } } for (const toolId of deleted) { - recordAudit({ - workspaceId: workspaceId ?? null, - actorId: context.userId, - action: AuditAction.CUSTOM_TOOL_DELETED, - resourceType: AuditResourceType.CUSTOM_TOOL, - resourceId: toolId, - description: 'Deleted custom tool', - metadata: { source: 'tool_input' }, - }) - if (workspaceId) { - captureServerEvent( - context.userId, - 'custom_tool_deleted', - { tool_id: toolId, workspace_id: workspaceId, source: 'tool_input' }, - { groups: { workspace: workspaceId } } - ) - } + captureServerEvent( + context.userId, + 'custom_tool_deleted', + { tool_id: toolId, workspace_id: workspaceId, source: 'tool_input' }, + { groups: { workspace: workspaceId } } + ) } return { @@ -281,9 +244,13 @@ export async function executeManageCustomTool( error: toError(error).message, } ) + const classified = asOrchestrationError(error) return { success: false, - error: getErrorMessage(error, 'Failed to manage custom tool'), + error: + classified && classified.code !== 'internal' + ? classified.message + : 'Failed to manage custom tool', } } } diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts index c13ba02c76e..5158176f27c 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts @@ -1,15 +1,15 @@ -import { db } from '@sim/db' -import { mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' -import { and, eq, isNull } from 'drizzle-orm' +import { toError } from '@sim/utils/errors' +import { executeCopilotMcpServerUseCase } from '@/lib/copilot/application/execute-mcp-server-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { copilotToolCanWrite, copilotWriteDeniedMessage } from '@/lib/copilot/tools/permissions' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { - performCreateMcpServer, - performDeleteMcpServer, - performUpdateMcpServer, -} from '@/lib/mcp/orchestration' + deleteMcpServerUseCase, + listMcpServersUseCase, + reconfigureMcpServerUseCase, + registerMcpServerUseCase, +} from '@/lib/mcp/application/use-cases' +import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CopilotToolExecutor') @@ -46,20 +46,11 @@ export async function executeManageMcpTool( return { success: false, error: 'workspaceId is required' } } - const writeOps: string[] = ['add', 'edit', 'delete'] - if (writeOps.includes(operation) && !copilotToolCanWrite(context.userPermission)) { - return { - success: false, - error: copilotWriteDeniedMessage('manage_mcp_tool', operation, context.userPermission), - } - } - try { if (operation === 'list') { - const servers = await db - .select() - .from(mcpServers) - .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) + const { servers } = await executeCopilotMcpServerUseCase(context, listMcpServersUseCase, { + workspaceId, + }) return { success: true, @@ -85,9 +76,8 @@ export async function executeManageMcpTool( return { success: false, error: "config.name and config.url are required for 'add'" } } - const result = await performCreateMcpServer({ + const result = await executeCopilotMcpServerUseCase(context, registerMcpServerUseCase, { workspaceId, - userId: context.userId, name: config.name, description: '', transport: config.transport || 'streamable-http', @@ -98,11 +88,21 @@ export async function executeManageMcpTool( enabled: config.enabled, source: 'tool_input', }) - if (!result.success || !result.serverId) { - return { - success: false, - error: result.error || `Failed to add MCP server "${config.name}"`, - } + if (!result.updated) { + captureServerEvent( + context.userId, + 'mcp_server_connected', + { + workspace_id: workspaceId, + server_name: result.server.name, + transport: result.server.transport, + source: 'tool_input', + }, + { + groups: { workspace: workspaceId }, + setOnce: { first_mcp_connected_at: new Date().toISOString() }, + } + ) } return { @@ -110,7 +110,7 @@ export async function executeManageMcpTool( output: { success: true, operation, - serverId: result.serverId, + serverId: result.server.id, name: config.name, message: result.updated ? `Updated existing MCP server "${config.name}"` @@ -128,9 +128,8 @@ export async function executeManageMcpTool( return { success: false, error: "'config' is required for 'edit'" } } - const result = await performUpdateMcpServer({ + const result = await executeCopilotMcpServerUseCase(context, reconfigureMcpServerUseCase, { workspaceId, - userId: context.userId, serverId: params.serverId, name: config.name, transport: config.transport, @@ -138,10 +137,8 @@ export async function executeManageMcpTool( headers: config.headers, timeout: config.timeout, enabled: config.enabled, + source: 'tool_input', }) - if (!result.success || !result.server) { - return { success: false, error: `MCP server not found: ${params.serverId}` } - } return { success: true, @@ -160,15 +157,21 @@ export async function executeManageMcpTool( return { success: false, error: "'serverId' is required for 'delete'" } } - const result = await performDeleteMcpServer({ + const result = await executeCopilotMcpServerUseCase(context, deleteMcpServerUseCase, { workspaceId, - userId: context.userId, serverId: params.serverId, source: 'tool_input', }) - if (!result.success || !result.server) { - return { success: false, error: `MCP server not found: ${params.serverId}` } - } + captureServerEvent( + context.userId, + 'mcp_server_disconnected', + { + workspace_id: workspaceId, + server_name: result.server.name, + source: 'tool_input', + }, + { groups: { workspace: workspaceId } } + ) return { success: true, @@ -193,9 +196,13 @@ export async function executeManageMcpTool( error: toError(error).message, } ) + const classified = asOrchestrationError(error) return { success: false, - error: getErrorMessage(error, 'Failed to manage MCP server'), + error: + classified && classified.code !== 'internal' + ? classified.message + : 'Failed to manage MCP server', } } } diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts index c68debf0af0..d17bac0bfd6 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts @@ -1,13 +1,15 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' +import { toError } from '@sim/utils/errors' +import { executeCopilotSkillUseCase } from '@/lib/copilot/application/execute-skill-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { copilotToolCanWrite, copilotWriteDeniedMessage } from '@/lib/copilot/tools/permissions' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { captureServerEvent } from '@/lib/posthog/server' import { - performCreateSkill, - performDeleteSkill, - performUpdateSkill, -} from '@/lib/skills/orchestration' -import { listSkillsForUser } from '@/lib/workflows/skills/operations' + createSkillUseCase, + deleteSkillUseCase, + listAvailableSkillsUseCase, + updateSkillUseCase, +} from '@/lib/skills/application/use-cases' const logger = createLogger('CopilotToolExecutor') @@ -37,18 +39,11 @@ export async function executeManageSkill( return { success: false, error: 'workspaceId is required' } } - // Workspace write gates only creation; edits and deletes are gated per skill - // below (skill editor — explicit editor row or derived workspace admin). - if (operation === 'add' && !copilotToolCanWrite(context.userPermission)) { - return { - success: false, - error: copilotWriteDeniedMessage('manage_skill', operation, context.userPermission), - } - } - try { if (operation === 'list') { - const skills = await listSkillsForUser({ workspaceId, userId: context.userId }) + const { skills } = await executeCopilotSkillUseCase(context, listAvailableSkillsUseCase, { + workspaceId, + }) return { success: true, @@ -74,26 +69,33 @@ export async function executeManageSkill( } } - const result = await performCreateSkill({ + const { skill } = await executeCopilotSkillUseCase(context, createSkillUseCase, { workspaceId, - userId: context.userId, name: params.name, description: params.description, content: params.content, source: 'tool_input', }) - if (!result.success || !result.skill) { - return { success: false, error: result.error ?? 'Failed to create skill' } - } + captureServerEvent( + context.userId, + 'skill_created', + { + skill_id: skill.id, + skill_name: skill.name, + workspace_id: workspaceId, + source: 'tool_input', + }, + { groups: { workspace: workspaceId } } + ) return { success: true, output: { success: true, operation, - skillId: result.skill.id, - name: result.skill.name, - message: `Created skill "${result.skill.name}"`, + skillId: skill.id, + name: skill.name, + message: `Created skill "${skill.name}"`, }, } } @@ -109,28 +111,34 @@ export async function executeManageSkill( } } - // Partial update: omitted fields keep their current values server-side. - const result = await performUpdateSkill({ + const { skill } = await executeCopilotSkillUseCase(context, updateSkillUseCase, { workspaceId, - userId: context.userId, skillId: params.skillId, ...(params.name ? { name: params.name } : {}), ...(params.description ? { description: params.description } : {}), ...(params.content ? { content: params.content } : {}), source: 'tool_input', }) - if (!result.success || !result.skill) { - return { success: false, error: result.error ?? 'Failed to update skill' } - } + captureServerEvent( + context.userId, + 'skill_updated', + { + skill_id: skill.id, + skill_name: skill.name, + workspace_id: workspaceId, + source: 'tool_input', + }, + { groups: { workspace: workspaceId } } + ) return { success: true, output: { success: true, operation, - skillId: result.skill.id, - name: result.skill.name, - message: `Updated skill "${result.skill.name}"`, + skillId: skill.id, + name: skill.name, + message: `Updated skill "${skill.name}"`, }, } } @@ -140,22 +148,24 @@ export async function executeManageSkill( return { success: false, error: "'skillId' is required for 'delete'" } } - const result = await performDeleteSkill({ + const { skill } = await executeCopilotSkillUseCase(context, deleteSkillUseCase, { workspaceId, - userId: context.userId, skillId: params.skillId, source: 'tool_input', }) - if (!result.success) { - return { success: false, error: result.error ?? 'Failed to delete skill' } - } + captureServerEvent( + context.userId, + 'skill_deleted', + { skill_id: skill.id, workspace_id: workspaceId, source: 'tool_input' }, + { groups: { workspace: workspaceId } } + ) return { success: true, output: { success: true, operation, - skillId: params.skillId, + skillId: skill.id, message: 'Deleted skill', }, } @@ -173,9 +183,13 @@ export async function executeManageSkill( error: toError(error).message, } ) + const classified = asOrchestrationError(error) return { success: false, - error: getErrorMessage(error, 'Failed to manage skill'), + error: + classified && classified.code !== 'internal' + ? classified.message + : 'Failed to manage skill', } } } diff --git a/apps/sim/lib/custom-tools/application/authorization.ts b/apps/sim/lib/custom-tools/application/authorization.ts new file mode 100644 index 00000000000..84e65bdf921 --- /dev/null +++ b/apps/sim/lib/custom-tools/application/authorization.ts @@ -0,0 +1,13 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegationPolicy } from '@/lib/core/application' + +export const CUSTOM_TOOL_DELEGATION_AUDIENCE = 'sim:custom-tools' + +export const customToolDelegationPolicy = { + audience: CUSTOM_TOOL_DELEGATION_AUDIENCE, + isWithinScope: (principal: DelegatedPrincipal) => principal.serviceId === 'copilot', +} as const satisfies WorkspaceDelegationPolicy<{ + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean +}> diff --git a/apps/sim/lib/custom-tools/application/operations.ts b/apps/sim/lib/custom-tools/application/operations.ts new file mode 100644 index 00000000000..eff480f5782 --- /dev/null +++ b/apps/sim/lib/custom-tools/application/operations.ts @@ -0,0 +1,68 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const ALL_PRINCIPAL_KINDS = [ + 'session', + 'personal_api_key', + 'workspace_api_key', + 'delegated', +] as const +const HUMAN_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'delegated'] as const + +export const customToolOperations = { + list: defineWorkspaceOperation({ + id: 'custom_tools.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + listAvailable: defineWorkspaceOperation({ + id: 'custom_tools.list_available', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: HUMAN_PRINCIPAL_KINDS, + }), + read: defineWorkspaceOperation({ + id: 'custom_tools.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + create: defineWorkspaceOperation({ + id: 'custom_tools.create', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + save: defineWorkspaceOperation({ + id: 'custom_tools.save', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + update: defineWorkspaceOperation({ + id: 'custom_tools.update', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + updateAvailable: defineWorkspaceOperation({ + id: 'custom_tools.update_available', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: HUMAN_PRINCIPAL_KINDS, + }), + delete: defineWorkspaceOperation({ + id: 'custom_tools.delete', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + deleteAvailable: defineWorkspaceOperation({ + id: 'custom_tools.delete_available', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: HUMAN_PRINCIPAL_KINDS, + }), +} as const + +export type CustomToolOperation = (typeof customToolOperations)[keyof typeof customToolOperations] diff --git a/apps/sim/lib/custom-tools/application/use-cases.test.ts b/apps/sim/lib/custom-tools/application/use-cases.test.ts new file mode 100644 index 00000000000..fb14babae18 --- /dev/null +++ b/apps/sim/lib/custom-tools/application/use-cases.test.ts @@ -0,0 +1,187 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks } = vi.hoisted(() => ({ + mocks: { + loadContext: vi.fn(), + resolvePermission: vi.fn(), + getByTitle: vi.fn(), + upsert: vi.fn(), + audit: vi.fn(), + }, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + loadActiveWorkspaceContext: mocks.loadContext, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { + CUSTOM_TOOL_CREATED: 'custom_tool.created', + CUSTOM_TOOL_UPDATED: 'custom_tool.updated', + CUSTOM_TOOL_DELETED: 'custom_tool.deleted', + }, + AuditResourceType: { CUSTOM_TOOL: 'custom_tool' }, + recordAudit: mocks.audit, +})) +vi.mock('@/lib/workflows/custom-tools/operations', () => ({ + deleteCustomTool: vi.fn(), + deleteWorkspaceCustomTool: vi.fn(), + getCustomToolById: vi.fn(), + getWorkspaceCustomTool: vi.fn(), + getWorkspaceCustomToolByTitle: mocks.getByTitle, + listCustomTools: vi.fn(), + listWorkspaceCustomTools: vi.fn(), + updateCustomTool: vi.fn(), + updateWorkspaceCustomTool: vi.fn(), + upsertCustomTools: mocks.upsert, +})) + +import { CUSTOM_TOOL_DELEGATION_AUDIENCE } from '@/lib/custom-tools/application/authorization' +import { + createWorkspaceCustomToolUseCase, + saveWorkspaceCustomToolUseCase, +} from '@/lib/custom-tools/application/use-cases' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', +} +const tool = { + id: 'tool-1', + workspaceId: workspace.workspaceId, + userId: 'owner-1', + title: 'lookup_order', + schema: { type: 'function' }, + code: 'return 1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} + +describe('custom tool application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.getByTitle.mockResolvedValue(null) + mocks.upsert.mockResolvedValue([tool]) + }) + + it('uses compatibility attribution without impersonating a workspace-key audit actor', async () => { + const principal = { + kind: 'workspace_api_key' as const, + workspaceId: workspace.workspaceId, + keyId: 'workspace-key-1', + } + + const result = await createWorkspaceCustomToolUseCase.execute({ + principal, + input: { + workspaceId: workspace.workspaceId, + title: tool.title, + schema: tool.schema, + code: tool.code, + source: 'api', + }, + }) + + expect(result.tool.id).toBe(tool.id) + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.upsert).toHaveBeenCalledWith({ + tools: [{ title: tool.title, schema: tool.schema, code: tool.code }], + workspaceId: workspace.workspaceId, + userId: workspace.billedAccountUserId, + }) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: null, + actorName: 'Workspace API key', + metadata: expect.objectContaining({ + operation: 'custom_tools.create', + actor: { + kind: 'workspace_api_key', + keyId: 'workspace-key-1', + workspaceId: workspace.workspaceId, + }, + }), + }) + ) + }) + + it('returns a typed conflict and does not audit a rejected create', async () => { + mocks.getByTitle.mockResolvedValueOnce(tool) + + await expect( + createWorkspaceCustomToolUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: workspace.workspaceId, + title: tool.title, + schema: tool.schema, + code: tool.code, + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + + expect(mocks.upsert).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('normalizes the in-transaction duplicate-title error for strict creates', async () => { + mocks.upsert.mockRejectedValueOnce( + new Error(`A tool with the title "${tool.title}" already exists in this workspace`) + ) + + await expect( + createWorkspaceCustomToolUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: workspace.workspaceId, + title: tool.title, + schema: tool.schema, + code: tool.code, + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('normalizes the in-transaction duplicate-title error for compatibility saves', async () => { + mocks.upsert.mockRejectedValueOnce( + new Error(`A tool with the title "${tool.title}" already exists in this workspace`) + ) + + await expect( + saveWorkspaceCustomToolUseCase.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: workspace.workspaceId, + delegationId: 'delegation-1', + audience: CUSTOM_TOOL_DELEGATION_AUDIENCE, + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + }, + input: { + workspaceId: workspace.workspaceId, + title: tool.title, + schema: tool.schema, + code: tool.code, + source: 'tool_input', + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + + expect(mocks.audit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/custom-tools/application/use-cases.ts b/apps/sim/lib/custom-tools/application/use-cases.ts new file mode 100644 index 00000000000..71a58139e83 --- /dev/null +++ b/apps/sim/lib/custom-tools/application/use-cases.ts @@ -0,0 +1,385 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import type { customTools } from '@sim/db/schema' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' +import type { ListSortOrder } from '@/lib/api/list-query' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { customToolDelegationPolicy } from '@/lib/custom-tools/application/authorization' +import { customToolOperations } from '@/lib/custom-tools/application/operations' +import { loadActiveWorkspaceContext } from '@/lib/uploads/contexts/workspace' +import { + type CustomToolSortBy, + deleteCustomTool, + deleteWorkspaceCustomTool, + getCustomToolById, + getWorkspaceCustomTool, + getWorkspaceCustomToolByTitle, + listCustomTools, + listWorkspaceCustomTools, + updateCustomTool, + updateWorkspaceCustomTool, + upsertCustomTools, +} from '@/lib/workflows/custom-tools/operations' + +type CustomToolRow = typeof customTools.$inferSelect +type CustomToolWriteSource = 'api' | 'settings' | 'tool_input' + +interface CustomToolWorkspaceContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} + +interface CustomToolContext extends CustomToolWorkspaceContext { + tool: CustomToolRow +} + +async function resolveWorkspaceContext(workspaceId: string): Promise<CustomToolWorkspaceContext> { + const context = await loadActiveWorkspaceContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} + +async function resolveWorkspaceToolContext( + workspaceId: string, + toolId: string +): Promise<CustomToolContext> { + const workspace = await resolveWorkspaceContext(workspaceId) + const tool = await getWorkspaceCustomTool({ workspaceId: workspace.workspaceId, toolId }) + if (!tool) throw new OrchestrationError('not_found', 'Custom tool not found') + return { ...workspace, tool } +} + +function humanUserId(principal: Exclude<Principal, { kind: 'workspace_api_key' }>): string { + return principal.kind === 'delegated' ? principal.subjectUserId : principal.userId +} + +async function resolveAvailableToolContext(args: { + principal: Exclude<Principal, { kind: 'workspace_api_key' }> + workspaceId: string + toolId: string +}): Promise<CustomToolContext> { + const workspace = await resolveWorkspaceContext(args.workspaceId) + const tool = await getCustomToolById({ + toolId: args.toolId, + userId: humanUserId(args.principal), + workspaceId: workspace.workspaceId, + }) + if (!tool) throw new OrchestrationError('not_found', 'Custom tool not found') + return { ...workspace, tool } +} + +function customToolConflict(error: unknown): never { + if (getPostgresErrorCode(error) === '23505') { + throw new OrchestrationError( + '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)) { + throw new OrchestrationError('conflict', message) + } + throw error +} + +const authorizationOptions = { delegation: customToolDelegationPolicy } + +export interface ListWorkspaceCustomToolsInput { + workspaceId: string + search?: string + sortBy?: CustomToolSortBy + sortOrder?: ListSortOrder +} + +export const listWorkspaceCustomToolsUseCase = defineAuthorizedWorkspaceUseCase({ + operation: customToolOperations.list, + resolveContext: ({ input }: { input: ListWorkspaceCustomToolsInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ input, context }) { + const tools = await listWorkspaceCustomTools({ ...input, workspaceId: context.workspaceId }) + return { tools } + }, +}) + +export interface ListAvailableCustomToolsInput { + workspaceId: string +} + +export const listAvailableCustomToolsUseCase = defineAuthorizedWorkspaceUseCase({ + operation: customToolOperations.listAvailable, + resolveContext: ({ input }: { input: ListAvailableCustomToolsInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ principal, context }) { + const tools = await listCustomTools({ + userId: humanUserId(principal), + workspaceId: context.workspaceId, + }) + return { tools } + }, +}) + +export interface GetWorkspaceCustomToolInput { + workspaceId: string + toolId: string +} + +export const getWorkspaceCustomToolUseCase = defineAuthorizedWorkspaceUseCase({ + operation: customToolOperations.read, + resolveContext: ({ input }: { input: GetWorkspaceCustomToolInput }) => + resolveWorkspaceToolContext(input.workspaceId, input.toolId), + authorizationOptions, + async execute({ context }) { + return { tool: context.tool } + }, +}) + +export interface CreateWorkspaceCustomToolInput { + workspaceId: string + title: string + schema: unknown + code: string + source?: CustomToolWriteSource +} + +export const createWorkspaceCustomToolUseCase = defineAuthorizedWorkspaceUseCase({ + operation: customToolOperations.create, + resolveContext: ({ input }: { input: CreateWorkspaceCustomToolInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ principal, input, context }) { + if ( + await getWorkspaceCustomToolByTitle({ workspaceId: context.workspaceId, title: input.title }) + ) { + throw new OrchestrationError( + 'conflict', + `A custom tool titled "${input.title}" already exists in this workspace` + ) + } + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + try { + const tools = await upsertCustomTools({ + tools: [{ title: input.title, schema: input.schema, code: input.code }], + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + }) + const tool = tools.find((candidate) => candidate.title === input.title) + if (!tool) throw new Error(`Custom tool "${input.title}" missing after a successful write`) + return { tool } + } catch (error) { + return customToolConflict(error) + } + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.CUSTOM_TOOL_CREATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: result.tool.id, + resourceName: result.tool.title, + description: `Created custom tool "${result.tool.title}"`, + metadata: { source: input.source }, + }), +}) + +export const saveWorkspaceCustomToolUseCase = defineAuthorizedWorkspaceUseCase({ + operation: customToolOperations.save, + resolveContext: ({ input }: { input: CreateWorkspaceCustomToolInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + try { + const tools = await upsertCustomTools({ + tools: [{ title: input.title, schema: input.schema, code: input.code }], + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + }) + const tool = tools.find((candidate) => candidate.title === input.title) + if (!tool) throw new Error(`Custom tool "${input.title}" missing after a successful save`) + return { tool } + } catch (error) { + return customToolConflict(error) + } + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.CUSTOM_TOOL_CREATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: result.tool.id, + resourceName: result.tool.title, + description: `Created custom tool "${result.tool.title}"`, + metadata: { source: input.source }, + }), +}) + +interface UpdateCustomToolFields { + title?: string + schema?: unknown + code?: string + source?: CustomToolWriteSource +} + +export interface UpdateWorkspaceCustomToolInput extends UpdateCustomToolFields { + workspaceId: string + toolId: string +} + +async function ensureTitleAvailable(context: CustomToolContext, title: string): Promise<void> { + if (title === context.tool.title) return + if (context.tool.workspaceId === null) return + const collision = await getWorkspaceCustomToolByTitle({ + workspaceId: context.workspaceId, + title, + }) + if (collision && collision.id !== context.tool.id) { + throw new OrchestrationError( + 'conflict', + `A custom tool titled "${title}" already exists in this workspace` + ) + } +} + +export const updateWorkspaceCustomToolUseCase = defineAuthorizedWorkspaceUseCase({ + operation: customToolOperations.update, + resolveContext: ({ input }: { input: UpdateWorkspaceCustomToolInput }) => + resolveWorkspaceToolContext(input.workspaceId, input.toolId), + authorizationOptions, + async execute({ input, context }) { + const title = input.title ?? context.tool.title + await ensureTitleAvailable(context, title) + try { + const tool = await updateWorkspaceCustomTool({ + workspaceId: context.workspaceId, + toolId: context.tool.id, + title, + schema: input.schema ?? context.tool.schema, + code: input.code ?? context.tool.code, + }) + if (!tool) throw new OrchestrationError('not_found', 'Custom tool not found') + return { tool } + } catch (error) { + return customToolConflict(error) + } + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.CUSTOM_TOOL_UPDATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: result.tool.id, + resourceName: result.tool.title, + description: `Updated custom tool "${result.tool.title}"`, + metadata: { source: input.source }, + }), +}) + +export const updateAvailableCustomToolUseCase = defineAuthorizedWorkspaceUseCase({ + operation: customToolOperations.updateAvailable, + resolveContext: ({ + principal, + input, + }: { + principal: Exclude<Principal, { kind: 'workspace_api_key' }> + input: UpdateWorkspaceCustomToolInput + }) => + resolveAvailableToolContext({ + principal, + workspaceId: input.workspaceId, + toolId: input.toolId, + }), + authorizationOptions, + async execute({ principal, input, context }) { + const title = input.title ?? context.tool.title + await ensureTitleAvailable(context, title) + try { + const tool = await updateCustomTool({ + workspaceId: context.workspaceId, + toolId: context.tool.id, + userId: humanUserId(principal), + title, + schema: input.schema ?? context.tool.schema, + code: input.code ?? context.tool.code, + }) + if (!tool) throw new OrchestrationError('not_found', 'Custom tool not found') + return { tool } + } catch (error) { + return customToolConflict(error) + } + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.CUSTOM_TOOL_UPDATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: result.tool.id, + resourceName: result.tool.title, + description: `Updated custom tool "${result.tool.title}"`, + metadata: { source: input.source }, + }), +}) + +export interface DeleteWorkspaceCustomToolInput { + workspaceId: string + toolId: string + source?: CustomToolWriteSource +} + +export const deleteWorkspaceCustomToolUseCase = defineAuthorizedWorkspaceUseCase({ + operation: customToolOperations.delete, + resolveContext: ({ input }: { input: DeleteWorkspaceCustomToolInput }) => + resolveWorkspaceToolContext(input.workspaceId, input.toolId), + authorizationOptions, + async execute({ context }) { + const deleted = await deleteWorkspaceCustomTool({ + workspaceId: context.workspaceId, + toolId: context.tool.id, + }) + if (!deleted) throw new OrchestrationError('not_found', 'Custom tool not found') + return { tool: context.tool } + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.CUSTOM_TOOL_DELETED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: result.tool.id, + resourceName: result.tool.title, + description: `Deleted custom tool "${result.tool.title}"`, + metadata: { source: input.source }, + }), +}) + +export const deleteAvailableCustomToolUseCase = defineAuthorizedWorkspaceUseCase({ + operation: customToolOperations.deleteAvailable, + resolveContext: ({ + principal, + input, + }: { + principal: Exclude<Principal, { kind: 'workspace_api_key' }> + input: DeleteWorkspaceCustomToolInput + }) => + resolveAvailableToolContext({ + principal, + workspaceId: input.workspaceId, + toolId: input.toolId, + }), + authorizationOptions, + async execute({ principal, context }) { + const deleted = await deleteCustomTool({ + workspaceId: context.workspaceId, + toolId: context.tool.id, + userId: humanUserId(principal), + }) + if (!deleted) throw new OrchestrationError('not_found', 'Custom tool not found') + return { tool: context.tool } + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.CUSTOM_TOOL_DELETED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: result.tool.id, + resourceName: result.tool.title, + description: `Deleted custom tool "${result.tool.title}"`, + metadata: { source: input.source }, + }), +}) diff --git a/apps/sim/lib/mcp/application/authorization.ts b/apps/sim/lib/mcp/application/authorization.ts new file mode 100644 index 00000000000..bd3c575a4d1 --- /dev/null +++ b/apps/sim/lib/mcp/application/authorization.ts @@ -0,0 +1,13 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegationPolicy } from '@/lib/core/application' + +export const MCP_SERVER_DELEGATION_AUDIENCE = 'sim:mcp-servers' + +export const mcpServerDelegationPolicy = { + audience: MCP_SERVER_DELEGATION_AUDIENCE, + isWithinScope: (principal: DelegatedPrincipal) => principal.serviceId === 'copilot', +} as const satisfies WorkspaceDelegationPolicy<{ + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean +}> diff --git a/apps/sim/lib/mcp/application/operations.ts b/apps/sim/lib/mcp/application/operations.ts new file mode 100644 index 00000000000..b0a6dcbc28f --- /dev/null +++ b/apps/sim/lib/mcp/application/operations.ts @@ -0,0 +1,55 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const ALL_PRINCIPAL_KINDS = [ + 'session', + 'personal_api_key', + 'workspace_api_key', + 'delegated', +] as const + +export const mcpServerOperations = { + list: defineWorkspaceOperation({ + id: 'mcp_servers.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + read: defineWorkspaceOperation({ + id: 'mcp_servers.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + create: defineWorkspaceOperation({ + id: 'mcp_servers.create', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + register: defineWorkspaceOperation({ + id: 'mcp_servers.register', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + update: defineWorkspaceOperation({ + id: 'mcp_servers.update', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + reconfigure: defineWorkspaceOperation({ + id: 'mcp_servers.reconfigure', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + delete: defineWorkspaceOperation({ + id: 'mcp_servers.delete', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), +} as const + +export type McpServerOperation = (typeof mcpServerOperations)[keyof typeof mcpServerOperations] diff --git a/apps/sim/lib/mcp/application/use-cases.test.ts b/apps/sim/lib/mcp/application/use-cases.test.ts new file mode 100644 index 00000000000..4cf037aa241 --- /dev/null +++ b/apps/sim/lib/mcp/application/use-cases.test.ts @@ -0,0 +1,163 @@ +/** + * @vitest-environment node + */ +import type { mcpServers } from '@sim/db/schema' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { events, mocks } = vi.hoisted(() => ({ + events: [] as string[], + mocks: { + loadContext: vi.fn(), + resolvePermission: vi.fn(), + idState: vi.fn(), + create: vi.fn(), + effects: vi.fn(), + audit: vi.fn(), + }, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + loadActiveWorkspaceContext: mocks.loadContext, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { + MCP_SERVER_ADDED: 'mcp_server.added', + MCP_SERVER_UPDATED: 'mcp_server.updated', + MCP_SERVER_REMOVED: 'mcp_server.removed', + }, + AuditResourceType: { MCP_SERVER: 'mcp_server' }, + recordAudit: mocks.audit, +})) +vi.mock('@/lib/mcp/orchestration', () => ({ + applyMcpServerMutationEffects: mocks.effects, + createMcpServer: mocks.create, + deleteMcpServer: vi.fn(), + updateMcpServer: vi.fn(), +})) +vi.mock('@/lib/mcp/queries', () => ({ + getMcpServerIdState: mocks.idState, + getWorkspaceMcpServer: vi.fn(), + listWorkspaceMcpServers: vi.fn(), +})) + +import { createMcpServerUseCase } from '@/lib/mcp/application/use-cases' + +type McpServerRow = typeof mcpServers.$inferSelect +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', +} +const server = { + id: 'mcp-server-1', + workspaceId: workspace.workspaceId, + createdBy: 'owner-1', + name: 'Docs server', + description: null, + transport: 'streamable-http', + url: 'https://mcp.example.com/sse', + authType: 'headers', + oauthClientId: null, + oauthClientSecret: null, + headers: {}, + timeout: 30_000, + retries: 3, + enabled: true, + lastConnected: null, + connectionStatus: 'connected', + lastError: null, + statusConfig: {}, + toolCount: 0, + lastToolsRefresh: null, + totalRequests: 0, + lastUsed: null, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} as McpServerRow + +describe('MCP server application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + events.length = 0 + mocks.loadContext.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.idState.mockResolvedValue(null) + mocks.create.mockResolvedValue({ + success: true, + serverId: server.id, + server, + updated: false, + }) + mocks.audit.mockImplementation(() => events.push('audit')) + mocks.effects.mockImplementation(async () => events.push('effects')) + }) + + it('keeps strict creation, compatibility attribution, audit, and effects in order', async () => { + const principal = { + kind: 'workspace_api_key' as const, + workspaceId: workspace.workspaceId, + keyId: 'workspace-key-1', + } + + const result = await createMcpServerUseCase.execute({ + principal, + input: { + workspaceId: workspace.workspaceId, + name: server.name, + url: server.url, + source: 'api', + }, + }) + + expect(result.server.id).toBe(server.id) + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: workspace.workspaceId, + userId: workspace.billedAccountUserId, + existingServerBehavior: 'reject', + }) + ) + expect(events).toEqual(['audit', 'effects']) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: null, + metadata: expect.objectContaining({ operation: 'mcp_servers.create' }), + }) + ) + }) + + it('rejects an existing live URL before mutation and audit', async () => { + mocks.idState.mockResolvedValueOnce({ deleted: false }) + + 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.create).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.effects).not.toHaveBeenCalled() + }) + + it('fails fast when a post-audit domain effect fails', async () => { + mocks.effects.mockRejectedValueOnce(new Error('cache unavailable')) + + await expect( + createMcpServerUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: workspace.workspaceId, name: server.name, url: server.url }, + }) + ).rejects.toThrow('cache unavailable') + + expect(mocks.audit).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/lib/mcp/application/use-cases.ts b/apps/sim/lib/mcp/application/use-cases.ts new file mode 100644 index 00000000000..9137d97cbb0 --- /dev/null +++ b/apps/sim/lib/mcp/application/use-cases.ts @@ -0,0 +1,372 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { getPostgresErrorCode } from '@sim/utils/errors' +import type { ListSortOrder } from '@/lib/api/list-query' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { mcpServerDelegationPolicy } from '@/lib/mcp/application/authorization' +import { mcpServerOperations } from '@/lib/mcp/application/operations' +import { + applyMcpServerMutationEffects, + createMcpServer, + deleteMcpServer, + type PerformMcpServerResult, + updateMcpServer as updateMcpServerRecord, +} from '@/lib/mcp/orchestration' +import { + getMcpServerIdState, + getWorkspaceMcpServer, + listWorkspaceMcpServers, + type McpServerRow, + type McpServerSortBy, +} from '@/lib/mcp/queries' +import type { McpAuthType } from '@/lib/mcp/types' +import { generateMcpServerId } from '@/lib/mcp/utils' +import { loadActiveWorkspaceContext } from '@/lib/uploads/contexts/workspace' + +type McpServerTransport = McpServerRow['transport'] +type McpWriteSource = 'api' | 'settings' | 'tool_input' + +interface McpWorkspaceContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} + +interface McpServerContext extends McpWorkspaceContext { + server: McpServerRow +} + +async function resolveWorkspaceContext(workspaceId: string): Promise<McpWorkspaceContext> { + const context = await loadActiveWorkspaceContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} + +async function resolveServerContext( + workspaceId: string, + serverId: string +): Promise<McpServerContext> { + const workspace = await resolveWorkspaceContext(workspaceId) + const server = await getWorkspaceMcpServer({ workspaceId: workspace.workspaceId, serverId }) + if (!server) throw new OrchestrationError('not_found', 'MCP server not found') + return { ...workspace, server } +} + +function requireSuccessfulResult( + result: PerformMcpServerResult, + fallback: string +): PerformMcpServerResult & { server: McpServerRow } { + if (result.success && result.server) + return result as PerformMcpServerResult & { server: McpServerRow } + switch (result.errorCode) { + case 'not_found': + throw new OrchestrationError('not_found', 'MCP server not found') + case 'forbidden': + throw new OrchestrationError('forbidden', result.error ?? fallback) + case 'bad_gateway': + throw new OrchestrationError('validation', result.error ?? fallback) + case 'conflict': + throw new OrchestrationError('conflict', result.error ?? fallback) + default: + throw new Error(fallback) + } +} + +const authorizationOptions = { delegation: mcpServerDelegationPolicy } + +export interface ListMcpServersInput { + workspaceId: string + search?: string + sortBy?: McpServerSortBy + sortOrder?: ListSortOrder +} + +export const listMcpServersUseCase = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.list, + resolveContext: ({ input }: { input: ListMcpServersInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ input, context }) { + const servers = await listWorkspaceMcpServers({ ...input, workspaceId: context.workspaceId }) + return { servers } + }, +}) + +export interface GetMcpServerInput { + workspaceId: string + serverId: string +} + +export const getMcpServerUseCase = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.read, + resolveContext: ({ input }: { input: GetMcpServerInput }) => + resolveServerContext(input.workspaceId, input.serverId), + authorizationOptions, + async execute({ context }) { + return { server: context.server } + }, +}) + +export interface SaveMcpServerInput { + workspaceId: string + name: string + description?: string | null + transport?: McpServerTransport + url: string + headers?: Record<string, string> + timeout?: number + retries?: number + enabled?: boolean + authType?: McpAuthType + oauthClientId?: string | null + oauthClientSecret?: string | null + source?: McpWriteSource +} + +async function saveMcpServer(args: { + principal: Parameters<typeof resolvePrincipalAttribution>[0] + input: SaveMcpServerInput + context: McpWorkspaceContext + existingServerBehavior?: 'update' | 'reject' +}): Promise<PerformMcpServerResult & { server: McpServerRow }> { + const attribution = resolvePrincipalAttribution(args.principal, { + workspaceBillingOwnerUserId: args.context.billedAccountUserId, + }) + const result = await createMcpServer({ + workspaceId: args.context.workspaceId, + userId: attribution.attributedUserId, + name: args.input.name, + description: args.input.description, + transport: args.input.transport, + url: args.input.url, + headers: args.input.headers, + timeout: args.input.timeout, + retries: args.input.retries, + enabled: args.input.enabled, + authType: args.input.authType, + oauthClientId: args.input.oauthClientId ?? null, + oauthClientIdProvided: args.input.oauthClientId !== undefined, + oauthClientSecret: args.input.oauthClientSecret, + oauthClientSecretProvided: args.input.oauthClientSecret !== undefined, + existingServerBehavior: args.existingServerBehavior, + }) + return requireSuccessfulResult(result, 'Failed to register MCP server') +} + +function createAudit( + input: SaveMcpServerInput, + result: PerformMcpServerResult & { server: McpServerRow } +) { + if (result.updated) return [] + return [ + { + action: AuditAction.MCP_SERVER_ADDED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.server.id, + resourceName: result.server.name, + description: `Added MCP server "${result.server.name}"`, + metadata: { + serverName: result.server.name, + transport: result.server.transport, + url: result.server.url, + timeout: result.server.timeout, + retries: result.server.retries, + source: input.source, + }, + }, + ] +} + +export const createMcpServerUseCase = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.create, + resolveContext: ({ input }: { input: SaveMcpServerInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ principal, input, context }) { + const serverId = generateMcpServerId(context.workspaceId, input.url) + const idState = await getMcpServerIdState({ workspaceId: context.workspaceId, serverId }) + if (idState && !idState.deleted) { + throw new OrchestrationError( + 'conflict', + 'An MCP server with this URL already exists in this workspace. Update it with PATCH /api/v2/mcp-servers/{id}.' + ) + } + let result: PerformMcpServerResult & { server: McpServerRow } + try { + result = await saveMcpServer({ + principal, + input, + context, + existingServerBehavior: 'reject', + }) + } catch (error) { + if (getPostgresErrorCode(error) === '23505') { + throw new OrchestrationError( + 'conflict', + 'An MCP server with this URL already exists in this workspace.' + ) + } + throw error + } + if (result.updated && idState?.deleted !== true) { + throw new OrchestrationError( + 'conflict', + 'An MCP server with this URL already exists in this workspace.' + ) + } + return result + }, + projectAudit: ({ input, result }) => createAudit(input, result), + afterSuccess: ({ context, result }) => + applyMcpServerMutationEffects({ action: 'create', workspaceId: context.workspaceId, result }), +}) + +export const registerMcpServerUseCase = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.register, + resolveContext: ({ input }: { input: SaveMcpServerInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + execute: ({ principal, input, context }) => saveMcpServer({ principal, input, context }), + projectAudit: ({ input, result }) => createAudit(input, result), + afterSuccess: ({ context, result }) => + applyMcpServerMutationEffects({ action: 'create', workspaceId: context.workspaceId, result }), +}) + +export interface UpdateMcpServerInput { + workspaceId: string + serverId: string + name?: string + description?: string | null + transport?: McpServerTransport + url?: string + headers?: Record<string, string> + timeout?: number + retries?: number + enabled?: boolean + authType?: McpAuthType + oauthClientId?: string | null + oauthClientSecret?: string | null + source?: McpWriteSource +} + +async function updateMcpServer(args: { + principal: Parameters<typeof resolvePrincipalAttribution>[0] + input: UpdateMcpServerInput + context: McpServerContext +}): Promise<PerformMcpServerResult & { server: McpServerRow }> { + const attribution = resolvePrincipalAttribution(args.principal, { + workspaceBillingOwnerUserId: args.context.billedAccountUserId, + }) + const result = await updateMcpServerRecord({ + workspaceId: args.context.workspaceId, + userId: attribution.attributedUserId, + serverId: args.context.server.id, + name: args.input.name, + description: args.input.description, + transport: args.input.transport, + url: args.input.url, + headers: args.input.headers, + timeout: args.input.timeout, + retries: args.input.retries, + enabled: args.input.enabled, + authType: args.input.authType, + oauthClientId: args.input.oauthClientId ?? null, + oauthClientIdProvided: args.input.oauthClientId !== undefined, + oauthClientSecret: args.input.oauthClientSecret, + oauthClientSecretProvided: args.input.oauthClientSecret !== undefined, + }) + return requireSuccessfulResult(result, 'Failed to update MCP server') +} + +function updateAudit( + input: UpdateMcpServerInput, + result: PerformMcpServerResult & { server: McpServerRow } +) { + return { + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.server.id, + resourceName: result.server.name, + description: `Updated MCP server "${result.server.name}"`, + metadata: { + serverName: result.server.name, + transport: result.server.transport, + url: result.server.url, + updatedFields: Object.keys(input).filter( + (key) => !['workspaceId', 'serverId', 'source'].includes(key) + ), + source: input.source, + }, + } +} + +export const updateMcpServerUseCase = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.update, + resolveContext: ({ input }: { input: UpdateMcpServerInput }) => + resolveServerContext(input.workspaceId, input.serverId), + authorizationOptions, + async execute({ principal, input, context }) { + if (input.url !== undefined && input.url !== context.server.url) { + throw new OrchestrationError( + 'validation', + 'url cannot be changed: an MCP server’s id is derived from its URL. Delete this server and create one at the new URL.' + ) + } + return updateMcpServer({ principal, input, context }) + }, + projectAudit: ({ input, result }) => updateAudit(input, result), + afterSuccess: ({ context, result }) => + applyMcpServerMutationEffects({ action: 'update', workspaceId: context.workspaceId, result }), +}) + +export const reconfigureMcpServerUseCase = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.reconfigure, + resolveContext: ({ input }: { input: UpdateMcpServerInput }) => + resolveServerContext(input.workspaceId, input.serverId), + authorizationOptions, + execute: ({ principal, input, context }) => updateMcpServer({ principal, input, context }), + projectAudit: ({ input, result }) => updateAudit(input, result), + afterSuccess: ({ context, result }) => + applyMcpServerMutationEffects({ action: 'update', workspaceId: context.workspaceId, result }), +}) + +export interface DeleteMcpServerInput { + workspaceId: string + serverId: string + source?: McpWriteSource +} + +export const deleteMcpServerUseCase = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.delete, + resolveContext: ({ input }: { input: DeleteMcpServerInput }) => + resolveServerContext(input.workspaceId, input.serverId), + authorizationOptions, + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await deleteMcpServer({ + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + serverId: context.server.id, + }) + return requireSuccessfulResult(result, 'Failed to delete MCP server') + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.MCP_SERVER_REMOVED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.server.id, + resourceName: result.server.name, + description: `Removed MCP server "${result.server.name}"`, + metadata: { + serverName: result.server.name, + transport: result.server.transport, + url: result.server.url, + source: input.source, + }, + }), + afterSuccess: ({ context, result }) => + applyMcpServerMutationEffects({ action: 'delete', workspaceId: context.workspaceId, result }), +}) diff --git a/apps/sim/lib/mcp/orchestration/index.ts b/apps/sim/lib/mcp/orchestration/index.ts index 7fac0516176..dc2297c6a73 100644 --- a/apps/sim/lib/mcp/orchestration/index.ts +++ b/apps/sim/lib/mcp/orchestration/index.ts @@ -1,4 +1,8 @@ export { + applyMcpServerMutationEffects, + createMcpServer, + deleteMcpServer, + type McpServerMutationAction, type McpServerOrchestrationErrorCode, type PerformCreateMcpServerParams, type PerformDeleteMcpServerParams, @@ -7,6 +11,7 @@ export { performCreateMcpServer, performDeleteMcpServer, performUpdateMcpServer, + updateMcpServer, } from './server-lifecycle' export { type PerformCreateWorkflowMcpServerParams, diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts index 29d746f3c7b..34b7fe2fbd8 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.test.ts @@ -108,6 +108,7 @@ describe('MCP server lifecycle orchestration', () => { lastError: null, }) ) + expect(result.configurationChanged).toBe(true) expect(mockClearCache).toHaveBeenCalledWith('workspace-1') }) @@ -164,6 +165,16 @@ describe('MCP server lifecycle orchestration', () => { oauthClientSecret: 'secret-1', }, ]) + 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', diff --git a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts index 2468900e386..c646cb0e5d2 100644 --- a/apps/sim/lib/mcp/orchestration/server-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/server-lifecycle.ts @@ -21,7 +21,12 @@ import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('McpServerOrchestration') -export type McpServerOrchestrationErrorCode = 'not_found' | 'forbidden' | 'bad_gateway' | 'internal' +export type McpServerOrchestrationErrorCode = + | 'not_found' + | 'forbidden' + | 'bad_gateway' + | 'conflict' + | 'internal' type McpServerTransport = (typeof mcpServers.$inferInsert)['transport'] @@ -48,6 +53,7 @@ export interface PerformCreateMcpServerParams extends ActorMetadata { oauthClientIdProvided?: boolean oauthClientSecret?: string | null oauthClientSecretProvided?: boolean + existingServerBehavior?: 'update' | 'reject' } export interface PerformUpdateMcpServerParams extends ActorMetadata { @@ -84,8 +90,11 @@ export interface PerformMcpServerResult { server?: typeof mcpServers.$inferSelect updated?: boolean authType?: McpAuthType + configurationChanged?: boolean } +export type McpServerMutationAction = 'create' | 'update' | 'delete' + type ValidateMcpServerUrlResult = | { ok: true; resolvedIP: string | null } | { ok: false; result: PerformMcpServerResult } @@ -109,8 +118,8 @@ async function validateMcpServerUrl(url: string): Promise<ValidateMcpServerUrlRe } } -export async function performCreateMcpServer( - params: PerformCreateMcpServerParams +export async function createMcpServer( + params: Omit<PerformCreateMcpServerParams, keyof ActorMetadata | 'source'> ): Promise<PerformMcpServerResult> { const validation = await validateMcpServerUrl(params.url) if (!validation.ok) return validation.result @@ -144,6 +153,18 @@ export async function performCreateMcpServer( const urlChanged = existingServer ? existingServer.url !== params.url : true + if ( + existingServer && + existingServer.deletedAt === null && + params.existingServerBehavior === 'reject' + ) { + return { + success: false, + error: 'An MCP server with this URL already exists in this workspace', + errorCode: 'conflict', + } + } + let resolvedAuthType: McpAuthType = params.authType ?? 'headers' if (!params.authType) { if (existingServer && !urlChanged) { @@ -213,9 +234,13 @@ export async function performCreateMcpServer( await tx.update(mcpServers).set(updateValues).where(eq(mcpServers.id, serverId)) }) - await mcpService.clearCache(params.workspaceId) - await mcpService.evictServerConnections(serverId, 'config changed') - return { success: true, serverId, updated: true, authType: resolvedAuthType } + const [server] = await db + .select() + .from(mcpServers) + .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, params.workspaceId))) + .limit(1) + if (!server) throw new Error(`MCP server ${serverId} missing after a successful update`) + return { success: true, serverId, server, updated: true, authType: resolvedAuthType } } await db.insert(mcpServers).values({ @@ -239,61 +264,21 @@ export async function performCreateMcpServer( updatedAt: new Date(), }) - await mcpService.clearCache(params.workspaceId) - - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.mcpServerAdded({ - serverId, - serverName: params.name, - transport, - workspaceId: params.workspaceId, - }) - } catch {} - - const source = - params.source === 'settings' || params.source === 'tool_input' ? params.source : undefined - - captureServerEvent( - params.userId, - 'mcp_server_connected', - { workspace_id: params.workspaceId, server_name: params.name, transport, source }, - { - groups: { workspace: params.workspaceId }, - setOnce: { first_mcp_connected_at: new Date().toISOString() }, - } - ) - - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.MCP_SERVER_ADDED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: params.name, - description: `Added MCP server "${params.name}"`, - metadata: { - serverName: params.name, - transport, - url: params.url, - timeout, - retries, - source, - }, - request: params.request, - }) - - return { success: true, serverId, updated: false, authType: resolvedAuthType } + const [server] = await db + .select() + .from(mcpServers) + .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, params.workspaceId))) + .limit(1) + if (!server) throw new Error(`MCP server ${serverId} missing after a successful insert`) + return { success: true, serverId, server, updated: false, authType: resolvedAuthType } } catch (error) { logger.error('Failed to create MCP server', { error }) - return { success: false, error: 'Failed to register MCP server', errorCode: 'internal' } + throw error } } -export async function performUpdateMcpServer( - params: PerformUpdateMcpServerParams +export async function updateMcpServer( + params: Omit<PerformUpdateMcpServerParams, keyof ActorMetadata> ): Promise<PerformMcpServerResult> { if (params.url) { const validation = await validateMcpServerUrl(params.url) @@ -404,10 +389,103 @@ export async function performUpdateMcpServer( params.timeout !== undefined || params.retries !== undefined - if (shouldClearCache) { - await mcpService.clearCache(params.workspaceId) - await mcpService.evictServerConnections(params.serverId, 'config changed') + return { success: true, server, configurationChanged: shouldClearCache } + } catch (error) { + logger.error('Failed to update MCP server', { error }) + throw error + } +} + +export async function deleteMcpServer( + params: Omit<PerformDeleteMcpServerParams, keyof ActorMetadata | 'source'> +): Promise<PerformMcpServerResult> { + try { + await revokeMcpOauthTokens(params.serverId) + const [server] = await db + .delete(mcpServers) + .where( + and(eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, params.workspaceId)) + ) + .returning() + + if (!server) return { success: false, error: 'Server not found', errorCode: 'not_found' } + + return { success: true, server } + } catch (error) { + logger.error('Failed to delete MCP server', { error }) + throw error + } +} + +function legacySource(source: string | undefined): 'settings' | 'tool_input' | undefined { + return source === 'settings' || source === 'tool_input' ? source : undefined +} + +/** Preserves the legacy internal registration result, analytics, audit, and effects contract. */ +export async function performCreateMcpServer( + params: PerformCreateMcpServerParams +): Promise<PerformMcpServerResult> { + try { + const result = await createMcpServer(params) + if (!result.success) return result + if (!result.server) throw new Error('Successful MCP registration is missing its server') + + await applyMcpServerMutationEffects({ + action: 'create', + workspaceId: params.workspaceId, + result, + }) + if (!result.updated) { + const source = legacySource(params.source) + captureServerEvent( + params.userId, + 'mcp_server_connected', + { + workspace_id: params.workspaceId, + server_name: result.server.name, + transport: result.server.transport, + source, + }, + { + groups: { workspace: params.workspaceId }, + setOnce: { first_mcp_connected_at: new Date().toISOString() }, + } + ) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.MCP_SERVER_ADDED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.server.id, + resourceName: result.server.name, + description: `Added MCP server "${result.server.name}"`, + metadata: { + serverName: result.server.name, + transport: result.server.transport, + url: result.server.url, + timeout: result.server.timeout, + retries: result.server.retries, + source, + }, + request: params.request, + }) } + return result + } catch (error) { + logger.error('Failed to register MCP server', { error }) + return { success: false, error: 'Failed to register MCP server', errorCode: 'internal' } + } +} + +/** Preserves the legacy internal update result, audit, and effects contract. */ +export async function performUpdateMcpServer( + params: PerformUpdateMcpServerParams +): Promise<PerformMcpServerResult> { + try { + const result = await updateMcpServer(params) + if (!result.success || !result.server) return result recordAudit({ workspaceId: params.workspaceId, @@ -416,51 +494,56 @@ export async function performUpdateMcpServer( actorEmail: params.actorEmail ?? undefined, action: AuditAction.MCP_SERVER_UPDATED, resourceType: AuditResourceType.MCP_SERVER, - resourceId: params.serverId, - resourceName: server.name || params.serverId, - description: `Updated MCP server "${server.name || params.serverId}"`, + resourceId: result.server.id, + resourceName: result.server.name, + description: `Updated MCP server "${result.server.name}"`, metadata: { - serverName: server.name, - transport: server.transport, - url: server.url, - updatedFields: Object.keys(updateData).filter((key) => key !== 'updatedAt'), + serverName: result.server.name, + transport: result.server.transport, + url: result.server.url, + updatedFields: Object.entries(params) + .filter( + ([key, value]) => + value !== undefined && + !['workspaceId', 'userId', 'serverId', 'actorName', 'actorEmail', 'request'].includes( + key + ) + ) + .map(([key]) => key), }, request: params.request, }) - - return { success: true, server } + await applyMcpServerMutationEffects({ + action: 'update', + workspaceId: params.workspaceId, + result, + }) + return result } catch (error) { logger.error('Failed to update MCP server', { error }) return { success: false, error: 'Failed to update MCP server', errorCode: 'internal' } } } +/** Preserves the legacy internal delete result, analytics, audit, and effects contract. */ export async function performDeleteMcpServer( params: PerformDeleteMcpServerParams ): Promise<PerformMcpServerResult> { try { - await revokeMcpOauthTokens(params.serverId) - const [server] = await db - .delete(mcpServers) - .where( - and(eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, params.workspaceId)) - ) - .returning() - - if (!server) return { success: false, error: 'Server not found', errorCode: 'not_found' } - - await mcpService.clearCache(params.workspaceId) - await mcpService.evictServerConnections(params.serverId, 'server deleted') - const source = - params.source === 'settings' || params.source === 'tool_input' ? params.source : undefined + const result = await deleteMcpServer(params) + if (!result.success || !result.server) return result + const source = legacySource(params.source) captureServerEvent( params.userId, 'mcp_server_disconnected', - { workspace_id: params.workspaceId, server_name: server.name, source }, + { + workspace_id: params.workspaceId, + server_name: result.server.name, + source, + }, { groups: { workspace: params.workspaceId } } ) - recordAudit({ workspaceId: params.workspaceId, actorId: params.userId, @@ -468,21 +551,57 @@ export async function performDeleteMcpServer( actorEmail: params.actorEmail ?? undefined, action: AuditAction.MCP_SERVER_REMOVED, resourceType: AuditResourceType.MCP_SERVER, - resourceId: params.serverId, - resourceName: server.name, - description: `Removed MCP server "${server.name}"`, + resourceId: result.server.id, + resourceName: result.server.name, + description: `Removed MCP server "${result.server.name}"`, metadata: { - serverName: server.name, - transport: server.transport, - url: server.url, + serverName: result.server.name, + transport: result.server.transport, + url: result.server.url, source, }, request: params.request, }) - - return { success: true, server } + await applyMcpServerMutationEffects({ + action: 'delete', + workspaceId: params.workspaceId, + result, + }) + return result } catch (error) { logger.error('Failed to delete MCP server', { error }) return { success: false, error: 'Failed to delete MCP server', errorCode: 'internal' } } } + +/** Applies shared cache, connection, and domain-telemetry effects after semantic audit. */ +export async function applyMcpServerMutationEffects(params: { + action: McpServerMutationAction + workspaceId: string + result: PerformMcpServerResult +}): Promise<void> { + const { action, workspaceId, result } = params + if (!result.serverId && !result.server?.id) { + throw new Error(`MCP ${action} result is missing its server ID`) + } + const serverId = result.serverId ?? result.server!.id + + if (action === 'update' && !result.configurationChanged) return + await mcpService.clearCache(workspaceId) + if (action !== 'create' || result.updated) { + await mcpService.evictServerConnections( + serverId, + action === 'delete' ? 'server deleted' : 'config changed' + ) + } + + if (action === 'create' && result.updated === false && result.server) { + const { PlatformEvents } = await import('@/lib/core/telemetry') + PlatformEvents.mcpServerAdded({ + serverId, + serverName: result.server.name, + transport: result.server.transport, + workspaceId, + }) + } +} diff --git a/apps/sim/lib/mcp/queries.ts b/apps/sim/lib/mcp/queries.ts index 789a86a4454..f7fdf40af1c 100644 --- a/apps/sim/lib/mcp/queries.ts +++ b/apps/sim/lib/mcp/queries.ts @@ -1,9 +1,7 @@ import { db } from '@sim/db' import { mcpServers } from '@sim/db/schema' import { and, type Column, eq, isNull } from 'drizzle-orm' -import type { V2McpServerSortBy } from '@/lib/api/contracts/v2/mcp-servers' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' -import { listOrderBy, searchFilter } from '@/lib/api/list-query' +import { type ListSortOrder, listOrderBy, searchFilter } from '@/lib/api/list-query' /** * Workspace-scoped MCP server reads. The lifecycle functions in @@ -12,6 +10,7 @@ import { listOrderBy, searchFilter } from '@/lib/api/list-query' */ export type McpServerRow = typeof mcpServers.$inferSelect +export type McpServerSortBy = 'name' | 'createdAt' | 'updatedAt' /** Live (non-soft-deleted) MCP servers in a workspace, newest first. */ /** @@ -23,14 +22,14 @@ const MCP_SERVER_SORTS = { name: [mcpServers.name, mcpServers.id], createdAt: [mcpServers.createdAt, mcpServers.id], updatedAt: [mcpServers.updatedAt, mcpServers.id], -} satisfies Record<V2McpServerSortBy, readonly Column[]> +} satisfies Record<McpServerSortBy, readonly Column[]> export async function listWorkspaceMcpServers(params: { workspaceId: string /** Case-insensitive substring match on the server name. */ search?: string - sortBy?: V2McpServerSortBy - sortOrder?: V2SortOrder + sortBy?: McpServerSortBy + sortOrder?: ListSortOrder }): Promise<McpServerRow[]> { const { sortBy = 'createdAt', sortOrder = 'desc' } = params return db diff --git a/apps/sim/lib/secrets/application/operations.ts b/apps/sim/lib/secrets/application/operations.ts new file mode 100644 index 00000000000..e7b60c59ac5 --- /dev/null +++ b/apps/sim/lib/secrets/application/operations.ts @@ -0,0 +1,26 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const HUMAN_API_PRINCIPAL_KINDS = ['session', 'personal_api_key'] as const + +export const secretOperations = { + list: defineWorkspaceOperation({ + id: 'secrets.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: HUMAN_API_PRINCIPAL_KINDS, + }), + set: defineWorkspaceOperation({ + id: 'secrets.set', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: HUMAN_API_PRINCIPAL_KINDS, + }), + delete: defineWorkspaceOperation({ + id: 'secrets.delete', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: HUMAN_API_PRINCIPAL_KINDS, + }), +} as const + +export type SecretOperation = (typeof secretOperations)[keyof typeof secretOperations] diff --git a/apps/sim/lib/secrets/application/use-cases.test.ts b/apps/sim/lib/secrets/application/use-cases.test.ts new file mode 100644 index 00000000000..1bb47279bd4 --- /dev/null +++ b/apps/sim/lib/secrets/application/use-cases.test.ts @@ -0,0 +1,146 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SetSecretInput } from '@/lib/secrets/application/use-cases' + +const { mocks } = vi.hoisted(() => ({ + mocks: { + loadContext: vi.fn(), + resolvePermission: vi.fn(), + workspaceAccess: vi.fn(), + keyAccess: vi.fn(), + setWorkspace: vi.fn(), + listCredentials: vi.fn(), + audit: vi.fn(), + }, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + loadActiveWorkspaceContext: mocks.loadContext, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { + ENVIRONMENT_UPDATED: 'environment.updated', + ENVIRONMENT_DELETED: 'environment.deleted', + }, + AuditResourceType: { ENVIRONMENT: 'environment' }, + recordAudit: mocks.audit, +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mocks.workspaceAccess, +})) +vi.mock('@/lib/credentials/environment', () => ({ + getWorkspaceEnvKeyAdminAccess: mocks.keyAccess, +})) +vi.mock('@/lib/credentials/queries', () => ({ + listVisibleWorkspaceCredentials: mocks.listCredentials, +})) +vi.mock('@/lib/credentials/secret-values', () => ({ + deletePersonalSecret: vi.fn(), + deleteWorkspaceSecret: vi.fn(), + setPersonalSecret: vi.fn(), + setWorkspaceSecret: mocks.setWorkspace, +})) + +import { setSecretUseCase } from '@/lib/secrets/application/use-cases' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', +} +const secret = { + id: 'secret-1', + workspaceId: workspace.workspaceId, + type: 'env_workspace' as const, + displayName: 'STRIPE_API_KEY', + description: null, + providerId: null, + accountId: null, + envKey: 'STRIPE_API_KEY', + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + hasServiceAccountKey: false, + role: 'admin' as const, +} + +describe('secret application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) + mocks.keyAccess.mockResolvedValue({ knownKeys: new Set(), adminKeys: new Set() }) + mocks.setWorkspace.mockResolvedValue({ created: true }) + mocks.listCredentials.mockResolvedValue([secret]) + }) + + it('rejects workspace keys before resolving or reading secret state', async () => { + const execute = setSecretUseCase.execute as (args: { + principal: Principal + input: SetSecretInput + }) => Promise<unknown> + + await expect( + execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: workspace.workspaceId, + keyId: 'workspace-key-1', + }, + input: { + workspaceId: workspace.workspaceId, + name: secret.envKey, + scope: 'workspace', + value: 'secret-value', + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadContext).not.toHaveBeenCalled() + expect(mocks.listCredentials).not.toHaveBeenCalled() + expect(mocks.setWorkspace).not.toHaveBeenCalled() + }) + + it('checks ACLs, writes through the manager, and audits without the secret value', async () => { + const result = await setSecretUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: workspace.workspaceId, + name: secret.envKey, + scope: 'workspace', + value: 'secret-value', + }, + }) + + expect(result.created).toBe(true) + expect(mocks.keyAccess).toHaveBeenCalledWith({ + workspaceId: workspace.workspaceId, + envKeys: [secret.envKey], + userId: 'user-1', + }) + expect(mocks.setWorkspace).toHaveBeenCalledWith({ + workspaceId: workspace.workspaceId, + name: secret.envKey, + value: 'secret-value', + userId: 'user-1', + }) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'user-1', + metadata: expect.objectContaining({ operation: 'secrets.set', scope: 'workspace' }), + }) + ) + expect(JSON.stringify(mocks.audit.mock.calls)).not.toContain('secret-value') + }) +}) diff --git a/apps/sim/lib/secrets/application/use-cases.ts b/apps/sim/lib/secrets/application/use-cases.ts new file mode 100644 index 00000000000..f30efa998b3 --- /dev/null +++ b/apps/sim/lib/secrets/application/use-cases.ts @@ -0,0 +1,232 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getWorkspaceEnvKeyAdminAccess } from '@/lib/credentials/environment' +import { + listVisibleWorkspaceCredentials, + type VisibleWorkspaceCredential, +} from '@/lib/credentials/queries' +import { + deletePersonalSecret, + deleteWorkspaceSecret, + setPersonalSecret, + setWorkspaceSecret, +} from '@/lib/credentials/secret-values' +import { secretOperations } from '@/lib/secrets/application/operations' +import { loadActiveWorkspaceContext } from '@/lib/uploads/contexts/workspace' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +export type SecretScope = 'workspace' | 'personal' +export type SecretSortBy = 'name' | 'createdAt' | 'updatedAt' + +interface SecretWorkspaceContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} + +async function resolveWorkspaceContext(workspaceId: string): Promise<SecretWorkspaceContext> { + const context = await loadActiveWorkspaceContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} + +function principalUserId( + principal: Extract<Principal, { kind: 'session' | 'personal_api_key' }> +): string { + return principal.userId +} + +function credentialTypes(scope?: SecretScope) { + if (scope === 'workspace') return ['env_workspace'] as const + if (scope === 'personal') return ['env_personal'] as const + return ['env_workspace', 'env_personal'] as const +} + +async function listSecretMetadata(params: { + workspaceId: string + userId: string + scope?: SecretScope + search?: string + sortBy: SecretSortBy + sortOrder: V2SortOrder +}): Promise<VisibleWorkspaceCredential[]> { + const workspaceAccess = await checkWorkspaceAccess(params.workspaceId, params.userId) + const rows = await listVisibleWorkspaceCredentials({ + workspaceId: params.workspaceId, + userId: params.userId, + workspaceAccess, + types: [...credentialTypes(params.scope)], + search: params.search, + sortBy: params.sortBy === 'name' ? 'displayName' : params.sortBy, + sortOrder: params.sortOrder, + }) + return rows.filter((row) => row.type === 'env_workspace' || row.envOwnerUserId === params.userId) +} + +async function requireWorkspaceSecretMutationAccess(params: { + workspaceId: string + name: string + userId: string +}): Promise<void> { + const [workspaceAccess, keyAccess] = await Promise.all([ + checkWorkspaceAccess(params.workspaceId, params.userId), + getWorkspaceEnvKeyAdminAccess({ + workspaceId: params.workspaceId, + envKeys: [params.name], + userId: params.userId, + }), + ]) + + if (keyAccess.knownKeys.has(params.name)) { + if (!workspaceAccess.canAdmin && !keyAccess.adminKeys.has(params.name)) { + throw new OrchestrationError( + 'forbidden', + 'Credential admin permission required for this secret' + ) + } + return + } + if (!workspaceAccess.canWrite) { + throw new OrchestrationError('forbidden', 'Write permission required to set this secret') + } +} + +async function getSecretMetadata(params: { + workspaceId: string + userId: string + scope: SecretScope + name: string +}): Promise<VisibleWorkspaceCredential> { + const rows = await listSecretMetadata({ + ...params, + search: params.name, + sortBy: 'name', + sortOrder: 'asc', + }) + const row = rows.find( + (candidate) => + candidate.envKey === params.name && + (params.scope === 'workspace' + ? candidate.type === 'env_workspace' + : candidate.type === 'env_personal' && candidate.envOwnerUserId === params.userId) + ) + if (!row) throw new Error(`Secret metadata was not created for ${params.scope}:${params.name}`) + return row +} + +const authorizationOptions = {} + +export interface ListSecretsInput { + workspaceId: string + scope?: SecretScope + search?: string + sortBy: SecretSortBy + sortOrder: V2SortOrder +} + +export const listSecretsUseCase = defineAuthorizedWorkspaceUseCase({ + operation: secretOperations.list, + resolveContext: ({ input }: { input: ListSecretsInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ principal, input, context }) { + const userId = principalUserId(principal) + const secrets = await listSecretMetadata({ + ...input, + workspaceId: context.workspaceId, + userId, + }) + return { secrets, userId } + }, +}) + +export interface SetSecretInput { + workspaceId: string + name: string + scope: SecretScope + value: string +} + +export const setSecretUseCase = defineAuthorizedWorkspaceUseCase({ + operation: secretOperations.set, + resolveContext: ({ input }: { input: SetSecretInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ principal, input, context }) { + const userId = principalUserId(principal) + if (input.scope === 'workspace') { + await requireWorkspaceSecretMutationAccess({ + workspaceId: context.workspaceId, + name: input.name, + userId, + }) + } + + const mutation = + input.scope === 'workspace' + ? await setWorkspaceSecret({ + workspaceId: context.workspaceId, + name: input.name, + value: input.value, + userId, + }) + : await setPersonalSecret({ userId, name: input.name, value: input.value }) + const secret = await getSecretMetadata({ + workspaceId: context.workspaceId, + userId, + scope: input.scope, + name: input.name, + }) + return { secret, userId, created: mutation.created } + }, + projectAudit: ({ input }) => ({ + action: AuditAction.ENVIRONMENT_UPDATED, + resourceType: AuditResourceType.ENVIRONMENT, + resourceId: `${input.scope}:${input.name}`, + resourceName: input.name, + description: `Set ${input.scope} secret "${input.name}"`, + metadata: { scope: input.scope, name: input.name }, + }), +}) + +export interface DeleteSecretInput { + workspaceId: string + name: string + scope: SecretScope +} + +export const deleteSecretUseCase = defineAuthorizedWorkspaceUseCase({ + operation: secretOperations.delete, + resolveContext: ({ input }: { input: DeleteSecretInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ principal, input, context }) { + const userId = principalUserId(principal) + if (input.scope === 'workspace') { + await requireWorkspaceSecretMutationAccess({ + workspaceId: context.workspaceId, + name: input.name, + userId, + }) + } + + const deleted = + input.scope === 'workspace' + ? await deleteWorkspaceSecret({ workspaceId: context.workspaceId, name: input.name }) + : await deletePersonalSecret({ userId, name: input.name }) + if (!deleted) throw new OrchestrationError('not_found', 'Secret not found') + return { name: input.name, scope: input.scope } + }, + projectAudit: ({ input }) => ({ + action: AuditAction.ENVIRONMENT_DELETED, + resourceType: AuditResourceType.ENVIRONMENT, + resourceId: `${input.scope}:${input.name}`, + resourceName: input.name, + description: `Deleted ${input.scope} secret "${input.name}"`, + metadata: { scope: input.scope, name: input.name }, + }), +}) diff --git a/apps/sim/lib/skills/application/authorization.ts b/apps/sim/lib/skills/application/authorization.ts new file mode 100644 index 00000000000..97aea84b6da --- /dev/null +++ b/apps/sim/lib/skills/application/authorization.ts @@ -0,0 +1,13 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegationPolicy } from '@/lib/core/application' + +export const SKILL_DELEGATION_AUDIENCE = 'sim:skills' + +export const skillDelegationPolicy = { + audience: SKILL_DELEGATION_AUDIENCE, + isWithinScope: (principal: DelegatedPrincipal) => principal.serviceId === 'copilot', +} as const satisfies WorkspaceDelegationPolicy<{ + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean +}> diff --git a/apps/sim/lib/skills/application/operations.ts b/apps/sim/lib/skills/application/operations.ts new file mode 100644 index 00000000000..b19065816be --- /dev/null +++ b/apps/sim/lib/skills/application/operations.ts @@ -0,0 +1,50 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const ALL_PRINCIPAL_KINDS = [ + 'session', + 'personal_api_key', + 'workspace_api_key', + 'delegated', +] as const +const HUMAN_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'delegated'] as const + +export const skillOperations = { + list: defineWorkspaceOperation({ + id: 'skills.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + listAvailable: defineWorkspaceOperation({ + id: 'skills.list_available', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: HUMAN_PRINCIPAL_KINDS, + }), + read: defineWorkspaceOperation({ + id: 'skills.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + create: defineWorkspaceOperation({ + id: 'skills.create', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + update: defineWorkspaceOperation({ + id: 'skills.update', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: HUMAN_PRINCIPAL_KINDS, + }), + delete: defineWorkspaceOperation({ + id: 'skills.delete', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: HUMAN_PRINCIPAL_KINDS, + }), +} as const + +export type SkillOperation = (typeof skillOperations)[keyof typeof skillOperations] diff --git a/apps/sim/lib/skills/application/use-cases.test.ts b/apps/sim/lib/skills/application/use-cases.test.ts new file mode 100644 index 00000000000..250c6d15dfe --- /dev/null +++ b/apps/sim/lib/skills/application/use-cases.test.ts @@ -0,0 +1,140 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks } = vi.hoisted(() => ({ + mocks: { + loadContext: vi.fn(), + resolvePermission: vi.fn(), + getById: vi.fn(), + update: vi.fn(), + audit: vi.fn(), + }, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + loadActiveWorkspaceContext: mocks.loadContext, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { + SKILL_CREATED: 'skill.created', + SKILL_UPDATED: 'skill.updated', + SKILL_DELETED: 'skill.deleted', + }, + AuditResourceType: { SKILL: 'skill' }, + recordAudit: mocks.audit, +})) +vi.mock('@/lib/skills/orchestration', () => ({ + createSkill: vi.fn(), + deleteSkillRecord: vi.fn(), + updateSkill: mocks.update, +})) +vi.mock('@/lib/workflows/skills/operations', () => ({ + getSkillById: mocks.getById, + listSkills: vi.fn(), + listSkillsForUser: vi.fn(), +})) + +import { updateSkillUseCase } from '@/lib/skills/application/use-cases' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', +} +const skill = { + id: 'skill-1', + workspaceId: workspace.workspaceId, + userId: 'user-1', + name: 'refund-policy', + description: 'Refund rules', + content: '# Refunds', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} + +describe('skill application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getById.mockResolvedValue(skill) + mocks.update.mockResolvedValue({ ...skill, content: '# Updated' }) + }) + + it('rejects workspace keys before resolving protected skill state', async () => { + await expect( + updateSkillUseCase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: workspace.workspaceId, + keyId: 'workspace-key-1', + }, + input: { workspaceId: workspace.workspaceId, skillId: skill.id, content: '# Updated' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadContext).not.toHaveBeenCalled() + expect(mocks.getById).not.toHaveBeenCalled() + expect(mocks.update).not.toHaveBeenCalled() + }) + + it('uses the subject identity and semantic audit for delegated Copilot updates', async () => { + const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: workspace.workspaceId, + delegationId: 'copilot-tool:call-1', + audience: 'sim:skills', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { chatId: 'chat-1' }, + } + + await updateSkillUseCase.execute({ + principal, + input: { + workspaceId: workspace.workspaceId, + skillId: skill.id, + content: '# Updated', + source: 'tool_input', + }, + }) + + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-1', + workspace.workspaceId, + null, + undefined, + { forUpdate: undefined } + ) + expect(mocks.update).toHaveBeenCalledWith({ + workspaceId: workspace.workspaceId, + userId: 'user-1', + skillId: skill.id, + name: undefined, + description: undefined, + content: '# Updated', + }) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'user-1', + metadata: expect.objectContaining({ + operation: 'skills.update', + actor: expect.objectContaining({ + kind: 'delegated', + delegationId: principal.delegationId, + }), + }), + }) + ) + }) +}) diff --git a/apps/sim/lib/skills/application/use-cases.ts b/apps/sim/lib/skills/application/use-cases.ts new file mode 100644 index 00000000000..89899bb5bcc --- /dev/null +++ b/apps/sim/lib/skills/application/use-cases.ts @@ -0,0 +1,204 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import type { skill } from '@sim/db/schema' +import type { ListSortOrder } from '@/lib/api/list-query' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { skillDelegationPolicy } from '@/lib/skills/application/authorization' +import { skillOperations } from '@/lib/skills/application/operations' +import { createSkill, deleteSkillRecord, updateSkill } from '@/lib/skills/orchestration' +import { loadActiveWorkspaceContext } from '@/lib/uploads/contexts/workspace' +import { + getSkillById, + listSkills, + listSkillsForUser, + type SkillSortBy, +} from '@/lib/workflows/skills/operations' + +type SkillRow = typeof skill.$inferSelect +type SkillWriteSource = 'api' | 'settings' | 'tool_input' + +interface SkillWorkspaceContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} + +interface SkillContext extends SkillWorkspaceContext { + skill: SkillRow +} + +async function resolveWorkspaceContext(workspaceId: string): Promise<SkillWorkspaceContext> { + const context = await loadActiveWorkspaceContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} + +async function resolveSkillContext(workspaceId: string, skillId: string): Promise<SkillContext> { + const workspace = await resolveWorkspaceContext(workspaceId) + const row = await getSkillById({ workspaceId: workspace.workspaceId, skillId }) + if (!row) throw new OrchestrationError('not_found', 'Skill not found') + return { ...workspace, skill: row } +} + +function humanUserId(principal: Exclude<Principal, { kind: 'workspace_api_key' }>): string { + return principal.kind === 'delegated' ? principal.subjectUserId : principal.userId +} + +const authorizationOptions = { delegation: skillDelegationPolicy } + +export interface ListSkillsInput { + workspaceId: string + search?: string + sortBy: SkillSortBy + sortOrder: ListSortOrder +} + +export const listSkillsUseCase = defineAuthorizedWorkspaceUseCase({ + operation: skillOperations.list, + resolveContext: ({ input }: { input: ListSkillsInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ input, context }) { + const skills = await listSkills({ + workspaceId: context.workspaceId, + search: input.search, + sort: { sortBy: input.sortBy, sortOrder: input.sortOrder }, + }) + return { skills } + }, +}) + +export interface ListAvailableSkillsInput { + workspaceId: string +} + +export const listAvailableSkillsUseCase = defineAuthorizedWorkspaceUseCase({ + operation: skillOperations.listAvailable, + resolveContext: ({ input }: { input: ListAvailableSkillsInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ principal, context }) { + const skills = await listSkillsForUser({ + workspaceId: context.workspaceId, + userId: humanUserId(principal), + }) + return { skills } + }, +}) + +export interface GetSkillInput { + workspaceId: string + skillId: string +} + +export const getSkillUseCase = defineAuthorizedWorkspaceUseCase({ + operation: skillOperations.read, + resolveContext: ({ input }: { input: GetSkillInput }) => + resolveSkillContext(input.workspaceId, input.skillId), + authorizationOptions, + async execute({ context }) { + return { skill: context.skill } + }, +}) + +export interface CreateSkillInput { + workspaceId: string + name: string + description: string + content: string + source?: SkillWriteSource +} + +export const createSkillUseCase = defineAuthorizedWorkspaceUseCase({ + operation: skillOperations.create, + resolveContext: ({ input }: { input: CreateSkillInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const row = await createSkill({ + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + name: input.name, + description: input.description, + content: input.content, + }) + return { skill: row } + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.SKILL_CREATED, + resourceType: AuditResourceType.SKILL, + resourceId: result.skill.id, + resourceName: result.skill.name, + description: `Created skill "${result.skill.name}"`, + metadata: { source: input.source }, + }), +}) + +export interface UpdateSkillInput { + workspaceId: string + skillId: string + name?: string + description?: string + content?: string + source?: SkillWriteSource +} + +export const updateSkillUseCase = defineAuthorizedWorkspaceUseCase({ + operation: skillOperations.update, + resolveContext: ({ input }: { input: UpdateSkillInput }) => + resolveSkillContext(input.workspaceId, input.skillId), + authorizationOptions, + async execute({ principal, input, context }) { + const row = await updateSkill({ + workspaceId: context.workspaceId, + userId: humanUserId(principal), + skillId: context.skill.id, + name: input.name, + description: input.description, + content: input.content, + }) + return { skill: row } + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.SKILL_UPDATED, + resourceType: AuditResourceType.SKILL, + resourceId: result.skill.id, + resourceName: result.skill.name, + description: `Updated skill "${result.skill.name}"`, + metadata: { source: input.source }, + }), +}) + +export interface DeleteSkillInput { + workspaceId: string + skillId: string + source?: SkillWriteSource +} + +export const deleteSkillUseCase = defineAuthorizedWorkspaceUseCase({ + operation: skillOperations.delete, + resolveContext: ({ input }: { input: DeleteSkillInput }) => + resolveSkillContext(input.workspaceId, input.skillId), + authorizationOptions, + async execute({ principal, context }) { + const row = await deleteSkillRecord({ + workspaceId: context.workspaceId, + userId: humanUserId(principal), + skillId: context.skill.id, + }) + return { skill: row } + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.SKILL_DELETED, + resourceType: AuditResourceType.SKILL, + resourceId: result.skill.id, + resourceName: result.skill.name, + description: `Deleted skill "${result.skill.name}"`, + metadata: { source: input.source }, + }), +}) diff --git a/apps/sim/lib/skills/orchestration/index.ts b/apps/sim/lib/skills/orchestration/index.ts index 48bf621ec27..fd07c58624d 100644 --- a/apps/sim/lib/skills/orchestration/index.ts +++ b/apps/sim/lib/skills/orchestration/index.ts @@ -1,4 +1,6 @@ export { + createSkill, + deleteSkillRecord, type PerformCreateSkillParams, type PerformDeleteSkillParams, type PerformSkillResult, @@ -9,4 +11,5 @@ export { type SkillOrchestrationErrorCode, type SkillWriteSource, statusForSkillOrchestrationError, + updateSkill, } from './skill-lifecycle' diff --git a/apps/sim/lib/skills/orchestration/skill-lifecycle.ts b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts index 5cb13daf037..1fd56b048c6 100644 --- a/apps/sim/lib/skills/orchestration/skill-lifecycle.ts +++ b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts @@ -9,7 +9,11 @@ import { skillDescriptionSchema, skillNameSchema, } from '@/lib/api/contracts/skills' -import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { + asOrchestrationError, + OrchestrationError, + type OrchestrationErrorCode, +} from '@/lib/core/orchestration/types' import { captureServerEvent } from '@/lib/posthog/server' import { getSkillActorContext } from '@/lib/skills/access' import { getBuiltinSkillByName, isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' @@ -18,18 +22,12 @@ import { deleteSkill, getSkillById, upsertSkills } from '@/lib/workflows/skills/ const logger = createLogger('SkillOrchestration') /** - * Single authority for skill create/update/delete. - * - * Before this module the API route owned the create-vs-update split, the - * built-in guard, the per-skill editor check, the field limits (which lived - * only in the route's Zod contract), and the audit — so the copilot's - * `manage_skill`, which calls `upsertSkills` directly, bypassed all of them. - * Every caller now goes through these functions and gets the same rules. + * Shared skill manager primitives and legacy orchestration adapters. * - * Workspace-level authorization stays with the caller: each surface has already - * established workspace access by the time it gets here (session middleware, - * the v2 `resolveWorkspaceAccess`, the copilot's permission context). What is - * owned here is everything *per skill*. + * The throwing primitives own field validation, built-in guards, conflicts, + * and per-skill editor checks. Authorized application use cases own workspace + * authorization and semantic audit. The `perform*` adapters preserve internal + * route result, audit, and analytics compatibility. */ /** @@ -241,12 +239,47 @@ function recordSkillEvent(params: { export async function performCreateSkill( params: PerformCreateSkillParams ): Promise<PerformSkillResult> { + try { + const skill = await createSkill(params) + recordSkillEvent({ + action: 'created', + workspaceId: params.workspaceId, + userId: params.userId, + skillId: skill.id, + skillName: skill.name, + actor: params, + }) + return { success: true, skill } + } catch (error) { + return skillFailureResult(error, 'Failed to create skill') + } +} + +function throwSkillFailure(result: PerformSkillResult): never { + throw new OrchestrationError( + result.errorCode ?? 'internal', + result.error ?? 'Skill operation failed' + ) +} + +function skillFailureResult(error: unknown, fallback: string): PerformSkillResult { + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } + logger.error(fallback, { error: getErrorMessage(error, fallback) }) + return { success: false, error: fallback, errorCode: 'internal' } +} + +export async function createSkill( + params: Omit<PerformCreateSkillParams, keyof ActorMetadata> +): Promise<SkillRow> { const invalid = fieldError(skillNameSchema, params.name) ?? fieldError(skillDescriptionSchema, params.description) ?? fieldError(skillContentSchema, params.content) ?? builtinNameCollision(params.name) - if (invalid) return validationFailure(invalid) + if (invalid) throw new OrchestrationError('validation', invalid) let created: { id: string; name: string } | undefined try { @@ -258,37 +291,49 @@ export async function performCreateSkill( }) created = touched[0] } catch (error) { - return classifyUpsertError(error) + throwSkillFailure(classifyUpsertError(error)) } if (!created) { - logger.error('Skill create returned no touched row', { workspaceId: params.workspaceId }) - return { success: false, error: 'Failed to create skill', errorCode: 'internal' } + throw new Error(`Skill create returned no touched row for workspace ${params.workspaceId}`) } - recordSkillEvent({ - action: 'created', - workspaceId: params.workspaceId, - userId: params.userId, - skillId: created.id, - skillName: created.name, - actor: params, - }) - const row = await getSkillById({ skillId: created.id, workspaceId: params.workspaceId }) - if (!row) return { success: false, error: 'Failed to create skill', errorCode: 'internal' } - return { success: true, skill: row } + if (!row) throw new Error(`Skill ${created.id} missing after a successful create`) + return row } export async function performUpdateSkill( params: PerformUpdateSkillParams ): Promise<PerformSkillResult> { + try { + const skill = await updateSkill(params) + recordSkillEvent({ + action: 'updated', + workspaceId: params.workspaceId, + userId: params.userId, + skillId: skill.id, + skillName: skill.name, + actor: params, + }) + return { success: true, skill } + } catch (error) { + return skillFailureResult(error, 'Failed to update skill') + } +} + +export async function updateSkill( + params: Omit<PerformUpdateSkillParams, keyof ActorMetadata> +): Promise<SkillRow> { if ( params.name === undefined && params.description === undefined && params.content === undefined ) { - return validationFailure('At least one of name, description, or content is required') + throw new OrchestrationError( + 'validation', + 'At least one of name, description, or content is required' + ) } const invalid = @@ -299,10 +344,10 @@ export async function performUpdateSkill( ? fieldError(skillDescriptionSchema, params.description) : null) ?? (params.content !== undefined ? fieldError(skillContentSchema, params.content) : null) - if (invalid) return validationFailure(invalid) + if (invalid) throw new OrchestrationError('validation', invalid) const resolved = await resolveEditableSkill(params) - if (!resolved.ok) return resolved.result + if (!resolved.ok) throwSkillFailure(resolved.result) try { await upsertSkills({ @@ -319,41 +364,40 @@ export async function performUpdateSkill( returnSkills: false, }) } catch (error) { - return classifyUpsertError(error) + throwSkillFailure(classifyUpsertError(error)) } const row = await getSkillById({ skillId: params.skillId, workspaceId: params.workspaceId }) - if (!row) return { success: false, error: 'Skill not found', errorCode: 'not_found' } - - recordSkillEvent({ - action: 'updated', - workspaceId: params.workspaceId, - userId: params.userId, - skillId: row.id, - skillName: row.name, - actor: params, - }) - - return { success: true, skill: row } + if (!row) throw new OrchestrationError('not_found', 'Skill not found') + return row } export async function performDeleteSkill( params: PerformDeleteSkillParams ): Promise<PerformSkillResult> { + try { + const skill = await deleteSkillRecord(params) + recordSkillEvent({ + action: 'deleted', + workspaceId: params.workspaceId, + userId: params.userId, + skillId: skill.id, + skillName: skill.name, + actor: params, + }) + return { success: true, skill } + } catch (error) { + return skillFailureResult(error, 'Failed to delete skill') + } +} + +export async function deleteSkillRecord( + params: Omit<PerformDeleteSkillParams, keyof ActorMetadata> +): Promise<SkillRow> { const resolved = await resolveEditableSkill(params) - if (!resolved.ok) return resolved.result + if (!resolved.ok) throwSkillFailure(resolved.result) const deleted = await deleteSkill({ skillId: params.skillId, workspaceId: params.workspaceId }) - if (!deleted) return { success: false, error: 'Skill not found', errorCode: 'not_found' } - - recordSkillEvent({ - action: 'deleted', - workspaceId: params.workspaceId, - userId: params.userId, - skillId: params.skillId, - skillName: resolved.skill.name, - actor: params, - }) - - return { success: true, skill: resolved.skill } + if (!deleted) throw new OrchestrationError('not_found', 'Skill not found') + return resolved.skill } diff --git a/apps/sim/lib/workflows/custom-tools/operations.ts b/apps/sim/lib/workflows/custom-tools/operations.ts index 32e2ba8bf3c..aedc1f6cc2b 100644 --- a/apps/sim/lib/workflows/custom-tools/operations.ts +++ b/apps/sim/lib/workflows/custom-tools/operations.ts @@ -3,13 +3,13 @@ import { customTools } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { and, type Column, desc, eq, isNull, or } from 'drizzle-orm' -import type { V2CustomToolSortBy } from '@/lib/api/contracts/v2/custom-tools' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' -import { listOrderBy, searchFilter } from '@/lib/api/list-query' +import { type ListSortOrder, listOrderBy, searchFilter } from '@/lib/api/list-query' import { generateRequestId } from '@/lib/core/utils/request' const logger = createLogger('CustomToolsOperations') +export type CustomToolSortBy = 'title' | 'createdAt' | 'updatedAt' + /** * Internal function to create/update custom tools * Can be called from API routes or internal services @@ -148,14 +148,14 @@ const CUSTOM_TOOL_SORTS = { title: [customTools.title, customTools.id], createdAt: [customTools.createdAt, customTools.id], updatedAt: [customTools.updatedAt, customTools.id], -} satisfies Record<V2CustomToolSortBy, readonly Column[]> +} satisfies Record<CustomToolSortBy, readonly Column[]> export async function listWorkspaceCustomTools(params: { workspaceId: string /** Case-insensitive substring match on the tool title. */ search?: string - sortBy?: V2CustomToolSortBy - sortOrder?: V2SortOrder + sortBy?: CustomToolSortBy + sortOrder?: ListSortOrder }) { const { sortBy = 'createdAt', sortOrder = 'desc' } = params return db @@ -290,6 +290,36 @@ export async function getCustomToolByIdOrTitle(params: { return legacyTool[0] || null } +export async function updateCustomTool(params: { + toolId: string + userId: string + workspaceId: string + title: string + schema: unknown + code: string +}) { + const workspaceTool = await updateWorkspaceCustomTool(params) + if (workspaceTool) return workspaceTool + + const [legacyTool] = await db + .update(customTools) + .set({ + title: params.title, + schema: params.schema, + code: params.code, + updatedAt: new Date(), + }) + .where( + and( + eq(customTools.id, params.toolId), + isNull(customTools.workspaceId), + eq(customTools.userId, params.userId) + ) + ) + .returning() + return legacyTool ?? null +} + export async function deleteCustomTool(params: { toolId: string userId: string diff --git a/apps/sim/lib/workflows/skills/operations.ts b/apps/sim/lib/workflows/skills/operations.ts index daf82b2d285..bdc1347fe5f 100644 --- a/apps/sim/lib/workflows/skills/operations.ts +++ b/apps/sim/lib/workflows/skills/operations.ts @@ -3,9 +3,7 @@ import { skill, skillMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId, generateShortId } from '@sim/utils/id' import { and, type Column, desc, eq, ne } from 'drizzle-orm' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' -import type { V2SkillSortBy } from '@/lib/api/contracts/v2/skills' -import { listOrderBy, searchFilter } from '@/lib/api/list-query' +import { type ListSortOrder, listOrderBy, searchFilter } from '@/lib/api/list-query' import { generateRequestId } from '@/lib/core/utils/request' import { getEditableSkillIds } from '@/lib/skills/access' import { @@ -36,6 +34,7 @@ function builtinSkillRow(workspaceId: string, builtin: BuiltinSkill): typeof ski } type SkillRow = typeof skill.$inferSelect +export type SkillSortBy = 'name' | 'createdAt' | 'updatedAt' /** * Orderings for the public list's sortable fields, made total over the contract @@ -46,15 +45,15 @@ const SKILL_SORTS = { name: [skill.name, skill.id], createdAt: [skill.createdAt, skill.id], updatedAt: [skill.updatedAt, skill.id], -} satisfies Record<V2SkillSortBy, readonly Column[]> +} satisfies Record<SkillSortBy, readonly Column[]> /** The sort key {@link SKILL_SORTS} orders on, for one row. */ -function skillSortKey(row: SkillRow, sortBy: V2SkillSortBy): [string | number, string] { +function skillSortKey(row: SkillRow, sortBy: SkillSortBy): [string | number, string] { if (sortBy === 'name') return [row.name, row.id] return [(sortBy === 'createdAt' ? row.createdAt : row.updatedAt).getTime(), row.id] } -function compareSkills(a: SkillRow, b: SkillRow, sortBy: V2SkillSortBy): number { +function compareSkills(a: SkillRow, b: SkillRow, sortBy: SkillSortBy): number { const [aKey, aId] = skillSortKey(a, sortBy) const [bKey, bId] = skillSortKey(b, sortBy) if (aKey !== bKey) return aKey < bKey ? -1 : 1 @@ -86,7 +85,7 @@ export async function listSkills(params: { includeBuiltins?: boolean /** Case-insensitive substring match on the skill name. */ search?: string - sort?: { sortBy: V2SkillSortBy; sortOrder: V2SortOrder } + sort?: { sortBy: SkillSortBy; sortOrder: ListSortOrder } }): Promise<SkillRow[]> { const sortBy = params.sort?.sortBy ?? 'createdAt' const sortOrder = params.sort?.sortOrder ?? 'desc' From 464bbf6bb652584c4621878bae48c55bbc6ebbcd Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 8 Aug 2026 10:36:37 -0700 Subject: [PATCH 098/159] improvement(api): migrate policy-sensitive v2 reads (#6410) --- apps/sim/app/api/audit-logs/route.test.ts | 83 ++++++ apps/sim/app/api/audit-logs/route.ts | 127 +++------ apps/sim/app/api/v2/audit-logs/[id]/route.ts | 54 ++-- apps/sim/app/api/v2/audit-logs/route.test.ts | 169 +++++++----- apps/sim/app/api/v2/audit-logs/route.ts | 84 +++--- .../sim/app/api/v2/billing/logs/route.test.ts | 175 ++++++------ apps/sim/app/api/v2/billing/logs/route.ts | 58 ++-- .../app/api/v2/billing/status/route.test.ts | 198 +++++--------- apps/sim/app/api/v2/billing/status/route.ts | 95 ++----- apps/sim/app/api/v2/billing/utils.ts | 61 ----- apps/sim/app/api/v2/credentials/route.test.ts | 251 ++++++++---------- apps/sim/app/api/v2/credentials/route.ts | 52 ++-- .../sim/app/api/v2/logs/[runId]/route.test.ts | 179 +++++++------ apps/sim/app/api/v2/logs/[runId]/route.ts | 49 ++-- apps/sim/app/api/v2/logs/route.test.ts | 154 ++++++----- apps/sim/app/api/v2/logs/route.ts | 144 ++++------ .../workspaces/[workspaceId]/members/route.ts | 74 +++--- .../api/v2/workspaces/[workspaceId]/route.ts | 45 ++-- apps/sim/app/api/v2/workspaces/route.test.ts | 236 ++++++++-------- .../application/audit-log-use-cases.test.ts | 120 +++++++++ .../authorized-audit-log-use-case.ts | 59 ++++ .../audit-logs/application/get-audit-log.ts | 37 +++ .../audit-logs/application/list-audit-logs.ts | 39 +++ .../lib/audit-logs/application/operations.ts | 39 +++ .../authorized-billing-read-use-case.ts | 98 +++++++ .../application/billing-use-cases.test.ts | 188 +++++++++++++ .../billing/application/get-billing-status.ts | 88 ++++++ .../billing/application/list-billing-logs.ts | 56 ++++ .../sim/lib/billing/application/operations.ts | 41 +++ .../list-workspace-credentials.test.ts | 146 ++++++++++ .../application/list-workspace-credentials.ts | 67 +++++ .../lib/credentials/application/operations.ts | 10 + apps/sim/lib/credentials/queries.test.ts | 76 ++++++ apps/sim/lib/credentials/queries.ts | 57 +++- apps/sim/lib/logs/api/route-policies.ts | 14 + .../lib/logs/application/get-public-log.ts | 67 +++++ .../lib/logs/application/list-public-logs.ts | 93 +++++++ apps/sim/lib/logs/application/operations.ts | 18 ++ .../application/public-log-use-cases.test.ts | 182 +++++++++++++ apps/sim/lib/logs/public-queries.ts | 18 ++ .../application/get-public-workspace.ts | 31 +++ .../list-public-workspace-members.ts | 36 +++ .../lib/workspaces/application/operations.ts | 18 ++ .../public-workspace-reads.test.ts | 108 ++++++++ .../application/workspace-context.test.ts | 44 +++ .../application/workspace-context.ts | 32 +++ 46 files changed, 2809 insertions(+), 1261 deletions(-) create mode 100644 apps/sim/app/api/audit-logs/route.test.ts delete mode 100644 apps/sim/app/api/v2/billing/utils.ts create mode 100644 apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts create mode 100644 apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts create mode 100644 apps/sim/lib/audit-logs/application/get-audit-log.ts create mode 100644 apps/sim/lib/audit-logs/application/list-audit-logs.ts create mode 100644 apps/sim/lib/audit-logs/application/operations.ts create mode 100644 apps/sim/lib/billing/application/authorized-billing-read-use-case.ts create mode 100644 apps/sim/lib/billing/application/billing-use-cases.test.ts create mode 100644 apps/sim/lib/billing/application/get-billing-status.ts create mode 100644 apps/sim/lib/billing/application/list-billing-logs.ts create mode 100644 apps/sim/lib/billing/application/operations.ts create mode 100644 apps/sim/lib/credentials/application/list-workspace-credentials.test.ts create mode 100644 apps/sim/lib/credentials/application/list-workspace-credentials.ts create mode 100644 apps/sim/lib/credentials/application/operations.ts create mode 100644 apps/sim/lib/credentials/queries.test.ts create mode 100644 apps/sim/lib/logs/api/route-policies.ts create mode 100644 apps/sim/lib/logs/application/get-public-log.ts create mode 100644 apps/sim/lib/logs/application/list-public-logs.ts create mode 100644 apps/sim/lib/logs/application/operations.ts create mode 100644 apps/sim/lib/logs/application/public-log-use-cases.test.ts create mode 100644 apps/sim/lib/workspaces/application/get-public-workspace.ts create mode 100644 apps/sim/lib/workspaces/application/list-public-workspace-members.ts create mode 100644 apps/sim/lib/workspaces/application/operations.ts create mode 100644 apps/sim/lib/workspaces/application/public-workspace-reads.test.ts create mode 100644 apps/sim/lib/workspaces/application/workspace-context.test.ts create mode 100644 apps/sim/lib/workspaces/application/workspace-context.ts diff --git a/apps/sim/app/api/audit-logs/route.test.ts b/apps/sim/app/api/audit-logs/route.test.ts new file mode 100644 index 00000000000..2d8c4af1aa1 --- /dev/null +++ b/apps/sim/app/api/audit-logs/route.test.ts @@ -0,0 +1,83 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + execute: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) + +vi.mock('@/lib/audit-logs/application/list-audit-logs', () => ({ + listAuditLogs: { operation: { id: 'audit_logs.list' }, execute: mocks.execute }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET } from '@/app/api/audit-logs/route' + +const log = { + id: 'audit-1', + workspaceId: 'workspace-1', + actorId: 'admin-1', + actorName: 'Ada', + actorEmail: 'ada@example.com', + action: 'workspace.updated', + resourceType: 'workspace', + resourceId: 'workspace-1', + resourceName: 'Engineering', + description: null, + metadata: {}, + createdAt: new Date('2026-08-01T00:00:00Z'), +} + +describe('GET /api/audit-logs', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'admin-1' }, + session: { id: 'session-1' }, + }) + mocks.execute.mockResolvedValue({ data: [log], nextCursor: 'next-1' }) + }) + + it('authenticates before parsing the organization query', async () => { + const response = await GET(new NextRequest('http://localhost:3000/api/audit-logs')) + + expect(response.status).toBe(400) + expect(mocks.getSession).toHaveBeenCalled() + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('keeps the internal envelope while sharing the application operation', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/audit-logs?organizationId=organization-1' + ) + const response = await GET(request) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + success: true, + data: [{ id: 'audit-1', actorId: 'admin-1' }], + nextCursor: 'next-1', + }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' }, + input: expect.objectContaining({ organizationId: 'organization-1' }), + request, + }) + }) + + it('preserves internal typed error presentation', async () => { + mocks.execute.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Admin required')) + + const response = await GET( + new NextRequest('http://localhost:3000/api/audit-logs?organizationId=organization-1') + ) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'Admin required' }) + }) +}) diff --git a/apps/sim/app/api/audit-logs/route.ts b/apps/sim/app/api/audit-logs/route.ts index 0b71d5ef7ef..b944bfb9d52 100644 --- a/apps/sim/app/api/audit-logs/route.ts +++ b/apps/sim/app/api/audit-logs/route.ts @@ -1,97 +1,42 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' import { listAuditLogsContract } from '@/lib/api/contracts/audit-logs' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' -import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { - buildFilterConditions, - buildOrgScopeCondition, - getOrgWorkspaceIds, - queryAuditLogs, -} from '@/app/api/v1/audit-logs/query' - -const logger = createLogger('AuditLogsAPI') + defineInternalJsonRoute, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { listAuditLogs } from '@/lib/audit-logs/application/list-audit-logs' +import { auditLogOperations } from '@/lib/audit-logs/application/operations' +import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' export const dynamic = 'force-dynamic' -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest( - listAuditLogsContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json( - { error: getValidationErrorMessage(error, 'Invalid query parameters') }, - { status: 400 } - ), - } - ) - if (!parsed.success) return parsed.response - - const authResult = await validateEnterpriseAuditAccess( - session.user.id, - parsed.data.query.organizationId - ) - if (!authResult.success) { - return authResult.response - } - - const { organizationId, orgMemberIds } = authResult.context - - const { - organizationId: _targetOrganizationId, - search, - action, - resourceType, - actorId, - startDate, - endDate, - includeDeparted, - limit, - cursor, - } = parsed.data.query - - const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) - const scopeCondition = buildOrgScopeCondition({ - organizationId, - orgWorkspaceIds, - orgMemberIds, - includeDeparted, - }) - const filterConditions = buildFilterConditions({ - action, - resourceType, - actorId, - search, - startDate, - endDate, - }) - - const { data, nextCursor } = await queryAuditLogs( - [scopeCondition, ...filterConditions], - limit, - cursor - ) - - return NextResponse.json({ - success: true, - data: data.map(formatAuditLogEntry), - nextCursor, - }) - } catch (error: unknown) { - const message = getErrorMessage(error, 'Unknown error') - logger.error('Audit logs fetch error', { error: message }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const GET = defineInternalJsonRoute({ + contract: listAuditLogsContract, + auth: internalSessionAuth, + operation: auditLogOperations.list, + rateLimit: internalRateLimits.none({ + reason: 'Existing authenticated audit-log settings read has no request-rate policy', + }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ query }) => ({ + organizationId: query.organizationId, + includeDeparted: query.includeDeparted, + filters: { + search: query.search, + action: query.action, + resourceType: query.resourceType, + actorId: query.actorId, + startDate: query.startDate, + endDate: query.endDate, + }, + limit: query.limit, + cursor: query.cursor, + }), + useCase: listAuditLogs, + present: ({ data, nextCursor }) => ({ + success: true, + data: data.map(formatAuditLogEntry), + nextCursor, + }), }) diff --git a/apps/sim/app/api/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[id]/route.ts index 242f07ada89..54d5292bb29 100644 --- a/apps/sim/app/api/v2/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v2/audit-logs/[id]/route.ts @@ -1,12 +1,13 @@ -import { db } from '@sim/db' -import { auditLog } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' import { v2GetAuditLogContract } from '@/lib/api/contracts/v2/audit-logs' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' -import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { getAuditLog } from '@/lib/audit-logs/application/get-audit-log' +import { auditLogOperations } from '@/lib/audit-logs/application/operations' import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' -import { v2Data, v2Error } from '@/app/api/v2/lib/response' export const revalidate = 0 @@ -17,36 +18,13 @@ export const revalidate = 0 * organization. Audit logs are personal-key-only because a workspace-scoped * key must never expand into organization-wide visibility. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetAuditLogContract, - rateLimitEndpoint: 'audit-logs', - handler: async ({ input, auth: { userId, rateLimit } }) => { - if (rateLimit.keyType !== 'personal') { - return v2Error('FORBIDDEN', 'Audit logs require a personal API key') - } - - const authResult = await resolveEnterpriseAuditAccess(userId, input.query.organizationId) - if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) - - const { id } = input.params - const { organizationId, orgMemberIds } = authResult.context - - const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) - const scopeCondition = buildOrgScopeCondition({ - organizationId, - orgWorkspaceIds, - orgMemberIds, - includeDeparted: true, - }) - - const [log] = await db - .select() - .from(auditLog) - .where(and(eq(auditLog.id, id), scopeCondition)) - .limit(1) - - if (!log) return v2Error('NOT_FOUND', 'Audit log not found') - - return v2Data(formatV2AuditLogEntry(log), { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: auditLogOperations.readDetail, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ id: params.id, organizationId: query.organizationId }), + useCase: getAuditLog, + present: ({ log }) => ({ data: formatV2AuditLogEntry(log) }), }) 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 c80f6e7c406..a8c984556ce 100644 --- a/apps/sim/app/api/v2/audit-logs/route.test.ts +++ b/apps/sim/app/api/v2/audit-logs/route.test.ts @@ -4,103 +4,138 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveEnterpriseAuditAccess, - mockBuildFilterConditions, - mockBuildOrgScopeCondition, - mockGetOrgWorkspaceIds, - mockQueryAuditLogs, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveEnterpriseAuditAccess: vi.fn(), - mockBuildFilterConditions: vi.fn(), - mockBuildOrgScopeCondition: vi.fn(), - mockGetOrgWorkspaceIds: vi.fn(), - mockQueryAuditLogs: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreauth: vi.fn(), + checkOperationRate: vi.fn(), + gate: vi.fn(), + list: vi.fn(), + get: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/app/api/v1/audit-logs/auth', () => ({ - resolveEnterpriseAuditAccess: mockResolveEnterpriseAuditAccess, +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreauth + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, })) -vi.mock('@/app/api/v1/audit-logs/query', () => ({ - buildFilterConditions: mockBuildFilterConditions, - buildOrgScopeCondition: mockBuildOrgScopeCondition, - getOrgWorkspaceIds: mockGetOrgWorkspaceIds, - queryAuditLogs: mockQueryAuditLogs, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) + +vi.mock('@/lib/audit-logs/application/list-audit-logs', () => ({ + listAuditLogs: { operation: { id: 'audit_logs.list' }, execute: mocks.list }, })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/audit-logs/application/get-audit-log', () => ({ + getAuditLog: { operation: { id: 'audit_logs.read_detail' }, execute: mocks.get }, })) -import { GET } from '@/app/api/v2/audit-logs/route' +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' -const RATE_LIMIT = { - allowed: true, - userId: 'admin-1', - keyType: 'personal', - limit: 100, - remaining: 99, - resetAt: new Date('2026-08-01T00:00:00Z'), +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'admin-1', keyId: 'key-1' }, + rolloutUserId: 'admin-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:admin-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, } - -function callGet(query = '') { - return GET(new NextRequest(`http://localhost:3000/api/v2/audit-logs${query}`)) +const log = { + id: 'audit-1', + workspaceId: 'workspace-1', + actorId: 'admin-1', + actorName: 'Ada', + actorEmail: 'ada@example.com', + action: 'workspace.updated', + resourceType: 'workspace', + resourceId: 'workspace-1', + resourceName: 'Engineering', + description: null, + metadata: {}, + createdAt: new Date('2026-08-01T00:00:00Z'), + ipAddress: null, + userAgent: null, } -describe('GET /api/v2/audit-logs', () => { +describe('v2 audit-log routes', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) - mockResolveEnterpriseAuditAccess.mockResolvedValue({ - success: true, - context: { organizationId: 'org-1', orgMemberIds: ['admin-1'] }, + mocks.authenticate.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkPreauth.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-01T01:00:00Z'), + }) + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-01T01:00:00Z'), }) - mockGetOrgWorkspaceIds.mockResolvedValue([]) - mockBuildOrgScopeCondition.mockReturnValue({ type: 'scope' }) - mockBuildFilterConditions.mockReturnValue([]) - mockQueryAuditLogs.mockResolvedValue({ data: [], nextCursor: undefined }) + mocks.list.mockResolvedValue({ data: [log], nextCursor: 'next-1' }) + mocks.get.mockResolvedValue({ log }) }) - it('requires an explicit organization before authorization', async () => { - const response = await callGet() + it('authenticates and rate-limits before validating organization input', async () => { + const response = await listLogs(new NextRequest('http://localhost:3000/api/v2/audit-logs')) expect(response.status).toBe(400) - expect(mockResolveEnterpriseAuditAccess).not.toHaveBeenCalled() + expect(mocks.authenticate).toHaveBeenCalled() + expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2) + expect(mocks.list).not.toHaveBeenCalled() }) - it('rejects workspace keys before organization-wide access is resolved', async () => { - mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) - - const response = await callGet('?organizationId=org-1') + it('maps list filters into the authorized application operation', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/v2/audit-logs?organizationId=org-1&actorEmail=ada%40example.com' + ) + const response = await listLogs(request) - expect(response.status).toBe(403) - expect(mockResolveEnterpriseAuditAccess).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ data: [{ id: 'audit-1' }], nextCursor: 'next-1' }) + expect(mocks.list).toHaveBeenCalledWith({ + principal: auth.principal, + input: expect.objectContaining({ + organizationId: 'org-1', + filters: expect.objectContaining({ actorEmail: 'ada@example.com' }), + }), + request, + }) + expect(response.headers.get('x-ratelimit-limit')).toBe('100') }) - it('authorizes exactly the requested organization for personal keys', async () => { - const response = await callGet('?organizationId=org-1') + it('projects typed admin-policy failures without leaking internals', async () => { + mocks.list.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Admin required')) - expect(response.status).toBe(200) - expect(mockResolveEnterpriseAuditAccess).toHaveBeenCalledWith('admin-1', 'org-1') - expect(mockQueryAuditLogs).toHaveBeenCalled() + const response = await listLogs( + new NextRequest('http://localhost:3000/api/v2/audit-logs?organizationId=org-1') + ) + + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ error: { code: 'FORBIDDEN' } }) }) - it('filters by the public actor email without requiring a user ID', async () => { - const response = await callGet('?organizationId=org-1&actorEmail=ada%40example.com') + it('keeps the detail envelope independent', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/v2/audit-logs/audit-1?organizationId=org-1' + ) + const response = await getDetail(request, { + params: Promise.resolve({ id: 'audit-1' }), + }) expect(response.status).toBe(200) - expect(mockBuildFilterConditions).toHaveBeenCalledWith( - expect.objectContaining({ actorEmail: 'ada@example.com' }) - ) - expect(mockBuildFilterConditions).toHaveBeenCalledWith( - expect.not.objectContaining({ actorId: expect.anything() }) - ) + expect(await response.json()).toMatchObject({ data: { id: 'audit-1' } }) + expect(mocks.get).toHaveBeenCalledWith({ + principal: auth.principal, + input: { id: 'audit-1', organizationId: 'org-1' }, + request, + }) }) }) diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts index dca2168878e..b9bc8743819 100644 --- a/apps/sim/app/api/v2/audit-logs/route.ts +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -1,14 +1,13 @@ import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { - buildFilterConditions, - buildOrgScopeCondition, - getOrgWorkspaceIds, - queryAuditLogs, -} from '@/app/api/v1/audit-logs/query' + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +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 { v2CursorList, v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -20,49 +19,30 @@ export const revalidate = 0 * are personal-key-only because a workspace-scoped key must never expand into * organization-wide visibility. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListAuditLogsContract, - rateLimitEndpoint: 'audit-logs', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const params = input.query - - if (rateLimit.keyType !== 'personal') { - return v2Error('FORBIDDEN', 'Audit logs require a personal API key') - } - - const authResult = await resolveEnterpriseAuditAccess(userId, params.organizationId) - if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) - - const { organizationId, orgMemberIds } = authResult.context - - const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) - - if (params.workspaceId && !orgWorkspaceIds.includes(params.workspaceId)) { - return v2Error('BAD_REQUEST', 'workspaceId does not belong to your organization') - } - - const scopeCondition = buildOrgScopeCondition({ - organizationId, - orgWorkspaceIds, - orgMemberIds, - includeDeparted: params.includeDeparted, - }) - const filterConditions = buildFilterConditions({ - action: params.action, - resourceType: params.resourceType, - resourceId: params.resourceId, - workspaceId: params.workspaceId, - actorEmail: params.actorEmail, - startDate: params.startDate, - endDate: params.endDate, - }) - - const { data, nextCursor } = await queryAuditLogs( - [scopeCondition, ...filterConditions], - params.limit, - params.cursor - ) - - return v2CursorList(data.map(formatV2AuditLogEntry), nextCursor ?? null, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: auditLogOperations.list, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => ({ + organizationId: query.organizationId, + includeDeparted: query.includeDeparted, + filters: { + action: query.action, + resourceType: query.resourceType, + resourceId: query.resourceId, + workspaceId: query.workspaceId, + actorEmail: query.actorEmail, + startDate: query.startDate, + endDate: query.endDate, + }, + limit: query.limit, + cursor: query.cursor, + }), + useCase: listAuditLogs, + present: ({ data, nextCursor }) => ({ + data: data.map(formatV2AuditLogEntry), + nextCursor: 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 e3a5c354dbc..95d0f017a58 100644 --- a/apps/sim/app/api/v2/billing/logs/route.test.ts +++ b/apps/sim/app/api/v2/billing/logs/route.test.ts @@ -3,139 +3,120 @@ */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { apportionCredits } from '@/lib/billing/credits/conversion' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetUserUsageLogs, - mockGetUsageCreditsByLogId, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetUserUsageLogs: vi.fn(), - mockGetUsageCreditsByLogId: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreauth: vi.fn(), + checkOperationRate: vi.fn(), + gate: vi.fn(), + execute: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/lib/billing/core/usage-log', () => ({ - getUserUsageLogs: mockGetUserUsageLogs, - getUsageCreditsByLogId: mockGetUsageCreditsByLogId, +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreauth + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) + +vi.mock('@/lib/billing/application/list-billing-logs', () => ({ + listBillingLogs: { operation: { id: 'billing.logs.list' }, execute: mocks.execute }, })) import { GET } from '@/app/api/v2/billing/logs/route' -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'personal', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -function callLogs(query = '') { - return GET(new NextRequest(`http://localhost:3000/api/v2/billing/logs${query}`)) +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, } describe('GET /api/v2/billing/logs', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetUserUsageLogs.mockResolvedValue({ - logs: [ - { - id: 'log-1', - createdAt: '2026-07-01T00:00:00.000Z', - category: 'model', - source: 'workflow', - description: 'claude-sonnet', - cost: 0.06, - workspaceId: 'ws-1', - workflowId: 'workflow-1', - workflowName: 'Support Agent', - executionId: 'execution-1', - }, - ], - summary: { totalCost: 0, bySource: {} }, - pagination: { hasMore: false }, + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-01T00:00:00Z')) + mocks.authenticate.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkPreauth.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-01T01:00:00Z'), + }) + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-01T01:00:00Z'), + }) + mocks.execute.mockResolvedValue({ + usage: { + logs: [ + { + id: 'log-1', + createdAt: '2026-07-01T00:00:00.000Z', + source: 'workflow', + cost: 0.06, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + workflowName: 'Support Agent', + executionId: 'run-1', + }, + ], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: false }, + }, + creditsByLogId: { 'log-1': 12 }, }) - mockGetUsageCreditsByLogId.mockResolvedValue( - apportionCredits([{ key: 'log-1', dollars: 0.06 }]) - ) }) - it('returns ledger rows without embedding billing status', async () => { - const response = await callLogs() - const body = await response.json() + it('maps billing filters and preserves the public ledger envelope', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/v2/billing/logs?workspaceId=workspace-1&source=sim-chat' + ) + const response = await GET(request) expect(response.status).toBe(200) - expect(body).toEqual({ + expect(await response.json()).toEqual({ data: [ { id: 'log-1', createdAt: '2026-07-01T00:00:00.000Z', source: 'workflow', - workspaceId: 'ws-1', + workspaceId: 'workspace-1', workflow: { id: 'workflow-1', name: 'Support Agent' }, - runId: 'execution-1', + runId: 'run-1', creditCost: 12, }, ], nextCursor: null, }) - expect(body).not.toHaveProperty('status') - }) - - it('normalizes both internal chat sources to sim-chat', async () => { - const response = await callLogs('?source=sim-chat') - - expect(response.status).toBe(200) - expect(mockGetUserUsageLogs).toHaveBeenCalledWith( - 'user-1', - expect.objectContaining({ source: ['copilot', 'workspace-chat'] }) - ) - }) - - it('forwards the cursor when more rows remain', async () => { - mockGetUserUsageLogs.mockResolvedValue({ - logs: [], - summary: { totalCost: 0, bySource: {} }, - pagination: { hasMore: true, nextCursor: 'log-42' }, + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: expect.objectContaining({ + workspaceId: 'workspace-1', + source: ['copilot', 'workspace-chat'], + }), + request, }) - mockGetUsageCreditsByLogId.mockResolvedValue({}) - - const body = await (await callLogs()).json() - - expect(body.nextCursor).toBe('log-42') }) - it('rejects custom periods without a start date', async () => { - const response = await callLogs('?period=custom') + it('authenticates before rejecting invalid custom ranges', async () => { + const response = await GET( + new NextRequest('http://localhost:3000/api/v2/billing/logs?period=custom') + ) expect(response.status).toBe(400) - expect(mockGetUserUsageLogs).not.toHaveBeenCalled() - }) - - it('authorizes a personal key before reading a workspace ledger', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - - const response = await callLogs('?workspaceId=ws-2') - - expect(response.status).toBe(403) - expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + expect(mocks.authenticate).toHaveBeenCalled() + expect(mocks.execute).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/billing/logs/route.ts b/apps/sim/app/api/v2/billing/logs/route.ts index b2893ec95b7..03d02a8d715 100644 --- a/apps/sim/app/api/v2/billing/logs/route.ts +++ b/apps/sim/app/api/v2/billing/logs/route.ts @@ -1,38 +1,39 @@ import { v2ListBillingLogsContract } from '@/lib/api/contracts/v2/billing' -import { getUsageCreditsByLogId, getUserUsageLogs } from '@/lib/billing/core/usage-log' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +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 { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' -import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' -import { v2CursorList } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** Cursor-paged, credit-denominated billing ledger. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListBillingLogsContract, - rateLimitEndpoint: 'billing-usage', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { source, workspaceId, period, startDate, endDate, limit, cursor } = input.query - - const workspaceFilter = await v2BillingWorkspaceFilter(rateLimit, workspaceId) - if (!workspaceFilter.ok) return workspaceFilter.response - - const dateRange = resolveDateRange(period, startDate, endDate) - const filter = { - source: source ? toInternalUsageLogSources(source) : undefined, - workspaceId: workspaceFilter.workspaceId, + auth: v2ApiKeyAuth, + operation: billingOperations.listLogs, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => { + const dateRange = resolveDateRange(query.period, query.startDate, query.endDate) + return { + source: query.source ? toInternalUsageLogSources(query.source) : undefined, + workspaceId: query.workspaceId, startDate: dateRange.startDate, endDate: dateRange.endDate, + limit: query.limit, + cursor: query.cursor, } - - const [result, creditsByLogId] = await Promise.all([ - getUserUsageLogs(userId, { ...filter, limit, cursor, includeSummary: false }), - getUsageCreditsByLogId(userId, filter), - ]) - - const items = result.logs.map((log) => ({ + }, + useCase: listBillingLogs, + present: ({ usage, creditsByLogId }) => ({ + data: usage.logs.map((log) => ({ id: log.id, createdAt: log.createdAt, source: toBillingUsageLogSource(log.source), @@ -40,12 +41,7 @@ export const GET = withPublicApiRouteHandler({ workflow: log.workflowId ? { id: log.workflowId, name: log.workflowName ?? null } : null, runId: log.executionId ?? null, creditCost: creditsByLogId[log.id] ?? 0, - })) - - return v2CursorList( - items, - result.pagination.hasMore ? (result.pagination.nextCursor ?? null) : null, - { rateLimit } - ) - }, + })), + nextCursor: usage.pagination.hasMore ? (usage.pagination.nextCursor ?? null) : null, + }), }) 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 84734542d33..6c87206289f 100644 --- a/apps/sim/app/api/v2/billing/status/route.test.ts +++ b/apps/sim/app/api/v2/billing/status/route.test.ts @@ -4,168 +4,106 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockCheckBillingBlocked, - mockCheckUsageStatus, - mockGetHighestPrioritySubscription, - mockDeriveBillingContext, - mockResolveBillingAttribution, - mockCheckAttributedBillingBlocks, - mockToUsageLimitSubscription, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockCheckBillingBlocked: vi.fn(), - mockCheckUsageStatus: vi.fn(), - mockGetHighestPrioritySubscription: vi.fn(), - mockDeriveBillingContext: vi.fn(), - mockResolveBillingAttribution: vi.fn(), - mockCheckAttributedBillingBlocks: vi.fn(), - mockToUsageLimitSubscription: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreauth: vi.fn(), + checkOperationRate: vi.fn(), + gate: vi.fn(), + execute: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ - checkBillingBlocked: mockCheckBillingBlocked, - checkBillingEntityBlocked: vi.fn(), - checkUsageStatus: mockCheckUsageStatus, +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreauth + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, })) -vi.mock('@/lib/billing/core/subscription', () => ({ - getHighestPrioritySubscription: mockGetHighestPrioritySubscription, -})) - -vi.mock('@/lib/billing/core/usage-log', () => ({ - deriveBillingContext: mockDeriveBillingContext, -})) - -vi.mock('@/lib/billing/core/billing-attribution', () => ({ - resolveBillingAttribution: mockResolveBillingAttribution, - checkAttributedBillingBlocks: mockCheckAttributedBillingBlocks, - toUsageLimitSubscription: mockToUsageLimitSubscription, -})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +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 { GET } from '@/app/api/v2/billing/status/route' -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'personal', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), +const auth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: 'workspace-1', keyId: 'key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -function callStatus(query = '') { - return GET(new NextRequest(`http://localhost:3000/api/v2/billing/status${query}`)) +const result = { + workspaceId: 'workspace-1', + period: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, + plan: 'team', + status: 'active' as const, + credits: { used: 500, limit: 20_000, remaining: 19_500 }, } describe('GET /api/v2/billing/status', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro' }) - mockDeriveBillingContext.mockReturnValue({ - billingEntity: { type: 'user', id: 'user-1' }, - billingPeriod: { - start: new Date('2026-07-01T00:00:00Z'), - end: new Date('2026-08-01T00:00:00Z'), - }, - }) - mockCheckUsageStatus.mockResolvedValue({ - isExceeded: false, - currentUsage: 2.5, - limit: 100, + mocks.authenticate.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkPreauth.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-01T01:00:00Z'), }) - mockCheckBillingBlocked.mockResolvedValue({ blocked: false }) - mockCheckAttributedBillingBlocks.mockResolvedValue({ blocked: false }) - mockToUsageLimitSubscription.mockReturnValue({ - referenceId: 'org-1', - plan: 'team', - status: 'active', - seats: 5, - periodStart: new Date('2026-07-01T00:00:00Z'), - periodEnd: new Date('2026-08-01T00:00:00Z'), + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-01T01:00:00Z'), }) + mocks.execute.mockResolvedValue(result) }) - it('returns status and allowance without ledger rows or source summaries', async () => { - const response = await callStatus() - const body = await response.json() + it('passes only the authenticated principal and requested scope to the use case', async () => { + const request = new NextRequest( + 'http://localhost:3000/api/v2/billing/status?workspaceId=workspace-1' + ) + const response = await GET(request) expect(response.status).toBe(200) - expect(body.data).toEqual({ - workspaceId: null, - period: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' }, - plan: 'pro', - status: 'active', - credits: { used: 500, limit: 20000, remaining: 19500 }, + expect(await response.json()).toEqual({ data: result }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: 'workspace-1' }, + request, }) - expect(body.data).not.toHaveProperty('bySourceCredits') + expect(response.headers.get('x-ratelimit-limit')).toBe('100') }) - it('reports billing blocks before usage-limit state', async () => { - mockCheckUsageStatus.mockResolvedValue({ isExceeded: true, currentUsage: 100, limit: 100 }) - mockCheckBillingBlocked.mockResolvedValue({ blocked: true }) + it('projects typed workspace-policy errors', async () => { + mocks.execute.mockRejectedValueOnce( + new OrchestrationError('forbidden', 'API key is not authorized for this workspace') + ) - const body = await (await callStatus()).json() + const response = await GET( + new NextRequest('http://localhost:3000/api/v2/billing/status?workspaceId=workspace-2') + ) - expect(body.data.status).toBe('billing_blocked') + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ error: { code: 'FORBIDDEN' } }) }) - it('resolves a workspace billing status against the workspace payer', async () => { - mockResolveBillingAttribution.mockResolvedValue({ - actorUserId: 'user-1', - workspaceId: 'ws-1', - organizationId: 'org-1', - billedAccountUserId: 'owner-1', - billingEntity: { type: 'organization', id: 'org-1' }, - billingPeriod: { - start: '2026-07-01T00:00:00.000Z', - end: '2026-08-01T00:00:00.000Z', - }, - payerSubscription: { - id: 'sub-1', - referenceId: 'org-1', - plan: 'team', - status: 'active', - seats: 5, - periodStart: '2026-07-01T00:00:00.000Z', - periodEnd: '2026-08-01T00:00:00.000Z', - }, - }) + it('hides unknown billing infrastructure errors', async () => { + mocks.execute.mockRejectedValueOnce(new Error('stripe account details')) - const body = await (await callStatus('?workspaceId=ws-1')).json() + const response = await GET(new NextRequest('http://localhost:3000/api/v2/billing/status')) - expect(body.data.workspaceId).toBe('ws-1') - expect(body.data.plan).toBe('team') - expect(mockCheckUsageStatus).toHaveBeenCalledWith( - 'owner-1', - expect.objectContaining({ referenceId: 'org-1', plan: 'team' }) - ) - }) - - it('403s a workspace API key asking for a different workspace', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - keyType: 'workspace', - workspaceId: 'ws-1', + expect(response.status).toBe(500) + expect(await response.json()).toMatchObject({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, }) - - const response = await callStatus('?workspaceId=ws-2') - - expect(response.status).toBe(403) - expect(mockCheckUsageStatus).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/billing/status/route.ts b/apps/sim/app/api/v2/billing/status/route.ts index 868e7dd0c06..b5a7fd95b5f 100644 --- a/apps/sim/app/api/v2/billing/status/route.ts +++ b/apps/sim/app/api/v2/billing/status/route.ts @@ -1,87 +1,24 @@ +import { v2GetBillingStatusContract } from '@/lib/api/contracts/v2/billing' import { - type V2BillingStatusData, - v2GetBillingStatusContract, -} from '@/lib/api/contracts/v2/billing' -import { - checkBillingBlocked, - checkBillingEntityBlocked, - checkUsageStatus, -} from '@/lib/billing/calculations/usage-monitor' -import { - checkAttributedBillingBlocks, - resolveBillingAttribution, - toUsageLimitSubscription, -} from '@/lib/billing/core/billing-attribution' -import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { deriveBillingContext } from '@/lib/billing/core/usage-log' -import { dollarsToCredits } from '@/lib/billing/credits/conversion' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' -import { v2Data } from '@/app/api/v2/lib/response' + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { getBillingStatus } from '@/lib/billing/application/get-billing-status' +import { billingOperations } from '@/lib/billing/application/operations' export const dynamic = 'force-dynamic' export const revalidate = 0 /** Current billing standing; ledger events are exposed separately by `/billing/logs`. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetBillingStatusContract, - rateLimitEndpoint: 'billing-usage', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const workspaceFilter = await v2BillingWorkspaceFilter(rateLimit, input.query.workspaceId) - if (!workspaceFilter.ok) return workspaceFilter.response - - let data: V2BillingStatusData - if (workspaceFilter.workspaceId) { - const attribution = await resolveBillingAttribution({ - actorUserId: userId, - workspaceId: workspaceFilter.workspaceId, - }) - const [usage, block] = await Promise.all([ - checkUsageStatus(attribution.billedAccountUserId, toUsageLimitSubscription(attribution)), - checkAttributedBillingBlocks(attribution), - ]) - data = { - workspaceId: workspaceFilter.workspaceId, - period: attribution.billingPeriod, - plan: attribution.payerSubscription?.plan ?? 'free', - status: block.blocked ? 'billing_blocked' : usage.isExceeded ? 'limit_exceeded' : 'active', - credits: { - used: dollarsToCredits(usage.currentUsage), - limit: dollarsToCredits(usage.limit), - remaining: dollarsToCredits(usage.limit - usage.currentUsage), - }, - } - } else { - const subscription = await getHighestPrioritySubscription(userId) - const { billingEntity, billingPeriod } = deriveBillingContext(userId, subscription) - const [usage, actorBlock, payerBlock] = await Promise.all([ - checkUsageStatus(userId, subscription), - checkBillingBlocked(userId), - billingEntity.type === 'user' && billingEntity.id === userId - ? Promise.resolve({ blocked: false }) - : checkBillingEntityBlocked(billingEntity), - ]) - data = { - workspaceId: null, - period: { - start: billingPeriod.start.toISOString(), - end: billingPeriod.end.toISOString(), - }, - plan: subscription?.plan ?? 'free', - status: - actorBlock.blocked || payerBlock.blocked - ? 'billing_blocked' - : usage.isExceeded - ? 'limit_exceeded' - : 'active', - credits: { - used: dollarsToCredits(usage.currentUsage), - limit: dollarsToCredits(usage.limit), - remaining: dollarsToCredits(usage.limit - usage.currentUsage), - }, - } - } - - return v2Data(data, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: billingOperations.readStatus, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => ({ workspaceId: query.workspaceId }), + useCase: getBillingStatus, + present: (data) => ({ data }), }) diff --git a/apps/sim/app/api/v2/billing/utils.ts b/apps/sim/app/api/v2/billing/utils.ts deleted file mode 100644 index 9cf95afcbef..00000000000 --- a/apps/sim/app/api/v2/billing/utils.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { NextResponse } from 'next/server' -import { type RateLimitResult, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2Error } from '@/app/api/v2/lib/response' - -type BillingWorkspaceFilter = - | { ok: true; workspaceId: string | undefined } - | { ok: false; response: NextResponse } - -/** - * Resolves the effective `workspaceId` ledger filter for the caller's key. - * Personal keys may read their account-wide ledger without a filter. When any - * key targets a workspace, the caller must have read access and the workspace's - * API-key policy must allow the key type. Workspace-scoped keys remain pinned to - * their own workspace. - */ -export async function v2BillingWorkspaceFilter( - rateLimit: RateLimitResult, - requestedWorkspaceId: string | undefined -): Promise<BillingWorkspaceFilter> { - if ( - rateLimit.keyType === 'workspace' && - requestedWorkspaceId && - requestedWorkspaceId !== rateLimit.workspaceId - ) { - return { - ok: false, - response: v2Error('FORBIDDEN', 'API key is not authorized for this workspace'), - } - } - - const workspaceId = - rateLimit.keyType === 'workspace' ? rateLimit.workspaceId : requestedWorkspaceId - - if (!workspaceId) { - if (rateLimit.keyType === 'workspace') { - return { - ok: false, - response: v2Error('FORBIDDEN', 'Workspace-scoped API key is missing its workspace'), - } - } - return { ok: true, workspaceId: undefined } - } - - const userId = rateLimit.userId - if (!userId) { - return { - ok: false, - response: v2Error('UNAUTHORIZED', 'Authentication required'), - } - } - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) { - return { - ok: false, - response: v2Error('FORBIDDEN', access.message), - } - } - - return { ok: true, workspaceId } -} diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index adb94296696..4a4badb6b0b 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -4,175 +4,152 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockCheckWorkspaceAccess, - mockListVisibleWorkspaceCredentials, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockCheckWorkspaceAccess: vi.fn(), - mockListVisibleWorkspaceCredentials: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreauth: vi.fn(), + checkOperationRate: vi.fn(), + gate: vi.fn(), + execute: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - checkWorkspaceAccess: mockCheckWorkspaceAccess, +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreauth + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, })) -vi.mock('@/lib/credentials/queries', () => ({ - listVisibleWorkspaceCredentials: mockListVisibleWorkspaceCredentials, -})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/credentials/application/list-workspace-credentials', () => ({ + listWorkspaceCredentials: { + operation: { id: 'credentials.connections.list' }, + execute: mocks.execute, + }, })) import { GET } from '@/app/api/v2/credentials/route' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} - -function buildVisible(overrides: Record<string, unknown> = {}) { - return { - id: 'cred_abc123', +const auth = { + principal: { + kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, - type: 'service_account' as const, - displayName: 'Zoom account acct_123', - description: null, - providerId: 'zoom-service-account', - accountId: null, - envKey: null, - envOwnerUserId: null, - createdBy: 'user-1', - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - hasServiceAccountKey: true, - role: 'admin' as const, - ...overrides, - } + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const credential = { + id: 'credential-1', + workspaceId: WORKSPACE_ID, + type: 'service_account' as const, + displayName: 'Zoom account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: 'MUST_NOT_LEAK', + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + hasServiceAccountKey: true, + role: 'member' as const, } - -const callList = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/credentials?${query}`)) describe('GET /api/v2/credentials', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: true }) - mockListVisibleWorkspaceCredentials.mockResolvedValue([buildVisible()]) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callList(`workspaceId=${WORKSPACE_ID}`) - - expect(res.status).toBe(404) - expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callList('') - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + mocks.authenticate.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkPreauth.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-01-01T01:00:00Z'), }) - - const res = await callList(`workspaceId=${WORKSPACE_ID}`) - - expect(res.status).toBe(403) - expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), + }) + mocks.execute.mockResolvedValue({ credentials: [credential] }) }) - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - - const res = await callList(`workspaceId=${WORKSPACE_ID}`) + it('authenticates and charges before validating workspace input', async () => { + const response = await GET(new NextRequest('http://localhost:3000/api/v2/credentials')) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') + expect(response.status).toBe(400) + expect(mocks.authenticate).toHaveBeenCalled() + expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2) + expect(mocks.execute).not.toHaveBeenCalled() }) - it('returns connection metadata without environment-secret fields', async () => { - const res = await callList(`workspaceId=${WORKSPACE_ID}`) - const body = await res.json() + it('calls the application operation with the workspace principal', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&type=service_account` + ) + const response = await GET(request) - expect(res.status).toBe(200) - expect(body.nextCursor).toBeNull() - expect(body.data).toEqual([ - { - id: 'cred_abc123', + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: WORKSPACE_ID, type: 'service_account', - displayName: 'Zoom account acct_123', - description: null, - providerId: 'zoom-service-account', - accountId: null, - hasServiceAccountKey: true, - role: 'admin', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', + providerId: undefined, + search: undefined, + sortBy: 'createdAt', + sortOrder: 'desc', }, - ]) - expect(JSON.stringify(body)).not.toContain('envKey') - expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: WORKSPACE_ID, - userId: 'user-1', - types: ['oauth', 'service_account'], - }) - ) + request, + }) }) - it('accepts only OAuth and service-account type filters', async () => { - await callList(`workspaceId=${WORKSPACE_ID}&type=oauth&providerId=slack`) - expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( - expect.objectContaining({ types: ['oauth'], providerId: 'slack' }) + 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}`) ) - - const invalid = await callList(`workspaceId=${WORKSPACE_ID}&type=env_workspace`) - expect(invalid.status).toBe(400) + const body = await response.json() + + expect(body).toEqual({ + data: [ + { + id: 'credential-1', + type: 'service_account', + displayName: 'Zoom account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + hasServiceAccountKey: true, + role: 'member', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, + ], + nextCursor: null, + }) + expect(JSON.stringify(body)).not.toContain('envKey') + expect(JSON.stringify(body)).not.toContain('createdBy') }) - it('rejects invalid list controls', async () => { - const invalidSort = await callList(`workspaceId=${WORKSPACE_ID}&sortBy=name);--`) - const invalidDirection = await callList(`workspaceId=${WORKSPACE_ID}&sortOrder=sideways`) - const emptySearch = await callList(`workspaceId=${WORKSPACE_ID}&search=`) + it('hides repository errors that may contain secret details', async () => { + mocks.execute.mockRejectedValueOnce(new Error('encryptedServiceAccountKey failed')) + + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}`) + ) - expect(invalidSort.status).toBe(400) - expect(invalidDirection.status).toBe(400) - expect(emptySearch.status).toBe(400) + expect(response.status).toBe(500) + expect(await response.json()).toMatchObject({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) }) }) diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index c61307198f1..0312ea3a957 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -1,42 +1,28 @@ import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' -import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} 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 { v2CursorList, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/credentials — List the credentials the caller can see in a workspace. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListCredentialsContract, - rateLimitEndpoint: 'credentials', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, type, providerId, search, sortBy, sortOrder } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - /** - * Credential visibility is per credential, not per workspace: membership - * rows and shared-type admin access decide what this caller sees, so the - * workspace permission is re-read here for the `canAdmin` bit. - */ - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) - const credentials = await listVisibleWorkspaceCredentials({ - workspaceId, - userId, - workspaceAccess, - types: type ? [type] : ['oauth', 'service_account'], - providerId, - search, - sortBy, - sortOrder, - }) - - // The per-workspace credential set is small and bounded → a single full page. - return v2CursorList(credentials.map(toV2Credential), null, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: credentialOperations.listConnections, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => query, + useCase: listWorkspaceCredentials, + present: ({ credentials }) => ({ + data: credentials.map(toV2Credential), + nextCursor: null, + }), }) diff --git a/apps/sim/app/api/v2/logs/[runId]/route.test.ts b/apps/sim/app/api/v2/logs/[runId]/route.test.ts index df621d35169..b538e07d63c 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.test.ts @@ -1,130 +1,139 @@ /** * @vitest-environment node */ - -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockLoadActiveFolderPathIndex, - mockMaterializeExecutionData, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockLoadActiveFolderPathIndex: vi.fn(), - mockMaterializeExecutionData: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreauth: vi.fn(), + checkOperationRate: vi.fn(), + gate: vi.fn(), + execute: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreauth + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, })) -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, -})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) -vi.mock('@/lib/logs/execution/trace-store', () => ({ - materializeExecutionData: mockMaterializeExecutionData, +vi.mock('@/lib/logs/application/get-public-log', () => ({ + getPublicLog: { operation: { id: 'logs.read_detail' }, execute: mocks.execute }, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET } from '@/app/api/v2/logs/[runId]/route' -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -const LOG_ROW = { +const log = { + executionId: 'run-1', workflowId: 'workflow-1', - workspaceId: 'workspace-1', - executionId: 'execution-1', deploymentVersionId: 'deployment-1', status: 'completed', level: 'info', trigger: 'api', - startedAt: new Date('2024-01-01T00:00:00Z'), - endedAt: new Date('2024-01-01T00:00:01Z'), + startedAt: new Date('2026-08-06T00:00:00Z'), + endedAt: new Date('2026-08-06T00:00:01Z'), totalDurationMs: 1000, - executionData: { stored: true }, - costTotal: '0.01', files: null, - createdAt: new Date('2024-01-01T00:00:00Z'), - workflowState: { blocks: {}, edges: [] }, workflowName: 'Support Agent', - workflowDescription: 'Handles support requests', - workflowFolderId: null, - workflowUserId: 'user-1', - workflowOwnerEmail: 'ada@example.com', + workflowDescription: null, + workflowOwnerEmail: 'owner@example.com', workflowWorkspaceId: 'workspace-1', - workflowCreatedAt: new Date('2023-12-01T00:00:00Z'), - workflowUpdatedAt: new Date('2023-12-02T00:00:00Z'), + workflowCreatedAt: new Date('2026-01-01T00:00:00Z'), + workflowUpdatedAt: new Date('2026-01-02T00:00:00Z'), workflowArchivedAt: null, -} - -const routeContext = () => ({ params: Promise.resolve({ runId: 'execution-1' }) }) - -function callGet() { - return GET(new NextRequest('http://localhost:3000/api/v2/logs/execution-1'), routeContext()) + workflowState: { blocks: {}, edges: [] }, + costTotal: '0.01', + createdAt: new Date('2026-08-06T00:00:00Z'), } describe('GET /api/v2/logs/[runId]', () => { beforeEach(() => { vi.clearAllMocks() - resetDbChainMock() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockLoadActiveFolderPathIndex.mockResolvedValue({ pathById: new Map() }) - dbChainMockFns.limit.mockResolvedValue([LOG_ROW]) - }) - - it('uses runId as the sole public identity and includes diagnostic data', async () => { - const traceSpans = [ - { - id: 'span-1', - name: 'Agent', - type: 'agent', - durationMs: 1000, - status: 'success', - output: { answer: 'done' }, - }, - ] - mockMaterializeExecutionData.mockResolvedValue({ - traceSpans, - finalOutput: { answer: 'done' }, + mocks.authenticate.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkPreauth.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-06T01:00:00Z'), + }) + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-06T01:00:00Z'), }) + mocks.execute.mockResolvedValue({ + log, + workflowFolderPath: '/agents', + executionData: { traceSpans: [], finalOutput: { ok: true } }, + }) + }) - const response = await callGet() + it('uses runId as the sole asserted identity', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/logs/run-1') + const response = await GET(request, { params: Promise.resolve({ runId: 'run-1' }) }) const body = await response.json() expect(response.status).toBe(200) - expect(body.data.runId).toBe('execution-1') - expect(body.data).not.toHaveProperty('id') + expect(body.data).toMatchObject({ + runId: 'run-1', + workflow: { folderPath: '/agents', ownerEmail: 'owner@example.com' }, + finalOutput: { ok: true }, + }) expect(body.data).not.toHaveProperty('executionData') - expect(body.data.traceSpans).toEqual(traceSpans) - expect(body.data.finalOutput).toEqual({ answer: 'done' }) - expect(body.data.workflowState).toEqual({ blocks: {}, edges: [] }) - expect(body.data.workflow.ownerEmail).toBe('ada@example.com') - expect(body.data.workflow).not.toHaveProperty('userId') + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { runId: 'run-1' }, + request, + }) + }) + + it('conceals canonical workspace authorization as log not-found', async () => { + mocks.execute.mockRejectedValueOnce( + new OrchestrationError('forbidden', 'Workspace API key cannot perform this operation') + ) + + const response = await GET(new NextRequest('http://localhost:3000/api/v2/logs/run-1'), { + params: Promise.resolve({ runId: 'run-1' }), + }) + + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ + error: { code: 'NOT_FOUND', message: 'Log not found' }, + }) }) - it('returns empty diagnostic collections when the execution produced none', async () => { - mockMaterializeExecutionData.mockResolvedValue({}) + it('hides unexpected materialization errors', async () => { + mocks.execute.mockRejectedValueOnce(new Error('storage key details')) - const body = await (await callGet()).json() + const response = await GET(new NextRequest('http://localhost:3000/api/v2/logs/run-1'), { + params: Promise.resolve({ runId: 'run-1' }), + }) - expect(body.data.traceSpans).toEqual([]) - expect(body.data.finalOutput).toBeNull() + expect(response.status).toBe(500) + expect(await response.json()).toMatchObject({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) }) }) diff --git a/apps/sim/app/api/v2/logs/[runId]/route.ts b/apps/sim/app/api/v2/logs/[runId]/route.ts index 811637abd8f..1902a4b44b0 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.ts @@ -1,41 +1,25 @@ import { traceSpansSchema } from '@/lib/api/contracts/logs' import { type V2LogDetail, v2GetLogContract, v2LogStatusSchema } from '@/lib/api/contracts/v2/logs' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { materializeExecutionData } from '@/lib/logs/execution/trace-store' -import { getPublicWorkflowLog } from '@/lib/logs/public-queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2Data, v2Error } from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2LogErrorPolicies } from '@/lib/logs/api/route-policies' +import { getPublicLog } from '@/lib/logs/application/get-public-log' +import { logOperations } from '@/lib/logs/application/operations' export const revalidate = 0 /** * Returns the diagnostic representation of a run. The run ID is the sole - * public identity; the workflow-execution-log row key remains an internal - * storage and pagination detail. + * public identity; canonical workflow and workspace scope come from the run. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetLogContract, - rateLimitEndpoint: 'logs-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { runId } = input.params - - const log = await getPublicWorkflowLog({ column: 'executionId', value: runId }) - - if (!log) return v2Error('NOT_FOUND', 'Log not found') - - const access = await resolveWorkspaceAccess(rateLimit, userId, log.workspaceId) - if (access) return v2Error('NOT_FOUND', 'Log not found') - - const folderIndex = await loadActiveFolderPathIndex(log.workspaceId, 'workflow') - const executionData = await materializeExecutionData( - log.executionData as Record<string, unknown> | null, - { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } - ) - if (log.workflowUserId && !log.workflowOwnerEmail) { - throw new Error(`Unable to resolve workflow owner email for ${log.workflowUserId}`) - } - + auth: v2ApiKeyAuth, + operation: logOperations.readDetail, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2LogErrorPolicies.concealDetailAuthorization, + mapInput: ({ params }) => ({ runId: params.runId }), + useCase: getPublicLog, + present: ({ log, workflowFolderPath, executionData }) => { const detail: V2LogDetail = { runId: log.executionId, workflowId: log.workflowId, @@ -51,9 +35,7 @@ export const GET = withPublicApiRouteHandler({ id: log.workflowId, name: log.workflowName || 'Deleted Workflow', description: log.workflowDescription, - folderPath: log.workflowFolderId - ? (folderIndex.pathById.get(log.workflowFolderId) ?? null) - : null, + folderPath: workflowFolderPath, ownerEmail: log.workflowOwnerEmail, workspaceId: log.workflowWorkspaceId, createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null, @@ -66,7 +48,6 @@ export const GET = withPublicApiRouteHandler({ cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, createdAt: log.createdAt.toISOString(), } - - return v2Data(detail, { rateLimit }) + return { data: detail } }, }) diff --git a/apps/sim/app/api/v2/logs/route.test.ts b/apps/sim/app/api/v2/logs/route.test.ts index 14bd9c03d4c..fb7c5013984 100644 --- a/apps/sim/app/api/v2/logs/route.test.ts +++ b/apps/sim/app/api/v2/logs/route.test.ts @@ -4,107 +4,135 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockListPublicWorkflowLogs, - mockMaterializeExecutionData, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockListPublicWorkflowLogs: vi.fn(), - mockMaterializeExecutionData: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreauth: vi.fn(), + checkOperationRate: vi.fn(), + gate: vi.fn(), + execute: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreauth + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, })) -vi.mock('@/lib/logs/public-queries', () => ({ - decodePublicLogCursor: vi.fn(), - listPublicWorkflowLogs: mockListPublicWorkflowLogs, -})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) -vi.mock('@/lib/logs/execution/trace-store', () => ({ - materializeExecutionData: mockMaterializeExecutionData, +vi.mock('@/lib/logs/application/list-public-logs', () => ({ + listPublicLogs: { operation: { id: 'logs.list' }, execute: mocks.execute }, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET } from '@/app/api/v2/logs/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2026-08-06T01:00:00.000Z'), +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } -const LOG_ROW = { - executionId: 'execution-1', +const log = { + executionId: 'run-1', workflowId: 'workflow-1', workspaceId: WORKSPACE_ID, deploymentVersionId: null, status: 'completed', level: 'info', trigger: 'api', - startedAt: new Date('2026-08-06T00:00:00.000Z'), - endedAt: new Date('2026-08-06T00:00:01.000Z'), + startedAt: new Date('2026-08-06T00:00:00Z'), + endedAt: new Date('2026-08-06T00:00:01Z'), totalDurationMs: 1000, costTotal: null, files: null, - executionData: { stored: true }, workflowName: 'Support Agent', workflowDescription: null, workflowArchivedAt: null, } -function callLogs(query: string) { - return GET( - new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&${query}`) - ) -} - -describe('GET /api/v2/logs materialized fields', () => { +describe('GET /api/v2/logs', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockListPublicWorkflowLogs.mockResolvedValue({ data: [LOG_ROW], nextCursor: null }) + mocks.authenticate.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkPreauth.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-06T01:00:00Z'), + }) + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-06T01:00:00Z'), + }) + mocks.execute.mockResolvedValue({ + items: [{ log, executionData: { finalOutput: false, traceSpans: [] } }], + nextCursor: null, + includeFullDetails: true, + includeFinalOutput: true, + includeTraceSpans: true, + }) }) - it.each([false, 0, ''])('preserves a requested falsy final output: %j', async (finalOutput) => { - mockMaterializeExecutionData.mockResolvedValue({ finalOutput }) - - const response = await callLogs('includeFinalOutput=true') + it('maps filters into the application operation and preserves diagnostic fields', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&includeFinalOutput=true&includeTraceSpans=true` + ) + const response = await GET(request) const body = await response.json() expect(response.status).toBe(200) - expect(body.data[0].runId).toBe('execution-1') - expect(body.data[0].finalOutput).toBe(finalOutput) - expect(body.data[0].workflow).toMatchObject({ name: 'Support Agent' }) - expect(mockListPublicWorkflowLogs).toHaveBeenCalledWith( - expect.objectContaining({ includeExecutionData: true }) - ) + expect(body.data[0]).toMatchObject({ + runId: 'run-1', + workflow: { name: 'Support Agent' }, + finalOutput: false, + traceSpans: [], + }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: expect.objectContaining({ + workspaceId: WORKSPACE_ID, + includeFinalOutput: true, + includeTraceSpans: true, + }), + request, + }) }) - it('makes includeTraceSpans imply full detail', async () => { - const traceSpans = [{ id: 'span-1', name: 'Agent', type: 'agent' }] - mockMaterializeExecutionData.mockResolvedValue({ traceSpans }) + it('rejects malformed cursors after admission and before protected reads', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&cursor=not-a-cursor` + ) + ) - const response = await callLogs('includeTraceSpans=true') - const body = await response.json() + expect(response.status).toBe(400) + expect(mocks.authenticate).toHaveBeenCalled() + expect(mocks.execute).not.toHaveBeenCalled() + }) - expect(response.status).toBe(200) - expect(body.data[0].traceSpans).toEqual(traceSpans) - expect(body.data[0].workflow).toMatchObject({ name: 'Support Agent' }) - expect(mockListPublicWorkflowLogs).toHaveBeenCalledWith( - expect.objectContaining({ includeExecutionData: true }) + it('projects typed folder errors', async () => { + mocks.execute.mockRejectedValueOnce(new OrchestrationError('not_found', 'Folder not found')) + + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}`) ) + + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ error: { code: 'NOT_FOUND' } }) }) }) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index 5e65d643894..ea37bd87e30 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -4,75 +4,57 @@ import { v2ListLogsContract, v2LogStatusSchema, } from '@/lib/api/contracts/v2/logs' -import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { materializeExecutionData } from '@/lib/logs/execution/trace-store' -import { decodePublicLogCursor, listPublicWorkflowLogs } from '@/lib/logs/public-queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { resolveFolderPathId } from '@/app/api/v2/lib/folders' -import { v2CursorList, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' +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' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListLogsContract, - rateLimitEndpoint: 'logs', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const params = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const folderPaths = params.folderPaths?.split(',').filter(Boolean) - const folderIndex = folderPaths - ? await loadActiveFolderPathIndex(params.workspaceId, 'workflow') + auth: v2ApiKeyAuth, + operation: logOperations.list, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2LogErrorPolicies.default, + mapInput: ({ query }) => { + const decodedCursor = query.cursor + ? decodePublicLogCursor(query.cursor, query.order ?? 'desc') : null - const resolvedFolderIds = folderPaths?.map((path) => resolveFolderPathId(folderIndex!, path)) - if (resolvedFolderIds?.some((folderId) => folderId === undefined)) { - return v2Error('NOT_FOUND', 'Folder not found') + if (query.cursor && !decodedCursor) { + throw new OrchestrationError('validation', 'Invalid cursor') } - const nonRootFolderIds = resolvedFolderIds?.filter( - (folderId): folderId is string => typeof folderId === 'string' - ) - const includesRoot = resolvedFolderIds?.includes(null) ?? false - - const decodedCursor = params.cursor - ? decodePublicLogCursor(params.cursor, params.order ?? 'desc') - : null - if (params.cursor && !decodedCursor) return v2Error('BAD_REQUEST', 'Invalid cursor') - const cursor = decodedCursor ?? undefined - const includeFullDetails = - params.details === 'full' || params.includeFinalOutput || params.includeTraceSpans - - const filters = { - workspaceId: params.workspaceId, - workflowIds: params.workflowIds?.split(',').filter(Boolean), - folderIds: nonRootFolderIds, - triggers: params.triggers?.split(',').filter(Boolean), - level: params.level, - startDate: params.startDate ? new Date(params.startDate) : undefined, - endDate: params.endDate ? new Date(params.endDate) : undefined, - executionId: params.runId, - minDurationMs: params.minDurationMs, - maxDurationMs: params.maxDurationMs, - minCost: params.minCost, - maxCost: params.maxCost, - model: params.model, - cursor, - order: params.order, + return { + workspaceId: query.workspaceId, + filters: { + workflowIds: query.workflowIds?.split(',').filter(Boolean), + triggers: query.triggers?.split(',').filter(Boolean), + level: query.level, + startDate: query.startDate ? new Date(query.startDate) : undefined, + endDate: query.endDate ? new Date(query.endDate) : undefined, + executionId: query.runId, + minDurationMs: query.minDurationMs, + maxDurationMs: query.maxDurationMs, + minCost: query.minCost, + maxCost: query.maxCost, + model: query.model, + cursor: decodedCursor ?? undefined, + order: query.order, + }, + folderPaths: query.folderPaths?.split(',').filter(Boolean), + limit: query.limit, + includeFullDetails: + query.details === 'full' || query.includeFinalOutput || query.includeTraceSpans, + includeFinalOutput: query.includeFinalOutput, + includeTraceSpans: query.includeTraceSpans, } - - const { data, nextCursor } = await listPublicWorkflowLogs({ - filters, - limit: params.limit, - includeExecutionData: includeFullDetails, - folderScope: folderPaths ? { includesRoot, folderIds: nonRootFolderIds ?? [] } : undefined, - }) - - type LogRow = (typeof data)[number] - const buildItem = (log: LogRow): V2LogListItem => { + }, + useCase: listPublicLogs, + present: ({ items, nextCursor, includeFullDetails, includeFinalOutput, includeTraceSpans }) => ({ + data: items.map(({ log, executionData }): V2LogListItem => { const item: V2LogListItem = { runId: log.executionId, workflowId: log.workflowId, @@ -94,34 +76,16 @@ export const GET = withPublicApiRouteHandler({ deleted: !log.workflowName || log.workflowArchivedAt !== null, } } + if (executionData) { + if (includeFinalOutput && executionData.finalOutput !== undefined) { + item.finalOutput = executionData.finalOutput + } + if (includeTraceSpans) { + item.traceSpans = traceSpansSchema.parse(executionData.traceSpans ?? []) + } + } return item - } - - const needsMaterialize = params.includeFinalOutput || params.includeTraceSpans - - const formattedLogs = needsMaterialize - ? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => { - const item = buildItem(log) - if (log.executionData) { - const execData = (await materializeExecutionData( - log.executionData as Record<string, unknown> | null, - { - workspaceId: log.workspaceId, - workflowId: log.workflowId, - executionId: log.executionId, - } - )) as Record<string, unknown> - if (params.includeFinalOutput && execData.finalOutput !== undefined) { - item.finalOutput = execData.finalOutput - } - if (params.includeTraceSpans) { - item.traceSpans = traceSpansSchema.parse(execData.traceSpans ?? []) - } - } - return item - }) - : data.map(buildItem) - - return v2CursorList(formattedLogs, nextCursor, { rateLimit }) - }, + }), + nextCursor, + }), }) 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 ba109669a1a..b07898ffdb1 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts @@ -2,49 +2,47 @@ import { v2ListWorkspaceMembersContract, v2WorkspaceMemberCursorSchema, } from '@/lib/api/contracts/v2/workspaces' -import { queryPublicWorkspaceMembers } from '@/lib/workspaces/public-queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { - decodeCursor, - encodeCursor, - v2CursorList, - v2Error, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +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' /** GET /api/v2/workspaces/[workspaceId]/members — Effective member roster. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListWorkspaceMembersContract, - rateLimitEndpoint: 'workspace-members', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId } = input.params - const { cursor, limit } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const decoded = cursor - ? v2WorkspaceMemberCursorSchema.safeParse(decodeCursor(cursor)) + auth: v2ApiKeyAuth, + operation: workspaceOperations.listPublicMembers, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => { + const decoded = query.cursor + ? v2WorkspaceMemberCursorSchema.safeParse(decodeCursor(query.cursor)) : undefined - if (decoded && !decoded.success) return v2Error('BAD_REQUEST', 'Invalid cursor') - - const page = await queryPublicWorkspaceMembers(workspaceId, { - limit, + if (decoded && !decoded.success) { + throw new OrchestrationError('validation', 'Invalid cursor') + } + return { + workspaceId: params.workspaceId, + limit: query.limit, afterEmail: decoded?.data.email, - }) - if (!page) return v2Error('NOT_FOUND', 'Workspace not found') - - return v2CursorList( - page.members.map((member) => ({ - email: member.email, - name: member.name, - image: member.image, - role: member.role, - isExternal: member.isExternal, - joinedAt: member.joinedAt.toISOString(), - })), - page.nextEmail ? encodeCursor({ email: page.nextEmail }) : null, - { rateLimit } - ) + } }, + useCase: listPublicWorkspaceMembers, + present: ({ page }) => ({ + data: page.members.map((member) => ({ + email: member.email, + name: member.name, + image: member.image, + role: member.role, + isExternal: member.isExternal, + joinedAt: member.joinedAt.toISOString(), + })), + nextCursor: page.nextEmail ? encodeCursor({ email: page.nextEmail }) : null, + }), }) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts index 54272638421..ecfdd075bde 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts @@ -1,28 +1,27 @@ import { v2GetWorkspaceContract } from '@/lib/api/contracts/v2/workspaces' -import { getPublicWorkspaceDetail } from '@/lib/workspaces/public-queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { getPublicWorkspace } from '@/lib/workspaces/application/get-public-workspace' +import { workspaceOperations } from '@/lib/workspaces/application/operations' /** GET /api/v2/workspaces/[workspaceId] — Public workspace metadata. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetWorkspaceContract, - rateLimitEndpoint: 'workspaces', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId } = input.params - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const workspace = await getPublicWorkspaceDetail(workspaceId) - if (!workspace) return v2Error('NOT_FOUND', 'Workspace not found') - - return v2Data( - { - ...workspace, - createdAt: workspace.createdAt.toISOString(), - updatedAt: workspace.updatedAt.toISOString(), - }, - { rateLimit } - ) - }, + auth: v2ApiKeyAuth, + operation: workspaceOperations.readPublicDetail, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.workspaceId }), + useCase: getPublicWorkspace, + present: ({ workspace }) => ({ + data: { + ...workspace, + createdAt: workspace.createdAt.toISOString(), + updatedAt: workspace.updatedAt.toISOString(), + }, + }), }) diff --git a/apps/sim/app/api/v2/workspaces/route.test.ts b/apps/sim/app/api/v2/workspaces/route.test.ts index 84ffe4c78a4..bde0ffa3827 100644 --- a/apps/sim/app/api/v2/workspaces/route.test.ts +++ b/apps/sim/app/api/v2/workspaces/route.test.ts @@ -4,151 +4,165 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetPublicWorkspaceDetail, - mockQueryPublicWorkspaceMembers, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetPublicWorkspaceDetail: vi.fn(), - mockQueryPublicWorkspaceMembers: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreauth: vi.fn(), + checkOperationRate: vi.fn(), + gate: vi.fn(), + getWorkspace: vi.fn(), + listMembers: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreauth + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, })) -vi.mock('@/lib/workspaces/public-queries', () => ({ - getPublicWorkspaceDetail: mockGetPublicWorkspaceDetail, - queryPublicWorkspaceMembers: mockQueryPublicWorkspaceMembers, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) + +vi.mock('@/lib/workspaces/application/get-public-workspace', () => ({ + getPublicWorkspace: { + operation: { id: 'workspaces.read_public_detail' }, + execute: mocks.getWorkspace, + }, +})) + +vi.mock('@/lib/workspaces/application/list-public-workspace-members', () => ({ + listPublicWorkspaceMembers: { + operation: { id: 'workspaces.members.list_public' }, + execute: mocks.listMembers, + }, })) -import { GET as listWorkspaceMembers } from '@/app/api/v2/workspaces/[workspaceId]/members/route' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET as listMembers } from '@/app/api/v2/workspaces/[workspaceId]/members/route' import { GET as getWorkspace } from '@/app/api/v2/workspaces/[workspaceId]/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - workspaceId: WORKSPACE_ID, - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2026-08-06T01:00:00.000Z'), +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } const context = () => ({ params: Promise.resolve({ workspaceId: WORKSPACE_ID }) }) -function callWorkspace() { - return getWorkspace( - new NextRequest(`http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}`), - context() - ) -} - -function callMembers(query = '') { - return listWorkspaceMembers( - new NextRequest(`http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}/members${query}`), - context() - ) -} - -beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetPublicWorkspaceDetail.mockResolvedValue({ - id: WORKSPACE_ID, - name: 'Engineering', - color: '#33C482', - logoUrl: null, - mode: 'organization', - memberCount: 2, - createdAt: new Date('2026-01-01T00:00:00.000Z'), - updatedAt: new Date('2026-01-02T00:00:00.000Z'), - }) - mockQueryPublicWorkspaceMembers.mockResolvedValue({ - members: [ - { - userId: 'user-1', - email: 'ada@example.com', - name: 'Ada', - image: null, - role: 'admin', - isExternal: false, - joinedAt: new Date('2026-01-01T00:00:00.000Z'), +describe('v2 workspace routes', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkPreauth.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-06T01:00:00Z'), + }) + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-06T01:00:00Z'), + }) + mocks.getWorkspace.mockResolvedValue({ + workspace: { + id: WORKSPACE_ID, + name: 'Engineering', + color: '#33C482', + logoUrl: null, + mode: 'organization', + memberCount: 1, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + }, + }) + mocks.listMembers.mockResolvedValue({ + page: { + members: [ + { + userId: 'user-1', + email: 'ada@example.com', + name: 'Ada', + image: null, + role: 'admin', + isExternal: false, + joinedAt: new Date('2026-01-01T00:00:00Z'), + }, + ], + nextEmail: 'ada@example.com', }, - ], - nextEmail: 'ada@example.com', + }) }) -}) -describe('GET /api/v2/workspaces/[workspaceId]', () => { - it('returns public metadata without governance or billing identities', async () => { - const response = await callWorkspace() + it('projects public workspace metadata without governance identities', async () => { + const request = new NextRequest(`http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}`) + const response = await getWorkspace(request, context()) const body = await response.json() expect(response.status).toBe(200) - expect(body.data).toEqual({ - id: WORKSPACE_ID, - name: 'Engineering', - color: '#33C482', - logoUrl: null, - mode: 'organization', - memberCount: 2, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-02T00:00:00.000Z', - }) + expect(body.data).toMatchObject({ id: WORKSPACE_ID, name: 'Engineering' }) expect(body.data).not.toHaveProperty('ownerId') expect(body.data).not.toHaveProperty('billedAccountUserId') - }) - - it('enforces workspace read access before loading metadata', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + expect(mocks.getWorkspace).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID }, + request, }) - - const response = await callWorkspace() - - expect(response.status).toBe(403) - expect(mockGetPublicWorkspaceDetail).not.toHaveBeenCalled() }) -}) -describe('GET /api/v2/workspaces/[workspaceId]/members', () => { - it('returns email-attributed members and keeps user IDs out of data and cursors', async () => { - const response = await callMembers('?limit=1') + it('keeps member user IDs out of data and cursors', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}/members?limit=1` + ) + const response = await listMembers(request, context()) const body = await response.json() expect(response.status).toBe(200) - expect(body.data).toEqual([ - { - email: 'ada@example.com', - name: 'Ada', - image: null, - role: 'admin', - isExternal: false, - joinedAt: '2026-01-01T00:00:00.000Z', - }, - ]) - expect(body.data[0]).not.toHaveProperty('userId') + expect(body.data[0]).toEqual({ + email: 'ada@example.com', + name: 'Ada', + image: null, + role: 'admin', + isExternal: false, + joinedAt: '2026-01-01T00:00:00.000Z', + }) expect(JSON.parse(Buffer.from(body.nextCursor, 'base64').toString())).toEqual({ email: 'ada@example.com', }) }) - it('rejects malformed cursors without querying members', async () => { - const response = await callMembers('?cursor=not-a-cursor') + it('rejects malformed cursors before the application read', async () => { + const response = await listMembers( + new NextRequest( + `http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}/members?cursor=not-a-cursor` + ), + context() + ) expect(response.status).toBe(400) - expect(mockQueryPublicWorkspaceMembers).not.toHaveBeenCalled() + expect(mocks.listMembers).not.toHaveBeenCalled() + }) + + it('projects typed workspace policy errors', async () => { + mocks.getWorkspace.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Access denied')) + + const response = await getWorkspace( + new NextRequest(`http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}`), + context() + ) + + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ error: { code: 'FORBIDDEN' } }) }) }) diff --git a/apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts b/apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts new file mode 100644 index 00000000000..7ebbc8676df --- /dev/null +++ b/apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveAccess: vi.fn(), + getOrgWorkspaceIds: vi.fn(), + buildOrgScopeCondition: vi.fn(), + buildFilterConditions: vi.fn(), + queryAuditLogs: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/app/api/v1/audit-logs/auth', () => ({ + resolveEnterpriseAuditAccess: mocks.resolveAccess, +})) + +vi.mock('@/app/api/v1/audit-logs/query', () => ({ + getOrgWorkspaceIds: mocks.getOrgWorkspaceIds, + buildOrgScopeCondition: mocks.buildOrgScopeCondition, + buildFilterConditions: mocks.buildFilterConditions, + queryAuditLogs: mocks.queryAuditLogs, +})) + +vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) + +import { getAuditLog } from '@/lib/audit-logs/application/get-audit-log' +import { listAuditLogs } from '@/lib/audit-logs/application/list-audit-logs' + +const sessionPrincipal: SessionPrincipal = { + kind: 'session', + userId: 'admin-1', + sessionId: 'session-1', +} +const workspacePrincipal: WorkspaceApiKeyPrincipal = { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', +} +const listInput = { + organizationId: 'organization-1', + includeDeparted: false, + filters: {}, + limit: 50, +} + +describe('audit-log application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.resolveAccess.mockResolvedValue({ + success: true, + context: { organizationId: 'organization-1', orgMemberIds: ['admin-1'] }, + }) + mocks.getOrgWorkspaceIds.mockResolvedValue(['workspace-1']) + mocks.buildOrgScopeCondition.mockReturnValue({ type: 'scope' }) + mocks.buildFilterConditions.mockReturnValue([]) + mocks.queryAuditLogs.mockResolvedValue({ data: [], nextCursor: undefined }) + }) + + it('rejects workspace keys before organization membership is loaded', async () => { + await expect( + listAuditLogs.execute({ principal: workspacePrincipal, input: listInput }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveAccess).not.toHaveBeenCalled() + expect(mocks.queryAuditLogs).not.toHaveBeenCalled() + }) + + it('authorizes the requested organization and scopes the query canonically', async () => { + await expect( + listAuditLogs.execute({ principal: sessionPrincipal, input: listInput }) + ).resolves.toEqual({ data: [], nextCursor: undefined }) + + expect(mocks.resolveAccess).toHaveBeenCalledWith('admin-1', 'organization-1') + expect(mocks.buildOrgScopeCondition).toHaveBeenCalledWith({ + organizationId: 'organization-1', + orgWorkspaceIds: ['workspace-1'], + orgMemberIds: ['admin-1'], + includeDeparted: false, + }) + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('rejects a workspace filter outside the authorized organization', async () => { + await expect( + listAuditLogs.execute({ + principal: sessionPrincipal, + input: { ...listInput, filters: { workspaceId: 'workspace-2' } }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.queryAuditLogs).not.toHaveBeenCalled() + }) + + it('returns a typed not-found only after applying organization scope', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + await expect( + getAuditLog.execute({ + principal: sessionPrincipal, + input: { organizationId: 'organization-1', id: 'audit-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.buildOrgScopeCondition).toHaveBeenCalled() + }) + + it('propagates organization-store failures', async () => { + const failure = new Error('database unavailable') + mocks.resolveAccess.mockRejectedValueOnce(failure) + + await expect( + listAuditLogs.execute({ principal: sessionPrincipal, input: listInput }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts b/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts new file mode 100644 index 00000000000..bc9e15f352f --- /dev/null +++ b/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts @@ -0,0 +1,59 @@ +import type { Principal } from '@sim/auth/principal' +import type { AuditLogOperation, AuditLogPrincipal } from '@/lib/audit-logs/application/operations' +import type { OperationUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' + +export interface AuthorizedAuditLogContext { + organizationId: string + orgMemberIds: string[] + actorUserId: string +} + +interface AuthorizedAuditLogDefinition<O extends AuditLogOperation, I, R> { + operation: O + organizationId(input: I): string + execute(args: { + principal: AuditLogPrincipal + input: I + context: AuthorizedAuditLogContext + }): Promise<R> +} + +function requireAuditLogPrincipal( + principal: Principal, + operation: AuditLogOperation +): asserts principal is AuditLogPrincipal { + if (!operation.principalKinds.some((kind) => kind === principal.kind)) { + throw new OrchestrationError( + 'forbidden', + `Principal kind ${principal.kind} cannot perform operation ${operation.id}` + ) + } +} + +function auditActorUserId(principal: AuditLogPrincipal): string { + return principal.userId +} + +export function defineAuthorizedAuditLogUseCase<const O extends AuditLogOperation, I, R>( + definition: AuthorizedAuditLogDefinition<O, I, R> +): OperationUseCase<O, I, R> { + return { + operation: definition.operation, + async execute({ principal, input }) { + requireAuditLogPrincipal(principal, definition.operation) + const actorUserId = auditActorUserId(principal) + const access = await resolveEnterpriseAuditAccess( + actorUserId, + definition.organizationId(input) + ) + if (!access.success) throw new OrchestrationError('forbidden', access.message) + return definition.execute({ + principal, + input, + context: { ...access.context, actorUserId }, + }) + }, + } +} diff --git a/apps/sim/lib/audit-logs/application/get-audit-log.ts b/apps/sim/lib/audit-logs/application/get-audit-log.ts new file mode 100644 index 00000000000..5cc262a44b3 --- /dev/null +++ b/apps/sim/lib/audit-logs/application/get-audit-log.ts @@ -0,0 +1,37 @@ +import { db } from '@sim/db' +import { auditLog } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { defineAuthorizedAuditLogUseCase } from '@/lib/audit-logs/application/authorized-audit-log-use-case' +import { auditLogOperations } from '@/lib/audit-logs/application/operations' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' + +export interface GetAuditLogInput { + organizationId: string + id: string +} + +export interface GetAuditLogResult { + log: typeof auditLog.$inferSelect +} + +export const getAuditLog = defineAuthorizedAuditLogUseCase({ + operation: auditLogOperations.readDetail, + organizationId: (input: GetAuditLogInput) => input.organizationId, + execute: async ({ input, context }): Promise<GetAuditLogResult> => { + const orgWorkspaceIds = await getOrgWorkspaceIds(context.organizationId) + const scopeCondition = buildOrgScopeCondition({ + organizationId: context.organizationId, + orgWorkspaceIds, + orgMemberIds: context.orgMemberIds, + includeDeparted: true, + }) + const [log] = await db + .select() + .from(auditLog) + .where(and(eq(auditLog.id, input.id), scopeCondition)) + .limit(1) + if (!log) throw new OrchestrationError('not_found', 'Audit log not found') + return { log } + }, +}) diff --git a/apps/sim/lib/audit-logs/application/list-audit-logs.ts b/apps/sim/lib/audit-logs/application/list-audit-logs.ts new file mode 100644 index 00000000000..54da626fef4 --- /dev/null +++ b/apps/sim/lib/audit-logs/application/list-audit-logs.ts @@ -0,0 +1,39 @@ +import { defineAuthorizedAuditLogUseCase } from '@/lib/audit-logs/application/authorized-audit-log-use-case' +import { auditLogOperations } from '@/lib/audit-logs/application/operations' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { AuditLogFilterParams } from '@/app/api/v1/audit-logs/query' +import { + buildFilterConditions, + buildOrgScopeCondition, + getOrgWorkspaceIds, + queryAuditLogs, +} from '@/app/api/v1/audit-logs/query' + +export interface ListAuditLogsInput { + organizationId: string + includeDeparted: boolean + filters: AuditLogFilterParams + limit: number + cursor?: string +} + +export type ListAuditLogsResult = Awaited<ReturnType<typeof queryAuditLogs>> + +export const listAuditLogs = defineAuthorizedAuditLogUseCase({ + operation: auditLogOperations.list, + organizationId: (input: ListAuditLogsInput) => input.organizationId, + execute: async ({ input, context }): Promise<ListAuditLogsResult> => { + const orgWorkspaceIds = await getOrgWorkspaceIds(context.organizationId) + if (input.filters.workspaceId && !orgWorkspaceIds.includes(input.filters.workspaceId)) { + throw new OrchestrationError('validation', 'workspaceId does not belong to your organization') + } + const scopeCondition = buildOrgScopeCondition({ + organizationId: context.organizationId, + orgWorkspaceIds, + orgMemberIds: context.orgMemberIds, + includeDeparted: input.includeDeparted, + }) + const filterConditions = buildFilterConditions(input.filters) + return queryAuditLogs([scopeCondition, ...filterConditions], input.limit, input.cursor) + }, +}) diff --git a/apps/sim/lib/audit-logs/application/operations.ts b/apps/sim/lib/audit-logs/application/operations.ts new file mode 100644 index 00000000000..3621f6732d1 --- /dev/null +++ b/apps/sim/lib/audit-logs/application/operations.ts @@ -0,0 +1,39 @@ +import type { Principal } from '@sim/auth/principal' +import type { ApplicationOperation } from '@/lib/core/application' + +export type AuditLogPrincipal = Extract<Principal, { kind: 'session' | 'personal_api_key' }> + +export interface AuditLogOperation<Id extends string = string> extends ApplicationOperation<Id> { + readonly authority: 'organization_admin' + readonly organizationRoles: readonly ['admin', 'owner'] + readonly workspaceApiKey: 'deny' + readonly principalKinds: readonly ['session', 'personal_api_key'] +} + +function defineAuditLogOperation<const Id extends string>( + operation: AuditLogOperation<Id> +): AuditLogOperation<Id> { + if ((operation.principalKinds as readonly string[]).includes('workspace_api_key')) { + throw new Error(`Organization-admin operation ${operation.id} cannot allow workspace API keys`) + } + Object.freeze(operation.organizationRoles) + Object.freeze(operation.principalKinds) + return Object.freeze(operation) +} + +export const auditLogOperations = { + list: defineAuditLogOperation({ + id: 'audit_logs.list', + authority: 'organization_admin', + organizationRoles: ['admin', 'owner'], + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key'], + }), + readDetail: defineAuditLogOperation({ + id: 'audit_logs.read_detail', + authority: 'organization_admin', + organizationRoles: ['admin', 'owner'], + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key'], + }), +} 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 new file mode 100644 index 00000000000..2377044a181 --- /dev/null +++ b/apps/sim/lib/billing/application/authorized-billing-read-use-case.ts @@ -0,0 +1,98 @@ +import type { Principal } from '@sim/auth/principal' +import { + permissionSatisfies, + resolveEffectiveWorkspacePermission, +} from '@sim/platform-authz/workspace' +import type { + BillingReadOperation, + BillingReadPrincipal, +} from '@/lib/billing/application/operations' +import type { OperationUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' + +export type BillingReadScope = + | { kind: 'account'; userId: string } + | { kind: 'workspace'; workspace: ActiveWorkspaceApplicationContext } + +interface AuthorizedBillingReadContext<O extends BillingReadOperation, I> { + principal: BillingReadPrincipal + operation: O + input: I + scope: BillingReadScope +} + +interface AuthorizedBillingReadDefinition<O extends BillingReadOperation, I, R> { + operation: O + requestedWorkspaceId(input: I): string | undefined + execute(args: AuthorizedBillingReadContext<O, I>): Promise<R> +} + +function requireBillingReadPrincipal( + principal: Principal, + 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}` + ) + } +} + +async function resolveBillingReadScope( + principal: BillingReadPrincipal, + operation: BillingReadOperation, + requestedWorkspaceId: string | undefined +): Promise<BillingReadScope> { + if (principal.kind === 'workspace_api_key') { + if (requestedWorkspaceId && requestedWorkspaceId !== principal.workspaceId) { + throw new OrchestrationError('forbidden', 'API key is not authorized for this workspace') + } + } else if (!requestedWorkspaceId) { + return { kind: 'account', userId: principal.userId } + } + + const workspaceId = + principal.kind === 'workspace_api_key' ? principal.workspaceId : requestedWorkspaceId + if (!workspaceId) throw new Error(`Billing operation ${operation.id} lost its workspace scope`) + + const workspace = await loadActiveWorkspaceApplicationContext(workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + + if (principal.kind === 'personal_api_key') { + if (!workspace.allowPersonalApiKeys) { + throw new OrchestrationError('forbidden', 'Personal API keys are disabled for this workspace') + } + const permission = await resolveEffectiveWorkspacePermission( + principal.userId, + workspace.workspaceId, + workspace.workspaceOrganizationId + ) + if (!permissionSatisfies(permission, operation.workspaceMinimumRole)) { + throw new OrchestrationError('forbidden', 'Access denied') + } + } + + return { kind: 'workspace', workspace } +} + +export function defineAuthorizedBillingReadUseCase<const O extends BillingReadOperation, I, R>( + definition: AuthorizedBillingReadDefinition<O, I, R> +): OperationUseCase<O, I, R> { + return { + operation: definition.operation, + async execute({ principal, input }) { + requireBillingReadPrincipal(principal, definition.operation) + const scope = await resolveBillingReadScope( + principal, + definition.operation, + definition.requestedWorkspaceId(input) + ) + return definition.execute({ principal, operation: definition.operation, input, scope }) + }, + } +} diff --git a/apps/sim/lib/billing/application/billing-use-cases.test.ts b/apps/sim/lib/billing/application/billing-use-cases.test.ts new file mode 100644 index 00000000000..415f736f269 --- /dev/null +++ b/apps/sim/lib/billing/application/billing-use-cases.test.ts @@ -0,0 +1,188 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + resolveSystemAttribution: vi.fn(), + resolveAttribution: vi.fn(), + checkUsageStatus: vi.fn(), + checkAttributedBlocks: vi.fn(), + toUsageLimitSubscription: vi.fn(), + getSubscription: vi.fn(), + deriveBillingContext: vi.fn(), + checkBillingBlocked: vi.fn(), + checkBillingEntityBlocked: vi.fn(), + getUsageLogs: vi.fn(), + getCredits: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveSystemBillingAttribution: mocks.resolveSystemAttribution, + resolveBillingAttribution: mocks.resolveAttribution, + checkAttributedBillingBlocks: mocks.checkAttributedBlocks, + toUsageLimitSubscription: mocks.toUsageLimitSubscription, +})) + +vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ + checkUsageStatus: mocks.checkUsageStatus, + checkBillingBlocked: mocks.checkBillingBlocked, + checkBillingEntityBlocked: mocks.checkBillingEntityBlocked, +})) + +vi.mock('@/lib/billing/core/subscription', () => ({ + getHighestPrioritySubscription: mocks.getSubscription, +})) + +vi.mock('@/lib/billing/core/usage-log', () => ({ + deriveBillingContext: mocks.deriveBillingContext, + getUserUsageLogs: mocks.getUsageLogs, + getUsageCreditsByLogId: mocks.getCredits, +})) + +vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) + +import { getBillingStatus } from '@/lib/billing/application/get-billing-status' +import { listBillingLogs } from '@/lib/billing/application/list-billing-logs' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'personal-key-1', +} +const workspacePrincipal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', +} + +describe('billing application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.checkUsageStatus.mockResolvedValue({ currentUsage: 1, limit: 10, isExceeded: false }) + mocks.checkAttributedBlocks.mockResolvedValue({ blocked: false }) + mocks.toUsageLimitSubscription.mockReturnValue(null) + mocks.resolveSystemAttribution.mockResolvedValue({ + billedAccountUserId: 'billing-owner-1', + billingPeriod: { start: '2026-01-01', end: '2026-02-01' }, + payerSubscription: null, + }) + mocks.resolveAttribution.mockResolvedValue({ + billedAccountUserId: 'billing-owner-1', + billingPeriod: { start: '2026-01-01', end: '2026-02-01' }, + payerSubscription: null, + }) + mocks.getUsageLogs.mockResolvedValue({ + logs: [], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: false }, + }) + mocks.getCredits.mockResolvedValue({}) + }) + + it('rejects unsupported principals before protected loading', async () => { + const session: SessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } + + await expect(getBillingStatus.execute({ principal: session, input: {} })).rejects.toMatchObject( + { + code: 'forbidden', + } + ) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.getSubscription).not.toHaveBeenCalled() + }) + + it('pins workspace keys before loading a different workspace', async () => { + await expect( + getBillingStatus.execute({ + principal: workspacePrincipal, + input: { workspaceId: 'workspace-2' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.resolveSystemAttribution).not.toHaveBeenCalled() + }) + + it('uses system billing attribution for workspace-key status without human authorization', async () => { + const result = await getBillingStatus.execute({ + principal: workspacePrincipal, + input: {}, + }) + + expect(result.workspaceId).toBe('workspace-1') + expect(mocks.resolveSystemAttribution).toHaveBeenCalledWith('workspace-1') + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.resolveAttribution).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('uses the personal principal as account authority', async () => { + mocks.getSubscription.mockResolvedValue({ plan: 'pro' }) + mocks.deriveBillingContext.mockReturnValue({ + billingEntity: { type: 'user', id: 'user-1' }, + billingPeriod: { + start: new Date('2026-01-01T00:00:00Z'), + end: new Date('2026-02-01T00:00:00Z'), + }, + }) + mocks.checkBillingBlocked.mockResolvedValue({ blocked: false }) + + await getBillingStatus.execute({ principal: personalPrincipal, input: {} }) + + expect(mocks.getSubscription).toHaveBeenCalledWith('user-1') + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) + + it('uses the billing owner only as the workspace ledger attribution', async () => { + await listBillingLogs.execute({ + principal: workspacePrincipal, + input: { + startDate: new Date('2026-01-01T00:00:00Z'), + endDate: new Date('2026-02-01T00:00:00Z'), + limit: 50, + }, + }) + + expect(mocks.getUsageLogs).toHaveBeenCalledWith( + 'billing-owner-1', + expect.objectContaining({ workspaceId: 'workspace-1' }) + ) + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('propagates workspace-store failures', async () => { + const failure = new Error('database unavailable') + mocks.loadWorkspace.mockRejectedValueOnce(failure) + + await expect( + getBillingStatus.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1' }, + }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/billing/application/get-billing-status.ts b/apps/sim/lib/billing/application/get-billing-status.ts new file mode 100644 index 00000000000..8a453d2e42f --- /dev/null +++ b/apps/sim/lib/billing/application/get-billing-status.ts @@ -0,0 +1,88 @@ +import { defineAuthorizedBillingReadUseCase } from '@/lib/billing/application/authorized-billing-read-use-case' +import { billingOperations } from '@/lib/billing/application/operations' +import { + checkBillingBlocked, + checkBillingEntityBlocked, + checkUsageStatus, +} from '@/lib/billing/calculations/usage-monitor' +import { + checkAttributedBillingBlocks, + resolveBillingAttribution, + resolveSystemBillingAttribution, + toUsageLimitSubscription, +} from '@/lib/billing/core/billing-attribution' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { deriveBillingContext } from '@/lib/billing/core/usage-log' +import { dollarsToCredits } from '@/lib/billing/credits/conversion' + +export interface GetBillingStatusInput { + workspaceId?: string +} + +export interface BillingStatusResult { + workspaceId: string | null + period: { start: string; end: string } + plan: string + status: 'active' | 'limit_exceeded' | 'billing_blocked' + credits: { used: number; limit: number; remaining: number } +} + +export const getBillingStatus = defineAuthorizedBillingReadUseCase({ + operation: billingOperations.readStatus, + requestedWorkspaceId: (input: GetBillingStatusInput) => input.workspaceId, + execute: async ({ principal, scope }): Promise<BillingStatusResult> => { + if (scope.kind === 'workspace') { + const attribution = + principal.kind === 'personal_api_key' + ? await resolveBillingAttribution({ + actorUserId: principal.userId, + workspaceId: scope.workspace.workspaceId, + }) + : await resolveSystemBillingAttribution(scope.workspace.workspaceId) + const [usage, block] = await Promise.all([ + checkUsageStatus(attribution.billedAccountUserId, toUsageLimitSubscription(attribution)), + checkAttributedBillingBlocks(attribution), + ]) + return { + workspaceId: scope.workspace.workspaceId, + period: attribution.billingPeriod, + plan: attribution.payerSubscription?.plan ?? 'free', + status: block.blocked ? 'billing_blocked' : usage.isExceeded ? 'limit_exceeded' : 'active', + credits: { + used: dollarsToCredits(usage.currentUsage), + limit: dollarsToCredits(usage.limit), + remaining: dollarsToCredits(usage.limit - usage.currentUsage), + }, + } + } + + const subscription = await getHighestPrioritySubscription(scope.userId) + const { billingEntity, billingPeriod } = deriveBillingContext(scope.userId, subscription) + const [usage, actorBlock, payerBlock] = await Promise.all([ + checkUsageStatus(scope.userId, subscription), + checkBillingBlocked(scope.userId), + billingEntity.type === 'user' && billingEntity.id === scope.userId + ? Promise.resolve({ blocked: false }) + : checkBillingEntityBlocked(billingEntity), + ]) + return { + workspaceId: null, + period: { + start: billingPeriod.start.toISOString(), + end: billingPeriod.end.toISOString(), + }, + plan: subscription?.plan ?? 'free', + status: + actorBlock.blocked || payerBlock.blocked + ? 'billing_blocked' + : usage.isExceeded + ? 'limit_exceeded' + : 'active', + credits: { + used: dollarsToCredits(usage.currentUsage), + limit: dollarsToCredits(usage.limit), + remaining: dollarsToCredits(usage.limit - usage.currentUsage), + }, + } + }, +}) diff --git a/apps/sim/lib/billing/application/list-billing-logs.ts b/apps/sim/lib/billing/application/list-billing-logs.ts new file mode 100644 index 00000000000..7986d2ea290 --- /dev/null +++ b/apps/sim/lib/billing/application/list-billing-logs.ts @@ -0,0 +1,56 @@ +import { defineAuthorizedBillingReadUseCase } from '@/lib/billing/application/authorized-billing-read-use-case' +import { billingOperations } from '@/lib/billing/application/operations' +import { resolveSystemBillingAttribution } from '@/lib/billing/core/billing-attribution' +import { + getUsageCreditsByLogId, + getUserUsageLogs, + type UsageLogSource, +} from '@/lib/billing/core/usage-log' + +export interface ListBillingLogsInput { + workspaceId?: string + source?: UsageLogSource[] + startDate?: Date + endDate: Date + limit: number + cursor?: string +} + +export interface ListBillingLogsResult { + usage: Awaited<ReturnType<typeof getUserUsageLogs>> + creditsByLogId: Record<string, number> +} + +export const listBillingLogs = defineAuthorizedBillingReadUseCase({ + operation: billingOperations.listLogs, + requestedWorkspaceId: (input: ListBillingLogsInput) => input.workspaceId, + execute: async ({ principal, input, scope }): Promise<ListBillingLogsResult> => { + const workspaceId = scope.kind === 'workspace' ? scope.workspace.workspaceId : undefined + let ledgerUserId: string + if (principal.kind === 'personal_api_key') { + ledgerUserId = principal.userId + } else { + if (scope.kind !== 'workspace') { + throw new Error('Workspace API key billing logs require a workspace scope') + } + ledgerUserId = (await resolveSystemBillingAttribution(scope.workspace.workspaceId)) + .billedAccountUserId + } + const filter = { + source: input.source, + workspaceId, + startDate: input.startDate, + endDate: input.endDate, + } + const [usage, creditsByLogId] = await Promise.all([ + getUserUsageLogs(ledgerUserId, { + ...filter, + limit: input.limit, + cursor: input.cursor, + includeSummary: false, + }), + getUsageCreditsByLogId(ledgerUserId, filter), + ]) + return { usage, creditsByLogId } + }, +}) diff --git a/apps/sim/lib/billing/application/operations.ts b/apps/sim/lib/billing/application/operations.ts new file mode 100644 index 00000000000..7fe8bc4fca6 --- /dev/null +++ b/apps/sim/lib/billing/application/operations.ts @@ -0,0 +1,41 @@ +import type { Principal } from '@sim/auth/principal' +import type { ApplicationOperation } from '@/lib/core/application' + +export type BillingReadPrincipal = Extract< + Principal, + { kind: 'personal_api_key' | 'workspace_api_key' } +> + +export interface BillingReadOperation<Id extends string = string> extends ApplicationOperation<Id> { + readonly accountScope: 'personal_self' + readonly workspaceMinimumRole: 'read' + readonly workspaceApiKey: 'workspace_only' + readonly principalKinds: readonly ['personal_api_key', 'workspace_api_key'] +} + +function defineBillingReadOperation<const Id extends string>( + operation: BillingReadOperation<Id> +): BillingReadOperation<Id> { + if (operation.workspaceMinimumRole !== 'read') { + throw new Error(`Billing read operation ${operation.id} exceeds its workspace-key ceiling`) + } + Object.freeze(operation.principalKinds) + return Object.freeze(operation) +} + +export const billingOperations = { + readStatus: defineBillingReadOperation({ + id: 'billing.status.read', + accountScope: 'personal_self', + workspaceMinimumRole: 'read', + workspaceApiKey: 'workspace_only', + principalKinds: ['personal_api_key', 'workspace_api_key'], + }), + listLogs: defineBillingReadOperation({ + id: 'billing.logs.list', + accountScope: 'personal_self', + workspaceMinimumRole: 'read', + workspaceApiKey: 'workspace_only', + principalKinds: ['personal_api_key', 'workspace_api_key'], + }), +} as const diff --git a/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts b/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts new file mode 100644 index 00000000000..f0dc64e48e3 --- /dev/null +++ b/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts @@ -0,0 +1,146 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + checkWorkspaceAccess: vi.fn(), + listVisible: vi.fn(), + listForWorkspacePrincipal: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mocks.checkWorkspaceAccess, +})) + +vi.mock('@/lib/credentials/queries', () => ({ + listVisibleWorkspaceCredentials: mocks.listVisible, + listWorkspacePrincipalCredentials: mocks.listForWorkspacePrincipal, +})) + +vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) + +import { listWorkspaceCredentials } from '@/lib/credentials/application/list-workspace-credentials' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const input = { + workspaceId: 'workspace-1', + sortBy: 'createdAt' as const, + sortOrder: 'desc' as const, +} + +describe('listWorkspaceCredentials', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.checkWorkspaceAccess.mockResolvedValue({ hasAccess: true, canAdmin: false }) + mocks.listVisible.mockResolvedValue([]) + mocks.listForWorkspacePrincipal.mockResolvedValue([]) + }) + + it('rejects unsupported principals before canonical workspace loading', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect(listWorkspaceCredentials.execute({ principal, input })).rejects.toMatchObject({ + code: 'forbidden', + }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) + + it('lists shared connections for a workspace key without creator identity', async () => { + const principal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', + } + + await listWorkspaceCredentials.execute({ principal, input }) + + expect(mocks.listForWorkspacePrincipal).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + types: ['oauth', 'service_account'], + providerId: undefined, + search: undefined, + sortBy: 'createdAt', + sortOrder: 'desc', + }) + expect(mocks.checkWorkspaceAccess).not.toHaveBeenCalled() + expect(mocks.listVisible).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('preserves human per-credential visibility for personal keys', async () => { + const principal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', + } + + await listWorkspaceCredentials.execute({ principal, input: { ...input, type: 'oauth' } }) + + expect(mocks.resolvePermission).toHaveBeenCalledWith('user-1', 'workspace-1', null, undefined, { + forUpdate: undefined, + }) + expect(mocks.listVisible).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', types: ['oauth'] }) + ) + }) + + it('rejects personal keys disabled by canonical workspace policy', async () => { + mocks.loadWorkspace.mockResolvedValue({ ...workspaceContext, allowPersonalApiKeys: false }) + + await expect( + listWorkspaceCredentials.execute({ + principal: { + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'key-1', + }, + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.listVisible).not.toHaveBeenCalled() + }) + + it('propagates repository failures without projecting secret details', async () => { + const failure = new Error('encrypted column read failed') + mocks.listForWorkspacePrincipal.mockRejectedValueOnce(failure) + + await expect( + listWorkspaceCredentials.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + input, + }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/credentials/application/list-workspace-credentials.ts b/apps/sim/lib/credentials/application/list-workspace-credentials.ts new file mode 100644 index 00000000000..2f92c8f1a86 --- /dev/null +++ b/apps/sim/lib/credentials/application/list-workspace-credentials.ts @@ -0,0 +1,67 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + listVisibleWorkspaceCredentials, + listWorkspacePrincipalCredentials, + type VisibleWorkspaceCredential, +} from '@/lib/credentials/queries' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +export interface ListWorkspaceCredentialsInput { + workspaceId: string + type?: 'oauth' | 'service_account' + providerId?: string + search?: string + sortBy: 'displayName' | 'createdAt' | 'updatedAt' + sortOrder: 'asc' | 'desc' +} + +export interface ListWorkspaceCredentialsResult { + credentials: VisibleWorkspaceCredential[] +} + +export const listWorkspaceCredentials = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.listConnections, + resolveContext: async ({ input }: { input: ListWorkspaceCredentialsInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + execute: async ({ principal, input, context }): Promise<ListWorkspaceCredentialsResult> => { + const types: Array<'oauth' | 'service_account'> = input.type + ? [input.type] + : ['oauth', 'service_account'] + if (principal.kind === 'workspace_api_key') { + return { + credentials: await listWorkspacePrincipalCredentials({ + workspaceId: context.workspaceId, + types, + providerId: input.providerId, + search: input.search, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + }), + } + } + + const workspaceAccess = await checkWorkspaceAccess(context.workspaceId, principal.userId) + if (!workspaceAccess.hasAccess) { + throw new OrchestrationError('forbidden', 'Access denied') + } + return { + credentials: await listVisibleWorkspaceCredentials({ + workspaceId: context.workspaceId, + userId: principal.userId, + workspaceAccess, + types, + providerId: input.providerId, + search: input.search, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + }), + } + }, +}) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts new file mode 100644 index 00000000000..4a3dcde7c11 --- /dev/null +++ b/apps/sim/lib/credentials/application/operations.ts @@ -0,0 +1,10 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +export const credentialOperations = { + listConnections: defineWorkspaceOperation({ + id: 'credentials.connections.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['personal_api_key', 'workspace_api_key'], + }), +} as const diff --git a/apps/sim/lib/credentials/queries.test.ts b/apps/sim/lib/credentials/queries.test.ts new file mode 100644 index 00000000000..d4391d06206 --- /dev/null +++ b/apps/sim/lib/credentials/queries.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { listWorkspacePrincipalCredentials } from '@/lib/credentials/queries' + +describe('listWorkspacePrincipalCredentials', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('selects and returns only connection metadata', async () => { + dbChainMockFns.orderBy.mockResolvedValueOnce([ + { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'service_account', + displayName: 'Zoom account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + hasServiceAccountKey: true, + }, + ]) + + const result = await listWorkspacePrincipalCredentials({ + workspaceId: 'workspace-1', + types: ['oauth', 'service_account'], + }) + + expect(result).toEqual([ + { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'service_account', + displayName: 'Zoom account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + hasServiceAccountKey: true, + role: 'member', + }, + ]) + expect(dbChainMockFns.select.mock.calls[0]?.[0]).not.toHaveProperty( + 'encryptedServiceAccountKey' + ) + }) + + it('fails fast on an empty connection-type policy', async () => { + await expect( + listWorkspacePrincipalCredentials({ workspaceId: 'workspace-1', types: [] }) + ).rejects.toThrow('Workspace credential types cannot be empty') + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('propagates database failures', async () => { + const failure = new Error('database unavailable') + dbChainMockFns.orderBy.mockRejectedValueOnce(failure) + + await expect( + listWorkspacePrincipalCredentials({ + workspaceId: 'workspace-1', + types: ['oauth', 'service_account'], + }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index db2572e82de..d37df87ed2a 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { credential, credentialMember } from '@sim/db/schema' -import { and, type Column, eq, inArray, isNotNull, or } from 'drizzle-orm' +import { and, type Column, eq, inArray, isNotNull, or, sql } from 'drizzle-orm' import type { V2CredentialSortBy } from '@/lib/api/contracts/v2/credentials' import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' import { listOrderBy, searchFilter } from '@/lib/api/list-query' @@ -126,6 +126,61 @@ export async function listVisibleWorkspaceCredentials(params: { })) } +/** + * Lists workspace-shared connection metadata for a workspace principal. + * + * Workspace API keys have no human identity and therefore never borrow their + * creator's credential memberships. The public operation is limited to OAuth + * and service-account connections, and this query does not select encrypted + * credential material. + */ +export async function listWorkspacePrincipalCredentials(params: { + workspaceId: string + types: Array<'oauth' | 'service_account'> + providerId?: string + search?: string + sortBy?: V2CredentialSortBy + sortOrder?: V2SortOrder +}): Promise<VisibleWorkspaceCredential[]> { + const { + workspaceId, + types, + providerId, + search, + sortBy = 'createdAt', + sortOrder = 'desc', + } = params + if (types.length === 0) throw new Error('Workspace credential types cannot be empty') + + const whereClauses = [eq(credential.workspaceId, workspaceId), inArray(credential.type, types)] + if (providerId) whereClauses.push(eq(credential.providerId, providerId)) + + const rows = await db + .select({ + id: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + displayName: credential.displayName, + description: credential.description, + providerId: credential.providerId, + accountId: credential.accountId, + createdBy: credential.createdBy, + createdAt: credential.createdAt, + updatedAt: credential.updatedAt, + hasServiceAccountKey: sql<boolean>`${credential.encryptedServiceAccountKey} IS NOT NULL`, + }) + .from(credential) + .where(and(...whereClauses, searchFilter(credential.displayName, search))) + .orderBy(...listOrderBy(CREDENTIAL_SORTS[sortBy], sortOrder)) + + return rows.map((row) => ({ + ...row, + envKey: null, + envOwnerUserId: null, + role: 'member', + })) +} + /** * A single credential scoped to a workspace, or null when it does not exist * there. Scoping by workspace is what keeps a credential id from another tenant diff --git a/apps/sim/lib/logs/api/route-policies.ts b/apps/sim/lib/logs/api/route-policies.ts new file mode 100644 index 00000000000..0c1ca12ed39 --- /dev/null +++ b/apps/sim/lib/logs/api/route-policies.ts @@ -0,0 +1,14 @@ +import { type V2ErrorPolicy, v2OrchestrationErrorPolicy } from '@/lib/api/server/routes' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +export const v2LogErrorPolicies = { + default: v2OrchestrationErrorPolicy, + concealDetailAuthorization: { + render(error) { + const response = v2CaughtOrchestrationError(error) + if (!response) return null + if (response.status === 403) return v2Error('NOT_FOUND', 'Log not found') + return response + }, + } satisfies V2ErrorPolicy, +} as const diff --git a/apps/sim/lib/logs/application/get-public-log.ts b/apps/sim/lib/logs/application/get-public-log.ts new file mode 100644 index 00000000000..4f9cdc0b3d7 --- /dev/null +++ b/apps/sim/lib/logs/application/get-public-log.ts @@ -0,0 +1,67 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { logOperations } from '@/lib/logs/application/operations' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { getPublicWorkflowLog, getPublicWorkflowLogScope } from '@/lib/logs/public-queries' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' + +type PublicWorkflowLog = NonNullable<Awaited<ReturnType<typeof getPublicWorkflowLog>>> + +interface PublicLogContext extends ActiveWorkspaceApplicationContext { + executionId: string + workflowId: string | null +} + +export interface GetPublicLogInput { + runId: string +} + +export interface GetPublicLogResult { + log: PublicWorkflowLog + workflowFolderPath: string | null + executionData: Record<string, unknown> +} + +export const getPublicLog = defineAuthorizedWorkspaceUseCase({ + operation: logOperations.readDetail, + resolveContext: async ({ input }: { input: GetPublicLogInput }): Promise<PublicLogContext> => { + const scope = await getPublicWorkflowLogScope(input.runId) + if (!scope) throw new OrchestrationError('not_found', 'Log not found') + const workspace = await loadActiveWorkspaceApplicationContext(scope.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Log not found') + return { ...workspace, executionId: scope.executionId, workflowId: scope.workflowId } + }, + authorizationOptions: {}, + execute: async ({ context }): Promise<GetPublicLogResult> => { + const log = await getPublicWorkflowLog( + { column: 'executionId', value: context.executionId }, + context.workspaceId + ) + if (!log || log.workflowId !== context.workflowId) { + throw new OrchestrationError('not_found', 'Log not found') + } + const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const executionData = await materializeExecutionData( + log.executionData as Record<string, unknown> | null, + { + workspaceId: context.workspaceId, + workflowId: log.workflowId, + executionId: log.executionId, + } + ) + if (log.workflowUserId && !log.workflowOwnerEmail) { + throw new Error(`Unable to resolve workflow owner email for ${log.workflowUserId}`) + } + return { + log, + workflowFolderPath: log.workflowFolderId + ? (folderIndex.pathById.get(log.workflowFolderId) ?? null) + : null, + executionData, + } + }, +}) diff --git a/apps/sim/lib/logs/application/list-public-logs.ts b/apps/sim/lib/logs/application/list-public-logs.ts new file mode 100644 index 00000000000..e75fca02cee --- /dev/null +++ b/apps/sim/lib/logs/application/list-public-logs.ts @@ -0,0 +1,93 @@ +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 { logOperations } from '@/lib/logs/application/operations' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import type { LogFilters } from '@/lib/logs/public-filters' +import { listPublicWorkflowLogs } from '@/lib/logs/public-queries' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +type PublicLogRow = Awaited<ReturnType<typeof listPublicWorkflowLogs>>['data'][number] + +export interface ListPublicLogsInput { + workspaceId: string + filters: Omit<LogFilters, 'workspaceId' | 'folderIds'> + folderPaths?: string[] + limit: number + includeFullDetails: boolean + includeFinalOutput: boolean + includeTraceSpans: boolean +} + +export interface PublicLogApplicationItem { + log: PublicLogRow + executionData?: Record<string, unknown> +} + +export interface ListPublicLogsResult { + items: PublicLogApplicationItem[] + nextCursor: string | null + includeFullDetails: boolean + includeFinalOutput: boolean + includeTraceSpans: boolean +} + +export const listPublicLogs = defineAuthorizedWorkspaceUseCase({ + operation: logOperations.list, + resolveContext: async ({ input }: { input: ListPublicLogsInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + execute: async ({ input, context }): Promise<ListPublicLogsResult> => { + 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') + } + + const folderIds = resolvedFolderIds?.filter( + (folderId): folderId is string => typeof folderId === 'string' + ) + const includesRoot = resolvedFolderIds?.includes(null) ?? false + const needsMaterialization = input.includeFinalOutput || input.includeTraceSpans + const { data, nextCursor } = await listPublicWorkflowLogs({ + filters: { ...input.filters, workspaceId: context.workspaceId, folderIds }, + limit: input.limit, + includeExecutionData: needsMaterialization, + folderScope: input.folderPaths ? { includesRoot, folderIds: folderIds ?? [] } : undefined, + }) + + const items = needsMaterialization + ? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => { + if (!log.executionData) return { log } + return { + log, + executionData: await materializeExecutionData( + log.executionData as Record<string, unknown>, + { + workspaceId: log.workspaceId, + workflowId: log.workflowId, + executionId: log.executionId, + } + ), + } + }) + : data.map((log) => ({ log })) + + return { + items, + nextCursor, + includeFullDetails: input.includeFullDetails, + includeFinalOutput: input.includeFinalOutput, + includeTraceSpans: input.includeTraceSpans, + } + }, +}) diff --git a/apps/sim/lib/logs/application/operations.ts b/apps/sim/lib/logs/application/operations.ts new file mode 100644 index 00000000000..9026c369451 --- /dev/null +++ b/apps/sim/lib/logs/application/operations.ts @@ -0,0 +1,18 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const PUBLIC_API_PRINCIPAL_KINDS = ['personal_api_key', 'workspace_api_key'] as const + +export const logOperations = { + list: defineWorkspaceOperation({ + id: 'logs.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: PUBLIC_API_PRINCIPAL_KINDS, + }), + readDetail: defineWorkspaceOperation({ + id: 'logs.read_detail', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: PUBLIC_API_PRINCIPAL_KINDS, + }), +} as const 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 new file mode 100644 index 00000000000..ef568c35394 --- /dev/null +++ b/apps/sim/lib/logs/application/public-log-use-cases.test.ts @@ -0,0 +1,182 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getLogScope: vi.fn(), + getLog: vi.fn(), + listLogs: vi.fn(), + loadFolders: vi.fn(), + materialize: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/logs/public-queries', () => ({ + getPublicWorkflowLogScope: mocks.getLogScope, + getPublicWorkflowLog: mocks.getLog, + listPublicWorkflowLogs: mocks.listLogs, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mocks.loadFolders, +})) + +vi.mock('@/lib/logs/execution/trace-store', () => ({ + materializeExecutionData: mocks.materialize, +})) + +vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) + +import { getPublicLog } from '@/lib/logs/application/get-public-log' +import { listPublicLogs } from '@/lib/logs/application/list-public-logs' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const log = { + executionId: 'run-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + workflowFolderId: 'folder-1', + workflowUserId: 'owner-1', + workflowOwnerEmail: 'owner@example.com', + executionData: { pointer: true }, +} +const workspacePrincipal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', +} + +describe('public log application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getLogScope.mockResolvedValue({ + executionId: 'run-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }) + mocks.getLog.mockResolvedValue(log) + mocks.listLogs.mockResolvedValue({ data: [log], nextCursor: null }) + mocks.loadFolders.mockResolvedValue({ + idByPath: new Map([['/agents', 'folder-1']]), + pathById: new Map([['folder-1', '/agents']]), + }) + mocks.materialize.mockResolvedValue({ finalOutput: { ok: true } }) + }) + + it('rejects unsupported principals before resolving the run', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect( + getPublicLog.execute({ principal, input: { runId: 'run-1' } }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.getLogScope).not.toHaveBeenCalled() + expect(mocks.getLog).not.toHaveBeenCalled() + }) + + it('derives workspace and materialization scope from the canonical run', async () => { + const result = await getPublicLog.execute({ + principal: workspacePrincipal, + input: { runId: 'run-1' }, + }) + + expect(mocks.loadWorkspace).toHaveBeenCalledWith('workspace-1') + expect(mocks.getLog).toHaveBeenCalledWith( + { column: 'executionId', value: 'run-1' }, + 'workspace-1' + ) + expect(mocks.materialize).toHaveBeenCalledWith( + { pointer: true }, + { workspaceId: 'workspace-1', workflowId: 'workflow-1', executionId: 'run-1' } + ) + expect(result.workflowFolderPath).toBe('/agents') + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('rejects a workspace key outside the run workspace before materialization', async () => { + await expect( + getPublicLog.execute({ + principal: { ...workspacePrincipal, workspaceId: 'workspace-2' }, + input: { runId: 'run-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.getLog).not.toHaveBeenCalled() + expect(mocks.materialize).not.toHaveBeenCalled() + }) + + it('resolves folder paths only after workspace authorization', async () => { + const result = await listPublicLogs.execute({ + principal: workspacePrincipal, + input: { + workspaceId: 'workspace-1', + filters: {}, + folderPaths: ['/agents'], + limit: 50, + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + }, + }) + + expect(mocks.listLogs).toHaveBeenCalledWith( + expect.objectContaining({ + filters: expect.objectContaining({ workspaceId: 'workspace-1', folderIds: ['folder-1'] }), + folderScope: { includesRoot: false, folderIds: ['folder-1'] }, + }) + ) + 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' }) + + expect(mocks.listLogs).not.toHaveBeenCalled() + }) + + it('propagates run-store failures', async () => { + const failure = new Error('database unavailable') + mocks.getLogScope.mockRejectedValueOnce(failure) + + await expect( + getPublicLog.execute({ principal: workspacePrincipal, input: { runId: 'run-1' } }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/logs/public-queries.ts b/apps/sim/lib/logs/public-queries.ts index 6c6d7866315..e627f76ecd7 100644 --- a/apps/sim/lib/logs/public-queries.ts +++ b/apps/sim/lib/logs/public-queries.ts @@ -120,6 +120,24 @@ export type PublicWorkflowLogLookup = | { column: 'id'; value: string } | { column: 'executionId'; value: string } +/** + * Resolves only the canonical resource scope needed to authorize a public run + * lookup. Protected log content is loaded separately after authorization. + */ +export async function getPublicWorkflowLogScope(executionId: string) { + const [scope] = await db + .select({ + executionId: workflowExecutionLogs.executionId, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + }) + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.executionId, executionId)) + .limit(1) + + return scope ?? null +} + /** * Loads one workflow log and its optional workflow snapshot. The snapshot join * is deliberately left-sided: a missing snapshot does not make an otherwise diff --git a/apps/sim/lib/workspaces/application/get-public-workspace.ts b/apps/sim/lib/workspaces/application/get-public-workspace.ts new file mode 100644 index 00000000000..df3cb5d3e84 --- /dev/null +++ b/apps/sim/lib/workspaces/application/get-public-workspace.ts @@ -0,0 +1,31 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { workspaceOperations } from '@/lib/workspaces/application/operations' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { + getPublicWorkspaceDetail, + type PublicWorkspaceDetail, +} from '@/lib/workspaces/public-queries' + +export interface GetPublicWorkspaceInput { + workspaceId: string +} + +export interface GetPublicWorkspaceResult { + workspace: PublicWorkspaceDetail +} + +export const getPublicWorkspace = defineAuthorizedWorkspaceUseCase({ + operation: workspaceOperations.readPublicDetail, + resolveContext: async ({ input }: { input: GetPublicWorkspaceInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + execute: async ({ context }): Promise<GetPublicWorkspaceResult> => { + const workspace = await getPublicWorkspaceDetail(context.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return { workspace } + }, +}) diff --git a/apps/sim/lib/workspaces/application/list-public-workspace-members.ts b/apps/sim/lib/workspaces/application/list-public-workspace-members.ts new file mode 100644 index 00000000000..4e5970349a9 --- /dev/null +++ b/apps/sim/lib/workspaces/application/list-public-workspace-members.ts @@ -0,0 +1,36 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { workspaceOperations } from '@/lib/workspaces/application/operations' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { + queryPublicWorkspaceMembers, + type WorkspaceMemberPage, +} from '@/lib/workspaces/public-queries' + +export interface ListPublicWorkspaceMembersInput { + workspaceId: string + limit: number + afterEmail?: string +} + +export interface ListPublicWorkspaceMembersResult { + page: WorkspaceMemberPage +} + +export const listPublicWorkspaceMembers = defineAuthorizedWorkspaceUseCase({ + operation: workspaceOperations.listPublicMembers, + resolveContext: async ({ input }: { input: ListPublicWorkspaceMembersInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + execute: async ({ input, context }): Promise<ListPublicWorkspaceMembersResult> => { + const page = await queryPublicWorkspaceMembers(context.workspaceId, { + limit: input.limit, + afterEmail: input.afterEmail, + }) + if (!page) throw new OrchestrationError('not_found', 'Workspace not found') + return { page } + }, +}) diff --git a/apps/sim/lib/workspaces/application/operations.ts b/apps/sim/lib/workspaces/application/operations.ts new file mode 100644 index 00000000000..7109d87f02a --- /dev/null +++ b/apps/sim/lib/workspaces/application/operations.ts @@ -0,0 +1,18 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const PUBLIC_API_PRINCIPAL_KINDS = ['personal_api_key', 'workspace_api_key'] as const + +export const workspaceOperations = { + readPublicDetail: defineWorkspaceOperation({ + id: 'workspaces.read_public_detail', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: PUBLIC_API_PRINCIPAL_KINDS, + }), + listPublicMembers: defineWorkspaceOperation({ + id: 'workspaces.members.list_public', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: PUBLIC_API_PRINCIPAL_KINDS, + }), +} as const diff --git a/apps/sim/lib/workspaces/application/public-workspace-reads.test.ts b/apps/sim/lib/workspaces/application/public-workspace-reads.test.ts new file mode 100644 index 00000000000..c11a76f0414 --- /dev/null +++ b/apps/sim/lib/workspaces/application/public-workspace-reads.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getDetail: vi.fn(), + listMembers: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workspaces/public-queries', () => ({ + getPublicWorkspaceDetail: mocks.getDetail, + queryPublicWorkspaceMembers: mocks.listMembers, +})) + +vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) + +import { getPublicWorkspace } from '@/lib/workspaces/application/get-public-workspace' +import { listPublicWorkspaceMembers } from '@/lib/workspaces/application/list-public-workspace-members' + +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const workspacePrincipal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', +} + +describe('public workspace application reads', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getDetail.mockResolvedValue({ id: 'workspace-1' }) + mocks.listMembers.mockResolvedValue({ members: [], nextEmail: null }) + }) + + it('authorizes workspace keys as the workspace without billing-owner membership', async () => { + await expect( + getPublicWorkspace.execute({ + principal: workspacePrincipal, + input: { workspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ workspace: { id: 'workspace-1' } }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('requires current personal-key workspace permission before member loading', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + await expect( + listPublicWorkspaceMembers.execute({ + principal: { + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'key-1', + }, + input: { workspaceId: 'workspace-1', limit: 50 }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.listMembers).not.toHaveBeenCalled() + }) + + it('returns not-found for an inactive canonical workspace', async () => { + mocks.loadWorkspace.mockResolvedValue(null) + + await expect( + getPublicWorkspace.execute({ + principal: workspacePrincipal, + input: { workspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.getDetail).not.toHaveBeenCalled() + }) + + it('propagates canonical workspace load failures', async () => { + const failure = new Error('database unavailable') + mocks.loadWorkspace.mockRejectedValueOnce(failure) + + await expect( + getPublicWorkspace.execute({ + principal: workspacePrincipal, + input: { workspaceId: 'workspace-1' }, + }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/workspaces/application/workspace-context.test.ts b/apps/sim/lib/workspaces/application/workspace-context.test.ts new file mode 100644 index 00000000000..c448ef5f91b --- /dev/null +++ b/apps/sim/lib/workspaces/application/workspace-context.test.ts @@ -0,0 +1,44 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +describe('loadActiveWorkspaceApplicationContext', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('returns canonical authorization and billing-attribution fields', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'workspace-1', + organizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }, + ]) + + await expect(loadActiveWorkspaceApplicationContext('workspace-1')).resolves.toEqual({ + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + expect(dbChainMockFns.from).toHaveBeenCalledWith(schemaMock.workspace) + }) + + it('returns null for an inactive or absent workspace', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + await expect(loadActiveWorkspaceApplicationContext('workspace-1')).resolves.toBeNull() + }) + + it('propagates database failures', async () => { + const failure = new Error('database unavailable') + dbChainMockFns.limit.mockRejectedValueOnce(failure) + + await expect(loadActiveWorkspaceApplicationContext('workspace-1')).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/workspaces/application/workspace-context.ts b/apps/sim/lib/workspaces/application/workspace-context.ts new file mode 100644 index 00000000000..fb01e591ac0 --- /dev/null +++ b/apps/sim/lib/workspaces/application/workspace-context.ts @@ -0,0 +1,32 @@ +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import type { WorkspaceAuthorizationContext } from '@/lib/core/application' + +export interface ActiveWorkspaceApplicationContext extends WorkspaceAuthorizationContext { + billedAccountUserId: string +} + +/** Loads the active canonical workspace state required by application authorization. */ +export async function loadActiveWorkspaceApplicationContext( + workspaceId: string +): Promise<ActiveWorkspaceApplicationContext | null> { + const [row] = await db + .select({ + id: workspace.id, + organizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + if (!row) return null + return { + workspaceId: row.id, + workspaceOrganizationId: row.organizationId, + allowPersonalApiKeys: row.allowPersonalApiKeys, + billedAccountUserId: row.billedAccountUserId, + } +} From d22e6bd0f5b3576818b12e4cbd34338d7b08f24e Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 8 Aug 2026 11:13:44 -0700 Subject: [PATCH 099/159] improvement(workflows): centralize v2 application operations (#6411) --- apps/sim/app/api/resume/resume-handler.ts | 417 +++----------- .../v2/workflows/[id]/deploy/route.test.ts | 114 ++++ .../app/api/v2/workflows/[id]/deploy/route.ts | 192 +++---- .../v2/workflows/[id]/execute/route.test.ts | 184 ++++++- .../api/v2/workflows/[id]/execute/route.ts | 140 +++-- .../v2/workflows/[id]/export/route.test.ts | 28 + .../app/api/v2/workflows/[id]/export/route.ts | 89 +-- .../v2/workflows/[id]/rollback/route.test.ts | 93 ++++ .../api/v2/workflows/[id]/rollback/route.ts | 140 ++--- .../app/api/v2/workflows/[id]/route.test.ts | 448 +++++---------- apps/sim/app/api/v2/workflows/[id]/route.ts | 234 ++------ .../[id]/runs/[runId]/cancel/route.ts | 90 ++- .../[id]/runs/[runId]/resume/route.test.ts | 187 ++++--- .../[id]/runs/[runId]/resume/route.ts | 124 ++--- .../workflows/[id]/runs/[runId]/route.test.ts | 326 +++++++---- .../v2/workflows/[id]/runs/[runId]/route.ts | 99 ++-- .../api/v2/workflows/[id]/runs/route.test.ts | 198 ++++--- .../app/api/v2/workflows/[id]/runs/route.ts | 128 ++--- .../[id]/versions/[version]/route.test.ts | 194 +++---- .../[id]/versions/[version]/route.ts | 59 +- .../v2/workflows/[id]/versions/route.test.ts | 282 +++------- .../api/v2/workflows/[id]/versions/route.ts | 96 ++-- .../api/v2/workflows/folders/route.test.ts | 253 ++------- .../sim/app/api/v2/workflows/folders/route.ts | 176 +++--- .../app/api/v2/workflows/import/route.test.ts | 30 + apps/sim/app/api/v2/workflows/import/route.ts | 113 +--- apps/sim/app/api/v2/workflows/lib/access.ts | 63 --- apps/sim/app/api/v2/workflows/route.test.ts | 521 +++++------------- apps/sim/app/api/v2/workflows/route.ts | 212 +++---- apps/sim/app/api/v2/workflows/utils.ts | 20 - .../app/api/workflows/[id]/deploy/route.ts | 140 +++-- .../[id]/deployments/[version]/route.ts | 110 ++-- .../api/workflows/[id]/deployments/route.ts | 38 +- apps/sim/lib/api/contracts/v2/workflows.ts | 8 +- apps/sim/lib/api/server/routes/index.ts | 2 + apps/sim/lib/api/server/validation.ts | 27 +- apps/sim/lib/core/application/index.ts | 5 + .../application/workspace-authorization.ts | 51 +- .../execution/cancel-workflow-execution.ts | 4 +- apps/sim/lib/folders/orchestration.test.ts | 16 + apps/sim/lib/folders/orchestration.ts | 167 ++++-- apps/sim/lib/workflows/api/index.ts | 1 + .../lib/workflows/api/route-policies.test.ts | 60 ++ apps/sim/lib/workflows/api/route-policies.ts | 51 ++ .../workflows/application/authorization.ts | 23 + .../authorized-workflow-use-case.ts | 28 + .../lib/workflows/application/cancel-run.ts | 51 ++ apps/sim/lib/workflows/application/context.ts | 136 +++++ .../workflows/application/create-workflow.ts | 77 +++ .../workflows/application/delete-workflow.ts | 69 +++ .../lib/workflows/application/deployments.ts | 170 ++++++ .../application/execute-workflow.test.ts | 170 ++++++ .../workflows/application/execute-workflow.ts | 69 +++ .../application/import-export.test.ts | 209 +++++++ .../workflows/application/import-export.ts | 128 +++++ .../application/list-workflow-runs.ts | 40 ++ .../application/list-workflow-versions.ts | 46 ++ .../workflows/application/list-workflows.ts | 80 +++ .../lib/workflows/application/operations.ts | 141 +++++ .../workflows/application/principal-scope.ts | 11 + .../application/read-workflow-run.ts | 50 ++ .../application/read-workflow-version.ts | 44 ++ .../workflows/application/read-workflow.ts | 47 ++ .../lib/workflows/application/resume-run.ts | 45 ++ .../application/transition-result.ts | 8 + .../workflows/application/update-workflow.ts | 89 +++ .../application/workflow-crud.test.ts | 298 ++++++++++ .../application/workflow-deployments.test.ts | 340 ++++++++++++ .../application/workflow-folders.test.ts | 193 +++++++ .../workflows/application/workflow-folders.ts | 246 +++++++++ .../application/workflow-import-error.ts | 13 + .../application/workflow-run-control.test.ts | 266 +++++++++ .../application/workflow-runs.test.ts | 175 ++++++ apps/sim/lib/workflows/deployment-outbox.ts | 79 ++- .../executor/human-in-the-loop-manager.ts | 2 +- .../workflows/executor/resume-execution.ts | 418 ++++++++++++++ .../workflows/operations/import-workflow.ts | 30 +- .../sim/lib/workflows/orchestration/deploy.ts | 34 +- apps/sim/lib/workflows/orchestration/index.ts | 5 + .../orchestration/workflow-lifecycle.ts | 345 +++++++----- 80 files changed, 6607 insertions(+), 3498 deletions(-) create mode 100644 apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/export/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/import/route.test.ts delete mode 100644 apps/sim/app/api/v2/workflows/lib/access.ts delete mode 100644 apps/sim/app/api/v2/workflows/utils.ts create mode 100644 apps/sim/lib/workflows/api/index.ts create mode 100644 apps/sim/lib/workflows/api/route-policies.test.ts create mode 100644 apps/sim/lib/workflows/api/route-policies.ts create mode 100644 apps/sim/lib/workflows/application/authorization.ts create mode 100644 apps/sim/lib/workflows/application/authorized-workflow-use-case.ts create mode 100644 apps/sim/lib/workflows/application/cancel-run.ts create mode 100644 apps/sim/lib/workflows/application/context.ts create mode 100644 apps/sim/lib/workflows/application/create-workflow.ts create mode 100644 apps/sim/lib/workflows/application/delete-workflow.ts create mode 100644 apps/sim/lib/workflows/application/deployments.ts create mode 100644 apps/sim/lib/workflows/application/execute-workflow.test.ts create mode 100644 apps/sim/lib/workflows/application/execute-workflow.ts create mode 100644 apps/sim/lib/workflows/application/import-export.test.ts create mode 100644 apps/sim/lib/workflows/application/import-export.ts create mode 100644 apps/sim/lib/workflows/application/list-workflow-runs.ts create mode 100644 apps/sim/lib/workflows/application/list-workflow-versions.ts create mode 100644 apps/sim/lib/workflows/application/list-workflows.ts create mode 100644 apps/sim/lib/workflows/application/operations.ts create mode 100644 apps/sim/lib/workflows/application/principal-scope.ts create mode 100644 apps/sim/lib/workflows/application/read-workflow-run.ts create mode 100644 apps/sim/lib/workflows/application/read-workflow-version.ts create mode 100644 apps/sim/lib/workflows/application/read-workflow.ts create mode 100644 apps/sim/lib/workflows/application/resume-run.ts create mode 100644 apps/sim/lib/workflows/application/transition-result.ts create mode 100644 apps/sim/lib/workflows/application/update-workflow.ts create mode 100644 apps/sim/lib/workflows/application/workflow-crud.test.ts create mode 100644 apps/sim/lib/workflows/application/workflow-deployments.test.ts create mode 100644 apps/sim/lib/workflows/application/workflow-folders.test.ts create mode 100644 apps/sim/lib/workflows/application/workflow-folders.ts create mode 100644 apps/sim/lib/workflows/application/workflow-import-error.ts create mode 100644 apps/sim/lib/workflows/application/workflow-run-control.test.ts create mode 100644 apps/sim/lib/workflows/application/workflow-runs.test.ts create mode 100644 apps/sim/lib/workflows/executor/resume-execution.ts diff --git a/apps/sim/app/api/resume/resume-handler.ts b/apps/sim/app/api/resume/resume-handler.ts index 266bd3bc038..2d404e163f1 100644 --- a/apps/sim/app/api/resume/resume-handler.ts +++ b/apps/sim/app/api/resume/resume-handler.ts @@ -1,50 +1,19 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { isRecordLike } from '@sim/utils/object' -import { type NextRequest, NextResponse } from 'next/server' -import { - assertBillingAttributionSnapshot, - type BillingAttributionSnapshot, -} from '@/lib/billing/core/billing-attribution' -import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' -import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' -import { toTriggerMaxDurationSeconds } from '@/lib/core/execution-limits' -import { generateRequestId } from '@/lib/core/utils/request' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { getBaseUrl } from '@/lib/core/utils/urls' -import { preprocessExecution } from '@/lib/execution/preprocessing' -import { RESUME_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/enqueue-execution' -import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import { - agentStreamProtocolResponseHeaders, - createStreamingResponse, -} from '@/lib/workflows/streaming/streaming' -import { executeResumeJob, type ResumeExecutionPayload } from '@/background/resume-execution' -import { ExecutionSnapshot } from '@/executor/execution/snapshot' + executeResumeWorkflow, + ResumeWorkflowExecutionError, + type ResumeWorkflowExecutionResult, +} from '@/lib/workflows/executor/resume-execution' +import { agentStreamProtocolResponseHeaders } from '@/lib/workflows/streaming/streaming' import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection' const logger = createLogger('WorkflowResumeAPI') -const INVALID_PAUSED_SNAPSHOT_ERROR = 'Paused execution snapshot is invalid' -const INVALID_PAUSED_ATTRIBUTION_ERROR = - 'Paused execution billing attribution is missing or invalid' -const PAUSED_EXECUTION_BINDING_ERROR = - 'Paused execution snapshot does not match the requested workflow or execution' -const PAUSED_ATTRIBUTION_BINDING_ERROR = - 'Paused execution billing attribution does not match its workspace or actor' - -interface PausedExecutionSnapshotSource { - workflowId: string - executionId: string - executionSnapshot: unknown -} - -interface PausedExecutionSnapshotBinding { - snapshot: ExecutionSnapshot - billingAttribution: BillingAttributionSnapshot -} - interface HandleResumeExecutionOptions { request: NextRequest workflowId: string @@ -55,344 +24,96 @@ interface HandleResumeExecutionOptions { resumeInput: unknown isApiCaller: boolean pollingSurface: 'legacy' | 'v2' - /** When false, inherited stream-mode resumes use async JSON polling instead of SSE. */ allowStreaming?: boolean } -function loadPausedExecutionSnapshot( - pausedExecution: PausedExecutionSnapshotSource, - expected: { workflowId: string; executionId: string; workspaceId: string } -): PausedExecutionSnapshotBinding { - if ( - !isRecordLike(pausedExecution.executionSnapshot) || - typeof pausedExecution.executionSnapshot.snapshot !== 'string' - ) { - throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) - } - - let snapshot: ExecutionSnapshot - try { - snapshot = ExecutionSnapshot.fromJSON(pausedExecution.executionSnapshot.snapshot) - } catch { - throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) - } - - if (!isRecordLike(snapshot.metadata)) { - throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) - } - - let billingAttribution: BillingAttributionSnapshot - try { - billingAttribution = assertBillingAttributionSnapshot(snapshot.metadata.billingAttribution) - } catch { - throw new Error(INVALID_PAUSED_ATTRIBUTION_ERROR) - } - - if ( - pausedExecution.workflowId !== expected.workflowId || - pausedExecution.executionId !== expected.executionId || - snapshot.metadata.workflowId !== expected.workflowId || - snapshot.metadata.executionId !== expected.executionId - ) { - throw new Error(PAUSED_EXECUTION_BINDING_ERROR) - } - - if ( - snapshot.metadata.workspaceId !== expected.workspaceId || - billingAttribution.workspaceId !== expected.workspaceId || - snapshot.metadata.userId !== billingAttribution.actorUserId - ) { - throw new Error(PAUSED_ATTRIBUTION_BINDING_ERROR) - } - - return { snapshot, billingAttribution } -} - -/** Executes the shared resume flow while preserving each API surface's polling contract. */ -export async function handleResumeExecution({ - request, - workflowId, - executionId, - contextId, - workspaceId, - userId, - resumeInput, - isApiCaller, - pollingSurface, - allowStreaming = true, -}: HandleResumeExecutionOptions): Promise<NextResponse> { - const requestId = generateRequestId() - const pausedExecution = await PauseResumeManager.getPausedExecutionDetail({ - workflowId, - executionId, - }) - if (!pausedExecution) { - return NextResponse.json({ error: 'Paused execution not found' }, { status: 404 }) - } - - let snapshotBinding: PausedExecutionSnapshotBinding - try { - snapshotBinding = loadPausedExecutionSnapshot(pausedExecution, { - workflowId, - executionId, - workspaceId, - }) - } catch (error) { - const message = toError(error).message - logger.error(`[${requestId}] Failed to validate paused execution snapshot`, { - workflowId, - executionId, - error: message, - }) - return NextResponse.json({ error: message }, { status: 500 }) - } - - const { snapshot: persistedSnapshot, billingAttribution } = snapshotBinding - const resumeExecutionId = generateId() - - logger.info(`[${requestId}] Preprocessing resume execution`, { - workflowId, - parentExecutionId: executionId, - resumeExecutionId, - userId, - actorUserId: billingAttribution.actorUserId, - }) - - /** - * This preflight gives synchronous callers current block/usage feedback - * without reserving under a throwaway id. The claimed resume reruns every - * gate and reserves atomically under its persisted resume execution id. - */ - const preprocessResult = await preprocessExecution({ - workflowId, - userId, - triggerType: 'manual', - executionId: resumeExecutionId, - requestId, - checkRateLimit: false, - checkDeployment: false, - skipConcurrencyReservation: true, - logPreprocessingErrors: false, - workspaceId, - billingAttribution, - }) - - if (!preprocessResult.success) { - logger.warn(`[${requestId}] Preprocessing failed for resume`, { - workflowId, - parentExecutionId: executionId, - error: preprocessResult.error?.message, - statusCode: preprocessResult.error?.statusCode, - }) - - return NextResponse.json( - { - error: - preprocessResult.error?.message || - 'Failed to validate resume execution. Please try again.', - }, - { status: preprocessResult.error?.statusCode || 400 } - ) - } - - logger.info(`[${requestId}] Preprocessing passed, proceeding with resume`, { - workflowId, - parentExecutionId: executionId, - resumeExecutionId, - actorUserId: preprocessResult.actorUserId, - }) - - try { - const enqueueResult = await PauseResumeManager.enqueueOrStartResume({ - executionId, - workflowId, - contextId, - resumeInput, - userId, - allowedPauseKinds: ['human'], - }) - - if (enqueueResult.status === 'queued') { +function presentResumeResult( + result: ResumeWorkflowExecutionResult, + request: NextRequest, + workflowId: string, + pollingSurface: 'legacy' | 'v2' +): NextResponse { + switch (result.kind) { + case 'queued': return NextResponse.json({ status: 'queued', - executionId: enqueueResult.resumeExecutionId, - queuePosition: enqueueResult.queuePosition, + executionId: result.executionId, + queuePosition: result.queuePosition, message: 'Resume queued. It will run after current resumes finish.', }) - } - - const resumeArgs = { - resumeEntryId: enqueueResult.resumeEntryId, - resumeExecutionId: enqueueResult.resumeExecutionId, - pausedExecution: enqueueResult.pausedExecution, - contextId: enqueueResult.contextId, - resumeInput: enqueueResult.resumeInput, - userId: enqueueResult.userId, - } - - const persistedExecutionMode = persistedSnapshot.metadata.executionMode ?? 'sync' - const executionMode = isApiCaller - ? persistedExecutionMode === 'stream' && !allowStreaming - ? 'async' - : persistedExecutionMode - : undefined - const includeThinking = persistedSnapshot.metadata.includeThinking === true - const includeToolCalls = persistedSnapshot.metadata.includeToolCalls === true - - if (isApiCaller && executionMode === 'stream') { - const stream = await createStreamingResponse({ - requestId, - streamConfig: { - selectedOutputs: persistedSnapshot.selectedOutputs, - timeoutMs: preprocessResult.executionTimeout?.sync, - includeThinking, - includeToolCalls, - }, - executionId: enqueueResult.resumeExecutionId, - workspaceId, - workflowId, - userId: enqueueResult.userId, - allowLargeValueWorkflowScope: true, - requestSignal: request.signal, - requestHeaders: request.headers, - executeFn: async ({ onStream, onBlockComplete, abortSignal }) => - PauseResumeManager.startResumeExecution({ - ...resumeArgs, - onStream, - onBlockComplete, - abortSignal, - }), - }) - - return new NextResponse(stream, { + case 'stream': + return new NextResponse(result.stream, { headers: { ...SSE_HEADERS, ...agentStreamProtocolResponseHeaders({ requestHeaders: request.headers }), - 'X-Execution-Id': enqueueResult.resumeExecutionId, + 'X-Execution-Id': result.executionId, }, }) - } - - if (isApiCaller && executionMode === 'sync') { - const result = await PauseResumeManager.startResumeExecution(resumeArgs) - + case 'sync': return NextResponse.json({ success: result.success, - status: result.status ?? (result.success ? 'completed' : 'failed'), - executionId: enqueueResult.resumeExecutionId, + status: result.status, + executionId: result.executionId, output: result.output, error: result.error, - metadata: result.metadata - ? { - duration: result.metadata.duration, - startTime: result.metadata.startTime, - endTime: result.metadata.endTime, - } - : undefined, + metadata: result.metadata, }) - } - - if (isApiCaller && executionMode === 'async') { - const correlation: AsyncExecutionCorrelation = { - executionId, - requestId, - source: 'workflow', - workflowId, - triggerType: 'resume', - } - const resumePayload: ResumeExecutionPayload = { - resumeEntryId: enqueueResult.resumeEntryId, - resumeExecutionId: enqueueResult.resumeExecutionId, - pausedExecutionId: enqueueResult.pausedExecution.id, - contextId: enqueueResult.contextId, - resumeInput: enqueueResult.resumeInput, - userId: enqueueResult.userId, - workflowId, - parentExecutionId: executionId, - executionTimeoutMs: preprocessResult.executionTimeout.async, - billingAttribution: preprocessResult.billingAttribution, - } - - let jobId: string - try { - const jobQueue = await getJobQueue() - const executeInline = shouldExecuteInline() - jobId = await jobQueue.enqueue('resume-execution', resumePayload, { - ...(pollingSurface === 'v2' - ? { jobId: `${RESUME_EXECUTION_JOB_ID_PREFIX}${enqueueResult.resumeEntryId}` } - : {}), - metadata: { - executionId, - workflowId, - workspaceId, - userId, - resumeExecutionId: enqueueResult.resumeExecutionId, - correlation, - }, - maxDurationSeconds: toTriggerMaxDurationSeconds(preprocessResult.executionTimeout.async), - ...(executeInline - ? { - runner: (_queuedPayload: unknown, signal: AbortSignal) => - executeResumeJob(resumePayload, signal), - } - : {}), - }) - logger.info('Enqueued async resume execution', { - jobId, - resumeExecutionId: enqueueResult.resumeExecutionId, - }) - } catch (dispatchError) { - logger.error('Failed to dispatch async resume execution', { - error: toError(dispatchError).message, - resumeExecutionId: enqueueResult.resumeExecutionId, - }) - await PauseResumeManager.markResumeAttemptFailed({ - resumeEntryId: enqueueResult.resumeEntryId, - pausedExecutionId: enqueueResult.pausedExecution.id, - parentExecutionId: executionId, - contextId: enqueueResult.contextId, - failureReason: 'Failed to queue async resume execution', - }) - await PauseResumeManager.processQueuedResumes(executionId, workflowId) - return NextResponse.json( - { error: 'Failed to queue resume execution. Please try again.' }, - { status: 503 } - ) - } - + case 'async': return NextResponse.json( { success: true, async: true, - ...(pollingSurface === 'legacy' ? { jobId } : {}), - executionId: enqueueResult.resumeExecutionId, + ...(pollingSurface === 'legacy' ? { jobId: result.jobId } : {}), + executionId: result.executionId, message: 'Resume execution queued', statusUrl: pollingSurface === 'legacy' - ? `${getBaseUrl()}/api/jobs/${jobId}` - : `${getBaseUrl()}/api/v2/workflows/${workflowId}/runs/${enqueueResult.resumeExecutionId}`, + ? `${getBaseUrl()}/api/jobs/${result.jobId}` + : `${getBaseUrl()}/api/v2/workflows/${workflowId}/runs/${result.executionId}`, }, { status: 202 } ) - } - - PauseResumeManager.startResumeExecution(resumeArgs).catch((error) => { - logger.error( - 'Failed to start resume execution', - projectResolvedSecretDiagnosticError(error, undefined, { - workflowId, - parentExecutionId: executionId, - resumeExecutionId: enqueueResult.resumeExecutionId, - }) - ) - }) + case 'started': + return NextResponse.json({ + status: 'started', + executionId: result.executionId, + message: 'Resume execution started.', + }) + } +} - return NextResponse.json({ - status: 'started', - executionId: enqueueResult.resumeExecutionId, - message: 'Resume execution started.', +/** Adapts the transport-neutral resume transition to the legacy response contract. */ +export async function handleResumeExecution({ + request, + workflowId, + executionId, + contextId, + workspaceId, + userId, + resumeInput, + isApiCaller, + pollingSurface, + allowStreaming = true, +}: HandleResumeExecutionOptions): Promise<NextResponse> { + try { + const result = await executeResumeWorkflow({ + workflowId, + executionId, + contextId, + workspaceId, + userId, + resumeInput, + isApiCaller, + pollingSurface, + allowStreaming, + requestSignal: request.signal, + requestHeaders: request.headers, }) + return presentResumeResult(result, request, workflowId, pollingSurface) } catch (error) { + if (error instanceof ResumeWorkflowExecutionError) { + return NextResponse.json({ error: error.message }, { status: error.statusCode }) + } logger.error( 'Resume request failed', projectResolvedSecretDiagnosticError(error, undefined, { @@ -401,11 +122,9 @@ export async function handleResumeExecution({ contextId, }) ) - const statusCode = - isRecordLike(error) && typeof error.statusCode === 'number' ? error.statusCode : 400 return NextResponse.json( { error: toError(error).message || 'Failed to queue resume request' }, - { status: statusCode } + { status: 400 } ) } } diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts new file mode 100644 index 00000000000..049527d5fda --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + defineRoute: vi.fn((definition) => definition), + capture: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes', () => ({ + defineV2JsonRoute: mocks.defineRoute, + v2ApiKeyAuth: { kind: 'v2-api-key' }, + v2RateLimits: { publicApi: { kind: 'public-api' } }, + v2OrchestrationErrorPolicy: { kind: 'orchestration-errors' }, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { + v2DeployWorkflowContract, + v2UndeployWorkflowContract, +} from '@/lib/api/contracts/v2/workflows' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { DELETE, POST } from '@/app/api/v2/workflows/[id]/deploy/route' + +describe('/api/v2/workflows/[id]/deploy route definitions', () => { + it('keeps an omitted deploy body valid and binds the authorized deployment use case', async () => { + expect(v2DeployWorkflowContract.body?.parse(undefined)).toEqual({}) + expect(POST).toMatchObject({ + operation: workflowOperations.deploy, + useCase: deployWorkflow, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + parseOptions: { optionalJsonBody: true }, + }) + expect(Reflect.get(POST, 'mapInput')({ params: { id: 'workflow-1' }, body: {} })).toEqual( + expect.objectContaining({ + workflowId: 'workflow-1', + name: undefined, + description: undefined, + }) + ) + + const invalidJsonResponse = Reflect.get( + Reflect.get(POST, 'parseOptions'), + 'invalidJsonResponse' + )() + expect(invalidJsonResponse.status).toBe(400) + expect(await invalidJsonResponse.json()).toEqual({ + error: { code: 'BAD_REQUEST', message: 'Request body must be valid JSON' }, + }) + + const payloadTooLargeResponse = Reflect.get( + Reflect.get(POST, 'parseOptions'), + 'payloadTooLargeResponse' + )() + expect(payloadTooLargeResponse.status).toBe(413) + expect(await payloadTooLargeResponse.json()).toEqual({ + error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' }, + }) + }) + + it('presents the full declared deployment lifecycle response', () => { + const body = Reflect.get( + POST, + 'present' + )({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + deployedAt: new Date('2026-01-01T00:00:00.000Z'), + version: 2, + warnings: [], + activeDeployment: null, + latestDeploymentAttempt: null, + }) + expect(body.data.isDeployed).toBe(false) + expect(v2DeployWorkflowContract.response.schema.parse(body)).toEqual(body) + }) + + it('keeps product analytics on the v2 adapter', async () => { + const result = { workflowId: 'workflow-1', workspaceId: 'workspace-1' } + await Reflect.get( + POST, + 'onSuccess' + )({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + result, + }) + expect(mocks.capture).toHaveBeenCalledWith( + 'user-1', + 'workflow_deployed', + { workflow_id: 'workflow-1', workspace_id: 'workspace-1' }, + expect.objectContaining({ groups: { workspace: 'workspace-1' } }) + ) + }) + + it('keeps undeploy on the authorized operation and declared response schema', () => { + expect(DELETE).toMatchObject({ + operation: workflowOperations.undeploy, + useCase: undeployWorkflow, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + }) + const body = Reflect.get( + DELETE, + 'present' + )({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + warnings: [], + }) + expect(v2UndeployWorkflowContract.response.schema.parse(body)).toEqual(body) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts index 0bf14474706..4827ea5e8e0 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -1,139 +1,91 @@ -import { createLogger } from '@sim/logger' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' -import { v1DeployWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' import { v2DeployWorkflowContract, v2UndeployWorkflowContract, } from '@/lib/api/contracts/v2/workflows' -import { parseOptionalJsonBody } from '@/lib/api/server' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { generateRequestId } from '@/lib/core/utils/request' import { captureServerEvent } from '@/lib/posthog/server' -import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' -import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' - -const logger = createLogger('V2WorkflowDeployAPI') +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 120 -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2DeployWorkflowContract, - rateLimitEndpoint: 'workflow-deploy', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - try { - const { id } = input.params - - const rawBody = await parseOptionalJsonBody(request) - if (!rawBody.success) { - return rawBody.response.status === 413 - ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') - : v2Error('BAD_REQUEST', 'Request body must be valid JSON') - } - const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {}) - if (!body.success) return v2ValidationError(body.error) - - const target = await resolveV2WorkflowTarget(rateLimit, userId, id, 'admin') - if (!target) return v2Error('NOT_FOUND', 'Workflow not found') - const { workspaceId } = target - - await assertWorkflowMutable(id) - - logger.info(`[${requestId}] Deploying workflow ${id} via v2 API`, { userId }) - - const result = await performFullDeploy({ - workflowId: id, - userId, - versionName: body.data.name, - versionDescription: body.data.description ?? undefined, - requestId, - }) - - if (!result.success) { - const code = - result.errorCode === 'not_found' - ? 'NOT_FOUND' - : result.errorCode === 'validation' - ? 'BAD_REQUEST' - : 'INTERNAL_ERROR' - return v2Error(code, result.error || 'Failed to deploy workflow') - } - - captureServerEvent( - userId, - 'workflow_deployed', - { workflow_id: id, workspace_id: workspaceId }, - { - groups: { workspace: workspaceId }, - setOnce: { first_workflow_deployed_at: new Date().toISOString() }, - } - ) - - return v2Data( - { - id, - isDeployed: true, - deployedAt: result.deployedAt?.toISOString() ?? null, - version: result.version, - warnings: result.warnings ?? [], - }, - { rateLimit } - ) - } catch (error) { - if (error instanceof WorkflowLockedError) { - return v2Error('LOCKED', error.message) - } - throw error + auth: v2ApiKeyAuth, + operation: workflowOperations.deploy, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + parseOptions: { + optionalJsonBody: true, + invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), + payloadTooLargeResponse: () => v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large'), + }, + mapInput: ({ params, body }) => ({ + workflowId: params.id, + name: body.name, + description: body.description ?? undefined, + requestId: generateRequestId(), + }), + useCase: deployWorkflow, + present: (result) => ({ + data: { + id: result.workflowId, + isDeployed: Boolean(result.activeDeployment), + deployedAt: result.deployedAt?.toISOString() ?? null, + version: result.version, + warnings: result.warnings ?? [], + activeDeployment: result.activeDeployment ?? null, + latestDeploymentAttempt: result.latestDeploymentAttempt ?? null, + }, + }), + onSuccess: ({ principal, result }) => { + if (principal.kind !== 'personal_api_key') { + throw new Error('Admin deployment unexpectedly admitted a workspace API key') } + captureServerEvent( + principal.userId, + 'workflow_deployed', + { workflow_id: result.workflowId, workspace_id: result.workspaceId }, + { + groups: { workspace: result.workspaceId }, + setOnce: { first_workflow_deployed_at: new Date().toISOString() }, + } + ) }, }) -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2UndeployWorkflowContract, - rateLimitEndpoint: 'workflow-deploy', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { id } = input.params - - const target = await resolveV2WorkflowTarget(rateLimit, userId, id, 'admin') - if (!target) return v2Error('NOT_FOUND', 'Workflow not found') - const { workflow, workspaceId } = target - - if (!workflow.isDeployed) { - return v2Error('BAD_REQUEST', 'Workflow is not deployed') - } - - await assertWorkflowMutable(id) - - logger.info(`[${requestId}] Undeploying workflow ${id} via v2 API`, { userId }) - - const result = await performFullUndeploy({ workflowId: id, userId, requestId }) - if (!result.success) { - return v2Error('INTERNAL_ERROR', result.error || 'Failed to undeploy workflow') - } - - captureServerEvent( - userId, - 'workflow_undeployed', - { workflow_id: id, workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - - return v2Data( - { - id, - isDeployed: false, - deployedAt: null, - warnings: result.warnings ?? [], - }, - { rateLimit } - ) - } catch (error) { - if (error instanceof WorkflowLockedError) { - return v2Error('LOCKED', error.message) - } - throw error + auth: v2ApiKeyAuth, + operation: workflowOperations.undeploy, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ workflowId: params.id, requestId: generateRequestId() }), + useCase: undeployWorkflow, + present: (result) => ({ + data: { + id: result.workflowId, + isDeployed: false, + deployedAt: null, + warnings: result.warnings ?? [], + activeDeployment: null, + latestDeploymentAttempt: null, + }, + }), + onSuccess: ({ principal, result }) => { + if (principal.kind !== 'personal_api_key') { + throw new Error('Admin undeployment unexpectedly admitted a workspace API key') } + captureServerEvent( + principal.userId, + 'workflow_undeployed', + { workflow_id: result.workflowId, workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } + ) }, }) diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts index ac9cdc87de5..f5de09391d9 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts @@ -18,39 +18,58 @@ import { import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockAuthenticateV1Request, + MockV2ApiKeyUnauthenticatedError, + mockAuthenticateV2ApiKey, mockClaimExecutionId, + mockCheckOperationRate, + mockCheckPreAuthRate, mockEnqueue, mockExecuteWorkflowCore, mockGenerateId, - mockGetWorkspaceBillingSettings, mockHasDurableExecutionOwner, mockReleaseExecutionIdClaim, mockReleaseExecutionSlot, mockValidatePublicApiAllowed, } = vi.hoisted(() => ({ - mockAuthenticateV1Request: vi.fn(), + MockV2ApiKeyUnauthenticatedError: class MockV2ApiKeyUnauthenticatedError extends Error {}, + mockAuthenticateV2ApiKey: vi.fn(), mockClaimExecutionId: vi.fn(), + mockCheckOperationRate: vi.fn(), + mockCheckPreAuthRate: vi.fn(), mockEnqueue: vi.fn().mockResolvedValue('workflow-execution:execution-123'), mockExecuteWorkflowCore: vi.fn(), mockGenerateId: vi.fn(() => 'execution-123'), - mockGetWorkspaceBillingSettings: vi.fn(), mockHasDurableExecutionOwner: vi.fn(), mockReleaseExecutionIdClaim: vi.fn(), mockReleaseExecutionSlot: vi.fn(), mockValidatePublicApiAllowed: vi.fn(), })) -vi.mock('@/app/api/v1/auth', () => ({ - authenticateV1Request: mockAuthenticateV1Request, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mockAuthenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mockCheckPreAuthRate + checkRateLimitDirectOrThrow = mockCheckOperationRate + }, })) vi.mock('@/lib/billing/calculations/usage-reservation', () => ({ releaseExecutionSlot: mockReleaseExecutionSlot, })) -vi.mock('@/lib/workspaces/utils', () => ({ - getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings, +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: vi.fn().mockResolvedValue('read'), })) vi.mock('@/ee/access-control/utils/permission-check', () => ({ @@ -164,7 +183,25 @@ const workflowRecord = { variables: {}, } +const applicationContext = { + workflowId: 'workflow-1', + workflow: workflowRecord, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'actor-1', +} + function callExecute(body: Record<string, unknown>, headers: Record<string, string> = {}) { + const req = createMockRequest('POST', body, { + 'Content-Type': 'application/json', + 'X-API-Key': 'test-key', + ...headers, + }) + return POST(req, { params: Promise.resolve({ id: 'workflow-1' }) }) +} + +function callPublicExecute(body: Record<string, unknown>, headers: Record<string, string> = {}) { const req = createMockRequest('POST', body, { 'Content-Type': 'application/json', ...headers, @@ -178,12 +215,28 @@ describe('POST /api/v2/workflows/[id]/execute', () => { resetDbChainMock() setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) mockGenerateId.mockReturnValue('execution-123') - mockAuthenticateV1Request.mockResolvedValue({ - authenticated: true, - userId: 'key-user-1', + mockCheckPreAuthRate.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-08T05:00:00Z'), + }) + mockCheckOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-08T05:00:00Z'), + }) + mockAuthenticateV2ApiKey.mockResolvedValue({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + rolloutUserId: 'actor-1', + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'], + rateLimitSubscription: null, keyType: 'workspace', - workspaceId: 'workspace-1', }) + dbChainMockFns.limit.mockResolvedValue([applicationContext]) mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord }) mockClaimExecutionId.mockImplementation(async (executionId: string) => ({ key: `workflow-execution-id:${executionId}`, @@ -272,6 +325,47 @@ describe('POST /api/v2/workflows/[id]/execute', () => { ) }) + it('admits keyed execution through request-rate buckets before separate execution preprocessing', async () => { + const response = await callExecute({ input: {} }) + + expect(response.status).toBe(200) + expect(mockCheckPreAuthRate).toHaveBeenCalledOnce() + expect(mockCheckOperationRate).toHaveBeenCalledTimes(2) + expect(mockCheckOperationRate).toHaveBeenCalledWith( + 'v2:workflows.execute:api-key:key-1', + expect.anything() + ) + expect(mockCheckOperationRate).toHaveBeenCalledWith( + 'v2:workflows.execute:workspace:workspace-1', + expect.anything() + ) + expect(mockPreprocessExecution).toHaveBeenCalledWith( + expect.objectContaining({ rateLimitCounter: 'sync' }) + ) + }) + + it('stops keyed execution at request-rate admission without consuming execution quota', async () => { + mockCheckOperationRate + .mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date('2026-08-08T05:00:00Z'), + retryAfterMs: 12_000, + }) + .mockResolvedValueOnce({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-08T05:00:00Z'), + }) + + const response = await callExecute({ input: {} }) + + expect(response.status).toBe(429) + expect(response.headers.get('Retry-After')).toBe('12') + expect(mockPreprocessExecution).not.toHaveBeenCalled() + expect(mockClaimExecutionId).not.toHaveBeenCalled() + }) + it('404s the whole surface when the v2-api flag is off', async () => { const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') const { v2Error } = await import('@/app/api/v2/lib/response') @@ -300,11 +394,16 @@ describe('POST /api/v2/workflows/[id]/execute', () => { }) it('masks a workspace-key/workflow mismatch as 404', async () => { - mockAuthenticateV1Request.mockResolvedValue({ - authenticated: true, - userId: 'key-user-1', + mockAuthenticateV2ApiKey.mockResolvedValue({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'other-workspace', + keyId: 'key-1', + }, + rolloutUserId: 'actor-1', + rateLimitSubjectIds: ['api-key:key-1', 'workspace:other-workspace'], + rateLimitSubscription: null, keyType: 'workspace', - workspaceId: 'other-workspace', }) const res = await callExecute({ input: {} }) @@ -314,12 +413,16 @@ describe('POST /api/v2/workflows/[id]/execute', () => { }) it('rejects personal keys when the workspace disallows them', async () => { - mockAuthenticateV1Request.mockResolvedValue({ - authenticated: true, - userId: 'key-user-1', + mockAuthenticateV2ApiKey.mockResolvedValue({ + principal: { kind: 'personal_api_key', userId: 'key-user-1', keyId: 'key-1' }, + rolloutUserId: 'key-user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:key-user-1'], + rateLimitSubscription: null, keyType: 'personal', }) - mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: false }) + dbChainMockFns.limit.mockResolvedValueOnce([ + { ...applicationContext, allowPersonalApiKeys: false }, + ]) const res = await callExecute({ input: {} }) @@ -352,6 +455,19 @@ describe('POST /api/v2/workflows/[id]/execute', () => { expect(mockPreprocessExecution).not.toHaveBeenCalled() }) + it('rejects an invalid API key without entering public execution', async () => { + mockAuthenticateV2ApiKey.mockRejectedValueOnce( + new MockV2ApiKeyUnauthenticatedError('Invalid API key') + ) + + const response = await callExecute({ input: {} }, { 'X-API-Key': 'invalid' }) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + expect(mockAuthorize).not.toHaveBeenCalled() + expect(mockPreprocessExecution).not.toHaveBeenCalled() + }) + it('surfaces the rate-limit failure with Retry-After', async () => { mockPreprocessExecution.mockResolvedValue({ success: false, @@ -371,32 +487,36 @@ describe('POST /api/v2/workflows/[id]/execute', () => { }) it('runs the anonymous public path sync but refuses async', async () => { - mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) dbChainMockFns.limit.mockResolvedValueOnce([ { isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, ]) - const okRes = await callExecute({ input: {} }) + const okRes = await callPublicExecute({ input: {} }) expect(okRes.status).toBe(200) + expect(mockAuthenticateV2ApiKey).not.toHaveBeenCalled() + expect(mockCheckOperationRate).not.toHaveBeenCalled() + expect(mockPreprocessExecution).toHaveBeenCalledWith( + expect.objectContaining({ rateLimitCounter: 'sync' }) + ) - mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) dbChainMockFns.limit.mockResolvedValueOnce([ { isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, ]) - const asyncRes = await callExecute({ input: {}, async: true }) + const asyncRes = await callPublicExecute({ input: {}, async: true }) expect(asyncRes.status).toBe(400) }) it('401s non-public workflows without a key', async () => { - mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) dbChainMockFns.limit.mockResolvedValueOnce([ { isPublicApi: false, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, ]) - const res = await callExecute({ input: {} }) + const res = await callPublicExecute({ input: {} }) expect(res.status).toBe(401) expect((await res.json()).error.code).toBe('UNAUTHORIZED') + expect(mockAuthenticateV2ApiKey).not.toHaveBeenCalled() + expect(mockCheckOperationRate).not.toHaveBeenCalled() }) it('releases the unused execution-id claim after a failed preprocess', async () => { @@ -410,4 +530,16 @@ describe('POST /api/v2/workflows/[id]/execute', () => { expect(res.status).toBe(404) expect(mockReleaseExecutionIdClaim).toHaveBeenCalled() }) + + it('returns a safe error when canonical workflow lookup fails', async () => { + dbChainMockFns.limit.mockRejectedValueOnce(new Error('database connection details')) + + const response = await callExecute({ input: {} }) + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) + expect(mockPreprocessExecution).not.toHaveBeenCalled() + }) }) 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 4947f04ba32..5c7a78cad99 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -10,13 +10,24 @@ import { v2ExecuteWorkflowContract, } from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' +import { + admitV2Request, + V2RouteInfrastructureError, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import type { V2ApiKeyPrincipal } from '@/lib/api/server/routes/v2-api-key-auth' import { tryAdmit } from '@/lib/core/admission/gate' import { ADMISSION_ERROR_DESCRIPTOR } from '@/lib/core/admission/transient-failure' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { executeWorkflowOperation } from '@/lib/workflows/application/execute-workflow' +import { workflowOperations } from '@/lib/workflows/application/operations' import { type ExecuteWorkflowServiceFailure, + type ExecuteWorkflowServiceResult, executeWorkflowService, } from '@/lib/workflows/executor/execute-service' import { @@ -25,8 +36,6 @@ import { clientAcceptsAgentStreamProtocol, hasAgentStreamPolicy, } from '@/lib/workflows/streaming/agent-stream-protocol' -import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' -import { authenticateV1Request } from '@/app/api/v1/auth' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { type V2ErrorCode, v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' import { @@ -90,9 +99,9 @@ function serviceFailureResponse(failure: ExecuteWorkflowServiceFailure) { * an in-band run failure is `status: 'failed'`, never an HTTP error. A * Response block's declared payload stays inside `output` — v2 never lets a * workflow author control response status or headers on this origin. - * - Rate limiting: the execution `sync`/`async` buckets via preprocessing — - * deliberately NOT the shared `api-endpoint` bucket, and async runs debit - * the async bucket (unlike v1's known sync-bucket bug). + * - Rate limiting: keyed requests consume the shared request-rate bucket; + * execution preprocessing separately enforces the `sync`/`async` execution + * bucket, quota, billing, and concurrency checks. */ export const POST = withRouteHandler( async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { @@ -101,18 +110,19 @@ export const POST = withRouteHandler( let userId: string let isPublicApiAccess = false - let apiKeyType: 'personal' | 'workspace' | undefined - let apiKeyWorkspaceId: string | undefined + let apiKeyPrincipal: V2ApiKeyPrincipal | undefined - const auth = await authenticateV1Request(req) - if (auth.authenticated && auth.userId) { - userId = auth.userId - apiKeyType = auth.keyType - apiKeyWorkspaceId = auth.workspaceId + if (req.headers.has('x-api-key')) { + const admission = await admitV2Request( + req, + workflowOperations.execute, + v2ApiKeyAuth, + v2RateLimits.publicApi + ) + if (!admission.success) return admission.response + apiKeyPrincipal = admission.auth.principal + userId = admission.auth.rolloutUserId } else { - if (req.headers.has('x-api-key')) { - return v2Error('UNAUTHORIZED', auth.error || 'Unauthorized') - } const [wf] = await db .select({ isPublicApi: workflowTable.isPublicApi, @@ -139,8 +149,10 @@ export const POST = withRouteHandler( isPublicApiAccess = true } - const gate = await v2ApiGateError(userId) - if (gate) return gate + if (isPublicApiAccess) { + const gate = await v2ApiGateError(userId) + if (gate) return gate + } const ticket = tryAdmit() if (!ticket) { @@ -204,48 +216,56 @@ export const POST = withRouteHandler( requestedExecutionId = runIdHeader } - const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'read', - }) - // Mask authorization failures as 404 so cross-workspace existence never leaks. - if (!workflowAuthorization.allowed || !workflowAuthorization.workflow) { - return v2Error('NOT_FOUND', 'Workflow not found') - } - const workflowRecord = workflowAuthorization.workflow - - if (apiKeyType === 'workspace' && workflowRecord.workspaceId !== apiKeyWorkspaceId) { - return v2Error('NOT_FOUND', 'Workflow not found') - } - if (apiKeyType === 'personal' && workflowRecord.workspaceId) { - const settings = await getWorkspaceBillingSettings(workflowRecord.workspaceId) - if (!settings?.allowPersonalApiKeys) { - return v2Error('FORBIDDEN', 'Personal API keys are not allowed for this workspace') + let result: ExecuteWorkflowServiceResult + if (apiKeyPrincipal) { + result = await executeWorkflowOperation.execute({ + principal: apiKeyPrincipal, + input: { + workflowId, + requestId, + input: body.input ?? {}, + executionId: requestedExecutionId, + includeFileBase64: body.includeFileBase64, + base64MaxBytes: body.base64MaxBytes, + selectedOutputs: body.selectedOutputs, + requestedTimeoutSeconds: body.executionTimeoutSeconds, + abortSignal: req.signal, + mode: body.async ? 'async' : body.stream ? 'stream' : 'sync', + requestHeaders: req.headers, + includeThinking: body.includeThinking, + includeToolCalls: body.includeToolCalls, + }, + request: req, + }) + } else { + const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'read', + }) + // Mask authorization failures as 404 so cross-workspace existence never leaks. + if (!workflowAuthorization.allowed || !workflowAuthorization.workflow) { + return v2Error('NOT_FOUND', 'Workflow not found') } + result = await executeWorkflowService({ + workflowId, + userId, + input: body.input ?? {}, + triggerType: 'api', + requestId, + workflowRecord: workflowAuthorization.workflow, + includeFileBase64: body.includeFileBase64, + base64MaxBytes: body.base64MaxBytes, + selectedOutputs: body.selectedOutputs, + rateLimitCounter: 'sync', + abortSignal: req.signal, + mode: body.stream ? 'stream' : 'sync', + requestHeaders: req.headers, + includeThinking: body.includeThinking, + includeToolCalls: body.includeToolCalls, + }) } - const result = await executeWorkflowService({ - workflowId, - userId, - input: body.input ?? {}, - triggerType: 'api', - requestId, - executionId: requestedExecutionId, - useAuthenticatedUserAsActor: apiKeyType === 'personal', - workflowRecord, - includeFileBase64: body.includeFileBase64, - base64MaxBytes: body.base64MaxBytes, - selectedOutputs: body.selectedOutputs, - rateLimitCounter: body.async ? 'async' : 'sync', - requestedTimeoutSeconds: body.executionTimeoutSeconds, - abortSignal: req.signal, - mode: body.async ? 'async' : body.stream ? 'stream' : 'sync', - requestHeaders: req.headers, - includeThinking: body.includeThinking, - includeToolCalls: body.includeToolCalls, - }) - if (!result.ok) { return serviceFailureResponse(result.failure) } @@ -285,6 +305,8 @@ export const POST = withRouteHandler( { headers: { [V2_WORKFLOW_RUN_ID_HEADER]: result.executionId } } ) } catch (error) { + const classified = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render(error) + if (classified) return classified logger.error(`[${requestId}] v2 execute failed`, { workflowId, error: getErrorMessage(error, 'Unknown error'), @@ -293,5 +315,11 @@ export const POST = withRouteHandler( } finally { ticket.release() } + }, + { + unhandledErrorResponse: ({ error }) => + error instanceof V2RouteInfrastructureError + ? v2Error('SERVICE_UNAVAILABLE', 'Service temporarily unavailable') + : v2Error('INTERNAL_ERROR', 'Internal server error'), } ) 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 new file mode 100644 index 00000000000..25e0062241d --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.test.ts @@ -0,0 +1,28 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition) })) + +vi.mock('@/lib/api/server/routes', () => ({ + defineV2JsonRoute: mocks.defineRoute, + v2ApiKeyAuth: { kind: 'v2-api-key' }, + v2RateLimits: { publicApi: { kind: 'public-api' } }, + v2OrchestrationErrorPolicy: { kind: 'orchestration-errors' }, +})) + +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { exportWorkflow } from '@/lib/workflows/application/import-export' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { GET } from '@/app/api/v2/workflows/[id]/export/route' + +describe('/api/v2/workflows/[id]/export route definition', () => { + it('uses canonical workflow authorization with concealment', () => { + expect(GET).toMatchObject({ + operation: workflowOperations.export, + useCase: exportWorkflow, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + }) + }) +}) 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 113b19fd3fa..d4011a4f64c 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.ts @@ -1,77 +1,30 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' import { v2ExportWorkflowContract } from '@/lib/api/contracts/v2/workflows' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { folderPathForId } from '@/app/api/v2/lib/folders' -import { v2Data, v2Error } from '@/app/api/v2/lib/response' - -const logger = createLogger('V2WorkflowExportAPI') +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { exportWorkflow } from '@/lib/workflows/application/import-export' +import { workflowOperations } from '@/lib/workflows/application/operations' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * GET /api/v2/workflows/[id]/export - * - * Exports a workflow as a portable JSON envelope that - * `POST /api/v2/workflows/import` accepts verbatim. Payload assembly and the - * sanitization guarantees are documented on the shared - * {@link buildWorkflowExportPayload}; this route authenticates and renders the - * v2 envelope. - */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ExportWorkflowContract, - rateLimitEndpoint: 'workflow-export', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - const { id } = input.params - - logger.info(`[${requestId}] Exporting workflow ${id}`, { userId }) - - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') - - const payload = await buildWorkflowExportPayload(workflowData) - if (!payload) return v2Error('NOT_FOUND', 'Workflow state not found') - const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') - const folderPath = folderPathForId(folderIndex, workflowData.folderId) - - recordAudit({ - workspaceId: workflowData.workspaceId, - actorId: userId, - action: AuditAction.WORKFLOW_EXPORTED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowData.id, - resourceName: workflowData.name, - description: `Exported workflow "${workflowData.name}" via the API`, - metadata: { - workspaceId: workflowData.workspaceId, + auth: v2ApiKeyAuth, + operation: workflowOperations.export, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ workflowId: params.id }), + useCase: exportWorkflow, + present: ({ payload, folderPath }) => ({ + data: { + ...payload, + workflow: { + id: payload.workflow.id, + name: payload.workflow.name, + description: payload.workflow.description, + workspaceId: payload.workflow.workspaceId, folderPath, - blocksCount: Object.keys(payload.state.blocks).length, - edgesCount: payload.state.edges.length, - }, - request, - }) - - return v2Data( - { - ...payload, - workflow: { - id: payload.workflow.id, - name: payload.workflow.name, - description: payload.workflow.description, - workspaceId: payload.workflow.workspaceId, - folderPath, - }, }, - { rateLimit } - ) - }, + }, + }), }) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts new file mode 100644 index 00000000000..34d98dd6f1e --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + defineRoute: vi.fn((definition) => definition), + capture: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes', () => ({ + defineV2JsonRoute: mocks.defineRoute, + v2ApiKeyAuth: { kind: 'v2-api-key' }, + v2RateLimits: { publicApi: { kind: 'public-api' } }, + v2OrchestrationErrorPolicy: { kind: 'orchestration-errors' }, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { activateWorkflowVersion } from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { POST } from '@/app/api/v2/workflows/[id]/rollback/route' + +describe('/api/v2/workflows/[id]/rollback route definition', () => { + it('keeps an omitted rollback body valid and delegates version selection to the use case', async () => { + expect(v2RollbackWorkflowContract.body?.parse(undefined)).toEqual({}) + expect(POST).toMatchObject({ + operation: workflowOperations.activateVersion, + useCase: activateWorkflowVersion, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + parseOptions: { optionalJsonBody: true }, + }) + expect(Reflect.get(POST, 'mapInput')({ params: { id: 'workflow-1' }, body: {} })).toEqual( + expect.objectContaining({ + workflowId: 'workflow-1', + version: undefined, + transition: 'rollback', + }) + ) + + const invalidJsonResponse = Reflect.get( + Reflect.get(POST, 'parseOptions'), + 'invalidJsonResponse' + )() + expect(invalidJsonResponse.status).toBe(400) + expect(await invalidJsonResponse.json()).toEqual({ + error: { code: 'BAD_REQUEST', message: 'Request body must be valid JSON' }, + }) + + const payloadTooLargeResponse = Reflect.get( + Reflect.get(POST, 'parseOptions'), + 'payloadTooLargeResponse' + )() + expect(payloadTooLargeResponse.status).toBe(413) + expect(await payloadTooLargeResponse.json()).toEqual({ + error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' }, + }) + }) + + it('presents the full declared rollback lifecycle response', () => { + const body = Reflect.get( + POST, + 'present' + )({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + deployedAt: new Date('2026-01-01T00:00:00.000Z'), + version: 1, + warnings: [], + activeDeployment: null, + latestDeploymentAttempt: null, + }) + expect(body.data.isDeployed).toBe(false) + expect(v2RollbackWorkflowContract.response.schema.parse(body)).toEqual(body) + }) + + it('keeps activation analytics on the v2 adapter', async () => { + await Reflect.get( + POST, + 'onSuccess' + )({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + result: { workflowId: 'workflow-1', workspaceId: 'workspace-1', version: 1 }, + }) + expect(mocks.capture).toHaveBeenCalledWith( + 'user-1', + 'deployment_version_activated', + { workflow_id: 'workflow-1', workspace_id: 'workspace-1', version: 1 }, + { groups: { workspace: 'workspace-1' } } + ) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts index 09fc344c878..42c1ef2e58e 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -1,104 +1,58 @@ -import { createLogger } from '@sim/logger' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' -import { v1RollbackWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows' -import { parseOptionalJsonBody } from '@/lib/api/server' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { generateRequestId } from '@/lib/core/utils/request' import { captureServerEvent } from '@/lib/posthog/server' -import { performActivateVersion } from '@/lib/workflows/orchestration' -import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' -import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' - -const logger = createLogger('V2WorkflowRollbackAPI') +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { activateWorkflowVersion } from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 120 -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2RollbackWorkflowContract, - rateLimitEndpoint: 'workflow-rollback', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - try { - const { id } = input.params - - const rawBody = await parseOptionalJsonBody(request) - if (!rawBody.success) { - return rawBody.response.status === 413 - ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') - : v2Error('BAD_REQUEST', 'Request body must be valid JSON') - } - const body = v1RollbackWorkflowBodySchema.safeParse(rawBody.data ?? {}) - if (!body.success) return v2ValidationError(body.error) - - const target = await resolveV2WorkflowTarget(rateLimit, userId, id, 'admin') - if (!target) return v2Error('NOT_FOUND', 'Workflow not found') - const { workflow, workspaceId } = target - - if (!workflow.isDeployed) { - return v2Error('BAD_REQUEST', 'Workflow is not deployed') - } - - await assertWorkflowMutable(id) - - let targetVersion = body.data.version - if (targetVersion === undefined) { - const previous = await findPreviousDeploymentVersion(id) - if (!previous.ok) { - const message = - previous.reason === 'no_active_version' - ? 'Workflow has no active deployment to roll back from' - : 'No previous deployment version to roll back to' - return v2Error('BAD_REQUEST', message) - } - targetVersion = previous.version - } - - logger.info( - `[${requestId}] Rolling back workflow ${id} to version ${targetVersion} via v2 API`, - { userId } - ) - - const result = await performActivateVersion({ - workflowId: id, - version: targetVersion, - userId, - requestId, - }) - - if (!result.success) { - const code = - result.errorCode === 'not_found' - ? 'NOT_FOUND' - : result.errorCode === 'validation' - ? 'BAD_REQUEST' - : 'INTERNAL_ERROR' - return v2Error(code, result.error || 'Failed to roll back workflow') - } - - captureServerEvent( - userId, - 'deployment_version_activated', - { workflow_id: id, workspace_id: workspaceId, version: targetVersion }, - { groups: { workspace: workspaceId } } - ) - - return v2Data( - { - id, - isDeployed: true, - deployedAt: result.deployedAt?.toISOString() ?? null, - version: targetVersion, - warnings: result.warnings ?? [], - }, - { rateLimit } - ) - } catch (error) { - if (error instanceof WorkflowLockedError) { - return v2Error('LOCKED', error.message) - } - throw error + auth: v2ApiKeyAuth, + operation: workflowOperations.activateVersion, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + parseOptions: { + optionalJsonBody: true, + invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), + payloadTooLargeResponse: () => v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large'), + }, + mapInput: ({ params, body }) => ({ + workflowId: params.id, + version: body.version, + transition: 'rollback' as const, + requestId: generateRequestId(), + }), + useCase: activateWorkflowVersion, + present: (result) => ({ + data: { + id: result.workflowId, + isDeployed: Boolean(result.activeDeployment), + deployedAt: result.deployedAt?.toISOString() ?? null, + version: result.version, + warnings: result.warnings ?? [], + activeDeployment: result.activeDeployment ?? null, + latestDeploymentAttempt: result.latestDeploymentAttempt ?? null, + }, + }), + onSuccess: ({ principal, result }) => { + if (principal.kind !== 'personal_api_key') { + throw new Error('Admin activation unexpectedly admitted a workspace API key') } + captureServerEvent( + principal.userId, + 'deployment_version_activated', + { + workflow_id: result.workflowId, + workspace_id: result.workspaceId, + version: result.version, + }, + { groups: { workspace: result.workspaceId } } + ) }, }) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/route.test.ts index 33de9864022..0d828ae87f0 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.test.ts @@ -1,348 +1,182 @@ /** * @vitest-environment node - * - * Public v2 workflow update/delete: the 404 mask on an access failure (the - * caller never names a workspace, so a 403 would confirm the workflow exists), - * the 423 a workflow mutation lock produces, and the orchestration failure - * codes rendered in the v2 error envelope. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetActiveWorkflowRecord, - mockPerformUpdateWorkflow, - mockPerformDeleteWorkflow, - mockAssertWorkflowMutable, - mockAssertFolderMutable, - mockLoadActiveFolderPathIndex, - WorkflowLockedErrorMock, - FolderLockedErrorMock, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetActiveWorkflowRecord: vi.fn(), - mockPerformUpdateWorkflow: vi.fn(), - mockPerformDeleteWorkflow: vi.fn(), - mockAssertWorkflowMutable: vi.fn(), - mockAssertFolderMutable: vi.fn(), - mockLoadActiveFolderPathIndex: vi.fn(), - WorkflowLockedErrorMock: class WorkflowLockedError extends Error { - status = 423 - }, - FolderLockedErrorMock: class FolderLockedError extends Error { - status = 423 - }, +const mocks = vi.hoisted(() => ({ + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + readWorkflow: vi.fn(), + updateWorkflow: vi.fn(), + deleteWorkflow: vi.fn(), + gate: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/workflows/application/read-workflow', () => ({ + readWorkflow: { operation: { id: 'workflows.read' }, execute: mocks.readWorkflow }, })) - -vi.mock('@/lib/workflows/orchestration', () => ({ - performUpdateWorkflow: mockPerformUpdateWorkflow, - performDeleteWorkflow: mockPerformDeleteWorkflow, -})) - -vi.mock('@sim/platform-authz/workflow', () => ({ - getActiveWorkflowRecord: mockGetActiveWorkflowRecord, - assertWorkflowMutable: mockAssertWorkflowMutable, - assertFolderMutable: mockAssertFolderMutable, - WorkflowLockedError: WorkflowLockedErrorMock, - FolderLockedError: FolderLockedErrorMock, +vi.mock('@/lib/workflows/application/update-workflow', () => ({ + updateWorkflow: { operation: { id: 'workflows.update' }, execute: mocks.updateWorkflow }, })) - -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +vi.mock('@/lib/workflows/application/delete-workflow', () => ({ + deleteWorkflow: { operation: { id: 'workflows.delete' }, execute: mocks.deleteWorkflow }, })) - -vi.mock('@/lib/workflows/input-format', () => ({ - extractInputFieldsFromBlocks: vi.fn().mockReturnValue([]), +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkRateLimitDirect + checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow + }, })) - -import { DELETE, PATCH } from '@/app/api/v2/workflows/[id]/route' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} - -const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } - -const WORKFLOW_RECORD = { - id: 'wf-1', - name: 'Support Agent', - description: 'Handles tickets', +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) + +import { + InsufficientWorkspacePermissionsError, + PersonalApiKeysDisabledError, +} from '@/lib/core/application' +import { DELETE, GET, PATCH } from '@/app/api/v2/workflows/[id]/route' + +const WORKSPACE_ID = 'workspace-1' +const WORKFLOW_ID = 'workflow-1' +const workflow = { + id: WORKFLOW_ID, + name: 'Daily digest', + description: null, + workspaceId: WORKSPACE_ID, folderId: null, - workspaceId: 'workspace-1', + variables: {}, isDeployed: true, - deployedAt: new Date('2024-01-03T00:00:00Z'), - runCount: 12, - lastRunAt: new Date('2024-01-04T00:00:00Z'), - locked: false, - forkSyncExcluded: false, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), -} - -const UPDATED = { - id: 'wf-1', - name: 'Support Agent v2', - description: 'Handles tickets', - workspaceId: 'workspace-1', - folderId: null, - sortOrder: 0, - locked: false, - forkSyncExcluded: false, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-05T00:00:00Z'), - archivedAt: null, + deployedAt: new Date('2026-08-03T00:00:00.000Z'), + runCount: 4, + lastRunAt: new Date('2026-08-04T00:00:00.000Z'), + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-02T00:00:00.000Z'), } - -const routeContext = () => ({ params: Promise.resolve({ id: 'wf-1' }) }) - -function callPatch(body: unknown) { - return PATCH( - new NextRequest('http://localhost:3000/api/v2/workflows/wf-1', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - routeContext() - ) +const auth = { + principal: { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'personal-key-1', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, } +const routeContext = { params: Promise.resolve({ id: WORKFLOW_ID }) } -const callDelete = () => - DELETE( - new NextRequest('http://localhost:3000/api/v2/workflows/wf-1', { method: 'DELETE' }), - routeContext() - ) - -describe('PATCH /api/v2/workflows/[id]', () => { +describe('/api/v2/workflows/[id]', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) - mockAssertWorkflowMutable.mockResolvedValue(undefined) - mockAssertFolderMutable.mockResolvedValue(undefined) - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map([['fld-1', { id: 'fld-1', name: 'Locked', parentId: null }]]), - pathById: new Map([['fld-1', '/Locked']]), - idByPath: new Map([['/Locked', 'fld-1']]), + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-01T01:00:00.000Z'), }) - mockPerformUpdateWorkflow.mockResolvedValue({ success: true, workflow: UPDATED }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callPatch({ name: 'Support Agent v2' }) - - expect(res.status).toBe(404) - expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() - }) - - it('400s when no field to change is supplied', async () => { - const res = await callPatch({}) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() - }) - - it('masks an access-denied failure as 404 so existence is not leaked', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callPatch({ name: 'Support Agent v2' }) - expect(res.status).toBe(404) - expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callPatch({ name: 'Support Agent v2' }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('404s when the workflow does not exist or is archived', async () => { - mockGetActiveWorkflowRecord.mockResolvedValue(null) - const res = await callPatch({ name: 'Support Agent v2' }) - expect(res.status).toBe(404) - expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() - }) - - it('423s the denial when the workflow is locked rather than failing with a 500', async () => { - mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedErrorMock('Workflow is locked')) - const res = await callPatch({ name: 'Support Agent v2' }) - expect(res.status).toBe(423) - expect((await res.json()).error.code).toBe('LOCKED') - expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() - }) - - it('423s when the destination folder is locked', async () => { - mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) - const res = await callPatch({ folderPath: '/Locked' }) - expect(res.status).toBe(423) - expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() - }) - - it('404s a path outside the workspace without ever reading its lock state', async () => { - const res = await callPatch({ folderPath: '/Elsewhere' }) - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockAssertFolderMutable).not.toHaveBeenCalled() - expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() - }) - - it('resolves the canonical path against the workflow workspace before mutability', async () => { - await callPatch({ folderPath: '/Locked' }) - - expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith( - 'workspace-1', - 'workflow', - expect.any(Object) - ) - expect(mockAssertFolderMutable).toHaveBeenCalledWith('fld-1') - }) - - it('skips the containment check on a rename that does not move the workflow', async () => { - await callPatch({ name: 'Support Agent v2' }) - expect(mockAssertFolderMutable).not.toHaveBeenCalled() - }) - - it('409s when the target name is taken in the destination folder', async () => { - mockPerformUpdateWorkflow.mockResolvedValue({ - success: false, - error: 'A workflow named "Support Agent v2" already exists in this folder', - errorCode: 'conflict', + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-01T01:00:00.000Z'), }) - const res = await callPatch({ name: 'Support Agent v2' }) - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - }) - - it('updates the workflow and carries the untouched deployment counters through', async () => { - const res = await callPatch({ name: 'Support Agent v2' }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body).toEqual({ - data: { - id: 'wf-1', - name: 'Support Agent v2', - description: 'Handles tickets', - folderPath: '/', - workspaceId: 'workspace-1', + mocks.readWorkflow.mockResolvedValue({ + workflow, + workspaceId: WORKSPACE_ID, + folderPath: '/', + inputs: [], + }) + mocks.updateWorkflow.mockResolvedValue({ + workflow: { ...workflow, name: 'Weekly digest' }, + workspaceId: WORKSPACE_ID, + folderPath: '/', + deployment: { isDeployed: true, - deployedAt: '2024-01-03T00:00:00.000Z', - runCount: 12, - lastRunAt: '2024-01-04T00:00:00.000Z', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-05T00:00:00.000Z', + deployedAt: workflow.deployedAt, + runCount: 4, + lastRunAt: workflow.lastRunAt, }, }) - expect(mockPerformUpdateWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ - workflowId: 'wf-1', - userId: 'user-1', - workspaceId: 'workspace-1', - currentName: 'Support Agent', - currentFolderId: null, - name: 'Support Agent v2', - }) - ) - }) -}) - -describe('DELETE /api/v2/workflows/[id]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) - mockAssertWorkflowMutable.mockResolvedValue(undefined) - mockPerformDeleteWorkflow.mockResolvedValue({ success: true }) + mocks.deleteWorkflow.mockResolvedValue({ workflowId: WORKFLOW_ID }) }) - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + it('presents the authorized canonical workflow detail', async () => { + const request = new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}`) + const response = await GET(request, routeContext) - const res = await callDelete() - - expect(res.status).toBe(404) - expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect((await response.json()).data).toMatchObject({ + id: WORKFLOW_ID, + workspaceId: WORKSPACE_ID, + folderPath: '/', + inputs: [], + }) + expect(mocks.readWorkflow).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workflowId: WORKFLOW_ID }, + request, + }) }) - it('masks an access-denied failure as 404 so existence is not leaked', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callDelete() - expect(res.status).toBe(404) - expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() - }) + it('conceals typed insufficient authorization as workflow absence', async () => { + mocks.readWorkflow.mockRejectedValue(new InsufficientWorkspacePermissionsError()) + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}`), + routeContext + ) - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callDelete() - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ + error: { code: 'NOT_FOUND', message: 'Workflow not found' }, + }) }) - it('404s when the workflow does not exist or is already archived', async () => { - mockGetActiveWorkflowRecord.mockResolvedValue(null) - const res = await callDelete() - expect(res.status).toBe(404) - expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() - }) + it('preserves the personal-key-disabled 403 instead of concealing it', async () => { + mocks.readWorkflow.mockRejectedValue(new PersonalApiKeysDisabledError()) + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}`), + routeContext + ) - it('423s the denial when the workflow is locked rather than failing with a 500', async () => { - mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedErrorMock('Workflow is locked')) - const res = await callDelete() - expect(res.status).toBe(423) - expect((await res.json()).error.code).toBe('LOCKED') - expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled() + expect(response.status).toBe(403) + expect((await response.json()).error.code).toBe('FORBIDDEN') }) - it('400s when it is the last workflow in the workspace', async () => { - mockPerformDeleteWorkflow.mockResolvedValue({ - success: false, - error: 'Cannot delete the only workflow in the workspace', - errorCode: 'validation', + it('updates only through the shared semantic use case', async () => { + const request = new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Weekly digest' }), + }) + const response = await PATCH(request, routeContext) + + expect(response.status).toBe(200) + expect((await response.json()).data.name).toBe('Weekly digest') + expect(mocks.updateWorkflow).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workflowId: WORKFLOW_ID, name: 'Weekly digest' }, + request, }) - const res = await callDelete() - expect(res.status).toBe(400) - expect((await res.json()).error.message).toContain('only workflow') }) - it('archives the workflow and acknowledges the delete', async () => { - const res = await callDelete() - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ data: { id: 'wf-1', deleted: true } }) - expect(mockPerformDeleteWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ workflowId: 'wf-1', userId: 'user-1' }) - ) + it('deletes through the shared use case and preserves the response contract', async () => { + const request = new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}`, { + method: 'DELETE', + }) + const response = await DELETE(request, routeContext) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: WORKFLOW_ID, deleted: true } }) + expect(mocks.deleteWorkflow).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workflowId: WORKFLOW_ID }, + request, + }) }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index f2f77b0f8e7..43c40be983f 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -1,192 +1,76 @@ import { - assertFolderMutable, - assertWorkflowMutable, - FolderLockedError, - getActiveWorkflowRecord, - WorkflowLockedError, -} from '@sim/platform-authz/workflow' -import { - type V2WorkflowDetail, - type V2WorkflowListItem, v2DeleteWorkflowContract, v2GetWorkflowContract, v2UpdateWorkflowContract, } from '@/lib/api/contracts/v2/workflows' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' -import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' -import { loadWorkflowReadSnapshot } from '@/lib/workflows/queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { v2Data, v2Error, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { deleteWorkflow } from '@/lib/workflows/application/delete-workflow' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { readWorkflow } from '@/lib/workflows/application/read-workflow' +import { updateWorkflow } from '@/lib/workflows/application/update-workflow' export const revalidate = 0 -interface RouteContext { - params: Promise<{ id: string }> -} - -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetWorkflowContract, - rateLimitEndpoint: 'workflow-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { id } = input.params - - const snapshot = await loadWorkflowReadSnapshot(id) - const workflowData = snapshot.workflowRecord - if (!workflowData?.workspaceId || workflowData.archivedAt) { - return v2Error('NOT_FOUND', 'Workflow not found') - } - - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') - - const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') - const inputs = extractInputFieldsFromBlocks(snapshot.normalizedData?.blocks ?? {}) - - const detail: V2WorkflowDetail = { - id: workflowData.id, - name: workflowData.name, - description: workflowData.description, - folderPath: folderPathForId(folderIndex, workflowData.folderId), - workspaceId: workflowData.workspaceId, - isDeployed: workflowData.isDeployed, - deployedAt: workflowData.deployedAt?.toISOString() ?? null, - runCount: workflowData.runCount, - lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, - variables: (workflowData.variables as Record<string, unknown> | null) ?? {}, + auth: v2ApiKeyAuth, + operation: workflowOperations.read, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ workflowId: params.id }), + useCase: readWorkflow, + present: ({ workflow, workspaceId, folderPath, inputs }) => ({ + data: { + id: workflow.id, + name: workflow.name, + description: workflow.description, + folderPath, + workspaceId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt?.toISOString() ?? null, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt?.toISOString() ?? null, + variables: (workflow.variables as Record<string, unknown> | null) ?? {}, inputs, - createdAt: workflowData.createdAt.toISOString(), - updatedAt: workflowData.updatedAt.toISOString(), - } - - return v2Data(detail, { rateLimit }) - }, + createdAt: workflow.createdAt.toISOString(), + updatedAt: workflow.updatedAt.toISOString(), + }, + }), }) -/** PATCH /api/v2/workflows/[id] — Rename, re-describe, or move a workflow. */ -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2UpdateWorkflowContract, - rateLimitEndpoint: 'workflow-detail', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { id } = input.params - const { name, description, folderPath } = input.body - - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess( - rateLimit, - userId, - workflowData.workspaceId, - 'write' - ) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') - - const resolution = - folderPath === undefined - ? undefined - : await resolveFolderPathIdentity({ - workspaceId: workflowData.workspaceId, - resourceType: 'workflow', - path: folderPath, - }) - if (resolution && !resolution.found) { - return v2Error('NOT_FOUND', 'Folder not found') - } - - const folderId = resolution?.folderId - await assertWorkflowMutable(id) - if (folderId !== undefined) await assertFolderMutable(folderId) - - const result = await performUpdateWorkflow({ - workflowId: id, - userId, - workspaceId: workflowData.workspaceId, - currentName: workflowData.name, - currentFolderId: workflowData.folderId, - name, - description, - folderId, - requestId, - }) - - if (!result.success || !result.workflow) { - return v2ErrorForOrchestration( - result.errorCode, - result.error ?? 'Failed to update workflow' - ) - } - - const updated = result.workflow - const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') - /** - * Deployment and run counters are untouched by a metadata update, so they - * come from the record read above rather than a second query. - */ - const item: V2WorkflowListItem = { - id: updated.id, - name: updated.name, - description: updated.description, - folderPath: folderPathForId(folderIndex, updated.folderId), - workspaceId: updated.workspaceId ?? workflowData.workspaceId, - isDeployed: workflowData.isDeployed, - deployedAt: workflowData.deployedAt?.toISOString() ?? null, - runCount: workflowData.runCount, - lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, - createdAt: updated.createdAt.toISOString(), - updatedAt: updated.updatedAt.toISOString(), - } - - return v2Data(item, { rateLimit }) - } catch (error) { - if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { - return v2Error('LOCKED', error.message) - } - - throw error - } - }, + auth: v2ApiKeyAuth, + operation: workflowOperations.update, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params, body }) => ({ workflowId: params.id, ...body }), + useCase: updateWorkflow, + present: ({ workflow, workspaceId, folderPath, deployment }) => ({ + data: { + id: workflow.id, + name: workflow.name, + description: workflow.description, + folderPath, + workspaceId, + isDeployed: deployment.isDeployed, + deployedAt: deployment.deployedAt?.toISOString() ?? null, + runCount: deployment.runCount, + lastRunAt: deployment.lastRunAt?.toISOString() ?? null, + createdAt: workflow.createdAt.toISOString(), + updatedAt: workflow.updatedAt.toISOString(), + }, + }), }) -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteWorkflowContract, - rateLimitEndpoint: 'workflow-detail', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { id } = input.params - - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess( - rateLimit, - userId, - workflowData.workspaceId, - 'write' - ) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') - - await assertWorkflowMutable(id) - - const result = await performDeleteWorkflow({ workflowId: id, userId, requestId }) - if (!result.success) { - return v2ErrorForOrchestration( - result.errorCode, - result.error ?? 'Failed to delete workflow' - ) - } - - return v2Data({ id, deleted: true as const }, { rateLimit }) - } catch (error) { - if (error instanceof WorkflowLockedError) return v2Error('LOCKED', error.message) - - throw error - } - }, + auth: v2ApiKeyAuth, + operation: workflowOperations.delete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ workflowId: params.id }), + useCase: deleteWorkflow, + present: ({ workflowId }) => ({ data: { id: workflowId, deleted: true as const } }), }) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts index 8e0f76b136f..5418355124d 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/cancel/route.ts @@ -1,61 +1,39 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CancelWorkflowRunContract } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - cancelWorkflowExecution, - WorkflowExecutionNotFoundError, -} from '@/lib/execution/cancel-workflow-execution' -import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' -import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' - -const logger = createLogger('V2CancelRunAPI') +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { captureServerEvent } from '@/lib/posthog/server' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { cancelWorkflowRun } from '@/lib/workflows/application/cancel-run' +import { workflowOperations } from '@/lib/workflows/application/operations' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string; runId: string }> }) => { - const parsed = await parseRequest(v2CancelWorkflowRunContract, req, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { id: workflowId, runId } = parsed.data.params - - const access = await resolveV2WorkflowAccess(req, workflowId, 'write') - if (!access.ok) return access.response - - try { - logger.info('Cancel run requested', { workflowId, runId, userId: access.userId }) - - const result = await cancelWorkflowExecution({ - executionId: runId, - workflowId, - userId: access.userId, - workspaceId: access.workflow.workspaceId ?? undefined, - }) - - return v2Data({ - success: result.success, - runId: result.executionId, - redisAvailable: result.redisAvailable, - durablyRecorded: result.durablyRecorded, - locallyAborted: result.locallyAborted, - pausedCancelled: result.pausedCancelled, - reason: result.reason, - }) - } catch (error) { - if (error instanceof WorkflowExecutionNotFoundError) { - return v2Error('NOT_FOUND', error.message) - } - logger.error('Failed to cancel run', { - workflowId, - runId, - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) +export const POST = defineV2JsonRoute({ + contract: v2CancelWorkflowRunContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.cancelRun, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealRunAuthorization, + mapInput: ({ params }) => ({ workflowId: params.id, runId: params.runId }), + useCase: cancelWorkflowRun, + present: (result) => ({ + data: { + success: result.success, + runId: result.executionId, + redisAvailable: result.redisAvailable, + durablyRecorded: result.durablyRecorded, + locallyAborted: result.locallyAborted, + pausedCancelled: result.pausedCancelled, + reason: result.reason, + }, + }), + onSuccess: ({ principal, result }) => { + if (!result.success || principal.kind !== 'personal_api_key') return + captureServerEvent( + principal.userId, + 'workflow_execution_cancelled', + { workflow_id: result.workflowId, workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } + ) + }, +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts index 82d3324c6bd..84430febc76 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.test.ts @@ -4,28 +4,44 @@ import { NextRequest, NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockHandleResumeExecution, mockResolveV2WorkflowAccess } = vi.hoisted(() => ({ - mockHandleResumeExecution: vi.fn(), - mockResolveV2WorkflowAccess: vi.fn(), +const mocks = vi.hoisted(() => ({ + admit: vi.fn(), + resume: vi.fn(), })) -vi.mock('@/app/api/resume/resume-handler', () => ({ - handleResumeExecution: mockHandleResumeExecution, +vi.mock('@/lib/api/server/routes', () => { + class V2RouteInfrastructureError extends Error {} + return { + admitV2Request: mocks.admit, + V2RouteInfrastructureError, + v2ApiKeyAuth: { kind: 'v2-api-key' }, + v2RateLimits: { publicApi: { kind: 'public-api' } }, + v2OrchestrationErrorPolicy: { render: () => null }, + } +}) + +vi.mock('@/lib/workflows/application/resume-run', () => ({ + resumeWorkflowRun: { execute: mocks.resume }, })) -vi.mock('@/app/api/v2/workflows/lib/access', () => ({ - resolveV2WorkflowAccess: mockResolveV2WorkflowAccess, +vi.mock('@/lib/workflows/executor/resume-execution', () => ({ + ResumeWorkflowExecutionError: class ResumeWorkflowExecutionError extends Error {}, })) vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://test.sim.ai', + SITE_URL: 'https://test.sim.ai', })) import { v2ResumeWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { PersonalApiKeysDisabledError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { workflowOperations } from '@/lib/workflows/application/operations' import { POST } from '@/app/api/v2/workflows/[id]/runs/[runId]/resume/route' const WORKFLOW_ID = 'workflow-1' const RUN_ID = 'run-1' +const principal = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' } function makeRequest(body: string) { return { @@ -44,17 +60,12 @@ function makeRequest(body: string) { describe('POST /api/v2/workflows/[id]/runs/[runId]/resume', () => { beforeEach(() => { vi.clearAllMocks() - mockResolveV2WorkflowAccess.mockResolvedValue({ - ok: true, - userId: 'user-1', - keyType: 'workspace', - workflow: { id: WORKFLOW_ID, workspaceId: 'workspace-1' }, - }) + mocks.admit.mockResolvedValue({ success: true, auth: { principal } }) }) - it('authenticates before parsing the request body', async () => { - mockResolveV2WorkflowAccess.mockResolvedValueOnce({ - ok: false, + it('runs v2 admission before parsing the bounded request body', async () => { + mocks.admit.mockResolvedValueOnce({ + success: false, response: NextResponse.json( { error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } }, { status: 401 } @@ -68,23 +79,41 @@ describe('POST /api/v2/workflows/[id]/runs/[runId]/resume', () => { expect(await response.json()).toEqual({ error: { code: 'UNAUTHORIZED', message: 'Unauthorized' }, }) - expect(mockResolveV2WorkflowAccess).toHaveBeenCalledWith(request, WORKFLOW_ID, 'write') - expect(mockHandleResumeExecution).not.toHaveBeenCalled() + expect(mocks.admit).toHaveBeenCalledOnce() + expect(mocks.admit).toHaveBeenCalledWith( + request, + workflowOperations.resumeRun, + { kind: 'v2-api-key' }, + { kind: 'public-api' } + ) + expect(mocks.resume).not.toHaveBeenCalled() }) - it('resumes a pause context through the run-scoped v2 endpoint', async () => { - mockHandleResumeExecution.mockResolvedValueOnce( - NextResponse.json( - { - success: true, - async: true, - executionId: 'resume-execution-1', - message: 'Resume execution queued', - statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/runs/resume-execution-1', - }, - { status: 202 } - ) + it('stops at request-rate admission without invoking resume execution controls', async () => { + mocks.admit.mockResolvedValueOnce({ + success: false, + response: NextResponse.json( + { error: { code: 'RATE_LIMITED', message: 'Rate limit exceeded' } }, + { status: 429, headers: { 'Retry-After': '7' } } + ), + }) + const { request, context } = makeRequest( + JSON.stringify({ contextId: 'context-1', input: { approved: true } }) ) + + const response = await POST(request, context) + + expect(response.status).toBe(429) + expect(response.headers.get('Retry-After')).toBe('7') + expect(mocks.resume).not.toHaveBeenCalled() + }) + + it('resumes through the authorized run use case and returns a polling receipt', async () => { + mocks.resume.mockResolvedValueOnce({ + kind: 'async', + executionId: 'resume-execution-1', + jobId: 'resume-job-1', + }) const { request, context } = makeRequest( JSON.stringify({ contextId: 'context-1', input: { approved: true } }) ) @@ -101,57 +130,54 @@ describe('POST /api/v2/workflows/[id]/runs/[runId]/resume', () => { }, }) expect(v2ResumeWorkflowContract.response.schema.parse(body)).toEqual(body) - expect(mockHandleResumeExecution).toHaveBeenCalledWith({ + expect(mocks.resume).toHaveBeenCalledWith({ + principal, + input: { + workflowId: WORKFLOW_ID, + runId: RUN_ID, + contextId: 'context-1', + resumeInput: { approved: true }, + }, request, - workflowId: WORKFLOW_ID, - executionId: RUN_ID, - contextId: 'context-1', - workspaceId: 'workspace-1', - userId: 'user-1', - resumeInput: { approved: true }, - isApiCaller: true, - pollingSurface: 'v2', - allowStreaming: false, }) }) - it('returns queued resumes as a v2 polling receipt', async () => { - mockHandleResumeExecution.mockResolvedValueOnce( - NextResponse.json({ - status: 'queued', - executionId: 'resume-execution-2', - queuePosition: 2, - message: 'Resume queued. It will run after current resumes finish.', - }) - ) + it('returns queued resumes as the declared v2 receipt', async () => { + mocks.resume.mockResolvedValueOnce({ + kind: 'queued', + executionId: 'resume-execution-2', + queuePosition: 2, + }) const { request, context } = makeRequest(JSON.stringify({ contextId: 'context-2' })) const response = await POST(request, context) + const body = await response.json() expect(response.status).toBe(202) - expect(await response.json()).toEqual({ + expect(body).toEqual({ data: { runId: 'resume-execution-2', statusUrl: 'https://test.sim.ai/api/v2/workflows/workflow-1/runs/resume-execution-2', queuePosition: 2, }, }) + expect(v2ResumeWorkflowContract.response.schema.parse(body)).toEqual(body) }) it('wraps synchronous resume results in the canonical v2 run shape', async () => { - mockHandleResumeExecution.mockResolvedValueOnce( - NextResponse.json({ - success: true, - status: 'completed', - executionId: 'resume-execution-3', - output: { approved: true }, - metadata: { - startTime: '2026-08-05T00:00:00.000Z', - endTime: '2026-08-05T00:00:01.000Z', - duration: 1000, - }, - }) - ) + mocks.resume.mockResolvedValueOnce({ + kind: 'sync', + success: true, + status: 'completed', + executionId: 'resume-execution-3', + output: { approved: true }, + error: undefined, + metadata: { + startTime: '2026-08-05T00:00:00.000Z', + endTime: '2026-08-05T00:00:01.000Z', + duration: 1000, + }, + }) const { request, context } = makeRequest(JSON.stringify({ contextId: 'context-3' })) const response = await POST(request, context) @@ -172,4 +198,39 @@ describe('POST /api/v2/workflows/[id]/runs/[runId]/resume', () => { }) expect(v2ResumeWorkflowContract.response.schema.parse(body)).toEqual(body) }) + + it('conceals canonical parent-run/workflow mismatches as absence', async () => { + mocks.resume.mockRejectedValueOnce(new OrchestrationError('not_found', 'Run not found')) + const { request, context } = makeRequest(JSON.stringify({ contextId: 'context-4' })) + + const response = await POST(request, context) + + expect(response.status).toBe(404) + expect((await response.json()).error).toMatchObject({ + code: 'NOT_FOUND', + message: 'Run not found', + }) + }) + + it('preserves the personal-key-disabled authorization response as forbidden', async () => { + mocks.resume.mockRejectedValueOnce(new PersonalApiKeysDisabledError()) + const { request, context } = makeRequest(JSON.stringify({ contextId: 'context-5' })) + + const response = await POST(request, context) + + expect(response.status).toBe(403) + expect((await response.json()).error.code).toBe('FORBIDDEN') + }) + + it('returns a safe error when the resume manager fails', async () => { + mocks.resume.mockRejectedValueOnce(new Error('resume database connection details')) + const { request, context } = makeRequest(JSON.stringify({ contextId: 'context-6' })) + + const response = await POST(request, context) + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) + }) }) 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 0021620ccc6..cc0938d8232 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 @@ -1,17 +1,24 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { isRecordLike } from '@sim/utils/object' import type { NextRequest } from 'next/server' import { V2_WORKFLOW_RUN_ID_HEADER, v2ResumeWorkflowContract, } from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' +import { + admitV2Request, + V2RouteInfrastructureError, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { handleResumeExecution } from '@/app/api/resume/resume-handler' +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 { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' import { classifyExecutionError } from '@/executor/utils/errors' const logger = createLogger('V2WorkflowResumeAPI') @@ -34,110 +41,89 @@ const ERROR_CODE_BY_STATUS: Record<number, V2ErrorCode> = { const TERMINAL_RESUME_STATUSES = new Set(['completed', 'failed', 'paused', 'cancelled']) -function errorMessage(payload: Record<string, unknown>): string { - return typeof payload.error === 'string' ? payload.error : 'Resume execution failed' -} - -/** - * POST /api/v2/workflows/[id]/runs/[runId]/resume resumes one pause context on - * the parent run. The new resume attempt gets its own run ID, which is the only - * polling handle exposed by v2. - */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string; runId: string }> }) => { - const { id: workflowId } = await context.params - const access = await resolveV2WorkflowAccess(request, workflowId, 'write') - if (!access.ok) return access.response + const admission = await admitV2Request( + request, + workflowOperations.resumeRun, + v2ApiKeyAuth, + v2RateLimits.publicApi + ) + if (!admission.success) return admission.response const parsed = await parseRequest(v2ResumeWorkflowContract, request, context, { maxBodyBytes: 10 * 1024 * 1024, validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response - const { runId } = parsed.data.params + const { id: workflowId, runId } = parsed.data.params const { contextId, input } = parsed.data.body - if (!access.workflow.workspaceId) { - return v2Error('INTERNAL_ERROR', 'Workflow has no associated workspace') - } - try { - const response = await handleResumeExecution({ + const result = await resumeWorkflowRun.execute({ + principal: admission.auth.principal, + input: { + workflowId, + runId, + contextId, + resumeInput: input === undefined ? {} : input, + }, request, - workflowId, - executionId: runId, - contextId, - workspaceId: access.workflow.workspaceId, - userId: access.userId, - resumeInput: input === undefined ? {} : input, - isApiCaller: true, - pollingSurface: 'v2', - allowStreaming: false, }) - const payload: unknown = await response.json() - if (!isRecordLike(payload)) { - return v2Error('INTERNAL_ERROR', 'Resume execution returned an invalid response') - } - - if (!response.ok) { - return v2Error( - ERROR_CODE_BY_STATUS[response.status] ?? 'INTERNAL_ERROR', - errorMessage(payload), - { status: response.status } - ) - } - - if (typeof payload.executionId !== 'string') { - return v2Error('INTERNAL_ERROR', 'Resume execution did not return a run ID') - } - - const statusUrl = `${getBaseUrl()}/api/v2/workflows/${workflowId}/runs/${payload.executionId}` - const headers = { [V2_WORKFLOW_RUN_ID_HEADER]: payload.executionId } - - if (response.status === 202 || payload.status === 'queued') { + const statusUrl = `${getBaseUrl()}/api/v2/workflows/${workflowId}/runs/${result.executionId}` + const headers = { [V2_WORKFLOW_RUN_ID_HEADER]: result.executionId } + if (result.kind === 'async' || result.kind === 'queued') { return v2Data( { - runId: payload.executionId, + runId: result.executionId, statusUrl, - ...(typeof payload.queuePosition === 'number' - ? { queuePosition: payload.queuePosition } - : {}), + ...(result.kind === 'queued' ? { queuePosition: result.queuePosition } : {}), }, { status: 202, headers } ) } - - if (typeof payload.status !== 'string' || !TERMINAL_RESUME_STATUSES.has(payload.status)) { + if (result.kind !== 'sync' || !TERMINAL_RESUME_STATUSES.has(result.status)) { return v2Error('INTERNAL_ERROR', 'Resume execution returned an invalid status') } - const metadata = isRecordLike(payload.metadata) ? payload.metadata : undefined return v2Data( { - runId: payload.executionId, + runId: result.executionId, workflowId, - status: payload.status as 'completed' | 'failed' | 'paused' | 'cancelled', - output: payload.output ?? null, + status: result.status as 'completed' | 'failed' | 'paused' | 'cancelled', + output: result.output ?? null, error: - typeof payload.error === 'string' - ? classifyExecutionError(new Error(payload.error)) + typeof result.error === 'string' + ? classifyExecutionError(new Error(result.error)) : null, - startedAt: - metadata && typeof metadata.startTime === 'string' ? metadata.startTime : undefined, - endedAt: metadata && typeof metadata.endTime === 'string' ? metadata.endTime : undefined, - durationMs: - metadata && typeof metadata.duration === 'number' ? metadata.duration : undefined, + startedAt: result.metadata?.startTime, + endedAt: result.metadata?.endTime, + durationMs: result.metadata?.duration, }, { headers } ) } catch (error) { + const domainResponse = v2WorkflowErrorPolicies.concealRunAuthorization.render(error) + if (domainResponse) return domainResponse + if (error instanceof ResumeWorkflowExecutionError) { + if (!error.safeForPublicApi) throw error + return v2Error(ERROR_CODE_BY_STATUS[error.statusCode] ?? 'INTERNAL_ERROR', error.message, { + status: error.statusCode, + }) + } logger.error('Failed to resume workflow run', { workflowId, runId, error: getErrorMessage(error, 'Unknown error'), }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } + }, + { + unhandledErrorResponse: ({ error }) => + error instanceof V2RouteInfrastructureError + ? v2Error('SERVICE_UNAVAILABLE', 'Service temporarily unavailable') + : v2Error('INTERNAL_ERROR', 'Internal server error'), } ) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts index 20a8e07fc1a..56ba1b4ba1d 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts @@ -1,46 +1,72 @@ /** * @vitest-environment node */ -import { createMockRequest, workflowAuthzMockFns } from '@sim/testing' +import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAuthenticateV1Request, mockGetWorkflowExecutionStatus, mockCancel } = vi.hoisted( - () => ({ - mockAuthenticateV1Request: vi.fn(), - mockGetWorkflowExecutionStatus: vi.fn(), - mockCancel: vi.fn(), - }) -) +const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { + class MockV2ApiKeyUnauthenticatedError extends Error {} + return { + MockV2ApiKeyUnauthenticatedError, + mocks: { + authenticate: vi.fn(), + cancel: vi.fn(), + capture: vi.fn(), + checkOperationRate: vi.fn(), + checkPreAuthRate: vi.fn(), + readRun: vi.fn(), + }, + } +}) -vi.mock('@/app/api/v1/auth', () => ({ - authenticateV1Request: mockAuthenticateV1Request, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, })) -vi.mock('@/lib/workspaces/utils', () => ({ - getWorkspaceBillingSettings: vi.fn().mockResolvedValue({ allowPersonalApiKeys: true }), +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreAuthRate + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, })) -vi.mock('@/lib/workflows/executor/execution-status', () => ({ - getWorkflowExecutionStatus: mockGetWorkflowExecutionStatus, +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), })) -vi.mock('@/lib/execution/cancel-workflow-execution', () => ({ - cancelWorkflowExecution: mockCancel, -})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/workflows/application/read-workflow-run', () => ({ + readWorkflowRun: { + operation: { id: 'workflows.runs.read' }, + execute: mocks.readRun, + }, })) -import { POST as cancelPost } from './cancel/route' -import { GET } from './route' +vi.mock('@/lib/workflows/application/cancel-run', () => ({ + cancelWorkflowRun: { + operation: { id: 'workflows.runs.cancel' }, + execute: mocks.cancel, + }, +})) -const mockAuthorize = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission +import { InsufficientWorkspacePermissionsError } from '@/lib/core/application' +import { POST as cancelPost } from '@/app/api/v2/workflows/[id]/runs/[runId]/cancel/route' +import { GET } from '@/app/api/v2/workflows/[id]/runs/[runId]/route' -const workflowRecord = { - id: 'workflow-1', - userId: 'owner-1', +const principal = { + kind: 'workspace_api_key' as const, workspaceId: 'workspace-1', + keyId: 'key-1', +} +const auth = { + principal, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } function callStatus(query = '') { @@ -48,84 +74,106 @@ function callStatus(query = '') { 'GET', undefined, {}, - `http://localhost:3000/api/v2/workflows/workflow-1/runs/exec-1${query}` + `http://localhost:3000/api/v2/workflows/workflow-1/runs/run-1${query}` ) - return GET(req, { params: Promise.resolve({ id: 'workflow-1', runId: 'exec-1' }) }) + return GET(req, { params: Promise.resolve({ id: 'workflow-1', runId: 'run-1' }) }) +} + +const baseStatus = { + executionId: 'run-1', + workflowId: 'workflow-1', + status: 'failed' as const, + trigger: 'api', + level: 'error', + startedAt: '2026-07-31T00:00:00.000Z', + endedAt: '2026-07-31T00:00:05.000Z', + totalDurationMs: 5000, + paused: null, + cost: { total: 0.02 }, + error: 'Send Email: Invalid credentials', + finalOutput: null, + blockOutputs: null, } -describe('v2 runs status + cancel', () => { +const successfulCancellation = { + success: true, + executionId: 'run-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + redisAvailable: true, + durablyRecorded: true, + locallyAborted: false, + pausedCancelled: false, + reason: 'recorded', +} + +describe('v2 run detail and cancel adapters', () => { beforeEach(() => { vi.clearAllMocks() - mockAuthenticateV1Request.mockResolvedValue({ - authenticated: true, - userId: 'key-user-1', - keyType: 'workspace', - workspaceId: 'workspace-1', + mocks.authenticate.mockResolvedValue(auth) + mocks.checkPreAuthRate.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-05T01:00:00Z'), }) - mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord }) + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-05T01:00:00Z'), + }) + mocks.readRun.mockResolvedValue(baseStatus) + mocks.cancel.mockResolvedValue(successfulCancellation) }) it('returns the run resource with a structured error', async () => { - mockGetWorkflowExecutionStatus.mockResolvedValue({ - executionId: 'exec-1', + const response = await callStatus() + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data).toMatchObject({ + runId: 'run-1', workflowId: 'workflow-1', status: 'failed', - trigger: 'api', - level: 'error', - startedAt: '2026-07-31T00:00:00.000Z', - endedAt: '2026-07-31T00:00:05.000Z', - totalDurationMs: 5000, - paused: null, - cost: { total: 0.02 }, - error: 'Send Email: Invalid credentials', - finalOutput: null, - blockOutputs: null, + durationMs: 5000, + error: { + code: 'EXECUTION_FAILED', + message: 'Send Email: Invalid credentials', + }, + }) + expect(mocks.readRun).toHaveBeenCalledWith({ + principal, + input: { + workflowId: 'workflow-1', + runId: 'run-1', + includeOutput: false, + selectedOutputs: [], + }, + request: expect.anything(), }) - - const res = await callStatus() - - expect(res.status).toBe(200) - const body = await res.json() - expect(body.data.status).toBe('failed') - expect(body.data.runId).toBe('exec-1') - expect(body.data.error.code).toBe('EXECUTION_FAILED') - expect(body.data.error.message).toBe('Send Email: Invalid credentials') - expect(body.data.durationMs).toBe(5000) }) - it('returns the queued run resource before the log row exists', async () => { - mockGetWorkflowExecutionStatus.mockResolvedValue({ - executionId: 'exec-1', - workflowId: 'workflow-1', + it('returns the queued run resource before a durable log exists', async () => { + mocks.readRun.mockResolvedValueOnce({ + ...baseStatus, status: 'queued', - trigger: 'api', level: 'info', - startedAt: '2026-07-31T00:00:00.000Z', endedAt: null, totalDurationMs: null, - paused: null, cost: null, error: null, - finalOutput: null, - blockOutputs: null, }) - const res = await callStatus() - - expect(res.status).toBe(200) - expect((await res.json()).data.status).toBe('queued') + expect((await (await callStatus()).json()).data.status).toBe('queued') }) - it('returns the resume context for a paused execution', async () => { - mockGetWorkflowExecutionStatus.mockResolvedValue({ - executionId: 'exec-1', - workflowId: 'workflow-1', + it('returns the public pause context without its internal paused-execution ID', async () => { + mocks.readRun.mockResolvedValueOnce({ + ...baseStatus, status: 'paused', - trigger: 'api', level: 'info', - startedAt: '2026-07-31T00:00:00.000Z', endedAt: null, totalDurationMs: null, + error: null, paused: { contextId: 'context-1', pausedAt: '2026-07-31T00:00:01.000Z', @@ -137,10 +185,6 @@ describe('v2 runs status + cancel', () => { pausePointCount: 1, resumedCount: 0, }, - cost: null, - error: null, - finalOutput: null, - blockOutputs: null, }) const body = await (await callStatus()).json() @@ -149,61 +193,111 @@ describe('v2 runs status + cancel', () => { expect(body.data.paused).not.toHaveProperty('pausedExecutionId') }) - it('404s when neither a log row nor a matching job exists', async () => { - mockGetWorkflowExecutionStatus.mockResolvedValue(null) + it('conceals canonical run authorization failures as absence', async () => { + mocks.readRun.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError()) - const res = await callStatus() + const response = await callStatus() - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(response.status).toBe(404) + expect((await response.json()).error).toMatchObject({ + code: 'NOT_FOUND', + message: 'Run not found', + }) }) - it('masks cross-workspace access as 404', async () => { - mockAuthenticateV1Request.mockResolvedValue({ - authenticated: true, - userId: 'key-user-1', - keyType: 'workspace', - workspaceId: 'other-workspace', - }) + it('rejects missing API keys before reading the run', async () => { + mocks.authenticate.mockRejectedValueOnce( + new MockV2ApiKeyUnauthenticatedError('API key required') + ) - const res = await callStatus() + const response = await callStatus() - expect(res.status).toBe(404) - expect(mockGetWorkflowExecutionStatus).not.toHaveBeenCalled() + expect(response.status).toBe(401) + expect(mocks.readRun).not.toHaveBeenCalled() }) - it('cancels through the shared lib and returns the tightened result', async () => { - mockCancel.mockResolvedValue({ + it('keeps cancel on its semantic application operation', async () => { + const response = await cancelPost(createMockRequest('POST', undefined, {}), { + params: Promise.resolve({ id: 'workflow-1', runId: 'run-1' }), + }) + + expect(response.status).toBe(200) + expect((await response.json()).data).toMatchObject({ success: true, - executionId: 'exec-1', - redisAvailable: true, - durablyRecorded: true, - locallyAborted: false, - pausedCancelled: false, + runId: 'run-1', reason: 'recorded', }) + expect(mocks.cancel).toHaveBeenCalledWith({ + principal, + input: { workflowId: 'workflow-1', runId: 'run-1' }, + request: expect.anything(), + }) + expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2) + expect(mocks.checkOperationRate).toHaveBeenCalledWith( + 'v2:workflows.runs.cancel:api-key:key-1', + expect.anything() + ) + expect(mocks.capture).not.toHaveBeenCalled() + }) - const req = createMockRequest('POST', undefined, {}) - const res = await cancelPost(req, { - params: Promise.resolve({ id: 'workflow-1', runId: 'exec-1' }), + it('keeps cancellation request-rate admission separate from run control', async () => { + mocks.checkOperationRate + .mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date('2026-08-05T01:00:00Z'), + retryAfterMs: 5_000, + }) + .mockResolvedValueOnce({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-05T01:00:00Z'), + }) + + const response = await cancelPost(createMockRequest('POST', undefined, {}), { + params: Promise.resolve({ id: 'workflow-1', runId: 'run-1' }), }) - expect(res.status).toBe(200) - const body = await res.json() - expect(body.data).toMatchObject({ success: true, runId: 'exec-1', reason: 'recorded' }) - expect(mockCancel).toHaveBeenCalledWith({ - executionId: 'exec-1', - workflowId: 'workflow-1', - userId: 'key-user-1', - workspaceId: 'workspace-1', + expect(response.status).toBe(429) + expect(response.headers.get('Retry-After')).toBe('5') + expect(mocks.cancel).not.toHaveBeenCalled() + }) + + it('conceals cancellation authorization failures using canonical run policy', async () => { + mocks.cancel.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError()) + + const response = await cancelPost(createMockRequest('POST', undefined, {}), { + params: Promise.resolve({ id: 'workflow-1', runId: 'run-1' }), + }) + + expect(response.status).toBe(404) + expect((await response.json()).error).toMatchObject({ + code: 'NOT_FOUND', + message: 'Run not found', }) + expect(mocks.capture).not.toHaveBeenCalled() }) - it('401s without an API key (no session/anonymous path on runs)', async () => { - mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + it('projects cancellation analytics only after a successful personal-key result', async () => { + mocks.authenticate.mockResolvedValueOnce({ + ...auth, + principal: { kind: 'personal_api_key', userId: 'key-user', keyId: 'personal-key' }, + rolloutUserId: 'key-user', + rateLimitSubjectIds: ['api-key:personal-key', 'user:key-user'], + keyType: 'personal', + }) - const res = await callStatus() + const response = await cancelPost(createMockRequest('POST', undefined, {}), { + params: Promise.resolve({ id: 'workflow-1', runId: 'run-1' }), + }) - expect(res.status).toBe(401) + expect(response.status).toBe(200) + expect(mocks.capture).toHaveBeenCalledOnce() + expect(mocks.capture).toHaveBeenCalledWith( + 'key-user', + 'workflow_execution_cancelled', + { workflow_id: 'workflow-1', workspace_id: 'workspace-1' }, + { groups: { workspace: 'workspace-1' } } + ) }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts index 65793c6307d..f511c4391a8 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.ts @@ -1,23 +1,13 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2GetWorkflowRunContract, v2WorkflowRunStatusSchema, } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE, - FunctionalOutputsUnavailableError, -} from '@/lib/logs/execution/functional-outputs' -import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' -import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' -import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { readWorkflowRun } from '@/lib/workflows/application/read-workflow-run' import { classifyExecutionError } from '@/executor/utils/errors' -const logger = createLogger('V2WorkflowRunStatusAPI') - export const dynamic = 'force-dynamic' /** @@ -26,54 +16,33 @@ export const dynamic = 'force-dynamic' * queue is consulted (deterministic job id) so a freshly-queued run reports * `queued` instead of 404. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; runId: string }> }) => { - const parsed = await parseRequest(v2GetWorkflowRunContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { id: workflowId, runId } = parsed.data.params - const { includeOutput, selectedOutputs } = parsed.data.query - - const access = await resolveV2WorkflowAccess(request, workflowId, 'read') - if (!access.ok) return access.response - - try { - const status = await getWorkflowExecutionStatus({ - workflowId, - executionId: runId, - includeOutput, - selectedOutputs, - }) - - if (!status) { - return v2Error('NOT_FOUND', 'Run not found') - } - - return v2Data({ - runId: status.executionId, - workflowId: status.workflowId, - status: status.status, - trigger: status.trigger ?? null, - startedAt: status.startedAt, - endedAt: status.endedAt, - durationMs: status.totalDurationMs, - paused: status.paused ? v2WorkflowRunStatusSchema.shape.paused.parse(status.paused) : null, - cost: status.cost, - error: status.error ? classifyExecutionError(new Error(status.error)) : null, - output: status.finalOutput, - blockOutputs: status.blockOutputs, - }) - } catch (error) { - if (error instanceof FunctionalOutputsUnavailableError) { - return v2Error('CONFLICT', FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE) - } - logger.error('Failed to fetch run status', { - workflowId, - runId, - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) +export const GET = defineV2JsonRoute({ + contract: v2GetWorkflowRunContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.readRun, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealRunAuthorization, + mapInput: ({ params, query }) => ({ + workflowId: params.id, + runId: params.runId, + includeOutput: query.includeOutput, + selectedOutputs: query.selectedOutputs, + }), + useCase: readWorkflowRun, + present: (status) => ({ + data: { + runId: status.executionId, + workflowId: status.workflowId, + status: status.status, + trigger: status.trigger ?? null, + startedAt: status.startedAt, + endedAt: status.endedAt, + durationMs: status.totalDurationMs, + paused: status.paused ? v2WorkflowRunStatusSchema.shape.paused.parse(status.paused) : null, + cost: status.cost, + error: status.error ? classifyExecutionError(new Error(status.error)) : null, + output: status.finalOutput, + blockOutputs: status.blockOutputs, + }, + }), +}) 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 102088826c4..f43dfac8df8 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 @@ -1,20 +1,58 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockResolveV2WorkflowAccess } = vi.hoisted(() => ({ - mockResolveV2WorkflowAccess: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreAuthRate: vi.fn(), + checkOperationRate: vi.fn(), + listRuns: vi.fn(), })) -vi.mock('@/app/api/v2/workflows/lib/access', () => ({ - resolveV2WorkflowAccess: mockResolveV2WorkflowAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreAuthRate + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/workflows/application/list-workflow-runs', () => ({ + listWorkflowRuns: { + operation: { id: 'workflows.runs.list' }, + execute: mocks.listRuns, + }, +})) + +import { + InsufficientWorkspacePermissionsError, + PersonalApiKeysDisabledError, +} from '@/lib/core/application' import { GET } from '@/app/api/v2/workflows/[id]/runs/route' +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', +} +const auth = { + principal, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} const routeContext = () => ({ params: Promise.resolve({ id: 'workflow-1' }) }) const callGet = (query = '') => GET( @@ -50,96 +88,122 @@ const EXECUTIONS = [ describe('GET /api/v2/workflows/[id]/runs', () => { beforeEach(() => { vi.clearAllMocks() - resetDbChainMock() - mockResolveV2WorkflowAccess.mockResolvedValue({ - ok: true, - userId: 'user-1', - keyType: 'workspace', - workflow: { id: 'workflow-1', workspaceId: 'workspace-1' }, + mocks.authenticate.mockResolvedValue(auth) + mocks.checkPreAuthRate.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-05T01:00:00Z'), + }) + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-05T01:00:00Z'), + }) + mocks.listRuns.mockResolvedValue({ + data: EXECUTIONS, + nextCursor: null, + workflowId: 'workflow-1', + order: 'desc', }) - dbChainMockFns.limit.mockResolvedValue(EXECUTIONS) }) - it('lists lightweight run resources in the cursor envelope', async () => { + it('lists lightweight run resources through the semantic operation', async () => { const response = await callGet() - const body = await response.json() expect(response.status).toBe(200) - expect(body.nextCursor).toBeNull() - expect(body.data).toEqual([ - { - runId: 'execution-2', - workflowId: 'workflow-1', - status: 'paused', - trigger: 'api', - startedAt: '2026-08-05T00:02:00.000Z', - endedAt: null, - durationMs: null, - cost: { total: 0.02 }, - }, - { - runId: 'execution-1', - workflowId: 'workflow-1', - status: 'completed', - trigger: 'schedule', - startedAt: '2026-08-05T00:01:00.000Z', - endedAt: '2026-08-05T00:01:03.000Z', - durationMs: 3000, - cost: null, - }, - ]) + expect(await response.json()).toEqual({ + data: [ + { + runId: 'execution-2', + workflowId: 'workflow-1', + status: 'paused', + trigger: 'api', + startedAt: '2026-08-05T00:02:00.000Z', + endedAt: null, + durationMs: null, + cost: { total: 0.02 }, + }, + { + runId: 'execution-1', + workflowId: 'workflow-1', + status: 'completed', + trigger: 'schedule', + startedAt: '2026-08-05T00:01:00.000Z', + endedAt: '2026-08-05T00:01:03.000Z', + durationMs: 3000, + cost: null, + }, + ], + nextCursor: null, + }) + expect(mocks.listRuns).toHaveBeenCalledWith({ + principal, + input: expect.objectContaining({ workflowId: 'workflow-1', limit: 50, order: 'desc' }), + request: expect.anything(), + }) }) - it('returns an opaque cursor when another row exists', async () => { - dbChainMockFns.limit.mockResolvedValue([...EXECUTIONS, { ...EXECUTIONS[1], rowId: 'row-0' }]) + it('encodes the repository cursor using the requested order', async () => { + mocks.listRuns.mockResolvedValueOnce({ + data: EXECUTIONS, + nextCursor: { startedAt: EXECUTIONS[1].startedAt, rowId: 'row-1' }, + workflowId: 'workflow-1', + order: 'asc', + }) - const body = await (await callGet('?limit=2')).json() + const body = await (await callGet('?order=asc')).json() - expect(body.data).toHaveLength(2) - expect(body.nextCursor).toEqual(expect.any(String)) expect(JSON.parse(Buffer.from(body.nextCursor, 'base64').toString())).toEqual({ - sort: 'startedAt:desc', + sort: 'startedAt:asc', keys: ['2026-08-05T00:01:00.000Z', 'row-1'], }) }) - it('rejects an invalid cursor', async () => { + it('rejects an invalid cursor after API-key admission without calling the use case', async () => { const response = await callGet('?cursor=not-a-cursor') expect(response.status).toBe(400) - expect(dbChainMockFns.limit).not.toHaveBeenCalled() - }) - - it('rejects a cursor minted under a different order', async () => { - const cursor = Buffer.from( - JSON.stringify({ - sort: 'startedAt:desc', - keys: ['2026-08-05T00:01:00.000Z', 'row-1'], - }) - ).toString('base64') - - const response = await callGet(`?order=asc&cursor=${encodeURIComponent(cursor)}`) - - expect(response.status).toBe(400) - expect(dbChainMockFns.limit).not.toHaveBeenCalled() + expect(mocks.authenticate).toHaveBeenCalledOnce() + expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2) + expect(mocks.listRuns).not.toHaveBeenCalled() }) it('rejects queued as a durable-history filter', async () => { const response = await callGet('?status=queued') expect(response.status).toBe(400) - expect(dbChainMockFns.limit).not.toHaveBeenCalled() + expect(mocks.listRuns).not.toHaveBeenCalled() }) - it('authorizes the workflow before validating filters', async () => { - mockResolveV2WorkflowAccess.mockResolvedValue({ - ok: false, - response: new Response(null, { status: 404 }), - }) + it('conceals workflow authorization failures as absence', async () => { + mocks.listRuns.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError()) - const response = await callGet('?limit=0') + const response = await callGet() expect(response.status).toBe(404) - expect(dbChainMockFns.limit).not.toHaveBeenCalled() + expect((await response.json()).error).toMatchObject({ + code: 'NOT_FOUND', + message: 'Workflow not found', + }) + }) + + it('preserves the personal API-key workspace-policy denial', async () => { + mocks.listRuns.mockRejectedValueOnce(new PersonalApiKeysDisabledError()) + + const response = await callGet() + + expect(response.status).toBe(403) + expect((await response.json()).error.code).toBe('FORBIDDEN') + }) + + it('returns a safe error when run storage fails', async () => { + mocks.listRuns.mockRejectedValueOnce(new Error('database connection details')) + + const response = await callGet() + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) }) }) 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 5e9a2c5cec8..6893f79f822 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/route.ts @@ -1,46 +1,33 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { type V2WorkflowRunListItem, v2ListWorkflowRunsContract, v2WorkflowRunListStatusValueSchema, } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { listWorkflowExecutions } from '@/lib/workflows/executor/execution-queries' -import { - cursorSortKey, - decodeSortedCursor, - encodeSortedCursor, - v2CursorList, - v2CursorSortError, - v2Error, - v2ValidationError, -} from '@/app/api/v2/lib/response' -import { resolveV2WorkflowAccess } from '@/app/api/v2/workflows/lib/access' - -const logger = createLogger('V2WorkflowRunsAPI') +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' export const dynamic = 'force-dynamic' export const revalidate = 0 /** List the durable runs belonging to one workflow. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const { id: workflowId } = await context.params - const access = await resolveV2WorkflowAccess(request, workflowId, 'read') - if (!access.ok) return access.response - - const parsed = await parseRequest(v2ListWorkflowRunsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { status, trigger, startDate, endDate, limit, cursor, order } = parsed.data.query +export const GET = defineV2JsonRoute({ + contract: v2ListWorkflowRunsContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.listRuns, + rateLimit: v2RateLimits.publicApi, + 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) - if (decodedCursor.status === 'invalid') return v2CursorSortError() + if (decodedCursor.status === 'invalid') { + throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + } const [cursorStartedAt, cursorRowId] = decodedCursor.status === 'ok' ? decodedCursor.keys : [] const cursorDate = typeof cursorStartedAt === 'string' ? new Date(cursorStartedAt) : null if ( @@ -50,49 +37,42 @@ export const GET = withRouteHandler( Number.isNaN(cursorDate.getTime()) || typeof cursorRowId !== 'string') ) { - return v2CursorSortError() + throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) } - try { - const result = await listWorkflowExecutions({ - workflowId, - status, - trigger, - startDate: startDate ? new Date(startDate) : undefined, - endDate: endDate ? new Date(endDate) : undefined, - limit, - cursor: - decodedCursor.status === 'ok' && cursorDate && typeof cursorRowId === 'string' - ? { startedAt: cursorDate, rowId: cursorRowId } - : undefined, - order, - }) - - const data: V2WorkflowRunListItem[] = result.data.map((row) => ({ - runId: row.executionId, - workflowId: row.workflowId ?? workflowId, - status: v2WorkflowRunListStatusValueSchema.parse(row.status), - trigger: row.trigger, - startedAt: row.startedAt.toISOString(), - endedAt: row.endedAt?.toISOString() ?? null, - durationMs: row.durationMs, - cost: row.costTotal != null ? { total: Number(row.costTotal) } : null, - })) - - const nextCursor = result.nextCursor - ? encodeSortedCursor(sort, [ - result.nextCursor.startedAt.toISOString(), - result.nextCursor.rowId, - ]) - : null - - return v2CursorList(data, nextCursor) - } catch (error) { - logger.error('Failed to list workflow runs', { - workflowId, - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + return { + workflowId: params.id, + status, + trigger, + startDate: startDate ? new Date(startDate) : undefined, + endDate: endDate ? new Date(endDate) : undefined, + limit, + cursor: + decodedCursor.status === 'ok' && cursorDate && typeof cursorRowId === 'string' + ? { startedAt: cursorDate, rowId: cursorRowId } + : undefined, + order, } - } -) + }, + useCase: listWorkflowRuns, + present: (result) => { + const data: V2WorkflowRunListItem[] = result.data.map((row) => ({ + runId: row.executionId, + workflowId: row.workflowId ?? result.workflowId, + status: v2WorkflowRunListStatusValueSchema.parse(row.status), + trigger: row.trigger, + startedAt: row.startedAt.toISOString(), + endedAt: row.endedAt?.toISOString() ?? null, + 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, + ]) + : null + return { data, nextCursor } + }, +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts index 72e3811cb6e..9eb2b7b2aa5 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.test.ts @@ -1,154 +1,90 @@ /** * @vitest-environment node - * - * Public v2 deployment-version detail: the 404 mask on an access failure, the - * coerced numeric version param, and the pinned workflow state it serves. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetActiveWorkflowRecord, - mockGetWorkflowDeploymentVersion, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetActiveWorkflowRecord: vi.fn(), - mockGetWorkflowDeploymentVersion: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + readVersion: vi.fn(), + gate: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/workflows/application/read-workflow-version', () => ({ + readWorkflowVersion: { + operation: { id: 'workflows.versions.read' }, + execute: mocks.readVersion, + }, })) - -vi.mock('@sim/platform-authz/workflow', () => ({ - getActiveWorkflowRecord: mockGetActiveWorkflowRecord, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/lib/workflows/persistence/utils', () => ({ - getWorkflowDeploymentVersion: mockGetWorkflowDeploymentVersion, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +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 })) import { GET } from '@/app/api/v2/workflows/[id]/versions/[version]/route' -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} - -const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } - -const WORKFLOW_RECORD = { id: 'wf-1', name: 'Support Agent', workspaceId: 'workspace-1' } - -const DEPLOYED_STATE = { blocks: {}, edges: [], loops: {}, parallels: {} } - -const VERSION_ROW = { - id: 'dv-3', - version: 3, - name: 'Escalation branch', - description: null, - isActive: true, - createdAt: new Date('2024-01-03T00:00:00Z'), - state: DEPLOYED_STATE, +const auth = { + principal: { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'personal-key-1', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, } -const routeContext = (version = '3') => ({ params: Promise.resolve({ id: 'wf-1', version }) }) -const callGet = (version = '3') => - GET( - new NextRequest(`http://localhost:3000/api/v2/workflows/wf-1/versions/${version}`), - routeContext(version) - ) - describe('GET /api/v2/workflows/[id]/versions/[version]', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) - mockGetWorkflowDeploymentVersion.mockResolvedValue(VERSION_ROW) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() - }) - - it('400s on a non-numeric version', async () => { - const res = await callGet('latest') - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() - }) - - it('masks an access-denied failure as 404 so existence is not leaked', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callGet() - expect(res.status).toBe(404) - expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callGet() - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('404s when the workflow does not exist or is archived', async () => { - mockGetActiveWorkflowRecord.mockResolvedValue(null) - const res = await callGet() - expect(res.status).toBe(404) - expect(mockGetWorkflowDeploymentVersion).not.toHaveBeenCalled() - }) - - it('404s when the version does not exist on this workflow', async () => { - mockGetWorkflowDeploymentVersion.mockResolvedValue(null) - const res = await callGet() - expect(res.status).toBe(404) - expect((await res.json()).error.message).toBe('Deployment version not found') - }) - - it('returns the version with the workflow state it pins', async () => { - const res = await callGet() - const body = await res.json() - - expect(res.status).toBe(200) - expect(body).toEqual({ - data: { - id: 'dv-3', - version: 3, - name: 'Escalation branch', + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-01T01:00:00.000Z'), + }) + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-01T01:00:00.000Z'), + }) + mocks.readVersion.mockResolvedValue({ + version: { + id: 'version-2', + version: 2, + name: 'Production', description: null, isActive: true, - createdAt: '2024-01-03T00:00:00.000Z', - state: DEPLOYED_STATE, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + state: { blocks: {}, edges: [], loops: {}, parallels: {}, version: '1.0' }, }, }) - expect(mockGetWorkflowDeploymentVersion).toHaveBeenCalledWith('wf-1', 3) + }) + + it('reads the requested version through the semantic use case', async () => { + const request = new NextRequest('http://localhost/api/v2/workflows/workflow-1/versions/2') + const response = await GET(request, { + params: Promise.resolve({ id: 'workflow-1', version: '2' }), + }) + + expect(response.status).toBe(200) + expect((await response.json()).data).toMatchObject({ id: 'version-2', version: 2 }) + expect(mocks.readVersion).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workflowId: 'workflow-1', version: 2 }, + request, + }) }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts index f1fe2633758..351e2de49fa 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts @@ -1,41 +1,30 @@ -import { - type V2WorkflowVersionDetail, - v2GetWorkflowVersionContract, -} from '@/lib/api/contracts/v2/workflows' -import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { v2Data, v2Error } from '@/app/api/v2/lib/response' -import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' +import type { V2WorkflowVersionDetail } from '@/lib/api/contracts/v2/workflows' +import { v2GetWorkflowVersionContract } from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * GET /api/v2/workflows/[id]/versions/[version] — Fetch one deployment version - * and the workflow state it pins. - */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetWorkflowVersionContract, - rateLimitEndpoint: 'workflow-version-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { id, version } = input.params - - const target = await resolveV2WorkflowTarget(rateLimit, userId, id) - if (!target) return v2Error('NOT_FOUND', 'Workflow not found') - - const row = await getWorkflowDeploymentVersion(id, version) - if (!row?.state) return v2Error('NOT_FOUND', 'Deployment version not found') - - const detail: V2WorkflowVersionDetail = { - id: row.id, - version: row.version, - name: row.name, - description: row.description, - isActive: row.isActive, - createdAt: row.createdAt.toISOString(), - state: row.state as V2WorkflowVersionDetail['state'], - } - - return v2Data(detail, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: workflowOperations.readVersion, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ workflowId: params.id, version: params.version }), + useCase: readWorkflowVersion, + present: ({ version }) => ({ + data: { + id: version.id, + version: version.version, + name: version.name, + description: version.description, + isActive: version.isActive, + createdAt: version.createdAt.toISOString(), + state: version.state as V2WorkflowVersionDetail['state'], + }, + }), }) 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 53025c2d07d..aca18ee8ede 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 @@ -1,221 +1,119 @@ /** * @vitest-environment node - * - * Public v2 deployment-version listing: the 404 mask on an access failure, the - * public projection (no raw `createdBy` user id), and the version-keyed cursor. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetActiveWorkflowRecord, - mockListWorkflowVersions, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetActiveWorkflowRecord: vi.fn(), - mockListWorkflowVersions: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + listVersions: vi.fn(), + gate: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/workflows/application/list-workflow-versions', () => ({ + listWorkflowVersions: { + operation: { id: 'workflows.versions.list' }, + execute: mocks.listVersions, + }, })) - -vi.mock('@sim/platform-authz/workflow', () => ({ - getActiveWorkflowRecord: mockGetActiveWorkflowRecord, -})) - -vi.mock('@/lib/workflows/persistence/utils', () => ({ - listWorkflowVersions: mockListWorkflowVersions, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +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 })) import { GET } from '@/app/api/v2/workflows/[id]/versions/route' -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:workspace-key-1', 'workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } - -const WORKFLOW_RECORD = { id: 'wf-1', name: 'Support Agent', workspaceId: 'workspace-1' } - -function buildVersion(version: number, overrides: Record<string, unknown> = {}) { - return { - id: `dv-${version}`, - version, - name: null, - description: null, - isActive: false, - createdAt: new Date(`2024-01-0${version}T00:00:00Z`), - createdBy: 'user-9', - deployedByName: 'Ada Lovelace', - latestOperationStatus: null, - ...overrides, - } -} - -const ALL_VERSIONS = [ - buildVersion(3, { isActive: true, name: 'Escalation branch', latestOperationStatus: 'active' }), - buildVersion(2), - buildVersion(1), -] - -const routeContext = () => ({ params: Promise.resolve({ id: 'wf-1' }) }) -const callGet = (query = '') => - GET( - new NextRequest(`http://localhost:3000/api/v2/workflows/wf-1/versions${query}`), - routeContext() - ) +const context = { params: Promise.resolve({ id: 'workflow-1' }) } describe('GET /api/v2/workflows/[id]/versions', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD) - /** - * Stands in for the keyset query the helper now runs, so the route's - * has-more probe and cursor round-trip are exercised against realistic - * `limit`/`afterVersion` behavior rather than a fixed array. - */ - mockListWorkflowVersions.mockImplementation( - async (_workflowId: string, options: { limit?: number; afterVersion?: number } = {}) => { - let versions = ALL_VERSIONS - if (options.afterVersion !== undefined) { - versions = versions.filter((row) => row.version < options.afterVersion!) - } - if (options.limit !== undefined) versions = versions.slice(0, options.limit) - return { versions } - } - ) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockListWorkflowVersions).not.toHaveBeenCalled() - }) - - it('400s on an out-of-range limit', async () => { - const res = await callGet('?limit=0') - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockListWorkflowVersions).not.toHaveBeenCalled() - }) - - it('masks an access-denied failure as 404 so existence is not leaked', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callGet() - expect(res.status).toBe(404) - expect(mockListWorkflowVersions).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callGet() - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('404s when the workflow does not exist or is archived', async () => { - mockGetActiveWorkflowRecord.mockResolvedValue(null) - const res = await callGet() - expect(res.status).toBe(404) - expect(mockListWorkflowVersions).not.toHaveBeenCalled() - }) - - it('returns the public version shape newest-first, without the raw creator id', async () => { - const res = await callGet() - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.nextCursor).toBeNull() - expect(body.data).toHaveLength(3) - expect(body.data[0]).toEqual({ - id: 'dv-3', - version: 3, - name: 'Escalation branch', - description: null, - isActive: true, - createdAt: '2024-01-03T00:00:00.000Z', - deployedBy: 'Ada Lovelace', - latestOperationStatus: 'active', + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-01T01:00:00.000Z'), }) - expect(body.data[0]).not.toHaveProperty('createdBy') - // Paging is pushed into the helper — the route never reads the full set. - expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1', { - limit: 51, - afterVersion: undefined, + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-01T01:00:00.000Z'), }) - }) - - it('bounds the read to one page plus the has-more probe', async () => { - await callGet('?limit=2') - expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1', { - limit: 3, - afterVersion: undefined, + mocks.listVersions.mockResolvedValue({ + versions: [ + { + id: 'version-2', + version: 2, + name: 'Production', + description: null, + isActive: true, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + deployedByName: 'Ada', + latestOperationStatus: 'active', + }, + ], + hasMore: false, }) }) - it('pushes the cursor down to the helper as a keyset bound', async () => { - const cursor = Buffer.from(JSON.stringify({ version: 3 })).toString('base64') - await callGet(`?limit=2&cursor=${encodeURIComponent(cursor)}`) - expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1', { limit: 3, afterVersion: 3 }) - }) - - it('400s a structurally invalid cursor instead of silently truncating the list', async () => { - // Decodes to valid JSON with no numeric `version` — the shape that would - // otherwise filter every row out and report a clean end-of-list. - const bogus = Buffer.from(JSON.stringify({ offset: 2 })).toString('base64') - const res = await callGet(`?cursor=${encodeURIComponent(bogus)}`) - - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockListWorkflowVersions).not.toHaveBeenCalled() - }) - - it('400s a cursor that is not decodable at all', async () => { - const res = await callGet('?cursor=not-a-cursor') - expect(res.status).toBe(400) - expect(mockListWorkflowVersions).not.toHaveBeenCalled() + it('lists versions through canonical workflow authorization', async () => { + const request = new NextRequest( + 'http://localhost/api/v2/workflows/workflow-1/versions?limit=10' + ) + const response = await GET(request, context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: [ + { + id: 'version-2', + version: 2, + name: 'Production', + description: null, + isActive: true, + createdAt: '2026-08-01T00:00:00.000Z', + deployedBy: 'Ada', + latestOperationStatus: 'active', + }, + ], + nextCursor: null, + }) + expect(mocks.listVersions).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workflowId: 'workflow-1', limit: 10, afterVersion: undefined }, + request, + }) }) - it('pages with a version-keyed cursor', async () => { - const first = await callGet('?limit=2') - const firstBody = await first.json() - - expect(firstBody.data.map((v: { version: number }) => v.version)).toEqual([3, 2]) - expect(firstBody.nextCursor).toEqual(expect.any(String)) - - const second = await callGet(`?limit=2&cursor=${encodeURIComponent(firstBody.nextCursor)}`) - const secondBody = await second.json() + it('rejects malformed cursors before the use case', async () => { + const response = await GET( + new NextRequest('http://localhost/api/v2/workflows/workflow-1/versions?cursor=bad'), + context + ) - expect(secondBody.data.map((v: { version: number }) => v.version)).toEqual([1]) - expect(secondBody.nextCursor).toBeNull() + expect(response.status).toBe(400) + expect(mocks.listVersions).not.toHaveBeenCalled() }) }) 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 1a75e45cd16..1fbb169fe33 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts @@ -1,71 +1,55 @@ -import { - type V2WorkflowVersion, - v2ListWorkflowVersionsContract, -} from '@/lib/api/contracts/v2/workflows' -import { listWorkflowVersions } from '@/lib/workflows/persistence/utils' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { decodeCursor, encodeCursor, v2CursorList, v2Error } from '@/app/api/v2/lib/response' -import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' +import type { V2WorkflowVersion } from '@/lib/api/contracts/v2/workflows' +import { v2ListWorkflowVersionsContract } 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' +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' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** Keyset cursor over the dense, strictly-descending version number. */ interface WorkflowVersionCursor { version: number } -/** - * GET /api/v2/workflows/[id]/versions — List a workflow's deployment versions, - * newest first. These are the versions `POST /api/v2/workflows/[id]/rollback` - * accepts, so a caller no longer has to guess a version number. - */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListWorkflowVersionsContract, - rateLimitEndpoint: 'workflow-versions', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { id } = input.params - const { limit, cursor } = input.query - - const target = await resolveV2WorkflowTarget(rateLimit, userId, id) - if (!target) return v2Error('NOT_FOUND', 'Workflow not found') - - /** - * A cursor that decodes to anything other than a version number is - * rejected rather than ignored: comparing every row against a missing - * `version` yields an empty page with `nextCursor: null`, which reads to - * the caller as a clean end-of-list while versions are still pending. - */ - const after = cursor ? decodeCursor<WorkflowVersionCursor>(cursor) : null - if (cursor && (!after || !Number.isInteger(after.version) || after.version < 1)) { - return v2Error('BAD_REQUEST', 'Invalid cursor') + auth: v2ApiKeyAuth, + operation: workflowOperations.listVersions, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params, query }) => { + const after = query.cursor ? decodeCursor<WorkflowVersionCursor>(query.cursor) : null + if (query.cursor && (!after || !Number.isInteger(after.version) || after.version < 1)) { + throw new OrchestrationError('validation', 'Invalid cursor') } - - // One extra row is the has-more probe, matching the other v2 cursor lists. - const { versions: rows } = await listWorkflowVersions(id, { - limit: limit + 1, + return { + workflowId: params.id, + limit: query.limit, afterVersion: after?.version, - }) - - const hasMore = rows.length > limit - const page = rows.slice(0, limit) - - const data: V2WorkflowVersion[] = page.map((row) => ({ - id: row.id, - version: row.version, - name: row.name, - description: row.description, - isActive: row.isActive, - createdAt: row.createdAt.toISOString(), - deployedBy: row.deployedByName, - // The shared helper widens the operation-status pg enum to `string`. + } + }, + useCase: listWorkflowVersions, + present: ({ versions, hasMore }) => { + const data: V2WorkflowVersion[] = versions.map((version) => ({ + id: version.id, + version: version.version, + name: version.name, + description: version.description, + isActive: version.isActive, + createdAt: version.createdAt.toISOString(), + deployedBy: version.deployedByName, latestOperationStatus: - row.latestOperationStatus as V2WorkflowVersion['latestOperationStatus'], + version.latestOperationStatus as V2WorkflowVersion['latestOperationStatus'], })) - - const nextCursor = - hasMore && data.length > 0 ? encodeCursor({ version: data[data.length - 1].version }) : null - - return v2CursorList(data, nextCursor, { rateLimit }) + return { + data, + nextCursor: + hasMore && data.length > 0 + ? encodeCursor({ version: data[data.length - 1].version }) + : null, + } }, }) diff --git a/apps/sim/app/api/v2/workflows/folders/route.test.ts b/apps/sim/app/api/v2/workflows/folders/route.test.ts index 696286333ed..9d37f033e5a 100644 --- a/apps/sim/app/api/v2/workflows/folders/route.test.ts +++ b/apps/sim/app/api/v2/workflows/folders/route.test.ts @@ -1,216 +1,79 @@ /** * @vitest-environment node */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockLoadActiveFolderPathIndex, - mockListActiveFolderRows, - mockCreateFolderAtPath, - mockRelocateFolderByPath, - mockDeleteFolderByPath, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockLoadActiveFolderPathIndex: vi.fn(), - mockListActiveFolderRows: vi.fn(), - mockCreateFolderAtPath: vi.fn(), - mockRelocateFolderByPath: vi.fn(), - mockDeleteFolderByPath: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) +const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition) })) -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, - listActiveFolderRows: mockListActiveFolderRows, -})) - -vi.mock('@/lib/folders/orchestration', () => ({ - createFolderAtPath: mockCreateFolderAtPath, - relocateFolderByPath: mockRelocateFolderByPath, - deleteFolderByPath: mockDeleteFolderByPath, +vi.mock('@/lib/api/server/routes', () => ({ + defineV2JsonRoute: mocks.defineRoute, + v2ApiKeyAuth: { kind: 'v2-api-key' }, + v2RateLimits: { publicApi: { kind: 'public-api' } }, + v2OrchestrationErrorPolicy: { kind: 'orchestration-errors' }, })) +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { + createWorkflowFolder, + deleteWorkflowFolder, + listWorkflowFolders, + relocateWorkflowFolder, +} from '@/lib/workflows/application/workflow-folders' import { DELETE, GET, PATCH, POST } from '@/app/api/v2/workflows/folders/route' -const WORKSPACE_ID = 'workspace-1' -const FOLDER_ID = 'internal-folder-id' -const RATE_LIMIT = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const folder = { - id: FOLDER_ID, - resourceType: 'workflow' as const, - name: 'Reports', - userId: 'user-1', - workspaceId: WORKSPACE_ID, - parentId: null, - sortOrder: 0, - locked: false, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - deletedAt: null, -} - -function pathIndex(path = '/Reports') { - return { - rowById: new Map([[FOLDER_ID, folder]]), - pathById: new Map([[FOLDER_ID, path]]), - idByPath: new Map([[path, FOLDER_ID]]), - } -} - -function request(method: string, path: string, body?: Record<string, unknown>) { - return new NextRequest(`http://localhost:3000${path}`, { - method, - headers: body ? { 'Content-Type': 'application/json' } : undefined, - body: body ? JSON.stringify(body) : undefined, - }) -} - -describe('/api/v2/workflows/folders', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockLoadActiveFolderPathIndex.mockResolvedValue(pathIndex()) - mockListActiveFolderRows.mockResolvedValue([folder]) - mockCreateFolderAtPath.mockResolvedValue({ - success: true, - folder, - path: '/Reports', +describe('/api/v2/workflows/folders route definitions', () => { + it('binds every method to the matching semantic operation and authorized use case', () => { + expect(GET).toMatchObject({ + operation: workflowOperations.listFolders, + useCase: listWorkflowFolders, + errorPolicy: v2WorkflowErrorPolicies.default, }) - mockRelocateFolderByPath.mockResolvedValue({ - success: true, - folder, - path: '/Reports', + expect(POST).toMatchObject({ + operation: workflowOperations.createFolder, + useCase: createWorkflowFolder, + errorPolicy: v2WorkflowErrorPolicies.default, }) - mockDeleteFolderByPath.mockResolvedValue({ - success: true, - path: '/Reports', - deletedItems: { folders: 1, workflows: 2 }, + expect(PATCH).toMatchObject({ + operation: workflowOperations.relocateFolder, + useCase: relocateWorkflowFolder, + errorPolicy: v2WorkflowErrorPolicies.default, }) - }) - - it('lists only root children when parentPath is root and never exposes database ids', async () => { - const response = await GET( - request('GET', `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}&parentPath=%2F`) - ) - const body = await response.json() - - expect(response.status).toBe(200) - expect(mockListActiveFolderRows).toHaveBeenCalledWith(WORKSPACE_ID, 'workflow', { - parentId: null, - search: undefined, - sortBy: 'name', - sortOrder: 'asc', + expect(DELETE).toMatchObject({ + operation: workflowOperations.deleteFolder, + useCase: deleteWorkflowFolder, + errorPolicy: v2WorkflowErrorPolicies.default, }) - expect(body.data).toEqual([ - { - name: 'Reports', - path: '/Reports', - parentPath: '/', - locked: false, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - }, - ]) }) - it('omits the parent filter to list folders from the whole tree', async () => { - await GET(request('GET', `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}`)) - - expect(mockListActiveFolderRows).toHaveBeenCalledWith(WORKSPACE_ID, 'workflow', { - parentId: undefined, - search: undefined, + it('maps only contract-owned inputs into application inputs', () => { + expect( + Reflect.get( + GET, + 'mapInput' + )({ + query: { + workspaceId: 'ws-1', + parentPath: '/', + search: 'reports', + sortBy: 'name', + sortOrder: 'asc', + }, + }) + ).toEqual({ + workspaceId: 'ws-1', + parentPath: '/', + search: 'reports', sortBy: 'name', sortOrder: 'asc', }) - }) - - it('creates a folder from a canonical path and rejects internal ids', async () => { - const created = await POST( - request('POST', '/api/v2/workflows/folders', { - workspaceId: WORKSPACE_ID, - path: '/Reports', + expect( + Reflect.get( + DELETE, + 'mapInput' + )({ + query: { workspaceId: 'ws-1', path: '/Reports', recursive: true }, }) - ) - - expect(created.status).toBe(201) - expect(mockCreateFolderAtPath).toHaveBeenCalledWith({ - resourceType: 'workflow', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - path: '/Reports', - }) - - const rejected = await POST( - request('POST', '/api/v2/workflows/folders', { - workspaceId: WORKSPACE_ID, - path: '/Reports', - folderId: FOLDER_ID, - }) - ) - expect(rejected.status).toBe(400) - }) - - it('relocates one folder by source and destination paths', async () => { - mockLoadActiveFolderPathIndex.mockResolvedValue(pathIndex('/Archive')) - const response = await PATCH( - request('PATCH', '/api/v2/workflows/folders', { - workspaceId: WORKSPACE_ID, - path: '/Reports', - destinationPath: '/Archive', - }) - ) - - expect(response.status).toBe(200) - expect(mockRelocateFolderByPath).toHaveBeenCalledWith({ - resourceType: 'workflow', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - path: '/Reports', - destinationPath: '/Archive', - }) - }) - - it('requires an explicit recursive delete choice', async () => { - const missing = await DELETE( - request('DELETE', `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}&path=%2FReports`) - ) - expect(missing.status).toBe(400) - expect(mockDeleteFolderByPath).not.toHaveBeenCalled() - - const deleted = await DELETE( - request( - 'DELETE', - `/api/v2/workflows/folders?workspaceId=${WORKSPACE_ID}&path=%2FReports&recursive=true` - ) - ) - expect(deleted.status).toBe(200) - expect(await deleted.json()).toEqual({ - data: { - path: '/Reports', - deleted: true, - deletedItems: { folders: 1, workflows: 2 }, - }, - }) + ).toEqual({ workspaceId: 'ws-1', path: '/Reports', recursive: true }) }) }) diff --git a/apps/sim/app/api/v2/workflows/folders/route.ts b/apps/sim/app/api/v2/workflows/folders/route.ts index 81fb20fa35a..dd59563e2e1 100644 --- a/apps/sim/app/api/v2/workflows/folders/route.ts +++ b/apps/sim/app/api/v2/workflows/folders/route.ts @@ -4,122 +4,92 @@ import { v2ListWorkflowFoldersContract, v2RelocateWorkflowFolderContract, } from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { workflowOperations } from '@/lib/workflows/application/operations' import { - createFolderAtPath, - deleteFolderByPath, - relocateFolderByPath, -} from '@/lib/folders/orchestration' -import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { - resolveFolderPathId, - toV2PathFolder, - v2FolderPathMutationError, -} from '@/app/api/v2/lib/folders' -import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' + createWorkflowFolder, + deleteWorkflowFolder, + listWorkflowFolders, + relocateWorkflowFolder, +} from '@/lib/workflows/application/workflow-folders' +import { toV2PathFolder } from '@/app/api/v2/lib/folders' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withPublicApiRouteHandler({ - contract: v2ListWorkflowFoldersContract, - rateLimitEndpoint: 'workflows', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, parentPath, search, sortBy, sortOrder } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) +function toV2WorkflowFolder( + folder: Parameters<typeof toV2PathFolder>[0], + index: Parameters<typeof toV2PathFolder>[1] +) { + const view = toV2PathFolder(folder, index, true) + if (!('locked' in view)) throw new Error('Workflow folder projection omitted lock state') + return view +} - const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') - const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) - if (parentPath !== undefined && parentId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found') - } - const rows = await listActiveFolderRows(workspaceId, 'workflow', { - parentId, - search, - sortBy, - sortOrder, - }) - return v2CursorList( - rows.map((row) => toV2PathFolder(row, index, true)), - null, - { rateLimit } - ) - }, +export const GET = defineV2JsonRoute({ + contract: v2ListWorkflowFoldersContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.listFolders, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.default, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + parentPath: query.parentPath, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + }), + useCase: listWorkflowFolders, + present: ({ folders, index }) => ({ + data: folders.map((folder) => toV2WorkflowFolder(folder, index)), + nextCursor: null, + }), }) -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateWorkflowFolderContract, - rateLimitEndpoint: 'workflows', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await createFolderAtPath({ resourceType: 'workflow', workspaceId, userId, path }) - if (!result.success || !result.folder || !result.path) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') - return v2Data( - { folder: toV2PathFolder(result.folder, index, true) }, - { rateLimit, status: 201 } - ) - }, + auth: v2ApiKeyAuth, + operation: workflowOperations.createFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.default, + mapInput: ({ body }) => ({ workspaceId: body.workspaceId, path: body.path }), + useCase: createWorkflowFolder, + present: ({ folder, index }) => ({ + data: { folder: toV2WorkflowFolder(folder, index) }, + }), }) -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2RelocateWorkflowFolderContract, - rateLimitEndpoint: 'workflows', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path, destinationPath } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await relocateFolderByPath({ - resourceType: 'workflow', - workspaceId, - userId, - path, - destinationPath, - }) - if (!result.success || !result.folder || !result.path) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') - return v2Data({ folder: toV2PathFolder(result.folder, index, true) }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: workflowOperations.relocateFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.default, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + path: body.path, + destinationPath: body.destinationPath, + }), + useCase: relocateWorkflowFolder, + present: ({ folder, index }) => ({ + data: { folder: toV2WorkflowFolder(folder, index) }, + }), }) -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteWorkflowFolderContract, - rateLimitEndpoint: 'workflows', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path, recursive } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await deleteFolderByPath({ - resourceType: 'workflow', - workspaceId, - userId, - path, - recursive, - }) - if (!result.success || !result.deletedItems) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') - } - return v2Data( - { - path, - deleted: true as const, - deletedItems: { - folders: result.deletedItems.folders, - workflows: result.deletedItems.workflows ?? 0, - }, - }, - { rateLimit } - ) - }, + auth: v2ApiKeyAuth, + operation: workflowOperations.deleteFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.default, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + path: query.path, + recursive: query.recursive, + }), + useCase: deleteWorkflowFolder, + present: ({ path, deletedItems }) => ({ + data: { path, deleted: true as const, deletedItems }, + }), }) diff --git a/apps/sim/app/api/v2/workflows/import/route.test.ts b/apps/sim/app/api/v2/workflows/import/route.test.ts new file mode 100644 index 00000000000..68d72c0bfa6 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/import/route.test.ts @@ -0,0 +1,30 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition) })) + +vi.mock('@/lib/api/server/routes', () => ({ + defineV2JsonRoute: mocks.defineRoute, + v2ApiKeyAuth: { kind: 'v2-api-key' }, + v2RateLimits: { publicApi: { kind: 'public-api' } }, + v2OrchestrationErrorPolicy: { kind: 'orchestration-errors' }, +})) + +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { importWorkflow } from '@/lib/workflows/application/import-export' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { MAX_IMPORT_BODY_BYTES } from '@/lib/workflows/operations/import-workflow' +import { POST } from '@/app/api/v2/workflows/import/route' + +describe('/api/v2/workflows/import route definition', () => { + it('uses authorized admission and preserves the bounded import lifecycle', () => { + expect(POST).toMatchObject({ + operation: workflowOperations.import, + useCase: importWorkflow, + errorPolicy: v2WorkflowErrorPolicies.import, + parseOptions: { maxBodyBytes: MAX_IMPORT_BODY_BYTES }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/import/route.ts b/apps/sim/app/api/v2/workflows/import/route.ts index 8ddd288d6a3..c4a9a7ea310 100644 --- a/apps/sim/app/api/v2/workflows/import/route.ts +++ b/apps/sim/app/api/v2/workflows/import/route.ts @@ -1,92 +1,37 @@ -import { createLogger } from '@sim/logger' import { v2ImportWorkflowContract } from '@/lib/api/contracts/v2/workflows' -import { - importWorkflowIntoWorkspace, - MAX_IMPORT_BODY_BYTES, -} from '@/lib/workflows/operations/import-workflow' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { - type V2ErrorCode, - v2Data, - v2Error, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2WorkflowImportAPI') +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { importWorkflow } from '@/lib/workflows/application/import-export' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { MAX_IMPORT_BODY_BYTES } from '@/lib/workflows/operations/import-workflow' export const dynamic = 'force-dynamic' export const revalidate = 0 -const ERROR_CODE_BY_STATUS: Record<number, V2ErrorCode> = { - 400: 'BAD_REQUEST', - 404: 'NOT_FOUND', - 409: 'CONFLICT', - 423: 'LOCKED', - 500: 'INTERNAL_ERROR', -} - -/** - * POST /api/v2/workflows/import - * - * Creates a new workflow in the target workspace from an export payload - * produced by `GET /api/v2/workflows/{id}/export`. The shared - * {@link importWorkflowIntoWorkspace} pipeline does the heavy lifting; this - * route authenticates and renders the v2 envelope. - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2ImportWorkflowContract, - rateLimitEndpoint: 'workflow-import', - parseOptions: { - maxBodyBytes: MAX_IMPORT_BODY_BYTES, - }, - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - const { workspaceId, folderPath, name, description } = input.body - - logger.info(`[${requestId}] Importing workflow into workspace ${workspaceId}`, { - userId, + auth: v2ApiKeyAuth, + operation: workflowOperations.import, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.import, + parseOptions: { maxBodyBytes: MAX_IMPORT_BODY_BYTES }, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + folderPath: body.folderPath, + name: body.name, + description: body.description, + workflow: body.workflow, + }), + useCase: importWorkflow, + present: ({ workflow, folderPath }) => ({ + data: { + id: workflow.id, + name: workflow.name, + description: workflow.description, + workspaceId: workflow.workspaceId, folderPath, - }) - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const resolution = await resolveFolderPathIdentity({ - workspaceId, - resourceType: 'workflow', - path: folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - - const result = await importWorkflowIntoWorkspace({ - workspaceId, - folderId: resolution.folderId ?? undefined, - name, - description, - workflow: input.body.workflow, - userId, - requestId, - }) - - if (!result.success) { - return v2Error(ERROR_CODE_BY_STATUS[result.status] ?? 'INTERNAL_ERROR', result.error, { - status: result.status, - details: result.details, - }) - } - - return v2Data( - { - id: result.workflow.id, - name: result.workflow.name, - description: result.workflow.description, - workspaceId: result.workflow.workspaceId, - folderPath: folderPathForId(resolution.index, result.workflow.folderId), - createdAt: result.workflow.createdAt.toISOString(), - updatedAt: result.workflow.updatedAt.toISOString(), - }, - { rateLimit, status: 201 } - ) - }, + createdAt: workflow.createdAt.toISOString(), + updatedAt: workflow.updatedAt.toISOString(), + }, + }), }) diff --git a/apps/sim/app/api/v2/workflows/lib/access.ts b/apps/sim/app/api/v2/workflows/lib/access.ts deleted file mode 100644 index 404b820ac89..00000000000 --- a/apps/sim/app/api/v2/workflows/lib/access.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { workflow as workflowTable } from '@sim/db/schema' -import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' -import type { NextRequest, NextResponse } from 'next/server' -import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' -import { authenticateV1Request } from '@/app/api/v1/auth' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Error } from '@/app/api/v2/lib/response' - -type WorkflowRecord = typeof workflowTable.$inferSelect - -export type V2WorkflowAccess = - | { - ok: true - userId: string - keyType: 'personal' | 'workspace' | undefined - workflow: WorkflowRecord - } - | { ok: false; response: NextResponse } - -/** - * X-API-Key auth + workflow authorization for the v2 execution sub-resources. - * Authorization failures and workspace-key scope mismatches are masked as 404 - * so cross-workspace workflow existence never leaks; personal keys honor the - * workspace's `allowPersonalApiKeys` setting. - */ -export async function resolveV2WorkflowAccess( - request: NextRequest, - workflowId: string, - action: 'read' | 'write' -): Promise<V2WorkflowAccess> { - const auth = await authenticateV1Request(request) - if (!auth.authenticated || !auth.userId) { - return { ok: false, response: v2Error('UNAUTHORIZED', auth.error || 'Unauthorized') } - } - - const gate = await v2ApiGateError(auth.userId) - if (gate) return { ok: false, response: gate } - - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId: auth.userId, - action, - }) - if (!authorization.allowed || !authorization.workflow) { - return { ok: false, response: v2Error('NOT_FOUND', 'Workflow not found') } - } - const workflow = authorization.workflow as WorkflowRecord - - if (auth.keyType === 'workspace' && workflow.workspaceId !== auth.workspaceId) { - return { ok: false, response: v2Error('NOT_FOUND', 'Workflow not found') } - } - if (auth.keyType === 'personal' && workflow.workspaceId) { - const settings = await getWorkspaceBillingSettings(workflow.workspaceId) - if (!settings?.allowPersonalApiKeys) { - return { - ok: false, - response: v2Error('FORBIDDEN', 'Personal API keys are not allowed for this workspace'), - } - } - } - - return { ok: true, userId: auth.userId, keyType: auth.keyType, workflow } -} diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts index 9ab8c6575ae..6a3e094b23c 100644 --- a/apps/sim/app/api/v2/workflows/route.test.ts +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -1,434 +1,179 @@ /** * @vitest-environment node - * - * Public v2 workflow list: the search/sort/filter convention, and the keyset - * cursor's binding to the sort it was minted under. The assertions look at the - * WHERE/ORDER BY the route hands drizzle, because that is the whole point of - * the change — a search must narrow the query, not the result. */ -import { - dbChainMockFns, - flattenMockConditions, - queueTableRows, - resetDbChainMock, - schemaMock, -} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockPerformCreateWorkflow, - mockAssertFolderMutable, - mockLoadActiveFolderPathIndex, - FolderLockedErrorMock, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformCreateWorkflow: vi.fn(), - mockAssertFolderMutable: vi.fn(), - mockLoadActiveFolderPathIndex: vi.fn(), - FolderLockedErrorMock: class FolderLockedError extends Error { - status = 423 - }, +const mocks = vi.hoisted(() => ({ + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + createWorkflow: vi.fn(), + listWorkflows: vi.fn(), + gate: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/workflows/application/create-workflow', () => ({ + createWorkflow: { operation: { id: 'workflows.create' }, execute: mocks.createWorkflow }, })) -vi.mock('@/lib/workflows/orchestration', () => ({ - performCreateWorkflow: mockPerformCreateWorkflow, +vi.mock('@/lib/workflows/application/list-workflows', () => ({ + listWorkflows: { operation: { id: 'workflows.list' }, execute: mocks.listWorkflows }, })) -vi.mock('@sim/platform-authz/workflow', () => ({ - assertFolderMutable: mockAssertFolderMutable, - FolderLockedError: FolderLockedErrorMock, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +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: vi.fn().mockResolvedValue(null), -})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) import { GET, POST } from '@/app/api/v2/workflows/route' -const WS = 'workspace-1' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), +const WORKSPACE_ID = 'workspace-1' +const WORKFLOW = { + id: 'workflow-1', + name: 'Daily digest', + description: null, + folderId: null, + folderPath: '/', + workspaceId: WORKSPACE_ID, + isDeployed: false, + deployedAt: null, + runCount: 3, + lastRunAt: null, + sortOrder: 0, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-02T00:00:00.000Z'), } -function buildRow(overrides: Record<string, unknown> = {}) { - return { - id: 'wf_1', - name: 'Daily digest', - description: null, - folderId: null, - workspaceId: WS, - isDeployed: false, - deployedAt: null, - runCount: 3, - lastRunAt: null, - sortOrder: 0, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - ...overrides, - } +const workspaceAuth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'workspace-key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:workspace-key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } -const callList = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/workflows?${query}`)) - -/** The condition nodes the route passed to `.where()` on the last query. */ -const lastConditions = () => - flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).filter(Boolean) - -const lastOrderBy = () => dbChainMockFns.orderBy.mock.calls.at(-1) ?? [] - -/** - * Timestamp keys order on `date_trunc('milliseconds', col)` rather than the raw - * column, so the mocked `sql` fragment carries the column in its interpolated - * values rather than being the column itself. - */ -const truncatedColumnOf = (entry: { column: { values?: unknown[] } }) => entry.column?.values?.[0] +const personalAuth = { + principal: { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'personal-key-1', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} -describe('GET /api/v2/workflows', () => { +describe('/api/v2/workflows', () => { beforeEach(() => { vi.clearAllMocks() - resetDbChainMock() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map(), - pathById: new Map(), - idByPath: new Map(), + mocks.authenticateV2ApiKey.mockResolvedValue(workspaceAuth) + mocks.gate.mockResolvedValue(null) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-01T01:00:00.000Z'), }) - }) - - it('narrows the query with a case-insensitive substring match on the name', async () => { - queueTableRows(schemaMock.workflow, [buildRow()]) - - const res = await callList(`workspaceId=${WS}&search=digest`) - - expect(res.status).toBe(200) - const search = lastConditions().find((c) => c.type === 'ilike') - expect(search).toMatchObject({ column: schemaMock.workflow.name, pattern: '%digest%' }) - }) - - it('escapes LIKE wildcards so a caller cannot widen its own match', async () => { - queueTableRows(schemaMock.workflow, []) - - await callList(`workspaceId=${WS}&search=${encodeURIComponent('100%_x')}`) - - expect(lastConditions().find((c) => c.type === 'ilike')).toMatchObject({ - pattern: '%100\\%\\_x%', + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-01T01:00:00.000Z'), }) + mocks.listWorkflows.mockResolvedValue({ + workflows: [WORKFLOW], + nextCursorKeys: null, + sortBy: 'position', + sortOrder: 'asc', + }) + mocks.createWorkflow.mockResolvedValue({ workflow: WORKFLOW, folderPath: '/' }) }) - it('adds no search condition when the caller did not search', async () => { - queueTableRows(schemaMock.workflow, [buildRow()]) - - await callList(`workspaceId=${WS}`) - - expect(lastConditions().some((c) => c.type === 'ilike')).toBe(false) - }) - - it('treats folderPath=/ as root-only while omission lists every folder', async () => { - queueTableRows(schemaMock.workflow, [buildRow()]) - - await callList(`workspaceId=${WS}&folderPath=%2F`) - - expect( - lastConditions().some( - (condition) => - condition.type === 'isNull' && condition.column === schemaMock.workflow.folderId - ) - ).toBe(true) - }) - - it('400s on a sort field outside the enum instead of letting it reach the query', async () => { - const res = await callList(`workspaceId=${WS}&sortBy=(select 1)`) - - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(dbChainMockFns.where).not.toHaveBeenCalled() - }) - - it('400s on a sort direction outside the enum', async () => { - const res = await callList(`workspaceId=${WS}&sortOrder=sideways`) - - expect(res.status).toBe(400) - expect(dbChainMockFns.where).not.toHaveBeenCalled() - }) - - it('400s on an empty search rather than treating it as unsearched', async () => { - const res = await callList(`workspaceId=${WS}&search=`) - - expect(res.status).toBe(400) - expect(dbChainMockFns.where).not.toHaveBeenCalled() - }) - - it('defaults to the workspace position ordering', async () => { - queueTableRows(schemaMock.workflow, [buildRow()]) - - await callList(`workspaceId=${WS}`) - - const orderBy = lastOrderBy() - expect(orderBy.map((e: { type: string }) => e.type)).toEqual(['asc', 'asc', 'asc']) - expect(orderBy[0].column).toBe(schemaMock.workflow.sortOrder) - expect(truncatedColumnOf(orderBy[1])).toBe(schemaMock.workflow.createdAt) - expect(orderBy[2].column).toBe(schemaMock.workflow.id) - }) - - it('orders by the requested field and direction', async () => { - queueTableRows(schemaMock.workflow, [buildRow()]) - - await callList(`workspaceId=${WS}&sortBy=name&sortOrder=desc`) - - expect(lastOrderBy()).toEqual([ - { type: 'desc', column: schemaMock.workflow.name }, - { type: 'desc', column: schemaMock.workflow.id }, - ]) - }) - - it('combines a filter with a cursor into one consistent page', async () => { - queueTableRows(schemaMock.workflow, [buildRow(), buildRow({ id: 'wf_2', name: 'Zebra' })]) - - const first = await callList(`workspaceId=${WS}&search=a&sortBy=name&limit=1`) - const body = await first.json() - - expect(body.data).toHaveLength(1) - expect(body.nextCursor).not.toBeNull() - - queueTableRows(schemaMock.workflow, [buildRow({ id: 'wf_2', name: 'Zebra' })]) - const second = await callList( - `workspaceId=${WS}&search=a&sortBy=name&limit=1&cursor=${encodeURIComponent(body.nextCursor)}` - ) - - expect(second.status).toBe(200) - const conditions = lastConditions() - // The filter survives the cursor page, and the keyset resumes from the last row. - expect(conditions.find((c) => c.type === 'ilike')).toMatchObject({ pattern: '%a%' }) - expect(conditions.some((c) => c.type === 'or')).toBe(true) - }) - - it('terminates pagination once a filtered page is not full', async () => { - queueTableRows(schemaMock.workflow, [buildRow()]) - - const res = await callList(`workspaceId=${WS}&search=digest&limit=50`) + it('authenticates and rate limits before parsing list input', async () => { + const response = await GET(new NextRequest('http://localhost/api/v2/workflows')) - expect((await res.json()).nextCursor).toBeNull() + expect(response.status).toBe(400) + expect(mocks.authenticateV2ApiKey).toHaveBeenCalledOnce() + expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(mocks.listWorkflows).not.toHaveBeenCalled() }) - it('400s when a cursor is replayed under a different sort', async () => { - queueTableRows(schemaMock.workflow, [buildRow(), buildRow({ id: 'wf_2' })]) - - const first = await callList(`workspaceId=${WS}&sortBy=name&limit=1`) - const { nextCursor } = await first.json() - vi.clearAllMocks() - - const res = await callList( - `workspaceId=${WS}&sortBy=createdAt&limit=1&cursor=${encodeURIComponent(nextCursor)}` + it('lists through the workspace principal and preserves rate headers', async () => { + const request = new NextRequest( + `http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}`, + { headers: { 'x-api-key': 'secret' } } ) - - expect(res.status).toBe(400) - expect((await res.json()).error.message).toMatch(/cursor does not match/i) - expect(dbChainMockFns.where).not.toHaveBeenCalled() - }) - - it('400s on a malformed cursor instead of silently restarting from page one', async () => { - const res = await callList(`workspaceId=${WS}&cursor=not-a-cursor`) - - expect(res.status).toBe(400) - expect(dbChainMockFns.where).not.toHaveBeenCalled() + const response = await GET(request) + + expect(response.status).toBe(200) + expect(response.headers.get('x-ratelimit-limit')).toBe('100') + expect(response.headers.get('x-ratelimit-remaining')).toBe('99') + expect(await response.json()).toEqual({ + data: [ + { + id: WORKFLOW.id, + name: WORKFLOW.name, + description: null, + folderPath: '/', + workspaceId: WORKSPACE_ID, + isDeployed: false, + deployedAt: null, + runCount: 3, + lastRunAt: null, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-02T00:00:00.000Z', + }, + ], + nextCursor: null, + }) + expect(mocks.listWorkflows).toHaveBeenCalledWith({ + principal: workspaceAuth.principal, + input: expect.objectContaining({ workspaceId: WORKSPACE_ID, limit: 50 }), + request, + }) }) -}) - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} -const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' } - -const CREATED = { - id: 'wf-1', - name: 'Support Agent', - description: 'Handles tickets', - workspaceId: 'workspace-1', - folderId: null, - sortOrder: 0, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-01T00:00:00Z'), - startBlockId: 'block-1', - subBlockValues: {}, -} - -const VALID_BODY = { - workspaceId: 'workspace-1', - name: 'Support Agent', - description: 'Handles tickets', -} - -function callPost(body: unknown) { - return POST( - new NextRequest('http://localhost:3000/api/v2/workflows', { + it('creates through a personal-key principal with the exact 201 contract', async () => { + mocks.authenticateV2ApiKey.mockResolvedValue(personalAuth) + const request = new NextRequest('http://localhost/api/v2/workflows', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, name: WORKFLOW.name }), }) - ) -} - -describe('POST /api/v2/workflows', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockAssertFolderMutable.mockResolvedValue(undefined) - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map([['fld-1', { id: 'fld-1', name: 'Locked', parentId: null }]]), - pathById: new Map([['fld-1', '/Locked']]), - idByPath: new Map([['/Locked', 'fld-1']]), + const response = await POST(request) + + expect(response.status).toBe(201) + expect((await response.json()).data.id).toBe(WORKFLOW.id) + expect(mocks.createWorkflow).toHaveBeenCalledWith({ + principal: personalAuth.principal, + input: { workspaceId: WORKSPACE_ID, name: WORKFLOW.name }, + request, }) - mockPerformCreateWorkflow.mockResolvedValue({ success: true, workflow: CREATED }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callPost(VALID_BODY) - - expect(res.status).toBe(404) - expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() - }) - - it('400s when name is missing', async () => { - const res = await callPost({ workspaceId: 'workspace-1' }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() - }) - - it('400s on an unknown body field', async () => { - const res = await callPost({ ...VALID_BODY, sortOrder: 3 }) - expect(res.status).toBe(400) - expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED) - const res = await callPost(VALID_BODY) - expect(res.status).toBe(403) - expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() }) - it('requires write access on the target workspace', async () => { - await callPost(VALID_BODY) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.anything(), - 'user-1', - 'workspace-1', - 'write' + it('hides infrastructure failures behind the safe v2 500 envelope', async () => { + mocks.listWorkflows.mockRejectedValue(new Error('database connection details')) + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}`) ) - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callPost(VALID_BODY) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('423s when the destination folder is locked', async () => { - mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked')) - const res = await callPost({ ...VALID_BODY, folderPath: '/Locked' }) - expect(res.status).toBe(423) - expect((await res.json()).error.code).toBe('LOCKED') - expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() - }) - - it('404s a path outside the workspace without ever reading its lock state', async () => { - const res = await callPost({ ...VALID_BODY, folderPath: '/Elsewhere' }) - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockAssertFolderMutable).not.toHaveBeenCalled() - expect(mockPerformCreateWorkflow).not.toHaveBeenCalled() - }) - - it('resolves the canonical path before checking mutability', async () => { - await callPost({ ...VALID_BODY, folderPath: '/Locked' }) - - expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith( - 'workspace-1', - 'workflow', - expect.any(Object) - ) - expect(mockAssertFolderMutable).toHaveBeenCalledWith('fld-1') - }) - - it('skips the containment check when no folder is supplied', async () => { - await callPost(VALID_BODY) - expect(mockAssertFolderMutable).toHaveBeenCalledWith(null) - }) - - it('409s when the name is already taken in the target folder', async () => { - mockPerformCreateWorkflow.mockResolvedValue({ - success: false, - error: 'A workflow named "Support Agent" already exists in this folder', - errorCode: 'conflict', - }) - const res = await callPost(VALID_BODY) - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - }) - - it('creates the workflow and returns 201 with the public shape', async () => { - const res = await callPost(VALID_BODY) - const body = await res.json() - - expect(res.status).toBe(201) - expect(body).toEqual({ - data: { - id: 'wf-1', - name: 'Support Agent', - description: 'Handles tickets', - folderPath: '/', - workspaceId: 'workspace-1', - isDeployed: false, - deployedAt: null, - runCount: 0, - lastRunAt: null, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - }, + expect(response.status).toBe(500) + expect(await response.json()).toMatchObject({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, }) - expect(res.headers.get('X-RateLimit-Remaining')).toBe('99') - expect(mockPerformCreateWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'user-1', - workspaceId: 'workspace-1', - name: 'Support Agent', - description: 'Handles tickets', - folderId: null, - }) - ) }) }) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index 3e0a42c8171..f4dc34c3ef7 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -1,150 +1,88 @@ -import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' +import type { V2WorkflowListItem } from '@/lib/api/contracts/v2/workflows' +import { v2CreateWorkflowContract, v2ListWorkflowsContract } from '@/lib/api/contracts/v2/workflows' +import { INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { - type V2WorkflowListItem, - v2CreateWorkflowContract, - v2ListWorkflowsContract, -} from '@/lib/api/contracts/v2/workflows' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { performCreateWorkflow } from '@/lib/workflows/orchestration' -import { InvalidWorkflowListCursorError, listWorkspaceWorkflows } from '@/lib/workflows/queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { - folderPathForId, - resolveFolderPathId, - resolveFolderPathIdentity, -} from '@/app/api/v2/lib/folders' -import { - cursorSortKey, - decodeSortedCursor, - encodeSortedCursor, - v2CursorList, - v2CursorSortError, - v2Data, - v2Error, - v2ErrorForOrchestration, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { OrchestrationError } from '@/lib/core/orchestration/types' +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, decodeSortedCursor, encodeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListWorkflowsContract, - rateLimitEndpoint: 'workflows', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const params = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const folderIndex = await loadActiveFolderPathIndex(params.workspaceId, 'workflow') - const folderId = - params.folderPath === undefined - ? undefined - : resolveFolderPathId(folderIndex, params.folderPath) - if (params.folderPath !== undefined && folderId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found') + auth: v2ApiKeyAuth, + operation: workflowOperations.list, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => { + const sort = cursorSortKey(query.sortBy, query.sortOrder) + const decoded = decodeSortedCursor(query.cursor, sort) + if (decoded.status === 'invalid') { + throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) } - - const sortKey = cursorSortKey(params.sortBy, params.sortOrder) - const decoded = decodeSortedCursor(params.cursor, sortKey) - if (decoded.status === 'invalid') return v2CursorSortError() - - let result - try { - result = await listWorkspaceWorkflows({ - workspaceId: params.workspaceId, - folderId, - deployedOnly: params.deployedOnly, - search: params.search, - sortBy: params.sortBy, - sortOrder: params.sortOrder, - cursorKeys: decoded.status === 'ok' ? decoded.keys : undefined, - limit: params.limit, - }) - } catch (error) { - if (error instanceof InvalidWorkflowListCursorError) return v2CursorSortError() - throw error + return { + workspaceId: query.workspaceId, + folderPath: query.folderPath, + deployedOnly: query.deployedOnly, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + cursorKeys: decoded.status === 'ok' ? decoded.keys : undefined, + limit: query.limit, } - - const nextCursor = result.nextCursorKeys - ? encodeSortedCursor(sortKey, result.nextCursorKeys) - : null - - const formatted: V2WorkflowListItem[] = result.data.map((w) => ({ - id: w.id, - name: w.name, - description: w.description, - folderPath: folderPathForId(folderIndex, w.folderId), - workspaceId: w.workspaceId ?? params.workspaceId, - isDeployed: w.isDeployed, - deployedAt: w.deployedAt?.toISOString() ?? null, - runCount: w.runCount, - lastRunAt: w.lastRunAt?.toISOString() ?? null, - createdAt: w.createdAt.toISOString(), - updatedAt: w.updatedAt.toISOString(), - })) - - return v2CursorList(formatted, nextCursor, { rateLimit }) }, + useCase: listWorkflows, + present: ({ workflows, nextCursorKeys, sortBy, sortOrder }) => ({ + data: workflows.map( + (workflow): V2WorkflowListItem => ({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + folderPath: workflow.folderPath, + workspaceId: workflow.workspaceId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt?.toISOString() ?? null, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt?.toISOString() ?? null, + createdAt: workflow.createdAt.toISOString(), + updatedAt: workflow.updatedAt.toISOString(), + }) + ), + nextCursor: nextCursorKeys + ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextCursorKeys) + : null, + }), }) -/** POST /api/v2/workflows — Create an empty workflow in a workspace. */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateWorkflowContract, - rateLimitEndpoint: 'workflows', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { workspaceId, name, description, folderPath } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const resolution = await resolveFolderPathIdentity({ - workspaceId, - resourceType: 'workflow', - path: folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - - await assertFolderMutable(resolution.folderId) - const result = await performCreateWorkflow({ - userId, - workspaceId, - name, - description, - folderId: resolution.folderId, - requestId, - }) - - if (!result.success || !result.workflow) { - return v2ErrorForOrchestration( - result.errorCode, - result.error ?? 'Failed to create workflow' - ) - } - - const created = result.workflow - const item: V2WorkflowListItem = { - id: created.id, - name: created.name, - description: created.description ?? null, - folderPath: folderPathForId(resolution.index, created.folderId), - workspaceId: created.workspaceId, - isDeployed: false, - deployedAt: null, - runCount: 0, - lastRunAt: null, - createdAt: created.createdAt.toISOString(), - updatedAt: created.updatedAt.toISOString(), - } - - return v2Data(item, { rateLimit, status: 201 }) - } catch (error) { - if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) - - throw error - } - }, + auth: v2ApiKeyAuth, + operation: workflowOperations.create, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ body }) => body, + useCase: createWorkflow, + present: ({ workflow, folderPath }) => ({ + data: { + id: workflow.id, + name: workflow.name, + description: workflow.description ?? null, + folderPath, + workspaceId: workflow.workspaceId, + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + createdAt: workflow.createdAt.toISOString(), + updatedAt: workflow.updatedAt.toISOString(), + }, + }), }) diff --git a/apps/sim/app/api/v2/workflows/utils.ts b/apps/sim/app/api/v2/workflows/utils.ts deleted file mode 100644 index 450521e2cca..00000000000 --- a/apps/sim/app/api/v2/workflows/utils.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { PermissionType } from '@sim/platform-authz/workspace' -import { - type DeploymentWorkflowTarget, - getDeploymentWorkflowTarget, -} from '@/lib/workflows/deployments/queries' -import { type RateLimitResult, resolveWorkspaceAccess } from '@/app/api/v1/middleware' - -/** Resolves an authorized active workflow while keeping the v2 response adapter route-local. */ -export async function resolveV2WorkflowTarget( - rateLimit: RateLimitResult, - userId: string, - workflowId: string, - level: PermissionType = 'read' -): Promise<DeploymentWorkflowTarget | null> { - const target = await getDeploymentWorkflowTarget(workflowId) - if (!target) return null - - const accessError = await resolveWorkspaceAccess(rateLimit, userId, target.workspaceId, level) - return accessError ? null : target -} diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 4fb34919b76..30960639858 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -7,15 +7,13 @@ import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { updatePublicApiContract } from '@/lib/api/contracts/deployments' import { parseRequest } from '@/lib/api/server' -import { statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { InternalUnauthenticatedError, internalSessionAuth } from '@/lib/api/server/routes' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { - getWorkflowDeploymentSummary, - performFullDeploy, - performFullUndeploy, -} from '@/lib/workflows/orchestration' +import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/application/deployments' +import { getWorkflowDeploymentSummary } from '@/lib/workflows/orchestration' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { checkNeedsRedeployment, @@ -94,9 +92,11 @@ export const GET = withRouteHandler( latestDeploymentAttempt: deploymentSummary.latestDeploymentAttempt, warnings: deploymentSummary.warnings, }) - } catch (error: any) { - logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error) - return createErrorResponse(error.message || 'Failed to fetch deployment information', 500) + } catch (error: unknown) { + logger.error(`[${requestId}] Error fetching deployment info: ${id}`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return createErrorResponse('Failed to fetch deployment information', 500) } } ) @@ -107,47 +107,31 @@ export const POST = withRouteHandler( const { id } = await params try { - const { - error, - session, - workflow: workflowData, - } = await validateWorkflowPermissions(id, requestId, 'admin') - if (error) { - return createErrorResponse(error.message, error.status) - } - - const actorUserId: string | null = session?.user?.id ?? null - if (!actorUserId) { - logger.warn(`[${requestId}] Unable to resolve actor user for workflow deployment: ${id}`) - return createErrorResponse('Unable to determine deploying user', 400) - } - await assertWorkflowMutable(id) - - const result = await performFullDeploy({ - workflowId: id, - userId: actorUserId, - requestId, + const principal = await internalSessionAuth.authenticate() + const result = await deployWorkflow.execute({ + principal, + input: { workflowId: id, requestId }, + request, }) - if (!result.success) { - return createErrorResponse( - result.error || 'Failed to deploy workflow', - statusForOrchestrationError(result.errorCode) - ) - } - const isDeployed = Boolean(result.activeDeployment) const attemptActivated = result.latestDeploymentAttempt?.status === 'active' logger.info( `[${requestId}] Workflow deployment ${attemptActivated ? 'activated' : 'accepted for preparation'}: ${id}` ) - const responseApiKeyInfo = workflowData!.workspaceId - ? 'Workspace API keys' - : 'Personal API keys' + captureServerEvent( + principal.userId, + 'workflow_deployed', + { workflow_id: result.workflowId, workspace_id: result.workspaceId }, + { + groups: { workspace: result.workspaceId }, + setOnce: { first_workflow_deployed_at: new Date().toISOString() }, + } + ) return createSuccessResponse({ - apiKey: responseApiKeyInfo, + apiKey: 'Workspace API keys', isDeployed, deployedAt: result.deployedAt, warnings: result.warnings, @@ -155,12 +139,20 @@ export const POST = withRouteHandler( latestDeploymentAttempt: result.latestDeploymentAttempt, }) } catch (error: unknown) { - if (error instanceof WorkflowLockedError) { - return createErrorResponse(error.message, error.status) + if (error instanceof InternalUnauthenticatedError) { + return createErrorResponse(error.message, 401) } - const message = getErrorMessage(error, 'Failed to deploy workflow') - logger.error(`[${requestId}] Error deploying workflow: ${id}`, { error }) - return createErrorResponse(message, 500) + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + return createErrorResponse( + orchestrationError.message, + statusForOrchestrationError(orchestrationError.code) + ) + } + logger.error(`[${requestId}] Error deploying workflow: ${id}`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return createErrorResponse('Failed to deploy workflow', 500) } } ) @@ -230,45 +222,31 @@ export const PATCH = withRouteHandler( if (error instanceof WorkflowLockedError) { return createErrorResponse(error.message, error.status) } - const message = getErrorMessage(error, 'Failed to update deployment settings') - logger.error(`[${requestId}] Error updating deployment settings`, { error }) - return createErrorResponse(message, 500) + logger.error(`[${requestId}] Error updating deployment settings`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return createErrorResponse('Failed to update deployment settings', 500) } } ) export const DELETE = withRouteHandler( - async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() const { id } = await params try { - const { - error, - session, - workflow: workflowData, - } = await validateWorkflowPermissions(id, requestId, 'admin') - if (error) { - return createErrorResponse(error.message, error.status) - } - await assertWorkflowMutable(id) - - const result = await performFullUndeploy({ - workflowId: id, - userId: session!.user.id, - requestId, + const principal = await internalSessionAuth.authenticate() + const result = await undeployWorkflow.execute({ + principal, + input: { workflowId: id, requestId }, + request, }) - - if (!result.success) { - return createErrorResponse(result.error || 'Failed to undeploy workflow', 500) - } - - const wsId = workflowData?.workspaceId captureServerEvent( - session!.user.id, + principal.userId, 'workflow_undeployed', - { workflow_id: id, workspace_id: wsId ?? '' }, - wsId ? { groups: { workspace: wsId } } : undefined + { workflow_id: result.workflowId, workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } ) return createSuccessResponse({ @@ -278,12 +256,20 @@ export const DELETE = withRouteHandler( warnings: result.warnings, }) } catch (error: unknown) { - if (error instanceof WorkflowLockedError) { - return createErrorResponse(error.message, error.status) + if (error instanceof InternalUnauthenticatedError) { + return createErrorResponse(error.message, 401) } - const message = getErrorMessage(error, 'Failed to undeploy workflow') - logger.error(`[${requestId}] Error undeploying workflow: ${id}`, { error }) - return createErrorResponse(message, 500) + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + return createErrorResponse( + orchestrationError.message, + statusForOrchestrationError(orchestrationError.code) + ) + } + logger.error(`[${requestId}] Error undeploying workflow: ${id}`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return createErrorResponse('Failed to undeploy workflow', 500) } } ) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index 5d5300ec13d..42516bd676a 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -4,14 +4,14 @@ import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { updateDeploymentVersionMetadataContract } from '@/lib/api/contracts/deployments' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { InternalUnauthenticatedError, internalSessionAuth } from '@/lib/api/server/routes' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performActivateVersion } from '@/lib/workflows/orchestration' -import { - getWorkflowDeploymentVersion, - updateDeploymentVersionMetadata, -} from '@/lib/workflows/persistence/utils' +import { captureServerEvent } from '@/lib/posthog/server' +import { activateWorkflowVersion } from '@/lib/workflows/application/deployments' +import { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version' +import { updateDeploymentVersionMetadata } from '@/lib/workflows/persistence/utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -30,28 +30,36 @@ export const GET = withRouteHandler( const { id, version } = await params try { - const { error } = await validateWorkflowPermissions(id, requestId, 'read') - if (error) { - return createErrorResponse(error.message, error.status) - } + const principal = await internalSessionAuth.authenticate() const versionNum = Number(version) if (!Number.isFinite(versionNum)) { return createErrorResponse('Invalid version', 400) } - const row = await getWorkflowDeploymentVersion(id, versionNum) - if (!row?.state) { - return createErrorResponse('Deployment version not found', 404) - } + const { version: row } = await readWorkflowVersion.execute({ + principal, + input: { workflowId: id, version: versionNum }, + request, + }) return createSuccessResponse({ deployedState: row.state }) - } catch (error: any) { + } catch (error: unknown) { + if (error instanceof InternalUnauthenticatedError) { + return createErrorResponse(error.message, 401) + } + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + return createErrorResponse( + orchestrationError.message, + statusForOrchestrationError(orchestrationError.code) + ) + } logger.error( `[${requestId}] Error fetching deployment version ${version} for workflow ${id}`, - error + { error } ) - return createErrorResponse(error.message || 'Failed to fetch deployment version', 500) + return createErrorResponse('Failed to fetch deployment version', 500) } } ) @@ -61,6 +69,7 @@ export const PATCH = withRouteHandler( const requestId = generateRequestId() try { + const principal = await internalSessionAuth.authenticate() const parsed = await parseRequest(updateDeploymentVersionMetadataContract, request, context, { validationErrorResponse: (error) => createErrorResponse(getValidationErrorMessage(error, 'Invalid request body'), 400), @@ -70,43 +79,16 @@ export const PATCH = withRouteHandler( const { id, version } = parsed.data.params const { name, description, isActive } = parsed.data.body - // Activation requires admin permission, other updates require write - const requiredPermission = isActive ? 'admin' : 'write' - const { error, session } = await validateWorkflowPermissions( - id, - requestId, - requiredPermission - ) - if (error) { - return createErrorResponse(error.message, error.status) - } - const versionNum = version // Handle activation if (isActive) { - const actorUserId = session?.user?.id - if (!actorUserId) { - logger.warn( - `[${requestId}] Unable to resolve actor user for deployment activation: ${id}` - ) - return createErrorResponse('Unable to determine activating user', 400) - } - - const activateResult = await performActivateVersion({ - workflowId: id, - version: versionNum, - userId: actorUserId, - requestId, + const activateResult = await activateWorkflowVersion.execute({ + principal, + input: { workflowId: id, version: versionNum, transition: 'activate', requestId }, + request, }) - if (!activateResult.success) { - return createErrorResponse( - activateResult.error || 'Failed to activate deployment', - statusForOrchestrationError(activateResult.errorCode) - ) - } - let updatedName: string | null | undefined let updatedDescription: string | null | undefined if (name !== undefined || description !== undefined) { @@ -142,6 +124,17 @@ export const PATCH = withRouteHandler( } } + captureServerEvent( + principal.userId, + 'deployment_version_activated', + { + workflow_id: activateResult.workflowId, + workspace_id: activateResult.workspaceId, + version: versionNum, + }, + { groups: { workspace: activateResult.workspaceId } } + ) + return createSuccessResponse({ success: true, deployedAt: activateResult.deployedAt ?? null, @@ -153,6 +146,11 @@ export const PATCH = withRouteHandler( }) } + const { error } = await validateWorkflowPermissions(id, requestId, 'write') + if (error) { + return createErrorResponse(error.message, error.status) + } + // Handle name/description updates (shared with the update_deployment_version copilot tool) const updated = await updateDeploymentVersionMetadata({ workflowId: id, @@ -171,9 +169,19 @@ export const PATCH = withRouteHandler( }) return createSuccessResponse({ name: updated.name, description: updated.description }) - } catch (error: any) { - logger.error(`[${requestId}] Error updating deployment version`, error) - return createErrorResponse(error.message || 'Failed to update deployment version', 500) + } catch (error: unknown) { + if (error instanceof InternalUnauthenticatedError) { + return createErrorResponse(error.message, 401) + } + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + return createErrorResponse( + orchestrationError.message, + statusForOrchestrationError(orchestrationError.code) + ) + } + logger.error(`[${requestId}] Error updating deployment version`, { error }) + return createErrorResponse('Failed to update deployment version', 500) } } ) diff --git a/apps/sim/app/api/workflows/[id]/deployments/route.ts b/apps/sim/app/api/workflows/[id]/deployments/route.ts index 4a1a9998ca6..f958f5b5de3 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/route.ts @@ -2,10 +2,11 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { listDeploymentVersionsContract } from '@/lib/api/contracts/deployments' import { parseRequest } from '@/lib/api/server' +import { InternalUnauthenticatedError, internalSessionAuth } from '@/lib/api/server/routes' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { listWorkflowVersions } from '@/lib/workflows/persistence/utils' -import { validateWorkflowPermissions } from '@/lib/workflows/utils' +import { listWorkflowVersions } from '@/lib/workflows/application/list-workflow-versions' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('WorkflowDeploymentsListAPI') @@ -16,26 +17,37 @@ export const runtime = 'nodejs' export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() - const parsed = await parseRequest(listDeploymentVersionsContract, request, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params try { - const { error } = await validateWorkflowPermissions(id, requestId, 'read') - if (error) { - return createErrorResponse(error.message, error.status) - } + const principal = await internalSessionAuth.authenticate() + const parsed = await parseRequest(listDeploymentVersionsContract, request, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params - const { versions: rows } = await listWorkflowVersions(id) + const { versions: rows } = await listWorkflowVersions.execute({ + principal, + input: { workflowId: id }, + request, + }) const versions = rows.map(({ deployedByName, ...version }) => ({ ...version, deployedBy: deployedByName, })) return createSuccessResponse({ versions }) - } catch (error: any) { - logger.error(`[${requestId}] Error listing deployments for workflow: ${id}`, error) - return createErrorResponse(error.message || 'Failed to list deployments', 500) + } catch (error: unknown) { + if (error instanceof InternalUnauthenticatedError) { + return createErrorResponse(error.message, 401) + } + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + return createErrorResponse( + orchestrationError.message, + statusForOrchestrationError(orchestrationError.code) + ) + } + logger.error(`[${requestId}] Error listing workflow deployments`, { error }) + return createErrorResponse('Failed to list deployments', 500) } } ) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 36b8c118fde..259e66f88bb 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -9,8 +9,10 @@ import { defineRouteContract } from '@/lib/api/contracts/types' import { V1_IMPORT_DESCRIPTION_MAX_LENGTH, V1_IMPORT_NAME_MAX_LENGTH, + v1DeployWorkflowBodySchema, v1DeployWorkflowDataSchema, v1ImportWorkflowBodySchema, + v1RollbackWorkflowBodySchema, v1RollbackWorkflowDataSchema, v1WorkflowExportPayloadSchema, } from '@/lib/api/contracts/v1/workflows' @@ -222,6 +224,7 @@ export const v2CreateWorkflowContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2WorkflowListItemSchema), + status: 201, }, }) @@ -268,7 +271,7 @@ export const v2CreateWorkflowFolderContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/folders', body: v2CreateFolderBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2WorkflowFolderDataSchema) }, + response: { mode: 'json', schema: v2DataResponse(v2WorkflowFolderDataSchema), status: 201 }, }) export const v2RelocateWorkflowFolderContract = defineRouteContract({ @@ -345,6 +348,7 @@ export const v2DeployWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/[id]/deploy', params: workflowIdParamsSchema, + body: v1DeployWorkflowBodySchema.optional().default({}), response: { mode: 'json', schema: v2DataResponse(v1DeployWorkflowDataSchema), @@ -365,6 +369,7 @@ export const v2RollbackWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows/[id]/rollback', params: workflowIdParamsSchema, + body: v1RollbackWorkflowBodySchema.optional().default({}), response: { mode: 'json', schema: v2DataResponse(v1RollbackWorkflowDataSchema), @@ -668,5 +673,6 @@ export const v2ImportWorkflowContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2ImportWorkflowDataSchema), + status: 201, }, }) diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts index f9fdcfd39c1..3eacbed3f3e 100644 --- a/apps/sim/lib/api/server/routes/index.ts +++ b/apps/sim/lib/api/server/routes/index.ts @@ -15,8 +15,10 @@ export { } from '@/lib/api/server/routes/internal-json-route' export { defineV2BinaryRoute } from '@/lib/api/server/routes/v2-binary-route' export { + admitV2Request, defineV2JsonRoute, type V2ErrorPolicy, + V2RouteInfrastructureError, v2ApiKeyAuth, v2OrchestrationErrorPolicy, v2RateLimits, diff --git a/apps/sim/lib/api/server/validation.ts b/apps/sim/lib/api/server/validation.ts index 06b433c7652..cc9f327d7bb 100644 --- a/apps/sim/lib/api/server/validation.ts +++ b/apps/sim/lib/api/server/validation.ts @@ -59,6 +59,8 @@ export interface ParseRequestOptions { * routes that legitimately accept large JSON payloads (e.g. inline file uploads). */ maxBodyBytes?: number + /** Treat an absent or whitespace-only body as `undefined` before contract validation. */ + optionalJsonBody?: boolean } export function serializeZodIssues(error: z.ZodError): z.core.$ZodIssue[] { @@ -164,7 +166,12 @@ export async function parseOptionalJsonBody( request: Request, maxBytes: number = DEFAULT_MAX_JSON_BODY_BYTES ): Promise< - { success: true; data: unknown } | { success: false; response: NextResponse<{ error: string }> } + | { success: true; data: unknown } + | { + success: false + reason: 'too_large' | 'invalid_json' + response: NextResponse<{ error: string }> + } > { try { assertContentLengthWithinLimit(request.headers, maxBytes, REQUEST_BODY_LABEL) @@ -184,6 +191,7 @@ export async function parseOptionalJsonBody( if (isPayloadSizeLimitError(error)) { return { success: false, + reason: 'too_large', response: NextResponse.json( { error: `Request body exceeds the maximum allowed size of ${maxBytes} bytes` }, { status: 413 } @@ -192,6 +200,7 @@ export async function parseOptionalJsonBody( } return { success: false, + reason: 'invalid_json', response: NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }), } } @@ -244,12 +253,22 @@ export async function parseRequest<C extends AnyApiRouteContract, TContext>( let body: unknown if (shouldReadJsonBody(contract)) { - const parsedBody = await parseJsonBody(request, options?.invalidJson, options?.maxBodyBytes) + const parsedBody = options?.optionalJsonBody + ? await parseOptionalJsonBody(request, options.maxBodyBytes) + : await parseJsonBody(request, options?.invalidJson, options?.maxBodyBytes) if (!parsedBody.success) { - if (options?.invalidJsonResponse && parsedBody.reason === 'invalid_json') { + if ( + options?.invalidJsonResponse && + 'reason' in parsedBody && + parsedBody.reason === 'invalid_json' + ) { return { success: false, response: options.invalidJsonResponse() } } - if (options?.payloadTooLargeResponse && parsedBody.reason === 'too_large') { + if ( + options?.payloadTooLargeResponse && + 'reason' in parsedBody && + parsedBody.reason === 'too_large' + ) { return { success: false, response: options.payloadTooLargeResponse() } } return parsedBody diff --git a/apps/sim/lib/core/application/index.ts b/apps/sim/lib/core/application/index.ts index 9c28f293d70..ea8138bf790 100644 --- a/apps/sim/lib/core/application/index.ts +++ b/apps/sim/lib/core/application/index.ts @@ -16,7 +16,12 @@ export type { } from '@/lib/core/application/workspace-authorization' export { authorizeWorkspaceOperation, + DelegatedWorkspaceAuthorizationError, + InsufficientWorkspacePermissionsError, + PersonalApiKeysDisabledError, + PrincipalKindAuthorizationError, requireAllowedWorkspacePrincipal, + WorkspaceApiKeyAuthorizationError, } from '@/lib/core/application/workspace-authorization' export { defineWorkspaceOperation, diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index 01d5e904e31..b511270f1f9 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -28,21 +28,53 @@ export interface WorkspaceAuthorizationOptions<C extends WorkspaceAuthorizationC delegation?: WorkspaceDelegationPolicy<C> } +export class InsufficientWorkspacePermissionsError extends OrchestrationError { + constructor() { + super('forbidden', 'Insufficient workspace permissions') + this.name = 'InsufficientWorkspacePermissionsError' + } +} + +export class PersonalApiKeysDisabledError extends OrchestrationError { + constructor() { + super('forbidden', 'Personal API keys are not allowed for this workspace') + this.name = 'PersonalApiKeysDisabledError' + } +} + +export class WorkspaceApiKeyAuthorizationError extends OrchestrationError { + constructor() { + super('forbidden', 'Workspace API key cannot perform this operation') + this.name = 'WorkspaceApiKeyAuthorizationError' + } +} + +export class DelegatedWorkspaceAuthorizationError extends OrchestrationError { + constructor() { + super('forbidden', 'Delegated workspace access is no longer valid') + this.name = 'DelegatedWorkspaceAuthorizationError' + } +} + +export class PrincipalKindAuthorizationError extends OrchestrationError { + constructor(principalKind: Principal['kind'], operationId: string) { + super('forbidden', `Principal kind ${principalKind} cannot perform operation ${operationId}`) + this.name = 'PrincipalKindAuthorizationError' + } +} + export function requireAllowedWorkspacePrincipal<O extends WorkspaceOperation>( principal: Principal, operation: O ): asserts principal is PrincipalForOperation<O> { 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) } } function requirePermission(permission: PermissionType | null, required: PermissionType): void { if (!permissionSatisfies(permission, required)) { - throw new OrchestrationError('forbidden', 'Insufficient workspace permissions') + throw new InsufficientWorkspacePermissionsError() } } @@ -76,10 +108,7 @@ export async function authorizeWorkspaceOperation<C extends WorkspaceAuthorizati return case 'personal_api_key': if (!context.allowPersonalApiKeys) { - throw new OrchestrationError( - 'forbidden', - 'Personal API keys are disabled for this workspace' - ) + throw new PersonalApiKeysDisabledError() } await requireCurrentHumanPermission(principal.userId, context, operation.minimumRole, options) return @@ -89,7 +118,7 @@ export async function authorizeWorkspaceOperation<C extends WorkspaceAuthorizati operation.workspaceApiKey !== 'allow' || !permissionSatisfies('write', operation.minimumRole) ) { - throw new OrchestrationError('forbidden', 'Workspace API key cannot perform this operation') + throw new WorkspaceApiKeyAuthorizationError() } return case 'delegated': { @@ -103,7 +132,7 @@ export async function authorizeWorkspaceOperation<C extends WorkspaceAuthorizati principal.workspaceId !== context.workspaceId || !delegation.isWithinScope(principal, context) ) { - throw new OrchestrationError('forbidden', 'Delegated workspace access is no longer valid') + throw new DelegatedWorkspaceAuthorizationError() } await requireCurrentHumanPermission( principal.subjectUserId, diff --git a/apps/sim/lib/execution/cancel-workflow-execution.ts b/apps/sim/lib/execution/cancel-workflow-execution.ts index feb2fc95518..74bd6790064 100644 --- a/apps/sim/lib/execution/cancel-workflow-execution.ts +++ b/apps/sim/lib/execution/cancel-workflow-execution.ts @@ -135,6 +135,8 @@ export interface CancelWorkflowExecutionInput { userId: string /** Workflow's workspace; feeds the event writer + analytics grouping. */ workspaceId?: string + /** Legacy callers emit product analytics here; migrated adapters emit it after success. */ + captureAnalytics?: boolean } export class WorkflowExecutionNotFoundError extends Error { @@ -294,7 +296,7 @@ export async function cancelWorkflowExecution( ? pausedCancelled && pausedCancellationPublished : cancellation.durablyRecorded || queuedJobCancelled) || locallyAborted - if (success) { + if (success && input.captureAnalytics !== false) { captureServerEvent( userId, 'workflow_execution_cancelled', diff --git a/apps/sim/lib/folders/orchestration.test.ts b/apps/sim/lib/folders/orchestration.test.ts index 055bf9caf91..3c5a87dfaec 100644 --- a/apps/sim/lib/folders/orchestration.test.ts +++ b/apps/sim/lib/folders/orchestration.test.ts @@ -72,6 +72,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ import { createFolder, createFolderAtPath, + createFolderAtPathTransition, deleteFolder, deleteFolderByPath, relocateFolderByPath, @@ -333,6 +334,21 @@ describe('createFolder', () => { }) describe('path-owned folder mutations', () => { + it('does not project legacy audit from the application transition', async () => { + queueTableRows(schemaMock.folder, [{ minSortOrder: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([folderRow()]) + + const result = await createFolderAtPathTransition({ + resourceType: 'workflow', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports', + }) + + expect(result).toMatchObject({ success: true, path: '/Reports' }) + expect(auditMock.recordAudit).not.toHaveBeenCalled() + }) + it('creates only the addressed leaf under an existing canonical parent path', async () => { const parent = folderRow({ id: 'parent-1', name: 'Reports' }) mockLoadActiveFolderPathIndex.mockResolvedValue({ diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index c19ca42d061..582b1f755cd 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -107,6 +107,8 @@ export interface DeleteFolderByPathParams { export interface DeleteFolderByPathResult extends DeleteFolderResult { path?: string + folderId?: string + folderName?: string } function validatePathLeafName(path: string): string { @@ -161,9 +163,9 @@ function pathMutationError(error: unknown): FolderPathMutationResult { return { success: false, error: 'Internal server error', errorCode: 'internal' } } -/** Creates exactly the leaf identified by `path`; every ancestor must already exist. */ -export async function createFolderAtPath( - params: Omit<CreateFolderParams, 'name' | 'parentId' | 'sortOrder' | 'id'> & { path: string } +async function executeCreateFolderAtPath( + params: Omit<CreateFolderParams, 'name' | 'parentId' | 'sortOrder' | 'id'> & { path: string }, + projectLegacyLifecycle: boolean ): Promise<FolderPathMutationResult> { try { requireNonRootFolderPath(params.path) @@ -211,16 +213,18 @@ export async function createFolderAtPath( { label: 'create-folder-at-path' } ) - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - action: AuditAction.FOLDER_CREATED, - resourceType: AuditResourceType.FOLDER, - resourceId: folder.id, - resourceName: folder.name, - description: `Created ${folderResourceConfig(params.resourceType).label} folder "${params.path}"`, - metadata: { path: params.path, folderResourceType: params.resourceType }, - }) + if (projectLegacyLifecycle) { + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + description: `Created ${folderResourceConfig(params.resourceType).label} folder "${params.path}"`, + metadata: { path: params.path, folderResourceType: params.resourceType }, + }) + } await notifyFolderResourceChanged(params.resourceType, params.workspaceId) return { success: true, folder, path: params.path } } catch (error) { @@ -228,14 +232,32 @@ export async function createFolderAtPath( } } -/** Renames, moves, or both by replacing one canonical path with another. */ -export async function relocateFolderByPath(params: { +/** Creates exactly the leaf identified by `path`; every ancestor must already exist. */ +export async function createFolderAtPath( + params: Omit<CreateFolderParams, 'name' | 'parentId' | 'sortOrder' | 'id'> & { path: string } +): Promise<FolderPathMutationResult> { + return executeCreateFolderAtPath(params, true) +} + +/** Applies the authoritative mutation without projecting audit or realtime side effects. */ +export async function createFolderAtPathTransition( + params: Omit<CreateFolderParams, 'name' | 'parentId' | 'sortOrder' | 'id'> & { path: string } +): Promise<FolderPathMutationResult> { + return executeCreateFolderAtPath(params, false) +} + +type RelocateFolderByPathParams = { resourceType: FolderResourceType workspaceId: string userId: string path: string destinationPath: string -}): Promise<FolderPathMutationResult> { +} + +async function executeRelocateFolderByPath( + params: RelocateFolderByPathParams, + projectLegacyLifecycle: boolean +): Promise<FolderPathMutationResult> { try { requireNonRootFolderPath(params.path) requireNonRootFolderPath(params.destinationPath) @@ -287,20 +309,22 @@ export async function relocateFolderByPath(params: { { label: 'relocate-folder-by-path' } ) - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - action: AuditAction.FOLDER_MOVED, - resourceType: AuditResourceType.FOLDER, - resourceId: folder.id, - resourceName: folder.name, - description: `Moved ${folderResourceConfig(params.resourceType).label} folder to "${params.destinationPath}"`, - metadata: { - sourcePath: params.path, - destinationPath: params.destinationPath, - folderResourceType: params.resourceType, - }, - }) + if (projectLegacyLifecycle) { + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + description: `Moved ${folderResourceConfig(params.resourceType).label} folder to "${params.destinationPath}"`, + metadata: { + sourcePath: params.path, + destinationPath: params.destinationPath, + folderResourceType: params.resourceType, + }, + }) + } await notifyFolderResourceChanged(params.resourceType, params.workspaceId) return { success: true, folder, path: params.destinationPath } } catch (error) { @@ -308,9 +332,23 @@ export async function relocateFolderByPath(params: { } } -/** Resolves a public path under the tree lock, then delegates the cascade to the domain engine. */ -export async function deleteFolderByPath( - params: DeleteFolderByPathParams +/** Renames, moves, or both by replacing one canonical path with another. */ +export async function relocateFolderByPath( + params: RelocateFolderByPathParams +): Promise<FolderPathMutationResult> { + return executeRelocateFolderByPath(params, true) +} + +/** Applies the authoritative mutation without projecting audit or realtime side effects. */ +export async function relocateFolderByPathTransition( + params: RelocateFolderByPathParams +): Promise<FolderPathMutationResult> { + return executeRelocateFolderByPath(params, false) +} + +async function executeDeleteFolderByPath( + params: DeleteFolderByPathParams, + projectLegacyLifecycle: boolean ): Promise<DeleteFolderByPathResult> { try { requireNonRootFolderPath(params.path) @@ -360,13 +398,32 @@ export async function deleteFolderByPath( } ) - const result = await deleteFolderWithoutTreeLock(resolved, null) - return { ...result, path: result.success ? params.path : undefined } + const result = await deleteFolderWithoutTreeLock(resolved, null, projectLegacyLifecycle) + return { + ...result, + path: result.success ? params.path : undefined, + folderId: result.success ? resolved.folderId : undefined, + folderName: result.success ? resolved.folderName : undefined, + } } catch (error) { return pathMutationError(error) } } +/** Resolves a public path under the tree lock, then delegates the cascade to the domain engine. */ +export async function deleteFolderByPath( + params: DeleteFolderByPathParams +): Promise<DeleteFolderByPathResult> { + return executeDeleteFolderByPath(params, true) +} + +/** Applies the authoritative mutation without projecting audit or realtime side effects. */ +export async function deleteFolderByPathTransition( + params: DeleteFolderByPathParams +): Promise<DeleteFolderByPathResult> { + return executeDeleteFolderByPath(params, false) +} + /** * Verifies that a prospective parent folder exists, belongs to the target workspace, is of * the same `resourceType`, and is not archived. @@ -669,12 +726,13 @@ export async function deleteFolder(params: DeleteFolderParams): Promise<DeleteFo return { success: false, error: 'Folder not found', errorCode: 'not_found' } } - return deleteFolderWithoutTreeLock(params, existing.deletedAt) + return deleteFolderWithoutTreeLock(params, existing.deletedAt, true) } async function deleteFolderWithoutTreeLock( params: DeleteFolderParams, - deletedAt: Date | null + deletedAt: Date | null, + projectLegacyLifecycle: boolean ): Promise<DeleteFolderResult> { const { resourceType, folderId, workspaceId, userId, folderName, folderPath } = params const config = folderResourceConfig(resourceType) @@ -700,24 +758,25 @@ async function deleteFolderWithoutTreeLock( logger.info('Deleted folder and all contents', { folderId, resourceType, counts }) - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.FOLDER_DELETED, - resourceType: AuditResourceType.FOLDER, - resourceId: folderId, - resourceName: folderName, - description: `Deleted ${config.label} folder "${folderPath ?? folderName ?? folderId}"`, - metadata: { - folderResourceType: resourceType, - path: folderPath, - affected: { - [config.countKey]: counts.children, - subfolders: Math.max(counts.folders - 1, 0), + if (projectLegacyLifecycle) { + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: folderId, + resourceName: folderName, + description: `Deleted ${config.label} folder "${folderPath ?? folderName ?? folderId}"`, + metadata: { + folderResourceType: resourceType, + path: folderPath, + affected: { + [config.countKey]: counts.children, + subfolders: Math.max(counts.folders - 1, 0), + }, }, - }, - }) - + }) + } // Live resource list (e.g. tables): a delete removes the folder and cascades to its contents. await notifyFolderResourceChanged(resourceType, workspaceId) return { success: true, deletedItems: toCascadeCounts(config, counts) } diff --git a/apps/sim/lib/workflows/api/index.ts b/apps/sim/lib/workflows/api/index.ts new file mode 100644 index 00000000000..ad2a025d322 --- /dev/null +++ b/apps/sim/lib/workflows/api/index.ts @@ -0,0 +1 @@ +export { v2WorkflowErrorPolicies } from '@/lib/workflows/api/route-policies' diff --git a/apps/sim/lib/workflows/api/route-policies.test.ts b/apps/sim/lib/workflows/api/route-policies.test.ts new file mode 100644 index 00000000000..0955c8082a0 --- /dev/null +++ b/apps/sim/lib/workflows/api/route-policies.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + DelegatedWorkspaceAuthorizationError, + InsufficientWorkspacePermissionsError, + PersonalApiKeysDisabledError, + PrincipalKindAuthorizationError, + WorkspaceApiKeyAuthorizationError, +} from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api/route-policies' + +describe('v2 workflow error policies', () => { + it.each([ + new InsufficientWorkspacePermissionsError(), + new WorkspaceApiKeyAuthorizationError(), + new DelegatedWorkspaceAuthorizationError(), + new PrincipalKindAuthorizationError('workspace_api_key', 'workflows.deploy'), + ])('conceals workflow authorization failures as absence', async (error) => { + const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render(error) + expect(response?.status).toBe(404) + expect(await response?.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Workflow not found' }, + }) + }) + + it('preserves the personal-api-key workspace policy failure as forbidden', async () => { + const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render( + new PersonalApiKeysDisabledError() + ) + expect(response?.status).toBe(403) + expect(await response?.json()).toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Personal API keys are not allowed for this workspace', + }, + }) + }) + + it('uses run-specific concealment text for canonical run operations', async () => { + const response = v2WorkflowErrorPolicies.concealRunAuthorization.render( + new InsufficientWorkspacePermissionsError() + ) + expect(await response?.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Run not found' }, + }) + }) + + it('does not conceal unrelated forbidden business errors', async () => { + const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render( + new OrchestrationError('forbidden', 'Workflow transition is forbidden') + ) + expect(response?.status).toBe(403) + expect(await response?.json()).toEqual({ + error: { code: 'FORBIDDEN', message: 'Workflow transition is forbidden' }, + }) + }) +}) diff --git a/apps/sim/lib/workflows/api/route-policies.ts b/apps/sim/lib/workflows/api/route-policies.ts new file mode 100644 index 00000000000..a90b6888819 --- /dev/null +++ b/apps/sim/lib/workflows/api/route-policies.ts @@ -0,0 +1,51 @@ +import { type V2ErrorPolicy, v2OrchestrationErrorPolicy } from '@/lib/api/server/routes' +import { + DelegatedWorkspaceAuthorizationError, + InsufficientWorkspacePermissionsError, + PersonalApiKeysDisabledError, + PrincipalKindAuthorizationError, + WorkspaceApiKeyAuthorizationError, +} from '@/lib/core/application' +import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error' +import { + v2CaughtOrchestrationError, + v2Error, + v2ErrorForOrchestration, +} from '@/app/api/v2/lib/response' + +function isConcealedResourceAuthorizationError(error: unknown): boolean { + return ( + error instanceof DelegatedWorkspaceAuthorizationError || + error instanceof InsufficientWorkspacePermissionsError || + error instanceof PrincipalKindAuthorizationError || + error instanceof WorkspaceApiKeyAuthorizationError + ) +} + +function concealResourceAuthorization(resourceName: 'Workflow' | 'Run'): V2ErrorPolicy { + return { + render(error) { + if (error instanceof PersonalApiKeysDisabledError) { + return v2CaughtOrchestrationError(error) + } + if (isConcealedResourceAuthorizationError(error)) { + return v2Error('NOT_FOUND', `${resourceName} not found`) + } + return v2CaughtOrchestrationError(error) + }, + } +} + +export const v2WorkflowErrorPolicies = { + default: v2OrchestrationErrorPolicy, + import: { + render(error) { + if (error instanceof WorkflowImportError) { + return v2ErrorForOrchestration(error.code, error.message, error.details) + } + return v2CaughtOrchestrationError(error) + }, + } satisfies V2ErrorPolicy, + concealWorkflowAuthorization: concealResourceAuthorization('Workflow'), + concealRunAuthorization: concealResourceAuthorization('Run'), +} as const diff --git a/apps/sim/lib/workflows/application/authorization.ts b/apps/sim/lib/workflows/application/authorization.ts new file mode 100644 index 00000000000..f8dffb5ba3e --- /dev/null +++ b/apps/sim/lib/workflows/application/authorization.ts @@ -0,0 +1,23 @@ +import type { Principal } from '@sim/auth/principal' +import type { + WorkspaceAuthorizationContext, + WorkspaceDelegationPolicy, +} from '@/lib/core/application' + +export const WORKFLOW_DELEGATION_AUDIENCE = 'sim:workflows' + +export interface WorkflowAuthorizationContext extends WorkspaceAuthorizationContext { + workflowId?: string + runId?: string + billedAccountUserId: string +} + +export const workflowDelegationPolicy: WorkspaceDelegationPolicy<WorkflowAuthorizationContext> = { + audience: WORKFLOW_DELEGATION_AUDIENCE, + isWithinScope( + principal: Extract<Principal, { kind: 'delegated' }>, + context: WorkflowAuthorizationContext + ) { + return principal.workspaceId === context.workspaceId + }, +} diff --git a/apps/sim/lib/workflows/application/authorized-workflow-use-case.ts b/apps/sim/lib/workflows/application/authorized-workflow-use-case.ts new file mode 100644 index 00000000000..b943c0ad4b9 --- /dev/null +++ b/apps/sim/lib/workflows/application/authorized-workflow-use-case.ts @@ -0,0 +1,28 @@ +import { + type AuthorizedWorkspaceUseCaseDefinition, + defineAuthorizedWorkspaceUseCase, + type WorkspaceOperation, +} from '@/lib/core/application' +import { + type WorkflowAuthorizationContext, + workflowDelegationPolicy, +} from '@/lib/workflows/application/authorization' + +type AuthorizedWorkflowUseCaseDefinition< + O extends WorkspaceOperation, + I, + C extends WorkflowAuthorizationContext, + R, +> = Omit<AuthorizedWorkspaceUseCaseDefinition<O, I, C, R>, 'authorizationOptions'> + +export function defineAuthorizedWorkflowUseCase< + const O extends WorkspaceOperation, + I, + C extends WorkflowAuthorizationContext, + R, +>(definition: AuthorizedWorkflowUseCaseDefinition<O, I, C, R>) { + return defineAuthorizedWorkspaceUseCase({ + ...definition, + authorizationOptions: { delegation: workflowDelegationPolicy }, + }) +} diff --git a/apps/sim/lib/workflows/application/cancel-run.ts b/apps/sim/lib/workflows/application/cancel-run.ts new file mode 100644 index 00000000000..c3f52c3c4c4 --- /dev/null +++ b/apps/sim/lib/workflows/application/cancel-run.ts @@ -0,0 +1,51 @@ +import type { Principal } from '@sim/auth/principal' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + cancelWorkflowExecution, + WorkflowExecutionNotFoundError, +} from '@/lib/execution/cancel-workflow-execution' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowRunApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' + +export interface CancelWorkflowRunInput { + workflowId: string + runId: string +} + +function assertedWorkspaceId(principal: Principal): string | undefined { + return principal.kind === 'workspace_api_key' || principal.kind === 'delegated' + ? principal.workspaceId + : undefined +} + +export const cancelWorkflowRun = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.cancelRun, + resolveContext: ({ principal, input }: { principal: Principal; input: CancelWorkflowRunInput }) => + resolveActiveWorkflowRunApplicationContext({ + runId: input.runId, + assertedWorkflowId: input.workflowId, + assertedWorkspaceId: assertedWorkspaceId(principal), + }), + async execute({ principal, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + try { + const result = await cancelWorkflowExecution({ + executionId: context.runId, + workflowId: context.workflowId, + userId: attribution.attributedUserId, + workspaceId: context.workspaceId, + captureAnalytics: false, + }) + return { ...result, workflowId: context.workflowId, workspaceId: context.workspaceId } + } catch (error) { + if (error instanceof WorkflowExecutionNotFoundError) { + throw new OrchestrationError('not_found', 'Run not found') + } + throw error + } + }, +}) diff --git a/apps/sim/lib/workflows/application/context.ts b/apps/sim/lib/workflows/application/context.ts new file mode 100644 index 00000000000..27bbdab0bed --- /dev/null +++ b/apps/sim/lib/workflows/application/context.ts @@ -0,0 +1,136 @@ +import { db } from '@sim/db' +import { + pausedExecutions, + resumeQueue, + workflow, + workflowExecutionLogs, + workspace, +} from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { getJobQueue } from '@/lib/core/async-jobs' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/execution-job-ids' + +export interface ActiveWorkflowApplicationContext { + workflowId: string + workflow: typeof workflow.$inferSelect + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} + +export interface ActiveWorkspaceApplicationContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} + +export interface ActiveWorkflowRunApplicationContext extends ActiveWorkflowApplicationContext { + runId: string +} + +export async function resolveActiveWorkspaceApplicationContext( + workspaceId: string +): Promise<ActiveWorkspaceApplicationContext> { + const [context] = await db + .select({ + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} + +export async function resolveActiveWorkflowApplicationContext(input: { + workflowId: string + assertedWorkspaceId?: string +}): Promise<ActiveWorkflowApplicationContext> { + const [context] = await db + .select({ + workflowId: workflow.id, + workflow, + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workflow) + .innerJoin(workspace, eq(workflow.workspaceId, workspace.id)) + .where( + and( + eq(workflow.id, input.workflowId), + isNull(workflow.archivedAt), + isNull(workspace.archivedAt) + ) + ) + .limit(1) + + if ( + !context || + (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== context.workspaceId) + ) { + throw new OrchestrationError('not_found', 'Workflow not found') + } + return context +} + +async function resolveCanonicalRunWorkflowId(runId: string): Promise<string | null> { + const [logRows, pausedRows, resumeRows] = await Promise.all([ + db + .select({ workflowId: workflowExecutionLogs.workflowId }) + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.executionId, runId)) + .limit(1), + db + .select({ workflowId: pausedExecutions.workflowId }) + .from(pausedExecutions) + .where(eq(pausedExecutions.executionId, runId)) + .limit(1), + db + .select({ workflowId: pausedExecutions.workflowId }) + .from(resumeQueue) + .innerJoin(pausedExecutions, eq(resumeQueue.pausedExecutionId, pausedExecutions.id)) + .where(eq(resumeQueue.newExecutionId, runId)) + .limit(1), + ]) + + const canonicalIds = new Set( + [logRows[0]?.workflowId, pausedRows[0]?.workflowId, resumeRows[0]?.workflowId].filter( + (value): value is string => typeof value === 'string' + ) + ) + + if (canonicalIds.size > 1) { + throw new Error(`Run ${runId} has conflicting canonical workflow bindings`) + } + if (canonicalIds.size === 1) return [...canonicalIds][0] + + const queue = await getJobQueue() + const job = await queue.getJob(`${WORKFLOW_EXECUTION_JOB_ID_PREFIX}${runId}`) + return job?.metadata.workflowId ?? null +} + +export async function resolveActiveWorkflowRunApplicationContext(input: { + runId: string + assertedWorkflowId?: string + assertedWorkspaceId?: string +}): Promise<ActiveWorkflowRunApplicationContext> { + const workflowId = await resolveCanonicalRunWorkflowId(input.runId) + if (!workflowId || (input.assertedWorkflowId && input.assertedWorkflowId !== workflowId)) { + throw new OrchestrationError('not_found', 'Run not found') + } + + const context = await resolveActiveWorkflowApplicationContext({ + workflowId, + assertedWorkspaceId: input.assertedWorkspaceId, + }) + return { ...context, runId: input.runId } +} diff --git a/apps/sim/lib/workflows/application/create-workflow.ts b/apps/sim/lib/workflows/application/create-workflow.ts new file mode 100644 index 00000000000..eeac83f1b40 --- /dev/null +++ b/apps/sim/lib/workflows/application/create-workflow.ts @@ -0,0 +1,77 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { requireWorkflowTransition } from '@/lib/workflows/application/transition-result' +import { + resolveWorkflowFolderPath, + workflowFolderPathForId, +} from '@/lib/workflows/application/workflow-folders' +import { performCreateWorkflowTransition } from '@/lib/workflows/orchestration' + +const logger = createLogger('CreateWorkflow') + +export interface CreateWorkflowInput { + workspaceId: string + name: string + description?: string | null + folderPath?: string +} + +export const createWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.create, + resolveContext: ({ input }: { input: CreateWorkflowInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }) { + const resolution = await resolveWorkflowFolderPath(context.workspaceId, input.folderPath ?? '/') + try { + await assertFolderMutable(resolution.folderId) + } catch (error) { + if (error instanceof FolderLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + } + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const transition = await performCreateWorkflowTransition({ + userId: attribution.attributedUserId, + workspaceId: context.workspaceId, + name: input.name, + description: input.description, + folderId: resolution.folderId, + }) + requireWorkflowTransition(transition, 'Failed to create workflow') + if (!transition.workflow) throw new Error('Successful workflow create returned no workflow') + + logger.info('Created workflow', { + workspaceId: context.workspaceId, + workflowId: transition.workflow.id, + principalKind: principal.kind, + }) + return { + workflow: transition.workflow, + folderPath: workflowFolderPathForId(resolution.index, transition.workflow.folderId), + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.WORKFLOW_CREATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.workflow.id, + resourceName: result.workflow.name, + description: `Created workflow "${result.workflow.name}"`, + metadata: { + name: result.workflow.name, + description: result.workflow.description || undefined, + workspaceId: result.workflow.workspaceId, + folderId: result.workflow.folderId || undefined, + sortOrder: result.workflow.sortOrder, + }, + }), +}) diff --git a/apps/sim/lib/workflows/application/delete-workflow.ts b/apps/sim/lib/workflows/application/delete-workflow.ts new file mode 100644 index 00000000000..f2a41746cc4 --- /dev/null +++ b/apps/sim/lib/workflows/application/delete-workflow.ts @@ -0,0 +1,69 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { requireWorkflowTransition } from '@/lib/workflows/application/transition-result' +import { deleteWorkflowRecord } from '@/lib/workflows/orchestration' + +const logger = createLogger('DeleteWorkflow') + +export interface DeleteWorkflowInput { + workflowId: string + assertedWorkspaceId?: string +} + +export const deleteWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.delete, + resolveContext: ({ principal, input }: { principal: Principal; input: DeleteWorkflowInput }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, context }) { + try { + await assertWorkflowMutable(context.workflowId) + } catch (error) { + if (error instanceof WorkflowLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + } + + const transition = await deleteWorkflowRecord({ + workflowId: context.workflowId, + userId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + }) + requireWorkflowTransition(transition, 'Failed to delete workflow') + if (!transition.workflow) throw new Error('Successful workflow delete returned no workflow') + + logger.info('Deleted workflow', { + workspaceId: context.workspaceId, + workflowId: context.workflowId, + archived: transition.archived, + principalKind: principal.kind, + }) + return { + workflowId: context.workflowId, + workflowName: transition.workflow.name, + archived: transition.archived === true, + } + }, + projectAudit: ({ result }) => + result.archived + ? { + action: AuditAction.WORKFLOW_DELETED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.workflowId, + resourceName: result.workflowName, + description: `Archived workflow "${result.workflowName}"`, + metadata: { archived: true }, + } + : [], +}) diff --git a/apps/sim/lib/workflows/application/deployments.ts b/apps/sim/lib/workflows/application/deployments.ts new file mode 100644 index 00000000000..bffda5ba2b7 --- /dev/null +++ b/apps/sim/lib/workflows/application/deployments.ts @@ -0,0 +1,170 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution, toPrincipalActor } from '@sim/auth/principal' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { + performActivateVersion, + performFullDeploy, + performFullUndeploy, +} from '@/lib/workflows/orchestration' +import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' + +export interface DeployWorkflowInput { + workflowId: string + name?: string + description?: string + requestId: string + idempotencyKey?: string +} + +export interface UndeployWorkflowInput { + workflowId: string + requestId: string +} + +export interface ActivateWorkflowVersionInput { + workflowId: string + version?: number + transition: 'activate' | 'rollback' + requestId: string + idempotencyKey?: string +} + +function throwDeploymentFailure( + result: { error?: string; errorCode?: OrchestrationErrorCode }, + fallback: string +): never { + if (!result.errorCode || result.errorCode === 'internal') { + throw new Error(fallback) + } + throw new OrchestrationError(result.errorCode, result.error ?? fallback) +} + +async function requireMutableWorkflow(workflowId: string): Promise<void> { + try { + await assertWorkflowMutable(workflowId) + } catch (error) { + if (error instanceof WorkflowLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + } +} + +export const deployWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.deploy, + resolveContext: ({ input }: { input: DeployWorkflowInput }) => + resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), + async execute({ principal, input, context }) { + await requireMutableWorkflow(context.workflowId) + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await performFullDeploy({ + workflowId: context.workflowId, + userId: attribution.attributedUserId, + actorId: attribution.attributedUserId, + actor: toPrincipalActor(principal), + captureAnalytics: false, + versionName: input.name, + versionDescription: input.description, + requestId: input.requestId, + idempotencyKey: input.idempotencyKey, + }) + if (!result.success) throwDeploymentFailure(result, 'Failed to deploy workflow') + return { + ...result, + workflowId: context.workflowId, + workspaceId: context.workspaceId, + } + }, +}) + +export const undeployWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.undeploy, + resolveContext: ({ input }: { input: UndeployWorkflowInput }) => + resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), + async execute({ principal, input, context }) { + if (!context.workflow.isDeployed) { + throw new OrchestrationError('validation', 'Workflow is not deployed') + } + await requireMutableWorkflow(context.workflowId) + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await performFullUndeploy({ + workflowId: context.workflowId, + userId: attribution.attributedUserId, + actorId: attribution.attributedUserId, + projectLegacyAudit: false, + requestId: input.requestId, + }) + if (!result.success) throw new Error('Failed to undeploy workflow') + return { + ...result, + workflowId: context.workflowId, + workspaceId: context.workspaceId, + workflowName: context.workflow.name, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.WORKFLOW_UNDEPLOYED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.workflowId, + resourceName: result.workflowName, + description: `Undeployed workflow "${result.workflowName}"`, + }), +}) + +export const activateWorkflowVersion = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.activateVersion, + resolveContext: ({ input }: { input: ActivateWorkflowVersionInput }) => + resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), + async execute({ principal, input, context }) { + if (input.transition === 'rollback' && !context.workflow.isDeployed) { + throw new OrchestrationError('validation', 'Workflow is not deployed') + } + await requireMutableWorkflow(context.workflowId) + + let targetVersion = input.version + if (targetVersion === undefined) { + if (input.transition !== 'rollback') { + throw new OrchestrationError('validation', 'Version is required for activation') + } + const previous = await findPreviousDeploymentVersion(context.workflowId) + if (!previous.ok) { + throw new OrchestrationError( + 'validation', + previous.reason === 'no_active_version' + ? 'Workflow has no active deployment to roll back from' + : 'No previous deployment version to roll back to' + ) + } + targetVersion = previous.version + } + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await performActivateVersion({ + workflowId: context.workflowId, + version: targetVersion, + userId: attribution.attributedUserId, + actorId: attribution.attributedUserId, + actor: toPrincipalActor(principal), + captureAnalytics: false, + requestId: input.requestId, + idempotencyKey: input.idempotencyKey, + }) + if (!result.success) throwDeploymentFailure(result, 'Failed to activate workflow version') + return { + ...result, + workflowId: context.workflowId, + workspaceId: context.workspaceId, + version: targetVersion, + } + }, +}) diff --git a/apps/sim/lib/workflows/application/execute-workflow.test.ts b/apps/sim/lib/workflows/application/execute-workflow.test.ts new file mode 100644 index 00000000000..26f53714618 --- /dev/null +++ b/apps/sim/lib/workflows/application/execute-workflow.test.ts @@ -0,0 +1,170 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + executeService: vi.fn(), + resolvePermission: vi.fn(), + resolveWorkflowContext: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, +})) + +vi.mock('@/lib/workflows/executor/execute-service', () => ({ + executeWorkflowService: mocks.executeService, +})) + +import { PersonalApiKeysDisabledError } from '@/lib/core/application' +import { executeWorkflowOperation } from '@/lib/workflows/application/execute-workflow' + +const workflow = { id: 'workflow-1', userId: 'owner-1', workspaceId: 'workspace-1' } +const workflowContext = { + workflowId: 'workflow-1', + workflow, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const baseInput = { + workflowId: 'workflow-1', + requestId: 'request-1', + input: { hello: 'world' }, + mode: 'sync' as const, + requestHeaders: new Headers(), +} + +describe('executeWorkflowOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveWorkflowContext.mockResolvedValue(workflowContext) + mocks.executeService.mockResolvedValue({ + ok: true, + executionId: 'run-1', + workflowId: 'workflow-1', + status: 'completed', + aborted: null, + output: {}, + error: null, + hasResponseBlock: false, + }) + }) + + it.each([ + { + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as Principal, + actorUserId: 'user-1', + authenticatesCredentials: true, + }, + { + principal: { + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'personal-key', + } as Principal, + actorUserId: 'user-1', + authenticatesCredentials: true, + }, + { + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key', + } as Principal, + actorUserId: 'billing-owner-1', + authenticatesCredentials: false, + }, + { + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2999-01-01T00:00:00Z'), + } as Principal, + actorUserId: 'user-1', + authenticatesCredentials: true, + }, + ])( + 'derives execution actor and credential policy from $principal.kind', + async ({ principal, actorUserId, authenticatesCredentials }) => { + await executeWorkflowOperation.execute({ principal, input: baseInput }) + + expect(mocks.executeService).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-1', + userId: actorUserId, + workflowRecord: workflow, + triggerType: 'api', + rateLimitCounter: 'sync', + useAuthenticatedUserAsActor: authenticatesCredentials, + }) + ) + } + ) + + it('uses the async execution quota bucket without performing request-rate limiting', async () => { + await executeWorkflowOperation.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key', + }, + input: { ...baseInput, mode: 'async', requestedTimeoutSeconds: 600 }, + }) + + expect(mocks.executeService).toHaveBeenCalledWith( + expect.objectContaining({ rateLimitCounter: 'async', requestedTimeoutSeconds: 600 }) + ) + }) + + it('rejects a personal key disabled by canonical workspace policy', async () => { + mocks.resolveWorkflowContext.mockResolvedValueOnce({ + ...workflowContext, + allowPersonalApiKeys: false, + }) + + await expect( + executeWorkflowOperation.execute({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'personal-key' }, + input: baseInput, + }) + ).rejects.toBeInstanceOf(PersonalApiKeysDisabledError) + expect(mocks.executeService).not.toHaveBeenCalled() + }) + + it('passes through execution infrastructure failures', async () => { + const infrastructureError = new Error('queue unavailable') + mocks.executeService.mockRejectedValueOnce(infrastructureError) + + await expect( + executeWorkflowOperation.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key', + }, + input: baseInput, + }) + ).rejects.toBe(infrastructureError) + }) +}) diff --git a/apps/sim/lib/workflows/application/execute-workflow.ts b/apps/sim/lib/workflows/application/execute-workflow.ts new file mode 100644 index 00000000000..aeee3f2de65 --- /dev/null +++ b/apps/sim/lib/workflows/application/execute-workflow.ts @@ -0,0 +1,69 @@ +import type { Principal } from '@sim/auth/principal' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { + type ExecuteWorkflowServiceResult, + executeWorkflowService, +} from '@/lib/workflows/executor/execute-service' + +export interface ExecuteWorkflowInput { + workflowId: string + requestId: string + input: unknown + executionId?: string + includeFileBase64?: boolean + base64MaxBytes?: number + selectedOutputs?: string[] + requestedTimeoutSeconds?: number + abortSignal?: AbortSignal + mode: 'sync' | 'async' | 'stream' + requestHeaders: Headers + includeThinking?: boolean + includeToolCalls?: boolean +} + +function assertedWorkspaceId(principal: Principal): string | undefined { + return principal.kind === 'workspace_api_key' || principal.kind === 'delegated' + ? principal.workspaceId + : undefined +} + +function authenticatesExecutionCredentials(principal: Principal): boolean { + return principal.kind !== 'workspace_api_key' +} + +export const executeWorkflowOperation = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.execute, + resolveContext: ({ principal, input }: { principal: Principal; input: ExecuteWorkflowInput }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkspaceId(principal), + }), + async execute({ principal, context, input }): Promise<ExecuteWorkflowServiceResult> { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + return executeWorkflowService({ + workflowId: context.workflowId, + userId: attribution.attributedUserId, + input: input.input, + triggerType: 'api', + requestId: input.requestId, + executionId: input.executionId, + useAuthenticatedUserAsActor: authenticatesExecutionCredentials(principal), + workflowRecord: context.workflow, + includeFileBase64: input.includeFileBase64, + base64MaxBytes: input.base64MaxBytes, + selectedOutputs: input.selectedOutputs, + rateLimitCounter: input.mode === 'async' ? 'async' : 'sync', + requestedTimeoutSeconds: input.requestedTimeoutSeconds, + abortSignal: input.abortSignal, + mode: input.mode, + requestHeaders: input.requestHeaders, + includeThinking: input.includeThinking, + includeToolCalls: input.includeToolCalls, + }) + }, +}) diff --git a/apps/sim/lib/workflows/application/import-export.test.ts b/apps/sim/lib/workflows/application/import-export.test.ts new file mode 100644 index 00000000000..91c03bf3e83 --- /dev/null +++ b/apps/sim/lib/workflows/application/import-export.test.ts @@ -0,0 +1,209 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveWorkspace: vi.fn(), + resolveWorkflow: vi.fn(), + resolvePermission: vi.fn(), + importTransition: vi.fn(), + buildExport: vi.fn(), + folderLock: vi.fn(), + loadIndex: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.resolveWorkspace, + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflow, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { + WORKFLOW_CREATED: 'workflow.created', + WORKFLOW_EXPORTED: 'workflow.exported', + }, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.recordAudit, +})) +vi.mock('@/lib/folders/locks', () => ({ + withFolderTreeLock: mocks.folderLock, +})) +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mocks.loadIndex, + resolveFolderPathFromIndex: (index: { idByPath: Map<string, string> }, path: string) => + path === '/' ? null : index.idByPath.get(path), +})) +vi.mock('@/lib/workflows/operations/import-workflow', () => ({ + importWorkflowIntoWorkspaceTransition: mocks.importTransition, +})) +vi.mock('@/lib/workflows/operations/export-workflow', () => ({ + buildWorkflowExportPayload: mocks.buildExport, +})) + +import { exportWorkflow, importWorkflow } from '@/lib/workflows/application/import-export' +import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error' + +const workspaceContext = { + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', +} +const workflowRecord = { + id: 'workflow-1', + userId: 'user-1', + workspaceId: 'ws-1', + folderId: 'folder-1', + sortOrder: 0, + name: 'Reports', + description: null, + variables: {}, +} +const folderIndex = { + rowById: new Map(), + pathById: new Map([['folder-1', '/Reports']]), + idByPath: new Map([['/Reports', 'folder-1']]), +} +const imported = { + id: 'workflow-2', + name: 'Imported', + description: null, + workspaceId: 'ws-1', + folderId: 'folder-1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} +const exportPayload = { + version: '1.0' as const, + exportedAt: '2026-01-01T00:00:00.000Z', + workflow: { + id: 'workflow-1', + name: 'Reports', + description: null, + workspaceId: 'ws-1', + folderId: 'folder-1', + }, + state: { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + metadata: { name: 'Reports', exportedAt: '2026-01-01T00:00:00.000Z' }, + }, +} + +describe('workflow import and export application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveWorkspace.mockResolvedValue(workspaceContext) + mocks.resolveWorkflow.mockResolvedValue({ + ...workspaceContext, + workflowId: 'workflow-1', + workflow: workflowRecord, + }) + mocks.resolvePermission.mockResolvedValue('write') + mocks.folderLock.mockImplementation( + async ( + _workspaceId: string, + _resourceType: string, + callback: (tx: Record<string, never>) => unknown + ) => callback({}) + ) + mocks.loadIndex.mockResolvedValue(folderIndex) + mocks.importTransition.mockResolvedValue({ success: true, workflow: imported }) + mocks.buildExport.mockResolvedValue(exportPayload) + }) + + it('imports through the unaudited transition and projects one semantic audit', async () => { + const result = await importWorkflow.execute({ + principal: { kind: 'workspace_api_key', workspaceId: 'ws-1', keyId: 'key-1' }, + input: { + workspaceId: 'ws-1', + folderPath: '/Reports', + workflow: { blocks: {}, edges: [] }, + }, + }) + + expect(result).toEqual({ workflow: imported, folderPath: '/Reports' }) + expect(mocks.importTransition).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'ws-1', + folderId: 'folder-1', + userId: 'owner-1', + }) + ) + expect(mocks.recordAudit).toHaveBeenCalledOnce() + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: null, + actorName: 'Workspace API key', + metadata: expect.objectContaining({ + operation: 'workflows.import', + actor: { kind: 'workspace_api_key', keyId: 'key-1', workspaceId: 'ws-1' }, + }), + }) + ) + }) + + it('preserves classified import details and does not audit a failure', async () => { + mocks.importTransition.mockResolvedValue({ + success: false, + status: 400, + error: 'Invalid workflow state', + details: [{ path: ['blocks'] }], + }) + + const error = await importWorkflow + .execute({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { workspaceId: 'ws-1', workflow: { blocks: null } }, + }) + .catch((failure: unknown) => failure) + + expect(error).toBeInstanceOf(WorkflowImportError) + expect(error).toMatchObject({ + code: 'validation', + details: [{ path: ['blocks'] }], + }) + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('exports from the canonical workflow context and audits authoritative counts', async () => { + const result = await exportWorkflow.execute({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { workflowId: 'workflow-1' }, + }) + + expect(mocks.resolveWorkflow).toHaveBeenCalledWith({ workflowId: 'workflow-1' }) + expect(mocks.buildExport).toHaveBeenCalledWith(workflowRecord) + expect(mocks.loadIndex).toHaveBeenCalledWith('ws-1', 'workflow') + expect(mocks.folderLock).not.toHaveBeenCalled() + expect(result).toEqual({ payload: exportPayload, folderPath: '/Reports' }) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'workflow-1', + metadata: expect.objectContaining({ blocksCount: 0, edgesCount: 0 }), + }) + ) + }) + + it('propagates export infrastructure failures without audit', async () => { + const failure = new Error('storage unavailable') + mocks.buildExport.mockRejectedValueOnce(failure) + + await expect( + exportWorkflow.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workflowId: 'workflow-1' }, + }) + ).rejects.toBe(failure) + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/import-export.ts b/apps/sim/lib/workflows/application/import-export.ts new file mode 100644 index 00000000000..88692f71514 --- /dev/null +++ b/apps/sim/lib/workflows/application/import-export.ts @@ -0,0 +1,128 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import type { V1WorkflowExportPayload } from '@/lib/api/contracts/v1/workflows' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { + resolveActiveWorkflowApplicationContext, + resolveActiveWorkspaceApplicationContext, +} from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { + resolveWorkflowFolderPath, + workflowFolderPathForId, +} from '@/lib/workflows/application/workflow-folders' +import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error' +import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow' +import { + type ImportedWorkflow, + importWorkflowIntoWorkspaceTransition, +} from '@/lib/workflows/operations/import-workflow' + +export interface ImportWorkflowInput { + workspaceId: string + folderPath?: string + name?: string + description?: string + workflow: string | Record<string, unknown> +} + +export interface ImportWorkflowResult { + workflow: ImportedWorkflow + folderPath: string +} + +export interface ExportWorkflowInput { + workflowId: string +} + +export interface ExportWorkflowResult { + payload: V1WorkflowExportPayload + folderPath: string +} + +function importErrorCode(status: number): OrchestrationErrorCode { + if (status === 400) return 'validation' + if (status === 404) return 'not_found' + if (status === 409) return 'conflict' + if (status === 423) return 'locked' + return 'internal' +} + +export const importWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.import, + resolveContext: ({ input }: { input: ImportWorkflowInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }): Promise<ImportWorkflowResult> { + const resolution = await resolveWorkflowFolderPath(context.workspaceId, input.folderPath ?? '/') + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await importWorkflowIntoWorkspaceTransition({ + workspaceId: context.workspaceId, + folderId: resolution.folderId ?? undefined, + name: input.name, + description: input.description, + workflow: input.workflow, + userId: attribution.attributedUserId, + requestId: generateRequestId(), + }) + if (!result.success) { + throw new WorkflowImportError(importErrorCode(result.status), result.error, result.details) + } + return { + workflow: result.workflow, + folderPath: workflowFolderPathForId(resolution.index, result.workflow.folderId), + } + }, + projectAudit({ result }) { + return { + action: AuditAction.WORKFLOW_CREATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.workflow.id, + resourceName: result.workflow.name, + description: `Created workflow "${result.workflow.name}"`, + metadata: { + name: result.workflow.name, + description: result.workflow.description || undefined, + workspaceId: result.workflow.workspaceId, + folderId: result.workflow.folderId || undefined, + sortOrder: result.workflow.sortOrder, + }, + } + }, +}) + +export const exportWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.export, + resolveContext: ({ input }: { input: ExportWorkflowInput }) => + resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), + async execute({ context }): Promise<ExportWorkflowResult> { + const payload = await buildWorkflowExportPayload(context.workflow) + if (!payload) throw new OrchestrationError('not_found', 'Workflow state not found') + const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + return { + payload, + folderPath: workflowFolderPathForId(folderIndex, context.workflow.folderId), + } + }, + projectAudit({ context, result }) { + return { + action: AuditAction.WORKFLOW_EXPORTED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: context.workflow.id, + resourceName: context.workflow.name, + description: `Exported workflow "${context.workflow.name}" via the API`, + metadata: { + workspaceId: context.workspaceId, + folderPath: result.folderPath, + blocksCount: Object.keys(result.payload.state.blocks).length, + edgesCount: result.payload.state.edges.length, + }, + } + }, +}) diff --git a/apps/sim/lib/workflows/application/list-workflow-runs.ts b/apps/sim/lib/workflows/application/list-workflow-runs.ts new file mode 100644 index 00000000000..6fd4ba4ab04 --- /dev/null +++ b/apps/sim/lib/workflows/application/list-workflow-runs.ts @@ -0,0 +1,40 @@ +import type { Principal } from '@sim/auth/principal' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { + type ListWorkflowExecutionsInput, + listWorkflowExecutions, +} from '@/lib/workflows/executor/execution-queries' + +export interface ListWorkflowRunsInput extends Omit<ListWorkflowExecutionsInput, 'workflowId'> { + workflowId: string +} + +function assertedWorkspaceId(principal: Principal): string | undefined { + return principal.kind === 'workspace_api_key' || principal.kind === 'delegated' + ? principal.workspaceId + : undefined +} + +export const listWorkflowRuns = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.listRuns, + resolveContext: ({ principal, input }: { principal: Principal; input: ListWorkflowRunsInput }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkspaceId(principal), + }), + async execute({ context, input }) { + const result = await listWorkflowExecutions({ + workflowId: context.workflowId, + status: input.status, + trigger: input.trigger, + startDate: input.startDate, + endDate: input.endDate, + limit: input.limit, + cursor: input.cursor, + order: input.order, + }) + return { ...result, workflowId: context.workflowId, order: input.order } + }, +}) diff --git a/apps/sim/lib/workflows/application/list-workflow-versions.ts b/apps/sim/lib/workflows/application/list-workflow-versions.ts new file mode 100644 index 00000000000..86748c0c090 --- /dev/null +++ b/apps/sim/lib/workflows/application/list-workflow-versions.ts @@ -0,0 +1,46 @@ +import type { Principal } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { listWorkflowVersions as listStoredWorkflowVersions } from '@/lib/workflows/persistence/utils' + +const logger = createLogger('ListWorkflowVersions') + +export interface ListWorkflowVersionsInput { + workflowId: string + assertedWorkspaceId?: string + limit?: number + afterVersion?: number +} + +export const listWorkflowVersions = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.listVersions, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: ListWorkflowVersionsInput + }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, input, context }) { + const { versions } = await listStoredWorkflowVersions(context.workflowId, { + limit: input.limit === undefined ? undefined : input.limit + 1, + afterVersion: input.afterVersion, + }) + const hasMore = input.limit !== undefined && versions.length > input.limit + const page = input.limit === undefined ? versions : versions.slice(0, input.limit) + logger.info('Listed workflow versions', { + workspaceId: context.workspaceId, + workflowId: context.workflowId, + count: page.length, + principalKind: principal.kind, + }) + return { versions: page, hasMore } + }, +}) diff --git a/apps/sim/lib/workflows/application/list-workflows.ts b/apps/sim/lib/workflows/application/list-workflows.ts new file mode 100644 index 00000000000..f278c9317d5 --- /dev/null +++ b/apps/sim/lib/workflows/application/list-workflows.ts @@ -0,0 +1,80 @@ +import { createLogger } from '@sim/logger' +import type { CursorKey } from '@/lib/api/list-query' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { loadActiveFolderPathIndex } 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' +import { workflowFolderPathForId } from '@/lib/workflows/application/workflow-folders' +import { + InvalidWorkflowListCursorError, + listWorkspaceWorkflows, + type WorkflowSortBy, + type WorkflowSortOrder, +} from '@/lib/workflows/queries' + +const logger = createLogger('ListWorkflows') + +export interface ListWorkflowsInput { + workspaceId: string + folderPath?: string + deployedOnly: boolean + search?: string + sortBy: WorkflowSortBy + sortOrder: WorkflowSortOrder + cursorKeys?: CursorKey[] + limit: number +} + +export const listWorkflows = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.list, + resolveContext: ({ input }: { input: ListWorkflowsInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }) { + const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + 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') + } + + let page + try { + page = await listWorkspaceWorkflows({ + workspaceId: context.workspaceId, + folderId, + deployedOnly: input.deployedOnly, + search: input.search, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + cursorKeys: input.cursorKeys, + limit: input.limit, + }) + } catch (error) { + if (error instanceof InvalidWorkflowListCursorError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + + logger.info('Listed workflows', { + workspaceId: context.workspaceId, + count: page.data.length, + principalKind: principal.kind, + }) + return { + workflows: page.data.map((workflow) => ({ + ...workflow, + workspaceId: workflow.workspaceId ?? context.workspaceId, + folderPath: workflowFolderPathForId(folderIndex, workflow.folderId), + })), + nextCursorKeys: page.nextCursorKeys, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + } + }, +}) diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts new file mode 100644 index 00000000000..08f6399352e --- /dev/null +++ b/apps/sim/lib/workflows/application/operations.ts @@ -0,0 +1,141 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const ALL_WORKFLOW_PRINCIPALS = [ + 'session', + 'personal_api_key', + 'workspace_api_key', + 'delegated', +] as const + +const HUMAN_WORKFLOW_PRINCIPALS = ['session', 'personal_api_key', 'delegated'] as const + +export const workflowOperations = { + list: defineWorkspaceOperation({ + id: 'workflows.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + read: defineWorkspaceOperation({ + id: 'workflows.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + create: defineWorkspaceOperation({ + id: 'workflows.create', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + update: defineWorkspaceOperation({ + id: 'workflows.update', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + delete: defineWorkspaceOperation({ + id: 'workflows.delete', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + listFolders: defineWorkspaceOperation({ + id: 'workflows.folders.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + createFolder: defineWorkspaceOperation({ + id: 'workflows.folders.create', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + relocateFolder: defineWorkspaceOperation({ + id: 'workflows.folders.relocate', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + deleteFolder: defineWorkspaceOperation({ + id: 'workflows.folders.delete', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + deploy: defineWorkspaceOperation({ + id: 'workflows.deploy', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: HUMAN_WORKFLOW_PRINCIPALS, + }), + undeploy: defineWorkspaceOperation({ + id: 'workflows.undeploy', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: HUMAN_WORKFLOW_PRINCIPALS, + }), + activateVersion: defineWorkspaceOperation({ + id: 'workflows.versions.activate', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: HUMAN_WORKFLOW_PRINCIPALS, + }), + listVersions: defineWorkspaceOperation({ + id: 'workflows.versions.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + readVersion: defineWorkspaceOperation({ + id: 'workflows.versions.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + export: defineWorkspaceOperation({ + id: 'workflows.export', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + import: defineWorkspaceOperation({ + id: 'workflows.import', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + execute: defineWorkspaceOperation({ + id: 'workflows.execute', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + listRuns: defineWorkspaceOperation({ + id: 'workflows.runs.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + readRun: defineWorkspaceOperation({ + id: 'workflows.runs.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + cancelRun: defineWorkspaceOperation({ + id: 'workflows.runs.cancel', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), + resumeRun: defineWorkspaceOperation({ + id: 'workflows.runs.resume', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_WORKFLOW_PRINCIPALS, + }), +} as const + +export type WorkflowOperation = (typeof workflowOperations)[keyof typeof workflowOperations] diff --git a/apps/sim/lib/workflows/application/principal-scope.ts b/apps/sim/lib/workflows/application/principal-scope.ts new file mode 100644 index 00000000000..d5e70577ee9 --- /dev/null +++ b/apps/sim/lib/workflows/application/principal-scope.ts @@ -0,0 +1,11 @@ +import type { Principal } from '@sim/auth/principal' + +export function assertedWorkflowWorkspaceId( + principal: Principal, + assertedWorkspaceId?: string +): string | undefined { + return ( + assertedWorkspaceId ?? + (principal.kind === 'workspace_api_key' ? principal.workspaceId : undefined) + ) +} diff --git a/apps/sim/lib/workflows/application/read-workflow-run.ts b/apps/sim/lib/workflows/application/read-workflow-run.ts new file mode 100644 index 00000000000..baa9de4de4c --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-run.ts @@ -0,0 +1,50 @@ +import type { Principal } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE, + FunctionalOutputsUnavailableError, +} from '@/lib/logs/execution/functional-outputs' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowRunApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' + +export interface ReadWorkflowRunInput { + workflowId: string + runId: string + includeOutput: boolean + selectedOutputs: string[] +} + +function assertedWorkspaceId(principal: Principal): string | undefined { + return principal.kind === 'workspace_api_key' || principal.kind === 'delegated' + ? principal.workspaceId + : undefined +} + +export const readWorkflowRun = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.readRun, + resolveContext: ({ principal, input }: { principal: Principal; input: ReadWorkflowRunInput }) => + resolveActiveWorkflowRunApplicationContext({ + runId: input.runId, + assertedWorkflowId: input.workflowId, + assertedWorkspaceId: assertedWorkspaceId(principal), + }), + async execute({ context, input }) { + try { + const status = await getWorkflowExecutionStatus({ + workflowId: context.workflowId, + executionId: context.runId, + includeOutput: input.includeOutput, + selectedOutputs: input.selectedOutputs, + }) + if (!status) throw new OrchestrationError('not_found', 'Run not found') + return status + } catch (error) { + if (error instanceof FunctionalOutputsUnavailableError) { + throw new OrchestrationError('conflict', FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE) + } + throw error + } + }, +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-version.ts b/apps/sim/lib/workflows/application/read-workflow-version.ts new file mode 100644 index 00000000000..bec3ac131af --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-version.ts @@ -0,0 +1,44 @@ +import type { Principal } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils' + +const logger = createLogger('ReadWorkflowVersion') + +export interface ReadWorkflowVersionInput { + workflowId: string + assertedWorkspaceId?: string + version: number +} + +export const readWorkflowVersion = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.readVersion, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: ReadWorkflowVersionInput + }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, input, context }) { + const version = await getWorkflowDeploymentVersion(context.workflowId, input.version) + if (!version?.state) { + throw new OrchestrationError('not_found', 'Deployment version not found') + } + logger.info('Read workflow version', { + workspaceId: context.workspaceId, + workflowId: context.workflowId, + version: input.version, + principalKind: principal.kind, + }) + return { version } + }, +}) diff --git a/apps/sim/lib/workflows/application/read-workflow.ts b/apps/sim/lib/workflows/application/read-workflow.ts new file mode 100644 index 00000000000..366ba20b2e0 --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow.ts @@ -0,0 +1,47 @@ +import type { Principal } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { workflowFolderPathForId } from '@/lib/workflows/application/workflow-folders' +import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +import { loadWorkflowReadSnapshot } from '@/lib/workflows/queries' + +const logger = createLogger('ReadWorkflow') + +export interface ReadWorkflowInput { + workflowId: string + assertedWorkspaceId?: string +} + +export const readWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.read, + resolveContext: ({ principal, input }: { principal: Principal; input: ReadWorkflowInput }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, context }) { + const snapshot = await loadWorkflowReadSnapshot(context.workflowId) + const workflow = snapshot.workflowRecord + if (!workflow || workflow.archivedAt || workflow.workspaceId !== context.workspaceId) { + throw new OrchestrationError('not_found', 'Workflow not found') + } + const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const inputs = extractInputFieldsFromBlocks(snapshot.normalizedData?.blocks ?? {}) + logger.info('Read workflow', { + workspaceId: context.workspaceId, + workflowId: context.workflowId, + principalKind: principal.kind, + }) + return { + workflow, + workspaceId: context.workspaceId, + inputs, + folderPath: workflowFolderPathForId(folderIndex, workflow.folderId), + } + }, +}) diff --git a/apps/sim/lib/workflows/application/resume-run.ts b/apps/sim/lib/workflows/application/resume-run.ts new file mode 100644 index 00000000000..08f9a94cc78 --- /dev/null +++ b/apps/sim/lib/workflows/application/resume-run.ts @@ -0,0 +1,45 @@ +import type { Principal } from '@sim/auth/principal' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowRunApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { executeResumeWorkflow } from '@/lib/workflows/executor/resume-execution' + +export interface ResumeWorkflowRunInput { + workflowId: string + runId: string + contextId: string + resumeInput: unknown +} + +function assertedWorkspaceId(principal: Principal): string | undefined { + return principal.kind === 'workspace_api_key' || principal.kind === 'delegated' + ? principal.workspaceId + : undefined +} + +export const resumeWorkflowRun = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.resumeRun, + resolveContext: ({ principal, input }: { principal: Principal; input: ResumeWorkflowRunInput }) => + resolveActiveWorkflowRunApplicationContext({ + runId: input.runId, + assertedWorkflowId: input.workflowId, + assertedWorkspaceId: assertedWorkspaceId(principal), + }), + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + return executeResumeWorkflow({ + workflowId: context.workflowId, + executionId: context.runId, + contextId: input.contextId, + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + resumeInput: input.resumeInput, + isApiCaller: true, + pollingSurface: 'v2', + allowStreaming: false, + }) + }, +}) diff --git a/apps/sim/lib/workflows/application/transition-result.ts b/apps/sim/lib/workflows/application/transition-result.ts new file mode 100644 index 00000000000..8ad59eceafd --- /dev/null +++ b/apps/sim/lib/workflows/application/transition-result.ts @@ -0,0 +1,8 @@ +import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' + +export function requireWorkflowTransition< + T extends { success: boolean; error?: string; errorCode?: OrchestrationErrorCode }, +>(result: T, fallbackMessage: string): asserts result is T & { success: true } { + if (result.success) return + throw new OrchestrationError(result.errorCode ?? 'internal', result.error ?? fallbackMessage) +} diff --git a/apps/sim/lib/workflows/application/update-workflow.ts b/apps/sim/lib/workflows/application/update-workflow.ts new file mode 100644 index 00000000000..bea4570979e --- /dev/null +++ b/apps/sim/lib/workflows/application/update-workflow.ts @@ -0,0 +1,89 @@ +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { + assertFolderMutable, + assertWorkflowMutable, + FolderLockedError, + WorkflowLockedError, +} from '@sim/platform-authz/workflow' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { requireWorkflowTransition } from '@/lib/workflows/application/transition-result' +import { + resolveWorkflowFolderPath, + workflowFolderPathForId, +} from '@/lib/workflows/application/workflow-folders' +import { updateWorkflowRecord } from '@/lib/workflows/orchestration' + +const logger = createLogger('UpdateWorkflow') + +export interface UpdateWorkflowInput { + workflowId: string + assertedWorkspaceId?: string + name?: string + description?: string | null + folderPath?: string +} + +export const updateWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.update, + resolveContext: ({ principal, input }: { principal: Principal; input: UpdateWorkflowInput }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, input, context }) { + const resolution = + input.folderPath === undefined + ? undefined + : await resolveWorkflowFolderPath(context.workspaceId, input.folderPath) + + try { + await assertWorkflowMutable(context.workflowId) + if (resolution) await assertFolderMutable(resolution.folderId) + } catch (error) { + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + } + + const transition = await updateWorkflowRecord({ + workflowId: context.workflowId, + userId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + workspaceId: context.workspaceId, + currentName: context.workflow.name, + currentFolderId: context.workflow.folderId, + name: input.name, + description: input.description, + folderId: resolution?.folderId, + }) + requireWorkflowTransition(transition, 'Failed to update workflow') + if (!transition.workflow) throw new Error('Successful workflow update returned no workflow') + + const folderIndex = + resolution?.index ?? (await loadActiveFolderPathIndex(context.workspaceId, 'workflow')) + logger.info('Updated workflow', { + workspaceId: context.workspaceId, + workflowId: context.workflowId, + principalKind: principal.kind, + }) + return { + workflow: transition.workflow, + workspaceId: context.workspaceId, + folderPath: workflowFolderPathForId(folderIndex, transition.workflow.folderId), + deployment: { + isDeployed: context.workflow.isDeployed, + deployedAt: context.workflow.deployedAt, + runCount: context.workflow.runCount, + lastRunAt: context.workflow.lastRunAt, + }, + } + }, +}) diff --git a/apps/sim/lib/workflows/application/workflow-crud.test.ts b/apps/sim/lib/workflows/application/workflow-crud.test.ts new file mode 100644 index 00000000000..64930d7bce7 --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-crud.test.ts @@ -0,0 +1,298 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + recordAudit: vi.fn(), + resolvePermission: vi.fn(), + resolveWorkspaceContext: vi.fn(), + resolveWorkflowContext: vi.fn(), + resolveFolderPath: vi.fn(), + folderPathForId: vi.fn(), + assertFolderMutable: vi.fn(), + assertWorkflowMutable: vi.fn(), + createTransition: vi.fn(), + updateRecord: vi.fn(), + deleteRecord: vi.fn(), + listRows: vi.fn(), + loadSnapshot: vi.fn(), + loadFolderIndex: vi.fn(), + listVersions: vi.fn(), + readVersion: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + WORKFLOW_CREATED: 'workflow.created', + WORKFLOW_DELETED: 'workflow.deleted', + }, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mocks.assertFolderMutable, + assertWorkflowMutable: mocks.assertWorkflowMutable, + FolderLockedError: class FolderLockedError extends Error {}, + WorkflowLockedError: class WorkflowLockedError extends Error {}, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.resolveWorkspaceContext, + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, +})) + +vi.mock('@/lib/workflows/application/workflow-folders', () => ({ + resolveWorkflowFolderPath: mocks.resolveFolderPath, + workflowFolderPathForId: mocks.folderPathForId, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performCreateWorkflowTransition: mocks.createTransition, + updateWorkflowRecord: mocks.updateRecord, + deleteWorkflowRecord: mocks.deleteRecord, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mocks.loadFolderIndex, +})) + +vi.mock('@/lib/workflows/queries', () => ({ + InvalidWorkflowListCursorError: class InvalidWorkflowListCursorError extends Error {}, + listWorkspaceWorkflows: mocks.listRows, + loadWorkflowReadSnapshot: mocks.loadSnapshot, +})) + +vi.mock('@/lib/workflows/input-format', () => ({ + extractInputFieldsFromBlocks: vi.fn().mockReturnValue([]), +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + listWorkflowVersions: mocks.listVersions, + getWorkflowDeploymentVersion: mocks.readVersion, +})) + +import { createWorkflow } from '@/lib/workflows/application/create-workflow' +import { deleteWorkflow } from '@/lib/workflows/application/delete-workflow' +import { listWorkflowVersions } from '@/lib/workflows/application/list-workflow-versions' +import { readWorkflow } from '@/lib/workflows/application/read-workflow' +import { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version' + +const WORKSPACE_ID = 'workspace-1' +const WORKFLOW_ID = 'workflow-1' +const now = new Date('2026-08-01T00:00:00.000Z') +const workflowRecord = { + id: WORKFLOW_ID, + userId: 'owner-1', + workspaceId: WORKSPACE_ID, + folderId: null, + name: 'Daily digest', + description: null, + variables: {}, + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + archivedAt: null, + createdAt: now, + updatedAt: now, +} +const workspaceContext = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const workflowContext = { + ...workspaceContext, + workflowId: WORKFLOW_ID, + workflow: workflowRecord, +} +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'personal-key-1', +} +const workspacePrincipal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'workspace-key-1', +} + +describe('authorized workflow CRUD and version reads', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('admin') + mocks.resolveWorkspaceContext.mockResolvedValue(workspaceContext) + mocks.resolveWorkflowContext.mockResolvedValue(workflowContext) + mocks.resolveFolderPath.mockResolvedValue({ folderId: null, index: {} }) + mocks.folderPathForId.mockReturnValue('/') + mocks.loadFolderIndex.mockResolvedValue({}) + mocks.createTransition.mockResolvedValue({ + success: true, + workflow: { + id: WORKFLOW_ID, + name: workflowRecord.name, + description: null, + workspaceId: WORKSPACE_ID, + folderId: null, + sortOrder: 0, + createdAt: now, + updatedAt: now, + subBlockValues: {}, + }, + }) + mocks.loadSnapshot.mockResolvedValue({ workflowRecord, normalizedData: { blocks: {} } }) + mocks.deleteRecord.mockResolvedValue({ + success: true, + archived: true, + workflow: { id: WORKFLOW_ID, name: workflowRecord.name, workspaceId: WORKSPACE_ID }, + }) + mocks.listVersions.mockResolvedValue({ versions: [] }) + mocks.readVersion.mockResolvedValue({ + id: 'version-1', + version: 1, + name: null, + description: null, + isActive: true, + createdAt: now, + state: { blocks: {}, edges: [], loops: {}, parallels: {}, version: '1.0' }, + }) + }) + + it('creates for a personal key and projects one authoritative semantic audit', async () => { + await expect( + createWorkflow.execute({ + principal: personalPrincipal, + input: { workspaceId: WORKSPACE_ID, name: workflowRecord.name }, + }) + ).resolves.toMatchObject({ workflow: { id: WORKFLOW_ID } }) + + expect(mocks.createTransition).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', workspaceId: WORKSPACE_ID }) + ) + expect(mocks.recordAudit).toHaveBeenCalledTimes(1) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'user-1', + action: 'workflow.created', + resourceId: WORKFLOW_ID, + metadata: expect.objectContaining({ + operation: 'workflows.create', + actor: expect.objectContaining({ kind: 'personal_api_key', keyId: 'personal-key-1' }), + }), + }) + ) + }) + + it('uses the billing owner only for the workspace key legacy user column', async () => { + await createWorkflow.execute({ + principal: workspacePrincipal, + input: { workspaceId: WORKSPACE_ID, name: workflowRecord.name }, + }) + + expect(mocks.createTransition).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'billing-owner-1' }) + ) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: null, + actorName: 'Workspace API key', + metadata: expect.objectContaining({ + actor: { + kind: 'workspace_api_key', + keyId: 'workspace-key-1', + workspaceId: WORKSPACE_ID, + }, + }), + }) + ) + }) + + it('propagates infrastructure failure without projecting audit', async () => { + const failure = new Error('database unavailable') + mocks.createTransition.mockRejectedValue(failure) + + await expect( + createWorkflow.execute({ + principal: workspacePrincipal, + input: { workspaceId: WORKSPACE_ID, name: workflowRecord.name }, + }) + ).rejects.toBe(failure) + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('binds workspace keys to canonical workflow scope before protected reads', async () => { + mocks.resolveWorkflowContext.mockRejectedValue(new Error('canonical mismatch')) + + await expect( + readWorkflow.execute({ + principal: { ...workspacePrincipal, workspaceId: 'workspace-other' }, + input: { workflowId: WORKFLOW_ID }, + }) + ).rejects.toThrow('canonical mismatch') + expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({ + workflowId: WORKFLOW_ID, + assertedWorkspaceId: 'workspace-other', + }) + expect(mocks.loadSnapshot).not.toHaveBeenCalled() + }) + + it('does not audit an authoritative delete no-op', async () => { + mocks.deleteRecord.mockResolvedValue({ + success: true, + archived: false, + workflow: { id: WORKFLOW_ID, name: workflowRecord.name, workspaceId: WORKSPACE_ID }, + }) + + await deleteWorkflow.execute({ + principal: personalPrincipal, + input: { workflowId: WORKFLOW_ID }, + }) + + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('supports bounded v2 and unbounded internal version listing', async () => { + await listWorkflowVersions.execute({ + principal: workspacePrincipal, + input: { workflowId: WORKFLOW_ID, limit: 50 }, + }) + expect(mocks.listVersions).toHaveBeenLastCalledWith(WORKFLOW_ID, { + limit: 51, + afterVersion: undefined, + }) + + await expect( + listWorkflowVersions.execute({ + principal: workspacePrincipal, + input: { workflowId: WORKFLOW_ID }, + }) + ).resolves.toEqual({ versions: [], hasMore: false }) + expect(mocks.listVersions).toHaveBeenLastCalledWith(WORKFLOW_ID, { + limit: undefined, + afterVersion: undefined, + }) + }) + + it('reads one version only after canonical workflow authorization', async () => { + await expect( + readWorkflowVersion.execute({ + principal: personalPrincipal, + input: { workflowId: WORKFLOW_ID, version: 1 }, + }) + ).resolves.toMatchObject({ version: { id: 'version-1', version: 1 } }) + expect(mocks.resolveWorkflowContext).toHaveBeenCalledBefore(mocks.readVersion) + }) +}) diff --git a/apps/sim/lib/workflows/application/workflow-deployments.test.ts b/apps/sim/lib/workflows/application/workflow-deployments.test.ts new file mode 100644 index 00000000000..3863d2f1a40 --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-deployments.test.ts @@ -0,0 +1,340 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { MockWorkflowLockedError, mocks } = vi.hoisted(() => { + class MockWorkflowLockedError extends Error {} + return { + MockWorkflowLockedError, + mocks: { + activate: vi.fn(), + assertMutable: vi.fn(), + audit: vi.fn(), + deploy: vi.fn(), + findPrevious: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + undeploy: vi.fn(), + }, + } +}) + +vi.mock('@sim/audit', () => ({ + AuditAction: { WORKFLOW_UNDEPLOYED: 'workflow.undeployed' }, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.audit, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertWorkflowMutable: mocks.assertMutable, + WorkflowLockedError: MockWorkflowLockedError, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveContext, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performActivateVersion: mocks.activate, + performFullDeploy: mocks.deploy, + performFullUndeploy: mocks.undeploy, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + findPreviousDeploymentVersion: mocks.findPrevious, +})) + +import { + activateWorkflowVersion, + deployWorkflow, + undeployWorkflow, +} from '@/lib/workflows/application/deployments' + +const workflow = { + id: 'workflow-1', + name: 'Release workflow', + userId: 'owner-1', + workspaceId: 'workspace-1', + isDeployed: true, +} +const context = { + workflowId: 'workflow-1', + workflow, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const adminPrincipals: Array<{ principal: Principal; actorUserId: string }> = [ + { + principal: { kind: 'session', userId: 'session-user', sessionId: 'session-1' }, + actorUserId: 'session-user', + }, + { + principal: { kind: 'personal_api_key', userId: 'key-user', keyId: 'personal-key' }, + actorUserId: 'key-user', + }, + { + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'delegated-user', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2999-01-01T00:00:00Z'), + }, + actorUserId: 'delegated-user', + }, +] + +describe('workflow deployment application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.deploy.mockResolvedValue({ + success: true, + deployedAt: new Date('2026-08-08T00:00:00Z'), + version: 4, + activeDeployment: null, + latestDeploymentAttempt: null, + warnings: [], + }) + mocks.undeploy.mockResolvedValue({ success: true, warnings: [] }) + mocks.activate.mockResolvedValue({ + success: true, + deployedAt: new Date('2026-08-08T00:01:00Z'), + activeDeployment: null, + latestDeploymentAttempt: null, + warnings: [], + }) + mocks.findPrevious.mockResolvedValue({ ok: true, version: 3 }) + }) + + it.each(adminPrincipals)( + 'admits $principal.kind deploys with canonical actor attribution', + async ({ principal, actorUserId }) => { + await deployWorkflow.execute({ + principal, + input: { + workflowId: 'workflow-1', + name: 'Version 4', + description: 'Production release', + requestId: 'request-1', + idempotencyKey: 'deploy-idempotency-1', + }, + }) + + expect(mocks.deploy).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-1', + userId: actorUserId, + actorId: actorUserId, + captureAnalytics: false, + versionName: 'Version 4', + versionDescription: 'Production release', + requestId: 'request-1', + idempotencyKey: 'deploy-idempotency-1', + }) + ) + expect(mocks.audit).not.toHaveBeenCalled() + } + ) + + it('denies workspace API keys before canonical lookup for admin transitions', async () => { + await expect( + deployWorkflow.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key', + }, + input: { workflowId: 'workflow-1', requestId: 'request-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveContext).not.toHaveBeenCalled() + expect(mocks.deploy).not.toHaveBeenCalled() + }) + + it('requires current admin permission before deployment', async () => { + mocks.resolvePermission.mockResolvedValueOnce('write') + + await expect( + deployWorkflow.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workflowId: 'workflow-1', requestId: 'request-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.assertMutable).not.toHaveBeenCalled() + expect(mocks.deploy).not.toHaveBeenCalled() + }) + + it('undeploys without legacy audit and projects one semantic audit entry', async () => { + await undeployWorkflow.execute({ + principal: { kind: 'personal_api_key', userId: 'key-user', keyId: 'personal-key' }, + input: { workflowId: 'workflow-1', requestId: 'request-2' }, + }) + + expect(mocks.undeploy).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + userId: 'key-user', + actorId: 'key-user', + projectLegacyAudit: false, + requestId: 'request-2', + }) + expect(mocks.audit).toHaveBeenCalledOnce() + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + actorId: 'key-user', + action: 'workflow.undeployed', + resourceType: 'workflow', + resourceId: 'workflow-1', + metadata: expect.objectContaining({ operation: 'workflows.undeploy' }), + }) + ) + }) + + it('activates an explicit version with analytics disabled in orchestration', async () => { + await activateWorkflowVersion.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workflowId: 'workflow-1', + version: 2, + transition: 'activate', + requestId: 'request-3', + idempotencyKey: 'activation-1', + }, + }) + + expect(mocks.findPrevious).not.toHaveBeenCalled() + expect(mocks.activate).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-1', + version: 2, + userId: 'user-1', + actorId: 'user-1', + captureAnalytics: false, + requestId: 'request-3', + idempotencyKey: 'activation-1', + }) + ) + }) + + it('resolves the previous active version for an implicit rollback', async () => { + const result = await activateWorkflowVersion.execute({ + principal: { kind: 'personal_api_key', userId: 'key-user', keyId: 'personal-key' }, + input: { workflowId: 'workflow-1', transition: 'rollback', requestId: 'request-4' }, + }) + + expect(mocks.findPrevious).toHaveBeenCalledWith('workflow-1') + expect(mocks.activate).toHaveBeenCalledWith(expect.objectContaining({ version: 3 })) + expect(result.version).toBe(3) + }) + + it('rejects undeploy and rollback when the canonical workflow is not deployed', async () => { + mocks.resolveContext.mockResolvedValue({ + ...context, + workflow: { ...workflow, isDeployed: false }, + }) + const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const + + await expect( + undeployWorkflow.execute({ + principal, + input: { workflowId: 'workflow-1', requestId: 'request-5' }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + await expect( + activateWorkflowVersion.execute({ + principal, + input: { + workflowId: 'workflow-1', + version: 1, + transition: 'rollback', + requestId: 'request-6', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.undeploy).not.toHaveBeenCalled() + expect(mocks.activate).not.toHaveBeenCalled() + }) + + it('allows explicit internal activation to redeploy an undeployed workflow', async () => { + mocks.resolveContext.mockResolvedValue({ + ...context, + workflow: { ...workflow, isDeployed: false }, + }) + + await activateWorkflowVersion.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workflowId: 'workflow-1', + version: 1, + transition: 'activate', + requestId: 'request-activation', + }, + }) + + expect(mocks.findPrevious).not.toHaveBeenCalled() + expect(mocks.activate).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'workflow-1', version: 1 }) + ) + }) + + it('maps lock failures and propagates manager infrastructure failures', async () => { + mocks.assertMutable.mockRejectedValueOnce(new MockWorkflowLockedError('Workflow is locked')) + + await expect( + deployWorkflow.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workflowId: 'workflow-1', requestId: 'request-7' }, + }) + ).rejects.toMatchObject({ code: 'locked', message: 'Workflow is locked' }) + + const infrastructureError = new Error('deployment manager unavailable') + mocks.activate.mockRejectedValueOnce(infrastructureError) + await expect( + activateWorkflowVersion.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workflowId: 'workflow-1', + version: 1, + transition: 'activate', + requestId: 'request-8', + }, + }) + ).rejects.toBe(infrastructureError) + }) + + it('does not expose an internal deployment failure message', async () => { + mocks.deploy.mockResolvedValueOnce({ + success: false, + errorCode: 'internal', + error: 'driver connection string', + }) + + await expect( + deployWorkflow.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workflowId: 'workflow-1', requestId: 'request-9' }, + }) + ).rejects.toThrow('Failed to deploy workflow') + }) +}) diff --git a/apps/sim/lib/workflows/application/workflow-folders.test.ts b/apps/sim/lib/workflows/application/workflow-folders.test.ts new file mode 100644 index 00000000000..c779babfd01 --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-folders.test.ts @@ -0,0 +1,193 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + create: vi.fn(), + relocate: vi.fn(), + delete: vi.fn(), + loadIndex: vi.fn(), + listRows: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.resolveContext, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { + FOLDER_CREATED: 'folder.created', + FOLDER_DELETED: 'folder.deleted', + FOLDER_MOVED: 'folder.moved', + }, + AuditResourceType: { FOLDER: 'folder' }, + recordAudit: mocks.recordAudit, +})) +vi.mock('@/lib/folders/orchestration', () => ({ + createFolderAtPathTransition: mocks.create, + deleteFolderByPathTransition: mocks.delete, + relocateFolderByPathTransition: mocks.relocate, +})) +vi.mock('@/lib/folders/queries', () => ({ + listActiveFolderRows: mocks.listRows, + loadActiveFolderPathIndex: mocks.loadIndex, + resolveFolderPathFromIndex: (index: { idByPath: Map<string, string> }, path: string) => + path === '/' ? null : index.idByPath.get(path), +})) + +import { + createWorkflowFolder, + deleteWorkflowFolder, + listWorkflowFolders, +} from '@/lib/workflows/application/workflow-folders' + +const folder = { + id: 'folder-1', + resourceType: 'workflow' as const, + name: 'Reports', + userId: 'owner-1', + workspaceId: 'ws-1', + parentId: null, + sortOrder: 0, + locked: false, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + deletedAt: null, +} +const index = { + rowById: new Map([[folder.id, folder]]), + pathById: new Map([[folder.id, '/Reports']]), + idByPath: new Map([['/Reports', folder.id]]), +} + +const principals: Principal[] = [ + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { kind: 'personal_api_key', userId: 'user-1', keyId: 'personal-1' }, + { kind: 'workspace_api_key', workspaceId: 'ws-1', keyId: 'workspace-1' }, + { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + }, +] + +describe('workflow folder application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue({ + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', + }) + mocks.resolvePermission.mockResolvedValue('write') + mocks.loadIndex.mockResolvedValue(index) + mocks.create.mockResolvedValue({ success: true, folder, path: '/Reports' }) + mocks.delete.mockResolvedValue({ + success: true, + folderId: folder.id, + folderName: folder.name, + path: '/Reports', + deletedItems: { folders: 1, workflows: 2 }, + }) + }) + + it.each(principals.map((principal) => [principal.kind, principal] as const))( + 'allows the %s principal through canonical workspace authorization', + async (_kind, principal) => { + await createWorkflowFolder.execute({ + principal, + input: { workspaceId: 'ws-1', path: '/Reports' }, + }) + + expect(mocks.create).toHaveBeenCalledWith({ + resourceType: 'workflow', + workspaceId: 'ws-1', + userId: principal.kind === 'workspace_api_key' ? 'owner-1' : 'user-1', + path: '/Reports', + }) + expect(mocks.recordAudit).toHaveBeenCalledOnce() + } + ) + + it('rejects a workspace key outside the canonical workspace before mutation', async () => { + await expect( + createWorkflowFolder.execute({ + principal: { kind: 'workspace_api_key', workspaceId: 'ws-2', keyId: 'workspace-2' }, + input: { workspaceId: 'ws-1', path: '/Reports' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.create).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('keeps workspace-key audit attribution non-human', async () => { + await deleteWorkflowFolder.execute({ + principal: { kind: 'workspace_api_key', workspaceId: 'ws-1', keyId: 'workspace-1' }, + input: { workspaceId: 'ws-1', path: '/Reports', recursive: true }, + }) + + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: null, + actorName: 'Workspace API key', + metadata: expect.objectContaining({ + actor: { + kind: 'workspace_api_key', + keyId: 'workspace-1', + workspaceId: 'ws-1', + }, + }), + }) + ) + }) + + it('does not audit a rejected transition', async () => { + mocks.create.mockResolvedValue({ + success: false, + error: 'Folder is locked', + errorCode: 'locked', + }) + + await expect( + createWorkflowFolder.execute({ + principal: principals[0], + input: { workspaceId: 'ws-1', path: '/Reports' }, + }) + ).rejects.toMatchObject({ code: 'locked' }) + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('propagates context infrastructure failures without mutation or audit', async () => { + const failure = new Error('database unavailable') + mocks.resolveContext.mockRejectedValueOnce(failure) + + await expect( + listWorkflowFolders.execute({ + principal: principals[0], + input: { + workspaceId: 'ws-1', + sortBy: 'name', + sortOrder: 'asc', + }, + }) + ).rejects.toBe(failure) + expect(mocks.listRows).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/workflow-folders.ts b/apps/sim/lib/workflows/application/workflow-folders.ts new file mode 100644 index 00000000000..c8e71faadf7 --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-folders.ts @@ -0,0 +1,246 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import type { folder } from '@sim/db/schema' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { withFolderTreeLock } from '@/lib/folders/locks' +import { + createFolderAtPathTransition, + deleteFolderByPathTransition, + relocateFolderByPathTransition, +} from '@/lib/folders/orchestration' +import type { FolderPathIndex } from '@/lib/folders/paths' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { + listActiveFolderRows, + loadActiveFolderPathIndex, + resolveFolderPathFromIndex, +} 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' + +type WorkflowFolderRecord = typeof folder.$inferSelect +type WorkflowFolderIndex = FolderPathIndex<WorkflowFolderRecord> + +export interface ListWorkflowFoldersInput { + workspaceId: string + parentPath?: string + search?: string + sortBy: 'name' | 'createdAt' | 'updatedAt' + sortOrder: 'asc' | 'desc' +} + +export interface WorkflowFolderResult { + folder: WorkflowFolderRecord + index: WorkflowFolderIndex +} + +export interface ListWorkflowFoldersResult { + folders: WorkflowFolderRecord[] + index: WorkflowFolderIndex +} + +export interface CreateWorkflowFolderInput { + workspaceId: string + path: string +} + +export interface RelocateWorkflowFolderInput { + workspaceId: string + path: string + destinationPath: string +} + +export interface DeleteWorkflowFolderInput { + workspaceId: string + path: string + recursive: boolean +} + +export interface DeleteWorkflowFolderResult { + path: string + folderId: string + folderName: string + deletedItems: { + folders: number + workflows: number + } +} + +function throwFolderMutationFailure(result: { + error?: string + errorCode?: OrchestrationErrorCode +}): never { + const code = result.errorCode ?? 'internal' + throw new OrchestrationError( + code, + code === 'internal' ? 'Internal server error' : (result.error ?? 'Folder mutation failed') + ) +} + +export async function resolveWorkflowFolderPath( + workspaceId: string, + path: string +): Promise<{ folderId: string | null; index: WorkflowFolderIndex }> { + const resolution = await withFolderTreeLock(workspaceId, 'workflow', async (tx) => { + const index = await loadActiveFolderPathIndex(workspaceId, 'workflow', tx) + const folderId = resolveFolderPathFromIndex(index, path) + return folderId === undefined + ? { found: false as const } + : { found: true as const, folderId, index } + }) + if (!resolution.found) throw new OrchestrationError('not_found', 'Folder not found') + return { folderId: resolution.folderId, index: resolution.index } +} + +export function workflowFolderPathForId( + index: WorkflowFolderIndex, + folderId: string | null | undefined +): string { + if (!folderId) return ROOT_FOLDER_PATH + const path = index.pathById.get(folderId) + if (!path) throw new Error('Workflow references an inactive or missing folder') + return path +} + +export const listWorkflowFolders = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.listFolders, + resolveContext: ({ input }: { input: ListWorkflowFoldersInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ input, context }): Promise<ListWorkflowFoldersResult> { + const index = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const parentId = + input.parentPath === undefined + ? undefined + : resolveFolderPathFromIndex(index, input.parentPath) + if (input.parentPath !== undefined && parentId === undefined) { + throw new OrchestrationError('not_found', 'Folder not found') + } + const folders = await listActiveFolderRows(context.workspaceId, 'workflow', { + parentId, + search: input.search, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + }) + return { folders, index } + }, +}) + +export const createWorkflowFolder = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.createFolder, + resolveContext: ({ input }: { input: CreateWorkflowFolderInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }): Promise<WorkflowFolderResult> { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await createFolderAtPathTransition({ + resourceType: 'workflow', + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + path: input.path, + }) + if (!result.success || !result.folder || !result.path) throwFolderMutationFailure(result) + const index = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + return { folder: result.folder, index } + }, + projectAudit({ input, result }) { + return { + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Created workflow folder "${input.path}"`, + metadata: { path: input.path, folderResourceType: 'workflow' }, + } + }, +}) + +export const relocateWorkflowFolder = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.relocateFolder, + resolveContext: ({ input }: { input: RelocateWorkflowFolderInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }): Promise<WorkflowFolderResult> { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await relocateFolderByPathTransition({ + resourceType: 'workflow', + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + path: input.path, + destinationPath: input.destinationPath, + }) + if (!result.success || !result.folder || !result.path) throwFolderMutationFailure(result) + const index = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + return { folder: result.folder, index } + }, + projectAudit({ input, result }) { + return { + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Moved workflow folder to "${input.destinationPath}"`, + metadata: { + sourcePath: input.path, + destinationPath: input.destinationPath, + folderResourceType: 'workflow', + }, + } + }, +}) + +export const deleteWorkflowFolder = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.deleteFolder, + resolveContext: ({ input }: { input: DeleteWorkflowFolderInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }): Promise<DeleteWorkflowFolderResult> { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await deleteFolderByPathTransition({ + resourceType: 'workflow', + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + path: input.path, + recursive: input.recursive, + }) + if ( + !result.success || + !result.deletedItems || + !result.folderId || + !result.folderName || + !result.path + ) { + throwFolderMutationFailure(result) + } + return { + path: result.path, + folderId: result.folderId, + folderName: result.folderName, + deletedItems: { + folders: result.deletedItems.folders, + workflows: result.deletedItems.workflows ?? 0, + }, + } + }, + projectAudit({ result }) { + return { + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folderId, + resourceName: result.folderName, + description: `Deleted workflow folder "${result.path}"`, + metadata: { + folderResourceType: 'workflow', + path: result.path, + affected: { + workflows: result.deletedItems.workflows, + subfolders: Math.max(result.deletedItems.folders - 1, 0), + }, + }, + } + }, +}) diff --git a/apps/sim/lib/workflows/application/workflow-import-error.ts b/apps/sim/lib/workflows/application/workflow-import-error.ts new file mode 100644 index 00000000000..780d5d4083e --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-import-error.ts @@ -0,0 +1,13 @@ +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +export class WorkflowImportError extends OrchestrationError { + constructor( + code: OrchestrationErrorCode, + message: string, + readonly details?: unknown + ) { + super(code, message) + this.name = 'WorkflowImportError' + } +} diff --git a/apps/sim/lib/workflows/application/workflow-run-control.test.ts b/apps/sim/lib/workflows/application/workflow-run-control.test.ts new file mode 100644 index 00000000000..262f99361b6 --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-run-control.test.ts @@ -0,0 +1,266 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { MockWorkflowExecutionNotFoundError, mocks } = vi.hoisted(() => { + class MockWorkflowExecutionNotFoundError extends Error {} + return { + MockWorkflowExecutionNotFoundError, + mocks: { + audit: vi.fn(), + cancel: vi.fn(), + resolvePermission: vi.fn(), + resolveRunContext: vi.fn(), + resume: vi.fn(), + }, + } +}) + +vi.mock('@sim/audit', () => ({ recordAudit: mocks.audit })) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowRunApplicationContext: mocks.resolveRunContext, +})) + +vi.mock('@/lib/execution/cancel-workflow-execution', () => ({ + cancelWorkflowExecution: mocks.cancel, + WorkflowExecutionNotFoundError: MockWorkflowExecutionNotFoundError, +})) + +vi.mock('@/lib/workflows/executor/resume-execution', () => ({ + executeResumeWorkflow: mocks.resume, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { cancelWorkflowRun } from '@/lib/workflows/application/cancel-run' +import { resumeWorkflowRun } from '@/lib/workflows/application/resume-run' + +const runContext = { + workflowId: 'workflow-1', + workflow: { id: 'workflow-1', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + runId: 'parent-run-1', +} + +const principals: Array<{ principal: Principal; actorUserId: string }> = [ + { + principal: { kind: 'session', userId: 'session-user', sessionId: 'session-1' }, + actorUserId: 'session-user', + }, + { + principal: { kind: 'personal_api_key', userId: 'key-user', keyId: 'personal-key' }, + actorUserId: 'key-user', + }, + { + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key', + }, + actorUserId: 'billing-owner-1', + }, + { + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'delegated-user', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2999-01-01T00:00:00Z'), + }, + actorUserId: 'delegated-user', + }, +] + +describe('workflow run-control application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveRunContext.mockResolvedValue(runContext) + mocks.cancel.mockResolvedValue({ + success: true, + executionId: 'parent-run-1', + redisAvailable: true, + durablyRecorded: true, + locallyAborted: false, + pausedCancelled: false, + reason: 'recorded', + }) + mocks.resume.mockResolvedValue({ + kind: 'queued', + executionId: 'resumed-run-2', + queuePosition: 1, + }) + }) + + it.each(principals)( + 'authorizes $principal.kind cancellation in canonical run scope', + async ({ principal, actorUserId }) => { + await cancelWorkflowRun.execute({ + principal, + input: { workflowId: 'workflow-1', runId: 'parent-run-1' }, + }) + + expect(mocks.resolveRunContext).toHaveBeenCalledWith({ + runId: 'parent-run-1', + assertedWorkflowId: 'workflow-1', + assertedWorkspaceId: + principal.kind === 'workspace_api_key' || principal.kind === 'delegated' + ? 'workspace-1' + : undefined, + }) + expect(mocks.cancel).toHaveBeenCalledWith({ + executionId: 'parent-run-1', + workflowId: 'workflow-1', + userId: actorUserId, + workspaceId: 'workspace-1', + captureAnalytics: false, + }) + expect(mocks.audit).not.toHaveBeenCalled() + } + ) + + it.each(principals)( + 'authorizes $principal.kind resume and preserves the parent/new run distinction', + async ({ principal, actorUserId }) => { + const result = await resumeWorkflowRun.execute({ + principal, + input: { + workflowId: 'workflow-1', + runId: 'parent-run-1', + contextId: 'context-1', + resumeInput: { approved: true }, + }, + }) + + expect(mocks.resolveRunContext).toHaveBeenCalledWith({ + runId: 'parent-run-1', + assertedWorkflowId: 'workflow-1', + assertedWorkspaceId: + principal.kind === 'workspace_api_key' || principal.kind === 'delegated' + ? 'workspace-1' + : undefined, + }) + expect(mocks.resume).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + executionId: 'parent-run-1', + contextId: 'context-1', + workspaceId: 'workspace-1', + userId: actorUserId, + resumeInput: { approved: true }, + isApiCaller: true, + pollingSurface: 'v2', + allowStreaming: false, + }) + expect(result).toMatchObject({ executionId: 'resumed-run-2' }) + expect(mocks.audit).not.toHaveBeenCalled() + } + ) + + it('stops cancellation and resume before authorization when workflow/run scope disagrees', async () => { + mocks.resolveRunContext.mockRejectedValue(new OrchestrationError('not_found', 'Run not found')) + const principal = principals[0].principal + + await expect( + cancelWorkflowRun.execute({ + principal, + input: { workflowId: 'wrong-workflow', runId: 'parent-run-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect( + resumeWorkflowRun.execute({ + principal, + input: { + workflowId: 'wrong-workflow', + runId: 'parent-run-1', + contextId: 'context-1', + resumeInput: {}, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.cancel).not.toHaveBeenCalled() + expect(mocks.resume).not.toHaveBeenCalled() + }) + + it('requires current write permission for session cancellation and resume', async () => { + mocks.resolvePermission.mockResolvedValue('read') + const principal = principals[0].principal + + await expect( + cancelWorkflowRun.execute({ + principal, + input: { workflowId: 'workflow-1', runId: 'parent-run-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await expect( + resumeWorkflowRun.execute({ + principal, + input: { + workflowId: 'workflow-1', + runId: 'parent-run-1', + contextId: 'context-1', + resumeInput: {}, + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.cancel).not.toHaveBeenCalled() + expect(mocks.resume).not.toHaveBeenCalled() + }) + + it('maps stale cancellation manager state to semantic absence', async () => { + mocks.cancel.mockRejectedValueOnce(new MockWorkflowExecutionNotFoundError()) + + await expect( + cancelWorkflowRun.execute({ + principal: principals[0].principal, + input: { workflowId: 'workflow-1', runId: 'parent-run-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Run not found' }) + }) + + it('propagates cancellation and resume infrastructure failures', async () => { + const cancelFailure = new Error('cancellation store unavailable') + const resumeFailure = new Error('resume manager unavailable') + mocks.cancel.mockRejectedValueOnce(cancelFailure) + + await expect( + cancelWorkflowRun.execute({ + principal: principals[2].principal, + input: { workflowId: 'workflow-1', runId: 'parent-run-1' }, + }) + ).rejects.toBe(cancelFailure) + + mocks.resume.mockRejectedValueOnce(resumeFailure) + await expect( + resumeWorkflowRun.execute({ + principal: principals[2].principal, + input: { + workflowId: 'workflow-1', + runId: 'parent-run-1', + contextId: 'context-1', + resumeInput: {}, + }, + }) + ).rejects.toBe(resumeFailure) + }) +}) diff --git a/apps/sim/lib/workflows/application/workflow-runs.test.ts b/apps/sim/lib/workflows/application/workflow-runs.test.ts new file mode 100644 index 00000000000..173bbab87ce --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-runs.test.ts @@ -0,0 +1,175 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + list: vi.fn(), + resolvePermission: vi.fn(), + resolveRunContext: vi.fn(), + resolveWorkflowContext: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, + resolveActiveWorkflowRunApplicationContext: mocks.resolveRunContext, +})) + +vi.mock('@/lib/workflows/executor/execution-queries', () => ({ + listWorkflowExecutions: mocks.list, +})) + +vi.mock('@/lib/workflows/executor/execution-status', () => ({ + getWorkflowExecutionStatus: mocks.getStatus, +})) + +import { FunctionalOutputsUnavailableError } from '@/lib/logs/execution/functional-outputs' +import { listWorkflowRuns } from '@/lib/workflows/application/list-workflow-runs' +import { readWorkflowRun } from '@/lib/workflows/application/read-workflow-run' + +const workflowContext = { + workflowId: 'workflow-1', + workflow: { id: 'workflow-1' }, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const runContext = { ...workflowContext, runId: 'run-1' } + +const principals: Principal[] = [ + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-personal' }, + { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-workspace' }, + { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2999-01-01T00:00:00Z'), + }, +] + +describe('workflow run application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveWorkflowContext.mockResolvedValue(workflowContext) + mocks.resolveRunContext.mockResolvedValue(runContext) + mocks.list.mockResolvedValue({ data: [], nextCursor: null }) + mocks.getStatus.mockResolvedValue({ + executionId: 'run-1', + workflowId: 'workflow-1', + status: 'completed', + }) + }) + + it.each(principals)( + 'allows $kind to list runs through the canonical workflow', + async (principal) => { + await listWorkflowRuns.execute({ + principal, + input: { workflowId: 'workflow-1', limit: 25, order: 'desc' }, + }) + + expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + assertedWorkspaceId: + principal.kind === 'workspace_api_key' || principal.kind === 'delegated' + ? 'workspace-1' + : undefined, + }) + expect(mocks.list).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'workflow-1', limit: 25, order: 'desc' }) + ) + } + ) + + it('resolves a run canonically before reading its status', async () => { + await readWorkflowRun.execute({ + principal: principals[2], + input: { + workflowId: 'workflow-1', + runId: 'run-1', + includeOutput: true, + selectedOutputs: ['block-1.value'], + }, + }) + + expect(mocks.resolveRunContext).toHaveBeenCalledWith({ + runId: 'run-1', + assertedWorkflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + }) + expect(mocks.getStatus).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + executionId: 'run-1', + includeOutput: true, + selectedOutputs: ['block-1.value'], + }) + }) + + it('stops before authorization and data access when canonical run scope disagrees', async () => { + mocks.resolveRunContext.mockRejectedValueOnce( + Object.assign(new Error('Run not found'), { code: 'not_found' }) + ) + + await expect( + readWorkflowRun.execute({ + principal: principals[2], + input: { + workflowId: 'other-workflow', + runId: 'run-1', + includeOutput: false, + selectedOutputs: [], + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.getStatus).not.toHaveBeenCalled() + }) + + it('maps unavailable functional outputs to a semantic conflict', async () => { + mocks.getStatus.mockRejectedValueOnce(new FunctionalOutputsUnavailableError()) + + await expect( + readWorkflowRun.execute({ + principal: principals[0], + input: { + workflowId: 'workflow-1', + runId: 'run-1', + includeOutput: false, + selectedOutputs: ['block-1'], + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + }) + + it('propagates run repository infrastructure failures', async () => { + const infrastructureError = new Error('database unavailable') + mocks.list.mockRejectedValueOnce(infrastructureError) + + await expect( + listWorkflowRuns.execute({ + principal: principals[0], + input: { workflowId: 'workflow-1', limit: 25, order: 'desc' }, + }) + ).rejects.toBe(infrastructureError) + }) +}) diff --git a/apps/sim/lib/workflows/deployment-outbox.ts b/apps/sim/lib/workflows/deployment-outbox.ts index ed58b1d040a..a22152e4421 100644 --- a/apps/sim/lib/workflows/deployment-outbox.ts +++ b/apps/sim/lib/workflows/deployment-outbox.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import type { PrincipalActor } from '@sim/auth/principal' import { db, workflowDeploymentVersion, workflow as workflowTable } from '@sim/db' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' @@ -100,6 +101,8 @@ export interface PrepareDeploymentV2Payload { deploymentVersionId: string version: number userId: string + actor?: PrincipalActor + captureAnalytics?: false requestId: string checkpoints: DeploymentPreparationCheckpoints } @@ -632,6 +635,7 @@ async function emitPostActivationSideEffects(params: { deploymentVersionId: params.payload.deploymentVersionId, version: params.payload.version, previousVersionId: params.operation.previousActiveVersionId || undefined, + ...(params.payload.actor ? { actor: params.payload.actor } : {}), }, }) params.context.signal.throwIfAborted() @@ -640,23 +644,25 @@ async function emitPostActivationSideEffects(params: { if (!params.checkpoints.analyticsCaptured) { params.context.signal.throwIfAborted() - const workspaceId = (params.workflow.workspaceId as string) || '' - const isVersionActivation = params.operation.action === 'activate' - captureServerEvent( - params.payload.userId, - isVersionActivation ? 'deployment_version_activated' : 'workflow_deployed', - { - workflow_id: params.payload.workflowId, - workspace_id: workspaceId, - ...(isVersionActivation ? { version: params.payload.version } : {}), - }, - { - groups: workspaceId ? { workspace: workspaceId } : undefined, - ...(isVersionActivation - ? {} - : { setOnce: { first_workflow_deployed_at: new Date().toISOString() } }), - } - ) + if (params.payload.captureAnalytics !== false) { + const workspaceId = (params.workflow.workspaceId as string) || '' + const isVersionActivation = params.operation.action === 'activate' + captureServerEvent( + params.payload.userId, + isVersionActivation ? 'deployment_version_activated' : 'workflow_deployed', + { + workflow_id: params.payload.workflowId, + workspace_id: workspaceId, + ...(isVersionActivation ? { version: params.payload.version } : {}), + }, + { + groups: workspaceId ? { workspace: workspaceId } : undefined, + ...(isVersionActivation + ? {} + : { setOnce: { first_workflow_deployed_at: new Date().toISOString() } }), + } + ) + } await params.checkpoint({ analyticsCaptured: true }) } @@ -1314,6 +1320,7 @@ function parsePrepareDeploymentV2Payload(payload: unknown): PrepareDeploymentV2P const deploymentVersionId = parseRequiredString(record.deploymentVersionId, 'deploymentVersionId') const version = parseRequiredPositiveInteger(record.version, 'version') const userId = parseRequiredString(record.userId, 'userId') + const actor = parseOptionalPrincipalActor(record.actor) const requestId = parseRequiredString(record.requestId, 'requestId') const checkpoints = parseDeploymentPreparationCheckpoints(record.checkpoints) @@ -1325,11 +1332,49 @@ function parsePrepareDeploymentV2Payload(payload: unknown): PrepareDeploymentV2P deploymentVersionId, version, userId, + ...(actor ? { actor } : {}), + ...(record.captureAnalytics === false ? { captureAnalytics: false as const } : {}), requestId, checkpoints, } } +function parseOptionalPrincipalActor(value: unknown): PrincipalActor | undefined { + if (value === undefined) return undefined + const record = parsePayloadRecord(value) + const kind = parseRequiredString(record.kind, 'actor.kind') + if (kind === 'session') { + return { kind, userId: parseRequiredString(record.userId, 'actor.userId') } + } + if (kind === 'personal_api_key') { + return { + kind, + keyId: parseRequiredString(record.keyId, 'actor.keyId'), + userId: parseRequiredString(record.userId, 'actor.userId'), + } + } + if (kind === 'workspace_api_key') { + return { + kind, + keyId: parseRequiredString(record.keyId, 'actor.keyId'), + workspaceId: parseRequiredString(record.workspaceId, 'actor.workspaceId'), + } + } + if (kind === 'delegated') { + const serviceId = parseRequiredString(record.serviceId, 'actor.serviceId') + if (serviceId !== 'copilot' && serviceId !== 'executor' && serviceId !== 'realtime') { + throw new Error(`Invalid deployment outbox actor service: ${serviceId}`) + } + return { + kind, + serviceId, + subjectUserId: parseRequiredString(record.subjectUserId, 'actor.subjectUserId'), + delegationId: parseRequiredString(record.delegationId, 'actor.delegationId'), + } + } + throw new Error(`Invalid deployment outbox actor kind: ${kind}`) +} + function parseDeploymentPreparationCheckpoints(value: unknown): DeploymentPreparationCheckpoints { if (!value || typeof value !== 'object' || Array.isArray(value)) return {} const record = value as Record<string, unknown> 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 b505539c146..155b385351d 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 @@ -692,7 +692,7 @@ export class PauseResumeManager { .limit(1) .then((rows) => rows[0]) - const resumeExecutionId = executionId + const resumeExecutionId = generateId() const now = new Date() if (activeResume) { diff --git a/apps/sim/lib/workflows/executor/resume-execution.ts b/apps/sim/lib/workflows/executor/resume-execution.ts new file mode 100644 index 00000000000..b30ef77cc55 --- /dev/null +++ b/apps/sim/lib/workflows/executor/resume-execution.ts @@ -0,0 +1,418 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { + assertBillingAttributionSnapshot, + type BillingAttributionSnapshot, +} from '@/lib/billing/core/billing-attribution' +import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' +import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' +import { toTriggerMaxDurationSeconds } from '@/lib/core/execution-limits' +import { generateRequestId } from '@/lib/core/utils/request' +import { preprocessExecution } from '@/lib/execution/preprocessing' +import { RESUME_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/enqueue-execution' +import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' +import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' +import { executeResumeJob, type ResumeExecutionPayload } from '@/background/resume-execution' +import { ExecutionSnapshot } from '@/executor/execution/snapshot' +import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection' + +const logger = createLogger('WorkflowResumeExecution') + +const INVALID_PAUSED_SNAPSHOT_ERROR = 'Paused execution snapshot is invalid' +const INVALID_PAUSED_ATTRIBUTION_ERROR = + 'Paused execution billing attribution is missing or invalid' +const PAUSED_EXECUTION_BINDING_ERROR = + 'Paused execution snapshot does not match the requested workflow or execution' +const PAUSED_ATTRIBUTION_BINDING_ERROR = + 'Paused execution billing attribution does not match its workspace or actor' + +interface PausedExecutionSnapshotSource { + workflowId: string + executionId: string + executionSnapshot: unknown +} + +interface PausedExecutionSnapshotBinding { + snapshot: ExecutionSnapshot + billingAttribution: BillingAttributionSnapshot +} + +export interface ExecuteResumeWorkflowOptions { + workflowId: string + executionId: string + contextId: string + workspaceId: string + userId: string + resumeInput: unknown + isApiCaller: boolean + pollingSurface: 'legacy' | 'v2' + allowStreaming?: boolean + requestSignal?: AbortSignal + requestHeaders?: Headers +} + +export type ResumeWorkflowExecutionResult = + | { + kind: 'queued' + executionId: string + queuePosition: number + } + | { + kind: 'stream' + executionId: string + stream: ReadableStream + } + | { + kind: 'sync' + executionId: string + success: boolean + status: string + output: unknown + error: unknown + metadata?: { + duration?: number + startTime?: string + endTime?: string + } + } + | { + kind: 'async' + executionId: string + jobId: string + } + | { + kind: 'started' + executionId: string + } + +export class ResumeWorkflowExecutionError extends Error { + constructor( + readonly statusCode: number, + message: string, + readonly safeForPublicApi: boolean + ) { + super(message) + this.name = 'ResumeWorkflowExecutionError' + } +} + +function loadPausedExecutionSnapshot( + pausedExecution: PausedExecutionSnapshotSource, + expected: { workflowId: string; executionId: string; workspaceId: string } +): PausedExecutionSnapshotBinding { + if ( + !isRecordLike(pausedExecution.executionSnapshot) || + typeof pausedExecution.executionSnapshot.snapshot !== 'string' + ) { + throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) + } + + let snapshot: ExecutionSnapshot + try { + snapshot = ExecutionSnapshot.fromJSON(pausedExecution.executionSnapshot.snapshot) + } catch { + throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) + } + + if (!isRecordLike(snapshot.metadata)) { + throw new Error(INVALID_PAUSED_SNAPSHOT_ERROR) + } + + let billingAttribution: BillingAttributionSnapshot + try { + billingAttribution = assertBillingAttributionSnapshot(snapshot.metadata.billingAttribution) + } catch { + throw new Error(INVALID_PAUSED_ATTRIBUTION_ERROR) + } + + if ( + pausedExecution.workflowId !== expected.workflowId || + pausedExecution.executionId !== expected.executionId || + snapshot.metadata.workflowId !== expected.workflowId || + snapshot.metadata.executionId !== expected.executionId + ) { + throw new Error(PAUSED_EXECUTION_BINDING_ERROR) + } + + if ( + snapshot.metadata.workspaceId !== expected.workspaceId || + billingAttribution.workspaceId !== expected.workspaceId || + snapshot.metadata.userId !== billingAttribution.actorUserId + ) { + throw new Error(PAUSED_ATTRIBUTION_BINDING_ERROR) + } + + return { snapshot, billingAttribution } +} + +/** Executes a resume transition without coupling application behavior to an HTTP response. */ +export async function executeResumeWorkflow({ + workflowId, + executionId, + contextId, + workspaceId, + userId, + resumeInput, + isApiCaller, + pollingSurface, + allowStreaming = true, + requestSignal, + requestHeaders, +}: ExecuteResumeWorkflowOptions): Promise<ResumeWorkflowExecutionResult> { + const requestId = generateRequestId() + const pausedExecution = await PauseResumeManager.getPausedExecutionDetail({ + workflowId, + executionId, + }) + if (!pausedExecution) { + throw new ResumeWorkflowExecutionError(404, 'Paused execution not found', true) + } + + let snapshotBinding: PausedExecutionSnapshotBinding + try { + snapshotBinding = loadPausedExecutionSnapshot(pausedExecution, { + workflowId, + executionId, + workspaceId, + }) + } catch (error) { + const message = toError(error).message + logger.error(`[${requestId}] Failed to validate paused execution snapshot`, { + workflowId, + executionId, + error: message, + }) + throw new ResumeWorkflowExecutionError(500, message, false) + } + + const { snapshot: persistedSnapshot, billingAttribution } = snapshotBinding + const resumeExecutionId = generateId() + + logger.info(`[${requestId}] Preprocessing resume execution`, { + workflowId, + parentExecutionId: executionId, + resumeExecutionId, + userId, + actorUserId: billingAttribution.actorUserId, + }) + + const preprocessResult = await preprocessExecution({ + workflowId, + userId, + triggerType: 'manual', + executionId: resumeExecutionId, + requestId, + checkRateLimit: false, + checkDeployment: false, + skipConcurrencyReservation: true, + logPreprocessingErrors: false, + workspaceId, + billingAttribution, + }) + + if (!preprocessResult.success) { + const statusCode = preprocessResult.error?.statusCode || 400 + const message = + preprocessResult.error?.message || 'Failed to validate resume execution. Please try again.' + logger.warn(`[${requestId}] Preprocessing failed for resume`, { + workflowId, + parentExecutionId: executionId, + error: message, + statusCode, + }) + throw new ResumeWorkflowExecutionError(statusCode, message, statusCode < 500) + } + + logger.info(`[${requestId}] Preprocessing passed, proceeding with resume`, { + workflowId, + parentExecutionId: executionId, + resumeExecutionId, + actorUserId: preprocessResult.actorUserId, + }) + + try { + const enqueueResult = await PauseResumeManager.enqueueOrStartResume({ + executionId, + workflowId, + contextId, + resumeInput, + userId, + allowedPauseKinds: ['human'], + }) + + if (enqueueResult.status === 'queued') { + return { + kind: 'queued', + executionId: enqueueResult.resumeExecutionId, + queuePosition: enqueueResult.queuePosition, + } + } + + const resumeArgs = { + resumeEntryId: enqueueResult.resumeEntryId, + resumeExecutionId: enqueueResult.resumeExecutionId, + pausedExecution: enqueueResult.pausedExecution, + contextId: enqueueResult.contextId, + resumeInput: enqueueResult.resumeInput, + userId: enqueueResult.userId, + } + + const persistedExecutionMode = persistedSnapshot.metadata.executionMode ?? 'sync' + const executionMode = isApiCaller + ? persistedExecutionMode === 'stream' && !allowStreaming + ? 'async' + : persistedExecutionMode + : undefined + + if (isApiCaller && executionMode === 'stream') { + if (!requestSignal || !requestHeaders) { + throw new Error('Streaming resume execution requires request signal and headers') + } + const stream = await createStreamingResponse({ + requestId, + streamConfig: { + selectedOutputs: persistedSnapshot.selectedOutputs, + timeoutMs: preprocessResult.executionTimeout?.sync, + includeThinking: persistedSnapshot.metadata.includeThinking === true, + includeToolCalls: persistedSnapshot.metadata.includeToolCalls === true, + }, + executionId: enqueueResult.resumeExecutionId, + workspaceId, + workflowId, + userId: enqueueResult.userId, + allowLargeValueWorkflowScope: true, + requestSignal, + requestHeaders, + executeFn: async ({ onStream, onBlockComplete, abortSignal }) => + PauseResumeManager.startResumeExecution({ + ...resumeArgs, + onStream, + onBlockComplete, + abortSignal, + }), + }) + return { kind: 'stream', executionId: enqueueResult.resumeExecutionId, stream } + } + + if (isApiCaller && executionMode === 'sync') { + const result = await PauseResumeManager.startResumeExecution(resumeArgs) + return { + kind: 'sync', + executionId: enqueueResult.resumeExecutionId, + success: result.success, + status: result.status ?? (result.success ? 'completed' : 'failed'), + output: result.output, + error: result.error, + metadata: result.metadata + ? { + duration: result.metadata.duration, + startTime: result.metadata.startTime, + endTime: result.metadata.endTime, + } + : undefined, + } + } + + if (isApiCaller && executionMode === 'async') { + const correlation: AsyncExecutionCorrelation = { + executionId, + requestId, + source: 'workflow', + workflowId, + triggerType: 'resume', + } + const resumePayload: ResumeExecutionPayload = { + resumeEntryId: enqueueResult.resumeEntryId, + resumeExecutionId: enqueueResult.resumeExecutionId, + pausedExecutionId: enqueueResult.pausedExecution.id, + contextId: enqueueResult.contextId, + resumeInput: enqueueResult.resumeInput, + userId: enqueueResult.userId, + workflowId, + parentExecutionId: executionId, + executionTimeoutMs: preprocessResult.executionTimeout.async, + billingAttribution: preprocessResult.billingAttribution, + } + + let jobId: string + try { + const jobQueue = await getJobQueue() + const executeInline = shouldExecuteInline() + jobId = await jobQueue.enqueue('resume-execution', resumePayload, { + ...(pollingSurface === 'v2' + ? { jobId: `${RESUME_EXECUTION_JOB_ID_PREFIX}${enqueueResult.resumeEntryId}` } + : {}), + metadata: { + executionId, + workflowId, + workspaceId, + userId, + resumeExecutionId: enqueueResult.resumeExecutionId, + correlation, + }, + maxDurationSeconds: toTriggerMaxDurationSeconds(preprocessResult.executionTimeout.async), + ...(executeInline + ? { + runner: (_queuedPayload: unknown, signal: AbortSignal) => + executeResumeJob(resumePayload, signal), + } + : {}), + }) + logger.info('Enqueued async resume execution', { + jobId, + resumeExecutionId: enqueueResult.resumeExecutionId, + }) + } catch (error) { + logger.error('Failed to dispatch async resume execution', { + error: toError(error).message, + resumeExecutionId: enqueueResult.resumeExecutionId, + }) + await PauseResumeManager.markResumeAttemptFailed({ + resumeEntryId: enqueueResult.resumeEntryId, + pausedExecutionId: enqueueResult.pausedExecution.id, + parentExecutionId: executionId, + contextId: enqueueResult.contextId, + failureReason: 'Failed to queue async resume execution', + }) + await PauseResumeManager.processQueuedResumes(executionId, workflowId) + throw new ResumeWorkflowExecutionError( + 503, + 'Failed to queue resume execution. Please try again.', + true + ) + } + + return { kind: 'async', executionId: enqueueResult.resumeExecutionId, jobId } + } + + PauseResumeManager.startResumeExecution(resumeArgs).catch((error) => { + logger.error( + 'Failed to start resume execution', + projectResolvedSecretDiagnosticError(error, undefined, { + workflowId, + parentExecutionId: executionId, + resumeExecutionId: enqueueResult.resumeExecutionId, + }) + ) + }) + return { kind: 'started', executionId: enqueueResult.resumeExecutionId } + } catch (error) { + if (error instanceof ResumeWorkflowExecutionError) throw error + logger.error( + 'Resume request failed', + projectResolvedSecretDiagnosticError(error, undefined, { + workflowId, + executionId, + contextId, + }) + ) + const statusCode = + isRecordLike(error) && typeof error.statusCode === 'number' ? error.statusCode : undefined + if (statusCode !== undefined) { + throw new ResumeWorkflowExecutionError(statusCode, toError(error).message, statusCode < 500) + } + throw error + } +} diff --git a/apps/sim/lib/workflows/operations/import-workflow.ts b/apps/sim/lib/workflows/operations/import-workflow.ts index 53dede6d709..5103b939c9a 100644 --- a/apps/sim/lib/workflows/operations/import-workflow.ts +++ b/apps/sim/lib/workflows/operations/import-workflow.ts @@ -17,7 +17,12 @@ import { import { workflowStateSchema } from '@/lib/api/contracts/workflows' import { serializeZodIssues } from '@/lib/api/server' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' -import { performCreateWorkflow } from '@/lib/workflows/orchestration' +import { + type PerformCreateWorkflowParams, + type PerformCreateWorkflowResult, + performCreateWorkflow, + performCreateWorkflowTransition, +} from '@/lib/workflows/orchestration' import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -64,6 +69,7 @@ export interface ImportedWorkflow { description: string | null workspaceId: string folderId: string | null + sortOrder: number createdAt: Date updatedAt: Date } @@ -166,8 +172,9 @@ function resolveImportedMetadata( * `workspaceId`; this performs only resource-level checks (workspace exists, * folder ownership/lock state). */ -export async function importWorkflowIntoWorkspace( - params: ImportWorkflowParams +async function executeImportWorkflowIntoWorkspace( + params: ImportWorkflowParams, + createWorkflow: (params: PerformCreateWorkflowParams) => Promise<PerformCreateWorkflowResult> ): Promise<ImportWorkflowResult> { const { workspaceId, folderId, userId, requestId } = params @@ -264,7 +271,7 @@ export async function importWorkflowIntoWorkspace( params.description ) - const created = await performCreateWorkflow({ + const created = await createWorkflow({ name, description, workspaceId, @@ -362,8 +369,23 @@ export async function importWorkflowIntoWorkspace( description: created.workflow.description ?? null, workspaceId, folderId: created.workflow.folderId ?? null, + sortOrder: created.workflow.sortOrder, createdAt: created.workflow.createdAt, updatedAt: created.workflow.updatedAt, }, } } + +/** Existing transport behavior, including its legacy workflow-created audit. */ +export async function importWorkflowIntoWorkspace( + params: ImportWorkflowParams +): Promise<ImportWorkflowResult> { + return executeImportWorkflowIntoWorkspace(params, performCreateWorkflow) +} + +/** Authoritative import transition without route- or service-local audit projection. */ +export async function importWorkflowIntoWorkspaceTransition( + params: ImportWorkflowParams +): Promise<ImportWorkflowResult> { + return executeImportWorkflowIntoWorkspace(params, performCreateWorkflowTransition) +} diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 5a4f5e51e17..1476840245d 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import type { PrincipalActor } from '@sim/auth/principal' import { db, workflowDeploymentVersion, workflow as workflowTable } from '@sim/db' import { createLogger } from '@sim/logger' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' @@ -101,6 +102,8 @@ export interface PerformFullDeployParams { * Defaults to `userId`. Use `'admin-api'` for admin-initiated actions. */ actorId?: string + actor?: PrincipalActor + captureAnalytics?: false } /** @@ -222,6 +225,8 @@ async function performStableFullDeploy(params: { deploymentVersionId: operation.deploymentVersionId, version: operation.version, userId: params.params.userId, + actor: params.params.actor, + captureAnalytics: params.params.captureAnalytics, requestId: params.requestId, checkpoints: {}, }) @@ -497,6 +502,7 @@ export interface PerformFullUndeployParams { requestId?: string /** Override the actor ID used in audit logs. Defaults to `userId`. */ actorId?: string + projectLegacyAudit?: boolean } export interface PerformFullUndeployResult { @@ -558,15 +564,17 @@ export async function performFullUndeploy( // Telemetry is best-effort } - recordAudit({ - workspaceId: (workflowData.workspaceId as string) || null, - actorId: actorId, - action: AuditAction.WORKFLOW_UNDEPLOYED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - resourceName: (workflowData.name as string) || undefined, - description: `Undeployed workflow "${(workflowData.name as string) || workflowId}"`, - }) + if (params.projectLegacyAudit !== false) { + recordAudit({ + workspaceId: (workflowData.workspaceId as string) || null, + actorId: actorId, + action: AuditAction.WORKFLOW_UNDEPLOYED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + resourceName: (workflowData.name as string) || undefined, + description: `Undeployed workflow "${(workflowData.name as string) || workflowId}"`, + }) + } await notifySocketDeploymentChanged(workflowId) const sideEffectWarning = await processDeploymentSideEffectsNow(outboxEventId, requestId) @@ -593,6 +601,8 @@ export interface PerformActivateVersionParams { requestId?: string /** Override the actor ID used in audit logs. Defaults to `userId`. */ actorId?: string + actor?: PrincipalActor + captureAnalytics?: false } export interface PerformActivateVersionResult { @@ -709,6 +719,8 @@ export async function performActivateVersion( version, userId, actorId, + actor: params.actor, + captureAnalytics: params.captureAnalytics, requestId, idempotencyKey, }) @@ -732,6 +744,8 @@ async function performStableVersionActivation(params: { version: number userId: string actorId: string + actor?: PrincipalActor + captureAnalytics?: false requestId: string idempotencyKey: string }): Promise<PerformActivateVersionResult> { @@ -762,6 +776,8 @@ async function performStableVersionActivation(params: { deploymentVersionId: operation.deploymentVersionId, version: operation.version, userId: params.userId, + actor: params.actor, + captureAnalytics: params.captureAnalytics, requestId: params.requestId, checkpoints: {}, }) diff --git a/apps/sim/lib/workflows/orchestration/index.ts b/apps/sim/lib/workflows/orchestration/index.ts index dc8d99a1d9f..7b7485d397f 100644 --- a/apps/sim/lib/workflows/orchestration/index.ts +++ b/apps/sim/lib/workflows/orchestration/index.ts @@ -10,8 +10,13 @@ export { performRevertToVersion, } from './deploy' export { + deleteWorkflowRecord, + type PerformCreateWorkflowParams, + type PerformCreateWorkflowResult, performCreateWorkflow, + performCreateWorkflowTransition, performDeleteWorkflow, performRestoreWorkflow, performUpdateWorkflow, + updateWorkflowRecord, } from './workflow-lifecycle' diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 07588af4460..e8c5d0d1ea3 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -98,6 +98,12 @@ export interface PerformDeleteWorkflowResult { success: boolean error?: string errorCode?: OrchestrationErrorCode + archived?: boolean + workflow?: { + id: string + name: string + workspaceId: string | null + } } export interface PerformRestoreWorkflowParams { @@ -188,170 +194,196 @@ async function workflowNameExistsInFolder(params: { return Boolean(duplicateWorkflow) } -export async function performCreateWorkflow( +export async function performCreateWorkflowTransition( params: PerformCreateWorkflowParams ): Promise<PerformCreateWorkflowResult> { const requestId = params.requestId ?? generateRequestId() const workflowId = params.id || generateId() const folderId = params.folderId || null - try { - if (!(await isFolderInWorkspace(folderId, params.workspaceId))) { - return { success: false, error: 'Target folder not found', errorCode: 'validation' } - } + if (!(await isFolderInWorkspace(folderId, params.workspaceId))) { + return { success: false, error: 'Target folder not found', errorCode: 'validation' } + } - const name = params.deduplicate - ? await deduplicateWorkflowName(params.name, params.workspaceId, folderId) - : params.name + const name = params.deduplicate + ? await deduplicateWorkflowName(params.name, params.workspaceId, folderId) + : params.name - if (!params.deduplicate) { - const duplicate = await workflowNameExistsInFolder({ - workspaceId: params.workspaceId, - name, - folderId, - }) - if (duplicate) { - return { - success: false, - error: `A workflow named "${name}" already exists in this folder`, - errorCode: 'conflict', - } + if (!params.deduplicate) { + const duplicate = await workflowNameExistsInFolder({ + workspaceId: params.workspaceId, + name, + folderId, + }) + if (duplicate) { + return { + success: false, + error: `A workflow named "${name}" already exists in this folder`, + errorCode: 'conflict', } } + } - const sortOrder = - params.sortOrder !== undefined - ? params.sortOrder - : await nextWorkflowSortOrder(params.workspaceId, folderId) - 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: {}, - }) - - await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + const sortOrder = + params.sortOrder !== undefined + ? params.sortOrder + : await nextWorkflowSortOrder(params.workspaceId, folderId) + 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: {}, }) - logger.info(`[${requestId}] Successfully created workflow ${workflowId}`) + await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + }) - recordAudit({ + logger.info(`[${requestId}] Successfully created workflow ${workflowId}`) + + return { + success: true, + workflow: { + id: workflowId, + name, + description: params.description, workspaceId: params.workspaceId, - actorId: params.userId, - action: AuditAction.WORKFLOW_CREATED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - resourceName: name, - description: `Created workflow "${name}"`, - metadata: { - name, - description: params.description || undefined, - workspaceId: params.workspaceId, - folderId: folderId || undefined, - sortOrder, - }, - }) + folderId, + sortOrder, + createdAt: now, + updatedAt: now, + startBlockId, + subBlockValues, + }, + } +} - return { - success: true, - workflow: { - id: workflowId, - name, - description: params.description, +export async function performCreateWorkflow( + params: PerformCreateWorkflowParams +): Promise<PerformCreateWorkflowResult> { + const requestId = params.requestId ?? generateRequestId() + try { + const result = await performCreateWorkflowTransition({ ...params, requestId }) + if (result.success && result.workflow) { + recordAudit({ workspaceId: params.workspaceId, - folderId, - sortOrder, - createdAt: now, - updatedAt: now, - startBlockId, - subBlockValues, - }, + actorId: params.userId, + action: AuditAction.WORKFLOW_CREATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.workflow.id, + resourceName: result.workflow.name, + description: `Created workflow "${result.workflow.name}"`, + metadata: { + name: result.workflow.name, + description: params.description || undefined, + workspaceId: params.workspaceId, + folderId: result.workflow.folderId || undefined, + sortOrder: result.workflow.sortOrder, + }, + }) } + return result } catch (error) { logger.error(`[${requestId}] Failed to create workflow`, { error }) return { success: false, error: toError(error).message, errorCode: 'internal' } } } -export async function performUpdateWorkflow( +export async function updateWorkflowRecord( params: PerformUpdateWorkflowParams ): Promise<PerformUpdateWorkflowResult> { const requestId = params.requestId ?? generateRequestId() + const targetName = params.name ?? params.currentName + const targetFolderId = + params.folderId !== undefined ? params.folderId || null : params.currentFolderId || null + + if ( + params.folderId !== undefined && + !(await isFolderInWorkspace(targetFolderId, params.workspaceId)) + ) { + return { success: false, error: 'Target folder not found', errorCode: 'validation' } + } - try { - const targetName = params.name ?? params.currentName - const targetFolderId = - params.folderId !== undefined ? params.folderId || null : params.currentFolderId || null - - if ( - params.folderId !== undefined && - !(await isFolderInWorkspace(targetFolderId, params.workspaceId)) - ) { - return { success: false, error: 'Target folder not found', errorCode: 'validation' } - } - - if (params.name !== undefined || params.folderId !== undefined) { - const duplicate = await workflowNameExistsInFolder({ - workspaceId: params.workspaceId, - name: targetName, - folderId: targetFolderId, - excludeWorkflowId: params.workflowId, - }) - if (duplicate) { - return { - success: false, - error: `A workflow named "${targetName}" already exists in this folder`, - errorCode: 'conflict', - } + if (params.name !== undefined || params.folderId !== undefined) { + const duplicate = await workflowNameExistsInFolder({ + workspaceId: params.workspaceId, + name: targetName, + folderId: targetFolderId, + excludeWorkflowId: params.workflowId, + }) + if (duplicate) { + return { + success: false, + error: `A workflow named "${targetName}" already exists in this folder`, + errorCode: 'conflict', } } + } - const updateData: Record<string, unknown> = { updatedAt: new Date() } - if (params.name !== undefined) updateData.name = params.name - if (params.description !== undefined) updateData.description = params.description - if (params.folderId !== undefined) updateData.folderId = params.folderId - if (params.sortOrder !== undefined) updateData.sortOrder = params.sortOrder - if (params.locked !== undefined) updateData.locked = params.locked - if (params.forkSyncExcluded !== undefined) updateData.forkSyncExcluded = params.forkSyncExcluded - - const [updatedWorkflow] = await db - .update(workflow) - .set(updateData) - .where(eq(workflow.id, params.workflowId)) - .returning({ - id: workflow.id, - name: workflow.name, - description: workflow.description, - workspaceId: workflow.workspaceId, - folderId: workflow.folderId, - sortOrder: workflow.sortOrder, - locked: workflow.locked, - forkSyncExcluded: workflow.forkSyncExcluded, - createdAt: workflow.createdAt, - updatedAt: workflow.updatedAt, - archivedAt: workflow.archivedAt, - }) + const updateData: Record<string, unknown> = { updatedAt: new Date() } + if (params.name !== undefined) updateData.name = params.name + if (params.description !== undefined) updateData.description = params.description + if (params.folderId !== undefined) updateData.folderId = params.folderId + if (params.sortOrder !== undefined) updateData.sortOrder = params.sortOrder + if (params.locked !== undefined) updateData.locked = params.locked + if (params.forkSyncExcluded !== undefined) updateData.forkSyncExcluded = params.forkSyncExcluded + + const [updatedWorkflow] = await db + .update(workflow) + .set(updateData) + .where( + and( + eq(workflow.id, params.workflowId), + eq(workflow.workspaceId, params.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .returning({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + workspaceId: workflow.workspaceId, + folderId: workflow.folderId, + sortOrder: workflow.sortOrder, + locked: workflow.locked, + forkSyncExcluded: workflow.forkSyncExcluded, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + archivedAt: workflow.archivedAt, + }) - if (!updatedWorkflow) { - return { success: false, error: 'Workflow not found', errorCode: 'not_found' } - } + if (!updatedWorkflow) { + return { success: false, error: 'Workflow not found', errorCode: 'not_found' } + } - logger.info(`[${requestId}] Successfully updated workflow ${params.workflowId}`, { - updates: updateData, - }) + logger.info(`[${requestId}] Successfully updated workflow ${params.workflowId}`, { + updates: updateData, + }) + + return { success: true, workflow: updatedWorkflow } +} + +export async function performUpdateWorkflow( + params: PerformUpdateWorkflowParams +): Promise<PerformUpdateWorkflowResult> { + const requestId = params.requestId ?? generateRequestId() + + try { + const result = await updateWorkflowRecord({ ...params, requestId }) + const updatedWorkflow = result.workflow + if (!result.success || !updatedWorkflow) return result if (params.locked !== undefined && params.locked !== (params.currentLocked ?? false)) { const workspaceId = updatedWorkflow.workspaceId @@ -408,24 +440,17 @@ export async function performUpdateWorkflow( ) } - return { success: true, workflow: updatedWorkflow } + return result } catch (error) { logger.error(`[${requestId}] Failed to update workflow ${params.workflowId}`, { error }) return { success: false, error: toError(error).message, errorCode: 'internal' } } } -/** - * Performs a full workflow deletion: enforces the last-workflow guard, - * archives the workflow via `archiveWorkflow`, and records an audit entry. - * Both the workflow API DELETE handler and the copilot delete_workflow tool - * must use this function. - */ -export async function performDeleteWorkflow( +export async function deleteWorkflowRecord( params: PerformDeleteWorkflowParams ): Promise<PerformDeleteWorkflowResult> { - const { workflowId, userId, skipLastWorkflowGuard = false } = params - const actorId = params.actorId ?? userId + const { workflowId, skipLastWorkflowGuard = false } = params const requestId = params.requestId ?? generateRequestId() const [workflowRecord] = await db @@ -459,21 +484,43 @@ export async function performDeleteWorkflow( } logger.info(`[${requestId}] Successfully archived workflow ${workflowId}`) + return { + success: true, + archived: archiveResult.archived, + workflow: { + id: archiveResult.workflow.id, + name: archiveResult.workflow.name, + workspaceId: archiveResult.workflow.workspaceId, + }, + } +} + +/** + * Performs a full workflow deletion: enforces the last-workflow guard, + * archives the workflow via `archiveWorkflow`, and records an audit entry. + * Both the workflow API DELETE handler and the copilot delete_workflow tool + * must use this function. + */ +export async function performDeleteWorkflow( + params: PerformDeleteWorkflowParams +): Promise<PerformDeleteWorkflowResult> { + const { workflowId, userId } = params + const actorId = params.actorId ?? userId + const result = await deleteWorkflowRecord(params) + if (!result.success || !result.archived || !result.workflow) return result recordAudit({ - workspaceId: workflowRecord.workspaceId || null, - actorId: actorId, + workspaceId: result.workflow.workspaceId || null, + actorId, action: AuditAction.WORKFLOW_DELETED, resourceType: AuditResourceType.WORKFLOW, resourceId: workflowId, - resourceName: workflowRecord.name, - description: `Archived workflow "${workflowRecord.name}"`, - metadata: { - archived: archiveResult.archived, - }, + resourceName: result.workflow.name, + description: `Archived workflow "${result.workflow.name}"`, + metadata: { archived: true }, }) - return { success: true } + return result } export async function performRestoreWorkflow( From d7ccbecb8ac1ec3bdd53b667c468306794bf3163 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 8 Aug 2026 11:26:59 -0700 Subject: [PATCH 100/159] refactor(api): migrate v2 knowledge operations (#6413) * refactor(api): migrate v2 knowledge operations * fix(knowledge): fail upload completion on dispatch errors * fix(knowledge): preserve upload retry and VFS errors --- .../uploads/[uploadId]/complete/route.ts | 83 +-- .../uploads/[uploadId]/parts/route.ts | 35 +- .../documents/uploads/[uploadId]/route.ts | 34 +- .../documents/uploads/control-routes.test.ts | 182 +++++ .../[id]/documents/uploads/route.test.ts | 151 ++-- .../knowledge/[id]/documents/uploads/route.ts | 48 +- .../[id]/documents/uploads/utils.test.ts | 36 + .../knowledge/[id]/documents/uploads/utils.ts | 60 +- .../[id]/documents/[documentId]/route.ts | 191 +++-- .../v2/knowledge/[id]/documents/route.test.ts | 218 ++++++ .../api/v2/knowledge/[id]/documents/route.ts | 337 +++++---- .../uploads/[uploadId]/complete/route.test.ts | 260 +++---- .../uploads/[uploadId]/complete/route.ts | 97 ++- .../uploads/[uploadId]/parts/route.ts | 60 +- .../documents/uploads/[uploadId]/route.ts | 53 +- .../documents/uploads/control-routes.test.ts | 163 ++++ .../[id]/documents/uploads/route.test.ts | 233 +++--- .../knowledge/[id]/documents/uploads/route.ts | 82 +- .../[id]/documents/uploads/utils.test.ts | 254 ------- .../knowledge/[id]/documents/uploads/utils.ts | 239 +----- apps/sim/app/api/v2/knowledge/[id]/route.ts | 222 +++--- .../sim/app/api/v2/knowledge/folders/route.ts | 175 ++--- apps/sim/app/api/v2/knowledge/route.test.ts | 223 +++--- apps/sim/app/api/v2/knowledge/route.ts | 185 +++-- .../app/api/v2/knowledge/search/route.test.ts | 145 ++++ apps/sim/app/api/v2/knowledge/search/route.ts | 319 ++------ .../contracts/knowledge/upload-sessions.ts | 1 + .../contracts/v2/__tests__/knowledge.test.ts | 42 ++ apps/sim/lib/api/contracts/v2/knowledge.ts | 5 +- .../application/execute-knowledge-use-case.ts | 65 ++ .../copilot/tools/handlers/resources.test.ts | 68 +- .../lib/copilot/tools/handlers/resources.ts | 29 +- .../copilot/tools/handlers/vfs-mutate.test.ts | 137 +++- .../lib/copilot/tools/handlers/vfs-mutate.ts | 114 ++- .../lib/copilot/tools/handlers/vfs.test.ts | 68 +- apps/sim/lib/copilot/tools/handlers/vfs.ts | 3 + .../server/knowledge/knowledge-base.test.ts | 703 ++++++++---------- .../tools/server/knowledge/knowledge-base.ts | 515 +++++++------ .../knowledge/search-knowledge-base.test.ts | 55 ++ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 210 +++--- apps/sim/lib/folders/cascade.test.ts | 22 +- apps/sim/lib/folders/cascade.ts | 9 +- apps/sim/lib/folders/orchestration.ts | 99 ++- apps/sim/lib/folders/queries.test.ts | 22 + apps/sim/lib/folders/queries.ts | 17 +- .../application/authorization.test.ts | 55 ++ .../knowledge/application/authorization.ts | 27 + .../authorized-knowledge-use-case.ts | 28 + apps/sim/lib/knowledge/application/billing.ts | 37 + .../sim/lib/knowledge/application/contexts.ts | 83 +++ .../application/delegated-principal.ts | 33 + .../knowledge/application/documents.test.ts | 251 +++++++ .../lib/knowledge/application/documents.ts | 246 ++++++ .../lib/knowledge/application/folder-paths.ts | 33 + .../lib/knowledge/application/folders.test.ts | 195 +++++ apps/sim/lib/knowledge/application/folders.ts | 209 ++++++ .../application/knowledge-bases.test.ts | 209 ++++++ .../knowledge/application/knowledge-bases.ts | 268 +++++++ .../knowledge/application/operations.test.ts | 50 ++ .../lib/knowledge/application/operations.ts | 123 +++ .../lib/knowledge/application/search.test.ts | 272 +++++++ apps/sim/lib/knowledge/application/search.ts | 278 +++++++ .../application/upload-sessions.test.ts | 620 +++++++++++++++ .../knowledge/application/upload-sessions.ts | 471 ++++++++++++ apps/sim/lib/knowledge/constants.ts | 6 + apps/sim/lib/knowledge/documents/service.ts | 122 ++- apps/sim/lib/knowledge/service.test.ts | 27 +- apps/sim/lib/knowledge/service.ts | 222 +++++- .../uploads/upload-session/service.test.ts | 162 +++- .../sim/lib/uploads/upload-session/service.ts | 53 +- 70 files changed, 7404 insertions(+), 2945 deletions(-) create mode 100644 apps/sim/app/api/knowledge/[id]/documents/uploads/control-routes.test.ts create mode 100644 apps/sim/app/api/knowledge/[id]/documents/uploads/utils.test.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/uploads/control-routes.test.ts delete mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts create mode 100644 apps/sim/app/api/v2/knowledge/search/route.test.ts create mode 100644 apps/sim/lib/api/contracts/v2/__tests__/knowledge.test.ts create mode 100644 apps/sim/lib/copilot/application/execute-knowledge-use-case.ts create mode 100644 apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.test.ts create mode 100644 apps/sim/lib/knowledge/application/authorization.test.ts create mode 100644 apps/sim/lib/knowledge/application/authorization.ts create mode 100644 apps/sim/lib/knowledge/application/authorized-knowledge-use-case.ts create mode 100644 apps/sim/lib/knowledge/application/billing.ts create mode 100644 apps/sim/lib/knowledge/application/contexts.ts create mode 100644 apps/sim/lib/knowledge/application/delegated-principal.ts create mode 100644 apps/sim/lib/knowledge/application/documents.test.ts create mode 100644 apps/sim/lib/knowledge/application/documents.ts create mode 100644 apps/sim/lib/knowledge/application/folder-paths.ts create mode 100644 apps/sim/lib/knowledge/application/folders.test.ts create mode 100644 apps/sim/lib/knowledge/application/folders.ts create mode 100644 apps/sim/lib/knowledge/application/knowledge-bases.test.ts create mode 100644 apps/sim/lib/knowledge/application/knowledge-bases.ts create mode 100644 apps/sim/lib/knowledge/application/operations.test.ts create mode 100644 apps/sim/lib/knowledge/application/operations.ts create mode 100644 apps/sim/lib/knowledge/application/search.test.ts create mode 100644 apps/sim/lib/knowledge/application/search.ts create mode 100644 apps/sim/lib/knowledge/application/upload-sessions.test.ts create mode 100644 apps/sim/lib/knowledge/application/upload-sessions.ts diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts index 0c9b4be4b23..c426602e85c 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts @@ -1,20 +1,15 @@ import { type NextRequest, NextResponse } from 'next/server' import { completeKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions' import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' +import { PlatformEvents } from '@/lib/core/telemetry' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { completeUploadSession } from '@/lib/uploads/upload-session/service' -import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' +import { completeKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' +import { captureServerEvent } from '@/lib/posthog/server' import { - requireKnowledgeDocumentUploadAccess, + knowledgeDocumentUploadErrorResponse, requireKnowledgeDocumentUploadActor, - resolveKnowledgeDocumentUploadAttribution, } from '@/app/api/knowledge/[id]/documents/uploads/utils' -import { - finalizeKnowledgeDocumentUpload, - getOwnedKnowledgeDocumentUpload, - toV2KnowledgeDocumentUpload, -} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' interface KnowledgeDocumentUploadRouteParams { params: Promise<{ id: string; uploadId: string }> @@ -28,44 +23,46 @@ export const POST = withRouteHandler( if (!parsed.success) return parsed.response const { id: knowledgeBaseId, uploadId } = parsed.data.params const { workspaceId } = parsed.data.query - const access = await requireKnowledgeDocumentUploadAccess({ - knowledgeBaseId, - workspaceId, - userId: actor.id, - }) - if (access instanceof NextResponse) return access - const requestId = generateRequestId() try { - const upload = await getOwnedKnowledgeDocumentUpload({ - knowledgeBaseId, - uploadId, - workspaceId, - userId: actor.id, - uploadToken: parsed.data.headers['upload-token'], - }) - const completed = await completeUploadSession({ - session: upload, - finalize: (claimed) => - finalizeKnowledgeDocumentUpload({ - claimed, - knowledgeBaseId, - knowledgeBaseName: access.knowledgeBase.name, - workspaceId, - userId: actor.id, - resolveAttribution: () => - resolveKnowledgeDocumentUploadAttribution({ workspaceId, userId: actor.id }), - source: 'ui', - requestId, - request, - actorName: actor.name, - actorEmail: actor.email, - }), + const completed = await completeKnowledgeDocumentUpload.execute({ + principal: { kind: 'session', userId: actor.id, sessionId: actor.sessionId }, + input: { + knowledgeBaseId, + assertedWorkspaceId: workspaceId, + uploadId, + uploadToken: parsed.data.headers['upload-token'], + source: 'ui', + }, + request, }) + if (completed.value.created) { + captureServerEvent( + actor.id, + 'knowledge_base_document_uploaded', + { + knowledge_base_id: completed.knowledgeBaseId, + workspace_id: completed.workspaceId, + document_count: 1, + upload_type: 'single', + }, + { + groups: { workspace: completed.workspaceId }, + setOnce: { first_document_uploaded_at: new Date().toISOString() }, + } + ) + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId: completed.knowledgeBaseId, + documentsCount: 1, + uploadType: 'single', + mimeType: completed.value.document.mimeType, + fileSize: completed.value.document.fileSize, + }) + } return NextResponse.json({ - data: toV2KnowledgeDocumentUpload(completed.session, completed.value), + data: toV2KnowledgeDocumentUpload(completed.session, completed.value.document), }) } catch (error) { - const classified = uploadSessionErrorResponse(error) + const classified = knowledgeDocumentUploadErrorResponse(error) if (classified) return classified throw error } diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts index da327ab4703..2ae1d2bc0e7 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts @@ -2,13 +2,11 @@ import { type NextRequest, NextResponse } from 'next/server' import { createKnowledgeDocumentUploadPartUrlsContract } from '@/lib/api/contracts/knowledge/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createUploadPartUrls } from '@/lib/uploads/upload-session/service' -import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' +import { issueKnowledgeDocumentUploadParts } from '@/lib/knowledge/application/upload-sessions' import { - requireKnowledgeDocumentUploadAccess, + knowledgeDocumentUploadErrorResponse, requireKnowledgeDocumentUploadActor, } from '@/app/api/knowledge/[id]/documents/uploads/utils' -import { getOwnedKnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' interface KnowledgeDocumentUploadRouteParams { params: Promise<{ id: string; uploadId: string }> @@ -26,28 +24,21 @@ export const POST = withRouteHandler( if (!parsed.success) return parsed.response const { id: knowledgeBaseId, uploadId } = parsed.data.params const { workspaceId } = parsed.data.query - const access = await requireKnowledgeDocumentUploadAccess({ - knowledgeBaseId, - workspaceId, - userId: actor.id, - }) - if (access instanceof NextResponse) return access try { - const upload = await getOwnedKnowledgeDocumentUpload({ - knowledgeBaseId, - uploadId, - workspaceId, - userId: actor.id, - uploadToken: parsed.data.headers['upload-token'], - }) - const parts = await createUploadPartUrls({ - session: upload, - partNumbers: parsed.data.body.partNumbers, - localOrigin: request.nextUrl.origin, + const { parts } = await issueKnowledgeDocumentUploadParts.execute({ + principal: { kind: 'session', userId: actor.id, sessionId: actor.sessionId }, + input: { + knowledgeBaseId, + assertedWorkspaceId: workspaceId, + uploadId, + uploadToken: parsed.data.headers['upload-token'], + partNumbers: parsed.data.body.partNumbers, + }, + request, }) return NextResponse.json({ data: { parts } }) } catch (error) { - const classified = uploadSessionErrorResponse(error) + const classified = knowledgeDocumentUploadErrorResponse(error) if (classified) return classified throw error } diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts index 6a44d82d895..4f9f0d2c5b1 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts @@ -2,16 +2,12 @@ import { type NextRequest, NextResponse } from 'next/server' import { abortKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' +import { cancelKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' import { - requireKnowledgeDocumentUploadAccess, + knowledgeDocumentUploadErrorResponse, requireKnowledgeDocumentUploadActor, } from '@/app/api/knowledge/[id]/documents/uploads/utils' -import { - abortKnowledgeDocumentUpload, - getOwnedKnowledgeDocumentUpload, - toV2KnowledgeDocumentUpload, -} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' interface KnowledgeDocumentUploadRouteParams { params: Promise<{ id: string; uploadId: string }> @@ -25,24 +21,20 @@ export const DELETE = withRouteHandler( if (!parsed.success) return parsed.response const { id: knowledgeBaseId, uploadId } = parsed.data.params const { workspaceId } = parsed.data.query - const access = await requireKnowledgeDocumentUploadAccess({ - knowledgeBaseId, - workspaceId, - userId: actor.id, - }) - if (access instanceof NextResponse) return access try { - const upload = await getOwnedKnowledgeDocumentUpload({ - knowledgeBaseId, - uploadId, - workspaceId, - userId: actor.id, - uploadToken: parsed.data.headers['upload-token'], + const aborted = await cancelKnowledgeDocumentUpload.execute({ + principal: { kind: 'session', userId: actor.id, sessionId: actor.sessionId }, + input: { + knowledgeBaseId, + assertedWorkspaceId: workspaceId, + uploadId, + uploadToken: parsed.data.headers['upload-token'], + }, + request, }) - const aborted = await abortKnowledgeDocumentUpload(upload, knowledgeBaseId) return NextResponse.json({ data: toV2KnowledgeDocumentUpload(aborted, null) }) } catch (error) { - const classified = uploadSessionErrorResponse(error) + const classified = knowledgeDocumentUploadErrorResponse(error) if (classified) return classified throw error } diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/control-routes.test.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/control-routes.test.ts new file mode 100644 index 00000000000..8a4b043cfff --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/control-routes.test.ts @@ -0,0 +1,182 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + cancel: vi.fn(), + captureServerEvent: vi.fn(), + complete: vi.fn(), + parts: vi.fn(), + platformEvent: vi.fn(), + requireActor: vi.fn(), +})) + +vi.mock('@/lib/knowledge/application/upload-sessions', () => ({ + cancelKnowledgeDocumentUpload: { execute: mocks.cancel }, + completeKnowledgeDocumentUpload: { execute: mocks.complete }, + issueKnowledgeDocumentUploadParts: { execute: mocks.parts }, +})) + +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { knowledgeBaseDocumentsUploaded: mocks.platformEvent }, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) +vi.mock('@/app/api/knowledge/[id]/documents/uploads/utils', () => ({ + knowledgeDocumentUploadErrorResponse: vi.fn(() => null), + requireKnowledgeDocumentUploadActor: mocks.requireActor, +})) +vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ + toV2KnowledgeDocumentUpload: (_session: unknown, document: unknown) => ({ + id: 'upload-1', + knowledgeBaseId: 'kb-1', + status: document ? 'completed' : 'aborted', + name: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + expiresAt: '2026-08-05T00:00:00.000Z', + error: null, + document, + }), +})) + +import { POST as COMPLETE } from '@/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route' +import { POST as PARTS } from '@/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route' +import { DELETE as CANCEL } from '@/app/api/knowledge/[id]/documents/uploads/[uploadId]/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const PRINCIPAL = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } +const SESSION = { id: 'upload-1', knowledgeBaseId: 'kb-1' } +const DOCUMENT = { + id: 'upload-1', + knowledgeBaseId: 'kb-1', + filename: 'guide.pdf', + fileSize: 1024, + mimeType: 'application/pdf', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: true, + uploadedAt: new Date('2026-08-03T21:01:00.000Z'), +} + +function routeContext() { + return { params: Promise.resolve({ id: 'kb-1', uploadId: 'upload-1' }) } +} + +function controlUrl(suffix = '') { + return `http://localhost:3000/api/knowledge/kb-1/documents/uploads/upload-1${suffix}?workspaceId=${WORKSPACE_ID}` +} + +describe('internal knowledge-document upload control routes', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.requireActor.mockResolvedValue({ id: 'user-1', sessionId: 'session-1' }) + mocks.parts.mockResolvedValue({ + parts: [ + { + partNumber: 1, + url: 'https://storage.example/1', + headers: {}, + expiresAt: '2026-08-04T21:00:00.000Z', + }, + ], + }) + mocks.cancel.mockResolvedValue(SESSION) + mocks.complete.mockResolvedValue({ + session: SESSION, + value: { document: DOCUMENT, created: true, knowledgeBaseName: 'Docs' }, + alreadyCompleted: false, + workspaceId: WORKSPACE_ID, + knowledgeBaseId: 'kb-1', + }) + }) + + it('delegates multipart part signing with the current session principal', async () => { + const request = new NextRequest(controlUrl('/parts'), { + method: 'POST', + headers: { 'content-type': 'application/json', 'upload-token': 'token' }, + body: JSON.stringify({ partNumbers: [1] }), + }) + + const response = await PARTS(request, routeContext()) + + expect(response.status).toBe(200) + expect(mocks.parts).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'kb-1', + assertedWorkspaceId: WORKSPACE_ID, + uploadId: 'upload-1', + uploadToken: 'token', + partNumbers: [1], + }, + request, + }) + }) + + it('delegates cancellation with the current session principal', async () => { + const request = new NextRequest(controlUrl(), { + method: 'DELETE', + headers: { 'upload-token': 'token' }, + }) + + const response = await CANCEL(request, routeContext()) + + expect(response.status).toBe(200) + expect(mocks.cancel).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'kb-1', + assertedWorkspaceId: WORKSPACE_ID, + uploadId: 'upload-1', + uploadToken: 'token', + }, + request, + }) + }) + + it('delegates completion and emits UI analytics only for a new document', async () => { + const request = new NextRequest(controlUrl('/complete'), { + method: 'POST', + headers: { 'upload-token': 'token' }, + }) + + const response = await COMPLETE(request, routeContext()) + + expect(response.status).toBe(200) + expect(mocks.complete).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'kb-1', + assertedWorkspaceId: WORKSPACE_ID, + uploadId: 'upload-1', + uploadToken: 'token', + source: 'ui', + }, + request, + }) + expect(mocks.captureServerEvent).toHaveBeenCalledTimes(1) + expect(mocks.platformEvent).toHaveBeenCalledTimes(1) + }) + + it('does not duplicate UI analytics on an idempotent completion retry', async () => { + mocks.complete.mockResolvedValue({ + session: SESSION, + value: { document: DOCUMENT, created: false, knowledgeBaseName: 'Docs' }, + alreadyCompleted: true, + workspaceId: WORKSPACE_ID, + knowledgeBaseId: 'kb-1', + }) + const request = new NextRequest(controlUrl('/complete'), { + method: 'POST', + headers: { 'upload-token': 'token' }, + }) + + await COMPLETE(request, routeContext()) + + expect(mocks.captureServerEvent).not.toHaveBeenCalled() + expect(mocks.platformEvent).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts index ea79d0f4dc9..afc86b5d24a 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts @@ -1,35 +1,33 @@ /** * @vitest-environment node */ -import { NextRequest, NextResponse } from 'next/server' +import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCreateKnowledgeDocumentUploadSession, - mockRequireKnowledgeDocumentUploadAccess, - mockRequireKnowledgeDocumentUploadActor, - mockRequireKnowledgeDocumentUploadBilling, -} = vi.hoisted(() => ({ - mockCreateKnowledgeDocumentUploadSession: vi.fn(), - mockRequireKnowledgeDocumentUploadAccess: vi.fn(), - mockRequireKnowledgeDocumentUploadActor: vi.fn(), - mockRequireKnowledgeDocumentUploadBilling: vi.fn(), +const mocks = vi.hoisted(() => ({ + createUpload: vi.fn(), + requireActor: vi.fn(), +})) + +vi.mock('@/lib/knowledge/application/upload-sessions', () => ({ + createKnowledgeDocumentUpload: { execute: mocks.createUpload }, })) vi.mock('@/app/api/knowledge/[id]/documents/uploads/utils', () => ({ - requireKnowledgeDocumentUploadAccess: mockRequireKnowledgeDocumentUploadAccess, - requireKnowledgeDocumentUploadActor: mockRequireKnowledgeDocumentUploadActor, - requireKnowledgeDocumentUploadBilling: mockRequireKnowledgeDocumentUploadBilling, + knowledgeDocumentUploadErrorResponse: vi.fn(() => null), + requireKnowledgeDocumentUploadActor: mocks.requireActor, })) -vi.mock('@/app/api/files/uploads/utils', () => ({ uploadSessionErrorResponse: vi.fn() })) + vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ - createKnowledgeDocumentUploadSession: mockCreateKnowledgeDocumentUploadSession, toV2KnowledgeDocumentUpload: (session: Record<string, unknown>) => ({ - ...session, + id: session.id, + knowledgeBaseId: session.knowledgeBaseId, + status: session.status, name: session.fileName, contentType: session.contentType, size: session.fileSize, expiresAt: '2026-08-05T00:00:00.000Z', + error: null, document: null, }), })) @@ -37,91 +35,70 @@ vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ import { POST } from '@/app/api/knowledge/[id]/documents/uploads/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const SESSION = { + id: 'upload-1', + knowledgeBaseId: 'kb-1', + status: 'uploading', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + uploadToken: 'token', + transfer: { + method: 'put' as const, + url: 'https://storage.example/upload', + headers: { 'content-type': 'application/pdf' }, + }, +} function request() { - return POST( - new NextRequest('http://localhost:3000/api/knowledge/kb-1/documents/uploads', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - name: 'guide.pdf', - contentType: 'application/pdf', - size: 1024, - tag1: 'product', - processingOptions: { recipe: 'default', lang: 'en' }, - }), + const request = new NextRequest('http://localhost:3000/api/knowledge/kb-1/documents/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + name: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + tag1: 'product', }), - { params: Promise.resolve({ id: 'kb-1' }) } - ) + }) + return { + request, + response: POST(request, { params: Promise.resolve({ id: 'kb-1' }) }), + } } describe('POST /api/knowledge/[id]/documents/uploads', () => { beforeEach(() => { vi.clearAllMocks() - mockRequireKnowledgeDocumentUploadActor.mockResolvedValue({ id: 'user-1' }) - mockRequireKnowledgeDocumentUploadAccess.mockResolvedValue({ - knowledgeBase: { id: 'kb-1', name: 'Docs', workspaceId: WORKSPACE_ID }, - }) - mockRequireKnowledgeDocumentUploadBilling.mockResolvedValue({ actorUserId: 'user-1' }) - mockCreateKnowledgeDocumentUploadSession.mockResolvedValue({ - id: 'upload-1', - knowledgeBaseId: 'kb-1', - status: 'uploading', - fileName: 'guide.pdf', - contentType: 'application/pdf', - fileSize: 1024, - uploadToken: 'token', - error: null, - transfer: { - method: 'put', - url: 'https://storage.example/upload', - headers: { 'content-type': 'application/pdf' }, - }, + mocks.requireActor.mockResolvedValue({ + id: 'user-1', + sessionId: 'session-1', + name: 'User', + email: 'user@example.com', }) + mocks.createUpload.mockResolvedValue(SESSION) }) - it('authorizes and bills before allocating a first-party upload session', async () => { - const response = await request() + it('constructs a server-authored session principal and delegates creation', async () => { + const call = request() + const response = await call.response expect(response.status).toBe(201) - expect(mockRequireKnowledgeDocumentUploadAccess).toHaveBeenCalledWith({ - knowledgeBaseId: 'kb-1', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - }) - expect(mockCreateKnowledgeDocumentUploadSession).toHaveBeenCalledWith({ - workspaceId: WORKSPACE_ID, - userId: 'user-1', - knowledgeBaseId: 'kb-1', - fileName: 'guide.pdf', - contentType: 'application/pdf', - fileSize: 1024, - metadata: { - tag1: 'product', - processingOptions: { recipe: 'default', lang: 'en' }, + expect(mocks.createUpload).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + knowledgeBaseId: 'kb-1', + assertedWorkspaceId: WORKSPACE_ID, + name: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + metadata: { tag1: 'product' }, }, - localOrigin: 'http://localhost:3000', + request: call.request, }) - expect((await response.json()).data).toMatchObject({ - session: { id: 'upload-1', status: 'uploading', document: null }, - uploadToken: 'token', - transfer: { method: 'put', url: 'https://storage.example/upload' }, + expect(await response.json()).toMatchObject({ + data: { session: { id: 'upload-1' }, uploadToken: 'token' }, }) - expect(mockRequireKnowledgeDocumentUploadBilling.mock.invocationCallOrder[0]).toBeLessThan( - mockCreateKnowledgeDocumentUploadSession.mock.invocationCallOrder[0] - ) - }) - - it('does not bill or allocate storage when write access is denied', async () => { - mockRequireKnowledgeDocumentUploadAccess.mockResolvedValue( - NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - ) - - const response = await request() - - expect(response.status).toBe(403) - expect(mockRequireKnowledgeDocumentUploadBilling).not.toHaveBeenCalled() - expect(mockCreateKnowledgeDocumentUploadSession).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts index 58a1c69c253..26a46296fe2 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts @@ -2,17 +2,12 @@ import { type NextRequest, NextResponse } from 'next/server' import { createKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { validateFileType } from '@/lib/uploads/utils/validation' -import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' +import { createKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' import { - requireKnowledgeDocumentUploadAccess, + knowledgeDocumentUploadErrorResponse, requireKnowledgeDocumentUploadActor, - requireKnowledgeDocumentUploadBilling, } from '@/app/api/knowledge/[id]/documents/uploads/utils' -import { - createKnowledgeDocumentUploadSession, - toV2KnowledgeDocumentUpload, -} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' +import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' interface KnowledgeDocumentUploadsRouteParams { params: Promise<{ id: string }> @@ -26,31 +21,18 @@ export const POST = withRouteHandler( if (!parsed.success) return parsed.response const { id: knowledgeBaseId } = parsed.data.params const { workspaceId, name, contentType, size, ...metadata } = parsed.data.body - const access = await requireKnowledgeDocumentUploadAccess({ - knowledgeBaseId, - workspaceId, - userId: actor.id, - }) - if (access instanceof NextResponse) return access - const billing = await requireKnowledgeDocumentUploadBilling({ - workspaceId, - userId: actor.id, - }) - if (billing instanceof NextResponse) return billing - const fileTypeError = validateFileType(name, contentType) - if (fileTypeError) { - return NextResponse.json({ error: fileTypeError.message }, { status: 415 }) - } try { - const upload = await createKnowledgeDocumentUploadSession({ - workspaceId, - userId: actor.id, - knowledgeBaseId, - fileName: name, - contentType, - fileSize: size, - metadata, - localOrigin: request.nextUrl.origin, + const upload = await createKnowledgeDocumentUpload.execute({ + principal: { kind: 'session', userId: actor.id, sessionId: actor.sessionId }, + input: { + knowledgeBaseId, + assertedWorkspaceId: workspaceId, + name, + contentType, + size, + metadata, + }, + request, }) return NextResponse.json( { @@ -63,7 +45,7 @@ export const POST = withRouteHandler( { status: 201 } ) } catch (error) { - const classified = uploadSessionErrorResponse(error) + const classified = knowledgeDocumentUploadErrorResponse(error) if (classified) return classified throw error } diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.test.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.test.ts new file mode 100644 index 00000000000..80ae8df1f47 --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.test.ts @@ -0,0 +1,36 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ getSession: vi.fn() })) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) + +import { requireKnowledgeDocumentUploadActor } from '@/app/api/knowledge/[id]/documents/uploads/utils' + +describe('knowledge-document upload session authentication', () => { + beforeEach(() => vi.clearAllMocks()) + + it('returns the authoritative session id with the authenticated user', async () => { + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1', name: 'User', email: 'user@example.com' }, + session: { id: 'session-1' }, + }) + + await expect(requireKnowledgeDocumentUploadActor()).resolves.toEqual({ + id: 'user-1', + sessionId: 'session-1', + name: 'User', + email: 'user@example.com', + }) + }) + + it('fails fast when authenticated state has no session id', async () => { + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: {} }) + + await expect(requireKnowledgeDocumentUploadActor()).rejects.toThrow( + 'Authenticated session is missing its session ID' + ) + }) +}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.ts index 450b17ecd0b..04aa96cc40a 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.ts @@ -1,15 +1,12 @@ import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' -import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' -import { - checkAttributedUsageLimits, - resolveBillingAttribution, -} from '@/lib/billing/core/billing-attribution' -import type { KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' -import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' +import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' +import { KnowledgeDocumentUnsupportedMediaTypeError } from '@/lib/knowledge/application/upload-sessions' +import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' export interface KnowledgeDocumentUploadActor { id: string + sessionId: string name?: string | null email?: string | null } @@ -21,53 +18,22 @@ export async function requireKnowledgeDocumentUploadActor(): Promise< if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') return { id: session.user.id, + sessionId, name: session.user.name, email: session.user.email, } } -export async function requireKnowledgeDocumentUploadAccess(params: { - knowledgeBaseId: string - workspaceId: string - userId: string -}): Promise<{ knowledgeBase: KnowledgeBaseAccessResult['knowledgeBase'] } | NextResponse> { - const access = await checkKnowledgeBaseWriteAccess(params.knowledgeBaseId, params.userId) - if (!access.hasAccess) { - return 'notFound' in access && access.notFound - ? NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) - : NextResponse.json({ error: 'Forbidden' }, { status: 403 }) +export function knowledgeDocumentUploadErrorResponse(error: unknown): NextResponse | null { + if (error instanceof KnowledgeDocumentUnsupportedMediaTypeError) { + return NextResponse.json({ error: error.message }, { status: 415 }) } - if (access.knowledgeBase.workspaceId !== params.workspaceId) { - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + if (error instanceof KnowledgeUsageLimitExceededError) { + return NextResponse.json({ error: error.message }, { status: 402 }) } - return { knowledgeBase: access.knowledgeBase } -} - -export async function requireKnowledgeDocumentUploadBilling(params: { - workspaceId: string - userId: string -}): Promise<BillingAttributionSnapshot | NextResponse> { - const attribution = await resolveKnowledgeDocumentUploadAttribution(params) - const usage = await checkAttributedUsageLimits(attribution) - if (usage.isExceeded) { - return NextResponse.json( - { - error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.', - }, - { status: 402 } - ) - } - return attribution -} - -export function resolveKnowledgeDocumentUploadAttribution(params: { - workspaceId: string - userId: string -}): Promise<BillingAttributionSnapshot> { - return resolveBillingAttribution({ - actorUserId: params.userId, - workspaceId: params.workspaceId, - }) + return uploadSessionErrorResponse(error) } diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts index 079e4d18311..f12d79ddd31 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -1,119 +1,110 @@ -import { NextResponse } from 'next/server' import { - type V2KnowledgeDocument, v2DeleteKnowledgeDocumentContract, v2GetKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' -import { getKnowledgeDocument } from '@/lib/knowledge/documents/service' -import { performDeleteKnowledgeDocument } from '@/lib/knowledge/orchestration' -import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' -import type { RateLimitResult } from '@/app/api/v1/middleware' -import { v2Data, v2Error, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' +import { + defineV2JsonRoute, + type V2ErrorPolicy, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { + deleteKnowledgeDocument, + readKnowledgeDocument, +} from '@/lib/knowledge/application/documents' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { captureServerEvent } from '@/lib/posthog/server' +import { serializeDate } from '@/app/api/v1/knowledge/utils' +import { v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * Resolves a knowledge base via the shared v1 ownership invariant - * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A - * `404` is always `NOT_FOUND`; a `403` is masked as `NOT_FOUND` on reads and - * surfaced as `FORBIDDEN` on writes. - */ -async function resolveKnowledgeBaseScoped( - id: string, - workspaceId: string, - userId: string, - rateLimit: RateLimitResult, - level: 'read' | 'write' -): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { - const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) - if (!(result instanceof NextResponse)) return result - if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') - return level === 'read' - ? v2Error('NOT_FOUND', 'Knowledge base not found') - : v2Error('FORBIDDEN', 'Access denied') +function toProcessingStatus(status: string): 'pending' | 'processing' | 'completed' | 'failed' { + switch (status) { + case 'pending': + case 'processing': + case 'completed': + case 'failed': + return status + default: + throw new Error(`Unexpected knowledge document processing status: ${status}`) + } } +const concealKnowledgeDocumentReadAuthorization = { + render(error) { + const response = v2OrchestrationErrorPolicy.render(error) + if (response?.status === 403) return v2Error('NOT_FOUND', 'Knowledge base not found') + return response + }, +} satisfies V2ErrorPolicy + /** GET /api/v2/knowledge/[id]/documents/[documentId] — Get document details. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetKnowledgeDocumentContract, - rateLimitEndpoint: 'knowledge-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { id: knowledgeBaseId, documentId } = input.params - - const result = await resolveKnowledgeBaseScoped( - knowledgeBaseId, - input.query.workspaceId, - userId, - rateLimit, - 'read' - ) - if (result instanceof NextResponse) return result - - const doc = await getKnowledgeDocument(knowledgeBaseId, documentId) - if (!doc) return v2Error('NOT_FOUND', 'Document not found') - - const documentDetail: V2KnowledgeDocument = { - id: doc.id, - knowledgeBaseId: doc.knowledgeBaseId, - filename: doc.filename, - fileSize: doc.fileSize, - mimeType: doc.mimeType, - processingStatus: doc.processingStatus as V2KnowledgeDocument['processingStatus'], - processingError: doc.processingError, - processingStartedAt: serializeDate(doc.processingStartedAt), - processingCompletedAt: serializeDate(doc.processingCompletedAt), - chunkCount: doc.chunkCount, - tokenCount: doc.tokenCount, - characterCount: doc.characterCount, - enabled: doc.enabled, - connectorId: doc.connectorId, - connectorType: doc.connectorType ?? null, - sourceUrl: doc.sourceUrl, - createdAt: serializeDate(doc.uploadedAt), - } - - return v2Data({ document: documentDetail }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.readDocument, + rateLimit: v2RateLimits.publicApi, + errorPolicy: concealKnowledgeDocumentReadAuthorization, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.id, + documentId: params.documentId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: readKnowledgeDocument, + present: ({ document }) => ({ + data: { + document: { + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + filename: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + processingStatus: toProcessingStatus(document.processingStatus), + processingError: document.processingError, + processingStartedAt: serializeDate(document.processingStartedAt), + processingCompletedAt: serializeDate(document.processingCompletedAt), + chunkCount: document.chunkCount, + tokenCount: document.tokenCount, + characterCount: document.characterCount, + enabled: document.enabled, + connectorId: document.connectorId, + connectorType: document.connectorType, + sourceUrl: document.sourceUrl, + createdAt: serializeDate(document.uploadedAt), + }, + }, + }), }) /** DELETE /api/v2/knowledge/[id]/documents/[documentId] — Delete a document. */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteKnowledgeDocumentContract, - rateLimitEndpoint: 'knowledge-detail', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - const { id: knowledgeBaseId, documentId } = input.params - - const result = await resolveKnowledgeBaseScoped( - knowledgeBaseId, - input.query.workspaceId, - userId, - rateLimit, - 'write' - ) - if (result instanceof NextResponse) return result - - const doc = await getKnowledgeDocument(knowledgeBaseId, documentId) - if (!doc) return v2Error('NOT_FOUND', 'Document not found') - - const outcome = await performDeleteKnowledgeDocument({ - knowledgeBase: { - id: knowledgeBaseId, - name: result.kb.name, - workspaceId: input.query.workspaceId, - }, - document: { id: documentId, filename: doc.filename }, - userId, - source: 'api', - requestId, - request, - }) - if (!outcome.success) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error) + auth: v2ApiKeyAuth, + operation: knowledgeOperations.deleteDocument, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.id, + documentId: params.documentId, + assertedWorkspaceId: query.workspaceId, + source: 'api', + }), + useCase: deleteKnowledgeDocument, + onSuccess: ({ principal, input }) => { + if (principal.kind === 'personal_api_key') { + captureServerEvent( + principal.userId, + 'knowledge_base_document_deleted', + { + knowledge_base_id: input.knowledgeBaseId, + workspace_id: input.assertedWorkspaceId ?? '', + }, + input.assertedWorkspaceId ? { groups: { workspace: input.assertedWorkspaceId } } : undefined + ) } - - return v2Data({ id: documentId, deleted: true as const }, { rateLimit }) }, + present: ({ id }) => ({ data: { id, deleted: true as const } }), }) 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 new file mode 100644 index 00000000000..cbb5e0d88d2 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts @@ -0,0 +1,218 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAuthenticate, + mockCheckPreAuth, + mockCheckRateLimit, + mockAdmitUpload, + mockUploadDocument, + mockReadFormData, + mockReadFile, + mockUploadWorkspaceFile, + mockPlatformUploaded, + mockCapture, +} = vi.hoisted(() => ({ + mockAuthenticate: vi.fn(), + mockCheckPreAuth: vi.fn(), + mockCheckRateLimit: vi.fn(), + mockAdmitUpload: vi.fn(), + mockUploadDocument: vi.fn(), + mockReadFormData: vi.fn(), + mockReadFile: vi.fn(), + mockUploadWorkspaceFile: vi.fn(), + mockPlatformUploaded: vi.fn(), + mockCapture: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mockAuthenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect(...args: unknown[]) { + return mockCheckPreAuth(...args) + } + + checkRateLimitDirectOrThrow(...args: unknown[]) { + return mockCheckRateLimit(...args) + } + }, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null) })) + +vi.mock('@/lib/knowledge/application/documents', () => ({ + listKnowledgeDocuments: { + operation: { id: 'knowledge.documents.list' }, + execute: vi.fn(), + }, + admitKnowledgeDocumentUpload: { + operation: { id: 'knowledge.documents.upload' }, + execute: mockAdmitUpload, + }, + uploadKnowledgeDocument: { + operation: { id: 'knowledge.documents.upload' }, + execute: mockUploadDocument, + }, +})) + +vi.mock('@/lib/core/utils/stream-limits', () => ({ + isPayloadSizeLimitError: () => false, + readFormDataWithLimit: mockReadFormData, + readFileToBufferWithLimit: mockReadFile, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + uploadWorkspaceFile: mockUploadWorkspaceFile, +})) + +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { knowledgeBaseDocumentsUploaded: mockPlatformUploaded }, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCapture })) + +import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' +import { POST } from '@/app/api/v2/knowledge/[id]/documents/route' + +const WORKSPACE_ID = 'workspace-1' +const RATE_LIMIT_OK = { + allowed: true, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 0, +} +const PRINCIPAL = { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' } as const + +function buildRequest() { + return new NextRequest( + `http://localhost/api/v2/knowledge/kb-1/documents?workspaceId=${WORKSPACE_ID}`, + { method: 'POST', headers: { 'x-api-key': 'secret' }, body: 'multipart-placeholder' } + ) +} + +describe('POST /api/v2/knowledge/[id]/documents', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckPreAuth.mockResolvedValue(RATE_LIMIT_OK) + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockAuthenticate.mockResolvedValue({ + principal: PRINCIPAL, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + mockAdmitUpload.mockResolvedValue({ + knowledgeBaseId: 'kb-1', + knowledgeBaseName: 'Support docs', + workspaceId: WORKSPACE_ID, + storageActorUserId: 'user-1', + }) + const formData = new FormData() + formData.set('file', new File(['hello'], 'support.txt', { type: 'text/plain' })) + mockReadFormData.mockResolvedValue(formData) + mockReadFile.mockResolvedValue(Buffer.from('hello')) + mockUploadWorkspaceFile.mockResolvedValue({ url: 's3://workspace/support.txt' }) + mockUploadDocument.mockResolvedValue({ + created: true, + document: { + id: 'doc-1', + knowledgeBaseId: 'kb-1', + filename: 'support.txt', + fileUrl: 's3://workspace/support.txt', + fileSize: 5, + mimeType: 'text/plain', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: true, + uploadedAt: new Date('2024-01-01T00:00:00Z'), + }, + }) + }) + + it('admits before buffering and reauthorizes durable registration with code-defined admission', async () => { + const request = buildRequest() + + const response = await POST(request, { params: Promise.resolve({ id: 'kb-1' }) }) + + expect(response.status).toBe(201) + expect(mockAdmitUpload.mock.invocationCallOrder[0]).toBeLessThan( + mockReadFormData.mock.invocationCallOrder[0] + ) + expect(mockAdmitUpload).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { knowledgeBaseId: 'kb-1', assertedWorkspaceId: WORKSPACE_ID }, + request, + }) + expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( + WORKSPACE_ID, + 'user-1', + Buffer.from('hello'), + 'support.txt', + 'text/plain' + ) + expect(mockUploadDocument).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'kb-1', + assertedWorkspaceId: WORKSPACE_ID, + document: { + filename: 'support.txt', + fileUrl: 's3://workspace/support.txt', + fileSize: 5, + mimeType: 'text/plain', + }, + startProcessing: true, + usageAdmission: 'pre_admitted', + source: 'api', + }, + request, + }) + expect(mockPlatformUploaded).toHaveBeenCalledOnce() + expect(mockCapture).toHaveBeenCalledWith( + 'user-1', + 'knowledge_base_document_uploaded', + expect.objectContaining({ knowledge_base_id: 'kb-1' }), + expect.any(Object) + ) + }) + + it('maps usage admission to the v2 error before multipart buffering', async () => { + mockAdmitUpload.mockRejectedValue(new KnowledgeUsageLimitExceededError('Upgrade required')) + + const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + + expect(response.status).toBe(402) + expect(await response.json()).toEqual({ + error: { code: 'USAGE_LIMIT_EXCEEDED', message: 'Upgrade required' }, + }) + expect(mockReadFormData).not.toHaveBeenCalled() + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + expect(mockUploadDocument).not.toHaveBeenCalled() + }) + + it('does not create human analytics for a workspace key', async () => { + mockAuthenticate.mockResolvedValue({ + principal: { kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'key-2' }, + rolloutUserId: 'billing-owner', + rateLimitSubjectIds: ['api-key:key-2', `workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace', + }) + + const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + + expect(response.status).toBe(201) + expect(mockPlatformUploaded).toHaveBeenCalledOnce() + expect(mockCapture).not.toHaveBeenCalled() + }) +}) 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 c189de04389..5acd58ac0c5 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -1,37 +1,41 @@ +import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { type V2KnowledgeDocumentSummary, v2ListKnowledgeDocumentsContract, v2UploadKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' import { - checkAttributedUsageLimits, - resolveBillingAttribution, - resolveSystemBillingAttribution, -} from '@/lib/billing/core/billing-attribution' + defineV2JsonRoute, + type V2ErrorPolicy, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import type { JsonRouteContext } from '@/lib/api/server/routes/types' +import { admitV2Request, V2RouteInfrastructureError } from '@/lib/api/server/routes/v2-json-route' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { PlatformEvents } from '@/lib/core/telemetry' import { isPayloadSizeLimitError, readFileToBufferWithLimit, readFormDataWithLimit, } from '@/lib/core/utils/stream-limits' -import { getDocuments } from '@/lib/knowledge/documents/service' -import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' -import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration' -import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' +import { + admitKnowledgeDocumentUpload, + listKnowledgeDocuments, + uploadKnowledgeDocument, +} from '@/lib/knowledge/application/documents' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { captureServerEvent } from '@/lib/posthog/server' import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' import { validateFileType } from '@/lib/uploads/utils/validation' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' -import type { RateLimitResult } from '@/app/api/v1/middleware' -import { - decodeCursor, - encodeCursor, - v2CursorList, - v2Data, - v2Error, - v2ErrorForOrchestration, -} from '@/app/api/v2/lib/response' +import { serializeDate } from '@/app/api/v1/knowledge/utils' +import { decodeCursor, encodeCursor, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -39,127 +43,109 @@ export const revalidate = 0 const MAX_FILE_SIZE = MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 -/** - * Resolves a knowledge base via the shared v1 ownership invariant - * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A - * `404` is always `NOT_FOUND`; a `403` is masked as `NOT_FOUND` on reads and - * surfaced as `FORBIDDEN` on writes. - */ -async function resolveKnowledgeBaseScoped( - id: string, - workspaceId: string, - userId: string, - rateLimit: RateLimitResult, - level: 'read' | 'write' -): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { - const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) - if (!(result instanceof NextResponse)) return result - if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') - return level === 'read' - ? v2Error('NOT_FOUND', 'Knowledge base not found') - : v2Error('FORBIDDEN', 'Access denied') +const concealKnowledgeDocumentListAuthorization = { + render(error) { + const response = v2OrchestrationErrorPolicy.render(error) + if (response?.status === 403) return v2Error('NOT_FOUND', 'Knowledge base not found') + return response + }, +} satisfies V2ErrorPolicy + +function toV2DocumentSummary(document: { + id: string + knowledgeBaseId: string + filename: string + fileSize: number + mimeType: string + processingStatus?: 'pending' | 'processing' | 'completed' | 'failed' + chunkCount: number + tokenCount: number + characterCount: number + enabled: boolean + uploadedAt: Date +}): V2KnowledgeDocumentSummary { + return { + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + filename: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + processingStatus: document.processingStatus ?? 'pending', + chunkCount: document.chunkCount, + tokenCount: document.tokenCount, + characterCount: document.characterCount, + enabled: document.enabled, + createdAt: serializeDate(document.uploadedAt), + } } /** GET /api/v2/knowledge/[id]/documents — List documents in a knowledge base. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListKnowledgeDocumentsContract, - rateLimitEndpoint: 'knowledge-detail', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - const { workspaceId, limit, cursor, search, enabledFilter, sortBy, sortOrder } = input.query - const { id: knowledgeBaseId } = input.params - - const result = await resolveKnowledgeBaseScoped( - knowledgeBaseId, - workspaceId, - userId, - rateLimit, - 'read' - ) - if (result instanceof NextResponse) return result - - const decodedCursor = cursor ? decodeCursor<{ offset: number }>(cursor) : null + auth: v2ApiKeyAuth, + operation: knowledgeOperations.listDocuments, + rateLimit: v2RateLimits.publicApi, + errorPolicy: concealKnowledgeDocumentListAuthorization, + mapInput: ({ params, query }) => { + const decodedCursor = query.cursor ? decodeCursor<{ offset: number }>(query.cursor) : null if ( - cursor && + query.cursor && (!decodedCursor || !Number.isInteger(decodedCursor.offset) || decodedCursor.offset < 0) ) { - return v2Error('BAD_REQUEST', 'Invalid cursor') + throw new OrchestrationError('validation', 'Invalid cursor') + } + return { + knowledgeBaseId: params.id, + assertedWorkspaceId: query.workspaceId, + enabledFilter: query.enabledFilter, + search: query.search, + limit: query.limit, + offset: decodedCursor?.offset ?? 0, + sortBy: query.sortBy, + sortOrder: query.sortOrder, } - const offset = decodedCursor?.offset ?? 0 - - const documentsResult = await getDocuments( - knowledgeBaseId, - { - enabledFilter: enabledFilter === 'all' ? undefined : enabledFilter, - search, - limit, - offset, - sortBy: sortBy as DocumentSortField, - sortOrder: sortOrder as SortOrder, - }, - requestId - ) - - const documents: V2KnowledgeDocumentSummary[] = documentsResult.documents.map((doc) => ({ - id: doc.id, - knowledgeBaseId, - filename: doc.filename, - fileSize: doc.fileSize, - mimeType: doc.mimeType, - processingStatus: doc.processingStatus, - chunkCount: doc.chunkCount, - tokenCount: doc.tokenCount, - characterCount: doc.characterCount, - enabled: doc.enabled, - createdAt: serializeDate(doc.uploadedAt), - })) - - const nextCursor = documentsResult.pagination.hasMore - ? encodeCursor({ offset: offset + limit }) - : null - return v2CursorList(documents, nextCursor, { rateLimit }) }, + useCase: listKnowledgeDocuments, + present: ({ documents, pagination }) => ({ + data: documents.map(toV2DocumentSummary), + nextCursor: pagination.hasMore + ? encodeCursor({ offset: pagination.offset + pagination.limit }) + : null, + }), }) -/** - * POST /api/v2/knowledge/[id]/documents — Upload a document to a knowledge base. - * - * Authorization runs fully before the multipart body is buffered: the workspace - * is a contract-validated query param (not a form field as in v1), so an - * unauthorized caller never streams a file into memory. Order: rate limit → - * KB ownership (write) → usage gate → buffered multipart read. - */ -export const POST = withPublicApiRouteHandler({ - contract: v2UploadKnowledgeDocumentContract, - rateLimitEndpoint: 'knowledge-detail', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - try { - const { id: knowledgeBaseId } = input.params - const { workspaceId } = input.query - - const result = await resolveKnowledgeBaseScoped( - knowledgeBaseId, - workspaceId, - userId, - rateLimit, - 'write' +/** POST /api/v2/knowledge/[id]/documents — Upload a document to a knowledge base. */ +export const POST = withRouteHandler<JsonRouteContext | undefined>( + async (request: NextRequest, context) => { + if (request.method !== v2UploadKnowledgeDocumentContract.method) { + throw new Error( + `Route received ${request.method} for ${v2UploadKnowledgeDocumentContract.method} contract ${v2UploadKnowledgeDocumentContract.path}` ) - if (result instanceof NextResponse) return result - - /** - * Gate before storage and indexing. Workspace keys use the billed account - * and immutable payer from one read; personal keys preserve their human actor. - */ - const billingAttribution = - rateLimit.keyType === 'workspace' - ? await resolveSystemBillingAttribution(workspaceId) - : await resolveBillingAttribution({ actorUserId: userId, workspaceId }) - const usage = await checkAttributedUsageLimits(billingAttribution) - if (usage.isExceeded) { - return v2Error( - 'USAGE_LIMIT_EXCEEDED', - usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' - ) - } + } + + const routeAdmission = await admitV2Request( + request, + knowledgeOperations.uploadDocument, + v2ApiKeyAuth, + v2RateLimits.publicApi + ) + if (!routeAdmission.success) return routeAdmission.response + + const parsed = await parseRequest(v2UploadKnowledgeDocumentContract, request, context ?? {}, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { principal } = routeAdmission.auth + const { id: knowledgeBaseId } = parsed.data.params + const { workspaceId } = parsed.data.query + + try { + const uploadAdmission = await admitKnowledgeDocumentUpload.execute({ + principal, + input: { knowledgeBaseId, assertedWorkspaceId: workspaceId }, + request, + }) let formData: FormData try { @@ -176,9 +162,7 @@ export const POST = withPublicApiRouteHandler({ const rawFile = formData.get('file') const file = rawFile instanceof File ? rawFile : null - if (!file) { - return v2Error('BAD_REQUEST', 'file form field is required') - } + if (!file) return v2Error('BAD_REQUEST', 'file form field is required') if (file.size > MAX_FILE_SIZE) { return v2Error( @@ -197,57 +181,80 @@ export const POST = withPublicApiRouteHandler({ label: 'knowledge document file', }) const contentType = file.type || 'application/octet-stream' - const uploadedFile = await uploadWorkspaceFile( - workspaceId, - userId, + uploadAdmission.workspaceId, + uploadAdmission.storageActorUserId, buffer, file.name, contentType ) - const outcome = await performUploadKnowledgeDocument({ - knowledgeBase: { id: knowledgeBaseId, name: result.kb.name, workspaceId }, - document: { - filename: file.name, - fileUrl: uploadedFile.url, - fileSize: file.size, - mimeType: contentType, + const result = await uploadKnowledgeDocument.execute({ + principal, + input: { + knowledgeBaseId, + assertedWorkspaceId: workspaceId, + document: { + filename: file.name, + fileUrl: uploadedFile.url, + fileSize: file.size, + mimeType: contentType, + }, + startProcessing: true, + usageAdmission: 'pre_admitted', + source: 'api', }, - startProcessing: 'queue', - billingAttribution, - uploadedBy: billingAttribution.actorUserId, - userId, - source: 'api', - requestId, request, }) - if (!outcome.success) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error) - } - const newDocument = outcome.document - const document: V2KnowledgeDocumentSummary = { - id: newDocument.id, + PlatformEvents.knowledgeBaseDocumentsUploaded({ knowledgeBaseId, - filename: newDocument.filename, - fileSize: newDocument.fileSize, - mimeType: newDocument.mimeType, - processingStatus: 'pending', - chunkCount: 0, - tokenCount: 0, - characterCount: 0, - enabled: newDocument.enabled, - createdAt: serializeDate(newDocument.uploadedAt), + documentsCount: 1, + uploadType: 'single', + mimeType: contentType, + fileSize: file.size, + }) + if (principal.kind === 'personal_api_key') { + captureServerEvent( + principal.userId, + 'knowledge_base_document_uploaded', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: workspaceId, + document_count: 1, + upload_type: 'single', + }, + { + groups: { workspace: workspaceId }, + setOnce: { first_document_uploaded_at: new Date().toISOString() }, + } + ) } - return v2Data({ document }, { rateLimit, status: 201 }) + const document = toV2DocumentSummary(result.document) + const body = v2UploadKnowledgeDocumentContract.response.schema.parse({ + data: { document }, + }) + return NextResponse.json(body, { + status: 201, + headers: { 'Cache-Control': 'private, no-store' }, + }) } catch (error) { + if (error instanceof KnowledgeUsageLimitExceededError) { + return v2Error('USAGE_LIMIT_EXCEEDED', error.message) + } if (isPayloadSizeLimitError(error)) { return v2Error('PAYLOAD_TOO_LARGE', error.message) } - + const response = v2OrchestrationErrorPolicy.render(error) + if (response) return response throw error } }, -}) + { + unhandledErrorResponse: ({ error }) => + error instanceof V2RouteInfrastructureError + ? v2Error('SERVICE_UNAVAILABLE', 'Service temporarily unavailable') + : v2Error('INTERNAL_ERROR', 'Internal server error'), + } +) 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 62a81397f64..e1db73c90fe 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 @@ -4,85 +4,81 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockCompleteUploadSession, - mockFinalizeKnowledgeDocumentUpload, - mockResolveKnowledgeDocumentUploadAccess, - mockResolveKnowledgeDocumentUploadAttribution, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockCompleteUploadSession: vi.fn(), - mockFinalizeKnowledgeDocumentUpload: vi.fn(), - mockResolveKnowledgeDocumentUploadAccess: vi.fn(), - mockResolveKnowledgeDocumentUploadAttribution: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticateV2ApiKey: vi.fn(), + captureServerEvent: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + completeUpload: vi.fn(), + gate: vi.fn(), + platformEvent: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/knowledge/application/upload-sessions', () => ({ + completeKnowledgeDocumentUpload: { + operation: { + id: 'knowledge.documents.upload.complete', + minimumRole: 'write', + workspaceApiKey: 'allow', + }, + execute: mocks.completeUpload, + }, +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/lib/uploads/upload-session/service', () => ({ - completeUploadSession: mockCompleteUploadSession, + +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('@/lib/core/telemetry', () => ({ + PlatformEvents: { knowledgeBaseDocumentsUploaded: mocks.platformEvent }, })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ - finalizeKnowledgeDocumentUpload: mockFinalizeKnowledgeDocumentUpload, - getOwnedKnowledgeDocumentUpload: vi.fn(() => SESSION), - resolveKnowledgeDocumentUploadAccess: mockResolveKnowledgeDocumentUploadAccess, - resolveKnowledgeDocumentUploadAttribution: mockResolveKnowledgeDocumentUploadAttribution, - toV2KnowledgeDocumentUpload: (session: Record<string, unknown>, document: unknown) => ({ - ...session, - name: session.fileName, - contentType: session.contentType, - size: session.fileSize, + toV2KnowledgeDocumentUpload: (_session: unknown, document: { id: string } | null) => ({ + id: 'upload-1', + knowledgeBaseId: 'kb-1', + status: 'completed', + name: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, expiresAt: '2026-08-04T21:00:00.000Z', - document, + error: null, + document: document + ? { + id: document.id, + knowledgeBaseId: 'kb-1', + filename: 'guide.pdf', + fileSize: 1024, + mimeType: 'application/pdf', + processingStatus: 'pending', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: true, + createdAt: '2026-08-03T21:01:00.000Z', + } + : null, }), + v2KnowledgeDocumentUploadError: vi.fn(() => null), })) -import { OrchestrationError } from '@/lib/core/orchestration/types' import { POST } from '@/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' -const FILE_URL = '/api/files/serve/s3/kb%2Fguide.pdf?context=knowledge-base' -const SESSION = { - id: 'upload-1', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - knowledgeBaseId: 'kb-1', - workflowId: null, - executionId: null, - purpose: 'knowledge_document', - method: 'multipart', - storageContext: 'knowledge-base', - storageKey: 'kb/guide.pdf', - finalKey: 'kb/guide.pdf', - storageProvider: 's3', - providerUploadId: 'provider-1', - providerObjectVersion: null, - fileName: 'guide.pdf', - contentType: 'application/pdf', - fileSize: 1024, - partSize: 8 * 1024 * 1024, - partCount: 1, - status: 'uploading', - metadata: { - tag1: 'product', - processingOptions: { recipe: 'default', lang: 'en' }, - }, - uploadToken: 'token', - createdAt: new Date('2026-08-03T21:00:00.000Z'), - expiresAt: new Date('2026-08-04T21:00:00.000Z'), - completedFileId: null, - error: null, - completedAt: null, - updatedAt: new Date('2026-08-03T21:00:00.000Z'), -} as const const DOCUMENT = { id: 'upload-1', knowledgeBaseId: 'kb-1', filename: 'guide.pdf', - fileUrl: FILE_URL, fileSize: 1024, mimeType: 'application/pdf', chunkCount: 0, @@ -91,94 +87,106 @@ const DOCUMENT = { enabled: true, uploadedAt: new Date('2026-08-03T21:01:00.000Z'), } -const RATE_LIMIT = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2026-08-03T22:00:00.000Z'), +const RESULT = { + session: { id: 'upload-1' }, + value: { document: DOCUMENT, created: true, knowledgeBaseName: 'Docs' }, + alreadyCompleted: false, + workspaceId: WORKSPACE_ID, + knowledgeBaseId: 'kb-1', +} + +function auth(principal: Record<string, unknown>) { + return { + principal, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: + principal.kind === 'workspace_api_key' ? ('workspace' as const) : ('personal' as const), + } } function request() { - return POST( - new NextRequest( - `http://localhost:3000/api/v2/knowledge/kb-1/documents/uploads/upload-1/complete?workspaceId=${WORKSPACE_ID}`, - { - method: 'POST', - headers: { 'upload-token': 'token' }, - } - ), - { params: Promise.resolve({ id: 'kb-1', uploadId: 'upload-1' }) } + const request = new NextRequest( + `http://localhost:3000/api/v2/knowledge/kb-1/documents/uploads/upload-1/complete?workspaceId=${WORKSPACE_ID}`, + { method: 'POST', headers: { 'upload-token': 'token', 'x-api-key': 'secret' } } ) + return { + request, + response: POST(request, { + params: Promise.resolve({ id: 'kb-1', uploadId: 'upload-1' }), + }), + } } -describe('POST knowledge-document multipart completion', () => { +describe('POST knowledge-document upload completion', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) - mockResolveKnowledgeDocumentUploadAccess.mockResolvedValue({ - kb: { id: 'kb-1', name: 'Docs' }, - }) - mockResolveKnowledgeDocumentUploadAttribution.mockResolvedValue({ actorUserId: 'payer-1' }) - mockFinalizeKnowledgeDocumentUpload.mockResolvedValue({ - value: DOCUMENT, - completedFileId: DOCUMENT.id, + mocks.authenticateV2ApiKey.mockResolvedValue( + auth({ kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }) + ) + mocks.gate.mockResolvedValue(null) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-04T21:00:00.000Z'), }) - mockCompleteUploadSession.mockImplementation(async ({ session, finalize }) => { - const finalized = await finalize(session) - return { - session: { ...session, status: 'completed', completedFileId: finalized.completedFileId }, - value: finalized.value, - alreadyCompleted: false, - } + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-04T21:00:00.000Z'), }) + mocks.completeUpload.mockResolvedValue(RESULT) }) - it('delegates completion to the shared finalizer and returns the bound document', async () => { - const response = await request() + it('delegates completion and emits v2 analytics only for a newly created document', async () => { + const call = request() + const response = await call.response expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ data: { document: { id: 'upload-1' } } }) - expect(mockFinalizeKnowledgeDocumentUpload).toHaveBeenCalledWith( - expect.objectContaining({ - claimed: SESSION, + expect(mocks.completeUpload).toHaveBeenCalledWith({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { knowledgeBaseId: 'kb-1', - knowledgeBaseName: 'Docs', - workspaceId: WORKSPACE_ID, - userId: 'user-1', + assertedWorkspaceId: WORKSPACE_ID, + uploadId: 'upload-1', + uploadToken: 'token', source: 'api', - }) - ) - expect(mockCompleteUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ - session: SESSION, - }) + }, + request: call.request, + }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'knowledge_base_document_uploaded', + expect.objectContaining({ knowledge_base_id: 'kb-1', workspace_id: WORKSPACE_ID }), + expect.any(Object) ) + expect(mocks.platformEvent).toHaveBeenCalledTimes(1) + expect(await response.json()).toMatchObject({ data: { document: { id: 'upload-1' } } }) }) - it('resolves the payer lazily, only when the finalizer asks for one', async () => { - await request() - - expect(mockResolveKnowledgeDocumentUploadAttribution).not.toHaveBeenCalled() + it('does not duplicate analytics for an idempotent completion retry', async () => { + mocks.completeUpload.mockResolvedValue({ + ...RESULT, + value: { ...RESULT.value, created: false }, + alreadyCompleted: true, + }) - const { resolveAttribution } = mockFinalizeKnowledgeDocumentUpload.mock.calls[0][0] - await resolveAttribution() + const response = await request().response - expect(mockResolveKnowledgeDocumentUploadAttribution).toHaveBeenCalledWith({ - workspaceId: WORKSPACE_ID, - userId: 'user-1', - rateLimit: RATE_LIMIT, - }) + expect(response.status).toBe(200) + expect(mocks.captureServerEvent).not.toHaveBeenCalled() + expect(mocks.platformEvent).not.toHaveBeenCalled() }) - it('maps an orchestration failure from the finalizer onto its v2 status', async () => { - mockFinalizeKnowledgeDocumentUpload.mockRejectedValue( - new OrchestrationError('payload_too_large', 'Storage limit exceeded') + it('does not attribute a workspace-key event to the billing owner', async () => { + mocks.authenticateV2ApiKey.mockResolvedValue( + auth({ kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'key-1' }) ) - const response = await request() + await request().response - expect(response.status).toBe(413) + expect(mocks.captureServerEvent).not.toHaveBeenCalled() + expect(mocks.platformEvent).toHaveBeenCalledTimes(1) }) }) 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 05b111c7cbf..aa9bf3e6751 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,61 +1,56 @@ -import { NextResponse } from 'next/server' import { v2CompleteKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' -import { completeUploadSession } from '@/lib/uploads/upload-session/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { PlatformEvents } from '@/lib/core/telemetry' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { completeKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' +import { captureServerEvent } from '@/lib/posthog/server' import { - finalizeKnowledgeDocumentUpload, - getOwnedKnowledgeDocumentUpload, - resolveKnowledgeDocumentUploadAccess, - resolveKnowledgeDocumentUploadAttribution, toV2KnowledgeDocumentUpload, + v2KnowledgeDocumentUploadError, } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' -import { v2CaughtOrchestrationError, v2Data } from '@/app/api/v2/lib/response' -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CompleteKnowledgeDocumentUploadContract, - rateLimitEndpoint: 'knowledge-detail', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - try { - const { id: knowledgeBaseId, uploadId } = input.params - const { workspaceId } = input.query - - const access = await resolveKnowledgeDocumentUploadAccess({ - knowledgeBaseId, - workspaceId, - userId, - rateLimit, - }) - if (access instanceof NextResponse) return access - - const session = await getOwnedKnowledgeDocumentUpload({ - knowledgeBaseId, - uploadId, - workspaceId, - userId, - uploadToken: input.headers['upload-token'], - }) - const result = await completeUploadSession({ - session, - finalize: (claimed) => - finalizeKnowledgeDocumentUpload({ - claimed, - knowledgeBaseId, - knowledgeBaseName: access.kb.name, - workspaceId, - userId, - resolveAttribution: () => - resolveKnowledgeDocumentUploadAttribution({ workspaceId, userId, rateLimit }), - source: 'api', - requestId, - request, - }), + auth: v2ApiKeyAuth, + operation: knowledgeOperations.uploadComplete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: { render: v2KnowledgeDocumentUploadError }, + mapInput: ({ params, query, headers }) => ({ + knowledgeBaseId: params.id, + assertedWorkspaceId: query.workspaceId, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + source: 'api' as const, + }), + useCase: completeKnowledgeDocumentUpload, + onSuccess: ({ principal, result }) => { + if (result.value.created && principal.kind === 'personal_api_key') { + captureServerEvent( + principal.userId, + 'knowledge_base_document_uploaded', + { + knowledge_base_id: result.knowledgeBaseId, + workspace_id: result.workspaceId, + document_count: 1, + upload_type: 'single', + }, + { + groups: { workspace: result.workspaceId }, + setOnce: { first_document_uploaded_at: new Date().toISOString() }, + } + ) + } + if (result.value.created) { + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId: result.knowledgeBaseId, + documentsCount: 1, + uploadType: 'single', + mimeType: result.value.document.mimeType, + fileSize: result.value.document.fileSize, }) - - return v2Data(toV2KnowledgeDocumentUpload(result.session, result.value), { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error } }, + present: (result) => ({ + data: toV2KnowledgeDocumentUpload(result.session, result.value.document), + }), }) 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 8b7320f397a..6640972b06b 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,46 +1,22 @@ -import { NextResponse } from 'next/server' import { v2CreateKnowledgeDocumentUploadPartUrlsContract } from '@/lib/api/contracts/v2/knowledge' -import { createUploadPartUrls } from '@/lib/uploads/upload-session/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { - getOwnedKnowledgeDocumentUpload, - resolveKnowledgeDocumentUploadAccess, -} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' -import { v2CaughtOrchestrationError, v2Data } from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +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 = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateKnowledgeDocumentUploadPartUrlsContract, - rateLimitEndpoint: 'knowledge-detail', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - try { - const { id: knowledgeBaseId, uploadId } = input.params - const { workspaceId } = input.query - - const access = await resolveKnowledgeDocumentUploadAccess({ - knowledgeBaseId, - workspaceId, - userId, - rateLimit, - }) - if (access instanceof NextResponse) return access - - const session = await getOwnedKnowledgeDocumentUpload({ - knowledgeBaseId, - uploadId, - workspaceId, - userId, - uploadToken: input.headers['upload-token'], - }) - const parts = await createUploadPartUrls({ - session, - partNumbers: input.body.partNumbers, - localOrigin: request.nextUrl.origin, - }) - return v2Data({ parts }, { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.uploadParts, + rateLimit: v2RateLimits.publicApi, + errorPolicy: { render: v2KnowledgeDocumentUploadError }, + mapInput: ({ params, query, headers, body }) => ({ + knowledgeBaseId: params.id, + assertedWorkspaceId: query.workspaceId, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + partNumbers: body.partNumbers, + }), + useCase: issueKnowledgeDocumentUploadParts, + present: ({ parts }) => ({ data: { parts } }), }) 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 a7ba8567f2a..87194ec5d55 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,43 +1,24 @@ -import { NextResponse } from 'next/server' import { v2AbortKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { cancelKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' import { - abortKnowledgeDocumentUpload, - getOwnedKnowledgeDocumentUpload, - resolveKnowledgeDocumentUploadAccess, toV2KnowledgeDocumentUpload, + v2KnowledgeDocumentUploadError, } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' -import { v2CaughtOrchestrationError, v2Data } from '@/app/api/v2/lib/response' -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2AbortKnowledgeDocumentUploadContract, - rateLimitEndpoint: 'knowledge-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { id: knowledgeBaseId, uploadId } = input.params - const { workspaceId } = input.query - - const access = await resolveKnowledgeDocumentUploadAccess({ - knowledgeBaseId, - workspaceId, - userId, - rateLimit, - }) - if (access instanceof NextResponse) return access - - const session = await getOwnedKnowledgeDocumentUpload({ - knowledgeBaseId, - uploadId, - workspaceId, - userId, - uploadToken: input.headers['upload-token'], - }) - const aborted = await abortKnowledgeDocumentUpload(session, knowledgeBaseId) - return v2Data(toV2KnowledgeDocumentUpload(aborted, null), { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.uploadCancel, + rateLimit: v2RateLimits.publicApi, + errorPolicy: { render: v2KnowledgeDocumentUploadError }, + mapInput: ({ params, query, headers }) => ({ + knowledgeBaseId: params.id, + assertedWorkspaceId: query.workspaceId, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + }), + useCase: cancelKnowledgeDocumentUpload, + present: (session) => ({ data: toV2KnowledgeDocumentUpload(session, null) }), }) 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 new file mode 100644 index 00000000000..8f90f4b030b --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/control-routes.test.ts @@ -0,0 +1,163 @@ +/** + * @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(), + gate: vi.fn(), + parts: vi.fn(), +})) + +vi.mock('@/lib/knowledge/application/upload-sessions', () => ({ + cancelKnowledgeDocumentUpload: { + operation: { + id: 'knowledge.documents.upload.cancel', + minimumRole: 'write', + workspaceApiKey: 'allow', + }, + execute: mocks.cancel, + }, + issueKnowledgeDocumentUploadParts: { + operation: { + id: 'knowledge.documents.upload.parts', + minimumRole: 'write', + workspaceApiKey: 'allow', + }, + execute: mocks.parts, + }, +})) + +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('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ + toV2KnowledgeDocumentUpload: () => ({ + id: 'upload-1', + knowledgeBaseId: 'kb-1', + status: 'aborted', + name: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + expiresAt: '2026-08-04T21:00:00.000Z', + error: null, + document: null, + }), + v2KnowledgeDocumentUploadError: vi.fn(() => null), +})) + +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' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const PRINCIPAL = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} + +function context() { + return { params: Promise.resolve({ id: 'kb-1', uploadId: 'upload-1' }) } +} + +function controlUrl(suffix = '') { + return `http://localhost:3000/api/v2/knowledge/kb-1/documents/uploads/upload-1${suffix}?workspaceId=${WORKSPACE_ID}` +} + +describe('v2 knowledge-document upload control routes', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticateV2ApiKey.mockResolvedValue({ + principal: PRINCIPAL, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + mocks.gate.mockResolvedValue(null) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-04T21:00:00.000Z'), + }) + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-04T21:00:00.000Z'), + }) + mocks.parts.mockResolvedValue({ + parts: [ + { + partNumber: 1, + url: 'https://storage.example/1', + headers: {}, + expiresAt: '2026-08-04T21:00:00.000Z', + }, + ], + }) + mocks.cancel.mockResolvedValue({ id: 'upload-1' }) + }) + + it('delegates part signing with the authenticated API-key principal', async () => { + const request = new NextRequest(controlUrl('/parts'), { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'upload-token': 'token', + 'x-api-key': 'secret', + }, + body: JSON.stringify({ partNumbers: [1] }), + }) + + const response = await PARTS(request, context()) + + expect(response.status).toBe(200) + expect(mocks.parts).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'kb-1', + assertedWorkspaceId: WORKSPACE_ID, + uploadId: 'upload-1', + uploadToken: 'token', + partNumbers: [1], + }, + request, + }) + }) + + it('delegates cancellation with the authenticated API-key principal', async () => { + const request = new NextRequest(controlUrl(), { + method: 'DELETE', + headers: { 'upload-token': 'token', 'x-api-key': 'secret' }, + }) + + const response = await CANCEL(request, context()) + + expect(response.status).toBe(200) + expect(mocks.cancel).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'kb-1', + assertedWorkspaceId: WORKSPACE_ID, + uploadId: 'upload-1', + uploadToken: 'token', + }, + request, + }) + }) +}) 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 0d0b0462332..652dd4404fb 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 @@ -1,138 +1,183 @@ /** * @vitest-environment node */ -import { NextRequest, NextResponse } from 'next/server' +import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockCreateKnowledgeDocumentUploadSession, - mockResolveKnowledgeDocumentUploadAccess, - mockResolveKnowledgeDocumentUploadBilling, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockCreateKnowledgeDocumentUploadSession: vi.fn(), - mockResolveKnowledgeDocumentUploadAccess: vi.fn(), - mockResolveKnowledgeDocumentUploadBilling: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + createUpload: vi.fn(), + gate: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/knowledge/application/upload-sessions', () => ({ + createKnowledgeDocumentUpload: { + operation: { + id: 'knowledge.documents.upload.create', + minimumRole: 'write', + workspaceApiKey: 'allow', + }, + execute: mocks.createUpload, + }, })) + +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('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ - createKnowledgeDocumentUploadSession: mockCreateKnowledgeDocumentUploadSession, - resolveKnowledgeDocumentUploadAccess: mockResolveKnowledgeDocumentUploadAccess, - resolveKnowledgeDocumentUploadBilling: mockResolveKnowledgeDocumentUploadBilling, toV2KnowledgeDocumentUpload: (session: Record<string, unknown>) => ({ - ...session, + id: session.id, + knowledgeBaseId: session.knowledgeBaseId, + status: session.status, name: session.fileName, contentType: session.contentType, size: session.fileSize, expiresAt: '2026-08-04T21:00:00.000Z', + error: null, document: null, }), + v2KnowledgeDocumentUploadError: vi.fn(() => null), })) import { POST } from '@/app/api/v2/knowledge/[id]/documents/uploads/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' -const RATE_LIMIT = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2026-08-03T22:00:00.000Z'), +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const SESSION = { + id: 'upload-1', + knowledgeBaseId: 'kb-1', + status: 'uploading', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + uploadToken: 'token', + transfer: { + method: 'put' as const, + url: 'https://storage.example/upload', + headers: { 'content-type': 'application/pdf' }, + }, } -function request() { - return POST( - new NextRequest('http://localhost:3000/api/v2/knowledge/kb-1/documents/uploads', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - name: 'guide.pdf', - contentType: 'application/pdf', - size: 1024, - tag1: 'product', - processingOptions: { recipe: 'default', lang: 'en' }, - }), - }), - { params: Promise.resolve({ id: 'kb-1' }) } - ) +function request(body: Record<string, unknown>) { + const request = new NextRequest('http://localhost:3000/api/v2/knowledge/kb-1/documents/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), + }) + return { + request, + response: POST(request, { params: Promise.resolve({ id: 'kb-1' }) }), + } } describe('POST /api/v2/knowledge/[id]/documents/uploads', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) - mockResolveKnowledgeDocumentUploadAccess.mockResolvedValue({ - kb: { id: 'kb-1', name: 'Docs' }, + mocks.authenticateV2ApiKey.mockResolvedValue(AUTH) + mocks.gate.mockResolvedValue(null) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-04T21:00:00.000Z'), }) - mockResolveKnowledgeDocumentUploadBilling.mockResolvedValue({ actorUserId: 'user-1' }) - mockCreateKnowledgeDocumentUploadSession.mockResolvedValue({ - id: 'upload-1', - knowledgeBaseId: 'kb-1', - status: 'uploading', - fileName: 'guide.pdf', - contentType: 'application/pdf', - fileSize: 1024, - uploadToken: 'token', - error: null, - transfer: { - method: 'put', - url: 'https://storage.example/upload', - headers: { 'content-type': 'application/pdf' }, - }, + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-04T21:00:00.000Z'), }) + mocks.createUpload.mockResolvedValue(SESSION) }) - it('authorizes the knowledge base and runs usage billing before accepting storage', async () => { - const response = await request() + it('delegates creation with the authenticated principal and asserted workspace', async () => { + const call = request({ + workspaceId: WORKSPACE_ID, + name: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + tag1: 'product', + processingOptions: { recipe: 'default', lang: 'en' }, + }) + const response = await call.response expect(response.status).toBe(201) - expect(mockResolveKnowledgeDocumentUploadAccess).toHaveBeenCalledWith( - expect.objectContaining({ + expect(mocks.createUpload).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { knowledgeBaseId: 'kb-1', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - }) - ) - expect(mockResolveKnowledgeDocumentUploadBilling).toHaveBeenCalled() - expect(mockCreateKnowledgeDocumentUploadSession).toHaveBeenCalledWith({ - workspaceId: WORKSPACE_ID, - userId: 'user-1', - knowledgeBaseId: 'kb-1', - fileName: 'guide.pdf', - contentType: 'application/pdf', - fileSize: 1024, - metadata: { - tag1: 'product', - processingOptions: { recipe: 'default', lang: 'en' }, + assertedWorkspaceId: WORKSPACE_ID, + name: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + metadata: { + tag1: 'product', + processingOptions: { recipe: 'default', lang: 'en' }, + }, }, - localOrigin: 'http://localhost:3000', + request: call.request, }) - expect((await response.json()).data).toMatchObject({ - session: { id: 'upload-1', status: 'uploading', document: null }, - uploadToken: 'token', - transfer: { method: 'put', url: 'https://storage.example/upload' }, + expect(await response.json()).toMatchObject({ + data: { + session: { id: 'upload-1', status: 'uploading', document: null }, + uploadToken: 'token', + }, }) - expect(mockResolveKnowledgeDocumentUploadBilling.mock.invocationCallOrder[0]).toBeLessThan( - mockCreateKnowledgeDocumentUploadSession.mock.invocationCallOrder[0] - ) }) - it('does not run billing or create provider state when knowledge write access is denied', async () => { - mockResolveKnowledgeDocumentUploadAccess.mockResolvedValue( - NextResponse.json({ error: { code: 'FORBIDDEN', message: 'Access denied' } }, { status: 403 }) - ) + it('authenticates and rate limits before parsing an invalid request', async () => { + const response = await request({ workspaceId: WORKSPACE_ID }).response - const response = await request() + expect(response.status).toBe(400) + expect(mocks.authenticateV2ApiKey).toHaveBeenCalledTimes(1) + expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(mocks.createUpload).not.toHaveBeenCalled() + }) + + it('rejects oversized or server-authored credential-binding body fields', async () => { + const oversized = await request({ + workspaceId: WORKSPACE_ID, + name: 'guide.pdf', + contentType: 'application/pdf', + size: 100 * 1024 * 1024 + 1, + }).response + const forgedBinding = await request({ + workspaceId: WORKSPACE_ID, + name: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + authBinding: { + version: 1, + workspaceId: WORKSPACE_ID, + principal: { kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'forged' }, + }, + }).response - expect(response.status).toBe(403) - expect(mockResolveKnowledgeDocumentUploadBilling).not.toHaveBeenCalled() - expect(mockCreateKnowledgeDocumentUploadSession).not.toHaveBeenCalled() + expect(oversized.status).toBe(400) + expect(forgedBinding.status).toBe(400) + expect(mocks.createUpload).not.toHaveBeenCalled() }) }) 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 509a01f6de0..03f1ea7289d 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,65 +1,35 @@ -import { NextResponse } from 'next/server' import { v2CreateKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' -import { validateFileType } from '@/lib/uploads/utils/validation' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { createKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' import { - createKnowledgeDocumentUploadSession, - resolveKnowledgeDocumentUploadAccess, - resolveKnowledgeDocumentUploadBilling, toV2KnowledgeDocumentUpload, + v2KnowledgeDocumentUploadError, } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' -import { v2CaughtOrchestrationError, v2Data, v2Error } from '@/app/api/v2/lib/response' -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateKnowledgeDocumentUploadContract, - rateLimitEndpoint: 'knowledge-detail', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - try { - const { id: knowledgeBaseId } = input.params - const { workspaceId, name, contentType, size, ...metadata } = input.body - - const access = await resolveKnowledgeDocumentUploadAccess({ - knowledgeBaseId, - workspaceId, - userId, - rateLimit, - }) - if (access instanceof NextResponse) return access - - const billing = await resolveKnowledgeDocumentUploadBilling({ - workspaceId, - userId, - rateLimit, - }) - if (billing instanceof NextResponse) return billing - - const fileTypeError = validateFileType(name, contentType) - if (fileTypeError) { - return v2Error('UNSUPPORTED_MEDIA_TYPE', fileTypeError.message) - } - - const session = await createKnowledgeDocumentUploadSession({ - workspaceId, - userId, - knowledgeBaseId, - fileName: name, - contentType, - fileSize: size, - metadata, - localOrigin: request.nextUrl.origin, - }) - return v2Data( - { - session: toV2KnowledgeDocumentUpload(session, null), - uploadToken: session.uploadToken, - transfer: session.transfer, - }, - { rateLimit, status: 201 } - ) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error + auth: v2ApiKeyAuth, + operation: knowledgeOperations.uploadCreate, + rateLimit: v2RateLimits.publicApi, + errorPolicy: { render: v2KnowledgeDocumentUploadError }, + mapInput: ({ params, body }) => { + const { workspaceId, name, contentType, size, ...metadata } = body + return { + knowledgeBaseId: params.id, + assertedWorkspaceId: workspaceId, + name, + contentType, + size, + metadata, } }, + useCase: createKnowledgeDocumentUpload, + present: (session) => ({ + data: { + session: toV2KnowledgeDocumentUpload(session, null), + uploadToken: session.uploadToken, + transfer: session.transfer, + }, + }), }) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts deleted file mode 100644 index d7f25983576..00000000000 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' - -const { - mockAbortUploadSession, - mockCreateUploadSession, - mockFindBoundKnowledgeDocument, - mockPerformUploadKnowledgeDocument, - mockRecordKnowledgeBaseFileOwnership, -} = vi.hoisted(() => ({ - mockAbortUploadSession: vi.fn(), - mockCreateUploadSession: vi.fn(), - mockFindBoundKnowledgeDocument: vi.fn(), - mockPerformUploadKnowledgeDocument: vi.fn(), - mockRecordKnowledgeBaseFileOwnership: vi.fn(), -})) - -vi.mock('@/lib/knowledge/orchestration', () => ({ - performUploadKnowledgeDocument: mockPerformUploadKnowledgeDocument, -})) -vi.mock('@/lib/knowledge/orchestration/documents', () => ({ - findBoundKnowledgeDocument: mockFindBoundKnowledgeDocument, -})) -vi.mock('@/lib/uploads/upload-session/service', () => ({ - abortUploadSession: mockAbortUploadSession, - createUploadSession: mockCreateUploadSession, - getOwnedUploadSession: vi.fn(), -})) -vi.mock('@/lib/uploads/server/metadata', () => ({ - recordKnowledgeBaseFileOwnership: mockRecordKnowledgeBaseFileOwnership, -})) - -import { - abortKnowledgeDocumentUpload, - createKnowledgeDocumentUploadSession, - finalizeKnowledgeDocumentUpload, - toV2KnowledgeDocumentUpload, -} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' - -const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' -const CLAIMED: UploadSessionRecord = { - id: 'upload-1', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - knowledgeBaseId: 'kb-1', - workflowId: null, - executionId: null, - purpose: 'knowledge_document', - method: 'multipart', - storageContext: 'knowledge-base', - storageKey: 'kb/guide.pdf', - finalKey: 'kb/guide.pdf', - storageProvider: 's3', - providerUploadId: 'provider-1', - providerObjectVersion: null, - fileName: 'guide.pdf', - contentType: 'application/pdf', - fileSize: 1024, - partSize: 8 * 1024 * 1024, - partCount: 1, - status: 'uploading', - metadata: { tag1: 'product', processingOptions: { recipe: 'default', lang: 'en' } }, - uploadToken: 'token', - createdAt: new Date('2026-08-03T21:00:00.000Z'), - expiresAt: new Date('2026-08-04T21:00:00.000Z'), - completedFileId: null, - error: null, - completedAt: null, - updatedAt: new Date('2026-08-03T21:00:00.000Z'), -} -const DOCUMENT = { id: 'upload-1', knowledgeBaseId: 'kb-1', filename: 'guide.pdf' } - -function finalize(resolveAttribution = vi.fn().mockResolvedValue({ actorUserId: 'payer-1' })) { - return finalizeKnowledgeDocumentUpload({ - claimed: CLAIMED, - knowledgeBaseId: 'kb-1', - knowledgeBaseName: 'Docs', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - resolveAttribution, - source: 'api', - requestId: 'req-1', - request: new NextRequest('http://localhost:3000/api/v2/knowledge/kb-1'), - }) -} - -function createSession() { - return createKnowledgeDocumentUploadSession({ - workspaceId: WORKSPACE_ID, - userId: 'user-1', - knowledgeBaseId: 'kb-1', - fileName: 'guide.pdf', - contentType: 'application/pdf', - fileSize: 1024, - metadata: { tag1: 'product' }, - localOrigin: 'http://localhost:3000', - }) -} - -describe('createKnowledgeDocumentUploadSession', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCreateUploadSession.mockResolvedValue(CLAIMED) - mockRecordKnowledgeBaseFileOwnership.mockResolvedValue(undefined) - mockAbortUploadSession.mockResolvedValue({ ...CLAIMED, status: 'aborted' }) - }) - - it('records the ownership binding before returning the upload token', async () => { - await expect(createSession()).resolves.toBe(CLAIMED) - - expect(mockCreateUploadSession).toHaveBeenCalledWith({ - workspaceId: WORKSPACE_ID, - userId: 'user-1', - knowledgeBaseId: 'kb-1', - purpose: 'knowledge_document', - fileName: 'guide.pdf', - contentType: 'application/pdf', - fileSize: 1024, - metadata: { tag1: 'product' }, - localOrigin: 'http://localhost:3000', - }) - expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenCalledWith({ - key: 'kb/guide.pdf', - userId: 'user-1', - workspaceId: WORKSPACE_ID, - originalName: 'guide.pdf', - contentType: 'application/pdf', - size: 1024, - }) - expect(mockCreateUploadSession.mock.invocationCallOrder[0]).toBeLessThan( - mockRecordKnowledgeBaseFileOwnership.mock.invocationCallOrder[0] - ) - }) - - it('aborts provider state when the ownership binding cannot be recorded', async () => { - mockRecordKnowledgeBaseFileOwnership.mockRejectedValue(new Error('database unavailable')) - - await expect(createSession()).rejects.toThrow('database unavailable') - expect(mockAbortUploadSession).toHaveBeenCalledWith(CLAIMED) - }) -}) - -describe('toV2KnowledgeDocumentUpload', () => { - it('does not expose reusable upload capabilities after session creation', () => { - const serialized = toV2KnowledgeDocumentUpload(CLAIMED, null) - - expect(serialized).not.toHaveProperty('uploadToken') - expect(serialized).not.toHaveProperty('partSize') - expect(serialized).not.toHaveProperty('partCount') - expect(serialized).not.toHaveProperty('transfer') - }) -}) - -describe('abortKnowledgeDocumentUpload', () => { - beforeEach(() => { - vi.clearAllMocks() - mockAbortUploadSession.mockResolvedValue({ ...CLAIMED, status: 'aborted' }) - }) - - it('aborts an upload that no document is bound to', async () => { - mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'absent' }) - - await expect(abortKnowledgeDocumentUpload(CLAIMED, 'kb-1')).resolves.toMatchObject({ - status: 'aborted', - }) - expect(mockAbortUploadSession).toHaveBeenCalledWith(CLAIMED) - }) - - it('refuses to abort once a document is bound, so committed bytes survive', async () => { - mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'bound', document: DOCUMENT }) - - await expect(abortKnowledgeDocumentUpload(CLAIMED, 'kb-1')).rejects.toThrow( - 'Upload has already been completed' - ) - expect(mockAbortUploadSession).not.toHaveBeenCalled() - }) -}) - -describe('finalizeKnowledgeDocumentUpload', () => { - beforeEach(() => { - vi.clearAllMocks() - mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'absent' }) - mockPerformUploadKnowledgeDocument.mockResolvedValue({ - success: true, - document: DOCUMENT, - created: true, - }) - }) - - it('creates the document, carrying session tags and processing options through', async () => { - const result = await finalize() - - expect(result).toEqual({ value: DOCUMENT, completedFileId: 'upload-1' }) - expect(mockPerformUploadKnowledgeDocument).toHaveBeenCalledWith( - expect.objectContaining({ - documentId: 'upload-1', - startProcessing: 'queue', - uploadedBy: 'payer-1', - processingOptions: { recipe: 'default', lang: 'en' }, - document: expect.objectContaining({ filename: 'guide.pdf', tag1: 'product' }), - }) - ) - }) - - it('answers a retry from the bound document without resolving a payer', async () => { - mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'bound', document: DOCUMENT }) - const resolveAttribution = vi.fn() - - const result = await finalize(resolveAttribution) - - expect(result).toEqual({ value: DOCUMENT, completedFileId: 'upload-1' }) - expect(resolveAttribution).not.toHaveBeenCalled() - expect(mockPerformUploadKnowledgeDocument).not.toHaveBeenCalled() - }) - - it('retains completed bytes for retry when document creation fails', async () => { - mockPerformUploadKnowledgeDocument.mockResolvedValue({ - success: false, - errorCode: 'payload_too_large', - error: 'Storage limit exceeded', - }) - - await expect(finalize()).rejects.toThrow('Storage limit exceeded') - expect(mockFindBoundKnowledgeDocument).toHaveBeenCalledTimes(1) - }) - - it('lets a retry converge when the first response fails after the document binds', async () => { - mockFindBoundKnowledgeDocument - .mockResolvedValueOnce({ status: 'absent' }) - .mockResolvedValueOnce({ status: 'bound', document: DOCUMENT }) - mockPerformUploadKnowledgeDocument.mockRejectedValue(new Error('audit sink exploded')) - - await expect(finalize()).rejects.toThrow('audit sink exploded') - await expect(finalize()).resolves.toEqual({ - value: DOCUMENT, - completedFileId: 'upload-1', - }) - expect(mockPerformUploadKnowledgeDocument).toHaveBeenCalledTimes(1) - }) - - it('rejects an upload id already bound to a different document without deleting anything', async () => { - mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'conflict' }) - const resolveAttribution = vi.fn() - - await expect(finalize(resolveAttribution)).rejects.toThrow( - 'Upload id is already bound to a different document' - ) - expect(resolveAttribution).not.toHaveBeenCalled() - }) -}) 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 4ef4bf2d8fa..fbf8e030418 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,136 +1,23 @@ -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' +import type { NextResponse } from 'next/server' import type { V2KnowledgeDocumentSummary, V2KnowledgeDocumentUpload, } from '@/lib/api/contracts/v2/knowledge' -import { v2KnowledgeDocumentUploadMetadataSchema } from '@/lib/api/contracts/v2/knowledge' -import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' -import { - checkAttributedUsageLimits, - resolveBillingAttribution, - resolveSystemBillingAttribution, -} from '@/lib/billing/core/billing-attribution' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration' +import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' +import { KnowledgeDocumentUnsupportedMediaTypeError } from '@/lib/knowledge/application/upload-sessions' import type { CreatedKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' -import { findBoundKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' -import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' -import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' -import { - abortUploadSession, - type CreatedUploadSession, - createUploadSession, - getOwnedUploadSession, - type UploadSessionRecord, -} from '@/lib/uploads/upload-session/service' -import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' -import type { RateLimitResult } from '@/app/api/v1/middleware' -import { v2Error } from '@/app/api/v2/lib/response' +import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' +import { serializeDate } from '@/app/api/v1/knowledge/utils' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' -export async function resolveKnowledgeDocumentUploadAccess(params: { - knowledgeBaseId: string - workspaceId: string - userId: string - rateLimit: RateLimitResult -}): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { - const result = await resolveKnowledgeBase( - params.knowledgeBaseId, - params.workspaceId, - params.userId, - params.rateLimit, - 'write' - ) - if (!(result instanceof NextResponse)) return result - if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') - return v2Error('FORBIDDEN', 'Access denied') -} - -/** - * Resolves the payer for an upload without enforcing usage limits. Completion uses this - * because its bytes were already admitted when the session was created; re-running - * admission there would strand uploaded parts and fail idempotent completion retries. - */ -export async function resolveKnowledgeDocumentUploadAttribution(params: { - workspaceId: string - userId: string - rateLimit: RateLimitResult -}): Promise<BillingAttributionSnapshot> { - return params.rateLimit.keyType === 'workspace' - ? resolveSystemBillingAttribution(params.workspaceId) - : resolveBillingAttribution({ - actorUserId: params.userId, - workspaceId: params.workspaceId, - }) -} - -/** Admission check for a new upload session. Enforced only at session creation. */ -export async function resolveKnowledgeDocumentUploadBilling(params: { - workspaceId: string - userId: string - rateLimit: RateLimitResult -}): Promise<BillingAttributionSnapshot | NextResponse> { - const attribution = await resolveKnowledgeDocumentUploadAttribution(params) - const usage = await checkAttributedUsageLimits(attribution) - if (usage.isExceeded) { - return v2Error( - 'USAGE_LIMIT_EXCEEDED', - usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' - ) +export function v2KnowledgeDocumentUploadError(error: unknown): NextResponse | null { + if (error instanceof KnowledgeDocumentUnsupportedMediaTypeError) { + return v2Error('UNSUPPORTED_MEDIA_TYPE', error.message) } - return attribution -} - -export async function getOwnedKnowledgeDocumentUpload(params: { - knowledgeBaseId: string - uploadId: string - workspaceId: string - userId: string - uploadToken: string -}): Promise<UploadSessionRecord> { - return getOwnedUploadSession({ - uploadId: params.uploadId, - workspaceId: params.workspaceId, - userId: params.userId, - purpose: 'knowledge_document', - knowledgeBaseId: params.knowledgeBaseId, - uploadToken: params.uploadToken, - }) -} - -/** - * Creates a knowledge-document upload and records its ownership binding before the token is - * returned. Failed or abandoned sessions can then be reclaimed by the knowledge-base orphan - * sweeper without racing a later document insert. - */ -export async function createKnowledgeDocumentUploadSession(params: { - workspaceId: string - userId: string - knowledgeBaseId: string - fileName: string - contentType: string - fileSize: number - metadata: Record<string, unknown> - localOrigin: string -}): Promise<CreatedUploadSession> { - const session = await createUploadSession({ - ...params, - purpose: 'knowledge_document', - }) - try { - await recordKnowledgeBaseFileOwnership({ - key: session.storageKey, - userId: params.userId, - workspaceId: params.workspaceId, - originalName: params.fileName, - contentType: params.contentType, - size: params.fileSize, - }) - } catch (error) { - await abortUploadSession(session) - throw error + if (error instanceof KnowledgeUsageLimitExceededError) { + return v2Error('USAGE_LIMIT_EXCEEDED', error.message) } - return session + return v2CaughtOrchestrationError(error) } export function toV2KnowledgeDocumentSummary( @@ -170,105 +57,3 @@ export function toV2KnowledgeDocumentUpload( document: document ? toV2KnowledgeDocumentSummary(document) : null, } } - -export function knowledgeDocumentFileUrl(session: UploadSessionRecord): string { - if (session.storageContext !== 'knowledge-base') { - throw new Error('Knowledge-document upload has an invalid storage context') - } - const providerPrefix = session.storageProvider === 'local' ? '' : `${session.storageProvider}/` - return `/api/files/serve/${providerPrefix}${encodeURIComponent(session.storageKey)}?context=knowledge-base` -} - -function knowledgeDocumentInputFor(session: UploadSessionRecord) { - const { processingOptions: _processingOptions, ...documentTags } = - v2KnowledgeDocumentUploadMetadataSchema.parse(session.metadata) - return { - filename: session.fileName, - fileUrl: knowledgeDocumentFileUrl(session), - fileSize: session.fileSize, - mimeType: session.contentType, - ...documentTags, - } -} - -/** - * Aborts an upload session, refusing once a document is bound to it. - * - * The document binding remains the domain-level completion authority while the upload row - * protects the provider object lifecycle. - */ -export async function abortKnowledgeDocumentUpload( - session: UploadSessionRecord, - knowledgeBaseId: string -): Promise<UploadSessionRecord> { - const bound = await findBoundKnowledgeDocument({ - documentId: session.id, - knowledgeBaseId, - document: knowledgeDocumentInputFor(session), - }) - if (bound.status !== 'absent') { - throw new OrchestrationError('conflict', 'Upload has already been completed') - } - return abortUploadSession(session) -} - -/** - * Binds a completed upload session to its knowledge document. Shared by the public v2 - * and session-authenticated routes so both get identical completion semantics. - * - * Ordering is load-bearing. A retry is answered from the already-bound document before any - * work that can fail independently of the upload runs, so a payer that became unresolvable - * after the session was created cannot turn a valid retry into an error. The ownership binding - * is recorded before the upload token is issued, so failures retain retriable state and the - * delayed orphan sweeper reclaims sessions that never bind to a document. - */ -export async function finalizeKnowledgeDocumentUpload(params: { - claimed: UploadSessionRecord - knowledgeBaseId: string - knowledgeBaseName: string | null - workspaceId: string - userId: string - resolveAttribution: () => Promise<BillingAttributionSnapshot> - source: 'api' | 'ui' - requestId: string - request: NextRequest - actorName?: string | null - actorEmail?: string | null -}): Promise<{ value: CreatedKnowledgeDocument; completedFileId: string }> { - const { claimed, knowledgeBaseId, workspaceId, requestId } = params - const { processingOptions } = v2KnowledgeDocumentUploadMetadataSchema.parse(claimed.metadata) - const document = knowledgeDocumentInputFor(claimed) - - const bound = await findBoundKnowledgeDocument({ - documentId: claimed.id, - knowledgeBaseId, - document, - }) - if (bound.status === 'bound') { - return { value: bound.document, completedFileId: bound.document.id } - } - if (bound.status === 'conflict') { - throw new OrchestrationError('conflict', 'Upload id is already bound to a different document') - } - - const billingAttribution = await params.resolveAttribution() - const outcome = await performUploadKnowledgeDocument({ - knowledgeBase: { id: knowledgeBaseId, name: params.knowledgeBaseName, workspaceId }, - document, - documentId: claimed.id, - startProcessing: 'queue', - processingOptions, - billingAttribution, - uploadedBy: billingAttribution.actorUserId, - userId: params.userId, - ...(params.actorName ? { actorName: params.actorName } : {}), - ...(params.actorEmail ? { actorEmail: params.actorEmail } : {}), - source: params.source, - requestId, - request: params.request, - }) - if (!outcome.success) { - throw new OrchestrationError(outcome.errorCode, outcome.error) - } - return { value: outcome.document, completedFileId: outcome.document.id } -} diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts index f2da8d298cb..a92d1b5fa85 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -1,151 +1,123 @@ -import { NextResponse } from 'next/server' import { v2DeleteKnowledgeBaseContract, v2GetKnowledgeBaseContract, v2UpdateKnowledgeBaseContract, } from '@/lib/api/contracts/v2/knowledge' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { - performDeleteKnowledgeBase, - performUpdateKnowledgeBase, -} from '@/lib/knowledge/orchestration' + defineV2JsonRoute, + type V2ErrorPolicy, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { PlatformEvents } from '@/lib/core/telemetry' +import { + deleteKnowledgeBaseOperation, + readKnowledgeBase, + updateKnowledgeBaseOperation, +} from '@/lib/knowledge/application/knowledge-bases' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { formatKnowledgeBase, resolveKnowledgeBase } from '@/app/api/v1/knowledge/utils' -import type { RateLimitResult } from '@/app/api/v1/middleware' -import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { v2Data, v2Error, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' +import { v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * Resolves a knowledge base via the shared v1 ownership invariant - * ({@link resolveKnowledgeBase}: workspace access + KB-belongs-to-workspace) and - * renders any failure in the v2 envelope. A `404` (missing KB or workspace - * mismatch) is always `NOT_FOUND`; a `403` (no workspace access) is masked as - * `NOT_FOUND` on reads so cross-workspace KB existence never leaks, and surfaced - * as `FORBIDDEN` on writes. - */ -async function resolveKnowledgeBaseScoped( - id: string, - workspaceId: string, - userId: string, - rateLimit: RateLimitResult, - level: 'read' | 'write' -): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { - const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) - if (!(result instanceof NextResponse)) return result - if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') - return level === 'read' - ? v2Error('NOT_FOUND', 'Knowledge base not found') - : v2Error('FORBIDDEN', 'Access denied') +function toV2KnowledgeBase(knowledgeBase: KnowledgeBaseWithCounts, folderPath: string) { + return { + id: knowledgeBase.id, + name: knowledgeBase.name, + description: knowledgeBase.description, + tokenCount: knowledgeBase.tokenCount, + embeddingModel: knowledgeBase.embeddingModel, + embeddingDimension: knowledgeBase.embeddingDimension, + chunkingConfig: { + maxSize: knowledgeBase.chunkingConfig.maxSize, + minSize: knowledgeBase.chunkingConfig.minSize, + overlap: knowledgeBase.chunkingConfig.overlap, + strategy: knowledgeBase.chunkingConfig.strategy, + strategyOptions: knowledgeBase.chunkingConfig.strategyOptions + ? { + pattern: knowledgeBase.chunkingConfig.strategyOptions.pattern, + separators: knowledgeBase.chunkingConfig.strategyOptions.separators, + recipe: knowledgeBase.chunkingConfig.strategyOptions.recipe, + strictBoundaries: knowledgeBase.chunkingConfig.strategyOptions.strictBoundaries, + } + : undefined, + }, + docCount: knowledgeBase.docCount, + connectorTypes: knowledgeBase.connectorTypes, + createdAt: knowledgeBase.createdAt.toISOString(), + updatedAt: knowledgeBase.updatedAt.toISOString(), + folderPath, + } } +const concealKnowledgeBaseReadAuthorization = { + render(error) { + const response = v2OrchestrationErrorPolicy.render(error) + if (response?.status === 403) return v2Error('NOT_FOUND', 'Knowledge base not found') + return response + }, +} satisfies V2ErrorPolicy + /** GET /api/v2/knowledge/[id] — Get knowledge base details. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetKnowledgeBaseContract, - rateLimitEndpoint: 'knowledge-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { id } = input.params - const result = await resolveKnowledgeBaseScoped( - id, - input.query.workspaceId, - userId, - rateLimit, - 'read' - ) - if (result instanceof NextResponse) return result - - const folderIndex = await loadActiveFolderPathIndex(input.query.workspaceId, 'knowledge_base') - - return v2Data( - { - knowledgeBase: { - ...formatKnowledgeBase(result.kb), - folderPath: folderPathForId(folderIndex, result.kb.folderId), - }, - }, - { rateLimit } - ) - }, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.read, + rateLimit: v2RateLimits.publicApi, + errorPolicy: concealKnowledgeBaseReadAuthorization, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.id, + assertedWorkspaceId: query.workspaceId, + }), + useCase: readKnowledgeBase, + present: ({ knowledgeBase, folderPath }) => ({ + data: { knowledgeBase: toV2KnowledgeBase(knowledgeBase, folderPath) }, + }), }) /** PUT /api/v2/knowledge/[id] — Update a knowledge base. */ -export const PUT = withPublicApiRouteHandler({ +export const PUT = defineV2JsonRoute({ contract: v2UpdateKnowledgeBaseContract, - rateLimitEndpoint: 'knowledge-detail', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - const { id } = input.params - const { workspaceId, name, description, chunkingConfig, folderPath } = input.body - - const result = await resolveKnowledgeBaseScoped(id, workspaceId, userId, rateLimit, 'write') - if (result instanceof NextResponse) return result - - const resolution = - folderPath === undefined - ? undefined - : await resolveFolderPathIdentity({ - workspaceId, - resourceType: 'knowledge_base', - path: folderPath, - }) - if (resolution && !resolution.found) { - return v2Error('NOT_FOUND', 'Folder not found') - } - - const outcome = await performUpdateKnowledgeBase({ - knowledgeBaseId: id, - workspaceId, - userId, - source: 'api', - updates: { name, description, chunkingConfig, folderId: resolution?.folderId }, - requestId, - request, - }) - if (!outcome.success) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error) - } - - const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') - return v2Data( - { - knowledgeBase: { - ...formatKnowledgeBase(outcome.knowledgeBase), - folderPath: folderPathForId(folderIndex, outcome.knowledgeBase.folderId), - }, - }, - { rateLimit } - ) + auth: v2ApiKeyAuth, + operation: knowledgeOperations.update, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + parseOptions: { + invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), }, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.id, + assertedWorkspaceId: body.workspaceId, + name: body.name, + description: body.description, + chunkingConfig: body.chunkingConfig, + folderPath: body.folderPath, + source: 'api', + }), + useCase: updateKnowledgeBaseOperation, + present: ({ knowledgeBase, folderPath }) => ({ + data: { knowledgeBase: toV2KnowledgeBase(knowledgeBase, folderPath) }, + }), }) /** DELETE /api/v2/knowledge/[id] — Delete a knowledge base. */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteKnowledgeBaseContract, - rateLimitEndpoint: 'knowledge-detail', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - const { id } = input.params - const result = await resolveKnowledgeBaseScoped( - id, - input.query.workspaceId, - userId, - rateLimit, - 'write' - ) - if (result instanceof NextResponse) return result - - const outcome = await performDeleteKnowledgeBase({ - knowledgeBase: { id, name: result.kb.name, workspaceId: input.query.workspaceId }, - userId, - source: 'api', - requestId, - request, - }) - if (!outcome.success) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error) - } - - return v2Data({ id, deleted: true as const }, { rateLimit }) + auth: v2ApiKeyAuth, + operation: knowledgeOperations.delete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.id, + assertedWorkspaceId: query.workspaceId, + source: 'api', + }), + useCase: deleteKnowledgeBaseOperation, + onSuccess: ({ result }) => { + PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: result.id }) }, + present: ({ id }) => ({ data: { id, deleted: true as const } }), }) diff --git a/apps/sim/app/api/v2/knowledge/folders/route.ts b/apps/sim/app/api/v2/knowledge/folders/route.ts index 3d5ff54a79a..aac1e97d802 100644 --- a/apps/sim/app/api/v2/knowledge/folders/route.ts +++ b/apps/sim/app/api/v2/knowledge/folders/route.ts @@ -5,123 +5,98 @@ import { v2RelocateKnowledgeFolderContract, } from '@/lib/api/contracts/v2/knowledge' import { - createFolderAtPath, - deleteFolderByPath, - relocateFolderByPath, -} from '@/lib/folders/orchestration' -import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toFolderPathView } from '@/lib/folders/paths' import { - resolveFolderPathId, - toV2PathFolder, - v2FolderPathMutationError, -} from '@/app/api/v2/lib/folders' -import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' + createKnowledgeFolder, + deleteKnowledgeFolder, + listKnowledgeFolders, + relocateKnowledgeFolder, +} from '@/lib/knowledge/application/folders' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListKnowledgeFoldersContract, - rateLimitEndpoint: 'knowledge', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, parentPath, search, sortBy, sortOrder } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') - const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) - if (parentPath !== undefined && parentId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found') - } - const rows = await listActiveFolderRows(workspaceId, 'knowledge_base', { - parentId, - search, - sortBy, - sortOrder, - }) - return v2CursorList( - rows.map((row) => toV2PathFolder(row, index, false)), - null, - { rateLimit } - ) - }, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.listFolders, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + parentPath: query.parentPath, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + }), + useCase: listKnowledgeFolders, + present: ({ folders }) => ({ + data: folders.map((folder) => toFolderPathView(folder, folder.path)), + nextCursor: null, + }), }) -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateKnowledgeFolderContract, - rateLimitEndpoint: 'knowledge', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await createFolderAtPath({ - resourceType: 'knowledge_base', - workspaceId, - userId, - path, - }) - if (!result.success || !result.folder) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') - return v2Data( - { folder: toV2PathFolder(result.folder, index, false) }, - { rateLimit, status: 201 } - ) + auth: v2ApiKeyAuth, + operation: knowledgeOperations.createFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + parseOptions: { + invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), }, + mapInput: ({ body }) => ({ workspaceId: body.workspaceId, path: body.path, source: 'api' }), + useCase: createKnowledgeFolder, + present: ({ folder }) => ({ data: { folder: toFolderPathView(folder, folder.path) } }), }) -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2RelocateKnowledgeFolderContract, - rateLimitEndpoint: 'knowledge', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path, destinationPath } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await relocateFolderByPath({ - resourceType: 'knowledge_base', - workspaceId, - userId, - path, - destinationPath, - }) - if (!result.success || !result.folder) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') - return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit }) + auth: v2ApiKeyAuth, + operation: knowledgeOperations.relocateFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + parseOptions: { + invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), }, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + path: body.path, + destinationPath: body.destinationPath, + source: 'api', + }), + useCase: relocateKnowledgeFolder, + present: ({ folder }) => ({ data: { folder: toFolderPathView(folder, folder.path) } }), }) -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteKnowledgeFolderContract, - rateLimitEndpoint: 'knowledge', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path, recursive } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await deleteFolderByPath({ - resourceType: 'knowledge_base', - workspaceId, - userId, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.deleteFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + path: query.path, + recursive: query.recursive, + source: 'api', + }), + useCase: deleteKnowledgeFolder, + present: ({ path, deletedItems }) => ({ + data: { path, - recursive, - }) - if (!result.success || !result.deletedItems) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') - } - return v2Data( - { - path, - deleted: true as const, - deletedItems: { - folders: result.deletedItems.folders, - knowledgeBases: result.deletedItems.knowledgeBases ?? 0, - }, + deleted: true as const, + deletedItems: { + folders: deletedItems.folders, + knowledgeBases: deletedItems.knowledgeBases ?? 0, }, - { rateLimit } - ) - }, + }, + }), }) diff --git a/apps/sim/app/api/v2/knowledge/route.test.ts b/apps/sim/app/api/v2/knowledge/route.test.ts index bdd087b441e..9bdc6765bdc 100644 --- a/apps/sim/app/api/v2/knowledge/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/route.test.ts @@ -1,157 +1,192 @@ /** * @vitest-environment node - * - * Public v2 knowledge-base list: the search/filter/sort convention reaching the - * lib rather than being applied over its result. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const { + mockAuthenticate, + mockCheckPreAuth, mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetKnowledgeBases, - mockLoadActiveFolderPathIndex, + mockList, + mockCreate, + mockPlatformCreated, + mockCapture, } = vi.hoisted(() => ({ + mockAuthenticate: vi.fn(), + mockCheckPreAuth: vi.fn(), mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetKnowledgeBases: vi.fn(), - mockLoadActiveFolderPathIndex: vi.fn(), + mockList: vi.fn(), + mockCreate: vi.fn(), + mockPlatformCreated: vi.fn(), + mockCapture: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mockAuthenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/lib/knowledge/service', () => ({ - getKnowledgeBases: mockGetKnowledgeBases, +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect(...args: unknown[]) { + return mockCheckPreAuth(...args) + } + + checkRateLimitDirectOrThrow(...args: unknown[]) { + return mockCheckRateLimit(...args) + } + }, })) -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, -})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null) })) -vi.mock('@/lib/knowledge/orchestration', () => ({ - performCreateKnowledgeBase: vi.fn(), +vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ + listKnowledgeBases: { operation: { id: 'knowledge.list' }, execute: mockList }, + createKnowledgeBase: { operation: { id: 'knowledge.create' }, execute: mockCreate }, })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { knowledgeBaseCreated: mockPlatformCreated }, })) -import { GET } from '@/app/api/v2/knowledge/route' +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCapture })) -const WS = 'workspace-1' -const FOLDER_ID = 'fold_1' +import { GET, POST } from '@/app/api/v2/knowledge/route' +const WORKSPACE_ID = 'workspace-1' const RATE_LIMIT_OK = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, remaining: 99, resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 0, } -/** What the route forwards for a bare `?workspaceId=` list. */ -const DEFAULT_LIST_ARGS = { - folderId: undefined, - search: undefined, - sortBy: 'createdAt', - sortOrder: 'asc', -} - -function buildKnowledgeBase(overrides: Record<string, unknown> = {}) { +function buildKnowledgeBase() { return { - id: 'kb_1', + id: 'kb-1', userId: 'user-1', name: 'Support docs', description: null, tokenCount: 0, embeddingModel: 'text-embedding-3-small', embeddingDimension: 1536, - chunkingConfig: { maxSize: 1024, minSize: 1, overlap: 200 }, - workspaceId: WS, + chunkingConfig: { maxSize: 1024, minSize: 100, overlap: 200 }, + workspaceId: WORKSPACE_ID, folderId: null, docCount: 2, + connectorTypes: ['notion'], createdAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-02T00:00:00Z'), deletedAt: null, - ...overrides, } } -const callList = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/knowledge?${query}`)) - -describe('GET /api/v2/knowledge', () => { +describe('/api/v2/knowledge route composition', () => { beforeEach(() => { vi.clearAllMocks() + mockCheckPreAuth.mockResolvedValue(RATE_LIMIT_OK) mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetKnowledgeBases.mockResolvedValue([buildKnowledgeBase()]) - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map([['fold_1', { id: 'fold_1', name: 'Support', parentId: null }]]), - pathById: new Map([['fold_1', '/Support']]), - idByPath: new Map([['/Support', 'fold_1']]), + mockAuthenticate.mockResolvedValue({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + mockList.mockResolvedValue({ + knowledgeBases: [{ knowledgeBase: buildKnowledgeBase(), folderPath: '/' }], }) + mockCreate.mockResolvedValue({ knowledgeBase: buildKnowledgeBase(), folderPath: '/' }) }) - it('forwards search, folder, and sort into the query rather than filtering the result', async () => { - const res = await callList( - `workspaceId=${WS}&search=support&folderPath=${encodeURIComponent('/Support')}&sortBy=name&sortOrder=desc` + it('delegates the bounded list query with the authenticated principal', async () => { + const request = new NextRequest( + `http://localhost/api/v2/knowledge?workspaceId=${WORKSPACE_ID}&search=support&folderPath=%2F&sortBy=name&sortOrder=desc`, + { headers: { 'x-api-key': 'secret' } } ) - expect(res.status).toBe(200) - expect(mockGetKnowledgeBases).toHaveBeenCalledWith('user-1', WS, 'active', { - folderId: FOLDER_ID, - search: 'support', - sortBy: 'name', - sortOrder: 'desc', + const response = await GET(request) + + expect(response.status).toBe(200) + expect(mockList).toHaveBeenCalledWith({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { + workspaceId: WORKSPACE_ID, + folderPath: '/', + search: 'support', + sortBy: 'name', + sortOrder: 'desc', + }, + request, }) - }) - - it('defaults to the createdAt ordering when no sort is requested', async () => { - await callList(`workspaceId=${WS}`) - - expect(mockGetKnowledgeBases).toHaveBeenCalledWith('user-1', WS, 'active', DEFAULT_LIST_ARGS) - }) - - it('treats folderPath=/ as root-only while omission lists every folder', async () => { - await callList(`workspaceId=${WS}&folderPath=%2F`) - - expect(mockGetKnowledgeBases).toHaveBeenCalledWith('user-1', WS, 'active', { - ...DEFAULT_LIST_ARGS, - folderId: null, + expect(await response.json()).toEqual({ + data: [ + expect.objectContaining({ + id: 'kb-1', + folderPath: '/', + connectorTypes: ['notion'], + createdAt: '2024-01-01T00:00:00.000Z', + }), + ], + nextCursor: null, }) }) - it('400s on a sort field outside the enum instead of letting it reach the query', async () => { - const res = await callList(`workspaceId=${WS}&sortBy=name);--`) - - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockGetKnowledgeBases).not.toHaveBeenCalled() - }) - - it('400s on a sort direction outside the enum', async () => { - const res = await callList(`workspaceId=${WS}&sortOrder=sideways`) + it('returns 201 and keeps human analytics on the personal-key actor', async () => { + const request = new NextRequest('http://localhost/api/v2/knowledge', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, name: 'Support docs' }), + }) - expect(res.status).toBe(400) - expect(mockGetKnowledgeBases).not.toHaveBeenCalled() + const response = await POST(request) + + expect(response.status).toBe(201) + expect(mockCreate).toHaveBeenCalledWith({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { + workspaceId: WORKSPACE_ID, + name: 'Support docs', + description: undefined, + chunkingConfig: { maxSize: 1024, minSize: 100, overlap: 200 }, + folderPath: undefined, + source: 'api', + }, + request, + }) + expect(mockPlatformCreated).toHaveBeenCalledWith({ + knowledgeBaseId: 'kb-1', + name: 'Support docs', + workspaceId: WORKSPACE_ID, + }) + expect(mockCapture).toHaveBeenCalledWith( + 'user-1', + 'knowledge_base_created', + expect.objectContaining({ workspace_id: WORKSPACE_ID }), + expect.any(Object) + ) }) - it('400s on an empty search rather than treating it as unsearched', async () => { - const res = await callList(`workspaceId=${WS}&search=`) - - expect(res.status).toBe(400) - expect(mockGetKnowledgeBases).not.toHaveBeenCalled() - }) + it('does not attribute workspace-key creation analytics to a billing owner', async () => { + mockAuthenticate.mockResolvedValue({ + principal: { kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'key-2' }, + rolloutUserId: 'billing-owner', + rateLimitSubjectIds: ['api-key:key-2', `workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace', + }) + const request = new NextRequest('http://localhost/api/v2/knowledge', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, name: 'Support docs' }), + }) - it('terminates pagination with a filter applied', async () => { - const res = await callList(`workspaceId=${WS}&search=support`) + const response = await POST(request) - expect((await res.json()).nextCursor).toBeNull() + expect(response.status).toBe(201) + expect(mockPlatformCreated).toHaveBeenCalledOnce() + expect(mockCapture).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index 8b20a6b7d3d..900cbb06ee3 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -2,101 +2,122 @@ import { v2CreateKnowledgeBaseContract, v2ListKnowledgeBasesContract, } from '@/lib/api/contracts/v2/knowledge' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { performCreateKnowledgeBase } from '@/lib/knowledge/orchestration' -import { getKnowledgeBases } from '@/lib/knowledge/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { formatKnowledgeBase } from '@/app/api/v1/knowledge/utils' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { - folderPathForId, - resolveFolderPathId, - resolveFolderPathIdentity, -} from '@/app/api/v2/lib/folders' + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { PlatformEvents } from '@/lib/core/telemetry' import { - v2CursorList, - v2Data, - v2Error, - v2ErrorForOrchestration, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' + createKnowledgeBase, + listKnowledgeBases, +} from '@/lib/knowledge/application/knowledge-bases' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { captureServerEvent } from '@/lib/posthog/server' +import { v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +function toV2KnowledgeBase(knowledgeBase: KnowledgeBaseWithCounts, folderPath: string) { + return { + id: knowledgeBase.id, + name: knowledgeBase.name, + description: knowledgeBase.description, + tokenCount: knowledgeBase.tokenCount, + embeddingModel: knowledgeBase.embeddingModel, + embeddingDimension: knowledgeBase.embeddingDimension, + chunkingConfig: { + maxSize: knowledgeBase.chunkingConfig.maxSize, + minSize: knowledgeBase.chunkingConfig.minSize, + overlap: knowledgeBase.chunkingConfig.overlap, + strategy: knowledgeBase.chunkingConfig.strategy, + strategyOptions: knowledgeBase.chunkingConfig.strategyOptions + ? { + pattern: knowledgeBase.chunkingConfig.strategyOptions.pattern, + separators: knowledgeBase.chunkingConfig.strategyOptions.separators, + recipe: knowledgeBase.chunkingConfig.strategyOptions.recipe, + strictBoundaries: knowledgeBase.chunkingConfig.strategyOptions.strictBoundaries, + } + : undefined, + }, + docCount: knowledgeBase.docCount, + connectorTypes: knowledgeBase.connectorTypes, + createdAt: knowledgeBase.createdAt.toISOString(), + updatedAt: knowledgeBase.updatedAt.toISOString(), + folderPath, + } +} + /** GET /api/v2/knowledge — List knowledge bases in a workspace. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListKnowledgeBasesContract, - rateLimitEndpoint: 'knowledge', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, folderPath, search, sortBy, sortOrder } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') - const folderId = - folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) - if (folderPath !== undefined && folderId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found') - } - - const knowledgeBases = await getKnowledgeBases(userId, workspaceId, 'active', { - folderId, - search, - sortBy, - sortOrder, - }) - const items = knowledgeBases.map((knowledgeBase) => ({ - ...formatKnowledgeBase(knowledgeBase), - folderPath: folderPathForId(folderIndex, knowledgeBase.folderId), - })) - - // `getKnowledgeBases` returns the full bounded workspace set → single page. - return v2CursorList(items, null, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.list, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + folderPath: query.folderPath, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + }), + useCase: listKnowledgeBases, + present: ({ knowledgeBases }) => ({ + data: knowledgeBases.map(({ knowledgeBase, folderPath }) => + toV2KnowledgeBase(knowledgeBase, folderPath) + ), + nextCursor: null, + }), }) /** POST /api/v2/knowledge — Create a new knowledge base. */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateKnowledgeBaseContract, - rateLimitEndpoint: 'knowledge', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - const { workspaceId, name, description, chunkingConfig, folderPath } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const resolution = await resolveFolderPathIdentity({ - workspaceId, - resourceType: 'knowledge_base', - path: folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - - const outcome = await performCreateKnowledgeBase({ - userId, - source: 'api', - workspaceId, - name, - description, - chunkingConfig, - folderId: resolution.folderId, - requestId, - request, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.create, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + parseOptions: { + invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), + }, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + name: body.name, + description: body.description, + chunkingConfig: body.chunkingConfig, + folderPath: body.folderPath, + source: 'api', + }), + useCase: createKnowledgeBase, + onSuccess: ({ principal, result: { knowledgeBase } }) => { + PlatformEvents.knowledgeBaseCreated({ + knowledgeBaseId: knowledgeBase.id, + name: knowledgeBase.name, + workspaceId: knowledgeBase.workspaceId ?? undefined, }) - if (!outcome.success) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error) - } - - return v2Data( - { - knowledgeBase: { - ...formatKnowledgeBase(outcome.knowledgeBase), - folderPath: folderPathForId(resolution.index, outcome.knowledgeBase.folderId), + if (principal.kind === 'personal_api_key') { + captureServerEvent( + principal.userId, + 'knowledge_base_created', + { + knowledge_base_id: knowledgeBase.id, + workspace_id: knowledgeBase.workspaceId ?? '', + name: knowledgeBase.name, }, - }, - { rateLimit, status: 201 } - ) + { + ...(knowledgeBase.workspaceId + ? { groups: { workspace: knowledgeBase.workspaceId } } + : {}), + setOnce: { first_kb_created_at: new Date().toISOString() }, + } + ) + } }, + present: ({ knowledgeBase, folderPath }) => ({ + data: { knowledgeBase: toV2KnowledgeBase(knowledgeBase, folderPath) }, + }), }) diff --git a/apps/sim/app/api/v2/knowledge/search/route.test.ts b/apps/sim/app/api/v2/knowledge/search/route.test.ts new file mode 100644 index 00000000000..462518ba38f --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/search/route.test.ts @@ -0,0 +1,145 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAuthenticate, mockCheckPreAuth, mockCheckRateLimit, mockSearch } = vi.hoisted(() => ({ + mockAuthenticate: vi.fn(), + mockCheckPreAuth: vi.fn(), + mockCheckRateLimit: vi.fn(), + mockSearch: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mockAuthenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect(...args: unknown[]) { + return mockCheckPreAuth(...args) + } + + checkRateLimitDirectOrThrow(...args: unknown[]) { + return mockCheckRateLimit(...args) + } + }, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null) })) + +vi.mock('@/lib/knowledge/application/search', () => ({ + searchKnowledge: { operation: { id: 'knowledge.search' }, execute: mockSearch }, +})) + +import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' +import { POST } from '@/app/api/v2/knowledge/search/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'key-1' } as const +const RATE_LIMIT_OK = { + allowed: true, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 0, +} + +function buildRequest(body: string) { + return new NextRequest('http://localhost/api/v2/knowledge/search', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body, + }) +} + +describe('POST /api/v2/knowledge/search', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckPreAuth.mockResolvedValue(RATE_LIMIT_OK) + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockAuthenticate.mockResolvedValue({ + principal: PRINCIPAL, + rolloutUserId: 'billing-owner', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace', + }) + mockSearch.mockResolvedValue({ + results: [ + { + documentId: 'doc-1', + documentName: 'support.txt', + sourceUrl: null, + content: 'hello', + chunkIndex: 0, + metadata: {}, + similarity: 0.9, + }, + ], + query: 'hello', + knowledgeBaseIds: ['kb-1'], + topK: 10, + totalResults: 1, + }) + }) + + it('delegates normalized IDs through the semantic operation', async () => { + const request = buildRequest( + JSON.stringify({ + workspaceId: WORKSPACE_ID, + knowledgeBaseIds: 'kb-1', + query: 'hello', + topK: 10, + }) + ) + + const response = await POST(request) + + expect(response.status).toBe(200) + expect(mockSearch).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + knowledgeBaseIds: ['kb-1'], + query: 'hello', + topK: 10, + tagFilters: undefined, + }, + request, + }) + expect(await response.json()).toEqual({ + data: expect.objectContaining({ knowledgeBaseIds: ['kb-1'], totalResults: 1 }), + }) + }) + + it('authenticates before rejecting malformed JSON', async () => { + const response = await POST(buildRequest('{')) + + expect(response.status).toBe(400) + expect(mockAuthenticate).toHaveBeenCalledOnce() + expect(mockSearch).not.toHaveBeenCalled() + }) + + it('maps usage failures without exposing infrastructure details', async () => { + mockSearch.mockRejectedValue(new KnowledgeUsageLimitExceededError('Upgrade required')) + + const response = await POST( + buildRequest( + JSON.stringify({ + workspaceId: WORKSPACE_ID, + knowledgeBaseIds: ['kb-1'], + query: 'hello', + topK: 10, + }) + ) + ) + + expect(response.status).toBe(402) + expect(await response.json()).toEqual({ + error: { code: 'USAGE_LIMIT_EXCEEDED', message: 'Upgrade required' }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index dc6f3fdc829..3aa8d98f158 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -1,271 +1,74 @@ -import { - type V2KnowledgeSearchResult, - v2SearchKnowledgeContract, -} from '@/lib/api/contracts/v2/knowledge' -import { isZodError } from '@/lib/api/server' -import { - checkAttributedUsageLimits, - resolveBillingAttribution, - resolveSystemBillingAttribution, -} from '@/lib/billing/core/billing-attribution' -import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' -import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' -import { - executeKnowledgeSearch, - generateSearchEmbedding, - getDocumentMetadataByIds, - type SearchResult, -} from '@/lib/knowledge/search/queries' -import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' -import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' -import type { StructuredFilter } from '@/lib/knowledge/types' -import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { - v2Data, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { v2SearchKnowledgeContract } from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { v2ApiKeyAuth, v2OrchestrationErrorPolicy, v2RateLimits } from '@/lib/api/server/routes' +import type { JsonRouteContext } from '@/lib/api/server/routes/types' +import { admitV2Request, V2RouteInfrastructureError } from '@/lib/api/server/routes/v2-json-route' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { searchKnowledge } from '@/lib/knowledge/application/search' +import { v2Error, v2ValidationError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** POST /api/v2/knowledge/search — Vector / tag search across knowledge bases. */ -export const POST = withPublicApiRouteHandler({ - contract: v2SearchKnowledgeContract, - rateLimitEndpoint: 'knowledge-search', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { workspaceId, topK, query, tagFilters } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - /** - * A query incurs hosted embedding (+ optional rerank) cost — gate the - * actor's usage before spending; tag-only search is free. Workspace keys - * resolve their system actor and immutable payer from one workspace read. - */ - const hasBillableQuery = Boolean(query?.trim()) - const billingAttribution = hasBillableQuery - ? rateLimit.keyType === 'workspace' - ? await resolveSystemBillingAttribution(workspaceId) - : await resolveBillingAttribution({ actorUserId: userId, workspaceId }) - : undefined - const billingActorUserId = billingAttribution?.actorUserId ?? userId - if (billingAttribution) { - const usage = await checkAttributedUsageLimits(billingAttribution) - if (usage.isExceeded) { - return v2Error( - 'USAGE_LIMIT_EXCEEDED', - usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' - ) - } - } - - const knowledgeBaseIds = Array.isArray(input.body.knowledgeBaseIds) - ? input.body.knowledgeBaseIds - : [input.body.knowledgeBaseIds] - - const accessChecks = await Promise.all( - knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId)) +export const POST = withRouteHandler<JsonRouteContext | undefined>( + async (request: NextRequest, context) => { + if (request.method !== v2SearchKnowledgeContract.method) { + throw new Error( + `Route received ${request.method} for ${v2SearchKnowledgeContract.method} contract ${v2SearchKnowledgeContract.path}` ) - const accessibleKbs = accessChecks - .filter( - (ac): ac is KnowledgeBaseAccessResult => - ac.hasAccess === true && ac.knowledgeBase.workspaceId === workspaceId - ) - .map((ac) => ac.knowledgeBase) - const accessibleKbIds = accessibleKbs.map((kb) => kb.id) - - if (accessibleKbIds.length === 0) { - return v2Error('NOT_FOUND', 'Knowledge base not found or access denied') - } - - const inaccessibleKbIds = knowledgeBaseIds.filter((id) => !accessibleKbIds.includes(id)) - if (inaccessibleKbIds.length > 0) { - return v2Error( - 'NOT_FOUND', - `Knowledge bases not found or access denied: ${inaccessibleKbIds.join(', ')}` - ) - } - - let structuredFilters: StructuredFilter[] = [] - const tagDefsCache = new Map<string, Awaited<ReturnType<typeof getDocumentTagDefinitions>>>() - - if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 1) { - return v2Error( - 'BAD_REQUEST', - 'Tag filters are only supported when searching a single knowledge base' - ) - } - - if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 0) { - const kbId = accessibleKbIds[0] - const tagDefs = await getDocumentTagDefinitions(kbId) - tagDefsCache.set(kbId, tagDefs) - - const displayNameToTagDef: Record<string, { tagSlot: string; fieldType: string }> = {} - tagDefs.forEach((def) => { - displayNameToTagDef[def.displayName] = { - tagSlot: def.tagSlot, - fieldType: def.fieldType, - } - }) - - const undefinedTags: string[] = [] - const typeErrors: string[] = [] - - for (const filter of tagFilters) { - const tagDef = displayNameToTagDef[filter.tagName] - if (!tagDef) { - undefinedTags.push(filter.tagName) - continue - } - const validationError = validateTagValue( - filter.tagName, - String(filter.value), - tagDef.fieldType - ) - if (validationError) { - typeErrors.push(validationError) - } - } - - if (undefinedTags.length > 0 || typeErrors.length > 0) { - const errorParts: string[] = [] - if (undefinedTags.length > 0) { - errorParts.push(buildUndefinedTagsError(undefinedTags)) - } - if (typeErrors.length > 0) { - errorParts.push(...typeErrors) - } - return v2Error('BAD_REQUEST', errorParts.join('\n')) - } - - structuredFilters = tagFilters.map((filter) => { - const tagDef = displayNameToTagDef[filter.tagName]! - return { - tagSlot: tagDef.tagSlot, - fieldType: tagDef.fieldType, - operator: filter.operator, - value: filter.value, - valueTo: filter.valueTo, - } - }) - } - - const hasQuery = Boolean(query && query.trim().length > 0) - const hasFilters = structuredFilters.length > 0 - - const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel))) - if (hasQuery && embeddingModels.length > 1) { - return v2Error( - 'BAD_REQUEST', - 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.' - ) - } - const queryEmbeddingModel = embeddingModels[0] - - if (!hasQuery && !hasFilters) { - return v2Error('BAD_REQUEST', 'Either query or tagFilters must be provided') - } - - let queryEmbeddingIsBYOK: boolean | null = null - let queryVector: string | undefined - - if (hasQuery) { - const queryEmbeddingResult = await generateSearchEmbedding( - query!, - queryEmbeddingModel, - workspaceId - ) - queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK - queryVector = JSON.stringify(queryEmbeddingResult.embedding) - } - - const results: SearchResult[] = await executeKnowledgeSearch({ - knowledgeBaseIds: accessibleKbIds, - topK, - searchMode: 'vector', - query, - queryVector, - structuredFilters, - }) - - if (queryEmbeddingIsBYOK !== null) { - await recordSearchEmbeddingUsage({ - userId: billingActorUserId, - workspaceId, - embeddingModel: queryEmbeddingModel, - query: query!, - isBYOK: queryEmbeddingIsBYOK, - sourceReference: `v2-kb-search:${requestId}`, - billingAttribution, - }) - } + } - const tagDefsResults = await Promise.all( - accessibleKbIds.map(async (kbId) => { - try { - const tagDefs = tagDefsCache.get(kbId) ?? (await getDocumentTagDefinitions(kbId)) - const map: Record<string, string> = {} - tagDefs.forEach((def) => { - map[def.tagSlot] = def.displayName - }) - return { kbId, map } - } catch { - return { kbId, map: {} as Record<string, string> } - } - }) - ) - const tagDefinitionsMap: Record<string, Record<string, string>> = {} - tagDefsResults.forEach(({ kbId, map }) => { - tagDefinitionsMap[kbId] = map + const admission = await admitV2Request( + request, + knowledgeOperations.search, + v2ApiKeyAuth, + v2RateLimits.publicApi + ) + if (!admission.success) return admission.response + + const parsed = await parseRequest(v2SearchKnowledgeContract, request, context ?? {}, { + validationErrorResponse: v2ValidationError, + invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), + }) + if (!parsed.success) return parsed.response + + const { body } = parsed.data + try { + const result = await searchKnowledge.execute({ + principal: admission.auth.principal, + input: { + workspaceId: body.workspaceId, + knowledgeBaseIds: Array.isArray(body.knowledgeBaseIds) + ? body.knowledgeBaseIds + : [body.knowledgeBaseIds], + query: body.query, + topK: body.topK, + tagFilters: body.tagFilters, + }, + request, }) - - const documentIds = results.map((r) => r.documentId) - const documentMetadataMap = await getDocumentMetadataByIds(documentIds) - - const searchResults: V2KnowledgeSearchResult[] = results.map((result) => { - const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} - const metadata: Record<string, unknown> = {} - - ALL_TAG_SLOTS.forEach((slot) => { - const tagValue = result[slot as keyof SearchResult] - if (tagValue !== null && tagValue !== undefined) { - const displayName = kbTagMap[slot] || slot - metadata[displayName] = tagValue - } - }) - - const docMeta = documentMetadataMap[result.documentId] - return { - documentId: result.documentId, - documentName: docMeta?.filename ?? null, - sourceUrl: docMeta?.sourceUrl ?? null, - content: result.content, - chunkIndex: result.chunkIndex, - metadata, - similarity: hasQuery ? 1 - result.distance : 1, - } + const responseBody = v2SearchKnowledgeContract.response.schema.parse({ data: result }) + return NextResponse.json(responseBody, { + headers: { 'Cache-Control': 'private, no-store' }, }) - - return v2Data( - { - results: searchResults, - query: query || '', - knowledgeBaseIds: accessibleKbIds, - topK, - totalResults: results.length, - }, - { rateLimit } - ) } catch (error) { - if (isZodError(error)) return v2ValidationError(error) + if (error instanceof KnowledgeUsageLimitExceededError) { + return v2Error('USAGE_LIMIT_EXCEEDED', error.message) + } + const response = v2OrchestrationErrorPolicy.render(error) + if (response) return response throw error } }, -}) + { + unhandledErrorResponse: ({ error }) => + error instanceof V2RouteInfrastructureError + ? v2Error('SERVICE_UNAVAILABLE', 'Service temporarily unavailable') + : v2Error('INTERNAL_ERROR', 'Internal server error'), + } +) diff --git a/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts b/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts index 01340182786..b37ad76428b 100644 --- a/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts +++ b/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts @@ -21,6 +21,7 @@ export const createKnowledgeDocumentUploadContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2CreateKnowledgeDocumentUploadDataSchema), + status: 201, }, }) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/knowledge.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/knowledge.test.ts new file mode 100644 index 00000000000..23960193484 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/knowledge.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { + v2CreateKnowledgeBaseContract, + v2CreateKnowledgeDocumentUploadContract, + v2CreateKnowledgeFolderContract, + v2SearchKnowledgeContract, + v2UploadKnowledgeDocumentContract, +} from '@/lib/api/contracts/v2/knowledge' + +describe('v2 knowledge contracts', () => { + it('declares 201 for every resource-creation response', () => { + expect(v2CreateKnowledgeBaseContract.response.status).toBe(201) + expect(v2CreateKnowledgeFolderContract.response.status).toBe(201) + expect(v2UploadKnowledgeDocumentContract.response.status).toBe(201) + expect(v2CreateKnowledgeDocumentUploadContract.response.status).toBe(201) + }) + + it('preserves knowledge search bounds', () => { + const valid = v2SearchKnowledgeContract.body?.safeParse({ + workspaceId: 'workspace-1', + knowledgeBaseIds: Array.from({ length: 20 }, (_, index) => `kb-${index}`), + query: 'support', + topK: 100, + }) + const tooManyKnowledgeBases = v2SearchKnowledgeContract.body?.safeParse({ + workspaceId: 'workspace-1', + knowledgeBaseIds: Array.from({ length: 21 }, (_, index) => `kb-${index}`), + query: 'support', + topK: 100, + }) + const excessiveTopK = v2SearchKnowledgeContract.body?.safeParse({ + workspaceId: 'workspace-1', + knowledgeBaseIds: ['kb-1'], + query: 'support', + topK: 101, + }) + + expect(valid?.success).toBe(true) + expect(tooManyKnowledgeBases?.success).toBe(false) + expect(excessiveTopK?.success).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 5ffc9ee7580..4ee31c3b933 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -307,6 +307,7 @@ export const v2CreateKnowledgeBaseContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2KnowledgeBaseDataSchema), + status: 201, }, }) @@ -362,7 +363,7 @@ export const v2CreateKnowledgeFolderContract = defineRouteContract({ method: 'POST', path: '/api/v2/knowledge/folders', body: v2CreateFolderBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2KnowledgeFolderDataSchema) }, + response: { mode: 'json', schema: v2DataResponse(v2KnowledgeFolderDataSchema), status: 201 }, }) export const v2RelocateKnowledgeFolderContract = defineRouteContract({ @@ -418,6 +419,7 @@ export const v2UploadKnowledgeDocumentContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentSummaryDataSchema), + status: 201, }, }) @@ -429,6 +431,7 @@ export const v2CreateKnowledgeDocumentUploadContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2CreateKnowledgeDocumentUploadDataSchema), + status: 201, }, }) diff --git a/apps/sim/lib/copilot/application/execute-knowledge-use-case.ts b/apps/sim/lib/copilot/application/execute-knowledge-use-case.ts new file mode 100644 index 00000000000..f9f9fb162f2 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-knowledge-use-case.ts @@ -0,0 +1,65 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { OperationUseCase } from '@/lib/core/application' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { createKnowledgeDelegatedPrincipal } from '@/lib/knowledge/application/delegated-principal' +import { + type KnowledgeOperation, + knowledgeOperations, +} from '@/lib/knowledge/application/operations' + +export interface CopilotKnowledgeDelegationContext { + userId: string + workspaceId?: string + chatId?: string + executionId?: string + toolCallId?: string + copilotToolExecution?: boolean +} + +const registeredKnowledgeOperationIds = new Set<string>( + Object.values(knowledgeOperations).map((operation) => operation.id) +) + +/** Normalizes immutable Copilot execution identity into a knowledge delegation. */ +export function resolveCopilotKnowledgePrincipal( + context: CopilotKnowledgeDelegationContext | undefined +): DelegatedPrincipal { + if (!context) throw new Error('Knowledge delegation requires a Copilot execution context') + if (!context.copilotToolExecution) { + throw new Error('Knowledge delegation requires a trusted Copilot execution context') + } + if (!context.userId) throw new Error('Knowledge delegation requires an authenticated user ID') + if (!context.workspaceId) throw new Error('Knowledge delegation requires a workspace ID') + if (!context.toolCallId) throw new Error('Knowledge delegation requires a tool call ID') + + return createKnowledgeDelegatedPrincipal({ + serviceId: 'copilot', + subjectUserId: context.userId, + workspaceId: context.workspaceId, + delegationId: context.toolCallId, + chatId: context.chatId, + executionId: context.executionId, + }) +} + +/** Enters a registered knowledge application use case with trusted Copilot identity. */ +export function executeCopilotKnowledgeUseCase<O extends KnowledgeOperation, I, R>( + context: CopilotKnowledgeDelegationContext | undefined, + useCase: OperationUseCase<O, I, R>, + input: I +): Promise<R> { + if (!registeredKnowledgeOperationIds.has(useCase.operation.id)) { + throw new Error(`Unregistered Copilot knowledge operation: ${useCase.operation.id}`) + } + return useCase.execute({ principal: resolveCopilotKnowledgePrincipal(context), input }) +} + +/** Projects only caller-actionable application errors into a Copilot result. */ +export function messageForCopilotKnowledgeError( + error: unknown, + fallback = 'Knowledge operation failed' +): string { + const classified = asOrchestrationError(error) + if (classified && classified.code !== 'internal') return classified.message + return fallback +} diff --git a/apps/sim/lib/copilot/tools/handlers/resources.test.ts b/apps/sim/lib/copilot/tools/handlers/resources.test.ts index f470d47e6da..6b5f8595ea2 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.test.ts @@ -4,10 +4,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { listAllWorkspaceFilesMock, readWorkspaceFileMetadataMock } = vi.hoisted(() => ({ - listAllWorkspaceFilesMock: vi.fn(), - readWorkspaceFileMetadataMock: vi.fn(), -})) +const { listAllWorkspaceFilesMock, readKnowledgeBaseMock, readWorkspaceFileMetadataMock } = + vi.hoisted(() => ({ + listAllWorkspaceFilesMock: vi.fn(), + readKnowledgeBaseMock: vi.fn(), + readWorkspaceFileMetadataMock: vi.fn(), + })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ findWorkspaceFileRecord: ( @@ -37,8 +39,11 @@ vi.mock('@/lib/table/service', () => ({ getTableById: vi.fn(), })) -vi.mock('@/lib/knowledge/service', () => ({ - getKnowledgeBaseById: vi.fn(), +vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ + readKnowledgeBase: { + operation: { id: 'knowledge.read' }, + execute: readKnowledgeBaseMock, + }, })) vi.mock('@/lib/logs/service', () => ({ @@ -136,4 +141,55 @@ describe('executeOpenResource', () => { ], }) }) + + it('opens a knowledge base through trusted application delegation', async () => { + readKnowledgeBaseMock.mockResolvedValue({ + knowledgeBase: { id: 'kb-1', name: 'Product Docs', workspaceId: 'workspace-1' }, + folderPath: '/', + }) + + const result = await executeOpenResource( + { resources: [{ type: 'knowledgebase', id: 'kb-1' }] }, + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-1', + copilotToolExecution: true, + } + ) + + expect(readKnowledgeBaseMock).toHaveBeenCalledWith( + expect.objectContaining({ + principal: expect.objectContaining({ + kind: 'delegated', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-1', + }), + input: { knowledgeBaseId: 'kb-1', assertedWorkspaceId: 'workspace-1' }, + }) + ) + expect(result).toMatchObject({ + success: true, + resources: [{ type: 'knowledgebase', id: 'kb-1', title: 'Product Docs' }], + }) + }) + + it('propagates knowledge application infrastructure failures', async () => { + readKnowledgeBaseMock.mockRejectedValueOnce(new Error('knowledge database unavailable')) + + await expect( + executeOpenResource( + { resources: [{ type: 'knowledgebase', id: 'kb-1' }] }, + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-1', + copilotToolExecution: true, + } + ) + ).rejects.toThrow('knowledge database unavailable') + }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/resources.ts b/apps/sim/lib/copilot/tools/handlers/resources.ts index 6a5e5556d4b..743c67b7784 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.ts @@ -1,8 +1,10 @@ import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' +import { executeCopilotKnowledgeUseCase } from '@/lib/copilot/application/execute-knowledge-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { type MothershipResource, MothershipResourceType } from '@/lib/copilot/resources/types' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' -import { getKnowledgeBaseById } from '@/lib/knowledge/service' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { readKnowledgeBase } from '@/lib/knowledge/application/knowledge-bases' import { getLogById } from '@/lib/logs/service' import { getTableById } from '@/lib/table/service' import { @@ -77,10 +79,27 @@ async function resolveResource( } if (resourceType === 'knowledgebase') { if (!item.id) return { error: 'knowledgebase resources require `id`.' } - const kb = await getKnowledgeBaseById(item.id) - if (!kb) return { error: `No knowledge base with id "${item.id}".` } - if (context.workspaceId && kb.workspaceId !== context.workspaceId) - return { error: `Knowledge base not found in the current workspace.` } + if (!context.workspaceId) { + return { error: 'Opening a knowledge base requires workspace context.' } + } + let kb: Awaited<ReturnType<typeof readKnowledgeBase.execute>>['knowledgeBase'] + try { + const result = await executeCopilotKnowledgeUseCase(context, readKnowledgeBase, { + knowledgeBaseId: item.id, + assertedWorkspaceId: context.workspaceId, + }) + kb = result.knowledgeBase + } catch (error) { + const classified = asOrchestrationError(error) + if ( + classified?.code === 'not_found' || + classified?.code === 'forbidden' || + classified?.code === 'unauthorized' + ) { + return { error: 'Knowledge base not found in the current workspace.' } + } + throw error + } resourceId = kb.id title = kb.name } diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index 9e3665e809a..4dd79f4fffc 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -33,9 +33,10 @@ const mocks = vi.hoisted(() => ({ verifyFolderWorkspace: vi.fn(), listTables: vi.fn(), renameTable: vi.fn(), - getKnowledgeBases: vi.fn(), + listKnowledgeBases: vi.fn(), updateKnowledgeBase: vi.fn(), - checkKnowledgeBaseWriteAccess: vi.fn(), + deleteKnowledgeBase: vi.fn(), + knowledgeBaseDeleted: vi.fn(), })) vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) @@ -151,17 +152,28 @@ vi.mock('@/lib/table/service', () => ({ renameTable: mocks.renameTable, })) -vi.mock('@/lib/knowledge/service', () => ({ - getKnowledgeBases: mocks.getKnowledgeBases, - updateKnowledgeBase: mocks.updateKnowledgeBase, +vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ + listKnowledgeBases: { + operation: { id: 'knowledge.list' }, + execute: mocks.listKnowledgeBases, + }, + updateKnowledgeBaseOperation: { + operation: { id: 'knowledge.update' }, + execute: mocks.updateKnowledgeBase, + }, + deleteKnowledgeBaseOperation: { + operation: { id: 'knowledge.delete' }, + execute: mocks.deleteKnowledgeBase, + }, })) -vi.mock('@/app/api/knowledge/utils', () => ({ - checkKnowledgeBaseWriteAccess: mocks.checkKnowledgeBaseWriteAccess, +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { knowledgeBaseDeleted: mocks.knowledgeBaseDeleted }, })) import type { ExecutionContext } from '@/lib/copilot/request/types' -import { executeVfsCp, executeVfsMkdir, executeVfsMv } from './vfs-mutate' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { executeVfsCp, executeVfsMkdir, executeVfsMv, executeVfsRm } from './vfs-mutate' const context = { userId: 'user-1', @@ -594,25 +606,69 @@ describe('vfs mv/cp', () => { expect(result.error).toContain('cannot be copied') }) - it('renames a knowledge base after a write-access check', async () => { - mocks.getKnowledgeBases.mockResolvedValue([{ id: 'kb-1', name: 'Docs' }]) - mocks.checkKnowledgeBaseWriteAccess.mockResolvedValue({ hasAccess: true }) - mocks.updateKnowledgeBase.mockResolvedValue({ id: 'kb-1', name: 'Product Docs' }) + it('renames a knowledge base through trusted application operations', async () => { + mocks.listKnowledgeBases.mockResolvedValue({ + knowledgeBases: [{ knowledgeBase: { id: 'kb-1', name: 'Docs' }, folderPath: '/' }], + }) + mocks.updateKnowledgeBase.mockResolvedValue({ + knowledgeBase: { id: 'kb-1', name: 'Product Docs' }, + folderPath: '/', + }) const result = await executeVfsMv( { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/Product Docs' }, context ) - expect(mocks.checkKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-1', 'user-1') expect(mocks.updateKnowledgeBase).toHaveBeenCalledWith( - 'kb-1', - { name: 'Product Docs' }, - expect.any(String) + expect.objectContaining({ + principal: expect.objectContaining({ + kind: 'delegated', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'tool-call-1', + }), + input: { + knowledgeBaseId: 'kb-1', + assertedWorkspaceId: 'ws-1', + name: 'Product Docs', + source: 'agent', + }, + }) ) expect(result.success).toBe(true) }) + it('propagates knowledge application infrastructure failures', async () => { + mocks.listKnowledgeBases.mockRejectedValueOnce(new Error('knowledge database unavailable')) + + await expect( + executeVfsMv( + { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/Product Docs' }, + context + ) + ).rejects.toThrow('knowledge database unavailable') + }) + + it('preserves an actionable knowledge rename conflict', async () => { + mocks.listKnowledgeBases.mockResolvedValue({ + knowledgeBases: [{ knowledgeBase: { id: 'kb-1', name: 'Docs' }, folderPath: '/' }], + }) + mocks.updateKnowledgeBase.mockRejectedValue( + new OrchestrationError('conflict', 'A knowledge base named Product Docs already exists') + ) + + const result = await executeVfsMv( + { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/Product Docs' }, + context + ) + + expect(result).toMatchObject({ + success: false, + error: 'A knowledge base named Product Docs already exists', + }) + }) + it('rejects the reserved knowledgebases/connectors name', async () => { const result = await executeVfsMv( { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/connectors' }, @@ -621,5 +677,54 @@ describe('vfs mv/cp', () => { expect(result.success).toBe(false) expect(result.error).toContain('reserved') }) + + it('deletes a knowledge base through the trusted application operation', async () => { + mocks.listKnowledgeBases.mockResolvedValue({ + knowledgeBases: [{ knowledgeBase: { id: 'kb-1', name: 'Docs' }, folderPath: '/' }], + }) + mocks.deleteKnowledgeBase.mockResolvedValue({ id: 'kb-1', name: 'Docs' }) + + const result = await executeVfsRm({ paths: ['knowledgebases/Docs'] }, context) + + expect(result).toMatchObject({ + success: true, + output: { results: [{ from: 'knowledgebases/Docs', id: 'kb-1' }] }, + }) + expect(mocks.deleteKnowledgeBase).toHaveBeenCalledWith( + expect.objectContaining({ + principal: expect.objectContaining({ delegationId: 'tool-call-1' }), + input: { + knowledgeBaseId: 'kb-1', + assertedWorkspaceId: 'ws-1', + source: 'agent', + }, + }) + ) + expect(mocks.knowledgeBaseDeleted).toHaveBeenCalledWith({ knowledgeBaseId: 'kb-1' }) + }) + + it('preserves an actionable knowledge delete failure', async () => { + mocks.listKnowledgeBases.mockResolvedValue({ + knowledgeBases: [{ knowledgeBase: { id: 'kb-1', name: 'Docs' }, folderPath: '/' }], + }) + mocks.deleteKnowledgeBase.mockRejectedValue( + new OrchestrationError('not_found', 'Knowledge base no longer exists') + ) + + const result = await executeVfsRm({ paths: ['knowledgebases/Docs'] }, context) + + expect(result).toMatchObject({ + success: false, + error: 'Knowledge base no longer exists', + output: { + results: [ + expect.objectContaining({ + from: 'knowledgebases/Docs', + error: 'Knowledge base no longer exists', + }), + ], + }, + }) + }) }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index d0a4779348c..961545fb299 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -7,6 +7,11 @@ import { executeCopilotFileUseCase, resolveCopilotWorkspaceFileReference, } from '@/lib/copilot/application/execute-file-use-case' +import { + executeCopilotKnowledgeUseCase, + messageForCopilotKnowledgeError, + resolveCopilotKnowledgePrincipal, +} from '@/lib/copilot/application/execute-knowledge-use-case' import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { ensureWorkflowAccess, ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' @@ -22,13 +27,14 @@ import { encodeVfsPathSegments, } from '@/lib/copilot/vfs/path-utils' import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { createFolder, deleteFolder, updateFolder } from '@/lib/folders/orchestration' import { - deleteKnowledgeBase, - getKnowledgeBases, - updateKnowledgeBase, -} from '@/lib/knowledge/service' + deleteKnowledgeBaseOperation, + listKnowledgeBases, + updateKnowledgeBaseOperation, +} from '@/lib/knowledge/application/knowledge-bases' import { performDeleteTable, performRenameTable } from '@/lib/table/orchestration' import { listTables } from '@/lib/table/service' import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' @@ -42,7 +48,6 @@ import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/applicati import { fileOperations } from '@/lib/workspace-files/application/operations' import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' import { updateWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' -import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('VfsMutateTools') @@ -78,6 +83,21 @@ interface VfsMutateOutcome { error?: string } +class KnowledgeVfsInfrastructureError extends Error { + constructor(readonly infrastructureCause: unknown) { + super('Knowledge VFS infrastructure failure') + this.name = 'KnowledgeVfsInfrastructureError' + } +} + +function messageForKnowledgeVfsError(error: unknown, forbiddenMessage: string): string { + const classified = asOrchestrationError(error) + if (!classified || classified.code === 'internal') { + throw new KnowledgeVfsInfrastructureError(error) + } + return classified.code === 'forbidden' ? forbiddenMessage : messageForCopilotKnowledgeError(error) +} + /** Top-level VFS segment of a raw (possibly encoded) path. */ function topLevelSegment(path: string): string { return path.trim().replace(/^\/+/, '').split('/')[0] ?? '' @@ -239,6 +259,9 @@ async function executeVfsMutate( } const workspaceId = requireCopilotWorkspace(context) + if (topLevelSegment(sources[0]) === 'knowledgebases') { + resolveCopilotKnowledgePrincipal(context) + } await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertMutationNotAborted(context) @@ -274,6 +297,9 @@ async function executeVfsMutate( return await renameFlatResource(verb, category, sources, destination, context, workspaceId) } } catch (error) { + if (error instanceof KnowledgeVfsInfrastructureError) { + throw error.infrastructureCause + } return { success: false, error: context.abortSignal?.aborted @@ -837,20 +863,41 @@ async function renameFlatResource( if (newName.toLowerCase() === 'connectors') { return { success: false, error: '"knowledgebases/connectors" is a reserved path.' } } - const kbs = await getKnowledgeBases(context.userId, workspaceId) - const match = kbs.find((kb) => normalizeVfsSegment(kb.name) === canonicalSource) + let knowledgeBases: Awaited<ReturnType<typeof listKnowledgeBases.execute>>['knowledgeBases'] + try { + const result = await executeCopilotKnowledgeUseCase(context, listKnowledgeBases, { + workspaceId, + }) + knowledgeBases = result.knowledgeBases + } catch (error) { + return { + success: false, + error: messageForKnowledgeVfsError(error, 'Write access required to rename knowledge bases'), + } + } + const match = knowledgeBases + .map(({ knowledgeBase }) => knowledgeBase) + .find((kb) => normalizeVfsSegment(kb.name) === canonicalSource) if (!match) { return { success: false, error: `Knowledge base not found at ${sources[0]}` } } - const access = await checkKnowledgeBaseWriteAccess(match.id, context.userId) - if (!access.hasAccess) { + assertMutationNotAborted(context) + try { + await executeCopilotKnowledgeUseCase(context, updateKnowledgeBaseOperation, { + knowledgeBaseId: match.id, + assertedWorkspaceId: workspaceId, + name: newName, + source: 'agent', + }) + } catch (error) { return { success: false, - error: `Write access required to rename knowledge base "${match.name}"`, + error: messageForKnowledgeVfsError( + error, + `Write access required to rename knowledge base "${match.name}"` + ), } } - assertMutationNotAborted(context) - await updateKnowledgeBase(match.id, { name: newName }, generateRequestId()) logger.info('Renamed knowledge base via mv', { knowledgeBaseId: match.id, workspaceId }) return buildResult(verb, [ { from: sources[0], to: `knowledgebases/${normalizeVfsSegment(newName)}`, kind, id: match.id }, @@ -877,6 +924,9 @@ export async function executeVfsRm( } const workspaceId = requireCopilotWorkspace(context) + if (paths.some((path) => topLevelSegment(path) === 'knowledgebases')) { + resolveCopilotKnowledgePrincipal(context) + } await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertMutationNotAborted(context) @@ -897,6 +947,7 @@ export async function executeVfsRm( await removeOne(classified.category, path, context, workspaceId, getWorkflowIndex) ) } catch (error) { + if (error instanceof KnowledgeVfsInfrastructureError) throw error outcomes.push({ from: path, kind: defaultKindFor(path), @@ -910,6 +961,9 @@ export async function executeVfsRm( return buildResult('rm', outcomes) } catch (error) { + if (error instanceof KnowledgeVfsInfrastructureError) { + throw error.infrastructureCause + } return { success: false, error: context.abortSignal?.aborted @@ -1139,23 +1193,43 @@ async function removeKnowledgeBasePath( error: '"knowledgebases/connectors" is a reserved path, not a knowledge base.', } } - const match = (await getKnowledgeBases(context.userId, workspaceId)).find( - (kb) => normalizeVfsSegment(kb.name) === canonical - ) + let knowledgeBases: Awaited<ReturnType<typeof listKnowledgeBases.execute>>['knowledgeBases'] + try { + const result = await executeCopilotKnowledgeUseCase(context, listKnowledgeBases, { + workspaceId, + }) + knowledgeBases = result.knowledgeBases + } catch (error) { + return { + from: path, + kind: 'knowledge_base', + error: messageForKnowledgeVfsError(error, 'Write access required to delete knowledge bases'), + } + } + const match = knowledgeBases + .map(({ knowledgeBase }) => knowledgeBase) + .find((kb) => normalizeVfsSegment(kb.name) === canonical) if (!match) return { from: path, kind: 'knowledge_base', error: `Knowledge base not found at ${path}` } - const access = await checkKnowledgeBaseWriteAccess(match.id, context.userId) - if (!access.hasAccess) { + try { + await executeCopilotKnowledgeUseCase(context, deleteKnowledgeBaseOperation, { + knowledgeBaseId: match.id, + assertedWorkspaceId: workspaceId, + source: 'agent', + }) + } catch (error) { return { from: path, kind: 'knowledge_base', id: match.id, - error: `Write access required to delete knowledge base "${match.name}"`, + error: messageForKnowledgeVfsError( + error, + `Write access required to delete knowledge base "${match.name}"` + ), } } - - await deleteKnowledgeBase(match.id, generateRequestId()) + PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: match.id }) logger.info('Deleted knowledge base via rm', { knowledgeBaseId: match.id, workspaceId }) return { from: path, kind: 'knowledge_base', id: match.id } } diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts index 3d2d82ed9bd..21284cb77ed 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -75,7 +75,13 @@ function makeVfs() { } } -const GREP_CTX = { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } +const GREP_CTX = { + userId: 'user-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + toolCallId: 'tool-1', + copilotToolExecution: true, +} const GREP_CTX_CHAT = { ...GREP_CTX, chatId: 'chat-1' } describe('vfs handlers oversize policy', () => { @@ -89,10 +95,7 @@ describe('vfs handlers oversize policy', () => { vfs.grep.mockReturnValue([{ path: 'files/a.txt', line: 1, content: OVERSIZED_INLINE_CONTENT }]) getOrMaterializeVFS.mockResolvedValue(vfs) - const result = await executeVfsGrep( - { pattern: 'foo', output_mode: 'content' }, - { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } - ) + const result = await executeVfsGrep({ pattern: 'foo', output_mode: 'content' }, GREP_CTX) expect(result.success).toBe(false) expect(result.error).toContain('more specific pattern') @@ -105,10 +108,7 @@ describe('vfs handlers oversize policy', () => { vfs.read.mockReturnValue({ content: OVERSIZED_INLINE_CONTENT, totalLines: 1 }) getOrMaterializeVFS.mockResolvedValue(vfs) - const result = await executeVfsRead( - { path: 'workflows/My Workflow/state.json' }, - { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } - ) + const result = await executeVfsRead({ path: 'workflows/My Workflow/state.json' }, GREP_CTX) expect(result.success).toBe(false) expect(result.error).toContain('Use grep') @@ -124,10 +124,7 @@ describe('vfs handlers oversize policy', () => { }) getOrMaterializeVFS.mockResolvedValue(vfs) - const result = await executeVfsRead( - { path: 'files/big.txt/content' }, - { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } - ) + const result = await executeVfsRead({ path: 'files/big.txt/content' }, GREP_CTX) expect(result.success).toBe(false) expect(result.error).toContain('File too large to display inline') @@ -147,10 +144,7 @@ describe('vfs handlers oversize policy', () => { }) getOrMaterializeVFS.mockResolvedValue(vfs) - const result = await executeVfsRead( - { path: 'files/chess.png/content' }, - { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } - ) + const result = await executeVfsRead({ path: 'files/chess.png/content' }, GREP_CTX) expect(result.success).toBe(true) expect((result.output as { attachment?: { type: string } })?.attachment?.type).toBe('image') @@ -170,10 +164,7 @@ describe('vfs handlers oversize policy', () => { }) getOrMaterializeVFS.mockResolvedValue(vfs) - const result = await executeVfsRead( - { path: 'files/reports/report.pdf/compiled' }, - { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } - ) + const result = await executeVfsRead({ path: 'files/reports/report.pdf/compiled' }, GREP_CTX) expect(result.success).toBe(true) expect((result.output as { attachment?: { type: string } })?.attachment?.type).toBe('file') @@ -187,10 +178,7 @@ describe('vfs handlers oversize policy', () => { }) getOrMaterializeVFS.mockResolvedValue(vfs) - const result = await executeVfsRead( - { path: 'files/huge.png/content' }, - { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } - ) + const result = await executeVfsRead({ path: 'files/huge.png/content' }, GREP_CTX) expect(result.success).toBe(false) expect(result.error).toContain('too large') @@ -204,10 +192,7 @@ describe('vfs handlers oversize policy', () => { }) getOrMaterializeVFS.mockResolvedValue(vfs) - const result = await executeVfsRead( - { path: 'files/report.csv' }, - { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } - ) + const result = await executeVfsRead({ path: 'files/report.csv' }, GREP_CTX) expect(result.success).toBe(true) expect(vfs.readFileContent).not.toHaveBeenCalled() @@ -229,9 +214,18 @@ describe('vfs handlers oversize policy', () => { ) expect(result.success).toBe(true) - expect(getOrMaterializeVFS).toHaveBeenCalledWith('ws-1', 'user-1', { - secretMountPolicy, - }) + expect(getOrMaterializeVFS).toHaveBeenCalledWith( + 'ws-1', + 'user-1', + expect.objectContaining({ + secretMountPolicy, + knowledgePrincipal: expect.objectContaining({ + kind: 'delegated', + delegationId: 'tool-1', + workspaceId: 'ws-1', + }), + }) + ) }) it('uses dynamic file reads for canonical style paths', async () => { @@ -242,10 +236,7 @@ describe('vfs handlers oversize policy', () => { }) getOrMaterializeVFS.mockResolvedValue(vfs) - const result = await executeVfsRead( - { path: 'files/reports/brief.docx/style' }, - { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } - ) + const result = await executeVfsRead({ path: 'files/reports/brief.docx/style' }, GREP_CTX) expect(result.success).toBe(true) expect(vfs.readFileContent).toHaveBeenCalledWith('files/reports/brief.docx/style') @@ -260,10 +251,7 @@ describe('vfs handlers oversize policy', () => { }) getOrMaterializeVFS.mockResolvedValue(vfs) - const result = await executeVfsRead( - { path: 'files/reports/brief.pdf/compiled' }, - { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } - ) + const result = await executeVfsRead({ path: 'files/reports/brief.pdf/compiled' }, GREP_CTX) expect(result.success).toBe(true) expect(vfs.readFileContent).toHaveBeenCalledWith('files/reports/brief.pdf/compiled') diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index 3aade59a519..b3bb30cbe6a 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { resolveCopilotKnowledgePrincipal } from '@/lib/copilot/application/execute-knowledge-use-case' import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants' @@ -30,6 +31,7 @@ const logger = createLogger('VfsTools') async function getGatedVFS(context: ExecutionContext) { const workspaceId = context.workspaceId if (!workspaceId) throw new Error('No workspace context available') + const knowledgePrincipal = resolveCopilotKnowledgePrincipal(context) const vis = await getBlockVisibilityForCopilot(context.userId, workspaceId) const filePrincipal = context.copilotToolExecution && context.toolCallId @@ -39,6 +41,7 @@ async function getGatedVFS(context: ExecutionContext) { getOrMaterializeVFS(workspaceId, context.userId, { secretMountPolicy: context.secretMountPolicy, filePrincipal, + knowledgePrincipal, }) ) } diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index fc3b1a54ae1..7e850b80dd5 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -1,69 +1,68 @@ /** * @vitest-environment node */ -import { knowledgeConnector } from '@sim/db/schema' -import { loggerMock, queueTableRows, resetDbChainMock } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockAssertBillingAttributionSnapshot, - mockCheckKnowledgeBaseWriteAccess, - mockGetKnowledgeBaseById, + mockCaptureServerEvent, + mockCreateKnowledgeBase, + mockDeleteKnowledgeBase, + mockDeleteKnowledgeDocument, mockGetBoundWorkspaceFileSecretProvenance, - mockImportKnowledgeSearchResultSecretProvenance, - mockPerformCreateKnowledgeConnector, - mockPerformDeleteKnowledgeBase, - mockPerformDeleteKnowledgeConnector, - mockPerformSyncKnowledgeConnector, + mockKnowledgeBaseCreated, + mockKnowledgeBaseDeleted, + mockKnowledgeBaseDocumentsUploaded, + mockReadKnowledgeBase, + mockResolveWorkspaceFileReference, + mockSearchKnowledge, + mockUpdateKnowledgeBase, + mockUploadKnowledgeDocument, } = vi.hoisted(() => ({ - mockAssertBillingAttributionSnapshot: vi.fn(), - mockCheckKnowledgeBaseWriteAccess: vi.fn(), - mockGetKnowledgeBaseById: vi.fn(), + mockCaptureServerEvent: vi.fn(), + mockCreateKnowledgeBase: vi.fn(), + mockDeleteKnowledgeBase: vi.fn(), + mockDeleteKnowledgeDocument: vi.fn(), mockGetBoundWorkspaceFileSecretProvenance: vi.fn(), - mockImportKnowledgeSearchResultSecretProvenance: vi.fn(), - mockPerformCreateKnowledgeConnector: vi.fn(), - mockPerformDeleteKnowledgeBase: vi.fn(), - mockPerformDeleteKnowledgeConnector: vi.fn(), - mockPerformSyncKnowledgeConnector: vi.fn(), + mockKnowledgeBaseCreated: vi.fn(), + mockKnowledgeBaseDeleted: vi.fn(), + mockKnowledgeBaseDocumentsUploaded: vi.fn(), + mockReadKnowledgeBase: vi.fn(), + mockResolveWorkspaceFileReference: vi.fn(), + mockSearchKnowledge: vi.fn(), + mockUpdateKnowledgeBase: vi.fn(), + mockUploadKnowledgeDocument: vi.fn(), })) -vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ - checkActorUsageLimits: vi.fn(), -})) -vi.mock('@/lib/billing/core/billing-attribution', () => ({ - assertBillingAttributionSnapshot: mockAssertBillingAttributionSnapshot, - checkAttributedUsageLimits: vi.fn(), -})) vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ KnowledgeBase: { id: 'knowledge_base' }, })) -vi.mock('@/lib/copilot/tools/server/base-tool', () => ({ - assertServerToolNotAborted: vi.fn(), +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { + knowledgeBaseCreated: mockKnowledgeBaseCreated, + knowledgeBaseDeleted: mockKnowledgeBaseDeleted, + knowledgeBaseDocumentsUploaded: mockKnowledgeBaseDocumentsUploaded, + }, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) +vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ + createKnowledgeBase: { execute: mockCreateKnowledgeBase }, + deleteKnowledgeBaseOperation: { execute: mockDeleteKnowledgeBase }, + readKnowledgeBase: { execute: mockReadKnowledgeBase }, + updateKnowledgeBaseOperation: { execute: mockUpdateKnowledgeBase }, +})) +vi.mock('@/lib/knowledge/application/documents', () => ({ + deleteKnowledgeDocument: { execute: mockDeleteKnowledgeDocument }, + uploadKnowledgeDocument: { execute: mockUploadKnowledgeDocument }, })) -vi.mock('@/lib/knowledge/embeddings', () => ({ - generateSearchEmbedding: vi.fn(), - recordSearchEmbeddingUsage: vi.fn(), +vi.mock('@/lib/knowledge/application/search', () => ({ + searchKnowledge: { execute: mockSearchKnowledge }, })) vi.mock('@/lib/knowledge/orchestration', () => ({ - performCreateKnowledgeBase: vi.fn(), - performDeleteKnowledgeBase: mockPerformDeleteKnowledgeBase, - performCreateKnowledgeConnector: mockPerformCreateKnowledgeConnector, - performDeleteKnowledgeConnector: mockPerformDeleteKnowledgeConnector, - performDeleteKnowledgeDocument: vi.fn(), - performSyncKnowledgeConnector: mockPerformSyncKnowledgeConnector, - performUpdateKnowledgeBase: vi.fn(), + performCreateKnowledgeConnector: vi.fn(), + performDeleteKnowledgeConnector: vi.fn(), + performSyncKnowledgeConnector: vi.fn(), performUpdateKnowledgeConnector: vi.fn(), performUpdateKnowledgeDocument: vi.fn(), - performUploadKnowledgeDocument: vi.fn(), -})) -vi.mock('@/lib/knowledge/service', () => ({ - getKnowledgeBaseById: mockGetKnowledgeBaseById, -})) -vi.mock('@/lib/knowledge/secret-provenance', () => ({ - importKnowledgeSearchResultSecretProvenance: mockImportKnowledgeSearchResultSecretProvenance, -})) -vi.mock('@/lib/knowledge/documents/service', () => ({ - createSingleDocument: vi.fn(), })) vi.mock('@/lib/knowledge/tags/service', () => ({ createTagDefinition: vi.fn(), @@ -74,438 +73,388 @@ vi.mock('@/lib/knowledge/tags/service', () => ({ getTagUsageStats: vi.fn(), updateTagDefinition: vi.fn(), })) -vi.mock('@/lib/uploads', () => ({ StorageService: {} })) +vi.mock('@/lib/uploads', () => ({ + StorageService: { generatePresignedDownloadUrl: vi.fn().mockResolvedValue('https://file.test') }, +})) vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ - resolveWorkspaceFileReference: vi.fn(), + resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, })) -vi.mock('@/app/api/auth/oauth/utils', () => ({ getCredential: vi.fn() })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ getBoundWorkspaceFileSecretProvenance: mockGetBoundWorkspaceFileSecretProvenance, })) -vi.mock('@/lib/knowledge/search/queries', () => ({ - executeKnowledgeSearch: vi.fn(), -})) +vi.mock('@/app/api/auth/oauth/utils', () => ({ getCredential: vi.fn() })) vi.mock('@/app/api/knowledge/utils', () => ({ checkDocumentWriteAccess: vi.fn(), checkKnowledgeBaseAccess: vi.fn(), - checkKnowledgeBaseWriteAccess: mockCheckKnowledgeBaseWriteAccess, + checkKnowledgeBaseWriteAccess: vi.fn(), })) -import { checkAttributedUsageLimits } from '@/lib/billing/core/billing-attribution' -import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' +import type { ServerToolContext } from '@/lib/copilot/tools/server/base-tool' import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' -import { createSingleDocument } from '@/lib/knowledge/documents/service' -import { generateSearchEmbedding, recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' -import { executeKnowledgeSearch } from '@/lib/knowledge/search/queries' -import { getKnowledgeBaseById } from '@/lib/knowledge/service' -import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' -import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' -const knowledgeLoggerIndex = loggerMock.createLogger.mock.calls.findIndex( - ([name]) => name === 'KnowledgeBaseServerTool' -) -const knowledgeLogger = loggerMock.createLogger.mock.results[knowledgeLoggerIndex]?.value - -const BILLING_ATTRIBUTION = { - actorUserId: 'external-admin', +const KNOWLEDGE_BASE = { + id: 'knowledge-base-1', + name: 'Private KB', + description: 'Private documentation', workspaceId: 'workspace-paid', - organizationId: 'organization-paid', - billedAccountUserId: 'workspace-owner', - billingEntity: { type: 'organization' as const, id: 'organization-paid' }, - billingPeriod: { - start: '2026-07-01T00:00:00.000Z', - end: '2026-08-01T00:00:00.000Z', - }, - payerSubscription: null, + docCount: 2, + tokenCount: 42, + embeddingModel: 'text-embedding-3-small', + chunkingConfig: { maxSize: 1024, minSize: 100, overlap: 200 }, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-02T00:00:00.000Z'), } const CONTEXT = { userId: 'external-admin', workspaceId: 'workspace-paid', - billingAttribution: BILLING_ATTRIBUTION, -} - -describe('knowledge base connector Copilot operations', () => { - afterAll(() => { - resetDbChainMock() + chatId: 'chat-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} satisfies ServerToolContext + +function expectDelegatedPrincipal(call: unknown): void { + expect(call).toMatchObject({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'external-admin', + workspaceId: 'workspace-paid', + delegationId: 'tool-call-1', + audience: 'sim:knowledge', + resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, + }, }) +} +describe('knowledge_base trusted application delegation', () => { beforeEach(() => { vi.clearAllMocks() - resetDbChainMock() - queueTableRows(knowledgeConnector, [{ knowledgeBaseId: 'knowledge-base-1' }]) - mockAssertBillingAttributionSnapshot.mockReturnValue(BILLING_ATTRIBUTION) - mockCheckKnowledgeBaseWriteAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { - id: 'knowledge-base-1', - workspaceId: 'workspace-paid', - name: 'Paid KB', - }, - }) - mockPerformCreateKnowledgeConnector.mockResolvedValue({ - success: true, - connector: { id: 'connector-1', connectorType: 'notion', status: 'active' }, - }) - mockPerformSyncKnowledgeConnector.mockResolvedValue({ success: true }) - mockPerformDeleteKnowledgeConnector.mockResolvedValue({ - success: true, - documentsDeleted: 0, - documentsKept: 3, + mockReadKnowledgeBase.mockResolvedValue({ knowledgeBase: KNOWLEDGE_BASE, folderPath: '/' }) + mockCreateKnowledgeBase.mockResolvedValue({ knowledgeBase: KNOWLEDGE_BASE, folderPath: '/' }) + mockUpdateKnowledgeBase.mockResolvedValue({ knowledgeBase: KNOWLEDGE_BASE, folderPath: '/' }) + mockDeleteKnowledgeBase.mockResolvedValue({ id: KNOWLEDGE_BASE.id, name: KNOWLEDGE_BASE.name }) + mockSearchKnowledge.mockResolvedValue({ + results: [], + query: 'query', + knowledgeBaseIds: [KNOWLEDGE_BASE.id], + topK: 5, + totalResults: 0, }) + mockDeleteKnowledgeDocument.mockResolvedValue({ id: 'document-1', filename: 'doc.pdf' }) }) it.each([ - { - operation: 'add_connector', - params: { - operation: 'add_connector', - args: { - knowledgeBaseId: 'knowledge-base-1', - connectorType: 'notion', - apiKey: 'api-key', - }, - }, - perform: mockPerformCreateKnowledgeConnector, - }, - { - operation: 'sync_connector', - params: { operation: 'sync_connector', args: { connectorId: 'connector-1' } }, - perform: mockPerformSyncKnowledgeConnector, - }, - ])('forwards immutable billing attribution for $operation', async ({ params, perform }) => { - const result = await knowledgeBaseServerTool.execute(params, CONTEXT) - - expect(result.success).toBe(true) - // The operation runs in-process now. The payer travels as a value on the - // orchestration call rather than as a serialized header on an internal - // HTTP self-call back into this same process. - const call = perform.mock.calls[0][0] - expect(await call.resolveBillingAttribution()).toEqual(BILLING_ATTRIBUTION) - expect(call.source).toBe('agent') - expect(mockAssertBillingAttributionSnapshot).toHaveBeenCalledWith(BILLING_ATTRIBUTION) + [{ ...CONTEXT, copilotToolExecution: false }, 'trusted Copilot execution context'], + [{ ...CONTEXT, workspaceId: undefined }, 'workspace ID'], + [{ ...CONTEXT, toolCallId: undefined }, 'tool call ID'], + [{ ...CONTEXT, userId: '' }, 'authenticated user ID'], + ])('rejects incomplete server-authored context', async (context, message) => { + await expect( + knowledgeBaseServerTool.execute( + { operation: 'get', args: { knowledgeBaseId: KNOWLEDGE_BASE.id } }, + context + ) + ).rejects.toThrow(message) + expect(mockReadKnowledgeBase).not.toHaveBeenCalled() }) - it('reports a failed knowledge base delete as failed, not as missing', async () => { - mockGetKnowledgeBaseById.mockResolvedValue({ - id: 'knowledge-base-1', - name: 'Paid KB', - workspaceId: 'workspace-paid', - }) - mockPerformDeleteKnowledgeBase.mockResolvedValue({ - success: false, - error: 'Knowledge base is locked', - errorCode: 'conflict', - }) - + it('creates in the trusted workspace and ignores a model workspace field', async () => { const result = await knowledgeBaseServerTool.execute( - { operation: 'delete', args: { knowledgeBaseId: 'knowledge-base-1' } }, + { + operation: 'create', + args: { name: 'Private KB', workspaceId: 'model-controlled-workspace' }, + }, CONTEXT ) - // A knowledge base that exists but could not be archived is neither deleted - // nor missing — folding it into notFound told the user it was never there. - expect(result.data.notFound).toEqual([]) - expect(result.data.failed).toEqual([ - { id: 'knowledge-base-1', name: 'Paid KB', reason: 'Knowledge base is locked' }, - ]) - expect(result.message).toContain('Knowledge base is locked') - }) - - it('never relays an unclassified fault to the agent verbatim', async () => { - mockGetKnowledgeBaseById.mockResolvedValue({ - id: 'knowledge-base-1', - name: 'Paid KB', + expect(result.success).toBe(true) + const call = mockCreateKnowledgeBase.mock.calls[0][0] + expectDelegatedPrincipal(call) + expect(call.input).toMatchObject({ workspaceId: 'workspace-paid', + name: 'Private KB', + source: 'agent', }) - mockPerformDeleteKnowledgeBase.mockResolvedValue({ - success: false, - error: 'select "id" from "knowledge_base" — connection terminated', - errorCode: 'internal', + expect(mockKnowledgeBaseCreated).toHaveBeenCalledWith({ + knowledgeBaseId: KNOWLEDGE_BASE.id, + name: KNOWLEDGE_BASE.name, + workspaceId: 'workspace-paid', }) - - const result = await knowledgeBaseServerTool.execute( - { operation: 'delete', args: { knowledgeBaseId: 'knowledge-base-1' } }, - CONTEXT + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'external-admin', + 'knowledge_base_created', + expect.objectContaining({ workspace_id: 'workspace-paid' }), + expect.any(Object) ) - - expect(result.data.failed[0].reason).toBe('Failed to delete knowledge base') - expect(result.message).not.toContain('connection terminated') }) - it('reports that a deleted connector kept its documents, because it did', async () => { + it('reads through the canonical application operation', async () => { const result = await knowledgeBaseServerTool.execute( - { operation: 'delete_connector', args: { connectorId: 'connector-1' } }, + { operation: 'get', args: { knowledgeBaseId: KNOWLEDGE_BASE.id } }, CONTEXT ) - // The old wording claimed the documents "have been removed". They never - // were: the tool reached the route over HTTP with no query string, so the - // route's keep-documents default always applied. expect(result.success).toBe(true) - expect(result.message).toContain('3 document(s) were kept') - expect(result.message).not.toContain('removed') - expect(mockPerformDeleteKnowledgeConnector).toHaveBeenCalledWith( - expect.objectContaining({ connectorId: 'connector-1', source: 'agent' }) - ) - }) -}) - -describe('knowledge base query model boundary', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true }) - vi.mocked(getKnowledgeBaseById).mockResolvedValue({ - id: 'knowledge-base-1', - name: 'Private KB', - workspaceId: 'workspace-paid', - embeddingModel: 'text-embedding-3-small', - } as Awaited<ReturnType<typeof getKnowledgeBaseById>>) - vi.mocked(checkAttributedUsageLimits).mockResolvedValue({ isExceeded: false }) - vi.mocked(generateSearchEmbedding).mockResolvedValue({ - embedding: [0.1, 0.2], - isBYOK: false, - }) - vi.mocked(executeKnowledgeSearch).mockResolvedValue([]) - vi.mocked(recordSearchEmbeddingUsage).mockResolvedValue(undefined) - mockImportKnowledgeSearchResultSecretProvenance.mockResolvedValue({ - imported: true, - documentMetadata: {}, + const call = mockReadKnowledgeBase.mock.calls[0][0] + expectDelegatedPrincipal(call) + expect(call.input).toEqual({ + knowledgeBaseId: KNOWLEDGE_BASE.id, + assertedWorkspaceId: 'workspace-paid', }) }) - it('projects the query at embedding, search, and usage boundaries', async () => { + it('projects query secrets before delegating search and passes only the trusted registry', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'KB_QUERY', - plaintext: 'private knowledge query', + plaintext: 'private query', encryptedValue: 'encrypted-query', }, ]) - registry.recordResolved('KB_QUERY', 'private knowledge query') + registry.recordResolved('KB_QUERY', 'private query') + mockSearchKnowledge.mockResolvedValueOnce({ + results: [ + { + embeddingId: 'embedding-1', + documentId: 'document-1', + documentName: 'doc.pdf', + sourceUrl: null, + content: 'result', + chunkIndex: 0, + metadata: {}, + similarity: 0.9, + }, + ], + query: '{{KB_QUERY}}', + knowledgeBaseIds: [KNOWLEDGE_BASE.id], + topK: 5, + totalResults: 1, + }) const result = await knowledgeBaseServerTool.execute( { operation: 'query', - args: { - knowledgeBaseId: 'knowledge-base-1', - query: 'private knowledge query', - }, + args: { knowledgeBaseId: KNOWLEDGE_BASE.id, query: 'private query' }, }, - { - userId: 'external-admin', - workspaceId: 'workspace-paid', - toolCallId: 'tool-1', - copilotToolExecution: true, - billingAttribution: BILLING_ATTRIBUTION, - resolvedSecretTraceRegistry: registry, - } + { ...CONTEXT, resolvedSecretTraceRegistry: registry } ) - expect(result.success).toBe(true) - expect(result.data?.query).toBe('private knowledge query') - expect(generateSearchEmbedding).toHaveBeenCalledWith( - '{{KB_QUERY}}', - 'text-embedding-3-small', - 'workspace-paid' - ) - expect(executeKnowledgeSearch).toHaveBeenCalledWith( - expect.objectContaining({ query: '{{KB_QUERY}}' }) - ) - expect(recordSearchEmbeddingUsage).toHaveBeenCalledWith( - expect.objectContaining({ query: '{{KB_QUERY}}' }) - ) - expect(mockImportKnowledgeSearchResultSecretProvenance).toHaveBeenCalledWith({ - registry, - results: [], + expect(result).toMatchObject({ + success: true, + data: { query: 'private query', results: [{ similarity: 0.9 }] }, + }) + const call = mockSearchKnowledge.mock.calls[0][0] + expectDelegatedPrincipal(call) + expect(call.input).toEqual({ + workspaceId: 'workspace-paid', + knowledgeBaseIds: [KNOWLEDGE_BASE.id], + query: '{{KB_QUERY}}', + topK: 5, + resultSecretRegistry: registry, }) - expect(knowledgeLogger).toBeDefined() - expect(JSON.stringify(knowledgeLogger?.info.mock.calls)).not.toContain( - 'private knowledge query' - ) }) - it('imports exact persisted result provenance before the Copilot result is projected', async () => { - const registry = new ResolvedSecretTraceRegistry([ - { - name: 'STORED_TOKEN', - plaintext: 'stored-secret-value', - encryptedValue: 'encrypted-stored-secret', - }, - ]) - const results = [ - { - id: 'embedding-1', - documentId: 'document-1', - content: 'stored-secret-value', - chunkIndex: 0, - distance: 0.1, - }, - ] - vi.mocked(executeKnowledgeSearch).mockResolvedValue(results) - mockImportKnowledgeSearchResultSecretProvenance.mockImplementationOnce( - async ({ registry: resultRegistry }) => { - expect(resultRegistry.recordResolved('STORED_TOKEN', 'stored-secret-value')).toBe(true) - return { imported: true, documentMetadata: {} } - } - ) + it('propagates search infrastructure failures', async () => { + mockSearchKnowledge.mockRejectedValueOnce(new Error('database unavailable')) + + await expect( + knowledgeBaseServerTool.execute( + { operation: 'query', args: { knowledgeBaseId: KNOWLEDGE_BASE.id, query: 'query' } }, + { ...CONTEXT, resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry() } + ) + ).rejects.toThrow('database unavailable') + }) + it('updates through the semantic operation', async () => { const result = await knowledgeBaseServerTool.execute( - { - operation: 'query', - args: { - knowledgeBaseId: 'knowledge-base-1', - query: 'public query', - }, - }, - { - userId: 'external-admin', - workspaceId: 'workspace-paid', - toolCallId: 'tool-1', - copilotToolExecution: true, - billingAttribution: BILLING_ATTRIBUTION, - resolvedSecretTraceRegistry: registry, - } + { operation: 'update', args: { knowledgeBaseId: KNOWLEDGE_BASE.id, name: 'Renamed' } }, + CONTEXT ) expect(result.success).toBe(true) - expect(result.data?.results[0].content).toBe('stored-secret-value') - expect(projectToolResultForCopilot({ success: true, output: result }, registry)).toMatchObject({ + const call = mockUpdateKnowledgeBase.mock.calls[0][0] + expectDelegatedPrincipal(call) + expect(call.input).toMatchObject({ + knowledgeBaseId: KNOWLEDGE_BASE.id, + assertedWorkspaceId: 'workspace-paid', + name: 'Renamed', + source: 'agent', + }) + }) + + it('keeps the unexposed delete compatibility path on the shared delete operation', async () => { + const result = await knowledgeBaseServerTool.execute( + { operation: 'delete', args: { knowledgeBaseId: KNOWLEDGE_BASE.id } }, + CONTEXT + ) + + expect(result).toMatchObject({ success: true, - output: { - data: { results: [{ content: '{{STORED_TOKEN}}' }] }, - }, + data: { deleted: [{ id: KNOWLEDGE_BASE.id, name: KNOWLEDGE_BASE.name }] }, + }) + const call = mockDeleteKnowledgeBase.mock.calls[0][0] + expectDelegatedPrincipal(call) + expect(call.input).toEqual({ + knowledgeBaseId: KNOWLEDGE_BASE.id, + assertedWorkspaceId: 'workspace-paid', + source: 'agent', + }) + expect(mockKnowledgeBaseDeleted).toHaveBeenCalledWith({ + knowledgeBaseId: KNOWLEDGE_BASE.id, }) }) - it('fails closed when persisted result provenance cannot be established', async () => { - const registry = new ResolvedSecretTraceRegistry() - vi.mocked(executeKnowledgeSearch).mockResolvedValue([ - { - id: 'embedding-1', - documentId: 'document-1', - content: 'unclassified persisted content', - chunkIndex: 0, - distance: 0.1, + it('keeps classified delete failures in the batch result', async () => { + mockDeleteKnowledgeBase.mockRejectedValueOnce( + new OrchestrationError('conflict', 'Knowledge base is locked') + ) + + const result = await knowledgeBaseServerTool.execute( + { operation: 'delete', args: { knowledgeBaseId: KNOWLEDGE_BASE.id } }, + CONTEXT + ) + + expect(result).toMatchObject({ + success: false, + data: { + notFound: [], + failed: [ + { id: KNOWLEDGE_BASE.id, name: KNOWLEDGE_BASE.name, reason: 'Knowledge base is locked' }, + ], }, - ]) - mockImportKnowledgeSearchResultSecretProvenance.mockResolvedValueOnce({ - imported: false, - documentMetadata: {}, }) + }) + + it('delegates document deletion and retains partial batch results', async () => { + mockDeleteKnowledgeDocument.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Document not found') + ) const result = await knowledgeBaseServerTool.execute( { - operation: 'query', - args: { - knowledgeBaseId: 'knowledge-base-1', - query: 'public query', - }, + operation: 'delete_document', + args: { knowledgeBaseId: KNOWLEDGE_BASE.id, documentIds: ['missing', 'document-1'] }, }, - { - userId: 'external-admin', - workspaceId: 'workspace-paid', - toolCallId: 'tool-1', - copilotToolExecution: true, - billingAttribution: BILLING_ATTRIBUTION, - resolvedSecretTraceRegistry: registry, - } + CONTEXT ) - expect(result).toEqual({ - success: false, - message: 'Failed to query knowledge base: Knowledge result secret provenance is unavailable', + expect(result).toMatchObject({ + success: true, + data: { deleted: ['document-1'], failed: ['missing'] }, }) - expect(registry.isPermanentlyIncomplete()).toBe(true) + expectDelegatedPrincipal(mockDeleteKnowledgeDocument.mock.calls[1][0]) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'external-admin', + 'knowledge_base_document_deleted', + expect.objectContaining({ knowledge_base_id: KNOWLEDGE_BASE.id }), + expect.any(Object) + ) }) + + it.each([ + { + operation: 'add_file', + args: { knowledgeBaseId: KNOWLEDGE_BASE.id, filePaths: Array(101).fill('files/doc.pdf') }, + }, + { + operation: 'delete', + args: { knowledgeBaseIds: Array.from({ length: 101 }, (_, index) => `kb-${index}`) }, + }, + { + operation: 'delete_document', + args: { + knowledgeBaseId: KNOWLEDGE_BASE.id, + documentIds: Array.from({ length: 101 }, (_, index) => `document-${index}`), + }, + }, + ])( + 'rejects oversized $operation batches before application work', + async ({ operation, args }) => { + const result = await knowledgeBaseServerTool.execute({ operation, args }, CONTEXT) + + expect(result.success).toBe(false) + expect(result.message).toContain('Maximum is 100') + expect(mockReadKnowledgeBase).not.toHaveBeenCalled() + expect(mockDeleteKnowledgeBase).not.toHaveBeenCalled() + expect(mockDeleteKnowledgeDocument).not.toHaveBeenCalled() + expect(mockUploadKnowledgeDocument).not.toHaveBeenCalled() + } + ) }) -describe('knowledge base add_file usage gate', () => { +describe('knowledge_base add_file delegation', () => { beforeEach(() => { vi.clearAllMocks() - resetDbChainMock() - mockCheckKnowledgeBaseWriteAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { id: 'knowledge-base-1', workspaceId: 'workspace-paid', name: 'Paid KB' }, + mockReadKnowledgeBase.mockResolvedValue({ knowledgeBase: KNOWLEDGE_BASE, folderPath: '/' }) + mockResolveWorkspaceFileReference.mockResolvedValue({ + id: 'file-1', + key: 'workspace/workspace-paid/report.pdf', + name: 'report.pdf', + size: 100, + type: 'application/pdf', }) - vi.mocked(getKnowledgeBaseById).mockResolvedValue({ - id: 'knowledge-base-1', - workspaceId: 'workspace-paid', - } as Awaited<ReturnType<typeof getKnowledgeBaseById>>) - mockGetBoundWorkspaceFileSecretProvenance.mockResolvedValue({ - status: 'exact', - entries: [], + mockGetBoundWorkspaceFileSecretProvenance.mockResolvedValue({ status: 'exact', entries: [] }) + mockUploadKnowledgeDocument.mockResolvedValue({ + created: true, + document: { + id: 'document-1', + filename: 'report.pdf', + fileSize: 100, + mimeType: 'application/pdf', + }, }) }) - function addFile() { - return knowledgeBaseServerTool.execute( + it('preserves file resolution and performs current admission inside uploadKnowledgeDocument', async () => { + const result = await knowledgeBaseServerTool.execute( { operation: 'add_file', - args: { knowledgeBaseId: 'knowledge-base-1', filePaths: ['files/report.pdf'] }, + args: { knowledgeBaseId: KNOWLEDGE_BASE.id, filePaths: ['files/report.pdf'] }, }, - { - userId: 'external-admin', - workspaceId: 'workspace-paid', - toolCallId: 'tool-1', - copilotToolExecution: true, - billingAttribution: BILLING_ATTRIBUTION, - } + CONTEXT ) - } - - it('refuses to index when the payer is over its usage limit', async () => { - vi.mocked(checkAttributedUsageLimits).mockResolvedValue({ - isExceeded: true, - message: 'Usage limit exceeded.', - } as Awaited<ReturnType<typeof checkAttributedUsageLimits>>) - - const result = await addFile() - - expect(result.success).toBe(false) - expect(result.message).toContain('Usage limit exceeded') - // The gate must precede any indexing work, matching the upload routes. - expect(resolveWorkspaceFileReference).not.toHaveBeenCalled() - expect(createSingleDocument).not.toHaveBeenCalled() - }) - it('gates on the knowledge base workspace payer, not the caller', async () => { - vi.mocked(checkAttributedUsageLimits).mockResolvedValue({ - isExceeded: false, - } as Awaited<ReturnType<typeof checkAttributedUsageLimits>>) - vi.mocked(resolveWorkspaceFileReference).mockResolvedValue(null) - - await addFile() - - expect(checkAttributedUsageLimits).toHaveBeenCalledWith(BILLING_ATTRIBUTION) + expect(result).toMatchObject({ + success: true, + data: { added: [{ documentId: 'document-1', filename: 'report.pdf' }] }, + }) + expect(mockResolveWorkspaceFileReference).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: 'workspace-paid', reference: 'files/report.pdf' }) + ) + const call = mockUploadKnowledgeDocument.mock.calls[0][0] + expectDelegatedPrincipal(call) + expect(call.input).toMatchObject({ + knowledgeBaseId: KNOWLEDGE_BASE.id, + assertedWorkspaceId: 'workspace-paid', + startProcessing: true, + source: 'agent', + document: { filename: 'report.pdf', fileSize: 100, mimeType: 'application/pdf' }, + }) + expect(call.input).not.toHaveProperty('usageAdmission') + expect(mockKnowledgeBaseDocumentsUploaded).toHaveBeenCalledWith( + expect.objectContaining({ knowledgeBaseId: KNOWLEDGE_BASE.id, documentsCount: 1 }) + ) }) - it('does not index a workspace file containing resolved-secret provenance', async () => { - vi.mocked(checkAttributedUsageLimits).mockResolvedValue({ - isExceeded: false, - } as Awaited<ReturnType<typeof checkAttributedUsageLimits>>) - vi.mocked(resolveWorkspaceFileReference).mockResolvedValue({ - id: 'file-1', - key: 'workspace/workspace-paid/report.pdf', - name: 'report.pdf', - size: 100, - type: 'application/pdf', - } as Awaited<ReturnType<typeof resolveWorkspaceFileReference>>) + it('rejects files carrying resolved-secret provenance before durable registration', async () => { mockGetBoundWorkspaceFileSecretProvenance.mockResolvedValueOnce({ status: 'exact', entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], }) - const result = await addFile() + const result = await knowledgeBaseServerTool.execute( + { + operation: 'add_file', + args: { knowledgeBaseId: KNOWLEDGE_BASE.id, filePaths: ['files/report.pdf'] }, + }, + CONTEXT + ) expect(result.success).toBe(false) - expect(mockGetBoundWorkspaceFileSecretProvenance).toHaveBeenCalledWith('workspace-paid', { - fileId: 'file-1', - key: 'workspace/workspace-paid/report.pdf', - context: 'workspace', - }) - expect(createSingleDocument).not.toHaveBeenCalled() + expect(mockUploadKnowledgeDocument).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index c79a4c18da7..924e77186d0 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -6,12 +6,14 @@ import { generateId } from '@sim/utils/id' import { filterUndefined } from '@sim/utils/object' import { truncate } from '@sim/utils/string' import { and, eq, isNull } from 'drizzle-orm' -import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { assertBillingAttributionSnapshot, type BillingAttributionSnapshot, - checkAttributedUsageLimits, } from '@/lib/billing/core/billing-attribution' +import { + messageForCopilotKnowledgeError, + resolveCopilotKnowledgePrincipal, +} from '@/lib/copilot/application/execute-knowledge-use-case' import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { KnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' @@ -22,25 +24,30 @@ import { } from '@/lib/copilot/tools/server/base-tool' import { projectServerToolModelInput } from '@/lib/copilot/tools/server/model-input' import { + asOrchestrationError, messageForOrchestrationError, type OrchestrationErrorCode, } from '@/lib/core/orchestration/types' -import { generateSearchEmbedding, recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' +import { PlatformEvents } from '@/lib/core/telemetry' +import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' +import { + deleteKnowledgeDocument, + uploadKnowledgeDocument, +} from '@/lib/knowledge/application/documents' +import { + createKnowledgeBase, + deleteKnowledgeBaseOperation, + readKnowledgeBase, + updateKnowledgeBaseOperation, +} from '@/lib/knowledge/application/knowledge-bases' +import { searchKnowledge } from '@/lib/knowledge/application/search' import { - performCreateKnowledgeBase, performCreateKnowledgeConnector, - performDeleteKnowledgeBase, performDeleteKnowledgeConnector, - performDeleteKnowledgeDocument, performSyncKnowledgeConnector, - performUpdateKnowledgeBase, performUpdateKnowledgeConnector, performUpdateKnowledgeDocument, - performUploadKnowledgeDocument, } from '@/lib/knowledge/orchestration' -import { executeKnowledgeSearch } from '@/lib/knowledge/search/queries' -import { importKnowledgeSearchResultSecretProvenance } from '@/lib/knowledge/secret-provenance' -import { getKnowledgeBaseById } from '@/lib/knowledge/service' import { createTagDefinition, deleteTagDefinition, @@ -50,6 +57,7 @@ import { getTagUsageStats, updateTagDefinition, } from '@/lib/knowledge/tags/service' +import { captureServerEvent } from '@/lib/posthog/server' import { StorageService } from '@/lib/uploads' import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { fileOperations } from '@/lib/workspace-files/application/operations' @@ -62,6 +70,7 @@ import { } from '@/app/api/knowledge/utils' const logger = createLogger('KnowledgeBaseServerTool') +const MAX_COPILOT_KNOWLEDGE_BATCH_SIZE = 100 function requireKnowledgeBillingAttribution( context: ServerToolContext, @@ -91,6 +100,73 @@ function agentFacingError( return messageForOrchestrationError(outcome, fallback) } +function captureKnowledgeBaseCreated( + userId: string, + workspaceId: string, + knowledgeBase: { id: string; name: string } +): void { + PlatformEvents.knowledgeBaseCreated({ + knowledgeBaseId: knowledgeBase.id, + name: knowledgeBase.name, + workspaceId, + }) + captureServerEvent( + userId, + 'knowledge_base_created', + { + knowledge_base_id: knowledgeBase.id, + workspace_id: workspaceId, + name: knowledgeBase.name, + }, + { + groups: { workspace: workspaceId }, + setOnce: { first_kb_created_at: new Date().toISOString() }, + } + ) +} + +function captureKnowledgeDocumentUploaded( + userId: string, + workspaceId: string, + knowledgeBaseId: string, + document: { mimeType: string; fileSize: number } +): void { + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId, + documentsCount: 1, + uploadType: 'single', + mimeType: document.mimeType, + fileSize: document.fileSize, + }) + captureServerEvent( + userId, + 'knowledge_base_document_uploaded', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: workspaceId, + document_count: 1, + upload_type: 'single', + }, + { + groups: { workspace: workspaceId }, + setOnce: { first_document_uploaded_at: new Date().toISOString() }, + } + ) +} + +function captureKnowledgeDocumentDeleted( + userId: string, + workspaceId: string, + knowledgeBaseId: string +): void { + captureServerEvent( + userId, + 'knowledge_base_document_deleted', + { knowledge_base_id: knowledgeBaseId, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) +} + type KnowledgeBaseArgs = { operation: string args?: Record<string, any> @@ -111,17 +187,10 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg params: KnowledgeBaseArgs, context?: ServerToolContext ): Promise<KnowledgeBaseResult> { - const withMessageId = (message: string) => - context?.messageId ? `${message} [messageId:${context.messageId}]` : message - - if (!context?.userId) { - logger.error('Unauthorized attempt to access knowledge base - no authenticated user context') - throw new Error('Authentication required') - } - + if (!context) throw new Error('Knowledge delegation requires a Copilot execution context') + const principal = resolveCopilotKnowledgePrincipal(context) const { operation, args = {} } = params - const workspaceId = - context.workspaceId || ((args as Record<string, unknown>).workspaceId as string | undefined) + const workspaceId = principal.workspaceId const assertNotAborted = () => assertServerToolNotAborted( context, @@ -156,23 +225,18 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg } } - const requestId = generateId().slice(0, 8) assertNotAborted() - const outcome = await performCreateKnowledgeBase({ - ...actor(requestId), - workspaceId, - name: args.name, - description: args.description, - chunkingConfig: args.chunkingConfig, + const { knowledgeBase: newKnowledgeBase } = await createKnowledgeBase.execute({ + principal, + input: { + workspaceId, + name: args.name, + description: args.description, + chunkingConfig: args.chunkingConfig, + source: 'agent', + }, }) - if (!outcome.success) { - return { - success: false, - message: agentFacingError(outcome, 'Failed to create knowledge base'), - } - } - - const newKnowledgeBase = outcome.knowledgeBase + captureKnowledgeBaseCreated(context.userId, workspaceId, newKnowledgeBase) return { success: true, message: `Knowledge base "${newKnowledgeBase.name}" created successfully`, @@ -195,21 +259,13 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg } } - const access = await checkKnowledgeBaseAccess(args.knowledgeBaseId, context.userId) - if (!access.hasAccess) { - return { - success: false, - message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`, - } - } - - const knowledgeBase = await getKnowledgeBaseById(args.knowledgeBaseId) - if (!knowledgeBase) { - return { - success: false, - message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`, - } - } + const { knowledgeBase } = await readKnowledgeBase.execute({ + principal, + input: { + knowledgeBaseId: args.knowledgeBaseId, + assertedWorkspaceId: workspaceId, + }, + }) logger.info('Knowledge base metadata retrieved via copilot', { knowledgeBaseId: knowledgeBase.id, @@ -249,76 +305,36 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg } } - const access = await checkKnowledgeBaseAccess(args.knowledgeBaseId, context.userId) - if (!access.hasAccess) { - return { - success: false, - message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`, - } - } - - const kb = await getKnowledgeBaseById(args.knowledgeBaseId) - if (!kb) { - return { - success: false, - message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`, - } - } - const topK = args.topK || 5 - - const billingAttribution = kb.workspaceId - ? requireKnowledgeBillingAttribution(context, kb.workspaceId) - : undefined - const usage = billingAttribution - ? await checkAttributedUsageLimits(billingAttribution) - : await checkActorUsageLimits(context.userId) - if (usage.isExceeded) { + const { query: modelQuery } = projectServerToolModelInput({ query: args.query }, context) + if (!context.resolvedSecretTraceRegistry) { return { success: false, message: - usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.', + 'Failed to query knowledge base: Knowledge result secret provenance is unavailable', } } - - const { query: modelQuery } = projectServerToolModelInput({ query: args.query }, context) - const { embedding: queryEmbedding, isBYOK: queryEmbeddingIsBYOK } = - await generateSearchEmbedding(modelQuery, kb.embeddingModel, kb.workspaceId) - const queryVector = JSON.stringify(queryEmbedding) - - const results = await executeKnowledgeSearch({ - knowledgeBaseIds: [args.knowledgeBaseId], - topK, - searchMode: 'vector', - query: modelQuery, - queryVector, - }) - - await recordSearchEmbeddingUsage({ - userId: context.userId, - workspaceId: kb.workspaceId, - embeddingModel: kb.embeddingModel, - query: modelQuery, - isBYOK: queryEmbeddingIsBYOK, - sourceReference: `copilot-kb-search:${args.knowledgeBaseId}`, - billingAttribution, + const { knowledgeBase: kb } = await readKnowledgeBase.execute({ + principal, + input: { + knowledgeBaseId: args.knowledgeBaseId, + assertedWorkspaceId: workspaceId, + }, }) - - const resultRegistry = context.resolvedSecretTraceRegistry - if (!resultRegistry) { - throw new Error('Knowledge result secret provenance is unavailable') - } - const resultProvenance = await importKnowledgeSearchResultSecretProvenance({ - registry: resultRegistry, - results, + const searchResult = await searchKnowledge.execute({ + principal, + input: { + workspaceId, + knowledgeBaseIds: [args.knowledgeBaseId], + query: modelQuery, + topK, + resultSecretRegistry: context.resolvedSecretTraceRegistry, + }, }) - if (!resultProvenance.imported) { - resultRegistry.markIncomplete() - throw new Error('Knowledge result secret provenance is unavailable') - } + const results = searchResult.results logger.info('Knowledge base queried via copilot', { - knowledgeBaseId: args.knowledgeBaseId, + knowledgeBaseIds: [args.knowledgeBaseId], queryLength: args.query.length, resultCount: results.length, userId: context.userId, @@ -337,7 +353,7 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg documentId: result.documentId, content: result.content, chunkIndex: result.chunkIndex, - similarity: 1 - result.distance, + similarity: result.similarity, })), }, } @@ -362,38 +378,20 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg 'filePaths is required for add_file. Use canonical VFS file paths from glob("files/**").', } } - - const writeAccess = await checkKnowledgeBaseWriteAccess( - args.knowledgeBaseId, - context.userId - ) - if (!writeAccess.hasAccess) { + if (fileRefs.length > MAX_COPILOT_KNOWLEDGE_BATCH_SIZE) { return { success: false, - message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`, + message: `Too many files (${fileRefs.length}). Maximum is ${MAX_COPILOT_KNOWLEDGE_BATCH_SIZE}.`, } } - const targetKb = await getKnowledgeBaseById(args.knowledgeBaseId) - if (!targetKb || !targetKb.workspaceId) { - return { - success: false, - message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`, - } - } - - const kbWorkspaceId: string = targetKb.workspaceId - const billingAttribution = requireKnowledgeBillingAttribution(context, kbWorkspaceId) - - // Gate the payer before accepting indexing work, same as the upload routes. - const usage = await checkAttributedUsageLimits(billingAttribution) - if (usage.isExceeded) { - return { - success: false, - message: - usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.', - } - } + const { knowledgeBase: targetKb } = await readKnowledgeBase.execute({ + principal, + input: { + knowledgeBaseId: args.knowledgeBaseId, + assertedWorkspaceId: workspaceId, + }, + }) const added: Array<{ documentId: string; filename: string }> = [] const failedFiles: string[] = [] @@ -405,15 +403,19 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg fileRecord = await resolveWorkspaceFileReference({ principal: filePrincipal, operation: fileOperations.readContent, - workspaceId: kbWorkspaceId, + workspaceId, reference: fileRef, }) - } catch { - failedFiles.push(fileRef) - continue + } catch (error) { + const classified = asOrchestrationError(error) + if (classified && classified.code !== 'internal') { + failedFiles.push(fileRef) + continue + } + throw error } - const fileProvenance = await getBoundWorkspaceFileSecretProvenance(kbWorkspaceId, { + const fileProvenance = await getBoundWorkspaceFileSecretProvenance(workspaceId, { fileId: fileRecord.id, key: fileRecord.key, context: 'workspace', @@ -429,30 +431,41 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg 5 * 60 ) - const requestId = generateId().slice(0, 8) assertNotAborted() - const outcome = await performUploadKnowledgeDocument({ - ...actor(requestId), - knowledgeBase: { - id: args.knowledgeBaseId, - name: targetKb.name, - workspaceId: kbWorkspaceId, - }, - document: { - filename: fileRecord.name, - fileUrl: presignedUrl, - fileSize: fileRecord.size, - mimeType: fileRecord.type, - }, - startProcessing: 'async', - billingAttribution, - }) - if (!outcome.success) { - failedFiles.push(fileRef) - continue + try { + const outcome = await uploadKnowledgeDocument.execute({ + principal, + input: { + knowledgeBaseId: args.knowledgeBaseId, + assertedWorkspaceId: workspaceId, + document: { + filename: fileRecord.name, + fileUrl: presignedUrl, + fileSize: fileRecord.size, + mimeType: fileRecord.type, + }, + startProcessing: true, + source: 'agent', + }, + }) + captureKnowledgeDocumentUploaded( + context.userId, + workspaceId, + args.knowledgeBaseId, + outcome.document + ) + added.push({ documentId: outcome.document.id, filename: fileRecord.name }) + } catch (error) { + if (error instanceof KnowledgeUsageLimitExceededError) { + return { success: false, message: error.message } + } + const classified = asOrchestrationError(error) + if (classified && classified.code !== 'internal') { + failedFiles.push(fileRef) + continue + } + throw error } - - added.push({ documentId: outcome.document.id, filename: fileRecord.name }) } const addedNames = added.map((a) => a.filename).join(', ') @@ -496,33 +509,16 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg } } - const writeAccess = await checkKnowledgeBaseWriteAccess( - args.knowledgeBaseId, - context.userId - ) - if (!writeAccess.hasAccess) { - return { - success: false, - message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`, - } - } - - const requestId = generateId().slice(0, 8) assertNotAborted() - const outcome = await performUpdateKnowledgeBase({ - ...actor(requestId), - knowledgeBaseId: args.knowledgeBaseId, - workspaceId: writeAccess.knowledgeBase.workspaceId ?? null, - updates, + const { knowledgeBase: updatedKb } = await updateKnowledgeBaseOperation.execute({ + principal, + input: { + knowledgeBaseId: args.knowledgeBaseId, + assertedWorkspaceId: workspaceId, + ...updates, + source: 'agent', + }, }) - if (!outcome.success) { - return { - success: false, - message: agentFacingError(outcome, 'Failed to update knowledge base'), - } - } - - const updatedKb = outcome.knowledgeBase return { success: true, message: `Knowledge base "${updatedKb.name}" updated successfully`, @@ -546,6 +542,12 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg message: 'knowledgeBaseId or knowledgeBaseIds is required for delete operation', } } + if (kbIds.length > MAX_COPILOT_KNOWLEDGE_BATCH_SIZE) { + return { + success: false, + message: `Too many knowledge base IDs (${kbIds.length}). Maximum is ${MAX_COPILOT_KNOWLEDGE_BATCH_SIZE}.`, + } + } const deleted: Array<{ id: string; name: string }> = [] const notFound: string[] = [] @@ -555,41 +557,42 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg const failed: Array<{ id: string; name: string; reason: string }> = [] for (const kbId of kbIds) { - const writeAccess = await checkKnowledgeBaseWriteAccess(kbId, context.userId) - if (!writeAccess.hasAccess) { - notFound.push(kbId) - continue - } - - const kbToDelete = await getKnowledgeBaseById(kbId) - if (!kbToDelete) { - notFound.push(kbId) - continue - } - - const requestId = generateId().slice(0, 8) - assertNotAborted() - const outcome = await performDeleteKnowledgeBase({ - ...actor(requestId), - knowledgeBase: { - id: kbId, - name: kbToDelete.name, - workspaceId: kbToDelete.workspaceId, - }, - }) - if (!outcome.success) { - if (outcome.errorCode === 'not_found') { + let knowledgeBaseName = kbId + try { + const readResult = await readKnowledgeBase.execute({ + principal, + input: { knowledgeBaseId: kbId, assertedWorkspaceId: workspaceId }, + }) + knowledgeBaseName = readResult.knowledgeBase.name + assertNotAborted() + const deletedKnowledgeBase = await deleteKnowledgeBaseOperation.execute({ + principal, + input: { + knowledgeBaseId: kbId, + assertedWorkspaceId: workspaceId, + source: 'agent', + }, + }) + PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: kbId }) + deleted.push(deletedKnowledgeBase) + } catch (error) { + const classified = asOrchestrationError(error) + if ( + classified?.code === 'not_found' || + classified?.code === 'forbidden' || + classified?.code === 'unauthorized' + ) { notFound.push(kbId) - } else { + } else if (classified && classified.code !== 'internal') { failed.push({ id: kbId, - name: kbToDelete.name, - reason: agentFacingError(outcome, 'Failed to delete knowledge base'), + name: knowledgeBaseName, + reason: classified.message, }) + } else { + throw error } - continue } - deleted.push({ id: kbId, name: kbToDelete.name }) } const deleteSummary = [ @@ -619,35 +622,37 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg message: 'documentId or documentIds is required for delete_document', } } + if (docIds.length > MAX_COPILOT_KNOWLEDGE_BATCH_SIZE) { + return { + success: false, + message: `Too many document IDs (${docIds.length}). Maximum is ${MAX_COPILOT_KNOWLEDGE_BATCH_SIZE}.`, + } + } const deleted: string[] = [] const failed: string[] = [] for (const docId of docIds) { assertNotAborted() - const docAccess = await checkDocumentWriteAccess( - args.knowledgeBaseId, - docId, - context.userId - ) - if (!docAccess.hasAccess) { - failed.push(docId) - continue - } - const requestId = generateId().slice(0, 8) - const outcome = await performDeleteKnowledgeDocument({ - ...actor(requestId), - knowledgeBase: { - id: args.knowledgeBaseId, - name: docAccess.knowledgeBase.name, - workspaceId: docAccess.knowledgeBase.workspaceId ?? null, - }, - document: docAccess.document, - }) - if (outcome.success) { + try { + await deleteKnowledgeDocument.execute({ + principal, + input: { + knowledgeBaseId: args.knowledgeBaseId, + documentId: docId, + assertedWorkspaceId: workspaceId, + source: 'agent', + }, + }) + captureKnowledgeDocumentDeleted(context.userId, workspaceId, args.knowledgeBaseId) deleted.push(docId) - } else { - failed.push(docId) + } catch (error) { + const classified = asOrchestrationError(error) + if (classified && classified.code !== 'internal') { + failed.push(docId) + continue + } + throw error } } @@ -1206,9 +1211,33 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg userId: context.userId, }) + if (operation === 'query' && context.resolvedSecretTraceRegistry?.isPermanentlyIncomplete()) { + return { + success: false, + message: + 'Failed to query knowledge base: Knowledge result secret provenance is unavailable', + } + } + if (error instanceof KnowledgeUsageLimitExceededError) { + return { success: false, message: error.message } + } + const classified = asOrchestrationError(error) + if (!classified || classified.code === 'internal') throw error + if ( + (classified.code === 'not_found' || classified.code === 'forbidden') && + args.knowledgeBaseId + ) { + return { + success: false, + message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`, + } + } return { success: false, - message: `Failed to ${operation} knowledge base: ${errorMessage}`, + message: `Failed to ${operation} knowledge base: ${messageForCopilotKnowledgeError( + error, + `Failed to ${operation} knowledge base` + )}`, } } }, diff --git a/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.test.ts new file mode 100644 index 00000000000..e26138d9688 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { executeKnowledgeBase } = vi.hoisted(() => ({ executeKnowledgeBase: vi.fn() })) + +vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ + SearchKnowledgeBase: { id: 'search_knowledge_base' }, +})) +vi.mock('@/lib/copilot/tools/server/knowledge/knowledge-base', () => ({ + knowledgeBaseServerTool: { execute: executeKnowledgeBase }, +})) + +import { searchKnowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/search-knowledge-base' + +describe('search_knowledge_base delegation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each(['get', 'query'])('forwards %s with the immutable trusted context', async (operation) => { + const params = { operation, args: { knowledgeBaseId: 'kb-1' } } + const context = { + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + toolCallId: 'tool-1', + copilotToolExecution: true, + } + executeKnowledgeBase.mockResolvedValueOnce({ success: true, message: 'ok' }) + + await expect(searchKnowledgeBaseServerTool.execute(params, context)).resolves.toEqual({ + success: true, + message: 'ok', + }) + expect(executeKnowledgeBase).toHaveBeenCalledWith(params, context) + }) + + it('does not expose the legacy delete compatibility operation', async () => { + const result = await searchKnowledgeBaseServerTool.execute( + { operation: 'delete', args: { knowledgeBaseId: 'kb-1' } }, + { + userId: 'user-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-1', + copilotToolExecution: true, + } + ) + + expect(result.success).toBe(false) + expect(result.message).toContain('read-only') + expect(executeKnowledgeBase).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index b451518ff56..1458b9249d4 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -4,7 +4,6 @@ import { db } from '@sim/db' import { chat as chatTable, customTools as customToolsTable, - document, folder as folderTable, knowledgeBaseTagDefinitions, knowledgeConnector, @@ -118,7 +117,9 @@ import { isOAuthServiceDeploymentAvailable, } from '@/lib/integrations/availability.server' import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' -import { getKnowledgeBases } from '@/lib/knowledge/service' +import { listKnowledgeDocuments } from '@/lib/knowledge/application/documents' +import { listKnowledgeBases } from '@/lib/knowledge/application/knowledge-bases' +import { getKnowledgeBases as getLegacyKnowledgeBases } from '@/lib/knowledge/service' import { validateMermaidSource } from '@/lib/mermaid/validate' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' @@ -157,6 +158,8 @@ const logger = createLogger('WorkspaceVFS') // double-cast-allowed: a no-op stands in for the unused SVG-typed BlockIcon slot const PLACEHOLDER_BLOCK_ICON = (() => null) as unknown as BlockIcon const MAX_COMPILED_ATTACHMENT_BYTES = 5 * 1024 * 1024 +const KNOWLEDGE_DOCUMENT_PAGE_SIZE = 100 +const MAX_VFS_KNOWLEDGE_DOCUMENTS = 10_000 function bindWorkspaceFileResult<T>( record: WorkspaceFileRecord, @@ -566,6 +569,7 @@ function getStaticComponentFiles(): Map<string, string> { */ export class WorkspaceVFS { private readonly filePrincipal?: Principal + private readonly knowledgePrincipal?: Principal // Eagerly-materialized, cheap content (structure + metadata): folder markers, // per-resource meta.json, WORKSPACE.md/WORKSPACE_CONTEXT.md, static components. private files: Map<string, string> = new Map() @@ -598,8 +602,9 @@ export class WorkspaceVFS { */ private _customBlockTypes: Set<string> | null = null - constructor(filePrincipal?: Principal) { + constructor(filePrincipal?: Principal, knowledgePrincipal?: Principal) { this.filePrincipal = filePrincipal + this.knowledgePrincipal = knowledgePrincipal } get workspaceId(): string { @@ -799,7 +804,7 @@ export class WorkspaceVFS { sandboxEntitled, ] = await Promise.all([ timed('workflows', this.materializeWorkflows(workspaceId)), - timed('knowledge_bases', this.materializeKnowledgeBases(workspaceId, userId)), + timed('knowledge_bases', this.materializeKnowledgeBases(workspaceId)), timed('tables', this.materializeTables(workspaceId)), timed('files', this.materializeFiles(workspaceId)), timed( @@ -1738,99 +1743,120 @@ export class WorkspaceVFS { })) } - /** - * Materialize knowledge bases using the shared getKnowledgeBases function. - * Returns a summary for WORKSPACE.md generation. - */ + /** Materializes authorized knowledge summaries for WORKSPACE.md generation. */ private async materializeKnowledgeBases( - workspaceId: string, - userId: string + workspaceId: string ): Promise<WorkspaceMdData['knowledgeBases']> { - const kbs = await getKnowledgeBases(userId, workspaceId) + if (!this.knowledgePrincipal) { + throw new Error('Workspace VFS knowledge materialization requires a trusted principal') + } + const { knowledgeBases } = await listKnowledgeBases.execute({ + principal: this.knowledgePrincipal, + input: { workspaceId }, + }) + const kbs = knowledgeBases.map(({ knowledgeBase }) => knowledgeBase) const tagDefinitionsByKb = await this.loadKbTagDefinitions(kbs.map((kb) => kb.id)) - await Promise.all( - kbs.map(async (kb) => { - const safeName = sanitizeName(kb.name) - const prefix = `knowledgebases/${safeName}/` + for (const kb of kbs) { + const safeName = sanitizeName(kb.name) + const prefix = `knowledgebases/${safeName}/` - this.files.set( - `${prefix}meta.json`, - serializeKBMeta({ - id: kb.id, - name: kb.name, - description: kb.description, - embeddingModel: kb.embeddingModel, - embeddingDimension: kb.embeddingDimension, - tokenCount: kb.tokenCount, - createdAt: kb.createdAt, - updatedAt: kb.updatedAt, - documentCount: kb.docCount, - connectorTypes: kb.connectorTypes, - tagDefinitions: tagDefinitionsByKb.get(kb.id), - }) - ) + this.files.set( + `${prefix}meta.json`, + serializeKBMeta({ + id: kb.id, + name: kb.name, + description: kb.description, + embeddingModel: kb.embeddingModel, + embeddingDimension: kb.embeddingDimension, + tokenCount: kb.tokenCount, + createdAt: kb.createdAt, + updatedAt: kb.updatedAt, + documentCount: kb.docCount, + connectorTypes: kb.connectorTypes, + tagDefinitions: tagDefinitionsByKb.get(kb.id), + }) + ) - // documents.json / connectors.json are lazy, advertised only when the KB - // summary says they exist (docCount / connectorTypes) — no per-KB query on - // a read/glob, only when the artifact is read or grepped. - if (kb.docCount > 0) { - this.registerLazy(`${prefix}documents.json`, async () => { - const docRows = await db - .select({ - id: document.id, - filename: document.filename, - fileSize: document.fileSize, - mimeType: document.mimeType, - chunkCount: document.chunkCount, - tokenCount: document.tokenCount, - processingStatus: document.processingStatus, - enabled: document.enabled, - uploadedAt: document.uploadedAt, - }) - .from(document) - .where( - and( - eq(document.knowledgeBaseId, kb.id), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) + // documents.json / connectors.json are lazy, advertised only when the KB + // summary says they exist (docCount / connectorTypes) — no per-KB query on + // a read/glob, only when the artifact is read or grepped. + if (kb.docCount > 0) { + this.registerLazy(`${prefix}documents.json`, async () => { + if (!this.knowledgePrincipal) { + throw new Error('Workspace VFS knowledge document read requires a trusted principal') + } + if (kb.docCount > MAX_VFS_KNOWLEDGE_DOCUMENTS) { + throw new Error( + `Knowledge base ${kb.id} has more than ${MAX_VFS_KNOWLEDGE_DOCUMENTS} documents; documents.json cannot be materialized` + ) + } + const documents: Awaited<ReturnType<typeof listKnowledgeDocuments.execute>>['documents'] = + [] + let offset = 0 + while (true) { + const page = await listKnowledgeDocuments.execute({ + principal: this.knowledgePrincipal, + input: { + knowledgeBaseId: kb.id, + assertedWorkspaceId: workspaceId, + limit: KNOWLEDGE_DOCUMENT_PAGE_SIZE, + offset, + }, + }) + documents.push(...page.documents) + if (documents.length > MAX_VFS_KNOWLEDGE_DOCUMENTS) { + throw new Error( + `Knowledge base ${kb.id} exceeded the ${MAX_VFS_KNOWLEDGE_DOCUMENTS} document limit while materializing documents.json` ) - return docRows.length > 0 ? serializeDocuments(docRows) : null - }) - } + } + if (!page.pagination.hasMore) break + offset += page.pagination.limit + } + const docRows = documents.map((document) => ({ + id: document.id, + filename: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + chunkCount: document.chunkCount, + tokenCount: document.tokenCount, + processingStatus: document.processingStatus, + enabled: document.enabled, + uploadedAt: document.uploadedAt, + })) + return docRows.length > 0 ? serializeDocuments(docRows) : null + }) + } - if (kb.connectorTypes.length > 0) { - this.registerLazy(`${prefix}connectors.json`, async () => { - const connectorRows = await db - .select({ - id: knowledgeConnector.id, - connectorType: knowledgeConnector.connectorType, - status: knowledgeConnector.status, - syncMode: knowledgeConnector.syncMode, - syncIntervalMinutes: knowledgeConnector.syncIntervalMinutes, - lastSyncAt: knowledgeConnector.lastSyncAt, - lastSyncError: knowledgeConnector.lastSyncError, - lastSyncDocCount: knowledgeConnector.lastSyncDocCount, - nextSyncAt: knowledgeConnector.nextSyncAt, - consecutiveFailures: knowledgeConnector.consecutiveFailures, - createdAt: knowledgeConnector.createdAt, - }) - .from(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.knowledgeBaseId, kb.id), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) + if (kb.connectorTypes.length > 0) { + this.registerLazy(`${prefix}connectors.json`, async () => { + const connectorRows = await db + .select({ + id: knowledgeConnector.id, + connectorType: knowledgeConnector.connectorType, + status: knowledgeConnector.status, + syncMode: knowledgeConnector.syncMode, + syncIntervalMinutes: knowledgeConnector.syncIntervalMinutes, + lastSyncAt: knowledgeConnector.lastSyncAt, + lastSyncError: knowledgeConnector.lastSyncError, + lastSyncDocCount: knowledgeConnector.lastSyncDocCount, + nextSyncAt: knowledgeConnector.nextSyncAt, + consecutiveFailures: knowledgeConnector.consecutiveFailures, + createdAt: knowledgeConnector.createdAt, + }) + .from(knowledgeConnector) + .where( + and( + eq(knowledgeConnector.knowledgeBaseId, kb.id), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) ) - return connectorRows.length > 0 ? serializeConnectors(connectorRows) : null - }) - } - }) - ) + ) + return connectorRows.length > 0 ? serializeConnectors(connectorRows) : null + }) + } + } return kbs.map((kb) => ({ id: kb.id, @@ -2343,7 +2369,7 @@ export class WorkspaceVFS { input: { workspaceId, scope: 'archived' }, }) .then(({ folders }) => folders), - getKnowledgeBases(userId, workspaceId, 'archived'), + getLegacyKnowledgeBases(userId, workspaceId, 'archived'), ]) for (const wf of archivedWorkflows) { @@ -2570,10 +2596,14 @@ export class WorkspaceVFS { export async function getOrMaterializeVFS( workspaceId: string, userId: string, - options?: { secretMountPolicy?: SecretMountPolicy; filePrincipal?: Principal } + options?: { + secretMountPolicy?: SecretMountPolicy + filePrincipal?: Principal + knowledgePrincipal?: Principal + } ): Promise<WorkspaceVFS> { await assertActiveWorkspaceAccess(workspaceId, userId) - const vfs = new WorkspaceVFS(options?.filePrincipal) + const vfs = new WorkspaceVFS(options?.filePrincipal, options?.knowledgePrincipal) await vfs.materialize(workspaceId, userId, options) return vfs } diff --git a/apps/sim/lib/folders/cascade.test.ts b/apps/sim/lib/folders/cascade.test.ts index f354a9ded09..e75fa3ee45d 100644 --- a/apps/sim/lib/folders/cascade.test.ts +++ b/apps/sim/lib/folders/cascade.test.ts @@ -41,7 +41,11 @@ function makeTx(options: { selects?: unknown[][]; updates?: unknown[][] } = {}) from: () => ({ where: (where: unknown) => { selectCalls.push({ where }) - return Promise.resolve(selectQueue.shift() ?? []) + const rows = selectQueue.shift() ?? [] + return { + limit: () => Promise.resolve(rows), + then: (resolve: (value: unknown) => unknown) => Promise.resolve(rows).then(resolve), + } }, }), }), @@ -139,6 +143,22 @@ describe('collectCascadeSubtreeIds', () => { ) expect(hasMockCondition(selectCalls[0].where, (node) => node.right === 'ws-1')).toBe(true) }) + + it('fails before materializing an oversized recursive cascade', async () => { + const { tx } = makeTx({ + selects: [ + [ + { id: 'root', parentId: null }, + { id: 'child', parentId: 'root' }, + { id: 'grandchild', parentId: 'child' }, + ], + ], + }) + + await expect( + collectCascadeSubtreeIds(tx, 'ws-1', 'knowledge_base', 'root', TIMESTAMP, 2) + ).rejects.toThrow('Folder cascade exceeds the 2 row limit') + }) }) describe('collectArchivedSubtreeIds', () => { diff --git a/apps/sim/lib/folders/cascade.ts b/apps/sim/lib/folders/cascade.ts index 3e72aa4fd02..c4da00dedd7 100644 --- a/apps/sim/lib/folders/cascade.ts +++ b/apps/sim/lib/folders/cascade.ts @@ -32,9 +32,10 @@ export async function collectCascadeSubtreeIds( workspaceId: string, resourceType: FolderResourceType, folderId: string, - timestamp: Date + timestamp: Date, + maxRows?: number ): Promise<string[]> { - const cascadeFolders = await tx + const query = tx .select({ id: folderTable.id, parentId: folderTable.parentId }) .from(folderTable) .where( @@ -44,6 +45,10 @@ export async function collectCascadeSubtreeIds( or(isNull(folderTable.deletedAt), eq(folderTable.deletedAt, timestamp)) ) ) + const cascadeFolders = maxRows === undefined ? await query : await query.limit(maxRows + 1) + if (maxRows !== undefined && cascadeFolders.length > maxRows) { + throw new Error(`Folder cascade exceeds the ${maxRows} row limit`) + } return [folderId, ...collectDescendantFolderIds(cascadeFolders, folderId)] } diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index 582b1f755cd..f2540f15bff 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -69,6 +69,7 @@ export interface DeleteFolderParams { userId: string folderName?: string folderPath?: string + maxFolderRows?: number } export interface DeleteFolderResult { @@ -103,6 +104,9 @@ export interface DeleteFolderByPathParams { userId: string path: string recursive: boolean + maxFolderRows?: number + effects?: boolean + throwInfrastructure?: boolean } export interface DeleteFolderByPathResult extends DeleteFolderResult { @@ -164,7 +168,12 @@ function pathMutationError(error: unknown): FolderPathMutationResult { } async function executeCreateFolderAtPath( - params: Omit<CreateFolderParams, 'name' | 'parentId' | 'sortOrder' | 'id'> & { path: string }, + params: Omit<CreateFolderParams, 'name' | 'parentId' | 'sortOrder' | 'id'> & { + path: string + effects?: boolean + throwInfrastructure?: boolean + maxFolderRows?: number + }, projectLegacyLifecycle: boolean ): Promise<FolderPathMutationResult> { try { @@ -173,7 +182,9 @@ async function executeCreateFolderAtPath( const folder = await withTransactionRetry( async (tx) => { await acquireFolderMutationLock(tx, params.workspaceId, params.resourceType) - const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) + const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx, { + maxRows: params.maxFolderRows, + }) if (index.idByPath.has(params.path)) throw new Error(DUPLICATE_NAME_ERROR) const parentPath = parentFolderPath(params.path) @@ -213,7 +224,7 @@ async function executeCreateFolderAtPath( { label: 'create-folder-at-path' } ) - if (projectLegacyLifecycle) { + if (projectLegacyLifecycle && params.effects !== false) { recordAudit({ workspaceId: params.workspaceId, actorId: params.userId, @@ -225,21 +236,30 @@ async function executeCreateFolderAtPath( metadata: { path: params.path, folderResourceType: params.resourceType }, }) } - await notifyFolderResourceChanged(params.resourceType, params.workspaceId) + if (params.effects !== false) { + await notifyFolderResourceChanged(params.resourceType, params.workspaceId) + } return { success: true, folder, path: params.path } } catch (error) { - return pathMutationError(error) + const result = pathMutationError(error) + if (params.throwInfrastructure && result.errorCode === 'internal') throw error + return result } } /** Creates exactly the leaf identified by `path`; every ancestor must already exist. */ export async function createFolderAtPath( - params: Omit<CreateFolderParams, 'name' | 'parentId' | 'sortOrder' | 'id'> & { path: string } + params: Omit<CreateFolderParams, 'name' | 'parentId' | 'sortOrder' | 'id'> & { + path: string + effects?: boolean + throwInfrastructure?: boolean + maxFolderRows?: number + } ): Promise<FolderPathMutationResult> { return executeCreateFolderAtPath(params, true) } -/** Applies the authoritative mutation without projecting audit or realtime side effects. */ +/** Applies the authoritative mutation without projecting legacy audit. */ export async function createFolderAtPathTransition( params: Omit<CreateFolderParams, 'name' | 'parentId' | 'sortOrder' | 'id'> & { path: string } ): Promise<FolderPathMutationResult> { @@ -252,6 +272,9 @@ type RelocateFolderByPathParams = { userId: string path: string destinationPath: string + effects?: boolean + throwInfrastructure?: boolean + maxFolderRows?: number } async function executeRelocateFolderByPath( @@ -266,7 +289,9 @@ async function executeRelocateFolderByPath( const folder = await withTransactionRetry( async (tx) => { await acquireFolderMutationLock(tx, params.workspaceId, params.resourceType) - const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) + const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx, { + maxRows: params.maxFolderRows, + }) const folderId = resolveRequiredFolderId(index, params.path) if (index.idByPath.has(params.destinationPath)) throw new Error(DUPLICATE_NAME_ERROR) @@ -309,7 +334,7 @@ async function executeRelocateFolderByPath( { label: 'relocate-folder-by-path' } ) - if (projectLegacyLifecycle) { + if (projectLegacyLifecycle && params.effects !== false) { recordAudit({ workspaceId: params.workspaceId, actorId: params.userId, @@ -325,10 +350,14 @@ async function executeRelocateFolderByPath( }, }) } - await notifyFolderResourceChanged(params.resourceType, params.workspaceId) + if (params.effects !== false) { + await notifyFolderResourceChanged(params.resourceType, params.workspaceId) + } return { success: true, folder, path: params.destinationPath } } catch (error) { - return pathMutationError(error) + const result = pathMutationError(error) + if (params.throwInfrastructure && result.errorCode === 'internal') throw error + return result } } @@ -339,7 +368,7 @@ export async function relocateFolderByPath( return executeRelocateFolderByPath(params, true) } -/** Applies the authoritative mutation without projecting audit or realtime side effects. */ +/** Applies the authoritative mutation without projecting legacy audit. */ export async function relocateFolderByPathTransition( params: RelocateFolderByPathParams ): Promise<FolderPathMutationResult> { @@ -356,7 +385,9 @@ async function executeDeleteFolderByPath( params.workspaceId, params.resourceType, async (tx) => { - const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx) + const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx, { + maxRows: params.maxFolderRows, + }) const folderId = resolveRequiredFolderId(index, params.path) if ( folderResourceConfig(params.resourceType).supportsLocking && @@ -394,11 +425,16 @@ async function executeDeleteFolderByPath( userId: params.userId, folderName: row.name, folderPath: params.path, + maxFolderRows: params.maxFolderRows, } } ) - const result = await deleteFolderWithoutTreeLock(resolved, null, projectLegacyLifecycle) + const effects = params.effects !== false + const result = await deleteFolderWithoutTreeLock(resolved, null, { + projectAudit: projectLegacyLifecycle && effects, + notify: effects, + }) return { ...result, path: result.success ? params.path : undefined, @@ -406,7 +442,9 @@ async function executeDeleteFolderByPath( folderName: result.success ? resolved.folderName : undefined, } } catch (error) { - return pathMutationError(error) + const result = pathMutationError(error) + if (params.throwInfrastructure && result.errorCode === 'internal') throw error + return result } } @@ -417,7 +455,7 @@ export async function deleteFolderByPath( return executeDeleteFolderByPath(params, true) } -/** Applies the authoritative mutation without projecting audit or realtime side effects. */ +/** Applies the authoritative mutation without projecting legacy audit. */ export async function deleteFolderByPathTransition( params: DeleteFolderByPathParams ): Promise<DeleteFolderByPathResult> { @@ -726,13 +764,16 @@ export async function deleteFolder(params: DeleteFolderParams): Promise<DeleteFo return { success: false, error: 'Folder not found', errorCode: 'not_found' } } - return deleteFolderWithoutTreeLock(params, existing.deletedAt, true) + return deleteFolderWithoutTreeLock(params, existing.deletedAt, { + projectAudit: true, + notify: true, + }) } async function deleteFolderWithoutTreeLock( params: DeleteFolderParams, deletedAt: Date | null, - projectLegacyLifecycle: boolean + options: { projectAudit: boolean; notify: boolean } ): Promise<DeleteFolderResult> { const { resourceType, folderId, workspaceId, userId, folderName, folderPath } = params const config = folderResourceConfig(resourceType) @@ -741,13 +782,17 @@ async function deleteFolderWithoutTreeLock( // it is what distinguishes folders this cascade already stamped from folders archived // independently. const timestamp = deletedAt ?? new Date() - const folderIds = await collectCascadeSubtreeIds( - db, - workspaceId, - resourceType, - folderId, - timestamp - ) + const folderIds = + params.maxFolderRows === undefined + ? await collectCascadeSubtreeIds(db, workspaceId, resourceType, folderId, timestamp) + : await collectCascadeSubtreeIds( + db, + workspaceId, + resourceType, + folderId, + timestamp, + params.maxFolderRows + ) const rejection = await config.guardDelete?.({ workspaceId, folderIds }) if (rejection) { @@ -758,7 +803,7 @@ async function deleteFolderWithoutTreeLock( logger.info('Deleted folder and all contents', { folderId, resourceType, counts }) - if (projectLegacyLifecycle) { + if (options.projectAudit) { recordAudit({ workspaceId, actorId: userId, @@ -778,7 +823,7 @@ async function deleteFolderWithoutTreeLock( }) } // Live resource list (e.g. tables): a delete removes the folder and cascades to its contents. - await notifyFolderResourceChanged(resourceType, workspaceId) + if (options.notify) await notifyFolderResourceChanged(resourceType, workspaceId) return { success: true, deletedItems: toCascadeCounts(config, counts) } } diff --git a/apps/sim/lib/folders/queries.test.ts b/apps/sim/lib/folders/queries.test.ts index d6210ed53b5..3d7a8e1ef02 100644 --- a/apps/sim/lib/folders/queries.test.ts +++ b/apps/sim/lib/folders/queries.test.ts @@ -11,7 +11,9 @@ import { import { beforeEach, describe, expect, it, vi } from 'vitest' import { findActiveFolder, + listActiveFolderRows, listFoldersForWorkspace, + loadActiveFolderPathIndex, resolveRestoredFolderId, toFolderApi, wouldCreateFolderCycle, @@ -184,6 +186,26 @@ describe('folder queries', () => { }) }) + describe('bounded folder reads', () => { + it('fails before building an oversized path index', async () => { + queueTableRows(schemaMock.folder, [ROW, { ...ROW, id: 'f-2' }, { ...ROW, id: 'f-3' }]) + + await expect( + loadActiveFolderPathIndex('ws-1', 'knowledge_base', undefined, { maxRows: 2 }) + ).rejects.toThrow('Folder path index exceeds the 2 row limit') + expect(dbChainMockFns.limit).toHaveBeenCalledWith(3) + }) + + it('fails before returning an oversized folder list', async () => { + queueTableRows(schemaMock.folder, [ROW, { ...ROW, id: 'f-2' }, { ...ROW, id: 'f-3' }]) + + await expect(listActiveFolderRows('ws-1', 'knowledge_base', { maxRows: 2 })).rejects.toThrow( + 'Folder list exceeds the 2 row limit' + ) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(3) + }) + }) + describe('toFolderApi', () => { it('serializes timestamps to ISO strings and preserves a null deletedAt', () => { expect(toFolderApi(ROW)).toMatchObject({ diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index 7811476e82d..ff2bade9564 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -166,14 +166,16 @@ interface ListActiveFolderRowsOptions { search?: string sortBy?: Exclude<FolderSortBy, 'position'> sortOrder?: V2SortOrder + maxRows?: number } export async function loadActiveFolderPathIndex( workspaceId: string, resourceType: FolderResourceType, - tx: DbOrTx = db + tx: DbOrTx = db, + options?: { maxRows?: number } ): Promise<FolderPathIndex<typeof folder.$inferSelect>> { - const rows = await tx + const query = tx .select() .from(folder) .where( @@ -183,6 +185,10 @@ export async function loadActiveFolderPathIndex( isNull(folder.deletedAt) ) ) + const rows = options?.maxRows === undefined ? await query : await query.limit(options.maxRows + 1) + if (options?.maxRows !== undefined && rows.length > options.maxRows) { + throw new Error(`Folder path index exceeds the ${options.maxRows} row limit`) + } return buildFolderPathIndex(rows) } @@ -208,7 +214,7 @@ export async function listActiveFolderRows( ? isNull(folder.parentId) : eq(folder.parentId, options.parentId) - return tx + const query = tx .select() .from(folder) .where( @@ -221,6 +227,11 @@ export async function listActiveFolderRows( ) ) .orderBy(...listOrderBy(FOLDER_SORTS[options.sortBy ?? 'name'], options.sortOrder ?? 'asc')) + const rows = options.maxRows === undefined ? await query : await query.limit(options.maxRows + 1) + if (options.maxRows !== undefined && rows.length > options.maxRows) { + throw new Error(`Folder list exceeds the ${options.maxRows} row limit`) + } + return rows } /** diff --git a/apps/sim/lib/knowledge/application/authorization.test.ts b/apps/sim/lib/knowledge/application/authorization.test.ts new file mode 100644 index 00000000000..4952e6f787b --- /dev/null +++ b/apps/sim/lib/knowledge/application/authorization.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment node + */ + +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { describe, expect, it } from 'vitest' +import { + KNOWLEDGE_DELEGATION_AUDIENCE, + knowledgeDelegationPolicy, +} from '@/lib/knowledge/application/authorization' +import { createKnowledgeDelegatedPrincipal } from '@/lib/knowledge/application/delegated-principal' + +describe('knowledge delegation policy', () => { + it('binds trusted delegation to the canonical workspace and audience', () => { + const principal = createKnowledgeDelegatedPrincipal({ + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + chatId: 'chat-1', + }) + + expect(principal.audience).toBe(KNOWLEDGE_DELEGATION_AUDIENCE) + expect(principal.resourceScope).toEqual({ chatId: 'chat-1' }) + expect( + knowledgeDelegationPolicy.isWithinScope(principal, { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + }) + ).toBe(true) + expect( + knowledgeDelegationPolicy.isWithinScope(principal, { + workspaceId: 'workspace-2', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + }) + ).toBe(false) + }) + + it('does not accept a model-authored audience', () => { + const principal: DelegatedPrincipal = { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'model:chosen', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + } + + expect(principal.audience).not.toBe(knowledgeDelegationPolicy.audience) + }) +}) diff --git a/apps/sim/lib/knowledge/application/authorization.ts b/apps/sim/lib/knowledge/application/authorization.ts new file mode 100644 index 00000000000..e75d1c496d4 --- /dev/null +++ b/apps/sim/lib/knowledge/application/authorization.ts @@ -0,0 +1,27 @@ +import type { Principal } from '@sim/auth/principal' +import type { + WorkspaceAuthorizationContext, + WorkspaceAuthorizationOptions, +} from '@/lib/core/application' + +export const KNOWLEDGE_DELEGATION_AUDIENCE = 'sim:knowledge' + +export interface KnowledgeAuthorizationContext extends WorkspaceAuthorizationContext { + knowledgeBaseId?: string + documentId?: string +} + +export type KnowledgeAuthorizationOptions = Omit< + WorkspaceAuthorizationOptions<KnowledgeAuthorizationContext>, + 'delegation' +> + +export const knowledgeDelegationPolicy = { + audience: KNOWLEDGE_DELEGATION_AUDIENCE, + isWithinScope( + delegated: Extract<Principal, { kind: 'delegated' }>, + canonicalContext: KnowledgeAuthorizationContext + ) { + return delegated.workspaceId === canonicalContext.workspaceId + }, +} as const diff --git a/apps/sim/lib/knowledge/application/authorized-knowledge-use-case.ts b/apps/sim/lib/knowledge/application/authorized-knowledge-use-case.ts new file mode 100644 index 00000000000..5a384ef3647 --- /dev/null +++ b/apps/sim/lib/knowledge/application/authorized-knowledge-use-case.ts @@ -0,0 +1,28 @@ +import { + type AuthorizedWorkspaceUseCaseDefinition, + defineAuthorizedWorkspaceUseCase, + type WorkspaceOperation, +} from '@/lib/core/application' +import { + type KnowledgeAuthorizationContext, + knowledgeDelegationPolicy, +} from '@/lib/knowledge/application/authorization' + +type AuthorizedKnowledgeUseCaseDefinition< + O extends WorkspaceOperation, + I, + C extends KnowledgeAuthorizationContext, + R, +> = Omit<AuthorizedWorkspaceUseCaseDefinition<O, I, C, R>, 'authorizationOptions'> + +export function defineAuthorizedKnowledgeUseCase< + const O extends WorkspaceOperation, + I, + C extends KnowledgeAuthorizationContext, + R, +>(definition: AuthorizedKnowledgeUseCaseDefinition<O, I, C, R>) { + return defineAuthorizedWorkspaceUseCase({ + ...definition, + authorizationOptions: { delegation: knowledgeDelegationPolicy }, + }) +} diff --git a/apps/sim/lib/knowledge/application/billing.ts b/apps/sim/lib/knowledge/application/billing.ts new file mode 100644 index 00000000000..f2a08cd4f63 --- /dev/null +++ b/apps/sim/lib/knowledge/application/billing.ts @@ -0,0 +1,37 @@ +import type { Principal } from '@sim/auth/principal' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { + type BillingAttributionSnapshot, + resolveBillingAttribution, + resolveSystemBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import type { KnowledgeWorkspaceContext } from '@/lib/knowledge/application/contexts' + +export class KnowledgeUsageLimitExceededError extends Error { + constructor(message: string) { + super(message) + this.name = 'KnowledgeUsageLimitExceededError' + } +} + +export function resolveKnowledgeAttributedUserId( + principal: Principal, + context: KnowledgeWorkspaceContext +): string { + return resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId +} + +export function resolveKnowledgeBillingAttribution( + principal: Principal, + context: KnowledgeWorkspaceContext +): Promise<BillingAttributionSnapshot> { + if (principal.kind === 'workspace_api_key') { + return resolveSystemBillingAttribution(context.workspaceId) + } + return resolveBillingAttribution({ + actorUserId: resolveKnowledgeAttributedUserId(principal, context), + workspaceId: context.workspaceId, + }) +} diff --git a/apps/sim/lib/knowledge/application/contexts.ts b/apps/sim/lib/knowledge/application/contexts.ts new file mode 100644 index 00000000000..1c2648aabd1 --- /dev/null +++ b/apps/sim/lib/knowledge/application/contexts.ts @@ -0,0 +1,83 @@ +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { KnowledgeAuthorizationContext } from '@/lib/knowledge/application/authorization' +import type { ActiveKnowledgeDocument } from '@/lib/knowledge/documents/service' +import { getKnowledgeDocument } from '@/lib/knowledge/documents/service' +import { getKnowledgeBaseById } from '@/lib/knowledge/service' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' + +export interface KnowledgeWorkspaceContext extends KnowledgeAuthorizationContext { + billedAccountUserId: string +} + +export interface ActiveKnowledgeBaseContext extends KnowledgeWorkspaceContext { + knowledgeBaseId: string + knowledgeBase: KnowledgeBaseWithCounts +} + +export interface ActiveKnowledgeDocumentContext extends ActiveKnowledgeBaseContext { + documentId: string + document: ActiveKnowledgeDocument +} + +export async function loadKnowledgeWorkspaceContext( + workspaceId: string +): Promise<KnowledgeWorkspaceContext | null> { + const [row] = await db + .select({ + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + return row ?? null +} + +export async function resolveKnowledgeWorkspaceContext(input: { + workspaceId: string +}): Promise<KnowledgeWorkspaceContext> { + const context = await loadKnowledgeWorkspaceContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} + +export async function resolveActiveKnowledgeBaseContext(input: { + knowledgeBaseId: string + assertedWorkspaceId?: string +}): Promise<ActiveKnowledgeBaseContext> { + const knowledgeBase = await getKnowledgeBaseById(input.knowledgeBaseId) + if ( + !knowledgeBase?.workspaceId || + (input.assertedWorkspaceId !== undefined && + knowledgeBase.workspaceId !== input.assertedWorkspaceId) + ) { + throw new OrchestrationError('not_found', 'Knowledge base not found') + } + const workspaceContext = await loadKnowledgeWorkspaceContext(knowledgeBase.workspaceId) + if (!workspaceContext) throw new OrchestrationError('not_found', 'Knowledge base not found') + return { + ...workspaceContext, + knowledgeBaseId: knowledgeBase.id, + knowledgeBase, + } +} + +export async function resolveActiveKnowledgeDocumentContext(input: { + knowledgeBaseId: string + documentId: string + assertedWorkspaceId?: string +}): Promise<ActiveKnowledgeDocumentContext> { + const context = await resolveActiveKnowledgeBaseContext(input) + const document = await getKnowledgeDocument(context.knowledgeBaseId, input.documentId) + if (!document) throw new OrchestrationError('not_found', 'Document not found') + return { + ...context, + documentId: document.id, + document, + } +} diff --git a/apps/sim/lib/knowledge/application/delegated-principal.ts b/apps/sim/lib/knowledge/application/delegated-principal.ts new file mode 100644 index 00000000000..ac0db06e992 --- /dev/null +++ b/apps/sim/lib/knowledge/application/delegated-principal.ts @@ -0,0 +1,33 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { KNOWLEDGE_DELEGATION_AUDIENCE } from '@/lib/knowledge/application/authorization' + +const KNOWLEDGE_DELEGATION_TTL_MS = 5 * 60 * 1000 + +export interface CreateKnowledgeDelegatedPrincipalInput { + serviceId: DelegatedPrincipal['serviceId'] + subjectUserId: string + workspaceId: string + delegationId: string + chatId?: string + executionId?: string +} + +export function createKnowledgeDelegatedPrincipal( + input: CreateKnowledgeDelegatedPrincipalInput +): DelegatedPrincipal { + const issuedAt = new Date() + return { + kind: 'delegated', + serviceId: input.serviceId, + subjectUserId: input.subjectUserId, + workspaceId: input.workspaceId, + delegationId: input.delegationId, + audience: KNOWLEDGE_DELEGATION_AUDIENCE, + issuedAt, + expiresAt: new Date(issuedAt.getTime() + KNOWLEDGE_DELEGATION_TTL_MS), + resourceScope: { + ...(input.chatId ? { chatId: input.chatId } : {}), + ...(input.executionId ? { executionId: input.executionId } : {}), + }, + } +} diff --git a/apps/sim/lib/knowledge/application/documents.test.ts b/apps/sim/lib/knowledge/application/documents.test.ts new file mode 100644 index 00000000000..acfeea66d47 --- /dev/null +++ b/apps/sim/lib/knowledge/application/documents.test.ts @@ -0,0 +1,251 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveKnowledgeBase: vi.fn(), + resolveDocument: vi.fn(), + resolvePermission: vi.fn(), + resolveHumanBilling: vi.fn(), + resolveSystemBilling: vi.fn(), + checkUsage: vi.fn(), + getDocuments: vi.fn(), + createDocument: vi.fn(), + deleteDocument: vi.fn(), + processQueue: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + DOCUMENT_UPLOADED: 'document.uploaded', + DOCUMENT_DELETED: 'document.deleted', + }, + AuditResourceType: { DOCUMENT: 'document' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveBillingAttribution: mocks.resolveHumanBilling, + resolveSystemBillingAttribution: mocks.resolveSystemBilling, + checkAttributedUsageLimits: mocks.checkUsage, +})) + +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveActiveKnowledgeBaseContext: mocks.resolveKnowledgeBase, + resolveActiveKnowledgeDocumentContext: mocks.resolveDocument, +})) + +vi.mock('@/lib/knowledge/documents/service', () => ({ + getDocuments: mocks.getDocuments, + createSingleDocument: mocks.createDocument, + deleteKnowledgeDocumentInKnowledgeBase: mocks.deleteDocument, + processDocumentsWithQueue: mocks.processQueue, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + deleteKnowledgeDocument, + listKnowledgeDocuments, + uploadKnowledgeDocument, +} from '@/lib/knowledge/application/documents' + +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + knowledgeBaseId: 'knowledge-1', + knowledgeBase: { id: 'knowledge-1', name: 'Docs' }, +} + +const document = { + id: 'document-1', + knowledgeBaseId: 'knowledge-1', + filename: 'guide.pdf', + fileUrl: '/api/files/serve/guide.pdf', + fileSize: 42, + mimeType: 'application/pdf', + enabled: true, + uploadedAt: new Date('2026-01-01T00:00:00Z'), +} + +describe('knowledge document application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveKnowledgeBase.mockResolvedValue(context) + mocks.resolveDocument.mockResolvedValue({ + ...context, + documentId: document.id, + document, + }) + mocks.resolveSystemBilling.mockResolvedValue({ + actorUserId: 'billing-owner-1', + workspaceId: 'workspace-1', + }) + mocks.resolveHumanBilling.mockResolvedValue({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + }) + mocks.checkUsage.mockResolvedValue({ isExceeded: false }) + mocks.createDocument.mockResolvedValue(document) + mocks.processQueue.mockResolvedValue(undefined) + mocks.getDocuments.mockResolvedValue({ + documents: [], + pagination: { total: 0, limit: 50, offset: 0, hasMore: false }, + }) + }) + + it('authorizes the canonical knowledge base before listing documents', async () => { + await listKnowledgeDocuments.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + limit: 25, + }, + }) + + expect(mocks.resolveKnowledgeBase).toHaveBeenCalledWith( + expect.objectContaining({ assertedWorkspaceId: 'workspace-1' }) + ) + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.getDocuments.mock.invocationCallOrder[0] + ) + }) + + it('resolves current workspace-key billing while retaining key audit attribution', async () => { + await uploadKnowledgeDocument.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + document, + source: 'v2', + }, + }) + + expect(mocks.resolveSystemBilling).toHaveBeenCalledWith('workspace-1') + expect(mocks.createDocument).toHaveBeenCalledWith( + document, + 'knowledge-1', + expect.any(String), + 'billing-owner-1', + undefined, + undefined, + { expectedWorkspaceId: 'workspace-1' } + ) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: null, + actorName: 'Workspace API key', + metadata: expect.objectContaining({ + operation: 'knowledge.documents.upload', + actor: { + kind: 'workspace_api_key', + keyId: 'key-1', + workspaceId: 'workspace-1', + }, + }), + }) + ) + }) + + it('does not repeat usage admission after a code-defined pre-admission', async () => { + await uploadKnowledgeDocument.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + document, + usageAdmission: 'pre_admitted', + }, + }) + + expect(mocks.checkUsage).not.toHaveBeenCalled() + expect(mocks.createDocument).toHaveBeenCalledOnce() + }) + + it('conceals a cross-knowledge-base document before deletion and audit', async () => { + mocks.resolveDocument.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Document not found') + ) + + await expect( + deleteKnowledgeDocument.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + knowledgeBaseId: 'knowledge-1', + documentId: 'document-from-another-kb', + assertedWorkspaceId: 'workspace-1', + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.deleteDocument).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('carries canonical knowledge-base scope through deletion and audit', async () => { + await deleteKnowledgeDocument.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + knowledgeBaseId: 'knowledge-1', + documentId: 'document-1', + assertedWorkspaceId: 'workspace-1', + source: 'v2', + }, + }) + + expect(mocks.deleteDocument).toHaveBeenCalledWith( + 'knowledge-1', + 'document-1', + expect.any(String) + ) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'document.deleted', + resourceId: 'document-1', + metadata: expect.objectContaining({ + operation: 'knowledge.documents.delete', + knowledgeBaseId: 'knowledge-1', + }), + }) + ) + }) + + it('propagates document infrastructure failures without audit', async () => { + const failure = new Error('storage ledger unavailable') + mocks.createDocument.mockRejectedValueOnce(failure) + + await expect( + uploadKnowledgeDocument.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + document, + }, + }) + ).rejects.toBe(failure) + + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/application/documents.ts b/apps/sim/lib/knowledge/application/documents.ts new file mode 100644 index 00000000000..76f81c22e7d --- /dev/null +++ b/apps/sim/lib/knowledge/application/documents.ts @@ -0,0 +1,246 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { checkAttributedUsageLimits } from '@/lib/billing/core/billing-attribution' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { + KnowledgeUsageLimitExceededError, + resolveKnowledgeAttributedUserId, + resolveKnowledgeBillingAttribution, +} from '@/lib/knowledge/application/billing' +import { + type ActiveKnowledgeDocumentContext, + resolveActiveKnowledgeBaseContext, + resolveActiveKnowledgeDocumentContext, +} from '@/lib/knowledge/application/contexts' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + createSingleDocument, + type DocumentData, + deleteKnowledgeDocumentInKnowledgeBase, + getDocuments, + type ProcessingOptions, + processDocumentsWithQueue, +} from '@/lib/knowledge/documents/service' +import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' +import { validateFileType } from '@/lib/uploads/utils/validation' + +const logger = createLogger('KnowledgeDocumentApplication') + +export interface ListKnowledgeDocumentsInput { + knowledgeBaseId: string + assertedWorkspaceId?: string + enabledFilter?: 'all' | 'enabled' | 'disabled' + search?: string + limit?: number + offset?: number + sortBy?: DocumentSortField + sortOrder?: SortOrder +} + +export interface ReadKnowledgeDocumentInput { + knowledgeBaseId: string + documentId: string + assertedWorkspaceId?: string +} + +export interface UploadKnowledgeDocumentAdmissionInput { + knowledgeBaseId: string + assertedWorkspaceId?: string +} + +export interface KnowledgeDocumentInput { + filename: string + fileUrl: string + fileSize: number + mimeType: string + documentTagsData?: string + tag1?: string + tag2?: string + tag3?: string + tag4?: string + tag5?: string + tag6?: string + tag7?: string +} + +export interface UploadKnowledgeDocumentInput extends UploadKnowledgeDocumentAdmissionInput { + document: KnowledgeDocumentInput + processingOptions?: ProcessingOptions + startProcessing?: boolean + /** Code-defined admission state; HTTP/model payloads must never populate it. */ + usageAdmission?: 'enforce' | 'pre_admitted' + source?: string +} + +export interface DeleteKnowledgeDocumentInput extends ReadKnowledgeDocumentInput { + source?: string +} + +export const listKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.listDocuments, + resolveContext: ({ input }: { input: ListKnowledgeDocumentsInput }) => + resolveActiveKnowledgeBaseContext(input), + async execute({ input, context }) { + const limit = input.limit ?? 50 + const offset = input.offset ?? 0 + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + throw new OrchestrationError('validation', 'Document limit must be between 1 and 100') + } + if (!Number.isInteger(offset) || offset < 0) { + throw new OrchestrationError('validation', 'Document offset must be a non-negative integer') + } + return getDocuments( + context.knowledgeBaseId, + { + enabledFilter: input.enabledFilter === 'all' ? undefined : input.enabledFilter, + search: input.search, + limit, + offset, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + }, + generateRequestId() + ) + }, +}) + +export const readKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.readDocument, + resolveContext: ({ input }: { input: ReadKnowledgeDocumentInput }) => + resolveActiveKnowledgeDocumentContext(input), + async execute({ context }: { context: ActiveKnowledgeDocumentContext }) { + return { document: context.document } + }, +}) + +export const admitKnowledgeDocumentUpload = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.uploadDocument, + resolveContext: ({ input }: { input: UploadKnowledgeDocumentAdmissionInput }) => + resolveActiveKnowledgeBaseContext(input), + async execute({ principal, context }) { + const billingAttribution = await resolveKnowledgeBillingAttribution(principal, context) + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + throw new KnowledgeUsageLimitExceededError( + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + return { + knowledgeBaseId: context.knowledgeBaseId, + knowledgeBaseName: context.knowledgeBase.name, + workspaceId: context.workspaceId, + storageActorUserId: resolveKnowledgeAttributedUserId(principal, context), + } + }, +}) + +export const uploadKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.uploadDocument, + resolveContext: ({ input }: { input: UploadKnowledgeDocumentInput }) => + resolveActiveKnowledgeBaseContext(input), + async execute({ principal, input, context }) { + if (input.document.fileSize < 0 || input.document.fileSize > MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE) { + throw new OrchestrationError( + 'payload_too_large', + 'Knowledge document exceeds the 100MB limit' + ) + } + const fileTypeError = validateFileType(input.document.filename, input.document.mimeType) + if (fileTypeError) throw new OrchestrationError('validation', fileTypeError.message) + const billingAttribution = await resolveKnowledgeBillingAttribution(principal, context) + if (input.usageAdmission !== 'pre_admitted') { + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + throw new KnowledgeUsageLimitExceededError( + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + } + const requestId = generateRequestId() + const uploadedBy = resolveKnowledgeAttributedUserId(principal, context) + const document = await createSingleDocument( + input.document, + context.knowledgeBaseId, + requestId, + uploadedBy, + undefined, + undefined, + { expectedWorkspaceId: context.workspaceId } + ) + if (input.startProcessing !== false) { + const processingDocument: DocumentData = { + documentId: document.id, + filename: document.filename, + fileUrl: document.fileUrl, + fileSize: document.fileSize, + mimeType: document.mimeType, + } + processDocumentsWithQueue( + [processingDocument], + context.knowledgeBaseId, + input.processingOptions ?? {}, + requestId, + billingAttribution + ).catch((error: unknown) => { + logger.error('Knowledge document processing pipeline failed', { + knowledgeBaseId: context.knowledgeBaseId, + documentId: document.id, + error, + }) + }) + } + return { document, created: true as const } + }, + projectAudit: ({ input, context, result }) => ({ + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: result.document.id, + resourceName: result.document.filename, + description: `Uploaded document "${result.document.filename}" to knowledge base "${context.knowledgeBase.name}"`, + metadata: { + source: input.source, + knowledgeBaseId: context.knowledgeBaseId, + knowledgeBaseName: context.knowledgeBase.name, + fileName: result.document.filename, + fileType: result.document.mimeType, + fileSize: result.document.fileSize, + }, + }), +}) + +export const deleteKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.deleteDocument, + resolveContext: ({ input }: { input: DeleteKnowledgeDocumentInput }) => + resolveActiveKnowledgeDocumentContext(input), + async execute({ context }: { context: ActiveKnowledgeDocumentContext }) { + await deleteKnowledgeDocumentInKnowledgeBase( + context.knowledgeBaseId, + context.documentId, + generateRequestId() + ) + return { + id: context.documentId, + filename: context.document.filename, + fileSize: context.document.fileSize, + mimeType: context.document.mimeType, + } + }, + projectAudit: ({ input, context, result }) => ({ + action: AuditAction.DOCUMENT_DELETED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: result.id, + resourceName: result.filename, + description: `Deleted document "${result.filename}" from knowledge base "${context.knowledgeBase.name}"`, + metadata: { + source: input.source, + knowledgeBaseId: context.knowledgeBaseId, + knowledgeBaseName: context.knowledgeBase.name, + fileName: result.filename, + fileSize: result.fileSize, + mimeType: result.mimeType, + }, + }), +}) diff --git a/apps/sim/lib/knowledge/application/folder-paths.ts b/apps/sim/lib/knowledge/application/folder-paths.ts new file mode 100644 index 00000000000..cc1c9a70bf6 --- /dev/null +++ b/apps/sim/lib/knowledge/application/folder-paths.ts @@ -0,0 +1,33 @@ +import type { folder } from '@sim/db/schema' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { withFolderTreeLock } from '@/lib/folders/locks' +import type { FolderPathIndex } from '@/lib/folders/paths' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' +import { MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE } from '@/lib/knowledge/constants' + +type FolderRow = typeof folder.$inferSelect + +export async function resolveKnowledgeFolderPath( + workspaceId: string, + path: string +): Promise<{ folderId: string | null; index: FolderPathIndex<FolderRow> }> { + return withFolderTreeLock(workspaceId, 'knowledge_base', async (tx) => { + const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base', tx, { + maxRows: MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE, + }) + const folderId = resolveFolderPathFromIndex(index, path) + if (folderId === undefined) throw new OrchestrationError('not_found', 'Folder not found') + return { folderId, index } + }) +} + +export function knowledgeFolderPathForId( + index: FolderPathIndex<FolderRow>, + folderId: string | null | undefined +): string { + if (!folderId) return ROOT_FOLDER_PATH + const path = index.pathById.get(folderId) + if (!path) throw new Error('Knowledge base references an inactive or missing folder') + return path +} diff --git a/apps/sim/lib/knowledge/application/folders.test.ts b/apps/sim/lib/knowledge/application/folders.test.ts new file mode 100644 index 00000000000..53e873224f7 --- /dev/null +++ b/apps/sim/lib/knowledge/application/folders.test.ts @@ -0,0 +1,195 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveWorkspace: vi.fn(), + resolvePermission: vi.fn(), + loadIndex: vi.fn(), + listRows: vi.fn(), + createAtPath: vi.fn(), + relocateByPath: vi.fn(), + deleteByPath: vi.fn(), + recordAudit: vi.fn(), + notify: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + FOLDER_CREATED: 'folder.created', + FOLDER_MOVED: 'folder.moved', + FOLDER_DELETED: 'folder.deleted', + }, + AuditResourceType: { FOLDER: 'folder' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveKnowledgeWorkspaceContext: mocks.resolveWorkspace, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mocks.loadIndex, + listActiveFolderRows: mocks.listRows, + resolveFolderPathFromIndex: (index: { idByPath: Map<string, string> }, path: string) => + path === '/' ? null : index.idByPath.get(path), +})) + +vi.mock('@/lib/folders/orchestration', () => ({ + createFolderAtPath: mocks.createAtPath, + relocateFolderByPath: mocks.relocateByPath, + deleteFolderByPath: mocks.deleteByPath, +})) + +vi.mock('@/lib/realtime/notify', () => ({ + notifyFolderResourceChanged: mocks.notify, +})) + +import { + createKnowledgeFolder, + deleteKnowledgeFolder, + listKnowledgeFolders, +} from '@/lib/knowledge/application/folders' + +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const folder = { + id: 'folder-1', + resourceType: 'knowledge_base', + name: 'Docs', + userId: 'billing-owner-1', + workspaceId: 'workspace-1', + parentId: null, + sortOrder: 0, + locked: false, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + deletedAt: null, +} + +describe('knowledge folder application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveWorkspace.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('write') + mocks.loadIndex.mockResolvedValue({ + idByPath: new Map([['/Docs', 'folder-1']]), + pathById: new Map([['folder-1', '/Docs']]), + rowById: new Map([['folder-1', folder]]), + }) + mocks.listRows.mockResolvedValue([folder]) + mocks.createAtPath.mockResolvedValue({ success: true, folder, path: '/Docs' }) + mocks.deleteByPath.mockResolvedValue({ + success: true, + path: '/Docs', + deletedItems: { folders: 2, knowledgeBases: 3 }, + }) + mocks.notify.mockResolvedValue(undefined) + }) + + it('resolves a canonical parent path before listing', async () => { + const result = await listKnowledgeFolders.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'workspace-1', parentPath: '/Docs' }, + }) + + expect(mocks.listRows).toHaveBeenCalledWith( + 'workspace-1', + 'knowledge_base', + expect.objectContaining({ parentId: 'folder-1' }) + ) + expect(result.folders[0]).toMatchObject({ id: 'folder-1', path: '/Docs' }) + }) + + it('rejects a missing parent without querying folder rows', async () => { + await expect( + listKnowledgeFolders.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'workspace-1', parentPath: '/Missing' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.listRows).not.toHaveBeenCalled() + }) + + it('uses compatibility attribution only for storage and key attribution for audit', async () => { + await createKnowledgeFolder.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + input: { workspaceId: 'workspace-1', path: '/Docs', source: 'v2' }, + }) + + expect(mocks.createAtPath).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'knowledge_base', + userId: 'billing-owner-1', + effects: false, + throwInfrastructure: true, + }) + ) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: null, + actorName: 'Workspace API key', + metadata: expect.objectContaining({ + operation: 'knowledge.folders.create', + actor: { + kind: 'workspace_api_key', + keyId: 'key-1', + workspaceId: 'workspace-1', + }, + }), + }) + ) + expect(mocks.recordAudit.mock.invocationCallOrder[0]).toBeLessThan( + mocks.notify.mock.invocationCallOrder[0] + ) + }) + + it('preserves recursive cascade counts', async () => { + const result = await deleteKnowledgeFolder.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'workspace-1', path: '/Docs', recursive: true }, + }) + + expect(mocks.deleteByPath).toHaveBeenCalledWith( + expect.objectContaining({ recursive: true, effects: false, throwInfrastructure: true }) + ) + expect(result.deletedItems).toEqual({ folders: 2, knowledgeBases: 3 }) + }) + + it('propagates infrastructure failures without audit or notification', async () => { + const failure = new Error('folder database unavailable') + mocks.createAtPath.mockRejectedValueOnce(failure) + + await expect( + createKnowledgeFolder.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'workspace-1', path: '/Docs' }, + }) + ).rejects.toBe(failure) + + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/application/folders.ts b/apps/sim/lib/knowledge/application/folders.ts new file mode 100644 index 00000000000..5485322c039 --- /dev/null +++ b/apps/sim/lib/knowledge/application/folders.ts @@ -0,0 +1,209 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { folder } from '@sim/db/schema' +import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { + createFolderAtPath, + deleteFolderByPath, + relocateFolderByPath, +} from '@/lib/folders/orchestration' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { + listActiveFolderRows, + loadActiveFolderPathIndex, + resolveFolderPathFromIndex, +} from '@/lib/folders/queries' +import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { resolveKnowledgeAttributedUserId } from '@/lib/knowledge/application/billing' +import { resolveKnowledgeWorkspaceContext } from '@/lib/knowledge/application/contexts' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE } from '@/lib/knowledge/constants' +import { notifyFolderResourceChanged } from '@/lib/realtime/notify' + +type KnowledgeFolder = typeof folder.$inferSelect & { path: string } + +export interface ListKnowledgeFoldersInput { + workspaceId: string + parentPath?: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export interface CreateKnowledgeFolderInput { + workspaceId: string + path: string + source?: string +} + +export interface RelocateKnowledgeFolderInput { + workspaceId: string + path: string + destinationPath: string + source?: string +} + +export interface DeleteKnowledgeFolderInput { + workspaceId: string + path: string + recursive?: boolean + source?: string +} + +function throwFolderFailure(result: { error?: string; errorCode?: OrchestrationErrorCode }): never { + throw new OrchestrationError( + result.errorCode ?? 'internal', + result.error ?? 'Folder operation failed' + ) +} + +export const listKnowledgeFolders = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.listFolders, + resolveContext: ({ input }: { input: ListKnowledgeFoldersInput }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ input, context }) { + const index = await loadActiveFolderPathIndex( + context.workspaceId, + 'knowledge_base', + undefined, + { maxRows: MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE } + ) + const parentId = + input.parentPath === undefined + ? undefined + : resolveFolderPathFromIndex(index, input.parentPath) + if (input.parentPath !== undefined && parentId === undefined) { + throw new OrchestrationError('not_found', 'Folder not found') + } + const folders = await listActiveFolderRows(context.workspaceId, 'knowledge_base', { + parentId, + search: input.search, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + maxRows: MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE, + }) + return { + folders: folders.map((folder): KnowledgeFolder => { + const path = index.pathById.get(folder.id) + if (!path) throw new Error('Folder path index is missing a listed folder') + return { ...folder, path } + }), + } + }, +}) + +export const createKnowledgeFolder = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.createFolder, + resolveContext: ({ input }: { input: CreateKnowledgeFolderInput }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ principal, input, context }) { + const result = await createFolderAtPath({ + resourceType: 'knowledge_base', + workspaceId: context.workspaceId, + userId: resolveKnowledgeAttributedUserId(principal, context), + path: input.path, + effects: false, + throwInfrastructure: true, + maxFolderRows: MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE, + }) + if (!result.success || !result.folder) return throwFolderFailure(result) + return { folder: { ...result.folder, path: result.path ?? input.path } } + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Created knowledge base folder "${result.folder.path}"`, + metadata: { + source: input.source, + path: result.folder.path, + folderResourceType: 'knowledge_base', + }, + }), + afterSuccess: ({ context }) => notifyFolderResourceChanged('knowledge_base', context.workspaceId), +}) + +export const relocateKnowledgeFolder = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.relocateFolder, + resolveContext: ({ input }: { input: RelocateKnowledgeFolderInput }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ principal, input, context }) { + const result = await relocateFolderByPath({ + resourceType: 'knowledge_base', + workspaceId: context.workspaceId, + userId: resolveKnowledgeAttributedUserId(principal, context), + path: input.path, + destinationPath: input.destinationPath, + effects: false, + throwInfrastructure: true, + maxFolderRows: MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE, + }) + if (!result.success || !result.folder) return throwFolderFailure(result) + return { folder: { ...result.folder, path: result.path ?? input.destinationPath } } + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Moved knowledge base folder to "${result.folder.path}"`, + metadata: { + source: input.source, + sourcePath: input.path, + destinationPath: result.folder.path, + folderResourceType: 'knowledge_base', + }, + }), + afterSuccess: ({ context }) => notifyFolderResourceChanged('knowledge_base', context.workspaceId), +}) + +export const deleteKnowledgeFolder = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.deleteFolder, + resolveContext: ({ input }: { input: DeleteKnowledgeFolderInput }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ principal, input, context }) { + if (input.path === ROOT_FOLDER_PATH) { + throw new OrchestrationError('validation', 'Cannot delete the root path') + } + const index = await loadActiveFolderPathIndex( + context.workspaceId, + 'knowledge_base', + undefined, + { maxRows: MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE } + ) + const folderId = resolveFolderPathFromIndex(index, input.path) + const folder = typeof folderId === 'string' ? index.rowById.get(folderId) : undefined + if (!folder) throw new OrchestrationError('not_found', 'Folder not found') + const result = await deleteFolderByPath({ + resourceType: 'knowledge_base', + workspaceId: context.workspaceId, + userId: resolveKnowledgeAttributedUserId(principal, context), + path: input.path, + recursive: input.recursive ?? false, + effects: false, + throwInfrastructure: true, + maxFolderRows: MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE, + }) + if (!result.success || !result.deletedItems) return throwFolderFailure(result) + return { + id: folder.id, + name: folder.name, + path: input.path, + deletedItems: result.deletedItems, + } + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.id, + resourceName: result.name, + description: `Deleted knowledge base folder "${result.path}"`, + metadata: { + source: input.source, + path: result.path, + folderResourceType: 'knowledge_base', + deletedItems: result.deletedItems, + }, + }), + afterSuccess: ({ context }) => notifyFolderResourceChanged('knowledge_base', context.workspaceId), +}) diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.test.ts b/apps/sim/lib/knowledge/application/knowledge-bases.test.ts new file mode 100644 index 00000000000..9ae6b3f7fc9 --- /dev/null +++ b/apps/sim/lib/knowledge/application/knowledge-bases.test.ts @@ -0,0 +1,209 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveWorkspace: vi.fn(), + resolveKnowledgeBase: vi.fn(), + resolvePermission: vi.fn(), + resolveFolderPath: vi.fn(), + createRecord: vi.fn(), + updateRecord: vi.fn(), + deleteRecord: vi.fn(), + listRecords: vi.fn(), + loadFolderIndex: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + KNOWLEDGE_BASE_CREATED: 'knowledge_base.created', + KNOWLEDGE_BASE_UPDATED: 'knowledge_base.updated', + KNOWLEDGE_BASE_DELETED: 'knowledge_base.deleted', + }, + AuditResourceType: { KNOWLEDGE_BASE: 'knowledge_base' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mocks.loadFolderIndex, +})) + +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveKnowledgeWorkspaceContext: mocks.resolveWorkspace, + resolveActiveKnowledgeBaseContext: mocks.resolveKnowledgeBase, +})) + +vi.mock('@/lib/knowledge/application/folder-paths', () => ({ + resolveKnowledgeFolderPath: mocks.resolveFolderPath, + knowledgeFolderPathForId: () => '/', +})) + +vi.mock('@/lib/knowledge/embeddings', () => ({ + EMBEDDING_DIMENSIONS: 1536, + getConfiguredEmbeddingModel: () => 'text-embedding-3-small', +})) + +vi.mock('@/lib/knowledge/service', () => ({ + createAuthorizedKnowledgeBase: mocks.createRecord, + updateKnowledgeBase: mocks.updateRecord, + deleteKnowledgeBase: mocks.deleteRecord, + getWorkspaceKnowledgeBases: mocks.listRecords, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + createKnowledgeBase, + readKnowledgeBase, + updateKnowledgeBaseOperation, +} from '@/lib/knowledge/application/knowledge-bases' + +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const knowledgeBase = { + id: 'knowledge-1', + userId: 'billing-owner-1', + name: 'Docs', + description: null, + tokenCount: 0, + embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, + chunkingConfig: { maxSize: 1024, minSize: 100, overlap: 200 }, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + deletedAt: null, + workspaceId: 'workspace-1', + folderId: null, + docCount: 0, + connectorTypes: [], +} + +describe('knowledge base application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveWorkspace.mockResolvedValue(context) + mocks.resolveKnowledgeBase.mockResolvedValue({ + ...context, + knowledgeBaseId: knowledgeBase.id, + knowledgeBase, + }) + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveFolderPath.mockResolvedValue({ + folderId: null, + index: { pathById: new Map(), idByPath: new Map(), rowById: new Map() }, + }) + mocks.loadFolderIndex.mockResolvedValue({ pathById: new Map() }) + mocks.createRecord.mockResolvedValue(knowledgeBase) + mocks.updateRecord.mockResolvedValue({ ...knowledgeBase, name: 'Renamed' }) + }) + + it('rejects an insufficient role before the protected mutation', async () => { + mocks.resolvePermission.mockResolvedValueOnce('read') + + await expect( + createKnowledgeBase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'workspace-1', name: 'Docs' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.createRecord).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('uses billing ownership only for the workspace-key compatibility column', async () => { + await createKnowledgeBase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + input: { workspaceId: 'workspace-1', name: 'Docs', source: 'v2' }, + }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.createRecord).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'billing-owner-1', workspaceId: 'workspace-1' }), + expect.any(String) + ) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: null, + actorName: 'Workspace API key', + metadata: expect.objectContaining({ + operation: 'knowledge.create', + actor: { + kind: 'workspace_api_key', + keyId: 'workspace-key-1', + workspaceId: 'workspace-1', + }, + }), + }) + ) + }) + + it('conceals a canonical scope mismatch and never audits it', async () => { + mocks.resolveKnowledgeBase.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Knowledge base not found') + ) + + await expect( + readKnowledgeBase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { knowledgeBaseId: 'knowledge-1', assertedWorkspaceId: 'workspace-2' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('propagates infrastructure failures without audit', async () => { + const failure = new Error('database unavailable') + mocks.createRecord.mockRejectedValueOnce(failure) + + await expect( + createKnowledgeBase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'workspace-1', name: 'Docs' }, + }) + ).rejects.toBe(failure) + + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('carries the canonical workspace predicate into the locked update', async () => { + await updateKnowledgeBaseOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + name: 'Renamed', + }, + }) + + expect(mocks.updateRecord).toHaveBeenCalledWith( + 'knowledge-1', + expect.objectContaining({ name: 'Renamed' }), + expect.any(String), + { assertedWorkspaceId: 'workspace-1' } + ) + }) +}) diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.ts b/apps/sim/lib/knowledge/application/knowledge-bases.ts new file mode 100644 index 00000000000..56d4ab1c2db --- /dev/null +++ b/apps/sim/lib/knowledge/application/knowledge-bases.ts @@ -0,0 +1,268 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { resolveKnowledgeAttributedUserId } from '@/lib/knowledge/application/billing' +import { + type ActiveKnowledgeBaseContext, + type KnowledgeWorkspaceContext, + resolveActiveKnowledgeBaseContext, + resolveKnowledgeWorkspaceContext, +} from '@/lib/knowledge/application/contexts' +import { + knowledgeFolderPathForId, + resolveKnowledgeFolderPath, +} from '@/lib/knowledge/application/folder-paths' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + DEFAULT_CHUNKING_CONFIG, + MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE, +} from '@/lib/knowledge/constants' +import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' +import { + createAuthorizedKnowledgeBase, + deleteKnowledgeBase, + getWorkspaceKnowledgeBases, + updateKnowledgeBase, +} from '@/lib/knowledge/service' +import type { ChunkingConfig, KnowledgeBaseWithCounts } from '@/lib/knowledge/types' + +const logger = createLogger('KnowledgeBaseApplication') + +export interface ListKnowledgeBasesInput { + workspaceId: string + folderPath?: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export interface KnowledgeBaseResult { + knowledgeBase: KnowledgeBaseWithCounts + folderPath: string +} + +export interface ListKnowledgeBasesResult { + knowledgeBases: KnowledgeBaseResult[] +} + +export interface CreateKnowledgeBaseInput { + workspaceId: string + name: string + description?: string + chunkingConfig?: Partial<ChunkingConfig> + folderPath?: string + source?: string +} + +export interface ReadKnowledgeBaseInput { + knowledgeBaseId: string + assertedWorkspaceId?: string +} + +export interface UpdateKnowledgeBaseInput extends ReadKnowledgeBaseInput { + name?: string + description?: string + chunkingConfig?: ChunkingConfig + folderPath?: string + source?: string +} + +export interface DeleteKnowledgeBaseInput extends ReadKnowledgeBaseInput { + source?: string +} + +async function executeListKnowledgeBases(args: { + input: ListKnowledgeBasesInput + context: KnowledgeWorkspaceContext +}): Promise<ListKnowledgeBasesResult> { + const index = await loadActiveFolderPathIndex( + args.context.workspaceId, + 'knowledge_base', + undefined, + { maxRows: MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE } + ) + const folderId = + args.input.folderPath === undefined + ? undefined + : await resolveKnowledgeFolderPath(args.context.workspaceId, args.input.folderPath).then( + (resolved) => resolved.folderId + ) + const rows = await getWorkspaceKnowledgeBases(args.context.workspaceId, 'active', { + folderId, + search: args.input.search, + sortBy: args.input.sortBy, + sortOrder: args.input.sortOrder, + }) + return { + knowledgeBases: rows.map((knowledgeBase) => ({ + knowledgeBase, + folderPath: knowledgeFolderPathForId(index, knowledgeBase.folderId), + })), + } +} + +async function executeCreateKnowledgeBase(args: { + principal: Parameters<typeof resolveKnowledgeAttributedUserId>[0] + input: CreateKnowledgeBaseInput + context: KnowledgeWorkspaceContext +}): Promise<KnowledgeBaseResult> { + const path = args.input.folderPath ?? '/' + const { folderId, index } = await resolveKnowledgeFolderPath(args.context.workspaceId, path) + const chunkingConfig: ChunkingConfig = { + ...DEFAULT_CHUNKING_CONFIG, + ...args.input.chunkingConfig, + } + const knowledgeBase = await createAuthorizedKnowledgeBase( + { + name: args.input.name, + description: args.input.description, + workspaceId: args.context.workspaceId, + folderId, + userId: resolveKnowledgeAttributedUserId(args.principal, args.context), + embeddingModel: getConfiguredEmbeddingModel(), + embeddingDimension: EMBEDDING_DIMENSIONS, + chunkingConfig, + }, + generateRequestId() + ) + logger.info('Created knowledge base', { + workspaceId: args.context.workspaceId, + knowledgeBaseId: knowledgeBase.id, + principalKind: args.principal.kind, + }) + return { knowledgeBase, folderPath: knowledgeFolderPathForId(index, knowledgeBase.folderId) } +} + +async function executeReadKnowledgeBase(args: { + context: ActiveKnowledgeBaseContext +}): Promise<KnowledgeBaseResult> { + const index = await loadActiveFolderPathIndex( + args.context.workspaceId, + 'knowledge_base', + undefined, + { maxRows: MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE } + ) + return { + knowledgeBase: args.context.knowledgeBase, + folderPath: knowledgeFolderPathForId(index, args.context.knowledgeBase.folderId), + } +} + +async function executeUpdateKnowledgeBase(args: { + input: UpdateKnowledgeBaseInput + context: ActiveKnowledgeBaseContext +}): Promise<KnowledgeBaseResult> { + const updates = { + name: args.input.name, + description: args.input.description, + chunkingConfig: args.input.chunkingConfig, + folderId: + args.input.folderPath === undefined + ? undefined + : (await resolveKnowledgeFolderPath(args.context.workspaceId, args.input.folderPath)) + .folderId, + } + if (Object.values(updates).every((value) => value === undefined)) { + throw new OrchestrationError('validation', 'No updates specified') + } + const knowledgeBase = await updateKnowledgeBase( + args.context.knowledgeBaseId, + updates, + generateRequestId(), + { assertedWorkspaceId: args.context.workspaceId } + ) + const index = await loadActiveFolderPathIndex( + args.context.workspaceId, + 'knowledge_base', + undefined, + { maxRows: MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE } + ) + logger.info('Updated knowledge base', { + workspaceId: args.context.workspaceId, + knowledgeBaseId: knowledgeBase.id, + }) + return { knowledgeBase, folderPath: knowledgeFolderPathForId(index, knowledgeBase.folderId) } +} + +async function executeDeleteKnowledgeBase(args: { + context: ActiveKnowledgeBaseContext +}): Promise<{ id: string; name: string }> { + await deleteKnowledgeBase(args.context.knowledgeBaseId, generateRequestId(), { + assertedWorkspaceId: args.context.workspaceId, + }) + return { id: args.context.knowledgeBaseId, name: args.context.knowledgeBase.name } +} + +export const listKnowledgeBases = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.list, + resolveContext: ({ input }: { input: ListKnowledgeBasesInput }) => + resolveKnowledgeWorkspaceContext(input), + execute: executeListKnowledgeBases, +}) + +export const createKnowledgeBase = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.create, + resolveContext: ({ input }: { input: CreateKnowledgeBaseInput }) => + resolveKnowledgeWorkspaceContext(input), + execute: executeCreateKnowledgeBase, + projectAudit: ({ input, result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_CREATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: result.knowledgeBase.id, + resourceName: result.knowledgeBase.name, + description: `Created knowledge base "${result.knowledgeBase.name}"`, + metadata: { + source: input.source, + name: result.knowledgeBase.name, + description: result.knowledgeBase.description, + embeddingModel: result.knowledgeBase.embeddingModel, + embeddingDimension: result.knowledgeBase.embeddingDimension, + folderPath: result.folderPath, + }, + }), +}) + +export const readKnowledgeBase = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.read, + resolveContext: ({ input }: { input: ReadKnowledgeBaseInput }) => + resolveActiveKnowledgeBaseContext(input), + execute: executeReadKnowledgeBase, +}) + +export const updateKnowledgeBaseOperation = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.update, + resolveContext: ({ input }: { input: UpdateKnowledgeBaseInput }) => + resolveActiveKnowledgeBaseContext(input), + execute: executeUpdateKnowledgeBase, + projectAudit: ({ input, result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: result.knowledgeBase.id, + resourceName: result.knowledgeBase.name, + description: `Updated knowledge base "${result.knowledgeBase.name}"`, + metadata: { + source: input.source, + updatedFields: ['name', 'description', 'chunkingConfig', 'folderPath'].filter( + (key) => input[key as keyof UpdateKnowledgeBaseInput] !== undefined + ), + }, + }), +}) + +export const deleteKnowledgeBaseOperation = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.delete, + resolveContext: ({ input }: { input: DeleteKnowledgeBaseInput }) => + resolveActiveKnowledgeBaseContext(input), + execute: executeDeleteKnowledgeBase, + projectAudit: ({ input, result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: result.id, + resourceName: result.name, + description: `Deleted knowledge base "${result.name}"`, + metadata: { source: input.source, knowledgeBaseName: result.name }, + }), +}) diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts new file mode 100644 index 00000000000..49b12c42476 --- /dev/null +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -0,0 +1,50 @@ +/** + * @vitest-environment node + */ + +import { permissionSatisfies } from '@sim/platform-authz/workspace' +import { describe, expect, it } from 'vitest' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' + +describe('knowledge operation registry', () => { + it('defines unique stable semantic operation IDs', () => { + const ids = Object.values(knowledgeOperations).map((operation) => operation.id) + expect(ids).toEqual([ + 'knowledge.list', + 'knowledge.read', + 'knowledge.create', + 'knowledge.update', + 'knowledge.delete', + 'knowledge.search', + 'knowledge.folders.list', + 'knowledge.folders.create', + 'knowledge.folders.relocate', + 'knowledge.folders.delete', + 'knowledge.documents.list', + 'knowledge.documents.read', + 'knowledge.documents.upload', + 'knowledge.documents.delete', + 'knowledge.documents.upload.create', + 'knowledge.documents.upload.parts', + 'knowledge.documents.upload.complete', + 'knowledge.documents.upload.cancel', + ]) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('keeps workspace keys within their fixed write ceiling', () => { + for (const operation of Object.values(knowledgeOperations)) { + expect(operation.workspaceApiKey).toBe('allow') + expect(operation.principalKinds).toContain('workspace_api_key') + expect(permissionSatisfies('write', operation.minimumRole)).toBe(true) + } + }) + + it('allows delegated callers only on semantic knowledge and document operations', () => { + expect(knowledgeOperations.list.principalKinds).toContain('delegated') + expect(knowledgeOperations.search.principalKinds).toContain('delegated') + expect(knowledgeOperations.uploadDocument.principalKinds).toContain('delegated') + expect(knowledgeOperations.listFolders.principalKinds).not.toContain('delegated') + expect(knowledgeOperations.uploadComplete.principalKinds).not.toContain('delegated') + }) +}) diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts new file mode 100644 index 00000000000..199ab6931d1 --- /dev/null +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -0,0 +1,123 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const ALL_PRINCIPAL_KINDS = [ + 'session', + 'personal_api_key', + 'workspace_api_key', + 'delegated', +] as const + +const HTTP_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'workspace_api_key'] as const + +export const knowledgeOperations = { + list: defineWorkspaceOperation({ + id: 'knowledge.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + read: defineWorkspaceOperation({ + id: 'knowledge.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + create: defineWorkspaceOperation({ + id: 'knowledge.create', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + update: defineWorkspaceOperation({ + id: 'knowledge.update', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + delete: defineWorkspaceOperation({ + id: 'knowledge.delete', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + search: defineWorkspaceOperation({ + id: 'knowledge.search', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + listFolders: defineWorkspaceOperation({ + id: 'knowledge.folders.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: HTTP_PRINCIPAL_KINDS, + }), + createFolder: defineWorkspaceOperation({ + id: 'knowledge.folders.create', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: HTTP_PRINCIPAL_KINDS, + }), + relocateFolder: defineWorkspaceOperation({ + id: 'knowledge.folders.relocate', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: HTTP_PRINCIPAL_KINDS, + }), + deleteFolder: defineWorkspaceOperation({ + id: 'knowledge.folders.delete', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: HTTP_PRINCIPAL_KINDS, + }), + listDocuments: defineWorkspaceOperation({ + id: 'knowledge.documents.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + readDocument: defineWorkspaceOperation({ + id: 'knowledge.documents.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + uploadDocument: defineWorkspaceOperation({ + id: 'knowledge.documents.upload', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + deleteDocument: defineWorkspaceOperation({ + id: 'knowledge.documents.delete', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + uploadCreate: defineWorkspaceOperation({ + id: 'knowledge.documents.upload.create', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: HTTP_PRINCIPAL_KINDS, + }), + uploadParts: defineWorkspaceOperation({ + id: 'knowledge.documents.upload.parts', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: HTTP_PRINCIPAL_KINDS, + }), + uploadComplete: defineWorkspaceOperation({ + id: 'knowledge.documents.upload.complete', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: HTTP_PRINCIPAL_KINDS, + }), + uploadCancel: defineWorkspaceOperation({ + id: 'knowledge.documents.upload.cancel', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: HTTP_PRINCIPAL_KINDS, + }), +} as const + +export type KnowledgeOperation = (typeof knowledgeOperations)[keyof typeof knowledgeOperations] diff --git a/apps/sim/lib/knowledge/application/search.test.ts b/apps/sim/lib/knowledge/application/search.test.ts new file mode 100644 index 00000000000..a56e6350ac4 --- /dev/null +++ b/apps/sim/lib/knowledge/application/search.test.ts @@ -0,0 +1,272 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getKnowledgeBase: vi.fn(), + resolveBilling: vi.fn(), + checkUsage: vi.fn(), + generateEmbedding: vi.fn(), + executeSearch: vi.fn(), + getDocumentMetadata: vi.fn(), + getTagDefinitions: vi.fn(), + recordEmbeddingUsage: vi.fn(), + importProvenance: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveBillingAttribution: mocks.resolveBilling, + resolveSystemBillingAttribution: mocks.resolveBilling, + checkAttributedUsageLimits: mocks.checkUsage, +})) + +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveKnowledgeWorkspaceContext: mocks.resolveWorkspace, +})) + +vi.mock('@/lib/knowledge/service', () => ({ + getKnowledgeBaseById: mocks.getKnowledgeBase, +})) + +vi.mock('@/lib/knowledge/embeddings', () => ({ + generateSearchEmbedding: mocks.generateEmbedding, + recordSearchEmbeddingUsage: mocks.recordEmbeddingUsage, +})) + +vi.mock('@/lib/knowledge/search/queries', () => ({ + generateSearchEmbedding: mocks.generateEmbedding, + executeKnowledgeSearch: mocks.executeSearch, + getDocumentMetadataByIds: mocks.getDocumentMetadata, +})) + +vi.mock('@/lib/knowledge/tags/service', () => ({ + getDocumentTagDefinitions: mocks.getTagDefinitions, +})) + +vi.mock('@/lib/knowledge/tags/utils', () => ({ + buildUndefinedTagsError: (tags: string[]) => `Undefined tags: ${tags.join(', ')}`, + validateTagValue: () => null, +})) + +vi.mock('@/lib/knowledge/secret-provenance', () => ({ + importKnowledgeSearchResultSecretProvenance: mocks.importProvenance, +})) + +import { searchKnowledge } from '@/lib/knowledge/application/search' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const knowledgeBase = { + id: 'knowledge-1', + workspaceId: 'workspace-1', + embeddingModel: 'text-embedding-3-small', +} + +describe('knowledge search application use case', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getKnowledgeBase.mockResolvedValue(knowledgeBase) + mocks.resolveBilling.mockResolvedValue({ + actorUserId: 'user-1', + workspaceId: 'workspace-1', + }) + mocks.checkUsage.mockResolvedValue({ isExceeded: false }) + mocks.generateEmbedding.mockResolvedValue({ embedding: [0.1], isBYOK: false }) + mocks.executeSearch.mockResolvedValue([ + { + id: 'embedding-1', + documentId: 'document-1', + knowledgeBaseId: 'knowledge-1', + content: 'answer', + chunkIndex: 0, + distance: 0.2, + tag1: null, + tag2: null, + tag3: null, + tag4: null, + tag5: null, + tag6: null, + tag7: null, + number1: null, + number2: null, + number3: null, + number4: null, + number5: null, + date1: null, + date2: null, + boolean1: null, + boolean2: null, + boolean3: null, + }, + ]) + mocks.getDocumentMetadata.mockResolvedValue({ + 'document-1': { filename: 'guide.pdf', sourceUrl: null }, + }) + mocks.getTagDefinitions.mockResolvedValue([]) + mocks.recordEmbeddingUsage.mockResolvedValue(undefined) + mocks.importProvenance.mockResolvedValue({ imported: true, documentMetadata: {} }) + }) + + it('authorizes every canonical knowledge base before billing and search', async () => { + const result = await searchKnowledge.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + query: 'answer', + topK: 5, + }, + }) + + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.resolveBilling.mock.invocationCallOrder[0] + ) + expect(mocks.resolveBilling.mock.invocationCallOrder[0]).toBeLessThan( + mocks.executeSearch.mock.invocationCallOrder[0] + ) + expect(mocks.executeSearch).toHaveBeenCalledWith( + expect.objectContaining({ + knowledgeBaseIds: ['knowledge-1'], + topK: 5, + searchMode: 'vector', + }) + ) + expect(result.results[0]).toMatchObject({ + embeddingId: 'embedding-1', + documentId: 'document-1', + similarity: 0.8, + }) + }) + + it('rejects a cross-workspace knowledge base before authorization or spend', async () => { + mocks.getKnowledgeBase.mockResolvedValueOnce({ + ...knowledgeBase, + workspaceId: 'workspace-2', + }) + + await expect( + searchKnowledge.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + query: 'answer', + topK: 5, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.resolveBilling).not.toHaveBeenCalled() + expect(mocks.executeSearch).not.toHaveBeenCalled() + }) + + it('enforces semantic knowledge-base and result bounds for trusted callers', async () => { + await expect( + searchKnowledge.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'workspace-1', + knowledgeBaseIds: Array.from({ length: 21 }, (_, index) => `knowledge-${index}`), + query: 'answer', + topK: 5, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + await expect( + searchKnowledge.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + query: 'answer', + topK: 101, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveWorkspace).not.toHaveBeenCalled() + expect(mocks.getKnowledgeBase).not.toHaveBeenCalled() + }) + + it('rejects multi-knowledge-base tag filters without embedding spend', async () => { + mocks.getKnowledgeBase + .mockResolvedValueOnce(knowledgeBase) + .mockResolvedValueOnce({ ...knowledgeBase, id: 'knowledge-2' }) + + await expect( + searchKnowledge.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1', 'knowledge-2'], + topK: 5, + tagFilters: [{ tagName: 'team', operator: 'eq', value: 'docs' }], + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.generateEmbedding).not.toHaveBeenCalled() + expect(mocks.executeSearch).not.toHaveBeenCalled() + }) + + it('verifies trusted result provenance inside the authorized use case', async () => { + const registry = { markIncomplete: vi.fn() } + await searchKnowledge.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + query: 'answer', + topK: 5, + resultSecretRegistry: registry as never, + }, + }) + + expect(mocks.importProvenance).toHaveBeenCalledWith({ + registry, + results: expect.arrayContaining([ + expect.objectContaining({ id: 'embedding-1', documentId: 'document-1' }), + ]), + }) + }) + + it('propagates tag-definition infrastructure failures', async () => { + const failure = new Error('tag database unavailable') + mocks.getTagDefinitions.mockRejectedValueOnce(failure) + + await expect( + searchKnowledge.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + query: 'answer', + topK: 5, + }, + }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/knowledge/application/search.ts b/apps/sim/lib/knowledge/application/search.ts new file mode 100644 index 00000000000..778806335df --- /dev/null +++ b/apps/sim/lib/knowledge/application/search.ts @@ -0,0 +1,278 @@ +import { checkAttributedUsageLimits } from '@/lib/billing/core/billing-attribution' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { + KnowledgeUsageLimitExceededError, + resolveKnowledgeAttributedUserId, + resolveKnowledgeBillingAttribution, +} from '@/lib/knowledge/application/billing' +import { + type KnowledgeWorkspaceContext, + resolveKnowledgeWorkspaceContext, +} from '@/lib/knowledge/application/contexts' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' +import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' +import { + executeKnowledgeSearch, + generateSearchEmbedding, + getDocumentMetadataByIds, + type SearchResult, +} from '@/lib/knowledge/search/queries' +import { importKnowledgeSearchResultSecretProvenance } from '@/lib/knowledge/secret-provenance' +import { getKnowledgeBaseById } from '@/lib/knowledge/service' +import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' +import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' +import type { KnowledgeBaseWithCounts, StructuredFilter } from '@/lib/knowledge/types' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +export interface KnowledgeSearchTagFilter { + tagName: string + fieldType?: 'text' | 'number' | 'date' | 'boolean' + operator: string + value: string | number | boolean + valueTo?: string | number +} + +export interface SearchKnowledgeInput { + workspaceId: string + knowledgeBaseIds: string[] + query?: string + topK: number + tagFilters?: KnowledgeSearchTagFilter[] + /** Trusted execution provenance sink; never sourced from an HTTP or model payload. */ + resultSecretRegistry?: ResolvedSecretTraceRegistry +} + +interface KnowledgeSearchContext extends KnowledgeWorkspaceContext { + knowledgeBases: KnowledgeBaseWithCounts[] +} + +export interface KnowledgeSearchItem { + /** Trusted embedding identity for provenance import; HTTP presenters omit it. */ + embeddingId: string + documentId: string + documentName: string | null + sourceUrl: string | null + content: string + chunkIndex: number + metadata: Record<string, unknown> + similarity: number +} + +export interface SearchKnowledgeResult { + results: KnowledgeSearchItem[] + query: string + knowledgeBaseIds: string[] + topK: number + totalResults: number +} + +async function resolveKnowledgeSearchContext( + input: SearchKnowledgeInput +): Promise<KnowledgeSearchContext> { + if (input.knowledgeBaseIds.length < 1 || input.knowledgeBaseIds.length > 20) { + throw new OrchestrationError( + 'validation', + 'Knowledge search requires between 1 and 20 knowledge bases' + ) + } + if (!Number.isInteger(input.topK) || input.topK < 1 || input.topK > 100) { + throw new OrchestrationError('validation', 'topK must be an integer between 1 and 100') + } + const workspaceContext = await resolveKnowledgeWorkspaceContext(input) + const knowledgeBases = await Promise.all(input.knowledgeBaseIds.map(getKnowledgeBaseById)) + const inaccessibleIds = input.knowledgeBaseIds.filter( + (_id, index) => knowledgeBases[index]?.workspaceId !== workspaceContext.workspaceId + ) + if (inaccessibleIds.length > 0) { + throw new OrchestrationError( + 'not_found', + `Knowledge bases not found or access denied: ${inaccessibleIds.join(', ')}` + ) + } + return { + ...workspaceContext, + knowledgeBases: knowledgeBases as KnowledgeBaseWithCounts[], + } +} + +function buildStructuredFilters( + filters: KnowledgeSearchTagFilter[], + tagDefinitions: Awaited<ReturnType<typeof getDocumentTagDefinitions>> +): StructuredFilter[] { + const definitionsByName = new Map( + tagDefinitions.map((definition) => [definition.displayName, definition]) + ) + const undefinedTags: string[] = [] + const typeErrors: string[] = [] + for (const filter of filters) { + const definition = definitionsByName.get(filter.tagName) + if (!definition) { + undefinedTags.push(filter.tagName) + continue + } + const validationError = validateTagValue( + filter.tagName, + String(filter.value), + definition.fieldType + ) + if (validationError) typeErrors.push(validationError) + } + if (undefinedTags.length > 0 || typeErrors.length > 0) { + const messages = [ + ...(undefinedTags.length > 0 ? [buildUndefinedTagsError(undefinedTags)] : []), + ...typeErrors, + ] + throw new OrchestrationError('validation', messages.join('\n')) + } + return filters.map((filter) => { + const definition = definitionsByName.get(filter.tagName) + if (!definition) throw new Error('Validated knowledge tag definition disappeared') + return { + tagSlot: definition.tagSlot, + fieldType: definition.fieldType, + operator: filter.operator, + value: filter.value, + valueTo: filter.valueTo, + } + }) +} + +export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.search, + resolveContext: ({ input }: { input: SearchKnowledgeInput }) => + resolveKnowledgeSearchContext(input), + async execute({ principal, input, context }) { + const hasQuery = Boolean(input.query?.trim()) + const filters = input.tagFilters ?? [] + if (!hasQuery && filters.length === 0) { + throw new OrchestrationError('validation', 'Either query or tagFilters must be provided') + } + if (filters.length > 0 && context.knowledgeBases.length > 1) { + throw new OrchestrationError( + 'validation', + 'Tag filters are only supported when searching a single knowledge base' + ) + } + + const billingAttribution = hasQuery + ? await resolveKnowledgeBillingAttribution(principal, context) + : undefined + if (billingAttribution) { + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + throw new KnowledgeUsageLimitExceededError( + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + } + + const tagDefinitionsByKnowledgeBase = new Map< + string, + Awaited<ReturnType<typeof getDocumentTagDefinitions>> + >() + let structuredFilters: StructuredFilter[] = [] + if (filters.length > 0) { + const knowledgeBaseId = context.knowledgeBases[0].id + const definitions = await getDocumentTagDefinitions(knowledgeBaseId) + tagDefinitionsByKnowledgeBase.set(knowledgeBaseId, definitions) + structuredFilters = buildStructuredFilters(filters, definitions) + } + + const embeddingModels = [...new Set(context.knowledgeBases.map((kb) => kb.embeddingModel))] + if (hasQuery && embeddingModels.length > 1) { + throw new OrchestrationError( + 'validation', + 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.' + ) + } + const embeddingModel = embeddingModels[0] + let queryEmbeddingIsBYOK: boolean | null = null + let queryVector: string | undefined + if (hasQuery) { + const generated = await generateSearchEmbedding( + input.query!, + embeddingModel, + context.workspaceId + ) + queryEmbeddingIsBYOK = generated.isBYOK + queryVector = JSON.stringify(generated.embedding) + } + + const knowledgeBaseIds = context.knowledgeBases.map((kb) => kb.id) + const rows = await executeKnowledgeSearch({ + knowledgeBaseIds, + topK: input.topK, + searchMode: 'vector', + query: input.query, + queryVector, + structuredFilters, + }) + + if (input.resultSecretRegistry) { + const provenance = await importKnowledgeSearchResultSecretProvenance({ + registry: input.resultSecretRegistry, + results: rows, + }) + if (!provenance.imported) { + input.resultSecretRegistry.markIncomplete() + throw new Error('Knowledge result secret provenance is unavailable') + } + } + + if (queryEmbeddingIsBYOK !== null && billingAttribution) { + await recordSearchEmbeddingUsage({ + userId: resolveKnowledgeAttributedUserId(principal, context), + workspaceId: context.workspaceId, + embeddingModel, + query: input.query!, + isBYOK: queryEmbeddingIsBYOK, + sourceReference: `v2-kb-search:${generateRequestId()}`, + billingAttribution, + }) + } + + const tagDefinitionEntries = await Promise.all( + knowledgeBaseIds.map(async (knowledgeBaseId) => { + const definitions = + tagDefinitionsByKnowledgeBase.get(knowledgeBaseId) ?? + (await getDocumentTagDefinitions(knowledgeBaseId)) + return [ + knowledgeBaseId, + new Map(definitions.map((definition) => [definition.tagSlot, definition.displayName])), + ] as const + }) + ) + const tagMaps = new Map(tagDefinitionEntries) + const documentMetadata = await getDocumentMetadataByIds(rows.map((row) => row.documentId)) + + const results = rows.map((row: SearchResult): KnowledgeSearchItem => { + const metadata: Record<string, unknown> = {} + const tagMap = tagMaps.get(row.knowledgeBaseId) + for (const slot of ALL_TAG_SLOTS) { + const value = row[slot] + if (value !== null && value !== undefined) metadata[tagMap?.get(slot) ?? slot] = value + } + const document = documentMetadata[row.documentId] + return { + embeddingId: row.id, + documentId: row.documentId, + documentName: document?.filename ?? null, + sourceUrl: document?.sourceUrl ?? null, + content: row.content, + chunkIndex: row.chunkIndex, + metadata, + similarity: hasQuery ? 1 - row.distance : 1, + } + }) + return { + results, + query: input.query ?? '', + knowledgeBaseIds, + topK: input.topK, + totalResults: results.length, + } + }, +}) diff --git a/apps/sim/lib/knowledge/application/upload-sessions.test.ts b/apps/sim/lib/knowledge/application/upload-sessions.test.ts new file mode 100644 index 00000000000..e4b43e9cf83 --- /dev/null +++ b/apps/sim/lib/knowledge/application/upload-sessions.test.ts @@ -0,0 +1,620 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + abortUpload: vi.fn(), + assertBinding: vi.fn(), + checkUsage: vi.fn(), + completeUpload: vi.fn(), + createDocument: vi.fn(), + createPartUrls: vi.fn(), + createUpload: vi.fn(), + findBound: vi.fn(), + getUpload: vi.fn(), + processQueue: vi.fn(), + recordAudit: vi.fn(), + recordOwnership: vi.fn(), + resolveBilling: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + validateFileType: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { DOCUMENT_UPLOADED: 'document.uploaded' }, + AuditResourceType: { DOCUMENT: 'document' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + checkAttributedUsageLimits: mocks.checkUsage, + resolveBillingAttribution: mocks.resolveBilling, + resolveSystemBillingAttribution: mocks.resolveBilling, +})) + +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveActiveKnowledgeBaseContext: mocks.resolveContext, +})) + +vi.mock('@/lib/knowledge/documents/service', () => ({ + createSingleDocument: mocks.createDocument, + processDocumentsWithQueue: mocks.processQueue, +})) + +vi.mock('@/lib/knowledge/orchestration/documents', () => ({ + findBoundKnowledgeDocument: mocks.findBound, +})) + +vi.mock('@/lib/uploads/server/metadata', () => ({ + recordKnowledgeBaseFileOwnership: mocks.recordOwnership, +})) + +vi.mock('@/lib/uploads/upload-session/application', () => ({ + requestOrigin: () => 'http://localhost:3000', +})) + +vi.mock('@/lib/uploads/upload-session/service', () => ({ + abortUploadSession: mocks.abortUpload, + assertUploadSessionAuthBinding: mocks.assertBinding, + completeUploadSession: mocks.completeUpload, + createUploadPartUrls: mocks.createPartUrls, + createUploadSession: mocks.createUpload, + getPrincipalKnowledgeDocumentUploadSession: mocks.getUpload, +})) + +vi.mock('@/lib/uploads/utils/validation', () => ({ + validateFileType: mocks.validateFileType, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + cancelKnowledgeDocumentUpload, + completeKnowledgeDocumentUpload, + createKnowledgeDocumentUpload, + issueKnowledgeDocumentUploadParts, +} from '@/lib/knowledge/application/upload-sessions' +import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' + +const CONTEXT = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + knowledgeBaseId: 'knowledge-1', + knowledgeBase: { id: 'knowledge-1', name: 'Docs', workspaceId: 'workspace-1' }, +} +const PRINCIPAL = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const BILLING = { + actorUserId: 'user-1', + workspaceId: 'workspace-1', + organizationId: 'organization-1', + billedAccountUserId: 'billing-owner-1', + billingEntity: { id: 'organization-1', type: 'organization' }, + billingPeriod: { start: '2026-08-01', end: '2026-09-01' }, + payerSubscription: null, +} +const SESSION: UploadSessionRecord = { + id: 'upload-1', + workspaceId: 'workspace-1', + userId: 'user-1', + knowledgeBaseId: 'knowledge-1', + workflowId: null, + executionId: null, + purpose: 'knowledge_document', + method: 'multipart', + storageContext: 'knowledge-base', + storageKey: 'kb/guide.pdf', + finalKey: 'kb/guide.pdf', + storageProvider: 's3', + providerUploadId: 'provider-1', + providerObjectVersion: null, + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + partSize: 8 * 1024 * 1024, + partCount: 1, + status: 'uploading', + metadata: { + tag1: 'product', + processingOptions: { recipe: 'default', lang: 'en' }, + authBinding: { + version: 1, + workspaceId: 'workspace-1', + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + }, + }, + uploadToken: 'token', + createdAt: new Date('2026-08-03T21:00:00.000Z'), + expiresAt: new Date('2026-08-04T21:00:00.000Z'), + completedFileId: null, + error: null, + completedAt: null, + updatedAt: new Date('2026-08-03T21:00:00.000Z'), +} +const DOCUMENT = { + id: 'upload-1', + knowledgeBaseId: 'knowledge-1', + filename: 'guide.pdf', + fileUrl: '/api/files/serve/s3/kb%2Fguide.pdf?context=knowledge-base', + fileSize: 1024, + mimeType: 'application/pdf', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: true, + uploadedAt: new Date('2026-08-03T21:01:00.000Z'), + tag1: 'product', + tag2: null, + tag3: null, + tag4: null, + tag5: null, + tag6: null, + tag7: null, +} +const REQUEST = { headers: new Headers() } + +describe('knowledge-document upload application lifecycle', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(CONTEXT) + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveBilling.mockResolvedValue(BILLING) + mocks.checkUsage.mockResolvedValue({ isExceeded: false }) + mocks.validateFileType.mockReturnValue(null) + mocks.createUpload.mockResolvedValue({ + ...SESSION, + transfer: { method: 'multipart', partSize: SESSION.partSize, partCount: 1 }, + }) + mocks.recordOwnership.mockResolvedValue(undefined) + mocks.getUpload.mockResolvedValue(SESSION) + mocks.createPartUrls.mockResolvedValue([ + { + partNumber: 1, + url: 'https://storage.example/1', + headers: {}, + expiresAt: '2026-08-04T21:00:00.000Z', + }, + ]) + mocks.abortUpload.mockResolvedValue({ ...SESSION, status: 'aborted' }) + mocks.findBound.mockResolvedValue({ status: 'absent' }) + mocks.createDocument.mockResolvedValue(DOCUMENT) + mocks.processQueue.mockResolvedValue(undefined) + }) + + it('admits, binds, and records ownership before returning upload credentials', async () => { + await createKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + name: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + metadata: { tag1: 'product' }, + }, + request: REQUEST, + }) + + expect(mocks.createUpload).toHaveBeenCalledWith( + expect.objectContaining({ + purpose: 'knowledge_document', + principal: PRINCIPAL, + userId: 'user-1', + workspaceId: 'workspace-1', + knowledgeBaseId: 'knowledge-1', + }) + ) + expect(mocks.recordOwnership).toHaveBeenCalledWith( + expect.objectContaining({ key: SESSION.storageKey, workspaceId: 'workspace-1' }) + ) + expect(mocks.recordOwnership.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.createUpload.mock.invocationCallOrder[0] + ) + }) + + it('rejects insufficient role before allocating provider state', async () => { + mocks.resolvePermission.mockResolvedValue('read') + + await expect( + createKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + name: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + metadata: {}, + }, + request: REQUEST, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.createUpload).not.toHaveBeenCalled() + }) + + it('aborts provider state and propagates an ownership registration failure', async () => { + const failure = new Error('ownership database unavailable') + mocks.recordOwnership.mockRejectedValue(failure) + + await expect( + createKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + name: 'guide.pdf', + contentType: 'application/pdf', + size: 1024, + metadata: {}, + }, + request: REQUEST, + }) + ).rejects.toBe(failure) + expect(mocks.abortUpload).toHaveBeenCalledWith(expect.objectContaining({ id: 'upload-1' })) + }) + + it('reauthorizes and verifies the immutable credential on the parts leg', async () => { + await issueKnowledgeDocumentUploadParts.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + uploadId: 'upload-1', + uploadToken: 'token', + partNumbers: [1], + }, + request: REQUEST, + }) + + expect(mocks.getUpload).toHaveBeenCalledWith( + expect.objectContaining({ principal: PRINCIPAL, uploadId: 'upload-1' }) + ) + expect(mocks.assertBinding).toHaveBeenCalledWith(SESSION, PRINCIPAL) + expect(mocks.resolvePermission).toHaveBeenCalledTimes(2) + expect(mocks.createPartUrls).toHaveBeenCalledWith( + expect.objectContaining({ session: SESSION, partNumbers: [1] }) + ) + }) + + it('checks durable binding before canceling', async () => { + await cancelKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + uploadId: 'upload-1', + uploadToken: 'token', + }, + request: REQUEST, + }) + + expect(mocks.findBound).toHaveBeenCalledWith( + expect.objectContaining({ documentId: 'upload-1', knowledgeBaseId: 'knowledge-1' }) + ) + expect(mocks.abortUpload).toHaveBeenCalledWith(SESSION) + }) + + it('reauthorizes immediately before durable registration and audits the created document', async () => { + 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.created).toBe(true) + expect(mocks.resolvePermission.mock.calls.length).toBeGreaterThanOrEqual(4) + expect(mocks.createDocument).toHaveBeenCalledWith( + expect.any(Object), + 'knowledge-1', + expect.any(String), + 'user-1', + 'upload-1', + undefined, + { expectedWorkspaceId: 'workspace-1' } + ) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'document.uploaded', + resourceId: 'upload-1', + metadata: expect.objectContaining({ operation: 'knowledge.documents.upload.complete' }), + }) + ) + }) + + it('returns an already-bound document without re-billing, re-registering, or auditing', async () => { + mocks.findBound.mockResolvedValue({ status: 'bound', document: DOCUMENT }) + mocks.completeUpload.mockImplementation( + async (params: { + session: UploadSessionRecord + finalize: (session: UploadSessionRecord) => Promise<{ + value: { document: typeof DOCUMENT; created: boolean; knowledgeBaseName: string | null } + }> + }) => ({ + session: { ...params.session, status: 'completed' as const }, + value: (await params.finalize({ ...params.session, error: null })).value, + alreadyCompleted: true, + }) + ) + + 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(false) + expect(mocks.resolveBilling).not.toHaveBeenCalled() + expect(mocks.createDocument).not.toHaveBeenCalled() + expect(mocks.processQueue).not.toHaveBeenCalled() + 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) + mocks.completeUpload.mockImplementation( + async (params: { + session: UploadSessionRecord + finalize: (session: UploadSessionRecord) => Promise<unknown> + }) => params.finalize(params.session) + ) + + 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, + }) + expect(mocks.createDocument).toHaveBeenCalledTimes(1) + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('retries a failed processing dispatch before completing a bound registration', async () => { + const recoveringSession = { + ...SESSION, + status: 'finalizing' as const, + completedFileId: null, + error: 'Knowledge document processing dispatch failed', + } + mocks.getUpload.mockResolvedValue(recoveringSession) + mocks.findBound.mockResolvedValue({ + status: 'bound', + document: { ...DOCUMENT, processingStatus: 'pending' }, + }) + mocks.completeUpload.mockImplementation( + async (params: { + session: UploadSessionRecord + finalize: (session: UploadSessionRecord) => Promise<{ + value: { document: typeof DOCUMENT; created: boolean; knowledgeBaseName: string | null } + }> + }) => ({ + session: { ...params.session, status: 'completed' as const }, + value: (await params.finalize({ ...params.session, error: null })).value, + alreadyCompleted: true, + }) + ) + + 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(mocks.resolveBilling).toHaveBeenCalledTimes(1) + expect(mocks.processQueue).toHaveBeenCalledTimes(1) + expect(mocks.createDocument).not.toHaveBeenCalled() + expect(mocks.recordAudit).toHaveBeenCalledTimes(1) + }) + + it('converges a finalization retry after durable bind without duplicate document or audit', async () => { + const recoveringSession = { + ...SESSION, + status: 'finalizing' as const, + completedFileId: null, + } + const completedSession = { + ...recoveringSession, + status: 'completed' as const, + completedFileId: DOCUMENT.id, + } + mocks.getUpload.mockResolvedValueOnce(recoveringSession).mockResolvedValueOnce(completedSession) + mocks.findBound.mockResolvedValue({ status: 'bound', document: DOCUMENT }) + mocks.completeUpload.mockImplementation( + async (params: { + session: UploadSessionRecord + finalize: (session: UploadSessionRecord) => Promise<{ + value: { document: typeof DOCUMENT; created: boolean; knowledgeBaseName: string | null } + }> + loadCompleted: (session: UploadSessionRecord) => Promise<{ + document: typeof DOCUMENT + created: boolean + knowledgeBaseName: string | null + }> + }) => { + if (params.session.status === 'completed') { + return { + session: params.session, + value: await params.loadCompleted(params.session), + alreadyCompleted: true, + } + } + return { + session: completedSession, + value: (await params.finalize(params.session)).value, + alreadyCompleted: true, + } + } + ) + + const input = { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + uploadId: 'upload-1', + uploadToken: 'token', + source: 'api' as const, + } + const recovered = await completeKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input, + request: REQUEST, + }) + const retry = await completeKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input, + request: REQUEST, + }) + + expect(recovered.value.created).toBe(true) + expect(retry.value.created).toBe(false) + expect(mocks.createDocument).not.toHaveBeenCalled() + expect(mocks.processQueue).not.toHaveBeenCalled() + expect(mocks.recordAudit).toHaveBeenCalledTimes(1) + }) + + it('fails fast when billing ownership changes before durable registration', async () => { + mocks.resolveBilling.mockResolvedValue({ ...BILLING, billedAccountUserId: 'stale-owner' }) + mocks.completeUpload.mockImplementation( + async (params: { + session: UploadSessionRecord + finalize: (session: UploadSessionRecord) => Promise<unknown> + }) => params.finalize(params.session) + ) + + await expect( + completeKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + uploadId: 'upload-1', + uploadToken: 'token', + source: 'api', + }, + request: REQUEST, + }) + ).rejects.toThrow('billing attribution changed') + expect(mocks.createDocument).not.toHaveBeenCalled() + }) + + it('propagates provider completion failures without audit or registration', async () => { + const failure = new Error('provider unavailable') + mocks.completeUpload.mockRejectedValue(failure) + + await expect( + completeKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + uploadId: 'upload-1', + uploadToken: 'token', + source: 'api', + }, + request: REQUEST, + }) + ).rejects.toBe(failure) + expect(mocks.createDocument).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('propagates canonical infrastructure failures without concealment fallback', async () => { + const failure = new Error('database unavailable') + mocks.resolveContext.mockRejectedValue(failure) + + await expect( + issueKnowledgeDocumentUploadParts.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + uploadId: 'upload-1', + uploadToken: 'token', + partNumbers: [1], + }, + request: REQUEST, + }) + ).rejects.toBe(failure) + }) + + it('conceals an asserted workspace mismatch as not found', async () => { + mocks.resolveContext.mockRejectedValue( + new OrchestrationError('not_found', 'Knowledge base not found') + ) + + await expect( + cancelKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'different-workspace', + uploadId: 'upload-1', + uploadToken: 'token', + }, + request: REQUEST, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) +}) diff --git a/apps/sim/lib/knowledge/application/upload-sessions.ts b/apps/sim/lib/knowledge/application/upload-sessions.ts new file mode 100644 index 00000000000..3d8586ad9da --- /dev/null +++ b/apps/sim/lib/knowledge/application/upload-sessions.ts @@ -0,0 +1,471 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import type { V2KnowledgeDocumentUploadMetadata } from '@/lib/api/contracts/v2/knowledge' +import { v2KnowledgeDocumentUploadMetadataSchema } from '@/lib/api/contracts/v2/knowledge' +import { checkAttributedUsageLimits } from '@/lib/billing/core/billing-attribution' +import { authorizeWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' +import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { + KnowledgeUsageLimitExceededError, + resolveKnowledgeAttributedUserId, + resolveKnowledgeBillingAttribution, +} from '@/lib/knowledge/application/billing' +import { + type ActiveKnowledgeBaseContext, + resolveActiveKnowledgeBaseContext, +} from '@/lib/knowledge/application/contexts' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + createSingleDocument, + type DocumentData, + processDocumentsWithQueue, +} from '@/lib/knowledge/documents/service' +import type { CreatedKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' +import { findBoundKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' +import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' +import { requestOrigin } from '@/lib/uploads/upload-session/application' +import { + abortUploadSession, + assertUploadSessionAuthBinding, + completeUploadSession, + createUploadPartUrls, + createUploadSession, + getPrincipalKnowledgeDocumentUploadSession, + type UploadSessionRecord, +} from '@/lib/uploads/upload-session/service' +import { validateFileType } from '@/lib/uploads/utils/validation' + +const PROCESSING_DISPATCH_FAILURE_MESSAGE = 'Knowledge document processing dispatch failed' + +class KnowledgeDocumentProcessingDispatchError extends Error { + constructor(cause: unknown) { + super(PROCESSING_DISPATCH_FAILURE_MESSAGE, { cause }) + this.name = 'KnowledgeDocumentProcessingDispatchError' + } +} + +export class KnowledgeDocumentUnsupportedMediaTypeError extends Error { + constructor(message: string) { + super(message) + this.name = 'KnowledgeDocumentUnsupportedMediaTypeError' + } +} + +export interface CreateKnowledgeDocumentUploadInput { + knowledgeBaseId: string + assertedWorkspaceId: string + name: string + contentType: string + size: number + metadata: V2KnowledgeDocumentUploadMetadata +} + +export interface KnowledgeDocumentUploadControlInput { + knowledgeBaseId: string + assertedWorkspaceId: string + uploadId: string + uploadToken: string +} + +export interface IssueKnowledgeDocumentUploadPartsInput + extends KnowledgeDocumentUploadControlInput { + partNumbers: number[] +} + +export interface CompleteKnowledgeDocumentUploadInput extends KnowledgeDocumentUploadControlInput { + source: 'api' | 'ui' +} + +interface KnowledgeDocumentUploadCompletion { + document: CreatedKnowledgeDocument + created: boolean + knowledgeBaseName: string | null +} + +export interface CompleteKnowledgeDocumentUploadResult { + session: UploadSessionRecord + value: KnowledgeDocumentUploadCompletion + alreadyCompleted: boolean + workspaceId: string + knowledgeBaseId: string +} + +export const createKnowledgeDocumentUpload = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.uploadCreate, + resolveContext: ({ input }: { input: CreateKnowledgeDocumentUploadInput }) => + resolveActiveKnowledgeBaseContext({ + knowledgeBaseId: input.knowledgeBaseId, + assertedWorkspaceId: input.assertedWorkspaceId, + }), + async execute({ principal, input, context, request }) { + if (!request) throw new Error('Knowledge upload creation requires a request context') + const billingAttribution = await resolveKnowledgeBillingAttribution(principal, context) + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + throw new KnowledgeUsageLimitExceededError( + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + + const fileTypeError = validateFileType(input.name, input.contentType) + if (fileTypeError) { + throw new KnowledgeDocumentUnsupportedMediaTypeError(fileTypeError.message) + } + + const storageActorUserId = resolveKnowledgeAttributedUserId(principal, context) + const session = await createUploadSession({ + purpose: 'knowledge_document', + workspaceId: context.workspaceId, + knowledgeBaseId: context.knowledgeBaseId, + userId: storageActorUserId, + principal, + fileName: input.name, + contentType: input.contentType, + fileSize: input.size, + metadata: input.metadata, + localOrigin: requestOrigin(request), + }) + try { + await recordKnowledgeBaseFileOwnership({ + key: session.storageKey, + userId: storageActorUserId, + workspaceId: context.workspaceId, + originalName: input.name, + contentType: input.contentType, + size: input.size, + }) + } catch (error) { + await abortUploadSession(session) + throw error + } + return session + }, +}) + +export const issueKnowledgeDocumentUploadParts = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.uploadParts, + resolveContext: ({ input }: { input: IssueKnowledgeDocumentUploadPartsInput }) => + resolveActiveKnowledgeBaseContext({ + knowledgeBaseId: input.knowledgeBaseId, + assertedWorkspaceId: input.assertedWorkspaceId, + }), + async execute({ principal, input, context, request }) { + if (!request) throw new Error('Knowledge upload part issuance requires a request context') + const session = await loadBoundKnowledgeDocumentUpload(principal, input, context) + await reauthorizeKnowledgeDocumentUpload(principal, session, knowledgeOperations.uploadParts) + return { + parts: await createUploadPartUrls({ + session, + partNumbers: input.partNumbers, + localOrigin: requestOrigin(request), + }), + } + }, +}) + +export const cancelKnowledgeDocumentUpload = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.uploadCancel, + resolveContext: ({ input }: { input: KnowledgeDocumentUploadControlInput }) => + resolveActiveKnowledgeBaseContext({ + knowledgeBaseId: input.knowledgeBaseId, + assertedWorkspaceId: input.assertedWorkspaceId, + }), + async execute({ principal, input, context }) { + const session = await loadBoundKnowledgeDocumentUpload(principal, input, context) + await reauthorizeKnowledgeDocumentUpload(principal, session, knowledgeOperations.uploadCancel) + const bound = await findBoundKnowledgeDocument({ + documentId: session.id, + knowledgeBaseId: context.knowledgeBaseId, + document: knowledgeDocumentInputFor(session), + }) + if (bound.status !== 'absent') { + throw new OrchestrationError('conflict', 'Upload has already been completed') + } + return abortUploadSession(session) + }, +}) + +export const completeKnowledgeDocumentUpload = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.uploadComplete, + resolveContext: ({ input }: { input: CompleteKnowledgeDocumentUploadInput }) => + resolveActiveKnowledgeBaseContext({ + knowledgeBaseId: input.knowledgeBaseId, + assertedWorkspaceId: input.assertedWorkspaceId, + }), + async execute({ + principal, + input, + context, + request, + }): Promise<CompleteKnowledgeDocumentUploadResult> { + if (!request) throw new Error('Knowledge upload completion requires a request context') + const session = await loadBoundKnowledgeDocumentUpload(principal, input, context) + await reauthorizeKnowledgeDocumentUpload(principal, session, knowledgeOperations.uploadComplete) + const requestId = generateRequestId() + const recoveringUnprojectedRegistration = + session.status === 'finalizing' && session.completedFileId === null + const result = await completeUploadSession<KnowledgeDocumentUploadCompletion>({ + session, + loadCompleted: async (claimed) => { + const freshContext = await reauthorizeKnowledgeDocumentUpload( + principal, + claimed, + knowledgeOperations.uploadComplete + ) + const document = knowledgeDocumentInputFor(claimed) + const bound = await findBoundKnowledgeDocument({ + documentId: claimed.id, + knowledgeBaseId: freshContext.knowledgeBaseId, + document, + }) + if (bound.status === 'conflict') { + throw new OrchestrationError( + 'conflict', + 'Upload id is already bound to a different document' + ) + } + if (bound.status === 'absent') { + throw new Error('Completed knowledge upload is missing its durable document') + } + if (claimed.completedFileId !== bound.document.id) { + throw new Error('Completed knowledge upload references a different durable document') + } + return { + document: bound.document, + created: false, + knowledgeBaseName: freshContext.knowledgeBase.name, + } + }, + finalize: async (claimed) => { + const freshContext = await reauthorizeKnowledgeDocumentUpload( + principal, + claimed, + knowledgeOperations.uploadComplete + ) + const { processingOptions } = knowledgeDocumentMetadataFor(claimed) + const document = knowledgeDocumentInputFor(claimed) + const bound = await findBoundKnowledgeDocument({ + documentId: claimed.id, + knowledgeBaseId: freshContext.knowledgeBaseId, + document, + }) + if (bound.status === 'conflict') { + throw new OrchestrationError( + 'conflict', + 'Upload id is already bound to a different document' + ) + } + 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, + processingOptions, + requestId, + billingAttribution + ) + } + return { + value: { + document: bound.document, + created: recoveringUnprojectedRegistration, + knowledgeBaseName: freshContext.knowledgeBase.name, + }, + completedFileId: bound.document.id, + } + } + + const billingAttribution = await resolveKnowledgeBillingAttribution(principal, freshContext) + const registrationContext = await reauthorizeKnowledgeDocumentUpload( + principal, + claimed, + knowledgeOperations.uploadComplete + ) + const uploadedBy = resolveKnowledgeAttributedUserId(principal, registrationContext) + if ( + billingAttribution.workspaceId !== registrationContext.workspaceId || + billingAttribution.actorUserId !== uploadedBy || + billingAttribution.organizationId !== registrationContext.workspaceOrganizationId || + billingAttribution.billedAccountUserId !== registrationContext.billedAccountUserId + ) { + throw new Error('Knowledge upload billing attribution changed before registration') + } + + let created: CreatedKnowledgeDocument + try { + created = await createSingleDocument( + document, + registrationContext.knowledgeBaseId, + requestId, + uploadedBy, + claimed.id, + undefined, + { expectedWorkspaceId: registrationContext.workspaceId } + ) + } catch (error) { + const afterError = await findBoundKnowledgeDocument({ + documentId: claimed.id, + knowledgeBaseId: registrationContext.knowledgeBaseId, + document, + }) + if (afterError.status === 'conflict') { + throw new OrchestrationError( + 'conflict', + 'Upload id is already bound to a different document' + ) + } + if (afterError.status === 'bound') { + return { + value: { + document: afterError.document, + created: false, + knowledgeBaseName: registrationContext.knowledgeBase.name, + }, + completedFileId: afterError.document.id, + } + } + throw error + } + + await dispatchKnowledgeDocumentProcessing( + created, + registrationContext.knowledgeBaseId, + processingOptions, + requestId, + billingAttribution + ) + return { + value: { + document: created, + created: true, + knowledgeBaseName: registrationContext.knowledgeBase.name, + }, + completedFileId: created.id, + } + }, + }) + return { + ...result, + workspaceId: context.workspaceId, + knowledgeBaseId: context.knowledgeBaseId, + } + }, + projectAudit: ({ input, context, result }) => { + if (!result.value.created) return [] + const { document, knowledgeBaseName } = result.value + return { + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: document.id, + resourceName: document.filename, + description: `Uploaded document "${document.filename}" to knowledge base "${knowledgeBaseName ?? context.knowledgeBaseId}"`, + metadata: { + source: input.source, + knowledgeBaseId: context.knowledgeBaseId, + knowledgeBaseName, + fileName: document.filename, + fileType: document.mimeType, + fileSize: document.fileSize, + }, + } + }, +}) + +async function dispatchKnowledgeDocumentProcessing( + document: CreatedKnowledgeDocument, + knowledgeBaseId: string, + processingOptions: V2KnowledgeDocumentUploadMetadata['processingOptions'], + requestId: string, + billingAttribution: Awaited<ReturnType<typeof resolveKnowledgeBillingAttribution>> +): Promise<void> { + const processingDocument: DocumentData = { + documentId: document.id, + filename: document.filename, + fileUrl: document.fileUrl, + fileSize: document.fileSize, + mimeType: document.mimeType, + } + try { + await processDocumentsWithQueue( + [processingDocument], + knowledgeBaseId, + processingOptions ?? {}, + requestId, + billingAttribution + ) + } catch (error) { + throw new KnowledgeDocumentProcessingDispatchError(error) + } +} + +async function loadBoundKnowledgeDocumentUpload( + principal: Principal, + input: KnowledgeDocumentUploadControlInput, + context: ActiveKnowledgeBaseContext +): Promise<UploadSessionRecord> { + return getPrincipalKnowledgeDocumentUploadSession({ + uploadId: input.uploadId, + uploadToken: input.uploadToken, + principal, + workspaceId: context.workspaceId, + knowledgeBaseId: context.knowledgeBaseId, + }) +} + +async function reauthorizeKnowledgeDocumentUpload( + principal: Principal, + session: UploadSessionRecord, + operation: WorkspaceOperation +): Promise<ActiveKnowledgeBaseContext> { + if ( + !session.workspaceId || + !session.knowledgeBaseId || + session.purpose !== 'knowledge_document' + ) { + throw new OrchestrationError('not_found', 'Upload session not found') + } + assertUploadSessionAuthBinding(session, principal) + const context = await resolveActiveKnowledgeBaseContext({ + knowledgeBaseId: session.knowledgeBaseId, + assertedWorkspaceId: session.workspaceId, + }) + await authorizeWorkspaceOperation(principal, operation, context, { + delegation: knowledgeDelegationPolicy, + }) + return context +} + +function knowledgeDocumentMetadataFor(session: UploadSessionRecord) { + const { authBinding: _authBinding, ...metadata } = session.metadata + return v2KnowledgeDocumentUploadMetadataSchema.parse(metadata) +} + +function knowledgeDocumentInputFor(session: UploadSessionRecord) { + const { processingOptions: _processingOptions, ...documentTags } = + knowledgeDocumentMetadataFor(session) + return { + filename: session.fileName, + fileUrl: knowledgeDocumentFileUrl(session), + fileSize: session.fileSize, + mimeType: session.contentType, + ...documentTags, + } +} + +export function knowledgeDocumentFileUrl(session: UploadSessionRecord): string { + if (session.storageContext !== 'knowledge-base') { + throw new Error('Knowledge-document upload has an invalid storage context') + } + const providerPrefix = session.storageProvider === 'local' ? '' : `${session.storageProvider}/` + return `/api/files/serve/${providerPrefix}${encodeURIComponent(session.storageKey)}?context=knowledge-base` +} diff --git a/apps/sim/lib/knowledge/constants.ts b/apps/sim/lib/knowledge/constants.ts index 57ee50321be..e0f53db00ae 100644 --- a/apps/sim/lib/knowledge/constants.ts +++ b/apps/sim/lib/knowledge/constants.ts @@ -1,5 +1,11 @@ /** Max character length for a knowledge base description, enforced at every layer (UI, internal API, v1 API). */ export const KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH = 10_000 +/** Hard bound for full-workspace knowledge-base list projections. */ +export const MAX_KNOWLEDGE_BASES_PER_WORKSPACE = 10_000 +/** Hard bound for path-indexed knowledge folder trees and recursive cascades. */ +export const MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE = 10_000 +/** Hard bound for connector-type rows projected onto one knowledge-base list. */ +export const MAX_KNOWLEDGE_CONNECTOR_TYPE_ROWS_PER_LIST = 100_000 /** * Chunking a knowledge base gets when its creator names no configuration. diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 187ef80076c..7425819f571 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -1838,7 +1838,8 @@ export async function createSingleDocument( requestId: string, uploadedBy: string | null = null, documentId = generateId(), - secretProvenance?: KnowledgeDocumentWriteSecretProvenance + secretProvenance?: KnowledgeDocumentWriteSecretProvenance, + options?: { expectedWorkspaceId?: string } ): Promise<{ id: string knowledgeBaseId: string @@ -1939,6 +1940,13 @@ export async function createSingleDocument( throw new OrchestrationError('not_found', 'Knowledge base not found') } + if ( + options?.expectedWorkspaceId !== undefined && + kb[0].workspaceId !== options.expectedWorkspaceId + ) { + throw new OrchestrationError('not_found', 'Knowledge base not found') + } + if ( kb[0].workspaceId !== admission.workspaceId || kb[0].userId !== admission.knowledgeBaseUserId @@ -2746,7 +2754,8 @@ async function excludeConnectorDocuments( async function deleteDocumentsByLifecyclePolicy( documentIds: string[], - requestId: string + requestId: string, + expectedKnowledgeBaseId?: string ): Promise<number> { const ids = [...new Set(documentIds)] if (ids.length === 0) { @@ -2759,14 +2768,26 @@ async function deleteDocumentsByLifecyclePolicy( connectorId: document.connectorId, }) .from(document) - .where(inArray(document.id, ids)) + .where( + expectedKnowledgeBaseId + ? and( + inArray(document.id, ids), + eq(document.knowledgeBaseId, expectedKnowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + : inArray(document.id, ids) + ) const connectorBackedIds = docs.filter((doc) => doc.connectorId !== null).map((doc) => doc.id) const hardDeleteIds = docs.filter((doc) => doc.connectorId === null).map((doc) => doc.id) const [excludedCount, hardDeletedCount] = await Promise.all([ - excludeConnectorDocuments(connectorBackedIds, requestId), - hardDeleteDocuments(hardDeleteIds, requestId), + expectedKnowledgeBaseId + ? excludeConnectorKnowledgeDocuments(expectedKnowledgeBaseId, connectorBackedIds, requestId) + : excludeConnectorDocuments(connectorBackedIds, requestId), + hardDeleteDocuments(hardDeleteIds, requestId, undefined, expectedKnowledgeBaseId), ]) return excludedCount + hardDeletedCount @@ -2784,7 +2805,8 @@ export async function hardDeleteDocuments( * connector, keep documents") would otherwise still have them purged here * despite no longer belonging to the connector the caller reasoned about. */ - expectedConnectorId?: string + expectedConnectorId?: string, + expectedKnowledgeBaseId?: string ): Promise<number> { const ids = [...new Set(documentIds)] if (ids.length === 0) { @@ -2796,7 +2818,8 @@ export async function hardDeleteDocuments( deletedCount += await hardDeleteDocumentBatch( ids.slice(offset, offset + HARD_DELETE_DOCUMENT_BATCH_SIZE), requestId, - expectedConnectorId + expectedConnectorId, + expectedKnowledgeBaseId ) } return deletedCount @@ -2809,7 +2832,8 @@ export async function hardDeleteDocuments( async function hardDeleteDocumentBatch( documentIds: string[], requestId: string, - expectedConnectorId?: string + expectedConnectorId?: string, + expectedKnowledgeBaseId?: string ): Promise<number> { const ids = [...new Set(documentIds)] const documentsToDelete = await db @@ -2826,9 +2850,14 @@ async function hardDeleteDocumentBatch( .from(document) .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) .where( - expectedConnectorId - ? and(inArray(document.id, ids), eq(document.connectorId, expectedConnectorId)) - : inArray(document.id, ids) + and( + inArray(document.id, ids), + expectedConnectorId ? eq(document.connectorId, expectedConnectorId) : undefined, + expectedKnowledgeBaseId ? eq(document.knowledgeBaseId, expectedKnowledgeBaseId) : undefined, + expectedKnowledgeBaseId ? eq(document.userExcluded, false) : undefined, + expectedKnowledgeBaseId ? isNull(document.archivedAt) : undefined, + expectedKnowledgeBaseId ? isNull(document.deletedAt) : undefined + ) ) if (documentsToDelete.length === 0) { @@ -2910,16 +2939,26 @@ async function hardDeleteDocumentBatch( * embedding delete and the document delete are scoped to this re-verified * ID set rather than the stale `existingIds`. */ - const stillTargetedIds = expectedConnectorId - ? ( - await tx - .select({ id: document.id }) - .from(document) - .where( - and(inArray(document.id, existingIds), eq(document.connectorId, expectedConnectorId)) - ) - ).map((d) => d.id) - : existingIds + const stillTargetedIds = + expectedConnectorId || expectedKnowledgeBaseId + ? ( + await tx + .select({ id: document.id }) + .from(document) + .where( + and( + inArray(document.id, existingIds), + expectedConnectorId ? eq(document.connectorId, expectedConnectorId) : undefined, + expectedKnowledgeBaseId + ? eq(document.knowledgeBaseId, expectedKnowledgeBaseId) + : undefined, + expectedKnowledgeBaseId ? eq(document.userExcluded, false) : undefined, + expectedKnowledgeBaseId ? isNull(document.archivedAt) : undefined, + expectedKnowledgeBaseId ? isNull(document.deletedAt) : undefined + ) + ) + ).map((d) => d.id) + : existingIds await tx.delete(embedding).where(inArray(embedding.documentId, stillTargetedIds)) const deletedRows = await tx @@ -2985,3 +3024,44 @@ export async function deleteDocument( message: 'Document deleted successfully', } } + +/** Deletes one currently visible document within its canonical knowledge base. */ +export async function deleteKnowledgeDocumentInKnowledgeBase( + knowledgeBaseId: string, + documentId: string, + requestId: string +): Promise<void> { + const current = await getKnowledgeDocument(knowledgeBaseId, documentId) + if (!current) throw new OrchestrationError('not_found', 'Document not found') + const affected = await deleteDocumentsByLifecyclePolicy([documentId], requestId, knowledgeBaseId) + if (affected !== 1) throw new OrchestrationError('not_found', 'Document not found') +} + +async function excludeConnectorKnowledgeDocuments( + knowledgeBaseId: string, + documentIds: string[], + requestId: string +): Promise<number> { + if (documentIds.length === 0) return 0 + const updated = await db + .update(document) + .set({ userExcluded: true, enabled: false }) + .where( + and( + inArray(document.id, documentIds), + eq(document.knowledgeBaseId, knowledgeBaseId), + isNotNull(document.connectorId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .returning({ id: document.id }) + if (updated.length > 0) { + logger.info(`[${requestId}] Excluded ${updated.length} connector-backed document(s)`, { + documentIds: updated.map((row) => row.id), + knowledgeBaseId, + }) + } + return updated.length +} diff --git a/apps/sim/lib/knowledge/service.test.ts b/apps/sim/lib/knowledge/service.test.ts index 7399b83aebe..b1be80ee910 100644 --- a/apps/sim/lib/knowledge/service.test.ts +++ b/apps/sim/lib/knowledge/service.test.ts @@ -31,7 +31,32 @@ vi.mock('@/lib/billing/core/usage', () => ({ ensureUserStatsExists: mockEnsureUserStatsExists, })) -import { KnowledgeBasePermissionError, updateKnowledgeBase } from '@/lib/knowledge/service' +import { MAX_KNOWLEDGE_BASES_PER_WORKSPACE } from '@/lib/knowledge/constants' +import { + getWorkspaceKnowledgeBases, + KnowledgeBasePermissionError, + updateKnowledgeBase, +} from '@/lib/knowledge/service' + +describe('getWorkspaceKnowledgeBases — bounded reads', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('fails before projecting connector data for an oversized workspace list', async () => { + dbChainMockFns.limit.mockResolvedValueOnce( + Array.from({ length: MAX_KNOWLEDGE_BASES_PER_WORKSPACE + 1 }, (_, index) => ({ + id: `kb-${index}`, + })) + ) + + await expect(getWorkspaceKnowledgeBases('ws-1')).rejects.toThrow( + `Knowledge base list exceeds the ${MAX_KNOWLEDGE_BASES_PER_WORKSPACE} row limit` + ) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(MAX_KNOWLEDGE_BASES_PER_WORKSPACE + 1) + }) +}) /** * These tests guard the workspace mass-assignment fix: diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index 0991e17a5f3..8de2d573357 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -38,6 +38,10 @@ import { import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRestoreName } from '@/lib/core/utils/restore-name' import { findActiveFolder, resolveRestoredFolderId } from '@/lib/folders/queries' +import { + MAX_KNOWLEDGE_BASES_PER_WORKSPACE, + MAX_KNOWLEDGE_CONNECTOR_TYPE_ROWS_PER_LIST, +} from '@/lib/knowledge/constants' import type { ChunkingConfig, CreateKnowledgeBaseData, @@ -133,9 +137,8 @@ const KNOWLEDGE_BASE_SORTS = { updatedAt: [knowledgeBase.updatedAt, knowledgeBase.createdAt], } satisfies Record<V2KnowledgeBaseSortBy, readonly Column[]> -interface GetKnowledgeBasesOptions { - /** Restrict to one knowledge-base folder. */ - /** `undefined` lists every folder, `null` lists only workspace-root resources. */ +export interface GetKnowledgeBasesOptions { + /** Restrict to one knowledge-base folder; `undefined` lists all and `null` lists the root. */ folderId?: string | null /** Case-insensitive substring match on the knowledge base name. */ search?: string @@ -143,6 +146,122 @@ interface GetKnowledgeBasesOptions { sortOrder?: V2SortOrder } +async function attachConnectorTypes( + knowledgeBases: Array<Omit<KnowledgeBaseWithCounts, 'connectorTypes'>> +): Promise<KnowledgeBaseWithCounts[]> { + const kbIds = knowledgeBases.map((kb) => kb.id) + const connectorRows = + kbIds.length > 0 + ? await db + .select({ + knowledgeBaseId: knowledgeConnector.knowledgeBaseId, + connectorType: knowledgeConnector.connectorType, + }) + .from(knowledgeConnector) + .where( + and( + inArray(knowledgeConnector.knowledgeBaseId, kbIds), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + .limit(MAX_KNOWLEDGE_CONNECTOR_TYPE_ROWS_PER_LIST + 1) + : [] + if (connectorRows.length > MAX_KNOWLEDGE_CONNECTOR_TYPE_ROWS_PER_LIST) { + throw new Error( + `Knowledge connector projection exceeds the ${MAX_KNOWLEDGE_CONNECTOR_TYPE_ROWS_PER_LIST} row limit` + ) + } + + const connectorTypesByKb = new Map<string, string[]>() + for (const row of connectorRows) { + const types = connectorTypesByKb.get(row.knowledgeBaseId) ?? [] + if (!types.includes(row.connectorType)) types.push(row.connectorType) + connectorTypesByKb.set(row.knowledgeBaseId, types) + } + + return knowledgeBases.map((kb) => ({ + ...kb, + connectorTypes: connectorTypesByKb.get(kb.id) ?? [], + })) +} + +/** + * Lists active knowledge bases in one canonical workspace after application + * authorization. Unlike the legacy user-oriented query, this never widens the + * scope to workspace-less rows and never depends on a human permission join. + */ +export async function getWorkspaceKnowledgeBases( + workspaceId: string, + scope: KnowledgeBaseScope = 'active', + options?: GetKnowledgeBasesOptions +): Promise<KnowledgeBaseWithCounts[]> { + const { folderId, search, sortBy = 'createdAt', sortOrder = 'asc' } = options ?? {} + const scopeCondition = + scope === 'all' + ? undefined + : scope === 'archived' + ? sql`${knowledgeBase.deletedAt} IS NOT NULL` + : isNull(knowledgeBase.deletedAt) + + const rows = await db + .select({ + id: knowledgeBase.id, + userId: knowledgeBase.userId, + name: knowledgeBase.name, + description: knowledgeBase.description, + tokenCount: sql<number>`COALESCE(SUM(${document.tokenCount}), 0)`.mapWith(Number), + embeddingModel: knowledgeBase.embeddingModel, + embeddingDimension: knowledgeBase.embeddingDimension, + chunkingConfig: knowledgeBase.chunkingConfig, + createdAt: knowledgeBase.createdAt, + updatedAt: knowledgeBase.updatedAt, + deletedAt: knowledgeBase.deletedAt, + workspaceId: knowledgeBase.workspaceId, + folderId: knowledgeBase.folderId, + docCount: count(document.id), + }) + .from(knowledgeBase) + .leftJoin( + document, + and( + eq(document.knowledgeBaseId, knowledgeBase.id), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .where( + and( + eq(knowledgeBase.workspaceId, workspaceId), + scopeCondition, + folderId === undefined + ? undefined + : folderId === null + ? isNull(knowledgeBase.folderId) + : eq(knowledgeBase.folderId, folderId), + searchFilter(knowledgeBase.name, search) + ) + ) + .groupBy(knowledgeBase.id) + .orderBy(...listOrderBy(KNOWLEDGE_BASE_SORTS[sortBy], sortOrder)) + .limit(MAX_KNOWLEDGE_BASES_PER_WORKSPACE + 1) + + if (rows.length > MAX_KNOWLEDGE_BASES_PER_WORKSPACE) { + throw new Error( + `Knowledge base list exceeds the ${MAX_KNOWLEDGE_BASES_PER_WORKSPACE} row limit` + ) + } + + return attachConnectorTypes( + rows.map((kb) => ({ + ...kb, + chunkingConfig: kb.chunkingConfig as ChunkingConfig, + docCount: Number(kb.docCount), + })) + ) +} + /** * Get knowledge bases that a user can access. * @@ -275,9 +394,6 @@ export async function createKnowledgeBase( data: CreateKnowledgeBaseData, requestId: string ): Promise<KnowledgeBaseWithCounts> { - const kbId = generateId() - const now = new Date() - const hasPermission = await getUserEntityPermissions(data.userId, 'workspace', data.workspaceId) if (hasPermission !== 'admin' && hasPermission !== 'write') { throw new KnowledgeBasePermissionError( @@ -285,6 +401,20 @@ export async function createKnowledgeBase( ) } + return createAuthorizedKnowledgeBase(data, requestId) +} + +/** + * Persists a knowledge base for an already-authorized application use case. + * Callers outside the application layer must use {@link createKnowledgeBase}. + */ +export async function createAuthorizedKnowledgeBase( + data: CreateKnowledgeBaseData, + requestId: string +): Promise<KnowledgeBaseWithCounts> { + const kbId = generateId() + const now = new Date() + await assertKnowledgeBaseFolder(data.folderId, data.workspaceId) const folderId = data.folderId ?? null @@ -368,7 +498,7 @@ export async function updateKnowledgeBase( } }, requestId: string, - options?: { actorUserId?: string } + options?: { actorUserId?: string; assertedWorkspaceId?: string } ): Promise<KnowledgeBaseWithCounts> { const now = new Date() const updateData: Partial<typeof knowledgeBase.$inferInsert> = { @@ -403,7 +533,15 @@ export async function updateKnowledgeBase( const [snapshot] = await db .select({ workspaceId: knowledgeBase.workspaceId }) .from(knowledgeBase) - .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .where( + and( + eq(knowledgeBase.id, knowledgeBaseId), + isNull(knowledgeBase.deletedAt), + options?.assertedWorkspaceId + ? eq(knowledgeBase.workspaceId, options.assertedWorkspaceId) + : undefined + ) + ) .limit(1) if (!snapshot) { throw new KnowledgeBaseNotFoundError(knowledgeBaseId) @@ -427,7 +565,15 @@ export async function updateKnowledgeBase( folderId: knowledgeBase.folderId, }) .from(knowledgeBase) - .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .where( + and( + eq(knowledgeBase.id, knowledgeBaseId), + isNull(knowledgeBase.deletedAt), + options?.assertedWorkspaceId + ? eq(knowledgeBase.workspaceId, options.assertedWorkspaceId) + : undefined + ) + ) .limit(1) if (!kbSnapshot) { throw new KnowledgeBaseNotFoundError(knowledgeBaseId) @@ -510,7 +656,15 @@ export async function updateKnowledgeBase( const [currentKb] = await tx .select({ workspaceId: knowledgeBase.workspaceId, userId: knowledgeBase.userId }) .from(knowledgeBase) - .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .where( + and( + eq(knowledgeBase.id, knowledgeBaseId), + isNull(knowledgeBase.deletedAt), + options?.assertedWorkspaceId + ? eq(knowledgeBase.workspaceId, options.assertedWorkspaceId) + : undefined + ) + ) .for('update') .limit(1) @@ -632,7 +786,15 @@ export async function updateKnowledgeBase( await tx .update(knowledgeBase) .set(updateData) - .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .where( + and( + eq(knowledgeBase.id, knowledgeBaseId), + isNull(knowledgeBase.deletedAt), + options?.assertedWorkspaceId + ? eq(knowledgeBase.workspaceId, options.assertedWorkspaceId) + : undefined + ) + ) // When a KB changes workspace, re-point the ownership bindings for its // stored files so file authorization (which resolves the owning workspace @@ -726,7 +888,15 @@ export async function updateKnowledgeBase( isNull(document.deletedAt) ) ) - .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .where( + and( + eq(knowledgeBase.id, knowledgeBaseId), + isNull(knowledgeBase.deletedAt), + options?.assertedWorkspaceId + ? eq(knowledgeBase.workspaceId, options.assertedWorkspaceId) + : undefined + ) + ) .groupBy(knowledgeBase.id) .limit(1) @@ -804,12 +974,26 @@ export async function getKnowledgeBaseById( export async function deleteKnowledgeBase( knowledgeBaseId: string, requestId: string, - options?: { archivedAt?: Date } + options?: { archivedAt?: Date; assertedWorkspaceId?: string } ): Promise<void> { const now = options?.archivedAt ?? new Date() await db.transaction(async (tx) => { - await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) + const [locked] = await tx + .select({ id: knowledgeBase.id, workspaceId: knowledgeBase.workspaceId }) + .from(knowledgeBase) + .where( + and( + eq(knowledgeBase.id, knowledgeBaseId), + isNull(knowledgeBase.deletedAt), + options?.assertedWorkspaceId + ? eq(knowledgeBase.workspaceId, options.assertedWorkspaceId) + : undefined + ) + ) + .limit(1) + .for('update') + if (!locked) throw new KnowledgeBaseNotFoundError(knowledgeBaseId) await tx .update(knowledgeBase) @@ -817,7 +1001,15 @@ export async function deleteKnowledgeBase( deletedAt: now, updatedAt: now, }) - .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .where( + and( + eq(knowledgeBase.id, knowledgeBaseId), + isNull(knowledgeBase.deletedAt), + options?.assertedWorkspaceId + ? eq(knowledgeBase.workspaceId, options.assertedWorkspaceId) + : undefined + ) + ) await tx .update(document) diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts index ea5901633c2..020e3f2c6b1 100644 --- a/apps/sim/lib/uploads/upload-session/service.test.ts +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import type { Principal } from '@sim/auth/principal' import { sha256Hex } from '@sim/security/hash' import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { eq, inArray, isNull } from 'drizzle-orm' @@ -60,8 +61,11 @@ import { assertUploadSessionAuthBinding, cleanupExpiredUploadSessions, completeUploadSession, + createUploadPartUrls, createUploadSession, + createUploadSessionAuthBinding, getOwnedUploadSession, + getPrincipalKnowledgeDocumentUploadSession, UPLOAD_SESSION_PART_SIZE, UPLOAD_SESSION_PUT_MAX_BYTES, type UploadSessionRecord, @@ -139,6 +143,103 @@ describe('upload sessions', () => { ).rejects.toMatchObject({ code: 'not_found' }) }) + it('binds new knowledge-document sessions to the exact creating credential', async () => { + const row = uploadRow({ + purpose: 'knowledge_document', + knowledgeBaseId: 'kb-1', + storageContext: 'knowledge-base', + finalKey: 'kb/guide.pdf', + fileName: 'guide.pdf', + contentType: 'application/pdf', + }) + dbChainMockFns.returning.mockResolvedValueOnce([row]) + + await createUploadSession({ + id: row.id, + workspaceId: WORKSPACE_ID, + knowledgeBaseId: 'kb-1', + userId: 'user-1', + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + purpose: 'knowledge_document', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 4, + localOrigin: 'http://localhost:3000', + }) + + expect(dbChainMockFns.values.mock.calls[0][0].metadata.authBinding).toEqual({ + version: 1, + workspaceId: WORKSPACE_ID, + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + }) + }) + + it('rejects a different API key on a bound knowledge-document control leg', async () => { + const row = uploadRow({ + purpose: 'knowledge_document', + knowledgeBaseId: 'kb-1', + storageContext: 'knowledge-base', + metadata: { + authBinding: { + version: 1, + workspaceId: WORKSPACE_ID, + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + }, + }, + }) + queueTableRows(schemaMock.uploadSession, [row]) + + await expect( + getPrincipalKnowledgeDocumentUploadSession({ + uploadId: row.id, + uploadToken: 'upload-secret', + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-2' }, + workspaceId: WORKSPACE_ID, + knowledgeBaseId: 'kb-1', + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it.each([ + { + label: 'session', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + mismatch: { kind: 'session', userId: 'user-1', sessionId: 'session-2' }, + }, + { + label: 'personal API key', + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + mismatch: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-2' }, + }, + { + label: 'workspace API key', + principal: { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'workspace-key-1', + }, + mismatch: { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'workspace-key-2', + }, + }, + ] satisfies Array<{ label: string; principal: Principal; mismatch: Principal }>)( + 'requires the exact bound $label credential for knowledge control', + ({ principal, mismatch }) => { + const session = sessionRecord({ + purpose: 'knowledge_document', + knowledgeBaseId: 'kb-1', + metadata: { authBinding: createUploadSessionAuthBinding(principal, WORKSPACE_ID) }, + }) + + expect(() => assertUploadSessionAuthBinding(session, principal)).not.toThrow() + expect(() => assertUploadSessionAuthBinding(session, mismatch)).toThrow( + 'Upload session not found' + ) + } + ) + it('preserves legacy unbound sessions under their prior ownership rules', () => { const legacy = sessionRecord({ metadata: {} }) @@ -180,8 +281,35 @@ describe('upload sessions', () => { ).toThrow('Upload session not found') }) + it('preserves the explicit missing-binding compatibility path for old knowledge sessions', () => { + const legacy = sessionRecord({ + purpose: 'knowledge_document', + knowledgeBaseId: 'kb-1', + metadata: {}, + }) + + expect(() => + assertUploadSessionAuthBinding(legacy, { + kind: 'personal_api_key', + userId: legacy.userId, + keyId: 'replacement-key', + }) + ).not.toThrow() + expect(() => + assertUploadSessionAuthBinding(legacy, { + kind: 'personal_api_key', + userId: 'different-user', + keyId: 'replacement-key', + }) + ).toThrow('Upload session not found') + }) + it('never treats a malformed credential binding as a legacy session', () => { - const malformed = sessionRecord({ metadata: { authBinding: { version: 1 } } }) + const malformed = sessionRecord({ + purpose: 'knowledge_document', + knowledgeBaseId: 'kb-1', + metadata: { authBinding: { version: 1 } }, + }) expect(() => assertUploadSessionAuthBinding(malformed, { @@ -216,6 +344,38 @@ describe('upload sessions', () => { expect(mockCreatePutTransfer).not.toHaveBeenCalled() }) + it('preserves multipart request bounds before provider signing', async () => { + const multipart = sessionRecord({ + method: 'multipart', + providerUploadId: 'provider-upload-1', + partSize: UPLOAD_SESSION_PART_SIZE, + partCount: 2, + fileSize: UPLOAD_SESSION_PART_SIZE + 1, + }) + + await expect( + createUploadPartUrls({ + session: multipart, + partNumbers: [1, 1], + localOrigin: 'http://localhost:3000', + }) + ).rejects.toMatchObject({ code: 'validation' }) + await expect( + createUploadPartUrls({ + session: multipart, + partNumbers: Array.from({ length: 101 }, (_, index) => index + 1), + localOrigin: 'http://localhost:3000', + }) + ).rejects.toMatchObject({ code: 'validation' }) + await expect( + createUploadPartUrls({ + session: multipart, + partNumbers: [3], + localOrigin: 'http://localhost:3000', + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) + it('loads ownership from PostgreSQL and rejects a mismatched token', async () => { const token = 'upload-secret' const row = uploadRow({ tokenHash: sha256Hex(token) }) diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index fa6f44e20bf..b6e3c7303e9 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -92,7 +92,7 @@ export interface UploadSessionRecord { } /** - * The credential that was authorized to create a workspace-file upload. + * The credential that was authorized to create a protected upload session. * * This is deliberately kept in the existing JSON metadata column. It is * server-authored and immutable for the lifetime of the session; the upload @@ -130,13 +130,18 @@ interface CreateUploadSessionBaseParams { fileSize: number metadata?: Record<string, unknown> localOrigin?: string - principal?: Principal } export type CreateUploadSessionParams = CreateUploadSessionBaseParams & ( - | { purpose: 'workspace_file' | 'table_import'; workspaceId: string } - | { purpose: 'knowledge_document'; workspaceId: string; knowledgeBaseId: string } + | { purpose: 'workspace_file'; workspaceId: string; principal: Principal } + | { purpose: 'table_import'; workspaceId: string } + | { + purpose: 'knowledge_document' + workspaceId: string + knowledgeBaseId: string + principal: Principal + } | { purpose: 'profile_picture'; workspaceId?: null } | { purpose: 'workspace_logo' | 'mothership_attachment'; workspaceId: string } | { @@ -157,10 +162,10 @@ export async function createUploadSession( const uploadToken = generateSecureToken(32) const workspaceId = params.purpose === 'profile_picture' ? null : params.workspaceId const metadata = { ...(params.metadata ?? {}) } - if (params.purpose === 'workspace_file') { - if (!workspaceId) throw new Error('Workspace-file upload is missing workspaceId') + if (params.purpose === 'workspace_file' || params.purpose === 'knowledge_document') { + if (!workspaceId) throw new Error(`${params.purpose} upload is missing workspaceId`) if (!params.principal) { - throw new Error('Workspace-file upload requires an authenticated principal') + throw new Error(`${params.purpose} upload requires an authenticated principal`) } metadata.authBinding = createUploadSessionAuthBinding(params.principal, workspaceId) } @@ -315,7 +320,7 @@ export async function getOwnedUploadSession(params: { if (params.executionId !== undefined && session.executionId !== params.executionId) { throw uploadNotFound() } - if (params.principal && session.purpose === 'workspace_file') { + if (params.principal && isPrincipalBoundUploadPurpose(session.purpose)) { assertUploadSessionAuthBinding(session, params.principal) } return session @@ -341,6 +346,24 @@ export async function getPrincipalUploadSession(params: { return session } +/** Loads a knowledge-document session using its immutable credential binding. */ +export async function getPrincipalKnowledgeDocumentUploadSession(params: { + uploadId: string + uploadToken: string + principal: Principal + workspaceId: string + knowledgeBaseId: string +}): Promise<UploadSessionRecord> { + return getOwnedUploadSession({ + uploadId: params.uploadId, + uploadToken: params.uploadToken, + workspaceId: params.workspaceId, + purpose: 'knowledge_document', + knowledgeBaseId: params.knowledgeBaseId, + principal: params.principal, + }) +} + export function createUploadSessionAuthBinding( principal: Principal, workspaceId: string @@ -380,7 +403,7 @@ export function assertUploadSessionAuthBinding( session: UploadSessionRecord, principal: Principal ): void { - if (session.purpose !== 'workspace_file') return + if (!isPrincipalBoundUploadPurpose(session.purpose)) return const candidate = session.metadata.authBinding if (candidate === undefined) { assertLegacyUploadSessionOwner(session, principal) @@ -408,10 +431,10 @@ export function assertUploadSessionAuthBinding( /** * Preserves control access for the bounded set of sessions created before - * immutable credential bindings shipped. New workspace-file sessions always - * persist `authBinding`, and malformed bindings never enter this compatibility - * path. The upload token and current workspace authorization are still checked - * by the calling control-plane use case. + * immutable credential bindings shipped. New protected sessions always persist + * `authBinding`, and malformed bindings never enter this compatibility path. + * The upload token and current workspace authorization are still checked by the + * calling control-plane use case. */ function assertLegacyUploadSessionOwner(session: UploadSessionRecord, principal: Principal): void { const matches = @@ -1083,6 +1106,10 @@ function requiresStorageQuota(purpose: UploadSessionPurpose): boolean { return purpose === 'workspace_file' || purpose === 'knowledge_document' } +function isPrincipalBoundUploadPurpose(purpose: UploadSessionPurpose): boolean { + return purpose === 'workspace_file' || purpose === 'knowledge_document' +} + function resolveUploadStorage( params: CreateUploadSessionParams, id: string From de263204fda9af05eb22aff0925227aef3110b50 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <siddharthganesan@gmail.com> Date: Sat, 8 Aug 2026 11:36:28 -0700 Subject: [PATCH 101/159] feat(cli): refine chat and desktop updates --- .github/workflows/desktop-e2e.yml | 4 +- .github/workflows/desktop-release.yml | 4 +- .../desktop/src/main/desktop-settings.test.ts | 16 ++- apps/desktop/src/main/desktop-settings.ts | 8 +- apps/desktop/src/main/terminal-themes.test.ts | 18 ++- apps/desktop/src/main/terminal-themes.ts | 43 ++++--- apps/desktop/src/main/updater.test.ts | 28 ++++ apps/desktop/src/main/updater.ts | 15 ++- .../terminal-session/terminal-session.tsx | 43 ++----- .../resource-content/resource-content.tsx | 28 +++- apps/sim/lib/desktop/appearance.test.ts | 56 +++++++- apps/sim/lib/desktop/appearance.ts | 31 +++++ apps/sim/lib/desktop/index.ts | 10 ++ packages/desktop-bridge/src/index.ts | 81 ++++++++---- .../protocol/chat-attachments.test.ts | 24 ---- .../src/commands/protocol/chat-attachments.ts | 121 ++++++++++++++---- .../protocol/chat-path-extraction.test.ts | 77 +++++++++++ .../src/commands/protocol/chat-suggestions.ts | 7 - .../commands/protocol/chat-terminal.test.ts | 8 +- .../src/commands/protocol/chat-terminal.ts | 26 ++-- .../src/commands/protocol/chat.test.ts | 79 +++++------- .../sim-cli/src/commands/protocol/chat.ts | 104 +++++---------- 22 files changed, 551 insertions(+), 280 deletions(-) create mode 100644 packages/sim-cli/src/commands/protocol/chat-path-extraction.test.ts diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index 4feb7b91318..7632b0bea66 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -17,7 +17,7 @@ concurrency: jobs: e2e: name: E2E (${{ matrix.electron }}) - runs-on: macos-14 + runs-on: macos-26 strategy: fail-fast: false matrix: @@ -58,7 +58,7 @@ jobs: package-smoke: name: Unsigned package smoke - runs-on: macos-14 + runs-on: macos-26 steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index d8a9e650d89..17d7e298a5d 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -49,7 +49,7 @@ permissions: jobs: build-sign-notarize: name: Build, Sign, Notarize - runs-on: macos-14 + runs-on: macos-26 steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -167,8 +167,8 @@ jobs: if: ${{ inputs.sign }} run: | DMG="$(ls apps/desktop/release/*.dmg | head -1)" - xcrun stapler validate "$DMG" hdiutil attach "$DMG" -mountpoint /tmp/sim-dmg -nobrowse -quiet + xcrun stapler validate /tmp/sim-dmg/*.app spctl --assess --type execute --verbose /tmp/sim-dmg/*.app codesign --verify --deep --strict /tmp/sim-dmg/*.app hdiutil detach /tmp/sim-dmg -quiet diff --git a/apps/desktop/src/main/desktop-settings.test.ts b/apps/desktop/src/main/desktop-settings.test.ts index 25c137aa8ca..b7849a57558 100644 --- a/apps/desktop/src/main/desktop-settings.test.ts +++ b/apps/desktop/src/main/desktop-settings.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { TERMINAL_DARK_THEME } from '@sim/desktop-bridge' +import { TERMINAL_DARK_THEME, TERMINAL_LIGHT_THEME } from '@sim/desktop-bridge' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) @@ -15,6 +15,14 @@ const IMPORTED_PALETTE = { ...TERMINAL_DARK_THEME, background: '#101010', } +const IMPORTED_LIGHT_PALETTE = { + ...TERMINAL_LIGHT_THEME, + background: '#fafafa', +} +const IMPORTED_DARK_PALETTE = { + ...TERMINAL_DARK_THEME, + background: '#202020', +} function makeService() { const config = createConfigStore( @@ -191,7 +199,7 @@ describe('desktop settings service', () => { expect(preferences?.browserDownloadDirectory).toBe('/tmp/custom-downloads') }) - it('caches and selects a Terminal or iTerm2 profile', () => { + it('persists a Terminal or iTerm2 profile with appearance-specific palettes', () => { const { config, service } = makeService() const preferences = service.selectTerminalProfile({ @@ -199,6 +207,8 @@ describe('desktop settings service', () => { name: 'Ocean', source: 'iterm2', palette: IMPORTED_PALETTE, + lightPalette: IMPORTED_LIGHT_PALETTE, + darkPalette: IMPORTED_DARK_PALETTE, }) expect(config.get('terminalTheme')).toEqual({ @@ -206,6 +216,8 @@ describe('desktop settings service', () => { name: 'Ocean', source: 'iterm2', palette: IMPORTED_PALETTE, + lightPalette: IMPORTED_LIGHT_PALETTE, + darkPalette: IMPORTED_DARK_PALETTE, }) expect(preferences).toMatchObject({ terminalTheme: { id: 'iterm2:ocean', name: 'Ocean' }, diff --git a/apps/desktop/src/main/desktop-settings.ts b/apps/desktop/src/main/desktop-settings.ts index aeaef5a0852..f84b364b40b 100644 --- a/apps/desktop/src/main/desktop-settings.ts +++ b/apps/desktop/src/main/desktop-settings.ts @@ -1,5 +1,6 @@ import { isAbsolute } from 'node:path' import { + cloneTerminalSelectedProfile, type DesktopAppearanceTheme, type DesktopNotificationPayload, type DesktopPreferenceKey, @@ -177,12 +178,7 @@ export function createDesktopSettingsService( return read() }, selectTerminalProfile(profile) { - deps.config.set('terminalTheme', { - id: profile.id, - name: profile.name, - source: profile.source, - palette: { ...profile.palette }, - }) + deps.config.set('terminalTheme', cloneTerminalSelectedProfile(profile)) deps.config.flush() return read() }, diff --git a/apps/desktop/src/main/terminal-themes.test.ts b/apps/desktop/src/main/terminal-themes.test.ts index 55538455b0c..4887ea43342 100644 --- a/apps/desktop/src/main/terminal-themes.test.ts +++ b/apps/desktop/src/main/terminal-themes.test.ts @@ -1,4 +1,4 @@ -import { TERMINAL_DARK_THEME } from '@sim/desktop-bridge' +import { TERMINAL_DARK_THEME, TERMINAL_LIGHT_THEME } from '@sim/desktop-bridge' import { describe, expect, it } from 'vitest' import { parseTerminalThemeProfiles } from '@/main/terminal-themes' @@ -6,6 +6,10 @@ const PALETTE = { ...TERMINAL_DARK_THEME, background: '#101010', } +const LIGHT_PALETTE = { + ...TERMINAL_LIGHT_THEME, + background: '#fafafa', +} function profile(id: string, overrides: Record<string, unknown> = {}) { return { @@ -22,10 +26,22 @@ describe('parseTerminalThemeProfiles', () => { expect(parseTerminalThemeProfiles([profile('iterm2:ocean')])).toEqual([profile('iterm2:ocean')]) }) + it('preserves separate iTerm2 light and dark palettes', () => { + const separateProfile = profile('iterm2:ocean', { + lightPalette: LIGHT_PALETTE, + darkPalette: PALETTE, + }) + + expect(parseTerminalThemeProfiles([separateProfile])).toEqual([separateProfile]) + }) + it('drops malformed colors and unsupported applications', () => { expect( parseTerminalThemeProfiles([ profile('bad-color', { palette: { ...PALETTE, background: 'rgb(0, 0, 0)' } }), + profile('bad-mode-color', { + lightPalette: { ...LIGHT_PALETTE, foreground: 'white' }, + }), profile('bad-source', { source: 'warp' }), ]) ).toEqual([]) diff --git a/apps/desktop/src/main/terminal-themes.ts b/apps/desktop/src/main/terminal-themes.ts index eecbe2e631c..dfd26cb60f5 100644 --- a/apps/desktop/src/main/terminal-themes.ts +++ b/apps/desktop/src/main/terminal-themes.ts @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { + cloneTerminalSelectedProfile, isTerminalSelectedProfile, TERMINAL_DARK_THEME, TERMINAL_LIGHT_THEME, @@ -86,21 +87,25 @@ function terminalPalette(profile) { return palette } -function itermPalette(profile) { - const background = dictionaryColor(profile['Background Color'], LIGHT_THEME.background) +function itermColor(profile, key, suffix, fallback) { + return dictionaryColor(profile[key + suffix], dictionaryColor(profile[key], fallback)) +} + +function itermPalette(profile, suffix) { + const background = itermColor(profile, 'Background Color', suffix, LIGHT_THEME.background) const dark = isDark(background) const fallback = dark ? DARK_THEME : LIGHT_THEME const palette = { background: background, - foreground: dictionaryColor(profile['Foreground Color'], fallback.foreground), - cursor: dictionaryColor(profile['Cursor Color'], fallback.cursor), - cursorAccent: dictionaryColor(profile['Cursor Text Color'], background), - selectionBackground: dictionaryColor(profile['Selection Color'], fallback.selectionBackground), - selectionForeground: dictionaryColor(profile['Selected Text Color'], fallback.foreground) + foreground: itermColor(profile, 'Foreground Color', suffix, fallback.foreground), + cursor: itermColor(profile, 'Cursor Color', suffix, fallback.cursor), + cursorAccent: itermColor(profile, 'Cursor Text Color', suffix, background), + selectionBackground: itermColor(profile, 'Selection Color', suffix, fallback.selectionBackground), + selectionForeground: itermColor(profile, 'Selected Text Color', suffix, fallback.foreground) } for (let index = 0; index < PALETTE_KEYS.length; index += 1) { const key = PALETTE_KEYS[index] - palette[key] = dictionaryColor(profile['Ansi ' + index + ' Color'], fallback[key]) + palette[key] = itermColor(profile, 'Ansi ' + index + ' Color', suffix, fallback[key]) } return palette } @@ -131,12 +136,18 @@ try { const guid = String(profile.Guid || '') const name = String(profile.Name || '') if (!guid || !name) continue - profiles.push({ + const result = { id: 'iterm2:' + encodeURIComponent(guid), name: name, source: 'iterm2', - palette: itermPalette(profile) - }) + palette: itermPalette(profile, '') + } + const separateColors = profile['Use Separate Colors for Light and Dark Mode'] + if (separateColors === true || separateColors === 1) { + result.lightPalette = itermPalette(profile, ' (Light)') + result.darkPalette = itermPalette(profile, ' (Dark)') + } + profiles.push(result) } } catch (_) {} @@ -151,12 +162,7 @@ export function parseTerminalThemeProfiles(value: unknown): TerminalThemeProfile for (const candidate of value) { if (!isTerminalSelectedProfile(candidate) || seen.has(candidate.id)) continue seen.add(candidate.id) - profiles.push({ - id: candidate.id, - name: candidate.name, - source: candidate.source, - palette: { ...candidate.palette }, - }) + profiles.push(cloneTerminalSelectedProfile(candidate)) } return profiles.sort( (left, right) => left.source.localeCompare(right.source) || left.name.localeCompare(right.name) @@ -180,9 +186,8 @@ async function readTerminalThemeProfiles(): Promise<TerminalThemeProfile[]> { let cachedProfiles: TerminalThemeProfile[] | null = null let profileLoad: Promise<TerminalThemeProfile[]> | null = null -/** Reads Terminal.app and iTerm2 profiles once per desktop process. */ +/** Reads current Terminal.app and iTerm2 profiles, coalescing concurrent requests. */ export async function listTerminalThemeProfiles(): Promise<TerminalThemeProfile[]> { - if (cachedProfiles) return cachedProfiles profileLoad ??= readTerminalThemeProfiles() .then((profiles) => { cachedProfiles = profiles diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index 0ba00f9fdca..b2ad5db2670 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -25,6 +25,7 @@ import { isNewerVersion, parseSemver, resolveUpdateChannel, + updateCheckIntervalMs, } from '@/main/updater' describe('resolveUpdateChannel', () => { @@ -39,6 +40,17 @@ describe('resolveUpdateChannel', () => { }) }) +describe('updateCheckIntervalMs', () => { + it('checks dev and staging builds every five minutes', () => { + expect(updateCheckIntervalMs('1.2.3-alpha.2')).toBe(5 * 60 * 1000) + expect(updateCheckIntervalMs('1.2.3-beta.1')).toBe(5 * 60 * 1000) + }) + + it('checks production builds every thirty minutes', () => { + expect(updateCheckIntervalMs('1.2.3')).toBe(30 * 60 * 1000) + }) +}) + describe('parseSemver', () => { it('parses plain and v-prefixed versions', () => { expect(parseSemver('1.2.3')).toEqual({ major: 1, minor: 2, patch: 3, prerelease: '' }) @@ -262,6 +274,22 @@ describe('initUpdater state machine', () => { vi.mocked(app.getVersion).mockReturnValue('1.0.0') } }) + + it.each([ + ['1.0.1-alpha.7', 5 * 60 * 1000], + ['1.0.1-beta.7', 5 * 60 * 1000], + ['1.0.1', 30 * 60 * 1000], + ])('schedules %s update polling every %i milliseconds', async (version, interval) => { + vi.mocked(app.getVersion).mockReturnValue(version) + const intervalSpy = vi.spyOn(globalThis, 'setInterval') + try { + await createUpdater({ feedAvailable: true }) + expect(intervalSpy).toHaveBeenCalledWith(expect.any(Function), interval) + } finally { + intervalSpy.mockRestore() + vi.mocked(app.getVersion).mockReturnValue('1.0.0') + } + }) }) function manifest(version: string): string { diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 711875cfdf6..c35d5e9eaec 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -10,7 +10,8 @@ import type { EventRecorder } from '@/main/observability' const logger = createLogger('DesktopUpdater') const INITIAL_CHECK_DELAY_MS = 10_000 -const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000 +const PRERELEASE_CHECK_INTERVAL_MS = 5 * 60 * 1000 +const STABLE_CHECK_INTERVAL_MS = 30 * 60 * 1000 export type UpdateChannel = 'latest' | 'beta' | 'alpha' @@ -72,6 +73,13 @@ export function resolveUpdateChannel(version: string): UpdateChannel { return 'latest' } +/** Dev/staging shells poll rapidly; production shells use a quieter cadence. */ +export function updateCheckIntervalMs(version: string): number { + return resolveUpdateChannel(version) === 'latest' + ? STABLE_CHECK_INTERVAL_MS + : PRERELEASE_CHECK_INTERVAL_MS +} + interface ParsedSemver { major: number minor: number @@ -263,7 +271,8 @@ interface UpdateEngine { /** * Keeps installed shells current against the per-environment update feed: - * checks on launch and every four hours, and mirrors pipeline state to the + * checks on launch, then every five minutes for dev/staging builds or every + * thirty minutes for production builds, and mirrors pipeline state to the * renderer for the settings update UI and the minimum-shell-version gate. * * Developer-ID-signed builds use electron-updater (background download, @@ -528,7 +537,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { } const check = () => engine?.check() setTimeout(check, INITIAL_CHECK_DELAY_MS) - setInterval(check, CHECK_INTERVAL_MS) + setInterval(check, updateCheckIntervalMs(currentVersion)) }) return { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx index 3dde6afcb0f..7a4647f8bfd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx @@ -11,15 +11,11 @@ import { useState, } from 'react' import { - type DesktopAppearanceTheme, type DesktopZoomAction, type DesktopZoomPercent, resolveDesktopZoom, - TERMINAL_DARK_THEME, - TERMINAL_LIGHT_THEME, type TerminalAppearanceTheme, type TerminalShortcutCommand, - type TerminalThemePalette, type TerminalThemeProfile, } from '@sim/desktop-bridge' import { @@ -45,7 +41,8 @@ import { getDesktopBridge } from '@/lib/desktop' import { loadDesktopTerminalAppearance, loadDesktopTerminalThemeProfiles, - resolveDesktopAppearanceTheme, + refreshSelectedTerminalProfile, + resolveTerminalThemePalette, withSelectedProfile, } from '@/lib/desktop/appearance' import { trackPanelFocus } from '@/lib/desktop/panel-focus' @@ -314,15 +311,7 @@ const TerminalView = memo(function TerminalView({ defaultZoom: DesktopZoomPercent }) { const { resolvedTheme } = useTheme() - const profileTheme = typeof appearanceTheme === 'string' ? undefined : appearanceTheme - const builtInTheme: DesktopAppearanceTheme = - typeof appearanceTheme === 'string' ? appearanceTheme : 'app' - const colorScheme = resolveDesktopAppearanceTheme(builtInTheme, resolvedTheme) - const terminalTheme: TerminalThemePalette = profileTheme - ? profileTheme.palette - : colorScheme === 'dark' - ? TERMINAL_DARK_THEME - : TERMINAL_LIGHT_THEME + const terminalTheme = resolveTerminalThemePalette(appearanceTheme, resolvedTheme) const hostRef = useRef<HTMLDivElement>(null) const terminalRef = useRef<Terminal | null>(null) const fitRef = useRef<FitAddon | null>(null) @@ -756,26 +745,20 @@ export function TerminalSession({ visible, scopeId }: TerminalSessionProps) { ) useEffect(() => { + if (!visible) return let active = true - void loadDesktopTerminalAppearance().then((next) => { - if (!active) return - setAppearanceTheme(next.theme) - setDefaultZoom(next.defaultZoom) - }) - return () => { - active = false - } - }, []) - - useEffect(() => { - let active = true - void loadDesktopTerminalThemeProfiles().then((next) => { - if (active) setProfiles(next) - }) + void Promise.all([loadDesktopTerminalAppearance(), loadDesktopTerminalThemeProfiles()]).then( + ([nextAppearance, nextProfiles]) => { + if (!active) return + setProfiles(nextProfiles) + setAppearanceTheme(refreshSelectedTerminalProfile(nextProfiles, nextAppearance.theme)) + setDefaultZoom(nextAppearance.defaultZoom) + } + ) return () => { active = false } - }, []) + }, [visible]) useEffect(() => { let active = true diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 5c7684320dd..7faf2135da8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -1,6 +1,6 @@ 'use client' -import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react' +import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, PlayOutline, Skeleton, Tooltip, toast } from '@sim/emcn' import { Download, @@ -24,6 +24,7 @@ import { reportManualRunToolStop, } from '@/lib/copilot/tools/client/run-tool-execution' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { prefersInPlaceNavigation } from '@/lib/desktop' import { triggerFileDownload } from '@/lib/uploads/client/download' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { @@ -73,6 +74,25 @@ const LOADING_SKELETON = ( </div> ) +/** + * Opens an internal app link the way the host expects: a new browser tab on the + * web, and the current view in the desktop app, whose shell would otherwise turn + * the same-origin `window.open` into a second Sim window. + */ +function useOpenInternalLink() { + const router = useRouter() + return useCallback( + (href: string) => { + if (prefersInPlaceNavigation()) { + router.push(href) + return + } + window.open(href, '_blank') + }, + [router] + ) +} + interface ResourceContentProps { workspaceId: string desktopScopeId: string @@ -350,6 +370,7 @@ interface EmbeddedWorkflowActionsProps { } export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWorkflowActionsProps) { + const openInternalLink = useOpenInternalLink() const { navigateToSettings } = useSettingsNavigation() const { data: session } = useSession() const hostContext = useWorkspaceHostContext() @@ -404,7 +425,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor } const handleOpenWorkflow = () => { - window.open(`/workspace/${workspaceId}/w/${workflowId}`, '_blank') + openInternalLink(`/workspace/${workspaceId}/w/${workflowId}`) } return ( @@ -727,6 +748,7 @@ interface EmbeddedFolderProps { } function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) { + const openInternalLink = useOpenInternalLink() const { data: folderList, isPending: isFoldersPending } = useFolders(workspaceId) const { data: workflowList = [] } = useWorkflows(workspaceId) @@ -760,7 +782,7 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) { <button key={w.id} type='button' - onClick={() => window.open(`/workspace/${workspaceId}/w/${w.id}`, '_blank')} + onClick={() => openInternalLink(`/workspace/${workspaceId}/w/${w.id}`)} className='flex items-center gap-2 rounded-[6px] px-3 py-2 text-left transition-colors hover:bg-[var(--surface-4)]' > <WorkflowIcon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' /> diff --git a/apps/sim/lib/desktop/appearance.test.ts b/apps/sim/lib/desktop/appearance.test.ts index f2d9284d585..76ded478bec 100644 --- a/apps/sim/lib/desktop/appearance.test.ts +++ b/apps/sim/lib/desktop/appearance.test.ts @@ -1,4 +1,4 @@ -import { TERMINAL_DARK_THEME } from '@sim/desktop-bridge' +import { TERMINAL_DARK_THEME, TERMINAL_LIGHT_THEME } from '@sim/desktop-bridge' import { afterEach, describe, expect, it, vi } from 'vitest' const { mockBridge } = vi.hoisted(() => ({ mockBridge: { current: undefined as unknown } })) @@ -10,7 +10,9 @@ vi.mock('@/lib/desktop', () => ({ import { loadDesktopTerminalAppearance, loadDesktopTerminalThemeProfiles, + refreshSelectedTerminalProfile, resolveDesktopAppearanceTheme, + resolveTerminalThemePalette, } from './appearance' afterEach(() => { @@ -35,6 +37,58 @@ describe('resolveDesktopAppearanceTheme', () => { }) }) +describe('resolveTerminalThemePalette', () => { + const fallbackPalette = { ...TERMINAL_DARK_THEME, background: '#111111' } + const lightPalette = { ...TERMINAL_LIGHT_THEME, background: '#fafafa' } + const darkPalette = { ...TERMINAL_DARK_THEME, background: '#222222' } + const profile = { + id: 'iterm2:ocean', + name: 'Ocean', + source: 'iterm2' as const, + palette: fallbackPalette, + lightPalette, + darkPalette, + } + + it('uses an imported profile palette matching Sim appearance', () => { + expect(resolveTerminalThemePalette(profile, 'light')).toBe(lightPalette) + expect(resolveTerminalThemePalette(profile, 'dark')).toBe(darkPalette) + }) + + it('falls back to the source palette when a profile has no mode-specific colors', () => { + const legacyProfile = { ...profile, lightPalette: undefined, darkPalette: undefined } + expect(resolveTerminalThemePalette(legacyProfile, 'light')).toBe(fallbackPalette) + expect(resolveTerminalThemePalette(legacyProfile, 'dark')).toBe(fallbackPalette) + }) + + it('keeps built-in Sim themes unchanged', () => { + expect(resolveTerminalThemePalette('light', 'dark')).toBe(TERMINAL_LIGHT_THEME) + expect(resolveTerminalThemePalette('dark', 'light')).toBe(TERMINAL_DARK_THEME) + }) +}) + +describe('refreshSelectedTerminalProfile', () => { + const storedProfile = { + id: 'iterm2:ocean', + name: 'Ocean', + source: 'iterm2' as const, + palette: { ...TERMINAL_DARK_THEME, background: '#111111' }, + } + const refreshedProfile = { + ...storedProfile, + palette: { ...storedProfile.palette, background: '#222222' }, + } + + it('uses newly discovered colors for the active source profile', () => { + expect(refreshSelectedTerminalProfile([refreshedProfile], storedProfile)).toBe(refreshedProfile) + }) + + it('keeps built-in and unavailable profile selections unchanged', () => { + expect(refreshSelectedTerminalProfile([refreshedProfile], 'app')).toBe('app') + expect(refreshSelectedTerminalProfile([], storedProfile)).toBe(storedProfile) + }) +}) + describe('loadDesktopTerminalAppearance', () => { it('returns a cached profile selection without waiting for source discovery', async () => { const selectedProfile = { diff --git a/apps/sim/lib/desktop/appearance.ts b/apps/sim/lib/desktop/appearance.ts index 2702c3c0983..00caae6e4bf 100644 --- a/apps/sim/lib/desktop/appearance.ts +++ b/apps/sim/lib/desktop/appearance.ts @@ -4,7 +4,10 @@ import { isDesktopAppearanceTheme, isDesktopZoomPercent, isTerminalAppearanceTheme, + TERMINAL_DARK_THEME, + TERMINAL_LIGHT_THEME, type TerminalAppearanceTheme, + type TerminalThemePalette, type TerminalThemeProfile, } from '@sim/desktop-bridge' import { getDesktopBridge } from '@/lib/desktop' @@ -66,6 +69,15 @@ export function withSelectedProfile( : profiles } +/** Replaces a persisted profile snapshot with freshly discovered source colors. */ +export function refreshSelectedTerminalProfile( + profiles: TerminalThemeProfile[], + theme: TerminalAppearanceTheme +): TerminalAppearanceTheme { + if (typeof theme === 'string') return theme + return profiles.find(({ id }) => id === theme.id) ?? theme +} + /** * Resolves `app` against next-themes' raw or resolved value. `system` stays * meaningful for browser CDP; terminal callers treat it as the light fallback @@ -78,3 +90,22 @@ export function resolveDesktopAppearanceTheme( if (preference !== 'app') return preference return appTheme === 'light' || appTheme === 'dark' || appTheme === 'system' ? appTheme : 'system' } + +/** + * Resolves built-in and imported terminal palettes against Sim's live + * appearance. Imported profiles always follow the app appearance — they carry + * their own colors, so there is no separate preference to pin them to. + */ +export function resolveTerminalThemePalette( + theme: TerminalAppearanceTheme, + appTheme: string | undefined +): TerminalThemePalette { + if (typeof theme !== 'string') { + return resolveDesktopAppearanceTheme('app', appTheme) === 'dark' + ? (theme.darkPalette ?? theme.palette) + : (theme.lightPalette ?? theme.palette) + } + return resolveDesktopAppearanceTheme(theme, appTheme) === 'dark' + ? TERMINAL_DARK_THEME + : TERMINAL_LIGHT_THEME +} diff --git a/apps/sim/lib/desktop/index.ts b/apps/sim/lib/desktop/index.ts index 1d31fb67cbf..f902743f021 100644 --- a/apps/sim/lib/desktop/index.ts +++ b/apps/sim/lib/desktop/index.ts @@ -55,6 +55,16 @@ export function hasDesktopSettings(): boolean { return isDesktopApp() } +/** + * True when an internal link must navigate the current view rather than open a + * second one. The shell has no tab strip, so its window-open policy routes a + * same-origin `window.open` to a full new Sim window — where a browser would + * have added a background tab, the desktop app throws up another window. + */ +export function prefersInPlaceNavigation(): boolean { + return isDesktopApp() +} + /** * The device switches for the browser and terminal, cached because the chat UI * reads availability synchronously while the shell only answers over async diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts index b4404aab108..61936588960 100644 --- a/packages/desktop-bridge/src/index.ts +++ b/packages/desktop-bridge/src/index.ts @@ -724,7 +724,15 @@ export interface TerminalSelectedProfile { id: string name: string source: TerminalThemeSource + /** + * Palette used when the source does not provide appearance-specific colors. + * Ignored once both `lightPalette` and `darkPalette` are present. + */ palette: TerminalThemePalette + /** Optional palette used while Sim is in light appearance. */ + lightPalette?: TerminalThemePalette + /** Optional palette used while Sim is in dark appearance. */ + darkPalette?: TerminalThemePalette } export type TerminalThemeProfile = TerminalSelectedProfile @@ -737,41 +745,60 @@ const TERMINAL_THEME_PALETTE_KEYS: readonly (keyof TerminalThemePalette)[] = [ ...TERMINAL_THEME_ANSI_KEYS, ] +const TERMINAL_THEME_OPTIONAL_PALETTE_KEYS = ['cursorAccent', 'selectionForeground'] as const + const TERMINAL_THEME_COLOR_PATTERN = /^#[0-9a-f]{6}$/i +function isTerminalThemeColor(value: unknown): value is string { + return typeof value === 'string' && TERMINAL_THEME_COLOR_PATTERN.test(value) +} + +function isTerminalThemePalette(value: unknown): value is TerminalThemePalette { + if (typeof value !== 'object' || value === null) return false + const palette = value as Partial<TerminalThemePalette> + return ( + TERMINAL_THEME_PALETTE_KEYS.every((key) => isTerminalThemeColor(palette[key])) && + TERMINAL_THEME_OPTIONAL_PALETTE_KEYS.every( + (key) => palette[key] === undefined || isTerminalThemeColor(palette[key]) + ) + ) +} + export function isTerminalSelectedProfile(value: unknown): value is TerminalSelectedProfile { if (typeof value !== 'object' || value === null) return false const candidate = value as Partial<TerminalSelectedProfile> - if ( - typeof candidate.id !== 'string' || - candidate.id.length === 0 || - candidate.id.length > 300 || - typeof candidate.name !== 'string' || - candidate.name.length === 0 || - candidate.name.length > 200 || - (candidate.source !== 'terminal' && candidate.source !== 'iterm2') || - typeof candidate.palette !== 'object' || - candidate.palette === null - ) { - return false - } - if ( - !TERMINAL_THEME_PALETTE_KEYS.every( - (key) => - typeof candidate.palette?.[key] === 'string' && - TERMINAL_THEME_COLOR_PATTERN.test(candidate.palette[key]) - ) - ) { - return false - } - return (['cursorAccent', 'selectionForeground'] as const).every( - (key) => - candidate.palette?.[key] === undefined || - (typeof candidate.palette[key] === 'string' && - TERMINAL_THEME_COLOR_PATTERN.test(candidate.palette[key])) + return ( + typeof candidate.id === 'string' && + candidate.id.length > 0 && + candidate.id.length <= 300 && + typeof candidate.name === 'string' && + candidate.name.length > 0 && + candidate.name.length <= 200 && + (candidate.source === 'terminal' || candidate.source === 'iterm2') && + isTerminalThemePalette(candidate.palette) && + (candidate.lightPalette === undefined || isTerminalThemePalette(candidate.lightPalette)) && + (candidate.darkPalette === undefined || isTerminalThemePalette(candidate.darkPalette)) ) } +/** + * Copies only the known profile fields, so untrusted source output and stored + * config never carry extra keys. The single definition of a profile's shape — + * new palette slots are added here rather than at each call site. + */ +export function cloneTerminalSelectedProfile( + profile: TerminalSelectedProfile +): TerminalSelectedProfile { + return { + id: profile.id, + name: profile.name, + source: profile.source, + palette: { ...profile.palette }, + ...(profile.lightPalette ? { lightPalette: { ...profile.lightPalette } } : {}), + ...(profile.darkPalette ? { darkPalette: { ...profile.darkPalette } } : {}), + } +} + export interface DesktopPreferences { notificationsEnabled: boolean notificationSounds: boolean diff --git a/packages/sim-cli/src/commands/protocol/chat-attachments.test.ts b/packages/sim-cli/src/commands/protocol/chat-attachments.test.ts index d0de466d569..642b20a8498 100644 --- a/packages/sim-cli/src/commands/protocol/chat-attachments.test.ts +++ b/packages/sim-cli/src/commands/protocol/chat-attachments.test.ts @@ -4,10 +4,8 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { combineChatAttachments, - existingAttachmentPaths, loadChatAttachment, loadChatAttachments, - parseAttachmentPaths, } from './chat-attachments.js' const temporaryDirectories: string[] = [] @@ -110,25 +108,3 @@ describe('chat attachments', () => { ) }) }) - -describe('attachment path parsing', () => { - it('supports quoted and terminal-escaped paths', () => { - expect(parseAttachmentPaths("'/tmp/one two.md' /tmp/three\\ four.png")).toEqual([ - '/tmp/one two.md', - '/tmp/three four.png', - ]) - expect(() => parseAttachmentPaths("'/tmp/open")).toThrow(/Unclosed quote/) - }) - - it('recognizes a pasted path containing spaces before trying shell splitting', async () => { - const path = await fixture('a file.txt', 'hello') - await expect(existingAttachmentPaths(path)).resolves.toEqual([path]) - await expect(existingAttachmentPaths('this is a normal question')).resolves.toBeNull() - }) - - it('rejects pasted candidate lists beyond the per-turn limit', async () => { - const candidates = Array.from({ length: 6 }, (_, index) => `/tmp/sim-file-${index}`).join(' ') - - await expect(existingAttachmentPaths(candidates)).resolves.toBeNull() - }) -}) diff --git a/packages/sim-cli/src/commands/protocol/chat-attachments.ts b/packages/sim-cli/src/commands/protocol/chat-attachments.ts index 7e313c74bc9..0697bac11c1 100644 --- a/packages/sim-cli/src/commands/protocol/chat-attachments.ts +++ b/packages/sim-cli/src/commands/protocol/chat-attachments.ts @@ -205,22 +205,34 @@ export async function loadChatAttachments(paths: string[]): Promise<ChatAttachme return attachments } +/** A token from a submitted line, with its span in the original input. */ +interface AttachmentToken { + value: string + start: number + end: number +} + /** - * Splits `/attach` input using the subset terminals produce for dragged paths: - * whitespace separation, single/double quotes, and backslash escapes. + * Splits an input line into shell-style tokens, honoring single quotes, double + * quotes and backslash escapes, and keeping each token's span so callers can + * rewrite the original text in place. */ -export function parseAttachmentPaths(input: string): string[] { - const paths: string[] = [] +function tokenizeAttachmentInput(input: string): AttachmentToken[] { + const tokens: AttachmentToken[] = [] let value = '' let quote: 'single' | 'double' | null = null let escaped = false + let start = -1 - const push = () => { - if (value) paths.push(value) + const push = (end: number) => { + if (value) tokens.push({ value, start, end }) value = '' + start = -1 } - for (const character of input.trim()) { + for (let index = 0; index < input.length; index++) { + const character = input[index] as string + if (start < 0 && !/\s/.test(character)) start = index if (escaped) { value += character escaped = false @@ -239,7 +251,7 @@ export function parseAttachmentPaths(input: string): string[] { continue } if (/\s/.test(character) && quote === null) { - push() + push(index) continue } value += character @@ -247,8 +259,8 @@ export function parseAttachmentPaths(input: string): string[] { if (escaped) value += '\\' if (quote !== null) throw attachmentError('Unclosed quote in attachment path.') - push() - return paths + push(input.length) + return tokens } /** @@ -259,6 +271,13 @@ export function parseAttachmentPaths(input: string): string[] { * image and the whole read fails. `loadChatAttachment` caps the size on fstat * and again on read, which is where the limit belongs anyway. */ +/** Returns the POSIX path of a file copied in Finder, which carries no text flavor. */ +const APPLE_SCRIPT_FILE = [ + 'on run', + 'return POSIX path of (the clipboard as «class furl»)', + 'end run', +] + const APPLE_SCRIPT = [ 'on run argv', 'set outputPath to item 1 of argv', @@ -278,9 +297,12 @@ const APPLE_SCRIPT = [ ] /** Best-effort macOS clipboard image extraction, used by the paste keystroke. */ -export async function readClipboardImage(): Promise<ChatAttachment | null> { +export async function readClipboardAttachment(): Promise<ChatAttachment | null> { if (process.platform !== 'darwin') return null + return (await readClipboardImage()) ?? (await readClipboardFile()) +} +async function readClipboardImage(): Promise<ChatAttachment | null> { const directory = await mkdtemp(join(tmpdir(), 'sim-chat-clipboard-')) const path = join(directory, 'clipboard.png') try { @@ -296,29 +318,76 @@ export async function readClipboardImage(): Promise<ChatAttachment | null> { } } +/** + * Reads a file copied in Finder. + * + * The `furl` coercion is lenient — plain clipboard text comes back as a path + * that was never on disk — so the result is only trusted once it stats as a + * real file. + */ +async function readClipboardFile(): Promise<ChatAttachment | null> { + try { + const args = APPLE_SCRIPT_FILE.flatMap((line) => ['-e', line]) + const { stdout } = await execFileAsync('osascript', args, { timeout: 5_000 }) + const path = stdout.trim() + if (!path || !(await stat(path)).isFile()) return null + return await loadChatAttachment(path) + } catch { + return null + } +} + /** True when every parsed path names an existing regular file. */ -export async function existingAttachmentPaths(input: string): Promise<string[] | null> { - let wholePath +async function isFile(path: string): Promise<boolean> { try { - wholePath = await stat(input.trim()) + return (await stat(path)).isFile() } catch { - wholePath = null + return false } - if (wholePath?.isFile()) return [input.trim()] +} + +/** Paths found inside a message, and the message with each replaced by a tag. */ +export interface ExtractedAttachments { + paths: string[] + text: string +} + +/** + * Pulls existing file paths out of a message, wherever they appear. + * + * A token only counts when it resolves to a real file, so prose that merely + * looks path-like — a snippet, a URL fragment — stays literal text. Each match + * is swapped for a `[File #N]` tag so the reader can see what was attached and + * delete it to detach. + */ +export async function extractAttachmentPaths(input: string): Promise<ExtractedAttachments | null> { + /* A path pasted whole may contain unescaped spaces, which tokenizing would + split apart, so the entire line gets the first look. */ + const whole = input.trim() + if (whole.includes('/') && (await isFile(whole))) return { paths: [whole], text: '[File #1]' } - let paths: string[] + let tokens: AttachmentToken[] try { - paths = parseAttachmentPaths(input) + tokens = tokenizeAttachmentInput(input) } catch { return null } - if (paths.length === 0 || paths.length > MAX_CHAT_ATTACHMENTS) return null - for (const path of paths) { - try { - if (!(await stat(path)).isFile()) return null - } catch { - return null - } + + const matches: Array<{ token: AttachmentToken; path: string }> = [] + for (const token of tokens) { + if (matches.length >= MAX_CHAT_ATTACHMENTS) break + if (!token.value.includes('/') && !token.value.includes('\\')) continue + if (await isFile(token.value)) matches.push({ token, path: token.value }) } - return paths + if (matches.length === 0) return null + + let text = '' + let cursor = 0 + for (const [index, match] of matches.entries()) { + text += `${input.slice(cursor, match.token.start)}[File #${index + 1}]` + cursor = match.token.end + } + text += input.slice(cursor) + + return { paths: matches.map((match) => match.path), text: text.trim() } } diff --git a/packages/sim-cli/src/commands/protocol/chat-path-extraction.test.ts b/packages/sim-cli/src/commands/protocol/chat-path-extraction.test.ts new file mode 100644 index 00000000000..839559f705e --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-path-extraction.test.ts @@ -0,0 +1,77 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { extractAttachmentPaths } from './chat-attachments.js' + +let dir: string +let file: string +let spaced: string + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-extract-')) + file = join(dir, 'report.pdf') + spaced = join(dir, 'my report.pdf') + writeFileSync(file, 'x') + writeFileSync(spaced, 'x') +}) +afterAll(() => rmSync(dir, { recursive: true, force: true })) + +describe('extractAttachmentPaths', () => { + it('pulls a path out of surrounding prose and leaves a tag', async () => { + const result = await extractAttachmentPaths(`summarize ${file} for me`) + expect(result).toEqual({ paths: [file], text: 'summarize [File #1] for me' }) + }) + + it('handles a path at the start or end of the line', async () => { + expect((await extractAttachmentPaths(`${file} what is this`))?.text).toBe( + '[File #1] what is this' + ) + expect((await extractAttachmentPaths(`look at ${file}`))?.text).toBe('look at [File #1]') + }) + + it('numbers multiple attachments in order', async () => { + const result = await extractAttachmentPaths(`diff ${file} against ${file}`) + expect(result?.text).toBe('diff [File #1] against [File #2]') + expect(result?.paths).toHaveLength(2) + }) + + it('understands quoted and escaped paths with spaces', async () => { + expect((await extractAttachmentPaths(`read "${spaced}" please`))?.paths).toEqual([spaced]) + const escaped = spaced.replace(/ /gu, '\\ ') + expect((await extractAttachmentPaths(`read ${escaped} please`))?.paths).toEqual([spaced]) + }) + + it('leaves path-like prose alone when the file does not exist', async () => { + expect(await extractAttachmentPaths('check /nope/missing.png please')).toBeNull() + expect(await extractAttachmentPaths('see src/does-not-exist.ts line 4')).toBeNull() + }) + + it('attaches a relative path that resolves against the working directory', async () => { + expect((await extractAttachmentPaths('read src/index.ts'))?.paths).toEqual(['src/index.ts']) + }) + + it('returns null for a message with no paths', async () => { + expect(await extractAttachmentPaths('hello there')).toBeNull() + }) + + it('takes a whole line that is one unescaped path with spaces', async () => { + expect((await extractAttachmentPaths(` ${spaced} `))?.paths).toEqual([spaced]) + }) + + it('stops at the per-turn attachment limit and leaves the rest as text', async () => { + const line = Array.from({ length: 6 }, () => file).join(' ') + const result = await extractAttachmentPaths(line) + expect(result?.paths).toHaveLength(5) + expect(result?.text).toBe(`[File #1] [File #2] [File #3] [File #4] [File #5] ${file}`) + }) + + it('keeps the line breaks in a multi-line message', async () => { + const result = await extractAttachmentPaths(`first line\nsummarize ${file}\nlast line`) + expect(result?.text).toBe('first line\nsummarize [File #1]\nlast line') + }) + + it('does not throw on an unclosed quote', async () => { + expect(await extractAttachmentPaths(`read "${file}`)).toBeNull() + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat-suggestions.ts b/packages/sim-cli/src/commands/protocol/chat-suggestions.ts index c4ccbf17cf5..f5ab77154c7 100644 --- a/packages/sim-cli/src/commands/protocol/chat-suggestions.ts +++ b/packages/sim-cli/src/commands/protocol/chat-suggestions.ts @@ -184,13 +184,6 @@ export function contextSpans( /** Composer slash commands, the source for the `/` menu. */ export const SLASH_COMMANDS: SuggestionItem[] = [ - { - id: 'attach', - value: '/attach', - displayText: '/attach <paths>', - description: 'attach local files to the next turn', - tag: 'command', - }, { id: 'clear', value: '/clear', diff --git a/packages/sim-cli/src/commands/protocol/chat-terminal.test.ts b/packages/sim-cli/src/commands/protocol/chat-terminal.test.ts index 089ffc54601..c32b4372232 100644 --- a/packages/sim-cli/src/commands/protocol/chat-terminal.test.ts +++ b/packages/sim-cli/src/commands/protocol/chat-terminal.test.ts @@ -510,7 +510,8 @@ describe('ReadlineChatTerminal', () => { expect(suggestion).toBeGreaterThanOrEqual(0) expect(thinking).toBeGreaterThan(suggestion) - expect(thinking).toBe(composer - 2) + expect(thinking).toBe(composer - 3) + expect(lines[composer - 2]).toBe('') expect(lines[composer - 1]).toBe(' ') expect(lines[composer + 1]).toBe(' ') expect(composer + 1).toBe(lines.length - 2) @@ -523,7 +524,7 @@ describe('ReadlineChatTerminal', () => { const renderedThinking = renderedLines.findIndex((line) => line?.includes('Thinking…')) const renderedComposer = renderedLines.findIndex((line) => line?.startsWith(' ❯ /')) expect(renderedThinking).toBeGreaterThanOrEqual(0) - expect(renderedComposer).toBe(renderedThinking + 2) + expect(renderedComposer).toBe(renderedThinking + 3) expectUserPanelRow(screen.terminal, renderedComposer - 1, columns) expectUserPanelRow(screen.terminal, renderedComposer, columns) expectUserPanelRow(screen.terminal, renderedComposer + 1, columns) @@ -2028,7 +2029,8 @@ describe('ReadlineChatTerminal', () => { expect(status).toMatch(/^[·•●] Thinking…$/u) expect(status).not.toContain('Planning next step') expect(statusRow).toBeGreaterThanOrEqual(0) - expect(statusRow).toBe(composerRow - 2) + expect(statusRow).toBe(composerRow - 3) + expect(panel[composerRow - 2]).toBe('') expect(panel[composerRow - 1]).toBe(' ') expect(panel[composerRow + 1]).toBe(' ') diff --git a/packages/sim-cli/src/commands/protocol/chat-terminal.ts b/packages/sim-cli/src/commands/protocol/chat-terminal.ts index e4afac55352..fe465c7661d 100644 --- a/packages/sim-cli/src/commands/protocol/chat-terminal.ts +++ b/packages/sim-cli/src/commands/protocol/chat-terminal.ts @@ -15,6 +15,9 @@ import { truncateDisplay, } from '../../output/terminal-text.js' +/** Which tag `noteAttachment` writes into the composer. */ +export type ChatAttachmentKind = 'Image' | 'File' + export type ChatTerminalInput = | { kind: 'line' @@ -97,8 +100,8 @@ export interface ChatTerminal { setChatTitle(title: string): void /** Fills in the workspace name once the lookup resolves. */ setWorkspaceName(name: string): void - /** Inserts an `[Image #N]` tag at the cursor for a just-attached image. */ - noteAttachment(): void + /** Inserts an `[Image #N]` or `[File #N]` tag at the cursor for a just-attached file. */ + noteAttachment(kind?: ChatAttachmentKind): void /** Supplies the home-composer `@` resource and `/` skill/MCP pools. */ setSuggestionCandidates?(candidates: ChatSuggestionCandidates): void /** Clears the visible conversation while preserving the active terminal session. */ @@ -321,7 +324,7 @@ export class ReadlineChatTerminal implements ChatTerminal { private resourceCandidates: SuggestionItem[] = [] private slashCandidates: SuggestionItem[] = [] private selectedContexts: ChatContext[] = [] - private nextAttachmentNumber = 1 + private readonly nextAttachmentNumber = new Map<ChatAttachmentKind, number>() private pasting = false private pasteBuffer = '' private pastedText = new Map<number, string>() @@ -1754,12 +1757,14 @@ export class ReadlineChatTerminal implements ChatTerminal { /* Keep the suggestion menu visually separate from the activity line. The composer's shaded top row already separates activity from input. */ const suggestionGap = suggestionRows.length ? [''] : [] + const activityGap = activityRows.length ? [''] : [] const composerCursor = { row: topMargin.length + suggestionRows.length + suggestionGap.length + activityRows.length + + activityGap.length + 1 + layout.cursor.row - firstVisible, @@ -1771,6 +1776,7 @@ export class ReadlineChatTerminal implements ChatTerminal { ...suggestionRows, ...suggestionGap, ...activityRows, + ...activityGap, userPanelRow(), ...visibleRows.map((line) => userPanelRow(line)), userPanelRow(), @@ -2034,8 +2040,10 @@ export class ReadlineChatTerminal implements ChatTerminal { )}${RESET}\n\n` } - noteAttachment(): void { - const token = `[Image #${this.nextAttachmentNumber++}]` + noteAttachment(kind: ChatAttachmentKind = 'Image'): void { + const number = this.nextAttachmentNumber.get(kind) ?? 1 + this.nextAttachmentNumber.set(kind, number + 1) + const token = `[${kind} #${number}]` const before = this.draft.slice(0, this.cursor) const separator = !before || /\s$/u.test(before) ? '' : ' ' this.insertText(`${separator}${token} `) @@ -2561,11 +2569,11 @@ function cursorTo(row: number, column: number): string { } /** - * Spans of `[Image #N]` tags, so a pasted attachment reads as a tag rather than - * loose text. Derived per render like context spans, so deleting the tag stops - * the highlight with no bookkeeping. + * Spans of `[Image #N]` and `[File #N]` tags, so an attachment reads as a tag + * rather than loose text. Derived per render like context spans, so deleting the + * tag stops the highlight with no bookkeeping. */ -const ATTACHMENT_TOKEN = /\[Image #\d+\]/gu +const ATTACHMENT_TOKEN = /\[(?:Image|File) #\d+\]/gu function attachmentSpans(text: string): Array<{ start: number; end: number }> { return [...text.matchAll(ATTACHMENT_TOKEN)].map((match) => ({ diff --git a/packages/sim-cli/src/commands/protocol/chat.test.ts b/packages/sim-cli/src/commands/protocol/chat.test.ts index b0b9d826f4c..40bd546da27 100644 --- a/packages/sim-cli/src/commands/protocol/chat.test.ts +++ b/packages/sim-cli/src/commands/protocol/chat.test.ts @@ -1014,7 +1014,7 @@ describe('interactive chat', () => { mocks.requestRaw.mockResolvedValueOnce(completed('Retried', 'next-token')) const terminal = new FakeTerminal( [ - { kind: 'line', value: '/attach "/private/tmp/notes.txt"' }, + { kind: 'clipboard', value: '' }, { kind: 'line', value: '/chats' }, { kind: 'line', value: prompt, display, pastes, contexts }, { kind: 'line', value: prompt, display, pastes, contexts }, @@ -1027,8 +1027,8 @@ describe('interactive chat', () => { await program(async () => '', vi.fn(), { isInteractive: () => true, createTerminal: () => terminal, - loadAttachments: async (paths) => (paths.length ? [attachment] : []), - pastedAttachmentPaths: async () => null, + clipboardAttachment: async () => attachment, + extractAttachmentPaths: async () => null, }).parseAsync(['node', 'sim', 'chat']) expect(detailRequests).toBe(3) @@ -1413,7 +1413,7 @@ describe('interactive chat', () => { ]) }) - it('waits for queued path confirmation before answering a retained question', async () => { + it('attaches a queued path without answering a retained question', async () => { const question = '<question>{"type":"single_select","prompt":"Proceed?","options":[{"id":"yes","label":"Yes"}]}</question>' const attachment: ChatAttachment = { @@ -1432,7 +1432,6 @@ describe('interactive chat', () => { queued: true, display: '/private/tmp/report.txt', }, - { kind: 'line', value: '/attach "/private/tmp/report.txt"' }, { kind: 'line', value: '/exit' }, ], [{ kind: 'answer', values: ['Yes'] }] @@ -1441,21 +1440,18 @@ describe('interactive chat', () => { await program(async () => '', vi.fn(), { isInteractive: () => true, createTerminal: () => terminal, - pastedAttachmentPaths: async (value) => - value === '/private/tmp/report.txt' ? ['/private/tmp/report.txt'] : null, + extractAttachmentPaths: async (value: string) => + value === '/private/tmp/report.txt' + ? { paths: ['/private/tmp/report.txt'], text: '[File #1]' } + : null, loadAttachments: async () => [attachment], }).parseAsync(['node', 'sim', 'chat', 'start']) - expect(terminal.preloads).toContainEqual({ - value: '/attach "/private/tmp/report.txt"', - queued: false, - }) expect(terminal.questions).toHaveLength(1) expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ workspaceId: 'ws_local', prompt: 'Proceed? — Yes', continuationToken: 'token-1', - attachments: [attachment], }) }) @@ -1549,7 +1545,7 @@ describe('interactive chat', () => { ) }) - it('requires an explicit Enter on a preloaded /attach command for pasted paths', async () => { + it('attaches a pasted path inline and sends the surrounding text', async () => { const attachment: ChatAttachment = { name: 'report.txt', mediaType: 'text/plain', @@ -1557,58 +1553,55 @@ describe('interactive chat', () => { } const absolutePath = '/private/tmp/report.txt' const terminal = new FakeTerminal([ - { kind: 'line', value: absolutePath }, - { kind: 'line', value: `/attach "${absolutePath}"` }, - { kind: 'line', value: 'Inspect this file' }, + { kind: 'line', value: `Inspect ${absolutePath} closely` }, { kind: 'line', value: '/exit' }, ]) mocks.requestRaw.mockResolvedValue(completed('Done')) - const pastedAttachmentPaths = vi.fn(async (value: string) => - value === absolutePath ? [absolutePath] : null + const extractAttachmentPaths = vi.fn(async (value: string) => + value.includes(absolutePath) + ? { paths: [absolutePath], text: value.replace(absolutePath, '[File #1]') } + : null ) const loadAttachments = vi.fn(async (paths: string[]) => (paths.length ? [attachment] : [])) await program(async () => '', vi.fn(), { isInteractive: () => true, createTerminal: () => terminal, - pastedAttachmentPaths, + extractAttachmentPaths, loadAttachments, }).parseAsync(['node', 'sim', 'chat']) expect(loadAttachments).toHaveBeenCalledWith([absolutePath]) - expect(terminal.reads[1].initialValue).toBe(`/attach "${absolutePath}"`) - expect(terminal.statuses).toContain( - 'File path detected. Press Enter to attach it, or edit the command.' - ) + expect(terminal.preloads).toEqual([]) expect(terminal.statuses.some((status) => status.startsWith('Unknown command:'))).toBe(false) expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ workspaceId: 'ws_local', - prompt: 'Inspect this file', + prompt: 'Inspect [File #1] closely', attachments: [attachment], }) }) - it('does not read or upload a detected path when confirmation is cancelled', async () => { + it('never reads a path out of a slash command', async () => { const absolutePath = '/private/tmp/private.txt' const terminal = new FakeTerminal([ - { kind: 'line', value: absolutePath }, + { kind: 'line', value: `/rename ${absolutePath}` }, { kind: 'line', value: '/exit' }, ]) - const pastedAttachmentPaths = vi.fn(async (value: string) => - value === absolutePath ? [absolutePath] : null - ) + const extractAttachmentPaths = vi.fn(async () => ({ + paths: [absolutePath], + text: '[File #1]', + })) const loadAttachments = vi.fn(async () => []) await program(async () => '', vi.fn(), { isInteractive: () => true, createTerminal: () => terminal, - pastedAttachmentPaths, + extractAttachmentPaths, loadAttachments, }).parseAsync(['node', 'sim', 'chat']) - expect(terminal.reads[1].initialValue).toBe(`/attach "${absolutePath}"`) - expect(loadAttachments).toHaveBeenCalledTimes(1) - expect(loadAttachments).toHaveBeenCalledWith([]) + expect(extractAttachmentPaths).not.toHaveBeenCalled() + expect(loadAttachments).not.toHaveBeenCalledWith([absolutePath]) expect(mocks.requestRaw).not.toHaveBeenCalled() }) @@ -1628,8 +1621,8 @@ describe('interactive chat', () => { await program(async () => '', vi.fn(), { isInteractive: () => true, createTerminal: () => terminal, - clipboardImage: async () => attachment, - pastedAttachmentPaths: async () => null, + clipboardAttachment: async () => attachment, + extractAttachmentPaths: async () => null, }).parseAsync(['node', 'sim', 'chat']) expect(terminal.reads[1].initialValue).toBe('') @@ -1738,7 +1731,7 @@ describe('interactive chat', () => { expect(terminal.preloads).toEqual([]) }) - it('leaves the active turn running for a queued path recognized by normal chat input', async () => { + it('steers the active turn with a queued line carrying a file path', async () => { const pathInput = { kind: 'line' as const, value: 'report.txt', @@ -1747,8 +1740,8 @@ describe('interactive chat', () => { } const terminal = new FakeTerminal([pathInput, { kind: 'line', value: '/exit' }]) let requestSignal: AbortSignal | undefined - const pastedAttachmentPaths = vi.fn(async (value: string) => - value === 'report.txt' ? ['report.txt'] : null + const extractAttachmentPaths = vi.fn(async (value: string) => + value === 'report.txt' ? { paths: ['report.txt'], text: '[File #1]' } : null ) mocks.requestRaw.mockImplementationOnce( async (_path: string, options: { signal: AbortSignal }) => { @@ -1762,15 +1755,11 @@ describe('interactive chat', () => { await program(async () => '', vi.fn(), { isInteractive: () => true, createTerminal: () => terminal, - pastedAttachmentPaths, + extractAttachmentPaths, }).parseAsync(['node', 'sim', 'chat', 'original']) - expect(requestSignal?.aborted).toBe(false) - expect(terminal.preloads).toContainEqual({ - value: '/attach "report.txt"', - queued: false, - }) - expect(mocks.requestRaw).toHaveBeenCalledTimes(1) + expect(requestSignal?.aborted).toBe(true) + expect(terminal.preloads).toEqual([{ value: 'report.txt', queued: true }]) }) it('queues /chats without interrupting the active stream', async () => { diff --git a/packages/sim-cli/src/commands/protocol/chat.ts b/packages/sim-cli/src/commands/protocol/chat.ts index d372f9bb54a..8459163295e 100644 --- a/packages/sim-cli/src/commands/protocol/chat.ts +++ b/packages/sim-cli/src/commands/protocol/chat.ts @@ -21,10 +21,10 @@ import { safeOneLine, sanitize } from '../../output/render.js' import { type ChatAttachment, combineChatAttachments, - existingAttachmentPaths, + type ExtractedAttachments, + extractAttachmentPaths, loadChatAttachments, - parseAttachmentPaths, - readClipboardImage, + readClipboardAttachment, } from './chat-attachments.js' import { ChatMarkdownStream } from './chat-markdown.js' import { @@ -50,8 +50,8 @@ export interface ChatDependencies { isInteractive: () => boolean createTerminal: () => ChatTerminal loadAttachments: (paths: string[]) => Promise<ChatAttachment[]> - clipboardImage: () => Promise<ChatAttachment | null> - pastedAttachmentPaths: (input: string) => Promise<string[] | null> + clipboardAttachment: () => Promise<ChatAttachment | null> + extractAttachmentPaths: (input: string) => Promise<ExtractedAttachments | null> formatMarkdown: () => boolean } @@ -383,8 +383,8 @@ function explainInteractiveCommands(terminal: ChatTerminal): void { terminal.status( [ 'Commands:', - ' /attach <paths> attach local files to the next turn', - ' ctrl+v attach an image from the clipboard (or cmd+v on macOS)', + ' ctrl+v attach the clipboard image or file (or cmd+v on macOS)', + ' <file path> drop or type a path to attach the file', ' /clear start a new conversation', ' /chats view and switch chats', ' /rename <title> rename the active chat', @@ -399,12 +399,6 @@ function attachmentStatus(attachments: ChatAttachment[]): string { return `Attached for the next turn (${attachments.length}/${5}): ${names}` } -function attachmentCommand(paths: string[]): string | null { - if (paths.some((path) => /[\u0000-\u001f\u007f]/u.test(path))) return null - const quoted = paths.map((path) => `"${path.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`) - return `/attach ${quoted.join(' ')}` -} - async function addPaths( current: ChatAttachment[], paths: string[], @@ -423,18 +417,18 @@ async function addPaths( } } -async function addClipboardImage( +async function addClipboardAttachment( current: ChatAttachment[], terminal: ChatTerminal, dependencies: ChatDependencies ): Promise<ChatAttachment[]> { - const image = await dependencies.clipboardImage() + const pasted = await dependencies.clipboardAttachment() /* Paste feedback is the `[Image #N]` tag in the composer, not a transcript line: the tag says what was attached and disappears when it is deleted. */ - if (!image) return current + if (!pasted) return current try { - const combined = combineChatAttachments(current, [image]) - terminal.noteAttachment() + const combined = combineChatAttachments(current, [pasted]) + terminal.noteAttachment(pasted.mediaType.startsWith('image/') ? 'Image' : 'File') return combined } catch { return current @@ -461,12 +455,12 @@ async function readUserTurn( continue } if (input.kind === 'clipboard') { - attachments = await addClipboardImage(attachments, terminal, dependencies) + attachments = await addClipboardAttachment(attachments, terminal, dependencies) continue } if (input.kind === 'selection') continue - const trimmed = input.value.trim() + let trimmed = input.value.trim() if (trimmed === '/exit' || trimmed === '/quit') return { kind: 'exit' } if (trimmed === '/help') { explainInteractiveCommands(terminal) @@ -492,42 +486,18 @@ async function readUserTurn( } return { kind: 'rename', title, attachments } } - if (trimmed === '/attach' || trimmed.startsWith('/attach ')) { - const rawPaths = trimmed.slice('/attach'.length).trim() - if (!rawPaths) { - terminal.status('Usage: /attach <path> [more paths]') - continue - } - try { - attachments = await addPaths( - attachments, - parseAttachmentPaths(rawPaths), - terminal, - dependencies - ) - } catch (error) { - terminal.status(`Error: ${error instanceof Error ? error.message : String(error)}`) - } - continue - } - if (trimmed) { - const pastedPaths = await dependencies.pastedAttachmentPaths(input.value) - if (pastedPaths) { - // A dragged path is still just user input. Preload an explicit command - // so the next Enter is the user's confirmation before any bytes are read. - const command = attachmentCommand(pastedPaths) - if (!command) { - terminal.status('The detected path cannot be safely preloaded. Use /attach manually.') - continue - } - if (!terminal.preload(command)) { - terminal.status( - 'File path detected, but newer composer input took priority. Use /attach to add it.' - ) + let prompt = input.value + if (trimmed && !trimmed.startsWith('/')) { + const extracted = await dependencies.extractAttachmentPaths(input.value) + if (extracted) { + try { + attachments = await addPaths(attachments, extracted.paths, terminal, dependencies) + prompt = extracted.text + trimmed = prompt.trim() + } catch (error) { + terminal.status(`Error: ${error instanceof Error ? error.message : String(error)}`) continue } - terminal.status('File path detected. Press Enter to attach it, or edit the command.') - continue } } if (trimmed.startsWith('/')) { @@ -540,13 +510,13 @@ async function readUserTurn( } } if (!trimmed && attachments.length === 0) continue - if (utf8Bytes(input.value) > MAX_CHAT_PROMPT_BYTES) { + if (utf8Bytes(prompt) > MAX_CHAT_PROMPT_BYTES) { terminal.status('Error: Chat input exceeds the 10 MiB limit.') continue } return { kind: 'turn', - prompt: input.value, + prompt, attachments, queued: input.queued === true, ...(input.display === undefined ? {} : { display: input.display }), @@ -577,19 +547,13 @@ async function answerQuestions( return { kind: 'answer', value: answers.join('\n') } } -async function isChatTurnInput( - input: Extract<ChatTerminalInput, { kind: 'line' }>, - dependencies: Pick<ChatDependencies, 'pastedAttachmentPaths'> -): Promise<boolean> { +function isChatTurnInput(input: Extract<ChatTerminalInput, { kind: 'line' }>): boolean { const trimmed = input.value.trim() if (!trimmed) return false - if ( - trimmed.startsWith('/') && - !input.contexts?.some((context) => context.kind === 'skill' || context.kind === 'mcp') - ) { - return false - } - return !(await dependencies.pastedAttachmentPaths(input.value)) + return ( + !trimmed.startsWith('/') || + input.contexts?.some((context) => context.kind === 'skill' || context.kind === 'mcp') === true + ) } function logSuggestionLabel( @@ -1192,7 +1156,7 @@ async function runInteractive( } if (input?.kind !== 'line') return submitChecks = submitChecks.then(async () => { - if (!(await isChatTurnInput(input, dependencies))) return + if (!isChatTurnInput(input)) return if (!submitRequested) { submitRequested = true if (sessionReady && !controller.signal.aborted) controller.abort(reason) @@ -1493,8 +1457,8 @@ export function chatCommand(overrides: Partial<ChatDependencies> = {}): Command Boolean(process.stdin.isTTY && process.stdout.isTTY && process.stderr.isTTY), createTerminal: () => new ReadlineChatTerminal(), loadAttachments: loadChatAttachments, - clipboardImage: readClipboardImage, - pastedAttachmentPaths: existingAttachmentPaths, + clipboardAttachment: readClipboardAttachment, + extractAttachmentPaths, // The fullscreen chat already requires a TTY and uses ANSI throughout. A // propagated TERM=dumb value must not leave model Markdown visible inside // an otherwise fully rendered TUI. From 2cab7d729c3c9c2f96f7bb6e7528a1a10d8fc3fd Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 8 Aug 2026 11:40:24 -0700 Subject: [PATCH 102/159] improvement(tables): centralize v2 application operations (#6414) * improvement(tables): centralize v2 application operations * fix(tables): preserve run validation and signals --- apps/sim/app/api/table/utils.ts | 4 +- .../[tableId]/cancel-runs/route.test.ts | 259 ++- .../v2/tables/[tableId]/cancel-runs/route.ts | 96 +- .../v2/tables/[tableId]/columns/route.test.ts | 181 +- .../api/v2/tables/[tableId]/columns/route.ts | 177 +- .../[tableId]/columns/run/route.test.ts | 249 +-- .../v2/tables/[tableId]/columns/run/route.ts | 106 +- .../v2/tables/[tableId]/exports/route.test.ts | 140 ++ .../api/v2/tables/[tableId]/exports/route.ts | 63 +- .../v2/tables/[tableId]/groups/route.test.ts | 512 ++---- .../api/v2/tables/[tableId]/groups/route.ts | 325 +--- .../v2/tables/[tableId]/query/route.test.ts | 369 ++-- .../api/v2/tables/[tableId]/query/route.ts | 130 +- .../app/api/v2/tables/[tableId]/route.test.ts | 570 ++---- apps/sim/app/api/v2/tables/[tableId]/route.ts | 288 +-- .../enrichment/[groupId]/route.test.ts | 206 +-- .../[rowId]/enrichment/[groupId]/route.ts | 77 +- .../[tableId]/rows/[rowId]/route.test.ts | 224 ++- .../v2/tables/[tableId]/rows/[rowId]/route.ts | 203 +-- .../tables/[tableId]/rows/find/route.test.ts | 262 +-- .../v2/tables/[tableId]/rows/find/route.ts | 110 +- .../v2/tables/[tableId]/rows/route.test.ts | 190 ++ .../app/api/v2/tables/[tableId]/rows/route.ts | 414 ++--- .../[tableId]/rows/upsert/route.test.ts | 137 ++ .../v2/tables/[tableId]/rows/upsert/route.ts | 83 +- .../[tableId]/views/[viewId]/route.test.ts | 305 +--- .../tables/[tableId]/views/[viewId]/route.ts | 142 +- .../v2/tables/[tableId]/views/route.test.ts | 293 ++- .../api/v2/tables/[tableId]/views/route.ts | 103 +- .../exports/[exportId]/download/route.ts | 61 +- .../api/v2/tables/exports/[exportId]/route.ts | 80 +- .../app/api/v2/tables/folders/route.test.ts | 158 ++ apps/sim/app/api/v2/tables/folders/route.ts | 144 +- .../imports/[importId]/complete/route.test.ts | 175 +- .../imports/[importId]/complete/route.ts | 67 +- .../tables/imports/[importId]/parts/route.ts | 54 +- .../api/v2/tables/imports/[importId]/route.ts | 89 +- .../app/api/v2/tables/imports/route.test.ts | 181 +- apps/sim/app/api/v2/tables/imports/route.ts | 61 +- apps/sim/app/api/v2/tables/route.test.ts | 366 ++-- apps/sim/app/api/v2/tables/route.ts | 162 +- apps/sim/app/api/v2/tables/utils.ts | 2 +- apps/sim/lib/api/contracts/v2/tables.ts | 13 +- .../application/execute-table-use-case.ts | 63 + .../lib/copilot/auth/table-delegation.test.ts | 44 + apps/sim/lib/copilot/auth/table-delegation.ts | 44 + .../lib/copilot/request/tools/tables.test.ts | 134 +- apps/sim/lib/copilot/request/tools/tables.ts | 125 +- apps/sim/lib/copilot/tools/server/router.ts | 8 + .../server/table/query-user-table.test.ts | 49 + .../tools/server/table/user-table.test.ts | 147 +- .../copilot/tools/server/table/user-table.ts | 861 +++++---- apps/sim/lib/folders/orchestration.test.ts | 27 + .../service-filter-threading.test.ts | 50 +- .../lib/table/__tests__/update-row.test.ts | 15 + apps/sim/lib/table/api/index.ts | 1 + apps/sim/lib/table/api/route-policies.ts | 54 + apps/sim/lib/table/api/row-route-policies.ts | 13 + .../table/application/authorization.test.ts | 160 ++ .../lib/table/application/authorization.ts | 42 + .../application/authorized-table-use-case.ts | 28 + apps/sim/lib/table/application/columns.ts | 160 ++ .../sim/lib/table/application/context.test.ts | 71 + apps/sim/lib/table/application/context.ts | 46 + .../table/application/delegated-principal.ts | 36 + apps/sim/lib/table/application/errors.ts | 31 + apps/sim/lib/table/application/exports.ts | 137 ++ .../sim/lib/table/application/folder-paths.ts | 33 + apps/sim/lib/table/application/folders.ts | 179 ++ apps/sim/lib/table/application/groups.ts | 279 +++ apps/sim/lib/table/application/imports.ts | 294 +++ .../lib/table/application/operations.test.ts | 55 + apps/sim/lib/table/application/operations.ts | 74 + apps/sim/lib/table/application/rows.test.ts | 313 ++++ apps/sim/lib/table/application/rows.ts | 621 +++++++ apps/sim/lib/table/application/runs.test.ts | 272 +++ apps/sim/lib/table/application/runs.ts | 210 +++ apps/sim/lib/table/application/tables.ts | 310 ++++ apps/sim/lib/table/application/views.ts | 199 ++ .../lib/table/column-types/registry.server.ts | 25 +- .../lib/table/column-types/types.server.ts | 1 + apps/sim/lib/table/columns/memory.test.ts | 31 + apps/sim/lib/table/columns/service.ts | 1607 +++++++++-------- apps/sim/lib/table/dispatcher.ts | 69 +- apps/sim/lib/table/export-runner.test.ts | 16 +- apps/sim/lib/table/export-runner.ts | 77 +- apps/sim/lib/table/import-data.ts | 36 +- apps/sim/lib/table/import-runner.test.ts | 7 +- apps/sim/lib/table/import-runner.ts | 57 +- apps/sim/lib/table/jobs/service.ts | 124 ++ apps/sim/lib/table/orchestration/columns.ts | 48 +- .../table/orchestration/export-resource.ts | 78 +- .../table/orchestration/import-resource.ts | 264 ++- apps/sim/lib/table/rows/executions.ts | 23 +- .../lib/table/rows/secret-provenance.test.ts | 18 +- apps/sim/lib/table/rows/secret-provenance.ts | 7 +- apps/sim/lib/table/rows/service.ts | 254 ++- apps/sim/lib/table/service.ts | 38 +- apps/sim/lib/table/types.ts | 10 + apps/sim/lib/table/views/service.test.ts | 18 + apps/sim/lib/table/views/service.ts | 77 +- apps/sim/lib/table/workflow-columns.ts | 94 +- apps/sim/lib/table/workflow-groups/service.ts | 1171 ++++++------ .../uploads/upload-session/service.test.ts | 49 + .../sim/lib/uploads/upload-session/service.ts | 10 +- packages/auth/src/principal.ts | 1 + 106 files changed, 10214 insertions(+), 7012 deletions(-) create mode 100644 apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.test.ts create mode 100644 apps/sim/app/api/v2/tables/folders/route.test.ts create mode 100644 apps/sim/lib/copilot/application/execute-table-use-case.ts create mode 100644 apps/sim/lib/copilot/auth/table-delegation.test.ts create mode 100644 apps/sim/lib/copilot/auth/table-delegation.ts create mode 100644 apps/sim/lib/copilot/tools/server/table/query-user-table.test.ts create mode 100644 apps/sim/lib/table/api/index.ts create mode 100644 apps/sim/lib/table/api/route-policies.ts create mode 100644 apps/sim/lib/table/api/row-route-policies.ts create mode 100644 apps/sim/lib/table/application/authorization.test.ts create mode 100644 apps/sim/lib/table/application/authorization.ts create mode 100644 apps/sim/lib/table/application/authorized-table-use-case.ts create mode 100644 apps/sim/lib/table/application/columns.ts create mode 100644 apps/sim/lib/table/application/context.test.ts create mode 100644 apps/sim/lib/table/application/context.ts create mode 100644 apps/sim/lib/table/application/delegated-principal.ts create mode 100644 apps/sim/lib/table/application/errors.ts create mode 100644 apps/sim/lib/table/application/exports.ts create mode 100644 apps/sim/lib/table/application/folder-paths.ts create mode 100644 apps/sim/lib/table/application/folders.ts create mode 100644 apps/sim/lib/table/application/groups.ts create mode 100644 apps/sim/lib/table/application/imports.ts create mode 100644 apps/sim/lib/table/application/operations.test.ts create mode 100644 apps/sim/lib/table/application/operations.ts create mode 100644 apps/sim/lib/table/application/rows.test.ts create mode 100644 apps/sim/lib/table/application/rows.ts create mode 100644 apps/sim/lib/table/application/runs.test.ts create mode 100644 apps/sim/lib/table/application/runs.ts create mode 100644 apps/sim/lib/table/application/tables.ts create mode 100644 apps/sim/lib/table/application/views.ts create mode 100644 apps/sim/lib/table/columns/memory.test.ts diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 805d2b16206..442594b4233 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -320,7 +320,9 @@ export const CreateColumnSchema = createTableColumnBodySchema export const UpdateColumnSchema = updateTableColumnBodySchema export const DeleteColumnSchema = deleteTableColumnBodySchema -export function normalizeColumn(col: ColumnDefinition): ColumnDefinition { +export function normalizeColumn( + col: ColumnDefinition +): ColumnDefinition & { required: boolean; unique: boolean } { return { // Preserve the stable column id — it's the row-data storage key, so dropping // it makes clients fall back to `name` and miss id-keyed cell values. diff --git a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts index fed9a372b17..17d013606d6 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts @@ -1,192 +1,149 @@ /** * @vitest-environment node - * - * Public v2 cancel-runs — stops workflow/enrichment cell runs, as opposed to - * `job/cancel`, which stops an import or delete. The predicate translates to - * storage keys before the cancel so an unknown field 400s rather than becoming - * a cancel that silently matches nothing. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockCancelRuns, - mockPredicateToFilter, - mockSignalRowsChanged, - mockGateError, - TableQueryValidationError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockCancelRuns: vi.fn(), - mockPredicateToFilter: vi.fn(), - mockSignalRowsChanged: vi.fn(), - mockGateError: vi.fn(), - TableQueryValidationError: class TableQueryValidationError extends Error {}, -})) +const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { + class MockTableRowsValidationError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + cancelRuns: vi.fn(), + }, + MockTableRowsValidationError, + } +}) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/table/utils', async (importOriginal) => ({ - ...(await importOriginal<Record<string, unknown>>()), - checkAccess: mockCheckAccess, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({ - ...(await importOriginal<Record<string, unknown>>()), - v2BulkPredicateToFilter: mockPredicateToFilter, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/rows', () => ({ + TableRowsValidationError: MockTableRowsValidationError, +})) +vi.mock('@/lib/table/application/runs', () => ({ + cancelTableRuns: { operation: { id: 'tables.runs.cancel' }, execute: mocks.cancelRuns }, })) - -vi.mock('@/lib/table/workflow-columns', () => ({ cancelWorkflowGroupRuns: mockCancelRuns })) -vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged })) -vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError })) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) import { POST } from '@/app/api/v2/tables/[tableId]/cancel-runs/route' -const TABLE = { - id: 'table-1', - workspaceId: 'ws-1', - schema: { columns: [{ id: 'col-1', name: 'status', type: 'string' }] }, +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', } - -const RATE_LIMIT_OK = { +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, remaining: 99, resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 0, } -function callPost(body: unknown) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/cancel-runs', { +function call(body: unknown) { + const request = new NextRequest('http://localhost/api/v2/tables/table-1/cancel-runs', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, body: JSON.stringify(body), }) - return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) + return { + request, + response: POST(request, { params: Promise.resolve({ tableId: 'table-1' }) }), + } } -beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockCancelRuns.mockResolvedValue(4) - mockGateError.mockResolvedValue(null) -}) - describe('POST /api/v2/tables/[tableId]/cancel-runs', () => { - it('cancels every run under scope "all" and reports the count', async () => { - const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ cancelled: 4 }) - expect(mockCancelRuns).toHaveBeenCalledWith('table-1', undefined, { - filter: undefined, - excludeRowIds: undefined, - }) - // Cancelling clears the affected cells, so open readers must refetch. - expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1') - }) - - it('scopes to a single row when asked', async () => { - const res = await callPost({ workspaceId: 'ws-1', scope: 'row', rowId: 'row-1' }) - - expect(res.status).toBe(200) - expect(mockCancelRuns).toHaveBeenCalledWith('table-1', 'row-1', expect.anything()) - }) - - it('translates a name-keyed predicate to the storage-keyed filter', async () => { - mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } }) - const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } - - await callPost({ workspaceId: 'ws-1', scope: 'all', filter: predicate }) - - expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema) - expect(mockCancelRuns).toHaveBeenCalledWith( - 'table-1', - undefined, - expect.objectContaining({ filter: { 'col-1': { $eq: 'active' } } }) - ) + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE) + mocks.operationRate.mockResolvedValue(RATE) + mocks.gate.mockResolvedValue(null) + mocks.cancelRuns.mockResolvedValue({ table: { id: 'table-1' }, cancelled: 4 }) }) - it('400s an unresolvable predicate field instead of cancelling nothing', async () => { - mockPredicateToFilter.mockImplementation(() => { - throw new TableQueryValidationError('Unknown column "nope"') - }) - - const res = await callPost({ - workspaceId: 'ws-1', + it('delegates a filtered all-scope cancellation and reports the authoritative count', async () => { + const predicate = { all: [{ field: 'status', op: 'eq', value: 'ready' }] } + const invocation = call({ + workspaceId: WORKSPACE_ID, scope: 'all', - filter: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, + filter: predicate, + excludeRowIds: ['row-2'], }) - - expect(res.status).toBe(400) - expect(mockCancelRuns).not.toHaveBeenCalled() - }) - - it('400s scope "row" with no rowId', async () => { - const res = await callPost({ workspaceId: 'ws-1', scope: 'row' }) - - expect(res.status).toBe(400) - expect(mockCancelRuns).not.toHaveBeenCalled() - }) - - it('400s scope "row" combined with a filter', async () => { - const res = await callPost({ - workspaceId: 'ws-1', - scope: 'row', - rowId: 'row-1', - filter: { all: [{ field: 'status', op: 'eq', value: 'active' }] }, + const response = await invocation.response + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { cancelled: 4 } }) + expect(mocks.cancelRuns).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + scope: 'all', + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + predicate, + excludeRowIds: ['row-2'], + }, + request: invocation.request, }) - - expect(res.status).toBe(400) - expect(mockCancelRuns).not.toHaveBeenCalled() }) - it('403s a read-only member', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) - - expect(res.status).toBe(403) - expect(mockCancelRuns).not.toHaveBeenCalled() + it('delegates one canonical row scope without select-all fields', async () => { + const invocation = call({ workspaceId: WORKSPACE_ID, scope: 'row', rowId: 'row-1' }) + const response = await invocation.response + + expect(response.status).toBe(200) + expect(mocks.cancelRuns).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + scope: 'row', + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + rowId: 'row-1', + }, + request: invocation.request, + }) }) - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) + it('preserves an authoritative zero-cancellation result', async () => { + mocks.cancelRuns.mockResolvedValue({ table: { id: 'table-1' }, cancelled: 0 }) - const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) + const response = await call({ workspaceId: WORKSPACE_ID, scope: 'all' }).response - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { cancelled: 0 } }) }) - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) + it('rejects an incomplete or contradictory row scope before delegation', async () => { + const missing = await call({ workspaceId: WORKSPACE_ID, scope: 'row' }).response + const contradictory = await call({ + workspaceId: WORKSPACE_ID, + scope: 'row', + rowId: 'row-1', + filter: { all: [{ field: 'status', op: 'eq', value: 'ready' }] }, + }).response - expect(res.status).toBe(429) - expect(mockCancelRuns).not.toHaveBeenCalled() + expect(missing.status).toBe(400) + expect(contradictory.status).toBe(400) + expect(mocks.cancelRuns).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts index 1ef1f1f6e09..696b81c975e 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts @@ -1,81 +1,39 @@ import { createLogger } from '@sim/logger' import { v2CancelTableRunsContract } from '@/lib/api/contracts/v2/tables' -import { isZodError } from '@/lib/api/server' -import type { Filter, TableSchema } from '@/lib/table' -import { TableQueryValidationError } from '@/lib/table/errors' -import { signalTableRowsChanged } from '@/lib/table/events' -import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2Data, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { v2BulkPredicateToFilter, v2TableAccessError } from '@/app/api/v2/tables/utils' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' +import { cancelTableRuns } from '@/lib/table/application/runs' const logger = createLogger('V2TableCancelRunsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * POST /api/v2/tables/[tableId]/cancel-runs — Stop in-flight cell runs. - * - * The counterpart to `POST /columns/run`, and distinct from - * `POST /job/cancel`, which stops an import or delete. `scope: 'all'` cancels - * every running and pending cell (optionally narrowed by `filter`); `row` - * cancels one row's cells. - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CancelTableRunsContract, - rateLimitEndpoint: 'table-enrichment', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const { workspaceId, scope, rowId, filter, excludeRowIds } = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const access = await checkAccess(tableId, userId, 'write') - if (!access.ok) return v2TableAccessError(access) - - if (access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - // The public predicate is column-NAME keyed; the runners compile the - // storage-keyed legacy filter. Translating up front makes an unknown field - // a 400 rather than a cancel that silently matches nothing. - let legacyFilter: Filter | undefined - if (filter) { - legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) - } - - const cancelled = await cancelWorkflowGroupRuns( - tableId, - scope === 'row' ? rowId : undefined, - { - filter: legacyFilter, - excludeRowIds, + operation: tableOperations.cancelRuns, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => + body.scope === 'row' + ? { + scope: 'row' as const, + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + rowId: body.rowId!, } - ) - - // Cancelling clears the affected rows' exec state, so open readers must - // refetch to pick up the cleared cells. - signalTableRowsChanged(tableId) - - logger.info(`[${requestId}] Cancelled table runs`, { tableId, scope, rowId, cancelled }) - - return v2Data({ cancelled }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - throw error - } + : { + scope: 'all' as const, + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + predicate: body.filter, + excludeRowIds: body.excludeRowIds, + }, + useCase: cancelTableRuns, + present: ({ table, cancelled }) => { + logger.info('Cancelled table runs', { tableId: table.id, cancelled }) + return { data: { cancelled } } }, }) 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 a7d6235dca0..4ca1f73bf67 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 @@ -1,105 +1,136 @@ /** * @vitest-environment node - * - * v2 column update wiring: the route authenticates, scopes, delegates to the - * orchestration function, and maps its failure classes onto the v2 envelope. - * The guards themselves are covered in lib/table/orchestration/columns.test.ts. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceScope, mockCheckAccess, mockPerformUpdate } = - vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockPerformUpdate: vi.fn(), - })) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + add: vi.fn(), + update: vi.fn(), + remove: vi.fn(), })) -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record<string, unknown>) => col, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/lib/table', () => ({ addTableColumn: vi.fn(), deleteColumn: vi.fn() })) - -vi.mock('@/lib/table/orchestration', () => ({ - performUpdateTableColumn: mockPerformUpdate, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/columns', () => ({ + addTableColumnUseCase: { operation: { id: 'tables.columns.add' }, execute: mocks.add }, + updateTableColumnUseCase: { operation: { id: 'tables.columns.update' }, execute: mocks.update }, + deleteTableColumnUseCase: { operation: { id: 'tables.columns.delete' }, execute: mocks.remove }, })) -import { PATCH } from '@/app/api/v2/tables/[tableId]/columns/route' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { DELETE, PATCH, POST } from '@/app/api/v2/tables/[tableId]/columns/route' -const COLUMN = { id: 'col-1', name: 'Status', type: 'text' } -const TABLE = { id: 'table-1', name: 'Tasks', workspaceId: 'ws-1', schema: { columns: [COLUMN] } } +const WORKSPACE_ID = 'workspace-1' +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const rate = { + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, +} +const table = { + id: 'table-1', + name: 'Contacts', + schema: { + columns: [ + { id: 'col-1', name: 'Name', type: 'string' as const, required: false, unique: false }, + ], + }, +} +const context = { params: Promise.resolve({ tableId: 'table-1' }) } -function patch(updates: Record<string, unknown> = { name: 'State' }) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/columns', { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ workspaceId: 'ws-1', columnName: 'Status', updates }), +function request(method: 'POST' | 'PATCH' | 'DELETE', body: unknown) { + return new NextRequest('http://localhost:3000/api/v2/tables/table-1/columns', { + method, + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), }) - return PATCH(req, { params: Promise.resolve({ tableId: 'table-1' }) }) } -describe('PATCH /api/v2/tables/[tableId]/columns', () => { +describe('/api/v2/tables/[tableId]/columns', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue({ - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), - }) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockPerformUpdate.mockResolvedValue({ success: true, table: TABLE }) + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) + mocks.add.mockResolvedValue({ table }) + mocks.update.mockResolvedValue({ table, changed: false }) + mocks.remove.mockResolvedValue({ table }) }) - it('delegates to the orchestration function with the resolved table and actor', async () => { - const res = await patch() - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ columns: [COLUMN] }) - expect(mockPerformUpdate).toHaveBeenCalledWith( - expect.objectContaining({ table: TABLE, columnName: 'Status', userId: 'user-1' }) - ) + it('delegates column creation with canonical path and body inputs', async () => { + const req = request('POST', { + workspaceId: WORKSPACE_ID, + column: { name: 'Name', type: 'string' }, + }) + const response = await POST(req, context) + + expect(response.status).toBe(200) + expect((await response.json()).data.columns).toEqual([ + { id: 'col-1', name: 'Name', type: 'string', required: false, unique: false }, + ]) + expect(mocks.add).toHaveBeenCalledWith({ + principal, + input: { + tableId: 'table-1', + workspaceId: WORKSPACE_ID, + column: { name: 'Name', type: 'string' }, + }, + request: req, + }) }) - it.each([ - ['validation', 400, 'BAD_REQUEST'], - ['not_found', 404, 'NOT_FOUND'], - ['locked', 423, 'LOCKED'], - ])('maps a %s failure to %i', async (errorCode, status, code) => { - mockPerformUpdate.mockResolvedValue({ success: false, errorCode, error: 'nope' }) + it('maps typed application validation failures without inspecting messages', async () => { + mocks.update.mockRejectedValueOnce(new OrchestrationError('validation', 'Invalid column')) - const res = await patch() + const response = await PATCH( + request('PATCH', { + workspaceId: WORKSPACE_ID, + columnName: 'Name', + updates: { name: 'Renamed' }, + }), + context + ) - expect(res.status).toBe(status) - expect((await res.json()).error.code).toBe(code) + expect(response.status).toBe(400) + expect((await response.json()).error.message).toBe('Invalid column') }) - it('does not leak an internal failure message', async () => { - mockPerformUpdate.mockResolvedValue({ - success: false, - errorCode: 'internal', - error: 'connection string leaked', - }) - - const res = await patch() + it('delegates deletion and returns the authoritative surviving schema', async () => { + const response = await DELETE( + request('DELETE', { workspaceId: WORKSPACE_ID, columnName: 'Other' }), + context + ) - expect(res.status).toBe(500) - expect(await res.text()).not.toContain('connection string') + expect(response.status).toBe(200) + expect(mocks.remove).toHaveBeenCalledOnce() }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts index 1c6d1092e17..38127237a15 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts @@ -1,158 +1,55 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { v2AddTableColumnContract, v2DeleteTableColumnContract, v2UpdateTableColumnContract, } from '@/lib/api/contracts/v2/tables' -import { isZodError } from '@/lib/api/server' -import { addTableColumn, deleteColumn } from '@/lib/table' -import { performUpdateTableColumn } from '@/lib/table/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess, normalizeColumn } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import type { TableDefinition } from '@/lib/table' +import { v2TableErrorPolicies } from '@/lib/table/api' import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { v2TableAccessError, v2TableOrchestrationError } from '@/app/api/v2/tables/utils' + addTableColumnUseCase, + deleteTableColumnUseCase, + updateTableColumnUseCase, +} from '@/lib/table/application/columns' +import { tableOperations } from '@/lib/table/application/operations' +import { normalizeColumn } from '@/app/api/table/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** POST /api/v2/tables/[tableId]/columns — Add a column to the table schema. */ -export const POST = withPublicApiRouteHandler({ - contract: v2AddTableColumnContract, - rateLimitEndpoint: 'table-columns', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const updatedTable = await addTableColumn(tableId, validated.column, requestId) - - recordAudit({ - workspaceId: validated.workspaceId, - actorId: userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Added column "${validated.column.name}" to table "${table.name}"`, - metadata: { column: validated.column }, - request, - }) +function presentColumns(result: { table: TableDefinition }) { + return { data: { columns: result.table.schema.columns.map(normalizeColumn) } } +} - return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - throw error - } - }, +export const POST = defineV2JsonRoute({ + contract: v2AddTableColumnContract, + operation: tableOperations.addColumn, + useCase: addTableColumnUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: presentColumns, }) -/** PATCH /api/v2/tables/[tableId]/columns — Update a column (rename, type change, constraints). */ -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2UpdateTableColumnContract, - rateLimitEndpoint: 'table-columns', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const outcome = await performUpdateTableColumn({ - table, - columnName: validated.columnName, - userId, - updates: validated.updates, - requestId, - request, - }) - if (!outcome.success || !outcome.table) { - return v2TableOrchestrationError(outcome, 'Failed to update column') - } - - return v2Data({ columns: outcome.table.schema.columns.map(normalizeColumn) }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - throw error - } - }, + operation: tableOperations.updateColumn, + useCase: updateTableColumnUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: presentColumns, }) -/** DELETE /api/v2/tables/[tableId]/columns — Delete a column from the table schema. */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteTableColumnContract, - rateLimitEndpoint: 'table-columns', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const updatedTable = await deleteColumn( - { tableId, columnName: validated.columnName }, - requestId - ) - - recordAudit({ - workspaceId: validated.workspaceId, - actorId: userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Deleted column "${validated.columnName}" from table "${table.name}"`, - metadata: { columnName: validated.columnName }, - request, - }) - - return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - throw error - } - }, + operation: tableOperations.deleteColumn, + useCase: deleteTableColumnUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: presentColumns, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts index e3dc1b23c0b..83b77f98923 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts @@ -1,195 +1,146 @@ /** * @vitest-environment node - * - * Public v2 column run. The public predicate is column-NAME keyed and the - * dispatcher compiles a storage-keyed legacy filter, so the route translates - * before dispatching — an unknown field must 400 here rather than becoming a - * run that silently matches nothing. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockRunWorkflowColumn, - mockPredicateToFilter, - mockSignalRowsChanged, - mockGateError, - TableQueryValidationError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockRunWorkflowColumn: vi.fn(), - mockPredicateToFilter: vi.fn(), - mockSignalRowsChanged: vi.fn(), - mockGateError: vi.fn(), - TableQueryValidationError: class TableQueryValidationError extends Error {}, -})) +const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { + class MockTableRowsValidationError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + startRun: vi.fn(), + }, + MockTableRowsValidationError, + } +}) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record<string, unknown>) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({ - ...(await importOriginal<Record<string, unknown>>()), - v2BulkPredicateToFilter: mockPredicateToFilter, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/rows', () => ({ + TableRowsValidationError: MockTableRowsValidationError, +})) +vi.mock('@/lib/table/application/runs', () => ({ + startTableRun: { operation: { id: 'tables.runs.start' }, execute: mocks.startRun }, })) - -vi.mock('@/lib/table/workflow-columns', () => ({ runWorkflowColumn: mockRunWorkflowColumn })) -vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged })) -vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError })) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) import { POST } from '@/app/api/v2/tables/[tableId]/columns/run/route' -const TABLE = { - id: 'table-1', - workspaceId: 'ws-1', - schema: { columns: [{ id: 'col-1', name: 'status', type: 'string' }] }, +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', } - -const RATE_LIMIT_OK = { +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, remaining: 99, resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 0, } -function callPost(body: unknown) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/columns/run', { +function call(body: unknown) { + const request = new NextRequest('http://localhost/api/v2/tables/table-1/columns/run', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, body: JSON.stringify(body), }) - return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) + return { + request, + response: POST(request, { params: Promise.resolve({ tableId: 'table-1' }) }), + } } describe('POST /api/v2/tables/[tableId]/columns/run', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockRunWorkflowColumn.mockResolvedValue({ dispatchId: 'dispatch-1' }) - mockGateError.mockResolvedValue(null) + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE) + mocks.operationRate.mockResolvedValue(RATE) + mocks.gate.mockResolvedValue(null) + mocks.startRun.mockResolvedValue({ table: { id: 'table-1' }, dispatchId: 'dispatch-1' }) }) - it('dispatches the run and returns the dispatch id', async () => { - const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'], rowIds: ['row-1'] }) - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ dispatchId: 'dispatch-1' }) - expect(mockRunWorkflowColumn).toHaveBeenCalledWith( - expect.objectContaining({ + it('delegates the bounded run selection and presents the dispatch id', async () => { + const predicate = { all: [{ field: 'status', op: 'eq', value: 'ready' }] } + const invocation = call({ + workspaceId: WORKSPACE_ID, + groupIds: ['group-1'], + runMode: 'incomplete', + filter: predicate, + excludeRowIds: ['row-2'], + limit: { type: 'rows', max: 25 }, + }) + const response = await invocation.response + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { dispatchId: 'dispatch-1' } }) + expect(mocks.startRun).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + kind: 'selection', tableId: 'table-1', - workspaceId: 'ws-1', + assertedWorkspaceId: WORKSPACE_ID, groupIds: ['group-1'], - rowIds: ['row-1'], - mode: 'all', - filter: undefined, - triggeredByUserId: 'user-1', - }) - ) - // The bulk clear is a row change even when the dispatch is a no-op. - expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1') + mode: 'incomplete', + rowIds: undefined, + predicate, + excludeRowIds: ['row-2'], + limit: { type: 'rows', max: 25 }, + }, + request: invocation.request, + }) }) - it('translates a name-keyed predicate to the storage-keyed filter the dispatcher walks', async () => { - mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } }) - const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } + it('preserves an authoritative null dispatch as a no-op', async () => { + mocks.startRun.mockResolvedValue({ table: { id: 'table-1' }, dispatchId: null }) - const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'], filter: predicate }) - - expect(res.status).toBe(200) - expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema) - expect(mockRunWorkflowColumn).toHaveBeenCalledWith( - expect.objectContaining({ filter: { 'col-1': { $eq: 'active' } } }) - ) - }) - - it('400s an unresolvable predicate field instead of dispatching a no-match run', async () => { - mockPredicateToFilter.mockImplementation(() => { - throw new TableQueryValidationError('Unknown column "nope"') - }) - - const res = await callPost({ - workspaceId: 'ws-1', + const response = await call({ + workspaceId: WORKSPACE_ID, groupIds: ['group-1'], - filter: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, - }) + }).response - expect(res.status).toBe(400) - expect((await res.json()).error.message).toBe('Unknown column "nope"') - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { dispatchId: null } }) }) - it('400s rowIds and filter together', async () => { - const res = await callPost({ - workspaceId: 'ws-1', + it('rejects mutually exclusive row and filter scopes before delegation', async () => { + const response = await call({ + workspaceId: WORKSPACE_ID, groupIds: ['group-1'], rowIds: ['row-1'], - filter: { all: [{ field: 'status', op: 'eq', value: 'active' }] }, - }) - - expect(res.status).toBe(400) - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() - }) - - it('400s an empty groupIds list', async () => { - const res = await callPost({ workspaceId: 'ws-1', groupIds: [] }) - - expect(res.status).toBe(400) - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() - }) + filter: { all: [{ field: 'status', op: 'eq', value: 'ready' }] }, + }).response - it('403s a read-only member', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] }) - - expect(res.status).toBe(403) - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] }) - - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + expect(response.status).toBe(400) + expect(mocks.startRun).not.toHaveBeenCalled() }) - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] }) + it('rejects an empty group selection before delegation', async () => { + const response = await call({ workspaceId: WORKSPACE_ID, groupIds: [] }).response - expect(res.status).toBe(429) - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + expect(response.status).toBe(400) + expect(mocks.startRun).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts index 7f2345f73be..48644f53b56 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts @@ -1,91 +1,29 @@ import { v2RunTableColumnContract } from '@/lib/api/contracts/v2/tables' -import { isZodError } from '@/lib/api/server' -import type { Filter, TableSchema } from '@/lib/table' -import { TableQueryValidationError } from '@/lib/table/errors' -import { signalTableRowsChanged } from '@/lib/table/events' -import { runWorkflowColumn } from '@/lib/table/workflow-columns' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { - v2BulkPredicateToFilter, - v2TableAccessError, - v2TableLockError, -} from '@/app/api/v2/tables/utils' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' +import { startTableRun } from '@/lib/table/application/runs' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * POST /api/v2/tables/[tableId]/columns/run — Run workflow/enrichment groups. - * - * Asynchronous: the response acknowledges the dispatch, not the results. The - * dispatcher walks the scoped rows and writes cells as runs land, so callers - * poll the row endpoints. `dispatchId` is `null` where no background runner is - * configured and cells execute inline. - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2RunTableColumnContract, - rateLimitEndpoint: 'table-enrichment', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const { workspaceId, groupIds, runMode, rowIds, filter, excludeRowIds, limit } = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const access = await checkAccess(tableId, userId, 'write') - if (!access.ok) return v2TableAccessError(access) - - if (access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - // The public predicate is column-NAME keyed; the dispatcher compiles the - // storage-keyed legacy filter. Translating up front also makes an unknown - // field a 400 here rather than a dispatch that silently matches nothing. - let legacyFilter: Filter | undefined - if (filter) { - legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) - } - - const { dispatchId } = await runWorkflowColumn({ - tableId, - workspaceId, - groupIds, - mode: runMode, - rowIds, - filter: legacyFilter, - excludeRowIds, - limit, - requestId, - triggeredByUserId: userId, - }) - - // Starting a run clears the target groups' cells to pending — a row change - // open readers must pick up. - signalTableRowsChanged(tableId) - - return v2Data({ dispatchId }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - const lockError = v2TableLockError(error) - if (lockError) return lockError - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - throw error - } - }, + operation: tableOperations.startRun, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => ({ + kind: 'selection' as const, + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + groupIds: body.groupIds, + mode: body.runMode, + rowIds: body.rowIds, + predicate: body.filter, + excludeRowIds: body.excludeRowIds, + limit: body.limit, + }), + useCase: startTableRun, + present: ({ dispatchId }) => ({ data: { dispatchId } }), }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts new file mode 100644 index 00000000000..d20a9074734 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts @@ -0,0 +1,140 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + create: vi.fn(), + read: vi.fn(), + download: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/exports', () => ({ + createTableExportUseCase: { operation: { id: 'tables.exports.create' }, execute: mocks.create }, + readTableExportUseCase: { operation: { id: 'tables.exports.read' }, execute: mocks.read }, + cancelTableExportUseCase: { operation: { id: 'tables.exports.cancel' }, execute: vi.fn() }, + downloadTableExportUseCase: { + operation: { id: 'tables.exports.download' }, + execute: mocks.download, + }, +})) + +import { POST } from '@/app/api/v2/tables/[tableId]/exports/route' +import { GET as DOWNLOAD } from '@/app/api/v2/tables/exports/[exportId]/download/route' +import { GET as STATUS } from '@/app/api/v2/tables/exports/[exportId]/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const rate = { + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, +} +const tableExport = { + id: 'export-1', + tableId: 'table-1', + workspaceId: WORKSPACE_ID, + format: 'csv' as const, + status: 'completed' as const, + rowsProcessed: 2, + error: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:01:00.000Z', + completedAt: '2026-01-01T00:01:00.000Z', +} + +describe('v2 table exports', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) + }) + + it('creates an export through the authorized use case', async () => { + mocks.create.mockResolvedValue({ export: tableExport }) + const request = new NextRequest('http://localhost:3000/api/v2/tables/table-1/exports', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, format: 'csv' }), + }) + + const response = await POST(request, { params: Promise.resolve({ tableId: 'table-1' }) }) + + expect(response.status).toBe(201) + expect(await response.json()).toEqual({ data: tableExport }) + expect(mocks.create).toHaveBeenCalledWith({ + principal, + input: { tableId: 'table-1', workspaceId: WORKSPACE_ID, format: 'csv' }, + request, + }) + }) + + it('reads status and download metadata through separate semantic operations', async () => { + mocks.read.mockResolvedValue({ export: tableExport }) + mocks.download.mockResolvedValue({ + url: 'https://storage.example/export.csv', + fileName: 'Contacts.csv', + expiresAt: '2026-01-01T01:00:00.000Z', + }) + const statusRequest = new NextRequest( + `http://localhost:3000/api/v2/tables/exports/export-1?workspaceId=${WORKSPACE_ID}` + ) + const downloadRequest = new NextRequest( + `http://localhost:3000/api/v2/tables/exports/export-1/download?workspaceId=${WORKSPACE_ID}` + ) + + const status = await STATUS(statusRequest, { + params: Promise.resolve({ exportId: 'export-1' }), + }) + const download = await DOWNLOAD(downloadRequest, { + params: Promise.resolve({ exportId: 'export-1' }), + }) + + expect(status.status).toBe(200) + expect(await status.json()).toEqual({ data: tableExport }) + expect(download.status).toBe(200) + expect((await download.json()).data.fileName).toBe('Contacts.csv') + expect(mocks.read).toHaveBeenCalledWith({ + principal, + input: { exportId: 'export-1', workspaceId: WORKSPACE_ID }, + request: statusRequest, + }) + expect(mocks.download).toHaveBeenCalledWith({ + principal, + input: { exportId: 'export-1', workspaceId: WORKSPACE_ID }, + request: downloadRequest, + }) + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts index 1582877d704..9b8115899a5 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts @@ -1,48 +1,23 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { v2CreateTableExportContract } from '@/lib/api/contracts/v2/tables' -import { - createTableExportResource, - toV2TableExport, -} from '@/lib/table/orchestration/export-resource' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { createTableExportUseCase } from '@/lib/table/application/exports' +import { tableOperations } from '@/lib/table/application/operations' -export const POST = withPublicApiRouteHandler({ +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const POST = defineV2JsonRoute({ contract: v2CreateTableExportContract, - rateLimitEndpoint: 'table-export', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - try { - const { workspaceId, format } = input.body - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - const access = await checkAccess(input.params.tableId, userId, 'read') - if (!access.ok || access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - const record = await createTableExportResource({ table: access.table, format }) - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.TABLE_EXPORTED, - resourceType: AuditResourceType.TABLE, - resourceId: access.table.id, - resourceName: access.table.name, - description: `Exported table "${access.table.name}" as ${format.toUpperCase()}`, - metadata: { format, rowCount: access.table.rowCount }, - request, - }) - return v2Data(toV2TableExport(record, true), { rateLimit, status: 201 }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.createExport, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, body }) => ({ + tableId: params.tableId, + workspaceId: body.workspaceId, + format: body.format, + }), + useCase: createTableExportUseCase, + present: ({ export: tableExport }) => ({ data: tableExport }), }) 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 0a847cabcac..85cd6d77e35 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 @@ -1,437 +1,167 @@ /** * @vitest-environment node - * - * Public v2 workflow-group listing — a read-only projection of the table's - * schema, exposed so a caller can discover the group ids the run endpoints - * take. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockGateError, - mockAddWorkflowGroup, - mockUpdateWorkflowGroup, - mockDeleteWorkflowGroup, - mockGetActiveWorkflowContext, - mockSignalSchemaChanged, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockGateError: vi.fn(), - mockAddWorkflowGroup: vi.fn(), - mockUpdateWorkflowGroup: vi.fn(), - mockDeleteWorkflowGroup: vi.fn(), - mockGetActiveWorkflowContext: vi.fn(), - mockSignalSchemaChanged: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record<string, unknown>) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/lib/table/workflow-groups/service', () => ({ - addWorkflowGroup: mockAddWorkflowGroup, - updateWorkflowGroup: mockUpdateWorkflowGroup, - deleteWorkflowGroup: mockDeleteWorkflowGroup, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/groups', () => ({ + listTableGroupsUseCase: { operation: { id: 'tables.groups.list' }, execute: mocks.list }, + createTableGroupUseCase: { operation: { id: 'tables.groups.create' }, execute: mocks.create }, + updateTableGroupUseCase: { operation: { id: 'tables.groups.update' }, execute: mocks.update }, + deleteTableGroupUseCase: { operation: { id: 'tables.groups.delete' }, execute: mocks.remove }, })) -vi.mock('@sim/platform-authz/workflow', () => ({ - getActiveWorkflowContext: mockGetActiveWorkflowContext, -})) - -vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mockSignalSchemaChanged })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - +import { OrchestrationError } from '@/lib/core/orchestration/types' import { DELETE, GET, PATCH, POST } from '@/app/api/v2/tables/[tableId]/groups/route' -const GROUP = { - id: 'group-1', - workflowId: 'wf-1', - name: 'Enrich', - outputs: [{ blockId: 'blk-1', path: 'content', columnName: 'summary' }], +const WORKSPACE_ID = 'workspace-1' +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', } -const TABLE = { - id: 'table-1', - workspaceId: 'ws-1', - schema: { columns: [], workflowGroups: [GROUP] }, +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -const RATE_LIMIT_OK = { +const rate = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -function callGet() { - const req = new NextRequest( - 'http://localhost:3000/api/v2/tables/table-1/groups?workspaceId=ws-1', - { method: 'GET' } - ) - return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, } - -describe('GET /api/v2/tables/[tableId]/groups', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockGateError.mockResolvedValue(null) - }) - - it('returns the schema groups as one full page', async () => { - const res = await callGet() - - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ data: [GROUP], nextCursor: null }) - }) - - it('returns an empty page for a table with no groups', async () => { - mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, schema: { columns: [] } } }) - - const res = await callGet() - - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ data: [], nextCursor: null }) - }) - - it('masks a permission failure as 404 so table existence never leaks', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callGet() - - expect(res.status).toBe(404) - }) - - it('400s a request with no workspaceId', async () => { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/groups', { - method: 'GET', - }) - const res = await GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) - - expect(res.status).toBe(400) - expect(mockCheckAccess).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callGet() - - expect(res.status).toBe(429) - expect(mockCheckAccess).not.toHaveBeenCalled() - }) -}) - -const ADD_BODY = { - workspaceId: 'ws-1', - group: { - workflowId: 'wf-1', - outputs: [{ blockId: 'blk-1', path: 'content', columnName: 'summary' }], - }, - outputColumns: [{ name: 'summary', type: 'string' }], +const group = { + id: 'group-1', + workflowId: 'workflow-1', + type: 'manual' as const, + outputs: [{ blockId: 'block-1', path: 'result', columnName: 'col-1' }], + autoRun: false, } - -const UPDATED_TABLE = { +const table = { id: 'table-1', - workspaceId: 'ws-1', - schema: { columns: [{ name: 'summary', type: 'string' }], workflowGroups: [GROUP] }, + name: 'Contacts', + schema: { + columns: [ + { + id: 'col-1', + name: 'Result', + type: 'string' as const, + required: false, + unique: false, + workflowGroupId: 'group-1', + }, + ], + }, } +const context = { params: Promise.resolve({ tableId: 'table-1' }) } -function callWrite(method: 'POST' | 'PATCH' | 'DELETE', body: unknown) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/groups', { +function writeRequest(method: 'POST' | 'PATCH' | 'DELETE', body: unknown) { + return new NextRequest('http://localhost:3000/api/v2/tables/table-1/groups', { method, - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, body: JSON.stringify(body), }) - const handler = method === 'POST' ? POST : method === 'PATCH' ? PATCH : DELETE - return handler(req, { params: Promise.resolve({ tableId: 'table-1' }) }) } -describe('POST /api/v2/tables/[tableId]/groups', () => { +describe('/api/v2/tables/[tableId]/groups', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockGateError.mockResolvedValue(null) - mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-1' }) - // Echo back the id the route generated, as the real service does. - mockAddWorkflowGroup.mockImplementation(async (data: { group: { id: string } }) => ({ - ...UPDATED_TABLE, - schema: { - ...UPDATED_TABLE.schema, - workflowGroups: [{ ...GROUP, id: data.group.id }], - }, - })) - }) - - it('creates the group and its columns, returning both', async () => { - const res = await callWrite('POST', ADD_BODY) - - expect(res.status).toBe(201) - const body = await res.json() - expect(body.data.group).toMatchObject({ workflowId: 'wf-1', name: 'Enrich' }) - expect(body.data.columns).toEqual([{ name: 'summary', type: 'string' }]) - expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1') - }) - - it('500s rather than emitting a body without the group it claims to have written', async () => { - // Write reports success but the group is absent — an internal inconsistency - // must not surface as a 200 with `group: undefined`. - mockAddWorkflowGroup.mockResolvedValue({ - ...UPDATED_TABLE, - schema: { columns: [], workflowGroups: [] }, - }) - - const res = await callWrite('POST', ADD_BODY) - - expect(res.status).toBe(500) - expect((await res.json()).error.code).toBe('INTERNAL_ERROR') - }) - - it('server-generates the group id and stamps it onto the output columns', async () => { - await callWrite('POST', ADD_BODY) - - const call = mockAddWorkflowGroup.mock.calls[0][0] - expect(call.group.id).toEqual(expect.any(String)) - expect(call.group.id).not.toBe('') - // The caller never supplies workflowGroupId — it is derived from the group. - expect(call.outputColumns[0].workflowGroupId).toBe(call.group.id) - }) - - it('defaults autoRun to false so one POST cannot fan out a metered backfill', async () => { - await callWrite('POST', ADD_BODY) - expect(mockAddWorkflowGroup.mock.calls[0][0].autoRun).toBe(false) - }) - - it('rejects a workflow from another workspace before persisting it', async () => { - mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-other' }) - - const res = await callWrite('POST', ADD_BODY) - - expect(res.status).toBe(400) - expect((await res.json()).error.message).toContain('Workflow not found') - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('rejects an output column that no group output feeds', async () => { - const res = await callWrite('POST', { - ...ADD_BODY, - outputColumns: [{ name: 'summry', type: 'string' }], - }) - - expect(res.status).toBe(400) - expect((await res.json()).error.message).toContain('summry') - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('400s an enrichment group with no enrichmentId', async () => { - const res = await callWrite('POST', { - ...ADD_BODY, - group: { ...ADD_BODY.group, workflowId: '', type: 'enrichment' }, - }) - - expect(res.status).toBe(400) - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('400s a workflow group with no workflowId', async () => { - const res = await callWrite('POST', { - ...ADD_BODY, - group: { ...ADD_BODY.group, workflowId: '' }, - }) - - expect(res.status).toBe(400) - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('masks a permission failure as 404', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callWrite('POST', ADD_BODY) - - expect(res.status).toBe(404) - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callWrite('POST', ADD_BODY) - - expect(res.status).toBe(404) - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT_OK, allowed: false, retryAfterMs: 1000 }) - - const res = await callWrite('POST', ADD_BODY) - - expect(res.status).toBe(429) - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('surfaces a duplicate-column failure as 400, not 500', async () => { - mockAddWorkflowGroup.mockRejectedValue(new Error('Column "summary" already exists')) - - const res = await callWrite('POST', ADD_BODY) - - expect(res.status).toBe(400) - expect((await res.json()).error.message).toContain('already exists') - }) -}) - -describe('PATCH /api/v2/tables/[tableId]/groups', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockGateError.mockResolvedValue(null) - mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-1' }) - mockUpdateWorkflowGroup.mockResolvedValue(UPDATED_TABLE) - }) - - it('updates the group and returns it with the resulting columns', async () => { - const res = await callWrite('PATCH', { - workspaceId: 'ws-1', - groupId: 'group-1', - name: 'Renamed', - }) - - expect(res.status).toBe(200) - const body = await res.json() - expect(body.data.group).toEqual(GROUP) - expect(body.data.columns).toEqual([{ name: 'summary', type: 'string' }]) - expect(mockUpdateWorkflowGroup).toHaveBeenCalledWith( - expect.objectContaining({ tableId: 'table-1', groupId: 'group-1', name: 'Renamed' }), - expect.any(String) + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) + mocks.list.mockResolvedValue({ groups: [group] }) + mocks.create.mockResolvedValue({ table, group }) + mocks.update.mockResolvedValue({ table, group, changed: true, startAutoRun: false }) + mocks.remove.mockResolvedValue({ table, groupId: 'group-1' }) + }) + + it('lists the bounded group projection through the read use case', async () => { + const req = new NextRequest( + `http://localhost:3000/api/v2/tables/table-1/groups?workspaceId=${WORKSPACE_ID}` ) - }) - - it('re-checks workspace containment when the group is re-pointed', async () => { - mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-other' }) - - const res = await callWrite('PATCH', { - workspaceId: 'ws-1', - groupId: 'group-1', - workflowId: 'wf-elsewhere', + const response = await GET(req, context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: [group], nextCursor: null }) + expect(mocks.list).toHaveBeenCalledWith({ + principal, + input: { tableId: 'table-1', workspaceId: WORKSPACE_ID }, + request: req, }) - - expect(res.status).toBe(400) - expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled() }) - it('stamps the group id onto any newly added output columns', async () => { - await callWrite('PATCH', { - workspaceId: 'ws-1', - groupId: 'group-1', - newOutputColumns: [{ name: 'score', type: 'number' }], + it('defaults create autoRun off and delegates all execution initiation to the application layer', async () => { + const req = writeRequest('POST', { + workspaceId: WORKSPACE_ID, + group: { + workflowId: 'workflow-1', + type: 'manual', + outputs: [{ blockId: 'block-1', path: 'result', columnName: 'Result' }], + }, + outputColumns: [{ name: 'Result', type: 'string' }], }) - - expect(mockUpdateWorkflowGroup.mock.calls[0][0].newOutputColumns[0].workflowGroupId).toBe( - 'group-1' - ) - }) - - it('masks a permission failure as 404', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callWrite('PATCH', { workspaceId: 'ws-1', groupId: 'group-1' }) - - expect(res.status).toBe(404) - expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled() - }) - - it('404s an unknown group rather than reporting a generic failure', async () => { - mockUpdateWorkflowGroup.mockRejectedValue(new Error('Workflow group not found')) - - const res = await callWrite('PATCH', { workspaceId: 'ws-1', groupId: 'nope' }) - - expect(res.status).toBe(404) - }) -}) - -describe('DELETE /api/v2/tables/[tableId]/groups', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockGateError.mockResolvedValue(null) - mockDeleteWorkflowGroup.mockResolvedValue({ - ...UPDATED_TABLE, - schema: { columns: [], workflowGroups: [] }, + const response = await POST(req, context) + + expect(response.status).toBe(201) + expect((await response.json()).data.group.id).toBe('group-1') + expect(mocks.create).toHaveBeenCalledWith({ + principal, + input: expect.objectContaining({ + tableId: 'table-1', + workspaceId: WORKSPACE_ID, + autoRun: false, + }), + request: req, }) }) - it('deletes the group and reports the surviving columns', async () => { - const res = await callWrite('DELETE', { workspaceId: 'ws-1', groupId: 'group-1' }) + it('conceals denied table access on group mutations', async () => { + mocks.update.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Forbidden')) - expect(res.status).toBe(200) - // The group's columns go with it — the caller sees what is left, not a bare ack. - expect(await res.json()).toEqual({ data: { id: 'group-1', deleted: true, columns: [] } }) - expect(mockDeleteWorkflowGroup).toHaveBeenCalledWith( - { tableId: 'table-1', groupId: 'group-1' }, - expect.any(String) + const response = await PATCH( + writeRequest('PATCH', { workspaceId: WORKSPACE_ID, groupId: 'group-1', name: 'Renamed' }), + context ) - }) - - it('masks a permission failure as 404', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - const res = await callWrite('DELETE', { workspaceId: 'ws-1', groupId: 'group-1' }) - - expect(res.status).toBe(404) - expect(mockDeleteWorkflowGroup).not.toHaveBeenCalled() + expect(response.status).toBe(404) + expect((await response.json()).error.message).toBe('Table not found') }) - it('400s a body with no groupId', async () => { - const res = await callWrite('DELETE', { workspaceId: 'ws-1' }) + it('returns authoritative surviving columns after deletion', async () => { + const response = await DELETE( + writeRequest('DELETE', { workspaceId: WORKSPACE_ID, groupId: 'group-1' }), + context + ) - expect(res.status).toBe(400) - expect(mockDeleteWorkflowGroup).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect((await response.json()).data).toMatchObject({ id: 'group-1', deleted: true }) }) }) 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 66d06b07f0c..8910958f55a 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts @@ -1,296 +1,73 @@ -import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' -import { generateId } from '@sim/utils/id' import { v2AddWorkflowGroupContract, v2DeleteWorkflowGroupContract, v2ListWorkflowGroupsContract, v2UpdateWorkflowGroupContract, } from '@/lib/api/contracts/v2/tables' -import type { TableDefinition, TableSchema } from '@/lib/table' -import { signalTableSchemaChanged } from '@/lib/table/events' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' import { - addWorkflowGroup, - deleteWorkflowGroup, - updateWorkflowGroup, -} from '@/lib/table/workflow-groups/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess, normalizeColumn } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' -import { v2TableLockError } from '@/app/api/v2/tables/utils' + createTableGroupUseCase, + deleteTableGroupUseCase, + listTableGroupsUseCase, + updateTableGroupUseCase, +} from '@/lib/table/application/groups' +import { tableOperations } from '@/lib/table/application/operations' +import { normalizeColumn } from '@/app/api/table/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * GET /api/v2/tables/[tableId]/groups — The table's workflow/enrichment groups. - * - * Read-only: groups are authored in the workflow builder, and the public - * surface exposes them so a caller can discover the `groupIds` the run - * endpoints take. Groups live on the table's schema, so this is a projection of - * the already-loaded definition rather than a second query, and the set is - * bounded per table — one full page, `nextCursor` always `null`. - */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListWorkflowGroupsContract, - rateLimitEndpoint: 'table-groups', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { tableId } = input.params - const { workspaceId } = input.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!result.ok || result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const groups = (result.table.schema as TableSchema).workflowGroups ?? [] - - return v2CursorList(groups, null, { rateLimit }) - }, + operation: tableOperations.listGroups, + useCase: listTableGroupsUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), + present: ({ groups }) => ({ data: groups, nextCursor: null }), }) -/** - * Maps expected group-service failures into the v2 envelope. The service - * signals through thrown `Error` messages rather than classified codes, so the - * string matching mirrors the first-party mapper. Unexpected errors keep - * bubbling to the public route wrapper for centralized logging and rendering. - */ -function groupMutationError(error: unknown) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - - if (error instanceof Error) { - const message = error.message - if (message === 'Table not found' || message.includes('not found')) { - return v2Error('NOT_FOUND', message) - } - if ( - message.includes('Schema validation') || - message.includes('Missing column definition') || - message.includes('already exists') || - message.includes('exceed') - ) { - return v2Error('BAD_REQUEST', message) - } - } - - throw error -} - -/** - * A group persists a `workflowId` that its runs later execute. Without this the - * table becomes a way to invoke workflows the API key cannot otherwise reach, - * so containment is asserted before the id is stored — on create and on any - * update that re-points the group. - */ -async function assertWorkflowInWorkspace(workflowId: string, workspaceId: string) { - const context = await getActiveWorkflowContext(workflowId) - if (!context || context.workspaceId !== workspaceId) { - return v2Error('BAD_REQUEST', 'Workflow not found in this workspace') - } - return null -} - -/** - * `{ group, columns }` for the group a mutation touched. - * - * Throws when the write reports success but the group is absent from the - * returned schema. The contract declares `group` as present, so emitting - * `undefined` there would ship a body no client can parse while reporting 200 — - * an internal inconsistency is worth a 500, not a malformed success. - */ -function groupResponse(table: TableDefinition, groupId: string) { - const schema = table.schema as TableSchema - const group = (schema.workflowGroups ?? []).find((candidate) => candidate.id === groupId) - if (!group) { - throw new Error(`Workflow group ${groupId} missing from the table after a successful write`) - } - return { group, columns: schema.columns.map(normalizeColumn) } -} - -/** - * POST /api/v2/tables/[tableId]/groups — Bind a workflow or enrichment to the - * table and create the columns its runs populate, in one call. - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2AddWorkflowGroupContract, - rateLimitEndpoint: 'table-groups', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok || result.table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - if (validated.group.workflowId) { - const workflowError = await assertWorkflowInWorkspace( - validated.group.workflowId, - result.table.workspaceId - ) - if (workflowError) return workflowError - } - - /** - * `outputs` and `outputColumns` are two arrays joined by column name, so a - * typo in either silently creates a column nothing feeds. The first-party - * client builds both from one picker and can't desync; a public caller can, - * so the mismatch is rejected rather than persisted. - */ - const outputNames = new Set(validated.group.outputs.map((output) => output.columnName)) - const orphan = validated.outputColumns.find((column) => !outputNames.has(column.name)) - if (orphan) { - return v2Error( - 'BAD_REQUEST', - `outputColumns entry "${orphan.name}" has no matching group.outputs[].columnName` - ) - } - - const groupId = validated.group.id ?? generateId() - - const updatedTable = await addWorkflowGroup( - { - tableId, - group: { ...validated.group, id: groupId }, - // Stamped from the resolved group rather than trusted from the caller. - outputColumns: validated.outputColumns.map((column) => ({ - ...column, - workflowGroupId: groupId, - })), - autoRun: validated.autoRun, - actorUserId: userId, - }, - requestId - ) - - signalTableSchemaChanged(tableId) - - return v2Data(groupResponse(updatedTable, groupId), { rateLimit, status: 201 }) - } catch (error) { - return groupMutationError(error) - } - }, + operation: tableOperations.createGroup, + useCase: createTableGroupUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: ({ table, group }) => ({ + data: { group, columns: table.schema.columns.map(normalizeColumn) }, + }), }) -/** - * PATCH /api/v2/tables/[tableId]/groups — Restructure a group: re-point it, - * add or remove outputs, or change how its runs are scheduled. - * - * Removing an output **deletes that column and its values** — the same - * behavior as `DELETE /columns` on a bound column. There is no detach. - */ -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2UpdateWorkflowGroupContract, - rateLimitEndpoint: 'table-groups', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok || result.table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - if (validated.workflowId !== undefined) { - const workflowError = await assertWorkflowInWorkspace( - validated.workflowId, - result.table.workspaceId - ) - if (workflowError) return workflowError - } - - const updatedTable = await updateWorkflowGroup( - { - tableId, - groupId: validated.groupId, - actorUserId: userId, - ...(validated.workflowId !== undefined ? { workflowId: validated.workflowId } : {}), - ...(validated.name !== undefined ? { name: validated.name } : {}), - ...(validated.dependencies !== undefined ? { dependencies: validated.dependencies } : {}), - ...(validated.outputs !== undefined ? { outputs: validated.outputs } : {}), - ...(validated.newOutputColumns !== undefined - ? { - newOutputColumns: validated.newOutputColumns.map((column) => ({ - ...column, - workflowGroupId: validated.groupId, - })), - } - : {}), - ...(validated.mappingUpdates !== undefined - ? { mappingUpdates: validated.mappingUpdates } - : {}), - ...(validated.inputMappings !== undefined - ? { inputMappings: validated.inputMappings } - : {}), - ...(validated.deploymentMode !== undefined - ? { deploymentMode: validated.deploymentMode } - : {}), - ...(validated.type !== undefined ? { type: validated.type } : {}), - ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}), - }, - requestId - ) - - signalTableSchemaChanged(tableId) - - return v2Data(groupResponse(updatedTable, validated.groupId), { rateLimit }) - } catch (error) { - return groupMutationError(error) - } - }, + operation: tableOperations.updateGroup, + useCase: updateTableGroupUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: ({ table, group }) => ({ + data: { group, columns: table.schema.columns.map(normalizeColumn) }, + }), }) -/** - * DELETE /api/v2/tables/[tableId]/groups — Remove a group **and every column it - * fed**, along with their values. The surviving column list comes back so a - * caller does not have to re-read the table to see what is left. - */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteWorkflowGroupContract, - rateLimitEndpoint: 'table-groups', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok || result.table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const updatedTable = await deleteWorkflowGroup( - { tableId, groupId: validated.groupId }, - requestId - ) - - signalTableSchemaChanged(tableId) - - return v2Data( - { - id: validated.groupId, - deleted: true as const, - columns: (updatedTable.schema as TableSchema).columns.map(normalizeColumn), - }, - { rateLimit } - ) - } catch (error) { - return groupMutationError(error) - } - }, + operation: tableOperations.deleteGroup, + useCase: deleteTableGroupUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: ({ table, groupId }) => ({ + data: { + id: groupId, + deleted: true as const, + columns: table.schema.columns.map(normalizeColumn), + }, + }), }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts index e868aabfd54..88ab015bf3c 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts @@ -1,299 +1,168 @@ /** * @vitest-environment node - * - * Public v2 query POST: typed predicate name→id translation, bounded-default vs - * explicit-unbounded limit, cursor validation, workspace scoping, and - * name-keyed row output in the `{ data, nextCursor }` envelope. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { TableDefinition } from '@/lib/table/types' - -const { - mockCheckAccess, - mockQueryRows, - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockIsFeatureEnabled, - mockGetWorkspaceOrganizationId, -} = vi.hoisted(() => ({ - mockCheckAccess: vi.fn(), - mockQueryRows: vi.fn(), - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockIsFeatureEnabled: vi.fn(), - mockGetWorkspaceOrganizationId: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, -})) - -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record<string, unknown>) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, -})) -vi.mock('@/lib/table', async () => { - const columnKeys = await import('@/lib/table/column-keys') - return { ...columnKeys } +const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { + class MockTableRowsValidationError extends Error { + constructor( + message: string, + readonly details?: unknown + ) { + super(message) + } + } + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + queryRows: vi.fn(), + }, + MockTableRowsValidationError, + } }) -vi.mock('@/lib/table/rows/service', () => ({ queryRows: mockQueryRows })) - -vi.mock('@/lib/core/config/feature-flags', () => ({ - isFeatureEnabled: mockIsFeatureEnabled, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/lib/workspaces/utils', () => ({ - getWorkspaceOrganizationId: mockGetWorkspaceOrganizationId, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -import { encodeCursor } from '@/lib/table/rows/cursor' - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/rows', () => ({ + TableRowsValidationError: MockTableRowsValidationError, + queryTableRows: { operation: { id: 'tables.rows.query' }, execute: mocks.queryRows }, })) import { POST } from '@/app/api/v2/tables/[tableId]/query/route' -const RATE_LIMIT_OK = { +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'workspace-1', - limit: 100, remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), + resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 0, } - -function buildTable(): TableDefinition { - return { - id: 'tbl_1', - name: 'People', - description: null, - schema: { - columns: [ - { id: 'col_name', name: 'name', type: 'string' }, - { id: 'col_wins', name: 'wins', type: 'number' }, - { id: 'col_status', name: 'status', type: 'string' }, - ], - }, - metadata: null, - rowCount: 0, - maxRows: 100, - workspaceId: 'workspace-1', - createdBy: 'user-1', - archivedAt: null, - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - } +const TABLE = { + id: 'table-1', + workspaceId: WORKSPACE_ID, + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' as const }] }, } - -const EMPTY_RESULT = { - rows: [], - rowCount: 0, - totalCount: 0, - limit: 100, - offset: 0, - nextCursor: null, +const ROW = { + id: 'row-1', + data: { 'column-name': 'Ada' }, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), } -function callQuery(body: Record<string, unknown>) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/tbl_1/query', { +function call(body: unknown) { + const request = new NextRequest('http://localhost/api/v2/tables/table-1/query', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, body: JSON.stringify(body), }) - return POST(req, { params: Promise.resolve({ tableId: 'tbl_1' }) }) + return { + request, + response: POST(request, { params: Promise.resolve({ tableId: 'table-1' }) }), + } } describe('POST /api/v2/tables/[tableId]/query', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) - mockQueryRows.mockResolvedValue(EMPTY_RESULT) - mockIsFeatureEnabled.mockResolvedValue(true) - mockGetWorkspaceOrganizationId.mockResolvedValue('org-1') - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callQuery({ workspaceId: 'workspace-1' }) - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockQueryRows).not.toHaveBeenCalled() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE) + mocks.operationRate.mockResolvedValue(RATE) + mocks.gate.mockResolvedValue(null) + mocks.queryRows.mockResolvedValue({ table: TABLE, rows: [ROW], nextCursor: null }) }) - it('runs the flag gate only after the access check, so it cannot leak a cohort oracle', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - const res = await callQuery({ workspaceId: 'workspace-1' }) - expect(res.status).toBe(403) - expect(mockGate).not.toHaveBeenCalled() - }) + it('delegates the typed query and preserves the public row envelope', async () => { + const predicate = { all: [{ field: 'name', op: 'eq', value: 'Ada' }] } + const invocation = call({ workspaceId: WORKSPACE_ID, predicate }) + const response = await invocation.response - it('translates a name-keyed predicate to storage ids', async () => { - const res = await callQuery({ - workspaceId: 'workspace-1', - predicate: { - all: [ - { field: 'status', op: 'eq', value: 'active' }, - { field: 'wins', op: 'gte', value: 10 }, - ], - }, - }) - expect(res.status).toBe(200) - expect(mockQueryRows.mock.calls[0][1].predicate).toEqual({ - all: [ - { field: 'col_status', op: 'eq', value: 'active' }, - { field: 'col_wins', op: 'gte', value: 10 }, + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: [ + { + id: 'row-1', + data: { name: 'Ada' }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, ], + nextCursor: null, }) - }) - - it('accepts a root condition and executes its canonical all group', async () => { - const res = await callQuery({ - workspaceId: 'workspace-1', - predicate: { field: 'status', op: 'eq', value: 'active' }, - }) - - expect(res.status).toBe(200) - expect(mockQueryRows.mock.calls[0][1].predicate).toEqual({ - all: [{ field: 'col_status', op: 'eq', value: 'active' }], + expect(mocks.queryRows).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + predicate, + sort: undefined, + cursor: undefined, + limit: 100, + includeTotal: false, + }, + request: invocation.request, }) }) - it('applies the bounded default limit when omitted', async () => { - await callQuery({ workspaceId: 'workspace-1' }) - expect(mockQueryRows.mock.calls[0][1].limit).toBe(100) - }) + it('preserves explicit limit=0 as the unbounded opt-in', async () => { + await call({ workspaceId: WORKSPACE_ID, limit: 0 }).response - it('treats limit=0 as the explicit unbounded opt-in', async () => { - await callQuery({ workspaceId: 'workspace-1', limit: 0 }) - expect(mockQueryRows.mock.calls[0][1].limit).toBeUndefined() + expect(mocks.queryRows).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ limit: undefined }) }) + ) }) - it('rejects a limit above the max', async () => { - const res = await callQuery({ workspaceId: 'workspace-1', limit: 5000 }) - expect(res.status).toBe(400) - expect(mockQueryRows).not.toHaveBeenCalled() - }) + it('rejects an invalid page limit after admission and before delegation', async () => { + const response = await call({ workspaceId: WORKSPACE_ID, limit: 5000 }).response - it('rejects the removed regex ops at the contract boundary', async () => { - // `match`/`imatch` are no longer in FILTER_OPS — a catastrophic-backtracking - // pattern can pin a shared-pool connection, and nothing shipped depends on them. - const res = await callQuery({ - workspaceId: 'workspace-1', - predicate: { all: [{ field: 'name', op: 'match', value: '^jo' }] }, - }) - expect(res.status).toBe(400) - expect(mockQueryRows).not.toHaveBeenCalled() + expect(response.status).toBe(400) + expect(mocks.authenticate).toHaveBeenCalledOnce() + expect(mocks.operationRate).toHaveBeenCalledOnce() + expect(mocks.queryRows).not.toHaveBeenCalled() }) - it('rejects a predicate referencing an unknown column', async () => { - const res = await callQuery({ - workspaceId: 'workspace-1', - predicate: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, - }) - expect(res.status).toBe(400) - expect((await res.json()).error.message).toMatch(/Unknown filter column/) - }) + it('keeps malformed POST query cursors as a structured 400', async () => { + mocks.queryRows.mockRejectedValue( + new MockTableRowsValidationError('Invalid cursor', { code: 'INVALID_CURSOR' }) + ) - it('rejects a keyset cursor combined with a custom sort', async () => { - const cursor = encodeCursor({ - lastRow: { id: 'r1', orderKey: 'a1' }, - keysetValid: true, - nextOffset: 1, - }) - const res = await callQuery({ - workspaceId: 'workspace-1', - sort: [{ field: 'wins', direction: 'desc' }], - cursor, - }) - expect(res.status).toBe(400) - expect((await res.json()).error.details.code).toBe('CURSOR_SORT_CONFLICT') - }) + const response = await call({ workspaceId: WORKSPACE_ID, cursor: 'malformed' }).response - it('returns 400 INVALID_CURSOR for a malformed cursor', async () => { - const res = await callQuery({ - workspaceId: 'workspace-1', - cursor: Buffer.from('42').toString('base64url'), - }) - expect(res.status).toBe(400) - expect((await res.json()).error.details.code).toBe('INVALID_CURSOR') - }) - - it('surfaces a workspace-scope 403 in the v2 error envelope', async () => { - mockResolveWorkspaceScope.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'API key is not authorized for this workspace', - }) - const res = await callQuery({ workspaceId: 'workspace-1' }) - expect(res.status).toBe(403) - expect((await res.json()).error.code).toBe('FORBIDDEN') - expect(mockQueryRows).not.toHaveBeenCalled() + expect(response.status).toBe(400) + expect((await response.json()).error.details).toEqual({ code: 'INVALID_CURSOR' }) }) - it('masks a workspace-id mismatch against the table as 404', async () => { - const res = await callQuery({ workspaceId: 'other-ws' }) - expect(res.status).toBe(404) - expect((await res.json()).error).toMatchObject({ - code: 'NOT_FOUND', - message: 'Table not found', - }) - }) + it('enforces the one MiB body cap before delegation', async () => { + const response = await call({ workspaceId: WORKSPACE_ID, cursor: 'x'.repeat(1024 * 1024) }) + .response - it('returns name-keyed row data with no storage internals and a private cache header', async () => { - mockQueryRows.mockResolvedValue({ - rows: [ - { - id: 'r1', - data: { col_status: 'active', col_wins: 12 }, - position: 3, - orderKey: 'a5', - executions: {}, - createdAt: new Date('2024-02-02'), - updatedAt: new Date('2024-02-03'), - }, - ], - rowCount: 1, - totalCount: 1, - limit: 100, - offset: 0, - nextCursor: null, - }) - const res = await callQuery({ workspaceId: 'workspace-1' }) - expect(res.headers.get('Cache-Control')).toBe('private, no-store') - const body = await res.json() - expect(body.nextCursor).toBeNull() - expect(body.data[0]).toEqual({ - id: 'r1', - data: { status: 'active', wins: 12 }, - createdAt: '2024-02-02T00:00:00.000Z', - updatedAt: '2024-02-03T00:00:00.000Z', - }) - }) - - it('returns the rate-limit response when the limiter denies the request', async () => { - mockCheckRateLimit.mockResolvedValue({ - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, - }) - const res = await callQuery({ workspaceId: 'workspace-1' }) - expect(res.status).toBe(429) - expect(mockCheckAccess).not.toHaveBeenCalled() + expect(response.status).toBe(413) + expect(mocks.queryRows).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts index a38f876dfbb..76805afa9b5 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts @@ -1,116 +1,38 @@ import { TABLE_QUERY_MAX_BODY_BYTES } from '@/lib/api/contracts/tables' import { V2_DEFAULT_ROW_LIMIT, v2QueryRowsContract } from '@/lib/api/contracts/v2/tables' -import { isZodError } from '@/lib/api/server' -import type { Sort, TablePredicate, TableSchema } from '@/lib/table' -import { buildIdByName, sortSpecNamesToIds } from '@/lib/table' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' +import { queryTableRows } from '@/lib/table/application/rows' import { namedRowMapper } from '@/lib/table/cell-format' -import { TableQueryValidationError } from '@/lib/table/errors' -import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/validate' -import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' -import { queryRows } from '@/lib/table/rows/service' -import { predicateToStorage } from '@/lib/table/select-values' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CursorList, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' import { toApiRow } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * POST /api/v2/tables/[tableId]/query — public row query. Typed `predicate`/`sort` - * objects + opaque cursor pagination. Default page {@link V2_DEFAULT_ROW_LIMIT}; - * `limit=0` = unbounded (whole result or 400). - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2QueryRowsContract, - rateLimitEndpoint: 'table-rows', - parseOptions: { - maxBodyBytes: TABLE_QUERY_MAX_BODY_BYTES, - }, - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const { workspaceId, sort, cursor: cursorToken, limit } = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const accessResult = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') - - const { table } = accessResult - if (workspaceId !== table.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const schema = table.schema as TableSchema - const cursor = cursorToken ? decodeCursor(cursorToken) : undefined - - const idByName = buildIdByName(schema) - // Fuses the id→name key remap with select-cell value formatting, so a select - // cell surfaces its option NAME rather than the stored option id. - const toNamedRow = namedRowMapper(schema.columns) - let predicate: TablePredicate | undefined = input.body.predicate - if (predicate) { - validatePredicate(predicate, schema.columns) - predicate = predicateToStorage(predicate, schema) - } - let sortSpec = sort - if (sortSpec?.length) { - validateSortSpec(sortSpec, schema.columns) - sortSpec = sortSpecNamesToIds(sortSpec, idByName) - } - const sortObj: Sort | undefined = sortSpec?.length - ? Object.fromEntries(sortSpec.map((s) => [s.field, s.direction])) - : undefined - - // A cursor is only valid for the query shape it was minted under: keyset - // cursors bind to the default order, offset cursors to their sort. Runs on - // the STORAGE-keyed sort so the fingerprint matches what queryRows stamped. - if (cursor) assertCursorSortBinding(cursor, sortObj) - - // Public default is a bounded page (unlike the internal surface's unbounded - // omit). `limit=0` is the explicit unbounded opt-in. - const effectiveLimit = - limit === undefined ? V2_DEFAULT_ROW_LIMIT : limit === 0 ? undefined : limit - - const result = await queryRows( - table, - { - predicate, - sort: sortObj, - limit: effectiveLimit, - after: cursor?.after, - offset: cursor?.offset, - includeTotal: false, - withExecutions: false, - }, - requestId - ) - - return v2CursorList( - result.rows.map((r) => toApiRow(r, toNamedRow)), - result.nextCursor, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - if (error instanceof TableQueryValidationError) { - return v2Error('BAD_REQUEST', error.message, { - details: error.code ? { code: error.code } : undefined, - }) - } - - throw error + operation: tableOperations.queryRows, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + parseOptions: { maxBodyBytes: TABLE_QUERY_MAX_BODY_BYTES }, + mapInput: ({ params, body }) => ({ + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + predicate: body.predicate, + sort: body.sort, + cursor: body.cursor, + limit: + body.limit === undefined ? V2_DEFAULT_ROW_LIMIT : body.limit === 0 ? undefined : body.limit, + includeTotal: false, + }), + useCase: queryTableRows, + present: ({ table, rows, nextCursor }) => { + const toNamedRow = namedRowMapper(table.schema.columns) + return { + data: rows.map((row) => toApiRow(row, toNamedRow)), + nextCursor, } }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts index 6b446248750..0aef78dbd47 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts @@ -1,476 +1,178 @@ /** * @vitest-environment node - * - * Public v2 table delete and update. Delete hands the actor to the service so - * the audit is emitted there — and only for a delete that actually archived a - * row. Update routes each field to its own orchestration call; lock flags are - * read-only on this surface and a request carrying them is refused outright. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockPerformDeleteTable, - mockPerformRenameTable, - mockPerformUpdateTableDescription, - mockPerformMoveTableToFolder, - mockPerformUpdateTableLocks, - mockRecordAudit, - mockGetTableById, - mockLoadActiveFolderPathIndex, - mockGateError, - mockSignalSchemaChanged, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockPerformDeleteTable: vi.fn(), - mockPerformRenameTable: vi.fn(), - mockPerformUpdateTableDescription: vi.fn(), - mockPerformMoveTableToFolder: vi.fn(), - mockPerformUpdateTableLocks: vi.fn(), - mockRecordAudit: vi.fn(), - mockGetTableById: vi.fn(), - mockLoadActiveFolderPathIndex: vi.fn(), - mockGateError: vi.fn(), - mockSignalSchemaChanged: vi.fn(), -})) - -vi.mock('@sim/audit', () => ({ - AuditAction: { TABLE_DELETED: 'table.deleted', TABLE_UPDATED: 'table.updated' }, - AuditResourceType: { TABLE: 'table' }, - recordAudit: mockRecordAudit, +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + read: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + capture: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record<string, unknown>) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/lib/table', () => ({ - updateTable: vi.fn(), - getTableById: mockGetTableById, - updateRow: vi.fn(), - rowDataNameToId: vi.fn(), - buildIdByName: vi.fn(), +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) +vi.mock('@/lib/table/application/tables', () => ({ + readTableUseCase: { operation: { id: 'tables.read' }, execute: mocks.read }, + updateTableUseCase: { operation: { id: 'tables.update' }, execute: mocks.update }, + deleteTableUseCase: { operation: { id: 'tables.delete' }, execute: mocks.remove }, })) -vi.mock('@/lib/table/events', () => ({ - signalTableSchemaChanged: mockSignalSchemaChanged, -})) -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, -})) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getWorkspaceWithOwner: vi.fn().mockResolvedValue({ organizationId: 'org-1' }), -})) +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { DELETE, GET, PATCH } from '@/app/api/v2/tables/[tableId]/route' -vi.mock('@/lib/table/orchestration', () => ({ - performDeleteTable: mockPerformDeleteTable, - performRenameTable: mockPerformRenameTable, - performUpdateTableDescription: mockPerformUpdateTableDescription, - performMoveTableToFolder: mockPerformMoveTableToFolder, - performUpdateTableLocks: mockPerformUpdateTableLocks, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - -import { DELETE, PATCH } from '@/app/api/v2/tables/[tableId]/route' - -const UNLOCKED = { - schemaLocked: false, - insertLocked: false, - updateLocked: false, - deleteLocked: false, +const WORKSPACE_ID = 'workspace-1' +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', } -const TABLE = { - id: 'table-1', - name: 'Tasks', - workspaceId: 'ws-1', - schema: { columns: [] }, - locks: UNLOCKED, +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, } -const UPDATED_TABLE = { - ...TABLE, - name: 'Renamed', - description: null, - rowCount: 0, - maxRows: 1000, - folderId: null, - createdAt: new Date('2026-01-01T00:00:00Z'), - updatedAt: new Date('2026-01-02T00:00:00Z'), -} - -const RATE_LIMIT_OK = { +const rate = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, } - -function callDelete() { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1?workspaceId=ws-1', { - method: 'DELETE', - }) - return DELETE(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +const table = { + id: 'table-1', + workspaceId: WORKSPACE_ID, + userId: 'owner-1', + name: 'Contacts', + description: null, + schema: { + columns: [ + { id: 'col-1', name: 'Name', type: 'string' as const, required: false, unique: false }, + ], + }, + rowCount: 0, + maxRows: 100, + folderId: null, + metadata: null, + locks: { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, + }, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), } - -function callPatch(body: unknown) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - return PATCH(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +const context = { params: Promise.resolve({ tableId: 'table-1' }) } + +function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { + return new NextRequest( + `http://localhost:3000/api/v2/tables/table-1${method === 'PATCH' ? '' : `?workspaceId=${WORKSPACE_ID}`}`, + { + method, + headers: { 'x-api-key': 'secret', ...(body ? { 'content-type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + } + ) } -beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockGetTableById.mockResolvedValue(UPDATED_TABLE) - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map([['folder-1', { id: 'folder-1', name: 'Reports', parentId: null }]]), - pathById: new Map([['folder-1', '/Reports']]), - idByPath: new Map([['/Reports', 'folder-1']]), - }) - mockGateError.mockResolvedValue(null) -}) - -describe('DELETE /api/v2/tables/[tableId]', () => { - it('delegates to the orchestration function with the resolved table and actor', async () => { - mockPerformDeleteTable.mockResolvedValue({ success: true }) - - const res = await callDelete() - - expect(res.status).toBe(200) - expect(mockPerformDeleteTable).toHaveBeenCalledWith( - expect.objectContaining({ table: TABLE, userId: 'user-1' }) - ) - expect((await res.json()).data).toEqual({ id: 'table-1', deleted: true }) - // The route no longer audits: doing so out here fired TABLE_DELETED even - // when the delete was a no-op on an already-archived table. - expect(mockRecordAudit).not.toHaveBeenCalled() - }) - - it('returns 423 LOCKED for a delete-locked table instead of a 500', async () => { - mockPerformDeleteTable.mockResolvedValue({ - success: false, - errorCode: 'locked', - error: 'Table is locked', +describe('/api/v2/tables/[tableId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) + mocks.read.mockResolvedValue({ table, folderPath: '/' }) + mocks.update.mockResolvedValue({ + table, + folderPath: '/', + applied: ['name'], + changed: [], }) - - const res = await callDelete() - - expect(res.status).toBe(423) - expect((await res.json()).error.code).toBe('LOCKED') - }) -}) - -describe('PATCH /api/v2/tables/[tableId]', () => { - it('renames through the orchestration function and returns the re-read table', async () => { - mockPerformRenameTable.mockResolvedValue({ success: true }) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ - table: { - id: 'table-1', - name: 'Renamed', - description: null, - schema: { columns: [] }, - rowCount: 0, - maxRows: 1000, - folderPath: '/', - locks: UNLOCKED, - job: null, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-02T00:00:00.000Z', - }, + mocks.remove.mockResolvedValue({ + id: 'table-1', + deleted: true, + archived: true, + tableName: 'Contacts', + workspaceId: WORKSPACE_ID, + attributedUserId: 'owner-1', }) - expect(mockPerformRenameTable).toHaveBeenCalledWith( - expect.objectContaining({ table: TABLE, newName: 'Renamed', userId: 'user-1' }) - ) - expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() - expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() }) - it('updates and clears the table description through orchestration', async () => { - mockPerformUpdateTableDescription.mockResolvedValue({ success: true }) - - const updateResponse = await callPatch({ workspaceId: 'ws-1', description: 'Finance data' }) + it('reads through the canonical authorized use case', async () => { + const req = request('GET') + const response = await GET(req, context) - expect(updateResponse.status).toBe(200) - expect(mockPerformUpdateTableDescription).toHaveBeenCalledWith( - expect.objectContaining({ - table: TABLE, - description: 'Finance data', - userId: 'user-1', - }) - ) - - const clearResponse = await callPatch({ workspaceId: 'ws-1', description: null }) - expect(clearResponse.status).toBe(200) - expect(mockPerformUpdateTableDescription).toHaveBeenLastCalledWith( - expect.objectContaining({ description: null }) - ) - }) - - it('surfaces a running import so an async job is observable, not just startable', async () => { - // `POST /import-async` and `POST /job/cancel` let a caller start and stop an - // import; without this the table never reports that it is running, so there - // is nothing to poll between the two. - mockGetTableById.mockResolvedValue({ - ...UPDATED_TABLE, - jobStatus: 'running', - jobId: 'job-1', - jobType: 'import', - jobRowsProcessed: 250, - jobError: null, - }) - mockPerformRenameTable.mockResolvedValue({ success: true }) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) - - expect((await res.json()).data.table.job).toEqual({ - id: 'job-1', - type: 'import', - status: 'running', - rowsProcessed: 250, - error: null, + expect(response.status).toBe(200) + expect((await response.json()).data.table.id).toBe('table-1') + expect(mocks.read).toHaveBeenCalledWith({ + principal, + input: { tableId: 'table-1', workspaceId: WORKSPACE_ID }, + request: req, }) }) - it('moves the table only after confirming the folder belongs to the workspace', async () => { - mockPerformMoveTableToFolder.mockResolvedValue({ success: true }) - - const res = await callPatch({ workspaceId: 'ws-1', folderPath: '/Reports' }) - - expect(res.status).toBe(200) - expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith('ws-1', 'table', expect.any(Object)) - expect(mockPerformMoveTableToFolder).toHaveBeenCalledWith( - expect.objectContaining({ table: TABLE, folderId: 'folder-1', userId: 'user-1' }) + it('preserves a successful no-op PATCH response', async () => { + const response = await PATCH( + request('PATCH', { workspaceId: WORKSPACE_ID, name: 'Contacts' }), + context ) - }) - it('404s a folder from outside the workspace without attempting the move', async () => { - const res = await callPatch({ workspaceId: 'ws-1', folderPath: '/Elsewhere' }) - - expect(res.status).toBe(404) - expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect((await response.json()).data.table.name).toBe('Contacts') }) - it('rejects a bad folder without applying the rename that came with it', async () => { - // The three operations are separate transactions, so validation has to run - // before the first write — otherwise a rejected PATCH still renames. - const res = await callPatch({ - workspaceId: 'ws-1', - name: 'Renamed', - folderPath: '/Elsewhere', + it('reports committed fields when a later composite PATCH step fails', async () => { + mocks.update.mockResolvedValueOnce({ + table, + folderPath: null, + applied: ['name'], + changed: ['name'], + failure: new OrchestrationError('not_found', 'Folder not found'), }) - expect(res.status).toBe(404) - expect(mockPerformRenameTable).not.toHaveBeenCalled() - expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() - expect(mockSignalSchemaChanged).not.toHaveBeenCalled() - }) - - it('reports which operations landed when a later one fails', async () => { - // The three writes commit independently, so rather than pretending - // atomicity the error states what is already live — a caller can reconcile - // instead of re-reading and diffing. - mockPerformRenameTable.mockResolvedValue({ success: true }) - mockPerformMoveTableToFolder.mockResolvedValue({ - success: false, - errorCode: 'not_found', - error: 'gone', - }) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderPath: '/Reports' }) - - expect(res.status).toBe(404) - expect((await res.json()).error.details).toEqual({ applied: ['name'] }) - }) - - it('omits the applied list when the very first operation fails', async () => { - // `details.applied` present must always mean "these changes are live". - mockPerformRenameTable.mockResolvedValue({ - success: false, - errorCode: 'conflict', - error: 'taken', - }) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderPath: '/Reports' }) - - expect(res.status).toBe(409) - expect((await res.json()).error.details).toBeUndefined() - expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() - }) - - it('still signals collaborators when a later operation fails after an earlier one landed', async () => { - // A mid-write fault can't be rolled back across three transactions, so the - // clients must at least be told to refetch what did apply. - mockPerformRenameTable.mockResolvedValue({ success: true }) - mockPerformMoveTableToFolder.mockResolvedValue({ - success: false, - errorCode: 'not_found', - error: 'gone', - }) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderPath: '/Reports' }) - - expect(res.status).toBe(404) - expect(mockPerformRenameTable).toHaveBeenCalled() - expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1') - }) - - /** - * Locks are read-only on the public API. A `write`-level API key can already - * mutate the table, so letting it clear a lock would let it undo the guard - * placed there to stop it. The strict body rejects the field outright rather - * than dropping it silently, which would report success for a change that - * never happened. - */ - it('rejects a lock change instead of applying or silently ignoring it', async () => { - const res = await callPatch({ workspaceId: 'ws-1', locks: { deleteLocked: true } }) - - expect(res.status).toBe(400) - const body = await res.json() - expect(body.error.code).toBe('BAD_REQUEST') - expect(JSON.stringify(body.error)).toContain('locks') - expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() - }) - - it('rejects a lock change even when paired with an otherwise valid rename', async () => { - const res = await callPatch({ - workspaceId: 'ws-1', - name: 'Renamed', - locks: { deleteLocked: false }, - }) - - expect(res.status).toBe(400) - // The whole request is refused — the rename must not land either. - expect(mockPerformRenameTable).not.toHaveBeenCalled() - }) - - /** - * The re-read runs after the writes have committed, so a failure there must - * still name what landed. Reporting a bare 500 tells the caller nothing took - * effect and it retries into a duplicate-name conflict. - */ - it('reports the applied operations when the final re-read throws', async () => { - mockPerformRenameTable.mockResolvedValue({ success: true }) - mockGetTableById.mockRejectedValue(new Error('connection reset')) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) - - expect(res.status).toBe(500) - expect((await res.json()).error.details).toEqual({ applied: ['name'] }) - }) - - it('reports the applied operations when the re-read finds the table archived', async () => { - mockPerformRenameTable.mockResolvedValue({ success: true }) - mockGetTableById.mockResolvedValue(null) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) - - expect(res.status).toBe(404) - expect((await res.json()).error.details).toEqual({ applied: ['name'] }) - }) - - it('omits applied details when the failure happened before any write', async () => { - mockGetTableById.mockRejectedValue(new Error('connection reset')) - - mockLoadActiveFolderPathIndex.mockRejectedValue(new Error('connection reset')) - - const res = await callPatch({ workspaceId: 'ws-1', folderPath: '/Nope' }) - - // Absence is meaningful: nothing is live, so a retry is safe. - expect((await res.json()).error.details).toBeUndefined() - }) - - it('still reports the stored lock flags on the table it returns', async () => { - // The response is a re-read, so the locked state has to come from there. - mockGetTableById.mockResolvedValue({ - ...UPDATED_TABLE, - locks: { ...UNLOCKED, deleteLocked: true }, - }) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) - - expect(res.status).toBe(200) - expect((await res.json()).data.table.locks).toMatchObject({ deleteLocked: true }) - }) - - it('maps a duplicate-name rename to 409 CONFLICT', async () => { - mockPerformRenameTable.mockResolvedValue({ - success: false, - errorCode: 'conflict', - error: 'A table named "Renamed" already exists', - }) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) - - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - }) - - it('rejects a body with nothing to change', async () => { - const res = await callPatch({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(400) - expect(mockPerformRenameTable).not.toHaveBeenCalled() - }) - - it('404s a table in another workspace without writing', async () => { - mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) - - expect(res.status).toBe(404) - expect(mockPerformRenameTable).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) + const response = await PATCH( + request('PATCH', { + workspaceId: WORKSPACE_ID, + name: 'Renamed', + folderPath: '/Missing', + }), + context ) - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) - - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - expect(mockPerformRenameTable).not.toHaveBeenCalled() + expect(response.status).toBe(404) + expect((await response.json()).error.details).toEqual({ applied: ['name'] }) }) - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + it('keeps delete analytics surface-specific after authoritative success', async () => { + const response = await DELETE(request('DELETE'), context) - expect(res.status).toBe(429) - expect(mockPerformRenameTable).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: 'table-1', deleted: true } }) + expect(mocks.capture).toHaveBeenCalledWith( + 'owner-1', + 'table_deleted', + { table_id: 'table-1', workspace_id: WORKSPACE_ID }, + { groups: { workspace: WORKSPACE_ID } } + ) }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index 2f41f7ff910..7b39bf8b7e3 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -1,242 +1,90 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { v2DeleteTableContract, v2GetTableContract, v2UpdateTableContract, } from '@/lib/api/contracts/v2/tables' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { getTableById } from '@/lib/table' -import { signalTableSchemaChanged } from '@/lib/table/events' +import { captureServerEvent } from '@/lib/posthog/server' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { TableOperationError } from '@/lib/table/application/errors' +import { tableOperations } from '@/lib/table/application/operations' import { - performDeleteTable, - performMoveTableToFolder, - performRenameTable, - performUpdateTableDescription, -} from '@/lib/table/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' -import type { OrchestrationOutcome } from '@/app/api/v2/tables/utils' -import { - toApiTable, - v2TableAccessError, - v2TableLockError, - v2TableOrchestrationError, -} from '@/app/api/v2/tables/utils' - -const logger = createLogger('V2TableDetailAPI') - -/** - * `details` payload naming the operations of a composite write that committed, - * or `undefined` when none did — so `details.applied` being present always - * means "these changes are live despite the error". - */ -function appliedDetails( - applied: readonly ('name' | 'description' | 'folderPath')[] -): { applied: readonly string[] } | undefined { - return applied.length > 0 ? { applied } : undefined -} + deleteTableUseCase, + readTableUseCase, + type UpdateTableResult, + updateTableUseCase, +} from '@/lib/table/application/tables' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { toApiTable } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** GET /api/v2/tables/[tableId] — Get table details. */ -export const GET = withPublicApiRouteHandler({ - contract: v2GetTableContract, - rateLimitEndpoint: 'table-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { tableId } = input.params - const { workspaceId } = input.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!result.ok) return v2Error('NOT_FOUND', 'Table not found') - - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'table') - return v2Data( - { table: toApiTable(result.table, folderPathForId(folderIndex, result.table.folderId)) }, - { rateLimit } +function rethrowUpdateFailure(result: UpdateTableResult): void { + if (!result.failure) return + if (result.applied.length === 0) throw result.failure + + const details = { applied: result.applied } + if (result.failure instanceof TableOperationError) { + throw new TableOperationError( + result.failure.code, + result.failure.message, + { ...result.failure.details, ...details }, + result.failure.lock ) - }, + } + if (result.failure instanceof TableLockedError) { + throw new TableOperationError('locked', result.failure.message, details, result.failure.lock) + } + const classified = asOrchestrationError(result.failure) + if (classified) throw new TableOperationError(classified.code, classified.message, details) + throw new TableOperationError('internal', 'Internal server error', details) +} + +export const GET = defineV2JsonRoute({ + contract: v2GetTableContract, + operation: tableOperations.read, + useCase: readTableUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), + present: ({ table, folderPath }) => ({ data: { table: toApiTable(table, folderPath) } }), }) -/** - * PATCH /api/v2/tables/[tableId] — Rename and/or move a table. - * - * Each field routes to its own orchestration call so the audit records the - * operation the caller actually performed. - * - * Lock flags are **not** settable here. They are readable on the table resource - * and enforced on every write, but an API key that can mutate a table must not - * also be able to clear the lock placed there to stop it; changing a lock stays - * a first-party admin action. The contract body is `.strict()`, so a request - * carrying `locks` is rejected rather than silently ignored. - */ -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2UpdateTableContract, - rateLimitEndpoint: 'table-detail', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - /** - * Hoisted above the `try` so every exit path can report it. Once a write has - * committed, the response must say so even when the failure came *after* the - * writes — a throw in the final re-read, or the re-read finding the table - * archived. Reporting a bare 500 there tells the caller nothing landed, and - * it retries into a duplicate-name conflict or a repeated move. - */ - const applied: ('name' | 'description' | 'folderPath')[] = [] - - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const resolution = - validated.folderPath === undefined - ? undefined - : await resolveFolderPathIdentity({ - workspaceId: table.workspaceId, - resourceType: 'table', - path: validated.folderPath, - }) - if (resolution && !resolution.found) { - return v2Error('NOT_FOUND', 'Folder not found in this workspace') - } - - let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null - - if (validated.name !== undefined) { - const outcome = await performRenameTable({ - table, - newName: validated.name, - userId, - requestId, - request, - }) - if (outcome.success) applied.push('name') - else failure = { outcome, fallback: 'Failed to rename table' } - } - - if (!failure && validated.description !== undefined) { - const outcome = await performUpdateTableDescription({ - table, - description: validated.description, - userId, - requestId, - request, - }) - if (outcome.success) applied.push('description') - else failure = { outcome, fallback: 'Failed to update table description' } - } - - if (!failure && validated.folderPath !== undefined) { - const outcome = await performMoveTableToFolder({ - table, - folderId: resolution?.folderId ?? null, - userId, - requestId, - request, - }) - if (outcome.success) { - applied.push('folderPath') - } else { - failure = { - outcome: - outcome.errorCode === 'not_found' - ? { ...outcome, error: 'Table not found' } - : outcome, - fallback: 'Failed to move table', - } - } - } - - if (applied.length > 0) signalTableSchemaChanged(tableId) - if (failure) { - return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied)) - } - - const updated = await getTableById(tableId) - if (!updated) { - return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) }) - } - - const folderIndex = await loadActiveFolderPathIndex(table.workspaceId, 'table') - return v2Data( - { table: toApiTable(updated, folderPathForId(folderIndex, updated.folderId)) }, - { rateLimit } - ) - } catch (error) { - const details = appliedDetails(applied) - - const lockError = v2TableLockError(error, details) - if (lockError) return lockError - - const classified = asOrchestrationError(error) - if (classified) { - return v2TableOrchestrationError( - { errorCode: classified.code, error: classified.message }, - 'Failed to update table', - details - ) - } - - logger.error(`[${requestId}] Error updating table`, { - error: getErrorMessage(error, 'Unknown error'), - applied, - }) - return v2Error('INTERNAL_ERROR', 'Internal server error', { details }) + operation: tableOperations.update, + useCase: updateTableUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: (result) => { + rethrowUpdateFailure(result) + if (!result.table || result.folderPath === null) { + throw new Error('Updated table is missing from the authoritative result') } + return { data: { table: toApiTable(result.table, result.folderPath) } } }, }) -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteTableContract, - rateLimitEndpoint: 'table-detail', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const { workspaceId } = input.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const outcome = await performDeleteTable({ table: result.table, userId, requestId, request }) - if (!outcome.success) { - return v2TableOrchestrationError(outcome, 'Failed to delete table') - } - - return v2Data({ id: tableId, deleted: true }, { rateLimit }) - } catch (error) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - throw error - } + operation: tableOperations.delete, + useCase: deleteTableUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), + onSuccess: ({ result }) => { + captureServerEvent( + result.attributedUserId, + 'table_deleted', + { table_id: result.id, workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } + ) }, + present: ({ id, deleted }) => ({ data: { id, deleted } }), }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts index cc1566ce848..c0e1306fe6a 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts @@ -1,160 +1,134 @@ /** * @vitest-environment node - * - * Public v2 per-row enrichment run — the single-cell case of the column run. - * Naming a specific cell is an explicit re-run, so it dispatches in `all` mode - * and recomputes an already-populated cell. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockRunWorkflowColumn, - mockSignalRowsChanged, - mockGateError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockRunWorkflowColumn: vi.fn(), - mockSignalRowsChanged: vi.fn(), - mockGateError: vi.fn(), -})) +const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { + class MockTableRowsValidationError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + startRun: vi.fn(), + }, + MockTableRowsValidationError, + } +}) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record<string, unknown>) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/rows', () => ({ + TableRowsValidationError: MockTableRowsValidationError, +})) +vi.mock('@/lib/table/application/runs', () => ({ + startTableRun: { operation: { id: 'tables.runs.start' }, execute: mocks.startRun }, })) -vi.mock('@/lib/table/workflow-columns', () => ({ runWorkflowColumn: mockRunWorkflowColumn })) -vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged })) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - +import { OrchestrationError } from '@/lib/core/orchestration/types' import { POST } from '@/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route' -const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } - -const RATE_LIMIT_OK = { +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, remaining: 99, resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 0, } -function callPost(body: unknown) { - const req = new NextRequest( - 'http://localhost:3000/api/v2/tables/table-1/rows/row-1/enrichment/group-1', +function call(body: unknown) { + const request = new NextRequest( + 'http://localhost/api/v2/tables/table-1/rows/row-1/enrichment/group-1', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, body: JSON.stringify(body), } ) - return POST(req, { - params: Promise.resolve({ tableId: 'table-1', rowId: 'row-1', groupId: 'group-1' }), - }) + return { + request, + response: POST(request, { + params: Promise.resolve({ tableId: 'table-1', rowId: 'row-1', groupId: 'group-1' }), + }), + } } describe('POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockRunWorkflowColumn.mockResolvedValue({ dispatchId: 'dispatch-1' }) - mockGateError.mockResolvedValue(null) + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE) + mocks.operationRate.mockResolvedValue(RATE) + mocks.gate.mockResolvedValue(null) + mocks.startRun.mockResolvedValue({ table: { id: 'table-1' }, dispatchId: 'dispatch-1' }) }) - it('scopes the dispatch to the one row and group in the path', async () => { - const res = await callPost({ workspaceId: 'ws-1' }) + it('delegates the canonical row and group path scope', async () => { + const invocation = call({ workspaceId: WORKSPACE_ID }) + const response = await invocation.response - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ dispatchId: 'dispatch-1' }) - expect(mockRunWorkflowColumn).toHaveBeenCalledWith( - expect.objectContaining({ + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { dispatchId: 'dispatch-1' } }) + expect(mocks.startRun).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + kind: 'row_enrichment', tableId: 'table-1', - workspaceId: 'ws-1', - groupIds: ['group-1'], - rowIds: ['row-1'], - mode: 'all', - triggeredByUserId: 'user-1', - }) - ) - expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1') - }) - - it('reports a null dispatch id verbatim rather than inventing one', async () => { - mockRunWorkflowColumn.mockResolvedValue({ dispatchId: null }) - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ dispatchId: null }) - }) - - it('404s a table in another workspace without dispatching', async () => { - mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(404) - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() - }) - - it('400s a body with no workspace', async () => { - const res = await callPost({}) - - expect(res.status).toBe(400) - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + rowId: 'row-1', + groupId: 'group-1', + assertedWorkspaceId: WORKSPACE_ID, + }, + request: invocation.request, + }) }) - it('403s a read-only member', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + it('preserves a null dispatch id instead of inventing one', async () => { + mocks.startRun.mockResolvedValue({ table: { id: 'table-1' }, dispatchId: null }) - const res = await callPost({ workspaceId: 'ws-1' }) + const response = await call({ workspaceId: WORKSPACE_ID }).response - expect(res.status).toBe(403) - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { dispatchId: null } }) }) - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callPost({ workspaceId: 'ws-1' }) + it('rejects a missing workspace before delegation', async () => { + const response = await call({}).response - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + expect(response.status).toBe(400) + expect(mocks.startRun).not.toHaveBeenCalled() }) - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) + it('conceals canonical row or group lookup failures', async () => { + mocks.startRun.mockRejectedValue(new OrchestrationError('not_found', 'Row not found')) - const res = await callPost({ workspaceId: 'ws-1' }) + const response = await call({ workspaceId: WORKSPACE_ID }).response - expect(res.status).toBe(429) - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts index 121c88c7e42..bc229a9579b 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts @@ -1,66 +1,25 @@ import { v2RunRowEnrichmentContract } from '@/lib/api/contracts/v2/tables' -import { signalTableRowsChanged } from '@/lib/table/events' -import { runWorkflowColumn } from '@/lib/table/workflow-columns' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' +import { startTableRun } from '@/lib/table/application/runs' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId] - * - * The single-cell case of `POST /columns/run`: runs one group for one row. - * `mode: 'all'` because naming a specific cell is an explicit re-run request — - * an already-populated cell must recompute rather than be skipped. - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2RunRowEnrichmentContract, - rateLimitEndpoint: 'table-enrichment', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId, rowId, groupId } = input.params - const { workspaceId } = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const access = await checkAccess(tableId, userId, 'write') - if (!access.ok) return v2TableAccessError(access) - - if (access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const { dispatchId } = await runWorkflowColumn({ - tableId, - workspaceId, - groupIds: [groupId], - rowIds: [rowId], - mode: 'all', - requestId, - triggeredByUserId: userId, - }) - - signalTableRowsChanged(tableId) - - return v2Data({ dispatchId }, { rateLimit }) - } catch (error) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - throw error - } - }, + operation: tableOperations.startRun, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => ({ + kind: 'row_enrichment' as const, + tableId: params.tableId, + rowId: params.rowId, + groupId: params.groupId, + assertedWorkspaceId: body.workspaceId, + }), + useCase: startTableRun, + present: ({ dispatchId }) => ({ data: { dispatchId } }), }) 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 626dd3ba567..8e22fca852c 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 @@ -1,122 +1,164 @@ /** * @vitest-environment node - * - * Public v2 single-row delete: goes through the row service so the delete lock - * and row-count bookkeeping are enforced, and renders lock/not-found in the v2 - * error envelope. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceScope, mockCheckAccess, mockPerformDeleteRow } = - vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockPerformDeleteRow: vi.fn(), - })) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, -})) +const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { + class MockTableRowsValidationError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + readRow: vi.fn(), + updateRow: vi.fn(), + deleteRow: vi.fn(), + }, + MockTableRowsValidationError, + } +}) -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record<string, unknown>) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/lib/table', () => ({ - updateTable: vi.fn(), - getTableById: vi.fn(), - updateRow: vi.fn(), - rowDataNameToId: vi.fn(), - buildIdByName: vi.fn(), +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/lib/table/orchestration', () => ({ performDeleteTableRow: mockPerformDeleteRow })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/rows', () => ({ + TableRowsValidationError: MockTableRowsValidationError, + readTableRow: { operation: { id: 'tables.rows.read' }, execute: mocks.readRow }, + updateTableRow: { operation: { id: 'tables.rows.update' }, execute: mocks.updateRow }, + deleteTableRow: { operation: { id: 'tables.rows.delete' }, execute: mocks.deleteRow }, })) -import { DELETE } from '@/app/api/v2/tables/[tableId]/rows/[rowId]/route' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { DELETE, GET, PATCH } from '@/app/api/v2/tables/[tableId]/rows/[rowId]/route' -const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } - -function callDelete() { - const req = new NextRequest( - 'http://localhost:3000/api/v2/tables/table-1/rows/row-1?workspaceId=ws-1', - { method: 'DELETE' } +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE = { + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 0, +} +const TABLE = { + id: 'table-1', + workspaceId: WORKSPACE_ID, + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' as const }] }, +} +const ROW = { + id: 'row-1', + data: { 'column-name': 'Ada' }, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} +const CONTEXT = { params: Promise.resolve({ tableId: 'table-1', rowId: 'row-1' }) } + +function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { + return new NextRequest( + `http://localhost/api/v2/tables/table-1/rows/row-1${method === 'PATCH' ? '' : `?workspaceId=${WORKSPACE_ID}`}`, + { + method, + headers: { + 'x-api-key': 'secret', + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + } ) - return DELETE(req, { params: Promise.resolve({ tableId: 'table-1', rowId: 'row-1' }) }) } -describe('DELETE /api/v2/tables/[tableId]/rows/[rowId]', () => { +describe('/api/v2/tables/[tableId]/rows/[rowId]', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue({ - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), - }) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - }) - - it('delegates to the orchestration function rather than deleting inline', async () => { - mockPerformDeleteRow.mockResolvedValue({ success: true }) - - const res = await callDelete() - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ deletedCount: 1, deletedRowIds: ['row-1'] }) - // The orchestration function routes through the row service, which applies - // the delete lock and the row-count decrement; the raw delete this replaced - // skipped both. - expect(mockPerformDeleteRow).toHaveBeenCalledWith( - expect.objectContaining({ table: TABLE, rowId: 'row-1' }) - ) + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE) + mocks.operationRate.mockResolvedValue(RATE) + mocks.gate.mockResolvedValue(null) + mocks.readRow.mockResolvedValue({ table: TABLE, row: ROW }) + mocks.updateRow.mockResolvedValue({ table: TABLE, row: ROW, changed: true }) + mocks.deleteRow.mockResolvedValue({ table: TABLE, deletedRowId: ROW.id }) }) - it.each([ - ['locked', 423, 'LOCKED'], - ['not_found', 404, 'NOT_FOUND'], - ])('maps a %s failure to %i', async (errorCode, status, code) => { - mockPerformDeleteRow.mockResolvedValue({ success: false, errorCode, error: 'nope' }) + it('reads through the shared use case and strips storage internals', async () => { + const req = request('GET') + const response = await GET(req, CONTEXT) - const res = await callDelete() - - expect(res.status).toBe(status) - expect((await res.json()).error.code).toBe(code) + expect(response.status).toBe(200) + expect((await response.json()).data.row).toEqual({ + id: 'row-1', + data: { name: 'Ada' }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }) + expect(mocks.readRow).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { tableId: 'table-1', rowId: 'row-1', assertedWorkspaceId: WORKSPACE_ID }, + request: req, + }) }) - it('names the lock on a 423 that arrived as a classified outcome, not a throw', async () => { - mockPerformDeleteRow.mockResolvedValue({ - success: false, - errorCode: 'locked', - error: 'Row deletes are locked for this table', - lock: 'delete', + it('updates through the shared use case with the exact patch', async () => { + const req = request('PATCH', { workspaceId: WORKSPACE_ID, data: { name: 'Ada' } }) + const response = await PATCH(req, CONTEXT) + + expect(response.status).toBe(200) + expect(mocks.updateRow).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + tableId: 'table-1', + rowId: 'row-1', + assertedWorkspaceId: WORKSPACE_ID, + data: { name: 'Ada' }, + }, + request: req, }) + }) - const res = await callDelete() + it('returns the compatible authoritative single-delete envelope', async () => { + const req = request('DELETE') + const response = await DELETE(req, CONTEXT) - expect(res.status).toBe(423) - expect((await res.json()).error.details).toEqual({ lock: 'delete' }) + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual({ + deletedCount: 1, + deletedRowIds: ['row-1'], + }) + expect(mocks.deleteRow).toHaveBeenCalledWith( + expect.objectContaining({ + principal: PRINCIPAL, + input: expect.objectContaining({ tableId: 'table-1', rowId: 'row-1' }), + }) + ) }) - it('omits details entirely when the lock kind is unknown', async () => { - // A caller branching on `details.lock` should see absence, not a null. - mockPerformDeleteRow.mockResolvedValue({ success: false, errorCode: 'locked', error: 'nope' }) + it('conceals a forbidden canonical lookup as not found', async () => { + mocks.readRow.mockRejectedValue(new OrchestrationError('forbidden', 'Forbidden')) - const res = await callDelete() + const response = await GET(request('GET'), CONTEXT) - expect((await res.json()).error.details).toBeUndefined() + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') }) }) 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 8834f457a9e..08e6d9c6a2f 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 @@ -1,173 +1,66 @@ -import { db } from '@sim/db' -import { userTableRows } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' import { v2DeleteTableRowContract, v2GetTableRowContract, v2UpdateTableRowContract, } from '@/lib/api/contracts/v2/tables' -import { isZodError } from '@/lib/api/server' -import type { RowData, TableSchema } from '@/lib/table' -import { buildIdByName, rowDataNameToId, updateRow } from '@/lib/table' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' +import { deleteTableRow, readTableRow, updateTableRow } from '@/lib/table/application/rows' import { namedRowMapper } from '@/lib/table/cell-format' -import { performDeleteTableRow } from '@/lib/table/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { - toApiRow, - v2TableAccessError, - v2TableLockError, - v2TableOrchestrationError, -} from '@/app/api/v2/tables/utils' +import { toApiRow } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** GET /api/v2/tables/[tableId]/rows/[rowId] — Get a single row. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetTableRowContract, - rateLimitEndpoint: 'table-row-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { tableId, rowId } = input.params - const { workspaceId } = input.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!result.ok) return v2Error('NOT_FOUND', 'Table not found') - - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const [row] = await db - .select({ - id: userTableRows.id, - data: userTableRows.data, - createdAt: userTableRows.createdAt, - updatedAt: userTableRows.updatedAt, - }) - .from(userTableRows) - .where( - and( - eq(userTableRows.id, rowId), - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, workspaceId) - ) - ) - .limit(1) - - if (!row) return v2Error('NOT_FOUND', 'Row not found') - - const toNamedRow = namedRowMapper((result.table.schema as TableSchema).columns) - return v2Data( - { - row: toApiRow( - { - id: row.id, - data: row.data as RowData, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }, - toNamedRow - ), - }, - { rateLimit } - ) - }, + operation: tableOperations.readRow, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, query }) => ({ + tableId: params.tableId, + rowId: params.rowId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: readTableRow, + present: ({ table, row }) => ({ + data: { row: toApiRow(row, namedRowMapper(table.schema.columns)) }, + }), }) -/** PATCH /api/v2/tables/[tableId]/rows/[rowId] — Partial update a single row. */ -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2UpdateTableRowContract, - rateLimitEndpoint: 'table-row-detail', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId, rowId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const idByName = buildIdByName(table.schema as TableSchema) - const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) - const updatedRow = await updateRow( - { - tableId, - rowId, - data: rowDataNameToId(validated.data as RowData, idByName), - workspaceId: validated.workspaceId, - actorUserId: userId, - }, - table, - requestId - ) - // No `cancellationGuard` is passed, so `updateRow` can't return null here. - // Defensive narrowing for TypeScript. - if (!updatedRow) return v2Error('NOT_FOUND', 'Row not found') - - return v2Data({ row: toApiRow(updatedRow, toNamedRow) }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - throw error - } - }, + operation: tableOperations.updateRow, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => ({ + tableId: params.tableId, + rowId: params.rowId, + assertedWorkspaceId: body.workspaceId, + data: body.data, + }), + useCase: updateTableRow, + present: ({ table, row }) => ({ + data: { row: toApiRow(row, namedRowMapper(table.schema.columns)) }, + }), }) -/** DELETE /api/v2/tables/[tableId]/rows/[rowId] — Delete a single row. */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteTableRowContract, - rateLimitEndpoint: 'table-row-detail', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId, rowId } = input.params - const { workspaceId } = input.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const outcome = await performDeleteTableRow({ table: result.table, rowId, requestId }) - if (!outcome.success) { - return v2TableOrchestrationError(outcome, 'Failed to delete row') - } - - // v2 mirrors the bulk delete shape: always returns `deletedRowIds`. - return v2Data({ deletedCount: 1, deletedRowIds: [rowId] }, { rateLimit }) - } catch (error) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.deleteRow, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, query }) => ({ + tableId: params.tableId, + rowId: params.rowId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: deleteTableRow, + present: ({ deletedRowId }) => ({ + data: { deletedCount: 1, deletedRowIds: [deletedRowId] }, + }), }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts index 19f38dfb59f..e86f657c272 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts @@ -1,207 +1,135 @@ /** * @vitest-environment node - * - * Public v2 row lookup. The wire is column-NAME keyed both ways: the predicate - * and sort translate down to storage ids on the way in, and the matched column - * id translates back to its name on the way out. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockFindRowMatches, - mockPredicateToFilter, - mockValidateSortSpec, - mockSortSpecNamesToIds, - mockGateError, - TableQueryValidationError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockFindRowMatches: vi.fn(), - mockPredicateToFilter: vi.fn(), - mockValidateSortSpec: vi.fn(), - mockSortSpecNamesToIds: vi.fn(), - mockGateError: vi.fn(), - TableQueryValidationError: class TableQueryValidationError extends Error {}, -})) +const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { + class MockTableRowsValidationError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + findRows: vi.fn(), + }, + MockTableRowsValidationError, + } +}) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record<string, unknown>) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({ - ...(await importOriginal<Record<string, unknown>>()), - v2BulkPredicateToFilter: mockPredicateToFilter, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/rows', () => ({ + TableRowsValidationError: MockTableRowsValidationError, + findTableRows: { operation: { id: 'tables.rows.find' }, execute: mocks.findRows }, })) -vi.mock('@/lib/table', () => ({ - buildIdByName: vi.fn().mockReturnValue({ status: 'col-1', name: 'col-2' }), - sortSpecNamesToIds: mockSortSpecNamesToIds, -})) -vi.mock('@/lib/table/rows/service', () => ({ findRowMatches: mockFindRowMatches })) -vi.mock('@/lib/table/query-builder/validate', () => ({ validateSortSpec: mockValidateSortSpec })) -vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError })) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - import { POST } from '@/app/api/v2/tables/[tableId]/rows/find/route' -const COLUMNS = [ - { id: 'col-1', name: 'status', type: 'string' }, - { id: 'col-2', name: 'name', type: 'string' }, -] -const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } } - -const RATE_LIMIT_OK = { +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, remaining: 99, resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 0, +} +const TABLE = { + id: 'table-1', + workspaceId: WORKSPACE_ID, + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' as const }] }, } -function callPost(body: unknown) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/rows/find', { +function call(body: unknown) { + const request = new NextRequest('http://localhost/api/v2/tables/table-1/rows/find', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, body: JSON.stringify(body), }) - return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) + return { + request, + response: POST(request, { params: Promise.resolve({ tableId: 'table-1' }) }), + } } describe('POST /api/v2/tables/[tableId]/rows/find', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockFindRowMatches.mockResolvedValue({ - matches: [{ ordinal: 3, rowId: 'row-1', column: 'col-2' }], - truncated: false, + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE) + mocks.operationRate.mockResolvedValue(RATE) + mocks.gate.mockResolvedValue(null) + mocks.findRows.mockResolvedValue({ + table: TABLE, + matches: [{ ordinal: 3, rowId: 'row-1', column: 'column-name' }], + truncated: true, }) - mockSortSpecNamesToIds.mockImplementation((spec: { field: string }[]) => - spec.map((s) => ({ ...s, field: s.field === 'name' ? 'col-2' : s.field })) - ) - mockGateError.mockResolvedValue(null) }) - it('reports the matched column by NAME, not its storage id', async () => { - const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ - matches: [{ ordinal: 3, rowId: 'row-1', column: 'name' }], - truncated: false, + it('delegates the bounded lookup and presents column names', async () => { + const predicate = { all: [{ field: 'name', op: 'eq', value: 'Ada' }] } + const sort = [{ field: 'name', direction: 'asc' }] + const invocation = call({ workspaceId: WORKSPACE_ID, q: 'ada', predicate, sort }) + const response = await invocation.response + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + matches: [{ ordinal: 3, rowId: 'row-1', column: 'name' }], + truncated: true, + }, }) - expect(mockFindRowMatches).toHaveBeenCalledWith( - TABLE, - { q: 'acme', filter: undefined, sort: undefined }, - expect.any(String) - ) - }) - - it('translates the predicate and sort to storage keys before searching', async () => { - mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } }) - const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } - - const res = await callPost({ - workspaceId: 'ws-1', - q: 'acme', - predicate, - sort: [{ field: 'name', direction: 'asc' }], + expect(mocks.findRows).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + q: 'ada', + predicate, + sort, + }, + request: invocation.request, }) - - expect(res.status).toBe(200) - expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema) - expect(mockValidateSortSpec).toHaveBeenCalledWith( - [{ field: 'name', direction: 'asc' }], - COLUMNS - ) - expect(mockFindRowMatches).toHaveBeenCalledWith( - TABLE, - { q: 'acme', filter: { 'col-1': { $eq: 'active' } }, sort: { 'col-2': 'asc' } }, - expect.any(String) - ) }) - it('surfaces truncation so a caller narrows instead of paging', async () => { - mockFindRowMatches.mockResolvedValue({ matches: [], truncated: true }) + it('rejects an empty search after admission and before delegation', async () => { + const response = await call({ workspaceId: WORKSPACE_ID, q: '' }).response - const res = await callPost({ workspaceId: 'ws-1', q: 'a' }) - - expect((await res.json()).data).toEqual({ matches: [], truncated: true }) + expect(response.status).toBe(400) + expect(mocks.authenticate).toHaveBeenCalledOnce() + expect(mocks.findRows).not.toHaveBeenCalled() }) - it('400s an unresolvable predicate field instead of returning zero matches', async () => { - mockPredicateToFilter.mockImplementation(() => { - throw new TableQueryValidationError('Unknown column "nope"') - }) - - const res = await callPost({ - workspaceId: 'ws-1', - q: 'acme', - predicate: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, - }) - - expect(res.status).toBe(400) - expect(mockFindRowMatches).not.toHaveBeenCalled() - }) - - it('400s an empty search string', async () => { - const res = await callPost({ workspaceId: 'ws-1', q: '' }) - - expect(res.status).toBe(400) - expect(mockFindRowMatches).not.toHaveBeenCalled() - }) - - it('masks a permission failure as 404 so table existence never leaks', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) - - expect(res.status).toBe(404) - expect(mockFindRowMatches).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) - - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - expect(mockFindRowMatches).not.toHaveBeenCalled() - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) + it('stops at the rollout gate before the shared use case', async () => { + const { v2Error } = await import('@/app/api/v2/lib/response') + mocks.gate.mockResolvedValue(v2Error('NOT_FOUND', 'Not found')) - const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) + const response = await call({ workspaceId: WORKSPACE_ID, q: 'ada' }).response - expect(res.status).toBe(429) - expect(mockFindRowMatches).not.toHaveBeenCalled() + expect(response.status).toBe(404) + expect(mocks.findRows).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts index 18ebe72ea1f..0dfac4b811c 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts @@ -1,90 +1,38 @@ import { v2FindTableRowsContract } from '@/lib/api/contracts/v2/tables' -import { isZodError } from '@/lib/api/server' -import type { Filter, Sort, TableSchema } from '@/lib/table' -import { buildIdByName, sortSpecNamesToIds } from '@/lib/table' -import { TableQueryValidationError } from '@/lib/table/errors' -import { validateSortSpec } from '@/lib/table/query-builder/validate' -import { findRowMatches } from '@/lib/table/rows/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2Data, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { columnNameById, v2BulkPredicateToFilter } from '@/app/api/v2/tables/utils' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' +import { findTableRows } from '@/lib/table/application/rows' +import { columnNameById } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * POST /api/v2/tables/[tableId]/rows/find — Case-insensitive substring search - * across every cell, narrowed by the same predicate/sort grammar as - * `POST /query`. - * - * Returns matching CELLS, not rows: each match carries the row's ordinal in the - * same filtered+sorted view a `POST /query` with these arguments would return, - * so a caller can jump straight to the page holding it. - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2FindTableRowsContract, - rateLimitEndpoint: 'table-rows-find', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const { workspaceId, q, predicate, sort } = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const accessResult = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!accessResult.ok || accessResult.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const { table } = accessResult - const schema = table.schema as TableSchema - - // The public wire is column-NAME keyed both ways: translate the predicate - // and sort down to storage ids on the way in, and the matched column id - // back to its name on the way out. - let filter: Filter | undefined - if (predicate) filter = v2BulkPredicateToFilter(predicate, schema) - - let sortObj: Sort | undefined - if (sort?.length) { - validateSortSpec(sort, schema.columns) - const storageSort = sortSpecNamesToIds(sort, buildIdByName(schema)) - sortObj = Object.fromEntries(storageSort.map((s) => [s.field, s.direction])) - } - - const { matches, truncated } = await findRowMatches( - table, - { q, filter, sort: sortObj }, - requestId - ) - - const toColumnName = columnNameById(schema) - - return v2Data( - { - matches: matches.map((match) => ({ - ordinal: match.ordinal, - rowId: match.rowId, - column: toColumnName(match.column), - })), - truncated, - }, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - throw error + operation: tableOperations.findRows, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => ({ + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + q: body.q, + predicate: body.predicate, + sort: body.sort, + }), + useCase: findTableRows, + present: ({ table, matches, truncated }) => { + const toColumnName = columnNameById(table.schema) + return { + data: { + matches: matches.map((match) => ({ + ordinal: match.ordinal, + rowId: match.rowId, + column: toColumnName(match.column), + })), + truncated, + }, } }, }) 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 new file mode 100644 index 00000000000..11ea0842bfb --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts @@ -0,0 +1,190 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { + class MockTableRowsValidationError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + listRows: vi.fn(), + createRows: vi.fn(), + updateRows: vi.fn(), + deleteRows: vi.fn(), + }, + MockTableRowsValidationError, + } +}) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/rows', () => ({ + TableRowsValidationError: MockTableRowsValidationError, + listTableRows: { operation: { id: 'tables.rows.list' }, execute: mocks.listRows }, + createTableRows: { operation: { id: 'tables.rows.create' }, execute: mocks.createRows }, + updateTableRows: { operation: { id: 'tables.rows.update_many' }, execute: mocks.updateRows }, + deleteTableRows: { operation: { id: 'tables.rows.delete_many' }, execute: mocks.deleteRows }, +})) + +import { DELETE, GET, POST, PUT } from '@/app/api/v2/tables/[tableId]/rows/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE = { + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 0, +} +const TABLE = { + id: 'table-1', + workspaceId: WORKSPACE_ID, + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' as const }] }, +} +const ROW = { + id: 'row-1', + data: { 'column-name': 'Ada' }, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} +const CONTEXT = { params: Promise.resolve({ tableId: 'table-1' }) } + +function request(method: 'GET' | 'POST' | 'PUT' | 'DELETE', body?: unknown, query = '') { + return new NextRequest(`http://localhost/api/v2/tables/table-1/rows${query}`, { + method, + headers: { + 'x-api-key': 'secret', + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) +} + +describe('/api/v2/tables/[tableId]/rows', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE) + mocks.operationRate.mockResolvedValue(RATE) + mocks.gate.mockResolvedValue(null) + mocks.listRows.mockResolvedValue({ table: TABLE, rows: [ROW], nextOffset: null }) + mocks.createRows.mockResolvedValue({ kind: 'single', table: TABLE, row: ROW }) + mocks.updateRows.mockResolvedValue({ + table: TABLE, + affectedCount: 1, + affectedRowIds: ['row-1'], + }) + mocks.deleteRows.mockResolvedValue({ + kind: 'ids', + table: TABLE, + deletedCount: 1, + deletedRowIds: ['row-1'], + requestedCount: 2, + missingRowIds: ['row-2'], + }) + }) + + it('retains malformed GET cursor fallback compatibility', async () => { + const req = request('GET', undefined, `?workspaceId=${WORKSPACE_ID}&limit=25&cursor=malformed`) + const response = await GET(req, CONTEXT) + + expect(response.status).toBe(200) + expect(mocks.listRows).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + limit: 25, + offset: 0, + }, + request: req, + }) + }) + + 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.row.id).toBe('row-1') + expect(mocks.createRows).toHaveBeenLastCalledWith({ + principal: PRINCIPAL, + input: { + kind: 'single', + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + data: { name: 'Ada' }, + }, + request: single, + }) + + 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) + expect(mocks.createRows).toHaveBeenLastCalledWith({ + principal: PRINCIPAL, + input: { + kind: 'batch', + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + rows: [{ name: 'Ada' }], + }, + request: batch, + }) + }) + + it('preserves authoritative bulk update counts including a zero-match result', async () => { + mocks.updateRows.mockResolvedValue({ table: TABLE, affectedCount: 0, affectedRowIds: [] }) + const req = request('PUT', { + workspaceId: WORKSPACE_ID, + filter: { all: [{ field: 'name', op: 'eq', value: 'missing' }] }, + data: { name: 'Grace' }, + }) + const response = await PUT(req, CONTEXT) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { updatedCount: 0, updatedRowIds: [] } }) + }) + + it('preserves id-delete requested and missing-row reporting', async () => { + const req = request('DELETE', { + workspaceId: WORKSPACE_ID, + rowIds: ['row-1', 'row-2'], + }) + const response = await DELETE(req, CONTEXT) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + deletedCount: 1, + deletedRowIds: ['row-1'], + requestedCount: 2, + missingRowIds: ['row-2'], + }, + }) + }) +}) 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 0be13bffdb4..d597c5bdc8e 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts @@ -1,342 +1,136 @@ -import type { NextResponse } from 'next/server' -import type { V1BatchInsertTableRowsBody } from '@/lib/api/contracts/v1/tables' import { v2CreateTableRowsContract, v2DeleteTableRowsContract, v2ListTableRowsContract, v2UpdateRowsByFilterContract, } from '@/lib/api/contracts/v2/tables' -import { isZodError } from '@/lib/api/server' -import type { RowData, TableSchema } from '@/lib/table' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' import { - batchInsertRows, - buildIdByName, - deleteRowsByFilter, - deleteRowsByIds, - insertRow, - rowDataNameToId, - updateRowsByFilter, - validateBatchRows, - validateRowData, - validateRowSize, -} from '@/lib/table' + createTableRows, + deleteTableRows, + listTableRows, + updateTableRows, +} from '@/lib/table/application/rows' import { namedRowMapper } from '@/lib/table/cell-format' -import { TableQueryValidationError } from '@/lib/table/errors' -import { queryRows } from '@/lib/table/rows/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { type RateLimitResult, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - decodeCursor, - encodeCursor, - v2CursorList, - v2Data, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { - toApiRow, - v2BulkPredicateToFilter, - v2RowValidationError, - v2RowWriteError, - v2TableAccessError, -} from '@/app/api/v2/tables/utils' +import { decodeCursor, encodeCursor } from '@/app/api/v2/lib/response' +import { toApiRow } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * Inserts a validated batch of rows. Authorizes against the table's own - * workspace (IDOR guard) before any write, translates name-keyed row data to - * storage ids, and returns the inserted rows in the canonical v2 envelope. - */ -async function handleBatchInsert( - requestId: string, - tableId: string, - validated: V1BatchInsertTableRowsBody, - userId: string, - rateLimit: RateLimitResult -): Promise<NextResponse> { - const accessResult = await checkAccess(tableId, userId, 'write') - if (!accessResult.ok) return v2TableAccessError(accessResult) - - const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - // External callers key row data by column name; storage keys by id. - const idByName = buildIdByName(table.schema as TableSchema) - const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) - const rows = (validated.rows as RowData[]).map((r) => rowDataNameToId(r, idByName)) - - const validation = await validateBatchRows({ - rows, - schema: table.schema as TableSchema, - tableId, - }) - if (!validation.valid) return v2RowValidationError(validation.response) - - try { - const insertedRows = await batchInsertRows( - { tableId, rows, workspaceId: validated.workspaceId, userId }, - table, - requestId - ) - - return v2Data( - { - rows: insertedRows.map((r) => toApiRow(r, toNamedRow)), - insertedCount: insertedRows.length, - }, - { rateLimit } - ) - } catch (error) { - const response = v2RowWriteError(error) - if (response) return response - - throw error - } -} - -/** - * GET /api/v2/tables/[tableId]/rows — Plain cursor page over the default row - * order. Filtered/sorted reads go through `POST /query`. - */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListTableRowsContract, - rateLimitEndpoint: 'table-rows', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.query - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const accessResult = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') - - const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) - - // Cursor-uniform v2 pagination: the opaque cursor encodes the underlying - // offset (upgradeable to keyset later without an interface change). Total row - // count is intentionally omitted here — it's available as `rowCount` on the table. - const offset = validated.cursor - ? (decodeCursor<{ offset: number }>(validated.cursor)?.offset ?? 0) - : 0 - - const result = await queryRows( - table, - { - limit: validated.limit, - offset, - includeTotal: true, - withExecutions: false, - }, - requestId - ) - - const total = result.totalCount ?? 0 - const hasMore = offset + result.rowCount < total - const nextCursor = hasMore ? encodeCursor({ offset: offset + validated.limit }) : null - - return v2CursorList( - result.rows.map((r) => toApiRow(r, toNamedRow)), - nextCursor, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - throw error + operation: tableOperations.listRows, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, query }) => ({ + tableId: params.tableId, + assertedWorkspaceId: query.workspaceId, + limit: query.limit, + offset: query.cursor ? (decodeCursor<{ offset: number }>(query.cursor)?.offset ?? 0) : 0, + }), + useCase: listTableRows, + present: ({ table, rows, nextOffset }) => { + const toNamedRow = namedRowMapper(table.schema.columns) + return { + data: rows.map((row) => toApiRow(row, toNamedRow)), + nextCursor: nextOffset === null ? null : encodeCursor({ offset: nextOffset }), } }, }) -/** POST /api/v2/tables/[tableId]/rows — Insert row(s). Supports single or batch. */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateTableRowsContract, - rateLimitEndpoint: 'table-rows', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - - if ('rows' in input.body) { - const batchValidated = input.body - const scopeError = await resolveWorkspaceScope(rateLimit, batchValidated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - return handleBatchInsert(requestId, tableId, batchValidated, userId, rateLimit) - } - - const validated = input.body - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const accessResult = await checkAccess(tableId, userId, 'write') - if (!accessResult.ok) return v2TableAccessError(accessResult) - - const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const idByName = buildIdByName(table.schema as TableSchema) - const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) - const rowData = rowDataNameToId(validated.data as RowData, idByName) - - const validation = await validateRowData({ - rowData, - schema: table.schema as TableSchema, - tableId, - }) - if (!validation.valid) return v2RowValidationError(validation.response) - - const row = await insertRow( - { tableId, data: rowData, workspaceId: validated.workspaceId, userId }, - table, - requestId - ) - - return v2Data({ row: toApiRow(row, toNamedRow) }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const response = v2RowWriteError(error) - if (response) return response - - throw error - } + operation: tableOperations.createRows, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => + 'rows' in body + ? { + kind: 'batch' as const, + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + rows: body.rows, + } + : { + kind: 'single' as const, + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + data: body.data, + }, + useCase: createTableRows, + present: (result) => { + const toNamedRow = namedRowMapper(result.table.schema.columns) + return result.kind === 'single' + ? { data: { row: toApiRow(result.row, toNamedRow) } } + : { + data: { + rows: result.rows.map((row) => toApiRow(row, toNamedRow)), + insertedCount: result.rows.length, + }, + } }, }) -/** PUT /api/v2/tables/[tableId]/rows — Bulk update rows by predicate filter. */ -export const PUT = withPublicApiRouteHandler({ +export const PUT = defineV2JsonRoute({ contract: v2UpdateRowsByFilterContract, - rateLimitEndpoint: 'table-rows', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const accessResult = await checkAccess(tableId, userId, 'write') - if (!accessResult.ok) return v2TableAccessError(accessResult) - - const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const idByName = buildIdByName(table.schema as TableSchema) - const patchData = rowDataNameToId(validated.data as RowData, idByName) - - const sizeValidation = validateRowSize(patchData) - if (!sizeValidation.valid) { - return v2Error('BAD_REQUEST', 'Invalid row data', { details: sizeValidation.errors }) - } - - const result = await updateRowsByFilter( - table, - { - filter: v2BulkPredicateToFilter(validated.filter, table.schema as TableSchema), - data: patchData, - limit: validated.limit, - actorUserId: userId, - }, - requestId - ) - - // v2 always returns `updatedRowIds` ([] when nothing matched); v1 dropped it - // on the zero-match branch. - return v2Data( - { updatedCount: result.affectedCount, updatedRowIds: result.affectedRowIds }, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - const response = v2RowWriteError(error) - if (response) return response - - throw error - } - }, + operation: tableOperations.updateRows, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => ({ + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + filter: body.filter, + data: body.data, + limit: body.limit, + }), + useCase: updateTableRows, + present: ({ affectedCount, affectedRowIds }) => ({ + data: { updatedCount: affectedCount, updatedRowIds: affectedRowIds }, + }), }) -/** DELETE /api/v2/tables/[tableId]/rows — Delete rows by predicate filter or IDs. */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteTableRowsContract, - rateLimitEndpoint: 'table-rows', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const accessResult = await checkAccess(tableId, userId, 'write') - if (!accessResult.ok) return v2TableAccessError(accessResult) - - const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - // id-based and filter-based deletes share one envelope; `requestedCount`/ - // `missingRowIds` are populated only for the id-based delete (which has a - // requested set) and omitted for the filter-based delete. - if (validated.rowIds) { - const result = await deleteRowsByIds( - table, - { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, - requestId - ) - - return v2Data( - { + operation: tableOperations.deleteRows, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => + body.rowIds + ? { + kind: 'ids' as const, + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + rowIds: body.rowIds, + } + : { + kind: 'filter' as const, + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + filter: body.filter!, + limit: body.limit, + }, + useCase: deleteTableRows, + present: (result) => + result.kind === 'ids' + ? { + data: { deletedCount: result.deletedCount, deletedRowIds: result.deletedRowIds, requestedCount: result.requestedCount, missingRowIds: result.missingRowIds, }, - { rateLimit } - ) - } - - const result = await deleteRowsByFilter( - table, - { - filter: v2BulkPredicateToFilter(validated.filter!, table.schema as TableSchema), - limit: validated.limit, + } + : { + data: { + deletedCount: result.affectedCount, + deletedRowIds: result.affectedRowIds, + }, }, - requestId - ) - - return v2Data( - { deletedCount: result.affectedCount, deletedRowIds: result.affectedRowIds }, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - const response = v2RowWriteError(error) - if (response) return response - - throw error - } - }, }) 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 new file mode 100644 index 00000000000..ddcd5106649 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.test.ts @@ -0,0 +1,137 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { + class MockTableRowsValidationError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + upsertRow: vi.fn(), + }, + MockTableRowsValidationError, + } +}) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/rows', () => ({ + TableRowsValidationError: MockTableRowsValidationError, + upsertTableRow: { operation: { id: 'tables.rows.upsert' }, execute: mocks.upsertRow }, +})) + +import { POST } from '@/app/api/v2/tables/[tableId]/rows/upsert/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE = { + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 0, +} +const TABLE = { + id: 'table-1', + workspaceId: WORKSPACE_ID, + schema: { columns: [{ id: 'column-email', name: 'email', type: 'string' as const }] }, +} +const ROW = { + id: 'row-1', + data: { 'column-email': 'ada@example.com' }, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} + +describe('POST /api/v2/tables/[tableId]/rows/upsert', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE) + mocks.operationRate.mockResolvedValue(RATE) + mocks.gate.mockResolvedValue(null) + mocks.upsertRow.mockResolvedValue({ table: TABLE, row: ROW, operation: 'update' }) + }) + + it('delegates the public conflict-target name unchanged for canonical ID resolution', async () => { + const request = new NextRequest('http://localhost/api/v2/tables/table-1/rows/upsert', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + data: { email: 'ada@example.com' }, + conflictTarget: 'email', + }), + }) + const response = await POST(request, { + params: Promise.resolve({ tableId: 'table-1' }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + row: { + id: 'row-1', + data: { email: 'ada@example.com' }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, + operation: 'update', + }, + }) + expect(mocks.upsertRow).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + data: { email: 'ada@example.com' }, + conflictTarget: 'email', + }, + request, + }) + }) + + it('rejects an empty conflict target before delegation', async () => { + const request = new NextRequest('http://localhost/api/v2/tables/table-1/rows/upsert', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + data: { email: 'ada@example.com' }, + conflictTarget: '', + }), + }) + const response = await POST(request, { + params: Promise.resolve({ tableId: 'table-1' }), + }) + + expect(response.status).toBe(400) + expect(mocks.upsertRow).not.toHaveBeenCalled() + }) +}) 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 346b8a74b15..6c2d84285de 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 @@ -1,68 +1,31 @@ import { v2UpsertTableRowContract } from '@/lib/api/contracts/v2/tables' -import { isZodError } from '@/lib/api/server' -import type { RowData, TableSchema } from '@/lib/table' -import { buildIdByName, rowDataNameToId, upsertRow } from '@/lib/table' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' +import { upsertTableRow } from '@/lib/table/application/rows' import { namedRowMapper } from '@/lib/table/cell-format' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { toApiRow, v2TableAccessError } from '@/app/api/v2/tables/utils' +import { toApiRow } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** POST /api/v2/tables/[tableId]/rows/upsert — Insert or update a row based on unique columns. */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2UpsertTableRowContract, - rateLimitEndpoint: 'table-rows', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const idByName = buildIdByName(table.schema as TableSchema) - const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) - const upsertResult = await upsertRow( - { - tableId, - workspaceId: validated.workspaceId, - data: rowDataNameToId(validated.data as RowData, idByName), - userId, - conflictTarget: validated.conflictTarget, - }, - table, - requestId - ) - - return v2Data( - { row: toApiRow(upsertResult.row, toNamedRow), operation: upsertResult.operation }, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - throw error - } - }, + operation: tableOperations.upsertRow, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => ({ + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + data: body.data, + conflictTarget: body.conflictTarget, + }), + useCase: upsertTableRow, + present: ({ table, row, operation }) => ({ + data: { + row: toApiRow(row, namedRowMapper(table.schema.columns)), + operation, + }, + }), }) 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 be8a5e6bd33..eb69a72d64d 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 @@ -1,252 +1,125 @@ /** * @vitest-environment node - * - * Public v2 saved-view detail: read, patch, delete. A view that is not on this - * table is a 404 rather than a silent no-op, so a caller can tell a wrong id - * from a successful write. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockGetTableView, - mockUpdateTableView, - mockDeleteTableView, - mockGateError, - mockGetRequiredUserEmail, - TableViewValidationError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockGetTableView: vi.fn(), - mockUpdateTableView: vi.fn(), - mockDeleteTableView: vi.fn(), - mockGateError: vi.fn(), - mockGetRequiredUserEmail: vi.fn(), - TableViewValidationError: class TableViewValidationError extends Error {}, -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + read: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + email: vi.fn(), })) -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record<string, unknown>) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/lib/table', () => ({ - getTableView: mockGetTableView, - updateTableView: mockUpdateTableView, - deleteTableView: mockDeleteTableView, - TableViewValidationError, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - -vi.mock('@/lib/users/queries', () => ({ - getRequiredUserEmail: mockGetRequiredUserEmail, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/views', () => ({ + readTableViewUseCase: { operation: { id: 'tables.views.read' }, execute: mocks.read }, + updateTableViewUseCase: { operation: { id: 'tables.views.update' }, execute: mocks.update }, + deleteTableViewUseCase: { operation: { id: 'tables.views.delete' }, execute: mocks.remove }, })) +vi.mock('@/lib/users/queries', () => ({ getRequiredUserEmail: mocks.email })) import { DELETE, GET, PATCH } from '@/app/api/v2/tables/[tableId]/views/[viewId]/route' -const COLUMNS = [{ id: 'col-1', name: 'status', type: 'string' }] -const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } } -const VIEW = { - id: 'view-1', - tableId: 'table-1', - name: 'Active', - config: {}, - isDefault: false, - createdBy: 'user-1', - createdAt: new Date('2026-01-01T00:00:00Z'), - updatedAt: new Date('2026-01-02T00:00:00Z'), +const WORKSPACE_ID = 'workspace-1' +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', } -const API_VIEW = { - id: VIEW.id, - tableId: VIEW.tableId, - name: VIEW.name, - config: VIEW.config, - isDefault: VIEW.isDefault, - createdByEmail: 'ada@example.com', - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-02T00:00:00.000Z', +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -const RATE_LIMIT_OK = { +const rate = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -const params = { params: Promise.resolve({ tableId: 'table-1', viewId: 'view-1' }) } - -function callGet() { - return GET( - new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1?workspaceId=ws-1', { - method: 'GET', - }), - params - ) + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, } - -function callPatch(body: unknown) { - return PATCH( - new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - params - ) +const view = { + id: 'view-1', + tableId: 'table-1', + name: 'Active', + config: {}, + isDefault: true, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), } - -function callDelete() { - return DELETE( - new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1?workspaceId=ws-1', { - method: 'DELETE', - }), - params +const context = { params: Promise.resolve({ tableId: 'table-1', viewId: 'view-1' }) } + +function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { + return new NextRequest( + `http://localhost:3000/api/v2/tables/table-1/views/view-1${method === 'PATCH' ? '' : `?workspaceId=${WORKSPACE_ID}`}`, + { + method, + headers: { 'x-api-key': 'secret', ...(body ? { 'content-type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + } ) } -beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockGateError.mockResolvedValue(null) - mockGetRequiredUserEmail.mockResolvedValue('ada@example.com') -}) - -describe('GET /api/v2/tables/[tableId]/views/[viewId]', () => { - it('returns the view scoped to its table', async () => { - mockGetTableView.mockResolvedValue(VIEW) - - const res = await callGet() - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ view: API_VIEW }) - expect(mockGetTableView).toHaveBeenCalledWith('view-1', 'table-1', COLUMNS) - }) - - it('404s a view id that belongs to a different table', async () => { - mockGetTableView.mockResolvedValue(null) - - const res = await callGet() - - expect(res.status).toBe(404) - expect((await res.json()).error.message).toBe('View not found') - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callGet() - - expect(res.status).toBe(429) - expect(mockGetTableView).not.toHaveBeenCalled() +describe('/api/v2/tables/[tableId]/views/[viewId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) + mocks.read.mockResolvedValue({ view }) + mocks.update.mockResolvedValue({ view, changed: false }) + mocks.remove.mockResolvedValue({ viewId: 'view-1' }) + mocks.email.mockResolvedValue('user@example.com') }) -}) -describe('PATCH /api/v2/tables/[tableId]/views/[viewId]', () => { - it('forwards the patch fields to the service', async () => { - mockUpdateTableView.mockResolvedValue({ ...VIEW, isDefault: true }) + it('reads the view through canonical table and view identities', async () => { + const req = request('GET') + const response = await GET(req, context) - const res = await callPatch({ workspaceId: 'ws-1', isDefault: true }) - - expect(res.status).toBe(200) - expect((await res.json()).data.view.isDefault).toBe(true) - expect(mockUpdateTableView).toHaveBeenCalledWith({ - viewId: 'view-1', - tableId: 'table-1', - name: undefined, - config: undefined, - configPatch: undefined, - isDefault: true, - columns: COLUMNS, + expect(response.status).toBe(200) + expect((await response.json()).data.view.id).toBe('view-1') + expect(mocks.read).toHaveBeenCalledWith({ + principal, + input: { tableId: 'table-1', viewId: 'view-1', workspaceId: WORKSPACE_ID }, + request: req, }) }) - it('400s a body that changes nothing', async () => { - const res = await callPatch({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(400) - expect(mockUpdateTableView).not.toHaveBeenCalled() - }) - - it('400s config and configPatch together', async () => { - const res = await callPatch({ workspaceId: 'ws-1', config: {}, configPatch: {} }) - - expect(res.status).toBe(400) - expect(mockUpdateTableView).not.toHaveBeenCalled() - }) - - it('403s a read-only member', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callPatch({ workspaceId: 'ws-1', isDefault: true }) - - expect(res.status).toBe(403) - expect(mockUpdateTableView).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) + it('preserves no-op PATCH response compatibility', async () => { + const response = await PATCH( + request('PATCH', { workspaceId: WORKSPACE_ID, name: 'Active' }), + context ) - const res = await callPatch({ workspaceId: 'ws-1', isDefault: true }) - - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - expect(mockUpdateTableView).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect((await response.json()).data.view.name).toBe('Active') }) -}) - -describe('DELETE /api/v2/tables/[tableId]/views/[viewId]', () => { - it('returns the deleted view id', async () => { - mockDeleteTableView.mockResolvedValue(true) - - const res = await callDelete() - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ id: 'view-1' }) - expect(mockDeleteTableView).toHaveBeenCalledWith('view-1', 'table-1') - }) - - it('404s when nothing was deleted rather than reporting a phantom success', async () => { - mockDeleteTableView.mockResolvedValue(false) - - const res = await callDelete() - - expect(res.status).toBe(404) - }) - - it('403s a read-only member', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - const res = await callDelete() + it('deletes through the authorized view use case', async () => { + const response = await DELETE(request('DELETE'), context) - expect(res.status).toBe(403) - expect(mockDeleteTableView).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: 'view-1' } }) + expect(mocks.remove).toHaveBeenCalledOnce() }) }) 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 0df7c7243c9..64de962a819 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 @@ -3,118 +3,58 @@ import { v2GetTableViewContract, v2UpdateTableViewContract, } from '@/lib/api/contracts/v2/tables' -import type { TableSchema } from '@/lib/table' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { tableOperations } from '@/lib/table/application/operations' import { - deleteTableView, - getTableView, - TableViewValidationError, - updateTableView, -} from '@/lib/table' + deleteTableViewUseCase, + readTableViewUseCase, + updateTableViewUseCase, +} from '@/lib/table/application/views' import { getRequiredUserEmail } from '@/lib/users/queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' -import { toApiView, v2TableAccessError } from '@/app/api/v2/tables/utils' +import { toApiView } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** GET /api/v2/tables/[tableId]/views/[viewId] — One saved view. */ -export const GET = withPublicApiRouteHandler({ - contract: v2GetTableViewContract, - rateLimitEndpoint: 'table-view-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { tableId, viewId } = input.params - const { workspaceId } = input.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!result.ok || result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const view = await getTableView(viewId, tableId, (result.table.schema as TableSchema).columns) - if (!view) return v2Error('NOT_FOUND', 'View not found') +async function presentView(result: { view: Parameters<typeof toApiView>[0] }) { + const { view } = result + return { + data: { + view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null), + }, + } +} - return v2Data( - { view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null) }, - { rateLimit } - ) - }, +export const GET = defineV2JsonRoute({ + contract: v2GetTableViewContract, + operation: tableOperations.readView, + useCase: readTableViewUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, query }) => ({ ...params, workspaceId: query.workspaceId }), + present: presentView, }) -/** - * PATCH /api/v2/tables/[tableId]/views/[viewId] — Rename, replace or merge the - * config, or promote the view to the table's default. - */ -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2UpdateTableViewContract, - rateLimitEndpoint: 'table-view-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { tableId, viewId } = input.params - const { workspaceId, name, config, configPatch, isDefault } = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const view = await updateTableView({ - viewId, - tableId, - name, - config, - configPatch, - isDefault, - columns: (result.table.schema as TableSchema).columns, - }) - if (!view) return v2Error('NOT_FOUND', 'View not found') - - return v2Data( - { - view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null), - }, - { rateLimit } - ) - } catch (error) { - if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) - - throw error - } - }, + operation: tableOperations.updateView, + useCase: updateTableViewUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ params, body }) => ({ ...params, ...body }), + present: presentView, }) -/** DELETE /api/v2/tables/[tableId]/views/[viewId] — Remove a saved view. */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteTableViewContract, - rateLimitEndpoint: 'table-view-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { tableId, viewId } = input.params - const { workspaceId } = input.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const deleted = await deleteTableView(viewId, tableId) - if (!deleted) return v2Error('NOT_FOUND', 'View not found') - - return v2Data({ id: viewId }, { rateLimit }) - }, + operation: tableOperations.deleteView, + useCase: deleteTableViewUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ params, query }) => ({ ...params, workspaceId: query.workspaceId }), + present: ({ viewId }) => ({ data: { id: viewId } }), }) 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 82ca191255e..0244927dcdf 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 @@ -1,221 +1,132 @@ /** * @vitest-environment node - * - * Public v2 saved views: list and create. A view is presentation state, so the - * read needs only `read` while saving one needs `write`. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockListTableViews, - mockCreateTableView, - mockGateError, - mockGetUserEmailsByIds, - mockGetRequiredUserEmail, - TableViewValidationError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockListTableViews: vi.fn(), - mockCreateTableView: vi.fn(), - mockGateError: vi.fn(), - mockGetUserEmailsByIds: vi.fn(), - mockGetRequiredUserEmail: vi.fn(), - TableViewValidationError: class TableViewValidationError extends Error {}, +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + list: vi.fn(), + create: vi.fn(), + emails: vi.fn(), + email: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record<string, unknown>) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/lib/table', () => ({ - listTableViews: mockListTableViews, - createTableView: mockCreateTableView, - TableViewValidationError, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/views', () => ({ + listTableViewsUseCase: { operation: { id: 'tables.views.list' }, execute: mocks.list }, + createTableViewUseCase: { operation: { id: 'tables.views.create' }, execute: mocks.create }, })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - vi.mock('@/lib/users/queries', () => ({ - getUserEmailsByIds: mockGetUserEmailsByIds, - getRequiredUserEmail: mockGetRequiredUserEmail, - requireResolvedUserEmail: (emails: Map<string, string>, userId: string) => emails.get(userId)!, + getUserEmailsByIds: mocks.emails, + getRequiredUserEmail: mocks.email, + requireResolvedUserEmail: (emails: Map<string, string>, userId: string) => emails.get(userId), })) import { GET, POST } from '@/app/api/v2/tables/[tableId]/views/route' -const COLUMNS = [{ id: 'col-1', name: 'status', type: 'string' }] -const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } } -const VIEW = { - id: 'view-1', - tableId: 'table-1', - name: 'Active', - config: { filter: { all: [{ field: 'col-1', op: 'eq', value: 'active' }] } }, - isDefault: true, - createdBy: 'user-1', - createdAt: new Date('2026-01-01T00:00:00Z'), - updatedAt: new Date('2026-01-02T00:00:00Z'), +const WORKSPACE_ID = 'workspace-1' +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', } -const API_VIEW = { - id: VIEW.id, - tableId: VIEW.tableId, - name: VIEW.name, - config: VIEW.config, - isDefault: VIEW.isDefault, - createdByEmail: 'ada@example.com', - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-02T00:00:00.000Z', +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -const RATE_LIMIT_OK = { +const rate = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, } - -function callGet() { - const req = new NextRequest( - 'http://localhost:3000/api/v2/tables/table-1/views?workspaceId=ws-1', - { method: 'GET' } - ) - return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) -} - -function callPost(body: unknown) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/views', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +const view = { + id: 'view-1', + tableId: 'table-1', + name: 'Active', + 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() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockGateError.mockResolvedValue(null) - mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) - mockGetRequiredUserEmail.mockResolvedValue('ada@example.com') -}) - -describe('GET /api/v2/tables/[tableId]/views', () => { - it('returns every view as one full page with ISO timestamps', async () => { - mockListTableViews.mockResolvedValue([VIEW]) - - const res = await callGet() - - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ data: [API_VIEW], nextCursor: null }) - // The columns are passed so stale references are pruned from each config. - expect(mockListTableViews).toHaveBeenCalledWith('table-1', COLUMNS) +const context = { params: Promise.resolve({ tableId: 'table-1' }) } + +describe('/api/v2/tables/[tableId]/views', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) + mocks.list.mockResolvedValue({ views: [view] }) + mocks.create.mockResolvedValue({ view }) + mocks.emails.mockResolvedValue(new Map([['user-1', 'user@example.com']])) + mocks.email.mockResolvedValue('user@example.com') }) - it('404s a table in another workspace without listing', async () => { - mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockListTableViews).not.toHaveBeenCalled() - }) - - it('masks a permission failure as 404 so table existence never leaks', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockListTableViews).not.toHaveBeenCalled() - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, + it('lists bounded views and resolves creator identities in the v2 presenter', async () => { + const req = new NextRequest( + `http://localhost:3000/api/v2/tables/table-1/views?workspaceId=${WORKSPACE_ID}` + ) + const response = await GET(req, context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: [ + { + id: 'view-1', + tableId: 'table-1', + name: 'Active', + config: {}, + isDefault: false, + createdByEmail: 'user@example.com', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + nextCursor: null, }) - - const res = await callGet() - - expect(res.status).toBe(429) - expect(mockListTableViews).not.toHaveBeenCalled() - }) -}) - -describe('POST /api/v2/tables/[tableId]/views', () => { - it('creates the view with the caller as author and answers 201', async () => { - mockCreateTableView.mockResolvedValue(VIEW) - - const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) - - expect(res.status).toBe(201) - expect((await res.json()).data).toEqual({ view: API_VIEW }) - expect(mockCreateTableView).toHaveBeenCalledWith({ - tableId: 'table-1', - workspaceId: 'ws-1', - name: 'Active', - config: {}, - userId: 'user-1', - columns: COLUMNS, + expect(mocks.list).toHaveBeenCalledWith({ + principal, + input: { tableId: 'table-1', workspaceId: WORKSPACE_ID }, + request: req, }) }) - it('400s a blank view name without touching the service', async () => { - const res = await callPost({ workspaceId: 'ws-1', name: ' ', config: {} }) - - expect(res.status).toBe(400) - expect(mockCreateTableView).not.toHaveBeenCalled() - }) - - it('403s a read-only member', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) - - expect(res.status).toBe(403) - expect(mockCreateTableView).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) - - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - expect(mockCreateTableView).not.toHaveBeenCalled() - }) - - it('surfaces a service-level view validation failure as 400', async () => { - mockCreateTableView.mockRejectedValue(new TableViewValidationError('View name cannot be empty')) - - const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) - - expect(res.status).toBe(400) - expect((await res.json()).error.message).toBe('View name cannot be empty') + it('creates through the authorized view use case and preserves 201', async () => { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/views', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, name: 'Active', config: {} }), + }) + const response = await POST(req, context) + + expect(response.status).toBe(201) + expect((await response.json()).data.view.createdByEmail).toBe('user@example.com') + expect(mocks.create).toHaveBeenCalledWith({ + principal, + input: { tableId: 'table-1', workspaceId: WORKSPACE_ID, name: 'Active', config: {} }, + request: req, + }) }) }) 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 841a5c8947e..cf87f47068f 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts @@ -1,98 +1,53 @@ import { v2CreateTableViewContract, v2ListTableViewsContract } from '@/lib/api/contracts/v2/tables' -import type { TableSchema } from '@/lib/table' -import { createTableView, listTableViews, TableViewValidationError } from '@/lib/table' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { tableOperations } from '@/lib/table/application/operations' +import { createTableViewUseCase, listTableViewsUseCase } from '@/lib/table/application/views' import { getRequiredUserEmail, getUserEmailsByIds, requireResolvedUserEmail, } from '@/lib/users/queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' -import { toApiView, v2TableAccessError } from '@/app/api/v2/tables/utils' +import { toApiView } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * GET /api/v2/tables/[tableId]/views — Every saved view on the table. - * - * A table carries a bounded set of views, so this is one full page and - * `nextCursor` is always `null`. - */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListTableViewsContract, - rateLimitEndpoint: 'table-views', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { tableId } = input.params - const { workspaceId } = input.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!result.ok || result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const views = await listTableViews(tableId, (result.table.schema as TableSchema).columns) - + operation: tableOperations.listViews, + useCase: listTableViewsUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), + present: async ({ views }) => { const emailByUserId = await getUserEmailsByIds( views.flatMap((view) => (view.createdBy ? [view.createdBy] : [])) ) - return v2CursorList( - views.map((view) => + return { + data: views.map((view) => toApiView( view, view.createdBy ? requireResolvedUserEmail(emailByUserId, view.createdBy) : null ) ), - null, - { rateLimit } - ) + nextCursor: null, + } }, }) -/** POST /api/v2/tables/[tableId]/views — Save a filter/sort/layout as a named view. */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateTableViewContract, - rateLimitEndpoint: 'table-views', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { tableId } = input.params - const { workspaceId, name, config } = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const view = await createTableView({ - tableId, - workspaceId, - name, - config, - userId, - columns: (result.table.schema as TableSchema).columns, - }) - - return v2Data( - { - view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null), - }, - { rateLimit, status: 201 } - ) - } catch (error) { - if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) - - throw error - } - }, + operation: tableOperations.createView, + useCase: createTableViewUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: async ({ view }) => ({ + data: { + view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null), + }, + }), }) diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts index d6902239d8f..88c00c6a9e1 100644 --- a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts @@ -1,49 +1,22 @@ import { v2TableExportDownloadContract } from '@/lib/api/contracts/v2/tables' -import { requireTableExport, tableExportResult } from '@/lib/table/orchestration/export-resource' -import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { downloadTableExportUseCase } from '@/lib/table/application/exports' +import { tableOperations } from '@/lib/table/application/operations' -const DOWNLOAD_TTL_SECONDS = 60 * 60 +export const dynamic = 'force-dynamic' +export const revalidate = 0 -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2TableExportDownloadContract, - rateLimitEndpoint: 'table-export', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { workspaceId } = input.query - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - const record = await requireTableExport(input.params.exportId, workspaceId) - const access = await checkAccess(record.tableId, userId, 'read') - if (!access.ok || access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table export not found') - } - const result = tableExportResult(record) - const url = await generatePresignedDownloadUrl( - result.resultKey, - 'workspace', - DOWNLOAD_TTL_SECONDS - ) - return v2Data( - { - url, - fileName: result.resultKey.split('/').pop() ?? `export.${result.format}`, - expiresAt: new Date(Date.now() + DOWNLOAD_TTL_SECONDS * 1000).toISOString(), - }, - { rateLimit } - ) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.downloadExport, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealExportAuthorization, + mapInput: ({ params, query }) => ({ + exportId: params.exportId, + workspaceId: query.workspaceId, + }), + useCase: downloadTableExportUseCase, + present: (result) => ({ data: result }), }) diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts index 721652921f4..61c18650cdf 100644 --- a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts @@ -2,62 +2,38 @@ import { v2CancelTableExportContract, v2GetTableExportContract, } from '@/lib/api/contracts/v2/tables' -import { - cancelTableExportResource, - requireTableExport, - toV2TableExport, -} from '@/lib/table/orchestration/export-resource' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { cancelTableExportUseCase, readTableExportUseCase } from '@/lib/table/application/exports' +import { tableOperations } from '@/lib/table/application/operations' -async function authorizeExport(exportId: string, workspaceId: string, userId: string) { - const record = await requireTableExport(exportId, workspaceId) - const access = await checkAccess(record.tableId, userId, 'read') - if (!access.ok || access.table.workspaceId !== workspaceId) return null - return record -} +export const dynamic = 'force-dynamic' +export const revalidate = 0 -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetTableExportContract, - rateLimitEndpoint: 'table-export', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { workspaceId } = input.query - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - const record = await authorizeExport(input.params.exportId, workspaceId, userId) - if (!record) return v2Error('NOT_FOUND', 'Table export not found') - return v2Data(toV2TableExport(record), { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.readExport, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealExportAuthorization, + mapInput: ({ params, query }) => ({ + exportId: params.exportId, + workspaceId: query.workspaceId, + }), + useCase: readTableExportUseCase, + present: ({ export: tableExport }) => ({ data: tableExport }), }) -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2CancelTableExportContract, - rateLimitEndpoint: 'table-export', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { workspaceId } = input.query - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - const record = await authorizeExport(input.params.exportId, workspaceId, userId) - if (!record) return v2Error('NOT_FOUND', 'Table export not found') - return v2Data(toV2TableExport(await cancelTableExportResource(record)), { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.cancelExport, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealExportAuthorization, + mapInput: ({ params, query }) => ({ + exportId: params.exportId, + workspaceId: query.workspaceId, + }), + useCase: cancelTableExportUseCase, + present: ({ export: tableExport }) => ({ data: tableExport }), }) diff --git a/apps/sim/app/api/v2/tables/folders/route.test.ts b/apps/sim/app/api/v2/tables/folders/route.test.ts new file mode 100644 index 00000000000..866438a058c --- /dev/null +++ b/apps/sim/app/api/v2/tables/folders/route.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/folders', () => ({ + listTableFoldersUseCase: { operation: { id: 'tables.folders.list' }, execute: mocks.list }, + createTableFolderUseCase: { operation: { id: 'tables.folders.create' }, execute: mocks.create }, + updateTableFolderUseCase: { operation: { id: 'tables.folders.update' }, execute: mocks.update }, + deleteTableFolderUseCase: { operation: { id: 'tables.folders.delete' }, execute: mocks.remove }, +})) + +import { DELETE, GET, PATCH, POST } from '@/app/api/v2/tables/folders/route' + +const WORKSPACE_ID = 'workspace-1' +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const rate = { + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, +} +const folder = { + id: 'folder-1', + workspaceId: WORKSPACE_ID, + userId: 'owner-1', + resourceType: 'table' as const, + name: 'Reports', + parentId: null, + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} +const index = { + rowById: new Map([['folder-1', folder]]), + pathById: new Map([['folder-1', '/Reports']]), + idByPath: new Map([['/Reports', 'folder-1']]), +} + +function request(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', url: string, body?: unknown) { + return new NextRequest(`http://localhost:3000${url}`, { + method, + headers: { 'x-api-key': 'secret', ...(body ? { 'content-type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) +} + +describe('/api/v2/tables/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) + mocks.list.mockResolvedValue({ folders: [folder], index }) + mocks.create.mockResolvedValue({ folder, index, path: '/Reports' }) + mocks.update.mockResolvedValue({ + folder, + index, + path: '/Reports', + sourcePath: '/Archive/Reports', + }) + mocks.remove.mockResolvedValue({ + path: '/Reports', + deleted: true, + deletedItems: { folders: 1, tables: 2 }, + }) + }) + + it('lists canonical paths through the folder read use case', async () => { + const req = request('GET', `/api/v2/tables/folders?workspaceId=${WORKSPACE_ID}`) + const response = await GET(req) + + expect(response.status).toBe(200) + expect((await response.json()).data[0]).toMatchObject({ path: '/Reports', parentPath: '/' }) + expect(mocks.list).toHaveBeenCalledWith({ + principal, + input: expect.objectContaining({ workspaceId: WORKSPACE_ID }), + request: req, + }) + }) + + it('delegates create and relocate without route-local authorization', async () => { + const createResponse = await POST( + request('POST', '/api/v2/tables/folders', { + workspaceId: WORKSPACE_ID, + path: '/Reports', + }) + ) + const updateResponse = await PATCH( + request('PATCH', '/api/v2/tables/folders', { + workspaceId: WORKSPACE_ID, + path: '/Archive/Reports', + destinationPath: '/Reports', + }) + ) + + expect(createResponse.status).toBe(201) + expect(updateResponse.status).toBe(200) + expect(mocks.create).toHaveBeenCalledOnce() + expect(mocks.update).toHaveBeenCalledOnce() + }) + + it('returns authoritative recursive deletion counts', async () => { + const response = await DELETE( + request( + 'DELETE', + `/api/v2/tables/folders?workspaceId=${WORKSPACE_ID}&path=%2FReports&recursive=true` + ) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + path: '/Reports', + deleted: true, + deletedItems: { folders: 1, tables: 2 }, + }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/tables/folders/route.ts b/apps/sim/app/api/v2/tables/folders/route.ts index 3e885727a91..ee00df517bb 100644 --- a/apps/sim/app/api/v2/tables/folders/route.ts +++ b/apps/sim/app/api/v2/tables/folders/route.ts @@ -4,119 +4,63 @@ import { v2ListTableFoldersContract, v2RelocateTableFolderContract, } from '@/lib/api/contracts/v2/tables' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' import { - createFolderAtPath, - deleteFolderByPath, - relocateFolderByPath, -} from '@/lib/folders/orchestration' -import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { - resolveFolderPathId, - toV2PathFolder, - v2FolderPathMutationError, -} from '@/app/api/v2/lib/folders' -import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' + createTableFolderUseCase, + deleteTableFolderUseCase, + listTableFoldersUseCase, + updateTableFolderUseCase, +} from '@/lib/table/application/folders' +import { tableOperations } from '@/lib/table/application/operations' +import { toV2PathFolder } from '@/app/api/v2/lib/folders' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListTableFoldersContract, - rateLimitEndpoint: 'tables', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, parentPath, search, sortBy, sortOrder } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const index = await loadActiveFolderPathIndex(workspaceId, 'table') - const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) - if (parentPath !== undefined && parentId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found') - } - const rows = await listActiveFolderRows(workspaceId, 'table', { - parentId, - search, - sortBy, - sortOrder, - }) - return v2CursorList( - rows.map((row) => toV2PathFolder(row, index, false)), - null, - { rateLimit } - ) - }, + operation: tableOperations.listFolders, + useCase: listTableFoldersUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ query }) => query, + present: ({ folders, index }) => ({ + data: folders.map((folder) => toV2PathFolder(folder, index, false)), + nextCursor: null, + }), }) -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateTableFolderContract, - rateLimitEndpoint: 'tables', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await createFolderAtPath({ resourceType: 'table', workspaceId, userId, path }) - if (!result.success || !result.folder) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'table') - return v2Data( - { folder: toV2PathFolder(result.folder, index, false) }, - { rateLimit, status: 201 } - ) - }, + operation: tableOperations.createFolder, + useCase: createTableFolderUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ body }) => body, + present: ({ folder, index }) => ({ data: { folder: toV2PathFolder(folder, index, false) } }), }) -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2RelocateTableFolderContract, - rateLimitEndpoint: 'tables', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path, destinationPath } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await relocateFolderByPath({ - resourceType: 'table', - workspaceId, - userId, - path, - destinationPath, - }) - if (!result.success || !result.folder) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'table') - return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit }) - }, + operation: tableOperations.updateFolder, + useCase: updateTableFolderUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ body }) => body, + present: ({ folder, index }) => ({ data: { folder: toV2PathFolder(folder, index, false) } }), }) -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteTableFolderContract, - rateLimitEndpoint: 'tables', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path, recursive } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await deleteFolderByPath({ - resourceType: 'table', - workspaceId, - userId, - path, - recursive, - }) - if (!result.success || !result.deletedItems) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') - } - return v2Data( - { - path, - deleted: true as const, - deletedItems: { - folders: result.deletedItems.folders, - tables: result.deletedItems.tables ?? 0, - }, - }, - { rateLimit } - ) - }, + operation: tableOperations.deleteFolder, + useCase: deleteTableFolderUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ query }) => query, + present: ({ path, deleted, deletedItems }) => ({ data: { path, deleted, deletedItems } }), }) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts index 137221ed511..de1c14d4349 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts @@ -1,136 +1,101 @@ /** * @vitest-environment node */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockGetOwnedTableImportUpload, - mockFindOwnedTableImport, - mockStartUploadedTableImport, - mockToV2TableImport, - mockCompleteUploadSession, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockGetOwnedTableImportUpload: vi.fn(), - mockFindOwnedTableImport: vi.fn(), - mockStartUploadedTableImport: vi.fn(), - mockToV2TableImport: vi.fn(), - mockCompleteUploadSession: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + complete: vi.fn(), })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/v2/tables/utils', () => ({ - v2TableLockError: vi.fn().mockReturnValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/lib/table/orchestration/import-resource', () => ({ - findOwnedTableImport: mockFindOwnedTableImport, - getOwnedTableImportUpload: mockGetOwnedTableImportUpload, - startUploadedTableImport: mockStartUploadedTableImport, - toV2TableImport: mockToV2TableImport, -})) - -vi.mock('@/lib/uploads/upload-session/service', () => ({ - completeUploadSession: mockCompleteUploadSession, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/imports', () => ({ + completeTableImportUseCase: { + operation: { id: 'tables.imports.complete' }, + execute: mocks.complete, + }, })) import { POST } from '@/app/api/v2/tables/imports/[importId]/complete/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' -const RATE_LIMIT = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2026-08-03T22:00:00.000Z'), -} -const UPLOAD = { - id: 'import-1', +const principal = { + kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, - userId: 'user-1', + keyId: 'key-1', } - -function request() { - return POST( - new NextRequest( - `http://localhost:3000/api/v2/tables/imports/import-1/complete?workspaceId=${WORKSPACE_ID}`, - { - method: 'POST', - headers: { - 'upload-token': 'signed-upload-token', - }, - } - ), - { params: Promise.resolve({ importId: 'import-1' }) } - ) +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const rate = { + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, } describe('POST /api/v2/tables/imports/[importId]/complete', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockGetOwnedTableImportUpload.mockReturnValue(UPLOAD) + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) }) - it('returns the existing table job when completion is retried', async () => { - const existing = { id: 'import-1', tableId: 'table-1', status: 'ready' } - const responseBody = { id: 'import-1', tableId: 'table-1', status: 'completed' } - mockFindOwnedTableImport.mockResolvedValue(existing) - mockToV2TableImport.mockReturnValue(responseBody) - - const response = await request() - - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ data: responseBody }) - expect(mockGetOwnedTableImportUpload).toHaveBeenCalledWith({ - importId: 'import-1', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - uploadToken: 'signed-upload-token', - }) - expect(mockFindOwnedTableImport).toHaveBeenCalledWith({ - importId: 'import-1', + it('delegates idempotent completion to the authorized import use case', async () => { + const timestamp = '2026-01-01T00:00:00.000Z' + const tableImport = { + id: 'import-1', workspaceId: WORKSPACE_ID, - userId: 'user-1', - }) - expect(mockCompleteUploadSession).not.toHaveBeenCalled() - expect(mockStartUploadedTableImport).not.toHaveBeenCalled() - }) - - it('completes by upload id and starts the import job', async () => { - const started = { id: 'import-1', tableId: 'table-1', status: 'running' } - const responseBody = { id: 'import-1', tableId: 'table-1', status: 'processing' } - mockFindOwnedTableImport.mockResolvedValue(null) - mockCompleteUploadSession.mockResolvedValue({ - session: UPLOAD, - value: null, - alreadyCompleted: false, - }) - mockStartUploadedTableImport.mockResolvedValue(started) - mockToV2TableImport.mockReturnValue(responseBody) + status: 'completed', + source: { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 128 }, + target: { type: 'new', name: 'imported_data' }, + tableId: 'table-1', + rowsProcessed: 2, + error: null, + createdAt: timestamp, + updatedAt: timestamp, + completedAt: timestamp, + } + mocks.complete.mockResolvedValue({ import: tableImport }) + const request = new NextRequest( + `http://localhost:3000/api/v2/tables/imports/import-1/complete?workspaceId=${WORKSPACE_ID}`, + { method: 'POST', headers: { 'upload-token': 'signed-upload-token' } } + ) - const response = await request() + const response = await POST(request, { params: Promise.resolve({ importId: 'import-1' }) }) expect(response.status).toBe(200) - expect(mockCompleteUploadSession).toHaveBeenCalledWith({ - session: UPLOAD, - finalize: expect.any(Function), + expect(await response.json()).toEqual({ data: tableImport }) + expect(mocks.complete).toHaveBeenCalledWith({ + principal, + input: { + importId: 'import-1', + workspaceId: WORKSPACE_ID, + uploadToken: 'signed-upload-token', + }, + request, }) - expect(mockStartUploadedTableImport).toHaveBeenCalledWith(UPLOAD) - expect(await response.json()).toEqual({ data: responseBody }) }) }) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts index 626b4cc2f8b..00ac2393205 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts @@ -1,52 +1,23 @@ import { v2CompleteTableImportContract } from '@/lib/api/contracts/v2/tables' -import { - findOwnedTableImport, - getOwnedTableImportUpload, - startUploadedTableImport, - toV2TableImport, -} from '@/lib/table/orchestration/import-resource' -import { completeUploadSession } from '@/lib/uploads/upload-session/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { v2TableLockError } from '@/app/api/v2/tables/utils' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { completeTableImportUseCase } from '@/lib/table/application/imports' +import { tableOperations } from '@/lib/table/application/operations' -export const POST = withPublicApiRouteHandler({ +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const POST = defineV2JsonRoute({ contract: v2CompleteTableImportContract, - rateLimitEndpoint: 'table-import', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { workspaceId } = input.query - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - const upload = await getOwnedTableImportUpload({ - importId: input.params.importId, - workspaceId, - userId, - uploadToken: input.headers['upload-token'], - }) - const existing = await findOwnedTableImport({ - importId: upload.id, - workspaceId, - userId: upload.userId, - }) - if (existing) return v2Data(toV2TableImport(existing), { rateLimit }) - const completed = await completeUploadSession({ - session: upload, - finalize: async () => ({ value: null }), - }) - const started = await startUploadedTableImport(completed.session) - return v2Data(await toV2TableImport(started), { rateLimit }) - } catch (error) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.completeImport, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealImportAuthorization, + mapInput: ({ params, query, headers }) => ({ + importId: params.importId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + }), + useCase: completeTableImportUseCase, + present: ({ import: tableImport }) => ({ data: tableImport }), }) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts index 779c90acb5e..1f4ddb01d01 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts @@ -1,38 +1,24 @@ import { v2CreateTableImportPartUrlsContract } from '@/lib/api/contracts/v2/tables' -import { getOwnedTableImportUpload } from '@/lib/table/orchestration/import-resource' -import { createUploadPartUrls } from '@/lib/uploads/upload-session/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { createTableImportPartsUseCase } from '@/lib/table/application/imports' +import { tableOperations } from '@/lib/table/application/operations' -export const POST = withPublicApiRouteHandler({ +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const POST = defineV2JsonRoute({ contract: v2CreateTableImportPartUrlsContract, - rateLimitEndpoint: 'table-import', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - try { - const { workspaceId } = input.query - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - const session = await getOwnedTableImportUpload({ - importId: input.params.importId, - workspaceId, - userId, - uploadToken: input.headers['upload-token'], - }) - const parts = await createUploadPartUrls({ - session, - partNumbers: input.body.partNumbers, - localOrigin: request.nextUrl.origin, - }) - return v2Data({ parts }, { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.createImportParts, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealImportAuthorization, + mapInput: ({ params, query, headers, body }) => ({ + importId: params.importId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + partNumbers: body.partNumbers, + }), + useCase: createTableImportPartsUseCase, + present: (result) => ({ data: result }), }) 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 7b5587a3ff0..aba56dd3df1 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts @@ -2,68 +2,39 @@ import { v2CancelTableImportContract, v2GetTableImportContract, } from '@/lib/api/contracts/v2/tables' -import { - abortTableImportUpload, - cancelTableImportResource, - getOwnedTableImport, - toV2TableImport, -} from '@/lib/table/orchestration/import-resource' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { cancelTableImportUseCase, readTableImportUseCase } from '@/lib/table/application/imports' +import { tableOperations } from '@/lib/table/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetTableImportContract, - rateLimitEndpoint: 'table-import', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const scopeError = await resolveWorkspaceScope(rateLimit, input.query.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - const record = await getOwnedTableImport({ - importId: input.params.importId, - workspaceId: input.query.workspaceId, - userId, - }) - return v2Data(await toV2TableImport(record), { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.readImport, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealImportAuthorization, + mapInput: ({ params, query }) => ({ + importId: params.importId, + workspaceId: query.workspaceId, + }), + useCase: readTableImportUseCase, + present: ({ import: tableImport }) => ({ data: tableImport }), }) -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2CancelTableImportContract, - rateLimitEndpoint: 'table-import', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const scopeError = await resolveWorkspaceScope(rateLimit, input.query.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - const uploadToken = input.headers['upload-token'] - const record = uploadToken - ? await abortTableImportUpload({ - importId: input.params.importId, - workspaceId: input.query.workspaceId, - userId, - uploadToken, - }) - : await cancelTableImportResource( - await getOwnedTableImport({ - importId: input.params.importId, - workspaceId: input.query.workspaceId, - userId, - }) - ) - return v2Data(toV2TableImport(record), { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.cancelImport, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealImportAuthorization, + mapInput: ({ params, query, headers }) => ({ + importId: params.importId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + }), + useCase: cancelTableImportUseCase, + present: ({ import: tableImport }) => ({ data: 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 f5c6a5f7541..a0d53ee1880 100644 --- a/apps/sim/app/api/v2/tables/imports/route.test.ts +++ b/apps/sim/app/api/v2/tables/imports/route.test.ts @@ -1,67 +1,64 @@ /** * @vitest-environment node */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCreateTableImportResource, - mockToV2CreateTableImport, - mockLoadActiveFolderPathIndex, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCreateTableImportResource: vi.fn(), - mockToV2CreateTableImport: vi.fn(), - mockLoadActiveFolderPathIndex: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + create: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/app/api/v2/tables/utils', () => ({ - v2TableLockError: vi.fn().mockReturnValue(null), -})) - -vi.mock('@/lib/table/orchestration/import-resource', () => ({ - createTableImportResource: mockCreateTableImportResource, - toV2CreateTableImport: mockToV2CreateTableImport, -})) - -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/imports', () => ({ + createTableImportUseCase: { operation: { id: 'tables.imports.create' }, execute: mocks.create }, })) import { POST } from '@/app/api/v2/tables/imports/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' -const RATE_LIMIT = { +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const rate = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, remaining: 99, - resetAt: new Date('2026-08-03T22:00:00.000Z'), + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, } +const timestamp = '2026-01-01T00:00:00.000Z' describe('POST /api/v2/tables/imports', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map(), - pathById: new Map(), - idByPath: new Map(), - }) + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) }) it.each([ @@ -69,7 +66,19 @@ describe('POST /api/v2/tables/imports', () => { 'upload', { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 128 }, { - session: { id: 'import-1', source: { type: 'upload' } }, + session: { + id: 'import-1', + workspaceId: WORKSPACE_ID, + status: 'uploading', + source: { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 128 }, + target: { type: 'new', name: 'imported_data' }, + tableId: null, + rowsProcessed: 0, + error: null, + createdAt: timestamp, + updatedAt: timestamp, + completedAt: null, + }, uploadToken: 'signed-token', transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, }, @@ -78,70 +87,58 @@ describe('POST /api/v2/tables/imports', () => { 'workspace file', { type: 'workspace_file', fileId: 'file-1' }, { - session: { id: 'import-1', source: { type: 'workspace_file', fileId: 'file-1' } }, + session: { + id: 'import-1', + workspaceId: WORKSPACE_ID, + status: 'queued', + source: { type: 'workspace_file', fileId: 'file-1' }, + target: { type: 'new', name: 'imported_data' }, + tableId: 'table-1', + rowsProcessed: 0, + error: null, + createdAt: timestamp, + updatedAt: timestamp, + completedAt: null, + }, uploadToken: null, transfer: null, }, ], - ])('returns the create envelope for a %s source', async (_label, source, responseData) => { - const requestBody = { - workspaceId: WORKSPACE_ID, - source, - target: { type: 'new', name: 'imported_data' }, - } - const created = { record: { id: 'import-1' }, upload: null } - mockCreateTableImportResource.mockResolvedValue(created) - mockToV2CreateTableImport.mockReturnValue(responseData) - - const response = await POST( - new NextRequest('http://localhost:3000/api/v2/tables/imports', { + ])( + 'delegates a %s source to the authorized import use case', + async (_label, source, tableImport) => { + const body = { + workspaceId: WORKSPACE_ID, + source, + target: { type: 'new', name: 'imported_data' }, + } + mocks.create.mockResolvedValue({ import: tableImport }) + const request = new NextRequest('http://localhost:3000/api/v2/tables/imports', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), }) - ) - expect(response.status).toBe(201) - expect(mockCreateTableImportResource).toHaveBeenCalledWith( - requestBody, - 'user-1', - 'http://localhost:3000', - null - ) - expect(mockToV2CreateTableImport).toHaveBeenCalledWith(created) - expect(await response.json()).toEqual({ data: responseData }) - }) + const response = await POST(request) - it('accepts native JSON mapping and createColumns values', async () => { - const requestBody = { - workspaceId: WORKSPACE_ID, - source: { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 128 }, - target: { type: 'existing', tableId: 'table-1', mode: 'append' }, - mapping: { email: 'email_address', notes: null }, - createColumns: ['phone'], + expect(response.status).toBe(201) + expect(await response.json()).toEqual({ data: tableImport }) + expect(mocks.create).toHaveBeenCalledWith({ principal, input: { body }, request }) } - const created = { record: { id: 'import-1' }, upload: null } - const responseData = { - session: { id: 'import-1', source: { type: 'upload' } }, - uploadToken: 'signed-token', - transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, - } - mockCreateTableImportResource.mockResolvedValue(created) - mockToV2CreateTableImport.mockReturnValue(responseData) + ) + it('authenticates and rate-limits before rejecting an invalid source', async () => { const response = await POST( new NextRequest('http://localhost:3000/api/v2/tables/imports', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, source: {}, target: {} }), }) ) - expect(response.status).toBe(201) - expect(mockCreateTableImportResource).toHaveBeenCalledWith( - requestBody, - 'user-1', - 'http://localhost:3000' - ) + expect(response.status).toBe(400) + expect(mocks.authenticate).toHaveBeenCalled() + expect(mocks.operationRate).toHaveBeenCalled() + expect(mocks.create).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/tables/imports/route.ts b/apps/sim/app/api/v2/tables/imports/route.ts index 8a97f9f36ad..2fba1da2417 100644 --- a/apps/sim/app/api/v2/tables/imports/route.ts +++ b/apps/sim/app/api/v2/tables/imports/route.ts @@ -1,50 +1,19 @@ import { v2CreateTableImportContract } from '@/lib/api/contracts/v2/tables' -import { - createTableImportResource, - toV2CreateTableImport, -} from '@/lib/table/orchestration/import-resource' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { v2TableLockError } from '@/app/api/v2/tables/utils' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { createTableImportUseCase } from '@/lib/table/application/imports' +import { tableOperations } from '@/lib/table/application/operations' -export const POST = withPublicApiRouteHandler({ +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const POST = defineV2JsonRoute({ contract: v2CreateTableImportContract, - rateLimitEndpoint: 'table-import', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - try { - const scopeError = await resolveWorkspaceScope(rateLimit, input.body.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - let created: Awaited<ReturnType<typeof createTableImportResource>> - if (input.body.target.type === 'new') { - const resolution = await resolveFolderPathIdentity({ - workspaceId: input.body.workspaceId, - resourceType: 'table', - path: input.body.target.folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - created = await createTableImportResource( - input.body, - userId, - request.nextUrl.origin, - resolution.folderId - ) - } else { - created = await createTableImportResource(input.body, userId, request.nextUrl.origin) - } - return v2Data(toV2CreateTableImport(created), { rateLimit, status: 201 }) - } catch (error) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.createImport, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ body }) => ({ body }), + useCase: createTableImportUseCase, + present: ({ import: tableImport }) => ({ data: tableImport }), }) diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts index b5af7c73822..77141cb842c 100644 --- a/apps/sim/app/api/v2/tables/route.test.ts +++ b/apps/sim/app/api/v2/tables/route.test.ts @@ -1,292 +1,160 @@ /** * @vitest-environment node - * - * Public v2 tables list: auth/scope gating, rollout gate ordering, typed - * summary output in the `{ data, nextCursor }` envelope, private cache header. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { TableDefinition } from '@/lib/table/types' - -const { - mockQueryTables, - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockIsFeatureEnabled, - mockGetWorkspaceOrganizationId, - mockLoadActiveFolderPathIndex, - mockResolveFolderPathIdentity, - mockCreateTable, - mockGetWorkspaceTableLimits, -} = vi.hoisted(() => ({ - mockQueryTables: vi.fn(), - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockIsFeatureEnabled: vi.fn(), - mockGetWorkspaceOrganizationId: vi.fn(), - mockLoadActiveFolderPathIndex: vi.fn(), - mockResolveFolderPathIdentity: vi.fn(), - mockCreateTable: vi.fn(), - mockGetWorkspaceTableLimits: vi.fn(), -})) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + list: vi.fn(), + create: vi.fn(), })) -vi.mock('@/lib/table', async () => { - const actual = await import('@/lib/table/column-keys') - return { - ...actual, - queryTables: mockQueryTables, - createTable: mockCreateTable, - getWorkspaceTableLimits: mockGetWorkspaceTableLimits, - } -}) - -vi.mock('@/app/api/table/utils', () => ({ - normalizeColumn: (col: Record<string, unknown>) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/lib/core/config/feature-flags', () => ({ - isFeatureEnabled: mockIsFeatureEnabled, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/lib/workspaces/utils', () => ({ - getWorkspaceOrganizationId: mockGetWorkspaceOrganizationId, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) - -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, -})) - -vi.mock('@/app/api/v2/lib/folders', () => ({ - folderPathForId: (_index: unknown, folderId: string | null | undefined) => - folderId ? '/Reports' : '/', - resolveFolderPathId: ( - index: { idByPath: Map<string, string> }, - path: string - ): string | null | undefined => (path === '/' ? null : index.idByPath.get(path)), - resolveFolderPathIdentity: mockResolveFolderPathIdentity, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/tables', () => ({ + listTablesUseCase: { operation: { id: 'tables.list' }, execute: mocks.list }, + createTableUseCase: { operation: { id: 'tables.create' }, execute: mocks.create }, })) import { GET, POST } from '@/app/api/v2/tables/route' -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), +const WORKSPACE_ID = 'workspace-1' +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', } - -function buildTable(): TableDefinition { - return { - id: 'tbl_1', - name: 'People', - description: 'A table', - schema: { columns: [{ id: 'col_name', name: 'name', type: 'string' }] }, - metadata: null, - rowCount: 5, - maxRows: 100, - workspaceId: 'workspace-1', - createdBy: 'user-1', - archivedAt: null, - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-02'), - } +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -function callList(query: string) { - const req = new NextRequest(`http://localhost:3000/api/v2/tables?${query}`) - return GET(req) +const rate = { + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, } - -function callCreate(body: Record<string, unknown>) { - return POST( - new NextRequest('http://localhost:3000/api/v2/tables', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }) - ) +const table = { + id: 'table-1', + workspaceId: WORKSPACE_ID, + userId: 'owner-1', + name: 'Contacts', + description: null, + schema: { + columns: [ + { id: 'col-1', name: 'Name', type: 'string' as const, required: false, unique: false }, + ], + }, + rowCount: 0, + maxRows: 100, + folderId: null, + metadata: null, + locks: { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, + }, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), } -describe('GET /api/v2/tables', () => { +describe('/api/v2/tables', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: null }) - mockIsFeatureEnabled.mockResolvedValue(true) - mockGetWorkspaceOrganizationId.mockResolvedValue('org-1') - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map(), - pathById: new Map(), - idByPath: new Map(), + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) + mocks.list.mockResolvedValue({ + tables: [{ table, folderPath: '/' }], + nextKeys: undefined, + sortBy: 'name', + sortOrder: 'asc', }) + mocks.create.mockResolvedValue({ table, folderPath: '/' }) }) - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callList('workspaceId=workspace-1') - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockQueryTables).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callList('') - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockQueryTables).not.toHaveBeenCalled() - }) + 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` + ) + const response = await GET(request) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + data: [{ id: 'table-1', folderPath: '/', description: null }], + nextCursor: null, }) - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(403) - expect((await res.json()).error).toMatchObject({ code: 'FORBIDDEN', message: 'Access denied' }) - expect(mockQueryTables).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue({ - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, + expect(mocks.list).toHaveBeenCalledWith({ + principal, + input: expect.objectContaining({ workspaceId: WORKSPACE_ID, limit: 25 }), + request, }) - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') }) - it('400s on a sort field outside the enum instead of letting it reach the query', async () => { - const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - }) - - it('400s on a sort direction outside the enum', async () => { - const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) - - expect(res.status).toBe(400) - }) - - it('400s on an empty search rather than treating it as unsearched', async () => { - const res = await callList(`workspaceId=workspace-1&search=`) - - expect(res.status).toBe(400) - }) - - it('forwards search and sort into the query and still terminates pagination', async () => { - const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`) + it('authenticates and rate-limits before rejecting invalid query input', async () => { + const response = await GET(new NextRequest('http://localhost:3000/api/v2/tables')) - expect(res.status).toBe(200) - expect((await res.json()).nextCursor).toBeNull() + expect(response.status).toBe(400) + expect(mocks.authenticate).toHaveBeenCalled() + expect(mocks.operationRate).toHaveBeenCalled() + expect(mocks.list).not.toHaveBeenCalled() }) - it('treats folderPath=/ as root-only while omission lists every folder', async () => { - await callList('workspaceId=workspace-1&folderPath=%2F') + it('maps operation rate-limit infrastructure failures to service unavailable', async () => { + mocks.operationRate.mockRejectedValueOnce(new Error('rate store unavailable')) - expect(mockQueryTables).toHaveBeenCalledWith( - 'workspace-1', - expect.objectContaining({ folderId: null }) + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/tables?workspaceId=${WORKSPACE_ID}&limit=25`) ) - }) - - it('passes limit and the decoded cursor through to the query', async () => { - mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: null }) - - await callList('workspaceId=workspace-1&limit=25&sortBy=name&sortOrder=desc') - - // The slice must happen in the query, not after a full-workspace read. - expect(mockQueryTables).toHaveBeenCalledWith( - 'workspace-1', - expect.objectContaining({ limit: 25, sortBy: 'name', sortOrder: 'desc' }) - ) - }) - - it('returns a nextCursor when the query reports another page', async () => { - mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: ['Alpha', 'tbl_1'] }) - const res = await callList('workspaceId=workspace-1&limit=1') - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.nextCursor).toEqual(expect.any(String)) - }) - - it('rejects a cursor that does not match the requested sort', async () => { - const first = await callList('workspaceId=workspace-1&sortBy=name') - // Encoded under sortBy=name, replayed under sortBy=createdAt. - mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: ['Alpha', 'tbl_1'] }) - const paged = await callList('workspaceId=workspace-1&sortBy=name&limit=1') - const cursor = (await paged.json()).nextCursor - - const res = await callList( - `?workspaceId=workspace-1&sortBy=createdAt&cursor=${encodeURIComponent(cursor)}` - ) - - expect(res.status).toBe(400) - expect(first.status).toBe(200) - }) -}) - -describe('POST /api/v2/tables', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetWorkspaceTableLimits.mockResolvedValue({ maxTables: 100 }) - mockResolveFolderPathIdentity.mockResolvedValue({ - found: true, - folderId: 'folder-1', - index: { - rowById: new Map(), - pathById: new Map([['folder-1', '/Reports']]), - idByPath: new Map([['/Reports', 'folder-1']]), + expect(response.status).toBe(503) + expect(await response.json()).toEqual({ + error: { + code: 'SERVICE_UNAVAILABLE', + message: 'Service temporarily unavailable', }, }) - mockCreateTable.mockResolvedValue({ ...buildTable(), folderId: 'folder-1' }) + expect(mocks.list).not.toHaveBeenCalled() }) - it('resolves a slashless folder path before creating the table outside the folder lock', async () => { - const res = await callCreate({ - workspaceId: 'workspace-1', - name: 'People', - folderPath: 'Reports', - schema: { columns: [{ name: 'email', type: 'string' }] }, + it('creates through the shared use case and keeps the 201 response contract', async () => { + 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' }] }, + }), }) - - expect(res.status).toBe(201) - expect(mockResolveFolderPathIdentity).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - resourceType: 'table', - path: '/Reports', + const response = await POST(request) + + expect(response.status).toBe(201) + expect((await response.json()).data.table.id).toBe('table-1') + expect(mocks.create).toHaveBeenCalledWith({ + principal, + input: expect.objectContaining({ workspaceId: WORKSPACE_ID, name: 'Contacts' }), + request, }) - expect(mockCreateTable).toHaveBeenCalledWith( - expect.objectContaining({ folderId: 'folder-1' }), - expect.any(String) - ) - expect((await res.json()).data.table.folderPath).toBe('/Reports') }) }) diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index 7caef0960a8..a770fc09ea3 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -1,132 +1,58 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { v2CreateTableContract, v2ListTablesContract } from '@/lib/api/contracts/v2/tables' -import { isZodError } from '@/lib/api/server' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { createTable, getWorkspaceTableLimits, queryTables, type TableSchema } from '@/lib/table' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { normalizeColumn } from '@/app/api/table/utils' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { - folderPathForId, - resolveFolderPathId, - resolveFolderPathIdentity, -} from '@/app/api/v2/lib/folders' -import { - cursorSortKey, - decodeSortedCursor, - encodeSortedCursor, - v2CaughtOrchestrationError, - v2CursorList, - v2CursorSortError, - v2Data, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +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 { v2TableErrorPolicies } from '@/lib/table/api' +import { tableOperations } from '@/lib/table/application/operations' +import { createTableUseCase, listTablesUseCase } from '@/lib/table/application/tables' +import { cursorSortKey, decodeSortedCursor, encodeSortedCursor } from '@/app/api/v2/lib/response' import { toApiTable } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** GET /api/v2/tables — List all tables in a workspace. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListTablesContract, - rateLimitEndpoint: 'tables', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'table') - const folderId = - folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) - if (folderPath !== undefined && folderId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found') + operation: tableOperations.list, + useCase: listTablesUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ query }) => { + const sort = cursorSortKey(query.sortBy, query.sortOrder) + const decoded = decodeSortedCursor(query.cursor, sort) + if (decoded.status === 'invalid') { + throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) } - - const sort = cursorSortKey(sortBy, sortOrder) - const decoded = decodeSortedCursor(cursor, sort) - if (decoded.status === 'invalid') return v2CursorSortError() - - const { tables, nextKeys } = await queryTables(workspaceId, { - folderId, - search, - sortBy, - sortOrder, - limit, + return { + workspaceId: query.workspaceId, + folderPath: query.folderPath, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, after: decoded.status === 'ok' ? decoded.keys : undefined, - }) - - const items = tables.map((table) => - toApiTable(table, folderPathForId(folderIndex, table.folderId)) - ) - const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null - - return v2CursorList(items, nextCursor, { rateLimit }) + } }, + present: ({ tables, nextKeys, sortBy, sortOrder }) => ({ + data: tables.map(({ table, folderPath }) => toApiTable(table, folderPath)), + nextCursor: nextKeys ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextKeys) : null, + }), }) -/** POST /api/v2/tables — Create a new table. */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateTableContract, - rateLimitEndpoint: 'tables', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - try { - const params = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const planLimits = await getWorkspaceTableLimits(params.workspaceId) - - const normalizedSchema: TableSchema = { - columns: params.schema.columns.map(normalizeColumn), - } - - const resolution = await resolveFolderPathIdentity({ - workspaceId: params.workspaceId, - resourceType: 'table', - path: params.folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - - const table = await createTable( - { - name: params.name, - description: params.description, - schema: normalizedSchema, - workspaceId: params.workspaceId, - userId, - maxTables: planLimits.maxTables, - folderId: resolution.folderId, - }, - requestId - ) - - recordAudit({ - workspaceId: params.workspaceId, - actorId: userId, - action: AuditAction.TABLE_CREATED, - resourceType: AuditResourceType.TABLE, - resourceId: table.id, - resourceName: table.name, - description: `Created table "${table.name}" via API`, - metadata: { columnCount: params.schema.columns.length }, - request, - }) - - return v2Data( - { table: toApiTable(table, folderPathForId(resolution.index, table.folderId)) }, - { rateLimit, status: 201 } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - throw error - } - }, + operation: tableOperations.create, + useCase: createTableUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + name: body.name, + description: body.description, + schema: body.schema, + folderPath: body.folderPath, + }), + present: ({ table, folderPath }) => ({ data: { table: toApiTable(table, folderPath) } }), }) diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index 6f49b8deb77..b173e3a6936 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -57,7 +57,7 @@ export function toApiTable(table: TableDefinition, folderPath: string) { return { id: table.id, name: table.name, - description: table.description, + description: table.description ?? null, schema: { columns: (table.schema as TableSchema).columns.map(normalizeColumn), }, diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index ff4fe06f853..6a9d7857393 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -262,6 +262,7 @@ export const v2CreateTableContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2TableDataSchema), + status: 201, }, }) @@ -343,7 +344,7 @@ export const v2CreateTableFolderContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/folders', body: v2CreateFolderBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2TableFolderDataSchema) }, + response: { mode: 'json', schema: v2DataResponse(v2TableFolderDataSchema), status: 201 }, }) export const v2RelocateTableFolderContract = defineRouteContract({ @@ -648,6 +649,7 @@ export const v2CreateTableViewContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2TableViewDataSchema), + status: 201, }, }) @@ -834,6 +836,7 @@ export const v2AddWorkflowGroupContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2WorkflowGroupDataSchema), + status: 201, }, }) @@ -1131,7 +1134,11 @@ export const v2CreateTableImportContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/imports', body: v2CreateTableImportBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2CreateTableImportDataSchema) }, + response: { + mode: 'json', + schema: v2DataResponse(v2CreateTableImportDataSchema), + status: 201, + }, }) export const v2GetTableImportContract = defineRouteContract({ @@ -1198,7 +1205,7 @@ export const v2CreateTableExportContract = defineRouteContract({ path: '/api/v2/tables/[tableId]/exports', params: tableIdParamsSchema, body: exportTableAsyncBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema), status: 201 }, }) export const v2GetTableExportContract = defineRouteContract({ diff --git a/apps/sim/lib/copilot/application/execute-table-use-case.ts b/apps/sim/lib/copilot/application/execute-table-use-case.ts new file mode 100644 index 00000000000..536a05f9161 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-table-use-case.ts @@ -0,0 +1,63 @@ +import { + type CopilotTableDelegationContext, + resolveCopilotTablePrincipal, +} from '@/lib/copilot/auth/table-delegation' +import type { OperationUseCase } from '@/lib/core/application' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { + resolveActiveTableContext, + resolveTableWorkspaceContext, +} from '@/lib/table/application/context' +import { type TableOperation, tableOperations } from '@/lib/table/application/operations' + +const registeredTableOperationIds = new Set<string>( + Object.values(tableOperations).map((operation) => operation.id) +) + +interface ExecuteCopilotTableUseCaseOptions { + tableId?: string +} + +export interface AdmitCopilotTableOperationInput { + workspaceId: string + tableId?: string +} + +/** Enters a registered table application use case under trusted Copilot delegation. */ +export function executeCopilotTableUseCase<O extends TableOperation, I, R>( + context: CopilotTableDelegationContext | undefined, + useCase: OperationUseCase<O, I, R>, + input: I, + options: ExecuteCopilotTableUseCaseOptions = {} +): Promise<R> { + if (!registeredTableOperationIds.has(useCase.operation.id)) { + throw new Error(`Unregistered Copilot table operation: ${useCase.operation.id}`) + } + return useCase.execute({ + principal: resolveCopilotTablePrincipal(context, options.tableId), + input, + }) +} + +/** + * Authorizes a Copilot operation that still retains a compatibility-specific + * presenter or execution strategy before that trusted adapter invokes it. + */ +export function admitCopilotTableOperation<O extends TableOperation>( + context: CopilotTableDelegationContext | undefined, + operation: O, + input: AdmitCopilotTableOperationInput +): Promise<void> { + const useCase = defineAuthorizedTableUseCase({ + operation, + resolveContext: ({ input: admitted }: { input: AdmitCopilotTableOperationInput }) => + admitted.tableId + ? resolveActiveTableContext({ + tableId: admitted.tableId, + assertedWorkspaceId: admitted.workspaceId, + }) + : resolveTableWorkspaceContext(admitted.workspaceId), + async execute() {}, + }) + return executeCopilotTableUseCase(context, useCase, input, { tableId: input.tableId }) +} diff --git a/apps/sim/lib/copilot/auth/table-delegation.test.ts b/apps/sim/lib/copilot/auth/table-delegation.test.ts new file mode 100644 index 00000000000..33dc57ea01f --- /dev/null +++ b/apps/sim/lib/copilot/auth/table-delegation.test.ts @@ -0,0 +1,44 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { resolveCopilotTablePrincipal } from '@/lib/copilot/auth/table-delegation' + +describe('Copilot table delegation', () => { + const context = { + userId: 'user-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-call-1', + chatId: 'chat-1', + executionId: 'execution-1', + copilotToolExecution: true, + } as const + + it('binds the trusted workspace, subject, tool call, and table scope', () => { + expect(resolveCopilotTablePrincipal(context, 'table-1')).toMatchObject({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-call-1', + audience: 'sim:tables', + resourceScope: { + tableId: 'table-1', + chatId: 'chat-1', + executionId: 'execution-1', + }, + }) + }) + + it('rejects untrusted or incomplete contexts', () => { + expect(() => + resolveCopilotTablePrincipal({ ...context, copilotToolExecution: false }, 'table-1') + ).toThrow('trusted Copilot execution context') + expect(() => + resolveCopilotTablePrincipal({ ...context, workspaceId: undefined }, 'table-1') + ).toThrow('workspace ID') + expect(() => + resolveCopilotTablePrincipal({ ...context, toolCallId: undefined }, 'table-1') + ).toThrow('tool call ID') + }) +}) diff --git a/apps/sim/lib/copilot/auth/table-delegation.ts b/apps/sim/lib/copilot/auth/table-delegation.ts new file mode 100644 index 00000000000..219985dc404 --- /dev/null +++ b/apps/sim/lib/copilot/auth/table-delegation.ts @@ -0,0 +1,44 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { createTableDelegatedPrincipal } from '@/lib/table/application/delegated-principal' + +export interface CopilotTableDelegationContext { + userId: string + workspaceId?: string + chatId?: string + executionId?: string + toolCallId?: string + copilotToolExecution?: boolean +} + +/** Normalizes trusted Copilot execution context into the shared table principal. */ +export function resolveCopilotTablePrincipal( + context: CopilotTableDelegationContext | undefined, + tableId?: string +): DelegatedPrincipal { + if (!context) throw new Error('Table delegation requires a Copilot execution context') + if (!context.copilotToolExecution) { + throw new Error('Table delegation requires a trusted Copilot execution context') + } + if (!context.toolCallId) throw new Error('Table delegation requires a tool call ID') + if (!context.workspaceId) throw new Error('Table delegation requires a workspace ID') + + return createTableDelegatedPrincipal({ + serviceId: 'copilot', + subjectUserId: context.userId, + workspaceId: context.workspaceId, + delegationId: `copilot-tool:${context.toolCallId}`, + tableId, + chatId: context.chatId, + executionId: context.executionId, + }) +} + +export function messageForCopilotTableError( + error: unknown, + fallback = 'Table operation failed' +): string { + const classified = asOrchestrationError(error) + if (classified && classified.code !== 'internal') return classified.message + return fallback +} diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index 55ad05c06e7..9d0eb39c9a3 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -6,18 +6,18 @@ import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table' -const { mockGetTableById, mockReplaceTableRows, mockSpanAddEvent } = vi.hoisted(() => ({ - mockGetTableById: vi.fn(), +const { mockReadTable, mockReplaceTableRows, mockSpanAddEvent } = vi.hoisted(() => ({ + mockReadTable: vi.fn(), mockReplaceTableRows: vi.fn(), mockSpanAddEvent: vi.fn(), })) -vi.mock('@/lib/table/service', () => ({ - getTableById: mockGetTableById, +vi.mock('@/lib/table/application/tables', () => ({ + readTableUseCase: { execute: mockReadTable }, })) -vi.mock('@/lib/table/rows/service', () => ({ - replaceTableRows: mockReplaceTableRows, +vi.mock('@/lib/table/application/rows', () => ({ + replaceTableRows: { execute: mockReplaceTableRows }, })) vi.mock('@/lib/copilot/request/otel', () => ({ @@ -76,6 +76,8 @@ function buildContext(overrides: Partial<ExecutionContext> = {}): ExecutionConte workflowId: 'wf-1', workspaceId: 'workspace-1', userPermission: 'write', + copilotToolExecution: true, + toolCallId: 'tool-call-1', resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), ...overrides, } @@ -84,12 +86,15 @@ function buildContext(overrides: Partial<ExecutionContext> = {}): ExecutionConte describe('maybeWriteOutputToTable', () => { beforeEach(() => { vi.clearAllMocks() - mockGetTableById.mockResolvedValue(buildTable()) - mockReplaceTableRows.mockResolvedValue({ deletedCount: 0, insertedCount: 2 }) + mockReadTable.mockResolvedValue({ table: buildTable(), folderPath: '/' }) + mockReplaceTableRows.mockImplementation(async ({ input }: { input: { rows: unknown[] } }) => ({ + deletedCount: 0, + insertedCount: input.rows.length, + })) }) it('rejects a table from another workspace without touching it', async () => { - mockGetTableById.mockResolvedValue(buildTable({ workspaceId: 'other-workspace' })) + mockReadTable.mockRejectedValue(new Error('Table not found')) const result = await maybeWriteOutputToTable( FunctionExecute.id, @@ -98,7 +103,10 @@ describe('maybeWriteOutputToTable', () => { buildContext() ) - expect(result).toEqual({ success: false, error: 'Table "tbl_1" not found' }) + expect(result).toEqual({ + success: false, + error: 'Failed to write to table: Table operation failed', + }) expect(mockReplaceTableRows).not.toHaveBeenCalled() }) @@ -112,7 +120,7 @@ describe('maybeWriteOutputToTable', () => { expect(result.success).toBe(false) expect(result.error).toContain('requires write access') - expect(mockGetTableById).not.toHaveBeenCalled() + expect(mockReadTable).not.toHaveBeenCalled() expect(mockReplaceTableRows).not.toHaveBeenCalled() }) @@ -134,17 +142,15 @@ describe('maybeWriteOutputToTable', () => { expect(result.success).toBe(true) expect(mockReplaceTableRows).toHaveBeenCalledTimes(1) - const [data, table] = mockReplaceTableRows.mock.calls[0] - expect(data).toMatchObject({ + const [{ input }] = mockReplaceTableRows.mock.calls[0] + expect(input).toMatchObject({ tableId: 'tbl_1', - workspaceId: 'workspace-1', - userId: 'user-1', + assertedWorkspaceId: 'workspace-1', rows: [ - { col_name: 'Alice', col_age: 30 }, - { col_name: 'Bob', col_age: 40 }, + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 40 }, ], }) - expect(table.id).toBe('tbl_1') }) it('projects activated secrets before persistence without rewriting sibling literals', async () => { @@ -173,10 +179,8 @@ describe('maybeWriteOutputToTable', () => { ) expect(result.success).toBe(true) - const persistedRows = mockReplaceTableRows.mock.calls[0][0].rows - expect(persistedRows).toEqual([ - { col_name: '{{OUTPUT_SECRET}}', col_age: '123', col_status: 'true' }, - ]) + const persistedRows = mockReplaceTableRows.mock.calls[0][0].input.rows + expect(persistedRows).toEqual([{ name: '{{OUTPUT_SECRET}}', age: '123', status: 'true' }]) expect(runtimeRows).toEqual([{ name: 'secret-value', age: '123', status: 'true' }]) const modelFacing = projectToolResultForCopilot( @@ -185,7 +189,7 @@ describe('maybeWriteOutputToTable', () => { ) expect(modelFacing.output).toEqual({ data: { - rows: [{ col_name: '{{OUTPUT_SECRET}}', col_age: '123', col_status: 'true' }], + rows: [{ name: '{{OUTPUT_SECRET}}', age: '123', status: 'true' }], }, }) @@ -224,9 +228,7 @@ describe('maybeWriteOutputToTable', () => { expect(result.success).toBe(true) expect(mockReplaceTableRows).toHaveBeenCalledWith( - expect.objectContaining({ rows: [{ col_name: 'unknown' }] }), - expect.anything(), - expect.any(String) + expect.objectContaining({ input: expect.objectContaining({ rows: [{ name: 'unknown' }] }) }) ) }) @@ -267,7 +269,21 @@ describe('maybeWriteOutputToTable', () => { ) expect(result.success).toBe(false) - expect(result.error).toContain('Row 1: name is required') + expect(result.error).toContain('Table operation failed') + }) + + it('fails fast when authoritative inserted count differs from the requested rows', async () => { + mockReplaceTableRows.mockResolvedValue({ deletedCount: 1, insertedCount: 1 }) + + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'tbl_1' }, + { success: true, output: { result: [{ name: 'Alice' }, { name: 'Bob' }] } }, + buildContext() + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('Table operation failed') }) it('keeps raw errors for terminal projection but projects application logs and OTel events', async () => { @@ -284,10 +300,10 @@ describe('maybeWriteOutputToTable', () => { buildContext({ resolvedSecretTraceRegistry: registry }) ) - expect(result.error).toContain('secret-value') - expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('{{SECRET}}') + expect(result.error).not.toContain('secret-value') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('Table operation failed') expect(JSON.stringify(tableLogger?.warn.mock.calls)).not.toContain('secret-value') - expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('Table operation failed') expect(JSON.stringify(mockSpanAddEvent.mock.calls)).not.toContain('secret-value') }) }) @@ -295,12 +311,15 @@ describe('maybeWriteOutputToTable', () => { describe('maybeWriteReadCsvToTable', () => { beforeEach(() => { vi.clearAllMocks() - mockGetTableById.mockResolvedValue(buildTable()) - mockReplaceTableRows.mockResolvedValue({ deletedCount: 0, insertedCount: 2 }) + mockReadTable.mockResolvedValue({ table: buildTable(), folderPath: '/' }) + mockReplaceTableRows.mockImplementation(async ({ input }: { input: { rows: unknown[] } }) => ({ + deletedCount: 0, + insertedCount: input.rows.length, + })) }) it('rejects a table from another workspace without touching it', async () => { - mockGetTableById.mockResolvedValue(buildTable({ workspaceId: 'other-workspace' })) + mockReadTable.mockRejectedValue(new Error('Table not found')) const result = await maybeWriteReadCsvToTable( ReadTool.id, @@ -309,7 +328,10 @@ describe('maybeWriteReadCsvToTable', () => { buildContext() ) - expect(result).toEqual({ success: false, error: 'Table "tbl_1" not found' }) + expect(result).toEqual({ + success: false, + error: 'Failed to import into table: Table operation failed', + }) expect(mockReplaceTableRows).not.toHaveBeenCalled() }) @@ -323,7 +345,7 @@ describe('maybeWriteReadCsvToTable', () => { expect(result.success).toBe(false) expect(result.error).toContain('requires write access') - expect(mockGetTableById).not.toHaveBeenCalled() + expect(mockReadTable).not.toHaveBeenCalled() expect(mockReplaceTableRows).not.toHaveBeenCalled() }) @@ -336,10 +358,10 @@ describe('maybeWriteReadCsvToTable', () => { ) expect(result.success).toBe(true) - const [data] = mockReplaceTableRows.mock.calls[0] - expect(data.rows).toEqual([ - { col_name: 'Alice', col_age: '30' }, - { col_name: 'Bob', col_age: '40' }, + const [{ input }] = mockReplaceTableRows.mock.calls[0] + expect(input.rows).toEqual([ + { name: 'Alice', age: '30' }, + { name: 'Bob', age: '40' }, ]) }) @@ -361,15 +383,15 @@ describe('maybeWriteReadCsvToTable', () => { expect(result.success).toBe(true) expect(mockReplaceTableRows).toHaveBeenCalledWith( expect.objectContaining({ - rows: [ - { - col_name: '{{NUMBER}}', - col_status: '{{BOOLEAN}}', - }, - ], - }), - expect.anything(), - expect.any(String) + input: expect.objectContaining({ + rows: [ + { + name: '{{NUMBER}}', + status: '{{BOOLEAN}}', + }, + ], + }), + }) ) }) @@ -427,10 +449,10 @@ describe('maybeWriteReadCsvToTable', () => { expect(result.success).toBe(true) expect(mockReplaceTableRows).toHaveBeenCalledWith( expect.objectContaining({ - rows: [{ col_name: 'legacy-value', col_age: '123', col_active: 'true' }], - }), - expect.anything(), - expect.any(String) + input: expect.objectContaining({ + rows: [{ name: 'legacy-value', age: '123', active: 'true' }], + }), + }) ) }) @@ -458,7 +480,7 @@ describe('maybeWriteReadCsvToTable', () => { ) expect(result.success).toBe(false) - expect(result.error).toContain('Row 1: name is required') + expect(result.error).toContain('Table operation failed') }) it('projects active secret literals in CSV-import log and OTel errors', async () => { @@ -475,10 +497,10 @@ describe('maybeWriteReadCsvToTable', () => { buildContext({ resolvedSecretTraceRegistry: registry }) ) - expect(result.error).toContain('secret-value') - expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('{{SECRET}}') + expect(result.error).not.toContain('secret-value') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('Table operation failed') expect(JSON.stringify(tableLogger?.warn.mock.calls)).not.toContain('secret-value') - expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('Table operation failed') expect(JSON.stringify(mockSpanAddEvent.mock.calls)).not.toContain('secret-value') }) }) diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index b8158308986..edf6dcb33af 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -1,9 +1,11 @@ import { isDeepStrictEqual } from 'node:util' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { isPlainRecord } from '@sim/utils/object' import { parse as csvParse } from 'csv-parse/sync' +import { + messageForCopilotTableError, + resolveCopilotTablePrincipal, +} from '@/lib/copilot/auth/table-delegation' import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' import { CopilotTableOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -17,11 +19,10 @@ import { } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import type { RowData, TableDefinition } from '@/lib/table' -import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' +import { replaceTableRows } from '@/lib/table/application/rows' +import { readTableUseCase } from '@/lib/table/application/tables' import { columnTypeOf } from '@/lib/table/column-types' import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' -import { replaceTableRows } from '@/lib/table/rows/service' -import { getTableById } from '@/lib/table/service' const logger = createLogger('CopilotToolResultTables') @@ -53,46 +54,63 @@ function hasUnsupportedProjectedCell( * locking, validation, plan row limits, batching, and rowCount maintenance. */ async function replaceTableRowsFromWire( - table: TableDefinition, + tableId: string, rows: Array<Record<string, unknown>>, context: ExecutionContext -): Promise<{ error?: string }> { +): Promise< + | { success: false; error: string } + | { success: true; table: TableDefinition; insertedCount: number; deletedCount: number } +> { + const principal = resolveCopilotTablePrincipal(context, tableId) + const { table } = await readTableUseCase.execute({ + principal, + input: { tableId, workspaceId: principal.workspaceId }, + }) const persistenceProjection = context.resolvedSecretTraceRegistry ? projectToolOutputForPersistence(rows, context.resolvedSecretTraceRegistry) : { safe: true as const, value: rows } - if (!persistenceProjection.safe) return { error: persistenceProjection.error } + if (!persistenceProjection.safe) { + return { success: false, error: persistenceProjection.error } + } if ( !Array.isArray(persistenceProjection.value) || !persistenceProjection.value.every(isPlainRecord) ) { - return { error: 'Table rows could not be persisted safely' } + return { success: false, error: 'Table rows could not be persisted safely' } } if (hasUnsupportedProjectedCell(table, rows, persistenceProjection.value)) { - return { error: TABLE_SECRET_PROJECTION_UNSUPPORTED_ERROR } + return { success: false, error: TABLE_SECRET_PROJECTION_UNSUPPORTED_ERROR } } - const idByName = buildIdByName(table.schema) - const idKeyedRows = persistenceProjection.value.map((row) => - rowDataNameToId(row as RowData, idByName) + const projectedRows = persistenceProjection.value.map((row) => row as RowData) + const columnNames = new Set(table.schema.columns.map((column) => column.name)) + const emptyIndex = projectedRows.findIndex( + (row) => !Object.keys(row).some((name) => columnNames.has(name)) ) - const emptyIndex = idKeyedRows.findIndex((row) => Object.keys(row).length === 0) if (emptyIndex !== -1) { return { + success: false, error: `Row ${emptyIndex + 1} has no keys matching columns on table "${table.name}" (columns: ${table.schema.columns.map((c) => c.name).join(', ')})`, } } - await replaceTableRows( - { + const replacement = await replaceTableRows.execute({ + principal, + input: { tableId: table.id, - rows: idKeyedRows, - workspaceId: table.workspaceId, - userId: context.userId, - secretProvenance: idKeyedRows.map(createExactEmptyTableRowSecretProvenance), + assertedWorkspaceId: principal.workspaceId, + rows: projectedRows, + secretProvenance: projectedRows.map(createExactEmptyTableRowSecretProvenance), }, + }) + if (replacement.insertedCount !== projectedRows.length) { + throw new Error('Table row replacement inserted an unexpected row count') + } + return { + success: true, table, - generateId().slice(0, 8) - ) - return {} + insertedCount: replacement.insertedCount, + deletedCount: replacement.deletedCount, + } } export async function maybeWriteOutputToTable( @@ -103,8 +121,6 @@ export async function maybeWriteOutputToTable( ): Promise<ToolCallResult> { if (toolName !== FunctionExecute.id) return result if (!result.success || !result.output) return result - if (!context.workspaceId || !context.userId) return result - const outputTable = params?.outputTable as string | undefined if (!outputTable) return result @@ -116,19 +132,10 @@ export async function maybeWriteOutputToTable( { [TraceAttr.ToolName]: toolName, [TraceAttr.CopilotTableId]: outputTable, - [TraceAttr.WorkspaceId]: context.workspaceId, + [TraceAttr.WorkspaceId]: context.workspaceId ?? '', }, async (span) => { try { - const table = await getTableById(outputTable) - if (!table || table.workspaceId !== context.workspaceId) { - span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.TableNotFound) - return { - success: false, - error: `Table "${outputTable}" not found`, - } - } - const rawOutput = result.output let rows: Array<Record<string, unknown>> @@ -174,8 +181,8 @@ export async function maybeWriteOutputToTable( if (context.abortSignal?.aborted) { throw new Error('Request aborted before tool mutation could be applied') } - const replaceResult = await replaceTableRowsFromWire(table, rows, context) - if (replaceResult.error) { + const replaceResult = await replaceTableRowsFromWire(outputTable, rows, context) + if (!replaceResult.success) { span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape) return { success: false, error: replaceResult.error } } @@ -183,21 +190,22 @@ export async function maybeWriteOutputToTable( logger.info('Tool output written to table', { toolName, tableId: outputTable, - rowCount: rows.length, + rowCount: replaceResult.insertedCount, + deletedCount: replaceResult.deletedCount, }) span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Wrote) return { success: true, output: { - message: `Wrote ${rows.length} rows to table ${outputTable}`, + message: `Wrote ${replaceResult.insertedCount} rows to table ${outputTable}`, tableId: outputTable, - rowCount: rows.length, + rowCount: replaceResult.insertedCount, }, } } catch (err) { - const rawMessage = toError(err).message + const safeMessage = messageForCopilotTableError(err) const projectedMessage = projectToolErrorMessageForCopilot( - rawMessage, + safeMessage, context.resolvedSecretTraceRegistry ) logger.warn('Failed to write tool output to table', { @@ -211,7 +219,7 @@ export async function maybeWriteOutputToTable( }) return { success: false, - error: `Failed to write to table: ${rawMessage}`, + error: `Failed to write to table: ${projectedMessage}`, } } } @@ -226,8 +234,6 @@ export async function maybeWriteReadCsvToTable( ): Promise<ToolCallResult> { if (toolName !== ReadTool.id) return result if (!result.success || !result.output) return result - if (!context.workspaceId || !context.userId) return result - const outputTable = params?.outputTable as string | undefined if (!outputTable) return result @@ -239,16 +245,10 @@ export async function maybeWriteReadCsvToTable( { [TraceAttr.ToolName]: toolName, [TraceAttr.CopilotTableId]: outputTable, - [TraceAttr.WorkspaceId]: context.workspaceId, + [TraceAttr.WorkspaceId]: context.workspaceId ?? '', }, async (span) => { try { - const table = await getTableById(outputTable) - if (!table || table.workspaceId !== context.workspaceId) { - span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.TableNotFound) - return { success: false, error: `Table "${outputTable}" not found` } - } - const output = result.output as Record<string, unknown> const content = output.content if (typeof content !== 'string') { @@ -310,8 +310,8 @@ export async function maybeWriteReadCsvToTable( if (context.abortSignal?.aborted) { throw new Error('Request aborted before tool mutation could be applied') } - const replaceResult = await replaceTableRowsFromWire(table, rows, context) - if (replaceResult.error) { + const replaceResult = await replaceTableRowsFromWire(outputTable, rows, context) + if (!replaceResult.success) { span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape) return { success: false, error: replaceResult.error } } @@ -319,24 +319,25 @@ export async function maybeWriteReadCsvToTable( logger.info('Read output written to table', { toolName, tableId: outputTable, - tableName: table.name, - rowCount: rows.length, + tableName: replaceResult.table.name, + rowCount: replaceResult.insertedCount, + deletedCount: replaceResult.deletedCount, filePath, }) span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Imported) return { success: true, output: { - message: `Imported ${rows.length} rows from "${filePath}" into table "${table.name}"`, + message: `Imported ${replaceResult.insertedCount} rows from "${filePath}" into table "${replaceResult.table.name}"`, tableId: outputTable, - tableName: table.name, - rowCount: rows.length, + tableName: replaceResult.table.name, + rowCount: replaceResult.insertedCount, }, } } catch (err) { - const rawMessage = toError(err).message + const safeMessage = messageForCopilotTableError(err) const projectedMessage = projectToolErrorMessageForCopilot( - rawMessage, + safeMessage, context.resolvedSecretTraceRegistry ) logger.warn('Failed to write read output to table', { @@ -350,7 +351,7 @@ export async function maybeWriteReadCsvToTable( }) return { success: false, - error: `Failed to import into table: ${rawMessage}`, + error: `Failed to import into table: ${projectedMessage}`, } } } diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 323738741b6..2eb7b1d681a 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -105,6 +105,7 @@ const WRITE_ACTIONS: Record<string, string[]> = { 'create_from_file', 'import_file', 'delete', + 'rename', 'insert_row', 'batch_insert_rows', 'update_row', @@ -117,6 +118,13 @@ const WRITE_ACTIONS: Record<string, string[]> = { 'rename_column', 'delete_column', 'update_column', + 'add_workflow_group', + 'update_workflow_group', + 'delete_workflow_group', + 'add_workflow_group_output', + 'delete_workflow_group_output', + 'run_column', + 'cancel_table_runs', 'add_enrichment', ], [ManageCustomTool.id]: ['add', 'edit', 'delete'], diff --git a/apps/sim/lib/copilot/tools/server/table/query-user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/query-user-table.test.ts new file mode 100644 index 00000000000..f6f3e35145f --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/query-user-table.test.ts @@ -0,0 +1,49 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const executeUserTable = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/copilot/tools/server/table/user-table', () => ({ + userTableServerTool: { execute: executeUserTable }, +})) + +import { queryUserTableServerTool } from '@/lib/copilot/tools/server/table/query-user-table' + +describe('query_user_table alias', () => { + beforeEach(() => { + vi.clearAllMocks() + executeUserTable.mockResolvedValue({ success: true, message: 'ok' }) + }) + + it('delegates read operations with the original trusted context', async () => { + const context = { + userId: 'user-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, + } + const params = { operation: 'query_rows', args: { tableId: 'table-1', limit: 10 } } + + await expect(queryUserTableServerTool.execute(params, context)).resolves.toEqual({ + success: true, + message: 'ok', + }) + expect(executeUserTable).toHaveBeenCalledWith(params, context) + }) + + it('rejects mutations and outputPath without invoking user_table', async () => { + await expect( + queryUserTableServerTool.execute({ operation: 'delete', args: { tableId: 'table-1' } }) + ).resolves.toMatchObject({ success: false, message: expect.stringContaining('read-only') }) + await expect( + queryUserTableServerTool.execute({ + operation: 'query_rows', + args: { tableId: 'table-1', outputPath: 'files/result.csv' }, + }) + ).resolves.toMatchObject({ success: false, message: expect.stringContaining('outputPath') }) + expect(executeUserTable).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 6395f4ddf44..5c1fbcc5a22 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ +import { getErrorMessage } from '@sim/utils/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table' @@ -26,6 +27,7 @@ const { mockRunTableImport, mockRunTableDelete, mockRunTableUpdate, + mockExecuteCopilotTableUseCase, fakeEnrichment, } = vi.hoisted(() => ({ mockUpdateColumnType: vi.fn(), @@ -48,6 +50,7 @@ const { mockRunTableImport: vi.fn(), mockRunTableDelete: vi.fn(), mockRunTableUpdate: vi.fn(), + mockExecuteCopilotTableUseCase: vi.fn(), fakeEnrichment: { id: 'work-email', name: 'Work Email', @@ -92,6 +95,26 @@ vi.mock('@/lib/copilot/auth/file-delegation', () => ({ })), })) +vi.mock('@/lib/copilot/auth/table-delegation', () => ({ + messageForCopilotTableError: (error: unknown) => getErrorMessage(error, 'Table operation failed'), + resolveCopilotTablePrincipal: (_context: unknown, tableId?: string) => ({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'test-tool', + audience: 'sim:tables', + issuedAt: new Date(0), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: tableId ? { tableId } : undefined, + }), +})) + +vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ + admitCopilotTableOperation: vi.fn(), + executeCopilotTableUseCase: mockExecuteCopilotTableUseCase, +})) + vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ getBoundWorkspaceFileSecretProvenance: mockGetBoundWorkspaceFileSecretProvenance, })) @@ -141,8 +164,8 @@ vi.mock('@/lib/table/rows/service', () => ({ })) vi.mock('@/lib/table/jobs/service', () => ({ - markTableJobRunning: mockMarkTableJobRunning, - releaseJobClaim: mockReleaseJobClaim, + markTableJobRunningInWorkspace: mockMarkTableJobRunning, + releaseJobClaimInWorkspace: mockReleaseJobClaim, })) vi.mock('@/lib/table/import-runner', () => ({ @@ -164,7 +187,70 @@ vi.mock('@/lib/table/billing', () => ({ })) import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' -import { encodeCursor } from '@/lib/table/rows/cursor' +import { decodeCursor, encodeCursor } from '@/lib/table/rows/cursor' + +beforeEach(() => { + mockExecuteCopilotTableUseCase.mockImplementation( + async ( + _context: unknown, + useCase: { operation: { id: string } }, + input: Record<string, unknown> + ) => { + const table = await mockGetTableById(input.tableId) + switch (useCase.operation.id) { + case 'tables.create': { + const limits = await mockGetWorkspaceTableLimits(input.workspaceId) + const created = await mockCreateTable({ ...input, ...limits }) + return { table: created } + } + case 'tables.rows.query': { + if (!table) throw new Error('Table not found') + if (input.cursor && Array.isArray(input.sort) && input.sort.length > 0) { + throw new Error('Cursor is not valid for a sorted query') + } + const cursor = input.cursor ? decodeCursor(String(input.cursor)) : undefined + const result = await mockQueryRows(table, { + predicate: input.predicate, + sort: input.sort, + limit: input.limit, + after: cursor?.after, + offset: cursor?.offset, + includeTotal: input.includeTotal, + withExecutions: false, + }) + return { table, ...result } + } + case 'tables.columns.update': { + if (!table) throw new Error('Table not found') + const updates = input.updates as Record<string, unknown> + const column = table.schema.columns.find( + (candidate) => candidate.name === input.columnName + ) + const next = + updates.type !== undefined && updates.type !== column?.type + ? await mockUpdateColumnType({ + tableId: input.tableId, + columnName: input.columnName, + newType: updates.type, + }) + : await mockUpdateColumnOptions({ + tableId: input.tableId, + columnName: input.columnName, + options: Array.isArray(updates.options) + ? updates.options.map((option) => + typeof option === 'string' ? { name: option } : option + ) + : column?.options, + multiple: updates.multiple, + }) + return { table: next, changed: true } + } + default: + throw new Error(`Unexpected application operation ${useCase.operation.id}`) + } + } + ) +}) function buildTable(overrides: Partial<TableDefinition> = {}): TableDefinition { return { @@ -209,7 +295,7 @@ describe('userTableServerTool.import_file', () => { mockDownloadWorkspaceFile.mockResolvedValue(Buffer.from('name,age\nAlice,30\nBob,40')) mockGetTableById.mockResolvedValue(buildTable()) mockMarkTableJobRunning.mockResolvedValue(true) - mockReleaseJobClaim.mockResolvedValue(undefined) + mockReleaseJobClaim.mockResolvedValue(true) mockBatchInsertRows.mockImplementation(async (data: { rows: unknown[] }) => data.rows.map((_, i) => ({ id: `row_${i}` })) ) @@ -359,10 +445,16 @@ describe('userTableServerTool.import_file', () => { ) expect(result.success).toBe(true) - expect(mockMarkTableJobRunning).toHaveBeenCalledWith('tbl_1', expect.any(String), 'import') + expect(mockMarkTableJobRunning).toHaveBeenCalledWith( + 'tbl_1', + 'workspace-1', + expect.any(String), + 'import' + ) expect(mockReleaseJobClaim).toHaveBeenCalledWith( 'tbl_1', - mockMarkTableJobRunning.mock.calls[0][1] + 'workspace-1', + mockMarkTableJobRunning.mock.calls[0][2] ) }) @@ -397,7 +489,12 @@ describe('userTableServerTool.import_file', () => { expect(result.success).toBe(true) expect(result.data?.jobId).toBeDefined() expect(result.message).toMatch(/background/i) - expect(mockMarkTableJobRunning).toHaveBeenCalledWith('tbl_1', expect.any(String), 'import') + expect(mockMarkTableJobRunning).toHaveBeenCalledWith( + 'tbl_1', + 'workspace-1', + expect.any(String), + 'import' + ) expect(mockBatchInsertRows).not.toHaveBeenCalled() expect(mockReplaceTableRows).not.toHaveBeenCalled() expect(mockDownloadWorkspaceFile).not.toHaveBeenCalled() @@ -946,7 +1043,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { // target = min(limit 5000, matchCount 20000) = 5000, above the inline cap → background. expect(result.data?.doomedCount).toBe(5000) expect(mockDeleteRowsByFilter).not.toHaveBeenCalled() - const [, , type, payload] = mockMarkTableJobRunning.mock.calls[0] + const [, , , type, payload] = mockMarkTableJobRunning.mock.calls[0] expect(type).toBe('delete') // Bounded delete carries maxRows and omits doomedCount so the mask is skipped and the count // isn't double-subtracted. @@ -968,7 +1065,12 @@ describe('userTableServerTool.delete_rows_by_filter', () => { expect(result.data?.affectedCount).toBe(5) expect(mockDeleteRowsByFilter).toHaveBeenCalledTimes(1) // Inline delete still claims (and releases) the table's write-job slot. - expect(mockMarkTableJobRunning).toHaveBeenCalledWith('tbl_1', expect.any(String), 'delete') + expect(mockMarkTableJobRunning).toHaveBeenCalledWith( + 'tbl_1', + 'workspace-1', + expect.any(String), + 'delete' + ) expect(mockReleaseJobClaim).toHaveBeenCalled() }) @@ -1027,8 +1129,9 @@ describe('userTableServerTool.delete_rows_by_filter', () => { expect(result.data?.jobId).toBeDefined() expect(result.data?.doomedCount).toBe(20000) expect(mockDeleteRowsByFilter).not.toHaveBeenCalled() - const [tableId, jobId, type, payload] = mockMarkTableJobRunning.mock.calls[0] + const [tableId, workspaceId, jobId, type, payload] = mockMarkTableJobRunning.mock.calls[0] expect(tableId).toBe('tbl_1') + expect(workspaceId).toBe('workspace-1') expect(type).toBe('delete') expect(payload).toMatchObject({ doomedCount: 20000, cutoff: expect.any(String) }) // Unbounded delete masks the whole set — no maxRows cap. @@ -1120,7 +1223,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { // target = min(limit 5000, matchCount 20000) = 5000, above the inline cap → background. expect(result.data?.affectedCount).toBe(5000) expect(mockUpdateRowsByFilter).not.toHaveBeenCalled() - const [, , type, payload] = mockMarkTableJobRunning.mock.calls[0] + const [, , , type, payload] = mockMarkTableJobRunning.mock.calls[0] expect(type).toBe('update') expect(payload).toMatchObject({ affectedCount: 5000, maxRows: 5000 }) expect(mockRunTableUpdate.mock.calls[0][0]).toMatchObject({ maxRows: 5000 }) @@ -1186,8 +1289,9 @@ describe('userTableServerTool.update_rows_by_filter', () => { expect(result.data?.jobId).toBeDefined() expect(result.data?.affectedCount).toBe(20000) expect(mockUpdateRowsByFilter).not.toHaveBeenCalled() - const [tableId, jobId, type, payload] = mockMarkTableJobRunning.mock.calls[0] + const [tableId, workspaceId, jobId, type, payload] = mockMarkTableJobRunning.mock.calls[0] expect(tableId).toBe('tbl_1') + expect(workspaceId).toBe('workspace-1') expect(type).toBe('update') expect(payload).toMatchObject({ affectedCount: 20000, @@ -1343,3 +1447,22 @@ describe('userTableServerTool.update_column — select routing', () => { expect(arg.options).toEqual([{ id: 'opt_open', name: 'Open' }]) }) }) + +describe('userTableServerTool.delete bounds', () => { + it('rejects an unbounded multi-table delete before invoking an application use case', async () => { + vi.clearAllMocks() + const result = await userTableServerTool.execute( + { + operation: 'delete', + args: { tableIds: Array.from({ length: 101 }, (_, index) => `table-${index}`) }, + }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(result).toEqual({ + success: false, + message: 'Cannot delete more than 100 tables at once', + }) + expect(mockExecuteCopilotTableUseCase).not.toHaveBeenCalled() + }) +}) 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 0a119295f69..fca3d5c1e97 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -2,7 +2,15 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { + admitCopilotTableOperation, + executeCopilotTableUseCase, +} from '@/lib/copilot/application/execute-table-use-case' import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' +import { + messageForCopilotTableError, + resolveCopilotTablePrincipal, +} from '@/lib/copilot/auth/table-delegation' import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, @@ -26,30 +34,48 @@ import { TABLE_LIMITS, validateMapping, } from '@/lib/table' +import { + addTableColumnUseCase, + deleteTableColumnUseCase, + updateTableColumnUseCase, +} from '@/lib/table/application/columns' +import { + createTableGroupUseCase, + deleteTableGroupUseCase, + updateTableGroupUseCase, +} from '@/lib/table/application/groups' +import { type TableOperation, tableOperations } from '@/lib/table/application/operations' +import { + createTableRows, + deleteTableRow, + deleteTableRows, + queryTableRows, + readTableRow, + updateTableRow, +} from '@/lib/table/application/rows' +import { cancelTableRuns, startTableRun } from '@/lib/table/application/runs' +import { + createTableUseCase, + deleteTableUseCase, + readTableUseCase, + updateTableUseCase, +} from '@/lib/table/application/tables' import { namedRowMapper } from '@/lib/table/cell-format' -import { buildIdByName, rowDataNameToId, sortSpecNamesToIds } from '@/lib/table/column-keys' +import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' -import { - addTableColumn, - deleteColumn, - deleteColumns, - renameColumn, -} from '@/lib/table/columns/service' +import { deleteColumns } from '@/lib/table/columns/service' import { isSupportedCurrencyCode } from '@/lib/table/currency' import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner' import { signalTableRowsChanged, signalTableSchemaChanged } from '@/lib/table/events' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' -import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' -import { assertRowDelete, assertRowUpdate, patchColumnIds } from '@/lib/table/mutation-locks' import { - performDeleteTable, - performRenameTable, - performUpdateTableColumn, -} from '@/lib/table/orchestration' + markTableJobRunningInWorkspace, + releaseJobClaimInWorkspace, +} from '@/lib/table/jobs/service' +import { assertRowDelete, assertRowUpdate, patchColumnIds } from '@/lib/table/mutation-locks' import { predicateToFilter } from '@/lib/table/query-builder/converters' import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate' -import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/validate' -import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' +import { validatePredicate } from '@/lib/table/query-builder/validate' import { createExactEmptyTableRowSecretProvenance, loadTableRowSecretProvenance, @@ -57,14 +83,9 @@ import { import { batchInsertRows, batchUpdateRows, - deleteRow, deleteRowsByFilter, - deleteRowsByIds, - getRowById, - insertRow, queryRows, replaceTableRows, - updateRow, updateRowsByFilter, } from '@/lib/table/rows/service' import { normalizeSelectOptionsInput } from '@/lib/table/select-options' @@ -77,7 +98,6 @@ import type { SortSpec, TableDefinition, TableDeleteJobPayload, - TablePredicate, TablePredicateInput, TableSchema, TableUpdateJobPayload, @@ -88,13 +108,10 @@ import type { WorkflowGroupOutput, } from '@/lib/table/types' import { markTableUpdateFailed, runTableUpdate } from '@/lib/table/update-runner' -import { cancelWorkflowGroupRuns, runWorkflowColumn } from '@/lib/table/workflow-columns' import { addWorkflowGroup, addWorkflowGroupOutput, - deleteWorkflowGroup, deleteWorkflowGroupOutput, - updateWorkflowGroup, } from '@/lib/table/workflow-groups/service' import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { @@ -122,6 +139,61 @@ type UserTableResult = { const MAX_BATCH_SIZE = CSV_MAX_BATCH_SIZE const MAX_INLINE_FILE_BYTES = 50 * 1024 * 1024 +const USER_TABLE_OPERATIONS: Readonly<Record<string, TableOperation | undefined>> = { + create: tableOperations.create, + create_from_file: tableOperations.create, + import_file: tableOperations.createImport, + get: tableOperations.read, + get_schema: tableOperations.read, + delete: tableOperations.delete, + insert_row: tableOperations.createRows, + batch_insert_rows: tableOperations.createRows, + get_row: tableOperations.readRow, + query_rows: tableOperations.queryRows, + update_row: tableOperations.updateRow, + delete_row: tableOperations.deleteRow, + update_rows_by_filter: tableOperations.updateRows, + delete_rows_by_filter: tableOperations.deleteRows, + batch_update_rows: tableOperations.updateRows, + batch_delete_rows: tableOperations.deleteRows, + add_column: tableOperations.addColumn, + rename_column: tableOperations.updateColumn, + delete_column: tableOperations.deleteColumn, + update_column: tableOperations.updateColumn, + rename: tableOperations.update, + add_workflow_group: tableOperations.createGroup, + update_workflow_group: tableOperations.updateGroup, + delete_workflow_group: tableOperations.deleteGroup, + add_workflow_group_output: tableOperations.updateGroup, + delete_workflow_group_output: tableOperations.updateGroup, + run_column: tableOperations.startRun, + cancel_table_runs: tableOperations.cancelRuns, + add_enrichment: tableOperations.createGroup, +} + +const DIRECT_APPLICATION_OPERATIONS = new Set([ + 'create', + 'get', + 'get_schema', + 'delete', + 'rename', + 'insert_row', + 'batch_insert_rows', + 'get_row', + 'query_rows', + 'update_row', + 'delete_row', + 'batch_delete_rows', + 'add_column', + 'rename_column', + 'update_column', + 'add_workflow_group', + 'update_workflow_group', + 'delete_workflow_group', + 'run_column', + 'cancel_table_runs', +]) + async function resolveWorkspaceFileRecordOrThrow( fileReference: string, workspaceId: string, @@ -202,7 +274,20 @@ async function dispatchImportJob(payload: TableImportPayload): Promise<void> { region: await resolveTriggerRegion(), }) } catch (error) { - await releaseJobClaim(payload.tableId, payload.importId).catch(() => {}) + try { + const released = await releaseJobClaimInWorkspace( + payload.tableId, + payload.workspaceId, + payload.importId + ) + if (!released) throw new Error('Table import claim was no longer active') + } catch (cleanupError) { + logger.error('Failed to release table import claim after dispatch failure', { + tableId: payload.tableId, + jobId: payload.importId, + error: getErrorMessage(cleanupError), + }) + } throw error } } else { @@ -237,7 +322,16 @@ async function dispatchDeleteJob(params: { { tags: [`tableId:${tableId}`, `jobId:${jobId}`], region: await resolveTriggerRegion() } ) } catch (error) { - await releaseJobClaim(tableId, jobId).catch(() => {}) + try { + const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) + if (!released) throw new Error('Table delete claim was no longer active') + } catch (cleanupError) { + logger.error('Failed to release table delete claim after dispatch failure', { + tableId, + jobId, + error: getErrorMessage(cleanupError), + }) + } throw error } } else { @@ -280,7 +374,16 @@ async function dispatchUpdateJob(params: { { tags: [`tableId:${tableId}`, `jobId:${jobId}`], region: await resolveTriggerRegion() } ) } catch (error) { - await releaseJobClaim(tableId, jobId).catch(() => {}) + try { + const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) + if (!released) throw new Error('Table update claim was no longer active') + } catch (cleanupError) { + logger.error('Failed to release table update claim after dispatch failure', { + tableId, + jobId, + error: getErrorMessage(cleanupError), + }) + } throw error } } else { @@ -295,6 +398,47 @@ async function dispatchUpdateJob(params: { } } +async function withReleasedTableJobClaim<T>( + tableId: string, + workspaceId: string, + jobId: string, + run: () => Promise<T> +): Promise<T> { + let result: T + try { + result = await run() + } catch (error) { + try { + const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) + if (!released) { + logger.error('Table job claim was no longer active after operation failure', { + tableId, + workspaceId, + jobId, + }) + } + } catch (cleanupError) { + logger.error('Failed to release table job claim after operation failure', { + tableId, + workspaceId, + jobId, + error: getErrorMessage(cleanupError), + }) + } + throw error + } + const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) + if (!released) { + logger.error('Table job claim was no longer active after successful operation', { + tableId, + workspaceId, + jobId, + }) + throw new Error('Table job claim was no longer active') + } + return result +} + /** * Loads the live workflow state and flattens it into pickable outputs. Used * to validate `(blockId, path)` pairs the AI passes to add/update_workflow_group @@ -446,12 +590,21 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> } const { operation, args = {} } = params - const workspaceId = - context.workspaceId || ((args as Record<string, unknown>).workspaceId as string | undefined) + const tableId = typeof args.tableId === 'string' ? args.tableId : undefined + const tablePrincipal = resolveCopilotTablePrincipal(context, tableId) + const workspaceId = tablePrincipal.workspaceId const assertNotAborted = () => assertServerToolNotAborted(context, 'Request aborted before table mutation could be applied.') try { + const semanticOperation = USER_TABLE_OPERATIONS[operation] + if (semanticOperation && !DIRECT_APPLICATION_OPERATIONS.has(operation)) { + await admitCopilotTableOperation( + context, + semanticOperation, + tableId ? { workspaceId, tableId } : { workspaceId } + ) + } switch (operation) { case 'create': { if (!args.name) { @@ -464,31 +617,12 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> return { success: false, message: 'Workspace ID is required' } } - const requestId = generateId().slice(0, 8) assertNotAborted() - const planLimits = await getWorkspaceTableLimits(workspaceId) - const table = await createTable( - { - name: args.name, - description: args.description, - // Agent authors select options by name; generate their stable ids here. - schema: normalizeSchemaSelectColumns(args.schema as TableSchema), - workspaceId, - userId: context.userId, - maxTables: planLimits.maxTables, - }, - requestId - ) - - recordAudit({ + const { table } = await executeCopilotTableUseCase(context, createTableUseCase, { + name: args.name, + description: args.description, + schema: normalizeSchemaSelectColumns(args.schema as TableSchema), workspaceId, - actorId: context.userId, - action: AuditAction.TABLE_CREATED, - resourceType: AuditResourceType.TABLE, - resourceId: table.id, - resourceName: table.name, - description: `Created table "${table.name}"`, - metadata: { source: 'tool_input' }, }) return { @@ -506,10 +640,12 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> return { success: false, message: 'Workspace ID is required' } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } + const { table } = await executeCopilotTableUseCase( + context, + readTableUseCase, + { tableId: args.tableId, workspaceId }, + { tableId: args.tableId } + ) return { success: true, @@ -526,10 +662,12 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> return { success: false, message: 'Workspace ID is required' } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } + const { table } = await executeCopilotTableUseCase( + context, + readTableUseCase, + { tableId: args.tableId, workspaceId }, + { tableId: args.tableId } + ) return { success: true, @@ -547,6 +685,12 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> if (tableIds.length === 0) { return { success: false, message: 'tableId or tableIds is required' } } + if (tableIds.length > TABLE_LIMITS.MAX_TABLES_PER_WORKSPACE) { + return { + success: false, + message: `Cannot delete more than ${TABLE_LIMITS.MAX_TABLES_PER_WORKSPACE} tables at once`, + } + } if (!workspaceId) { return { success: false, message: 'Workspace ID is required' } } @@ -555,23 +699,23 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> const failed: string[] = [] for (const tableId of tableIds) { - const table = await getTableById(tableId) - if (!table || table.workspaceId !== workspaceId) { - failed.push(tableId) - continue - } - - const requestId = generateId().slice(0, 8) - assertNotAborted() - const deleteOutcome = await performDeleteTable({ - table, - userId: context.userId, - requestId, - }) - if (!deleteOutcome.success) { - return { success: false, message: deleteOutcome.error ?? 'Failed to delete table' } + try { + assertNotAborted() + await executeCopilotTableUseCase( + context, + deleteTableUseCase, + { tableId, workspaceId }, + { tableId } + ) + deleted.push(tableId) + } catch (error) { + const classified = messageForCopilotTableError(error, '') + if (classified === 'Table not found') { + failed.push(tableId) + continue + } + throw error } - deleted.push(tableId) } return { @@ -592,30 +736,23 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> return { success: false, message: 'Workspace ID is required' } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - - const requestId = generateId().slice(0, 8) assertNotAborted() - // The LLM authors row data by column name; storage keys by id. - const idByName = buildIdByName(table.schema) - const toNamedRow = namedRowMapper(table.schema.columns) - const rowData = rowDataNameToId(args.data, idByName) - const row = await insertRow( + const result = await executeCopilotTableUseCase( + context, + createTableRows, { + kind: 'single', tableId: args.tableId, - data: rowData, - workspaceId, - userId: context.userId, + assertedWorkspaceId: workspaceId, + data: args.data, position: args.position as number | undefined, - secretProvenance: createExactEmptyTableRowSecretProvenance(rowData), + secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), }, - table, - requestId + { tableId: args.tableId } ) - signalTableRowsChanged(args.tableId) + if (result.kind !== 'single') throw new Error('Single row insert returned a batch') + const { table, row } = result + const toNamedRow = namedRowMapper(table.schema.columns) return { success: true, @@ -640,28 +777,23 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> return { success: false, message: 'Workspace ID is required' } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - - const requestId = generateId().slice(0, 8) assertNotAborted() - const idByName = buildIdByName(table.schema) - const toNamedRow = namedRowMapper(table.schema.columns) - const rowData = args.rows.map((row: RowData) => rowDataNameToId(row, idByName)) - const rows = await batchInsertRows( + const sourceRows = args.rows as RowData[] + const result = await executeCopilotTableUseCase( + context, + createTableRows, { + kind: 'batch', tableId: args.tableId, - rows: rowData, - workspaceId, - userId: context.userId, - secretProvenance: rowData.map(createExactEmptyTableRowSecretProvenance), + assertedWorkspaceId: workspaceId, + rows: sourceRows, + secretProvenance: sourceRows.map(createExactEmptyTableRowSecretProvenance), }, - table, - requestId + { tableId: args.tableId } ) - signalTableRowsChanged(args.tableId) + if (result.kind !== 'batch') throw new Error('Batch row insert returned one row') + const { table, rows } = result + const toNamedRow = namedRowMapper(table.schema.columns) return { success: true, @@ -687,14 +819,16 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> return { success: false, message: 'Workspace ID is required' } } - const rowTable = await getTableById(args.tableId) - if (!rowTable || rowTable.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - const row = await getRowById(args.tableId, args.rowId, workspaceId) - if (!row) { - return { success: false, message: `Row not found: ${args.rowId}` } - } + const { table: rowTable, row } = await executeCopilotTableUseCase( + context, + readTableRow, + { + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + rowId: args.rowId, + }, + { tableId: args.tableId } + ) await importRowsForModel([row], context) const toNamedRow = namedRowMapper(rowTable.schema.columns) @@ -723,66 +857,24 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> return { success: false, message: queryLimitError } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - - const requestId = generateId().slice(0, 8) - const idByName = buildIdByName(table.schema) - // Typed predicate/sort objects, validated against the schema (column - // NAMES) then translated to storage ids. - let predicate: TablePredicate | undefined - if (args.filter) { - const filter = args.filter as TablePredicateInput - validatePredicate(filter, table.schema.columns) - const normalizedFilter = normalizeTablePredicate(filter) - predicate = predicateToStorage(normalizedFilter, table.schema) - } - let orderSpec = args.order as SortSpec | undefined - if (orderSpec?.length) { - validateSortSpec(orderSpec, table.schema.columns) - orderSpec = sortSpecNamesToIds(orderSpec, idByName) - } - const sort = orderSpec?.length - ? Object.fromEntries(orderSpec.map((s) => [s.field, s.direction])) - : undefined - - // Opaque cursor pagination (keyset seek on the default order; the token - // hides an internal offset only for custom-sorted views, which a keyset - // physically can't page). A keyset cursor is bound to the default order, - // so it can't be combined with a fresh sort. - const cursor = args.cursor ? decodeCursor(args.cursor) : undefined - if (cursor) { - try { - // Keyset cursors bind to the default order; offset cursors to the - // exact sort they were minted under. - assertCursorSortBinding(cursor, sort) - } catch (bindError) { - return { success: false, message: getErrorMessage(bindError, 'Invalid cursor') } - } - } - - // No limit returns the ENTIRE matching result, failing fast once the - // 5MB byte budget is exceeded (caught below → structured tool error - // the model can react to by adding a filter or a limit). An explicit - // limit pages; byte-cut pages set nextCursor and the message says to - // continue with the opaque cursor. - const toNamedRow = namedRowMapper(table.schema.columns) - const result = await queryRows( - table, + const result = await executeCopilotTableUseCase( + context, + queryTableRows, { - predicate, - sort, + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + predicate: args.filter + ? normalizeTablePredicate(args.filter as TablePredicateInput) + : undefined, + sort: args.order as SortSpec | undefined, limit: args.limit, - after: cursor?.after, - offset: cursor?.offset, - // Only the first page (no inbound cursor) pays for the COUNT(*). + cursor: args.cursor, includeTotal: !args.cursor, - withExecutions: false, }, - requestId + { tableId: args.tableId } ) + const { table } = result + const toNamedRow = namedRowMapper(table.schema.columns) await importRowsForModel(result.rows, context) // nextCursor covers both cut kinds (explicit limit or the 5MB byte @@ -819,40 +911,21 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> return { success: false, message: 'Workspace ID is required' } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - - const requestId = generateId().slice(0, 8) assertNotAborted() - const idByName = buildIdByName(table.schema) - const toNamedRow = namedRowMapper(table.schema.columns) - const rowData = rowDataNameToId(args.data, idByName) - const updatedRow = await updateRow( + const { table, row: updatedRow } = await executeCopilotTableUseCase( + context, + updateTableRow, { tableId: args.tableId, + assertedWorkspaceId: workspaceId, rowId: args.rowId, - data: rowData, - workspaceId, - actorUserId: context.userId, - secretProvenance: createExactEmptyTableRowSecretProvenance(rowData), + data: args.data, + secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), }, - table, - requestId + { tableId: args.tableId } ) - if (!updatedRow) { - // Only the cell-task path passes a `cancellationGuard`; this caller - // doesn't, so the guard never trips here. Defensive narrowing. - return { success: false, message: 'Row update was skipped' } - } + const toNamedRow = namedRowMapper(table.schema.columns) await importRowsForModel([updatedRow], context) - signalTableRowsChanged(args.tableId) - // Auto-dispatch for user edits is handled inside `updateRow` - // (mode: 'new' for newly-cleared groups + cancel+rerun for in-flight - // downstream groups). Firing a second mode: 'incomplete' dispatch - // here would race with the internal one AND bulk-clear sibling-group - // outputs (mode: 'incomplete' wipes terminal-state cells in scope). return { success: true, @@ -877,17 +950,17 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> return { success: false, message: 'Workspace ID is required' } } - const requestId = generateId().slice(0, 8) assertNotAborted() - const deleteRowTable = await getTableById(args.tableId) - // The old signature passed `workspaceId` into `deleteRow`, which scoped - // the query; taking a TableDefinition instead means the ownership check - // has to happen here, as every other operation in this tool does. - if (!deleteRowTable || deleteRowTable.workspaceId !== workspaceId) { - return { success: false, message: `Table ${args.tableId} not found` } - } - await deleteRow(deleteRowTable, args.rowId, requestId) - signalTableRowsChanged(args.tableId) + await executeCopilotTableUseCase( + context, + deleteTableRow, + { + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + rowId: args.rowId, + }, + { tableId: args.tableId } + ) return { success: true, @@ -962,7 +1035,13 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> // trusted continuation and does not re-check. assertRowUpdate(table, patchColumnIds(idData)) assertNotAborted() - const claimed = await markTableJobRunning(table.id, jobId, 'update', payload) + const claimed = await markTableJobRunningInWorkspace( + table.id, + workspaceId, + jobId, + 'update', + payload + ) if (!claimed) { return { success: false, message: 'A job is already in progress for this table' } } @@ -1062,7 +1141,13 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> // Gate the delete lock at enqueue — the worker is a trusted continuation. assertRowDelete(table) assertNotAborted() - const claimed = await markTableJobRunning(table.id, jobId, 'delete', payload) + const claimed = await markTableJobRunningInWorkspace( + table.id, + workspaceId, + jobId, + 'delete', + payload + ) if (!claimed) { return { success: false, message: 'A job is already in progress for this table' } } @@ -1090,36 +1175,39 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> // completes synchronously within this request before the slot is released. assertNotAborted() const inlineDeleteId = generateId() - const deleteClaimed = await markTableJobRunning(table.id, inlineDeleteId, 'delete') + const deleteClaimed = await markTableJobRunningInWorkspace( + table.id, + workspaceId, + inlineDeleteId, + 'delete' + ) if (!deleteClaimed) { return { success: false, message: 'A job is already in progress for this table' } } - let result: Awaited<ReturnType<typeof deleteRowsByFilter>> - try { - result = await deleteRowsByFilter( - table, - { filter: idFilter, limit: args.limit }, - requestId - ) - } finally { - await releaseJobClaim(table.id, inlineDeleteId).catch(() => {}) - } + const result = await withReleasedTableJobClaim( + table.id, + workspaceId, + inlineDeleteId, + () => deleteRowsByFilter(table, { filter: idFilter, limit: args.limit }, requestId) + ) if (result.affectedCount > 0) signalTableRowsChanged(args.tableId) - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: table.id, - resourceName: table.name, - description: `Deleted ${result.affectedCount} row(s) from table "${table.name}"`, - metadata: { - op: 'bulk_delete', - rowsDeleted: result.affectedCount, - source: 'tool_input', - }, - }) + if (result.affectedCount > 0) { + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: table.name, + description: `Deleted ${result.affectedCount} row(s) from table "${table.name}"`, + metadata: { + op: 'bulk_delete', + rowsDeleted: result.affectedCount, + source: 'tool_input', + }, + }) + } return { success: true, @@ -1224,28 +1312,19 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> } } - const requestId = generateId().slice(0, 8) assertNotAborted() - const batchDeleteTable = await getTableById(args.tableId) - if (!batchDeleteTable || batchDeleteTable.workspaceId !== workspaceId) { - return { success: false, message: `Table ${args.tableId} not found` } - } - const result = await deleteRowsByIds( - batchDeleteTable, - { tableId: args.tableId, rowIds, workspaceId }, - requestId + const result = await executeCopilotTableUseCase( + context, + deleteTableRows, + { + kind: 'ids', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + rowIds, + }, + { tableId: args.tableId } ) - if (result.deletedCount > 0) signalTableRowsChanged(args.tableId) - - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: args.tableId, - description: `Deleted ${result.deletedCount} row(s)`, - metadata: { op: 'bulk_delete', rowsDeleted: result.deletedCount, source: 'tool_input' }, - }) + if (result.kind !== 'ids') throw new Error('Row ID deletion returned a filter result') return { success: true, @@ -1321,8 +1400,14 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> deleteSourceFile: false, }) } catch (dispatchError) { - // The user never saw the placeholder — archive it back out. - await deleteTable(table.id, generateId().slice(0, 8)).catch(() => {}) + try { + await deleteTable(table.id, generateId().slice(0, 8)) + } catch (cleanupError) { + logger.error('Failed to remove placeholder table after import dispatch failure', { + tableId: table.id, + error: getErrorMessage(cleanupError), + }) + } throw dispatchError } return { @@ -1487,7 +1572,12 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> if (shouldImportInBackground(record)) { const importId = generateId() assertNotAborted() - const claimed = await markTableJobRunning(table.id, importId, 'import') + const claimed = await markTableJobRunningInWorkspace( + table.id, + workspaceId, + importId, + 'import' + ) if (!claimed) { return { success: false, message: 'A job is already in progress for this table' } } @@ -1516,11 +1606,16 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> // and contention is detected before the parse work is spent. const inlineImportId = generateId() assertNotAborted() - const inlineClaimed = await markTableJobRunning(table.id, inlineImportId, 'import') + const inlineClaimed = await markTableJobRunningInWorkspace( + table.id, + workspaceId, + inlineImportId, + 'import' + ) if (!inlineClaimed) { return { success: false, message: 'A job is already in progress for this table' } } - try { + return withReleasedTableJobClaim(table.id, workspaceId, inlineImportId, async () => { const file = { buffer: ( await readWorkspaceFileContent.execute({ @@ -1631,9 +1726,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> sourceFile: file.name, }, } - } finally { - await releaseJobClaim(table.id, inlineImportId).catch(() => {}) - } + }) } case 'add_column': { @@ -1660,11 +1753,6 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> message: 'column with name and type is required for add_column', } } - const tableForAdd = await getTableById(args.tableId) - if (!tableForAdd || tableForAdd.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - const requestId = generateId().slice(0, 8) assertNotAborted() if (col.currencyCode !== undefined && !isSupportedCurrencyCode(col.currencyCode)) { return { @@ -1677,8 +1765,12 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> col.type === 'select' ? { ...col, options: normalizeSelectOptionsInput(col.options) } : { ...col, options: undefined } - const updated = await addTableColumn(args.tableId, columnToAdd, requestId) - signalTableSchemaChanged(args.tableId) + const { table: updated } = await executeCopilotTableUseCase( + context, + addTableColumnUseCase, + { tableId: args.tableId, workspaceId, column: columnToAdd }, + { tableId: args.tableId } + ) return { success: true, message: `Added column "${col.name}" (${col.type}) to table`, @@ -1698,17 +1790,18 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> if (!colName || !newColName) { return { success: false, message: 'columnName and newName are required' } } - const tableForRename = await getTableById(args.tableId) - if (!tableForRename || tableForRename.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - const requestId = generateId().slice(0, 8) assertNotAborted() - const updated = await renameColumn( - { tableId: args.tableId, oldName: colName, newName: newColName }, - requestId + const { table: updated } = await executeCopilotTableUseCase( + context, + updateTableColumnUseCase, + { + tableId: args.tableId, + workspaceId, + columnName: colName, + updates: { name: newColName }, + }, + { tableId: args.tableId } ) - signalTableSchemaChanged(args.tableId) return { success: true, message: `Renamed column "${colName}" to "${newColName}"`, @@ -1729,28 +1822,31 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> if (!names || names.length === 0) { return { success: false, message: 'columnName or columnNames is required' } } - const tableForDelete = await getTableById(args.tableId) - if (!tableForDelete || tableForDelete.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - const requestId = generateId().slice(0, 8) if (names.length === 1) { assertNotAborted() - const updated = await deleteColumn( - { tableId: args.tableId, columnName: names[0] }, - requestId + const { table: updated } = await executeCopilotTableUseCase( + context, + deleteTableColumnUseCase, + { tableId: args.tableId, workspaceId, columnName: names[0] }, + { tableId: args.tableId } ) - signalTableSchemaChanged(args.tableId) return { success: true, message: `Deleted column "${names[0]}"`, data: { schema: updated.schema }, } } + await executeCopilotTableUseCase( + context, + readTableUseCase, + { tableId: args.tableId, workspaceId }, + { tableId: args.tableId } + ) assertNotAborted() const updated = await deleteColumns( { tableId: args.tableId, columnNames: names }, - requestId + generateId().slice(0, 8), + { expectedWorkspaceId: workspaceId } ) signalTableSchemaChanged(args.tableId) return { @@ -1801,31 +1897,30 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> message: `Invalid column type "${newType}". Must be one of: ${COLUMN_TYPES.join(', ')}`, } } - const tableForUpdate = await getTableById(args.tableId) - if (!tableForUpdate || tableForUpdate.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } assertNotAborted() - const outcome = await performUpdateTableColumn({ - table: tableForUpdate, - columnName: colName, - userId: context.userId, - updates: { - ...(newType !== undefined ? { type: newType as (typeof COLUMN_TYPES)[number] } : {}), - ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), - ...(rawOptions !== undefined ? { options: rawOptions } : {}), - ...(multiple !== undefined ? { multiple } : {}), - ...(currencyCode !== undefined ? { currencyCode } : {}), + const { table: updated } = await executeCopilotTableUseCase( + context, + updateTableColumnUseCase, + { + tableId: args.tableId, + workspaceId, + columnName: colName, + updates: { + ...(newType !== undefined + ? { type: newType as (typeof COLUMN_TYPES)[number] } + : {}), + ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), + ...(rawOptions !== undefined ? { options: rawOptions } : {}), + ...(multiple !== undefined ? { multiple } : {}), + ...(currencyCode !== undefined ? { currencyCode } : {}), + }, }, - }) - if (!outcome.success || !outcome.table) { - return { success: false, message: outcome.error ?? 'Failed to update column' } - } - signalTableSchemaChanged(args.tableId) + { tableId: args.tableId } + ) return { success: true, message: `Updated column "${colName}"`, - data: { schema: outcome.table.schema }, + data: { schema: updated.schema }, } } case 'rename': { @@ -1840,28 +1935,21 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> return { success: false, message: 'Workspace ID is required' } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - - const requestId = generateId().slice(0, 8) assertNotAborted() - const renameOutcome = await performRenameTable({ - table, - newName, - userId: context.userId, - requestId, - }) - if (!renameOutcome.success) { - return { success: false, message: renameOutcome.error ?? 'Failed to rename table' } + const result = await executeCopilotTableUseCase( + context, + updateTableUseCase, + { tableId: args.tableId, workspaceId, name: newName }, + { tableId: args.tableId } + ) + if (result.failure) { + throw result.failure } - signalTableSchemaChanged(args.tableId) return { success: true, message: `Renamed table to "${newName}"`, - data: { table: { id: args.tableId, name: newName } }, + data: { table: { id: args.tableId, name: result.table?.name ?? newName } }, } } @@ -1909,10 +1997,12 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> message: 'outputs array (with blockId + path entries) is required', } } - const tableForGroup = await getTableById(args.tableId) - if (!tableForGroup || tableForGroup.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } + const { table: tableForGroup } = await executeCopilotTableUseCase( + context, + readTableUseCase, + { tableId: args.tableId, workspaceId }, + { tableId: args.tableId } + ) for (const o of rawOutputs) { if (!o.blockId || !o.path) { @@ -1970,17 +2060,20 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> ...(deploymentMode ? { deploymentMode } : {}), outputs, } - const requestId = generateId().slice(0, 8) assertNotAborted() - // Mothership stages groups silently by default — the AI may add more - // columns or update deps before the user wants rows to fire. Caller - // can opt in by passing `autoRun: true`. const autoRun = args.autoRun === true - const updated = await addWorkflowGroup( - { tableId: args.tableId, group, outputColumns, autoRun, actorUserId: context.userId }, - requestId + const { table: updated } = await executeCopilotTableUseCase( + context, + createTableGroupUseCase, + { + tableId: args.tableId, + workspaceId, + group, + outputColumns, + autoRun, + }, + { tableId: args.tableId } ) - signalTableSchemaChanged(args.tableId) return { success: true, message: `Added workflow group "${name ?? groupId}" with ${outputs.length} output column(s)`, @@ -1998,10 +2091,12 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> if (!groupId) { return { success: false, message: 'groupId is required for update_workflow_group' } } - const tableForUpdate = await getTableById(args.tableId) - if (!tableForUpdate || tableForUpdate.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } + const { table: tableForUpdate } = await executeCopilotTableUseCase( + context, + readTableUseCase, + { tableId: args.tableId, workspaceId }, + { tableId: args.tableId } + ) const updateOutputs = args.outputs as WorkflowGroupOutput[] | undefined if (updateOutputs && updateOutputs.length > 0) { // Resolve which workflow these outputs apply to: explicit override @@ -2033,13 +2128,14 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> return { success: false, message: validationError } } } - const requestId = generateId().slice(0, 8) assertNotAborted() - const updated = await updateWorkflowGroup( + const { table: updated } = await executeCopilotTableUseCase( + context, + updateTableGroupUseCase, { tableId: args.tableId, + workspaceId, groupId, - actorUserId: context.userId, workflowId: args.workflowId as string | undefined, name: args.name as string | undefined, dependencies: args.dependencies as WorkflowGroupDependencies | undefined, @@ -2051,9 +2147,8 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> deploymentMode: parseDeploymentMode(args.deploymentMode), autoRun: typeof args.autoRun === 'boolean' ? args.autoRun : undefined, }, - requestId + { tableId: args.tableId } ) - signalTableSchemaChanged(args.tableId) return { success: true, message: `Updated workflow group ${groupId}`, @@ -2068,14 +2163,13 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> if (!groupId) { return { success: false, message: 'groupId is required for delete_workflow_group' } } - const tableForDelete = await getTableById(args.tableId) - if (!tableForDelete || tableForDelete.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - const requestId = generateId().slice(0, 8) assertNotAborted() - const updated = await deleteWorkflowGroup({ tableId: args.tableId, groupId }, requestId) - signalTableSchemaChanged(args.tableId) + const { table: updated } = await executeCopilotTableUseCase( + context, + deleteTableGroupUseCase, + { tableId: args.tableId, workspaceId, groupId }, + { tableId: args.tableId } + ) return { success: true, message: `Deleted workflow group ${groupId}`, @@ -2110,6 +2204,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> path, columnName, actorUserId: context.userId, + workspaceId, }, requestId ) @@ -2139,7 +2234,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> const requestId = generateId().slice(0, 8) assertNotAborted() const updated = await deleteWorkflowGroupOutput( - { tableId: args.tableId, groupId, columnName }, + { tableId: args.tableId, groupId, columnName, workspaceId }, requestId ) signalTableSchemaChanged(args.tableId) @@ -2187,17 +2282,20 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> } rowIds = rawRowIds as string[] } - const requestId = generateId().slice(0, 8) assertNotAborted() - const { dispatchId } = await runWorkflowColumn({ - tableId: args.tableId, - workspaceId, - groupIds, - mode: runMode, - rowIds, - requestId, - triggeredByUserId: context.userId, - }) + const { dispatchId } = await executeCopilotTableUseCase( + context, + startTableRun, + { + kind: 'selection', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + groupIds, + mode: runMode, + rowIds, + }, + { tableId: args.tableId } + ) const scopeLabel = rowIds ? `${rowIds.length} row(s) by id` : runMode return { success: true, @@ -2220,14 +2318,23 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> if (scope === 'row' && !rowId) { return { success: false, message: 'rowId is required when scope is "row"' } } - const tableForCancel = await getTableById(args.tableId) - if (!tableForCancel || tableForCancel.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } assertNotAborted() - const cancelled = await cancelWorkflowGroupRuns( - args.tableId, - scope === 'row' ? rowId : undefined + const { cancelled } = await executeCopilotTableUseCase( + context, + cancelTableRuns, + scope === 'row' + ? { + scope: 'row', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + rowId: rowId as string, + } + : { + scope: 'all', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + }, + { tableId: args.tableId } ) return { success: true, @@ -2377,8 +2484,10 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> error: errorMessage, cause, }) - const displayMessage = cause ? `${errorMessage} (${cause})` : errorMessage - return { success: false, message: `Operation failed: ${displayMessage}` } + return { + success: false, + message: `Operation failed: ${messageForCopilotTableError(error)}`, + } } }, } diff --git a/apps/sim/lib/folders/orchestration.test.ts b/apps/sim/lib/folders/orchestration.test.ts index 3c5a87dfaec..013bd4c166c 100644 --- a/apps/sim/lib/folders/orchestration.test.ts +++ b/apps/sim/lib/folders/orchestration.test.ts @@ -75,6 +75,7 @@ import { createFolderAtPathTransition, deleteFolder, deleteFolderByPath, + deleteFolderByPathTransition, relocateFolderByPath, restoreFolder, updateFolder, @@ -415,6 +416,32 @@ describe('path-owned folder mutations', () => { expect(result).toMatchObject({ success: true, path: '/Reports' }) }) + it('returns authoritative folder identity without double-auditing for application projection', async () => { + const source = folderRow({ id: 'folder-1', name: 'Reports' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['folder-1', source]]), + pathById: new Map([['folder-1', '/Reports']]), + idByPath: new Map([['/Reports', 'folder-1']]), + }) + mockArchiveFolderCascade.mockResolvedValueOnce({ folders: 1, children: 2 }) + + const result = await deleteFolderByPathTransition({ + resourceType: 'table', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports', + recursive: true, + }) + + expect(result).toMatchObject({ + success: true, + folderId: 'folder-1', + folderName: 'Reports', + deletedItems: { folders: 1, tables: 2 }, + }) + expect(auditMock.recordAudit).not.toHaveBeenCalled() + }) + it('rejects relocating a folder beneath its own descendant before writing', async () => { const source = folderRow({ id: 'folder-1', name: 'Reports' }) mockLoadActiveFolderPathIndex.mockResolvedValue({ diff --git a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts index 889e75de2d8..2730adf8431 100644 --- a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts +++ b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts @@ -45,7 +45,12 @@ vi.mock('@/lib/table/validation', () => ({ checkBatchUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })), })) -import { deleteRowsByFilter, queryRows, updateRowsByFilter } from '@/lib/table/rows/service' +import { + deleteRowsByFilter, + queryRows, + requireTableRowIds, + updateRowsByFilter, +} from '@/lib/table/rows/service' const COLUMNS: ColumnDefinition[] = [ { name: 'name', type: 'string' }, @@ -99,7 +104,6 @@ describe('service filter threading', () => { }) it('updateRowsByFilter forwards table.schema.columns to buildFilterClause', async () => { - dbChainMockFns.where.mockResolvedValueOnce([]) await updateRowsByFilter( TABLE, { filter: { birthDate: { $lt: '2024-06-01' } }, data: { name: 'x' } }, @@ -114,8 +118,20 @@ describe('service filter threading', () => { ) }) + it('treats an empty bulk patch as a no-op before selecting rows', async () => { + const result = await updateRowsByFilter( + TABLE, + { filter: { score: { $gt: 0 } }, data: {} }, + 'req-1' + ) + + expect(result).toEqual({ affectedCount: 0, affectedRowIds: [] }) + expect(buildFilterClause).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it('deleteRowsByFilter forwards table.schema.columns to buildFilterClause', async () => { - dbChainMockFns.where.mockResolvedValueOnce([]) await deleteRowsByFilter(TABLE, { filter: { score: { $gt: 90 } } }, 'req-1') expect(buildFilterClause).toHaveBeenCalledTimes(1) @@ -125,6 +141,28 @@ describe('service filter threading', () => { COLUMNS ) }) + + it('verifies explicit row selections in bounded canonical-scope chunks', async () => { + const rowIds = Array.from( + { length: TABLE_LIMITS.DELETE_BATCH_SIZE + 1 }, + (_, index) => `row-${index}` + ) + dbChainMockFns.where + .mockResolvedValueOnce([{ count: TABLE_LIMITS.DELETE_BATCH_SIZE }]) + .mockResolvedValueOnce([{ count: 1 }]) + + await expect(requireTableRowIds(TABLE.id, TABLE.workspaceId, rowIds)).resolves.toBeUndefined() + expect(dbChainMockFns.select).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.where).toHaveBeenCalledTimes(2) + }) + + it('conceals a missing explicit row selection', async () => { + dbChainMockFns.where.mockResolvedValueOnce([{ count: 0 }]) + + await expect( + requireTableRowIds(TABLE.id, TABLE.workspaceId, ['missing-row']) + ).rejects.toMatchObject({ code: 'not_found' }) + }) }) describe('bulk update/delete limited-subset ordering', () => { @@ -143,10 +181,10 @@ describe('bulk update/delete limited-subset ordering', () => { expect(dbChainMockFns.limit).toHaveBeenCalledWith(5) }) - it('does not order an unbounded updateRowsByFilter', async () => { - dbChainMockFns.where.mockResolvedValueOnce([]) + it('orders and caps an updateRowsByFilter without an explicit limit', async () => { await updateRowsByFilter(TABLE, { filter: { score: { $gt: 0 } }, data: { name: 'x' } }, 'req-1') - expect(dbChainMockFns.orderBy).not.toHaveBeenCalled() + expect(dbChainMockFns.orderBy).toHaveBeenCalled() + expect(dbChainMockFns.limit).toHaveBeenCalledWith(TABLE_LIMITS.MAX_BULK_OPERATION_SIZE + 1) }) it('orders the match query when deleteRowsByFilter has a limit', async () => { diff --git a/apps/sim/lib/table/__tests__/update-row.test.ts b/apps/sim/lib/table/__tests__/update-row.test.ts index 461d6cecbe1..91bdef534c9 100644 --- a/apps/sim/lib/table/__tests__/update-row.test.ts +++ b/apps/sim/lib/table/__tests__/update-row.test.ts @@ -330,6 +330,7 @@ describe('mutation paths — SET LOCAL timeouts', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + dbChainMockFns.execute.mockResolvedValue([{ count: 0 }]) }) it('insertRow sets the default 10s/3s/5s timeouts', async () => { @@ -421,6 +422,20 @@ describe('mutation paths — SET LOCAL timeouts', () => { expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true) expect(findExecutedSqlContaining('hashtextextended')).toBe(true) }) + + it('replaceTableRows reports the authoritative bounded delete count', async () => { + dbChainMockFns.execute.mockResolvedValue([{ count: 7 }]) + + const result = await replaceTableRows( + { tableId: 'tbl-1', workspaceId: 'ws-1', rows: [] }, + { ...TABLE, rowCount: 7 }, + 'req-1' + ) + + expect(result).toEqual({ deletedCount: 7, insertedCount: 0 }) + expect(findExecutedSqlContaining('DELETE FROM')).toBe(true) + expect(findExecutedSqlContaining('SELECT count(*)::integer AS count FROM deleted')).toBe(true) + }) }) describe('batchUpdateRows — per-row partial merge', () => { diff --git a/apps/sim/lib/table/api/index.ts b/apps/sim/lib/table/api/index.ts new file mode 100644 index 00000000000..962b274a4f3 --- /dev/null +++ b/apps/sim/lib/table/api/index.ts @@ -0,0 +1 @@ +export { v2TableErrorPolicies } from '@/lib/table/api/route-policies' diff --git a/apps/sim/lib/table/api/route-policies.ts b/apps/sim/lib/table/api/route-policies.ts new file mode 100644 index 00000000000..a6b270166d1 --- /dev/null +++ b/apps/sim/lib/table/api/route-policies.ts @@ -0,0 +1,54 @@ +import type { V2ErrorPolicy } from '@/lib/api/server/routes' +import { TableOperationError } from '@/lib/table/application/errors' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { + v2CaughtOrchestrationError, + v2Error, + v2ErrorForOrchestration, +} from '@/app/api/v2/lib/response' + +function renderTableError(error: unknown) { + if (error instanceof TableOperationError) { + return v2ErrorForOrchestration( + error.code, + error.message, + error.code === 'locked' + ? { ...(error.lock ? { lock: error.lock } : {}), ...error.details } + : error.details + ) + } + if (error instanceof TableLockedError) { + return v2Error('LOCKED', error.message, { details: { lock: error.lock } }) + } + return v2CaughtOrchestrationError(error) +} + +export const v2TableErrorPolicies = { + default: { + render: renderTableError, + } satisfies V2ErrorPolicy, + concealTableAuthorization: { + render(error) { + const response = renderTableError(error) + if (!response) return null + if (response.status === 403) return v2Error('NOT_FOUND', 'Table not found') + return response + }, + } satisfies V2ErrorPolicy, + concealImportAuthorization: { + render(error) { + const response = renderTableError(error) + if (!response) return null + if (response.status === 403) return v2Error('NOT_FOUND', 'Table import not found') + return response + }, + } satisfies V2ErrorPolicy, + concealExportAuthorization: { + render(error) { + const response = renderTableError(error) + if (!response) return null + if (response.status === 403) return v2Error('NOT_FOUND', 'Table export not found') + return response + }, + } satisfies V2ErrorPolicy, +} as const diff --git a/apps/sim/lib/table/api/row-route-policies.ts b/apps/sim/lib/table/api/row-route-policies.ts new file mode 100644 index 00000000000..08dba0ffdda --- /dev/null +++ b/apps/sim/lib/table/api/row-route-policies.ts @@ -0,0 +1,13 @@ +import type { V2ErrorPolicy } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api/route-policies' +import { TableRowsValidationError } from '@/lib/table/application/rows' +import { v2Error } from '@/app/api/v2/lib/response' + +export const v2TableRowsErrorPolicy = { + render(error) { + if (error instanceof TableRowsValidationError) { + return v2Error('BAD_REQUEST', error.message, { details: error.details }) + } + return v2TableErrorPolicies.concealTableAuthorization.render(error) + }, +} satisfies V2ErrorPolicy diff --git a/apps/sim/lib/table/application/authorization.test.ts b/apps/sim/lib/table/application/authorization.test.ts new file mode 100644 index 00000000000..f5a2ae99f7b --- /dev/null +++ b/apps/sim/lib/table/application/authorization.test.ts @@ -0,0 +1,160 @@ +/** + * @vitest-environment node + */ + +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const resolvePermission = vi.hoisted(() => vi.fn()) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: resolvePermission, +})) + +import type { OrchestrationError } from '@/lib/core/orchestration/types' +import { authorizeTableOperation } from '@/lib/table/application/authorization' +import { tableOperations } from '@/lib/table/application/operations' + +const authorizationContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-user-1', + tableId: 'table-1', +} + +async function expectForbidden(principal: Principal) { + await expect( + authorizeTableOperation(principal, tableOperations.updateRow, authorizationContext) + ).rejects.toMatchObject<Partial<OrchestrationError>>({ code: 'forbidden' }) +} + +describe('table operation authorization', () => { + beforeEach(() => { + vi.clearAllMocks() + resolvePermission.mockResolvedValue('write') + }) + + it('reauthorizes session and personal-key subjects against current policy', async () => { + await authorizeTableOperation( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + tableOperations.updateRow, + authorizationContext + ) + await authorizeTableOperation( + { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + tableOperations.updateRow, + authorizationContext + ) + + expect(resolvePermission).toHaveBeenCalledTimes(2) + expect(resolvePermission).toHaveBeenNthCalledWith( + 1, + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + }) + + it('rejects a current reader for a write operation', async () => { + resolvePermission.mockResolvedValue('read') + + await expectForbidden({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + }) + + it('rejects disabled personal keys before permission lookup', async () => { + await expect( + authorizeTableOperation( + { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + tableOperations.updateRow, + { ...authorizationContext, allowPersonalApiKeys: false } + ) + ).rejects.toMatchObject<Partial<OrchestrationError>>({ code: 'forbidden' }) + expect(resolvePermission).not.toHaveBeenCalled() + }) + + it('allows workspace keys only in their credential workspace', async () => { + await authorizeTableOperation( + { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + tableOperations.updateRow, + authorizationContext + ) + expect(resolvePermission).not.toHaveBeenCalled() + + await expectForbidden({ + kind: 'workspace_api_key', + workspaceId: 'workspace-2', + keyId: 'key-2', + }) + }) + + it('reauthorizes a valid table-scoped delegation as its human subject', async () => { + await authorizeTableOperation( + { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:tables', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { tableId: 'table-1', chatId: 'chat-1' }, + }, + tableOperations.updateRow, + authorizationContext + ) + + expect(resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + }) + + it('rejects wrong-audience, expired, cross-workspace, and wrong-table delegations before lookup', async () => { + const base = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:tables', + issuedAt: new Date(Date.now() - 10_000), + } + + await expectForbidden({ + ...base, + audience: 'sim:files', + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { tableId: 'table-1' }, + }) + await expectForbidden({ + ...base, + expiresAt: new Date(Date.now() - 1), + resourceScope: { tableId: 'table-1' }, + }) + await expectForbidden({ + ...base, + workspaceId: 'workspace-2', + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { tableId: 'table-1' }, + }) + await expectForbidden({ + ...base, + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { tableId: 'table-2' }, + }) + expect(resolvePermission).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/authorization.ts b/apps/sim/lib/table/application/authorization.ts new file mode 100644 index 00000000000..03612e1d534 --- /dev/null +++ b/apps/sim/lib/table/application/authorization.ts @@ -0,0 +1,42 @@ +import type { Principal } from '@sim/auth/principal' +import { + authorizeWorkspaceOperation, + type WorkspaceAuthorizationContext, + type WorkspaceDelegationPolicy, +} from '@/lib/core/application' +import type { TableOperation } from '@/lib/table/application/operations' + +export const TABLE_DELEGATION_AUDIENCE = 'sim:tables' + +export interface TableAuthorizationContext extends WorkspaceAuthorizationContext { + tableId?: string + rowId?: string + viewId?: string + groupId?: string + importId?: string + exportId?: string + billedAccountUserId: string +} + +export const tableDelegationPolicy: WorkspaceDelegationPolicy<TableAuthorizationContext> = { + audience: TABLE_DELEGATION_AUDIENCE, + isWithinScope( + principal: Extract<Principal, { kind: 'delegated' }>, + context: TableAuthorizationContext + ) { + return ( + principal.resourceScope?.tableId === undefined || + principal.resourceScope.tableId === context.tableId + ) + }, +} + +export function authorizeTableOperation( + principal: Principal, + operation: TableOperation, + context: TableAuthorizationContext +) { + return authorizeWorkspaceOperation(principal, operation, context, { + delegation: tableDelegationPolicy, + }) +} diff --git a/apps/sim/lib/table/application/authorized-table-use-case.ts b/apps/sim/lib/table/application/authorized-table-use-case.ts new file mode 100644 index 00000000000..d5b26fa77b0 --- /dev/null +++ b/apps/sim/lib/table/application/authorized-table-use-case.ts @@ -0,0 +1,28 @@ +import { + type AuthorizedWorkspaceUseCaseDefinition, + defineAuthorizedWorkspaceUseCase, + type WorkspaceOperation, +} from '@/lib/core/application' +import { + type TableAuthorizationContext, + tableDelegationPolicy, +} from '@/lib/table/application/authorization' + +type AuthorizedTableUseCaseDefinition< + O extends WorkspaceOperation, + I, + C extends TableAuthorizationContext, + R, +> = Omit<AuthorizedWorkspaceUseCaseDefinition<O, I, C, R>, 'authorizationOptions'> + +export function defineAuthorizedTableUseCase< + const O extends WorkspaceOperation, + I, + C extends TableAuthorizationContext, + R, +>(definition: AuthorizedTableUseCaseDefinition<O, I, C, R>) { + return defineAuthorizedWorkspaceUseCase({ + ...definition, + authorizationOptions: { delegation: tableDelegationPolicy }, + }) +} diff --git a/apps/sim/lib/table/application/columns.ts b/apps/sim/lib/table/application/columns.ts new file mode 100644 index 00000000000..eae6c59b4f7 --- /dev/null +++ b/apps/sim/lib/table/application/columns.ts @@ -0,0 +1,160 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { generateRequestId } from '@/lib/core/utils/request' +import { + addTableColumn, + type ColumnDefinition, + type ColumnType, + deleteColumn, + type SelectOption, + type TableDefinition, +} from '@/lib/table' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { resolveActiveTableContext } from '@/lib/table/application/context' +import { throwTableOperationFailure } from '@/lib/table/application/errors' +import { tableOperations } from '@/lib/table/application/operations' +import { signalTableSchemaChanged } from '@/lib/table/events' +import { performUpdateTableColumn } from '@/lib/table/orchestration' + +interface TableColumnInput { + tableId: string + workspaceId: string +} + +export interface AddTableColumnInput extends TableColumnInput { + column: { + id?: string + name: string + type: string + required?: boolean + unique?: boolean + position?: number + options?: SelectOption[] + multiple?: boolean + currencyCode?: string + } +} + +export const addTableColumnUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.addColumn, + resolveContext: ({ input }: { input: AddTableColumnInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ input, context }) { + const table = await addTableColumn(context.table.id, input.column, generateRequestId(), { + expectedWorkspaceId: context.workspaceId, + }) + return { table } + }, + projectAudit({ input, context, result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Added column "${input.column.name}" to table "${context.table.name}"`, + metadata: { column: input.column }, + } + }, + afterSuccess({ context }) { + signalTableSchemaChanged(context.table.id) + }, +}) + +export interface UpdateTableColumnInput extends TableColumnInput { + columnName: string + updates: { + name?: string + type?: ColumnType + required?: boolean + unique?: boolean + options?: unknown + multiple?: boolean + currencyCode?: string + } +} + +export const updateTableColumnUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.updateColumn, + resolveContext: ({ input }: { input: UpdateTableColumnInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const outcome = await performUpdateTableColumn({ + table: context.table, + columnName: input.columnName, + userId: attribution.attributedUserId, + updates: input.updates, + requestId: generateRequestId(), + expectedWorkspaceId: context.workspaceId, + recordAudit: false, + }) + if (!outcome.success || !outcome.table) { + throwTableOperationFailure(outcome, 'Failed to update column') + } + return { + table: outcome.table, + changed: + JSON.stringify(context.table.schema) !== JSON.stringify(outcome.table.schema) || + JSON.stringify(context.table.metadata) !== JSON.stringify(outcome.table.metadata), + } + }, + projectAudit({ input, context, result }) { + if (!result.changed) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Updated column "${input.columnName}" in table "${context.table.name}"`, + metadata: { columnName: input.columnName, updates: input.updates }, + } + }, + afterSuccess({ context, result }) { + if (result.changed) signalTableSchemaChanged(context.table.id) + }, +}) + +export interface DeleteTableColumnInput extends TableColumnInput { + columnName: string +} + +export const deleteTableColumnUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteColumn, + resolveContext: ({ input }: { input: DeleteTableColumnInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ input, context }): Promise<{ table: TableDefinition }> { + const table = await deleteColumn( + { tableId: context.table.id, columnName: input.columnName }, + generateRequestId(), + { expectedWorkspaceId: context.workspaceId } + ) + return { table } + }, + projectAudit({ input, context, result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Deleted column "${input.columnName}" from table "${context.table.name}"`, + metadata: { columnName: input.columnName }, + } + }, + afterSuccess({ context }) { + signalTableSchemaChanged(context.table.id) + }, +}) + +export type TableColumnApplicationResult = { table: TableDefinition } +export type TableColumnDefinition = ColumnDefinition diff --git a/apps/sim/lib/table/application/context.test.ts b/apps/sim/lib/table/application/context.test.ts new file mode 100644 index 00000000000..bc6f2b41186 --- /dev/null +++ b/apps/sim/lib/table/application/context.test.ts @@ -0,0 +1,71 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { getTableById, select } = vi.hoisted(() => ({ + getTableById: vi.fn(), + select: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ db: { select } })) +vi.mock('@/lib/table', () => ({ getTableById })) + +import { resolveActiveTableContext } from '@/lib/table/application/context' + +function mockWorkspaceQuery(rows: unknown[]) { + const limit = vi.fn().mockResolvedValue(rows) + const where = vi.fn(() => ({ limit })) + const from = vi.fn(() => ({ where })) + select.mockReturnValue({ from }) + return { from, where, limit } +} + +describe('table application context', () => { + beforeEach(() => { + vi.clearAllMocks() + getTableById.mockResolvedValue({ + id: 'table-1', + workspaceId: 'workspace-1', + name: 'Contacts', + }) + }) + + it('derives workspace scope from the canonical active table', async () => { + mockWorkspaceQuery([ + { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-user-1', + }, + ]) + + await expect( + resolveActiveTableContext({ tableId: 'table-1', assertedWorkspaceId: 'workspace-1' }) + ).resolves.toMatchObject({ + tableId: 'table-1', + workspaceId: 'workspace-1', + billedAccountUserId: 'billing-user-1', + }) + expect(getTableById).toHaveBeenCalledWith('table-1') + expect(select).toHaveBeenCalledTimes(1) + }) + + it('conceals an asserted cross-workspace table before workspace resolution', async () => { + await expect( + resolveActiveTableContext({ tableId: 'table-1', assertedWorkspaceId: 'workspace-2' }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Table not found' }) + expect(select).not.toHaveBeenCalled() + }) + + it('fails when the canonical workspace is unavailable', async () => { + mockWorkspaceQuery([]) + + await expect(resolveActiveTableContext({ tableId: 'table-1' })).rejects.toMatchObject({ + code: 'not_found', + message: 'Workspace not found', + }) + }) +}) diff --git a/apps/sim/lib/table/application/context.ts b/apps/sim/lib/table/application/context.ts new file mode 100644 index 00000000000..2d9603aec8b --- /dev/null +++ b/apps/sim/lib/table/application/context.ts @@ -0,0 +1,46 @@ +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getTableById, type TableDefinition } from '@/lib/table' +import type { TableAuthorizationContext } from '@/lib/table/application/authorization' + +export type TableWorkspaceContext = TableAuthorizationContext + +export interface ActiveTableContext extends TableWorkspaceContext { + tableId: string + table: TableDefinition +} + +export async function resolveTableWorkspaceContext( + workspaceId: string +): Promise<TableWorkspaceContext> { + const [canonical] = await db + .select({ + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + if (!canonical) throw new OrchestrationError('not_found', 'Workspace not found') + return canonical +} + +export async function resolveActiveTableContext(input: { + tableId: string + assertedWorkspaceId?: string +}): Promise<ActiveTableContext> { + const table = await getTableById(input.tableId) + if ( + !table || + (input.assertedWorkspaceId !== undefined && table.workspaceId !== input.assertedWorkspaceId) + ) { + throw new OrchestrationError('not_found', 'Table not found') + } + const workspaceContext = await resolveTableWorkspaceContext(table.workspaceId) + return { ...workspaceContext, tableId: table.id, table } +} diff --git a/apps/sim/lib/table/application/delegated-principal.ts b/apps/sim/lib/table/application/delegated-principal.ts new file mode 100644 index 00000000000..db2c3365c61 --- /dev/null +++ b/apps/sim/lib/table/application/delegated-principal.ts @@ -0,0 +1,36 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { TABLE_DELEGATION_AUDIENCE } from '@/lib/table/application/authorization' + +const TABLE_DELEGATION_TTL_MS = 5 * 60 * 1000 + +export interface TableDelegationInput { + serviceId: DelegatedPrincipal['serviceId'] + subjectUserId: string + workspaceId: string + delegationId: string + tableId?: string + chatId?: string + executionId?: string +} + +export function createTableDelegatedPrincipal(input: TableDelegationInput): DelegatedPrincipal { + if (!input.subjectUserId || !input.workspaceId || !input.delegationId) { + throw new Error('Table delegation requires subject, workspace, and delegation IDs') + } + const issuedAt = new Date() + return { + kind: 'delegated', + serviceId: input.serviceId, + subjectUserId: input.subjectUserId, + workspaceId: input.workspaceId, + delegationId: input.delegationId, + audience: TABLE_DELEGATION_AUDIENCE, + issuedAt, + expiresAt: new Date(issuedAt.getTime() + TABLE_DELEGATION_TTL_MS), + resourceScope: { + ...(input.tableId ? { tableId: input.tableId } : {}), + ...(input.chatId ? { chatId: input.chatId } : {}), + ...(input.executionId ? { executionId: input.executionId } : {}), + }, + } +} diff --git a/apps/sim/lib/table/application/errors.ts b/apps/sim/lib/table/application/errors.ts new file mode 100644 index 00000000000..2b49a259cc9 --- /dev/null +++ b/apps/sim/lib/table/application/errors.ts @@ -0,0 +1,31 @@ +import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import type { TableLockKind } from '@/lib/table' + +export class TableOperationError extends OrchestrationError { + constructor( + code: OrchestrationErrorCode, + message: string, + readonly details?: Record<string, unknown>, + readonly lock?: TableLockKind + ) { + super(code, message) + this.name = 'TableOperationError' + } +} + +export function throwTableOperationFailure( + outcome: { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + lock?: TableLockKind + }, + fallback: string, + details?: Record<string, unknown> +): never { + if (outcome.success) throw new Error('Cannot throw a successful table operation outcome') + if (!outcome.errorCode || outcome.errorCode === 'internal') { + throw new Error(fallback) + } + throw new TableOperationError(outcome.errorCode, outcome.error ?? fallback, details, outcome.lock) +} diff --git a/apps/sim/lib/table/application/exports.ts b/apps/sim/lib/table/application/exports.ts new file mode 100644 index 00000000000..9ff24c64d42 --- /dev/null +++ b/apps/sim/lib/table/application/exports.ts @@ -0,0 +1,137 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { createLogger } from '@sim/logger' +import type { V2TableExport } from '@/lib/api/contracts/v2/tables' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getTableById, type TableDefinition } from '@/lib/table' +import type { TableAuthorizationContext } from '@/lib/table/application/authorization' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { + resolveActiveTableContext, + resolveTableWorkspaceContext, +} from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { + cancelTableExportResource, + createTableExportResource, + requireTableExport, + type TableExportRecord, + tableExportResult, + toV2TableExport, +} from '@/lib/table/orchestration/export-resource' +import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' + +const logger = createLogger('TableExportApplication') +const DOWNLOAD_TTL_SECONDS = 60 * 60 + +export interface CreateTableExportInput { + tableId: string + workspaceId: string + format: 'csv' | 'json' +} + +export interface TableExportResourceInput { + exportId: string + workspaceId: string +} + +export interface TableExportResult { + export: V2TableExport +} + +export interface DownloadTableExportResult { + url: string + fileName: string + expiresAt: string +} + +interface TableExportContext extends TableAuthorizationContext { + exportId: string + tableId: string + table: TableDefinition + record: TableExportRecord +} + +async function resolveTableExportContext( + input: TableExportResourceInput +): Promise<TableExportContext> { + const record = await requireTableExport(input.exportId, input.workspaceId) + const table = await getTableById(record.tableId) + if (!table || table.workspaceId !== record.workspaceId) { + throw new OrchestrationError('not_found', 'Table export not found') + } + const workspace = await resolveTableWorkspaceContext(record.workspaceId) + return { + ...workspace, + exportId: record.id, + tableId: table.id, + table, + record, + } +} + +export const createTableExportUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.createExport, + resolveContext: ({ input }: { input: CreateTableExportInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, input, context }): Promise<TableExportResult> { + const record = await createTableExportResource({ table: context.table, format: input.format }) + logger.info('Created table export', { + exportId: record.id, + tableId: context.table.id, + workspaceId: context.workspaceId, + format: input.format, + principalKind: principal.kind, + }) + return { export: toV2TableExport(record, true) } + }, + projectAudit: ({ input, context }) => ({ + action: AuditAction.TABLE_EXPORTED, + resourceType: AuditResourceType.TABLE, + resourceId: context.table.id, + resourceName: context.table.name, + description: `Exported table "${context.table.name}" as ${input.format.toUpperCase()}`, + metadata: { format: input.format, rowCount: context.table.rowCount }, + }), +}) + +export const readTableExportUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.readExport, + resolveContext: ({ input }: { input: TableExportResourceInput }) => + resolveTableExportContext(input), + async execute({ context }): Promise<TableExportResult> { + return { export: toV2TableExport(context.record) } + }, +}) + +export const cancelTableExportUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.cancelExport, + resolveContext: ({ input }: { input: TableExportResourceInput }) => + resolveTableExportContext(input), + async execute({ principal, context }): Promise<TableExportResult> { + const record = await cancelTableExportResource(context.record) + logger.info('Canceled table export', { + exportId: record.id, + tableId: context.tableId, + workspaceId: context.workspaceId, + principalKind: principal.kind, + }) + return { export: toV2TableExport(record) } + }, +}) + +export const downloadTableExportUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.downloadExport, + resolveContext: ({ input }: { input: TableExportResourceInput }) => + resolveTableExportContext(input), + async execute({ context }): Promise<DownloadTableExportResult> { + const result = tableExportResult(context.record) + return { + url: await generatePresignedDownloadUrl(result.resultKey, 'workspace', DOWNLOAD_TTL_SECONDS), + fileName: result.resultKey.split('/').pop() ?? `export.${result.format}`, + expiresAt: new Date(Date.now() + DOWNLOAD_TTL_SECONDS * 1000).toISOString(), + } + }, +}) diff --git a/apps/sim/lib/table/application/folder-paths.ts b/apps/sim/lib/table/application/folder-paths.ts new file mode 100644 index 00000000000..62683ff3504 --- /dev/null +++ b/apps/sim/lib/table/application/folder-paths.ts @@ -0,0 +1,33 @@ +import type { folder } from '@sim/db/schema' +import { withFolderTreeLock } from '@/lib/folders/locks' +import type { FolderPathIndex } from '@/lib/folders/paths' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' + +type FolderRow = typeof folder.$inferSelect + +export interface ResolvedTableFolderPath { + folderId: string | null + index: FolderPathIndex<FolderRow> +} + +export async function resolveTableFolderPath( + workspaceId: string, + path: string +): Promise<ResolvedTableFolderPath | null> { + return withFolderTreeLock(workspaceId, 'table', async (tx) => { + const index = await loadActiveFolderPathIndex(workspaceId, 'table', tx) + const folderId = resolveFolderPathFromIndex(index, path) + return folderId === undefined ? null : { folderId, index } + }) +} + +export function tableFolderPathForId( + index: FolderPathIndex<FolderRow>, + folderId: string | null | undefined +): string { + if (!folderId) return ROOT_FOLDER_PATH + const path = index.pathById.get(folderId) + if (!path) throw new Error('Table references an inactive or missing folder') + return path +} diff --git a/apps/sim/lib/table/application/folders.ts b/apps/sim/lib/table/application/folders.ts new file mode 100644 index 00000000000..4126481064e --- /dev/null +++ b/apps/sim/lib/table/application/folders.ts @@ -0,0 +1,179 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + createFolderAtPathTransition, + deleteFolderByPathTransition, + relocateFolderByPathTransition, +} from '@/lib/folders/orchestration' +import { + type FolderSortBy, + listActiveFolderRows, + loadActiveFolderPathIndex, + resolveFolderPathFromIndex, +} from '@/lib/folders/queries' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { resolveTableWorkspaceContext } from '@/lib/table/application/context' +import { throwTableOperationFailure } from '@/lib/table/application/errors' +import { tableOperations } from '@/lib/table/application/operations' + +export interface ListTableFoldersInput { + workspaceId: string + parentPath?: string + search?: string + sortBy?: Exclude<FolderSortBy, 'position'> + sortOrder?: V2SortOrder +} + +export const listTableFoldersUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.listFolders, + resolveContext: ({ input }: { input: ListTableFoldersInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ input, context }) { + const index = await loadActiveFolderPathIndex(context.workspaceId, 'table') + const parentId = + input.parentPath === undefined + ? undefined + : resolveFolderPathFromIndex(index, input.parentPath) + if (input.parentPath !== undefined && parentId === undefined) { + throw new OrchestrationError('not_found', 'Folder not found') + } + const folders = await listActiveFolderRows(context.workspaceId, 'table', { + parentId, + search: input.search, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + }) + return { folders, index } + }, +}) + +export interface CreateTableFolderInput { + workspaceId: string + path: string +} + +export const createTableFolderUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.createFolder, + resolveContext: ({ input }: { input: CreateTableFolderInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await createFolderAtPathTransition({ + resourceType: 'table', + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + path: input.path, + }) + if (!result.success || !result.folder) { + throwTableOperationFailure(result, 'Failed to create folder') + } + const index = await loadActiveFolderPathIndex(context.workspaceId, 'table') + return { folder: result.folder, index, path: input.path } + }, + projectAudit({ result }) { + return { + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Created table folder "${result.path}"`, + metadata: { path: result.path, folderResourceType: 'table' }, + } + }, +}) + +export interface UpdateTableFolderInput extends CreateTableFolderInput { + destinationPath: string +} + +export const updateTableFolderUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.updateFolder, + resolveContext: ({ input }: { input: UpdateTableFolderInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await relocateFolderByPathTransition({ + resourceType: 'table', + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + path: input.path, + destinationPath: input.destinationPath, + }) + if (!result.success || !result.folder) { + throwTableOperationFailure(result, 'Failed to move folder') + } + const index = await loadActiveFolderPathIndex(context.workspaceId, 'table') + return { folder: result.folder, index, path: input.destinationPath, sourcePath: input.path } + }, + projectAudit({ result }) { + return { + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Moved table folder to "${result.path}"`, + metadata: { + sourcePath: result.sourcePath, + destinationPath: result.path, + folderResourceType: 'table', + }, + } + }, +}) + +export interface DeleteTableFolderInput extends CreateTableFolderInput { + recursive: boolean +} + +export const deleteTableFolderUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteFolder, + resolveContext: ({ input }: { input: DeleteTableFolderInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await deleteFolderByPathTransition({ + resourceType: 'table', + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + path: input.path, + recursive: input.recursive, + }) + if (!result.success || !result.deletedItems || !result.folderId || !result.folderName) { + throwTableOperationFailure(result, 'Failed to delete folder') + } + return { + path: input.path, + deleted: true as const, + deletedItems: { + folders: result.deletedItems.folders, + tables: result.deletedItems.tables ?? 0, + }, + folder: { id: result.folderId, name: result.folderName }, + } + }, + projectAudit({ result }) { + return { + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Deleted table folder "${result.path}"`, + metadata: { + folderResourceType: 'table', + path: result.path, + affected: { + tables: result.deletedItems.tables, + subfolders: Math.max(result.deletedItems.folders - 1, 0), + }, + }, + } + }, +}) diff --git a/apps/sim/lib/table/application/groups.ts b/apps/sim/lib/table/application/groups.ts new file mode 100644 index 00000000000..665e887374f --- /dev/null +++ b/apps/sim/lib/table/application/groups.ts @@ -0,0 +1,279 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' +import { generateId } from '@sim/utils/id' +import type { V2AddWorkflowGroupBody } from '@/lib/api/contracts/v2/tables' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' +import type { + DeleteWorkflowGroupData, + TableDefinition, + TableSchema, + UpdateWorkflowGroupData, + WorkflowGroup, +} from '@/lib/table' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { resolveActiveTableContext } from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { startTableRun } from '@/lib/table/application/runs' +import { signalTableSchemaChanged } from '@/lib/table/events' +import { + addWorkflowGroup, + deleteWorkflowGroup, + updateWorkflowGroup, +} from '@/lib/table/workflow-groups/service' + +const logger = createLogger('TableGroupApplication') + +interface TableGroupInput { + tableId: string + workspaceId: string +} + +function groupFromTable(table: TableDefinition, groupId: string): WorkflowGroup { + const group = (table.schema as TableSchema).workflowGroups?.find( + (candidate) => candidate.id === groupId + ) + if (!group) { + throw new Error(`Workflow group ${groupId} missing from the table after a successful write`) + } + return group +} + +async function requireWorkflowInTableWorkspace( + workflowId: string, + workspaceId: string +): Promise<void> { + const workflow = await getActiveWorkflowContext(workflowId) + if (!workflow || workflow.workspaceId !== workspaceId) { + throw new OrchestrationError('validation', 'Workflow not found in this workspace') + } +} + +export const listTableGroupsUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.listGroups, + resolveContext: ({ input }: { input: TableGroupInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ context }) { + return { groups: (context.table.schema as TableSchema).workflowGroups ?? [] } + }, +}) + +export interface CreateTableGroupInput extends TableGroupInput { + group: V2AddWorkflowGroupBody['group'] + outputColumns: V2AddWorkflowGroupBody['outputColumns'] + autoRun?: boolean +} + +export const createTableGroupUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.createGroup, + resolveContext: ({ input }: { input: CreateTableGroupInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, input, context }) { + if (input.group.workflowId) { + await requireWorkflowInTableWorkspace(input.group.workflowId, context.workspaceId) + } + const outputNames = new Set(input.group.outputs.map((output) => output.columnName)) + const orphan = input.outputColumns.find((column) => !outputNames.has(column.name)) + if (orphan) { + throw new OrchestrationError( + 'validation', + `outputColumns entry "${orphan.name}" has no matching group.outputs[].columnName` + ) + } + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const groupId = input.group.id ?? generateId() + const table = await addWorkflowGroup( + { + tableId: context.table.id, + workspaceId: context.workspaceId, + group: { ...input.group, id: groupId } as WorkflowGroup, + outputColumns: input.outputColumns.map((column) => ({ + ...column, + workflowGroupId: groupId, + })), + autoRun: input.autoRun ?? false, + suppressAutoRunDispatch: true, + actorUserId: attribution.attributedUserId, + }, + generateRequestId() + ) + return { table, group: groupFromTable(table, groupId) } + }, + projectAudit({ result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Added workflow group "${result.group.id}" to table "${result.table.name}"`, + metadata: { op: 'add_group', groupId: result.group.id }, + } + }, + afterSuccess({ principal, input, context, result, request }) { + signalTableSchemaChanged(context.table.id) + if (input.autoRun === true) { + runDetached('table-group-create-auto-run', async () => { + await startTableRun.execute({ + principal, + input: { + kind: 'selection', + tableId: context.table.id, + assertedWorkspaceId: context.workspaceId, + groupIds: [result.group.id], + mode: 'all', + }, + request, + }) + logger.info('Started table group auto-run', { + tableId: context.table.id, + groupId: result.group.id, + }) + }) + } + }, +}) + +export interface UpdateTableGroupInput + extends TableGroupInput, + Omit< + UpdateWorkflowGroupData, + 'tableId' | 'workspaceId' | 'actorUserId' | 'suppressAutoRunDispatch' + > {} + +export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.updateGroup, + resolveContext: ({ input }: { input: UpdateTableGroupInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, input, context }) { + if (input.workflowId !== undefined) { + await requireWorkflowInTableWorkspace(input.workflowId, context.workspaceId) + } + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const previousGroup = (context.table.schema.workflowGroups ?? []).find( + (group) => group.id === input.groupId + ) + const table = await updateWorkflowGroup( + { + tableId: context.table.id, + workspaceId: context.workspaceId, + groupId: input.groupId, + actorUserId: attribution.attributedUserId, + suppressAutoRunDispatch: true, + ...(input.workflowId !== undefined ? { workflowId: input.workflowId } : {}), + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.dependencies !== undefined ? { dependencies: input.dependencies } : {}), + ...(input.outputs !== undefined ? { outputs: input.outputs } : {}), + ...(input.newOutputColumns !== undefined + ? { + newOutputColumns: input.newOutputColumns.map((column) => ({ + ...column, + workflowGroupId: input.groupId, + })), + } + : {}), + ...(input.mappingUpdates !== undefined ? { mappingUpdates: input.mappingUpdates } : {}), + ...(input.inputMappings !== undefined ? { inputMappings: input.inputMappings } : {}), + ...(input.deploymentMode !== undefined ? { deploymentMode: input.deploymentMode } : {}), + ...(input.type !== undefined ? { type: input.type } : {}), + ...(input.autoRun !== undefined ? { autoRun: input.autoRun } : {}), + }, + generateRequestId() + ) + const group = groupFromTable(table, input.groupId) + return { + table, + group, + changed: + JSON.stringify(context.table.schema) !== JSON.stringify(table.schema) || + JSON.stringify(context.table.metadata) !== JSON.stringify(table.metadata), + startAutoRun: previousGroup?.autoRun !== true && input.autoRun === true, + } + }, + projectAudit({ result }) { + if (!result.changed) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Updated workflow group "${result.group.id}" in table "${result.table.name}"`, + metadata: { op: 'update_group', groupId: result.group.id }, + } + }, + afterSuccess({ principal, context, result, request }) { + if (result.changed) signalTableSchemaChanged(context.table.id) + if (result.startAutoRun) { + runDetached('table-group-update-auto-run', async () => { + await startTableRun.execute({ + principal, + input: { + kind: 'selection', + tableId: context.table.id, + assertedWorkspaceId: context.workspaceId, + groupIds: [result.group.id], + mode: 'all', + }, + request, + }) + logger.info('Started table group auto-run', { + tableId: context.table.id, + groupId: result.group.id, + }) + }) + } + }, +}) + +export interface DeleteTableGroupInput + extends TableGroupInput, + Omit<DeleteWorkflowGroupData, 'tableId' | 'workspaceId'> {} + +export const deleteTableGroupUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteGroup, + resolveContext: ({ input }: { input: DeleteTableGroupInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ input, context }) { + const table = await deleteWorkflowGroup( + { + tableId: context.table.id, + workspaceId: context.workspaceId, + groupId: input.groupId, + }, + generateRequestId() + ) + return { table, groupId: input.groupId } + }, + projectAudit({ result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Deleted workflow group "${result.groupId}" from table "${result.table.name}"`, + metadata: { op: 'delete_group', groupId: result.groupId }, + } + }, + afterSuccess({ context }) { + signalTableSchemaChanged(context.table.id) + }, +}) diff --git a/apps/sim/lib/table/application/imports.ts b/apps/sim/lib/table/application/imports.ts new file mode 100644 index 00000000000..e55563ad2dc --- /dev/null +++ b/apps/sim/lib/table/application/imports.ts @@ -0,0 +1,294 @@ +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import type { + V2CreateTableImportBody, + V2CreateTableImportData, + V2TableImport, +} from '@/lib/api/contracts/v2/tables' +import { authorizeWorkspaceOperation } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { withFolderTreeLock } from '@/lib/folders/locks' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' +import { + type TableAuthorizationContext, + tableDelegationPolicy, +} from '@/lib/table/application/authorization' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { + resolveActiveTableContext, + resolveTableWorkspaceContext, +} from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { + abortAuthorizedTableImportUpload, + cancelTableImportResource, + createAuthorizedTableImportResource, + findTableImportResource, + getPrincipalTableImportUpload, + getTableImportResource, + startUploadedTableImport, + type TableImportResource, + tableImportBodyFromUpload, + toV2CreateTableImport, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +import { requestOrigin } from '@/lib/uploads/upload-session/application' +import { + assertUploadSessionAuthBinding, + completeUploadSession, + createUploadPartUrls, + type UploadSessionRecord, +} from '@/lib/uploads/upload-session/service' +import { readWorkspaceFileContentRecord } from '@/lib/workspace-files/application/read-workspace-file-record' + +const logger = createLogger('TableImportApplication') + +export interface CreateTableImportInput { + body: V2CreateTableImportBody +} + +export interface TableImportResourceInput { + importId: string + workspaceId: string +} + +export interface TableImportUploadInput extends TableImportResourceInput { + uploadToken: string +} + +export interface CreateTableImportPartsInput extends TableImportUploadInput { + partNumbers: number[] +} + +export interface CancelTableImportInput extends TableImportResourceInput { + uploadToken?: string +} + +export interface CreateTableImportResult { + import: V2CreateTableImportData +} + +export interface TableImportResult { + import: V2TableImport +} + +export interface CreateTableImportPartsResult { + parts: Awaited<ReturnType<typeof createUploadPartUrls>> +} + +interface TableImportContext extends TableAuthorizationContext { + importId: string + record: TableImportResource +} + +interface TableImportUploadContext extends TableAuthorizationContext { + importId: string + upload: UploadSessionRecord +} + +async function resolveCreateTableImportContext(input: CreateTableImportInput) { + if (input.body.target.type === 'existing') { + return resolveActiveTableContext({ + tableId: input.body.target.tableId, + assertedWorkspaceId: input.body.workspaceId, + }) + } + return resolveTableWorkspaceContext(input.body.workspaceId) +} + +async function resolveTableImportContext( + input: TableImportResourceInput +): Promise<TableImportContext> { + const record = await getTableImportResource({ + importId: input.importId, + assertedWorkspaceId: input.workspaceId, + }) + const workspace = await resolveTableWorkspaceContext(record.workspaceId) + return { + ...workspace, + importId: record.id, + ...(record.tableId ? { tableId: record.tableId } : {}), + record, + } +} + +async function resolveTableImportUploadContext( + principal: Principal, + input: TableImportUploadInput +): Promise<TableImportUploadContext> { + const upload = await getPrincipalTableImportUpload({ + importId: input.importId, + assertedWorkspaceId: input.workspaceId, + principal, + uploadToken: input.uploadToken, + }) + const body = tableImportBodyFromUpload(upload) + const workspace = await resolveTableWorkspaceContext(body.workspaceId) + return { + ...workspace, + importId: upload.id, + ...(body.target.type === 'existing' ? { tableId: body.target.tableId } : {}), + upload, + } +} + +async function resolveImportFolderId( + workspaceId: string, + body: V2CreateTableImportBody +): Promise<string | null | undefined> { + if (body.target.type !== 'new') return undefined + const path = body.target.folderPath ?? ROOT_FOLDER_PATH + return withFolderTreeLock(workspaceId, 'table', async (tx) => { + const index = await loadActiveFolderPathIndex(workspaceId, 'table', tx) + const folderId = resolveFolderPathFromIndex(index, path) + if (folderId === undefined) { + throw new OrchestrationError('not_found', 'Folder not found') + } + return folderId + }) +} + +export const createTableImportUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.createImport, + resolveContext: ({ input }: { input: CreateTableImportInput }) => + resolveCreateTableImportContext(input), + async execute({ principal, input, context, request }): Promise<CreateTableImportResult> { + if (principal.kind === 'delegated') { + throw new OrchestrationError( + 'forbidden', + input.body.source.type === 'upload' + ? 'Delegated principals cannot initiate table import uploads' + : 'Delegated principals cannot initiate workspace-file table imports' + ) + } + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const folderId = await resolveImportFolderId(context.workspaceId, input.body) + const workspaceFile = + input.body.source.type === 'workspace_file' + ? ( + await readWorkspaceFileContentRecord.execute({ + principal, + input: { + fileId: input.body.source.fileId, + assertedWorkspaceId: context.workspaceId, + }, + }) + ).file + : undefined + if (input.body.source.type === 'upload' && !request) { + throw new Error('Table import upload creation requires a request context') + } + const created = await createAuthorizedTableImportResource({ + body: input.body, + userId: attribution.attributedUserId, + principal, + localOrigin: request ? requestOrigin(request) : undefined, + resolvedFolderId: folderId, + workspaceFile, + }) + logger.info('Created table import', { + importId: created.record.id, + workspaceId: context.workspaceId, + sourceType: input.body.source.type, + targetType: input.body.target.type, + principalKind: principal.kind, + }) + return { import: toV2CreateTableImport(created) } + }, +}) + +export const readTableImportUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.readImport, + resolveContext: ({ input }: { input: TableImportResourceInput }) => + resolveTableImportContext(input), + async execute({ context }): Promise<TableImportResult> { + return { import: toV2TableImport(context.record) } + }, +}) + +export const createTableImportPartsUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.createImportParts, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: CreateTableImportPartsInput + }) => resolveTableImportUploadContext(principal, input), + async execute({ input, context, request }): Promise<CreateTableImportPartsResult> { + if (!request) throw new Error('Table import part creation requires a request context') + return { + parts: await createUploadPartUrls({ + session: context.upload, + partNumbers: input.partNumbers, + localOrigin: requestOrigin(request), + }), + } + }, +}) + +export const completeTableImportUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.completeImport, + resolveContext: ({ principal, input }: { principal: Principal; input: TableImportUploadInput }) => + resolveTableImportUploadContext(principal, input), + async execute({ principal, context }): Promise<TableImportResult> { + const existing = await findTableImportResource({ + importId: context.upload.id, + assertedWorkspaceId: context.workspaceId, + }) + if (existing) return { import: toV2TableImport(existing) } + + const completed = await completeUploadSession({ + session: context.upload, + finalize: async (claimed) => { + assertUploadSessionAuthBinding(claimed, principal) + await authorizeWorkspaceOperation(principal, tableOperations.completeImport, context, { + delegation: tableDelegationPolicy, + }) + return { value: null } + }, + }) + const started = await startUploadedTableImport(completed.session) + logger.info('Completed table import upload', { + importId: started.id, + workspaceId: context.workspaceId, + tableId: started.tableId, + principalKind: principal.kind, + }) + return { import: toV2TableImport(started) } + }, +}) + +export const cancelTableImportUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.cancelImport, + async resolveContext({ + principal, + input, + }: { + principal: Principal + input: CancelTableImportInput + }) { + return input.uploadToken + ? resolveTableImportUploadContext(principal, { + ...input, + uploadToken: input.uploadToken, + }) + : resolveTableImportContext(input) + }, + async execute({ principal, context }): Promise<TableImportResult> { + const record = + 'upload' in context + ? await abortAuthorizedTableImportUpload(context.upload, principal) + : await cancelTableImportResource(context.record) + logger.info('Canceled table import', { + importId: record.id, + workspaceId: context.workspaceId, + tableId: record.tableId, + principalKind: principal.kind, + }) + return { import: toV2TableImport(record) } + }, +}) diff --git a/apps/sim/lib/table/application/operations.test.ts b/apps/sim/lib/table/application/operations.test.ts new file mode 100644 index 00000000000..e792a611fb3 --- /dev/null +++ b/apps/sim/lib/table/application/operations.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment node + */ + +import { permissionSatisfies } from '@sim/platform-authz/workspace' +import { describe, expect, it } from 'vitest' +import { tableOperations } from '@/lib/table/application/operations' + +describe('table operation registry', () => { + it('uses unique stable operation IDs with non-empty principal policies', () => { + const operations = Object.values(tableOperations) + const ids = operations.map((operation) => operation.id) + + expect(new Set(ids).size).toBe(ids.length) + for (const operation of operations) { + expect( + operation.principalKinds.length, + `${operation.id} has no allowed principals` + ).toBeGreaterThan(0) + expect( + new Set(operation.principalKinds).size, + `${operation.id} repeats a principal kind` + ).toBe(operation.principalKinds.length) + } + }) + + it('keeps workspace-key operations at or below the fixed write ceiling', () => { + for (const operation of Object.values(tableOperations)) { + expect( + operation.principalKinds.includes('workspace_api_key'), + `${operation.id} has inconsistent workspace API-key declarations` + ).toBe(operation.workspaceApiKey === 'allow') + + if (operation.workspaceApiKey === 'allow') { + expect( + permissionSatisfies('write', operation.minimumRole), + `${operation.id} exceeds the workspace API-key write ceiling` + ).toBe(true) + } + } + }) + + it('keeps reads and mutations on their declared semantic roles', () => { + expect(tableOperations.read.minimumRole).toBe('read') + expect(tableOperations.queryRows.minimumRole).toBe('read') + expect(tableOperations.readView.minimumRole).toBe('read') + expect(tableOperations.startRun.minimumRole).toBe('write') + expect(tableOperations.cancelRuns.minimumRole).toBe('write') + expect(tableOperations.replaceRows.minimumRole).toBe('write') + expect(tableOperations.completeImport.minimumRole).toBe('write') + + expect(tableOperations.createExport.minimumRole).toBe('read') + expect(tableOperations.cancelExport.minimumRole).toBe('read') + }) +}) diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts new file mode 100644 index 00000000000..9a8b30915da --- /dev/null +++ b/apps/sim/lib/table/application/operations.ts @@ -0,0 +1,74 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const ALL_PRINCIPAL_KINDS = [ + 'session', + 'personal_api_key', + 'workspace_api_key', + 'delegated', +] as const + +function readOperation<const Id extends string>(id: Id) { + return defineWorkspaceOperation({ + id, + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }) +} + +function writeOperation<const Id extends string>(id: Id) { + return defineWorkspaceOperation({ + id, + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }) +} + +export const tableOperations = { + list: readOperation('tables.list'), + read: readOperation('tables.read'), + create: writeOperation('tables.create'), + update: writeOperation('tables.update'), + delete: writeOperation('tables.delete'), + listFolders: readOperation('tables.folders.list'), + createFolder: writeOperation('tables.folders.create'), + updateFolder: writeOperation('tables.folders.update'), + deleteFolder: writeOperation('tables.folders.delete'), + addColumn: writeOperation('tables.columns.add'), + updateColumn: writeOperation('tables.columns.update'), + deleteColumn: writeOperation('tables.columns.delete'), + listRows: readOperation('tables.rows.list'), + queryRows: readOperation('tables.rows.query'), + findRows: readOperation('tables.rows.find'), + readRow: readOperation('tables.rows.read'), + createRows: writeOperation('tables.rows.create'), + replaceRows: writeOperation('tables.rows.replace'), + updateRow: writeOperation('tables.rows.update'), + updateRows: writeOperation('tables.rows.update_many'), + deleteRow: writeOperation('tables.rows.delete'), + deleteRows: writeOperation('tables.rows.delete_many'), + upsertRow: writeOperation('tables.rows.upsert'), + listViews: readOperation('tables.views.list'), + readView: readOperation('tables.views.read'), + createView: writeOperation('tables.views.create'), + updateView: writeOperation('tables.views.update'), + deleteView: writeOperation('tables.views.delete'), + listGroups: readOperation('tables.groups.list'), + createGroup: writeOperation('tables.groups.create'), + updateGroup: writeOperation('tables.groups.update'), + deleteGroup: writeOperation('tables.groups.delete'), + startRun: writeOperation('tables.runs.start'), + cancelRuns: writeOperation('tables.runs.cancel'), + createImport: writeOperation('tables.imports.create'), + readImport: readOperation('tables.imports.read'), + createImportParts: writeOperation('tables.imports.create_parts'), + completeImport: writeOperation('tables.imports.complete'), + cancelImport: writeOperation('tables.imports.cancel'), + createExport: readOperation('tables.exports.create'), + readExport: readOperation('tables.exports.read'), + cancelExport: readOperation('tables.exports.cancel'), + downloadExport: readOperation('tables.exports.download'), +} as const + +export type TableOperation = (typeof tableOperations)[keyof typeof tableOperations] diff --git a/apps/sim/lib/table/application/rows.test.ts b/apps/sim/lib/table/application/rows.test.ts new file mode 100644 index 00000000000..55db224860d --- /dev/null +++ b/apps/sim/lib/table/application/rows.test.ts @@ -0,0 +1,313 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const { + mockReplaceRowsPrimitive, + mockDeleteRowsByIds, + mockQueryRows, + mockRecordAudit, + mockResolveContext, + mockResolvePermission, + mockSignalRowsChanged, + mockUpsertRow, +} = vi.hoisted(() => ({ + mockReplaceRowsPrimitive: vi.fn(), + mockDeleteRowsByIds: vi.fn(), + mockQueryRows: vi.fn(), + mockRecordAudit: vi.fn(), + mockResolveContext: vi.fn(), + mockResolvePermission: vi.fn(), + mockSignalRowsChanged: vi.fn(), + mockUpsertRow: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_UPDATED: 'table.updated' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mockResolvePermission, +})) + +vi.mock('@/lib/table', () => ({ + TABLE_LIMITS: { + MAX_BATCH_INSERT_SIZE: 1000, + MAX_BULK_OPERATION_SIZE: 1000, + MAX_QUERY_LIMIT: 1000, + }, + batchInsertRows: vi.fn(), + deleteRow: vi.fn(), + deleteRowsByFilter: vi.fn(), + deleteRowsByIds: mockDeleteRowsByIds, + findRowMatches: vi.fn(), + getRowById: vi.fn(), + insertRow: vi.fn(), + queryRows: mockQueryRows, + replaceTableRows: mockReplaceRowsPrimitive, + rowDataNameToId: (data: Record<string, unknown>, idByName: Map<string, string>) => + Object.fromEntries( + Object.entries(data).flatMap(([name, value]) => { + const id = idByName.get(name) + return id ? [[id, value]] : [] + }) + ), + sortSpecNamesToIds: vi.fn(), + updateRow: vi.fn(), + updateRowsByFilter: vi.fn(), + upsertRow: mockUpsertRow, + validateBatchRows: vi.fn(), + validateRowData: vi.fn(), +})) + +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mockResolveContext, +})) + +vi.mock('@/lib/table/events', () => ({ + signalTableRowsChanged: mockSignalRowsChanged, +})) + +import { + deleteTableRows, + queryTableRows, + replaceTableRows, + TableRowsValidationError, + tablePredicateNamesToFilter, + upsertTableRow, +} from '@/lib/table/application/rows' + +const TABLE: TableDefinition = { + id: 'table-1', + name: 'People', + description: null, + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' }] }, + metadata: null, + rowCount: 2, + maxRows: 10_000, + workspaceId: 'workspace-canonical', + createdBy: 'owner-1', + archivedAt: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), +} + +const PRINCIPAL = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + +describe('table predicate translation', () => { + it('maps invalid run filters to the shared row validation error', () => { + expect(() => + tablePredicateNamesToFilter({ all: [{ field: 'missing', op: 'eq', value: 'ready' }] }, TABLE) + ).toThrowError( + expect.objectContaining({ + name: 'TableRowsValidationError', + details: { code: 'INVALID_FILTER' }, + }) + ) + }) +}) + +describe('replaceTableRows application use case', () => { + 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', + }) + mockReplaceRowsPrimitive.mockResolvedValue({ deletedCount: 2, insertedCount: 1 }) + }) + + it('uses canonical scope, stable column ids, and principal attribution', async () => { + const result = await replaceTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + assertedWorkspaceId: TABLE.workspaceId, + requestId: 'request-1', + rows: [{ name: 'Ada', unknown: 'dropped' }], + }, + }) + + expect(mockReplaceRowsPrimitive).toHaveBeenCalledWith( + { + tableId: TABLE.id, + workspaceId: TABLE.workspaceId, + rows: [{ 'column-name': 'Ada' }], + userId: PRINCIPAL.userId, + secretProvenance: undefined, + }, + TABLE, + 'request-1' + ) + expect(result).toMatchObject({ deletedCount: 2, insertedCount: 1 }) + expect(mockSignalRowsChanged).toHaveBeenCalledWith(TABLE.id) + }) + + it('rejects more than 10,000 rows before opening the atomic primitive', async () => { + await expect( + replaceTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + rows: Array.from({ length: 10_001 }, () => ({})), + }, + }) + ).rejects.toBeInstanceOf(TableRowsValidationError) + expect(mockReplaceRowsPrimitive).not.toHaveBeenCalled() + }) + + it('fails fast on misaligned provenance', async () => { + await expect( + replaceTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + rows: [{ name: 'Ada' }], + secretProvenance: [], + }, + }) + ).rejects.toThrow('Secret provenance must align one-to-one with rows') + expect(mockReplaceRowsPrimitive).not.toHaveBeenCalled() + }) + + it('does not signal for an authoritative no-op result', async () => { + mockReplaceRowsPrimitive.mockResolvedValue({ deletedCount: 0, insertedCount: 0 }) + + await replaceTableRows.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, rows: [] }, + }) + + expect(mockSignalRowsChanged).not.toHaveBeenCalled() + }) + + it('propagates primitive infrastructure failures', async () => { + mockReplaceRowsPrimitive.mockRejectedValue(new Error('database unavailable')) + + await expect( + replaceTableRows.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, rows: [] }, + }) + ).rejects.toThrow('database unavailable') + expect(mockSignalRowsChanged).not.toHaveBeenCalled() + }) +}) + +describe('row query and upsert application semantics', () => { + 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', + }) + }) + + it('rejects a malformed POST query cursor before querying storage', async () => { + await expect( + queryTableRows.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, cursor: 'malformed', limit: 100 }, + }) + ).rejects.toMatchObject({ details: { code: 'INVALID_CURSOR' } }) + expect(mockQueryRows).not.toHaveBeenCalled() + }) + + it('audits only the authoritative deleted count and suppresses no-op audit', async () => { + mockDeleteRowsByIds.mockResolvedValueOnce({ + deletedCount: 1, + deletedRowIds: ['row-1'], + requestedCount: 2, + missingRowIds: ['missing-row'], + }) + + await deleteTableRows.execute({ + principal: PRINCIPAL, + input: { + kind: 'ids', + tableId: TABLE.id, + rowIds: ['row-1', 'missing-row'], + }, + }) + + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: TABLE.workspaceId, + resourceId: TABLE.id, + metadata: expect.objectContaining({ + operation: 'tables.rows.delete_many', + rowsDeleted: 1, + }), + }) + ) + + mockRecordAudit.mockClear() + mockDeleteRowsByIds.mockResolvedValueOnce({ + deletedCount: 0, + deletedRowIds: [], + requestedCount: 1, + missingRowIds: ['missing-row'], + }) + await deleteTableRows.execute({ + principal: PRINCIPAL, + input: { kind: 'ids', tableId: TABLE.id, rowIds: ['missing-row'] }, + }) + + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('resolves a public upsert conflict-target name to its stable column id', async () => { + mockUpsertRow.mockResolvedValue({ + operation: 'update', + row: { + id: 'row-1', + data: { 'column-name': 'Ada' }, + createdAt: new Date(), + updatedAt: new Date(), + }, + }) + + await upsertTableRow.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + requestId: 'request-1', + data: { name: 'Ada' }, + conflictTarget: 'name', + }, + }) + + expect(mockUpsertRow).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: TABLE.id, + workspaceId: TABLE.workspaceId, + data: { 'column-name': 'Ada' }, + conflictTarget: 'column-name', + userId: PRINCIPAL.userId, + }), + TABLE, + 'request-1' + ) + }) +}) diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts new file mode 100644 index 00000000000..999b7bd133f --- /dev/null +++ b/apps/sim/lib/table/application/rows.ts @@ -0,0 +1,621 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { getRequestContext } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { + BulkDeleteByIdsResult, + BulkOperationResult, + Filter, + ReplaceRowsResult, + RowData, + Sort, + SortSpec, + TableDefinition, + TablePredicate, + TableRow, + TableRowSecretProvenanceWrite, +} from '@/lib/table' +import { + batchInsertRows, + deleteRow, + deleteRowsByFilter, + deleteRowsByIds, + findRowMatches, + getRowById, + insertRow, + queryRows, + replaceTableRows as replaceTableRowsPrimitive, + rowDataNameToId, + sortSpecNamesToIds, + TABLE_LIMITS, + updateRow, + updateRowsByFilter, + upsertRow, + validateBatchRows, + validateRowData, +} from '@/lib/table' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { resolveActiveTableContext } from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { buildIdByName } from '@/lib/table/column-keys' +import { TableQueryValidationError } from '@/lib/table/errors' +import { signalTableRowsChanged } from '@/lib/table/events' +import { predicateToFilter } from '@/lib/table/query-builder/converters' +import { + validatePredicate, + validatePredicateShape, + validateSortSpec, + validateStoragePredicate, +} from '@/lib/table/query-builder/validate' +import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' +import type { FindRowMatch } from '@/lib/table/rows/service' +import { predicateToStorage } from '@/lib/table/select-values' + +export class TableRowsValidationError extends OrchestrationError { + constructor( + message: string, + readonly details?: unknown + ) { + super('validation', message) + this.name = 'TableRowsValidationError' + } +} + +interface TableScopedInput { + tableId: string + assertedWorkspaceId?: string + requestId?: string +} + +interface TableResult { + table: TableDefinition +} + +function requestId(input: TableScopedInput): string { + return input.requestId ?? getRequestContext()?.requestId ?? generateId().slice(0, 8) +} + +function actorUserId( + principal: Parameters<typeof resolvePrincipalAttribution>[0], + billedAccountUserId: string +): string { + return resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: billedAccountUserId, + }).attributedUserId +} + +function namedDataToStorage(data: RowData, table: TableDefinition): RowData { + return rowDataNameToId(data, buildIdByName(table.schema)) +} + +function requireIntegerInRange(value: number, min: number, max: number, label: string): void { + if (!Number.isSafeInteger(value) || value < min || value > max) { + throw new TableRowsValidationError(`${label} must be between ${min} and ${max}`) + } +} + +export function tablePredicateNamesToFilter( + predicate: TablePredicate, + table: TableDefinition +): Filter { + try { + validatePredicateShape(predicate) + const translated = predicateToStorage(predicate, table.schema) + validateStoragePredicate(translated, table.schema.columns) + return predicateToFilter(translated) + } catch (error) { + rethrowQueryValidation(error) + } +} + +async function throwValidationResponse( + validation: + | { valid: true } + | { valid: false; response: { clone(): { json(): Promise<unknown> } } } +): Promise<void> { + if (validation.valid) return + const body = (await validation.response.clone().json()) as { + error?: string + details?: unknown + } + throw new TableRowsValidationError(body.error ?? 'Invalid row data', body.details) +} + +function rethrowQueryValidation(error: unknown): never { + if (error instanceof TableQueryValidationError) { + throw new TableRowsValidationError(error.message, error.code ? { code: error.code } : undefined) + } + throw error +} + +export interface ListTableRowsInput extends TableScopedInput { + limit: number + offset: number +} + +export interface ListTableRowsResult extends TableResult { + rows: TableRow[] + nextOffset: number | null +} + +export const listTableRows = defineAuthorizedTableUseCase({ + operation: tableOperations.listRows, + resolveContext: ({ input }: { input: ListTableRowsInput }) => resolveActiveTableContext(input), + async execute({ input, context }): Promise<ListTableRowsResult> { + requireIntegerInRange(input.limit, 1, TABLE_LIMITS.MAX_QUERY_LIMIT, 'Limit') + if (!Number.isSafeInteger(input.offset) || input.offset < 0) { + throw new TableRowsValidationError('Offset must be 0 or greater') + } + try { + const result = await queryRows( + context.table, + { + limit: input.limit, + offset: input.offset, + includeTotal: true, + withExecutions: false, + }, + requestId(input) + ) + const total = result.totalCount ?? 0 + return { + table: context.table, + rows: result.rows, + nextOffset: input.offset + result.rowCount < total ? input.offset + input.limit : null, + } + } catch (error) { + rethrowQueryValidation(error) + } + }, +}) + +export interface QueryTableRowsInput extends TableScopedInput { + predicate?: TablePredicate + sort?: SortSpec + limit?: number + cursor?: string + includeTotal?: boolean +} + +export interface QueryTableRowsResult extends TableResult { + rows: TableRow[] + rowCount: number + totalCount: number | null + nextCursor: string | null +} + +export const queryTableRows = defineAuthorizedTableUseCase({ + operation: tableOperations.queryRows, + resolveContext: ({ input }: { input: QueryTableRowsInput }) => resolveActiveTableContext(input), + async execute({ input, context }): Promise<QueryTableRowsResult> { + try { + if (input.limit !== undefined) { + requireIntegerInRange(input.limit, 1, TABLE_LIMITS.MAX_QUERY_LIMIT, 'Limit') + } + let predicate = input.predicate + if (predicate) { + validatePredicate(predicate, context.table.schema.columns) + predicate = predicateToStorage(predicate, context.table.schema) + } + let sortSpec = input.sort + if (sortSpec?.length) { + validateSortSpec(sortSpec, context.table.schema.columns) + sortSpec = sortSpecNamesToIds(sortSpec, buildIdByName(context.table.schema)) + } + const sort: Sort | undefined = sortSpec?.length + ? Object.fromEntries(sortSpec.map((item) => [item.field, item.direction])) + : undefined + const cursor = input.cursor ? decodeCursor(input.cursor) : undefined + if (cursor) assertCursorSortBinding(cursor, sort) + const result = await queryRows( + context.table, + { + predicate, + sort, + limit: input.limit, + after: cursor?.after, + offset: cursor?.offset, + includeTotal: input.includeTotal ?? false, + withExecutions: false, + }, + requestId(input) + ) + return { table: context.table, ...result } + } catch (error) { + rethrowQueryValidation(error) + } + }, +}) + +export interface FindTableRowsInput extends TableScopedInput { + q: string + predicate?: TablePredicate + sort?: SortSpec +} + +export interface FindTableRowsResult extends TableResult { + matches: FindRowMatch[] + truncated: boolean +} + +export const findTableRows = defineAuthorizedTableUseCase({ + operation: tableOperations.findRows, + resolveContext: ({ input }: { input: FindTableRowsInput }) => resolveActiveTableContext(input), + async execute({ input, context }): Promise<FindTableRowsResult> { + try { + if (input.q.length === 0) { + throw new TableRowsValidationError('q must be a non-empty search string') + } + const filter = input.predicate + ? tablePredicateNamesToFilter(input.predicate, context.table) + : undefined + let sort: Sort | undefined + if (input.sort?.length) { + validateSortSpec(input.sort, context.table.schema.columns) + const translated = sortSpecNamesToIds(input.sort, buildIdByName(context.table.schema)) + sort = Object.fromEntries(translated.map((item) => [item.field, item.direction])) + } + const result = await findRowMatches( + context.table, + { q: input.q, filter, sort }, + requestId(input) + ) + return { table: context.table, ...result } + } catch (error) { + rethrowQueryValidation(error) + } + }, +}) + +export interface ReadTableRowInput extends TableScopedInput { + rowId: string +} + +export interface ReadTableRowResult extends TableResult { + row: TableRow +} + +export const readTableRow = defineAuthorizedTableUseCase({ + operation: tableOperations.readRow, + resolveContext: ({ input }: { input: ReadTableRowInput }) => resolveActiveTableContext(input), + async execute({ input, context }): Promise<ReadTableRowResult> { + const row = await getRowById(context.tableId, input.rowId, context.workspaceId) + if (!row) throw new OrchestrationError('not_found', 'Row not found') + return { table: context.table, row } + }, +}) + +interface CreateSingleTableRowInput extends TableScopedInput { + kind: 'single' + data: RowData + position?: number + afterRowId?: string + beforeRowId?: string + secretProvenance?: TableRowSecretProvenanceWrite +} + +interface CreateBatchTableRowsInput extends TableScopedInput { + kind: 'batch' + rows: RowData[] + orderKeys?: string[] + secretProvenance?: Array<TableRowSecretProvenanceWrite | undefined> +} + +export type CreateTableRowsInput = CreateSingleTableRowInput | CreateBatchTableRowsInput + +export type CreateTableRowsResult = + | (TableResult & { kind: 'single'; row: TableRow }) + | (TableResult & { kind: 'batch'; rows: TableRow[] }) + +export const createTableRows = defineAuthorizedTableUseCase({ + operation: tableOperations.createRows, + resolveContext: ({ input }: { input: CreateTableRowsInput }) => resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise<CreateTableRowsResult> { + const userId = actorUserId(principal, context.billedAccountUserId) + if (input.kind === 'single') { + if (input.afterRowId && input.beforeRowId) { + throw new TableRowsValidationError('afterRowId and beforeRowId are mutually exclusive') + } + if ( + input.position !== undefined && + (!Number.isSafeInteger(input.position) || input.position < 0) + ) { + throw new TableRowsValidationError('Position must be 0 or greater') + } + const data = namedDataToStorage(input.data, context.table) + await throwValidationResponse( + await validateRowData({ + rowData: data, + schema: context.table.schema, + tableId: context.tableId, + }) + ) + const row = await insertRow( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + data, + userId, + position: input.position, + afterRowId: input.afterRowId, + beforeRowId: input.beforeRowId, + secretProvenance: input.secretProvenance, + }, + context.table, + requestId(input) + ) + return { kind: 'single', table: context.table, row } + } + if (input.rows.length < 1 || input.rows.length > TABLE_LIMITS.MAX_BATCH_INSERT_SIZE) { + throw new TableRowsValidationError( + `Batch row count must be between 1 and ${TABLE_LIMITS.MAX_BATCH_INSERT_SIZE}` + ) + } + if (input.secretProvenance && input.secretProvenance.length !== input.rows.length) { + throw new TableRowsValidationError('Secret provenance must align one-to-one with rows') + } + if (input.orderKeys && input.orderKeys.length !== input.rows.length) { + throw new TableRowsValidationError('orderKeys must align one-to-one with rows') + } + const rows = input.rows.map((row) => namedDataToStorage(row, context.table)) + await throwValidationResponse( + await validateBatchRows({ + rows, + schema: context.table.schema, + tableId: context.tableId, + }) + ) + const created = await batchInsertRows( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + rows, + userId, + orderKeys: input.orderKeys, + secretProvenance: input.secretProvenance, + }, + context.table, + requestId(input) + ) + return { kind: 'batch', table: context.table, rows: created } + }, + afterSuccess: ({ context, result }) => { + const affected = result.kind === 'single' ? 1 : result.rows.length + if (affected > 0) signalTableRowsChanged(context.tableId) + }, +}) + +const MAX_REPLACE_TABLE_ROWS = 10_000 + +export interface ReplaceTableRowsInput extends TableScopedInput { + rows: RowData[] + secretProvenance?: Array<TableRowSecretProvenanceWrite | undefined> +} + +export interface ReplaceTableRowsResult extends TableResult, ReplaceRowsResult {} + +export const replaceTableRows = defineAuthorizedTableUseCase({ + operation: tableOperations.replaceRows, + resolveContext: ({ input }: { input: ReplaceTableRowsInput }) => resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise<ReplaceTableRowsResult> { + if (input.rows.length > MAX_REPLACE_TABLE_ROWS) { + throw new TableRowsValidationError( + `Table row replacement limit exceeded: got ${input.rows.length}, max is ${MAX_REPLACE_TABLE_ROWS}` + ) + } + if (input.secretProvenance && input.secretProvenance.length !== input.rows.length) { + throw new TableRowsValidationError('Secret provenance must align one-to-one with rows') + } + + const result = await replaceTableRowsPrimitive( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + rows: input.rows.map((row) => namedDataToStorage(row, context.table)), + userId: actorUserId(principal, context.billedAccountUserId), + secretProvenance: input.secretProvenance, + }, + context.table, + requestId(input) + ) + return { table: context.table, ...result } + }, + afterSuccess: ({ context, result }) => { + if (result.deletedCount > 0 || result.insertedCount > 0) { + signalTableRowsChanged(context.tableId) + } + }, +}) + +export interface UpdateTableRowInput extends TableScopedInput { + rowId: string + data: RowData + secretProvenance?: TableRowSecretProvenanceWrite +} + +export interface UpdateTableRowResult extends TableResult { + row: TableRow + changed: boolean +} + +export const updateTableRow = defineAuthorizedTableUseCase({ + operation: tableOperations.updateRow, + resolveContext: ({ input }: { input: UpdateTableRowInput }) => resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise<UpdateTableRowResult> { + const data = namedDataToStorage(input.data, context.table) + const row = await updateRow( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + rowId: input.rowId, + data, + actorUserId: actorUserId(principal, context.billedAccountUserId), + secretProvenance: input.secretProvenance, + }, + context.table, + requestId(input) + ) + if (!row) throw new Error('Unconditional table row update was rejected') + return { table: context.table, row, changed: Object.keys(data).length > 0 } + }, + afterSuccess: ({ context, result }) => { + if (result.changed) signalTableRowsChanged(context.tableId) + }, +}) + +export interface UpdateTableRowsInput extends TableScopedInput { + filter: TablePredicate + data: RowData + limit?: number + secretProvenance?: TableRowSecretProvenanceWrite +} + +export interface UpdateTableRowsResult extends TableResult, BulkOperationResult {} + +export const updateTableRows = defineAuthorizedTableUseCase({ + operation: tableOperations.updateRows, + resolveContext: ({ input }: { input: UpdateTableRowsInput }) => resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise<UpdateTableRowsResult> { + try { + if (input.limit !== undefined) { + requireIntegerInRange(input.limit, 1, TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, 'Limit') + } + const result = await updateRowsByFilter( + context.table, + { + filter: tablePredicateNamesToFilter(input.filter, context.table), + data: namedDataToStorage(input.data, context.table), + limit: input.limit, + actorUserId: actorUserId(principal, context.billedAccountUserId), + secretProvenance: input.secretProvenance, + }, + requestId(input) + ) + return { table: context.table, ...result } + } catch (error) { + rethrowQueryValidation(error) + } + }, + afterSuccess: ({ context, result }) => { + if (result.affectedCount > 0) signalTableRowsChanged(context.tableId) + }, +}) + +export interface DeleteTableRowInput extends TableScopedInput { + rowId: string +} + +export interface DeleteTableRowResult extends TableResult { + deletedRowId: string +} + +export const deleteTableRow = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteRow, + resolveContext: ({ input }: { input: DeleteTableRowInput }) => resolveActiveTableContext(input), + async execute({ input, context }): Promise<DeleteTableRowResult> { + await deleteRow(context.table, input.rowId, requestId(input)) + return { table: context.table, deletedRowId: input.rowId } + }, + afterSuccess: ({ context }) => signalTableRowsChanged(context.tableId), +}) + +export type DeleteTableRowsInput = TableScopedInput & + ({ kind: 'ids'; rowIds: string[] } | { kind: 'filter'; filter: TablePredicate; limit?: number }) + +export type DeleteTableRowsResult = TableResult & + (({ kind: 'ids' } & BulkDeleteByIdsResult) | ({ kind: 'filter' } & BulkOperationResult)) + +export const deleteTableRows = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteRows, + resolveContext: ({ input }: { input: DeleteTableRowsInput }) => resolveActiveTableContext(input), + async execute({ input, context }): Promise<DeleteTableRowsResult> { + try { + if (input.kind === 'ids') { + if (input.rowIds.length < 1 || input.rowIds.length > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) { + throw new TableRowsValidationError( + `Row ID count must be between 1 and ${TABLE_LIMITS.MAX_BULK_OPERATION_SIZE}` + ) + } + const result = await deleteRowsByIds( + context.table, + { + tableId: context.tableId, + workspaceId: context.workspaceId, + rowIds: input.rowIds, + }, + requestId(input) + ) + return { kind: 'ids', table: context.table, ...result } + } + if (input.limit !== undefined) { + requireIntegerInRange(input.limit, 1, TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, 'Limit') + } + const result = await deleteRowsByFilter( + context.table, + { + filter: tablePredicateNamesToFilter(input.filter, context.table), + limit: input.limit, + }, + requestId(input) + ) + return { kind: 'filter', table: context.table, ...result } + } catch (error) { + rethrowQueryValidation(error) + } + }, + projectAudit: ({ result }) => { + const affected = result.kind === 'ids' ? result.deletedCount : result.affectedCount + if (affected === 0) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Deleted ${affected} row(s) from table "${result.table.name}"`, + metadata: { + op: 'bulk_delete', + rowsDeleted: affected, + }, + } + }, + afterSuccess: ({ context, result }) => { + const affected = result.kind === 'ids' ? result.deletedCount : result.affectedCount + if (affected > 0) signalTableRowsChanged(context.tableId) + }, +}) + +export interface UpsertTableRowInput extends TableScopedInput { + data: RowData + conflictTarget?: string + secretProvenance?: TableRowSecretProvenanceWrite +} + +export interface UpsertTableRowResult extends TableResult { + row: TableRow + operation: 'insert' | 'update' +} + +export const upsertTableRow = defineAuthorizedTableUseCase({ + operation: tableOperations.upsertRow, + resolveContext: ({ input }: { input: UpsertTableRowInput }) => resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise<UpsertTableRowResult> { + const conflictTarget = input.conflictTarget + ? (buildIdByName(context.table.schema).get(input.conflictTarget) ?? input.conflictTarget) + : undefined + const result = await upsertRow( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + data: namedDataToStorage(input.data, context.table), + conflictTarget, + userId: actorUserId(principal, context.billedAccountUserId), + secretProvenance: input.secretProvenance, + }, + context.table, + requestId(input) + ) + return { table: context.table, row: result.row, operation: result.operation } + }, + afterSuccess: ({ context }) => signalTableRowsChanged(context.tableId), +}) diff --git a/apps/sim/lib/table/application/runs.test.ts b/apps/sim/lib/table/application/runs.test.ts new file mode 100644 index 00000000000..909afff9c88 --- /dev/null +++ b/apps/sim/lib/table/application/runs.test.ts @@ -0,0 +1,272 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const { + mockCancelRuns, + mockGetRowById, + mockResolveContext, + mockResolvePermission, + mockRequireTableRowIds, + mockRunWorkflowColumn, + mockSignalRowsChanged, + mockTranslatePredicate, +} = vi.hoisted(() => ({ + mockCancelRuns: vi.fn(), + mockGetRowById: vi.fn(), + mockResolveContext: vi.fn(), + mockResolvePermission: vi.fn(), + mockRequireTableRowIds: vi.fn(), + mockRunWorkflowColumn: vi.fn(), + mockSignalRowsChanged: vi.fn(), + mockTranslatePredicate: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mockResolvePermission, +})) + +vi.mock('@/lib/table', () => ({ + DEFAULT_TABLE_PLAN_LIMITS: { enterprise: { maxRowsPerTable: 2 } }, + getRowById: mockGetRowById, + requireTableRowIds: mockRequireTableRowIds, + TABLE_LIMITS: { MAX_COLUMNS_PER_TABLE: 2 }, +})) + +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mockResolveContext, +})) + +vi.mock('@/lib/table/application/rows', () => ({ + tablePredicateNamesToFilter: mockTranslatePredicate, +})) + +vi.mock('@/lib/table/events', () => ({ + signalTableRowsChanged: mockSignalRowsChanged, +})) + +vi.mock('@/lib/table/workflow-columns', () => ({ + cancelWorkflowGroupRuns: mockCancelRuns, + runWorkflowColumn: mockRunWorkflowColumn, +})) + +import { cancelTableRuns, startTableRun } from '@/lib/table/application/runs' + +const TABLE: TableDefinition = { + id: 'table-1', + name: 'People', + description: null, + schema: { + columns: [], + workflowGroups: [ + { + id: 'group-1', + name: 'Enrich', + type: 'enrichment', + enrichmentId: 'enrichment-1', + workflowId: '', + targetColumnIds: [], + sourceColumnIds: [], + }, + ], + }, + metadata: null, + rowCount: 1, + maxRows: 10, + workspaceId: 'workspace-canonical', + createdBy: 'owner-1', + archivedAt: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), +} + +const PRINCIPAL = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + +describe('table run application use cases', () => { + 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', + }) + mockGetRowById.mockResolvedValue({ id: 'row-1' }) + mockRunWorkflowColumn.mockResolvedValue({ + dispatchId: 'dispatch-1', + shouldSignalRowsChanged: true, + }) + mockRequireTableRowIds.mockResolvedValue(undefined) + mockCancelRuns.mockResolvedValue(1) + mockTranslatePredicate.mockReturnValue({ all: [] }) + }) + + it('canonically validates row and group before enrichment dispatch', async () => { + const result = await startTableRun.execute({ + principal: PRINCIPAL, + input: { + kind: 'row_enrichment', + tableId: TABLE.id, + assertedWorkspaceId: TABLE.workspaceId, + rowId: 'row-1', + groupId: 'group-1', + requestId: 'request-1', + }, + }) + + expect(mockGetRowById).toHaveBeenCalledWith(TABLE.id, 'row-1', TABLE.workspaceId) + expect(mockRunWorkflowColumn).toHaveBeenCalledWith({ + tableId: TABLE.id, + workspaceId: TABLE.workspaceId, + groupIds: ['group-1'], + rowIds: ['row-1'], + mode: 'all', + requestId: 'request-1', + triggeredByUserId: PRINCIPAL.userId, + }) + expect(result.dispatchId).toBe('dispatch-1') + expect(mockSignalRowsChanged).toHaveBeenCalledWith(TABLE.id) + }) + + it('rejects missing canonical groups and rows without dispatching', async () => { + await expect( + startTableRun.execute({ + principal: PRINCIPAL, + input: { + kind: 'row_enrichment', + tableId: TABLE.id, + rowId: 'row-1', + groupId: 'missing-group', + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + mockGetRowById.mockResolvedValueOnce(null) + await expect( + startTableRun.execute({ + principal: PRINCIPAL, + input: { + kind: 'row_enrichment', + tableId: TABLE.id, + rowId: 'missing-row', + groupId: 'group-1', + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('bounds explicit row selections before dispatch', async () => { + await expect( + startTableRun.execute({ + principal: PRINCIPAL, + input: { + kind: 'selection', + tableId: TABLE.id, + groupIds: ['group-1'], + mode: 'all', + rowIds: ['row-1', 'row-2', 'row-3'], + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('deduplicates and canonically verifies explicit row selections', async () => { + await startTableRun.execute({ + principal: PRINCIPAL, + input: { + kind: 'selection', + tableId: TABLE.id, + groupIds: ['group-1', 'group-1'], + mode: 'all', + rowIds: ['row-1', 'row-1'], + }, + }) + + expect(mockRequireTableRowIds).toHaveBeenCalledWith(TABLE.id, TABLE.workspaceId, ['row-1']) + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ groupIds: ['group-1'], rowIds: ['row-1'] }) + ) + }) + + it('does not signal when the dispatcher reports a no-op', async () => { + mockRunWorkflowColumn.mockResolvedValue({ + dispatchId: null, + shouldSignalRowsChanged: false, + }) + + await startTableRun.execute({ + principal: PRINCIPAL, + input: { + kind: 'selection', + tableId: TABLE.id, + groupIds: ['group-1'], + mode: 'incomplete', + }, + }) + + expect(mockSignalRowsChanged).not.toHaveBeenCalled() + }) + + it('signals a cleared row state when cancellation wins before dispatch', async () => { + mockRunWorkflowColumn.mockResolvedValue({ + dispatchId: null, + shouldSignalRowsChanged: true, + }) + + await startTableRun.execute({ + principal: PRINCIPAL, + input: { + kind: 'selection', + tableId: TABLE.id, + groupIds: ['group-1'], + mode: 'all', + }, + }) + + expect(mockSignalRowsChanged).toHaveBeenCalledWith(TABLE.id) + }) + + it('requires a canonical row for row cancellation', async () => { + mockGetRowById.mockResolvedValue(null) + + await expect( + cancelTableRuns.execute({ + principal: PRINCIPAL, + input: { scope: 'row', tableId: TABLE.id, rowId: 'missing-row' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mockCancelRuns).not.toHaveBeenCalled() + }) + + it('signals only authoritative cancellations and propagates infrastructure failures', async () => { + mockCancelRuns.mockResolvedValueOnce(0) + await cancelTableRuns.execute({ + principal: PRINCIPAL, + input: { scope: 'all', tableId: TABLE.id }, + }) + expect(mockSignalRowsChanged).not.toHaveBeenCalled() + + mockCancelRuns.mockRejectedValueOnce(new Error('database unavailable')) + await expect( + cancelTableRuns.execute({ + principal: PRINCIPAL, + input: { scope: 'all', tableId: TABLE.id }, + }) + ).rejects.toThrow('database unavailable') + expect(mockSignalRowsChanged).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/runs.ts b/apps/sim/lib/table/application/runs.ts new file mode 100644 index 00000000000..aa39b45dd31 --- /dev/null +++ b/apps/sim/lib/table/application/runs.ts @@ -0,0 +1,210 @@ +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { getRequestContext } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + DEFAULT_TABLE_PLAN_LIMITS, + getRowById, + requireTableRowIds, + TABLE_LIMITS, + type TableDefinition, + type TablePredicate, +} from '@/lib/table' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { resolveActiveTableContext } from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { tablePredicateNamesToFilter } from '@/lib/table/application/rows' +import type { DispatchLimit, DispatchMode } from '@/lib/table/dispatcher' +import { signalTableRowsChanged } from '@/lib/table/events' +import { cancelWorkflowGroupRuns, runWorkflowColumn } from '@/lib/table/workflow-columns' + +interface TableRunInput { + tableId: string + assertedWorkspaceId?: string + requestId?: string +} + +interface TableRunResult { + table: TableDefinition +} + +function requestId(input: TableRunInput): string { + return input.requestId ?? getRequestContext()?.requestId ?? generateId().slice(0, 8) +} + +function actorUserId( + principal: Parameters<typeof resolvePrincipalAttribution>[0], + billedAccountUserId: string +): string { + return resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: billedAccountUserId, + }).attributedUserId +} + +interface StartSelectionRunInput extends TableRunInput { + kind: 'selection' + groupIds: string[] + mode: Extract<DispatchMode, 'all' | 'incomplete'> + rowIds?: string[] + predicate?: TablePredicate + excludeRowIds?: string[] + limit?: DispatchLimit +} + +interface StartRowEnrichmentInput extends TableRunInput { + kind: 'row_enrichment' + rowId: string + groupId: string +} + +export type StartTableRunInput = StartSelectionRunInput | StartRowEnrichmentInput + +export interface StartTableRunResult extends TableRunResult { + dispatchId: string | null + shouldSignalRowsChanged: boolean +} + +function requireCanonicalGroups(table: TableDefinition, groupIds: string[]): void { + if (groupIds.length === 0) { + throw new OrchestrationError('validation', 'At least one workflow group is required') + } + if (groupIds.length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { + throw new OrchestrationError( + 'validation', + `Cannot run more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} groups` + ) + } + const canonicalGroupIds = new Set((table.schema.workflowGroups ?? []).map((group) => group.id)) + const missing = [...new Set(groupIds)].filter((groupId) => !canonicalGroupIds.has(groupId)) + if (missing.length > 0) throw new OrchestrationError('not_found', 'Workflow group not found') +} + +export const startTableRun = defineAuthorizedTableUseCase({ + operation: tableOperations.startRun, + resolveContext: ({ input }: { input: StartTableRunInput }) => resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise<StartTableRunResult> { + const triggeredByUserId = actorUserId(principal, context.billedAccountUserId) + if (input.kind === 'row_enrichment') { + requireCanonicalGroups(context.table, [input.groupId]) + const row = await getRowById(context.tableId, input.rowId, context.workspaceId) + if (!row) throw new OrchestrationError('not_found', 'Row not found') + const result = await runWorkflowColumn({ + tableId: context.tableId, + workspaceId: context.workspaceId, + groupIds: [input.groupId], + rowIds: [input.rowId], + mode: 'all', + requestId: requestId(input), + triggeredByUserId, + }) + return { + table: context.table, + dispatchId: result.dispatchId, + shouldSignalRowsChanged: result.shouldSignalRowsChanged, + } + } + + if (input.rowIds && input.predicate) { + throw new OrchestrationError('validation', 'Provide either predicate or rowIds, but not both') + } + if (input.rowIds && input.excludeRowIds) { + throw new OrchestrationError( + 'validation', + 'excludeRowIds only applies to select-all scope (no rowIds)' + ) + } + const groupIds = [...new Set(input.groupIds)] + requireCanonicalGroups(context.table, groupIds) + const maxTargetRows = DEFAULT_TABLE_PLAN_LIMITS.enterprise.maxRowsPerTable + if (input.rowIds?.length === 0) { + throw new OrchestrationError('validation', 'At least one row ID is required') + } + if (input.rowIds && input.rowIds.length > maxTargetRows) { + throw new OrchestrationError('validation', `Cannot target more than ${maxTargetRows} rows`) + } + const rowIds = input.rowIds ? [...new Set(input.rowIds)] : undefined + if (rowIds) await requireTableRowIds(context.tableId, context.workspaceId, rowIds) + if (input.excludeRowIds && input.excludeRowIds.length > TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS) { + throw new OrchestrationError( + 'validation', + `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` + ) + } + const excludeRowIds = input.excludeRowIds ? [...new Set(input.excludeRowIds)] : undefined + if ( + input.limit && + (!Number.isSafeInteger(input.limit.max) || + input.limit.max < 1 || + input.limit.max > maxTargetRows) + ) { + throw new OrchestrationError('validation', `Run limit must be between 1 and ${maxTargetRows}`) + } + const filter = input.predicate + ? tablePredicateNamesToFilter(input.predicate, context.table) + : undefined + const result = await runWorkflowColumn({ + tableId: context.tableId, + workspaceId: context.workspaceId, + groupIds, + mode: input.mode, + rowIds, + filter, + excludeRowIds, + limit: input.limit, + requestId: requestId(input), + triggeredByUserId, + }) + return { + table: context.table, + dispatchId: result.dispatchId, + shouldSignalRowsChanged: result.shouldSignalRowsChanged, + } + }, + afterSuccess: ({ context, result }) => { + if (result.shouldSignalRowsChanged) signalTableRowsChanged(context.tableId) + }, +}) + +interface CancelAllTableRunsInput extends TableRunInput { + scope: 'all' + predicate?: TablePredicate + excludeRowIds?: string[] +} + +interface CancelRowTableRunsInput extends TableRunInput { + scope: 'row' + rowId: string +} + +export type CancelTableRunsInput = CancelAllTableRunsInput | CancelRowTableRunsInput + +export interface CancelTableRunsResult extends TableRunResult { + cancelled: number +} + +export const cancelTableRuns = defineAuthorizedTableUseCase({ + operation: tableOperations.cancelRuns, + resolveContext: ({ input }: { input: CancelTableRunsInput }) => resolveActiveTableContext(input), + async execute({ input, context }): Promise<CancelTableRunsResult> { + if (input.scope === 'row') { + const row = await getRowById(context.tableId, input.rowId, context.workspaceId) + if (!row) throw new OrchestrationError('not_found', 'Row not found') + } + const filter = + input.scope === 'all' && input.predicate + ? tablePredicateNamesToFilter(input.predicate, context.table) + : undefined + const cancelled = await cancelWorkflowGroupRuns( + context.tableId, + input.scope === 'row' ? input.rowId : undefined, + { + filter, + excludeRowIds: input.scope === 'all' ? input.excludeRowIds : undefined, + } + ) + return { table: context.table, cancelled } + }, + afterSuccess: ({ context, result }) => { + if (result.cancelled > 0) signalTableRowsChanged(context.tableId) + }, +}) diff --git a/apps/sim/lib/table/application/tables.ts b/apps/sim/lib/table/application/tables.ts new file mode 100644 index 00000000000..2374fd1b379 --- /dev/null +++ b/apps/sim/lib/table/application/tables.ts @@ -0,0 +1,310 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { V2TableSortBy } from '@/lib/api/contracts/v2/tables' +import type { CursorKey } from '@/lib/api/list-query' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { + createTable, + deleteTable, + getTableById, + getWorkspaceTableLimits, + moveTableToFolder, + queryTables, + renameTable, + type TableDefinition, + type TableSchema, + updateTableDescription, +} from '@/lib/table' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { + resolveActiveTableContext, + resolveTableWorkspaceContext, +} from '@/lib/table/application/context' +import { resolveTableFolderPath, tableFolderPathForId } from '@/lib/table/application/folder-paths' +import { tableOperations } from '@/lib/table/application/operations' +import { signalTableSchemaChanged } from '@/lib/table/events' + +export interface ListTablesInput { + workspaceId: string + folderPath?: string + search?: string + sortBy: V2TableSortBy + sortOrder: V2SortOrder + limit: number + after?: CursorKey[] +} + +export const listTablesUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.list, + resolveContext: ({ input }: { input: ListTablesInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ input, context }) { + const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'table') + 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 { tables, nextKeys } = await queryTables(context.workspaceId, { + folderId, + search: input.search, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + limit: input.limit, + after: input.after, + }) + + return { + tables: tables.map((table) => ({ + table, + folderPath: tableFolderPathForId(folderIndex, table.folderId), + })), + nextKeys, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + } + }, +}) + +export interface CreateTableInput { + workspaceId: string + name: string + description?: string + schema: TableSchema + folderPath?: string + initialRowCount?: number +} + +export const createTableUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.create, + resolveContext: ({ input }: { input: CreateTableInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const planLimits = await getWorkspaceTableLimits(context.workspaceId) + const resolution = await resolveTableFolderPath(context.workspaceId, input.folderPath ?? '/') + if (!resolution) throw new OrchestrationError('not_found', 'Folder not found') + + const table = await createTable( + { + name: input.name, + description: input.description, + schema: input.schema, + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + maxTables: planLimits.maxTables, + folderId: resolution.folderId, + initialRowCount: input.initialRowCount, + }, + generateRequestId() + ) + + return { + table, + folderPath: tableFolderPathForId(resolution.index, table.folderId), + } + }, + projectAudit({ input, result }) { + return { + action: AuditAction.TABLE_CREATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Created table "${result.table.name}"`, + metadata: { columnCount: input.schema.columns.length }, + } + }, +}) + +export interface ReadTableInput { + tableId: string + workspaceId: string +} + +export const readTableUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.read, + resolveContext: ({ input }: { input: ReadTableInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ context }) { + const index = await loadActiveFolderPathIndex(context.workspaceId, 'table') + return { + table: context.table, + folderPath: tableFolderPathForId(index, context.table.folderId), + } + }, +}) + +export type AppliedTableUpdate = 'name' | 'description' | 'folderPath' + +export interface UpdateTableInput extends ReadTableInput { + name?: string + description?: string | null + folderPath?: string +} + +export interface UpdateTableResult { + table: TableDefinition | null + folderPath: string | null + applied: AppliedTableUpdate[] + changed: AppliedTableUpdate[] + failure?: unknown +} + +export const updateTableUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.update, + resolveContext: ({ input }: { input: UpdateTableInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ input, context }): Promise<UpdateTableResult> { + const applied: AppliedTableUpdate[] = [] + const changed: AppliedTableUpdate[] = [] + const resolution = + input.folderPath === undefined + ? undefined + : await resolveTableFolderPath(context.workspaceId, input.folderPath) + if (input.folderPath !== undefined && !resolution) { + throw new OrchestrationError('not_found', 'Folder not found in this workspace') + } + + let current = context.table + try { + if (input.name !== undefined) { + if (input.name !== current.name) { + await renameTable(current.id, input.name, generateRequestId(), { + expectedWorkspaceId: context.workspaceId, + }) + current = { ...current, name: input.name } + changed.push('name') + } + applied.push('name') + } + + if (input.description !== undefined) { + if (input.description !== (current.description ?? null)) { + await updateTableDescription( + current.id, + context.workspaceId, + input.description, + generateRequestId() + ) + current = { ...current, description: input.description } + changed.push('description') + } + applied.push('description') + } + + if (input.folderPath !== undefined) { + const folderId = resolution?.folderId ?? null + if (folderId !== (current.folderId ?? null)) { + await moveTableToFolder(current.id, context.workspaceId, folderId, generateRequestId()) + current = { ...current, folderId } + changed.push('folderPath') + } + applied.push('folderPath') + } + + const table = await getTableById(current.id) + if (!table || table.workspaceId !== context.workspaceId) { + throw new OrchestrationError('not_found', 'Table not found') + } + const index = + resolution?.index ?? (await loadActiveFolderPathIndex(context.workspaceId, 'table')) + return { + table, + folderPath: tableFolderPathForId(index, table.folderId), + applied, + changed, + } + } catch (failure) { + return { table: current, folderPath: null, applied, changed, failure } + } + }, + projectAudit({ input, context, result }) { + return result.changed.map((field) => { + if (field === 'name') { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: context.table.id, + resourceName: input.name ?? context.table.name, + description: `Renamed table to "${input.name}"`, + metadata: { op: 'rename', previousName: context.table.name }, + } + } + if (field === 'description') { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: context.table.id, + resourceName: result.table?.name ?? context.table.name, + description: `Updated description for table "${result.table?.name ?? context.table.name}"`, + metadata: { op: 'description' }, + } + } + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: context.table.id, + resourceName: result.table?.name ?? context.table.name, + description: + input.folderPath === '/' + ? `Moved table "${result.table?.name ?? context.table.name}" to the workspace root` + : `Moved table "${result.table?.name ?? context.table.name}" into a folder`, + metadata: { op: 'move', folderPath: input.folderPath }, + } + }) + }, + afterSuccess({ context, result }) { + if (result.changed.length > 0) signalTableSchemaChanged(context.table.id) + }, +}) + +export const deleteTableUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.delete, + resolveContext: ({ input }: { input: ReadTableInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const { archived } = await deleteTable(context.table.id, generateRequestId(), { + expectedWorkspaceId: context.workspaceId, + }) + if (!archived) throw new OrchestrationError('not_found', 'Table not found') + return { + id: context.table.id, + deleted: true as const, + archived: true as const, + tableName: archived.name, + workspaceId: context.workspaceId, + attributedUserId: attribution.attributedUserId, + } + }, + projectAudit({ result }) { + return { + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: result.id, + resourceName: result.tableName, + description: `Archived table "${result.tableName}"`, + } + }, +}) diff --git a/apps/sim/lib/table/application/views.ts b/apps/sim/lib/table/application/views.ts new file mode 100644 index 00000000000..1df29d7cc95 --- /dev/null +++ b/apps/sim/lib/table/application/views.ts @@ -0,0 +1,199 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { TableSchema, TableViewConfig } from '@/lib/table' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { resolveActiveTableContext } from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { + createTableView, + deleteTableView, + getTableView, + listTableViews, + TableViewValidationError, + updateTableView, +} from '@/lib/table/views/service' + +interface TableViewInput { + tableId: string + workspaceId: string +} + +interface TableViewResourceInput extends TableViewInput { + viewId: string +} + +function rethrowViewError(error: unknown): never { + if (error instanceof TableViewValidationError) { + throw new OrchestrationError('validation', error.message) + } + throw error +} + +export const listTableViewsUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.listViews, + resolveContext: ({ input }: { input: TableViewInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ context }) { + const views = await listTableViews( + context.table.id, + (context.table.schema as TableSchema).columns, + context.workspaceId + ) + return { views } + }, +}) + +export const readTableViewUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.readView, + resolveContext: ({ input }: { input: TableViewResourceInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ input, context }) { + const view = await getTableView( + input.viewId, + context.table.id, + (context.table.schema as TableSchema).columns, + context.workspaceId + ) + if (!view) throw new OrchestrationError('not_found', 'View not found') + return { view } + }, +}) + +export interface CreateTableViewInput extends TableViewInput { + name: string + config: TableViewConfig +} + +export const createTableViewUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.createView, + resolveContext: ({ input }: { input: CreateTableViewInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + try { + const view = await createTableView({ + tableId: context.table.id, + workspaceId: context.workspaceId, + name: input.name, + config: input.config, + userId: attribution.attributedUserId, + columns: (context.table.schema as TableSchema).columns, + }) + return { view, table: context.table } + } catch (error) { + rethrowViewError(error) + } + }, + projectAudit({ result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Created view "${result.view.name}" on table "${result.table.name}"`, + metadata: { op: 'create_view', viewId: result.view.id }, + } + }, +}) + +export interface UpdateTableViewInput extends TableViewResourceInput { + name?: string + config?: TableViewConfig + configPatch?: TableViewConfig + isDefault?: boolean +} + +export const updateTableViewUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.updateView, + resolveContext: ({ input }: { input: UpdateTableViewInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ input, context }) { + try { + const existing = await getTableView( + input.viewId, + context.table.id, + (context.table.schema as TableSchema).columns, + context.workspaceId + ) + if (!existing) throw new OrchestrationError('not_found', 'View not found') + const view = await updateTableView({ + viewId: input.viewId, + tableId: context.table.id, + workspaceId: context.workspaceId, + name: input.name, + config: input.config, + configPatch: input.configPatch, + isDefault: input.isDefault, + columns: (context.table.schema as TableSchema).columns, + }) + if (!view) throw new OrchestrationError('not_found', 'View not found') + return { + view, + table: context.table, + changed: + existing.name !== view.name || + existing.isDefault !== view.isDefault || + JSON.stringify(existing.config) !== JSON.stringify(view.config), + } + } catch (error) { + rethrowViewError(error) + } + }, + projectAudit({ result }) { + if (!result.changed) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Updated view "${result.view.name}" on table "${result.table.name}"`, + metadata: { op: 'update_view', viewId: result.view.id }, + } + }, +}) + +export const deleteTableViewUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteView, + resolveContext: ({ input }: { input: TableViewResourceInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ input, context }) { + const existing = await getTableView( + input.viewId, + context.table.id, + (context.table.schema as TableSchema).columns, + context.workspaceId + ) + if (!existing) throw new OrchestrationError('not_found', 'View not found') + const deleted = await deleteTableView(input.viewId, context.table.id, context.workspaceId) + if (!deleted) throw new OrchestrationError('not_found', 'View not found') + return { viewId: input.viewId, viewName: existing.name, table: context.table } + }, + projectAudit({ result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Deleted view "${result.viewName}" from table "${result.table.name}"`, + metadata: { op: 'delete_view', viewId: result.viewId }, + } + }, +}) diff --git a/apps/sim/lib/table/column-types/registry.server.ts b/apps/sim/lib/table/column-types/registry.server.ts index de27abc60ab..6ba27f5c616 100644 --- a/apps/sim/lib/table/column-types/registry.server.ts +++ b/apps/sim/lib/table/column-types/registry.server.ts @@ -39,6 +39,7 @@ import type { JsonValue, SelectOption } from '@/lib/table/types' async function migrateSelectCellsToNames( trx: DbTransaction, tableId: string, + workspaceId: string, columnKey: string, options: SelectOption[] ): Promise<void> { @@ -46,6 +47,7 @@ async function migrateSelectCellsToNames( await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere: and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) = 'string'` )!, transformation: { @@ -60,6 +62,7 @@ async function migrateSelectCellsToNames( await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere: and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) = 'array'` )!, transformation: { @@ -95,6 +98,7 @@ const COERCED_WRITE_BACK_BATCH_SIZE = 5000 export async function writeBackCoercedCells( trx: DbTransaction, tableId: string, + workspaceId: string, columnKey: string, valueByRowId: ReadonlyMap<string, JsonValue> ): Promise<void> { @@ -108,6 +112,7 @@ export async function writeBackCoercedCells( await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere: and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`${batch}::jsonb ? ${userTableRows.id}` )!, transformation: { @@ -148,6 +153,7 @@ export async function writeBackCoercedCells( async function migrateCellsToSelectIds( trx: DbTransaction, tableId: string, + workspaceId: string, columnKey: string, options: SelectOption[], multiple: boolean @@ -177,6 +183,7 @@ async function migrateCellsToSelectIds( await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere: and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) IN ('string', 'number', 'boolean')` )!, transformation: { @@ -199,6 +206,7 @@ async function migrateCellsToSelectIds( await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere: and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) = 'array'` )!, transformation: { @@ -219,6 +227,7 @@ async function migrateCellsToSelectIds( await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere: and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) IN ('string', 'number', 'boolean')` )!, transformation: { @@ -238,6 +247,7 @@ async function migrateCellsToSelectIds( await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere: and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) = 'array'` )!, transformation: { @@ -266,10 +276,17 @@ export const COLUMN_TYPE_SERVER_REGISTRY: Record<ColumnType, ColumnTypeServerEnt json: COLUMN_TYPE_REGISTRY.json, select: { ...COLUMN_TYPE_REGISTRY.select, - migrateCellsTo: ({ trx, tableId, columnKey, target }) => - migrateCellsToSelectIds(trx, tableId, columnKey, target.options ?? [], !!target.multiple), - migrateCellsFrom: ({ trx, tableId, columnKey, previous }) => - migrateSelectCellsToNames(trx, tableId, columnKey, previous.options ?? []), + migrateCellsTo: ({ trx, tableId, workspaceId, columnKey, target }) => + migrateCellsToSelectIds( + trx, + tableId, + workspaceId, + columnKey, + target.options ?? [], + !!target.multiple + ), + migrateCellsFrom: ({ trx, tableId, workspaceId, columnKey, previous }) => + migrateSelectCellsToNames(trx, tableId, workspaceId, columnKey, previous.options ?? []), }, currency: COLUMN_TYPE_REGISTRY.currency, } diff --git a/apps/sim/lib/table/column-types/types.server.ts b/apps/sim/lib/table/column-types/types.server.ts index c650c31c72e..48f34f812e9 100644 --- a/apps/sim/lib/table/column-types/types.server.ts +++ b/apps/sim/lib/table/column-types/types.server.ts @@ -14,6 +14,7 @@ import type { ColumnDefinition, JsonValue } from '@/lib/table/types' export interface ColumnCellMigrationContext { trx: DbTransaction tableId: string + workspaceId: string /** JSONB storage key for the column (its stable id). */ columnKey: string /** The column definition as it was before the conversion. */ diff --git a/apps/sim/lib/table/columns/memory.test.ts b/apps/sim/lib/table/columns/memory.test.ts new file mode 100644 index 00000000000..873ff7ff927 --- /dev/null +++ b/apps/sim/lib/table/columns/memory.test.ts @@ -0,0 +1,31 @@ +/** + * @vitest-environment node + */ + +import { resetEnvMock, setEnv } from '@sim/testing' +import { afterEach, describe, expect, it } from 'vitest' +import { getColumnRetypeScanBatchSize } from '@/lib/table/columns/service' +import { TABLE_LIMITS } from '@/lib/table/constants' + +const RETYPE_SCAN_BUDGET_BYTES = 32 * 1024 * 1024 + +describe('column retype memory bounds', () => { + afterEach(resetEnvMock) + + it('derives the page cap from the maximum row size and the fixed byte budget', () => { + expect(getColumnRetypeScanBatchSize()).toBe( + Math.floor(RETYPE_SCAN_BUDGET_BYTES / TABLE_LIMITS.MAX_ROW_SIZE_BYTES) + ) + + setEnv({ TABLE_MAX_ROW_SIZE_BYTES: 16 * 1024 * 1024 }) + expect(getColumnRetypeScanBatchSize()).toBe(2) + }) + + it('always processes at least one row without exceeding the row-count cap', () => { + setEnv({ TABLE_MAX_ROW_SIZE_BYTES: RETYPE_SCAN_BUDGET_BYTES * 2 }) + expect(getColumnRetypeScanBatchSize()).toBe(1) + + setEnv({ TABLE_MAX_ROW_SIZE_BYTES: 1 }) + expect(getColumnRetypeScanBatchSize()).toBe(1000) + }) +}) diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index 62309b4d995..4769e627b41 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -19,7 +19,7 @@ import { db } from '@sim/db' import { userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { omit } from '@sim/utils/object' -import { and, count, eq, sql } from 'drizzle-orm' +import { and, asc, count, eq, gt, sql } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { columnMatchesRef, generateColumnId, getColumnId } from '@/lib/table/column-keys' import { @@ -33,7 +33,7 @@ import { migrationTo, writeBackCoercedCells, } from '@/lib/table/column-types/registry.server' -import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' +import { COLUMN_TYPES, getMaxRowSizeBytes, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' import { resolveCurrencyCode } from '@/lib/table/currency' import { assertColumnDestructive, assertSchemaMutable } from '@/lib/table/mutation-locks' import type { DbTransaction } from '@/lib/table/planner' @@ -47,7 +47,6 @@ import type { DeleteColumnData, JsonValue, RenameColumnData, - RowData, SelectOption, TableDefinition, TableMetadata, @@ -61,6 +60,49 @@ import { validateColumnDefinition } from '@/lib/table/validation' import { assertValidSchema, stripGroupDeps } from '@/lib/table/workflow-columns' const logger = createLogger('TableColumnService') +const COLUMN_RETYPE_SCAN_MAX_BYTES = 32 * 1024 * 1024 +const COLUMN_RETYPE_SCAN_MAX_ROWS = 1000 + +export function getColumnRetypeScanBatchSize(): number { + return Math.max( + 1, + Math.min( + COLUMN_RETYPE_SCAN_MAX_ROWS, + Math.floor(COLUMN_RETYPE_SCAN_MAX_BYTES / getMaxRowSizeBytes()) + ) + ) +} + +export interface ColumnMutationOptions { + expectedWorkspaceId?: string +} + +async function readColumnRetypePage( + trx: DbTransaction, + tableId: string, + workspaceId: string, + columnKey: string, + limit: number, + afterId?: string +): Promise<Array<{ id: string; value: unknown }>> { + return trx + .select({ + id: userTableRows.id, + value: sql<unknown>`${userTableRows.data}->${columnKey}::text`, + }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), + afterId ? gt(userTableRows.id, afterId) : undefined, + sql`${userTableRows.data} ? ${columnKey}`, + sql`${userTableRows.data}->>${columnKey}::text IS NOT NULL` + ) + ) + .orderBy(asc(userTableRows.id)) + .limit(limit) +} /** * Adds a column to an existing table's schema. @@ -84,114 +126,128 @@ export async function addTableColumn( multiple?: boolean currencyCode?: string }, - requestId: string + requestId: string, + options?: ColumnMutationOptions ): Promise<TableDefinition> { - return withLockedTable(tableId, async (table, trx) => { - assertSchemaMutable(table) - if (!NAME_PATTERN.test(column.name)) { - throw new OrchestrationError( - 'validation', - `Invalid column name "${column.name}". Must start with a letter or underscore and contain only alphanumeric characters and underscores.` - ) - } + return withLockedTable( + tableId, + async (table, trx) => { + assertSchemaMutable(table) + if (!NAME_PATTERN.test(column.name)) { + throw new OrchestrationError( + 'validation', + `Invalid column name "${column.name}". Must start with a letter or underscore and contain only alphanumeric characters and underscores.` + ) + } - if (column.name.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { - throw new OrchestrationError( - 'validation', - `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` - ) - } + if (column.name.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { + throw new OrchestrationError( + 'validation', + `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` + ) + } - if (!COLUMN_TYPES.includes(column.type as (typeof COLUMN_TYPES)[number])) { - throw new OrchestrationError( - 'validation', - `Invalid column type "${column.type}". Must be one of: ${COLUMN_TYPES.join(', ')}` - ) - } + if (!COLUMN_TYPES.includes(column.type as (typeof COLUMN_TYPES)[number])) { + throw new OrchestrationError( + 'validation', + `Invalid column type "${column.type}". Must be one of: ${COLUMN_TYPES.join(', ')}` + ) + } - const schema = table.schema - if (schema.columns.some((c) => c.name.toLowerCase() === column.name.toLowerCase())) { - throw new OrchestrationError('validation', `Column "${column.name}" already exists`) - } + const schema = table.schema + if (schema.columns.some((c) => c.name.toLowerCase() === column.name.toLowerCase())) { + throw new OrchestrationError('validation', `Column "${column.name}" already exists`) + } - if (schema.columns.length >= TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { - throw new OrchestrationError( - 'validation', - `Table has reached maximum column limit (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE})` - ) - } + if (schema.columns.length >= TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { + throw new OrchestrationError( + 'validation', + `Table has reached maximum column limit (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE})` + ) + } - const newColumn: TableSchema['columns'][number] = { - // Honor a caller-provided id (undo of a delete reuses the original id); - // otherwise mint a fresh one. - id: column.id ?? generateColumnId(), - name: column.name, - type: column.type as TableSchema['columns'][number]['type'], - required: column.required ?? false, - unique: column.unique ?? false, - ...(column.options ? { options: column.options } : {}), - ...(column.multiple ? { multiple: true } : {}), - ...columnTypeById(column.type).defaultMetadata?.(column as ColumnDefinition), - } + const newColumn: TableSchema['columns'][number] = { + // Honor a caller-provided id (undo of a delete reuses the original id); + // otherwise mint a fresh one. + id: column.id ?? generateColumnId(), + name: column.name, + type: column.type as TableSchema['columns'][number]['type'], + required: column.required ?? false, + unique: column.unique ?? false, + ...(column.options ? { options: column.options } : {}), + ...(column.multiple ? { multiple: true } : {}), + ...columnTypeById(column.type).defaultMetadata?.(column as ColumnDefinition), + } - const columnValidation = validateColumnDefinition(newColumn) - if (!columnValidation.valid) { - throw new OrchestrationError( - 'validation', - `Invalid column: ${columnValidation.errors.join('; ')}` - ) - } + const columnValidation = validateColumnDefinition(newColumn) + if (!columnValidation.valid) { + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) + } - const newColumnId = getColumnId(newColumn) + const newColumnId = getColumnId(newColumn) - const columns = [...schema.columns] - if (column.position !== undefined && column.position >= 0 && column.position < columns.length) { - columns.splice(column.position, 0, newColumn) - } else { - columns.push(newColumn) - } + const columns = [...schema.columns] + if ( + column.position !== undefined && + column.position >= 0 && + column.position < columns.length + ) { + columns.splice(column.position, 0, newColumn) + } else { + columns.push(newColumn) + } - const updatedSchema: TableSchema = { ...schema, columns } - - // Keep `metadata.columnOrder` (a list of column ids) in sync: splicing the - // new column's id at the same index we used in `columns` keeps display - // ordering aligned with the user's intent for `position`-based inserts. - const existingOrder = table.metadata?.columnOrder - let updatedMetadata = table.metadata - if (existingOrder && existingOrder.length > 0 && !existingOrder.includes(newColumnId)) { - let insertIdx = existingOrder.length - if (column.position !== undefined && column.position >= 0) { - // Anchor on the column previously at `position` — that column shifted - // right by one in `columns`, so the new id slots in at its old spot. - const anchor = schema.columns[column.position] - if (anchor) { - const anchorIdx = existingOrder.indexOf(getColumnId(anchor)) - if (anchorIdx !== -1) insertIdx = anchorIdx + const updatedSchema: TableSchema = { ...schema, columns } + + // Keep `metadata.columnOrder` (a list of column ids) in sync: splicing the + // new column's id at the same index we used in `columns` keeps display + // ordering aligned with the user's intent for `position`-based inserts. + const existingOrder = table.metadata?.columnOrder + let updatedMetadata = table.metadata + if (existingOrder && existingOrder.length > 0 && !existingOrder.includes(newColumnId)) { + let insertIdx = existingOrder.length + if (column.position !== undefined && column.position >= 0) { + // Anchor on the column previously at `position` — that column shifted + // right by one in `columns`, so the new id slots in at its old spot. + const anchor = schema.columns[column.position] + if (anchor) { + const anchorIdx = existingOrder.indexOf(getColumnId(anchor)) + if (anchorIdx !== -1) insertIdx = anchorIdx + } } + const nextOrder = [...existingOrder] + nextOrder.splice(insertIdx, 0, newColumnId) + updatedMetadata = { ...table.metadata, columnOrder: nextOrder } } - const nextOrder = [...existingOrder] - nextOrder.splice(insertIdx, 0, newColumnId) - updatedMetadata = { ...table.metadata, columnOrder: nextOrder } - } - assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) + assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) - const now = new Date() + const now = new Date() - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, tableId)) + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) - logger.info(`[${requestId}] Added column "${column.name}" to table ${tableId}`) + logger.info(`[${requestId}] Added column "${column.name}" to table ${tableId}`) - return { - ...table, - schema: updatedSchema, - metadata: updatedMetadata, - updatedAt: now, - } - }) + return { + ...table, + schema: updatedSchema, + metadata: updatedMetadata, + updatedAt: now, + } + }, + { expectedWorkspaceId: options?.expectedWorkspaceId } + ) } /** @@ -204,64 +260,74 @@ export async function addTableColumn( */ export async function renameColumn( data: RenameColumnData, - requestId: string + requestId: string, + options?: ColumnMutationOptions ): Promise<TableDefinition> { - return withLockedTable(data.tableId, async (table, trx) => { - assertSchemaMutable(table) - if (!NAME_PATTERN.test(data.newName)) { - throw new OrchestrationError( - 'validation', - `Invalid column name "${data.newName}". Column names must start with a letter or underscore, followed by alphanumeric characters or underscores.` - ) - } + return withLockedTable( + data.tableId, + async (table, trx) => { + assertSchemaMutable(table) + if (!NAME_PATTERN.test(data.newName)) { + throw new OrchestrationError( + 'validation', + `Invalid column name "${data.newName}". Column names must start with a letter or underscore, followed by alphanumeric characters or underscores.` + ) + } - if (data.newName.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { - throw new OrchestrationError( - 'validation', - `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` - ) - } + if (data.newName.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { + throw new OrchestrationError( + 'validation', + `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` + ) + } - const schema = table.schema - const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.oldName)) - if (columnIndex === -1) { - throw new OrchestrationError('not_found', `Column "${data.oldName}" not found`) - } + const schema = table.schema + const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.oldName)) + if (columnIndex === -1) { + throw new OrchestrationError('not_found', `Column "${data.oldName}" not found`) + } - if ( - schema.columns.some( - (c, i) => i !== columnIndex && c.name.toLowerCase() === data.newName.toLowerCase() - ) - ) { - throw new OrchestrationError('validation', `Column "${data.newName}" already exists`) - } + if ( + schema.columns.some( + (c, i) => i !== columnIndex && c.name.toLowerCase() === data.newName.toLowerCase() + ) + ) { + throw new OrchestrationError('validation', `Column "${data.newName}" already exists`) + } - const targetColumn = schema.columns[columnIndex] - const actualOldName = targetColumn.name - - // Rename is metadata-only: stored rows, metadata, and workflow-group refs all - // key on the column's stable id, which a rename never changes — so this is a - // pure schema write, no per-row JSONB rewrite or group/metadata cascade. - // Stamp the current storage key as the id (for any not-yet-backfilled column) - // so existing rows stay reachable as the display name changes. - const columnId = targetColumn.id ?? actualOldName - const updatedColumns = schema.columns.map((c, i) => - i === columnIndex ? { ...c, id: columnId, name: data.newName } : c - ) - const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } - assertValidSchema(updatedSchema, table.metadata?.columnOrder) + const targetColumn = schema.columns[columnIndex] + const actualOldName = targetColumn.name + + // Rename is metadata-only: stored rows, metadata, and workflow-group refs all + // key on the column's stable id, which a rename never changes — so this is a + // pure schema write, no per-row JSONB rewrite or group/metadata cascade. + // Stamp the current storage key as the id (for any not-yet-backfilled column) + // so existing rows stay reachable as the display name changes. + const columnId = targetColumn.id ?? actualOldName + const updatedColumns = schema.columns.map((c, i) => + i === columnIndex ? { ...c, id: columnId, name: data.newName } : c + ) + const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } + assertValidSchema(updatedSchema, table.metadata?.columnOrder) - const now = new Date() - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) + const now = new Date() + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) - logger.info( - `[${requestId}] Renamed column "${actualOldName}" to "${data.newName}" in table ${data.tableId}` - ) - return { ...table, schema: updatedSchema, updatedAt: now } - }) + logger.info( + `[${requestId}] Renamed column "${actualOldName}" to "${data.newName}" in table ${data.tableId}` + ) + return { ...table, schema: updatedSchema, updatedAt: now } + }, + { expectedWorkspaceId: options?.expectedWorkspaceId } + ) } /** Removes the given column-id keys from a metadata blob (widths/order/pinned). */ @@ -298,6 +364,7 @@ function stripColumnIdsFromMetadata( */ function stripColumnDataInBackground( tableId: string, + workspaceId: string, columnIds: string[], rowCount: number, requestId: string @@ -312,7 +379,10 @@ function stripColumnDataInBackground( }) await setTableTxTimeouts(trx, { statementMs }) await updateTableRowsWithDerivedSecretProvenance(trx, { - rowWhere: eq(userTableRows.tableId, tableId), + rowWhere: and( + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId) + )!, transformation: { mode: 'remove-columns', columnIds }, }) }) @@ -340,76 +410,96 @@ function stripColumnDataInBackground( */ export async function deleteColumn( data: DeleteColumnData, - requestId: string + requestId: string, + options?: ColumnMutationOptions ): Promise<TableDefinition> { - const { def, stripKey } = await withLockedTable(data.tableId, async (table, trx) => { - assertColumnDestructive(table) - const schema = table.schema - const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) - if (columnIndex === -1) { - throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) - } + const { def, stripKey } = await withLockedTable( + data.tableId, + async (table, trx) => { + assertColumnDestructive(table) + const schema = table.schema + const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) + if (columnIndex === -1) { + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) + } - if (schema.columns.length <= 1) { - throw new OrchestrationError('validation', 'Cannot delete the last column in a table') - } + if (schema.columns.length <= 1) { + throw new OrchestrationError('validation', 'Cannot delete the last column in a table') + } - const targetColumn = schema.columns[columnIndex] - const actualName = targetColumn.name - const columnId = getColumnId(targetColumn) - const ownerGroupId = targetColumn.workflowGroupId - - // Drop this column's reference (by id) from every group's outputs and - // `columns` dependency. If the column is the last output of its parent - // group, the group itself is also removed (a group with zero outputs is - // invalid). - let groupRemovedId: string | null = null - const updatedGroups = (schema.workflowGroups ?? []) - .map((group) => { - let next = group - if (ownerGroupId && group.id === ownerGroupId) { - const remaining = group.outputs.filter((o) => o.columnName !== columnId) - if (remaining.length === 0) { - groupRemovedId = group.id + const targetColumn = schema.columns[columnIndex] + const actualName = targetColumn.name + const columnId = getColumnId(targetColumn) + const ownerGroupId = targetColumn.workflowGroupId + + // Drop this column's reference (by id) from every group's outputs and + // `columns` dependency. If the column is the last output of its parent + // group, the group itself is also removed (a group with zero outputs is + // invalid). + let groupRemovedId: string | null = null + const updatedGroups = (schema.workflowGroups ?? []) + .map((group) => { + let next = group + if (ownerGroupId && group.id === ownerGroupId) { + const remaining = group.outputs.filter((o) => o.columnName !== columnId) + if (remaining.length === 0) { + groupRemovedId = group.id + } + next = { ...next, outputs: remaining } } - next = { ...next, outputs: remaining } - } - return stripGroupDeps(next, new Set([columnId])) - }) - .filter((g) => g.id !== groupRemovedId) - - const updatedSchema: TableSchema = { - ...schema, - columns: schema.columns.filter((_, i) => i !== columnIndex), - ...(updatedGroups.length > 0 ? { workflowGroups: updatedGroups } : {}), - } - const updatedMetadata = stripColumnIdsFromMetadata( - table.metadata as TableMetadata | null, - new Set([columnId]) - ) - assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) + return stripGroupDeps(next, new Set([columnId])) + }) + .filter((g) => g.id !== groupRemovedId) - const now = new Date() + const updatedSchema: TableSchema = { + ...schema, + columns: schema.columns.filter((_, i) => i !== columnIndex), + ...(updatedGroups.length > 0 ? { workflowGroups: updatedGroups } : {}), + } + const updatedMetadata = stripColumnIdsFromMetadata( + table.metadata as TableMetadata | null, + new Set([columnId]) + ) + assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) - // Schema/metadata update commits now; the column's row-data storage is - // reclaimed in the background (fire-and-forget) — reads never surface the - // orphaned id since the column is already gone from the schema. - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) + const now = new Date() - if (groupRemovedId) await stripGroupExecutions(trx, data.tableId, [groupRemovedId]) + // Schema/metadata update commits now; the column's row-data storage is + // reclaimed in the background (fire-and-forget) — reads never surface the + // orphaned id since the column is already gone from the schema. + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) - logger.info(`[${requestId}] Deleted column "${actualName}" from table ${data.tableId}`) + if (groupRemovedId) { + await stripGroupExecutions(trx, data.tableId, [groupRemovedId], { + expectedWorkspaceId: table.workspaceId, + }) + } - return { - def: { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }, - stripKey: columnId, - } - }) + logger.info(`[${requestId}] Deleted column "${actualName}" from table ${data.tableId}`) - stripColumnDataInBackground(data.tableId, [stripKey], def.rowCount ?? 0, requestId) + return { + def: { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }, + stripKey: columnId, + } + }, + { expectedWorkspaceId: options?.expectedWorkspaceId } + ) + + stripColumnDataInBackground( + data.tableId, + def.workspaceId, + [stripKey], + def.rowCount ?? 0, + requestId + ) return def } @@ -419,85 +509,103 @@ export async function deleteColumn( */ export async function deleteColumns( data: { tableId: string; columnNames: string[] }, - requestId: string + requestId: string, + options?: ColumnMutationOptions ): Promise<TableDefinition> { - const { def, stripKeys } = await withLockedTable(data.tableId, async (table, trx) => { - assertColumnDestructive(table) - const schema = table.schema - const namesToDelete = new Set<string>() - const idsToDelete = new Set<string>() - const notFound: string[] = [] - - for (const name of data.columnNames) { - const col = schema.columns.find((c) => columnMatchesRef(c, name)) - if (!col) { - notFound.push(name) - } else { - namesToDelete.add(col.name) - idsToDelete.add(getColumnId(col)) + const { def, stripKeys } = await withLockedTable( + data.tableId, + async (table, trx) => { + assertColumnDestructive(table) + const schema = table.schema + const namesToDelete = new Set<string>() + const idsToDelete = new Set<string>() + const notFound: string[] = [] + + for (const name of data.columnNames) { + const col = schema.columns.find((c) => columnMatchesRef(c, name)) + if (!col) { + notFound.push(name) + } else { + namesToDelete.add(col.name) + idsToDelete.add(getColumnId(col)) + } } - } - if (notFound.length > 0) { - throw new OrchestrationError('not_found', `Columns not found: ${notFound.join(', ')}`) - } + if (notFound.length > 0) { + throw new OrchestrationError('not_found', `Columns not found: ${notFound.join(', ')}`) + } - const remaining = schema.columns.filter((c) => !namesToDelete.has(c.name)) - if (remaining.length === 0) { - throw new OrchestrationError('validation', 'Cannot delete all columns from a table') - } + const remaining = schema.columns.filter((c) => !namesToDelete.has(c.name)) + if (remaining.length === 0) { + throw new OrchestrationError('validation', 'Cannot delete all columns from a table') + } - // For each group, drop outputs whose column (by id) is being deleted. Groups - // that end up with zero outputs are removed entirely (they'd be invalid). - // Then any remaining group's dependencies referencing a removed column are - // cleaned up. - const removedGroupIds = new Set<string>() - let updatedGroups = (schema.workflowGroups ?? []).map((group) => { - const remainingOutputs = group.outputs.filter((o) => !idsToDelete.has(o.columnName)) - if (remainingOutputs.length === 0) { - removedGroupIds.add(group.id) + // For each group, drop outputs whose column (by id) is being deleted. Groups + // that end up with zero outputs are removed entirely (they'd be invalid). + // Then any remaining group's dependencies referencing a removed column are + // cleaned up. + const removedGroupIds = new Set<string>() + let updatedGroups = (schema.workflowGroups ?? []).map((group) => { + const remainingOutputs = group.outputs.filter((o) => !idsToDelete.has(o.columnName)) + if (remainingOutputs.length === 0) { + removedGroupIds.add(group.id) + } + return remainingOutputs.length === group.outputs.length + ? group + : { ...group, outputs: remainingOutputs } + }) + updatedGroups = updatedGroups + .filter((g) => !removedGroupIds.has(g.id)) + .map((group) => stripGroupDeps(group, idsToDelete)) + const updatedSchema: TableSchema = { + ...schema, + columns: remaining, + ...(updatedGroups.length > 0 ? { workflowGroups: updatedGroups } : {}), } - return remainingOutputs.length === group.outputs.length - ? group - : { ...group, outputs: remainingOutputs } - }) - updatedGroups = updatedGroups - .filter((g) => !removedGroupIds.has(g.id)) - .map((group) => stripGroupDeps(group, idsToDelete)) - const updatedSchema: TableSchema = { - ...schema, - columns: remaining, - ...(updatedGroups.length > 0 ? { workflowGroups: updatedGroups } : {}), - } - const updatedMetadata = stripColumnIdsFromMetadata( - table.metadata as TableMetadata | null, - idsToDelete - ) - assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) + const updatedMetadata = stripColumnIdsFromMetadata( + table.metadata as TableMetadata | null, + idsToDelete + ) + assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) - const now = new Date() + const now = new Date() - // Schema/metadata commit now; row storage for the deleted columns is - // reclaimed in the background (fire-and-forget). - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) + // Schema/metadata commit now; row storage for the deleted columns is + // reclaimed in the background (fire-and-forget). + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) - await stripGroupExecutions(trx, data.tableId, removedGroupIds) + await stripGroupExecutions(trx, data.tableId, removedGroupIds, { + expectedWorkspaceId: table.workspaceId, + }) - logger.info( - `[${requestId}] Deleted columns [${[...namesToDelete].join(', ')}] from table ${data.tableId}` - ) + logger.info( + `[${requestId}] Deleted columns [${[...namesToDelete].join(', ')}] from table ${data.tableId}` + ) - return { - def: { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }, - stripKeys: Array.from(idsToDelete), - } - }) + return { + def: { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }, + stripKeys: Array.from(idsToDelete), + } + }, + { expectedWorkspaceId: options?.expectedWorkspaceId } + ) if (stripKeys.length > 0) { - stripColumnDataInBackground(data.tableId, stripKeys, def.rowCount ?? 0, requestId) + stripColumnDataInBackground( + data.tableId, + def.workspaceId, + stripKeys, + def.rowCount ?? 0, + requestId + ) } return def } @@ -515,6 +623,7 @@ export async function deleteColumns( async function applyConstraints( trx: DbTransaction, tableId: string, + workspaceId: string, column: ColumnDefinition, columnKey: string, data: { required?: boolean; unique?: boolean } @@ -528,7 +637,7 @@ async function applyConstraints( ) } if (data.required === true && !column.required) { - const emptyCount = await countEmptyCells(trx, tableId, columnKey) + const emptyCount = await countEmptyCells(trx, tableId, workspaceId, columnKey) if (emptyCount > 0) { throw new OrchestrationError( 'validation', @@ -543,7 +652,7 @@ async function applyConstraints( `Cannot set column "${column.name}" as unique: ${column.type} columns compare stored values that would allow only one row per value.` ) } - if (await hasDuplicateValues(trx, tableId, columnKey)) { + if (await hasDuplicateValues(trx, tableId, workspaceId, columnKey)) { throw new OrchestrationError( 'validation', `Cannot set column "${column.name}" as unique: duplicate values exist` @@ -568,7 +677,12 @@ async function persistColumns( await trx .update(userTableDefinitions) .set({ schema: updatedSchema, updatedAt: now }) - .where(eq(userTableDefinitions.id, table.id)) + .where( + and( + eq(userTableDefinitions.id, table.id), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) return { ...table, schema: updatedSchema, updatedAt: now } } @@ -584,10 +698,11 @@ async function persistColumns( async function hasDuplicateValues( trx: DbTransaction, tableId: string, + workspaceId: string, columnKey: string ): Promise<boolean> { const duplicates = (await trx.execute( - sql`SELECT ${userTableRows.data}->>${columnKey}::text AS val, count(*) AS cnt FROM ${userTableRows} WHERE table_id = ${tableId} AND ${userTableRows.data} ? ${columnKey} AND ${userTableRows.data}->>${columnKey}::text IS NOT NULL GROUP BY val HAVING count(*) > 1 LIMIT 1` + sql`SELECT ${userTableRows.data}->>${columnKey}::text AS val, count(*) AS cnt FROM ${userTableRows} WHERE table_id = ${tableId} AND workspace_id = ${workspaceId} AND ${userTableRows.data} ? ${columnKey} AND ${userTableRows.data}->>${columnKey}::text IS NOT NULL GROUP BY val HAVING count(*) > 1 LIMIT 1` )) as { val: string; cnt: number }[] return duplicates.length > 0 } @@ -695,240 +810,270 @@ function buildConvertedColumn( */ export async function updateColumnType( data: UpdateColumnTypeData, - requestId: string + requestId: string, + options?: ColumnMutationOptions ): Promise<TableDefinition> { - return withLockedTable(data.tableId, async (table, trx) => { - // Retype reinterprets every stored value under a new type — destructive. - assertColumnDestructive(table) - // Scale both statement and idle timeouts to row count: the compatibility - // check below iterates every row in Node between the row SELECT and the - // schema UPDATE, leaving the transaction idle for that gap. The default 5s - // `idle_in_transaction_session_timeout` would abort a valid type change on - // a large table. - const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { - baseMs: 60_000, - perRowMs: 2, - }) - await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) - - if (!(COLUMN_TYPES as readonly string[]).includes(data.newType)) { - throw new OrchestrationError( - 'validation', - `Invalid column type "${data.newType}". Valid types: ${COLUMN_TYPES.join(', ')}` - ) - } - - const schema = table.schema - const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) - if (columnIndex === -1) { - throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) - } + return withLockedTable( + data.tableId, + async (table, trx) => { + // Retype reinterprets every stored value under a new type — destructive. + assertColumnDestructive(table) + // Scale both statement and idle timeouts to row count: the compatibility + // check below iterates every row in Node between the row SELECT and the + // schema UPDATE, leaving the transaction idle for that gap. The default 5s + // `idle_in_transaction_session_timeout` would abort a valid type change on + // a large table. + const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { + baseMs: 60_000, + perRowMs: 2, + }) + await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) - const column = schema.columns[columnIndex] - if (column.type === data.newType) { - // Callers gate on the type actually changing, but they compute that from - // a schema read taken before this transaction took the lock — so a - // concurrent change can land us here with real work still to do. Only a - // rename can be honoured without a conversion; anything else would be - // silently discarded, and answering success for a change that never - // happened is the worst outcome available. - const carriesOtherWork = - data.required !== undefined || - data.unique !== undefined || - data.options !== undefined || - data.multiple !== undefined || - data.currencyCode !== undefined - if (carriesOtherWork) { + if (!(COLUMN_TYPES as readonly string[]).includes(data.newType)) { throw new OrchestrationError( 'validation', - `Column "${column.name}" is already type "${data.newType}"; re-issue the request without a type change.` + `Invalid column type "${data.newType}". Valid types: ${COLUMN_TYPES.join(', ')}` ) } - const renamed = applyPendingRename(schema.columns, columnIndex, data.newName) - if (renamed === column) return table - return persistColumns( - trx, - table, - schema.columns.map((c, i) => (i === columnIndex ? renamed : c)) - ) - } - const columnKey = getColumnId(column) - // Validate existing data is compatible with the new type - const rows = await trx - .select({ id: userTableRows.id, data: userTableRows.data }) - .from(userTableRows) - .where( - and( - eq(userTableRows.tableId, data.tableId), - sql`${userTableRows.data} ? ${columnKey}`, - sql`${userTableRows.data}->>${columnKey}::text IS NOT NULL` - ) - ) + const schema = table.schema + const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) + if (columnIndex === -1) { + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) + } - // Options the column will carry after the change — a `select` value is only - // compatible if it resolves against this set. - const isSelectType = data.newType === 'select' - const targetOptions = data.options ?? column.options ?? [] - const targetMultiple = data.multiple ?? column.multiple - // Leaving `select` behind: stored cells hold option ids, which mean nothing - // once the column is text/number/etc. Check compatibility against the option - // NAME — that's what the cell will actually become (migrated below). - const convertingAwayFromSelect = column.type === 'select' && !isSelectType - // The constraint the column ends up with, which may be arriving in this - // same request — this write applies it, so the scan below has to judge - // against the target value rather than the current one. - const targetRequired = !!(data.required ?? column.required) - - // Rows missing the key (or holding null/`[]`) are filtered out of `rows` - // entirely, so the loop below can never see them — they have to be counted - // separately, through the same predicate `applyConstraints` uses. - if (targetRequired) { - const emptyCount = await countEmptyCells(trx, data.tableId, columnKey) - if (emptyCount > 0) { - throw new OrchestrationError( - 'validation', - `Cannot change column "${column.name}" to a required "${data.newType}": ${emptyCount} row(s) have null, missing, or empty values. Fill them first, or apply the type change without making the column required.` + const column = schema.columns[columnIndex] + if (column.type === data.newType) { + // Callers gate on the type actually changing, but they compute that from + // a schema read taken before this transaction took the lock — so a + // concurrent change can land us here with real work still to do. Only a + // rename can be honoured without a conversion; anything else would be + // silently discarded, and answering success for a change that never + // happened is the worst outcome available. + const carriesOtherWork = + data.required !== undefined || + data.unique !== undefined || + data.options !== undefined || + data.multiple !== undefined || + data.currencyCode !== undefined + if (carriesOtherWork) { + throw new OrchestrationError( + 'validation', + `Column "${column.name}" is already type "${data.newType}"; re-issue the request without a type change.` + ) + } + const renamed = applyPendingRename(schema.columns, columnIndex, data.newName) + if (renamed === column) return table + return persistColumns( + trx, + table, + schema.columns.map((c, i) => (i === columnIndex ? renamed : c)) ) } - } - - /** - * The column definition the table ends up with. Built before the scan so - * the coercion below reads the same metadata (option set, currency) the - * stored value will be validated against afterwards. - */ - const convertedColumn = buildConvertedColumn(column, data, { - isSelectType, - targetMultiple: !!targetMultiple, - }) - - let incompatibleCount = 0 - let blankCount = 0 - /** - * Row id → the value the cell must END UP holding. - * - * Collected during the compatibility scan rather than re-derived later, so - * it reads the same `effective` value the check accepted — which for a - * `select` source is the option name, not the stored id. - * - * Load-bearing: a conversion is allowed exactly when the target type's - * `coerce` accepts the value, and `coerce` frequently *transforms* it (an - * epoch number becomes an ISO date, a formatted amount becomes a number). - * Without writing the transformed value back, the cell keeps its old bytes - * under the new type — and since filters and sorts apply the type's - * `jsonbCast` to whatever is stored, an epoch left in a `date` column makes - * `::timestamptz` fail on EVERY query against it. - */ - const coercedByRowId = new Map<string, JsonValue>() - for (const row of rows) { - const rowData = row.data as RowData - const value = rowData[columnKey] - if (value === null || value === undefined) continue - - const effective = convertingAwayFromSelect ? selectValueForConversion(column, value) : value - - if (!isValueCompatibleWithColumn(effective, convertedColumn)) { - // A cell the target cannot read but that is merely EMPTY is not a - // conversion failure — the write path already turns an unreadable value - // into null on an optional column, so the conversion does the same. Only - // a required target has a real problem with it, and the guard above has - // already reported those. Blocking here meant a text column with a - // single blank cell could not be converted to a number at all. - if (effective === null || effective === '') { - if (targetRequired) blankCount++ - else coercedByRowId.set(row.id, null) - } else { - incompatibleCount++ + const columnKey = getColumnId(column) + + // Options the column will carry after the change — a `select` value is only + // compatible if it resolves against this set. + const isSelectType = data.newType === 'select' + const targetOptions = data.options ?? column.options ?? [] + const targetMultiple = data.multiple ?? column.multiple + // Leaving `select` behind: stored cells hold option ids, which mean nothing + // once the column is text/number/etc. Check compatibility against the option + // NAME — that's what the cell will actually become (migrated below). + const convertingAwayFromSelect = column.type === 'select' && !isSelectType + // The constraint the column ends up with, which may be arriving in this + // same request — this write applies it, so the scan below has to judge + // against the target value rather than the current one. + const targetRequired = !!(data.required ?? column.required) + + // Rows missing the key (or holding null/`[]`) are filtered out of `rows` + // entirely, so the loop below can never see them — they have to be counted + // separately, through the same predicate `applyConstraints` uses. + if (targetRequired) { + const emptyCount = await countEmptyCells(trx, data.tableId, table.workspaceId, columnKey) + if (emptyCount > 0) { + throw new OrchestrationError( + 'validation', + `Cannot change column "${column.name}" to a required "${data.newType}": ${emptyCount} row(s) have null, missing, or empty values. Fill them first, or apply the type change without making the column required.` + ) } - continue } - // `select` keeps its own id↔name migrations; everything else writes back - // whatever `coerce` produced, when that differs from what is stored. - if (!isSelectType && effective !== null) { - const coerced = columnTypeById(data.newType).coerce(effective as JsonValue, convertedColumn) - if (coerced.ok && !Object.is(coerced.value, value)) { - coercedByRowId.set(row.id, coerced.value) + /** + * The column definition the table ends up with. Built before the scan so + * the coercion below reads the same metadata (option set, currency) the + * stored value will be validated against afterwards. + */ + const convertedColumn = buildConvertedColumn(column, data, { + isSelectType, + targetMultiple: !!targetMultiple, + }) + + let incompatibleCount = 0 + let blankCount = 0 + /** + * Row id → the value the cell must END UP holding. + * + * Collected during the compatibility scan rather than re-derived later, so + * it reads the same `effective` value the check accepted — which for a + * `select` source is the option name, not the stored id. + * + * Load-bearing: a conversion is allowed exactly when the target type's + * `coerce` accepts the value, and `coerce` frequently *transforms* it (an + * epoch number becomes an ISO date, a formatted amount becomes a number). + * Without writing the transformed value back, the cell keeps its old bytes + * under the new type — and since filters and sorts apply the type's + * `jsonbCast` to whatever is stored, an epoch left in a `date` column makes + * `::timestamptz` fail on EVERY query against it. + */ + const retypeScanBatchSize = getColumnRetypeScanBatchSize() + let validationAfterId: string | undefined + while (true) { + const rows = await readColumnRetypePage( + trx, + data.tableId, + table.workspaceId, + columnKey, + retypeScanBatchSize, + validationAfterId + ) + if (rows.length === 0) break + for (const row of rows) { + const value = row.value + if (value === null || value === undefined) continue + + const effective = convertingAwayFromSelect + ? selectValueForConversion(column, value) + : value + + if (!isValueCompatibleWithColumn(effective, convertedColumn)) { + if (effective === null || effective === '') { + if (targetRequired) blankCount++ + } else { + incompatibleCount++ + } + } } + validationAfterId = rows.at(-1)?.id + if (rows.length < retypeScanBatchSize) break } - } - - if (blankCount > 0) { - throw new OrchestrationError( - 'validation', - `Cannot change column "${column.name}" to a required "${data.newType}": ${blankCount} row(s) are empty. Fill them first, or apply the type change without making the column required.` - ) - } - if (incompatibleCount > 0) { - throw new OrchestrationError( - 'validation', - `Cannot change column "${column.name}" to type "${data.newType}": ${incompatibleCount} row(s) have incompatible values. Fix or remove the incompatible values first.` - ) - } + if (blankCount > 0) { + throw new OrchestrationError( + 'validation', + `Cannot change column "${column.name}" to a required "${data.newType}": ${blankCount} row(s) are empty. Fill them first, or apply the type change without making the column required.` + ) + } - const renamedColumns = schema.columns.map((c, i) => (i === columnIndex ? convertedColumn : c)) - const updatedColumns = renamedColumns.map((c, i) => - i === columnIndex ? applyPendingRename(renamedColumns, columnIndex, data.newName) : c - ) + if (incompatibleCount > 0) { + throw new OrchestrationError( + 'validation', + `Cannot change column "${column.name}" to type "${data.newType}": ${incompatibleCount} row(s) have incompatible values. Fix or remove the incompatible values first.` + ) + } - const columnValidation = validateColumnDefinition(updatedColumns[columnIndex]) - if (!columnValidation.valid) { - throw new OrchestrationError( - 'validation', - `Invalid column: ${columnValidation.errors.join('; ')}` + const renamedColumns = schema.columns.map((c, i) => (i === columnIndex ? convertedColumn : c)) + const updatedColumns = renamedColumns.map((c, i) => + i === columnIndex ? applyPendingRename(renamedColumns, columnIndex, data.newName) : c ) - } - - const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } - const now = new Date() - - // Cell rewrites are owned by the column-type registry, keyed by direction. - // Outbound runs first: leaving `select` turns opaque option ids into names, - // which is the form the inbound migration (if any) then reads. - const migrationContext = { - trx, - tableId: data.tableId, - columnKey, - previous: column, - target: updatedColumns[columnIndex], - resolved: coercedByRowId, - } - await migrationFrom(column.type)?.(migrationContext) - if (isSelectType) { - await migrationTo(data.newType)?.(migrationContext) - } else { - await writeBackCoercedCells(trx, data.tableId, columnKey, coercedByRowId) - } - // A `unique` arriving with this retype is validated HERE, against the values - // the conversion just wrote — not by the separate constraint write that - // follows. The conversion itself manufactures duplicates that no scan of the - // pre-conversion data can see (`"5"` and `"5.0"` both coerce to `5`), and - // that write runs in its own transaction, so discovering it there would - // report an error with the retype already committed and the original text - // irrecoverably rewritten. - if (data.unique === true && !column.unique) { - if (await hasDuplicateValues(trx, data.tableId, columnKey)) { + const columnValidation = validateColumnDefinition(updatedColumns[columnIndex]) + if (!columnValidation.valid) { throw new OrchestrationError( 'validation', - `Cannot change column "${column.name}" to type "${data.newType}" and set it as unique: the converted values contain duplicates.` + `Invalid column: ${columnValidation.errors.join('; ')}` ) } - } - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) + const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } + const now = new Date() - logger.info( - `[${requestId}] Changed column "${column.name}" type from "${column.type}" to "${data.newType}" in table ${data.tableId}` - ) + // Cell rewrites are owned by the column-type registry, keyed by direction. + // Outbound runs first: leaving `select` turns opaque option ids into names, + // which is the form the inbound migration (if any) then reads. + const migrationContext = { + trx, + tableId: data.tableId, + workspaceId: table.workspaceId, + columnKey, + previous: column, + target: updatedColumns[columnIndex], + resolved: new Map<string, JsonValue>(), + } + await migrationFrom(column.type)?.(migrationContext) + if (isSelectType) { + await migrationTo(data.newType)?.(migrationContext) + } else { + let rewriteAfterId: string | undefined + while (true) { + const rows = await readColumnRetypePage( + trx, + data.tableId, + table.workspaceId, + columnKey, + retypeScanBatchSize, + rewriteAfterId + ) + if (rows.length === 0) break + const coercedByRowId = new Map<string, JsonValue>() + for (const row of rows) { + const value = row.value + if (value === null || value === undefined) continue + if (value === '') { + coercedByRowId.set(row.id, null) + continue + } + const coerced = columnTypeById(data.newType).coerce(value as JsonValue, convertedColumn) + if (coerced.ok && !Object.is(coerced.value, value)) { + coercedByRowId.set(row.id, coerced.value) + } + } + await writeBackCoercedCells( + trx, + data.tableId, + table.workspaceId, + columnKey, + coercedByRowId + ) + rewriteAfterId = rows.at(-1)?.id + if (rows.length < retypeScanBatchSize) break + } + } - return { ...table, schema: updatedSchema, updatedAt: now } - }) + // A `unique` arriving with this retype is validated HERE, against the values + // the conversion just wrote — not by the separate constraint write that + // follows. The conversion itself manufactures duplicates that no scan of the + // pre-conversion data can see (`"5"` and `"5.0"` both coerce to `5`), and + // that write runs in its own transaction, so discovering it there would + // report an error with the retype already committed and the original text + // irrecoverably rewritten. + if (data.unique === true && !column.unique) { + if (await hasDuplicateValues(trx, data.tableId, table.workspaceId, columnKey)) { + throw new OrchestrationError( + 'validation', + `Cannot change column "${column.name}" to type "${data.newType}" and set it as unique: the converted values contain duplicates.` + ) + } + } + + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) + + logger.info( + `[${requestId}] Changed column "${column.name}" type from "${column.type}" to "${data.newType}" in table ${data.tableId}` + ) + + return { ...table, schema: updatedSchema, updatedAt: now } + }, + { expectedWorkspaceId: options?.expectedWorkspaceId } + ) } /** @@ -941,48 +1086,65 @@ export async function updateColumnType( */ export async function updateColumnConstraints( data: UpdateColumnConstraintsData, - requestId: string + requestId: string, + options?: ColumnMutationOptions ): Promise<TableDefinition> { - return withLockedTable(data.tableId, async (table, trx) => { - assertSchemaMutable(table) - // Scale both statement and idle timeouts to row count: the required/unique - // validation runs between separate queries inside this transaction, leaving - // it briefly idle. Match `updateColumnType` so the default 5s - // `idle_in_transaction_session_timeout` can't abort a valid change on a - // large table. - const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { - baseMs: 60_000, - perRowMs: 2, - }) - await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) + return withLockedTable( + data.tableId, + async (table, trx) => { + assertSchemaMutable(table) + // Scale both statement and idle timeouts to row count: the required/unique + // validation runs between separate queries inside this transaction, leaving + // it briefly idle. Match `updateColumnType` so the default 5s + // `idle_in_transaction_session_timeout` can't abort a valid change on a + // large table. + const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { + baseMs: 60_000, + perRowMs: 2, + }) + await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) - const schema = table.schema - const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) - if (columnIndex === -1) { - throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) - } + const schema = table.schema + const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) + if (columnIndex === -1) { + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) + } - const column = schema.columns[columnIndex] - const columnKey = getColumnId(column) - const constrained = await applyConstraints(trx, data.tableId, column, columnKey, data) - const withConstraints = schema.columns.map((c, i) => (i === columnIndex ? constrained : c)) - const updatedColumns = withConstraints.map((c, i) => - i === columnIndex ? applyPendingRename(withConstraints, columnIndex, data.newName) : c - ) - const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } - const now = new Date() + const column = schema.columns[columnIndex] + const columnKey = getColumnId(column) + const constrained = await applyConstraints( + trx, + data.tableId, + table.workspaceId, + column, + columnKey, + data + ) + const withConstraints = schema.columns.map((c, i) => (i === columnIndex ? constrained : c)) + const updatedColumns = withConstraints.map((c, i) => + i === columnIndex ? applyPendingRename(withConstraints, columnIndex, data.newName) : c + ) + const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } + const now = new Date() - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) - logger.info( - `[${requestId}] Updated constraints for column "${column.name}" in table ${data.tableId}` - ) + logger.info( + `[${requestId}] Updated constraints for column "${column.name}" in table ${data.tableId}` + ) - return { ...table, schema: updatedSchema, updatedAt: now } - }) + return { ...table, schema: updatedSchema, updatedAt: now } + }, + { expectedWorkspaceId: options?.expectedWorkspaceId } + ) } /** @@ -993,161 +1155,177 @@ export async function updateColumnConstraints( */ export async function updateColumnOptions( data: UpdateColumnOptionsData, - requestId: string + requestId: string, + options?: ColumnMutationOptions ): Promise<TableDefinition> { - return withLockedTable(data.tableId, async (table, trx) => { - const schema = table.schema - const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) - if (columnIndex === -1) { - throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) - } + return withLockedTable( + data.tableId, + async (table, trx) => { + const schema = table.schema + const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) + if (columnIndex === -1) { + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) + } - const column = schema.columns[columnIndex] - if (column.type !== 'select') { - throw new OrchestrationError( - 'validation', - `Cannot set options on column "${column.name}" of type "${column.type}"` - ) - } + const column = schema.columns[columnIndex] + if (column.type !== 'select') { + throw new OrchestrationError( + 'validation', + `Cannot set options on column "${column.name}" of type "${column.type}"` + ) + } - const columnKey = getColumnId(column) + const columnKey = getColumnId(column) - const { multiple: _prevMultiple, ...columnRest } = column - const updatedColumn = { - ...columnRest, - options: data.options, - ...((data.multiple ?? column.multiple) ? { multiple: true } : {}), - } - const columnValidation = validateColumnDefinition(updatedColumn) - if (!columnValidation.valid) { - throw new OrchestrationError( - 'validation', - `Invalid column: ${columnValidation.errors.join('; ')}` - ) - } + const { multiple: _prevMultiple, ...columnRest } = column + const updatedColumn = { + ...columnRest, + options: data.options, + ...((data.multiple ?? column.multiple) ? { multiple: true } : {}), + } + const columnValidation = validateColumnDefinition(updatedColumn) + if (!columnValidation.valid) { + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) + } - const nextMultiple = !!(data.multiple ?? column.multiple) - const wasMultiple = !!column.multiple - const keptIds = new Set(data.options.map((o) => o.id)) - const removedAny = (column.options ?? []).some((o) => !keptIds.has(o.id)) - const togglingCardinality = nextMultiple !== wasMultiple - // The constraint the column ENDS UP with, which may be arriving in this same - // request. `applyConstraints` validates and applies it below, after the cell - // migrations; the checks in between need to read the target value. - const targetRequired = !!(data.required ?? column.required) - - if (togglingCardinality || removedAny) { - const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { - baseMs: 60_000, - perRowMs: 2, - }) - await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) - } + const nextMultiple = !!(data.multiple ?? column.multiple) + const wasMultiple = !!column.multiple + const keptIds = new Set(data.options.map((o) => o.id)) + const removedAny = (column.options ?? []).some((o) => !keptIds.has(o.id)) + const togglingCardinality = nextMultiple !== wasMultiple + // The constraint the column ENDS UP with, which may be arriving in this same + // request. `applyConstraints` validates and applies it below, after the cell + // migrations; the checks in between need to read the target value. + const targetRequired = !!(data.required ?? column.required) + + if (togglingCardinality || removedAny) { + const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { + baseMs: 60_000, + perRowMs: 2, + }) + await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) + } - // Removal runs FIRST, before the multi→single guard and the shape migration. - // Both of those read the cells: the guard would otherwise count options this - // same request is dropping, and the migration keeps a multi cell's FIRST - // element — which could be a removed id sitting ahead of a kept one, so the - // surviving option would be discarded and the dead one kept. - // - // Cells are still in their pre-toggle shape here, so this passes the CURRENT - // cardinality, not the target one. - if (removedAny) { - // On a required column, clearing is not an option: it would leave rows the - // write path rejects, and `updateColumnConstraints` refuses to CREATE that - // state, so producing it here would be inconsistent. Make the caller - // reassign those rows first. + // Removal runs FIRST, before the multi→single guard and the shape migration. + // Both of those read the cells: the guard would otherwise count options this + // same request is dropping, and the migration keeps a multi cell's FIRST + // element — which could be a removed id sitting ahead of a kept one, so the + // surviving option would be discarded and the dead one kept. // - // Gated on the constraint the column ENDS UP with, which may be arriving - // in this same request: validating against the current flag both blocks a - // removal paired with `required: false` that is about to be fine, and lets - // a removal paired with `required: true` clear cells and then fail the - // constraint write, leaving this change committed behind an error. - if (targetRequired) { - const strandedCount = await countCellsLosingTheirOptions( + // Cells are still in their pre-toggle shape here, so this passes the CURRENT + // cardinality, not the target one. + if (removedAny) { + // On a required column, clearing is not an option: it would leave rows the + // write path rejects, and `updateColumnConstraints` refuses to CREATE that + // state, so producing it here would be inconsistent. Make the caller + // reassign those rows first. + // + // Gated on the constraint the column ENDS UP with, which may be arriving + // in this same request: validating against the current flag both blocks a + // removal paired with `required: false` that is about to be fine, and lets + // a removal paired with `required: true` clear cells and then fail the + // constraint write, leaving this change committed behind an error. + if (targetRequired) { + const strandedCount = await countCellsLosingTheirOptions( + trx, + data.tableId, + table.workspaceId, + columnKey, + data.options, + wasMultiple + ) + if (strandedCount > 0) { + throw new OrchestrationError( + 'validation', + `Cannot remove options from required column "${column.name}": ${strandedCount} row(s) would be left empty. Reassign those rows to a remaining option first.` + ) + } + } + await clearRemovedSelectOptions( trx, data.tableId, + table.workspaceId, columnKey, data.options, wasMultiple ) - if (strandedCount > 0) { + } + + // Switching multiple → single drops all but the first option in any cell + // that still holds several — block it rather than silently losing data. + // Counted after the removal above, so dropping surplus options and turning + // multiselect off in one save is allowed when every cell ends up with one. + if (wasMultiple && !nextMultiple) { + const [result] = await trx + .select({ count: count() }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, table.workspaceId), + sql`CASE WHEN jsonb_typeof(${userTableRows.data}->${columnKey}::text) = 'array' + THEN jsonb_array_length(${userTableRows.data}->${columnKey}::text) > 1 + ELSE false END` + ) + ) + const multiValuedCount = result?.count ?? 0 + + if (multiValuedCount > 0) { throw new OrchestrationError( 'validation', - `Cannot remove options from required column "${column.name}": ${strandedCount} row(s) would be left empty. Reassign those rows to a remaining option first.` + `Cannot switch column "${column.name}" to single-select: ${multiValuedCount} row(s) have multiple options selected. Reduce them to one option first.` ) } } - await clearRemovedSelectOptions(trx, data.tableId, columnKey, data.options, wasMultiple) - } - - // Switching multiple → single drops all but the first option in any cell - // that still holds several — block it rather than silently losing data. - // Counted after the removal above, so dropping surplus options and turning - // multiselect off in one save is allowed when every cell ends up with one. - if (wasMultiple && !nextMultiple) { - const rows = await trx - .select({ data: userTableRows.data }) - .from(userTableRows) - .where( - and(eq(userTableRows.tableId, data.tableId), sql`${userTableRows.data} ? ${columnKey}`) - ) - - let multiValuedCount = 0 - for (const row of rows) { - const value = (row.data as RowData)[columnKey] - if (Array.isArray(value) && value.length > 1) multiValuedCount++ - } - if (multiValuedCount > 0) { - throw new OrchestrationError( - 'validation', - `Cannot switch column "${column.name}" to single-select: ${multiValuedCount} row(s) have multiple options selected. Reduce them to one option first.` - ) + // A single↔multi toggle changes the stored shape (scalar id vs array of + // ids). Multi filters compile to array containment, which never matches a + // scalar, so leaving cells un-normalized would silently drop every + // pre-toggle row out of its own column's filters. + if (togglingCardinality) { + // Same registry migration the retype path uses — `updatedColumn` already + // carries the post-toggle `options`/`multiple`, which is all it reads. + await migrationTo('select')?.({ + trx, + tableId: data.tableId, + workspaceId: table.workspaceId, + columnKey, + previous: column, + target: updatedColumn, + resolved: new Map(), + }) } - } - // A single↔multi toggle changes the stored shape (scalar id vs array of - // ids). Multi filters compile to array containment, which never matches a - // scalar, so leaving cells un-normalized would silently drop every - // pre-toggle row out of its own column's filters. - if (togglingCardinality) { - // Same registry migration the retype path uses — `updatedColumn` already - // carries the post-toggle `options`/`multiple`, which is all it reads. - await migrationTo('select')?.({ + // Constraints are validated and applied AFTER the migrations above, because + // those migrations rewrite stored values — a `unique` scan run before them + // would read the pre-migration shape and pass, and the migration could then + // produce the duplicates it was meant to prevent. + const constrainedColumn = await applyConstraints( trx, - tableId: data.tableId, + data.tableId, + table.workspaceId, + updatedColumn, columnKey, - previous: column, - target: updatedColumn, - resolved: new Map(), - }) - } - - // Constraints are validated and applied AFTER the migrations above, because - // those migrations rewrite stored values — a `unique` scan run before them - // would read the pre-migration shape and pass, and the migration could then - // produce the duplicates it was meant to prevent. - const constrainedColumn = await applyConstraints( - trx, - data.tableId, - updatedColumn, - columnKey, - data - ) - const withOptions = schema.columns.map((c, i) => (i === columnIndex ? constrainedColumn : c)) - const updatedColumns = withOptions.map((c, i) => - i === columnIndex ? applyPendingRename(withOptions, columnIndex, data.newName) : c - ) + data + ) + const withOptions = schema.columns.map((c, i) => (i === columnIndex ? constrainedColumn : c)) + const updatedColumns = withOptions.map((c, i) => + i === columnIndex ? applyPendingRename(withOptions, columnIndex, data.newName) : c + ) - const updated = await persistColumns(trx, table, updatedColumns) + const updated = await persistColumns(trx, table, updatedColumns) - logger.info( - `[${requestId}] Updated options for column "${column.name}" in table ${data.tableId}` - ) + logger.info( + `[${requestId}] Updated options for column "${column.name}" in table ${data.tableId}` + ) - return updated - }) + return updated + }, + { expectedWorkspaceId: options?.expectedWorkspaceId } + ) } /** @@ -1165,73 +1343,84 @@ export async function updateColumnOptions( */ export async function updateColumnCurrency( data: UpdateColumnCurrencyData, - requestId: string + requestId: string, + options?: ColumnMutationOptions ): Promise<TableDefinition> { - return withLockedTable(data.tableId, async (table, trx) => { - assertSchemaMutable(table) + 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) { + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) + } - const schema = table.schema - const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) - if (columnIndex === -1) { - throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) - } + const column = schema.columns[columnIndex] + if (column.type !== 'currency') { + throw new OrchestrationError( + 'validation', + `Cannot set currency on column "${column.name}" of type "${column.type}"` + ) + } - const column = schema.columns[columnIndex] - if (column.type !== 'currency') { - throw new OrchestrationError( - 'validation', - `Cannot set currency on column "${column.name}" of type "${column.type}"` - ) - } + const updatedColumn: ColumnDefinition = { + ...column, + currencyCode: resolveCurrencyCode(data.currencyCode), + } + const columnValidation = validateColumnDefinition(updatedColumn) + if (!columnValidation.valid) { + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) + } - const updatedColumn: ColumnDefinition = { - ...column, - currencyCode: resolveCurrencyCode(data.currencyCode), - } - const columnValidation = validateColumnDefinition(updatedColumn) - if (!columnValidation.valid) { - throw new OrchestrationError( - 'validation', - `Invalid column: ${columnValidation.errors.join('; ')}` + const constrained = await applyConstraints( + trx, + data.tableId, + table.workspaceId, + updatedColumn, + getColumnId(column), + data ) - } - - const constrained = await applyConstraints( - trx, - data.tableId, - updatedColumn, - getColumnId(column), - data - ) - // Only a no-op when nothing at all changed — currency, constraints, name. - const renamePending = data.newName !== undefined && data.newName !== column.name - if ( - constrained === updatedColumn && - updatedColumn.currencyCode === column.currencyCode && - !renamePending - ) { - return table - } + // Only a no-op when nothing at all changed — currency, constraints, name. + const renamePending = data.newName !== undefined && data.newName !== column.name + if ( + constrained === updatedColumn && + updatedColumn.currencyCode === column.currencyCode && + !renamePending + ) { + return table + } - const withCurrency = schema.columns.map((c, i) => (i === columnIndex ? constrained : c)) - const updatedColumns = withCurrency.map((c, i) => - i === columnIndex ? applyPendingRename(withCurrency, columnIndex, data.newName) : c - ) - const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } - const now = new Date() + const withCurrency = schema.columns.map((c, i) => (i === columnIndex ? constrained : c)) + const updatedColumns = withCurrency.map((c, i) => + i === columnIndex ? applyPendingRename(withCurrency, columnIndex, data.newName) : c + ) + const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } + const now = new Date() - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) - logger.info( - `[${requestId}] Set currency for column "${column.name}" to "${updatedColumn.currencyCode}" in table ${data.tableId}` - ) + logger.info( + `[${requestId}] Set currency for column "${column.name}" to "${updatedColumn.currencyCode}" in table ${data.tableId}` + ) - return { ...table, schema: updatedSchema, updatedAt: now } - }) + return { ...table, schema: updatedSchema, updatedAt: now } + }, + { expectedWorkspaceId: options?.expectedWorkspaceId } + ) } /** @@ -1247,6 +1436,7 @@ export async function updateColumnCurrency( async function countEmptyCells( trx: DbTransaction, tableId: string, + workspaceId: string, columnKey: string ): Promise<number> { const [result] = await trx @@ -1255,6 +1445,7 @@ async function countEmptyCells( .where( and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`(NOT (${userTableRows.data} ? ${columnKey}) OR ${userTableRows.data}->>${columnKey}::text IS NULL OR ${userTableRows.data}->${columnKey}::text = '[]'::jsonb)` @@ -1271,6 +1462,7 @@ async function countEmptyCells( async function countCellsLosingTheirOptions( trx: DbTransaction, tableId: string, + workspaceId: string, columnKey: string, options: SelectOption[], multiple: boolean @@ -1284,6 +1476,7 @@ async function countCellsLosingTheirOptions( .where( and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) = 'array'`, sql`${userTableRows.data}->${columnKey}::text <> '[]'::jsonb`, // The type guard above is not ordered against this predicate, so the @@ -1308,6 +1501,7 @@ async function countCellsLosingTheirOptions( .where( and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) = 'string'`, sql`${userTableRows.data}->>${columnKey}::text <> ''`, sql`NOT (${keptIds}::jsonb @> jsonb_build_array(${userTableRows.data}->${columnKey}::text))` @@ -1325,6 +1519,7 @@ async function countCellsLosingTheirOptions( async function clearRemovedSelectOptions( trx: DbTransaction, tableId: string, + workspaceId: string, columnKey: string, options: SelectOption[], multiple: boolean @@ -1335,6 +1530,7 @@ async function clearRemovedSelectOptions( await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere: and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) = 'array'`, sql`NOT (${keptIds}::jsonb @> (${userTableRows.data}->${columnKey}::text))` )!, @@ -1354,6 +1550,7 @@ async function clearRemovedSelectOptions( await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere: and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) = 'string'`, sql`${userTableRows.data}->>${columnKey}::text <> ''`, sql`NOT (${keptIds}::jsonb @> jsonb_build_array(${userTableRows.data}->${columnKey}::text))` diff --git a/apps/sim/lib/table/dispatcher.ts b/apps/sim/lib/table/dispatcher.ts index 30bb60c4101..6b2747e0e4d 100644 --- a/apps/sim/lib/table/dispatcher.ts +++ b/apps/sim/lib/table/dispatcher.ts @@ -21,7 +21,7 @@ import { writeWorkflowGroupState } from '@/lib/table/cell-write' import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' import { isExecCancelledAfter } from '@/lib/table/deps' import { appendTableEvent } from '@/lib/table/events' -import { type DbExecutor, withSeqscanOff } from '@/lib/table/planner' +import { type DbExecutor, type DbTransaction, withSeqscanOff } from '@/lib/table/planner' import { updateTableRowsWithDerivedSecretProvenance } from '@/lib/table/rows/secret-provenance' import { buildFilterClause } from '@/lib/table/sql' import type { @@ -87,6 +87,24 @@ export interface DispatchRow { requestedAt: Date } +async function deleteExecutionRows(trx: DbTransaction, filters: SQL[]): Promise<number> { + const countRows = await trx.execute<{ count: number | string }>(sql` + WITH deleted AS ( + DELETE FROM ${tableRowExecutions} + WHERE ${and(...filters)} + RETURNING 1 + ) + SELECT count(*)::integer AS count FROM deleted + `) + const [countRow] = Array.isArray(countRows) ? countRows : [] + if (!countRow) throw new Error('Workflow cell clearing did not return a deleted count') + const count = Number(countRow.count) + if (!Number.isSafeInteger(count) || count < 0) { + throw new Error('Workflow cell clearing returned an invalid deleted count') + } + return count +} + export type DispatcherStepResult = 'continue' | 'done' /** Eager bulk clear at click time so the user sees every targeted cell go @@ -96,17 +114,18 @@ export type DispatcherStepResult = 'continue' | 'done' * already filled, mirroring the eligibility predicate. */ export async function bulkClearWorkflowGroupCells(input: { tableId: string + workspaceId: string groups: Array<{ id: string; outputs: Array<{ columnName: string }> }> rowIds?: string[] /** Select-all scope: deselected rows whose outputs must NOT be wiped. */ excludeRowIds?: string[] mode: DispatchMode -}): Promise<void> { - const { tableId, groups, rowIds, excludeRowIds, mode } = input - if (groups.length === 0) return +}): Promise<boolean> { + const { tableId, workspaceId, groups, rowIds, excludeRowIds, mode } = input + if (groups.length === 0) return false // `'new'` mode targets only rows with no prior attempt — nothing to clear. // Pre-existing outputs on any other row must not be wiped by an auto-fire. - if (mode === 'new') return + if (mode === 'new') return false const groupIds = groups.map((g) => g.id) const rowScope = rowIds && rowIds.length > 0 ? rowIds : null @@ -119,25 +138,34 @@ export async function bulkClearWorkflowGroupCells(input: { const outputCols = Array.from( new Set(groups.flatMap((g) => g.outputs.map((o) => o.columnName))) ) - const filters: SQL[] = [eq(userTableRows.tableId, tableId)] + const filters: SQL[] = [ + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), + ] if (rowScope) filters.push(inArray(userTableRows.id, rowScope)) if (excluded) filters.push(notInArray(userTableRows.id, excluded)) - await db.transaction(async (trx) => { + return db.transaction(async (trx) => { const rowWhere = and(...filters)! - await updateTableRowsWithDerivedSecretProvenance(trx, { + const clearedRows = await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere, transformation: { mode: 'remove-columns', columnIds: outputCols }, }) const execFilters: SQL[] = [ eq(tableRowExecutions.tableId, tableId), inArray(tableRowExecutions.groupId, groupIds), + sql`${tableRowExecutions.rowId} IN ( + SELECT ${userTableRows.id} + FROM ${userTableRows} + WHERE ${userTableRows.tableId} = ${tableId} + AND ${userTableRows.workspaceId} = ${workspaceId} + )`, ] if (rowScope) execFilters.push(inArray(tableRowExecutions.rowId, rowScope)) if (excluded) execFilters.push(notInArray(tableRowExecutions.rowId, excluded)) - await trx.delete(tableRowExecutions).where(and(...execFilters)) + const deletedExecutions = await deleteExecutionRows(trx, execFilters) + return clearedRows > 0 || deletedExecutions > 0 }) - return } // `incomplete`: clear per-group, not per-row. Only groups that are @@ -147,7 +175,8 @@ export async function bulkClearWorkflowGroupCells(input: { // because a *sibling* group on the same row is incomplete, re-running the // completed one. (`never-run` groups have no exec/output to clear — the // dispatcher runs them via eligibility.) - await db.transaction(async (trx) => { + return db.transaction(async (trx) => { + let rowsChanged = false for (const group of groups) { const reRunnable = sql`EXISTS ( SELECT 1 FROM ${tableRowExecutions} re @@ -155,12 +184,16 @@ export async function bulkClearWorkflowGroupCells(input: { AND re.group_id = ${group.id} AND re.status IN ('error', 'cancelled') )` - const filters: SQL[] = [eq(userTableRows.tableId, tableId), reRunnable] + const filters: SQL[] = [ + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), + reRunnable, + ] if (rowScope) filters.push(inArray(userTableRows.id, rowScope)) if (excluded) filters.push(notInArray(userTableRows.id, excluded)) const rowWhere = and(...filters)! - await updateTableRowsWithDerivedSecretProvenance(trx, { + const clearedRows = await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere, transformation: { mode: 'remove-columns', @@ -172,11 +205,19 @@ export async function bulkClearWorkflowGroupCells(input: { eq(tableRowExecutions.tableId, tableId), eq(tableRowExecutions.groupId, group.id), sql`${tableRowExecutions.status} IN ('error', 'cancelled')`, + sql`${tableRowExecutions.rowId} IN ( + SELECT ${userTableRows.id} + FROM ${userTableRows} + WHERE ${userTableRows.tableId} = ${tableId} + AND ${userTableRows.workspaceId} = ${workspaceId} + )`, ] if (rowScope) execFilters.push(inArray(tableRowExecutions.rowId, rowScope)) if (excluded) execFilters.push(notInArray(tableRowExecutions.rowId, excluded)) - await trx.delete(tableRowExecutions).where(and(...execFilters)) + const deletedExecutions = await deleteExecutionRows(trx, execFilters) + rowsChanged ||= clearedRows > 0 || deletedExecutions > 0 } + return rowsChanged }) } diff --git a/apps/sim/lib/table/export-runner.test.ts b/apps/sim/lib/table/export-runner.test.ts index 959d7cba670..c026bc6cb04 100644 --- a/apps/sim/lib/table/export-runner.test.ts +++ b/apps/sim/lib/table/export-runner.test.ts @@ -30,10 +30,10 @@ vi.mock('@/lib/table/service', () => ({ })) vi.mock('@/lib/table/jobs/service', () => ({ selectExportRowPage: mockSelectExportRowPage, - updateJobProgress: mockUpdateJobProgress, - markJobReady: mockMarkJobReady, - markJobFailed: mockMarkJobFailed, - setJobResultKey: mockSetJobResultKey, + updateJobProgressInWorkspace: mockUpdateJobProgress, + markJobReadyInWorkspace: mockMarkJobReady, + markJobFailedInWorkspace: mockMarkJobFailed, + setJobResultKeyInWorkspace: mockSetJobResultKey, })) vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent })) vi.mock('@/lib/uploads/core/storage-service', () => ({ @@ -84,7 +84,7 @@ describe('runTableExport', () => { mockUpdateJobProgress.mockResolvedValue(true) mockMarkJobReady.mockResolvedValue(true) mockMarkJobFailed.mockResolvedValue(undefined) - mockSetJobResultKey.mockResolvedValue(undefined) + mockSetJobResultKey.mockResolvedValue(true) mockDeleteFile.mockResolvedValue(undefined) // A handle that records every write so tests can assert the streamed bytes, and echoes the // pinned key back from `complete` like the real uploader does. @@ -126,8 +126,8 @@ describe('runTableExport', () => { expect(lastHandle?.complete).toHaveBeenCalledTimes(1) expect(lastHandle?.abort).not.toHaveBeenCalled() - expect(mockSetJobResultKey).toHaveBeenCalledWith('tbl_1', 'job_1', init.key) - expect(mockMarkJobReady).toHaveBeenCalledWith('tbl_1', 'job_1') + expect(mockSetJobResultKey).toHaveBeenCalledWith('tbl_1', 'ws_1', 'job_1', init.key) + expect(mockMarkJobReady).toHaveBeenCalledWith('tbl_1', 'ws_1', 'job_1') expect(mockAppendTableEvent).toHaveBeenCalledWith( expect.objectContaining({ kind: 'job', type: 'export', status: 'ready', progress: 1 }) ) @@ -186,7 +186,7 @@ describe('runTableExport', () => { await runTableExport(payload) expect(lastHandle?.abort).toHaveBeenCalledTimes(1) - expect(mockMarkJobFailed).toHaveBeenCalledWith('tbl_1', 'job_1', 'boom') + expect(mockMarkJobFailed).toHaveBeenCalledWith('tbl_1', 'ws_1', 'job_1', 'boom') expect(mockAppendTableEvent).toHaveBeenCalledWith( expect.objectContaining({ kind: 'job', type: 'export', status: 'failed', error: 'boom' }) ) diff --git a/apps/sim/lib/table/export-runner.ts b/apps/sim/lib/table/export-runner.ts index d9b6c190298..f85f0d86f1e 100644 --- a/apps/sim/lib/table/export-runner.ts +++ b/apps/sim/lib/table/export-runner.ts @@ -11,11 +11,11 @@ import { toCsvRow, } from '@/lib/table/export-format' import { - markJobFailed, - markJobReady, + markJobFailedInWorkspace, + markJobReadyInWorkspace, selectExportRowPage, - setJobResultKey, - updateJobProgress, + setJobResultKeyInWorkspace, + updateJobProgressInWorkspace, } from '@/lib/table/jobs/service' import { getTableById } from '@/lib/table/service' import { @@ -57,7 +57,9 @@ export async function runTableExport(payload: TableExportPayload): Promise<void> try { const table = await getTableById(tableId, { includeArchived: true }) - if (!table) throw new Error(`Export target table ${tableId} not found`) + if (!table || table.workspaceId !== workspaceId) { + throw new Error(`Export target table ${tableId} not found in workspace ${workspaceId}`) + } const columns = table.schema.columns // Stored row data is id-keyed and select cells hold option ids; JSON keys are display @@ -90,7 +92,7 @@ export async function runTableExport(payload: TableExportPayload): Promise<void> let after: { orderKey: string | null; id: string } | null = null while (true) { // Ownership gate before every page: a canceled job stops within one batch. - const owns = await updateJobProgress(tableId, exported, jobId) + const owns = await updateJobProgressInWorkspace(tableId, workspaceId, exported, jobId) if (!owns) throw new JobSupersededError() const page = await selectExportRowPage(table, after, EXPORT_BATCH_SIZE) @@ -117,16 +119,23 @@ export async function runTableExport(payload: TableExportPayload): Promise<void> } if (format === 'json') await handle.write(']') - const ownsFinalize = await updateJobProgress(tableId, exported, jobId) + const ownsFinalize = await updateJobProgressInWorkspace(tableId, workspaceId, exported, jobId) if (!ownsFinalize) throw new JobSupersededError() const uploaded = await handle.complete() uploadedKey = uploaded.key - await setJobResultKey(tableId, jobId, uploaded.key) - - await updateJobProgress(tableId, exported, jobId) + const storedResult = await setJobResultKeyInWorkspace(tableId, workspaceId, jobId, uploaded.key) + if (!storedResult) throw new JobSupersededError() + + const ownsReadyTransition = await updateJobProgressInWorkspace( + tableId, + workspaceId, + exported, + jobId + ) + if (!ownsReadyTransition) throw new JobSupersededError() // Only announce success if we still won the transition (not canceled at the wire). - const becameReady = await markJobReady(tableId, jobId) + const becameReady = await markJobReadyInWorkspace(tableId, workspaceId, jobId) if (becameReady) { void appendTableEvent({ kind: 'job', @@ -140,7 +149,17 @@ export async function runTableExport(payload: TableExportPayload): Promise<void> } else { // Canceled at the very end — the file is orphaned; remove it (janitor would otherwise // only catch it via the pruned job's resultKey). - await deleteFile({ key: uploaded.key, context: 'workspace' }).catch(() => {}) + try { + await deleteFile({ key: uploaded.key, context: 'workspace' }) + } catch (cleanupError) { + logger.warn(`[${requestId}] Failed to delete superseded export`, { + tableId, + workspaceId, + jobId, + key: uploaded.key, + error: getErrorMessage(cleanupError, 'Unknown cleanup error'), + }) + } logger.info(`[${requestId}] Export finished but no longer owns the run`, { tableId, jobId }) } } catch (err) { @@ -148,16 +167,44 @@ export async function runTableExport(payload: TableExportPayload): Promise<void> // in-flight multipart upload (not yet completed) is aborted so no staged parts linger; a // completed-but-unannounced upload is removed by key. if (uploadedKey) { - await deleteFile({ key: uploadedKey, context: 'workspace' }).catch(() => {}) + try { + await deleteFile({ key: uploadedKey, context: 'workspace' }) + } catch (cleanupError) { + logger.warn(`[${requestId}] Failed to delete incomplete export`, { + tableId, + workspaceId, + jobId, + key: uploadedKey, + error: getErrorMessage(cleanupError, 'Unknown cleanup error'), + }) + } } else if (handle) { - await handle.abort().catch(() => {}) + try { + await handle.abort() + } catch (cleanupError) { + logger.warn(`[${requestId}] Failed to abort incomplete export`, { + tableId, + workspaceId, + jobId, + error: getErrorMessage(cleanupError, 'Unknown cleanup error'), + }) + } } if (err instanceof JobSupersededError) { logger.info(`[${requestId}] Export superseded/canceled; stopping`, { tableId, jobId }) } else { const message = getErrorMessage(err, 'Export failed') logger.error(`[${requestId}] Export failed for table ${tableId}:`, err) - await markJobFailed(tableId, jobId, message).catch(() => {}) + try { + await markJobFailedInWorkspace(tableId, workspaceId, jobId, message) + } catch (failureError) { + logger.error(`[${requestId}] Failed to mark export job failed`, { + tableId, + workspaceId, + jobId, + error: getErrorMessage(failureError, 'Unknown job transition error'), + }) + } void appendTableEvent({ kind: 'job', type: 'export', diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index 041352c0166..593e79a513d 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -8,7 +8,7 @@ import { db } from '@sim/db' import { userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' import { CSV_MAX_BATCH_SIZE } from '@/lib/table/import' @@ -128,9 +128,9 @@ export async function bulkInsertImportBatch( ...(data.userId ? { createdBy: data.userId } : {}), })) - await db.transaction(async (trx) => { + const inserted = await db.transaction(async (trx) => { await guardBatch(trx, data.tableId, revalidate) - await mutateTableRowsWithSecretProvenance(trx, { + return mutateTableRowsWithSecretProvenance(trx, { rows: rowsToInsert.map((row) => ({ rowId: row.id, provenance: createExactEmptyTableRowSecretProvenance(row.data), @@ -142,13 +142,16 @@ export async function bulkInsertImportBatch( .insert(userTableRows) .values(rowsToInsert) .returning({ id: userTableRows.id }) - return { value: undefined, affectedRowIds: inserted.map((row) => row.id) } + return { value: inserted.length, affectedRowIds: inserted.map((row) => row.id) } }, }) }) - logger.info(`[${requestId}] Bulk-imported ${rowsToInsert.length} rows into table ${data.tableId}`) + if (inserted !== rowsToInsert.length) { + throw new Error('Bulk table import inserted an unexpected row count') + } + logger.info(`[${requestId}] Bulk-imported ${inserted} rows into table ${data.tableId}`) return { - inserted: rowsToInsert.length, + inserted, lastOrderKey: orderKeys[orderKeys.length - 1] ?? data.afterOrderKey ?? null, } } @@ -164,7 +167,11 @@ export async function deleteAllTableRows( if (!revalidate) assertRowDelete(table) await db.transaction(async (trx) => { await guardBatch(trx, table.id, revalidate) - await trx.delete(userTableRows).where(eq(userTableRows.tableId, table.id)) + await trx + .delete(userTableRows) + .where( + and(eq(userTableRows.tableId, table.id), eq(userTableRows.workspaceId, table.workspaceId)) + ) }) } @@ -210,7 +217,12 @@ export async function setTableSchemaForImport( await trx .update(userTableDefinitions) .set({ schema, updatedAt: new Date() }) - .where(eq(userTableDefinitions.id, table.id)) + .where( + and( + eq(userTableDefinitions.id, table.id), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) }) } @@ -231,9 +243,13 @@ async function refreshUnderLock( ): Promise<TableDefinition> { const fresh = await guardBatch(trx, table.id, async (tx) => { const latest = await getTableById(table.id, { tx, includeArchived: true }) - return latest ?? undefined + if (!latest || latest.workspaceId !== table.workspaceId) { + throw new OrchestrationError('not_found', 'Table not found') + } + return latest }) - return fresh ?? table + if (!fresh) throw new Error('Table refresh did not return a canonical table') + return fresh } /** diff --git a/apps/sim/lib/table/import-runner.test.ts b/apps/sim/lib/table/import-runner.test.ts index b0fae115c83..168bfba8d82 100644 --- a/apps/sim/lib/table/import-runner.test.ts +++ b/apps/sim/lib/table/import-runner.test.ts @@ -40,9 +40,9 @@ vi.mock('@/lib/table/import-data', () => ({ setTableSchemaForImport: vi.fn(), })) vi.mock('@/lib/table/jobs/service', () => ({ - markJobFailed: mockMarkJobFailed, - markJobReady: mockMarkJobReady, - updateJobProgress: mockUpdateJobProgress, + markJobFailedInWorkspace: mockMarkJobFailed, + markJobReadyInWorkspace: mockMarkJobReady, + updateJobProgressInWorkspace: mockUpdateJobProgress, })) vi.mock('@/lib/table/rows/ordering', () => ({ nextImportStartOrderKey: mockNextImportStartOrderKey, @@ -114,6 +114,7 @@ describe('runTableImport source-file cleanup', () => { expect(mockMarkJobFailed).toHaveBeenCalledWith( 'tbl_1', + 'ws_1', 'job_1', expect.stringMatching(/insert-locked/i) ) diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index 669fe7169ee..9afb692daf3 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { truncate } from '@sim/utils/string' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { captureServerEvent } from '@/lib/posthog/server' import { buildAutoMapping, @@ -28,7 +29,11 @@ import { deleteAllTableRows, setTableSchemaForImport, } from '@/lib/table/import-data' -import { markJobFailed, markJobReady, updateJobProgress } from '@/lib/table/jobs/service' +import { + markJobFailedInWorkspace, + markJobReadyInWorkspace, + updateJobProgressInWorkspace, +} from '@/lib/table/jobs/service' import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks' import type { DbTransaction } from '@/lib/table/planner' import { nextImportStartOrderKey, nextImportStartPosition } from '@/lib/table/rows/ordering' @@ -99,9 +104,13 @@ export async function runTableImport(payload: TableImportPayload): Promise<void> let source: Readable | undefined try { - if (!(await updateJobProgress(tableId, 0, importId))) throw new ImportSupersededError() + if (!(await updateJobProgressInWorkspace(tableId, workspaceId, 0, importId))) { + throw new ImportSupersededError() + } const loaded = await getTableById(tableId, { includeArchived: true }) - if (!loaded) throw new Error(`Import target table ${tableId} not found`) + if (!loaded || loaded.workspaceId !== workspaceId) { + throw new Error(`Import target table ${tableId} not found in workspace ${workspaceId}`) + } const table = loaded // Every mode ends in row inserts, and `replace` deletes first. Assert both @@ -118,20 +127,29 @@ export async function runTableImport(payload: TableImportPayload): Promise<void> // file through. Rows already committed stay — as with an explicit cancel. const revalidateInsert = async (trx: DbTransaction) => { const fresh = await getTableById(tableId, { tx: trx, includeArchived: true }) - if (fresh) assertRowInsert(fresh) - return fresh ?? undefined + if (!fresh || fresh.workspaceId !== workspaceId) { + throw new OrchestrationError('not_found', 'Table not found') + } + assertRowInsert(fresh) + return fresh } /** Same guard for the replace-mode wipe, which lands before the first batch. */ const revalidateDelete = async (trx: DbTransaction) => { const fresh = await getTableById(tableId, { tx: trx, includeArchived: true }) - if (fresh) assertRowDelete(fresh) - return fresh ?? undefined + if (!fresh || fresh.workspaceId !== workspaceId) { + throw new OrchestrationError('not_found', 'Table not found') + } + assertRowDelete(fresh) + return fresh } /** Same guard for the inferred-schema write and `createColumns`. */ const revalidateSchema = async (trx: DbTransaction) => { const fresh = await getTableById(tableId, { tx: trx, includeArchived: true }) - if (fresh) assertSchemaMutable(fresh) - return fresh ?? undefined + if (!fresh || fresh.workspaceId !== workspaceId) { + throw new OrchestrationError('not_found', 'Table not found') + } + assertSchemaMutable(fresh) + return fresh } // Total byte size for the progress estimate — a cheap HEAD, no download. May be null on @@ -190,7 +208,7 @@ export async function runTableImport(payload: TableImportPayload): Promise<void> * map onto the existing schema, optionally auto-creating `createColumns` first. */ const resolveSetup = async () => { - if (!(await updateJobProgress(tableId, inserted, importId))) { + if (!(await updateJobProgressInWorkspace(tableId, workspaceId, inserted, importId))) { throw new ImportSupersededError() } const headers = csvHeaders @@ -260,7 +278,7 @@ export async function runTableImport(payload: TableImportPayload): Promise<void> // Ownership gate before every insert: once this run loses the table (cancel/supersede), // updateJobProgress returns false and we stop before writing into a table a newer import // may own. Runs per batch (not just at the emit cadence) so we stop within one batch. - const owns = await updateJobProgress(tableId, inserted, importId) + const owns = await updateJobProgressInWorkspace(tableId, workspaceId, inserted, importId) if (!owns) throw new ImportSupersededError() const coerced = coerceRowsForTable(rows, schema, headerToColumn, { timezone: payload.timezone, @@ -359,7 +377,7 @@ export async function runTableImport(payload: TableImportPayload): Promise<void> if (sample.length === 0) { // No data rows — fail rather than report a successful empty import (matches the sync route). const message = 'CSV file has no data rows' - await markJobFailed(tableId, importId, message) + await markJobFailedInWorkspace(tableId, workspaceId, importId, message) void appendTableEvent({ kind: 'job', type: 'import', @@ -390,10 +408,10 @@ export async function runTableImport(payload: TableImportPayload): Promise<void> await flush(batch) } - await updateJobProgress(tableId, inserted, importId) + await updateJobProgressInWorkspace(tableId, workspaceId, inserted, importId) // Only announce success if we actually won the transition — a cancel/supersede that landed // right at the end makes this a no-op, and we must not emit a false `ready`. - const becameReady = await markJobReady(tableId, importId) + const becameReady = await markJobReadyInWorkspace(tableId, workspaceId, importId) if (becameReady) { void appendTableEvent({ kind: 'job', @@ -437,7 +455,16 @@ export async function runTableImport(payload: TableImportPayload): Promise<void> const message = getErrorMessage(err, 'Import failed') logger.error(`[${requestId}] Import failed for table ${tableId}:`, err) // Scoped to importId — a no-op if a newer import has taken over. - await markJobFailed(tableId, importId, message).catch(() => {}) + try { + await markJobFailedInWorkspace(tableId, workspaceId, importId, message) + } catch (failureError) { + logger.error(`[${requestId}] Failed to mark import job failed`, { + tableId, + workspaceId, + importId, + error: getErrorMessage(failureError, 'Unknown job transition error'), + }) + } void appendTableEvent({ kind: 'job', type: 'import', diff --git a/apps/sim/lib/table/jobs/service.ts b/apps/sim/lib/table/jobs/service.ts index ca916e689b8..3dbc48eadcd 100644 --- a/apps/sim/lib/table/jobs/service.ts +++ b/apps/sim/lib/table/jobs/service.ts @@ -156,6 +156,37 @@ export async function markTableJobRunning( return inserted.length > 0 } +/** Claims a job only when the canonical table remains in the expected workspace. */ +export async function markTableJobRunningInWorkspace( + tableId: string, + workspaceId: string, + jobId: string, + type: TableJobType, + payload?: unknown +): Promise<boolean> { + const [definition] = await db + .select({ workspaceId: userTableDefinitions.workspaceId }) + .from(userTableDefinitions) + .where( + and(eq(userTableDefinitions.id, tableId), eq(userTableDefinitions.workspaceId, workspaceId)) + ) + .limit(1) + if (!definition) return false + const inserted = await db + .insert(tableJobs) + .values({ + id: jobId, + tableId, + workspaceId: definition.workspaceId, + type, + status: 'running', + payload: payload ?? null, + }) + .onConflictDoNothing() + .returning({ id: tableJobs.id }) + return inserted.length > 0 +} + /** * Releases a claim taken by {@link markTableJobRunning} for a synchronous job — deletes the * transient claim row. Scoped to `jobId` + still-running so it only clears its own claim, never a @@ -169,6 +200,26 @@ export async function releaseJobClaim(tableId: string, jobId: string): Promise<v ) } +/** Releases only the active claim in the canonical workspace and reports no-op races. */ +export async function releaseJobClaimInWorkspace( + tableId: string, + workspaceId: string, + jobId: string +): Promise<boolean> { + const released = await db + .delete(tableJobs) + .where( + and( + eq(tableJobs.id, jobId), + eq(tableJobs.tableId, tableId), + eq(tableJobs.workspaceId, workspaceId), + eq(tableJobs.status, 'running') + ) + ) + .returning({ id: tableJobs.id }) + return released.length > 0 +} + /** * Records job progress (rows processed so far) and bumps `updated_at` so the stale-job janitor * (`cleanup-stale-executions`) sees a live heartbeat. @@ -191,6 +242,21 @@ export async function updateJobProgress( return updated.length > 0 } +/** Updates transfer progress under the job's canonical workspace scope. */ +export async function updateJobProgressInWorkspace( + tableId: string, + workspaceId: string, + rowsProcessed: number, + jobId: string +): Promise<boolean> { + const updated = await db + .update(tableJobs) + .set({ rowsProcessed, updatedAt: new Date() }) + .where(ownsActiveJobInWorkspace(tableId, workspaceId, jobId)) + .returning({ id: tableJobs.id }) + return updated.length > 0 +} + /** * Reads the persisted progress of an in-flight job this worker still owns (`null` when the job * was canceled/superseded). A retried run seeds its counter from this so progress stays @@ -340,6 +406,24 @@ export async function setJobResultKey( .where(ownsActiveJob(tableId, jobId)) } +/** Stamps an export result only while the canonical workspace-scoped job is active. */ +export async function setJobResultKeyInWorkspace( + tableId: string, + workspaceId: string, + jobId: string, + resultKey: string +): Promise<boolean> { + const updated = await db + .update(tableJobs) + .set({ + payload: sql`coalesce(${tableJobs.payload}, '{}'::jsonb) || jsonb_build_object('resultKey', ${resultKey}::text)`, + updatedAt: new Date(), + }) + .where(ownsActiveJobInWorkspace(tableId, workspaceId, jobId)) + .returning({ id: tableJobs.id }) + return updated.length > 0 +} + /** Shared WHERE for terminal transitions: this job run, and still in-flight (write-once). */ function ownsActiveJob(tableId: string, jobId: string) { return and( @@ -349,6 +433,15 @@ function ownsActiveJob(tableId: string, jobId: string) { ) } +function ownsActiveJobInWorkspace(tableId: string, workspaceId: string, jobId: string) { + return and( + eq(tableJobs.id, jobId), + eq(tableJobs.tableId, tableId), + eq(tableJobs.workspaceId, workspaceId), + eq(tableJobs.status, 'running') + ) +} + /** * Marks a job complete. No-op unless it's still this in-flight run. Returns whether it * transitioned, so the worker only emits the `ready` event when it actually won (and not after a @@ -364,6 +457,21 @@ export async function markJobReady(tableId: string, jobId: string): Promise<bool return updated.length > 0 } +/** Completes a transfer only while its canonical workspace-scoped job is active. */ +export async function markJobReadyInWorkspace( + tableId: string, + workspaceId: string, + jobId: string +): Promise<boolean> { + const now = new Date() + const updated = await db + .update(tableJobs) + .set({ status: 'ready', error: null, completedAt: now, updatedAt: now }) + .where(ownsActiveJobInWorkspace(tableId, workspaceId, jobId)) + .returning({ id: tableJobs.id }) + return updated.length > 0 +} + /** * Marks a job failed, leaving any already-committed work in place. No-op unless it's still this * in-flight run (so a stale worker can't clobber a newer job or a cancel). @@ -376,6 +484,22 @@ export async function markJobFailed(tableId: string, jobId: string, error: strin .where(ownsActiveJob(tableId, jobId)) } +/** Fails a transfer only while its canonical workspace-scoped job is active. */ +export async function markJobFailedInWorkspace( + tableId: string, + workspaceId: string, + jobId: string, + error: string +): Promise<boolean> { + const now = new Date() + const updated = await db + .update(tableJobs) + .set({ status: 'failed', error: error.slice(0, 2000), completedAt: now, updatedAt: now }) + .where(ownsActiveJobInWorkspace(tableId, workspaceId, jobId)) + .returning({ id: tableJobs.id }) + return updated.length > 0 +} + /** * Marks an in-flight job canceled (user-initiated). No-op unless it's still running. The * worker's next ownership check then returns `false` and it stops; committed work is left in diff --git a/apps/sim/lib/table/orchestration/columns.ts b/apps/sim/lib/table/orchestration/columns.ts index 71048def646..48f8f5ffd3d 100644 --- a/apps/sim/lib/table/orchestration/columns.ts +++ b/apps/sim/lib/table/orchestration/columns.ts @@ -9,6 +9,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys' import { columnTypeById } from '@/lib/table/column-types' import { + type ColumnMutationOptions, renameColumn, updateColumnConstraints, updateColumnCurrency, @@ -22,6 +23,12 @@ import type { ColumnType, SelectOption, TableDefinition, TableLockKind } from '@ const logger = createLogger('TableColumnOrchestration') +function workspaceMutationOptions( + expectedWorkspaceId: string | undefined +): [] | [ColumnMutationOptions] { + return expectedWorkspaceId ? [{ expectedWorkspaceId }] : [] +} + export interface PerformUpdateTableColumnParams { table: TableDefinition columnName: string @@ -37,6 +44,8 @@ export interface PerformUpdateTableColumnParams { currencyCode?: string } requestId?: string + expectedWorkspaceId?: string + recordAudit?: boolean /** Forwarded to the audit record for IP / user-agent capture. */ request?: OrchestrationRequestContext } @@ -174,7 +183,8 @@ export async function performUpdateTableColumn( ...(updates.unique !== undefined ? { unique: updates.unique } : {}), ...renameWithTypedWrite, }, - requestId + requestId, + ...workspaceMutationOptions(params.expectedWorkspaceId) ) } else if (updates.currencyCode !== undefined) { // Re-denominating an existing currency column: schema-only, no cell @@ -189,7 +199,8 @@ export async function performUpdateTableColumn( ...(updates.unique !== undefined ? { unique: updates.unique } : {}), ...renameWithTypedWrite, }, - requestId + requestId, + ...workspaceMutationOptions(params.expectedWorkspaceId) ) } else if (options !== undefined || updates.multiple !== undefined) { updated = await updateColumnOptions( @@ -202,7 +213,8 @@ export async function performUpdateTableColumn( ...(updates.unique !== undefined ? { unique: updates.unique } : {}), ...renameWithTypedWrite, }, - requestId + requestId, + ...workspaceMutationOptions(params.expectedWorkspaceId) ) } @@ -217,7 +229,8 @@ export async function performUpdateTableColumn( ...(updates.unique !== undefined ? { unique: updates.unique } : {}), ...(updates.name ? { newName: updates.name } : {}), }, - requestId + requestId, + ...workspaceMutationOptions(params.expectedWorkspaceId) ) } @@ -229,7 +242,8 @@ export async function performUpdateTableColumn( if (updates.name && !updated) { updated = await renameColumn( { tableId, oldName: columnRef, newName: updates.name }, - requestId + requestId, + ...workspaceMutationOptions(params.expectedWorkspaceId) ) } } catch (error) { @@ -252,17 +266,19 @@ export async function performUpdateTableColumn( return fail('No updates specified', 'validation') } - recordAudit({ - workspaceId: table.workspaceId, - actorId: userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Updated column "${columnName}" in table "${table.name}"`, - metadata: { columnName, updates }, - ...(request ? { request } : {}), - }) + if (params.recordAudit !== false) { + recordAudit({ + workspaceId: table.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Updated column "${columnName}" in table "${table.name}"`, + metadata: { columnName, updates }, + ...(request ? { request } : {}), + }) + } return { success: true, table: updated } } diff --git a/apps/sim/lib/table/orchestration/export-resource.ts b/apps/sim/lib/table/orchestration/export-resource.ts index 381ae860051..6f8410d5c1a 100644 --- a/apps/sim/lib/table/orchestration/export-resource.ts +++ b/apps/sim/lib/table/orchestration/export-resource.ts @@ -9,7 +9,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { runDetached } from '@/lib/core/utils/background' import { TABLE_LIMITS } from '@/lib/table/constants' import { runTableExport, type TableExportPayload } from '@/lib/table/export-runner' -import { markJobCanceled, markJobFailed, markTableJobRunning } from '@/lib/table/jobs/service' +import { markTableJobRunningInWorkspace } from '@/lib/table/jobs/service' import type { TableDefinition, TableExportJobPayload } from '@/lib/table/types' export type TableExportRecord = typeof tableJobs.$inferSelect @@ -20,7 +20,15 @@ export async function createTableExportResource(params: { }): Promise<TableExportRecord> { const exportId = generateId() const payload: TableExportJobPayload = { format: params.format } - if (!(await markTableJobRunning(params.table.id, exportId, 'export', payload))) { + if ( + !(await markTableJobRunningInWorkspace( + params.table.id, + params.table.workspaceId, + exportId, + 'export', + payload + )) + ) { throw new OrchestrationError('conflict', 'Failed to start export') } const runnerPayload: TableExportPayload = { @@ -48,11 +56,12 @@ export async function createTableExportResource(params: { runDetached('table-export', () => runTableExport(runnerPayload)) } } catch (error) { - await markJobFailed( - params.table.id, + await markExportFailed({ + tableId: params.table.id, + workspaceId: params.table.workspaceId, exportId, - getErrorMessage(error, 'Failed to dispatch table export') - ) + error: getErrorMessage(error, 'Failed to dispatch table export'), + }) throw error } } @@ -62,7 +71,7 @@ export async function createTableExportResource(params: { export async function requireTableExport( exportId: string, - workspaceId: string + assertedWorkspaceId?: string ): Promise<TableExportRecord> { const [record] = await db .select() @@ -70,8 +79,10 @@ export async function requireTableExport( .where( and( eq(tableJobs.id, exportId), - eq(tableJobs.workspaceId, workspaceId), - eq(tableJobs.type, 'export') + eq(tableJobs.type, 'export'), + assertedWorkspaceId === undefined + ? undefined + : eq(tableJobs.workspaceId, assertedWorkspaceId) ) ) .limit(1) @@ -86,10 +97,57 @@ export async function cancelTableExportResource( if (record.status !== 'running') { throw new OrchestrationError('conflict', `Table export is ${publicExportStatus(record.status)}`) } - await markJobCanceled(record.tableId, record.id) + const now = new Date() + const canceled = await db + .update(tableJobs) + .set({ status: 'canceled', completedAt: now, updatedAt: now }) + .where( + and( + eq(tableJobs.id, record.id), + eq(tableJobs.tableId, record.tableId), + eq(tableJobs.workspaceId, record.workspaceId), + eq(tableJobs.type, 'export'), + eq(tableJobs.status, 'running') + ) + ) + .returning({ id: tableJobs.id }) + if (canceled.length === 0) { + const current = await requireTableExport(record.id, record.workspaceId) + if (current.status === 'canceled') return current + throw new OrchestrationError( + 'conflict', + `Table export is ${publicExportStatus(current.status)}` + ) + } return requireTableExport(record.id, record.workspaceId) } +async function markExportFailed(params: { + tableId: string + workspaceId: string + exportId: string + error: string +}): Promise<void> { + const now = new Date() + await db + .update(tableJobs) + .set({ + status: 'failed', + error: params.error.slice(0, 2000), + completedAt: now, + updatedAt: now, + }) + .where( + and( + eq(tableJobs.id, params.exportId), + eq(tableJobs.tableId, params.tableId), + eq(tableJobs.workspaceId, params.workspaceId), + eq(tableJobs.type, 'export'), + eq(tableJobs.status, 'running') + ) + ) +} + export function toV2TableExport(record: TableExportRecord, queued = false): V2TableExport { const payload = record.payload as TableExportJobPayload | null if (!payload?.format) throw new Error(`Table export ${record.id} has no format`) diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts index 8cbb8cae36c..b61cab84a64 100644 --- a/apps/sim/lib/table/orchestration/import-resource.ts +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -1,5 +1,7 @@ +import type { Principal } from '@sim/auth/principal' import { db } from '@sim/db' import { tableJobs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' @@ -22,13 +24,14 @@ import { findActiveFolder } from '@/lib/folders/queries' import { getWorkspaceTableLimits } from '@/lib/table/billing' import { CSV_MAX_FILE_SIZE_BYTES, CSV_MAX_FILE_SIZE_MESSAGE } from '@/lib/table/import' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' -import { markJobCanceled, markJobFailed, markTableJobRunning } from '@/lib/table/jobs/service' +import { markTableJobRunningInWorkspace } from '@/lib/table/jobs/service' import { assertRowDelete, assertRowInsert } from '@/lib/table/mutation-locks' import { createTable, getTableById } from '@/lib/table/service' import type { TableImportJobPayload } from '@/lib/table/types' import { getWorkspaceFile, type WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { abortUploadSession, + assertUploadSessionAuthBinding, type CreatedUploadSession, createUploadSession, getOwnedUploadSession, @@ -37,9 +40,11 @@ import { import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -type TableImportStatus = 'uploading' | 'running' | 'ready' | 'failed' | 'canceled' +const logger = createLogger('TableImportResource') -interface TableImportResource { +type TableImportStatus = 'uploading' | 'running' | 'ready' | 'failed' | 'canceled' | 'expired' + +export interface TableImportResource { id: string workspaceId: string userId: string @@ -55,18 +60,24 @@ interface TableImportResource { completedAt: Date | null } -interface CreateTableImportResult { +export interface CreateTableImportResult { record: TableImportResource upload: CreatedUploadSession | null } -export async function createTableImportResource( - body: V2CreateTableImportBody, - userId: string, - localOrigin: string, +interface AuthorizedTableImportResourceParams { + body: V2CreateTableImportBody + userId: string + principal?: Principal + localOrigin?: string resolvedFolderId?: string | null + workspaceFile?: WorkspaceFileRecord +} + +async function createTableImportResourceCore( + params: AuthorizedTableImportResourceParams ): Promise<CreateTableImportResult> { - await assertWorkspaceWrite(userId, body.workspaceId) + const { body, userId, principal, localOrigin, resolvedFolderId, workspaceFile } = params await validateTarget(body.workspaceId, body.target, resolvedFolderId) const importId = generateId() const options = importOptions(body) @@ -80,6 +91,7 @@ export async function createTableImportResource( id: importId, workspaceId: body.workspaceId, userId, + ...(principal ? { principal } : {}), purpose: 'table_import', fileName: body.source.name, contentType: body.source.contentType, @@ -90,7 +102,7 @@ export async function createTableImportResource( return { record: resourceFromUpload(upload, body), upload } } - const file = await requireWorkspaceSource(body.workspaceId, body.source.fileId) + const file = await requireWorkspaceSource(body.workspaceId, body.source.fileId, workspaceFile) assertCsvFileName(file.name) return { record: await startTableImport({ @@ -110,15 +122,36 @@ export async function createTableImportResource( } } +export async function createAuthorizedTableImportResource( + params: AuthorizedTableImportResourceParams & { principal: Principal } +): Promise<CreateTableImportResult> { + return createTableImportResourceCore(params) +} + +/** Legacy internal resource entry point retained until internal JWTs carry signed workspace scope. */ +export async function createTableImportResource( + body: V2CreateTableImportBody, + userId: string, + localOrigin: string, + resolvedFolderId?: string | null +): Promise<CreateTableImportResult> { + await assertWorkspaceWrite(userId, body.workspaceId) + return createTableImportResourceCore({ + body, + userId, + localOrigin, + resolvedFolderId, + }) +} + export async function startUploadedTableImport( upload: UploadSessionRecord ): Promise<TableImportResource> { const body = tableImportBodyFromUpload(upload) const workspaceId = body.workspaceId - const existing = await findOwnedTableImport({ + const existing = await findTableImportResource({ importId: upload.id, - workspaceId, - userId: upload.userId, + assertedWorkspaceId: workspaceId, }) if (existing) return existing const storedFolderId = upload.metadata.tableImportFolderId @@ -145,6 +178,24 @@ export async function startUploadedTableImport( }) } +export async function getPrincipalTableImportUpload(params: { + importId: string + assertedWorkspaceId?: string + principal: Principal + uploadToken: string +}): Promise<UploadSessionRecord> { + const upload = await getOwnedUploadSession({ + uploadId: params.importId, + workspaceId: params.assertedWorkspaceId, + purpose: 'table_import', + uploadToken: params.uploadToken, + principal: params.principal, + }) + tableImportBodyFromUpload(upload) + return upload +} + +/** Legacy internal lookup retained until its bearer token can bind a full Principal. */ export async function getOwnedTableImportUpload(params: { importId: string workspaceId: string @@ -162,6 +213,16 @@ export async function getOwnedTableImportUpload(params: { return upload } +export async function abortAuthorizedTableImportUpload( + upload: UploadSessionRecord, + principal: Principal +): Promise<TableImportResource> { + assertUploadSessionAuthBinding(upload, principal) + const body = tableImportBodyFromUpload(upload) + return resourceFromUpload(await abortUploadSession(upload), body) +} + +/** Legacy internal cancellation retained until its bearer token can bind a full Principal. */ export async function abortTableImportUpload(params: { importId: string workspaceId: string @@ -173,20 +234,18 @@ export async function abortTableImportUpload(params: { return resourceFromUpload(await abortUploadSession(upload), body) } -export async function getOwnedTableImport(params: { +export async function getTableImportResource(params: { importId: string - workspaceId: string - userId: string + assertedWorkspaceId?: string }): Promise<TableImportResource> { - const record = await findOwnedTableImport(params) + const record = await findTableImportResource(params) if (!record) throw new OrchestrationError('not_found', 'Table import not found') return record } -export async function findOwnedTableImport(params: { +export async function findTableImportResource(params: { importId: string - workspaceId: string - userId: string + assertedWorkspaceId?: string }): Promise<TableImportResource | null> { const [job] = await db .select() @@ -194,14 +253,15 @@ export async function findOwnedTableImport(params: { .where( and( eq(tableJobs.id, params.importId), - eq(tableJobs.workspaceId, params.workspaceId), - eq(tableJobs.type, 'import') + eq(tableJobs.type, 'import'), + params.assertedWorkspaceId === undefined + ? undefined + : eq(tableJobs.workspaceId, params.assertedWorkspaceId) ) ) .limit(1) if (!job) return null const payload = parseImportJobPayload(job.payload) - if (payload.userId !== params.userId) return null return { id: job.id, workspaceId: job.workspaceId, @@ -219,6 +279,28 @@ export async function findOwnedTableImport(params: { } } +export async function getOwnedTableImport(params: { + importId: string + workspaceId: string + userId: string +}): Promise<TableImportResource> { + const record = await findOwnedTableImport(params) + if (!record) throw new OrchestrationError('not_found', 'Table import not found') + return record +} + +export async function findOwnedTableImport(params: { + importId: string + workspaceId: string + userId: string +}): Promise<TableImportResource | null> { + const record = await findTableImportResource({ + importId: params.importId, + assertedWorkspaceId: params.workspaceId, + }) + return record?.userId === params.userId ? record : null +} + export async function cancelTableImportResource( record: TableImportResource ): Promise<TableImportResource> { @@ -226,11 +308,34 @@ export async function cancelTableImportResource( if (record.status !== 'running' || !record.tableId) { throw new OrchestrationError('conflict', `Table import is ${publicImportStatus(record.status)}`) } - await markJobCanceled(record.tableId, record.id) - return getOwnedTableImport({ + const now = new Date() + const canceled = await db + .update(tableJobs) + .set({ status: 'canceled', completedAt: now, updatedAt: now }) + .where( + and( + eq(tableJobs.id, record.id), + eq(tableJobs.tableId, record.tableId), + eq(tableJobs.workspaceId, record.workspaceId), + eq(tableJobs.type, 'import'), + eq(tableJobs.status, 'running') + ) + ) + .returning({ id: tableJobs.id }) + if (canceled.length === 0) { + const current = await getTableImportResource({ + importId: record.id, + assertedWorkspaceId: record.workspaceId, + }) + if (current.status === 'canceled') return current + throw new OrchestrationError( + 'conflict', + `Table import is ${publicImportStatus(current.status)}` + ) + } + return getTableImportResource({ importId: record.id, - workspaceId: record.workspaceId, - userId: record.userId, + assertedWorkspaceId: record.workspaceId, }) } @@ -305,7 +410,15 @@ async function startTableImport(params: StartTableImportParams): Promise<TableIm } else { const table = await requireExistingTarget(params.workspaceId, params.target) tableId = table.id - if (!(await markTableJobRunning(tableId, params.id, 'import', jobPayload))) { + if ( + !(await markTableJobRunningInWorkspace( + tableId, + params.workspaceId, + params.id, + 'import', + jobPayload + )) + ) { throw new OrchestrationError('conflict', 'A job is already in progress for this table') } } @@ -339,17 +452,42 @@ async function startTableImport(params: StartTableImportParams): Promise<TableIm } else { runDetached('table-import', () => runTableImport(payload)) } - return getOwnedTableImport({ + return getTableImportResource({ importId: params.id, - workspaceId: params.workspaceId, - userId: params.userId, + assertedWorkspaceId: params.workspaceId, }) } catch (error) { const message = getErrorMessage(error, 'Failed to dispatch table import') - if (tableId) await markJobFailed(tableId, params.id, message).catch(() => {}) + if (tableId) { + try { + await markImportFailed({ + tableId, + workspaceId: params.workspaceId, + importId: params.id, + error: message, + }) + } catch (cleanupError) { + logger.error('Failed to mark table import dispatch failure', { + importId: params.id, + tableId, + workspaceId: params.workspaceId, + error: getErrorMessage(cleanupError, 'Unknown cleanup error'), + }) + } + } if (params.deleteSourceFile) { const { deleteFile } = await import('@/lib/uploads/core/storage-service') - await deleteFile({ key: params.fileKey, context: params.storageContext }).catch(() => {}) + try { + await deleteFile({ key: params.fileKey, context: params.storageContext }) + } catch (cleanupError) { + logger.error('Failed to delete table import source after dispatch failure', { + importId: params.id, + tableId, + workspaceId: params.workspaceId, + storageContext: params.storageContext, + error: getErrorMessage(cleanupError, 'Unknown cleanup error'), + }) + } } throw error } @@ -367,7 +505,7 @@ function resourceFromUpload( target: body.target, options: importOptions(body), tableId: body.target.type === 'existing' ? body.target.tableId : null, - status: upload.status === 'aborted' ? 'canceled' : 'uploading', + status: uploadStatus(upload), rowsProcessed: 0, error: null, createdAt: upload.createdAt, @@ -376,7 +514,7 @@ function resourceFromUpload( } } -function tableImportBodyFromUpload(upload: UploadSessionRecord): V2CreateTableImportBody { +export function tableImportBodyFromUpload(upload: UploadSessionRecord): V2CreateTableImportBody { if (upload.purpose !== 'table_import' || upload.storageContext !== 'table-import') { throw new OrchestrationError('conflict', 'Upload is not a table import') } @@ -445,14 +583,17 @@ async function requireExistingTarget( async function requireWorkspaceSource( workspaceId: string, - fileId: string + fileId: string, + file: WorkspaceFileRecord | undefined ): Promise<WorkspaceFileRecord> { - const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) - if (!file) throw new OrchestrationError('not_found', 'Workspace file not found') - if (file.size > CSV_MAX_FILE_SIZE_BYTES) { + const resolved = file ?? (await getWorkspaceFile(workspaceId, fileId, { throwOnError: true })) + if (!resolved || resolved.id !== fileId || resolved.workspaceId !== workspaceId) { + throw new OrchestrationError('not_found', 'Workspace file not found') + } + if (resolved.size > CSV_MAX_FILE_SIZE_BYTES) { throw new OrchestrationError('validation', CSV_MAX_FILE_SIZE_MESSAGE) } - return file + return resolved } async function assertWorkspaceWrite(userId: string, workspaceId: string): Promise<void> { @@ -462,6 +603,49 @@ async function assertWorkspaceWrite(userId: string, workspaceId: string): Promis } } +function uploadStatus(upload: UploadSessionRecord): TableImportStatus { + switch (upload.status) { + case 'uploading': + case 'completing': + case 'finalizing': + case 'completed': + return 'uploading' + case 'aborting': + case 'aborted': + return 'canceled' + case 'failed': + return 'failed' + case 'expired': + return 'expired' + } +} + +async function markImportFailed(params: { + tableId: string + workspaceId: string + importId: string + error: string +}): Promise<void> { + const now = new Date() + await db + .update(tableJobs) + .set({ + status: 'failed', + error: params.error.slice(0, 2000), + completedAt: now, + updatedAt: now, + }) + .where( + and( + eq(tableJobs.id, params.importId), + eq(tableJobs.tableId, params.tableId), + eq(tableJobs.workspaceId, params.workspaceId), + eq(tableJobs.type, 'import'), + eq(tableJobs.status, 'running') + ) + ) +} + function assertCsvFileName(fileName: string): void { const normalized = fileName.toLowerCase() if (!normalized.endsWith('.csv') && !normalized.endsWith('.tsv')) { diff --git a/apps/sim/lib/table/rows/executions.ts b/apps/sim/lib/table/rows/executions.ts index 21a56cf8333..c453b504b8e 100644 --- a/apps/sim/lib/table/rows/executions.ts +++ b/apps/sim/lib/table/rows/executions.ts @@ -5,7 +5,7 @@ * directly from `@/lib/table/rows/executions`. */ -import { tableRowExecutions } from '@sim/db/schema' +import { tableRowExecutions, userTableRows } from '@sim/db/schema' import { and, eq, inArray, type SQL, sql } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' import { getColumnId } from '@/lib/table/column-keys' @@ -353,13 +353,22 @@ export async function writeExecutionsPatch( export async function stripGroupExecutions( trx: DbOrTx, tableId: string, - groupIds: Iterable<string> + groupIds: Iterable<string>, + options?: { expectedWorkspaceId?: string } ): Promise<void> { const ids = Array.from(new Set(groupIds)) if (ids.length === 0) return - await trx - .delete(tableRowExecutions) - .where( - and(eq(tableRowExecutions.tableId, tableId), inArray(tableRowExecutions.groupId, ids)) as SQL - ) + await trx.delete(tableRowExecutions).where( + and( + eq(tableRowExecutions.tableId, tableId), + inArray(tableRowExecutions.groupId, ids), + options?.expectedWorkspaceId + ? sql`EXISTS ( + SELECT 1 FROM ${userTableRows} + WHERE ${userTableRows.id} = ${tableRowExecutions.rowId} + AND ${userTableRows.workspaceId} = ${options.expectedWorkspaceId} + )` + : undefined + ) as SQL + ) } diff --git a/apps/sim/lib/table/rows/secret-provenance.test.ts b/apps/sim/lib/table/rows/secret-provenance.test.ts index 20240b69f2a..2f7177233eb 100644 --- a/apps/sim/lib/table/rows/secret-provenance.test.ts +++ b/apps/sim/lib/table/rows/secret-provenance.test.ts @@ -464,14 +464,18 @@ describe('table row secret provenance', () => { it('binds derived rows only after their matching sidecars are written', async () => { queueTableRows(userTableRows, [{ id: 'legacy-row' }]) - await updateTableRowsWithDerivedSecretProvenance(dbChainMock.db as unknown as DbTransaction, { - rowWhere: eq(userTableRows.id, 'legacy-row'), - transformation: { - mode: 'remove-columns', - columnIds: ['deleted-column', 'deleted-column'], - }, - }) + const updatedCount = await updateTableRowsWithDerivedSecretProvenance( + dbChainMock.db as unknown as DbTransaction, + { + rowWhere: eq(userTableRows.id, 'legacy-row'), + transformation: { + mode: 'remove-columns', + columnIds: ['deleted-column', 'deleted-column'], + }, + } + ) + expect(updatedCount).toBe(1) expect(dbChainMockFns.execute).toHaveBeenCalledTimes(2) expect(boundArrayValues(dbChainMockFns.execute.mock.calls[0][0])).toEqual([]) expect(sqlText(dbChainMockFns.execute.mock.calls[0][0])).not.toMatch( diff --git a/apps/sim/lib/table/rows/secret-provenance.ts b/apps/sim/lib/table/rows/secret-provenance.ts index cb75cb6abec..82c2710b8c3 100644 --- a/apps/sim/lib/table/rows/secret-provenance.ts +++ b/apps/sim/lib/table/rows/secret-provenance.ts @@ -434,13 +434,13 @@ export async function updateTableRowsWithDerivedSecretProvenance( rowWhere: SQL transformation: DerivedTableRowTransformation } -): Promise<void> { +): Promise<number> { const removedColumnIds = options.transformation.mode === 'remove-columns' ? [...new Set(options.transformation.columnIds)] : [] if (options.transformation.mode === 'remove-columns') { - if (removedColumnIds.length === 0) return + if (removedColumnIds.length === 0) return 0 if ( removedColumnIds.length > MAX_PROVENANCE_COLUMNS_PER_ROW || removedColumnIds.some((columnId) => columnId.length === 0) @@ -481,6 +481,7 @@ export async function updateTableRowsWithDerivedSecretProvenance( : sql`source.provenance_entries` let afterId: string | undefined + let updatedCount = 0 for (;;) { const page = await trx .select({ id: userTableRows.id }) @@ -491,6 +492,7 @@ export async function updateTableRowsWithDerivedSecretProvenance( .for('update') if (page.length === 0) break const rowIds = page.map((row) => row.id) + updatedCount += rowIds.length await trx.execute(sql` WITH source AS MATERIALIZED ( @@ -628,6 +630,7 @@ export async function updateTableRowsWithDerivedSecretProvenance( afterId = rowIds[rowIds.length - 1] if (page.length < QUERY_CHUNK_SIZE) break } + return updatedCount } async function readTableRowsVersion(tableId: string, workspaceId: string): Promise<number | null> { diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index d0c89850f18..192099216ef 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -505,10 +505,23 @@ export async function replaceTableRowsWithTx( // the union of both row sets instead of only the last caller's rows. await acquireRowOrderLock(trx, data.tableId) - const deletedRows = await trx - .delete(userTableRows) - .where(eq(userTableRows.tableId, data.tableId)) - .returning({ id: userTableRows.id }) + const deleteCountRows = await trx.execute<{ count: number | string }>(sql` + WITH deleted AS ( + DELETE FROM ${userTableRows} + WHERE ${and( + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, data.workspaceId) + )} + RETURNING 1 + ) + SELECT count(*)::integer AS count FROM deleted + `) + const [deleteCountRow] = Array.isArray(deleteCountRows) ? deleteCountRows : [] + if (!deleteCountRow) throw new Error('Table row replacement did not return a deleted count') + const deletedCount = Number(deleteCountRow.count) + if (!Number.isSafeInteger(deletedCount) || deletedCount < 0) { + throw new Error('Table row replacement returned an invalid deleted count') + } let insertedCount = 0 if (data.rows.length > 0) { @@ -549,10 +562,10 @@ export async function replaceTableRowsWithTx( } logger.info( - `[${requestId}] Replaced rows in table ${data.tableId}: deleted ${deletedRows.length}, inserted ${insertedCount}` + `[${requestId}] Replaced rows in table ${data.tableId}: deleted ${deletedCount}, inserted ${insertedCount}` ) - return { deletedCount: deletedRows.length, insertedCount } + return { deletedCount, insertedCount } } /** @@ -730,7 +743,13 @@ export async function upsertRow( const [row] = await trx .update(userTableRows) .set({ data: data.data, updatedAt: now }) - .where(eq(userTableRows.id, matchedRowId)) + .where( + and( + eq(userTableRows.id, matchedRowId), + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, data.workspaceId) + ) + ) .returning() if (!row) return { value: undefined, affectedRowIds: [] } return { value: row, affectedRowIds: [row.id] } @@ -1449,6 +1468,33 @@ export async function getRowById( } } +/** + * Verifies an explicit row selection against the canonical table/workspace in + * bounded database chunks without materializing the complete row set. + */ +export async function requireTableRowIds( + tableId: string, + workspaceId: string, + rowIds: string[] +): Promise<void> { + for (let index = 0; index < rowIds.length; index += TABLE_LIMITS.DELETE_BATCH_SIZE) { + const chunk = rowIds.slice(index, index + TABLE_LIMITS.DELETE_BATCH_SIZE) + const [result] = await db + .select({ count: count() }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), + inArray(userTableRows.id, chunk) + ) + ) + if (!result || Number(result.count) !== chunk.length) { + throw new OrchestrationError('not_found', 'Row not found') + } + } +} + /** * Fetches the `data` payloads for a set of rows by id, scoped to a table and * workspace. Returns lightweight `{ id, data }` records (no executions) in the @@ -1535,6 +1581,9 @@ export async function updateRow( if (!existingRow) { throw new OrchestrationError('not_found', 'Row not found') } + if (Object.keys(data.data).length === 0 && data.executionsPatch === undefined) { + return existingRow + } // Merge partial update with existing row data so callers can pass only changed fields const mergedData = { @@ -1605,7 +1654,13 @@ export async function updateRow( const updatedRows = await trx .update(userTableRows) .set({ data: persistedData, updatedAt: now }) - .where(eq(userTableRows.id, data.rowId)) + .where( + and( + eq(userTableRows.id, data.rowId), + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, data.workspaceId) + ) + ) .returning({ id: userTableRows.id, updatedAt: userTableRows.updatedAt }) const [updatedRow] = updatedRows if (!updatedRow) throw new Error('Table row no longer exists') @@ -1748,6 +1803,9 @@ export async function updateRowsByFilter( requestId: string ): Promise<BulkOperationResult> { assertRowUpdate(table, patchColumnIds(data.data)) + if (Object.keys(data.data).length === 0) { + return { affectedCount: 0, affectedRowIds: [] } + } const tableName = USER_TABLE_ROWS_SQL_NAME @@ -1771,14 +1829,18 @@ export async function updateRowsByFilter( .select({ id: userTableRows.id, data: userTableRows.data }) .from(userTableRows) .where(and(baseConditions, filterClause)) - if (data.limit) { - return base - .orderBy(buildRowOrderBySql(undefined, tableName, table.schema.columns)) - .limit(data.limit) - } return base + .orderBy(buildRowOrderBySql(undefined, tableName, table.schema.columns)) + .limit(data.limit ?? TABLE_LIMITS.MAX_BULK_OPERATION_SIZE + 1) }) + if (matchingRows.length > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) { + throw new OrchestrationError( + 'validation', + `Cannot update more than ${TABLE_LIMITS.MAX_BULK_OPERATION_SIZE} rows per operation` + ) + } + if (matchingRows.length === 0) { return { affectedCount: 0, affectedRowIds: [] } } @@ -1811,7 +1873,7 @@ export async function updateRowsByFilter( } const uniqueColumns = getUniqueColumns(table.schema) - const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in data.data) + const uniqueColumnsInUpdate = uniqueColumns.filter((col) => getColumnId(col) in data.data) if (uniqueColumnsInUpdate.length > 0) { if (matchingRows.length > 1) { throw new OrchestrationError( @@ -1843,9 +1905,9 @@ export async function updateRowsByFilter( const ids = matchingRows.map((r) => r.id) const patchJson = JSON.stringify(data.data) - await db.transaction(async (trx) => { + const affectedRowIds = await db.transaction(async (trx) => { await setTableTxTimeouts(trx, { statementMs: 60_000 }) - await mutateTableRowsWithSecretProvenance(trx, { + return mutateTableRowsWithSecretProvenance(trx, { rows: ids.map((rowId) => ({ rowId, provenance: data.secretProvenance })), rowState: 'existing', mode: 'merge', @@ -1859,19 +1921,27 @@ export async function updateRowsByFilter( data: sql`${userTableRows.data} || ${patchJson}::jsonb`, updatedAt: now, }) - .where(inArray(userTableRows.id, batchIds)) + .where( + and( + eq(userTableRows.tableId, table.id), + eq(userTableRows.workspaceId, table.workspaceId), + inArray(userTableRows.id, batchIds) + ) + ) .returning({ id: userTableRows.id }) affectedRowIds.push(...updated.map((row) => row.id)) } - return { value: undefined, affectedRowIds } + return { value: affectedRowIds, affectedRowIds } }, }) }) - logger.info(`[${requestId}] Updated ${matchingRows.length} rows in table ${table.id}`) + logger.info(`[${requestId}] Updated ${affectedRowIds.length} rows in table ${table.id}`) - const oldRows = new Map(matchingRows.map((r) => [r.id, r.data as RowData])) - const updatedRows: TableRow[] = matchingRows.map((r) => ({ + const affectedRowIdSet = new Set(affectedRowIds) + const affectedRows = matchingRows.filter((row) => affectedRowIdSet.has(row.id)) + const oldRows = new Map(affectedRows.map((r) => [r.id, r.data as RowData])) + const updatedRows: TableRow[] = affectedRows.map((r) => ({ id: r.id, data: { ...(r.data as RowData), ...data.data }, executions: {}, @@ -1879,28 +1949,32 @@ export async function updateRowsByFilter( createdAt: now, updatedAt: now, })) - void fireTableTrigger( - table.id, - table.name, - 'update', - updatedRows, - oldRows, - table.schema, - requestId - ) - void runWorkflowColumn({ - tableId: table.id, - workspaceId: table.workspaceId, - rowIds: updatedRows.map((r) => r.id), - mode: 'new', - isManualRun: false, - requestId, - triggeredByUserId: data.actorUserId, - }).catch((err) => logger.error(`[${requestId}] auto-dispatch (updateRowsByFilter) failed:`, err)) + if (updatedRows.length > 0) { + void fireTableTrigger( + table.id, + table.name, + 'update', + updatedRows, + oldRows, + table.schema, + requestId + ) + void runWorkflowColumn({ + tableId: table.id, + workspaceId: table.workspaceId, + rowIds: updatedRows.map((r) => r.id), + mode: 'new', + isManualRun: false, + requestId, + triggeredByUserId: data.actorUserId, + }).catch((err) => + logger.error(`[${requestId}] auto-dispatch (updateRowsByFilter) failed:`, err) + ) + } return { - affectedCount: matchingRows.length, - affectedRowIds: ids, + affectedCount: affectedRowIds.length, + affectedRowIds, } } @@ -2038,9 +2112,9 @@ export async function batchUpdateRows( const now = new Date() - await db.transaction(async (trx) => { + const affectedRowIds = await db.transaction(async (trx) => { await setTableTxTimeouts(trx, { statementMs: 60_000 }) - await mutateTableRowsWithSecretProvenance(trx, { + return mutateTableRowsWithSecretProvenance(trx, { rows: mergedUpdates.map((update) => ({ rowId: update.rowId, provenance: data.secretProvenanceByRowId?.[update.rowId], @@ -2055,7 +2129,13 @@ export async function batchUpdateRows( trx .update(userTableRows) .set({ data: jsonbMergePatch(changedColumnIds, mergedData), updatedAt: now }) - .where(eq(userTableRows.id, rowId)) + .where( + and( + eq(userTableRows.id, rowId), + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, data.workspaceId) + ) + ) .returning({ id: userTableRows.id }) ) const updatedRows = await Promise.all(dataPromises) @@ -2064,42 +2144,47 @@ export async function batchUpdateRows( await writeExecutionsPatch(trx, data.tableId, rowId, executionsPatch) } } - return { value: undefined, affectedRowIds } + return { value: affectedRowIds, affectedRowIds } }, }) }) - logger.info(`[${requestId}] Batch updated ${mergedUpdates.length} rows in table ${data.tableId}`) + logger.info(`[${requestId}] Batch updated ${affectedRowIds.length} rows in table ${data.tableId}`) + const affectedRowIdSet = new Set(affectedRowIds) const oldRowsForTrigger = new Map( - data.updates.map((u) => [u.rowId, existingMap.get(u.rowId)!.data]) + data.updates + .filter((update) => affectedRowIdSet.has(update.rowId)) + .map((update) => [update.rowId, existingMap.get(update.rowId)!.data]) ) - const updatedRowsForTrigger: TableRow[] = mergedUpdates.map( - ({ rowId, mergedData, mergedExecutions }) => ({ + const updatedRowsForTrigger: TableRow[] = mergedUpdates + .filter((update) => affectedRowIdSet.has(update.rowId)) + .map(({ rowId, mergedData, mergedExecutions }) => ({ id: rowId, data: mergedData, executions: mergedExecutions, position: 0, createdAt: now, updatedAt: now, - }) - ) - void fireTableTrigger( - data.tableId, - table.name, - 'update', - updatedRowsForTrigger, - oldRowsForTrigger, - table.schema, - requestId - ) + })) + if (updatedRowsForTrigger.length > 0) { + void fireTableTrigger( + data.tableId, + table.name, + 'update', + updatedRowsForTrigger, + oldRowsForTrigger, + table.schema, + requestId + ) + } // Per-row cancel+rerun for in-flight downstream groups whose deps just // changed — same orchestration as single-row `updateRow`. Without this, // batch updates would leave running workflows reading stale dep values. // Each row needs its own cancel + manual-incomplete dispatch because // `cancelWorkflowGroupRuns`'s `groupIds` filter is per-row. const rowsWithInFlightDownstream = mergedUpdates.filter( - (u) => u.inFlightDownstreamGroups.length > 0 + (update) => affectedRowIdSet.has(update.rowId) && update.inFlightDownstreamGroups.length > 0 ) if (rowsWithInFlightDownstream.length > 0) { void (async () => { @@ -2127,19 +2212,21 @@ export async function batchUpdateRows( } })() } - void runWorkflowColumn({ - tableId: table.id, - workspaceId: table.workspaceId, - rowIds: updatedRowsForTrigger.map((r) => r.id), - mode: 'new', - isManualRun: false, - requestId, - triggeredByUserId: data.actorUserId, - }).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchUpdateRows) failed:`, err)) + if (updatedRowsForTrigger.length > 0) { + void runWorkflowColumn({ + tableId: table.id, + workspaceId: table.workspaceId, + rowIds: updatedRowsForTrigger.map((r) => r.id), + mode: 'new', + isManualRun: false, + requestId, + triggeredByUserId: data.actorUserId, + }).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchUpdateRows) failed:`, err)) + } return { - affectedCount: mergedUpdates.length, - affectedRowIds: mergedUpdates.map((u) => u.rowId), + affectedCount: affectedRowIds.length, + affectedRowIds, } } @@ -2180,32 +2267,37 @@ export async function deleteRowsByFilter( .select({ id: userTableRows.id, position: userTableRows.position }) .from(userTableRows) .where(and(baseConditions, filterClause)) - if (data.limit) { - return base - .orderBy(buildRowOrderBySql(undefined, tableName, table.schema.columns)) - .limit(data.limit) - } return base + .orderBy(buildRowOrderBySql(undefined, tableName, table.schema.columns)) + .limit(data.limit ?? TABLE_LIMITS.MAX_BULK_OPERATION_SIZE + 1) }) + if (matchingRows.length > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) { + throw new OrchestrationError( + 'validation', + `Cannot delete more than ${TABLE_LIMITS.MAX_BULK_OPERATION_SIZE} rows per operation` + ) + } + if (matchingRows.length === 0) { return { affectedCount: 0, affectedRowIds: [] } } const rowIds = matchingRows.map((r) => r.id) - await deleteOrderedRowsByIds({ + const deletedRows = await deleteOrderedRowsByIds({ tableId: table.id, workspaceId: table.workspaceId, rowIds, proof, }) + const deletedRowIds = deletedRows.map((row) => row.id) - logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${table.id}`) + logger.info(`[${requestId}] Deleted ${deletedRowIds.length} rows from table ${table.id}`) return { - affectedCount: matchingRows.length, - affectedRowIds: rowIds, + affectedCount: deletedRowIds.length, + affectedRowIds: deletedRowIds, } } diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 5b98c0fda17..a596ab1244b 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -117,7 +117,7 @@ function readLocks(row: { export async function withLockedTable<T>( tableId: string, mutate: (table: TableDefinition, trx: DbTransaction) => Promise<T>, - opts?: { includeArchived?: boolean } + opts?: { includeArchived?: boolean; expectedWorkspaceId?: string } ): Promise<T> { return db.transaction(async (trx) => { await setTableTxTimeouts(trx) @@ -125,7 +125,7 @@ export async function withLockedTable<T>( sql`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_schema:${tableId}`}, 0))` ) const table = await getTableById(tableId, { tx: trx, includeArchived: opts?.includeArchived }) - if (!table) { + if (!table || (opts?.expectedWorkspaceId && table.workspaceId !== opts.expectedWorkspaceId)) { throw new OrchestrationError('not_found', 'Table not found') } return mutate(table, trx) @@ -729,7 +729,12 @@ export async function addTableColumnsWithTx( await trx .update(userTableDefinitions) .set({ schema: updatedSchema, updatedAt: now }) - .where(eq(userTableDefinitions.id, table.id)) + .where( + and( + eq(userTableDefinitions.id, table.id), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) logger.info( `[${requestId}] Added ${additions.length} column(s) to table ${table.id}: ${additions.map((c) => c.name).join(', ')}` @@ -779,7 +784,8 @@ export function auditTableColumnsAdded( export async function renameTable( tableId: string, newName: string, - requestId: string + requestId: string, + options?: { expectedWorkspaceId?: string } ): Promise<{ id: string; name: string }> { const nameValidation = validateTableName(newName) if (!nameValidation.valid) { @@ -791,7 +797,15 @@ export async function renameTable( const result = await db .update(userTableDefinitions) .set({ name: newName, updatedAt: now }) - .where(eq(userTableDefinitions.id, tableId)) + .where( + and( + eq(userTableDefinitions.id, tableId), + options?.expectedWorkspaceId + ? eq(userTableDefinitions.workspaceId, options.expectedWorkspaceId) + : undefined, + isNull(userTableDefinitions.archivedAt) + ) + ) // `workspaceId` is selected for the live-list notify below, not for an audit — // the audit moved up to `performRenameTable`. .returning({ id: userTableDefinitions.id, workspaceId: userTableDefinitions.workspaceId }) @@ -1014,7 +1028,7 @@ export async function updateTableMetadata( export async function deleteTable( tableId: string, requestId: string, - options?: { archivedAt?: Date; skipNotify?: boolean } + options?: { archivedAt?: Date; skipNotify?: boolean; expectedWorkspaceId?: string } ): Promise<{ archived: { name: string; workspaceId: string | null } | null }> { const now = options?.archivedAt ?? new Date() // Archiving destroys access to every row, so it is gated on the delete lock. @@ -1026,6 +1040,9 @@ export async function deleteTable( .where( and( eq(userTableDefinitions.id, tableId), + options?.expectedWorkspaceId + ? eq(userTableDefinitions.workspaceId, options.expectedWorkspaceId) + : undefined, isNull(userTableDefinitions.archivedAt), eq(userTableDefinitions.deleteLocked, false) ) @@ -1045,7 +1062,14 @@ export async function deleteTable( workspaceId: userTableDefinitions.workspaceId, }) .from(userTableDefinitions) - .where(eq(userTableDefinitions.id, tableId)) + .where( + and( + eq(userTableDefinitions.id, tableId), + options?.expectedWorkspaceId + ? eq(userTableDefinitions.workspaceId, options.expectedWorkspaceId) + : undefined + ) + ) .limit(1) if (existing && !existing.archivedAt && existing.deleteLocked) { logger.warn('Table mutation blocked by lock', { diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index cc67764b07e..6d95f1c9f54 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -905,12 +905,16 @@ export interface DeleteColumnData { /** Payload for `addWorkflowGroup` — atomic insert of a group + its outputs. */ export interface AddWorkflowGroupData { tableId: string + /** Canonical workspace derived from the table by an authorized caller. */ + workspaceId?: string group: WorkflowGroup outputColumns: ColumnDefinition[] /** When `false`, the post-add row-scheduling pass is skipped. Defaults to * `true` (UI behavior). Mothership passes `false` so groups can be staged * without firing every dep-satisfied row. */ autoRun?: boolean + /** Persist auto-run state without dispatching through the primitive. */ + suppressAutoRunDispatch?: boolean /** The member adding the group — billed/gated for the auto-run enrichment pass. */ actorUserId?: string | null } @@ -918,6 +922,8 @@ export interface AddWorkflowGroupData { /** Payload for `updateWorkflowGroup` — diffs outputs and writes columns. */ export interface UpdateWorkflowGroupData { tableId: string + /** Canonical workspace derived from the table by an authorized caller. */ + workspaceId?: string groupId: string workflowId?: string name?: string @@ -941,11 +947,15 @@ export interface UpdateWorkflowGroupData { type?: WorkflowGroupType /** Toggle the group's auto-run flag. Omit to leave it unchanged. */ autoRun?: boolean + /** Skip primitive dispatch when an authorized caller will start the run itself. */ + suppressAutoRunDispatch?: boolean /** The member updating the group — billed/gated for any triggered re-run. */ actorUserId?: string | null } export interface DeleteWorkflowGroupData { tableId: string + /** Canonical workspace derived from the table by an authorized caller. */ + workspaceId?: string groupId: string } diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index 24dde87c3fa..29e88f403bd 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -166,6 +166,24 @@ describe('table-view mutations signal collaborators', () => { expect(mockSignalTableViewsChanged).not.toHaveBeenCalled() }) + it('updateTableView returns the canonical view without writing or signaling on a true no-op', async () => { + queueTableRows(tableViews, [viewRow]) + + const result = await updateTableView({ + viewId: 'view-1', + tableId: 'table-1', + workspaceId: 'ws-1', + name: viewRow.name, + config: viewRow.config, + isDefault: viewRow.isDefault, + columns, + }) + + expect(result).toMatchObject({ id: 'view-1', name: 'My View', isDefault: false }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockSignalTableViewsChanged).not.toHaveBeenCalled() + }) + it('deleteTableView signals when a row was actually deleted', async () => { dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'view-1' }]) diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index bd3fb64963e..de0c8c24118 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -140,12 +140,18 @@ function toTableView(row: typeof tableViews.$inferSelect, columns: ColumnDefinit /** Every view on a table, oldest first, with stale column references pruned. */ export async function listTableViews( tableId: string, - columns: ColumnDefinition[] + columns: ColumnDefinition[], + workspaceId?: string ): Promise<TableView[]> { const rows = await db .select() .from(tableViews) - .where(eq(tableViews.tableId, tableId)) + .where( + and( + eq(tableViews.tableId, tableId), + workspaceId ? eq(tableViews.workspaceId, workspaceId) : undefined + ) + ) .orderBy(asc(tableViews.createdAt), asc(tableViews.id)) return rows.map((row) => toTableView(row, columns)) @@ -155,12 +161,19 @@ export async function listTableViews( export async function getTableView( viewId: string, tableId: string, - columns: ColumnDefinition[] + columns: ColumnDefinition[], + workspaceId?: string ): Promise<TableView | null> { const [row] = await db .select() .from(tableViews) - .where(and(eq(tableViews.id, viewId), eq(tableViews.tableId, tableId))) + .where( + and( + eq(tableViews.id, viewId), + eq(tableViews.tableId, tableId), + workspaceId ? eq(tableViews.workspaceId, workspaceId) : undefined + ) + ) .limit(1) return row ? toTableView(row, columns) : null @@ -205,6 +218,7 @@ export async function createTableView(data: CreateTableViewData): Promise<TableV export interface UpdateTableViewData { viewId: string tableId: string + workspaceId?: string name?: string /** Full replace — an explicit Save, where removing a filter must persist. */ config?: TableViewConfig @@ -232,18 +246,35 @@ export async function updateTableView(data: UpdateTableViewData): Promise<TableV } if (data.isDefault !== undefined) patch.isDefault = data.isDefault - const row = await db.transaction(async (tx) => { + 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 // missing view the target update matches nothing, so without this the demote // would still commit and silently clear the table's real default. const [existing] = await tx - .select({ id: tableViews.id }) + .select() .from(tableViews) - .where(and(eq(tableViews.id, data.viewId), eq(tableViews.tableId, data.tableId))) + .where( + and( + eq(tableViews.id, data.viewId), + eq(tableViews.tableId, data.tableId), + data.workspaceId ? eq(tableViews.workspaceId, data.workspaceId) : undefined + ) + ) .limit(1) if (!existing) return null + const nextName = data.name === undefined ? existing.name : normalizeName(data.name) + const storedConfig = (existing.config ?? {}) as TableViewConfig + const nextConfig = + data.config ?? (data.configPatch ? { ...storedConfig, ...data.configPatch } : storedConfig) + const nextIsDefault = data.isDefault ?? existing.isDefault + const changed = + nextName !== existing.name || + JSON.stringify(nextConfig) !== JSON.stringify(storedConfig) || + nextIsDefault !== existing.isDefault + if (!changed) return { row: existing, changed: false as const } + if (data.isDefault === true) { await tx .update(tableViews) @@ -251,6 +282,7 @@ export async function updateTableView(data: UpdateTableViewData): Promise<TableV .where( and( eq(tableViews.tableId, data.tableId), + data.workspaceId ? eq(tableViews.workspaceId, data.workspaceId) : undefined, eq(tableViews.isDefault, true), ne(tableViews.id, data.viewId) ) @@ -260,26 +292,41 @@ export async function updateTableView(data: UpdateTableViewData): Promise<TableV const [updated] = await tx .update(tableViews) .set(patch) - .where(and(eq(tableViews.id, data.viewId), eq(tableViews.tableId, data.tableId))) + .where( + and( + eq(tableViews.id, data.viewId), + eq(tableViews.tableId, data.tableId), + data.workspaceId ? eq(tableViews.workspaceId, data.workspaceId) : undefined + ) + ) .returning() - return updated + return updated ? { row: updated, changed: true as const } : null }) // `null`, not a validation error: an absent view is a missing resource, and the // route maps it to 404 the same way `deleteTableView`'s `false` does. - if (!row) return null + if (!outcome) return null - // Only signal a real update — a no-op PATCH on a missing view (row === null) changed nothing. - signalTableViewsChanged(data.tableId) - return toTableView(row, data.columns) + if (outcome.changed) signalTableViewsChanged(data.tableId) + return toTableView(outcome.row, data.columns) } /** Deleting the default simply leaves the table on "All". */ -export async function deleteTableView(viewId: string, tableId: string): Promise<boolean> { +export async function deleteTableView( + viewId: string, + tableId: string, + workspaceId?: string +): Promise<boolean> { const deleted = await db .delete(tableViews) - .where(and(eq(tableViews.id, viewId), eq(tableViews.tableId, tableId))) + .where( + and( + eq(tableViews.id, viewId), + eq(tableViews.tableId, tableId), + workspaceId ? eq(tableViews.workspaceId, workspaceId) : undefined + ) + ) .returning({ id: tableViews.id }) if (deleted.length > 0) { diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index 0f573f96f7a..ef6aade8368 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -490,10 +490,7 @@ export async function cancelWorkflowGroupRuns( ) const table = await getTableById(tableId) - if (!table) { - logger.warn(`cancelWorkflowGroupRuns: table ${tableId} not found`) - return 0 - } + if (!table) throw new OrchestrationError('not_found', 'Table not found') // Per-row cancel leaves the dispatcher alone — other rows in the same // dispatch keep running. Table-wide cancel must stop it, else the cursor @@ -581,7 +578,13 @@ export async function cancelWorkflowGroupRuns( db .select({ id: userTableRowsTable.id }) .from(userTableRowsTable) - .where(and(eq(userTableRowsTable.tableId, tableId), filterClause)) + .where( + and( + eq(userTableRowsTable.tableId, tableId), + eq(userTableRowsTable.workspaceId, table.workspaceId), + filterClause + ) + ) ) ) } @@ -599,6 +602,7 @@ export async function cancelWorkflowGroupRuns( : Promise.resolve() let cursor: { rowId: string; groupId: string } | undefined let processedCount = 0 + let cancelledCount = 0 let reachedEnd = false const handledGroupIds = new Set<string>() @@ -711,7 +715,7 @@ export async function cancelWorkflowGroupRuns( ) await mapWithConcurrency(mutations, TABLE_CANCELLATION_CONCURRENCY, async (mutation) => { - await updateRow( + const updated = await updateRow( { tableId, rowId: mutation.rowId, @@ -721,12 +725,10 @@ export async function cancelWorkflowGroupRuns( }, table, `wfgrp-cancel-${mutation.rowId}` - ).catch((error) => { - logger.error(`Failed to write cancelled state for row ${mutation.rowId}`, { - error: toError(error).message, - }) - }) + ) + if (!updated) throw new Error('Authoritative cancellation write was rejected') }) + cancelledCount += mutations.reduce((total, mutation) => total + mutation.cancelledCount, 0) if (inFlightRows.length < pageSize) { reachedEnd = true @@ -740,17 +742,28 @@ export async function cancelWorkflowGroupRuns( tableId, maxRows: TABLE_CANCELLATION_MAX_ROWS, }) - await db - .update(tableRowExecutions) - .set({ - status: 'cancelled', - jobId: null, - error: 'Cancelled', - runningBlockIds: [], - cancelledAt: now, - updatedAt: now, - }) - .where(and(...inFlightFilters)) + const rows = await db.execute<{ count: number | string }>(sql` + WITH cancelled AS ( + UPDATE ${tableRowExecutions} + SET + status = 'cancelled', + job_id = NULL, + error = 'Cancelled', + running_block_ids = ARRAY[]::text[], + cancelled_at = ${sql.param(now, tableRowExecutions.cancelledAt)}, + updated_at = ${sql.param(now, tableRowExecutions.updatedAt)} + WHERE ${and(...inFlightFilters)} + RETURNING 1 + ) + SELECT count(*)::integer AS count FROM cancelled + `) + const [countRow] = Array.isArray(rows) ? rows : [] + if (!countRow) throw new Error('Cancellation update did not return an affected count') + const remainingCancelled = Number(countRow.count) + if (!Number.isSafeInteger(remainingCancelled) || remainingCancelled < 0) { + throw new Error('Cancellation update returned an invalid affected count') + } + cancelledCount += remainingCancelled } await tagSweepPromise @@ -787,18 +800,12 @@ export async function cancelWorkflowGroupRuns( .onConflictDoNothing({ target: [tableRowExecutions.rowId, tableRowExecutions.groupId], }) - .catch((error) => { - logger.error( - `Failed to write tombstone for ${tableId}/${rowId}/${tombstone.groupId}`, - { error: toError(error).message } - ) - }) } ) } } - return processedCount + return cancelledCount } /** @@ -832,7 +839,7 @@ export async function runWorkflowColumn(opts: { * callers (row writes, CSV import) → falls back to the workspace billed * account at billing time. */ triggeredByUserId?: string | null -}): Promise<{ dispatchId: string | null }> { +}): Promise<{ dispatchId: string | null; shouldSignalRowsChanged: boolean }> { const { tableId, workspaceId, @@ -849,7 +856,9 @@ export async function runWorkflowColumn(opts: { // Empty `rowIds` array means "scope explicitly empty" — auto-fire callers // (CSV import on zero matches, etc.) end up here. Skip the dispatch entirely // rather than walk the table with a no-match filter. - if (rowIds && rowIds.length === 0) return { dispatchId: null } + if (rowIds && rowIds.length === 0) { + return { dispatchId: null, shouldSignalRowsChanged: false } + } // Lazy imports: `./service` and `./dispatcher` both close cycles back to // this module; `@trigger.dev/sdk` is heavy and only needed on this op. const { getTableById } = await import('@/lib/table/service') @@ -864,8 +873,11 @@ export async function runWorkflowColumn(opts: { // every row write would otherwise produce error-level log spam on every // PATCH/insert. Manual run-column callers always pass `groupIds` so they // can't reach here with an empty target. - if (targetGroups.length === 0) return { dispatchId: null } + if (targetGroups.length === 0) { + return { dispatchId: null, shouldSignalRowsChanged: false } + } const targetGroupIds = targetGroups.map((g) => g.id) + let shouldSignalRowsChanged = false const { bulkClearWorkflowGroupCells, @@ -928,18 +940,22 @@ export async function runWorkflowColumn(opts: { if (!rowIds || rowIds.length === 0) { // Filtered runs cancel only their own scope — a table-wide cancel here // would stop unrelated work on rows outside the filter (or on deselected rows). - await cancelWorkflowGroupRuns(tableId, undefined, { + const cancelled = await cancelWorkflowGroupRuns(tableId, undefined, { groupIds: targetGroupIds, filter, excludeRowIds, spareDispatchId: dispatchId, }) + shouldSignalRowsChanged ||= cancelled > 0 } else { // Per-row cancel — sequential so we don't fan out N parallel // markActiveDispatchesCancelled calls (it's a no-op when rowId is set, // but each call still touches the DB). for (const rowId of rowIds) { - await cancelWorkflowGroupRuns(tableId, rowId, { groupIds: targetGroupIds }) + const cancelled = await cancelWorkflowGroupRuns(tableId, rowId, { + groupIds: targetGroupIds, + }) + shouldSignalRowsChanged ||= cancelled > 0 } } } @@ -955,13 +971,15 @@ export async function runWorkflowColumn(opts: { // filtered scope has none — clearing table-wide would blank rows that don't match the filter. The // dispatcher's per-row pre-stamp still provides instant Pending feedback as it walks. if (!limit && !filter) { - await bulkClearWorkflowGroupCells({ + const clearedRows = await bulkClearWorkflowGroupCells({ tableId, + workspaceId, groups: targetGroups.map((g) => ({ id: g.id, outputs: g.outputs })), rowIds, excludeRowIds, mode, }) + shouldSignalRowsChanged ||= clearedRows } } catch (err) { // Prep failed after the dispatch row was inserted — cancel it so an @@ -991,7 +1009,7 @@ export async function runWorkflowColumn(opts: { logger.info( `[Cascade] [${requestId}] dispatch ${dispatchId} cancelled during prep — not firing` ) - return { dispatchId: null } + return { dispatchId: null, shouldSignalRowsChanged } } logger.info( @@ -1023,7 +1041,7 @@ export async function runWorkflowColumn(opts: { ) } - return { dispatchId } + return { dispatchId, shouldSignalRowsChanged: true } } // ───────────────────────────── Validation ───────────────────────────── @@ -1316,6 +1334,6 @@ export function findSplitGroups( export function assertValidSchema(schema: TableSchema, columnOrder: string[] | undefined): void { const errs = validateSchema(schema, columnOrder) if (errs.length > 0) { - throw new Error(`Schema validation failed: ${errs.join('; ')}`) + throw new OrchestrationError('validation', `Schema validation failed: ${errs.join('; ')}`) } } diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index f15421c4c66..394a16922f3 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -11,6 +11,7 @@ import { db } from '@sim/db' import { userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' import { columnMatchesRef, @@ -111,7 +112,9 @@ export async function pruneStaleWorkflowGroupOutputs({ schema: { ...schema, workflowGroups: nextGroups }, updatedAt: new Date(), }) - .where(eq(userTableDefinitions.id, t.id)) + .where( + and(eq(userTableDefinitions.id, t.id), eq(userTableDefinitions.workspaceId, workspaceId)) + ) logger.info(`[${requestId}] Pruned stale workflow=${workflowId} block refs from table ${t.id}`) } @@ -126,86 +129,100 @@ export async function addWorkflowGroup( data: AddWorkflowGroupData, requestId: string ): Promise<TableDefinition> { - const updatedTable = await withLockedTable(data.tableId, async (table, trx) => { - assertSchemaMutable(table) - const schema = table.schema - const groups = schema.workflowGroups ?? [] - if (groups.some((g) => g.id === data.group.id)) { - throw new Error(`Workflow group "${data.group.id}" already exists`) - } - - const existingNames = new Set(schema.columns.map((c) => c.name.toLowerCase())) - for (const col of data.outputColumns) { - if (!NAME_PATTERN.test(col.name)) { - throw new Error( - `Invalid output column name "${col.name}". Must satisfy ${NAME_PATTERN.source}.` + const updatedTable = await withLockedTable( + data.tableId, + async (table, trx) => { + assertSchemaMutable(table) + const schema = table.schema + const groups = schema.workflowGroups ?? [] + if (groups.some((g) => g.id === data.group.id)) { + throw new OrchestrationError( + 'validation', + `Workflow group "${data.group.id}" already exists` ) } - if (existingNames.has(col.name.toLowerCase())) { - throw new Error(`Column "${col.name}" already exists`) + + const existingNames = new Set(schema.columns.map((c) => c.name.toLowerCase())) + for (const col of data.outputColumns) { + if (!NAME_PATTERN.test(col.name)) { + throw new OrchestrationError( + 'validation', + `Invalid output column name "${col.name}". Must satisfy ${NAME_PATTERN.source}.` + ) + } + if (existingNames.has(col.name.toLowerCase())) { + throw new OrchestrationError('validation', `Column "${col.name}" already exists`) + } + } + + if (schema.columns.length + data.outputColumns.length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { + throw new OrchestrationError( + 'validation', + `Adding ${data.outputColumns.length} columns would exceed the maximum (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE}).` + ) } - } - if (schema.columns.length + data.outputColumns.length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { - throw new Error( - `Adding ${data.outputColumns.length} columns would exceed the maximum (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE}).` + // Assign stable ids to the new output columns, then rewrite the group's + // column refs from name → id so outputs/deps/inputMappings key on ids — + // matching the row-data storage key and surviving future renames. + const outputColumns = data.outputColumns.map((col) => + col.id ? col : { ...col, id: generateColumnId() } ) - } + const updatedColumns = [...schema.columns, ...outputColumns] + const idByName = new Map(updatedColumns.map((c) => [c.name, getColumnId(c)])) + const group = remapGroupColumnRefs(data.group, idByName) - // Assign stable ids to the new output columns, then rewrite the group's - // column refs from name → id so outputs/deps/inputMappings key on ids — - // matching the row-data storage key and surviving future renames. - const outputColumns = data.outputColumns.map((col) => - col.id ? col : { ...col, id: generateColumnId() } - ) - const updatedColumns = [...schema.columns, ...outputColumns] - const idByName = new Map(updatedColumns.map((c) => [c.name, getColumnId(c)])) - const group = remapGroupColumnRefs(data.group, idByName) - - const updatedSchema: TableSchema = { - ...schema, - columns: updatedColumns, - workflowGroups: [...groups, group], - } + const updatedSchema: TableSchema = { + ...schema, + columns: updatedColumns, + workflowGroups: [...groups, group], + } - // Keep `metadata.columnOrder` (column ids) in sync — see `addTableColumn`. - // New output columns get appended in the order the caller supplied. - const existingOrder = table.metadata?.columnOrder - let updatedMetadata = table.metadata - if (existingOrder && existingOrder.length > 0) { - const known = new Set(existingOrder) - const append = outputColumns.map(getColumnId).filter((id) => !known.has(id)) - if (append.length > 0) { - updatedMetadata = { ...table.metadata, columnOrder: [...existingOrder, ...append] } + // Keep `metadata.columnOrder` (column ids) in sync — see `addTableColumn`. + // New output columns get appended in the order the caller supplied. + const existingOrder = table.metadata?.columnOrder + let updatedMetadata = table.metadata + if (existingOrder && existingOrder.length > 0) { + const known = new Set(existingOrder) + const append = outputColumns.map(getColumnId).filter((id) => !known.has(id)) + if (append.length > 0) { + updatedMetadata = { ...table.metadata, columnOrder: [...existingOrder, ...append] } + } } - } - assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) + assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) - const now = new Date() - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) + const now = new Date() + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) - logger.info( - `[${requestId}] Added workflow group "${data.group.id}" with ${data.outputColumns.length} output column(s) to table ${data.tableId}` - ) + logger.info( + `[${requestId}] Added workflow group "${data.group.id}" with ${data.outputColumns.length} output column(s) to table ${data.tableId}` + ) - return { - ...table, - schema: updatedSchema, - metadata: updatedMetadata, - updatedAt: now, - } - }) + return { + ...table, + schema: updatedSchema, + metadata: updatedMetadata, + updatedAt: now, + } + }, + { expectedWorkspaceId: data.workspaceId } + ) // Auto-fire existing rows whose deps are already met for the new group. // Fire-and-forget — the dispatcher bounds queue depth (window of 20) and // walks the table in the background. HTTP returns instantly; cells fill // in over the next minutes as the dispatcher walks. Mothership opts out // by setting `autoRun: false`. - if (data.autoRun !== false) { + if (data.autoRun !== false && data.suppressAutoRunDispatch !== true) { void runWorkflowColumn({ tableId: updatedTable.id, workspaceId: updatedTable.workspaceId, @@ -244,8 +261,11 @@ export async function updateWorkflowGroup( // the lock — a concurrent `workflowId` change would make them stale. let resolvedForWorkflowId: string | undefined if (mappingUpdates.length > 0) { + const preTable = await getTableById(data.tableId) + if (!preTable || (data.workspaceId && preTable.workspaceId !== data.workspaceId)) { + throw new OrchestrationError('not_found', 'Table not found') + } try { - const preTable = await getTableById(data.tableId) const preGroup = preTable?.schema.workflowGroups?.find((g) => g.id === data.groupId) const targetWorkflowId = data.workflowId ?? preGroup?.workflowId if (targetWorkflowId) { @@ -287,251 +307,269 @@ export async function updateWorkflowGroup( } const { updatedTable, added, remappedColumnIds, newOutputs, previousAutoRun } = - await withLockedTable(data.tableId, async (table, trx) => { - // Any group patch edits the schema; the stronger destructive assert is - // applied below, only once we know this patch actually drops or remaps - // output columns (a rename / autoRun / mapping-only edit must not need - // the delete lock clear). - assertSchemaMutable(table) - await setTableTxTimeouts(trx, { statementMs: 60_000 }) - - const schema = table.schema - const groups = schema.workflowGroups ?? [] - const groupIndex = groups.findIndex((g) => g.id === data.groupId) - if (groupIndex === -1) { - throw new Error(`Workflow group "${data.groupId}" not found`) - } - const group = groups[groupIndex] - - // Normalize every caller-supplied column reference to its stable id, so - // the diff/splice/clear logic below operates uniformly in id-space (the - // row-data storage key). New output columns get ids first; then output - // `columnName`, deps, input mappings, and mapping-update targets are - // remapped name → id. Callers that already pass ids are unaffected. - const newColDefs = (data.newOutputColumns ?? []).map((col) => - col.id ? col : { ...col, id: generateColumnId() } - ) - const idByName = new Map( - [...schema.columns, ...newColDefs].map((c) => [c.name, getColumnId(c)]) - ) - const remapRef = (ref: string) => idByName.get(ref) ?? ref - const outputsInput = data.outputs?.map((o) => ({ ...o, columnName: remapRef(o.columnName) })) - const dependenciesInput = data.dependencies - ? { columns: data.dependencies.columns?.map(remapRef) } - : undefined - const inputMappingsInput = data.inputMappings?.map((m) => ({ - ...m, - columnName: remapRef(m.columnName), - })) - const mappingUpdatesNorm = mappingUpdates.map((u) => ({ - ...u, - columnName: remapRef(u.columnName), - })) - // Re-key the out-of-lock leaf-type resolution to ids to match. - const remapLeafTypeById = new Map<string, ColumnDefinition['type']>() - for (const [name, type] of remapLeafTypeByColumn) remapLeafTypeById.set(remapRef(name), type) - - // Apply `mappingUpdates` first: each entry repoints an existing output's - // `(blockId, path)` while preserving the column. We patch the **old** view - // of outputs so the downstream `(blockId, path)`-keyed diff doesn't see the - // swap as a remove+add. The corresponding row data is cleared after the - // schema write so stale values from the old source don't linger. - const remappedColumnIds = new Set<string>() - // Per-column type override (keyed by id) resolved (out-of-lock) from the - // new mapping's leaf type. Only populated when a remap actually changes - // the column's type against the fresh schema. - const remappedColumnTypes = new Map<string, ColumnDefinition['type']>() - let oldOutputs = group.outputs - if (mappingUpdatesNorm.length > 0) { - const updateById = new Map(mappingUpdatesNorm.map((u) => [u.columnName, u])) - for (const u of mappingUpdatesNorm) { - const exists = oldOutputs.some((o) => o.columnName === u.columnName) - if (!exists) { - throw new Error( - `Mapping update for unknown column "${u.columnName}" (group ${data.groupId}).` - ) - } + await withLockedTable( + data.tableId, + async (table, trx) => { + // Any group patch edits the schema; the stronger destructive assert is + // applied below, only once we know this patch actually drops or remaps + // output columns (a rename / autoRun / mapping-only edit must not need + // the delete lock clear). + assertSchemaMutable(table) + await setTableTxTimeouts(trx, { statementMs: 60_000 }) + + const schema = table.schema + const groups = schema.workflowGroups ?? [] + const groupIndex = groups.findIndex((g) => g.id === data.groupId) + if (groupIndex === -1) { + throw new OrchestrationError('not_found', `Workflow group "${data.groupId}" not found`) } - oldOutputs = oldOutputs.map((o) => { - const u = updateById.get(o.columnName) - if (!u) return o - remappedColumnIds.add(o.columnName) - return { ...o, blockId: u.blockId, path: u.path } - }) - - // Only apply the out-of-lock leaf-type resolution if the group still - // points at the workflow we resolved against. If a concurrent writer - // changed `workflowId` between phase 1 and now, those types are stale — - // leave column types unchanged (best-effort, same as a resolution - // failure) rather than stamping types from the old workflow. - const finalWorkflowId = data.workflowId ?? group.workflowId - if (remapLeafTypeById.size > 0 && resolvedForWorkflowId !== finalWorkflowId) { - logger.warn( - `[${requestId}] Workflow group "${data.groupId}" workflowId changed between leaf-type resolution and apply; leaving remapped column types unchanged.` - ) - } else { - const colById = new Map(schema.columns.map((c) => [getColumnId(c), c])) + const group = groups[groupIndex] + + // Normalize every caller-supplied column reference to its stable id, so + // the diff/splice/clear logic below operates uniformly in id-space (the + // row-data storage key). New output columns get ids first; then output + // `columnName`, deps, input mappings, and mapping-update targets are + // remapped name → id. Callers that already pass ids are unaffected. + const newColDefs = (data.newOutputColumns ?? []).map((col) => + col.id ? col : { ...col, id: generateColumnId() } + ) + const idByName = new Map( + [...schema.columns, ...newColDefs].map((c) => [c.name, getColumnId(c)]) + ) + const remapRef = (ref: string) => idByName.get(ref) ?? ref + const outputsInput = data.outputs?.map((o) => ({ + ...o, + columnName: remapRef(o.columnName), + })) + const dependenciesInput = data.dependencies + ? { columns: data.dependencies.columns?.map(remapRef) } + : undefined + const inputMappingsInput = data.inputMappings?.map((m) => ({ + ...m, + columnName: remapRef(m.columnName), + })) + const mappingUpdatesNorm = mappingUpdates.map((u) => ({ + ...u, + columnName: remapRef(u.columnName), + })) + // Re-key the out-of-lock leaf-type resolution to ids to match. + const remapLeafTypeById = new Map<string, ColumnDefinition['type']>() + for (const [name, type] of remapLeafTypeByColumn) + remapLeafTypeById.set(remapRef(name), type) + + // Apply `mappingUpdates` first: each entry repoints an existing output's + // `(blockId, path)` while preserving the column. We patch the **old** view + // of outputs so the downstream `(blockId, path)`-keyed diff doesn't see the + // swap as a remove+add. The corresponding row data is cleared after the + // schema write so stale values from the old source don't linger. + const remappedColumnIds = new Set<string>() + // Per-column type override (keyed by id) resolved (out-of-lock) from the + // new mapping's leaf type. Only populated when a remap actually changes + // the column's type against the fresh schema. + const remappedColumnTypes = new Map<string, ColumnDefinition['type']>() + let oldOutputs = group.outputs + if (mappingUpdatesNorm.length > 0) { + const updateById = new Map(mappingUpdatesNorm.map((u) => [u.columnName, u])) for (const u of mappingUpdatesNorm) { - const newType = remapLeafTypeById.get(u.columnName) - if (!newType) continue - const oldType = colById.get(u.columnName)?.type - if (newType !== oldType) { - remappedColumnTypes.set(u.columnName, newType) + const exists = oldOutputs.some((o) => o.columnName === u.columnName) + if (!exists) { + throw new OrchestrationError( + 'validation', + `Mapping update for unknown column "${u.columnName}" (group ${data.groupId}).` + ) + } + } + oldOutputs = oldOutputs.map((o) => { + const u = updateById.get(o.columnName) + if (!u) return o + remappedColumnIds.add(o.columnName) + return { ...o, blockId: u.blockId, path: u.path } + }) + + // Only apply the out-of-lock leaf-type resolution if the group still + // points at the workflow we resolved against. If a concurrent writer + // changed `workflowId` between phase 1 and now, those types are stale — + // leave column types unchanged (best-effort, same as a resolution + // failure) rather than stamping types from the old workflow. + const finalWorkflowId = data.workflowId ?? group.workflowId + if (remapLeafTypeById.size > 0 && resolvedForWorkflowId !== finalWorkflowId) { + logger.warn( + `[${requestId}] Workflow group "${data.groupId}" workflowId changed between leaf-type resolution and apply; leaving remapped column types unchanged.` + ) + } else { + const colById = new Map(schema.columns.map((c) => [getColumnId(c), c])) + for (const u of mappingUpdatesNorm) { + const newType = remapLeafTypeById.get(u.columnName) + if (!newType) continue + const oldType = colById.get(u.columnName)?.type + if (newType !== oldType) { + remappedColumnTypes.set(u.columnName, newType) + } } } } - } - // If the caller passed `outputs`, that's the new full set. If only - // `mappingUpdates` was sent, the new set is the remapped old set. - const newOutputs = outputsInput ?? oldOutputs - // Enrichment outputs all share empty `blockId`/`path`, so keying on those - // alone collapses every sibling to one entry (dropping columns on diff). Key - // on the registry `outputId` when present; fall back to `blockId::path` for - // workflow outputs. - const oldKey = (o: WorkflowGroupOutput) => - o.outputId ? `out::${o.outputId}` : `${o.blockId}::${o.path}` - const oldByKey = new Map(oldOutputs.map((o) => [oldKey(o), o])) - const newByKey = new Map(newOutputs.map((o) => [oldKey(o), o])) - - const removed = oldOutputs.filter((o) => !newByKey.has(oldKey(o))) - const added = newOutputs.filter((o) => !oldByKey.has(oldKey(o))) - const newColById = new Map(newColDefs.map((c) => [getColumnId(c), c])) - - for (const out of added) { - if (!newColById.has(out.columnName)) { - throw new Error( - `Missing column definition for new output "${out.columnName}" (group ${data.groupId}).` - ) + // If the caller passed `outputs`, that's the new full set. If only + // `mappingUpdates` was sent, the new set is the remapped old set. + const newOutputs = outputsInput ?? oldOutputs + // Enrichment outputs all share empty `blockId`/`path`, so keying on those + // alone collapses every sibling to one entry (dropping columns on diff). Key + // on the registry `outputId` when present; fall back to `blockId::path` for + // workflow outputs. + const oldKey = (o: WorkflowGroupOutput) => + o.outputId ? `out::${o.outputId}` : `${o.blockId}::${o.path}` + const oldByKey = new Map(oldOutputs.map((o) => [oldKey(o), o])) + const newByKey = new Map(newOutputs.map((o) => [oldKey(o), o])) + + const removed = oldOutputs.filter((o) => !newByKey.has(oldKey(o))) + const added = newOutputs.filter((o) => !oldByKey.has(oldKey(o))) + const newColById = new Map(newColDefs.map((c) => [getColumnId(c), c])) + + for (const out of added) { + if (!newColById.has(out.columnName)) { + throw new OrchestrationError( + 'validation', + `Missing column definition for new output "${out.columnName}" (group ${data.groupId}).` + ) + } } - } - const removedColumnIds = new Set(removed.map((o) => o.columnName)) - // Both paths strip values out of every row below, so they need the delete - // lock clear as well as the schema lock — same rule as a column drop. - if (removedColumnIds.size > 0 || remappedColumnIds.size > 0) { - assertColumnDestructive(table) - } - let nextColumns = schema.columns - .filter((c) => !removedColumnIds.has(getColumnId(c))) - .map((c) => { - const newType = remappedColumnTypes.get(getColumnId(c)) - return newType ? { ...c, type: newType } : c - }) - if (newColDefs.length > 0) { - // Splice the new column defs into the group's contiguous run rather than - // appending at the end. The desired in-group order is `newOutputs` (the - // sidebar's BFS-of-the-workflow ordering); we walk it, anchor at the first - // surviving sibling's index in `nextColumns`, and emit each output's - // column def in turn. - const groupColIds = new Set(newOutputs.map((o) => o.columnName)) - const firstGroupIdx = nextColumns.findIndex((c) => groupColIds.has(getColumnId(c))) - const anchorIdx = firstGroupIdx === -1 ? nextColumns.length : firstGroupIdx - const orderedGroupCols: ColumnDefinition[] = [] - for (const out of newOutputs) { - const fresh = newColById.get(out.columnName) - if (fresh) { - orderedGroupCols.push(fresh) - } else { - const existing = nextColumns.find((c) => getColumnId(c) === out.columnName) - if (existing) orderedGroupCols.push(existing) + const removedColumnIds = new Set(removed.map((o) => o.columnName)) + // Both paths strip values out of every row below, so they need the delete + // lock clear as well as the schema lock — same rule as a column drop. + if (removedColumnIds.size > 0 || remappedColumnIds.size > 0) { + assertColumnDestructive(table) + } + let nextColumns = schema.columns + .filter((c) => !removedColumnIds.has(getColumnId(c))) + .map((c) => { + const newType = remappedColumnTypes.get(getColumnId(c)) + return newType ? { ...c, type: newType } : c + }) + if (newColDefs.length > 0) { + // Splice the new column defs into the group's contiguous run rather than + // appending at the end. The desired in-group order is `newOutputs` (the + // sidebar's BFS-of-the-workflow ordering); we walk it, anchor at the first + // surviving sibling's index in `nextColumns`, and emit each output's + // column def in turn. + const groupColIds = new Set(newOutputs.map((o) => o.columnName)) + const firstGroupIdx = nextColumns.findIndex((c) => groupColIds.has(getColumnId(c))) + const anchorIdx = firstGroupIdx === -1 ? nextColumns.length : firstGroupIdx + const orderedGroupCols: ColumnDefinition[] = [] + for (const out of newOutputs) { + const fresh = newColById.get(out.columnName) + if (fresh) { + orderedGroupCols.push(fresh) + } else { + const existing = nextColumns.find((c) => getColumnId(c) === out.columnName) + if (existing) orderedGroupCols.push(existing) + } } + const remaining = nextColumns.filter((c) => !groupColIds.has(getColumnId(c))) + nextColumns = [ + ...remaining.slice(0, anchorIdx), + ...orderedGroupCols, + ...remaining.slice(anchorIdx), + ] } - const remaining = nextColumns.filter((c) => !groupColIds.has(getColumnId(c))) - nextColumns = [ - ...remaining.slice(0, anchorIdx), - ...orderedGroupCols, - ...remaining.slice(anchorIdx), - ] - } - - const updatedGroup: WorkflowGroup = { - ...group, - workflowId: data.workflowId ?? group.workflowId, - name: data.name ?? group.name, - dependencies: dependenciesInput ?? group.dependencies, - outputs: newOutputs, - ...(inputMappingsInput !== undefined ? { inputMappings: inputMappingsInput } : {}), - ...(data.deploymentMode !== undefined ? { deploymentMode: data.deploymentMode } : {}), - ...(data.type !== undefined ? { type: data.type } : {}), - ...(data.autoRun !== undefined ? { autoRun: data.autoRun } : {}), - } - // Removed outputs may be referenced as deps by sibling groups; strip those - // refs so we don't leave dangling-column deps that fail schema validation. - const nextGroups = groups - .map((g, i) => (i === groupIndex ? updatedGroup : g)) - .map((g) => (g.id === updatedGroup.id ? g : stripGroupDeps(g, removedColumnIds))) - const updatedSchema: TableSchema = { - ...schema, - columns: nextColumns, - workflowGroups: nextGroups, - } - - // `columnOrder` (column ids) mirrors the schema layout. Drop removed - // columns, then splice the new ones in at the same anchor as `nextColumns` - // so the table renders them inside the group's contiguous run. - let updatedColumnOrder = table.metadata?.columnOrder?.filter( - (id) => !removedColumnIds.has(id) - ) - if (updatedColumnOrder && newColDefs.length > 0) { - const newColIds = new Set(newColDefs.map(getColumnId)) - const orderWithoutNew = updatedColumnOrder.filter((id) => !newColIds.has(id)) - const groupColIds = new Set(newOutputs.map((o) => o.columnName)) - const orderedGroupIds = newOutputs.map((o) => o.columnName) - const firstGroupOrderIdx = orderWithoutNew.findIndex((id) => groupColIds.has(id)) - const anchorOrderIdx = - firstGroupOrderIdx === -1 ? orderWithoutNew.length : firstGroupOrderIdx - const remainingOrder = orderWithoutNew.filter((id) => !groupColIds.has(id)) - updatedColumnOrder = [ - ...remainingOrder.slice(0, anchorOrderIdx), - ...orderedGroupIds, - ...remainingOrder.slice(anchorOrderIdx), - ] - } - assertValidSchema(updatedSchema, updatedColumnOrder) - const updatedMetadata: TableMetadata | null = - updatedColumnOrder && table.metadata - ? { ...table.metadata, columnOrder: updatedColumnOrder } - : table.metadata - ? { ...table.metadata } - : null + const updatedGroup: WorkflowGroup = { + ...group, + workflowId: data.workflowId ?? group.workflowId, + name: data.name ?? group.name, + dependencies: dependenciesInput ?? group.dependencies, + outputs: newOutputs, + ...(inputMappingsInput !== undefined ? { inputMappings: inputMappingsInput } : {}), + ...(data.deploymentMode !== undefined ? { deploymentMode: data.deploymentMode } : {}), + ...(data.type !== undefined ? { type: data.type } : {}), + ...(data.autoRun !== undefined ? { autoRun: data.autoRun } : {}), + } + // Removed outputs may be referenced as deps by sibling groups; strip those + // refs so we don't leave dangling-column deps that fail schema validation. + const nextGroups = groups + .map((g, i) => (i === groupIndex ? updatedGroup : g)) + .map((g) => (g.id === updatedGroup.id ? g : stripGroupDeps(g, removedColumnIds))) + const updatedSchema: TableSchema = { + ...schema, + columns: nextColumns, + workflowGroups: nextGroups, + } - const now = new Date() - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) - // Remapped columns: clear stale values in-tx so rows the backfill can't - // repopulate (no log, no matching span output) end up empty rather than - // retaining the previous mapping's value. The backfill below then writes - // the new mapping's value into rows where it can find one. - const clearedColumnIds = [...new Set([...removedColumnIds, ...remappedColumnIds])] - if (clearedColumnIds.length > 0) { - await updateTableRowsWithDerivedSecretProvenance(trx, { - rowWhere: eq(userTableRows.tableId, data.tableId), - transformation: { mode: 'remove-columns', columnIds: clearedColumnIds }, - }) - } + // `columnOrder` (column ids) mirrors the schema layout. Drop removed + // columns, then splice the new ones in at the same anchor as `nextColumns` + // so the table renders them inside the group's contiguous run. + let updatedColumnOrder = table.metadata?.columnOrder?.filter( + (id) => !removedColumnIds.has(id) + ) + if (updatedColumnOrder && newColDefs.length > 0) { + const newColIds = new Set(newColDefs.map(getColumnId)) + const orderWithoutNew = updatedColumnOrder.filter((id) => !newColIds.has(id)) + const groupColIds = new Set(newOutputs.map((o) => o.columnName)) + const orderedGroupIds = newOutputs.map((o) => o.columnName) + const firstGroupOrderIdx = orderWithoutNew.findIndex((id) => groupColIds.has(id)) + const anchorOrderIdx = + firstGroupOrderIdx === -1 ? orderWithoutNew.length : firstGroupOrderIdx + const remainingOrder = orderWithoutNew.filter((id) => !groupColIds.has(id)) + updatedColumnOrder = [ + ...remainingOrder.slice(0, anchorOrderIdx), + ...orderedGroupIds, + ...remainingOrder.slice(anchorOrderIdx), + ] + } + assertValidSchema(updatedSchema, updatedColumnOrder) + + const updatedMetadata: TableMetadata | null = + updatedColumnOrder && table.metadata + ? { ...table.metadata, columnOrder: updatedColumnOrder } + : table.metadata + ? { ...table.metadata } + : null + + const now = new Date() + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) + // Remapped columns: clear stale values in-tx so rows the backfill can't + // repopulate (no log, no matching span output) end up empty rather than + // retaining the previous mapping's value. The backfill below then writes + // the new mapping's value into rows where it can find one. + const clearedColumnIds = [...new Set([...removedColumnIds, ...remappedColumnIds])] + if (clearedColumnIds.length > 0) { + await updateTableRowsWithDerivedSecretProvenance(trx, { + rowWhere: and( + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, table.workspaceId) + )!, + transformation: { mode: 'remove-columns', columnIds: clearedColumnIds }, + }) + } - logger.info( - `[${requestId}] Updated workflow group "${data.groupId}" in table ${data.tableId} (added=${added.length}, removed=${removed.length}, remapped=${remappedColumnIds.size})` - ) + logger.info( + `[${requestId}] Updated workflow group "${data.groupId}" in table ${data.tableId} (added=${added.length}, removed=${removed.length}, remapped=${remappedColumnIds.size})` + ) - const updatedTable: TableDefinition = { - ...table, - schema: updatedSchema, - metadata: updatedMetadata, - updatedAt: now, - } - return { - updatedTable, - added, - remappedColumnIds, - newOutputs, - previousAutoRun: group.autoRun, - } - }) + const updatedTable: TableDefinition = { + ...table, + schema: updatedSchema, + metadata: updatedMetadata, + updatedAt: now, + } + return { + updatedTable, + added, + remappedColumnIds, + newOutputs, + previousAutoRun: group.autoRun, + } + }, + { expectedWorkspaceId: data.workspaceId } + ) // Backfill from saved execution logs so already-completed group runs surface // the schema changes without re-running the workflow. Two passes: @@ -583,7 +621,7 @@ export async function updateWorkflowGroup( // autoRun toggled false → true: fire deps-satisfied rows now via the // dispatcher. Mirrors the post-add path so re-enabling auto-fire doesn't // require manual run clicks for rows that are already eligible. - if (previousAutoRun === false && data.autoRun === true) { + if (previousAutoRun === false && data.autoRun === true && data.suppressAutoRunDispatch !== true) { void runWorkflowColumn({ tableId: updatedTable.id, workspaceId: updatedTable.workspaceId, @@ -610,6 +648,8 @@ export async function updateWorkflowGroup( export async function addWorkflowGroupOutput( data: { tableId: string + /** Canonical workspace derived by the authorized caller. */ + workspaceId?: string groupId: string blockId: string path: string @@ -627,10 +667,12 @@ export async function addWorkflowGroupOutput( // time out waiting (the Mothership fan-out this fix targets). Phase 2 // re-validates that the group still maps to the same workflow under the lock. const preTable = await getTableById(data.tableId) - if (!preTable) throw new Error('Table not found') + if (!preTable || (data.workspaceId && preTable.workspaceId !== data.workspaceId)) { + throw new OrchestrationError('not_found', 'Table not found') + } const preGroup = (preTable.schema.workflowGroups ?? []).find((g) => g.id === data.groupId) if (!preGroup) { - throw new Error(`Workflow group "${data.groupId}" not found`) + throw new OrchestrationError('not_found', `Workflow group "${data.groupId}" not found`) } const workflowId = preGroup.workflowId @@ -645,7 +687,7 @@ export async function addWorkflowGroupOutput( ]) const normalized = await loadWorkflowFromNormalizedTables(workflowId) if (!normalized) { - throw new Error(`Workflow ${workflowId} not found`) + throw new OrchestrationError('not_found', `Workflow ${workflowId} not found`) } const blocks = Object.values(normalized.blocks ?? {}).map((b) => ({ id: b.id, @@ -657,7 +699,8 @@ export async function addWorkflowGroupOutput( const flattened = flattenWorkflowOutputs(blocks, normalized.edges ?? []) const match = flattened.find((f) => f.blockId === data.blockId && f.path === data.path) if (!match) { - throw new Error( + throw new OrchestrationError( + 'validation', `Output ${data.blockId}::${data.path} is not a valid pickable output on workflow ${workflowId}` ) } @@ -668,153 +711,168 @@ export async function addWorkflowGroupOutput( // Phase 2 (locked): re-read fresh, validate against the current schema, and // write. The critical section holds no I/O — just the in-memory splice + the // schema UPDATE — so concurrent adders queue behind it quickly. - const { updatedTable, newOutput } = await withLockedTable(data.tableId, async (table, trx) => { - assertSchemaMutable(table) - const schema = table.schema - const groups = schema.workflowGroups ?? [] - const groupIndex = groups.findIndex((g) => g.id === data.groupId) - if (groupIndex === -1) { - throw new Error(`Workflow group "${data.groupId}" not found`) - } - const group = groups[groupIndex] - if (group.workflowId !== workflowId) { - throw new Error( - `Workflow group "${data.groupId}" was remapped to a different workflow concurrently; retry the add.` - ) - } + const { updatedTable, newOutput } = await withLockedTable( + data.tableId, + async (table, trx) => { + assertSchemaMutable(table) + const schema = table.schema + const groups = schema.workflowGroups ?? [] + const groupIndex = groups.findIndex((g) => g.id === data.groupId) + if (groupIndex === -1) { + throw new OrchestrationError('not_found', `Workflow group "${data.groupId}" not found`) + } + const group = groups[groupIndex] + if (group.workflowId !== workflowId) { + throw new OrchestrationError( + 'conflict', + `Workflow group "${data.groupId}" was remapped to a different workflow concurrently; retry the add.` + ) + } - if (group.outputs.some((o) => o.blockId === data.blockId && o.path === data.path)) { - throw new Error( - `Workflow group "${data.groupId}" already has an output at ${data.blockId}::${data.path}` - ) - } + if (group.outputs.some((o) => o.blockId === data.blockId && o.path === data.path)) { + throw new OrchestrationError( + 'validation', + `Workflow group "${data.groupId}" already has an output at ${data.blockId}::${data.path}` + ) + } - const taken = new Set(schema.columns.map((c) => c.name)) - const columnName = data.columnName ?? deriveOutputColumnName(data.path, taken) - if (!NAME_PATTERN.test(columnName)) { - throw new Error(`Invalid column name "${columnName}". Must satisfy ${NAME_PATTERN.source}.`) - } - if (taken.has(columnName)) { - throw new Error(`Column "${columnName}" already exists`) - } - if (schema.columns.length + 1 > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { - throw new Error( - `Adding a column would exceed the maximum (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE}).` - ) - } + const taken = new Set(schema.columns.map((c) => c.name)) + const columnName = data.columnName ?? deriveOutputColumnName(data.path, taken) + if (!NAME_PATTERN.test(columnName)) { + throw new OrchestrationError( + 'validation', + `Invalid column name "${columnName}". Must satisfy ${NAME_PATTERN.source}.` + ) + } + if (taken.has(columnName)) { + throw new OrchestrationError('validation', `Column "${columnName}" already exists`) + } + if (schema.columns.length + 1 > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { + throw new OrchestrationError( + 'validation', + `Adding a column would exceed the maximum (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE}).` + ) + } - const newColDef: ColumnDefinition = { - id: generateColumnId(), - name: columnName, - type: newColumnType, - required: false, - unique: false, - workflowGroupId: data.groupId, - } - const newColumnId = getColumnId(newColDef) - const newOutput: WorkflowGroupOutput = { - blockId: data.blockId, - path: data.path, - columnName: newColumnId, - } + const newColDef: ColumnDefinition = { + id: generateColumnId(), + name: columnName, + type: newColumnType, + required: false, + unique: false, + workflowGroupId: data.groupId, + } + const newColumnId = getColumnId(newColDef) + const newOutput: WorkflowGroupOutput = { + blockId: data.blockId, + path: data.path, + columnName: newColumnId, + } - // Sort all of the group's outputs (existing + new) in workflow execution - // order: BFS distance from the start block ASC, with discovery order as - // tiebreak. This matches what the column-sidebar does at create time, so - // columns from the same workflow always read in the order their blocks run - // — regardless of whether they were added at create time or one-by-one. - const groupColIdsBefore = new Set(group.outputs.map((o) => o.columnName)) - const orderKey = (o: { blockId: string; path: string }) => { - const d = distances[o.blockId] - const dist = d === undefined || d < 0 ? Number.POSITIVE_INFINITY : d - const idx = flatIndex.get(`${o.blockId}::${o.path}`) ?? Number.POSITIVE_INFINITY - return [dist, idx] as const - } - const allGroupOutputs = [...group.outputs, newOutput].sort((a, b) => { - const [da, ia] = orderKey(a) - const [db, ib] = orderKey(b) - return da !== db ? da - db : ia - ib - }) - const orderedGroupColIds = allGroupOutputs.map((o) => o.columnName) - const updatedGroup: WorkflowGroup = { - ...group, - outputs: allGroupOutputs, - } - const nextGroups = groups.map((g, i) => (i === groupIndex ? updatedGroup : g)) - - // Splice the new column run into nextColumns: keep the columns outside the - // group where they were, replace the group's contiguous run with the - // BFS-ordered list. Anchor at the position of the first existing sibling - // (or append if the group was empty). - const colById = new Map(schema.columns.map((c) => [getColumnId(c), c])) - const orderedGroupCols: ColumnDefinition[] = orderedGroupColIds.map((id) => { - if (id === newColumnId) return newColDef - const existing = colById.get(id) - if (!existing) { - throw new Error(`Internal: column "${id}" missing while splicing group outputs`) + // Sort all of the group's outputs (existing + new) in workflow execution + // order: BFS distance from the start block ASC, with discovery order as + // tiebreak. This matches what the column-sidebar does at create time, so + // columns from the same workflow always read in the order their blocks run + // — regardless of whether they were added at create time or one-by-one. + const groupColIdsBefore = new Set(group.outputs.map((o) => o.columnName)) + const orderKey = (o: { blockId: string; path: string }) => { + const d = distances[o.blockId] + const dist = d === undefined || d < 0 ? Number.POSITIVE_INFINITY : d + const idx = flatIndex.get(`${o.blockId}::${o.path}`) ?? Number.POSITIVE_INFINITY + return [dist, idx] as const } - return existing - }) - const remainingCols = schema.columns.filter((c) => !groupColIdsBefore.has(getColumnId(c))) - const firstGroupIdx = schema.columns.findIndex((c) => groupColIdsBefore.has(getColumnId(c))) - const colAnchor = firstGroupIdx === -1 ? remainingCols.length : firstGroupIdx - const nextColumns = [ - ...remainingCols.slice(0, colAnchor), - ...orderedGroupCols, - ...remainingCols.slice(colAnchor), - ] - - const updatedSchema: TableSchema = { - ...schema, - columns: nextColumns, - workflowGroups: nextGroups, - } + const allGroupOutputs = [...group.outputs, newOutput].sort((a, b) => { + const [da, ia] = orderKey(a) + const [db, ib] = orderKey(b) + return da !== db ? da - db : ia - ib + }) + const orderedGroupColIds = allGroupOutputs.map((o) => o.columnName) + const updatedGroup: WorkflowGroup = { + ...group, + outputs: allGroupOutputs, + } + const nextGroups = groups.map((g, i) => (i === groupIndex ? updatedGroup : g)) + + // Splice the new column run into nextColumns: keep the columns outside the + // group where they were, replace the group's contiguous run with the + // BFS-ordered list. Anchor at the position of the first existing sibling + // (or append if the group was empty). + const colById = new Map(schema.columns.map((c) => [getColumnId(c), c])) + const orderedGroupCols: ColumnDefinition[] = orderedGroupColIds.map((id) => { + if (id === newColumnId) return newColDef + const existing = colById.get(id) + if (!existing) { + throw new Error(`Internal: column "${id}" missing while splicing group outputs`) + } + return existing + }) + const remainingCols = schema.columns.filter((c) => !groupColIdsBefore.has(getColumnId(c))) + const firstGroupIdx = schema.columns.findIndex((c) => groupColIdsBefore.has(getColumnId(c))) + const colAnchor = firstGroupIdx === -1 ? remainingCols.length : firstGroupIdx + const nextColumns = [ + ...remainingCols.slice(0, colAnchor), + ...orderedGroupCols, + ...remainingCols.slice(colAnchor), + ] - const updatedColumnOrder = table.metadata?.columnOrder - ? (() => { - const orderWithoutGroup = table.metadata!.columnOrder!.filter( - (id) => !groupColIdsBefore.has(id) - ) - const firstGroupOrderIdx = table.metadata!.columnOrder!.findIndex((id) => - groupColIdsBefore.has(id) - ) - const orderAnchor = - firstGroupOrderIdx === -1 ? orderWithoutGroup.length : firstGroupOrderIdx - return [ - ...orderWithoutGroup.slice(0, orderAnchor), - ...orderedGroupColIds, - ...orderWithoutGroup.slice(orderAnchor), - ] - })() - : undefined + const updatedSchema: TableSchema = { + ...schema, + columns: nextColumns, + workflowGroups: nextGroups, + } - assertValidSchema(updatedSchema, updatedColumnOrder) + const updatedColumnOrder = table.metadata?.columnOrder + ? (() => { + const orderWithoutGroup = table.metadata!.columnOrder!.filter( + (id) => !groupColIdsBefore.has(id) + ) + const firstGroupOrderIdx = table.metadata!.columnOrder!.findIndex((id) => + groupColIdsBefore.has(id) + ) + const orderAnchor = + firstGroupOrderIdx === -1 ? orderWithoutGroup.length : firstGroupOrderIdx + return [ + ...orderWithoutGroup.slice(0, orderAnchor), + ...orderedGroupColIds, + ...orderWithoutGroup.slice(orderAnchor), + ] + })() + : undefined - const updatedMetadata: TableMetadata | null = - updatedColumnOrder && table.metadata - ? { ...table.metadata, columnOrder: updatedColumnOrder } - : table.metadata - ? { ...table.metadata } - : null + assertValidSchema(updatedSchema, updatedColumnOrder) - const now = new Date() - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) + const updatedMetadata: TableMetadata | null = + updatedColumnOrder && table.metadata + ? { ...table.metadata, columnOrder: updatedColumnOrder } + : table.metadata + ? { ...table.metadata } + : null - logger.info( - `[${requestId}] Added output "${columnName}" (${newColDef.type}) to workflow group "${data.groupId}" in table ${data.tableId}` - ) + const now = new Date() + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) - const updatedTable: TableDefinition = { - ...table, - schema: updatedSchema, - metadata: updatedMetadata, - updatedAt: now, - } - return { updatedTable, newOutput } - }) + logger.info( + `[${requestId}] Added output "${columnName}" (${newColDef.type}) to workflow group "${data.groupId}" in table ${data.tableId}` + ) + + const updatedTable: TableDefinition = { + ...table, + schema: updatedSchema, + metadata: updatedMetadata, + updatedAt: now, + } + return { updatedTable, newOutput } + }, + { expectedWorkspaceId: data.workspaceId } + ) // Backfill from saved execution logs — same flow `updateWorkflowGroup` // uses for added outputs. Reads each row's saved trace spans for the @@ -852,67 +910,80 @@ export async function addWorkflowGroupOutput( * `deleteWorkflowGroup` if needed. */ export async function deleteWorkflowGroupOutput( - data: { tableId: string; groupId: string; columnName: string }, + data: { tableId: string; workspaceId?: string; groupId: string; columnName: string }, requestId: string ): Promise<TableDefinition> { - return withLockedTable(data.tableId, async (table, trx) => { - assertColumnDestructive(table) - const schema = table.schema - const groups = schema.workflowGroups ?? [] - const groupIndex = groups.findIndex((g) => g.id === data.groupId) - if (groupIndex === -1) { - throw new Error(`Workflow group "${data.groupId}" not found`) - } - const group = groups[groupIndex] - // `data.columnName` may be a column id (first-party) or display name - // (mothership/legacy); resolve to the stable id used everywhere below. - const targetColumn = schema.columns.find((c) => columnMatchesRef(c, data.columnName)) - const columnId = targetColumn ? getColumnId(targetColumn) : data.columnName - if (!group.outputs.some((o) => o.columnName === columnId)) { - throw new Error( - `Workflow group "${data.groupId}" has no output bound to column "${data.columnName}"` - ) - } + return withLockedTable( + data.tableId, + async (table, trx) => { + assertColumnDestructive(table) + const schema = table.schema + const groups = schema.workflowGroups ?? [] + const groupIndex = groups.findIndex((g) => g.id === data.groupId) + if (groupIndex === -1) { + throw new OrchestrationError('not_found', `Workflow group "${data.groupId}" not found`) + } + const group = groups[groupIndex] + // `data.columnName` may be a column id (first-party) or display name + // (mothership/legacy); resolve to the stable id used everywhere below. + const targetColumn = schema.columns.find((c) => columnMatchesRef(c, data.columnName)) + const columnId = targetColumn ? getColumnId(targetColumn) : data.columnName + if (!group.outputs.some((o) => o.columnName === columnId)) { + throw new OrchestrationError( + 'not_found', + `Workflow group "${data.groupId}" has no output bound to column "${data.columnName}"` + ) + } - const updatedGroup: WorkflowGroup = { - ...group, - outputs: group.outputs.filter((o) => o.columnName !== columnId), - } - const nextGroups = groups.map((g, i) => (i === groupIndex ? updatedGroup : g)) - const nextColumns = schema.columns.filter((c) => getColumnId(c) !== columnId) - const updatedSchema: TableSchema = { - ...schema, - columns: nextColumns, - workflowGroups: nextGroups, - } + const updatedGroup: WorkflowGroup = { + ...group, + outputs: group.outputs.filter((o) => o.columnName !== columnId), + } + const nextGroups = groups.map((g, i) => (i === groupIndex ? updatedGroup : g)) + const nextColumns = schema.columns.filter((c) => getColumnId(c) !== columnId) + const updatedSchema: TableSchema = { + ...schema, + columns: nextColumns, + workflowGroups: nextGroups, + } - const updatedColumnOrder = table.metadata?.columnOrder?.filter((id) => id !== columnId) - assertValidSchema(updatedSchema, updatedColumnOrder) + const updatedColumnOrder = table.metadata?.columnOrder?.filter((id) => id !== columnId) + assertValidSchema(updatedSchema, updatedColumnOrder) - const updatedMetadata: TableMetadata | null = - updatedColumnOrder && table.metadata - ? { ...table.metadata, columnOrder: updatedColumnOrder } - : table.metadata - ? { ...table.metadata } - : null + const updatedMetadata: TableMetadata | null = + updatedColumnOrder && table.metadata + ? { ...table.metadata, columnOrder: updatedColumnOrder } + : table.metadata + ? { ...table.metadata } + : null - const now = new Date() - await setTableTxTimeouts(trx, { statementMs: 60_000 }) - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) - await updateTableRowsWithDerivedSecretProvenance(trx, { - rowWhere: eq(userTableRows.tableId, data.tableId), - transformation: { mode: 'remove-columns', columnIds: [columnId] }, - }) + const now = new Date() + await setTableTxTimeouts(trx, { statementMs: 60_000 }) + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) + await updateTableRowsWithDerivedSecretProvenance(trx, { + rowWhere: and( + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, table.workspaceId) + )!, + transformation: { mode: 'remove-columns', columnIds: [columnId] }, + }) - logger.info( - `[${requestId}] Removed output "${data.columnName}" from workflow group "${data.groupId}" in table ${data.tableId}` - ) + logger.info( + `[${requestId}] Removed output "${data.columnName}" from workflow group "${data.groupId}" in table ${data.tableId}` + ) - return { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now } - }) + return { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now } + }, + { expectedWorkspaceId: data.workspaceId } + ) } /** @@ -923,62 +994,76 @@ export async function deleteWorkflowGroup( data: DeleteWorkflowGroupData, requestId: string ): Promise<TableDefinition> { - return withLockedTable(data.tableId, async (table, trx) => { - assertColumnDestructive(table) - const schema = table.schema - const groups = schema.workflowGroups ?? [] - const group = groups.find((g) => g.id === data.groupId) - if (!group) { - throw new Error(`Workflow group "${data.groupId}" not found`) - } + return withLockedTable( + data.tableId, + async (table, trx) => { + assertColumnDestructive(table) + const schema = table.schema + const groups = schema.workflowGroups ?? [] + const group = groups.find((g) => g.id === data.groupId) + if (!group) { + throw new OrchestrationError('not_found', `Workflow group "${data.groupId}" not found`) + } - const removedColumnIds = new Set(group.outputs.map((o) => o.columnName)) - // Removed group's output columns may be referenced as deps by sibling groups. - // Strip those refs so we don't leave dangling-column deps behind. - const nextGroups = groups - .filter((g) => g.id !== data.groupId) - .map((g) => stripGroupDeps(g, removedColumnIds)) - const updatedSchema: TableSchema = { - ...schema, - columns: schema.columns.filter((c) => !removedColumnIds.has(getColumnId(c))), - workflowGroups: nextGroups, - } - const updatedColumnOrder = table.metadata?.columnOrder?.filter( - (id) => !removedColumnIds.has(id) - ) - assertValidSchema(updatedSchema, updatedColumnOrder) - - const updatedMetadata: TableMetadata | null = - updatedColumnOrder && table.metadata - ? { ...table.metadata, columnOrder: updatedColumnOrder } - : table.metadata - ? { ...table.metadata } - : null - - const now = new Date() - await setTableTxTimeouts(trx, { statementMs: 60_000 }) - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) - const removedIds = [...removedColumnIds] - if (removedIds.length > 0) { - await updateTableRowsWithDerivedSecretProvenance(trx, { - rowWhere: eq(userTableRows.tableId, data.tableId), - transformation: { mode: 'remove-columns', columnIds: removedIds }, + const removedColumnIds = new Set(group.outputs.map((o) => o.columnName)) + // Removed group's output columns may be referenced as deps by sibling groups. + // Strip those refs so we don't leave dangling-column deps behind. + const nextGroups = groups + .filter((g) => g.id !== data.groupId) + .map((g) => stripGroupDeps(g, removedColumnIds)) + const updatedSchema: TableSchema = { + ...schema, + columns: schema.columns.filter((c) => !removedColumnIds.has(getColumnId(c))), + workflowGroups: nextGroups, + } + const updatedColumnOrder = table.metadata?.columnOrder?.filter( + (id) => !removedColumnIds.has(id) + ) + assertValidSchema(updatedSchema, updatedColumnOrder) + + const updatedMetadata: TableMetadata | null = + updatedColumnOrder && table.metadata + ? { ...table.metadata, columnOrder: updatedColumnOrder } + : table.metadata + ? { ...table.metadata } + : null + + const now = new Date() + await setTableTxTimeouts(trx, { statementMs: 60_000 }) + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) + const removedIds = [...removedColumnIds] + if (removedIds.length > 0) { + await updateTableRowsWithDerivedSecretProvenance(trx, { + rowWhere: and( + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, table.workspaceId) + )!, + transformation: { mode: 'remove-columns', columnIds: removedIds }, + }) + } + await stripGroupExecutions(trx, data.tableId, [data.groupId], { + expectedWorkspaceId: table.workspaceId, }) - } - await stripGroupExecutions(trx, data.tableId, [data.groupId]) - logger.info( - `[${requestId}] Deleted workflow group "${data.groupId}" from table ${data.tableId}` - ) + logger.info( + `[${requestId}] Deleted workflow group "${data.groupId}" from table ${data.tableId}` + ) - return { - ...table, - schema: updatedSchema, - metadata: updatedMetadata, - updatedAt: now, - } - }) + return { + ...table, + schema: updatedSchema, + metadata: updatedMetadata, + updatedAt: now, + } + }, + { expectedWorkspaceId: data.workspaceId } + ) } diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts index 020e3f2c6b1..aa2c88c5d31 100644 --- a/apps/sim/lib/uploads/upload-session/service.test.ts +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -320,6 +320,55 @@ describe('upload sessions', () => { ).toThrow('Upload session not found') }) + it('requires an exact immutable credential binding for table-import control', async () => { + const bound = uploadRow({ + purpose: 'table_import', + storageContext: 'table-import', + metadata: { + authBinding: { + version: 1, + workspaceId: WORKSPACE_ID, + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + }, + }, + }) + queueTableRows(schemaMock.uploadSession, [bound]) + queueTableRows(schemaMock.uploadSession, [bound]) + + await expect( + getOwnedUploadSession({ + uploadId: bound.id, + uploadToken: 'upload-secret', + purpose: 'table_import', + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + }) + ).resolves.toMatchObject({ id: bound.id, purpose: 'table_import' }) + await expect( + getOwnedUploadSession({ + uploadId: bound.id, + uploadToken: 'upload-secret', + purpose: 'table_import', + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-2' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('fails closed for legacy table-import sessions without a binding', () => { + const legacyImport = sessionRecord({ + purpose: 'table_import', + storageContext: 'table-import', + metadata: {}, + }) + + expect(() => + assertUploadSessionAuthBinding(legacyImport, { + kind: 'session', + userId: legacyImport.userId, + sessionId: 'current-session', + }) + ).toThrow('Upload session not found') + }) + it('initiates multipart storage directly at the final key', async () => { const fileSize = UPLOAD_SESSION_PUT_MAX_BYTES + 1 const row = uploadRow({ diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index b6e3c7303e9..a24b99e6a4f 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -135,7 +135,7 @@ interface CreateUploadSessionBaseParams { export type CreateUploadSessionParams = CreateUploadSessionBaseParams & ( | { purpose: 'workspace_file'; workspaceId: string; principal: Principal } - | { purpose: 'table_import'; workspaceId: string } + | { purpose: 'table_import'; workspaceId: string; principal?: Principal } | { purpose: 'knowledge_document' workspaceId: string @@ -168,6 +168,9 @@ export async function createUploadSession( throw new Error(`${params.purpose} upload requires an authenticated principal`) } metadata.authBinding = createUploadSessionAuthBinding(params.principal, workspaceId) + } else if (params.purpose === 'table_import' && params.principal) { + if (!workspaceId) throw new Error('table_import upload is missing workspaceId') + metadata.authBinding = createUploadSessionAuthBinding(params.principal, workspaceId) } const { storageContext, finalKey } = resolveUploadStorage(params, id) const method: UploadTransferMethod = @@ -406,6 +409,7 @@ export function assertUploadSessionAuthBinding( if (!isPrincipalBoundUploadPurpose(session.purpose)) return const candidate = session.metadata.authBinding if (candidate === undefined) { + if (session.purpose === 'table_import') throw uploadNotFound() assertLegacyUploadSessionOwner(session, principal) return } @@ -1107,7 +1111,9 @@ function requiresStorageQuota(purpose: UploadSessionPurpose): boolean { } function isPrincipalBoundUploadPurpose(purpose: UploadSessionPurpose): boolean { - return purpose === 'workspace_file' || purpose === 'knowledge_document' + return ( + purpose === 'workspace_file' || purpose === 'knowledge_document' || purpose === 'table_import' + ) } function resolveUploadStorage( diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index 18a138fa9a9..62136ce8635 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -33,6 +33,7 @@ export interface DelegatedPrincipal { expiresAt: Date resourceScope?: { fileId?: string + tableId?: string chatId?: string executionId?: string } From e7e432b1edec171ae681bc4b1ec743f31b3efb4e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <siddharthganesan@gmail.com> Date: Sat, 8 Aug 2026 12:18:55 -0700 Subject: [PATCH 103/159] style(desktop): refine macOS installer layout --- apps/desktop/build/dmg-background.png | Bin 0 -> 245440 bytes apps/desktop/build/dmg-background@2x.png | Bin 0 -> 698554 bytes apps/desktop/electron-builder.yml | 15 +++++++++++++++ 3 files changed, 15 insertions(+) create mode 100644 apps/desktop/build/dmg-background.png create mode 100644 apps/desktop/build/dmg-background@2x.png diff --git a/apps/desktop/build/dmg-background.png b/apps/desktop/build/dmg-background.png new file mode 100644 index 0000000000000000000000000000000000000000..a79f4414535bb3c44feb6aa8480f2bf24c835ff6 GIT binary patch literal 245440 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%0DhYzKvmJBX`_6z<c1hG#*+V`#VB}9}okX$f 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�U?GB$U-G|E#&@ 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 literal 0 HcmV?d00001 diff --git a/apps/desktop/build/dmg-background@2x.png b/apps/desktop/build/dmg-background@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..2b0f9170246726283cf8c22ffba5555c65d5f1e2 GIT binary patch literal 698554 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μwMBMjTF3)_=BCR}?*xv93`h)=}MOHms?`&ZxDK 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�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Ξs%{YMD@a+e%)C9nyT^d=2fc1l=ebel9aEj}N=tpT0=zE1?;`Np*R=)wf9E#S z+Q!n<Jn*$g4``d&_Llpym78iWteju@Tm-29+smeQeq8%=`SAi%%hR<X`!1>Svov36 z=lY#nOGAC^E)eaf?>km%!#h{6j4Zt`f7bSDt0?2U+v!uAOXHS*rLTO^*L$s}{aRjM ga`}~S{i(112QSO&Tnv#Nn*aa+07*qoM6N<$f;z}z9{>OV literal 0 HcmV?d00001 diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index c13ceaaa30c..bb5d92a8dcf 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -60,6 +60,21 @@ mac: dmg: sign: false + title: ${productName} + # Finder uses the image dimensions as the installer window dimensions. The + # @2x companion is detected automatically and keeps the mountain artwork and + # install arrow sharp on Retina displays. + background: build/dmg-background.png + iconSize: 96 + iconTextSize: 13 + contents: + - x: 165 + y: 210 + type: file + - x: 495 + y: 210 + type: link + path: /Applications # node-pty ships pure N-API prebuilds, which are ABI-stable across Node and # Electron versions, so there is nothing to rebuild against Electron's ABI. From 8142bf46ae3a9849f4cc909715b7c0e1d833b781 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <siddharthganesan@gmail.com> Date: Sat, 8 Aug 2026 12:20:56 -0700 Subject: [PATCH 104/159] fix(api): type timestamp cursor parameters --- apps/sim/lib/api/list-query.test.ts | 15 +++++++++++---- apps/sim/lib/api/list-query.ts | 9 +++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/api/list-query.test.ts b/apps/sim/lib/api/list-query.test.ts index 4da992aa2f4..0974e8673d0 100644 --- a/apps/sim/lib/api/list-query.test.ts +++ b/apps/sim/lib/api/list-query.test.ts @@ -105,10 +105,17 @@ describe('timestampKey', () => { ) }) - it('truncates the bound cursor value to match, binding it through the column encoder', () => { + /** + * The cast is not cosmetic. `date_trunc` is overloaded on timestamp, + * timestamptz and interval, so an untyped parameter makes the call ambiguous + * and Postgres rejects the whole statement with "function date_trunc(unknown, + * unknown) is not unique" — which only ever fires on a second page, since + * page one carries no cursor. + */ + it('truncates the bound cursor value to match, cast to the column type', () => { const { sql: text, params } = render(createdKey.bind('2024-01-01T00:00:00.123Z')!) - expect(text).toBe(`date_trunc('milliseconds', $1)`) + expect(text).toBe(`date_trunc('milliseconds', $1::timestamp)`) expect(params).toEqual(['2024-01-01T00:00:00.123Z']) }) @@ -173,8 +180,8 @@ describe('keysetAfter', () => { ) expect(text).toBe( - `(date_trunc('milliseconds', "thing"."created_at") > date_trunc('milliseconds', $1) or ` + - `(date_trunc('milliseconds', "thing"."created_at") = date_trunc('milliseconds', $2) and ` + + `(date_trunc('milliseconds', "thing"."created_at") > date_trunc('milliseconds', $1::timestamp) or ` + + `(date_trunc('milliseconds', "thing"."created_at") = date_trunc('milliseconds', $2::timestamp) and ` + `"thing"."id" > $3))` ) expect(params).toEqual(['2024-01-01T00:00:00.123Z', '2024-01-01T00:00:00.123Z', 'file-7']) diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts index eb0d450705d..9d24d45f984 100644 --- a/apps/sim/lib/api/list-query.ts +++ b/apps/sim/lib/api/list-query.ts @@ -116,8 +116,13 @@ export function timestampKey<Row>(column: Column, read: (row: Row) => Date): Key if (typeof value !== 'string') return null const date = new Date(value) if (Number.isNaN(date.getTime())) return null - // Bound through the column so drizzle's own timestamp encoder serializes it. - return sql`date_trunc('milliseconds', ${sql.param(date, column)})` + /* Bound through the column so drizzle's own timestamp encoder serializes + it, then cast: an untyped parameter leaves `date_trunc(unknown, + unknown)` ambiguous across its timestamp, timestamptz and interval + overloads, and Postgres rejects the statement rather than guess. Every + column reaching here is `timestamp without time zone`, matching the + type `expr` yields on the other side of the comparison. */ + return sql`date_trunc('milliseconds', ${sql.param(date, column)}::timestamp)` }, } } From 3f0c1fcce06a37f6e08b7b0508a8ecdb31fc7e50 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <siddharthganesan@gmail.com> Date: Sat, 8 Aug 2026 12:21:08 -0700 Subject: [PATCH 105/159] feat(cli): add saved chat commands --- packages/sim-cli/README.md | 19 +- .../src/commands/protocol/chat-suggestions.ts | 8 +- .../src/commands/protocol/chat.test.ts | 166 +++++++++++++++++- .../sim-cli/src/commands/protocol/chat.ts | 49 +++++- packages/sim-cli/src/contract/commands.ts | 31 ++++ packages/sim-cli/src/http/client.test.ts | 3 + packages/sim-cli/src/runtime/build.test.ts | 93 ++++++++++ 7 files changed, 350 insertions(+), 19 deletions(-) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 18141268c0d..f2a3d90f292 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -101,16 +101,20 @@ Settings → API keys. ## Commands -Plural resource names are canonical, but every plural top-level resource group -also accepts its singular form: for example, `sim table list`, +Plural resource names are canonical, but most plural top-level resource groups +also accept their singular form: for example, `sim table list`, `sim file download`, and `sim workflow get` are equivalent to their plural -spellings. +spellings. Chats deliberately keep separate names: `sim chat` is the terminal +conversation, while `sim chats` manages saved chat resources. `knowledge` also accepts the shorter `kb` alias. ```bash sim chat [prompt...] [-f <path>...] [--read-only] sim chat -p [prompt...] [-f <path>...] [--read-only] +sim chats list [--search <text>] [--limit <n>] +sim chats get <chatId> [--read-only] +sim chats rename <chatId> --title <title> sim workflows ls [path] [--search <text>] [--limit <n>] sim workflows list [--folder <path>] [--deployed-only] [--limit <n>] @@ -176,6 +180,8 @@ sim billing logs [--period 7d] [--source sim-chat] [--limit <n>] [--all-workspac The `sim-chat` billing source combines Copilot and workspace chat usage. Organization audit logs require a personal API key. Commands with `--all-workspaces` otherwise default to the workspace in the active profile. +Saved chat commands also require a personal API key and use that active +workspace unless `--workspace` overrides it. `workflows runs get` is the lightweight status and polling resource. `--workflow` names the parent resource, while the run ID remains positional. @@ -230,7 +236,7 @@ a searchable picker. Selecting one restores its transcript and continues it with a fresh opaque token. The header shows the active chat title and keeps the `/chats` switch hint visible; a new chat's generated title appears there as soon as the server publishes it. `/rename <title>` retitles the active synced chat in -both the terminal and Sim Home. `/clear` clears the visible transcript and +both the terminal and Sim Home. `/new` clears the visible transcript and starts a new conversation, `/help` lists commands, and `/exit` or Ctrl+D exits. Ctrl+C clears idle input or cancels the active generation and returns to the prompt. @@ -254,11 +260,16 @@ pipelines and redirected output must use `-p`. ```bash sim chat -p "Which workflows handle support tickets?" +sim chat -p --chat <chatId> "Continue this conversation" cat incident.txt | sim chat -p "Which workflow is most likely involved?" sim chat -p < question.txt sim chat -p --file report.pdf "Summarize this in workspace context" ``` +Pass `--chat <chatId>` to append one print-mode turn to an existing inactive +chat, print its answer, and exit. The chat must belong to the active workspace, +and synchronized history requires a personal API key. + When both a positional prompt and stdin are present, the positional prompt comes first and the piped content follows on the next line. This matches Claude Code's print-mode input behavior. Combined input is limited to 10 MiB of UTF-8 text. diff --git a/packages/sim-cli/src/commands/protocol/chat-suggestions.ts b/packages/sim-cli/src/commands/protocol/chat-suggestions.ts index f5ab77154c7..856b87e44be 100644 --- a/packages/sim-cli/src/commands/protocol/chat-suggestions.ts +++ b/packages/sim-cli/src/commands/protocol/chat-suggestions.ts @@ -185,10 +185,10 @@ export function contextSpans( /** Composer slash commands, the source for the `/` menu. */ export const SLASH_COMMANDS: SuggestionItem[] = [ { - id: 'clear', - value: '/clear', - displayText: '/clear', - description: 'start a new conversation', + id: 'new', + value: '/new', + displayText: '/new', + description: 'start a new chat', tag: 'command', }, { diff --git a/packages/sim-cli/src/commands/protocol/chat.test.ts b/packages/sim-cli/src/commands/protocol/chat.test.ts index 40bd546da27..184f120e6da 100644 --- a/packages/sim-cli/src/commands/protocol/chat.test.ts +++ b/packages/sim-cli/src/commands/protocol/chat.test.ts @@ -280,6 +280,166 @@ describe('chat print mode', () => { expect(writeOutput).toHaveBeenCalledWith('Hello world') }) + it('resumes an existing chat by ID for one print-mode turn', async () => { + mocks.request.mockResolvedValueOnce({ + data: { + id: 'chat-1', + title: 'Existing chat', + messages: [], + continuationToken: 'resume-token', + active: false, + }, + }) + mocks.requestRaw.mockResolvedValue(completed('Continued answer', 'next-token')) + const writeOutput = vi.fn() + + await program(async () => '', writeOutput).parseAsync([ + 'node', + 'sim', + 'chat', + '-p', + '--chat', + 'chat-1', + 'Continue here', + ]) + + expect(mocks.request).toHaveBeenCalledWith('/api/v2/chats/chat-1', { + query: { workspaceId: 'ws_local' }, + auth: 'optional', + }) + expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'Continue here', + continuationToken: 'resume-token', + }) + expect(writeOutput).toHaveBeenCalledWith('Continued answer') + }) + + it('binds a resumed print-mode token to read-only mode', async () => { + mocks.request.mockResolvedValueOnce({ + data: { + id: 'chat-1', + title: 'Existing chat', + messages: [], + continuationToken: 'read-only-token', + active: false, + }, + }) + mocks.requestRaw.mockResolvedValue(completed('Read-only answer')) + + await program(async () => '').parseAsync([ + 'node', + 'sim', + 'chat', + '-p', + '--read-only', + '--chat', + 'chat-1', + 'Continue safely', + ]) + + expect(mocks.request).toHaveBeenCalledWith('/api/v2/chats/chat-1', { + query: { workspaceId: 'ws_local', readOnly: true }, + auth: 'optional', + }) + expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'Continue safely', + readOnly: true, + continuationToken: 'read-only-token', + }) + }) + + it('rejects --chat outside print mode', async () => { + await expect( + program(async () => '', vi.fn(), { isInteractive: () => true }).parseAsync([ + 'node', + 'sim', + 'chat', + '--chat', + 'chat-1', + ]) + ).rejects.toThrow('--chat can only be used with -p/--print') + + expect(mocks.request).not.toHaveBeenCalled() + expect(mocks.requestRaw).not.toHaveBeenCalled() + }) + + it('does not race a print-mode turn into a chat active elsewhere', async () => { + mocks.request.mockResolvedValueOnce({ + data: { + id: 'chat-1', + title: 'Existing chat', + messages: [], + continuationToken: 'resume-token', + active: true, + }, + }) + + await expect( + program(async () => '').parseAsync([ + 'node', + 'sim', + 'chat', + '-p', + '--chat', + 'chat-1', + 'Continue here', + ]) + ).rejects.toThrow('currently active in another client') + + expect(mocks.requestRaw).not.toHaveBeenCalled() + }) + + it('surfaces a conflict if the chat becomes active after lookup', async () => { + mocks.request.mockResolvedValueOnce({ + data: { + id: 'chat-1', + title: 'Existing chat', + messages: [], + continuationToken: 'resume-token', + active: false, + }, + }) + mocks.requestRaw.mockRejectedValueOnce( + new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') + ) + const writeOutput = vi.fn() + + await expect( + program(async () => '', writeOutput).parseAsync([ + 'node', + 'sim', + 'chat', + '-p', + '--chat', + 'chat-1', + 'Continue here', + ]) + ).rejects.toThrow('A response is already in progress for this chat') + + expect(mocks.requestRaw).toHaveBeenCalledOnce() + expect(writeOutput).not.toHaveBeenCalled() + }) + + it('surfaces an inaccessible chat without starting a new one', async () => { + mocks.request.mockRejectedValueOnce(new SimApiError('Chat not found', 404, 'NOT_FOUND')) + + await expect( + program(async () => '').parseAsync([ + 'node', + 'sim', + 'chat', + '-p', + '--chat', + 'missing-chat', + 'Continue here', + ]) + ).rejects.toThrow('Chat not found') + + expect(mocks.requestRaw).not.toHaveBeenCalled() + }) + it('keeps the profile shorthand distinct from chat -p', async () => { mocks.requestRaw.mockResolvedValue(completed('answer')) @@ -1052,7 +1212,7 @@ describe('interactive chat', () => { }) }) - it('visibly resets the transcript and continuation identity with /clear', async () => { + it('visibly resets the transcript and continuation identity with /new', async () => { mocks.requestRaw .mockResolvedValueOnce( sse([ @@ -1062,7 +1222,7 @@ describe('interactive chat', () => { ) .mockResolvedValueOnce(completed('Second', 'token-2')) const terminal = new FakeTerminal([ - { kind: 'line', value: '/clear' }, + { kind: 'line', value: '/new' }, { kind: 'line', value: 'Fresh question' }, { kind: 'line', value: '/exit' }, ]) @@ -1998,7 +2158,7 @@ describe('interactive chat', () => { it('does not move queued prompts into another conversation', async () => { const terminal = new FakeTerminal([ { kind: 'line', value: 'first' }, - { kind: 'line', value: '/clear', queued: true, display: '/clear' }, + { kind: 'line', value: '/new', queued: true, display: '/new' }, { kind: 'line', value: 'second', queued: true, display: 'second' }, { kind: 'line', value: '/chats', queued: true, display: '/chats' }, { kind: 'line', value: 'third', queued: true, display: 'third' }, diff --git a/packages/sim-cli/src/commands/protocol/chat.ts b/packages/sim-cli/src/commands/protocol/chat.ts index 8459163295e..984dd2fbb94 100644 --- a/packages/sim-cli/src/commands/protocol/chat.ts +++ b/packages/sim-cli/src/commands/protocol/chat.ts @@ -332,7 +332,8 @@ async function runOneShot( prompt: string, attachments: ChatAttachment[], readOnly: boolean, - dependencies: ChatDependencies + dependencies: ChatDependencies, + continuationToken?: string ): Promise<void> { const controller = new AbortController() const cancel = () => controller.abort() @@ -345,6 +346,7 @@ async function runOneShot( workspaceId, prompt, ...(readOnly ? { readOnly: true } : {}), + ...(continuationToken ? { continuationToken } : {}), ...(attachments.length ? { attachments } : {}), }, controller.signal @@ -373,7 +375,7 @@ type UserTurnResult = pastes?: ReadonlyMap<number, string> contexts?: ChatContext[] } - | { kind: 'clear'; attachments: ChatAttachment[] } + | { kind: 'new'; attachments: ChatAttachment[] } | { kind: 'chats'; attachments: ChatAttachment[] } | { kind: 'rename'; title: string; attachments: ChatAttachment[] } | { kind: 'idle'; attachments: ChatAttachment[] } @@ -385,7 +387,7 @@ function explainInteractiveCommands(terminal: ChatTerminal): void { 'Commands:', ' ctrl+v attach the clipboard image or file (or cmd+v on macOS)', ' <file path> drop or type a path to attach the file', - ' /clear start a new conversation', + ' /new start a new chat', ' /chats view and switch chats', ' /rename <title> rename the active chat', ' /help show this help', @@ -466,7 +468,7 @@ async function readUserTurn( explainInteractiveCommands(terminal) continue } - if (trimmed === '/clear') return { kind: 'clear', attachments } + if (trimmed === '/new') return { kind: 'new', attachments } if (trimmed === '/chats') { return { kind: 'chats', attachments } } @@ -1013,11 +1015,11 @@ async function runInteractive( nextPromptConflictRetries = 0 continue } - if ((input.kind === 'clear' || input.kind === 'chats') && terminal.hasQueuedInput()) { + if ((input.kind === 'new' || input.kind === 'chats') && terminal.hasQueuedInput()) { terminal.status('Finish queued prompts before changing conversations.') continue } - if (input.kind === 'clear') { + if (input.kind === 'new') { startNewConversation() continue } @@ -1470,18 +1472,30 @@ export function chatCommand(overrides: Partial<ChatDependencies> = {}): Command .description('Ask Sim Chat about the active workspace') .argument('[prompt...]', 'Question to ask') .option('-p, --print', 'Print the final response and exit') + .option('--chat <chatId>', 'Resume an existing chat by ID (print mode only)') .option('-f, --file <path>', 'Attach a local file (repeatable)', collectFile, []) .option('--read-only', 'Restrict Sim Chat to read-only workspace tools') .action( async ( promptParts: string[], - options: { print?: boolean; file: string[]; readOnly?: boolean }, + options: { print?: boolean; chat?: string; file: string[]; readOnly?: boolean }, command: Command ) => { const positionalPrompt = promptParts.join(' ') const positionalBytes = utf8Bytes(positionalPrompt) if (positionalBytes > MAX_CHAT_PROMPT_BYTES) throw inputTooLarge() + const chatId = options.chat?.trim() + if (options.chat !== undefined && !chatId) { + throw new SimApiError('Chat ID must not be empty.', 0) + } + if (chatId && !options.print) { + throw new SimApiError( + '--chat can only be used with -p/--print. Use /chats in interactive mode.', + 0 + ) + } + const interactive = !options.print && dependencies.isInteractive() if (!options.print && !interactive) { throw new SimApiError( @@ -1515,13 +1529,32 @@ export function chatCommand(overrides: Partial<ChatDependencies> = {}): Command ) return } + let continuationToken: string | undefined + if (chatId) { + const chat = await loadChat(client, workspaceId, chatId, options.readOnly === true) + if (!chat.continuationToken) { + throw new SimApiError( + 'Sim Chat did not return a continuation token for the selected chat.', + 0 + ) + } + if (chat.active) { + throw new SimApiError( + 'The selected chat is currently active in another client. Wait for it to finish before resuming it.', + 409, + 'CONFLICT' + ) + } + continuationToken = chat.continuationToken + } await runOneShot( client, workspaceId, prompt, attachments, options.readOnly === true, - dependencies + dependencies, + continuationToken ) } ) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index fab31bd615b..a36b89ea4c3 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -292,6 +292,37 @@ export const CLI_CONTRACT: CliContract = { updateCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, // ─── Output columns for list commands ───────────────────────────────────── + listChats: { + flags: { search: { describe: 'Filter chats by title' } }, + columns: [ + { header: 'id' }, + { header: 'title' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + { header: 'pinned', format: 'bool' }, + { header: 'active', format: 'bool' }, + ], + }, + getChat: { + describe: 'Show chat metadata and message count', + flags: { + readOnly: { + boolean: true, + describe: 'Bind the returned continuation token to read-only mode', + }, + }, + fields: [ + { header: 'id' }, + { header: 'title' }, + { header: 'messages', format: 'count' }, + { header: 'active', format: 'bool' }, + ], + }, + renameChat: { + command: 'chats rename', + describe: 'Rename a chat', + flags: { title: { describe: 'New chat title' } }, + fields: [{ header: 'id' }, { header: 'title' }], + }, listTables: { flags: { folderPath: FOLDER_PATH_FLAG }, columns: [ diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 0679db8b4dd..d966aea9d1a 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -252,6 +252,9 @@ describe('generated operation table', () => { 'getLog', 'getBillingStatus', 'listBillingLogs', + 'listChats', + 'getChat', + 'renameChat', 'listWorkflowRuns', 'getWorkflowRun', 'resumeWorkflow', diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 1299292d08c..03a03f1cd2f 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -108,6 +108,56 @@ describe('commands parsed through commander', () => { expect(program().commands.some((command) => command.name() === 'folders')).toBe(false) }) + it('exposes saved chats through the generated resource commands', async () => { + expect( + commandAt('chats') + .commands.map((command) => command.name()) + .sort() + ).toEqual(['get', 'list', 'rename']) + + const listHelp = commandAt('chats', 'list').helpInformation() + expect(listHelp).toContain('--search <value>') + expect(listHelp).toContain('Filter chats by title') + expect(listHelp).toContain('--limit <n>') + + const [listPath, listOptions] = await run([ + 'chats', + 'list', + '--search', + 'incident', + '--limit', + '5', + ]) + expect(listPath).toBe('/api/v2/chats') + expect(listOptions.query).toMatchObject({ + workspaceId: 'ws_local', + search: 'incident', + limit: 5, + }) + + const getHelp = commandAt('chats', 'get').helpInformation() + expect(getHelp).toContain('Bind the returned continuation token to read-only mode') + const [getPath, getOptions] = await run(['chats', 'get', 'chat_1', '--read-only']) + expect(getPath).toBe('/api/v2/chats/chat_1') + expect(getOptions.query).toEqual({ workspaceId: 'ws_local', readOnly: true }) + + const renameHelp = commandAt('chats', 'rename').helpInformation() + expect(renameHelp).toContain('--title <value>') + expect(renameHelp).toContain('New chat title') + const [renamePath, renameOptions] = await run([ + 'chats', + 'rename', + 'chat_1', + '--title', + 'Incident review', + ]) + expect(renamePath).toBe('/api/v2/chats/chat_1') + expect(renameOptions).toMatchObject({ + method: 'PATCH', + body: { workspaceId: 'ws_local', title: 'Incident review' }, + }) + }) + it('describes generated resource and sub-resource groups', () => { expect(commandAt('tables').description()).toBe('Manage tables') expect(commandAt('tables', 'rows').description()).toBe('Manage table rows') @@ -754,6 +804,32 @@ describe('single-resource rendering', () => { expect(JSON.parse(printed[0])).toEqual({ row: { id: 'r1' }, operation: 'inserted' }) }) + it('keeps chat implementation details out of human output', async () => { + const chat = { + id: 'chat_1', + title: 'Incident review', + messages: [ + { id: 'message_1', role: 'user', content: 'Private prompt', timestamp: '2026-08-04' }, + { + id: 'message_2', + role: 'assistant', + content: 'Private answer', + timestamp: '2026-08-04', + }, + ], + continuationToken: 'opaque-token', + active: false, + } + + const human = await lines(['chats', 'get', 'chat_1'], chat, 'text') + expect(human).toEqual(['id\tchat_1', 'title\tIncident review', 'messages\t2', 'active\tno']) + expect(human.join('\n')).not.toContain('opaque-token') + expect(human.join('\n')).not.toContain('Private prompt') + + const machine = await lines(['chats', 'get', 'chat_1'], chat, 'json') + expect(JSON.parse(machine[0])).toEqual(chat) + }) + it('keeps sensitive run detail opt-in for human log output', async () => { const log = { runId: 'run_1', @@ -850,6 +926,23 @@ describe('contract-selected list rendering', () => { expect(printed).toEqual(['0.91\tpolicy.md\t2\tRefunds are available for 30 days.']) }) + it('formats saved chats like other resource lists', async () => { + const printed = await lines( + ['chats', 'list'], + [ + { + id: 'chat_1', + title: 'Incident review', + updatedAt: '2026-08-04T12:34:56.789Z', + pinned: false, + active: true, + }, + ] + ) + + expect(printed).toEqual(['chat_1\tIncident review\t2026-08-04 12:34:56\tno\tyes']) + }) + it('renders row matches as rows', async () => { const printed = await lines(['tables', 'rows', 'find', 'tbl_1', '--q', 'alice'], { matches: [{ ordinal: 3, rowId: 'row_1', column: 'email' }], From ae6e1287ebc5dec303da9855b0064939f6844258 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <siddharthganesan@gmail.com> Date: Sat, 8 Aug 2026 12:30:05 -0700 Subject: [PATCH 106/159] chore(desktop): trigger updated prerelease --- apps/desktop/electron-builder.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index bb5d92a8dcf..c8f3d603c6e 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -67,6 +67,7 @@ dmg: background: build/dmg-background.png iconSize: 96 iconTextSize: 13 + # Keep both icon centers inside the compact 660x420 Finder canvas. contents: - x: 165 y: 210 From 6129182f748cebadd06fe787fe159951148eaa29 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 8 Aug 2026 12:56:31 -0700 Subject: [PATCH 107/159] feat(cli): publish @simai/cli release channels --- .github/workflows/publish-sim-cli.yml | 112 ++++++++++++++ bun.lock | 30 ++-- packages/sim-cli/LICENSE | 202 ++++++++++++++++++++++++++ packages/sim-cli/README.md | 9 +- packages/sim-cli/package.json | 18 ++- packages/sim-cli/src/index.ts | 18 ++- packages/sim-cli/tsconfig.build.json | 9 ++ 7 files changed, 379 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/publish-sim-cli.yml create mode 100644 packages/sim-cli/LICENSE create mode 100644 packages/sim-cli/tsconfig.build.json diff --git a/.github/workflows/publish-sim-cli.yml b/.github/workflows/publish-sim-cli.yml new file mode 100644 index 00000000000..48f7b0f557f --- /dev/null +++ b/.github/workflows/publish-sim-cli.yml @@ -0,0 +1,112 @@ +name: Publish Sim API CLI Package + +on: + push: + branches: [main, staging, dev] + paths: + - 'packages/sim-cli/**' + +permissions: + contents: read + +concurrency: + group: publish-sim-cli-${{ github.ref }} + cancel-in-progress: true + +jobs: + publish-npm: + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Cache Bun dependencies + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: | + ~/.bun/install/cache + node_modules + **/node_modules + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Verify initial package exists + run: bun pm view @simai/cli@preview name + + - name: Run tests + working-directory: packages/sim-cli + run: bun run test + + - name: Type-check package + working-directory: packages/sim-cli + run: bun run type-check + + - name: Build package + working-directory: packages/sim-cli + run: bun run build + + - name: Resolve release channel + id: release + working-directory: packages/sim-cli + env: + BRANCH: ${{ github.ref_name }} + run: | + BASE_VERSION="$(bun -p "require('./package.json').version")" + if [[ ! "$BASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Package version must be a stable X.Y.Z base, got '$BASE_VERSION'." >&2 + exit 1 + fi + + case "$BRANCH" in + dev) + VERSION="${BASE_VERSION}-dev.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" + TAG="dev" + ;; + staging) + VERSION="${BASE_VERSION}-preview.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" + TAG="preview" + ;; + main) + VERSION="$BASE_VERSION" + TAG="latest" + ;; + *) + echo "Unsupported release branch '$BRANCH'." >&2 + exit 1 + ;; + esac + + bun pm pkg set "version=$VERSION" + RESOLVED_VERSION="$(bun -p "require('./package.json').version")" + if [ "$RESOLVED_VERSION" != "$VERSION" ]; then + echo "Version injection mismatch: wanted '$VERSION', got '$RESOLVED_VERSION'." >&2 + exit 1 + fi + + { + echo "version=$VERSION" + echo "tag=$TAG" + } >> "$GITHUB_OUTPUT" + + - name: Publish to npm + working-directory: packages/sim-cli + env: + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TAG: ${{ steps.release.outputs.tag }} + run: bun publish --access public --tag "$NPM_TAG" --no-save + + - name: Summarize release + env: + VERSION: ${{ steps.release.outputs.version }} + NPM_TAG: ${{ steps.release.outputs.tag }} + run: echo "Published @simai/cli@$VERSION with the '$NPM_TAG' tag." diff --git a/bun.lock b/bun.lock index c2c5543f6e9..c2ae2ec96e9 100644 --- a/bun.lock +++ b/bun.lock @@ -582,7 +582,7 @@ }, }, "packages/sim-cli": { - "name": "@sim/cli", + "name": "@simai/cli", "version": "0.1.0", "bin": { "sim": "dist/index.js", @@ -1808,8 +1808,6 @@ "@sim/browser-protocol": ["@sim/browser-protocol@workspace:packages/browser-protocol"], - "@sim/cli": ["@sim/cli@workspace:packages/sim-cli"], - "@sim/db": ["@sim/db@workspace:packages/db"], "@sim/desktop": ["@sim/desktop@workspace:apps/desktop"], @@ -1846,6 +1844,8 @@ "@sim/workflow-types": ["@sim/workflow-types@workspace:packages/workflow-types"], + "@simai/cli": ["@simai/cli@workspace:packages/sim-cli"], + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], "@smithy/config-resolver": ["@smithy/config-resolver@4.6.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-NJF/Xc69G68BzZMKMEpWkCY9HjZJzTWztTW4VxBC2SodX+H60xw+NGckNhkgg4uMRHrpDkhWeBeigM3YJmv1FQ=="], @@ -4924,7 +4924,7 @@ "@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], - "@sim/cli/vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="], + "@simai/cli/vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="], "@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="], @@ -5512,27 +5512,27 @@ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], - "@sim/cli/vitest/@vitest/expect": ["@vitest/expect@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w=="], + "@simai/cli/vitest/@vitest/expect": ["@vitest/expect@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w=="], - "@sim/cli/vitest/@vitest/mocker": ["@vitest/mocker@3.2.7", "", { "dependencies": { "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA=="], + "@simai/cli/vitest/@vitest/mocker": ["@vitest/mocker@3.2.7", "", { "dependencies": { "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA=="], - "@sim/cli/vitest/@vitest/pretty-format": ["@vitest/pretty-format@3.2.7", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA=="], + "@simai/cli/vitest/@vitest/pretty-format": ["@vitest/pretty-format@3.2.7", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA=="], - "@sim/cli/vitest/@vitest/runner": ["@vitest/runner@3.2.7", "", { "dependencies": { "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA=="], + "@simai/cli/vitest/@vitest/runner": ["@vitest/runner@3.2.7", "", { "dependencies": { "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA=="], - "@sim/cli/vitest/@vitest/snapshot": ["@vitest/snapshot@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g=="], + "@simai/cli/vitest/@vitest/snapshot": ["@vitest/snapshot@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g=="], - "@sim/cli/vitest/@vitest/spy": ["@vitest/spy@3.2.7", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ=="], + "@simai/cli/vitest/@vitest/spy": ["@vitest/spy@3.2.7", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ=="], - "@sim/cli/vitest/@vitest/utils": ["@vitest/utils@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw=="], + "@simai/cli/vitest/@vitest/utils": ["@vitest/utils@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw=="], - "@sim/cli/vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "@simai/cli/vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], - "@sim/cli/vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + "@simai/cli/vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - "@sim/cli/vitest/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + "@simai/cli/vitest/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], - "@sim/cli/vitest/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + "@simai/cli/vitest/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], "@tailwindcss/postcss/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], diff --git a/packages/sim-cli/LICENSE b/packages/sim-cli/LICENSE new file mode 100644 index 00000000000..f4e76aaaac1 --- /dev/null +++ b/packages/sim-cli/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Sim Studio, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 18141268c0d..210577eec28 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -3,11 +3,18 @@ Talk to the [Sim](https://sim.ai) API from your terminal. ```bash -bun add --global @sim/cli +npm install --global @simai/cli sim login sim workflows list ``` +Prerelease channels track the corresponding Sim environments: + +```bash +npm install --global @simai/cli@preview # staging +npm install --global @simai/cli@dev # dev +``` + ## Profiles Profiles work like the AWS CLI: one identity and one set of defaults per named diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json index d65ac913894..432f0befb83 100644 --- a/packages/sim-cli/package.json +++ b/packages/sim-cli/package.json @@ -1,5 +1,5 @@ { - "name": "@sim/cli", + "name": "@simai/cli", "version": "0.1.0", "description": "Sim CLI - talk to the Sim API from your terminal", "type": "module", @@ -7,7 +7,9 @@ "sim": "dist/index.js" }, "scripts": { - "build": "tsc", + "prebuild": "bun run clean", + "build": "tsc --project tsconfig.build.json", + "clean": "bun -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"", "type-check": "tsc --noEmit", "lint": "biome check --write --unsafe .", "lint:check": "biome check .", @@ -28,6 +30,18 @@ ], "author": "Sim", "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/simstudioai/sim.git", + "directory": "packages/sim-cli" + }, + "homepage": "https://github.com/simstudioai/sim/tree/main/packages/sim-cli#readme", + "bugs": { + "url": "https://github.com/simstudioai/sim/issues" + }, + "publishConfig": { + "access": "public" + }, "engines": { "node": ">=20" }, diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 4fedd5db663..a29feb9e8aa 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { readFileSync } from 'node:fs' import chalk from 'chalk' import { Command, Option } from 'commander' import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth.js' @@ -12,10 +13,25 @@ import { buildGeneratedCommands } from './runtime/build.js' const program = new Command() +function readPackageVersion(): string { + const metadata: unknown = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8') + ) + if ( + typeof metadata !== 'object' || + metadata === null || + !('version' in metadata) || + typeof metadata.version !== 'string' + ) { + throw new Error('CLI package metadata is missing a valid version') + } + return metadata.version +} + program .name('sim') .description('Talk to the Sim API from your terminal') - .version('0.1.0') + .version(readPackageVersion()) .option('-P, --profile <name>', 'Profile to use (env: SIM_PROFILE)') .option('--endpoint <url>', 'Sim deployment to talk to (env: SIM_ENDPOINT)') .option('-w, --workspace <id>', 'Workspace to target (env: SIM_WORKSPACE)') diff --git a/packages/sim-cli/tsconfig.build.json b/packages/sim-cli/tsconfig.build.json new file mode 100644 index 00000000000..302f241b4d3 --- /dev/null +++ b/packages/sim-cli/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} From 35a7078a7daeaf4bb2cdc14c93bcd0b115c8b799 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 8 Aug 2026 14:26:22 -0700 Subject: [PATCH 108/159] fix(ci): include auth package in app prune --- apps/sim/package.json | 1 + bun.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/sim/package.json b/apps/sim/package.json index 01233e2f11c..45aa1a6a820 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -106,6 +106,7 @@ "@react-email/components": "1.0.12", "@react-email/render": "2.1.0", "@sim/audit": "workspace:*", + "@sim/auth": "workspace:*", "@sim/browser-protocol": "workspace:*", "@sim/desktop-bridge": "workspace:*", "@sim/emcn": "workspace:*", diff --git a/bun.lock b/bun.lock index c2ae2ec96e9..6846b3bee44 100644 --- a/bun.lock +++ b/bun.lock @@ -207,6 +207,7 @@ "@react-email/components": "1.0.12", "@react-email/render": "2.1.0", "@sim/audit": "workspace:*", + "@sim/auth": "workspace:*", "@sim/browser-protocol": "workspace:*", "@sim/desktop-bridge": "workspace:*", "@sim/emcn": "workspace:*", From 9d7264c63bbc21a2d0b57f37321284f51c60b757 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 8 Aug 2026 15:47:39 -0700 Subject: [PATCH 109/159] feat(cli): separate file descriptions from content --- apps/docs/openapi-v2-files-audit.json | 481 ++++++++---------- .../v2/files/[fileId]/content/route.test.ts | 67 ++- .../api/v2/files/[fileId]/content/route.ts | 30 +- .../v2/files/[fileId]/metadata/route.test.ts | 139 ----- .../api/v2/files/[fileId]/metadata/route.ts | 24 - .../app/api/v2/files/[fileId]/route.test.ts | 70 ++- apps/sim/app/api/v2/files/[fileId]/route.ts | 34 +- .../api/v2/files/[fileId]/share/route.test.ts | 158 +++--- .../app/api/v2/files/[fileId]/share/route.ts | 39 +- apps/sim/app/api/v2/files/utils.ts | 34 +- apps/sim/lib/api/contracts/v2/files.ts | 98 ++-- .../application/describe-workspace-file.ts | 43 ++ .../application/share-workspace-file.test.ts | 126 +++++ .../application/share-workspace-file.ts | 52 ++ packages/sim-cli/README.md | 8 +- ...les-download.test.ts => files-get.test.ts} | 20 +- .../{files-download.ts => files-get.ts} | 10 +- .../sim-cli/src/commands/protocol/index.ts | 4 +- packages/sim-cli/src/contract/commands.ts | 44 +- packages/sim-cli/src/generated/v2-api.ts | 223 ++++---- packages/sim-cli/src/runtime/build.test.ts | 60 +-- 21 files changed, 962 insertions(+), 802 deletions(-) delete mode 100644 apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts delete mode 100644 apps/sim/app/api/v2/files/[fileId]/metadata/route.ts create mode 100644 apps/sim/lib/workspace-files/application/describe-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/share-workspace-file.test.ts rename packages/sim-cli/src/commands/protocol/{files-download.test.ts => files-get.test.ts} (90%) rename packages/sim-cli/src/commands/protocol/{files-download.ts => files-get.ts} (92%) diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index a2925d24e50..7620106b4f6 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -467,16 +467,16 @@ }, "/api/v2/files/{fileId}": { "get": { - "operationId": "downloadFile", - "summary": "Download File", - "description": "Download the raw bytes of a file. The success response body is the file content itself — there is no JSON envelope. The actual `Content-Type` reflects the stored file's MIME type (shown here as `application/octet-stream`); `Content-Disposition` and `Content-Length` describe the attachment, and rate-limit state is returned in the `X-RateLimit-*` headers. Lookups are workspace-scoped: a file that belongs to another workspace returns `404`. Error responses still use the canonical v2 JSON error envelope.", + "operationId": "describeFile", + "summary": "Describe File", + "description": "Return one workspace file's metadata and current sharing state without downloading its content. Lookups are workspace-scoped: a file that belongs to another workspace returns `404`.", "tags": ["Files"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files/{fileId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -o downloaded-file.csv" + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files/{fileId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" } ], "parameters": [ @@ -489,26 +489,8 @@ ], "responses": { "200": { - "description": "The raw file content as binary data. The `Content-Type` header reflects the file's stored MIME type.", + "description": "The file metadata and sharing state.", "headers": { - "Content-Type": { - "description": "MIME type of the file. Varies per file; defaults to application/octet-stream when unknown.", - "schema": { - "type": "string" - } - }, - "Content-Disposition": { - "description": "Attachment disposition carrying the (sanitized and RFC 5987 encoded) filename.", - "schema": { - "type": "string" - } - }, - "Content-Length": { - "description": "Size of the file in bytes.", - "schema": { - "type": "string" - } - }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -520,10 +502,9 @@ } }, "content": { - "application/octet-stream": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/V2FileDescriptionResponse" } } } @@ -708,71 +689,6 @@ } } }, - "/api/v2/files/{fileId}/metadata": { - "get": { - "operationId": "getFile", - "summary": "Get File Metadata", - "description": "Return one workspace file's metadata without downloading its content. Lookups are workspace-scoped: a file that belongs to another workspace returns `404`.", - "tags": ["Files"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files/{fileId}/metadata?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/FileIdPath" - }, - { - "$ref": "#/components/parameters/WorkspaceIdQuery" - } - ], - "responses": { - "200": { - "description": "The file metadata.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2FileResponse" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, "/api/v2/audit-logs": { "get": { "operationId": "listAuditLogs", @@ -1152,95 +1068,17 @@ } }, "/api/v2/files/{fileId}/share": { - "get": { - "operationId": "getFileShare", - "summary": "Get File Share", - "description": "Read a file's public share state. `share` is `null` when the file has never been shared. The encrypted password is never returned — `hasPassword` is the only password signal.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, delete the file instead.", - "tags": ["Files"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X GET \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share?workspaceId=YOUR_WORKSPACE_ID\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\"" - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/FileIdPath" - }, - { - "$ref": "#/components/parameters/WorkspaceIdQuery" - } - ], - "responses": { - "200": { - "description": "The file's share state.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2GetFileShareResponse" - }, - "example": { - "data": { - "share": { - "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", - "token": "share-token-example", - "url": "https://www.sim.ai/f/share-token-example", - "isActive": true, - "resourceType": "file", - "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", - "authType": "public", - "hasPassword": false, - "allowedEmails": [] - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - }, "put": { - "operationId": "upsertFileShare", - "summary": "Enable or Disable File Share", - "description": "Enable or disable a file's public share. Requires workspace `write`.\n\nThe share token is always server-generated; there is no way to supply one. `authType` selects how the link is gated: `public` (anyone with the link), `password` (requires `password` on first enable), or `email` / `sso` (requires a non-empty `allowedEmails`). Omitting `authType` on a re-enable keeps the stored mode, and the org access-control policy is evaluated against that stored mode rather than against `public`. Disabling is never blocked by the policy.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, delete the file instead.", + "operationId": "shareFile", + "summary": "Share File", + "description": "Enable a file's public share or update its access settings. Requires workspace `write`. The share token is always server-generated. Omitting `authType` on a re-enable keeps the stored mode.", "tags": ["Files"], "x-codeSamples": [ { "id": "curl", "label": "cURL", "lang": "bash", - "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"isActive\": true, \"authType\": \"public\"}'" + "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"authType\": \"public\"}'" } ], "parameters": [ @@ -1254,17 +1092,13 @@ "application/json": { "schema": { "type": "object", - "required": ["workspaceId", "isActive"], + "required": ["workspaceId"], "properties": { "workspaceId": { "type": "string", "description": "The workspace that owns the file.", "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" }, - "isActive": { - "type": "boolean", - "description": "Whether the share should resolve. `false` disables without revoking." - }, "authType": { "type": "string", "enum": ["public", "password", "email", "sso"], @@ -1293,7 +1127,6 @@ "summary": "Enable a public link", "value": { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isActive": true, "authType": "public" } }, @@ -1301,17 +1134,9 @@ "summary": "Enable a password-protected link", "value": { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isActive": true, "authType": "password", "password": "EXAMPLE_PASSWORD" } - }, - "disable": { - "summary": "Disable (keeps the token and stored config)", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isActive": false - } } } } @@ -1319,7 +1144,7 @@ }, "responses": { "200": { - "description": "The share after the update.", + "description": "The enabled sharing state.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1334,17 +1159,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2UpsertFileShareResponse" + "$ref": "#/components/schemas/V2ShareFileResponse" }, "example": { "data": { - "share": { - "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", - "token": "share-token-example", + "sharing": { + "enabled": true, "url": "https://www.sim.ai/f/share-token-example", - "isActive": true, - "resourceType": "file", - "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", "authType": "public", "hasPassword": false, "allowedEmails": [] @@ -1373,9 +1194,133 @@ "$ref": "#/components/responses/InternalError" } } + }, + "delete": { + "operationId": "unshareFile", + "summary": "Unshare File", + "description": "Disable a file's public share while preserving its token and stored access settings for a future re-enable. The operation is idempotent.", + "tags": ["Files"], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "Sharing is disabled.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2UnshareFileResponse" + }, + "example": { + "data": { + "sharing": { + "enabled": false + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } } }, "/api/v2/files/{fileId}/content": { + "get": { + "operationId": "getFileContent", + "summary": "Get File Content", + "description": "Stream the raw bytes of a file. The success response is the content itself, without a JSON envelope. Errors still use the canonical v2 JSON envelope.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files/{fileId}/content?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -o downloaded-file.csv" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The raw file content as binary data.", + "headers": { + "Content-Type": { + "description": "The file's stored MIME type.", + "schema": { + "type": "string" + } + }, + "Content-Disposition": { + "description": "Attachment disposition carrying the filename.", + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the file in bytes.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, "put": { "operationId": "updateFileContent", "summary": "Replace File Content", @@ -2196,6 +2141,16 @@ } } }, + "V2FileDescriptionResponse": { + "type": "object", + "description": "A single file with its current sharing state.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2FileDescription" + } + } + }, "V2DeleteFileResponse": { "type": "object", "description": "The result of archiving a file.", @@ -2258,69 +2213,73 @@ } } }, - "V2FileShare": { + "V2DisabledFileSharing": { "type": "object", - "description": "A file's public share. Never carries the storage key or the encrypted password — `hasPassword` is the only password signal exposed.", - "required": [ - "id", - "token", - "url", - "isActive", - "resourceType", - "resourceId", - "authType", - "hasPassword", - "allowedEmails" - ], + "required": ["enabled"], "properties": { - "id": { - "type": "string", - "description": "Unique share identifier.", - "example": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb" - }, - "token": { - "type": "string", - "description": "The public token embedded in the share URL. Always server-generated.", - "example": "share-token-example" - }, - "url": { - "type": "string", - "format": "uri", - "description": "The public share URL.", - "example": "https://www.sim.ai/f/share-token-example" - }, - "isActive": { + "enabled": { "type": "boolean", - "description": "Whether the share currently resolves. Disabling does not revoke — see the endpoint description." - }, - "resourceType": { - "type": "string", - "enum": ["file", "folder"], - "description": "The kind of resource shared. Always `file` on this surface." + "const": false + } + } + }, + "V2EnabledFileSharing": { + "type": "object", + "required": ["enabled", "url", "authType", "hasPassword", "allowedEmails"], + "properties": { + "enabled": { + "type": "boolean", + "const": true }, - "resourceId": { + "url": { "type": "string", - "description": "The shared resource id.", - "example": "wf_V1StGXR8z5jdHi6BmyT91" + "format": "uri" }, "authType": { "type": "string", - "enum": ["public", "password", "email", "sso"], - "description": "How the share is gated." + "enum": ["public", "password", "email", "sso"] }, "hasPassword": { - "type": "boolean", - "description": "Whether a password is stored for this share." + "type": "boolean" }, "allowedEmails": { "type": "array", + "maxItems": 200, "items": { - "type": "string" - }, - "description": "Allow-list of addresses or `@domain` patterns for `email`/`sso` shares. Empty otherwise." + "type": "string", + "minLength": 1, + "maxLength": 320 + } } } }, + "V2FileSharing": { + "description": "The file's current sharing state. Disabled sharing carries no stale access details.", + "oneOf": [ + { + "$ref": "#/components/schemas/V2DisabledFileSharing" + }, + { + "$ref": "#/components/schemas/V2EnabledFileSharing" + } + ] + }, + "V2FileDescription": { + "allOf": [ + { + "$ref": "#/components/schemas/V2File" + }, + { + "type": "object", + "required": ["sharing"], + "properties": { + "sharing": { + "$ref": "#/components/schemas/V2FileSharing" + } + } + } + ] + }, "V2MoveFileItemsResult": { "type": "object", "description": "What the move actually relocated.", @@ -2337,31 +2296,23 @@ } } }, - "V2GetFileShareResult": { + "V2ShareFileResult": { "type": "object", - "description": "The file's share state, or null when the file has never been shared.", - "required": ["share"], + "description": "The enabled file sharing state.", + "required": ["sharing"], "properties": { - "share": { - "oneOf": [ - { - "$ref": "#/components/schemas/V2FileShare" - }, - { - "type": "null" - } - ], - "description": "The share, or null when the file has never been shared." + "sharing": { + "$ref": "#/components/schemas/V2EnabledFileSharing" } } }, - "V2UpsertFileShareResult": { + "V2UnshareFileResult": { "type": "object", - "description": "The share after the upsert.", - "required": ["share"], + "description": "The disabled file sharing state.", + "required": ["sharing"], "properties": { - "share": { - "$ref": "#/components/schemas/V2FileShare" + "sharing": { + "$ref": "#/components/schemas/V2DisabledFileSharing" } } }, @@ -2375,23 +2326,23 @@ } } }, - "V2GetFileShareResponse": { + "V2ShareFileResponse": { "type": "object", - "description": "The file's public share state.", + "description": "The enabled sharing state.", "required": ["data"], "properties": { "data": { - "$ref": "#/components/schemas/V2GetFileShareResult" + "$ref": "#/components/schemas/V2ShareFileResult" } } }, - "V2UpsertFileShareResponse": { + "V2UnshareFileResponse": { "type": "object", - "description": "The share after enabling or disabling it.", + "description": "The disabled sharing state.", "required": ["data"], "properties": { "data": { - "$ref": "#/components/schemas/V2UpsertFileShareResult" + "$ref": "#/components/schemas/V2UnshareFileResult" } } }, diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts index 989a7ef434b..54aced0423f 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ admit: vi.fn(), + download: vi.fn(), updateContent: vi.fn(), authenticateV2ApiKey: vi.fn(), checkRateLimitDirect: vi.fn(), @@ -13,6 +14,13 @@ const mocks = vi.hoisted(() => ({ getUserEmailsByIds: vi.fn(), })) +vi.mock('@/lib/workspace-files/application/download-workspace-file', () => ({ + downloadWorkspaceFileStream: { + operation: { id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.download, + }, +})) + vi.mock('@/lib/workspace-files/orchestration', () => ({ MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, })) @@ -48,7 +56,7 @@ vi.mock('@/lib/users/queries', () => ({ })) import { OrchestrationError } from '@/lib/core/orchestration/types' -import { PUT } from '@/app/api/v2/files/[fileId]/content/route' +import { GET, PUT } from '@/app/api/v2/files/[fileId]/content/route' const WORKSPACE_ID = 'workspace-1' const FILE_ID = 'wf_1' @@ -76,6 +84,15 @@ const record = { uploadedAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-03T00:00:00Z'), } +const context = { params: Promise.resolve({ fileId: FILE_ID }) } + +const callGet = () => + GET( + new NextRequest( + `http://localhost:3000/api/v2/files/${FILE_ID}/content?workspaceId=${WORKSPACE_ID}` + ), + context + ) const callPut = (body: unknown, contentLength?: number) => PUT( @@ -87,9 +104,55 @@ const callPut = (body: unknown, contentLength?: number) => }, body: typeof body === 'string' ? body : JSON.stringify(body), }), - { params: Promise.resolve({ fileId: FILE_ID }) } + context ) +describe('GET /api/v2/files/[fileId]/content', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2024-01-01T01:00:00Z'), + }) + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), + }) + mocks.download.mockResolvedValue({ + file: record, + stream: new Blob(['id,name\n']).stream(), + }) + }) + + it('streams bytes through the binary adapter', async () => { + const response = await callGet() + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/csv') + expect(response.headers.get('Content-Disposition')).toContain('data.csv') + expect(await response.text()).toBe('id,name\n') + expect(mocks.download).toHaveBeenCalledWith({ + principal: auth.principal, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request: expect.anything(), + }) + }) + + it('conceals content authorization failures', async () => { + mocks.download.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) + + const response = await callGet() + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + }) +}) + describe('PUT /api/v2/files/[fileId]/content', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts index c85c5251366..28830f40731 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -1,6 +1,12 @@ -import { v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files' -import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2GetFileContentContract, v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files' +import { + defineV2BinaryRoute, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file' import { fileOperations } from '@/lib/workspace-files/application/operations' import { admitUpdateWorkspaceFileContent, @@ -13,6 +19,26 @@ import { v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 +/** GET /api/v2/files/[fileId]/content — Stream a file's bytes. */ +export const GET = defineV2BinaryRoute({ + contract: v2GetFileContentContract, + auth: v2ApiKeyAuth, + operation: fileOperations.download, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: downloadWorkspaceFileStream, + present: ({ file, stream }) => ({ + body: stream, + contentType: file.type || 'application/octet-stream', + contentDisposition: `attachment; filename="${file.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(file.name)}`, + contentLength: file.size, + }), +}) + /** PUT /api/v2/files/[fileId]/content — Replace a file's bytes. */ export const PUT = defineV2JsonRoute({ contract: v2UpdateFileContentContract, diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts deleted file mode 100644 index e838e936fd0..00000000000 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - readMetadata: vi.fn(), - authenticateV2ApiKey: vi.fn(), - checkRateLimitDirect: vi.fn(), - checkRateLimitDirectOrThrow: vi.fn(), - getUserEmailsByIds: vi.fn(), -})) - -vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({ - readWorkspaceFileMetadata: { - operation: { id: 'files.read_metadata', minimumRole: 'read', workspaceApiKey: 'allow' }, - execute: mocks.readMetadata, - }, -})) - -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: vi.fn().mockResolvedValue(null), -})) - -vi.mock('@/lib/users/queries', () => ({ - getUserEmailsByIds: mocks.getUserEmailsByIds, - requireResolvedUserEmail: (emails: Map<string, string>, userId: string) => emails.get(userId)!, -})) - -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { GET } from '@/app/api/v2/files/[fileId]/metadata/route' - -const WORKSPACE_ID = 'workspace-1' -const FILE_ID = 'wf_1' -const context = { params: Promise.resolve({ fileId: FILE_ID }) } -const auth = { - principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' }, - rolloutUserId: 'billing-owner-1', - rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, - rateLimitSubscription: null, - keyType: 'workspace' as const, -} - -function buildRecord() { - return { - id: FILE_ID, - workspaceId: WORKSPACE_ID, - name: 'data.csv', - key: 'workspace/ws/1-x-data.csv', - path: '/api/files/serve/x', - size: 1024, - type: 'text/csv', - uploadedBy: 'user-1', - folderId: null, - folderPath: null, - uploadedAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - } -} - -const callGet = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/metadata?${query}`), context) - -describe('GET /api/v2/files/[fileId]/metadata', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.authenticateV2ApiKey.mockResolvedValue(auth) - mocks.checkRateLimitDirect.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2024-01-01T01:00:00Z'), - }) - mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), - }) - mocks.readMetadata.mockResolvedValue({ file: buildRecord() }) - mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) - }) - - it('authenticates and charges before rejecting a missing workspaceId', async () => { - const response = await callGet('') - - expect(response.status).toBe(400) - expect(mocks.authenticateV2ApiKey).toHaveBeenCalled() - expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) - expect(mocks.readMetadata).not.toHaveBeenCalled() - }) - - it('conceals an authorization failure as not found', async () => { - mocks.readMetadata.mockRejectedValue( - new OrchestrationError('forbidden', 'Insufficient workspace permissions') - ) - - const response = await callGet(`workspaceId=${WORKSPACE_ID}`) - - expect(response.status).toBe(404) - expect((await response.json()).error.code).toBe('NOT_FOUND') - }) - - it('returns the v2 metadata projection through the shared use case', async () => { - const response = await callGet(`workspaceId=${WORKSPACE_ID}`) - - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ - data: { - id: FILE_ID, - name: 'data.csv', - size: 1024, - type: 'text/csv', - key: 'workspace/ws/1-x-data.csv', - folderPath: '/', - uploadedByEmail: 'ada@example.com', - uploadedAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - }, - }) - expect(mocks.readMetadata).toHaveBeenCalledWith({ - principal: auth.principal, - input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, - request: expect.anything(), - }) - }) -}) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts deleted file mode 100644 index a76a4862229..00000000000 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { v2GetFileContract } from '@/lib/api/contracts/v2/files' -import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' -import { v2FileErrorPolicies } from '@/lib/workspace-files/api' -import { fileOperations } from '@/lib/workspace-files/application/operations' -import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' -import { toV2File } from '@/app/api/v2/files/utils' - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -/** GET /api/v2/files/[fileId]/metadata — Return file metadata without downloading its bytes. */ -export const GET = defineV2JsonRoute({ - contract: v2GetFileContract, - auth: v2ApiKeyAuth, - operation: fileOperations.readMetadata, - rateLimit: v2RateLimits.publicApi, - errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, - mapInput: ({ params, query }) => ({ - fileId: params.fileId, - assertedWorkspaceId: query.workspaceId, - }), - useCase: readWorkspaceFileMetadata, - present: async ({ file }) => ({ data: await toV2File(file) }), -}) 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 4168341cf70..938b72a3925 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -5,7 +5,7 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - download: vi.fn(), + describeFile: vi.fn(), rename: vi.fn(), deleteFile: vi.fn(), authenticateV2ApiKey: vi.fn(), @@ -14,10 +14,10 @@ const mocks = vi.hoisted(() => ({ getUserEmailsByIds: vi.fn(), })) -vi.mock('@/lib/workspace-files/application/download-workspace-file', () => ({ - downloadWorkspaceFileStream: { - operation: { id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow' }, - execute: mocks.download, +vi.mock('@/lib/workspace-files/application/describe-workspace-file', () => ({ + describeWorkspaceFile: { + operation: { id: 'files.read_metadata', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.describeFile, }, })) @@ -90,6 +90,18 @@ function fileRecord(overrides: Record<string, unknown> = {}) { } } +const SHARE = { + id: 'shr_1', + token: 'existing-token-abcd', + url: 'https://www.sim.ai/f/existing-token-abcd', + isActive: true, + resourceType: 'file' as const, + resourceId: FILE_ID, + authType: 'email' as const, + hasPassword: false, + allowedEmails: ['ada@example.com'], +} + describe('v2 single-file routes', () => { beforeEach(() => { vi.clearAllMocks() @@ -104,10 +116,7 @@ describe('v2 single-file routes', () => { remaining: 99, resetAt: new Date('2024-01-01T01:00:00Z'), }) - mocks.download.mockResolvedValue({ - file: fileRecord(), - stream: new Blob(['id,name\n']).stream(), - }) + mocks.describeFile.mockResolvedValue({ file: fileRecord(), share: SHARE }) mocks.rename.mockResolvedValue({ file: fileRecord({ name: 'renamed.csv' }) }) mocks.deleteFile.mockResolvedValue({ id: FILE_ID, @@ -117,26 +126,53 @@ describe('v2 single-file routes', () => { mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) - it('downloads bytes through the binary adapter with operation rate headers', async () => { + it('describes the file and its sharing state', async () => { const response = await GET( new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`), context ) expect(response.status).toBe(200) - expect(response.headers.get('Content-Type')).toBe('text/csv') - expect(response.headers.get('Content-Disposition')).toContain('data.csv') - expect(response.headers.get('X-RateLimit-Remaining')).toBe('99') - expect(await response.text()).toBe('id,name\n') - expect(mocks.download).toHaveBeenCalledWith({ + expect(await response.json()).toEqual({ + data: { + id: FILE_ID, + name: 'data.csv', + size: 8, + type: 'text/csv', + key: 'workspace/ws/1-x-data.csv', + folderPath: '/', + uploadedByEmail: 'ada@example.com', + uploadedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-03T00:00:00.000Z', + sharing: { + enabled: true, + url: SHARE.url, + authType: 'email', + hasPassword: false, + allowedEmails: ['ada@example.com'], + }, + }, + }) + expect(mocks.describeFile).toHaveBeenCalledWith({ principal: auth.principal, input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, request: expect.anything(), }) }) - it('conceals download authorization failures', async () => { - mocks.download.mockRejectedValue( + it('returns an explicit disabled sharing state', async () => { + mocks.describeFile.mockResolvedValueOnce({ file: fileRecord(), share: null }) + + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`), + context + ) + + expect((await response.json()).data.sharing).toEqual({ enabled: false }) + }) + + it('conceals description authorization failures', async () => { + mocks.describeFile.mockRejectedValue( new OrchestrationError('forbidden', 'Insufficient workspace permissions') ) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index 5db1ff967de..1ea2ac15534 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -1,47 +1,35 @@ import { v2DeleteFileContract, - v2DownloadFileContract, + v2DescribeFileContract, v2RenameFileContract, } from '@/lib/api/contracts/v2/files' -import { - defineV2BinaryRoute, - defineV2JsonRoute, - v2ApiKeyAuth, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' -import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file' +import { describeWorkspaceFile } from '@/lib/workspace-files/application/describe-workspace-file' import { fileOperations } from '@/lib/workspace-files/application/operations' import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' -import { toV2File } from '@/app/api/v2/files/utils' +import { toV2File, toV2FileSharing } from '@/app/api/v2/files/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 /** - * GET /api/v2/files/[fileId] — Download file content (binary). - * - * The response carries no JSON envelope, so rate-limit state is surfaced via - * `X-RateLimit-*` headers. Errors still render the canonical v2 JSON error body. - * Lookups are workspace-scoped (IDOR-safe): a file in another workspace 404s. + * GET /api/v2/files/[fileId] — Describe one file and its sharing state. */ -export const GET = defineV2BinaryRoute({ - contract: v2DownloadFileContract, +export const GET = defineV2JsonRoute({ + contract: v2DescribeFileContract, auth: v2ApiKeyAuth, - operation: fileOperations.download, + operation: fileOperations.readMetadata, rateLimit: v2RateLimits.publicApi, errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, mapInput: ({ params, query }) => ({ fileId: params.fileId, assertedWorkspaceId: query.workspaceId, }), - useCase: downloadWorkspaceFileStream, - present: ({ file, stream }) => ({ - body: stream, - contentType: file.type || 'application/octet-stream', - contentDisposition: `attachment; filename="${file.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(file.name)}`, - contentLength: file.size, + useCase: describeWorkspaceFile, + present: async ({ file, share }) => ({ + data: { ...(await toV2File(file)), sharing: toV2FileSharing(share) }, }), }) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts index 78123770c87..a1ab5da7df7 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts @@ -18,8 +18,8 @@ const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { preauthRate: vi.fn(), operationRate: vi.fn(), gate: vi.fn(), - getShare: vi.fn(), updateShare: vi.fn(), + unshare: vi.fn(), }, MockV2ApiKeyUnauthenticatedError, } @@ -55,18 +55,18 @@ vi.mock('@/lib/core/utils/request', () => ({ vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) vi.mock('@/lib/workspace-files/application/share-workspace-file', () => ({ - getWorkspaceFileShare: { - operation: { id: 'files.share.read', minimumRole: 'read', workspaceApiKey: 'allow' }, - execute: mocks.getShare, - }, updateWorkspaceFileShare: { operation: { id: 'files.share.update', minimumRole: 'write', workspaceApiKey: 'allow' }, execute: mocks.updateShare, }, + unshareWorkspaceFile: { + operation: { id: 'files.share.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.unshare, + }, })) import { OrchestrationError } from '@/lib/core/orchestration/types' -import { GET, PUT } from '@/app/api/v2/files/[fileId]/share/route' +import { DELETE, PUT } from '@/app/api/v2/files/[fileId]/share/route' const WORKSPACE_ID = 'workspace-1' const FILE_ID = 'wf_1' @@ -98,15 +98,6 @@ const SHARE = { } const context = { params: Promise.resolve({ fileId: FILE_ID }) } -function callGet(query = `workspaceId=${WORKSPACE_ID}`) { - return GET( - new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share?${query}`, { - headers: { 'x-api-key': 'key' }, - }), - context - ) -} - function callPut(body: unknown) { return PUT( new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share`, { @@ -118,77 +109,15 @@ function callPut(body: unknown) { ) } -describe('GET /api/v2/files/[fileId]/share', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(AUTH) - mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) - mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) - mocks.gate.mockResolvedValue(null) - mocks.getShare.mockResolvedValue({ share: SHARE }) - }) - - it('authenticates and rate-limits before parsing or executing', async () => { - mocks.authenticate.mockRejectedValueOnce( - new MockV2ApiKeyUnauthenticatedError('API key required') - ) - - const response = await callGet() - - expect(response.status).toBe(401) - expect(mocks.getShare).not.toHaveBeenCalled() - expect(mocks.operationRate).not.toHaveBeenCalled() - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2Error } = await import('@/app/api/v2/lib/response') - mocks.gate.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const response = await callGet() - - expect(response.status).toBe(404) - expect(mocks.getShare).not.toHaveBeenCalled() - }) - - it('validates the asserted workspace before executing the use case', async () => { - const response = await callGet('') - - expect(response.status).toBe(400) - expect(mocks.getShare).not.toHaveBeenCalled() - }) - - it('conceals authorization failures as not found', async () => { - mocks.getShare.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Access denied')) - - const response = await callGet() - const body = await response.json() - - expect(response.status).toBe(404) - expect(body.error.code).toBe('NOT_FOUND') - expect(mocks.getShare).toHaveBeenCalledWith({ - principal: PRINCIPAL, - input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, - request: expect.anything(), - }) - }) - - it('returns the share through the v2 envelope', async () => { - const response = await callGet() - - expect(response.status).toBe(200) - expect((await response.json()).data).toEqual({ share: SHARE }) - }) - - it('returns the rate-limit response when denied', async () => { - mocks.operationRate.mockResolvedValueOnce({ ...RATE_LIMIT_OK, allowed: false, remaining: 0 }) - - const response = await callGet() - - expect(response.status).toBe(429) - expect((await response.json()).error.code).toBe('RATE_LIMITED') - expect(mocks.getShare).not.toHaveBeenCalled() - }) -}) +function callDelete(query = `workspaceId=${WORKSPACE_ID}`) { + return DELETE( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share?${query}`, { + method: 'DELETE', + headers: { 'x-api-key': 'key' }, + }), + context + ) +} describe('PUT /api/v2/files/[fileId]/share', () => { beforeEach(() => { @@ -203,7 +132,6 @@ describe('PUT /api/v2/files/[fileId]/share', () => { it('rejects a caller-supplied token at the v2 boundary', async () => { const response = await callPut({ workspaceId: WORKSPACE_ID, - isActive: true, token: 'attacker-chosen-token', }) @@ -217,7 +145,7 @@ describe('PUT /api/v2/files/[fileId]/share', () => { new OrchestrationError('validation', 'Password is required for password-protected shares') ) - const response = await callPut({ workspaceId: WORKSPACE_ID, isActive: true }) + const response = await callPut({ workspaceId: WORKSPACE_ID }) const body = await response.json() expect(response.status).toBe(400) @@ -230,13 +158,20 @@ describe('PUT /api/v2/files/[fileId]/share', () => { it('passes the shared principal and canonical workspace assertion to the use case', async () => { const response = await callPut({ workspaceId: WORKSPACE_ID, - isActive: true, authType: 'password', password: 'hunter2hunter2', }) expect(response.status).toBe(200) - expect((await response.json()).data).toEqual({ share: SHARE }) + expect((await response.json()).data).toEqual({ + sharing: { + enabled: true, + url: SHARE.url, + authType: 'public', + hasPassword: false, + allowedEmails: [], + }, + }) expect(mocks.updateShare).toHaveBeenCalledWith({ principal: PRINCIPAL, input: { @@ -254,7 +189,7 @@ describe('PUT /api/v2/files/[fileId]/share', () => { it('conceals forbidden updates as not found', async () => { mocks.updateShare.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Access denied')) - const response = await callPut({ workspaceId: WORKSPACE_ID, isActive: true }) + const response = await callPut({ workspaceId: WORKSPACE_ID }) expect(response.status).toBe(404) expect((await response.json()).error.code).toBe('NOT_FOUND') @@ -263,10 +198,49 @@ describe('PUT /api/v2/files/[fileId]/share', () => { it('returns the rate-limit response when denied', async () => { mocks.operationRate.mockResolvedValueOnce({ ...RATE_LIMIT_OK, allowed: false, remaining: 0 }) - const response = await callPut({ workspaceId: WORKSPACE_ID, isActive: true }) + const response = await callPut({ workspaceId: WORKSPACE_ID }) expect(response.status).toBe(429) expect((await response.json()).error.code).toBe('RATE_LIMITED') expect(mocks.updateShare).not.toHaveBeenCalled() }) }) + +describe('DELETE /api/v2/files/[fileId]/share', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.unshare.mockResolvedValue({ share: { ...SHARE, isActive: false }, changed: true }) + }) + + it('disables sharing through the canonical workspace assertion', async () => { + const response = await callDelete() + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual({ sharing: { enabled: false } }) + expect(mocks.unshare).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request: expect.anything(), + }) + }) + + it('validates the workspace before executing', async () => { + const response = await callDelete('') + + expect(response.status).toBe(400) + expect(mocks.unshare).not.toHaveBeenCalled() + }) + + it('returns disabled when the file was already unshared', async () => { + mocks.unshare.mockResolvedValueOnce({ share: null, changed: false }) + + const response = await callDelete() + + expect(response.status).toBe(200) + expect((await response.json()).data.sharing).toEqual({ enabled: false }) + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.ts index 5b6e5a6a015..a0c910e2b85 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.ts @@ -1,31 +1,18 @@ -import { v2GetFileShareContract, v2UpsertFileShareContract } from '@/lib/api/contracts/v2/files' +import { v2ShareFileContract, v2UnshareFileContract } from '@/lib/api/contracts/v2/files' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { - getWorkspaceFileShare, + unshareWorkspaceFile, updateWorkspaceFileShare, } from '@/lib/workspace-files/application/share-workspace-file' +import { toV2DisabledFileSharing, toV2EnabledFileSharing } from '@/app/api/v2/files/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = defineV2JsonRoute({ - contract: v2GetFileShareContract, - auth: v2ApiKeyAuth, - operation: fileOperations.readShare, - rateLimit: v2RateLimits.publicApi, - errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, - mapInput: ({ params, query }) => ({ - fileId: params.fileId, - assertedWorkspaceId: query.workspaceId, - }), - useCase: getWorkspaceFileShare, - present: ({ share }) => ({ data: { share } }), -}) - export const PUT = defineV2JsonRoute({ - contract: v2UpsertFileShareContract, + contract: v2ShareFileContract, auth: v2ApiKeyAuth, operation: fileOperations.updateShare, rateLimit: v2RateLimits.publicApi, @@ -33,11 +20,25 @@ export const PUT = defineV2JsonRoute({ mapInput: ({ params, body }) => ({ fileId: params.fileId, assertedWorkspaceId: body.workspaceId, - isActive: body.isActive, + isActive: true, authType: body.authType, password: body.password, allowedEmails: body.allowedEmails, }), useCase: updateWorkspaceFileShare, - present: ({ share }) => ({ data: { share } }), + present: ({ share }) => ({ data: { sharing: toV2EnabledFileSharing(share) } }), +}) + +export const DELETE = defineV2JsonRoute({ + contract: v2UnshareFileContract, + auth: v2ApiKeyAuth, + operation: fileOperations.updateShare, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: unshareWorkspaceFile, + present: ({ share }) => ({ data: { sharing: toV2DisabledFileSharing(share) } }), }) diff --git a/apps/sim/app/api/v2/files/utils.ts b/apps/sim/app/api/v2/files/utils.ts index 2e11d23f03d..1e8006082aa 100644 --- a/apps/sim/app/api/v2/files/utils.ts +++ b/apps/sim/app/api/v2/files/utils.ts @@ -1,4 +1,10 @@ -import type { V2File } from '@/lib/api/contracts/v2/files' +import type { ShareRecord } from '@/lib/api/contracts/public-shares' +import type { + V2DisabledFileSharing, + V2EnabledFileSharing, + V2File, + V2FileSharing, +} from '@/lib/api/contracts/v2/files' import { buildFolderPath } from '@/lib/folders/paths' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { getUserEmailsByIds, requireResolvedUserEmail } from '@/lib/users/queries' @@ -45,3 +51,29 @@ export async function toV2Files(records: WorkspaceFileRecord[]): Promise<V2File[ serializeV2File(record, requireResolvedUserEmail(emailByUserId, record.uploadedBy)) ) } + +/** Projects the persisted share row into the stable public sharing state. */ +export function toV2FileSharing(share: ShareRecord | null): V2FileSharing { + if (!share?.isActive) return { enabled: false } + + return { + enabled: true, + url: share.url, + authType: share.authType, + hasPassword: share.hasPassword, + allowedEmails: share.allowedEmails, + } +} + +/** Projects a successful share mutation and rejects an inconsistent inactive result. */ +export function toV2EnabledFileSharing(share: ShareRecord): V2EnabledFileSharing { + const sharing = toV2FileSharing(share) + if (!sharing.enabled) throw new Error('Sharing a workspace file returned an inactive share') + return sharing +} + +/** Projects a successful unshare mutation and rejects an inconsistent active result. */ +export function toV2DisabledFileSharing(share: ShareRecord | null): V2DisabledFileSharing { + if (share?.isActive) throw new Error('Unsharing a workspace file returned an active share') + return { enabled: false } +} diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 607f582d695..ba3e5973edc 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -5,7 +5,7 @@ import { workspaceFileNameSchema, workspaceIdSchema, } from '@/lib/api/contracts/primitives' -import { shareAuthTypeSchema, shareRecordSchema } from '@/lib/api/contracts/public-shares' +import { shareAuthTypeSchema } from '@/lib/api/contracts/public-shares' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CreateFolderBodySchema, @@ -61,6 +61,32 @@ export const v2FileSchema = z.object({ export type V2File = z.output<typeof v2FileSchema> +export const v2DisabledFileSharingSchema = z.object({ enabled: z.literal(false) }) + +export const v2EnabledFileSharingSchema = z.object({ + enabled: z.literal(true), + url: z.string().url(), + authType: shareAuthTypeSchema, + hasPassword: z.boolean(), + allowedEmails: z.array(z.string().min(1).max(320)), +}) + +export const v2FileSharingSchema = z.discriminatedUnion('enabled', [ + v2DisabledFileSharingSchema, + v2EnabledFileSharingSchema, +]) + +export type V2DisabledFileSharing = z.output<typeof v2DisabledFileSharingSchema> +export type V2EnabledFileSharing = z.output<typeof v2EnabledFileSharingSchema> +export type V2FileSharing = z.output<typeof v2FileSharingSchema> + +/** A single file's canonical description, including its current sharing state. */ +export const v2FileDescriptionSchema = v2FileSchema.extend({ + sharing: v2FileSharingSchema, +}) + +export type V2FileDescription = z.output<typeof v2FileDescriptionSchema> + export const v2FileUploadParamsSchema = z.object({ uploadId: z.string().min(1) }) export type V2FileUploadParams = z.output<typeof v2FileUploadParamsSchema> @@ -259,28 +285,11 @@ export const v2DeleteFileFolderContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2DeleteFileFolderDataSchema) }, }) -/** - * Public share state. Reuses the internal {@link shareRecordSchema}, which is - * already public-safe — `hasPassword` is a boolean and neither the ciphertext - * nor the storage key is carried — with `url` tightened to a real URL. - */ -export const v2FileShareSchema = shareRecordSchema.extend({ - url: z.string().url(), -}) - -export type V2FileShare = z.output<typeof v2FileShareSchema> +export const v2ShareFileResultSchema = z.object({ sharing: v2EnabledFileSharingSchema }) +export const v2UnshareFileResultSchema = z.object({ sharing: v2DisabledFileSharingSchema }) -export const v2GetFileShareResultSchema = z.object({ - share: v2FileShareSchema.nullable(), -}) - -export type V2GetFileShareResult = z.output<typeof v2GetFileShareResultSchema> - -export const v2UpsertFileShareResultSchema = z.object({ - share: v2FileShareSchema, -}) - -export type V2UpsertFileShareResult = z.output<typeof v2UpsertFileShareResultSchema> +export type V2ShareFileResult = z.output<typeof v2ShareFileResultSchema> +export type V2UnshareFileResult = z.output<typeof v2UnshareFileResultSchema> /** * Share upsert body. The internal surface accepts a caller-supplied `token` so @@ -288,10 +297,9 @@ export type V2UpsertFileShareResult = z.output<typeof v2UpsertFileShareResultSch * let a caller mint predictable public URLs, and a token collision surfaces as * an unhandled unique-index violation. v2 tokens are always server-generated. */ -export const v2UpsertFileShareBodySchema = z +export const v2ShareFileBodySchema = z .object({ workspaceId: workspaceIdSchema, - isActive: z.boolean(), authType: shareAuthTypeSchema.optional(), password: z .string() @@ -305,7 +313,7 @@ export const v2UpsertFileShareBodySchema = z }) .strict() -export type V2UpsertFileShareBody = z.input<typeof v2UpsertFileShareBodySchema> +export type V2ShareFileBody = z.input<typeof v2ShareFileBodySchema> /** * Content replace body. `content` is the whole new body of the file — this is a @@ -386,24 +394,14 @@ export const v2CompleteFileUploadContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2FileUploadSchema) }, }) -export const v2DownloadFileContract = defineRouteContract({ +export const v2DescribeFileContract = defineRouteContract({ method: 'GET', path: '/api/v2/files/[fileId]', params: v2FileParamsSchema, query: v2FileWorkspaceQuerySchema, - response: { - mode: 'binary', - }, -}) - -export const v2GetFileContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/files/[fileId]/metadata', - params: v2FileParamsSchema, - query: v2FileWorkspaceQuerySchema, response: { mode: 'json', - schema: v2DataResponse(v2FileSchema), + schema: v2DataResponse(v2FileDescriptionSchema), }, }) @@ -450,25 +448,35 @@ export const v2BulkDeleteFilesContract = defineRouteContract({ }, }) -export const v2GetFileShareContract = defineRouteContract({ - method: 'GET', +export const v2ShareFileContract = defineRouteContract({ + method: 'PUT', path: '/api/v2/files/[fileId]/share', params: v2FileParamsSchema, - query: v2FileWorkspaceQuerySchema, + body: v2ShareFileBodySchema, response: { mode: 'json', - schema: v2DataResponse(v2GetFileShareResultSchema), + schema: v2DataResponse(v2ShareFileResultSchema), }, }) -export const v2UpsertFileShareContract = defineRouteContract({ - method: 'PUT', +export const v2UnshareFileContract = defineRouteContract({ + method: 'DELETE', path: '/api/v2/files/[fileId]/share', params: v2FileParamsSchema, - body: v2UpsertFileShareBodySchema, + query: v2FileWorkspaceQuerySchema, response: { mode: 'json', - schema: v2DataResponse(v2UpsertFileShareResultSchema), + schema: v2DataResponse(v2UnshareFileResultSchema), + }, +}) + +export const v2GetFileContentContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/[fileId]/content', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'binary', }, }) diff --git a/apps/sim/lib/workspace-files/application/describe-workspace-file.ts b/apps/sim/lib/workspace-files/application/describe-workspace-file.ts new file mode 100644 index 00000000000..2354740f282 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/describe-workspace-file.ts @@ -0,0 +1,43 @@ +import type { ShareRecord } from '@/lib/api/contracts/public-shares' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getShareForResource } from '@/lib/public-shares/share-manager' +import { + type ActiveWorkspaceFileContext, + getWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' + +export interface DescribeWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string +} + +export interface DescribeWorkspaceFileResult { + file: WorkspaceFileRecord + share: ShareRecord | null +} + +async function executeDescribeWorkspaceFile({ + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.readMetadata, + DescribeWorkspaceFileInput, + ActiveWorkspaceFileContext +>): Promise<DescribeWorkspaceFileResult> { + const [file, share] = await Promise.all([ + getWorkspaceFile(context.workspaceId, context.fileId, { throwOnError: true }), + getShareForResource('file', context.fileId), + ]) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { file, share } +} + +export const describeWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.readMetadata, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeDescribeWorkspaceFile, +}) diff --git a/apps/sim/lib/workspace-files/application/share-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/share-workspace-file.test.ts new file mode 100644 index 00000000000..9e526270a9b --- /dev/null +++ b/apps/sim/lib/workspace-files/application/share-workspace-file.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getFile: vi.fn(), + getShare: vi.fn(), + loadContext: vi.fn(), + recordAudit: vi.fn(), + resolvePermission: vi.fn(), + upsertShare: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_SHARED: 'FILE_SHARED', FILE_SHARE_DISABLED: 'FILE_SHARE_DISABLED' }, + AuditResourceType: { FILE: 'FILE' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/auth/principal', () => ({ + resolvePrincipalAttribution: () => ({ attributedUserId: 'user-1' }), + resolvePrincipalAuditAttribution: () => ({ + actorId: 'user-1', + actorName: null, + actor: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + }), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/public-shares/share-manager', () => ({ + getShareForResource: mocks.getShare, + ShareValidationError: class ShareValidationError extends Error {}, + upsertFileShare: mocks.upsertShare, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: mocks.getFile, + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + PublicFileSharingNotAllowedError: class PublicFileSharingNotAllowedError extends Error {}, + validatePublicFileSharing: vi.fn(), +})) + +import { unshareWorkspaceFile } from '@/lib/workspace-files/application/share-workspace-file' + +const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const file = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'report.pdf', +} + +const activeShare = { + id: 'share-1', + resourceType: 'file', + resourceId: 'file-1', + token: 'token-1', + url: 'https://sim.ai/f/token-1', + isActive: true, + authType: 'public', + hasPassword: false, + allowedEmails: [], +} + +describe('unshareWorkspaceFile application service', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.getFile.mockResolvedValue(file) + mocks.resolvePermission.mockResolvedValue('admin') + }) + + it('disables an active share and records the transition', async () => { + const disabledShare = { ...activeShare, isActive: false } + mocks.getShare.mockResolvedValue(activeShare) + mocks.upsertShare.mockResolvedValue(disabledShare) + + await expect( + unshareWorkspaceFile.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ share: disabledShare, changed: true }) + + expect(mocks.upsertShare).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + fileId: 'file-1', + userId: 'user-1', + isActive: false, + }) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'FILE_SHARE_DISABLED', + resourceId: 'file-1', + }) + ) + }) + + it('is idempotent without creating an inactive share or audit entry', async () => { + mocks.getShare.mockResolvedValue(null) + + await expect( + unshareWorkspaceFile.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ share: null, changed: false }) + + expect(mocks.upsertShare).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) +}) 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 599d3c3ed09..59bbe0d46b9 100644 --- a/apps/sim/lib/workspace-files/application/share-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/share-workspace-file.ts @@ -43,6 +43,16 @@ export interface UpdateWorkspaceFileShareResult { share: ShareRecord } +export interface UnshareWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string +} + +export interface UnshareWorkspaceFileResult { + share: ShareRecord | null + changed: boolean +} + export class WorkspaceFileShareNoopError extends Error { constructor() { super('Workspace file is not currently shared') @@ -124,3 +134,45 @@ export const updateWorkspaceFileShare = defineAuthorizedWorkspaceFileUseCase({ description: `${input.isActive ? 'Enabled' : 'Disabled'} public share for "${context.file.name}"`, }), }) + +export const unshareWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.updateShare, + async resolveContext({ input }: { input: UnshareWorkspaceFileInput }) { + const canonical = await resolveActiveWorkspaceFileContext(input) + const file = await getWorkspaceFile(canonical.workspaceId, canonical.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { ...canonical, file } + }, + async execute({ principal, context }): Promise<UnshareWorkspaceFileResult> { + const existingShare = await getShareForResource('file', context.fileId) + if (!existingShare?.isActive) return { share: existingShare, changed: false } + + const subjectUserId = resolvePrincipalAttribution(principal).attributedUserId + const share = await upsertFileShare({ + workspaceId: context.workspaceId, + fileId: context.fileId, + userId: subjectUserId, + isActive: false, + }) + if (!share) throw new Error('Disabling workspace file share returned no share') + + logger.info('Disabled share for workspace file', { + workspaceId: context.workspaceId, + fileId: context.fileId, + principalKind: principal.kind, + }) + return { share, changed: true } + }, + projectAudit: ({ context, result }) => + result.changed + ? { + action: AuditAction.FILE_SHARE_DISABLED, + resourceType: AuditResourceType.FILE, + resourceId: context.fileId, + resourceName: context.file.name, + description: `Disabled public share for "${context.file.name}"`, + } + : [], +}) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 0ac1a3ee753..91841adbf10 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -110,7 +110,7 @@ Settings → API keys. Plural resource names are canonical, but most plural top-level resource groups also accept their singular form: for example, `sim table list`, -`sim file download`, and `sim workflow get` are equivalent to their plural +`sim file get`, and `sim workflow get` are equivalent to their plural spellings. Chats deliberately keep separate names: `sim chat` is the terminal conversation, while `sim chats` manages saved chat resources. @@ -160,10 +160,12 @@ sim tables rows batch-delete <tableId> (--row <id>… | --filter <json>) --yes sim files ls [path] [--search <text>] [--limit <n>] sim files list [--folder <path>] -sim files get <fileId> +sim files describe <fileId> +sim files get <fileId> [-o <path|->] sim files create --name <name> [--folder <path>] [--content <value>] [--encoding utf-8|base64] sim files upload <path> [--name <name>] [--folder <path>] -sim files download <fileId> [-o <path|->] +sim files share <fileId> [--auth-type public|password|email|sso] +sim files unshare <fileId> sim files mv --file-ids <id>… [--to <path>] sim files batch-delete --file-ids <id>… --yes sim files delete <fileId> --yes diff --git a/packages/sim-cli/src/commands/protocol/files-download.test.ts b/packages/sim-cli/src/commands/protocol/files-get.test.ts similarity index 90% rename from packages/sim-cli/src/commands/protocol/files-download.test.ts rename to packages/sim-cli/src/commands/protocol/files-get.test.ts index 7dcda706b52..d2836da1445 100644 --- a/packages/sim-cli/src/commands/protocol/files-download.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { buildGeneratedCommands } from '../../runtime/build.js' -import { streamToFile } from './files-download.js' +import { streamToFile } from './files-get.js' import { attachProtocolCommands } from './index.js' const { output, requestRaw } = vi.hoisted(() => ({ @@ -87,29 +87,21 @@ describe('streamToFile', () => { ) }) -describe('files download', () => { +describe('files get', () => { it('prints a normalized machine-readable result', async () => { const target = join(dir, 'download.txt') requestRaw.mockResolvedValue(new Response('downloaded', { status: 200 })) const logged: string[] = [] vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) - await program().parseAsync([ - 'node', - 'sim', - 'file', - 'download', - 'file_1', - '--output-file', - target, - ]) + await program().parseAsync(['node', 'sim', 'file', 'get', 'file_1', '--output-file', target]) expect(JSON.parse(logged[0])).toEqual({ id: 'file_1', path: target, status: 'saved', }) - expect(requestRaw).toHaveBeenCalledWith('/api/v2/files/file_1', { + expect(requestRaw).toHaveBeenCalledWith('/api/v2/files/file_1/content', { method: 'GET', query: { workspaceId: 'ws_local' }, }) @@ -124,7 +116,7 @@ describe('files download', () => { }) const logged = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program().parseAsync(['node', 'sim', 'file', 'download', 'file_1', '-o', '-']) + await program().parseAsync(['node', 'sim', 'file', 'get', 'file_1', '-o', '-']) expect(Buffer.concat(chunks).toString('utf8')).toBe('downloaded') expect(logged).not.toHaveBeenCalled() @@ -132,7 +124,7 @@ describe('files download', () => { it('rejects overwrite semantics for stdout', async () => { await expect( - program().parseAsync(['node', 'sim', 'file', 'download', 'file_1', '-o', '-', '--force']) + program().parseAsync(['node', 'sim', 'file', 'get', 'file_1', '-o', '-', '--force']) ).rejects.toThrow(/--force cannot be used/) expect(requestRaw).not.toHaveBeenCalled() }) diff --git a/packages/sim-cli/src/commands/protocol/files-download.ts b/packages/sim-cli/src/commands/protocol/files-get.ts similarity index 92% rename from packages/sim-cli/src/commands/protocol/files-download.ts rename to packages/sim-cli/src/commands/protocol/files-get.ts index 9a4999c2184..b58b546cb7b 100644 --- a/packages/sim-cli/src/commands/protocol/files-download.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -65,10 +65,10 @@ export async function streamToStdout( } } -export function attachFileDownload(files: Command): void { +export function attachFileGet(files: Command): void { files - .command('download <fileId>') - .description('Download a file') + .command('get <fileId>') + .description('Get a file’s content') .option('-o, --output-file <path>', 'Where to write it (default: file name; -: stdout)') .option('--force', 'Overwrite the destination if it already exists') .action( @@ -83,13 +83,13 @@ export function attachFileDownload(files: Command): void { const { client, profile } = clientFrom(command) const workspaceId = client.requireWorkspace() - const operation = V2_OPERATIONS.downloadFile + const operation = V2_OPERATIONS.getFileContent const response = await client.requestRaw(resolvePath(operation.path, { fileId }), { method: operation.method, query: { workspaceId }, }) if (!response.body) { - throw new SimApiError('Download returned an empty response.', response.status) + throw new SimApiError('File content response was empty.', response.status) } if (options.outputFile === '-') { diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index 1d07aef5d84..fe92da21683 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -1,6 +1,6 @@ import { Command } from 'commander' import { chatCommand } from './chat.js' -import { attachFileDownload } from './files-download.js' +import { attachFileGet } from './files-get.js' import { attachFileUpload } from './files-upload.js' import { attachKnowledgeDocumentUpload } from './knowledge-document-upload.js' import { attachResourceDirectoryCommands } from './resource-directory.js' @@ -20,7 +20,7 @@ export function attachProtocolCommands(program: Command): void { const files = group(program, 'files') attachFileUpload(files) - attachFileDownload(files) + attachFileGet(files) attachResourceDirectoryCommands(files, { kind: 'file', resources: 'listFiles', diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index a36b89ea4c3..1ee49bc6b67 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -478,8 +478,7 @@ export const CLI_CONTRACT: CliContract = { // ─── The expanded files surface ─────────────────────────────────────────── // Every one of these derives badly. `/files/move` and `/files/bulk-delete` // are verbs sitting where the deriver expects a sub-resource, so it made them - // groups holding a lone `create`; and `GET /files/[id]/share` fetches one - // share, which the deriver read as a collection and named `list`. + // groups holding a lone `create`. bulkDeleteFiles: { // `batch-` for the bulk form, matching `tables rows batch-delete`. command: 'files batch-delete', @@ -489,9 +488,23 @@ export const CLI_CONTRACT: CliContract = { }, confirm: 'This deletes every listed file.', }, - getFile: { - command: 'files get', - describe: 'Show file metadata', + describeFile: { + command: 'files describe', + describe: 'Show file metadata and sharing status', + fields: [ + { header: 'id' }, + { header: 'name' }, + { header: 'size', format: 'bytes' }, + { header: 'type' }, + { header: 'folder', path: 'folderPath' }, + { header: 'uploaded by', path: 'uploadedByEmail' }, + { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + { header: 'shared', path: 'sharing.enabled', format: 'bool' }, + { header: 'share URL', path: 'sharing.url' }, + { header: 'share auth', path: 'sharing.authType' }, + { header: 'allowed emails', path: 'sharing.allowedEmails', format: 'count' }, + ], }, moveFileItems: { command: 'files move', @@ -518,16 +531,23 @@ export const CLI_CONTRACT: CliContract = { encoding: { choices: ['utf-8', 'base64'], describe: 'Content encoding' }, }, }, - getFileShare: { - command: 'files share get', - describe: 'Show a file’s share settings', - }, - upsertFileShare: { - command: 'files share set', - describe: 'Enable or disable sharing for a file', + shareFile: { + command: 'files share', + describe: 'Share a file or update its access settings', flags: { allowedEmails: { list: true }, }, + fields: [ + { header: 'shared', path: 'sharing.enabled', format: 'bool' }, + { header: 'URL', path: 'sharing.url' }, + { header: 'auth', path: 'sharing.authType' }, + { header: 'allowed emails', path: 'sharing.allowedEmails', format: 'count' }, + ], + }, + unshareFile: { + command: 'files unshare', + describe: 'Disable sharing for a file', + fields: [{ header: 'shared', path: 'sharing.enabled', format: 'bool' }], }, // ─── Resource-scoped, path-addressed folders ────────────────────────────── diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 29e47ba4b4e..30d0faa9e77 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -1661,6 +1661,11 @@ export type DeployWorkflowParams = { id: string } +export type DeployWorkflowBody = { + name?: string + description?: string | null +} + export type DeployWorkflowResponse = { data: { id: string @@ -1697,16 +1702,38 @@ export type DeployWorkflowResponse = { } /** `GET /api/v2/files/[fileId]` */ -export type DownloadFileParams = { +export type DescribeFileParams = { fileId: string } -export type DownloadFileQuery = { +export type DescribeFileQuery = { workspaceId: string } -/** Non-JSON response (`binary`). */ -export type DownloadFileResponse = never +export type DescribeFileResponse = { + data: { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedByEmail: string + uploadedAt: string + updatedAt: string + sharing: + | { + enabled: false + } + | { + enabled: true + url: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array<string> + } + } +} /** `POST /api/v2/workflows/[id]/execute` */ export type ExecuteWorkflowParams = { @@ -2011,53 +2038,17 @@ export type GetCustomToolResponse = { } } -/** `GET /api/v2/files/[fileId]/metadata` */ -export type GetFileParams = { - fileId: string -} - -export type GetFileQuery = { - workspaceId: string -} - -export type GetFileResponse = { - data: { - id: string - name: string - size: number - type: string - key: string - folderPath: string - uploadedByEmail: string - uploadedAt: string - updatedAt: string - } -} - -/** `GET /api/v2/files/[fileId]/share` */ -export type GetFileShareParams = { +/** `GET /api/v2/files/[fileId]/content` */ +export type GetFileContentParams = { fileId: string } -export type GetFileShareQuery = { +export type GetFileContentQuery = { workspaceId: string } -export type GetFileShareResponse = { - data: { - share: { - id: string - token: string - url: string - isActive: boolean - resourceType: 'file' | 'folder' - resourceId: string - authType: 'public' | 'password' | 'email' | 'sso' - hasPassword: boolean - allowedEmails: Array<string> - } | null - } -} +/** Non-JSON response (`binary`). */ +export type GetFileContentResponse = never /** `GET /api/v2/knowledge/[id]` */ export type GetKnowledgeBaseParams = { @@ -3548,6 +3539,10 @@ export type RollbackWorkflowParams = { id: string } +export type RollbackWorkflowBody = { + version?: number +} + export type RollbackWorkflowResponse = { data: { id: string @@ -3681,6 +3676,30 @@ export type SetSecretResponse = { } } +/** `PUT /api/v2/files/[fileId]/share` */ +export type ShareFileParams = { + fileId: string +} + +export type ShareFileBody = { + workspaceId: string + authType?: 'public' | 'password' | 'email' | 'sso' + password?: string + allowedEmails?: Array<string> +} + +export type ShareFileResponse = { + data: { + sharing: { + enabled: true + url: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array<string> + } + } +} + /** `GET /api/v2/tables/exports/[exportId]/download` */ export type TableExportDownloadParams = { exportId: string @@ -3737,6 +3756,23 @@ export type UndeployWorkflowResponse = { } } +/** `DELETE /api/v2/files/[fileId]/share` */ +export type UnshareFileParams = { + fileId: string +} + +export type UnshareFileQuery = { + workspaceId: string +} + +export type UnshareFileResponse = { + data: { + sharing: { + enabled: false + } + } +} + /** `PATCH /api/v2/custom-tools/[id]` */ export type UpdateCustomToolParams = { id: string @@ -4260,35 +4296,6 @@ export type UploadKnowledgeDocumentResponse = { } } -/** `PUT /api/v2/files/[fileId]/share` */ -export type UpsertFileShareParams = { - fileId: string -} - -export type UpsertFileShareBody = { - workspaceId: string - isActive: boolean - authType?: 'public' | 'password' | 'email' | 'sso' - password?: string - allowedEmails?: Array<string> -} - -export type UpsertFileShareResponse = { - data: { - share: { - id: string - token: string - url: string - isActive: boolean - resourceType: 'file' | 'folder' - resourceId: string - authType: 'public' | 'password' | 'email' | 'sso' - hasPassword: boolean - allowedEmails: Array<string> - } - } -} - /** `POST /api/v2/tables/[tableId]/rows/upsert` */ export type UpsertTableRowParams = { tableId: string @@ -4933,13 +4940,17 @@ export const V2_OPERATIONS = { pathParams: ['id'] as const, responseMode: 'json', summary: 'Deploy Workflow', + body: { + name: { kind: 'string' }, + description: { kind: 'string' }, + }, }, - downloadFile: { + describeFile: { method: 'GET', path: '/api/v2/files/[fileId]', pathParams: ['fileId'] as const, - responseMode: 'binary', - summary: 'Download File', + responseMode: 'json', + summary: 'Describe File', query: { workspaceId: { kind: 'string', required: true }, }, @@ -5023,22 +5034,12 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, - getFile: { - method: 'GET', - path: '/api/v2/files/[fileId]/metadata', - pathParams: ['fileId'] as const, - responseMode: 'json', - summary: 'Get File Metadata', - query: { - workspaceId: { kind: 'string', required: true }, - }, - }, - getFileShare: { + getFileContent: { method: 'GET', - path: '/api/v2/files/[fileId]/share', + path: '/api/v2/files/[fileId]/content', pathParams: ['fileId'] as const, - responseMode: 'json', - summary: 'Get File Share', + responseMode: 'binary', + summary: 'Get File Content', query: { workspaceId: { kind: 'string', required: true }, }, @@ -5737,6 +5738,9 @@ export const V2_OPERATIONS = { pathParams: ['id'] as const, responseMode: 'json', summary: 'Rollback Workflow', + body: { + version: { kind: 'integer' }, + }, }, runRowEnrichment: { method: 'POST', @@ -5791,6 +5795,19 @@ export const V2_OPERATIONS = { value: { kind: 'string', required: true }, }, }, + shareFile: { + method: 'PUT', + path: '/api/v2/files/[fileId]/share', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Share File', + body: { + workspaceId: { kind: 'string', required: true }, + authType: { kind: 'enum', values: ['public', 'password', 'email', 'sso'] as const }, + password: { kind: 'string' }, + allowedEmails: { kind: 'array' }, + }, + }, tableExportDownload: { method: 'GET', path: '/api/v2/tables/exports/[exportId]/download', @@ -5808,6 +5825,16 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Undeploy Workflow', }, + unshareFile: { + method: 'DELETE', + path: '/api/v2/files/[fileId]/share', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Unshare File', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, updateCustomTool: { method: 'PATCH', path: '/api/v2/custom-tools/[id]', @@ -5987,20 +6014,6 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, - upsertFileShare: { - method: 'PUT', - path: '/api/v2/files/[fileId]/share', - pathParams: ['fileId'] as const, - responseMode: 'json', - summary: 'Enable or Disable File Share', - body: { - workspaceId: { kind: 'string', required: true }, - isActive: { kind: 'boolean', required: true }, - authType: { kind: 'enum', values: ['public', 'password', 'email', 'sso'] as const }, - password: { kind: 'string' }, - allowedEmails: { kind: 'array' }, - }, - }, upsertTableRow: { method: 'POST', path: '/api/v2/tables/[tableId]/rows/upsert', diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 03a03f1cd2f..191e9136da6 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -348,12 +348,37 @@ describe('commands parsed through commander', () => { }) }) - it('exposes the v2 file metadata route as files get', async () => { - const [path, options] = await run(['file', 'get', 'file_1'], { data: { id: 'file_1' } }) - expect(path).toBe('/api/v2/files/file_1/metadata') + it('describes file metadata and sharing without fetching content', async () => { + const [path, options] = await run(['file', 'describe', 'file_1'], { + data: { id: 'file_1', sharing: { enabled: false } }, + }) + expect(path).toBe('/api/v2/files/file_1') expect(options.query).toEqual({ workspaceId: 'ws_local' }) }) + it('exposes sharing as direct file actions', async () => { + const [sharePath, shareOptions] = await run([ + 'file', + 'share', + 'file_1', + '--auth-type', + 'email', + '--allowed-emails', + 'ada@example.com', + ]) + expect(sharePath).toBe('/api/v2/files/file_1/share') + expect(shareOptions.body).toEqual({ + workspaceId: 'ws_local', + authType: 'email', + allowedEmails: ['ada@example.com'], + }) + + const [unsharePath, unshareOptions] = await run(['file', 'unshare', 'file_1']) + expect(unsharePath).toBe('/api/v2/files/file_1/share') + expect(unshareOptions.method).toBe('DELETE') + expect(unshareOptions.query).toEqual({ workspaceId: 'ws_local' }) + }) + it('moves space-separated file ids to a folder path', async () => { const [path, options] = await run([ 'file', @@ -1092,35 +1117,6 @@ describe('rows whose content sits in a wrapper', () => { }) describe('boolean flags', () => { - it('takes an explicit value when the field is required', async () => { - // As a presence-only flag this could only ever send `true`: `--is-active - // false` turned sharing ON and reported success, with the `false` dropped - // as a stray argument. - const [, options] = await run([ - 'files', - 'share', - 'set', - 'f_1', - '--is-active', - 'false', - '--auth-type', - 'public', - ]) - expect(options.body).toMatchObject({ isActive: false }) - - const [, on] = await run([ - 'files', - 'share', - 'set', - 'f_1', - '--is-active', - 'true', - '--auth-type', - 'public', - ]) - expect(on.body).toMatchObject({ isActive: true }) - }) - it('negates an optional boolean, which omitting it cannot do', async () => { // Omitting `enabled` means "leave it alone"; there was no way to say false, // so an MCP server could not be disabled or a folder unlocked. From d4c74648b02396d2e96e5a8c6c30d775d7e2d0d7 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <siddharthganesan@gmail.com> Date: Sat, 8 Aug 2026 16:02:09 -0700 Subject: [PATCH 110/159] feat(cli): add resumable async Sim Chat --- apps/docs/openapi-core.json | 277 +++++++- apps/sim/app/api/v2/chat/route.test.ts | 320 +++++++++- apps/sim/app/api/v2/chat/route.ts | 110 +++- .../api/v2/chat/runs/[runId]/route.test.ts | 157 +++++ .../sim/app/api/v2/chat/runs/[runId]/route.ts | 32 + apps/sim/app/api/v2/chat/runs/route.test.ts | 139 ++++ apps/sim/app/api/v2/chat/runs/route.ts | 47 ++ apps/sim/app/api/v2/chats/route.ts | 4 +- .../lib/api/contracts/v2/chat-runs.test.ts | 81 +++ apps/sim/lib/api/contracts/v2/chat-runs.ts | 89 +++ apps/sim/lib/api/contracts/v2/chat.test.ts | 12 + apps/sim/lib/api/contracts/v2/chat.ts | 20 +- apps/sim/lib/api/list-query.test.ts | 8 + apps/sim/lib/api/list-query.ts | 10 + .../lib/copilot/chat/api/run-presenters.ts | 13 + .../copilot/chat/api/run-route-policy.test.ts | 51 ++ .../lib/copilot/chat/api/run-route-policy.ts | 40 ++ .../lib/copilot/chat/application/errors.ts | 6 + .../copilot/chat/application/operations.ts | 18 + .../lib/copilot/chat/application/runs.test.ts | 420 ++++++++++++ apps/sim/lib/copilot/chat/application/runs.ts | 212 +++++++ .../copilot/chat/public-activity.test.ts} | 2 +- .../copilot/chat/public-activity.ts} | 5 +- apps/sim/lib/copilot/chat/public-runs.test.ts | 123 ++++ apps/sim/lib/copilot/chat/public-runs.ts | 138 ++++ .../lib/copilot/request/lifecycle/run.test.ts | 1 + apps/sim/lib/copilot/request/lifecycle/run.ts | 11 + packages/sim-cli/README.md | 89 ++- .../src/commands/protocol/chat.test.ts | 466 ++++++++++++-- .../sim-cli/src/commands/protocol/chat.ts | 598 +++++++++++++++--- .../sim-cli/src/commands/protocol/index.ts | 2 +- packages/sim-cli/src/contract/commands.ts | 24 + packages/sim-cli/src/contract/types.ts | 2 + packages/sim-cli/src/generated/v2-api.ts | 108 ++++ packages/sim-cli/src/index.ts | 6 +- packages/sim-cli/src/runtime/build.test.ts | 35 + packages/sim-cli/src/runtime/execute.ts | 9 +- scripts/check-api-validation-contracts.ts | 4 +- 38 files changed, 3484 insertions(+), 205 deletions(-) create mode 100644 apps/sim/app/api/v2/chat/runs/[runId]/route.test.ts create mode 100644 apps/sim/app/api/v2/chat/runs/[runId]/route.ts create mode 100644 apps/sim/app/api/v2/chat/runs/route.test.ts create mode 100644 apps/sim/app/api/v2/chat/runs/route.ts create mode 100644 apps/sim/lib/api/contracts/v2/chat-runs.test.ts create mode 100644 apps/sim/lib/api/contracts/v2/chat-runs.ts create mode 100644 apps/sim/lib/copilot/chat/api/run-presenters.ts create mode 100644 apps/sim/lib/copilot/chat/api/run-route-policy.test.ts create mode 100644 apps/sim/lib/copilot/chat/api/run-route-policy.ts create mode 100644 apps/sim/lib/copilot/chat/application/errors.ts create mode 100644 apps/sim/lib/copilot/chat/application/operations.ts create mode 100644 apps/sim/lib/copilot/chat/application/runs.test.ts create mode 100644 apps/sim/lib/copilot/chat/application/runs.ts rename apps/sim/{app/api/v2/chat/activity.test.ts => lib/copilot/chat/public-activity.test.ts} (99%) rename apps/sim/{app/api/v2/chat/activity.ts => lib/copilot/chat/public-activity.ts} (99%) create mode 100644 apps/sim/lib/copilot/chat/public-runs.test.ts create mode 100644 apps/sim/lib/copilot/chat/public-runs.ts diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index dfc07313e3d..8f9def2750a 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -1434,7 +1434,7 @@ "post": { "operationId": "chat", "summary": "Ask Sim Chat", - "description": "Chat with the Mothership agent for a workspace. Personal API keys use their owner's current workspace permission, integrations, credentials, environment context, and memory, and their conversations are synchronized with the Sim Home chat history. Shared workspace keys retain normal workspace capabilities but do not inherit a human owner's personal integrations, secrets, environment, memory, or private chat history. Set `readOnly` to select the subtractive, secretless workspace-query policy. Omit `continuationToken` for a one-shot or first interactive turn, then send the latest opaque token returned by the stream to continue the same conversation. Tokens are bound to the workspace, authorization principal, credential type, and read-only mode, and expire on a rolling 24-hour window; this chat POST never accepts a raw chat ID. The response is a Server-Sent Events stream. `text` events contain incremental assistant output and `complete` contains the authoritative final result. Comment frames are heartbeats and `data: [DONE]` closes a successful stream. The caller's Sim API key selects and authorizes the local workspace but is never forwarded to Mothership. Workspace keys use the workspace billing account as their system actor while local tool authorization remains bound to the key owner; personal keys use their owner. Inline attachments are base64-only: paths and URLs are not accepted or resolved.", + "description": "Chat with the Mothership agent for a workspace. Personal API keys use their owner's current workspace permission, integrations, credentials, environment context, and memory, and their conversations are synchronized with the Sim Home chat history. Shared workspace keys retain normal workspace capabilities but do not inherit a human owner's personal integrations, secrets, environment, memory, or private chat history. Set `readOnly` to select the subtractive, secretless workspace-query policy. Omit `continuationToken` for a one-shot or first interactive turn, then send the latest opaque token returned by the stream to continue the same conversation. Tokens are bound to the workspace, authorization principal, credential type, and read-only mode, and expire on a rolling 24-hour window; this chat POST never accepts a raw chat ID. Set `async: true` with a personal API key to keep an accepted persisted turn running after disconnect; its first accepted `session` event includes a durable `runId` that can be polled through the chat-run endpoints. `runId` is omitted for ordinary synchronous turns. Set `persistChat: false` to suppress persistence of a newly created chat; that mode cannot be asynchronous. The response is a Server-Sent Events stream. `text` events contain incremental assistant output and `complete` contains the authoritative final result. Comment frames are heartbeats and `data: [DONE]` closes a successful stream. The caller's Sim API key selects and authorizes the local workspace but is never forwarded to Mothership. Workspace keys use the workspace billing account as their system actor while local tool authorization remains bound to the key owner; personal keys use their owner. Inline attachments are base64-only: paths and URLs are not accepted or resolved.", "tags": ["Chat"], "security": [ { @@ -1472,6 +1472,16 @@ "default": false, "description": "Use the secretless, read-only workspace-query policy. The default keeps normal workspace capabilities; shared workspace credentials still exclude personal integrations, secrets, environment, and persistent memory." }, + "async": { + "type": "boolean", + "default": false, + "description": "Keep an accepted persisted turn running after the caller disconnects and return its durable run ID in the session event. Requires a personal API key and `persistChat: true`; poll the chat-run endpoints for progress." + }, + "persistChat": { + "type": "boolean", + "default": true, + "description": "Allow a new conversation to be persisted in the workspace chat list. Setting this to false suppresses new-chat persistence and cannot be combined with `async: true`; an existing persisted chat remains persisted when continued." + }, "attachments": { "type": "array", "maxItems": 5, @@ -1637,6 +1647,7 @@ "example": { "workspaceId": "ws_abc123", "prompt": "Summarize the attached notes and compare them with this workspace.", + "async": true, "attachments": [ { "name": "notes.md", @@ -1668,7 +1679,7 @@ "content": { "text/event-stream": { "schema": { "type": "string" }, - "example": "data: {\"type\":\"session\",\"continuationToken\":\"sim-v2-chat-v1.opaque.refreshed\",\"requestId\":\"req_123\",\"chatId\":\"80a47295-040e-46f9-9ea8-ad78eff3bcab\"}\n\ndata: {\"type\":\"text\",\"delta\":\"Two workflows...\"}\n\ndata: {\"type\":\"complete\",\"data\":{\"content\":\"Two workflows...\",\"continuationToken\":\"sim-v2-chat-v1.opaque.refreshed\",\"usage\":{\"prompt\":120,\"completion\":18,\"total\":138}}}\n\ndata: [DONE]\n\n" + "example": "data: {\"type\":\"session\",\"continuationToken\":\"sim-v2-chat-v1.opaque.refreshed\",\"requestId\":\"req_123\",\"chatId\":\"80a47295-040e-46f9-9ea8-ad78eff3bcab\",\"runId\":\"4bfa6f89-b746-43be-8246-bf1c69b58593\"}\n\ndata: {\"type\":\"text\",\"delta\":\"Two workflows...\"}\n\ndata: {\"type\":\"complete\",\"data\":{\"content\":\"Two workflows...\",\"continuationToken\":\"sim-v2-chat-v1.opaque.refreshed\",\"usage\":{\"prompt\":120,\"completion\":18,\"total\":138}}}\n\ndata: [DONE]\n\n" } } }, @@ -1705,6 +1716,186 @@ } } }, + "/api/v2/chat/runs": { + "get": { + "operationId": "listChatRuns", + "summary": "List Sim Chat Runs", + "description": "List a bounded page of the authenticated user's root Mothership runs for one workspace, newest first. This private history surface requires a personal API key. It returns durable run state and safe chat metadata only; stream IDs, continuation tokens, model reasoning, tool payloads, and errors are never exposed. Pass `nextCursor` back as `cursor` to load another page.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { "type": "string" }, + "description": "Workspace whose owned Sim Chat runs should be listed." + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "active", + "paused_waiting_for_tool", + "resuming", + "complete", + "error", + "cancelled" + ] + }, + "description": "Return only runs with this durable status." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 30 }, + "description": "Maximum runs to return." + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { "type": "string", "minLength": 1 }, + "description": "Opaque cursor returned by the previous page." + } + ], + "responses": { + "200": { + "description": "A bounded page of safe chat run summaries.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { "$ref": "#/components/schemas/V2ChatRunSummary" } + }, + "nextCursor": { "type": ["string", "null"] } + } + }, + "example": { + "data": [ + { + "runId": "4bfa6f89-b746-43be-8246-bf1c69b58593", + "chatId": "80a47295-040e-46f9-9ea8-ad78eff3bcab", + "chatTitle": "Review release workflow", + "status": "complete", + "startedAt": "2026-08-08T18:29:00.000Z", + "completedAt": "2026-08-08T18:30:00.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + } + }, + "/api/v2/chat/runs/{runId}": { + "get": { + "operationId": "getChatRun", + "summary": "Get Sim Chat Run", + "description": "Poll one owned root Mothership run. In addition to durable status and chat metadata, the response contains accumulated root-assistant text and chronological display-safe activity updates when complete replay is available. A terminal run falls back to its persisted assistant response after replay expires. Raw argument/result objects, model reasoning, upstream errors, stream IDs, and continuation tokens are never returned; activity labels may summarize the same user-visible target or operation shown in Sim Home. Runs outside the user, workspace, live Mothership chat, or root-run scope all return the same 404.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Run ID returned by Ask Sim Chat or List Sim Chat Runs." + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { "type": "string" }, + "description": "Workspace the run and its chat must belong to." + } + ], + "responses": { + "200": { + "description": "A safe snapshot of the chat run.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { "$ref": "#/components/schemas/V2ChatRunDetail" } + } + }, + "example": { + "data": { + "runId": "4bfa6f89-b746-43be-8246-bf1c69b58593", + "chatId": "80a47295-040e-46f9-9ea8-ad78eff3bcab", + "chatTitle": "Review release workflow", + "status": "active", + "startedAt": "2026-08-08T18:29:00.000Z", + "completedAt": null, + "response": "I reviewed the release workflow.", + "activities": [ + { + "kind": "tool", + "id": "tool-1", + "label": "Read workflow", + "state": "complete" + } + ] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "404": { + "$ref": "#/components/responses/V2NotFound" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + }, + "503": { + "$ref": "#/components/responses/V2ServiceUnavailable" + } + } + } + }, "/api/v2/billing/status": { "get": { "operationId": "getBillingStatus", @@ -2738,6 +2929,88 @@ } } }, + "V2ChatRunSummary": { + "type": "object", + "required": ["runId", "chatId", "chatTitle", "status", "startedAt", "completedAt"], + "properties": { + "runId": { "type": "string", "format": "uuid" }, + "chatId": { "type": "string", "format": "uuid" }, + "chatTitle": { "type": ["string", "null"] }, + "status": { + "type": "string", + "enum": [ + "active", + "paused_waiting_for_tool", + "resuming", + "complete", + "error", + "cancelled" + ] + }, + "startedAt": { "type": "string", "format": "date-time" }, + "completedAt": { "type": ["string", "null"], "format": "date-time" } + } + }, + "V2ChatRunActivity": { + "oneOf": [ + { + "type": "object", + "required": ["kind", "id", "label", "state"], + "properties": { + "kind": { "type": "string", "enum": ["subagent", "tool"] }, + "id": { "type": "string" }, + "parentId": { "type": "string" }, + "label": { "type": "string" }, + "state": { "type": "string", "enum": ["running", "complete", "error"] } + } + }, + { + "type": "object", + "required": ["kind", "parentId", "delta"], + "properties": { + "kind": { "type": "string", "const": "narration" }, + "parentId": { "type": "string" }, + "delta": { "type": "string" } + } + } + ] + }, + "V2ChatRunDetail": { + "type": "object", + "required": [ + "runId", + "chatId", + "chatTitle", + "status", + "startedAt", + "completedAt", + "response", + "activities" + ], + "properties": { + "runId": { "type": "string", "format": "uuid" }, + "chatId": { "type": "string", "format": "uuid" }, + "chatTitle": { "type": ["string", "null"] }, + "status": { + "type": "string", + "enum": [ + "active", + "paused_waiting_for_tool", + "resuming", + "complete", + "error", + "cancelled" + ] + }, + "startedAt": { "type": "string", "format": "date-time" }, + "completedAt": { "type": ["string", "null"], "format": "date-time" }, + "response": { "type": "string" }, + "activities": { + "type": "array", + "items": { "$ref": "#/components/schemas/V2ChatRunActivity" } + } + } + }, "V2Error": { "type": "object", "required": ["error"], diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts index 09fd941b55a..b53ded5b476 100644 --- a/apps/sim/app/api/v2/chat/route.test.ts +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -184,6 +184,14 @@ vi.mock('@/lib/core/config/env', () => ({ env: mockEnv })) vi.mock('@/lib/core/config/env-flags', () => mockEnvFlags) vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) +vi.mock('@/executor/utils/resolved-secret-trace-registry', () => ({ + ResolvedSecretTraceRegistry: class MockResolvedSecretTraceRegistry { + getModelEgressSnapshot() { + return { complete: true } + } + }, +})) + vi.mock('@sim/utils/id', () => ({ generateId: mockGenerateId })) import { MAX_V2_CHAT_BODY_BYTES } from '@/lib/api/contracts/v2/chat' @@ -299,6 +307,55 @@ describe('POST /api/v2/chat', () => { }) }) + /** + * The one-off CLI turn (`sim chat ask`) is a command, not a conversation the + * workspace accumulates, so it must leave nothing for the chat list or + * `sim chats list` to surface — matching the Mothership block, which mints + * its own conversation id and never writes a chat row. The turn still gets a + * chat id and a continuation token, so the conversation remains continuable. + */ + it('creates no chat row when the caller opts out of persistence', async () => { + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'What is here?', + persistChat: false, + }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(mockResolveOrCreateChat).not.toHaveBeenCalled() + expect(mockPersistCopilotUserMessage).not.toHaveBeenCalled() + expect(stream).toContain('"type":"complete"') + // No Sim-side row, so the token must not claim Sim persistence. + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( + expect.not.objectContaining({ persistence: 'sim' }) + ) + }) + + it('still persists the chat when the caller does not opt out', async () => { + await callChat({ workspaceId: 'workspace-1', prompt: 'What is here?' }) + expect(mockResolveOrCreateChat).toHaveBeenCalled() + }) + + it('requires persisted chat storage for asynchronous execution', async () => { + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'What is here?', + async: true, + persistChat: false, + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: { + code: 'BAD_REQUEST', + message: 'Asynchronous chat requires persistChat to be true', + }, + }) + expect(mockResolveOrCreateChat).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + it('streams a personal-key chat and bills its authenticated actor', async () => { const response = await callChat({ workspaceId: 'workspace-1', prompt: 'What is here?' }) const stream = await response.text() @@ -309,6 +366,7 @@ describe('POST /api/v2/chat', () => { expect(stream).toContain('"type":"session"') expect(stream).toContain('"continuationToken":"continuation-new"') expect(stream).toContain('"chatId":"chat-1"') + expect(stream).not.toContain('"runId":"run-1"') expect(stream).toContain('"delta":"Hello from Sim"') expect(stream).toContain('"type":"complete"') expect(stream).toContain('data: [DONE]') @@ -403,6 +461,17 @@ describe('POST /api/v2/chat', () => { workspaceId: 'workspace-1', }) ) + /** + * Title generation projects its input against the secret-trace registry and + * fails closed when none is supplied, so omitting this silently skips every + * title on this route — the failure is a missing log line, not an error. + * The registry must also report complete, or the projection is still unsafe. + */ + const titleParams = mockFireTitleGeneration.mock.calls[0]![0] as { + resolvedSecretTraceRegistry?: { getModelEgressSnapshot(): { complete: boolean } } + } + expect(titleParams.resolvedSecretTraceRegistry).toBeDefined() + expect(titleParams.resolvedSecretTraceRegistry!.getModelEgressSnapshot().complete).toBe(true) expect(mockPublisherClose).toHaveBeenCalledTimes(1) expect(mockScheduleBufferCleanup).toHaveBeenCalledWith('message-1') expect(mockScheduleFilePreviewSessionCleanup).toHaveBeenCalledWith('message-1') @@ -559,7 +628,7 @@ describe('POST /api/v2/chat', () => { }) }) - it('does not hold the Go leg on run-segment creation but waits before finalizing it', async () => { + it('does not hold a synchronous Go leg on run creation but waits before finalizing it', async () => { let resolveRunSegment!: () => void let resolveChat!: () => void mockCreateRunSegment.mockReturnValueOnce( @@ -593,7 +662,41 @@ describe('POST /api/v2/chat', () => { expect(mockFinalizeStream).toHaveBeenCalledTimes(1) }) - it('keeps a synced turn working when run-segment creation fails', async () => { + it('creates an asynchronous run durably before starting Go or exposing its session', async () => { + let resolveRunSegment!: () => void + mockCreateRunSegment.mockReturnValueOnce( + new Promise((resolve) => { + resolveRunSegment = () => resolve({ id: 'run-1' }) + }) + ) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Continue', + async: true, + }) + const reader = response.body!.getReader() + let firstReadSettled = false + const firstRead = reader.read().then((result) => { + firstReadSettled = true + return result + }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + expect(firstReadSettled).toBe(false) + + resolveRunSegment() + const first = await firstRead + expect(new TextDecoder().decode(first.value)).toContain('"runId":"run-1"') + while (!(await reader.read()).done) { + // Drain the completion so route cleanup can release its lease. + } + expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1) + expect(mockFinalizeStream).toHaveBeenCalledTimes(1) + }) + + it('keeps a synchronous synced turn working when run creation fails', async () => { mockCreateRunSegment.mockRejectedValueOnce(new Error('run table unavailable')) const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Continue' }) @@ -601,9 +704,28 @@ describe('POST /api/v2/chat', () => { expect(response.status).toBe(200) expect(stream).toContain('"type":"complete"') + expect(stream).not.toContain('"runId":"run-1"') + expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1) expect(mockFinalizeStream).toHaveBeenCalledTimes(1) }) + it('fails before acceptance when durable run creation fails', async () => { + mockCreateRunSegment.mockRejectedValueOnce(new Error('run table unavailable')) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Continue', + async: true, + }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(stream).not.toContain('"type":"session"') + expect(stream).toContain('"code":"INTERNAL_ERROR"') + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + expect(mockFinalizeStream).not.toHaveBeenCalled() + }) + it('surfaces a pre-acceptance failure without exposing a continuation token', async () => { mockRunWorkspaceChat.mockResolvedValueOnce({ success: false, @@ -681,6 +803,31 @@ describe('POST /api/v2/chat', () => { expect(mockPublishStatusChanged).not.toHaveBeenCalled() }) + it('rejects asynchronous continuation of a legacy Go-only chat', async () => { + mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ + valid: true, + chatId: 'private-chat-id', + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Tell me more', + continuationToken: 'continuation-old', + async: true, + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: { + code: 'BAD_REQUEST', + message: 'Asynchronous chat requires a persisted chat', + }, + }) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockCreateRunSegment).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + it('continues an existing persisted personal chat with UI replay enabled', async () => { mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ valid: true, @@ -1213,6 +1360,31 @@ describe('POST /api/v2/chat', () => { expect(mockPublishStatusChanged).not.toHaveBeenCalled() }) + it('requires a personal API key for asynchronous execution', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT, + keyType: 'workspace', + workspaceId: 'workspace-1', + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Summarize it', + async: true, + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Asynchronous chat requires a personal API key', + }, + }) + expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled() + expect(mockResolveOrCreateChat).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + it('routes a workspace-key abort by its owner while preserving the billing actor body', async () => { mockGenerateId .mockReset() @@ -1485,6 +1657,150 @@ describe('POST /api/v2/chat', () => { expect(mockCleanupAbortMarker).toHaveBeenCalledWith('message-1') }) + it('keeps an accepted asynchronous turn running after its reader disconnects', async () => { + let settle!: () => void + let lifecycleSignal: AbortSignal | undefined + let userStopSignal: AbortSignal | undefined + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + lifecycleSignal = input.abortSignal + userStopSignal = input.userStopSignal + input.onInitialStreamAccepted?.() + settle = () => + resolve({ + success: true, + content: 'Finished in the background', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'keep going', + async: true, + }) + const reader = response.body!.getReader() + const acceptedSession = new TextDecoder().decode((await reader.read()).value) + expect(acceptedSession).toContain('"runId":"run-1"') + + await reader.cancel('async_receipt_received') + await new Promise((resolve) => setImmediate(resolve)) + + expect(mockRequestExplicitStreamAbort).not.toHaveBeenCalled() + expect(lifecycleSignal?.aborted).toBe(false) + expect(userStopSignal?.aborted).toBe(false) + expect(mockReleasePendingChatStream).not.toHaveBeenCalled() + + settle() + await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) + expect(mockFinalizeStream).toHaveBeenCalledWith( + expect.objectContaining({ success: true, content: 'Finished in the background' }), + expect.any(Object), + 'run-1', + 'success', + 'request-1' + ) + }) + + it('does not classify an accepted asynchronous turn as cancelled when its request aborts', async () => { + const requestAbortController = new AbortController() + let settle!: () => void + let userStopSignal: AbortSignal | undefined + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + userStopSignal = input.userStopSignal + input.onInitialStreamAccepted?.() + settle = () => + resolve({ + success: true, + content: 'Finished after request disconnect', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + const request = new NextRequest('http://localhost:3000/api/v2/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'caller-platform-key', + }, + body: JSON.stringify({ + workspaceId: 'workspace-1', + prompt: 'keep going', + async: true, + }), + signal: requestAbortController.signal, + }) + + const response = await POST(request) + const reader = response.body!.getReader() + expect(new TextDecoder().decode((await reader.read()).value)).toContain('"runId":"run-1"') + + requestAbortController.abort() + await new Promise((resolve) => setImmediate(resolve)) + expect(mockRequestExplicitStreamAbort).not.toHaveBeenCalled() + expect(userStopSignal?.aborted).toBe(false) + + settle() + while (!(await reader.read()).done) { + // Drain the completion so route cleanup can release its lease. + } + await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) + expect(mockFinalizeStream).toHaveBeenCalledWith( + expect.objectContaining({ success: true, content: 'Finished after request disconnect' }), + expect.any(Object), + 'run-1', + 'success', + 'request-1' + ) + }) + + it('still stops an asynchronous turn when the reader disconnects before acceptance', async () => { + let settle!: () => void + let userStopSignal: AbortSignal | undefined + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + userStopSignal = input.userStopSignal + settle = () => + resolve({ + success: false, + cancelled: true, + content: '', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'keep going', + async: true, + }) + await vi.waitFor(() => expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1)) + await response.body!.cancel('pre_accept_disconnect') + + await vi.waitFor(() => expect(mockRequestExplicitStreamAbort).toHaveBeenCalledTimes(1)) + expect(userStopSignal?.aborted).toBe(true) + expect(userStopSignal?.reason).toBe('user_stop:abortActiveStream') + + settle() + await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) + expect(mockFinalizeStream).toHaveBeenCalledWith( + expect.objectContaining({ cancelled: true }), + expect.any(Object), + 'run-1', + 'cancelled', + 'request-1' + ) + }) + it('does not start a lifecycle when Stop wins before workspace chat begins', async () => { mockRegisterActiveStream.mockImplementationOnce( ( diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts index cb99ea7ab07..e4671df3fba 100644 --- a/apps/sim/app/api/v2/chat/route.ts +++ b/apps/sim/app/api/v2/chat/route.ts @@ -14,6 +14,7 @@ import { getAccessibleCopilotChatContinuationMetadata, resolveOrCreateChat, } from '@/lib/copilot/chat/lifecycle' +import { ChatActivityProjector, type V2ChatActivity } from '@/lib/copilot/chat/public-activity' import { buildCopilotTurnOnComplete, buildCopilotTurnOnError, @@ -62,7 +63,6 @@ import { isAuthDisabled } from '@/lib/core/config/env-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { ChatActivityProjector, type V2ChatActivity } from '@/app/api/v2/chat/activity' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { rateLimitHeaders, @@ -71,6 +71,7 @@ import { v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' export const maxDuration = 3600 export const runtime = 'nodejs' @@ -82,6 +83,22 @@ const HEARTBEAT_INTERVAL_MS = 15_000 const ATTACHMENT_ONLY_PROMPT = 'Please inspect the attached file(s).' const V2_CHAT_TITLE_MODEL = 'claude-opus-4-8' +/** + * An empty, complete secret-trace registry for title generation. + * + * `projectResolvedSecretModelContent` fails closed on a missing registry, so + * without one every title on this route is skipped. The empty registry is the + * accurate claim rather than a bypass: the title is generated from + * `effectivePrompt`, which is the request body's own `prompt` verbatim — this + * route runs no workflow and resolves no secrets into it, so there is nothing + * for the matcher to redact. Shared because it is immutable and the matcher + * cache is keyed on the instance. + * + * If this route ever resolves secrets into the prompt, thread that execution's + * real registry through here instead. + */ +const V2_CHAT_TITLE_SECRET_REGISTRY = new ResolvedSecretTraceRegistry([]) + interface SyncedChat { chat: { title?: string | null } | null isNewChat: boolean @@ -130,8 +147,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { : parsed.response } - const { workspaceId, prompt, continuationToken, readOnly, attachments, contexts } = - parsed.data.body + const { + workspaceId, + prompt, + continuationToken, + readOnly, + async: asyncRequested, + attachments, + contexts, + persistChat, + } = parsed.data.body const credentialType = rateLimit.keyType === 'workspace' ? 'workspace' : 'personal' const effectivePrompt = prompt.trim() ? prompt : ATTACHMENT_ONLY_PROMPT // DISABLE_AUTH produces an anonymous pseudo-personal principal. It is not @@ -155,6 +180,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } + if (asyncRequested && rateLimit.keyType !== 'personal') { + return v2Error('FORBIDDEN', 'Asynchronous chat requires a personal API key', { + headers: rateLimitHeaders(rateLimit), + }) + } + if (asyncRequested && !persistChat) { + return v2Error('BAD_REQUEST', 'Asynchronous chat requires persistChat to be true', { + headers: rateLimitHeaders(rateLimit), + }) + } + const continuation = continuationToken ? await verifyV2ChatContinuationToken(continuationToken, { workspaceId, @@ -203,6 +239,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } } + if (asyncRequested && continuation?.valid && !continuedSyncedChat) { + return v2Error('BAD_REQUEST', 'Asynchronous chat requires a persisted chat', { + headers: rateLimitHeaders(rateLimit), + }) + } const preparedAttachments = prepareV2ChatAttachments(attachments) if (!preparedAttachments.success) { @@ -236,7 +277,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (continuation?.valid) { chatId = continuation.chatId - } else if (shouldSyncChat) { + } else if (shouldSyncChat && persistChat) { + /* `persistChat: false` gates chat *creation* only. It deliberately does + not reach the continuation branch above: detaching a chat that is + already persisted would silently drop the rest of its transcript. */ const created = await resolveOrCreateChat({ userId: authenticatedUserId, workspaceId, @@ -321,7 +365,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let lifecycleStarted = false let abortRequested = false let allowExplicitAbort = true + let sessionAccepted = false let explicitAbortRequest: Promise<void> | undefined + const acceptedAsyncTurnIsDetached = () => asyncRequested && sessionAccepted + const requestAbortStopsLifecycle = () => + request.signal.aborted && !acceptedAsyncTurnIsDetached() const requestExplicitAbortOnce = () => { if (!lifecycleStarted || !allowExplicitAbort) return undefined @@ -346,12 +394,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } /** - * A disconnected public reader is an explicit stop request. Match the web - * UI's Stop path: stop Sim-side work, mark the detached Go execution, and - * keep draining the active Go leg so persistence settles before cleanup. - * The route owns the chat lease until that lifecycle has unwound. + * A normal disconnect is an explicit stop request. Once an asynchronous + * caller has received its durable session receipt, however, disconnect is + * passive and the route keeps draining the Go leg into persisted state. + * In either case the route owns the chat lease until lifecycle settlement. */ const abortLifecycle = () => { + if (acceptedAsyncTurnIsDetached()) return abortRequested = true requestExplicitAbortOnce() if (allowExplicitAbort && !userStopController.signal.aborted) { @@ -383,7 +432,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { for (const activity of activities) send({ type: 'activity', data: activity }) } - let sessionSent = false let pendingTitle = syncedChat?.chat?.title?.trim() || undefined let publishedTitle: string | undefined let replayFinalized = false @@ -392,20 +440,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const next = title.trim() if (!next) return pendingTitle = next - if (!sessionSent || next === publishedTitle) return - if (send({ type: 'session', chatId, title: next })) publishedTitle = next + if (!sessionAccepted || next === publishedTitle) return + if ( + send({ + type: 'session', + chatId, + ...(asyncRequested && runId ? { runId } : {}), + title: next, + }) + ) { + publishedTitle = next + } } const sendSession = () => { - if (sessionSent) return - sessionSent = true + if (sessionAccepted) return const sent = send({ type: 'session', continuationToken: refreshedContinuationToken, requestId, ...(syncedChat ? { chatId } : {}), + ...(asyncRequested && runId ? { runId } : {}), ...(pendingTitle ? { title: pendingTitle } : {}), }) - if (sent && pendingTitle) publishedTitle = pendingTitle + if (!sent) return + sessionAccepted = true + if (pendingTitle) publishedTitle = pendingTitle } heartbeatId = setInterval(() => { if (!cancelled && publicStreamOpen) { @@ -421,7 +480,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (replayPublisher && syncedChat && executionId && runId) { await Promise.all([resetBuffer(messageId), clearFilePreviewSessions(messageId)]) - runSegmentPromise = createRunSegment({ + const createRunSegmentPromise = createRunSegment({ id: runId, executionId, chatId, @@ -430,11 +489,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { streamId: messageId, model: null, requestContext: { requestId, source: 'v2_chat' }, - }).catch((error) => { - logger.warn(`[${requestId}] Failed to create v2 chat run segment`, { - error: getErrorMessage(error), - }) }) + runSegmentPromise = asyncRequested + ? createRunSegmentPromise + : createRunSegmentPromise.catch((error) => { + logger.warn(`[${requestId}] Failed to create v2 chat run segment`, { + error: getErrorMessage(error), + }) + }) + if (asyncRequested) await runSegmentPromise replayPublisher.publish({ type: MothershipStreamV1EventType.session, payload: { kind: MothershipStreamV1SessionKind.chat, chatId }, @@ -458,6 +521,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { workspaceId, billingAttribution, requestId, + resolvedSecretTraceRegistry: V2_CHAT_TITLE_SECRET_REGISTRY, publisher: { publish(event) { replayPublisher.publish(event) @@ -519,7 +583,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { : result.cancelled || lifecycleAbortController.signal.aborted || userStopController.signal.aborted || - request.signal.aborted + requestAbortStopsLifecycle() ? RequestTraceV1Outcome.cancelled : RequestTraceV1Outcome.error await finalizeStream(result, replayPublisher, runId, replayOutcome, requestId) @@ -540,13 +604,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return } - if (!sessionSent) { + if (!sessionAccepted) { throw new Error('Mothership did not acknowledge the initial chat stream') } if ( lifecycleAbortController.signal.aborted || userStopController.signal.aborted || - request.signal.aborted || + requestAbortStopsLifecycle() || result.cancelled ) { requestExplicitAbortOnce() @@ -589,7 +653,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const aborted = lifecycleAbortController.signal.aborted || userStopController.signal.aborted || - request.signal.aborted || + requestAbortStopsLifecycle() || isAbortError(error) const terminalResult: OrchestratorResult = { success: false, diff --git a/apps/sim/app/api/v2/chat/runs/[runId]/route.test.ts b/apps/sim/app/api/v2/chat/runs/[runId]/route.test.ts new file mode 100644 index 00000000000..c06c1512ad3 --- /dev/null +++ b/apps/sim/app/api/v2/chat/runs/[runId]/route.test.ts @@ -0,0 +1,157 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreauth: vi.fn(), + checkOperationRate: vi.fn(), + gate: vi.fn(), + readRun: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreauth + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) + +vi.mock('@/lib/copilot/chat/application/runs', () => ({ + readChatRun: { + operation: { id: 'chat.runs.read' }, + execute: mocks.readRun, + }, +})) + +import { ChatRunProgressUnavailableError } from '@/lib/copilot/chat/application/errors' +import { InsufficientWorkspacePermissionsError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET } from '@/app/api/v2/chat/runs/[runId]/route' + +const RUN_ID = '4bfa6f89-b746-43be-8246-bf1c69b58593' +const CHAT_ID = '80a47295-040e-46f9-9ea8-ad78eff3bcab' +const auth = { + principal: { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} +const run = { + runId: RUN_ID, + chatId: CHAT_ID, + chatTitle: 'Release plan', + streamId: 'stream-1', + status: 'active' as const, + startedAt: new Date('2026-08-08T12:00:00.000Z'), + completedAt: null, +} +const context = () => ({ params: Promise.resolve({ runId: RUN_ID }) }) + +function callDetail() { + return GET( + new NextRequest(`http://localhost:3000/api/v2/chat/runs/${RUN_ID}?workspaceId=workspace-1`), + context() + ) +} + +describe('GET /api/v2/chat/runs/[runId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkPreauth.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-08T13:00:00.000Z'), + }) + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-08T13:00:00.000Z'), + }) + mocks.readRun.mockResolvedValue({ + run, + status: 'active', + completedAt: null, + response: 'Working', + activities: [{ kind: 'tool', id: 'tool-1', label: 'Reading file', state: 'running' }], + }) + }) + + it('projects the authorized application result through the public contract', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/chat/runs/${RUN_ID}?workspaceId=workspace-1` + ) + const response = await GET(request, context()) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + runId: RUN_ID, + chatId: CHAT_ID, + chatTitle: 'Release plan', + status: 'active', + startedAt: '2026-08-08T12:00:00.000Z', + completedAt: null, + response: 'Working', + activities: [{ kind: 'tool', id: 'tool-1', label: 'Reading file', state: 'running' }], + }, + }) + expect(mocks.readRun).toHaveBeenCalledWith({ + principal: auth.principal, + input: { runId: RUN_ID, workspaceId: 'workspace-1' }, + request, + }) + }) + + it.each([ + new InsufficientWorkspacePermissionsError(), + new OrchestrationError('not_found', 'Workspace not found'), + new OrchestrationError('not_found', 'Chat run not found'), + ])('uniformly conceals inaccessible scoped runs', async (error) => { + mocks.readRun.mockRejectedValue(error) + + const response = await callDetail() + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Chat run not found' }, + }) + }) + + it('returns a retryable 503 for temporarily unavailable progress', async () => { + mocks.readRun.mockRejectedValue(new ChatRunProgressUnavailableError()) + + const response = await callDetail() + + expect(response.status).toBe(503) + expect((await response.json()).error.code).toBe('SERVICE_UNAVAILABLE') + }) + + it('does not disguise unexpected infrastructure failures as absence', async () => { + mocks.readRun.mockRejectedValue(new Error('database unavailable')) + + const response = await callDetail() + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/chat/runs/[runId]/route.ts b/apps/sim/app/api/v2/chat/runs/[runId]/route.ts new file mode 100644 index 00000000000..864fd284298 --- /dev/null +++ b/apps/sim/app/api/v2/chat/runs/[runId]/route.ts @@ -0,0 +1,32 @@ +import { v2GetChatRunContract } from '@/lib/api/contracts/v2/chat-runs' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { toPublicChatRunSummary } from '@/lib/copilot/chat/api/run-presenters' +import { v2ChatRunErrorPolicies } from '@/lib/copilot/chat/api/run-route-policy' +import { chatOperations } from '@/lib/copilot/chat/application/operations' +import { readChatRun } from '@/lib/copilot/chat/application/runs' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/chat/runs/[runId] — safe pollable run status and progress. */ +export const GET = defineV2JsonRoute({ + contract: v2GetChatRunContract, + auth: v2ApiKeyAuth, + operation: chatOperations.readRun, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ChatRunErrorPolicies.detail, + mapInput: ({ params, query }) => ({ + runId: params.runId, + workspaceId: query.workspaceId, + }), + useCase: readChatRun, + present: ({ run, status, completedAt, response, activities }) => ({ + data: { + ...toPublicChatRunSummary(run), + status, + completedAt: completedAt?.toISOString() ?? null, + response, + activities, + }, + }), +}) diff --git a/apps/sim/app/api/v2/chat/runs/route.test.ts b/apps/sim/app/api/v2/chat/runs/route.test.ts new file mode 100644 index 00000000000..5682ba00ba1 --- /dev/null +++ b/apps/sim/app/api/v2/chat/runs/route.test.ts @@ -0,0 +1,139 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + checkPreauth: vi.fn(), + checkOperationRate: vi.fn(), + gate: vi.fn(), + listRuns: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkPreauth + checkRateLimitDirectOrThrow = mocks.checkOperationRate + }, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) + +vi.mock('@/lib/copilot/chat/application/runs', () => ({ + listChatRuns: { + operation: { id: 'chat.runs.list' }, + execute: mocks.listRuns, + }, +})) + +import { PrincipalKindAuthorizationError } from '@/lib/core/application' +import { GET } from '@/app/api/v2/chat/runs/route' + +const RUN_ID = '4bfa6f89-b746-43be-8246-bf1c69b58593' +const CHAT_ID = '80a47295-040e-46f9-9ea8-ad78eff3bcab' +const auth = { + principal: { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} +const run = { + runId: RUN_ID, + chatId: CHAT_ID, + chatTitle: 'Release plan', + streamId: 'stream-1', + status: 'complete' as const, + startedAt: new Date('2026-08-08T12:00:00.000Z'), + completedAt: new Date('2026-08-08T12:01:00.000Z'), +} + +function callList(query = 'workspaceId=workspace-1') { + return GET(new NextRequest(`http://localhost:3000/api/v2/chat/runs?${query}`)) +} + +describe('GET /api/v2/chat/runs', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkPreauth.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-08T13:00:00.000Z'), + }) + mocks.checkOperationRate.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-08T13:00:00.000Z'), + }) + mocks.listRuns.mockResolvedValue({ rows: [], hasMore: false }) + }) + + it('routes validated list input through the semantic application operation', async () => { + mocks.listRuns.mockResolvedValue({ rows: [run], hasMore: true }) + const request = new NextRequest( + 'http://localhost:3000/api/v2/chat/runs?workspaceId=workspace-1&status=complete&limit=1' + ) + + const response = await GET(request) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual([ + { + runId: RUN_ID, + chatId: CHAT_ID, + chatTitle: 'Release plan', + status: 'complete', + startedAt: '2026-08-08T12:00:00.000Z', + completedAt: '2026-08-08T12:01:00.000Z', + }, + ]) + expect(body.nextCursor).toEqual(expect.any(String)) + expect(mocks.listRuns).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: 'workspace-1', + status: 'complete', + limit: 1, + cursorKeys: undefined, + }, + request, + }) + expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2) + }) + + it('rejects malformed cursors before application execution', async () => { + const response = await callList('workspaceId=workspace-1&cursor=not-a-cursor') + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toMatch(/cursor does not match/i) + expect(mocks.listRuns).not.toHaveBeenCalled() + }) + + it('renders the personal-key-only operation failure consistently', async () => { + mocks.listRuns.mockRejectedValue( + new PrincipalKindAuthorizationError('workspace_api_key', 'chat.runs.list') + ) + + const response = await callList() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { code: 'FORBIDDEN', message: 'Chat runs require a personal API key' }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/chat/runs/route.ts b/apps/sim/app/api/v2/chat/runs/route.ts new file mode 100644 index 00000000000..d58e894b44f --- /dev/null +++ b/apps/sim/app/api/v2/chat/runs/route.ts @@ -0,0 +1,47 @@ +import { v2ListChatRunsContract } from '@/lib/api/contracts/v2/chat-runs' +import { INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { toPublicChatRunSummary } from '@/lib/copilot/chat/api/run-presenters' +import { v2ChatRunErrorPolicies } from '@/lib/copilot/chat/api/run-route-policy' +import { chatOperations } from '@/lib/copilot/chat/application/operations' +import { listChatRuns } from '@/lib/copilot/chat/application/runs' +import { encodePublicChatRunCursor, PUBLIC_CHAT_RUN_SORT } from '@/lib/copilot/chat/public-runs' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { decodeSortedCursor, encodeSortedCursor } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +function decodeCursor(cursor: string | undefined) { + const decoded = decodeSortedCursor(cursor, PUBLIC_CHAT_RUN_SORT) + if (decoded.status === 'invalid') { + throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + } + return decoded.status === 'ok' ? decoded.keys : undefined +} + +/** GET /api/v2/chat/runs — list owned root Mothership chat runs. */ +export const GET = defineV2JsonRoute({ + contract: v2ListChatRunsContract, + auth: v2ApiKeyAuth, + operation: chatOperations.listRuns, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ChatRunErrorPolicies.default, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + status: query.status, + limit: query.limit, + cursorKeys: decodeCursor(query.cursor), + }), + useCase: listChatRuns, + present: ({ rows, hasMore }) => { + const last = rows.at(-1) + return { + data: rows.map(toPublicChatRunSummary), + nextCursor: + hasMore && last + ? encodeSortedCursor(PUBLIC_CHAT_RUN_SORT, encodePublicChatRunCursor(last)) + : null, + } + }, +}) diff --git a/apps/sim/app/api/v2/chats/route.ts b/apps/sim/app/api/v2/chats/route.ts index 667f5c788f4..f713db70161 100644 --- a/apps/sim/app/api/v2/chats/route.ts +++ b/apps/sim/app/api/v2/chats/route.ts @@ -12,8 +12,8 @@ import { listOrderBy, numberKey, searchFilter, - textKey, timestampKey, + uuidKey, } from '@/lib/api/list-query' import { parseRequest } from '@/lib/api/server' import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' @@ -50,7 +50,7 @@ const pinnedRank = sql<number>`case when ${copilotChats.pinned} then 1 else 0 en const CHAT_KEYS = [ numberKey<ChatRow>(pinnedRank, (row) => (row.pinned ? 1 : 0)), timestampKey<ChatRow>(copilotChats.updatedAt, (row) => row.updatedAt), - textKey<ChatRow>(copilotChats.id, (row) => row.id), + uuidKey<ChatRow>(copilotChats.id, (row) => row.id), ] /** GET /api/v2/chats — bounded personal chat history for the terminal picker. */ diff --git a/apps/sim/lib/api/contracts/v2/chat-runs.test.ts b/apps/sim/lib/api/contracts/v2/chat-runs.test.ts new file mode 100644 index 00000000000..ff9cc3f27bf --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/chat-runs.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { + v2ChatRunDetailSchema, + v2ChatRunParamsSchema, + v2GetChatRunQuerySchema, + v2ListChatRunsQuerySchema, +} from '@/lib/api/contracts/v2/chat-runs' + +describe('v2ListChatRunsQuerySchema', () => { + it('defaults and clamps its bounded page size', () => { + expect(v2ListChatRunsQuerySchema.parse({ workspaceId: 'workspace-1' }).limit).toBe(30) + expect(v2ListChatRunsQuerySchema.parse({ workspaceId: 'workspace-1', limit: '0' }).limit).toBe( + 1 + ) + expect( + v2ListChatRunsQuerySchema.parse({ workspaceId: 'workspace-1', limit: '999' }).limit + ).toBe(100) + }) + + it('accepts only durable run statuses and a non-empty cursor', () => { + expect( + v2ListChatRunsQuerySchema.parse({ workspaceId: 'workspace-1', status: 'resuming' }).status + ).toBe('resuming') + expect( + v2ListChatRunsQuerySchema.safeParse({ workspaceId: 'workspace-1', status: 'running' }).success + ).toBe(false) + expect( + v2ListChatRunsQuerySchema.safeParse({ workspaceId: 'workspace-1', cursor: '' }).success + ).toBe(false) + }) +}) + +describe('v2GetChatRunQuerySchema', () => { + it('rejects unknown query fields', () => { + expect( + v2GetChatRunQuerySchema.safeParse({ workspaceId: 'workspace-1', continuationToken: 'secret' }) + .success + ).toBe(false) + }) + + it('rejects a malformed run UUID before it reaches Postgres', () => { + expect(v2ChatRunParamsSchema.safeParse({ runId: 'not-a-uuid' }).success).toBe(false) + }) +}) + +describe('v2ChatRunDetailSchema', () => { + it('strips private replay fields from the public projection', () => { + const parsed = v2ChatRunDetailSchema.parse({ + runId: '4bfa6f89-b746-43be-8246-bf1c69b58593', + chatId: '80a47295-040e-46f9-9ea8-ad78eff3bcab', + chatTitle: 'Release plan', + status: 'complete', + startedAt: '2026-08-08T12:00:00.000Z', + completedAt: '2026-08-08T12:01:00.000Z', + response: 'Done', + activities: [ + { + kind: 'tool', + id: 'tool-1', + label: 'Read file', + state: 'complete', + arguments: { secret: 'private' }, + result: 'private', + }, + ], + continuationToken: 'private', + error: 'private', + }) + + expect(parsed).toEqual({ + runId: '4bfa6f89-b746-43be-8246-bf1c69b58593', + chatId: '80a47295-040e-46f9-9ea8-ad78eff3bcab', + chatTitle: 'Release plan', + status: 'complete', + startedAt: '2026-08-08T12:00:00.000Z', + completedAt: '2026-08-08T12:01:00.000Z', + response: 'Done', + activities: [{ kind: 'tool', id: 'tool-1', label: 'Read file', state: 'complete' }], + }) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/chat-runs.ts b/apps/sim/lib/api/contracts/v2/chat-runs.ts new file mode 100644 index 00000000000..1eaf364b0a4 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/chat-runs.ts @@ -0,0 +1,89 @@ +import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +export const v2ChatRunStatusSchema = z.enum([ + 'active', + 'paused_waiting_for_tool', + 'resuming', + 'complete', + 'error', + 'cancelled', +]) + +export type V2ChatRunStatus = z.output<typeof v2ChatRunStatusSchema> + +/** Safe, durable metadata for one root Mothership chat run. */ +export const v2ChatRunSummarySchema = z.object({ + runId: z.string().uuid(), + chatId: z.string().uuid(), + chatTitle: z.string().nullable(), + status: v2ChatRunStatusSchema, + startedAt: z.string().datetime(), + completedAt: z.string().datetime().nullable(), +}) + +export type V2ChatRunSummary = z.output<typeof v2ChatRunSummarySchema> + +const v2ChatRunActivityStateSchema = z.enum(['running', 'complete', 'error']) + +export const v2ChatRunActivitySchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.enum(['subagent', 'tool']), + id: z.string().min(1), + parentId: z.string().min(1).optional(), + label: z.string(), + state: v2ChatRunActivityStateSchema, + }), + z.object({ + kind: z.literal('narration'), + parentId: z.string().min(1), + delta: z.string(), + }), +]) + +export type V2ChatRunActivity = z.output<typeof v2ChatRunActivitySchema> + +export const v2ChatRunDetailSchema = v2ChatRunSummarySchema.extend({ + /** Accumulated root-assistant response; empty until public text is available. */ + response: z.string(), + /** Chronological, display-safe activity updates projected from replay. */ + activities: z.array(v2ChatRunActivitySchema), +}) + +export type V2ChatRunDetail = z.output<typeof v2ChatRunDetailSchema> + +export const v2ListChatRunsQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + status: v2ChatRunStatusSchema.optional(), + limit: z.coerce + .number() + .optional() + .default(30) + .transform((value) => Math.min(Math.max(1, Math.trunc(value)), 100)), + cursor: z.string().min(1).optional(), + }) + .strict() + +export type V2ListChatRunsQuery = z.output<typeof v2ListChatRunsQuerySchema> + +export const v2ChatRunParamsSchema = z.object({ runId: z.string().uuid() }).strict() + +export const v2GetChatRunQuerySchema = z.object({ workspaceId: workspaceIdSchema }).strict() + +export const v2ListChatRunsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/chat/runs', + query: v2ListChatRunsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2ChatRunSummarySchema) }, +}) + +export const v2GetChatRunContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/chat/runs/[runId]', + params: v2ChatRunParamsSchema, + query: v2GetChatRunQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2ChatRunDetailSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/chat.test.ts b/apps/sim/lib/api/contracts/v2/chat.test.ts index c72f89de194..81dbedc13b9 100644 --- a/apps/sim/lib/api/contracts/v2/chat.test.ts +++ b/apps/sim/lib/api/contracts/v2/chat.test.ts @@ -34,10 +34,22 @@ describe('v2ChatBodySchema', () => { prompt: 'Read this', continuationToken: 'opaque-token', readOnly: false, + async: false, + persistChat: true, attachments: [{ name: 'Notes.MD', mediaType: 'text/markdown', data: 'aGk=' }], }) }) + it('accepts explicit asynchronous execution', () => { + expect( + v2ChatBodySchema.parse({ + workspaceId: 'workspace-1', + prompt: 'Run this in the background', + async: true, + }).async + ).toBe(true) + }) + it('accepts only the identity-bearing contexts supported by public resource lists', () => { const contexts = [ { kind: 'workflow', workflowId: 'workflow-1', label: 'Release' }, diff --git a/apps/sim/lib/api/contracts/v2/chat.ts b/apps/sim/lib/api/contracts/v2/chat.ts index 65b9238cca8..14657ec7007 100644 --- a/apps/sim/lib/api/contracts/v2/chat.ts +++ b/apps/sim/lib/api/contracts/v2/chat.ts @@ -170,6 +170,20 @@ export const v2ChatBodySchema = z continuationToken: z.string().min(1).max(MAX_V2_CHAT_CONTINUATION_TOKEN_LENGTH).optional(), /** Normal Mothership is the default; this explicitly selects its read-only projection. */ readOnly: z.boolean().optional().default(false), + /** + * Keep an accepted persisted turn running after the caller disconnects. + * The route requires a personal API key and `persistChat: true` so it can + * return a durable run id that another client can follow. + */ + async: z.boolean().optional().default(false), + /** + * Whether this turn may create a chat that shows up in the workspace's chat + * list. `false` matches the Mothership block: the turn still gets a chat id + * and stays continuable through its token, but leaves no row behind for the + * UI or `sim chats list` to surface. Only suppresses *creating* a chat — a + * continuation token for an already-persisted chat keeps persisting. + */ + persistChat: z.boolean().optional().default(true), attachments: z.array(v2ChatAttachmentSchema).max(MAX_V2_CHAT_ATTACHMENTS).optional(), contexts: z.array(v2ChatContextSchema).max(MAX_V2_CHAT_CONTEXTS).optional(), }) @@ -189,8 +203,10 @@ export type V2ChatBody = z.input<typeof v2ChatBodySchema> * A normal workspace Mothership turn. Omit `continuationToken` for a one-shot * or the first interactive turn; pass the latest server-issued token to * continue the same private conversation. `readOnly` explicitly selects the - * secretless query projection. Successful responses are SSE so proxies stay - * alive during long agent turns and callers can cancel the run. + * secretless query projection. `async: true` keeps an accepted persisted turn + * running after disconnect. `persistChat: false` runs the turn without leaving + * a chat behind in the workspace's chat list. Successful responses are SSE so + * proxies stay alive during long agent turns and callers can cancel the run. */ export const v2ChatContract = defineRouteContract({ method: 'POST', diff --git a/apps/sim/lib/api/list-query.test.ts b/apps/sim/lib/api/list-query.test.ts index 0974e8673d0..1351651d87b 100644 --- a/apps/sim/lib/api/list-query.test.ts +++ b/apps/sim/lib/api/list-query.test.ts @@ -21,6 +21,7 @@ import { searchFilter, textKey, timestampKey, + uuidKey, } from '@/lib/api/list-query' const thing = pgTable('thing', { @@ -139,6 +140,13 @@ describe('cursor key value validation', () => { expect(sizeKey.bind(Number.POSITIVE_INFINITY)).toBeNull() expect(sizeKey.bind(12)).not.toBeNull() }) + + it('rejects a malformed UUID before it reaches a UUID column comparison', () => { + const id = uuidKey<Row>(thing.id, (row) => row.id) + + expect(id.bind('not-a-uuid')).toBeNull() + expect(id.bind('4bfa6f89-b746-43be-8246-bf1c69b58593')).not.toBeNull() + }) }) describe('encodeKeyset / keysetColumns', () => { diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts index 9d24d45f984..2f0526cf268 100644 --- a/apps/sim/lib/api/list-query.ts +++ b/apps/sim/lib/api/list-query.ts @@ -1,3 +1,4 @@ +import { isValidUuid } from '@sim/utils/id' import { and, asc, @@ -83,6 +84,15 @@ export function textKey<Row>(column: Column, read: (row: Row) => string): Keyset } } +/** A UUID key whose caller-controlled cursor value is validated before SQL binding. */ +export function uuidKey<Row>(column: Column, read: (row: Row) => string): KeysetKey<Row> { + return { + expr: column, + encode: read, + bind: (value) => (typeof value === 'string' && isValidUuid(value) ? sql`${value}` : null), + } +} + /** A numeric key — sizes, counts, manual positions. */ export function numberKey<Row>(column: SQLWrapper, read: (row: Row) => number): KeysetKey<Row> { return { diff --git a/apps/sim/lib/copilot/chat/api/run-presenters.ts b/apps/sim/lib/copilot/chat/api/run-presenters.ts new file mode 100644 index 00000000000..54b86534bb1 --- /dev/null +++ b/apps/sim/lib/copilot/chat/api/run-presenters.ts @@ -0,0 +1,13 @@ +import type { V2ChatRunSummary } from '@/lib/api/contracts/v2/chat-runs' +import type { PublicChatRunRow } from '@/lib/copilot/chat/public-runs' + +export function toPublicChatRunSummary(row: PublicChatRunRow): V2ChatRunSummary { + return { + runId: row.runId, + chatId: row.chatId, + chatTitle: row.chatTitle, + status: row.status, + startedAt: row.startedAt.toISOString(), + completedAt: row.completedAt?.toISOString() ?? null, + } +} diff --git a/apps/sim/lib/copilot/chat/api/run-route-policy.test.ts b/apps/sim/lib/copilot/chat/api/run-route-policy.test.ts new file mode 100644 index 00000000000..5e696525fa0 --- /dev/null +++ b/apps/sim/lib/copilot/chat/api/run-route-policy.test.ts @@ -0,0 +1,51 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { v2ChatRunErrorPolicies } from '@/lib/copilot/chat/api/run-route-policy' +import { ChatRunProgressUnavailableError } from '@/lib/copilot/chat/application/errors' +import { + InsufficientWorkspacePermissionsError, + PersonalApiKeysDisabledError, + PrincipalKindAuthorizationError, +} from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +describe('v2 chat run error policies', () => { + it('keeps the personal-key-only failure explicit', async () => { + const response = v2ChatRunErrorPolicies.default.render( + new PrincipalKindAuthorizationError('workspace_api_key', 'chat.runs.list') + ) + + expect(response?.status).toBe(403) + expect(await response?.json()).toEqual({ + error: { code: 'FORBIDDEN', message: 'Chat runs require a personal API key' }, + }) + }) + + it('conceals detail authorization and scoped misses as the same absence', async () => { + for (const error of [ + new InsufficientWorkspacePermissionsError(), + new OrchestrationError('not_found', 'Workspace not found'), + new OrchestrationError('not_found', 'Chat run not found'), + ]) { + const response = v2ChatRunErrorPolicies.detail.render(error) + expect(response?.status).toBe(404) + expect(await response?.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Chat run not found' }, + }) + } + }) + + it('preserves workspace personal-key policy failures', async () => { + const response = v2ChatRunErrorPolicies.detail.render(new PersonalApiKeysDisabledError()) + expect(response?.status).toBe(403) + }) + + it('maps transient replay unavailability without masking infrastructure failures', async () => { + expect( + v2ChatRunErrorPolicies.detail.render(new ChatRunProgressUnavailableError())?.status + ).toBe(503) + expect(v2ChatRunErrorPolicies.detail.render(new Error('redis unavailable'))).toBeNull() + }) +}) diff --git a/apps/sim/lib/copilot/chat/api/run-route-policy.ts b/apps/sim/lib/copilot/chat/api/run-route-policy.ts new file mode 100644 index 00000000000..9b4750f2b1e --- /dev/null +++ b/apps/sim/lib/copilot/chat/api/run-route-policy.ts @@ -0,0 +1,40 @@ +import type { V2ErrorPolicy } from '@/lib/api/server/routes' +import { ChatRunProgressUnavailableError } from '@/lib/copilot/chat/application/errors' +import { + InsufficientWorkspacePermissionsError, + PersonalApiKeysDisabledError, + PrincipalKindAuthorizationError, +} from '@/lib/core/application' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +const defaultPolicy = { + render(error) { + if (error instanceof PrincipalKindAuthorizationError) { + return v2Error('FORBIDDEN', 'Chat runs require a personal API key') + } + if (error instanceof ChatRunProgressUnavailableError) { + return v2Error('SERVICE_UNAVAILABLE', error.message) + } + return v2CaughtOrchestrationError(error) + }, +} satisfies V2ErrorPolicy + +export const v2ChatRunErrorPolicies = { + default: defaultPolicy, + detail: { + render(error) { + if ( + error instanceof PrincipalKindAuthorizationError || + error instanceof PersonalApiKeysDisabledError || + error instanceof ChatRunProgressUnavailableError + ) { + return defaultPolicy.render(error) + } + if (error instanceof InsufficientWorkspacePermissionsError) { + return v2Error('NOT_FOUND', 'Chat run not found') + } + const response = v2CaughtOrchestrationError(error) + return response?.status === 404 ? v2Error('NOT_FOUND', 'Chat run not found') : response + }, + } satisfies V2ErrorPolicy, +} as const diff --git a/apps/sim/lib/copilot/chat/application/errors.ts b/apps/sim/lib/copilot/chat/application/errors.ts new file mode 100644 index 00000000000..1e48beec978 --- /dev/null +++ b/apps/sim/lib/copilot/chat/application/errors.ts @@ -0,0 +1,6 @@ +export class ChatRunProgressUnavailableError extends Error { + constructor() { + super('Chat run progress is temporarily unavailable') + this.name = 'ChatRunProgressUnavailableError' + } +} diff --git a/apps/sim/lib/copilot/chat/application/operations.ts b/apps/sim/lib/copilot/chat/application/operations.ts new file mode 100644 index 00000000000..6f030410b51 --- /dev/null +++ b/apps/sim/lib/copilot/chat/application/operations.ts @@ -0,0 +1,18 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const PERSONAL_API_KEY_PRINCIPALS = ['personal_api_key'] as const + +export const chatOperations = { + listRuns: defineWorkspaceOperation({ + id: 'chat.runs.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: PERSONAL_API_KEY_PRINCIPALS, + }), + readRun: defineWorkspaceOperation({ + id: 'chat.runs.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: PERSONAL_API_KEY_PRINCIPALS, + }), +} as const diff --git a/apps/sim/lib/copilot/chat/application/runs.test.ts b/apps/sim/lib/copilot/chat/application/runs.test.ts new file mode 100644 index 00000000000..1e9f92e95cf --- /dev/null +++ b/apps/sim/lib/copilot/chat/application/runs.test.ts @@ -0,0 +1,420 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + listRuns: vi.fn(), + getRun: vi.fn(), + getPersistedResponse: vi.fn(), + readEvents: vi.fn(), + updateRunStatus: vi.fn(), + recordAudit: vi.fn(), + envFlags: { isAuthDisabled: false }, +})) + +vi.mock('@/lib/core/config/env-flags', () => mocks.envFlags) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/copilot/chat/public-runs', () => ({ + listPublicChatRuns: mocks.listRuns, + getPublicChatRun: mocks.getRun, + getPersistedPublicChatRunResponse: mocks.getPersistedResponse, +})) + +vi.mock('@/lib/copilot/request/session', () => ({ + readEvents: mocks.readEvents, + eventToStreamEvent: (event: { type: string; payload: unknown; scope?: unknown }) => ({ + type: event.type, + payload: event.payload, + ...(event.scope ? { scope: event.scope } : {}), + }), +})) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + updateRunStatus: mocks.updateRunStatus, +})) + +vi.mock('@/lib/copilot/headless/workspace-chat', () => ({ + publicChatUsageLimitMessage: (content: string) => + /^\s*<usage_upgrade>[\s\S]+<\/usage_upgrade>\s*$/.test(content) ? 'Usage limit exceeded' : null, +})) + +vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) + +import { ChatRunProgressUnavailableError } from '@/lib/copilot/chat/application/errors' +import { chatOperations } from '@/lib/copilot/chat/application/operations' +import { listChatRuns, readChatRun } from '@/lib/copilot/chat/application/runs' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const run = { + runId: '4bfa6f89-b746-43be-8246-bf1c69b58593', + chatId: '80a47295-040e-46f9-9ea8-ad78eff3bcab', + chatTitle: 'Release plan', + streamId: 'stream-1', + status: 'complete' as const, + startedAt: new Date('2026-08-08T12:00:00.000Z'), + completedAt: new Date('2026-08-08T12:01:00.000Z'), +} + +function envelope(seq: number, type: string, payload: Record<string, unknown>) { + return { + v: 1, + seq, + ts: `2026-08-08T12:00:0${seq}.000Z`, + stream: { streamId: 'stream-1' }, + type, + payload, + } +} + +describe('chat run application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.envFlags.isAuthDisabled = false + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.listRuns.mockResolvedValue({ status: 'ok', rows: [] }) + mocks.getRun.mockResolvedValue(run) + mocks.getPersistedResponse.mockResolvedValue(null) + mocks.readEvents.mockResolvedValue([ + envelope(1, 'session', { kind: 'chat', chatId: run.chatId }), + envelope(2, 'text', { channel: 'assistant', text: 'Done' }), + envelope(3, 'complete', { status: 'complete' }), + ]) + mocks.updateRunStatus.mockResolvedValue(null) + }) + + it('defines read-only personal-key operations', () => { + for (const operation of [chatOperations.listRuns, chatOperations.readRun]) { + expect(operation).toMatchObject({ + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['personal_api_key'], + }) + } + }) + + it('rejects workspace keys before canonical workspace or run loading', async () => { + await expect( + readChatRun.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + input: { runId: run.runId, workspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.getRun).not.toHaveBeenCalled() + }) + + it('enforces personal-key workspace policy before protected run reads', async () => { + mocks.loadWorkspace.mockResolvedValue({ ...workspaceContext, allowPersonalApiKeys: false }) + + await expect( + listChatRuns.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1', limit: 30 }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await expect( + readChatRun.execute({ + principal: personalPrincipal, + input: { runId: run.runId, workspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.listRuns).not.toHaveBeenCalled() + expect(mocks.getRun).not.toHaveBeenCalled() + }) + + it('does not treat the auth-disabled self-host principal as a real personal key', async () => { + mocks.envFlags.isAuthDisabled = true + mocks.loadWorkspace.mockResolvedValue({ ...workspaceContext, allowPersonalApiKeys: false }) + + await listChatRuns.execute({ + principal: { ...personalPrincipal, keyId: 'auth-disabled' }, + input: { workspaceId: 'workspace-1', limit: 30 }, + }) + + expect(mocks.resolvePermission).toHaveBeenCalled() + expect(mocks.listRuns).toHaveBeenCalled() + }) + + it('requires current personal-key permission before protected run reads', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + await expect( + listChatRuns.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1', limit: 30 }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await expect( + readChatRun.execute({ + principal: personalPrincipal, + input: { runId: run.runId, workspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.listRuns).not.toHaveBeenCalled() + expect(mocks.getRun).not.toHaveBeenCalled() + }) + + it('authorizes the personal key before listing its owned runs', async () => { + mocks.listRuns.mockResolvedValue({ status: 'ok', rows: [run, { ...run }] }) + + const result = await listChatRuns.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1', status: 'complete', limit: 1 }, + }) + + expect(mocks.resolvePermission).toHaveBeenCalled() + expect(mocks.listRuns).toHaveBeenCalledWith({ + userId: 'user-1', + workspaceId: 'workspace-1', + status: 'complete', + limit: 1, + cursorKeys: undefined, + }) + expect(result).toEqual({ rows: [run], hasMore: true }) + }) + + it('keeps malformed keyset cursors out of successful application results', async () => { + mocks.listRuns.mockResolvedValue({ status: 'invalid_cursor' }) + + await expect( + listChatRuns.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1', limit: 30, cursorKeys: ['bad'] }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) + + it('masks every scoped run miss as chat-run absence', async () => { + mocks.getRun.mockResolvedValue(null) + + await expect( + readChatRun.execute({ + principal: personalPrincipal, + input: { runId: run.runId, workspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Chat run not found' }) + }) + + it('returns safe accumulated text and repairs stale terminal status from replay', async () => { + mocks.getRun.mockResolvedValue({ ...run, status: 'active', completedAt: null }) + + const result = await readChatRun.execute({ + principal: personalPrincipal, + input: { runId: run.runId, workspaceId: 'workspace-1' }, + }) + + expect(result).toMatchObject({ + status: 'complete', + completedAt: new Date('2026-08-08T12:00:03.000Z'), + response: 'Done', + activities: [], + }) + expect(mocks.updateRunStatus).toHaveBeenCalledWith(run.runId, 'complete', { + completedAt: new Date('2026-08-08T12:00:03.000Z'), + }) + }) + + it('projects replay into safe root text and opaque activities', async () => { + mocks.readEvents.mockResolvedValue([ + envelope(1, 'session', { kind: 'chat', chatId: run.chatId }), + envelope(2, 'text', { channel: 'assistant', text: 'Done' }), + envelope(3, 'text', { channel: 'thinking', text: 'private chain of thought' }), + envelope(4, 'tool', { + toolCallId: 'private-tool-id', + toolName: 'read', + phase: 'call', + arguments: { path: 'files/private.txt', secret: 'private-argument' }, + executor: 'go', + mode: 'sync', + }), + envelope(5, 'tool', { + toolCallId: 'private-tool-id', + toolName: 'read', + phase: 'result', + status: 'success', + success: true, + output: { secret: 'private-result' }, + }), + envelope(6, 'error', { code: 'PRIVATE', message: 'private-error' }), + envelope(7, 'complete', { status: 'complete', reason: 'private-reason' }), + ]) + + const result = await readChatRun.execute({ + principal: personalPrincipal, + input: { runId: run.runId, workspaceId: 'workspace-1' }, + }) + const progress = { response: result.response, activities: result.activities } + const serialized = JSON.stringify(progress) + + expect(progress).toEqual({ + response: 'Done', + activities: [ + { kind: 'tool', id: 'tool-1', label: 'Reading private.txt', state: 'running' }, + { kind: 'tool', id: 'tool-1', label: 'Read private.txt', state: 'complete' }, + ], + }) + for (const privateValue of [ + 'private-tool-id', + 'files/private.txt', + 'private-argument', + 'private-result', + 'private-error', + 'private-reason', + 'private chain of thought', + ]) { + expect(serialized).not.toContain(privateValue) + } + }) + + it('uses persisted assistant prose after a terminal replay expires', async () => { + mocks.readEvents.mockResolvedValue([]) + mocks.getPersistedResponse.mockResolvedValue('Stored answer') + + await expect( + readChatRun.execute({ + principal: personalPrincipal, + input: { runId: run.runId, workspaceId: 'workspace-1' }, + }) + ).resolves.toMatchObject({ response: 'Stored answer', activities: [] }) + }) + + it('uses persisted assistant prose when terminal replay has no root text', async () => { + mocks.getPersistedResponse.mockResolvedValue('Stored answer') + mocks.readEvents.mockResolvedValue([ + envelope(1, 'session', { kind: 'chat', chatId: run.chatId }), + envelope(2, 'complete', { status: 'complete' }), + ]) + + await expect( + readChatRun.execute({ + principal: personalPrincipal, + input: { runId: run.runId, workspaceId: 'workspace-1' }, + }) + ).resolves.toMatchObject({ response: 'Stored answer' }) + }) + + it('falls back to persisted text when terminal replay has a sequence gap', async () => { + mocks.getPersistedResponse.mockResolvedValue('Complete stored answer') + mocks.readEvents.mockResolvedValue([ + envelope(1, 'text', { channel: 'assistant', text: 'Truncated ' }), + envelope(3, 'complete', { status: 'complete' }), + ]) + + await expect( + readChatRun.execute({ + principal: personalPrincipal, + input: { runId: run.runId, workspaceId: 'workspace-1' }, + }) + ).resolves.toMatchObject({ + status: 'complete', + response: 'Complete stored answer', + activities: [], + }) + }) + + it('uses replay completion metadata when stale durable terminal state disagrees', async () => { + mocks.getRun.mockResolvedValue({ + ...run, + status: 'error', + completedAt: new Date('2026-08-08T11:59:00.000Z'), + }) + + await expect( + readChatRun.execute({ + principal: personalPrincipal, + input: { runId: run.runId, workspaceId: 'workspace-1' }, + }) + ).resolves.toMatchObject({ + status: 'complete', + completedAt: new Date('2026-08-08T12:00:03.000Z'), + response: 'Done', + }) + expect(mocks.updateRunStatus).toHaveBeenCalledWith(run.runId, 'complete', { + completedAt: new Date('2026-08-08T12:00:03.000Z'), + }) + }) + + it('reports missing or gapped active replay as transient', async () => { + mocks.getRun.mockResolvedValue({ ...run, status: 'active', completedAt: null }) + mocks.readEvents.mockResolvedValue([]) + + await expect( + readChatRun.execute({ + principal: personalPrincipal, + input: { runId: run.runId, workspaceId: 'workspace-1' }, + }) + ).rejects.toBeInstanceOf(ChatRunProgressUnavailableError) + }) + + it('does not regress an active run when its replay is gapped', async () => { + mocks.getRun.mockResolvedValue({ ...run, status: 'active', completedAt: null }) + mocks.readEvents.mockResolvedValue([ + envelope(1, 'text', { channel: 'assistant', text: 'Partial' }), + envelope(3, 'text', { channel: 'assistant', text: ' answer' }), + ]) + + await expect( + readChatRun.execute({ + principal: personalPrincipal, + input: { runId: run.runId, workspaceId: 'workspace-1' }, + }) + ).rejects.toBeInstanceOf(ChatRunProgressUnavailableError) + expect(mocks.getPersistedResponse).not.toHaveBeenCalled() + }) + + it('reports replay-store failures as transient only while a run is active', async () => { + mocks.getRun.mockResolvedValue({ ...run, status: 'active', completedAt: null }) + mocks.readEvents.mockRejectedValue(new Error('redis unavailable')) + + await expect( + readChatRun.execute({ + principal: personalPrincipal, + input: { runId: run.runId, workspaceId: 'workspace-1' }, + }) + ).rejects.toBeInstanceOf(ChatRunProgressUnavailableError) + expect(mocks.getPersistedResponse).not.toHaveBeenCalled() + }) + + it('propagates unexpected run-store failures unchanged', async () => { + const failure = new Error('database unavailable') + mocks.getRun.mockRejectedValueOnce(failure) + + await expect( + readChatRun.execute({ + principal: personalPrincipal, + input: { runId: run.runId, workspaceId: 'workspace-1' }, + }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/copilot/chat/application/runs.ts b/apps/sim/lib/copilot/chat/application/runs.ts new file mode 100644 index 00000000000..1fbc4c4de7e --- /dev/null +++ b/apps/sim/lib/copilot/chat/application/runs.ts @@ -0,0 +1,212 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' +import { updateRunStatus } from '@/lib/copilot/async-runs/repository' +import { ChatRunProgressUnavailableError } from '@/lib/copilot/chat/application/errors' +import { chatOperations } from '@/lib/copilot/chat/application/operations' +import { ChatActivityProjector, type V2ChatActivity } from '@/lib/copilot/chat/public-activity' +import { + getPersistedPublicChatRunResponse, + getPublicChatRun, + listPublicChatRuns, + type PublicChatRunRow, +} from '@/lib/copilot/chat/public-runs' +import { + MothershipStreamV1CompletionStatus, + MothershipStreamV1EventType, + MothershipStreamV1TextChannel, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { publicChatUsageLimitMessage } from '@/lib/copilot/headless/workspace-chat' +import { eventToStreamEvent, readEvents } from '@/lib/copilot/request/session' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +const logger = createLogger('CopilotChatRunsApplication') +const TERMINAL_RUN_STATUSES = new Set<PublicChatRunRow['status']>([ + 'complete', + 'error', + 'cancelled', +]) + +async function loadWorkspaceContext(workspaceId: string) { + const context = await loadActiveWorkspaceApplicationContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + /** + * DISABLE_AUTH's synthetic principal is not a personal API key. Preserve the + * self-host policy while still requiring its current workspace permission. + */ + return isAuthDisabled ? { ...context, allowPersonalApiKeys: true } : context +} + +export interface ListChatRunsInput { + workspaceId: string + status?: PublicChatRunRow['status'] + limit: number + cursorKeys?: CursorKey[] +} + +export interface ListChatRunsResult { + rows: PublicChatRunRow[] + hasMore: boolean +} + +export const listChatRuns = defineAuthorizedWorkspaceUseCase({ + operation: chatOperations.listRuns, + resolveContext: ({ input }: { input: ListChatRunsInput }) => + loadWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + execute: async ({ principal, input, context }): Promise<ListChatRunsResult> => { + const result = await listPublicChatRuns({ + userId: principal.userId, + workspaceId: context.workspaceId, + status: input.status, + limit: input.limit, + cursorKeys: input.cursorKeys, + }) + if (result.status === 'invalid_cursor') { + throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + } + return { + rows: result.rows.slice(0, input.limit), + hasMore: result.rows.length > input.limit, + } + }, +}) + +interface PublicRunSnapshot { + response: string + activities: V2ChatActivity[] + replayStatus?: PublicChatRunRow['status'] + replayCompletedAt?: Date +} + +async function persistedFallback(run: PublicChatRunRow): Promise<string> { + const response = (await getPersistedPublicChatRunResponse(run.chatId, run.streamId)) ?? '' + return publicChatUsageLimitMessage(response) ? '' : response +} + +async function buildPublicRunSnapshot(run: PublicChatRunRow): Promise<PublicRunSnapshot | null> { + let envelopes + try { + envelopes = await readEvents(run.streamId, '0') + } catch (error) { + logger.warn('Failed to read chat run replay; using safe fallback', { + runId: run.runId, + error: getErrorMessage(error, 'Unknown error'), + }) + return TERMINAL_RUN_STATUSES.has(run.status) + ? { response: await persistedFallback(run), activities: [] } + : null + } + + if (envelopes.length === 0 || envelopes.some((envelope, index) => envelope.seq !== index + 1)) { + return TERMINAL_RUN_STATUSES.has(run.status) + ? { response: await persistedFallback(run), activities: [] } + : null + } + + const projector = new ChatActivityProjector() + const activities: V2ChatActivity[] = [] + const rootText: string[] = [] + let completionStatus: 'complete' | 'error' | undefined + let replayStatus: PublicChatRunRow['status'] | undefined + let replayCompletedAt: Date | undefined + + for (const envelope of envelopes) { + const event = eventToStreamEvent(envelope) + activities.push(...projector.project(event)) + + if ( + event.type === MothershipStreamV1EventType.text && + event.payload.channel === MothershipStreamV1TextChannel.assistant && + !event.scope && + event.payload.text && + !publicChatUsageLimitMessage(event.payload.text) + ) { + rootText.push(event.payload.text) + } + + if (event.type === MothershipStreamV1EventType.complete && !event.scope) { + replayStatus = + event.payload.status === MothershipStreamV1CompletionStatus.complete + ? 'complete' + : event.payload.status === MothershipStreamV1CompletionStatus.cancelled + ? 'cancelled' + : 'error' + replayCompletedAt = new Date(envelope.ts) + completionStatus = replayStatus === 'complete' ? 'complete' : 'error' + } + } + + if (!completionStatus && TERMINAL_RUN_STATUSES.has(run.status)) { + completionStatus = run.status === 'complete' ? 'complete' : 'error' + } + if (completionStatus) activities.push(...projector.finish(completionStatus)) + + const accumulated = rootText.join('') + const response = + accumulated.length === 0 && (replayStatus || TERMINAL_RUN_STATUSES.has(run.status)) + ? await persistedFallback(run) + : accumulated + return { + response: publicChatUsageLimitMessage(response) ? '' : response, + activities, + ...(replayStatus ? { replayStatus, replayCompletedAt } : {}), + } +} + +export interface ReadChatRunInput { + runId: string + workspaceId: string +} + +export interface ReadChatRunResult { + run: PublicChatRunRow + status: PublicChatRunRow['status'] + completedAt: Date | null + response: string + activities: V2ChatActivity[] +} + +export const readChatRun = defineAuthorizedWorkspaceUseCase({ + operation: chatOperations.readRun, + resolveContext: ({ input }: { input: ReadChatRunInput }) => + loadWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + execute: async ({ principal, input, context }): Promise<ReadChatRunResult> => { + const run = await getPublicChatRun({ + runId: input.runId, + userId: principal.userId, + workspaceId: context.workspaceId, + }) + if (!run) throw new OrchestrationError('not_found', 'Chat run not found') + + const snapshot = await buildPublicRunSnapshot(run) + if (!snapshot) throw new ChatRunProgressUnavailableError() + + const status = snapshot.replayStatus ?? run.status + const completedAt = snapshot.replayCompletedAt ?? run.completedAt + if (snapshot.replayStatus && (run.status !== snapshot.replayStatus || !run.completedAt)) { + try { + await updateRunStatus(run.runId, snapshot.replayStatus, { + completedAt: snapshot.replayCompletedAt ?? new Date(), + }) + } catch (error) { + logger.warn('Failed to reconcile chat run status from terminal replay', { + runId: run.runId, + error: getErrorMessage(error, 'Unknown error'), + }) + } + } + + return { + run, + status, + completedAt, + response: snapshot.response, + activities: snapshot.activities, + } + }, +}) diff --git a/apps/sim/app/api/v2/chat/activity.test.ts b/apps/sim/lib/copilot/chat/public-activity.test.ts similarity index 99% rename from apps/sim/app/api/v2/chat/activity.test.ts rename to apps/sim/lib/copilot/chat/public-activity.test.ts index feed95c1305..506d44a5e54 100644 --- a/apps/sim/app/api/v2/chat/activity.test.ts +++ b/apps/sim/lib/copilot/chat/public-activity.test.ts @@ -2,9 +2,9 @@ * @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' +import { ChatActivityProjector } from '@/lib/copilot/chat/public-activity' import type { MothershipStreamV1StreamScope } from '@/lib/copilot/generated/mothership-stream-v1' import type { StreamEvent } from '@/lib/copilot/request/types' -import { ChatActivityProjector } from '@/app/api/v2/chat/activity' vi.mock('@/lib/copilot/tools/client/read-block', () => ({ getReadTargetBlock: vi.fn((path: string | undefined) => diff --git a/apps/sim/app/api/v2/chat/activity.ts b/apps/sim/lib/copilot/chat/public-activity.ts similarity index 99% rename from apps/sim/app/api/v2/chat/activity.ts rename to apps/sim/lib/copilot/chat/public-activity.ts index fde09002790..46e91e7c2b1 100644 --- a/apps/sim/app/api/v2/chat/activity.ts +++ b/apps/sim/lib/copilot/chat/public-activity.ts @@ -88,8 +88,9 @@ const FILE_SUBAGENT = 'file' /** * Request-local projection of the private Mothership stream onto the public - * activity tree. Raw span/tool ids, arguments, results, errors, and thinking - * never cross this boundary. + * activity tree. Raw span/tool ids, argument/result objects, errors, and + * thinking never cross this boundary. Labels may summarize the same + * user-visible target or operation shown in Sim Home. */ export class ChatActivityProjector { private readonly calls = new Map<string, ToolProjection>() diff --git a/apps/sim/lib/copilot/chat/public-runs.test.ts b/apps/sim/lib/copilot/chat/public-runs.test.ts new file mode 100644 index 00000000000..5bb65d0dca5 --- /dev/null +++ b/apps/sim/lib/copilot/chat/public-runs.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment node + */ +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getPersistedPublicChatRunResponse, + getPublicChatRun, + listPublicChatRuns, +} from '@/lib/copilot/chat/public-runs' + +function assertOwnedRootMothershipScope(where: unknown, extraRight?: string) { + const conditions = flattenMockConditions(where) + const equalities = conditions.filter((condition) => condition.type === 'eq') + const nullChecks = conditions.filter((condition) => condition.type === 'isNull') + + // Both the run and joined chat are independently pinned to the caller. + expect(equalities.filter((condition) => condition.right === 'user-1')).toHaveLength(2) + expect(equalities.filter((condition) => condition.right === 'workspace-1')).toHaveLength(2) + expect(equalities).toContainEqual(expect.objectContaining({ left: 'type', right: 'mothership' })) + expect(nullChecks).toContainEqual(expect.objectContaining({ column: 'parentRunId' })) + expect(nullChecks).toContainEqual(expect.objectContaining({ column: 'deletedAt' })) + if (extraRight) { + expect(equalities).toContainEqual(expect.objectContaining({ right: extraRight })) + } + + expect(dbChainMockFns.innerJoin).toHaveBeenCalledWith(schemaMock.copilotChats, expect.anything()) +} + +describe('public chat run repository', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('scopes list queries to owned root runs in live Mothership chats', async () => { + queueTableRows(schemaMock.copilotRuns, []) + + await listPublicChatRuns({ + userId: 'user-1', + workspaceId: 'workspace-1', + status: 'active', + limit: 30, + }) + + assertOwnedRootMothershipScope(dbChainMockFns.where.mock.calls.at(-1)?.[0], 'active') + expect(dbChainMockFns.limit).toHaveBeenCalledWith(31) + }) + + it('uses the same masked scope for run detail lookups', async () => { + queueTableRows(schemaMock.copilotRuns, []) + + expect( + await getPublicChatRun({ + runId: 'run-private', + userId: 'user-1', + workspaceId: 'workspace-1', + }) + ).toBeNull() + + assertOwnedRootMothershipScope(dbChainMockFns.where.mock.calls.at(-1)?.[0], 'run-private') + expect(dbChainMockFns.limit).toHaveBeenCalledWith(1) + }) + + it('rejects a cursor with a malformed UUID before querying Postgres', async () => { + await expect( + listPublicChatRuns({ + userId: 'user-1', + workspaceId: 'workspace-1', + limit: 30, + cursorKeys: ['2026-08-08T12:00:00.000Z', 'not-a-uuid'], + }) + ).resolves.toEqual({ status: 'invalid_cursor' }) + + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('returns only assistant prose from persisted stream messages', async () => { + queueTableRows(schemaMock.copilotMessages, [ + { + content: { + id: 'assistant-1', + role: 'assistant', + content: 'Stored answer', + timestamp: '2026-08-08T12:00:00.000Z', + contentBlocks: [ + { + type: 'tool', + toolCall: { params: { secret: 'private' }, result: { output: 'private' } }, + }, + ], + }, + }, + ]) + + await expect(getPersistedPublicChatRunResponse('chat-1', 'stream-1')).resolves.toBe( + 'Stored answer' + ) + const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + expect(conditions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'eq', left: 'chatId', right: 'chat-1' }), + expect.objectContaining({ type: 'eq', left: 'streamId', right: 'stream-1' }), + expect.objectContaining({ type: 'eq', left: 'role', right: 'assistant' }), + expect.objectContaining({ type: 'isNull', column: 'deletedAt' }), + ]) + ) + }) + + it('refuses a malformed persisted content value instead of forwarding it', async () => { + queueTableRows(schemaMock.copilotMessages, [ + { content: { content: { secret: 'private' }, toolResult: 'private' } }, + ]) + + await expect(getPersistedPublicChatRunResponse('chat-1', 'stream-1')).resolves.toBeNull() + }) +}) diff --git a/apps/sim/lib/copilot/chat/public-runs.ts b/apps/sim/lib/copilot/chat/public-runs.ts new file mode 100644 index 00000000000..3b150661d3e --- /dev/null +++ b/apps/sim/lib/copilot/chat/public-runs.ts @@ -0,0 +1,138 @@ +import { db } from '@sim/db' +import { type CopilotRunStatus, copilotChats, copilotMessages, copilotRuns } from '@sim/db/schema' +import { and, desc, eq, isNull, type SQL } from 'drizzle-orm' +import { + type CursorKey, + encodeKeyset, + keysetAfter, + keysetColumns, + listOrderBy, + timestampKey, + uuidKey, +} from '@/lib/api/list-query' + +export const PUBLIC_CHAT_RUN_SORT = 'startedAt:desc' + +export interface PublicChatRunRow { + runId: string + chatId: string + chatTitle: string | null + streamId: string + status: CopilotRunStatus + startedAt: Date + completedAt: Date | null +} + +const PUBLIC_CHAT_RUN_KEYS = [ + timestampKey<PublicChatRunRow>(copilotRuns.startedAt, (row) => row.startedAt), + uuidKey<PublicChatRunRow>(copilotRuns.id, (row) => row.runId), +] + +const publicChatRunSelection = { + runId: copilotRuns.id, + chatId: copilotRuns.chatId, + chatTitle: copilotChats.title, + streamId: copilotRuns.streamId, + status: copilotRuns.status, + startedAt: copilotRuns.startedAt, + completedAt: copilotRuns.completedAt, +} as const + +function ownedRootMothershipRunWhere(input: { + userId: string + workspaceId: string + runId?: string + status?: CopilotRunStatus + resumeAfter?: SQL +}) { + return and( + eq(copilotRuns.userId, input.userId), + eq(copilotRuns.workspaceId, input.workspaceId), + isNull(copilotRuns.parentRunId), + eq(copilotChats.userId, input.userId), + eq(copilotChats.workspaceId, input.workspaceId), + eq(copilotChats.type, 'mothership'), + isNull(copilotChats.deletedAt), + input.runId ? eq(copilotRuns.id, input.runId) : undefined, + input.status ? eq(copilotRuns.status, input.status) : undefined, + input.resumeAfter + ) +} + +export type ListPublicChatRunsResult = + | { status: 'ok'; rows: PublicChatRunRow[] } + | { status: 'invalid_cursor' } + +/** Lists only user-owned root runs from live Mothership chats. */ +export async function listPublicChatRuns(input: { + userId: string + workspaceId: string + status?: CopilotRunStatus + limit: number + cursorKeys?: CursorKey[] +}): Promise<ListPublicChatRunsResult> { + const resumeAfter = input.cursorKeys + ? keysetAfter(PUBLIC_CHAT_RUN_KEYS, input.cursorKeys, 'desc') + : undefined + if (resumeAfter === null) return { status: 'invalid_cursor' } + + const rows = await db + .select(publicChatRunSelection) + .from(copilotRuns) + .innerJoin(copilotChats, eq(copilotChats.id, copilotRuns.chatId)) + .where(ownedRootMothershipRunWhere({ ...input, resumeAfter })) + .orderBy(...listOrderBy(keysetColumns(PUBLIC_CHAT_RUN_KEYS), 'desc')) + .limit(input.limit + 1) + + return { status: 'ok', rows } +} + +export function encodePublicChatRunCursor(row: PublicChatRunRow): CursorKey[] { + return encodeKeyset(PUBLIC_CHAT_RUN_KEYS, row) +} + +/** + * Loads one public run while masking every ownership, scope, type, deletion, + * and parent-run mismatch behind the same absence result. + */ +export async function getPublicChatRun(input: { + runId: string + userId: string + workspaceId: string +}): Promise<PublicChatRunRow | null> { + const [run] = await db + .select(publicChatRunSelection) + .from(copilotRuns) + .innerJoin(copilotChats, eq(copilotChats.id, copilotRuns.chatId)) + .where(ownedRootMothershipRunWhere(input)) + .limit(1) + + return run ?? null +} + +/** + * Reads only the root assistant prose persisted for this stream. Tool blocks + * stay inside the JSON message and never cross the public boundary. + */ +export async function getPersistedPublicChatRunResponse( + chatId: string, + streamId: string +): Promise<string | null> { + const [row] = await db + .select({ content: copilotMessages.content }) + .from(copilotMessages) + .where( + and( + eq(copilotMessages.chatId, chatId), + eq(copilotMessages.streamId, streamId), + eq(copilotMessages.role, 'assistant'), + isNull(copilotMessages.deletedAt) + ) + ) + .orderBy(desc(copilotMessages.seq), desc(copilotMessages.createdAt), desc(copilotMessages.id)) + .limit(1) + + if (!row?.content || typeof row.content !== 'object' || Array.isArray(row.content)) return null + const response = (row.content as Record<string, unknown>).content + return typeof response === 'string' ? response : null +} diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index c05fa7a76f5..aaa92662750 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -1841,6 +1841,7 @@ describe('runCopilotLifecycle', () => { ) expect(fetchUrls[1]).toBe('http://mothership.test/api/tools/resume') + expect(mockUpdateRunStatus).toHaveBeenCalledWith('run-1', 'resuming') expect(requestBodies[1]).toEqual( expect.objectContaining({ checkpointId: 'ckpt-1', diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 60fcf011885..a92eb175a55 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -1490,6 +1490,17 @@ async function runCheckpointLoop( break } + if (isResume && options.runId) { + try { + await updateRunStatus(options.runId, 'resuming') + } catch (error) { + logger.warn('Failed to mark run as resuming', { + runId: options.runId, + error: toError(error).message, + }) + } + } + const loopOptions = { ...options, onEvent: async (event: StreamEvent) => { diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 0ac1a3ee753..b5dd5796c6a 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -18,7 +18,7 @@ npm install --global @simai/cli@dev # dev ## Profiles Profiles work like the AWS CLI: one identity and one set of defaults per named -profile, selected with `-P`, `--profile`, or `SIM_PROFILE`. This is what lets you keep +profile, selected with `-p`, `--profile`, or `SIM_PROFILE`. This is what lets you keep production and a local dev stack side by side without re-authenticating. Non-secret settings live in `~/.sim/config`: @@ -118,7 +118,10 @@ conversation, while `sim chats` manages saved chat resources. ```bash sim chat [prompt...] [-f <path>...] [--read-only] -sim chat -p [prompt...] [-f <path>...] [--read-only] +sim chat ask [prompt...] [-f <path>...] [--read-only] [--chat <chatId>] [--async] +sim chat follow <runId> +sim chat runs list [--status <status>] [--limit <n>] +sim chat runs get <runId> sim chats list [--search <text>] [--limit <n>] sim chats get <chatId> [--read-only] sim chats rename <chatId> --title <title> @@ -234,19 +237,18 @@ sim chat --file screenshot.png "What is failing here?" sim chat --read-only "Summarize this workspace without changing it" ``` -Inside the chat, `/attach <paths>` attaches up to five local images, PDFs, or -UTF-8 text files to the next turn. A pasted or dragged file path preloads an -`/attach` command; review it and press Enter before the CLI reads the file. On -macOS, press Ctrl+V or use `/paste-image` to attach a clipboard image; -any draft text remains in the prompt. `/chats` loads the chat history and opens -a searchable picker. Selecting one restores its transcript and continues it -with a fresh opaque token. The header shows the active chat title and keeps the -`/chats` switch hint visible; a new chat's generated title appears there as soon -as the server publishes it. `/rename <title>` retitles the active synced chat in -both the terminal and Sim Home. `/new` clears the visible transcript and -starts a new conversation, `/help` lists commands, and `/exit` or Ctrl+D exits. -Ctrl+C clears idle input or cancels the active generation and returns to the -prompt. +Inside the chat, type or drop local paths to attach up to five images, PDFs, or +UTF-8 text files. Paths are removed from the submitted prompt and the files are +sent inline; a path-only turn sends just the attachments. Press Ctrl+V (or +Cmd+V on macOS) to add a clipboard image or file without replacing draft text. +`/chats` loads the chat history and opens a searchable picker. Selecting one +restores its transcript and continues it with a fresh opaque token. The header +shows the active chat title and keeps the `/chats` switch hint visible; a new +chat's generated title appears there as soon as the server publishes it. +`/rename <title>` retitles the active synced chat in both the terminal and Sim +Home. `/new` clears the visible transcript and starts a new conversation, +`/help` lists commands, and `/exit` or Ctrl+D exits. Ctrl+C clears idle input or +cancels the active generation and returns to the prompt. Chats sent with the personal API key issued by `sim login` use the same history as Sim Home, so a CLI conversation appears in the web UI and a web conversation @@ -257,29 +259,58 @@ profile to replace it with a personal key and enable synchronized history and `/chats`. Chat uses the full Mothership toolset by default. Add `--read-only` in either -interactive or print mode when the conversation must be restricted to +interactive or one-shot mode when the conversation must be restricted to workspace-reading tools. -`sim chat -p` is the non-interactive form. It never opens a prompt: the -completed, terminal-safe answer is the only thing written to stdout, so it -composes cleanly with shell tools. Bare `sim chat` requires a real terminal; -pipelines and redirected output must use `-p`. +`sim chat ask` is the non-interactive form. It never opens a prompt. With a +personal API key, each ask creates or continues a saved chat, so the returned +chat ID can be passed back with `--chat` and the conversation also appears in +Sim Home. Without `--async`, table and text output keep the completed, +terminal-safe answer as the only stdout payload, so it composes cleanly with +shell tools; an interactive terminal shows the chat ID on stderr. JSON and YAML +return both `content` and `chatId`. Bare `sim chat` requires a real terminal; +pipelines and redirected output must use `chat ask`. ```bash -sim chat -p "Which workflows handle support tickets?" -sim chat -p --chat <chatId> "Continue this conversation" -cat incident.txt | sim chat -p "Which workflow is most likely involved?" -sim chat -p < question.txt -sim chat -p --file report.pdf "Summarize this in workspace context" +sim chat ask "Which workflows handle support tickets?" +sim chat ask --chat <chatId> "Continue this conversation" +cat incident.txt | sim chat ask "Which workflow is most likely involved?" +sim chat ask < question.txt +sim chat ask --file report.pdf "Summarize this in workspace context" ``` -Pass `--chat <chatId>` to append one print-mode turn to an existing inactive +Pass `--chat <chatId>` to append one turn to an existing inactive chat, print its answer, and exit. The chat must belong to the active workspace, and synchronized history requires a personal API key. +```bash +chat_id=$(sim --output json chat ask "Inspect this workspace" | jq -r .chatId) +sim chat ask --chat "$chat_id" "Now inspect the deployed workflows" +``` + +Add `--async` when the shell should regain control as soon as the run is +accepted. The receipt contains the `runId`, `chatId`, and initial `active` +status in the profile's normal output format. Async asks are saved chats, so +they stay synchronized with Sim Home. `chat runs list` shows recent requests, +and `chat runs get --output json` or `--output yaml` includes one run's +accumulated response and activity. + +Use `chat follow` to observe a run until it reaches a terminal state. In human +output it streams only response text that has appeared since the last poll; +when stderr is a terminal, concise status and tool activity appear there. +JSON and YAML wait and emit one final snapshot instead. Ctrl+C detaches the +observer without cancelling the server-side run. A run ending in `error` or +`cancelled` still emits its safe partial/final result and exits nonzero. + +```bash +run_id=$(sim --output json chat ask --async "Audit this workspace" | jq -r .runId) +sim chat follow "$run_id" +sim chat runs get "$run_id" +``` + When both a positional prompt and stdin are present, the positional prompt comes -first and the piped content follows on the next line. This matches Claude Code's -print-mode input behavior. Combined input is limited to 10 MiB of UTF-8 text. +first and the piped content follows on the next line. Combined input is limited +to 10 MiB of UTF-8 text. Files are sent inline by basename only: local paths never cross the API boundary. Images and PDFs are limited to 5 MiB each, text files to 200 KiB, and all attachments in a turn to 10 MiB total. @@ -289,7 +320,7 @@ workspace without logging in locally: ```bash sim configure --set-endpoint http://localhost:3000 --set-workspace ws_local -sim chat -p "What is in this workspace?" +sim chat ask "What is in this workspace?" ``` That deployment must enable `V2_API=true` and set `COPILOT_API_KEY` server-side. diff --git a/packages/sim-cli/src/commands/protocol/chat.test.ts b/packages/sim-cli/src/commands/protocol/chat.test.ts index 184f120e6da..3524973ace1 100644 --- a/packages/sim-cli/src/commands/protocol/chat.test.ts +++ b/packages/sim-cli/src/commands/protocol/chat.test.ts @@ -23,21 +23,32 @@ import type { ChatTerminalSelectResult, ChatTerminalWelcome, } from './chat-terminal.js' +import { attachProtocolCommands } from './index.js' const mocks = vi.hoisted(() => ({ + output: 'table' as 'table' | 'text' | 'json' | 'yaml', request: vi.fn(), requestRaw: vi.fn(), requireWorkspace: vi.fn(() => 'ws_local'), + selectedProfile: vi.fn(), })) vi.mock('../../context.js', () => ({ - clientFrom: () => ({ client: mocks, profile: { endpoint: 'https://sim.example' } }), + clientFrom: (command: Command) => { + mocks.selectedProfile(command.optsWithGlobals().profile) + return { + client: mocks, + profile: { endpoint: 'https://sim.example', name: 'default', output: mocks.output }, + } + }, })) beforeEach(() => { mocks.request.mockReset().mockResolvedValue({ data: [], nextCursor: null }) mocks.requestRaw.mockReset() mocks.requireWorkspace.mockClear() + mocks.selectedProfile.mockReset() + mocks.output = 'table' }) function sse(chunks: string[]): Response { @@ -50,12 +61,18 @@ function sse(chunks: string[]): Response { return new Response(body, { headers: { 'content-type': 'text/event-stream' } }) } -function completed(content: string, token = 'continuation-1', deltas: string[] = []): Response { +function completed( + content: string, + token = 'continuation-1', + deltas: string[] = [], + chatId: string | null = null +): Response { return sse([ `event: session\ndata: ${JSON.stringify({ type: 'session', continuationToken: token, requestId: 'req_1', + ...(chatId ? { chatId } : {}), })}\n\n`, ...deltas.map((delta) => `event: text\ndata: ${JSON.stringify({ type: 'text', delta })}\n\n`), `event: complete\ndata: ${JSON.stringify({ @@ -85,8 +102,8 @@ function program( writeOutput = vi.fn(), overrides: Partial<ChatDependencies> = {} ): Command { - const root = new Command('sim').exitOverride() - root.option('-P, --profile <name>') + const root = new Command('sim') + root.option('-p, --profile <name>') root.addCommand( chatCommand({ readInput, @@ -95,6 +112,11 @@ function program( ...overrides, }) ) + const overrideExit = (command: Command) => { + command.exitOverride() + command.commands.forEach(overrideExit) + } + overrideExit(root) return root } @@ -236,7 +258,7 @@ class FakeTerminal implements ChatTerminal { } } -describe('chat print mode', () => { +describe('chat ask', () => { it('posts to the selected workspace and prints only the completed answer', async () => { const wire = [ ': keepalive\n\n', @@ -262,7 +284,7 @@ describe('chat print mode', () => { 'node', 'sim', 'chat', - '-p', + 'ask', 'What', 'is', 'here?', @@ -280,6 +302,62 @@ describe('chat print mode', () => { expect(writeOutput).toHaveBeenCalledWith('Hello world') }) + it('shows the reusable saved chat ID on stderr without changing answer stdout', async () => { + mocks.requestRaw.mockResolvedValue(completed('Saved answer', 'token', [], 'chat-1')) + const writeOutput = vi.fn() + const writeProgress = vi.fn() + + await program(async () => '', writeOutput, { + showProgress: () => true, + writeProgress, + }).parseAsync(['node', 'sim', 'chat', 'ask', 'Save this']) + + expect(writeOutput).toHaveBeenCalledWith('Saved answer') + expect(writeProgress).toHaveBeenCalledWith('chat: chat-1\n') + }) + + it.each([ + ['json', '{"content":"Saved answer","chatId":"chat-1"}'], + ['yaml', 'content: Saved answer\nchatId: chat-1'], + ] as const)('returns content and reusable chat ID in %s output', async (format, expected) => { + mocks.requestRaw.mockResolvedValue(completed('Saved answer', 'token', [], 'chat-1')) + mocks.output = format + const writeOutput = vi.fn() + const writeProgress = vi.fn() + const logged: string[] = [] + const log = vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + try { + await program(async () => '', writeOutput, { + showProgress: () => true, + writeProgress, + }).parseAsync(['node', 'sim', 'chat', 'ask', 'Save this']) + } finally { + log.mockRestore() + } + + expect(writeOutput).not.toHaveBeenCalled() + expect(writeProgress).not.toHaveBeenCalled() + expect(logged).toHaveLength(1) + expect(format === 'json' ? JSON.stringify(JSON.parse(logged[0])) : logged[0]).toBe(expected) + }) + + it('keeps workspace-key one-shot answers usable when no saved chat ID is available', async () => { + mocks.requestRaw.mockResolvedValue(completed('Unsynced answer', 'token', [], null)) + const writeOutput = vi.fn() + const writeProgress = vi.fn() + + await program(async () => '', writeOutput, { + showProgress: () => true, + writeProgress, + }).parseAsync(['node', 'sim', 'chat', 'ask', 'Read this']) + + expect(writeOutput).toHaveBeenCalledWith('Unsynced answer') + expect(writeProgress).toHaveBeenCalledWith( + 'chat: not saved (a personal API key is required for resumable history)\n' + ) + }) + it('resumes an existing chat by ID for one print-mode turn', async () => { mocks.request.mockResolvedValueOnce({ data: { @@ -297,7 +375,7 @@ describe('chat print mode', () => { 'node', 'sim', 'chat', - '-p', + 'ask', '--chat', 'chat-1', 'Continue here', @@ -331,7 +409,7 @@ describe('chat print mode', () => { 'node', 'sim', 'chat', - '-p', + 'ask', '--read-only', '--chat', 'chat-1', @@ -350,16 +428,13 @@ describe('chat print mode', () => { }) }) - it('rejects --chat outside print mode', async () => { - await expect( - program(async () => '', vi.fn(), { isInteractive: () => true }).parseAsync([ - 'node', - 'sim', - 'chat', - '--chat', - 'chat-1', - ]) - ).rejects.toThrow('--chat can only be used with -p/--print') + it('keeps --chat scoped to one-shot asks', async () => { + const root = program(async () => '', vi.fn(), { isInteractive: () => true }) + const chat = root.commands.find((command) => command.name() === 'chat') + const ask = chat?.commands.find((command) => command.name() === 'ask') + + expect(chat?.helpInformation()).not.toContain('--chat') + expect(ask?.helpInformation()).toContain('--chat <chatId>') expect(mocks.request).not.toHaveBeenCalled() expect(mocks.requestRaw).not.toHaveBeenCalled() @@ -381,7 +456,7 @@ describe('chat print mode', () => { 'node', 'sim', 'chat', - '-p', + 'ask', '--chat', 'chat-1', 'Continue here', @@ -411,7 +486,7 @@ describe('chat print mode', () => { 'node', 'sim', 'chat', - '-p', + 'ask', '--chat', 'chat-1', 'Continue here', @@ -430,7 +505,7 @@ describe('chat print mode', () => { 'node', 'sim', 'chat', - '-p', + 'ask', '--chat', 'missing-chat', 'Continue here', @@ -440,15 +515,24 @@ describe('chat print mode', () => { expect(mocks.requestRaw).not.toHaveBeenCalled() }) - it('keeps the profile shorthand distinct from chat -p', async () => { + it('accepts the global profile shorthand after chat ask', async () => { mocks.requestRaw.mockResolvedValue(completed('answer')) - await program(async () => '').parseAsync(['node', 'sim', '-P', 'dev', 'chat', '-p', 'question']) + await program(async () => '').parseAsync([ + 'node', + 'sim', + 'chat', + 'ask', + '-p', + 'dev', + 'question', + ]) expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ workspaceId: 'ws_local', prompt: 'question', }) + expect(mocks.selectedProfile).toHaveBeenCalledWith('dev') }) it('opts into query-only chat only when --read-only is passed', async () => { @@ -458,7 +542,7 @@ describe('chat print mode', () => { 'node', 'sim', 'chat', - '-p', + 'ask', '--read-only', 'question', ]) @@ -477,7 +561,7 @@ describe('chat print mode', () => { 'node', 'sim', 'chat', - '--print', + 'ask', 'Explain', 'this', ]) @@ -488,7 +572,7 @@ describe('chat print mode', () => { it('accepts piped input without a positional prompt', async () => { mocks.requestRaw.mockResolvedValue(completed('answer')) - await program(async () => 'question from stdin\n').parseAsync(['node', 'sim', 'chat', '-p']) + await program(async () => 'question from stdin\n').parseAsync(['node', 'sim', 'chat', 'ask']) expect(mocks.requestRaw.mock.calls[0][1].body.prompt).toBe('question from stdin\n') }) @@ -506,7 +590,7 @@ describe('chat print mode', () => { 'node', 'sim', 'chat', - '-p', + 'ask', '--file', '/private/local/notes.md', ]) @@ -521,9 +605,9 @@ describe('chat print mode', () => { }) it('requires a prompt, attachment, or stdin', async () => { - await expect(program(async () => '').parseAsync(['node', 'sim', 'chat', '-p'])).rejects.toThrow( - /Provide a prompt, attach a file, or pipe input/ - ) + await expect( + program(async () => '').parseAsync(['node', 'sim', 'chat', 'ask']) + ).rejects.toThrow(/Provide a prompt, attach a file, or pipe input/) expect(mocks.requestRaw).not.toHaveBeenCalled() }) @@ -534,7 +618,7 @@ describe('chat print mode', () => { 'node', 'sim', 'chat', - '-p', + 'ask', ]) await expect(result).rejects.toMatchObject({ @@ -547,11 +631,11 @@ describe('chat print mode', () => { it('fails clearly instead of blocking when bare chat has no interactive terminal', async () => { await expect( program(async () => '').parseAsync(['node', 'sim', 'chat', 'question']) - ).rejects.toThrow(/Use sim chat -p/) + ).rejects.toThrow(/Use sim chat ask/) expect(mocks.requestRaw).not.toHaveBeenCalled() }) - it('never constructs a terminal prompt in -p mode', async () => { + it('never constructs a terminal prompt for chat ask', async () => { mocks.requestRaw.mockResolvedValue(completed('answer')) const createTerminal = vi.fn(() => { throw new Error('must not prompt') @@ -560,7 +644,7 @@ describe('chat print mode', () => { await program(async () => '', vi.fn(), { isInteractive: () => true, createTerminal, - }).parseAsync(['node', 'sim', 'chat', '-p', 'question']) + }).parseAsync(['node', 'sim', 'chat', 'ask', 'question']) expect(createTerminal).not.toHaveBeenCalled() }) @@ -574,18 +658,30 @@ describe('chat print mode', () => { ) const writeOutput = vi.fn() - await program(async () => '', writeOutput).parseAsync(['node', 'sim', 'chat', '-p', 'question']) + await program(async () => '', writeOutput).parseAsync([ + 'node', + 'sim', + 'chat', + 'ask', + 'question', + ]) expect(writeOutput).toHaveBeenCalledWith('Safe text') expect(writeOutput.mock.calls[0][0]).not.toContain(terminalEscape) }) - it('trims whitespace owned by hidden options in print mode', async () => { + it('trims whitespace owned by hidden options in one-shot output', async () => { const options = '<options>{"1":{"title":"Next","description":"Continue"}}</options>' mocks.requestRaw.mockResolvedValue(completed(`Answer\n\n${options}\n\n`)) const writeOutput = vi.fn() - await program(async () => '', writeOutput).parseAsync(['node', 'sim', 'chat', '-p', 'question']) + await program(async () => '', writeOutput).parseAsync([ + 'node', + 'sim', + 'chat', + 'ask', + 'question', + ]) expect(writeOutput).toHaveBeenCalledOnce() expect(writeOutput).toHaveBeenCalledWith('Answer') @@ -603,7 +699,7 @@ describe('chat print mode', () => { 'node', 'sim', 'chat', - '-p', + 'ask', 'find file', ]) @@ -611,7 +707,7 @@ describe('chat print mode', () => { expect(writeOutput).toHaveBeenCalledWith('Q4 report') }) - it('omits a trailing standalone workspace link in print mode', async () => { + it('omits a trailing standalone workspace link in one-shot output', async () => { const resource = '<workspace_resource>{"type":"workflow","id":"wf-forceful","title":"forceful-arm"}</workspace_resource>' mocks.requestRaw.mockResolvedValue(completed(`Summary.\n\n${resource}`)) @@ -621,7 +717,7 @@ describe('chat print mode', () => { 'node', 'sim', 'chat', - '-p', + 'ask', 'inspect forceful-arm', ]) @@ -638,12 +734,12 @@ describe('chat print mode', () => { const writeOutput = vi.fn() await expect( - program(async () => '', writeOutput).parseAsync(['node', 'sim', 'chat', '-p', 'question']) + program(async () => '', writeOutput).parseAsync(['node', 'sim', 'chat', 'ask', 'question']) ).rejects.toThrow('No answer') expect(writeOutput).not.toHaveBeenCalled() }) - it('keeps thinking and activity events silent in print mode', async () => { + it('keeps thinking and activity events silent in one-shot output', async () => { mocks.requestRaw.mockResolvedValue( sse([ 'event: thinking\ndata: {"type":"thinking","delta":"Checking the workspace"}\n\n', @@ -655,10 +751,294 @@ describe('chat print mode', () => { ]) ) const writeOutput = vi.fn() - await program(async () => '', writeOutput).parseAsync(['node', 'sim', 'chat', '-p', 'question']) + await program(async () => '', writeOutput).parseAsync([ + 'node', + 'sim', + 'chat', + 'ask', + 'question', + ]) expect(writeOutput).toHaveBeenCalledWith('Answer') }) + + it('starts an asynchronous run, closes the accepted stream, and prints a normal receipt', async () => { + const accepted = openSse( + 'event: session\ndata: {"type":"session","runId":"run-1","chatId":"chat-1"}\n\n' + ) + mocks.requestRaw.mockResolvedValue(accepted.response) + mocks.output = 'json' + const logged: string[] = [] + const log = vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + try { + await program(async () => '').parseAsync([ + 'node', + 'sim', + 'chat', + 'ask', + '--async', + 'inspect workspace', + ]) + } finally { + log.mockRestore() + } + + expect(mocks.requestRaw).toHaveBeenCalledWith('/api/v2/chat', { + method: 'POST', + headers: { accept: 'text/event-stream' }, + body: { + workspaceId: 'ws_local', + prompt: 'inspect workspace', + persistChat: true, + async: true, + }, + signal: expect.any(AbortSignal), + auth: 'optional', + }) + expect(accepted.cancel).toHaveBeenCalledOnce() + expect(JSON.parse(logged.join('\n'))).toEqual({ + runId: 'run-1', + chatId: 'chat-1', + status: 'active', + }) + }) +}) + +describe('chat follow', () => { + const snapshot = ( + status: string, + response: string, + activities: Array<Record<string, unknown>> = [] + ) => ({ + data: { + runId: 'run-1', + chatId: 'chat-1', + chatTitle: 'Workspace audit', + status, + startedAt: '2026-08-08T12:00:00.000Z', + completedAt: status === 'complete' ? '2026-08-08T12:00:02.000Z' : null, + response, + activities, + }, + }) + + it('prints only newly accumulated response text and useful progress', async () => { + mocks.request + .mockResolvedValueOnce( + snapshot('active', 'Hel', [ + { kind: 'tool', id: 'tool-1', label: 'Read file', state: 'running' }, + ]) + ) + .mockResolvedValueOnce( + snapshot('active', 'Hello', [ + { kind: 'tool', id: 'tool-1', label: 'Read file', state: 'running' }, + ]) + ) + .mockResolvedValueOnce( + snapshot('complete', 'Hello world', [ + { kind: 'tool', id: 'tool-1', label: 'Read file', state: 'running' }, + { kind: 'tool', id: 'tool-1', label: 'Read file', state: 'complete' }, + ]) + ) + const terminalWrites: string[] = [] + const writeStream = vi.fn((content: string) => terminalWrites.push(content)) + const writeProgress = vi.fn((content: string) => terminalWrites.push(content)) + const pollDelay = vi.fn(async () => {}) + + await program(async () => '', vi.fn(), { + writeStream, + writeProgress, + showProgress: () => true, + pollDelay, + }).parseAsync(['node', 'sim', 'chat', 'follow', 'run-1']) + + expect(mocks.request).toHaveBeenCalledTimes(3) + for (const [path, options] of mocks.request.mock.calls) { + expect(path).toBe('/api/v2/chat/runs/run-1') + expect(options).toMatchObject({ query: { workspaceId: 'ws_local' }, auth: 'optional' }) + expect(options.signal).toBeInstanceOf(AbortSignal) + } + expect(pollDelay).toHaveBeenCalledTimes(2) + expect(writeStream.mock.calls.map(([value]) => value)).toEqual(['Hel', 'lo', ' world', '\n']) + expect(writeProgress.mock.calls.map(([value]) => value)).toEqual([ + 'status: active\n', + '● Read file\n', + 'status: complete\n', + '✓ Read file\n', + ]) + expect(terminalWrites).toEqual([ + 'status: active\n', + '● Read file\n', + 'Hel', + 'lo', + ' world', + '\n', + 'status: complete\n', + '✓ Read file\n', + ]) + }) + + it('retries transient status failures without regressing accumulated output', async () => { + mocks.request + .mockRejectedValueOnce(new SimApiError('Progress unavailable', 503, 'SERVICE_UNAVAILABLE')) + .mockResolvedValueOnce(snapshot('complete', 'Recovered answer')) + const writeStream = vi.fn() + const pollDelay = vi.fn(async () => {}) + + await program(async () => '', vi.fn(), { + writeStream, + showProgress: () => false, + pollDelay, + }).parseAsync(['node', 'sim', 'chat', 'follow', 'run-1']) + + expect(mocks.request).toHaveBeenCalledTimes(2) + expect(pollDelay).toHaveBeenCalledOnce() + expect(writeStream.mock.calls.map(([value]) => value)).toEqual(['Recovered answer', '\n']) + }) + + it('emits one safe final snapshot for JSON output', async () => { + const terminalEscape = `${String.fromCharCode(27)}]0;owned\u0007` + mocks.request.mockResolvedValueOnce(snapshot('complete', `${terminalEscape}Answer`)) + mocks.output = 'json' + const writeStream = vi.fn() + const writeProgress = vi.fn() + const logged: string[] = [] + const log = vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + try { + await program(async () => '', vi.fn(), { + writeStream, + writeProgress, + showProgress: () => true, + }).parseAsync(['node', 'sim', 'chat', 'follow', 'run-1']) + } finally { + log.mockRestore() + } + + expect(writeStream).not.toHaveBeenCalled() + expect(writeProgress).not.toHaveBeenCalled() + expect(logged).toHaveLength(1) + expect(JSON.parse(logged[0])).toMatchObject({ + runId: 'run-1', + chatId: 'chat-1', + status: 'complete', + response: `${terminalEscape}Answer`, + }) + }) + + it('streams through the normal structured renderer and omits a trailing resource', async () => { + const options = '<options>{"1":{"title":"Next","description":"Continue"}}</options>' + const resource = + '<workspace_resource>{"type":"workflow","id":"wf-1","title":"forceful-arm"}</workspace_resource>' + mocks.request + .mockResolvedValueOnce(snapshot('active', 'Answer\n\n<op')) + .mockResolvedValueOnce(snapshot('complete', `Answer\n\n${options}\n\n${resource}`)) + const writeStream = vi.fn() + + await program(async () => '', vi.fn(), { + writeStream, + showProgress: () => false, + pollDelay: async () => {}, + }).parseAsync(['node', 'sim', 'chat', 'follow', 'run-1']) + + expect(writeStream.mock.calls.map(([value]) => value)).toEqual(['Answer', '\n']) + expect(writeStream.mock.calls.flat().join('')).not.toContain('<options>') + expect(writeStream.mock.calls.flat().join('')).not.toContain('forceful-arm') + }) + + it('prints the safe partial answer before failing an errored run', async () => { + mocks.request.mockResolvedValueOnce(snapshot('error', 'Partial answer')) + const writeStream = vi.fn() + + await expect( + program(async () => '', vi.fn(), { + writeStream, + showProgress: () => false, + }).parseAsync(['node', 'sim', 'chat', 'follow', 'run-1']) + ).rejects.toMatchObject({ + message: 'Sim Chat run ended with status "error".', + code: 'CHAT_RUN_FAILED', + }) + + expect(writeStream.mock.calls.map(([value]) => value)).toEqual(['Partial answer', '\n']) + }) + + it('emits one final JSON snapshot before failing a cancelled run', async () => { + mocks.request.mockResolvedValueOnce(snapshot('cancelled', 'Stopped safely')) + mocks.output = 'json' + const logged: string[] = [] + const log = vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + try { + await expect( + program(async () => '', vi.fn(), { + showProgress: () => false, + }).parseAsync(['node', 'sim', 'chat', 'follow', 'run-1']) + ).rejects.toMatchObject({ code: 'CHAT_RUN_FAILED' }) + } finally { + log.mockRestore() + } + + expect(logged).toHaveLength(1) + expect(JSON.parse(logged[0])).toMatchObject({ + runId: 'run-1', + status: 'cancelled', + response: 'Stopped safely', + }) + }) + + it('detaches on Ctrl+C without cancelling the run', async () => { + mocks.request.mockResolvedValueOnce(snapshot('active', 'partial')) + let interrupt: (() => void) | undefined + const writeStream = vi.fn() + const pollDelay = vi.fn(async () => { + interrupt?.() + }) + + await program(async () => '', vi.fn(), { + writeStream, + showProgress: () => false, + pollDelay, + onInterrupt: (listener) => { + interrupt = listener + return () => { + interrupt = undefined + } + }, + }).parseAsync(['node', 'sim', 'chat', 'follow', 'run-1']) + + expect(mocks.request).toHaveBeenCalledTimes(1) + expect(mocks.request.mock.calls[0][0]).toBe('/api/v2/chat/runs/run-1') + expect(writeStream.mock.calls.map(([value]) => value)).toEqual(['partial', '\n']) + }) + + it('rejects extra positional arguments after the run ID', async () => { + await expect( + program(async () => '').parseAsync(['node', 'sim', 'chat', 'follow', 'run-1', 'unexpected']) + ).rejects.toThrow(/too many arguments/i) + + expect(mocks.request).not.toHaveBeenCalled() + }) +}) + +describe('chat command composition', () => { + it('merges the manual chat protocol into an existing generated chat group', () => { + const root = new Command('sim') + const generatedChat = new Command('chat') + const runs = new Command('runs').addCommand(new Command('get')) + generatedChat.addCommand(runs) + root.addCommand(generatedChat) + + attachProtocolCommands(root) + + expect(root.commands.filter((command) => command.name() === 'chat')).toEqual([generatedChat]) + expect(generatedChat.commands.map((command) => command.name()).sort()).toEqual([ + 'ask', + 'follow', + 'runs', + ]) + }) }) describe('interactive chat', () => { diff --git a/packages/sim-cli/src/commands/protocol/chat.ts b/packages/sim-cli/src/commands/protocol/chat.ts index 984dd2fbb94..2443865c059 100644 --- a/packages/sim-cli/src/commands/protocol/chat.ts +++ b/packages/sim-cli/src/commands/protocol/chat.ts @@ -1,8 +1,11 @@ +import { setTimeout as delay } from 'node:timers/promises' import { Command } from 'commander' +import type { OutputFormat } from '../../config/index.js' import { clientFrom } from '../../context.js' import type { ChatBody, GetChatResponse, + GetChatRunResponse, GetWorkspaceResponse, ListChatsResponse, ListFilesResponse, @@ -43,6 +46,7 @@ import { type ChatTerminalSelectResult, ReadlineChatTerminal, } from './chat-terminal.js' +import { printProtocolResult } from './result.js' export interface ChatDependencies { readInput: (maxBytes: number) => Promise<string> @@ -53,6 +57,11 @@ export interface ChatDependencies { clipboardAttachment: () => Promise<ChatAttachment | null> extractAttachmentPaths: (input: string) => Promise<ExtractedAttachments | null> formatMarkdown: () => boolean + writeStream: (content: string) => void + writeProgress: (content: string) => void + showProgress: () => boolean + pollDelay: (milliseconds: number, signal: AbortSignal) => Promise<void> + onInterrupt: (listener: () => void) => () => void } interface ChatEvent { @@ -62,6 +71,7 @@ interface ChatEvent { error?: unknown continuationToken?: unknown chatId?: unknown + runId?: unknown title?: unknown } @@ -86,10 +96,44 @@ export interface ReadChatTurnOptions { onTitle?: (title: string) => void | Promise<void> } -type ChatRequest = ChatBody +interface ChatAcceptance { + runId: string + chatId: string +} + +interface ChatRunSnapshot { + runId: string + chatId: string + chatTitle?: string | null + status: GetChatRunResponse['data']['status'] + startedAt?: string | null + completedAt?: string | null + response: string + activities: ChatActivityUpdate[] +} + +interface ParsedChatRunSnapshot { + /** Sanitized and shape-checked values used by the human renderer. */ + snapshot: ChatRunSnapshot + /** Exact API data used by JSON/YAML, which preserve wire values by convention. */ + raw: Record<string, unknown> +} const MAX_CHAT_PROMPT_BYTES = 10 * 1024 * 1024 const MAX_LOG_SUGGESTIONS = 50 +const CHAT_RUN_POLL_MS = 3_000 +const CHAT_RUN_STATUSES = new Set<GetChatRunResponse['data']['status']>([ + ...V2_OPERATIONS.listChatRuns.query.status.values, +]) +const TERMINAL_CHAT_RUN_STATUSES = new Set<GetChatRunResponse['data']['status']>([ + 'complete', + 'error', + 'cancelled', +]) +const FAILED_CHAT_RUN_STATUSES = new Set<GetChatRunResponse['data']['status']>([ + 'error', + 'cancelled', +]) function inputTooLarge(): SimApiError { return new SimApiError('Chat input exceeds the 10 MiB limit.', 0) @@ -189,6 +233,63 @@ function streamError(event: ChatEvent): SimApiError { ) } +function eventString(event: ChatEvent, field: 'runId' | 'chatId'): string | null { + const direct = event[field] + if (typeof direct === 'string' && direct) return direct + if (!event.data || typeof event.data !== 'object') return null + const nested = (event.data as Record<string, unknown>)[field] + return typeof nested === 'string' && nested ? nested : null +} + +/** Reads only the accepted session for a detached chat run, then closes the HTTP reader. */ +export async function readChatAcceptance(response: Response): Promise<ChatAcceptance> { + if (!response.body) throw new SimApiError('Sim Chat returned an empty response.', 0) + + let eventLines: string[] = [] + let runId: string | null = null + let chatId: string | null = null + + const consume = (): ChatAcceptance | null => { + const raw = dataFromEvent(eventLines) + eventLines = [] + if (raw === null || raw === '[DONE]') return null + + let parsed: ChatEvent + try { + parsed = JSON.parse(raw) as ChatEvent + } catch { + throw new SimApiError('Sim Chat returned malformed streaming data.', 0) + } + + if (parsed.type === 'error') throw streamError(parsed) + if (parsed.type !== 'session') return null + runId = eventString(parsed, 'runId') ?? runId + chatId = eventString(parsed, 'chatId') ?? chatId + return runId && chatId ? { runId, chatId } : null + } + + try { + for await (const line of linesOf(response.body)) { + if (line !== '') { + eventLines.push(line) + continue + } + const accepted = consume() + if (accepted) return accepted + } + if (eventLines.length > 0) { + const accepted = consume() + if (accepted) return accepted + } + } catch (error) { + if (error instanceof SimApiError) throw error + const message = error instanceof Error ? error.message : String(error) + throw new SimApiError(`Sim Chat stream failed: ${sanitize(message)}`, 0) + } + + throw new SimApiError('Sim Chat ended before accepting the asynchronous run.', 0) +} + function tokenFrom(value: unknown): string | null { if (!value || typeof value !== 'object') return null const token = (value as { continuationToken?: unknown }).continuationToken @@ -222,6 +323,71 @@ function activityFrom(value: unknown): ChatActivityUpdate | null { : null } +function optionalSnapshotString(value: unknown, field: string): string | null | undefined { + if (value === undefined) return undefined + if (value === null) return null + if (typeof value !== 'string') { + throw new SimApiError(`Sim Chat returned an invalid ${field}.`, 0) + } + return sanitize(value) +} + +function parseChatRunSnapshot(value: unknown, expectedRunId: string): ParsedChatRunSnapshot { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new SimApiError('Sim Chat returned an invalid run status.', 0) + } + const envelope = value as Record<string, unknown> + const raw = + envelope.data && typeof envelope.data === 'object' && !Array.isArray(envelope.data) + ? (envelope.data as Record<string, unknown>) + : envelope + const runId = optionalSnapshotString(raw.runId, 'run ID') + const chatId = optionalSnapshotString(raw.chatId, 'chat ID') + const status = optionalSnapshotString(raw.status, 'run status') + if (!runId || !chatId || !status) { + throw new SimApiError('Sim Chat returned an incomplete run status.', 0) + } + if (runId !== expectedRunId) { + throw new SimApiError('Sim Chat returned status for a different run.', 0) + } + if (!CHAT_RUN_STATUSES.has(status as GetChatRunResponse['data']['status'])) { + throw new SimApiError(`Sim Chat returned unknown run status "${safeOneLine(status)}".`, 0) + } + if (raw.response !== undefined && raw.response !== null && typeof raw.response !== 'string') { + throw new SimApiError('Sim Chat returned an invalid run response.', 0) + } + if (raw.activities !== undefined && !Array.isArray(raw.activities)) { + throw new SimApiError('Sim Chat returned invalid run activity.', 0) + } + + return { + raw, + snapshot: { + runId, + chatId, + status: status as GetChatRunResponse['data']['status'], + // The structured stream parser owns human-output sanitization. Retaining + // the exact cumulative string here also makes prefix checks meaningful. + response: typeof raw.response === 'string' ? raw.response : '', + activities: Array.isArray(raw.activities) + ? raw.activities.flatMap((activity) => { + const parsed = activityFrom(activity) + return parsed ? [parsed] : [] + }) + : [], + ...(raw.chatTitle !== undefined + ? { chatTitle: optionalSnapshotString(raw.chatTitle, 'chat title') } + : {}), + ...(raw.startedAt !== undefined + ? { startedAt: optionalSnapshotString(raw.startedAt, 'start time') } + : {}), + ...(raw.completedAt !== undefined + ? { completedAt: optionalSnapshotString(raw.completedAt, 'completion time') } + : {}), + }, + } +} + /** Reads one public chat turn, optionally forwarding raw text deltas to a safe renderer. */ export async function readChatTurn( response: Response, @@ -312,7 +478,7 @@ export async function readChatResponse(response: Response): Promise<string> { return (await readChatTurn(response)).content } -function requestChat(client: SimClient, body: ChatRequest, signal: AbortSignal): Promise<Response> { +function requestChat(client: SimClient, body: ChatBody, signal: AbortSignal): Promise<Response> { return client.requestRaw(V2_OPERATIONS.chat.path, { method: 'POST', headers: { accept: 'text/event-stream' }, @@ -322,6 +488,178 @@ function requestChat(client: SimClient, body: ChatRequest, signal: AbortSignal): }) } +function isMachineOutput(format: OutputFormat): boolean { + return format === 'json' || format === 'yaml' +} + +function renderRunProgress( + snapshot: ChatRunSnapshot, + previousStatus: string | undefined, + seenActivityUpdates: Set<string>, + write: (content: string) => void +): void { + if (snapshot.status !== previousStatus) write(`status: ${safeOneLine(snapshot.status)}\n`) + + snapshot.activities.forEach((activity, index) => { + if (activity.kind === 'narration') { + const narration = safeOneLine(activity.delta) + if (!narration) return + const key = `${index}:narration:${activity.parentId}:${narration}` + if (seenActivityUpdates.has(key)) return + seenActivityUpdates.add(key) + write(` ${narration}\n`) + return + } + + // The API returns a cumulative, chronological activity snapshot. Include + // the stable position and full public transition in the identity so a + // running -> complete pair is emitted once each rather than replayed on + // every poll. + const key = `${index}:${activity.kind}:${activity.id}:${activity.state}:${activity.label}` + if (seenActivityUpdates.has(key)) return + seenActivityUpdates.add(key) + const marker = activity.state === 'running' ? '●' : activity.state === 'complete' ? '✓' : '✗' + write(`${marker} ${safeOneLine(activity.label)}\n`) + }) +} + +async function followChatRun( + client: SimClient, + workspaceId: string, + runId: string, + format: OutputFormat, + dependencies: ChatDependencies +): Promise<void> { + const controller = new AbortController() + let interrupted = false + const stopListening = dependencies.onInterrupt(() => { + interrupted = true + controller.abort() + }) + const machineOutput = isMachineOutput(format) + const progressEnabled = !machineOutput && dependencies.showProgress() + const seenActivityUpdates = new Set<string>() + const responseParser = machineOutput ? null : new ChatStructuredParser() + const responseSegments: ChatStructuredSegment[] = [] + let observedResponse = '' + let emittedResponse = '' + let renderedProgressStatus: string | undefined + let finalSnapshot: ChatRunSnapshot | undefined + let finalRawSnapshot: Record<string, unknown> | undefined + + try { + while (!interrupted) { + let result: GetChatRunResponse + try { + result = await client.request<GetChatRunResponse>( + resolvePath(V2_OPERATIONS.getChatRun.path, { runId }), + { + query: { workspaceId }, + signal: controller.signal, + auth: 'optional', + } + ) + } catch (error) { + if (interrupted || controller.signal.aborted) break + if (error instanceof SimApiError && (error.status === 429 || error.status >= 500)) { + try { + await dependencies.pollDelay(CHAT_RUN_POLL_MS, controller.signal) + } catch (delayError) { + if (interrupted || controller.signal.aborted) break + throw delayError + } + continue + } + throw error + } + + const parsed = parseChatRunSnapshot(result, runId) + const { snapshot } = parsed + finalSnapshot = snapshot + finalRawSnapshot = parsed.raw + + let displayDelta = '' + if (!machineOutput) { + if (!snapshot.response.startsWith(observedResponse)) { + throw new SimApiError('Sim Chat returned a non-monotonic run response.', 0) + } + const responseDelta = snapshot.response.slice(observedResponse.length) + observedResponse = snapshot.response + if (responseDelta) responseSegments.push(...responseParser!.push(responseDelta)) + const terminal = TERMINAL_CHAT_RUN_STATUSES.has(snapshot.status) + if (terminal) responseSegments.push(...responseParser!.finish()) + + const rendered = sanitize( + renderChatStructured( + withoutTrailingStandaloneResource(responseSegments), + renderContext(false) + ).text + ) + // A future structured tag may own the whitespace immediately before + // it. Hold that tiny unstable suffix until more content or completion + // makes its purpose known, while still streaming all substantive text. + const stableRendered = terminal ? rendered : rendered.trimEnd() + if (!stableRendered.startsWith(emittedResponse)) { + throw new SimApiError('Sim Chat returned non-monotonic rendered output.', 0) + } + displayDelta = stableRendered.slice(emittedResponse.length) + // Progress owns complete lines. Once response prose starts, defer any + // later progress until the response has received its final newline so + // stdout and stderr cannot concatenate on a shared terminal. + if (progressEnabled && !emittedResponse) { + renderRunProgress( + snapshot, + renderedProgressStatus, + seenActivityUpdates, + dependencies.writeProgress + ) + renderedProgressStatus = snapshot.status + } + if (displayDelta) dependencies.writeStream(displayDelta) + emittedResponse = stableRendered + } + + if (TERMINAL_CHAT_RUN_STATUSES.has(snapshot.status)) break + try { + await dependencies.pollDelay(CHAT_RUN_POLL_MS, controller.signal) + } catch (error) { + if (interrupted || controller.signal.aborted) break + throw error + } + } + } finally { + stopListening() + } + + if (interrupted) { + if (!machineOutput && emittedResponse && !emittedResponse.endsWith('\n')) { + dependencies.writeStream('\n') + } + return + } + if (!finalSnapshot) return + if (machineOutput) { + printProtocolResult(format, finalRawSnapshot ?? { ...finalSnapshot }) + } else { + if (emittedResponse && !emittedResponse.endsWith('\n')) dependencies.writeStream('\n') + if (progressEnabled && emittedResponse) { + renderRunProgress( + finalSnapshot, + renderedProgressStatus, + seenActivityUpdates, + dependencies.writeProgress + ) + } + } + if (FAILED_CHAT_RUN_STATUSES.has(finalSnapshot.status)) { + throw new SimApiError( + `Sim Chat run ended with status "${safeOneLine(finalSnapshot.status)}".`, + 0, + 'CHAT_RUN_FAILED' + ) + } +} + function renderContext(interactive: boolean) { return { printMode: !interactive } } @@ -333,7 +671,9 @@ async function runOneShot( attachments: ChatAttachment[], readOnly: boolean, dependencies: ChatDependencies, - continuationToken?: string + output: OutputFormat, + continuationToken?: string, + asyncMode = false ): Promise<void> { const controller = new AbortController() const cancel = () => controller.abort() @@ -345,18 +685,46 @@ async function runOneShot( { workspaceId, prompt, + // The server's default persists a normal one-shot chat. Keep the new + // field off the blocking wire for compatibility with older strict v2 + // servers; detached runs must opt in explicitly to both behaviors. + ...(asyncMode ? { async: true, persistChat: true } : {}), ...(readOnly ? { readOnly: true } : {}), ...(continuationToken ? { continuationToken } : {}), ...(attachments.length ? { attachments } : {}), }, controller.signal ) - const result = await readChatTurn(response) + if (asyncMode) { + const accepted = await readChatAcceptance(response) + printProtocolResult(output, { ...accepted, status: 'active' }) + return + } + let chatId: string | null = null + const result = await readChatTurn(response, { + onChatId: (acceptedChatId) => { + chatId = acceptedChatId + }, + }) const segments = withoutTrailingStandaloneResource(parseChatStructured(result.content)) const rendered = renderChatStructured(segments, renderContext(false)) // Print mode deliberately has no ANSI/OSC of its own, so a final defense at // the stdout boundary is safe and preserves shell composability. - dependencies.writeOutput(sanitize(rendered.text)) + const content = sanitize(rendered.text) + if (isMachineOutput(output)) { + // Machine formats preserve the server's exact completed content. JSON + // and YAML encode control bytes safely and are the lossless API surface. + printProtocolResult(output, { content: result.content, chatId }) + return + } + dependencies.writeOutput(content) + if (dependencies.showProgress()) { + dependencies.writeProgress( + chatId + ? `chat: ${safeOneLine(chatId)}\n` + : 'chat: not saved (a personal API key is required for resumable history)\n' + ) + } } catch (error) { if (controller.signal.aborted) throw new SimApiError('Sim Chat cancelled.', 0) throw error @@ -1450,8 +1818,24 @@ function collectFile(value: string, previous: string[] = []): string[] { return [...previous, value] } -/** Creates print-mode and interactive workspace chat. */ -export function chatCommand(overrides: Partial<ChatDependencies> = {}): Command { +interface ChatCommandOptions { + async?: boolean + chat?: string + file?: string[] + readOnly?: boolean +} + +function addChatInputOptions(command: Command): Command { + return command + .option('-f, --file <path>', 'Attach a local file (repeatable)', collectFile, []) + .option('--read-only', 'Restrict Sim Chat to read-only workspace tools') +} + +/** Creates one-shot and interactive workspace chat. */ +export function chatCommand( + overrides: Partial<ChatDependencies> = {}, + target = new Command('chat') +): Command { const dependencies: ChatDependencies = { readInput: readPipedInput, writeOutput: writeCompletedAnswer, @@ -1465,97 +1849,127 @@ export function chatCommand(overrides: Partial<ChatDependencies> = {}): Command // propagated TERM=dumb value must not leave model Markdown visible inside // an otherwise fully rendered TUI. formatMarkdown: () => Boolean(process.stdout.isTTY), + writeStream: (content) => process.stdout.write(content), + writeProgress: (content) => process.stderr.write(content), + showProgress: () => Boolean(process.stderr.isTTY), + pollDelay: (milliseconds, signal) => delay(milliseconds, undefined, { signal }), + onInterrupt: (listener) => { + process.once('SIGINT', listener) + return () => process.removeListener('SIGINT', listener) + }, ...overrides, } - return new Command('chat') - .description('Ask Sim Chat about the active workspace') - .argument('[prompt...]', 'Question to ask') - .option('-p, --print', 'Print the final response and exit') - .option('--chat <chatId>', 'Resume an existing chat by ID (print mode only)') - .option('-f, --file <path>', 'Attach a local file (repeatable)', collectFile, []) - .option('--read-only', 'Restrict Sim Chat to read-only workspace tools') - .action( - async ( - promptParts: string[], - options: { print?: boolean; chat?: string; file: string[]; readOnly?: boolean }, - command: Command - ) => { - const positionalPrompt = promptParts.join(' ') - const positionalBytes = utf8Bytes(positionalPrompt) - if (positionalBytes > MAX_CHAT_PROMPT_BYTES) throw inputTooLarge() - - const chatId = options.chat?.trim() - if (options.chat !== undefined && !chatId) { - throw new SimApiError('Chat ID must not be empty.', 0) - } - if (chatId && !options.print) { - throw new SimApiError( - '--chat can only be used with -p/--print. Use /chats in interactive mode.', - 0 - ) - } + const run = async (promptParts: string[], oneShot: boolean, command: Command): Promise<void> => { + const options = command.optsWithGlobals() as ChatCommandOptions + const positionalPrompt = promptParts.join(' ') + const positionalBytes = utf8Bytes(positionalPrompt) + if (positionalBytes > MAX_CHAT_PROMPT_BYTES) throw inputTooLarge() - const interactive = !options.print && dependencies.isInteractive() - if (!options.print && !interactive) { - throw new SimApiError( - 'Interactive Sim Chat requires a terminal. Use sim chat -p for pipelines or redirected output.', - 0 - ) - } - const separatorBytes = positionalPrompt ? 1 : 0 - const pipedInput = interactive - ? '' - : await dependencies.readInput(MAX_CHAT_PROMPT_BYTES - positionalBytes - separatorBytes) - const prompt = composeChatPrompt(promptParts, pipedInput) - if (utf8Bytes(prompt) > MAX_CHAT_PROMPT_BYTES) throw inputTooLarge() - - const attachments = await dependencies.loadAttachments(options.file ?? []) - if (!interactive && !prompt.trim() && attachments.length === 0) { - throw new SimApiError('Provide a prompt, attach a file, or pipe input to sim chat -p.', 0) - } + const chatId = options.chat?.trim() + if (options.chat !== undefined && !chatId) { + throw new SimApiError('Chat ID must not be empty.', 0) + } - const { client, profile } = clientFrom(command) - const workspaceId = client.requireWorkspace(undefined, { auth: 'optional' }) - if (interactive) { - await runInteractive( - client, - workspaceId, - prompt, - attachments, - options.readOnly === true, - dependencies, - profile.name - ) - return - } - let continuationToken: string | undefined - if (chatId) { - const chat = await loadChat(client, workspaceId, chatId, options.readOnly === true) - if (!chat.continuationToken) { - throw new SimApiError( - 'Sim Chat did not return a continuation token for the selected chat.', - 0 - ) - } - if (chat.active) { - throw new SimApiError( - 'The selected chat is currently active in another client. Wait for it to finish before resuming it.', - 409, - 'CONFLICT' - ) - } - continuationToken = chat.continuationToken - } - await runOneShot( - client, - workspaceId, - prompt, - attachments, - options.readOnly === true, - dependencies, - continuationToken + const interactive = !oneShot && dependencies.isInteractive() + if (!oneShot && !interactive) { + throw new SimApiError( + 'Interactive Sim Chat requires a terminal. Use sim chat ask for pipelines or redirected output.', + 0 + ) + } + const separatorBytes = positionalPrompt ? 1 : 0 + const pipedInput = interactive + ? '' + : await dependencies.readInput(MAX_CHAT_PROMPT_BYTES - positionalBytes - separatorBytes) + const prompt = composeChatPrompt(promptParts, pipedInput) + if (utf8Bytes(prompt) > MAX_CHAT_PROMPT_BYTES) throw inputTooLarge() + + const attachments = await dependencies.loadAttachments(options.file ?? []) + if (!interactive && !prompt.trim() && attachments.length === 0) { + throw new SimApiError('Provide a prompt, attach a file, or pipe input to sim chat ask.', 0) + } + + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace(undefined, { auth: 'optional' }) + if (interactive) { + await runInteractive( + client, + workspaceId, + prompt, + attachments, + options.readOnly === true, + dependencies, + profile.name + ) + return + } + + let continuationToken: string | undefined + if (chatId) { + const chat = await loadChat(client, workspaceId, chatId, options.readOnly === true) + if (!chat.continuationToken) { + throw new SimApiError( + 'Sim Chat did not return a continuation token for the selected chat.', + 0 + ) + } + if (chat.active) { + throw new SimApiError( + 'The selected chat is currently active in another client. Wait for it to finish before resuming it.', + 409, + 'CONFLICT' ) } + continuationToken = chat.continuationToken + } + await runOneShot( + client, + workspaceId, + prompt, + attachments, + options.readOnly === true, + dependencies, + profile.output, + continuationToken, + options.async === true ) + } + + const chat = addChatInputOptions(target) + .description('Ask Sim Chat about the active workspace') + .argument('[prompt...]', 'Question to ask') + .action((promptParts: string[], _options: ChatCommandOptions, command: Command) => + run(promptParts, false, command) + ) + + const ask = addChatInputOptions(new Command('ask')) + .description('Ask once, save the chat, print the response, and exit') + .argument('[prompt...]', 'Question to ask') + .option('--chat <chatId>', 'Continue an existing chat by ID') + .option('--async', 'Start the chat run and return immediately') + .action((promptParts: string[], _options: ChatCommandOptions, command: Command) => + run(promptParts, true, command) + ) + + const follow = new Command('follow') + .description('Follow a chat run until it finishes') + .argument('<runId>', 'Chat run ID returned by chat ask --async') + .allowExcessArguments(false) + .action(async (runId: string, _options: unknown, command: Command) => { + const normalizedRunId = runId.trim() + if (!normalizedRunId) throw new SimApiError('Run ID must not be empty.', 0) + const { client, profile } = clientFrom(command) + await followChatRun( + client, + client.requireWorkspace(undefined, { auth: 'optional' }), + normalizedRunId, + profile.output, + dependencies + ) + }) + + chat.addCommand(ask) + chat.addCommand(follow) + return chat } diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index 1d07aef5d84..09bb27a5abe 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -16,7 +16,7 @@ function group(program: Command, name: string): Command { /** Attaches commands whose multi-request or binary protocols cannot be generated. */ export function attachProtocolCommands(program: Command): void { - program.addCommand(chatCommand()) + chatCommand({}, group(program, 'chat')) const files = group(program, 'files') attachFileUpload(files) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index a36b89ea4c3..fb89c837362 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -317,6 +317,30 @@ export const CLI_CONTRACT: CliContract = { { header: 'active', format: 'bool' }, ], }, + listChatRuns: { + describe: 'List recent Sim Chat runs', + auth: 'optional', + flags: { status: { describe: 'Filter by durable run status' } }, + columns: [ + { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'status' }, + { header: 'title', path: 'chatTitle' }, + { header: 'chat', path: 'chatId' }, + { header: 'run', path: 'runId' }, + ], + }, + getChatRun: { + describe: 'Show chat run status (response and activity are included in JSON or YAML output)', + auth: 'optional', + fields: [ + { header: 'run', path: 'runId' }, + { header: 'chat', path: 'chatId' }, + { header: 'title', path: 'chatTitle' }, + { header: 'status' }, + { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'completed', path: 'completedAt', format: 'timestamp' }, + ], + }, renameChat: { command: 'chats rename', describe: 'Rename a chat', diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index d7df31c341e..a75bc584377 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -135,6 +135,8 @@ export interface CommandSpec { variants?: readonly CommandVariantSpec[] /** One-line help. Falls back to the OpenAPI summary for the operation. */ describe?: string + /** Allow an auth-disabled self-hosted route to run without a locally stored API key. */ + auth?: 'required' | 'optional' /** Per-field flag overrides, keyed by the contract's field name. */ flags?: Record<string, FlagSpec> /** Friendly mutually-exclusive flags for an otherwise opaque union body. */ diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 29e47ba4b4e..1375156fae7 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -358,6 +358,8 @@ export type ChatBody = { prompt: string continuationToken?: string readOnly?: boolean + async?: boolean + persistChat?: boolean attachments?: Array<{ name: string mediaType: string @@ -1661,6 +1663,11 @@ export type DeployWorkflowParams = { id: string } +export type DeployWorkflowBody = { + name?: string + description?: string | null +} + export type DeployWorkflowResponse = { data: { id: string @@ -1978,6 +1985,41 @@ export type GetChatResponse = { } } +/** `GET /api/v2/chat/runs/[runId]` */ +export type GetChatRunParams = { + runId: string +} + +export type GetChatRunQuery = { + workspaceId: string +} + +export type GetChatRunResponse = { + data: { + runId: string + chatId: string + chatTitle: string | null + status: 'active' | 'paused_waiting_for_tool' | 'resuming' | 'complete' | 'error' | 'cancelled' + startedAt: string + completedAt: string | null + response: string + activities: Array< + | { + kind: 'subagent' | 'tool' + id: string + parentId?: string + label: string + state: 'running' | 'complete' | 'error' + } + | { + kind: 'narration' + parentId: string + delta: string + } + > + } +} + /** `GET /api/v2/custom-tools/[id]` */ export type GetCustomToolParams = { id: string @@ -2663,6 +2705,26 @@ export type ListBillingLogsResponse = { nextCursor: string | null } +/** `GET /api/v2/chat/runs` */ +export type ListChatRunsQuery = { + workspaceId: string + status?: 'active' | 'paused_waiting_for_tool' | 'resuming' | 'complete' | 'error' | 'cancelled' + limit?: number + cursor?: string +} + +export type ListChatRunsResponse = { + data: Array<{ + runId: string + chatId: string + chatTitle: string | null + status: 'active' | 'paused_waiting_for_tool' | 'resuming' | 'complete' | 'error' | 'cancelled' + startedAt: string + completedAt: string | null + }> + nextCursor: string | null +} + /** `GET /api/v2/chats` */ export type ListChatsQuery = { workspaceId: string @@ -3548,6 +3610,10 @@ export type RollbackWorkflowParams = { id: string } +export type RollbackWorkflowBody = { + version?: number +} + export type RollbackWorkflowResponse = { data: { id: string @@ -4432,6 +4498,8 @@ export const V2_OPERATIONS = { prompt: { kind: 'string', required: true }, continuationToken: { kind: 'string' }, readOnly: { kind: 'boolean', default: false }, + async: { kind: 'boolean', default: false }, + persistChat: { kind: 'boolean', default: true }, attachments: { kind: 'array' }, contexts: { kind: 'array' }, }, @@ -4933,6 +5001,10 @@ export const V2_OPERATIONS = { pathParams: ['id'] as const, responseMode: 'json', summary: 'Deploy Workflow', + body: { + name: { kind: 'string' }, + description: { kind: 'string' }, + }, }, downloadFile: { method: 'GET', @@ -5013,6 +5085,16 @@ export const V2_OPERATIONS = { readOnly: { kind: 'boolean' }, }, }, + getChatRun: { + method: 'GET', + path: '/api/v2/chat/runs/[runId]', + pathParams: ['runId'] as const, + responseMode: 'json', + summary: 'Get Sim Chat Run', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, getCustomTool: { method: 'GET', path: '/api/v2/custom-tools/[id]', @@ -5239,6 +5321,29 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + listChatRuns: { + method: 'GET', + path: '/api/v2/chat/runs', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Sim Chat Runs', + query: { + workspaceId: { kind: 'string', required: true }, + status: { + kind: 'enum', + values: [ + 'active', + 'paused_waiting_for_tool', + 'resuming', + 'complete', + 'error', + 'cancelled', + ] as const, + }, + limit: { kind: 'number', default: 30 }, + cursor: { kind: 'string' }, + }, + }, listChats: { method: 'GET', path: '/api/v2/chats', @@ -5737,6 +5842,9 @@ export const V2_OPERATIONS = { pathParams: ['id'] as const, responseMode: 'json', summary: 'Rollback Workflow', + body: { + version: { kind: 'integer' }, + }, }, runRowEnrichment: { method: 'POST', diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index a29feb9e8aa..1687fbc4a8a 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -32,7 +32,7 @@ program .name('sim') .description('Talk to the Sim API from your terminal') .version(readPackageVersion()) - .option('-P, --profile <name>', 'Profile to use (env: SIM_PROFILE)') + .option('-p, --profile <name>', 'Profile to use (env: SIM_PROFILE)') .option('--endpoint <url>', 'Sim deployment to talk to (env: SIM_ENDPOINT)') .option('-w, --workspace <id>', 'Workspace to target (env: SIM_WORKSPACE)') .addOption( @@ -55,12 +55,12 @@ program.addHelpText( 'after', ` Profiles work like the AWS CLI: settings live in ~/.sim/config, keys in -~/.sim/credentials (0600). Select one with -P, --profile, or SIM_PROFILE. +~/.sim/credentials (0600). Select one with -p, --profile, or SIM_PROFILE. Examples: $ sim login Authorize the default profile $ sim login --profile dev --endpoint http://localhost:3000 - $ sim chat -p "Which workflows handle support tickets?" + $ sim chat ask "Which workflows handle support tickets?" $ sim workflows list $ sim logs list --level error --limit 20 $ sim --output json tables get tbl_123 Override output for one command diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 03a03f1cd2f..a87fe81bf0d 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -158,6 +158,41 @@ describe('commands parsed through commander', () => { }) }) + it('exposes pollable chat runs under the manual chat command group', async () => { + expect( + commandAt('chat', 'runs') + .commands.map((command) => command.name()) + .sort() + ).toEqual(['get', 'list']) + + const listHelp = commandAt('chat', 'runs', 'list').helpInformation() + expect(listHelp).toContain('--status <value>') + expect(listHelp).toContain('--limit <n>') + const [listPath, listOptions] = await run([ + 'chat', + 'runs', + 'list', + '--status', + 'active', + '--limit', + '5', + ]) + expect(listPath).toBe('/api/v2/chat/runs') + expect(listOptions.query).toMatchObject({ + workspaceId: 'ws_local', + status: 'active', + limit: 5, + }) + expect(listOptions.auth).toBe('optional') + + const get = commandAt('chat', 'runs', 'get') + expect(get.description()).toContain('response and activity') + const [getPath, getOptions] = await run(['chat', 'runs', 'get', 'run_1']) + expect(getPath).toBe('/api/v2/chat/runs/run_1') + expect(getOptions.query).toEqual({ workspaceId: 'ws_local' }) + expect(getOptions.auth).toBe('optional') + }) + it('describes generated resource and sub-resource groups', () => { expect(commandAt('tables').description()).toBe('Manage tables') expect(commandAt('tables', 'rows').description()).toBe('Manage table rows') diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 73d6adbe6c6..afe62fa1725 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -54,6 +54,7 @@ export async function executeOperation( } const { client, profile } = clientFrom(host) + const auth = commandSpec.auth const hasWorkspaceField = Boolean( (operationSpec.query && PROFILE_INJECTED_FIELD in operationSpec.query) || (operationSpec.body && PROFILE_INJECTED_FIELD in operationSpec.body) @@ -63,7 +64,11 @@ export async function executeOperation( operation, positional, requestFlags, - hasWorkspaceField && !omitsWorkspace ? client.requireWorkspace() : profile.workspaceId + hasWorkspaceField && !omitsWorkspace + ? auth + ? client.requireWorkspace(undefined, { auth }) + : client.requireWorkspace() + : profile.workspaceId ) const paging = cursorSlot(operationSpec) @@ -87,6 +92,7 @@ export async function executeOperation( paging === 'body' ? { ...(request.body ?? {}), ...pageLimit, ...(cursor ? { cursor } : {}) } : request.body, + ...(auth ? { auth } : {}), }) rows.push(...page.data) cursor = page.nextCursor @@ -100,6 +106,7 @@ export async function executeOperation( method: operationSpec.method, query: request.query, body: request.body, + ...(auth ? { auth } : {}), }) renderResult(operation, profile.output, result?.data ?? result, commandSpec, { expandedTrace: requestFlags.trace === true, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index da366e427f4..6292205b951 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1093, - zodRoutes: 1093, + totalRoutes: 1095, + zodRoutes: 1095, nonZodRoutes: 0, } as const From a6aad94952cec236dca29377304581660690c0ad Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <siddharthganesan@gmail.com> Date: Sat, 8 Aug 2026 16:07:35 -0700 Subject: [PATCH 111/159] fix(cli): guard file unsharing --- packages/sim-cli/src/contract/commands.ts | 1 + packages/sim-cli/src/runtime/build.test.ts | 2 +- scripts/check-api-validation-contracts.ts | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index a57c61c8d28..ae0c648107b 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -571,6 +571,7 @@ export const CLI_CONTRACT: CliContract = { unshareFile: { command: 'files unshare', describe: 'Disable sharing for a file', + confirm: 'This disables shared access to the file.', fields: [{ header: 'shared', path: 'sharing.enabled', format: 'bool' }], }, diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 7fb44185f4f..bb0f3f169cf 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -408,7 +408,7 @@ describe('commands parsed through commander', () => { allowedEmails: ['ada@example.com'], }) - const [unsharePath, unshareOptions] = await run(['file', 'unshare', 'file_1']) + const [unsharePath, unshareOptions] = await run(['file', 'unshare', 'file_1', '--yes']) expect(unsharePath).toBe('/api/v2/files/file_1/share') expect(unshareOptions.method).toBe('DELETE') expect(unshareOptions.query).toEqual({ workspaceId: 'ws_local' }) diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 6292205b951..0d4c07bd18f 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1095, - zodRoutes: 1095, + totalRoutes: 1094, + zodRoutes: 1094, nonZodRoutes: 0, } as const From aae5354f75915afc70a81423792b1daf64ef96aa Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 8 Aug 2026 16:12:52 -0700 Subject: [PATCH 112/159] feat(auth): add scoped internal executor delegation (#6459) * feat(auth): add scoped internal executor delegation * fix(auth): derive delegation lifetime from one timestamp --- .../[id]/files/[fileId]/csv-preview/route.ts | 4 +- apps/sim/lib/api/server/routes/index.ts | 2 +- .../api/server/routes/internal-json-route.ts | 55 ++++++-- apps/sim/lib/auth/internal-delegation.test.ts | 115 +++++++++++++++++ apps/sim/lib/auth/internal-delegation.ts | 59 +++++++++ apps/sim/lib/auth/internal.test.ts | 65 +++++++++- apps/sim/lib/auth/internal.ts | 119 +++++++++++++++++- apps/sim/lib/workspace-files/api/index.ts | 2 +- .../api/route-policies.test.ts | 113 ++++++++++++++--- .../lib/workspace-files/api/route-policies.ts | 26 ++-- packages/auth/src/principal.ts | 11 ++ 11 files changed, 520 insertions(+), 51 deletions(-) create mode 100644 apps/sim/lib/auth/internal-delegation.test.ts create mode 100644 apps/sim/lib/auth/internal-delegation.ts diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts index 100a7fadca0..de4b8c6104f 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { getWorkspaceCsvPreviewContract } from '@/lib/api/contracts/workspace-file-table' import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' -import { internalFileErrorPolicies, internalSessionOrServiceAuth } from '@/lib/workspace-files/api' +import { internalFileErrorPolicies, internalSessionOrExecutorAuth } from '@/lib/workspace-files/api' import { csvPreviewWorkspaceFile } from '@/lib/workspace-files/application/csv-preview-workspace-file' const logger = createLogger('WorkspaceCsvPreviewAPI') @@ -11,7 +11,7 @@ export const dynamic = 'force-dynamic' export const GET = defineInternalJsonRoute({ contract: getWorkspaceCsvPreviewContract, - auth: internalSessionOrServiceAuth, + auth: internalSessionOrExecutorAuth, operation: csvPreviewWorkspaceFile.operation, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal CSV preview behavior' }), errorPolicy: internalFileErrorPolicies.plain, diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts index 3eacbed3f3e..408ecff04d0 100644 --- a/apps/sim/lib/api/server/routes/index.ts +++ b/apps/sim/lib/api/server/routes/index.ts @@ -1,6 +1,6 @@ export { defineInternalBinaryRoute } from '@/lib/api/server/routes/internal-binary-route' export { - createInternalSessionOrServiceAuth, + createInternalSessionOrExecutorAuth, defineInternalJsonRoute, extendInternalErrorPolicy, type InternalAuthPolicy, 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 8191e353794..6563d5e7cdb 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -1,4 +1,9 @@ -import type { DelegatedPrincipal, Principal, SessionPrincipal } from '@sim/auth/principal' +import type { + DelegatedPrincipal, + Principal, + SessionPrincipal, + WorkflowExecutionDelegatedPrincipal, +} from '@sim/auth/principal' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import type { ContractJsonResponse } from '@/lib/api/contracts' @@ -15,7 +20,14 @@ import { parseRequest, } from '@/lib/api/server/validation' import { getSession } from '@/lib/auth' -import { verifyInternalToken } from '@/lib/auth/internal' +import { + InvalidInternalDelegationTokenError, + verifyInternalDelegationToken, +} from '@/lib/auth/internal' +import { + bindInternalExecutorDelegation, + InvalidInternalDelegationBindingError, +} from '@/lib/auth/internal-delegation' import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -37,12 +49,18 @@ export const internalSessionAuth = { }, } as const -export function createInternalSessionOrServiceAuth<P extends DelegatedPrincipal>( - bindDelegation: (args: { - subjectUserId: string +export interface InternalSessionOrExecutorAuthOptions { + audience: string + resourceScope?( params: Record<string, string | string[] | undefined> - }) => P -): InternalAuthPolicy<SessionPrincipal | P> { + ): DelegatedPrincipal['resourceScope'] +} + +export function createInternalSessionOrExecutorAuth( + options: InternalSessionOrExecutorAuthOptions +): InternalAuthPolicy<SessionPrincipal | WorkflowExecutionDelegatedPrincipal> { + if (!options.audience.trim()) throw new Error('Internal executor auth audience must not be empty') + return { async authenticate(request, params) { if (request.headers.has('x-api-key')) { @@ -50,13 +68,28 @@ export function createInternalSessionOrServiceAuth<P extends DelegatedPrincipal> } const authorization = request.headers.get('authorization') - if (!authorization?.startsWith('Bearer ')) return internalSessionAuth.authenticate() + if (!authorization) return internalSessionAuth.authenticate() + if (!authorization.startsWith('Bearer ')) { + throw new InternalUnauthenticatedError('Authentication required') + } - const verification = await verifyInternalToken(authorization.slice('Bearer '.length)) - if (!verification.valid || !verification.userId) { + let delegation + try { + delegation = await verifyInternalDelegationToken(authorization.slice('Bearer '.length)) + } catch (error) { + if (!(error instanceof InvalidInternalDelegationTokenError)) throw error + throw new InternalUnauthenticatedError('Authentication required') + } + + try { + return await bindInternalExecutorDelegation(delegation, { + audience: options.audience, + resourceScope: options.resourceScope?.(params), + }) + } catch (error) { + if (!(error instanceof InvalidInternalDelegationBindingError)) throw error throw new InternalUnauthenticatedError('Authentication required') } - return bindDelegation({ subjectUserId: verification.userId, params }) }, } } diff --git a/apps/sim/lib/auth/internal-delegation.test.ts b/apps/sim/lib/auth/internal-delegation.test.ts new file mode 100644 index 00000000000..a041ec712a8 --- /dev/null +++ b/apps/sim/lib/auth/internal-delegation.test.ts @@ -0,0 +1,115 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockResolveWorkflow, mockResolveRun } = vi.hoisted(() => ({ + mockResolveWorkflow: vi.fn(), + mockResolveRun: vi.fn(), +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mockResolveWorkflow, + resolveActiveWorkflowRunApplicationContext: mockResolveRun, +})) + +import { + bindInternalExecutorDelegation, + InvalidInternalDelegationBindingError, +} from '@/lib/auth/internal-delegation' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const claims = { + serviceId: 'executor' as const, + subjectUserId: 'user-1', + workflowId: 'workflow-1', + delegationId: 'delegation-1', + issuedAt: new Date('2026-08-08T12:00:00.000Z'), + expiresAt: new Date('2026-08-08T12:05:00.000Z'), +} + +describe('bindInternalExecutorDelegation', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveWorkflow.mockResolvedValue({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + }) + mockResolveRun.mockResolvedValue({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + runId: 'execution-1', + }) + }) + + it('derives workspace authority from the canonical workflow', async () => { + await expect( + bindInternalExecutorDelegation(claims, { audience: 'sim:knowledge' }) + ).resolves.toEqual({ + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:knowledge', + issuedAt: claims.issuedAt, + expiresAt: claims.expiresAt, + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + }, + }) + expect(mockResolveWorkflow).toHaveBeenCalledWith({ workflowId: 'workflow-1' }) + expect(mockResolveRun).not.toHaveBeenCalled() + }) + + it('canonically binds an execution to its signed workflow', async () => { + const executionClaims = { ...claims, executionId: 'execution-1' } + + const principal = await bindInternalExecutorDelegation(executionClaims, { + audience: 'sim:workspace-files', + resourceScope: { fileId: 'file-1' }, + }) + + expect(mockResolveRun).toHaveBeenCalledWith({ + runId: 'execution-1', + assertedWorkflowId: 'workflow-1', + }) + expect(principal).toMatchObject({ + workspaceId: 'workspace-1', + resourceScope: { fileId: 'file-1' }, + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + }) + + it('fails before canonical loading when the domain audience is missing', async () => { + await expect(bindInternalExecutorDelegation(claims, { audience: ' ' })).rejects.toThrow( + 'Internal delegation audience must not be empty' + ) + expect(mockResolveWorkflow).not.toHaveBeenCalled() + }) + + it('classifies a missing canonical execution as an invalid delegation binding', async () => { + mockResolveRun.mockRejectedValue(new OrchestrationError('not_found', 'Workflow run not found')) + + await expect( + bindInternalExecutorDelegation( + { ...claims, executionId: 'execution-1' }, + { audience: 'sim:workspace-files' } + ) + ).rejects.toBeInstanceOf(InvalidInternalDelegationBindingError) + }) + + it('does not disguise canonical-load infrastructure failures as invalid credentials', async () => { + const infrastructureError = new Error('database unavailable') + mockResolveWorkflow.mockRejectedValue(infrastructureError) + + await expect( + bindInternalExecutorDelegation(claims, { audience: 'sim:workspace-files' }) + ).rejects.toBe(infrastructureError) + }) +}) diff --git a/apps/sim/lib/auth/internal-delegation.ts b/apps/sim/lib/auth/internal-delegation.ts new file mode 100644 index 00000000000..b37b4418c8a --- /dev/null +++ b/apps/sim/lib/auth/internal-delegation.ts @@ -0,0 +1,59 @@ +import type { DelegatedPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import type { VerifiedInternalDelegation } from '@/lib/auth/internal' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { + resolveActiveWorkflowApplicationContext, + resolveActiveWorkflowRunApplicationContext, +} from '@/lib/workflows/application/context' + +export interface BindInternalExecutorDelegationOptions { + audience: string + resourceScope?: DelegatedPrincipal['resourceScope'] +} + +export class InvalidInternalDelegationBindingError extends Error { + constructor() { + super('Internal delegation no longer resolves to an active workflow execution') + this.name = 'InvalidInternalDelegationBindingError' + } +} + +/** Binds signed executor claims to the workflow's canonical active workspace. */ +export async function bindInternalExecutorDelegation( + claims: VerifiedInternalDelegation, + options: BindInternalExecutorDelegationOptions +): Promise<WorkflowExecutionDelegatedPrincipal> { + if (!options.audience.trim()) throw new Error('Internal delegation audience must not be empty') + + let context + try { + context = claims.executionId + ? await resolveActiveWorkflowRunApplicationContext({ + runId: claims.executionId, + assertedWorkflowId: claims.workflowId, + }) + : await resolveActiveWorkflowApplicationContext({ workflowId: claims.workflowId }) + } catch (error) { + if (asOrchestrationError(error)?.code === 'not_found') { + throw new InvalidInternalDelegationBindingError() + } + throw error + } + + return { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: claims.subjectUserId, + workspaceId: context.workspaceId, + delegationId: claims.delegationId, + audience: options.audience, + issuedAt: claims.issuedAt, + expiresAt: claims.expiresAt, + ...(options.resourceScope ? { resourceScope: options.resourceScope } : {}), + delegationContext: { + kind: 'workflow_execution', + workflowId: context.workflowId, + ...(claims.executionId ? { executionId: claims.executionId } : {}), + }, + } +} diff --git a/apps/sim/lib/auth/internal.test.ts b/apps/sim/lib/auth/internal.test.ts index fcabbb47c01..1f51376c7fc 100644 --- a/apps/sim/lib/auth/internal.test.ts +++ b/apps/sim/lib/auth/internal.test.ts @@ -3,11 +3,18 @@ */ import { resetEnvMock } from '@sim/testing' +import { decodeJwt } from 'jose' import { afterAll, describe, expect, it, vi } from 'vitest' vi.unmock('@/lib/auth/internal') -import { generateInternalToken, verifyInternalToken } from '@/lib/auth/internal' +import { + generateInternalDelegationToken, + generateInternalToken, + InvalidInternalDelegationTokenError, + verifyInternalDelegationToken, + verifyInternalToken, +} from '@/lib/auth/internal' afterAll(resetEnvMock) @@ -39,3 +46,59 @@ describe('internal JWT claims', () => { await expect(verifyInternalToken(token)).resolves.toEqual({ valid: false }) }) }) + +describe('internal executor delegation claims', () => { + it('round-trips a subject-bearing workflow execution delegation', async () => { + const token = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + + const delegation = await verifyInternalDelegationToken(token) + + expect(delegation).toMatchObject({ + serviceId: 'executor', + subjectUserId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + expect(delegation.delegationId).toBeTruthy() + expect(delegation.issuedAt).toBeInstanceOf(Date) + expect(delegation.expiresAt.getTime()).toBeGreaterThan(delegation.issuedAt.getTime()) + }) + + it('derives issued-at and expiry from one timestamp', async () => { + const token = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }) + const payload = decodeJwt(token) + + if (typeof payload.exp !== 'number' || typeof payload.iat !== 'number') { + throw new Error('Generated delegation token is missing numeric lifetime claims') + } + expect(payload.exp - payload.iat).toBe(5 * 60) + }) + + it('rejects missing delegation scope at issuance', async () => { + await expect( + generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: ' ', + }) + ).rejects.toThrow('Internal delegation workflowId must not be empty') + }) + + it('does not accept legacy subject or actorless tokens as executor delegations', async () => { + const legacySubjectToken = await generateInternalToken('user-1') + const actorlessToken = await generateInternalToken() + + await expect(verifyInternalDelegationToken(legacySubjectToken)).rejects.toBeInstanceOf( + InvalidInternalDelegationTokenError + ) + await expect(verifyInternalDelegationToken(actorlessToken)).rejects.toBeInstanceOf( + InvalidInternalDelegationTokenError + ) + }) +}) diff --git a/apps/sim/lib/auth/internal.ts b/apps/sim/lib/auth/internal.ts index 1880efee202..eb28a0cc645 100644 --- a/apps/sim/lib/auth/internal.ts +++ b/apps/sim/lib/auth/internal.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' -import { jwtVerify, SignJWT } from 'jose' +import { generateId } from '@sim/utils/id' +import { type JWTPayload, jwtVerify, SignJWT } from 'jose' import { type NextRequest, NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' import { getClientIp } from '@/lib/core/utils/request' @@ -14,6 +15,34 @@ export interface InternalTokenClaims { sandboxProfile?: InternalSandboxProfile } +export interface GenerateInternalDelegationTokenInput { + subjectUserId: string + workflowId: string + executionId?: string +} + +export interface VerifiedInternalDelegation { + serviceId: 'executor' + subjectUserId: string + workflowId: string + executionId?: string + delegationId: string + issuedAt: Date + expiresAt: Date +} + +export class InvalidInternalDelegationTokenError extends Error { + constructor(message = 'Invalid internal delegation token') { + super(message) + this.name = 'InvalidInternalDelegationTokenError' + } +} + +const INTERNAL_DELEGATION_ISSUER = 'sim-internal' +const INTERNAL_DELEGATION_AUDIENCE = 'sim-api' +const INTERNAL_DELEGATION_TTL_SECONDS = 5 * 60 +const INTERNAL_DELEGATION_CLOCK_TOLERANCE_SECONDS = 5 + const getJwtSecret = () => { // Prefer a dedicated JWT signing key so the internal-JWT trust domain is // separable from the raw INTERNAL_API_SECRET shared-bearer secret: leaking one @@ -57,6 +86,94 @@ export async function generateInternalToken( return token } +function requireNonEmptyDelegationClaim(value: string, name: string): string { + if (!value.trim()) throw new Error(`Internal delegation ${name} must not be empty`) + return value +} + +/** Generates a subject-bearing executor token bound to a workflow and optional execution origin. */ +export async function generateInternalDelegationToken( + input: GenerateInternalDelegationTokenInput +): Promise<string> { + const subjectUserId = requireNonEmptyDelegationClaim(input.subjectUserId, 'subjectUserId') + const workflowId = requireNonEmptyDelegationClaim(input.workflowId, 'workflowId') + const issuedAtSeconds = Math.floor(Date.now() / 1000) + const executionId = input.executionId + ? requireNonEmptyDelegationClaim(input.executionId, 'executionId') + : undefined + + return new SignJWT({ + type: 'internal_delegation', + serviceId: 'executor', + workflowId, + ...(executionId ? { executionId } : {}), + }) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(subjectUserId) + .setJti(generateId()) + .setIssuedAt(issuedAtSeconds) + .setExpirationTime(issuedAtSeconds + INTERNAL_DELEGATION_TTL_SECONDS) + .setIssuer(INTERNAL_DELEGATION_ISSUER) + .setAudience(INTERNAL_DELEGATION_AUDIENCE) + .sign(getJwtSecret()) +} + +function readVerifiedDelegationClaim(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null +} + +/** Verifies a scoped executor delegation without accepting legacy or actorless tokens. */ +export async function verifyInternalDelegationToken( + token: string +): Promise<VerifiedInternalDelegation> { + const secret = getJwtSecret() + let payload: JWTPayload + try { + const verification = await jwtVerify(token, secret, { + issuer: INTERNAL_DELEGATION_ISSUER, + audience: INTERNAL_DELEGATION_AUDIENCE, + algorithms: ['HS256'], + clockTolerance: INTERNAL_DELEGATION_CLOCK_TOLERANCE_SECONDS, + }) + payload = verification.payload + } catch { + throw new InvalidInternalDelegationTokenError() + } + + const subjectUserId = readVerifiedDelegationClaim(payload.sub) + const workflowId = readVerifiedDelegationClaim(payload.workflowId) + const executionId = + payload.executionId === undefined ? undefined : readVerifiedDelegationClaim(payload.executionId) + const delegationId = readVerifiedDelegationClaim(payload.jti) + const nowSeconds = Math.floor(Date.now() / 1000) + + if ( + payload.type !== 'internal_delegation' || + payload.serviceId !== 'executor' || + !subjectUserId || + !workflowId || + executionId === null || + !delegationId || + typeof payload.iat !== 'number' || + typeof payload.exp !== 'number' || + payload.iat > nowSeconds + INTERNAL_DELEGATION_CLOCK_TOLERANCE_SECONDS || + payload.exp <= payload.iat || + payload.exp - payload.iat > INTERNAL_DELEGATION_TTL_SECONDS + ) { + throw new InvalidInternalDelegationTokenError() + } + + return { + serviceId: 'executor', + subjectUserId, + workflowId, + ...(executionId ? { executionId } : {}), + delegationId, + issuedAt: new Date(payload.iat * 1000), + expiresAt: new Date(payload.exp * 1000), + } +} + /** * Verify an internal JWT token * Returns verification result with userId if present in token diff --git a/apps/sim/lib/workspace-files/api/index.ts b/apps/sim/lib/workspace-files/api/index.ts index 2b23ed91382..dd0b8f04359 100644 --- a/apps/sim/lib/workspace-files/api/index.ts +++ b/apps/sim/lib/workspace-files/api/index.ts @@ -2,6 +2,6 @@ export { internalFileAnalytics } from '@/lib/workspace-files/api/internal-analyt export { internalFileErrorPolicies } from '@/lib/workspace-files/api/internal-error-policies' export { internalFilePresenters } from '@/lib/workspace-files/api/internal-presenters' export { - internalSessionOrServiceAuth, + internalSessionOrExecutorAuth, v2FileErrorPolicies, } from '@/lib/workspace-files/api/route-policies' diff --git a/apps/sim/lib/workspace-files/api/route-policies.test.ts b/apps/sim/lib/workspace-files/api/route-policies.test.ts index 3fd91c612bb..3ab9860d5f5 100644 --- a/apps/sim/lib/workspace-files/api/route-policies.test.ts +++ b/apps/sim/lib/workspace-files/api/route-policies.test.ts @@ -1,32 +1,65 @@ /** * @vitest-environment node */ + +import { resetEnvMock } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockVerifyInternalToken } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockVerifyInternalToken: vi.fn(), -})) +const { MockInvalidBindingError, mockBindDelegation, mockGetSession } = vi.hoisted(() => { + class MockInvalidBindingError extends Error {} + return { + MockInvalidBindingError, + mockBindDelegation: vi.fn(), + mockGetSession: vi.fn(), + } +}) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) -vi.mock('@/lib/auth/internal', () => ({ verifyInternalToken: mockVerifyInternalToken })) +vi.mock('@/lib/auth/internal-delegation', () => ({ + bindInternalExecutorDelegation: mockBindDelegation, + InvalidInternalDelegationBindingError: MockInvalidBindingError, +})) +vi.unmock('@/lib/auth/internal') import { InternalUnauthenticatedError } from '@/lib/api/server/routes' -import { internalSessionOrServiceAuth } from '@/lib/workspace-files/api' +import { generateInternalDelegationToken, generateInternalToken } from '@/lib/auth/internal' +import { internalSessionOrExecutorAuth } from '@/lib/workspace-files/api' + +afterAll(resetEnvMock) describe('internal file route authentication', () => { beforeEach(() => { vi.clearAllMocks() mockGetSession.mockResolvedValue(null) + mockBindDelegation.mockImplementation(async (delegation, options) => ({ + kind: 'delegated', + serviceId: 'executor', + subjectUserId: delegation.subjectUserId, + workspaceId: 'canonical-workspace', + delegationId: delegation.delegationId, + audience: options.audience, + issuedAt: delegation.issuedAt, + expiresAt: delegation.expiresAt, + resourceScope: options.resourceScope, + delegationContext: { + kind: 'workflow_execution', + workflowId: delegation.workflowId, + executionId: delegation.executionId, + }, + })) }) - it('binds a verified internal user to an executor file principal', async () => { - mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-1' }) + it('binds a scoped executor token without trusting the workspace route parameter', async () => { + const token = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) - const principal = await internalSessionOrServiceAuth.authenticate( + const principal = await internalSessionOrExecutorAuth.authenticate( new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1', { - headers: { authorization: 'Bearer signed-token' }, + headers: { authorization: `Bearer ${token}` }, }), { id: 'ws-1', fileId: 'file-1' } ) @@ -35,26 +68,72 @@ describe('internal file route authentication', () => { kind: 'delegated', serviceId: 'executor', subjectUserId: 'user-1', - workspaceId: 'ws-1', + workspaceId: 'canonical-workspace', audience: 'sim:workspace-files', resourceScope: { fileId: 'file-1' }, }) + expect(mockBindDelegation).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-1', + executionId: 'execution-1', + }), + { + audience: 'sim:workspace-files', + resourceScope: { fileId: 'file-1' }, + } + ) expect(mockGetSession).not.toHaveBeenCalled() }) - it('rejects internal tokens that do not carry a human subject', async () => { - mockVerifyInternalToken.mockResolvedValue({ valid: true }) + it('rejects legacy actorless internal tokens before canonical binding', async () => { + const token = await generateInternalToken() + + await expect( + internalSessionOrExecutorAuth.authenticate( + new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1', { + headers: { authorization: `Bearer ${token}` }, + }), + { id: 'ws-1', fileId: 'file-1' } + ) + ).rejects.toBeInstanceOf(InternalUnauthenticatedError) + expect(mockBindDelegation).not.toHaveBeenCalled() + }) + + it('rejects a scoped token whose canonical workflow binding no longer exists', async () => { + const token = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }) + mockBindDelegation.mockRejectedValue(new MockInvalidBindingError()) await expect( - internalSessionOrServiceAuth.authenticate( + internalSessionOrExecutorAuth.authenticate( new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1', { - headers: { authorization: 'Bearer signed-token' }, + headers: { authorization: `Bearer ${token}` }, }), { id: 'ws-1', fileId: 'file-1' } ) ).rejects.toBeInstanceOf(InternalUnauthenticatedError) }) + it('does not render canonical-binding infrastructure failures as bad credentials', async () => { + const token = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }) + const infrastructureError = new Error('database unavailable') + mockBindDelegation.mockRejectedValue(infrastructureError) + + await expect( + internalSessionOrExecutorAuth.authenticate( + new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1', { + headers: { authorization: `Bearer ${token}` }, + }), + { id: 'ws-1', fileId: 'file-1' } + ) + ).rejects.toBe(infrastructureError) + }) + it('preserves browser session principals when no service token is supplied', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-1' }, @@ -62,7 +141,7 @@ describe('internal file route authentication', () => { }) await expect( - internalSessionOrServiceAuth.authenticate( + internalSessionOrExecutorAuth.authenticate( new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1'), { id: 'ws-1', fileId: 'file-1' } ) diff --git a/apps/sim/lib/workspace-files/api/route-policies.ts b/apps/sim/lib/workspace-files/api/route-policies.ts index c287586ddd2..6ec9c725b04 100644 --- a/apps/sim/lib/workspace-files/api/route-policies.ts +++ b/apps/sim/lib/workspace-files/api/route-policies.ts @@ -1,26 +1,18 @@ import { - createInternalSessionOrServiceAuth, + createInternalSessionOrExecutorAuth, type V2ErrorPolicy, v2OrchestrationErrorPolicy, } from '@/lib/api/server/routes' -import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' -export const internalSessionOrServiceAuth = createInternalSessionOrServiceAuth( - ({ subjectUserId, params }) => { - const workspaceId = params.id - if (typeof workspaceId !== 'string' || !workspaceId) { - throw new Error('Internal file delegation requires a workspace route parameter') - } - return createWorkspaceFileDelegatedPrincipal({ - serviceId: 'executor', - subjectUserId, - workspaceId, - delegationId: `internal-file:${subjectUserId}`, - fileId: typeof params.fileId === 'string' ? params.fileId : undefined, - }) - } -) +export const internalSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({ + audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, + resourceScope: (params) => { + const fileId = typeof params.fileId === 'string' ? params.fileId : undefined + return fileId ? { fileId } : undefined + }, +}) export const v2FileErrorPolicies = { default: v2OrchestrationErrorPolicy, diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index 62136ce8635..d87626272bd 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -39,6 +39,17 @@ export interface DelegatedPrincipal { } } +export interface WorkflowExecutionDelegationContext { + kind: 'workflow_execution' + workflowId: string + executionId?: string +} + +export type WorkflowExecutionDelegatedPrincipal = DelegatedPrincipal & { + serviceId: 'executor' + delegationContext: WorkflowExecutionDelegationContext +} + export type PrincipalActor = | { kind: 'session'; userId: string } | { kind: 'personal_api_key'; keyId: string; userId: string } From 18c043ecdeeb327d7e2e9506b3666c2adc27b19a Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 8 Aug 2026 16:29:39 -0700 Subject: [PATCH 113/159] Include share status in file metadata --- .../v2/files/[fileId]/metadata/route.test.ts | 23 +++++++++++++- .../api/v2/files/[fileId]/metadata/route.ts | 2 +- apps/sim/lib/api/contracts/v2/files.ts | 31 ++++++++++++------- .../read-workspace-file-metadata.test.ts | 23 ++++++++++++-- .../read-workspace-file-metadata.ts | 16 +++++++--- 5 files changed, 74 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts index e838e936fd0..1cd31213dcf 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts @@ -54,6 +54,17 @@ const auth = { rateLimitSubscription: null, keyType: 'workspace' as const, } +const SHARE = { + id: 'share-1', + token: 'share-token', + url: 'https://example.com/f/share-token', + isActive: true, + resourceType: 'file', + resourceId: FILE_ID, + authType: 'public', + hasPassword: false, + allowedEmails: [], +} function buildRecord() { return { @@ -89,7 +100,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { remaining: 99, resetAt: new Date('2024-01-01T01:00:00Z'), }) - mocks.readMetadata.mockResolvedValue({ file: buildRecord() }) + mocks.readMetadata.mockResolvedValue({ file: buildRecord(), share: SHARE }) mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) @@ -128,6 +139,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { uploadedByEmail: 'ada@example.com', uploadedAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-02T00:00:00.000Z', + share: SHARE, }, }) expect(mocks.readMetadata).toHaveBeenCalledWith({ @@ -136,4 +148,13 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { request: expect.anything(), }) }) + + it('returns a null share when the file has no share configuration', async () => { + mocks.readMetadata.mockResolvedValueOnce({ file: buildRecord(), share: null }) + + const response = await callGet(`workspaceId=${WORKSPACE_ID}`) + + expect(response.status).toBe(200) + expect((await response.json()).data.share).toBeNull() + }) }) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts index a76a4862229..68d1420c541 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts @@ -20,5 +20,5 @@ export const GET = defineV2JsonRoute({ assertedWorkspaceId: query.workspaceId, }), useCase: readWorkspaceFileMetadata, - present: async ({ file }) => ({ data: await toV2File(file) }), + present: async ({ file, share }) => ({ data: { ...(await toV2File(file)), share } }), }) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 607f582d695..c7a3d5e5159 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -61,6 +61,24 @@ export const v2FileSchema = z.object({ export type V2File = z.output<typeof v2FileSchema> +/** + * Public share state. Reuses the internal {@link shareRecordSchema}, which is + * already public-safe — `hasPassword` is a boolean and neither the ciphertext + * nor the storage key is carried — with `url` tightened to a real URL. + */ +export const v2FileShareSchema = shareRecordSchema.extend({ + url: z.string().url(), +}) + +export type V2FileShare = z.output<typeof v2FileShareSchema> + +/** File metadata enriched with its current public-share configuration. */ +export const v2FileMetadataSchema = v2FileSchema.extend({ + share: v2FileShareSchema.nullable(), +}) + +export type V2FileMetadata = z.output<typeof v2FileMetadataSchema> + export const v2FileUploadParamsSchema = z.object({ uploadId: z.string().min(1) }) export type V2FileUploadParams = z.output<typeof v2FileUploadParamsSchema> @@ -259,17 +277,6 @@ export const v2DeleteFileFolderContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2DeleteFileFolderDataSchema) }, }) -/** - * Public share state. Reuses the internal {@link shareRecordSchema}, which is - * already public-safe — `hasPassword` is a boolean and neither the ciphertext - * nor the storage key is carried — with `url` tightened to a real URL. - */ -export const v2FileShareSchema = shareRecordSchema.extend({ - url: z.string().url(), -}) - -export type V2FileShare = z.output<typeof v2FileShareSchema> - export const v2GetFileShareResultSchema = z.object({ share: v2FileShareSchema.nullable(), }) @@ -403,7 +410,7 @@ export const v2GetFileContract = defineRouteContract({ query: v2FileWorkspaceQuerySchema, response: { mode: 'json', - schema: v2DataResponse(v2FileSchema), + schema: v2DataResponse(v2FileMetadataSchema), }, }) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts index 888869e4918..4d29c4211c5 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ loadContext: vi.fn(), getWorkspaceFile: vi.fn(), + getShareForResource: vi.fn(), resolvePermission: vi.fn(), })) @@ -19,6 +20,10 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ loadActiveWorkspaceFileContext: mocks.loadContext, })) +vi.mock('@/lib/public-shares/share-manager', () => ({ + getShareForResource: mocks.getShareForResource, +})) + import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' const canonical = { @@ -43,15 +48,28 @@ const file = { updatedAt: new Date('2026-01-02T00:00:00Z'), } +const share = { + id: 'share-1', + token: 'share-token', + url: 'https://example.com/f/share-token', + isActive: true, + resourceType: 'file' as const, + resourceId: 'file-1', + authType: 'public' as const, + hasPassword: false, + allowedEmails: [], +} + describe('readWorkspaceFileMetadata', () => { beforeEach(() => { vi.clearAllMocks() mocks.loadContext.mockResolvedValue(canonical) mocks.getWorkspaceFile.mockResolvedValue(file) + mocks.getShareForResource.mockResolvedValue(share) mocks.resolvePermission.mockResolvedValue('admin') }) - it('returns the canonical active file without side effects', async () => { + it('returns the canonical active file and its share status without side effects', async () => { const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } await expect( @@ -62,7 +80,7 @@ describe('readWorkspaceFileMetadata', () => { assertedWorkspaceId: 'workspace-1', }, }) - ).resolves.toEqual({ file }) + ).resolves.toEqual({ file, share }) expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: undefined, @@ -70,6 +88,7 @@ describe('readWorkspaceFileMetadata', () => { expect(mocks.getWorkspaceFile).toHaveBeenCalledWith('workspace-1', 'file-1', { throwOnError: true, }) + expect(mocks.getShareForResource).toHaveBeenCalledWith('file', 'file-1') }) it('fails fast if the authorized file disappears before projection', async () => { diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts index 9fbc52ff850..5be9e1c9c99 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts @@ -1,5 +1,7 @@ +import type { ShareRecord } from '@/lib/api/contracts/public-shares' import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getShareForResource } from '@/lib/public-shares/share-manager' import { type ActiveWorkspaceFileContext, getWorkspaceFile, @@ -17,6 +19,7 @@ export interface ReadWorkspaceFileMetadataInput { export interface ReadWorkspaceFileMetadataResult { file: WorkspaceFileRecord + share: ShareRecord | null } async function executeReadWorkspaceFileMetadata({ @@ -27,12 +30,15 @@ async function executeReadWorkspaceFileMetadata({ ReadWorkspaceFileMetadataInput, ActiveWorkspaceFileContext >): Promise<ReadWorkspaceFileMetadataResult> { - const file = await getWorkspaceFile(context.workspaceId, context.fileId, { - includeDeleted: input.includeDeleted, - throwOnError: true, - }) + const [file, share] = await Promise.all([ + getWorkspaceFile(context.workspaceId, context.fileId, { + includeDeleted: input.includeDeleted, + throwOnError: true, + }), + getShareForResource('file', context.fileId), + ]) if (!file) throw new OrchestrationError('not_found', 'File not found') - return { file } + return { file, share } } export const readWorkspaceFileMetadata = defineAuthorizedWorkspaceFileUseCase({ From 97957d1d54af6865764334fa22ae59e8fdf036b2 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 8 Aug 2026 17:03:13 -0700 Subject: [PATCH 114/159] feat(auth): centralize delegated identity policy (#6462) --- apps/docs/openapi-v2-files-audit.json | 36 +++++++++- apps/sim/executor/utils/http.test.ts | 69 +++++++++++++++++++ apps/sim/executor/utils/http.ts | 22 +++++- apps/sim/lib/auth/principal.test.ts | 43 ++++++++++++ .../execute-workspace-use-case.test.ts | 1 + .../authorized-workspace-use-case.test.ts | 44 +++++++++++- apps/sim/lib/core/application/index.ts | 1 + .../application/workspace-authorization.ts | 16 +++++ .../application/workspace-operation.test.ts | 55 +++++++++++++++ .../core/application/workspace-operation.ts | 56 +++++++++++++-- .../custom-tools/application/authorization.ts | 3 +- .../custom-tools/application/operations.ts | 33 ++++----- .../lib/custom-tools/application/use-cases.ts | 18 ++--- .../knowledge/application/operations.test.ts | 2 + .../lib/knowledge/application/operations.ts | 30 ++++---- apps/sim/lib/mcp/application/authorization.ts | 3 +- apps/sim/lib/mcp/application/operations.ts | 24 +++---- .../lib/skills/application/authorization.ts | 3 +- apps/sim/lib/skills/application/operations.ts | 27 ++++---- apps/sim/lib/skills/application/use-cases.ts | 12 ++-- .../lib/table/application/operations.test.ts | 6 ++ apps/sim/lib/table/application/operations.ts | 14 ++-- .../lib/workflows/application/operations.ts | 57 +++++++-------- .../application/authorization.test.ts | 30 ++++++++ .../application/operations.test.ts | 18 +++++ .../workspace-files/application/operations.ts | 63 +++++++++-------- .../application/share-workspace-file.ts | 4 +- packages/auth/src/principal.ts | 22 ++++++ 28 files changed, 553 insertions(+), 159 deletions(-) create mode 100644 apps/sim/executor/utils/http.test.ts create mode 100644 apps/sim/lib/core/application/workspace-operation.test.ts diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index a2925d24e50..c3cf92abbce 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -747,7 +747,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileResponse" + "$ref": "#/components/schemas/V2FileMetadataResponse" } } } @@ -2196,6 +2196,40 @@ } } }, + "V2FileMetadata": { + "allOf": [ + { + "$ref": "#/components/schemas/V2File" + }, + { + "type": "object", + "required": ["share"], + "properties": { + "share": { + "oneOf": [ + { + "$ref": "#/components/schemas/V2FileShare" + }, + { + "type": "null" + } + ], + "description": "The file's public share state, or null when the file has never been shared." + } + } + } + ] + }, + "V2FileMetadataResponse": { + "type": "object", + "description": "A single file resource with its public share state.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2FileMetadata" + } + } + }, "V2DeleteFileResponse": { "type": "object", "description": "The result of archiving a file.", diff --git a/apps/sim/executor/utils/http.test.ts b/apps/sim/executor/utils/http.test.ts new file mode 100644 index 00000000000..811a77e60f3 --- /dev/null +++ b/apps/sim/executor/utils/http.test.ts @@ -0,0 +1,69 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + generateInternalDelegationToken: vi.fn(), + generateInternalToken: vi.fn(), +})) + +vi.mock('@/lib/auth/internal', () => ({ + generateInternalDelegationToken: mocks.generateInternalDelegationToken, + generateInternalToken: mocks.generateInternalToken, +})) + +import { buildAuthHeaders, buildExecutorDelegationHeaders } from '@/executor/utils/http' + +describe('executor HTTP authentication headers', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.generateInternalDelegationToken.mockResolvedValue('delegation-token') + mocks.generateInternalToken.mockResolvedValue('legacy-token') + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('issues a workflow-scoped executor delegation', async () => { + await expect( + buildExecutorDelegationHeaders({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + ).resolves.toEqual({ + 'Content-Type': 'application/json', + Authorization: 'Bearer delegation-token', + }) + + expect(mocks.generateInternalDelegationToken).toHaveBeenCalledWith({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + expect(mocks.generateInternalToken).not.toHaveBeenCalled() + }) + + it('fails instead of issuing trusted delegation headers in a browser', async () => { + vi.stubGlobal('window', {}) + + await expect( + buildExecutorDelegationHeaders({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }) + ).rejects.toThrow('Executor delegation headers can only be created on the server') + expect(mocks.generateInternalDelegationToken).not.toHaveBeenCalled() + }) + + it('keeps the legacy helper separate during endpoint migration', async () => { + await expect(buildAuthHeaders('user-1')).resolves.toEqual({ + 'Content-Type': 'application/json', + Authorization: 'Bearer legacy-token', + }) + expect(mocks.generateInternalToken).toHaveBeenCalledWith('user-1') + expect(mocks.generateInternalDelegationToken).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/executor/utils/http.ts b/apps/sim/executor/utils/http.ts index 57ea632a41b..0d74d422268 100644 --- a/apps/sim/executor/utils/http.ts +++ b/apps/sim/executor/utils/http.ts @@ -1,7 +1,12 @@ -import { generateInternalToken } from '@/lib/auth/internal' +import { + type GenerateInternalDelegationTokenInput, + generateInternalDelegationToken, + generateInternalToken, +} from '@/lib/auth/internal' import { getBaseUrl, getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { HTTP } from '@/executor/constants' +/** @deprecated Use `buildExecutorDelegationHeaders` for protected application routes. */ export async function buildAuthHeaders(userId?: string): Promise<Record<string, string>> { const headers: Record<string, string> = { 'Content-Type': HTTP.CONTENT_TYPE.JSON, @@ -15,6 +20,21 @@ export async function buildAuthHeaders(userId?: string): Promise<Record<string, return headers } +/** Builds server-only headers for an executor call bound to its workflow execution origin. */ +export async function buildExecutorDelegationHeaders( + input: GenerateInternalDelegationTokenInput +): Promise<Record<string, string>> { + if (typeof window !== 'undefined') { + throw new Error('Executor delegation headers can only be created on the server') + } + + const token = await generateInternalDelegationToken(input) + return { + 'Content-Type': HTTP.CONTENT_TYPE.JSON, + Authorization: `Bearer ${token}`, + } +} + export function buildAPIUrl(path: string, params?: Record<string, string>): URL { const baseUrl = path.startsWith('/api/') ? getInternalApiBaseUrl() : getBaseUrl() const url = new URL(path, baseUrl) diff --git a/apps/sim/lib/auth/principal.test.ts b/apps/sim/lib/auth/principal.test.ts index 27248919ee8..d440b02be05 100644 --- a/apps/sim/lib/auth/principal.test.ts +++ b/apps/sim/lib/auth/principal.test.ts @@ -2,12 +2,55 @@ * @vitest-environment node */ import { + PrincipalSubjectUserRequiredError, + requirePrincipalSubjectUserId, resolvePrincipalAttribution, resolvePrincipalAuditAttribution, toPrincipalActor, } from '@sim/auth/principal' import { describe, expect, it } from 'vitest' +describe('principal subject users', () => { + it('resolves the human subject represented by user-backed principals', () => { + expect( + requirePrincipalSubjectUserId({ + kind: 'session', + userId: 'session-user', + sessionId: 'session-1', + }) + ).toBe('session-user') + expect( + requirePrincipalSubjectUserId({ + kind: 'personal_api_key', + userId: 'key-user', + keyId: 'key-1', + }) + ).toBe('key-user') + expect( + requirePrincipalSubjectUserId({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'delegated-user', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:test', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2026-01-01T00:05:00Z'), + }) + ).toBe('delegated-user') + }) + + it('fails fast instead of fabricating a workspace-key subject', () => { + expect(() => + requirePrincipalSubjectUserId({ + kind: 'workspace_api_key', + keyId: 'key-1', + workspaceId: 'workspace-1', + }) + ).toThrow(PrincipalSubjectUserRequiredError) + }) +}) + describe('principal actors', () => { it('maps every principal to an audit actor without billing-owner substitution', () => { expect( diff --git a/apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts b/apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts index e2aaa045163..5e18cd751a7 100644 --- a/apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts +++ b/apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts @@ -9,6 +9,7 @@ const operation = { minimumRole: 'read' as const, workspaceApiKey: 'deny' as const, principalKinds: ['delegated'] as const, + delegatedServices: ['copilot'] as const, } describe('Copilot workspace application delegation', () => { diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts index 6c72bb0d3e5..aa2f4e5e494 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts @@ -47,6 +47,7 @@ const delegatedOperation = defineWorkspaceOperation({ minimumRole: 'read', workspaceApiKey: 'deny', principalKinds: ['delegated'], + delegatedServices: ['executor'], }) const workspaceKeyOperation = defineWorkspaceOperation({ @@ -222,7 +223,8 @@ describe('defineAuthorizedWorkspaceUseCase', () => { resolveContext: async (_args: { principal: DelegatedPrincipal; input: TestInput }) => canonicalContext, authorizationOptions: ({ principal }) => { - expectTypeOf(principal).toEqualTypeOf<DelegatedPrincipal>() + expectTypeOf(principal).toMatchTypeOf<DelegatedPrincipal>() + expectTypeOf(principal.serviceId).toEqualTypeOf<'executor'>() return { delegation: { audience: 'test:files', @@ -231,7 +233,8 @@ describe('defineAuthorizedWorkspaceUseCase', () => { } }, async execute({ principal }) { - expectTypeOf(principal).toEqualTypeOf<DelegatedPrincipal>() + expectTypeOf(principal).toMatchTypeOf<DelegatedPrincipal>() + expectTypeOf(principal.serviceId).toEqualTypeOf<'executor'>() return { ok: true as const } }, }) @@ -260,6 +263,43 @@ describe('defineAuthorizedWorkspaceUseCase', () => { ) }) + it('rejects a disallowed delegated service before canonical loading', async () => { + const resolveContext = vi.fn( + async (_args: { principal: DelegatedPrincipal; input: TestInput }) => canonicalContext + ) + const useCase = defineAuthorizedWorkspaceUseCase({ + operation: delegatedOperation, + resolveContext, + authorizationOptions: { + delegation: { audience: 'test:files', isWithinScope: () => true }, + }, + async execute() { + return { ok: true as const } + }, + }) + + await expect( + useCase.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'test:files', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + }, + input: { resourceId: 'resource-1' }, + }) + ).rejects.toMatchObject<Partial<OrchestrationError>>({ + code: 'forbidden', + message: 'Delegated service copilot cannot perform operation test.delegated_read', + }) + expect(resolveContext).not.toHaveBeenCalled() + expect(mocks.resolvePermission).not.toHaveBeenCalled() + }) + it('records workspace API keys as non-human audit actors', async () => { const useCase = defineAuthorizedWorkspaceUseCase({ operation: workspaceKeyOperation, diff --git a/apps/sim/lib/core/application/index.ts b/apps/sim/lib/core/application/index.ts index ea8138bf790..9a32e0a7a94 100644 --- a/apps/sim/lib/core/application/index.ts +++ b/apps/sim/lib/core/application/index.ts @@ -16,6 +16,7 @@ export type { } from '@/lib/core/application/workspace-authorization' export { authorizeWorkspaceOperation, + DelegatedServiceAuthorizationError, DelegatedWorkspaceAuthorizationError, InsufficientWorkspacePermissionsError, PersonalApiKeysDisabledError, diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index b511270f1f9..0d12773d3fb 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -63,6 +63,13 @@ export class PrincipalKindAuthorizationError extends OrchestrationError { } } +export class DelegatedServiceAuthorizationError extends OrchestrationError { + constructor(serviceId: DelegatedPrincipal['serviceId'], operationId: string) { + super('forbidden', `Delegated service ${serviceId} cannot perform operation ${operationId}`) + this.name = 'DelegatedServiceAuthorizationError' + } +} + export function requireAllowedWorkspacePrincipal<O extends WorkspaceOperation>( principal: Principal, operation: O @@ -70,6 +77,15 @@ export function requireAllowedWorkspacePrincipal<O extends WorkspaceOperation>( if (!operation.principalKinds.some((kind) => kind === principal.kind)) { throw new PrincipalKindAuthorizationError(principal.kind, operation.id) } + if (principal.kind !== 'delegated') return + + const delegatedServices = operation.delegatedServices + if (!delegatedServices?.length) { + throw new Error(`Operation ${operation.id} is missing its delegated service policy`) + } + if (!delegatedServices.some((serviceId) => serviceId === principal.serviceId)) { + throw new DelegatedServiceAuthorizationError(principal.serviceId, operation.id) + } } function requirePermission(permission: PermissionType | null, required: PermissionType): void { diff --git a/apps/sim/lib/core/application/workspace-operation.test.ts b/apps/sim/lib/core/application/workspace-operation.test.ts new file mode 100644 index 00000000000..20e5ed02822 --- /dev/null +++ b/apps/sim/lib/core/application/workspace-operation.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' + +describe('defineWorkspaceOperation delegated service policy', () => { + it('preserves and freezes an explicit delegated service allowlist', () => { + const operation = defineWorkspaceOperation({ + id: 'test.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot', 'executor'], + }) + + expect(operation.delegatedServices).toEqual(['copilot', 'executor']) + expect(Object.isFrozen(operation.delegatedServices)).toBe(true) + }) + + it('fails fast when delegated principals have no service policy', () => { + expect(() => + defineWorkspaceOperation({ + id: 'test.missing_service_policy', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + } as never) + ).toThrow('Operation test.missing_service_policy has inconsistent delegated service policy') + }) + + it('fails fast when a non-delegated operation declares delegated services', () => { + expect(() => + defineWorkspaceOperation({ + id: 'test.unused_service_policy', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + delegatedServices: ['copilot'], + } as never) + ).toThrow('Operation test.unused_service_policy has inconsistent delegated service policy') + }) + + it('fails fast for duplicate delegated services', () => { + expect(() => + defineWorkspaceOperation({ + id: 'test.duplicate_service_policy', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot', 'copilot'], + } as never) + ).toThrow('Operation test.duplicate_service_policy declares duplicate delegated services') + }) +}) diff --git a/apps/sim/lib/core/application/workspace-operation.ts b/apps/sim/lib/core/application/workspace-operation.ts index ec6d731adfb..aebf094713e 100644 --- a/apps/sim/lib/core/application/workspace-operation.ts +++ b/apps/sim/lib/core/application/workspace-operation.ts @@ -1,4 +1,4 @@ -import type { Principal } from '@sim/auth/principal' +import type { DelegatedPrincipal, DelegatedServiceId, Principal } from '@sim/auth/principal' import type { PermissionType } from '@sim/platform-authz/workspace' import type { ApplicationOperation } from '@/lib/core/application/operation' @@ -6,17 +6,36 @@ type WorkspaceApiKeyPolicy<R extends PermissionType> = R extends 'admin' ? 'deny export type PrincipalKind = Principal['kind'] -export type PrincipalForOperation<O extends { readonly principalKinds: readonly PrincipalKind[] }> = - Extract<Principal, { kind: O['principalKinds'][number] }> +type NonDelegatedPrincipalForOperation< + O extends { readonly principalKinds: readonly PrincipalKind[] }, +> = Exclude<Extract<Principal, { kind: O['principalKinds'][number] }>, DelegatedPrincipal> + +type DelegatedPrincipalForOperation< + O extends { + readonly principalKinds: readonly PrincipalKind[] + readonly delegatedServices?: readonly DelegatedServiceId[] + }, +> = 'delegated' extends O['principalKinds'][number] + ? DelegatedPrincipal & { serviceId: NonNullable<O['delegatedServices']>[number] } + : never + +export type PrincipalForOperation< + O extends { + readonly principalKinds: readonly PrincipalKind[] + readonly delegatedServices?: readonly DelegatedServiceId[] + }, +> = NonDelegatedPrincipalForOperation<O> | DelegatedPrincipalForOperation<O> export interface WorkspaceOperation< Id extends string = string, Role extends PermissionType = PermissionType, PrincipalKinds extends readonly PrincipalKind[] = readonly PrincipalKind[], + DelegatedServices extends readonly DelegatedServiceId[] = readonly DelegatedServiceId[], > extends ApplicationOperation<Id> { readonly minimumRole: Role readonly workspaceApiKey: WorkspaceApiKeyPolicy<Role> readonly principalKinds: PrincipalKinds + readonly delegatedServices?: DelegatedServices } type WorkspaceApiKeyPrincipalConsistency< @@ -26,14 +45,26 @@ type WorkspaceApiKeyPrincipalConsistency< ? { readonly workspaceApiKey: Role extends 'admin' ? never : 'allow' } : { readonly workspaceApiKey: 'deny' } +type DelegatedPrincipalConsistency< + PrincipalKinds extends readonly PrincipalKind[], + DelegatedServices extends readonly DelegatedServiceId[], +> = 'delegated' extends PrincipalKinds[number] + ? { + readonly delegatedServices: DelegatedServices extends readonly [] ? never : DelegatedServices + } + : { readonly delegatedServices?: never } + export function defineWorkspaceOperation< const Id extends string, const Role extends PermissionType, const PrincipalKinds extends readonly PrincipalKind[], + const DelegatedServices extends readonly DelegatedServiceId[] = readonly [], >( - operation: WorkspaceOperation<Id, Role, PrincipalKinds> & - WorkspaceApiKeyPrincipalConsistency<Role, PrincipalKinds> -): WorkspaceOperation<Id, Role, PrincipalKinds> { + operation: WorkspaceOperation<Id, Role, PrincipalKinds, DelegatedServices> & + WorkspaceApiKeyPrincipalConsistency<Role, PrincipalKinds> & + DelegatedPrincipalConsistency<PrincipalKinds, DelegatedServices> +): WorkspaceOperation<Id, Role, PrincipalKinds, DelegatedServices> & + DelegatedPrincipalConsistency<PrincipalKinds, DelegatedServices> { if (operation.principalKinds.length === 0) { throw new Error(`Operation ${operation.id} must allow at least one principal kind`) } @@ -49,6 +80,17 @@ export function defineWorkspaceOperation< throw new Error(`Operation ${operation.id} exceeds the workspace API key write ceiling`) } + const allowsDelegatedPrincipal = operation.principalKinds.includes('delegated') + const delegatedServices = operation.delegatedServices ?? [] + if (allowsDelegatedPrincipal !== delegatedServices.length > 0) { + throw new Error(`Operation ${operation.id} has inconsistent delegated service policy`) + } + if (new Set(delegatedServices).size !== delegatedServices.length) { + throw new Error(`Operation ${operation.id} declares duplicate delegated services`) + } + Object.freeze(operation.principalKinds) - return Object.freeze(operation) + if (operation.delegatedServices) Object.freeze(operation.delegatedServices) + Object.freeze(operation) + return operation } diff --git a/apps/sim/lib/custom-tools/application/authorization.ts b/apps/sim/lib/custom-tools/application/authorization.ts index 84e65bdf921..4dd7b31f2ce 100644 --- a/apps/sim/lib/custom-tools/application/authorization.ts +++ b/apps/sim/lib/custom-tools/application/authorization.ts @@ -1,11 +1,10 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' import type { WorkspaceDelegationPolicy } from '@/lib/core/application' export const CUSTOM_TOOL_DELEGATION_AUDIENCE = 'sim:custom-tools' export const customToolDelegationPolicy = { audience: CUSTOM_TOOL_DELEGATION_AUDIENCE, - isWithinScope: (principal: DelegatedPrincipal) => principal.serviceId === 'copilot', + isWithinScope: () => true, } as const satisfies WorkspaceDelegationPolicy<{ workspaceId: string workspaceOrganizationId: string | null diff --git a/apps/sim/lib/custom-tools/application/operations.ts b/apps/sim/lib/custom-tools/application/operations.ts index eff480f5782..2bee97b7cf8 100644 --- a/apps/sim/lib/custom-tools/application/operations.ts +++ b/apps/sim/lib/custom-tools/application/operations.ts @@ -1,67 +1,68 @@ import { defineWorkspaceOperation } from '@/lib/core/application' -const ALL_PRINCIPAL_KINDS = [ - 'session', - 'personal_api_key', - 'workspace_api_key', - 'delegated', -] as const -const HUMAN_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'delegated'] as const +const ALL_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const +const HUMAN_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const export const customToolOperations = { list: defineWorkspaceOperation({ id: 'custom_tools.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), listAvailable: defineWorkspaceOperation({ id: 'custom_tools.list_available', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: HUMAN_PRINCIPAL_KINDS, + ...HUMAN_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'custom_tools.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), create: defineWorkspaceOperation({ id: 'custom_tools.create', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), save: defineWorkspaceOperation({ id: 'custom_tools.save', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'custom_tools.update', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), updateAvailable: defineWorkspaceOperation({ id: 'custom_tools.update_available', minimumRole: 'write', workspaceApiKey: 'deny', - principalKinds: HUMAN_PRINCIPAL_KINDS, + ...HUMAN_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'custom_tools.delete', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), deleteAvailable: defineWorkspaceOperation({ id: 'custom_tools.delete_available', minimumRole: 'write', workspaceApiKey: 'deny', - principalKinds: HUMAN_PRINCIPAL_KINDS, + ...HUMAN_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/custom-tools/application/use-cases.ts b/apps/sim/lib/custom-tools/application/use-cases.ts index 71a58139e83..853e7662b4b 100644 --- a/apps/sim/lib/custom-tools/application/use-cases.ts +++ b/apps/sim/lib/custom-tools/application/use-cases.ts @@ -1,5 +1,9 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { + type Principal, + requirePrincipalSubjectUserId, + resolvePrincipalAttribution, +} from '@sim/auth/principal' import type { customTools } from '@sim/db/schema' import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import type { ListSortOrder } from '@/lib/api/list-query' @@ -52,10 +56,6 @@ async function resolveWorkspaceToolContext( return { ...workspace, tool } } -function humanUserId(principal: Exclude<Principal, { kind: 'workspace_api_key' }>): string { - return principal.kind === 'delegated' ? principal.subjectUserId : principal.userId -} - async function resolveAvailableToolContext(args: { principal: Exclude<Principal, { kind: 'workspace_api_key' }> workspaceId: string @@ -64,7 +64,7 @@ async function resolveAvailableToolContext(args: { const workspace = await resolveWorkspaceContext(args.workspaceId) const tool = await getCustomToolById({ toolId: args.toolId, - userId: humanUserId(args.principal), + userId: requirePrincipalSubjectUserId(args.principal), workspaceId: workspace.workspaceId, }) if (!tool) throw new OrchestrationError('not_found', 'Custom tool not found') @@ -116,7 +116,7 @@ export const listAvailableCustomToolsUseCase = defineAuthorizedWorkspaceUseCase( authorizationOptions, async execute({ principal, context }) { const tools = await listCustomTools({ - userId: humanUserId(principal), + userId: requirePrincipalSubjectUserId(principal), workspaceId: context.workspaceId, }) return { tools } @@ -300,7 +300,7 @@ export const updateAvailableCustomToolUseCase = defineAuthorizedWorkspaceUseCase const tool = await updateCustomTool({ workspaceId: context.workspaceId, toolId: context.tool.id, - userId: humanUserId(principal), + userId: requirePrincipalSubjectUserId(principal), title, schema: input.schema ?? context.tool.schema, code: input.code ?? context.tool.code, @@ -369,7 +369,7 @@ export const deleteAvailableCustomToolUseCase = defineAuthorizedWorkspaceUseCase const deleted = await deleteCustomTool({ workspaceId: context.workspaceId, toolId: context.tool.id, - userId: humanUserId(principal), + userId: requirePrincipalSubjectUserId(principal), }) if (!deleted) throw new OrchestrationError('not_found', 'Custom tool not found') return { tool: context.tool } diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index 49b12c42476..c8d39ec283b 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -46,5 +46,7 @@ describe('knowledge operation registry', () => { expect(knowledgeOperations.uploadDocument.principalKinds).toContain('delegated') expect(knowledgeOperations.listFolders.principalKinds).not.toContain('delegated') expect(knowledgeOperations.uploadComplete.principalKinds).not.toContain('delegated') + expect(knowledgeOperations.list.delegatedServices).toEqual(['copilot']) + expect(knowledgeOperations.uploadComplete.delegatedServices).toBeUndefined() }) }) diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index 199ab6931d1..dda120bb467 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -1,11 +1,9 @@ import { defineWorkspaceOperation } from '@/lib/core/application' -const ALL_PRINCIPAL_KINDS = [ - 'session', - 'personal_api_key', - 'workspace_api_key', - 'delegated', -] as const +const ALL_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const const HTTP_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'workspace_api_key'] as const @@ -14,37 +12,37 @@ export const knowledgeOperations = { id: 'knowledge.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'knowledge.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), create: defineWorkspaceOperation({ id: 'knowledge.create', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'knowledge.update', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'knowledge.delete', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), search: defineWorkspaceOperation({ id: 'knowledge.search', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), listFolders: defineWorkspaceOperation({ id: 'knowledge.folders.list', @@ -74,25 +72,25 @@ export const knowledgeOperations = { id: 'knowledge.documents.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), readDocument: defineWorkspaceOperation({ id: 'knowledge.documents.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), uploadDocument: defineWorkspaceOperation({ id: 'knowledge.documents.upload', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), deleteDocument: defineWorkspaceOperation({ id: 'knowledge.documents.delete', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), uploadCreate: defineWorkspaceOperation({ id: 'knowledge.documents.upload.create', diff --git a/apps/sim/lib/mcp/application/authorization.ts b/apps/sim/lib/mcp/application/authorization.ts index bd3c575a4d1..3e854da883b 100644 --- a/apps/sim/lib/mcp/application/authorization.ts +++ b/apps/sim/lib/mcp/application/authorization.ts @@ -1,11 +1,10 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' import type { WorkspaceDelegationPolicy } from '@/lib/core/application' export const MCP_SERVER_DELEGATION_AUDIENCE = 'sim:mcp-servers' export const mcpServerDelegationPolicy = { audience: MCP_SERVER_DELEGATION_AUDIENCE, - isWithinScope: (principal: DelegatedPrincipal) => principal.serviceId === 'copilot', + isWithinScope: () => true, } as const satisfies WorkspaceDelegationPolicy<{ workspaceId: string workspaceOrganizationId: string | null diff --git a/apps/sim/lib/mcp/application/operations.ts b/apps/sim/lib/mcp/application/operations.ts index b0a6dcbc28f..e615dcfe77a 100644 --- a/apps/sim/lib/mcp/application/operations.ts +++ b/apps/sim/lib/mcp/application/operations.ts @@ -1,54 +1,52 @@ import { defineWorkspaceOperation } from '@/lib/core/application' -const ALL_PRINCIPAL_KINDS = [ - 'session', - 'personal_api_key', - 'workspace_api_key', - 'delegated', -] as const +const ALL_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const export const mcpServerOperations = { list: defineWorkspaceOperation({ id: 'mcp_servers.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'mcp_servers.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), create: defineWorkspaceOperation({ id: 'mcp_servers.create', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), register: defineWorkspaceOperation({ id: 'mcp_servers.register', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'mcp_servers.update', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), reconfigure: defineWorkspaceOperation({ id: 'mcp_servers.reconfigure', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'mcp_servers.delete', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/skills/application/authorization.ts b/apps/sim/lib/skills/application/authorization.ts index 97aea84b6da..057af302526 100644 --- a/apps/sim/lib/skills/application/authorization.ts +++ b/apps/sim/lib/skills/application/authorization.ts @@ -1,11 +1,10 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' import type { WorkspaceDelegationPolicy } from '@/lib/core/application' export const SKILL_DELEGATION_AUDIENCE = 'sim:skills' export const skillDelegationPolicy = { audience: SKILL_DELEGATION_AUDIENCE, - isWithinScope: (principal: DelegatedPrincipal) => principal.serviceId === 'copilot', + isWithinScope: () => true, } as const satisfies WorkspaceDelegationPolicy<{ workspaceId: string workspaceOrganizationId: string | null diff --git a/apps/sim/lib/skills/application/operations.ts b/apps/sim/lib/skills/application/operations.ts index b19065816be..dc0ecbebd90 100644 --- a/apps/sim/lib/skills/application/operations.ts +++ b/apps/sim/lib/skills/application/operations.ts @@ -1,49 +1,50 @@ import { defineWorkspaceOperation } from '@/lib/core/application' -const ALL_PRINCIPAL_KINDS = [ - 'session', - 'personal_api_key', - 'workspace_api_key', - 'delegated', -] as const -const HUMAN_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'delegated'] as const +const ALL_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const +const HUMAN_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const export const skillOperations = { list: defineWorkspaceOperation({ id: 'skills.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), listAvailable: defineWorkspaceOperation({ id: 'skills.list_available', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: HUMAN_PRINCIPAL_KINDS, + ...HUMAN_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'skills.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), create: defineWorkspaceOperation({ id: 'skills.create', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'skills.update', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: HUMAN_PRINCIPAL_KINDS, + ...HUMAN_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'skills.delete', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: HUMAN_PRINCIPAL_KINDS, + ...HUMAN_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/skills/application/use-cases.ts b/apps/sim/lib/skills/application/use-cases.ts index 89899bb5bcc..73fbf9130dc 100644 --- a/apps/sim/lib/skills/application/use-cases.ts +++ b/apps/sim/lib/skills/application/use-cases.ts @@ -1,5 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { requirePrincipalSubjectUserId, resolvePrincipalAttribution } from '@sim/auth/principal' import type { skill } from '@sim/db/schema' import type { ListSortOrder } from '@/lib/api/list-query' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' @@ -42,10 +42,6 @@ async function resolveSkillContext(workspaceId: string, skillId: string): Promis return { ...workspace, skill: row } } -function humanUserId(principal: Exclude<Principal, { kind: 'workspace_api_key' }>): string { - return principal.kind === 'delegated' ? principal.subjectUserId : principal.userId -} - const authorizationOptions = { delegation: skillDelegationPolicy } export interface ListSkillsInput { @@ -82,7 +78,7 @@ export const listAvailableSkillsUseCase = defineAuthorizedWorkspaceUseCase({ async execute({ principal, context }) { const skills = await listSkillsForUser({ workspaceId: context.workspaceId, - userId: humanUserId(principal), + userId: requirePrincipalSubjectUserId(principal), }) return { skills } }, @@ -156,7 +152,7 @@ export const updateSkillUseCase = defineAuthorizedWorkspaceUseCase({ async execute({ principal, input, context }) { const row = await updateSkill({ workspaceId: context.workspaceId, - userId: humanUserId(principal), + userId: requirePrincipalSubjectUserId(principal), skillId: context.skill.id, name: input.name, description: input.description, @@ -188,7 +184,7 @@ export const deleteSkillUseCase = defineAuthorizedWorkspaceUseCase({ async execute({ principal, context }) { const row = await deleteSkillRecord({ workspaceId: context.workspaceId, - userId: humanUserId(principal), + userId: requirePrincipalSubjectUserId(principal), skillId: context.skill.id, }) return { skill: row } diff --git a/apps/sim/lib/table/application/operations.test.ts b/apps/sim/lib/table/application/operations.test.ts index e792a611fb3..07722033747 100644 --- a/apps/sim/lib/table/application/operations.test.ts +++ b/apps/sim/lib/table/application/operations.test.ts @@ -52,4 +52,10 @@ describe('table operation registry', () => { expect(tableOperations.createExport.minimumRole).toBe('read') expect(tableOperations.cancelExport.minimumRole).toBe('read') }) + + it('keeps delegated table operations Copilot-only', () => { + for (const operation of Object.values(tableOperations)) { + expect(operation.delegatedServices).toEqual(['copilot']) + } + }) }) diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index 9a8b30915da..8662437787f 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -1,18 +1,16 @@ import { defineWorkspaceOperation } from '@/lib/core/application' -const ALL_PRINCIPAL_KINDS = [ - 'session', - 'personal_api_key', - 'workspace_api_key', - 'delegated', -] as const +const ALL_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const function readOperation<const Id extends string>(id: Id) { return defineWorkspaceOperation({ id, minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }) } @@ -21,7 +19,7 @@ function writeOperation<const Id extends string>(id: Id) { id, minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }) } diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index 08f6399352e..8150e81a21d 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -1,140 +1,141 @@ import { defineWorkspaceOperation } from '@/lib/core/application' -const ALL_WORKFLOW_PRINCIPALS = [ - 'session', - 'personal_api_key', - 'workspace_api_key', - 'delegated', -] as const +const ALL_WORKFLOW_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const -const HUMAN_WORKFLOW_PRINCIPALS = ['session', 'personal_api_key', 'delegated'] as const +const HUMAN_WORKFLOW_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const export const workflowOperations = { list: defineWorkspaceOperation({ id: 'workflows.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'workflows.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), create: defineWorkspaceOperation({ id: 'workflows.create', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'workflows.update', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'workflows.delete', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), listFolders: defineWorkspaceOperation({ id: 'workflows.folders.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), createFolder: defineWorkspaceOperation({ id: 'workflows.folders.create', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), relocateFolder: defineWorkspaceOperation({ id: 'workflows.folders.relocate', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), deleteFolder: defineWorkspaceOperation({ id: 'workflows.folders.delete', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), deploy: defineWorkspaceOperation({ id: 'workflows.deploy', minimumRole: 'admin', workspaceApiKey: 'deny', - principalKinds: HUMAN_WORKFLOW_PRINCIPALS, + ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), undeploy: defineWorkspaceOperation({ id: 'workflows.undeploy', minimumRole: 'admin', workspaceApiKey: 'deny', - principalKinds: HUMAN_WORKFLOW_PRINCIPALS, + ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), activateVersion: defineWorkspaceOperation({ id: 'workflows.versions.activate', minimumRole: 'admin', workspaceApiKey: 'deny', - principalKinds: HUMAN_WORKFLOW_PRINCIPALS, + ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), listVersions: defineWorkspaceOperation({ id: 'workflows.versions.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), readVersion: defineWorkspaceOperation({ id: 'workflows.versions.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), export: defineWorkspaceOperation({ id: 'workflows.export', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), import: defineWorkspaceOperation({ id: 'workflows.import', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), execute: defineWorkspaceOperation({ id: 'workflows.execute', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), listRuns: defineWorkspaceOperation({ id: 'workflows.runs.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), readRun: defineWorkspaceOperation({ id: 'workflows.runs.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), cancelRun: defineWorkspaceOperation({ id: 'workflows.runs.cancel', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), resumeRun: defineWorkspaceOperation({ id: 'workflows.runs.resume', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/workspace-files/application/authorization.test.ts b/apps/sim/lib/workspace-files/application/authorization.test.ts index 524fd4aed21..1ae557d8895 100644 --- a/apps/sim/lib/workspace-files/application/authorization.test.ts +++ b/apps/sim/lib/workspace-files/application/authorization.test.ts @@ -126,6 +126,36 @@ describe('file operation authorization', () => { ) }) + it('admits executor delegation only for explicitly declared file-tool operations', async () => { + const principal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { fileId: 'file-1', executionId: 'execution-1' }, + } + + await authorizeWorkspaceFileAccess( + principal, + fileOperations.updateContent, + authorizationContext + ) + expect(resolvePermission).toHaveBeenCalledTimes(1) + + resolvePermission.mockClear() + await expect( + authorizeWorkspaceFileAccess(principal, fileOperations.rename, authorizationContext) + ).rejects.toMatchObject<Partial<OrchestrationError>>({ + code: 'forbidden', + message: 'Delegated service executor cannot perform operation files.rename', + }) + expect(resolvePermission).not.toHaveBeenCalled() + }) + it('rejects expired or wrong-file delegations before permission lookup', async () => { const base = { kind: 'delegated' as const, diff --git a/apps/sim/lib/workspace-files/application/operations.test.ts b/apps/sim/lib/workspace-files/application/operations.test.ts index 82b5bdbd55c..858b83c2f63 100644 --- a/apps/sim/lib/workspace-files/application/operations.test.ts +++ b/apps/sim/lib/workspace-files/application/operations.test.ts @@ -36,6 +36,23 @@ describe('file operation registry', () => { expect(new Set(ids).size).toBe(ids.length) }) + it('allows executor delegation only for operations used by the internal file tool', () => { + const executorOperationIds = Object.values(fileOperations) + .filter((operation) => operation.delegatedServices?.includes('executor')) + .map((operation) => operation.id) + + expect(executorOperationIds).toEqual([ + 'files.read_metadata', + 'files.read_content', + 'files.download', + 'files.create', + 'files.update_content', + 'files.move', + 'files.share.update', + 'files.folders.create', + ]) + }) + it('keeps external sharing policy changes human-delegated', () => { expect(fileOperations.updateShare.workspaceApiKey).toBe('deny') expect(fileOperations.updateShare.principalKinds).toEqual([ @@ -43,6 +60,7 @@ describe('file operation registry', () => { 'personal_api_key', 'delegated', ]) + expect(fileOperations.updateShare.delegatedServices).toEqual(['copilot', 'executor']) }) it('restricts compiled checks to authenticated sessions', () => { diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index cd4f6179b23..dba3e7da931 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -1,37 +1,42 @@ import { defineWorkspaceOperation } from '@/lib/core/application' -const ALL_PRINCIPAL_KINDS = [ - 'session', - 'personal_api_key', - 'workspace_api_key', - 'delegated', -] as const -const HUMAN_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'delegated'] as const +const ALL_COPILOT_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const +const ALL_FILE_TOOL_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot', 'executor'], +} as const +const HUMAN_FILE_TOOL_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot', 'executor'], +} as const export const fileOperations = { list: defineWorkspaceOperation({ id: 'files.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), readMetadata: defineWorkspaceOperation({ id: 'files.read_metadata', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), readContent: defineWorkspaceOperation({ id: 'files.read_content', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), download: defineWorkspaceOperation({ id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), compiledCheck: defineWorkspaceOperation({ id: 'files.compiled_check', @@ -43,109 +48,109 @@ export const fileOperations = { id: 'files.create', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), rename: defineWorkspaceOperation({ id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), updateContent: defineWorkspaceOperation({ id: 'files.update_content', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), updateMetadata: defineWorkspaceOperation({ id: 'files.update_metadata', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), move: defineWorkspaceOperation({ id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), restore: defineWorkspaceOperation({ id: 'files.restore', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), readShare: defineWorkspaceOperation({ id: 'files.share.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), updateShare: defineWorkspaceOperation({ id: 'files.share.update', minimumRole: 'write', workspaceApiKey: 'deny', - principalKinds: HUMAN_PRINCIPAL_KINDS, + ...HUMAN_FILE_TOOL_PRINCIPAL_POLICY, }), listFolders: defineWorkspaceOperation({ id: 'files.folders.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), createFolder: defineWorkspaceOperation({ id: 'files.folders.create', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), updateFolder: defineWorkspaceOperation({ id: 'files.folders.update', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), deleteFolder: defineWorkspaceOperation({ id: 'files.folders.delete', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), restoreFolder: defineWorkspaceOperation({ id: 'files.folders.restore', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), uploadCreate: defineWorkspaceOperation({ id: 'files.upload.create', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), uploadParts: defineWorkspaceOperation({ id: 'files.upload.parts', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), uploadComplete: defineWorkspaceOperation({ id: 'files.upload.complete', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), uploadCancel: defineWorkspaceOperation({ id: 'files.upload.cancel', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), } as const 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 599d3c3ed09..051957f390c 100644 --- a/apps/sim/lib/workspace-files/application/share-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/share-workspace-file.ts @@ -1,5 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -71,7 +71,7 @@ export const updateWorkspaceFileShare = defineAuthorizedWorkspaceFileUseCase({ return { ...canonical, file } }, async execute({ principal, input, context }): Promise<UpdateWorkspaceFileShareResult> { - const subjectUserId = resolvePrincipalAttribution(principal).attributedUserId + const subjectUserId = requirePrincipalSubjectUserId(principal) const existingShare = await getShareForResource('file', context.fileId) if (input.noOpIfInactive && !input.isActive && !existingShare?.isActive) { diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index d87626272bd..114fe2a0663 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -39,6 +39,28 @@ export interface DelegatedPrincipal { } } +export type DelegatedServiceId = DelegatedPrincipal['serviceId'] + +export class PrincipalSubjectUserRequiredError extends Error { + constructor(principalKind: Principal['kind']) { + super(`Principal kind ${principalKind} does not represent a human subject`) + this.name = 'PrincipalSubjectUserRequiredError' + } +} + +/** Resolves the real human subject represented by a principal or fails fast. */ +export function requirePrincipalSubjectUserId(principal: Principal): string { + switch (principal.kind) { + case 'session': + case 'personal_api_key': + return principal.userId + case 'delegated': + return principal.subjectUserId + case 'workspace_api_key': + throw new PrincipalSubjectUserRequiredError(principal.kind) + } +} + export interface WorkflowExecutionDelegationContext { kind: 'workflow_execution' workflowId: string From 5e30080addb05274c7b219da93093e95d5d76155 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 8 Aug 2026 17:54:04 -0700 Subject: [PATCH 115/159] fix(cli): stream file content to stdout by default --- packages/sim-cli/README.md | 2 +- .../src/commands/protocol/files-get.test.ts | 47 +++++++++++++--- .../src/commands/protocol/files-get.ts | 53 ++++++++++++++----- 3 files changed, 82 insertions(+), 20 deletions(-) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index fa9e6dfe4ec..06e028d64e5 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -164,7 +164,7 @@ sim tables rows batch-delete <tableId> (--row <id>… | --filter <json>) --yes sim files ls [path] [--search <text>] [--limit <n>] sim files list [--folder <path>] sim files describe <fileId> -sim files get <fileId> [-o <path|->] +sim files get <fileId> [-o <path>] # stdout by default sim files create --name <name> [--folder <path>] [--content <value>] [--encoding utf-8|base64] sim files upload <path> [--name <name>] [--folder <path>] sim files share <fileId> [--auth-type public|password|email|sso] diff --git a/packages/sim-cli/src/commands/protocol/files-get.test.ts b/packages/sim-cli/src/commands/protocol/files-get.test.ts index d2836da1445..85f67b1b0bd 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { buildGeneratedCommands } from '../../runtime/build.js' -import { streamToFile } from './files-get.js' +import { isTerminalSafeContentType, streamToFile } from './files-get.js' import { attachProtocolCommands } from './index.js' const { output, requestRaw } = vi.hoisted(() => ({ @@ -87,6 +87,15 @@ describe('streamToFile', () => { ) }) +describe('isTerminalSafeContentType', () => { + it('accepts text formats and rejects binary or unknown formats', () => { + expect(isTerminalSafeContentType('text/markdown; charset=utf-8')).toBe(true) + expect(isTerminalSafeContentType('application/problem+json')).toBe(true) + expect(isTerminalSafeContentType('application/pdf')).toBe(false) + expect(isTerminalSafeContentType(null)).toBe(false) + }) +}) + describe('files get', () => { it('prints a normalized machine-readable result', async () => { const target = join(dir, 'download.txt') @@ -107,7 +116,7 @@ describe('files get', () => { }) }) - it('streams raw bytes to stdout with the conventional - destination', async () => { + it('streams raw bytes to stdout by default', async () => { requestRaw.mockResolvedValue(new Response('downloaded', { status: 200 })) const chunks: Uint8Array[] = [] vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { @@ -116,16 +125,42 @@ describe('files get', () => { }) const logged = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program().parseAsync(['node', 'sim', 'file', 'get', 'file_1', '-o', '-']) + await program().parseAsync(['node', 'sim', 'file', 'get', 'file_1']) expect(Buffer.concat(chunks).toString('utf8')).toBe('downloaded') expect(logged).not.toHaveBeenCalled() }) - it('rejects overwrite semantics for stdout', async () => { + it.each([ + ['without an output path', ['--force']], + ['with the stdout alias', ['-o', '-', '--force']], + ])('rejects --force %s', async (_label, args) => { await expect( - program().parseAsync(['node', 'sim', 'file', 'get', 'file_1', '-o', '-', '--force']) - ).rejects.toThrow(/--force cannot be used/) + program().parseAsync(['node', 'sim', 'file', 'get', 'file_1', ...args]) + ).rejects.toThrow(/--force requires --output-file <path>/) expect(requestRaw).not.toHaveBeenCalled() }) + + it('refuses binary content when stdout is an interactive terminal', async () => { + requestRaw.mockResolvedValue( + new Response(new Uint8Array([0, 1, 2]), { + status: 200, + headers: { 'content-type': 'application/octet-stream' }, + }) + ) + const originalDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY') + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }) + + try { + await expect(program().parseAsync(['node', 'sim', 'file', 'get', 'file_1'])).rejects.toThrow( + /Refusing to write application\/octet-stream.*--output-file/s + ) + } finally { + if (originalDescriptor) { + Object.defineProperty(process.stdout, 'isTTY', originalDescriptor) + } else { + Reflect.deleteProperty(process.stdout, 'isTTY') + } + } + }) }) diff --git a/packages/sim-cli/src/commands/protocol/files-get.ts b/packages/sim-cli/src/commands/protocol/files-get.ts index b58b546cb7b..94548016e34 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -1,6 +1,5 @@ import { once } from 'node:events' import { createWriteStream, type WriteStream } from 'node:fs' -import { basename } from 'node:path' import type { Command } from 'commander' import { clientFrom } from '../../context.js' import { V2_OPERATIONS } from '../../generated/v2-api.js' @@ -40,7 +39,7 @@ export async function streamToFile( const code = (error as NodeJS.ErrnoException).code if (code === 'EEXIST') { throw new SimApiError( - `${file.path} already exists. Pass --force to overwrite, or -o to write elsewhere.`, + `${file.path} already exists. Pass --force to overwrite it, or choose another output path.`, 0 ) } @@ -65,20 +64,44 @@ export async function streamToStdout( } } +/** Returns whether content can be written directly to an interactive terminal. */ +export function isTerminalSafeContentType(contentType: string | null): boolean { + if (!contentType) return false + + const mediaType = contentType.split(';', 1)[0].trim().toLowerCase() + return ( + mediaType.startsWith('text/') || + mediaType.endsWith('+json') || + mediaType.endsWith('+xml') || + [ + 'application/graphql', + 'application/javascript', + 'application/json', + 'application/sql', + 'application/x-javascript', + 'application/x-yaml', + 'application/xml', + 'application/yaml', + 'image/svg+xml', + ].includes(mediaType) + ) +} + export function attachFileGet(files: Command): void { files .command('get <fileId>') .description('Get a file’s content') - .option('-o, --output-file <path>', 'Where to write it (default: file name; -: stdout)') - .option('--force', 'Overwrite the destination if it already exists') + .option('-o, --output-file <path>', 'Write content to a file instead of stdout') + .option('--force', 'Overwrite --output-file if it already exists') .action( async ( fileId: string, options: { outputFile?: string; force?: boolean }, command: Command ) => { - if (options.outputFile === '-' && options.force) { - throw new SimApiError('--force cannot be used when --output-file is -', 0) + const writesToStdout = options.outputFile === undefined || options.outputFile === '-' + if (writesToStdout && options.force) { + throw new SimApiError('--force requires --output-file <path>', 0) } const { client, profile } = clientFrom(command) @@ -92,17 +115,21 @@ export function attachFileGet(files: Command): void { throw new SimApiError('File content response was empty.', response.status) } - if (options.outputFile === '-') { + if (options.outputFile === undefined || options.outputFile === '-') { + const contentType = response.headers.get('content-type') + if (process.stdout.isTTY && !isTerminalSafeContentType(contentType)) { + await response.body.cancel() + throw new SimApiError( + `Refusing to write ${contentType ?? 'unknown content'} to an interactive terminal. Use --output-file <path> or pipe stdout.`, + 0 + ) + } + await streamToStdout(response.body) return } - const target = - options.outputFile ?? - basename( - /filename="?([^";]+)"?/.exec(response.headers.get('content-disposition') ?? '')?.[1] ?? - fileId - ) + const target = options.outputFile await streamToFile( response.body, From 8dfc6bb60796cc71cc20b596057bcfbcb5d85651 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sun, 9 Aug 2026 00:42:42 -0700 Subject: [PATCH 116/159] improvement(copilot): consolidate application adapters (#6450) --- .../application/application-adapter.test.ts | 191 ++++++++++++++++++ .../application/application-adapter.ts | 155 ++++++++++++++ .../sim/lib/copilot/application/error.test.ts | 37 ++++ apps/sim/lib/copilot/application/error.ts | 13 ++ .../execute-custom-tool-use-case.ts | 14 +- .../application/execute-file-use-case.ts | 30 +-- .../application/execute-knowledge-use-case.ts | 64 +++--- .../execute-mcp-server-use-case.ts | 14 +- .../application/execute-skill-use-case.ts | 14 +- .../application/execute-table-use-case.ts | 34 ++-- .../execute-workspace-use-case.test.ts | 100 --------- .../application/execute-workspace-use-case.ts | 34 ---- .../auth/application-delegation.test.ts | 64 ++++++ .../copilot/auth/application-delegation.ts | 138 +++++++++++++ apps/sim/lib/copilot/auth/file-delegation.ts | 89 ++++---- apps/sim/lib/copilot/auth/table-delegation.ts | 43 ++-- .../auth/workspace-application-delegation.ts | 46 ----- .../registry/server-tool-adapter.test.ts | 46 ++++- .../tools/registry/server-tool-adapter.ts | 22 +- .../application/authorization.test.ts | 35 ++-- .../application/delegated-principal.ts | 33 --- .../table/application/delegated-principal.ts | 36 ---- 22 files changed, 822 insertions(+), 430 deletions(-) create mode 100644 apps/sim/lib/copilot/application/application-adapter.test.ts create mode 100644 apps/sim/lib/copilot/application/application-adapter.ts create mode 100644 apps/sim/lib/copilot/application/error.test.ts create mode 100644 apps/sim/lib/copilot/application/error.ts delete mode 100644 apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts delete mode 100644 apps/sim/lib/copilot/application/execute-workspace-use-case.ts create mode 100644 apps/sim/lib/copilot/auth/application-delegation.test.ts create mode 100644 apps/sim/lib/copilot/auth/application-delegation.ts delete mode 100644 apps/sim/lib/copilot/auth/workspace-application-delegation.ts delete mode 100644 apps/sim/lib/knowledge/application/delegated-principal.ts delete mode 100644 apps/sim/lib/table/application/delegated-principal.ts diff --git a/apps/sim/lib/copilot/application/application-adapter.test.ts b/apps/sim/lib/copilot/application/application-adapter.test.ts new file mode 100644 index 00000000000..0ef223344bf --- /dev/null +++ b/apps/sim/lib/copilot/application/application-adapter.test.ts @@ -0,0 +1,191 @@ +/** + * @vitest-environment node + */ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { describe, expect, it, vi } from 'vitest' +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { + type CopilotDelegationConfiguration, + type CopilotResourceScope, + createCopilotApplicationPrincipal, + type TrustedCopilotExecutionContext, +} from '@/lib/copilot/auth/application-delegation' +import { defineWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' + +const operation = defineWorkspaceOperation({ + id: 'files.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], +}) + +const delegation = { + audience: 'sim:files', + ttlMs: 5 * 60 * 1000, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, +} as const satisfies CopilotDelegationConfiguration + +const trustedContext = { + userId: 'trusted-user', + workspaceId: 'workspace-1', + chatId: 'chat-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} as const + +interface FileScopeInput { + fileId: string +} + +function createAdapter( + createPrincipal?: (args: { + context: TrustedCopilotExecutionContext + resourceScope: CopilotResourceScope + }) => DelegatedPrincipal +) { + return createCopilotApplicationAdapter<WorkspaceOperation, FileScopeInput>({ + domain: 'file', + delegation, + operations: { read: operation }, + projectResourceScope: ({ fileId }) => ({ fileId }), + createPrincipal, + }) +} + +describe('Copilot application adapter', () => { + it('binds only code-projected scope and leaves model input non-authoritative', async () => { + const execute = vi.fn().mockResolvedValue({ ok: true }) + + await createAdapter()( + trustedContext, + { operation, execute }, + { fileId: 'model-forged-file' }, + { fileId: 'trusted-file' } + ) + + expect(execute).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + subjectUserId: 'trusted-user', + workspaceId: 'workspace-1', + resourceScope: { + fileId: 'trusted-file', + chatId: 'chat-1', + executionId: 'execution-1', + }, + }), + input: { fileId: 'model-forged-file' }, + }) + }) + + it('rejects unregistered and same-ID forged operation objects', () => { + const executeCopilotUseCase = createAdapter() + const unregistered = defineWorkspaceOperation({ + id: 'files.unregistered', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }) + const sameIdDifferentPolicy = defineWorkspaceOperation({ + id: operation.id, + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }) + + expect(() => + executeCopilotUseCase( + trustedContext, + { operation: unregistered, execute: vi.fn() }, + {}, + { fileId: 'file-1' } + ) + ).toThrow('Unregistered Copilot file operation') + expect(() => + executeCopilotUseCase( + trustedContext, + { operation: sameIdDifferentPolicy, execute: vi.fn() }, + {}, + { fileId: 'file-1' } + ) + ).toThrow('Unregistered Copilot file operation') + }) + + it('rejects an operation whose delegated identity policy excludes Copilot', () => { + const executorOperation = defineWorkspaceOperation({ + id: 'files.executor_only', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }) + const execute = vi.fn() + const executeCopilotUseCase = createCopilotApplicationAdapter< + WorkspaceOperation, + FileScopeInput + >({ + domain: 'file', + delegation, + operations: { executorOnly: executorOperation }, + projectResourceScope: ({ fileId }) => ({ fileId }), + }) + + expect(() => + executeCopilotUseCase( + trustedContext, + { operation: executorOperation, execute }, + {}, + { fileId: 'file-1' } + ) + ).toThrow('Delegated service copilot cannot perform operation files.executor_only') + expect(execute).not.toHaveBeenCalled() + }) + + it('rejects a principal factory that changes the configured audience', () => { + const execute = vi.fn() + const executeCopilotUseCase = createAdapter(({ context, resourceScope }) => ({ + ...createCopilotApplicationPrincipal(context, { ...delegation, resourceScope }), + audience: 'sim:forged', + })) + + expect(() => + executeCopilotUseCase(trustedContext, { operation, execute }, {}, { fileId: 'file-1' }) + ).toThrow('configured delegation identity') + expect(execute).not.toHaveBeenCalled() + }) + + it('rejects an expired principal before application execution', () => { + const execute = vi.fn() + const executeCopilotUseCase = createAdapter(({ context, resourceScope }) => { + const principal = createCopilotApplicationPrincipal(context, { + ...delegation, + resourceScope, + }) + return { ...principal, expiresAt: new Date(principal.issuedAt.getTime() - 1) } + }) + + expect(() => + executeCopilotUseCase(trustedContext, { operation, execute }, {}, { fileId: 'file-1' }) + ).toThrow('configured delegation expiry') + expect(execute).not.toHaveBeenCalled() + }) + + it('rejects a principal factory scoped to a different resource', () => { + const execute = vi.fn() + const executeCopilotUseCase = createAdapter(({ context, resourceScope }) => { + const principal = createCopilotApplicationPrincipal(context, { + ...delegation, + resourceScope, + }) + return { ...principal, resourceScope: { ...principal.resourceScope, fileId: 'file-2' } } + }) + + expect(() => + executeCopilotUseCase(trustedContext, { operation, execute }, {}, { fileId: 'file-1' }) + ).toThrow('configured resource scope') + expect(execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/application/application-adapter.ts b/apps/sim/lib/copilot/application/application-adapter.ts new file mode 100644 index 00000000000..2cb58cd32c9 --- /dev/null +++ b/apps/sim/lib/copilot/application/application-adapter.ts @@ -0,0 +1,155 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { + type CopilotDelegationConfiguration, + type CopilotExecutionContext, + type CopilotResourceScope, + createCopilotApplicationPrincipal, + requireTrustedCopilotExecutionContext, + type TrustedCopilotExecutionContext, +} from '@/lib/copilot/auth/application-delegation' +import { + type OperationUseCase, + requireAllowedWorkspacePrincipal, + type WorkspaceOperation, +} from '@/lib/core/application' + +type CopilotApplicationPrincipalFactory = (args: { + context: TrustedCopilotExecutionContext + resourceScope: CopilotResourceScope +}) => DelegatedPrincipal + +interface CopilotApplicationAdapterOptions<O extends WorkspaceOperation, ScopeInput = undefined> { + domain: string + delegation: CopilotDelegationConfiguration + operations: Readonly<Record<string, O>> + projectResourceScope?( + input: ScopeInput, + context: TrustedCopilotExecutionContext + ): CopilotResourceScope + createPrincipal?: CopilotApplicationPrincipalFactory +} + +type ScopeArguments<ScopeInput> = [ScopeInput] extends [undefined] ? [] : [scope: ScopeInput] + +const RESOURCE_SCOPE_KEYS = ['fileId', 'tableId', 'chatId', 'executionId'] as const + +function requireValidProjectedResourceScope(resourceScope: CopilotResourceScope): void { + if (resourceScope.fileId !== undefined && !resourceScope.fileId.trim()) { + throw new Error('Copilot application resource scope contains an invalid file ID') + } + if (resourceScope.tableId !== undefined && !resourceScope.tableId.trim()) { + throw new Error('Copilot application resource scope contains an invalid table ID') + } +} + +function expectedResourceScope( + context: TrustedCopilotExecutionContext, + resourceScope: CopilotResourceScope +): NonNullable<DelegatedPrincipal['resourceScope']> { + return { + ...resourceScope, + ...(context.chatId ? { chatId: context.chatId } : {}), + ...(context.executionId ? { executionId: context.executionId } : {}), + } +} + +function requireMatchingPrincipal( + principal: DelegatedPrincipal, + context: TrustedCopilotExecutionContext, + delegation: CopilotDelegationConfiguration, + resourceScope: CopilotResourceScope +): void { + const delegationId = delegation.createDelegationId(context) + if ( + principal.kind !== 'delegated' || + principal.serviceId !== 'copilot' || + principal.subjectUserId !== context.userId || + principal.workspaceId !== context.workspaceId || + !delegationId.trim() || + principal.delegationId !== delegationId || + principal.audience !== delegation.audience + ) { + throw new Error('Copilot principal factory violated the configured delegation identity') + } + + const issuedAt = principal.issuedAt.getTime() + const expiresAt = principal.expiresAt.getTime() + if ( + !Number.isFinite(issuedAt) || + !Number.isFinite(expiresAt) || + issuedAt > Date.now() || + expiresAt <= Date.now() || + expiresAt - issuedAt !== delegation.ttlMs + ) { + throw new Error('Copilot principal factory violated the configured delegation expiry') + } + + const expectedScope = expectedResourceScope(context, resourceScope) + if (RESOURCE_SCOPE_KEYS.some((key) => principal.resourceScope?.[key] !== expectedScope[key])) { + throw new Error('Copilot principal factory violated the configured resource scope') + } +} + +/** Adapts trusted Copilot calls to a domain's existing application use cases. */ +export function createCopilotApplicationAdapter< + O extends WorkspaceOperation, + ScopeInput = undefined, +>(options: CopilotApplicationAdapterOptions<O, ScopeInput>) { + if (!options.domain.trim()) throw new Error('Copilot application adapter requires a domain') + if (!options.delegation.audience.trim()) { + throw new Error('Copilot application adapter requires a delegation audience') + } + if (!Number.isInteger(options.delegation.ttlMs) || options.delegation.ttlMs <= 0) { + throw new Error('Copilot application adapter requires a positive integer delegation TTL') + } + + const operations = Object.values(options.operations) + if (operations.length === 0) { + throw new Error(`Copilot ${options.domain} operation registry cannot be empty`) + } + const operationIds = new Set<string>() + for (const operation of operations) { + if (!Object.isFrozen(operation)) { + throw new Error(`Copilot ${options.domain} operation ${operation.id} must be immutable`) + } + if (operationIds.has(operation.id)) { + throw new Error(`Copilot ${options.domain} operation registry contains duplicate IDs`) + } + operationIds.add(operation.id) + } + const registeredOperations = new Set<O>(operations) + + return function executeCopilotApplicationUseCase<Selected extends O, I, R>( + context: CopilotExecutionContext | undefined, + useCase: OperationUseCase<Selected, I, R>, + input: I, + ...scopeArguments: ScopeArguments<ScopeInput> + ): Promise<R> { + if (!registeredOperations.has(useCase.operation)) { + throw new Error(`Unregistered Copilot ${options.domain} operation: ${useCase.operation.id}`) + } + + const trustedContext = requireTrustedCopilotExecutionContext(context) + let resourceScope: CopilotResourceScope = {} + if (options.projectResourceScope) { + if (scopeArguments.length !== 1) { + throw new Error(`Copilot ${options.domain} execution requires trusted scope input`) + } + resourceScope = options.projectResourceScope(scopeArguments[0], trustedContext) + } else if (scopeArguments.length !== 0) { + throw new Error(`Copilot ${options.domain} execution does not accept resource scope input`) + } + requireValidProjectedResourceScope(resourceScope) + + const principal = options.createPrincipal + ? options.createPrincipal({ context: trustedContext, resourceScope }) + : createCopilotApplicationPrincipal(trustedContext, { + ...options.delegation, + resourceScope, + }) + requireMatchingPrincipal(principal, trustedContext, options.delegation, resourceScope) + requireAllowedWorkspacePrincipal(principal, useCase.operation) + + return useCase.execute({ principal, input }) + } +} diff --git a/apps/sim/lib/copilot/application/error.test.ts b/apps/sim/lib/copilot/application/error.test.ts new file mode 100644 index 00000000000..b0a0d4feb7b --- /dev/null +++ b/apps/sim/lib/copilot/application/error.test.ts @@ -0,0 +1,37 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE, + messageForCopilotApplicationError, +} from '@/lib/copilot/application/error' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +describe('Copilot application error projection', () => { + it('exposes only non-internal application errors', () => { + expect( + messageForCopilotApplicationError(new OrchestrationError('conflict', 'Name already exists')) + ).toBe('Name already exists') + }) + + it('projects internal and unknown infrastructure failures to a generic retryable message', () => { + expect( + messageForCopilotApplicationError( + new OrchestrationError('internal', 'select secret_column from workspace_files') + ) + ).toBe(COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE) + expect(messageForCopilotApplicationError(new Error('storage bucket credential rejected'))).toBe( + COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE + ) + }) + + it('supports a caller-defined safe fallback without exposing the cause', () => { + expect( + messageForCopilotApplicationError( + new Error('update workspace_files set content = raw'), + 'File operation failed. Please retry.' + ) + ).toBe('File operation failed. Please retry.') + }) +}) diff --git a/apps/sim/lib/copilot/application/error.ts b/apps/sim/lib/copilot/application/error.ts new file mode 100644 index 00000000000..966a3233b7c --- /dev/null +++ b/apps/sim/lib/copilot/application/error.ts @@ -0,0 +1,13 @@ +import { asOrchestrationError } from '@/lib/core/orchestration/types' + +export const COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE = + 'The operation failed due to a system error. Please retry.' + +/** Projects only caller-actionable application failures into Copilot-visible content. */ +export function messageForCopilotApplicationError( + error: unknown, + fallback = COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE +): string { + const classified = asOrchestrationError(error) + return classified && classified.code !== 'internal' ? classified.message : fallback +} diff --git a/apps/sim/lib/copilot/application/execute-custom-tool-use-case.ts b/apps/sim/lib/copilot/application/execute-custom-tool-use-case.ts index 0ba46365102..70662c129f3 100644 --- a/apps/sim/lib/copilot/application/execute-custom-tool-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-custom-tool-use-case.ts @@ -1,8 +1,14 @@ -import { createCopilotWorkspaceUseCaseExecutor } from '@/lib/copilot/application/execute-workspace-use-case' -import { CUSTOM_TOOL_DELEGATION_AUDIENCE } from '@/lib/custom-tools/application/authorization' +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import { customToolDelegationPolicy } from '@/lib/custom-tools/application/authorization' import { customToolOperations } from '@/lib/custom-tools/application/operations' -export const executeCopilotCustomToolUseCase = createCopilotWorkspaceUseCaseExecutor({ - audience: CUSTOM_TOOL_DELEGATION_AUDIENCE, +export const executeCopilotCustomToolUseCase = createCopilotApplicationAdapter({ + domain: 'custom tool', + delegation: { + audience: customToolDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, operations: customToolOperations, }) diff --git a/apps/sim/lib/copilot/application/execute-file-use-case.ts b/apps/sim/lib/copilot/application/execute-file-use-case.ts index 2532fe7c370..cb03a490bbe 100644 --- a/apps/sim/lib/copilot/application/execute-file-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-file-use-case.ts @@ -1,19 +1,32 @@ +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' import { type CopilotFileDelegationContext, resolveCopilotFilePrincipal, } from '@/lib/copilot/auth/file-delegation' import type { OperationUseCase } from '@/lib/core/application' +import { workspaceFileDelegationPolicy } from '@/lib/workspace-files/application/authorization' import { type FileOperation, fileOperations } from '@/lib/workspace-files/application/operations' import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' -const registeredFileOperationIds = new Set<string>( - Object.values(fileOperations).map((operation) => operation.id) -) - interface ExecuteCopilotFileUseCaseOptions { fileId?: string } +const executeFileUseCase = createCopilotApplicationAdapter< + FileOperation, + ExecuteCopilotFileUseCaseOptions +>({ + domain: 'file', + delegation: { + audience: workspaceFileDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, + operations: fileOperations, + projectResourceScope: ({ fileId }) => (fileId ? { fileId } : {}), +}) + /** Normalizes trusted Copilot authentication before entering a file application use case. */ export function executeCopilotFileUseCase<O extends FileOperation, I, R>( context: CopilotFileDelegationContext | undefined, @@ -21,14 +34,7 @@ export function executeCopilotFileUseCase<O extends FileOperation, I, R>( input: I, options: ExecuteCopilotFileUseCaseOptions = {} ): Promise<R> { - if (!registeredFileOperationIds.has(useCase.operation.id)) { - throw new Error(`Unregistered Copilot file operation: ${useCase.operation.id}`) - } - - return useCase.execute({ - principal: resolveCopilotFilePrincipal(context, options.fileId), - input, - }) + return executeFileUseCase(context, useCase, input, options) } /** Resolves a model-supplied VFS reference under a trusted Copilot delegation. */ diff --git a/apps/sim/lib/copilot/application/execute-knowledge-use-case.ts b/apps/sim/lib/copilot/application/execute-knowledge-use-case.ts index f9f9fb162f2..d88c5c940b3 100644 --- a/apps/sim/lib/copilot/application/execute-knowledge-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-knowledge-use-case.ts @@ -1,45 +1,42 @@ import type { DelegatedPrincipal } from '@sim/auth/principal' +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + type CopilotExecutionContext, + createCopilotApplicationPrincipal, + requireTrustedCopilotExecutionContext, +} from '@/lib/copilot/auth/application-delegation' import type { OperationUseCase } from '@/lib/core/application' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { createKnowledgeDelegatedPrincipal } from '@/lib/knowledge/application/delegated-principal' +import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' import { type KnowledgeOperation, knowledgeOperations, } from '@/lib/knowledge/application/operations' -export interface CopilotKnowledgeDelegationContext { - userId: string - workspaceId?: string - chatId?: string - executionId?: string - toolCallId?: string - copilotToolExecution?: boolean -} +export type CopilotKnowledgeDelegationContext = CopilotExecutionContext -const registeredKnowledgeOperationIds = new Set<string>( - Object.values(knowledgeOperations).map((operation) => operation.id) -) +const knowledgeDelegation = { + audience: knowledgeDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context: Parameters<typeof createCopilotApplicationPrincipal>[0]) => + context.toolCallId, +} as const + +const executeKnowledgeUseCase = createCopilotApplicationAdapter({ + domain: 'knowledge', + delegation: knowledgeDelegation, + operations: knowledgeOperations, +}) /** Normalizes immutable Copilot execution identity into a knowledge delegation. */ export function resolveCopilotKnowledgePrincipal( context: CopilotKnowledgeDelegationContext | undefined ): DelegatedPrincipal { - if (!context) throw new Error('Knowledge delegation requires a Copilot execution context') - if (!context.copilotToolExecution) { - throw new Error('Knowledge delegation requires a trusted Copilot execution context') - } - if (!context.userId) throw new Error('Knowledge delegation requires an authenticated user ID') - if (!context.workspaceId) throw new Error('Knowledge delegation requires a workspace ID') - if (!context.toolCallId) throw new Error('Knowledge delegation requires a tool call ID') - - return createKnowledgeDelegatedPrincipal({ - serviceId: 'copilot', - subjectUserId: context.userId, - workspaceId: context.workspaceId, - delegationId: context.toolCallId, - chatId: context.chatId, - executionId: context.executionId, - }) + return createCopilotApplicationPrincipal( + requireTrustedCopilotExecutionContext(context), + knowledgeDelegation + ) } /** Enters a registered knowledge application use case with trusted Copilot identity. */ @@ -48,10 +45,7 @@ export function executeCopilotKnowledgeUseCase<O extends KnowledgeOperation, I, useCase: OperationUseCase<O, I, R>, input: I ): Promise<R> { - if (!registeredKnowledgeOperationIds.has(useCase.operation.id)) { - throw new Error(`Unregistered Copilot knowledge operation: ${useCase.operation.id}`) - } - return useCase.execute({ principal: resolveCopilotKnowledgePrincipal(context), input }) + return executeKnowledgeUseCase(context, useCase, input) } /** Projects only caller-actionable application errors into a Copilot result. */ @@ -59,7 +53,5 @@ export function messageForCopilotKnowledgeError( error: unknown, fallback = 'Knowledge operation failed' ): string { - const classified = asOrchestrationError(error) - if (classified && classified.code !== 'internal') return classified.message - return fallback + return messageForCopilotApplicationError(error, fallback) } diff --git a/apps/sim/lib/copilot/application/execute-mcp-server-use-case.ts b/apps/sim/lib/copilot/application/execute-mcp-server-use-case.ts index 7744e0e0b2a..76c05447784 100644 --- a/apps/sim/lib/copilot/application/execute-mcp-server-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-mcp-server-use-case.ts @@ -1,8 +1,14 @@ -import { createCopilotWorkspaceUseCaseExecutor } from '@/lib/copilot/application/execute-workspace-use-case' -import { MCP_SERVER_DELEGATION_AUDIENCE } from '@/lib/mcp/application/authorization' +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import { mcpServerDelegationPolicy } from '@/lib/mcp/application/authorization' import { mcpServerOperations } from '@/lib/mcp/application/operations' -export const executeCopilotMcpServerUseCase = createCopilotWorkspaceUseCaseExecutor({ - audience: MCP_SERVER_DELEGATION_AUDIENCE, +export const executeCopilotMcpServerUseCase = createCopilotApplicationAdapter({ + domain: 'MCP server', + delegation: { + audience: mcpServerDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, operations: mcpServerOperations, }) diff --git a/apps/sim/lib/copilot/application/execute-skill-use-case.ts b/apps/sim/lib/copilot/application/execute-skill-use-case.ts index 8acce8e1e12..7e38e20d9ff 100644 --- a/apps/sim/lib/copilot/application/execute-skill-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-skill-use-case.ts @@ -1,8 +1,14 @@ -import { createCopilotWorkspaceUseCaseExecutor } from '@/lib/copilot/application/execute-workspace-use-case' -import { SKILL_DELEGATION_AUDIENCE } from '@/lib/skills/application/authorization' +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import { skillDelegationPolicy } from '@/lib/skills/application/authorization' import { skillOperations } from '@/lib/skills/application/operations' -export const executeCopilotSkillUseCase = createCopilotWorkspaceUseCaseExecutor({ - audience: SKILL_DELEGATION_AUDIENCE, +export const executeCopilotSkillUseCase = createCopilotApplicationAdapter({ + domain: 'skill', + delegation: { + audience: skillDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, operations: skillOperations, }) diff --git a/apps/sim/lib/copilot/application/execute-table-use-case.ts b/apps/sim/lib/copilot/application/execute-table-use-case.ts index 536a05f9161..b973f5fd1bf 100644 --- a/apps/sim/lib/copilot/application/execute-table-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-table-use-case.ts @@ -1,8 +1,8 @@ -import { - type CopilotTableDelegationContext, - resolveCopilotTablePrincipal, -} from '@/lib/copilot/auth/table-delegation' +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import type { CopilotTableDelegationContext } from '@/lib/copilot/auth/table-delegation' import type { OperationUseCase } from '@/lib/core/application' +import { tableDelegationPolicy } from '@/lib/table/application/authorization' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' import { resolveActiveTableContext, @@ -10,10 +10,6 @@ import { } from '@/lib/table/application/context' import { type TableOperation, tableOperations } from '@/lib/table/application/operations' -const registeredTableOperationIds = new Set<string>( - Object.values(tableOperations).map((operation) => operation.id) -) - interface ExecuteCopilotTableUseCaseOptions { tableId?: string } @@ -23,6 +19,20 @@ export interface AdmitCopilotTableOperationInput { tableId?: string } +const executeTableUseCase = createCopilotApplicationAdapter< + TableOperation, + ExecuteCopilotTableUseCaseOptions +>({ + domain: 'table', + delegation: { + audience: tableDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, + operations: tableOperations, + projectResourceScope: ({ tableId }) => (tableId ? { tableId } : {}), +}) + /** Enters a registered table application use case under trusted Copilot delegation. */ export function executeCopilotTableUseCase<O extends TableOperation, I, R>( context: CopilotTableDelegationContext | undefined, @@ -30,13 +40,7 @@ export function executeCopilotTableUseCase<O extends TableOperation, I, R>( input: I, options: ExecuteCopilotTableUseCaseOptions = {} ): Promise<R> { - if (!registeredTableOperationIds.has(useCase.operation.id)) { - throw new Error(`Unregistered Copilot table operation: ${useCase.operation.id}`) - } - return useCase.execute({ - principal: resolveCopilotTablePrincipal(context, options.tableId), - input, - }) + return executeTableUseCase(context, useCase, input, options) } /** diff --git a/apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts b/apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts deleted file mode 100644 index 5e18cd751a7..00000000000 --- a/apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * @vitest-environment node - */ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createCopilotWorkspaceUseCaseExecutor } from '@/lib/copilot/application/execute-workspace-use-case' - -const operation = { - id: 'skills.update', - minimumRole: 'read' as const, - workspaceApiKey: 'deny' as const, - principalKinds: ['delegated'] as const, - delegatedServices: ['copilot'] as const, -} - -describe('Copilot workspace application delegation', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it('builds a bounded principal from trusted runtime context, never tool input identity', async () => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) - const execute = vi.fn().mockResolvedValue({ ok: true }) - const executeCopilotUseCase = createCopilotWorkspaceUseCaseExecutor({ - audience: 'sim:skills', - operations: { update: operation }, - }) - - await executeCopilotUseCase( - { - userId: 'trusted-user', - workspaceId: 'workspace-1', - chatId: 'chat-1', - executionId: 'execution-1', - toolCallId: 'call-1', - copilotToolExecution: true, - }, - { operation, execute }, - { userId: 'model-supplied-user', workspaceId: 'workspace-1' } - ) - - expect(execute).toHaveBeenCalledWith({ - principal: { - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: 'trusted-user', - workspaceId: 'workspace-1', - delegationId: 'copilot-tool:call-1', - audience: 'sim:skills', - issuedAt: new Date('2026-01-01T00:00:00Z'), - expiresAt: new Date('2026-01-01T00:05:00Z'), - resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, - }, - input: { userId: 'model-supplied-user', workspaceId: 'workspace-1' }, - }) - }) - - it('fails fast for an untrusted execution context', async () => { - const execute = vi.fn() - const executeCopilotUseCase = createCopilotWorkspaceUseCaseExecutor({ - audience: 'sim:skills', - operations: { update: operation }, - }) - - expect(() => - executeCopilotUseCase( - { - userId: 'user-1', - workspaceId: 'workspace-1', - toolCallId: 'call-1', - copilotToolExecution: false, - }, - { operation, execute }, - { workspaceId: 'workspace-1' } - ) - ).toThrow('trusted Copilot execution context') - expect(execute).not.toHaveBeenCalled() - }) - - it('fails fast when a tool adapter tries an unregistered operation', () => { - const executeCopilotUseCase = createCopilotWorkspaceUseCaseExecutor({ - audience: 'sim:skills', - operations: { update: operation }, - }) - const unregistered = { ...operation, id: 'skills.unregistered' } - - expect(() => - executeCopilotUseCase( - { - userId: 'user-1', - workspaceId: 'workspace-1', - toolCallId: 'call-1', - copilotToolExecution: true, - }, - { operation: unregistered, execute: vi.fn() }, - { workspaceId: 'workspace-1' } - ) - ).toThrow('Unregistered Copilot workspace operation') - }) -}) diff --git a/apps/sim/lib/copilot/application/execute-workspace-use-case.ts b/apps/sim/lib/copilot/application/execute-workspace-use-case.ts deleted file mode 100644 index 03774dee872..00000000000 --- a/apps/sim/lib/copilot/application/execute-workspace-use-case.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - type CopilotWorkspaceDelegationContext, - createCopilotWorkspacePrincipal, -} from '@/lib/copilot/auth/workspace-application-delegation' -import type { OperationUseCase, WorkspaceOperation } from '@/lib/core/application' - -interface CopilotWorkspaceUseCaseExecutorOptions<O extends WorkspaceOperation> { - audience: string - operations: Readonly<Record<string, O>> -} - -/** Binds a domain registry to the trusted Copilot workspace execution runtime. */ -export function createCopilotWorkspaceUseCaseExecutor<O extends WorkspaceOperation>( - options: CopilotWorkspaceUseCaseExecutorOptions<O> -) { - const registeredOperationIds = new Set( - Object.values(options.operations).map((operation) => operation.id) - ) - - return function executeCopilotWorkspaceUseCase<Selected extends O, I, R>( - context: CopilotWorkspaceDelegationContext | undefined, - useCase: OperationUseCase<Selected, I, R>, - input: I - ): Promise<R> { - if (!registeredOperationIds.has(useCase.operation.id)) { - throw new Error(`Unregistered Copilot workspace operation: ${useCase.operation.id}`) - } - - return useCase.execute({ - principal: createCopilotWorkspacePrincipal(context, { audience: options.audience }), - input, - }) - } -} diff --git a/apps/sim/lib/copilot/auth/application-delegation.test.ts b/apps/sim/lib/copilot/auth/application-delegation.test.ts new file mode 100644 index 00000000000..1e0bdc5037c --- /dev/null +++ b/apps/sim/lib/copilot/auth/application-delegation.test.ts @@ -0,0 +1,64 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createCopilotApplicationPrincipal, + requireTrustedCopilotExecutionContext, +} from '@/lib/copilot/auth/application-delegation' + +const trustedContext = { + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} as const + +describe('Copilot application delegation', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each([ + [ + 'marker', + { ...trustedContext, copilotToolExecution: undefined }, + 'trusted Copilot execution context', + ], + ['user', { ...trustedContext, userId: undefined }, 'authenticated user ID'], + ['workspace', { ...trustedContext, workspaceId: undefined }, 'workspace ID'], + ['tool call', { ...trustedContext, toolCallId: undefined }, 'tool call ID'], + ])('rejects a missing or invalid trusted %s', (_field, context, message) => { + expect(() => requireTrustedCopilotExecutionContext(context)).toThrow(message) + }) + + it('creates an explicitly bounded Copilot principal from trusted context', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + + expect( + createCopilotApplicationPrincipal(trustedContext, { + audience: 'sim:files', + ttlMs: 5 * 60 * 1000, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + resourceScope: { fileId: 'file-1' }, + }) + ).toEqual({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-call-1', + audience: 'sim:files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2026-01-01T00:05:00Z'), + resourceScope: { + fileId: 'file-1', + chatId: 'chat-1', + executionId: 'execution-1', + }, + }) + }) +}) diff --git a/apps/sim/lib/copilot/auth/application-delegation.ts b/apps/sim/lib/copilot/auth/application-delegation.ts new file mode 100644 index 00000000000..969bf37b325 --- /dev/null +++ b/apps/sim/lib/copilot/auth/application-delegation.ts @@ -0,0 +1,138 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' + +export const COPILOT_APPLICATION_DELEGATION_TTL_MS = 5 * 60 * 1000 + +export interface CopilotExecutionContext { + userId?: string + workspaceId?: string + chatId?: string + executionId?: string + toolCallId?: string + copilotToolExecution?: boolean +} + +export interface TrustedCopilotExecutionContext extends CopilotExecutionContext { + userId: string + workspaceId: string + toolCallId: string + copilotToolExecution: true +} + +export type CopilotResourceScope = Pick< + NonNullable<DelegatedPrincipal['resourceScope']>, + 'fileId' | 'tableId' +> + +export interface CopilotDelegationConfiguration { + audience: string + ttlMs: number + createDelegationId(context: TrustedCopilotExecutionContext): string +} + +interface CreateCopilotApplicationPrincipalOptions extends CopilotDelegationConfiguration { + resourceScope?: CopilotResourceScope +} + +interface CreateTrustedCopilotPrincipalInput { + userId: string + workspaceId: string + delegationId: string + chatId?: string + executionId?: string +} + +interface CreateTrustedCopilotPrincipalOptions { + audience: string + ttlMs: number + resourceScope?: CopilotResourceScope +} + +function requireNonEmpty(value: string | undefined, field: string): asserts value is string { + if (!value?.trim()) throw new Error(`Copilot execution context requires ${field}`) +} + +/** Validates and narrows the server-authored identity attached to a Copilot tool call. */ +export function requireTrustedCopilotExecutionContext( + context: CopilotExecutionContext | undefined +): TrustedCopilotExecutionContext { + if (!context) throw new Error('Copilot execution context is required') + if (context.copilotToolExecution !== true) { + throw new Error('Copilot execution context requires a trusted Copilot execution context') + } + requireNonEmpty(context.userId, 'an authenticated user ID') + requireNonEmpty(context.workspaceId, 'a workspace ID') + requireNonEmpty(context.toolCallId, 'a tool call ID') + if (context.chatId !== undefined) requireNonEmpty(context.chatId, 'a valid chat ID') + if (context.executionId !== undefined) { + requireNonEmpty(context.executionId, 'a valid execution ID') + } + + return Object.freeze({ + userId: context.userId, + workspaceId: context.workspaceId, + ...(context.chatId ? { chatId: context.chatId } : {}), + ...(context.executionId ? { executionId: context.executionId } : {}), + toolCallId: context.toolCallId, + copilotToolExecution: true, + }) +} + +/** Creates a bounded Copilot principal from an explicitly trusted server lifecycle. */ +export function createTrustedCopilotPrincipal( + input: CreateTrustedCopilotPrincipalInput, + options: CreateTrustedCopilotPrincipalOptions +): DelegatedPrincipal { + requireNonEmpty(input.userId, 'an authenticated user ID') + requireNonEmpty(input.workspaceId, 'a workspace ID') + requireNonEmpty(input.delegationId, 'a delegation ID') + if (input.chatId !== undefined) requireNonEmpty(input.chatId, 'a valid chat ID') + if (input.executionId !== undefined) requireNonEmpty(input.executionId, 'a valid execution ID') + requireNonEmpty(options.audience, 'a delegation audience') + if (!Number.isInteger(options.ttlMs) || options.ttlMs <= 0) { + throw new Error('Copilot application delegation requires a positive integer TTL') + } + if (options.resourceScope?.fileId !== undefined) { + requireNonEmpty(options.resourceScope.fileId, 'a valid file scope') + } + if (options.resourceScope?.tableId !== undefined) { + requireNonEmpty(options.resourceScope.tableId, 'a valid table scope') + } + + const issuedAt = new Date() + const resourceScope = Object.freeze({ + ...(options.resourceScope?.fileId ? { fileId: options.resourceScope.fileId } : {}), + ...(options.resourceScope?.tableId ? { tableId: options.resourceScope.tableId } : {}), + ...(input.chatId ? { chatId: input.chatId } : {}), + ...(input.executionId ? { executionId: input.executionId } : {}), + }) + + return Object.freeze({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: input.userId, + workspaceId: input.workspaceId, + delegationId: input.delegationId, + audience: options.audience, + issuedAt, + expiresAt: new Date(issuedAt.getTime() + options.ttlMs), + resourceScope, + }) +} + +/** Creates a bounded principal from a validated Copilot tool execution context. */ +export function createCopilotApplicationPrincipal( + trustedContext: TrustedCopilotExecutionContext, + options: CreateCopilotApplicationPrincipalOptions +): DelegatedPrincipal { + const delegationId = options.createDelegationId(trustedContext) + return createTrustedCopilotPrincipal( + { + userId: trustedContext.userId, + workspaceId: trustedContext.workspaceId, + delegationId, + chatId: trustedContext.chatId, + executionId: trustedContext.executionId, + }, + options + ) +} diff --git a/apps/sim/lib/copilot/auth/file-delegation.ts b/apps/sim/lib/copilot/auth/file-delegation.ts index 8f673083aae..bda9619cddc 100644 --- a/apps/sim/lib/copilot/auth/file-delegation.ts +++ b/apps/sim/lib/copilot/auth/file-delegation.ts @@ -1,15 +1,15 @@ import type { DelegatedPrincipal } from '@sim/auth/principal' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + type CopilotExecutionContext, + createCopilotApplicationPrincipal, + createTrustedCopilotPrincipal, + requireTrustedCopilotExecutionContext, +} from '@/lib/copilot/auth/application-delegation' +import { workspaceFileDelegationPolicy } from '@/lib/workspace-files/application/authorization' -export interface CopilotFileDelegationContext { - userId: string - workspaceId?: string - chatId?: string - executionId?: string - toolCallId?: string - copilotToolExecution?: boolean -} +export type CopilotFileDelegationContext = CopilotExecutionContext export interface CopilotChatFileDelegationContext { userId: string @@ -22,32 +22,21 @@ export interface CopilotWorkspaceContextFileDelegationContext executionId?: string } +const fileDelegation = { + audience: workspaceFileDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context: Parameters<typeof createCopilotApplicationPrincipal>[0]) => + `copilot-tool:${context.toolCallId}`, +} as const + /** Normalizes a trusted Copilot tool context into the shared file principal. */ export function resolveCopilotFilePrincipal( context: CopilotFileDelegationContext | undefined, fileId?: string ): DelegatedPrincipal { - if (!context) { - throw new Error('File delegation requires a Copilot execution context') - } - if (!context.copilotToolExecution) { - throw new Error('File delegation requires a trusted Copilot execution context') - } - if (!context.toolCallId) { - throw new Error('File delegation requires a tool call ID') - } - if (!context.workspaceId) { - throw new Error('File delegation requires a workspace ID') - } - - return createWorkspaceFileDelegatedPrincipal({ - serviceId: 'copilot', - subjectUserId: context.userId, - workspaceId: context.workspaceId, - delegationId: `copilot-tool:${context.toolCallId}`, - fileId, - chatId: context.chatId, - executionId: context.executionId, + return createCopilotApplicationPrincipal(requireTrustedCopilotExecutionContext(context), { + ...fileDelegation, + resourceScope: fileId ? { fileId } : undefined, }) } @@ -55,34 +44,36 @@ export function resolveCopilotFilePrincipal( export function createCopilotChatFilePrincipal( context: CopilotChatFileDelegationContext ): DelegatedPrincipal { - return createWorkspaceFileDelegatedPrincipal({ - serviceId: 'copilot', - subjectUserId: context.userId, - workspaceId: context.workspaceId, - delegationId: `copilot-chat:${context.chatId ?? context.workspaceId}`, - chatId: context.chatId, - }) + return createTrustedCopilotPrincipal( + { + userId: context.userId, + workspaceId: context.workspaceId, + delegationId: `copilot-chat:${context.chatId ?? context.workspaceId}`, + chatId: context.chatId, + }, + fileDelegation + ) } /** Creates the principal used while materializing the Copilot workspace index. */ export function createCopilotWorkspaceContextFilePrincipal( context: CopilotWorkspaceContextFileDelegationContext ): DelegatedPrincipal { - return createWorkspaceFileDelegatedPrincipal({ - serviceId: 'copilot', - subjectUserId: context.userId, - workspaceId: context.workspaceId, - delegationId: `copilot-workspace-context:${context.chatId ?? context.executionId ?? context.workspaceId}`, - chatId: context.chatId, - executionId: context.executionId, - }) + return createTrustedCopilotPrincipal( + { + userId: context.userId, + workspaceId: context.workspaceId, + delegationId: `copilot-workspace-context:${context.chatId ?? context.executionId ?? context.workspaceId}`, + chatId: context.chatId, + executionId: context.executionId, + }, + fileDelegation + ) } export function messageForCopilotFileError( error: unknown, fallback = 'File operation failed' ): string { - const classified = asOrchestrationError(error) - if (classified && classified.code !== 'internal') return classified.message - return fallback + return messageForCopilotApplicationError(error, fallback) } diff --git a/apps/sim/lib/copilot/auth/table-delegation.ts b/apps/sim/lib/copilot/auth/table-delegation.ts index 219985dc404..83a055b99c1 100644 --- a/apps/sim/lib/copilot/auth/table-delegation.ts +++ b/apps/sim/lib/copilot/auth/table-delegation.ts @@ -1,36 +1,25 @@ import type { DelegatedPrincipal } from '@sim/auth/principal' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { createTableDelegatedPrincipal } from '@/lib/table/application/delegated-principal' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + type CopilotExecutionContext, + createCopilotApplicationPrincipal, + requireTrustedCopilotExecutionContext, +} from '@/lib/copilot/auth/application-delegation' +import { tableDelegationPolicy } from '@/lib/table/application/authorization' -export interface CopilotTableDelegationContext { - userId: string - workspaceId?: string - chatId?: string - executionId?: string - toolCallId?: string - copilotToolExecution?: boolean -} +export type CopilotTableDelegationContext = CopilotExecutionContext /** Normalizes trusted Copilot execution context into the shared table principal. */ export function resolveCopilotTablePrincipal( context: CopilotTableDelegationContext | undefined, tableId?: string ): DelegatedPrincipal { - if (!context) throw new Error('Table delegation requires a Copilot execution context') - if (!context.copilotToolExecution) { - throw new Error('Table delegation requires a trusted Copilot execution context') - } - if (!context.toolCallId) throw new Error('Table delegation requires a tool call ID') - if (!context.workspaceId) throw new Error('Table delegation requires a workspace ID') - - return createTableDelegatedPrincipal({ - serviceId: 'copilot', - subjectUserId: context.userId, - workspaceId: context.workspaceId, - delegationId: `copilot-tool:${context.toolCallId}`, - tableId, - chatId: context.chatId, - executionId: context.executionId, + return createCopilotApplicationPrincipal(requireTrustedCopilotExecutionContext(context), { + audience: tableDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (trustedContext) => `copilot-tool:${trustedContext.toolCallId}`, + resourceScope: tableId ? { tableId } : undefined, }) } @@ -38,7 +27,5 @@ export function messageForCopilotTableError( error: unknown, fallback = 'Table operation failed' ): string { - const classified = asOrchestrationError(error) - if (classified && classified.code !== 'internal') return classified.message - return fallback + return messageForCopilotApplicationError(error, fallback) } diff --git a/apps/sim/lib/copilot/auth/workspace-application-delegation.ts b/apps/sim/lib/copilot/auth/workspace-application-delegation.ts deleted file mode 100644 index d2c40aae739..00000000000 --- a/apps/sim/lib/copilot/auth/workspace-application-delegation.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' - -const COPILOT_WORKSPACE_DELEGATION_TTL_MS = 5 * 60 * 1000 - -export interface CopilotWorkspaceDelegationContext { - userId: string - workspaceId?: string - chatId?: string - executionId?: string - toolCallId?: string - copilotToolExecution?: boolean -} - -interface CreateCopilotWorkspacePrincipalOptions { - audience: string -} - -/** Creates a delegated principal exclusively from server-authored Copilot execution context. */ -export function createCopilotWorkspacePrincipal( - context: CopilotWorkspaceDelegationContext | undefined, - options: CreateCopilotWorkspacePrincipalOptions -): DelegatedPrincipal { - if (!context) throw new Error('Workspace delegation requires a Copilot execution context') - if (!context.copilotToolExecution) { - throw new Error('Workspace delegation requires a trusted Copilot execution context') - } - if (!context.toolCallId) throw new Error('Workspace delegation requires a tool call ID') - if (!context.workspaceId) throw new Error('Workspace delegation requires a workspace ID') - if (!options.audience) throw new Error('Workspace delegation requires an audience') - - const issuedAt = new Date() - return { - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: context.userId, - workspaceId: context.workspaceId, - delegationId: `copilot-tool:${context.toolCallId}`, - audience: options.audience, - issuedAt, - expiresAt: new Date(issuedAt.getTime() + COPILOT_WORKSPACE_DELEGATION_TTL_MS), - resourceScope: { - ...(context.chatId ? { chatId: context.chatId } : {}), - ...(context.executionId ? { executionId: context.executionId } : {}), - }, - } -} diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts index 3b205556cce..0f574e56112 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts @@ -2,17 +2,25 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TOOL_RESULT_UNAVAILABLE_ERROR } from '@/lib/copilot/request/tools/resolved-secret-result' -const routeExecution = vi.hoisted(() => vi.fn()) +const mocks = vi.hoisted(() => ({ + loggerError: vi.fn(), + routeExecution: vi.fn(), +})) -vi.mock('@/lib/copilot/tools/server/router', () => ({ routeExecution })) +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ error: mocks.loggerError }), +})) + +vi.mock('@/lib/copilot/tools/server/router', () => ({ routeExecution: mocks.routeExecution })) import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' describe('server tool adapter authority boundary', () => { beforeEach(() => { vi.clearAllMocks() - routeExecution.mockResolvedValue({ success: true }) + mocks.routeExecution.mockResolvedValue({ success: true }) }) it('overwrites model-supplied workspace scope and forwards trusted delegation context', async () => { @@ -30,7 +38,7 @@ describe('server tool adapter authority boundary', () => { } ) - expect(routeExecution).toHaveBeenCalledWith( + expect(mocks.routeExecution).toHaveBeenCalledWith( 'workspace_file', expect.objectContaining({ workspaceId: 'workspace-1', operation: 'rename' }), expect.objectContaining({ @@ -42,4 +50,34 @@ describe('server tool adapter authority boundary', () => { }) ) }) + + it('logs unexpected failures in full and returns only a generic system message', async () => { + const storageError = new Error('update workspace_files set secret_column = value') + mocks.routeExecution.mockRejectedValue(storageError) + + const result = await createServerToolHandler('workspace_file')( + {}, + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, + } + ) + + expect(result).toEqual({ + success: false, + error: `[workspace_file] ${TOOL_RESULT_UNAVAILABLE_ERROR}`, + }) + expect(result.error).not.toContain('workspace_files') + expect(mocks.loggerError).toHaveBeenCalledWith( + 'Server tool execution failed', + { + toolId: 'workspace_file', + abortSignalAborted: false, + }, + storageError + ) + }) }) diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts index 552ed7a7b32..76e001fe4f5 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ToolExecutionResult, ToolHandler } from '@/lib/copilot/tool-executor/types' import { routeExecution } from '@/lib/copilot/tools/server/router' @@ -42,15 +43,22 @@ export function createServerToolHandler(toolId: string): ToolHandler { } return { success: true, output: result } } catch (error) { - const message = toError(error).message - logger.error('Server tool execution failed', { - toolId, - error: projectToolErrorMessageForCopilot(message, context.resolvedSecretTraceRegistry), - abortSignalAborted: context.abortSignal?.aborted ?? false, - }) + const caughtError = toError(error) + logger.error( + 'Server tool execution failed', + { + toolId, + abortSignalAborted: context.abortSignal?.aborted ?? false, + }, + caughtError + ) + const safeMessage = projectToolErrorMessageForCopilot( + messageForCopilotApplicationError(error), + context.resolvedSecretTraceRegistry + ) return { success: false, - error: `[${toolId}] ${message}`, + error: `[${toolId}] ${safeMessage}`, } } } diff --git a/apps/sim/lib/knowledge/application/authorization.test.ts b/apps/sim/lib/knowledge/application/authorization.test.ts index 4952e6f787b..bb5ea1030d9 100644 --- a/apps/sim/lib/knowledge/application/authorization.test.ts +++ b/apps/sim/lib/knowledge/application/authorization.test.ts @@ -8,17 +8,25 @@ import { KNOWLEDGE_DELEGATION_AUDIENCE, knowledgeDelegationPolicy, } from '@/lib/knowledge/application/authorization' -import { createKnowledgeDelegatedPrincipal } from '@/lib/knowledge/application/delegated-principal' + +function createKnowledgePrincipal(overrides: Partial<DelegatedPrincipal> = {}): DelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: KNOWLEDGE_DELEGATION_AUDIENCE, + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { chatId: 'chat-1' }, + ...overrides, + } +} describe('knowledge delegation policy', () => { it('binds trusted delegation to the canonical workspace and audience', () => { - const principal = createKnowledgeDelegatedPrincipal({ - serviceId: 'copilot', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'tool-call-1', - chatId: 'chat-1', - }) + const principal = createKnowledgePrincipal() expect(principal.audience).toBe(KNOWLEDGE_DELEGATION_AUDIENCE) expect(principal.resourceScope).toEqual({ chatId: 'chat-1' }) @@ -39,16 +47,7 @@ describe('knowledge delegation policy', () => { }) it('does not accept a model-authored audience', () => { - const principal: DelegatedPrincipal = { - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'tool-call-1', - audience: 'model:chosen', - issuedAt: new Date(), - expiresAt: new Date(Date.now() + 60_000), - } + const principal = createKnowledgePrincipal({ audience: 'model:chosen' }) expect(principal.audience).not.toBe(knowledgeDelegationPolicy.audience) }) diff --git a/apps/sim/lib/knowledge/application/delegated-principal.ts b/apps/sim/lib/knowledge/application/delegated-principal.ts deleted file mode 100644 index ac0db06e992..00000000000 --- a/apps/sim/lib/knowledge/application/delegated-principal.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' -import { KNOWLEDGE_DELEGATION_AUDIENCE } from '@/lib/knowledge/application/authorization' - -const KNOWLEDGE_DELEGATION_TTL_MS = 5 * 60 * 1000 - -export interface CreateKnowledgeDelegatedPrincipalInput { - serviceId: DelegatedPrincipal['serviceId'] - subjectUserId: string - workspaceId: string - delegationId: string - chatId?: string - executionId?: string -} - -export function createKnowledgeDelegatedPrincipal( - input: CreateKnowledgeDelegatedPrincipalInput -): DelegatedPrincipal { - const issuedAt = new Date() - return { - kind: 'delegated', - serviceId: input.serviceId, - subjectUserId: input.subjectUserId, - workspaceId: input.workspaceId, - delegationId: input.delegationId, - audience: KNOWLEDGE_DELEGATION_AUDIENCE, - issuedAt, - expiresAt: new Date(issuedAt.getTime() + KNOWLEDGE_DELEGATION_TTL_MS), - resourceScope: { - ...(input.chatId ? { chatId: input.chatId } : {}), - ...(input.executionId ? { executionId: input.executionId } : {}), - }, - } -} diff --git a/apps/sim/lib/table/application/delegated-principal.ts b/apps/sim/lib/table/application/delegated-principal.ts deleted file mode 100644 index db2c3365c61..00000000000 --- a/apps/sim/lib/table/application/delegated-principal.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' -import { TABLE_DELEGATION_AUDIENCE } from '@/lib/table/application/authorization' - -const TABLE_DELEGATION_TTL_MS = 5 * 60 * 1000 - -export interface TableDelegationInput { - serviceId: DelegatedPrincipal['serviceId'] - subjectUserId: string - workspaceId: string - delegationId: string - tableId?: string - chatId?: string - executionId?: string -} - -export function createTableDelegatedPrincipal(input: TableDelegationInput): DelegatedPrincipal { - if (!input.subjectUserId || !input.workspaceId || !input.delegationId) { - throw new Error('Table delegation requires subject, workspace, and delegation IDs') - } - const issuedAt = new Date() - return { - kind: 'delegated', - serviceId: input.serviceId, - subjectUserId: input.subjectUserId, - workspaceId: input.workspaceId, - delegationId: input.delegationId, - audience: TABLE_DELEGATION_AUDIENCE, - issuedAt, - expiresAt: new Date(issuedAt.getTime() + TABLE_DELEGATION_TTL_MS), - resourceScope: { - ...(input.tableId ? { tableId: input.tableId } : {}), - ...(input.chatId ? { chatId: input.chatId } : {}), - ...(input.executionId ? { executionId: input.executionId } : {}), - }, - } -} From d69224595c8d39517e716f5b925eff8a3420f117 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sun, 9 Aug 2026 00:55:22 -0700 Subject: [PATCH 117/159] improvement(api): harden application route boundaries (#6451) * improvement(api): harden application route boundaries * fix(folders): reject creates at workspace cap --- .../app/api/audit-logs/export/route.test.ts | 2 +- apps/sim/app/api/audit-logs/export/route.ts | 12 +- apps/sim/app/api/v1/admin/audit-logs/route.ts | 2 +- .../app/api/v1/audit-logs/[id]/route.test.ts | 2 +- apps/sim/app/api/v1/audit-logs/[id]/route.ts | 2 +- apps/sim/app/api/v1/audit-logs/auth.ts | 130 +----- apps/sim/app/api/v1/audit-logs/route.test.ts | 2 +- apps/sim/app/api/v1/audit-logs/route.ts | 8 +- apps/sim/app/api/v2/audit-logs/route.test.ts | 49 +-- .../sim/app/api/v2/billing/logs/route.test.ts | 47 +- .../app/api/v2/billing/status/route.test.ts | 45 +- apps/sim/app/api/v2/credentials/route.test.ts | 49 +-- .../[id]/documents/[documentId]/route.ts | 22 +- .../api/v2/knowledge/[id]/documents/route.ts | 12 +- apps/sim/app/api/v2/knowledge/[id]/route.ts | 23 +- .../sim/app/api/v2/logs/[runId]/route.test.ts | 45 +- apps/sim/app/api/v2/logs/route.test.ts | 47 +- apps/sim/app/api/v2/workspaces/route.test.ts | 45 +- apps/sim/lib/api/contracts/v2/shared.ts | 5 +- apps/sim/lib/api/list-query.ts | 3 +- apps/sim/lib/api/server/routes/index.ts | 1 + .../api/server/routes/v2-json-route.test.ts | 401 ++++++++++++++++++ .../routes/v2-resource-concealment.test.ts | 98 +++++ .../server/routes/v2-resource-concealment.ts | 35 ++ .../application/audit-log-use-cases.test.ts | 4 +- .../authorized-audit-log-use-case.ts | 2 +- .../audit-logs/application/get-audit-log.ts | 2 +- .../audit-logs/application/list-audit-logs.ts | 6 +- apps/sim/lib/audit-logs/authorization.ts | 98 +++++ .../api/v1 => lib}/audit-logs/query.test.ts | 4 +- .../{app/api/v1 => lib}/audit-logs/query.ts | 35 +- apps/sim/lib/credentials/queries.ts | 6 +- .../folders/application-folder-caps.test.ts | 154 +++++++ apps/sim/lib/folders/cascade.test.ts | 10 +- apps/sim/lib/folders/cascade.ts | 3 +- apps/sim/lib/folders/constants.ts | 2 + apps/sim/lib/folders/errors.ts | 11 + apps/sim/lib/folders/orchestration.test.ts | 55 +++ apps/sim/lib/folders/orchestration.ts | 13 +- apps/sim/lib/folders/queries.test.ts | 21 +- apps/sim/lib/folders/queries.ts | 12 +- apps/sim/lib/knowledge/api/route-policies.ts | 11 + .../knowledge/application/contexts.test.ts | 60 +++ .../sim/lib/knowledge/application/contexts.ts | 16 +- apps/sim/lib/knowledge/constants.ts | 4 +- apps/sim/lib/knowledge/service.ts | 4 +- apps/sim/lib/secrets/application/use-cases.ts | 6 +- apps/sim/lib/table/api/route-policies.ts | 38 +- .../sim/lib/table/application/context.test.ts | 44 +- apps/sim/lib/table/application/context.ts | 16 +- .../sim/lib/table/application/folder-paths.ts | 5 +- apps/sim/lib/table/application/folders.ts | 21 +- apps/sim/lib/table/application/imports.ts | 5 +- apps/sim/lib/table/application/tables.ts | 19 +- apps/sim/lib/table/service.ts | 6 +- .../workspace/workspace-file-manager.ts | 4 +- apps/sim/lib/workflows/api/route-policies.ts | 48 +-- .../lib/workflows/application/context.test.ts | 89 ++++ apps/sim/lib/workflows/application/context.ts | 58 +-- .../application/import-export.test.ts | 5 +- .../workflows/application/import-export.ts | 16 +- .../workflows/application/list-workflows.ts | 8 +- .../workflows/application/read-workflow.ts | 8 +- .../workflows/application/update-workflow.ts | 6 +- .../application/workflow-folders.test.ts | 20 + .../workflows/application/workflow-folders.ts | 21 +- .../workflows/operations/export-workflow.ts | 43 +- apps/sim/lib/workflows/queries.ts | 3 +- .../lib/workspace-files/api/route-policies.ts | 13 +- packages/testing/src/mocks/index.ts | 9 + packages/testing/src/mocks/v2-route.mock.ts | 42 ++ 71 files changed, 1509 insertions(+), 664 deletions(-) create mode 100644 apps/sim/lib/api/server/routes/v2-json-route.test.ts create mode 100644 apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts create mode 100644 apps/sim/lib/api/server/routes/v2-resource-concealment.ts create mode 100644 apps/sim/lib/audit-logs/authorization.ts rename apps/sim/{app/api/v1 => lib}/audit-logs/query.test.ts (96%) rename apps/sim/{app/api/v1 => lib}/audit-logs/query.ts (85%) create mode 100644 apps/sim/lib/folders/application-folder-caps.test.ts create mode 100644 apps/sim/lib/folders/constants.ts create mode 100644 apps/sim/lib/folders/errors.ts create mode 100644 apps/sim/lib/knowledge/api/route-policies.ts create mode 100644 apps/sim/lib/knowledge/application/contexts.test.ts create mode 100644 apps/sim/lib/workflows/application/context.test.ts create mode 100644 packages/testing/src/mocks/v2-route.mock.ts diff --git a/apps/sim/app/api/audit-logs/export/route.test.ts b/apps/sim/app/api/audit-logs/export/route.test.ts index 367c04cb86f..6f177ab7b9f 100644 --- a/apps/sim/app/api/audit-logs/export/route.test.ts +++ b/apps/sim/app/api/audit-logs/export/route.test.ts @@ -22,7 +22,7 @@ vi.mock('@/app/api/v1/audit-logs/auth', () => ({ validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess, })) -vi.mock('@/app/api/v1/audit-logs/query', () => ({ +vi.mock('@/lib/audit-logs/query', () => ({ buildFilterConditions: mockBuildFilterConditions, buildOrgScopeCondition: mockBuildOrgScopeCondition, getOrgWorkspaceIds: mockGetOrgWorkspaceIds, diff --git a/apps/sim/app/api/audit-logs/export/route.ts b/apps/sim/app/api/audit-logs/export/route.ts index 089b1274811..e6d4a562809 100644 --- a/apps/sim/app/api/audit-logs/export/route.ts +++ b/apps/sim/app/api/audit-logs/export/route.ts @@ -3,17 +3,17 @@ import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { exportAuditLogsContract } from '@/lib/api/contracts/audit-logs' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { formatCsvValue, toCsvRow } from '@/lib/table/export-format' -import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' -import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { buildFilterConditions, buildOrgScopeCondition, getOrgWorkspaceIds, queryAuditLogs, -} from '@/app/api/v1/audit-logs/query' +} from '@/lib/audit-logs/query' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { formatCsvValue, toCsvRow } from '@/lib/table/export-format' +import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' const logger = createLogger('AuditLogsExportAPI') diff --git a/apps/sim/app/api/v1/admin/audit-logs/route.ts b/apps/sim/app/api/v1/admin/audit-logs/route.ts index f3dbc231e69..8403bed1530 100644 --- a/apps/sim/app/api/v1/admin/audit-logs/route.ts +++ b/apps/sim/app/api/v1/admin/audit-logs/route.ts @@ -24,6 +24,7 @@ import { createLogger } from '@sim/logger' import { and, count, desc } from 'drizzle-orm' import { v1AdminListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs' import { parseRequest } from '@/lib/api/server' +import { buildFilterConditions } from '@/lib/audit-logs/query' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { @@ -32,7 +33,6 @@ import { listResponse, } from '@/app/api/v1/admin/responses' import { type AdminAuditLog, createPaginationMeta, toAdminAuditLog } from '@/app/api/v1/admin/types' -import { buildFilterConditions } from '@/app/api/v1/audit-logs/query' const logger = createLogger('AdminAuditLogsAPI') diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts index 0424663f9c1..6849e05ffe6 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts @@ -28,7 +28,7 @@ vi.mock('@/app/api/v1/audit-logs/auth', () => ({ validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess, })) -vi.mock('@/app/api/v1/audit-logs/query', () => ({ +vi.mock('@/lib/audit-logs/query', () => ({ buildOrgScopeCondition: mockBuildOrgScopeCondition, getOrgWorkspaceIds: mockGetOrgWorkspaceIds, })) diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.ts index bac0b83f160..965d619cee8 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.ts @@ -20,10 +20,10 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v1GetAuditLogContract } from '@/lib/api/contracts/v1/audit-logs' import { parseRequest } from '@/lib/api/server' +import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/lib/audit-logs/query' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' -import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts index 7076d5ec7d0..739e1c39918 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -1,135 +1,13 @@ -/** - * Enterprise audit log authorization. - * - * Validates that the authenticated user is an admin/owner of an enterprise organization - * and returns the organization context needed for scoped queries. - */ - -import { db } from '@sim/db' -import { member, subscription } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' import { NextResponse } from 'next/server' -import { isOrganizationBillingBlocked } from '@/lib/billing/core/access' -import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' -import { isAuditLogsEnabled, isBillingEnabled } from '@/lib/core/config/env-flags' - -const logger = createLogger('V1AuditLogsAuth') - -interface EnterpriseAuditContext { - organizationId: string - orgMemberIds: string[] -} +import { + type EnterpriseAuditContext, + resolveEnterpriseAuditAccess, +} from '@/lib/audit-logs/authorization' type AuthResult = | { success: true; context: EnterpriseAuditContext } | { success: false; response: NextResponse } -/** - * Structured enterprise audit-access result shared by the v1 and v2 surfaces so - * each version can render the failure in its own response envelope. - */ -export type EnterpriseAuditAccessResult = - | { success: true; context: EnterpriseAuditContext } - | { success: false; status: number; message: string } - -/** - * Core enterprise audit-access check (no response rendering). - * - * Checks: - * 1. User belongs to an organization (the target one when - * `targetOrganizationId` is given) - * 2. User has admin or owner role - * 3. The organization is entitled to audit logs — an active enterprise - * subscription when billing runs, otherwise the deployment's audit-logs - * entitlement - * - * The subscription query is skipped entirely with billing off. Requiring it - * there made audit logs unreachable on every self-hosted deployment, since no - * subscription row is ever written without billing. - * - * Returns the organization ID and all member user IDs on success. - */ -export async function resolveEnterpriseAuditAccess( - userId: string, - targetOrganizationId?: string -): Promise<EnterpriseAuditAccessResult> { - const [membership] = await db - .select({ organizationId: member.organizationId, role: member.role }) - .from(member) - .where( - targetOrganizationId - ? and(eq(member.userId, userId), eq(member.organizationId, targetOrganizationId)) - : eq(member.userId, userId) - ) - .limit(1) - - if (!membership) { - return { - success: false, - status: 403, - message: targetOrganizationId - ? 'Not a member of the requested organization' - : 'Not a member of any organization', - } - } - - if (membership.role !== 'admin' && membership.role !== 'owner') { - return { success: false, status: 403, message: 'Organization admin or owner role required' } - } - - if (isBillingEnabled) { - const billingBlocked = await isOrganizationBillingBlocked(membership.organizationId) - if (billingBlocked) { - return { success: false, status: 403, message: 'Active enterprise subscription required' } - } - } else if (!isAuditLogsEnabled) { - return { - success: false, - status: 403, - message: - 'Audit logs are disabled. Set ENTERPRISE_ENABLED or AUDIT_LOGS_ENABLED to enable them.', - } - } - - const [orgSub, orgMembers] = await Promise.all([ - isBillingEnabled - ? db - .select({ id: subscription.id }) - .from(subscription) - .where( - and( - eq(subscription.referenceId, membership.organizationId), - eq(subscription.plan, 'enterprise'), - inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) - ) - ) - .limit(1) - : Promise.resolve([]), - db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, membership.organizationId)), - ]) - - if (isBillingEnabled && orgSub.length === 0) { - return { success: false, status: 403, message: 'Active enterprise subscription required' } - } - - const orgMemberIds = orgMembers.map((m) => m.userId) - - logger.info('Enterprise audit access validated', { - userId, - organizationId: membership.organizationId, - memberCount: orgMemberIds.length, - }) - - return { - success: true, - context: { organizationId: membership.organizationId, orgMemberIds }, - } -} - /** * v1 wrapper: renders {@link resolveEnterpriseAuditAccess} as the v1 `{ error }` * response body. diff --git a/apps/sim/app/api/v1/audit-logs/route.test.ts b/apps/sim/app/api/v1/audit-logs/route.test.ts index 9fa39f447eb..2644d07132f 100644 --- a/apps/sim/app/api/v1/audit-logs/route.test.ts +++ b/apps/sim/app/api/v1/audit-logs/route.test.ts @@ -34,7 +34,7 @@ vi.mock('@/app/api/v1/audit-logs/auth', () => ({ validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess, })) -vi.mock('@/app/api/v1/audit-logs/query', () => ({ +vi.mock('@/lib/audit-logs/query', () => ({ buildFilterConditions: mockBuildFilterConditions, buildOrgScopeCondition: mockBuildOrgScopeCondition, getOrgWorkspaceIds: mockGetOrgWorkspaceIds, diff --git a/apps/sim/app/api/v1/audit-logs/route.ts b/apps/sim/app/api/v1/audit-logs/route.ts index c6eca39ffe2..36c12d1019d 100644 --- a/apps/sim/app/api/v1/audit-logs/route.ts +++ b/apps/sim/app/api/v1/audit-logs/route.ts @@ -25,15 +25,15 @@ import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { v1ListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs' import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' -import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { buildFilterConditions, buildOrgScopeCondition, getOrgWorkspaceIds, queryAuditLogs, -} from '@/app/api/v1/audit-logs/query' +} from '@/lib/audit-logs/query' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, 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 a8c984556ce..ec46e6fe9d6 100644 --- a/apps/sim/app/api/v2/audit-logs/route.test.ts +++ b/apps/sim/app/api/v2/audit-logs/route.test.ts @@ -1,32 +1,25 @@ /** * @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - checkPreauth: vi.fn(), - checkOperationRate: vi.fn(), - gate: vi.fn(), list: vi.fn(), get: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -vi.mock('@/lib/core/rate-limiter', () => ({ - getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), - RateLimiter: class RateLimiter { - checkRateLimitDirect = mocks.checkPreauth - checkRateLimitDirectOrThrow = mocks.checkOperationRate - }, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/audit-logs/application/list-audit-logs', () => ({ listAuditLogs: { operation: { id: 'audit_logs.list' }, execute: mocks.list }, @@ -67,18 +60,10 @@ const log = { describe('v2 audit-log routes', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.gate.mockResolvedValue(null) - mocks.checkPreauth.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-08-01T01:00:00Z'), - }) - mocks.checkOperationRate.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-08-01T01:00:00Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.list.mockResolvedValue({ data: [log], nextCursor: 'next-1' }) mocks.get.mockResolvedValue({ log }) }) @@ -87,8 +72,8 @@ describe('v2 audit-log routes', () => { const response = await listLogs(new NextRequest('http://localhost:3000/api/v2/audit-logs')) expect(response.status).toBe(400) - expect(mocks.authenticate).toHaveBeenCalled() - expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2) + expect(v2RouteMocks.authenticate).toHaveBeenCalled() + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) expect(mocks.list).not.toHaveBeenCalled() }) 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 95d0f017a58..0c5e5f79387 100644 --- a/apps/sim/app/api/v2/billing/logs/route.test.ts +++ b/apps/sim/app/api/v2/billing/logs/route.test.ts @@ -1,31 +1,24 @@ /** * @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - checkPreauth: vi.fn(), - checkOperationRate: vi.fn(), - gate: vi.fn(), execute: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -vi.mock('@/lib/core/rate-limiter', () => ({ - getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), - RateLimiter: class RateLimiter { - checkRateLimitDirect = mocks.checkPreauth - checkRateLimitDirectOrThrow = mocks.checkOperationRate - }, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/billing/application/list-billing-logs', () => ({ listBillingLogs: { operation: { id: 'billing.logs.list' }, execute: mocks.execute }, @@ -46,18 +39,10 @@ describe('GET /api/v2/billing/logs', () => { vi.clearAllMocks() vi.useFakeTimers() vi.setSystemTime(new Date('2026-08-01T00:00:00Z')) - mocks.authenticate.mockResolvedValue(auth) - mocks.gate.mockResolvedValue(null) - mocks.checkPreauth.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-08-01T01:00:00Z'), - }) - mocks.checkOperationRate.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-08-01T01:00:00Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.execute.mockResolvedValue({ usage: { logs: [ @@ -116,7 +101,7 @@ describe('GET /api/v2/billing/logs', () => { ) expect(response.status).toBe(400) - expect(mocks.authenticate).toHaveBeenCalled() + expect(v2RouteMocks.authenticate).toHaveBeenCalled() expect(mocks.execute).not.toHaveBeenCalled() }) }) 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 6c87206289f..30e6f21b74e 100644 --- a/apps/sim/app/api/v2/billing/status/route.test.ts +++ b/apps/sim/app/api/v2/billing/status/route.test.ts @@ -1,31 +1,24 @@ /** * @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - checkPreauth: vi.fn(), - checkOperationRate: vi.fn(), - gate: vi.fn(), execute: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -vi.mock('@/lib/core/rate-limiter', () => ({ - getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), - RateLimiter: class RateLimiter { - checkRateLimitDirect = mocks.checkPreauth - checkRateLimitDirectOrThrow = mocks.checkOperationRate - }, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/billing/application/get-billing-status', () => ({ getBillingStatus: { operation: { id: 'billing.status.read' }, execute: mocks.execute }, @@ -52,18 +45,10 @@ const result = { describe('GET /api/v2/billing/status', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.gate.mockResolvedValue(null) - mocks.checkPreauth.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-08-01T01:00:00Z'), - }) - mocks.checkOperationRate.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-08-01T01:00:00Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.execute.mockResolvedValue(result) }) diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index 4a4badb6b0b..a1987f1b5af 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -1,31 +1,24 @@ /** * @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - checkPreauth: vi.fn(), - checkOperationRate: vi.fn(), - gate: vi.fn(), execute: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -vi.mock('@/lib/core/rate-limiter', () => ({ - getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), - RateLimiter: class RateLimiter { - checkRateLimitDirect = mocks.checkPreauth - checkRateLimitDirectOrThrow = mocks.checkOperationRate - }, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/credentials/application/list-workspace-credentials', () => ({ listWorkspaceCredentials: { @@ -68,18 +61,10 @@ const credential = { describe('GET /api/v2/credentials', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.gate.mockResolvedValue(null) - mocks.checkPreauth.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-01-01T01:00:00Z'), - }) - mocks.checkOperationRate.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.execute.mockResolvedValue({ credentials: [credential] }) }) @@ -87,8 +72,8 @@ describe('GET /api/v2/credentials', () => { const response = await GET(new NextRequest('http://localhost:3000/api/v2/credentials')) expect(response.status).toBe(400) - expect(mocks.authenticate).toHaveBeenCalled() - expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2) + expect(v2RouteMocks.authenticate).toHaveBeenCalled() + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) expect(mocks.execute).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts index f12d79ddd31..2a0faf317f5 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -2,13 +2,8 @@ import { v2DeleteKnowledgeDocumentContract, v2GetKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' -import { - defineV2JsonRoute, - type V2ErrorPolicy, - v2ApiKeyAuth, - v2OrchestrationErrorPolicy, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { deleteKnowledgeDocument, readKnowledgeDocument, @@ -16,7 +11,6 @@ import { import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { captureServerEvent } from '@/lib/posthog/server' import { serializeDate } from '@/app/api/v1/knowledge/utils' -import { v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -33,21 +27,13 @@ function toProcessingStatus(status: string): 'pending' | 'processing' | 'complet } } -const concealKnowledgeDocumentReadAuthorization = { - render(error) { - const response = v2OrchestrationErrorPolicy.render(error) - if (response?.status === 403) return v2Error('NOT_FOUND', 'Knowledge base not found') - return response - }, -} satisfies V2ErrorPolicy - /** GET /api/v2/knowledge/[id]/documents/[documentId] — Get document details. */ export const GET = defineV2JsonRoute({ contract: v2GetKnowledgeDocumentContract, auth: v2ApiKeyAuth, operation: knowledgeOperations.readDocument, rateLimit: v2RateLimits.publicApi, - errorPolicy: concealKnowledgeDocumentReadAuthorization, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => ({ knowledgeBaseId: params.id, documentId: params.documentId, @@ -85,7 +71,7 @@ export const DELETE = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.deleteDocument, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: v2KnowledgeErrorPolicies.default, mapInput: ({ params, query }) => ({ knowledgeBaseId: params.id, documentId: params.documentId, 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 5acd58ac0c5..539ffd677a1 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -8,7 +8,6 @@ import { import { parseRequest } from '@/lib/api/server' import { defineV2JsonRoute, - type V2ErrorPolicy, v2ApiKeyAuth, v2OrchestrationErrorPolicy, v2RateLimits, @@ -23,6 +22,7 @@ import { readFormDataWithLimit, } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' import { admitKnowledgeDocumentUpload, @@ -43,14 +43,6 @@ export const revalidate = 0 const MAX_FILE_SIZE = MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 -const concealKnowledgeDocumentListAuthorization = { - render(error) { - const response = v2OrchestrationErrorPolicy.render(error) - if (response?.status === 403) return v2Error('NOT_FOUND', 'Knowledge base not found') - return response - }, -} satisfies V2ErrorPolicy - function toV2DocumentSummary(document: { id: string knowledgeBaseId: string @@ -85,7 +77,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.listDocuments, rateLimit: v2RateLimits.publicApi, - errorPolicy: concealKnowledgeDocumentListAuthorization, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => { const decodedCursor = query.cursor ? decodeCursor<{ offset: number }>(query.cursor) : null if ( diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts index a92d1b5fa85..aca1150ddc2 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -3,14 +3,9 @@ import { v2GetKnowledgeBaseContract, v2UpdateKnowledgeBaseContract, } from '@/lib/api/contracts/v2/knowledge' -import { - defineV2JsonRoute, - type V2ErrorPolicy, - v2ApiKeyAuth, - v2OrchestrationErrorPolicy, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { PlatformEvents } from '@/lib/core/telemetry' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { deleteKnowledgeBaseOperation, readKnowledgeBase, @@ -53,21 +48,13 @@ function toV2KnowledgeBase(knowledgeBase: KnowledgeBaseWithCounts, folderPath: s } } -const concealKnowledgeBaseReadAuthorization = { - render(error) { - const response = v2OrchestrationErrorPolicy.render(error) - if (response?.status === 403) return v2Error('NOT_FOUND', 'Knowledge base not found') - return response - }, -} satisfies V2ErrorPolicy - /** GET /api/v2/knowledge/[id] — Get knowledge base details. */ export const GET = defineV2JsonRoute({ contract: v2GetKnowledgeBaseContract, auth: v2ApiKeyAuth, operation: knowledgeOperations.read, rateLimit: v2RateLimits.publicApi, - errorPolicy: concealKnowledgeBaseReadAuthorization, + errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, mapInput: ({ params, query }) => ({ knowledgeBaseId: params.id, assertedWorkspaceId: query.workspaceId, @@ -84,7 +71,7 @@ export const PUT = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.update, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: v2KnowledgeErrorPolicies.default, parseOptions: { invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), }, @@ -109,7 +96,7 @@ export const DELETE = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: knowledgeOperations.delete, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: v2KnowledgeErrorPolicies.default, mapInput: ({ params, query }) => ({ knowledgeBaseId: params.id, assertedWorkspaceId: query.workspaceId, diff --git a/apps/sim/app/api/v2/logs/[runId]/route.test.ts b/apps/sim/app/api/v2/logs/[runId]/route.test.ts index b538e07d63c..907a1b00e1a 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.test.ts @@ -1,31 +1,24 @@ /** * @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - checkPreauth: vi.fn(), - checkOperationRate: vi.fn(), - gate: vi.fn(), execute: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -vi.mock('@/lib/core/rate-limiter', () => ({ - getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), - RateLimiter: class RateLimiter { - checkRateLimitDirect = mocks.checkPreauth - checkRateLimitDirectOrThrow = mocks.checkOperationRate - }, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/logs/application/get-public-log', () => ({ getPublicLog: { operation: { id: 'logs.read_detail' }, execute: mocks.execute }, @@ -71,18 +64,10 @@ const log = { describe('GET /api/v2/logs/[runId]', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.gate.mockResolvedValue(null) - mocks.checkPreauth.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-08-06T01:00:00Z'), - }) - mocks.checkOperationRate.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-08-06T01:00:00Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.execute.mockResolvedValue({ log, workflowFolderPath: '/agents', diff --git a/apps/sim/app/api/v2/logs/route.test.ts b/apps/sim/app/api/v2/logs/route.test.ts index fb7c5013984..069bb3ed605 100644 --- a/apps/sim/app/api/v2/logs/route.test.ts +++ b/apps/sim/app/api/v2/logs/route.test.ts @@ -1,31 +1,24 @@ /** * @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - checkPreauth: vi.fn(), - checkOperationRate: vi.fn(), - gate: vi.fn(), execute: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -vi.mock('@/lib/core/rate-limiter', () => ({ - getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), - RateLimiter: class RateLimiter { - checkRateLimitDirect = mocks.checkPreauth - checkRateLimitDirectOrThrow = mocks.checkOperationRate - }, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/logs/application/list-public-logs', () => ({ listPublicLogs: { operation: { id: 'logs.list' }, execute: mocks.execute }, @@ -67,18 +60,10 @@ const log = { describe('GET /api/v2/logs', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.gate.mockResolvedValue(null) - mocks.checkPreauth.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-08-06T01:00:00Z'), - }) - mocks.checkOperationRate.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-08-06T01:00:00Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.execute.mockResolvedValue({ items: [{ log, executionData: { finalOutput: false, traceSpans: [] } }], nextCursor: null, @@ -121,7 +106,7 @@ describe('GET /api/v2/logs', () => { ) expect(response.status).toBe(400) - expect(mocks.authenticate).toHaveBeenCalled() + expect(v2RouteMocks.authenticate).toHaveBeenCalled() expect(mocks.execute).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/v2/workspaces/route.test.ts b/apps/sim/app/api/v2/workspaces/route.test.ts index bde0ffa3827..d8f6fae0513 100644 --- a/apps/sim/app/api/v2/workspaces/route.test.ts +++ b/apps/sim/app/api/v2/workspaces/route.test.ts @@ -1,32 +1,25 @@ /** * @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - checkPreauth: vi.fn(), - checkOperationRate: vi.fn(), - gate: vi.fn(), getWorkspace: vi.fn(), listMembers: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -vi.mock('@/lib/core/rate-limiter', () => ({ - getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), - RateLimiter: class RateLimiter { - checkRateLimitDirect = mocks.checkPreauth - checkRateLimitDirectOrThrow = mocks.checkOperationRate - }, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +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) vi.mock('@/lib/workspaces/application/get-public-workspace', () => ({ getPublicWorkspace: { @@ -63,18 +56,10 @@ const context = () => ({ params: Promise.resolve({ workspaceId: WORKSPACE_ID }) describe('v2 workspace routes', () => { beforeEach(() => { vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.gate.mockResolvedValue(null) - mocks.checkPreauth.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-08-06T01:00:00Z'), - }) - mocks.checkOperationRate.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-08-06T01:00:00Z'), - }) + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.getWorkspace.mockResolvedValue({ workspace: { id: WORKSPACE_ID, diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 653ef97c0bc..6048c8bab13 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { LIST_SORT_ORDERS, type ListSortOrder } from '@/lib/api/list-query' import { FolderPathError, parseFolderPath, requireNonRootFolderPath } from '@/lib/folders/paths' /** @@ -92,9 +93,9 @@ export const v2SearchSchema = z .max(200, 'search is too long') .optional() -export const v2SortOrderSchema = z.enum(['asc', 'desc']) +export const v2SortOrderSchema = z.enum(LIST_SORT_ORDERS) -export type V2SortOrder = z.output<typeof v2SortOrderSchema> +export type V2SortOrder = ListSortOrder function canonicalFolderPathSchema(parser: (path: string) => string[]) { return z.string().superRefine((path, ctx) => { diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts index eb0d450705d..6607ecde36a 100644 --- a/apps/sim/lib/api/list-query.ts +++ b/apps/sim/lib/api/list-query.ts @@ -13,7 +13,8 @@ import { sql, } from 'drizzle-orm' -export type ListSortOrder = 'asc' | 'desc' +export const LIST_SORT_ORDERS = ['asc', 'desc'] as const +export type ListSortOrder = (typeof LIST_SORT_ORDERS)[number] /** * Runtime half of the v2 list convention declared in diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts index 408ecff04d0..22c753ca9a2 100644 --- a/apps/sim/lib/api/server/routes/index.ts +++ b/apps/sim/lib/api/server/routes/index.ts @@ -23,3 +23,4 @@ export { v2OrchestrationErrorPolicy, v2RateLimits, } from '@/lib/api/server/routes/v2-json-route' +export { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes/v2-resource-concealment' 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 new file mode 100644 index 00000000000..ea4f0ba6710 --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-json-route.test.ts @@ -0,0 +1,401 @@ +/** + * @vitest-environment node + */ +import type { PersonalApiKeyPrincipal } from '@sim/auth/principal' +import { + MockV2ApiKeyUnauthenticatedError, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import type { ParsedRequest } from '@/lib/api/server/validation' +import type { OperationUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +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 type { V2ApiKeyAuthContext } from '@/lib/api/server/routes/v2-api-key-auth' +import { + defineV2JsonRoute, + type V2ErrorPolicy, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes/v2-json-route' + +const operation = { id: 'widgets.update' } 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: 'POST', + path: '/api/v2/widgets', + body: z.object({ value: z.string() }).strict(), + response: { + mode: 'json', + status: 201, + schema: z.object({ data: z.object({ value: z.string() }) }), + }, +}) + +interface Input { + value: string +} + +interface Result { + value: string +} + +type Execute = OperationUseCase<typeof operation, Input, Result>['execute'] + +interface HandlerOverrides { + beforeParse?: (args: { + request: NextRequest + principal: PersonalApiKeyPrincipal + params: Record<string, string | string[] | undefined> + }) => void | Promise<void> + errorPolicy?: V2ErrorPolicy + execute?: Execute + mapInput?: (input: ParsedRequest<typeof contract>) => Input + onSuccess?: (args: { + principal: PersonalApiKeyPrincipal + input: Input + result: Result + }) => void | Promise<void> + present?: (result: Result) => { data: { value: string } } | Promise<{ data: { value: string } }> + statusForResult?: (result: Result) => number +} + +function createHandler(overrides: HandlerOverrides = {}) { + const useCase: OperationUseCase<typeof operation, Input, Result> = { + operation, + execute: + overrides.execute ?? + (async ({ input }) => ({ + value: input.value, + })), + } + return defineV2JsonRoute({ + contract, + auth: v2ApiKeyAuth, + operation, + rateLimit: v2RateLimits.publicApi, + errorPolicy: overrides.errorPolicy ?? v2OrchestrationErrorPolicy, + beforeParse: overrides.beforeParse, + mapInput: overrides.mapInput ?? (({ body }) => body), + useCase, + present: overrides.present ?? ((result) => ({ data: result })), + onSuccess: overrides.onSuccess, + statusForResult: overrides.statusForResult, + }) +} + +function request(body: unknown = { value: 'ok' }): NextRequest { + return new NextRequest('http://localhost/api/v2/widgets', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), + }) +} + +describe('defineV2JsonRoute', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue({ allowed: true, remaining: 599, resetAt }) + v2RouteMocks.operationRate.mockResolvedValue(allowedRate) + }) + + it('runs admission, parsing, use case, presentation, and success effects in order', async () => { + const events: string[] = [] + v2RouteMocks.preauthRate.mockImplementation(async () => { + events.push('ip-limit') + return { allowed: true, remaining: 599, resetAt } + }) + v2RouteMocks.authenticate.mockImplementation(async () => { + events.push('authenticate') + return { ...auth, rateLimitSubjectIds: ['api-key:key-1'] as const } + }) + v2RouteMocks.gate.mockImplementation(async () => { + events.push('rollout') + return null + }) + v2RouteMocks.operationRate.mockImplementation(async () => { + events.push('operation-limit') + return allowedRate + }) + + const handler = createHandler({ + beforeParse: () => { + events.push('before-parse') + }, + mapInput: ({ body }) => { + events.push('parse-and-map') + return body + }, + execute: async ({ input }) => { + events.push('use-case') + return input + }, + present: (result) => { + events.push('presentation') + return { data: result } + }, + onSuccess: () => { + events.push('on-success') + }, + }) + + const response = await handler(request()) + + expect(response.status).toBe(201) + expect(events).toEqual([ + 'ip-limit', + 'authenticate', + 'rollout', + 'operation-limit', + 'before-parse', + 'parse-and-map', + 'use-case', + 'presentation', + 'on-success', + ]) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + }) + + it('fails closed before authentication when the IP bucket cannot admit the request', async () => { + v2RouteMocks.preauthRate.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt, + retryAfterMs: 60_000, + }) + + const response = await createHandler()(request()) + + expect(response.status).toBe(429) + expect(v2RouteMocks.preauthRate).toHaveBeenCalledWith( + expect.stringMatching(/^v2:preauth:ip:/), + expect.objectContaining({ maxTokens: 600 }), + { failClosed: true } + ) + expect(v2RouteMocks.authenticate).not.toHaveBeenCalled() + expect(v2RouteMocks.gate).not.toHaveBeenCalled() + expect(v2RouteMocks.operationRate).not.toHaveBeenCalled() + }) + + it('renders invalid credentials as 401 without continuing admission', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce( + new MockV2ApiKeyUnauthenticatedError('API key required') + ) + + const response = await createHandler()(request()) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ + error: { code: 'UNAUTHORIZED', message: 'API key required' }, + }) + expect(v2RouteMocks.gate).not.toHaveBeenCalled() + expect(v2RouteMocks.operationRate).not.toHaveBeenCalled() + }) + + it('short-circuits parsing and operation rate limiting when rollout denies admission', async () => { + const mapInput = vi.fn<(input: ParsedRequest<typeof contract>) => Input>() + const execute = vi.fn<Execute>() + v2RouteMocks.gate.mockResolvedValueOnce( + NextResponse.json({ error: { code: 'NOT_FOUND', message: 'Not found' } }, { status: 404 }) + ) + + const response = await createHandler({ mapInput, execute })(request()) + + expect(response.status).toBe(404) + expect(v2RouteMocks.operationRate).not.toHaveBeenCalled() + expect(mapInput).not.toHaveBeenCalled() + expect(execute).not.toHaveBeenCalled() + }) + + it.each([ + { + stage: 'authentication', + fail: () => + v2RouteMocks.authenticate.mockRejectedValueOnce(new Error('auth store unavailable')), + }, + { + stage: 'rollout gate', + fail: () => v2RouteMocks.gate.mockRejectedValueOnce(new Error('gate store unavailable')), + }, + { + stage: 'operation rate limit', + fail: () => v2RouteMocks.operationRate.mockRejectedValue(new Error('rate store unavailable')), + }, + ])('maps $stage infrastructure failure to 503', async ({ fail }) => { + fail() + + const response = await createHandler()(request()) + + expect(response.status).toBe(503) + await expect(response.json()).resolves.toEqual({ + error: { + code: 'SERVICE_UNAVAILABLE', + message: 'Service temporarily unavailable', + }, + }) + }) + + it('enforces every rate subject and publishes the most restrictive allowed bucket', async () => { + const restrictiveReset = new Date('2026-08-08T21:00:00.000Z') + v2RouteMocks.operationRate.mockImplementation(async (key: string) => + key.endsWith('user:user-1') + ? { allowed: true, remaining: 12, resetAt: restrictiveReset } + : { allowed: true, remaining: 80, resetAt } + ) + + const response = await createHandler()(request()) + + expect(response.status).toBe(201) + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) + expect(v2RouteMocks.operationRate).toHaveBeenCalledWith( + 'v2:widgets.update:api-key:key-1', + expect.objectContaining({ maxTokens: 100 }) + ) + expect(v2RouteMocks.operationRate).toHaveBeenCalledWith( + 'v2:widgets.update:user:user-1', + expect.objectContaining({ maxTokens: 100 }) + ) + expect(response.headers.get('X-RateLimit-Limit')).toBe('100') + expect(response.headers.get('X-RateLimit-Remaining')).toBe('12') + expect(response.headers.get('X-RateLimit-Reset')).toBe(restrictiveReset.toISOString()) + }) + + it('rejects when any rate subject is denied, regardless of other bucket capacity', async () => { + const mapInput = vi.fn<(input: ParsedRequest<typeof contract>) => Input>() + const execute = vi.fn<Execute>() + v2RouteMocks.operationRate.mockImplementation(async (key: string) => + key.endsWith('user:user-1') + ? { allowed: false, remaining: 0, resetAt, retryAfterMs: 30_000 } + : { allowed: true, remaining: 99, resetAt } + ) + + const response = await createHandler({ mapInput, execute })(request()) + + expect(response.status).toBe(429) + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) + expect(response.headers.get('X-RateLimit-Remaining')).toBe('0') + expect(mapInput).not.toHaveBeenCalled() + expect(execute).not.toHaveBeenCalled() + }) + + it('short-circuits parsing when beforeParse rejects the admitted principal', async () => { + const mapInput = vi.fn<(input: ParsedRequest<typeof contract>) => Input>() + const execute = vi.fn<Execute>() + const response = await createHandler({ + beforeParse: () => { + throw new OrchestrationError('forbidden', 'Header policy denied') + }, + mapInput, + execute, + })(request()) + + expect(response.status).toBe(403) + expect(mapInput).not.toHaveBeenCalled() + expect(execute).not.toHaveBeenCalled() + expect(response.headers.get('X-RateLimit-Remaining')).toBe('99') + }) + + it('authenticates, gates, and rate-limits before parse rejection, then stops', async () => { + const execute = vi.fn<Execute>() + const present = vi.fn<(result: Result) => { data: { value: string } }>() + const onSuccess = vi.fn() + const mapInput = vi.fn<(input: ParsedRequest<typeof contract>) => Input>() + const response = await createHandler({ execute, present, onSuccess, mapInput })(request({})) + + expect(response.status).toBe(400) + expect(v2RouteMocks.authenticate).toHaveBeenCalledOnce() + expect(v2RouteMocks.gate).toHaveBeenCalledOnce() + expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) + expect(mapInput).not.toHaveBeenCalled() + expect(execute).not.toHaveBeenCalled() + expect(present).not.toHaveBeenCalled() + expect(onSuccess).not.toHaveBeenCalled() + expect(response.headers.get('X-RateLimit-Remaining')).toBe('99') + }) + + it('short-circuits presentation and onSuccess after a typed use-case failure', async () => { + const present = vi.fn<(result: Result) => { data: { value: string } }>() + const onSuccess = vi.fn() + const response = await createHandler({ + execute: async () => { + throw new OrchestrationError('conflict', 'Already exists') + }, + present, + onSuccess, + })(request()) + + expect(response.status).toBe(409) + expect(present).not.toHaveBeenCalled() + expect(onSuccess).not.toHaveBeenCalled() + expect(response.headers.get('X-RateLimit-Remaining')).toBe('99') + }) + + it('validates the presented response before onSuccess', async () => { + const onSuccess = vi.fn() + const response = await createHandler({ + present: () => + ({ data: { value: 42 } }) as unknown as { + data: { value: string } + }, + onSuccess, + })(request()) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) + expect(onSuccess).not.toHaveBeenCalled() + }) + + it('turns an onSuccess failure into an error response after successful presentation', async () => { + const present = vi.fn((result: Result) => ({ data: result })) + const response = await createHandler({ + present, + onSuccess: () => { + throw new OrchestrationError('conflict', 'Success projection failed') + }, + })(request()) + + expect(present).toHaveBeenCalledOnce() + expect(response.status).toBe(409) + await expect(response.json()).resolves.toEqual({ + error: { code: 'CONFLICT', message: 'Success projection failed' }, + }) + }) + + it('fails fast on an invalid dynamic success status', async () => { + const response = await createHandler({ statusForResult: () => 400 })(request()) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) + }) +}) diff --git a/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts b/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts new file mode 100644 index 00000000000..8224d495182 --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts @@ -0,0 +1,98 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { V2ErrorPolicy } from '@/lib/api/server/routes' +import { + DelegatedWorkspaceAuthorizationError, + InsufficientWorkspacePermissionsError, + PersonalApiKeysDisabledError, + PrincipalKindAuthorizationError, + WorkspaceApiKeyAuthorizationError, +} from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { v2TableErrorPolicies } from '@/lib/table/api/route-policies' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api/route-policies' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api/route-policies' + +const policies: Array<{ + domain: string + policy: V2ErrorPolicy + notFoundMessage: string +}> = [ + { + domain: 'file', + policy: v2FileErrorPolicies.concealResourceAuthorization, + notFoundMessage: 'File not found', + }, + { + domain: 'workflow', + policy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + notFoundMessage: 'Workflow not found', + }, + { + domain: 'workflow run', + policy: v2WorkflowErrorPolicies.concealRunAuthorization, + notFoundMessage: 'Run not found', + }, + { + domain: 'table', + policy: v2TableErrorPolicies.concealTableAuthorization, + notFoundMessage: 'Table not found', + }, + { + domain: 'table import', + policy: v2TableErrorPolicies.concealImportAuthorization, + notFoundMessage: 'Table import not found', + }, + { + domain: 'table export', + policy: v2TableErrorPolicies.concealExportAuthorization, + notFoundMessage: 'Table export not found', + }, + { + domain: 'knowledge base', + policy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, + notFoundMessage: 'Knowledge base not found', + }, +] + +const resourceAuthorizationErrors = [ + new InsufficientWorkspacePermissionsError(), + new WorkspaceApiKeyAuthorizationError(), + new DelegatedWorkspaceAuthorizationError(), + new PrincipalKindAuthorizationError('workspace_api_key', 'resources.read'), +] + +describe.each(policies)('$domain resource concealment', ({ policy, notFoundMessage }) => { + it.each(resourceAuthorizationErrors)( + 'conceals typed resource authorization: %s', + async (error) => { + const response = policy.render(error) + expect(response?.status).toBe(404) + await expect(response?.json()).resolves.toEqual({ + error: { code: 'NOT_FOUND', message: notFoundMessage }, + }) + } + ) + + it('preserves workspace personal-key policy denial as forbidden', async () => { + const response = policy.render(new PersonalApiKeysDisabledError()) + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Personal API keys are not allowed for this workspace', + }, + }) + }) + + it('preserves unrelated forbidden business failures', async () => { + const response = policy.render(new OrchestrationError('forbidden', 'Business rule denied')) + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toEqual({ + error: { code: 'FORBIDDEN', message: 'Business rule denied' }, + }) + }) +}) diff --git a/apps/sim/lib/api/server/routes/v2-resource-concealment.ts b/apps/sim/lib/api/server/routes/v2-resource-concealment.ts new file mode 100644 index 00000000000..6df54519f19 --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-resource-concealment.ts @@ -0,0 +1,35 @@ +import type { V2ErrorPolicy } from '@/lib/api/server/routes/v2-json-route' +import { + DelegatedWorkspaceAuthorizationError, + InsufficientWorkspacePermissionsError, + PrincipalKindAuthorizationError, + WorkspaceApiKeyAuthorizationError, +} from '@/lib/core/application' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +type V2ErrorRenderer = V2ErrorPolicy['render'] + +function isResourceAuthorizationError(error: unknown): boolean { + return ( + error instanceof DelegatedWorkspaceAuthorizationError || + error instanceof InsufficientWorkspacePermissionsError || + error instanceof PrincipalKindAuthorizationError || + error instanceof WorkspaceApiKeyAuthorizationError + ) +} + +/** Conceals only typed resource-authorization failures without hiding workspace policy denials. */ +export function createV2ResourceConcealmentPolicy(options: { + notFoundMessage: string + render?: V2ErrorRenderer +}): V2ErrorPolicy { + const render = options.render ?? v2CaughtOrchestrationError + return { + render(error) { + if (isResourceAuthorizationError(error)) { + return v2Error('NOT_FOUND', options.notFoundMessage) + } + return render(error) + }, + } +} diff --git a/apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts b/apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts index 7ebbc8676df..afce912a58d 100644 --- a/apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts +++ b/apps/sim/lib/audit-logs/application/audit-log-use-cases.test.ts @@ -14,11 +14,11 @@ const mocks = vi.hoisted(() => ({ recordAudit: vi.fn(), })) -vi.mock('@/app/api/v1/audit-logs/auth', () => ({ +vi.mock('@/lib/audit-logs/authorization', () => ({ resolveEnterpriseAuditAccess: mocks.resolveAccess, })) -vi.mock('@/app/api/v1/audit-logs/query', () => ({ +vi.mock('@/lib/audit-logs/query', () => ({ getOrgWorkspaceIds: mocks.getOrgWorkspaceIds, buildOrgScopeCondition: mocks.buildOrgScopeCondition, buildFilterConditions: mocks.buildFilterConditions, diff --git a/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts b/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts index bc9e15f352f..363816b7c83 100644 --- a/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts +++ b/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts @@ -1,8 +1,8 @@ import type { Principal } from '@sim/auth/principal' import type { AuditLogOperation, AuditLogPrincipal } from '@/lib/audit-logs/application/operations' +import { resolveEnterpriseAuditAccess } from '@/lib/audit-logs/authorization' import type { OperationUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' export interface AuthorizedAuditLogContext { organizationId: string diff --git a/apps/sim/lib/audit-logs/application/get-audit-log.ts b/apps/sim/lib/audit-logs/application/get-audit-log.ts index 5cc262a44b3..3e5bfbdb61f 100644 --- a/apps/sim/lib/audit-logs/application/get-audit-log.ts +++ b/apps/sim/lib/audit-logs/application/get-audit-log.ts @@ -3,8 +3,8 @@ import { auditLog } from '@sim/db/schema' import { and, eq } from 'drizzle-orm' import { defineAuthorizedAuditLogUseCase } from '@/lib/audit-logs/application/authorized-audit-log-use-case' import { auditLogOperations } from '@/lib/audit-logs/application/operations' +import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/lib/audit-logs/query' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' export interface GetAuditLogInput { organizationId: string diff --git a/apps/sim/lib/audit-logs/application/list-audit-logs.ts b/apps/sim/lib/audit-logs/application/list-audit-logs.ts index 54da626fef4..a387267fb5a 100644 --- a/apps/sim/lib/audit-logs/application/list-audit-logs.ts +++ b/apps/sim/lib/audit-logs/application/list-audit-logs.ts @@ -1,13 +1,13 @@ import { defineAuthorizedAuditLogUseCase } from '@/lib/audit-logs/application/authorized-audit-log-use-case' import { auditLogOperations } from '@/lib/audit-logs/application/operations' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import type { AuditLogFilterParams } from '@/app/api/v1/audit-logs/query' import { + type AuditLogFilterParams, buildFilterConditions, buildOrgScopeCondition, getOrgWorkspaceIds, queryAuditLogs, -} from '@/app/api/v1/audit-logs/query' +} from '@/lib/audit-logs/query' +import { OrchestrationError } from '@/lib/core/orchestration/types' export interface ListAuditLogsInput { organizationId: string diff --git a/apps/sim/lib/audit-logs/authorization.ts b/apps/sim/lib/audit-logs/authorization.ts new file mode 100644 index 00000000000..518e9f0d3ff --- /dev/null +++ b/apps/sim/lib/audit-logs/authorization.ts @@ -0,0 +1,98 @@ +import { db } from '@sim/db' +import { member, subscription } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, inArray } from 'drizzle-orm' +import { isOrganizationBillingBlocked } from '@/lib/billing/core/access' +import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' +import { isAuditLogsEnabled, isBillingEnabled } from '@/lib/core/config/env-flags' + +const logger = createLogger('AuditLogAuthorization') + +export interface EnterpriseAuditContext { + organizationId: string + orgMemberIds: string[] +} + +export type EnterpriseAuditAccessResult = + | { success: true; context: EnterpriseAuditContext } + | { success: false; status: 403; message: string } + +/** Resolves transport-neutral enterprise audit-log access for an organization administrator. */ +export async function resolveEnterpriseAuditAccess( + userId: string, + targetOrganizationId?: string +): Promise<EnterpriseAuditAccessResult> { + const [membership] = await db + .select({ organizationId: member.organizationId, role: member.role }) + .from(member) + .where( + targetOrganizationId + ? and(eq(member.userId, userId), eq(member.organizationId, targetOrganizationId)) + : eq(member.userId, userId) + ) + .limit(1) + + if (!membership) { + return { + success: false, + status: 403, + message: targetOrganizationId + ? 'Not a member of the requested organization' + : 'Not a member of any organization', + } + } + + if (membership.role !== 'admin' && membership.role !== 'owner') { + return { success: false, status: 403, message: 'Organization admin or owner role required' } + } + + if (isBillingEnabled) { + const billingBlocked = await isOrganizationBillingBlocked(membership.organizationId) + if (billingBlocked) { + return { success: false, status: 403, message: 'Active enterprise subscription required' } + } + } else if (!isAuditLogsEnabled) { + return { + success: false, + status: 403, + message: + 'Audit logs are disabled. Set ENTERPRISE_ENABLED or AUDIT_LOGS_ENABLED to enable them.', + } + } + + const [orgSub, orgMembers] = await Promise.all([ + isBillingEnabled + ? db + .select({ id: subscription.id }) + .from(subscription) + .where( + and( + eq(subscription.referenceId, membership.organizationId), + eq(subscription.plan, 'enterprise'), + inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) + ) + ) + .limit(1) + : Promise.resolve([]), + db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, membership.organizationId)), + ]) + + if (isBillingEnabled && orgSub.length === 0) { + return { success: false, status: 403, message: 'Active enterprise subscription required' } + } + + const orgMemberIds = orgMembers.map((organizationMember) => organizationMember.userId) + logger.info('Enterprise audit access validated', { + userId, + organizationId: membership.organizationId, + memberCount: orgMemberIds.length, + }) + + return { + success: true, + context: { organizationId: membership.organizationId, orgMemberIds }, + } +} diff --git a/apps/sim/app/api/v1/audit-logs/query.test.ts b/apps/sim/lib/audit-logs/query.test.ts similarity index 96% rename from apps/sim/app/api/v1/audit-logs/query.test.ts rename to apps/sim/lib/audit-logs/query.test.ts index 72740ca537f..78b82b90767 100644 --- a/apps/sim/app/api/v1/audit-logs/query.test.ts +++ b/apps/sim/lib/audit-logs/query.test.ts @@ -1,13 +1,13 @@ /** * @vitest-environment node * - * Tests for the enterprise audit-log tenant boundary. The global drizzle-orm + * Verifies the enterprise audit-log tenant boundary. The global drizzle-orm * mock returns structured operator objects, so these tests assert directly on * the predicate tree. */ import { dbChainMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' +import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/lib/audit-logs/query' const ORG_ID = 'org-1' const MEMBER_IDS = ['user-1', 'user-2'] diff --git a/apps/sim/app/api/v1/audit-logs/query.ts b/apps/sim/lib/audit-logs/query.ts similarity index 85% rename from apps/sim/app/api/v1/audit-logs/query.ts rename to apps/sim/lib/audit-logs/query.ts index 795c54ecfb4..70fbea8fde1 100644 --- a/apps/sim/app/api/v1/audit-logs/query.ts +++ b/apps/sim/lib/audit-logs/query.ts @@ -69,9 +69,7 @@ export function buildFilterConditions(params: AuditLogFilterParams): SQL<unknown return conditions } -/** - * Returns the IDs of all workspaces attached to the organization. - */ +/** Returns the IDs of all workspaces attached to the organization. */ export async function getOrgWorkspaceIds(organizationId: string): Promise<string[]> { const rows = await db .select({ id: workspace.id }) @@ -87,14 +85,7 @@ export interface OrgScopeParams { includeDeparted: boolean } -/** - * Builds the tenant-boundary predicate for organization audit log access: - * rows in org-attached workspaces, plus org-level rows (`workspace_id IS - * NULL`) tied to the org via `metadata.organizationId` or the organization - * resource itself. Actor membership is never a standalone boundary — when - * `includeDeparted` is false it only narrows the org scope to current members - * and system events (null actor). - */ +/** Builds the tenant-boundary predicate for organization audit log access. */ export function buildOrgScopeCondition(params: OrgScopeParams): SQL<unknown> { const { organizationId, orgWorkspaceIds, orgMemberIds, includeDeparted } = params @@ -114,9 +105,7 @@ export function buildOrgScopeCondition(params: OrgScopeParams): SQL<unknown> { ? or(inArray(auditLog.workspaceId, orgWorkspaceIds), orgLevelCondition)! : orgLevelCondition - if (includeDeparted) { - return orgScope - } + if (includeDeparted) return orgScope const currentActorCondition = orgMemberIds.length > 0 @@ -150,7 +139,6 @@ export async function queryAuditLogs( cursor?: string ): Promise<CursorPaginatedResult> { const allConditions = [...conditions] - if (cursor) { const cursorCondition = buildCursorCondition(cursor) if (cursorCondition) allConditions.push(cursorCondition) @@ -165,15 +153,12 @@ export async function queryAuditLogs( const hasMore = rows.length > limit const data = rows.slice(0, limit) - - let nextCursor: string | undefined - if (hasMore && data.length > 0) { - const last = data[data.length - 1] - nextCursor = encodeCursor({ - createdAt: last.createdAt.toISOString(), - id: last.id, - }) + const last = data.at(-1) + return { + data, + nextCursor: + hasMore && last + ? encodeCursor({ createdAt: last.createdAt.toISOString(), id: last.id }) + : undefined, } - - return { data, nextCursor } } diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index d37df87ed2a..9724fadd410 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { credential, credentialMember } from '@sim/db/schema' import { and, type Column, eq, inArray, isNotNull, or, sql } from 'drizzle-orm' import type { V2CredentialSortBy } from '@/lib/api/contracts/v2/credentials' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { ListSortOrder } from '@/lib/api/list-query' import { listOrderBy, searchFilter } from '@/lib/api/list-query' import { isSharedCredentialType, SHARED_CREDENTIAL_TYPES } from '@/lib/credentials/access' import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -58,7 +58,7 @@ export async function listVisibleWorkspaceCredentials(params: { /** Case-insensitive substring match on the credential display name. */ search?: string sortBy?: V2CredentialSortBy - sortOrder?: V2SortOrder + sortOrder?: ListSortOrder }): Promise<VisibleWorkspaceCredential[]> { const { workspaceId, @@ -140,7 +140,7 @@ export async function listWorkspacePrincipalCredentials(params: { providerId?: string search?: string sortBy?: V2CredentialSortBy - sortOrder?: V2SortOrder + sortOrder?: ListSortOrder }): Promise<VisibleWorkspaceCredential[]> { const { workspaceId, diff --git a/apps/sim/lib/folders/application-folder-caps.test.ts b/apps/sim/lib/folders/application-folder-caps.test.ts new file mode 100644 index 00000000000..7a6d2524f25 --- /dev/null +++ b/apps/sim/lib/folders/application-folder-caps.test.ts @@ -0,0 +1,154 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + listFolderRows: vi.fn(), + listTables: vi.fn(), + listWorkflows: vi.fn(), + loadFolderIndex: vi.fn(), + resolvePermission: vi.fn(), + resolveTableWorkspace: vi.fn(), + resolveWorkflowWorkspace: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => actual === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/folders/queries', () => ({ + listActiveFolderRows: mocks.listFolderRows, + loadActiveFolderPathIndex: mocks.loadFolderIndex, + resolveFolderPathFromIndex: (index: { idByPath: Map<string, string> }, path: string) => + path === '/' ? null : index.idByPath.get(path), +})) +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.resolveWorkflowWorkspace, +})) +vi.mock('@/lib/workflows/queries', () => ({ + InvalidWorkflowListCursorError: class InvalidWorkflowListCursorError extends Error {}, + listWorkspaceWorkflows: mocks.listWorkflows, +})) +vi.mock('@/lib/table/application/context', () => ({ + resolveTableWorkspaceContext: mocks.resolveTableWorkspace, +})) +vi.mock('@/lib/table', () => ({ + createTable: vi.fn(), + deleteTable: vi.fn(), + getTableById: vi.fn(), + getWorkspaceTableLimits: vi.fn(), + moveTableToFolder: vi.fn(), + queryTables: mocks.listTables, + renameTable: vi.fn(), + updateTableDescription: vi.fn(), +})) +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: 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' + +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +const folderIndex = { + idByPath: new Map<string, string>(), + pathById: new Map<string, string>(), + rowById: new Map(), +} + +describe('workflow and table application folder caps', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveWorkflowWorkspace.mockResolvedValue(context) + mocks.resolveTableWorkspace.mockResolvedValue(context) + mocks.loadFolderIndex.mockResolvedValue(folderIndex) + mocks.listFolderRows.mockResolvedValue([]) + mocks.listWorkflows.mockResolvedValue({ data: [], nextCursorKeys: null }) + mocks.listTables.mockResolvedValue({ tables: [], nextKeys: null }) + }) + + it.each([ + [ + 'workflow', + () => + listWorkflowFolders.execute({ + principal, + input: { + workspaceId: context.workspaceId, + sortBy: 'name', + sortOrder: 'asc', + }, + }), + ], + [ + 'table', + () => + listTableFoldersUseCase.execute({ + principal, + input: { workspaceId: context.workspaceId }, + }), + ], + ] as const)('bounds the %s folder-list index and result rows', async (resourceType, execute) => { + await execute() + + expect(mocks.loadFolderIndex).toHaveBeenCalledWith( + context.workspaceId, + resourceType, + undefined, + { maxRows: MAX_FOLDERS_PER_WORKSPACE } + ) + expect(mocks.listFolderRows).toHaveBeenCalledWith( + context.workspaceId, + resourceType, + expect.objectContaining({ maxRows: MAX_FOLDERS_PER_WORKSPACE }) + ) + }) + + it.each([ + [ + 'workflow', + () => + listWorkflows.execute({ + principal, + input: { + workspaceId: context.workspaceId, + deployedOnly: false, + sortBy: 'name', + sortOrder: 'asc', + limit: 25, + }, + }), + ], + [ + 'table', + () => + listTablesUseCase.execute({ + principal, + input: { + workspaceId: context.workspaceId, + sortBy: 'name', + sortOrder: 'asc', + limit: 25, + }, + }), + ], + ] as const)('bounds the %s paged-resource folder index', async (resourceType, execute) => { + await execute() + + expect(mocks.loadFolderIndex).toHaveBeenCalledWith( + context.workspaceId, + resourceType, + undefined, + { maxRows: MAX_FOLDERS_PER_WORKSPACE } + ) + }) +}) diff --git a/apps/sim/lib/folders/cascade.test.ts b/apps/sim/lib/folders/cascade.test.ts index e75fa3ee45d..e177dd3c308 100644 --- a/apps/sim/lib/folders/cascade.test.ts +++ b/apps/sim/lib/folders/cascade.test.ts @@ -13,6 +13,7 @@ import { toCascadeCounts, } from '@/lib/folders/cascade' import { FOLDER_RESOURCES, type FolderResourceConfig } from '@/lib/folders/config' +import { FolderCollectionLimitExceededError } from '@/lib/folders/errors' import { folderMutationStatus } from '@/lib/folders/status' interface SelectCall { @@ -155,9 +156,14 @@ describe('collectCascadeSubtreeIds', () => { ], }) - await expect( + const rejection = expect( collectCascadeSubtreeIds(tx, 'ws-1', 'knowledge_base', 'root', TIMESTAMP, 2) - ).rejects.toThrow('Folder cascade exceeds the 2 row limit') + ).rejects + await rejection.toBeInstanceOf(FolderCollectionLimitExceededError) + await rejection.toMatchObject({ + code: 'payload_too_large', + message: 'Folder cascade exceeds the 2 row limit', + }) }) }) diff --git a/apps/sim/lib/folders/cascade.ts b/apps/sim/lib/folders/cascade.ts index c4da00dedd7..08db6cab1b3 100644 --- a/apps/sim/lib/folders/cascade.ts +++ b/apps/sim/lib/folders/cascade.ts @@ -3,6 +3,7 @@ import { folder as folderTable } from '@sim/db/schema' import { and, eq, inArray, isNull, or, type SQL } from 'drizzle-orm' import type { FolderCascadeCountsApi, FolderResourceType } from '@/lib/api/contracts/folders' import type { FolderResourceConfig } from '@/lib/folders/config' +import { FolderCollectionLimitExceededError } from '@/lib/folders/errors' import { collectDescendantFolderIds } from '@/lib/folders/subtree' /** Narrow enough for both `db` and an open transaction handle. */ @@ -47,7 +48,7 @@ export async function collectCascadeSubtreeIds( ) const cascadeFolders = maxRows === undefined ? await query : await query.limit(maxRows + 1) if (maxRows !== undefined && cascadeFolders.length > maxRows) { - throw new Error(`Folder cascade exceeds the ${maxRows} row limit`) + throw new FolderCollectionLimitExceededError('cascade', maxRows) } return [folderId, ...collectDescendantFolderIds(cascadeFolders, folderId)] diff --git a/apps/sim/lib/folders/constants.ts b/apps/sim/lib/folders/constants.ts new file mode 100644 index 00000000000..64574b1c817 --- /dev/null +++ b/apps/sim/lib/folders/constants.ts @@ -0,0 +1,2 @@ +/** Hard bound for any active folder tree materialized by an application operation. */ +export const MAX_FOLDERS_PER_WORKSPACE = 10_000 diff --git a/apps/sim/lib/folders/errors.ts b/apps/sim/lib/folders/errors.ts new file mode 100644 index 00000000000..525a9279ef9 --- /dev/null +++ b/apps/sim/lib/folders/errors.ts @@ -0,0 +1,11 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' + +type FolderCollection = 'cascade' | 'list' | 'path index' + +/** Typed failure used when a complete folder collection cannot be materialized safely. */ +export class FolderCollectionLimitExceededError extends OrchestrationError { + constructor(collection: FolderCollection, maxRows: number) { + super('payload_too_large', `Folder ${collection} exceeds the ${maxRows} row limit`) + this.name = 'FolderCollectionLimitExceededError' + } +} diff --git a/apps/sim/lib/folders/orchestration.test.ts b/apps/sim/lib/folders/orchestration.test.ts index 013bd4c166c..d87b037d6e3 100644 --- a/apps/sim/lib/folders/orchestration.test.ts +++ b/apps/sim/lib/folders/orchestration.test.ts @@ -11,6 +11,7 @@ import { schemaMock, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { FolderCollectionLimitExceededError } from '@/lib/folders/errors' const { mockArchiveFolderCascade, @@ -335,6 +336,60 @@ describe('createFolder', () => { }) describe('path-owned folder mutations', () => { + it('returns a typed limit failure before expanding an oversized mutation index', async () => { + mockLoadActiveFolderPathIndex.mockRejectedValueOnce( + new FolderCollectionLimitExceededError('path index', 10_000) + ) + + const result = await createFolderAtPathTransition({ + resourceType: 'workflow', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports', + maxFolderRows: 10_000, + }) + + expect(result).toEqual({ + success: false, + error: 'Folder path index exceeds the 10000 row limit', + errorCode: 'payload_too_large', + }) + expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith( + 'ws-1', + 'workflow', + expect.anything(), + { maxRows: 10_000 } + ) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it.each(['workflow', 'table'] as const)( + 'rejects a %s folder create at the cap before inserting', + async (resourceType) => { + const existing = folderRow({ id: 'existing-1', resourceType, name: 'Existing' }) + mockLoadActiveFolderPathIndex.mockResolvedValueOnce({ + rowById: new Map([[existing.id, existing]]), + pathById: new Map([[existing.id, '/Existing']]), + idByPath: new Map([['/Existing', existing.id]]), + }) + + const result = await createFolderAtPathTransition({ + resourceType, + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports', + maxFolderRows: 1, + }) + + expect(result).toEqual({ + success: false, + error: 'Folder path index exceeds the 1 row limit', + errorCode: 'payload_too_large', + }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + } + ) + it('does not project legacy audit from the application transition', async () => { queueTableRows(schemaMock.folder, [{ minSortOrder: 0 }]) dbChainMockFns.returning.mockResolvedValueOnce([folderRow()]) diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index f2540f15bff..b08a8115916 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -6,7 +6,7 @@ import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull, min } from 'drizzle-orm' import type { FolderCascadeCountsApi, FolderResourceType } from '@/lib/api/contracts/folders' -import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { asOrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { withTransactionRetry } from '@/lib/db/transaction' import type { DbOrTx } from '@/lib/db/types' import { @@ -18,6 +18,7 @@ import { toCascadeCounts, } from '@/lib/folders/cascade' import { folderResourceConfig } from '@/lib/folders/config' +import { FolderCollectionLimitExceededError } from '@/lib/folders/errors' import { acquireFolderMutationLock, withFolderTreeLock } from '@/lib/folders/locks' import { deduplicateFolderName } from '@/lib/folders/naming' import { @@ -142,6 +143,8 @@ function isEffectivelyLocked(index: FolderPathIndex<typeof folderTable.$inferSel function pathMutationError(error: unknown): FolderPathMutationResult { const message = getErrorMessage(error, 'Internal server error') + const classified = asOrchestrationError(error) + if (classified) return { success: false, error: classified.message, errorCode: classified.code } if (message === 'Folder not found' || message === 'Parent folder not found') { return { success: false, error: message, errorCode: 'not_found' } } @@ -197,6 +200,9 @@ async function executeCreateFolderAtPath( ) { throw new Error('Folder is locked') } + if (params.maxFolderRows !== undefined && index.rowById.size >= params.maxFolderRows) { + throw new FolderCollectionLimitExceededError('path index', params.maxFolderRows) + } const sortOrder = await nextFolderSortOrder( params.resourceType, @@ -261,7 +267,10 @@ export async function createFolderAtPath( /** Applies the authoritative mutation without projecting legacy audit. */ export async function createFolderAtPathTransition( - params: Omit<CreateFolderParams, 'name' | 'parentId' | 'sortOrder' | 'id'> & { path: string } + params: Omit<CreateFolderParams, 'name' | 'parentId' | 'sortOrder' | 'id'> & { + path: string + maxFolderRows?: number + } ): Promise<FolderPathMutationResult> { return executeCreateFolderAtPath(params, false) } diff --git a/apps/sim/lib/folders/queries.test.ts b/apps/sim/lib/folders/queries.test.ts index 3d7a8e1ef02..dac83c35df7 100644 --- a/apps/sim/lib/folders/queries.test.ts +++ b/apps/sim/lib/folders/queries.test.ts @@ -9,6 +9,7 @@ import { schemaMock, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FolderCollectionLimitExceededError } from '@/lib/folders/errors' import { findActiveFolder, listActiveFolderRows, @@ -190,18 +191,28 @@ describe('folder queries', () => { it('fails before building an oversized path index', async () => { queueTableRows(schemaMock.folder, [ROW, { ...ROW, id: 'f-2' }, { ...ROW, id: 'f-3' }]) - await expect( + const rejection = expect( loadActiveFolderPathIndex('ws-1', 'knowledge_base', undefined, { maxRows: 2 }) - ).rejects.toThrow('Folder path index exceeds the 2 row limit') + ).rejects + await rejection.toBeInstanceOf(FolderCollectionLimitExceededError) + await rejection.toMatchObject({ + code: 'payload_too_large', + message: 'Folder path index exceeds the 2 row limit', + }) expect(dbChainMockFns.limit).toHaveBeenCalledWith(3) }) it('fails before returning an oversized folder list', async () => { queueTableRows(schemaMock.folder, [ROW, { ...ROW, id: 'f-2' }, { ...ROW, id: 'f-3' }]) - await expect(listActiveFolderRows('ws-1', 'knowledge_base', { maxRows: 2 })).rejects.toThrow( - 'Folder list exceeds the 2 row limit' - ) + const rejection = expect( + listActiveFolderRows('ws-1', 'knowledge_base', { maxRows: 2 }) + ).rejects + await rejection.toBeInstanceOf(FolderCollectionLimitExceededError) + await rejection.toMatchObject({ + code: 'payload_too_large', + message: 'Folder list exceeds the 2 row limit', + }) expect(dbChainMockFns.limit).toHaveBeenCalledWith(3) }) }) diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts index ff2bade9564..27128143d75 100644 --- a/apps/sim/lib/folders/queries.ts +++ b/apps/sim/lib/folders/queries.ts @@ -2,9 +2,9 @@ import { db } from '@sim/db' import { folder } from '@sim/db/schema' import { and, type Column, eq, isNotNull, isNull } from 'drizzle-orm' import type { FolderApi, FolderResourceType } from '@/lib/api/contracts/folders' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' -import { listOrderBy, searchFilter } from '@/lib/api/list-query' +import { type ListSortOrder, listOrderBy, searchFilter } from '@/lib/api/list-query' import type { DbOrTx } from '@/lib/db/types' +import { FolderCollectionLimitExceededError } from '@/lib/folders/errors' import { buildFolderPathIndex, type FolderPathIndex, ROOT_FOLDER_PATH } from '@/lib/folders/paths' import type { FolderQueryScope } from '@/hooks/queries/utils/folder-keys' @@ -158,14 +158,14 @@ interface ListFoldersOptions { /** Case-insensitive substring match on the folder name. */ search?: string sortBy?: FolderSortBy - sortOrder?: V2SortOrder + sortOrder?: ListSortOrder } interface ListActiveFolderRowsOptions { parentId?: string | null search?: string sortBy?: Exclude<FolderSortBy, 'position'> - sortOrder?: V2SortOrder + sortOrder?: ListSortOrder maxRows?: number } @@ -187,7 +187,7 @@ export async function loadActiveFolderPathIndex( ) const rows = options?.maxRows === undefined ? await query : await query.limit(options.maxRows + 1) if (options?.maxRows !== undefined && rows.length > options.maxRows) { - throw new Error(`Folder path index exceeds the ${options.maxRows} row limit`) + throw new FolderCollectionLimitExceededError('path index', options.maxRows) } return buildFolderPathIndex(rows) @@ -229,7 +229,7 @@ export async function listActiveFolderRows( .orderBy(...listOrderBy(FOLDER_SORTS[options.sortBy ?? 'name'], options.sortOrder ?? 'asc')) const rows = options.maxRows === undefined ? await query : await query.limit(options.maxRows + 1) if (options.maxRows !== undefined && rows.length > options.maxRows) { - throw new Error(`Folder list exceeds the ${options.maxRows} row limit`) + throw new FolderCollectionLimitExceededError('list', options.maxRows) } return rows } diff --git a/apps/sim/lib/knowledge/api/route-policies.ts b/apps/sim/lib/knowledge/api/route-policies.ts new file mode 100644 index 00000000000..a832cbf32e5 --- /dev/null +++ b/apps/sim/lib/knowledge/api/route-policies.ts @@ -0,0 +1,11 @@ +import { + createV2ResourceConcealmentPolicy, + v2OrchestrationErrorPolicy, +} from '@/lib/api/server/routes' + +export const v2KnowledgeErrorPolicies = { + default: v2OrchestrationErrorPolicy, + concealKnowledgeBaseAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Knowledge base not found', + }), +} as const diff --git a/apps/sim/lib/knowledge/application/contexts.test.ts b/apps/sim/lib/knowledge/application/contexts.test.ts new file mode 100644 index 00000000000..950ae781ecd --- /dev/null +++ b/apps/sim/lib/knowledge/application/contexts.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getKnowledgeBase: vi.fn(), + loadWorkspace: vi.fn(), +})) + +vi.mock('@/lib/knowledge/service', () => ({ getKnowledgeBaseById: mocks.getKnowledgeBase })) +vi.mock('@/lib/knowledge/documents/service', () => ({ getKnowledgeDocument: vi.fn() })) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +import { + resolveActiveKnowledgeBaseContext, + resolveKnowledgeWorkspaceContext, +} from '@/lib/knowledge/application/contexts' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-user-1', +} +const knowledgeBase = { id: 'knowledge-1', workspaceId: 'workspace-1' } + +describe('knowledge application contexts', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getKnowledgeBase.mockResolvedValue(knowledgeBase) + mocks.loadWorkspace.mockResolvedValue(workspace) + }) + + it('uses the canonical active-workspace loader', async () => { + await expect(resolveKnowledgeWorkspaceContext({ workspaceId: 'workspace-1' })).resolves.toBe( + workspace + ) + expect(mocks.loadWorkspace).toHaveBeenCalledWith('workspace-1') + }) + + it('conceals an inactive canonical workspace as knowledge-base absence', async () => { + mocks.loadWorkspace.mockResolvedValueOnce(null) + + await expect( + resolveActiveKnowledgeBaseContext({ knowledgeBaseId: 'knowledge-1' }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Knowledge base not found' }) + }) + + it('propagates canonical workspace database failures', async () => { + const failure = new Error('workspace database unavailable') + mocks.loadWorkspace.mockRejectedValueOnce(failure) + + await expect( + resolveActiveKnowledgeBaseContext({ knowledgeBaseId: 'knowledge-1' }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/knowledge/application/contexts.ts b/apps/sim/lib/knowledge/application/contexts.ts index 1c2648aabd1..57a6cd81c64 100644 --- a/apps/sim/lib/knowledge/application/contexts.ts +++ b/apps/sim/lib/knowledge/application/contexts.ts @@ -1,12 +1,10 @@ -import { db } from '@sim/db' -import { workspace } from '@sim/db/schema' -import { and, eq, isNull } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { KnowledgeAuthorizationContext } from '@/lib/knowledge/application/authorization' import type { ActiveKnowledgeDocument } from '@/lib/knowledge/documents/service' import { getKnowledgeDocument } from '@/lib/knowledge/documents/service' import { getKnowledgeBaseById } from '@/lib/knowledge/service' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' export interface KnowledgeWorkspaceContext extends KnowledgeAuthorizationContext { billedAccountUserId: string @@ -25,17 +23,7 @@ export interface ActiveKnowledgeDocumentContext extends ActiveKnowledgeBaseConte export async function loadKnowledgeWorkspaceContext( workspaceId: string ): Promise<KnowledgeWorkspaceContext | null> { - const [row] = await db - .select({ - workspaceId: workspace.id, - workspaceOrganizationId: workspace.organizationId, - allowPersonalApiKeys: workspace.allowPersonalApiKeys, - billedAccountUserId: workspace.billedAccountUserId, - }) - .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - .limit(1) - return row ?? null + return loadActiveWorkspaceApplicationContext(workspaceId) } export async function resolveKnowledgeWorkspaceContext(input: { diff --git a/apps/sim/lib/knowledge/constants.ts b/apps/sim/lib/knowledge/constants.ts index e0f53db00ae..54cdf56ec97 100644 --- a/apps/sim/lib/knowledge/constants.ts +++ b/apps/sim/lib/knowledge/constants.ts @@ -1,9 +1,11 @@ +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' + /** Max character length for a knowledge base description, enforced at every layer (UI, internal API, v1 API). */ export const KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH = 10_000 /** Hard bound for full-workspace knowledge-base list projections. */ export const MAX_KNOWLEDGE_BASES_PER_WORKSPACE = 10_000 /** Hard bound for path-indexed knowledge folder trees and recursive cascades. */ -export const MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE = 10_000 +export const MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE = MAX_FOLDERS_PER_WORKSPACE /** Hard bound for connector-type rows projected onto one knowledge-base list. */ export const MAX_KNOWLEDGE_CONNECTOR_TYPE_ROWS_PER_LIST = 100_000 diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index 8de2d573357..6ce9201e60a 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -24,7 +24,7 @@ import { sql, } from 'drizzle-orm' import type { V2KnowledgeBaseSortBy } from '@/lib/api/contracts/v2/knowledge' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { ListSortOrder } from '@/lib/api/list-query' import { listOrderBy, searchFilter } from '@/lib/api/list-query' import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' @@ -143,7 +143,7 @@ export interface GetKnowledgeBasesOptions { /** Case-insensitive substring match on the knowledge base name. */ search?: string sortBy?: V2KnowledgeBaseSortBy - sortOrder?: V2SortOrder + sortOrder?: ListSortOrder } async function attachConnectorTypes( diff --git a/apps/sim/lib/secrets/application/use-cases.ts b/apps/sim/lib/secrets/application/use-cases.ts index f30efa998b3..abd1b5c27ce 100644 --- a/apps/sim/lib/secrets/application/use-cases.ts +++ b/apps/sim/lib/secrets/application/use-cases.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import type { Principal } from '@sim/auth/principal' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { ListSortOrder } from '@/lib/api/list-query' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getWorkspaceEnvKeyAdminAccess } from '@/lib/credentials/environment' @@ -52,7 +52,7 @@ async function listSecretMetadata(params: { scope?: SecretScope search?: string sortBy: SecretSortBy - sortOrder: V2SortOrder + sortOrder: ListSortOrder }): Promise<VisibleWorkspaceCredential[]> { const workspaceAccess = await checkWorkspaceAccess(params.workspaceId, params.userId) const rows = await listVisibleWorkspaceCredentials({ @@ -125,7 +125,7 @@ export interface ListSecretsInput { scope?: SecretScope search?: string sortBy: SecretSortBy - sortOrder: V2SortOrder + sortOrder: ListSortOrder } export const listSecretsUseCase = defineAuthorizedWorkspaceUseCase({ diff --git a/apps/sim/lib/table/api/route-policies.ts b/apps/sim/lib/table/api/route-policies.ts index a6b270166d1..4f0b039202c 100644 --- a/apps/sim/lib/table/api/route-policies.ts +++ b/apps/sim/lib/table/api/route-policies.ts @@ -1,4 +1,4 @@ -import type { V2ErrorPolicy } from '@/lib/api/server/routes' +import { createV2ResourceConcealmentPolicy, type V2ErrorPolicy } from '@/lib/api/server/routes' import { TableOperationError } from '@/lib/table/application/errors' import { TableLockedError } from '@/lib/table/mutation-locks' import { @@ -27,28 +27,16 @@ export const v2TableErrorPolicies = { default: { render: renderTableError, } satisfies V2ErrorPolicy, - concealTableAuthorization: { - render(error) { - const response = renderTableError(error) - if (!response) return null - if (response.status === 403) return v2Error('NOT_FOUND', 'Table not found') - return response - }, - } satisfies V2ErrorPolicy, - concealImportAuthorization: { - render(error) { - const response = renderTableError(error) - if (!response) return null - if (response.status === 403) return v2Error('NOT_FOUND', 'Table import not found') - return response - }, - } satisfies V2ErrorPolicy, - concealExportAuthorization: { - render(error) { - const response = renderTableError(error) - if (!response) return null - if (response.status === 403) return v2Error('NOT_FOUND', 'Table export not found') - return response - }, - } satisfies V2ErrorPolicy, + concealTableAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Table not found', + render: renderTableError, + }), + concealImportAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Table import not found', + render: renderTableError, + }), + concealExportAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Table export not found', + render: renderTableError, + }), } as const diff --git a/apps/sim/lib/table/application/context.test.ts b/apps/sim/lib/table/application/context.test.ts index bc6f2b41186..d1b8ff08a66 100644 --- a/apps/sim/lib/table/application/context.test.ts +++ b/apps/sim/lib/table/application/context.test.ts @@ -4,24 +4,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { getTableById, select } = vi.hoisted(() => ({ +const { getTableById, loadWorkspace } = vi.hoisted(() => ({ getTableById: vi.fn(), - select: vi.fn(), + loadWorkspace: vi.fn(), })) -vi.mock('@sim/db', () => ({ db: { select } })) vi.mock('@/lib/table', () => ({ getTableById })) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: loadWorkspace, +})) import { resolveActiveTableContext } from '@/lib/table/application/context' -function mockWorkspaceQuery(rows: unknown[]) { - const limit = vi.fn().mockResolvedValue(rows) - const where = vi.fn(() => ({ limit })) - const from = vi.fn(() => ({ where })) - select.mockReturnValue({ from }) - return { from, where, limit } -} - describe('table application context', () => { beforeEach(() => { vi.clearAllMocks() @@ -30,18 +24,15 @@ describe('table application context', () => { workspaceId: 'workspace-1', name: 'Contacts', }) + loadWorkspace.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-user-1', + }) }) it('derives workspace scope from the canonical active table', async () => { - mockWorkspaceQuery([ - { - workspaceId: 'workspace-1', - workspaceOrganizationId: 'organization-1', - allowPersonalApiKeys: true, - billedAccountUserId: 'billing-user-1', - }, - ]) - await expect( resolveActiveTableContext({ tableId: 'table-1', assertedWorkspaceId: 'workspace-1' }) ).resolves.toMatchObject({ @@ -50,22 +41,29 @@ describe('table application context', () => { billedAccountUserId: 'billing-user-1', }) expect(getTableById).toHaveBeenCalledWith('table-1') - expect(select).toHaveBeenCalledTimes(1) + expect(loadWorkspace).toHaveBeenCalledWith('workspace-1') }) it('conceals an asserted cross-workspace table before workspace resolution', async () => { await expect( resolveActiveTableContext({ tableId: 'table-1', assertedWorkspaceId: 'workspace-2' }) ).rejects.toMatchObject({ code: 'not_found', message: 'Table not found' }) - expect(select).not.toHaveBeenCalled() + expect(loadWorkspace).not.toHaveBeenCalled() }) it('fails when the canonical workspace is unavailable', async () => { - mockWorkspaceQuery([]) + loadWorkspace.mockResolvedValueOnce(null) await expect(resolveActiveTableContext({ tableId: 'table-1' })).rejects.toMatchObject({ code: 'not_found', message: 'Workspace not found', }) }) + + it('propagates canonical workspace database failures', async () => { + const failure = new Error('workspace database unavailable') + loadWorkspace.mockRejectedValueOnce(failure) + + await expect(resolveActiveTableContext({ tableId: 'table-1' })).rejects.toBe(failure) + }) }) diff --git a/apps/sim/lib/table/application/context.ts b/apps/sim/lib/table/application/context.ts index 2d9603aec8b..d87150c0f50 100644 --- a/apps/sim/lib/table/application/context.ts +++ b/apps/sim/lib/table/application/context.ts @@ -1,9 +1,7 @@ -import { db } from '@sim/db' -import { workspace } from '@sim/db/schema' -import { and, eq, isNull } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getTableById, type TableDefinition } from '@/lib/table' import type { TableAuthorizationContext } from '@/lib/table/application/authorization' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' export type TableWorkspaceContext = TableAuthorizationContext @@ -15,17 +13,7 @@ export interface ActiveTableContext extends TableWorkspaceContext { export async function resolveTableWorkspaceContext( workspaceId: string ): Promise<TableWorkspaceContext> { - const [canonical] = await db - .select({ - workspaceId: workspace.id, - workspaceOrganizationId: workspace.organizationId, - allowPersonalApiKeys: workspace.allowPersonalApiKeys, - billedAccountUserId: workspace.billedAccountUserId, - }) - .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - .limit(1) - + const canonical = await loadActiveWorkspaceApplicationContext(workspaceId) if (!canonical) throw new OrchestrationError('not_found', 'Workspace not found') return canonical } diff --git a/apps/sim/lib/table/application/folder-paths.ts b/apps/sim/lib/table/application/folder-paths.ts index 62683ff3504..e6864954eb1 100644 --- a/apps/sim/lib/table/application/folder-paths.ts +++ b/apps/sim/lib/table/application/folder-paths.ts @@ -1,4 +1,5 @@ import type { folder } from '@sim/db/schema' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { withFolderTreeLock } from '@/lib/folders/locks' import type { FolderPathIndex } from '@/lib/folders/paths' import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' @@ -16,7 +17,9 @@ export async function resolveTableFolderPath( path: string ): Promise<ResolvedTableFolderPath | null> { return withFolderTreeLock(workspaceId, 'table', async (tx) => { - const index = await loadActiveFolderPathIndex(workspaceId, 'table', tx) + const index = await loadActiveFolderPathIndex(workspaceId, 'table', tx, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) const folderId = resolveFolderPathFromIndex(index, path) return folderId === undefined ? null : { folderId, index } }) diff --git a/apps/sim/lib/table/application/folders.ts b/apps/sim/lib/table/application/folders.ts index 4126481064e..dbf9c825d32 100644 --- a/apps/sim/lib/table/application/folders.ts +++ b/apps/sim/lib/table/application/folders.ts @@ -1,7 +1,8 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { resolvePrincipalAttribution } from '@sim/auth/principal' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { ListSortOrder } from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { createFolderAtPathTransition, deleteFolderByPathTransition, @@ -23,7 +24,7 @@ export interface ListTableFoldersInput { parentPath?: string search?: string sortBy?: Exclude<FolderSortBy, 'position'> - sortOrder?: V2SortOrder + sortOrder?: ListSortOrder } export const listTableFoldersUseCase = defineAuthorizedTableUseCase({ @@ -31,7 +32,9 @@ export const listTableFoldersUseCase = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: ListTableFoldersInput }) => resolveTableWorkspaceContext(input.workspaceId), async execute({ input, context }) { - const index = await loadActiveFolderPathIndex(context.workspaceId, 'table') + const index = await loadActiveFolderPathIndex(context.workspaceId, 'table', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) const parentId = input.parentPath === undefined ? undefined @@ -44,6 +47,7 @@ export const listTableFoldersUseCase = defineAuthorizedTableUseCase({ search: input.search, sortBy: input.sortBy, sortOrder: input.sortOrder, + maxRows: MAX_FOLDERS_PER_WORKSPACE, }) return { folders, index } }, @@ -67,11 +71,14 @@ export const createTableFolderUseCase = defineAuthorizedTableUseCase({ workspaceId: context.workspaceId, userId: attribution.attributedUserId, path: input.path, + maxFolderRows: MAX_FOLDERS_PER_WORKSPACE, }) if (!result.success || !result.folder) { throwTableOperationFailure(result, 'Failed to create folder') } - const index = await loadActiveFolderPathIndex(context.workspaceId, 'table') + const index = await loadActiveFolderPathIndex(context.workspaceId, 'table', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) return { folder: result.folder, index, path: input.path } }, projectAudit({ result }) { @@ -104,11 +111,14 @@ export const updateTableFolderUseCase = defineAuthorizedTableUseCase({ userId: attribution.attributedUserId, path: input.path, destinationPath: input.destinationPath, + maxFolderRows: MAX_FOLDERS_PER_WORKSPACE, }) if (!result.success || !result.folder) { throwTableOperationFailure(result, 'Failed to move folder') } - const index = await loadActiveFolderPathIndex(context.workspaceId, 'table') + const index = await loadActiveFolderPathIndex(context.workspaceId, 'table', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) return { folder: result.folder, index, path: input.destinationPath, sourcePath: input.path } }, projectAudit({ result }) { @@ -145,6 +155,7 @@ export const deleteTableFolderUseCase = defineAuthorizedTableUseCase({ userId: attribution.attributedUserId, path: input.path, recursive: input.recursive, + maxFolderRows: MAX_FOLDERS_PER_WORKSPACE, }) if (!result.success || !result.deletedItems || !result.folderId || !result.folderName) { throwTableOperationFailure(result, 'Failed to delete folder') diff --git a/apps/sim/lib/table/application/imports.ts b/apps/sim/lib/table/application/imports.ts index e55563ad2dc..370770fdd3e 100644 --- a/apps/sim/lib/table/application/imports.ts +++ b/apps/sim/lib/table/application/imports.ts @@ -7,6 +7,7 @@ import type { } from '@/lib/api/contracts/v2/tables' import { authorizeWorkspaceOperation } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { withFolderTreeLock } from '@/lib/folders/locks' import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' @@ -140,7 +141,9 @@ async function resolveImportFolderId( if (body.target.type !== 'new') return undefined const path = body.target.folderPath ?? ROOT_FOLDER_PATH return withFolderTreeLock(workspaceId, 'table', async (tx) => { - const index = await loadActiveFolderPathIndex(workspaceId, 'table', tx) + const index = await loadActiveFolderPathIndex(workspaceId, 'table', tx, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) const folderId = resolveFolderPathFromIndex(index, path) if (folderId === undefined) { throw new OrchestrationError('not_found', 'Folder not found') diff --git a/apps/sim/lib/table/application/tables.ts b/apps/sim/lib/table/application/tables.ts index 2374fd1b379..8f904d3b22c 100644 --- a/apps/sim/lib/table/application/tables.ts +++ b/apps/sim/lib/table/application/tables.ts @@ -1,10 +1,10 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { resolvePrincipalAttribution } from '@sim/auth/principal' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' import type { V2TableSortBy } from '@/lib/api/contracts/v2/tables' -import type { CursorKey } from '@/lib/api/list-query' +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 { createTable, @@ -32,7 +32,7 @@ export interface ListTablesInput { folderPath?: string search?: string sortBy: V2TableSortBy - sortOrder: V2SortOrder + sortOrder: ListSortOrder limit: number after?: CursorKey[] } @@ -42,7 +42,9 @@ export const listTablesUseCase = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: ListTablesInput }) => resolveTableWorkspaceContext(input.workspaceId), async execute({ input, context }) { - const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'table') + const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'table', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) const folderId = input.folderPath === undefined ? undefined @@ -139,7 +141,9 @@ export const readTableUseCase = defineAuthorizedTableUseCase({ assertedWorkspaceId: input.workspaceId, }), async execute({ context }) { - const index = await loadActiveFolderPathIndex(context.workspaceId, 'table') + const index = await loadActiveFolderPathIndex(context.workspaceId, 'table', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) return { table: context.table, folderPath: tableFolderPathForId(index, context.table.folderId), @@ -223,7 +227,10 @@ export const updateTableUseCase = defineAuthorizedTableUseCase({ throw new OrchestrationError('not_found', 'Table not found') } const index = - resolution?.index ?? (await loadActiveFolderPathIndex(context.workspaceId, 'table')) + resolution?.index ?? + (await loadActiveFolderPathIndex(context.workspaceId, 'table', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + })) return { table, folderPath: tableFolderPathForId(index, table.folderId), diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index a596ab1244b..e30a4ecb83e 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -14,8 +14,8 @@ import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, type Column, count, eq, isNotNull, isNull, type SQL, sql } from 'drizzle-orm' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' import type { V2TableSortBy } from '@/lib/api/contracts/v2/tables' +import type { ListSortOrder } from '@/lib/api/list-query' import { type CursorKey, encodeKeyset, @@ -291,7 +291,7 @@ interface ListTablesOptions { /** Case-insensitive substring match on the table name. */ search?: string sortBy?: V2TableSortBy - sortOrder?: V2SortOrder + sortOrder?: ListSortOrder } /** @@ -377,7 +377,7 @@ export interface QueryTablesOptions { /** Case-insensitive substring match on the table name. */ search?: string sortBy: V2TableSortBy - sortOrder: V2SortOrder + sortOrder: ListSortOrder limit: number /** Keyset values from a cursor, in the sort's key order. */ after?: CursorKey[] 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 bd5075549cd..257c529122f 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -17,7 +17,7 @@ import { generateShortId } from '@sim/utils/id' import { and, eq, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm' import type { ShareRecord } from '@/lib/api/contracts/public-shares' import type { V2FileSortBy } from '@/lib/api/contracts/v2/files' -import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { ListSortOrder } from '@/lib/api/list-query' import { type CursorKey, encodeKeyset, @@ -1213,7 +1213,7 @@ export interface QueryWorkspaceFilesOptions { /** Case-insensitive substring match on the file name. */ search?: string sortBy: V2FileSortBy - sortOrder: V2SortOrder + sortOrder: ListSortOrder limit: number /** Keyset values from a cursor, in the sort's key order. */ after?: CursorKey[] diff --git a/apps/sim/lib/workflows/api/route-policies.ts b/apps/sim/lib/workflows/api/route-policies.ts index a90b6888819..9f17e4708d5 100644 --- a/apps/sim/lib/workflows/api/route-policies.ts +++ b/apps/sim/lib/workflows/api/route-policies.ts @@ -1,40 +1,10 @@ -import { type V2ErrorPolicy, v2OrchestrationErrorPolicy } from '@/lib/api/server/routes' import { - DelegatedWorkspaceAuthorizationError, - InsufficientWorkspacePermissionsError, - PersonalApiKeysDisabledError, - PrincipalKindAuthorizationError, - WorkspaceApiKeyAuthorizationError, -} from '@/lib/core/application' + createV2ResourceConcealmentPolicy, + type V2ErrorPolicy, + v2OrchestrationErrorPolicy, +} from '@/lib/api/server/routes' import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error' -import { - v2CaughtOrchestrationError, - v2Error, - v2ErrorForOrchestration, -} from '@/app/api/v2/lib/response' - -function isConcealedResourceAuthorizationError(error: unknown): boolean { - return ( - error instanceof DelegatedWorkspaceAuthorizationError || - error instanceof InsufficientWorkspacePermissionsError || - error instanceof PrincipalKindAuthorizationError || - error instanceof WorkspaceApiKeyAuthorizationError - ) -} - -function concealResourceAuthorization(resourceName: 'Workflow' | 'Run'): V2ErrorPolicy { - return { - render(error) { - if (error instanceof PersonalApiKeysDisabledError) { - return v2CaughtOrchestrationError(error) - } - if (isConcealedResourceAuthorizationError(error)) { - return v2Error('NOT_FOUND', `${resourceName} not found`) - } - return v2CaughtOrchestrationError(error) - }, - } -} +import { v2CaughtOrchestrationError, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' export const v2WorkflowErrorPolicies = { default: v2OrchestrationErrorPolicy, @@ -46,6 +16,10 @@ export const v2WorkflowErrorPolicies = { return v2CaughtOrchestrationError(error) }, } satisfies V2ErrorPolicy, - concealWorkflowAuthorization: concealResourceAuthorization('Workflow'), - concealRunAuthorization: concealResourceAuthorization('Run'), + concealWorkflowAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workflow not found', + }), + concealRunAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Run not found', + }), } as const diff --git a/apps/sim/lib/workflows/application/context.test.ts b/apps/sim/lib/workflows/application/context.test.ts new file mode 100644 index 00000000000..5c8f59b0e0c --- /dev/null +++ b/apps/sim/lib/workflows/application/context.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ loadWorkspace: vi.fn() })) + +vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: vi.fn() })) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +import { + resolveActiveWorkflowApplicationContext, + resolveActiveWorkspaceApplicationContext, +} from '@/lib/workflows/application/context' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-user-1', +} +const workflow = { id: 'workflow-1', workspaceId: 'workspace-1', archivedAt: null } + +describe('workflow application contexts', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.loadWorkspace.mockResolvedValue(workspace) + }) + + it('uses the canonical loader for workspace-scoped operations', async () => { + await expect(resolveActiveWorkspaceApplicationContext('workspace-1')).resolves.toBe(workspace) + expect(mocks.loadWorkspace).toHaveBeenCalledWith('workspace-1') + }) + + it('derives workflow authorization from its canonical active workspace', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { workflowId: 'workflow-1', workflow, workspaceId: 'workspace-1' }, + ]) + + await expect( + resolveActiveWorkflowApplicationContext({ + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + }) + ).resolves.toEqual({ ...workspace, workflowId: 'workflow-1', workflow }) + expect(mocks.loadWorkspace).toHaveBeenCalledWith('workspace-1') + }) + + it('conceals an asserted workspace mismatch before loading workspace policy', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { workflowId: 'workflow-1', workflow, workspaceId: 'workspace-1' }, + ]) + + await expect( + resolveActiveWorkflowApplicationContext({ + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-2', + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Workflow not found' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) + + it('conceals an inactive canonical workspace as workflow absence', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { workflowId: 'workflow-1', workflow, workspaceId: 'workspace-1' }, + ]) + mocks.loadWorkspace.mockResolvedValueOnce(null) + + await expect( + resolveActiveWorkflowApplicationContext({ workflowId: 'workflow-1' }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Workflow not found' }) + }) + + it('propagates canonical workspace database failures', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { workflowId: 'workflow-1', workflow, workspaceId: 'workspace-1' }, + ]) + const failure = new Error('workspace database unavailable') + mocks.loadWorkspace.mockRejectedValueOnce(failure) + + await expect( + resolveActiveWorkflowApplicationContext({ workflowId: 'workflow-1' }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/workflows/application/context.ts b/apps/sim/lib/workflows/application/context.ts index 27bbdab0bed..49b7c657468 100644 --- a/apps/sim/lib/workflows/application/context.ts +++ b/apps/sim/lib/workflows/application/context.ts @@ -1,15 +1,13 @@ import { db } from '@sim/db' -import { - pausedExecutions, - resumeQueue, - workflow, - workflowExecutionLogs, - workspace, -} from '@sim/db/schema' +import { pausedExecutions, resumeQueue, workflow, workflowExecutionLogs } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' import { getJobQueue } from '@/lib/core/async-jobs' import { OrchestrationError } from '@/lib/core/orchestration/types' import { WORKFLOW_EXECUTION_JOB_ID_PREFIX } from '@/lib/workflows/executor/execution-job-ids' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' export interface ActiveWorkflowApplicationContext { workflowId: string @@ -20,13 +18,6 @@ export interface ActiveWorkflowApplicationContext { billedAccountUserId: string } -export interface ActiveWorkspaceApplicationContext { - workspaceId: string - workspaceOrganizationId: string | null - allowPersonalApiKeys: boolean - billedAccountUserId: string -} - export interface ActiveWorkflowRunApplicationContext extends ActiveWorkflowApplicationContext { runId: string } @@ -34,17 +25,7 @@ export interface ActiveWorkflowRunApplicationContext extends ActiveWorkflowAppli export async function resolveActiveWorkspaceApplicationContext( workspaceId: string ): Promise<ActiveWorkspaceApplicationContext> { - const [context] = await db - .select({ - workspaceId: workspace.id, - workspaceOrganizationId: workspace.organizationId, - allowPersonalApiKeys: workspace.allowPersonalApiKeys, - billedAccountUserId: workspace.billedAccountUserId, - }) - .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - .limit(1) - + const context = await loadActiveWorkspaceApplicationContext(workspaceId) if (!context) throw new OrchestrationError('not_found', 'Workspace not found') return context } @@ -53,33 +34,28 @@ export async function resolveActiveWorkflowApplicationContext(input: { workflowId: string assertedWorkspaceId?: string }): Promise<ActiveWorkflowApplicationContext> { - const [context] = await db + const [canonicalWorkflow] = await db .select({ workflowId: workflow.id, workflow, - workspaceId: workspace.id, - workspaceOrganizationId: workspace.organizationId, - allowPersonalApiKeys: workspace.allowPersonalApiKeys, - billedAccountUserId: workspace.billedAccountUserId, + workspaceId: workflow.workspaceId, }) .from(workflow) - .innerJoin(workspace, eq(workflow.workspaceId, workspace.id)) - .where( - and( - eq(workflow.id, input.workflowId), - isNull(workflow.archivedAt), - isNull(workspace.archivedAt) - ) - ) + .where(and(eq(workflow.id, input.workflowId), isNull(workflow.archivedAt))) .limit(1) if ( - !context || - (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== context.workspaceId) + !canonicalWorkflow?.workspaceId || + (input.assertedWorkspaceId !== undefined && + input.assertedWorkspaceId !== canonicalWorkflow.workspaceId) ) { throw new OrchestrationError('not_found', 'Workflow not found') } - return context + const workspaceContext = await loadActiveWorkspaceApplicationContext( + canonicalWorkflow.workspaceId + ) + if (!workspaceContext) throw new OrchestrationError('not_found', 'Workflow not found') + return { ...workspaceContext, ...canonicalWorkflow, workspaceId: workspaceContext.workspaceId } } async function resolveCanonicalRunWorkflowId(runId: string): Promise<string | null> { diff --git a/apps/sim/lib/workflows/application/import-export.test.ts b/apps/sim/lib/workflows/application/import-export.test.ts index 91c03bf3e83..49ba3869074 100644 --- a/apps/sim/lib/workflows/application/import-export.test.ts +++ b/apps/sim/lib/workflows/application/import-export.test.ts @@ -46,6 +46,7 @@ vi.mock('@/lib/workflows/operations/export-workflow', () => ({ buildWorkflowExportPayload: mocks.buildExport, })) +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { exportWorkflow, importWorkflow } from '@/lib/workflows/application/import-export' import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error' @@ -183,7 +184,9 @@ describe('workflow import and export application operations', () => { expect(mocks.resolveWorkflow).toHaveBeenCalledWith({ workflowId: 'workflow-1' }) expect(mocks.buildExport).toHaveBeenCalledWith(workflowRecord) - expect(mocks.loadIndex).toHaveBeenCalledWith('ws-1', 'workflow') + expect(mocks.loadIndex).toHaveBeenCalledWith('ws-1', 'workflow', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) expect(mocks.folderLock).not.toHaveBeenCalled() expect(result).toEqual({ payload: exportPayload, folderPath: '/Reports' }) expect(mocks.recordAudit).toHaveBeenCalledWith( diff --git a/apps/sim/lib/workflows/application/import-export.ts b/apps/sim/lib/workflows/application/import-export.ts index 88692f71514..4516f02ce70 100644 --- a/apps/sim/lib/workflows/application/import-export.ts +++ b/apps/sim/lib/workflows/application/import-export.ts @@ -1,9 +1,9 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { resolvePrincipalAttribution } from '@sim/auth/principal' -import type { V1WorkflowExportPayload } from '@/lib/api/contracts/v1/workflows' import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' 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 { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { @@ -16,7 +16,10 @@ import { workflowFolderPathForId, } from '@/lib/workflows/application/workflow-folders' import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error' -import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow' +import { + buildWorkflowExportPayload, + type WorkflowExportPayload, +} from '@/lib/workflows/operations/export-workflow' import { type ImportedWorkflow, importWorkflowIntoWorkspaceTransition, @@ -40,7 +43,7 @@ export interface ExportWorkflowInput { } export interface ExportWorkflowResult { - payload: V1WorkflowExportPayload + payload: WorkflowExportPayload folderPath: string } @@ -104,7 +107,12 @@ export const exportWorkflow = defineAuthorizedWorkflowUseCase({ async execute({ context }): Promise<ExportWorkflowResult> { const payload = await buildWorkflowExportPayload(context.workflow) if (!payload) throw new OrchestrationError('not_found', 'Workflow state not found') - const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const folderIndex = await loadActiveFolderPathIndex( + context.workspaceId, + 'workflow', + undefined, + { maxRows: MAX_FOLDERS_PER_WORKSPACE } + ) return { payload, folderPath: workflowFolderPathForId(folderIndex, context.workflow.folderId), diff --git a/apps/sim/lib/workflows/application/list-workflows.ts b/apps/sim/lib/workflows/application/list-workflows.ts index f278c9317d5..f00ade5e38b 100644 --- a/apps/sim/lib/workflows/application/list-workflows.ts +++ b/apps/sim/lib/workflows/application/list-workflows.ts @@ -1,6 +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 { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' @@ -31,7 +32,12 @@ export const listWorkflows = defineAuthorizedWorkflowUseCase({ resolveContext: ({ input }: { input: ListWorkflowsInput }) => resolveActiveWorkspaceApplicationContext(input.workspaceId), async execute({ principal, input, context }) { - const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const folderIndex = await loadActiveFolderPathIndex( + context.workspaceId, + 'workflow', + undefined, + { maxRows: MAX_FOLDERS_PER_WORKSPACE } + ) const folderId = input.folderPath === undefined ? undefined diff --git a/apps/sim/lib/workflows/application/read-workflow.ts b/apps/sim/lib/workflows/application/read-workflow.ts index 366ba20b2e0..984fd49a782 100644 --- a/apps/sim/lib/workflows/application/read-workflow.ts +++ b/apps/sim/lib/workflows/application/read-workflow.ts @@ -1,6 +1,7 @@ import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' @@ -30,7 +31,12 @@ export const readWorkflow = defineAuthorizedWorkflowUseCase({ if (!workflow || workflow.archivedAt || workflow.workspaceId !== context.workspaceId) { throw new OrchestrationError('not_found', 'Workflow not found') } - const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const folderIndex = await loadActiveFolderPathIndex( + context.workspaceId, + 'workflow', + undefined, + { maxRows: MAX_FOLDERS_PER_WORKSPACE } + ) const inputs = extractInputFieldsFromBlocks(snapshot.normalizedData?.blocks ?? {}) logger.info('Read workflow', { workspaceId: context.workspaceId, diff --git a/apps/sim/lib/workflows/application/update-workflow.ts b/apps/sim/lib/workflows/application/update-workflow.ts index bea4570979e..2760ff20c62 100644 --- a/apps/sim/lib/workflows/application/update-workflow.ts +++ b/apps/sim/lib/workflows/application/update-workflow.ts @@ -7,6 +7,7 @@ import { WorkflowLockedError, } from '@sim/platform-authz/workflow' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' @@ -68,7 +69,10 @@ export const updateWorkflow = defineAuthorizedWorkflowUseCase({ if (!transition.workflow) throw new Error('Successful workflow update returned no workflow') const folderIndex = - resolution?.index ?? (await loadActiveFolderPathIndex(context.workspaceId, 'workflow')) + resolution?.index ?? + (await loadActiveFolderPathIndex(context.workspaceId, 'workflow', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + })) logger.info('Updated workflow', { workspaceId: context.workspaceId, workflowId: context.workflowId, diff --git a/apps/sim/lib/workflows/application/workflow-folders.test.ts b/apps/sim/lib/workflows/application/workflow-folders.test.ts index c779babfd01..b99d0a0715b 100644 --- a/apps/sim/lib/workflows/application/workflow-folders.test.ts +++ b/apps/sim/lib/workflows/application/workflow-folders.test.ts @@ -3,6 +3,7 @@ */ import type { Principal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' const mocks = vi.hoisted(() => ({ resolveContext: vi.fn(), @@ -119,11 +120,30 @@ describe('workflow folder application operations', () => { workspaceId: 'ws-1', userId: principal.kind === 'workspace_api_key' ? 'owner-1' : 'user-1', path: '/Reports', + maxFolderRows: MAX_FOLDERS_PER_WORKSPACE, }) expect(mocks.recordAudit).toHaveBeenCalledOnce() } ) + it('bounds both the path index and listed folder rows', async () => { + mocks.listRows.mockResolvedValueOnce([folder]) + + await listWorkflowFolders.execute({ + principal: principals[0], + input: { workspaceId: 'ws-1', sortBy: 'name', sortOrder: 'asc' }, + }) + + expect(mocks.loadIndex).toHaveBeenCalledWith('ws-1', 'workflow', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + expect(mocks.listRows).toHaveBeenCalledWith( + 'ws-1', + 'workflow', + expect.objectContaining({ maxRows: MAX_FOLDERS_PER_WORKSPACE }) + ) + }) + it('rejects a workspace key outside the canonical workspace before mutation', async () => { await expect( createWorkflowFolder.execute({ diff --git a/apps/sim/lib/workflows/application/workflow-folders.ts b/apps/sim/lib/workflows/application/workflow-folders.ts index c8e71faadf7..ae1bd746e89 100644 --- a/apps/sim/lib/workflows/application/workflow-folders.ts +++ b/apps/sim/lib/workflows/application/workflow-folders.ts @@ -3,6 +3,7 @@ import { resolvePrincipalAttribution } from '@sim/auth/principal' import type { folder } from '@sim/db/schema' import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { withFolderTreeLock } from '@/lib/folders/locks' import { createFolderAtPathTransition, @@ -84,7 +85,9 @@ export async function resolveWorkflowFolderPath( path: string ): Promise<{ folderId: string | null; index: WorkflowFolderIndex }> { const resolution = await withFolderTreeLock(workspaceId, 'workflow', async (tx) => { - const index = await loadActiveFolderPathIndex(workspaceId, 'workflow', tx) + const index = await loadActiveFolderPathIndex(workspaceId, 'workflow', tx, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) const folderId = resolveFolderPathFromIndex(index, path) return folderId === undefined ? { found: false as const } @@ -109,7 +112,9 @@ export const listWorkflowFolders = defineAuthorizedWorkflowUseCase({ resolveContext: ({ input }: { input: ListWorkflowFoldersInput }) => resolveActiveWorkspaceApplicationContext(input.workspaceId), async execute({ input, context }): Promise<ListWorkflowFoldersResult> { - const index = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const index = await loadActiveFolderPathIndex(context.workspaceId, 'workflow', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) const parentId = input.parentPath === undefined ? undefined @@ -122,6 +127,7 @@ export const listWorkflowFolders = defineAuthorizedWorkflowUseCase({ search: input.search, sortBy: input.sortBy, sortOrder: input.sortOrder, + maxRows: MAX_FOLDERS_PER_WORKSPACE, }) return { folders, index } }, @@ -140,9 +146,12 @@ export const createWorkflowFolder = defineAuthorizedWorkflowUseCase({ workspaceId: context.workspaceId, userId: attribution.attributedUserId, path: input.path, + maxFolderRows: MAX_FOLDERS_PER_WORKSPACE, }) if (!result.success || !result.folder || !result.path) throwFolderMutationFailure(result) - const index = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const index = await loadActiveFolderPathIndex(context.workspaceId, 'workflow', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) return { folder: result.folder, index } }, projectAudit({ input, result }) { @@ -171,9 +180,12 @@ export const relocateWorkflowFolder = defineAuthorizedWorkflowUseCase({ userId: attribution.attributedUserId, path: input.path, destinationPath: input.destinationPath, + maxFolderRows: MAX_FOLDERS_PER_WORKSPACE, }) if (!result.success || !result.folder || !result.path) throwFolderMutationFailure(result) - const index = await loadActiveFolderPathIndex(context.workspaceId, 'workflow') + const index = await loadActiveFolderPathIndex(context.workspaceId, 'workflow', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) return { folder: result.folder, index } }, projectAudit({ input, result }) { @@ -206,6 +218,7 @@ export const deleteWorkflowFolder = defineAuthorizedWorkflowUseCase({ userId: attribution.attributedUserId, path: input.path, recursive: input.recursive, + maxFolderRows: MAX_FOLDERS_PER_WORKSPACE, }) if ( !result.success || diff --git a/apps/sim/lib/workflows/operations/export-workflow.ts b/apps/sim/lib/workflows/operations/export-workflow.ts index d6e26c6bff4..b347d9b3289 100644 --- a/apps/sim/lib/workflows/operations/export-workflow.ts +++ b/apps/sim/lib/workflows/operations/export-workflow.ts @@ -1,7 +1,9 @@ import type { Edge } from 'reactflow' -import type { V1WorkflowExportPayload } from '@/lib/api/contracts/v1/workflows' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' -import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' +import { + type ExportWorkflowState, + sanitizeForExport, +} from '@/lib/workflows/sanitization/json-sanitizer' import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' /** @@ -39,7 +41,38 @@ export interface ExportableWorkflowRecord { variables: unknown } -type ExportedEdge = V1WorkflowExportPayload['state']['edges'][number] +export interface WorkflowExportEdge { + id: string + source: string + target: string + sourceHandle: string | undefined + targetHandle: string | undefined + type?: string + animated?: boolean + style?: Record<string, unknown> + data?: Record<string, unknown> + label?: string + labelStyle?: Record<string, unknown> + labelShowBg?: boolean + labelBgStyle?: Record<string, unknown> + labelBgPadding?: [number, number] + labelBgBorderRadius?: number + markerStart?: string + markerEnd?: string +} + +export interface WorkflowExportPayload { + version: '1.0' + exportedAt: string + workflow: { + id: string + name: string + description: string | null + workspaceId: string | null + folderId: string | null + } + state: Omit<ExportWorkflowState['state'], 'edges'> & { edges: WorkflowExportEdge[] } +} /** * Projects a persisted ReactFlow edge onto the wire shape declared by the @@ -50,7 +83,7 @@ type ExportedEdge = V1WorkflowExportPayload['state']['edges'][number] * object. Non-serializable values in those slots are dropped rather than * emitted as `{}`. */ -function toExportedEdge(edge: Edge): ExportedEdge { +function toExportedEdge(edge: Edge): WorkflowExportEdge { return { id: edge.id, source: edge.source, @@ -79,7 +112,7 @@ function toExportedEdge(edge: Edge): ExportedEdge { */ export async function buildWorkflowExportPayload( workflowData: ExportableWorkflowRecord -): Promise<V1WorkflowExportPayload | null> { +): Promise<WorkflowExportPayload | null> { const normalizedData = await loadWorkflowFromNormalizedTables(workflowData.id) if (!normalizedData) return null diff --git a/apps/sim/lib/workflows/queries.ts b/apps/sim/lib/workflows/queries.ts index 2d543003747..e53aae0f052 100644 --- a/apps/sim/lib/workflows/queries.ts +++ b/apps/sim/lib/workflows/queries.ts @@ -8,6 +8,7 @@ import { type KeysetKey, keysetAfter, keysetColumns, + type ListSortOrder, listOrderBy, numberKey, searchFilter, @@ -20,7 +21,7 @@ import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' type WorkflowListScope = 'active' | 'archived' | 'all' export type WorkflowSortBy = 'position' | 'name' | 'createdAt' | 'updatedAt' | 'runCount' -export type WorkflowSortOrder = 'asc' | 'desc' +export type WorkflowSortOrder = ListSortOrder export interface WorkspaceWorkflowListRow { id: string diff --git a/apps/sim/lib/workspace-files/api/route-policies.ts b/apps/sim/lib/workspace-files/api/route-policies.ts index 6ec9c725b04..d8d2d4cdb91 100644 --- a/apps/sim/lib/workspace-files/api/route-policies.ts +++ b/apps/sim/lib/workspace-files/api/route-policies.ts @@ -1,10 +1,10 @@ import { createInternalSessionOrExecutorAuth, + createV2ResourceConcealmentPolicy, type V2ErrorPolicy, v2OrchestrationErrorPolicy, } from '@/lib/api/server/routes' import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' -import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' export const internalSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({ audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, @@ -16,12 +16,7 @@ export const internalSessionOrExecutorAuth = createInternalSessionOrExecutorAuth export const v2FileErrorPolicies = { default: v2OrchestrationErrorPolicy, - concealResourceAuthorization: { - render(error) { - const response = v2CaughtOrchestrationError(error) - if (!response) return null - if (response.status === 403) return v2Error('NOT_FOUND', 'File not found') - return response - }, - } satisfies V2ErrorPolicy, + concealResourceAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'File not found', + }) satisfies V2ErrorPolicy, } as const diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index f5d7f1c0d7d..ce93312ff1a 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -172,6 +172,15 @@ export { } from './terminal-console.mock' // URL mocks export { LOCALHOST_HOSTNAMES_MOCK, resetUrlsMock, urlsMock, urlsMockFns } from './urls.mock' +export { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from './v2-route.mock' // Workflow authz package mocks (for @sim/platform-authz/workflow) export { workflowAuthzMock, workflowAuthzMockFns } from './workflow-authz.mock' // Workflows API utils mocks (for @/app/api/workflows/utils) diff --git a/packages/testing/src/mocks/v2-route.mock.ts b/packages/testing/src/mocks/v2-route.mock.ts new file mode 100644 index 00000000000..babe7d3a479 --- /dev/null +++ b/packages/testing/src/mocks/v2-route.mock.ts @@ -0,0 +1,42 @@ +import { vi } from 'vitest' + +export class MockV2ApiKeyUnauthenticatedError extends Error { + constructor(message = 'Invalid API key') { + super(message) + this.name = 'V2ApiKeyUnauthenticatedError' + } +} + +export const v2RouteMocks = { + authenticate: vi.fn(), + gate: vi.fn(), + operationRate: vi.fn(), + preauthRate: vi.fn(), +} + +export const v2ApiKeyAuthModuleMock = { + authenticateV2ApiKey: v2RouteMocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, +} + +export const v2RateLimiterModuleMock = { + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = v2RouteMocks.preauthRate + checkRateLimitDirectOrThrow = v2RouteMocks.operationRate + }, +} + +export const v2GateModuleMock = { v2ApiGateError: v2RouteMocks.gate } + +export const V2_PREAUTH_RATE_LIMIT_ALLOWED = { + allowed: true, + remaining: 599, + resetAt: new Date('2026-01-01T01:00:00.000Z'), +} as const + +export const V2_OPERATION_RATE_LIMIT_ALLOWED = { + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00.000Z'), +} as const From 8c1f927eb68d020d3dfc09440940d19f3e802b86 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sun, 9 Aug 2026 22:09:12 -0700 Subject: [PATCH 118/159] fix(knowledge): enforce trusted workspace scope (#6452) * fix(knowledge): enforce trusted workspace scope * refactor(knowledge): declare v2 body lifecycle * finish knowledge application migration * refactor(knowledge): compose copilot batch commands * fix(knowledge): parse connector query flags * fix(knowledge): finalize partial batch effects * fix(knowledge): align merged application boundaries * fix(knowledge): close application boundary review gaps * style(knowledge): satisfy branch biome checks * fix(knowledge): page connector documents in editor --- .../api/auth/oauth/microsoft/file/route.ts | 2 +- .../api/auth/oauth/microsoft/files/route.ts | 2 +- .../app/api/auth/oauth/token/route.test.ts | 2 +- apps/sim/app/api/auth/oauth/token/route.ts | 4 +- apps/sim/app/api/auth/oauth/utils.test.ts | 12 +- .../api/auth/oauth/wealthbox/item/route.ts | 2 +- .../api/auth/oauth/wealthbox/items/route.ts | 2 +- .../auth/oauth2/callback/instagram/route.ts | 2 +- .../api/auth/oauth2/shopify/store/route.ts | 2 +- apps/sim/app/api/auth/trello/store/route.ts | 2 +- .../cron/renew-subscriptions/route.test.ts | 2 +- .../app/api/cron/renew-subscriptions/route.ts | 2 +- .../[connectorId]/documents/route.test.ts | 200 --- .../[connectorId]/documents/route.ts | 307 +--- .../connectors/[connectorId]/route.test.ts | 249 ---- .../[id]/connectors/[connectorId]/route.ts | 314 +--- .../[connectorId]/sync/route.test.ts | 180 --- .../connectors/[connectorId]/sync/route.ts | 99 +- .../knowledge/[id]/connectors/route.test.ts | 182 --- .../api/knowledge/[id]/connectors/route.ts | 205 +-- .../[documentId]/chunks/[chunkId]/route.ts | 370 ++--- .../documents/[documentId]/chunks/route.ts | 499 ++----- .../[id]/documents/[documentId]/route.test.ts | 542 ------- .../[id]/documents/[documentId]/route.ts | 365 ++--- .../[documentId]/tag-definitions/route.ts | 292 ++-- .../knowledge/[id]/documents/route.test.ts | 595 -------- .../app/api/knowledge/[id]/documents/route.ts | 539 ++----- .../uploads/[uploadId]/complete/route.ts | 115 +- .../uploads/[uploadId]/parts/route.ts | 68 +- .../documents/uploads/[uploadId]/route.ts | 64 +- .../documents/uploads/control-routes.test.ts | 182 --- .../[id]/documents/uploads/route.test.ts | 104 -- .../knowledge/[id]/documents/uploads/route.ts | 84 +- .../[id]/documents/uploads/utils.test.ts | 36 - .../knowledge/[id]/documents/uploads/utils.ts | 39 - .../[id]/documents/upsert/route.test.ts | 114 -- .../knowledge/[id]/documents/upsert/route.ts | 378 ++--- .../[id]/next-available-slot/route.ts | 94 +- .../app/api/knowledge/[id]/restore/route.ts | 95 +- apps/sim/app/api/knowledge/[id]/route.test.ts | 454 ------ apps/sim/app/api/knowledge/[id]/route.ts | 238 +-- .../[id]/tag-definitions/[tagId]/route.ts | 76 +- .../knowledge/[id]/tag-definitions/route.ts | 155 +- .../app/api/knowledge/[id]/tag-usage/route.ts | 69 +- .../app/api/knowledge/migrated-routes.test.ts | 552 +++++++ apps/sim/app/api/knowledge/route.test.ts | 356 ++--- apps/sim/app/api/knowledge/route.ts | 146 +- .../app/api/knowledge/search/route.test.ts | 1278 ----------------- apps/sim/app/api/knowledge/search/route.ts | 727 +--------- .../app/api/knowledge/secret-provenance.ts | 50 +- apps/sim/app/api/providers/route.test.ts | 2 +- apps/sim/app/api/providers/route.ts | 4 +- .../sim/app/api/tools/airtable/bases/route.ts | 2 +- .../app/api/tools/airtable/tables/route.ts | 2 +- .../app/api/tools/asana/workspaces/route.ts | 2 +- apps/sim/app/api/tools/attio/lists/route.ts | 2 +- apps/sim/app/api/tools/attio/objects/route.ts | 2 +- .../app/api/tools/calcom/event-types/route.ts | 2 +- .../app/api/tools/calcom/schedules/route.ts | 2 +- .../app/api/tools/clickup/folders/route.ts | 2 +- apps/sim/app/api/tools/clickup/lists/route.ts | 2 +- .../sim/app/api/tools/clickup/spaces/route.ts | 2 +- .../app/api/tools/clickup/workspaces/route.ts | 2 +- .../tools/confluence/selector-spaces/route.ts | 4 +- apps/sim/app/api/tools/drive/file/route.ts | 5 +- apps/sim/app/api/tools/drive/files/route.ts | 5 +- apps/sim/app/api/tools/gmail/label/route.ts | 5 +- apps/sim/app/api/tools/gmail/labels/route.ts | 4 +- .../tools/google_bigquery/datasets/route.ts | 5 +- .../api/tools/google_bigquery/tables/route.ts | 5 +- .../tools/google_calendar/calendars/route.ts | 5 +- .../api/tools/google_sheets/sheets/route.ts | 5 +- .../tools/google_tasks/task-lists/route.ts | 5 +- apps/sim/app/api/tools/hubspot/lists/route.ts | 2 +- .../sim/app/api/tools/hubspot/owners/route.ts | 2 +- .../app/api/tools/hubspot/pipelines/route.ts | 2 +- .../app/api/tools/hubspot/properties/route.ts | 2 +- .../tools/jsm/selector-requesttypes/route.ts | 2 +- .../tools/jsm/selector-servicedesks/route.ts | 2 +- .../app/api/tools/linear/projects/route.ts | 2 +- apps/sim/app/api/tools/linear/teams/route.ts | 2 +- .../app/api/tools/managed-agent/list/route.ts | 2 +- .../tools/microsoft-teams/channels/route.ts | 2 +- .../api/tools/microsoft-teams/chats/route.ts | 2 +- .../api/tools/microsoft-teams/teams/route.ts | 2 +- .../api/tools/microsoft_excel/drives/route.ts | 2 +- .../api/tools/microsoft_excel/sheets/route.ts | 2 +- .../tools/microsoft_planner/plans/route.ts | 2 +- .../tools/microsoft_planner/tasks/route.ts | 2 +- apps/sim/app/api/tools/monday/boards/route.ts | 2 +- apps/sim/app/api/tools/monday/groups/route.ts | 2 +- .../app/api/tools/notion/databases/route.ts | 2 +- apps/sim/app/api/tools/notion/pages/route.ts | 2 +- .../sim/app/api/tools/onedrive/files/route.ts | 2 +- .../app/api/tools/onedrive/folder/route.ts | 2 +- .../app/api/tools/onedrive/folders/route.ts | 2 +- .../app/api/tools/outlook/calendars/route.ts | 2 +- .../app/api/tools/outlook/folders/route.ts | 2 +- .../api/tools/pipedrive/pipelines/route.ts | 2 +- .../app/api/tools/sharepoint/lists/route.ts | 2 +- .../app/api/tools/sharepoint/site/route.ts | 2 +- .../app/api/tools/sharepoint/sites/route.ts | 2 +- .../sim/app/api/tools/slack/channels/route.ts | 2 +- apps/sim/app/api/tools/slack/users/route.ts | 2 +- apps/sim/app/api/tools/trello/boards/route.ts | 2 +- .../sim/app/api/tools/wealthbox/item/route.ts | 2 +- .../app/api/tools/wealthbox/items/route.ts | 2 +- .../api/tools/webflow/collections/route.ts | 2 +- apps/sim/app/api/tools/webflow/items/route.ts | 2 +- apps/sim/app/api/tools/webflow/sites/route.ts | 2 +- .../tools/zoho_desk/selector-credential.ts | 2 +- apps/sim/app/api/tools/zoom/meetings/route.ts | 2 +- .../v2/knowledge/[id]/documents/route.test.ts | 175 ++- .../api/v2/knowledge/[id]/documents/route.ts | 238 ++- .../app/api/v2/knowledge/search/route.test.ts | 91 +- apps/sim/app/api/v2/knowledge/search/route.ts | 88 +- .../slack/custom/[credentialId]/route.test.ts | 2 +- .../slack/custom/[credentialId]/route.ts | 2 +- .../edit-connector-modal.tsx | 35 +- apps/sim/background/webhook-execution.test.ts | 2 +- apps/sim/background/webhook-execution.ts | 2 +- .../evaluator/evaluator-handler.test.ts | 2 +- .../handlers/router/router-handler.test.ts | 2 +- .../executor/utils/vertex-credential.test.ts | 2 +- apps/sim/executor/utils/vertex-credential.ts | 2 +- apps/sim/hooks/queries/kb/connectors.test.ts | 89 ++ apps/sim/hooks/queries/kb/connectors.ts | 36 +- apps/sim/hooks/queries/kb/knowledge.ts | 4 +- .../lib/api/contracts/knowledge/connectors.ts | 21 +- .../lib/api/contracts/knowledge/documents.ts | 39 +- .../sim/lib/api/contracts/knowledge/search.ts | 61 + apps/sim/lib/api/contracts/knowledge/tags.ts | 1 + .../contracts/knowledge/upload-sessions.ts | 166 ++- apps/sim/lib/api/contracts/v2/knowledge.ts | 23 +- apps/sim/lib/api/server/routes/index.ts | 1 + .../server/routes/internal-json-route.test.ts | 175 ++- .../api/server/routes/internal-json-route.ts | 74 +- .../routes/v2-body-lifecycle-route.test.ts | 298 ++++ .../server/routes/v2-body-lifecycle-route.ts | 174 +++ .../application/execute-knowledge-use-case.ts | 29 + .../lib/copilot/chat/process-contents.test.ts | 75 + apps/sim/lib/copilot/chat/process-contents.ts | 54 +- .../copilot/tool-executor/executor.test.ts | 48 +- .../sim/lib/copilot/tool-executor/executor.ts | 20 +- .../server/knowledge/knowledge-base.test.ts | 563 ++++++-- .../tools/server/knowledge/knowledge-base.ts | 880 +++++------- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 136 +- .../lib/credentials/service-account-secret.ts | 2 +- .../lib/guardrails/validate_hallucination.ts | 2 +- apps/sim/lib/knowledge/api/internal-route.ts | 389 +++++ .../lib/knowledge/api/route-policies.test.ts | 52 + apps/sim/lib/knowledge/api/route-policies.ts | 87 ++ .../application/add-workspace-files.test.ts | 342 +++++ .../application/add-workspace-files.ts | 270 ++++ .../application/authorization.test.ts | 21 + .../knowledge/application/authorization.ts | 3 + .../lib/knowledge/application/batch-policy.ts | 47 + apps/sim/lib/knowledge/application/chunks.ts | 260 ++++ .../knowledge/application/connectors.test.ts | 476 ++++++ .../lib/knowledge/application/connectors.ts | 533 +++++++ .../knowledge/application/contexts.test.ts | 101 +- .../sim/lib/knowledge/application/contexts.ts | 127 +- .../knowledge/application/documents.test.ts | 339 +++++ .../lib/knowledge/application/documents.ts | 558 ++++++- .../lib/knowledge/application/folders.test.ts | 13 +- apps/sim/lib/knowledge/application/folders.ts | 4 - .../application/knowledge-bases.test.ts | 398 +++++ .../knowledge/application/knowledge-bases.ts | 475 +++++- .../knowledge/application/operations.test.ts | 60 +- .../lib/knowledge/application/operations.ts | 209 ++- .../lib/knowledge/application/search.test.ts | 2 + apps/sim/lib/knowledge/application/search.ts | 473 ++++-- .../lib/knowledge/application/tags.test.ts | 203 +++ apps/sim/lib/knowledge/application/tags.ts | 311 ++++ .../knowledge/application/upload-sessions.ts | 12 +- apps/sim/lib/knowledge/connectors/service.ts | 34 + .../knowledge/connectors/sync-engine.test.ts | 2 +- .../lib/knowledge/connectors/sync-engine.ts | 2 +- apps/sim/lib/knowledge/constants.ts | 7 + apps/sim/lib/knowledge/documents/service.ts | 24 + .../orchestration/connectors.test.ts | 56 + .../lib/knowledge/orchestration/connectors.ts | 250 ++-- .../knowledge/orchestration/documents.test.ts | 47 +- .../lib/knowledge/orchestration/documents.ts | 78 +- .../orchestration/knowledge-bases.test.ts | 4 +- .../orchestration/knowledge-bases.ts | 7 +- .../tags/secret-provenance-delete.test.ts | 9 + apps/sim/lib/knowledge/tags/service.ts | 8 +- apps/sim/lib/knowledge/upload-metadata.ts | 28 + .../oauth/credential-service.ts} | 4 +- apps/sim/lib/uploads/client/session-upload.ts | 17 +- apps/sim/lib/webhooks/deploy.test.ts | 2 +- apps/sim/lib/webhooks/deploy.ts | 10 +- apps/sim/lib/webhooks/polling/utils.test.ts | 8 +- apps/sim/lib/webhooks/polling/utils.ts | 6 +- .../webhooks/provider-subscription-utils.ts | 2 +- apps/sim/lib/webhooks/providers/airtable.ts | 10 +- apps/sim/lib/webhooks/providers/attio.ts | 2 +- .../lib/webhooks/providers/clickup.test.ts | 2 +- apps/sim/lib/webhooks/providers/clickup.ts | 2 +- apps/sim/lib/webhooks/providers/gmail.ts | 2 +- .../providers/microsoft-teams.test.ts | 2 +- .../lib/webhooks/providers/microsoft-teams.ts | 2 +- apps/sim/lib/webhooks/providers/monday.ts | 2 +- apps/sim/lib/webhooks/providers/outlook.ts | 2 +- apps/sim/lib/webhooks/providers/slack.ts | 10 +- apps/sim/lib/webhooks/providers/webflow.ts | 2 +- .../lib/webhooks/providers/zoho-desk.test.ts | 4 +- apps/sim/lib/webhooks/providers/zoho-desk.ts | 2 +- .../application/workspace-context.test.ts | 25 +- .../application/workspace-context.ts | 21 +- apps/sim/tools/index.test.ts | 76 + apps/sim/tools/index.ts | 62 +- apps/sim/tools/knowledge/create_document.ts | 1 + apps/sim/tools/knowledge/delete_chunk.ts | 1 + apps/sim/tools/knowledge/delete_document.ts | 1 + apps/sim/tools/knowledge/get_connector.ts | 1 + apps/sim/tools/knowledge/get_document.ts | 1 + apps/sim/tools/knowledge/list_chunks.ts | 1 + apps/sim/tools/knowledge/list_connectors.ts | 1 + apps/sim/tools/knowledge/list_documents.ts | 1 + apps/sim/tools/knowledge/list_tags.ts | 1 + apps/sim/tools/knowledge/search.ts | 1 + apps/sim/tools/knowledge/trigger_sync.ts | 1 + apps/sim/tools/knowledge/update_chunk.ts | 1 + apps/sim/tools/knowledge/upload_chunk.ts | 1 + apps/sim/tools/knowledge/upsert_document.ts | 1 + apps/sim/tools/types.ts | 2 + 228 files changed, 10922 insertions(+), 9908 deletions(-) delete mode 100644 apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts delete mode 100644 apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts delete mode 100644 apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts delete mode 100644 apps/sim/app/api/knowledge/[id]/connectors/route.test.ts delete mode 100644 apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts delete mode 100644 apps/sim/app/api/knowledge/[id]/documents/route.test.ts delete mode 100644 apps/sim/app/api/knowledge/[id]/documents/uploads/control-routes.test.ts delete mode 100644 apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts delete mode 100644 apps/sim/app/api/knowledge/[id]/documents/uploads/utils.test.ts delete mode 100644 apps/sim/app/api/knowledge/[id]/documents/uploads/utils.ts delete mode 100644 apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts delete mode 100644 apps/sim/app/api/knowledge/[id]/route.test.ts create mode 100644 apps/sim/app/api/knowledge/migrated-routes.test.ts delete mode 100644 apps/sim/app/api/knowledge/search/route.test.ts create mode 100644 apps/sim/hooks/queries/kb/connectors.test.ts create mode 100644 apps/sim/lib/api/server/routes/v2-body-lifecycle-route.test.ts create mode 100644 apps/sim/lib/api/server/routes/v2-body-lifecycle-route.ts create mode 100644 apps/sim/lib/knowledge/api/internal-route.ts create mode 100644 apps/sim/lib/knowledge/api/route-policies.test.ts create mode 100644 apps/sim/lib/knowledge/application/add-workspace-files.test.ts create mode 100644 apps/sim/lib/knowledge/application/add-workspace-files.ts create mode 100644 apps/sim/lib/knowledge/application/batch-policy.ts create mode 100644 apps/sim/lib/knowledge/application/chunks.ts create mode 100644 apps/sim/lib/knowledge/application/connectors.test.ts create mode 100644 apps/sim/lib/knowledge/application/connectors.ts create mode 100644 apps/sim/lib/knowledge/application/tags.test.ts create mode 100644 apps/sim/lib/knowledge/application/tags.ts create mode 100644 apps/sim/lib/knowledge/connectors/service.ts create mode 100644 apps/sim/lib/knowledge/upload-metadata.ts rename apps/sim/{app/api/auth/oauth/utils.ts => lib/oauth/credential-service.ts} (99%) diff --git a/apps/sim/app/api/auth/oauth/microsoft/file/route.ts b/apps/sim/app/api/auth/oauth/microsoft/file/route.ts index 058f007427f..d0bb8a8af7d 100644 --- a/apps/sim/app/api/auth/oauth/microsoft/file/route.ts +++ b/apps/sim/app/api/auth/oauth/microsoft/file/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getCredential, refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts index 7dcd342d662..8b658cd453e 100644 --- a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts +++ b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getCredential, refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils' import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index e1ef6105675..17948c9f52f 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -17,7 +17,7 @@ const { mockAuthorizeCredentialUse, mockResolveServiceAccountToken } = vi.hoiste mockResolveServiceAccountToken: vi.fn(), })) -vi.mock('@/app/api/auth/oauth/utils', () => ({ +vi.mock('@/lib/oauth/credential-service', () => ({ ...authOAuthUtilsMock, resolveServiceAccountToken: mockResolveServiceAccountToken, })) diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index 302898717d0..a58591b3c1d 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -12,14 +12,14 @@ import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' -import { captureServerEvent } from '@/lib/posthog/server' import { getCredential, getOAuthToken, refreshTokenIfNeeded, resolveOAuthAccountId, resolveServiceAccountToken, -} from '@/app/api/auth/oauth/utils' +} from '@/lib/oauth/credential-service' +import { captureServerEvent } from '@/lib/posthog/server' import { extractZohoDeskBaseFromScope } from '@/tools/zoho_desk/host-allowlist' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 4a22a97b8fc..e7cee6bbc33 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -28,17 +28,17 @@ import { db } from '@sim/db' import { __resetCoalesceLocallyForTests } from '@/lib/concurrency/singleflight' import { ZOOM_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/client-credential-accounts/descriptors' import { refreshOAuthToken } from '@/lib/oauth' -import { - ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, - GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, - SLACK_CUSTOM_BOT_PROVIDER_ID, -} from '@/lib/oauth/types' import { getCredential, refreshAccessTokenIfNeeded, refreshTokenIfNeeded, resolveServiceAccountToken, -} from '@/app/api/auth/oauth/utils' +} from '@/lib/oauth/credential-service' +import { + ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + SLACK_CUSTOM_BOT_PROVIDER_ID, +} from '@/lib/oauth/types' const mockDb = db as any const mockRefreshOAuthToken = refreshOAuthToken as any diff --git a/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts b/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts index 9e43c3bc8a0..fa871722f36 100644 --- a/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts +++ b/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts b/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts index 6a31bcf3b9f..f8781057cfe 100644 --- a/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts +++ b/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts index 6b6b9b76a7b..4aea1372f83 100644 --- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -18,13 +18,13 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { safeAccountInsert } from '@/lib/oauth/credential-service' import { parseInstagramLongLivedToken, parseInstagramProfile, parseInstagramShortLivedToken, } from '@/lib/oauth/instagram' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' -import { safeAccountInsert } from '@/app/api/auth/oauth/utils' import { INSTAGRAM_GRAPH_BASE } from '@/tools/instagram/constants' const logger = createLogger('InstagramCallback') diff --git a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts index 4d26c178f5b..182989f917a 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -12,7 +12,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processCredentialDraft } from '@/lib/credentials/draft-processor' -import { safeAccountInsert } from '@/app/api/auth/oauth/utils' +import { safeAccountInsert } from '@/lib/oauth/credential-service' import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' const logger = createLogger('ShopifyStore') diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index 156ed9a65d6..f4fb16feece 100644 --- a/apps/sim/app/api/auth/trello/store/route.ts +++ b/apps/sim/app/api/auth/trello/store/route.ts @@ -9,8 +9,8 @@ import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { safeAccountInsert } from '@/lib/oauth/credential-service' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' -import { safeAccountInsert } from '@/app/api/auth/oauth/utils' const logger = createLogger('TrelloStore') diff --git a/apps/sim/app/api/cron/renew-subscriptions/route.test.ts b/apps/sim/app/api/cron/renew-subscriptions/route.test.ts index 6bdd19602ac..86579f7f037 100644 --- a/apps/sim/app/api/cron/renew-subscriptions/route.test.ts +++ b/apps/sim/app/api/cron/renew-subscriptions/route.test.ts @@ -21,7 +21,7 @@ vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth, })) -vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) +vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock) import { GET } from './route' diff --git a/apps/sim/app/api/cron/renew-subscriptions/route.ts b/apps/sim/app/api/cron/renew-subscriptions/route.ts index 97f00a26ff2..8818d5462b1 100644 --- a/apps/sim/app/api/cron/renew-subscriptions/route.ts +++ b/apps/sim/app/api/cron/renew-subscriptions/route.ts @@ -8,9 +8,9 @@ import { verifyCronAuth } from '@/lib/auth/internal' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { runDetached } from '@/lib/core/utils/background' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate' import { getCredentialOwner, getNotificationUrl } from '@/lib/webhooks/provider-subscription-utils' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('TeamsSubscriptionRenewal') diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts deleted file mode 100644 index b31ec4e5ea1..00000000000 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * @vitest-environment node - */ -import { - auditMock, - createMockRequest, - dbChainMockFns, - hybridAuthMockFns, - knowledgeApiUtilsMock, - knowledgeApiUtilsMockFns, - requestUtilsMockFns, - resetDbChainMock, -} from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const mockCheckAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess -const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess - -vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) -vi.mock('@sim/audit', () => auditMock) - -import { GET, PATCH } from '@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route' - -describe('Connector Documents API Route', () => { - const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' }) - - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-req-id') - }) - - afterAll(() => { - resetDbChainMock() - }) - - describe('GET', () => { - it('returns 401 when unauthenticated', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: false, - userId: null, - }) - - const req = createMockRequest('GET') - const response = await GET(req as never, { params: mockParams }) - - expect(response.status).toBe(401) - }) - - it('returns 404 when connector not found', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - }) - mockCheckAccess.mockResolvedValue({ hasAccess: true }) - dbChainMockFns.limit.mockResolvedValueOnce([]) - - const req = createMockRequest('GET') - const response = await GET(req as never, { params: mockParams }) - - expect(response.status).toBe(404) - }) - - it('returns documents list on success', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - }) - mockCheckAccess.mockResolvedValue({ hasAccess: true }) - - const doc = { id: 'doc-1', filename: 'test.txt', userExcluded: false } - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) - dbChainMockFns.orderBy.mockResolvedValueOnce([doc]) - - const url = 'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents' - const req = createMockRequest('GET', undefined, undefined, url) - const response = await GET(req as never, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.data.documents).toHaveLength(1) - expect(data.data.counts.active).toBe(1) - expect(data.data.counts.excluded).toBe(0) - }) - - it('includes excluded documents when includeExcluded=true', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - }) - mockCheckAccess.mockResolvedValue({ hasAccess: true }) - - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) - dbChainMockFns.orderBy - .mockResolvedValueOnce([{ id: 'doc-1', userExcluded: false }]) - .mockResolvedValueOnce([{ id: 'doc-2', userExcluded: true }]) - - const url = - 'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents?includeExcluded=true' - const req = createMockRequest('GET', undefined, undefined, url) - const response = await GET(req as never, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.data.documents).toHaveLength(2) - expect(data.data.counts.active).toBe(1) - expect(data.data.counts.excluded).toBe(1) - }) - }) - - describe('PATCH', () => { - it('returns 401 when unauthenticated', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: false, - userId: null, - }) - - const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] }) - const response = await PATCH(req as never, { params: mockParams }) - - expect(response.status).toBe(401) - }) - - it('returns 400 for invalid body', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - }) - mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) - - const req = createMockRequest('PATCH', { documentIds: [] }) - const response = await PATCH(req as never, { params: mockParams }) - - expect(response.status).toBe(400) - }) - - it('returns 404 when connector not found', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - }) - mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) - dbChainMockFns.limit.mockResolvedValueOnce([]) - - const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] }) - const response = await PATCH(req as never, { params: mockParams }) - - expect(response.status).toBe(404) - }) - - it('returns success for restore operation', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - userName: 'Test', - userEmail: 'test@test.com', - }) - mockCheckWriteAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, - }) - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) - dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'doc-1' }]) - - const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] }) - const response = await PATCH(req as never, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.data.restoredCount).toBe(1) - }) - - it('returns success for exclude operation', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - userName: 'Test', - userEmail: 'test@test.com', - }) - mockCheckWriteAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, - }) - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) - dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'doc-2' }, { id: 'doc-3' }]) - - const req = createMockRequest('PATCH', { - operation: 'exclude', - documentIds: ['doc-2', 'doc-3'], - }) - const response = await PATCH(req as never, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.data.excludedCount).toBe(2) - expect(data.data.documentIds).toEqual(['doc-2', 'doc-3']) - }) - }) -}) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.ts index 9eebf944c29..68f3fd82248 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.ts @@ -1,247 +1,66 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { document, knowledgeConnector } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, inArray, isNull } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { patchKnowledgeConnectorDocumentsContract } from '@/lib/api/contracts/knowledge' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' - -const logger = createLogger('ConnectorDocumentsAPI') - -type RouteParams = { params: Promise<{ id: string; connectorId: string }> } - -/** - * GET /api/knowledge/[id]/connectors/[connectorId]/documents - * Returns documents for a connector, optionally including user-excluded ones. - */ -export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - const { id: knowledgeBaseId, connectorId } = await params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId) - if (!accessCheck.hasAccess) { - const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401 - return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) - } - - const connectorRows = await db - .select({ id: knowledgeConnector.id }) - .from(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - .limit(1) - - if (connectorRows.length === 0) { - return NextResponse.json({ error: 'Connector not found' }, { status: 404 }) - } - - const includeExcluded = request.nextUrl.searchParams.get('includeExcluded') === 'true' - - const activeDocs = await db - .select({ - id: document.id, - filename: document.filename, - externalId: document.externalId, - sourceUrl: document.sourceUrl, - enabled: document.enabled, - userExcluded: document.userExcluded, - uploadedAt: document.uploadedAt, - processingStatus: document.processingStatus, - }) - .from(document) - .where( - and( - eq(document.connectorId, connectorId), - isNull(document.archivedAt), - isNull(document.deletedAt), - eq(document.userExcluded, false) - ) - ) - .orderBy(document.filename) - - const excludedDocs = includeExcluded - ? await db - .select({ - id: document.id, - filename: document.filename, - externalId: document.externalId, - sourceUrl: document.sourceUrl, - enabled: document.enabled, - userExcluded: document.userExcluded, - uploadedAt: document.uploadedAt, - processingStatus: document.processingStatus, - }) - .from(document) - .where( - and( - eq(document.connectorId, connectorId), - eq(document.userExcluded, true), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) - .orderBy(document.filename) - : [] - - const docs = [...activeDocs, ...excludedDocs] - const activeCount = activeDocs.length - const excludedCount = excludedDocs.length - - return NextResponse.json({ - success: true, - data: { - documents: docs, - counts: { active: activeCount, excluded: excludedCount }, - }, - }) - } catch (error) { - logger.error(`[${requestId}] Error fetching connector documents`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +import { + listKnowledgeConnectorDocumentsContract, + patchKnowledgeConnectorDocumentsContract, +} from '@/lib/api/contracts/knowledge' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { + internalKnowledgeErrorPolicies, + internalKnowledgeSessionOrExecutorAuth, +} from '@/lib/knowledge/api/route-policies' +import { + listKnowledgeConnectorDocuments, + updateKnowledgeConnectorDocuments, +} from '@/lib/knowledge/application/connectors' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: listKnowledgeConnectorDocumentsContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.listConnectorDocuments, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal connector-document list behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.id, + connectorId: params.connectorId, + includeExcluded: query.includeExcluded, + limit: query.limit, + offset: query.offset, + }), + useCase: listKnowledgeConnectorDocuments, + present: ({ documents, counts }) => ({ + success: true as const, + data: { + documents: documents.map((document) => ({ + ...document, + deletedAt: null, + uploadedAt: document.uploadedAt.toISOString(), + })), + counts, + }, + }), }) -/** - * PATCH /api/knowledge/[id]/connectors/[connectorId]/documents - * Restore or exclude connector documents. - */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteParams) => { - const requestId = generateRequestId() - const { id: knowledgeBaseId, connectorId } = await context.params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!writeCheck.hasAccess) { - const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 - return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) - } - - const connectorRows = await db - .select({ id: knowledgeConnector.id }) - .from(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - .limit(1) - - if (connectorRows.length === 0) { - return NextResponse.json({ error: 'Connector not found' }, { status: 404 }) - } - - const parsed = await parseRequest(patchKnowledgeConnectorDocumentsContract, request, context) - if (!parsed.success) return parsed.response - - const { operation, documentIds } = parsed.data.body - - if (operation === 'restore') { - const updated = await db - .update(document) - .set({ userExcluded: false, enabled: true }) - .where( - and( - eq(document.connectorId, connectorId), - inArray(document.id, documentIds), - eq(document.userExcluded, true), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) - .returning({ id: document.id }) - - logger.info(`[${requestId}] Restored ${updated.length} excluded documents`, { connectorId }) - - recordAudit({ - workspaceId: writeCheck.knowledgeBase.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.CONNECTOR_DOCUMENT_RESTORED, - resourceType: AuditResourceType.CONNECTOR, - resourceId: connectorId, - description: `Restored ${updated.length} excluded document(s) for knowledge base "${writeCheck.knowledgeBase.name}"`, - metadata: { - knowledgeBaseId, - knowledgeBaseName: writeCheck.knowledgeBase.name, - operation: 'restore', - documentCount: updated.length, - documentIds: updated.map((d) => d.id), - }, - request, - }) - - return NextResponse.json({ - success: true, - data: { restoredCount: updated.length, documentIds: updated.map((d) => d.id) }, - }) - } - - const updated = await db - .update(document) - .set({ userExcluded: true, enabled: false }) - .where( - and( - eq(document.connectorId, connectorId), - inArray(document.id, documentIds), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) - .returning({ id: document.id }) - - logger.info(`[${requestId}] Excluded ${updated.length} documents`, { connectorId }) - - recordAudit({ - workspaceId: writeCheck.knowledgeBase.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.CONNECTOR_DOCUMENT_EXCLUDED, - resourceType: AuditResourceType.CONNECTOR, - resourceId: connectorId, - description: `Excluded ${updated.length} document(s) from knowledge base "${writeCheck.knowledgeBase.name}"`, - metadata: { - knowledgeBaseId, - knowledgeBaseName: writeCheck.knowledgeBase.name, - operation: 'exclude', - documentCount: updated.length, - documentIds: updated.map((d) => d.id), - }, - request, - }) - - return NextResponse.json({ - success: true, - data: { excludedCount: updated.length, documentIds: updated.map((d) => d.id) }, - }) - } catch (error) { - logger.error(`[${requestId}] Error updating connector documents`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const PATCH = defineInternalJsonRoute({ + contract: patchKnowledgeConnectorDocumentsContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.updateConnectorDocuments, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal connector-document update behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.id, + connectorId: params.connectorId, + ...body, + }), + useCase: updateKnowledgeConnectorDocuments, + present: ({ operation, count, documentIds }) => ({ + success: true as const, + data: + operation === 'restore' + ? { restoredCount: count, documentIds } + : { excludedCount: count, documentIds }, + }), }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts deleted file mode 100644 index ce255768db1..00000000000 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** - * @vitest-environment node - */ -import { - auditMock, - authOAuthUtilsMock, - createMockRequest, - dbChainMockFns, - hybridAuthMockFns, - knowledgeApiUtilsMock, - knowledgeApiUtilsMockFns, - queueTableRows, - resetDbChainMock, - schemaMock, -} from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockHasWorkspaceLiveSyncAccess, mockValidateConfig } = vi.hoisted(() => ({ - mockHasWorkspaceLiveSyncAccess: vi.fn(), - mockValidateConfig: vi.fn(), -})) - -const mockCheckAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess -const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess - -vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) -vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) -vi.mock('@/connectors/registry.server', () => ({ - CONNECTOR_REGISTRY: { - jira: { validateConfig: mockValidateConfig }, - }, -})) -vi.mock('@/lib/knowledge/tags/service', () => ({ - cleanupUnusedTagDefinitions: vi.fn().mockResolvedValue(undefined), -})) -vi.mock('@/lib/knowledge/documents/service', () => ({ - deleteDocumentStorageFiles: vi.fn().mockResolvedValue(undefined), -})) -vi.mock('@/lib/billing/core/subscription', () => ({ - hasWorkspaceLiveSyncAccess: mockHasWorkspaceLiveSyncAccess, -})) -vi.mock('@sim/audit', () => auditMock) - -import { DELETE, GET, PATCH } from '@/app/api/knowledge/[id]/connectors/[connectorId]/route' - -describe('Knowledge Connector By ID API Route', () => { - const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' }) - - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - }) - - afterAll(() => { - resetDbChainMock() - }) - - describe('GET', () => { - it('returns 401 when unauthenticated', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: false, - userId: null, - }) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - - expect(response.status).toBe(401) - }) - - it('returns 404 when KB not found', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - }) - mockCheckAccess.mockResolvedValue({ hasAccess: false, notFound: true }) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - - expect(response.status).toBe(404) - }) - - it('returns 404 when connector not found', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - }) - mockCheckAccess.mockResolvedValue({ hasAccess: true }) - dbChainMockFns.limit.mockResolvedValueOnce([]) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - - expect(response.status).toBe(404) - }) - - it('returns connector with sync logs on success', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - }) - mockCheckAccess.mockResolvedValue({ hasAccess: true }) - - const mockConnector = { id: 'conn-456', connectorType: 'jira', status: 'active' } - const mockLogs = [{ id: 'log-1', status: 'completed' }] - - dbChainMockFns.limit.mockResolvedValueOnce([mockConnector]).mockResolvedValueOnce(mockLogs) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.id).toBe('conn-456') - expect(data.data.syncLogs).toHaveLength(1) - }) - }) - - describe('PATCH', () => { - it('returns 401 when unauthenticated', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: false, - userId: null, - }) - - const req = createMockRequest('PATCH', { status: 'paused' }) - const response = await PATCH(req, { params: mockParams }) - - expect(response.status).toBe(401) - }) - - it('returns 400 for invalid body', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - }) - mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) - - const req = createMockRequest('PATCH', { syncIntervalMinutes: 'not a number' }) - const response = await PATCH(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toBe('Validation error') - }) - - it('returns 404 when connector not found during sourceConfig validation', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - }) - mockCheckWriteAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, - }) - dbChainMockFns.limit.mockResolvedValueOnce([]) - - const req = createMockRequest('PATCH', { sourceConfig: { project: 'NEW' } }) - const response = await PATCH(req, { params: mockParams }) - - expect(response.status).toBe(404) - }) - - it('allows a free external actor to enable live sync for a Max workspace', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'free-external-admin', - userName: 'Test', - userEmail: 'test@test.com', - }) - mockCheckWriteAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, - }) - mockHasWorkspaceLiveSyncAccess.mockResolvedValue(true) - - const updatedConnector = { id: 'conn-456', status: 'paused', syncIntervalMinutes: 5 } - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }]) - dbChainMockFns.returning.mockResolvedValueOnce([updatedConnector]) - - const req = createMockRequest('PATCH', { status: 'paused', syncIntervalMinutes: 5 }) - const response = await PATCH(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.status).toBe('paused') - expect(mockHasWorkspaceLiveSyncAccess).toHaveBeenCalledWith('ws-1') - }) - - it('denies a paid actor when the knowledge base workspace is free', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'paid-external-admin', - }) - mockCheckWriteAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { workspaceId: 'ws-free', name: 'Free KB' }, - }) - mockHasWorkspaceLiveSyncAccess.mockResolvedValue(false) - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }]) - - const req = createMockRequest('PATCH', { syncIntervalMinutes: 5 }) - const response = await PATCH(req, { params: mockParams }) - - expect(response.status).toBe(403) - expect(mockHasWorkspaceLiveSyncAccess).toHaveBeenCalledWith('ws-free') - }) - }) - - describe('DELETE', () => { - it('returns 401 when unauthenticated', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: false, - userId: null, - }) - - const req = createMockRequest('DELETE') - const response = await DELETE(req, { params: mockParams }) - - expect(response.status).toBe(401) - }) - - it('returns 200 on successful hard-delete', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - userName: 'Test', - userEmail: 'test@test.com', - }) - mockCheckWriteAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, - }) - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }]) - queueTableRows(schemaMock.document, [{ id: 'doc-1', fileUrl: '/api/uploads/test.txt' }]) - dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'conn-456' }]) - - const req = createMockRequest('DELETE') - const response = await DELETE(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - }) - }) -}) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts index d63513af694..bfa2f27c880 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts @@ -1,256 +1,80 @@ -import { db } from '@sim/db' -import { knowledgeConnectorSyncLog } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { desc, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { deleteKnowledgeConnectorContract, + getKnowledgeConnectorContract, updateKnowledgeConnectorContract, } from '@/lib/api/contracts/knowledge' -import { parseRequest } from '@/lib/api/server' -import { decryptApiKey } from '@/lib/api-key/crypto' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' import { - messageForOrchestrationError, - statusForOrchestrationError, -} from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveCredentialTokenIdentity } from '@/lib/credentials/access' + internalKnowledgeAnalytics, + toInternalKnowledgeConnector, + toInternalKnowledgeConnectorDetail, +} from '@/lib/knowledge/api/internal-route' import { - getKnowledgeConnector, - type KnowledgeConnectorRow, - performDeleteKnowledgeConnector, - performUpdateKnowledgeConnector, - type SourceConfigRejection, -} from '@/lib/knowledge/orchestration' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' -import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' -import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' - -const logger = createLogger('KnowledgeConnectorByIdAPI') - -type RouteParams = { params: Promise<{ id: string; connectorId: string }> } - -/** - * GET /api/knowledge/[id]/connectors/[connectorId] - Get connector details with recent sync logs - */ -export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - const { id: knowledgeBaseId, connectorId } = await params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId) - if (!accessCheck.hasAccess) { - const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401 - return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) - } - - const connector = await getKnowledgeConnector(knowledgeBaseId, connectorId) - if (!connector) { - return NextResponse.json({ error: 'Connector not found' }, { status: 404 }) - } - - const syncLogs = await db - .select() - .from(knowledgeConnectorSyncLog) - .where(eq(knowledgeConnectorSyncLog.connectorId, connectorId)) - .orderBy(desc(knowledgeConnectorSyncLog.startedAt)) - .limit(10) - - const { encryptedApiKey: _, ...connectorData } = connector - return NextResponse.json({ - success: true, - data: { - ...connectorData, - syncLogs, - }, - }) - } catch (error) { - logger.error(`[${requestId}] Error fetching connector`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } + internalKnowledgeErrorPolicies, + internalKnowledgeSessionOrExecutorAuth, +} from '@/lib/knowledge/api/route-policies' +import { + deleteKnowledgeConnector, + readKnowledgeConnector, + updateKnowledgeConnector, +} from '@/lib/knowledge/application/connectors' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: getKnowledgeConnectorContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.readConnector, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal connector-read behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ params }) => ({ + knowledgeBaseId: params.id, + connectorId: params.connectorId, + }), + useCase: readKnowledgeConnector, + present: ({ connector }) => ({ + success: true as const, + data: toInternalKnowledgeConnectorDetail(connector), + }), }) -/** - * Validates a replacement `sourceConfig` against the live source, resolving the - * connector's own token first. Returns a rejection message, or `null` to accept. - * - * Stays with the route rather than moving into orchestration because resolving - * the token needs the requesting identity: workspace credentials are shared and - * token reads are scoped to `account.userId`, so the credential's own account - * owner is used — not the knowledge base owner, and not the acting user when a - * service account mints its own token. - */ -function makeSourceConfigValidator( - actingUserId: string, - workspaceId: string | null, - connectorId: string -) { - return async ( - connector: KnowledgeConnectorRow, - sourceConfig: Record<string, unknown> - ): Promise<SourceConfigRejection | null> => { - const connectorConfig = CONNECTOR_REGISTRY[connector.connectorType] - if (!connectorConfig) { - return { - message: `Unknown connector type: ${connector.connectorType}`, - errorCode: 'validation', - } - } - - let accessToken: string | null = null - if (connectorConfig.auth.mode === 'apiKey') { - if (!connector.encryptedApiKey) { - return { - message: 'API key not found. Please reconfigure the connector.', - errorCode: 'validation', - } - } - accessToken = (await decryptApiKey(connector.encryptedApiKey)).decrypted - } else { - if (!connector.credentialId) { - return { - message: 'OAuth credential not found. Please reconfigure the connector.', - errorCode: 'validation', - } - } - if (!workspaceId) { - return { - message: 'Knowledge base is missing workspace context', - errorCode: 'conflict', - } - } - const identity = await resolveCredentialTokenIdentity(connector.credentialId, workspaceId) - if (!identity) { - return { - message: 'Credential is no longer usable in this workspace. Please reconnect it.', - errorCode: 'validation', - } - } - accessToken = await refreshAccessTokenIfNeeded( - connector.credentialId, - // Service accounts mint their own token and ignore the acting user. - identity.kind === 'oauth' ? identity.userId : actingUserId, - `patch-${connectorId}` - ) - } - - if (!accessToken) { - // A stale stored credential, not an unauthenticated caller — but the route - // has always answered 401 here, so keep that rather than silently - // reclassifying it as part of this refactor. - return { - message: 'Failed to refresh access token. Please reconnect your account.', - errorCode: 'unauthorized', - } - } - - const validation = await connectorConfig.validateConfig(accessToken, sourceConfig) - return validation.valid - ? null - : { message: validation.error || 'Invalid source configuration', errorCode: 'validation' } - } -} - -/** - * PATCH /api/knowledge/[id]/connectors/[connectorId] - Update a connector - */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteParams) => { - const requestId = generateRequestId() - const { id: knowledgeBaseId, connectorId } = await context.params - - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!writeCheck.hasAccess) { - const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 - return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) - } - - const parsed = await parseRequest(updateKnowledgeConnectorContract, request, context) - if (!parsed.success) return parsed.response - - const outcome = await performUpdateKnowledgeConnector({ - knowledgeBase: { - id: knowledgeBaseId, - name: writeCheck.knowledgeBase.name, - workspaceId: writeCheck.knowledgeBase.workspaceId ?? null, - }, - connectorId, - updates: parsed.data.body, - validateSourceConfig: makeSourceConfigValidator( - auth.userId, - writeCheck.knowledgeBase.workspaceId ?? null, - connectorId - ), - userId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - source: 'ui', - requestId, - request, - }) - if (!outcome.success) { - return NextResponse.json( - { error: messageForOrchestrationError(outcome, 'Internal server error') }, - { status: statusForOrchestrationError(outcome.errorCode) } - ) - } - - return NextResponse.json({ success: true, data: outcome.connector }) +export const PATCH = defineInternalJsonRoute({ + contract: updateKnowledgeConnectorContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.updateConnector, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal connector-update behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ params, body }) => ({ + connectorId: params.connectorId, + knowledgeBaseId: params.id, + updates: body, + source: 'ui' as const, + }), + useCase: updateKnowledgeConnector, + present: ({ connector }) => ({ + success: true as const, + data: toInternalKnowledgeConnector(connector), + }), }) -/** - * DELETE /api/knowledge/[id]/connectors/[connectorId] - Hard-delete a connector - */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteParams) => { - const requestId = generateRequestId() - const { id: knowledgeBaseId, connectorId } = await context.params - - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!writeCheck.hasAccess) { - const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 - return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) - } - - const parsed = await parseRequest(deleteKnowledgeConnectorContract, request, context) - if (!parsed.success) return parsed.response - - const outcome = await performDeleteKnowledgeConnector({ - knowledgeBase: { - id: knowledgeBaseId, - name: writeCheck.knowledgeBase.name, - workspaceId: writeCheck.knowledgeBase.workspaceId ?? null, - }, - connectorId, - deleteDocuments: parsed.data.query.deleteDocuments, - userId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - source: 'ui', - requestId, - request, - }) - if (!outcome.success) { - return NextResponse.json( - { error: messageForOrchestrationError(outcome, 'Internal server error') }, - { status: statusForOrchestrationError(outcome.errorCode) } - ) - } - - return NextResponse.json({ success: true }) +export const DELETE = defineInternalJsonRoute({ + contract: deleteKnowledgeConnectorContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.deleteConnector, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal connector-delete behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ params, query }) => ({ + connectorId: params.connectorId, + knowledgeBaseId: params.id, + deleteDocuments: query.deleteDocuments, + source: 'ui' as const, + }), + useCase: deleteKnowledgeConnector, + onSuccess: internalKnowledgeAnalytics.connectorRemoved, + present: () => ({ success: true as const }), }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts deleted file mode 100644 index b8869013644..00000000000 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * @vitest-environment node - */ -import { - auditMock, - createMockRequest, - dbChainMockFns, - hybridAuthMockFns, - knowledgeApiUtilsMock, - knowledgeApiUtilsMockFns, - requestUtilsMockFns, - resetDbChainMock, -} from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockDispatchSync, mockResolveBillingAttribution } = vi.hoisted(() => ({ - mockDispatchSync: vi.fn().mockResolvedValue(undefined), - mockResolveBillingAttribution: vi.fn(), -})) - -const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess - -vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) -vi.mock('@/lib/billing/core/billing-attribution', () => ({ - requireBillingAttributionHeader: vi.fn(), - resolveBillingAttribution: mockResolveBillingAttribution, -})) -vi.mock('@/lib/knowledge/connectors/queue', () => ({ - dispatchSync: mockDispatchSync, -})) -vi.mock('@sim/audit', () => auditMock) - -import { POST } from '@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route' - -describe('Connector Manual Sync API Route', () => { - const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' }) - - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-req-id') - }) - - afterAll(() => { - resetDbChainMock() - }) - - it('returns 401 when unauthenticated', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: false, - userId: null, - }) - - const req = createMockRequest('POST') - const response = await POST(req as never, { params: mockParams }) - - expect(response.status).toBe(401) - }) - - it('returns 404 when connector not found', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - }) - mockCheckWriteAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, - }) - dbChainMockFns.limit.mockResolvedValueOnce([]) - - const req = createMockRequest('POST') - const response = await POST(req as never, { params: mockParams }) - - expect(response.status).toBe(404) - }) - - it('returns 409 when connector is syncing', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - }) - mockCheckWriteAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, - }) - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'syncing' }]) - - const req = createMockRequest('POST') - const response = await POST(req as never, { params: mockParams }) - - expect(response.status).toBe(409) - }) - - it('dispatches sync on valid request', async () => { - const billingAttribution = { - actorUserId: 'external-admin', - workspaceId: 'ws-1', - organizationId: null, - billedAccountUserId: 'owner-1', - billingEntity: { type: 'user' as const, id: 'owner-1' }, - billingPeriod: { - start: '2026-07-01T00:00:00.000Z', - end: '2026-08-01T00:00:00.000Z', - }, - payerSubscription: null, - } - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - authType: 'session', - userId: 'external-admin', - userName: 'Test', - userEmail: 'test@test.com', - }) - mockCheckWriteAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, - }) - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }]) - mockResolveBillingAttribution.mockResolvedValue(billingAttribution) - - const req = createMockRequest('POST') - const response = await POST(req as never, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ - actorUserId: 'external-admin', - workspaceId: 'ws-1', - }) - expect(mockDispatchSync).toHaveBeenCalledWith('conn-456', { - billingAttribution, - requestId: 'test-req-id', - rehydrate: false, - }) - }) - - it('dispatches a full resync when rehydrate=true is set', async () => { - const billingAttribution = { - actorUserId: 'external-admin', - workspaceId: 'ws-1', - organizationId: null, - billedAccountUserId: 'owner-1', - billingEntity: { type: 'user' as const, id: 'owner-1' }, - billingPeriod: { - start: '2026-07-01T00:00:00.000Z', - end: '2026-08-01T00:00:00.000Z', - }, - payerSubscription: null, - } - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - authType: 'session', - userId: 'external-admin', - userName: 'Test', - userEmail: 'test@test.com', - }) - mockCheckWriteAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, - }) - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }]) - mockResolveBillingAttribution.mockResolvedValue(billingAttribution) - - const req = createMockRequest( - 'POST', - undefined, - {}, - 'http://localhost:3000/api/knowledge/kb-123/connectors/conn-456/sync?rehydrate=true' - ) - const response = await POST(req as never, { params: mockParams }) - - expect(response.status).toBe(200) - expect(mockDispatchSync).toHaveBeenCalledWith('conn-456', { - billingAttribution, - requestId: 'test-req-id', - rehydrate: true, - }) - }) -}) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts index 21e6bfdb50e..9862922bcad 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts @@ -1,76 +1,33 @@ -import { type NextRequest, NextResponse } from 'next/server' import { triggerKnowledgeConnectorSyncContract } from '@/lib/api/contracts/knowledge' -import { parseRequest } from '@/lib/api/server' -import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' import { - requireBillingAttributionHeader, - resolveBillingAttribution, -} from '@/lib/billing/core/billing-attribution' + internalKnowledgeAnalytics, + resolveInternalKnowledgeBillingAttribution, +} from '@/lib/knowledge/api/internal-route' import { - messageForOrchestrationError, - statusForOrchestrationError, -} from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performSyncKnowledgeConnector } from '@/lib/knowledge/orchestration' -import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' + internalKnowledgeErrorPolicies, + internalKnowledgeSessionOrExecutorAuth, +} from '@/lib/knowledge/api/route-policies' +import { syncKnowledgeConnector } from '@/lib/knowledge/application/connectors' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' -type RouteParams = { params: Promise<{ id: string; connectorId: string }> } - -/** - * POST /api/knowledge/[id]/connectors/[connectorId]/sync - Trigger a manual sync - */ -export const POST = withRouteHandler(async (request: NextRequest, context: RouteParams) => { - const requestId = generateRequestId() - const parsed = await parseRequest(triggerKnowledgeConnectorSyncContract, request, context) - if (!parsed.success) return parsed.response - const { id: knowledgeBaseId, connectorId } = parsed.data.params - const { rehydrate } = parsed.data.query - - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!writeCheck.hasAccess) { - const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 - return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) - } - - const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? null - - const outcome = await performSyncKnowledgeConnector({ - knowledgeBase: { - id: knowledgeBaseId, - name: writeCheck.knowledgeBase.name, - workspaceId: kbWorkspaceId, - }, - connectorId, - resolveBillingAttribution: async () => - auth.authType === AuthType.INTERNAL_JWT - ? requireBillingAttributionHeader(request.headers, { - actorUserId: auth.userId as string, - workspaceId: kbWorkspaceId as string, - }) - : resolveBillingAttribution({ - actorUserId: auth.userId as string, - workspaceId: kbWorkspaceId as string, - }), - rehydrate, - userId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - source: 'ui', - requestId, - request, - }) - if (!outcome.success) { - return NextResponse.json( - { error: messageForOrchestrationError(outcome, 'Internal server error') }, - { status: statusForOrchestrationError(outcome.errorCode) } - ) - } - - return NextResponse.json({ success: true, message: 'Sync triggered' }) +export const POST = defineInternalJsonRoute({ + contract: triggerKnowledgeConnectorSyncContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.syncConnector, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal connector-sync behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ params, query }, { principal, request }) => ({ + connectorId: params.connectorId, + knowledgeBaseId: params.id, + rehydrate: query.rehydrate, + resolveBillingAttribution: (workspaceId: string) => + resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), + source: 'ui' as const, + }), + useCase: syncKnowledgeConnector, + onSuccess: internalKnowledgeAnalytics.connectorSynced, + present: () => ({ success: true as const, message: 'Sync triggered' }), }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts deleted file mode 100644 index 361a8e2ad68..00000000000 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * @vitest-environment node - */ -import { - auditMock, - authOAuthUtilsMock, - createMockRequest, - dbChainMockFns, - hybridAuthMockFns, - knowledgeApiUtilsMock, - knowledgeApiUtilsMockFns, - resetDbChainMock, -} from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCaptureServerEvent, - mockDispatchSync, - mockEncryptApiKey, - mockHasWorkspaceLiveSyncAccess, - mockResolveBillingAttribution, - mockValidateConfig, -} = vi.hoisted(() => ({ - mockCaptureServerEvent: vi.fn(), - mockDispatchSync: vi.fn(), - mockEncryptApiKey: vi.fn(), - mockHasWorkspaceLiveSyncAccess: vi.fn(), - mockResolveBillingAttribution: vi.fn(), - mockValidateConfig: vi.fn(), -})) - -const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess - -vi.mock('@sim/audit', () => auditMock) -vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) -vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) -vi.mock('@/connectors/registry.server', () => ({ - CONNECTOR_REGISTRY: { - test: { - auth: { mode: 'apiKey' }, - validateConfig: mockValidateConfig, - }, - }, -})) -vi.mock('@/lib/api-key/crypto', () => ({ - encryptApiKey: mockEncryptApiKey, -})) -vi.mock('@/lib/billing/core/billing-attribution', () => ({ - requireBillingAttributionHeader: vi.fn(), - resolveBillingAttribution: mockResolveBillingAttribution, -})) -vi.mock('@/lib/billing/core/subscription', () => ({ - hasWorkspaceLiveSyncAccess: mockHasWorkspaceLiveSyncAccess, -})) -vi.mock('@/lib/knowledge/connectors/queue', () => ({ - dispatchSync: mockDispatchSync, -})) -vi.mock('@/lib/knowledge/tags/service', () => ({ - createTagDefinition: vi.fn(), -})) -vi.mock('@/lib/posthog/server', () => ({ - captureServerEvent: mockCaptureServerEvent, -})) - -import { POST } from '@/app/api/knowledge/[id]/connectors/route' - -const BILLING_ATTRIBUTION = { - actorUserId: 'free-external-admin', - workspaceId: 'workspace-paid', - organizationId: 'organization-paid', - billedAccountUserId: 'workspace-owner', - billingEntity: { type: 'organization' as const, id: 'organization-paid' }, - billingPeriod: { - start: '2026-07-01T00:00:00.000Z', - end: '2026-08-01T00:00:00.000Z', - }, - payerSubscription: { - id: 'subscription-paid', - referenceId: 'organization-paid', - plan: 'team_25000', - status: 'active', - seats: 5, - periodStart: '2026-07-01T00:00:00.000Z', - periodEnd: '2026-08-01T00:00:00.000Z', - }, -} - -describe('Knowledge Connectors API Route', () => { - const context = { params: Promise.resolve({ id: 'knowledge-base-1' }) } - - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - mockDispatchSync.mockResolvedValue(undefined) - mockEncryptApiKey.mockResolvedValue({ encrypted: 'encrypted-api-key' }) - mockValidateConfig.mockResolvedValue({ valid: true }) - }) - - afterAll(() => { - resetDbChainMock() - }) - - it('queues the authenticated actor with the paid workspace payer', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - authType: 'session', - userId: 'free-external-admin', - userName: 'External Admin', - userEmail: 'external@example.com', - }) - mockCheckWriteAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { - id: 'knowledge-base-1', - name: 'Paid KB', - workspaceId: 'workspace-paid', - }, - }) - mockHasWorkspaceLiveSyncAccess.mockResolvedValue(true) - mockResolveBillingAttribution.mockResolvedValue(BILLING_ATTRIBUTION) - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'knowledge-base-1' }]) - dbChainMockFns.returning.mockResolvedValueOnce([ - { - id: 'connector-1', - knowledgeBaseId: 'knowledge-base-1', - connectorType: 'test', - status: 'active', - }, - ]) - - const request = createMockRequest('POST', { - connectorType: 'test', - apiKey: 'api-key', - sourceConfig: {}, - syncIntervalMinutes: 5, - }) - const response = await POST(request, context) - - expect(response.status).toBe(201) - expect(mockHasWorkspaceLiveSyncAccess).toHaveBeenCalledWith('workspace-paid') - expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ - actorUserId: 'free-external-admin', - workspaceId: 'workspace-paid', - }) - expect(mockDispatchSync).toHaveBeenCalledWith(expect.any(String), { - billingAttribution: BILLING_ATTRIBUTION, - requestId: expect.any(String), - }) - }) - - it('denies a paid actor when the workspace payer lacks Max access', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - authType: 'session', - userId: 'paid-external-admin', - }) - mockCheckWriteAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { - id: 'knowledge-base-1', - name: 'Free KB', - workspaceId: 'workspace-free', - }, - }) - mockHasWorkspaceLiveSyncAccess.mockResolvedValue(false) - - const request = createMockRequest('POST', { - connectorType: 'test', - apiKey: 'api-key', - sourceConfig: {}, - syncIntervalMinutes: 5, - }) - const response = await POST(request, context) - - expect(response.status).toBe(403) - expect(mockHasWorkspaceLiveSyncAccess).toHaveBeenCalledWith('workspace-free') - // The payer is resolved lazily, so a request the plan gate rejects never - // pays for the lookup. - expect(mockResolveBillingAttribution).not.toHaveBeenCalled() - expect(mockDispatchSync).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.ts index df2f246ae1d..b28042f6481 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.ts @@ -1,147 +1,62 @@ -import { db } from '@sim/db' -import { knowledgeConnector } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, desc, eq, isNull } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { createKnowledgeConnectorContract } from '@/lib/api/contracts/knowledge' -import { parseRequest } from '@/lib/api/server' -import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { - requireBillingAttributionHeader, - resolveBillingAttribution, -} from '@/lib/billing/core/billing-attribution' + createKnowledgeConnectorContract, + listKnowledgeConnectorsContract, +} from '@/lib/api/contracts/knowledge' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' import { - messageForOrchestrationError, - OrchestrationError, - statusForOrchestrationError, -} from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performCreateKnowledgeConnector } from '@/lib/knowledge/orchestration' -import { getCredential } from '@/app/api/auth/oauth/utils' -import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' - -const logger = createLogger('KnowledgeConnectorsAPI') - -/** - * GET /api/knowledge/[id]/connectors - List connectors for a knowledge base - */ -export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const { id: knowledgeBaseId } = await params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId) - if (!accessCheck.hasAccess) { - const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401 - return NextResponse.json( - { error: status === 404 ? 'Not found' : 'Unauthorized' }, - { status } - ) - } - - const connectors = await db - .select() - .from(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - .orderBy(desc(knowledgeConnector.createdAt)) - - return NextResponse.json({ - success: true, - data: connectors.map(({ encryptedApiKey: _, ...rest }) => rest), - }) - } catch (error) { - logger.error(`[${requestId}] Error listing connectors`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) - -/** - * POST /api/knowledge/[id]/connectors - Create a new connector - */ -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const { id: knowledgeBaseId } = await context.params - - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!writeCheck.hasAccess) { - const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 - return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) - } - - const parsed = await parseRequest(createKnowledgeConnectorContract, request, context) - if (!parsed.success) return parsed.response - - const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } = - parsed.data.body - - const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId - if (!kbWorkspaceId) { - return NextResponse.json( - { error: 'Knowledge base is missing workspace billing context' }, - { status: 409 } - ) - } - - const outcome = await performCreateKnowledgeConnector({ - knowledgeBase: { - id: knowledgeBaseId, - name: writeCheck.knowledgeBase.name, - workspaceId: kbWorkspaceId, - }, - connectorType, - credentialId, - apiKey, - sourceConfig, - syncIntervalMinutes, - resolveBillingAttribution: async () => - auth.authType === AuthType.INTERNAL_JWT - ? requireBillingAttributionHeader(request.headers, { - actorUserId: auth.userId as string, - workspaceId: kbWorkspaceId, - }) - : resolveBillingAttribution({ - actorUserId: auth.userId as string, - workspaceId: kbWorkspaceId, - }), - resolveAccessToken: async (id) => { - const credential = await getCredential(requestId, id, auth.userId as string) - if (!credential) throw new OrchestrationError('validation', 'Credential not found') - return credential.accessToken ?? null - }, - userId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - source: 'ui', - requestId, - request, - }) - if (!outcome.success) { - return NextResponse.json( - { error: messageForOrchestrationError(outcome, 'Internal server error') }, - { status: statusForOrchestrationError(outcome.errorCode) } - ) - } - - return NextResponse.json({ success: true, data: outcome.connector }, { status: 201 }) - } -) + internalKnowledgeAnalytics, + resolveInternalKnowledgeBillingAttribution, + toInternalKnowledgeConnector, +} from '@/lib/knowledge/api/internal-route' +import { + internalKnowledgeErrorPolicies, + internalKnowledgeSessionOrExecutorAuth, +} from '@/lib/knowledge/api/route-policies' +import { + createKnowledgeConnector, + listKnowledgeConnectors, +} from '@/lib/knowledge/application/connectors' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: listKnowledgeConnectorsContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.listConnectors, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal connector-list behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ params }) => ({ knowledgeBaseId: params.id }), + useCase: listKnowledgeConnectors, + present: ({ connectors }) => ({ + success: true as const, + data: connectors.map(toInternalKnowledgeConnector), + }), +}) + +export const POST = defineInternalJsonRoute({ + contract: createKnowledgeConnectorContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.createConnector, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal connector-create behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ params, body }, { principal, request }) => ({ + knowledgeBaseId: params.id, + connectorType: body.connectorType, + credentialId: body.credentialId, + apiKey: body.apiKey, + sourceConfig: body.sourceConfig, + syncIntervalMinutes: body.syncIntervalMinutes, + resolveBillingAttribution: (workspaceId: string) => + resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), + source: 'ui' as const, + }), + useCase: createKnowledgeConnector, + onSuccess: internalKnowledgeAnalytics.connectorAdded, + present: ({ connector }) => ({ + success: true as const, + data: toInternalKnowledgeConnector(connector), + }), +}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts index 1e1ad1fed19..92e8ef049e2 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts @@ -1,243 +1,137 @@ -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { type NextRequest, NextResponse } from 'next/server' -import { updateKnowledgeChunkContract } from '@/lib/api/contracts/knowledge' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createDurableSecretProvenanceRegistry } from '@/lib/execution/durable-secret-provenance' -import { deleteChunk, updateChunk } from '@/lib/knowledge/chunks/service' -import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' +import type { Principal } from '@sim/auth/principal' +import type { NextRequest } from 'next/server' import { - createKnowledgePersistedResponse, + deleteKnowledgeChunkContract, + getKnowledgeChunkContract, + updateKnowledgeChunkContract, +} from '@/lib/api/contracts/knowledge' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + internalKnowledgeActorUserId, + internalKnowledgeAuthType, + toInternalKnowledgeChunk, +} from '@/lib/knowledge/api/internal-route' +import { + internalKnowledgeErrorPolicies, + internalKnowledgeSessionOrExecutorAuth, +} from '@/lib/knowledge/api/route-policies' +import { + deleteKnowledgeChunk, + readKnowledgeChunk, + updateKnowledgeChunk, +} from '@/lib/knowledge/application/chunks' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + finalizeKnowledgePersistedResponse, resolveKnowledgeWriteSecretProvenance, } from '@/app/api/knowledge/secret-provenance' -import { checkChunkAccess, checkChunkWriteAccess } from '@/app/api/knowledge/utils' - -const logger = createLogger('ChunkByIdAPI') - -export const GET = withRouteHandler( - async ( - req: NextRequest, - { params }: { params: Promise<{ id: string; documentId: string; chunkId: string }> } - ) => { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId, documentId, chunkId } = await params - - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized chunk access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - const accessCheck = await checkChunkAccess(knowledgeBaseId, documentId, chunkId, userId) - - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { - logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}, Chunk=${chunkId}` - ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted unauthorized chunk access: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - logger.info( - `[${requestId}] Retrieved chunk: ${chunkId} from document ${documentId} in knowledge base ${knowledgeBaseId}` - ) - - const responseBody = { - success: true, - data: accessCheck.chunk, - } - const workspaceId = accessCheck.knowledgeBase?.workspaceId ?? undefined - return createKnowledgePersistedResponse({ - request: req, - authType: auth.authType, - userId, - ...(workspaceId ? { workspaceId } : {}), - body: responseBody, - chunks: accessCheck.chunk - ? [ - { - id: accessCheck.chunk.id, - documentId, - content: accessCheck.chunk.content, - value: accessCheck.chunk, - }, - ] - : [], - }) - } catch (error) { - logger.error(`[${requestId}] Error fetching chunk`, error) - return NextResponse.json({ error: 'Failed to fetch chunk' }, { status: 500 }) - } - } -) - -export const PUT = withRouteHandler( - async ( - req: NextRequest, - context: { params: Promise<{ id: string; documentId: string; chunkId: string }> } - ) => { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId, documentId, chunkId } = await context.params - - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized chunk update attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - const accessCheck = await checkChunkWriteAccess(knowledgeBaseId, documentId, chunkId, userId) - - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { - logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}, Chunk=${chunkId}` - ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted unauthorized chunk update: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - if (accessCheck.document?.connectorId) { - logger.warn( - `[${requestId}] User ${userId} attempted to update chunk on connector-synced document: Doc=${documentId}` - ) - return NextResponse.json( - { error: 'Chunks from connector-synced documents are read-only' }, - { status: 403 } - ) - } - - const parsed = await parseRequest(updateKnowledgeChunkContract, req, context) - if (!parsed.success) return parsed.response - - const validatedData = parsed.data.body - const workspaceId = accessCheck.knowledgeBase?.workspaceId ?? undefined - const writeProvenance = resolveKnowledgeWriteSecretProvenance({ - request: req, - payload: validatedData, - authType: auth.authType, - userId, - ...(workspaceId ? { workspaceId } : {}), - selectionKeys: validatedData.content === undefined ? [] : ['chunk-content'], - }) - if (!writeProvenance.success) return writeProvenance.response - const chunkProvenance = writeProvenance.provenances?.[0] - if (chunkProvenance?.status === 'unknown') { - return NextResponse.json( - { error: 'Knowledge chunk secret provenance is unavailable' }, - { status: 400 } - ) - } - const registry = chunkProvenance - ? await createDurableSecretProvenanceRegistry(chunkProvenance, { - userId, - ...(workspaceId ? { workspaceId } : {}), - }) - : undefined - - const updatedChunk = await runWithKnowledgeModelInputProvenance(registry, () => - updateChunk(chunkId, validatedData, requestId, workspaceId, chunkProvenance) - ) - - logger.info( - `[${requestId}] Chunk updated: ${chunkId} in document ${documentId} in knowledge base ${knowledgeBaseId}` - ) - - return createKnowledgePersistedResponse({ - request: req, - authType: auth.authType, - userId, - ...(workspaceId ? { workspaceId } : {}), - body: { success: true, data: updatedChunk }, - chunks: [ - { - id: updatedChunk.id, - documentId, - content: updatedChunk.content, - value: updatedChunk, - }, - ], - }) - } catch (error) { - logger.error(`[${requestId}] Error updating chunk`, error) - return NextResponse.json({ error: 'Failed to update chunk' }, { status: 500 }) - } - } -) - -export const DELETE = withRouteHandler( - async ( - req: NextRequest, - { params }: { params: Promise<{ id: string; documentId: string; chunkId: string }> } - ) => { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId, documentId, chunkId } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized chunk delete attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const accessCheck = await checkChunkWriteAccess( - knowledgeBaseId, - documentId, - chunkId, - session.user.id - ) - - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { - logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}, Chunk=${chunkId}` - ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${session.user.id} attempted unauthorized chunk deletion: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - if (accessCheck.document?.connectorId) { - logger.warn( - `[${requestId}] User ${session.user.id} attempted to delete chunk on connector-synced document: Doc=${documentId}` - ) - return NextResponse.json( - { error: 'Chunks from connector-synced documents are read-only' }, - { status: 403 } - ) - } - - await deleteChunk(chunkId, documentId, requestId) - - logger.info( - `[${requestId}] Chunk deleted: ${chunkId} from document ${documentId} in knowledge base ${knowledgeBaseId}` - ) - return NextResponse.json({ - success: true, - data: { message: 'Chunk deleted successfully' }, - }) - } catch (error) { - logger.error(`[${requestId}] Error deleting chunk`, error) - return NextResponse.json({ error: 'Failed to delete chunk' }, { status: 500 }) - } +function resolveContentProvenance( + request: NextRequest, + principal: Principal, + payload: unknown, + workspaceId: string, + includeContent: boolean +) { + const resolved = resolveKnowledgeWriteSecretProvenance({ + request, + payload, + authType: internalKnowledgeAuthType(principal), + userId: internalKnowledgeActorUserId(principal), + workspaceId, + selectionKeys: includeContent ? ['chunk-content'] : [], + }) + if (!resolved.success) { + throw new OrchestrationError('validation', 'Invalid knowledge secret provenance') } -) + return resolved.provenances?.[0] +} + +export const GET = defineInternalJsonRoute({ + contract: getKnowledgeChunkContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.readChunk, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal chunk-read behavior' }), + errorPolicy: internalKnowledgeErrorPolicies.chunks, + mapInput: ({ params }) => ({ + knowledgeBaseId: params.id, + documentId: params.documentId, + chunkId: params.chunkId, + }), + useCase: readKnowledgeChunk, + present: ({ chunk }) => ({ success: true as const, data: toInternalKnowledgeChunk(chunk) }), + finalizeResponse: ({ request, principal, result, body }) => + finalizeKnowledgePersistedResponse({ + request, + authType: internalKnowledgeAuthType(principal), + userId: internalKnowledgeActorUserId(principal), + workspaceId: result.workspaceId, + body, + chunks: [ + { + id: result.chunk.id, + documentId: result.documentId, + content: result.chunk.content, + value: result.chunk, + }, + ], + }), +}) + +export const PUT = defineInternalJsonRoute({ + contract: updateKnowledgeChunkContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.updateChunk, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal chunk-update behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.chunks, + mapInput: ({ params, body }, { principal, request }) => ({ + knowledgeBaseId: params.id, + documentId: params.documentId, + chunkId: params.chunkId, + content: body.content, + enabled: body.enabled, + resolveContentProvenance: ({ workspaceId }: { workspaceId: string }) => + resolveContentProvenance(request, principal, body, workspaceId, body.content !== undefined), + }), + useCase: updateKnowledgeChunk, + present: ({ chunk }) => ({ success: true as const, data: toInternalKnowledgeChunk(chunk) }), + finalizeResponse: ({ request, principal, result, body }) => + finalizeKnowledgePersistedResponse({ + request, + authType: internalKnowledgeAuthType(principal), + userId: internalKnowledgeActorUserId(principal), + workspaceId: result.workspaceId, + body, + chunks: [ + { + id: result.chunk.id, + documentId: result.documentId, + content: result.chunk.content, + value: result.chunk, + }, + ], + }), +}) + +export const DELETE = defineInternalJsonRoute({ + contract: deleteKnowledgeChunkContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.deleteChunk, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal chunk-delete behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.chunks, + mapInput: ({ params }) => ({ + knowledgeBaseId: params.id, + documentId: params.documentId, + chunkId: params.chunkId, + }), + useCase: deleteKnowledgeChunk, + present: () => ({ + success: true as const, + data: { message: 'Chunk deleted successfully' }, + }), +}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts index bb6e3249851..44afacfe847 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts @@ -1,388 +1,127 @@ -import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' +import type { Principal } from '@sim/auth/principal' +import type { NextRequest } from 'next/server' import { bulkKnowledgeChunksContract, - createChunkBodySchema, - listKnowledgeChunksQuerySchema, + createKnowledgeChunkContract, + listKnowledgeChunksContract, } from '@/lib/api/contracts/knowledge' -import { isZodError, parseJsonBody, parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createDurableSecretProvenanceRegistry } from '@/lib/execution/durable-secret-provenance' -import { batchChunkOperation, createChunk, queryChunks } from '@/lib/knowledge/chunks/service' -import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { - createKnowledgePersistedResponse, - createKnowledgeProvenanceResponse, + internalKnowledgeActorUserId, + internalKnowledgeAuthType, + toInternalKnowledgeChunk, +} from '@/lib/knowledge/api/internal-route' +import { + internalKnowledgeErrorPolicies, + internalKnowledgeSessionOrExecutorAuth, +} from '@/lib/knowledge/api/route-policies' +import { + bulkUpdateKnowledgeChunks, + createKnowledgeChunk, + listKnowledgeChunks, +} from '@/lib/knowledge/application/chunks' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + finalizeKnowledgePersistedResponse, + finalizeKnowledgeProvenanceResponse, resolveKnowledgeWriteSecretProvenance, } from '@/app/api/knowledge/secret-provenance' -import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils' -import { calculateCost } from '@/providers/utils' - -const logger = createLogger('DocumentChunksAPI') - -export const GET = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { - const requestId = generateRequestId() - const { id: knowledgeBaseId, documentId } = await params - - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized chunks access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, userId) - - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { - logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` - ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted unauthorized chunks access: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const doc = accessCheck.document - if (!doc) { - logger.warn( - `[${requestId}] Document data not available: KB=${knowledgeBaseId}, Doc=${documentId}` - ) - return NextResponse.json({ error: 'Document not found' }, { status: 404 }) - } - - if (doc.processingStatus !== 'completed') { - logger.warn( - `[${requestId}] Document ${documentId} is not ready for chunk access (status: ${doc.processingStatus})` - ) - return NextResponse.json( - { - error: 'Document is not ready for access', - details: `Document status: ${doc.processingStatus}`, - retryAfter: doc.processingStatus === 'processing' ? 5 : null, - }, - { status: 400 } - ) - } - - const { searchParams } = new URL(req.url) - const queryResult = listKnowledgeChunksQuerySchema.safeParse({ - search: searchParams.get('search') || undefined, - enabled: searchParams.get('enabled') || undefined, - limit: searchParams.get('limit') || undefined, - offset: searchParams.get('offset') || undefined, - sortBy: searchParams.get('sortBy') || undefined, - sortOrder: searchParams.get('sortOrder') || undefined, - }) - if (!queryResult.success) { - return NextResponse.json( - { error: 'Invalid query parameters', details: queryResult.error.issues }, - { status: 400 } - ) - } - - const result = await queryChunks(documentId, queryResult.data, requestId) - - const responseBody = { - success: true, - data: result.chunks, - pagination: result.pagination, - } - const workspaceId = accessCheck.knowledgeBase?.workspaceId ?? undefined - return createKnowledgePersistedResponse({ - request: req, - authType: auth.authType, - userId, - ...(workspaceId ? { workspaceId } : {}), - body: responseBody, - chunks: result.chunks.map((item) => ({ - id: item.id, - documentId, - content: item.content, - value: item, - })), - }) - } catch (error) { - logger.error(`[${requestId}] Error fetching chunks`, error) - return NextResponse.json({ error: 'Failed to fetch chunks' }, { status: 500 }) - } - } -) - -export const POST = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { - const requestId = generateRequestId() - const { id: knowledgeBaseId, documentId } = await params - - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - const parsedBody = await parseJsonBody(req) - if (!parsedBody.success) return parsedBody.response - const { workflowId, ...searchParams } = parsedBody.data as Record<string, unknown> - - if (workflowId) { - if (typeof workflowId !== 'string') { - return NextResponse.json({ error: 'workflowId must be a string' }, { status: 400 }) - } - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'write', - }) - if (!authorization.allowed) { - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status } - ) - } - } - - const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId) - - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { - logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` - ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted unauthorized chunk creation: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const doc = accessCheck.document - if (!doc) { - logger.warn( - `[${requestId}] Document data not available: KB=${knowledgeBaseId}, Doc=${documentId}` - ) - return NextResponse.json({ error: 'Document not found' }, { status: 404 }) - } - - if (doc.connectorId) { - logger.warn( - `[${requestId}] User ${userId} attempted to create chunk on connector-synced document: Doc=${documentId}` - ) - return NextResponse.json( - { error: 'Chunks from connector-synced documents are read-only' }, - { status: 403 } - ) - } - - if (doc.processingStatus === 'failed') { - logger.warn(`[${requestId}] Document ${documentId} is in failed state, cannot add chunks`) - return NextResponse.json({ error: 'Cannot add chunks to failed document' }, { status: 400 }) - } - - try { - const validatedData = createChunkBodySchema.parse(searchParams) - const workspaceId = accessCheck.knowledgeBase?.workspaceId ?? undefined - const writeProvenance = resolveKnowledgeWriteSecretProvenance({ - request: req, - payload: parsedBody.data, - authType: auth.authType, - userId, - ...(workspaceId ? { workspaceId } : {}), - selectionKeys: ['chunk-content'], - }) - if (!writeProvenance.success) return writeProvenance.response - const chunkProvenance = writeProvenance.provenances?.[0] - if (chunkProvenance?.status === 'unknown') { - return NextResponse.json( - { error: 'Knowledge chunk secret provenance is unavailable' }, - { status: 400 } - ) - } - const registry = chunkProvenance - ? await createDurableSecretProvenanceRegistry(chunkProvenance, { - userId, - ...(workspaceId ? { workspaceId } : {}), - }) - : undefined - - const docTags = { - tag1: doc.tag1 ?? null, - tag2: doc.tag2 ?? null, - tag3: doc.tag3 ?? null, - tag4: doc.tag4 ?? null, - tag5: doc.tag5 ?? null, - tag6: doc.tag6 ?? null, - tag7: doc.tag7 ?? null, - number1: doc.number1 ?? null, - number2: doc.number2 ?? null, - number3: doc.number3 ?? null, - number4: doc.number4 ?? null, - number5: doc.number5 ?? null, - date1: doc.date1 ?? null, - date2: doc.date2 ?? null, - boolean1: doc.boolean1 ?? null, - boolean2: doc.boolean2 ?? null, - boolean3: doc.boolean3 ?? null, - } - - const newChunk = await runWithKnowledgeModelInputProvenance(registry, () => - createChunk( - knowledgeBaseId, - documentId, - docTags, - validatedData, - requestId, - workspaceId, - chunkProvenance - ) - ) - - let cost = null - try { - cost = calculateCost( - accessCheck.knowledgeBase.embeddingModel, - newChunk.tokenCount, - 0, - false - ) - } catch (error) { - logger.warn(`[${requestId}] Failed to calculate cost for chunk upload`, { - error: getErrorMessage(error, 'Unknown error'), - }) - } - - return createKnowledgeProvenanceResponse({ - request: req, - authType: auth.authType, - userId, - ...(workspaceId ? { workspaceId } : {}), - provenances: chunkProvenance ? [chunkProvenance] : [], - body: { - success: true, - data: { - ...newChunk, - documentId, - documentName: doc.filename, - ...(cost - ? { - cost: { - input: cost.input, - output: cost.output, - total: cost.total, - tokens: { - prompt: newChunk.tokenCount, - completion: 0, - total: newChunk.tokenCount, - }, - model: accessCheck.knowledgeBase.embeddingModel, - pricing: cost.pricing, - }, - } - : {}), - }, - }, - }) - } catch (validationError) { - if (isZodError(validationError)) { - logger.warn(`[${requestId}] Invalid chunk creation data`, { - errors: validationError.issues, - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.issues }, - { status: 400 } - ) - } - throw validationError - } - } catch (error) { - logger.error(`[${requestId}] Error creating chunk`, error) - return NextResponse.json({ error: 'Failed to create chunk' }, { status: 500 }) - } - } -) - -export const PATCH = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { - const requestId = generateRequestId() - const { id: knowledgeBaseId, documentId } = await params - - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized batch chunk operation attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId) - - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { - logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` - ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted unauthorized batch chunk operation: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - if (accessCheck.document?.connectorId) { - logger.warn( - `[${requestId}] User ${userId} attempted batch chunk operation on connector-synced document: Doc=${documentId}` - ) - return NextResponse.json( - { error: 'Chunks from connector-synced documents are read-only' }, - { status: 403 } - ) - } - - const parsed = await parseRequest( - bulkKnowledgeChunksContract, - req, - { params }, - { - validationErrorResponse: (error) => { - logger.warn(`[${requestId}] Invalid batch operation data`, { errors: error.issues }) - return NextResponse.json( - { error: 'Invalid request data', details: error.issues }, - { status: 400 } - ) - }, - } - ) - if (!parsed.success) return parsed.response - const validatedData = parsed.data.body - const { operation, chunkIds } = validatedData - - const result = await batchChunkOperation(documentId, operation, chunkIds, requestId) - return NextResponse.json({ - success: true, - data: { - operation, - successCount: result.processed, - errorCount: result.errors.length, - processed: result.processed, - errors: result.errors, - }, - }) - } catch (error) { - logger.error(`[${requestId}] Error in batch chunk operation`, error) - return NextResponse.json({ error: 'Failed to perform batch operation' }, { status: 500 }) - } +function resolveContentProvenance( + request: NextRequest, + principal: Principal, + payload: unknown, + workspaceId: string, + includeContent: boolean +) { + const resolved = resolveKnowledgeWriteSecretProvenance({ + request, + payload, + authType: internalKnowledgeAuthType(principal), + userId: internalKnowledgeActorUserId(principal), + workspaceId, + selectionKeys: includeContent ? ['chunk-content'] : [], + }) + if (!resolved.success) { + throw new OrchestrationError('validation', 'Invalid knowledge secret provenance') } -) + return resolved.provenances?.[0] +} + +export const GET = defineInternalJsonRoute({ + contract: listKnowledgeChunksContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.listChunks, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal chunk-list behavior' }), + errorPolicy: internalKnowledgeErrorPolicies.chunks, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.id, + documentId: params.documentId, + ...query, + }), + useCase: listKnowledgeChunks, + present: ({ chunks, pagination }) => ({ + success: true as const, + data: chunks.map(toInternalKnowledgeChunk), + pagination, + }), + finalizeResponse: ({ request, principal, result, body }) => + finalizeKnowledgePersistedResponse({ + request, + authType: internalKnowledgeAuthType(principal), + userId: internalKnowledgeActorUserId(principal), + workspaceId: result.workspaceId, + body, + chunks: result.chunks.map((chunk) => ({ + id: chunk.id, + documentId: result.documentId, + content: chunk.content, + value: chunk, + })), + }), +}) + +export const POST = defineInternalJsonRoute({ + contract: createKnowledgeChunkContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.createChunk, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal chunk-create behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.chunks, + mapInput: ({ params, body }, { principal, request }) => ({ + knowledgeBaseId: params.id, + documentId: params.documentId, + content: body.content, + enabled: body.enabled, + resolveContentProvenance: ({ workspaceId }: { workspaceId: string }) => + resolveContentProvenance(request, principal, body, workspaceId, true), + }), + useCase: createKnowledgeChunk, + present: ({ chunk }) => ({ success: true as const, data: toInternalKnowledgeChunk(chunk) }), + finalizeResponse: ({ request, principal, result, body }) => + finalizeKnowledgeProvenanceResponse({ + request, + authType: internalKnowledgeAuthType(principal), + userId: result.userId, + workspaceId: result.workspaceId, + body, + provenances: result.provenance ? [result.provenance] : [], + }), +}) + +export const PATCH = defineInternalJsonRoute({ + contract: bulkKnowledgeChunksContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.bulkChunks, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal bulk-chunk behavior' }), + errorPolicy: internalKnowledgeErrorPolicies.chunks, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.id, + documentId: params.documentId, + ...body, + }), + useCase: bulkUpdateKnowledgeChunks, + present: (data) => ({ success: true as const, data }), +}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts deleted file mode 100644 index 84bdd9736e5..00000000000 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts +++ /dev/null @@ -1,542 +0,0 @@ -/** - * Tests for document by ID API route - * - * @vitest-environment node - */ -import { - auditMock, - authMockFns, - createMockRequest, - knowledgeApiUtilsMock, - resetDbChainMock, -} from '@sim/testing' -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) - -vi.mock('@/lib/knowledge/documents/service', () => ({ - updateDocument: vi.fn(), - deleteDocument: vi.fn(), - markDocumentAsFailedTimeout: vi.fn(), - retryDocumentProcessing: vi.fn(), - processDocumentAsync: vi.fn(), -})) - -vi.mock('@sim/audit', () => auditMock) - -import { - deleteDocument, - markDocumentAsFailedTimeout, - retryDocumentProcessing, - updateDocument, -} from '@/lib/knowledge/documents/service' -import { DELETE, GET, PUT } from '@/app/api/knowledge/[id]/documents/[documentId]/route' -import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils' - -describe('Document By ID API Route', () => { - const mockDocument = { - id: 'doc-123', - knowledgeBaseId: 'kb-123', - filename: 'test-document.pdf', - fileUrl: 'https://example.com/test-document.pdf', - fileSize: 1024, - mimeType: 'application/pdf', - chunkCount: 5, - tokenCount: 100, - characterCount: 500, - processingStatus: 'completed' as const, - processingStartedAt: new Date('2023-01-01T10:00:00Z'), - processingCompletedAt: new Date('2023-01-01T10:05:00Z'), - processingError: null, - enabled: true, - uploadedAt: new Date('2023-01-01T09:00:00Z'), - tag1: null, - tag2: null, - tag3: null, - tag4: null, - tag5: null, - tag6: null, - tag7: null, - number1: null, - number2: null, - number3: null, - number4: null, - number5: null, - date1: null, - date2: null, - boolean1: null, - boolean2: null, - boolean3: null, - deletedAt: null, - } - - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - - vi.stubGlobal('crypto', { - randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), - }) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - afterAll(() => { - resetDbChainMock() - }) - - describe('GET /api/knowledge/[id]/documents/[documentId]', () => { - const mockParams = Promise.resolve({ id: 'kb-123', documentId: 'doc-123' }) - - it('should retrieve document successfully for authenticated user', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkDocumentAccess).mockResolvedValue({ - hasAccess: true, - document: mockDocument, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.id).toBe('doc-123') - expect(data.data.filename).toBe('test-document.pdf') - expect(vi.mocked(checkDocumentAccess)).toHaveBeenCalledWith('kb-123', 'doc-123', 'user-123') - }) - - it('should return unauthorized for unauthenticated user', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data.error).toBe('Unauthorized') - }) - - it('should return not found for non-existent document', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkDocumentAccess).mockResolvedValue({ - hasAccess: false, - notFound: true, - reason: 'Document not found', - }) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(404) - expect(data.error).toBe('Document not found') - }) - - it('should return unauthorized for document without access', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkDocumentAccess).mockResolvedValue({ - hasAccess: false, - reason: 'Access denied', - }) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data.error).toBe('Unauthorized') - }) - - it('should handle database errors', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkDocumentAccess).mockRejectedValue(new Error('Database error')) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.error).toBe('Failed to fetch document') - }) - }) - - describe('PUT /api/knowledge/[id]/documents/[documentId] - Regular Updates', () => { - const mockParams = Promise.resolve({ id: 'kb-123', documentId: 'doc-123' }) - const validUpdateData = { - filename: 'updated-document.pdf', - enabled: false, - chunkCount: 10, - tokenCount: 200, - } - - it('should update document successfully', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ - hasAccess: true, - document: mockDocument, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - const updatedDocument = { - ...mockDocument, - ...validUpdateData, - deletedAt: null, - } - vi.mocked(updateDocument).mockResolvedValue(updatedDocument) - - const req = createMockRequest('PUT', validUpdateData) - const response = await PUT(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.filename).toBe('updated-document.pdf') - expect(data.data.enabled).toBe(false) - expect(vi.mocked(updateDocument)).toHaveBeenCalledWith( - 'doc-123', - validUpdateData, - expect.any(String) - ) - }) - - it('should validate update data', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ - hasAccess: true, - document: mockDocument, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - const invalidData = { - filename: '', // Invalid: empty filename - chunkCount: -1, // Invalid: negative count - processingStatus: 'invalid', // Invalid: not in enum - } - - const req = createMockRequest('PUT', invalidData) - const response = await PUT(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toBe('Invalid request data') - expect(data.details).toBeDefined() - }) - }) - - describe('PUT /api/knowledge/[id]/documents/[documentId] - Mark Failed Due to Timeout', () => { - const mockParams = Promise.resolve({ id: 'kb-123', documentId: 'doc-123' }) - - it('should mark document as failed due to timeout successfully', async () => { - const processingDocument = { - ...mockDocument, - processingStatus: 'processing', - processingStartedAt: new Date(Date.now() - 200000), // 200 seconds ago - } - - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ - hasAccess: true, - document: processingDocument, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - vi.mocked(markDocumentAsFailedTimeout).mockResolvedValue({ - success: true, - processingDuration: 200000, - }) - - const req = createMockRequest('PUT', { markFailedDueToTimeout: true }) - const response = await PUT(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.documentId).toBe('doc-123') - expect(data.data.status).toBe('failed') - expect(data.data.message).toBe('Document marked as failed due to timeout') - expect(vi.mocked(markDocumentAsFailedTimeout)).toHaveBeenCalledWith( - 'doc-123', - processingDocument.processingStartedAt, - expect.any(String) - ) - }) - - it('should reject marking failed for non-processing document', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ - hasAccess: true, - document: { ...mockDocument, processingStatus: 'completed' }, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - const req = createMockRequest('PUT', { markFailedDueToTimeout: true }) - const response = await PUT(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toContain('Document is not in processing state') - }) - - it('should reject marking failed for recently started processing', async () => { - const recentProcessingDocument = { - ...mockDocument, - processingStatus: 'processing', - processingStartedAt: new Date(Date.now() - 60000), // 60 seconds ago - } - - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ - hasAccess: true, - document: recentProcessingDocument, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - vi.mocked(markDocumentAsFailedTimeout).mockRejectedValue( - new Error('Document has not been processing long enough to be considered dead') - ) - - const req = createMockRequest('PUT', { markFailedDueToTimeout: true }) - const response = await PUT(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toContain('Document has not been processing long enough') - }) - }) - - describe('PUT /api/knowledge/[id]/documents/[documentId] - Retry Processing', () => { - const mockParams = Promise.resolve({ id: 'kb-123', documentId: 'doc-123' }) - - it('should retry processing successfully', async () => { - const failedDocument = { - ...mockDocument, - processingStatus: 'failed', - processingError: 'Previous processing failed', - } - - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ - hasAccess: true, - document: failedDocument, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - vi.mocked(retryDocumentProcessing).mockResolvedValue({ - success: true, - status: 'pending', - message: 'Document retry processing started', - }) - - const req = createMockRequest('PUT', { retryProcessing: true }) - const response = await PUT(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.status).toBe('pending') - expect(data.data.message).toBe('Document retry processing started') - expect(vi.mocked(retryDocumentProcessing)).toHaveBeenCalledWith( - 'kb-123', - 'doc-123', - { - filename: failedDocument.filename, - fileUrl: failedDocument.fileUrl, - fileSize: failedDocument.fileSize, - mimeType: failedDocument.mimeType, - }, - expect.any(String), - undefined - ) - }) - - it('should reject retry for non-failed document', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ - hasAccess: true, - document: { ...mockDocument, processingStatus: 'completed' }, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - const req = createMockRequest('PUT', { retryProcessing: true }) - const response = await PUT(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toBe('Document is not in failed state') - }) - }) - - describe('PUT /api/knowledge/[id]/documents/[documentId] - Authentication & Authorization', () => { - const mockParams = Promise.resolve({ id: 'kb-123', documentId: 'doc-123' }) - const validUpdateData = { filename: 'updated-document.pdf' } - - it('should return unauthorized for unauthenticated user', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const req = createMockRequest('PUT', validUpdateData) - const response = await PUT(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data.error).toBe('Unauthorized') - }) - - it('should return not found for non-existent document', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ - hasAccess: false, - notFound: true, - reason: 'Document not found', - }) - - const req = createMockRequest('PUT', validUpdateData) - const response = await PUT(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(404) - expect(data.error).toBe('Document not found') - }) - - it('should handle database errors during update', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ - hasAccess: true, - document: mockDocument, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - vi.mocked(updateDocument).mockRejectedValue(new Error('Database error')) - - const req = createMockRequest('PUT', validUpdateData) - const response = await PUT(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.error).toBe('Failed to update document') - }) - }) - - describe('DELETE /api/knowledge/[id]/documents/[documentId]', () => { - const mockParams = Promise.resolve({ id: 'kb-123', documentId: 'doc-123' }) - - it('should delete document successfully', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ - hasAccess: true, - document: mockDocument, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - vi.mocked(deleteDocument).mockResolvedValue({ - success: true, - message: 'Document deleted successfully', - }) - - const req = createMockRequest('DELETE') - const response = await DELETE(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.message).toBe('Document deleted successfully') - expect(vi.mocked(deleteDocument)).toHaveBeenCalledWith('doc-123', expect.any(String)) - }) - - it('should return unauthorized for unauthenticated user', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const req = createMockRequest('DELETE') - const response = await DELETE(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data.error).toBe('Unauthorized') - }) - - it('should return not found for non-existent document', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ - hasAccess: false, - notFound: true, - reason: 'Document not found', - }) - - const req = createMockRequest('DELETE') - const response = await DELETE(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(404) - expect(data.error).toBe('Document not found') - }) - - it('should return unauthorized for document without access', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ - hasAccess: false, - reason: 'Access denied', - }) - - const req = createMockRequest('DELETE') - const response = await DELETE(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data.error).toBe('Unauthorized') - }) - - it('should handle database errors during deletion', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkDocumentWriteAccess).mockResolvedValue({ - hasAccess: true, - document: mockDocument, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - vi.mocked(deleteDocument).mockRejectedValue(new Error('Database error')) - - const req = createMockRequest('DELETE') - const response = await DELETE(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.error).toBe('Failed to delete document') - }) - }) -}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts index e8ad31d04fa..26cea872efa 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts @@ -1,266 +1,115 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { updateKnowledgeDocumentContract } from '@/lib/api/contracts/knowledge' -import { parseRequest } from '@/lib/api/server' -import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { - requireBillingAttributionHeader, - resolveBillingAttribution, -} from '@/lib/billing/core/billing-attribution' + deleteKnowledgeDocumentContract, + getKnowledgeDocumentContract, + updateKnowledgeDocumentContract, +} from '@/lib/api/contracts/knowledge' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' import { - messageForOrchestrationError, - type OrchestrationErrorCode, - statusForOrchestrationError, -} from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + internalKnowledgeActorUserId, + internalKnowledgeAnalytics, + internalKnowledgeAuthType, + resolveInternalKnowledgeBillingAttribution, + toInternalKnowledgeDocument, +} from '@/lib/knowledge/api/internal-route' import { - performDeleteKnowledgeDocument, - performMarkKnowledgeDocumentTimedOut, - performRetryKnowledgeDocumentProcessing, - performUpdateKnowledgeDocument, -} from '@/lib/knowledge/orchestration' + internalKnowledgeErrorPolicies, + internalKnowledgeSessionOrExecutorAuth, +} from '@/lib/knowledge/api/route-policies' +import { + deleteKnowledgeDocument, + readKnowledgeDocument, + updateKnowledgeDocument, +} from '@/lib/knowledge/application/documents' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { createKnowledgeDocumentSourceValue } from '@/lib/knowledge/secret-provenance' -import { createKnowledgePersistedResponse } from '@/app/api/knowledge/secret-provenance' -import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils' - -const logger = createLogger('DocumentByIdAPI') - -export const GET = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { - const requestId = generateRequestId() - const { id: knowledgeBaseId, documentId } = await params - - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized document access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, userId) - - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { - logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` - ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted unauthorized document access: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - logger.info( - `[${requestId}] Retrieved document: ${documentId} from knowledge base ${knowledgeBaseId}` - ) - - const responseBody = { - success: true, - data: accessCheck.document, - } - const workspaceId = accessCheck.knowledgeBase?.workspaceId ?? undefined - return createKnowledgePersistedResponse({ - request: req, - authType: auth.authType, - userId, - ...(workspaceId ? { workspaceId } : {}), - body: responseBody, - documents: accessCheck.document - ? [ - { - id: accessCheck.document.id, - source: createKnowledgeDocumentSourceValue(accessCheck.document), - value: accessCheck.document, - }, - ] - : [], - }) - } catch (error) { - logger.error(`[${requestId}] Error fetching document`, error) - return NextResponse.json({ error: 'Failed to fetch document' }, { status: 500 }) - } - } -) - -export const PUT = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { - const requestId = generateRequestId() - const { id: knowledgeBaseId, documentId } = await params - - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized document update attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId) - - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { - logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` - ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted unauthorized document update: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest( - updateKnowledgeDocumentContract, - req, - { params }, +import { finalizeKnowledgePersistedResponse } from '@/app/api/knowledge/secret-provenance' + +export const GET = defineInternalJsonRoute({ + contract: getKnowledgeDocumentContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.readDocument, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal document-read behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.documents, + mapInput: ({ params }) => ({ + knowledgeBaseId: params.id, + documentId: params.documentId, + }), + useCase: readKnowledgeDocument, + present: ({ document }) => ({ + success: true as const, + data: toInternalKnowledgeDocument(document), + }), + finalizeResponse: ({ request, principal, result, body }) => + finalizeKnowledgePersistedResponse({ + request, + authType: internalKnowledgeAuthType(principal), + userId: internalKnowledgeActorUserId(principal), + workspaceId: result.workspaceId, + body, + documents: [ { - validationErrorResponse: (error) => { - logger.warn(`[${requestId}] Invalid document update data`, { errors: error.issues }) - return NextResponse.json( - { error: 'Invalid request data', details: error.issues }, - { status: 400 } - ) - }, - } - ) - if (!parsed.success) return parsed.response - - const { markFailedDueToTimeout, retryProcessing, ...documentUpdates } = parsed.data.body - const doc = accessCheck.document - const workspaceId = accessCheck.knowledgeBase?.workspaceId ?? null - - const failed = (outcome: { error?: string; errorCode?: OrchestrationErrorCode }) => - NextResponse.json( - { error: messageForOrchestrationError(outcome, 'Failed to update document') }, - { status: statusForOrchestrationError(outcome.errorCode) } - ) - - if (markFailedDueToTimeout) { - const outcome = await performMarkKnowledgeDocumentTimedOut({ - document: doc, - requestId, - }) - if (!outcome.success) return failed(outcome) - - return NextResponse.json({ - success: true, - data: { documentId, status: outcome.status, message: outcome.message }, - }) - } - - if (retryProcessing) { - const billingAttribution = workspaceId - ? auth.authType === AuthType.INTERNAL_JWT - ? requireBillingAttributionHeader(req.headers, { - actorUserId: userId, - workspaceId, - }) - : await resolveBillingAttribution({ - actorUserId: userId, - workspaceId, - }) - : undefined - - const outcome = await performRetryKnowledgeDocumentProcessing({ - knowledgeBaseId, - document: doc, - billingAttribution, - requestId, - }) - if (!outcome.success) return failed(outcome) - - return NextResponse.json({ - success: true, - data: { documentId, status: outcome.status, message: outcome.message }, - }) - } - - const outcome = await performUpdateKnowledgeDocument({ - knowledgeBase: { - id: knowledgeBaseId, - name: accessCheck.knowledgeBase?.name, - workspaceId, + id: result.document.id, + source: createKnowledgeDocumentSourceValue(result.document), + value: result.document, }, - document: { id: documentId, filename: doc.filename }, - updates: documentUpdates, - userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - source: 'ui', - requestId, - request: req, - }) - if (!outcome.success) return failed(outcome) - - return NextResponse.json({ success: true, data: outcome.document }) - } catch (error) { - logger.error(`[${requestId}] Error updating document ${documentId}`, error) - return NextResponse.json({ error: 'Failed to update document' }, { status: 500 }) + ], + }), +}) + +export const PUT = defineInternalJsonRoute({ + contract: updateKnowledgeDocumentContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.updateDocument, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal document-update behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.documents, + mapInput: ({ params, body }, { principal, request }) => { + const { markFailedDueToTimeout, retryProcessing, ...updates } = body + return { + knowledgeBaseId: params.id, + documentId: params.documentId, + updates, + markFailedDueToTimeout, + retryProcessing, + resolveBillingAttribution: (workspaceId: string) => + resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), + source: 'ui', } - } -) - -export const DELETE = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { - const requestId = generateRequestId() - const { id: knowledgeBaseId, documentId } = await params - - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized document delete attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId) - - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { - logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` - ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + }, + useCase: updateKnowledgeDocument, + present: (result) => + result.kind === 'processing' + ? { + success: true as const, + data: { + documentId: result.documentId, + status: result.status, + message: result.message, + }, } - logger.warn( - `[${requestId}] User ${userId} attempted unauthorized document deletion: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const outcome = await performDeleteKnowledgeDocument({ - knowledgeBase: { - id: knowledgeBaseId, - name: accessCheck.knowledgeBase?.name, - workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, - }, - document: accessCheck.document, - userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - source: 'ui', - requestId, - request: req, - }) - if (!outcome.success) { - return NextResponse.json( - { error: messageForOrchestrationError(outcome, 'Failed to delete document') }, - { status: statusForOrchestrationError(outcome.errorCode) } - ) - } - - return NextResponse.json({ - success: true, - data: { success: true, message: 'Document deleted successfully' }, - }) - } catch (error) { - logger.error(`[${requestId}] Error deleting document`, error) - return NextResponse.json({ error: 'Failed to delete document' }, { status: 500 }) - } - } -) + : { success: true as const, data: toInternalKnowledgeDocument(result.document) }, +}) + +export const DELETE = defineInternalJsonRoute({ + contract: deleteKnowledgeDocumentContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.deleteDocument, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal document-delete behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.documents, + mapInput: ({ params }) => ({ + knowledgeBaseId: params.id, + documentId: params.documentId, + source: 'ui', + }), + useCase: deleteKnowledgeDocument, + onSuccess: internalKnowledgeAnalytics.documentDeleted, + present: () => ({ + success: true as const, + data: { success: true, message: 'Document deleted successfully' }, + }), +}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts index 83fa06c35eb..8cb5cc3bb65 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts @@ -1,209 +1,87 @@ -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { type NextRequest, NextResponse } from 'next/server' -import { saveDocumentTagDefinitionsContract } from '@/lib/api/contracts/knowledge' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants' import { - cleanupUnusedTagDefinitions, - createOrUpdateTagDefinitionsBulk, - deleteAllTagDefinitions, - getDocumentTagDefinitions, - KnowledgeTagProvenanceConflictError, -} from '@/lib/knowledge/tags/service' -import type { BulkTagDefinitionsData } from '@/lib/knowledge/tags/types' -import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('DocumentTagDefinitionsAPI') - -// GET /api/knowledge/[id]/documents/[documentId]/tag-definitions - Get tag definitions for a document -export const GET = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId, documentId } = await params - - try { - logger.info(`[${requestId}] Getting tag definitions for document ${documentId}`) - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Verify document exists and belongs to the knowledge base - const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, session.user.id) - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { - logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` - ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${session.user.id} attempted unauthorized document access: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const tagDefinitions = await getDocumentTagDefinitions(knowledgeBaseId) - - logger.info(`[${requestId}] Retrieved ${tagDefinitions.length} tag definitions`) - - return NextResponse.json({ - success: true, - data: tagDefinitions, - }) - } catch (error) { - logger.error(`[${requestId}] Error getting tag definitions`, error) - return NextResponse.json({ error: 'Failed to get tag definitions' }, { status: 500 }) - } - } -) - -// POST /api/knowledge/[id]/documents/[documentId]/tag-definitions - Create/update tag definitions -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string; documentId: string }> }) => { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId, documentId } = await context.params - - try { - logger.info(`[${requestId}] Creating/updating tag definitions for document ${documentId}`) - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Verify document exists and user has write access - const accessCheck = await checkDocumentWriteAccess( - knowledgeBaseId, - documentId, - session.user.id - ) - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { - logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` - ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${session.user.id} attempted unauthorized document write access: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(saveDocumentTagDefinitionsContract, req, context) - if (!parsed.success) return parsed.response - - const validatedData = parsed.data.body - - for (const def of validatedData.definitions) { - /** - * Defense-in-depth runtime check: the contract types `fieldType` as a plain - * string because tightening to the field-type enum cascades into UI form - * state types. Cast here to allow `includes` to accept the wider input. - */ - if (!(SUPPORTED_FIELD_TYPES as readonly string[]).includes(def.fieldType)) { - return NextResponse.json( - { error: 'Invalid request data', details: `Unsupported field type: ${def.fieldType}` }, - { status: 400 } - ) - } - } - - const bulkData: BulkTagDefinitionsData = { - definitions: validatedData.definitions.map((def) => ({ - tagSlot: def.tagSlot, - displayName: def.displayName, - fieldType: def.fieldType, - originalDisplayName: def._originalDisplayName, - })), - } - - const result = await createOrUpdateTagDefinitionsBulk(knowledgeBaseId, bulkData, requestId) - - return NextResponse.json({ - success: true, - data: { - created: result.created, - updated: result.updated, - errors: result.errors, + deleteDocumentTagDefinitionsContract, + listDocumentTagDefinitionsContract, + saveDocumentTagDefinitionsContract, +} from '@/lib/api/contracts/knowledge' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { toInternalKnowledgeTag } from '@/lib/knowledge/api/internal-route' +import { + internalKnowledgeErrorPolicies, + internalKnowledgeSessionOrExecutorAuth, +} from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + deleteKnowledgeDocumentTagDefinitions, + listKnowledgeDocumentTagDefinitions, + saveKnowledgeDocumentTagDefinitions, +} from '@/lib/knowledge/application/tags' + +const rateLimit = internalRateLimits.none({ + reason: 'Preserve existing internal document-tag behavior', +}) + +export const GET = defineInternalJsonRoute({ + contract: listDocumentTagDefinitionsContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.listTags, + rateLimit, + errorPolicy: internalKnowledgeErrorPolicies.tags, + mapInput: ({ params }) => ({ + knowledgeBaseId: params.id, + documentId: params.documentId, + }), + useCase: listKnowledgeDocumentTagDefinitions, + present: ({ tagDefinitions }) => ({ + success: true as const, + data: tagDefinitions.map(toInternalKnowledgeTag), + }), +}) + +export const POST = defineInternalJsonRoute({ + contract: saveDocumentTagDefinitionsContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.saveDocumentTagDefinitions, + rateLimit, + errorPolicy: internalKnowledgeErrorPolicies.tags, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.id, + documentId: params.documentId, + definitions: body.definitions.map((definition) => ({ + tagSlot: definition.tagSlot, + displayName: definition.displayName, + fieldType: definition.fieldType, + originalDisplayName: definition._originalDisplayName, + })), + }), + useCase: saveKnowledgeDocumentTagDefinitions, + present: ({ created, updated, errors }) => ({ + success: true as const, + data: { + created: created.map(toInternalKnowledgeTag), + updated: updated.map(toInternalKnowledgeTag), + errors, + }, + }), +}) + +export const DELETE = defineInternalJsonRoute({ + contract: deleteDocumentTagDefinitionsContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.deleteDocumentTagDefinitions, + rateLimit, + errorPolicy: internalKnowledgeErrorPolicies.tags, + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.id, + documentId: params.documentId, + action: query.action, + }), + useCase: deleteKnowledgeDocumentTagDefinitions, + present: ({ action, count }) => + action === 'cleanup' + ? { success: true as const, data: { cleanedUp: count } } + : { + success: true as const, + message: 'Tag definitions deleted successfully', + data: { deleted: count }, }, - }) - } catch (error) { - logger.error(`[${requestId}] Error creating/updating tag definitions`, error) - return NextResponse.json( - { error: 'Failed to create/update tag definitions' }, - { status: 500 } - ) - } - } -) - -// DELETE /api/knowledge/[id]/documents/[documentId]/tag-definitions - Delete all tag definitions for a document -export const DELETE = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId, documentId } = await params - const { searchParams } = new URL(req.url) - const action = searchParams.get('action') // 'cleanup' or 'all' - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Verify document exists and user has write access - const accessCheck = await checkDocumentWriteAccess( - knowledgeBaseId, - documentId, - session.user.id - ) - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { - logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` - ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${session.user.id} attempted unauthorized document write access: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - if (action === 'cleanup') { - // Just run cleanup - logger.info(`[${requestId}] Running cleanup for KB ${knowledgeBaseId}`) - const cleanedUpCount = await cleanupUnusedTagDefinitions(knowledgeBaseId, requestId) - - return NextResponse.json({ - success: true, - data: { cleanedUp: cleanedUpCount }, - }) - } - // Delete all tag definitions (original behavior) - logger.info(`[${requestId}] Deleting all tag definitions for KB ${knowledgeBaseId}`) - - const deletedCount = await deleteAllTagDefinitions(knowledgeBaseId, requestId) - - return NextResponse.json({ - success: true, - message: 'Tag definitions deleted successfully', - data: { deleted: deletedCount }, - }) - } catch (error) { - if (error instanceof KnowledgeTagProvenanceConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } - logger.error(`[${requestId}] Error with tag definitions operation`, error) - return NextResponse.json({ error: 'Failed to process tag definitions' }, { status: 500 }) - } - } -) +}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts deleted file mode 100644 index 9ed788c8a56..00000000000 --- a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts +++ /dev/null @@ -1,595 +0,0 @@ -/** - * Tests for knowledge base documents API route - * - * @vitest-environment node - */ -import { - auditMock, - authMockFns, - createMockRequest, - knowledgeApiUtilsMock, - resetDbChainMock, -} from '@sim/testing' -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) - -vi.mock('@/lib/knowledge/documents/service', () => ({ - getDocuments: vi.fn(), - createSingleDocument: vi.fn(), - createDocumentRecords: vi.fn(), - processDocumentsWithQueue: vi.fn(), - getProcessingConfig: vi.fn(), - bulkDocumentOperation: vi.fn(), - updateDocument: vi.fn(), - deleteDocument: vi.fn(), - markDocumentAsFailedTimeout: vi.fn(), - retryDocumentProcessing: vi.fn(), - KnowledgeBaseFileOwnershipError: class KnowledgeBaseFileOwnershipError extends Error {}, -})) - -vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ - checkActorUsageLimits: vi.fn().mockResolvedValue({ isExceeded: false }), -})) - -vi.mock('@sim/audit', () => auditMock) - -import { - createDocumentRecords, - createSingleDocument, - getDocuments, - getProcessingConfig, - processDocumentsWithQueue, -} from '@/lib/knowledge/documents/service' -import { GET, POST } from '@/app/api/knowledge/[id]/documents/route' -import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' - -describe('Knowledge Base Documents API Route', () => { - const mockDocument = { - id: 'doc-123', - knowledgeBaseId: 'kb-123', - filename: 'test-document.pdf', - fileUrl: 'https://example.com/test-document.pdf', - fileSize: 1024, - mimeType: 'application/pdf', - chunkCount: 5, - tokenCount: 100, - characterCount: 500, - processingStatus: 'completed' as const, - processingStartedAt: new Date(), - processingCompletedAt: new Date(), - processingError: null, - enabled: true, - uploadedAt: new Date(), - tag1: null, - tag2: null, - tag3: null, - tag4: null, - tag5: null, - tag6: null, - tag7: null, - number1: null, - number2: null, - number3: null, - number4: null, - number5: null, - date1: null, - date2: null, - boolean1: null, - boolean2: null, - boolean3: null, - deletedAt: null, - } - - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - - vi.stubGlobal('crypto', { - randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), - }) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - afterAll(() => { - resetDbChainMock() - }) - - describe('GET /api/knowledge/[id]/documents', () => { - const mockParams = Promise.resolve({ id: 'kb-123' }) - - it('should retrieve documents successfully for authenticated user', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - vi.mocked(getDocuments).mockResolvedValue({ - documents: [mockDocument], - pagination: { - total: 1, - limit: 50, - offset: 0, - hasMore: false, - }, - }) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.documents).toHaveLength(1) - expect(data.data.documents[0].id).toBe('doc-123') - expect(vi.mocked(checkKnowledgeBaseAccess)).toHaveBeenCalledWith('kb-123', 'user-123') - expect(vi.mocked(getDocuments)).toHaveBeenCalledWith( - 'kb-123', - { - enabledFilter: undefined, - search: undefined, - limit: 50, - offset: 0, - }, - expect.any(String) - ) - }) - - it('should return documents with default filter', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - vi.mocked(getDocuments).mockResolvedValue({ - documents: [mockDocument], - pagination: { - total: 1, - limit: 50, - offset: 0, - hasMore: false, - }, - }) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - - expect(response.status).toBe(200) - expect(vi.mocked(getDocuments)).toHaveBeenCalledWith( - 'kb-123', - { - enabledFilter: undefined, - search: undefined, - limit: 50, - offset: 0, - }, - expect.any(String) - ) - }) - - it('should filter documents by enabled status when requested', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - vi.mocked(getDocuments).mockResolvedValue({ - documents: [mockDocument], - pagination: { - total: 1, - limit: 50, - offset: 0, - hasMore: false, - }, - }) - - const url = 'http://localhost:3000/api/knowledge/kb-123/documents?enabledFilter=disabled' - const req = new Request(url, { method: 'GET' }) as any - - const response = await GET(req, { params: mockParams }) - - expect(response.status).toBe(200) - expect(vi.mocked(getDocuments)).toHaveBeenCalledWith( - 'kb-123', - { - enabledFilter: 'disabled', - search: undefined, - limit: 50, - offset: 0, - }, - expect.any(String) - ) - }) - - it('should return unauthorized for unauthenticated user', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data.error).toBe('Unauthorized') - }) - - it('should return not found for non-existent knowledge base', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ - hasAccess: false, - notFound: true, - }) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(404) - expect(data.error).toBe('Knowledge base not found') - }) - - it('should return unauthorized for knowledge base without access', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: false }) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data.error).toBe('Unauthorized') - }) - - it('should handle database errors', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - vi.mocked(getDocuments).mockRejectedValue(new Error('Database error')) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.error).toBe('Failed to fetch documents') - }) - }) - - describe('POST /api/knowledge/[id]/documents - Single Document', () => { - const mockParams = Promise.resolve({ id: 'kb-123' }) - const validDocumentData = { - filename: 'test-document.pdf', - fileUrl: 'https://example.com/test-document.pdf', - fileSize: 1024, - mimeType: 'application/pdf', - } - - it('should create single document successfully', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - const createdDocument = { - id: 'doc-123', - knowledgeBaseId: 'kb-123', - filename: validDocumentData.filename, - fileUrl: validDocumentData.fileUrl, - fileSize: validDocumentData.fileSize, - mimeType: validDocumentData.mimeType, - chunkCount: 0, - tokenCount: 0, - characterCount: 0, - enabled: true, - uploadedAt: new Date(), - tag1: null, - tag2: null, - tag3: null, - tag4: null, - tag5: null, - tag6: null, - tag7: null, - } - vi.mocked(createSingleDocument).mockResolvedValue(createdDocument) - - const req = createMockRequest('POST', validDocumentData) - const response = await POST(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.filename).toBe(validDocumentData.filename) - expect(data.data.fileUrl).toBe(validDocumentData.fileUrl) - expect(vi.mocked(createSingleDocument)).toHaveBeenCalledWith( - validDocumentData, - 'kb-123', - expect.any(String), - 'user-123', - { - filename: { status: 'exact', entries: [] }, - content: { status: 'exact', entries: [] }, - tags: [], - } - ) - }) - - it('should validate single document data', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - const invalidData = { - filename: '', // Invalid: empty filename - fileUrl: 'invalid-url', // Invalid: not a valid URL - fileSize: 0, // Invalid: size must be > 0 - mimeType: '', // Invalid: empty mime type - } - - const req = createMockRequest('POST', invalidData) - const response = await POST(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toBe('Invalid request data') - expect(data.details).toBeDefined() - }) - }) - - describe('POST /api/knowledge/[id]/documents - Bulk Documents', () => { - const mockParams = Promise.resolve({ id: 'kb-123' }) - const validBulkData = { - bulk: true, - documents: [ - { - filename: 'doc1.pdf', - fileUrl: 'https://example.com/doc1.pdf', - fileSize: 1024, - mimeType: 'application/pdf', - }, - { - filename: 'doc2.pdf', - fileUrl: 'https://example.com/doc2.pdf', - fileSize: 2048, - mimeType: 'application/pdf', - }, - ], - processingOptions: { - recipe: 'default', - lang: 'en', - }, - } - - it('should create bulk documents successfully', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - const createdDocuments = [ - { - documentId: 'doc-1', - filename: 'doc1.pdf', - fileUrl: 'https://example.com/doc1.pdf', - fileSize: 1024, - mimeType: 'application/pdf', - }, - { - documentId: 'doc-2', - filename: 'doc2.pdf', - fileUrl: 'https://example.com/doc2.pdf', - fileSize: 2048, - mimeType: 'application/pdf', - }, - ] - - vi.mocked(createDocumentRecords).mockResolvedValue(createdDocuments) - vi.mocked(processDocumentsWithQueue).mockResolvedValue(undefined) - vi.mocked(getProcessingConfig).mockReturnValue({ - maxConcurrentDocuments: 8, - batchSize: 20, - delayBetweenBatches: 100, - delayBetweenDocuments: 0, - }) - - const req = createMockRequest('POST', validBulkData) - const response = await POST(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.total).toBe(2) - expect(data.data.documentsCreated).toHaveLength(2) - expect(data.data.processingMethod).toBe('background') - expect(vi.mocked(createDocumentRecords)).toHaveBeenCalledWith( - validBulkData.documents, - 'kb-123', - expect.any(String), - 'user-123', - [ - { - filename: { status: 'exact', entries: [] }, - content: { status: 'exact', entries: [] }, - tags: [], - }, - { - filename: { status: 'exact', entries: [] }, - content: { status: 'exact', entries: [] }, - tags: [], - }, - ] - ) - expect(vi.mocked(processDocumentsWithQueue)).toHaveBeenCalled() - }) - - it('should validate bulk document data', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - const invalidBulkData = { - bulk: true, - documents: [ - { - filename: '', // Invalid: empty filename - fileUrl: 'invalid-url', - fileSize: 0, - mimeType: '', - }, - ], - processingOptions: { - recipe: 'default', - lang: 'en', - }, - } - - const req = createMockRequest('POST', invalidBulkData) - const response = await POST(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toBe('Invalid request data') - expect(data.details).toBeDefined() - }) - - it('should handle processing errors gracefully', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - const createdDocuments = [ - { - documentId: 'doc-1', - filename: 'doc1.pdf', - fileUrl: 'https://example.com/doc1.pdf', - fileSize: 1024, - mimeType: 'application/pdf', - }, - ] - - vi.mocked(createDocumentRecords).mockResolvedValue(createdDocuments) - vi.mocked(processDocumentsWithQueue).mockResolvedValue(undefined) - vi.mocked(getProcessingConfig).mockReturnValue({ - maxConcurrentDocuments: 8, - batchSize: 20, - delayBetweenBatches: 100, - delayBetweenDocuments: 0, - }) - - const req = createMockRequest('POST', validBulkData) - const response = await POST(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - }) - }) - - describe('POST /api/knowledge/[id]/documents - Authentication & Authorization', () => { - const mockParams = Promise.resolve({ id: 'kb-123' }) - const validDocumentData = { - filename: 'test-document.pdf', - fileUrl: 'https://example.com/test-document.pdf', - fileSize: 1024, - mimeType: 'application/pdf', - } - - it('should return unauthorized for unauthenticated user', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const req = createMockRequest('POST', validDocumentData) - const response = await POST(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data.error).toBe('Unauthorized') - }) - - it('should return not found for non-existent knowledge base', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ - hasAccess: false, - notFound: true, - }) - - const req = createMockRequest('POST', validDocumentData) - const response = await POST(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(404) - expect(data.error).toBe('Knowledge base not found') - }) - - it('should return unauthorized for knowledge base without access', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: false }) - - const req = createMockRequest('POST', validDocumentData) - const response = await POST(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data.error).toBe('Unauthorized') - }) - - it('should handle database errors during creation', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - vi.mocked(createSingleDocument).mockRejectedValue(new Error('Database error')) - - const req = createMockRequest('POST', validDocumentData) - const response = await POST(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(500) - // An unclassified fault renders the route's own wording; the driver's - // message is logged, not returned. - expect(data.error).toBe('Failed to create document') - }) - }) -}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index 411cfe69b14..5f372b0956e 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -1,423 +1,154 @@ -import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { type NextRequest, NextResponse } from 'next/server' import { bulkKnowledgeDocumentsContract, createKnowledgeDocumentsContract, - listKnowledgeDocumentsQuerySchema, + listKnowledgeDocumentsContract, parseDocumentTagFiltersParam, } from '@/lib/api/contracts/knowledge' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { - checkAttributedUsageLimits, - requireBillingAttributionHeader, - resolveBillingAttribution, -} from '@/lib/billing/core/billing-attribution' + internalKnowledgeActorUserId, + internalKnowledgeAnalytics, + internalKnowledgeAuthType, + resolveInternalKnowledgeBillingAttribution, + toInternalKnowledgeDocument, +} from '@/lib/knowledge/api/internal-route' import { - messageForOrchestrationError, - statusForOrchestrationError, -} from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + internalKnowledgeErrorPolicies, + internalKnowledgeSessionOrExecutorAuth, +} from '@/lib/knowledge/api/route-policies' import { - bulkDocumentOperation, - bulkDocumentOperationByFilter, - getDocuments, - getProcessingConfig, -} from '@/lib/knowledge/documents/service' -import type { TagFilterCondition } from '@/lib/knowledge/documents/tag-filter' -import { - performUploadKnowledgeDocument, - performUploadKnowledgeDocuments, -} from '@/lib/knowledge/orchestration' + bulkUpdateKnowledgeDocuments, + createKnowledgeDocuments, + listKnowledgeDocuments, +} from '@/lib/knowledge/application/documents' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { createKnowledgeDocumentSourceValue } from '@/lib/knowledge/secret-provenance' import { - createKnowledgePersistedResponse, - createKnowledgeProvenanceResponse, + finalizeKnowledgePersistedResponse, + finalizeKnowledgeProvenanceResponse, resolveKnowledgeDocumentWriteSecretProvenance, } from '@/app/api/knowledge/secret-provenance' -import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' - -const logger = createLogger('DocumentsAPI') - -export const GET = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await params +export const GET = defineInternalJsonRoute({ + contract: listKnowledgeDocumentsContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.listDocuments, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal document-list behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.documents, + mapInput: ({ params, query }) => { + let tagFilters try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized documents access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, userId) - - if (!accessCheck.hasAccess) { - if ('notFound' in accessCheck && accessCheck.notFound) { - logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`) - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted to access unauthorized knowledge base documents ${knowledgeBaseId}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const queryResult = listKnowledgeDocumentsQuerySchema.safeParse( - Object.fromEntries(new URL(req.url).searchParams.entries()) - ) - if (!queryResult.success) { - return NextResponse.json( - { error: 'Invalid query parameters', details: queryResult.error.issues }, - { status: 400 } - ) - } - const { enabledFilter, search, limit, offset, sortBy, sortOrder, tagFilters } = - queryResult.data - - let parsedTagFilters: TagFilterCondition[] | undefined - try { - parsedTagFilters = parseDocumentTagFiltersParam(tagFilters) as - | TagFilterCondition[] - | undefined - } catch { - return NextResponse.json( - { error: 'tagFilters must be a valid JSON array' }, - { status: 400 } - ) - } - - const result = await getDocuments( - knowledgeBaseId, - { - enabledFilter: enabledFilter || undefined, - search, - limit, - offset, - ...(sortBy && { sortBy }), - ...(sortOrder && { sortOrder }), - tagFilters: parsedTagFilters, - }, - requestId - ) - - const responseBody = { - success: true, - data: { - documents: result.documents, - pagination: result.pagination, - }, - } - const workspaceId = accessCheck.knowledgeBase?.workspaceId ?? undefined - return createKnowledgePersistedResponse({ - request: req, - authType: auth.authType, - userId, - ...(workspaceId ? { workspaceId } : {}), - body: responseBody, - documents: result.documents.map((item) => ({ - id: item.id, - source: createKnowledgeDocumentSourceValue(item), - value: item, - })), - }) - } catch (error) { - logger.error(`[${requestId}] Error fetching documents`, error) - return NextResponse.json({ error: 'Failed to fetch documents' }, { status: 500 }) + tagFilters = parseDocumentTagFiltersParam(query.tagFilters) + } catch { + throw new OrchestrationError('validation', 'tagFilters must be a valid JSON array') } - } -) - -export const POST = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await params - - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - const parsed = await parseRequest( - createKnowledgeDocumentsContract, - req, - { params }, - { - validationErrorResponse: (error) => { - logger.warn(`[${requestId}] Invalid document creation request`, { - errors: error.issues, - }) - return NextResponse.json( - { error: 'Invalid request data', details: error.issues }, - { status: 400 } - ) - }, - } - ) - if (!parsed.success) return parsed.response - const body = parsed.data.body - const workflowId = body.workflowId - - logger.info(`[${requestId}] Knowledge base document creation request`, { - knowledgeBaseId, - workflowId, - hasWorkflowId: !!workflowId, - bulk: body.bulk === true, - }) - - if (workflowId) { - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'write', - }) - if (!authorization.allowed) { - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status } - ) - } - } - - const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, userId) - - if (!accessCheck.hasAccess) { - if ('notFound' in accessCheck && accessCheck.notFound) { - logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`) - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted to create document in unauthorized knowledge base ${knowledgeBaseId}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId - const billingAttribution = kbWorkspaceId - ? auth.authType === AuthType.INTERNAL_JWT - ? requireBillingAttributionHeader(req.headers, { - actorUserId: userId, - workspaceId: kbWorkspaceId, - }) - : await resolveBillingAttribution({ - actorUserId: userId, - workspaceId: kbWorkspaceId, - }) - : undefined - - /** - * Gate the workspace payer and uploader before accepting indexing work. - * Legacy workspace-less KBs retain account-only enforcement; asynchronous - * connector, cron, and retry paths apply the same backstop. - */ - const usage = billingAttribution - ? await checkAttributedUsageLimits(billingAttribution) - : await checkActorUsageLimits(userId) - if (usage.isExceeded) { - return NextResponse.json( - { - error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.', - }, - { status: 402 } - ) - } - - const provenanceDocuments = body.bulk === true ? body.documents : [body] - const writeProvenance = resolveKnowledgeDocumentWriteSecretProvenance({ - request: req, - payload: body, - authType: auth.authType, - userId, - ...(kbWorkspaceId ? { workspaceId: kbWorkspaceId } : {}), - documents: provenanceDocuments, - }) - if (!writeProvenance.success) return writeProvenance.response - - const knowledgeBase = { - id: knowledgeBaseId, - name: accessCheck.knowledgeBase?.name, - workspaceId: kbWorkspaceId ?? null, - } - const actor = { - userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - source: 'ui' as const, - requestId, - request: req, - } - - if (body.bulk === true) { - const outcome = await performUploadKnowledgeDocuments({ - ...actor, - knowledgeBase, - documents: body.documents, - processingOptions: body.processingOptions, - billingAttribution, - secretProvenances: writeProvenance.provenances, - }) - if (!outcome.success) { - return NextResponse.json( - { error: messageForOrchestrationError(outcome, 'Failed to create document') }, - { status: statusForOrchestrationError(outcome.errorCode) } - ) - } - - const { batchSize, maxConcurrentDocuments } = getProcessingConfig() - return createKnowledgeProvenanceResponse({ - request: req, - authType: auth.authType, - userId, - ...(kbWorkspaceId ? { workspaceId: kbWorkspaceId } : {}), - provenances: - writeProvenance.provenances?.flatMap((provenance) => [ - provenance.filename, - ...provenance.tags.map((tag) => tag.provenance), - ]) ?? [], - body: { - success: true, - data: { - total: outcome.documents.length, - documentsCreated: outcome.documents.map((doc) => ({ - documentId: doc.documentId, - filename: doc.filename, - status: 'pending', - })), - processingMethod: 'background', - processingConfig: { - maxConcurrentDocuments, - batchSize, - totalBatches: Math.ceil(outcome.documents.length / batchSize), - }, - }, - }, - }) - } - - const { bulk: _bulk, workflowId: _workflowId, ...singleDocumentData } = body - // Indexing is deliberately not started here: this path only records the - // document, and its caller drives processing separately. - const outcome = await performUploadKnowledgeDocument({ - ...actor, - knowledgeBase, - document: singleDocumentData, - billingAttribution, - secretProvenance: writeProvenance.provenances?.[0], - }) - if (!outcome.success) { - return NextResponse.json( - { error: messageForOrchestrationError(outcome, 'Failed to create document') }, - { status: statusForOrchestrationError(outcome.errorCode) } - ) - } - - return createKnowledgeProvenanceResponse({ - request: req, - authType: auth.authType, - userId, - ...(kbWorkspaceId ? { workspaceId: kbWorkspaceId } : {}), - provenances: - writeProvenance.provenances?.flatMap((provenance) => [ - provenance.filename, - ...provenance.tags.map((tag) => tag.provenance), - ]) ?? [], - body: { success: true, data: outcome.document }, - }) - } catch (error) { - logger.error(`[${requestId}] Error creating document`, error) - return NextResponse.json( - { error: getErrorMessage(error, 'Failed to create document') }, - { status: 500 } - ) + return { + knowledgeBaseId: params.id, + enabledFilter: query.enabledFilter, + search: query.search, + limit: query.limit, + offset: query.offset, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + tagFilters, } - } -) - -export const PATCH = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized bulk document operation attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, session.user.id) - - if (!accessCheck.hasAccess) { - if ('notFound' in accessCheck && accessCheck.notFound) { - logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`) - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${session.user.id} attempted to perform bulk operation on unauthorized knowledge base ${knowledgeBaseId}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest( - bulkKnowledgeDocumentsContract, - req, - { params }, - { - validationErrorResponse: (error) => { - logger.warn(`[${requestId}] Invalid bulk operation data`, { errors: error.issues }) - return NextResponse.json( - { error: 'Invalid request data', details: error.issues }, - { status: 400 } - ) - }, - } - ) - if (!parsed.success) return parsed.response - const validatedData = parsed.data.body - const { operation, documentIds, selectAll, enabledFilter } = validatedData - - try { - let result - if (selectAll) { - result = await bulkDocumentOperationByFilter( - knowledgeBaseId, - operation, - enabledFilter, - requestId - ) - } else if (documentIds && documentIds.length > 0) { - result = await bulkDocumentOperation(knowledgeBaseId, operation, documentIds, requestId) - } else { - return NextResponse.json({ error: 'No documents specified' }, { status: 400 }) - } - - return NextResponse.json({ - success: true, - data: { - operation, - successCount: result.successCount, - updatedDocuments: result.updatedDocuments, - }, + }, + useCase: listKnowledgeDocuments, + present: ({ documents, pagination }) => ({ + success: true as const, + data: { + documents: documents.map(toInternalKnowledgeDocument), + pagination, + }, + }), + finalizeResponse: ({ request, principal, result, body }) => + finalizeKnowledgePersistedResponse({ + request, + authType: internalKnowledgeAuthType(principal), + userId: internalKnowledgeActorUserId(principal), + workspaceId: result.workspaceId, + body, + documents: result.documents.map((document) => ({ + id: document.id, + source: createKnowledgeDocumentSourceValue(document), + value: document, + })), + }), +}) + +export const POST = defineInternalJsonRoute({ + contract: createKnowledgeDocumentsContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.uploadDocument, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal document-create behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.documents, + mapInput: ({ params, body }, { principal, request }) => { + const documents = body.bulk ? body.documents : [body] + return { + knowledgeBaseId: params.id, + documents, + bulk: body.bulk, + processingOptions: body.bulk ? body.processingOptions : undefined, + resolveBillingAttribution: (workspaceId: string) => + resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), + resolveSecretProvenances: ({ userId, workspaceId }) => { + const resolution = resolveKnowledgeDocumentWriteSecretProvenance({ + request, + payload: body, + authType: internalKnowledgeAuthType(principal), + userId, + workspaceId, + documents, }) - } catch (error) { - if (error instanceof Error && error.message === 'No valid documents found to update') { - return NextResponse.json({ error: 'No valid documents found to update' }, { status: 404 }) + if (!resolution.success) { + throw new OrchestrationError('validation', 'Invalid knowledge secret provenance') } - throw error - } - } catch (error) { - logger.error(`[${requestId}] Error in bulk document operation`, error) - return NextResponse.json({ error: 'Failed to perform bulk operation' }, { status: 500 }) + return resolution.provenances + }, + source: 'ui' as const, } - } -) + }, + useCase: createKnowledgeDocuments, + onSuccess: internalKnowledgeAnalytics.documentsUploaded, + present: (result) => ({ + success: true as const, + data: result.kind === 'bulk' ? result.data : toInternalKnowledgeDocument(result.data), + }), + finalizeResponse: ({ request, principal, result, body }) => + finalizeKnowledgeProvenanceResponse({ + request, + authType: internalKnowledgeAuthType(principal), + userId: result.userId, + workspaceId: result.workspaceId, + provenances: + result.secretProvenances?.flatMap((provenance) => [ + provenance.filename, + ...provenance.tags.map((tag) => tag.provenance), + ]) ?? [], + body, + }), +}) + +export const PATCH = defineInternalJsonRoute({ + contract: bulkKnowledgeDocumentsContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.bulkDocuments, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal bulk-document behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.documents, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.id, + operation: body.operation, + documentIds: body.documentIds, + selectAll: body.selectAll, + enabledFilter: body.enabledFilter, + }), + useCase: bulkUpdateKnowledgeDocuments, + present: (result) => ({ success: true as const, data: result }), +}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts index c426602e85c..329bdda36ed 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts @@ -1,70 +1,57 @@ -import { type NextRequest, NextResponse } from 'next/server' import { completeKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions' -import { parseRequest } from '@/lib/api/server' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { PlatformEvents } from '@/lib/core/telemetry' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { toInternalKnowledgeDocumentUpload } from '@/lib/knowledge/api/internal-route' +import { internalKnowledgeErrorPolicies } 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 { - knowledgeDocumentUploadErrorResponse, - requireKnowledgeDocumentUploadActor, -} from '@/app/api/knowledge/[id]/documents/uploads/utils' -import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' - -interface KnowledgeDocumentUploadRouteParams { - params: Promise<{ id: string; uploadId: string }> -} -export const POST = withRouteHandler( - async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { - const actor = await requireKnowledgeDocumentUploadActor() - if (actor instanceof NextResponse) return actor - const parsed = await parseRequest(completeKnowledgeDocumentUploadContract, request, context) - if (!parsed.success) return parsed.response - const { id: knowledgeBaseId, uploadId } = parsed.data.params - const { workspaceId } = parsed.data.query - try { - const completed = await completeKnowledgeDocumentUpload.execute({ - principal: { kind: 'session', userId: actor.id, sessionId: actor.sessionId }, - input: { - knowledgeBaseId, - assertedWorkspaceId: workspaceId, - uploadId, - uploadToken: parsed.data.headers['upload-token'], - source: 'ui', - }, - request, - }) - if (completed.value.created) { - captureServerEvent( - actor.id, - 'knowledge_base_document_uploaded', - { - knowledge_base_id: completed.knowledgeBaseId, - workspace_id: completed.workspaceId, - document_count: 1, - upload_type: 'single', - }, - { - groups: { workspace: completed.workspaceId }, - setOnce: { first_document_uploaded_at: new Date().toISOString() }, - } - ) - PlatformEvents.knowledgeBaseDocumentsUploaded({ - knowledgeBaseId: completed.knowledgeBaseId, - documentsCount: 1, - uploadType: 'single', - mimeType: completed.value.document.mimeType, - fileSize: completed.value.document.fileSize, - }) +export const POST = defineInternalJsonRoute({ + contract: completeKnowledgeDocumentUploadContract, + auth: internalSessionAuth, + operation: knowledgeOperations.uploadComplete, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal upload-session completion behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.uploads, + mapInput: ({ params, query, headers }) => ({ + knowledgeBaseId: params.id, + assertedWorkspaceId: query.workspaceId, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + source: 'ui' as const, + }), + useCase: completeKnowledgeDocumentUpload, + onSuccess: ({ principal, result }) => { + if (!result.value.created) return + captureServerEvent( + principal.userId, + 'knowledge_base_document_uploaded', + { + knowledge_base_id: result.knowledgeBaseId, + workspace_id: result.workspaceId, + document_count: 1, + upload_type: 'single', + }, + { + groups: { workspace: result.workspaceId }, + setOnce: { first_document_uploaded_at: new Date().toISOString() }, } - return NextResponse.json({ - data: toV2KnowledgeDocumentUpload(completed.session, completed.value.document), - }) - } catch (error) { - const classified = knowledgeDocumentUploadErrorResponse(error) - if (classified) return classified - throw error - } - } -) + ) + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId: result.knowledgeBaseId, + documentsCount: 1, + uploadType: 'single', + mimeType: result.value.document.mimeType, + fileSize: result.value.document.fileSize, + }) + }, + present: (result) => ({ + data: toInternalKnowledgeDocumentUpload(result.session, result.value.document), + }), +}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts index 2ae1d2bc0e7..68fa4083dbe 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts @@ -1,46 +1,28 @@ -import { type NextRequest, NextResponse } from 'next/server' import { createKnowledgeDocumentUploadPartUrlsContract } from '@/lib/api/contracts/knowledge/upload-sessions' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { issueKnowledgeDocumentUploadParts } from '@/lib/knowledge/application/upload-sessions' import { - knowledgeDocumentUploadErrorResponse, - requireKnowledgeDocumentUploadActor, -} from '@/app/api/knowledge/[id]/documents/uploads/utils' - -interface KnowledgeDocumentUploadRouteParams { - params: Promise<{ id: string; uploadId: string }> -} + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { issueKnowledgeDocumentUploadParts } from '@/lib/knowledge/application/upload-sessions' -export const POST = withRouteHandler( - async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { - const actor = await requireKnowledgeDocumentUploadActor() - if (actor instanceof NextResponse) return actor - const parsed = await parseRequest( - createKnowledgeDocumentUploadPartUrlsContract, - request, - context - ) - if (!parsed.success) return parsed.response - const { id: knowledgeBaseId, uploadId } = parsed.data.params - const { workspaceId } = parsed.data.query - try { - const { parts } = await issueKnowledgeDocumentUploadParts.execute({ - principal: { kind: 'session', userId: actor.id, sessionId: actor.sessionId }, - input: { - knowledgeBaseId, - assertedWorkspaceId: workspaceId, - uploadId, - uploadToken: parsed.data.headers['upload-token'], - partNumbers: parsed.data.body.partNumbers, - }, - request, - }) - return NextResponse.json({ data: { parts } }) - } catch (error) { - const classified = knowledgeDocumentUploadErrorResponse(error) - if (classified) return classified - throw error - } - } -) +export const POST = defineInternalJsonRoute({ + contract: createKnowledgeDocumentUploadPartUrlsContract, + auth: internalSessionAuth, + operation: knowledgeOperations.uploadParts, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal upload-part issuance behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.uploads, + mapInput: ({ params, query, headers, body }) => ({ + knowledgeBaseId: params.id, + assertedWorkspaceId: query.workspaceId, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + partNumbers: body.partNumbers, + }), + useCase: issueKnowledgeDocumentUploadParts, + present: ({ parts }) => ({ data: { parts } }), +}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts index 4f9f0d2c5b1..12b42bd2c94 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts @@ -1,42 +1,28 @@ -import { type NextRequest, NextResponse } from 'next/server' import { abortKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { cancelKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' import { - knowledgeDocumentUploadErrorResponse, - requireKnowledgeDocumentUploadActor, -} from '@/app/api/knowledge/[id]/documents/uploads/utils' -import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' - -interface KnowledgeDocumentUploadRouteParams { - params: Promise<{ id: string; uploadId: string }> -} + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { toInternalKnowledgeDocumentUpload } from '@/lib/knowledge/api/internal-route' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { cancelKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' -export const DELETE = withRouteHandler( - async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { - const actor = await requireKnowledgeDocumentUploadActor() - if (actor instanceof NextResponse) return actor - const parsed = await parseRequest(abortKnowledgeDocumentUploadContract, request, context) - if (!parsed.success) return parsed.response - const { id: knowledgeBaseId, uploadId } = parsed.data.params - const { workspaceId } = parsed.data.query - try { - const aborted = await cancelKnowledgeDocumentUpload.execute({ - principal: { kind: 'session', userId: actor.id, sessionId: actor.sessionId }, - input: { - knowledgeBaseId, - assertedWorkspaceId: workspaceId, - uploadId, - uploadToken: parsed.data.headers['upload-token'], - }, - request, - }) - return NextResponse.json({ data: toV2KnowledgeDocumentUpload(aborted, null) }) - } catch (error) { - const classified = knowledgeDocumentUploadErrorResponse(error) - if (classified) return classified - throw error - } - } -) +export const DELETE = defineInternalJsonRoute({ + contract: abortKnowledgeDocumentUploadContract, + auth: internalSessionAuth, + operation: knowledgeOperations.uploadCancel, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal upload-session cancellation behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.uploads, + mapInput: ({ params, query, headers }) => ({ + knowledgeBaseId: params.id, + assertedWorkspaceId: query.workspaceId, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + }), + useCase: cancelKnowledgeDocumentUpload, + present: (session) => ({ data: toInternalKnowledgeDocumentUpload(session, null) }), +}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/control-routes.test.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/control-routes.test.ts deleted file mode 100644 index 8a4b043cfff..00000000000 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/control-routes.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - cancel: vi.fn(), - captureServerEvent: vi.fn(), - complete: vi.fn(), - parts: vi.fn(), - platformEvent: vi.fn(), - requireActor: vi.fn(), -})) - -vi.mock('@/lib/knowledge/application/upload-sessions', () => ({ - cancelKnowledgeDocumentUpload: { execute: mocks.cancel }, - completeKnowledgeDocumentUpload: { execute: mocks.complete }, - issueKnowledgeDocumentUploadParts: { execute: mocks.parts }, -})) - -vi.mock('@/lib/core/telemetry', () => ({ - PlatformEvents: { knowledgeBaseDocumentsUploaded: mocks.platformEvent }, -})) -vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) -vi.mock('@/app/api/knowledge/[id]/documents/uploads/utils', () => ({ - knowledgeDocumentUploadErrorResponse: vi.fn(() => null), - requireKnowledgeDocumentUploadActor: mocks.requireActor, -})) -vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ - toV2KnowledgeDocumentUpload: (_session: unknown, document: unknown) => ({ - id: 'upload-1', - knowledgeBaseId: 'kb-1', - status: document ? 'completed' : 'aborted', - name: 'guide.pdf', - contentType: 'application/pdf', - size: 1024, - expiresAt: '2026-08-05T00:00:00.000Z', - error: null, - document, - }), -})) - -import { POST as COMPLETE } from '@/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route' -import { POST as PARTS } from '@/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route' -import { DELETE as CANCEL } from '@/app/api/knowledge/[id]/documents/uploads/[uploadId]/route' - -const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' -const PRINCIPAL = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } -const SESSION = { id: 'upload-1', knowledgeBaseId: 'kb-1' } -const DOCUMENT = { - id: 'upload-1', - knowledgeBaseId: 'kb-1', - filename: 'guide.pdf', - fileSize: 1024, - mimeType: 'application/pdf', - chunkCount: 0, - tokenCount: 0, - characterCount: 0, - enabled: true, - uploadedAt: new Date('2026-08-03T21:01:00.000Z'), -} - -function routeContext() { - return { params: Promise.resolve({ id: 'kb-1', uploadId: 'upload-1' }) } -} - -function controlUrl(suffix = '') { - return `http://localhost:3000/api/knowledge/kb-1/documents/uploads/upload-1${suffix}?workspaceId=${WORKSPACE_ID}` -} - -describe('internal knowledge-document upload control routes', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.requireActor.mockResolvedValue({ id: 'user-1', sessionId: 'session-1' }) - mocks.parts.mockResolvedValue({ - parts: [ - { - partNumber: 1, - url: 'https://storage.example/1', - headers: {}, - expiresAt: '2026-08-04T21:00:00.000Z', - }, - ], - }) - mocks.cancel.mockResolvedValue(SESSION) - mocks.complete.mockResolvedValue({ - session: SESSION, - value: { document: DOCUMENT, created: true, knowledgeBaseName: 'Docs' }, - alreadyCompleted: false, - workspaceId: WORKSPACE_ID, - knowledgeBaseId: 'kb-1', - }) - }) - - it('delegates multipart part signing with the current session principal', async () => { - const request = new NextRequest(controlUrl('/parts'), { - method: 'POST', - headers: { 'content-type': 'application/json', 'upload-token': 'token' }, - body: JSON.stringify({ partNumbers: [1] }), - }) - - const response = await PARTS(request, routeContext()) - - expect(response.status).toBe(200) - expect(mocks.parts).toHaveBeenCalledWith({ - principal: PRINCIPAL, - input: { - knowledgeBaseId: 'kb-1', - assertedWorkspaceId: WORKSPACE_ID, - uploadId: 'upload-1', - uploadToken: 'token', - partNumbers: [1], - }, - request, - }) - }) - - it('delegates cancellation with the current session principal', async () => { - const request = new NextRequest(controlUrl(), { - method: 'DELETE', - headers: { 'upload-token': 'token' }, - }) - - const response = await CANCEL(request, routeContext()) - - expect(response.status).toBe(200) - expect(mocks.cancel).toHaveBeenCalledWith({ - principal: PRINCIPAL, - input: { - knowledgeBaseId: 'kb-1', - assertedWorkspaceId: WORKSPACE_ID, - uploadId: 'upload-1', - uploadToken: 'token', - }, - request, - }) - }) - - it('delegates completion and emits UI analytics only for a new document', async () => { - const request = new NextRequest(controlUrl('/complete'), { - method: 'POST', - headers: { 'upload-token': 'token' }, - }) - - const response = await COMPLETE(request, routeContext()) - - expect(response.status).toBe(200) - expect(mocks.complete).toHaveBeenCalledWith({ - principal: PRINCIPAL, - input: { - knowledgeBaseId: 'kb-1', - assertedWorkspaceId: WORKSPACE_ID, - uploadId: 'upload-1', - uploadToken: 'token', - source: 'ui', - }, - request, - }) - expect(mocks.captureServerEvent).toHaveBeenCalledTimes(1) - expect(mocks.platformEvent).toHaveBeenCalledTimes(1) - }) - - it('does not duplicate UI analytics on an idempotent completion retry', async () => { - mocks.complete.mockResolvedValue({ - session: SESSION, - value: { document: DOCUMENT, created: false, knowledgeBaseName: 'Docs' }, - alreadyCompleted: true, - workspaceId: WORKSPACE_ID, - knowledgeBaseId: 'kb-1', - }) - const request = new NextRequest(controlUrl('/complete'), { - method: 'POST', - headers: { 'upload-token': 'token' }, - }) - - await COMPLETE(request, routeContext()) - - expect(mocks.captureServerEvent).not.toHaveBeenCalled() - expect(mocks.platformEvent).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts deleted file mode 100644 index afc86b5d24a..00000000000 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - createUpload: vi.fn(), - requireActor: vi.fn(), -})) - -vi.mock('@/lib/knowledge/application/upload-sessions', () => ({ - createKnowledgeDocumentUpload: { execute: mocks.createUpload }, -})) - -vi.mock('@/app/api/knowledge/[id]/documents/uploads/utils', () => ({ - knowledgeDocumentUploadErrorResponse: vi.fn(() => null), - requireKnowledgeDocumentUploadActor: mocks.requireActor, -})) - -vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({ - toV2KnowledgeDocumentUpload: (session: Record<string, unknown>) => ({ - id: session.id, - knowledgeBaseId: session.knowledgeBaseId, - status: session.status, - name: session.fileName, - contentType: session.contentType, - size: session.fileSize, - expiresAt: '2026-08-05T00:00:00.000Z', - error: null, - document: null, - }), -})) - -import { POST } from '@/app/api/knowledge/[id]/documents/uploads/route' - -const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' -const SESSION = { - id: 'upload-1', - knowledgeBaseId: 'kb-1', - status: 'uploading', - fileName: 'guide.pdf', - contentType: 'application/pdf', - fileSize: 1024, - uploadToken: 'token', - transfer: { - method: 'put' as const, - url: 'https://storage.example/upload', - headers: { 'content-type': 'application/pdf' }, - }, -} - -function request() { - const request = new NextRequest('http://localhost:3000/api/knowledge/kb-1/documents/uploads', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - name: 'guide.pdf', - contentType: 'application/pdf', - size: 1024, - tag1: 'product', - }), - }) - return { - request, - response: POST(request, { params: Promise.resolve({ id: 'kb-1' }) }), - } -} - -describe('POST /api/knowledge/[id]/documents/uploads', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.requireActor.mockResolvedValue({ - id: 'user-1', - sessionId: 'session-1', - name: 'User', - email: 'user@example.com', - }) - mocks.createUpload.mockResolvedValue(SESSION) - }) - - it('constructs a server-authored session principal and delegates creation', async () => { - const call = request() - const response = await call.response - - expect(response.status).toBe(201) - expect(mocks.createUpload).toHaveBeenCalledWith({ - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - input: { - knowledgeBaseId: 'kb-1', - assertedWorkspaceId: WORKSPACE_ID, - name: 'guide.pdf', - contentType: 'application/pdf', - size: 1024, - metadata: { tag1: 'product' }, - }, - request: call.request, - }) - expect(await response.json()).toMatchObject({ - data: { session: { id: 'upload-1' }, uploadToken: 'token' }, - }) - }) -}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts index 26a46296fe2..3d50830db0b 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts @@ -1,53 +1,39 @@ -import { type NextRequest, NextResponse } from 'next/server' import { createKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' import { - knowledgeDocumentUploadErrorResponse, - requireKnowledgeDocumentUploadActor, -} from '@/app/api/knowledge/[id]/documents/uploads/utils' -import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' - -interface KnowledgeDocumentUploadsRouteParams { - params: Promise<{ id: string }> -} + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { toInternalKnowledgeDocumentUpload } from '@/lib/knowledge/api/internal-route' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { createKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions' -export const POST = withRouteHandler( - async (request: NextRequest, context: KnowledgeDocumentUploadsRouteParams) => { - const actor = await requireKnowledgeDocumentUploadActor() - if (actor instanceof NextResponse) return actor - const parsed = await parseRequest(createKnowledgeDocumentUploadContract, request, context) - if (!parsed.success) return parsed.response - const { id: knowledgeBaseId } = parsed.data.params - const { workspaceId, name, contentType, size, ...metadata } = parsed.data.body - try { - const upload = await createKnowledgeDocumentUpload.execute({ - principal: { kind: 'session', userId: actor.id, sessionId: actor.sessionId }, - input: { - knowledgeBaseId, - assertedWorkspaceId: workspaceId, - name, - contentType, - size, - metadata, - }, - request, - }) - return NextResponse.json( - { - data: { - session: toV2KnowledgeDocumentUpload(upload, null), - uploadToken: upload.uploadToken, - transfer: upload.transfer, - }, - }, - { status: 201 } - ) - } catch (error) { - const classified = knowledgeDocumentUploadErrorResponse(error) - if (classified) return classified - throw error +export const POST = defineInternalJsonRoute({ + contract: createKnowledgeDocumentUploadContract, + auth: internalSessionAuth, + operation: knowledgeOperations.uploadCreate, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal upload-session creation behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.uploads, + mapInput: ({ params, body }) => { + const { workspaceId, name, contentType, size, ...metadata } = body + return { + knowledgeBaseId: params.id, + assertedWorkspaceId: workspaceId, + name, + contentType, + size, + metadata, } - } -) + }, + useCase: createKnowledgeDocumentUpload, + present: (upload) => ({ + data: { + session: toInternalKnowledgeDocumentUpload(upload, null), + uploadToken: upload.uploadToken, + transfer: upload.transfer, + }, + }), +}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.test.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.test.ts deleted file mode 100644 index 80ae8df1f47..00000000000 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ getSession: vi.fn() })) - -vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) - -import { requireKnowledgeDocumentUploadActor } from '@/app/api/knowledge/[id]/documents/uploads/utils' - -describe('knowledge-document upload session authentication', () => { - beforeEach(() => vi.clearAllMocks()) - - it('returns the authoritative session id with the authenticated user', async () => { - mocks.getSession.mockResolvedValue({ - user: { id: 'user-1', name: 'User', email: 'user@example.com' }, - session: { id: 'session-1' }, - }) - - await expect(requireKnowledgeDocumentUploadActor()).resolves.toEqual({ - id: 'user-1', - sessionId: 'session-1', - name: 'User', - email: 'user@example.com', - }) - }) - - it('fails fast when authenticated state has no session id', async () => { - mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: {} }) - - await expect(requireKnowledgeDocumentUploadActor()).rejects.toThrow( - 'Authenticated session is missing its session ID' - ) - }) -}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.ts b/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.ts deleted file mode 100644 index 04aa96cc40a..00000000000 --- a/apps/sim/app/api/knowledge/[id]/documents/uploads/utils.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' -import { KnowledgeDocumentUnsupportedMediaTypeError } from '@/lib/knowledge/application/upload-sessions' -import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' - -export interface KnowledgeDocumentUploadActor { - id: string - sessionId: string - name?: string | null - email?: string | null -} - -export async function requireKnowledgeDocumentUploadActor(): Promise< - KnowledgeDocumentUploadActor | NextResponse -> { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const sessionId = session.session?.id - if (!sessionId) throw new Error('Authenticated session is missing its session ID') - return { - id: session.user.id, - sessionId, - name: session.user.name, - email: session.user.email, - } -} - -export function knowledgeDocumentUploadErrorResponse(error: unknown): NextResponse | null { - if (error instanceof KnowledgeDocumentUnsupportedMediaTypeError) { - return NextResponse.json({ error: error.message }, { status: 415 }) - } - if (error instanceof KnowledgeUsageLimitExceededError) { - return NextResponse.json({ error: error.message }, { status: 402 }) - } - return uploadSessionErrorResponse(error) -} diff --git a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts deleted file mode 100644 index ddd9c80cb7f..00000000000 --- a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Tests for knowledge base document upsert API route - * - * @vitest-environment node - */ -import { - auditMock, - createMockRequest, - hybridAuthMockFns, - knowledgeApiUtilsMock, - resetDbChainMock, -} from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) -vi.mock('@sim/audit', () => auditMock) - -vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ - checkActorUsageLimits: vi.fn().mockResolvedValue({ isExceeded: false }), -})) - -vi.mock('@/lib/billing/core/billing-attribution', () => ({ - resolveBillingAttribution: vi.fn().mockResolvedValue({ - actorUserId: 'user-1', - workspaceId: 'ws-1', - billingEntity: { type: 'organization', id: 'org-1' }, - }), - checkAttributedUsageLimits: vi.fn().mockResolvedValue({ isExceeded: false }), -})) - -vi.mock('@/lib/knowledge/documents/service', () => ({ - createDocumentRecords: vi.fn(), - deleteDocument: vi.fn(), - getProcessingConfig: vi.fn().mockReturnValue({ maxConcurrentDocuments: 1, batchSize: 1 }), - processDocumentsWithQueue: vi.fn(), - KnowledgeBaseFileOwnershipError: class KnowledgeBaseFileOwnershipError extends Error {}, -})) - -import { createDocumentRecords, processDocumentsWithQueue } from '@/lib/knowledge/documents/service' -import { POST } from '@/app/api/knowledge/[id]/documents/upsert/route' -import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' - -describe('POST /api/knowledge/[id]/documents/upsert', () => { - const params = Promise.resolve({ id: 'kb-123' }) - - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - authType: 'session', - userName: 'Test User', - userEmail: 'test@example.com', - }) - - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-1', workspaceId: 'ws-1', name: 'KB' }, - } as any) - - vi.mocked(createDocumentRecords).mockResolvedValue([ - { documentId: 'doc-new', filename: 'note.txt' }, - ] as any) - vi.mocked(processDocumentsWithQueue).mockResolvedValue(undefined as any) - }) - - afterAll(() => { - resetDbChainMock() - }) - - const baseBody = { - filename: 'note.txt', - fileSize: 11, - mimeType: 'text/plain', - } - - it('accepts a data: URI', async () => { - const req = createMockRequest('POST', { - ...baseBody, - fileUrl: 'data:text/plain;base64,SGVsbG8gd29ybGQ=', - }) - const res = await POST(req, { params }) - expect(res.status).toBe(200) - expect(createDocumentRecords).toHaveBeenCalled() - }) - - it('accepts an https URL', async () => { - const req = createMockRequest('POST', { - ...baseBody, - fileUrl: 'https://example.com/note.txt', - }) - const res = await POST(req, { params }) - expect(res.status).toBe(200) - expect(createDocumentRecords).toHaveBeenCalled() - }) - - it.each([ - ['absolute local path', '/etc/passwd'], - ['app config path', '/app/.env'], - ['file:// URL', 'file:///etc/passwd'], - ['relative serve path', '/api/files/serve/kb/foo.pdf'], - ['ftp URL', 'ftp://example.com/file.pdf'], - ['parent traversal', '../../etc/passwd'], - ['windows path', 'C:\\Windows\\System32\\config\\SAM'], - ])('rejects %s with 400 and never invokes the pipeline', async (_label, fileUrl) => { - const req = createMockRequest('POST', { ...baseBody, fileUrl }) - const res = await POST(req, { params }) - expect(res.status).toBe(400) - expect(createDocumentRecords).not.toHaveBeenCalled() - expect(processDocumentsWithQueue).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts index 8792517dd4e..2da21e78cd9 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts @@ -1,308 +1,92 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { document } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' -import { getErrorMessage, toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { and, eq, isNull } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { upsertKnowledgeDocumentContract } from '@/lib/api/contracts/knowledge' -import { parseRequest } from '@/lib/api/server' -import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { - checkAttributedUsageLimits, - requireBillingAttributionHeader, - resolveBillingAttribution, -} from '@/lib/billing/core/billing-attribution' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + internalKnowledgeAnalytics, + internalKnowledgeAuthType, + resolveInternalKnowledgeBillingAttribution, +} from '@/lib/knowledge/api/internal-route' import { - createDocumentRecords, - deleteDocument, - getProcessingConfig, - KnowledgeBaseFileOwnershipError, - processDocumentsWithQueue, -} from '@/lib/knowledge/documents/service' + internalKnowledgeErrorPolicies, + internalKnowledgeSessionOrExecutorAuth, +} from '@/lib/knowledge/api/route-policies' +import { upsertKnowledgeDocument } from '@/lib/knowledge/application/documents' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { - createKnowledgeProvenanceResponse, + finalizeKnowledgeProvenanceResponse, resolveKnowledgeDocumentWriteSecretProvenance, } from '@/app/api/knowledge/secret-provenance' -import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' -const logger = createLogger('DocumentUpsertAPI') - -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await context.params - - try { - const parsed = await parseRequest(upsertKnowledgeDocumentContract, req, context) - if (!parsed.success) return parsed.response - const validatedData = parsed.data.body - - logger.info(`[${requestId}] Knowledge base document upsert request`, { - knowledgeBaseId, - hasDocumentId: !!validatedData.documentId, - mimeType: validatedData.mimeType, - fileSize: validatedData.fileSize, - }) - - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - if (validatedData.workflowId) { - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId: validatedData.workflowId, - userId, - action: 'write', - }) - if (!authorization.allowed) { - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status } - ) - } - } - - const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, userId) - - if (!accessCheck.hasAccess) { - if ('notFound' in accessCheck && accessCheck.notFound) { - logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`) - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted to upsert document in unauthorized knowledge base ${knowledgeBaseId}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - /** - * Gate the workspace payer and uploader before mutation so an over-limit - * upsert cannot delete an indexed document. Workspace-less legacy KBs - * retain account-only enforcement. - */ - const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId - const billingAttribution = kbWorkspaceId - ? auth.authType === AuthType.INTERNAL_JWT - ? requireBillingAttributionHeader(req.headers, { - actorUserId: userId, - workspaceId: kbWorkspaceId, - }) - : await resolveBillingAttribution({ - actorUserId: userId, - workspaceId: kbWorkspaceId, - }) - : undefined - const usage = billingAttribution - ? await checkAttributedUsageLimits(billingAttribution) - : await checkActorUsageLimits(userId) - if (usage.isExceeded) { - return NextResponse.json( - { - error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.', - }, - { status: 402 } - ) - } - - const writeProvenance = resolveKnowledgeDocumentWriteSecretProvenance({ - request: req, - payload: validatedData, - authType: auth.authType, - userId, - ...(kbWorkspaceId ? { workspaceId: kbWorkspaceId } : {}), - documents: [validatedData], - }) - if (!writeProvenance.success) return writeProvenance.response - - let existingDocumentId: string | null = null - let isUpdate = false - - if (validatedData.documentId) { - const existingDoc = await db - .select({ id: document.id }) - .from(document) - .where( - and( - eq(document.id, validatedData.documentId), - eq(document.knowledgeBaseId, knowledgeBaseId), - isNull(document.deletedAt) - ) - ) - .limit(1) - - if (existingDoc.length > 0) { - existingDocumentId = existingDoc[0].id - } - } else { - const docsByFilename = await db - .select({ id: document.id }) - .from(document) - .where( - and( - eq(document.filename, validatedData.filename), - eq(document.knowledgeBaseId, knowledgeBaseId), - isNull(document.deletedAt) - ) - ) - .limit(1) - - if (docsByFilename.length > 0) { - existingDocumentId = docsByFilename[0].id - } - } - - if (existingDocumentId) { - isUpdate = true - logger.info( - `[${requestId}] Found existing document ${existingDocumentId}, creating replacement before deleting old` - ) - } - - const createdDocuments = await createDocumentRecords( - [ - { - filename: validatedData.filename, - fileUrl: validatedData.fileUrl, - fileSize: validatedData.fileSize, - mimeType: validatedData.mimeType, - ...(validatedData.documentTagsData && { - documentTagsData: validatedData.documentTagsData, - }), - }, - ], - knowledgeBaseId, - requestId, +export const POST = defineInternalJsonRoute({ + contract: upsertKnowledgeDocumentContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.uploadDocument, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal document-upsert behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.upsert, + parseOptions: { maxBodyBytes: 2 * 1024 * 1024 }, + mapInput: ({ params, body }, { principal, request }) => ({ + knowledgeBaseId: params.id, + documentId: body.documentId, + filename: body.filename, + fileUrl: body.fileUrl, + fileSize: body.fileSize, + mimeType: body.mimeType, + documentTagsData: body.documentTagsData, + processingOptions: body.processingOptions, + resolveBillingAttribution: (workspaceId: string) => + resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), + resolveSecretProvenances: ({ + userId, + workspaceId, + }: { + userId: string + workspaceId: string + }) => { + const resolved = resolveKnowledgeDocumentWriteSecretProvenance({ + request, + payload: body, + authType: internalKnowledgeAuthType(principal), userId, - writeProvenance.provenances - ) - - const firstDocument = createdDocuments[0] - if (!firstDocument) { - logger.error(`[${requestId}] createDocumentRecords returned empty array unexpectedly`) - return NextResponse.json({ error: 'Failed to create document record' }, { status: 500 }) - } - - if (existingDocumentId) { - try { - await deleteDocument(existingDocumentId, requestId) - } catch (deleteError) { - logger.error( - `[${requestId}] Failed to delete old document ${existingDocumentId}, rolling back new record`, - { errorType: toError(deleteError).name } - ) - await deleteDocument(firstDocument.documentId, requestId).catch(() => {}) - return NextResponse.json( - { error: 'Failed to replace existing document' }, - { status: 500 } - ) - } - } - - processDocumentsWithQueue( - createdDocuments, - knowledgeBaseId, - validatedData.processingOptions ?? {}, - requestId, - billingAttribution - ).catch((error: unknown) => { - logger.error(`[${requestId}] Critical error in document processing pipeline`, { - errorType: toError(error).name, - }) + workspaceId, + documents: [body], }) - - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.knowledgeBaseDocumentsUploaded({ - knowledgeBaseId, - documentsCount: 1, - uploadType: 'single', - recipe: validatedData.processingOptions?.recipe, - }) - } catch (_e) { - // Silently fail + if (!resolved.success) { + throw new OrchestrationError('validation', 'Invalid knowledge secret provenance') } - - recordAudit({ - workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: isUpdate ? AuditAction.DOCUMENT_UPDATED : AuditAction.DOCUMENT_UPLOADED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: knowledgeBaseId, - resourceName: validatedData.filename, - description: isUpdate - ? `Upserted (replaced) document "${validatedData.filename}" in knowledge base "${knowledgeBaseId}"` - : `Upserted (created) document "${validatedData.filename}" in knowledge base "${knowledgeBaseId}"`, - metadata: { - knowledgeBaseName: accessCheck.knowledgeBase?.name, - fileName: validatedData.filename, - fileType: validatedData.mimeType, - fileSize: validatedData.fileSize, - previousDocumentId: existingDocumentId, - isUpdate, - }, - request: req, - }) - - return createKnowledgeProvenanceResponse({ - request: req, - authType: auth.authType, - userId, - ...(kbWorkspaceId ? { workspaceId: kbWorkspaceId } : {}), - provenances: - writeProvenance.provenances?.flatMap((provenance) => [ - provenance.filename, - ...provenance.tags.map((tag) => tag.provenance), - ]) ?? [], - body: { - success: true, - data: { - documentsCreated: [ - { - documentId: firstDocument.documentId, - filename: firstDocument.filename, - status: 'pending', - }, - ], - isUpdate, - previousDocumentId: existingDocumentId, - processingMethod: 'background', - processingConfig: { - maxConcurrentDocuments: getProcessingConfig().maxConcurrentDocuments, - batchSize: getProcessingConfig().batchSize, - }, - }, + return resolved.provenances + }, + }), + useCase: upsertKnowledgeDocument, + onSuccess: internalKnowledgeAnalytics.documentUpserted, + present: ({ document, isUpdate, previousDocumentId, processingConfig }) => ({ + success: true as const, + data: { + documentsCreated: [ + { + documentId: document.documentId, + filename: document.filename, + status: 'pending' as const, }, - }) - } catch (error) { - logger.error(`[${requestId}] Error upserting document`, { - errorType: toError(error).name, - }) - - if (error instanceof KnowledgeBaseFileOwnershipError) { - return NextResponse.json( - { error: 'File URL does not reference a file owned by this knowledge base' }, - { status: 403 } - ) - } - const errorMessage = getErrorMessage(error, 'Failed to upsert document') - const isStorageLimitError = - errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit') - const isMissingKnowledgeBase = errorMessage === 'Knowledge base not found' - - return NextResponse.json( - { error: errorMessage }, - { status: isMissingKnowledgeBase ? 404 : isStorageLimitError ? 413 : 500 } - ) - } - } -) + ], + isUpdate, + previousDocumentId, + processingMethod: 'background' as const, + processingConfig, + }, + }), + finalizeResponse: ({ request, principal, result, body }) => + finalizeKnowledgeProvenanceResponse({ + request, + authType: internalKnowledgeAuthType(principal), + userId: result.userId, + workspaceId: result.workspaceId, + body, + provenances: + result.secretProvenances?.flatMap((provenance) => [ + provenance.filename, + ...provenance.tags.map((tag) => tag.provenance), + ]) ?? [], + }), +}) diff --git a/apps/sim/app/api/knowledge/[id]/next-available-slot/route.ts b/apps/sim/app/api/knowledge/[id]/next-available-slot/route.ts index 6e46b7bb18f..ac872c35417 100644 --- a/apps/sim/app/api/knowledge/[id]/next-available-slot/route.ts +++ b/apps/sim/app/api/knowledge/[id]/next-available-slot/route.ts @@ -1,77 +1,19 @@ -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { type NextRequest, NextResponse } from 'next/server' import { nextAvailableSlotContract } from '@/lib/api/contracts/knowledge' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getNextAvailableSlot, getTagDefinitions } from '@/lib/knowledge/tags/service' -import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' - -const logger = createLogger('NextAvailableSlotAPI') - -// GET /api/knowledge/[id]/next-available-slot - Get the next available tag slot for a knowledge base and field type -export const GET = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) - const parsed = await parseRequest(nextAvailableSlotContract, req, context) - if (!parsed.success) { - return NextResponse.json({ error: 'fieldType parameter is required' }, { status: 400 }) - } - const { id: knowledgeBaseId } = parsed.data.params - const { fieldType } = parsed.data.query - - try { - logger.info( - `[${requestId}] Getting next available slot for knowledge base ${knowledgeBaseId}, fieldType: ${fieldType}` - ) - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id) - if (!accessCheck.hasAccess) { - return NextResponse.json( - { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, - { status: accessCheck.notFound ? 404 : 403 } - ) - } - - // Get existing definitions once and reuse - const existingDefinitions = await getTagDefinitions(knowledgeBaseId) - const usedSlots = existingDefinitions - .filter((def) => def.fieldType === fieldType) - .map((def) => def.tagSlot) - - // Create a map for efficient lookup and pass to avoid redundant query - const existingBySlot = new Map(existingDefinitions.map((def) => [def.tagSlot as string, def])) - const nextAvailableSlot = await getNextAvailableSlot( - knowledgeBaseId, - fieldType, - existingBySlot - ) - - logger.info( - `[${requestId}] Next available slot for fieldType ${fieldType}: ${nextAvailableSlot}` - ) - - const result = { - nextAvailableSlot, - fieldType, - usedSlots, - totalSlots: 7, - availableSlots: nextAvailableSlot ? 7 - usedSlots.length : 0, - } - - return NextResponse.json({ - success: true, - data: result, - }) - } catch (error) { - logger.error(`[${requestId}] Error getting next available slot`, error) - return NextResponse.json({ error: 'Failed to get next available slot' }, { status: 500 }) - } - } -) +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { + internalKnowledgeErrorPolicies, + internalKnowledgeSessionOrExecutorAuth, +} from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { readNextKnowledgeTagSlot } from '@/lib/knowledge/application/tags' + +export const GET = defineInternalJsonRoute({ + contract: nextAvailableSlotContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.readNextTagSlot, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal tag-slot behavior' }), + errorPolicy: internalKnowledgeErrorPolicies.tags, + mapInput: ({ params, query }) => ({ knowledgeBaseId: params.id, fieldType: query.fieldType }), + useCase: readNextKnowledgeTagSlot, + present: (data) => ({ success: true as const, data }), +}) diff --git a/apps/sim/app/api/knowledge/[id]/restore/route.ts b/apps/sim/app/api/knowledge/[id]/restore/route.ts index a5ed8b85808..fa65567869e 100644 --- a/apps/sim/app/api/knowledge/[id]/restore/route.ts +++ b/apps/sim/app/api/knowledge/[id]/restore/route.ts @@ -1,76 +1,23 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' import { restoreKnowledgeBaseContract } from '@/lib/api/contracts/knowledge' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { - messageForOrchestrationError, - statusForOrchestrationError, -} from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - getRestorableKnowledgeBase, - performRestoreKnowledgeBase, -} from '@/lib/knowledge/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('RestoreKnowledgeBaseAPI') - -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const parsed = await parseRequest(restoreKnowledgeBaseContract, request, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const kb = await getRestorableKnowledgeBase(id) - - if (!kb) { - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) - } - - if (kb.workspaceId) { - const permission = await getUserEntityPermissions(auth.userId, 'workspace', kb.workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - } else if (kb.userId !== auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const result = await performRestoreKnowledgeBase({ - knowledgeBaseId: id, - userId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - source: 'ui', - requestId, - request, - }) - if (!result.success) { - return NextResponse.json( - { error: messageForOrchestrationError(result, 'Failed to restore knowledge base') }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - - logger.info(`[${requestId}] Restored knowledge base ${id}`) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error(`[${requestId}] Error restoring knowledge base ${id}`, error) - return NextResponse.json( - { error: getErrorMessage(error, 'Internal server error') }, - { status: 500 } - ) - } - } -) + defineInternalJsonRoute, + internalJsonPresenters, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { restoreInternalKnowledgeBase } from '@/lib/knowledge/application/knowledge-bases' +import { knowledgeSessionOperations } from '@/lib/knowledge/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: restoreKnowledgeBaseContract, + auth: internalSessionAuth, + operation: knowledgeSessionOperations.restore, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal knowledge base restore behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.restore, + mapInput: ({ params }) => ({ knowledgeBaseId: params.id }), + useCase: restoreInternalKnowledgeBase, + present: internalJsonPresenters.successFrom('success'), +}) diff --git a/apps/sim/app/api/knowledge/[id]/route.test.ts b/apps/sim/app/api/knowledge/[id]/route.test.ts deleted file mode 100644 index 882a1df6f0d..00000000000 --- a/apps/sim/app/api/knowledge/[id]/route.test.ts +++ /dev/null @@ -1,454 +0,0 @@ -/** - * Tests for knowledge base by ID API route - * - * @vitest-environment node - */ -import { - auditMock, - authMockFns, - createMockRequest, - knowledgeApiUtilsMock, - resetDbChainMock, -} from '@sim/testing' -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/audit', () => auditMock) - -vi.mock('@/lib/knowledge/service', async (importOriginal) => { - const actual = await importOriginal<typeof import('@/lib/knowledge/service')>() - return { - ...actual, - getKnowledgeBaseById: vi.fn(), - updateKnowledgeBase: vi.fn(), - deleteKnowledgeBase: vi.fn(), - KnowledgeBasePermissionError: actual.KnowledgeBasePermissionError, - } -}) - -vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) - -import { - deleteKnowledgeBase, - getKnowledgeBaseById, - KnowledgeBasePermissionError, - updateKnowledgeBase, -} from '@/lib/knowledge/service' -import { DELETE, GET, PUT } from '@/app/api/knowledge/[id]/route' -import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' - -describe('Knowledge Base By ID API Route', () => { - const mockKnowledgeBase = { - id: 'kb-123', - userId: 'user-123', - name: 'Test Knowledge Base', - description: 'Test description', - tokenCount: 100, - embeddingModel: 'text-embedding-3-small', - embeddingDimension: 1536, - chunkingConfig: { maxSize: 1024, minSize: 100, overlap: 200 }, - createdAt: new Date(), - updatedAt: new Date(), - workspaceId: null, - deletedAt: null, - } - - const resetMocks = () => { - vi.clearAllMocks() - resetDbChainMock() - } - - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - - vi.stubGlobal('crypto', { - randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), - }) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - afterAll(() => { - resetDbChainMock() - }) - - describe('GET /api/knowledge/[id]', () => { - const mockParams = Promise.resolve({ id: 'kb-123' }) - - it('should retrieve knowledge base successfully for authenticated user', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - - vi.mocked(checkKnowledgeBaseAccess).mockResolvedValueOnce({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - vi.mocked(getKnowledgeBaseById).mockResolvedValueOnce(mockKnowledgeBase) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.id).toBe('kb-123') - expect(data.data.name).toBe('Test Knowledge Base') - expect(checkKnowledgeBaseAccess).toHaveBeenCalledWith('kb-123', 'user-123') - expect(getKnowledgeBaseById).toHaveBeenCalledWith('kb-123') - }) - - it('should return unauthorized for unauthenticated user', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data.error).toBe('Unauthorized') - }) - - it('should return not found for non-existent knowledge base', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - - vi.mocked(checkKnowledgeBaseAccess).mockResolvedValueOnce({ - hasAccess: false, - notFound: true, - }) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(404) - expect(data.error).toBe('Knowledge base not found') - }) - - it('should return unauthorized for knowledge base owned by different user', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - - vi.mocked(checkKnowledgeBaseAccess).mockResolvedValueOnce({ - hasAccess: false, - notFound: false, - }) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data.error).toBe('Unauthorized') - }) - - it('should return not found when service returns null', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - - vi.mocked(checkKnowledgeBaseAccess).mockResolvedValueOnce({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - vi.mocked(getKnowledgeBaseById).mockResolvedValueOnce(null) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(404) - expect(data.error).toBe('Knowledge base not found') - }) - - it('should handle database errors', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - - vi.mocked(checkKnowledgeBaseAccess).mockRejectedValueOnce(new Error('Database error')) - - const req = createMockRequest('GET') - const response = await GET(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.error).toBe('Failed to fetch knowledge base') - }) - }) - - describe('PUT /api/knowledge/[id]', () => { - const mockParams = Promise.resolve({ id: 'kb-123' }) - const validUpdateData = { - name: 'Updated Knowledge Base', - description: 'Updated description', - } - - it('should update knowledge base successfully', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - - resetMocks() - - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - const updatedKnowledgeBase = { ...mockKnowledgeBase, ...validUpdateData } - vi.mocked(updateKnowledgeBase).mockResolvedValueOnce(updatedKnowledgeBase) - - const req = createMockRequest('PUT', validUpdateData) - const response = await PUT(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.name).toBe('Updated Knowledge Base') - expect(checkKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-123', 'user-123') - expect(updateKnowledgeBase).toHaveBeenCalledWith( - 'kb-123', - { - name: validUpdateData.name, - description: validUpdateData.description, - workspaceId: undefined, - chunkingConfig: undefined, - }, - expect.any(String), - { actorUserId: 'user-123' } - ) - }) - - it('returns 403 when service rejects a cross-workspace transfer', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'attacker', email: 'a@example.com' }, - }) - - resetMocks() - - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123', workspaceId: 'ws-current' }, - }) - - vi.mocked(updateKnowledgeBase).mockRejectedValueOnce( - new KnowledgeBasePermissionError('User does not have permission on the target workspace') - ) - - const req = createMockRequest('PUT', { workspaceId: 'ws-target' }) - const response = await PUT(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(403) - expect(data.error).toBe('User does not have permission on the target workspace') - }) - - it('returns 403 when service rejects clearing workspaceId', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - - resetMocks() - - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123', workspaceId: 'ws-current' }, - }) - - vi.mocked(updateKnowledgeBase).mockRejectedValueOnce( - new KnowledgeBasePermissionError('Knowledge base workspace cannot be cleared') - ) - - const req = createMockRequest('PUT', { workspaceId: null }) - const response = await PUT(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(403) - expect(data.error).toBe('Knowledge base workspace cannot be cleared') - }) - - it('should return unauthorized for unauthenticated user', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const req = createMockRequest('PUT', validUpdateData) - const response = await PUT(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data.error).toBe('Unauthorized') - }) - - it('should return not found for non-existent knowledge base', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - - resetMocks() - - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ - hasAccess: false, - notFound: true, - }) - - const req = createMockRequest('PUT', validUpdateData) - const response = await PUT(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(404) - expect(data.error).toBe('Knowledge base not found') - }) - - it('should validate update data', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - - resetMocks() - - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - const invalidData = { - name: '', - } - - const req = createMockRequest('PUT', invalidData) - const response = await PUT(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toBe('Validation error') - expect(data.details).toBeDefined() - }) - - it('should handle database errors during update', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - vi.mocked(updateKnowledgeBase).mockRejectedValueOnce(new Error('Database error')) - - const req = createMockRequest('PUT', validUpdateData) - const response = await PUT(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.error).toBe('Failed to update knowledge base') - }) - }) - - describe('DELETE /api/knowledge/[id]', () => { - const mockParams = Promise.resolve({ id: 'kb-123' }) - - it('should delete knowledge base successfully', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - - resetMocks() - - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - vi.mocked(deleteKnowledgeBase).mockResolvedValueOnce(undefined) - - const req = createMockRequest('DELETE') - const response = await DELETE(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.message).toBe('Knowledge base deleted successfully') - expect(checkKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-123', 'user-123') - expect(deleteKnowledgeBase).toHaveBeenCalledWith('kb-123', expect.any(String)) - }) - - it('should return unauthorized for unauthenticated user', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const req = createMockRequest('DELETE') - const response = await DELETE(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data.error).toBe('Unauthorized') - }) - - it('should return not found for non-existent knowledge base', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - - resetMocks() - - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ - hasAccess: false, - notFound: true, - }) - - const req = createMockRequest('DELETE') - const response = await DELETE(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(404) - expect(data.error).toBe('Knowledge base not found') - }) - - it('should return unauthorized for knowledge base owned by different user', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - - resetMocks() - - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ - hasAccess: false, - notFound: false, - }) - - const req = createMockRequest('DELETE') - const response = await DELETE(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data.error).toBe('Unauthorized') - }) - - it('should handle database errors during delete', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - - vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({ - hasAccess: true, - knowledgeBase: { id: 'kb-123', userId: 'user-123' }, - }) - - vi.mocked(deleteKnowledgeBase).mockRejectedValueOnce(new Error('Database error')) - - const req = createMockRequest('DELETE') - const response = await DELETE(req, { params: mockParams }) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.error).toBe('Failed to delete knowledge base') - }) - }) -}) diff --git a/apps/sim/app/api/knowledge/[id]/route.ts b/apps/sim/app/api/knowledge/[id]/route.ts index 3b91289af20..42e10e2ea08 100644 --- a/apps/sim/app/api/knowledge/[id]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/route.ts @@ -1,174 +1,68 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { updateKnowledgeBaseContract } from '@/lib/api/contracts/knowledge' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { - messageForOrchestrationError, - statusForOrchestrationError, -} from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + deleteKnowledgeBaseContract, + getKnowledgeBaseContract, + updateKnowledgeBaseContract, +} from '@/lib/api/contracts/knowledge' +import { validationErrorResponse } from '@/lib/api/server' import { - performDeleteKnowledgeBase, - performUpdateKnowledgeBase, -} from '@/lib/knowledge/orchestration' -import { getKnowledgeBaseById } from '@/lib/knowledge/service' -import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' - -const logger = createLogger('KnowledgeBaseByIdAPI') - -export const GET = withRouteHandler( - async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const { id } = await params - - try { - const auth = await checkSessionOrInternalAuth(_request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized knowledge base access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - const accessCheck = await checkKnowledgeBaseAccess(id, userId) - - if (!accessCheck.hasAccess) { - if ('notFound' in accessCheck && accessCheck.notFound) { - logger.warn(`[${requestId}] Knowledge base not found: ${id}`) - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted to access unauthorized knowledge base ${id}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const knowledgeBaseData = await getKnowledgeBaseById(id) - - if (!knowledgeBaseData) { - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) - } - - logger.info(`[${requestId}] Retrieved knowledge base: ${id} for user ${userId}`) - - return NextResponse.json({ - success: true, - data: knowledgeBaseData, - }) - } catch (error) { - logger.error(`[${requestId}] Error fetching knowledge base`, error) - return NextResponse.json({ error: 'Failed to fetch knowledge base' }, { status: 500 }) - } - } -) - -export const PUT = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const { id } = await context.params - - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized knowledge base update attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId) - - if (!accessCheck.hasAccess) { - if ('notFound' in accessCheck && accessCheck.notFound) { - logger.warn(`[${requestId}] Knowledge base not found: ${id}`) - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted to update unauthorized knowledge base ${id}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(updateKnowledgeBaseContract, req, context) - if (!parsed.success) return parsed.response - - const body = parsed.data.body - - const outcome = await performUpdateKnowledgeBase({ - knowledgeBaseId: id, - workspaceId: accessCheck.knowledgeBase.workspaceId ?? null, - userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - source: 'ui', - updates: { - name: body.name, - description: body.description, - workspaceId: body.workspaceId, - folderId: body.folderId, - chunkingConfig: body.chunkingConfig, - }, - requestId, - request: req, - }) - if (!outcome.success) { - return NextResponse.json( - { error: messageForOrchestrationError(outcome, 'Failed to update knowledge base') }, - { status: statusForOrchestrationError(outcome.errorCode) } - ) - } - - return NextResponse.json({ success: true, data: outcome.knowledgeBase }) - } -) - -export const DELETE = withRouteHandler( - async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const { id } = await params - - const auth = await checkSessionOrInternalAuth(_request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized knowledge base delete attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId) - - if (!accessCheck.hasAccess) { - if ('notFound' in accessCheck && accessCheck.notFound) { - logger.warn(`[${requestId}] Knowledge base not found: ${id}`) - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted to delete unauthorized knowledge base ${id}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const outcome = await performDeleteKnowledgeBase({ - knowledgeBase: { - id, - name: accessCheck.knowledgeBase.name, - workspaceId: accessCheck.knowledgeBase.workspaceId ?? null, - }, - userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - source: 'ui', - requestId, - request: _request, - }) - if (!outcome.success) { - return NextResponse.json( - { error: messageForOrchestrationError(outcome, 'Failed to delete knowledge base') }, - { status: statusForOrchestrationError(outcome.errorCode) } - ) - } - - return NextResponse.json({ - success: true, - data: { message: 'Knowledge base deleted successfully' }, - }) - } -) + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgePresenters } from '@/lib/knowledge/api/internal-route' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { + deleteInternalKnowledgeBase, + readInternalKnowledgeBase, + updateInternalKnowledgeBase, +} from '@/lib/knowledge/application/knowledge-bases' +import { knowledgeSessionOperations } from '@/lib/knowledge/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: getKnowledgeBaseContract, + auth: internalSessionAuth, + operation: knowledgeSessionOperations.read, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal knowledge base detail behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.read, + mapInput: ({ params }) => ({ knowledgeBaseId: params.id }), + useCase: readInternalKnowledgeBase, + present: internalKnowledgePresenters.read, +}) + +export const PUT = defineInternalJsonRoute({ + contract: updateKnowledgeBaseContract, + auth: internalSessionAuth, + operation: knowledgeSessionOperations.update, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal knowledge base update behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.update, + parseOptions: { + validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'), + }, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.id, + name: body.name, + description: body.description, + workspaceId: body.workspaceId, + folderId: body.folderId, + chunkingConfig: body.chunkingConfig, + }), + useCase: updateInternalKnowledgeBase, + present: internalKnowledgePresenters.read, +}) + +export const DELETE = defineInternalJsonRoute({ + contract: deleteKnowledgeBaseContract, + auth: internalSessionAuth, + operation: knowledgeSessionOperations.delete, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal knowledge base deletion behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.delete, + mapInput: ({ params }) => ({ knowledgeBaseId: params.id }), + useCase: deleteInternalKnowledgeBase, + present: internalKnowledgePresenters.deleted, +}) diff --git a/apps/sim/app/api/knowledge/[id]/tag-definitions/[tagId]/route.ts b/apps/sim/app/api/knowledge/[id]/tag-definitions/[tagId]/route.ts index 17772638f3e..0f40474f658 100644 --- a/apps/sim/app/api/knowledge/[id]/tag-definitions/[tagId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/tag-definitions/[tagId]/route.ts @@ -1,58 +1,28 @@ -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { type NextRequest, NextResponse } from 'next/server' import { deleteTagDefinitionContract } from '@/lib/api/contracts/knowledge' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' import { - deleteTagDefinition, - KnowledgeTagProvenanceConflictError, -} from '@/lib/knowledge/tags/service' -import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' + internalKnowledgeErrorPolicies, + internalKnowledgeSessionOrExecutorAuth, +} from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { deleteKnowledgeTag } from '@/lib/knowledge/application/tags' export const dynamic = 'force-dynamic' -const logger = createLogger('TagDefinitionAPI') - -// DELETE /api/knowledge/[id]/tag-definitions/[tagId] - Delete a tag definition -export const DELETE = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string; tagId: string }> }) => { - const requestId = generateId().slice(0, 8) - const parsed = await parseRequest(deleteTagDefinitionContract, req, context) - if (!parsed.success) return parsed.response - const { id: knowledgeBaseId, tagId } = parsed.data.params - - try { - logger.info( - `[${requestId}] Deleting tag definition ${tagId} from knowledge base ${knowledgeBaseId}` - ) - - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!accessCheck.hasAccess) { - return NextResponse.json( - { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, - { status: accessCheck.notFound ? 404 : 403 } - ) - } - - const deletedTag = await deleteTagDefinition(knowledgeBaseId, tagId, requestId) - - return NextResponse.json({ - success: true, - message: `Tag definition "${deletedTag.displayName}" deleted successfully`, - }) - } catch (error) { - if (error instanceof KnowledgeTagProvenanceConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } - logger.error(`[${requestId}] Error deleting tag definition`, error) - return NextResponse.json({ error: 'Failed to delete tag definition' }, { status: 500 }) - } - } -) +export const DELETE = defineInternalJsonRoute({ + contract: deleteTagDefinitionContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.deleteTag, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal tag-delete behavior' }), + errorPolicy: internalKnowledgeErrorPolicies.tags, + mapInput: ({ params }) => ({ + knowledgeBaseId: params.id, + tagDefinitionId: params.tagId, + source: 'ui' as const, + }), + useCase: deleteKnowledgeTag, + present: (deleted) => ({ + success: true as const, + message: `Tag definition "${deleted.displayName}" deleted successfully`, + }), +}) diff --git a/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts b/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts index 8d8b1cc41be..baed4d96f80 100644 --- a/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts +++ b/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts @@ -1,113 +1,48 @@ -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { type NextRequest, NextResponse } from 'next/server' -import { createTagDefinitionContract } from '@/lib/api/contracts/knowledge' -import { parseRequest } from '@/lib/api/server' -import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants' -import { createTagDefinition, getTagDefinitions } from '@/lib/knowledge/tags/service' -import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' +import { + createTagDefinitionContract, + listTagDefinitionsContract, +} from '@/lib/api/contracts/knowledge' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { toInternalKnowledgeTag } from '@/lib/knowledge/api/internal-route' +import { + internalKnowledgeErrorPolicies, + internalKnowledgeSessionOrExecutorAuth, +} from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { createKnowledgeTag, listKnowledgeTags } from '@/lib/knowledge/application/tags' export const dynamic = 'force-dynamic' -const logger = createLogger('KnowledgeBaseTagDefinitionsAPI') - -// GET /api/knowledge/[id]/tag-definitions - Get all tag definitions for a knowledge base -export const GET = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await params - - try { - logger.info(`[${requestId}] Getting tag definitions for knowledge base ${knowledgeBaseId}`) - - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success) { - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) - } - - // For session auth, verify KB access. Internal JWT is trusted. - if (auth.authType === AuthType.SESSION && auth.userId) { - const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!accessCheck.hasAccess) { - return NextResponse.json( - { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, - { status: accessCheck.notFound ? 404 : 403 } - ) - } - } - - const tagDefinitions = await getTagDefinitions(knowledgeBaseId) - - logger.info( - `[${requestId}] Retrieved ${tagDefinitions.length} tag definitions (${auth.authType})` - ) - - return NextResponse.json({ - success: true, - data: tagDefinitions, - }) - } catch (error) { - logger.error(`[${requestId}] Error getting tag definitions`, error) - return NextResponse.json({ error: 'Failed to get tag definitions' }, { status: 500 }) - } - } -) - -// POST /api/knowledge/[id]/tag-definitions - Create a new tag definition -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await context.params - - try { - logger.info(`[${requestId}] Creating tag definition for knowledge base ${knowledgeBaseId}`) - - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success) { - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) - } - - // For session auth, verify KB access. Internal JWT is trusted. - if (auth.authType === AuthType.SESSION && auth.userId) { - const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!accessCheck.hasAccess) { - return NextResponse.json( - { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, - { status: accessCheck.notFound ? 404 : 403 } - ) - } - } - - const parsed = await parseRequest(createTagDefinitionContract, req, context) - if (!parsed.success) return parsed.response - - const validatedData = parsed.data.body - if (!(SUPPORTED_FIELD_TYPES as readonly string[]).includes(validatedData.fieldType)) { - return NextResponse.json( - { error: 'Invalid request data', details: 'Invalid field type' }, - { status: 400 } - ) - } - - const newTagDefinition = await createTagDefinition( - { - knowledgeBaseId, - tagSlot: validatedData.tagSlot, - displayName: validatedData.displayName, - fieldType: validatedData.fieldType, - }, - requestId - ) - - return NextResponse.json({ - success: true, - data: newTagDefinition, - }) - } catch (error) { - logger.error(`[${requestId}] Error creating tag definition`, error) - return NextResponse.json({ error: 'Failed to create tag definition' }, { status: 500 }) - } - } -) +export const GET = defineInternalJsonRoute({ + contract: listTagDefinitionsContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.listTags, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal tag-list behavior' }), + errorPolicy: internalKnowledgeErrorPolicies.tags, + mapInput: ({ params }) => ({ knowledgeBaseId: params.id }), + useCase: listKnowledgeTags, + present: ({ tagDefinitions }) => ({ + success: true as const, + data: tagDefinitions.map(toInternalKnowledgeTag), + }), +}) + +export const POST = defineInternalJsonRoute({ + contract: createTagDefinitionContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.createTag, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal tag-create behavior' }), + errorPolicy: internalKnowledgeErrorPolicies.tags, + mapInput: ({ params, body }) => ({ + knowledgeBaseId: params.id, + tagSlot: body.tagSlot, + displayName: body.displayName, + fieldType: body.fieldType, + source: 'ui' as const, + }), + useCase: createKnowledgeTag, + present: ({ tagDefinition }) => ({ + success: true as const, + data: toInternalKnowledgeTag(tagDefinition), + }), +}) diff --git a/apps/sim/app/api/knowledge/[id]/tag-usage/route.ts b/apps/sim/app/api/knowledge/[id]/tag-usage/route.ts index 7412ccf307a..b71e73fb548 100644 --- a/apps/sim/app/api/knowledge/[id]/tag-usage/route.ts +++ b/apps/sim/app/api/knowledge/[id]/tag-usage/route.ts @@ -1,56 +1,21 @@ -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { type NextRequest, NextResponse } from 'next/server' import { getTagUsageContract } from '@/lib/api/contracts/knowledge' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getTagUsage } from '@/lib/knowledge/tags/service' -import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { + internalKnowledgeErrorPolicies, + internalKnowledgeSessionOrExecutorAuth, +} from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { readDetailedKnowledgeTagUsage } from '@/lib/knowledge/application/tags' export const dynamic = 'force-dynamic' -const logger = createLogger('TagUsageAPI') - -// GET /api/knowledge/[id]/tag-usage - Get usage statistics for all tag definitions -export const GET = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) - const parsed = await parseRequest(getTagUsageContract, req, context) - if (!parsed.success) return parsed.response - const { id: knowledgeBaseId } = parsed.data.params - - try { - logger.info( - `[${requestId}] Getting tag usage statistics for knowledge base ${knowledgeBaseId}` - ) - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id) - if (!accessCheck.hasAccess) { - return NextResponse.json( - { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, - { status: accessCheck.notFound ? 404 : 403 } - ) - } - - const usageStats = await getTagUsage(knowledgeBaseId, requestId) - - logger.info( - `[${requestId}] Retrieved usage statistics for ${usageStats.length} tag definitions` - ) - - return NextResponse.json({ - success: true, - data: usageStats, - }) - } catch (error) { - logger.error(`[${requestId}] Error getting tag usage statistics`, error) - return NextResponse.json({ error: 'Failed to get tag usage statistics' }, { status: 500 }) - } - } -) +export const GET = defineInternalJsonRoute({ + contract: getTagUsageContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.readDetailedTagUsage, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal tag-usage behavior' }), + errorPolicy: internalKnowledgeErrorPolicies.tags, + mapInput: ({ params }) => ({ knowledgeBaseId: params.id }), + useCase: readDetailedKnowledgeTagUsage, + present: ({ usage }) => ({ success: true as const, data: usage }), +}) diff --git a/apps/sim/app/api/knowledge/migrated-routes.test.ts b/apps/sim/app/api/knowledge/migrated-routes.test.ts new file mode 100644 index 00000000000..c25aa75d757 --- /dev/null +++ b/apps/sim/app/api/knowledge/migrated-routes.test.ts @@ -0,0 +1,552 @@ +/** + * @vitest-environment node + */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + useCase: (id: string, execute: ReturnType<typeof vi.fn>) => ({ operation: { id }, execute }), + listDocuments: vi.fn(), + createDocuments: vi.fn(), + bulkDocuments: vi.fn(), + readDocument: vi.fn(), + updateDocument: vi.fn(), + deleteDocument: vi.fn(), + upsertDocument: vi.fn(), + listConnectors: vi.fn(), + createConnector: vi.fn(), + readConnector: vi.fn(), + updateConnector: vi.fn(), + deleteConnector: vi.fn(), + syncConnector: vi.fn(), + listConnectorDocuments: vi.fn(), + updateConnectorDocuments: vi.fn(), + search: vi.fn(), + createUpload: vi.fn(), + issueParts: vi.fn(), + completeUpload: vi.fn(), + cancelUpload: vi.fn(), + persistedResponse: vi.fn(), + provenanceResponse: vi.fn(), + registryResponse: vi.fn(), + resolveDocumentProvenance: vi.fn(), + readKnowledgeBase: vi.fn(), + updateKnowledgeBase: vi.fn(), + deleteKnowledgeBase: vi.fn(), + restoreKnowledgeBase: vi.fn(), + platformUpload: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@/lib/knowledge/application/documents', () => ({ + listKnowledgeDocuments: mocks.useCase('knowledge.documents.list', mocks.listDocuments), + createKnowledgeDocuments: mocks.useCase('knowledge.documents.upload', mocks.createDocuments), + bulkUpdateKnowledgeDocuments: mocks.useCase('knowledge.documents.bulk', mocks.bulkDocuments), + readKnowledgeDocument: mocks.useCase('knowledge.documents.read', mocks.readDocument), + updateKnowledgeDocument: mocks.useCase('knowledge.documents.update', mocks.updateDocument), + deleteKnowledgeDocument: mocks.useCase('knowledge.documents.delete', mocks.deleteDocument), + upsertKnowledgeDocument: mocks.useCase('knowledge.documents.upload', mocks.upsertDocument), +})) + +vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ + readInternalKnowledgeBase: mocks.useCase('knowledge.session.read', mocks.readKnowledgeBase), + updateInternalKnowledgeBase: mocks.useCase('knowledge.session.update', mocks.updateKnowledgeBase), + deleteInternalKnowledgeBase: mocks.useCase('knowledge.session.delete', mocks.deleteKnowledgeBase), + restoreInternalKnowledgeBase: mocks.useCase( + 'knowledge.session.restore', + mocks.restoreKnowledgeBase + ), +})) + +vi.mock('@/lib/knowledge/application/connectors', () => ({ + listKnowledgeConnectors: mocks.useCase('knowledge.connectors.list', mocks.listConnectors), + createKnowledgeConnector: mocks.useCase('knowledge.connectors.create', mocks.createConnector), + readKnowledgeConnector: mocks.useCase('knowledge.connectors.read', mocks.readConnector), + updateKnowledgeConnector: mocks.useCase('knowledge.connectors.update', mocks.updateConnector), + deleteKnowledgeConnector: mocks.useCase('knowledge.connectors.delete', mocks.deleteConnector), + syncKnowledgeConnector: mocks.useCase('knowledge.connectors.sync', mocks.syncConnector), + listKnowledgeConnectorDocuments: mocks.useCase( + 'knowledge.connectors.documents.list', + mocks.listConnectorDocuments + ), + updateKnowledgeConnectorDocuments: mocks.useCase( + 'knowledge.connectors.documents.update', + mocks.updateConnectorDocuments + ), +})) + +vi.mock('@/lib/knowledge/application/search', () => ({ + KnowledgeSearchProvenanceUnavailableError: class extends Error {}, + searchKnowledge: mocks.useCase('knowledge.search', mocks.search), +})) + +vi.mock('@/lib/knowledge/application/upload-sessions', () => ({ + KnowledgeDocumentUnsupportedMediaTypeError: class extends Error {}, + createKnowledgeDocumentUpload: mocks.useCase( + 'knowledge.documents.upload.create', + mocks.createUpload + ), + issueKnowledgeDocumentUploadParts: mocks.useCase( + 'knowledge.documents.upload.parts', + mocks.issueParts + ), + completeKnowledgeDocumentUpload: mocks.useCase( + 'knowledge.documents.upload.complete', + mocks.completeUpload + ), + cancelKnowledgeDocumentUpload: mocks.useCase( + 'knowledge.documents.upload.cancel', + mocks.cancelUpload + ), +})) + +vi.mock('@/app/api/knowledge/secret-provenance', () => ({ + finalizeKnowledgePersistedResponse: mocks.persistedResponse, + finalizeKnowledgeProvenanceResponse: mocks.provenanceResponse, + finalizeKnowledgeRegistryResponse: mocks.registryResponse, + resolveKnowledgeDocumentWriteSecretProvenance: mocks.resolveDocumentProvenance, +})) + +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { knowledgeBaseDocumentsUploaded: mocks.platformUpload }, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { + GET as listConnectorDocuments, + PATCH as updateConnectorDocuments, +} from '@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route' +import { PUT as updateDocument } from '@/app/api/knowledge/[id]/documents/[documentId]/route' +import { + POST as createDocuments, + GET as listDocuments, +} from '@/app/api/knowledge/[id]/documents/route' +import { POST as completeUpload } from '@/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route' +import { POST as upsertDocument } from '@/app/api/knowledge/[id]/documents/upsert/route' +import { POST as restoreKnowledgeBase } from '@/app/api/knowledge/[id]/restore/route' +import { + DELETE as deleteKnowledgeBase, + GET as readKnowledgeBase, + PUT as updateKnowledgeBase, +} from '@/app/api/knowledge/[id]/route' +import { POST as search } from '@/app/api/knowledge/search/route' + +const session = { + user: { id: 'user-1', email: 'user@example.com', name: 'User' }, + session: { id: 'session-1' }, +} + +const document = { + id: 'document-1', + knowledgeBaseId: 'knowledge-1', + filename: 'guide.pdf', + fileUrl: 'https://example.com/guide.pdf', + fileSize: 42, + mimeType: 'application/pdf', + chunkCount: 1, + tokenCount: 5, + characterCount: 20, + processingStatus: 'completed' as const, + enabled: true, + uploadedAt: new Date('2026-01-01T00:00:00Z'), +} + +const knowledgeBase = { + id: 'knowledge-1', + userId: 'user-1', + name: 'Docs', + description: null, + tokenCount: 0, + embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, + chunkingConfig: { maxSize: 1024, minSize: 100, overlap: 200 }, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + deletedAt: null, + workspaceId: null, + folderId: null, + docCount: 0, + connectorTypes: [], +} + +describe('migrated internal Knowledge routes', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue(session) + mocks.persistedResponse.mockResolvedValue({}) + mocks.provenanceResponse.mockResolvedValue({}) + mocks.registryResponse.mockReturnValue({}) + mocks.resolveDocumentProvenance.mockReturnValue({ success: true }) + }) + + it('authenticates before parsing malformed document JSON', async () => { + authMockFns.mockGetSession.mockResolvedValueOnce(null) + const request = new NextRequest('http://localhost/api/knowledge/knowledge-1/documents', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{', + }) + + const response = await createDocuments(request, { + params: Promise.resolve({ id: 'knowledge-1' }), + }) + + expect(response.status).toBe(401) + expect(mocks.createDocuments).not.toHaveBeenCalled() + }) + + it('authenticates before parsing malformed knowledge base JSON', async () => { + authMockFns.mockGetSession.mockResolvedValueOnce(null) + const request = new NextRequest('http://localhost/api/knowledge/knowledge-1', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: '{', + }) + + const response = await updateKnowledgeBase(request, { + params: Promise.resolve({ id: 'knowledge-1' }), + }) + + expect(response.status).toBe(401) + expect(mocks.updateKnowledgeBase).not.toHaveBeenCalled() + }) + + it('preserves knowledge base detail, delete, and restore envelopes', async () => { + mocks.readKnowledgeBase.mockResolvedValue({ knowledgeBase }) + const readResponse = await readKnowledgeBase(createMockRequest('GET'), { + params: Promise.resolve({ id: 'knowledge-1' }), + }) + await expect(readResponse.json()).resolves.toEqual({ + success: true, + data: expect.objectContaining({ + id: 'knowledge-1', + workspaceId: null, + createdAt: '2026-01-01T00:00:00.000Z', + }), + }) + + mocks.deleteKnowledgeBase.mockResolvedValue({ success: true }) + const deleteResponse = await deleteKnowledgeBase(createMockRequest('DELETE'), { + params: Promise.resolve({ id: 'knowledge-1' }), + }) + await expect(deleteResponse.json()).resolves.toEqual({ + success: true, + data: { message: 'Knowledge base deleted successfully' }, + }) + + mocks.restoreKnowledgeBase.mockResolvedValue({ success: true }) + const restoreResponse = await restoreKnowledgeBase(createMockRequest('POST'), { + params: Promise.resolve({ id: 'knowledge-1' }), + }) + await expect(restoreResponse.json()).resolves.toEqual({ success: true }) + }) + + it('renders unknown knowledge base failures safely', async () => { + mocks.readKnowledgeBase.mockRejectedValue(new Error('postgres password=secret')) + const response = await readKnowledgeBase(createMockRequest('GET'), { + params: Promise.resolve({ id: 'knowledge-1' }), + }) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ error: 'Failed to fetch knowledge base' }) + }) + + it('preserves the document-list envelope and canonical application input', async () => { + mocks.listDocuments.mockResolvedValue({ + documents: [document], + pagination: { total: 1, limit: 50, offset: 0, hasMore: false }, + workspaceId: 'workspace-1', + }) + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost/api/knowledge/knowledge-1/documents?enabledFilter=all' + ) + + const response = await listDocuments(request, { + params: Promise.resolve({ id: 'knowledge-1' }), + }) + + expect(mocks.listDocuments).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: expect.objectContaining({ knowledgeBaseId: 'knowledge-1', enabledFilter: 'all' }), + request, + }) + await expect(response.json()).resolves.toEqual({ + success: true, + data: { + documents: [ + expect.objectContaining({ id: 'document-1', uploadedAt: '2026-01-01T00:00:00.000Z' }), + ], + pagination: { total: 1, limit: 50, offset: 0, hasMore: false }, + }, + }) + }) + + it('renders unknown document failures safely', async () => { + mocks.updateDocument.mockRejectedValue(new Error('postgres password=secret')) + const response = await updateDocument(createMockRequest('PUT', { filename: 'renamed.pdf' }), { + params: Promise.resolve({ id: 'knowledge-1', documentId: 'document-1' }), + }) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + error: 'Failed to process knowledge document request', + }) + }) + + it('keeps document upsert admission behind auth and preserves its response', async () => { + mocks.upsertDocument.mockResolvedValue({ + document: { documentId: 'document-2', filename: 'new.txt' }, + knowledgeBaseId: 'knowledge-1', + isUpdate: false, + previousDocumentId: null, + processingConfig: { maxConcurrentDocuments: 5, batchSize: 10 }, + workspaceId: 'workspace-1', + userId: 'user-1', + }) + const response = await upsertDocument( + createMockRequest('POST', { + filename: 'new.txt', + fileUrl: 'data:text/plain;base64,aGVsbG8=', + fileSize: 5, + mimeType: 'text/plain', + }), + { params: Promise.resolve({ id: 'knowledge-1' }) } + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + data: expect.objectContaining({ + documentsCreated: [{ documentId: 'document-2', filename: 'new.txt', status: 'pending' }], + isUpdate: false, + previousDocumentId: null, + processingMethod: 'background', + }), + }) + expect(mocks.platformUpload).toHaveBeenCalledWith( + expect.objectContaining({ knowledgeBaseId: 'knowledge-1', documentsCount: 1 }) + ) + }) + + it('runs internal document analytics only after application success', async () => { + mocks.createDocuments.mockResolvedValue({ + kind: 'single', + data: document, + workspaceId: 'workspace-1', + userId: 'user-1', + }) + const request = createMockRequest('POST', { + bulk: false, + filename: document.filename, + fileUrl: document.fileUrl, + fileSize: document.fileSize, + mimeType: document.mimeType, + }) + + const response = await createDocuments(request, { + params: Promise.resolve({ id: 'knowledge-1' }), + }) + + expect(response.status).toBe(200) + expect(mocks.platformUpload).toHaveBeenCalledOnce() + expect(mocks.capture).toHaveBeenCalledOnce() + expect(mocks.createDocuments.mock.invocationCallOrder[0]).toBeLessThan( + mocks.platformUpload.mock.invocationCallOrder[0] + ) + }) + + it('rejects oversized document-create arrays at the contract boundary', async () => { + const response = await createDocuments( + createMockRequest('POST', { + bulk: true, + documents: Array.from({ length: 101 }, (_, index) => ({ + filename: `document-${index}.txt`, + fileUrl: `https://example.com/document-${index}.txt`, + fileSize: 1, + mimeType: 'text/plain', + })), + }), + { params: Promise.resolve({ id: 'knowledge-1' }) } + ) + + expect(response.status).toBe(400) + expect(mocks.createDocuments).not.toHaveBeenCalled() + }) + + it('preserves connector-document list and mutation envelopes', async () => { + mocks.listConnectorDocuments.mockResolvedValue({ + documents: [ + { + id: 'document-1', + filename: 'Guide', + externalId: null, + sourceUrl: null, + enabled: true, + userExcluded: false, + uploadedAt: new Date('2026-01-01T00:00:00Z'), + processingStatus: 'completed', + }, + ], + counts: { active: 1, excluded: 0 }, + }) + const params = Promise.resolve({ id: 'knowledge-1', connectorId: 'connector-1' }) + const listResponse = await listConnectorDocuments( + new NextRequest( + 'http://localhost/api/knowledge/knowledge-1/connectors/connector-1/documents?includeExcluded=true&limit=25&offset=50' + ), + { params } + ) + await expect(listResponse.json()).resolves.toEqual({ + success: true, + data: { + documents: [ + expect.objectContaining({ id: 'document-1', uploadedAt: '2026-01-01T00:00:00.000Z' }), + ], + counts: { active: 1, excluded: 0 }, + }, + }) + expect(mocks.listConnectorDocuments).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ includeExcluded: true, limit: 25, offset: 50 }), + }) + ) + + const filteredListResponse = await listConnectorDocuments( + new NextRequest( + 'http://localhost/api/knowledge/knowledge-1/connectors/connector-1/documents?includeExcluded=false' + ), + { params: Promise.resolve({ id: 'knowledge-1', connectorId: 'connector-1' }) } + ) + expect(filteredListResponse.status).toBe(200) + expect(mocks.listConnectorDocuments).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ includeExcluded: false }), + }) + ) + + mocks.updateConnectorDocuments.mockResolvedValue({ + operation: 'exclude', + count: 1, + documentIds: ['document-1'], + }) + const updateResponse = await updateConnectorDocuments( + createMockRequest('PATCH', { operation: 'exclude', documentIds: ['document-1'] }), + { params: Promise.resolve({ id: 'knowledge-1', connectorId: 'connector-1' }) } + ) + await expect(updateResponse.json()).resolves.toEqual({ + success: true, + data: { excludedCount: 1, documentIds: ['document-1'] }, + }) + }) + + it('rejects oversized connector-document mutations at the contract boundary', async () => { + const response = await updateConnectorDocuments( + createMockRequest('PATCH', { + operation: 'exclude', + documentIds: Array.from({ length: 101 }, (_, index) => `document-${index}`), + }), + { params: Promise.resolve({ id: 'knowledge-1', connectorId: 'connector-1' }) } + ) + + expect(response.status).toBe(400) + expect(mocks.updateConnectorDocuments).not.toHaveBeenCalled() + }) + + it('preserves search cost shape and sanitizes infrastructure errors', async () => { + const registry = {} + mocks.search.mockResolvedValue({ + results: [ + { + embeddingId: 'embedding-1', + documentId: 'document-1', + documentName: 'Guide', + sourceUrl: null, + content: 'hello', + chunkIndex: 0, + metadata: {}, + similarity: 0.9, + }, + ], + query: 'hello', + knowledgeBaseIds: ['knowledge-1'], + knowledgeBaseId: 'knowledge-1', + topK: 10, + totalResults: 1, + workspaceId: 'workspace-1', + userId: 'user-1', + resultSecretRegistry: registry, + cost: { + input: 0.1, + output: 0, + total: 0.1, + tokens: { prompt: 1, completion: 0, total: 1 }, + model: 'text-embedding-3-small', + pricing: { input: 0.1, output: 0 }, + }, + }) + const response = await search( + createMockRequest('POST', { + knowledgeBaseIds: ['knowledge-1'], + query: 'hello', + }) + ) + const body = await response.json() + expect(body.data.results[0]).not.toHaveProperty('embeddingId') + expect(body.data.cost).toEqual(expect.objectContaining({ total: 0.1 })) + + mocks.search.mockRejectedValueOnce(new Error('database host secret.internal')) + const failure = await search( + createMockRequest('POST', { knowledgeBaseIds: ['knowledge-1'], query: 'hello' }) + ) + expect(failure.status).toBe(500) + await expect(failure.json()).resolves.toEqual({ error: 'Failed to perform vector search' }) + }) + + it('runs upload analytics only after a newly-created completion', async () => { + const completed = { + session: { + id: 'upload-1', + knowledgeBaseId: 'knowledge-1', + workspaceId: 'workspace-1', + status: 'completed', + uploadToken: 'token', + objectKey: 'key', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 42, + expiresAt: new Date('2026-01-02T00:00:00Z'), + error: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + }, + value: { + created: true, + document: { + ...document, + documentId: document.id, + }, + }, + knowledgeBaseId: 'knowledge-1', + workspaceId: 'workspace-1', + } + mocks.completeUpload.mockResolvedValue(completed) + const response = await completeUpload( + createMockRequest( + 'POST', + undefined, + { 'upload-token': 'token' }, + 'http://localhost/api/knowledge/knowledge-1/documents/uploads/upload-1/complete?workspaceId=workspace-1' + ), + { params: Promise.resolve({ id: 'knowledge-1', uploadId: 'upload-1' }) } + ) + + expect(response.status).toBe(200) + expect(mocks.capture).toHaveBeenCalledOnce() + expect(mocks.platformUpload).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/app/api/knowledge/route.test.ts b/apps/sim/app/api/knowledge/route.test.ts index 3c8f8083b79..011d7822057 100644 --- a/apps/sim/app/api/knowledge/route.test.ts +++ b/apps/sim/app/api/knowledge/route.test.ts @@ -1,211 +1,227 @@ /** - * Tests for knowledge base API route - * * @vitest-environment node */ -import { - auditMock, - authMockFns, - createMockRequest, - dbChainMockFns, - permissionsMock, - permissionsMockFns, - resetDbChainMock, -} from '@sim/testing' -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/audit', () => auditMock) - -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) - +import { authMockFns, createMockRequest } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + create: vi.fn(), + platformCreated: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ + listInternalKnowledgeBases: { + operation: { id: 'knowledge.session.list' }, + execute: mocks.list, + }, + createKnowledgeBase: { + operation: { id: 'knowledge.create' }, + execute: mocks.create, + }, +})) + +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { knowledgeBaseCreated: mocks.platformCreated }, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET, POST } from '@/app/api/knowledge/route' -describe('Knowledge Base API Route', () => { +const session = { + user: { id: 'user-123', email: 'test@example.com', name: 'Test User' }, + session: { id: 'session-123' }, +} + +const knowledgeBase = { + id: 'knowledge-1', + userId: 'user-123', + name: 'Test Knowledge Base', + description: 'Test description', + tokenCount: 0, + embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, + chunkingConfig: { maxSize: 1024, minSize: 100, overlap: 200 }, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + deletedAt: null, + workspaceId: 'workspace-1', + folderId: null, + docCount: 0, + connectorTypes: [], +} + +const expectedKnowledgeBase = { + ...knowledgeBase, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', +} + +describe('/api/knowledge internal route composition', () => { beforeEach(() => { vi.clearAllMocks() - resetDbChainMock() - - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') + authMockFns.mockGetSession.mockResolvedValue(session) + mocks.list.mockResolvedValue({ knowledgeBases: [knowledgeBase] }) + mocks.create.mockResolvedValue({ knowledgeBase, folderPath: '/' }) + }) - vi.stubGlobal('crypto', { - randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), + it('authenticates before parsing malformed JSON', async () => { + authMockFns.mockGetSession.mockResolvedValueOnce(null) + const request = new NextRequest('http://localhost/api/knowledge', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{', }) - }) - afterEach(() => { - vi.clearAllMocks() - }) + const response = await POST(request) - afterAll(() => { - resetDbChainMock() + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }) + expect(mocks.create).not.toHaveBeenCalled() }) - describe('GET /api/knowledge', () => { - it('should return unauthorized for unauthenticated user', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) + it('preserves the legacy personal listing envelope without inventing a workspace', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost/api/knowledge?scope=all' + ) - const req = createMockRequest('GET') - const response = await GET(req) - const data = await response.json() + const response = await GET(request) - expect(response.status).toBe(401) - expect(data.error).toBe('Unauthorized') + expect(mocks.list).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-123', sessionId: 'session-123' }, + input: { workspaceId: undefined, scope: 'all' }, + request, }) - - it('should handle database errors', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - dbChainMockFns.orderBy.mockRejectedValueOnce(new Error('Database error')) - - const req = createMockRequest('GET') - const response = await GET(req) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.error).toBe('Failed to fetch knowledge bases') + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + data: [expectedKnowledgeBase], }) }) - describe('POST /api/knowledge', () => { - const validKnowledgeBaseData = { - name: 'Test Knowledge Base', - description: 'Test description', - workspaceId: 'test-workspace-id', - chunkingConfig: { - maxSize: 1024, - minSize: 100, - overlap: 200, - }, - } - - it('should create knowledge base successfully', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) + it('preserves workspace-scoped archived listing input and envelope', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost/api/knowledge?workspaceId=workspace-1&scope=archived' + ) - const req = createMockRequest('POST', validKnowledgeBaseData) - const response = await POST(req) - const data = await response.json() + const response = await GET(request) - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.name).toBe(validKnowledgeBaseData.name) - expect(data.data.description).toBe(validKnowledgeBaseData.description) - expect(dbChainMockFns.insert).toHaveBeenCalled() + expect(mocks.list).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-123', sessionId: 'session-123' }, + input: { workspaceId: 'workspace-1', scope: 'archived' }, + request, }) - - it('should return unauthorized for unauthenticated user', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const req = createMockRequest('POST', validKnowledgeBaseData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data.error).toBe('Unauthorized') + await expect(response.json()).resolves.toEqual({ + success: true, + data: [expectedKnowledgeBase], }) + }) - it('should validate required fields', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - - const req = createMockRequest('POST', { description: 'Missing name' }) - const response = await POST(req) - const data = await response.json() + it('preserves the legacy query validation envelope', async () => { + const response = await GET( + createMockRequest('GET', undefined, {}, 'http://localhost/api/knowledge?scope=invalid') + ) + const body = await response.json() - expect(response.status).toBe(400) - expect(data.error).toBe('Invalid request data') - expect(data.details).toBeDefined() + expect(response.status).toBe(400) + expect(body).toEqual({ + error: 'Invalid query parameters', + details: expect.any(Array), }) + expect(mocks.list).not.toHaveBeenCalled() + }) - it('should require workspaceId', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - - const req = createMockRequest('POST', { name: 'Test KB' }) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toBe('Invalid request data') - expect(data.details).toBeDefined() + it('preserves the exact creation envelope and runs internal analytics after success', async () => { + const request = createMockRequest('POST', { + name: 'Test Knowledge Base', + description: 'Test description', + workspaceId: 'workspace-1', + folderId: null, }) - it('returns 403 when user lacks permission on target workspace', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'attacker', email: 'a@example.com' }, - }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read') - - const req = createMockRequest('POST', validKnowledgeBaseData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(403) - expect(data.error).toBe( - 'User does not have permission to create knowledge bases in this workspace' - ) - expect(dbChainMockFns.insert).not.toHaveBeenCalled() + const response = await POST(request) + + expect(mocks.create).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-123', sessionId: 'session-123' }, + input: { + workspaceId: 'workspace-1', + name: 'Test Knowledge Base', + description: 'Test description', + folderId: null, + chunkingConfig: { maxSize: 1024, minSize: 100, overlap: 200 }, + source: 'ui', + }, + request, }) - - it('should validate chunking config constraints', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - - const invalidData = { - name: 'Test KB', - workspaceId: 'test-workspace-id', - chunkingConfig: { - maxSize: 100, // 100 tokens = 400 characters - minSize: 500, // Invalid: minSize (500 chars) > maxSize (400 chars) - overlap: 50, - }, + expect(mocks.platformCreated).toHaveBeenCalledWith({ + knowledgeBaseId: 'knowledge-1', + name: 'Test Knowledge Base', + workspaceId: 'workspace-1', + }) + expect(mocks.capture).toHaveBeenCalledWith( + 'user-123', + 'knowledge_base_created', + { + knowledge_base_id: 'knowledge-1', + workspace_id: 'workspace-1', + name: 'Test Knowledge Base', + }, + { + groups: { workspace: 'workspace-1' }, + setOnce: { first_kb_created_at: expect.any(String) }, } + ) + expect(mocks.create.mock.invocationCallOrder[0]).toBeLessThan( + mocks.platformCreated.mock.invocationCallOrder[0] + ) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + data: expectedKnowledgeBase, + }) + }) - const req = createMockRequest('POST', invalidData) - const response = await POST(req) - const data = await response.json() + it('preserves the legacy body validation envelope', async () => { + const response = await POST(createMockRequest('POST', { description: 'Missing fields' })) + const body = await response.json() - expect(response.status).toBe(400) - expect(data.error).toBe('Invalid request data') - }) + expect(response.status).toBe(400) + expect(body).toEqual({ error: 'Invalid request data', details: expect.any(Array) }) + expect(mocks.create).not.toHaveBeenCalled() + }) - it('should use default values for optional fields', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) + it('projects typed application errors without running analytics', async () => { + mocks.create.mockRejectedValueOnce(new OrchestrationError('conflict', 'Already exists')) - const minimalData = { name: 'Test KB', workspaceId: 'test-workspace-id' } - const req = createMockRequest('POST', minimalData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.data.embeddingModel).toBe('text-embedding-3-small') - expect(data.data.embeddingDimension).toBe(1536) - expect(data.data.chunkingConfig).toEqual({ - maxSize: 1024, - minSize: 100, - overlap: 200, + const response = await POST( + createMockRequest('POST', { + name: 'Test Knowledge Base', + workspaceId: 'workspace-1', }) - }) + ) - it('should handle database errors during creation', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com' }, - }) - dbChainMockFns.values.mockRejectedValueOnce(new Error('Database error')) + expect(response.status).toBe(409) + await expect(response.json()).resolves.toEqual({ error: 'Already exists' }) + expect(mocks.platformCreated).not.toHaveBeenCalled() + expect(mocks.capture).not.toHaveBeenCalled() + }) - const req = createMockRequest('POST', validKnowledgeBaseData) - const response = await POST(req) - const data = await response.json() + it('returns a safe list error for unknown infrastructure failures', async () => { + mocks.list.mockRejectedValueOnce(new Error('database DSN secret')) - expect(response.status).toBe(500) - expect(data.error).toBe('Failed to create knowledge base') - }) + const response = await GET(createMockRequest('GET')) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ error: 'Failed to fetch knowledge bases' }) }) }) diff --git a/apps/sim/app/api/knowledge/route.ts b/apps/sim/app/api/knowledge/route.ts index 09178f9dff1..40ef69cb20f 100644 --- a/apps/sim/app/api/knowledge/route.ts +++ b/apps/sim/app/api/knowledge/route.ts @@ -1,107 +1,63 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { createKnowledgeBaseContract, - listKnowledgeBasesQuerySchema, + listKnowledgeBasesContract, } from '@/lib/api/contracts/knowledge' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { validationErrorResponse } from '@/lib/api/server' import { - messageForOrchestrationError, - statusForOrchestrationError, -} from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performCreateKnowledgeBase } from '@/lib/knowledge/orchestration' -import { getKnowledgeBases, type KnowledgeBaseScope } from '@/lib/knowledge/service' - -const logger = createLogger('KnowledgeBaseAPI') - -export const GET = withRouteHandler(async (req: NextRequest) => { - const requestId = generateRequestId() - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized knowledge base access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(req.url) - const query = listKnowledgeBasesQuerySchema.safeParse({ - workspaceId: searchParams.get('workspaceId') ?? undefined, - scope: searchParams.get('scope') ?? undefined, - }) - if (!query.success) { - return NextResponse.json( - { error: 'Invalid query parameters', details: query.error.issues }, - { status: 400 } - ) - } - const { workspaceId, scope } = query.data - - const knowledgeBasesWithCounts = await getKnowledgeBases( - session.user.id, - workspaceId, - scope as KnowledgeBaseScope - ) - - return NextResponse.json({ - success: true, - data: knowledgeBasesWithCounts, - }) - } catch (error) { - logger.error(`[${requestId}] Error fetching knowledge bases`, error) - return NextResponse.json({ error: 'Failed to fetch knowledge bases' }, { status: 500 }) - } + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + internalKnowledgeAnalytics, + internalKnowledgePresenters, +} from '@/lib/knowledge/api/internal-route' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { + createKnowledgeBase, + listInternalKnowledgeBases, +} from '@/lib/knowledge/application/knowledge-bases' +import { + knowledgeOperations, + knowledgeSessionOperations, +} from '@/lib/knowledge/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: listKnowledgeBasesContract, + auth: internalSessionAuth, + operation: knowledgeSessionOperations.list, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal knowledge base listing behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.list, + parseOptions: { + validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid query parameters'), + }, + mapInput: ({ query }) => ({ workspaceId: query.workspaceId, scope: query.scope }), + useCase: listInternalKnowledgeBases, + present: internalKnowledgePresenters.list, }) -export const POST = withRouteHandler(async (req: NextRequest) => { - const requestId = generateRequestId() - - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized knowledge base creation attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest( - createKnowledgeBaseContract, - req, - {}, - { - validationErrorResponse: (error) => { - logger.warn(`[${requestId}] Invalid knowledge base data`, { errors: error.issues }) - return NextResponse.json( - { error: 'Invalid request data', details: error.issues }, - { status: 400 } - ) - }, - } - ) - if (!parsed.success) return parsed.response - - const body = parsed.data.body - - const outcome = await performCreateKnowledgeBase({ - userId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - source: 'ui', +export const POST = defineInternalJsonRoute({ + contract: createKnowledgeBaseContract, + auth: internalSessionAuth, + operation: knowledgeOperations.create, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal knowledge base creation behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.create, + parseOptions: { + validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'), + }, + mapInput: ({ body }) => ({ workspaceId: body.workspaceId, name: body.name, description: body.description, folderId: body.folderId, chunkingConfig: body.chunkingConfig, - requestId, - request: req, - }) - if (!outcome.success) { - return NextResponse.json( - { error: messageForOrchestrationError(outcome, 'Failed to create knowledge base') }, - { status: statusForOrchestrationError(outcome.errorCode) } - ) - } - - return NextResponse.json({ success: true, data: outcome.knowledgeBase }) + source: 'ui', + }), + useCase: createKnowledgeBase, + onSuccess: internalKnowledgeAnalytics.created, + present: internalKnowledgePresenters.create, }) diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts deleted file mode 100644 index 07526264200..00000000000 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ /dev/null @@ -1,1278 +0,0 @@ -/** - * Tests for knowledge search API route - * Focuses on route-specific functionality: authentication, validation, API contract, error handling - * Search logic is tested in utils.test.ts - * - * @vitest-environment node - */ -import { - createMockRequest, - dbChainMockFns, - hybridAuthMockFns, - knowledgeApiUtilsMock, - knowledgeApiUtilsMockFns, - resetDbChainMock, - resetEnvMock, - setEnv, - workflowAuthzMockFns, - workflowsUtilsMock, -} from '@sim/testing' -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockGetDocumentTagDefinitions, - mockExecuteKnowledgeSearch, - mockGenerateSearchEmbedding, - mockImportKnowledgeSearchResultSecretProvenance, -} = vi.hoisted(() => ({ - mockGetDocumentTagDefinitions: vi.fn(), - mockExecuteKnowledgeSearch: vi.fn(), - mockGenerateSearchEmbedding: vi.fn(), - mockImportKnowledgeSearchResultSecretProvenance: vi.fn(), -})) - -const mockCheckKnowledgeBaseAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess - -vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) - -vi.mock('@/lib/documents/utils', () => ({ - retryWithExponentialBackoff: vi.fn().mockImplementation((fn) => fn()), -})) - -vi.mock('@/lib/tokenization/estimators', () => ({ - estimateTokenCount: vi.fn().mockReturnValue({ count: 521 }), -})) - -vi.mock('@/providers/utils', () => ({ - isFunctionToolCall: (toolCall: unknown) => - typeof toolCall === 'object' && - toolCall !== null && - 'function' in toolCall && - (toolCall as { function?: unknown }).function != null, - calculateCost: vi.fn().mockReturnValue({ - input: 0.00001042, - output: 0, - total: 0.00001042, - pricing: { - input: 0.02, - output: 0, - updatedAt: '2025-07-10', - }, - }), -})) - -vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) - -vi.mock('@/lib/knowledge/tags/service', () => ({ - getDocumentTagDefinitions: mockGetDocumentTagDefinitions, -})) - -vi.mock('@/lib/knowledge/secret-provenance', () => ({ - importKnowledgeSearchResultSecretProvenance: mockImportKnowledgeSearchResultSecretProvenance, -})) - -vi.mock('@/lib/knowledge/search/queries', () => ({ - executeKnowledgeSearch: mockExecuteKnowledgeSearch, - generateSearchEmbedding: mockGenerateSearchEmbedding, - APIError: class APIError extends Error { - public status: number - constructor(message: string, status: number) { - super(message) - this.name = 'APIError' - this.status = status - } - }, -})) - -import { PRIVATE_MODEL_INPUT_PROVENANCE_HEADER } from '@/lib/execution/model-input-provenance' -import { - RESOLVED_SECRET_PROVENANCE_FIELD, - RESOLVED_SECRET_PROVENANCE_METADATA_V1, -} from '@/lib/execution/private-tool-metadata' -import { estimateTokenCount } from '@/lib/tokenization/estimators' -import { POST } from '@/app/api/knowledge/search/route' -import { calculateCost } from '@/providers/utils' - -describe('Knowledge Search API Route', () => { - const mockGetUserId = vi.fn() - const mockFetch = vi.fn() - - const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5] - const mockSearchResults = [ - { - id: 'chunk-1', - content: 'This is a test chunk', - documentId: 'doc-1', - chunkIndex: 0, - metadata: { title: 'Test Document' }, - distance: 0.2, - }, - { - id: 'chunk-2', - content: 'Another test chunk', - documentId: 'doc-2', - chunkIndex: 1, - metadata: { title: 'Another Document' }, - distance: 0.3, - }, - ] - - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - setEnv({ OPENAI_API_KEY: 'test-api-key' }) - - mockExecuteKnowledgeSearch.mockClear() - mockGenerateSearchEmbedding - .mockClear() - .mockResolvedValue({ embedding: [0.1, 0.2, 0.3, 0.4, 0.5], isBYOK: false }) - mockImportKnowledgeSearchResultSecretProvenance.mockClear().mockResolvedValue({ - imported: true, - documentMetadata: { - doc1: { - filename: 'Document 1', - sourceUrl: null, - provenance: { status: 'known', entries: [] }, - }, - doc2: { - filename: 'Document 2', - sourceUrl: null, - provenance: { status: 'known', entries: [] }, - }, - }, - }) - mockGetDocumentTagDefinitions.mockClear() - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockClear().mockResolvedValue({ - success: true, - userId: 'user-123', - authType: 'session', - }) - workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockClear().mockResolvedValue({ - allowed: true, - status: 200, - }) - - vi.stubGlobal('crypto', { - randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), - }) - - vi.stubGlobal('fetch', mockFetch) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - afterAll(() => { - resetDbChainMock() - resetEnvMock() - }) - - describe('POST /api/knowledge/search', () => { - const validSearchData = { - knowledgeBaseIds: 'kb-123', - query: 'test search query', - topK: 10, - } - - const mockKnowledgeBases = [ - { - id: 'kb-123', - userId: 'user-123', - name: 'Test KB', - deletedAt: null, - }, - ] - - it('should perform search successfully with single knowledge base', async () => { - mockGetUserId.mockResolvedValue('user-123') - - mockCheckKnowledgeBaseAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { - id: 'kb-123', - userId: 'user-123', - name: 'Test KB', - deletedAt: null, - }, - }) - - dbChainMockFns.limit.mockResolvedValue([]) - - mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults) - - mockFetch.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - data: [{ embedding: mockEmbedding }], - }), - }) - - const req = createMockRequest('POST', validSearchData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.results).toHaveLength(2) - expect(data.data.results[0].similarity).toBe(0.8) // 1 - 0.2 - expect(data.data.query).toBe(validSearchData.query) - expect(data.data.knowledgeBaseIds).toEqual(['kb-123']) - expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({ - knowledgeBaseIds: ['kb-123'], - topK: 10, - searchMode: 'vector', - query: validSearchData.query, - queryVector: JSON.stringify(mockEmbedding), - structuredFilters: undefined, - }) - }) - - it('should forward the hybrid searchMode opt-in to the retrieval layer', async () => { - mockGetUserId.mockResolvedValue('user-123') - - mockCheckKnowledgeBaseAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { - id: 'kb-123', - userId: 'user-123', - name: 'Test KB', - deletedAt: null, - }, - }) - - dbChainMockFns.limit.mockResolvedValue([]) - - mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults) - - mockFetch.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - data: [{ embedding: mockEmbedding }], - }), - }) - - const req = createMockRequest('POST', { ...validSearchData, searchMode: 'hybrid' }) - const response = await POST(req) - - expect(response.status).toBe(200) - expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith( - expect.objectContaining({ searchMode: 'hybrid' }) - ) - }) - - it('should perform search successfully with multiple knowledge bases', async () => { - const multiKbData = { - ...validSearchData, - knowledgeBaseIds: ['kb-123', 'kb-456'], - } - - const multiKbs = [ - ...mockKnowledgeBases, - { id: 'kb-456', userId: 'user-123', name: 'Test KB 2', deletedAt: null }, - ] - - mockGetUserId.mockResolvedValue('user-123') - - mockCheckKnowledgeBaseAccess - .mockResolvedValueOnce({ hasAccess: true, knowledgeBase: multiKbs[0] }) - .mockResolvedValueOnce({ hasAccess: true, knowledgeBase: multiKbs[1] }) - - dbChainMockFns.limit.mockResolvedValue([]) - - mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults) - - mockFetch.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - data: [{ embedding: mockEmbedding }], - }), - }) - - const req = createMockRequest('POST', multiKbData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.knowledgeBaseIds).toEqual(['kb-123', 'kb-456']) - expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({ - knowledgeBaseIds: ['kb-123', 'kb-456'], - topK: 10, - searchMode: 'vector', - query: multiKbData.query, - queryVector: JSON.stringify(mockEmbedding), - structuredFilters: undefined, - }) - }) - - it('should handle workflow-based authentication', async () => { - const workflowData = { - ...validSearchData, - workflowId: 'workflow-123', - } - - mockGetUserId.mockResolvedValue('user-123') - - mockCheckKnowledgeBaseAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { - id: 'kb-123', - userId: 'user-123', - name: 'Test KB', - deletedAt: null, - }, - }) - - dbChainMockFns.limit.mockResolvedValue([]) - - mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults) - - mockFetch.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - data: [{ embedding: mockEmbedding }], - }), - }) - - const req = createMockRequest('POST', workflowData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ - workflowId: 'workflow-123', - userId: 'user-123', - action: 'read', - }) - }) - - it('fails before embedding work when an internal workspace request omits attribution', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ - success: true, - userId: 'user-123', - authType: 'internal_jwt', - }) - mockCheckKnowledgeBaseAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { - id: 'kb-123', - userId: 'user-123', - workspaceId: 'workspace-123', - embeddingModel: 'text-embedding-3-small', - }, - }) - - const req = createMockRequest('POST', { - ...validSearchData, - skipUsageBilling: true, - }) - const response = await POST(req) - - expect(response.status).toBe(500) - expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled() - }) - - it('uses the immutable header for an internal unmetered workspace search', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ - success: true, - userId: 'user-123', - authType: 'internal_jwt', - }) - mockCheckKnowledgeBaseAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { - id: 'kb-123', - userId: 'user-123', - workspaceId: 'workspace-123', - embeddingModel: 'text-embedding-3-small', - }, - }) - mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults) - const attribution = encodeURIComponent( - JSON.stringify({ - actorUserId: 'user-123', - workspaceId: 'workspace-123', - organizationId: 'organization-123', - billedAccountUserId: 'owner-123', - billingEntity: { type: 'organization', id: 'organization-123' }, - billingPeriod: { - start: '2026-07-01T00:00:00.000Z', - end: '2026-08-01T00:00:00.000Z', - }, - payerSubscription: null, - }) - ) - - const req = createMockRequest( - 'POST', - { - ...validSearchData, - skipUsageBilling: true, - [RESOLVED_SECRET_PROVENANCE_FIELD]: { version: 1, complete: true, entries: [] }, - }, - { - 'x-sim-billing-attribution': attribution, - [PRIVATE_MODEL_INPUT_PROVENANCE_HEADER]: RESOLVED_SECRET_PROVENANCE_METADATA_V1, - } - ) - const response = await POST(req) - - expect(response.status).toBe(200) - expect(mockGenerateSearchEmbedding).toHaveBeenCalledOnce() - }) - - it.concurrent('should return unauthorized for unauthenticated request', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ - success: false, - error: 'Unauthorized', - }) - - const req = createMockRequest('POST', validSearchData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data.error).toBe('Unauthorized') - }) - - it.concurrent('should return not found for workflow that does not exist', async () => { - const workflowData = { - ...validSearchData, - workflowId: 'nonexistent-workflow', - } - - workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ - allowed: false, - status: 404, - message: 'Workflow not found', - }) - - const req = createMockRequest('POST', workflowData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(404) - expect(data.error).toBe('Workflow not found') - }) - - it('should return not found for non-existent knowledge base', async () => { - mockGetUserId.mockResolvedValue('user-123') - - mockCheckKnowledgeBaseAccess.mockResolvedValue({ - hasAccess: false, - notFound: true, - }) - - const req = createMockRequest('POST', validSearchData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(404) - expect(data.error).toBe('Knowledge base not found or access denied') - }) - - it('should return not found for some missing knowledge bases', async () => { - const multiKbData = { - ...validSearchData, - knowledgeBaseIds: ['kb-123', 'kb-missing'], - } - - mockGetUserId.mockResolvedValue('user-123') - - mockCheckKnowledgeBaseAccess - .mockResolvedValueOnce({ hasAccess: true, knowledgeBase: mockKnowledgeBases[0] }) - .mockResolvedValueOnce({ hasAccess: false, notFound: true }) - - const req = createMockRequest('POST', multiKbData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(404) - expect(data.error).toBe('Knowledge bases not found or access denied: kb-missing') - }) - - it.concurrent('should validate search parameters', async () => { - const invalidData = { - knowledgeBaseIds: '', // Empty string - query: '', // Empty query - topK: 150, // Too high - } - - const req = createMockRequest('POST', invalidData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toBe('Validation error') - expect(data.details).toBeDefined() - }) - - it('should use default topK value when not provided', async () => { - const dataWithoutTopK = { - knowledgeBaseIds: 'kb-123', - query: 'test search query', - } - - mockGetUserId.mockResolvedValue('user-123') - - mockCheckKnowledgeBaseAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { - id: 'kb-123', - userId: 'user-123', - name: 'Test KB', - deletedAt: null, - embeddingModel: 'text-embedding-3-small', - }, - }) - - dbChainMockFns.limit.mockResolvedValueOnce(mockSearchResults) // Search results - - mockFetch.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - data: [{ embedding: mockEmbedding }], - }), - }) - - const req = createMockRequest('POST', dataWithoutTopK) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.data.topK).toBe(10) // Default value - }) - - it.concurrent('should handle OpenAI API errors', async () => { - mockGetUserId.mockResolvedValue('user-123') - dbChainMockFns.limit.mockResolvedValueOnce(mockKnowledgeBases) - - mockGenerateSearchEmbedding.mockRejectedValueOnce( - new Error('OpenAI API error: 401 Unauthorized - Invalid API key') - ) - - const req = createMockRequest('POST', validSearchData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.error).toBe('Failed to perform vector search') - }) - - it.concurrent('should handle missing OpenAI API key', async () => { - mockGetUserId.mockResolvedValue('user-123') - dbChainMockFns.limit.mockResolvedValueOnce(mockKnowledgeBases) - - mockGenerateSearchEmbedding.mockRejectedValueOnce(new Error('OPENAI_API_KEY not configured')) - - const req = createMockRequest('POST', validSearchData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.error).toBe('Failed to perform vector search') - }) - - it.concurrent('should handle database errors during search', async () => { - mockGetUserId.mockResolvedValue('user-123') - dbChainMockFns.limit.mockResolvedValueOnce(mockKnowledgeBases) - - mockExecuteKnowledgeSearch.mockRejectedValueOnce(new Error('Database error')) - - const req = createMockRequest('POST', validSearchData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.error).toBe('Failed to perform vector search') - }) - - it.concurrent('should handle invalid OpenAI response format', async () => { - mockGetUserId.mockResolvedValue('user-123') - dbChainMockFns.limit.mockResolvedValueOnce(mockKnowledgeBases) - - mockGenerateSearchEmbedding.mockRejectedValueOnce( - new Error('Invalid response format from OpenAI embeddings API') - ) - - const req = createMockRequest('POST', validSearchData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.error).toBe('Failed to perform vector search') - }) - - describe('Cost tracking', () => { - it.concurrent('should include cost information in successful search response', async () => { - mockGetUserId.mockResolvedValue('user-123') - - mockCheckKnowledgeBaseAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { - id: 'kb-123', - userId: 'user-123', - name: 'Test KB', - deletedAt: null, - embeddingModel: 'text-embedding-3-small', - }, - }) - - dbChainMockFns.limit.mockResolvedValueOnce(mockSearchResults) - - mockFetch.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - data: [{ embedding: mockEmbedding }], - }), - }) - - const req = createMockRequest('POST', validSearchData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - - expect(data.data.cost).toBeDefined() - expect(data.data.cost.input).toBe(0.00001042) - expect(data.data.cost.output).toBe(0) - expect(data.data.cost.total).toBe(0.00001042) - expect(data.data.cost.tokens).toEqual({ - prompt: 521, - completion: 0, - total: 521, - }) - expect(data.data.cost.model).toBe('text-embedding-3-small') - expect(data.data.cost.pricing).toEqual({ - input: 0.02, - output: 0, - updatedAt: '2025-07-10', - }) - }) - - it('should call cost calculation functions with correct parameters', async () => { - mockGetUserId.mockResolvedValue('user-123') - - mockCheckKnowledgeBaseAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { - id: 'kb-123', - userId: 'user-123', - name: 'Test KB', - deletedAt: null, - embeddingModel: 'text-embedding-3-small', - }, - }) - - dbChainMockFns.limit.mockResolvedValueOnce(mockSearchResults) - - mockFetch.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - data: [{ embedding: mockEmbedding }], - }), - }) - - const req = createMockRequest('POST', validSearchData) - await POST(req) - - expect(estimateTokenCount).toHaveBeenCalledWith('test search query', 'openai') - - expect(calculateCost).toHaveBeenCalledWith('text-embedding-3-small', 521, 0, false) - }) - - it('should handle cost calculation with different query lengths', async () => { - vi.mocked(estimateTokenCount).mockReturnValue({ - count: 1042, - confidence: 'high', - provider: 'openai', - method: 'precise', - }) - vi.mocked(calculateCost).mockReturnValue({ - input: 0.00002084, - output: 0, - total: 0.00002084, - pricing: { - input: 0.02, - output: 0, - updatedAt: '2025-07-10', - }, - }) - - const longQueryData = { - ...validSearchData, - query: - 'This is a much longer search query with many more tokens to test cost calculation accuracy', - } - - mockGetUserId.mockResolvedValue('user-123') - - mockCheckKnowledgeBaseAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { - id: 'kb-123', - userId: 'user-123', - name: 'Test KB', - deletedAt: null, - embeddingModel: 'text-embedding-3-small', - }, - }) - - dbChainMockFns.limit.mockResolvedValueOnce(mockSearchResults) - - mockFetch.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - data: [{ embedding: mockEmbedding }], - }), - }) - - const req = createMockRequest('POST', longQueryData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.data.cost.input).toBe(0.00002084) - expect(data.data.cost.tokens.prompt).toBe(1042) - expect(calculateCost).toHaveBeenCalledWith('text-embedding-3-small', 1042, 0, false) - }) - }) - }) - - describe('Optional Query Search', () => { - const mockTagDefinitions = [ - { tagSlot: 'tag1', displayName: 'category', fieldType: 'text' }, - { tagSlot: 'tag2', displayName: 'priority', fieldType: 'text' }, - ] - - const mockTaggedResults = [ - { - id: 'chunk-1', - content: 'Tagged content 1', - documentId: 'doc-1', - chunkIndex: 0, - tag1: 'api', - tag2: 'high', - distance: 0, - knowledgeBaseId: 'kb-123', - }, - { - id: 'chunk-2', - content: 'Tagged content 2', - documentId: 'doc-2', - chunkIndex: 1, - tag1: 'docs', - tag2: 'medium', - distance: 0, - knowledgeBaseId: 'kb-123', - }, - ] - - it('should perform tag-only search without query', async () => { - const tagOnlyData = { - knowledgeBaseIds: 'kb-123', - tagFilters: [{ tagName: 'category', value: 'api', fieldType: 'text', operator: 'eq' }], - topK: 10, - } - - mockGetUserId.mockResolvedValue('user-123') - mockCheckKnowledgeBaseAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { - id: 'kb-123', - userId: 'user-123', - name: 'Test KB', - deletedAt: null, - embeddingModel: 'text-embedding-3-small', - }, - }) - - mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions) - - dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions) - - mockExecuteKnowledgeSearch.mockResolvedValue(mockTaggedResults) - - const req = createMockRequest('POST', tagOnlyData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.results).toHaveLength(2) - expect(data.data.results[0].similarity).toBe(1) // Perfect similarity for tag-only - expect(data.data.query).toBe('') // Empty query - expect(data.data.cost).toBeUndefined() // No cost for tag-only search - expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled() // No embedding API call - expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({ - knowledgeBaseIds: ['kb-123'], - topK: 10, - searchMode: 'vector', - structuredFilters: [ - { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined }, - ], - }) - }) - - it('should perform query + tag combination search', async () => { - const combinedData = { - knowledgeBaseIds: 'kb-123', - query: 'test search', - tagFilters: [{ tagName: 'category', value: 'api', fieldType: 'text', operator: 'eq' }], - topK: 10, - } - - mockGetUserId.mockResolvedValue('user-123') - mockCheckKnowledgeBaseAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { - id: 'kb-123', - userId: 'user-123', - name: 'Test KB', - deletedAt: null, - embeddingModel: 'text-embedding-3-small', - }, - }) - - mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions) - - dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions) - - mockExecuteKnowledgeSearch.mockResolvedValue(mockSearchResults) - - mockFetch.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - data: [{ embedding: mockEmbedding }], - }), - }) - - const req = createMockRequest('POST', combinedData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.results).toHaveLength(2) - expect(data.data.query).toBe('test search') - expect(data.data.cost).toBeDefined() // Cost included for vector search - expect(mockGenerateSearchEmbedding).toHaveBeenCalled() // Embedding API called - expect(mockExecuteKnowledgeSearch).toHaveBeenCalledWith({ - knowledgeBaseIds: ['kb-123'], - topK: 10, - searchMode: 'vector', - query: 'test search', - queryVector: JSON.stringify(mockEmbedding), - structuredFilters: [ - { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined }, - ], - }) - }) - - it('should validate that either query or filters are provided', async () => { - const emptyData = { - knowledgeBaseIds: 'kb-123', - topK: 10, - } - - const req = createMockRequest('POST', emptyData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toBe('Validation error') - expect(data.details).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - message: - 'Please provide either a search query or tag filters to search your knowledge base', - }), - ]) - ) - }) - - it('should validate that empty query with empty filters fails', async () => { - const emptyFiltersData = { - knowledgeBaseIds: 'kb-123', - query: '', - filters: {}, - topK: 10, - } - - const req = createMockRequest('POST', emptyFiltersData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toBe('Validation error') - }) - - it('should handle empty tag values gracefully', async () => { - const emptyTagValueData = { - knowledgeBaseIds: 'kb-123', - query: '', - topK: 10, - } - - const req = createMockRequest('POST', emptyTagValueData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toBe('Validation error') - expect(data.details).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - message: - 'Please provide either a search query or tag filters to search your knowledge base', - }), - ]) - ) - }) - - it('should handle null values from frontend gracefully', async () => { - const nullValuesData = { - knowledgeBaseIds: 'kb-123', - topK: null, - query: null, - filters: null, - } - - const req = createMockRequest('POST', nullValuesData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.error).toBe('Validation error') - expect(data.details).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - message: - 'Please provide either a search query or tag filters to search your knowledge base', - }), - ]) - ) - }) - - it('should perform query-only search (existing behavior)', async () => { - const queryOnlyData = { - knowledgeBaseIds: 'kb-123', - query: 'test search query', - topK: 10, - } - - mockGetUserId.mockResolvedValue('user-123') - mockCheckKnowledgeBaseAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { - id: 'kb-123', - userId: 'user-123', - name: 'Test KB', - deletedAt: null, - embeddingModel: 'text-embedding-3-small', - }, - }) - - dbChainMockFns.limit.mockResolvedValueOnce(mockSearchResults) - - mockFetch.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - data: [{ embedding: mockEmbedding }], - }), - }) - - const req = createMockRequest('POST', queryOnlyData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.results).toHaveLength(2) - expect(data.data.query).toBe('test search query') - expect(data.data.cost).toBeDefined() // Cost included for vector search - expect(mockGenerateSearchEmbedding).toHaveBeenCalled() // Embedding API called - }) - - it('should handle tag-only search with multiple knowledge bases', async () => { - const multiKbTagData = { - knowledgeBaseIds: ['kb-123', 'kb-456'], - tagFilters: [ - { tagName: 'category', value: 'docs', fieldType: 'text', operator: 'eq' }, - { tagName: 'priority', value: 'high', fieldType: 'text', operator: 'eq' }, - ], - topK: 10, - } - - mockGetUserId.mockResolvedValue('user-123') - mockCheckKnowledgeBaseAccess - .mockResolvedValueOnce({ - hasAccess: true, - knowledgeBase: { - id: 'kb-123', - userId: 'user-123', - name: 'Test KB', - deletedAt: null, - embeddingModel: 'text-embedding-3-small', - }, - }) - .mockResolvedValueOnce({ - hasAccess: true, - knowledgeBase: { - id: 'kb-456', - userId: 'user-123', - name: 'Test KB 2', - embeddingModel: 'text-embedding-3-small', - }, - }) - - mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions) - - mockExecuteKnowledgeSearch.mockResolvedValue(mockTaggedResults) - - dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions) - - const req = createMockRequest('POST', multiKbTagData) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.knowledgeBaseIds).toEqual(['kb-123', 'kb-456']) - expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled() // No embedding for tag-only - }) - }) - - describe('Deleted document filtering', () => { - it('should exclude results from deleted documents in vector search', async () => { - mockGetUserId.mockResolvedValue('user-123') - - mockCheckKnowledgeBaseAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { - id: 'kb-123', - userId: 'user-123', - name: 'Test KB', - deletedAt: null, - }, - }) - - mockExecuteKnowledgeSearch.mockResolvedValue([ - { - id: 'chunk-1', - content: 'Content from active document', - documentId: 'doc-active', - chunkIndex: 0, - tag1: null, - tag2: null, - tag3: null, - tag4: null, - tag5: null, - tag6: null, - tag7: null, - distance: 0.2, - knowledgeBaseId: 'kb-123', - }, - ]) - - mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2, 0.3], isBYOK: false }) - mockImportKnowledgeSearchResultSecretProvenance.mockResolvedValue({ - imported: true, - documentMetadata: { - 'doc-active': { - filename: 'Active Document.pdf', - sourceUrl: 'https://example.atlassian.net/wiki/spaces/DOCS/pages/12345', - provenance: { status: 'known', entries: [] }, - }, - }, - }) - - const mockTagDefs = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockResolvedValue([]), - } - dbChainMockFns.select.mockReturnValueOnce(mockTagDefs) - - const req = createMockRequest('POST', { - knowledgeBaseIds: ['kb-123'], - query: 'test query', - topK: 10, - }) - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.results).toHaveLength(1) - expect(data.data.results[0].documentId).toBe('doc-active') - expect(data.data.results[0].documentName).toBe('Active Document.pdf') - expect(data.data.results[0].sourceUrl).toBe( - 'https://example.atlassian.net/wiki/spaces/DOCS/pages/12345' - ) - }) - - it('should exclude results from deleted documents in tag search', async () => { - mockGetUserId.mockResolvedValue('user-123') - - mockCheckKnowledgeBaseAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { - id: 'kb-123', - userId: 'user-123', - name: 'Test KB', - deletedAt: null, - }, - }) - - mockGetDocumentTagDefinitions.mockResolvedValue([ - { tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }, - ]) - - mockExecuteKnowledgeSearch.mockResolvedValue([ - { - id: 'chunk-2', - content: 'Content from active document with tag', - documentId: 'doc-active-tagged', - chunkIndex: 0, - tag1: 'api', - tag2: null, - tag3: null, - tag4: null, - tag5: null, - tag6: null, - tag7: null, - distance: 0, - knowledgeBaseId: 'kb-123', - }, - ]) - - mockImportKnowledgeSearchResultSecretProvenance.mockResolvedValue({ - imported: true, - documentMetadata: { - 'doc-active-tagged': { - filename: 'Active Tagged Document.pdf', - sourceUrl: null, - tag1: 'api', - provenance: { status: 'known', entries: [] }, - }, - }, - }) - - const mockTagDefs = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi - .fn() - .mockResolvedValue([{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }]), - } - dbChainMockFns.select.mockReturnValueOnce(mockTagDefs) - - const req = createMockRequest('POST', { - knowledgeBaseIds: ['kb-123'], - tagFilters: [{ tagName: 'tag1', value: 'api', fieldType: 'text', operator: 'eq' }], - topK: 10, - }) - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.results).toHaveLength(1) - expect(data.data.results[0].documentId).toBe('doc-active-tagged') - expect(data.data.results[0].documentName).toBe('Active Tagged Document.pdf') - expect(data.data.results[0].metadata).toEqual({ tag1: 'api' }) - }) - - it('should exclude results from deleted documents in combined tag+vector search', async () => { - mockGetUserId.mockResolvedValue('user-123') - - mockCheckKnowledgeBaseAccess.mockResolvedValue({ - hasAccess: true, - knowledgeBase: { - id: 'kb-123', - userId: 'user-123', - name: 'Test KB', - deletedAt: null, - }, - }) - - mockGetDocumentTagDefinitions.mockResolvedValue([ - { tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }, - ]) - - mockExecuteKnowledgeSearch.mockResolvedValue([ - { - id: 'chunk-3', - content: 'Relevant content from active document', - documentId: 'doc-active-combined', - chunkIndex: 0, - tag1: 'guide', - tag2: null, - tag3: null, - tag4: null, - tag5: null, - tag6: null, - tag7: null, - distance: 0.15, - knowledgeBaseId: 'kb-123', - }, - ]) - - mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2, 0.3], isBYOK: false }) - mockImportKnowledgeSearchResultSecretProvenance.mockResolvedValue({ - imported: true, - documentMetadata: { - 'doc-active-combined': { - filename: 'Active Combined Search.pdf', - sourceUrl: null, - tag1: 'guide', - provenance: { status: 'known', entries: [] }, - }, - }, - }) - - const mockTagDefs = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi - .fn() - .mockResolvedValue([{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }]), - } - dbChainMockFns.select.mockReturnValueOnce(mockTagDefs) - - const req = createMockRequest('POST', { - knowledgeBaseIds: ['kb-123'], - query: 'relevant content', - tagFilters: [{ tagName: 'tag1', value: 'guide', fieldType: 'text', operator: 'eq' }], - topK: 10, - }) - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.data.results).toHaveLength(1) - expect(data.data.results[0].documentId).toBe('doc-active-combined') - expect(data.data.results[0].documentName).toBe('Active Combined Search.pdf') - expect(data.data.results[0].metadata).toEqual({ tag1: 'guide' }) - expect(data.data.results[0].similarity).toBe(0.85) // 1 - 0.15 distance - }) - }) -}) diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index e44bb92571e..c9a99ffa3de 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -1,659 +1,84 @@ -import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { knowledgeSearchBodySchema } from '@/lib/api/contracts/knowledge' -import { parseJsonBody, validationErrorResponse } from '@/lib/api/server' -import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { internalKnowledgeSearchContract } from '@/lib/api/contracts/knowledge' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { - checkAttributedUsageLimits, - requireBillingAttributionHeader, - resolveBillingAttribution, - toBillingContext, -} from '@/lib/billing/core/billing-attribution' + internalKnowledgeAuthType, + resolveInternalKnowledgeBillingAttribution, +} from '@/lib/knowledge/api/internal-route' import { - checkAndBillOverageThreshold, - checkAndBillPayerOverageThreshold, -} from '@/lib/billing/threshold-billing' -import { PlatformEvents } from '@/lib/core/telemetry' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { importDurableSecretProvenance } from '@/lib/execution/durable-secret-provenance' -import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' -import { getEmbeddingModelInfo } from '@/lib/knowledge/embedding-models' -import { - prepareKnowledgeModelInputProvenance, - runWithKnowledgeModelInputProvenance, -} from '@/lib/knowledge/model-input-provenance' -import { rerank } from '@/lib/knowledge/reranker' -import { - executeKnowledgeSearch, - generateSearchEmbedding, - type SearchResult, -} from '@/lib/knowledge/search/queries' -import { importKnowledgeSearchResultSecretProvenance } from '@/lib/knowledge/secret-provenance' -import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' -import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' -import type { StructuredFilter } from '@/lib/knowledge/types' -import { estimateTokenCount } from '@/lib/tokenization/estimators' -import { createKnowledgeRegistryResponse } from '@/app/api/knowledge/secret-provenance' -import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' -import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' -import { getRerankModelPricing } from '@/providers/models' -import { calculateCost } from '@/providers/utils' - -const logger = createLogger('VectorSearchAPI') - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const parsedBody = await parseJsonBody(request) - if (!parsedBody.success) return parsedBody.response - const body = parsedBody.data as Record<string, unknown> - const { workflowId, skipUsageBilling, ...searchParams } = body - - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - // Only the internal workflow tool may suppress route metering (it rolls the - // cost into the executor's usage instead). Session/API-key callers cannot set - // skipUsageBilling to dodge their own embedding/reranker charge. - const shouldMeter = !(skipUsageBilling === true && auth.authType === AuthType.INTERNAL_JWT) - - if (workflowId) { - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId: workflowId as string, - userId, - action: 'read', - }) - if (!authorization.allowed) { - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status } - ) - } - } - - const validation = knowledgeSearchBodySchema.safeParse(searchParams) - if (!validation.success) return validationErrorResponse(validation.error) - const validatedData = validation.data - - const knowledgeBaseIds = Array.isArray(validatedData.knowledgeBaseIds) - ? validatedData.knowledgeBaseIds - : [validatedData.knowledgeBaseIds] - - const accessChecks = await Promise.all( - knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId)) - ) - const accessibleKbIds: string[] = knowledgeBaseIds.filter( - (_, idx) => accessChecks[idx]?.hasAccess - ) - - let structuredFilters: StructuredFilter[] = [] - - if (validatedData.tagFilters && accessibleKbIds.length > 0) { - const kbTagDefs = await Promise.all( - accessibleKbIds.map(async (kbId) => ({ - kbId, - tagDefs: await getDocumentTagDefinitions(kbId), - })) - ) - - const displayNameToTagDef: Record<string, { tagSlot: string; fieldType: string }> = {} - for (const { kbId, tagDefs } of kbTagDefs) { - const perKbMap = new Map( - tagDefs.map((def) => [ - def.displayName, - { tagSlot: def.tagSlot, fieldType: def.fieldType }, - ]) - ) - - for (const filter of validatedData.tagFilters) { - const current = perKbMap.get(filter.tagName) - if (!current) { - if (accessibleKbIds.length > 1) { - return NextResponse.json( - { - error: `Tag "${filter.tagName}" does not exist in all selected knowledge bases. Search those knowledge bases separately.`, - }, - { status: 400 } - ) - } - continue - } - - const existing = displayNameToTagDef[filter.tagName] - if ( - existing && - (existing.tagSlot !== current.tagSlot || existing.fieldType !== current.fieldType) - ) { - return NextResponse.json( - { - error: `Tag "${filter.tagName}" is not mapped consistently across the selected knowledge bases. Search those knowledge bases separately.`, - }, - { status: 400 } - ) - } - - displayNameToTagDef[filter.tagName] = current - } - - logger.debug(`[${requestId}] Loaded tag definitions for KB ${kbId}`, { - tagCount: tagDefs.length, - }) - } - - const undefinedTags: string[] = [] - const typeErrors: string[] = [] - - for (const filter of validatedData.tagFilters) { - const tagDef = displayNameToTagDef[filter.tagName] - - if (!tagDef) { - undefinedTags.push(filter.tagName) - continue - } - - const validationError = validateTagValue( - filter.tagName, - String(filter.value), - tagDef.fieldType - ) - if (validationError) { - typeErrors.push(validationError) - } - } - - if (undefinedTags.length > 0 || typeErrors.length > 0) { - const errorParts: string[] = [] - - if (undefinedTags.length > 0) { - errorParts.push(buildUndefinedTagsError(undefinedTags)) - } - - if (typeErrors.length > 0) { - errorParts.push(...typeErrors) - } - - return NextResponse.json({ error: errorParts.join('\n') }, { status: 400 }) - } - - structuredFilters = validatedData.tagFilters.map((filter) => { - const tagDef = displayNameToTagDef[filter.tagName]! - const tagSlot = tagDef.tagSlot - const fieldType = tagDef.fieldType - - logger.debug( - `[${requestId}] Structured filter: ${filter.tagName} -> ${tagSlot} (${fieldType}) ${filter.operator}` - ) - - return { - tagSlot, - fieldType, - operator: filter.operator, - value: filter.value, - valueTo: filter.valueTo, - } - }) - } - - if (accessibleKbIds.length === 0) { - return NextResponse.json( - { error: 'Knowledge base not found or access denied' }, - { status: 404 } - ) - } - - const accessibleKbs = accessChecks - .filter((ac): ac is KnowledgeBaseAccessResult => Boolean(ac?.hasAccess)) - .map((ac) => ac.knowledgeBase) - const useReranker = validatedData.rerankerEnabled && Boolean(validatedData.query?.trim()) - const rerankerModel = useReranker ? validatedData.rerankerModel : null - - const hasQuery = validatedData.query && validatedData.query.trim().length > 0 - const workspaceIds = new Set(accessibleKbs.map((kb) => kb.workspaceId ?? null)) - if (hasQuery && workspaceIds.size > 1) { - return NextResponse.json( - { error: 'Selected knowledge bases must belong to the same workspace' }, - { status: 400 } - ) - } - const workspaceId = accessibleKbs[0]?.workspaceId - - if (workflowId) { - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId: workflowId as string, - userId, - action: 'read', - }) - const workflowWorkspaceId = authorization.workflow?.workspaceId ?? null - if ( - workflowWorkspaceId && - accessChecks.some( - (accessCheck) => - accessCheck?.hasAccess && accessCheck.knowledgeBase?.workspaceId !== workflowWorkspaceId - ) - ) { - return NextResponse.json( - { error: 'Knowledge base does not belong to the workflow workspace' }, - { status: 400 } - ) - } - } - - const billingAttribution = - hasQuery && workspaceId - ? auth.authType === AuthType.INTERNAL_JWT - ? requireBillingAttributionHeader(request.headers, { - actorUserId: userId, - workspaceId, - }) - : shouldMeter - ? await resolveBillingAttribution({ - actorUserId: userId, - workspaceId, - }) - : undefined - : undefined - const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel))) - if (hasQuery && embeddingModels.length > 1) { - return NextResponse.json( - { - error: - 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.', - }, - { status: 400 } - ) - } - const queryEmbeddingModel = embeddingModels[0] - - const inaccessibleKbIds = knowledgeBaseIds.filter((id) => !accessibleKbIds.includes(id)) - - if (inaccessibleKbIds.length > 0) { - return NextResponse.json( - { error: `Knowledge bases not found or access denied: ${inaccessibleKbIds.join(', ')}` }, - { status: 404 } - ) - } - - /** - * Gate the workspace payer and actor before hosted embedding cost. Internal - * workflow tools were gated during preprocessing, and tag-only search is free. - */ - if (shouldMeter && hasQuery) { - const usage = billingAttribution - ? await checkAttributedUsageLimits(billingAttribution) - : await checkActorUsageLimits(userId) - if (usage.isExceeded) { - return NextResponse.json( - { error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' }, - { status: 402 } - ) - } - } - - const modelInputProvenance = await prepareKnowledgeModelInputProvenance({ - headers: request.headers, - payload: body, - isInternalRequest: auth.authType === AuthType.INTERNAL_JWT, + internalKnowledgeErrorPolicies, + internalKnowledgeSessionOrExecutorAuth, +} from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { searchKnowledge } from '@/lib/knowledge/application/search' +import { prepareKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' +import { finalizeKnowledgeRegistryResponse } from '@/app/api/knowledge/secret-provenance' + +export const POST = defineInternalJsonRoute({ + contract: internalKnowledgeSearchContract, + auth: internalKnowledgeSessionOrExecutorAuth, + operation: knowledgeOperations.search, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal Knowledge-search behavior', + }), + errorPolicy: internalKnowledgeErrorPolicies.search, + parseOptions: { maxBodyBytes: 2 * 1024 * 1024 }, + mapInput: ({ body }, { principal, request }) => ({ + knowledgeBaseIds: Array.isArray(body.knowledgeBaseIds) + ? body.knowledgeBaseIds + : [body.knowledgeBaseIds], + query: body.query, + topK: body.topK, + tagFilters: body.tagFilters, + searchMode: body.searchMode, + rerankerEnabled: body.rerankerEnabled, + rerankerModel: body.rerankerModel, + rerankerInputCount: body.rerankerInputCount, + rerankerApiKey: body.rerankerApiKey, + skipUsageBilling: body.skipUsageBilling, + resolveBillingAttribution: (workspaceId: string) => + resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), + prepareModelInputProvenance: async ({ userId, - workspaceId: workspaceId ?? undefined, - modelInput: validatedData.query, - }) - if (!modelInputProvenance.success) { - return NextResponse.json( - { error: modelInputProvenance.error }, - { status: modelInputProvenance.status } - ) - } - - const queryEmbeddingPromise = hasQuery - ? runWithKnowledgeModelInputProvenance(modelInputProvenance.registry, () => - generateSearchEmbedding(validatedData.query!, queryEmbeddingModel, workspaceId) - ) - : Promise.resolve(null) - - let results: SearchResult[] - - const hasFilters = structuredFilters && structuredFilters.length > 0 - - /** Oversample vector results when reranking so the reranker has more to choose from. - * Cap at 100 to bound Cohere request cost (1 search unit = ≤100 docs). When the caller - * supplies `rerankerInputCount`, honor it but never let it drop below `topK` - * (which would defeat the purpose) or exceed 100 (which would split into >1 search units). */ - const rawInputCount = validatedData.rerankerInputCount - if (useReranker && rawInputCount !== undefined && rawInputCount < validatedData.topK) { - logger.warn( - `[${requestId}] rerankerInputCount (${rawInputCount}) is below topK (${validatedData.topK}); raising to topK` - ) - } - const candidateTopK = useReranker - ? rawInputCount !== undefined - ? Math.min(100, Math.max(validatedData.topK, rawInputCount)) - : Math.min(100, validatedData.topK * 4) - : validatedData.topK - - if (!hasQuery && hasFilters) { - results = await executeKnowledgeSearch({ - knowledgeBaseIds: accessibleKbIds, - topK: validatedData.topK, - searchMode: validatedData.searchMode, - structuredFilters, - }) - } else if (hasQuery) { - logger.debug( - `[${requestId}] Executing ${validatedData.searchMode} search`, - hasFilters ? { filterCount: structuredFilters?.length ?? 0 } : undefined - ) - const queryVector = JSON.stringify((await queryEmbeddingPromise)?.embedding ?? null) - - results = await executeKnowledgeSearch({ - knowledgeBaseIds: accessibleKbIds, - topK: candidateTopK, - searchMode: validatedData.searchMode, - query: validatedData.query, - queryVector, - structuredFilters: hasFilters ? structuredFilters : undefined, - }) - } else { - return NextResponse.json( - { - error: - 'Please provide either a search query or tag filters to search your knowledge base', - }, - { status: 400 } - ) - } - - const resultSecretRegistry = - modelInputProvenance.registry ?? - new ResolvedSecretTraceRegistry([], { + workspaceId, + }: { + userId: string + workspaceId: string + }) => { + const prepared = await prepareKnowledgeModelInputProvenance({ + headers: request.headers, + payload: body, + isInternalRequest: principal.kind === 'delegated', userId, - ...(workspaceId ? { workspaceId } : {}), + workspaceId, + modelInput: body.query, }) - const resultProvenanceSnapshot = await importKnowledgeSearchResultSecretProvenance({ - registry: resultSecretRegistry, - results, - }) - if (!resultProvenanceSnapshot.imported) { - resultSecretRegistry.markIncomplete() - if (useReranker) { - return NextResponse.json( - { error: 'Knowledge result secret provenance is unavailable' }, - { status: 422 } - ) - } - } - - /** Optional Cohere rerank pass on top of vector results. - * `rerankBilled` = Cohere was successfully called (even with 0 results) and we owe the search unit. */ - const rerankedScores = new Map<string, number>() - let rerankBilled = false - let rerankIsBYOK = false - if (useReranker && rerankerModel && results.length > 0) { - const candidateCount = results.length - try { - const { results: ranked, isBYOK } = await runWithKnowledgeModelInputProvenance( - resultSecretRegistry, - () => - rerank( - validatedData.query!, - results.map((r) => ({ id: r.id, text: r.content })), - { - model: rerankerModel, - topN: validatedData.topK, - workspaceId, - apiKey: validatedData.rerankerApiKey, - } - ) - ) - rerankBilled = true - rerankIsBYOK = isBYOK - if (ranked.length === 0) { - logger.warn( - `[${requestId}] Reranker returned 0 results; falling back to vector ordering`, - { model: rerankerModel, candidateCount } - ) - results = results.slice(0, validatedData.topK) - } else { - const idToResult = new Map(results.map((r) => [r.id, r])) - results = ranked - .map((r) => idToResult.get(r.item.id)) - .filter((r): r is SearchResult => Boolean(r)) - for (const r of ranked) rerankedScores.set(r.item.id, r.relevanceScore) - logger.info(`[${requestId}] Reranked ${candidateCount} → ${results.length} results`, { - model: rerankerModel, - }) - } - } catch (error) { - if (resultSecretRegistry.isPermanentlyIncomplete()) throw error - logger.warn(`[${requestId}] Reranker failed; falling back to vector ordering`, { - error: getErrorMessage(error, 'Unknown error'), - model: rerankerModel, - candidateCount, - workspaceId, - }) - results = results.slice(0, validatedData.topK) - } - } else if (useReranker) { - results = results.slice(0, validatedData.topK) - } - - let cost = null - let tokenCount = null - if (hasQuery) { - try { - tokenCount = estimateTokenCount( - validatedData.query!, - getEmbeddingModelInfo(queryEmbeddingModel).tokenizerProvider - ) - // BYOK query embeddings incur no Sim cost, so don't bill (or roll up) them. - const queryEmbeddingResult = await queryEmbeddingPromise - if (!queryEmbeddingResult?.isBYOK) { - cost = calculateCost(queryEmbeddingModel, tokenCount.count, 0, false) - } - } catch (error) { - logger.warn(`[${requestId}] Failed to calculate cost for search query`, { - error: getErrorMessage(error, 'Unknown error'), - }) - } - } - - /** Add Cohere rerank cost (1 search unit per successful call, since we cap candidates ≤100). - * Bill on every successful API response — Cohere charges even when 0 results are returned. */ - let rerankerCost = 0 - if (rerankBilled && rerankerModel && !rerankIsBYOK) { - const pricing = getRerankModelPricing(rerankerModel) - if (pricing) { - rerankerCost = pricing.perSearchUnit - if (cost) { - cost = { - ...cost, - input: cost.input + rerankerCost, - total: cost.total + rerankerCost, - } - } else { - cost = { - input: rerankerCost, - output: 0, - total: rerankerCost, - pricing: { input: 0, output: 0, updatedAt: pricing.updatedAt }, - } - } - } else { - logger.warn(`[${requestId}] No pricing entry for rerank model ${rerankerModel}`) - } - } - - // Record query-embedding + reranker cost for standalone callers (UI, copilot, - // guardrail RAG). The workflow tool sets skipUsageBilling and rolls the cost - // up via the executor instead, so this never double-bills; BYOK already - // resolved to 0 above. - if (shouldMeter && cost && cost.total > 0) { - const { recordUsage } = await import('@/lib/billing/core/usage-log') - try { - await recordUsage({ - userId, - workspaceId: workspaceId ?? undefined, - ...(billingAttribution ? toBillingContext(billingAttribution) : {}), - entries: [ - { - category: 'model', - source: 'knowledge-base', - description: queryEmbeddingModel, - cost: cost.total, - sourceReference: `kb-search:${requestId}`, - }, - ], - }) - if (billingAttribution) { - await checkAndBillPayerOverageThreshold(billingAttribution.billingEntity) - } else { - await checkAndBillOverageThreshold(userId) - } - } catch (billingError) { - logger.error(`[${requestId}] Failed to record KB search usage`, { error: billingError }) - } - } - - const tagDefsResults = await Promise.all( - accessibleKbIds.map(async (kbId) => { - try { - const tagDefs = await getDocumentTagDefinitions(kbId) - const map: Record<string, string> = {} - tagDefs.forEach((def) => { - map[def.tagSlot] = def.displayName - }) - return { kbId, map } - } catch (error) { - logger.warn(`[${requestId}] Failed to fetch tag definitions for display mapping:`, error) - return { kbId, map: {} as Record<string, string> } - } - }) - ) - const tagDefinitionsMap: Record<string, Record<string, string>> = {} - tagDefsResults.forEach(({ kbId, map }) => { - tagDefinitionsMap[kbId] = map - }) - - const documentMetadataMap = resultProvenanceSnapshot.documentMetadata - - try { - PlatformEvents.knowledgeBaseSearched({ - knowledgeBaseId: accessibleKbIds[0], - resultsCount: results.length, - workspaceId: workspaceId || undefined, - }) - } catch { - // Telemetry should not fail the operation - } - - const renderedResults = results.map((result) => { - const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} - logger.debug( - `[${requestId}] Result KB: ${result.knowledgeBaseId}, available mappings:`, - kbTagMap - ) - - const tags: Record<string, unknown> = {} - const docMeta = documentMetadataMap[result.documentId] - ALL_TAG_SLOTS.forEach((slot) => { - const tagValue = slot.startsWith('tag') - ? docMeta?.[ - slot as keyof Pick< - typeof docMeta, - 'tag1' | 'tag2' | 'tag3' | 'tag4' | 'tag5' | 'tag6' | 'tag7' - > - ] - : result[slot] - if (tagValue !== null && tagValue !== undefined) { - const displayName = kbTagMap[slot] || slot - logger.debug(`[${requestId}] Mapping ${slot} -> "${displayName}"`) - tags[displayName] = tagValue - } - }) - - const rerankerScore = rerankedScores.get(result.id) - return { - documentId: result.documentId, - documentName: docMeta?.filename || undefined, - sourceUrl: docMeta?.sourceUrl ?? null, - content: result.content, - chunkIndex: result.chunkIndex, - metadata: tags, - similarity: hasQuery ? 1 - result.distance : 1, - ...(rerankerScore !== undefined && { rerankerScore }), - } - }) - - for (const [documentId, metadata] of Object.entries(documentMetadataMap)) { - const renderedMetadata = renderedResults - .filter((result) => result.documentId === documentId) - .map((result) => ({ - documentName: result.documentName, - sourceUrl: result.sourceUrl, - metadata: result.metadata, - })) - if ( - renderedMetadata.length > 0 && - !(await importDurableSecretProvenance( - resultSecretRegistry, - metadata.provenance, - renderedMetadata - )) - ) { - resultSecretRegistry.markIncomplete() - } - } - - const responseBody = { - success: true, - data: { - results: renderedResults, - query: validatedData.query || '', - knowledgeBaseIds: accessibleKbIds, - knowledgeBaseId: accessibleKbIds[0], - topK: validatedData.topK, - totalResults: results.length, - ...(cost - ? { - cost: { - input: cost.input, - output: cost.output, - total: cost.total, - tokens: { - prompt: tokenCount?.count ?? 0, - completion: 0, - total: tokenCount?.count ?? 0, - }, - model: queryEmbeddingModel, - pricing: cost.pricing, - ...(rerankBilled && !rerankIsBYOK - ? { rerankerCost, rerankerModel, rerankerSearchUnits: 1 } - : {}), - }, - } - : {}), - }, - } - return createKnowledgeRegistryResponse({ + if (!prepared.success) throw new OrchestrationError('validation', prepared.error) + return prepared.registry + }, + }), + useCase: searchKnowledge, + present: (result) => ({ + success: true as const, + data: { + results: result.results.map(({ embeddingId: _embeddingId, ...item }) => item), + query: result.query, + knowledgeBaseIds: result.knowledgeBaseIds, + knowledgeBaseId: result.knowledgeBaseId, + topK: result.topK, + totalResults: result.totalResults, + ...(result.cost ? { cost: result.cost } : {}), + }, + }), + finalizeResponse: ({ request, principal, result, body }) => { + if (!result.resultSecretRegistry) { + throw new Error('Internal Knowledge search did not produce a provenance registry') + } + return finalizeKnowledgeRegistryResponse({ request, - authType: auth.authType, - body: responseBody, - registry: resultSecretRegistry, + authType: internalKnowledgeAuthType(principal), + body, + registry: result.resultSecretRegistry, }) - } catch (error) { - return NextResponse.json( - { - error: 'Failed to perform vector search', - message: getErrorMessage(error, 'Unknown error'), - }, - { status: 500 } - ) - } + }, }) diff --git a/apps/sim/app/api/knowledge/secret-provenance.ts b/apps/sim/app/api/knowledge/secret-provenance.ts index 45d45206b4d..10ba3b6fe7c 100644 --- a/apps/sim/app/api/knowledge/secret-provenance.ts +++ b/apps/sim/app/api/knowledge/secret-provenance.ts @@ -1,5 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' +import type { InternalJsonResponseFinalization } from '@/lib/api/server/routes/internal-json-route' import { AuthType, type AuthTypeValue } from '@/lib/auth/hybrid' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { createDurableSecretProvenanceRegistry, type DurableSecretProvenance, @@ -12,6 +14,7 @@ import { } from '@/lib/execution/model-input-provenance' import { negotiatePrivateToolMetadataResponse, + RESOLVED_SECRET_PROVENANCE_FIELD, RESOLVED_SECRET_PROVENANCE_METADATA_V1, serializePrivateToolMetadataResponseEnvelope, } from '@/lib/execution/private-tool-metadata' @@ -33,6 +36,21 @@ function invalidKnowledgeProvenanceResponse(): NextResponse { return NextResponse.json({ error: 'Invalid knowledge secret provenance' }, { status: 400 }) } +function rejectInvalidKnowledgeProvenance(): never { + throw new OrchestrationError('validation', 'Invalid knowledge secret provenance') +} + +function finalizeKnowledgeMetadataEnvelope( + envelope: ReturnType<typeof serializePrivateToolMetadataResponseEnvelope> +): InternalJsonResponseFinalization { + return { + bodyFields: { + [RESOLVED_SECRET_PROVENANCE_FIELD]: envelope.body[RESOLVED_SECRET_PROVENANCE_FIELD], + }, + headers: envelope.headers, + } +} + type KnowledgeWriteProvenanceResolution = | { success: true; provenances?: DurableSecretProvenance[] } | { success: false; response: NextResponse } @@ -134,23 +152,23 @@ export function resolveKnowledgeDocumentWriteSecretProvenance(options: { return { success: true, provenances } } -/** Adds private provenance for a raw Knowledge response without changing its functional shape. */ -export async function createKnowledgeProvenanceResponse(options: { +/** Finalizes private provenance after the functional Knowledge response passes its contract. */ +export async function finalizeKnowledgeProvenanceResponse(options: { request: NextRequest authType: AuthTypeValue | undefined userId: string workspaceId?: string body: Record<string, unknown> provenances: readonly DurableSecretProvenance[] -}): Promise<NextResponse> { +}): Promise<InternalJsonResponseFinalization> { const { request } = options const negotiation = negotiatePrivateToolMetadataResponse( request.headers, RESOLVED_SECRET_PROVENANCE_METADATA_V1, options.authType === AuthType.INTERNAL_JWT ) - if (negotiation.status === 'not-requested') return NextResponse.json(options.body) - if (negotiation.status === 'rejected') return invalidKnowledgeProvenanceResponse() + if (negotiation.status === 'not-requested') return {} + if (negotiation.status === 'rejected') rejectInvalidKnowledgeProvenance() const registry = new ResolvedSecretTraceRegistry([], { userId: options.userId, ...(options.workspaceId ? { workspaceId: options.workspaceId } : {}), @@ -171,34 +189,34 @@ export async function createKnowledgeProvenanceResponse(options: { RESOLVED_SECRET_PROVENANCE_METADATA_V1, registry.exportCommittedProvenanceForValue(options.body) ) - return NextResponse.json(envelope.body, { headers: envelope.headers }) + return finalizeKnowledgeMetadataEnvelope(envelope) } /** Serializes an already-populated request registry as private response metadata. */ -export function createKnowledgeRegistryResponse(options: { +export function finalizeKnowledgeRegistryResponse(options: { request: NextRequest authType: AuthTypeValue | undefined body: Record<string, unknown> registry: ResolvedSecretTraceRegistry -}): NextResponse { +}): InternalJsonResponseFinalization { const { request } = options const negotiation = negotiatePrivateToolMetadataResponse( request.headers, RESOLVED_SECRET_PROVENANCE_METADATA_V1, options.authType === AuthType.INTERNAL_JWT ) - if (negotiation.status === 'not-requested') return NextResponse.json(options.body) - if (negotiation.status === 'rejected') return invalidKnowledgeProvenanceResponse() + if (negotiation.status === 'not-requested') return {} + if (negotiation.status === 'rejected') rejectInvalidKnowledgeProvenance() const envelope = serializePrivateToolMetadataResponseEnvelope( options.body, RESOLVED_SECRET_PROVENANCE_METADATA_V1, options.registry.exportCommittedProvenanceForValue(options.body) ) - return NextResponse.json(envelope.body, { headers: envelope.headers }) + return finalizeKnowledgeMetadataEnvelope(envelope) } /** Emits private response provenance for a bounded exact snapshot of persisted KB rows. */ -export async function createKnowledgePersistedResponse(options: { +export async function finalizeKnowledgePersistedResponse(options: { request: NextRequest authType: AuthTypeValue | undefined userId: string @@ -215,15 +233,15 @@ export async function createKnowledgePersistedResponse(options: { content: string value: unknown }[] -}): Promise<NextResponse> { +}): Promise<InternalJsonResponseFinalization> { const { request } = options const negotiation = negotiatePrivateToolMetadataResponse( request.headers, RESOLVED_SECRET_PROVENANCE_METADATA_V1, options.authType === AuthType.INTERNAL_JWT ) - if (negotiation.status === 'not-requested') return NextResponse.json(options.body) - if (negotiation.status === 'rejected') return invalidKnowledgeProvenanceResponse() + if (negotiation.status === 'not-requested') return {} + if (negotiation.status === 'rejected') rejectInvalidKnowledgeProvenance() const registry = new ResolvedSecretTraceRegistry([], { userId: options.userId, @@ -234,7 +252,7 @@ export async function createKnowledgePersistedResponse(options: { documents: options.documents, chunks: options.chunks, }) - return createKnowledgeRegistryResponse({ + return finalizeKnowledgeRegistryResponse({ request: options.request, authType: options.authType, body: options.body, diff --git a/apps/sim/app/api/providers/route.test.ts b/apps/sim/app/api/providers/route.test.ts index 34558934d2e..05f809a8184 100644 --- a/apps/sim/app/api/providers/route.test.ts +++ b/apps/sim/app/api/providers/route.test.ts @@ -49,7 +49,7 @@ vi.mock('@/providers/model-input-provenance', () => ({ collectProviderModelInputProvenanceValues: mockCollectProviderModelInputProvenanceValues, })) -vi.mock('@/app/api/auth/oauth/utils', () => ({ +vi.mock('@/lib/oauth/credential-service', () => ({ getServiceAccountToken: vi.fn(), refreshTokenIfNeeded: vi.fn(), resolveOAuthAccountId: vi.fn(), diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index bc335f9ceaa..a5292978d3b 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -17,12 +17,12 @@ import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-cont import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { inspectModelInputProvenanceRequest } from '@/lib/execution/model-input-provenance' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { getServiceAccountToken, refreshTokenIfNeeded, resolveOAuthAccountId, -} from '@/app/api/auth/oauth/utils' +} from '@/lib/oauth/credential-service' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { assertPermissionsAllowed, IntegrationNotAllowedError, diff --git a/apps/sim/app/api/tools/airtable/bases/route.ts b/apps/sim/app/api/tools/airtable/bases/route.ts index 20b3b3459d7..a309daa1f08 100644 --- a/apps/sim/app/api/tools/airtable/bases/route.ts +++ b/apps/sim/app/api/tools/airtable/bases/route.ts @@ -5,7 +5,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' const logger = createLogger('AirtableBasesAPI') diff --git a/apps/sim/app/api/tools/airtable/tables/route.ts b/apps/sim/app/api/tools/airtable/tables/route.ts index 5d08b698747..3f4ba3c9739 100644 --- a/apps/sim/app/api/tools/airtable/tables/route.ts +++ b/apps/sim/app/api/tools/airtable/tables/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAirtableId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' const logger = createLogger('AirtableTablesAPI') diff --git a/apps/sim/app/api/tools/asana/workspaces/route.ts b/apps/sim/app/api/tools/asana/workspaces/route.ts index 0f71376c6df..0aaec253c95 100644 --- a/apps/sim/app/api/tools/asana/workspaces/route.ts +++ b/apps/sim/app/api/tools/asana/workspaces/route.ts @@ -5,7 +5,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' const logger = createLogger('AsanaWorkspacesAPI') diff --git a/apps/sim/app/api/tools/attio/lists/route.ts b/apps/sim/app/api/tools/attio/lists/route.ts index ea30238d737..310784e6fb4 100644 --- a/apps/sim/app/api/tools/attio/lists/route.ts +++ b/apps/sim/app/api/tools/attio/lists/route.ts @@ -5,7 +5,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' const logger = createLogger('AttioListsAPI') diff --git a/apps/sim/app/api/tools/attio/objects/route.ts b/apps/sim/app/api/tools/attio/objects/route.ts index 38cc19d4bbc..de0e7820f91 100644 --- a/apps/sim/app/api/tools/attio/objects/route.ts +++ b/apps/sim/app/api/tools/attio/objects/route.ts @@ -5,7 +5,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' const logger = createLogger('AttioObjectsAPI') diff --git a/apps/sim/app/api/tools/calcom/event-types/route.ts b/apps/sim/app/api/tools/calcom/event-types/route.ts index a9ab63da8e4..0bb4a9cd8d0 100644 --- a/apps/sim/app/api/tools/calcom/event-types/route.ts +++ b/apps/sim/app/api/tools/calcom/event-types/route.ts @@ -5,7 +5,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' const logger = createLogger('CalcomEventTypesAPI') diff --git a/apps/sim/app/api/tools/calcom/schedules/route.ts b/apps/sim/app/api/tools/calcom/schedules/route.ts index 15b6e1dfc6e..8ccb0700017 100644 --- a/apps/sim/app/api/tools/calcom/schedules/route.ts +++ b/apps/sim/app/api/tools/calcom/schedules/route.ts @@ -5,7 +5,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' const logger = createLogger('CalcomSchedulesAPI') diff --git a/apps/sim/app/api/tools/clickup/folders/route.ts b/apps/sim/app/api/tools/clickup/folders/route.ts index 6613be9189e..fc9063ce7a6 100644 --- a/apps/sim/app/api/tools/clickup/folders/route.ts +++ b/apps/sim/app/api/tools/clickup/folders/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' const logger = createLogger('ClickUpFoldersAPI') diff --git a/apps/sim/app/api/tools/clickup/lists/route.ts b/apps/sim/app/api/tools/clickup/lists/route.ts index a07712b36e4..f77d7153b2b 100644 --- a/apps/sim/app/api/tools/clickup/lists/route.ts +++ b/apps/sim/app/api/tools/clickup/lists/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' const logger = createLogger('ClickUpListsAPI') diff --git a/apps/sim/app/api/tools/clickup/spaces/route.ts b/apps/sim/app/api/tools/clickup/spaces/route.ts index 197e5245ca4..2f8d6ca5932 100644 --- a/apps/sim/app/api/tools/clickup/spaces/route.ts +++ b/apps/sim/app/api/tools/clickup/spaces/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' const logger = createLogger('ClickUpSpacesAPI') diff --git a/apps/sim/app/api/tools/clickup/workspaces/route.ts b/apps/sim/app/api/tools/clickup/workspaces/route.ts index 2ff80e7f2dd..9c2990a18f7 100644 --- a/apps/sim/app/api/tools/clickup/workspaces/route.ts +++ b/apps/sim/app/api/tools/clickup/workspaces/route.ts @@ -5,7 +5,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' const logger = createLogger('ClickUpWorkspacesAPI') diff --git a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts index 4ad5c0f2629..ca7431294c5 100644 --- a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts +++ b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts @@ -6,12 +6,12 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' import { getAtlassianServiceAccountSecret, refreshAccessTokenIfNeeded, resolveOAuthAccountId, -} from '@/app/api/auth/oauth/utils' +} from '@/lib/oauth/credential-service' +import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' import { getConfluenceCloudId } from '@/tools/confluence/utils' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' diff --git a/apps/sim/app/api/tools/drive/file/route.ts b/apps/sim/app/api/tools/drive/file/route.ts index 85af8e72bc7..69e569564cf 100644 --- a/apps/sim/app/api/tools/drive/file/route.ts +++ b/apps/sim/app/api/tools/drive/file/route.ts @@ -7,8 +7,11 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + refreshAccessTokenIfNeeded, + ServiceAccountTokenError, +} from '@/lib/oauth/credential-service' import { getScopesForService } from '@/lib/oauth/utils' -import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('GoogleDriveFileAPI') diff --git a/apps/sim/app/api/tools/drive/files/route.ts b/apps/sim/app/api/tools/drive/files/route.ts index 4c38334b14d..c3bdd3c6911 100644 --- a/apps/sim/app/api/tools/drive/files/route.ts +++ b/apps/sim/app/api/tools/drive/files/route.ts @@ -7,9 +7,12 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + refreshAccessTokenIfNeeded, + ServiceAccountTokenError, +} from '@/lib/oauth/credential-service' import { drainGooglePagedList, GooglePageError } from '@/lib/oauth/google-pagination' import { getScopesForService } from '@/lib/oauth/utils' -import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('GoogleDriveFilesAPI') diff --git a/apps/sim/app/api/tools/gmail/label/route.ts b/apps/sim/app/api/tools/gmail/label/route.ts index 75cd890a522..f1abd52383c 100644 --- a/apps/sim/app/api/tools/gmail/label/route.ts +++ b/apps/sim/app/api/tools/gmail/label/route.ts @@ -6,8 +6,11 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + refreshAccessTokenIfNeeded, + ServiceAccountTokenError, +} from '@/lib/oauth/credential-service' import { getScopesForService } from '@/lib/oauth/utils' -import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/gmail/labels/route.ts b/apps/sim/app/api/tools/gmail/labels/route.ts index 3b05cf12a9e..d531ec0a61d 100644 --- a/apps/sim/app/api/tools/gmail/labels/route.ts +++ b/apps/sim/app/api/tools/gmail/labels/route.ts @@ -6,12 +6,12 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getScopesForService } from '@/lib/oauth/utils' import { getServiceAccountToken, refreshAccessTokenIfNeeded, ServiceAccountTokenError, -} from '@/app/api/auth/oauth/utils' +} from '@/lib/oauth/credential-service' +import { getScopesForService } from '@/lib/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('GmailLabelsAPI') diff --git a/apps/sim/app/api/tools/google_bigquery/datasets/route.ts b/apps/sim/app/api/tools/google_bigquery/datasets/route.ts index 695a371e170..db98979b24e 100644 --- a/apps/sim/app/api/tools/google_bigquery/datasets/route.ts +++ b/apps/sim/app/api/tools/google_bigquery/datasets/route.ts @@ -5,9 +5,12 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + refreshAccessTokenIfNeeded, + ServiceAccountTokenError, +} from '@/lib/oauth/credential-service' import { drainGooglePagedList, GooglePageError } from '@/lib/oauth/google-pagination' import { getScopesForService } from '@/lib/oauth/utils' -import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' const logger = createLogger('GoogleBigQueryDatasetsAPI') diff --git a/apps/sim/app/api/tools/google_bigquery/tables/route.ts b/apps/sim/app/api/tools/google_bigquery/tables/route.ts index af013790595..8cb630b944e 100644 --- a/apps/sim/app/api/tools/google_bigquery/tables/route.ts +++ b/apps/sim/app/api/tools/google_bigquery/tables/route.ts @@ -5,9 +5,12 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + refreshAccessTokenIfNeeded, + ServiceAccountTokenError, +} from '@/lib/oauth/credential-service' import { drainGooglePagedList, GooglePageError } from '@/lib/oauth/google-pagination' import { getScopesForService } from '@/lib/oauth/utils' -import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' const logger = createLogger('GoogleBigQueryTablesAPI') diff --git a/apps/sim/app/api/tools/google_calendar/calendars/route.ts b/apps/sim/app/api/tools/google_calendar/calendars/route.ts index 752cc72b229..0102f8c3b78 100644 --- a/apps/sim/app/api/tools/google_calendar/calendars/route.ts +++ b/apps/sim/app/api/tools/google_calendar/calendars/route.ts @@ -5,9 +5,12 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + refreshAccessTokenIfNeeded, + ServiceAccountTokenError, +} from '@/lib/oauth/credential-service' import { drainGooglePagedList, GooglePageError } from '@/lib/oauth/google-pagination' import { getScopesForService } from '@/lib/oauth/utils' -import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('GoogleCalendarAPI') diff --git a/apps/sim/app/api/tools/google_sheets/sheets/route.ts b/apps/sim/app/api/tools/google_sheets/sheets/route.ts index 951c31f67e8..18fca36e377 100644 --- a/apps/sim/app/api/tools/google_sheets/sheets/route.ts +++ b/apps/sim/app/api/tools/google_sheets/sheets/route.ts @@ -6,8 +6,11 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + refreshAccessTokenIfNeeded, + ServiceAccountTokenError, +} from '@/lib/oauth/credential-service' import { getScopesForService } from '@/lib/oauth/utils' -import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/google_tasks/task-lists/route.ts b/apps/sim/app/api/tools/google_tasks/task-lists/route.ts index 80c5a99f598..6b6dff75db7 100644 --- a/apps/sim/app/api/tools/google_tasks/task-lists/route.ts +++ b/apps/sim/app/api/tools/google_tasks/task-lists/route.ts @@ -5,9 +5,12 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + refreshAccessTokenIfNeeded, + ServiceAccountTokenError, +} from '@/lib/oauth/credential-service' import { drainGooglePagedList, GooglePageError } from '@/lib/oauth/google-pagination' import { getScopesForService } from '@/lib/oauth/utils' -import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' const logger = createLogger('GoogleTasksTaskListsAPI') diff --git a/apps/sim/app/api/tools/hubspot/lists/route.ts b/apps/sim/app/api/tools/hubspot/lists/route.ts index ab7cf55230e..171474f48c5 100644 --- a/apps/sim/app/api/tools/hubspot/lists/route.ts +++ b/apps/sim/app/api/tools/hubspot/lists/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/hubspot/owners/route.ts b/apps/sim/app/api/tools/hubspot/owners/route.ts index be34256def9..43f0b3576b6 100644 --- a/apps/sim/app/api/tools/hubspot/owners/route.ts +++ b/apps/sim/app/api/tools/hubspot/owners/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/hubspot/pipelines/route.ts b/apps/sim/app/api/tools/hubspot/pipelines/route.ts index fd9643bed3a..c7fd92edaa1 100644 --- a/apps/sim/app/api/tools/hubspot/pipelines/route.ts +++ b/apps/sim/app/api/tools/hubspot/pipelines/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/hubspot/properties/route.ts b/apps/sim/app/api/tools/hubspot/properties/route.ts index e52185455fc..3d52b9b0d8f 100644 --- a/apps/sim/app/api/tools/hubspot/properties/route.ts +++ b/apps/sim/app/api/tools/hubspot/properties/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts b/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts index b23b4b7c7a7..705dc2be2e0 100644 --- a/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts +++ b/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' diff --git a/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts b/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts index 786483630dd..9dc55ea0a83 100644 --- a/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts +++ b/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' diff --git a/apps/sim/app/api/tools/linear/projects/route.ts b/apps/sim/app/api/tools/linear/projects/route.ts index c549654b80a..360453b615a 100644 --- a/apps/sim/app/api/tools/linear/projects/route.ts +++ b/apps/sim/app/api/tools/linear/projects/route.ts @@ -7,7 +7,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/linear/teams/route.ts b/apps/sim/app/api/tools/linear/teams/route.ts index a03ccaea4d6..5d788bc2b07 100644 --- a/apps/sim/app/api/tools/linear/teams/route.ts +++ b/apps/sim/app/api/tools/linear/teams/route.ts @@ -7,7 +7,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/managed-agent/list/route.ts b/apps/sim/app/api/tools/managed-agent/list/route.ts index 1776f198793..97a1ca6fa47 100644 --- a/apps/sim/app/api/tools/managed-agent/list/route.ts +++ b/apps/sim/app/api/tools/managed-agent/list/route.ts @@ -12,8 +12,8 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/token-service-accounts/descriptors' import { AGENT_MEMORY_BETA, managedAgentsList } from '@/lib/managed-agents/session-client' +import { resolveOAuthAccountId, resolveServiceAccountToken } from '@/lib/oauth/credential-service' import { captureServerEvent } from '@/lib/posthog/server' -import { resolveOAuthAccountId, resolveServiceAccountToken } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/microsoft-teams/channels/route.ts b/apps/sim/app/api/tools/microsoft-teams/channels/route.ts index c8bd6ddcb57..27070fb419a 100644 --- a/apps/sim/app/api/tools/microsoft-teams/channels/route.ts +++ b/apps/sim/app/api/tools/microsoft-teams/channels/route.ts @@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/microsoft-teams/chats/route.ts b/apps/sim/app/api/tools/microsoft-teams/chats/route.ts index d709bcd62e6..afc5fc7668d 100644 --- a/apps/sim/app/api/tools/microsoft-teams/chats/route.ts +++ b/apps/sim/app/api/tools/microsoft-teams/chats/route.ts @@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/microsoft-teams/teams/route.ts b/apps/sim/app/api/tools/microsoft-teams/teams/route.ts index 990bfd282d2..7206e85ab24 100644 --- a/apps/sim/app/api/tools/microsoft-teams/teams/route.ts +++ b/apps/sim/app/api/tools/microsoft-teams/teams/route.ts @@ -5,7 +5,7 @@ import { microsoftTeamsSelectorContract } from '@/lib/api/contracts/selectors/mi import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/microsoft_excel/drives/route.ts b/apps/sim/app/api/tools/microsoft_excel/drives/route.ts index 97d921a5ea4..99dbd4d9f09 100644 --- a/apps/sim/app/api/tools/microsoft_excel/drives/route.ts +++ b/apps/sim/app/api/tools/microsoft_excel/drives/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validatePathSegment, validateSharePointSiteId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { extractGraphError, GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils' import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' diff --git a/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts b/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts index f08f968734c..bd6ff64e8df 100644 --- a/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts +++ b/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts @@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { extractGraphError, getItemBasePath } from '@/tools/microsoft_excel/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/microsoft_planner/plans/route.ts b/apps/sim/app/api/tools/microsoft_planner/plans/route.ts index 604f7c85b34..bee66b58459 100644 --- a/apps/sim/app/api/tools/microsoft_planner/plans/route.ts +++ b/apps/sim/app/api/tools/microsoft_planner/plans/route.ts @@ -5,7 +5,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' const logger = createLogger('MicrosoftPlannerPlansAPI') diff --git a/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts b/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts index b9b764089bd..e04a9bc2e3f 100644 --- a/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts +++ b/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import type { PlannerTask } from '@/tools/microsoft_planner/types' import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' diff --git a/apps/sim/app/api/tools/monday/boards/route.ts b/apps/sim/app/api/tools/monday/boards/route.ts index e5d3dc5fadc..a3de9b41989 100644 --- a/apps/sim/app/api/tools/monday/boards/route.ts +++ b/apps/sim/app/api/tools/monday/boards/route.ts @@ -5,7 +5,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/monday/groups/route.ts b/apps/sim/app/api/tools/monday/groups/route.ts index 49021443e64..3492f448564 100644 --- a/apps/sim/app/api/tools/monday/groups/route.ts +++ b/apps/sim/app/api/tools/monday/groups/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMondayNumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/notion/databases/route.ts b/apps/sim/app/api/tools/notion/databases/route.ts index c3f844495d9..966ac49fb51 100644 --- a/apps/sim/app/api/tools/notion/databases/route.ts +++ b/apps/sim/app/api/tools/notion/databases/route.ts @@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { extractTitleFromItem } from '@/tools/notion/utils' const logger = createLogger('NotionDatabasesAPI') diff --git a/apps/sim/app/api/tools/notion/pages/route.ts b/apps/sim/app/api/tools/notion/pages/route.ts index e48eadf8a41..4a0f486e495 100644 --- a/apps/sim/app/api/tools/notion/pages/route.ts +++ b/apps/sim/app/api/tools/notion/pages/route.ts @@ -6,7 +6,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { extractTitleFromItem } from '@/tools/notion/utils' const logger = createLogger('NotionPagesAPI') diff --git a/apps/sim/app/api/tools/onedrive/files/route.ts b/apps/sim/app/api/tools/onedrive/files/route.ts index 5bf2a580ac0..992ecd16875 100644 --- a/apps/sim/app/api/tools/onedrive/files/route.ts +++ b/apps/sim/app/api/tools/onedrive/files/route.ts @@ -6,7 +6,7 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types' import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' diff --git a/apps/sim/app/api/tools/onedrive/folder/route.ts b/apps/sim/app/api/tools/onedrive/folder/route.ts index 17ff0b02d8e..df3d192ad9c 100644 --- a/apps/sim/app/api/tools/onedrive/folder/route.ts +++ b/apps/sim/app/api/tools/onedrive/folder/route.ts @@ -6,7 +6,7 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/onedrive/folders/route.ts b/apps/sim/app/api/tools/onedrive/folders/route.ts index bcfd9273c2e..2ce737a57fe 100644 --- a/apps/sim/app/api/tools/onedrive/folders/route.ts +++ b/apps/sim/app/api/tools/onedrive/folders/route.ts @@ -6,7 +6,7 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types' import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' diff --git a/apps/sim/app/api/tools/outlook/calendars/route.ts b/apps/sim/app/api/tools/outlook/calendars/route.ts index 2c6a68c70b1..4227222c9e0 100644 --- a/apps/sim/app/api/tools/outlook/calendars/route.ts +++ b/apps/sim/app/api/tools/outlook/calendars/route.ts @@ -7,7 +7,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/outlook/folders/route.ts b/apps/sim/app/api/tools/outlook/folders/route.ts index 8ae9d3e9e21..1a6721b703b 100644 --- a/apps/sim/app/api/tools/outlook/folders/route.ts +++ b/apps/sim/app/api/tools/outlook/folders/route.ts @@ -7,7 +7,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/pipedrive/pipelines/route.ts b/apps/sim/app/api/tools/pipedrive/pipelines/route.ts index 16381901228..be03aa5bde7 100644 --- a/apps/sim/app/api/tools/pipedrive/pipelines/route.ts +++ b/apps/sim/app/api/tools/pipedrive/pipelines/route.ts @@ -5,7 +5,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveCredentialAccessToken } from '@/app/api/auth/oauth/utils' +import { resolveCredentialAccessToken } from '@/lib/oauth/credential-service' import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' const logger = createLogger('PipedrivePipelinesAPI') diff --git a/apps/sim/app/api/tools/sharepoint/lists/route.ts b/apps/sim/app/api/tools/sharepoint/lists/route.ts index a3970a6f043..43dc8ae95c9 100644 --- a/apps/sim/app/api/tools/sharepoint/lists/route.ts +++ b/apps/sim/app/api/tools/sharepoint/lists/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateSharePointSiteId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/sharepoint/site/route.ts b/apps/sim/app/api/tools/sharepoint/site/route.ts index ce7bcefcbc9..4dc2b508917 100644 --- a/apps/sim/app/api/tools/sharepoint/site/route.ts +++ b/apps/sim/app/api/tools/sharepoint/site/route.ts @@ -6,7 +6,7 @@ import { getValidationErrorMessage } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/sharepoint/sites/route.ts b/apps/sim/app/api/tools/sharepoint/sites/route.ts index fc8db948c7d..64bdd3684d5 100644 --- a/apps/sim/app/api/tools/sharepoint/sites/route.ts +++ b/apps/sim/app/api/tools/sharepoint/sites/route.ts @@ -5,7 +5,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import type { SharepointSite } from '@/tools/sharepoint/types' import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' diff --git a/apps/sim/app/api/tools/slack/channels/route.ts b/apps/sim/app/api/tools/slack/channels/route.ts index feb5a7b5153..3648f70e4fd 100644 --- a/apps/sim/app/api/tools/slack/channels/route.ts +++ b/apps/sim/app/api/tools/slack/channels/route.ts @@ -9,7 +9,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/slack/users/route.ts b/apps/sim/app/api/tools/slack/users/route.ts index cb1e69569f4..6d0ae67e31a 100644 --- a/apps/sim/app/api/tools/slack/users/route.ts +++ b/apps/sim/app/api/tools/slack/users/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/trello/boards/route.ts b/apps/sim/app/api/tools/trello/boards/route.ts index e4ca2f42461..8ce07d0500f 100644 --- a/apps/sim/app/api/tools/trello/boards/route.ts +++ b/apps/sim/app/api/tools/trello/boards/route.ts @@ -5,7 +5,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' const logger = createLogger('TrelloBoardsAPI') diff --git a/apps/sim/app/api/tools/wealthbox/item/route.ts b/apps/sim/app/api/tools/wealthbox/item/route.ts index 066cfb6fcbe..da8ad62b91f 100644 --- a/apps/sim/app/api/tools/wealthbox/item/route.ts +++ b/apps/sim/app/api/tools/wealthbox/item/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/wealthbox/items/route.ts b/apps/sim/app/api/tools/wealthbox/items/route.ts index a10c4672eb3..98223382e94 100644 --- a/apps/sim/app/api/tools/wealthbox/items/route.ts +++ b/apps/sim/app/api/tools/wealthbox/items/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/tools/webflow/collections/route.ts b/apps/sim/app/api/tools/webflow/collections/route.ts index 4df1bceaeca..4fd56b91f58 100644 --- a/apps/sim/app/api/tools/webflow/collections/route.ts +++ b/apps/sim/app/api/tools/webflow/collections/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' const logger = createLogger('WebflowCollectionsAPI') diff --git a/apps/sim/app/api/tools/webflow/items/route.ts b/apps/sim/app/api/tools/webflow/items/route.ts index 4feb0f40417..3a363c5eacc 100644 --- a/apps/sim/app/api/tools/webflow/items/route.ts +++ b/apps/sim/app/api/tools/webflow/items/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' const logger = createLogger('WebflowItemsAPI') diff --git a/apps/sim/app/api/tools/webflow/sites/route.ts b/apps/sim/app/api/tools/webflow/sites/route.ts index 89073c6eeb5..4aab5b999b2 100644 --- a/apps/sim/app/api/tools/webflow/sites/route.ts +++ b/apps/sim/app/api/tools/webflow/sites/route.ts @@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' const logger = createLogger('WebflowSitesAPI') diff --git a/apps/sim/app/api/tools/zoho_desk/selector-credential.ts b/apps/sim/app/api/tools/zoho_desk/selector-credential.ts index dbffa284a14..9d59d67ffdd 100644 --- a/apps/sim/app/api/tools/zoho_desk/selector-credential.ts +++ b/apps/sim/app/api/tools/zoho_desk/selector-credential.ts @@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { resolveCredentialAccessToken, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { resolveCredentialAccessToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service' import { extractZohoDeskBaseFromScope } from '@/tools/zoho_desk/host-allowlist' import { getZohoDeskApiBase } from '@/tools/zoho_desk/utils' diff --git a/apps/sim/app/api/tools/zoom/meetings/route.ts b/apps/sim/app/api/tools/zoom/meetings/route.ts index 53e78f408ca..48ba158659e 100644 --- a/apps/sim/app/api/tools/zoom/meetings/route.ts +++ b/apps/sim/app/api/tools/zoom/meetings/route.ts @@ -5,7 +5,7 @@ import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' const logger = createLogger('ZoomMeetingsAPI') 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 cbb5e0d88d2..401d0f63f9b 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 @@ -1,13 +1,18 @@ /** * @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockAuthenticate, - mockCheckPreAuth, - mockCheckRateLimit, mockAdmitUpload, mockUploadDocument, mockReadFormData, @@ -15,10 +20,8 @@ const { mockUploadWorkspaceFile, mockPlatformUploaded, mockCapture, + mockIsPayloadSizeLimitError, } = vi.hoisted(() => ({ - mockAuthenticate: vi.fn(), - mockCheckPreAuth: vi.fn(), - mockCheckRateLimit: vi.fn(), mockAdmitUpload: vi.fn(), mockUploadDocument: vi.fn(), mockReadFormData: vi.fn(), @@ -26,27 +29,12 @@ const { mockUploadWorkspaceFile: vi.fn(), mockPlatformUploaded: vi.fn(), mockCapture: vi.fn(), + mockIsPayloadSizeLimitError: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mockAuthenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -vi.mock('@/lib/core/rate-limiter', () => ({ - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), - RateLimiter: class RateLimiter { - checkRateLimitDirect(...args: unknown[]) { - return mockCheckPreAuth(...args) - } - - checkRateLimitDirectOrThrow(...args: unknown[]) { - return mockCheckRateLimit(...args) - } - }, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null) })) +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) vi.mock('@/lib/knowledge/application/documents', () => ({ listKnowledgeDocuments: { @@ -64,7 +52,8 @@ vi.mock('@/lib/knowledge/application/documents', () => ({ })) vi.mock('@/lib/core/utils/stream-limits', () => ({ - isPayloadSizeLimitError: () => false, + MAX_MULTIPART_OVERHEAD_BYTES: 1024 * 1024, + isPayloadSizeLimitError: mockIsPayloadSizeLimitError, readFormDataWithLimit: mockReadFormData, readFileToBufferWithLimit: mockReadFile, })) @@ -79,16 +68,13 @@ vi.mock('@/lib/core/telemetry', () => ({ vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCapture })) +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' const WORKSPACE_ID = 'workspace-1' -const RATE_LIMIT_OK = { - allowed: true, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 0, -} const PRINCIPAL = { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' } as const function buildRequest() { @@ -101,9 +87,11 @@ function buildRequest() { describe('POST /api/v2/knowledge/[id]/documents', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckPreAuth.mockResolvedValue(RATE_LIMIT_OK) - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockAuthenticate.mockResolvedValue({ + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + v2RouteMocks.gate.mockResolvedValue(null) + mockIsPayloadSizeLimitError.mockReturnValue(false) + v2RouteMocks.authenticate.mockResolvedValue({ principal: PRINCIPAL, rolloutUserId: 'user-1', rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], @@ -184,6 +172,16 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { expect.objectContaining({ knowledge_base_id: 'kb-1' }), expect.any(Object) ) + expect(mockReadFormData).toHaveBeenCalledWith(request, { + maxBytes: MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE + 1024 * 1024, + label: 'knowledge document upload body', + }) + expect(mockReadFile).toHaveBeenCalledWith(expect.any(File), { + maxBytes: MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE, + label: 'knowledge document file', + }) + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(response.headers.get('x-ratelimit-limit')).toBe('100') }) it('maps usage admission to the v2 error before multipart buffering', async () => { @@ -201,7 +199,7 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { }) it('does not create human analytics for a workspace key', async () => { - mockAuthenticate.mockResolvedValue({ + v2RouteMocks.authenticate.mockResolvedValue({ principal: { kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'key-2' }, rolloutUserId: 'billing-owner', rateLimitSubjectIds: ['api-key:key-2', `workspace:${WORKSPACE_ID}`], @@ -215,4 +213,109 @@ describe('POST /api/v2/knowledge/[id]/documents', () => { expect(mockPlatformUploaded).toHaveBeenCalledOnce() expect(mockCapture).not.toHaveBeenCalled() }) + + it('preserves the malformed multipart envelope without transferring storage', async () => { + mockReadFormData.mockRejectedValueOnce(new Error('multipart boundary missing')) + + 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: 'Request body must be valid multipart form data' }, + }) + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + expect(mockUploadDocument).not.toHaveBeenCalled() + expect(mockPlatformUploaded).not.toHaveBeenCalled() + }) + + it('preserves bounded multipart rejection and stops before storage transfer', async () => { + const error = new Error('knowledge document upload body exceeds maximum size') + mockReadFormData.mockRejectedValueOnce(error) + mockIsPayloadSizeLimitError.mockImplementation((candidate: unknown) => candidate === error) + + const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ + error: { code: 'PAYLOAD_TOO_LARGE', message: error.message }, + }) + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + expect(mockUploadDocument).not.toHaveBeenCalled() + }) + + it('requires a file form field before storage transfer', async () => { + mockReadFormData.mockResolvedValueOnce(new FormData()) + + 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: 'file form field is required' }, + }) + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + }) + + it('preserves the exact file-size rejection before reading file bytes', async () => { + const formData = new FormData() + const file = new File(['x'], 'large.txt', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE + 1 }) + formData.set('file', file) + mockReadFormData.mockResolvedValueOnce(formData) + + const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ + error: { code: 'PAYLOAD_TOO_LARGE', message: 'File size exceeds 100MB limit (100.00MB)' }, + }) + expect(mockReadFile).not.toHaveBeenCalled() + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + }) + + it('preserves unsupported file-type validation before reading file bytes', async () => { + const formData = new FormData() + formData.set('file', new File(['x'], 'malware.exe', { type: 'application/octet-stream' })) + mockReadFormData.mockResolvedValueOnce(formData) + const expectedMessage = validateFileType('malware.exe', 'application/octet-stream')?.message + if (!expectedMessage) throw new Error('Expected unsupported file type validation to fail') + + const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + + expect(response.status).toBe(415) + expect(await response.json()).toEqual({ + error: { code: 'UNSUPPORTED_MEDIA_TYPE', message: expectedMessage }, + }) + expect(mockReadFile).not.toHaveBeenCalled() + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + }) + + it('does not register or emit effects when storage transfer fails', async () => { + mockUploadWorkspaceFile.mockRejectedValueOnce(new Error('storage unavailable')) + + const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) + expect(mockUploadDocument).not.toHaveBeenCalled() + expect(mockPlatformUploaded).not.toHaveBeenCalled() + expect(mockCapture).not.toHaveBeenCalled() + }) + + it('preserves application authorization errors after storage transfer', async () => { + mockUploadDocument.mockRejectedValueOnce( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) + + const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' }, + }) + expect(mockUploadWorkspaceFile).toHaveBeenCalledOnce() + expect(mockPlatformUploaded).not.toHaveBeenCalled() + expect(mockCapture).not.toHaveBeenCalled() + }) }) 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 539ffd677a1..a8b04e82093 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -1,47 +1,41 @@ -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' import { type V2KnowledgeDocumentSummary, v2ListKnowledgeDocumentsContract, v2UploadKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' import { + defineV2BodyLifecycleRoute, defineV2JsonRoute, v2ApiKeyAuth, - v2OrchestrationErrorPolicy, v2RateLimits, } from '@/lib/api/server/routes' -import type { JsonRouteContext } from '@/lib/api/server/routes/types' -import { admitV2Request, V2RouteInfrastructureError } from '@/lib/api/server/routes/v2-json-route' import { OrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' import { isPayloadSizeLimitError, + MAX_MULTIPART_OVERHEAD_BYTES, readFileToBufferWithLimit, readFormDataWithLimit, } from '@/lib/core/utils/stream-limits' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' -import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' import { admitKnowledgeDocumentUpload, listKnowledgeDocuments, uploadKnowledgeDocument, } from '@/lib/knowledge/application/documents' import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { KnowledgeDocumentUnsupportedMediaTypeError } from '@/lib/knowledge/application/upload-sessions' import { captureServerEvent } from '@/lib/posthog/server' import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' import { validateFileType } from '@/lib/uploads/utils/validation' import { serializeDate } from '@/app/api/v1/knowledge/utils' -import { decodeCursor, encodeCursor, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' +import { decodeCursor, encodeCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 const MAX_FILE_SIZE = MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE -const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 function toV2DocumentSummary(document: { id: string @@ -107,146 +101,98 @@ export const GET = defineV2JsonRoute({ }) /** POST /api/v2/knowledge/[id]/documents — Upload a document to a knowledge base. */ -export const POST = withRouteHandler<JsonRouteContext | undefined>( - async (request: NextRequest, context) => { - if (request.method !== v2UploadKnowledgeDocumentContract.method) { - throw new Error( - `Route received ${request.method} for ${v2UploadKnowledgeDocumentContract.method} contract ${v2UploadKnowledgeDocumentContract.path}` - ) - } - - const routeAdmission = await admitV2Request( - request, - knowledgeOperations.uploadDocument, - v2ApiKeyAuth, - v2RateLimits.publicApi - ) - if (!routeAdmission.success) return routeAdmission.response - - const parsed = await parseRequest(v2UploadKnowledgeDocumentContract, request, context ?? {}, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { principal } = routeAdmission.auth - const { id: knowledgeBaseId } = parsed.data.params - const { workspaceId } = parsed.data.query - +export const POST = defineV2BodyLifecycleRoute({ + contract: v2UploadKnowledgeDocumentContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.uploadDocument, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.documentUpload, + admission: { + mapInput: ({ params, query }) => ({ + knowledgeBaseId: params.id, + assertedWorkspaceId: query.workspaceId, + }), + useCase: admitKnowledgeDocumentUpload, + }, + async readBody({ request }) { + let formData: FormData try { - const uploadAdmission = await admitKnowledgeDocumentUpload.execute({ - principal, - input: { knowledgeBaseId, assertedWorkspaceId: workspaceId }, - request, + formData = await readFormDataWithLimit(request, { + maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'knowledge document upload body', }) + } catch (error) { + if (isPayloadSizeLimitError(error)) throw error + throw new OrchestrationError('validation', 'Request body must be valid multipart form data') + } - let formData: FormData - try { - formData = await readFormDataWithLimit(request, { - maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, - label: 'knowledge document upload body', - }) - } catch (error) { - if (isPayloadSizeLimitError(error)) { - return v2Error('PAYLOAD_TOO_LARGE', error.message) - } - return v2Error('BAD_REQUEST', 'Request body must be valid multipart form data') - } - - const rawFile = formData.get('file') - const file = rawFile instanceof File ? rawFile : null - if (!file) return v2Error('BAD_REQUEST', 'file form field is required') - - if (file.size > MAX_FILE_SIZE) { - return v2Error( - 'PAYLOAD_TOO_LARGE', - `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` - ) - } - - const fileTypeError = validateFileType(file.name, file.type || '') - if (fileTypeError) { - return v2Error('UNSUPPORTED_MEDIA_TYPE', fileTypeError.message) - } - - const buffer = await readFileToBufferWithLimit(file, { - maxBytes: MAX_FILE_SIZE, - label: 'knowledge document file', - }) - const contentType = file.type || 'application/octet-stream' - const uploadedFile = await uploadWorkspaceFile( - uploadAdmission.workspaceId, - uploadAdmission.storageActorUserId, - buffer, - file.name, - contentType + const rawFile = formData.get('file') + if (!(rawFile instanceof File)) { + throw new OrchestrationError('validation', 'file form field is required') + } + if (rawFile.size > MAX_FILE_SIZE) { + throw new OrchestrationError( + 'payload_too_large', + `File size exceeds 100MB limit (${(rawFile.size / (1024 * 1024)).toFixed(2)}MB)` ) - - const result = await uploadKnowledgeDocument.execute({ - principal, - input: { - knowledgeBaseId, - assertedWorkspaceId: workspaceId, - document: { - filename: file.name, - fileUrl: uploadedFile.url, - fileSize: file.size, - mimeType: contentType, - }, - startProcessing: true, - usageAdmission: 'pre_admitted', - source: 'api', + } + const contentType = rawFile.type || 'application/octet-stream' + const fileTypeError = validateFileType(rawFile.name, rawFile.type || '') + if (fileTypeError) { + throw new KnowledgeDocumentUnsupportedMediaTypeError(fileTypeError.message) + } + const buffer = await readFileToBufferWithLimit(rawFile, { + maxBytes: MAX_FILE_SIZE, + label: 'knowledge document file', + }) + return { file: rawFile, buffer, contentType } + }, + transfer: ({ admission, body }) => + uploadWorkspaceFile( + admission.workspaceId, + admission.storageActorUserId, + body.buffer, + body.file.name, + body.contentType + ), + mapInput: ({ parsed, body, transfer }) => ({ + knowledgeBaseId: parsed.params.id, + assertedWorkspaceId: parsed.query.workspaceId, + document: { + filename: body.file.name, + fileUrl: transfer.url, + fileSize: body.file.size, + mimeType: body.contentType, + }, + startProcessing: true, + usageAdmission: 'pre_admitted' as const, + source: 'api' as const, + }), + useCase: uploadKnowledgeDocument, + present: (result) => ({ data: { document: toV2DocumentSummary(result.document) } }), + onSuccess: ({ principal, admission, result }) => { + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId: result.document.knowledgeBaseId, + documentsCount: 1, + uploadType: 'single', + mimeType: result.document.mimeType, + fileSize: result.document.fileSize, + }) + if (principal.kind === 'personal_api_key') { + captureServerEvent( + principal.userId, + 'knowledge_base_document_uploaded', + { + knowledge_base_id: result.document.knowledgeBaseId, + workspace_id: admission.workspaceId, + document_count: 1, + upload_type: 'single', }, - request, - }) - - PlatformEvents.knowledgeBaseDocumentsUploaded({ - knowledgeBaseId, - documentsCount: 1, - uploadType: 'single', - mimeType: contentType, - fileSize: file.size, - }) - if (principal.kind === 'personal_api_key') { - captureServerEvent( - principal.userId, - 'knowledge_base_document_uploaded', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: workspaceId, - document_count: 1, - upload_type: 'single', - }, - { - groups: { workspace: workspaceId }, - setOnce: { first_document_uploaded_at: new Date().toISOString() }, - } - ) - } - - const document = toV2DocumentSummary(result.document) - const body = v2UploadKnowledgeDocumentContract.response.schema.parse({ - data: { document }, - }) - return NextResponse.json(body, { - status: 201, - headers: { 'Cache-Control': 'private, no-store' }, - }) - } catch (error) { - if (error instanceof KnowledgeUsageLimitExceededError) { - return v2Error('USAGE_LIMIT_EXCEEDED', error.message) - } - if (isPayloadSizeLimitError(error)) { - return v2Error('PAYLOAD_TOO_LARGE', error.message) - } - const response = v2OrchestrationErrorPolicy.render(error) - if (response) return response - throw error + { + groups: { workspace: admission.workspaceId }, + setOnce: { first_document_uploaded_at: new Date().toISOString() }, + } + ) } }, - { - unhandledErrorResponse: ({ error }) => - error instanceof V2RouteInfrastructureError - ? v2Error('SERVICE_UNAVAILABLE', 'Service temporarily unavailable') - : v2Error('INTERNAL_ERROR', 'Internal server error'), - } -) +}) 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 462518ba38f..6a307e41b96 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.test.ts @@ -1,56 +1,39 @@ /** * @vitest-environment node */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAuthenticate, mockCheckPreAuth, mockCheckRateLimit, mockSearch } = vi.hoisted(() => ({ - mockAuthenticate: vi.fn(), - mockCheckPreAuth: vi.fn(), - mockCheckRateLimit: vi.fn(), +const { mockSearch } = vi.hoisted(() => ({ mockSearch: vi.fn(), })) -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mockAuthenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -vi.mock('@/lib/core/rate-limiter', () => ({ - getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), - RateLimiter: class RateLimiter { - checkRateLimitDirect(...args: unknown[]) { - return mockCheckPreAuth(...args) - } - - checkRateLimitDirectOrThrow(...args: unknown[]) { - return mockCheckRateLimit(...args) - } - }, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null) })) +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) vi.mock('@/lib/knowledge/application/search', () => ({ searchKnowledge: { operation: { id: 'knowledge.search' }, execute: mockSearch }, })) +import { DEFAULT_MAX_JSON_BODY_BYTES } from '@/lib/api/server/validation' import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' import { POST } from '@/app/api/v2/knowledge/search/route' const WORKSPACE_ID = 'workspace-1' const PRINCIPAL = { kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'key-1' } as const -const RATE_LIMIT_OK = { - allowed: true, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 0, -} - -function buildRequest(body: string) { +function buildRequest(body: string, headers: Record<string, string> = {}) { return new NextRequest('http://localhost/api/v2/knowledge/search', { method: 'POST', - headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + headers: { 'content-type': 'application/json', 'x-api-key': 'secret', ...headers }, body, }) } @@ -58,9 +41,10 @@ function buildRequest(body: string) { describe('POST /api/v2/knowledge/search', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckPreAuth.mockResolvedValue(RATE_LIMIT_OK) - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockAuthenticate.mockResolvedValue({ + 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: 'billing-owner', rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`], @@ -113,13 +97,15 @@ describe('POST /api/v2/knowledge/search', () => { expect(await response.json()).toEqual({ data: expect.objectContaining({ knowledgeBaseIds: ['kb-1'], totalResults: 1 }), }) + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(response.headers.get('x-ratelimit-limit')).toBe('100') }) it('authenticates before rejecting malformed JSON', async () => { const response = await POST(buildRequest('{')) expect(response.status).toBe(400) - expect(mockAuthenticate).toHaveBeenCalledOnce() + expect(v2RouteMocks.authenticate).toHaveBeenCalledOnce() expect(mockSearch).not.toHaveBeenCalled() }) @@ -142,4 +128,37 @@ describe('POST /api/v2/knowledge/search', () => { error: { code: 'USAGE_LIMIT_EXCEEDED', message: 'Upgrade required' }, }) }) + + it('preserves the bounded JSON rejection before application execution', async () => { + const response = await POST( + buildRequest('{}', { 'content-length': String(DEFAULT_MAX_JSON_BODY_BYTES + 1) }) + ) + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ + error: `Request body exceeds the maximum allowed size of ${DEFAULT_MAX_JSON_BODY_BYTES} bytes`, + }) + expect(mockSearch).not.toHaveBeenCalled() + expect(response.headers.get('x-ratelimit-limit')).toBe('100') + }) + + it('does not expose application infrastructure failures', async () => { + mockSearch.mockRejectedValueOnce(new Error('database host is private-db')) + + const response = await POST( + buildRequest( + JSON.stringify({ + workspaceId: WORKSPACE_ID, + knowledgeBaseIds: ['kb-1'], + query: 'hello', + topK: 10, + }) + ) + ) + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) + }) }) diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index 3aa8d98f158..07ba633ed00 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -1,74 +1,32 @@ -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' import { v2SearchKnowledgeContract } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' -import { v2ApiKeyAuth, v2OrchestrationErrorPolicy, v2RateLimits } from '@/lib/api/server/routes' -import type { JsonRouteContext } from '@/lib/api/server/routes/types' -import { admitV2Request, V2RouteInfrastructureError } from '@/lib/api/server/routes/v2-json-route' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' +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 { searchKnowledge } from '@/lib/knowledge/application/search' -import { v2Error, v2ValidationError } from '@/app/api/v2/lib/response' +import { v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** POST /api/v2/knowledge/search — Vector / tag search across knowledge bases. */ -export const POST = withRouteHandler<JsonRouteContext | undefined>( - async (request: NextRequest, context) => { - if (request.method !== v2SearchKnowledgeContract.method) { - throw new Error( - `Route received ${request.method} for ${v2SearchKnowledgeContract.method} contract ${v2SearchKnowledgeContract.path}` - ) - } - - const admission = await admitV2Request( - request, - knowledgeOperations.search, - v2ApiKeyAuth, - v2RateLimits.publicApi - ) - if (!admission.success) return admission.response - - const parsed = await parseRequest(v2SearchKnowledgeContract, request, context ?? {}, { - validationErrorResponse: v2ValidationError, - invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), - }) - if (!parsed.success) return parsed.response - - const { body } = parsed.data - try { - const result = await searchKnowledge.execute({ - principal: admission.auth.principal, - input: { - workspaceId: body.workspaceId, - knowledgeBaseIds: Array.isArray(body.knowledgeBaseIds) - ? body.knowledgeBaseIds - : [body.knowledgeBaseIds], - query: body.query, - topK: body.topK, - tagFilters: body.tagFilters, - }, - request, - }) - const responseBody = v2SearchKnowledgeContract.response.schema.parse({ data: result }) - return NextResponse.json(responseBody, { - headers: { 'Cache-Control': 'private, no-store' }, - }) - } catch (error) { - if (error instanceof KnowledgeUsageLimitExceededError) { - return v2Error('USAGE_LIMIT_EXCEEDED', error.message) - } - const response = v2OrchestrationErrorPolicy.render(error) - if (response) return response - throw error - } +export const POST = defineV2JsonRoute({ + contract: v2SearchKnowledgeContract, + auth: v2ApiKeyAuth, + operation: knowledgeOperations.search, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2KnowledgeErrorPolicies.usage, + parseOptions: { + invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), }, - { - unhandledErrorResponse: ({ error }) => - error instanceof V2RouteInfrastructureError - ? v2Error('SERVICE_UNAVAILABLE', 'Service temporarily unavailable') - : v2Error('INTERNAL_ERROR', 'Internal server error'), - } -) + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + knowledgeBaseIds: Array.isArray(body.knowledgeBaseIds) + ? body.knowledgeBaseIds + : [body.knowledgeBaseIds], + query: body.query, + topK: body.topK, + tagFilters: body.tagFilters, + }), + useCase: searchKnowledge, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts index ae51cee4d19..b1d182cdbd1 100644 --- a/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts +++ b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts @@ -24,7 +24,7 @@ vi.mock('@/lib/core/admission/gate', () => ({ admissionRejectedResponse: () => new Response(null, { status: 503 }), })) -vi.mock('@/app/api/auth/oauth/utils', () => ({ +vi.mock('@/lib/oauth/credential-service', () => ({ getSlackBotCredential: mockGetSlackBotCredential, })) diff --git a/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts index 2267179417d..9925aca7f50 100644 --- a/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts +++ b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts @@ -3,10 +3,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getSlackBotCredential } from '@/lib/oauth/credential-service' import { findWebhooksByRoutingKey, parseWebhookBody } from '@/lib/webhooks/processor' import { handleSlackChallenge, verifySlackRequestSignature } from '@/lib/webhooks/providers/slack' import { dispatchSlackWebhooks } from '@/lib/webhooks/slack-dispatch' -import { getSlackBotCredential } from '@/app/api/auth/oauth/utils' const logger = createLogger('SlackCustomBotWebhookAPI') diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index 2bdf1e118c9..d23554278a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -402,19 +402,27 @@ interface DocumentsTabProps { function DocumentsTab({ knowledgeBaseId, connectorId }: DocumentsTabProps) { const [filter, setFilter] = useState<'active' | 'excluded'>('active') - const { data, isLoading } = useConnectorDocuments(knowledgeBaseId, connectorId, { - includeExcluded: true, - }) + const { data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } = useConnectorDocuments( + knowledgeBaseId, + connectorId, + { + includeExcluded: true, + } + ) const { mutate: excludeDoc, isPending: isExcluding } = useExcludeConnectorDocument() const { mutate: restoreDoc, isPending: isRestoring } = useRestoreConnectorDocument() const documents = useMemo(() => { - if (!data?.documents) return [] - return data.documents.filter((d) => (filter === 'excluded' ? d.userExcluded : !d.userExcluded)) - }, [data?.documents, filter]) + const loadedDocuments = data?.pages.flatMap((page) => page.documents) ?? [] + return loadedDocuments.filter((document) => + filter === 'excluded' ? document.userExcluded : !document.userExcluded + ) + }, [data?.pages, filter]) - const counts = data?.counts ?? { active: 0, excluded: 0 } + const counts = data?.pages[0]?.counts ?? { active: 0, excluded: 0 } + const visibleDocumentCount = filter === 'excluded' ? counts.excluded : counts.active + const hasMoreVisibleDocuments = Boolean(hasNextPage && documents.length < visibleDocumentCount) if (isLoading) { return ( @@ -435,7 +443,7 @@ function DocumentsTab({ knowledgeBaseId, connectorId }: DocumentsTabProps) { </ButtonGroup> <div className='max-h-[320px] min-h-0 overflow-y-auto [scrollbar-gutter:stable]'> - {documents.length === 0 ? ( + {visibleDocumentCount === 0 ? ( <p className='rounded-lg bg-[var(--surface-3)] px-3 py-8 text-center text-[var(--text-muted)] text-small'> {filter === 'excluded' ? 'No excluded documents' : 'No documents yet'} </p> @@ -488,6 +496,17 @@ function DocumentsTab({ knowledgeBaseId, connectorId }: DocumentsTabProps) { </Button> </div> ))} + {hasMoreVisibleDocuments && ( + <Button + variant='ghost-secondary' + size='sm' + className='w-full' + disabled={isFetchingNextPage} + onClick={() => fetchNextPage()} + > + {isFetchingNextPage ? 'Loading…' : 'Load more documents'} + </Button> + )} </div> )} </div> diff --git a/apps/sim/background/webhook-execution.test.ts b/apps/sim/background/webhook-execution.test.ts index 17953ae6feb..29baecab743 100644 --- a/apps/sim/background/webhook-execution.test.ts +++ b/apps/sim/background/webhook-execution.test.ts @@ -115,7 +115,7 @@ vi.mock('@/lib/webhooks/attachment-processor', () => ({ WebhookAttachmentProcessor: class {}, })) -vi.mock('@/app/api/auth/oauth/utils', () => ({ +vi.mock('@/lib/oauth/credential-service', () => ({ resolveOAuthAccountId: vi.fn(), })) diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index bb3ba290468..dc7782615cf 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -31,6 +31,7 @@ import { import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' +import { resolveOAuthAccountId } from '@/lib/oauth/credential-service' import { type WebhookAttachment, WebhookAttachmentProcessor, @@ -49,7 +50,6 @@ import { loadDeployedWorkflowState, loadWorkflowDeploymentVersionState, } from '@/lib/workflows/persistence/utils' -import { resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' import { WEBHOOK_EXECUTION_CONCURRENCY_LIMIT } from '@/background/concurrency-limits' import { getBlock } from '@/blocks' import { ExecutionSnapshot } from '@/executor/execution/snapshot' diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts index 1483c5d9fe0..bd04d3f07b8 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts @@ -8,7 +8,7 @@ const { mockResolveAutoModel } = vi.hoisted(() => ({ mockResolveAutoModel: vi.fn(), })) -vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) +vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock) vi.mock('@/lib/credentials/access', () => ({ getCredentialActorContext: vi.fn().mockResolvedValue({ diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index db605abaa1a..1e256a9024a 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -8,7 +8,7 @@ const { mockResolveAutoModel } = vi.hoisted(() => ({ mockResolveAutoModel: vi.fn(), })) -vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) +vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock) vi.mock('@/lib/credentials/access', () => ({ getCredentialActorContext: vi.fn().mockResolvedValue({ diff --git a/apps/sim/executor/utils/vertex-credential.test.ts b/apps/sim/executor/utils/vertex-credential.test.ts index 9776fac39d2..242da2ad413 100644 --- a/apps/sim/executor/utils/vertex-credential.test.ts +++ b/apps/sim/executor/utils/vertex-credential.test.ts @@ -13,7 +13,7 @@ const { mockGetCredentialActorContext, mockGetServiceAccountToken, mockRefreshTo vi.mock('@/lib/credentials/access', () => ({ getCredentialActorContext: mockGetCredentialActorContext, })) -vi.mock('@/app/api/auth/oauth/utils', () => ({ +vi.mock('@/lib/oauth/credential-service', () => ({ getServiceAccountToken: mockGetServiceAccountToken, refreshTokenIfNeeded: mockRefreshTokenIfNeeded, })) diff --git a/apps/sim/executor/utils/vertex-credential.ts b/apps/sim/executor/utils/vertex-credential.ts index 9da58ffe40e..33f37c33ba6 100644 --- a/apps/sim/executor/utils/vertex-credential.ts +++ b/apps/sim/executor/utils/vertex-credential.ts @@ -3,7 +3,7 @@ import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { getCredentialActorContext } from '@/lib/credentials/access' -import { getServiceAccountToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getServiceAccountToken, refreshTokenIfNeeded } from '@/lib/oauth/credential-service' const logger = createLogger('VertexCredential') diff --git a/apps/sim/hooks/queries/kb/connectors.test.ts b/apps/sim/hooks/queries/kb/connectors.test.ts new file mode 100644 index 00000000000..14d7ead56ea --- /dev/null +++ b/apps/sim/hooks/queries/kb/connectors.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + requestJson: vi.fn(), + useInfiniteQuery: vi.fn(), +})) + +vi.mock('@tanstack/react-query', () => ({ + keepPreviousData: Symbol('keepPreviousData'), + useInfiniteQuery: mocks.useInfiniteQuery, + useMutation: vi.fn(), + useQuery: vi.fn(), + useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn() })), +})) + +vi.mock('@/lib/api/client/request', () => ({ + requestJson: mocks.requestJson, +})) + +import { listKnowledgeConnectorDocumentsContract } from '@/lib/api/contracts/knowledge' +import { MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE } from '@/lib/knowledge/constants' +import { useConnectorDocuments } from '@/hooks/queries/kb/connectors' + +interface ConnectorDocumentsPage { + documents: Array<{ id: string }> + counts: { active: number; excluded: number } +} + +interface ConnectorDocumentsQueryOptions { + initialPageParam: number + queryFn: (context: { signal: AbortSignal; pageParam: number }) => Promise<unknown> + getNextPageParam: ( + lastPage: ConnectorDocumentsPage, + pages: ConnectorDocumentsPage[] + ) => number | undefined +} + +describe('useConnectorDocuments', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('requests bounded pages and advances until the authoritative total is loaded', async () => { + const firstPage = { + documents: [{ id: 'document-1' }, { id: 'document-2' }], + counts: { active: 2, excluded: 1 }, + } + const finalPage = { + documents: [{ id: 'document-3' }], + counts: firstPage.counts, + } + mocks.requestJson.mockResolvedValue({ data: firstPage }) + + useConnectorDocuments('knowledge-1', 'connector-1', { includeExcluded: true }) + + const options = mocks.useInfiniteQuery.mock.calls[0]?.[0] as ConnectorDocumentsQueryOptions + const signal = new AbortController().signal + await options.queryFn({ signal, pageParam: 200 }) + + expect(mocks.requestJson).toHaveBeenCalledWith(listKnowledgeConnectorDocumentsContract, { + params: { id: 'knowledge-1', connectorId: 'connector-1' }, + query: { + includeExcluded: true, + limit: MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE, + offset: 200, + }, + signal, + }) + expect(options.initialPageParam).toBe(0) + expect(options.getNextPageParam(firstPage, [firstPage])).toBe(2) + expect(options.getNextPageParam(finalPage, [firstPage, finalPage])).toBeUndefined() + }) + + it('does not page toward excluded documents when they were not requested', () => { + const activePage = { + documents: [{ id: 'document-1' }, { id: 'document-2' }], + counts: { active: 2, excluded: 10 }, + } + + useConnectorDocuments('knowledge-1', 'connector-1') + + const options = mocks.useInfiniteQuery.mock.calls[0]?.[0] as ConnectorDocumentsQueryOptions + expect(options.getNextPageParam(activePage, [activePage])).toBeUndefined() + }) +}) diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index fb31f7a90ef..fe4d6d21004 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -1,5 +1,11 @@ import { createLogger } from '@sim/logger' -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + keepPreviousData, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { type ConnectorData, @@ -15,6 +21,7 @@ import { triggerKnowledgeConnectorSyncContract, updateKnowledgeConnectorContract, } from '@/lib/api/contracts/knowledge' +import { MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE } from '@/lib/knowledge/constants' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' const logger = createLogger('KnowledgeConnectorQueries') @@ -245,11 +252,16 @@ async function fetchConnectorDocuments( knowledgeBaseId: string, connectorId: string, includeExcluded: boolean, + offset: number, signal?: AbortSignal ): Promise<ConnectorDocumentsData> { const result = await requestJson(listKnowledgeConnectorDocumentsContract, { params: { id: knowledgeBaseId, connectorId }, - query: { includeExcluded }, + query: { + includeExcluded, + limit: MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE, + offset, + }, signal, }) @@ -261,18 +273,24 @@ export function useConnectorDocuments( connectorId?: string, options?: { includeExcluded?: boolean } ) { - return useQuery({ - queryKey: [ - ...connectorDocumentKeys.list(knowledgeBaseId, connectorId), - options?.includeExcluded ?? false, - ], - queryFn: ({ signal }) => + const includeExcluded = options?.includeExcluded ?? false + return useInfiniteQuery({ + queryKey: [...connectorDocumentKeys.list(knowledgeBaseId, connectorId), includeExcluded], + queryFn: ({ signal, pageParam }) => fetchConnectorDocuments( knowledgeBaseId as string, connectorId as string, - options?.includeExcluded ?? false, + includeExcluded, + pageParam, signal ), + initialPageParam: 0, + getNextPageParam: (lastPage, pages) => { + const loadedCount = pages.reduce((total, page) => total + page.documents.length, 0) + const totalCount = lastPage.counts.active + (includeExcluded ? lastPage.counts.excluded : 0) + if (lastPage.documents.length === 0 || loadedCount >= totalCount) return undefined + return loadedCount + }, enabled: Boolean(knowledgeBaseId && connectorId), staleTime: CONNECTOR_DOCUMENT_LIST_STALE_TIME, placeholderData: keepPreviousData, diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index 118b81ba5e5..0dd4606b44f 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -39,6 +39,7 @@ import { saveDocumentTagDefinitionsContract, type TagDefinitionData, type TagUsageData, + type UpdateKnowledgeDocumentResponseData, updateKnowledgeBaseContract, updateKnowledgeChunkContract, updateKnowledgeDocumentContract, @@ -490,7 +491,7 @@ async function updateDocument({ knowledgeBaseId, documentId, updates, -}: UpdateDocumentParams): Promise<DocumentData> { +}: UpdateDocumentParams): Promise<UpdateKnowledgeDocumentResponseData> { const result = await requestJson(updateKnowledgeDocumentContract, { params: { id: knowledgeBaseId, documentId }, body: updates, @@ -1000,6 +1001,7 @@ async function deleteDocumentTagDefinitions({ }: DeleteDocumentTagDefinitionsParams): Promise<void> { await requestJson(deleteDocumentTagDefinitionsContract, { params: { id: knowledgeBaseId, documentId }, + query: {}, }) } diff --git a/apps/sim/lib/api/contracts/knowledge/connectors.ts b/apps/sim/lib/api/contracts/knowledge/connectors.ts index cef3d8718d1..88b90907d52 100644 --- a/apps/sim/lib/api/contracts/knowledge/connectors.ts +++ b/apps/sim/lib/api/contracts/knowledge/connectors.ts @@ -6,6 +6,11 @@ import { } from '@/lib/api/contracts/knowledge/shared' import { booleanQueryFlagSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' +import { + DEFAULT_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE, + MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_MUTATION_ITEMS, + MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE, +} from '@/lib/knowledge/constants' export const createConnectorBodySchema = z.object({ connectorType: z.string().min(1), @@ -27,12 +32,23 @@ export const deleteConnectorQuerySchema = z.object({ }) export const connectorDocumentsQuerySchema = z.object({ - includeExcluded: z.boolean().optional(), + includeExcluded: booleanQueryFlagSchema.optional(), + limit: z.coerce + .number() + .int() + .min(1) + .max(MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE) + .optional() + .default(DEFAULT_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE), + offset: z.coerce.number().int().min(0).optional().default(0), }) export const connectorDocumentsPatchBodySchema = z.object({ operation: z.enum(['restore', 'exclude']), - documentIds: z.array(z.string()).min(1), + documentIds: z + .array(z.string().min(1)) + .min(1) + .max(MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_MUTATION_ITEMS), }) export const connectorDataSchema = z @@ -117,6 +133,7 @@ export const createKnowledgeConnectorContract = defineRouteContract({ response: { mode: 'json', schema: successResponseSchema(connectorDataSchema), + status: 201, }, }) diff --git a/apps/sim/lib/api/contracts/knowledge/documents.ts b/apps/sim/lib/api/contracts/knowledge/documents.ts index e3f54a9d111..e2b9fd37691 100644 --- a/apps/sim/lib/api/contracts/knowledge/documents.ts +++ b/apps/sim/lib/api/contracts/knowledge/documents.ts @@ -15,7 +15,7 @@ import { import { privateSecretProvenanceBundleSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata' -import { getFieldTypeForSlot } from '@/lib/knowledge/constants' +import { getFieldTypeForSlot, MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE } from '@/lib/knowledge/constants' import { getOperatorsForFieldType, isValidFilterValue } from '@/lib/knowledge/filters/types' export const documentTagFilterSchema = z @@ -129,7 +129,13 @@ export const createDocumentBodySchema = z.object({ }) export const bulkCreateDocumentsBodySchema = z.object({ - documents: z.array(createDocumentBodySchema), + documents: z + .array(createDocumentBodySchema) + .min(1, 'At least one document is required') + .max( + MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE, + `At most ${MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE} documents may be created at once` + ), processingOptions: z .object({ recipe: z.string().optional(), @@ -338,9 +344,17 @@ export const updateKnowledgeDocumentContract = defineRouteContract({ body: updateDocumentBodySchema, response: { mode: 'json', - schema: successResponseSchema(documentDataSchema), + schema: successResponseSchema( + z.union([ + documentDataSchema, + z.object({ documentId: z.string(), status: z.string(), message: z.string() }), + ]) + ), }, }) +export type UpdateKnowledgeDocumentResponseData = z.output< + typeof updateKnowledgeDocumentContract.response.schema +>['data'] export const updateKnowledgeDocumentTagsContract = defineRouteContract({ method: 'PUT', @@ -381,6 +395,23 @@ export const upsertKnowledgeDocumentContract = defineRouteContract({ body: upsertDocumentBodySchema, response: { mode: 'json', - schema: successResponseSchema(documentDataSchema), + schema: successResponseSchema( + z.object({ + documentsCreated: z.array( + z.object({ + documentId: z.string(), + filename: z.string(), + status: z.literal('pending'), + }) + ), + isUpdate: z.boolean(), + previousDocumentId: z.string().nullable(), + processingMethod: z.literal('background'), + processingConfig: z.object({ + maxConcurrentDocuments: z.number(), + batchSize: z.number(), + }), + }) + ), }, }) diff --git a/apps/sim/lib/api/contracts/knowledge/search.ts b/apps/sim/lib/api/contracts/knowledge/search.ts index 3550311f21d..cd5653285e7 100644 --- a/apps/sim/lib/api/contracts/knowledge/search.ts +++ b/apps/sim/lib/api/contracts/knowledge/search.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' import { DEFAULT_RERANKER_MODEL, rerankerModelSchema } from '@/lib/knowledge/reranker-models' export const knowledgeSearchTagFilterSchema = z.object({ @@ -85,3 +86,63 @@ export const knowledgeSearchBodySchema = z } ) export type KnowledgeSearchBody = z.output<typeof knowledgeSearchBodySchema> + +export const internalKnowledgeSearchBodySchema = z.intersection( + knowledgeSearchBodySchema, + z.object({ + workflowId: z.string().optional(), + skipUsageBilling: z.boolean().optional(), + }) +) + +export const internalKnowledgeSearchResultSchema = z.object({ + documentId: z.string(), + documentName: z.string().nullable(), + sourceUrl: z.string().nullable(), + content: z.string(), + chunkIndex: z.number(), + metadata: z.record(z.string(), z.unknown()), + similarity: z.number(), + rerankerScore: z.number().optional(), +}) + +export const internalKnowledgeSearchContract = defineRouteContract({ + method: 'POST', + path: '/api/knowledge/search', + body: internalKnowledgeSearchBodySchema, + response: { + mode: 'json', + schema: z.object({ + success: z.literal(true), + data: z.object({ + results: z.array(internalKnowledgeSearchResultSchema), + query: z.string(), + knowledgeBaseIds: z.array(z.string()), + knowledgeBaseId: z.string(), + topK: z.number(), + totalResults: z.number(), + cost: z + .object({ + input: z.number(), + output: z.number(), + total: z.number(), + tokens: z.object({ + prompt: z.number(), + completion: z.number(), + total: z.number(), + }), + model: z.string(), + pricing: z.object({ + input: z.number(), + output: z.number(), + updatedAt: z.string().optional(), + }), + rerankerCost: z.number().optional(), + rerankerModel: z.string().optional(), + rerankerSearchUnits: z.number().optional(), + }) + .optional(), + }), + }), + }, +}) diff --git a/apps/sim/lib/api/contracts/knowledge/tags.ts b/apps/sim/lib/api/contracts/knowledge/tags.ts index c44cc9424d3..730202ae997 100644 --- a/apps/sim/lib/api/contracts/knowledge/tags.ts +++ b/apps/sim/lib/api/contracts/knowledge/tags.ts @@ -144,6 +144,7 @@ export const deleteDocumentTagDefinitionsContract = defineRouteContract({ method: 'DELETE', path: '/api/knowledge/[id]/documents/[documentId]/tag-definitions', params: knowledgeDocumentParamsSchema, + query: deleteDocumentTagDefinitionsQuerySchema, response: { mode: 'json', schema: z.object({ success: z.literal(true) }).passthrough(), diff --git a/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts b/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts index b37ad76428b..f90cf22422d 100644 --- a/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts +++ b/apps/sim/lib/api/contracts/knowledge/upload-sessions.ts @@ -1,26 +1,134 @@ -import { defineRouteContract } from '@/lib/api/contracts/types' -import { - v2CreateKnowledgeDocumentUploadBodySchema, - v2CreateKnowledgeDocumentUploadDataSchema, - v2KnowledgeDocumentUploadParamsSchema, - v2KnowledgeDocumentUploadSchema, - v2UploadKnowledgeDocumentQuerySchema, -} from '@/lib/api/contracts/v2/knowledge' -import { v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { z } from 'zod' +import { documentDataSchema } from '@/lib/api/contracts/knowledge/documents' import { - v2PartUrlsBodySchema, - v2PartUrlsDataSchema, - v2UploadTokenHeadersSchema, -} from '@/lib/api/contracts/v2/uploads' + knowledgeBaseParamsSchema, + nullableWireDateSchema, +} from '@/lib/api/contracts/knowledge/shared' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { knowledgeDocumentUploadMetadataSchema } from '@/lib/knowledge/upload-metadata' +import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' + +const knowledgeDocumentUploadStatusSchema = z.enum([ + 'uploading', + 'completing', + 'finalizing', + 'completed', + 'failed', + 'aborting', + 'aborted', + 'expired', +]) + +const knowledgeDocumentUploadTokenHeadersSchema = z.object({ + 'upload-token': z.string().min(1, 'upload-token header is required'), +}) + +const knowledgeDocumentUploadTransferSchema = z.discriminatedUnion('method', [ + z + .object({ + method: z.literal('put'), + url: z.string().url(), + headers: z.record(z.string(), z.string()), + }) + .strict(), + z + .object({ + method: z.literal('multipart'), + partSize: z.number().int().positive(), + partCount: z.number().int().positive().max(640), + }) + .strict(), +]) +export type KnowledgeDocumentUploadTransfer = z.output<typeof knowledgeDocumentUploadTransferSchema> + +const knowledgeDocumentUploadPartUrlsBodySchema = z + .object({ + partNumbers: z.array(z.number().int().min(1)).min(1).max(100), + }) + .strict() + +const knowledgeDocumentUploadPartUrlSchema = z.object({ + partNumber: z.number().int().min(1), + url: z.string().url(), + headers: z.record(z.string(), z.string()), + expiresAt: z.string().datetime(), +}) +export type KnowledgeDocumentUploadPartUrl = z.output<typeof knowledgeDocumentUploadPartUrlSchema> + +const knowledgeDocumentUploadPartUrlsDataSchema = z.object({ + parts: z.array(knowledgeDocumentUploadPartUrlSchema).max(100), +}) + +const knowledgeDocumentSummarySchema = documentDataSchema + .pick({ + id: true, + knowledgeBaseId: true, + filename: true, + fileSize: true, + mimeType: true, + processingStatus: true, + chunkCount: true, + tokenCount: true, + characterCount: true, + enabled: true, + }) + .extend({ createdAt: nullableWireDateSchema }) +export type KnowledgeDocumentUploadSummary = z.output<typeof knowledgeDocumentSummarySchema> + +const knowledgeDocumentUploadParamsSchema = knowledgeBaseParamsSchema.extend({ + uploadId: z.string().min(1, 'uploadId is required'), +}) + +const uploadKnowledgeDocumentQuerySchema = z.object({ workspaceId: workspaceIdSchema }) + +export const knowledgeDocumentUploadMetadataBodySchema = z + .object({ ...knowledgeDocumentUploadMetadataSchema.shape }) + .strict() +export type KnowledgeDocumentUploadMetadataBody = z.input< + typeof knowledgeDocumentUploadMetadataBodySchema +> + +const createKnowledgeDocumentUploadBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), + contentType: z.string().trim().min(1, 'contentType is required').max(255), + size: z.number().int().min(1).max(MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE), + ...knowledgeDocumentUploadMetadataBodySchema.shape, + }) + .strict() + +const knowledgeDocumentUploadSchema = z.object({ + id: z.string(), + knowledgeBaseId: z.string(), + status: knowledgeDocumentUploadStatusSchema, + name: z.string(), + contentType: z.string(), + size: z.number().int().positive(), + expiresAt: z.string().datetime(), + error: z.string().nullable(), + document: knowledgeDocumentSummarySchema.nullable(), +}) + +const createKnowledgeDocumentUploadDataSchema = z + .object({ + session: knowledgeDocumentUploadSchema, + uploadToken: z.string().min(1), + transfer: knowledgeDocumentUploadTransferSchema, + }) + .strict() + +const dataResponse = <T extends z.ZodType>(schema: T) => z.object({ data: schema }) export const createKnowledgeDocumentUploadContract = defineRouteContract({ method: 'POST', path: '/api/knowledge/[id]/documents/uploads', - params: v2KnowledgeDocumentUploadParamsSchema.omit({ uploadId: true }), - body: v2CreateKnowledgeDocumentUploadBodySchema, + params: knowledgeDocumentUploadParamsSchema.omit({ uploadId: true }), + body: createKnowledgeDocumentUploadBodySchema, response: { mode: 'json', - schema: v2DataResponse(v2CreateKnowledgeDocumentUploadDataSchema), + schema: dataResponse(createKnowledgeDocumentUploadDataSchema), status: 201, }, }) @@ -28,27 +136,27 @@ export const createKnowledgeDocumentUploadContract = defineRouteContract({ export const abortKnowledgeDocumentUploadContract = defineRouteContract({ method: 'DELETE', path: '/api/knowledge/[id]/documents/uploads/[uploadId]', - params: v2KnowledgeDocumentUploadParamsSchema, - query: v2UploadKnowledgeDocumentQuerySchema, - headers: v2UploadTokenHeadersSchema, - response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentUploadSchema) }, + params: knowledgeDocumentUploadParamsSchema, + query: uploadKnowledgeDocumentQuerySchema, + headers: knowledgeDocumentUploadTokenHeadersSchema, + response: { mode: 'json', schema: dataResponse(knowledgeDocumentUploadSchema) }, }) export const createKnowledgeDocumentUploadPartUrlsContract = defineRouteContract({ method: 'POST', path: '/api/knowledge/[id]/documents/uploads/[uploadId]/parts', - params: v2KnowledgeDocumentUploadParamsSchema, - query: v2UploadKnowledgeDocumentQuerySchema, - headers: v2UploadTokenHeadersSchema, - body: v2PartUrlsBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, + params: knowledgeDocumentUploadParamsSchema, + query: uploadKnowledgeDocumentQuerySchema, + headers: knowledgeDocumentUploadTokenHeadersSchema, + body: knowledgeDocumentUploadPartUrlsBodySchema, + response: { mode: 'json', schema: dataResponse(knowledgeDocumentUploadPartUrlsDataSchema) }, }) export const completeKnowledgeDocumentUploadContract = defineRouteContract({ method: 'POST', path: '/api/knowledge/[id]/documents/uploads/[uploadId]/complete', - params: v2KnowledgeDocumentUploadParamsSchema, - query: v2UploadKnowledgeDocumentQuerySchema, - headers: v2UploadTokenHeadersSchema, - response: { mode: 'json', schema: v2DataResponse(v2KnowledgeDocumentUploadSchema) }, + params: knowledgeDocumentUploadParamsSchema, + query: uploadKnowledgeDocumentQuerySchema, + headers: knowledgeDocumentUploadTokenHeadersSchema, + response: { mode: 'json', schema: dataResponse(knowledgeDocumentUploadSchema) }, }) diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 4ee31c3b933..d4901710217 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -34,6 +34,7 @@ import { v2UploadTokenHeadersSchema, v2UploadTransferSchema, } from '@/lib/api/contracts/v2/uploads' +import { knowledgeDocumentUploadMetadataSchema } from '@/lib/knowledge/upload-metadata' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' /** @@ -171,28 +172,8 @@ export const v2KnowledgeDocumentUploadParamsSchema = knowledgeBaseParamsSchema.e }) export type V2KnowledgeDocumentUploadParams = z.output<typeof v2KnowledgeDocumentUploadParamsSchema> -const knowledgeDocumentUploadTagSchema = z - .string() - .max(1000, 'Knowledge document tag values cannot exceed 1000 characters') - .optional() - export const v2KnowledgeDocumentUploadMetadataSchema = z - .object({ - tag1: knowledgeDocumentUploadTagSchema, - tag2: knowledgeDocumentUploadTagSchema, - tag3: knowledgeDocumentUploadTagSchema, - tag4: knowledgeDocumentUploadTagSchema, - tag5: knowledgeDocumentUploadTagSchema, - tag6: knowledgeDocumentUploadTagSchema, - tag7: knowledgeDocumentUploadTagSchema, - processingOptions: z - .object({ - recipe: z.string().max(255, 'recipe cannot exceed 255 characters').optional(), - lang: z.string().max(35, 'lang cannot exceed 35 characters').optional(), - }) - .strict() - .optional(), - }) + .object({ ...knowledgeDocumentUploadMetadataSchema.shape }) .strict() export type V2KnowledgeDocumentUploadMetadata = z.output< typeof v2KnowledgeDocumentUploadMetadataSchema diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts index 22c753ca9a2..f75fe366a79 100644 --- a/apps/sim/lib/api/server/routes/index.ts +++ b/apps/sim/lib/api/server/routes/index.ts @@ -14,6 +14,7 @@ export { internalSessionAuth, } from '@/lib/api/server/routes/internal-json-route' export { defineV2BinaryRoute } from '@/lib/api/server/routes/v2-binary-route' +export { defineV2BodyLifecycleRoute } from '@/lib/api/server/routes/v2-body-lifecycle-route' export { admitV2Request, defineV2JsonRoute, diff --git a/apps/sim/lib/api/server/routes/internal-json-route.test.ts b/apps/sim/lib/api/server/routes/internal-json-route.test.ts index c295caec6d4..9449c25acd0 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { NextRequest } from 'next/server' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts' import { @@ -32,6 +32,10 @@ const contract = defineRouteContract({ }) describe('defineInternalJsonRoute', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + it('uses the use-case result directly when it already matches the contract', async () => { const handler = defineInternalJsonRoute({ contract, @@ -82,4 +86,173 @@ describe('defineInternalJsonRoute', () => { 'Internal error responses require a 4xx or 5xx status' ) }) + + it('orders auth, rate limiting, parsing, async mapping, and application execution', async () => { + const events: string[] = [] + const orderedContract = defineRouteContract({ + method: 'POST', + path: '/api/test/internal-json-route', + body: z.object({ value: z.string() }).transform((body) => { + events.push('parse') + return body + }), + response: { mode: 'json', schema: z.object({ value: z.string() }) }, + }) + const handler = defineInternalJsonRoute({ + contract: orderedContract, + auth: { + async authenticate() { + events.push('auth') + return { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + }, + }, + operation, + rateLimit: { + kind: 'none', + reason: 'Unit test', + async enforce() { + events.push('rate') + }, + }, + errorPolicy: internalPlainOrchestrationErrorPolicy, + async mapInput({ body }) { + events.push('map:start') + await Promise.resolve() + events.push('map:end') + return body.value + }, + useCase: { + operation, + async execute({ input }) { + events.push('use-case') + return { value: input } + }, + }, + }) + + const response = await handler( + new NextRequest('http://localhost/api/test/internal-json-route', { + method: 'POST', + body: JSON.stringify({ value: 'ok' }), + }) + ) + + expect(response.status).toBe(200) + expect(events).toEqual(['auth', 'rate', 'parse', 'map:start', 'map:end', 'use-case']) + }) + + it('projects async mapping errors before application execution', async () => { + const execute = vi.fn() + const handler = defineInternalJsonRoute({ + contract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + async mapInput() { + await Promise.resolve() + throw new OrchestrationError('validation', 'Invalid mapped input') + }, + useCase: { operation, execute }, + }) + + const response = await handler(new NextRequest('http://localhost/api/test/internal-json-route')) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'Invalid mapped input' }) + expect(execute).not.toHaveBeenCalled() + }) + + it('validates the contract response before invoking the finalizer', async () => { + const finalizeResponse = vi.fn() + const handler = defineInternalJsonRoute({ + contract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 42 } + }, + }, + present: ({ value }) => ({ value: value as unknown as string }), + finalizeResponse, + }) + + const response = await handler(new NextRequest('http://localhost/api/test/internal-json-route')) + + expect(response.status).toBe(500) + expect(finalizeResponse).not.toHaveBeenCalled() + }) + + it('projects finalizer failures through the declared error policy', async () => { + const handler = defineInternalJsonRoute({ + contract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 'ok' } + }, + }, + finalizeResponse() { + throw new OrchestrationError('conflict', 'Metadata conflict') + }, + }) + + const response = await handler(new NextRequest('http://localhost/api/test/internal-json-route')) + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toEqual({ error: 'Metadata conflict' }) + }) + + it('appends finalizer metadata while preserving the success status and declared headers', async () => { + const createdContract = defineRouteContract({ + method: 'POST', + path: '/api/test/internal-json-route', + response: { + mode: 'json', + schema: z.object({ value: z.string() }), + status: 201, + }, + }) + const handler = defineInternalJsonRoute({ + contract: createdContract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 'ok' } + }, + }, + responseHeaders: () => ({ 'x-contract-header': 'preserved' }), + finalizeResponse: ({ body }) => ({ + bodyFields: { __privateMetadata: { value: body.value } }, + headers: { 'x-private-metadata': 'v1' }, + }), + }) + + const response = await handler( + new NextRequest('http://localhost/api/test/internal-json-route', { method: 'POST' }) + ) + + expect(response.status).toBe(201) + expect(response.headers.get('x-contract-header')).toBe('preserved') + expect(response.headers.get('x-private-metadata')).toBe('v1') + await expect(response.json()).resolves.toEqual({ + value: 'ok', + __privateMetadata: { value: 'ok' }, + }) + }) }) 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 6563d5e7cdb..0330013ce95 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -179,6 +179,16 @@ export interface InternalAuthPolicy<P extends Principal> { ): Promise<P> } +export interface InternalJsonResponseFinalization { + bodyFields?: Readonly<Record<string, unknown>> + headers?: HeadersInit +} + +type InternalJsonParseOptions = Pick< + ParseRequestOptions, + 'maxBodyBytes' | 'validationErrorResponse' +> + type InternalJsonPresenter<C extends JsonApiRouteContract, R> = [R] extends [ ContractJsonResponse<C>, ] @@ -198,12 +208,12 @@ type InternalJsonRouteOptions< > = { contract: C operation: O - mapInput(input: ParsedRequest<C>): I + mapInput(input: ParsedRequest<C>, context: { principal: P; request: NextRequest }): I | Promise<I> useCase: OperationUseCase<NoInfer<O>, I, R> auth: InternalAuthPolicy<P> rateLimit: InternalRateLimitPolicy errorPolicy: InternalErrorPolicy - parseOptions?: Omit<ParseRequestOptions, 'validationErrorResponse'> + parseOptions?: InternalJsonParseOptions beforeParse?(args: { request: NextRequest principal: P @@ -211,6 +221,13 @@ type InternalJsonRouteOptions< }): void | Promise<void> onSuccess?(args: { principal: P; input: NoInfer<I>; result: NoInfer<R> }): void | Promise<void> responseHeaders?(args: { principal: P; input: NoInfer<I>; result: NoInfer<R> }): HeadersInit + finalizeResponse?(args: { + request: NextRequest + principal: P + input: NoInfer<I> + result: NoInfer<R> + body: ContractJsonResponse<C> + }): InternalJsonResponseFinalization | Promise<InternalJsonResponseFinalization> } & InternalJsonPresenter<C, R> function createJsonErrorResponse(descriptor: JsonErrorResponseDescriptor): NextResponse { @@ -220,6 +237,34 @@ function createJsonErrorResponse(descriptor: JsonErrorResponseDescriptor): NextR }) } +function appendFinalizedBodyFields( + body: unknown, + bodyFields?: Readonly<Record<string, unknown>> +): unknown { + if (!bodyFields || Object.keys(bodyFields).length === 0) return body + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new Error('Internal JSON response metadata requires an object response body') + } + for (const key of Object.keys(bodyFields)) { + if (Object.hasOwn(body, key)) { + throw new Error(`Internal JSON response metadata cannot replace contract field "${key}"`) + } + } + return { ...body, ...bodyFields } +} + +function appendFinalizedHeaders(base: HeadersInit | undefined, additions?: HeadersInit): Headers { + const headers = new Headers(base) + if (!additions) return headers + new Headers(additions).forEach((value, key) => { + if (headers.has(key)) { + throw new Error(`Internal JSON response finalizer cannot replace header "${key}"`) + } + headers.set(key, value) + }) + return headers +} + export function defineInternalJsonRoute< C extends JsonApiRouteContract, O extends ApplicationOperation, @@ -271,7 +316,7 @@ export function defineInternalJsonRoute< if (!parsed.success) return parsed.response try { - const input = options.mapInput(parsed.data) + const input = await options.mapInput(parsed.data, { principal, request }) const result = await options.useCase.execute({ principal, input, @@ -283,11 +328,24 @@ export function defineInternalJsonRoute< if (responseSchema.mode !== 'json') { throw new Error('Internal JSON route response mode changed after initialization') } - const validatedBody = responseSchema.schema.parse(body) - return NextResponse.json(validatedBody, { - status: successStatus, - headers: options.responseHeaders?.({ principal, input, result }), - }) + const validatedBody = responseSchema.schema.parse(body) as ContractJsonResponse<C> + const headers = options.responseHeaders?.({ principal, input, result }) + const finalization = options.finalizeResponse + ? await options.finalizeResponse({ + request, + principal, + input, + result, + body: validatedBody, + }) + : undefined + return NextResponse.json( + appendFinalizedBodyFields(validatedBody, finalization?.bodyFields), + { + status: successStatus, + headers: appendFinalizedHeaders(headers, finalization?.headers), + } + ) } catch (error) { const response = options.errorPolicy.project(error) if (response) return createJsonErrorResponse(response) diff --git a/apps/sim/lib/api/server/routes/v2-body-lifecycle-route.test.ts b/apps/sim/lib/api/server/routes/v2-body-lifecycle-route.test.ts new file mode 100644 index 00000000000..f0d940e8abb --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-body-lifecycle-route.test.ts @@ -0,0 +1,298 @@ +/** + * @vitest-environment node + */ + +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' + +const mocks = vi.hoisted(() => ({ + order: [] as string[], +})) + +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 { defineRouteContract } from '@/lib/api/contracts' +import { defineV2BodyLifecycleRoute } from '@/lib/api/server/routes/v2-body-lifecycle-route' +import { v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes/v2-json-route' +import { v2Error } from '@/app/api/v2/lib/response' + +const operation = { id: 'test.body_lifecycle' } as const +const contract = defineRouteContract({ + method: 'POST', + path: '/api/v2/body-lifecycle/[id]', + params: z.object({ id: z.string().min(1) }), + query: z.object({ workspaceId: z.string().min(1) }), + response: { + mode: 'json', + schema: z.object({ data: z.object({ id: z.string() }) }), + status: 201, + }, +}) + +class StageRejection extends Error {} + +type RejectableStage = 'admission' | 'body' | 'transfer' | 'application' | 'presenter' | 'effects' + +let rejectedStage: RejectableStage | null = null + +function rejectAt(stage: RejectableStage): void { + mocks.order.push(stage) + if (rejectedStage === stage) throw new StageRejection(`${stage} rejected`) +} + +function buildHandler() { + return defineV2BodyLifecycleRoute({ + contract, + auth: v2ApiKeyAuth, + operation, + rateLimit: v2RateLimits.publicApi, + errorPolicy: { + render(error) { + return error instanceof StageRejection ? v2Error('CONFLICT', error.message) : null + }, + }, + admission: { + mapInput: ({ params, query }) => { + mocks.order.push('contract') + return { id: params.id, workspaceId: query.workspaceId } + }, + useCase: { + operation, + async execute({ input }) { + rejectAt('admission') + return { canonicalWorkspaceId: input.workspaceId } + }, + }, + }, + async readBody() { + rejectAt('body') + return { bytes: Buffer.from('body') } + }, + async transfer({ admission }) { + rejectAt('transfer') + return { url: `stored://${admission.canonicalWorkspaceId}` } + }, + mapInput: ({ parsed, transfer }) => ({ id: parsed.params.id, url: transfer.url }), + useCase: { + operation, + async execute({ input }) { + rejectAt('application') + return input + }, + }, + present(result) { + rejectAt('presenter') + return { data: { id: result.id } } + }, + onSuccess() { + rejectAt('effects') + }, + }) +} + +function buildRequest() { + return new NextRequest('http://localhost/api/v2/body-lifecycle/item-1?workspaceId=workspace-1', { + method: 'POST', + headers: { 'x-api-key': 'secret' }, + body: 'unread-body', + }) +} + +function context(id = 'item-1') { + return { params: Promise.resolve({ id }) } +} + +describe('defineV2BodyLifecycleRoute', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.order.splice(0) + rejectedStage = null + v2RouteMocks.preauthRate.mockImplementation(async () => { + mocks.order.push('ip-limit') + return V2_PREAUTH_RATE_LIMIT_ALLOWED + }) + v2RouteMocks.operationRate.mockImplementation(async () => { + mocks.order.push('operation-limit') + return V2_OPERATION_RATE_LIMIT_ALLOWED + }) + v2RouteMocks.gate.mockImplementation(async () => { + mocks.order.push('rollout') + return null + }) + v2RouteMocks.authenticate.mockImplementation(async () => { + mocks.order.push('authenticate') + return { + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], + rateLimitSubscription: null, + keyType: 'personal', + } + }) + }) + + it('fails fast when a contract body would be read before staged admission', () => { + const bodyContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/body-lifecycle', + body: z.object({ value: z.string() }), + response: { mode: 'json', schema: z.object({ data: z.object({ id: z.string() }) }) }, + }) + const useCase = { operation, execute: async () => ({ id: 'item-1' }) } + + expect(() => + defineV2BodyLifecycleRoute({ + contract: bodyContract, + auth: v2ApiKeyAuth, + operation, + rateLimit: v2RateLimits.publicApi, + errorPolicy: { render: () => null }, + admission: { mapInput: () => ({}), useCase }, + readBody: async () => Buffer.alloc(0), + transfer: async () => ({ url: 'stored://item-1' }), + mapInput: () => ({}), + useCase, + present: () => ({ data: { id: 'item-1' } }), + }) + ).toThrow('must omit its body schema so admission precedes body reads') + }) + + it('runs admission, bounded body work, registration, presentation, and effects in order', async () => { + const response = await buildHandler()(buildRequest(), context()) + + expect(response.status).toBe(201) + expect(await response.json()).toEqual({ data: { id: 'item-1' } }) + expect(mocks.order).toEqual([ + 'ip-limit', + 'authenticate', + 'rollout', + 'operation-limit', + 'operation-limit', + 'contract', + 'admission', + 'body', + 'transfer', + 'application', + 'presenter', + 'effects', + ]) + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(response.headers.get('x-ratelimit-limit')).toBe('100') + expect(response.headers.get('x-request-id')).toBeTruthy() + }) + + it('rejects at the IP abuse limit before authentication', async () => { + v2RouteMocks.preauthRate.mockImplementation(async () => { + mocks.order.push('ip-limit') + return { ...V2_PREAUTH_RATE_LIMIT_ALLOWED, allowed: false, remaining: 0 } + }) + + const response = await buildHandler()(buildRequest(), context()) + + expect(response.status).toBe(429) + expect(mocks.order).toEqual(['ip-limit']) + }) + + it('rejects unauthenticated requests before rollout and operation limiting', async () => { + v2RouteMocks.authenticate.mockImplementation(async () => { + mocks.order.push('authenticate') + throw new MockV2ApiKeyUnauthenticatedError('Authentication required') + }) + + const response = await buildHandler()(buildRequest(), context()) + + expect(response.status).toBe(401) + expect(mocks.order).toEqual(['ip-limit', 'authenticate']) + }) + + it('rejects rollout-gated requests before operation limiting', async () => { + v2RouteMocks.gate.mockImplementation(async () => { + mocks.order.push('rollout') + return v2Error('FORBIDDEN', 'V2 API access is not enabled') + }) + + const response = await buildHandler()(buildRequest(), context()) + + expect(response.status).toBe(403) + expect(mocks.order).toEqual(['ip-limit', 'authenticate', 'rollout']) + }) + + it('rejects operation-limited requests before contract or application admission', async () => { + v2RouteMocks.operationRate.mockImplementation(async () => { + mocks.order.push('operation-limit') + return { ...V2_OPERATION_RATE_LIMIT_ALLOWED, allowed: false, remaining: 0 } + }) + + const response = await buildHandler()(buildRequest(), context()) + + expect(response.status).toBe(429) + expect(mocks.order).toEqual([ + 'ip-limit', + 'authenticate', + 'rollout', + 'operation-limit', + 'operation-limit', + ]) + }) + + it('rejects invalid contract input before application admission or body reads', async () => { + const response = await buildHandler()(buildRequest(), context('')) + + expect(response.status).toBe(400) + expect(mocks.order).toEqual([ + 'ip-limit', + 'authenticate', + 'rollout', + 'operation-limit', + 'operation-limit', + ]) + }) + + it.each<RejectableStage>([ + 'admission', + 'body', + 'transfer', + 'application', + 'presenter', + 'effects', + ])('renders typed %s rejection without entering later phases', async (stage) => { + rejectedStage = stage + + const response = await buildHandler()(buildRequest(), context()) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: { code: 'CONFLICT', message: `${stage} rejected` }, + }) + expect(mocks.order.at(-1)).toBe(stage) + }) + + it.each(['authentication', 'rollout', 'rate_limit'] as const)( + 'maps %s infrastructure failures to service unavailable', + async (stage) => { + const failure = new Error(`${stage} unavailable`) + if (stage === 'authentication') v2RouteMocks.authenticate.mockRejectedValue(failure) + if (stage === 'rollout') v2RouteMocks.gate.mockRejectedValue(failure) + if (stage === 'rate_limit') v2RouteMocks.operationRate.mockRejectedValue(failure) + + const response = await buildHandler()(buildRequest(), context()) + + expect(response.status).toBe(503) + expect(await response.json()).toEqual({ + error: { code: 'SERVICE_UNAVAILABLE', message: 'Service temporarily unavailable' }, + }) + } + ) +}) diff --git a/apps/sim/lib/api/server/routes/v2-body-lifecycle-route.ts b/apps/sim/lib/api/server/routes/v2-body-lifecycle-route.ts new file mode 100644 index 00000000000..9e83ca0c155 --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-body-lifecycle-route.ts @@ -0,0 +1,174 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import type { ContractJsonResponse } from '@/lib/api/contracts' +import { requireJsonRouteDefinition } from '@/lib/api/server/routes/definition' +import type { + JsonApiRouteContract, + JsonNextRouteHandler, + JsonRouteContext, +} from '@/lib/api/server/routes/types' +import { + admitV2Request, + type V2ErrorPolicy, + type V2RateLimitPolicy, + V2RouteInfrastructureError, + type v2ApiKeyAuth, +} from '@/lib/api/server/routes/v2-json-route' +import { + type ParsedRequest, + type ParseRequestOptions, + parseRequest, +} from '@/lib/api/server/validation' +import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { v2Error, v2ValidationError } from '@/app/api/v2/lib/response' + +interface V2BodyLifecycleAdmission< + O extends ApplicationOperation, + C extends JsonApiRouteContract, + I, + R, +> { + mapInput(input: ParsedRequest<C>): I + useCase: OperationUseCase<NoInfer<O>, I, R> +} + +interface V2BodyLifecycleContext<C extends JsonApiRouteContract, A> { + request: NextRequest + principal: Awaited<ReturnType<typeof v2ApiKeyAuth.authenticate>>['principal'] + parsed: ParsedRequest<C> + admission: A +} + +interface V2BodyLifecycleTransferContext<C extends JsonApiRouteContract, A, B> + extends V2BodyLifecycleContext<C, A> { + body: B +} + +interface V2BodyLifecycleInputContext<C extends JsonApiRouteContract, A, B, T> + extends V2BodyLifecycleTransferContext<C, A, B> { + transfer: T +} + +interface V2BodyLifecycleSuccessContext<C extends JsonApiRouteContract, A, B, T, I, R> + extends V2BodyLifecycleInputContext<C, A, B, T> { + input: I + result: R +} + +interface V2BodyLifecycleRouteOptions< + C extends JsonApiRouteContract, + O extends ApplicationOperation, + AI, + A, + B, + T, + I, + R, +> { + contract: C + operation: O + auth: typeof v2ApiKeyAuth + rateLimit: V2RateLimitPolicy + errorPolicy: V2ErrorPolicy + parseOptions?: Omit<ParseRequestOptions, 'validationErrorResponse'> + admission: V2BodyLifecycleAdmission<O, C, AI, A> + readBody(context: V2BodyLifecycleContext<C, A>): Promise<B> + transfer(context: V2BodyLifecycleTransferContext<C, A, B>): Promise<T> + mapInput(context: V2BodyLifecycleInputContext<C, A, B, T>): I + useCase: OperationUseCase<NoInfer<O>, I, R> + present(result: R): ContractJsonResponse<C> | Promise<ContractJsonResponse<C>> + onSuccess?( + context: V2BodyLifecycleSuccessContext<C, A, B, T, NoInfer<I>, NoInfer<R>> + ): void | Promise<void> +} + +/** + * Defines a v2 route whose body must not be read until an application use case + * has completed cheap canonical admission. The contract intentionally omits a + * body schema because the bounded byte-plane reader owns that non-JSON payload. + */ +export function defineV2BodyLifecycleRoute< + C extends JsonApiRouteContract, + O extends ApplicationOperation, + AI, + A, + B, + T, + I, + R, +>(options: V2BodyLifecycleRouteOptions<C, O, AI, A, B, T, I, R>): JsonNextRouteHandler { + if (options.contract.body) { + throw new Error( + `${options.contract.method} ${options.contract.path} must omit its body schema so admission precedes body reads` + ) + } + const { successStatus } = requireJsonRouteDefinition( + options.contract, + options.operation, + options.useCase.operation + ) + requireJsonRouteDefinition( + options.contract, + options.operation, + options.admission.useCase.operation + ) + + const wrapped = withRouteHandler<JsonRouteContext | undefined>( + async (request, context) => { + if (request.method !== options.contract.method) { + throw new Error( + `Route received ${request.method} for ${options.contract.method} contract ${options.contract.path}` + ) + } + + const routeAdmission = await admitV2Request( + request, + options.operation, + options.auth, + options.rateLimit + ) + if (!routeAdmission.success) return routeAdmission.response + + const parsed = await parseRequest(options.contract, request, context ?? {}, { + ...options.parseOptions, + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { principal } = routeAdmission.auth + try { + const admissionInput = options.admission.mapInput(parsed.data) + const admission = await options.admission.useCase.execute({ + principal, + input: admissionInput, + request, + }) + const lifecycleContext = { request, principal, parsed: parsed.data, admission } + const body = await options.readBody(lifecycleContext) + const transfer = await options.transfer({ ...lifecycleContext, body }) + const inputContext = { ...lifecycleContext, body, transfer } + const input = options.mapInput(inputContext) + const result = await options.useCase.execute({ principal, input, request }) + const responseBody = options.contract.response.schema.parse(await options.present(result)) + await options.onSuccess?.({ ...inputContext, input, result }) + return NextResponse.json(responseBody, { + status: successStatus, + headers: { 'Cache-Control': 'private, no-store' }, + }) + } catch (error) { + const response = options.errorPolicy.render(error) + if (response) return response + throw error + } + }, + { + unhandledErrorResponse: ({ error }) => + error instanceof V2RouteInfrastructureError + ? v2Error('SERVICE_UNAVAILABLE', 'Service temporarily unavailable') + : v2Error('INTERNAL_ERROR', 'Internal server error'), + } + ) + + return async (request, context) => wrapped(request, context) +} diff --git a/apps/sim/lib/copilot/application/execute-knowledge-use-case.ts b/apps/sim/lib/copilot/application/execute-knowledge-use-case.ts index d88c5c940b3..f21b9b0c41a 100644 --- a/apps/sim/lib/copilot/application/execute-knowledge-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-knowledge-use-case.ts @@ -5,6 +5,7 @@ import { COPILOT_APPLICATION_DELEGATION_TTL_MS, type CopilotExecutionContext, createCopilotApplicationPrincipal, + createTrustedCopilotPrincipal, requireTrustedCopilotExecutionContext, } from '@/lib/copilot/auth/application-delegation' import type { OperationUseCase } from '@/lib/core/application' @@ -16,6 +17,12 @@ import { export type CopilotKnowledgeDelegationContext = CopilotExecutionContext +export interface CopilotChatKnowledgeDelegationContext { + userId: string + workspaceId: string + chatId?: string +} + const knowledgeDelegation = { audience: knowledgeDelegationPolicy.audience, ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, @@ -29,6 +36,13 @@ const executeKnowledgeUseCase = createCopilotApplicationAdapter({ operations: knowledgeOperations, }) +/** Requires the immutable trusted workspace bound to a Copilot Knowledge execution. */ +export function requireCopilotKnowledgeWorkspaceId( + context: CopilotKnowledgeDelegationContext | undefined +): string { + return requireTrustedCopilotExecutionContext(context).workspaceId +} + /** Normalizes immutable Copilot execution identity into a knowledge delegation. */ export function resolveCopilotKnowledgePrincipal( context: CopilotKnowledgeDelegationContext | undefined @@ -39,6 +53,21 @@ export function resolveCopilotKnowledgePrincipal( ) } +/** Creates the trusted Knowledge principal used while resolving Copilot chat context. */ +export function createCopilotChatKnowledgePrincipal( + context: CopilotChatKnowledgeDelegationContext +): DelegatedPrincipal { + return createTrustedCopilotPrincipal( + { + userId: context.userId, + workspaceId: context.workspaceId, + delegationId: `copilot-chat:${context.chatId ?? context.workspaceId}`, + chatId: context.chatId, + }, + knowledgeDelegation + ) +} + /** Enters a registered knowledge application use case with trusted Copilot identity. */ export function executeCopilotKnowledgeUseCase<O extends KnowledgeOperation, I, R>( context: CopilotKnowledgeDelegationContext | undefined, diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 0c1307fd4a5..96aaa155462 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -8,6 +8,7 @@ import { MAX_TABLE_SELECTION_CONTENT_LENGTH, MAX_TABLE_SELECTION_ROWS, } from '@/lib/copilot/chat/selection-context' +import { DelegatedWorkspaceAuthorizationError } from '@/lib/core/application' import type { ChatContext } from '@/stores/panel' const { @@ -20,6 +21,7 @@ const { readWorkspaceFileMetadata, getTableById, getRowsByIds, + readKnowledgeBase, getBlockVisibilityForCopilot, isIntegrationDeploymentAvailable, } = vi.hoisted(() => ({ @@ -32,6 +34,7 @@ const { readWorkspaceFileMetadata: vi.fn(), getTableById: vi.fn(), getRowsByIds: vi.fn(), + readKnowledgeBase: vi.fn(), getBlockVisibilityForCopilot: vi.fn(async () => null), isIntegrationDeploymentAvailable: vi.fn(() => true), })) @@ -50,6 +53,9 @@ vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => })) vi.mock('@/lib/table/service', () => ({ getTableById })) vi.mock('@/lib/table/rows/service', () => ({ getRowsByIds })) +vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ + readKnowledgeBase: { execute: readKnowledgeBase }, +})) /** * Overrides the global `@sim/db` mock: the logs-context tests below need @@ -58,6 +64,75 @@ vi.mock('@/lib/table/rows/service', () => ({ getRowsByIds })) import { processContextsServer } from './process-contents' +describe('processContextsServer - knowledge contexts', () => { + beforeEach(() => { + vi.clearAllMocks() + readKnowledgeBase.mockResolvedValue({ + knowledgeBase: { id: 'knowledge-1', name: 'Product docs' }, + }) + }) + + it('reads through the fixed application query with a trusted chat principal', async () => { + const result = await processContextsServer( + [{ kind: 'knowledge', knowledgeId: 'knowledge-1', label: 'Docs' } as ChatContext], + 'dual-workspace-user', + 'hello', + 'workspace-a', + 'chat-1' + ) + + expect(readKnowledgeBase).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'dual-workspace-user', + workspaceId: 'workspace-a', + audience: 'sim:knowledge', + }), + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-a', + }, + }) + expect(result).toEqual([ + { + type: 'knowledge', + tag: '@Docs', + content: '', + path: 'knowledgebases/Product%20docs/meta.json', + }, + ]) + }) + + it('conceals a cross-workspace Knowledge target from Copilot context', async () => { + readKnowledgeBase.mockRejectedValueOnce(new DelegatedWorkspaceAuthorizationError()) + + await expect( + processContextsServer( + [{ kind: 'knowledge', knowledgeId: 'knowledge-b', label: 'Hidden' } as ChatContext], + 'dual-workspace-user', + 'hello', + 'workspace-a', + 'chat-1' + ) + ).resolves.toEqual([]) + }) + + it('conceals infrastructure details from Copilot context', async () => { + readKnowledgeBase.mockRejectedValueOnce(new Error('database host and password')) + + await expect( + processContextsServer( + [{ kind: 'knowledge', knowledgeId: 'knowledge-b', label: 'Hidden' } as ChatContext], + 'dual-workspace-user', + 'hello', + 'workspace-a', + 'chat-1' + ) + ).resolves.toEqual([]) + }) +}) + describe('processContextsServer - block contexts', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 7dd0e3ac386..c3c108d09f2 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -1,11 +1,11 @@ -import { db, dbReplica } from '@sim/db' -import { knowledgeBase } from '@sim/db/schema' +import { db } from '@sim/db' import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission, getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' -import { and, eq, isNull } from 'drizzle-orm' +import { eq } from 'drizzle-orm' +import { createCopilotChatKnowledgePrincipal } from '@/lib/copilot/application/execute-knowledge-use-case' import { createCopilotChatFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { @@ -27,6 +27,7 @@ import { import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' +import { readKnowledgeBase } from '@/lib/knowledge/application/knowledge-bases' import { toOverview } from '@/lib/logs/log-views' import type { TraceSpan } from '@/lib/logs/types' import { mcpService } from '@/lib/mcp/service' @@ -41,7 +42,6 @@ import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/wor import { getSkillById } from '@/lib/workflows/skills/operations' import { listFolders } from '@/lib/workflows/utils' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' -import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { escapeRegExp } from '@/executor/constants' import type { BrowserTextSelection, ChatContext, TerminalTextSelection } from '@/stores/panel' @@ -172,7 +172,8 @@ export async function processContextsServer( ctx.knowledgeId, userId, ctx.label ? `@${ctx.label}` : '@', - currentWorkspaceId + currentWorkspaceId, + chatId ) } if (ctx.kind === 'blocks' && ctx.blockIds?.length > 0) { @@ -559,33 +560,23 @@ async function processKnowledgeFromDb( knowledgeBaseId: string, userId: string | undefined, tag: string, - currentWorkspaceId?: string + currentWorkspaceId?: string, + chatId?: string ): Promise<AgentContext | null> { try { - if (userId) { - const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, userId) - if (!accessCheck.hasAccess) { - return null - } - if (currentWorkspaceId && accessCheck.knowledgeBase?.workspaceId !== currentWorkspaceId) { - return null - } - } - - const conditions = [eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)] - if (currentWorkspaceId) { - conditions.push(eq(knowledgeBase.workspaceId, currentWorkspaceId)) - } - const kbRows = await dbReplica - .select({ - id: knowledgeBase.id, - name: knowledgeBase.name, - }) - .from(knowledgeBase) - .where(and(...conditions)) - .limit(1) - const kb = kbRows?.[0] - if (!kb) return null + if (!userId || !currentWorkspaceId) return null + const principal = createCopilotChatKnowledgePrincipal({ + userId, + workspaceId: currentWorkspaceId, + chatId, + }) + const { knowledgeBase: kb } = await readKnowledgeBase.execute({ + principal, + input: { + knowledgeBaseId, + assertedWorkspaceId: currentWorkspaceId, + }, + }) return { type: 'knowledge', @@ -836,7 +827,8 @@ export async function resolveActiveResourceContext( resourceId, userId, '@active_resource', - workspaceId + workspaceId, + chatId ) if (!ctx) return null return { diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index 30b5d8d4f17..2f241d2478b 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -138,7 +138,13 @@ describe('copilot tool executor fallback', () => { chatId: 'chat-1', enforceCredentialAccess: true, }), - }) + }), + { + internalExecutorDelegation: { + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }, + } ) expect(result).toEqual({ success: true, output: { emails: [] } }) }) @@ -214,7 +220,13 @@ describe('copilot tool executor fallback', () => { query: 'hello', _context: expect.not.objectContaining({ resolvedSecretTraceRegistry: expect.anything() }), }), - { resolvedSecretTraceRegistry: registry } + { + resolvedSecretTraceRegistry: registry, + internalExecutorDelegation: { + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }, + } ) const appParams = executeAppTool.mock.calls[0]?.[1] expect(JSON.stringify(appParams)).not.toContain('resolvedSecretTraceRegistry') @@ -274,7 +286,13 @@ describe('copilot tool executor fallback', () => { _context: expect.objectContaining({ copilotToolExecution: true, }), - }) + }), + { + internalExecutorDelegation: { + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }, + } ) }) @@ -323,7 +341,13 @@ describe('copilot tool executor fallback', () => { 'function_execute', expect.objectContaining({ timeout: 10_000, - }) + }), + { + internalExecutorDelegation: { + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }, + } ) }) @@ -347,7 +371,13 @@ describe('copilot tool executor fallback', () => { 'function_execute', expect.objectContaining({ timeout: 10_000, - }) + }), + { + internalExecutorDelegation: { + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }, + } ) }) @@ -371,7 +401,13 @@ describe('copilot tool executor fallback', () => { 'function_execute', expect.objectContaining({ timeout: DEFAULT_EXECUTION_TIMEOUT_MS, - }) + }), + { + internalExecutorDelegation: { + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }, + } ) }) }) diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index 6488b695f25..6184d682204 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -71,10 +71,22 @@ export async function executeTool( (isSimExecuted(toolId) || (isClientExecuted(toolId) && hasHandler(toolId))) if (!canUseRegisteredHandler) { const appParams = buildAppToolParams(normalizedParams, context) - return context.resolvedSecretTraceRegistry - ? executeAppTool(toolId, appParams, { - resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry, - }) + const options = { + ...(context.resolvedSecretTraceRegistry + ? { resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry } + : {}), + ...(context.workflowId + ? { + internalExecutorDelegation: { + subjectUserId: context.userId, + workflowId: context.workflowId, + ...(context.executionId ? { executionId: context.executionId } : {}), + }, + } + : {}), + } + return Object.keys(options).length > 0 + ? executeAppTool(toolId, appParams, options) : executeAppTool(toolId, appParams) } diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index 7e850b80dd5..711a9ca58eb 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -4,34 +4,81 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { + mockAddWorkspaceFiles, + mockBulkDeleteKnowledgeBases, + mockBulkDeleteKnowledgeDocuments, mockCaptureServerEvent, mockCreateKnowledgeBase, - mockDeleteKnowledgeBase, - mockDeleteKnowledgeDocument, - mockGetBoundWorkspaceFileSecretProvenance, + mockDeleteKnowledgeConnector, + mockDeleteKnowledgeTag, mockKnowledgeBaseCreated, mockKnowledgeBaseDeleted, mockKnowledgeBaseDocumentsUploaded, mockReadKnowledgeBase, - mockResolveWorkspaceFileReference, + mockReadKnowledgeTagUsage, mockSearchKnowledge, + mockSyncKnowledgeConnector, mockUpdateKnowledgeBase, - mockUploadKnowledgeDocument, -} = vi.hoisted(() => ({ - mockCaptureServerEvent: vi.fn(), - mockCreateKnowledgeBase: vi.fn(), - mockDeleteKnowledgeBase: vi.fn(), - mockDeleteKnowledgeDocument: vi.fn(), - mockGetBoundWorkspaceFileSecretProvenance: vi.fn(), - mockKnowledgeBaseCreated: vi.fn(), - mockKnowledgeBaseDeleted: vi.fn(), - mockKnowledgeBaseDocumentsUploaded: vi.fn(), - mockReadKnowledgeBase: vi.fn(), - mockResolveWorkspaceFileReference: vi.fn(), - mockSearchKnowledge: vi.fn(), - mockUpdateKnowledgeBase: vi.fn(), - mockUploadKnowledgeDocument: vi.fn(), -})) + mockUpdateKnowledgeConnector, + mockUpdateKnowledgeDocument, + mockUpdateKnowledgeTag, + mockCreateKnowledgeConnector, + mockCreateKnowledgeTag, + mockListKnowledgeTags, + knowledgeOperations, +} = vi.hoisted(() => { + const defineOperation = (id: string, minimumRole: 'read' | 'write') => + Object.freeze({ + id, + minimumRole, + workspaceApiKey: 'deny' as const, + principalKinds: ['session', 'personal_api_key', 'delegated'] as const, + delegatedServices: ['copilot'] as const, + }) + + return { + mockAddWorkspaceFiles: vi.fn(), + mockBulkDeleteKnowledgeBases: vi.fn(), + mockBulkDeleteKnowledgeDocuments: vi.fn(), + mockCaptureServerEvent: vi.fn(), + mockCreateKnowledgeBase: vi.fn(), + mockDeleteKnowledgeConnector: vi.fn(), + mockDeleteKnowledgeTag: vi.fn(), + mockKnowledgeBaseCreated: vi.fn(), + mockKnowledgeBaseDeleted: vi.fn(), + mockKnowledgeBaseDocumentsUploaded: vi.fn(), + mockReadKnowledgeBase: vi.fn(), + mockReadKnowledgeTagUsage: vi.fn(), + mockSearchKnowledge: vi.fn(), + mockSyncKnowledgeConnector: vi.fn(), + mockUpdateKnowledgeBase: vi.fn(), + mockUpdateKnowledgeConnector: vi.fn(), + mockUpdateKnowledgeDocument: vi.fn(), + mockUpdateKnowledgeTag: vi.fn(), + mockCreateKnowledgeConnector: vi.fn(), + mockCreateKnowledgeTag: vi.fn(), + mockListKnowledgeTags: vi.fn(), + knowledgeOperations: { + addWorkspaceFiles: defineOperation('knowledge.documents.add_workspace_files', 'write'), + bulkDelete: defineOperation('knowledge.bulk_delete', 'write'), + bulkDeleteDocuments: defineOperation('knowledge.documents.bulk_delete', 'write'), + create: defineOperation('knowledge.create', 'write'), + createConnector: defineOperation('knowledge.connectors.create', 'write'), + createTag: defineOperation('knowledge.tags.create', 'write'), + deleteConnector: defineOperation('knowledge.connectors.delete', 'write'), + deleteTag: defineOperation('knowledge.tags.delete', 'write'), + listTags: defineOperation('knowledge.tags.list', 'read'), + read: defineOperation('knowledge.read', 'read'), + readTagUsage: defineOperation('knowledge.tags.read_usage', 'read'), + search: defineOperation('knowledge.search', 'read'), + syncConnector: defineOperation('knowledge.connectors.sync', 'write'), + update: defineOperation('knowledge.update', 'write'), + updateConnector: defineOperation('knowledge.connectors.update', 'write'), + updateDocument: defineOperation('knowledge.documents.update', 'write'), + updateTag: defineOperation('knowledge.tags.update', 'write'), + }, + } +}) vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ KnowledgeBase: { id: 'knowledge_base' }, @@ -44,49 +91,80 @@ vi.mock('@/lib/core/telemetry', () => ({ }, })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) +vi.mock('@/lib/knowledge/application/operations', () => ({ knowledgeOperations })) +vi.mock('@/lib/knowledge/application/add-workspace-files', () => ({ + addWorkspaceFilesToKnowledgeBase: { + operation: knowledgeOperations.addWorkspaceFiles, + execute: mockAddWorkspaceFiles, + }, +})) vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ - createKnowledgeBase: { execute: mockCreateKnowledgeBase }, - deleteKnowledgeBaseOperation: { execute: mockDeleteKnowledgeBase }, - readKnowledgeBase: { execute: mockReadKnowledgeBase }, - updateKnowledgeBaseOperation: { execute: mockUpdateKnowledgeBase }, + bulkDeleteKnowledgeBases: { + operation: knowledgeOperations.bulkDelete, + execute: mockBulkDeleteKnowledgeBases, + }, + createKnowledgeBase: { + operation: knowledgeOperations.create, + execute: mockCreateKnowledgeBase, + }, + readKnowledgeBase: { operation: knowledgeOperations.read, execute: mockReadKnowledgeBase }, + updateKnowledgeBaseOperation: { + operation: knowledgeOperations.update, + execute: mockUpdateKnowledgeBase, + }, })) vi.mock('@/lib/knowledge/application/documents', () => ({ - deleteKnowledgeDocument: { execute: mockDeleteKnowledgeDocument }, - uploadKnowledgeDocument: { execute: mockUploadKnowledgeDocument }, + bulkDeleteKnowledgeDocuments: { + operation: knowledgeOperations.bulkDeleteDocuments, + execute: mockBulkDeleteKnowledgeDocuments, + }, + updateKnowledgeDocument: { + operation: knowledgeOperations.updateDocument, + execute: mockUpdateKnowledgeDocument, + }, })) vi.mock('@/lib/knowledge/application/search', () => ({ - searchKnowledge: { execute: mockSearchKnowledge }, + searchKnowledge: { operation: knowledgeOperations.search, execute: mockSearchKnowledge }, })) -vi.mock('@/lib/knowledge/orchestration', () => ({ - performCreateKnowledgeConnector: vi.fn(), - performDeleteKnowledgeConnector: vi.fn(), - performSyncKnowledgeConnector: vi.fn(), - performUpdateKnowledgeConnector: vi.fn(), - performUpdateKnowledgeDocument: vi.fn(), -})) -vi.mock('@/lib/knowledge/tags/service', () => ({ - createTagDefinition: vi.fn(), - deleteTagDefinition: vi.fn(), - getDocumentTagDefinitions: vi.fn(), - getNextAvailableSlot: vi.fn(), - getTagDefinitionById: vi.fn(), - getTagUsageStats: vi.fn(), - updateTagDefinition: vi.fn(), -})) -vi.mock('@/lib/uploads', () => ({ - StorageService: { generatePresignedDownloadUrl: vi.fn().mockResolvedValue('https://file.test') }, -})) -vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ - resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, -})) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ - getBoundWorkspaceFileSecretProvenance: mockGetBoundWorkspaceFileSecretProvenance, +vi.mock('@/lib/knowledge/application/connectors', () => ({ + createKnowledgeConnector: { + operation: knowledgeOperations.createConnector, + execute: mockCreateKnowledgeConnector, + }, + updateKnowledgeConnector: { + operation: knowledgeOperations.updateConnector, + execute: mockUpdateKnowledgeConnector, + }, + deleteKnowledgeConnector: { + operation: knowledgeOperations.deleteConnector, + execute: mockDeleteKnowledgeConnector, + }, + syncKnowledgeConnector: { + operation: knowledgeOperations.syncConnector, + execute: mockSyncKnowledgeConnector, + }, })) -vi.mock('@/app/api/auth/oauth/utils', () => ({ getCredential: vi.fn() })) -vi.mock('@/app/api/knowledge/utils', () => ({ - checkDocumentWriteAccess: vi.fn(), - checkKnowledgeBaseAccess: vi.fn(), - checkKnowledgeBaseWriteAccess: vi.fn(), +vi.mock('@/lib/knowledge/application/tags', () => ({ + createKnowledgeTag: { + operation: knowledgeOperations.createTag, + execute: mockCreateKnowledgeTag, + }, + deleteKnowledgeTag: { + operation: knowledgeOperations.deleteTag, + execute: mockDeleteKnowledgeTag, + }, + listKnowledgeTags: { + operation: knowledgeOperations.listTags, + execute: mockListKnowledgeTags, + }, + readKnowledgeTagUsage: { + operation: knowledgeOperations.readTagUsage, + execute: mockReadKnowledgeTagUsage, + }, + updateKnowledgeTag: { + operation: knowledgeOperations.updateTag, + execute: mockUpdateKnowledgeTag, + }, })) import type { ServerToolContext } from '@/lib/copilot/tools/server/base-tool' @@ -116,6 +194,22 @@ const CONTEXT = { copilotToolExecution: true, } satisfies ServerToolContext +const BILLED_CONTEXT = { + ...CONTEXT, + billingAttribution: { + actorUserId: 'external-admin', + workspaceId: 'workspace-paid', + billedAccountUserId: 'workspace-owner', + organizationId: null, + billingEntity: { type: 'user' as const, id: 'workspace-owner' }, + billingPeriod: { + start: '2026-08-01T00:00:00.000Z', + end: '2026-09-01T00:00:00.000Z', + }, + payerSubscription: null, + }, +} satisfies ServerToolContext + function expectDelegatedPrincipal(call: unknown): void { expect(call).toMatchObject({ principal: { @@ -136,15 +230,62 @@ describe('knowledge_base trusted application delegation', () => { mockReadKnowledgeBase.mockResolvedValue({ knowledgeBase: KNOWLEDGE_BASE, folderPath: '/' }) mockCreateKnowledgeBase.mockResolvedValue({ knowledgeBase: KNOWLEDGE_BASE, folderPath: '/' }) mockUpdateKnowledgeBase.mockResolvedValue({ knowledgeBase: KNOWLEDGE_BASE, folderPath: '/' }) - mockDeleteKnowledgeBase.mockResolvedValue({ id: KNOWLEDGE_BASE.id, name: KNOWLEDGE_BASE.name }) + mockBulkDeleteKnowledgeBases.mockResolvedValue({ + deleted: [{ id: KNOWLEDGE_BASE.id, name: KNOWLEDGE_BASE.name }], + notFound: [], + failed: [], + }) + mockBulkDeleteKnowledgeDocuments.mockResolvedValue({ + knowledgeBaseId: KNOWLEDGE_BASE.id, + deleted: ['document-1'], + failed: [], + deletedDocuments: [], + }) + mockAddWorkspaceFiles.mockResolvedValue({ + knowledgeBaseId: KNOWLEDGE_BASE.id, + knowledgeBaseName: KNOWLEDGE_BASE.name, + added: [ + { + documentId: 'document-1', + filename: 'report.pdf', + fileSize: 100, + mimeType: 'application/pdf', + }, + ], + failed: [], + }) mockSearchKnowledge.mockResolvedValue({ results: [], query: 'query', knowledgeBaseIds: [KNOWLEDGE_BASE.id], + knowledgeBases: [{ id: KNOWLEDGE_BASE.id, name: KNOWLEDGE_BASE.name }], topK: 5, totalResults: 0, }) - mockDeleteKnowledgeDocument.mockResolvedValue({ id: 'document-1', filename: 'doc.pdf' }) + mockCreateKnowledgeConnector.mockResolvedValue({ + connector: { + id: 'connector-1', + knowledgeBaseId: KNOWLEDGE_BASE.id, + connectorType: 'notion', + status: 'active', + syncIntervalMinutes: 1440, + }, + workspaceId: 'workspace-paid', + }) + mockDeleteKnowledgeConnector.mockResolvedValue({ + knowledgeBaseId: KNOWLEDGE_BASE.id, + workspaceId: 'workspace-paid', + connectorId: 'connector-1', + connectorType: 'notion', + documentsDeleted: 0, + documentsKept: 2, + }) + mockSyncKnowledgeConnector.mockResolvedValue({ + knowledgeBaseId: KNOWLEDGE_BASE.id, + workspaceId: 'workspace-paid', + connectorId: 'connector-1', + connectorType: 'notion', + }) }) it.each([ @@ -231,6 +372,7 @@ describe('knowledge_base trusted application delegation', () => { ], query: '{{KB_QUERY}}', knowledgeBaseIds: [KNOWLEDGE_BASE.id], + knowledgeBases: [{ id: KNOWLEDGE_BASE.id, name: KNOWLEDGE_BASE.name }], topK: 5, totalResults: 1, }) @@ -256,17 +398,19 @@ describe('knowledge_base trusted application delegation', () => { topK: 5, resultSecretRegistry: registry, }) + expect(mockReadKnowledgeBase).not.toHaveBeenCalled() }) - it('propagates search infrastructure failures', async () => { + it('returns a safe model result for search infrastructure failures', async () => { mockSearchKnowledge.mockRejectedValueOnce(new Error('database unavailable')) - await expect( - knowledgeBaseServerTool.execute( - { operation: 'query', args: { knowledgeBaseId: KNOWLEDGE_BASE.id, query: 'query' } }, - { ...CONTEXT, resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry() } - ) - ).rejects.toThrow('database unavailable') + const result = await knowledgeBaseServerTool.execute( + { operation: 'query', args: { knowledgeBaseId: KNOWLEDGE_BASE.id, query: 'query' } }, + { ...CONTEXT, resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry() } + ) + + expect(result).toEqual({ success: false, message: 'Failed to query knowledge base' }) + expect(result.message).not.toContain('database unavailable') }) it('updates through the semantic operation', async () => { @@ -286,7 +430,7 @@ describe('knowledge_base trusted application delegation', () => { }) }) - it('keeps the unexposed delete compatibility path on the shared delete operation', async () => { + it('delegates the unexposed delete compatibility path once to the bulk command', async () => { const result = await knowledgeBaseServerTool.execute( { operation: 'delete', args: { knowledgeBaseId: KNOWLEDGE_BASE.id } }, CONTEXT @@ -296,22 +440,23 @@ describe('knowledge_base trusted application delegation', () => { success: true, data: { deleted: [{ id: KNOWLEDGE_BASE.id, name: KNOWLEDGE_BASE.name }] }, }) - const call = mockDeleteKnowledgeBase.mock.calls[0][0] + const call = mockBulkDeleteKnowledgeBases.mock.calls[0][0] expectDelegatedPrincipal(call) - expect(call.input).toEqual({ - knowledgeBaseId: KNOWLEDGE_BASE.id, + expect(call.input).toMatchObject({ assertedWorkspaceId: 'workspace-paid', + knowledgeBaseIds: [KNOWLEDGE_BASE.id], source: 'agent', }) - expect(mockKnowledgeBaseDeleted).toHaveBeenCalledWith({ - knowledgeBaseId: KNOWLEDGE_BASE.id, - }) }) it('keeps classified delete failures in the batch result', async () => { - mockDeleteKnowledgeBase.mockRejectedValueOnce( - new OrchestrationError('conflict', 'Knowledge base is locked') - ) + mockBulkDeleteKnowledgeBases.mockResolvedValueOnce({ + deleted: [], + notFound: [], + failed: [ + { id: KNOWLEDGE_BASE.id, name: KNOWLEDGE_BASE.name, reason: 'Knowledge base is locked' }, + ], + }) const result = await knowledgeBaseServerTool.execute( { operation: 'delete', args: { knowledgeBaseId: KNOWLEDGE_BASE.id } }, @@ -330,9 +475,19 @@ describe('knowledge_base trusted application delegation', () => { }) it('delegates document deletion and retains partial batch results', async () => { - mockDeleteKnowledgeDocument.mockRejectedValueOnce( - new OrchestrationError('not_found', 'Document not found') - ) + mockBulkDeleteKnowledgeDocuments.mockResolvedValueOnce({ + knowledgeBaseId: KNOWLEDGE_BASE.id, + deleted: ['document-1'], + failed: ['missing'], + deletedDocuments: [ + { + id: 'document-1', + filename: 'guide.pdf', + fileSize: 42, + mimeType: 'application/pdf', + }, + ], + }) const result = await knowledgeBaseServerTool.execute( { @@ -346,7 +501,8 @@ describe('knowledge_base trusted application delegation', () => { success: true, data: { deleted: ['document-1'], failed: ['missing'] }, }) - expectDelegatedPrincipal(mockDeleteKnowledgeDocument.mock.calls[1][0]) + expectDelegatedPrincipal(mockBulkDeleteKnowledgeDocuments.mock.calls[0][0]) + expect(mockBulkDeleteKnowledgeDocuments).toHaveBeenCalledOnce() expect(mockCaptureServerEvent).toHaveBeenCalledWith( 'external-admin', 'knowledge_base_document_deleted', @@ -355,6 +511,149 @@ describe('knowledge_base trusted application delegation', () => { ) }) + it.each([ + [ + 'add_connector', + { + knowledgeBaseId: KNOWLEDGE_BASE.id, + connectorType: 'notion', + credentialId: 'credential-1', + }, + 'knowledge_base_connector_added', + ], + ['delete_connector', { connectorId: 'connector-1' }, 'knowledge_base_connector_removed'], + ['sync_connector', { connectorId: 'connector-1' }, 'knowledge_base_connector_synced'], + ])( + 'records %s product analytics only after application success', + async (operation, args, event) => { + const result = await knowledgeBaseServerTool.execute({ operation, args }, BILLED_CONTEXT) + + expect(result.success).toBe(true) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'external-admin', + event, + expect.objectContaining({ workspace_id: 'workspace-paid' }), + expect.any(Object) + ) + } + ) + + it('delegates document updates with only the trusted workspace assertion', async () => { + mockUpdateKnowledgeDocument.mockResolvedValueOnce({ document: {}, updatedFields: ['filename'] }) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'update_document', + args: { + knowledgeBaseId: KNOWLEDGE_BASE.id, + documentId: 'document-1', + filename: 'renamed.pdf', + }, + }, + CONTEXT + ) + + expect(result).toMatchObject({ success: true, data: { documentId: 'document-1' } }) + const call = mockUpdateKnowledgeDocument.mock.calls[0][0] + expectDelegatedPrincipal(call) + expect(call.input).toEqual({ + knowledgeBaseId: KNOWLEDGE_BASE.id, + documentId: 'document-1', + assertedWorkspaceId: 'workspace-paid', + filename: 'renamed.pdf', + source: 'agent', + }) + }) + + it('does not expose connector infrastructure errors to the model', async () => { + mockUpdateKnowledgeConnector.mockRejectedValueOnce(new Error('sql host=private-db')) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'update_connector', + args: { connectorId: 'connector-1', connectorStatus: 'paused' }, + }, + CONTEXT + ) + + expect(result).toEqual({ + success: false, + message: 'Failed to update connector', + }) + expect(result.message).not.toContain('private-db') + }) + + it.each([ + [ + 'update_document', + { + knowledgeBaseId: KNOWLEDGE_BASE.id, + documentId: 'document-1', + filename: 'renamed.pdf', + }, + mockUpdateKnowledgeDocument, + ], + [ + 'update_tag', + { + knowledgeBaseId: KNOWLEDGE_BASE.id, + tagDefinitionId: 'tag-1', + displayName: 'Renamed', + }, + mockUpdateKnowledgeTag, + ], + ])('does not expose %s infrastructure details to the model', async (operation, args, useCase) => { + useCase.mockRejectedValueOnce(new Error('database password=private')) + + const result = await knowledgeBaseServerTool.execute({ operation, args }, CONTEXT) + + expect(result.success).toBe(false) + expect(result.message).not.toContain('database') + expect(result.message).not.toContain('private') + }) + + it('preserves caller-actionable connector failure messages', async () => { + mockUpdateKnowledgeConnector.mockRejectedValueOnce( + new OrchestrationError('validation', 'At least one connector update is required') + ) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'update_connector', + args: { connectorId: 'connector-1', connectorStatus: 'paused' }, + }, + CONTEXT + ) + + expect(result).toEqual({ + success: false, + message: 'At least one connector update is required', + }) + }) + + it('preserves caller-actionable tag provenance conflicts', async () => { + mockDeleteKnowledgeTag.mockRejectedValueOnce( + new OrchestrationError( + 'conflict', + 'Tag definitions cannot be deleted while resolved-secret document provenance is present' + ) + ) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'delete_tag', + args: { knowledgeBaseId: KNOWLEDGE_BASE.id, tagDefinitionId: 'tag-1' }, + }, + CONTEXT + ) + + expect(result).toEqual({ + success: false, + message: + 'Failed to delete_tag knowledge base: Tag definitions cannot be deleted while resolved-secret document provenance is present', + }) + }) + it.each([ { operation: 'add_file', @@ -379,9 +678,9 @@ describe('knowledge_base trusted application delegation', () => { expect(result.success).toBe(false) expect(result.message).toContain('Maximum is 100') expect(mockReadKnowledgeBase).not.toHaveBeenCalled() - expect(mockDeleteKnowledgeBase).not.toHaveBeenCalled() - expect(mockDeleteKnowledgeDocument).not.toHaveBeenCalled() - expect(mockUploadKnowledgeDocument).not.toHaveBeenCalled() + expect(mockBulkDeleteKnowledgeBases).not.toHaveBeenCalled() + expect(mockBulkDeleteKnowledgeDocuments).not.toHaveBeenCalled() + expect(mockAddWorkspaceFiles).not.toHaveBeenCalled() } ) }) @@ -389,27 +688,22 @@ describe('knowledge_base trusted application delegation', () => { describe('knowledge_base add_file delegation', () => { beforeEach(() => { vi.clearAllMocks() - mockReadKnowledgeBase.mockResolvedValue({ knowledgeBase: KNOWLEDGE_BASE, folderPath: '/' }) - mockResolveWorkspaceFileReference.mockResolvedValue({ - id: 'file-1', - key: 'workspace/workspace-paid/report.pdf', - name: 'report.pdf', - size: 100, - type: 'application/pdf', - }) - mockGetBoundWorkspaceFileSecretProvenance.mockResolvedValue({ status: 'exact', entries: [] }) - mockUploadKnowledgeDocument.mockResolvedValue({ - created: true, - document: { - id: 'document-1', - filename: 'report.pdf', - fileSize: 100, - mimeType: 'application/pdf', - }, + mockAddWorkspaceFiles.mockResolvedValue({ + knowledgeBaseId: KNOWLEDGE_BASE.id, + knowledgeBaseName: KNOWLEDGE_BASE.name, + added: [ + { + documentId: 'document-1', + filename: 'report.pdf', + fileSize: 100, + mimeType: 'application/pdf', + }, + ], + failed: [], }) }) - it('preserves file resolution and performs current admission inside uploadKnowledgeDocument', async () => { + it('maps aliases and delegates the complete batch once to the application command', async () => { const result = await knowledgeBaseServerTool.execute( { operation: 'add_file', @@ -422,28 +716,32 @@ describe('knowledge_base add_file delegation', () => { success: true, data: { added: [{ documentId: 'document-1', filename: 'report.pdf' }] }, }) - expect(mockResolveWorkspaceFileReference).toHaveBeenCalledWith( - expect.objectContaining({ workspaceId: 'workspace-paid', reference: 'files/report.pdf' }) - ) - const call = mockUploadKnowledgeDocument.mock.calls[0][0] + const call = mockAddWorkspaceFiles.mock.calls[0][0] expectDelegatedPrincipal(call) expect(call.input).toMatchObject({ knowledgeBaseId: KNOWLEDGE_BASE.id, assertedWorkspaceId: 'workspace-paid', - startProcessing: true, + fileReferences: ['files/report.pdf'], source: 'agent', - document: { filename: 'report.pdf', fileSize: 100, mimeType: 'application/pdf' }, }) - expect(call.input).not.toHaveProperty('usageAdmission') + expect(mockAddWorkspaceFiles).toHaveBeenCalledOnce() expect(mockKnowledgeBaseDocumentsUploaded).toHaveBeenCalledWith( expect.objectContaining({ knowledgeBaseId: KNOWLEDGE_BASE.id, documentsCount: 1 }) ) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'external-admin', + 'knowledge_base_document_uploaded', + expect.objectContaining({ knowledge_base_id: KNOWLEDGE_BASE.id }), + expect.any(Object) + ) }) - it('rejects files carrying resolved-secret provenance before durable registration', async () => { - mockGetBoundWorkspaceFileSecretProvenance.mockResolvedValueOnce({ - status: 'exact', - entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], + it('preserves explicit partial failures returned by the application command', async () => { + mockAddWorkspaceFiles.mockResolvedValueOnce({ + knowledgeBaseId: KNOWLEDGE_BASE.id, + knowledgeBaseName: KNOWLEDGE_BASE.name, + added: [], + failed: ['files/report.pdf'], }) const result = await knowledgeBaseServerTool.execute( @@ -455,6 +753,47 @@ describe('knowledge_base add_file delegation', () => { ) expect(result.success).toBe(false) - expect(mockUploadKnowledgeDocument).not.toHaveBeenCalled() + expect(result).toMatchObject({ data: { added: [], failed: ['files/report.pdf'] } }) + }) + + it('does not expose add-file infrastructure failures to the model', async () => { + mockAddWorkspaceFiles.mockRejectedValueOnce(new Error('storage host=private-bucket')) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'add_file', + args: { knowledgeBaseId: KNOWLEDGE_BASE.id, filePaths: ['files/report.pdf'] }, + }, + CONTEXT + ) + + expect(result).toEqual({ success: false, message: 'Failed to add_file knowledge base' }) + expect(result.message).not.toContain('private-bucket') + }) + + it('rechecks cancellation after application composition before presenting a partial result', async () => { + const controller = new AbortController() + mockAddWorkspaceFiles.mockImplementationOnce(async () => { + controller.abort('user stopped') + return { + knowledgeBaseId: KNOWLEDGE_BASE.id, + knowledgeBaseName: KNOWLEDGE_BASE.name, + added: [{ documentId: 'document-1', filename: 'report.pdf' }], + failed: [], + cancelled: true, + } + }) + + await expect( + knowledgeBaseServerTool.execute( + { + operation: 'add_file', + args: { knowledgeBaseId: KNOWLEDGE_BASE.id, filePaths: ['files/report.pdf'] }, + }, + { ...CONTEXT, userStopSignal: controller.signal } + ) + ).rejects.toThrow('Request aborted before knowledge mutation could be applied') + + expect(mockAddWorkspaceFiles).toHaveBeenCalledOnce() }) }) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index 924e77186d0..1f6de8b8938 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -1,20 +1,15 @@ -import { db } from '@sim/db' -import { knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { filterUndefined } from '@sim/utils/object' import { truncate } from '@sim/utils/string' -import { and, eq, isNull } from 'drizzle-orm' import { assertBillingAttributionSnapshot, type BillingAttributionSnapshot, } from '@/lib/billing/core/billing-attribution' import { + executeCopilotKnowledgeUseCase, messageForCopilotKnowledgeError, - resolveCopilotKnowledgePrincipal, + requireCopilotKnowledgeWorkspaceId, } from '@/lib/copilot/application/execute-knowledge-use-case' -import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { KnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { @@ -23,54 +18,38 @@ import { type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { projectServerToolModelInput } from '@/lib/copilot/tools/server/model-input' -import { - asOrchestrationError, - messageForOrchestrationError, - type OrchestrationErrorCode, -} from '@/lib/core/orchestration/types' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' +import { addWorkspaceFilesToKnowledgeBase } from '@/lib/knowledge/application/add-workspace-files' +import { MAX_KNOWLEDGE_BATCH_ITEMS } from '@/lib/knowledge/application/batch-policy' import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' import { - deleteKnowledgeDocument, - uploadKnowledgeDocument, + createKnowledgeConnector, + deleteKnowledgeConnector, + syncKnowledgeConnector, + updateKnowledgeConnector, +} from '@/lib/knowledge/application/connectors' +import { + bulkDeleteKnowledgeDocuments, + updateKnowledgeDocument, } from '@/lib/knowledge/application/documents' import { + bulkDeleteKnowledgeBases, createKnowledgeBase, - deleteKnowledgeBaseOperation, readKnowledgeBase, updateKnowledgeBaseOperation, } from '@/lib/knowledge/application/knowledge-bases' import { searchKnowledge } from '@/lib/knowledge/application/search' import { - performCreateKnowledgeConnector, - performDeleteKnowledgeConnector, - performSyncKnowledgeConnector, - performUpdateKnowledgeConnector, - performUpdateKnowledgeDocument, -} from '@/lib/knowledge/orchestration' -import { - createTagDefinition, - deleteTagDefinition, - getDocumentTagDefinitions, - getNextAvailableSlot, - getTagDefinitionById, - getTagUsageStats, - updateTagDefinition, -} from '@/lib/knowledge/tags/service' + createKnowledgeTag, + deleteKnowledgeTag, + listKnowledgeTags, + readKnowledgeTagUsage, + updateKnowledgeTag, +} from '@/lib/knowledge/application/tags' import { captureServerEvent } from '@/lib/posthog/server' -import { StorageService } from '@/lib/uploads' -import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' -import { fileOperations } from '@/lib/workspace-files/application/operations' -import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' -import { getCredential } from '@/app/api/auth/oauth/utils' -import { - checkDocumentWriteAccess, - checkKnowledgeBaseAccess, - checkKnowledgeBaseWriteAccess, -} from '@/app/api/knowledge/utils' const logger = createLogger('KnowledgeBaseServerTool') -const MAX_COPILOT_KNOWLEDGE_BATCH_SIZE = 100 function requireKnowledgeBillingAttribution( context: ServerToolContext, @@ -86,20 +65,7 @@ function requireKnowledgeBillingAttribution( return attribution } -/** - * The message the agent — and therefore the user — is shown for a failed - * operation. Mirrors `messageForOrchestrationError` on the HTTP surfaces: a - * classified failure is caller-fixable and safe to relay, an unclassified one - * carries whatever text the fault happened to have (a driver's failed SQL, say) - * and is replaced by the operation's own wording. - */ -function agentFacingError( - outcome: { error?: string; errorCode?: OrchestrationErrorCode }, - fallback: string -): string { - return messageForOrchestrationError(outcome, fallback) -} - +/** Records the existing Copilot product analytics after application success. */ function captureKnowledgeBaseCreated( userId: string, workspaceId: string, @@ -125,48 +91,133 @@ function captureKnowledgeBaseCreated( ) } -function captureKnowledgeDocumentUploaded( +function captureKnowledgeDocumentsUploaded( userId: string, workspaceId: string, knowledgeBaseId: string, - document: { mimeType: string; fileSize: number } + documents: readonly { mimeType: string; fileSize: number }[] +): void { + for (const document of documents) { + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId, + documentsCount: 1, + uploadType: 'single', + mimeType: document.mimeType, + fileSize: document.fileSize, + }) + captureServerEvent( + userId, + 'knowledge_base_document_uploaded', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: workspaceId, + document_count: 1, + upload_type: 'single', + }, + { + groups: { workspace: workspaceId }, + setOnce: { first_document_uploaded_at: new Date().toISOString() }, + } + ) + } +} + +function captureKnowledgeDocumentsDeleted( + userId: string, + workspaceId: string, + knowledgeBaseId: string, + count: number +): void { + for (let index = 0; index < count; index += 1) { + captureServerEvent( + userId, + 'knowledge_base_document_deleted', + { knowledge_base_id: knowledgeBaseId, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + } +} + +function captureKnowledgeConnectorAdded( + userId: string, + workspaceId: string, + knowledgeBaseId: string, + connectorType: string, + syncIntervalMinutes: number ): void { - PlatformEvents.knowledgeBaseDocumentsUploaded({ - knowledgeBaseId, - documentsCount: 1, - uploadType: 'single', - mimeType: document.mimeType, - fileSize: document.fileSize, - }) captureServerEvent( userId, - 'knowledge_base_document_uploaded', + 'knowledge_base_connector_added', { knowledge_base_id: knowledgeBaseId, workspace_id: workspaceId, - document_count: 1, - upload_type: 'single', + connector_type: connectorType, + sync_interval_minutes: syncIntervalMinutes, }, { groups: { workspace: workspaceId }, - setOnce: { first_document_uploaded_at: new Date().toISOString() }, + setOnce: { first_connector_added_at: new Date().toISOString() }, } ) } -function captureKnowledgeDocumentDeleted( +function captureKnowledgeConnectorRemoved( userId: string, workspaceId: string, - knowledgeBaseId: string + knowledgeBaseId: string, + connectorType: string, + documentsDeleted: number ): void { captureServerEvent( userId, - 'knowledge_base_document_deleted', - { knowledge_base_id: knowledgeBaseId, workspace_id: workspaceId }, + 'knowledge_base_connector_removed', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: workspaceId, + connector_type: connectorType, + documents_deleted: documentsDeleted, + }, { groups: { workspace: workspaceId } } ) } +function captureKnowledgeConnectorSynced( + userId: string, + workspaceId: string, + knowledgeBaseId: string, + connectorType: string +): void { + captureServerEvent( + userId, + 'knowledge_base_connector_synced', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: workspaceId, + connector_type: connectorType, + }, + { groups: { workspace: workspaceId } } + ) +} + +function applicationFailureFallback(operation: string): string | null { + switch (operation) { + case 'update_document': + return 'Failed to update document' + case 'create_tag': + return 'Failed to create tag' + case 'add_connector': + return 'Failed to add connector' + case 'update_connector': + return 'Failed to update connector' + case 'delete_connector': + return 'Failed to delete connector' + case 'sync_connector': + return 'Failed to sync connector' + default: + return null + } +} + type KnowledgeBaseArgs = { operation: string args?: Record<string, any> @@ -188,26 +239,13 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg context?: ServerToolContext ): Promise<KnowledgeBaseResult> { if (!context) throw new Error('Knowledge delegation requires a Copilot execution context') - const principal = resolveCopilotKnowledgePrincipal(context) const { operation, args = {} } = params - const workspaceId = principal.workspaceId + const workspaceId = requireCopilotKnowledgeWorkspaceId(context) const assertNotAborted = () => assertServerToolNotAborted( context, 'Request aborted before knowledge mutation could be applied.' ) - /** - * The acting agent, as every knowledge orchestration function expects it. - * `source: 'agent'` is what makes an agent-driven mutation distinguishable in - * the audit log — before these operations went through orchestration they - * were not recorded there at all. - */ - const actor = (requestId: string) => ({ - userId: context.userId as string, - source: 'agent' as const, - requestId, - }) - try { switch (operation) { case 'create': { @@ -226,16 +264,17 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg } assertNotAborted() - const { knowledgeBase: newKnowledgeBase } = await createKnowledgeBase.execute({ - principal, - input: { + const { knowledgeBase: newKnowledgeBase } = await executeCopilotKnowledgeUseCase( + context, + createKnowledgeBase, + { workspaceId, name: args.name, description: args.description, chunkingConfig: args.chunkingConfig, source: 'agent', - }, - }) + } + ) captureKnowledgeBaseCreated(context.userId, workspaceId, newKnowledgeBase) return { success: true, @@ -259,13 +298,14 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg } } - const { knowledgeBase } = await readKnowledgeBase.execute({ - principal, - input: { + const { knowledgeBase } = await executeCopilotKnowledgeUseCase( + context, + readKnowledgeBase, + { knowledgeBaseId: args.knowledgeBaseId, assertedWorkspaceId: workspaceId, - }, - }) + } + ) logger.info('Knowledge base metadata retrieved via copilot', { knowledgeBaseId: knowledgeBase.id, @@ -314,24 +354,17 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg 'Failed to query knowledge base: Knowledge result secret provenance is unavailable', } } - const { knowledgeBase: kb } = await readKnowledgeBase.execute({ - principal, - input: { - knowledgeBaseId: args.knowledgeBaseId, - assertedWorkspaceId: workspaceId, - }, - }) - const searchResult = await searchKnowledge.execute({ - principal, - input: { - workspaceId, - knowledgeBaseIds: [args.knowledgeBaseId], - query: modelQuery, - topK, - resultSecretRegistry: context.resolvedSecretTraceRegistry, - }, + const searchResult = await executeCopilotKnowledgeUseCase(context, searchKnowledge, { + workspaceId, + knowledgeBaseIds: [args.knowledgeBaseId], + query: modelQuery, + topK, + resultSecretRegistry: context.resolvedSecretTraceRegistry, }) const results = searchResult.results + const knowledgeBase = searchResult.knowledgeBases[0] + if (!knowledgeBase) + throw new Error('Knowledge search returned no canonical knowledge base') logger.info('Knowledge base queried via copilot', { knowledgeBaseIds: [args.knowledgeBaseId], @@ -345,7 +378,7 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg message: `Found ${results.length} result(s) for query "${truncate(args.query, 50)}"`, data: { knowledgeBaseId: args.knowledgeBaseId, - knowledgeBaseName: kb.name, + knowledgeBaseName: knowledgeBase.name, query: args.query, topK, totalResults: results.length, @@ -378,108 +411,49 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg 'filePaths is required for add_file. Use canonical VFS file paths from glob("files/**").', } } - if (fileRefs.length > MAX_COPILOT_KNOWLEDGE_BATCH_SIZE) { + if (fileRefs.length > MAX_KNOWLEDGE_BATCH_ITEMS) { return { success: false, - message: `Too many files (${fileRefs.length}). Maximum is ${MAX_COPILOT_KNOWLEDGE_BATCH_SIZE}.`, + message: `Too many files (${fileRefs.length}). Maximum is ${MAX_KNOWLEDGE_BATCH_ITEMS}.`, } } - const { knowledgeBase: targetKb } = await readKnowledgeBase.execute({ - principal, - input: { + assertNotAborted() + const outcome = await executeCopilotKnowledgeUseCase( + context, + addWorkspaceFilesToKnowledgeBase, + { knowledgeBaseId: args.knowledgeBaseId, assertedWorkspaceId: workspaceId, - }, - }) - - const added: Array<{ documentId: string; filename: string }> = [] - const failedFiles: string[] = [] - const filePrincipal = resolveCopilotFilePrincipal(context) - - for (const fileRef of fileRefs) { - let fileRecord - try { - fileRecord = await resolveWorkspaceFileReference({ - principal: filePrincipal, - operation: fileOperations.readContent, - workspaceId, - reference: fileRef, - }) - } catch (error) { - const classified = asOrchestrationError(error) - if (classified && classified.code !== 'internal') { - failedFiles.push(fileRef) - continue - } - throw error - } - - const fileProvenance = await getBoundWorkspaceFileSecretProvenance(workspaceId, { - fileId: fileRecord.id, - key: fileRecord.key, - context: 'workspace', - }) - if (fileProvenance.status !== 'exact' || fileProvenance.entries.length > 0) { - failedFiles.push(fileRef) - continue - } - - const presignedUrl = await StorageService.generatePresignedDownloadUrl( - fileRecord.key, - 'workspace', - 5 * 60 - ) - - assertNotAborted() - try { - const outcome = await uploadKnowledgeDocument.execute({ - principal, - input: { - knowledgeBaseId: args.knowledgeBaseId, - assertedWorkspaceId: workspaceId, - document: { - filename: fileRecord.name, - fileUrl: presignedUrl, - fileSize: fileRecord.size, - mimeType: fileRecord.type, - }, - startProcessing: true, - source: 'agent', - }, - }) - captureKnowledgeDocumentUploaded( - context.userId, - workspaceId, - args.knowledgeBaseId, - outcome.document - ) - added.push({ documentId: outcome.document.id, filename: fileRecord.name }) - } catch (error) { - if (error instanceof KnowledgeUsageLimitExceededError) { - return { success: false, message: error.message } - } - const classified = asOrchestrationError(error) - if (classified && classified.code !== 'internal') { - failedFiles.push(fileRef) - continue - } - throw error + fileReferences: fileRefs, + cancellationSignal: context.userStopSignal, + source: 'agent', } - } + ) + captureKnowledgeDocumentsUploaded( + context.userId, + workspaceId, + outcome.knowledgeBaseId, + outcome.added + ) + assertNotAborted() - const addedNames = added.map((a) => a.filename).join(', ') + const added = outcome.added.map(({ documentId, filename }) => ({ + documentId, + filename, + })) + const addedNames = added.map((item) => item.filename).join(', ') return { success: added.length > 0, message: added.length > 0 - ? `Added ${added.length} file(s) to "${targetKb.name}": ${addedNames}. Processing started.` + ? `Added ${added.length} file(s) to "${outcome.knowledgeBaseName}": ${addedNames}. Processing started.` : `No files could be added.`, data: { knowledgeBaseId: args.knowledgeBaseId, - knowledgeBaseName: targetKb.name, + knowledgeBaseName: outcome.knowledgeBaseName, added, - failed: failedFiles, + failed: outcome.failed, }, } } @@ -510,15 +484,16 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg } assertNotAborted() - const { knowledgeBase: updatedKb } = await updateKnowledgeBaseOperation.execute({ - principal, - input: { + const { knowledgeBase: updatedKb } = await executeCopilotKnowledgeUseCase( + context, + updateKnowledgeBaseOperation, + { knowledgeBaseId: args.knowledgeBaseId, assertedWorkspaceId: workspaceId, ...updates, source: 'agent', - }, - }) + } + ) return { success: true, message: `Knowledge base "${updatedKb.name}" updated successfully`, @@ -542,58 +517,25 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg message: 'knowledgeBaseId or knowledgeBaseIds is required for delete operation', } } - if (kbIds.length > MAX_COPILOT_KNOWLEDGE_BATCH_SIZE) { + if (kbIds.length > MAX_KNOWLEDGE_BATCH_ITEMS) { return { success: false, - message: `Too many knowledge base IDs (${kbIds.length}). Maximum is ${MAX_COPILOT_KNOWLEDGE_BATCH_SIZE}.`, + message: `Too many knowledge base IDs (${kbIds.length}). Maximum is ${MAX_KNOWLEDGE_BATCH_ITEMS}.`, } } - const deleted: Array<{ id: string; name: string }> = [] - const notFound: string[] = [] - // A knowledge base that exists but could not be archived is neither - // deleted nor missing. Folding it into `notFound` told the user it was - // never there instead of why the delete failed. - const failed: Array<{ id: string; name: string; reason: string }> = [] - - for (const kbId of kbIds) { - let knowledgeBaseName = kbId - try { - const readResult = await readKnowledgeBase.execute({ - principal, - input: { knowledgeBaseId: kbId, assertedWorkspaceId: workspaceId }, - }) - knowledgeBaseName = readResult.knowledgeBase.name - assertNotAborted() - const deletedKnowledgeBase = await deleteKnowledgeBaseOperation.execute({ - principal, - input: { - knowledgeBaseId: kbId, - assertedWorkspaceId: workspaceId, - source: 'agent', - }, - }) - PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: kbId }) - deleted.push(deletedKnowledgeBase) - } catch (error) { - const classified = asOrchestrationError(error) - if ( - classified?.code === 'not_found' || - classified?.code === 'forbidden' || - classified?.code === 'unauthorized' - ) { - notFound.push(kbId) - } else if (classified && classified.code !== 'internal') { - failed.push({ - id: kbId, - name: knowledgeBaseName, - reason: classified.message, - }) - } else { - throw error - } + assertNotAborted() + const { deleted, notFound, failed } = await executeCopilotKnowledgeUseCase( + context, + bulkDeleteKnowledgeBases, + { + assertedWorkspaceId: workspaceId, + knowledgeBaseIds: kbIds, + cancellationSignal: context.userStopSignal, + source: 'agent', } - } + ) + assertNotAborted() const deleteSummary = [ deleted.length > 0 ? `Deleted: ${deleted.map((d) => d.name).join(', ')}` : null, @@ -622,44 +564,34 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg message: 'documentId or documentIds is required for delete_document', } } - if (docIds.length > MAX_COPILOT_KNOWLEDGE_BATCH_SIZE) { + if (docIds.length > MAX_KNOWLEDGE_BATCH_ITEMS) { return { success: false, - message: `Too many document IDs (${docIds.length}). Maximum is ${MAX_COPILOT_KNOWLEDGE_BATCH_SIZE}.`, + message: `Too many document IDs (${docIds.length}). Maximum is ${MAX_KNOWLEDGE_BATCH_ITEMS}.`, } } - const deleted: string[] = [] - const failed: string[] = [] - - for (const docId of docIds) { - assertNotAborted() - try { - await deleteKnowledgeDocument.execute({ - principal, - input: { - knowledgeBaseId: args.knowledgeBaseId, - documentId: docId, - assertedWorkspaceId: workspaceId, - source: 'agent', - }, - }) - captureKnowledgeDocumentDeleted(context.userId, workspaceId, args.knowledgeBaseId) - deleted.push(docId) - } catch (error) { - const classified = asOrchestrationError(error) - if (classified && classified.code !== 'internal') { - failed.push(docId) - continue - } - throw error - } - } + assertNotAborted() + const { knowledgeBaseId, deleted, deletedDocuments, failed } = + await executeCopilotKnowledgeUseCase(context, bulkDeleteKnowledgeDocuments, { + knowledgeBaseId: args.knowledgeBaseId, + documentIds: docIds, + assertedWorkspaceId: workspaceId, + cancellationSignal: context.userStopSignal, + source: 'agent', + }) + captureKnowledgeDocumentsDeleted( + context.userId, + workspaceId, + knowledgeBaseId, + deletedDocuments.length + ) + assertNotAborted() return { success: deleted.length > 0, message: `Deleted ${deleted.length} document(s)${failed.length > 0 ? `, ${failed.length} failed` : ''}`, - data: { knowledgeBaseId: args.knowledgeBaseId, deleted, failed }, + data: { knowledgeBaseId, deleted, failed }, } } @@ -683,35 +615,14 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg message: 'At least one of filename or enabled is required for update_document', } } - const docAccess = await checkDocumentWriteAccess( - args.knowledgeBaseId, - args.documentId, - context.userId - ) - if (!docAccess.hasAccess) { - return { - success: false, - message: `Document with ID "${args.documentId}" not found`, - } - } - const requestId = generateId().slice(0, 8) assertNotAborted() - const outcome = await performUpdateKnowledgeDocument({ - ...actor(requestId), - knowledgeBase: { - id: args.knowledgeBaseId, - name: docAccess.knowledgeBase.name, - workspaceId: docAccess.knowledgeBase.workspaceId ?? null, - }, - document: docAccess.document, - updates: updateData, + await executeCopilotKnowledgeUseCase(context, updateKnowledgeDocument, { + knowledgeBaseId: args.knowledgeBaseId, + documentId: args.documentId, + assertedWorkspaceId: workspaceId, + ...updateData, + source: 'agent', }) - if (!outcome.success) { - return { - success: false, - message: agentFacingError(outcome, 'Failed to update document'), - } - } return { success: true, @@ -732,15 +643,14 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg } } - const access = await checkKnowledgeBaseAccess(args.knowledgeBaseId, context.userId) - if (!access.hasAccess) { - return { - success: false, - message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`, + const { tagDefinitions } = await executeCopilotKnowledgeUseCase( + context, + listKnowledgeTags, + { + knowledgeBaseId: args.knowledgeBaseId, + assertedWorkspaceId: workspaceId, } - } - - const tagDefinitions = await getDocumentTagDefinitions(args.knowledgeBaseId) + ) logger.info('Tag definitions listed via copilot', { knowledgeBaseId: args.knowledgeBaseId, @@ -775,37 +685,18 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg } } - const writeAccess = await checkKnowledgeBaseWriteAccess( - args.knowledgeBaseId, - context.userId - ) - if (!writeAccess.hasAccess) { - return { - success: false, - message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`, - } - } - const fieldType = args.tagFieldType || 'text' - - const tagSlot = await getNextAvailableSlot(args.knowledgeBaseId, fieldType) - if (!tagSlot) { - return { - success: false, - message: `No available slots for field type "${fieldType}". Maximum tags of this type reached.`, - } - } - - const requestId = generateId().slice(0, 8) assertNotAborted() - const newTag = await createTagDefinition( + const { tagDefinition: newTag } = await executeCopilotKnowledgeUseCase( + context, + createKnowledgeTag, { knowledgeBaseId: args.knowledgeBaseId, - tagSlot, displayName: args.tagDisplayName, fieldType, - }, - requestId + assertedWorkspaceId: workspaceId, + source: 'agent', + } ) logger.info('Tag definition created via copilot', { @@ -847,32 +738,18 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg } } - const existingTag = await getTagDefinitionById(args.tagDefinitionId) - if (!existingTag) { - return { - success: false, - message: `Tag definition with ID "${args.tagDefinitionId}" not found`, - } - } - - const writeAccess = await checkKnowledgeBaseWriteAccess( - existingTag.knowledgeBaseId, - context.userId - ) - if (!writeAccess.hasAccess) { - return { - success: false, - message: `Tag definition with ID "${args.tagDefinitionId}" not found`, - } - } - - const requestId = generateId().slice(0, 8) assertNotAborted() - const updatedTag = await updateTagDefinition(args.tagDefinitionId, updateData, requestId) + const { tagDefinition: updatedTag, knowledgeBaseId } = + await executeCopilotKnowledgeUseCase(context, updateKnowledgeTag, { + tagDefinitionId: args.tagDefinitionId, + assertedWorkspaceId: workspaceId, + updates: updateData, + source: 'agent', + }) logger.info('Tag definition updated via copilot', { tagId: args.tagDefinitionId, - knowledgeBaseId: existingTag.knowledgeBaseId, + knowledgeBaseId, userId: context.userId, }) @@ -881,7 +758,7 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg message: `Tag "${updatedTag.displayName}" updated successfully`, data: { id: updatedTag.id, - knowledgeBaseId: existingTag.knowledgeBaseId, + knowledgeBaseId, tagSlot: updatedTag.tagSlot, displayName: updatedTag.displayName, fieldType: updatedTag.fieldType, @@ -903,24 +780,13 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg } } - const writeAccess = await checkKnowledgeBaseWriteAccess( - args.knowledgeBaseId, - context.userId - ) - if (!writeAccess.hasAccess) { - return { - success: false, - message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`, - } - } - - const requestId = generateId().slice(0, 8) assertNotAborted() - const deleted = await deleteTagDefinition( - args.knowledgeBaseId, - args.tagDefinitionId, - requestId - ) + const deleted = await executeCopilotKnowledgeUseCase(context, deleteKnowledgeTag, { + knowledgeBaseId: args.knowledgeBaseId, + tagDefinitionId: args.tagDefinitionId, + assertedWorkspaceId: workspaceId, + source: 'agent', + }) logger.info('Tag definition deleted via copilot', { tagId: args.tagDefinitionId, @@ -948,16 +814,14 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg } } - const access = await checkKnowledgeBaseAccess(args.knowledgeBaseId, context.userId) - if (!access.hasAccess) { - return { - success: false, - message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`, + const { usage: stats } = await executeCopilotKnowledgeUseCase( + context, + readKnowledgeTagUsage, + { + knowledgeBaseId: args.knowledgeBaseId, + assertedWorkspaceId: workspaceId, } - } - - const requestId = generateId().slice(0, 8) - const stats = await getTagUsageStats(args.knowledgeBaseId, requestId) + ) return { success: true, @@ -981,57 +845,32 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg } } - const writeAccess = await checkKnowledgeBaseWriteAccess( - args.knowledgeBaseId, - context.userId - ) - if (!writeAccess.hasAccess) { - return { - success: false, - message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`, - } - } - const connectorWorkspaceId = writeAccess.knowledgeBase.workspaceId - if (!connectorWorkspaceId) { - return { - success: false, - message: `Knowledge base with ID "${args.knowledgeBaseId}" has no workspace billing context`, - } - } - const billingAttribution = requireKnowledgeBillingAttribution( - context, - connectorWorkspaceId - ) - const sourceConfig: Record<string, unknown> = { ...(args.sourceConfig ?? {}) } if (args.disabledTagIds?.length) { sourceConfig.disabledTagIds = args.disabledTagIds } - const requestId = generateId().slice(0, 8) assertNotAborted() - const outcome = await performCreateKnowledgeConnector({ - ...actor(requestId), - knowledgeBase: { - id: args.knowledgeBaseId, - name: writeAccess.knowledgeBase.name, - workspaceId: connectorWorkspaceId, - }, - connectorType: args.connectorType, - credentialId: args.credentialId, - apiKey: args.apiKey, - sourceConfig, - syncIntervalMinutes: args.syncIntervalMinutes ?? 1440, - resolveBillingAttribution: async () => billingAttribution, - resolveAccessToken: async (credentialId) => - (await getCredential(requestId, credentialId, context.userId as string)) - ?.accessToken ?? null, - }) - if (!outcome.success) { - return { success: false, message: agentFacingError(outcome, 'Failed to add connector') } - } - - const connector = outcome.connector + const { connector, workspaceId: canonicalWorkspaceId } = + await executeCopilotKnowledgeUseCase(context, createKnowledgeConnector, { + knowledgeBaseId: args.knowledgeBaseId, + assertedWorkspaceId: workspaceId, + connectorType: args.connectorType, + credentialId: args.credentialId, + apiKey: args.apiKey, + sourceConfig, + syncIntervalMinutes: args.syncIntervalMinutes ?? 1440, + resolveBillingAttribution: async (billingWorkspaceId) => + requireKnowledgeBillingAttribution(context, billingWorkspaceId), + source: 'agent', + }) + captureKnowledgeConnectorAdded( + context.userId, + canonicalWorkspaceId, + connector.knowledgeBaseId, + connector.connectorType, + connector.syncIntervalMinutes + ) return { success: true, message: `Connector "${args.connectorType}" added to knowledge base. Initial sync started.`, @@ -1039,7 +878,7 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg id: connector.id, connectorType: connector.connectorType, status: connector.status, - knowledgeBaseId: args.knowledgeBaseId, + knowledgeBaseId: connector.knowledgeBaseId, }, } } @@ -1049,48 +888,31 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg return { success: false, message: 'connectorId is required for update_connector' } } - const kbId = await resolveKnowledgeBaseId(args.connectorId) - if (!kbId) { - return { success: false, message: `Connector "${args.connectorId}" not found` } - } - - const writeAccess = await checkKnowledgeBaseWriteAccess(kbId, context.userId) - if (!writeAccess.hasAccess) { - return { success: false, message: `Connector "${args.connectorId}" not found` } - } - const updates = { sourceConfig: args.sourceConfig, syncIntervalMinutes: args.syncIntervalMinutes, status: args.connectorStatus, } - const requestId = generateId().slice(0, 8) assertNotAborted() - // No `validateSourceConfig`: the agent has no requesting identity to - // resolve the connector's OAuth token with, so a replacement config is - // stored unvalidated and the next sync reports any problem with it. - const outcome = await performUpdateKnowledgeConnector({ - ...actor(requestId), - knowledgeBase: { - id: kbId, - name: writeAccess.knowledgeBase.name, - workspaceId: writeAccess.knowledgeBase.workspaceId ?? null, - }, + await executeCopilotKnowledgeUseCase(context, updateKnowledgeConnector, { connectorId: args.connectorId, + assertedWorkspaceId: workspaceId, updates, + source: 'agent', }) - if (!outcome.success) { - return { - success: false, - message: agentFacingError(outcome, 'Failed to update connector'), - } - } return { success: true, message: 'Connector updated successfully', - data: { id: args.connectorId, ...filterUndefined(updates) }, + data: { + id: args.connectorId, + ...(updates.sourceConfig !== undefined && { sourceConfig: updates.sourceConfig }), + ...(updates.syncIntervalMinutes !== undefined && { + syncIntervalMinutes: updates.syncIntervalMinutes, + }), + ...(updates.status !== undefined && { status: updates.status }), + }, } } @@ -1099,33 +921,19 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg return { success: false, message: 'connectorId is required for delete_connector' } } - const deleteKbId = await resolveKnowledgeBaseId(args.connectorId) - if (!deleteKbId) { - return { success: false, message: `Connector "${args.connectorId}" not found` } - } - - const writeAccess = await checkKnowledgeBaseWriteAccess(deleteKbId, context.userId) - if (!writeAccess.hasAccess) { - return { success: false, message: `Connector "${args.connectorId}" not found` } - } - - const requestId = generateId().slice(0, 8) assertNotAborted() - const outcome = await performDeleteKnowledgeConnector({ - ...actor(requestId), - knowledgeBase: { - id: deleteKbId, - name: writeAccess.knowledgeBase.name, - workspaceId: writeAccess.knowledgeBase.workspaceId ?? null, - }, + const outcome = await executeCopilotKnowledgeUseCase(context, deleteKnowledgeConnector, { connectorId: args.connectorId, + assertedWorkspaceId: workspaceId, + source: 'agent', }) - if (!outcome.success) { - return { - success: false, - message: agentFacingError(outcome, 'Failed to delete connector'), - } - } + captureKnowledgeConnectorRemoved( + context.userId, + outcome.workspaceId, + outcome.knowledgeBaseId, + outcome.connectorType, + outcome.documentsDeleted + ) // Report what the delete actually did. The documents are kept — this // used to claim they had been removed, which was never true on this @@ -1150,45 +958,20 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg return { success: false, message: 'connectorId is required for sync_connector' } } - const syncKbId = await resolveKnowledgeBaseId(args.connectorId) - if (!syncKbId) { - return { success: false, message: `Connector "${args.connectorId}" not found` } - } - - const writeAccess = await checkKnowledgeBaseWriteAccess(syncKbId, context.userId) - if (!writeAccess.hasAccess) { - return { success: false, message: `Connector "${args.connectorId}" not found` } - } - const connectorWorkspaceId = writeAccess.knowledgeBase.workspaceId - if (!connectorWorkspaceId) { - return { - success: false, - message: `Connector "${args.connectorId}" has no workspace billing context`, - } - } - const billingAttribution = requireKnowledgeBillingAttribution( - context, - connectorWorkspaceId - ) - - const requestId = generateId().slice(0, 8) assertNotAborted() - const outcome = await performSyncKnowledgeConnector({ - ...actor(requestId), - knowledgeBase: { - id: syncKbId, - name: writeAccess.knowledgeBase.name, - workspaceId: connectorWorkspaceId, - }, + const outcome = await executeCopilotKnowledgeUseCase(context, syncKnowledgeConnector, { connectorId: args.connectorId, - resolveBillingAttribution: async () => billingAttribution, + assertedWorkspaceId: workspaceId, + resolveBillingAttribution: async (canonicalWorkspaceId) => + requireKnowledgeBillingAttribution(context, canonicalWorkspaceId), + source: 'agent', }) - if (!outcome.success) { - return { - success: false, - message: agentFacingError(outcome, 'Failed to sync connector'), - } - } + captureKnowledgeConnectorSynced( + context.userId, + outcome.workspaceId, + outcome.knowledgeBaseId, + outcome.connectorType + ) return { success: true, @@ -1221,40 +1004,39 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg if (error instanceof KnowledgeUsageLimitExceededError) { return { success: false, message: error.message } } + if (context.userStopSignal?.aborted) throw error const classified = asOrchestrationError(error) - if (!classified || classified.code === 'internal') throw error - if ( - (classified.code === 'not_found' || classified.code === 'forbidden') && - args.knowledgeBaseId - ) { - return { - success: false, - message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`, + if (classified?.code === 'not_found' || classified?.code === 'forbidden') { + if (args.connectorId) { + return { success: false, message: `Connector "${args.connectorId}" not found` } + } + if (args.tagDefinitionId) { + return { + success: false, + message: `Tag definition with ID "${args.tagDefinitionId}" not found`, + } + } + if (args.documentId) { + return { + success: false, + message: `Document with ID "${args.documentId}" not found`, + } + } + if (args.knowledgeBaseId) { + return { + success: false, + message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`, + } } } + const directFallback = applicationFailureFallback(operation) + const fallback = directFallback ?? `Failed to ${operation} knowledge base` + const safeMessage = messageForCopilotKnowledgeError(error, fallback) return { success: false, - message: `Failed to ${operation} knowledge base: ${messageForCopilotKnowledgeError( - error, - `Failed to ${operation} knowledge base` - )}`, + message: + directFallback || safeMessage === fallback ? safeMessage : `${fallback}: ${safeMessage}`, } } }, } - -async function resolveKnowledgeBaseId(connectorId: string): Promise<string | null> { - const rows = await db - .select({ knowledgeBaseId: knowledgeConnector.knowledgeBaseId }) - .from(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.id, connectorId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - .limit(1) - - return rows[0]?.knowledgeBaseId ?? null -} diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 1458b9249d4..60f9f033c6e 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -5,8 +5,6 @@ import { chat as chatTable, customTools as customToolsTable, folder as folderTable, - knowledgeBaseTagDefinitions, - knowledgeConnector, mcpServers as mcpServersTable, skill as skillTable, workflowDeploymentVersion, @@ -62,11 +60,7 @@ import { canonicalWorkspaceFilePath, encodeVfsPathSegments, } from '@/lib/copilot/vfs/path-utils' -import type { - DeploymentData, - KbTagDefinitionSummary, - VfsServiceAccountAuth, -} from '@/lib/copilot/vfs/serializers' +import type { DeploymentData, VfsServiceAccountAuth } from '@/lib/copilot/vfs/serializers' import { describeServiceAccountForOAuthProvider, serializeApiKeyIntegrations, @@ -117,9 +111,12 @@ import { isOAuthServiceDeploymentAvailable, } from '@/lib/integrations/availability.server' import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' +import { listKnowledgeConnectors } from '@/lib/knowledge/application/connectors' import { listKnowledgeDocuments } from '@/lib/knowledge/application/documents' -import { listKnowledgeBases } from '@/lib/knowledge/application/knowledge-bases' -import { getKnowledgeBases as getLegacyKnowledgeBases } from '@/lib/knowledge/service' +import { + listArchivedKnowledgeBases, + listKnowledgeBaseCatalog, +} from '@/lib/knowledge/application/knowledge-bases' import { validateMermaidSource } from '@/lib/mermaid/validate' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' @@ -851,7 +848,7 @@ export class WorkspaceVFS { this.files.set('WORKSPACE.md', buildWorkspaceMd(workspaceMdData)) this.files.set('WORKSPACE_CONTEXT.md', buildWorkspaceContextMd(workspaceMdData)) - await timed('recently_deleted', this.materializeRecentlyDeleted(workspaceId, userId)) + await timed('recently_deleted', this.materializeRecentlyDeleted(workspaceId)) // Per-viewer gating happens HERE, not in the shared builder: files // owned by blocks hidden for this viewer are skipped at stamp time. @@ -1064,6 +1061,13 @@ export class WorkspaceVFS { return this.filePrincipal } + private requireKnowledgePrincipal(): Principal { + if (!this.knowledgePrincipal) { + throw new Error('Workspace Knowledge reads require a trusted Copilot principal') + } + return this.knowledgePrincipal + } + /** * Renders a renderable doc (pptx/docx/pdf) record to a contact-sheet image and * returns it as a model readable JPEG attachment. Shared by the `/render` and @@ -1747,18 +1751,13 @@ export class WorkspaceVFS { private async materializeKnowledgeBases( workspaceId: string ): Promise<WorkspaceMdData['knowledgeBases']> { - if (!this.knowledgePrincipal) { - throw new Error('Workspace VFS knowledge materialization requires a trusted principal') - } - const { knowledgeBases } = await listKnowledgeBases.execute({ - principal: this.knowledgePrincipal, + const { knowledgeBases } = await listKnowledgeBaseCatalog.execute({ + principal: this.requireKnowledgePrincipal(), input: { workspaceId }, }) const kbs = knowledgeBases.map(({ knowledgeBase }) => knowledgeBase) - const tagDefinitionsByKb = await this.loadKbTagDefinitions(kbs.map((kb) => kb.id)) - - for (const kb of kbs) { + for (const { knowledgeBase: kb, tagDefinitions } of knowledgeBases) { const safeName = sanitizeName(kb.name) const prefix = `knowledgebases/${safeName}/` @@ -1775,7 +1774,11 @@ export class WorkspaceVFS { updatedAt: kb.updatedAt, documentCount: kb.docCount, connectorTypes: kb.connectorTypes, - tagDefinitions: tagDefinitionsByKb.get(kb.id), + tagDefinitions: tagDefinitions.map((definition) => ({ + tagName: definition.displayName, + tagSlot: definition.tagSlot, + fieldType: definition.fieldType, + })), }) ) @@ -1784,9 +1787,6 @@ export class WorkspaceVFS { // a read/glob, only when the artifact is read or grepped. if (kb.docCount > 0) { this.registerLazy(`${prefix}documents.json`, async () => { - if (!this.knowledgePrincipal) { - throw new Error('Workspace VFS knowledge document read requires a trusted principal') - } if (kb.docCount > MAX_VFS_KNOWLEDGE_DOCUMENTS) { throw new Error( `Knowledge base ${kb.id} has more than ${MAX_VFS_KNOWLEDGE_DOCUMENTS} documents; documents.json cannot be materialized` @@ -1797,7 +1797,7 @@ export class WorkspaceVFS { let offset = 0 while (true) { const page = await listKnowledgeDocuments.execute({ - principal: this.knowledgePrincipal, + principal: this.requireKnowledgePrincipal(), input: { knowledgeBaseId: kb.id, assertedWorkspaceId: workspaceId, @@ -1831,28 +1831,10 @@ export class WorkspaceVFS { if (kb.connectorTypes.length > 0) { this.registerLazy(`${prefix}connectors.json`, async () => { - const connectorRows = await db - .select({ - id: knowledgeConnector.id, - connectorType: knowledgeConnector.connectorType, - status: knowledgeConnector.status, - syncMode: knowledgeConnector.syncMode, - syncIntervalMinutes: knowledgeConnector.syncIntervalMinutes, - lastSyncAt: knowledgeConnector.lastSyncAt, - lastSyncError: knowledgeConnector.lastSyncError, - lastSyncDocCount: knowledgeConnector.lastSyncDocCount, - nextSyncAt: knowledgeConnector.nextSyncAt, - consecutiveFailures: knowledgeConnector.consecutiveFailures, - createdAt: knowledgeConnector.createdAt, - }) - .from(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.knowledgeBaseId, kb.id), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) + const { connectors: connectorRows } = await listKnowledgeConnectors.execute({ + principal: this.requireKnowledgePrincipal(), + input: { knowledgeBaseId: kb.id, assertedWorkspaceId: workspaceId }, + }) return connectorRows.length > 0 ? serializeConnectors(connectorRows) : null }) } @@ -1866,61 +1848,6 @@ export class WorkspaceVFS { })) } - /** - * Load tag definitions for the given knowledge bases in a single query, grouped by - * KB id and ordered by tag slot. Surfaced inline in each KB's meta.json so the agent - * knows which tags exist (and their slot binding) when editing a knowledge-tag filter. - * - * @remarks - * Tag definitions are an optional enrichment, so a query failure degrades to a meta.json - * without them rather than rejecting. This materializer runs inside the top-level - * `Promise.all`, whose rejection would fail the entire workspace VFS build and leave the - * agent unable to read any file. - */ - private async loadKbTagDefinitions( - kbIds: string[] - ): Promise<Map<string, KbTagDefinitionSummary[]>> { - const byKb = new Map<string, KbTagDefinitionSummary[]>() - if (kbIds.length === 0) return byKb - - let rows: Array<{ - knowledgeBaseId: string - tagSlot: string - displayName: string - fieldType: string - }> - try { - rows = await db - .select({ - knowledgeBaseId: knowledgeBaseTagDefinitions.knowledgeBaseId, - tagSlot: knowledgeBaseTagDefinitions.tagSlot, - displayName: knowledgeBaseTagDefinitions.displayName, - fieldType: knowledgeBaseTagDefinitions.fieldType, - }) - .from(knowledgeBaseTagDefinitions) - .where(inArray(knowledgeBaseTagDefinitions.knowledgeBaseId, kbIds)) - .orderBy(knowledgeBaseTagDefinitions.tagSlot) - } catch (err) { - logger.warn('Failed to load knowledge base tag definitions', { - error: toError(err).message, - }) - return byKb - } - - for (const row of rows) { - const entry = { - tagName: row.displayName, - tagSlot: row.tagSlot, - fieldType: row.fieldType, - } - const existing = byKb.get(row.knowledgeBaseId) - if (existing) existing.push(entry) - else byKb.set(row.knowledgeBaseId, [entry]) - } - - return byKb - } - /** * Materialize tables using the shared listTables function. * Returns a summary for WORKSPACE.md generation. @@ -2331,7 +2258,7 @@ export class WorkspaceVFS { return [] } } - private async materializeRecentlyDeleted(workspaceId: string, userId: string): Promise<void> { + private async materializeRecentlyDeleted(workspaceId: string): Promise<void> { try { const [ archivedWorkflows, @@ -2369,7 +2296,12 @@ export class WorkspaceVFS { input: { workspaceId, scope: 'archived' }, }) .then(({ folders }) => folders), - getLegacyKnowledgeBases(userId, workspaceId, 'archived'), + listArchivedKnowledgeBases + .execute({ + principal: this.requireKnowledgePrincipal(), + input: { workspaceId }, + }) + .then(({ knowledgeBases }) => knowledgeBases), ]) for (const wf of archivedWorkflows) { diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index d6b678d4a17..a2f6dfa8334 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -95,7 +95,7 @@ async function buildAtlassianServiceAccountSecret( ...(validation.emailAddress ? { label: validation.emailAddress } : {}), } // `atlassianAccountId` stays at the blob's top level: `getAtlassianServiceAccountSecret` - // in `app/api/auth/oauth/utils.ts` reads it there on every existing credential. + // in `lib/oauth/credential-service.ts` reads it there on every existing credential. const blob = JSON.stringify({ type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, apiToken, diff --git a/apps/sim/lib/guardrails/validate_hallucination.ts b/apps/sim/lib/guardrails/validate_hallucination.ts index d97cf94bf8a..f61b0c7ee13 100644 --- a/apps/sim/lib/guardrails/validate_hallucination.ts +++ b/apps/sim/lib/guardrails/validate_hallucination.ts @@ -20,7 +20,7 @@ import { RESOLVED_SECRET_PROVENANCE_FIELD, RESOLVED_SECRET_PROVENANCE_METADATA_V1, } from '@/lib/execution/private-tool-metadata' -import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshTokenIfNeeded } from '@/lib/oauth/credential-service' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { executeProviderRequest } from '@/providers' diff --git a/apps/sim/lib/knowledge/api/internal-route.ts b/apps/sim/lib/knowledge/api/internal-route.ts new file mode 100644 index 00000000000..b2a5e881b54 --- /dev/null +++ b/apps/sim/lib/knowledge/api/internal-route.ts @@ -0,0 +1,389 @@ +import { + type Principal, + requirePrincipalSubjectUserId, + type SessionPrincipal, +} from '@sim/auth/principal' +import type { NextRequest } from 'next/server' +import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge/base' +import { type ChunkData, chunkDataSchema } from '@/lib/api/contracts/knowledge/chunks' +import { + type ConnectorData, + type ConnectorDetailData, + connectorDataSchema, + connectorDetailDataSchema, +} from '@/lib/api/contracts/knowledge/connectors' +import { type DocumentData, documentDataSchema } from '@/lib/api/contracts/knowledge/documents' +import { type TagDefinitionData, tagDefinitionDataSchema } from '@/lib/api/contracts/knowledge/tags' +import { AuthType, type AuthTypeValue } from '@/lib/auth/hybrid' +import { + requireBillingAttributionHeader, + resolveBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import { PlatformEvents } from '@/lib/core/telemetry' +import type { + CreateKnowledgeBaseInput, + InternalKnowledgeBaseResult, + KnowledgeBaseResult, +} from '@/lib/knowledge/application/knowledge-bases' +import type { CreatedKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { captureServerEvent } from '@/lib/posthog/server' +import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' + +export function internalKnowledgeActorUserId(principal: Principal): string { + return requirePrincipalSubjectUserId(principal) +} + +export function internalKnowledgeAuthType(principal: Principal): AuthTypeValue { + return principal.kind === 'delegated' ? AuthType.INTERNAL_JWT : AuthType.SESSION +} + +export async function resolveInternalKnowledgeBillingAttribution( + request: NextRequest, + principal: Principal, + workspaceId: string +) { + const actorUserId = internalKnowledgeActorUserId(principal) + return await (principal.kind === 'delegated' + ? requireBillingAttributionHeader(request.headers, { actorUserId, workspaceId }) + : resolveBillingAttribution({ actorUserId, workspaceId })) +} + +function serializeDate(date: Date | string): string { + return date instanceof Date ? date.toISOString() : date +} + +function serializeNullableDate(date: Date | string | null): string | null { + return date ? serializeDate(date) : null +} + +export function toInternalKnowledgeDocument< + T extends { + uploadedAt: Date | string + processingStartedAt?: Date | string | null + processingCompletedAt?: Date | string | null + date1?: Date | string | null + date2?: Date | string | null + }, +>(document: T): DocumentData { + return documentDataSchema.parse({ + ...document, + uploadedAt: serializeDate(document.uploadedAt), + processingStartedAt: serializeNullableDate(document.processingStartedAt ?? null), + processingCompletedAt: serializeNullableDate(document.processingCompletedAt ?? null), + date1: serializeNullableDate(document.date1 ?? null), + date2: serializeNullableDate(document.date2 ?? null), + }) +} + +export function toInternalKnowledgeChunk< + T extends { createdAt: Date | string; updatedAt: Date | string }, +>(chunk: T): ChunkData { + return chunkDataSchema.parse({ + ...chunk, + createdAt: serializeDate(chunk.createdAt), + updatedAt: serializeDate(chunk.updatedAt), + }) +} + +export function toInternalKnowledgeTag< + T extends { createdAt: Date | string; updatedAt: Date | string }, +>(tag: T): TagDefinitionData { + return tagDefinitionDataSchema.parse({ + ...tag, + createdAt: serializeDate(tag.createdAt), + updatedAt: serializeDate(tag.updatedAt), + }) +} + +export function toInternalKnowledgeConnector< + T extends { + sourceConfig: unknown + createdAt: Date | string + updatedAt: Date | string + lastSyncAt: Date | string | null + nextSyncAt: Date | string | null + }, +>(connector: T): ConnectorData { + return connectorDataSchema.parse({ + ...connector, + createdAt: serializeDate(connector.createdAt), + updatedAt: serializeDate(connector.updatedAt), + lastSyncAt: serializeNullableDate(connector.lastSyncAt), + nextSyncAt: serializeNullableDate(connector.nextSyncAt), + }) +} + +export function toInternalKnowledgeConnectorDetail< + T extends Parameters<typeof toInternalKnowledgeConnector>[0] & { + syncLogs: Array<{ + startedAt: Date | string + completedAt: Date | string | null + [key: string]: unknown + }> + }, +>(connector: T): ConnectorDetailData { + return connectorDetailDataSchema.parse({ + ...toInternalKnowledgeConnector(connector), + syncLogs: connector.syncLogs.map((log) => ({ + ...log, + startedAt: serializeDate(log.startedAt), + completedAt: serializeNullableDate(log.completedAt), + })), + }) +} + +export function toInternalKnowledgeDocumentUpload( + session: UploadSessionRecord, + document: CreatedKnowledgeDocument | null +) { + if (!session.knowledgeBaseId) { + throw new Error('Knowledge-document upload session is missing its knowledge base') + } + return { + id: session.id, + knowledgeBaseId: session.knowledgeBaseId, + status: session.status, + name: session.fileName, + contentType: session.contentType, + size: session.fileSize, + expiresAt: serializeDate(session.expiresAt), + error: session.error, + document: document + ? { + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + filename: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + processingStatus: document.processingStatus ?? 'pending', + chunkCount: document.chunkCount, + tokenCount: document.tokenCount, + characterCount: document.characterCount, + enabled: document.enabled, + createdAt: serializeNullableDate(document.uploadedAt), + } + : null, + } +} + +function toInternalKnowledgeBase(knowledgeBase: KnowledgeBaseWithCounts): KnowledgeBaseData { + return { + ...knowledgeBase, + chunkingConfig: { ...knowledgeBase.chunkingConfig }, + createdAt: serializeDate(knowledgeBase.createdAt), + updatedAt: serializeDate(knowledgeBase.updatedAt), + deletedAt: knowledgeBase.deletedAt ? serializeDate(knowledgeBase.deletedAt) : null, + } +} + +export const internalKnowledgePresenters = { + list({ knowledgeBases }: { knowledgeBases: KnowledgeBaseWithCounts[] }) { + return { success: true as const, data: knowledgeBases.map(toInternalKnowledgeBase) } + }, + create({ knowledgeBase }: KnowledgeBaseResult) { + return { success: true as const, data: toInternalKnowledgeBase(knowledgeBase) } + }, + read({ knowledgeBase }: InternalKnowledgeBaseResult) { + return { success: true as const, data: toInternalKnowledgeBase(knowledgeBase) } + }, + deleted() { + return { + success: true as const, + data: { message: 'Knowledge base deleted successfully' }, + } + }, +} as const + +export const internalKnowledgeAnalytics = { + created({ + principal, + result: { knowledgeBase }, + }: { + principal: SessionPrincipal + input: CreateKnowledgeBaseInput + result: KnowledgeBaseResult + }): void { + if (!knowledgeBase.workspaceId) { + throw new Error('Created knowledge base is missing its workspace analytics scope') + } + PlatformEvents.knowledgeBaseCreated({ + knowledgeBaseId: knowledgeBase.id, + name: knowledgeBase.name, + workspaceId: knowledgeBase.workspaceId, + }) + captureServerEvent( + principal.userId, + 'knowledge_base_created', + { + knowledge_base_id: knowledgeBase.id, + workspace_id: knowledgeBase.workspaceId, + name: knowledgeBase.name, + }, + { + groups: { workspace: knowledgeBase.workspaceId }, + setOnce: { first_kb_created_at: new Date().toISOString() }, + } + ) + }, + documentsUploaded({ + principal, + input, + result, + }: { + principal: Principal + input: { processingOptions?: { recipe?: string } } + result: + | { + kind: 'single' + workspaceId: string + data: { knowledgeBaseId: string; mimeType: string; fileSize: number } + } + | { kind: 'bulk'; workspaceId: string; data: { total: number }; knowledgeBaseId?: string } + }): void { + const userId = internalKnowledgeActorUserId(principal) + const documentCount = result.kind === 'bulk' ? result.data.total : 1 + const knowledgeBaseId = + result.kind === 'single' ? result.data.knowledgeBaseId : result.knowledgeBaseId + if (!knowledgeBaseId) { + throw new Error('Bulk document result is missing its knowledge base analytics scope') + } + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId, + documentsCount: documentCount, + uploadType: result.kind, + ...(result.kind === 'single' + ? { mimeType: result.data.mimeType, fileSize: result.data.fileSize } + : { recipe: input.processingOptions?.recipe }), + }) + captureServerEvent( + userId, + 'knowledge_base_document_uploaded', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: result.workspaceId, + document_count: documentCount, + upload_type: result.kind, + }, + { + groups: { workspace: result.workspaceId }, + setOnce: { first_document_uploaded_at: new Date().toISOString() }, + } + ) + }, + documentUpserted({ + input, + result, + }: { + principal: Principal + input: { processingOptions?: { recipe?: string } } + result: { knowledgeBaseId: string } + }): void { + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId: result.knowledgeBaseId, + documentsCount: 1, + uploadType: 'single', + recipe: input.processingOptions?.recipe, + }) + }, + documentDeleted({ + principal, + result, + }: { + principal: Principal + result: { knowledgeBaseId: string; workspaceId?: string } + }): void { + const workspaceId = result.workspaceId + if (!workspaceId) throw new Error('Deleted document result is missing its workspace scope') + captureServerEvent( + internalKnowledgeActorUserId(principal), + 'knowledge_base_document_deleted', + { knowledge_base_id: result.knowledgeBaseId, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + }, + connectorAdded({ + principal, + result: { connector, workspaceId }, + }: { + principal: Principal + input: unknown + result: { + workspaceId: string + connector: { + knowledgeBaseId: string + connectorType: string + syncIntervalMinutes: number + } + } + }): void { + captureServerEvent( + internalKnowledgeActorUserId(principal), + 'knowledge_base_connector_added', + { + knowledge_base_id: connector.knowledgeBaseId, + workspace_id: workspaceId, + connector_type: connector.connectorType, + sync_interval_minutes: connector.syncIntervalMinutes, + }, + { + groups: { workspace: workspaceId }, + setOnce: { first_connector_added_at: new Date().toISOString() }, + } + ) + }, + connectorRemoved({ + principal, + result, + }: { + principal: Principal + input: unknown + result: { + knowledgeBaseId: string + connectorType: string + documentsDeleted: number + workspaceId?: string + } + }): void { + if (!result.workspaceId) { + throw new Error('Deleted connector result is missing its workspace analytics scope') + } + captureServerEvent( + internalKnowledgeActorUserId(principal), + 'knowledge_base_connector_removed', + { + knowledge_base_id: result.knowledgeBaseId, + workspace_id: result.workspaceId, + connector_type: result.connectorType, + documents_deleted: result.documentsDeleted, + }, + { groups: { workspace: result.workspaceId } } + ) + }, + connectorSynced({ + principal, + result, + }: { + principal: Principal + input: unknown + result: { + knowledgeBaseId: string + connectorType: string + workspaceId?: string + } + }): void { + if (!result.workspaceId) { + throw new Error('Synced connector result is missing its workspace analytics scope') + } + captureServerEvent( + internalKnowledgeActorUserId(principal), + 'knowledge_base_connector_synced', + { + knowledge_base_id: result.knowledgeBaseId, + workspace_id: result.workspaceId, + connector_type: result.connectorType, + }, + { groups: { workspace: result.workspaceId } } + ) + }, +} as const diff --git a/apps/sim/lib/knowledge/api/route-policies.test.ts b/apps/sim/lib/knowledge/api/route-policies.test.ts new file mode 100644 index 00000000000..e70bc59c2fe --- /dev/null +++ b/apps/sim/lib/knowledge/api/route-policies.test.ts @@ -0,0 +1,52 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { + DelegatedWorkspaceAuthorizationError, + InsufficientWorkspacePermissionsError, + PersonalApiKeysDisabledError, + PrincipalKindAuthorizationError, + WorkspaceApiKeyAuthorizationError, +} from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' + +describe('v2 knowledge error policies', () => { + it.each([ + new InsufficientWorkspacePermissionsError(), + new WorkspaceApiKeyAuthorizationError(), + new DelegatedWorkspaceAuthorizationError(), + new PrincipalKindAuthorizationError('workspace_api_key', 'knowledge.read'), + ])('conceals canonical resource authorization failures as absence', async (error) => { + const response = v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization.render(error) + expect(response?.status).toBe(404) + expect(await response?.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Knowledge base not found' }, + }) + }) + + it('preserves the personal-api-key policy failure as forbidden', async () => { + const response = v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization.render( + new PersonalApiKeysDisabledError() + ) + expect(response?.status).toBe(403) + expect(await response?.json()).toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Personal API keys are not allowed for this workspace', + }, + }) + }) + + it('does not conceal unrelated forbidden business errors', async () => { + const response = v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization.render( + new OrchestrationError('forbidden', 'Knowledge base transition is forbidden') + ) + expect(response?.status).toBe(403) + expect(await response?.json()).toEqual({ + error: { code: 'FORBIDDEN', message: 'Knowledge base transition is forbidden' }, + }) + }) +}) diff --git a/apps/sim/lib/knowledge/api/route-policies.ts b/apps/sim/lib/knowledge/api/route-policies.ts index a832cbf32e5..83e7cc498b0 100644 --- a/apps/sim/lib/knowledge/api/route-policies.ts +++ b/apps/sim/lib/knowledge/api/route-policies.ts @@ -1,10 +1,97 @@ import { + createInternalSessionOrExecutorAuth, createV2ResourceConcealmentPolicy, + type InternalErrorPolicy, + internalErrorResponse, + internalPlainOrchestrationErrorPolicy, + type V2ErrorPolicy, v2OrchestrationErrorPolicy, } from '@/lib/api/server/routes' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { KNOWLEDGE_DELEGATION_AUDIENCE } from '@/lib/knowledge/application/authorization' +import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' +import { KnowledgeSearchProvenanceUnavailableError } from '@/lib/knowledge/application/search' +import { KnowledgeDocumentUnsupportedMediaTypeError } from '@/lib/knowledge/application/upload-sessions' +import { v2Error } from '@/app/api/v2/lib/response' + +function internalKnowledgeErrorPolicy(unhandledMessage: string): InternalErrorPolicy { + return { + project: internalPlainOrchestrationErrorPolicy.project, + unhandled: () => internalErrorResponse(500, { error: unhandledMessage }), + } +} + +const internalKnowledgeUploadErrorPolicy: InternalErrorPolicy = { + project(error) { + if (error instanceof KnowledgeDocumentUnsupportedMediaTypeError) { + return internalErrorResponse(415, { error: error.message }) + } + if (error instanceof KnowledgeUsageLimitExceededError) { + return internalErrorResponse(402, { error: error.message }) + } + return internalPlainOrchestrationErrorPolicy.project(error) + }, + unhandled: () => + internalErrorResponse(500, { error: 'Failed to process knowledge upload request' }), +} + +const internalKnowledgeSearchErrorPolicy: InternalErrorPolicy = { + project(error) { + if (error instanceof KnowledgeUsageLimitExceededError) { + return internalErrorResponse(402, { error: error.message }) + } + if (error instanceof KnowledgeSearchProvenanceUnavailableError) { + return internalErrorResponse(422, { error: error.message }) + } + return internalPlainOrchestrationErrorPolicy.project(error) + }, + unhandled: () => internalErrorResponse(500, { error: 'Failed to perform vector search' }), +} + +export const internalKnowledgeSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({ + audience: KNOWLEDGE_DELEGATION_AUDIENCE, +}) + +export const internalKnowledgeErrorPolicies = { + list: internalKnowledgeErrorPolicy('Failed to fetch knowledge bases'), + read: internalKnowledgeErrorPolicy('Failed to fetch knowledge base'), + create: internalKnowledgeErrorPolicy('Failed to create knowledge base'), + update: internalKnowledgeErrorPolicy('Failed to update knowledge base'), + delete: internalKnowledgeErrorPolicy('Failed to delete knowledge base'), + restore: internalKnowledgeErrorPolicy('Internal server error'), + default: internalKnowledgeErrorPolicy('Internal server error'), + documents: internalKnowledgeErrorPolicy('Failed to process knowledge document request'), + chunks: internalKnowledgeErrorPolicy('Failed to process knowledge chunk request'), + upsert: internalKnowledgeUploadErrorPolicy, + search: internalKnowledgeSearchErrorPolicy, + tags: internalKnowledgeErrorPolicy('Failed to process knowledge tag request'), + connectors: internalKnowledgeErrorPolicy('Internal server error'), + uploads: internalKnowledgeUploadErrorPolicy, +} as const + +const v2KnowledgeUsageErrorPolicy = { + render(error) { + if (error instanceof KnowledgeUsageLimitExceededError) { + return v2Error('USAGE_LIMIT_EXCEEDED', error.message) + } + return v2OrchestrationErrorPolicy.render(error) + }, +} satisfies V2ErrorPolicy export const v2KnowledgeErrorPolicies = { default: v2OrchestrationErrorPolicy, + usage: v2KnowledgeUsageErrorPolicy, + documentUpload: { + render(error) { + if (error instanceof KnowledgeDocumentUnsupportedMediaTypeError) { + return v2Error('UNSUPPORTED_MEDIA_TYPE', error.message) + } + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + return v2KnowledgeUsageErrorPolicy.render(error) + }, + } satisfies V2ErrorPolicy, concealKnowledgeBaseAuthorization: createV2ResourceConcealmentPolicy({ notFoundMessage: 'Knowledge base not found', }), diff --git a/apps/sim/lib/knowledge/application/add-workspace-files.test.ts b/apps/sim/lib/knowledge/application/add-workspace-files.test.ts new file mode 100644 index 00000000000..c245ef702ac --- /dev/null +++ b/apps/sim/lib/knowledge/application/add-workspace-files.test.ts @@ -0,0 +1,342 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveKnowledgeBase: vi.fn(), + resolvePermission: vi.fn(), + resolveFile: vi.fn(), + loadFileContext: vi.fn(), + getProvenance: vi.fn(), + presign: vi.fn(), + resolveBilling: vi.fn(), + checkUsage: vi.fn(), + createDocument: vi.fn(), + processQueue: vi.fn(), + recordAudit: vi.fn(), + platformUploaded: vi.fn(), + captureServerEvent: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { DOCUMENT_UPLOADED: 'document.uploaded' }, + AuditResourceType: { DOCUMENT: 'document' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveBillingAttribution: mocks.resolveBilling, + resolveSystemBillingAttribution: mocks.resolveBilling, + checkAttributedUsageLimits: mocks.checkUsage, +})) + +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { knowledgeBaseDocumentsUploaded: mocks.platformUploaded }, +})) + +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveActiveKnowledgeBaseContext: mocks.resolveKnowledgeBase, +})) + +vi.mock('@/lib/knowledge/documents/service', () => ({ + createSingleDocument: mocks.createDocument, + processDocumentsWithQueue: mocks.processQueue, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) + +vi.mock('@/lib/uploads', () => ({ + StorageService: { generatePresignedDownloadUrl: mocks.presign }, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ + getBoundWorkspaceFileSecretProvenance: mocks.getProvenance, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + loadActiveWorkspaceFileContext: mocks.loadFileContext, + resolveWorkspaceFileReference: mocks.resolveFile, +})) + +vi.mock('@/lib/uploads/utils/validation', () => ({ validateFileType: () => null })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { addWorkspaceFilesToKnowledgeBase } from '@/lib/knowledge/application/add-workspace-files' + +const knowledgeContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + knowledgeBaseId: 'knowledge-1', + knowledgeBase: { id: 'knowledge-1', name: 'Docs' }, +} + +const workspaceFile = { + id: 'file-1', + workspaceId: 'workspace-1', + key: 'workspace/workspace-1/file-1-report.pdf', + name: 'report.pdf', + path: '/api/files/serve/file-1', + size: 100, + type: 'application/pdf', + uploadedBy: 'user-1', + uploadedAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} + +const delegatedPrincipal = { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'dual-workspace-user', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:knowledge', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), +} as const + +describe('add workspace files to knowledge base application command', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveKnowledgeBase.mockResolvedValue(knowledgeContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveFile.mockResolvedValue(workspaceFile) + mocks.loadFileContext.mockResolvedValue({ + fileId: workspaceFile.id, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.getProvenance.mockResolvedValue({ status: 'exact', entries: [] }) + mocks.presign.mockResolvedValue('https://storage.test/report.pdf') + mocks.resolveBilling.mockResolvedValue({ + actorUserId: 'dual-workspace-user', + workspaceId: 'workspace-1', + }) + mocks.checkUsage.mockResolvedValue({ isExceeded: false }) + mocks.createDocument.mockResolvedValue({ + id: 'document-1', + filename: workspaceFile.name, + fileUrl: 'https://storage.test/report.pdf', + fileSize: workspaceFile.size, + mimeType: workspaceFile.type, + }) + mocks.processQueue.mockResolvedValue(undefined) + }) + + it('bounds file references before canonical knowledge loading', async () => { + await expect( + addWorkspaceFilesToKnowledgeBase.execute({ + principal: delegatedPrincipal, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + fileReferences: Array.from({ length: 101 }, (_, index) => `files/file-${index}.pdf`), + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveKnowledgeBase).not.toHaveBeenCalled() + expect(mocks.createDocument).not.toHaveBeenCalled() + }) + + it('authorizes canonical scope and admits usage before document creation', async () => { + await addWorkspaceFilesToKnowledgeBase.execute({ + principal: delegatedPrincipal, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + fileReferences: ['files/report.pdf'], + source: 'agent', + }, + }) + + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.resolveFile.mock.invocationCallOrder[0] + ) + expect(mocks.getProvenance.mock.invocationCallOrder[0]).toBeLessThan( + mocks.resolveBilling.mock.invocationCallOrder[0] + ) + expect(mocks.checkUsage.mock.invocationCallOrder[0]).toBeLessThan( + mocks.createDocument.mock.invocationCallOrder[0] + ) + expect(mocks.resolvePermission).toHaveBeenCalledTimes(2) + expect(mocks.createDocument).toHaveBeenCalledWith( + expect.objectContaining({ filename: 'report.pdf' }), + 'knowledge-1', + expect.any(String), + 'dual-workspace-user', + undefined, + undefined, + { expectedWorkspaceId: 'workspace-1' } + ) + }) + + it('conceals a cross-workspace file before provenance, storage, or mutation', async () => { + mocks.loadFileContext.mockResolvedValueOnce({ + fileId: 'workspace-2-file', + workspaceId: 'workspace-2', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-2', + }) + + const result = await addWorkspaceFilesToKnowledgeBase.execute({ + principal: delegatedPrincipal, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + fileReferences: ['workspace-2-file'], + }, + }) + + expect(result).toMatchObject({ added: [], failed: ['workspace-2-file'] }) + expect(mocks.getProvenance).not.toHaveBeenCalled() + expect(mocks.presign).not.toHaveBeenCalled() + expect(mocks.checkUsage).not.toHaveBeenCalled() + expect(mocks.createDocument).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('returns partial outcomes and keeps product analytics out of the application', async () => { + mocks.resolveFile + .mockResolvedValueOnce(workspaceFile) + .mockRejectedValueOnce(new OrchestrationError('not_found', 'File not found')) + + const result = await addWorkspaceFilesToKnowledgeBase.execute({ + principal: delegatedPrincipal, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + fileReferences: ['files/report.pdf', 'files/missing.pdf'], + source: 'agent', + }, + }) + + expect(result).toMatchObject({ + added: [{ documentId: 'document-1', filename: 'report.pdf' }], + failed: ['files/missing.pdf'], + cancelled: false, + }) + expect(mocks.recordAudit).toHaveBeenCalledOnce() + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'document-1', + metadata: expect.objectContaining({ + operation: 'knowledge.documents.add_workspace_files', + }), + }) + ) + expect(mocks.platformUploaded).not.toHaveBeenCalled() + expect(mocks.captureServerEvent).not.toHaveBeenCalled() + }) + + it('stops between document creations while auditing completed items', async () => { + const controller = new AbortController() + mocks.resolveFile + .mockResolvedValueOnce(workspaceFile) + .mockResolvedValueOnce({ ...workspaceFile, id: 'file-2', name: 'second.pdf' }) + mocks.loadFileContext + .mockResolvedValueOnce({ + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + .mockResolvedValueOnce({ + fileId: 'file-2', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.createDocument.mockImplementationOnce(async () => { + controller.abort('user stopped') + return { + id: 'document-1', + filename: 'report.pdf', + fileUrl: 'https://storage.test/report.pdf', + fileSize: 100, + mimeType: 'application/pdf', + } + }) + + const result = await addWorkspaceFilesToKnowledgeBase.execute({ + principal: delegatedPrincipal, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + fileReferences: ['files/report.pdf', 'files/second.pdf'], + cancellationSignal: controller.signal, + }, + }) + + expect(result).toMatchObject({ added: [{ documentId: 'document-1' }], cancelled: true }) + expect(mocks.createDocument).toHaveBeenCalledOnce() + expect(mocks.recordAudit).toHaveBeenCalledOnce() + }) + + it('audits completed documents before propagating a later infrastructure failure', async () => { + const failure = new Error('document store unavailable') + mocks.resolveFile + .mockResolvedValueOnce(workspaceFile) + .mockResolvedValueOnce({ ...workspaceFile, id: 'file-2', name: 'second.pdf' }) + mocks.loadFileContext + .mockResolvedValueOnce({ + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + .mockResolvedValueOnce({ + fileId: 'file-2', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.createDocument + .mockResolvedValueOnce({ + id: 'document-1', + filename: 'report.pdf', + fileUrl: 'https://storage.test/report.pdf', + fileSize: 100, + mimeType: 'application/pdf', + }) + .mockRejectedValueOnce(failure) + + await expect( + addWorkspaceFilesToKnowledgeBase.execute({ + principal: delegatedPrincipal, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + fileReferences: ['files/report.pdf', 'files/second.pdf'], + }, + }) + ).rejects.toBe(failure) + + expect(mocks.recordAudit).toHaveBeenCalledOnce() + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ resourceId: 'document-1' }) + ) + expect(mocks.platformUploaded).not.toHaveBeenCalled() + expect(mocks.captureServerEvent).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/application/add-workspace-files.ts b/apps/sim/lib/knowledge/application/add-workspace-files.ts new file mode 100644 index 00000000000..c0400c14647 --- /dev/null +++ b/apps/sim/lib/knowledge/application/add-workspace-files.ts @@ -0,0 +1,270 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { checkAttributedUsageLimits } from '@/lib/billing/core/billing-attribution' +import { authorizeWorkspaceOperation } from '@/lib/core/application' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' +import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { + ADD_WORKSPACE_FILES_COST_POLICY, + type KnowledgeBatchExecutionResult, + requireBoundedKnowledgeBatch, + rethrowKnowledgeBatchTerminalFailure, +} from '@/lib/knowledge/application/batch-policy' +import { + KnowledgeUsageLimitExceededError, + resolveKnowledgeAttributedUserId, + resolveKnowledgeBillingAttribution, +} from '@/lib/knowledge/application/billing' +import { + type ActiveKnowledgeBaseContext, + resolveActiveKnowledgeBaseContext, +} from '@/lib/knowledge/application/contexts' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + createSingleDocument, + type DocumentData, + processDocumentsWithQueue, +} from '@/lib/knowledge/documents/service' +import { StorageService } from '@/lib/uploads' +import { + loadActiveWorkspaceFileContext, + resolveWorkspaceFileReference, + 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 { validateFileType } from '@/lib/uploads/utils/validation' + +const logger = createLogger('AddWorkspaceFilesToKnowledgeBase') + +export interface AddWorkspaceFilesToKnowledgeBaseInput { + knowledgeBaseId: string + assertedWorkspaceId?: string + fileReferences: string[] + cancellationSignal?: AbortSignal + source?: string +} + +interface AddedWorkspaceFileDocument { + documentId: string + filename: string + mimeType: string + fileSize: number +} + +export interface AddWorkspaceFilesToKnowledgeBaseResult { + knowledgeBaseId: string + knowledgeBaseName: string + added: AddedWorkspaceFileDocument[] + failed: string[] + cancelled: boolean +} + +interface AddWorkspaceFilesExecutionResult + extends AddWorkspaceFilesToKnowledgeBaseResult, + KnowledgeBatchExecutionResult {} + +interface AddWorkspaceFilesContext extends ActiveKnowledgeBaseContext { + fileReferences: string[] +} + +interface PreparedWorkspaceFile { + reference: string + file: WorkspaceFileRecord + fileUrl: string +} + +async function prepareWorkspaceFile( + principal: Principal, + context: AddWorkspaceFilesContext, + reference: string +): Promise<PreparedWorkspaceFile> { + const file = await resolveWorkspaceFileReference(context.workspaceId, reference) + if (!file) throw new OrchestrationError('not_found', 'File not found') + const canonical = await loadActiveWorkspaceFileContext(file.id) + if (!canonical || canonical.workspaceId !== context.workspaceId) { + throw new OrchestrationError('not_found', 'File not found') + } + await authorizeWorkspaceOperation(principal, knowledgeOperations.addWorkspaceFiles, canonical, { + delegation: knowledgeDelegationPolicy, + }) + if (file.size < 0 || file.size > MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE) { + throw new OrchestrationError('payload_too_large', 'Knowledge document exceeds the 100MB limit') + } + const fileTypeError = validateFileType(file.name, file.type) + if (fileTypeError) throw new OrchestrationError('validation', fileTypeError.message) + + const provenance = await getBoundWorkspaceFileSecretProvenance(context.workspaceId, { + fileId: file.id, + key: file.key, + context: 'workspace', + }) + if (provenance.status !== 'exact' || provenance.entries.length > 0) { + throw new OrchestrationError( + 'validation', + 'Workspace file secret provenance prevents knowledge ingestion' + ) + } + + return { + reference, + file, + fileUrl: await StorageService.generatePresignedDownloadUrl(file.key, 'workspace', 5 * 60), + } +} + +export const addWorkspaceFilesToKnowledgeBase = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.addWorkspaceFiles, + async resolveContext({ + input, + }: { + input: AddWorkspaceFilesToKnowledgeBaseInput + }): Promise<AddWorkspaceFilesContext> { + const fileReferences = requireBoundedKnowledgeBatch( + input.fileReferences, + 'files', + ADD_WORKSPACE_FILES_COST_POLICY.maxItems + ) + return { + ...(await resolveActiveKnowledgeBaseContext(input)), + fileReferences, + } + }, + async execute({ principal, input, context }): Promise<AddWorkspaceFilesExecutionResult> { + const prepared: PreparedWorkspaceFile[] = [] + const failed: string[] = [] + const canonicalFileIds = new Set<string>() + + for (const reference of context.fileReferences) { + if (input.cancellationSignal?.aborted) break + try { + const candidate = await prepareWorkspaceFile(principal, context, reference) + if (canonicalFileIds.has(candidate.file.id)) continue + canonicalFileIds.add(candidate.file.id) + prepared.push(candidate) + } catch (error) { + const classified = asOrchestrationError(error) + if (classified && classified.code !== 'internal') { + failed.push(reference) + continue + } + throw error + } + } + + if (prepared.length === 0) { + return { + knowledgeBaseId: context.knowledgeBaseId, + knowledgeBaseName: context.knowledgeBase.name, + added: [], + failed, + cancelled: input.cancellationSignal?.aborted ?? false, + } + } + + if (input.cancellationSignal?.aborted) { + return { + knowledgeBaseId: context.knowledgeBaseId, + knowledgeBaseName: context.knowledgeBase.name, + added: [], + failed, + cancelled: true, + } + } + + const billingAttribution = await resolveKnowledgeBillingAttribution(principal, context) + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + throw new KnowledgeUsageLimitExceededError( + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + const uploadedBy = resolveKnowledgeAttributedUserId(principal, context) + const added: AddedWorkspaceFileDocument[] = [] + let terminalFailure: KnowledgeBatchExecutionResult['terminalFailure'] + + for (const candidate of prepared) { + if (input.cancellationSignal?.aborted) break + try { + const requestId = generateRequestId() + const document = await createSingleDocument( + { + filename: candidate.file.name, + fileUrl: candidate.fileUrl, + fileSize: candidate.file.size, + mimeType: candidate.file.type, + }, + context.knowledgeBaseId, + requestId, + uploadedBy, + undefined, + undefined, + { expectedWorkspaceId: context.workspaceId } + ) + const processingDocument: DocumentData = { + documentId: document.id, + filename: document.filename, + fileUrl: document.fileUrl, + fileSize: document.fileSize, + mimeType: document.mimeType, + } + processDocumentsWithQueue( + [processingDocument], + context.knowledgeBaseId, + {}, + requestId, + billingAttribution + ).catch((error: unknown) => { + logger.error('Knowledge document processing pipeline failed', { + knowledgeBaseId: context.knowledgeBaseId, + documentId: document.id, + error, + }) + }) + added.push({ + documentId: document.id, + filename: document.filename, + mimeType: document.mimeType, + fileSize: document.fileSize, + }) + } catch (error) { + const classified = asOrchestrationError(error) + if (classified && classified.code !== 'internal') { + failed.push(candidate.reference) + continue + } + terminalFailure = { error } + break + } + } + + return { + knowledgeBaseId: context.knowledgeBaseId, + knowledgeBaseName: context.knowledgeBase.name, + added, + failed, + cancelled: input.cancellationSignal?.aborted ?? false, + ...(terminalFailure && { terminalFailure }), + } + }, + projectAudit: ({ input, context, result }) => + result.added.map((document) => ({ + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: document.documentId, + resourceName: document.filename, + description: `Uploaded document "${document.filename}" to knowledge base "${context.knowledgeBase.name}"`, + metadata: { + source: input.source, + knowledgeBaseId: context.knowledgeBaseId, + knowledgeBaseName: context.knowledgeBase.name, + fileName: document.filename, + fileType: document.mimeType, + fileSize: document.fileSize, + }, + })), + afterSuccess: ({ result }) => rethrowKnowledgeBatchTerminalFailure(result), +}) diff --git a/apps/sim/lib/knowledge/application/authorization.test.ts b/apps/sim/lib/knowledge/application/authorization.test.ts index bb5ea1030d9..50744f68ff2 100644 --- a/apps/sim/lib/knowledge/application/authorization.test.ts +++ b/apps/sim/lib/knowledge/application/authorization.test.ts @@ -51,4 +51,25 @@ describe('knowledge delegation policy', () => { expect(principal.audience).not.toBe(knowledgeDelegationPolicy.audience) }) + + it('accepts a correctly scoped executor delegation for executor-enabled operations', () => { + const principal: DelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'execution-1', + audience: KNOWLEDGE_DELEGATION_AUDIENCE, + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + } + + expect( + knowledgeDelegationPolicy.isWithinScope(principal, { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + }) + ).toBe(true) + }) }) diff --git a/apps/sim/lib/knowledge/application/authorization.ts b/apps/sim/lib/knowledge/application/authorization.ts index e75d1c496d4..60320ea04d8 100644 --- a/apps/sim/lib/knowledge/application/authorization.ts +++ b/apps/sim/lib/knowledge/application/authorization.ts @@ -9,6 +9,9 @@ export const KNOWLEDGE_DELEGATION_AUDIENCE = 'sim:knowledge' export interface KnowledgeAuthorizationContext extends WorkspaceAuthorizationContext { knowledgeBaseId?: string documentId?: string + chunkId?: string + tagDefinitionId?: string + connectorId?: string } export type KnowledgeAuthorizationOptions = Omit< diff --git a/apps/sim/lib/knowledge/application/batch-policy.ts b/apps/sim/lib/knowledge/application/batch-policy.ts new file mode 100644 index 00000000000..3e995996c01 --- /dev/null +++ b/apps/sim/lib/knowledge/application/batch-policy.ts @@ -0,0 +1,47 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' + +export const MAX_KNOWLEDGE_BATCH_ITEMS = 100 + +export const ADD_WORKSPACE_FILES_COST_POLICY = { + maxItems: MAX_KNOWLEDGE_BATCH_ITEMS, + usageAdmission: 'once_before_processing', +} as const + +export const BULK_DELETE_KNOWLEDGE_BASES_COST_POLICY = { + maxItems: MAX_KNOWLEDGE_BATCH_ITEMS, + execution: 'sequential_best_effort', +} as const + +export const BULK_DELETE_KNOWLEDGE_DOCUMENTS_COST_POLICY = { + maxItems: MAX_KNOWLEDGE_BATCH_ITEMS, + execution: 'sequential_best_effort', +} as const + +export interface KnowledgeBatchTerminalFailure { + error: unknown +} + +export interface KnowledgeBatchExecutionResult { + terminalFailure?: KnowledgeBatchTerminalFailure +} + +export function rethrowKnowledgeBatchTerminalFailure(result: KnowledgeBatchExecutionResult): void { + if (result.terminalFailure) throw result.terminalFailure.error +} + +export function requireBoundedKnowledgeBatch( + items: readonly string[], + resource: string, + maxItems: number +): string[] { + if (items.length === 0) { + throw new OrchestrationError('validation', `At least one ${resource} is required`) + } + if (items.length > maxItems) { + throw new OrchestrationError( + 'validation', + `Too many ${resource} (${items.length}). Maximum is ${maxItems}.` + ) + } + return [...new Set(items)] +} diff --git a/apps/sim/lib/knowledge/application/chunks.ts b/apps/sim/lib/knowledge/application/chunks.ts new file mode 100644 index 00000000000..38d4d56dcca --- /dev/null +++ b/apps/sim/lib/knowledge/application/chunks.ts @@ -0,0 +1,260 @@ +import { createLogger } from '@sim/logger' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { + createDurableSecretProvenanceRegistry, + type DurableSecretProvenance, +} from '@/lib/execution/durable-secret-provenance' +import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { resolveKnowledgeAttributedUserId } from '@/lib/knowledge/application/billing' +import { + type ActiveKnowledgeDocumentContext, + resolveActiveKnowledgeChunkContext, + resolveCanonicalActiveKnowledgeDocumentContext, +} from '@/lib/knowledge/application/contexts' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + batchChunkOperation, + createChunk, + deleteChunk, + queryChunks, + updateChunk, +} from '@/lib/knowledge/chunks/service' +import type { ChunkFilters } from '@/lib/knowledge/chunks/types' +import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' +import { calculateCost } from '@/providers/utils' + +const logger = createLogger('KnowledgeChunkApplication') + +interface KnowledgeDocumentChunkInput { + knowledgeBaseId: string + documentId: string + assertedWorkspaceId?: string +} + +interface KnowledgeChunkInput extends KnowledgeDocumentChunkInput { + chunkId: string +} + +interface ResolveChunkProvenanceInput { + userId: string + workspaceId: string +} + +export interface ListKnowledgeChunksInput extends KnowledgeDocumentChunkInput, ChunkFilters {} + +export interface CreateKnowledgeChunkInput extends KnowledgeDocumentChunkInput { + content: string + enabled?: boolean + resolveContentProvenance(input: ResolveChunkProvenanceInput): DurableSecretProvenance | undefined +} + +export interface UpdateKnowledgeChunkInput extends KnowledgeChunkInput { + content?: string + enabled?: boolean + resolveContentProvenance(input: ResolveChunkProvenanceInput): DurableSecretProvenance | undefined +} + +export interface BulkKnowledgeChunksInput extends KnowledgeDocumentChunkInput { + operation: 'enable' | 'disable' | 'delete' + chunkIds: string[] +} + +function requireChunkReadable(context: ActiveKnowledgeDocumentContext): void { + if (context.document.processingStatus !== 'completed') { + throw new OrchestrationError( + 'validation', + `Document is not ready for access (status: ${context.document.processingStatus})` + ) + } +} + +function requireChunkWritable(context: ActiveKnowledgeDocumentContext): void { + if (context.document.connectorId) { + throw new OrchestrationError( + 'forbidden', + 'Chunks from connector-synced documents are read-only' + ) + } +} + +function documentTags(context: ActiveKnowledgeDocumentContext) { + const document = context.document + return { + tag1: document.tag1 ?? null, + tag2: document.tag2 ?? null, + tag3: document.tag3 ?? null, + tag4: document.tag4 ?? null, + tag5: document.tag5 ?? null, + tag6: document.tag6 ?? null, + tag7: document.tag7 ?? null, + number1: document.number1 ?? null, + number2: document.number2 ?? null, + number3: document.number3 ?? null, + number4: document.number4 ?? null, + number5: document.number5 ?? null, + date1: document.date1 ?? null, + date2: document.date2 ?? null, + boolean1: document.boolean1 ?? null, + boolean2: document.boolean2 ?? null, + boolean3: document.boolean3 ?? null, + } +} + +export const listKnowledgeChunks = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.listChunks, + resolveContext: ({ input }: { input: ListKnowledgeChunksInput }) => + resolveCanonicalActiveKnowledgeDocumentContext(input), + async execute({ input, context }) { + requireChunkReadable(context) + const { + knowledgeBaseId: _knowledgeBaseId, + documentId, + assertedWorkspaceId: _scope, + ...filters + } = input + const result = await queryChunks(documentId, filters, generateRequestId()) + return { ...result, workspaceId: context.workspaceId, documentId } + }, +}) + +export const readKnowledgeChunk = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.readChunk, + resolveContext: ({ input }: { input: KnowledgeChunkInput }) => + resolveActiveKnowledgeChunkContext(input), + async execute({ context }) { + requireChunkReadable(context) + return { + chunk: context.chunk, + workspaceId: context.workspaceId, + documentId: context.documentId, + } + }, +}) + +export const createKnowledgeChunk = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.createChunk, + resolveContext: ({ input }: { input: CreateKnowledgeChunkInput }) => + resolveCanonicalActiveKnowledgeDocumentContext(input), + async execute({ principal, input, context }) { + requireChunkWritable(context) + if (context.document.processingStatus === 'failed') { + throw new OrchestrationError('validation', 'Cannot add chunks to failed document') + } + const userId = resolveKnowledgeAttributedUserId(principal, context) + const provenance = input.resolveContentProvenance({ userId, workspaceId: context.workspaceId }) + if (provenance?.status === 'unknown') { + throw new OrchestrationError('validation', 'Knowledge chunk secret provenance is unavailable') + } + const registry = provenance + ? await createDurableSecretProvenanceRegistry(provenance, { + userId, + workspaceId: context.workspaceId, + }) + : undefined + const chunk = await runWithKnowledgeModelInputProvenance(registry, () => + createChunk( + context.knowledgeBaseId, + context.documentId, + documentTags(context), + { content: input.content, enabled: input.enabled }, + generateRequestId(), + context.workspaceId, + provenance + ) + ) + let cost: ReturnType<typeof calculateCost> | null = null + try { + cost = calculateCost(context.knowledgeBase.embeddingModel, chunk.tokenCount, 0, false) + } catch (error) { + logger.warn('Failed to calculate cost for chunk upload', { error }) + } + return { + chunk: { + ...chunk, + documentId: context.documentId, + documentName: context.document.filename, + ...(cost + ? { + cost: { + input: cost.input, + output: cost.output, + total: cost.total, + tokens: { prompt: chunk.tokenCount, completion: 0, total: chunk.tokenCount }, + model: context.knowledgeBase.embeddingModel, + pricing: cost.pricing, + }, + } + : {}), + }, + provenance, + workspaceId: context.workspaceId, + userId, + } + }, +}) + +export const updateKnowledgeChunk = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.updateChunk, + resolveContext: ({ input }: { input: UpdateKnowledgeChunkInput }) => + resolveActiveKnowledgeChunkContext(input), + async execute({ principal, input, context }) { + requireChunkReadable(context) + requireChunkWritable(context) + const userId = resolveKnowledgeAttributedUserId(principal, context) + const provenance = input.resolveContentProvenance({ userId, workspaceId: context.workspaceId }) + if (provenance?.status === 'unknown') { + throw new OrchestrationError('validation', 'Knowledge chunk secret provenance is unavailable') + } + const registry = provenance + ? await createDurableSecretProvenanceRegistry(provenance, { + userId, + workspaceId: context.workspaceId, + }) + : undefined + const chunk = await runWithKnowledgeModelInputProvenance(registry, () => + updateChunk( + context.chunkId, + { content: input.content, enabled: input.enabled }, + generateRequestId(), + context.workspaceId, + provenance + ) + ) + return { chunk, workspaceId: context.workspaceId, documentId: context.documentId } + }, +}) + +export const deleteKnowledgeChunk = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.deleteChunk, + resolveContext: ({ input }: { input: KnowledgeChunkInput }) => + resolveActiveKnowledgeChunkContext(input), + async execute({ context }) { + requireChunkReadable(context) + requireChunkWritable(context) + await deleteChunk(context.chunkId, context.documentId, generateRequestId()) + return { deleted: true as const } + }, +}) + +export const bulkUpdateKnowledgeChunks = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.bulkChunks, + resolveContext: ({ input }: { input: BulkKnowledgeChunksInput }) => + resolveCanonicalActiveKnowledgeDocumentContext(input), + async execute({ input, context }) { + requireChunkWritable(context) + const result = await batchChunkOperation( + context.documentId, + input.operation, + input.chunkIds, + generateRequestId() + ) + return { + operation: input.operation, + successCount: result.processed, + errorCount: result.errors.length, + processed: result.processed, + errors: result.errors, + } + }, +}) diff --git a/apps/sim/lib/knowledge/application/connectors.test.ts b/apps/sim/lib/knowledge/application/connectors.test.ts new file mode 100644 index 00000000000..816565d737e --- /dev/null +++ b/apps/sim/lib/knowledge/application/connectors.test.ts @@ -0,0 +1,476 @@ +/** + * @vitest-environment node + */ + +import { document } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveKnowledgeBase: vi.fn(), + resolveConnector: vi.fn(), + resolvePermission: vi.fn(), + createConnector: vi.fn(), + updateConnector: vi.fn(), + deleteConnector: vi.fn(), + syncConnector: vi.fn(), + resolveBilling: vi.fn(), + resolveTokenIdentity: vi.fn(), + refreshToken: vi.fn(), + validateConnectorConfig: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + CONNECTOR_CREATED: 'connector.created', + CONNECTOR_UPDATED: 'connector.updated', + CONNECTOR_DELETED: 'connector.deleted', + CONNECTOR_SYNCED: 'connector.synced', + }, + AuditResourceType: { CONNECTOR: 'connector' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveActiveKnowledgeBaseContext: mocks.resolveKnowledgeBase, + resolveActiveKnowledgeConnectorContext: mocks.resolveConnector, +})) + +vi.mock('@/lib/knowledge/orchestration/connectors', () => ({ + performCreateKnowledgeConnector: mocks.createConnector, + performUpdateKnowledgeConnector: mocks.updateConnector, + performDeleteKnowledgeConnector: mocks.deleteConnector, + performSyncKnowledgeConnector: mocks.syncConnector, +})) + +vi.mock('@/lib/credentials/access', () => ({ + resolveCredentialTokenIdentity: mocks.resolveTokenIdentity, +})) + +vi.mock('@/lib/oauth/credential-service', () => ({ + refreshAccessTokenIfNeeded: mocks.refreshToken, +})) + +vi.mock('@/connectors/registry.server', () => ({ + CONNECTOR_REGISTRY: { + confluence: { + auth: { mode: 'oauth' }, + validateConfig: mocks.validateConnectorConfig, + }, + }, +})) + +import { + createKnowledgeConnector, + deleteKnowledgeConnector, + listKnowledgeConnectorDocuments, + syncKnowledgeConnector, + updateKnowledgeConnector, + updateKnowledgeConnectorDocuments, +} from '@/lib/knowledge/application/connectors' + +const crossWorkspaceContext = { + workspaceId: 'workspace-b', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-b', + knowledgeBaseId: 'knowledge-b', + knowledgeBase: { id: 'knowledge-b', name: 'Workspace B docs' }, +} + +const connectorContext = { + ...crossWorkspaceContext, + connectorId: 'connector-b', + connector: { + id: 'connector-b', + knowledgeBaseId: 'knowledge-b', + connectorType: 'confluence', + status: 'active', + }, +} + +const delegatedPrincipal = { + kind: 'delegated' as const, + serviceId: 'copilot', + subjectUserId: 'shared-user', + workspaceId: 'workspace-a', + delegationId: 'tool-call-1', + audience: 'sim:knowledge', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: {}, +} + +describe('knowledge connector application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveKnowledgeBase.mockResolvedValue(crossWorkspaceContext) + mocks.resolveConnector.mockResolvedValue(connectorContext) + mocks.resolveTokenIdentity.mockResolvedValue({ kind: 'oauth', userId: 'credential-owner' }) + mocks.refreshToken.mockResolvedValue('access-token') + mocks.validateConnectorConfig.mockResolvedValue({ valid: true }) + }) + + afterAll(resetDbChainMock) + + it.each([ + [ + 'create', + createKnowledgeConnector, + { + knowledgeBaseId: 'knowledge-b', + assertedWorkspaceId: 'workspace-a', + connectorType: 'confluence', + credentialId: 'credential-1', + sourceConfig: {}, + syncIntervalMinutes: 1440, + resolveBillingAttribution: mocks.resolveBilling, + }, + ], + [ + 'update', + updateKnowledgeConnector, + { + connectorId: 'connector-b', + assertedWorkspaceId: 'workspace-a', + updates: { status: 'paused' as const }, + }, + ], + [ + 'delete', + deleteKnowledgeConnector, + { connectorId: 'connector-b', assertedWorkspaceId: 'workspace-a' }, + ], + [ + 'sync', + syncKnowledgeConnector, + { + connectorId: 'connector-b', + assertedWorkspaceId: 'workspace-a', + resolveBillingAttribution: mocks.resolveBilling, + }, + ], + ])( + 'rejects cross-workspace %s before membership, billing, or orchestration', + async (_name, useCase, input) => { + await expect(useCase.execute({ principal: delegatedPrincipal, input })).rejects.toMatchObject( + { + name: 'DelegatedWorkspaceAuthorizationError', + code: 'forbidden', + } + ) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.resolveBilling).not.toHaveBeenCalled() + expect(mocks.resolveTokenIdentity).not.toHaveBeenCalled() + expect(mocks.createConnector).not.toHaveBeenCalled() + expect(mocks.updateConnector).not.toHaveBeenCalled() + expect(mocks.deleteConnector).not.toHaveBeenCalled() + expect(mocks.syncConnector).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + } + ) + + it('authorizes current delegated membership before orchestration and owns semantic audit', async () => { + const sameWorkspaceContext = { + ...connectorContext, + workspaceId: 'workspace-a', + knowledgeBaseId: 'knowledge-a', + knowledgeBase: { id: 'knowledge-a', name: 'Workspace A docs' }, + connector: { ...connectorContext.connector, knowledgeBaseId: 'knowledge-a' }, + } + const updatedConnector = { + ...sameWorkspaceContext.connector, + credentialId: 'credential-1', + sourceConfig: {}, + syncIntervalMinutes: 1440, + } + mocks.resolveConnector.mockResolvedValueOnce(sameWorkspaceContext) + mocks.updateConnector.mockResolvedValueOnce({ + success: true, + connector: updatedConnector, + }) + + const result = await updateKnowledgeConnector.execute({ + principal: delegatedPrincipal, + input: { + connectorId: 'connector-b', + assertedWorkspaceId: 'workspace-a', + updates: { status: 'paused' }, + source: 'agent', + }, + }) + + expect(result.connector).toEqual(updatedConnector) + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'shared-user', + 'workspace-a', + null, + undefined, + { forUpdate: undefined } + ) + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.updateConnector.mock.invocationCallOrder[0] + ) + expect(mocks.updateConnector).toHaveBeenCalledWith( + expect.objectContaining({ + connectorId: 'connector-b', + userId: 'shared-user', + source: 'agent', + recordSemanticAudit: false, + }) + ) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-a', + action: 'connector.updated', + metadata: expect.objectContaining({ + operation: 'knowledge.connectors.update', + actor: expect.objectContaining({ kind: 'delegated', serviceId: 'copilot' }), + }), + }) + ) + }) + + it('owns source-config credential resolution and validation after authorization', async () => { + const sameWorkspaceContext = { + ...connectorContext, + workspaceId: 'workspace-a', + knowledgeBaseId: 'knowledge-a', + knowledgeBase: { id: 'knowledge-a', name: 'Workspace A docs' }, + connector: { ...connectorContext.connector, knowledgeBaseId: 'knowledge-a' }, + } + mocks.resolveConnector.mockResolvedValueOnce(sameWorkspaceContext) + mocks.updateConnector.mockResolvedValueOnce({ + success: true, + connector: { ...sameWorkspaceContext.connector, sourceConfig: { space: 'ENG' } }, + }) + + await updateKnowledgeConnector.execute({ + principal: delegatedPrincipal, + input: { + connectorId: 'connector-b', + assertedWorkspaceId: 'workspace-a', + updates: { sourceConfig: { space: 'ENG' } }, + source: 'agent', + }, + }) + + const orchestrationInput = mocks.updateConnector.mock.calls[0]?.[0] as { + validateSourceConfig?: ( + connector: { + connectorType: string + credentialId: string + encryptedApiKey: null + }, + sourceConfig: Record<string, unknown> + ) => Promise<unknown> + } + if (!orchestrationInput.validateSourceConfig) { + throw new Error('Application command did not provide source-config validation') + } + await expect( + orchestrationInput.validateSourceConfig( + { + connectorType: 'confluence', + credentialId: 'credential-1', + encryptedApiKey: null, + }, + { space: 'ENG' } + ) + ).resolves.toBeNull() + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.updateConnector.mock.invocationCallOrder[0] + ) + expect(mocks.resolveTokenIdentity).toHaveBeenCalledWith('credential-1', 'workspace-a') + expect(mocks.refreshToken).toHaveBeenCalledWith( + 'credential-1', + 'credential-owner', + expect.any(String) + ) + expect(mocks.validateConnectorConfig).toHaveBeenCalledWith('access-token', { space: 'ENG' }) + }) + + it.each([ + [ + 'create', + createKnowledgeConnector, + mocks.createConnector, + { + knowledgeBaseId: 'knowledge-a', + assertedWorkspaceId: 'workspace-a', + connectorType: 'confluence', + credentialId: 'credential-1', + sourceConfig: {}, + syncIntervalMinutes: 1440, + resolveBillingAttribution: mocks.resolveBilling, + }, + { + success: true, + connector: { ...connectorContext.connector, knowledgeBaseId: 'knowledge-a' }, + }, + ], + [ + 'delete', + deleteKnowledgeConnector, + mocks.deleteConnector, + { connectorId: 'connector-b', assertedWorkspaceId: 'workspace-a' }, + { success: true, documentsDeleted: 0, documentsKept: 1 }, + ], + [ + 'sync', + syncKnowledgeConnector, + mocks.syncConnector, + { + connectorId: 'connector-b', + assertedWorkspaceId: 'workspace-a', + resolveBillingAttribution: mocks.resolveBilling, + }, + { success: true }, + ], + ])( + 'disables legacy semantic audit and product analytics for %s', + async (_name, useCase, orchestration, input, outcome) => { + const sameWorkspaceContext = { + ...connectorContext, + workspaceId: 'workspace-a', + knowledgeBaseId: 'knowledge-a', + knowledgeBase: { id: 'knowledge-a', name: 'Workspace A docs' }, + connector: { ...connectorContext.connector, knowledgeBaseId: 'knowledge-a' }, + } + mocks.resolveKnowledgeBase.mockResolvedValueOnce(sameWorkspaceContext) + mocks.resolveConnector.mockResolvedValueOnce(sameWorkspaceContext) + orchestration.mockResolvedValueOnce(outcome) + + await useCase.execute({ principal: delegatedPrincipal, input }) + + expect(orchestration).toHaveBeenCalledWith( + expect.objectContaining({ + recordSemanticAudit: false, + recordProductAnalytics: false, + }) + ) + expect(mocks.recordAudit).toHaveBeenCalledOnce() + } + ) + + it('paginates connector documents while returning authoritative total counts', async () => { + const sameWorkspaceContext = { + ...connectorContext, + workspaceId: 'workspace-a', + knowledgeBaseId: 'knowledge-a', + knowledgeBase: { id: 'knowledge-a', name: 'Workspace A docs' }, + connector: { ...connectorContext.connector, knowledgeBaseId: 'knowledge-a' }, + } + mocks.resolveConnector.mockResolvedValueOnce(sameWorkspaceContext) + queueTableRows(document, [{ value: 5 }]) + queueTableRows(document, [{ value: 2 }]) + queueTableRows(document, [ + { id: 'document-3', filename: 'c.txt', userExcluded: false }, + { id: 'document-4', filename: 'd.txt', userExcluded: true }, + ]) + + const result = await listKnowledgeConnectorDocuments.execute({ + principal: delegatedPrincipal, + input: { + knowledgeBaseId: 'knowledge-a', + connectorId: 'connector-b', + assertedWorkspaceId: 'workspace-a', + includeExcluded: true, + limit: 2, + offset: 2, + }, + }) + + expect(result).toEqual({ + documents: [ + { id: 'document-3', filename: 'c.txt', userExcluded: false }, + { id: 'document-4', filename: 'd.txt', userExcluded: true }, + ], + counts: { active: 5, excluded: 2 }, + }) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(2) + expect(dbChainMockFns.offset).toHaveBeenCalledWith(2) + }) + + it('caps connector document mutations before persistence', async () => { + const sameWorkspaceContext = { + ...connectorContext, + workspaceId: 'workspace-a', + knowledgeBaseId: 'knowledge-a', + knowledgeBase: { id: 'knowledge-a', name: 'Workspace A docs' }, + connector: { ...connectorContext.connector, knowledgeBaseId: 'knowledge-a' }, + } + mocks.resolveConnector.mockResolvedValueOnce(sameWorkspaceContext) + + await expect( + updateKnowledgeConnectorDocuments.execute({ + principal: delegatedPrincipal, + input: { + knowledgeBaseId: 'knowledge-a', + connectorId: 'connector-b', + assertedWorkspaceId: 'workspace-a', + operation: 'exclude', + documentIds: Array.from({ length: 101 }, (_, index) => `document-${index}`), + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('deduplicates connector document IDs before mutation and audit', async () => { + const sameWorkspaceContext = { + ...connectorContext, + workspaceId: 'workspace-a', + knowledgeBaseId: 'knowledge-a', + knowledgeBase: { id: 'knowledge-a', name: 'Workspace A docs' }, + connector: { ...connectorContext.connector, knowledgeBaseId: 'knowledge-a' }, + } + mocks.resolveConnector.mockResolvedValueOnce(sameWorkspaceContext) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'document-1' }, { id: 'document-2' }]) + + const result = await updateKnowledgeConnectorDocuments.execute({ + principal: delegatedPrincipal, + input: { + knowledgeBaseId: 'knowledge-a', + connectorId: 'connector-b', + assertedWorkspaceId: 'workspace-a', + operation: 'exclude', + documentIds: ['document-1', 'document-1', 'document-2'], + }, + }) + + expect(result.documentIds).toEqual(['document-1', 'document-2']) + const where = dbChainMockFns.where.mock.calls.at(-1)?.[0] + expect(where).toEqual( + expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + type: 'inArray', + values: ['document-1', 'document-2'], + }), + ]), + }) + ) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ documentIds: ['document-1', 'document-2'] }), + }) + ) + }) +}) diff --git a/apps/sim/lib/knowledge/application/connectors.ts b/apps/sim/lib/knowledge/application/connectors.ts new file mode 100644 index 00000000000..15e9e24b4c7 --- /dev/null +++ b/apps/sim/lib/knowledge/application/connectors.ts @@ -0,0 +1,533 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { document, knowledgeConnector, knowledgeConnectorSyncLog } from '@sim/db/schema' +import { and, asc, count, desc, eq, inArray, isNull } from 'drizzle-orm' +import { decryptApiKey } from '@/lib/api-key/crypto' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { resolveCredentialTokenIdentity } from '@/lib/credentials/access' +import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { resolveKnowledgeAttributedUserId } from '@/lib/knowledge/application/billing' +import { + type ActiveKnowledgeBaseContext, + resolveActiveKnowledgeBaseContext, + resolveActiveKnowledgeConnectorContext, +} from '@/lib/knowledge/application/contexts' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + DEFAULT_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE, + MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_MUTATION_ITEMS, + MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE, +} from '@/lib/knowledge/constants' +import { + getKnowledgeConnector, + type KnowledgeConnectorRow, + performCreateKnowledgeConnector, + performDeleteKnowledgeConnector, + performSyncKnowledgeConnector, + performUpdateKnowledgeConnector, + type SourceConfigRejection, +} from '@/lib/knowledge/orchestration/connectors' +import type { + KnowledgeOperationSource, + KnowledgeOrchestrationResult, +} from '@/lib/knowledge/orchestration/shared' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' + +interface KnowledgeConnectorApplicationInput { + assertedWorkspaceId?: string + source?: KnowledgeOperationSource +} + +export interface ListKnowledgeConnectorsInput extends KnowledgeConnectorApplicationInput { + knowledgeBaseId: string +} + +export interface ReadKnowledgeConnectorInput extends KnowledgeConnectorApplicationInput { + knowledgeBaseId: string + connectorId: string +} + +export interface CreateKnowledgeConnectorInput extends KnowledgeConnectorApplicationInput { + knowledgeBaseId: string + connectorType: string + credentialId?: string + apiKey?: string + sourceConfig: Record<string, unknown> + syncIntervalMinutes: number + resolveBillingAttribution(workspaceId: string): Promise<BillingAttributionSnapshot> +} + +export interface UpdateKnowledgeConnectorInput extends KnowledgeConnectorApplicationInput { + connectorId: string + updates: { + sourceConfig?: Record<string, unknown> + syncIntervalMinutes?: number + status?: 'active' | 'paused' + } +} + +export interface DeleteKnowledgeConnectorInput extends KnowledgeConnectorApplicationInput { + connectorId: string + deleteDocuments?: boolean +} + +export interface SyncKnowledgeConnectorInput extends KnowledgeConnectorApplicationInput { + connectorId: string + rehydrate?: boolean + resolveBillingAttribution(workspaceId: string): Promise<BillingAttributionSnapshot> +} + +export interface ListKnowledgeConnectorDocumentsInput extends ReadKnowledgeConnectorInput { + includeExcluded?: boolean + limit?: number + offset?: number +} + +export interface UpdateKnowledgeConnectorDocumentsInput extends ReadKnowledgeConnectorInput { + operation: 'restore' | 'exclude' + documentIds: string[] +} + +function requireSuccessfulOutcome<T extends object>( + outcome: KnowledgeOrchestrationResult<T>, + fallback: string +): asserts outcome is { success: true } & T { + if (outcome.success) return + if (outcome.errorCode === 'internal') { + throw new Error(fallback, { cause: new Error(outcome.error) }) + } + throw new OrchestrationError(outcome.errorCode, outcome.error) +} + +function connectorTarget(context: ActiveKnowledgeBaseContext) { + return { + id: context.knowledgeBaseId, + name: context.knowledgeBase.name, + workspaceId: context.workspaceId, + } +} + +async function resolveConnectorCredentialAccessToken(input: { + credentialId: string + workspaceId: string + actingUserId: string + requestId: string +}): Promise<string | null> { + const identity = await resolveCredentialTokenIdentity(input.credentialId, input.workspaceId) + if (!identity) return null + return refreshAccessTokenIfNeeded( + input.credentialId, + identity.kind === 'oauth' ? identity.userId : input.actingUserId, + input.requestId + ) +} + +async function validateConnectorSourceConfig(input: { + connector: KnowledgeConnectorRow + sourceConfig: Record<string, unknown> + workspaceId: string + actingUserId: string + requestId: string +}): Promise<SourceConfigRejection | null> { + const { CONNECTOR_REGISTRY } = await import('@/connectors/registry.server') + const connectorConfig = CONNECTOR_REGISTRY[input.connector.connectorType] + if (!connectorConfig) { + return { + message: `Unknown connector type: ${input.connector.connectorType}`, + errorCode: 'validation', + } + } + + let accessToken: string | null = null + if (connectorConfig.auth.mode === 'apiKey') { + if (!input.connector.encryptedApiKey) { + return { + message: 'API key not found. Please reconfigure the connector.', + errorCode: 'validation', + } + } + accessToken = (await decryptApiKey(input.connector.encryptedApiKey)).decrypted + } else { + if (!input.connector.credentialId) { + return { + message: 'OAuth credential not found. Please reconfigure the connector.', + errorCode: 'validation', + } + } + const identity = await resolveCredentialTokenIdentity( + input.connector.credentialId, + input.workspaceId + ) + if (!identity) { + return { + message: 'Credential is no longer usable in this workspace. Please reconnect it.', + errorCode: 'validation', + } + } + accessToken = await refreshAccessTokenIfNeeded( + input.connector.credentialId, + identity.kind === 'oauth' ? identity.userId : input.actingUserId, + input.requestId + ) + if (!accessToken) { + return { + message: 'Failed to refresh access token. Please reconnect your account.', + errorCode: 'unauthorized', + } + } + } + + const validation = await connectorConfig.validateConfig(accessToken, input.sourceConfig) + return validation.valid + ? null + : { message: validation.error || 'Invalid source configuration', errorCode: 'validation' } +} + +export const listKnowledgeConnectors = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.listConnectors, + resolveContext: ({ input }: { input: ListKnowledgeConnectorsInput }) => + resolveActiveKnowledgeBaseContext(input), + async execute({ context }) { + const connectors = await db + .select() + .from(knowledgeConnector) + .where( + and( + eq(knowledgeConnector.knowledgeBaseId, context.knowledgeBaseId), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + .orderBy(desc(knowledgeConnector.createdAt)) + return { connectors: connectors.map(({ encryptedApiKey: _encryptedApiKey, ...rest }) => rest) } + }, +}) + +export const readKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.readConnector, + resolveContext: ({ input }: { input: ReadKnowledgeConnectorInput }) => + resolveActiveKnowledgeConnectorContext(input), + async execute({ context }) { + const connector = await getKnowledgeConnector(context.knowledgeBaseId, context.connectorId) + if (!connector) throw new OrchestrationError('not_found', 'Connector not found') + const syncLogs = await db + .select() + .from(knowledgeConnectorSyncLog) + .where(eq(knowledgeConnectorSyncLog.connectorId, context.connectorId)) + .orderBy(desc(knowledgeConnectorSyncLog.startedAt)) + .limit(10) + const { encryptedApiKey: _encryptedApiKey, ...connectorData } = connector + return { connector: { ...connectorData, syncLogs } } + }, +}) + +export const createKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.createConnector, + resolveContext: ({ input }: { input: CreateKnowledgeConnectorInput }) => + resolveActiveKnowledgeBaseContext(input), + async execute({ principal, input, context, request }) { + const requestId = generateRequestId() + const actingUserId = resolveKnowledgeAttributedUserId(principal, context) + const outcome = await performCreateKnowledgeConnector({ + knowledgeBase: connectorTarget(context), + connectorType: input.connectorType, + credentialId: input.credentialId, + apiKey: input.apiKey, + sourceConfig: input.sourceConfig, + syncIntervalMinutes: input.syncIntervalMinutes, + resolveBillingAttribution: () => input.resolveBillingAttribution(context.workspaceId), + resolveAccessToken: (credentialId) => + resolveConnectorCredentialAccessToken({ + credentialId, + workspaceId: context.workspaceId, + actingUserId, + requestId, + }), + userId: actingUserId, + source: input.source ?? 'agent', + requestId, + request, + recordSemanticAudit: false, + recordProductAnalytics: false, + }) + requireSuccessfulOutcome(outcome, 'Knowledge connector creation failed') + return { connector: outcome.connector, workspaceId: context.workspaceId } + }, + projectAudit: ({ input, context, result }) => ({ + action: AuditAction.CONNECTOR_CREATED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: result.connector.id, + resourceName: result.connector.connectorType, + description: `Created ${result.connector.connectorType} connector for knowledge base "${context.knowledgeBase.name}"`, + metadata: { + source: input.source, + knowledgeBaseId: context.knowledgeBaseId, + knowledgeBaseName: context.knowledgeBase.name, + connectorType: result.connector.connectorType, + syncIntervalMinutes: result.connector.syncIntervalMinutes, + authMode: result.connector.credentialId ? 'oauth' : 'apiKey', + }, + }), +}) + +export const updateKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.updateConnector, + resolveContext: ({ input }: { input: UpdateKnowledgeConnectorInput }) => + resolveActiveKnowledgeConnectorContext(input), + async execute({ principal, input, context, request }) { + const requestId = generateRequestId() + const actingUserId = resolveKnowledgeAttributedUserId(principal, context) + const outcome = await performUpdateKnowledgeConnector({ + knowledgeBase: connectorTarget(context), + connectorId: context.connectorId, + updates: input.updates, + validateSourceConfig: (connector, sourceConfig) => + validateConnectorSourceConfig({ + connector, + sourceConfig, + workspaceId: context.workspaceId, + actingUserId, + requestId, + }), + userId: actingUserId, + source: input.source ?? 'agent', + requestId, + request, + recordSemanticAudit: false, + }) + requireSuccessfulOutcome(outcome, 'Knowledge connector update failed') + return { connector: outcome.connector } + }, + projectAudit: ({ input, context, result }) => ({ + action: AuditAction.CONNECTOR_UPDATED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: result.connector.id, + resourceName: result.connector.connectorType, + description: `Updated connector for knowledge base "${context.knowledgeBase.name}"`, + metadata: { + source: input.source, + knowledgeBaseId: context.knowledgeBaseId, + knowledgeBaseName: context.knowledgeBase.name, + connectorType: result.connector.connectorType, + updatedFields: Object.keys(input.updates).filter( + (key) => input.updates[key as keyof UpdateKnowledgeConnectorInput['updates']] !== undefined + ), + ...(input.updates.syncIntervalMinutes !== undefined && { + syncIntervalMinutes: input.updates.syncIntervalMinutes, + }), + ...(input.updates.status !== undefined && { newStatus: input.updates.status }), + }, + }), +}) + +export const deleteKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.deleteConnector, + resolveContext: ({ input }: { input: DeleteKnowledgeConnectorInput }) => + resolveActiveKnowledgeConnectorContext(input), + async execute({ principal, input, context, request }) { + const outcome = await performDeleteKnowledgeConnector({ + knowledgeBase: connectorTarget(context), + connectorId: context.connectorId, + deleteDocuments: input.deleteDocuments, + userId: resolveKnowledgeAttributedUserId(principal, context), + source: input.source ?? 'agent', + requestId: generateRequestId(), + request, + recordSemanticAudit: false, + recordProductAnalytics: false, + }) + requireSuccessfulOutcome(outcome, 'Knowledge connector deletion failed') + return { + knowledgeBaseId: context.knowledgeBaseId, + workspaceId: context.workspaceId, + connectorId: context.connectorId, + connectorType: context.connector.connectorType, + documentsDeleted: outcome.documentsDeleted, + documentsKept: outcome.documentsKept, + } + }, + projectAudit: ({ input, context, result }) => ({ + action: AuditAction.CONNECTOR_DELETED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: result.connectorId, + resourceName: context.connector.connectorType, + description: `Deleted connector from knowledge base "${context.knowledgeBase.name}"`, + metadata: { + source: input.source, + knowledgeBaseId: context.knowledgeBaseId, + knowledgeBaseName: context.knowledgeBase.name, + connectorType: context.connector.connectorType, + deleteDocuments: input.deleteDocuments ?? false, + documentsDeleted: result.documentsDeleted, + documentsKept: result.documentsKept, + }, + }), +}) + +export const syncKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.syncConnector, + resolveContext: ({ input }: { input: SyncKnowledgeConnectorInput }) => + resolveActiveKnowledgeConnectorContext(input), + async execute({ principal, input, context, request }) { + const outcome = await performSyncKnowledgeConnector({ + knowledgeBase: connectorTarget(context), + connectorId: context.connectorId, + resolveBillingAttribution: () => input.resolveBillingAttribution(context.workspaceId), + rehydrate: input.rehydrate, + userId: resolveKnowledgeAttributedUserId(principal, context), + source: input.source ?? 'agent', + requestId: generateRequestId(), + request, + recordSemanticAudit: false, + recordProductAnalytics: false, + }) + requireSuccessfulOutcome(outcome, 'Knowledge connector sync failed') + return { + knowledgeBaseId: context.knowledgeBaseId, + workspaceId: context.workspaceId, + connectorId: context.connectorId, + connectorType: context.connector.connectorType, + } + }, + projectAudit: ({ input, context, result }) => ({ + action: AuditAction.CONNECTOR_SYNCED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: result.connectorId, + resourceName: context.connector.connectorType, + description: `Triggered manual sync for connector on knowledge base "${context.knowledgeBase.name}"`, + metadata: { + source: input.source, + knowledgeBaseId: context.knowledgeBaseId, + knowledgeBaseName: context.knowledgeBase.name, + connectorType: context.connector.connectorType, + connectorStatus: context.connector.status, + syncType: input.rehydrate ? 'manual-rehydrate' : 'manual', + }, + }), +}) + +const connectorDocumentSelection = { + id: document.id, + filename: document.filename, + externalId: document.externalId, + sourceUrl: document.sourceUrl, + enabled: document.enabled, + userExcluded: document.userExcluded, + uploadedAt: document.uploadedAt, + processingStatus: document.processingStatus, +} + +export const listKnowledgeConnectorDocuments = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.listConnectorDocuments, + resolveContext: ({ input }: { input: ListKnowledgeConnectorDocumentsInput }) => + resolveActiveKnowledgeConnectorContext(input), + async execute({ input, context }) { + const limit = input.limit ?? DEFAULT_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE + const offset = input.offset ?? 0 + if ( + !Number.isInteger(limit) || + limit < 1 || + limit > MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE + ) { + throw new OrchestrationError( + 'validation', + `Connector document limit must be between 1 and ${MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE}` + ) + } + if (!Number.isInteger(offset) || offset < 0) { + throw new OrchestrationError( + 'validation', + 'Connector document offset must be a non-negative integer' + ) + } + const baseConditions = [ + eq(document.connectorId, context.connectorId), + isNull(document.archivedAt), + isNull(document.deletedAt), + ] as const + const [[activeCount], excludedCountRows] = await Promise.all([ + db + .select({ value: count() }) + .from(document) + .where(and(...baseConditions, eq(document.userExcluded, false))), + input.includeExcluded + ? db + .select({ value: count() }) + .from(document) + .where(and(...baseConditions, eq(document.userExcluded, true))) + : Promise.resolve([{ value: 0 }]), + ]) + const excludedCount = excludedCountRows[0] + const documents = await db + .select(connectorDocumentSelection) + .from(document) + .where( + and(...baseConditions, input.includeExcluded ? undefined : eq(document.userExcluded, false)) + ) + .orderBy(asc(document.userExcluded), asc(document.filename)) + .limit(limit) + .offset(offset) + return { + documents, + counts: { active: activeCount?.value ?? 0, excluded: excludedCount?.value ?? 0 }, + } + }, +}) + +export const updateKnowledgeConnectorDocuments = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.updateConnectorDocuments, + resolveContext: ({ input }: { input: UpdateKnowledgeConnectorDocumentsInput }) => + resolveActiveKnowledgeConnectorContext(input), + async execute({ input, context }) { + if (input.documentIds.length === 0) { + throw new OrchestrationError('validation', 'At least one connector document is required') + } + if (input.documentIds.length > MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_MUTATION_ITEMS) { + throw new OrchestrationError( + 'validation', + `At most ${MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_MUTATION_ITEMS} connector documents may be updated at once` + ) + } + const documentIds = [...new Set(input.documentIds)] + const restoring = input.operation === 'restore' + const updated = await db + .update(document) + .set({ userExcluded: !restoring, enabled: restoring }) + .where( + and( + eq(document.connectorId, context.connectorId), + inArray(document.id, documentIds), + eq(document.userExcluded, !restoring), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .returning({ id: document.id }) + return { + operation: input.operation, + count: updated.length, + documentIds: updated.map(({ id }) => id), + } + }, + projectAudit: ({ input, context, result }) => ({ + action: + input.operation === 'restore' + ? AuditAction.CONNECTOR_DOCUMENT_RESTORED + : AuditAction.CONNECTOR_DOCUMENT_EXCLUDED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: context.connectorId, + description: + input.operation === 'restore' + ? `Restored ${result.count} excluded document(s) for knowledge base "${context.knowledgeBase.name}"` + : `Excluded ${result.count} document(s) from knowledge base "${context.knowledgeBase.name}"`, + metadata: { + knowledgeBaseId: context.knowledgeBaseId, + knowledgeBaseName: context.knowledgeBase.name, + operation: input.operation, + documentCount: result.count, + documentIds: result.documentIds, + }, + }), +}) diff --git a/apps/sim/lib/knowledge/application/contexts.test.ts b/apps/sim/lib/knowledge/application/contexts.test.ts index 950ae781ecd..0e04796c5b7 100644 --- a/apps/sim/lib/knowledge/application/contexts.test.ts +++ b/apps/sim/lib/knowledge/application/contexts.test.ts @@ -1,21 +1,47 @@ /** * @vitest-environment node */ + import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ getKnowledgeBase: vi.fn(), + getDocument: vi.fn(), + getDocumentById: vi.fn(), + getTag: vi.fn(), + getConnector: vi.fn(), loadWorkspace: vi.fn(), + loadWorkspaceIncludingArchived: vi.fn(), +})) + +vi.mock('@/lib/knowledge/service', () => ({ + getKnowledgeBaseById: mocks.getKnowledgeBase, +})) + +vi.mock('@/lib/knowledge/documents/service', () => ({ + getKnowledgeDocument: mocks.getDocument, + getKnowledgeDocumentById: mocks.getDocumentById, +})) + +vi.mock('@/lib/knowledge/tags/service', () => ({ + getTagDefinitionById: mocks.getTag, +})) + +vi.mock('@/lib/knowledge/connectors/service', () => ({ + getActiveKnowledgeConnectorReference: mocks.getConnector, })) -vi.mock('@/lib/knowledge/service', () => ({ getKnowledgeBaseById: mocks.getKnowledgeBase })) -vi.mock('@/lib/knowledge/documents/service', () => ({ getKnowledgeDocument: vi.fn() })) vi.mock('@/lib/workspaces/application/workspace-context', () => ({ loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, + loadWorkspaceApplicationContext: mocks.loadWorkspaceIncludingArchived, })) import { + loadKnowledgeWorkspaceAuthorizationContext, resolveActiveKnowledgeBaseContext, + resolveActiveKnowledgeConnectorContext, + resolveActiveKnowledgeTagContext, + resolveCanonicalActiveKnowledgeDocumentContext, resolveKnowledgeWorkspaceContext, } from '@/lib/knowledge/application/contexts' @@ -32,6 +58,7 @@ describe('knowledge application contexts', () => { vi.clearAllMocks() mocks.getKnowledgeBase.mockResolvedValue(knowledgeBase) mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.loadWorkspaceIncludingArchived.mockResolvedValue(workspace) }) it('uses the canonical active-workspace loader', async () => { @@ -41,6 +68,15 @@ describe('knowledge application contexts', () => { expect(mocks.loadWorkspace).toHaveBeenCalledWith('workspace-1') }) + it('uses the neutral canonical loader when archived workspace authorization is explicit', async () => { + await expect( + loadKnowledgeWorkspaceAuthorizationContext('workspace-1', { includeArchived: true }) + ).resolves.toBe(workspace) + expect(mocks.loadWorkspaceIncludingArchived).toHaveBeenCalledWith('workspace-1', { + includeArchived: true, + }) + }) + it('conceals an inactive canonical workspace as knowledge-base absence', async () => { mocks.loadWorkspace.mockResolvedValueOnce(null) @@ -57,4 +93,65 @@ describe('knowledge application contexts', () => { resolveActiveKnowledgeBaseContext({ knowledgeBaseId: 'knowledge-1' }) ).rejects.toBe(failure) }) + + describe('canonical child resources', () => { + beforeEach(() => { + mocks.getKnowledgeBase.mockResolvedValue({ + id: 'knowledge-b', + name: 'Workspace B docs', + workspaceId: 'workspace-b', + }) + mocks.getDocumentById.mockResolvedValue({ + id: 'document-b', + knowledgeBaseId: 'knowledge-b', + }) + mocks.getTag.mockResolvedValue({ + id: 'tag-b', + knowledgeBaseId: 'knowledge-b', + }) + mocks.getConnector.mockResolvedValue({ + id: 'connector-b', + knowledgeBaseId: 'knowledge-b', + connectorType: 'confluence', + status: 'active', + }) + }) + + it('resolves a document parent canonically before comparing the trusted workspace', async () => { + await expect( + resolveCanonicalActiveKnowledgeDocumentContext({ + knowledgeBaseId: 'knowledge-b', + documentId: 'document-b', + assertedWorkspaceId: 'workspace-a', + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.getDocumentById).toHaveBeenCalledWith('document-b') + expect(mocks.getKnowledgeBase).toHaveBeenCalledWith('knowledge-b') + }) + + it('resolves a tag parent canonically before comparing the trusted workspace', async () => { + await expect( + resolveActiveKnowledgeTagContext({ + tagDefinitionId: 'tag-b', + assertedWorkspaceId: 'workspace-a', + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.getTag).toHaveBeenCalledWith('tag-b') + expect(mocks.getKnowledgeBase).toHaveBeenCalledWith('knowledge-b') + }) + + it('resolves a connector parent canonically before comparing the trusted workspace', async () => { + await expect( + resolveActiveKnowledgeConnectorContext({ + connectorId: 'connector-b', + assertedWorkspaceId: 'workspace-a', + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.getConnector).toHaveBeenCalledWith('connector-b') + expect(mocks.getKnowledgeBase).toHaveBeenCalledWith('knowledge-b') + }) + }) }) diff --git a/apps/sim/lib/knowledge/application/contexts.ts b/apps/sim/lib/knowledge/application/contexts.ts index 57a6cd81c64..b81227640a2 100644 --- a/apps/sim/lib/knowledge/application/contexts.ts +++ b/apps/sim/lib/knowledge/application/contexts.ts @@ -1,10 +1,23 @@ +import { db } from '@sim/db' +import { embedding } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { KnowledgeAuthorizationContext } from '@/lib/knowledge/application/authorization' +import type { ChunkData } from '@/lib/knowledge/chunks/types' +import { + type ActiveKnowledgeConnectorReference, + getActiveKnowledgeConnectorReference, +} from '@/lib/knowledge/connectors/service' import type { ActiveKnowledgeDocument } from '@/lib/knowledge/documents/service' -import { getKnowledgeDocument } from '@/lib/knowledge/documents/service' +import { getKnowledgeDocument, getKnowledgeDocumentById } from '@/lib/knowledge/documents/service' import { getKnowledgeBaseById } from '@/lib/knowledge/service' +import { getTagDefinitionById } from '@/lib/knowledge/tags/service' +import type { DocumentTagDefinition } from '@/lib/knowledge/tags/types' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' -import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { + loadActiveWorkspaceApplicationContext, + loadWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' export interface KnowledgeWorkspaceContext extends KnowledgeAuthorizationContext { billedAccountUserId: string @@ -20,12 +33,34 @@ export interface ActiveKnowledgeDocumentContext extends ActiveKnowledgeBaseConte document: ActiveKnowledgeDocument } +export interface ActiveKnowledgeTagContext extends ActiveKnowledgeBaseContext { + tagDefinitionId: string + tagDefinition: DocumentTagDefinition +} + +export interface ActiveKnowledgeConnectorContext extends ActiveKnowledgeBaseContext { + connectorId: string + connector: ActiveKnowledgeConnectorReference +} + +export interface ActiveKnowledgeChunkContext extends ActiveKnowledgeDocumentContext { + chunkId: string + chunk: ChunkData +} + export async function loadKnowledgeWorkspaceContext( workspaceId: string ): Promise<KnowledgeWorkspaceContext | null> { return loadActiveWorkspaceApplicationContext(workspaceId) } +export async function loadKnowledgeWorkspaceAuthorizationContext( + workspaceId: string, + options: { includeArchived?: boolean } = {} +): Promise<KnowledgeWorkspaceContext | null> { + return loadWorkspaceApplicationContext(workspaceId, options) +} + export async function resolveKnowledgeWorkspaceContext(input: { workspaceId: string }): Promise<KnowledgeWorkspaceContext> { @@ -69,3 +104,91 @@ export async function resolveActiveKnowledgeDocumentContext(input: { document, } } + +export async function resolveCanonicalActiveKnowledgeDocumentContext(input: { + knowledgeBaseId: string + documentId: string + assertedWorkspaceId?: string +}): Promise<ActiveKnowledgeDocumentContext> { + const document = await getKnowledgeDocumentById(input.documentId) + if (!document || document.knowledgeBaseId !== input.knowledgeBaseId) { + throw new OrchestrationError('not_found', 'Document not found') + } + const context = await resolveActiveKnowledgeBaseContext({ + knowledgeBaseId: document.knowledgeBaseId, + assertedWorkspaceId: input.assertedWorkspaceId, + }) + return { + ...context, + documentId: document.id, + document, + } +} + +export async function resolveActiveKnowledgeChunkContext(input: { + knowledgeBaseId: string + documentId: string + chunkId: string + assertedWorkspaceId?: string +}): Promise<ActiveKnowledgeChunkContext> { + const [chunk] = await db + .select() + .from(embedding) + .where(and(eq(embedding.id, input.chunkId), eq(embedding.documentId, input.documentId))) + .limit(1) + if (!chunk || chunk.knowledgeBaseId !== input.knowledgeBaseId) { + throw new OrchestrationError('not_found', 'Chunk not found') + } + const context = await resolveCanonicalActiveKnowledgeDocumentContext(input) + return { + ...context, + chunkId: chunk.id, + chunk: chunk as ChunkData, + } +} + +export async function resolveActiveKnowledgeTagContext(input: { + tagDefinitionId: string + knowledgeBaseId?: string + assertedWorkspaceId?: string +}): Promise<ActiveKnowledgeTagContext> { + const tagDefinition = await getTagDefinitionById(input.tagDefinitionId) + if ( + !tagDefinition || + (input.knowledgeBaseId && tagDefinition.knowledgeBaseId !== input.knowledgeBaseId) + ) { + throw new OrchestrationError('not_found', 'Tag definition not found') + } + const context = await resolveActiveKnowledgeBaseContext({ + knowledgeBaseId: tagDefinition.knowledgeBaseId, + assertedWorkspaceId: input.assertedWorkspaceId, + }) + return { + ...context, + tagDefinitionId: tagDefinition.id, + tagDefinition, + } +} + +export async function resolveActiveKnowledgeConnectorContext(input: { + connectorId: string + knowledgeBaseId?: string + assertedWorkspaceId?: string +}): Promise<ActiveKnowledgeConnectorContext> { + const connector = await getActiveKnowledgeConnectorReference(input.connectorId) + if ( + !connector || + (input.knowledgeBaseId && connector.knowledgeBaseId !== input.knowledgeBaseId) + ) { + throw new OrchestrationError('not_found', 'Connector not found') + } + const context = await resolveActiveKnowledgeBaseContext({ + knowledgeBaseId: connector.knowledgeBaseId, + assertedWorkspaceId: input.assertedWorkspaceId, + }) + return { + ...context, + connectorId: connector.id, + connector, + } +} diff --git a/apps/sim/lib/knowledge/application/documents.test.ts b/apps/sim/lib/knowledge/application/documents.test.ts index acfeea66d47..20806987c63 100644 --- a/apps/sim/lib/knowledge/application/documents.test.ts +++ b/apps/sim/lib/knowledge/application/documents.test.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ resolveKnowledgeBase: vi.fn(), resolveDocument: vi.fn(), + resolveCanonicalDocument: vi.fn(), resolvePermission: vi.fn(), resolveHumanBilling: vi.fn(), resolveSystemBilling: vi.fn(), @@ -14,14 +15,22 @@ const mocks = vi.hoisted(() => ({ getDocuments: vi.fn(), createDocument: vi.fn(), deleteDocument: vi.fn(), + updateDocument: vi.fn(), processQueue: vi.fn(), + getProcessingConfig: vi.fn(), + performSingleUpload: vi.fn(), + performBulkUpload: vi.fn(), + markTimedOut: vi.fn(), + retryProcessing: vi.fn(), recordAudit: vi.fn(), + captureServerEvent: vi.fn(), })) vi.mock('@sim/audit', () => ({ AuditAction: { DOCUMENT_UPLOADED: 'document.uploaded', DOCUMENT_DELETED: 'document.deleted', + DOCUMENT_UPDATED: 'document.updated', }, AuditResourceType: { DOCUMENT: 'document' }, recordAudit: mocks.recordAudit, @@ -46,19 +55,34 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ vi.mock('@/lib/knowledge/application/contexts', () => ({ resolveActiveKnowledgeBaseContext: mocks.resolveKnowledgeBase, resolveActiveKnowledgeDocumentContext: mocks.resolveDocument, + resolveCanonicalActiveKnowledgeDocumentContext: mocks.resolveCanonicalDocument, })) vi.mock('@/lib/knowledge/documents/service', () => ({ getDocuments: mocks.getDocuments, createSingleDocument: mocks.createDocument, deleteKnowledgeDocumentInKnowledgeBase: mocks.deleteDocument, + updateDocument: mocks.updateDocument, processDocumentsWithQueue: mocks.processQueue, + getProcessingConfig: mocks.getProcessingConfig, })) +vi.mock('@/lib/knowledge/orchestration/documents', () => ({ + performUploadKnowledgeDocument: mocks.performSingleUpload, + performUploadKnowledgeDocuments: mocks.performBulkUpload, + performMarkKnowledgeDocumentTimedOut: mocks.markTimedOut, + performRetryKnowledgeDocumentProcessing: mocks.retryProcessing, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) + import { OrchestrationError } from '@/lib/core/orchestration/types' import { + bulkDeleteKnowledgeDocuments, + createKnowledgeDocuments, deleteKnowledgeDocument, listKnowledgeDocuments, + updateKnowledgeDocument, uploadKnowledgeDocument, } from '@/lib/knowledge/application/documents' @@ -92,6 +116,11 @@ describe('knowledge document application use cases', () => { documentId: document.id, document, }) + mocks.resolveCanonicalDocument.mockResolvedValue({ + ...context, + documentId: document.id, + document, + }) mocks.resolveSystemBilling.mockResolvedValue({ actorUserId: 'billing-owner-1', workspaceId: 'workspace-1', @@ -102,7 +131,21 @@ describe('knowledge document application use cases', () => { }) mocks.checkUsage.mockResolvedValue({ isExceeded: false }) mocks.createDocument.mockResolvedValue(document) + mocks.updateDocument.mockResolvedValue(document) mocks.processQueue.mockResolvedValue(undefined) + mocks.getProcessingConfig.mockReturnValue({ batchSize: 10, maxConcurrentDocuments: 2 }) + mocks.performBulkUpload.mockResolvedValue({ + success: true, + documents: [ + { + documentId: document.id, + filename: document.filename, + fileUrl: document.fileUrl, + fileSize: document.fileSize, + mimeType: document.mimeType, + }, + ], + }) mocks.getDocuments.mockResolvedValue({ documents: [], pagination: { total: 0, limit: 50, offset: 0, hasMore: false }, @@ -231,6 +274,89 @@ describe('knowledge document application use cases', () => { ) }) + it('rejects a cross-workspace document update before current membership or mutation', async () => { + mocks.resolveCanonicalDocument.mockResolvedValueOnce({ + ...context, + workspaceId: 'workspace-b', + billedAccountUserId: 'billing-owner-b', + knowledgeBaseId: 'knowledge-b', + knowledgeBase: { id: 'knowledge-b', name: 'Workspace B docs' }, + documentId: 'document-b', + document: { ...document, id: 'document-b', knowledgeBaseId: 'knowledge-b' }, + }) + + await expect( + updateKnowledgeDocument.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'shared-user', + workspaceId: 'workspace-a', + delegationId: 'tool-call-1', + audience: 'sim:knowledge', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: {}, + }, + input: { + knowledgeBaseId: 'knowledge-b', + documentId: 'document-b', + assertedWorkspaceId: 'workspace-a', + filename: 'renamed.pdf', + }, + }) + ).rejects.toMatchObject({ + name: 'DelegatedWorkspaceAuthorizationError', + code: 'forbidden', + }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.updateDocument).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('authorizes and audits a same-workspace delegated document update', async () => { + await updateKnowledgeDocument.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'shared-user', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:knowledge', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: {}, + }, + input: { + knowledgeBaseId: 'knowledge-1', + documentId: 'document-1', + assertedWorkspaceId: 'workspace-1', + enabled: false, + source: 'agent', + }, + }) + + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.updateDocument.mock.invocationCallOrder[0] + ) + expect(mocks.updateDocument).toHaveBeenCalledWith( + 'document-1', + { filename: undefined, enabled: false }, + expect.any(String) + ) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'document.updated', + metadata: expect.objectContaining({ + operation: 'knowledge.documents.update', + enabled: false, + actor: expect.objectContaining({ kind: 'delegated', serviceId: 'copilot' }), + }), + }) + ) + }) + it('propagates document infrastructure failures without audit', async () => { const failure = new Error('storage ledger unavailable') mocks.createDocument.mockRejectedValueOnce(failure) @@ -248,4 +374,217 @@ describe('knowledge document application use cases', () => { expect(mocks.recordAudit).not.toHaveBeenCalled() }) + + it('bounds bulk document creation before billing or orchestration', async () => { + await expect( + createKnowledgeDocuments.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + documents: Array.from({ length: 101 }, (_, index) => ({ + filename: `document-${index}.txt`, + fileUrl: `/document-${index}.txt`, + fileSize: 1, + mimeType: 'text/plain', + })), + bulk: true, + resolveSecretProvenances: () => undefined, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.checkUsage).not.toHaveBeenCalled() + expect(mocks.performBulkUpload).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('disables legacy analytics and projects delegated audit for bulk creation', async () => { + await createKnowledgeDocuments.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'shared-user', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:knowledge', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + }, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + documents: [ + { + filename: document.filename, + fileUrl: document.fileUrl, + fileSize: document.fileSize, + mimeType: document.mimeType, + }, + ], + bulk: true, + source: 'agent', + resolveSecretProvenances: () => undefined, + }, + }) + + expect(mocks.performBulkUpload).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'shared-user', + recordSemanticAudit: false, + recordProductAnalytics: false, + }) + ) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + action: 'document.uploaded', + metadata: expect.objectContaining({ + operation: 'knowledge.documents.upload', + actor: expect.objectContaining({ kind: 'delegated', serviceId: 'copilot' }), + }), + }) + ) + expect(mocks.captureServerEvent).not.toHaveBeenCalled() + }) + + it('bounds best-effort document deletion before canonical knowledge loading', async () => { + await expect( + bulkDeleteKnowledgeDocuments.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + documentIds: Array.from({ length: 101 }, (_, index) => `document-${index}`), + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveKnowledgeBase).not.toHaveBeenCalled() + expect(mocks.deleteDocument).not.toHaveBeenCalled() + }) + + it('conceals a cross-knowledge-base bulk document before mutation for a dual-workspace subject', async () => { + mocks.resolveCanonicalDocument.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Document not found') + ) + + const result = await bulkDeleteKnowledgeDocuments.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'dual-workspace-user', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:knowledge', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + }, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + documentIds: ['workspace-2-document'], + }, + }) + + expect(result).toMatchObject({ deleted: [], failed: ['workspace-2-document'] }) + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'dual-workspace-user', + 'workspace-1', + null, + undefined, + { forUpdate: undefined } + ) + expect(mocks.deleteDocument).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('returns partial document outcomes and keeps product analytics out of the application', async () => { + mocks.resolveCanonicalDocument.mockImplementation(async ({ documentId }) => ({ + ...context, + documentId, + document: { ...document, id: documentId, filename: `${documentId}.pdf` }, + })) + mocks.deleteDocument + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new OrchestrationError('conflict', 'Document is locked')) + + const result = await bulkDeleteKnowledgeDocuments.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + documentIds: ['document-1', 'document-2'], + source: 'agent', + }, + }) + + expect(result).toMatchObject({ + deleted: ['document-1'], + failed: ['document-2'], + cancelled: false, + }) + expect(mocks.resolvePermission).toHaveBeenCalledTimes(3) + expect(mocks.recordAudit).toHaveBeenCalledOnce() + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'document-1', + metadata: expect.objectContaining({ operation: 'knowledge.documents.bulk_delete' }), + }) + ) + expect(mocks.captureServerEvent).not.toHaveBeenCalled() + }) + + it('stops between document deletions while auditing completed items', async () => { + const controller = new AbortController() + mocks.resolveCanonicalDocument.mockImplementation(async ({ documentId }) => ({ + ...context, + documentId, + document: { ...document, id: documentId }, + })) + mocks.deleteDocument.mockImplementationOnce(async () => { + controller.abort('user stopped') + }) + + const result = await bulkDeleteKnowledgeDocuments.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + documentIds: ['document-1', 'document-2'], + cancellationSignal: controller.signal, + }, + }) + + expect(result).toMatchObject({ deleted: ['document-1'], cancelled: true }) + expect(mocks.deleteDocument).toHaveBeenCalledOnce() + expect(mocks.recordAudit).toHaveBeenCalledOnce() + }) + + it('audits completed document deletions before propagating infrastructure failure', async () => { + const failure = new Error('document store unavailable') + mocks.resolveCanonicalDocument.mockImplementation(async ({ documentId }) => ({ + ...context, + documentId, + document: { ...document, id: documentId }, + })) + mocks.deleteDocument.mockResolvedValueOnce(undefined).mockRejectedValueOnce(failure) + + await expect( + bulkDeleteKnowledgeDocuments.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + documentIds: ['document-1', 'document-2'], + }, + }) + ).rejects.toBe(failure) + + expect(mocks.recordAudit).toHaveBeenCalledOnce() + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ resourceId: 'document-1' }) + ) + expect(mocks.captureServerEvent).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/knowledge/application/documents.ts b/apps/sim/lib/knowledge/application/documents.ts index 76f81c22e7d..70a9f8af830 100644 --- a/apps/sim/lib/knowledge/application/documents.ts +++ b/apps/sim/lib/knowledge/application/documents.ts @@ -1,29 +1,57 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { document as documentTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { and, eq, isNull } from 'drizzle-orm' import { checkAttributedUsageLimits } from '@/lib/billing/core/billing-attribution' -import { OrchestrationError } from '@/lib/core/orchestration/types' +import { authorizeWorkspaceOperation } from '@/lib/core/application' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' +import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { + BULK_DELETE_KNOWLEDGE_DOCUMENTS_COST_POLICY, + type KnowledgeBatchExecutionResult, + requireBoundedKnowledgeBatch, + rethrowKnowledgeBatchTerminalFailure, +} from '@/lib/knowledge/application/batch-policy' import { KnowledgeUsageLimitExceededError, resolveKnowledgeAttributedUserId, resolveKnowledgeBillingAttribution, } from '@/lib/knowledge/application/billing' import { + type ActiveKnowledgeBaseContext, type ActiveKnowledgeDocumentContext, resolveActiveKnowledgeBaseContext, resolveActiveKnowledgeDocumentContext, + resolveCanonicalActiveKnowledgeDocumentContext, } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE } from '@/lib/knowledge/constants' import { + bulkDocumentOperation, + bulkDocumentOperationByFilter, + createDocumentRecords, createSingleDocument, type DocumentData, + deleteDocument, deleteKnowledgeDocumentInKnowledgeBase, getDocuments, + getProcessingConfig, type ProcessingOptions, processDocumentsWithQueue, + updateDocument, } from '@/lib/knowledge/documents/service' +import type { TagFilterCondition } from '@/lib/knowledge/documents/tag-filter' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import { + performMarkKnowledgeDocumentTimedOut, + performRetryKnowledgeDocumentProcessing, + performUploadKnowledgeDocument, + performUploadKnowledgeDocuments, +} from '@/lib/knowledge/orchestration/documents' +import type { KnowledgeDocumentWriteSecretProvenance } from '@/lib/knowledge/secret-provenance' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' import { validateFileType } from '@/lib/uploads/utils/validation' @@ -38,6 +66,7 @@ export interface ListKnowledgeDocumentsInput { offset?: number sortBy?: DocumentSortField sortOrder?: SortOrder + tagFilters?: TagFilterCondition[] } export interface ReadKnowledgeDocumentInput { @@ -75,10 +104,89 @@ export interface UploadKnowledgeDocumentInput extends UploadKnowledgeDocumentAdm source?: string } +export interface CreateKnowledgeDocumentsInput extends UploadKnowledgeDocumentAdmissionInput { + documents: KnowledgeDocumentInput[] + bulk: boolean + processingOptions?: ProcessingOptions + source?: 'ui' | 'api' | 'agent' + resolveBillingAttribution?( + workspaceId: string + ): Promise<Awaited<ReturnType<typeof resolveKnowledgeBillingAttribution>>> + resolveSecretProvenances(input: { + userId: string + workspaceId: string + }): KnowledgeDocumentWriteSecretProvenance[] | undefined +} + export interface DeleteKnowledgeDocumentInput extends ReadKnowledgeDocumentInput { source?: string } +export interface BulkDeleteKnowledgeDocumentsInput extends UploadKnowledgeDocumentAdmissionInput { + documentIds: string[] + cancellationSignal?: AbortSignal + source?: string +} + +interface DeletedKnowledgeDocument { + id: string + filename: string + fileSize: number + mimeType: string +} + +export interface BulkDeleteKnowledgeDocumentsResult { + knowledgeBaseId: string + deleted: string[] + failed: string[] + deletedDocuments: DeletedKnowledgeDocument[] + cancelled: boolean +} + +interface BulkDeleteKnowledgeDocumentsExecutionResult + extends BulkDeleteKnowledgeDocumentsResult, + KnowledgeBatchExecutionResult {} + +interface BulkDeleteKnowledgeDocumentsContext extends ActiveKnowledgeBaseContext { + documentIds: string[] +} + +export interface UpdateKnowledgeDocumentInput extends ReadKnowledgeDocumentInput { + filename?: string + enabled?: boolean + updates?: Parameters<typeof updateDocument>[1] + markFailedDueToTimeout?: boolean + retryProcessing?: boolean + resolveBillingAttribution?( + workspaceId: string + ): Promise<Awaited<ReturnType<typeof resolveKnowledgeBillingAttribution>>> + source?: string +} + +export interface BulkKnowledgeDocumentsInput extends UploadKnowledgeDocumentAdmissionInput { + operation: 'enable' | 'disable' | 'delete' + documentIds?: string[] + selectAll?: boolean + enabledFilter?: 'all' | 'enabled' | 'disabled' +} + +export interface UpsertKnowledgeDocumentInput extends UploadKnowledgeDocumentAdmissionInput { + documentId?: string + filename: string + fileUrl: string + fileSize: number + mimeType: string + documentTagsData?: string + processingOptions?: ProcessingOptions + resolveBillingAttribution( + workspaceId: string + ): Promise<Awaited<ReturnType<typeof resolveKnowledgeBillingAttribution>>> + resolveSecretProvenances(input: { + userId: string + workspaceId: string + }): KnowledgeDocumentWriteSecretProvenance[] | undefined +} + export const listKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.listDocuments, resolveContext: ({ input }: { input: ListKnowledgeDocumentsInput }) => @@ -92,7 +200,7 @@ export const listKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({ if (!Number.isInteger(offset) || offset < 0) { throw new OrchestrationError('validation', 'Document offset must be a non-negative integer') } - return getDocuments( + const result = await getDocuments( context.knowledgeBaseId, { enabledFilter: input.enabledFilter === 'all' ? undefined : input.enabledFilter, @@ -101,9 +209,11 @@ export const listKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({ offset, sortBy: input.sortBy, sortOrder: input.sortOrder, + tagFilters: input.tagFilters, }, generateRequestId() ) + return { ...result, workspaceId: context.workspaceId } }, }) @@ -112,7 +222,7 @@ export const readKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ resolveContext: ({ input }: { input: ReadKnowledgeDocumentInput }) => resolveActiveKnowledgeDocumentContext(input), async execute({ context }: { context: ActiveKnowledgeDocumentContext }) { - return { document: context.document } + return { document: context.document, workspaceId: context.workspaceId } }, }) @@ -211,6 +321,265 @@ export const uploadKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ }), }) +export const createKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.uploadDocument, + resolveContext: ({ input }: { input: CreateKnowledgeDocumentsInput }) => + resolveActiveKnowledgeBaseContext(input), + async execute({ principal, input, context, request }) { + if (input.documents.length === 0) { + throw new OrchestrationError('validation', 'No documents specified') + } + if (input.documents.length > MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE) { + throw new OrchestrationError( + 'validation', + `At most ${MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE} documents may be created at once` + ) + } + const billingAttribution = input.resolveBillingAttribution + ? await input.resolveBillingAttribution(context.workspaceId) + : await resolveKnowledgeBillingAttribution(principal, context) + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + throw new KnowledgeUsageLimitExceededError( + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + const userId = resolveKnowledgeAttributedUserId(principal, context) + const secretProvenances = input.resolveSecretProvenances({ + userId, + workspaceId: context.workspaceId, + }) + const knowledgeBase = { + id: context.knowledgeBaseId, + name: context.knowledgeBase.name, + workspaceId: context.workspaceId, + } + if (input.bulk) { + const outcome = await performUploadKnowledgeDocuments({ + knowledgeBase, + documents: input.documents, + processingOptions: input.processingOptions, + billingAttribution, + uploadedBy: userId, + secretProvenances, + userId, + source: input.source ?? 'ui', + request, + recordSemanticAudit: false, + recordProductAnalytics: false, + }) + if (!outcome.success) { + if (outcome.errorCode === 'internal') throw new Error('Knowledge document creation failed') + throw new OrchestrationError(outcome.errorCode, outcome.error) + } + const { batchSize, maxConcurrentDocuments } = getProcessingConfig() + return { + kind: 'bulk' as const, + data: { + total: outcome.documents.length, + documentsCreated: outcome.documents.map((document) => ({ + documentId: document.documentId, + filename: document.filename, + status: 'pending' as const, + })), + processingMethod: 'background', + processingConfig: { + maxConcurrentDocuments, + batchSize, + totalBatches: Math.ceil(outcome.documents.length / batchSize), + }, + }, + workspaceId: context.workspaceId, + knowledgeBaseId: context.knowledgeBaseId, + userId, + secretProvenances, + } + } + + const document = input.documents[0] + if (!document) throw new OrchestrationError('validation', 'No documents specified') + const outcome = await performUploadKnowledgeDocument({ + knowledgeBase, + document, + billingAttribution, + uploadedBy: userId, + secretProvenance: secretProvenances?.[0], + userId, + source: input.source ?? 'ui', + request, + recordSemanticAudit: false, + recordProductAnalytics: false, + }) + if (!outcome.success) { + if (outcome.errorCode === 'internal') throw new Error('Knowledge document creation failed') + throw new OrchestrationError(outcome.errorCode, outcome.error) + } + return { + kind: 'single' as const, + data: outcome.document, + workspaceId: context.workspaceId, + userId, + secretProvenances, + } + }, + projectAudit: ({ input, context, result }) => { + if (result.kind === 'bulk') { + return { + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: context.knowledgeBaseId, + resourceName: `${result.data.total} document(s)`, + description: `Uploaded ${result.data.total} document(s) to knowledge base "${context.knowledgeBase.name}"`, + metadata: { + source: input.source, + knowledgeBaseId: context.knowledgeBaseId, + knowledgeBaseName: context.knowledgeBase.name, + fileCount: result.data.total, + }, + } + } + return { + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: result.data.id, + resourceName: result.data.filename, + description: `Uploaded document "${result.data.filename}" to knowledge base "${context.knowledgeBase.name}"`, + metadata: { + source: input.source, + knowledgeBaseId: context.knowledgeBaseId, + knowledgeBaseName: context.knowledgeBase.name, + fileName: result.data.filename, + fileType: result.data.mimeType, + fileSize: result.data.fileSize, + }, + } + }, +}) + +export const upsertKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.uploadDocument, + resolveContext: ({ input }: { input: UpsertKnowledgeDocumentInput }) => + resolveActiveKnowledgeBaseContext(input), + async execute({ principal, input, context }) { + const billingAttribution = await input.resolveBillingAttribution(context.workspaceId) + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + throw new KnowledgeUsageLimitExceededError( + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + const userId = resolveKnowledgeAttributedUserId(principal, context) + const secretProvenances = input.resolveSecretProvenances({ + userId, + workspaceId: context.workspaceId, + }) + let existingDocumentId: string | null = null + if (input.documentId) { + const [existing] = await db + .select({ id: documentTable.id }) + .from(documentTable) + .where( + and( + eq(documentTable.id, input.documentId), + eq(documentTable.knowledgeBaseId, context.knowledgeBaseId), + isNull(documentTable.deletedAt) + ) + ) + .limit(1) + existingDocumentId = existing?.id ?? null + } else { + const [existing] = await db + .select({ id: documentTable.id }) + .from(documentTable) + .where( + and( + eq(documentTable.filename, input.filename), + eq(documentTable.knowledgeBaseId, context.knowledgeBaseId), + isNull(documentTable.deletedAt) + ) + ) + .limit(1) + existingDocumentId = existing?.id ?? null + } + const requestId = generateRequestId() + const createdDocuments = await createDocumentRecords( + [ + { + filename: input.filename, + fileUrl: input.fileUrl, + fileSize: input.fileSize, + mimeType: input.mimeType, + ...(input.documentTagsData ? { documentTagsData: input.documentTagsData } : {}), + }, + ], + context.knowledgeBaseId, + requestId, + userId, + secretProvenances + ) + const createdDocument = createdDocuments[0] + if (!createdDocument) throw new Error('Knowledge document upsert created no document record') + if (existingDocumentId) { + try { + await deleteDocument(existingDocumentId, requestId) + } catch (error) { + try { + await deleteDocument(createdDocument.documentId, requestId) + } catch (rollbackError) { + logger.error('Failed to remove replacement after document upsert failure', { + knowledgeBaseId: context.knowledgeBaseId, + documentId: createdDocument.documentId, + rollbackError, + }) + } + throw new Error('Failed to replace existing document', { cause: error }) + } + } + processDocumentsWithQueue( + createdDocuments, + context.knowledgeBaseId, + input.processingOptions ?? {}, + requestId, + billingAttribution + ).catch((error: unknown) => { + logger.error('Knowledge document upsert processing pipeline failed', { + knowledgeBaseId: context.knowledgeBaseId, + documentId: createdDocument.documentId, + error, + }) + }) + const isUpdate = existingDocumentId !== null + const { maxConcurrentDocuments, batchSize } = getProcessingConfig() + return { + document: createdDocument, + knowledgeBaseId: context.knowledgeBaseId, + isUpdate, + previousDocumentId: existingDocumentId, + processingConfig: { maxConcurrentDocuments, batchSize }, + workspaceId: context.workspaceId, + userId, + secretProvenances, + } + }, + projectAudit: ({ input, context, result }) => ({ + action: result.isUpdate ? AuditAction.DOCUMENT_UPDATED : AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: context.knowledgeBaseId, + resourceName: input.filename, + description: result.isUpdate + ? `Upserted (replaced) document "${input.filename}" in knowledge base "${context.knowledgeBaseId}"` + : `Upserted (created) document "${input.filename}" in knowledge base "${context.knowledgeBaseId}"`, + metadata: { + knowledgeBaseName: context.knowledgeBase.name, + fileName: input.filename, + fileType: input.mimeType, + fileSize: input.fileSize, + previousDocumentId: result.previousDocumentId, + isUpdate: result.isUpdate, + }, + }), +}) + export const deleteKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.deleteDocument, resolveContext: ({ input }: { input: DeleteKnowledgeDocumentInput }) => @@ -223,6 +592,8 @@ export const deleteKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ ) return { id: context.documentId, + knowledgeBaseId: context.knowledgeBaseId, + workspaceId: context.workspaceId, filename: context.document.filename, fileSize: context.document.fileSize, mimeType: context.document.mimeType, @@ -244,3 +615,184 @@ export const deleteKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ }, }), }) + +export const bulkDeleteKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.bulkDeleteDocuments, + async resolveContext({ + input, + }: { + input: BulkDeleteKnowledgeDocumentsInput + }): Promise<BulkDeleteKnowledgeDocumentsContext> { + const documentIds = requireBoundedKnowledgeBatch( + input.documentIds, + 'document IDs', + BULK_DELETE_KNOWLEDGE_DOCUMENTS_COST_POLICY.maxItems + ) + return { + ...(await resolveActiveKnowledgeBaseContext(input)), + documentIds, + } + }, + async execute({ + principal, + input, + context, + }): Promise<BulkDeleteKnowledgeDocumentsExecutionResult> { + const deletedDocuments: DeletedKnowledgeDocument[] = [] + const failed: string[] = [] + let terminalFailure: KnowledgeBatchExecutionResult['terminalFailure'] + + for (const documentId of context.documentIds) { + if (input.cancellationSignal?.aborted) break + try { + const canonical = await resolveCanonicalActiveKnowledgeDocumentContext({ + knowledgeBaseId: context.knowledgeBaseId, + documentId, + assertedWorkspaceId: context.workspaceId, + }) + await authorizeWorkspaceOperation( + principal, + knowledgeOperations.bulkDeleteDocuments, + canonical, + { delegation: knowledgeDelegationPolicy } + ) + if (input.cancellationSignal?.aborted) break + await deleteKnowledgeDocumentInKnowledgeBase( + canonical.knowledgeBaseId, + canonical.documentId, + generateRequestId() + ) + deletedDocuments.push({ + id: canonical.documentId, + filename: canonical.document.filename, + fileSize: canonical.document.fileSize, + mimeType: canonical.document.mimeType, + }) + } catch (error) { + const classified = asOrchestrationError(error) + if (classified && classified.code !== 'internal') { + failed.push(documentId) + continue + } + terminalFailure = { error } + break + } + } + + return { + knowledgeBaseId: context.knowledgeBaseId, + deleted: deletedDocuments.map((document) => document.id), + failed, + deletedDocuments, + cancelled: input.cancellationSignal?.aborted ?? false, + ...(terminalFailure && { terminalFailure }), + } + }, + projectAudit: ({ input, context, result }) => + result.deletedDocuments.map((document) => ({ + action: AuditAction.DOCUMENT_DELETED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: document.id, + resourceName: document.filename, + description: `Deleted document "${document.filename}" from knowledge base "${context.knowledgeBase.name}"`, + metadata: { + source: input.source, + knowledgeBaseId: context.knowledgeBaseId, + knowledgeBaseName: context.knowledgeBase.name, + fileName: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + }, + })), + afterSuccess: ({ result }) => rethrowKnowledgeBatchTerminalFailure(result), +}) + +export const updateKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.updateDocument, + resolveContext: ({ input }: { input: UpdateKnowledgeDocumentInput }) => + resolveCanonicalActiveKnowledgeDocumentContext(input), + async execute({ principal, input, context }) { + if (input.markFailedDueToTimeout || input.retryProcessing) { + const outcome = input.markFailedDueToTimeout + ? await performMarkKnowledgeDocumentTimedOut({ document: context.document }) + : await performRetryKnowledgeDocumentProcessing({ + knowledgeBaseId: context.knowledgeBaseId, + document: context.document, + billingAttribution: input.resolveBillingAttribution + ? await input.resolveBillingAttribution(context.workspaceId) + : await resolveKnowledgeBillingAttribution(principal, context), + }) + if (!outcome.success) { + if (outcome.errorCode === 'internal') { + throw new Error('Knowledge document processing operation failed') + } + throw new OrchestrationError(outcome.errorCode, outcome.error) + } + return { + kind: 'processing' as const, + documentId: context.documentId, + status: outcome.status, + message: outcome.message, + } + } + const updates = input.updates ?? { filename: input.filename, enabled: input.enabled } + const updatedFields = Object.keys(updates).filter( + (key) => updates[key as keyof typeof updates] !== undefined + ) + if (updatedFields.length === 0) { + throw new OrchestrationError('validation', 'No updates specified') + } + return { + kind: 'updated' as const, + document: await updateDocument(context.documentId, updates, generateRequestId()), + updatedFields, + } + }, + projectAudit: ({ input, context, result }) => { + if (result.kind === 'processing') return [] + return { + action: AuditAction.DOCUMENT_UPDATED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: result.document.id, + resourceName: result.document.filename, + description: `Updated document "${result.document.filename}" in knowledge base "${context.knowledgeBase.name}"`, + metadata: { + source: input.source, + knowledgeBaseId: context.knowledgeBaseId, + knowledgeBaseName: context.knowledgeBase.name, + fileName: result.document.filename, + updatedFields: result.updatedFields, + ...(input.enabled !== undefined && { enabled: input.enabled }), + }, + } + }, +}) + +export const bulkUpdateKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.bulkDocuments, + resolveContext: ({ input }: { input: BulkKnowledgeDocumentsInput }) => + resolveActiveKnowledgeBaseContext(input), + async execute({ input, context }) { + const result = input.selectAll + ? await bulkDocumentOperationByFilter( + context.knowledgeBaseId, + input.operation, + input.enabledFilter, + generateRequestId() + ) + : input.documentIds?.length + ? await bulkDocumentOperation( + context.knowledgeBaseId, + input.operation, + input.documentIds, + generateRequestId() + ) + : null + if (!result) throw new OrchestrationError('validation', 'No documents specified') + return { + operation: input.operation, + successCount: result.successCount, + updatedDocuments: result.updatedDocuments, + } + }, +}) diff --git a/apps/sim/lib/knowledge/application/folders.test.ts b/apps/sim/lib/knowledge/application/folders.test.ts index 53e873224f7..33442e38f6d 100644 --- a/apps/sim/lib/knowledge/application/folders.test.ts +++ b/apps/sim/lib/knowledge/application/folders.test.ts @@ -13,7 +13,6 @@ const mocks = vi.hoisted(() => ({ relocateByPath: vi.fn(), deleteByPath: vi.fn(), recordAudit: vi.fn(), - notify: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -53,10 +52,6 @@ vi.mock('@/lib/folders/orchestration', () => ({ deleteFolderByPath: mocks.deleteByPath, })) -vi.mock('@/lib/realtime/notify', () => ({ - notifyFolderResourceChanged: mocks.notify, -})) - import { createKnowledgeFolder, deleteKnowledgeFolder, @@ -101,7 +96,6 @@ describe('knowledge folder application use cases', () => { path: '/Docs', deletedItems: { folders: 2, knowledgeBases: 3 }, }) - mocks.notify.mockResolvedValue(undefined) }) it('resolves a canonical parent path before listing', async () => { @@ -161,9 +155,7 @@ describe('knowledge folder application use cases', () => { }), }) ) - expect(mocks.recordAudit.mock.invocationCallOrder[0]).toBeLessThan( - mocks.notify.mock.invocationCallOrder[0] - ) + expect(mocks.recordAudit).toHaveBeenCalledOnce() }) it('preserves recursive cascade counts', async () => { @@ -178,7 +170,7 @@ describe('knowledge folder application use cases', () => { expect(result.deletedItems).toEqual({ folders: 2, knowledgeBases: 3 }) }) - it('propagates infrastructure failures without audit or notification', async () => { + it('propagates infrastructure failures without audit', async () => { const failure = new Error('folder database unavailable') mocks.createAtPath.mockRejectedValueOnce(failure) @@ -190,6 +182,5 @@ describe('knowledge folder application use cases', () => { ).rejects.toBe(failure) expect(mocks.recordAudit).not.toHaveBeenCalled() - expect(mocks.notify).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/knowledge/application/folders.ts b/apps/sim/lib/knowledge/application/folders.ts index 5485322c039..a3f530c25bb 100644 --- a/apps/sim/lib/knowledge/application/folders.ts +++ b/apps/sim/lib/knowledge/application/folders.ts @@ -17,7 +17,6 @@ import { resolveKnowledgeAttributedUserId } from '@/lib/knowledge/application/bi import { resolveKnowledgeWorkspaceContext } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE } from '@/lib/knowledge/constants' -import { notifyFolderResourceChanged } from '@/lib/realtime/notify' type KnowledgeFolder = typeof folder.$inferSelect & { path: string } @@ -120,7 +119,6 @@ export const createKnowledgeFolder = defineAuthorizedKnowledgeUseCase({ folderResourceType: 'knowledge_base', }, }), - afterSuccess: ({ context }) => notifyFolderResourceChanged('knowledge_base', context.workspaceId), }) export const relocateKnowledgeFolder = defineAuthorizedKnowledgeUseCase({ @@ -154,7 +152,6 @@ export const relocateKnowledgeFolder = defineAuthorizedKnowledgeUseCase({ folderResourceType: 'knowledge_base', }, }), - afterSuccess: ({ context }) => notifyFolderResourceChanged('knowledge_base', context.workspaceId), }) export const deleteKnowledgeFolder = defineAuthorizedKnowledgeUseCase({ @@ -205,5 +202,4 @@ export const deleteKnowledgeFolder = defineAuthorizedKnowledgeUseCase({ deletedItems: result.deletedItems, }, }), - afterSuccess: ({ context }) => notifyFolderResourceChanged('knowledge_base', context.workspaceId), }) diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.test.ts b/apps/sim/lib/knowledge/application/knowledge-bases.test.ts index 9ae6b3f7fc9..c3318e7daa1 100644 --- a/apps/sim/lib/knowledge/application/knowledge-bases.test.ts +++ b/apps/sim/lib/knowledge/application/knowledge-bases.test.ts @@ -2,10 +2,12 @@ * @vitest-environment node */ +import { dbChainMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ resolveWorkspace: vi.fn(), + loadAuthorizationWorkspace: vi.fn(), resolveKnowledgeBase: vi.fn(), resolvePermission: vi.fn(), resolveFolderPath: vi.fn(), @@ -13,8 +15,15 @@ const mocks = vi.hoisted(() => ({ updateRecord: vi.fn(), deleteRecord: vi.fn(), listRecords: vi.fn(), + listInternalRecords: vi.fn(), + getRecord: vi.fn(), + getRestorableRecord: vi.fn(), + performUpdate: vi.fn(), + performDelete: vi.fn(), + performRestore: vi.fn(), loadFolderIndex: vi.fn(), recordAudit: vi.fn(), + knowledgeBaseDeleted: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -37,11 +46,16 @@ vi.mock('@sim/platform-authz/workspace', () => ({ resolveEffectiveWorkspacePermission: mocks.resolvePermission, })) +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { knowledgeBaseDeleted: mocks.knowledgeBaseDeleted }, +})) + vi.mock('@/lib/folders/queries', () => ({ loadActiveFolderPathIndex: mocks.loadFolderIndex, })) vi.mock('@/lib/knowledge/application/contexts', () => ({ + loadKnowledgeWorkspaceAuthorizationContext: mocks.loadAuthorizationWorkspace, resolveKnowledgeWorkspaceContext: mocks.resolveWorkspace, resolveActiveKnowledgeBaseContext: mocks.resolveKnowledgeBase, })) @@ -60,13 +74,30 @@ vi.mock('@/lib/knowledge/service', () => ({ createAuthorizedKnowledgeBase: mocks.createRecord, updateKnowledgeBase: mocks.updateRecord, deleteKnowledgeBase: mocks.deleteRecord, + getKnowledgeBaseById: mocks.getRecord, + getKnowledgeBases: mocks.listInternalRecords, getWorkspaceKnowledgeBases: mocks.listRecords, })) +vi.mock('@/lib/knowledge/orchestration', () => ({ + getRestorableKnowledgeBase: mocks.getRestorableRecord, + performUpdateKnowledgeBase: mocks.performUpdate, + performDeleteKnowledgeBase: mocks.performDelete, + performRestoreKnowledgeBase: mocks.performRestore, +})) + import { OrchestrationError } from '@/lib/core/orchestration/types' import { + bulkDeleteKnowledgeBases, createKnowledgeBase, + deleteInternalKnowledgeBase, + listArchivedKnowledgeBases, + listInternalKnowledgeBases, + listKnowledgeBaseCatalog, + readInternalKnowledgeBase, readKnowledgeBase, + restoreInternalKnowledgeBase, + updateInternalKnowledgeBase, updateKnowledgeBaseOperation, } from '@/lib/knowledge/application/knowledge-bases' @@ -99,6 +130,7 @@ describe('knowledge base application use cases', () => { beforeEach(() => { vi.clearAllMocks() mocks.resolveWorkspace.mockResolvedValue(context) + mocks.loadAuthorizationWorkspace.mockResolvedValue(context) mocks.resolveKnowledgeBase.mockResolvedValue({ ...context, knowledgeBaseId: knowledgeBase.id, @@ -111,7 +143,137 @@ describe('knowledge base application use cases', () => { }) mocks.loadFolderIndex.mockResolvedValue({ pathById: new Map() }) mocks.createRecord.mockResolvedValue(knowledgeBase) + mocks.listRecords.mockResolvedValue([]) + mocks.listInternalRecords.mockResolvedValue([knowledgeBase]) + mocks.getRecord.mockResolvedValue(knowledgeBase) + mocks.getRestorableRecord.mockResolvedValue(knowledgeBase) + mocks.performUpdate.mockResolvedValue({ + success: true, + knowledgeBase: { ...knowledgeBase, name: 'Renamed' }, + }) + mocks.performDelete.mockResolvedValue({ success: true }) + mocks.performRestore.mockResolvedValue({ success: true, knowledgeBase }) mocks.updateRecord.mockResolvedValue({ ...knowledgeBase, name: 'Renamed' }) + mocks.deleteRecord.mockResolvedValue(undefined) + }) + + it('lists legacy personal knowledge bases through the explicit session-only operation', async () => { + await expect( + listInternalKnowledgeBases.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { scope: 'all' }, + }) + ).resolves.toEqual({ knowledgeBases: [knowledgeBase] }) + + expect(mocks.resolveWorkspace).not.toHaveBeenCalled() + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.listInternalRecords).toHaveBeenCalledWith('user-1', undefined, 'all') + }) + + it('authorizes a canonical workspace before listing its internal knowledge bases', async () => { + await listInternalKnowledgeBases.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'workspace-1', scope: 'archived' }, + }) + + expect(mocks.resolveWorkspace).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + expect(mocks.listInternalRecords).toHaveBeenCalledWith('user-1', 'workspace-1', 'archived') + }) + + it('loads the active knowledge catalog and tag metadata only after workspace authorization', async () => { + mocks.listRecords.mockResolvedValueOnce([knowledgeBase]) + dbChainMockFns.orderBy.mockResolvedValueOnce([ + { + knowledgeBaseId: 'knowledge-1', + tagSlot: 'tag1', + displayName: 'Department', + fieldType: 'text', + }, + ]) + + const result = await listKnowledgeBaseCatalog.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'vfs-1', + audience: 'sim:knowledge', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + }, + input: { workspaceId: 'workspace-1' }, + }) + + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.listRecords.mock.invocationCallOrder[0] + ) + expect(result.knowledgeBases).toEqual([ + expect.objectContaining({ + knowledgeBase, + tagDefinitions: [ + { + knowledgeBaseId: 'knowledge-1', + tagSlot: 'tag1', + displayName: 'Department', + fieldType: 'text', + }, + ], + }), + ]) + }) + + it('rejects an archived Knowledge list bound to another trusted workspace before reading', async () => { + await expect( + listArchivedKnowledgeBases.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'dual-workspace-user', + workspaceId: 'workspace-b', + delegationId: 'vfs-1', + audience: 'sim:knowledge', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + }, + input: { workspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.listRecords).not.toHaveBeenCalled() + }) + + it('rejects a workspace listing before reading when current access is insufficient', async () => { + mocks.resolvePermission.mockResolvedValueOnce(null) + + await expect( + listInternalKnowledgeBases.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'workspace-1', scope: 'active' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.listInternalRecords).not.toHaveBeenCalled() + }) + + it('rejects non-session principals before resolving internal list input', async () => { + await expect( + listInternalKnowledgeBases.execute({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { scope: 'active' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveWorkspace).not.toHaveBeenCalled() + expect(mocks.listInternalRecords).not.toHaveBeenCalled() }) it('rejects an insufficient role before the protected mutation', async () => { @@ -159,6 +321,29 @@ describe('knowledge base application use cases', () => { ) }) + it('passes the internal folder ID through only after workspace authorization', async () => { + await createKnowledgeBase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'workspace-1', + name: 'Docs', + folderId: 'folder-1', + source: 'ui', + }, + }) + + expect(mocks.loadFolderIndex).toHaveBeenCalledWith( + 'workspace-1', + 'knowledge_base', + undefined, + expect.any(Object) + ) + expect(mocks.createRecord).toHaveBeenCalledWith( + expect.objectContaining({ folderId: 'folder-1' }), + expect.any(String) + ) + }) + it('conceals a canonical scope mismatch and never audits it', async () => { mocks.resolveKnowledgeBase.mockRejectedValueOnce( new OrchestrationError('not_found', 'Knowledge base not found') @@ -206,4 +391,217 @@ describe('knowledge base application use cases', () => { { assertedWorkspaceId: 'workspace-1' } ) }) + + it('reads a legacy personal knowledge base only for its owning session', async () => { + const personalKnowledgeBase = { + ...knowledgeBase, + userId: 'user-1', + workspaceId: null, + } + mocks.getRecord.mockResolvedValueOnce(personalKnowledgeBase) + + await expect( + readInternalKnowledgeBase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { knowledgeBaseId: 'knowledge-1' }, + }) + ).resolves.toEqual({ knowledgeBase: personalKnowledgeBase }) + expect(mocks.loadAuthorizationWorkspace).not.toHaveBeenCalled() + }) + + it('authorizes the canonical workspace before an internal detail read', async () => { + await readInternalKnowledgeBase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { knowledgeBaseId: 'knowledge-1' }, + }) + + expect(mocks.loadAuthorizationWorkspace).toHaveBeenCalledWith('workspace-1') + expect(mocks.resolvePermission).toHaveBeenCalled() + }) + + it('authorizes both canonical workspaces before moving a knowledge base', async () => { + mocks.resolveWorkspace.mockResolvedValueOnce({ ...context, workspaceId: 'workspace-2' }) + + await updateInternalKnowledgeBase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { knowledgeBaseId: 'knowledge-1', workspaceId: 'workspace-2' }, + }) + + expect(mocks.resolvePermission).toHaveBeenCalledTimes(2) + expect(mocks.performUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + updates: expect.objectContaining({ workspaceId: 'workspace-2' }), + }) + ) + }) + + it('rejects a destination workspace before an internal move mutation', async () => { + mocks.resolveWorkspace.mockResolvedValueOnce({ ...context, workspaceId: 'workspace-2' }) + mocks.resolvePermission.mockResolvedValueOnce('write').mockResolvedValueOnce(null) + + await expect( + updateInternalKnowledgeBase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { knowledgeBaseId: 'knowledge-1', workspaceId: 'workspace-2' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.performUpdate).not.toHaveBeenCalled() + }) + + it('carries canonical scope into internal delete and restores only after authorization', async () => { + const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const + await deleteInternalKnowledgeBase.execute({ + principal, + input: { knowledgeBaseId: 'knowledge-1' }, + }) + await restoreInternalKnowledgeBase.execute({ + principal, + input: { knowledgeBaseId: 'knowledge-1' }, + }) + + expect(mocks.performDelete).toHaveBeenCalledWith( + expect.objectContaining({ assertedWorkspaceId: 'workspace-1' }) + ) + expect(mocks.loadAuthorizationWorkspace).toHaveBeenLastCalledWith('workspace-1', { + includeArchived: true, + }) + expect(mocks.performRestore).toHaveBeenCalledOnce() + }) + + it('bounds bulk deletion before canonical workspace loading', async () => { + await expect( + bulkDeleteKnowledgeBases.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: Array.from({ length: 101 }, (_, index) => `knowledge-${index}`), + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveWorkspace).not.toHaveBeenCalled() + expect(mocks.deleteRecord).not.toHaveBeenCalled() + }) + + it('conceals a cross-workspace bulk target before mutation for a dual-workspace subject', async () => { + mocks.resolveKnowledgeBase.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Knowledge base not found') + ) + + const result = await bulkDeleteKnowledgeBases.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'dual-workspace-user', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:knowledge', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + }, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['workspace-2-knowledge'], + }, + }) + + expect(result).toMatchObject({ deleted: [], notFound: ['workspace-2-knowledge'] }) + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'dual-workspace-user', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + expect(mocks.deleteRecord).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('returns explicit best-effort outcomes and audits only authoritative deletions', async () => { + mocks.resolveKnowledgeBase.mockImplementation(async ({ knowledgeBaseId }) => ({ + ...context, + knowledgeBaseId, + knowledgeBase: { ...knowledgeBase, id: knowledgeBaseId, name: `Name ${knowledgeBaseId}` }, + })) + mocks.deleteRecord + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new OrchestrationError('conflict', 'Knowledge base is locked')) + + const result = await bulkDeleteKnowledgeBases.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1', 'knowledge-2'], + source: 'agent', + }, + }) + + expect(result).toMatchObject({ + deleted: [{ id: 'knowledge-1', name: 'Name knowledge-1' }], + failed: [{ id: 'knowledge-2', name: 'Name knowledge-2', reason: 'Knowledge base is locked' }], + cancelled: false, + }) + expect(mocks.resolvePermission).toHaveBeenCalledTimes(3) + expect(mocks.recordAudit).toHaveBeenCalledOnce() + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'knowledge-1', + metadata: expect.objectContaining({ operation: 'knowledge.bulk_delete' }), + }) + ) + expect(mocks.knowledgeBaseDeleted).toHaveBeenCalledWith({ knowledgeBaseId: 'knowledge-1' }) + }) + + it('stops between bulk mutations while auditing completed items', async () => { + const controller = new AbortController() + mocks.resolveKnowledgeBase.mockImplementation(async ({ knowledgeBaseId }) => ({ + ...context, + knowledgeBaseId, + knowledgeBase: { ...knowledgeBase, id: knowledgeBaseId, name: knowledgeBaseId }, + })) + mocks.deleteRecord.mockImplementationOnce(async () => { + controller.abort('user stopped') + }) + + const result = await bulkDeleteKnowledgeBases.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1', 'knowledge-2'], + cancellationSignal: controller.signal, + }, + }) + + expect(result).toMatchObject({ deleted: [{ id: 'knowledge-1' }], cancelled: true }) + expect(mocks.deleteRecord).toHaveBeenCalledOnce() + expect(mocks.recordAudit).toHaveBeenCalledOnce() + }) + + it('audits completed knowledge base deletions before propagating infrastructure failure', async () => { + const failure = new Error('knowledge store unavailable') + mocks.resolveKnowledgeBase.mockImplementation(async ({ knowledgeBaseId }) => ({ + ...context, + knowledgeBaseId, + knowledgeBase: { ...knowledgeBase, id: knowledgeBaseId, name: knowledgeBaseId }, + })) + mocks.deleteRecord.mockResolvedValueOnce(undefined).mockRejectedValueOnce(failure) + + await expect( + bulkDeleteKnowledgeBases.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1', 'knowledge-2'], + }, + }) + ).rejects.toBe(failure) + + expect(mocks.recordAudit).toHaveBeenCalledOnce() + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ resourceId: 'knowledge-1' }) + ) + expect(mocks.knowledgeBaseDeleted).toHaveBeenCalledOnce() + }) }) diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.ts b/apps/sim/lib/knowledge/application/knowledge-bases.ts index 56d4ab1c2db..44180f1a1b7 100644 --- a/apps/sim/lib/knowledge/application/knowledge-bases.ts +++ b/apps/sim/lib/knowledge/application/knowledge-bases.ts @@ -1,13 +1,32 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import type { Principal, SessionPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { knowledgeBaseTagDefinitions } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { OrchestrationError } from '@/lib/core/orchestration/types' +import { inArray } from 'drizzle-orm' +import { + authorizeWorkspaceOperation, + type OperationUseCase, + PrincipalKindAuthorizationError, + type WorkspaceOperation, +} from '@/lib/core/application' +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 { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { + BULK_DELETE_KNOWLEDGE_BASES_COST_POLICY, + type KnowledgeBatchExecutionResult, + requireBoundedKnowledgeBatch, + rethrowKnowledgeBatchTerminalFailure, +} from '@/lib/knowledge/application/batch-policy' import { resolveKnowledgeAttributedUserId } from '@/lib/knowledge/application/billing' import { type ActiveKnowledgeBaseContext, type KnowledgeWorkspaceContext, + loadKnowledgeWorkspaceAuthorizationContext, resolveActiveKnowledgeBaseContext, resolveKnowledgeWorkspaceContext, } from '@/lib/knowledge/application/contexts' @@ -15,16 +34,29 @@ import { knowledgeFolderPathForId, resolveKnowledgeFolderPath, } from '@/lib/knowledge/application/folder-paths' -import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + knowledgeOperations, + knowledgeSessionOperations, +} from '@/lib/knowledge/application/operations' import { DEFAULT_CHUNKING_CONFIG, MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE, } from '@/lib/knowledge/constants' import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' +import { + getRestorableKnowledgeBase, + performDeleteKnowledgeBase, + performRestoreKnowledgeBase, + performUpdateKnowledgeBase, +} from '@/lib/knowledge/orchestration' +import type { KnowledgeOrchestrationResult } from '@/lib/knowledge/orchestration/shared' import { createAuthorizedKnowledgeBase, deleteKnowledgeBase, + getKnowledgeBaseById, + getKnowledgeBases, getWorkspaceKnowledgeBases, + type KnowledgeBaseScope, updateKnowledgeBase, } from '@/lib/knowledge/service' import type { ChunkingConfig, KnowledgeBaseWithCounts } from '@/lib/knowledge/types' @@ -48,15 +80,42 @@ export interface ListKnowledgeBasesResult { knowledgeBases: KnowledgeBaseResult[] } +export interface ListArchivedKnowledgeBasesResult { + knowledgeBases: KnowledgeBaseWithCounts[] +} + +export interface KnowledgeBaseCatalogTagDefinition { + knowledgeBaseId: string + tagSlot: string + displayName: string + fieldType: string +} + +export interface ListKnowledgeBaseCatalogResult { + knowledgeBases: Array< + KnowledgeBaseResult & { tagDefinitions: KnowledgeBaseCatalogTagDefinition[] } + > +} + export interface CreateKnowledgeBaseInput { workspaceId: string name: string description?: string chunkingConfig?: Partial<ChunkingConfig> folderPath?: string + folderId?: string | null source?: string } +export interface ListInternalKnowledgeBasesInput { + workspaceId?: string + scope: KnowledgeBaseScope +} + +export interface ListInternalKnowledgeBasesResult { + knowledgeBases: KnowledgeBaseWithCounts[] +} + export interface ReadKnowledgeBaseInput { knowledgeBaseId: string assertedWorkspaceId?: string @@ -74,6 +133,87 @@ export interface DeleteKnowledgeBaseInput extends ReadKnowledgeBaseInput { source?: string } +export interface BulkDeleteKnowledgeBasesInput { + assertedWorkspaceId: string + knowledgeBaseIds: string[] + cancellationSignal?: AbortSignal + source?: string +} + +export interface BulkDeleteKnowledgeBasesResult { + deleted: Array<{ id: string; name: string }> + notFound: string[] + failed: Array<{ id: string; name: string; reason: string }> + cancelled: boolean +} + +interface BulkDeleteKnowledgeBasesExecutionResult + extends BulkDeleteKnowledgeBasesResult, + KnowledgeBatchExecutionResult {} + +interface BulkDeleteKnowledgeBasesContext extends KnowledgeWorkspaceContext { + knowledgeBaseIds: string[] +} + +export interface ReadInternalKnowledgeBaseInput { + knowledgeBaseId: string +} + +export interface UpdateInternalKnowledgeBaseInput extends ReadInternalKnowledgeBaseInput { + name?: string + description?: string + workspaceId?: string | null + folderId?: string | null + chunkingConfig?: ChunkingConfig +} + +export interface RestoreInternalKnowledgeBaseInput extends ReadInternalKnowledgeBaseInput {} + +export interface InternalKnowledgeBaseResult { + knowledgeBase: KnowledgeBaseWithCounts +} + +function requireSessionPrincipal( + principal: Principal, + operationId: string +): asserts principal is SessionPrincipal { + if (principal.kind !== 'session') { + throw new PrincipalKindAuthorizationError(principal.kind, operationId) + } +} + +function throwKnowledgeOrchestrationFailure( + outcome: Extract<KnowledgeOrchestrationResult, { success: false }>, + fallback: string +): never { + if (outcome.errorCode === 'internal') throw new Error(fallback) + throw new OrchestrationError(outcome.errorCode, outcome.error) +} + +async function loadInternalActiveKnowledgeBase( + knowledgeBaseId: string +): Promise<KnowledgeBaseWithCounts> { + const knowledgeBase = await getKnowledgeBaseById(knowledgeBaseId) + if (!knowledgeBase) throw new OrchestrationError('not_found', 'Knowledge base not found') + return knowledgeBase +} + +async function authorizeInternalKnowledgeBase( + principal: SessionPrincipal, + knowledgeBase: Pick<KnowledgeBaseWithCounts, 'userId' | 'workspaceId'>, + operation: WorkspaceOperation +): Promise<void> { + if (!knowledgeBase.workspaceId) { + if (knowledgeBase.userId !== principal.userId) { + throw new OrchestrationError('unauthorized', 'Unauthorized') + } + return + } + const context = await loadKnowledgeWorkspaceAuthorizationContext(knowledgeBase.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Knowledge base not found') + await authorizeWorkspaceOperation(principal, operation, context) +} + async function executeListKnowledgeBases(args: { input: ListKnowledgeBasesInput context: KnowledgeWorkspaceContext @@ -109,8 +249,21 @@ async function executeCreateKnowledgeBase(args: { input: CreateKnowledgeBaseInput context: KnowledgeWorkspaceContext }): Promise<KnowledgeBaseResult> { - const path = args.input.folderPath ?? '/' - const { folderId, index } = await resolveKnowledgeFolderPath(args.context.workspaceId, path) + if (args.input.folderId !== undefined && args.input.folderPath !== undefined) { + throw new OrchestrationError('validation', 'Specify either folderId or folderPath, not both') + } + const { folderId, index } = + args.input.folderId !== undefined + ? { + folderId: args.input.folderId, + index: await loadActiveFolderPathIndex( + args.context.workspaceId, + 'knowledge_base', + undefined, + { maxRows: MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE } + ), + } + : await resolveKnowledgeFolderPath(args.context.workspaceId, args.input.folderPath ?? '/') const chunkingConfig: ChunkingConfig = { ...DEFAULT_CHUNKING_CONFIG, ...args.input.chunkingConfig, @@ -203,6 +356,78 @@ export const listKnowledgeBases = defineAuthorizedKnowledgeUseCase({ execute: executeListKnowledgeBases, }) +export const listKnowledgeBaseCatalog = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.list, + resolveContext: ({ input }: { input: ListKnowledgeBasesInput }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ input, context }): Promise<ListKnowledgeBaseCatalogResult> { + const result = await executeListKnowledgeBases({ input, context }) + const knowledgeBaseIds = result.knowledgeBases.map(({ knowledgeBase }) => knowledgeBase.id) + const tagDefinitions = + knowledgeBaseIds.length === 0 + ? [] + : await db + .select({ + knowledgeBaseId: knowledgeBaseTagDefinitions.knowledgeBaseId, + tagSlot: knowledgeBaseTagDefinitions.tagSlot, + displayName: knowledgeBaseTagDefinitions.displayName, + fieldType: knowledgeBaseTagDefinitions.fieldType, + }) + .from(knowledgeBaseTagDefinitions) + .where(inArray(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseIds)) + .orderBy(knowledgeBaseTagDefinitions.tagSlot) + const tagsByKnowledgeBase = new Map<string, KnowledgeBaseCatalogTagDefinition[]>() + for (const definition of tagDefinitions) { + const existing = tagsByKnowledgeBase.get(definition.knowledgeBaseId) + if (existing) existing.push(definition) + else tagsByKnowledgeBase.set(definition.knowledgeBaseId, [definition]) + } + return { + knowledgeBases: result.knowledgeBases.map((entry) => ({ + ...entry, + tagDefinitions: tagsByKnowledgeBase.get(entry.knowledgeBase.id) ?? [], + })), + } + }, +}) + +export const listArchivedKnowledgeBases = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.listArchived, + resolveContext: ({ input }: { input: { workspaceId: string } }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ context }): Promise<ListArchivedKnowledgeBasesResult> { + return { + knowledgeBases: await getWorkspaceKnowledgeBases(context.workspaceId, 'archived'), + } + }, +}) + +export const listInternalKnowledgeBases = { + operation: knowledgeSessionOperations.list, + async execute({ + principal, + input, + }: { + principal: Principal + input: ListInternalKnowledgeBasesInput + }): Promise<ListInternalKnowledgeBasesResult> { + if (principal.kind !== 'session') { + throw new PrincipalKindAuthorizationError(principal.kind, knowledgeSessionOperations.list.id) + } + if (input.workspaceId !== undefined) { + const context = await resolveKnowledgeWorkspaceContext({ workspaceId: input.workspaceId }) + await authorizeWorkspaceOperation(principal, knowledgeOperations.list, context) + } + return { + knowledgeBases: await getKnowledgeBases(principal.userId, input.workspaceId, input.scope), + } + }, +} satisfies OperationUseCase< + (typeof knowledgeSessionOperations)['list'], + ListInternalKnowledgeBasesInput, + ListInternalKnowledgeBasesResult +> + export const createKnowledgeBase = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.create, resolveContext: ({ input }: { input: CreateKnowledgeBaseInput }) => @@ -266,3 +491,245 @@ export const deleteKnowledgeBaseOperation = defineAuthorizedKnowledgeUseCase({ metadata: { source: input.source, knowledgeBaseName: result.name }, }), }) + +export const bulkDeleteKnowledgeBases = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.bulkDelete, + async resolveContext({ + input, + }: { + input: BulkDeleteKnowledgeBasesInput + }): Promise<BulkDeleteKnowledgeBasesContext> { + const knowledgeBaseIds = requireBoundedKnowledgeBatch( + input.knowledgeBaseIds, + 'knowledge base IDs', + BULK_DELETE_KNOWLEDGE_BASES_COST_POLICY.maxItems + ) + return { + ...(await resolveKnowledgeWorkspaceContext({ workspaceId: input.assertedWorkspaceId })), + knowledgeBaseIds, + } + }, + async execute({ principal, input, context }): Promise<BulkDeleteKnowledgeBasesExecutionResult> { + const deleted: BulkDeleteKnowledgeBasesResult['deleted'] = [] + const notFound: string[] = [] + const failed: BulkDeleteKnowledgeBasesResult['failed'] = [] + let terminalFailure: KnowledgeBatchExecutionResult['terminalFailure'] + + for (const knowledgeBaseId of context.knowledgeBaseIds) { + if (input.cancellationSignal?.aborted) break + let knowledgeBaseName = knowledgeBaseId + try { + const canonical = await resolveActiveKnowledgeBaseContext({ + knowledgeBaseId, + assertedWorkspaceId: context.workspaceId, + }) + knowledgeBaseName = canonical.knowledgeBase.name + await authorizeWorkspaceOperation(principal, knowledgeOperations.bulkDelete, canonical, { + delegation: knowledgeDelegationPolicy, + }) + if (input.cancellationSignal?.aborted) break + deleted.push(await executeDeleteKnowledgeBase({ context: canonical })) + } catch (error) { + const classified = asOrchestrationError(error) + if ( + classified?.code === 'not_found' || + classified?.code === 'forbidden' || + classified?.code === 'unauthorized' + ) { + notFound.push(knowledgeBaseId) + continue + } + if (classified && classified.code !== 'internal') { + failed.push({ + id: knowledgeBaseId, + name: knowledgeBaseName, + reason: classified.message, + }) + continue + } + terminalFailure = { error } + break + } + } + + return { + deleted, + notFound, + failed, + cancelled: input.cancellationSignal?.aborted ?? false, + ...(terminalFailure && { terminalFailure }), + } + }, + projectAudit: ({ input, result }) => + result.deleted.map((knowledgeBase) => ({ + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: knowledgeBase.id, + resourceName: knowledgeBase.name, + description: `Deleted knowledge base "${knowledgeBase.name}"`, + metadata: { source: input.source, knowledgeBaseName: knowledgeBase.name }, + })), + afterSuccess: ({ result }) => { + try { + for (const knowledgeBase of result.deleted) { + PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: knowledgeBase.id }) + } + } finally { + rethrowKnowledgeBatchTerminalFailure(result) + } + }, +}) + +export const readInternalKnowledgeBase = { + operation: knowledgeSessionOperations.read, + async execute({ + principal, + input, + }: { + principal: Principal + input: ReadInternalKnowledgeBaseInput + }): Promise<InternalKnowledgeBaseResult> { + requireSessionPrincipal(principal, knowledgeSessionOperations.read.id) + const knowledgeBase = await loadInternalActiveKnowledgeBase(input.knowledgeBaseId) + await authorizeInternalKnowledgeBase(principal, knowledgeBase, knowledgeOperations.read) + return { knowledgeBase } + }, +} satisfies OperationUseCase< + (typeof knowledgeSessionOperations)['read'], + ReadInternalKnowledgeBaseInput, + InternalKnowledgeBaseResult +> + +export const updateInternalKnowledgeBase = { + operation: knowledgeSessionOperations.update, + async execute({ + principal, + input, + request, + }: { + principal: Principal + input: UpdateInternalKnowledgeBaseInput + request?: { headers: { get(name: string): string | null } } + }): Promise<InternalKnowledgeBaseResult> { + requireSessionPrincipal(principal, knowledgeSessionOperations.update.id) + const knowledgeBase = await loadInternalActiveKnowledgeBase(input.knowledgeBaseId) + await authorizeInternalKnowledgeBase(principal, knowledgeBase, knowledgeOperations.update) + + if (input.workspaceId !== undefined && input.workspaceId !== knowledgeBase.workspaceId) { + if (input.workspaceId === null) { + if (knowledgeBase.userId !== principal.userId) { + throw new OrchestrationError( + 'forbidden', + 'Only the knowledge base owner can remove it from a workspace' + ) + } + } else { + const destination = await resolveKnowledgeWorkspaceContext({ + workspaceId: input.workspaceId, + }) + await authorizeWorkspaceOperation(principal, knowledgeOperations.update, destination) + } + } + + const outcome = await performUpdateKnowledgeBase({ + knowledgeBaseId: knowledgeBase.id, + workspaceId: knowledgeBase.workspaceId, + assertedWorkspaceId: knowledgeBase.workspaceId ?? undefined, + userId: principal.userId, + source: 'ui', + updates: { + name: input.name, + description: input.description, + workspaceId: input.workspaceId, + folderId: input.folderId, + chunkingConfig: input.chunkingConfig, + }, + request, + }) + if (!outcome.success) { + throwKnowledgeOrchestrationFailure(outcome, 'Failed to update knowledge base') + } + return { knowledgeBase: outcome.knowledgeBase } + }, +} satisfies OperationUseCase< + (typeof knowledgeSessionOperations)['update'], + UpdateInternalKnowledgeBaseInput, + InternalKnowledgeBaseResult +> + +export const deleteInternalKnowledgeBase = { + operation: knowledgeSessionOperations.delete, + async execute({ + principal, + input, + request, + }: { + principal: Principal + input: ReadInternalKnowledgeBaseInput + request?: { headers: { get(name: string): string | null } } + }): Promise<{ success: true }> { + requireSessionPrincipal(principal, knowledgeSessionOperations.delete.id) + const knowledgeBase = await loadInternalActiveKnowledgeBase(input.knowledgeBaseId) + await authorizeInternalKnowledgeBase(principal, knowledgeBase, knowledgeOperations.delete) + const outcome = await performDeleteKnowledgeBase({ + knowledgeBase: { + id: knowledgeBase.id, + name: knowledgeBase.name, + workspaceId: knowledgeBase.workspaceId, + }, + assertedWorkspaceId: knowledgeBase.workspaceId ?? undefined, + userId: principal.userId, + source: 'ui', + request, + }) + if (!outcome.success) { + throwKnowledgeOrchestrationFailure(outcome, 'Failed to delete knowledge base') + } + return { success: true } + }, +} satisfies OperationUseCase< + (typeof knowledgeSessionOperations)['delete'], + ReadInternalKnowledgeBaseInput, + { success: true } +> + +export const restoreInternalKnowledgeBase = { + operation: knowledgeSessionOperations.restore, + async execute({ + principal, + input, + request, + }: { + principal: Principal + input: RestoreInternalKnowledgeBaseInput + request?: { headers: { get(name: string): string | null } } + }): Promise<{ success: true }> { + requireSessionPrincipal(principal, knowledgeSessionOperations.restore.id) + const knowledgeBase = await getRestorableKnowledgeBase(input.knowledgeBaseId) + if (!knowledgeBase) throw new OrchestrationError('not_found', 'Knowledge base not found') + if (knowledgeBase.workspaceId) { + const context = await loadKnowledgeWorkspaceAuthorizationContext(knowledgeBase.workspaceId, { + includeArchived: true, + }) + if (!context) throw new OrchestrationError('not_found', 'Knowledge base not found') + await authorizeWorkspaceOperation(principal, knowledgeOperations.update, context) + } else if (knowledgeBase.userId !== principal.userId) { + throw new OrchestrationError('unauthorized', 'Unauthorized') + } + + const outcome = await performRestoreKnowledgeBase({ + knowledgeBaseId: knowledgeBase.id, + userId: principal.userId, + source: 'ui', + request, + }) + if (!outcome.success) { + throwKnowledgeOrchestrationFailure(outcome, 'Failed to restore knowledge base') + } + return { success: true } + }, +} satisfies OperationUseCase< + (typeof knowledgeSessionOperations)['restore'], + RestoreInternalKnowledgeBaseInput, + { success: true } +> diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index c8d39ec283b..b7b48188f8f 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -11,10 +11,12 @@ describe('knowledge operation registry', () => { const ids = Object.values(knowledgeOperations).map((operation) => operation.id) expect(ids).toEqual([ 'knowledge.list', + 'knowledge.list_archived', 'knowledge.read', 'knowledge.create', 'knowledge.update', 'knowledge.delete', + 'knowledge.bulk_delete', 'knowledge.search', 'knowledge.folders.list', 'knowledge.folders.create', @@ -23,7 +25,34 @@ describe('knowledge operation registry', () => { 'knowledge.documents.list', 'knowledge.documents.read', 'knowledge.documents.upload', + 'knowledge.documents.add_workspace_files', 'knowledge.documents.delete', + 'knowledge.documents.bulk_delete', + 'knowledge.documents.update', + 'knowledge.documents.bulk', + 'knowledge.chunks.list', + 'knowledge.chunks.read', + 'knowledge.chunks.create', + 'knowledge.chunks.update', + 'knowledge.chunks.delete', + 'knowledge.chunks.bulk', + 'knowledge.tags.list', + 'knowledge.tags.create', + 'knowledge.tags.update', + 'knowledge.tags.delete', + 'knowledge.tags.read_usage', + 'knowledge.tags.read_detailed_usage', + 'knowledge.tags.read_next_slot', + 'knowledge.tags.save_document_definitions', + 'knowledge.tags.delete_document_definitions', + 'knowledge.connectors.list', + 'knowledge.connectors.read', + 'knowledge.connectors.create', + 'knowledge.connectors.update', + 'knowledge.connectors.delete', + 'knowledge.connectors.sync', + 'knowledge.connectors.documents.list', + 'knowledge.connectors.documents.update', 'knowledge.documents.upload.create', 'knowledge.documents.upload.parts', 'knowledge.documents.upload.complete', @@ -33,20 +62,49 @@ describe('knowledge operation registry', () => { }) it('keeps workspace keys within their fixed write ceiling', () => { - for (const operation of Object.values(knowledgeOperations)) { + const workspaceKeyOperations = Object.values(knowledgeOperations).filter( + (operation) => operation.workspaceApiKey === 'allow' + ) + for (const operation of workspaceKeyOperations) { expect(operation.workspaceApiKey).toBe('allow') expect(operation.principalKinds).toContain('workspace_api_key') expect(permissionSatisfies('write', operation.minimumRole)).toBe(true) } }) + it('keeps human-delegated tag, connector, and composed document operations off workspace keys', () => { + const operations = [ + knowledgeOperations.updateDocument, + knowledgeOperations.addWorkspaceFiles, + knowledgeOperations.bulkDeleteDocuments, + knowledgeOperations.listTags, + knowledgeOperations.createTag, + knowledgeOperations.updateTag, + knowledgeOperations.deleteTag, + knowledgeOperations.readTagUsage, + knowledgeOperations.createConnector, + knowledgeOperations.updateConnector, + knowledgeOperations.deleteConnector, + knowledgeOperations.syncConnector, + ] + for (const operation of operations) { + expect(operation.workspaceApiKey).toBe('deny') + expect(operation.principalKinds).not.toContain('workspace_api_key') + expect(operation.principalKinds).toContain('delegated') + } + }) + it('allows delegated callers only on semantic knowledge and document operations', () => { expect(knowledgeOperations.list.principalKinds).toContain('delegated') expect(knowledgeOperations.search.principalKinds).toContain('delegated') expect(knowledgeOperations.uploadDocument.principalKinds).toContain('delegated') + expect(knowledgeOperations.updateDocument.principalKinds).toContain('delegated') + expect(knowledgeOperations.updateTag.principalKinds).toContain('delegated') + expect(knowledgeOperations.syncConnector.principalKinds).toContain('delegated') expect(knowledgeOperations.listFolders.principalKinds).not.toContain('delegated') expect(knowledgeOperations.uploadComplete.principalKinds).not.toContain('delegated') expect(knowledgeOperations.list.delegatedServices).toEqual(['copilot']) + expect(knowledgeOperations.search.delegatedServices).toEqual(['copilot', 'executor']) expect(knowledgeOperations.uploadComplete.delegatedServices).toBeUndefined() }) }) diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index dda120bb467..4bacaa7a01b 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -5,8 +5,25 @@ const ALL_PRINCIPAL_POLICY = { delegatedServices: ['copilot'], } as const +const ALL_PRINCIPAL_WITH_EXECUTOR_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot', 'executor'], +} as const + const HTTP_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'workspace_api_key'] as const +const HUMAN_AND_DELEGATED_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'delegated'] as const + +const HUMAN_AND_COPILOT_PRINCIPAL_POLICY = { + principalKinds: HUMAN_AND_DELEGATED_PRINCIPAL_KINDS, + delegatedServices: ['copilot'], +} as const + +const HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY = { + principalKinds: HUMAN_AND_DELEGATED_PRINCIPAL_KINDS, + delegatedServices: ['copilot', 'executor'], +} as const + export const knowledgeOperations = { list: defineWorkspaceOperation({ id: 'knowledge.list', @@ -14,6 +31,12 @@ export const knowledgeOperations = { workspaceApiKey: 'allow', ...ALL_PRINCIPAL_POLICY, }), + listArchived: defineWorkspaceOperation({ + id: 'knowledge.list_archived', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, + }), read: defineWorkspaceOperation({ id: 'knowledge.read', minimumRole: 'read', @@ -38,11 +61,17 @@ export const knowledgeOperations = { workspaceApiKey: 'allow', ...ALL_PRINCIPAL_POLICY, }), + bulkDelete: defineWorkspaceOperation({ + id: 'knowledge.bulk_delete', + minimumRole: 'write', + workspaceApiKey: 'allow', + ...ALL_PRINCIPAL_POLICY, + }), search: defineWorkspaceOperation({ id: 'knowledge.search', minimumRole: 'read', workspaceApiKey: 'allow', - ...ALL_PRINCIPAL_POLICY, + ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, }), listFolders: defineWorkspaceOperation({ id: 'knowledge.folders.list', @@ -72,25 +101,187 @@ export const knowledgeOperations = { id: 'knowledge.documents.list', minimumRole: 'read', workspaceApiKey: 'allow', - ...ALL_PRINCIPAL_POLICY, + ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, }), readDocument: defineWorkspaceOperation({ id: 'knowledge.documents.read', minimumRole: 'read', workspaceApiKey: 'allow', - ...ALL_PRINCIPAL_POLICY, + ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, }), uploadDocument: defineWorkspaceOperation({ id: 'knowledge.documents.upload', minimumRole: 'write', workspaceApiKey: 'allow', - ...ALL_PRINCIPAL_POLICY, + ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, + }), + addWorkspaceFiles: defineWorkspaceOperation({ + id: 'knowledge.documents.add_workspace_files', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), deleteDocument: defineWorkspaceOperation({ id: 'knowledge.documents.delete', minimumRole: 'write', workspaceApiKey: 'allow', - ...ALL_PRINCIPAL_POLICY, + ...ALL_PRINCIPAL_WITH_EXECUTOR_POLICY, + }), + bulkDeleteDocuments: defineWorkspaceOperation({ + id: 'knowledge.documents.bulk_delete', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, + }), + updateDocument: defineWorkspaceOperation({ + id: 'knowledge.documents.update', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, + }), + bulkDocuments: defineWorkspaceOperation({ + id: 'knowledge.documents.bulk', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, + }), + listChunks: defineWorkspaceOperation({ + id: 'knowledge.chunks.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, + }), + readChunk: defineWorkspaceOperation({ + id: 'knowledge.chunks.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, + }), + createChunk: defineWorkspaceOperation({ + id: 'knowledge.chunks.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, + }), + updateChunk: defineWorkspaceOperation({ + id: 'knowledge.chunks.update', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, + }), + deleteChunk: defineWorkspaceOperation({ + id: 'knowledge.chunks.delete', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, + }), + bulkChunks: defineWorkspaceOperation({ + id: 'knowledge.chunks.bulk', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, + }), + listTags: defineWorkspaceOperation({ + id: 'knowledge.tags.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, + }), + createTag: defineWorkspaceOperation({ + id: 'knowledge.tags.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, + }), + updateTag: defineWorkspaceOperation({ + id: 'knowledge.tags.update', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, + }), + deleteTag: defineWorkspaceOperation({ + id: 'knowledge.tags.delete', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, + }), + readTagUsage: defineWorkspaceOperation({ + id: 'knowledge.tags.read_usage', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, + }), + readDetailedTagUsage: defineWorkspaceOperation({ + id: 'knowledge.tags.read_detailed_usage', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, + }), + readNextTagSlot: defineWorkspaceOperation({ + id: 'knowledge.tags.read_next_slot', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, + }), + saveDocumentTagDefinitions: defineWorkspaceOperation({ + id: 'knowledge.tags.save_document_definitions', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, + }), + deleteDocumentTagDefinitions: defineWorkspaceOperation({ + id: 'knowledge.tags.delete_document_definitions', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, + }), + listConnectors: defineWorkspaceOperation({ + id: 'knowledge.connectors.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, + }), + readConnector: defineWorkspaceOperation({ + id: 'knowledge.connectors.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, + }), + createConnector: defineWorkspaceOperation({ + id: 'knowledge.connectors.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, + }), + updateConnector: defineWorkspaceOperation({ + id: 'knowledge.connectors.update', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, + }), + deleteConnector: defineWorkspaceOperation({ + id: 'knowledge.connectors.delete', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, + }), + syncConnector: defineWorkspaceOperation({ + id: 'knowledge.connectors.sync', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY, + }), + listConnectorDocuments: defineWorkspaceOperation({ + id: 'knowledge.connectors.documents.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, + }), + updateConnectorDocuments: defineWorkspaceOperation({ + id: 'knowledge.connectors.documents.update', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPAL_POLICY, }), uploadCreate: defineWorkspaceOperation({ id: 'knowledge.documents.upload.create', @@ -118,4 +309,12 @@ export const knowledgeOperations = { }), } as const +export const knowledgeSessionOperations = { + list: Object.freeze({ id: 'knowledge.session.list' as const }), + read: Object.freeze({ id: 'knowledge.session.read' as const }), + update: Object.freeze({ id: 'knowledge.session.update' as const }), + delete: Object.freeze({ id: 'knowledge.session.delete' as const }), + restore: Object.freeze({ id: 'knowledge.session.restore' as const }), +} as const + export type KnowledgeOperation = (typeof knowledgeOperations)[keyof typeof knowledgeOperations] diff --git a/apps/sim/lib/knowledge/application/search.test.ts b/apps/sim/lib/knowledge/application/search.test.ts index a56e6350ac4..90b6e7fb32a 100644 --- a/apps/sim/lib/knowledge/application/search.test.ts +++ b/apps/sim/lib/knowledge/application/search.test.ts @@ -77,6 +77,7 @@ const workspace = { const knowledgeBase = { id: 'knowledge-1', + name: 'Docs', workspaceId: 'workspace-1', embeddingModel: 'text-embedding-3-small', } @@ -157,6 +158,7 @@ describe('knowledge search application use case', () => { documentId: 'document-1', similarity: 0.8, }) + expect(result.knowledgeBases).toEqual([{ id: 'knowledge-1', name: 'Docs' }]) }) it('rejects a cross-workspace knowledge base before authorization or spend', async () => { diff --git a/apps/sim/lib/knowledge/application/search.ts b/apps/sim/lib/knowledge/application/search.ts index 778806335df..e3aecf60b6f 100644 --- a/apps/sim/lib/knowledge/application/search.ts +++ b/apps/sim/lib/knowledge/application/search.ts @@ -1,6 +1,16 @@ -import { checkAttributedUsageLimits } from '@/lib/billing/core/billing-attribution' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { + type BillingAttributionSnapshot, + checkAttributedUsageLimits, + toBillingContext, +} from '@/lib/billing/core/billing-attribution' +import { recordUsage } from '@/lib/billing/core/usage-log' +import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' +import { importDurableSecretProvenance } from '@/lib/execution/durable-secret-provenance' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { KnowledgeUsageLimitExceededError, @@ -13,7 +23,9 @@ import { } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' -import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' +import { getEmbeddingModelInfo } from '@/lib/knowledge/embedding-models' +import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' +import { rerank } from '@/lib/knowledge/reranker' import { executeKnowledgeSearch, generateSearchEmbedding, @@ -25,7 +37,25 @@ import { getKnowledgeBaseById } from '@/lib/knowledge/service' import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' import type { KnowledgeBaseWithCounts, StructuredFilter } from '@/lib/knowledge/types' -import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { estimateTokenCount } from '@/lib/tokenization/estimators' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { getRerankModelPricing } from '@/providers/models' +import { calculateCost } from '@/providers/utils' + +const logger = createLogger('KnowledgeSearchApplication') + +export const KNOWLEDGE_SEARCH_COST_POLICY = { + maxKnowledgeBases: 20, + maxTopK: 100, + usageAdmission: 'before_model_execution', +} as const + +export class KnowledgeSearchProvenanceUnavailableError extends Error { + constructor() { + super('Knowledge result secret provenance is unavailable') + this.name = 'KnowledgeSearchProvenanceUnavailableError' + } +} export interface KnowledgeSearchTagFilter { tagName: string @@ -36,11 +66,24 @@ export interface KnowledgeSearchTagFilter { } export interface SearchKnowledgeInput { - workspaceId: string + /** Optional assertion from a trusted adapter or public contract. */ + workspaceId?: string knowledgeBaseIds: string[] query?: string topK: number tagFilters?: KnowledgeSearchTagFilter[] + searchMode?: 'vector' | 'hybrid' + rerankerEnabled?: boolean + rerankerModel?: string + rerankerInputCount?: number + rerankerApiKey?: string + /** Honored only for an authenticated executor delegation. */ + skipUsageBilling?: boolean + resolveBillingAttribution?(workspaceId: string): Promise<BillingAttributionSnapshot> + prepareModelInputProvenance?(input: { + userId: string + workspaceId: string + }): Promise<ResolvedSecretTraceRegistry | undefined> /** Trusted execution provenance sink; never sourced from an HTTP or model payload. */ resultSecretRegistry?: ResolvedSecretTraceRegistry } @@ -59,56 +102,140 @@ export interface KnowledgeSearchItem { chunkIndex: number metadata: Record<string, unknown> similarity: number + rerankerScore?: number +} + +interface KnowledgeSearchCost { + input: number + output: number + total: number + tokens: { prompt: number; completion: number; total: number } + model: string + pricing: { input: number; output: number; updatedAt?: string } + rerankerCost?: number + rerankerModel?: string + rerankerSearchUnits?: number } export interface SearchKnowledgeResult { results: KnowledgeSearchItem[] query: string knowledgeBaseIds: string[] + knowledgeBases: Array<{ id: string; name: string }> + knowledgeBaseId: string topK: number totalResults: number + cost?: KnowledgeSearchCost + workspaceId: string + userId: string + resultSecretRegistry?: ResolvedSecretTraceRegistry } async function resolveKnowledgeSearchContext( input: SearchKnowledgeInput ): Promise<KnowledgeSearchContext> { - if (input.knowledgeBaseIds.length < 1 || input.knowledgeBaseIds.length > 20) { + if ( + input.knowledgeBaseIds.length < 1 || + input.knowledgeBaseIds.length > KNOWLEDGE_SEARCH_COST_POLICY.maxKnowledgeBases + ) { throw new OrchestrationError( 'validation', - 'Knowledge search requires between 1 and 20 knowledge bases' + `Knowledge search requires between 1 and ${KNOWLEDGE_SEARCH_COST_POLICY.maxKnowledgeBases} knowledge bases` ) } - if (!Number.isInteger(input.topK) || input.topK < 1 || input.topK > 100) { - throw new OrchestrationError('validation', 'topK must be an integer between 1 and 100') + if ( + !Number.isInteger(input.topK) || + input.topK < 1 || + input.topK > KNOWLEDGE_SEARCH_COST_POLICY.maxTopK + ) { + throw new OrchestrationError( + 'validation', + `topK must be an integer between 1 and ${KNOWLEDGE_SEARCH_COST_POLICY.maxTopK}` + ) } - const workspaceContext = await resolveKnowledgeWorkspaceContext(input) const knowledgeBases = await Promise.all(input.knowledgeBaseIds.map(getKnowledgeBaseById)) - const inaccessibleIds = input.knowledgeBaseIds.filter( - (_id, index) => knowledgeBases[index]?.workspaceId !== workspaceContext.workspaceId + const missingIds = input.knowledgeBaseIds.filter( + (_, index) => !knowledgeBases[index]?.workspaceId ) - if (inaccessibleIds.length > 0) { + if (missingIds.length > 0) { + throw new OrchestrationError( + 'not_found', + `Knowledge bases not found or access denied: ${missingIds.join(', ')}` + ) + } + const canonicalWorkspaceIds = new Set(knowledgeBases.map((kb) => kb?.workspaceId)) + if (canonicalWorkspaceIds.size !== 1) { + throw new OrchestrationError( + 'validation', + 'Selected knowledge bases must belong to the same workspace' + ) + } + const canonicalWorkspaceId = knowledgeBases[0]?.workspaceId + if (!canonicalWorkspaceId || (input.workspaceId && input.workspaceId !== canonicalWorkspaceId)) { throw new OrchestrationError( 'not_found', - `Knowledge bases not found or access denied: ${inaccessibleIds.join(', ')}` + `Knowledge bases not found or access denied: ${input.knowledgeBaseIds.join(', ')}` ) } + const workspaceContext = await resolveKnowledgeWorkspaceContext({ + workspaceId: canonicalWorkspaceId, + }) return { ...workspaceContext, knowledgeBases: knowledgeBases as KnowledgeBaseWithCounts[], } } -function buildStructuredFilters( +async function buildStructuredFilters( filters: KnowledgeSearchTagFilter[], - tagDefinitions: Awaited<ReturnType<typeof getDocumentTagDefinitions>> -): StructuredFilter[] { - const definitionsByName = new Map( - tagDefinitions.map((definition) => [definition.displayName, definition]) + knowledgeBaseIds: string[] +): Promise<{ + structuredFilters: StructuredFilter[] + definitionsByKnowledgeBase: Map<string, Awaited<ReturnType<typeof getDocumentTagDefinitions>>> +}> { + const definitionEntries = await Promise.all( + knowledgeBaseIds.map( + async (knowledgeBaseId) => + [knowledgeBaseId, await getDocumentTagDefinitions(knowledgeBaseId)] as const + ) ) + const definitionsByKnowledgeBase = new Map(definitionEntries) + const sharedDefinitions = new Map<string, { tagSlot: string; fieldType: string }>() + for (const [, definitions] of definitionEntries) { + const currentByName = new Map( + definitions.map((definition) => [ + definition.displayName, + { tagSlot: definition.tagSlot, fieldType: definition.fieldType }, + ]) + ) + for (const filter of filters) { + const current = currentByName.get(filter.tagName) + if (!current) { + if (knowledgeBaseIds.length > 1) { + throw new OrchestrationError( + 'validation', + `Tag "${filter.tagName}" does not exist in all selected knowledge bases. Search those knowledge bases separately.` + ) + } + continue + } + const existing = sharedDefinitions.get(filter.tagName) + if ( + existing && + (existing.tagSlot !== current.tagSlot || existing.fieldType !== current.fieldType) + ) { + throw new OrchestrationError( + 'validation', + `Tag "${filter.tagName}" is not mapped consistently across the selected knowledge bases. Search those knowledge bases separately.` + ) + } + sharedDefinitions.set(filter.tagName, current) + } + } const undefinedTags: string[] = [] const typeErrors: string[] = [] for (const filter of filters) { - const definition = definitionsByName.get(filter.tagName) + const definition = sharedDefinitions.get(filter.tagName) if (!definition) { undefinedTags.push(filter.tagName) continue @@ -121,23 +248,28 @@ function buildStructuredFilters( if (validationError) typeErrors.push(validationError) } if (undefinedTags.length > 0 || typeErrors.length > 0) { - const messages = [ - ...(undefinedTags.length > 0 ? [buildUndefinedTagsError(undefinedTags)] : []), - ...typeErrors, - ] - throw new OrchestrationError('validation', messages.join('\n')) + throw new OrchestrationError( + 'validation', + [ + ...(undefinedTags.length > 0 ? [buildUndefinedTagsError(undefinedTags)] : []), + ...typeErrors, + ].join('\n') + ) + } + return { + structuredFilters: filters.map((filter) => { + const definition = sharedDefinitions.get(filter.tagName) + if (!definition) throw new Error('Validated knowledge tag definition disappeared') + return { + tagSlot: definition.tagSlot, + fieldType: definition.fieldType, + operator: filter.operator, + value: filter.value, + valueTo: filter.valueTo, + } + }), + definitionsByKnowledgeBase, } - return filters.map((filter) => { - const definition = definitionsByName.get(filter.tagName) - if (!definition) throw new Error('Validated knowledge tag definition disappeared') - return { - tagSlot: definition.tagSlot, - fieldType: definition.fieldType, - operator: filter.operator, - value: filter.value, - valueTo: filter.valueTo, - } - }) } export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ @@ -145,22 +277,27 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ resolveContext: ({ input }: { input: SearchKnowledgeInput }) => resolveKnowledgeSearchContext(input), async execute({ principal, input, context }) { + const requestId = generateRequestId() const hasQuery = Boolean(input.query?.trim()) const filters = input.tagFilters ?? [] if (!hasQuery && filters.length === 0) { - throw new OrchestrationError('validation', 'Either query or tagFilters must be provided') - } - if (filters.length > 0 && context.knowledgeBases.length > 1) { throw new OrchestrationError( 'validation', - 'Tag filters are only supported when searching a single knowledge base' + 'Please provide either a search query or tag filters to search your knowledge base' ) } - + const userId = resolveKnowledgeAttributedUserId(principal, context) + const shouldMeter = !( + input.skipUsageBilling && + principal.kind === 'delegated' && + principal.serviceId === 'executor' + ) const billingAttribution = hasQuery - ? await resolveKnowledgeBillingAttribution(principal, context) + ? input.resolveBillingAttribution + ? await input.resolveBillingAttribution(context.workspaceId) + : await resolveKnowledgeBillingAttribution(principal, context) : undefined - if (billingAttribution) { + if (shouldMeter && billingAttribution) { const usage = await checkAttributedUsageLimits(billingAttribution) if (usage.isExceeded) { throw new KnowledgeUsageLimitExceededError( @@ -169,16 +306,16 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ } } - const tagDefinitionsByKnowledgeBase = new Map< + const knowledgeBaseIds = context.knowledgeBases.map((knowledgeBase) => knowledgeBase.id) + let structuredFilters: StructuredFilter[] = [] + let definitionsByKnowledgeBase = new Map< string, Awaited<ReturnType<typeof getDocumentTagDefinitions>> >() - let structuredFilters: StructuredFilter[] = [] if (filters.length > 0) { - const knowledgeBaseId = context.knowledgeBases[0].id - const definitions = await getDocumentTagDefinitions(knowledgeBaseId) - tagDefinitionsByKnowledgeBase.set(knowledgeBaseId, definitions) - structuredFilters = buildStructuredFilters(filters, definitions) + const built = await buildStructuredFilters(filters, knowledgeBaseIds) + structuredFilters = built.structuredFilters + definitionsByKnowledgeBase = built.definitionsByKnowledgeBase } const embeddingModels = [...new Set(context.knowledgeBases.map((kb) => kb.embeddingModel))] @@ -189,55 +326,156 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ ) } const embeddingModel = embeddingModels[0] - let queryEmbeddingIsBYOK: boolean | null = null - let queryVector: string | undefined - if (hasQuery) { - const generated = await generateSearchEmbedding( - input.query!, - embeddingModel, - context.workspaceId - ) - queryEmbeddingIsBYOK = generated.isBYOK - queryVector = JSON.stringify(generated.embedding) - } - - const knowledgeBaseIds = context.knowledgeBases.map((kb) => kb.id) - const rows = await executeKnowledgeSearch({ + const preparedRegistry = input.prepareModelInputProvenance + ? await input.prepareModelInputProvenance({ userId, workspaceId: context.workspaceId }) + : undefined + const resultSecretRegistry = preparedRegistry ?? input.resultSecretRegistry + const queryEmbeddingPromise = hasQuery + ? runWithKnowledgeModelInputProvenance(resultSecretRegistry, () => + generateSearchEmbedding(input.query!, embeddingModel, context.workspaceId) + ) + : Promise.resolve(null) + const useReranker = Boolean(input.rerankerEnabled && hasQuery) + const candidateTopK = useReranker + ? input.rerankerInputCount !== undefined + ? Math.min( + KNOWLEDGE_SEARCH_COST_POLICY.maxTopK, + Math.max(input.topK, input.rerankerInputCount) + ) + : Math.min(KNOWLEDGE_SEARCH_COST_POLICY.maxTopK, input.topK * 4) + : input.topK + let rows = await executeKnowledgeSearch({ knowledgeBaseIds, - topK: input.topK, - searchMode: 'vector', + topK: candidateTopK, + searchMode: input.searchMode ?? 'vector', query: input.query, - queryVector, - structuredFilters, + queryVector: hasQuery + ? JSON.stringify((await queryEmbeddingPromise)?.embedding ?? null) + : undefined, + structuredFilters: structuredFilters.length > 0 ? structuredFilters : undefined, }) - if (input.resultSecretRegistry) { - const provenance = await importKnowledgeSearchResultSecretProvenance({ - registry: input.resultSecretRegistry, + const registry = + resultSecretRegistry ?? + (input.prepareModelInputProvenance + ? new ResolvedSecretTraceRegistry([], { + userId, + workspaceId: context.workspaceId, + }) + : undefined) + let provenanceSnapshot: Awaited< + ReturnType<typeof importKnowledgeSearchResultSecretProvenance> + > | null = null + if (registry) { + provenanceSnapshot = await importKnowledgeSearchResultSecretProvenance({ + registry, results: rows, }) - if (!provenance.imported) { - input.resultSecretRegistry.markIncomplete() - throw new Error('Knowledge result secret provenance is unavailable') + if (!provenanceSnapshot.imported) { + registry.markIncomplete() + if (useReranker) throw new KnowledgeSearchProvenanceUnavailableError() } } - if (queryEmbeddingIsBYOK !== null && billingAttribution) { - await recordSearchEmbeddingUsage({ - userId: resolveKnowledgeAttributedUserId(principal, context), - workspaceId: context.workspaceId, - embeddingModel, - query: input.query!, - isBYOK: queryEmbeddingIsBYOK, - sourceReference: `v2-kb-search:${generateRequestId()}`, - billingAttribution, - }) + const rerankerScores = new Map<string, number>() + let rerankerBilled = false + let rerankerIsBYOK = false + if (useReranker && input.rerankerModel && rows.length > 0) { + const candidateCount = rows.length + try { + const reranked = await runWithKnowledgeModelInputProvenance(registry, () => + rerank( + input.query!, + rows.map((row) => ({ id: row.id, text: row.content })), + { + model: input.rerankerModel!, + topN: input.topK, + workspaceId: context.workspaceId, + apiKey: input.rerankerApiKey, + } + ) + ) + rerankerBilled = true + rerankerIsBYOK = reranked.isBYOK + if (reranked.results.length === 0) { + rows = rows.slice(0, input.topK) + } else { + const byId = new Map(rows.map((row) => [row.id, row])) + rows = reranked.results + .map((ranked) => byId.get(ranked.item.id)) + .filter((row): row is SearchResult => Boolean(row)) + for (const ranked of reranked.results) { + rerankerScores.set(ranked.item.id, ranked.relevanceScore) + } + } + } catch (error) { + if (registry?.isPermanentlyIncomplete()) throw error + logger.warn('Knowledge reranker failed; using vector ordering', { + error: getErrorMessage(error), + model: input.rerankerModel, + candidateCount, + }) + rows = rows.slice(0, input.topK) + } + } else if (useReranker) { + rows = rows.slice(0, input.topK) + } + + const queryEmbedding = await queryEmbeddingPromise + let tokenCount = 0 + let baseCost: ReturnType<typeof calculateCost> | null = null + if (hasQuery) { + tokenCount = estimateTokenCount( + input.query!, + getEmbeddingModelInfo(embeddingModel).tokenizerProvider + ).count + if (!queryEmbedding?.isBYOK) baseCost = calculateCost(embeddingModel, tokenCount, 0, false) + } + let rerankerCost = 0 + if (rerankerBilled && input.rerankerModel && !rerankerIsBYOK) { + const pricing = getRerankModelPricing(input.rerankerModel) + if (pricing) { + rerankerCost = pricing.perSearchUnit + baseCost = baseCost + ? { + ...baseCost, + input: baseCost.input + rerankerCost, + total: baseCost.total + rerankerCost, + } + : { + input: rerankerCost, + output: 0, + total: rerankerCost, + pricing: { input: 0, output: 0, updatedAt: pricing.updatedAt }, + } + } + } + if (shouldMeter && billingAttribution && baseCost && baseCost.total > 0) { + try { + await recordUsage({ + userId, + workspaceId: context.workspaceId, + ...toBillingContext(billingAttribution), + entries: [ + { + category: 'model', + source: 'knowledge-base', + description: embeddingModel, + cost: baseCost.total, + sourceReference: `kb-search:${requestId}`, + }, + ], + }) + await checkAndBillPayerOverageThreshold(billingAttribution.billingEntity) + } catch (error) { + logger.error('Failed to record Knowledge search usage', { error }) + } } const tagDefinitionEntries = await Promise.all( knowledgeBaseIds.map(async (knowledgeBaseId) => { const definitions = - tagDefinitionsByKnowledgeBase.get(knowledgeBaseId) ?? + definitionsByKnowledgeBase.get(knowledgeBaseId) ?? (await getDocumentTagDefinitions(knowledgeBaseId)) return [ knowledgeBaseId, @@ -246,16 +484,24 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ }) ) const tagMaps = new Map(tagDefinitionEntries) - const documentMetadata = await getDocumentMetadataByIds(rows.map((row) => row.documentId)) - - const results = rows.map((row: SearchResult): KnowledgeSearchItem => { + const basicDocumentMetadata = provenanceSnapshot + ? {} + : await getDocumentMetadataByIds(rows.map((row) => row.documentId)) + const results = rows.map((row): KnowledgeSearchItem => { const metadata: Record<string, unknown> = {} const tagMap = tagMaps.get(row.knowledgeBaseId) + const provenanceDocument = provenanceSnapshot?.documentMetadata[row.documentId] + const document = provenanceDocument ?? basicDocumentMetadata[row.documentId] for (const slot of ALL_TAG_SLOTS) { - const value = row[slot] + const value = + provenanceDocument && slot.startsWith('tag') + ? provenanceDocument[ + slot as 'tag1' | 'tag2' | 'tag3' | 'tag4' | 'tag5' | 'tag6' | 'tag7' + ] + : row[slot] if (value !== null && value !== undefined) metadata[tagMap?.get(slot) ?? slot] = value } - const document = documentMetadata[row.documentId] + const rerankerScore = rerankerScores.get(row.id) return { embeddingId: row.id, documentId: row.documentId, @@ -265,14 +511,65 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ chunkIndex: row.chunkIndex, metadata, similarity: hasQuery ? 1 - row.distance : 1, + ...(rerankerScore !== undefined ? { rerankerScore } : {}), } }) + if (registry && provenanceSnapshot) { + for (const [documentId, document] of Object.entries(provenanceSnapshot.documentMetadata)) { + const renderedMetadata = results + .filter((result) => result.documentId === documentId) + .map((result) => ({ + documentName: result.documentName, + sourceUrl: result.sourceUrl, + metadata: result.metadata, + })) + if ( + renderedMetadata.length > 0 && + !(await importDurableSecretProvenance(registry, document.provenance, renderedMetadata)) + ) { + registry.markIncomplete() + } + } + } + const cost = baseCost + ? { + input: baseCost.input, + output: baseCost.output, + total: baseCost.total, + tokens: { prompt: tokenCount, completion: 0, total: tokenCount }, + model: embeddingModel, + pricing: baseCost.pricing, + ...(rerankerBilled && !rerankerIsBYOK + ? { + rerankerCost, + rerankerModel: input.rerankerModel, + rerankerSearchUnits: 1, + } + : {}), + } + : undefined return { results, query: input.query ?? '', knowledgeBaseIds, + knowledgeBases: context.knowledgeBases.map((knowledgeBase) => ({ + id: knowledgeBase.id, + name: knowledgeBase.name, + })), + knowledgeBaseId: knowledgeBaseIds[0], topK: input.topK, totalResults: results.length, + cost, + workspaceId: context.workspaceId, + userId, + resultSecretRegistry: registry, } }, + afterSuccess: ({ context, result }) => { + PlatformEvents.knowledgeBaseSearched({ + knowledgeBaseId: result.knowledgeBaseId, + resultsCount: result.totalResults, + workspaceId: context.workspaceId, + }) + }, }) diff --git a/apps/sim/lib/knowledge/application/tags.test.ts b/apps/sim/lib/knowledge/application/tags.test.ts new file mode 100644 index 00000000000..836e3991057 --- /dev/null +++ b/apps/sim/lib/knowledge/application/tags.test.ts @@ -0,0 +1,203 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveKnowledgeBase: vi.fn(), + resolveTag: vi.fn(), + resolvePermission: vi.fn(), + listTags: vi.fn(), + nextSlot: vi.fn(), + createTag: vi.fn(), + updateTag: vi.fn(), + deleteTag: vi.fn(), + readUsage: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { KNOWLEDGE_BASE_UPDATED: 'knowledge_base.updated' }, + AuditResourceType: { KNOWLEDGE_BASE: 'knowledge_base' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveActiveKnowledgeBaseContext: mocks.resolveKnowledgeBase, + resolveActiveKnowledgeTagContext: mocks.resolveTag, +})) + +vi.mock('@/lib/knowledge/tags/service', () => ({ + getDocumentTagDefinitions: mocks.listTags, + getNextAvailableSlot: mocks.nextSlot, + createTagDefinition: mocks.createTag, + updateTagDefinition: mocks.updateTag, + deleteTagDefinition: mocks.deleteTag, + getTagUsageStats: mocks.readUsage, +})) + +import { + createKnowledgeTag, + deleteKnowledgeTag, + listKnowledgeTags, + readKnowledgeTagUsage, + updateKnowledgeTag, +} from '@/lib/knowledge/application/tags' + +const crossWorkspaceContext = { + workspaceId: 'workspace-b', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-b', + knowledgeBaseId: 'knowledge-b', + knowledgeBase: { id: 'knowledge-b', name: 'Workspace B docs' }, +} + +const tagContext = { + ...crossWorkspaceContext, + tagDefinitionId: 'tag-b', + tagDefinition: { + id: 'tag-b', + knowledgeBaseId: 'knowledge-b', + tagSlot: 'tag1', + displayName: 'Region', + fieldType: 'text', + }, +} + +const delegatedPrincipal = { + kind: 'delegated' as const, + serviceId: 'copilot', + subjectUserId: 'shared-user', + workspaceId: 'workspace-a', + delegationId: 'tool-call-1', + audience: 'sim:knowledge', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: {}, +} + +describe('knowledge tag application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveKnowledgeBase.mockResolvedValue(crossWorkspaceContext) + mocks.resolveTag.mockResolvedValue(tagContext) + }) + + it.each([ + [ + 'list', + listKnowledgeTags, + { knowledgeBaseId: 'knowledge-b', assertedWorkspaceId: 'workspace-a' }, + ], + [ + 'create', + createKnowledgeTag, + { + knowledgeBaseId: 'knowledge-b', + assertedWorkspaceId: 'workspace-a', + displayName: 'Region', + }, + ], + [ + 'update', + updateKnowledgeTag, + { + tagDefinitionId: 'tag-b', + assertedWorkspaceId: 'workspace-a', + updates: { displayName: 'Market' }, + }, + ], + [ + 'delete', + deleteKnowledgeTag, + { + knowledgeBaseId: 'knowledge-b', + tagDefinitionId: 'tag-b', + assertedWorkspaceId: 'workspace-a', + }, + ], + [ + 'read usage', + readKnowledgeTagUsage, + { knowledgeBaseId: 'knowledge-b', assertedWorkspaceId: 'workspace-a' }, + ], + ])( + 'rejects cross-workspace %s before current membership or tag work', + async (_name, useCase, input) => { + await expect(useCase.execute({ principal: delegatedPrincipal, input })).rejects.toMatchObject( + { + name: 'DelegatedWorkspaceAuthorizationError', + code: 'forbidden', + } + ) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.listTags).not.toHaveBeenCalled() + expect(mocks.nextSlot).not.toHaveBeenCalled() + expect(mocks.createTag).not.toHaveBeenCalled() + expect(mocks.updateTag).not.toHaveBeenCalled() + expect(mocks.deleteTag).not.toHaveBeenCalled() + expect(mocks.readUsage).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + } + ) + + it('authorizes current delegated membership before mutation and records semantic audit', async () => { + const sameWorkspaceContext = { + ...tagContext, + workspaceId: 'workspace-a', + knowledgeBaseId: 'knowledge-a', + knowledgeBase: { id: 'knowledge-a', name: 'Workspace A docs' }, + tagDefinition: { ...tagContext.tagDefinition, knowledgeBaseId: 'knowledge-a' }, + } + const updatedTag = { ...sameWorkspaceContext.tagDefinition, displayName: 'Market' } + mocks.resolveTag.mockResolvedValueOnce(sameWorkspaceContext) + mocks.updateTag.mockResolvedValueOnce(updatedTag) + + const result = await updateKnowledgeTag.execute({ + principal: delegatedPrincipal, + input: { + tagDefinitionId: 'tag-b', + assertedWorkspaceId: 'workspace-a', + updates: { displayName: 'Market' }, + source: 'agent', + }, + }) + + expect(result.tagDefinition).toEqual(updatedTag) + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'shared-user', + 'workspace-a', + null, + undefined, + { forUpdate: undefined } + ) + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.updateTag.mock.invocationCallOrder[0] + ) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-a', + action: 'knowledge_base.updated', + metadata: expect.objectContaining({ + operation: 'knowledge.tags.update', + change: 'tag_updated', + actor: expect.objectContaining({ kind: 'delegated', serviceId: 'copilot' }), + }), + }) + ) + }) +}) diff --git a/apps/sim/lib/knowledge/application/tags.ts b/apps/sim/lib/knowledge/application/tags.ts new file mode 100644 index 00000000000..7aec057f414 --- /dev/null +++ b/apps/sim/lib/knowledge/application/tags.ts @@ -0,0 +1,311 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { + resolveActiveKnowledgeBaseContext, + resolveActiveKnowledgeTagContext, + resolveCanonicalActiveKnowledgeDocumentContext, +} from '@/lib/knowledge/application/contexts' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants' +import { + cleanupUnusedTagDefinitions, + createOrUpdateTagDefinitionsBulk, + createTagDefinition, + deleteAllTagDefinitions, + deleteTagDefinition, + getDocumentTagDefinitions, + getNextAvailableSlot, + getTagDefinitions, + getTagUsage, + getTagUsageStats, + updateTagDefinition, +} from '@/lib/knowledge/tags/service' +import type { BulkTagDefinitionsData } from '@/lib/knowledge/tags/types' +import type { TagDefinition, UpdateTagDefinitionData } from '@/lib/knowledge/types' + +export interface ListKnowledgeTagsInput { + knowledgeBaseId: string + assertedWorkspaceId?: string +} + +export interface CreateKnowledgeTagInput extends ListKnowledgeTagsInput { + tagSlot?: string + displayName: string + fieldType?: string + source?: string +} + +export interface UpdateKnowledgeTagInput { + tagDefinitionId: string + assertedWorkspaceId?: string + updates: UpdateTagDefinitionData + source?: string +} + +export interface DeleteKnowledgeTagInput extends ListKnowledgeTagsInput { + tagDefinitionId: string + source?: string +} + +export interface ReadNextKnowledgeTagSlotInput extends ListKnowledgeTagsInput { + fieldType: string +} + +export interface KnowledgeDocumentTagDefinitionsInput extends ListKnowledgeTagsInput { + documentId: string +} + +export interface SaveKnowledgeDocumentTagDefinitionsInput + extends KnowledgeDocumentTagDefinitionsInput { + definitions: BulkTagDefinitionsData['definitions'] +} + +export interface DeleteKnowledgeDocumentTagDefinitionsInput + extends KnowledgeDocumentTagDefinitionsInput { + action?: 'cleanup' | 'all' +} + +export const listKnowledgeTags = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.listTags, + resolveContext: ({ input }: { input: ListKnowledgeTagsInput }) => + resolveActiveKnowledgeBaseContext(input), + async execute({ context }) { + return { tagDefinitions: await getDocumentTagDefinitions(context.knowledgeBaseId) } + }, +}) + +export const createKnowledgeTag = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.createTag, + resolveContext: ({ input }: { input: CreateKnowledgeTagInput }) => + resolveActiveKnowledgeBaseContext(input), + async execute({ input, context }): Promise<{ + tagDefinition: TagDefinition + knowledgeBaseId: string + }> { + const fieldType = input.fieldType ?? 'text' + if (!(SUPPORTED_FIELD_TYPES as readonly string[]).includes(fieldType)) { + throw new OrchestrationError('validation', 'Invalid field type') + } + const tagSlot = + input.tagSlot ?? (await getNextAvailableSlot(context.knowledgeBaseId, fieldType)) + if (!tagSlot) { + throw new OrchestrationError( + 'validation', + `No available slots for field type "${fieldType}". Maximum tags of this type reached.` + ) + } + const tagDefinition = await createTagDefinition( + { + knowledgeBaseId: context.knowledgeBaseId, + tagSlot, + displayName: input.displayName, + fieldType, + }, + generateRequestId() + ) + return { tagDefinition, knowledgeBaseId: context.knowledgeBaseId } + }, + projectAudit: ({ input, context, result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: context.knowledgeBaseId, + resourceName: context.knowledgeBase.name, + description: `Created tag "${result.tagDefinition.displayName}" in knowledge base "${context.knowledgeBase.name}"`, + metadata: { + source: input.source, + change: 'tag_created', + tagDefinitionId: result.tagDefinition.id, + tagSlot: result.tagDefinition.tagSlot, + fieldType: result.tagDefinition.fieldType, + }, + }), +}) + +export const updateKnowledgeTag = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.updateTag, + resolveContext: ({ input }: { input: UpdateKnowledgeTagInput }) => + resolveActiveKnowledgeTagContext(input), + async execute({ input, context }): Promise<{ + tagDefinition: TagDefinition + knowledgeBaseId: string + }> { + if (input.updates.displayName === undefined && input.updates.fieldType === undefined) { + throw new OrchestrationError('validation', 'No tag updates specified') + } + return { + tagDefinition: await updateTagDefinition( + context.tagDefinitionId, + input.updates, + generateRequestId() + ), + knowledgeBaseId: context.knowledgeBaseId, + } + }, + projectAudit: ({ input, context, result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: context.knowledgeBaseId, + resourceName: context.knowledgeBase.name, + description: `Updated tag "${result.tagDefinition.displayName}" in knowledge base "${context.knowledgeBase.name}"`, + metadata: { + source: input.source, + change: 'tag_updated', + tagDefinitionId: result.tagDefinition.id, + updatedFields: Object.keys(input.updates).filter( + (key) => input.updates[key as keyof UpdateTagDefinitionData] !== undefined + ), + }, + }), +}) + +export const deleteKnowledgeTag = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.deleteTag, + resolveContext: ({ input }: { input: DeleteKnowledgeTagInput }) => + resolveActiveKnowledgeTagContext(input), + async execute({ context }) { + const deleted = await deleteTagDefinition( + context.knowledgeBaseId, + context.tagDefinitionId, + generateRequestId() + ) + return { ...deleted, tagDefinitionId: context.tagDefinitionId } + }, + projectAudit: ({ input, context, result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: context.knowledgeBaseId, + resourceName: context.knowledgeBase.name, + description: `Deleted tag "${result.displayName}" from knowledge base "${context.knowledgeBase.name}"`, + metadata: { + source: input.source, + change: 'tag_deleted', + tagDefinitionId: result.tagDefinitionId, + tagSlot: result.tagSlot, + }, + }), +}) + +export const readKnowledgeTagUsage = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.readTagUsage, + resolveContext: ({ input }: { input: ListKnowledgeTagsInput }) => + resolveActiveKnowledgeBaseContext(input), + async execute({ context }) { + return { usage: await getTagUsageStats(context.knowledgeBaseId, generateRequestId()) } + }, +}) + +export const readDetailedKnowledgeTagUsage = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.readDetailedTagUsage, + resolveContext: ({ input }: { input: ListKnowledgeTagsInput }) => + resolveActiveKnowledgeBaseContext(input), + async execute({ context }) { + return { usage: await getTagUsage(context.knowledgeBaseId, generateRequestId()) } + }, +}) + +export const readNextKnowledgeTagSlot = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.readNextTagSlot, + resolveContext: ({ input }: { input: ReadNextKnowledgeTagSlotInput }) => + resolveActiveKnowledgeBaseContext(input), + async execute({ input, context }) { + if (!(SUPPORTED_FIELD_TYPES as readonly string[]).includes(input.fieldType)) { + throw new OrchestrationError('validation', 'Invalid field type') + } + const existingDefinitions = await getTagDefinitions(context.knowledgeBaseId) + const usedSlots = existingDefinitions + .filter((definition) => definition.fieldType === input.fieldType) + .map((definition) => definition.tagSlot) + const existingBySlot = new Map( + existingDefinitions.map((definition) => [definition.tagSlot, definition]) + ) + const nextAvailableSlot = await getNextAvailableSlot( + context.knowledgeBaseId, + input.fieldType, + existingBySlot + ) + return { + nextAvailableSlot, + fieldType: input.fieldType, + usedSlots, + totalSlots: 7, + availableSlots: nextAvailableSlot ? 7 - usedSlots.length : 0, + } + }, +}) + +export const listKnowledgeDocumentTagDefinitions = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.listTags, + resolveContext: ({ input }: { input: KnowledgeDocumentTagDefinitionsInput }) => + resolveCanonicalActiveKnowledgeDocumentContext(input), + async execute({ context }) { + return { tagDefinitions: await getDocumentTagDefinitions(context.knowledgeBaseId) } + }, +}) + +export const saveKnowledgeDocumentTagDefinitions = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.saveDocumentTagDefinitions, + resolveContext: ({ input }: { input: SaveKnowledgeDocumentTagDefinitionsInput }) => + resolveCanonicalActiveKnowledgeDocumentContext(input), + async execute({ input, context }) { + for (const definition of input.definitions) { + if (!(SUPPORTED_FIELD_TYPES as readonly string[]).includes(definition.fieldType)) { + throw new OrchestrationError( + 'validation', + `Unsupported field type: ${definition.fieldType}` + ) + } + } + return createOrUpdateTagDefinitionsBulk( + context.knowledgeBaseId, + { definitions: input.definitions }, + generateRequestId() + ) + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: context.knowledgeBaseId, + resourceName: context.knowledgeBase.name, + description: `Updated tag definitions in knowledge base "${context.knowledgeBase.name}"`, + metadata: { + change: 'document_tag_definitions_saved', + createdCount: result.created.length, + updatedCount: result.updated.length, + errorCount: result.errors.length, + }, + }), +}) + +export const deleteKnowledgeDocumentTagDefinitions = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.deleteDocumentTagDefinitions, + resolveContext: ({ input }: { input: DeleteKnowledgeDocumentTagDefinitionsInput }) => + resolveCanonicalActiveKnowledgeDocumentContext(input), + async execute({ input, context }) { + if (input.action === 'cleanup') { + return { + action: 'cleanup' as const, + count: await cleanupUnusedTagDefinitions(context.knowledgeBaseId, generateRequestId()), + } + } + return { + action: 'all' as const, + count: await deleteAllTagDefinitions(context.knowledgeBaseId, generateRequestId()), + } + }, + projectAudit: ({ input, context, result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: context.knowledgeBaseId, + resourceName: context.knowledgeBase.name, + description: + input.action === 'cleanup' + ? `Cleaned unused tag definitions in knowledge base "${context.knowledgeBase.name}"` + : `Deleted tag definitions in knowledge base "${context.knowledgeBase.name}"`, + metadata: { + change: result.action === 'cleanup' ? 'tag_definitions_cleaned' : 'tag_definitions_deleted', + count: result.count, + }, + }), +}) diff --git a/apps/sim/lib/knowledge/application/upload-sessions.ts b/apps/sim/lib/knowledge/application/upload-sessions.ts index 3d8586ad9da..73526ddf1f5 100644 --- a/apps/sim/lib/knowledge/application/upload-sessions.ts +++ b/apps/sim/lib/knowledge/application/upload-sessions.ts @@ -1,7 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import type { Principal } from '@sim/auth/principal' -import type { V2KnowledgeDocumentUploadMetadata } from '@/lib/api/contracts/v2/knowledge' -import { v2KnowledgeDocumentUploadMetadataSchema } from '@/lib/api/contracts/v2/knowledge' import { checkAttributedUsageLimits } from '@/lib/billing/core/billing-attribution' import { authorizeWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -25,6 +23,10 @@ import { } from '@/lib/knowledge/documents/service' import type { CreatedKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' import { findBoundKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' +import { + type KnowledgeDocumentUploadMetadata, + knowledgeDocumentUploadMetadataSchema, +} from '@/lib/knowledge/upload-metadata' import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' import { requestOrigin } from '@/lib/uploads/upload-session/application' import { @@ -60,7 +62,7 @@ export interface CreateKnowledgeDocumentUploadInput { name: string contentType: string size: number - metadata: V2KnowledgeDocumentUploadMetadata + metadata: KnowledgeDocumentUploadMetadata } export interface KnowledgeDocumentUploadControlInput { @@ -384,7 +386,7 @@ export const completeKnowledgeDocumentUpload = defineAuthorizedKnowledgeUseCase( async function dispatchKnowledgeDocumentProcessing( document: CreatedKnowledgeDocument, knowledgeBaseId: string, - processingOptions: V2KnowledgeDocumentUploadMetadata['processingOptions'], + processingOptions: KnowledgeDocumentUploadMetadata['processingOptions'], requestId: string, billingAttribution: Awaited<ReturnType<typeof resolveKnowledgeBillingAttribution>> ): Promise<void> { @@ -447,7 +449,7 @@ async function reauthorizeKnowledgeDocumentUpload( function knowledgeDocumentMetadataFor(session: UploadSessionRecord) { const { authBinding: _authBinding, ...metadata } = session.metadata - return v2KnowledgeDocumentUploadMetadataSchema.parse(metadata) + return knowledgeDocumentUploadMetadataSchema.parse(metadata) } function knowledgeDocumentInputFor(session: UploadSessionRecord) { diff --git a/apps/sim/lib/knowledge/connectors/service.ts b/apps/sim/lib/knowledge/connectors/service.ts new file mode 100644 index 00000000000..9d454e62109 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/service.ts @@ -0,0 +1,34 @@ +import { db } from '@sim/db' +import { knowledgeConnector } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' + +export interface ActiveKnowledgeConnectorReference { + id: string + knowledgeBaseId: string + connectorType: string + status: string +} + +/** Resolves a connector's canonical active parent without trusting a caller-supplied KB ID. */ +export async function getActiveKnowledgeConnectorReference( + connectorId: string +): Promise<ActiveKnowledgeConnectorReference | null> { + const [connector] = await db + .select({ + id: knowledgeConnector.id, + knowledgeBaseId: knowledgeConnector.knowledgeBaseId, + connectorType: knowledgeConnector.connectorType, + status: knowledgeConnector.status, + }) + .from(knowledgeConnector) + .where( + and( + eq(knowledgeConnector.id, connectorId), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + .limit(1) + + return connector ?? null +} diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index 540ae694595..b4d3a162480 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -18,7 +18,7 @@ vi.mock('@/lib/knowledge/documents/service', () => ({ processDocumentAsync: vi.fn(), })) vi.mock('@/lib/uploads', () => ({ StorageService: {} })) -vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) +vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock) vi.mock('@/background/knowledge-connector-sync', () => ({ knowledgeConnectorSync: { trigger: vi.fn() }, })) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 8ca978ec829..109167e2edc 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -20,11 +20,11 @@ import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { resolveCredentialTokenIdentity } from '@/lib/credentials/access' 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 { deleteFile } from '@/lib/uploads/core/storage-service' import { deleteFileMetadata } from '@/lib/uploads/server/metadata' import { extractStorageKey } from '@/lib/uploads/utils/file-utils' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' import type { ConnectorAuthConfig, diff --git a/apps/sim/lib/knowledge/constants.ts b/apps/sim/lib/knowledge/constants.ts index 54cdf56ec97..71a03f5d0ed 100644 --- a/apps/sim/lib/knowledge/constants.ts +++ b/apps/sim/lib/knowledge/constants.ts @@ -8,6 +8,13 @@ export const MAX_KNOWLEDGE_BASES_PER_WORKSPACE = 10_000 export const MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE = MAX_FOLDERS_PER_WORKSPACE /** Hard bound for connector-type rows projected onto one knowledge-base list. */ export const MAX_KNOWLEDGE_CONNECTOR_TYPE_ROWS_PER_LIST = 100_000 +/** Maximum documents accepted by one internal bulk-create command. */ +export const MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE = 100 +/** Maximum connector documents mutated atomically by one command. */ +export const MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_MUTATION_ITEMS = 100 +/** Default and maximum bounded connector-document list page sizes. */ +export const DEFAULT_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE = 100 +export const MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE = 200 /** * Chunking a knowledge base gets when its creator names no configuration. diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 7425819f571..8f9219df7ab 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -1819,6 +1819,30 @@ export async function getKnowledgeDocument( return row ? { ...row, connectorType: row.connectorType ?? null } : null } +/** Loads one visible document by its canonical ID before any asserted parent is trusted. */ +export async function getKnowledgeDocumentById( + documentId: string +): Promise<ActiveKnowledgeDocument | null> { + const [row] = await db + .select({ + ...getTableColumns(document), + connectorType: knowledgeConnector.connectorType, + }) + .from(document) + .leftJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id)) + .where( + and( + eq(document.id, documentId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(1) + + return row ? { ...row, connectorType: row.connectorType ?? null } : null +} + export async function createSingleDocument( documentData: { filename: string diff --git a/apps/sim/lib/knowledge/orchestration/connectors.test.ts b/apps/sim/lib/knowledge/orchestration/connectors.test.ts index 68f7a10f242..6131c462c93 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.test.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.test.ts @@ -112,6 +112,24 @@ describe('performDeleteKnowledgeConnector', () => { expect(outcome).toMatchObject({ success: false, errorCode: 'not_found' }) expect(mockRecordAudit).not.toHaveBeenCalled() }) + + it('returns authoritative delete counts without legacy audit or analytics when disabled', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + queueTableRows(document, [{ id: 'doc-1', fileUrl: '/a.txt' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'conn-1' }]) + + const outcome = await performDeleteKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + recordSemanticAudit: false, + recordProductAnalytics: false, + }) + + expect(outcome).toMatchObject({ success: true, documentsKept: 1 }) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) }) describe('performUpdateKnowledgeConnector', () => { @@ -207,6 +225,24 @@ describe('performUpdateKnowledgeConnector', () => { expect.objectContaining({ consecutiveFailures: 0, lastSyncError: null }) ) }) + + it('leaves semantic audit to an authorized application caller when requested', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-1', connectorType: 'notion' }]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'paused' }, + ]) + + const outcome = await performUpdateKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + updates: { status: 'paused' }, + recordSemanticAudit: false, + }) + + expect(outcome).toMatchObject({ success: true }) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) }) describe('performSyncKnowledgeConnector', () => { @@ -295,4 +331,24 @@ describe('performSyncKnowledgeConnector', () => { expect(outcome).toMatchObject({ success: false, errorCode: 'conflict' }) }) + + it('dispatches while leaving semantic audit and product analytics to the application surface', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'conn-1', connectorType: 'notion', status: 'active' }, + ]) + + const outcome = await performSyncKnowledgeConnector({ + ...ACTOR, + knowledgeBase: KB, + connectorId: 'conn-1', + resolveBillingAttribution, + recordSemanticAudit: false, + recordProductAnalytics: false, + }) + + expect(outcome).toMatchObject({ success: true }) + expect(mockDispatchSync).toHaveBeenCalledOnce() + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/knowledge/orchestration/connectors.ts b/apps/sim/lib/knowledge/orchestration/connectors.ts index 01d18f5ccea..16ca5d23d8c 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.ts @@ -98,6 +98,10 @@ export interface PerformCreateKnowledgeConnectorParams extends KnowledgeOperatio * because credential lookup is scoped to the requesting identity. */ resolveAccessToken: (credentialId: string) => Promise<string | null> + /** False only when an authorized application use case projects the semantic audit. */ + recordSemanticAudit?: boolean + /** False when the calling HTTP/tool adapter owns product analytics. */ + recordProductAnalytics?: boolean } export type PerformConnectorResult = KnowledgeOrchestrationResult<{ @@ -302,39 +306,43 @@ export async function performCreateKnowledgeConnector( logger.info(`[${requestId}] Created connector ${connectorId} for KB ${kb.id}`) - captureServerEvent( - params.userId, - 'knowledge_base_connector_added', - { - knowledge_base_id: kb.id, - workspace_id: workspaceId, - connector_type: connectorType, - sync_interval_minutes: syncIntervalMinutes, - }, - { - groups: { workspace: workspaceId }, - setOnce: { first_connector_added_at: new Date().toISOString() }, - } - ) + if (params.recordProductAnalytics !== false) { + captureServerEvent( + params.userId, + 'knowledge_base_connector_added', + { + knowledge_base_id: kb.id, + workspace_id: workspaceId, + connector_type: connectorType, + sync_interval_minutes: syncIntervalMinutes, + }, + { + groups: { workspace: workspaceId }, + setOnce: { first_connector_added_at: new Date().toISOString() }, + } + ) + } - recordAudit({ - workspaceId, - ...auditActorFields(params), - action: AuditAction.CONNECTOR_CREATED, - resourceType: AuditResourceType.CONNECTOR, - resourceId: connectorId, - resourceName: connectorType, - description: `Created ${connectorType} connector for knowledge base "${kb.name}"`, - metadata: { - source, - knowledgeBaseId: kb.id, - knowledgeBaseName: kb.name, - connectorType, - syncIntervalMinutes, - authMode: connectorConfig.auth.mode, - }, - ...(request ? { request } : {}), - }) + if (params.recordSemanticAudit !== false) { + recordAudit({ + workspaceId, + ...auditActorFields(params), + action: AuditAction.CONNECTOR_CREATED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: connectorId, + resourceName: connectorType, + description: `Created ${connectorType} connector for knowledge base "${kb.name}"`, + metadata: { + source, + knowledgeBaseId: kb.id, + knowledgeBaseName: kb.name, + connectorType, + syncIntervalMinutes, + authMode: connectorConfig.auth.mode, + }, + ...(request ? { request } : {}), + }) + } const dispatchSync = await loadDispatchSync() dispatchSync(connectorId, { billingAttribution, requestId }).catch((error) => { @@ -368,6 +376,8 @@ export interface PerformUpdateKnowledgeConnectorParams extends KnowledgeOperatio connector: KnowledgeConnectorRow, sourceConfig: Record<string, unknown> ) => Promise<SourceConfigRejection | null> + /** False only when an authorized application use case projects the semantic audit. */ + recordSemanticAudit?: boolean } /** Loads an active connector scoped to its knowledge base. */ @@ -480,27 +490,29 @@ export async function performUpdateKnowledgeConnector( return classifyKnowledgeFailure(error, requestId, `Update connector ${connectorId}`) } - recordAudit({ - workspaceId: kb.workspaceId, - ...auditActorFields(params), - action: AuditAction.CONNECTOR_UPDATED, - resourceType: AuditResourceType.CONNECTOR, - resourceId: connectorId, - resourceName: updated.connectorType, - description: `Updated connector for knowledge base "${kb.name}"`, - metadata: { - source, - knowledgeBaseId: kb.id, - knowledgeBaseName: kb.name, - connectorType: updated.connectorType, - updatedFields, - ...(updates.syncIntervalMinutes !== undefined && { - syncIntervalMinutes: updates.syncIntervalMinutes, - }), - ...(updates.status !== undefined && { newStatus: updates.status }), - }, - ...(request ? { request } : {}), - }) + if (params.recordSemanticAudit !== false) { + recordAudit({ + workspaceId: kb.workspaceId, + ...auditActorFields(params), + action: AuditAction.CONNECTOR_UPDATED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: connectorId, + resourceName: updated.connectorType, + description: `Updated connector for knowledge base "${kb.name}"`, + metadata: { + source, + knowledgeBaseId: kb.id, + knowledgeBaseName: kb.name, + connectorType: updated.connectorType, + updatedFields, + ...(updates.syncIntervalMinutes !== undefined && { + syncIntervalMinutes: updates.syncIntervalMinutes, + }), + ...(updates.status !== undefined && { newStatus: updates.status }), + }, + ...(request ? { request } : {}), + }) + } return { success: true, connector: withoutSecret(updated) } } @@ -513,6 +525,10 @@ export interface PerformDeleteKnowledgeConnectorParams extends KnowledgeOperatio * them, which turns them into ordinary standalone knowledge base entries. */ deleteDocuments?: boolean + /** False only when an authorized application use case projects the semantic audit. */ + recordSemanticAudit?: boolean + /** False when the calling HTTP/tool adapter owns product analytics. */ + recordProductAnalytics?: boolean } /** What actually happened to the connector's documents, for the caller to report. */ @@ -609,37 +625,41 @@ export async function performDeleteKnowledgeConnector( `[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}` ) - captureServerEvent( - params.userId, - 'knowledge_base_connector_removed', - { - knowledge_base_id: kb.id, - workspace_id: kb.workspaceId ?? '', - connector_type: existing.connectorType, - documents_deleted: deleteDocuments ? docCount : 0, - }, - kb.workspaceId ? { groups: { workspace: kb.workspaceId } } : undefined - ) + if (params.recordProductAnalytics !== false) { + captureServerEvent( + params.userId, + 'knowledge_base_connector_removed', + { + knowledge_base_id: kb.id, + workspace_id: kb.workspaceId ?? '', + connector_type: existing.connectorType, + documents_deleted: deleteDocuments ? docCount : 0, + }, + kb.workspaceId ? { groups: { workspace: kb.workspaceId } } : undefined + ) + } - recordAudit({ - workspaceId: kb.workspaceId, - ...auditActorFields(params), - action: AuditAction.CONNECTOR_DELETED, - resourceType: AuditResourceType.CONNECTOR, - resourceId: connectorId, - resourceName: existing.connectorType, - description: `Deleted connector from knowledge base "${kb.name}"`, - metadata: { - source, - knowledgeBaseId: kb.id, - knowledgeBaseName: kb.name, - connectorType: existing.connectorType, - deleteDocuments, - documentsDeleted: deleteDocuments ? docCount : 0, - documentsKept: deleteDocuments ? 0 : docCount, - }, - ...(request ? { request } : {}), - }) + if (params.recordSemanticAudit !== false) { + recordAudit({ + workspaceId: kb.workspaceId, + ...auditActorFields(params), + action: AuditAction.CONNECTOR_DELETED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: connectorId, + resourceName: existing.connectorType, + description: `Deleted connector from knowledge base "${kb.name}"`, + metadata: { + source, + knowledgeBaseId: kb.id, + knowledgeBaseName: kb.name, + connectorType: existing.connectorType, + deleteDocuments, + documentsDeleted: deleteDocuments ? docCount : 0, + documentsKept: deleteDocuments ? 0 : docCount, + }, + ...(request ? { request } : {}), + }) + } return { success: true, @@ -658,6 +678,10 @@ export interface PerformSyncKnowledgeConnectorParams extends KnowledgeOperationC resolveBillingAttribution: () => Promise<BillingAttributionSnapshot> /** Re-fetch and re-index every already-synced document, not only changed ones. */ rehydrate?: boolean + /** False only when an authorized application use case projects the semantic audit. */ + recordSemanticAudit?: boolean + /** False when the calling HTTP/tool adapter owns product analytics. */ + recordProductAnalytics?: boolean } export type PerformSyncKnowledgeConnectorResult = KnowledgeOrchestrationResult @@ -693,35 +717,39 @@ export async function performSyncKnowledgeConnector( `[${requestId}] Manual sync${rehydrate ? ' (full rehydrate)' : ''} triggered for connector ${connectorId}` ) - captureServerEvent( - params.userId, - 'knowledge_base_connector_synced', - { - knowledge_base_id: kb.id, - workspace_id: kb.workspaceId ?? '', - connector_type: connector.connectorType, - }, - kb.workspaceId ? { groups: { workspace: kb.workspaceId } } : undefined - ) + if (params.recordProductAnalytics !== false) { + captureServerEvent( + params.userId, + 'knowledge_base_connector_synced', + { + knowledge_base_id: kb.id, + workspace_id: kb.workspaceId ?? '', + connector_type: connector.connectorType, + }, + kb.workspaceId ? { groups: { workspace: kb.workspaceId } } : undefined + ) + } - recordAudit({ - workspaceId: kb.workspaceId, - ...auditActorFields(params), - action: AuditAction.CONNECTOR_SYNCED, - resourceType: AuditResourceType.CONNECTOR, - resourceId: connectorId, - resourceName: connector.connectorType, - description: `Triggered manual sync for connector on knowledge base "${kb.name}"`, - metadata: { - source, - knowledgeBaseId: kb.id, - knowledgeBaseName: kb.name, - connectorType: connector.connectorType, - connectorStatus: connector.status, - syncType: rehydrate ? 'manual-rehydrate' : 'manual', - }, - ...(request ? { request } : {}), - }) + if (params.recordSemanticAudit !== false) { + recordAudit({ + workspaceId: kb.workspaceId, + ...auditActorFields(params), + action: AuditAction.CONNECTOR_SYNCED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: connectorId, + resourceName: connector.connectorType, + description: `Triggered manual sync for connector on knowledge base "${kb.name}"`, + metadata: { + source, + knowledgeBaseId: kb.id, + knowledgeBaseName: kb.name, + connectorType: connector.connectorType, + connectorStatus: connector.status, + syncType: rehydrate ? 'manual-rehydrate' : 'manual', + }, + ...(request ? { request } : {}), + }) + } const dispatchSync = await loadDispatchSync() dispatchSync(connectorId, { billingAttribution, requestId, rehydrate }).catch((error) => { diff --git a/apps/sim/lib/knowledge/orchestration/documents.test.ts b/apps/sim/lib/knowledge/orchestration/documents.test.ts index 42a8b250e05..e4a66fec7f7 100644 --- a/apps/sim/lib/knowledge/orchestration/documents.test.ts +++ b/apps/sim/lib/knowledge/orchestration/documents.test.ts @@ -12,6 +12,7 @@ const { mockMarkDocumentAsFailedTimeout, mockProcessDocumentAsync, mockProcessDocumentsWithQueue, + mockPlatformUpload, mockRecordAudit, mockRetryDocumentProcessing, mockUpdateDocument, @@ -24,6 +25,7 @@ const { mockMarkDocumentAsFailedTimeout: vi.fn(), mockProcessDocumentAsync: vi.fn(), mockProcessDocumentsWithQueue: vi.fn(), + mockPlatformUpload: vi.fn(), mockRecordAudit: vi.fn(), mockRetryDocumentProcessing: vi.fn(), mockUpdateDocument: vi.fn(), @@ -39,7 +41,7 @@ vi.mock('@sim/audit', () => ({ recordAudit: mockRecordAudit, })) vi.mock('@/lib/core/telemetry', () => ({ - PlatformEvents: { knowledgeBaseDocumentsUploaded: vi.fn() }, + PlatformEvents: { knowledgeBaseDocumentsUploaded: mockPlatformUpload }, })) vi.mock('@/lib/knowledge/documents/service', () => ({ createDocumentRecords: mockCreateDocumentRecords, @@ -110,7 +112,14 @@ describe('performUploadKnowledgeDocument', () => { uploadedBy: 'workspace-owner', }) - expect(mockCreateSingleDocument).toHaveBeenCalledWith(FILE, 'kb-1', 'req-1', 'workspace-owner') + expect(mockCreateSingleDocument).toHaveBeenCalledWith( + FILE, + 'kb-1', + 'req-1', + 'workspace-owner', + undefined, + undefined + ) }) it('starts no indexing unless the caller asks for it', async () => { @@ -160,6 +169,21 @@ describe('performUploadKnowledgeDocument', () => { ).toBe('forbidden') }) + it('returns the authoritative upload without legacy audit or product analytics when disabled', async () => { + const outcome = await performUploadKnowledgeDocument({ + ...ACTOR, + knowledgeBase: KB, + document: FILE, + recordSemanticAudit: false, + recordProductAnalytics: false, + }) + + expect(outcome).toMatchObject({ success: true, document: { id: 'doc-1' } }) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockPlatformUpload).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) + it('returns the document already bound to a stateless upload id without duplicating work', async () => { const existing = { id: 'upload-1', @@ -219,7 +243,8 @@ describe('performUploadKnowledgeDocument', () => { 'kb-1', 'req-1', 'user-1', - 'upload-1' + 'upload-1', + undefined ) expect(mockProcessDocumentsWithQueue).not.toHaveBeenCalled() expect(mockRecordAudit).not.toHaveBeenCalled() @@ -261,6 +286,22 @@ describe('performUploadKnowledgeDocuments', () => { expect(outcome).toMatchObject({ success: false, errorCode: 'validation' }) expect(mockCreateDocumentRecords).not.toHaveBeenCalled() }) + + it('returns the authoritative batch without legacy audit or product analytics when disabled', async () => { + const outcome = await performUploadKnowledgeDocuments({ + ...ACTOR, + knowledgeBase: KB, + documents: [FILE], + recordSemanticAudit: false, + recordProductAnalytics: false, + }) + + expect(outcome).toMatchObject({ success: true }) + expect(outcome.success && outcome.documents[0]).toMatchObject({ documentId: 'doc-1' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockPlatformUpload).not.toHaveBeenCalled() + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) }) describe('performUpdateKnowledgeDocument', () => { diff --git a/apps/sim/lib/knowledge/orchestration/documents.ts b/apps/sim/lib/knowledge/orchestration/documents.ts index d4b0d00556c..72c7eb51cf7 100644 --- a/apps/sim/lib/knowledge/orchestration/documents.ts +++ b/apps/sim/lib/knowledge/orchestration/documents.ts @@ -77,6 +77,10 @@ export interface PerformUploadKnowledgeDocumentParams extends KnowledgeOperation /** Deterministic id carried by a stateless upload token for completion retries. */ documentId?: string secretProvenance?: KnowledgeDocumentWriteSecretProvenance + /** False when an authorized application use case projects the semantic audit. */ + recordSemanticAudit?: boolean + /** False when the calling HTTP/tool adapter owns product analytics. */ + recordProductAnalytics?: boolean } export type PerformUploadKnowledgeDocumentResult = KnowledgeOrchestrationResult<{ @@ -266,25 +270,29 @@ export async function performUploadKnowledgeDocument( }) } - PlatformEvents.knowledgeBaseDocumentsUploaded({ - knowledgeBaseId: knowledgeBase.id, - documentsCount: 1, - uploadType: 'single', - mimeType: document.mimeType, - fileSize: document.fileSize, - }) - captureUpload(params, 1, 'single') - - auditUpload(params, { - resourceId: created.id, - resourceName: document.filename, - description: `Uploaded document "${document.filename}" to knowledge base "${knowledgeBase.name ?? knowledgeBase.id}"`, - metadata: { - fileName: document.filename, - fileType: document.mimeType, + if (params.recordProductAnalytics !== false) { + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId: knowledgeBase.id, + documentsCount: 1, + uploadType: 'single', + mimeType: document.mimeType, fileSize: document.fileSize, - }, - }) + }) + captureUpload(params, 1, 'single') + } + + if (params.recordSemanticAudit !== false) { + auditUpload(params, { + resourceId: created.id, + resourceName: document.filename, + description: `Uploaded document "${document.filename}" to knowledge base "${knowledgeBase.name ?? knowledgeBase.id}"`, + metadata: { + fileName: document.filename, + fileType: document.mimeType, + fileSize: document.fileSize, + }, + }) + } return { success: true, document: created, created: true } } @@ -296,6 +304,10 @@ export interface PerformUploadKnowledgeDocumentsParams extends KnowledgeOperatio billingAttribution?: BillingAttributionSnapshot uploadedBy?: string | null secretProvenances?: readonly KnowledgeDocumentWriteSecretProvenance[] + /** False when an authorized application use case projects the semantic audit. */ + recordSemanticAudit?: boolean + /** False when the calling HTTP/tool adapter owns product analytics. */ + recordProductAnalytics?: boolean } export type PerformUploadKnowledgeDocumentsResult = KnowledgeOrchestrationResult<{ @@ -349,20 +361,24 @@ export async function performUploadKnowledgeDocuments( logger.error(`[${requestId}] Critical error in document processing pipeline`, { error }) }) - PlatformEvents.knowledgeBaseDocumentsUploaded({ - knowledgeBaseId: knowledgeBase.id, - documentsCount: created.length, - uploadType: 'bulk', - recipe: processingOptions?.recipe, - }) - captureUpload(params, created.length, 'bulk') + if (params.recordProductAnalytics !== false) { + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId: knowledgeBase.id, + documentsCount: created.length, + uploadType: 'bulk', + recipe: processingOptions?.recipe, + }) + captureUpload(params, created.length, 'bulk') + } - auditUpload(params, { - resourceId: knowledgeBase.id, - resourceName: `${created.length} document(s)`, - description: `Uploaded ${created.length} document(s) to knowledge base "${knowledgeBase.name ?? knowledgeBase.id}"`, - metadata: { fileCount: created.length }, - }) + if (params.recordSemanticAudit !== false) { + auditUpload(params, { + resourceId: knowledgeBase.id, + resourceName: `${created.length} document(s)`, + description: `Uploaded ${created.length} document(s) to knowledge base "${knowledgeBase.name ?? knowledgeBase.id}"`, + metadata: { fileCount: created.length }, + }) + } return { success: true, documents: created } } diff --git a/apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts b/apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts index 6aed845a9be..74ed2b10951 100644 --- a/apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts +++ b/apps/sim/lib/knowledge/orchestration/knowledge-bases.test.ts @@ -235,7 +235,9 @@ describe('performDeleteKnowledgeBase', () => { }) expect(outcome.success).toBe(true) - expect(mockDeleteKnowledgeBase).toHaveBeenCalledWith('kb-1', 'req-1') + expect(mockDeleteKnowledgeBase).toHaveBeenCalledWith('kb-1', 'req-1', { + assertedWorkspaceId: undefined, + }) expect(mockRecordAudit).toHaveBeenCalledWith( expect.objectContaining({ actorId: 'user-1', resourceId: 'kb-1' }) ) diff --git a/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts b/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts index b808f820e97..b2f6d873764 100644 --- a/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts +++ b/apps/sim/lib/knowledge/orchestration/knowledge-bases.ts @@ -121,6 +121,7 @@ export async function performCreateKnowledgeBase( export interface PerformUpdateKnowledgeBaseParams extends KnowledgeOperationContext { knowledgeBaseId: string + assertedWorkspaceId?: string /** Workspace the knowledge base currently belongs to, for the audit row. */ workspaceId: string | null updates: { @@ -157,6 +158,7 @@ export async function performUpdateKnowledgeBase( try { updated = await updateKnowledgeBase(knowledgeBaseId, updates, requestId, { actorUserId: params.userId, + assertedWorkspaceId: params.assertedWorkspaceId, }) } catch (error) { return classifyKnowledgeFailure(error, requestId, `Update knowledge base ${knowledgeBaseId}`) @@ -193,6 +195,7 @@ export async function performUpdateKnowledgeBase( export interface PerformDeleteKnowledgeBaseParams extends KnowledgeOperationContext { knowledgeBase: { id: string; name: string; workspaceId: string | null } + assertedWorkspaceId?: string } export type PerformDeleteKnowledgeBaseResult = KnowledgeOrchestrationResult @@ -211,7 +214,9 @@ export async function performDeleteKnowledgeBase( const requestId = params.requestId ?? generateRequestId() try { - await deleteKnowledgeBase(knowledgeBase.id, requestId) + await deleteKnowledgeBase(knowledgeBase.id, requestId, { + assertedWorkspaceId: params.assertedWorkspaceId, + }) } catch (error) { return classifyKnowledgeFailure(error, requestId, `Delete knowledge base ${knowledgeBase.id}`) } diff --git a/apps/sim/lib/knowledge/tags/secret-provenance-delete.test.ts b/apps/sim/lib/knowledge/tags/secret-provenance-delete.test.ts index 11a8551abb0..73439d09062 100644 --- a/apps/sim/lib/knowledge/tags/secret-provenance-delete.test.ts +++ b/apps/sim/lib/knowledge/tags/secret-provenance-delete.test.ts @@ -4,6 +4,7 @@ import { document, embedding, knowledgeBase, knowledgeBaseTagDefinitions } from '@sim/db/schema' import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { deleteAllTagDefinitions, deleteTagDefinition, @@ -60,6 +61,14 @@ describe('knowledge tag deletion provenance', () => { expect(dbChainMockFns.delete).not.toHaveBeenCalled() }) + it('classifies provenance rejection as a caller-actionable conflict', () => { + expect(asOrchestrationError(new KnowledgeTagProvenanceConflictError())).toMatchObject({ + code: 'conflict', + message: + 'Tag definitions cannot be deleted while resolved-secret document provenance is present', + }) + }) + it('clears every bounded tag slot through the same guarded mutation path', async () => { queueTableRows(knowledgeBase, [{ id: KNOWLEDGE_BASE_ID }]) queueTableRows(knowledgeBaseTagDefinitions, [ diff --git a/apps/sim/lib/knowledge/tags/service.ts b/apps/sim/lib/knowledge/tags/service.ts index 7fe061670ea..b7f559da7e9 100644 --- a/apps/sim/lib/knowledge/tags/service.ts +++ b/apps/sim/lib/knowledge/tags/service.ts @@ -9,6 +9,7 @@ import { import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNotNull, isNull, or, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx, DbTransaction } from '@/lib/db/types' import { getSlotsForFieldType, SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants' import type { BulkTagDefinitionsData, DocumentTagDefinition } from '@/lib/knowledge/tags/types' @@ -43,9 +44,12 @@ const TAG_MUTATION_STATEMENT_TIMEOUT_MS = 120_000 const TAG_MUTATION_LOCK_TIMEOUT_MS = 5_000 const TAG_MUTATION_IDLE_TIMEOUT_MS = 30_000 -export class KnowledgeTagProvenanceConflictError extends Error { +export class KnowledgeTagProvenanceConflictError extends OrchestrationError { constructor() { - super('Tag definitions cannot be deleted while resolved-secret document provenance is present') + super( + 'conflict', + 'Tag definitions cannot be deleted while resolved-secret document provenance is present' + ) this.name = 'KnowledgeTagProvenanceConflictError' } } diff --git a/apps/sim/lib/knowledge/upload-metadata.ts b/apps/sim/lib/knowledge/upload-metadata.ts new file mode 100644 index 00000000000..ec5761b925d --- /dev/null +++ b/apps/sim/lib/knowledge/upload-metadata.ts @@ -0,0 +1,28 @@ +import { z } from 'zod' + +const knowledgeDocumentUploadTagSchema = z + .string() + .max(1000, 'Knowledge document tag values cannot exceed 1000 characters') + .optional() + +/** Persisted metadata stored with a resumable Knowledge document upload session. */ +export const knowledgeDocumentUploadMetadataSchema = z + .object({ + tag1: knowledgeDocumentUploadTagSchema, + tag2: knowledgeDocumentUploadTagSchema, + tag3: knowledgeDocumentUploadTagSchema, + tag4: knowledgeDocumentUploadTagSchema, + tag5: knowledgeDocumentUploadTagSchema, + tag6: knowledgeDocumentUploadTagSchema, + tag7: knowledgeDocumentUploadTagSchema, + processingOptions: z + .object({ + recipe: z.string().max(255, 'recipe cannot exceed 255 characters').optional(), + lang: z.string().max(35, 'lang cannot exceed 35 characters').optional(), + }) + .strict() + .optional(), + }) + .strict() + +export type KnowledgeDocumentUploadMetadata = z.output<typeof knowledgeDocumentUploadMetadataSchema> diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/lib/oauth/credential-service.ts similarity index 99% rename from apps/sim/app/api/auth/oauth/utils.ts rename to apps/sim/lib/oauth/credential-service.ts index b004e1d3148..e9b8bafcf85 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -20,13 +20,13 @@ import { parseTokenServiceAccountSecretBlob, type TokenServiceAccountSecretBlob, } from '@/lib/credentials/token-service-accounts/server' -import { refreshOAuthToken } from '@/lib/oauth' import { isInstagramProvider, shouldProactivelyRefreshInstagramToken } from '@/lib/oauth/instagram' import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider, PROACTIVE_REFRESH_THRESHOLD_DAYS, } from '@/lib/oauth/microsoft' +import { refreshOAuthToken } from '@/lib/oauth/oauth' import { extractSlackTeamId, fanOutSlackTokenChain, @@ -46,7 +46,7 @@ import { SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' -const logger = createLogger('OAuthUtilsAPI') +const logger = createLogger('OAuthCredentialService') export class ServiceAccountTokenError extends Error { constructor( diff --git a/apps/sim/lib/uploads/client/session-upload.ts b/apps/sim/lib/uploads/client/session-upload.ts index 244703a8dbe..55d1a7466f6 100644 --- a/apps/sim/lib/uploads/client/session-upload.ts +++ b/apps/sim/lib/uploads/client/session-upload.ts @@ -4,6 +4,10 @@ import { completeKnowledgeDocumentUploadContract, createKnowledgeDocumentUploadContract, createKnowledgeDocumentUploadPartUrlsContract, + type KnowledgeDocumentUploadMetadataBody, + type KnowledgeDocumentUploadPartUrl, + type KnowledgeDocumentUploadSummary, + type KnowledgeDocumentUploadTransfer, } from '@/lib/api/contracts/knowledge/upload-sessions' import { abortInternalFileUploadContract, @@ -13,11 +17,6 @@ import { createInternalFileUploadPartUrlsContract, type InternalFileUploadSession, } from '@/lib/api/contracts/upload-sessions' -import type { - V2KnowledgeDocumentSummary, - V2KnowledgeDocumentUploadMetadata, -} from '@/lib/api/contracts/v2/knowledge' -import type { V2UploadPartUrl, V2UploadTransfer } from '@/lib/api/contracts/v2/uploads' import type { UploadProgressEvent } from '@/lib/uploads/client/types' import { uploadFileSession } from '@/lib/uploads/client/upload-session' import { getFileContentType } from '@/lib/uploads/utils/file-utils' @@ -55,7 +54,7 @@ type InternalUploadResult<Purpose extends InternalUploadPurpose> = NonNullable< export type UploadInternalFileSessionParams = InternalUploadCommonParams & InternalUploadContext -interface UploadKnowledgeDocumentSessionParams extends V2KnowledgeDocumentUploadMetadata { +interface UploadKnowledgeDocumentSessionParams extends KnowledgeDocumentUploadMetadataBody { workspaceId: string knowledgeBaseId: string file: File @@ -65,10 +64,10 @@ interface UploadKnowledgeDocumentSessionParams extends V2KnowledgeDocumentUpload interface RunCreatedUploadParams<T> { file: File - transfer: V2UploadTransfer + transfer: KnowledgeDocumentUploadTransfer signal?: AbortSignal onProgress?: (event: UploadProgressEvent) => void - getPartUrls: (partNumbers: number[]) => Promise<V2UploadPartUrl[]> + getPartUrls: (partNumbers: number[]) => Promise<KnowledgeDocumentUploadPartUrl[]> complete: () => Promise<T> abort: () => Promise<void> } @@ -182,7 +181,7 @@ function internalUploadBody(params: UploadInternalFileSessionParams): CreateInte export async function uploadKnowledgeDocumentSession( params: UploadKnowledgeDocumentSessionParams -): Promise<V2KnowledgeDocumentSummary> { +): Promise<KnowledgeDocumentUploadSummary> { const { workspaceId, knowledgeBaseId, file, signal, onProgress, ...metadata } = params const created = await requestJson(createKnowledgeDocumentUploadContract, { params: { id: knowledgeBaseId }, diff --git a/apps/sim/lib/webhooks/deploy.test.ts b/apps/sim/lib/webhooks/deploy.test.ts index 4aa780ceeac..eb81920d0c6 100644 --- a/apps/sim/lib/webhooks/deploy.test.ts +++ b/apps/sim/lib/webhooks/deploy.test.ts @@ -41,7 +41,7 @@ const { mockRefreshAccessTokenIfNeeded: vi.fn(), mockFetchSlackTeamId: vi.fn(), })) -vi.mock('@/app/api/auth/oauth/utils', () => ({ +vi.mock('@/lib/oauth/credential-service', () => ({ getSlackBotCredential: mockGetSlackBotCredential, resolveOAuthAccountId: mockResolveOAuthAccountId, refreshAccessTokenIfNeeded: mockRefreshAccessTokenIfNeeded, diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 52e3e5244d2..7827315ec9e 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -6,6 +6,11 @@ import { generateShortId } from '@sim/utils/id' import { and, eq, inArray, isNull, or } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { getProviderIdFromServiceId } from '@/lib/oauth' +import { + getSlackBotCredential, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/lib/oauth/credential-service' import { WebhookPathClaimConflictError } from '@/lib/webhooks/path-claims' import { PendingWebhookVerificationTracker } from '@/lib/webhooks/pending-verification' import { @@ -27,11 +32,6 @@ import { isCanonicalPair, resolveActiveCanonicalValue, } from '@/lib/workflows/subblocks/visibility' -import { - getSlackBotCredential, - refreshAccessTokenIfNeeded, - resolveOAuthAccountId, -} from '@/app/api/auth/oauth/utils' import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' import { getTrigger, isTriggerValid } from '@/triggers' diff --git a/apps/sim/lib/webhooks/polling/utils.test.ts b/apps/sim/lib/webhooks/polling/utils.test.ts index ace42a66e9d..04dd995442e 100644 --- a/apps/sim/lib/webhooks/polling/utils.test.ts +++ b/apps/sim/lib/webhooks/polling/utils.test.ts @@ -27,20 +27,20 @@ vi.mock('drizzle-orm', () => { or: vi.fn(), } }) -vi.mock('@/app/api/auth/oauth/utils', () => ({ +vi.mock('@/lib/oauth/credential-service', () => ({ getOAuthToken: vi.fn(), refreshAccessTokenIfNeeded: vi.fn(), resolveOAuthAccountId: vi.fn(), })) vi.mock('@/triggers/constants', () => ({ MAX_CONSECUTIVE_FAILURES: 5 })) -import type { WebhookRecord } from '@/lib/webhooks/polling/types' -import { resolveOAuthCredential, updateWebhookProviderConfig } from '@/lib/webhooks/polling/utils' import { getOAuthToken, refreshAccessTokenIfNeeded, resolveOAuthAccountId, -} from '@/app/api/auth/oauth/utils' +} from '@/lib/oauth/credential-service' +import type { WebhookRecord } from '@/lib/webhooks/polling/types' +import { resolveOAuthCredential, updateWebhookProviderConfig } from '@/lib/webhooks/polling/utils' afterAll(resetDbChainMock) diff --git a/apps/sim/lib/webhooks/polling/utils.ts b/apps/sim/lib/webhooks/polling/utils.ts index 8064d8ab928..df082862cf8 100644 --- a/apps/sim/lib/webhooks/polling/utils.ts +++ b/apps/sim/lib/webhooks/polling/utils.ts @@ -2,14 +2,14 @@ import { db } from '@sim/db' import { account, webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' import type { Logger } from '@sim/logger' import { and, eq, isNull, ne, or, sql } from 'drizzle-orm' -import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate' -import type { WebhookRecord, WorkflowRecord } from '@/lib/webhooks/polling/types' import { getOAuthToken, refreshAccessTokenIfNeeded, resolveOAuthAccountId, resolveServiceAccountToken, -} from '@/app/api/auth/oauth/utils' +} from '@/lib/oauth/credential-service' +import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate' +import type { WebhookRecord, WorkflowRecord } from '@/lib/webhooks/polling/types' import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants' /** Concurrency limit for parallel webhook processing. Standardized across all providers. */ diff --git a/apps/sim/lib/webhooks/provider-subscription-utils.ts b/apps/sim/lib/webhooks/provider-subscription-utils.ts index e52e1eeefa1..b6f02f880ff 100644 --- a/apps/sim/lib/webhooks/provider-subscription-utils.ts +++ b/apps/sim/lib/webhooks/provider-subscription-utils.ts @@ -3,7 +3,7 @@ import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { getBaseUrl } from '@/lib/core/utils/urls' -import { resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { resolveOAuthAccountId } from '@/lib/oauth/credential-service' const logger = createLogger('WebhookProviderSubscriptions') diff --git a/apps/sim/lib/webhooks/providers/airtable.ts b/apps/sim/lib/webhooks/providers/airtable.ts index fd0463b4b95..99b77074cf6 100644 --- a/apps/sim/lib/webhooks/providers/airtable.ts +++ b/apps/sim/lib/webhooks/providers/airtable.ts @@ -4,6 +4,11 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { validateAirtableId } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' +import { + getOAuthToken, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/lib/oauth/credential-service' import { getCredentialOwner, getNotificationUrl, @@ -16,11 +21,6 @@ import type { SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { - getOAuthToken, - refreshAccessTokenIfNeeded, - resolveOAuthAccountId, -} from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookProvider:Airtable') diff --git a/apps/sim/lib/webhooks/providers/attio.ts b/apps/sim/lib/webhooks/providers/attio.ts index 1e607f786f0..693e7e1f5e6 100644 --- a/apps/sim/lib/webhooks/providers/attio.ts +++ b/apps/sim/lib/webhooks/providers/attio.ts @@ -4,6 +4,7 @@ import { hmacSha256Hex } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' import { NextResponse } from 'next/server' import { getBaseUrl } from '@/lib/core/utils/urls' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { AuthContext, @@ -15,7 +16,6 @@ import type { SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookProvider:Attio') diff --git a/apps/sim/lib/webhooks/providers/clickup.test.ts b/apps/sim/lib/webhooks/providers/clickup.test.ts index 00247cd12e4..af6ab66ac90 100644 --- a/apps/sim/lib/webhooks/providers/clickup.test.ts +++ b/apps/sim/lib/webhooks/providers/clickup.test.ts @@ -17,7 +17,7 @@ vi.mock('@/lib/webhooks/provider-subscription-utils', () => ({ getCredentialOwner: mockGetCredentialOwner, })) -vi.mock('@/app/api/auth/oauth/utils', () => ({ +vi.mock('@/lib/oauth/credential-service', () => ({ refreshAccessTokenIfNeeded: mockRefreshAccessTokenIfNeeded, })) diff --git a/apps/sim/lib/webhooks/providers/clickup.ts b/apps/sim/lib/webhooks/providers/clickup.ts index 63fa5c420ac..2ca160112a6 100644 --- a/apps/sim/lib/webhooks/providers/clickup.ts +++ b/apps/sim/lib/webhooks/providers/clickup.ts @@ -3,6 +3,7 @@ import { safeCompare } from '@sim/security/compare' import { hmacSha256Hex } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' import { NextResponse } from 'next/server' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { getCredentialOwner, getNotificationUrl, @@ -18,7 +19,6 @@ import type { WebhookProviderHandler, } from '@/lib/webhooks/providers/types' import { createHmacVerifier } from '@/lib/webhooks/providers/utils' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' const logger = createLogger('WebhookProvider:ClickUp') diff --git a/apps/sim/lib/webhooks/providers/gmail.ts b/apps/sim/lib/webhooks/providers/gmail.ts index ca5e1094bb4..4abc2caa6fb 100644 --- a/apps/sim/lib/webhooks/providers/gmail.ts +++ b/apps/sim/lib/webhooks/providers/gmail.ts @@ -2,13 +2,13 @@ import { db } from '@sim/db' import { account, webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/lib/oauth/credential-service' import type { FormatInputContext, FormatInputResult, PollingConfigContext, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookProvider:Gmail') diff --git a/apps/sim/lib/webhooks/providers/microsoft-teams.test.ts b/apps/sim/lib/webhooks/providers/microsoft-teams.test.ts index d209d33695a..93bf5642662 100644 --- a/apps/sim/lib/webhooks/providers/microsoft-teams.test.ts +++ b/apps/sim/lib/webhooks/providers/microsoft-teams.test.ts @@ -7,7 +7,7 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) -vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) +vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock) import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams' diff --git a/apps/sim/lib/webhooks/providers/microsoft-teams.ts b/apps/sim/lib/webhooks/providers/microsoft-teams.ts index eabb0cb656c..ccaa56eb12c 100644 --- a/apps/sim/lib/webhooks/providers/microsoft-teams.ts +++ b/apps/sim/lib/webhooks/providers/microsoft-teams.ts @@ -14,6 +14,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { sanitizeUrlForLog } from '@/lib/core/utils/logging' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/lib/oauth/credential-service' import { getCredentialOwner, getNotificationUrl, @@ -29,7 +30,6 @@ import type { SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookProvider:MicrosoftTeams') diff --git a/apps/sim/lib/webhooks/providers/monday.ts b/apps/sim/lib/webhooks/providers/monday.ts index 87c1f49994a..b0ad31267d2 100644 --- a/apps/sim/lib/webhooks/providers/monday.ts +++ b/apps/sim/lib/webhooks/providers/monday.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { NextResponse } from 'next/server' import { validateMondayNumericId } from '@/lib/core/security/input-validation' +import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { getCredentialOwner, getNotificationUrl, @@ -15,7 +16,6 @@ import type { SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookProvider:Monday') diff --git a/apps/sim/lib/webhooks/providers/outlook.ts b/apps/sim/lib/webhooks/providers/outlook.ts index f9d6727fb98..a6f2fa3d80e 100644 --- a/apps/sim/lib/webhooks/providers/outlook.ts +++ b/apps/sim/lib/webhooks/providers/outlook.ts @@ -2,13 +2,13 @@ import { db } from '@sim/db' import { account, webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/lib/oauth/credential-service' import type { FormatInputContext, FormatInputResult, PollingConfigContext, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookProvider:Outlook') diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index fcd88767072..9830f7a5a9a 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -11,6 +11,11 @@ import { secureFetchWithPinnedIP, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' +import { + getSlackBotCredential, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/lib/oauth/credential-service' import type { AuthContext, EventFilterContext, @@ -18,11 +23,6 @@ import type { FormatInputResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { - getSlackBotCredential, - refreshAccessTokenIfNeeded, - resolveOAuthAccountId, -} from '@/app/api/auth/oauth/utils' import { type SlackEventFilter, slackEventSupportsFilter } from '@/triggers/slack/shared' const logger = createLogger('WebhookProvider:Slack') diff --git a/apps/sim/lib/webhooks/providers/webflow.ts b/apps/sim/lib/webhooks/providers/webflow.ts index 7494ae39568..25fabfc36d4 100644 --- a/apps/sim/lib/webhooks/providers/webflow.ts +++ b/apps/sim/lib/webhooks/providers/webflow.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' +import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, @@ -11,7 +12,6 @@ import type { SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookProvider:Webflow') diff --git a/apps/sim/lib/webhooks/providers/zoho-desk.test.ts b/apps/sim/lib/webhooks/providers/zoho-desk.test.ts index 3c516cfdbab..fee16ac6612 100644 --- a/apps/sim/lib/webhooks/providers/zoho-desk.test.ts +++ b/apps/sim/lib/webhooks/providers/zoho-desk.test.ts @@ -3,7 +3,7 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest' -vi.mock('@/app/api/auth/oauth/utils', () => ({ +vi.mock('@/lib/oauth/credential-service', () => ({ refreshAccessTokenIfNeeded: vi.fn(), })) @@ -12,13 +12,13 @@ vi.mock('@/lib/webhooks/provider-subscription-utils', () => ({ getNotificationUrl: vi.fn(() => 'https://example.com/api/webhooks/trigger/path'), })) +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { matchesPendingWebhookVerificationProbe, requiresPendingWebhookVerification, } from '@/lib/webhooks/pending-verification' import { getCredentialOwner } from '@/lib/webhooks/provider-subscription-utils' import { mapZohoWebhookError, zohoDeskHandler } from '@/lib/webhooks/providers/zoho-desk' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' function errorStatus(err: unknown): number | undefined { return (err as { status?: number })?.status diff --git a/apps/sim/lib/webhooks/providers/zoho-desk.ts b/apps/sim/lib/webhooks/providers/zoho-desk.ts index a70935d4385..719038fe31b 100644 --- a/apps/sim/lib/webhooks/providers/zoho-desk.ts +++ b/apps/sim/lib/webhooks/providers/zoho-desk.ts @@ -6,6 +6,7 @@ import { truncate } from '@sim/utils/string' import { eq } from 'drizzle-orm' import * as jose from 'jose' import { NextResponse } from 'next/server' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' import { getCredentialOwner, getNotificationUrl } from '@/lib/webhooks/provider-subscription-utils' import type { AuthContext, @@ -16,7 +17,6 @@ import type { SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { isZohoHost } from '@/tools/zoho_desk/host-allowlist' import { withDerivedContentText } from '@/tools/zoho_desk/utils' diff --git a/apps/sim/lib/workspaces/application/workspace-context.test.ts b/apps/sim/lib/workspaces/application/workspace-context.test.ts index c448ef5f91b..db644e1fb84 100644 --- a/apps/sim/lib/workspaces/application/workspace-context.test.ts +++ b/apps/sim/lib/workspaces/application/workspace-context.test.ts @@ -3,7 +3,10 @@ */ import { dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it } from 'vitest' -import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { + loadActiveWorkspaceApplicationContext, + loadWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' describe('loadActiveWorkspaceApplicationContext', () => { beforeEach(() => { @@ -35,6 +38,26 @@ describe('loadActiveWorkspaceApplicationContext', () => { await expect(loadActiveWorkspaceApplicationContext('workspace-1')).resolves.toBeNull() }) + it('can explicitly include archived workspaces', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'workspace-1', + organizationId: null, + allowPersonalApiKeys: false, + billedAccountUserId: 'billing-owner-1', + }, + ]) + + await expect( + loadWorkspaceApplicationContext('workspace-1', { includeArchived: true }) + ).resolves.toEqual({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: false, + billedAccountUserId: 'billing-owner-1', + }) + }) + it('propagates database failures', async () => { const failure = new Error('database unavailable') dbChainMockFns.limit.mockRejectedValueOnce(failure) diff --git a/apps/sim/lib/workspaces/application/workspace-context.ts b/apps/sim/lib/workspaces/application/workspace-context.ts index fb01e591ac0..af4194a992b 100644 --- a/apps/sim/lib/workspaces/application/workspace-context.ts +++ b/apps/sim/lib/workspaces/application/workspace-context.ts @@ -7,9 +7,10 @@ export interface ActiveWorkspaceApplicationContext extends WorkspaceAuthorizatio billedAccountUserId: string } -/** Loads the active canonical workspace state required by application authorization. */ -export async function loadActiveWorkspaceApplicationContext( - workspaceId: string +/** Loads canonical workspace state required by application authorization. */ +export async function loadWorkspaceApplicationContext( + workspaceId: string, + options: { includeArchived?: boolean } = {} ): Promise<ActiveWorkspaceApplicationContext | null> { const [row] = await db .select({ @@ -19,7 +20,12 @@ export async function loadActiveWorkspaceApplicationContext( billedAccountUserId: workspace.billedAccountUserId, }) .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .where( + and( + eq(workspace.id, workspaceId), + options.includeArchived ? undefined : isNull(workspace.archivedAt) + ) + ) .limit(1) if (!row) return null @@ -30,3 +36,10 @@ export async function loadActiveWorkspaceApplicationContext( billedAccountUserId: row.billedAccountUserId, } } + +/** Loads active canonical workspace state required by application authorization. */ +export async function loadActiveWorkspaceApplicationContext( + workspaceId: string +): Promise<ActiveWorkspaceApplicationContext | null> { + return loadWorkspaceApplicationContext(workspaceId) +} diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index a0e4f2d90cc..6972002f77d 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -48,6 +48,7 @@ const { mockListCustomTools, mockMarkWorkspaceFileSecretProvenanceUnknown, mockGetCustomToolByIdOrTitle, + mockGenerateInternalDelegationToken, mockGenerateInternalToken, mockResolveWorkspaceFileReference, } = vi.hoisted(() => ({ @@ -62,6 +63,7 @@ const { mockListCustomTools: vi.fn(), mockMarkWorkspaceFileSecretProvenanceUnknown: vi.fn(), mockGetCustomToolByIdOrTitle: vi.fn(), + mockGenerateInternalDelegationToken: vi.fn(), mockGenerateInternalToken: vi.fn(), mockResolveWorkspaceFileReference: vi.fn(), })) @@ -76,6 +78,8 @@ vi.mock('@/lib/api-key/byok', () => ({ })) vi.mock('@/lib/auth/internal', () => ({ + generateInternalDelegationToken: (...args: unknown[]) => + mockGenerateInternalDelegationToken(...args), generateInternalToken: (...args: unknown[]) => mockGenerateInternalToken(...args), })) @@ -209,6 +213,25 @@ const mockRegistryTools: Record<string, any> = { result: { type: 'json', description: 'Execution result' }, }, }, + test_executor_delegation: { + id: 'test_executor_delegation', + name: 'Executor Delegation Test', + description: 'Exercises scoped internal executor authentication', + version: '1.0.0', + params: {}, + request: { + url: '/api/knowledge/test', + method: 'POST', + internalAuth: 'executor_delegation', + headers: () => ({ 'Content-Type': 'application/json' }), + body: () => ({}), + }, + transformResponse: async (response: Response) => ({ + success: response.ok, + output: await response.json(), + }), + outputs: {}, + }, gmail_read: { id: 'gmail_read', name: 'Gmail Read', @@ -423,6 +446,7 @@ vi.spyOn(getQueryClientModule, 'getQueryClient').mockImplementation(createMockQu beforeEach(() => { vi.spyOn(getQueryClientModule, 'getQueryClient').mockImplementation(createMockQueryClient) + mockGenerateInternalDelegationToken.mockResolvedValue('executor-token') // Suites below call vi.resetAllMocks(), which wipes the shared env/urls mock // implementations — restore their defaults and re-pin the base URL each test. resetEnvMock() @@ -707,6 +731,58 @@ describe('executeTool Function', () => { expect(new Headers(request?.headers).get('authorization')).toBe('Bearer mothership-token') }) + it('uses server-authored executor identity for protected internal tools', async () => { + global.fetch = Object.assign( + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ), + { preconnect: vi.fn() } + ) as typeof fetch + + const executionContext = createToolExecutionContext({ + userId: 'trusted-user', + workflowId: 'trusted-workflow', + executionId: 'trusted-execution', + }) + await executeTool( + 'test_executor_delegation', + { + _context: { + userId: 'model-user', + workflowId: 'model-workflow', + }, + }, + { executionContext } + ) + + expect(mockGenerateInternalDelegationToken).toHaveBeenCalledWith({ + subjectUserId: 'trusted-user', + workflowId: 'trusted-workflow', + executionId: 'trusted-execution', + }) + const request = vi.mocked(global.fetch).mock.calls[0]?.[1] + expect(new Headers(request?.headers).get('authorization')).toBe('Bearer executor-token') + }) + + it('rejects protected internal tools without trusted executor scope before transport', async () => { + const result = await executeTool('test_executor_delegation', { + _context: { + userId: 'model-user', + workflowId: 'model-workflow', + }, + }) + + expect(result).toMatchObject({ + success: false, + error: 'Executor delegation requires a trusted workflow execution context', + }) + expect(mockGenerateInternalDelegationToken).not.toHaveBeenCalled() + expect(global.fetch).not.toHaveBeenCalled() + }) + it('imports File Get Content provenance without exposing private transport metadata', async () => { const registry = new ResolvedSecretTraceRegistry([], { userId: 'user-1', diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 3575ccad894..dbdf118f084 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -5,6 +5,7 @@ import { isPlainRecord } from '@sim/utils/object' import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' import { getBYOKKey } from '@/lib/api-key/byok' import { + type GenerateInternalDelegationTokenInput, generateInternalToken, type InternalSandboxProfile, type InternalTokenClaims, @@ -62,6 +63,7 @@ import { assertPermissionsAllowed } from '@/ee/access-control/utils/permission-c import { isCustomTool, isMcpTool } from '@/executor/constants' import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver' import type { ExecutionContext, UserFile } from '@/executor/types' +import { buildExecutorDelegationHeaders } from '@/executor/utils/http' import { resolveEnvVarReferences } from '@/executor/utils/reference-validation' import { projectResolvedSecretDiagnosticContent, @@ -179,6 +181,28 @@ function resolveToolScope( } } +function resolveInternalExecutorDelegation( + tool: ToolConfig, + executionContext: ExecutionContext | undefined, + supplied: GenerateInternalDelegationTokenInput | undefined +): GenerateInternalDelegationTokenInput | undefined { + if (tool.request.internalAuth !== 'executor_delegation') return undefined + if (supplied) { + if (!supplied.subjectUserId || !supplied.workflowId) { + throw new Error('Executor delegation requires an authenticated user and workflow') + } + return supplied + } + if (!executionContext?.userId || !executionContext.workflowId) { + throw new Error('Executor delegation requires a trusted workflow execution context') + } + return { + subjectUserId: executionContext.userId, + workflowId: executionContext.workflowId, + ...(executionContext.executionId ? { executionId: executionContext.executionId } : {}), + } +} + function toUserFileFromWorkspaceRecord(record: { id: string name: string @@ -1147,6 +1171,8 @@ export interface ExecuteToolOptions { resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry /** Trusted base image claim for an internal Function execution. */ internalSandboxProfile?: InternalSandboxProfile + /** Trusted executor identity supplied by a server adapter without entering model parameters. */ + internalExecutorDelegation?: GenerateInternalDelegationTokenInput } interface PrivateToolResponseMetadataResult { @@ -1518,6 +1544,7 @@ async function executeToolImplementation( signal, resolvedSecretTraceRegistry: explicitResolvedSecretTraceRegistry, internalSandboxProfile, + internalExecutorDelegation: suppliedInternalExecutorDelegation, } = options const resolvedSecretTraceRegistry = explicitResolvedSecretTraceRegistry ?? executionContext?.resolvedSecretTraceRegistry @@ -1649,6 +1676,12 @@ async function executeToolImplementation( throw new Error(`Tool not found: ${toolId}`) } + const internalExecutorDelegation = resolveInternalExecutorDelegation( + tool, + executionContext, + suppliedInternalExecutorDelegation + ) + await normalizeCopilotFileParams(tool, contextParams, scope) normalizeCopilotCredentialParams(contextParams) enforceCopilotCredentialSelection(toolId, tool, contextParams, scope) @@ -1938,7 +1971,8 @@ async function executeToolImplementation( privateToolMetadataType, privateToolMetadataPolicy?.incomplete, resolvedSecretTraceRegistry, - internalSandboxProfile + internalSandboxProfile, + internalExecutorDelegation ), { requestId, @@ -1966,7 +2000,8 @@ async function executeToolImplementation( privateToolMetadataType, privateToolMetadataPolicy?.incomplete, resolvedSecretTraceRegistry, - internalSandboxProfile + internalSandboxProfile, + internalExecutorDelegation ) }, } @@ -1979,7 +2014,8 @@ async function executeToolImplementation( privateToolMetadataType, privateToolMetadataPolicy?.incomplete, resolvedSecretTraceRegistry, - internalSandboxProfile + internalSandboxProfile, + internalExecutorDelegation ) // Apply post-processing if available and not skipped @@ -2213,14 +2249,19 @@ async function addInternalAuthIfNeeded( requestId: string, context: string, userId?: string, - claims?: InternalTokenClaims + claims?: InternalTokenClaims, + executorDelegation?: GenerateInternalDelegationTokenInput ): Promise<void> { if (typeof window === 'undefined') { if (isInternalRoute) { try { - const internalToken = claims - ? await generateInternalToken(userId, claims) - : await generateInternalToken(userId) + const internalToken = executorDelegation + ? (await buildExecutorDelegationHeaders(executorDelegation)).Authorization.slice( + 'Bearer '.length + ) + : claims + ? await generateInternalToken(userId, claims) + : await generateInternalToken(userId) if (headers instanceof Headers) { headers.set('Authorization', `Bearer ${internalToken}`) } else { @@ -2229,6 +2270,7 @@ async function addInternalAuthIfNeeded( logger.info(`[${requestId}] Added internal auth token for ${context}`) } catch (error) { logger.error(`[${requestId}] Failed to generate internal token for ${context}:`, error) + if (executorDelegation) throw error } } else { logger.info(`[${requestId}] Skipping internal auth token for external URL: ${context}`) @@ -2306,7 +2348,8 @@ async function executeToolRequest( privateToolMetadataType?: PrivateToolMetadataType, privateToolMetadataIncomplete: 'reject' | 'propagate' = 'reject', resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry, - internalSandboxProfile?: InternalSandboxProfile + internalSandboxProfile?: InternalSandboxProfile, + internalExecutorDelegation?: GenerateInternalDelegationTokenInput ): Promise<ToolResponse> { const requestId = generateRequestId() const structuralOnlyToolLogs = @@ -2368,7 +2411,8 @@ async function executeToolRequest( requestId, toolId, params._context?.userId, - internalSandboxProfile ? { sandboxProfile: internalSandboxProfile } : undefined + internalSandboxProfile ? { sandboxProfile: internalSandboxProfile } : undefined, + internalExecutorDelegation ) if (isInternalRoute && params._context?.billingAttribution) { headers.set( diff --git a/apps/sim/tools/knowledge/create_document.ts b/apps/sim/tools/knowledge/create_document.ts index 002cfe3e95a..2046f738f43 100644 --- a/apps/sim/tools/knowledge/create_document.ts +++ b/apps/sim/tools/knowledge/create_document.ts @@ -48,6 +48,7 @@ export const knowledgeCreateDocumentTool: ToolConfig<any, KnowledgeCreateDocumen }, request: { + internalAuth: 'executor_delegation', url: (params) => `/api/knowledge/${params.knowledgeBaseId}/documents`, method: 'POST', secretProvenance: { diff --git a/apps/sim/tools/knowledge/delete_chunk.ts b/apps/sim/tools/knowledge/delete_chunk.ts index 3bc759af63f..1ddcef291e1 100644 --- a/apps/sim/tools/knowledge/delete_chunk.ts +++ b/apps/sim/tools/knowledge/delete_chunk.ts @@ -29,6 +29,7 @@ export const knowledgeDeleteChunkTool: ToolConfig<any, KnowledgeDeleteChunkRespo }, request: { + internalAuth: 'executor_delegation', url: (params) => `/api/knowledge/${params.knowledgeBaseId}/documents/${params.documentId}/chunks/${params.chunkId}`, method: 'DELETE', diff --git a/apps/sim/tools/knowledge/delete_document.ts b/apps/sim/tools/knowledge/delete_document.ts index 39493e38283..e48891f1892 100644 --- a/apps/sim/tools/knowledge/delete_document.ts +++ b/apps/sim/tools/knowledge/delete_document.ts @@ -23,6 +23,7 @@ export const knowledgeDeleteDocumentTool: ToolConfig<any, KnowledgeDeleteDocumen }, request: { + internalAuth: 'executor_delegation', url: (params) => `/api/knowledge/${params.knowledgeBaseId}/documents/${params.documentId}`, method: 'DELETE', headers: () => ({ diff --git a/apps/sim/tools/knowledge/get_connector.ts b/apps/sim/tools/knowledge/get_connector.ts index 9ae1e03e548..bfe919128cb 100644 --- a/apps/sim/tools/knowledge/get_connector.ts +++ b/apps/sim/tools/knowledge/get_connector.ts @@ -24,6 +24,7 @@ export const knowledgeGetConnectorTool: ToolConfig<any, KnowledgeGetConnectorRes }, request: { + internalAuth: 'executor_delegation', url: (params) => `/api/knowledge/${params.knowledgeBaseId}/connectors/${params.connectorId}`, method: 'GET', headers: () => ({ diff --git a/apps/sim/tools/knowledge/get_document.ts b/apps/sim/tools/knowledge/get_document.ts index 2ac840ca032..2ddc3bf3efe 100644 --- a/apps/sim/tools/knowledge/get_document.ts +++ b/apps/sim/tools/knowledge/get_document.ts @@ -24,6 +24,7 @@ export const knowledgeGetDocumentTool: ToolConfig<any, KnowledgeGetDocumentRespo }, request: { + internalAuth: 'executor_delegation', url: (params) => `/api/knowledge/${params.knowledgeBaseId}/documents/${params.documentId}`, method: 'GET', secretProvenance: { response: { incomplete: 'reject' } }, diff --git a/apps/sim/tools/knowledge/list_chunks.ts b/apps/sim/tools/knowledge/list_chunks.ts index 7198b63fa6b..2b0b019a75d 100644 --- a/apps/sim/tools/knowledge/list_chunks.ts +++ b/apps/sim/tools/knowledge/list_chunks.ts @@ -48,6 +48,7 @@ export const knowledgeListChunksTool: ToolConfig<any, KnowledgeListChunksRespons }, request: { + internalAuth: 'executor_delegation', url: (params) => { const queryParams = new URLSearchParams() if (params.search) queryParams.set('search', params.search) diff --git a/apps/sim/tools/knowledge/list_connectors.ts b/apps/sim/tools/knowledge/list_connectors.ts index 5acf7081e4a..155ce61f416 100644 --- a/apps/sim/tools/knowledge/list_connectors.ts +++ b/apps/sim/tools/knowledge/list_connectors.ts @@ -18,6 +18,7 @@ export const knowledgeListConnectorsTool: ToolConfig<any, KnowledgeListConnector }, request: { + internalAuth: 'executor_delegation', url: (params) => `/api/knowledge/${params.knowledgeBaseId}/connectors`, method: 'GET', headers: () => ({ diff --git a/apps/sim/tools/knowledge/list_documents.ts b/apps/sim/tools/knowledge/list_documents.ts index 6bf491a6521..cf300122b82 100644 --- a/apps/sim/tools/knowledge/list_documents.ts +++ b/apps/sim/tools/knowledge/list_documents.ts @@ -41,6 +41,7 @@ export const knowledgeListDocumentsTool: ToolConfig<any, KnowledgeListDocumentsR }, request: { + internalAuth: 'executor_delegation', url: (params) => { const queryParams = new URLSearchParams() if (params.search) queryParams.set('search', params.search) diff --git a/apps/sim/tools/knowledge/list_tags.ts b/apps/sim/tools/knowledge/list_tags.ts index fbe95a6a2bc..7a5bb95ab21 100644 --- a/apps/sim/tools/knowledge/list_tags.ts +++ b/apps/sim/tools/knowledge/list_tags.ts @@ -17,6 +17,7 @@ export const knowledgeListTagsTool: ToolConfig<any, KnowledgeListTagsResponse> = }, request: { + internalAuth: 'executor_delegation', url: (params) => `/api/knowledge/${params.knowledgeBaseId}/tag-definitions`, method: 'GET', headers: () => ({ diff --git a/apps/sim/tools/knowledge/search.ts b/apps/sim/tools/knowledge/search.ts index ebc8aed6944..f5ea1022622 100644 --- a/apps/sim/tools/knowledge/search.ts +++ b/apps/sim/tools/knowledge/search.ts @@ -85,6 +85,7 @@ export const knowledgeSearchTool: ToolConfig<any, KnowledgeSearchResponse> = { }, request: { + internalAuth: 'executor_delegation', url: () => '/api/knowledge/search', method: 'POST', modelInput: { diff --git a/apps/sim/tools/knowledge/trigger_sync.ts b/apps/sim/tools/knowledge/trigger_sync.ts index 127c37a0c1a..5d584e980ae 100644 --- a/apps/sim/tools/knowledge/trigger_sync.ts +++ b/apps/sim/tools/knowledge/trigger_sync.ts @@ -23,6 +23,7 @@ export const knowledgeTriggerSyncTool: ToolConfig<any, KnowledgeTriggerSyncRespo }, request: { + internalAuth: 'executor_delegation', url: (params) => `/api/knowledge/${params.knowledgeBaseId}/connectors/${params.connectorId}/sync`, method: 'POST', diff --git a/apps/sim/tools/knowledge/update_chunk.ts b/apps/sim/tools/knowledge/update_chunk.ts index e0de0164596..d4b5345464c 100644 --- a/apps/sim/tools/knowledge/update_chunk.ts +++ b/apps/sim/tools/knowledge/update_chunk.ts @@ -41,6 +41,7 @@ export const knowledgeUpdateChunkTool: ToolConfig<any, KnowledgeUpdateChunkRespo }, request: { + internalAuth: 'executor_delegation', url: (params) => `/api/knowledge/${params.knowledgeBaseId}/documents/${params.documentId}/chunks/${params.chunkId}`, method: 'PUT', diff --git a/apps/sim/tools/knowledge/upload_chunk.ts b/apps/sim/tools/knowledge/upload_chunk.ts index 85cf9ba79aa..9887778f78e 100644 --- a/apps/sim/tools/knowledge/upload_chunk.ts +++ b/apps/sim/tools/knowledge/upload_chunk.ts @@ -29,6 +29,7 @@ export const knowledgeUploadChunkTool: ToolConfig<any, KnowledgeUploadChunkRespo }, request: { + internalAuth: 'executor_delegation', url: (params) => `/api/knowledge/${params.knowledgeBaseId}/documents/${params.documentId}/chunks`, method: 'POST', diff --git a/apps/sim/tools/knowledge/upsert_document.ts b/apps/sim/tools/knowledge/upsert_document.ts index 2cfec98070e..1c7f8edd8da 100644 --- a/apps/sim/tools/knowledge/upsert_document.ts +++ b/apps/sim/tools/knowledge/upsert_document.ts @@ -60,6 +60,7 @@ export const knowledgeUpsertDocumentTool: ToolConfig< }, request: { + internalAuth: 'executor_delegation', url: (params) => `/api/knowledge/${params.knowledgeBaseId}/documents/upsert`, method: 'POST', secretProvenance: { diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index fc2e25436c7..bffcb82c6f0 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -177,6 +177,8 @@ export interface ToolConfig<P = any, R = any> { method: HttpMethod | ((params: P) => HttpMethod) headers: (params: P) => Record<string, string> body?: (params: P) => Record<string, any> | string | FormData | undefined + /** Selects the signed, workflow-scoped identity required by protected internal routes. */ + internalAuth?: 'executor_delegation' /** Defines the exact request fields that may become model-visible. */ modelInput?: | { From 8b3b41b8fa31f2a1f1fb546e336603d1ffc6fbed Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sun, 9 Aug 2026 22:18:02 -0700 Subject: [PATCH 119/159] refactor: enforce Copilot table application boundary (#6453) * refactor: enforce copilot table application boundary * fix(tables): finish application boundary migration * fix(tables): restore scoped copilot imports * fix(tables): compose copilot commands atomically * fix(tables): preserve workflow group scheduling * fix(tables): complete fixed copilot composition * fix(tables): reject enrichment output mutation * fix(tables): complete authorized application boundary --- .../app/api/table/[tableId]/exports/route.ts | 58 +- .../api/table/[tableId]/groups/route.test.ts | 250 +-- .../app/api/table/[tableId]/groups/route.ts | 257 +-- .../exports/[exportId]/download/route.ts | 66 +- .../app/api/table/exports/[exportId]/route.ts | 93 +- .../imports/[importId]/complete/route.ts | 70 +- .../table/imports/[importId]/parts/route.ts | 60 +- .../app/api/table/imports/[importId]/route.ts | 102 +- apps/sim/app/api/table/imports/route.ts | 46 +- .../api/table/table-transfer-routes.test.ts | 113 ++ .../v2/tables/[tableId]/exports/route.test.ts | 3 + .../api/v2/tables/[tableId]/exports/route.ts | 3 +- .../api/v2/tables/exports/[exportId]/route.ts | 5 +- .../imports/[importId]/complete/route.test.ts | 3 + .../imports/[importId]/complete/route.ts | 3 +- .../api/v2/tables/imports/[importId]/route.ts | 5 +- .../app/api/v2/tables/imports/route.test.ts | 3 + apps/sim/app/api/v2/tables/imports/route.ts | 3 +- apps/sim/app/api/v2/tables/presenters.test.ts | 82 + apps/sim/app/api/v2/tables/presenters.ts | 19 + apps/sim/lib/api/contracts/table-transfers.ts | 4 +- .../execute-table-use-case.test.ts | 96 + .../application/execute-table-use-case.ts | 35 +- .../execute-workflow-use-case.test.ts | 70 + .../application/execute-workflow-use-case.ts | 35 + .../application/table-commands.test.ts | 183 ++ .../lib/copilot/application/table-commands.ts | 144 ++ .../lib/copilot/auth/table-delegation.test.ts | 44 - apps/sim/lib/copilot/auth/table-delegation.ts | 22 +- .../lib/copilot/request/tools/tables.test.ts | 526 ++---- apps/sim/lib/copilot/request/tools/tables.ts | 92 +- .../tools/server/table/user-table.test.ts | 485 ++++-- .../copilot/tools/server/table/user-table.ts | 1543 +++-------------- apps/sim/lib/table/api/index.ts | 5 +- apps/sim/lib/table/api/route-policies.test.ts | 190 ++ apps/sim/lib/table/api/route-policies.ts | 15 +- .../table/application/authorization.test.ts | 33 +- .../lib/table/application/authorization.ts | 7 +- .../sim/lib/table/application/columns.test.ts | 189 ++ apps/sim/lib/table/application/columns.ts | 60 + .../application/copilot-bulk-rows.test.ts | 242 +++ .../table/application/copilot-bulk-rows.ts | 424 +++++ .../copilot-table-lifecycle.test.ts | 196 +++ .../application/copilot-table-lifecycle.ts | 85 + .../sim/lib/table/application/exports.test.ts | 167 ++ apps/sim/lib/table/application/exports.ts | 14 +- apps/sim/lib/table/application/groups.test.ts | 584 +++++++ apps/sim/lib/table/application/groups.ts | 848 ++++++++- .../sim/lib/table/application/imports.test.ts | 419 +++++ apps/sim/lib/table/application/imports.ts | 67 +- .../lib/table/application/operations.test.ts | 37 +- apps/sim/lib/table/application/operations.ts | 73 +- apps/sim/lib/table/application/rows.test.ts | 237 +++ apps/sim/lib/table/application/rows.ts | 197 ++- .../workspace-file-imports.test.ts | 355 ++++ .../application/workspace-file-imports.ts | 548 ++++++ .../orchestration/import-resource.test.ts | 79 +- .../table/orchestration/import-resource.ts | 80 +- apps/sim/lib/table/types.ts | 5 + apps/sim/lib/table/workflow-groups/service.ts | 152 +- .../uploads/upload-session/service.test.ts | 100 +- .../sim/lib/uploads/upload-session/service.ts | 89 +- .../resolve-workflow-outputs.test.ts | 122 ++ .../application/resolve-workflow-outputs.ts | 57 + 64 files changed, 7156 insertions(+), 3043 deletions(-) create mode 100644 apps/sim/app/api/table/table-transfer-routes.test.ts create mode 100644 apps/sim/app/api/v2/tables/presenters.test.ts create mode 100644 apps/sim/app/api/v2/tables/presenters.ts create mode 100644 apps/sim/lib/copilot/application/execute-table-use-case.test.ts create mode 100644 apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts create mode 100644 apps/sim/lib/copilot/application/execute-workflow-use-case.ts create mode 100644 apps/sim/lib/copilot/application/table-commands.test.ts create mode 100644 apps/sim/lib/copilot/application/table-commands.ts delete mode 100644 apps/sim/lib/copilot/auth/table-delegation.test.ts create mode 100644 apps/sim/lib/table/api/route-policies.test.ts create mode 100644 apps/sim/lib/table/application/columns.test.ts create mode 100644 apps/sim/lib/table/application/copilot-bulk-rows.test.ts create mode 100644 apps/sim/lib/table/application/copilot-bulk-rows.ts create mode 100644 apps/sim/lib/table/application/copilot-table-lifecycle.test.ts create mode 100644 apps/sim/lib/table/application/copilot-table-lifecycle.ts create mode 100644 apps/sim/lib/table/application/exports.test.ts create mode 100644 apps/sim/lib/table/application/groups.test.ts create mode 100644 apps/sim/lib/table/application/imports.test.ts create mode 100644 apps/sim/lib/table/application/workspace-file-imports.test.ts create mode 100644 apps/sim/lib/table/application/workspace-file-imports.ts create mode 100644 apps/sim/lib/workflows/application/resolve-workflow-outputs.test.ts create mode 100644 apps/sim/lib/workflows/application/resolve-workflow-outputs.ts diff --git a/apps/sim/app/api/table/[tableId]/exports/route.ts b/apps/sim/app/api/table/[tableId]/exports/route.ts index 525f455b81b..228fc3be0db 100644 --- a/apps/sim/app/api/table/[tableId]/exports/route.ts +++ b/apps/sim/app/api/table/[tableId]/exports/route.ts @@ -1,39 +1,27 @@ -import { type NextRequest, NextResponse } from 'next/server' import { createTableExportResourceContract } from '@/lib/api/contracts/table-transfers' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - createTableExportResource, - toV2TableExport, -} from '@/lib/table/orchestration/export-resource' -import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils' + defineInternalJsonRoute, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { createTableExportUseCase } from '@/lib/table/application/exports' +import { tableOperations } from '@/lib/table/application/operations' +import { toV2TableExport } from '@/lib/table/orchestration/export-resource' -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(createTableExportResourceContract, request, context) - if (!parsed.success) return parsed.response - const access = await checkAccess(parsed.data.params.tableId, auth.userId, 'read') - if (!access.ok) return accessError(access, 'table-export') - if (access.table.workspaceId !== parsed.data.body.workspaceId) { - return NextResponse.json({ error: 'Table not found' }, { status: 404 }) - } - try { - const record = await createTableExportResource({ - table: access.table, - format: parsed.data.body.format, - }) - return NextResponse.json({ data: toV2TableExport(record, true) }, { status: 201 }) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } +export const POST = defineInternalJsonRoute({ + contract: createTableExportResourceContract, + auth: internalTableSessionOrExecutorAuth, + operation: tableOperations.createExport, + rateLimit: internalRateLimits.none({ + reason: 'Existing authenticated table export creation has no request-rate policy', + }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + tableId: params.tableId, + workspaceId: body.workspaceId, + format: body.format, + }), + useCase: createTableExportUseCase, + present: ({ export: record }) => ({ data: toV2TableExport(record, true) }), }) diff --git a/apps/sim/app/api/table/[tableId]/groups/route.test.ts b/apps/sim/app/api/table/[tableId]/groups/route.test.ts index 674298e8c66..24b6c533a57 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.test.ts @@ -1,209 +1,73 @@ /** * @vitest-environment node */ -import { hybridAuthMockFns, workflowAuthzMockFns } from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { TableDefinition } from '@/lib/table' +import { describe, expect, it, vi } from 'vitest' -const { mockCheckAccess, mockAddWorkflowGroup, mockUpdateWorkflowGroup } = vi.hoisted(() => ({ - mockCheckAccess: vi.fn(), - mockAddWorkflowGroup: vi.fn(), - mockUpdateWorkflowGroup: vi.fn(), -})) +interface CapturedDefinition { + contract: { method: string; path: string } + auth: unknown + operation: { id: string } + useCase: unknown +} -vi.mock('@/app/api/table/utils', async () => { - const { NextResponse } = await import('next/server') - return { - accessError: (result: { status: number }) => - NextResponse.json({ error: 'denied' }, { status: result.status }), - checkAccess: mockCheckAccess, - normalizeColumn: (column: unknown) => column, - } -}) +const mocks = vi.hoisted(() => ({ + auth: { kind: 'session-or-executor' }, + definitions: [] as CapturedDefinition[], + useCases: { + create: { operation: { id: 'tables.groups.create' } }, + remove: { operation: { id: 'tables.groups.delete' } }, + update: { operation: { id: 'tables.groups.update' } }, + }, +})) -vi.mock('@/lib/table/workflow-groups/service', () => ({ - addWorkflowGroup: mockAddWorkflowGroup, - updateWorkflowGroup: mockUpdateWorkflowGroup, - deleteWorkflowGroup: vi.fn(), +vi.mock('@/lib/api/server/routes', () => ({ + defineInternalJsonRoute: (definition: CapturedDefinition) => { + mocks.definitions.push(definition) + return vi.fn() + }, + extendInternalErrorPolicy: vi.fn(() => ({ kind: 'table' })), + internalErrorResponse: vi.fn(), + internalPlainOrchestrationErrorPolicy: { kind: 'plain' }, + internalRateLimits: { + none: ({ reason }: { reason: string }) => ({ kind: 'none', reason }), + }, })) -import { PATCH, POST } from '@/app/api/table/[tableId]/groups/route' +vi.mock('@/lib/table/api', () => ({ internalTableSessionOrExecutorAuth: mocks.auth })) -function buildTable(overrides: Partial<TableDefinition> = {}): TableDefinition { - return { - id: 'tbl_1', - name: 'People', - description: null, - schema: { columns: [] }, - metadata: null, - rowCount: 0, - maxRows: 100, - workspaceId: 'workspace-1', - createdBy: 'user-1', - archivedAt: null, - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - ...overrides, - } -} +vi.mock('@/lib/table/application/groups', () => ({ + createTableGroupUseCase: mocks.useCases.create, + deleteTableGroupUseCase: mocks.useCases.remove, + updateTableGroupUseCase: mocks.useCases.update, +})) -function callPost(body: Record<string, unknown>, tableId = 'tbl_1') { - const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/groups`, { - method: 'POST', - body: JSON.stringify(body), - headers: { 'Content-Type': 'application/json' }, - }) - return POST(req, { params: Promise.resolve({ tableId }) }) -} +vi.mock('@/app/api/table/utils', () => ({ + normalizeColumn: vi.fn(), +})) -function callPatch(body: Record<string, unknown>, tableId = 'tbl_1') { - const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/groups`, { - method: 'PATCH', - body: JSON.stringify(body), - headers: { 'Content-Type': 'application/json' }, - }) - return PATCH(req, { params: Promise.resolve({ tableId }) }) -} +import '@/app/api/table/[tableId]/groups/route' -const baseGroup = { - id: 'grp_1', - workflowId: 'wf_1', - outputs: [{ blockId: 'block_1', path: 'result', columnName: 'result' }], +function definition(method: string): CapturedDefinition { + const match = mocks.definitions.find((candidate) => candidate.contract.method === method) + if (!match) throw new Error(`Missing ${method} group route definition`) + return match } -const baseOutputColumns = [{ name: 'result', type: 'string', workflowGroupId: 'grp_1' }] - -describe('POST /api/table/[tableId]/groups', () => { - beforeEach(() => { - vi.clearAllMocks() - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - authType: 'session', - }) - mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) - workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: { id: 'wf_1' }, - workspaceId: 'workspace-1', - workspaceOrganizationId: null, - }) - mockAddWorkflowGroup.mockResolvedValue({ - schema: { columns: baseOutputColumns, workflowGroups: [baseGroup] }, - }) - }) - - it('rejects a workflowId belonging to a different workspace', async () => { - workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: { id: 'wf_1' }, - workspaceId: 'other-workspace', - workspaceOrganizationId: null, - }) - const res = await callPost({ - workspaceId: 'workspace-1', - group: baseGroup, - outputColumns: baseOutputColumns, - }) - expect(res.status).toBe(400) - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('rejects a nonexistent workflowId', async () => { - workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue(null) - const res = await callPost({ - workspaceId: 'workspace-1', - group: baseGroup, - outputColumns: baseOutputColumns, - }) - expect(res.status).toBe(400) - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('succeeds when the workflow belongs to the same workspace', async () => { - const res = await callPost({ - workspaceId: 'workspace-1', - group: baseGroup, - outputColumns: baseOutputColumns, - }) - expect(res.status).toBe(200) - expect(mockAddWorkflowGroup).toHaveBeenCalled() - }) - - it('skips the workflow check for enrichment groups without a workflowId', async () => { - const res = await callPost({ - workspaceId: 'workspace-1', - group: { ...baseGroup, workflowId: '', enrichmentId: 'enrich_1' }, - outputColumns: baseOutputColumns, - }) - expect(res.status).toBe(200) - expect(workflowAuthzMockFns.mockGetActiveWorkflowContext).not.toHaveBeenCalled() - expect(mockAddWorkflowGroup).toHaveBeenCalled() - }) -}) - -describe('PATCH /api/table/[tableId]/groups', () => { - beforeEach(() => { - vi.clearAllMocks() - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - authType: 'session', - }) - mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) - workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: { id: 'wf_1' }, - workspaceId: 'workspace-1', - workspaceOrganizationId: null, - }) - mockUpdateWorkflowGroup.mockResolvedValue({ - schema: { columns: baseOutputColumns, workflowGroups: [baseGroup] }, - }) - }) - - it('rejects changing workflowId to one in a different workspace', async () => { - workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: { id: 'wf_2' }, - workspaceId: 'other-workspace', - workspaceOrganizationId: null, - }) - const res = await callPatch({ - workspaceId: 'workspace-1', - groupId: 'grp_1', - workflowId: 'wf_2', - }) - expect(res.status).toBe(400) - expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled() - }) - - it('rejects a nonexistent workflowId', async () => { - workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue(null) - const res = await callPatch({ - workspaceId: 'workspace-1', - groupId: 'grp_1', - workflowId: 'wf_missing', - }) - expect(res.status).toBe(400) - expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled() - }) - - it('succeeds when changing workflowId to one in the same workspace', async () => { - const res = await callPatch({ - workspaceId: 'workspace-1', - groupId: 'grp_1', - workflowId: 'wf_1', - }) - expect(res.status).toBe(200) - expect(mockUpdateWorkflowGroup).toHaveBeenCalled() - }) - - it('skips the workflow check when workflowId is not being changed', async () => { - const res = await callPatch({ - workspaceId: 'workspace-1', - groupId: 'grp_1', - name: 'Renamed group', - }) - expect(res.status).toBe(200) - expect(workflowAuthzMockFns.mockGetActiveWorkflowContext).not.toHaveBeenCalled() - expect(mockUpdateWorkflowGroup).toHaveBeenCalled() +describe('/api/table/[tableId]/groups', () => { + it('routes every mutation through its session-or-executor application use case', () => { + const expected = [ + ['POST', mocks.useCases.create], + ['PATCH', mocks.useCases.update], + ['DELETE', mocks.useCases.remove], + ] as const + + expect(mocks.definitions).toHaveLength(expected.length) + for (const [method, useCase] of expected) { + const route = definition(method) + expect(route.contract.path).toBe('/api/table/[tableId]/groups') + expect(route.auth).toBe(mocks.auth) + expect(route.useCase).toBe(useCase) + expect(route.operation.id).toBe(useCase.operation.id) + } }) }) diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts index c6f2e7c46b4..70ab6758ade 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.ts @@ -1,218 +1,75 @@ -import { createLogger } from '@sim/logger' -import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' -import { type NextRequest, NextResponse } from 'next/server' import { addWorkflowGroupContract, deleteWorkflowGroupContract, updateWorkflowGroupContract, } from '@/lib/api/contracts/tables' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { signalTableSchemaChanged } from '@/lib/table/events' import { - addWorkflowGroup, - deleteWorkflowGroup, - updateWorkflowGroup, -} from '@/lib/table/workflow-groups/service' + defineInternalJsonRoute, + extendInternalErrorPolicy, + internalErrorResponse, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' import { - accessError, - checkAccess, - normalizeColumn, - tableLockErrorResponse, -} from '@/app/api/table/utils' + createTableGroupUseCase, + deleteTableGroupUseCase, + updateTableGroupUseCase, +} from '@/lib/table/application/groups' +import { tableOperations } from '@/lib/table/application/operations' +import { TableLockedError } from '@/lib/table/mutation-locks' +import type { TableDefinition } from '@/lib/table/types' +import { normalizeColumn } from '@/app/api/table/utils' -const logger = createLogger('TableWorkflowGroupsAPI') +const errorPolicy = extendInternalErrorPolicy(internalPlainOrchestrationErrorPolicy, (error) => + error instanceof TableLockedError + ? internalErrorResponse(423, { error: error.message, lock: error.lock }) + : null +) -interface RouteParams { - params: Promise<{ tableId: string }> -} - -/** - * Confirms `workflowId` resolves to an active workflow in `workspaceId` before it is - * persisted onto a table's workflow group. Returns a 400 response when the workflow - * doesn't exist or belongs to a different workspace, otherwise `null`. - */ -async function validateWorkflowInWorkspace( - workflowId: string, - workspaceId: string -): Promise<NextResponse | null> { - const context = await getActiveWorkflowContext(workflowId) - if (!context || context.workspaceId !== workspaceId) { - return NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }) - } - return null -} +const rateLimit = internalRateLimits.none({ + reason: 'Existing authenticated table group mutations have no request-rate policy', +}) -/** - * Maps known service-layer error messages onto HTTP responses; falls through - * to a 500 with a generic message for anything unrecognized. The three - * group-route handlers all surface the same error shapes from - * `addWorkflowGroup` / `updateWorkflowGroup` / `deleteWorkflowGroup`, so they - * share this mapper instead of repeating the if-chain three times. - */ -function mapWorkflowGroupError(error: unknown, fallbackMessage: string): NextResponse { - const lockError = tableLockErrorResponse(error) - if (lockError) return lockError - if (error instanceof Error) { - const msg = error.message - if (msg === 'Table not found' || msg.includes('not found')) { - return NextResponse.json({ error: msg }, { status: 404 }) - } - if ( - msg.includes('Schema validation') || - msg.includes('Missing column definition') || - msg.includes('already exists') || - msg.includes('exceed') - ) { - return NextResponse.json({ error: msg }, { status: 400 }) - } +function presentTable(table: TableDefinition) { + return { + success: true as const, + data: { + columns: table.schema.columns.map(normalizeColumn), + workflowGroups: table.schema.workflowGroups ?? [], + }, } - logger.error(fallbackMessage, error) - return NextResponse.json({ error: fallbackMessage }, { status: 500 }) } -/** POST /api/table/[tableId]/groups — create a workflow group + its output columns. */ -export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(addWorkflowGroupContract, request, { params }) - if (!parsed.success) return parsed.response - const { tableId } = parsed.data.params - const validated = parsed.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) - if (result.table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - if (validated.group.workflowId) { - const workflowError = await validateWorkflowInWorkspace( - validated.group.workflowId, - result.table.workspaceId - ) - if (workflowError) return workflowError - } - const updatedTable = await addWorkflowGroup( - { - tableId, - group: validated.group, - outputColumns: validated.outputColumns, - autoRun: validated.autoRun, - actorUserId: authResult.userId, - }, - requestId - ) - signalTableSchemaChanged(tableId) - return NextResponse.json({ - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), - workflowGroups: updatedTable.schema.workflowGroups ?? [], - }, - }) - } catch (error) { - return mapWorkflowGroupError(error, 'Failed to add workflow group') - } +export const POST = defineInternalJsonRoute({ + contract: addWorkflowGroupContract, + operation: tableOperations.createGroup, + useCase: createTableGroupUseCase, + auth: internalTableSessionOrExecutorAuth, + rateLimit, + errorPolicy, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: ({ table }) => presentTable(table), }) -/** PATCH /api/table/[tableId]/groups — update a workflow group (deps / outputs). */ -export const PATCH = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(updateWorkflowGroupContract, request, { params }) - if (!parsed.success) return parsed.response - const { tableId } = parsed.data.params - const validated = parsed.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) - if (result.table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - if (validated.workflowId !== undefined) { - const workflowError = await validateWorkflowInWorkspace( - validated.workflowId, - result.table.workspaceId - ) - if (workflowError) return workflowError - } - const updatedTable = await updateWorkflowGroup( - { - tableId, - groupId: validated.groupId, - actorUserId: authResult.userId, - ...(validated.workflowId !== undefined ? { workflowId: validated.workflowId } : {}), - ...(validated.name !== undefined ? { name: validated.name } : {}), - ...(validated.dependencies !== undefined ? { dependencies: validated.dependencies } : {}), - ...(validated.outputs !== undefined ? { outputs: validated.outputs } : {}), - ...(validated.newOutputColumns !== undefined - ? { newOutputColumns: validated.newOutputColumns } - : {}), - ...(validated.mappingUpdates !== undefined - ? { mappingUpdates: validated.mappingUpdates } - : {}), - ...(validated.inputMappings !== undefined - ? { inputMappings: validated.inputMappings } - : {}), - ...(validated.deploymentMode !== undefined - ? { deploymentMode: validated.deploymentMode } - : {}), - ...(validated.type !== undefined ? { type: validated.type } : {}), - ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}), - }, - requestId - ) - signalTableSchemaChanged(tableId) - return NextResponse.json({ - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), - workflowGroups: updatedTable.schema.workflowGroups ?? [], - }, - }) - } catch (error) { - return mapWorkflowGroupError(error, 'Failed to update workflow group') - } +export const PATCH = defineInternalJsonRoute({ + contract: updateWorkflowGroupContract, + operation: tableOperations.updateGroup, + useCase: updateTableGroupUseCase, + auth: internalTableSessionOrExecutorAuth, + rateLimit, + errorPolicy, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: ({ table }) => presentTable(table), }) -/** DELETE /api/table/[tableId]/groups — remove a workflow group + its columns. */ -export const DELETE = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(deleteWorkflowGroupContract, request, { params }) - if (!parsed.success) return parsed.response - const { tableId } = parsed.data.params - const validated = parsed.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) - if (result.table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - const updatedTable = await deleteWorkflowGroup( - { tableId, groupId: validated.groupId }, - requestId - ) - signalTableSchemaChanged(tableId) - return NextResponse.json({ - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), - workflowGroups: updatedTable.schema.workflowGroups ?? [], - }, - }) - } catch (error) { - return mapWorkflowGroupError(error, 'Failed to delete workflow group') - } +export const DELETE = defineInternalJsonRoute({ + contract: deleteWorkflowGroupContract, + operation: tableOperations.deleteGroup, + useCase: deleteTableGroupUseCase, + auth: internalTableSessionOrExecutorAuth, + rateLimit, + errorPolicy, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: ({ table }) => presentTable(table), }) diff --git a/apps/sim/app/api/table/exports/[exportId]/download/route.ts b/apps/sim/app/api/table/exports/[exportId]/download/route.ts index 93ba5175585..71c9ca300c3 100644 --- a/apps/sim/app/api/table/exports/[exportId]/download/route.ts +++ b/apps/sim/app/api/table/exports/[exportId]/download/route.ts @@ -1,47 +1,25 @@ -import { type NextRequest, NextResponse } from 'next/server' import { downloadTableExportResourceContract } from '@/lib/api/contracts/table-transfers' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { requireTableExport, tableExportResult } from '@/lib/table/orchestration/export-resource' -import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' -import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils' +import { + defineInternalJsonRoute, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { downloadTableExportUseCase } from '@/lib/table/application/exports' +import { tableOperations } from '@/lib/table/application/operations' -const DOWNLOAD_TTL_SECONDS = 60 * 60 - -interface ExportRouteParams { - params: Promise<{ exportId: string }> -} - -export const GET = withRouteHandler(async (request: NextRequest, context: ExportRouteParams) => { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(downloadTableExportResourceContract, request, context) - if (!parsed.success) return parsed.response - try { - const record = await requireTableExport( - parsed.data.params.exportId, - parsed.data.query.workspaceId - ) - const access = await checkAccess(record.tableId, auth.userId, 'read') - if (!access.ok) return accessError(access, 'table-export') - const result = tableExportResult(record) - return NextResponse.json({ - data: { - url: await generatePresignedDownloadUrl( - result.resultKey, - 'workspace', - DOWNLOAD_TTL_SECONDS - ), - fileName: result.resultKey.split('/').pop() ?? `export.${result.format}`, - expiresAt: new Date(Date.now() + DOWNLOAD_TTL_SECONDS * 1000).toISOString(), - }, - }) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } +export const GET = defineInternalJsonRoute({ + contract: downloadTableExportResourceContract, + auth: internalTableSessionOrExecutorAuth, + operation: tableOperations.downloadExport, + rateLimit: internalRateLimits.none({ + reason: 'Existing authenticated table export download signing has no request-rate policy', + }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + exportId: params.exportId, + workspaceId: query.workspaceId, + }), + useCase: downloadTableExportUseCase, + present: (result) => ({ data: result }), }) diff --git a/apps/sim/app/api/table/exports/[exportId]/route.ts b/apps/sim/app/api/table/exports/[exportId]/route.ts index c7e9f56b405..5e4d3a8c14c 100644 --- a/apps/sim/app/api/table/exports/[exportId]/route.ts +++ b/apps/sim/app/api/table/exports/[exportId]/route.ts @@ -1,68 +1,45 @@ -import { type NextRequest, NextResponse } from 'next/server' import { cancelTableExportResourceContract, getTableExportResourceContract, } from '@/lib/api/contracts/table-transfers' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - cancelTableExportResource, - requireTableExport, - toV2TableExport, -} from '@/lib/table/orchestration/export-resource' -import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils' + defineInternalJsonRoute, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { cancelTableExportUseCase, readTableExportUseCase } from '@/lib/table/application/exports' +import { tableOperations } from '@/lib/table/application/operations' +import { toV2TableExport } from '@/lib/table/orchestration/export-resource' -interface ExportRouteParams { - params: Promise<{ exportId: string }> -} - -async function authorizedExport(exportId: string, workspaceId: string, userId: string) { - const record = await requireTableExport(exportId, workspaceId) - const access = await checkAccess(record.tableId, userId, 'read') - return { record, access } -} +const rateLimit = internalRateLimits.none({ + reason: 'Existing authenticated table export resource access has no request-rate policy', +}) -export const GET = withRouteHandler(async (request: NextRequest, context: ExportRouteParams) => { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(getTableExportResourceContract, request, context) - if (!parsed.success) return parsed.response - try { - const { record, access } = await authorizedExport( - parsed.data.params.exportId, - parsed.data.query.workspaceId, - auth.userId - ) - if (!access.ok) return accessError(access, 'table-export') - return NextResponse.json({ data: toV2TableExport(record) }) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } +export const GET = defineInternalJsonRoute({ + contract: getTableExportResourceContract, + auth: internalTableSessionOrExecutorAuth, + operation: tableOperations.readExport, + rateLimit, + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + exportId: params.exportId, + workspaceId: query.workspaceId, + }), + useCase: readTableExportUseCase, + present: ({ export: record }) => ({ data: toV2TableExport(record) }), }) -export const DELETE = withRouteHandler(async (request: NextRequest, context: ExportRouteParams) => { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(cancelTableExportResourceContract, request, context) - if (!parsed.success) return parsed.response - try { - const { record, access } = await authorizedExport( - parsed.data.params.exportId, - parsed.data.query.workspaceId, - auth.userId - ) - if (!access.ok) return accessError(access, 'table-export') - return NextResponse.json({ data: toV2TableExport(await cancelTableExportResource(record)) }) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } +export const DELETE = defineInternalJsonRoute({ + contract: cancelTableExportResourceContract, + auth: internalTableSessionOrExecutorAuth, + operation: tableOperations.cancelExport, + rateLimit, + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + exportId: params.exportId, + workspaceId: query.workspaceId, + }), + useCase: cancelTableExportUseCase, + present: ({ export: record }) => ({ data: toV2TableExport(record) }), }) diff --git a/apps/sim/app/api/table/imports/[importId]/complete/route.ts b/apps/sim/app/api/table/imports/[importId]/complete/route.ts index 0de609c4c0d..6952511835d 100644 --- a/apps/sim/app/api/table/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/complete/route.ts @@ -1,51 +1,27 @@ -import { type NextRequest, NextResponse } from 'next/server' import { completeTableImportResourceContract } from '@/lib/api/contracts/table-transfers' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - findOwnedTableImport, - getOwnedTableImportUpload, - startUploadedTableImport, - toV2TableImport, -} from '@/lib/table/orchestration/import-resource' -import { completeUploadSession } from '@/lib/uploads/upload-session/service' -import { orchestrationErrorResponse } from '@/app/api/table/utils' + defineInternalJsonRoute, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { completeTableImportUseCase } from '@/lib/table/application/imports' +import { tableOperations } from '@/lib/table/application/operations' +import { toV2TableImport } from '@/lib/table/orchestration/import-resource' -interface ImportRouteParams { - params: Promise<{ importId: string }> -} - -export const POST = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(completeTableImportResourceContract, request, context) - if (!parsed.success) return parsed.response - try { - const upload = await getOwnedTableImportUpload({ - importId: parsed.data.params.importId, - workspaceId: parsed.data.query.workspaceId, - userId: auth.userId, - uploadToken: parsed.data.headers['upload-token'], - }) - const existing = await findOwnedTableImport({ - importId: upload.id, - workspaceId: parsed.data.query.workspaceId, - userId: upload.userId, - }) - if (existing) return NextResponse.json({ data: toV2TableImport(existing) }) - const completed = await completeUploadSession({ - session: upload, - finalize: async () => ({ value: null }), - }) - return NextResponse.json({ - data: toV2TableImport(await startUploadedTableImport(completed.session)), - }) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } +export const POST = defineInternalJsonRoute({ + contract: completeTableImportResourceContract, + auth: internalTableSessionOrExecutorAuth, + operation: tableOperations.completeImport, + rateLimit: internalRateLimits.none({ + reason: 'Existing authenticated table import completion has no request-rate policy', + }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params, query, headers }) => ({ + importId: params.importId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + }), + useCase: completeTableImportUseCase, + present: ({ import: record }) => ({ data: toV2TableImport(record) }), }) diff --git a/apps/sim/app/api/table/imports/[importId]/parts/route.ts b/apps/sim/app/api/table/imports/[importId]/parts/route.ts index f86289bf5a2..4a3b4261c60 100644 --- a/apps/sim/app/api/table/imports/[importId]/parts/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/parts/route.ts @@ -1,39 +1,27 @@ -import { type NextRequest, NextResponse } from 'next/server' import { createTableImportPartUrlsContract } from '@/lib/api/contracts/table-transfers' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getOwnedTableImportUpload } from '@/lib/table/orchestration/import-resource' -import { createUploadPartUrls } from '@/lib/uploads/upload-session/service' -import { orchestrationErrorResponse } from '@/app/api/table/utils' +import { + defineInternalJsonRoute, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { createTableImportPartsUseCase } from '@/lib/table/application/imports' +import { tableOperations } from '@/lib/table/application/operations' -interface ImportRouteParams { - params: Promise<{ importId: string }> -} - -export const POST = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(createTableImportPartUrlsContract, request, context) - if (!parsed.success) return parsed.response - try { - const upload = await getOwnedTableImportUpload({ - importId: parsed.data.params.importId, - workspaceId: parsed.data.query.workspaceId, - userId: auth.userId, - uploadToken: parsed.data.headers['upload-token'], - }) - const parts = await createUploadPartUrls({ - session: upload, - partNumbers: parsed.data.body.partNumbers, - localOrigin: request.nextUrl.origin, - }) - return NextResponse.json({ data: { parts } }) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } +export const POST = defineInternalJsonRoute({ + contract: createTableImportPartUrlsContract, + auth: internalTableSessionOrExecutorAuth, + operation: tableOperations.createImportParts, + rateLimit: internalRateLimits.none({ + reason: 'Existing authenticated table import part signing has no request-rate policy', + }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params, query, headers, body }) => ({ + importId: params.importId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + partNumbers: body.partNumbers, + }), + useCase: createTableImportPartsUseCase, + present: ({ parts }) => ({ data: { parts } }), }) diff --git a/apps/sim/app/api/table/imports/[importId]/route.ts b/apps/sim/app/api/table/imports/[importId]/route.ts index 15fb5cd2914..60dd58dc125 100644 --- a/apps/sim/app/api/table/imports/[importId]/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/route.ts @@ -1,76 +1,46 @@ -import { type NextRequest, NextResponse } from 'next/server' import { cancelTableImportResourceContract, getTableImportResourceContract, } from '@/lib/api/contracts/table-transfers' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - abortTableImportUpload, - cancelTableImportResource, - getOwnedTableImport, - toV2TableImport, -} from '@/lib/table/orchestration/import-resource' -import { orchestrationErrorResponse } from '@/app/api/table/utils' + defineInternalJsonRoute, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { cancelTableImportUseCase, readTableImportUseCase } from '@/lib/table/application/imports' +import { tableOperations } from '@/lib/table/application/operations' +import { toV2TableImport } from '@/lib/table/orchestration/import-resource' -interface ImportRouteParams { - params: Promise<{ importId: string }> -} - -async function userId(request: NextRequest): Promise<string | NextResponse> { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - return auth.success && auth.userId - ? auth.userId - : NextResponse.json({ error: 'Authentication required' }, { status: 401 }) -} +const rateLimit = internalRateLimits.none({ + reason: 'Existing authenticated table import resource access has no request-rate policy', +}) -export const GET = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { - const user = await userId(request) - if (user instanceof NextResponse) return user - const parsed = await parseRequest(getTableImportResourceContract, request, context) - if (!parsed.success) return parsed.response - try { - const record = await getOwnedTableImport({ - importId: parsed.data.params.importId, - workspaceId: parsed.data.query.workspaceId, - userId: user, - }) - return NextResponse.json({ data: await toV2TableImport(record) }) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } +export const GET = defineInternalJsonRoute({ + contract: getTableImportResourceContract, + auth: internalTableSessionOrExecutorAuth, + operation: tableOperations.readImport, + rateLimit, + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + importId: params.importId, + workspaceId: query.workspaceId, + }), + useCase: readTableImportUseCase, + present: ({ import: record }) => ({ data: toV2TableImport(record) }), }) -export const DELETE = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { - const user = await userId(request) - if (user instanceof NextResponse) return user - const parsed = await parseRequest(cancelTableImportResourceContract, request, context) - if (!parsed.success) return parsed.response - try { - const uploadToken = parsed.data.headers['upload-token'] - const record = uploadToken - ? await abortTableImportUpload({ - importId: parsed.data.params.importId, - workspaceId: parsed.data.query.workspaceId, - userId: user, - uploadToken, - }) - : await cancelTableImportResource( - await getOwnedTableImport({ - importId: parsed.data.params.importId, - workspaceId: parsed.data.query.workspaceId, - userId: user, - }) - ) - return NextResponse.json({ - data: toV2TableImport(record), - }) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } +export const DELETE = defineInternalJsonRoute({ + contract: cancelTableImportResourceContract, + auth: internalTableSessionOrExecutorAuth, + operation: tableOperations.cancelImport, + rateLimit, + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params, query, headers }) => ({ + importId: params.importId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + }), + useCase: cancelTableImportUseCase, + present: ({ import: record }) => ({ data: toV2TableImport(record) }), }) diff --git a/apps/sim/app/api/table/imports/route.ts b/apps/sim/app/api/table/imports/route.ts index d7e42288f09..143207c2e5e 100644 --- a/apps/sim/app/api/table/imports/route.ts +++ b/apps/sim/app/api/table/imports/route.ts @@ -1,31 +1,23 @@ -import { type NextRequest, NextResponse } from 'next/server' import { createTableImportResourceContract } from '@/lib/api/contracts/table-transfers' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - createTableImportResource, - toV2CreateTableImport, -} from '@/lib/table/orchestration/import-resource' -import { orchestrationErrorResponse } from '@/app/api/table/utils' + defineInternalJsonRoute, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { createTableImportUseCase } from '@/lib/table/application/imports' +import { tableOperations } from '@/lib/table/application/operations' +import { toV2CreateTableImport } from '@/lib/table/orchestration/import-resource' -export const POST = withRouteHandler(async (request: NextRequest) => { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(createTableImportResourceContract, request, {}) - if (!parsed.success) return parsed.response - try { - const created = await createTableImportResource( - parsed.data.body, - auth.userId, - request.nextUrl.origin - ) - return NextResponse.json({ data: toV2CreateTableImport(created) }, { status: 201 }) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } +export const POST = defineInternalJsonRoute({ + contract: createTableImportResourceContract, + auth: internalTableSessionOrExecutorAuth, + operation: tableOperations.createImport, + rateLimit: internalRateLimits.none({ + reason: 'Existing authenticated table import creation has no request-rate policy', + }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ body }) => ({ body }), + useCase: createTableImportUseCase, + present: ({ import: created }) => ({ data: toV2CreateTableImport(created) }), }) diff --git a/apps/sim/app/api/table/table-transfer-routes.test.ts b/apps/sim/app/api/table/table-transfer-routes.test.ts new file mode 100644 index 00000000000..7511900d81f --- /dev/null +++ b/apps/sim/app/api/table/table-transfer-routes.test.ts @@ -0,0 +1,113 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +interface CapturedDefinition { + contract: { + method: string + path: string + response: { status?: number } + } + auth: unknown + operation: { id: string } + useCase: unknown +} + +const mocks = vi.hoisted(() => ({ + auth: { kind: 'session-or-executor' }, + definitions: [] as CapturedDefinition[], + useCases: { + cancelExport: { operation: { id: 'tables.exports.cancel' } }, + cancelImport: { operation: { id: 'tables.imports.cancel' } }, + completeImport: { operation: { id: 'tables.imports.complete' } }, + createExport: { operation: { id: 'tables.exports.create' } }, + createImport: { operation: { id: 'tables.imports.create' } }, + createImportParts: { operation: { id: 'tables.imports.create_parts' } }, + downloadExport: { operation: { id: 'tables.exports.download' } }, + readExport: { operation: { id: 'tables.exports.read' } }, + readImport: { operation: { id: 'tables.imports.read' } }, + }, +})) + +vi.mock('@/lib/api/server/routes', () => ({ + defineInternalJsonRoute: (definition: CapturedDefinition) => { + mocks.definitions.push(definition) + return vi.fn() + }, + internalPlainOrchestrationErrorPolicy: { kind: 'plain' }, + internalRateLimits: { + none: ({ reason }: { reason: string }) => ({ kind: 'none', reason }), + }, +})) + +vi.mock('@/lib/table/api', () => ({ internalTableSessionOrExecutorAuth: mocks.auth })) + +vi.mock('@/lib/table/application/imports', () => ({ + cancelTableImportUseCase: mocks.useCases.cancelImport, + completeTableImportUseCase: mocks.useCases.completeImport, + createTableImportPartsUseCase: mocks.useCases.createImportParts, + createTableImportUseCase: mocks.useCases.createImport, + readTableImportUseCase: mocks.useCases.readImport, +})) + +vi.mock('@/lib/table/application/exports', () => ({ + cancelTableExportUseCase: mocks.useCases.cancelExport, + createTableExportUseCase: mocks.useCases.createExport, + downloadTableExportUseCase: mocks.useCases.downloadExport, + readTableExportUseCase: mocks.useCases.readExport, +})) + +vi.mock('@/lib/table/orchestration/import-resource', () => ({ + toV2CreateTableImport: vi.fn(), + toV2TableImport: vi.fn(), +})) + +vi.mock('@/lib/table/orchestration/export-resource', () => ({ + toV2TableExport: vi.fn(), +})) + +import '@/app/api/table/[tableId]/exports/route' +import '@/app/api/table/exports/[exportId]/download/route' +import '@/app/api/table/exports/[exportId]/route' +import '@/app/api/table/imports/[importId]/complete/route' +import '@/app/api/table/imports/[importId]/parts/route' +import '@/app/api/table/imports/[importId]/route' +import '@/app/api/table/imports/route' + +function definition(method: string, path: string): CapturedDefinition { + const match = mocks.definitions.find( + (candidate) => candidate.contract.method === method && candidate.contract.path === path + ) + if (!match) throw new Error(`Missing ${method} ${path} route definition`) + return match +} + +describe('internal table transfer routes', () => { + it('routes every ordinary transfer control leg through session-or-executor use cases', () => { + const expected = [ + ['POST', '/api/table/imports', mocks.useCases.createImport], + ['GET', '/api/table/imports/[importId]', mocks.useCases.readImport], + ['DELETE', '/api/table/imports/[importId]', mocks.useCases.cancelImport], + ['POST', '/api/table/imports/[importId]/parts', mocks.useCases.createImportParts], + ['POST', '/api/table/imports/[importId]/complete', mocks.useCases.completeImport], + ['POST', '/api/table/[tableId]/exports', mocks.useCases.createExport], + ['GET', '/api/table/exports/[exportId]', mocks.useCases.readExport], + ['DELETE', '/api/table/exports/[exportId]', mocks.useCases.cancelExport], + ['GET', '/api/table/exports/[exportId]/download', mocks.useCases.downloadExport], + ] as const + + expect(mocks.definitions).toHaveLength(expected.length) + for (const [method, path, useCase] of expected) { + const route = definition(method, path) + expect(route.auth).toBe(mocks.auth) + expect(route.useCase).toBe(useCase) + expect(route.operation.id).toBe(useCase.operation.id) + } + }) + + it('preserves the create response statuses', () => { + expect(definition('POST', '/api/table/imports').contract.response.status).toBe(201) + expect(definition('POST', '/api/table/[tableId]/exports').contract.response.status).toBe(201) + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts index d20a9074734..ad7a2d1bc2f 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts @@ -27,6 +27,9 @@ vi.mock('@/lib/core/rate-limiter', () => ({ getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/app/api/v2/tables/presenters', () => ({ + presentV2TableExport: (tableExport: unknown) => ({ data: tableExport }), +})) vi.mock('@/lib/table/application/exports', () => ({ createTableExportUseCase: { operation: { id: 'tables.exports.create' }, execute: mocks.create }, readTableExportUseCase: { operation: { id: 'tables.exports.read' }, execute: mocks.read }, diff --git a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts index 9b8115899a5..9de6249cbe0 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts @@ -3,6 +3,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2TableErrorPolicies } from '@/lib/table/api' import { createTableExportUseCase } from '@/lib/table/application/exports' import { tableOperations } from '@/lib/table/application/operations' +import { presentV2TableExport } from '@/app/api/v2/tables/presenters' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -19,5 +20,5 @@ export const POST = defineV2JsonRoute({ format: body.format, }), useCase: createTableExportUseCase, - present: ({ export: tableExport }) => ({ data: tableExport }), + present: ({ export: tableExport }) => presentV2TableExport(tableExport, true), }) diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts index 61c18650cdf..d962454a3ad 100644 --- a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts @@ -6,6 +6,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2TableErrorPolicies } from '@/lib/table/api' import { cancelTableExportUseCase, readTableExportUseCase } from '@/lib/table/application/exports' import { tableOperations } from '@/lib/table/application/operations' +import { presentV2TableExport } from '@/app/api/v2/tables/presenters' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -21,7 +22,7 @@ export const GET = defineV2JsonRoute({ workspaceId: query.workspaceId, }), useCase: readTableExportUseCase, - present: ({ export: tableExport }) => ({ data: tableExport }), + present: ({ export: tableExport }) => presentV2TableExport(tableExport), }) export const DELETE = defineV2JsonRoute({ @@ -35,5 +36,5 @@ export const DELETE = defineV2JsonRoute({ workspaceId: query.workspaceId, }), useCase: cancelTableExportUseCase, - present: ({ export: tableExport }) => ({ data: tableExport }), + present: ({ export: tableExport }) => presentV2TableExport(tableExport), }) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts index de1c14d4349..6964d818d6b 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts @@ -25,6 +25,9 @@ vi.mock('@/lib/core/rate-limiter', () => ({ getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/app/api/v2/tables/presenters', () => ({ + presentV2TableImport: (tableImport: unknown) => ({ data: tableImport }), +})) vi.mock('@/lib/table/application/imports', () => ({ completeTableImportUseCase: { operation: { id: 'tables.imports.complete' }, diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts index 00ac2393205..368970ec9b9 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts @@ -3,6 +3,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2TableErrorPolicies } from '@/lib/table/api' import { completeTableImportUseCase } from '@/lib/table/application/imports' import { tableOperations } from '@/lib/table/application/operations' +import { presentV2TableImport } from '@/app/api/v2/tables/presenters' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -19,5 +20,5 @@ export const POST = defineV2JsonRoute({ uploadToken: headers['upload-token'], }), useCase: completeTableImportUseCase, - present: ({ import: tableImport }) => ({ data: tableImport }), + present: ({ import: tableImport }) => presentV2TableImport(tableImport), }) 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 aba56dd3df1..7092782c602 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts @@ -6,6 +6,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2TableErrorPolicies } from '@/lib/table/api' import { cancelTableImportUseCase, readTableImportUseCase } from '@/lib/table/application/imports' import { tableOperations } from '@/lib/table/application/operations' +import { presentV2TableImport } from '@/app/api/v2/tables/presenters' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -21,7 +22,7 @@ export const GET = defineV2JsonRoute({ workspaceId: query.workspaceId, }), useCase: readTableImportUseCase, - present: ({ import: tableImport }) => ({ data: tableImport }), + present: ({ import: tableImport }) => presentV2TableImport(tableImport), }) export const DELETE = defineV2JsonRoute({ @@ -36,5 +37,5 @@ export const DELETE = defineV2JsonRoute({ uploadToken: headers['upload-token'], }), useCase: cancelTableImportUseCase, - present: ({ import: tableImport }) => ({ data: tableImport }), + 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 a0d53ee1880..90bd9e23e22 100644 --- a/apps/sim/app/api/v2/tables/imports/route.test.ts +++ b/apps/sim/app/api/v2/tables/imports/route.test.ts @@ -25,6 +25,9 @@ vi.mock('@/lib/core/rate-limiter', () => ({ getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/app/api/v2/tables/presenters', () => ({ + presentV2CreateTableImport: (tableImport: unknown) => ({ data: tableImport }), +})) vi.mock('@/lib/table/application/imports', () => ({ createTableImportUseCase: { operation: { id: 'tables.imports.create' }, execute: mocks.create }, })) diff --git a/apps/sim/app/api/v2/tables/imports/route.ts b/apps/sim/app/api/v2/tables/imports/route.ts index 2fba1da2417..ea630f4f9c7 100644 --- a/apps/sim/app/api/v2/tables/imports/route.ts +++ b/apps/sim/app/api/v2/tables/imports/route.ts @@ -3,6 +3,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2TableErrorPolicies } from '@/lib/table/api' import { createTableImportUseCase } from '@/lib/table/application/imports' import { tableOperations } from '@/lib/table/application/operations' +import { presentV2CreateTableImport } from '@/app/api/v2/tables/presenters' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -15,5 +16,5 @@ export const POST = defineV2JsonRoute({ errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ body }) => ({ body }), useCase: createTableImportUseCase, - present: ({ import: tableImport }) => ({ data: tableImport }), + present: ({ import: tableImport }) => presentV2CreateTableImport(tableImport), }) diff --git a/apps/sim/app/api/v2/tables/presenters.test.ts b/apps/sim/app/api/v2/tables/presenters.test.ts new file mode 100644 index 00000000000..da5f27bca2c --- /dev/null +++ b/apps/sim/app/api/v2/tables/presenters.test.ts @@ -0,0 +1,82 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { + presentV2CreateTableImport, + presentV2TableExport, + presentV2TableImport, +} from '@/app/api/v2/tables/presenters' + +const createdAt = new Date('2026-08-01T00:00:00.000Z') +const importRecord = { + id: 'import-1', + workspaceId: 'workspace-1', + userId: 'user-1', + source: { type: 'workspace_file' as const, fileId: 'file-1' }, + target: { type: 'new' as const, name: 'People' }, + options: {}, + tableId: 'table-1', + status: 'running' as const, + rowsProcessed: 2, + error: null, + createdAt, + updatedAt: createdAt, + completedAt: null, +} +const exportRecord = { + id: 'export-1', + tableId: 'table-1', + workspaceId: 'workspace-1', + type: 'export', + status: 'running', + payload: { format: 'csv' as const }, + rowsProcessed: 0, + error: null, + startedAt: createdAt, + updatedAt: createdAt, + completedAt: null, +} + +describe('v2 table presenters', () => { + it('converts domain import records at the v2 boundary', () => { + expect(presentV2CreateTableImport({ record: importRecord, upload: null })).toEqual({ + data: { + session: { + id: 'import-1', + workspaceId: 'workspace-1', + status: 'processing', + source: importRecord.source, + target: importRecord.target, + tableId: 'table-1', + rowsProcessed: 2, + error: null, + createdAt: createdAt.toISOString(), + updatedAt: createdAt.toISOString(), + completedAt: null, + }, + uploadToken: null, + transfer: null, + }, + }) + expect(presentV2TableImport(importRecord).data.createdAt).toBe(createdAt.toISOString()) + }) + + it('converts domain export records and preserves queued create presentation', () => { + expect(presentV2TableExport(exportRecord, true)).toEqual({ + data: { + id: 'export-1', + tableId: 'table-1', + workspaceId: 'workspace-1', + format: 'csv', + status: 'queued', + rowsProcessed: 0, + error: null, + createdAt: createdAt.toISOString(), + updatedAt: createdAt.toISOString(), + completedAt: null, + }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/tables/presenters.ts b/apps/sim/app/api/v2/tables/presenters.ts new file mode 100644 index 00000000000..d194cb5c3fa --- /dev/null +++ b/apps/sim/app/api/v2/tables/presenters.ts @@ -0,0 +1,19 @@ +import { type TableExportRecord, toV2TableExport } from '@/lib/table/orchestration/export-resource' +import { + type CreateTableImportResult, + type TableImportResource, + toV2CreateTableImport, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' + +export function presentV2CreateTableImport(result: CreateTableImportResult) { + return { data: toV2CreateTableImport(result) } +} + +export function presentV2TableImport(record: TableImportResource) { + return { data: toV2TableImport(record) } +} + +export function presentV2TableExport(record: TableExportRecord, queued = false) { + return { data: toV2TableExport(record, queued) } +} diff --git a/apps/sim/lib/api/contracts/table-transfers.ts b/apps/sim/lib/api/contracts/table-transfers.ts index a1849b8736c..abd6194254f 100644 --- a/apps/sim/lib/api/contracts/table-transfers.ts +++ b/apps/sim/lib/api/contracts/table-transfers.ts @@ -22,7 +22,7 @@ export const createTableImportResourceContract = defineRouteContract({ method: 'POST', path: '/api/table/imports', body: v2CreateTableImportBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2CreateTableImportDataSchema) }, + response: { mode: 'json', schema: v2DataResponse(v2CreateTableImportDataSchema), status: 201 }, }) export const getTableImportResourceContract = defineRouteContract({ @@ -66,7 +66,7 @@ export const createTableExportResourceContract = defineRouteContract({ path: '/api/table/[tableId]/exports', params: tableIdParamsSchema, body: exportTableAsyncBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema), status: 201 }, }) export const getTableExportResourceContract = defineRouteContract({ diff --git a/apps/sim/lib/copilot/application/execute-table-use-case.test.ts b/apps/sim/lib/copilot/application/execute-table-use-case.test.ts new file mode 100644 index 00000000000..a7fbcd2d531 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-table-use-case.test.ts @@ -0,0 +1,96 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it, vi } from 'vitest' +import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' +import { tableOperations } from '@/lib/table/application/operations' + +const trustedContext = { + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} as const + +describe('executeCopilotTableUseCase', () => { + it('binds the in-process Copilot identity and exact table scope', async () => { + const execute = vi.fn().mockResolvedValue({ id: 'table-1' }) + const useCase = { operation: tableOperations.read, execute } + + await expect( + executeCopilotTableUseCase( + trustedContext, + useCase, + { tableId: 'model-table' }, + { + tableId: 'table-1', + } + ) + ).resolves.toEqual({ id: 'table-1' }) + + expect(execute).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + audience: 'sim:tables', + delegationId: 'copilot-tool:tool-call-1', + resourceScope: { + tableId: 'table-1', + chatId: 'chat-1', + executionId: 'execution-1', + }, + }), + input: { tableId: 'model-table' }, + }) + }) + + it('creates an unscoped Table principal for workspace-level commands', async () => { + const execute = vi.fn().mockResolvedValue({ ok: true }) + + await executeCopilotTableUseCase( + trustedContext, + { operation: tableOperations.delete, execute }, + { workspaceId: 'workspace-1' } + ) + + expect(execute).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + serviceId: 'copilot', + resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, + }), + input: { workspaceId: 'workspace-1' }, + }) + }) + + it('rejects untrusted contexts and unregistered operation objects before execution', () => { + const execute = vi.fn() + const useCase = { operation: tableOperations.read, execute } + + expect(() => + executeCopilotTableUseCase( + { ...trustedContext, copilotToolExecution: false }, + useCase, + { tableId: 'table-1' }, + { tableId: 'table-1' } + ) + ).toThrow('trusted Copilot execution context') + + expect(() => + executeCopilotTableUseCase( + trustedContext, + { + operation: { ...tableOperations.read, minimumRole: 'write' }, + execute, + }, + { tableId: 'table-1' }, + { tableId: 'table-1' } + ) + ).toThrow('Unregistered Copilot table operation') + expect(execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/application/execute-table-use-case.ts b/apps/sim/lib/copilot/application/execute-table-use-case.ts index b973f5fd1bf..d634d93141c 100644 --- a/apps/sim/lib/copilot/application/execute-table-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-table-use-case.ts @@ -3,22 +3,12 @@ import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/applic import type { CopilotTableDelegationContext } from '@/lib/copilot/auth/table-delegation' import type { OperationUseCase } from '@/lib/core/application' import { tableDelegationPolicy } from '@/lib/table/application/authorization' -import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' -import { - resolveActiveTableContext, - resolveTableWorkspaceContext, -} from '@/lib/table/application/context' import { type TableOperation, tableOperations } from '@/lib/table/application/operations' interface ExecuteCopilotTableUseCaseOptions { tableId?: string } -export interface AdmitCopilotTableOperationInput { - workspaceId: string - tableId?: string -} - const executeTableUseCase = createCopilotApplicationAdapter< TableOperation, ExecuteCopilotTableUseCaseOptions @@ -33,7 +23,7 @@ const executeTableUseCase = createCopilotApplicationAdapter< projectResourceScope: ({ tableId }) => (tableId ? { tableId } : {}), }) -/** Enters a registered table application use case under trusted Copilot delegation. */ +/** Normalizes trusted Copilot authentication before entering a Table application use case. */ export function executeCopilotTableUseCase<O extends TableOperation, I, R>( context: CopilotTableDelegationContext | undefined, useCase: OperationUseCase<O, I, R>, @@ -42,26 +32,3 @@ export function executeCopilotTableUseCase<O extends TableOperation, I, R>( ): Promise<R> { return executeTableUseCase(context, useCase, input, options) } - -/** - * Authorizes a Copilot operation that still retains a compatibility-specific - * presenter or execution strategy before that trusted adapter invokes it. - */ -export function admitCopilotTableOperation<O extends TableOperation>( - context: CopilotTableDelegationContext | undefined, - operation: O, - input: AdmitCopilotTableOperationInput -): Promise<void> { - const useCase = defineAuthorizedTableUseCase({ - operation, - resolveContext: ({ input: admitted }: { input: AdmitCopilotTableOperationInput }) => - admitted.tableId - ? resolveActiveTableContext({ - tableId: admitted.tableId, - assertedWorkspaceId: admitted.workspaceId, - }) - : resolveTableWorkspaceContext(admitted.workspaceId), - async execute() {}, - }) - return executeCopilotTableUseCase(context, useCase, input, { tableId: input.tableId }) -} diff --git a/apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts b/apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts new file mode 100644 index 00000000000..f453b071688 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts @@ -0,0 +1,70 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/workflows/application/resolve-workflow-outputs', () => ({ + resolveWorkflowOutputs: { execute: mocks.execute }, +})) + +import { executeCopilotResolveWorkflowOutputs } from '@/lib/copilot/application/execute-workflow-use-case' + +const trustedContext = { + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} as const + +describe('executeCopilotResolveWorkflowOutputs', () => { + afterEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + }) + + it('enters the fixed Workflow resolver with trusted Copilot identity', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + mocks.execute.mockResolvedValueOnce({ + workflowId: 'workflow-1', + outputs: null, + executionOrderByBlockId: {}, + }) + + await expect( + executeCopilotResolveWorkflowOutputs(trustedContext, { + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + }) + ).resolves.toMatchObject({ workflowId: 'workflow-1' }) + + expect(mocks.execute).toHaveBeenCalledWith({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-call-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2026-01-01T00:05:00Z'), + resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, + }, + input: { workflowId: 'workflow-1', assertedWorkspaceId: 'workspace-1' }, + }) + }) + + it('rejects untrusted context before Workflow application execution', () => { + expect(() => + executeCopilotResolveWorkflowOutputs( + { ...trustedContext, copilotToolExecution: false }, + { workflowId: 'workflow-1', assertedWorkspaceId: 'workspace-1' } + ) + ).toThrow('trusted Copilot execution context') + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/application/execute-workflow-use-case.ts b/apps/sim/lib/copilot/application/execute-workflow-use-case.ts new file mode 100644 index 00000000000..a91acba9560 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-workflow-use-case.ts @@ -0,0 +1,35 @@ +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + type CopilotExecutionContext, + createCopilotApplicationPrincipal, + requireTrustedCopilotExecutionContext, +} from '@/lib/copilot/auth/application-delegation' +import { workflowDelegationPolicy } from '@/lib/workflows/application/authorization' +import { + type ResolveWorkflowOutputsInput, + type ResolveWorkflowOutputsResult, + resolveWorkflowOutputs, +} from '@/lib/workflows/application/resolve-workflow-outputs' + +export type CopilotWorkflowDelegationContext = CopilotExecutionContext + +const workflowDelegation = { + audience: workflowDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context: Parameters<typeof createCopilotApplicationPrincipal>[0]) => + `copilot-tool:${context.toolCallId}`, +} as const + +/** Resolves workflow output metadata through one fixed authorized Workflow command. */ +export function executeCopilotResolveWorkflowOutputs( + context: CopilotWorkflowDelegationContext | undefined, + input: ResolveWorkflowOutputsInput +): Promise<ResolveWorkflowOutputsResult> { + return resolveWorkflowOutputs.execute({ + principal: createCopilotApplicationPrincipal( + requireTrustedCopilotExecutionContext(context), + workflowDelegation + ), + input, + }) +} diff --git a/apps/sim/lib/copilot/application/table-commands.test.ts b/apps/sim/lib/copilot/application/table-commands.test.ts new file mode 100644 index 00000000000..900f8b76dfe --- /dev/null +++ b/apps/sim/lib/copilot/application/table-commands.test.ts @@ -0,0 +1,183 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + executeTableUseCase: vi.fn(), + useCases: { + addOutput: { operation: { id: 'tables.groups.update' } }, + createEnrichment: { operation: { id: 'tables.groups.create' } }, + createFromFile: { operation: { id: 'tables.imports.create_from_workspace_file' } }, + createWorkflowGroup: { operation: { id: 'tables.groups.create' } }, + deleteTables: { operation: { id: 'tables.delete' } }, + importFile: { operation: { id: 'tables.imports.workspace_file' } }, + replaceProjectedRows: { operation: { id: 'tables.rows.replace' } }, + updateWorkflowGroup: { operation: { id: 'tables.groups.update' } }, + }, +})) + +vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ + executeCopilotTableUseCase: mocks.executeTableUseCase, +})) +vi.mock('@/lib/table/application/groups', () => ({ + addWorkflowTableGroupOutput: mocks.useCases.addOutput, + createTableEnrichmentGroup: mocks.useCases.createEnrichment, + createWorkflowTableGroup: mocks.useCases.createWorkflowGroup, + updateWorkflowTableGroup: mocks.useCases.updateWorkflowGroup, +})) +vi.mock('@/lib/table/application/copilot-table-lifecycle', () => ({ + deleteCopilotTables: mocks.useCases.deleteTables, +})) +vi.mock('@/lib/table/application/rows', () => ({ + replaceProjectedWireRows: mocks.useCases.replaceProjectedRows, +})) +vi.mock('@/lib/table/application/workspace-file-imports', () => ({ + createTableFromWorkspaceFile: mocks.useCases.createFromFile, + importWorkspaceFileIntoTable: mocks.useCases.importFile, +})) + +import { + copilotAddWorkflowTableGroupOutputPolicy, + copilotCreateTableEnrichmentGroupPolicy, + copilotCreateTableFromWorkspaceFilePolicy, + copilotCreateWorkflowTableGroupPolicy, + copilotDeleteTablesPolicy, + copilotImportWorkspaceFileIntoTablePolicy, + copilotReplaceProjectedWireRowsPolicy, + copilotUpdateWorkflowTableGroupPolicy, + executeCopilotAddWorkflowTableGroupOutput, + executeCopilotCreateTableEnrichmentGroup, + executeCopilotCreateTableFromWorkspaceFile, + executeCopilotCreateWorkflowTableGroup, + executeCopilotDeleteTables, + executeCopilotImportWorkspaceFileIntoTable, + executeCopilotReplaceProjectedWireRows, + executeCopilotUpdateWorkflowTableGroup, +} from '@/lib/copilot/application/table-commands' + +const context = { + userId: 'user-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} +describe('fixed Copilot Table application commands', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each([ + [ + 'replace projected rows', + executeCopilotReplaceProjectedWireRows, + mocks.useCases.replaceProjectedRows, + ], + [ + 'create workflow group', + executeCopilotCreateWorkflowTableGroup, + mocks.useCases.createWorkflowGroup, + ], + [ + 'update workflow group', + executeCopilotUpdateWorkflowTableGroup, + mocks.useCases.updateWorkflowGroup, + ], + ['add workflow output', executeCopilotAddWorkflowTableGroupOutput, mocks.useCases.addOutput], + [ + 'create enrichment group', + executeCopilotCreateTableEnrichmentGroup, + mocks.useCases.createEnrichment, + ], + [ + 'import a workspace file', + executeCopilotImportWorkspaceFileIntoTable, + mocks.useCases.importFile, + ], + ])( + 'dispatches %s to exactly one code-defined Table command', + async (_label, execute, useCase) => { + mocks.executeTableUseCase.mockResolvedValue({ ok: true }) + const input = { tableId: 'table-1', workspaceId: 'workspace-1' } + + await expect(execute(context, input as never)).resolves.toEqual({ ok: true }) + + expect(mocks.executeTableUseCase).toHaveBeenCalledWith(context, useCase, input, { + tableId: 'table-1', + }) + expect(mocks.executeTableUseCase).toHaveBeenCalledTimes(1) + } + ) + + it('uses a workspace-scoped Table principal for create-from-file', async () => { + mocks.executeTableUseCase.mockResolvedValue({ kind: 'empty' }) + const input = { workspaceId: 'workspace-1', fileReference: 'files/people.csv' } + + await executeCopilotCreateTableFromWorkspaceFile(context, input) + + expect(mocks.executeTableUseCase).toHaveBeenCalledWith( + context, + mocks.useCases.createFromFile, + input + ) + }) + + it('uses one workspace-scoped Table command for best-effort multi-table deletion', async () => { + const result = { + deleted: [{ id: 'table-1', name: 'People' }], + failed: ['table-2'], + } + mocks.executeTableUseCase.mockResolvedValue(result) + const input = { + workspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2'], + assertNotAborted: vi.fn(), + } + + await expect(executeCopilotDeleteTables(context, input)).resolves.toBe(result) + + expect(mocks.executeTableUseCase).toHaveBeenCalledWith( + context, + mocks.useCases.deleteTables, + input + ) + expect(mocks.executeTableUseCase).toHaveBeenCalledTimes(1) + }) + + it('declares inherited request-rate admission and no direct provider cost for every command', () => { + const policies = [ + copilotReplaceProjectedWireRowsPolicy, + copilotCreateWorkflowTableGroupPolicy, + copilotDeleteTablesPolicy, + copilotUpdateWorkflowTableGroupPolicy, + copilotAddWorkflowTableGroupOutputPolicy, + copilotCreateTableEnrichmentGroupPolicy, + copilotCreateTableFromWorkspaceFilePolicy, + copilotImportWorkspaceFileIntoTablePolicy, + ] + + for (const policy of policies) { + expect(policy.rate.kind).toBe('inherited_copilot_request') + expect(policy.rate.reason).toBeTruthy() + expect(policy.cost.kind).toBe('none') + expect(policy.cost.reason).toBeTruthy() + } + }) + + it('rejects an untrusted context before application execution', async () => { + const error = new Error('trusted Copilot execution context required') + mocks.executeTableUseCase.mockImplementationOnce(() => { + throw error + }) + + expect(() => + executeCopilotReplaceProjectedWireRows(undefined, { + tableId: 'table-1', + sourceRows: [], + projectedRows: [], + }) + ).toThrow(error) + expect(mocks.executeTableUseCase).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/lib/copilot/application/table-commands.ts b/apps/sim/lib/copilot/application/table-commands.ts new file mode 100644 index 00000000000..66f8ead69f4 --- /dev/null +++ b/apps/sim/lib/copilot/application/table-commands.ts @@ -0,0 +1,144 @@ +import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' +import type { CopilotTableDelegationContext } from '@/lib/copilot/auth/table-delegation' +import { + type DeleteCopilotTablesInput, + deleteCopilotTables, +} from '@/lib/table/application/copilot-table-lifecycle' +import { + type AddTableGroupOutputInput, + addWorkflowTableGroupOutput, + type CreateTableEnrichmentGroupInput, + type CreateWorkflowTableGroupInput, + createTableEnrichmentGroup, + createWorkflowTableGroup, + type UpdateWorkflowTableGroupInput, + updateWorkflowTableGroup, +} from '@/lib/table/application/groups' +import { + type ReplaceProjectedWireRowsInput, + replaceProjectedWireRows, +} from '@/lib/table/application/rows' +import { + type CreateTableFromWorkspaceFileInput, + createTableFromWorkspaceFile, + type ImportWorkspaceFileInput, + importWorkspaceFileIntoTable, +} from '@/lib/table/application/workspace-file-imports' + +const INHERITED_COPILOT_RATE_POLICY = { + kind: 'inherited_copilot_request', + reason: 'The authenticated Copilot request owns request-rate admission.', +} as const + +const NO_DIRECT_PROVIDER_COST_POLICY = { + kind: 'none', + reason: 'This command does not invoke a paid provider; table quota and storage limits apply.', +} as const + +export const copilotDeleteTablesPolicy = { + rate: INHERITED_COPILOT_RATE_POLICY, + cost: NO_DIRECT_PROVIDER_COST_POLICY, +} as const + +export function executeCopilotDeleteTables( + context: CopilotTableDelegationContext | undefined, + input: DeleteCopilotTablesInput +) { + return executeCopilotTableUseCase(context, deleteCopilotTables, input) +} + +export const copilotReplaceProjectedWireRowsPolicy = { + rate: INHERITED_COPILOT_RATE_POLICY, + cost: NO_DIRECT_PROVIDER_COST_POLICY, +} as const + +export function executeCopilotReplaceProjectedWireRows( + context: CopilotTableDelegationContext | undefined, + input: ReplaceProjectedWireRowsInput +) { + return executeCopilotTableUseCase(context, replaceProjectedWireRows, input, { + tableId: input.tableId, + }) +} + +export const copilotCreateWorkflowTableGroupPolicy = { + rate: INHERITED_COPILOT_RATE_POLICY, + cost: NO_DIRECT_PROVIDER_COST_POLICY, +} as const + +export function executeCopilotCreateWorkflowTableGroup( + context: CopilotTableDelegationContext | undefined, + input: CreateWorkflowTableGroupInput +) { + return executeCopilotTableUseCase(context, createWorkflowTableGroup, input, { + tableId: input.tableId, + }) +} + +export const copilotUpdateWorkflowTableGroupPolicy = { + rate: INHERITED_COPILOT_RATE_POLICY, + cost: NO_DIRECT_PROVIDER_COST_POLICY, +} as const + +export function executeCopilotUpdateWorkflowTableGroup( + context: CopilotTableDelegationContext | undefined, + input: UpdateWorkflowTableGroupInput +) { + return executeCopilotTableUseCase(context, updateWorkflowTableGroup, input, { + tableId: input.tableId, + }) +} + +export const copilotAddWorkflowTableGroupOutputPolicy = { + rate: INHERITED_COPILOT_RATE_POLICY, + cost: NO_DIRECT_PROVIDER_COST_POLICY, +} as const + +export function executeCopilotAddWorkflowTableGroupOutput( + context: CopilotTableDelegationContext | undefined, + input: AddTableGroupOutputInput +) { + return executeCopilotTableUseCase(context, addWorkflowTableGroupOutput, input, { + tableId: input.tableId, + }) +} + +export const copilotCreateTableEnrichmentGroupPolicy = { + rate: INHERITED_COPILOT_RATE_POLICY, + cost: NO_DIRECT_PROVIDER_COST_POLICY, +} as const + +export function executeCopilotCreateTableEnrichmentGroup( + context: CopilotTableDelegationContext | undefined, + input: CreateTableEnrichmentGroupInput +) { + return executeCopilotTableUseCase(context, createTableEnrichmentGroup, input, { + tableId: input.tableId, + }) +} + +export const copilotCreateTableFromWorkspaceFilePolicy = { + rate: INHERITED_COPILOT_RATE_POLICY, + cost: NO_DIRECT_PROVIDER_COST_POLICY, +} as const + +export function executeCopilotCreateTableFromWorkspaceFile( + context: CopilotTableDelegationContext | undefined, + input: CreateTableFromWorkspaceFileInput +) { + return executeCopilotTableUseCase(context, createTableFromWorkspaceFile, input) +} + +export const copilotImportWorkspaceFileIntoTablePolicy = { + rate: INHERITED_COPILOT_RATE_POLICY, + cost: NO_DIRECT_PROVIDER_COST_POLICY, +} as const + +export function executeCopilotImportWorkspaceFileIntoTable( + context: CopilotTableDelegationContext | undefined, + input: ImportWorkspaceFileInput +) { + return executeCopilotTableUseCase(context, importWorkspaceFileIntoTable, input, { + tableId: input.tableId, + }) +} diff --git a/apps/sim/lib/copilot/auth/table-delegation.test.ts b/apps/sim/lib/copilot/auth/table-delegation.test.ts deleted file mode 100644 index 33dc57ea01f..00000000000 --- a/apps/sim/lib/copilot/auth/table-delegation.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { resolveCopilotTablePrincipal } from '@/lib/copilot/auth/table-delegation' - -describe('Copilot table delegation', () => { - const context = { - userId: 'user-1', - workspaceId: 'workspace-1', - toolCallId: 'tool-call-1', - chatId: 'chat-1', - executionId: 'execution-1', - copilotToolExecution: true, - } as const - - it('binds the trusted workspace, subject, tool call, and table scope', () => { - expect(resolveCopilotTablePrincipal(context, 'table-1')).toMatchObject({ - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'copilot-tool:tool-call-1', - audience: 'sim:tables', - resourceScope: { - tableId: 'table-1', - chatId: 'chat-1', - executionId: 'execution-1', - }, - }) - }) - - it('rejects untrusted or incomplete contexts', () => { - expect(() => - resolveCopilotTablePrincipal({ ...context, copilotToolExecution: false }, 'table-1') - ).toThrow('trusted Copilot execution context') - expect(() => - resolveCopilotTablePrincipal({ ...context, workspaceId: undefined }, 'table-1') - ).toThrow('workspace ID') - expect(() => - resolveCopilotTablePrincipal({ ...context, toolCallId: undefined }, 'table-1') - ).toThrow('tool call ID') - }) -}) diff --git a/apps/sim/lib/copilot/auth/table-delegation.ts b/apps/sim/lib/copilot/auth/table-delegation.ts index 83a055b99c1..7e516616fcb 100644 --- a/apps/sim/lib/copilot/auth/table-delegation.ts +++ b/apps/sim/lib/copilot/auth/table-delegation.ts @@ -1,28 +1,8 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' -import { - COPILOT_APPLICATION_DELEGATION_TTL_MS, - type CopilotExecutionContext, - createCopilotApplicationPrincipal, - requireTrustedCopilotExecutionContext, -} from '@/lib/copilot/auth/application-delegation' -import { tableDelegationPolicy } from '@/lib/table/application/authorization' +import type { CopilotExecutionContext } from '@/lib/copilot/auth/application-delegation' export type CopilotTableDelegationContext = CopilotExecutionContext -/** Normalizes trusted Copilot execution context into the shared table principal. */ -export function resolveCopilotTablePrincipal( - context: CopilotTableDelegationContext | undefined, - tableId?: string -): DelegatedPrincipal { - return createCopilotApplicationPrincipal(requireTrustedCopilotExecutionContext(context), { - audience: tableDelegationPolicy.audience, - ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, - createDelegationId: (trustedContext) => `copilot-tool:${trustedContext.toolCallId}`, - resourceScope: tableId ? { tableId } : undefined, - }) -} - export function messageForCopilotTableError( error: unknown, fallback = 'Table operation failed' diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index 9d0eb39c9a3..42d15ce282c 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -6,26 +6,25 @@ import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table' -const { mockReadTable, mockReplaceTableRows, mockSpanAddEvent } = vi.hoisted(() => ({ - mockReadTable: vi.fn(), - mockReplaceTableRows: vi.fn(), - mockSpanAddEvent: vi.fn(), +const mocks = vi.hoisted(() => ({ + executeReplace: vi.fn(), + spanAddEvent: vi.fn(), })) -vi.mock('@/lib/table/application/tables', () => ({ - readTableUseCase: { execute: mockReadTable }, +vi.mock('@/lib/copilot/application/table-commands', () => ({ + executeCopilotReplaceProjectedWireRows: mocks.executeReplace, })) - -vi.mock('@/lib/table/application/rows', () => ({ - replaceTableRows: { execute: mockReplaceTableRows }, -})) - vi.mock('@/lib/copilot/request/otel', () => ({ withCopilotSpan: ( _name: string, _attrs: Record<string, unknown> | undefined, - fn: (span: unknown) => Promise<unknown> - ) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: mockSpanAddEvent }), + run: (span: unknown) => Promise<unknown> + ) => + run({ + setAttribute: vi.fn(), + setAttributes: vi.fn(), + addEvent: mocks.spanAddEvent, + }), })) import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' @@ -35,45 +34,34 @@ import { maybeWriteReadCsvToTable, } from '@/lib/copilot/request/tools/tables' import type { ExecutionContext } from '@/lib/copilot/request/types' +import { ProjectedWireRowsValidationError } from '@/lib/table/application/rows' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +const table: TableDefinition = { + id: 'table-1', + name: 'People', + description: null, + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' }] }, + metadata: null, + rowCount: 0, + maxRows: 100, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), +} + const tableLogger = vi.mocked(loggerMock.createLogger).mock.results[ vi .mocked(loggerMock.createLogger) .mock.calls.findIndex(([name]) => name === 'CopilotToolResultTables') ]?.value -function buildTable(overrides: Partial<TableDefinition> = {}): TableDefinition { - return { - id: 'tbl_1', - name: 'People', - description: null, - schema: { - columns: [ - { id: 'col_name', name: 'name', type: 'string' }, - { id: 'col_age', name: 'age', type: 'number' }, - { id: 'col_status', name: 'status', type: 'string' }, - { id: 'col_active', name: 'active', type: 'boolean' }, - { id: 'col_metadata', name: 'metadata', type: 'json' }, - ], - }, - metadata: null, - rowCount: 0, - maxRows: 100, - workspaceId: 'workspace-1', - createdBy: 'user-1', - locks: { schemaLocked: false, insertLocked: false, updateLocked: false, deleteLocked: false }, - archivedAt: null, - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - ...overrides, - } as TableDefinition -} - function buildContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext { return { userId: 'user-1', - workflowId: 'wf-1', + workflowId: 'workflow-1', workspaceId: 'workspace-1', userPermission: 'write', copilotToolExecution: true, @@ -83,424 +71,244 @@ function buildContext(overrides: Partial<ExecutionContext> = {}): ExecutionConte } } -describe('maybeWriteOutputToTable', () => { +describe('automatic Copilot tool-output table persistence', () => { beforeEach(() => { vi.clearAllMocks() - mockReadTable.mockResolvedValue({ table: buildTable(), folderPath: '/' }) - mockReplaceTableRows.mockImplementation(async ({ input }: { input: { rows: unknown[] } }) => ({ - deletedCount: 0, - insertedCount: input.rows.length, - })) + mocks.executeReplace.mockImplementation( + async (_context: ExecutionContext, input: { sourceRows: unknown[] }) => ({ + table, + deletedCount: 0, + insertedCount: input.sourceRows.length, + }) + ) }) - it('rejects a table from another workspace without touching it', async () => { - mockReadTable.mockRejectedValue(new Error('Table not found')) + it('maps tool rows into one authorized schema-locked replacement command', async () => { + const context = buildContext() + const rows = [ + { name: 'Ada', age: 30 }, + { name: 'Grace', age: 40 }, + ] const result = await maybeWriteOutputToTable( FunctionExecute.id, - { outputTable: 'tbl_1' }, - { success: true, output: { result: [{ name: 'Alice' }] } }, - buildContext() + { outputTable: 'table-1' }, + { success: true, output: { result: rows } }, + context ) expect(result).toEqual({ - success: false, - error: 'Failed to write to table: Table operation failed', - }) - expect(mockReplaceTableRows).not.toHaveBeenCalled() - }) - - it('denies a read-only principal without touching the table', async () => { - const result = await maybeWriteOutputToTable( - FunctionExecute.id, - { outputTable: 'tbl_1' }, - { success: true, output: { result: [{ name: 'Alice' }] } }, - buildContext({ userPermission: 'read' }) - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('requires write access') - expect(mockReadTable).not.toHaveBeenCalled() - expect(mockReplaceTableRows).not.toHaveBeenCalled() - }) - - it('replaces rows through the service with name keys remapped to column ids', async () => { - const result = await maybeWriteOutputToTable( - FunctionExecute.id, - { outputTable: 'tbl_1' }, - { - success: true, - output: { - result: [ - { name: 'Alice', age: 30 }, - { name: 'Bob', age: 40 }, - ], - }, + success: true, + output: { + message: 'Wrote 2 rows to table table-1', + tableId: 'table-1', + rowCount: 2, }, - buildContext() - ) - - expect(result.success).toBe(true) - expect(mockReplaceTableRows).toHaveBeenCalledTimes(1) - const [{ input }] = mockReplaceTableRows.mock.calls[0] - expect(input).toMatchObject({ - tableId: 'tbl_1', + }) + expect(mocks.executeReplace).toHaveBeenCalledTimes(1) + expect(mocks.executeReplace).toHaveBeenCalledWith(context, { + tableId: 'table-1', assertedWorkspaceId: 'workspace-1', - rows: [ - { name: 'Alice', age: 30 }, - { name: 'Bob', age: 40 }, - ], + sourceRows: rows, + projectedRows: rows, }) }) - it('projects activated secrets before persistence without rewriting sibling literals', async () => { - const parentRegistry = new ResolvedSecretTraceRegistry([ - { - name: 'OUTPUT_SECRET', - plaintext: 'secret-value', - encryptedValue: 'encrypted-output-secret', - }, - { - name: 'UNRELATED', - plaintext: 'true', - encryptedValue: 'encrypted-unrelated', - }, + it('projects active secrets before handing rows to the application command', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'OUTPUT_SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret' }, ]) - parentRegistry.recordResolved('UNRELATED', 'true') - const toolRegistry = parentRegistry.forkForToolInput({ code: 'return {{OUTPUT_SECRET}}' }) - toolRegistry.recordResolved('OUTPUT_SECRET', 'secret-value') - const runtimeRows = [{ name: 'secret-value', age: '123', status: 'true' }] + registry.recordResolved('OUTPUT_SECRET', 'secret-value') + const runtimeRows = [{ name: 'secret-value', status: 'literal' }] const result = await maybeWriteOutputToTable( FunctionExecute.id, - { outputTable: 'tbl_1' }, + { outputTable: 'table-1' }, { success: true, output: { result: runtimeRows } }, - buildContext({ resolvedSecretTraceRegistry: toolRegistry }) + buildContext({ resolvedSecretTraceRegistry: registry }) ) expect(result.success).toBe(true) - const persistedRows = mockReplaceTableRows.mock.calls[0][0].input.rows - expect(persistedRows).toEqual([{ name: '{{OUTPUT_SECRET}}', age: '123', status: 'true' }]) - expect(runtimeRows).toEqual([{ name: 'secret-value', age: '123', status: 'true' }]) - - const modelFacing = projectToolResultForCopilot( - { success: true, output: { data: { rows: persistedRows } } }, - toolRegistry + expect(mocks.executeReplace).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + sourceRows: runtimeRows, + projectedRows: [{ name: '{{OUTPUT_SECRET}}', status: 'literal' }], + }) ) - expect(modelFacing.output).toEqual({ - data: { - rows: [{ name: '{{OUTPUT_SECRET}}', age: '123', status: 'true' }], - }, - }) + expect(runtimeRows).toEqual([{ name: 'secret-value', status: 'literal' }]) + const projectedRows = mocks.executeReplace.mock.calls[0][1].projectedRows const laterRead = projectToolResultForCopilot( - { success: true, output: { data: { rows: persistedRows } } }, + { success: true, output: { data: { rows: projectedRows } } }, new ResolvedSecretTraceRegistry() ) - expect(laterRead.output).toEqual({ data: { rows: persistedRows } }) + expect(laterRead.output).toEqual({ data: { rows: projectedRows } }) }) - it('does not write when table persistence provenance is incomplete', async () => { + it('rejects unavailable secret provenance before the application command', async () => { const registry = new ResolvedSecretTraceRegistry() registry.markIncomplete() - const result = await maybeWriteOutputToTable( - FunctionExecute.id, - { outputTable: 'tbl_1' }, - { success: true, output: { result: [{ name: 'unknown' }] } }, - buildContext({ resolvedSecretTraceRegistry: registry }) - ) - - expect(result).toEqual({ + await expect( + maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'table-1' }, + { success: true, output: { result: [{ name: 'unknown' }] } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + ).resolves.toEqual({ success: false, error: 'Tool output could not be persisted safely because secret provenance was unavailable.', }) - expect(mockReplaceTableRows).not.toHaveBeenCalled() - }) - - it('preserves legacy table writes when execution provenance is unavailable', async () => { - const result = await maybeWriteOutputToTable( - FunctionExecute.id, - { outputTable: 'tbl_1' }, - { success: true, output: { result: [{ name: 'unknown' }] } }, - buildContext({ resolvedSecretTraceRegistry: undefined }) - ) - - expect(result.success).toBe(true) - expect(mockReplaceTableRows).toHaveBeenCalledWith( - expect.objectContaining({ input: expect.objectContaining({ rows: [{ name: 'unknown' }] }) }) - ) - }) - - it('fails fast when no row keys match the table columns', async () => { - const result = await maybeWriteOutputToTable( - FunctionExecute.id, - { outputTable: 'tbl_1' }, - { success: true, output: { result: [{ wrong: 1 }, { keys: 2 }] } }, - buildContext() - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('Row 1 has no keys matching columns') - expect(mockReplaceTableRows).not.toHaveBeenCalled() - }) - - it('fails fast when only some rows match instead of writing empty rows', async () => { - const result = await maybeWriteOutputToTable( - FunctionExecute.id, - { outputTable: 'tbl_1' }, - { success: true, output: { result: [{ name: 'Alice' }, { wrong: 'x' }] } }, - buildContext() - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('Row 2 has no keys matching columns') - expect(mockReplaceTableRows).not.toHaveBeenCalled() + expect(mocks.executeReplace).not.toHaveBeenCalled() }) - it('surfaces service validation failures as tool errors', async () => { - mockReplaceTableRows.mockRejectedValue(new Error('Row 1: name is required')) - - const result = await maybeWriteOutputToTable( - FunctionExecute.id, - { outputTable: 'tbl_1' }, - { success: true, output: { result: [{ age: 30 }] } }, - buildContext() + it('preserves typed application validation for a correctable tool error', async () => { + mocks.executeReplace.mockRejectedValueOnce( + new ProjectedWireRowsValidationError('Row 1 has no keys matching table columns') ) - expect(result.success).toBe(false) - expect(result.error).toContain('Table operation failed') - }) - - it('fails fast when authoritative inserted count differs from the requested rows', async () => { - mockReplaceTableRows.mockResolvedValue({ deletedCount: 1, insertedCount: 1 }) - const result = await maybeWriteOutputToTable( FunctionExecute.id, - { outputTable: 'tbl_1' }, - { success: true, output: { result: [{ name: 'Alice' }, { name: 'Bob' }] } }, + { outputTable: 'table-1' }, + { success: true, output: { result: [{ wrong: true }] } }, buildContext() ) - expect(result.success).toBe(false) - expect(result.error).toContain('Table operation failed') + expect(result).toEqual({ + success: false, + error: 'Row 1 has no keys matching table columns', + }) }) - it('keeps raw errors for terminal projection but projects application logs and OTel events', async () => { + it('conceals unknown application failures in results, logs, and trace events', async () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' }, + { name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret' }, ]) registry.recordResolved('SECRET', 'secret-value') - mockReplaceTableRows.mockRejectedValue(new Error('Duplicate value "secret-value"')) + mocks.executeReplace.mockRejectedValueOnce(new Error('database duplicate: secret-value')) const result = await maybeWriteOutputToTable( FunctionExecute.id, - { outputTable: 'tbl_1' }, + { outputTable: 'table-1' }, { success: true, output: { result: [{ name: 'secret-value' }] } }, buildContext({ resolvedSecretTraceRegistry: registry }) ) - expect(result.error).not.toContain('secret-value') - expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('Table operation failed') - expect(JSON.stringify(tableLogger?.warn.mock.calls)).not.toContain('secret-value') - expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('Table operation failed') - expect(JSON.stringify(mockSpanAddEvent.mock.calls)).not.toContain('secret-value') - }) -}) - -describe('maybeWriteReadCsvToTable', () => { - beforeEach(() => { - vi.clearAllMocks() - mockReadTable.mockResolvedValue({ table: buildTable(), folderPath: '/' }) - mockReplaceTableRows.mockImplementation(async ({ input }: { input: { rows: unknown[] } }) => ({ - deletedCount: 0, - insertedCount: input.rows.length, - })) - }) - - it('rejects a table from another workspace without touching it', async () => { - mockReadTable.mockRejectedValue(new Error('Table not found')) - - const result = await maybeWriteReadCsvToTable( - ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'name,age\nAlice,30' } }, - buildContext() - ) - expect(result).toEqual({ success: false, - error: 'Failed to import into table: Table operation failed', + error: 'Failed to write to table: Table operation failed', }) - expect(mockReplaceTableRows).not.toHaveBeenCalled() + expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('Table operation failed') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).not.toContain('secret-value') + expect(JSON.stringify(mocks.spanAddEvent.mock.calls)).toContain('Table operation failed') + expect(JSON.stringify(mocks.spanAddEvent.mock.calls)).not.toContain('secret-value') }) - it('denies a read-only principal without touching the table', async () => { - const result = await maybeWriteReadCsvToTable( - ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'name,age\nAlice,30' } }, + it('rejects read-only Copilot execution before any application command', async () => { + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'table-1' }, + { success: true, output: { result: [{ name: 'Ada' }] } }, buildContext({ userPermission: 'read' }) ) expect(result.success).toBe(false) expect(result.error).toContain('requires write access') - expect(mockReadTable).not.toHaveBeenCalled() - expect(mockReplaceTableRows).not.toHaveBeenCalled() + expect(mocks.executeReplace).not.toHaveBeenCalled() }) - it('imports CSV content through the service with id-keyed rows', async () => { - const result = await maybeWriteReadCsvToTable( - ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'name,age\nAlice,30\nBob,40' } }, + it('fails closed when the authoritative inserted count is inconsistent', async () => { + mocks.executeReplace.mockResolvedValueOnce({ table, deletedCount: 1, insertedCount: 1 }) + + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'table-1' }, + { success: true, output: { result: [{ name: 'Ada' }, { name: 'Grace' }] } }, buildContext() ) - expect(result.success).toBe(true) - const [{ input }] = mockReplaceTableRows.mock.calls[0] - expect(input.rows).toEqual([ - { name: 'Alice', age: '30' }, - { name: 'Bob', age: '40' }, - ]) + expect(result).toEqual({ + success: false, + error: 'Failed to write to table: Table operation failed', + }) }) +}) - it('projects active secret literals into string-compatible CSV columns', async () => { - const registry = new ResolvedSecretTraceRegistry([ - { name: 'NUMBER', plaintext: '123', encryptedValue: 'encrypted-number' }, - { name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'encrypted-boolean' }, - ]) - registry.recordResolved('NUMBER', '123') - registry.recordResolved('BOOLEAN', 'true') - - const result = await maybeWriteReadCsvToTable( - ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'name,status\n123,true' } }, - buildContext({ resolvedSecretTraceRegistry: registry }) - ) - - expect(result.success).toBe(true) - expect(mockReplaceTableRows).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ - rows: [ - { - name: '{{NUMBER}}', - status: '{{BOOLEAN}}', - }, - ], - }), +describe('automatic Copilot file-read table persistence', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.executeReplace.mockImplementation( + async (_context: ExecutionContext, input: { sourceRows: unknown[] }) => ({ + table, + deletedCount: 1, + insertedCount: input.sourceRows.length, }) ) }) - it('rejects active secret literals in number and boolean columns before mutation', async () => { - const registry = new ResolvedSecretTraceRegistry([ - { name: 'NUMBER', plaintext: '123', encryptedValue: 'encrypted-number' }, - { name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'encrypted-boolean' }, - ]) - registry.recordResolved('NUMBER', '123') - registry.recordResolved('BOOLEAN', 'true') - + it('keeps CSV parsing and presentation in the adapter and performs one application command', async () => { + const context = buildContext() const result = await maybeWriteReadCsvToTable( ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'name,age,active\nAlice,123,true' } }, - buildContext({ resolvedSecretTraceRegistry: registry }) + { outputTable: 'table-1', path: 'files/people.csv' }, + { success: true, output: { content: 'name,age\nAda,30\nGrace,40' } }, + context ) expect(result).toEqual({ - success: false, - error: - 'Tool output could not be persisted safely because a resolved secret is incompatible with the target column type.', + success: true, + output: { + message: 'Imported 2 rows from "files/people.csv" into table "People"', + tableId: 'table-1', + tableName: 'People', + rowCount: 2, + }, + }) + expect(mocks.executeReplace).toHaveBeenCalledTimes(1) + expect(mocks.executeReplace).toHaveBeenCalledWith(context, { + tableId: 'table-1', + assertedWorkspaceId: 'workspace-1', + sourceRows: [ + { name: 'Ada', age: '30' }, + { name: 'Grace', age: '40' }, + ], + projectedRows: [ + { name: 'Ada', age: '30' }, + { name: 'Grace', age: '40' }, + ], }) - expect(mockReplaceTableRows).not.toHaveBeenCalled() - expect(JSON.stringify(result)).not.toContain('123') - expect(JSON.stringify(result)).not.toContain('true') }) - it('does not import CSV rows when persistence provenance is incomplete', async () => { - const registry = new ResolvedSecretTraceRegistry() - registry.markIncomplete() - + it('keeps JSON shape validation in the adapter', async () => { const result = await maybeWriteReadCsvToTable( ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'name\nAlice' } }, - buildContext({ resolvedSecretTraceRegistry: registry }) + { outputTable: 'table-1', path: 'files/people.json' }, + { success: true, output: { content: '{"name":"Ada"}' } }, + buildContext() ) expect(result).toEqual({ success: false, - error: 'Tool output could not be persisted safely because secret provenance was unavailable.', + error: 'JSON file must contain an array of objects for table import', }) - expect(mockReplaceTableRows).not.toHaveBeenCalled() - }) - - it('preserves legacy CSV imports when execution provenance is unavailable', async () => { - const result = await maybeWriteReadCsvToTable( - ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'name,age,active\nlegacy-value,123,true' } }, - buildContext({ resolvedSecretTraceRegistry: undefined }) - ) - - expect(result.success).toBe(true) - expect(mockReplaceTableRows).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ - rows: [{ name: 'legacy-value', age: '123', active: 'true' }], - }), - }) - ) - }) - - it('fails fast when the file headers match no table columns', async () => { - const result = await maybeWriteReadCsvToTable( - ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'wrong,headers\n1,2' } }, - buildContext() - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('Row 1 has no keys matching columns') - expect(mockReplaceTableRows).not.toHaveBeenCalled() + expect(mocks.executeReplace).not.toHaveBeenCalled() }) - it('surfaces service validation failures as tool errors', async () => { - mockReplaceTableRows.mockRejectedValue(new Error('Row 1: name is required')) + it('preserves safe unknown-error projection for CSV persistence', async () => { + mocks.executeReplace.mockRejectedValueOnce(new Error('database unavailable')) const result = await maybeWriteReadCsvToTable( ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'age\n30' } }, + { outputTable: 'table-1', path: 'files/people.csv' }, + { success: true, output: { content: 'name\nAda' } }, buildContext() ) - expect(result.success).toBe(false) - expect(result.error).toContain('Table operation failed') - }) - - it('projects active secret literals in CSV-import log and OTel errors', async () => { - const registry = new ResolvedSecretTraceRegistry([ - { name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' }, - ]) - registry.recordResolved('SECRET', 'secret-value') - mockReplaceTableRows.mockRejectedValue(new Error('Duplicate value "secret-value"')) - - const result = await maybeWriteReadCsvToTable( - ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'name\nsecret-value' } }, - buildContext({ resolvedSecretTraceRegistry: registry }) - ) - - expect(result.error).not.toContain('secret-value') - expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('Table operation failed') - expect(JSON.stringify(tableLogger?.warn.mock.calls)).not.toContain('secret-value') - expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('Table operation failed') - expect(JSON.stringify(mockSpanAddEvent.mock.calls)).not.toContain('secret-value') + expect(result).toEqual({ + success: false, + error: 'Failed to import into table: Table operation failed', + }) }) }) diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index edf6dcb33af..5e38eaba239 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -1,11 +1,7 @@ -import { isDeepStrictEqual } from 'node:util' import { createLogger } from '@sim/logger' -import { isPlainRecord } from '@sim/utils/object' import { parse as csvParse } from 'csv-parse/sync' -import { - messageForCopilotTableError, - resolveCopilotTablePrincipal, -} from '@/lib/copilot/auth/table-delegation' +import { executeCopilotReplaceProjectedWireRows } from '@/lib/copilot/application/table-commands' +import { messageForCopilotTableError } from '@/lib/copilot/auth/table-delegation' import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' import { CopilotTableOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -18,40 +14,17 @@ import { projectToolOutputForPersistence, } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import type { RowData, TableDefinition } from '@/lib/table' -import { replaceTableRows } from '@/lib/table/application/rows' -import { readTableUseCase } from '@/lib/table/application/tables' -import { columnTypeOf } from '@/lib/table/column-types' -import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' +import type { TableDefinition } from '@/lib/table' +import { ProjectedWireRowsValidationError } from '@/lib/table/application/rows' const logger = createLogger('CopilotToolResultTables') const MAX_OUTPUT_TABLE_ROWS = 10_000 -const TABLE_SECRET_PROJECTION_UNSUPPORTED_ERROR = - 'Tool output could not be persisted safely because a resolved secret is incompatible with the target column type.' - -function hasUnsupportedProjectedCell( - table: TableDefinition, - sourceRows: Array<Record<string, unknown>>, - projectedRows: Array<Record<string, unknown>> -): boolean { - const columnsByName = new Map(table.schema.columns.map((column) => [column.name, column])) - for (let rowIndex = 0; rowIndex < projectedRows.length; rowIndex += 1) { - for (const [name, projectedValue] of Object.entries(projectedRows[rowIndex])) { - const column = columnsByName.get(name) - if (!column || isDeepStrictEqual(sourceRows[rowIndex]?.[name], projectedValue)) continue - const type = columnTypeOf(column).id - if (type !== 'string' && type !== 'json') return true - } - } - return false -} - /** * Replaces a table's rows with wire rows keyed by column name. Translates the - * keys to stable column ids (unknown keys are dropped, matching every other - * name-translating boundary) and delegates to `replaceTableRows`, which owns - * locking, validation, plan row limits, batching, and rowCount maintenance. + * projected values through one authorized application command. That command + * validates and translates against the table schema it holds under the schema + * lock before performing the atomic replacement. */ async function replaceTableRowsFromWire( tableId: string, @@ -61,53 +34,34 @@ async function replaceTableRowsFromWire( | { success: false; error: string } | { success: true; table: TableDefinition; insertedCount: number; deletedCount: number } > { - const principal = resolveCopilotTablePrincipal(context, tableId) - const { table } = await readTableUseCase.execute({ - principal, - input: { tableId, workspaceId: principal.workspaceId }, - }) + const workspaceId = context.workspaceId + if (!workspaceId) throw new Error('Table persistence requires a workspace ID') const persistenceProjection = context.resolvedSecretTraceRegistry ? projectToolOutputForPersistence(rows, context.resolvedSecretTraceRegistry) : { safe: true as const, value: rows } if (!persistenceProjection.safe) { return { success: false, error: persistenceProjection.error } } - if ( - !Array.isArray(persistenceProjection.value) || - !persistenceProjection.value.every(isPlainRecord) - ) { - return { success: false, error: 'Table rows could not be persisted safely' } - } - if (hasUnsupportedProjectedCell(table, rows, persistenceProjection.value)) { - return { success: false, error: TABLE_SECRET_PROJECTION_UNSUPPORTED_ERROR } - } - - const projectedRows = persistenceProjection.value.map((row) => row as RowData) - const columnNames = new Set(table.schema.columns.map((column) => column.name)) - const emptyIndex = projectedRows.findIndex( - (row) => !Object.keys(row).some((name) => columnNames.has(name)) - ) - if (emptyIndex !== -1) { - return { - success: false, - error: `Row ${emptyIndex + 1} has no keys matching columns on table "${table.name}" (columns: ${table.schema.columns.map((c) => c.name).join(', ')})`, + let replacement: Awaited<ReturnType<typeof executeCopilotReplaceProjectedWireRows>> + try { + replacement = await executeCopilotReplaceProjectedWireRows(context, { + tableId, + assertedWorkspaceId: workspaceId, + sourceRows: rows, + projectedRows: persistenceProjection.value, + }) + } catch (error) { + if (error instanceof ProjectedWireRowsValidationError) { + return { success: false, error: error.message } } + throw error } - const replacement = await replaceTableRows.execute({ - principal, - input: { - tableId: table.id, - assertedWorkspaceId: principal.workspaceId, - rows: projectedRows, - secretProvenance: projectedRows.map(createExactEmptyTableRowSecretProvenance), - }, - }) - if (replacement.insertedCount !== projectedRows.length) { + if (replacement.insertedCount !== rows.length) { throw new Error('Table row replacement inserted an unexpected row count') } return { success: true, - table, + table: replacement.table, insertedCount: replacement.insertedCount, deletedCount: replacement.deletedCount, } diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 5c1fbcc5a22..83c263815a2 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ -import { getErrorMessage } from '@sim/utils/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' import type { TableDefinition } from '@/lib/table' const { @@ -23,11 +23,16 @@ const { mockReleaseJobClaim, mockQueryRows, mockDeleteRowsByFilter, + mockDeleteColumns, mockUpdateRowsByFilter, mockRunTableImport, mockRunTableDelete, mockRunTableUpdate, - mockExecuteCopilotTableUseCase, + mockExecuteCopilotFileUseCase, + mockExecuteCopilotWorkflowUseCase, + mockLoadWorkspaceFileContext, + mockLoadTableRowSecretProvenance, + mockResolveWorkflowContext, fakeEnrichment, } = vi.hoisted(() => ({ mockUpdateColumnType: vi.fn(), @@ -46,11 +51,16 @@ const { mockReleaseJobClaim: vi.fn(), mockQueryRows: vi.fn(), mockDeleteRowsByFilter: vi.fn(), + mockDeleteColumns: vi.fn(), mockUpdateRowsByFilter: vi.fn(), mockRunTableImport: vi.fn(), mockRunTableDelete: vi.fn(), mockRunTableUpdate: vi.fn(), - mockExecuteCopilotTableUseCase: vi.fn(), + mockExecuteCopilotFileUseCase: vi.fn(), + mockExecuteCopilotWorkflowUseCase: vi.fn(), + mockLoadWorkspaceFileContext: vi.fn(), + mockLoadTableRowSecretProvenance: vi.fn(), + mockResolveWorkflowContext: vi.fn(), fakeEnrichment: { id: 'work-email', name: 'Work Email', @@ -71,11 +81,12 @@ vi.mock('@sim/utils/id', () => ({ })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, + resolveWorkspaceFileReference: async (workspaceId: string, reference: string) => { + const file = await mockResolveWorkspaceFileReference(workspaceId, reference) + return file ? { ...file, workspaceId: file.workspaceId ?? workspaceId } : null + }, fetchWorkspaceFileBuffer: mockDownloadWorkspaceFile, -})) -vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ - resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, + loadActiveWorkspaceFileContext: mockLoadWorkspaceFileContext, })) vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ readWorkspaceFileContent: { @@ -96,23 +107,64 @@ vi.mock('@/lib/copilot/auth/file-delegation', () => ({ })) vi.mock('@/lib/copilot/auth/table-delegation', () => ({ - messageForCopilotTableError: (error: unknown) => getErrorMessage(error, 'Table operation failed'), - resolveCopilotTablePrincipal: (_context: unknown, tableId?: string) => ({ - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'test-tool', - audience: 'sim:tables', - issuedAt: new Date(0), - expiresAt: new Date(Date.now() + 60_000), - resourceScope: tableId ? { tableId } : undefined, + messageForCopilotTableError: (error: unknown) => { + const classified = error as { code?: string; message?: string } + return classified.code && classified.code !== 'internal' + ? (classified.message ?? 'Table operation failed') + : 'Table operation failed' + }, +})) + +vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({ + executeCopilotFileUseCase: mockExecuteCopilotFileUseCase, +})) + +vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ + executeCopilotResolveWorkflowOutputs: mockExecuteCopilotWorkflowUseCase, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: vi.fn().mockResolvedValue('write'), +})) + +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: async (input: { tableId: string; assertedWorkspaceId?: string }) => { + const table = await mockGetTableById(input.tableId) + if (!table || (input.assertedWorkspaceId && table.workspaceId !== input.assertedWorkspaceId)) { + throw Object.assign(new Error('Table not found'), { code: 'not_found' }) + } + if (table.archivedAt) { + throw Object.assign(new Error('Table is archived'), { code: 'conflict' }) + } + return { + tableId: table.id, + table, + workspaceId: table.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', + } + }, + resolveTableWorkspaceContext: async (workspaceId: string) => ({ + workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', }), })) -vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ - admitCopilotTableOperation: vi.fn(), - executeCopilotTableUseCase: mockExecuteCopilotTableUseCase, +vi.mock('@/lib/table/application/folder-paths', () => ({ + resolveTableFolderPath: async () => ({ + folderId: null, + index: { idByPath: new Map(), pathById: new Map() }, + }), + tableFolderPathForId: () => '/', })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ @@ -142,7 +194,7 @@ vi.mock('@/lib/table/workflow-groups/service', () => ({ vi.mock('@/lib/table/columns/service', () => ({ addTableColumn: vi.fn(), deleteColumn: vi.fn(), - deleteColumns: vi.fn(), + deleteColumns: mockDeleteColumns, renameColumn: vi.fn(), updateColumnConstraints: vi.fn(), updateColumnType: mockUpdateColumnType, @@ -163,6 +215,16 @@ vi.mock('@/lib/table/rows/service', () => ({ updateRowsByFilter: mockUpdateRowsByFilter, })) +vi.mock('@/lib/table/rows/secret-provenance', () => ({ + createExactEmptyTableRowSecretProvenance: (data: Record<string, unknown>) => ({ + complete: true, + columns: Object.fromEntries( + Object.keys(data).map((columnId) => [columnId, { version: 1, complete: true, entries: [] }]) + ), + }), + loadTableRowSecretProvenance: mockLoadTableRowSecretProvenance, +})) + vi.mock('@/lib/table/jobs/service', () => ({ markTableJobRunningInWorkspace: mockMarkTableJobRunning, releaseJobClaimInWorkspace: mockReleaseJobClaim, @@ -186,67 +248,74 @@ vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetWorkspaceTableLimits, })) +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mockResolveWorkflowContext, +})) + +vi.mock('@/lib/workflows/application/resolve-workflow-outputs', () => ({ + loadResolvedWorkflowOutputs: async () => mockExecuteCopilotWorkflowUseCase(), + resolveWorkflowOutputs: { operation: { id: 'workflows.read' } }, +})) + import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' -import { decodeCursor, encodeCursor } from '@/lib/table/rows/cursor' +import { encodeCursor } from '@/lib/table/rows/cursor' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' beforeEach(() => { - mockExecuteCopilotTableUseCase.mockImplementation( - async ( - _context: unknown, - useCase: { operation: { id: string } }, - input: Record<string, unknown> - ) => { - const table = await mockGetTableById(input.tableId) - switch (useCase.operation.id) { - case 'tables.create': { - const limits = await mockGetWorkspaceTableLimits(input.workspaceId) - const created = await mockCreateTable({ ...input, ...limits }) - return { table: created } - } - case 'tables.rows.query': { - if (!table) throw new Error('Table not found') - if (input.cursor && Array.isArray(input.sort) && input.sort.length > 0) { - throw new Error('Cursor is not valid for a sorted query') - } - const cursor = input.cursor ? decodeCursor(String(input.cursor)) : undefined - const result = await mockQueryRows(table, { - predicate: input.predicate, - sort: input.sort, - limit: input.limit, - after: cursor?.after, - offset: cursor?.offset, - includeTotal: input.includeTotal, - withExecutions: false, - }) - return { table, ...result } - } - case 'tables.columns.update': { - if (!table) throw new Error('Table not found') - const updates = input.updates as Record<string, unknown> - const column = table.schema.columns.find( - (candidate) => candidate.name === input.columnName - ) - const next = - updates.type !== undefined && updates.type !== column?.type - ? await mockUpdateColumnType({ - tableId: input.tableId, - columnName: input.columnName, - newType: updates.type, - }) - : await mockUpdateColumnOptions({ - tableId: input.tableId, - columnName: input.columnName, - options: Array.isArray(updates.options) - ? updates.options.map((option) => - typeof option === 'string' ? { name: option } : option - ) - : column?.options, - multiple: updates.multiple, - }) - return { table: next, changed: true } - } - default: - throw new Error(`Unexpected application operation ${useCase.operation.id}`) + mockLoadWorkspaceFileContext.mockResolvedValue({ workspaceId: 'workspace-1' }) + mockLoadTableRowSecretProvenance.mockResolvedValue({ + version: 1, + complete: true, + entries: [], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }) + mockResolveWorkflowContext.mockImplementation( + async ({ + workflowId, + assertedWorkspaceId, + }: { + workflowId: string + assertedWorkspaceId: string + }) => ({ + workflowId, + workspaceId: assertedWorkspaceId, + }) + ) + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ + workflowId: 'workflow-1', + outputs: [ + { + blockId: 'block-1', + blockName: 'Agent', + blockType: 'agent', + path: 'content', + leafType: 'string', + }, + ], + executionOrderByBlockId: { 'block-1': 1 }, + }) + mockExecuteCopilotFileUseCase.mockImplementation( + async (_context: unknown, _useCase: unknown, input: Record<string, unknown>) => { + const workspaceId = String(input.workspaceId) + const reference = String(input.reference) + const file = await mockResolveWorkspaceFileReference(workspaceId, reference) + if (!file) throw new OrchestrationError('not_found', 'File not found') + const provenance = await mockGetBoundWorkspaceFileSecretProvenance(workspaceId, { + fileId: file.id, + key: file.key, + context: 'workspace', + }) + if (provenance.status !== 'exact' || provenance.entries.length > 0) { + throw new OrchestrationError( + 'validation', + `Cannot import "${reference}": the file cannot be verified as free of resolved secrets.` + ) + } + return { + file: { ...file, workspaceId }, + ...(input.maxBytes === undefined + ? {} + : { content: await mockDownloadWorkspaceFile(file, { maxBytes: input.maxBytes }) }), } } ) @@ -275,6 +344,15 @@ function buildTable(overrides: Partial<TableDefinition> = {}): TableDefinition { } } +function buildToolContext() { + return { + userId: 'user-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, + } as const +} + /** Lets a runDetached microtask chain run before asserting on the work it dispatched. */ async function flushDetached(): Promise<void> { await Promise.resolve() @@ -308,7 +386,7 @@ describe('userTableServerTool.import_file', () => { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -330,7 +408,7 @@ describe('userTableServerTool.import_file', () => { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1', mode: 'replace' }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -373,7 +451,7 @@ describe('userTableServerTool.import_file', () => { mapping: { 'Full Name': 'name', Years: 'age' }, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -390,7 +468,7 @@ describe('userTableServerTool.import_file', () => { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1', mode: 'merge' }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) expect(result.message).toMatch(/Invalid mode/) @@ -404,7 +482,7 @@ describe('userTableServerTool.import_file', () => { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) expect(result.message).toMatch(/archived/i) @@ -417,7 +495,7 @@ describe('userTableServerTool.import_file', () => { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) expect(result.message).toMatch(/not found/i) @@ -431,7 +509,7 @@ describe('userTableServerTool.import_file', () => { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) expect(result.message).toMatch(/missing required columns/i) @@ -441,7 +519,7 @@ describe('userTableServerTool.import_file', () => { it('claims and releases the table job slot around an inline import', async () => { const result = await userTableServerTool.execute( { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -462,7 +540,7 @@ describe('userTableServerTool.import_file', () => { mockMarkTableJobRunning.mockResolvedValueOnce(false) const result = await userTableServerTool.execute( { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -482,7 +560,7 @@ describe('userTableServerTool.import_file', () => { const result = await userTableServerTool.execute( { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1', mode: 'replace' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) await flushDetached() @@ -516,7 +594,7 @@ describe('userTableServerTool.import_file', () => { const result = await userTableServerTool.execute( { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -534,7 +612,7 @@ describe('userTableServerTool.import_file', () => { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'uploads/people.csv' }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -550,7 +628,7 @@ describe('userTableServerTool.import_file', () => { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'files/typo.csv' }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -559,6 +637,7 @@ describe('userTableServerTool.import_file', () => { it('rejects a background import while another job holds the table slot', async () => { mockResolveWorkspaceFileReference.mockResolvedValueOnce({ + id: 'file-1', name: 'big.csv', type: 'text/csv', key: 'workspace/workspace-1/big.csv', @@ -568,7 +647,7 @@ describe('userTableServerTool.import_file', () => { const result = await userTableServerTool.execute( { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) await flushDetached() @@ -601,7 +680,7 @@ describe('userTableServerTool.create_from_file', () => { it('stamps the workspace plan limits on the created table', async () => { const result = await userTableServerTool.execute( { operation: 'create_from_file', args: { fileId: 'file-1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -617,7 +696,7 @@ describe('userTableServerTool.create_from_file', () => { const result = await userTableServerTool.execute( { operation: 'create_from_file', args: { fileId: 'file-1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -629,18 +708,18 @@ describe('userTableServerTool.create_from_file', () => { expect(mockDeleteTable).not.toHaveBeenCalled() }) - it('rolls back the created table and reports the reason when row insertion fails', async () => { + it('rolls back the created table and safely conceals unknown insertion failures', async () => { mockBatchInsertRows.mockRejectedValueOnce(new Error('Row 2: Column "email" must be unique')) const result = await userTableServerTool.execute( { operation: 'create_from_file', args: { fileId: 'file-1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) expect(mockDeleteTable).toHaveBeenCalledWith('tbl_new', expect.any(String)) - expect(result.message).toMatch(/rolled back/i) - expect(result.message).toMatch(/must be unique/i) + expect(result.message).toBe('Operation failed: Table operation failed') + expect(result.message).not.toMatch(/must be unique/i) }) it('creates a placeholder table and dispatches a background import for large CSV files', async () => { @@ -654,7 +733,7 @@ describe('userTableServerTool.create_from_file', () => { const result = await userTableServerTool.execute( { operation: 'create_from_file', args: { fileId: 'file-1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) await flushDetached() @@ -683,7 +762,7 @@ describe('userTableServerTool.create_from_file', () => { const result = await userTableServerTool.execute( { operation: 'create_from_file', args: { fileId: 'file-1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -709,7 +788,7 @@ describe('userTableServerTool.create', () => { schema: { columns: [{ name: 'name', type: 'string', required: true }] }, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -719,6 +798,96 @@ describe('userTableServerTool.create', () => { }) }) +describe('userTableServerTool.delete_column', () => { + it('presents the authoritative canonical deletion for aliases and duplicates', async () => { + const current = buildTable({ + schema: { + columns: [ + { id: 'column-name', name: 'name', type: 'string' }, + { id: 'column-age', name: 'age', type: 'number' }, + ], + }, + }) + mockGetTableById.mockResolvedValue(current) + mockDeleteColumns.mockResolvedValue({ + ...current, + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' }] }, + }) + + const result = await userTableServerTool.execute( + { + operation: 'delete_column', + args: { + tableId: 'tbl_1', + columnNames: ['age', 'column-age', 'AGE'], + }, + }, + buildToolContext() + ) + + expect(result.success).toBe(true) + expect(result.message).toBe('Deleted 1 column: age') + expect(mockDeleteColumns).toHaveBeenCalledWith( + { tableId: 'tbl_1', columnNames: ['age', 'column-age', 'AGE'] }, + expect.any(String), + { expectedWorkspaceId: 'workspace-1' } + ) + }) +}) + +describe('userTableServerTool workflow scope', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetTableById.mockResolvedValue(buildTable()) + mockAddWorkflowGroup.mockImplementation( + async ({ group, outputColumns }: { group: unknown; outputColumns: unknown[] }) => + buildTable({ + schema: { + columns: outputColumns, + workflowGroups: [group], + } as never, + }) + ) + }) + + it('conceals a cross-workspace workflow id before persisting a group', async () => { + mockResolveWorkflowContext.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Workflow not found') + ) + + const result = await userTableServerTool.execute( + { + operation: 'add_workflow_group', + args: { + tableId: 'tbl_1', + workflowId: 'workflow-cross-workspace', + outputs: [{ blockId: 'block-1', path: 'content' }], + }, + }, + buildToolContext() + ) + + expect(result).toEqual({ success: false, message: 'Operation failed: Workflow not found' }) + expect(mockResolveWorkflowContext).toHaveBeenCalledWith({ + workflowId: 'workflow-cross-workspace', + assertedWorkspaceId: 'workspace-1', + }) + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('conceals unknown application failures from tool output', async () => { + mockQueryRows.mockRejectedValueOnce(new Error('database host unavailable')) + + const result = await userTableServerTool.execute( + { operation: 'query_rows', args: { tableId: 'tbl_1' } }, + buildToolContext() + ) + + expect(result).toEqual({ success: false, message: 'Operation failed: Table operation failed' }) + expect(result.message).not.toContain('database host unavailable') + }) +}) + describe('userTableServerTool.list_enrichments', () => { beforeEach(() => { vi.clearAllMocks() @@ -727,7 +896,7 @@ describe('userTableServerTool.list_enrichments', () => { it('returns the enrichment catalog metadata', async () => { const result = await userTableServerTool.execute( { operation: 'list_enrichments', args: {} }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -759,7 +928,15 @@ describe('userTableServerTool.add_enrichment', () => { }, }) ) - mockAddWorkflowGroup.mockResolvedValue(buildTable()) + mockAddWorkflowGroup.mockImplementation( + async ({ group, outputColumns }: { group: unknown; outputColumns: unknown[] }) => + buildTable({ + schema: { + columns: outputColumns, + workflowGroups: [group], + } as never, + }) + ) }) it('creates an enrichment group with mapped inputs and derived output columns', async () => { @@ -775,7 +952,7 @@ describe('userTableServerTool.add_enrichment', () => { ], }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -820,7 +997,7 @@ describe('userTableServerTool.add_enrichment', () => { autoRun: true, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -836,7 +1013,7 @@ describe('userTableServerTool.add_enrichment', () => { operation: 'add_enrichment', args: { tableId: 'tbl_1', enrichmentId: 'nope', inputMappings: [] }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -854,7 +1031,7 @@ describe('userTableServerTool.add_enrichment', () => { inputMappings: [{ inputName: 'fullName', columnName: 'name' }], }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -875,7 +1052,7 @@ describe('userTableServerTool.add_enrichment', () => { ], }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -907,26 +1084,52 @@ describe('userTableServerTool.query_rows', () => { }) }) - it('passes an explicit limit through unchanged (no row cap)', async () => { + it('rejects an explicit limit above the Copilot page cap before any DB call', async () => { const result = await userTableServerTool.execute( { operation: 'query_rows', args: { tableId: 'tbl_1', limit: 100000 } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) - expect(result.success).toBe(true) - const options = mockQueryRows.mock.calls[0][1] as Record<string, unknown> - expect(options.limit).toBe(100000) + expect(result.success).toBe(false) + expect(result.message).toBe('Limit cannot exceed 1000') + expect(mockGetTableById).not.toHaveBeenCalled() + expect(mockQueryRows).not.toHaveBeenCalled() }) - it('omits the limit so queryRows returns every matching row', async () => { + it('defaults an omitted Copilot page limit to the surface maximum', async () => { const result = await userTableServerTool.execute( { operation: 'query_rows', args: { tableId: 'tbl_1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) const options = mockQueryRows.mock.calls[0][1] as Record<string, unknown> - expect(options.limit).toBeUndefined() + expect(options.limit).toBe(1000) + }) + + it('imports application-owned persisted provenance into the tool trace registry', async () => { + const registry = new ResolvedSecretTraceRegistry() + const importProvenance = vi.spyOn(registry, 'importCrossingProvenance') + + const result = await userTableServerTool.execute( + { operation: 'query_rows', args: { tableId: 'tbl_1', limit: 2 } }, + { ...buildToolContext(), resolvedSecretTraceRegistry: registry } + ) + + expect(result.success).toBe(true) + expect(mockLoadTableRowSecretProvenance).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: 'row_1' })]), + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + expect(importProvenance).toHaveBeenCalledWith( + expect.objectContaining({ + version: 1, + complete: true, + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }), + expect.arrayContaining([{ name: 'r1' }]), + { trusted: true } + ) }) it('normalizes a root condition before querying', async () => { @@ -935,7 +1138,7 @@ describe('userTableServerTool.query_rows', () => { operation: 'query_rows', args: { tableId: 'tbl_1', filter: { field: 'name', op: 'eq', value: 'r1' } }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -952,7 +1155,7 @@ describe('userTableServerTool.query_rows', () => { }) const result = await userTableServerTool.execute( { operation: 'query_rows', args: { tableId: 'tbl_1', limit: 2, cursor } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -974,7 +1177,7 @@ describe('userTableServerTool.query_rows', () => { }) const result = await userTableServerTool.execute( { operation: 'query_rows', args: { tableId: 'tbl_1', limit: 2 } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.message).toContain('more available') @@ -993,7 +1196,7 @@ describe('userTableServerTool.query_rows', () => { operation: 'query_rows', args: { tableId: 'tbl_1', cursor, order: [{ field: 'name', direction: 'desc' }] }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -1035,7 +1238,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { limit: 5000, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) await flushDetached() @@ -1058,7 +1261,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { operation: 'delete_rows_by_filter', args: { tableId: 'tbl_1', filter: { all: [{ field: 'name', op: 'eq', value: 'x' }] } }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -1080,7 +1283,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { operation: 'delete_rows_by_filter', args: { tableId: 'tbl_1', filter: { field: 'name', op: 'eq', value: 'x' } }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -1099,7 +1302,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { limit: 100, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -1121,7 +1324,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { operation: 'delete_rows_by_filter', args: { tableId: 'tbl_1', filter: { all: [{ field: 'name', op: 'eq', value: 'x' }] } }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) await flushDetached() @@ -1160,7 +1363,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { operation: 'delete_rows_by_filter', args: { tableId: 'tbl_1', filter: { all: [{ field: 'name', op: 'eq', value: 'x' }] } }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -1179,7 +1382,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { limit: 100, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -1215,7 +1418,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { limit: 5000, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) await flushDetached() @@ -1239,7 +1442,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { data: { age: 1 }, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) expect(result.data?.affectedCount).toBe(5) @@ -1257,7 +1460,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { data: { age: 1 }, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -1281,7 +1484,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { data: { age: 1 }, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) await flushDetached() @@ -1322,7 +1525,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { data: { email: 'y' }, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) expect(mockQueryRows).not.toHaveBeenCalled() @@ -1348,7 +1551,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { data: { age: 1 }, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) expect(result.message).toMatch(/job is already in progress/i) @@ -1367,7 +1570,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { limit: 100, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) expect(mockQueryRows).not.toHaveBeenCalled() @@ -1409,7 +1612,7 @@ describe('userTableServerTool.update_column — select routing', () => { options: ['Open', 'Closed'], }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } as never + buildToolContext() ) expect(mockUpdateColumnType).not.toHaveBeenCalled() @@ -1425,7 +1628,7 @@ describe('userTableServerTool.update_column — select routing', () => { operation: 'update_column', args: { tableId: 'tbl_1', columnName: 'status', newType: 'string' }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } as never + buildToolContext() ) expect(mockUpdateColumnType).toHaveBeenCalledTimes(1) @@ -1438,7 +1641,7 @@ describe('userTableServerTool.update_column — select routing', () => { operation: 'update_column', args: { tableId: 'tbl_1', columnName: 'status', multiple: true }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } as never + buildToolContext() ) expect(mockUpdateColumnOptions).toHaveBeenCalledTimes(1) @@ -1456,13 +1659,13 @@ describe('userTableServerTool.delete bounds', () => { operation: 'delete', args: { tableIds: Array.from({ length: 101 }, (_, index) => `table-${index}`) }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result).toEqual({ success: false, message: 'Cannot delete more than 100 tables at once', }) - expect(mockExecuteCopilotTableUseCase).not.toHaveBeenCalled() + expect(mockGetTableById).not.toHaveBeenCalled() }) }) 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 fca3d5c1e97..2abb2117184 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -1,50 +1,39 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' +import { toError } from '@sim/utils/errors' +import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' +import { executeCopilotResolveWorkflowOutputs } from '@/lib/copilot/application/execute-workflow-use-case' import { - admitCopilotTableOperation, - executeCopilotTableUseCase, -} from '@/lib/copilot/application/execute-table-use-case' -import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' -import { - messageForCopilotTableError, - resolveCopilotTablePrincipal, -} from '@/lib/copilot/auth/table-delegation' + executeCopilotAddWorkflowTableGroupOutput, + executeCopilotCreateTableEnrichmentGroup, + executeCopilotCreateTableFromWorkspaceFile, + executeCopilotCreateWorkflowTableGroup, + executeCopilotDeleteTables, + executeCopilotImportWorkspaceFileIntoTable, + executeCopilotUpdateWorkflowTableGroup, +} from '@/lib/copilot/application/table-commands' +import { messageForCopilotTableError } from '@/lib/copilot/auth/table-delegation' import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' -import { runDetached } from '@/lib/core/utils/background' -import { - buildAutoMapping, - COLUMN_TYPES, - CSV_ASYNC_IMPORT_THRESHOLD_BYTES, - CSV_MAX_BATCH_SIZE, - type CsvHeaderMapping, - CsvImportValidationError, - coerceRowsForTable, - getWorkspaceTableLimits, - inferSchemaFromCsv, - parseFileRows, - sanitizeName, - TABLE_LIMITS, - validateMapping, -} from '@/lib/table' +import { COLUMN_TYPES, CSV_MAX_BATCH_SIZE, type CsvHeaderMapping, TABLE_LIMITS } from '@/lib/table' import { addTableColumnUseCase, + deleteTableColumnsUseCase, deleteTableColumnUseCase, updateTableColumnUseCase, } from '@/lib/table/application/columns' import { - createTableGroupUseCase, + copilotBatchUpdateRows, + copilotDeleteRowsByFilter, + copilotUpdateRowsByFilter, +} from '@/lib/table/application/copilot-bulk-rows' +import { + deleteTableGroupOutputUseCase, deleteTableGroupUseCase, - updateTableGroupUseCase, } from '@/lib/table/application/groups' -import { type TableOperation, tableOperations } from '@/lib/table/application/operations' import { createTableRows, deleteTableRow, @@ -56,72 +45,23 @@ import { import { cancelTableRuns, startTableRun } from '@/lib/table/application/runs' import { createTableUseCase, - deleteTableUseCase, readTableUseCase, updateTableUseCase, } from '@/lib/table/application/tables' import { namedRowMapper } from '@/lib/table/cell-format' -import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' -import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' -import { deleteColumns } from '@/lib/table/columns/service' import { isSupportedCurrencyCode } from '@/lib/table/currency' -import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner' -import { signalTableRowsChanged, signalTableSchemaChanged } from '@/lib/table/events' -import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' -import { - markTableJobRunningInWorkspace, - releaseJobClaimInWorkspace, -} from '@/lib/table/jobs/service' -import { assertRowDelete, assertRowUpdate, patchColumnIds } from '@/lib/table/mutation-locks' -import { predicateToFilter } from '@/lib/table/query-builder/converters' import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate' -import { validatePredicate } from '@/lib/table/query-builder/validate' -import { - createExactEmptyTableRowSecretProvenance, - loadTableRowSecretProvenance, -} from '@/lib/table/rows/secret-provenance' -import { - batchInsertRows, - batchUpdateRows, - deleteRowsByFilter, - queryRows, - replaceTableRows, - updateRowsByFilter, -} from '@/lib/table/rows/service' +import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' import { normalizeSelectOptionsInput } from '@/lib/table/select-options' -import { predicateToStorage } from '@/lib/table/select-values' -import { createTable, deleteTable, getTableById } from '@/lib/table/service' import type { - ColumnDefinition, - Filter, RowData, SortSpec, - TableDefinition, - TableDeleteJobPayload, TablePredicateInput, TableSchema, - TableUpdateJobPayload, - WorkflowGroup, WorkflowGroupDependencies, WorkflowGroupDeploymentMode, - WorkflowGroupInputMapping, - WorkflowGroupOutput, } from '@/lib/table/types' -import { markTableUpdateFailed, runTableUpdate } from '@/lib/table/update-runner' -import { - addWorkflowGroup, - addWorkflowGroupOutput, - deleteWorkflowGroupOutput, -} from '@/lib/table/workflow-groups/service' -import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' -import { - type FlattenedBlockOutput, - flattenWorkflowOutputs, -} from '@/lib/workflows/blocks/flatten-outputs' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' -import { fileOperations } from '@/lib/workspace-files/application/operations' -import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' -import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' +import type { ResolvedSecretTraceProvenanceV1 } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('UserTableServerTool') @@ -137,348 +77,16 @@ type UserTableResult = { } const MAX_BATCH_SIZE = CSV_MAX_BATCH_SIZE -const MAX_INLINE_FILE_BYTES = 50 * 1024 * 1024 - -const USER_TABLE_OPERATIONS: Readonly<Record<string, TableOperation | undefined>> = { - create: tableOperations.create, - create_from_file: tableOperations.create, - import_file: tableOperations.createImport, - get: tableOperations.read, - get_schema: tableOperations.read, - delete: tableOperations.delete, - insert_row: tableOperations.createRows, - batch_insert_rows: tableOperations.createRows, - get_row: tableOperations.readRow, - query_rows: tableOperations.queryRows, - update_row: tableOperations.updateRow, - delete_row: tableOperations.deleteRow, - update_rows_by_filter: tableOperations.updateRows, - delete_rows_by_filter: tableOperations.deleteRows, - batch_update_rows: tableOperations.updateRows, - batch_delete_rows: tableOperations.deleteRows, - add_column: tableOperations.addColumn, - rename_column: tableOperations.updateColumn, - delete_column: tableOperations.deleteColumn, - update_column: tableOperations.updateColumn, - rename: tableOperations.update, - add_workflow_group: tableOperations.createGroup, - update_workflow_group: tableOperations.updateGroup, - delete_workflow_group: tableOperations.deleteGroup, - add_workflow_group_output: tableOperations.updateGroup, - delete_workflow_group_output: tableOperations.updateGroup, - run_column: tableOperations.startRun, - cancel_table_runs: tableOperations.cancelRuns, - add_enrichment: tableOperations.createGroup, -} -const DIRECT_APPLICATION_OPERATIONS = new Set([ - 'create', - 'get', - 'get_schema', - 'delete', - 'rename', - 'insert_row', - 'batch_insert_rows', - 'get_row', - 'query_rows', - 'update_row', - 'delete_row', - 'batch_delete_rows', - 'add_column', - 'rename_column', - 'update_column', - 'add_workflow_group', - 'update_workflow_group', - 'delete_workflow_group', - 'run_column', - 'cancel_table_runs', -]) - -async function resolveWorkspaceFileRecordOrThrow( - fileReference: string, +function resolveAuthorizedWorkflowOutputs( + workflowId: string, workspaceId: string, - principal: ReturnType<typeof resolveCopilotFilePrincipal> + context: ServerToolContext ) { - let record - try { - record = await resolveWorkspaceFileReference({ - principal, - operation: fileOperations.readContent, - workspaceId, - reference: fileReference, - }) - } catch { - // Only workspace files resolve here. A chat upload is a real, correctly-copied - // path, so pointing it at glob("files/**") would send the agent looking for a - // file that is not in that tree until materialize_file moves it there. - if (fileReference.replace(/^\/+/, '').startsWith('uploads/')) { - throw new Error( - `Cannot import "${fileReference}": chat uploads are not workspace files. Use materialize_file to save it to a files/... path first, then pass that canonical path.` - ) - } - throw new Error( - `File not found: "${fileReference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.` - ) - } - if (!record) { - if (fileReference.replace(/^\/+/, '').startsWith('uploads/')) { - throw new Error( - `Cannot import "${fileReference}": chat uploads are not workspace files. Use materialize_file to save it to a files/... path first, then pass that canonical path.` - ) - } - throw new Error( - `File not found: "${fileReference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.` - ) - } - - const provenance = await getBoundWorkspaceFileSecretProvenance(workspaceId, { - fileId: record.id, - key: record.key, - context: 'workspace', + return executeCopilotResolveWorkflowOutputs(context, { + workflowId, + assertedWorkspaceId: workspaceId, }) - if (provenance.status !== 'exact' || provenance.entries.length > 0) { - throw new Error( - `Cannot import "${fileReference}": the file cannot be verified as free of resolved secrets.` - ) - } - - return record -} - -/** - * Whether a workspace file should import as a background job instead of inline: - * CSV/TSV at or above the same byte threshold the UI uses. Other formats - * (xlsx/json) aren't supported by the streaming import worker and stay inline. - */ -function shouldImportInBackground(record: { name: string; size: number }): boolean { - const ext = record.name.split('.').pop()?.toLowerCase() - return (ext === 'csv' || ext === 'tsv') && record.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES -} - -/** - * Dispatches a background import for an already-claimed job slot, mirroring the - * import-async routes: trigger.dev when enabled (survives deploys, retries), - * detached in-process worker otherwise. A failed dispatch releases the claim so - * a ghost `running` job can't hold the table's one-write-job slot. - */ -async function dispatchImportJob(payload: TableImportPayload): Promise<void> { - if (isTriggerDevEnabled) { - try { - const [{ tableImportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ - import('@/background/table-import'), - import('@trigger.dev/sdk'), - import('@/lib/core/async-jobs/region'), - ]) - await tasks.trigger<typeof tableImportTask>('table-import', payload, { - tags: [`tableId:${payload.tableId}`, `jobId:${payload.importId}`], - region: await resolveTriggerRegion(), - }) - } catch (error) { - try { - const released = await releaseJobClaimInWorkspace( - payload.tableId, - payload.workspaceId, - payload.importId - ) - if (!released) throw new Error('Table import claim was no longer active') - } catch (cleanupError) { - logger.error('Failed to release table import claim after dispatch failure', { - tableId: payload.tableId, - jobId: payload.importId, - error: getErrorMessage(cleanupError), - }) - } - throw error - } - } else { - runDetached('table-import', () => runTableImport(payload)) - } -} - -/** - * Dispatches a background filter-delete for an already-claimed job slot, - * mirroring the delete-async route. Same release-on-failed-dispatch guard as - * {@link dispatchImportJob}. - */ -async function dispatchDeleteJob(params: { - jobId: string - tableId: string - workspaceId: string - filter: Filter - cutoff: Date - maxRows?: number -}): Promise<void> { - const { jobId, tableId, workspaceId, filter, cutoff, maxRows } = params - if (isTriggerDevEnabled) { - try { - const [{ tableDeleteTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ - import('@/background/table-delete'), - import('@trigger.dev/sdk'), - import('@/lib/core/async-jobs/region'), - ]) - await tasks.trigger<typeof tableDeleteTask>( - 'table-delete', - { jobId, tableId, workspaceId, filter, cutoff: cutoff.toISOString(), maxRows }, - { tags: [`tableId:${tableId}`, `jobId:${jobId}`], region: await resolveTriggerRegion() } - ) - } catch (error) { - try { - const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) - if (!released) throw new Error('Table delete claim was no longer active') - } catch (cleanupError) { - logger.error('Failed to release table delete claim after dispatch failure', { - tableId, - jobId, - error: getErrorMessage(cleanupError), - }) - } - throw error - } - } else { - runDetached('table-delete', () => - runTableDelete({ jobId, tableId, workspaceId, filter, cutoff, maxRows }).catch( - async (error) => { - await markTableDeleteFailed(tableId, jobId, error) - throw error - } - ) - ) - } -} - -/** - * Dispatches a background bulk update for an already-claimed job slot, mirroring - * {@link dispatchDeleteJob}: trigger.dev when enabled, detached worker otherwise, releasing the - * slot on a failed dispatch. - */ -async function dispatchUpdateJob(params: { - jobId: string - tableId: string - workspaceId: string - filter: Filter - data: RowData - cutoff: Date - maxRows?: number -}): Promise<void> { - const { jobId, tableId, workspaceId, filter, data, cutoff, maxRows } = params - if (isTriggerDevEnabled) { - try { - const [{ tableUpdateTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ - import('@/background/table-update'), - import('@trigger.dev/sdk'), - import('@/lib/core/async-jobs/region'), - ]) - await tasks.trigger<typeof tableUpdateTask>( - 'table-update', - { jobId, tableId, workspaceId, filter, data, cutoff: cutoff.toISOString(), maxRows }, - { tags: [`tableId:${tableId}`, `jobId:${jobId}`], region: await resolveTriggerRegion() } - ) - } catch (error) { - try { - const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) - if (!released) throw new Error('Table update claim was no longer active') - } catch (cleanupError) { - logger.error('Failed to release table update claim after dispatch failure', { - tableId, - jobId, - error: getErrorMessage(cleanupError), - }) - } - throw error - } - } else { - runDetached('table-update', () => - runTableUpdate({ jobId, tableId, workspaceId, filter, data, cutoff, maxRows }).catch( - async (error) => { - await markTableUpdateFailed(tableId, jobId, error) - throw error - } - ) - ) - } -} - -async function withReleasedTableJobClaim<T>( - tableId: string, - workspaceId: string, - jobId: string, - run: () => Promise<T> -): Promise<T> { - let result: T - try { - result = await run() - } catch (error) { - try { - const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) - if (!released) { - logger.error('Table job claim was no longer active after operation failure', { - tableId, - workspaceId, - jobId, - }) - } - } catch (cleanupError) { - logger.error('Failed to release table job claim after operation failure', { - tableId, - workspaceId, - jobId, - error: getErrorMessage(cleanupError), - }) - } - throw error - } - const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) - if (!released) { - logger.error('Table job claim was no longer active after successful operation', { - tableId, - workspaceId, - jobId, - }) - throw new Error('Table job claim was no longer active') - } - return result -} - -/** - * Loads the live workflow state and flattens it into pickable outputs. Used - * to validate `(blockId, path)` pairs the AI passes to add/update_workflow_group - * before they get stored as stale references — and to power `list_workflow_outputs` - * so the AI can discover valid picks instead of guessing. - */ -async function loadFlattenedWorkflowOutputs( - workflowId: string -): Promise<FlattenedBlockOutput[] | null> { - const normalized = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalized) return null - const blocks = Object.values(normalized.blocks ?? {}).map((b) => ({ - id: b.id, - type: b.type, - name: b.name, - triggerMode: (b as { triggerMode?: boolean }).triggerMode, - subBlocks: b.subBlocks as Record<string, unknown> | undefined, - })) - return flattenWorkflowOutputs(blocks, normalized.edges ?? []) -} - -/** - * Validates a list of `(blockId, path)` outputs against the live workflow. - * Returns `null` on success; on failure returns an error message that lists - * the valid options so the AI can retry without guessing again. - */ -function validateOutputsAgainstWorkflow( - outputs: Array<{ blockId: string; path: string }>, - flattened: FlattenedBlockOutput[], - workflowId: string -): string | null { - const valid = new Set(flattened.map((f) => `${f.blockId}::${f.path}`)) - const invalid = outputs.filter((o) => !valid.has(`${o.blockId}::${o.path}`)) - if (invalid.length === 0) return null - const sample = flattened - .slice(0, 12) - .map((f) => ` - ${f.blockId} (${f.blockName}) → ${f.path}`) - .join('\n') - const invalidList = invalid.map((o) => ` - ${o.blockId} → ${o.path}`).join('\n') - return `Invalid output(s) for workflow ${workflowId}:\n${invalidList}\n\nValid options${flattened.length > 12 ? ' (first 12)' : ''}:\n${sample}\n\nCall list_workflow_outputs with workflowId="${workflowId}" to see all valid (blockId, path) picks.` } /** @@ -491,17 +99,15 @@ function parseDeploymentMode(value: unknown): WorkflowGroupDeploymentMode | unde return value === 'live' || value === 'deployed' ? value : undefined } -/** - * Validates an optional row limit. There's no upper bound the caller must respect — the model may - * ask for any number. `MAX_QUERY_LIMIT` / `MAX_BULK_OPERATION_SIZE` are applied internally instead - * (query_rows clamps the page; bulk ops above the bound run as a background job). Returns an error - * message, or `null` when the limit is acceptable. - */ -function limitError(limit: unknown): string | null { +/** Validates an optional row limit against the policy for the requested surface operation. */ +function limitError(limit: unknown, max?: number): string | null { if (limit === undefined) return null if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1) { return 'Limit must be an integer of at least 1' } + if (max !== undefined && limit > max) { + return `Limit cannot exceed ${max}` + } return null } @@ -525,57 +131,18 @@ function normalizeSchemaSelectColumns(schema: TableSchema): TableSchema { } } -async function batchInsertAll( - tableId: string, - rows: RowData[], - table: TableDefinition, - workspaceId: string, - context?: ServerToolContext -): Promise<number> { - let inserted = 0 - const userId = context?.userId - for (let i = 0; i < rows.length; i += MAX_BATCH_SIZE) { - assertServerToolNotAborted(context, 'Request aborted before table mutation could be applied.') - const batch = rows.slice(i, i + MAX_BATCH_SIZE) - const requestId = generateId().slice(0, 8) - const result = await batchInsertRows( - { - tableId, - rows: batch, - workspaceId, - userId, - secretProvenance: batch.map(createExactEmptyTableRowSecretProvenance), - }, - // Pass the running total so each batch's capacity check sees cumulative rows, - // not the same pre-loop snapshot (which would let a multi-batch insert overshoot). - { ...table, rowCount: table.rowCount + inserted }, - requestId - ) - inserted += result.length - } - return inserted -} - -async function importRowsForModel( - rows: Array<{ id: string; data: RowData; updatedAt: Date | string }>, +async function importRowsProvenanceForModel( + provenance: ResolvedSecretTraceProvenanceV1 | undefined, + values: unknown[], context: ServerToolContext ): Promise<void> { const registry = context.resolvedSecretTraceRegistry if (!registry) return - if (!context.workspaceId) { + if (!provenance) { registry.markIncomplete() return } - - const provenance = await loadTableRowSecretProvenance(rows, { - userId: context.userId, - workspaceId: context.workspaceId, - }) - await registry.importCrossingProvenance( - provenance, - rows.map((row) => row.data), - { trusted: true } - ) + await registry.importCrossingProvenance(provenance, values, { trusted: true }) } export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> = { @@ -590,21 +157,10 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> } const { operation, args = {} } = params - const tableId = typeof args.tableId === 'string' ? args.tableId : undefined - const tablePrincipal = resolveCopilotTablePrincipal(context, tableId) - const workspaceId = tablePrincipal.workspaceId + const workspaceId = context.workspaceId const assertNotAborted = () => assertServerToolNotAborted(context, 'Request aborted before table mutation could be applied.') - try { - const semanticOperation = USER_TABLE_OPERATIONS[operation] - if (semanticOperation && !DIRECT_APPLICATION_OPERATIONS.has(operation)) { - await admitCopilotTableOperation( - context, - semanticOperation, - tableId ? { workspaceId, tableId } : { workspaceId } - ) - } switch (operation) { case 'create': { if (!args.name) { @@ -695,28 +251,13 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> return { success: false, message: 'Workspace ID is required' } } - const deleted: string[] = [] - const failed: string[] = [] - - for (const tableId of tableIds) { - try { - assertNotAborted() - await executeCopilotTableUseCase( - context, - deleteTableUseCase, - { tableId, workspaceId }, - { tableId } - ) - deleted.push(tableId) - } catch (error) { - const classified = messageForCopilotTableError(error, '') - if (classified === 'Table not found') { - failed.push(tableId) - continue - } - throw error - } - } + assertNotAborted() + const { deleted: archived, failed } = await executeCopilotDeleteTables(context, { + tableIds, + workspaceId, + assertNotAborted, + }) + const deleted = archived.map((table) => table.id) return { success: deleted.length > 0, @@ -819,17 +360,22 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> return { success: false, message: 'Workspace ID is required' } } - const { table: rowTable, row } = await executeCopilotTableUseCase( + const { + table: rowTable, + row, + secretProvenance, + } = await executeCopilotTableUseCase( context, readTableRow, { tableId: args.tableId, assertedWorkspaceId: workspaceId, rowId: args.rowId, + includePersistedSecretProvenance: Boolean(context.resolvedSecretTraceRegistry), }, { tableId: args.tableId } ) - await importRowsForModel([row], context) + await importRowsProvenanceForModel(secretProvenance, [row.data], context) const toNamedRow = namedRowMapper(rowTable.schema.columns) return { @@ -852,7 +398,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> return { success: false, message: 'Workspace ID is required' } } - const queryLimitError = limitError(args.limit) + const queryLimitError = limitError(args.limit, TABLE_LIMITS.MAX_QUERY_LIMIT) if (queryLimitError) { return { success: false, message: queryLimitError } } @@ -867,15 +413,20 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> ? normalizeTablePredicate(args.filter as TablePredicateInput) : undefined, sort: args.order as SortSpec | undefined, - limit: args.limit, + limit: args.limit ?? TABLE_LIMITS.MAX_QUERY_LIMIT, cursor: args.cursor, includeTotal: !args.cursor, + includePersistedSecretProvenance: Boolean(context.resolvedSecretTraceRegistry), }, { tableId: args.tableId } ) - const { table } = result + const { table, secretProvenance, ...queryResult } = result const toNamedRow = namedRowMapper(table.schema.columns) - await importRowsForModel(result.rows, context) + await importRowsProvenanceForModel( + secretProvenance, + result.rows.map((row) => row.data), + context + ) // nextCursor covers both cut kinds (explicit limit or the 5MB byte // budget) — either way the truthful signal is "more rows exist". The @@ -888,7 +439,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> success: true, message, data: { - ...result, + ...queryResult, rows: result.rows.map((r) => ({ ...r, data: toNamedRow(r.data), @@ -912,7 +463,11 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> } assertNotAborted() - const { table, row: updatedRow } = await executeCopilotTableUseCase( + const { + table, + row: updatedRow, + secretProvenance, + } = await executeCopilotTableUseCase( context, updateTableRow, { @@ -921,11 +476,12 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> rowId: args.rowId, data: args.data, secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), + includePersistedSecretProvenance: Boolean(context.resolvedSecretTraceRegistry), }, { tableId: args.tableId } ) const toNamedRow = namedRowMapper(table.schema.columns) - await importRowsForModel([updatedRow], context) + await importRowsProvenanceForModel(secretProvenance, [updatedRow.data], context) return { success: true, @@ -986,95 +542,26 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> return { success: false, message: updateLimitError } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - - const requestId = generateId().slice(0, 8) - const idByName = buildIdByName(table.schema) - // Agent authors a predicate object; validate → translate → Filter for - // the bulk engine (same fieldPredicate leaf → identical SQL). Select - // operands arrive as option NAMES and must resolve to stored ids. - const filter = args.filter as TablePredicateInput - validatePredicate(filter, table.schema.columns) - const normalizedFilter = normalizeTablePredicate(filter) - const idFilter = predicateToFilter(predicateToStorage(normalizedFilter, table.schema)) - const idData = rowDataNameToId(args.data, idByName) - - // Inline handles up to MAX_BULK_OPERATION_SIZE rows in one request; a larger operation - // (an explicit limit above the cap, or unbounded "update everything matching") runs in the - // background worker so a broad update on a huge table doesn't load every matching row into - // this request. A small explicit limit is the fast path — no count needed. A patch - // touching a unique column always stays inline (the service rejects bulk-setting a unique - // value across multiple rows). - const patchTouchesUnique = table.schema.columns.some( - (c) => c.unique === true && (c.id ?? c.name) in idData - ) - const updateInlineEligible = - args.limit !== undefined && args.limit <= TABLE_LIMITS.MAX_BULK_OPERATION_SIZE - if (!updateInlineEligible && !patchTouchesUnique) { - const { totalCount } = await queryRows( - table, - { filter: idFilter, limit: 1, withExecutions: false }, - requestId - ) - const matchCount = totalCount ?? 0 - const target = args.limit !== undefined ? Math.min(args.limit, matchCount) : matchCount - if (target > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) { - const cutoff = new Date() - const jobId = generateId() - const payload: TableUpdateJobPayload = { - filter: idFilter, - data: idData, - cutoff: cutoff.toISOString(), - affectedCount: target, - maxRows: args.limit, - } - // Gate the update lock at enqueue — the background worker is a - // trusted continuation and does not re-check. - assertRowUpdate(table, patchColumnIds(idData)) - assertNotAborted() - const claimed = await markTableJobRunningInWorkspace( - table.id, - workspaceId, - jobId, - 'update', - payload - ) - if (!claimed) { - return { success: false, message: 'A job is already in progress for this table' } - } - await dispatchUpdateJob({ - jobId, - tableId: table.id, - workspaceId, - filter: idFilter, - data: idData, - cutoff, - maxRows: args.limit, - }) - return { - success: true, - message: `Started background update of ${target} matching rows (job ${jobId}). Rows update in the background — query_rows to check progress. Note: background updates don't auto-recompute workflow/enrichment columns; use run_column afterward if needed.`, - data: { jobId, affectedCount: target }, - } - } - } - assertNotAborted() - const result = await updateRowsByFilter( - table, + const result = await executeCopilotTableUseCase( + context, + copilotUpdateRowsByFilter, { - filter: idFilter, - data: idData, + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + filter: normalizeTablePredicate(args.filter as TablePredicateInput), + data: args.data as RowData, limit: args.limit, - actorUserId: context.userId, - secretProvenance: createExactEmptyTableRowSecretProvenance(idData), }, - requestId + { tableId: args.tableId } ) - if (result.affectedCount > 0) signalTableRowsChanged(args.tableId) + if (result.kind === 'background') { + return { + success: true, + message: `Started background update of ${result.affectedCount} matching rows (job ${result.jobId}). Rows update in the background — query_rows to check progress. Note: background updates don't auto-recompute workflow/enrichment columns; use run_column afterward if needed.`, + data: { jobId: result.jobId, affectedCount: result.affectedCount }, + } + } return { success: true, @@ -1098,115 +585,26 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> return { success: false, message: deleteLimitError } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - - const requestId = generateId().slice(0, 8) - const idByName = buildIdByName(table.schema) - // Agent authors a predicate object; validate → translate → Filter for - // the bulk engine (same fieldPredicate leaf → identical SQL). Select - // operands arrive as option NAMES and must resolve to stored ids. - const filter = args.filter as TablePredicateInput - validatePredicate(filter, table.schema.columns) - const normalizedFilter = normalizeTablePredicate(filter) - const idFilter = predicateToFilter(predicateToStorage(normalizedFilter, table.schema)) - - // Inline handles up to MAX_BULK_OPERATION_SIZE rows; a larger delete (an explicit limit - // above the cap, or unbounded "delete everything matching") hands off to the background - // delete worker so a broad delete on a huge table doesn't load every matching id into this - // request. A small explicit limit is the fast path. - const deleteInlineEligible = - args.limit !== undefined && args.limit <= TABLE_LIMITS.MAX_BULK_OPERATION_SIZE - if (!deleteInlineEligible) { - const { totalCount } = await queryRows( - table, - { filter: idFilter, limit: 1, withExecutions: false }, - requestId - ) - const matchCount = totalCount ?? 0 - const target = args.limit !== undefined ? Math.min(args.limit, matchCount) : matchCount - if (target > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) { - const doomedCount = Math.min(target, table.rowCount) - const cutoff = new Date() - const jobId = generateId() - // Unbounded: mask the whole matching set (instant post-delete view), so `doomedCount` - // drives the count adjustment. Bounded (maxRows): no mask — `doomedCount` is omitted so - // the count isn't double-subtracted; rows disappear progressively as they're deleted. - const bounded = args.limit !== undefined - const payload: TableDeleteJobPayload = bounded - ? { filter: idFilter, cutoff: cutoff.toISOString(), maxRows: args.limit } - : { filter: idFilter, cutoff: cutoff.toISOString(), doomedCount } - // Gate the delete lock at enqueue — the worker is a trusted continuation. - assertRowDelete(table) - assertNotAborted() - const claimed = await markTableJobRunningInWorkspace( - table.id, - workspaceId, - jobId, - 'delete', - payload - ) - if (!claimed) { - return { success: false, message: 'A job is already in progress for this table' } - } - await dispatchDeleteJob({ - jobId, - tableId: table.id, - workspaceId, - filter: idFilter, - cutoff, - maxRows: args.limit, - }) - return { - success: true, - message: bounded - ? `Started background delete of up to ${doomedCount} matching rows (job ${jobId}). Rows delete in the background — query_rows to check progress.` - : `Started background delete of ${doomedCount} matching rows (job ${jobId}). The rows are hidden from reads immediately — query_rows already reflects the post-delete view.`, - data: { jobId, doomedCount }, - } - } - } - - // Claim the table's one-write-job slot for the inline delete too, so it - // can't interleave with a running background import/delete. Mask-safe: a - // payload-less delete job is ignored by pendingDeleteMask, and the delete - // completes synchronously within this request before the slot is released. assertNotAborted() - const inlineDeleteId = generateId() - const deleteClaimed = await markTableJobRunningInWorkspace( - table.id, - workspaceId, - inlineDeleteId, - 'delete' - ) - if (!deleteClaimed) { - return { success: false, message: 'A job is already in progress for this table' } - } - const result = await withReleasedTableJobClaim( - table.id, - workspaceId, - inlineDeleteId, - () => deleteRowsByFilter(table, { filter: idFilter, limit: args.limit }, requestId) + const result = await executeCopilotTableUseCase( + context, + copilotDeleteRowsByFilter, + { + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + filter: normalizeTablePredicate(args.filter as TablePredicateInput), + limit: args.limit, + }, + { tableId: args.tableId } ) - if (result.affectedCount > 0) signalTableRowsChanged(args.tableId) - - if (result.affectedCount > 0) { - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: table.id, - resourceName: table.name, - description: `Deleted ${result.affectedCount} row(s) from table "${table.name}"`, - metadata: { - op: 'bulk_delete', - rowsDeleted: result.affectedCount, - source: 'tool_input', - }, - }) + if (result.kind === 'background') { + return { + success: true, + message: result.bounded + ? `Started background delete of up to ${result.doomedCount} matching rows (job ${result.jobId}). Rows delete in the background — query_rows to check progress.` + : `Started background delete of ${result.doomedCount} matching rows (job ${result.jobId}). The rows are hidden from reads immediately — query_rows already reflects the post-delete view.`, + data: { jobId: result.jobId, doomedCount: result.doomedCount }, + } } return { @@ -1255,35 +653,17 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - - const requestId = generateId().slice(0, 8) assertNotAborted() - const idByName = buildIdByName(table.schema) - const idUpdates = (updates as Array<{ rowId: string; data: RowData }>).map((update) => ({ - rowId: update.rowId, - data: rowDataNameToId(update.data, idByName), - })) - const result = await batchUpdateRows( + const result = await executeCopilotTableUseCase( + context, + copilotBatchUpdateRows, { tableId: args.tableId, - updates: idUpdates, - workspaceId, - actorUserId: context.userId, - secretProvenanceByRowId: Object.fromEntries( - idUpdates.map((update) => [ - update.rowId, - createExactEmptyTableRowSecretProvenance(update.data), - ]) - ), + assertedWorkspaceId: workspaceId, + updates: updates as Array<{ rowId: string; data: RowData }>, }, - table, - requestId + { tableId: args.tableId } ) - if (result.affectedCount > 0) signalTableRowsChanged(args.tableId) return { success: true, @@ -1351,172 +731,49 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> return { success: false, message: 'Workspace ID is required' } } - const filePrincipal = resolveCopilotFilePrincipal(context) - const record = await resolveWorkspaceFileRecordOrThrow( - fileReference, + assertNotAborted() + const result = await executeCopilotCreateTableFromWorkspaceFile(context, { workspaceId, - filePrincipal - ) - - // Large CSV/TSV: create a placeholder table whose creation claims the - // job slot, then let the streaming import worker infer the schema and - // populate rows in the background (mirrors POST /api/table/import-async). - if (shouldImportInBackground(record)) { - const planLimits = await getWorkspaceTableLimits(workspaceId) - const tableName = - args.name || - sanitizeName(record.name.replace(/\.[^.]+$/, ''), 'imported_table').slice( - 0, - TABLE_LIMITS.MAX_TABLE_NAME_LENGTH - ) - const requestId = generateId().slice(0, 8) - const importId = generateId() - assertNotAborted() - const table = await createTable( - { - name: tableName, - description: args.description || `Imported from ${record.name}`, - schema: { columns: [{ name: 'column_1', type: 'string' }] }, - workspaceId, - userId: context.userId, - maxRows: planLimits.maxRowsPerTable, - maxTables: planLimits.maxTables, - jobStatus: 'running', - jobType: 'import', - jobId: importId, - }, - requestId - ) - try { - await dispatchImportJob({ - importId, - tableId: table.id, - workspaceId, - userId: context.userId, - fileKey: record.key, - fileName: record.name, - delimiter: record.name.toLowerCase().endsWith('.tsv') ? '\t' : ',', - mode: 'create', - deleteSourceFile: false, - }) - } catch (dispatchError) { - try { - await deleteTable(table.id, generateId().slice(0, 8)) - } catch (cleanupError) { - logger.error('Failed to remove placeholder table after import dispatch failure', { - tableId: table.id, - error: getErrorMessage(cleanupError), - }) - } - throw dispatchError - } + fileReference, + name: args.name, + description: args.description, + assertNotAborted, + }) + if (result.kind === 'empty') { + return { success: false, message: 'File contains no data rows' } + } + const record = result.sourceFile + if (result.kind === 'background') { return { success: true, - message: `Created table "${table.name}" (${table.id}); importing rows from "${record.name}" in the background (job ${importId}). Columns and rows appear as the import progresses — query_rows to check what has landed.`, + message: `Created table "${result.table.name}" (${result.table.id}); importing rows from "${record.name}" in the background (job ${result.jobId}). Columns and rows appear as the import progresses — query_rows to check what has landed.`, data: { - tableId: table.id, - tableName: table.name, - jobId: importId, + tableId: result.table.id, + tableName: result.table.name, + jobId: result.jobId, sourceFile: record.name, }, } } - const file = { - buffer: ( - await readWorkspaceFileContent.execute({ - principal: filePrincipal, - input: { - fileId: record.id, - assertedWorkspaceId: workspaceId, - maxBytes: MAX_INLINE_FILE_BYTES, - }, - }) - ).content, - name: record.name, - type: record.type, - } - const { headers, rows } = await parseFileRows(file.buffer, file.name, file.type) - if (rows.length === 0) { - return { success: false, message: 'File contains no data rows' } - } - - const { columns, headerToColumn } = inferSchemaFromCsv(headers, rows) - const tableName = args.name || file.name.replace(/\.[^.]+$/, '') - const requestId = generateId().slice(0, 8) - assertNotAborted() - const planLimits = await getWorkspaceTableLimits(workspaceId) - - const droppedRows = Math.max(0, rows.length - planLimits.maxRowsPerTable) - const rowsToImport = droppedRows > 0 ? rows.slice(0, planLimits.maxRowsPerTable) : rows - - const table = await createTable( - { - name: tableName, - description: args.description || `Imported from ${file.name}`, - schema: { columns }, - workspaceId, - userId: context.userId, - maxTables: planLimits.maxTables, - }, - requestId - ) - - // Coerce against the created table's schema so rows key by the ids - // `createTable` assigned (not the inferred, id-less columns). - const coerced = coerceRowsForTable(rowsToImport, table.schema, headerToColumn) - let inserted: number - try { - inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context) - } catch (insertError) { - const cleanupRequestId = generateId().slice(0, 8) - await deleteTable(table.id, cleanupRequestId).catch((cleanupError) => { - logger.error('Failed to roll back table after import failure', { - tableId: table.id, - error: toError(cleanupError).message, - }) - }) - const reason = toError(insertError).message - const cause = - insertError instanceof Error && insertError.cause - ? toError(insertError.cause).message - : undefined - logger.error('Failed to import rows into new table', { - tableId: table.id, - fileName: file.name, - error: reason, - cause, - }) - return { - success: false, - message: `Failed to import rows from "${file.name}" — the table was rolled back. ${cause ? `${reason} (${cause})` : reason}`, - } - } - - logger.info('Table created from file', { - tableId: table.id, - fileName: file.name, - columns: columns.length, - rows: inserted, - droppedRows, - userId: context.userId, - }) - - const createdMessage = `Created table "${table.name}" with ${columns.length} columns and ${inserted.toLocaleString()} rows from "${file.name}"` + const createdMessage = `Created table "${result.table.name}" with ${result.columns.length} columns and ${result.insertedCount.toLocaleString()} rows from "${record.name}"` const message = - droppedRows > 0 - ? `${createdMessage}. Dropped ${droppedRows.toLocaleString()} row(s) that exceed this plan's limit of ${planLimits.maxRowsPerTable.toLocaleString()} rows per table.` + result.droppedRows > 0 + ? `${createdMessage}. Dropped ${result.droppedRows.toLocaleString()} row(s) that exceed this plan's limit of ${result.maxRowsPerTable.toLocaleString()} rows per table.` : createdMessage return { success: true, message, data: { - tableId: table.id, - tableName: table.name, - columns: columns.map((c) => ({ name: c.name, type: c.type })), - rowCount: inserted, - sourceFile: file.name, + tableId: result.table.id, + tableName: result.table.name, + columns: result.columns.map((column) => ({ + name: column.name, + type: column.type, + })), + rowCount: result.insertedCount, + sourceFile: record.name, }, } } @@ -1551,182 +808,56 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> } const mode: 'append' | 'replace' = rawMode === 'replace' ? 'replace' : 'append' - const table = await getTableById(tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${tableId}` } - } - if (table.archivedAt) { - return { success: false, message: `Table is archived: ${tableId}` } - } - - const filePrincipal = resolveCopilotFilePrincipal(context) - const record = await resolveWorkspaceFileRecordOrThrow( + assertNotAborted() + const result = await executeCopilotImportWorkspaceFileIntoTable(context, { + tableId, + assertedWorkspaceId: workspaceId, fileReference, - workspaceId, - filePrincipal - ) - - // Large CSV/TSV: claim the table's one-write-job slot and hand the - // file to the streaming import worker (mirrors - // POST /api/table/[tableId]/import-async). - if (shouldImportInBackground(record)) { - const importId = generateId() - assertNotAborted() - const claimed = await markTableJobRunningInWorkspace( - table.id, - workspaceId, - importId, - 'import' - ) - if (!claimed) { - return { success: false, message: 'A job is already in progress for this table' } - } - await dispatchImportJob({ - importId, - tableId: table.id, - workspaceId, - userId: context.userId, - fileKey: record.key, - fileName: record.name, - delimiter: record.name.toLowerCase().endsWith('.tsv') ? '\t' : ',', - mode, - mapping: rawMapping, - deleteSourceFile: false, - }) + mode, + mapping: rawMapping, + assertNotAborted, + }) + if (result.kind === 'background') { return { success: true, - message: `Started background ${mode} import of "${record.name}" into "${table.name}" (job ${importId}). Rows appear as the import progresses — query_rows to check what has landed.`, - data: { tableId: table.id, jobId: importId, mode }, + message: `Started background ${mode} import of "${result.sourceFileName}" into "${result.table.name}" (job ${result.jobId}). Rows appear as the import progresses — query_rows to check what has landed.`, + data: { tableId: result.table.id, jobId: result.jobId, mode }, } } - - // Claim the table's one-write-job slot up front — before the download - // and parse — so the inline import is mutually exclusive with any - // background import/delete for its whole duration, not just the write, - // and contention is detected before the parse work is spent. - const inlineImportId = generateId() - assertNotAborted() - const inlineClaimed = await markTableJobRunningInWorkspace( - table.id, - workspaceId, - inlineImportId, - 'import' - ) - if (!inlineClaimed) { - return { success: false, message: 'A job is already in progress for this table' } - } - return withReleasedTableJobClaim(table.id, workspaceId, inlineImportId, async () => { - const file = { - buffer: ( - await readWorkspaceFileContent.execute({ - principal: filePrincipal, - input: { - fileId: record.id, - assertedWorkspaceId: workspaceId, - maxBytes: MAX_INLINE_FILE_BYTES, - }, - }) - ).content, - name: record.name, - type: record.type, - } - const { headers, rows } = await parseFileRows(file.buffer, file.name, file.type) - if (rows.length === 0) { - return { success: false, message: 'File contains no data rows' } - } - - const mapping: CsvHeaderMapping = rawMapping ?? buildAutoMapping(headers, table.schema) - - let validation: ReturnType<typeof validateMapping> - try { - validation = validateMapping({ - csvHeaders: headers, - mapping, - tableSchema: table.schema, - }) - } catch (err) { - if (err instanceof CsvImportValidationError) { - return { success: false, message: err.message } - } - throw err - } - - if (validation.mappedHeaders.length === 0) { - return { - success: false, - message: `No matching columns between file (${headers.join(', ')}) and table (${table.schema.columns.map((c) => c.name).join(', ')})`, - } - } - - const coerced = coerceRowsForTable(rows, table.schema, validation.effectiveMap) - - if (mode === 'replace') { - const requestId = generateId().slice(0, 8) - const result = await replaceTableRows( - { - tableId: table.id, - rows: coerced, - workspaceId, - userId: context.userId, - secretProvenance: coerced.map(createExactEmptyTableRowSecretProvenance), - }, - table, - requestId - ) - signalTableRowsChanged(table.id) - - logger.info('Rows replaced from file', { - tableId: table.id, - fileName: file.name, - mode, - matchedColumns: validation.mappedHeaders.length, - deleted: result.deletedCount, - inserted: result.insertedCount, - userId: context.userId, - }) - - return { - success: true, - message: `Replaced rows in "${table.name}" from "${file.name}": deleted ${result.deletedCount}, inserted ${result.insertedCount}`, - data: { - tableId: table.id, - tableName: table.name, - mode, - matchedColumns: validation.mappedHeaders, - skippedColumns: validation.skippedHeaders, - deletedCount: result.deletedCount, - insertedCount: result.insertedCount, - sourceFile: file.name, - }, - } - } - - const inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context) - if (inserted > 0) signalTableRowsChanged(table.id) - - logger.info('Rows imported from file', { - tableId: table.id, - fileName: file.name, - mode, - matchedColumns: validation.mappedHeaders.length, - rows: inserted, - userId: context.userId, - }) - + if (result.kind === 'empty') { + return { success: false, message: 'File contains no data rows' } + } + if (result.kind !== 'inline') + throw new Error('Inline table import returned a background job') + if (result.mode === 'replace') { return { success: true, - message: `Imported ${inserted} rows into "${table.name}" from "${file.name}" (${validation.mappedHeaders.length} columns matched)`, + message: `Replaced rows in "${result.table.name}" from "${result.sourceFileName}": deleted ${result.deletedCount}, inserted ${result.insertedCount}`, data: { - tableId: table.id, - tableName: table.name, + tableId: result.table.id, + tableName: result.table.name, mode, - matchedColumns: validation.mappedHeaders, - skippedColumns: validation.skippedHeaders, - rowCount: inserted, - sourceFile: file.name, + matchedColumns: result.matchedColumns, + skippedColumns: result.skippedColumns, + deletedCount: result.deletedCount, + insertedCount: result.insertedCount, + sourceFile: result.sourceFileName, }, } - }) + } + return { + success: true, + message: `Imported ${result.insertedCount} rows into "${result.table.name}" from "${result.sourceFileName}" (${result.matchedColumns.length} columns matched)`, + data: { + tableId: result.table.id, + tableName: result.table.name, + mode, + matchedColumns: result.matchedColumns, + skippedColumns: result.skippedColumns, + rowCount: result.insertedCount, + sourceFile: result.sourceFileName, + }, + } } case 'add_column': { @@ -1836,22 +967,18 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> data: { schema: updated.schema }, } } - await executeCopilotTableUseCase( + assertNotAborted() + const { table: updated, deletedColumns } = await executeCopilotTableUseCase( context, - readTableUseCase, - { tableId: args.tableId, workspaceId }, + deleteTableColumnsUseCase, + { tableId: args.tableId, workspaceId, columnNames: names }, { tableId: args.tableId } ) - assertNotAborted() - const updated = await deleteColumns( - { tableId: args.tableId, columnNames: names }, - generateId().slice(0, 8), - { expectedWorkspaceId: workspaceId } - ) - signalTableSchemaChanged(args.tableId) return { success: true, - message: `Deleted ${names.length} columns: ${names.join(', ')}`, + message: `Deleted ${deletedColumns.length} ${deletedColumns.length === 1 ? 'column' : 'columns'}: ${deletedColumns + .map((column) => column.name) + .join(', ')}`, data: { schema: updated.schema }, } } @@ -1962,7 +1089,12 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> message: 'workflowId is required for list_workflow_outputs', } } - const flattened = await loadFlattenedWorkflowOutputs(workflowId) + const resolvedWorkflow = await resolveAuthorizedWorkflowOutputs( + workflowId, + workspaceId, + context + ) + const flattened = resolvedWorkflow.outputs if (!flattened) { return { success: false, @@ -1997,13 +1129,6 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> message: 'outputs array (with blockId + path entries) is required', } } - const { table: tableForGroup } = await executeCopilotTableUseCase( - context, - readTableUseCase, - { tableId: args.tableId, workspaceId }, - { tableId: args.tableId } - ) - for (const o of rawOutputs) { if (!o.blockId || !o.path) { return { @@ -2013,72 +1138,26 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> } } - const flattened = await loadFlattenedWorkflowOutputs(workflowId) - if (!flattened) { - return { - success: false, - message: `Workflow not found or has no blocks: ${workflowId}`, - } - } - const validationError = validateOutputsAgainstWorkflow( - rawOutputs.map((o) => ({ blockId: o.blockId, path: o.path })), - flattened, - workflowId - ) - if (validationError) { - return { success: false, message: validationError } - } - const leafTypeByKey = new Map( - flattened.map((f) => [`${f.blockId}::${f.path}`, f.leafType]) - ) - - const taken = new Set(tableForGroup.schema.columns.map((c) => c.name)) - const groupId = generateId() - const outputs: WorkflowGroupOutput[] = [] - const outputColumns: ColumnDefinition[] = [] - for (const o of rawOutputs) { - const colName = o.columnName ?? deriveOutputColumnName(o.path, taken) - taken.add(colName) - outputs.push({ blockId: o.blockId, path: o.path, columnName: colName }) - const leafType = o.columnType ?? leafTypeByKey.get(`${o.blockId}::${o.path}`) - outputColumns.push({ - name: colName, - type: columnTypeForLeaf(leafType), - required: false, - unique: false, - workflowGroupId: groupId, - }) - } const dependencies = args.dependencies as WorkflowGroupDependencies | undefined const name = args.name as string | undefined const deploymentMode = parseDeploymentMode(args.deploymentMode) - const group: WorkflowGroup = { - id: groupId, - workflowId, - ...(name ? { name } : {}), - ...(dependencies ? { dependencies } : {}), - ...(deploymentMode ? { deploymentMode } : {}), - outputs, - } assertNotAborted() const autoRun = args.autoRun === true - const { table: updated } = await executeCopilotTableUseCase( - context, - createTableGroupUseCase, - { - tableId: args.tableId, - workspaceId, - group, - outputColumns, - autoRun, - }, - { tableId: args.tableId } - ) + const { table: updated, group } = await executeCopilotCreateWorkflowTableGroup(context, { + tableId: args.tableId, + workspaceId, + workflowId, + outputs: rawOutputs, + name, + dependencies, + deploymentMode, + autoRun, + }) return { success: true, - message: `Added workflow group "${name ?? groupId}" with ${outputs.length} output column(s)`, + message: `Added workflow group "${name ?? group.id}" with ${group.outputs.length} output column(s)`, data: { - groupId, + groupId: group.id, schema: updated.schema, }, } @@ -2091,64 +1170,31 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> if (!groupId) { return { success: false, message: 'groupId is required for update_workflow_group' } } - const { table: tableForUpdate } = await executeCopilotTableUseCase( - context, - readTableUseCase, - { tableId: args.tableId, workspaceId }, - { tableId: args.tableId } - ) - const updateOutputs = args.outputs as WorkflowGroupOutput[] | undefined - if (updateOutputs && updateOutputs.length > 0) { - // Resolve which workflow these outputs apply to: explicit override - // wins, else the existing group's workflowId. - const existingGroup = tableForUpdate.schema.workflowGroups?.find( - (g) => g.id === groupId - ) - const targetWorkflowId = - (args.workflowId as string | undefined) ?? existingGroup?.workflowId - if (!targetWorkflowId) { - return { - success: false, - message: `Cannot validate outputs — workflow group ${groupId} not found and no workflowId provided`, - } - } - const flattened = await loadFlattenedWorkflowOutputs(targetWorkflowId) - if (!flattened) { - return { - success: false, - message: `Workflow not found or has no blocks: ${targetWorkflowId}`, - } - } - const validationError = validateOutputsAgainstWorkflow( - updateOutputs.map((o) => ({ blockId: o.blockId, path: o.path })), - flattened, - targetWorkflowId - ) - if (validationError) { - return { success: false, message: validationError } - } - } + const updateOutputs = args.outputs as + | Array<{ + blockId: string + path: string + columnName?: string + columnType?: string + }> + | undefined + const mappingUpdates = args.mappingUpdates as + | Array<{ columnName: string; blockId: string; path: string }> + | undefined + const explicitWorkflowId = args.workflowId as string | undefined assertNotAborted() - const { table: updated } = await executeCopilotTableUseCase( - context, - updateTableGroupUseCase, - { - tableId: args.tableId, - workspaceId, - groupId, - workflowId: args.workflowId as string | undefined, - name: args.name as string | undefined, - dependencies: args.dependencies as WorkflowGroupDependencies | undefined, - outputs: updateOutputs, - newOutputColumns: args.newOutputColumns as ColumnDefinition[] | undefined, - mappingUpdates: args.mappingUpdates as - | Array<{ columnName: string; blockId: string; path: string }> - | undefined, - deploymentMode: parseDeploymentMode(args.deploymentMode), - autoRun: typeof args.autoRun === 'boolean' ? args.autoRun : undefined, - }, - { tableId: args.tableId } - ) + const { table: updated } = await executeCopilotUpdateWorkflowTableGroup(context, { + tableId: args.tableId, + workspaceId, + groupId, + workflowId: explicitWorkflowId, + name: args.name as string | undefined, + dependencies: args.dependencies as WorkflowGroupDependencies | undefined, + outputs: updateOutputs, + mappingUpdates, + deploymentMode: parseDeploymentMode(args.deploymentMode), + autoRun: typeof args.autoRun === 'boolean' ? args.autoRun : undefined, + }) return { success: true, message: `Updated workflow group ${groupId}`, @@ -2190,25 +1236,15 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> message: 'groupId, blockId, and path are required for add_workflow_group_output', } } - const tableForAdd = await getTableById(args.tableId) - if (!tableForAdd || tableForAdd.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - const requestId = generateId().slice(0, 8) assertNotAborted() - const updated = await addWorkflowGroupOutput( - { - tableId: args.tableId, - groupId, - blockId, - path, - columnName, - actorUserId: context.userId, - workspaceId, - }, - requestId - ) - signalTableSchemaChanged(args.tableId) + const { table: updated } = await executeCopilotAddWorkflowTableGroupOutput(context, { + tableId: args.tableId, + workspaceId, + groupId, + blockId, + path, + columnName, + }) return { success: true, message: `Added output to workflow group ${groupId}`, @@ -2227,17 +1263,13 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> message: 'groupId and columnName are required for delete_workflow_group_output', } } - const tableForRemove = await getTableById(args.tableId) - if (!tableForRemove || tableForRemove.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - const requestId = generateId().slice(0, 8) assertNotAborted() - const updated = await deleteWorkflowGroupOutput( + const { table: updated } = await executeCopilotTableUseCase( + context, + deleteTableGroupOutputUseCase, { tableId: args.tableId, groupId, columnName, workspaceId }, - requestId + { tableId: args.tableId } ) - signalTableSchemaChanged(args.tableId) return { success: true, message: `Removed output "${columnName}" from workflow group ${groupId}`, @@ -2371,105 +1403,30 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> if (!enrichmentId) { return { success: false, message: 'enrichmentId is required for add_enrichment' } } - const { getEnrichment } = await import('@/enrichments/registry') - const enrichment = getEnrichment(enrichmentId) - if (!enrichment) { - return { - success: false, - message: `Unknown enrichment "${enrichmentId}". Call list_enrichments to see available ids.`, - } - } - const tableForEnrichment = await getTableById(args.tableId) - if (!tableForEnrichment || tableForEnrichment.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - - // Validate the input mapping: every required input must be mapped, and - // each mapped column must already exist on the table. const rawMappings = args.inputMappings as | Array<{ inputName: string; columnName: string }> | undefined - const mappingByInput = new Map( - (Array.isArray(rawMappings) ? rawMappings : []).map((m) => [m.inputName, m.columnName]) - ) - const existingColumns = new Set(tableForEnrichment.schema.columns.map((c) => c.name)) - for (const input of enrichment.inputs) { - const mapped = mappingByInput.get(input.id) - if (input.required && !mapped) { - return { - success: false, - message: `Enrichment "${enrichment.name}" requires input "${input.id}" to be mapped to a column`, - } - } - if (mapped && !existingColumns.has(mapped)) { - return { - success: false, - message: `Mapped column "${mapped}" for input "${input.id}" does not exist on table ${args.tableId}`, - } - } - } - const inputMappings: WorkflowGroupInputMapping[] = enrichment.inputs - .filter((input) => mappingByInput.has(input.id)) - .map((input) => ({ - inputName: input.id, - columnName: mappingByInput.get(input.id) as string, - })) - - // Each enrichment output becomes a new column. Names can be overridden - // per output id; otherwise the enrichment's default name is used. - const outputNameOverrides = (args.outputColumnNames ?? {}) as Record<string, string> - const taken = new Set(tableForEnrichment.schema.columns.map((c) => c.name)) - const groupId = generateId() - const outputs: WorkflowGroupOutput[] = [] - const outputColumns: ColumnDefinition[] = [] - for (const out of enrichment.outputs) { - const desired = (outputNameOverrides[out.id] ?? '').trim() || out.name - const colName = deriveOutputColumnName(desired, taken) - taken.add(colName) - outputs.push({ blockId: '', path: '', outputId: out.id, columnName: colName }) - outputColumns.push({ - name: colName, - type: out.type, - required: false, - unique: false, - workflowGroupId: groupId, - }) - } - - // Default the run dependencies to the mapped input columns so a row - // fires once its inputs are filled. Mothership stages groups silently - // by default (autoRun false) — call run_column to fire rows. - const dependencies = - (args.dependencies as WorkflowGroupDependencies | undefined) ?? - ({ - columns: inputMappings.map((m) => m.columnName), - } satisfies WorkflowGroupDependencies) - const name = (args.name as string | undefined) ?? enrichment.name const autoRun = args.autoRun === true - const group: WorkflowGroup = { - id: groupId, - workflowId: '', - enrichmentId, - name, - type: 'enrichment', - dependencies, - outputs, - inputMappings, - autoRun, - } - const requestId = generateId().slice(0, 8) assertNotAborted() - const updated = await addWorkflowGroup( - { tableId: args.tableId, group, outputColumns, autoRun, actorUserId: context.userId }, - requestId + const { table: updated, group } = await executeCopilotCreateTableEnrichmentGroup( + context, + { + tableId: args.tableId, + workspaceId, + enrichmentId, + inputMappings: Array.isArray(rawMappings) ? rawMappings : undefined, + outputColumnNames: (args.outputColumnNames ?? {}) as Record<string, string>, + dependencies: args.dependencies as WorkflowGroupDependencies | undefined, + name: args.name as string | undefined, + autoRun, + } ) - signalTableSchemaChanged(args.tableId) return { success: true, - message: `Added enrichment "${name}" with ${outputs.length} output column(s)${ + message: `Added enrichment "${group.name}" with ${group.outputs.length} output column(s)${ autoRun ? ' (auto-run enabled)' : ' (staged — use run_column to fire rows)' }`, - data: { groupId, schema: updated.schema }, + data: { groupId: group.id, schema: updated.schema }, } } diff --git a/apps/sim/lib/table/api/index.ts b/apps/sim/lib/table/api/index.ts index 962b274a4f3..892fe18d6f9 100644 --- a/apps/sim/lib/table/api/index.ts +++ b/apps/sim/lib/table/api/index.ts @@ -1 +1,4 @@ -export { v2TableErrorPolicies } from '@/lib/table/api/route-policies' +export { + internalTableSessionOrExecutorAuth, + v2TableErrorPolicies, +} from '@/lib/table/api/route-policies' diff --git a/apps/sim/lib/table/api/route-policies.test.ts b/apps/sim/lib/table/api/route-policies.test.ts new file mode 100644 index 00000000000..2f5a0ba19e2 --- /dev/null +++ b/apps/sim/lib/table/api/route-policies.test.ts @@ -0,0 +1,190 @@ +/** + * @vitest-environment node + */ + +import { resetEnvMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { MockInvalidBindingError, mockBindDelegation, mockGetSession } = vi.hoisted(() => { + class MockInvalidBindingError extends Error {} + return { + MockInvalidBindingError, + mockBindDelegation: vi.fn(), + mockGetSession: vi.fn(), + } +}) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/auth/internal-delegation', () => ({ + bindInternalExecutorDelegation: mockBindDelegation, + InvalidInternalDelegationBindingError: MockInvalidBindingError, +})) +vi.unmock('@/lib/auth/internal') + +import { + InternalUnauthenticatedError, + internalPlainOrchestrationErrorPolicy, +} from '@/lib/api/server/routes' +import { generateInternalDelegationToken, generateInternalToken } from '@/lib/auth/internal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { v2TableErrorPolicies } from '@/lib/table/api/route-policies' + +afterAll(resetEnvMock) + +describe('internal Table route authentication', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue(null) + mockBindDelegation.mockImplementation(async (delegation, options) => ({ + kind: 'delegated', + serviceId: 'executor', + subjectUserId: delegation.subjectUserId, + workspaceId: 'canonical-workspace', + delegationId: delegation.delegationId, + audience: options.audience, + issuedAt: delegation.issuedAt, + expiresAt: delegation.expiresAt, + resourceScope: options.resourceScope, + delegationContext: { + kind: 'workflow_execution', + workflowId: delegation.workflowId, + executionId: delegation.executionId, + }, + })) + }) + + it('binds table scope to the current workflow without trusting route workspace input', async () => { + const token = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + + const principal = await internalTableSessionOrExecutorAuth.authenticate( + new NextRequest('http://localhost/api/table/table-1/groups?workspaceId=forged-workspace', { + headers: { authorization: `Bearer ${token}` }, + }), + { tableId: 'table-1', workspaceId: 'forged-workspace' } + ) + + expect(principal).toMatchObject({ + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'canonical-workspace', + audience: 'sim:tables', + resourceScope: { tableId: 'table-1' }, + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(mockBindDelegation).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-1', + executionId: 'execution-1', + }), + { audience: 'sim:tables', resourceScope: { tableId: 'table-1' } } + ) + }) + + it('binds transfer resource routes as unscoped Table-domain principals', async () => { + const token = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }) + + await internalTableSessionOrExecutorAuth.authenticate( + new NextRequest('http://localhost/api/table/imports/import-1', { + headers: { authorization: `Bearer ${token}` }, + }), + { importId: 'import-1' } + ) + + expect(mockBindDelegation).toHaveBeenCalledWith(expect.any(Object), { + audience: 'sim:tables', + resourceScope: undefined, + }) + }) + + it('rejects legacy actorless internal tokens before canonical binding', async () => { + const token = await generateInternalToken() + + await expect( + internalTableSessionOrExecutorAuth.authenticate( + new NextRequest('http://localhost/api/table/table-1/groups', { + headers: { authorization: `Bearer ${token}` }, + }), + { tableId: 'table-1' } + ) + ).rejects.toBeInstanceOf(InternalUnauthenticatedError) + expect(mockBindDelegation).not.toHaveBeenCalled() + }) + + it('rejects a token whose current workflow binding no longer exists', async () => { + const token = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }) + mockBindDelegation.mockRejectedValue(new MockInvalidBindingError()) + + await expect( + internalTableSessionOrExecutorAuth.authenticate( + new NextRequest('http://localhost/api/table/table-1/groups', { + headers: { authorization: `Bearer ${token}` }, + }), + { tableId: 'table-1' } + ) + ).rejects.toBeInstanceOf(InternalUnauthenticatedError) + }) + + it('propagates canonical-binding infrastructure failures', async () => { + const token = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }) + const infrastructureError = new Error('database unavailable') + mockBindDelegation.mockRejectedValue(infrastructureError) + + await expect( + internalTableSessionOrExecutorAuth.authenticate( + new NextRequest('http://localhost/api/table/table-1/groups', { + headers: { authorization: `Bearer ${token}` }, + }), + { tableId: 'table-1' } + ) + ).rejects.toBe(infrastructureError) + }) + + it('preserves browser session principals when no executor token is supplied', async () => { + mockGetSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + + await expect( + internalTableSessionOrExecutorAuth.authenticate( + new NextRequest('http://localhost/api/table/table-1/groups'), + { tableId: 'table-1' } + ) + ).resolves.toEqual({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + }) + + it('renders an invalid related workflow as 400 on internal and v2 surfaces', async () => { + const error = new OrchestrationError('validation', 'Invalid workflow ID') + + expect(internalPlainOrchestrationErrorPolicy.project(error)).toEqual({ + status: 400, + body: { error: 'Invalid workflow ID' }, + headers: undefined, + }) + const response = v2TableErrorPolicies.default.render(error) + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: { code: 'BAD_REQUEST', message: 'Invalid workflow ID' }, + }) + }) +}) diff --git a/apps/sim/lib/table/api/route-policies.ts b/apps/sim/lib/table/api/route-policies.ts index 4f0b039202c..41d1eda13ff 100644 --- a/apps/sim/lib/table/api/route-policies.ts +++ b/apps/sim/lib/table/api/route-policies.ts @@ -1,4 +1,9 @@ -import { createV2ResourceConcealmentPolicy, type V2ErrorPolicy } from '@/lib/api/server/routes' +import { + createInternalSessionOrExecutorAuth, + createV2ResourceConcealmentPolicy, + type V2ErrorPolicy, +} from '@/lib/api/server/routes' +import { TABLE_DELEGATION_AUDIENCE } from '@/lib/table/application/authorization' import { TableOperationError } from '@/lib/table/application/errors' import { TableLockedError } from '@/lib/table/mutation-locks' import { @@ -7,6 +12,14 @@ import { v2ErrorForOrchestration, } from '@/app/api/v2/lib/response' +export const internalTableSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({ + audience: TABLE_DELEGATION_AUDIENCE, + resourceScope: (params) => { + const tableId = typeof params.tableId === 'string' ? params.tableId : undefined + return tableId ? { tableId } : undefined + }, +}) + function renderTableError(error: unknown) { if (error instanceof TableOperationError) { return v2ErrorForOrchestration( diff --git a/apps/sim/lib/table/application/authorization.test.ts b/apps/sim/lib/table/application/authorization.test.ts index f5a2ae99f7b..297359a3b42 100644 --- a/apps/sim/lib/table/application/authorization.test.ts +++ b/apps/sim/lib/table/application/authorization.test.ts @@ -122,7 +122,34 @@ describe('table operation authorization', () => { ) }) - it('rejects wrong-audience, expired, cross-workspace, and wrong-table delegations before lookup', async () => { + it('requires delegated scope to match the context in both directions', async () => { + const unscopedPrincipal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'execution-1', + audience: 'sim:tables', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + } + const workspaceContext = { ...authorizationContext, tableId: undefined } + + await authorizeTableOperation(unscopedPrincipal, tableOperations.readImport, workspaceContext) + + await expect( + authorizeTableOperation( + { ...unscopedPrincipal, resourceScope: { tableId: 'table-1' } }, + tableOperations.readImport, + workspaceContext + ) + ).rejects.toMatchObject<Partial<OrchestrationError>>({ code: 'forbidden' }) + await expect( + authorizeTableOperation(unscopedPrincipal, tableOperations.read, authorizationContext) + ).rejects.toMatchObject<Partial<OrchestrationError>>({ code: 'forbidden' }) + }) + + it('rejects wrong-audience, expired, cross-workspace, unscoped, and wrong-table delegations before lookup', async () => { const base = { kind: 'delegated' as const, serviceId: 'copilot' as const, @@ -150,6 +177,10 @@ describe('table operation authorization', () => { expiresAt: new Date(Date.now() + 60_000), resourceScope: { tableId: 'table-1' }, }) + await expectForbidden({ + ...base, + expiresAt: new Date(Date.now() + 60_000), + }) await expectForbidden({ ...base, expiresAt: new Date(Date.now() + 60_000), diff --git a/apps/sim/lib/table/application/authorization.ts b/apps/sim/lib/table/application/authorization.ts index 03612e1d534..85330ac85c9 100644 --- a/apps/sim/lib/table/application/authorization.ts +++ b/apps/sim/lib/table/application/authorization.ts @@ -24,10 +24,9 @@ export const tableDelegationPolicy: WorkspaceDelegationPolicy<TableAuthorization principal: Extract<Principal, { kind: 'delegated' }>, context: TableAuthorizationContext ) { - return ( - principal.resourceScope?.tableId === undefined || - principal.resourceScope.tableId === context.tableId - ) + return context.tableId === undefined + ? principal.resourceScope?.tableId === undefined + : principal.resourceScope?.tableId === context.tableId }, } diff --git a/apps/sim/lib/table/application/columns.test.ts b/apps/sim/lib/table/application/columns.test.ts new file mode 100644 index 00000000000..dfa9f41ccc5 --- /dev/null +++ b/apps/sim/lib/table/application/columns.test.ts @@ -0,0 +1,189 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const mocks = vi.hoisted(() => ({ + audit: vi.fn(), + deleteColumns: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + signal: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_UPDATED: 'table.updated' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) +vi.mock('@/lib/table', () => ({ + TABLE_LIMITS: { MAX_COLUMNS_PER_TABLE: 3 }, + addTableColumn: vi.fn(), + deleteColumn: vi.fn(), + deleteColumns: mocks.deleteColumns, + getColumnId: (column: { id?: string; name: string }) => column.id ?? column.name, +})) +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mocks.resolveContext, +})) +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mocks.signal })) +vi.mock('@/lib/table/orchestration', () => ({ performUpdateTableColumn: vi.fn() })) + +import { deleteTableColumnsUseCase } from '@/lib/table/application/columns' + +const table: TableDefinition = { + id: 'table-1', + name: 'People', + description: null, + schema: { + columns: [ + { id: 'column-name', name: 'name', type: 'string' }, + { id: 'column-first', name: 'first', type: 'string' }, + { id: 'column-last', name: 'last', type: 'string' }, + ], + }, + metadata: null, + rowCount: 0, + maxRows: 100, + workspaceId: 'workspace-1', + createdBy: 'owner-1', + archivedAt: null, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-1', + audience: 'sim:tables', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), + resourceScope: { tableId: 'table-1' }, +} + +const tableAfterDelete: TableDefinition = { + ...table, + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' }] }, + updatedAt: new Date('2026-08-02T00:00:00.000Z'), +} + +describe('multi-column delete application use case', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveContext.mockResolvedValue({ + tableId: table.id, + table, + workspaceId: table.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.deleteColumns.mockResolvedValue(tableAfterDelete) + }) + + it('owns canonical mutation, audit, and schema effects', async () => { + const result = await deleteTableColumnsUseCase.execute({ + principal, + input: { + tableId: 'table-1', + workspaceId: 'workspace-1', + columnNames: ['first', 'last'], + }, + }) + + expect(mocks.deleteColumns).toHaveBeenCalledWith( + { tableId: 'table-1', columnNames: ['first', 'last'] }, + 'request-1', + { expectedWorkspaceId: 'workspace-1' } + ) + expect(result.deletedColumns).toEqual([ + { id: 'column-first', name: 'first' }, + { id: 'column-last', name: 'last' }, + ]) + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Deleted 2 columns from table "People"', + metadata: expect.objectContaining({ columnNames: ['first', 'last'] }), + }) + ) + expect(mocks.signal).toHaveBeenCalledWith('table-1') + }) + + it('derives aliases and duplicate references from the authoritative schema delta', async () => { + mocks.deleteColumns.mockResolvedValue({ + ...table, + schema: { + columns: [ + { id: 'column-name', name: 'name', type: 'string' }, + { id: 'column-last', name: 'last', type: 'string' }, + ], + }, + }) + + const result = await deleteTableColumnsUseCase.execute({ + principal, + input: { + tableId: 'table-1', + workspaceId: 'workspace-1', + columnNames: ['first', 'column-first', 'FIRST'], + }, + }) + + expect(result.deletedColumns).toEqual([{ id: 'column-first', name: 'first' }]) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Deleted 1 column from table "People"', + metadata: expect.objectContaining({ columnNames: ['first'] }), + }) + ) + }) + + it('rejects an oversized request before mutation', async () => { + await expect( + deleteTableColumnsUseCase.execute({ + principal, + input: { + tableId: 'table-1', + workspaceId: 'workspace-1', + columnNames: ['first', 'last', 'name', 'extra'], + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.deleteColumns).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) + + it('rejects admission before mutation when delegated scope is stale', async () => { + mocks.resolvePermission.mockResolvedValueOnce('read') + + await expect( + deleteTableColumnsUseCase.execute({ + principal, + input: { + tableId: 'table-1', + workspaceId: 'workspace-1', + columnNames: ['first', 'last'], + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.deleteColumns).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/columns.ts b/apps/sim/lib/table/application/columns.ts index eae6c59b4f7..4a56b2c1b4a 100644 --- a/apps/sim/lib/table/application/columns.ts +++ b/apps/sim/lib/table/application/columns.ts @@ -1,12 +1,16 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { addTableColumn, type ColumnDefinition, type ColumnType, deleteColumn, + deleteColumns, + getColumnId, type SelectOption, + TABLE_LIMITS, type TableDefinition, } from '@/lib/table' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' @@ -156,5 +160,61 @@ export const deleteTableColumnUseCase = defineAuthorizedTableUseCase({ }, }) +export interface DeleteTableColumnsInput extends TableColumnInput { + columnNames: string[] +} + +interface DeletedTableColumn { + id: string + name: string +} + +export const deleteTableColumnsUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteColumn, + resolveContext: ({ input }: { input: DeleteTableColumnsInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ input, context }): Promise<{ + table: TableDefinition + deletedColumns: DeletedTableColumn[] + }> { + if (input.columnNames.length < 1) { + throw new OrchestrationError('validation', 'At least one column name is required') + } + if (input.columnNames.length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { + throw new OrchestrationError( + 'validation', + `Cannot delete more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} columns` + ) + } + const table = await deleteColumns( + { tableId: context.table.id, columnNames: input.columnNames }, + generateRequestId(), + { expectedWorkspaceId: context.workspaceId } + ) + const remainingColumnIds = new Set(table.schema.columns.map(getColumnId)) + const deletedColumns = context.table.schema.columns + .filter((column) => !remainingColumnIds.has(getColumnId(column))) + .map((column) => ({ id: getColumnId(column), name: column.name })) + return { table, deletedColumns } + }, + projectAudit({ context, result }) { + if (result.deletedColumns.length === 0) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Deleted ${result.deletedColumns.length} ${result.deletedColumns.length === 1 ? 'column' : 'columns'} from table "${context.table.name}"`, + metadata: { columnNames: result.deletedColumns.map((column) => column.name) }, + } + }, + afterSuccess({ context, result }) { + if (result.deletedColumns.length > 0) signalTableSchemaChanged(context.table.id) + }, +}) + export type TableColumnApplicationResult = { table: TableDefinition } export type TableColumnDefinition = ColumnDefinition diff --git a/apps/sim/lib/table/application/copilot-bulk-rows.test.ts b/apps/sim/lib/table/application/copilot-bulk-rows.test.ts new file mode 100644 index 00000000000..031893dcff6 --- /dev/null +++ b/apps/sim/lib/table/application/copilot-bulk-rows.test.ts @@ -0,0 +1,242 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const mocks = vi.hoisted(() => ({ + audit: vi.fn(), + batchUpdate: vi.fn(), + deleteByFilter: vi.fn(), + markJob: vi.fn(), + releaseJob: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + signal: vi.fn(), + translateFilter: vi.fn(), + updateByFilter: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_UPDATED: 'table.updated' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mocks.audit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/utils/id', () => ({ + generateId: vi.fn(() => 'job-12345678'), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: false })) +vi.mock('@/lib/core/utils/background', () => ({ runDetached: vi.fn() })) + +vi.mock('@/lib/table', () => ({ + batchUpdateRows: mocks.batchUpdate, + CSV_MAX_BATCH_SIZE: 1000, + deleteRowsByFilter: mocks.deleteByFilter, + queryRows: vi.fn(), + rowDataNameToId: (data: Record<string, unknown>) => data, + TABLE_LIMITS: { MAX_BULK_OPERATION_SIZE: 1000 }, + updateRowsByFilter: mocks.updateByFilter, +})) + +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mocks.resolveContext, +})) + +vi.mock('@/lib/table/application/rows', () => ({ + tablePredicateNamesToFilter: mocks.translateFilter, +})) + +vi.mock('@/lib/table/column-keys', () => ({ buildIdByName: () => new Map() })) +vi.mock('@/lib/table/delete-runner', () => ({ + markTableDeleteFailed: vi.fn(), + runTableDelete: vi.fn(), +})) +vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mocks.signal })) +vi.mock('@/lib/table/jobs/service', () => ({ + markTableJobRunningInWorkspace: mocks.markJob, + releaseJobClaimInWorkspace: mocks.releaseJob, +})) +vi.mock('@/lib/table/mutation-locks', () => ({ + assertRowDelete: vi.fn(), + assertRowUpdate: vi.fn(), + patchColumnIds: () => [], +})) +vi.mock('@/lib/table/rows/secret-provenance', () => ({ + createExactEmptyTableRowSecretProvenance: () => ({ complete: true, columns: {} }), +})) +vi.mock('@/lib/table/update-runner', () => ({ + markTableUpdateFailed: vi.fn(), + runTableUpdate: vi.fn(), +})) + +import { + copilotBatchUpdateRows, + copilotDeleteRowsByFilter, + copilotUpdateRowsByFilter, +} from '@/lib/table/application/copilot-bulk-rows' + +const table: TableDefinition = { + id: 'table-1', + name: 'People', + description: null, + schema: { columns: [{ id: 'column-1', name: 'name', type: 'string' }] }, + metadata: null, + rowCount: 2, + maxRows: 100, + workspaceId: 'workspace-1', + createdBy: 'owner-1', + archivedAt: null, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), +} + +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-1', + audience: 'sim:tables', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), + resourceScope: { tableId: 'table-1' }, +} + +const input = { + tableId: 'table-1', + assertedWorkspaceId: 'workspace-1', + filter: { all: [] as [] }, + data: { name: 'Ada' }, + limit: 1, +} + +describe('Copilot bulk row application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveContext.mockResolvedValue({ + tableId: table.id, + table, + workspaceId: table.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.translateFilter.mockReturnValue({}) + mocks.updateByFilter.mockResolvedValue({ affectedCount: 1, affectedRowIds: ['row-1'] }) + mocks.deleteByFilter.mockResolvedValue({ affectedCount: 1, affectedRowIds: ['row-1'] }) + mocks.batchUpdate.mockResolvedValue({ affectedCount: 1, affectedRowIds: ['row-1'] }) + mocks.markJob.mockResolvedValue(true) + mocks.releaseJob.mockResolvedValue(true) + }) + + it('rejects delegated resource-scope mismatches before mutation', async () => { + await expect( + copilotUpdateRowsByFilter.execute({ + principal: { ...principal, resourceScope: { tableId: 'table-other' } }, + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.updateByFilter).not.toHaveBeenCalled() + }) + + it('rejects current permission loss before mutation', async () => { + mocks.resolvePermission.mockResolvedValueOnce('read') + + await expect(copilotUpdateRowsByFilter.execute({ principal, input })).rejects.toMatchObject({ + code: 'forbidden', + }) + expect(mocks.updateByFilter).not.toHaveBeenCalled() + }) + + it('projects audit and shared effects only from an authoritative mutation result', async () => { + await copilotUpdateRowsByFilter.execute({ principal, input }) + + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'table.updated', + resourceId: 'table-1', + metadata: expect.objectContaining({ operation: 'tables.rows.update_many', rowsUpdated: 1 }), + }) + ) + expect(mocks.signal).toHaveBeenCalledWith('table-1') + + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveContext.mockResolvedValue({ + tableId: table.id, + table, + workspaceId: table.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.translateFilter.mockReturnValue({}) + mocks.updateByFilter.mockResolvedValue({ affectedCount: 0, affectedRowIds: [] }) + + await copilotUpdateRowsByFilter.execute({ principal, input }) + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) + + it('releases inline delete claims and audits the committed count', async () => { + await copilotDeleteRowsByFilter.execute({ + principal, + input: { + tableId: 'table-1', + assertedWorkspaceId: 'workspace-1', + filter: { all: [] }, + limit: 1, + }, + }) + + expect(mocks.deleteByFilter).toHaveBeenCalledTimes(1) + expect(mocks.releaseJob).toHaveBeenCalledWith('table-1', 'workspace-1', 'job-12345678') + expect(mocks.audit).toHaveBeenCalledTimes(1) + }) + + it('runs batch mutation behavior behind the same delegated boundary', async () => { + await copilotBatchUpdateRows.execute({ + principal, + input: { + tableId: 'table-1', + assertedWorkspaceId: 'workspace-1', + updates: [{ rowId: 'row-1', data: { name: 'Grace' } }], + }, + }) + + expect(mocks.batchUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: 'table-1', + workspaceId: 'workspace-1', + actorUserId: 'user-1', + }), + table, + 'job-1234' + ) + }) + + it('propagates unknown infrastructure failures without audit or effects', async () => { + const failure = new Error('database host unavailable') + mocks.updateByFilter.mockRejectedValueOnce(failure) + + await expect(copilotUpdateRowsByFilter.execute({ principal, input })).rejects.toBe(failure) + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/copilot-bulk-rows.ts b/apps/sim/lib/table/application/copilot-bulk-rows.ts new file mode 100644 index 00000000000..cd4393fe5ef --- /dev/null +++ b/apps/sim/lib/table/application/copilot-bulk-rows.ts @@ -0,0 +1,424 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { runDetached } from '@/lib/core/utils/background' +import { + batchUpdateRows, + CSV_MAX_BATCH_SIZE, + deleteRowsByFilter, + type Filter, + queryRows, + type RowData, + rowDataNameToId, + TABLE_LIMITS, + type TableDeleteJobPayload, + type TablePredicate, + type TableUpdateJobPayload, + updateRowsByFilter, +} from '@/lib/table' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { resolveActiveTableContext } from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { tablePredicateNamesToFilter } from '@/lib/table/application/rows' +import { buildIdByName } from '@/lib/table/column-keys' +import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner' +import { signalTableRowsChanged } from '@/lib/table/events' +import { + markTableJobRunningInWorkspace, + releaseJobClaimInWorkspace, +} from '@/lib/table/jobs/service' +import { assertRowDelete, assertRowUpdate, patchColumnIds } from '@/lib/table/mutation-locks' +import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' +import { markTableUpdateFailed, runTableUpdate } from '@/lib/table/update-runner' + +const logger = createLogger('CopilotBulkRowsApplication') + +interface CopilotBulkRowsInput { + tableId: string + assertedWorkspaceId: string +} + +export interface CopilotUpdateRowsByFilterInput extends CopilotBulkRowsInput { + filter: TablePredicate + data: RowData + limit?: number +} + +export type CopilotUpdateRowsByFilterResult = + | { kind: 'inline'; affectedCount: number; affectedRowIds: string[] } + | { kind: 'background'; affectedCount: number; jobId: string } + +export interface CopilotDeleteRowsByFilterInput extends CopilotBulkRowsInput { + filter: TablePredicate + limit?: number +} + +export type CopilotDeleteRowsByFilterResult = + | { kind: 'inline'; affectedCount: number; affectedRowIds: string[] } + | { kind: 'background'; doomedCount: number; jobId: string; bounded: boolean } + +export interface CopilotBatchUpdateRowsInput extends CopilotBulkRowsInput { + updates: Array<{ rowId: string; data: RowData }> +} + +export interface CopilotBatchUpdateRowsResult { + affectedCount: number + affectedRowIds: string[] +} + +function requestId(): string { + return generateId().slice(0, 8) +} + +function validateLimit(limit: number | undefined): void { + if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1)) { + throw new OrchestrationError('validation', 'Limit must be an integer of at least 1') + } +} + +async function releaseClaim(tableId: string, workspaceId: string, jobId: string): Promise<void> { + const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) + if (!released) throw new Error('Table job claim was no longer active') +} + +async function withReleasedClaim<T>( + tableId: string, + workspaceId: string, + jobId: string, + run: () => Promise<T> +): Promise<T> { + let result: T + try { + result = await run() + } catch (error) { + try { + await releaseClaim(tableId, workspaceId, jobId) + } catch (cleanupError) { + logger.error('Failed to release table job claim after operation failure', { + tableId, + workspaceId, + jobId, + error: getErrorMessage(cleanupError), + }) + } + throw error + } + await releaseClaim(tableId, workspaceId, jobId) + return result +} + +async function releaseClaimAfterDispatchFailure(params: { + tableId: string + workspaceId: string + jobId: string +}): Promise<void> { + try { + await releaseClaim(params.tableId, params.workspaceId, params.jobId) + } catch (cleanupError) { + logger.error('Failed to release table job claim after dispatch failure', { + ...params, + error: getErrorMessage(cleanupError), + }) + } +} + +async function dispatchUpdateJob(params: { + jobId: string + tableId: string + workspaceId: string + filter: Filter + data: RowData + cutoff: Date + maxRows?: number +}): Promise<void> { + if (isTriggerDevEnabled) { + try { + const [{ tableUpdateTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ + import('@/background/table-update'), + import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), + ]) + await tasks.trigger<typeof tableUpdateTask>( + 'table-update', + { ...params, cutoff: params.cutoff.toISOString() }, + { + tags: [`tableId:${params.tableId}`, `jobId:${params.jobId}`], + region: await resolveTriggerRegion(), + } + ) + } catch (error) { + await releaseClaimAfterDispatchFailure(params) + throw error + } + return + } + runDetached('table-update', () => + runTableUpdate(params).catch(async (error) => { + await markTableUpdateFailed(params.tableId, params.jobId, error) + throw error + }) + ) +} + +async function dispatchDeleteJob(params: { + jobId: string + tableId: string + workspaceId: string + filter: Filter + cutoff: Date + maxRows?: number +}): Promise<void> { + if (isTriggerDevEnabled) { + try { + const [{ tableDeleteTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ + import('@/background/table-delete'), + import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), + ]) + await tasks.trigger<typeof tableDeleteTask>( + 'table-delete', + { ...params, cutoff: params.cutoff.toISOString() }, + { + tags: [`tableId:${params.tableId}`, `jobId:${params.jobId}`], + region: await resolveTriggerRegion(), + } + ) + } catch (error) { + await releaseClaimAfterDispatchFailure(params) + throw error + } + return + } + runDetached('table-delete', () => + runTableDelete(params).catch(async (error) => { + await markTableDeleteFailed(params.tableId, params.jobId, error) + throw error + }) + ) +} + +export const copilotUpdateRowsByFilter = defineAuthorizedTableUseCase({ + operation: tableOperations.updateRows, + resolveContext: ({ input }: { input: CopilotUpdateRowsByFilterInput }) => + resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise<CopilotUpdateRowsByFilterResult> { + validateLimit(input.limit) + const idData = rowDataNameToId(input.data, buildIdByName(context.table.schema)) + const filter = tablePredicateNamesToFilter(input.filter, context.table) + const patchTouchesUnique = context.table.schema.columns.some( + (column) => column.unique === true && (column.id ?? column.name) in idData + ) + const inlineEligible = + input.limit !== undefined && input.limit <= TABLE_LIMITS.MAX_BULK_OPERATION_SIZE + + if (!inlineEligible && !patchTouchesUnique) { + const { totalCount } = await queryRows( + context.table, + { filter, limit: 1, withExecutions: false }, + requestId() + ) + const matchCount = totalCount ?? 0 + const target = input.limit === undefined ? matchCount : Math.min(input.limit, matchCount) + if (target > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) { + const cutoff = new Date() + const jobId = generateId() + const payload: TableUpdateJobPayload = { + filter, + data: idData, + cutoff: cutoff.toISOString(), + affectedCount: target, + maxRows: input.limit, + } + assertRowUpdate(context.table, patchColumnIds(idData)) + const claimed = await markTableJobRunningInWorkspace( + context.tableId, + context.workspaceId, + jobId, + 'update', + payload + ) + if (!claimed) { + throw new OrchestrationError('conflict', 'A job is already in progress for this table') + } + await dispatchUpdateJob({ + jobId, + tableId: context.tableId, + workspaceId: context.workspaceId, + filter, + data: idData, + cutoff, + maxRows: input.limit, + }) + return { kind: 'background', affectedCount: target, jobId } + } + } + + const result = await updateRowsByFilter( + context.table, + { + filter, + data: idData, + limit: input.limit, + actorUserId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + secretProvenance: createExactEmptyTableRowSecretProvenance(idData), + }, + requestId() + ) + return { kind: 'inline', ...result } + }, + projectAudit({ context, result }) { + if (result.kind !== 'inline' || result.affectedCount === 0) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: context.tableId, + resourceName: context.table.name, + description: `Updated ${result.affectedCount} row(s) in table "${context.table.name}"`, + metadata: { op: 'bulk_update', rowsUpdated: result.affectedCount }, + } + }, + afterSuccess({ context, result }) { + if (result.kind === 'inline' && result.affectedCount > 0) { + signalTableRowsChanged(context.tableId) + } + }, +}) + +export const copilotDeleteRowsByFilter = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteRows, + resolveContext: ({ input }: { input: CopilotDeleteRowsByFilterInput }) => + resolveActiveTableContext(input), + async execute({ input, context }): Promise<CopilotDeleteRowsByFilterResult> { + validateLimit(input.limit) + const filter = tablePredicateNamesToFilter(input.filter, context.table) + const inlineEligible = + input.limit !== undefined && input.limit <= TABLE_LIMITS.MAX_BULK_OPERATION_SIZE + + if (!inlineEligible) { + const { totalCount } = await queryRows( + context.table, + { filter, limit: 1, withExecutions: false }, + requestId() + ) + const matchCount = totalCount ?? 0 + const target = input.limit === undefined ? matchCount : Math.min(input.limit, matchCount) + if (target > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) { + const doomedCount = Math.min(target, context.table.rowCount) + const cutoff = new Date() + const jobId = generateId() + const bounded = input.limit !== undefined + const payload: TableDeleteJobPayload = bounded + ? { filter, cutoff: cutoff.toISOString(), maxRows: input.limit } + : { filter, cutoff: cutoff.toISOString(), doomedCount } + assertRowDelete(context.table) + const claimed = await markTableJobRunningInWorkspace( + context.tableId, + context.workspaceId, + jobId, + 'delete', + payload + ) + if (!claimed) { + throw new OrchestrationError('conflict', 'A job is already in progress for this table') + } + await dispatchDeleteJob({ + jobId, + tableId: context.tableId, + workspaceId: context.workspaceId, + filter, + cutoff, + maxRows: input.limit, + }) + return { kind: 'background', doomedCount, jobId, bounded } + } + } + + const jobId = generateId() + const claimed = await markTableJobRunningInWorkspace( + context.tableId, + context.workspaceId, + jobId, + 'delete' + ) + if (!claimed) { + throw new OrchestrationError('conflict', 'A job is already in progress for this table') + } + const result = await withReleasedClaim(context.tableId, context.workspaceId, jobId, () => + deleteRowsByFilter(context.table, { filter, limit: input.limit }, requestId()) + ) + return { kind: 'inline', ...result } + }, + projectAudit({ context, result }) { + if (result.kind !== 'inline' || result.affectedCount === 0) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: context.tableId, + resourceName: context.table.name, + description: `Deleted ${result.affectedCount} row(s) from table "${context.table.name}"`, + metadata: { op: 'bulk_delete', rowsDeleted: result.affectedCount }, + } + }, + afterSuccess({ context, result }) { + if (result.kind === 'inline' && result.affectedCount > 0) { + signalTableRowsChanged(context.tableId) + } + }, +}) + +export const copilotBatchUpdateRows = defineAuthorizedTableUseCase({ + operation: tableOperations.updateRows, + resolveContext: ({ input }: { input: CopilotBatchUpdateRowsInput }) => + resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise<CopilotBatchUpdateRowsResult> { + if (input.updates.length < 1 || input.updates.length > CSV_MAX_BATCH_SIZE) { + throw new OrchestrationError( + 'validation', + `Batch update count must be between 1 and ${CSV_MAX_BATCH_SIZE}` + ) + } + const idByName = buildIdByName(context.table.schema) + const updates = input.updates.map((update) => ({ + rowId: update.rowId, + data: rowDataNameToId(update.data, idByName), + })) + return batchUpdateRows( + { + tableId: context.tableId, + updates, + workspaceId: context.workspaceId, + actorUserId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + secretProvenanceByRowId: Object.fromEntries( + updates.map((update) => [ + update.rowId, + createExactEmptyTableRowSecretProvenance(update.data), + ]) + ), + }, + context.table, + requestId() + ) + }, + projectAudit({ context, result }) { + if (result.affectedCount === 0) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: context.tableId, + resourceName: context.table.name, + description: `Updated ${result.affectedCount} row(s) in table "${context.table.name}"`, + metadata: { op: 'batch_update', rowsUpdated: result.affectedCount }, + } + }, + afterSuccess({ context, result }) { + if (result.affectedCount > 0) signalTableRowsChanged(context.tableId) + }, +}) diff --git a/apps/sim/lib/table/application/copilot-table-lifecycle.test.ts b/apps/sim/lib/table/application/copilot-table-lifecycle.test.ts new file mode 100644 index 00000000000..51e04aa5bad --- /dev/null +++ b/apps/sim/lib/table/application/copilot-table-lifecycle.test.ts @@ -0,0 +1,196 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const mocks = vi.hoisted(() => ({ + audit: vi.fn(), + deleteTable: vi.fn(), + resolveActiveTableContext: vi.fn(), + resolvePermission: vi.fn(), + resolveWorkspaceContext: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_DELETED: 'table.deleted' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) +vi.mock('@/lib/table', () => ({ + deleteTable: mocks.deleteTable, + TABLE_LIMITS: { MAX_TABLES_PER_WORKSPACE: 100 }, +})) +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mocks.resolveActiveTableContext, + resolveTableWorkspaceContext: mocks.resolveWorkspaceContext, +})) + +import { deleteCopilotTables } from '@/lib/table/application/copilot-table-lifecycle' + +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-1', + audience: 'sim:tables', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), + resourceScope: { chatId: 'chat-1' }, +} + +describe('deleteCopilotTables', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveWorkspaceContext.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.resolveActiveTableContext.mockImplementation( + async ({ tableId }: { tableId: string }) => ({ + tableId, + workspaceId: 'workspace-1', + }) + ) + mocks.deleteTable.mockImplementation(async (tableId: string) => ({ + archived: { name: `Table ${tableId}`, workspaceId: 'workspace-1' }, + })) + }) + + it('canonically resolves each table and audits each authoritative archive', async () => { + const assertNotAborted = vi.fn() + const result = await deleteCopilotTables.execute({ + principal, + input: { + workspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2'], + assertNotAborted, + }, + }) + + expect(result).toEqual({ + deleted: [ + { id: 'table-1', name: 'Table table-1' }, + { id: 'table-2', name: 'Table table-2' }, + ], + failed: [], + }) + expect(mocks.resolveActiveTableContext).toHaveBeenNthCalledWith(1, { + tableId: 'table-1', + assertedWorkspaceId: 'workspace-1', + }) + expect(mocks.resolveActiveTableContext).toHaveBeenNthCalledWith(2, { + tableId: 'table-2', + assertedWorkspaceId: 'workspace-1', + }) + expect(assertNotAborted).toHaveBeenCalledTimes(2) + expect(mocks.audit).toHaveBeenCalledTimes(2) + expect(mocks.audit).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ resourceId: 'table-1', resourceName: 'Table table-1' }) + ) + }) + + it('conceals a cross-workspace table as a best-effort miss', async () => { + mocks.resolveActiveTableContext.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Table not found') + ) + + await expect( + deleteCopilotTables.execute({ + principal, + input: { + workspaceId: 'workspace-1', + tableIds: ['table-other'], + assertNotAborted: vi.fn(), + }, + }) + ).resolves.toEqual({ deleted: [], failed: ['table-other'] }) + + expect(mocks.deleteTable).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('rejects admission before canonical loads or mutation', async () => { + mocks.resolvePermission.mockResolvedValueOnce(null) + + await expect( + deleteCopilotTables.execute({ + principal, + input: { + workspaceId: 'workspace-1', + tableIds: ['table-1'], + assertNotAborted: vi.fn(), + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveActiveTableContext).not.toHaveBeenCalled() + expect(mocks.deleteTable).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('does not project partial audit when the compound command fails', async () => { + const failure = new Error('delete storage unavailable') + mocks.deleteTable + .mockResolvedValueOnce({ + archived: { name: 'Table table-1', workspaceId: 'workspace-1' }, + }) + .mockRejectedValueOnce(failure) + + await expect( + deleteCopilotTables.execute({ + principal, + input: { + workspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2'], + assertNotAborted: vi.fn(), + }, + }) + ).rejects.toBe(failure) + + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('checks cancellation immediately before each archive and stops partial progress', async () => { + const canceled = new Error('Request aborted before tool mutation could be applied') + const assertNotAborted = vi + .fn() + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => { + throw canceled + }) + + await expect( + deleteCopilotTables.execute({ + principal, + input: { + workspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2'], + assertNotAborted, + }, + }) + ).rejects.toBe(canceled) + + expect(mocks.resolveActiveTableContext).toHaveBeenCalledTimes(2) + expect(mocks.deleteTable).toHaveBeenCalledTimes(1) + expect(mocks.deleteTable).toHaveBeenCalledWith('table-1', 'request-1', { + expectedWorkspaceId: 'workspace-1', + }) + expect(mocks.audit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/copilot-table-lifecycle.ts b/apps/sim/lib/table/application/copilot-table-lifecycle.ts new file mode 100644 index 00000000000..57369da7510 --- /dev/null +++ b/apps/sim/lib/table/application/copilot-table-lifecycle.ts @@ -0,0 +1,85 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { deleteTable, TABLE_LIMITS } from '@/lib/table' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { + resolveActiveTableContext, + resolveTableWorkspaceContext, +} from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' + +export interface DeleteCopilotTablesInput { + workspaceId: string + tableIds: string[] + assertNotAborted: () => void +} + +export interface ArchivedCopilotTable { + id: string + name: string +} + +export interface DeleteCopilotTablesResult { + deleted: ArchivedCopilotTable[] + failed: string[] +} + +/** Owns Copilot's ordered, best-effort multi-table archive operation. */ +export const deleteCopilotTables = defineAuthorizedTableUseCase({ + operation: tableOperations.delete, + resolveContext: ({ input }: { input: DeleteCopilotTablesInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ input, context }): Promise<DeleteCopilotTablesResult> { + if ( + input.tableIds.length < 1 || + input.tableIds.length > TABLE_LIMITS.MAX_TABLES_PER_WORKSPACE + ) { + throw new OrchestrationError( + 'validation', + `Table ID count must be between 1 and ${TABLE_LIMITS.MAX_TABLES_PER_WORKSPACE}` + ) + } + if (input.tableIds.some((tableId) => typeof tableId !== 'string' || !tableId.trim())) { + throw new OrchestrationError('validation', 'Each table ID must be a non-empty string') + } + + const deleted: ArchivedCopilotTable[] = [] + const failed: string[] = [] + + for (const tableId of input.tableIds) { + try { + const tableContext = await resolveActiveTableContext({ + tableId, + assertedWorkspaceId: context.workspaceId, + }) + input.assertNotAborted() + const { archived } = await deleteTable(tableContext.tableId, generateRequestId(), { + expectedWorkspaceId: context.workspaceId, + }) + if (!archived) { + failed.push(tableId) + continue + } + + deleted.push({ id: tableId, name: archived.name }) + } catch (error) { + if (asOrchestrationError(error)?.code === 'not_found') { + failed.push(tableId) + continue + } + throw error + } + } + + return { deleted, failed } + }, + projectAudit: ({ result }) => + result.deleted.map((table) => ({ + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: table.name, + description: `Archived table "${table.name}"`, + })), +}) diff --git a/apps/sim/lib/table/application/exports.test.ts b/apps/sim/lib/table/application/exports.test.ts new file mode 100644 index 00000000000..736ecd7c0c2 --- /dev/null +++ b/apps/sim/lib/table/application/exports.test.ts @@ -0,0 +1,167 @@ +/** + * @vitest-environment node + */ + +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const mocks = vi.hoisted(() => ({ + cancel: vi.fn(), + create: vi.fn(), + getTable: vi.fn(), + require: vi.fn(), + resolveContext: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_EXPORTED: 'table.exported' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: vi.fn(), +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: vi.fn(), +})) +vi.mock('@/lib/table', () => ({ getTableById: mocks.getTable })) +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mocks.resolveContext, + resolveTableWorkspaceContext: vi.fn(async (workspaceId: string) => ({ + workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + })), +})) +vi.mock('@/lib/table/orchestration/export-resource', () => ({ + cancelTableExportResource: mocks.cancel, + createTableExportResource: mocks.create, + requireTableExport: mocks.require, + tableExportResult: vi.fn(), +})) +vi.mock('@/lib/uploads/core/storage-service', () => ({ + generatePresignedDownloadUrl: vi.fn(), +})) + +import { + cancelTableExportUseCase, + createTableExportUseCase, + readTableExportUseCase, +} from '@/lib/table/application/exports' + +const now = new Date('2026-08-01T00:00:00.000Z') +const table: TableDefinition = { + id: 'table-1', + name: 'People', + description: null, + schema: { columns: [] }, + metadata: null, + rowCount: 0, + maxRows: 100, + workspaceId: 'workspace-1', + createdBy: 'owner-1', + archivedAt: null, + createdAt: now, + updatedAt: now, +} +const record = { + id: 'export-1', + tableId: 'table-1', + workspaceId: 'workspace-1', + type: 'export', + status: 'running', + payload: { format: 'csv' }, + rowsProcessed: 0, + error: null, + startedAt: now, + updatedAt: now, + completedAt: null, +} +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', +} +const executor: WorkflowExecutionDelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:tables', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), + resourceScope: { tableId: 'table-1' }, + delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, +} + +describe('table export application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue({ + tableId: table.id, + table, + workspaceId: table.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.getTable.mockResolvedValue(table) + mocks.create.mockResolvedValue(record) + mocks.require.mockResolvedValue(record) + mocks.cancel.mockResolvedValue({ ...record, status: 'canceled' }) + }) + + it('returns domain records for create and read operations', async () => { + await expect( + createTableExportUseCase.execute({ + principal, + input: { tableId: 'table-1', workspaceId: 'workspace-1', format: 'csv' }, + }) + ).resolves.toEqual({ export: record }) + + await expect( + readTableExportUseCase.execute({ + principal, + input: { exportId: 'export-1', workspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ export: record }) + + expect(record.startedAt).toBeInstanceOf(Date) + }) + + it('returns the authoritative canceled domain record', async () => { + await expect( + cancelTableExportUseCase.execute({ + principal, + input: { exportId: 'export-1', workspaceId: 'workspace-1' }, + }) + ).resolves.toMatchObject({ export: { status: 'canceled', startedAt: now } }) + }) + + it('supports exact table-scoped executor create and unscoped resource reads', async () => { + await expect( + createTableExportUseCase.execute({ + principal: executor, + input: { tableId: 'table-1', workspaceId: 'workspace-1', format: 'csv' }, + }) + ).resolves.toEqual({ export: record }) + await expect( + readTableExportUseCase.execute({ + principal: { ...executor, resourceScope: undefined }, + input: { exportId: 'export-1', workspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ export: record }) + }) + + it('rejects a mismatched executor table scope before export mutation', async () => { + await expect( + createTableExportUseCase.execute({ + principal: { ...executor, resourceScope: { tableId: 'table-other' } }, + input: { tableId: 'table-1', workspaceId: 'workspace-1', format: 'csv' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.create).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/exports.ts b/apps/sim/lib/table/application/exports.ts index 9ff24c64d42..83414144bce 100644 --- a/apps/sim/lib/table/application/exports.ts +++ b/apps/sim/lib/table/application/exports.ts @@ -1,6 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { createLogger } from '@sim/logger' -import type { V2TableExport } from '@/lib/api/contracts/v2/tables' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getTableById, type TableDefinition } from '@/lib/table' import type { TableAuthorizationContext } from '@/lib/table/application/authorization' @@ -16,7 +15,6 @@ import { requireTableExport, type TableExportRecord, tableExportResult, - toV2TableExport, } from '@/lib/table/orchestration/export-resource' import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' @@ -35,7 +33,7 @@ export interface TableExportResourceInput { } export interface TableExportResult { - export: V2TableExport + export: TableExportRecord } export interface DownloadTableExportResult { @@ -46,7 +44,6 @@ export interface DownloadTableExportResult { interface TableExportContext extends TableAuthorizationContext { exportId: string - tableId: string table: TableDefinition record: TableExportRecord } @@ -63,7 +60,6 @@ async function resolveTableExportContext( return { ...workspace, exportId: record.id, - tableId: table.id, table, record, } @@ -85,7 +81,7 @@ export const createTableExportUseCase = defineAuthorizedTableUseCase({ format: input.format, principalKind: principal.kind, }) - return { export: toV2TableExport(record, true) } + return { export: record } }, projectAudit: ({ input, context }) => ({ action: AuditAction.TABLE_EXPORTED, @@ -102,7 +98,7 @@ export const readTableExportUseCase = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: TableExportResourceInput }) => resolveTableExportContext(input), async execute({ context }): Promise<TableExportResult> { - return { export: toV2TableExport(context.record) } + return { export: context.record } }, }) @@ -114,11 +110,11 @@ export const cancelTableExportUseCase = defineAuthorizedTableUseCase({ const record = await cancelTableExportResource(context.record) logger.info('Canceled table export', { exportId: record.id, - tableId: context.tableId, + tableId: context.table.id, workspaceId: context.workspaceId, principalKind: principal.kind, }) - return { export: toV2TableExport(record) } + return { export: record } }, }) diff --git a/apps/sim/lib/table/application/groups.test.ts b/apps/sim/lib/table/application/groups.test.ts new file mode 100644 index 00000000000..228467271b3 --- /dev/null +++ b/apps/sim/lib/table/application/groups.test.ts @@ -0,0 +1,584 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { TableDefinition, WorkflowGroup } from '@/lib/table/types' + +const mocks = vi.hoisted(() => ({ + addGroup: vi.fn(), + addOutput: vi.fn(), + audit: vi.fn(), + deleteOutput: vi.fn(), + getEnrichment: vi.fn(), + loadWorkflowOutputs: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + resolveWorkflowContext: vi.fn(), + signal: vi.fn(), + updateGroup: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_UPDATED: 'table.updated' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@sim/utils/id', () => ({ generateId: () => 'generated-id' })) +vi.mock('@/enrichments/registry', () => ({ getEnrichment: mocks.getEnrichment })) +vi.mock('@/lib/core/utils/background', () => ({ runDetached: vi.fn() })) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mocks.resolveContext, +})) +vi.mock('@/lib/table/column-naming', () => ({ + columnTypeForLeaf: (leafType: string | undefined) => + leafType === 'number' ? 'number' : 'string', + deriveOutputColumnName: (path: string, taken: Set<string>) => { + const base = path.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase() + if (!taken.has(base)) return base + return `${base}_0` + }, +})) +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mocks.signal })) +vi.mock('@/lib/table/workflow-columns', () => ({ runWorkflowColumn: vi.fn() })) +vi.mock('@/lib/table/workflow-groups/service', () => ({ + addWorkflowGroup: mocks.addGroup, + addWorkflowGroupOutput: mocks.addOutput, + deleteWorkflowGroup: vi.fn(), + deleteWorkflowGroupOutput: mocks.deleteOutput, + updateWorkflowGroup: mocks.updateGroup, +})) +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, +})) +vi.mock('@/lib/workflows/application/resolve-workflow-outputs', () => ({ + loadResolvedWorkflowOutputs: mocks.loadWorkflowOutputs, +})) + +import { + addWorkflowTableGroupOutput, + createTableEnrichmentGroup, + createTableGroupUseCase, + createWorkflowTableGroup, + deleteTableGroupOutputUseCase, + updateTableGroupUseCase, + updateWorkflowTableGroup, +} from '@/lib/table/application/groups' + +const group: WorkflowGroup = { + id: 'group-1', + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-1', path: 'content', columnName: 'column-result' }], +} +const table: TableDefinition = { + id: 'table-1', + name: 'People', + description: null, + schema: { + columns: [ + { id: 'column-name', name: 'name', type: 'string' }, + { id: 'column-result', name: 'result', type: 'string', workflowGroupId: 'group-1' }, + ], + workflowGroups: [group], + }, + metadata: null, + rowCount: 1, + maxRows: 100, + workspaceId: 'workspace-1', + createdBy: 'owner-1', + archivedAt: null, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-1', + audience: 'sim:tables', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), + resourceScope: { tableId: 'table-1' }, +} +const resolvedWorkflow = { + workflowId: 'workflow-1', + outputs: [ + { + blockId: 'block-1', + blockName: 'Agent', + blockType: 'agent', + path: 'content', + leafType: 'string', + }, + { + blockId: 'block-2', + blockName: 'Scorer', + blockType: 'function', + path: 'score', + leafType: 'number', + }, + ], + executionOrderByBlockId: { 'block-1': 1, 'block-2': 2 }, +} + +function tableWithGroup(nextGroup: WorkflowGroup, columns = table.schema.columns): TableDefinition { + return { + ...table, + schema: { ...table.schema, columns, workflowGroups: [nextGroup] }, + updatedAt: new Date('2026-08-02T00:00:00.000Z'), + } +} + +describe('workflow and enrichment Table application commands', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveContext.mockResolvedValue({ + tableId: table.id, + table, + workspaceId: table.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.resolveWorkflowContext.mockResolvedValue({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + }) + mocks.loadWorkflowOutputs.mockResolvedValue(resolvedWorkflow) + mocks.addGroup.mockImplementation(async ({ group: nextGroup, outputColumns }) => + tableWithGroup(nextGroup, [...table.schema.columns, ...outputColumns]) + ) + mocks.addOutput.mockResolvedValue(table) + mocks.deleteOutput.mockResolvedValue(table) + mocks.updateGroup.mockImplementation(async (input) => + tableWithGroup({ + ...group, + ...(input.workflowId ? { workflowId: input.workflowId } : {}), + ...(input.name ? { name: input.name } : {}), + ...(input.outputs ? { outputs: input.outputs } : {}), + ...(input.autoRun !== undefined ? { autoRun: input.autoRun } : {}), + }) + ) + mocks.getEnrichment.mockReturnValue({ + id: 'company-domain', + name: 'Company Domain', + inputs: [{ id: 'company', name: 'Company', type: 'string', required: true }], + outputs: [{ id: 'domain', name: 'domain', type: 'string' }], + }) + }) + + it('owns workflow resolution plus group and column construction', async () => { + const result = await createWorkflowTableGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + workflowId: 'workflow-1', + name: 'Scoring', + outputs: [{ blockId: 'block-2', path: 'score' }], + }, + }) + + expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + }) + expect(mocks.addGroup).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: table.id, + workspaceId: table.workspaceId, + group: expect.objectContaining({ + id: 'generated-id', + workflowId: 'workflow-1', + name: 'Scoring', + autoRun: false, + outputs: [{ blockId: 'block-2', path: 'score', columnName: 'score' }], + }), + outputColumns: [ + expect.objectContaining({ + name: 'score', + type: 'number', + workflowGroupId: 'generated-id', + }), + ], + }), + 'request-1' + ) + expect(result.group.id).toBe('generated-id') + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.signal).toHaveBeenCalledWith(table.id) + }) + + it('persists disabled auto-run on a newly created workflow group', async () => { + const result = await createWorkflowTableGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-2', path: 'score' }], + autoRun: false, + }, + }) + + expect(result.group.autoRun).toBe(false) + expect(mocks.addGroup).toHaveBeenCalledWith( + expect.objectContaining({ + group: expect.objectContaining({ autoRun: false }), + autoRun: false, + }), + 'request-1' + ) + }) + + it('conceals a cross-workspace workflow before group mutation or effects', async () => { + mocks.resolveWorkflowContext.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Workflow not found') + ) + + await expect( + createWorkflowTableGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + workflowId: 'workflow-other', + outputs: [{ blockId: 'block-2', path: 'score' }], + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({ + workflowId: 'workflow-other', + assertedWorkspaceId: table.workspaceId, + }) + expect(mocks.addGroup).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) + + it('preserves the internal create contract for an invalid related workflow', async () => { + mocks.resolveWorkflowContext.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Workflow not found') + ) + + await expect( + createTableGroupUseCase.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + group: { + id: 'group-new', + workflowId: 'workflow-other', + outputs: [{ blockId: 'block-2', path: 'score', columnName: 'score' }], + }, + outputColumns: [{ name: 'score', type: 'number' }], + }, + }) + ).rejects.toMatchObject({ code: 'validation', message: 'Invalid workflow ID' }) + + expect(mocks.addGroup).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('preserves the internal update contract for an invalid related workflow', async () => { + mocks.resolveWorkflowContext.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Workflow not found') + ) + + await expect( + updateTableGroupUseCase.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + workflowId: 'workflow-other', + }, + }) + ).rejects.toMatchObject({ code: 'validation', message: 'Invalid workflow ID' }) + + expect(mocks.updateGroup).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('rejects an invalid output before constructing or mutating the group', async () => { + await expect( + createWorkflowTableGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + workflowId: 'workflow-1', + outputs: [{ blockId: 'missing', path: 'value' }], + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.addGroup).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('rejects oversized workflow output construction before resolution or mutation', async () => { + await expect( + createWorkflowTableGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + workflowId: 'workflow-1', + outputs: Array.from({ length: 1001 }, (_, index) => ({ + blockId: `block-${index}`, + path: 'content', + })), + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled() + expect(mocks.addGroup).not.toHaveBeenCalled() + }) + + it('constructs new columns while preserving existing bindings during restructure', async () => { + await updateWorkflowTableGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + outputs: [ + { blockId: 'block-1', path: 'content', columnName: 'ignored-rename' }, + { blockId: 'block-2', path: 'score', columnName: 'score_value' }, + ], + }, + }) + + expect(mocks.updateGroup).toHaveBeenCalledWith( + expect.objectContaining({ + outputs: [ + { blockId: 'block-1', path: 'content', columnName: 'column-result' }, + { blockId: 'block-2', path: 'score', columnName: 'score_value' }, + ], + newOutputColumns: [ + expect.objectContaining({ + name: 'score_value', + type: 'number', + workflowGroupId: group.id, + }), + ], + }), + 'request-1' + ) + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.signal).toHaveBeenCalledWith(table.id) + }) + + it('allows a replacement output to reuse the removed output column name', async () => { + await updateWorkflowTableGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + outputs: [{ blockId: 'block-2', path: 'score', columnName: 'result' }], + }, + }) + + expect(mocks.updateGroup).toHaveBeenCalledWith( + expect.objectContaining({ + outputs: [{ blockId: 'block-2', path: 'score', columnName: 'result' }], + newOutputColumns: [ + expect.objectContaining({ + name: 'result', + type: 'number', + workflowGroupId: group.id, + }), + ], + }), + 'request-1' + ) + }) + + it('propagates a concurrent schema conflict without audit or effects', async () => { + const conflict = Object.assign(new Error('retry the update'), { code: 'conflict' }) + mocks.updateGroup.mockRejectedValueOnce(conflict) + + await expect( + updateWorkflowTableGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + mappingUpdates: [{ columnName: 'column-result', blockId: 'block-2', path: 'score' }], + }, + }) + ).rejects.toBe(conflict) + + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) + + it('does not audit or signal an authoritative no-op group update', async () => { + mocks.updateGroup.mockResolvedValueOnce(table) + + const result = await updateWorkflowTableGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + name: group.name, + }, + }) + + expect(result.changed).toBe(false) + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) + + it('passes authorized output type and ordering to the add-output mutation', async () => { + await addWorkflowTableGroupOutput.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + blockId: 'block-2', + path: 'score', + }, + }) + + expect(mocks.addOutput).toHaveBeenCalledWith( + expect.objectContaining({ + resolvedOutput: expect.objectContaining({ + workflowId: 'workflow-1', + columnType: 'number', + order: expect.arrayContaining([ + expect.objectContaining({ blockId: 'block-2', executionDistance: 2 }), + ]), + }), + }), + 'request-1' + ) + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.signal).toHaveBeenCalledWith(table.id) + }) + + it('rejects adding a workflow output to an enrichment group before resolution or mutation', async () => { + mocks.resolveContext.mockResolvedValueOnce({ + tableId: table.id, + table: tableWithGroup({ + id: 'enrichment-group-1', + type: 'enrichment', + workflowId: '', + enrichmentId: 'company-domain', + outputs: [], + }), + workspaceId: table.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + + await expect( + addWorkflowTableGroupOutput.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: 'enrichment-group-1', + blockId: 'block-2', + path: 'score', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled() + expect(mocks.loadWorkflowOutputs).not.toHaveBeenCalled() + expect(mocks.addOutput).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) + + it('validates enrichment mappings before constructing the group', async () => { + await expect( + createTableEnrichmentGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + enrichmentId: 'company-domain', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.addGroup).not.toHaveBeenCalled() + + const result = await createTableEnrichmentGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + enrichmentId: 'company-domain', + inputMappings: [{ inputName: 'company', columnName: 'name' }], + }, + }) + + expect(mocks.addGroup).toHaveBeenCalledWith( + expect.objectContaining({ + group: expect.objectContaining({ + id: 'generated-id', + enrichmentId: 'company-domain', + inputMappings: [{ inputName: 'company', columnName: 'name' }], + dependencies: { columns: ['name'] }, + outputs: [{ blockId: '', path: '', outputId: 'domain', columnName: 'domain' }], + }), + outputColumns: [ + expect.objectContaining({ name: 'domain', workflowGroupId: 'generated-id' }), + ], + }), + 'request-1' + ) + expect(result.group.enrichmentId).toBe('company-domain') + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.signal).toHaveBeenCalledWith(table.id) + }) + + it('deletes an output with authoritative audit and schema effects', async () => { + await deleteTableGroupOutputUseCase.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + columnName: 'result', + }, + }) + + expect(mocks.deleteOutput).toHaveBeenCalledWith( + { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + columnName: 'result', + }, + 'request-1' + ) + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.signal).toHaveBeenCalledWith(table.id) + }) +}) diff --git a/apps/sim/lib/table/application/groups.ts b/apps/sim/lib/table/application/groups.ts index 665e887374f..143a4a4213a 100644 --- a/apps/sim/lib/table/application/groups.ts +++ b/apps/sim/lib/table/application/groups.ts @@ -1,29 +1,42 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' -import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' import { generateId } from '@sim/utils/id' import type { V2AddWorkflowGroupBody } from '@/lib/api/contracts/v2/tables' -import { OrchestrationError } from '@/lib/core/orchestration/types' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' -import type { - DeleteWorkflowGroupData, - TableDefinition, - TableSchema, - UpdateWorkflowGroupData, - WorkflowGroup, +import { + type ColumnDefinition, + type DeleteWorkflowGroupData, + getColumnId, + TABLE_LIMITS, + type TableDefinition, + type TableSchema, + type UpdateWorkflowGroupData, + type WorkflowGroup, + type WorkflowGroupDependencies, + type WorkflowGroupDeploymentMode, + type WorkflowGroupInputMapping, + type WorkflowGroupOutput, } from '@/lib/table' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' import { resolveActiveTableContext } from '@/lib/table/application/context' import { tableOperations } from '@/lib/table/application/operations' -import { startTableRun } from '@/lib/table/application/runs' +import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' import { signalTableSchemaChanged } from '@/lib/table/events' +import { runWorkflowColumn } from '@/lib/table/workflow-columns' import { addWorkflowGroup, + addWorkflowGroupOutput, deleteWorkflowGroup, + deleteWorkflowGroupOutput, updateWorkflowGroup, } from '@/lib/table/workflow-groups/service' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import type { ResolveWorkflowOutputsResult } from '@/lib/workflows/application/resolve-workflow-outputs' +import { loadResolvedWorkflowOutputs } from '@/lib/workflows/application/resolve-workflow-outputs' +import { getEnrichment } from '@/enrichments/registry' const logger = createLogger('TableGroupApplication') @@ -42,14 +55,116 @@ function groupFromTable(table: TableDefinition, groupId: string): WorkflowGroup return group } -async function requireWorkflowInTableWorkspace( +async function resolveWorkflowForAuthorizedTableCommand( + workflowId: string, + workspaceId: string +): Promise<ResolveWorkflowOutputsResult> { + const workflowContext = await resolveActiveWorkflowApplicationContext({ + workflowId, + assertedWorkspaceId: workspaceId, + }) + return loadResolvedWorkflowOutputs(workflowContext) +} + +async function resolveRelatedWorkflowForTableRoute( workflowId: string, workspaceId: string -): Promise<void> { - const workflow = await getActiveWorkflowContext(workflowId) - if (!workflow || workflow.workspaceId !== workspaceId) { - throw new OrchestrationError('validation', 'Workflow not found in this workspace') +): Promise<ResolveWorkflowOutputsResult> { + try { + return await resolveWorkflowForAuthorizedTableCommand(workflowId, workspaceId) + } catch (error) { + if (asOrchestrationError(error)?.code === 'not_found') { + throw new OrchestrationError('validation', 'Invalid workflow ID') + } + throw error + } +} + +function requireWorkflowOutputs( + resolved: ResolveWorkflowOutputsResult, + workflowId: string +): NonNullable<ResolveWorkflowOutputsResult['outputs']> { + if (!resolved.outputs) { + throw new OrchestrationError('validation', `Workflow has no pickable outputs: ${workflowId}`) } + return resolved.outputs +} + +function requireBoundedGroupItems(items: readonly unknown[] | undefined, label: string): void { + if ((items?.length ?? 0) > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { + throw new OrchestrationError( + 'validation', + `${label} cannot exceed ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} entries` + ) + } +} + +function validateRequestedOutputs( + requested: Array<{ blockId: string; path: string }>, + resolved: ResolveWorkflowOutputsResult, + workflowId: string +): NonNullable<ResolveWorkflowOutputsResult['outputs']> { + const outputs = requireWorkflowOutputs(resolved, workflowId) + const valid = new Set(outputs.map((output) => `${output.blockId}::${output.path}`)) + const invalid = requested.filter((output) => !valid.has(`${output.blockId}::${output.path}`)) + if (invalid.length === 0) return outputs + + const sample = outputs + .slice(0, 12) + .map((output) => ` - ${output.blockId} (${output.blockName}) → ${output.path}`) + .join('\n') + const invalidList = invalid.map((output) => ` - ${output.blockId} → ${output.path}`).join('\n') + throw new OrchestrationError( + 'validation', + `Invalid output(s) for workflow ${workflowId}:\n${invalidList}\n\nValid options${outputs.length > 12 ? ' (first 12)' : ''}:\n${sample}` + ) +} + +function workflowOutputColumnType( + requestedType: string | undefined, + resolvedLeafType: string | undefined +): ColumnDefinition['type'] { + if (requestedType === undefined) return columnTypeForLeaf(resolvedLeafType) + const type = columnTypeForLeaf(requestedType) + if (type !== requestedType) { + throw new OrchestrationError( + 'validation', + `Invalid workflow output column type "${requestedType}"` + ) + } + return type +} + +function attributedUserId( + principal: Parameters<typeof resolvePrincipalAttribution>[0], + billedAccountUserId: string +): string { + return resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: billedAccountUserId, + }).attributedUserId +} + +function dispatchGroupAutoRun(params: { + tableId: string + workspaceId: string + groupId: string + actorUserId: string + label: string +}): void { + runDetached(params.label, async () => { + await runWorkflowColumn({ + tableId: params.tableId, + workspaceId: params.workspaceId, + groupIds: [params.groupId], + mode: 'all', + requestId: generateRequestId(), + triggeredByUserId: params.actorUserId, + }) + logger.info('Started table group auto-run', { + tableId: params.tableId, + groupId: params.groupId, + }) + }) } export const listTableGroupsUseCase = defineAuthorizedTableUseCase({ @@ -78,8 +193,11 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ assertedWorkspaceId: input.workspaceId, }), async execute({ principal, input, context }) { + requireBoundedGroupItems(input.group.outputs, 'Workflow group outputs') + requireBoundedGroupItems(input.outputColumns, 'Workflow group output columns') + requireBoundedGroupItems(input.group.inputMappings, 'Workflow group input mappings') if (input.group.workflowId) { - await requireWorkflowInTableWorkspace(input.group.workflowId, context.workspaceId) + await resolveRelatedWorkflowForTableRoute(input.group.workflowId, context.workspaceId) } const outputNames = new Set(input.group.outputs.map((output) => output.columnName)) const orphan = input.outputColumns.find((column) => !outputNames.has(column.name)) @@ -90,9 +208,7 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ ) } - const attribution = resolvePrincipalAttribution(principal, { - workspaceBillingOwnerUserId: context.billedAccountUserId, - }) + const actorUserId = attributedUserId(principal, context.billedAccountUserId) const groupId = input.group.id ?? generateId() const table = await addWorkflowGroup( { @@ -105,11 +221,11 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ })), autoRun: input.autoRun ?? false, suppressAutoRunDispatch: true, - actorUserId: attribution.attributedUserId, + actorUserId, }, generateRequestId() ) - return { table, group: groupFromTable(table, groupId) } + return { table, group: groupFromTable(table, groupId), actorUserId } }, projectAudit({ result }) { return { @@ -121,25 +237,290 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ metadata: { op: 'add_group', groupId: result.group.id }, } }, - afterSuccess({ principal, input, context, result, request }) { + afterSuccess({ input, context, result }) { signalTableSchemaChanged(context.table.id) if (input.autoRun === true) { - runDetached('table-group-create-auto-run', async () => { - await startTableRun.execute({ - principal, - input: { - kind: 'selection', - tableId: context.table.id, - assertedWorkspaceId: context.workspaceId, - groupIds: [result.group.id], - mode: 'all', - }, - request, - }) - logger.info('Started table group auto-run', { - tableId: context.table.id, - groupId: result.group.id, - }) + dispatchGroupAutoRun({ + tableId: context.table.id, + workspaceId: context.workspaceId, + groupId: result.group.id, + actorUserId: result.actorUserId, + label: 'table-group-create-auto-run', + }) + } + }, +}) + +export interface CreateWorkflowTableGroupInput extends TableGroupInput { + workflowId: string + outputs: Array<{ + blockId: string + path: string + columnName?: string + columnType?: string + }> + name?: string + dependencies?: WorkflowGroupDependencies + deploymentMode?: WorkflowGroupDeploymentMode + autoRun?: boolean +} + +/** Creates a workflow-backed group from requested workflow output coordinates. */ +export const createWorkflowTableGroup = defineAuthorizedTableUseCase({ + operation: tableOperations.createGroup, + resolveContext: ({ input }: { input: CreateWorkflowTableGroupInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, input, context }) { + requireBoundedGroupItems(input.outputs, 'Workflow group outputs') + if (input.outputs.length === 0) { + throw new OrchestrationError('validation', 'At least one workflow output is required') + } + if (input.outputs.some((output) => !output.blockId || !output.path)) { + throw new OrchestrationError( + 'validation', + 'Each output entry must include both blockId and path' + ) + } + + const resolvedWorkflow = await resolveWorkflowForAuthorizedTableCommand( + input.workflowId, + context.workspaceId + ) + const canonicalOutputs = validateRequestedOutputs( + input.outputs, + resolvedWorkflow, + input.workflowId + ) + const leafTypeByKey = new Map( + canonicalOutputs.map((output) => [`${output.blockId}::${output.path}`, output.leafType]) + ) + const taken = new Set(context.table.schema.columns.map((column) => column.name)) + const groupId = generateId() + const outputs: WorkflowGroupOutput[] = [] + const outputColumns: ColumnDefinition[] = [] + for (const requested of input.outputs) { + const columnName = requested.columnName ?? deriveOutputColumnName(requested.path, taken) + taken.add(columnName) + outputs.push({ + blockId: requested.blockId, + path: requested.path, + columnName, + }) + outputColumns.push({ + name: columnName, + type: workflowOutputColumnType( + requested.columnType, + leafTypeByKey.get(`${requested.blockId}::${requested.path}`) + ), + required: false, + unique: false, + workflowGroupId: groupId, + }) + } + + const group: WorkflowGroup = { + id: groupId, + workflowId: input.workflowId, + ...(input.name ? { name: input.name } : {}), + ...(input.dependencies ? { dependencies: input.dependencies } : {}), + ...(input.deploymentMode ? { deploymentMode: input.deploymentMode } : {}), + autoRun: input.autoRun ?? false, + outputs, + } + const actorUserId = attributedUserId(principal, context.billedAccountUserId) + const table = await addWorkflowGroup( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + group, + outputColumns, + autoRun: input.autoRun ?? false, + suppressAutoRunDispatch: true, + actorUserId, + }, + generateRequestId() + ) + return { table, group: groupFromTable(table, groupId), actorUserId } + }, + projectAudit({ result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Added workflow group "${result.group.id}" to table "${result.table.name}"`, + metadata: { op: 'add_workflow_group', groupId: result.group.id }, + } + }, + afterSuccess({ input, context, result }) { + signalTableSchemaChanged(context.tableId) + if (input.autoRun === true) { + dispatchGroupAutoRun({ + tableId: context.tableId, + workspaceId: context.workspaceId, + groupId: result.group.id, + actorUserId: result.actorUserId, + label: 'table-workflow-group-create-auto-run', + }) + } + }, +}) + +export interface CreateTableEnrichmentGroupInput extends TableGroupInput { + enrichmentId: string + inputMappings?: Array<{ inputName: string; columnName: string }> + outputColumnNames?: Record<string, string> + dependencies?: WorkflowGroupDependencies + name?: string + autoRun?: boolean +} + +/** Creates an enrichment group from the code-defined enrichment registry. */ +export const createTableEnrichmentGroup = defineAuthorizedTableUseCase({ + operation: tableOperations.createGroup, + resolveContext: ({ input }: { input: CreateTableEnrichmentGroupInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, input, context }) { + requireBoundedGroupItems(input.inputMappings, 'Enrichment input mappings') + if (Object.keys(input.outputColumnNames ?? {}).length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { + throw new OrchestrationError( + 'validation', + `Enrichment output names cannot exceed ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} entries` + ) + } + const enrichment = getEnrichment(input.enrichmentId) + if (!enrichment) { + throw new OrchestrationError( + 'validation', + `Unknown enrichment "${input.enrichmentId}". Call list_enrichments to see available ids.` + ) + } + + const enrichmentInputIds = new Set( + enrichment.inputs.map((enrichmentInput) => enrichmentInput.id) + ) + const mappingByInput = new Map<string, string>() + for (const mapping of input.inputMappings ?? []) { + if (!enrichmentInputIds.has(mapping.inputName)) { + throw new OrchestrationError( + 'validation', + `Enrichment "${enrichment.name}" has no input "${mapping.inputName}"` + ) + } + if (mappingByInput.has(mapping.inputName)) { + throw new OrchestrationError( + 'validation', + `Enrichment input "${mapping.inputName}" cannot be mapped more than once` + ) + } + mappingByInput.set(mapping.inputName, mapping.columnName) + } + const enrichmentOutputIds = new Set(enrichment.outputs.map((output) => output.id)) + for (const outputId of Object.keys(input.outputColumnNames ?? {})) { + if (!enrichmentOutputIds.has(outputId)) { + throw new OrchestrationError( + 'validation', + `Enrichment "${enrichment.name}" has no output "${outputId}"` + ) + } + } + const existingColumns = new Set(context.table.schema.columns.map((column) => column.name)) + for (const enrichmentInput of enrichment.inputs) { + const mapped = mappingByInput.get(enrichmentInput.id) + if (enrichmentInput.required && !mapped) { + throw new OrchestrationError( + 'validation', + `Enrichment "${enrichment.name}" requires input "${enrichmentInput.id}" to be mapped to a column` + ) + } + if (mapped && !existingColumns.has(mapped)) { + throw new OrchestrationError( + 'validation', + `Mapped column "${mapped}" for input "${enrichmentInput.id}" does not exist on table ${context.tableId}` + ) + } + } + + const inputMappings: WorkflowGroupInputMapping[] = enrichment.inputs + .filter((enrichmentInput) => mappingByInput.has(enrichmentInput.id)) + .map((enrichmentInput) => ({ + inputName: enrichmentInput.id, + columnName: mappingByInput.get(enrichmentInput.id) as string, + })) + const taken = new Set(context.table.schema.columns.map((column) => column.name)) + const groupId = generateId() + const outputs: WorkflowGroupOutput[] = [] + const outputColumns: ColumnDefinition[] = [] + for (const output of enrichment.outputs) { + const desired = (input.outputColumnNames?.[output.id] ?? '').trim() || output.name + const columnName = deriveOutputColumnName(desired, taken) + taken.add(columnName) + outputs.push({ blockId: '', path: '', outputId: output.id, columnName }) + outputColumns.push({ + name: columnName, + type: output.type, + required: false, + unique: false, + workflowGroupId: groupId, + }) + } + + const name = input.name ?? enrichment.name + const group: WorkflowGroup = { + id: groupId, + workflowId: '', + enrichmentId: input.enrichmentId, + name, + type: 'enrichment', + dependencies: input.dependencies ?? { columns: inputMappings.map((item) => item.columnName) }, + outputs, + inputMappings, + autoRun: input.autoRun ?? false, + } + const actorUserId = attributedUserId(principal, context.billedAccountUserId) + const table = await addWorkflowGroup( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + group, + outputColumns, + autoRun: input.autoRun ?? false, + suppressAutoRunDispatch: true, + actorUserId, + }, + generateRequestId() + ) + return { table, group: groupFromTable(table, groupId), actorUserId } + }, + projectAudit({ result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Added enrichment "${result.group.name ?? result.group.id}" to table "${result.table.name}"`, + metadata: { + op: 'add_enrichment', + groupId: result.group.id, + enrichmentId: result.group.enrichmentId, + }, + } + }, + afterSuccess({ input, context, result }) { + signalTableSchemaChanged(context.tableId) + if (input.autoRun === true) { + dispatchGroupAutoRun({ + tableId: context.tableId, + workspaceId: context.workspaceId, + groupId: result.group.id, + actorUserId: result.actorUserId, + label: 'table-enrichment-group-create-auto-run', }) } }, @@ -160,21 +541,61 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ assertedWorkspaceId: input.workspaceId, }), async execute({ principal, input, context }) { - if (input.workflowId !== undefined) { - await requireWorkflowInTableWorkspace(input.workflowId, context.workspaceId) - } - const attribution = resolvePrincipalAttribution(principal, { - workspaceBillingOwnerUserId: context.billedAccountUserId, - }) + requireBoundedGroupItems(input.outputs, 'Workflow group outputs') + requireBoundedGroupItems(input.newOutputColumns, 'Workflow group output columns') + requireBoundedGroupItems(input.mappingUpdates, 'Workflow group mapping updates') + requireBoundedGroupItems(input.inputMappings, 'Workflow group input mappings') const previousGroup = (context.table.schema.workflowGroups ?? []).find( (group) => group.id === input.groupId ) + const workflowMetadataRequired = + input.workflowId !== undefined || + input.outputs !== undefined || + (input.mappingUpdates?.length ?? 0) > 0 + const targetWorkflowId = input.workflowId ?? previousGroup?.workflowId + let resolvedWorkflow: ResolveWorkflowOutputsResult | undefined + if (workflowMetadataRequired) { + if (!targetWorkflowId) { + throw new OrchestrationError('not_found', 'Workflow not found') + } + resolvedWorkflow = await resolveRelatedWorkflowForTableRoute( + targetWorkflowId, + context.workspaceId + ) + if (input.outputs && input.outputs.length > 0) { + validateRequestedOutputs(input.outputs, resolvedWorkflow, targetWorkflowId) + } + } + const actorUserId = attributedUserId(principal, context.billedAccountUserId) + const hasMappingUpdates = Boolean(input.mappingUpdates && input.mappingUpdates.length > 0) + if (hasMappingUpdates && !resolvedWorkflow) { + throw new Error('Workflow metadata is required for workflow group mapping updates') + } + const resolvedMappingTypes = + input.mappingUpdates && input.mappingUpdates.length > 0 && resolvedWorkflow + ? { + workflowId: resolvedWorkflow.workflowId, + columns: input.mappingUpdates.map((mapping) => { + const output = resolvedWorkflow.outputs?.find( + (candidate) => + candidate.blockId === mapping.blockId && candidate.path === mapping.path + ) + if (!output) { + throw new OrchestrationError( + 'validation', + `Output ${mapping.blockId}::${mapping.path} is not a valid pickable output on workflow ${targetWorkflowId}` + ) + } + return { columnName: mapping.columnName, type: columnTypeForLeaf(output.leafType) } + }), + } + : undefined const table = await updateWorkflowGroup( { tableId: context.table.id, workspaceId: context.workspaceId, groupId: input.groupId, - actorUserId: attribution.attributedUserId, + actorUserId, suppressAutoRunDispatch: true, ...(input.workflowId !== undefined ? { workflowId: input.workflowId } : {}), ...(input.name !== undefined ? { name: input.name } : {}), @@ -189,6 +610,7 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ } : {}), ...(input.mappingUpdates !== undefined ? { mappingUpdates: input.mappingUpdates } : {}), + ...(resolvedMappingTypes ? { resolvedMappingTypes } : {}), ...(input.inputMappings !== undefined ? { inputMappings: input.inputMappings } : {}), ...(input.deploymentMode !== undefined ? { deploymentMode: input.deploymentMode } : {}), ...(input.type !== undefined ? { type: input.type } : {}), @@ -204,6 +626,7 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ JSON.stringify(context.table.schema) !== JSON.stringify(table.schema) || JSON.stringify(context.table.metadata) !== JSON.stringify(table.metadata), startAutoRun: previousGroup?.autoRun !== true && input.autoRun === true, + actorUserId, } }, projectAudit({ result }) { @@ -217,25 +640,204 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ metadata: { op: 'update_group', groupId: result.group.id }, } }, - afterSuccess({ principal, context, result, request }) { + afterSuccess({ context, result }) { if (result.changed) signalTableSchemaChanged(context.table.id) if (result.startAutoRun) { - runDetached('table-group-update-auto-run', async () => { - await startTableRun.execute({ - principal, - input: { - kind: 'selection', - tableId: context.table.id, - assertedWorkspaceId: context.workspaceId, - groupIds: [result.group.id], - mode: 'all', - }, - request, + dispatchGroupAutoRun({ + tableId: context.table.id, + workspaceId: context.workspaceId, + groupId: result.group.id, + actorUserId: result.actorUserId, + label: 'table-group-update-auto-run', + }) + } + }, +}) + +export interface UpdateWorkflowTableGroupInput extends TableGroupInput { + groupId: string + workflowId?: string + name?: string + dependencies?: WorkflowGroupDependencies + outputs?: Array<{ + blockId: string + path: string + columnName?: string + columnType?: string + }> + mappingUpdates?: Array<{ columnName: string; blockId: string; path: string }> + deploymentMode?: WorkflowGroupDeploymentMode + autoRun?: boolean +} + +/** Updates a workflow-backed group from output coordinates rather than caller-built columns. */ +export const updateWorkflowTableGroup = defineAuthorizedTableUseCase({ + operation: tableOperations.updateGroup, + resolveContext: ({ input }: { input: UpdateWorkflowTableGroupInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, input, context }) { + requireBoundedGroupItems(input.outputs, 'Workflow group outputs') + requireBoundedGroupItems(input.mappingUpdates, 'Workflow group mapping updates') + const previousGroup = context.table.schema.workflowGroups?.find( + (candidate) => candidate.id === input.groupId + ) + if (!previousGroup) { + throw new OrchestrationError('not_found', `Workflow group "${input.groupId}" not found`) + } + if (previousGroup.type === 'enrichment' || !previousGroup.workflowId) { + throw new OrchestrationError( + 'validation', + `Workflow group "${input.groupId}" is not backed by a workflow` + ) + } + + const targetWorkflowId = input.workflowId ?? previousGroup.workflowId + const workflowMetadataRequired = + input.workflowId !== undefined || + input.outputs !== undefined || + (input.mappingUpdates?.length ?? 0) > 0 + const resolvedWorkflow = workflowMetadataRequired + ? await resolveWorkflowForAuthorizedTableCommand(targetWorkflowId, context.workspaceId) + : undefined + if (input.outputs && resolvedWorkflow) { + validateRequestedOutputs(input.outputs, resolvedWorkflow, targetWorkflowId) + } else if (input.workflowId && resolvedWorkflow) { + validateRequestedOutputs(previousGroup.outputs, resolvedWorkflow, targetWorkflowId) + } + + let outputs: WorkflowGroupOutput[] | undefined + let newOutputColumns: ColumnDefinition[] | undefined + if (input.outputs) { + if (!resolvedWorkflow) { + throw new Error('Workflow metadata is required to restructure workflow outputs') + } + const canonicalOutputs = requireWorkflowOutputs(resolvedWorkflow, targetWorkflowId) + const leafTypeByKey = new Map( + canonicalOutputs.map((output) => [`${output.blockId}::${output.path}`, output.leafType]) + ) + const existingByKey = new Map( + previousGroup.outputs.map((output) => [`${output.blockId}::${output.path}`, output]) + ) + const requestedKeys = new Set( + input.outputs.map((output) => `${output.blockId}::${output.path}`) + ) + const releasedColumnIds = new Set( + previousGroup.outputs + .filter((output) => !requestedKeys.has(`${output.blockId}::${output.path}`)) + .map((output) => output.columnName) + ) + const taken = new Set( + context.table.schema.columns + .filter((column) => !releasedColumnIds.has(getColumnId(column))) + .map((column) => column.name) + ) + outputs = [] + newOutputColumns = [] + for (const requested of input.outputs) { + const key = `${requested.blockId}::${requested.path}` + const existing = existingByKey.get(key) + if (existing) { + outputs.push(existing) + continue + } + const requestedName = requested.columnName?.trim() + const columnName = requestedName || deriveOutputColumnName(requested.path, taken) + if (taken.has(columnName)) { + throw new OrchestrationError('validation', `Column "${columnName}" already exists`) + } + taken.add(columnName) + outputs.push({ + blockId: requested.blockId, + path: requested.path, + columnName, }) - logger.info('Started table group auto-run', { - tableId: context.table.id, - groupId: result.group.id, + newOutputColumns.push({ + name: columnName, + type: workflowOutputColumnType(requested.columnType, leafTypeByKey.get(key)), + required: false, + unique: false, + workflowGroupId: input.groupId, }) + } + } + + const resolvedMappingTypes = + input.mappingUpdates && input.mappingUpdates.length > 0 && resolvedWorkflow + ? { + workflowId: resolvedWorkflow.workflowId, + columns: input.mappingUpdates.map((mapping) => { + const output = resolvedWorkflow.outputs?.find( + (candidate) => + candidate.blockId === mapping.blockId && candidate.path === mapping.path + ) + if (!output) { + throw new OrchestrationError( + 'validation', + `Output ${mapping.blockId}::${mapping.path} is not a valid pickable output on workflow ${targetWorkflowId}` + ) + } + return { columnName: mapping.columnName, type: columnTypeForLeaf(output.leafType) } + }), + } + : undefined + if (input.mappingUpdates?.length && !resolvedMappingTypes) { + throw new Error('Workflow metadata is required for workflow group mapping updates') + } + + const actorUserId = attributedUserId(principal, context.billedAccountUserId) + const table = await updateWorkflowGroup( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + groupId: input.groupId, + actorUserId, + suppressAutoRunDispatch: true, + ...(input.workflowId !== undefined ? { workflowId: input.workflowId } : {}), + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.dependencies !== undefined ? { dependencies: input.dependencies } : {}), + ...(outputs !== undefined ? { outputs } : {}), + ...(newOutputColumns !== undefined ? { newOutputColumns } : {}), + ...(input.mappingUpdates !== undefined ? { mappingUpdates: input.mappingUpdates } : {}), + ...(resolvedMappingTypes ? { resolvedMappingTypes } : {}), + ...(input.deploymentMode !== undefined ? { deploymentMode: input.deploymentMode } : {}), + ...(input.autoRun !== undefined ? { autoRun: input.autoRun } : {}), + }, + generateRequestId() + ) + const group = groupFromTable(table, input.groupId) + return { + table, + group, + changed: + JSON.stringify(context.table.schema) !== JSON.stringify(table.schema) || + JSON.stringify(context.table.metadata) !== JSON.stringify(table.metadata), + startAutoRun: previousGroup.autoRun !== true && input.autoRun === true, + actorUserId, + } + }, + projectAudit({ result }) { + if (!result.changed) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Updated workflow group "${result.group.id}" in table "${result.table.name}"`, + metadata: { op: 'update_workflow_group', groupId: result.group.id }, + } + }, + afterSuccess({ context, result }) { + if (result.changed) signalTableSchemaChanged(context.tableId) + if (result.startAutoRun) { + dispatchGroupAutoRun({ + tableId: context.tableId, + workspaceId: context.workspaceId, + groupId: result.group.id, + actorUserId: result.actorUserId, + label: 'table-workflow-group-update-auto-run', }) } }, @@ -277,3 +879,131 @@ export const deleteTableGroupUseCase = defineAuthorizedTableUseCase({ signalTableSchemaChanged(context.table.id) }, }) + +export interface AddTableGroupOutputInput extends TableGroupInput { + groupId: string + blockId: string + path: string + columnName?: string +} + +export const addWorkflowTableGroupOutput = defineAuthorizedTableUseCase({ + operation: tableOperations.updateGroup, + resolveContext: ({ input }: { input: AddTableGroupOutputInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, input, context }) { + const group = context.table.schema.workflowGroups?.find( + (candidate) => candidate.id === input.groupId + ) + if (!group) + throw new OrchestrationError('not_found', `Workflow group "${input.groupId}" not found`) + if (group.type === 'enrichment' || !group.workflowId) { + throw new OrchestrationError( + 'validation', + `Workflow group "${input.groupId}" is not backed by a workflow` + ) + } + const resolvedWorkflow = await resolveWorkflowForAuthorizedTableCommand( + group.workflowId, + context.workspaceId + ) + const outputs = requireWorkflowOutputs(resolvedWorkflow, group.workflowId) + const output = outputs.find( + (candidate) => candidate.blockId === input.blockId && candidate.path === input.path + ) + if (!output) { + throw new OrchestrationError( + 'validation', + `Output ${input.blockId}::${input.path} is not a valid pickable output on workflow ${group.workflowId}` + ) + } + const table = await addWorkflowGroupOutput( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + groupId: input.groupId, + blockId: input.blockId, + path: input.path, + columnName: input.columnName, + actorUserId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + resolvedOutput: { + workflowId: resolvedWorkflow.workflowId, + columnType: columnTypeForLeaf(output.leafType), + order: outputs.map((candidate, discoveryIndex) => { + const distance = resolvedWorkflow.executionOrderByBlockId[candidate.blockId] + return { + blockId: candidate.blockId, + path: candidate.path, + executionDistance: + distance === undefined || distance < 0 ? Number.POSITIVE_INFINITY : distance, + discoveryIndex, + } + }), + }, + }, + generateRequestId() + ) + return { table, groupId: input.groupId } + }, + projectAudit({ result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Added an output to workflow group "${result.groupId}"`, + metadata: { op: 'add_group_output', groupId: result.groupId }, + } + }, + afterSuccess({ context }) { + signalTableSchemaChanged(context.tableId) + }, +}) + +export interface DeleteTableGroupOutputInput extends TableGroupInput { + groupId: string + columnName: string +} + +export const deleteTableGroupOutputUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.updateGroup, + resolveContext: ({ input }: { input: DeleteTableGroupOutputInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ input, context }) { + const table = await deleteWorkflowGroupOutput( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + groupId: input.groupId, + columnName: input.columnName, + }, + generateRequestId() + ) + return { table, groupId: input.groupId, columnName: input.columnName } + }, + projectAudit({ result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Deleted an output from workflow group "${result.groupId}"`, + metadata: { + op: 'delete_group_output', + groupId: result.groupId, + columnName: result.columnName, + }, + } + }, + afterSuccess({ context }) { + signalTableSchemaChanged(context.tableId) + }, +}) diff --git a/apps/sim/lib/table/application/imports.test.ts b/apps/sim/lib/table/application/imports.test.ts new file mode 100644 index 00000000000..79d3fae1691 --- /dev/null +++ b/apps/sim/lib/table/application/imports.test.ts @@ -0,0 +1,419 @@ +/** + * @vitest-environment node + */ + +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + abortUpload: vi.fn(), + assertUploadBinding: vi.fn(), + cancelResource: vi.fn(), + createParts: vi.fn(), + createResource: vi.fn(), + completeUpload: vi.fn(), + findResource: vi.fn(), + getResource: vi.fn(), + getUpload: vi.fn(), + getWorkspaceFile: vi.fn(), + resolvePermission: vi.fn(), + resolveTableContext: vi.fn(), + resolveWorkspaceContext: vi.fn(), + startUploadedImport: vi.fn(), + tableImportBodyFromUpload: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/folders/locks', () => ({ withFolderTreeLock: vi.fn() })) +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: vi.fn(), + resolveFolderPathFromIndex: vi.fn(), +})) + +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mocks.resolveTableContext, + resolveTableWorkspaceContext: mocks.resolveWorkspaceContext, +})) + +vi.mock('@/lib/table/orchestration/import-resource', () => ({ + abortAuthorizedTableImportUpload: mocks.abortUpload, + cancelTableImportResource: mocks.cancelResource, + createAuthorizedTableImportResource: mocks.createResource, + findTableImportResource: mocks.findResource, + getPrincipalTableImportUpload: mocks.getUpload, + getTableImportResource: mocks.getResource, + startUploadedTableImport: mocks.startUploadedImport, + tableImportBodyFromUpload: mocks.tableImportBodyFromUpload, +})) + +vi.mock('@/lib/uploads/upload-session/application', () => ({ + requestOrigin: () => 'http://localhost:3000', +})) + +vi.mock('@/lib/uploads/upload-session/service', () => ({ + assertUploadSessionAuthBinding: mocks.assertUploadBinding, + completeUploadSession: mocks.completeUpload, + createUploadPartUrls: mocks.createParts, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: mocks.getWorkspaceFile, +})) + +import { + cancelTableImportUseCase, + completeTableImportUseCase, + createTableImportPartsUseCase, + createTableImportUseCase, + readTableImportUseCase, +} from '@/lib/table/application/imports' + +const createdAt = new Date('2026-08-01T00:00:00.000Z') +const record = { + id: 'import-1', + workspaceId: 'workspace-1', + userId: 'uploader-1', + source: { type: 'upload' as const, name: 'people.csv', contentType: 'text/csv', size: 128 }, + target: { type: 'new' as const, name: 'People' }, + options: {}, + tableId: null, + status: 'uploading' as const, + rowsProcessed: 0, + error: null, + createdAt, + updatedAt: createdAt, + completedAt: null, +} +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const reader = { kind: 'session' as const, userId: 'reader-2', sessionId: 'session-2' } +const workspaceKey = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', +} +const executor: WorkflowExecutionDelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'executor-user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:tables', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, +} +const upload = { + id: 'import-1', + workspaceId: 'workspace-1', + userId: 'uploader-1', + fileName: 'people.csv', +} +const workspaceFile = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'people.csv', + key: 'workspace/workspace-1/people.csv', +} + +describe('table import application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveWorkspaceContext.mockResolvedValue(workspaceContext) + mocks.getResource.mockResolvedValue(record) + mocks.cancelResource.mockResolvedValue({ ...record, status: 'canceled' }) + mocks.getUpload.mockResolvedValue(upload) + mocks.tableImportBodyFromUpload.mockReturnValue({ + workspaceId: 'workspace-1', + source: record.source, + target: record.target, + }) + mocks.createParts.mockResolvedValue([{ partNumber: 1, url: 'https://storage/part-1' }]) + mocks.abortUpload.mockResolvedValue({ ...record, status: 'canceled' }) + mocks.findResource.mockResolvedValue(null) + mocks.completeUpload.mockImplementation( + async ({ + session, + finalize, + }: { + session: unknown + finalize: (value: unknown) => unknown + }) => { + await finalize(session) + return { session } + } + ) + mocks.startUploadedImport.mockResolvedValue({ ...record, status: 'ready' }) + mocks.createResource.mockResolvedValue({ record, upload: null }) + mocks.getWorkspaceFile.mockResolvedValue(workspaceFile) + }) + + it('creates an import through the domain resource boundary without presenting a v2 DTO', async () => { + const request = new Request('http://localhost:3000/api/table/imports', { method: 'POST' }) + + await expect( + createTableImportUseCase.execute({ + principal: reader, + input: { + body: { + workspaceId: 'workspace-1', + source: record.source, + target: record.target, + }, + }, + request, + }) + ).resolves.toEqual({ import: { record, upload: null } }) + + expect(mocks.createResource).toHaveBeenCalledWith({ + body: { + workspaceId: 'workspace-1', + source: record.source, + target: record.target, + }, + userId: 'reader-2', + principal: reader, + localOrigin: 'http://localhost:3000', + resolvedFolderId: undefined, + workspaceFile: undefined, + }) + expect(record.createdAt).toBe(createdAt) + }) + + it('creates an upload import for an unscoped current-workflow executor principal', async () => { + const request = new Request('http://localhost:3000/api/table/imports', { method: 'POST' }) + + await createTableImportUseCase.execute({ + principal: executor, + input: { + body: { + workspaceId: 'workspace-1', + source: record.source, + target: record.target, + }, + }, + request, + }) + + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'executor-user-1', + 'workspace-1', + null, + undefined, + { forUpdate: undefined } + ) + expect(mocks.createResource).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'executor-user-1', + principal: executor, + }) + ) + }) + + it('reads a durable import by workspace role rather than uploader identity', async () => { + await expect( + readTableImportUseCase.execute({ + principal: reader, + input: { importId: 'import-1', workspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ import: record }) + + expect(mocks.getResource).toHaveBeenCalledWith({ + importId: 'import-1', + assertedWorkspaceId: 'workspace-1', + }) + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'reader-2', + 'workspace-1', + null, + undefined, + { forUpdate: undefined } + ) + }) + + it('resolves a workspace-file source canonically inside the authorized import command', async () => { + await createTableImportUseCase.execute({ + principal: reader, + input: { + body: { + workspaceId: 'workspace-1', + source: { type: 'workspace_file', fileId: 'file-1' }, + target: record.target, + }, + }, + }) + + expect(mocks.getWorkspaceFile).toHaveBeenLastCalledWith('workspace-1', 'file-1', { + throwOnError: true, + }) + expect(mocks.createResource).toHaveBeenCalledWith(expect.objectContaining({ workspaceFile })) + }) + + it('conceals a cross-workspace workspace-file id before import mutation', async () => { + mocks.getWorkspaceFile.mockResolvedValueOnce(null) + + await expect( + createTableImportUseCase.execute({ + principal: reader, + input: { + body: { + workspaceId: 'workspace-1', + source: { type: 'workspace_file', fileId: 'file-other' }, + target: record.target, + }, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.createResource).not.toHaveBeenCalled() + }) + + it('lets a workspace key cancel the durable workspace resource', async () => { + await cancelTableImportUseCase.execute({ + principal: workspaceKey, + input: { importId: 'import-1', workspaceId: 'workspace-1' }, + }) + + expect(mocks.cancelResource).toHaveBeenCalledWith(record) + }) + + it('preserves exact principal and token binding on upload control legs', async () => { + const request = new Request('http://localhost:3000/api/v2/tables/imports/import-1/parts', { + method: 'POST', + }) + await createTableImportPartsUseCase.execute({ + principal: workspaceKey, + input: { + importId: 'import-1', + workspaceId: 'workspace-1', + uploadToken: 'signed-token', + partNumbers: [1], + }, + request, + }) + await cancelTableImportUseCase.execute({ + principal: workspaceKey, + input: { + importId: 'import-1', + workspaceId: 'workspace-1', + uploadToken: 'signed-token', + }, + }) + await completeTableImportUseCase.execute({ + principal: workspaceKey, + input: { + importId: 'import-1', + workspaceId: 'workspace-1', + uploadToken: 'signed-token', + }, + }) + + expect(mocks.getUpload).toHaveBeenNthCalledWith(1, { + importId: 'import-1', + assertedWorkspaceId: 'workspace-1', + principal: workspaceKey, + uploadToken: 'signed-token', + }) + expect(mocks.getUpload).toHaveBeenNthCalledWith(2, { + importId: 'import-1', + assertedWorkspaceId: 'workspace-1', + principal: workspaceKey, + uploadToken: 'signed-token', + }) + expect(mocks.getUpload).toHaveBeenNthCalledWith(3, { + importId: 'import-1', + assertedWorkspaceId: 'workspace-1', + principal: workspaceKey, + uploadToken: 'signed-token', + }) + expect(mocks.abortUpload).toHaveBeenCalledWith(upload, workspaceKey) + expect(mocks.assertUploadBinding).toHaveBeenCalledWith(upload, workspaceKey) + }) + + it('threads the exact executor principal through upload control and finalization', async () => { + const request = new Request('http://localhost:3000/api/table/imports/import-1/parts', { + method: 'POST', + }) + + await createTableImportPartsUseCase.execute({ + principal: executor, + input: { + importId: 'import-1', + workspaceId: 'workspace-1', + uploadToken: 'signed-token', + partNumbers: [1], + }, + request, + }) + await completeTableImportUseCase.execute({ + principal: executor, + input: { + importId: 'import-1', + workspaceId: 'workspace-1', + uploadToken: 'signed-token', + }, + }) + + expect(mocks.getUpload).toHaveBeenNthCalledWith(1, { + importId: 'import-1', + assertedWorkspaceId: 'workspace-1', + principal: executor, + uploadToken: 'signed-token', + }) + expect(mocks.getUpload).toHaveBeenNthCalledWith(2, { + importId: 'import-1', + assertedWorkspaceId: 'workspace-1', + principal: executor, + uploadToken: 'signed-token', + }) + expect(mocks.assertUploadBinding).toHaveBeenCalledWith(upload, executor) + }) + + it('rejects delegated HTTP import creation before canonical load or mutation', async () => { + const delegated = { + kind: 'delegated' as const, + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-1', + audience: 'sim:tables', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), + } + + await expect( + createTableImportUseCase.execute({ + principal: delegated as never, + input: { + body: { + workspaceId: 'workspace-1', + source: record.source, + target: record.target, + }, + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveWorkspaceContext).not.toHaveBeenCalled() + expect(mocks.resolveTableContext).not.toHaveBeenCalled() + expect(mocks.createResource).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/imports.ts b/apps/sim/lib/table/application/imports.ts index 370770fdd3e..3f27c72c9df 100644 --- a/apps/sim/lib/table/application/imports.ts +++ b/apps/sim/lib/table/application/imports.ts @@ -1,10 +1,5 @@ import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' -import type { - V2CreateTableImportBody, - V2CreateTableImportData, - V2TableImport, -} from '@/lib/api/contracts/v2/tables' import { authorizeWorkspaceOperation } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' @@ -23,6 +18,8 @@ import { import { tableOperations } from '@/lib/table/application/operations' import { abortAuthorizedTableImportUpload, + type CreateTableImportRequest, + type CreateTableImportResult as CreateTableImportResourceResult, cancelTableImportResource, createAuthorizedTableImportResource, findTableImportResource, @@ -31,9 +28,11 @@ import { startUploadedTableImport, type TableImportResource, tableImportBodyFromUpload, - toV2CreateTableImport, - toV2TableImport, } from '@/lib/table/orchestration/import-resource' +import { + getWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { requestOrigin } from '@/lib/uploads/upload-session/application' import { assertUploadSessionAuthBinding, @@ -41,12 +40,11 @@ import { createUploadPartUrls, type UploadSessionRecord, } from '@/lib/uploads/upload-session/service' -import { readWorkspaceFileContentRecord } from '@/lib/workspace-files/application/read-workspace-file-record' const logger = createLogger('TableImportApplication') export interface CreateTableImportInput { - body: V2CreateTableImportBody + body: CreateTableImportRequest } export interface TableImportResourceInput { @@ -67,11 +65,11 @@ export interface CancelTableImportInput extends TableImportResourceInput { } export interface CreateTableImportResult { - import: V2CreateTableImportData + import: CreateTableImportResourceResult } export interface TableImportResult { - import: V2TableImport + import: TableImportResource } export interface CreateTableImportPartsResult { @@ -90,10 +88,11 @@ interface TableImportUploadContext extends TableAuthorizationContext { async function resolveCreateTableImportContext(input: CreateTableImportInput) { if (input.body.target.type === 'existing') { - return resolveActiveTableContext({ + const { tableId: _tableId, ...context } = await resolveActiveTableContext({ tableId: input.body.target.tableId, assertedWorkspaceId: input.body.workspaceId, }) + return context } return resolveTableWorkspaceContext(input.body.workspaceId) } @@ -109,7 +108,6 @@ async function resolveTableImportContext( return { ...workspace, importId: record.id, - ...(record.tableId ? { tableId: record.tableId } : {}), record, } } @@ -129,14 +127,13 @@ async function resolveTableImportUploadContext( return { ...workspace, importId: upload.id, - ...(body.target.type === 'existing' ? { tableId: body.target.tableId } : {}), upload, } } async function resolveImportFolderId( workspaceId: string, - body: V2CreateTableImportBody + body: CreateTableImportRequest ): Promise<string | null | undefined> { if (body.target.type !== 'new') return undefined const path = body.target.folderPath ?? ROOT_FOLDER_PATH @@ -152,34 +149,30 @@ async function resolveImportFolderId( }) } +async function loadAuthorizedTableImportWorkspaceFile( + workspaceId: string, + fileId: string +): Promise<WorkspaceFileRecord> { + const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return file +} + export const createTableImportUseCase = defineAuthorizedTableUseCase({ operation: tableOperations.createImport, resolveContext: ({ input }: { input: CreateTableImportInput }) => resolveCreateTableImportContext(input), async execute({ principal, input, context, request }): Promise<CreateTableImportResult> { - if (principal.kind === 'delegated') { - throw new OrchestrationError( - 'forbidden', - input.body.source.type === 'upload' - ? 'Delegated principals cannot initiate table import uploads' - : 'Delegated principals cannot initiate workspace-file table imports' - ) - } const attribution = resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }) const folderId = await resolveImportFolderId(context.workspaceId, input.body) const workspaceFile = input.body.source.type === 'workspace_file' - ? ( - await readWorkspaceFileContentRecord.execute({ - principal, - input: { - fileId: input.body.source.fileId, - assertedWorkspaceId: context.workspaceId, - }, - }) - ).file + ? await loadAuthorizedTableImportWorkspaceFile( + context.workspaceId, + input.body.source.fileId + ) : undefined if (input.body.source.type === 'upload' && !request) { throw new Error('Table import upload creation requires a request context') @@ -199,7 +192,7 @@ export const createTableImportUseCase = defineAuthorizedTableUseCase({ targetType: input.body.target.type, principalKind: principal.kind, }) - return { import: toV2CreateTableImport(created) } + return { import: created } }, }) @@ -208,7 +201,7 @@ export const readTableImportUseCase = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: TableImportResourceInput }) => resolveTableImportContext(input), async execute({ context }): Promise<TableImportResult> { - return { import: toV2TableImport(context.record) } + return { import: context.record } }, }) @@ -242,7 +235,7 @@ export const completeTableImportUseCase = defineAuthorizedTableUseCase({ importId: context.upload.id, assertedWorkspaceId: context.workspaceId, }) - if (existing) return { import: toV2TableImport(existing) } + if (existing) return { import: existing } const completed = await completeUploadSession({ session: context.upload, @@ -261,7 +254,7 @@ export const completeTableImportUseCase = defineAuthorizedTableUseCase({ tableId: started.tableId, principalKind: principal.kind, }) - return { import: toV2TableImport(started) } + return { import: started } }, }) @@ -292,6 +285,6 @@ export const cancelTableImportUseCase = defineAuthorizedTableUseCase({ tableId: record.tableId, principalKind: principal.kind, }) - return { import: toV2TableImport(record) } + return { import: record } }, }) diff --git a/apps/sim/lib/table/application/operations.test.ts b/apps/sim/lib/table/application/operations.test.ts index 07722033747..3b9dc95cc27 100644 --- a/apps/sim/lib/table/application/operations.test.ts +++ b/apps/sim/lib/table/application/operations.test.ts @@ -53,9 +53,42 @@ describe('table operation registry', () => { expect(tableOperations.cancelExport.minimumRole).toBe('read') }) - it('keeps delegated table operations Copilot-only', () => { + it('admits executor delegation only for the intentional internal route operations', () => { + const executorOnlyOperations = new Set([ + tableOperations.createImport.id, + tableOperations.readImport.id, + tableOperations.createImportParts.id, + tableOperations.completeImport.id, + tableOperations.cancelImport.id, + tableOperations.createExport.id, + tableOperations.readExport.id, + tableOperations.cancelExport.id, + tableOperations.downloadExport.id, + ]) + const sharedGroupOperations = new Set([ + tableOperations.createGroup.id, + tableOperations.updateGroup.id, + tableOperations.deleteGroup.id, + ]) + for (const operation of Object.values(tableOperations)) { - expect(operation.delegatedServices).toEqual(['copilot']) + expect(operation.delegatedServices).toEqual( + executorOnlyOperations.has(operation.id) + ? ['executor'] + : sharedGroupOperations.has(operation.id) + ? ['copilot', 'executor'] + : ['copilot'] + ) } }) + + it('separates Copilot workspace-file imports from the credential-bound upload lifecycle', () => { + expect(tableOperations.createImport.delegatedServices).toEqual(['executor']) + expect(tableOperations.createImportParts.delegatedServices).toEqual(['executor']) + expect(tableOperations.completeImport.delegatedServices).toEqual(['executor']) + expect(tableOperations.createFromWorkspaceFile.principalKinds).toEqual(['delegated']) + expect(tableOperations.createFromWorkspaceFile.delegatedServices).toEqual(['copilot']) + expect(tableOperations.importWorkspaceFile.principalKinds).toEqual(['delegated']) + expect(tableOperations.importWorkspaceFile.delegatedServices).toEqual(['copilot']) + }) }) diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index 8662437787f..9d0f6009346 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -5,6 +5,16 @@ const ALL_PRINCIPAL_POLICY = { delegatedServices: ['copilot'], } as const +const ALL_TABLE_TOOL_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot', 'executor'], +} as const + +const INTERNAL_EXECUTOR_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['executor'], +} as const + function readOperation<const Id extends string>(id: Id) { return defineWorkspaceOperation({ id, @@ -23,6 +33,43 @@ function writeOperation<const Id extends string>(id: Id) { }) } +function toolWriteOperation<const Id extends string>(id: Id) { + return defineWorkspaceOperation({ + id, + minimumRole: 'write', + workspaceApiKey: 'allow', + ...ALL_TABLE_TOOL_PRINCIPAL_POLICY, + }) +} + +function internalExecutorReadOperation<const Id extends string>(id: Id) { + return defineWorkspaceOperation({ + id, + minimumRole: 'read', + workspaceApiKey: 'allow', + ...INTERNAL_EXECUTOR_PRINCIPAL_POLICY, + }) +} + +function internalExecutorWriteOperation<const Id extends string>(id: Id) { + return defineWorkspaceOperation({ + id, + minimumRole: 'write', + workspaceApiKey: 'allow', + ...INTERNAL_EXECUTOR_PRINCIPAL_POLICY, + }) +} + +function delegatedWriteOperation<const Id extends string>(id: Id) { + return defineWorkspaceOperation({ + id, + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }) +} + export const tableOperations = { list: readOperation('tables.list'), read: readOperation('tables.read'), @@ -53,20 +100,22 @@ export const tableOperations = { updateView: writeOperation('tables.views.update'), deleteView: writeOperation('tables.views.delete'), listGroups: readOperation('tables.groups.list'), - createGroup: writeOperation('tables.groups.create'), - updateGroup: writeOperation('tables.groups.update'), - deleteGroup: writeOperation('tables.groups.delete'), + createGroup: toolWriteOperation('tables.groups.create'), + updateGroup: toolWriteOperation('tables.groups.update'), + deleteGroup: toolWriteOperation('tables.groups.delete'), startRun: writeOperation('tables.runs.start'), cancelRuns: writeOperation('tables.runs.cancel'), - createImport: writeOperation('tables.imports.create'), - readImport: readOperation('tables.imports.read'), - createImportParts: writeOperation('tables.imports.create_parts'), - completeImport: writeOperation('tables.imports.complete'), - cancelImport: writeOperation('tables.imports.cancel'), - createExport: readOperation('tables.exports.create'), - readExport: readOperation('tables.exports.read'), - cancelExport: readOperation('tables.exports.cancel'), - downloadExport: readOperation('tables.exports.download'), + createImport: internalExecutorWriteOperation('tables.imports.create'), + createFromWorkspaceFile: delegatedWriteOperation('tables.imports.create_from_workspace_file'), + importWorkspaceFile: delegatedWriteOperation('tables.imports.workspace_file'), + readImport: internalExecutorReadOperation('tables.imports.read'), + createImportParts: internalExecutorWriteOperation('tables.imports.create_parts'), + completeImport: internalExecutorWriteOperation('tables.imports.complete'), + cancelImport: internalExecutorWriteOperation('tables.imports.cancel'), + createExport: internalExecutorReadOperation('tables.exports.create'), + readExport: internalExecutorReadOperation('tables.exports.read'), + cancelExport: internalExecutorReadOperation('tables.exports.cancel'), + downloadExport: internalExecutorReadOperation('tables.exports.download'), } as const export type TableOperation = (typeof tableOperations)[keyof typeof tableOperations] diff --git a/apps/sim/lib/table/application/rows.test.ts b/apps/sim/lib/table/application/rows.test.ts index 55db224860d..f06658ece14 100644 --- a/apps/sim/lib/table/application/rows.test.ts +++ b/apps/sim/lib/table/application/rows.test.ts @@ -8,21 +8,31 @@ import type { TableDefinition } from '@/lib/table/types' const { mockReplaceRowsPrimitive, mockDeleteRowsByIds, + mockLoadSecretProvenance, + mockAssertRowCapacity, + mockNotifyTableRowUsage, mockQueryRows, mockRecordAudit, + mockReplaceRowsWithTx, mockResolveContext, mockResolvePermission, mockSignalRowsChanged, mockUpsertRow, + mockWithLockedTable, } = vi.hoisted(() => ({ mockReplaceRowsPrimitive: vi.fn(), mockDeleteRowsByIds: vi.fn(), + mockLoadSecretProvenance: vi.fn(), + mockAssertRowCapacity: vi.fn(), + mockNotifyTableRowUsage: vi.fn(), mockQueryRows: vi.fn(), mockRecordAudit: vi.fn(), + mockReplaceRowsWithTx: vi.fn(), mockResolveContext: vi.fn(), mockResolvePermission: vi.fn(), mockSignalRowsChanged: vi.fn(), mockUpsertRow: vi.fn(), + mockWithLockedTable: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -69,6 +79,25 @@ vi.mock('@/lib/table', () => ({ upsertRow: mockUpsertRow, validateBatchRows: vi.fn(), validateRowData: vi.fn(), + withLockedTable: mockWithLockedTable, +})) + +vi.mock('@/lib/table/billing', () => ({ + assertRowCapacity: mockAssertRowCapacity, + notifyTableRowUsage: mockNotifyTableRowUsage, +})) + +vi.mock('@/lib/table/column-types', () => ({ + columnTypeOf: (column: { type: string }) => ({ id: column.type }), +})) + +vi.mock('@/lib/table/rows/secret-provenance', () => ({ + createExactEmptyTableRowSecretProvenance: () => ({ complete: true, columns: {} }), + loadTableRowSecretProvenance: mockLoadSecretProvenance, +})) + +vi.mock('@/lib/table/rows/service', () => ({ + replaceTableRowsWithTx: mockReplaceRowsWithTx, })) vi.mock('@/lib/table/application/context', () => ({ @@ -81,7 +110,9 @@ vi.mock('@/lib/table/events', () => ({ import { deleteTableRows, + ProjectedWireRowsValidationError, queryTableRows, + replaceProjectedWireRows, replaceTableRows, TableRowsValidationError, tablePredicateNamesToFilter, @@ -118,6 +149,167 @@ describe('table predicate translation', () => { }) }) +describe('replaceProjectedWireRows application command', () => { + const freshTable: TableDefinition = { + ...TABLE, + schema: { + columns: [ + { id: 'column-fresh', name: 'full_name', type: 'string' }, + { id: 'column-score', name: 'score', type: 'number' }, + ], + }, + } + const delegatedPrincipal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: TABLE.workspaceId, + delegationId: 'copilot-tool:tool-1', + audience: 'sim:tables', + issuedAt: new Date('2026-01-01'), + expiresAt: new Date('2099-01-01'), + resourceScope: { tableId: TABLE.id }, + } + + 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', + }) + mockAssertRowCapacity.mockResolvedValue(10_000) + mockWithLockedTable.mockImplementation( + async (_tableId: string, run: (table: TableDefinition, trx: unknown) => unknown) => + run(freshTable, { kind: 'transaction' }) + ) + mockReplaceRowsWithTx.mockResolvedValue({ deletedCount: 2, insertedCount: 1 }) + }) + + it('validates and replaces against the fresh schema held under the table lock', async () => { + const result = await replaceProjectedWireRows.execute({ + principal: delegatedPrincipal, + input: { + tableId: TABLE.id, + assertedWorkspaceId: TABLE.workspaceId, + sourceRows: [{ full_name: 'Ada' }], + projectedRows: [{ full_name: 'Ada' }], + requestId: 'request-1', + }, + }) + + expect(mockWithLockedTable).toHaveBeenCalledWith(TABLE.id, expect.any(Function), { + expectedWorkspaceId: TABLE.workspaceId, + }) + expect(mockReplaceRowsWithTx).toHaveBeenCalledWith( + { kind: 'transaction' }, + { + tableId: TABLE.id, + workspaceId: TABLE.workspaceId, + rows: [{ 'column-fresh': 'Ada' }], + userId: 'user-1', + secretProvenance: [{ complete: true, columns: {} }], + }, + freshTable, + 'request-1' + ) + expect(result.table).toBe(freshTable) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + operation: 'tables.rows.replace', + rowsDeleted: 2, + rowsInserted: 1, + }), + }) + ) + expect(mockSignalRowsChanged).toHaveBeenCalledWith(TABLE.id) + expect(mockNotifyTableRowUsage).toHaveBeenCalledWith({ + workspaceId: TABLE.workspaceId, + currentRowCount: 0, + addedRows: 1, + limit: 10_000, + }) + }) + + it('rejects a projected row that only matched the stale pre-lock schema', async () => { + await expect( + replaceProjectedWireRows.execute({ + principal: delegatedPrincipal, + input: { + tableId: TABLE.id, + assertedWorkspaceId: TABLE.workspaceId, + sourceRows: [{ name: 'Ada' }], + projectedRows: [{ name: 'Ada' }], + }, + }) + ).rejects.toBeInstanceOf(ProjectedWireRowsValidationError) + + expect(mockReplaceRowsWithTx).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockSignalRowsChanged).not.toHaveBeenCalled() + }) + + it('rejects delegated table-scope mismatch before opening the mutation lock', async () => { + await expect( + replaceProjectedWireRows.execute({ + principal: { + ...delegatedPrincipal, + resourceScope: { tableId: 'table-other' }, + }, + input: { + tableId: TABLE.id, + sourceRows: [{ full_name: 'Ada' }], + projectedRows: [{ full_name: 'Ada' }], + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mockWithLockedTable).not.toHaveBeenCalled() + expect(mockReplaceRowsWithTx).not.toHaveBeenCalled() + }) + + it('does not audit or signal when the authoritative replacement is a no-op', async () => { + mockReplaceRowsWithTx.mockResolvedValueOnce({ deletedCount: 0, insertedCount: 0 }) + + await replaceProjectedWireRows.execute({ + principal: delegatedPrincipal, + input: { + tableId: TABLE.id, + sourceRows: [{ full_name: 'Ada' }], + projectedRows: [{ full_name: 'Ada' }], + }, + }) + + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockSignalRowsChanged).not.toHaveBeenCalled() + }) + + it('propagates replacement failures without audit or shared effects', async () => { + const failure = new Error('database unavailable') + mockReplaceRowsWithTx.mockRejectedValueOnce(failure) + + await expect( + replaceProjectedWireRows.execute({ + principal: delegatedPrincipal, + input: { + tableId: TABLE.id, + sourceRows: [{ full_name: 'Ada' }], + projectedRows: [{ full_name: 'Ada' }], + }, + }) + ).rejects.toBe(failure) + + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockSignalRowsChanged).not.toHaveBeenCalled() + expect(mockNotifyTableRowUsage).not.toHaveBeenCalled() + }) +}) + describe('replaceTableRows application use case', () => { beforeEach(() => { vi.clearAllMocks() @@ -234,6 +426,51 @@ describe('row query and upsert application semantics', () => { expect(mockQueryRows).not.toHaveBeenCalled() }) + it('rejects an oversized page before querying storage', async () => { + await expect( + queryTableRows.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, limit: 1001 }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mockQueryRows).not.toHaveBeenCalled() + expect(mockLoadSecretProvenance).not.toHaveBeenCalled() + }) + + it('loads requested persisted provenance inside the authorized application query', async () => { + const row = { + id: 'row-1', + tableId: TABLE.id, + data: { 'column-name': 'Ada' }, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + } + const provenance = { complete: true, columns: {} } + mockQueryRows.mockResolvedValueOnce({ + rows: [row], + rowCount: 1, + totalCount: null, + nextCursor: null, + }) + mockLoadSecretProvenance.mockResolvedValueOnce(provenance) + + const result = await queryTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + limit: 10, + includePersistedSecretProvenance: true, + }, + }) + + expect(mockLoadSecretProvenance).toHaveBeenCalledWith([row], { + userId: 'user-1', + workspaceId: TABLE.workspaceId, + }) + expect(result.secretProvenance).toBe(provenance) + }) + it('audits only the authoritative deleted count and suppresses no-op audit', async () => { mockDeleteRowsByIds.mockResolvedValueOnce({ deletedCount: 1, diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts index 999b7bd133f..c8881b0c822 100644 --- a/apps/sim/lib/table/application/rows.ts +++ b/apps/sim/lib/table/application/rows.ts @@ -1,7 +1,9 @@ +import { isDeepStrictEqual } from 'node:util' import { AuditAction, AuditResourceType } from '@sim/audit' -import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { requirePrincipalSubjectUserId, resolvePrincipalAttribution } from '@sim/auth/principal' import { getRequestContext } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { isPlainRecord } from '@sim/utils/object' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { BulkDeleteByIdsResult, @@ -34,11 +36,14 @@ import { upsertRow, validateBatchRows, validateRowData, + withLockedTable, } from '@/lib/table' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' 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 { columnTypeOf } from '@/lib/table/column-types' import { TableQueryValidationError } from '@/lib/table/errors' import { signalTableRowsChanged } from '@/lib/table/events' import { predicateToFilter } from '@/lib/table/query-builder/converters' @@ -49,7 +54,12 @@ import { validateStoragePredicate, } from '@/lib/table/query-builder/validate' import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' +import { + createExactEmptyTableRowSecretProvenance, + loadTableRowSecretProvenance, +} from '@/lib/table/rows/secret-provenance' import type { FindRowMatch } from '@/lib/table/rows/service' +import { replaceTableRowsWithTx } from '@/lib/table/rows/service' import { predicateToStorage } from '@/lib/table/select-values' export class TableRowsValidationError extends OrchestrationError { @@ -72,6 +82,21 @@ interface TableResult { table: TableDefinition } +type TableRowsProvenance = Awaited<ReturnType<typeof loadTableRowSecretProvenance>> + +async function loadAuthorizedRowsProvenance( + principal: Parameters<typeof requirePrincipalSubjectUserId>[0], + workspaceId: string, + rows: TableRow[], + include: boolean | undefined +): Promise<TableRowsProvenance | undefined> { + if (!include) return undefined + return loadTableRowSecretProvenance(rows, { + userId: requirePrincipalSubjectUserId(principal), + workspaceId, + }) +} + function requestId(input: TableScopedInput): string { return input.requestId ?? getRequestContext()?.requestId ?? generateId().slice(0, 8) } @@ -176,6 +201,7 @@ export interface QueryTableRowsInput extends TableScopedInput { limit?: number cursor?: string includeTotal?: boolean + includePersistedSecretProvenance?: boolean } export interface QueryTableRowsResult extends TableResult { @@ -183,12 +209,13 @@ export interface QueryTableRowsResult extends TableResult { rowCount: number totalCount: number | null nextCursor: string | null + secretProvenance?: TableRowsProvenance } export const queryTableRows = defineAuthorizedTableUseCase({ operation: tableOperations.queryRows, resolveContext: ({ input }: { input: QueryTableRowsInput }) => resolveActiveTableContext(input), - async execute({ input, context }): Promise<QueryTableRowsResult> { + async execute({ principal, input, context }): Promise<QueryTableRowsResult> { try { if (input.limit !== undefined) { requireIntegerInRange(input.limit, 1, TABLE_LIMITS.MAX_QUERY_LIMIT, 'Limit') @@ -221,7 +248,16 @@ export const queryTableRows = defineAuthorizedTableUseCase({ }, requestId(input) ) - return { table: context.table, ...result } + return { + table: context.table, + ...result, + secretProvenance: await loadAuthorizedRowsProvenance( + principal, + context.workspaceId, + result.rows, + input.includePersistedSecretProvenance + ), + } } catch (error) { rethrowQueryValidation(error) } @@ -270,19 +306,30 @@ export const findTableRows = defineAuthorizedTableUseCase({ export interface ReadTableRowInput extends TableScopedInput { rowId: string + includePersistedSecretProvenance?: boolean } export interface ReadTableRowResult extends TableResult { row: TableRow + secretProvenance?: TableRowsProvenance } export const readTableRow = defineAuthorizedTableUseCase({ operation: tableOperations.readRow, resolveContext: ({ input }: { input: ReadTableRowInput }) => resolveActiveTableContext(input), - async execute({ input, context }): Promise<ReadTableRowResult> { + async execute({ principal, input, context }): Promise<ReadTableRowResult> { const row = await getRowById(context.tableId, input.rowId, context.workspaceId) if (!row) throw new OrchestrationError('not_found', 'Row not found') - return { table: context.table, row } + return { + table: context.table, + row, + secretProvenance: await loadAuthorizedRowsProvenance( + principal, + context.workspaceId, + [row], + input.includePersistedSecretProvenance + ), + } }, }) @@ -428,15 +475,143 @@ export const replaceTableRows = defineAuthorizedTableUseCase({ }, }) +const PROJECTED_WIRE_ROWS_LIMIT = 10_000 +const PROJECTED_SECRET_COLUMN_TYPE_ERROR = + 'Tool output could not be persisted safely because a resolved secret is incompatible with the target column type.' + +export class ProjectedWireRowsValidationError extends TableRowsValidationError { + constructor(message: string) { + super(message) + this.name = 'ProjectedWireRowsValidationError' + } +} + +export interface ReplaceProjectedWireRowsInput extends TableScopedInput { + sourceRows: Array<Record<string, unknown>> + projectedRows: unknown +} + +export interface ReplaceProjectedWireRowsResult extends TableResult, ReplaceRowsResult {} + +function projectedRowsForTable( + table: TableDefinition, + sourceRows: Array<Record<string, unknown>>, + value: unknown +): RowData[] { + if (!Array.isArray(value) || !value.every(isPlainRecord)) { + throw new ProjectedWireRowsValidationError('Table rows could not be persisted safely') + } + if (value.length !== sourceRows.length) { + throw new ProjectedWireRowsValidationError( + 'Projected table rows must align one-to-one with source rows' + ) + } + if (value.length > PROJECTED_WIRE_ROWS_LIMIT) { + throw new ProjectedWireRowsValidationError( + `Table row replacement limit exceeded: got ${value.length}, max is ${PROJECTED_WIRE_ROWS_LIMIT}` + ) + } + + const columnsByName = new Map(table.schema.columns.map((column) => [column.name, column])) + for (let rowIndex = 0; rowIndex < value.length; rowIndex += 1) { + const projected = value[rowIndex] + for (const [name, projectedValue] of Object.entries(projected)) { + const column = columnsByName.get(name) + if (!column || isDeepStrictEqual(sourceRows[rowIndex]?.[name], projectedValue)) continue + const type = columnTypeOf(column).id + if (type !== 'string' && type !== 'json') { + throw new ProjectedWireRowsValidationError(PROJECTED_SECRET_COLUMN_TYPE_ERROR) + } + } + + if (!Object.keys(projected).some((name) => columnsByName.has(name))) { + throw new ProjectedWireRowsValidationError( + `Row ${rowIndex + 1} has no keys matching columns on table "${table.name}" (columns: ${table.schema.columns.map((column) => column.name).join(', ')})` + ) + } + } + + const idByName = buildIdByName(table.schema) + return value.map((row) => rowDataNameToId(row as RowData, idByName)) +} + +/** Atomically validates name-keyed projected rows against the locked schema and replaces the table. */ +export const replaceProjectedWireRows = defineAuthorizedTableUseCase({ + operation: tableOperations.replaceRows, + resolveContext: ({ input }: { input: ReplaceProjectedWireRowsInput }) => + resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise<ReplaceProjectedWireRowsResult> { + if (input.sourceRows.length > PROJECTED_WIRE_ROWS_LIMIT) { + throw new ProjectedWireRowsValidationError( + `Table row replacement limit exceeded: got ${input.sourceRows.length}, max is ${PROJECTED_WIRE_ROWS_LIMIT}` + ) + } + const rowLimit = await assertRowCapacity({ + workspaceId: context.workspaceId, + currentRowCount: 0, + addedRows: input.sourceRows.length, + }) + const result = await withLockedTable( + context.tableId, + async (table, trx) => { + const rows = projectedRowsForTable(table, input.sourceRows, input.projectedRows) + const replacement = await replaceTableRowsWithTx( + trx, + { + tableId: table.id, + workspaceId: table.workspaceId, + rows, + userId: actorUserId(principal, context.billedAccountUserId), + secretProvenance: rows.map(createExactEmptyTableRowSecretProvenance), + }, + table, + requestId(input) + ) + return { table, ...replacement } + }, + { expectedWorkspaceId: context.workspaceId } + ) + notifyTableRowUsage({ + workspaceId: context.workspaceId, + currentRowCount: 0, + addedRows: result.insertedCount, + limit: rowLimit, + }) + return result + }, + projectAudit({ result }) { + if (result.deletedCount === 0 && result.insertedCount === 0) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Replaced rows in table "${result.table.name}"`, + metadata: { + op: 'replace_projected_rows', + rowsDeleted: result.deletedCount, + rowsInserted: result.insertedCount, + }, + } + }, + afterSuccess({ context, result }) { + if (result.deletedCount > 0 || result.insertedCount > 0) { + signalTableRowsChanged(context.tableId) + } + }, +}) + export interface UpdateTableRowInput extends TableScopedInput { rowId: string data: RowData secretProvenance?: TableRowSecretProvenanceWrite + includePersistedSecretProvenance?: boolean } export interface UpdateTableRowResult extends TableResult { row: TableRow changed: boolean + secretProvenance?: TableRowsProvenance } export const updateTableRow = defineAuthorizedTableUseCase({ @@ -457,7 +632,17 @@ export const updateTableRow = defineAuthorizedTableUseCase({ requestId(input) ) if (!row) throw new Error('Unconditional table row update was rejected') - return { table: context.table, row, changed: Object.keys(data).length > 0 } + return { + table: context.table, + row, + changed: Object.keys(data).length > 0, + secretProvenance: await loadAuthorizedRowsProvenance( + principal, + context.workspaceId, + [row], + input.includePersistedSecretProvenance + ), + } }, afterSuccess: ({ context, result }) => { if (result.changed) signalTableRowsChanged(context.tableId) diff --git a/apps/sim/lib/table/application/workspace-file-imports.test.ts b/apps/sim/lib/table/application/workspace-file-imports.test.ts new file mode 100644 index 00000000000..b7f8cfdb73c --- /dev/null +++ b/apps/sim/lib/table/application/workspace-file-imports.test.ts @@ -0,0 +1,355 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const mocks = vi.hoisted(() => ({ + audit: vi.fn(), + batchInsert: vi.fn(), + createTable: vi.fn(), + deleteTable: vi.fn(), + fetchFile: vi.fn(), + inferSchema: vi.fn(), + loadFileContext: vi.fn(), + markJob: vi.fn(), + parseRows: vi.fn(), + provenance: vi.fn(), + releaseJob: vi.fn(), + replaceRows: vi.fn(), + resolveFile: vi.fn(), + resolvePermission: vi.fn(), + resolveTableContext: vi.fn(), + resolveWorkspaceContext: vi.fn(), + runDetached: vi.fn(), + signal: vi.fn(), + validateMapping: vi.fn(), + CsvImportValidationError: class extends Error {}, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_CREATED: 'table.created', TABLE_UPDATED: 'table.updated' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@sim/utils/id', () => ({ generateId: () => 'request-id-1234' })) +vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: false })) +vi.mock('@/lib/core/utils/background', () => ({ runDetached: mocks.runDetached })) +vi.mock('@/lib/table', () => ({ + batchInsertRows: mocks.batchInsert, + buildAutoMapping: vi.fn(() => ({ name: 'name' })), + coerceRowsForTable: (rows: unknown[]) => rows, + CsvImportValidationError: mocks.CsvImportValidationError, + CSV_ASYNC_IMPORT_THRESHOLD_BYTES: 8 * 1024 * 1024, + CSV_MAX_BATCH_SIZE: 1000, + getWorkspaceTableLimits: vi.fn(() => ({ maxRowsPerTable: 100, maxTables: 5 })), + inferSchemaFromCsv: mocks.inferSchema, + parseFileRows: mocks.parseRows, + replaceTableRows: mocks.replaceRows, + sanitizeName: (value: string) => value, + TABLE_LIMITS: { MAX_TABLE_NAME_LENGTH: 128 }, + validateMapping: mocks.validateMapping, +})) +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mocks.resolveTableContext, + resolveTableWorkspaceContext: mocks.resolveWorkspaceContext, +})) +vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mocks.signal })) +vi.mock('@/lib/table/import-runner', () => ({ runTableImport: vi.fn() })) +vi.mock('@/lib/table/jobs/service', () => ({ + markTableJobRunningInWorkspace: mocks.markJob, + releaseJobClaimInWorkspace: mocks.releaseJob, +})) +vi.mock('@/lib/table/rows/secret-provenance', () => ({ + createExactEmptyTableRowSecretProvenance: () => ({ complete: true, columns: {} }), +})) +vi.mock('@/lib/table/service', () => ({ + createTable: mocks.createTable, + deleteTable: mocks.deleteTable, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + fetchWorkspaceFileBuffer: mocks.fetchFile, + loadActiveWorkspaceFileContext: mocks.loadFileContext, + resolveWorkspaceFileReference: mocks.resolveFile, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ + getBoundWorkspaceFileSecretProvenance: mocks.provenance, +})) + +import { + createTableFromWorkspaceFile, + importWorkspaceFileIntoTable, +} from '@/lib/table/application/workspace-file-imports' + +const table: TableDefinition = { + id: 'table-1', + name: 'People', + description: 'Imported', + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' }] }, + metadata: null, + rowCount: 0, + maxRows: 100, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), +} +const sourceFile = { + id: 'file-1', + workspaceId: 'workspace-1', + key: 'workspace/workspace-1/people.csv', + name: 'people.csv', + type: 'text/csv', + size: 128, +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-1', + audience: 'sim:tables', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), +} +const tablePrincipal = { ...principal, resourceScope: { tableId: 'table-1' } } + +describe('workspace-file Table application commands', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveWorkspaceContext.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.resolveTableContext.mockResolvedValue({ + tableId: table.id, + table, + workspaceId: table.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.resolveFile.mockResolvedValue(sourceFile) + mocks.loadFileContext.mockResolvedValue(sourceFile) + mocks.provenance.mockResolvedValue({ status: 'exact', entries: [] }) + mocks.fetchFile.mockResolvedValue(Buffer.from('name\nAda')) + mocks.parseRows.mockResolvedValue({ headers: ['name'], rows: [{ name: 'Ada' }] }) + mocks.inferSchema.mockReturnValue({ + columns: [{ name: 'name', type: 'string' }], + headerToColumn: new Map([['name', 'name']]), + }) + mocks.createTable.mockResolvedValue(table) + mocks.deleteTable.mockResolvedValue(undefined) + mocks.batchInsert.mockImplementation(async ({ rows }: { rows: unknown[] }) => + rows.map((_, index) => ({ id: `row-${index}` })) + ) + mocks.replaceRows.mockResolvedValue({ insertedCount: 1, deletedCount: 2 }) + mocks.markJob.mockResolvedValue(true) + mocks.releaseJob.mockResolvedValue(true) + mocks.validateMapping.mockReturnValue({ + effectiveMap: new Map([['name', 'name']]), + mappedHeaders: ['name'], + skippedHeaders: [], + }) + }) + + it('owns canonical file resolution, bounded parsing, table creation, audit, and effects', async () => { + const result = await createTableFromWorkspaceFile.execute({ + principal, + input: { + workspaceId: 'workspace-1', + fileReference: 'files/people.csv', + name: 'People', + }, + }) + + expect(result).toMatchObject({ kind: 'inline', insertedCount: 1, table }) + expect(mocks.resolveFile).toHaveBeenCalledWith('workspace-1', 'files/people.csv') + expect(mocks.fetchFile).toHaveBeenCalledWith(sourceFile, { maxBytes: 50 * 1024 * 1024 }) + expect(mocks.createTable).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: 'workspace-1', userId: 'user-1', maxTables: 5 }), + 'request-' + ) + expect(mocks.batchInsert).toHaveBeenCalledTimes(1) + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.signal).toHaveBeenCalledWith(table.id) + }) + + it('conceals cross-workspace files before parsing or table mutation', async () => { + mocks.resolveFile.mockResolvedValueOnce({ ...sourceFile, workspaceId: 'workspace-other' }) + + await expect( + createTableFromWorkspaceFile.execute({ + principal, + input: { workspaceId: 'workspace-1', fileReference: 'files/people.csv' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.fetchFile).not.toHaveBeenCalled() + expect(mocks.createTable).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('rejects non-delegated upload identities before canonical workspace or file loading', async () => { + await expect( + createTableFromWorkspaceFile.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + } as never, + input: { workspaceId: 'workspace-1', fileReference: 'files/people.csv' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveWorkspaceContext).not.toHaveBeenCalled() + expect(mocks.resolveFile).not.toHaveBeenCalled() + }) + + it('preserves large-file background admission without buffering inline', async () => { + mocks.resolveFile.mockResolvedValueOnce({ ...sourceFile, size: 8 * 1024 * 1024 }) + + const result = await createTableFromWorkspaceFile.execute({ + principal, + input: { workspaceId: 'workspace-1', fileReference: 'files/people.csv' }, + }) + + expect(result).toMatchObject({ kind: 'background', table, jobId: 'request-id-1234' }) + expect(mocks.fetchFile).not.toHaveBeenCalled() + expect(mocks.runDetached).toHaveBeenCalledTimes(1) + expect(mocks.audit).toHaveBeenCalledTimes(1) + }) + + it('rolls back a partially-created table and emits no audit or effect on insertion failure', async () => { + const failure = new Error('database unavailable') + mocks.batchInsert.mockRejectedValueOnce(failure) + + await expect( + createTableFromWorkspaceFile.execute({ + principal, + input: { workspaceId: 'workspace-1', fileReference: 'files/people.csv' }, + }) + ).rejects.toBe(failure) + + expect(mocks.deleteTable).toHaveBeenCalledWith(table.id, 'request-') + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) + + it('holds the concurrency claim across file loading and inline mutation', async () => { + const events: string[] = [] + mocks.markJob.mockImplementationOnce(async () => { + events.push('claim') + return true + }) + mocks.fetchFile.mockImplementationOnce(async () => { + events.push('load') + return Buffer.from('name\nAda') + }) + mocks.batchInsert.mockImplementationOnce(async () => { + events.push('mutate') + return [{ id: 'row-1' }] + }) + mocks.releaseJob.mockImplementationOnce(async () => { + events.push('release') + return true + }) + + await importWorkspaceFileIntoTable.execute({ + principal: tablePrincipal, + input: { + tableId: table.id, + assertedWorkspaceId: table.workspaceId, + fileReference: 'files/people.csv', + mode: 'append', + }, + }) + + expect(events).toEqual(['claim', 'load', 'mutate', 'release']) + }) + + it('rejects a concurrent import claim before buffering or mutating rows', async () => { + mocks.markJob.mockResolvedValueOnce(false) + + await expect( + importWorkspaceFileIntoTable.execute({ + principal: tablePrincipal, + input: { + tableId: table.id, + assertedWorkspaceId: table.workspaceId, + fileReference: 'files/people.csv', + mode: 'append', + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + + expect(mocks.fetchFile).not.toHaveBeenCalled() + expect(mocks.batchInsert).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('preserves append partial-failure semantics and releases the claim on abort', async () => { + mocks.parseRows.mockResolvedValueOnce({ + headers: ['name'], + rows: Array.from({ length: 1001 }, (_, index) => ({ name: `Person ${index}` })), + }) + const stopped = new Error('stopped') + let checks = 0 + const assertNotAborted = vi.fn(() => { + checks += 1 + if (checks === 3) throw stopped + }) + + await expect( + importWorkspaceFileIntoTable.execute({ + principal: tablePrincipal, + input: { + tableId: table.id, + assertedWorkspaceId: table.workspaceId, + fileReference: 'files/people.csv', + mode: 'append', + assertNotAborted, + }, + }) + ).rejects.toBe(stopped) + + expect(mocks.batchInsert).toHaveBeenCalledTimes(1) + expect(mocks.releaseJob).toHaveBeenCalledWith(table.id, table.workspaceId, 'request-id-1234') + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) + + it('rejects non-empty secret provenance before parsing or mutation', async () => { + mocks.provenance.mockResolvedValueOnce({ status: 'exact', entries: [{ name: 'SECRET' }] }) + + await expect( + importWorkspaceFileIntoTable.execute({ + principal: tablePrincipal, + input: { + tableId: table.id, + assertedWorkspaceId: table.workspaceId, + fileReference: 'files/people.csv', + mode: 'append', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.fetchFile).not.toHaveBeenCalled() + expect(mocks.markJob).not.toHaveBeenCalled() + expect(mocks.batchInsert).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/workspace-file-imports.ts b/apps/sim/lib/table/application/workspace-file-imports.ts new file mode 100644 index 00000000000..f0e710a0660 --- /dev/null +++ b/apps/sim/lib/table/application/workspace-file-imports.ts @@ -0,0 +1,548 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { runDetached } from '@/lib/core/utils/background' +import { + batchInsertRows, + buildAutoMapping, + type ColumnDefinition, + CSV_ASYNC_IMPORT_THRESHOLD_BYTES, + CSV_MAX_BATCH_SIZE, + type CsvHeaderMapping, + CsvImportValidationError, + coerceRowsForTable, + getWorkspaceTableLimits, + inferSchemaFromCsv, + parseFileRows, + type RowData, + replaceTableRows, + sanitizeName, + TABLE_LIMITS, + type TableDefinition, + validateMapping, +} from '@/lib/table' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { + resolveActiveTableContext, + resolveTableWorkspaceContext, +} from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { signalTableRowsChanged } from '@/lib/table/events' +import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' +import { + markTableJobRunningInWorkspace, + releaseJobClaimInWorkspace, +} from '@/lib/table/jobs/service' +import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' +import { createTable, deleteTable } from '@/lib/table/service' +import { + fetchWorkspaceFileBuffer, + loadActiveWorkspaceFileContext, + resolveWorkspaceFileReference, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' + +const logger = createLogger('TableWorkspaceFileImportApplication') + +export interface TableWorkspaceFileSource { + id: string + workspaceId: string + key: string + name: string + type: string + size: number +} + +const MAX_INLINE_FILE_BYTES = 50 * 1024 * 1024 + +export interface CreateTableFromWorkspaceFileInput { + workspaceId: string + fileReference: string + name?: string + description?: string + assertNotAborted?: () => void +} + +export type CreateTableFromWorkspaceFileResult = + | { + kind: 'empty' + sourceFile: TableWorkspaceFileSource + } + | { + kind: 'background' + table: TableDefinition + jobId: string + sourceFile: TableWorkspaceFileSource + } + | { + kind: 'inline' + table: TableDefinition + columns: ColumnDefinition[] + insertedCount: number + droppedRows: number + maxRowsPerTable: number + sourceFile: TableWorkspaceFileSource + } + +export interface ImportWorkspaceFileInput { + tableId: string + assertedWorkspaceId: string + fileReference: string + mode: 'append' | 'replace' + mapping?: CsvHeaderMapping + assertNotAborted?: () => void +} + +export type ImportWorkspaceFileResult = + | { + kind: 'background' + table: TableDefinition + jobId: string + mode: 'append' | 'replace' + sourceFileName: string + } + | { + kind: 'empty' + table: TableDefinition + mode: 'append' | 'replace' + } + | { + kind: 'inline' + table: TableDefinition + mode: 'append' + matchedColumns: string[] + skippedColumns: string[] + insertedCount: number + sourceFileName: string + } + | { + kind: 'inline' + table: TableDefinition + mode: 'replace' + matchedColumns: string[] + skippedColumns: string[] + insertedCount: number + deletedCount: number + sourceFileName: string + } + +function requestId(): string { + return generateId().slice(0, 8) +} + +async function resolveSafeSourceFile( + workspaceId: string, + reference: string +): Promise<WorkspaceFileRecord> { + const file = await resolveWorkspaceFileReference(workspaceId, reference) + if (!file) { + if (reference.replace(/^\/+/, '').startsWith('uploads/')) { + throw new OrchestrationError( + 'validation', + `Cannot import "${reference}": chat uploads are not workspace files. Use materialize_file to save it to a files/... path first, then pass that canonical path.` + ) + } + throw new OrchestrationError( + 'not_found', + `File not found: "${reference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.` + ) + } + const canonical = await loadActiveWorkspaceFileContext(file.id) + if (!canonical || canonical.workspaceId !== workspaceId || file.workspaceId !== workspaceId) { + throw new OrchestrationError('not_found', 'Workspace file not found') + } + const provenance = await getBoundWorkspaceFileSecretProvenance(workspaceId, { + fileId: file.id, + key: file.key, + context: 'workspace', + }) + if (provenance.status !== 'exact' || provenance.entries.length > 0) { + throw new OrchestrationError( + 'validation', + `Cannot import "${reference}": the file cannot be verified as free of resolved secrets.` + ) + } + return file +} + +function shouldImportInBackground(file: TableWorkspaceFileSource): boolean { + const extension = file.name.split('.').pop()?.toLowerCase() + return ( + (extension === 'csv' || extension === 'tsv') && file.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES + ) +} + +async function loadInlineRows(file: WorkspaceFileRecord) { + const content = await fetchWorkspaceFileBuffer(file, { maxBytes: MAX_INLINE_FILE_BYTES }) + return parseFileRows(content, file.name, file.type) +} + +async function batchInsertAll(params: { + table: TableDefinition + rows: RowData[] + workspaceId: string + userId: string + assertNotAborted?: () => void +}): Promise<number> { + let inserted = 0 + for (let index = 0; index < params.rows.length; index += CSV_MAX_BATCH_SIZE) { + params.assertNotAborted?.() + const batch = params.rows.slice(index, index + CSV_MAX_BATCH_SIZE) + const result = await batchInsertRows( + { + tableId: params.table.id, + rows: batch, + workspaceId: params.workspaceId, + userId: params.userId, + secretProvenance: batch.map(createExactEmptyTableRowSecretProvenance), + }, + { ...params.table, rowCount: params.table.rowCount + inserted }, + requestId() + ) + inserted += result.length + } + return inserted +} + +async function dispatchImportJob(payload: TableImportPayload): Promise<void> { + if (isTriggerDevEnabled) { + try { + const [{ tableImportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ + import('@/background/table-import'), + import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), + ]) + await tasks.trigger<typeof tableImportTask>('table-import', payload, { + tags: [`tableId:${payload.tableId}`, `jobId:${payload.importId}`], + region: await resolveTriggerRegion(), + }) + } catch (error) { + try { + const released = await releaseJobClaimInWorkspace( + payload.tableId, + payload.workspaceId, + payload.importId + ) + if (!released) throw new Error('Table import claim was no longer active') + } catch (cleanupError) { + logger.error('Failed to release table import claim after dispatch failure', { + tableId: payload.tableId, + jobId: payload.importId, + error: getErrorMessage(cleanupError), + }) + } + throw error + } + return + } + runDetached('table-import', () => runTableImport(payload)) +} + +async function withReleasedTableJobClaim<T>( + tableId: string, + workspaceId: string, + jobId: string, + run: () => Promise<T> +): Promise<T> { + let result: T + try { + result = await run() + } catch (error) { + try { + const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) + if (!released) throw new Error('Table import claim was no longer active') + } catch (cleanupError) { + logger.error('Failed to release table import claim after operation failure', { + tableId, + workspaceId, + jobId, + error: getErrorMessage(cleanupError), + }) + } + throw error + } + const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) + if (!released) throw new Error('Table import claim was no longer active') + return result +} + +export const createTableFromWorkspaceFile = defineAuthorizedTableUseCase({ + operation: tableOperations.createFromWorkspaceFile, + resolveContext: ({ input }: { input: CreateTableFromWorkspaceFileInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }): Promise<CreateTableFromWorkspaceFileResult> { + const sourceFile = await resolveSafeSourceFile(context.workspaceId, input.fileReference) + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const limits = await getWorkspaceTableLimits(context.workspaceId) + const name = + input.name ?? + sanitizeName(sourceFile.name.replace(/\.[^.]+$/, ''), 'imported_table').slice( + 0, + TABLE_LIMITS.MAX_TABLE_NAME_LENGTH + ) + const description = input.description ?? `Imported from ${sourceFile.name}` + + if (shouldImportInBackground(sourceFile)) { + input.assertNotAborted?.() + const jobId = generateId() + const table = await createTable( + { + name, + description, + schema: { columns: [{ name: 'column_1', type: 'string' }] }, + workspaceId: context.workspaceId, + userId, + maxRows: limits.maxRowsPerTable, + maxTables: limits.maxTables, + jobStatus: 'running', + jobType: 'import', + jobId, + }, + requestId() + ) + try { + await dispatchImportJob({ + importId: jobId, + tableId: table.id, + workspaceId: context.workspaceId, + userId, + fileKey: sourceFile.key, + fileName: sourceFile.name, + delimiter: sourceFile.name.toLowerCase().endsWith('.tsv') ? '\t' : ',', + mode: 'create', + deleteSourceFile: false, + }) + } catch (error) { + try { + await deleteTable(table.id, requestId()) + } catch (cleanupError) { + logger.error('Failed to remove placeholder table after import dispatch failure', { + tableId: table.id, + error: getErrorMessage(cleanupError), + }) + } + throw error + } + return { kind: 'background', table, jobId, sourceFile } + } + + const { headers, rows: sourceRows } = await loadInlineRows(sourceFile) + if (sourceRows.length === 0) { + return { kind: 'empty', sourceFile } + } + const { columns, headerToColumn } = inferSchemaFromCsv(headers, sourceRows) + input.assertNotAborted?.() + const droppedRows = Math.max(0, sourceRows.length - limits.maxRowsPerTable) + const rows = droppedRows > 0 ? sourceRows.slice(0, limits.maxRowsPerTable) : sourceRows + const table = await createTable( + { + name, + description, + schema: { columns }, + workspaceId: context.workspaceId, + userId, + maxTables: limits.maxTables, + }, + requestId() + ) + try { + const insertedCount = await batchInsertAll({ + table, + rows: coerceRowsForTable(rows, table.schema, headerToColumn), + workspaceId: context.workspaceId, + userId, + assertNotAborted: input.assertNotAborted, + }) + return { + kind: 'inline', + table, + columns, + insertedCount, + droppedRows, + maxRowsPerTable: limits.maxRowsPerTable, + sourceFile, + } + } catch (error) { + try { + await deleteTable(table.id, requestId()) + } catch (cleanupError) { + logger.error('Failed to roll back table after import failure', { + tableId: table.id, + error: getErrorMessage(cleanupError), + }) + } + throw error + } + }, + projectAudit({ result }) { + if (result.kind === 'empty') return [] + return { + action: AuditAction.TABLE_CREATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Created table "${result.table.name}" from workspace file`, + metadata: { sourceFileId: result.sourceFile.id, importMode: result.kind }, + } + }, + afterSuccess({ result }) { + if (result.kind === 'inline' && result.insertedCount > 0) { + signalTableRowsChanged(result.table.id) + } + }, +}) + +export const importWorkspaceFileIntoTable = defineAuthorizedTableUseCase({ + operation: tableOperations.importWorkspaceFile, + resolveContext: ({ input }: { input: ImportWorkspaceFileInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.assertedWorkspaceId, + }), + async execute({ principal, input, context }): Promise<ImportWorkspaceFileResult> { + const sourceFile = await resolveSafeSourceFile(context.workspaceId, input.fileReference) + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + + if (shouldImportInBackground(sourceFile)) { + input.assertNotAborted?.() + const jobId = generateId() + const claimed = await markTableJobRunningInWorkspace( + context.table.id, + context.workspaceId, + jobId, + 'import' + ) + if (!claimed) + throw new OrchestrationError('conflict', 'A job is already in progress for this table') + await dispatchImportJob({ + importId: jobId, + tableId: context.table.id, + workspaceId: context.workspaceId, + userId, + fileKey: sourceFile.key, + fileName: sourceFile.name, + delimiter: sourceFile.name.toLowerCase().endsWith('.tsv') ? '\t' : ',', + mode: input.mode, + mapping: input.mapping, + deleteSourceFile: false, + }) + return { + kind: 'background', + table: context.table, + jobId, + mode: input.mode, + sourceFileName: sourceFile.name, + } + } + + const jobId = generateId() + const claimed = await markTableJobRunningInWorkspace( + context.table.id, + context.workspaceId, + jobId, + 'import' + ) + if (!claimed) + throw new OrchestrationError('conflict', 'A job is already in progress for this table') + return withReleasedTableJobClaim(context.table.id, context.workspaceId, jobId, async () => { + const { headers, rows: sourceRows } = await loadInlineRows(sourceFile) + input.assertNotAborted?.() + if (sourceRows.length === 0) { + return { kind: 'empty', table: context.table, mode: input.mode } + } + const mapping = input.mapping ?? buildAutoMapping(headers, context.table.schema) + let validation: ReturnType<typeof validateMapping> + try { + validation = validateMapping({ + csvHeaders: headers, + mapping, + tableSchema: context.table.schema, + }) + } catch (error) { + if (!(error instanceof CsvImportValidationError)) throw error + throw new OrchestrationError('validation', error.message) + } + if (validation.mappedHeaders.length === 0) { + throw new OrchestrationError( + 'validation', + `No matching columns between file (${headers.join(', ')}) and table (${context.table.schema.columns.map((column) => column.name).join(', ')})` + ) + } + const rows = coerceRowsForTable(sourceRows, context.table.schema, validation.effectiveMap) + if (input.mode === 'replace') { + const result = await replaceTableRows( + { + tableId: context.table.id, + rows, + workspaceId: context.workspaceId, + userId, + secretProvenance: rows.map(createExactEmptyTableRowSecretProvenance), + }, + context.table, + requestId() + ) + return { + kind: 'inline', + table: context.table, + mode: input.mode, + matchedColumns: validation.mappedHeaders, + skippedColumns: validation.skippedHeaders, + insertedCount: result.insertedCount, + deletedCount: result.deletedCount, + sourceFileName: sourceFile.name, + } + } + const insertedCount = await batchInsertAll({ + table: context.table, + rows, + workspaceId: context.workspaceId, + userId, + assertNotAborted: input.assertNotAborted, + }) + return { + kind: 'inline', + table: context.table, + mode: input.mode, + matchedColumns: validation.mappedHeaders, + skippedColumns: validation.skippedHeaders, + insertedCount, + sourceFileName: sourceFile.name, + } + }) + }, + projectAudit({ result }) { + if (result.kind !== 'inline') return [] + const affected = result.insertedCount + (result.mode === 'replace' ? result.deletedCount : 0) + if (affected === 0) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Imported workspace file into table "${result.table.name}"`, + metadata: { + op: 'workspace_file_import', + mode: result.mode, + rowsInserted: result.insertedCount, + ...(result.mode === 'replace' ? { rowsDeleted: result.deletedCount } : {}), + }, + } + }, + afterSuccess({ result }) { + if ( + result.kind === 'inline' && + (result.insertedCount > 0 || (result.mode === 'replace' && result.deletedCount > 0)) + ) { + signalTableRowsChanged(result.table.id) + } + }, +}) diff --git a/apps/sim/lib/table/orchestration/import-resource.test.ts b/apps/sim/lib/table/orchestration/import-resource.test.ts index b281965477d..143b360d4e6 100644 --- a/apps/sim/lib/table/orchestration/import-resource.test.ts +++ b/apps/sim/lib/table/orchestration/import-resource.test.ts @@ -7,7 +7,6 @@ const { mockCreateTable, mockCreateUploadSession, mockDbLimit, - mockGetUserEntityPermissions, mockGetUserSettings, mockGetWorkspaceFile, mockGetWorkspaceTableLimits, @@ -16,7 +15,6 @@ const { mockCreateTable: vi.fn(), mockCreateUploadSession: vi.fn(), mockDbLimit: vi.fn(), - mockGetUserEntityPermissions: vi.fn(), mockGetUserSettings: vi.fn(), mockGetWorkspaceFile: vi.fn(), mockGetWorkspaceTableLimits: vi.fn(), @@ -47,16 +45,23 @@ vi.mock('@/lib/uploads/upload-session/service', () => ({ getOwnedUploadSession: vi.fn(), })) vi.mock('@/lib/users/queries', () => ({ getUserSettings: mockGetUserSettings })) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, -})) import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' -import { createTableImportResource } from '@/lib/table/orchestration/import-resource' +import { createAuthorizedTableImportResource } from '@/lib/table/orchestration/import-resource' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' const SOURCE = { type: 'workspace_file' as const, fileId: 'file-1' } const TARGET = { type: 'new' as const, name: 'imported_data' } +const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + +function createImport(body: Parameters<typeof createAuthorizedTableImportResource>[0]['body']) { + return createAuthorizedTableImportResource({ + body, + userId: 'user-1', + principal, + localOrigin: 'http://localhost:3000', + }) +} function workspaceFile(size: number) { return { @@ -73,10 +78,9 @@ function workspaceFile(size: number) { } } -describe('createTableImportResource workspace file size', () => { +describe('createAuthorizedTableImportResource workspace file size', () => { beforeEach(() => { vi.clearAllMocks() - mockGetUserEntityPermissions.mockResolvedValue('write') mockGetWorkspaceTableLimits.mockResolvedValue({ maxTables: 100, maxRowsPerTable: 10_000 }) mockCreateTable.mockResolvedValue({ id: 'table-1' }) mockGetUserSettings.mockResolvedValue({ timezone: 'UTC' }) @@ -106,11 +110,7 @@ describe('createTableImportResource workspace file size', () => { it('accepts a workspace CSV at the exact byte limit', async () => { mockGetWorkspaceFile.mockResolvedValue(workspaceFile(CSV_MAX_FILE_SIZE_BYTES)) - const result = await createTableImportResource( - { workspaceId: WORKSPACE_ID, source: SOURCE, target: TARGET }, - 'user-1', - 'http://localhost:3000' - ) + const result = await createImport({ workspaceId: WORKSPACE_ID, source: SOURCE, target: TARGET }) expect(result.upload).toBeNull() expect(mockCreateTable).toHaveBeenCalledOnce() @@ -121,21 +121,16 @@ describe('createTableImportResource workspace file size', () => { mockGetWorkspaceFile.mockResolvedValue(workspaceFile(CSV_MAX_FILE_SIZE_BYTES + 1)) await expect( - createTableImportResource( - { workspaceId: WORKSPACE_ID, source: SOURCE, target: TARGET }, - 'user-1', - 'http://localhost:3000' - ) + createImport({ workspaceId: WORKSPACE_ID, source: SOURCE, target: TARGET }) ).rejects.toMatchObject({ code: 'validation' }) expect(mockCreateTable).not.toHaveBeenCalled() expect(mockRunDetached).not.toHaveBeenCalled() }) }) -describe('createTableImportResource upload size', () => { +describe('createAuthorizedTableImportResource upload size', () => { beforeEach(() => { vi.clearAllMocks() - mockGetUserEntityPermissions.mockResolvedValue('write') mockCreateUploadSession.mockResolvedValue({ id: 'import-1', userId: 'user-1', @@ -149,20 +144,16 @@ describe('createTableImportResource upload size', () => { }) it('creates an upload session for a CSV at the exact byte limit', async () => { - await createTableImportResource( - { - workspaceId: WORKSPACE_ID, - source: { - type: 'upload', - name: 'data.csv', - contentType: 'text/csv', - size: CSV_MAX_FILE_SIZE_BYTES, - }, - target: TARGET, + await createImport({ + workspaceId: WORKSPACE_ID, + source: { + type: 'upload', + name: 'data.csv', + contentType: 'text/csv', + size: CSV_MAX_FILE_SIZE_BYTES, }, - 'user-1', - 'http://localhost:3000' - ) + target: TARGET, + }) expect(mockCreateUploadSession).toHaveBeenCalledWith( expect.objectContaining({ fileSize: CSV_MAX_FILE_SIZE_BYTES, purpose: 'table_import' }) @@ -171,20 +162,16 @@ describe('createTableImportResource upload size', () => { it('rejects an upload one byte over the limit before creating a session', async () => { await expect( - createTableImportResource( - { - workspaceId: WORKSPACE_ID, - source: { - type: 'upload', - name: 'data.csv', - contentType: 'text/csv', - size: CSV_MAX_FILE_SIZE_BYTES + 1, - }, - target: TARGET, + createImport({ + workspaceId: WORKSPACE_ID, + source: { + type: 'upload', + name: 'data.csv', + contentType: 'text/csv', + size: CSV_MAX_FILE_SIZE_BYTES + 1, }, - 'user-1', - 'http://localhost:3000' - ) + target: TARGET, + }) ).rejects.toMatchObject({ code: 'validation' }) expect(mockCreateUploadSession).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts index b61cab84a64..d58ca0c9a57 100644 --- a/apps/sim/lib/table/orchestration/import-resource.ts +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -38,12 +38,13 @@ import { type UploadSessionRecord, } from '@/lib/uploads/upload-session/service' import { getUserSettings } from '@/lib/users/queries' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('TableImportResource') type TableImportStatus = 'uploading' | 'running' | 'ready' | 'failed' | 'canceled' | 'expired' +export type CreateTableImportRequest = V2CreateTableImportBody + export interface TableImportResource { id: string workspaceId: string @@ -66,7 +67,7 @@ export interface CreateTableImportResult { } interface AuthorizedTableImportResourceParams { - body: V2CreateTableImportBody + body: CreateTableImportRequest userId: string principal?: Principal localOrigin?: string @@ -128,22 +129,6 @@ export async function createAuthorizedTableImportResource( return createTableImportResourceCore(params) } -/** Legacy internal resource entry point retained until internal JWTs carry signed workspace scope. */ -export async function createTableImportResource( - body: V2CreateTableImportBody, - userId: string, - localOrigin: string, - resolvedFolderId?: string | null -): Promise<CreateTableImportResult> { - await assertWorkspaceWrite(userId, body.workspaceId) - return createTableImportResourceCore({ - body, - userId, - localOrigin, - resolvedFolderId, - }) -} - export async function startUploadedTableImport( upload: UploadSessionRecord ): Promise<TableImportResource> { @@ -195,24 +180,6 @@ export async function getPrincipalTableImportUpload(params: { return upload } -/** Legacy internal lookup retained until its bearer token can bind a full Principal. */ -export async function getOwnedTableImportUpload(params: { - importId: string - workspaceId: string - userId: string - uploadToken: string -}): Promise<UploadSessionRecord> { - const upload = await getOwnedUploadSession({ - uploadId: params.importId, - workspaceId: params.workspaceId, - userId: params.userId, - purpose: 'table_import', - uploadToken: params.uploadToken, - }) - tableImportBodyFromUpload(upload) - return upload -} - export async function abortAuthorizedTableImportUpload( upload: UploadSessionRecord, principal: Principal @@ -222,18 +189,6 @@ export async function abortAuthorizedTableImportUpload( return resourceFromUpload(await abortUploadSession(upload), body) } -/** Legacy internal cancellation retained until its bearer token can bind a full Principal. */ -export async function abortTableImportUpload(params: { - importId: string - workspaceId: string - userId: string - uploadToken: string -}): Promise<TableImportResource> { - const upload = await getOwnedTableImportUpload(params) - const body = tableImportBodyFromUpload(upload) - return resourceFromUpload(await abortUploadSession(upload), body) -} - export async function getTableImportResource(params: { importId: string assertedWorkspaceId?: string @@ -279,28 +234,6 @@ export async function findTableImportResource(params: { } } -export async function getOwnedTableImport(params: { - importId: string - workspaceId: string - userId: string -}): Promise<TableImportResource> { - const record = await findOwnedTableImport(params) - if (!record) throw new OrchestrationError('not_found', 'Table import not found') - return record -} - -export async function findOwnedTableImport(params: { - importId: string - workspaceId: string - userId: string -}): Promise<TableImportResource | null> { - const record = await findTableImportResource({ - importId: params.importId, - assertedWorkspaceId: params.workspaceId, - }) - return record?.userId === params.userId ? record : null -} - export async function cancelTableImportResource( record: TableImportResource ): Promise<TableImportResource> { @@ -596,13 +529,6 @@ async function requireWorkspaceSource( return resolved } -async function assertWorkspaceWrite(userId: string, workspaceId: string): Promise<void> { - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - throw new OrchestrationError('forbidden', 'Access denied') - } -} - function uploadStatus(upload: UploadSessionRecord): TableImportStatus { switch (upload.status) { case 'uploading': diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 6d95f1c9f54..cc009203728 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -939,6 +939,11 @@ export interface UpdateWorkflowGroupData { * source. */ mappingUpdates?: Array<{ columnName: string; blockId: string; path: string }> + /** Workflow-authorized column types for mapping updates. */ + resolvedMappingTypes?: { + workflowId: string + columns: Array<{ columnName: string; type: ColumnDefinition['type'] }> + } /** Replace the group's input mappings. Omit to leave them unchanged. */ inputMappings?: WorkflowGroupInputMapping[] /** Change which workflow state the group runs against. Omit to leave unchanged. */ diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index 394a16922f3..35378a997b9 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -19,6 +19,7 @@ import { getColumnId, remapGroupColumnRefs, } from '@/lib/table/column-keys' +import { deriveOutputColumnName } from '@/lib/table/column-naming' import { NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' import { assertColumnDestructive, assertSchemaMutable } from '@/lib/table/mutation-locks' import { stripGroupExecutions } from '@/lib/table/rows/executions' @@ -249,60 +250,22 @@ export async function updateWorkflowGroup( ): Promise<TableDefinition> { const mappingUpdates = data.mappingUpdates ?? [] - // Phase 1 (no lock): when there are mapping updates, load the workflow once to - // resolve each remap's new leaf type. Kept OFF the advisory-lock critical - // section so concurrent group edits on the same table don't time out waiting - // on this DB load. Best-effort — a resolution failure leaves column types - // unchanged (workflow deleted, block removed). The result is applied against - // the fresh schema under the lock in phase 2. + // Phase 1 (no lock): consume the output types resolved and authorized by the + // application command. Resolution stays outside the advisory-lock critical + // section so concurrent group edits do not hold the schema lock during the + // workflow read. Missing metadata is an application-boundary violation. const remapLeafTypeByColumn = new Map<string, ColumnDefinition['type']>() // The workflow id the leaf types above were resolved against. Phase 2 only // applies the resolved types if the group still points at this workflow under // the lock — a concurrent `workflowId` change would make them stale. let resolvedForWorkflowId: string | undefined if (mappingUpdates.length > 0) { - const preTable = await getTableById(data.tableId) - if (!preTable || (data.workspaceId && preTable.workspaceId !== data.workspaceId)) { - throw new OrchestrationError('not_found', 'Table not found') + if (!data.resolvedMappingTypes) { + throw new Error('Workflow group mapping updates require authorized resolved output types') } - try { - const preGroup = preTable?.schema.workflowGroups?.find((g) => g.id === data.groupId) - const targetWorkflowId = data.workflowId ?? preGroup?.workflowId - if (targetWorkflowId) { - resolvedForWorkflowId = targetWorkflowId - const [ - { loadWorkflowFromNormalizedTables }, - { flattenWorkflowOutputs }, - { columnTypeForLeaf }, - ] = await Promise.all([ - import('@/lib/workflows/persistence/utils'), - import('@/lib/workflows/blocks/flatten-outputs'), - import('@/lib/table/column-naming'), - ]) - const normalized = await loadWorkflowFromNormalizedTables(targetWorkflowId) - if (normalized) { - const blocks = Object.values(normalized.blocks ?? {}).map((b) => ({ - id: b.id, - type: b.type, - name: b.name, - triggerMode: (b as { triggerMode?: boolean }).triggerMode, - subBlocks: b.subBlocks as Record<string, unknown> | undefined, - })) - const flattened = flattenWorkflowOutputs(blocks, normalized.edges ?? []) - const flatByKey = new Map(flattened.map((f) => [`${f.blockId}::${f.path}`, f])) - for (const u of mappingUpdates) { - const match = flatByKey.get(`${u.blockId}::${u.path}`) - if (!match) continue - const newType = columnTypeForLeaf(match.leafType) - if (newType) remapLeafTypeByColumn.set(u.columnName, newType) - } - } - } - } catch (err) { - logger.warn( - `[${requestId}] Could not resolve new leaf types for remap on group ${data.groupId}; leaving column types unchanged:`, - err - ) + resolvedForWorkflowId = data.resolvedMappingTypes.workflowId + for (const resolved of data.resolvedMappingTypes.columns) { + remapLeafTypeByColumn.set(resolved.columnName, resolved.type) } } @@ -387,24 +350,22 @@ export async function updateWorkflowGroup( }) // Only apply the out-of-lock leaf-type resolution if the group still - // points at the workflow we resolved against. If a concurrent writer - // changed `workflowId` between phase 1 and now, those types are stale — - // leave column types unchanged (best-effort, same as a resolution - // failure) rather than stamping types from the old workflow. + // points at the workflow we resolved against. A concurrent workflow + // remap invalidates the command snapshot and must be retried. const finalWorkflowId = data.workflowId ?? group.workflowId if (remapLeafTypeById.size > 0 && resolvedForWorkflowId !== finalWorkflowId) { - logger.warn( - `[${requestId}] Workflow group "${data.groupId}" workflowId changed between leaf-type resolution and apply; leaving remapped column types unchanged.` + throw new OrchestrationError( + 'conflict', + `Workflow group "${data.groupId}" changed concurrently; retry the update.` ) - } else { - const colById = new Map(schema.columns.map((c) => [getColumnId(c), c])) - for (const u of mappingUpdatesNorm) { - const newType = remapLeafTypeById.get(u.columnName) - if (!newType) continue - const oldType = colById.get(u.columnName)?.type - if (newType !== oldType) { - remappedColumnTypes.set(u.columnName, newType) - } + } + const colById = new Map(schema.columns.map((c) => [getColumnId(c), c])) + for (const u of mappingUpdatesNorm) { + const newType = remapLeafTypeById.get(u.columnName) + if (!newType) continue + const oldType = colById.get(u.columnName)?.type + if (newType !== oldType) { + remappedColumnTypes.set(u.columnName, newType) } } } @@ -657,15 +618,22 @@ export async function addWorkflowGroupOutput( columnName?: string /** The member adding the output — billed/gated for any backfill-triggered re-run. */ actorUserId?: string | null + resolvedOutput: { + workflowId: string + columnType: ColumnDefinition['type'] + order: Array<{ + blockId: string + path: string + executionDistance: number + discoveryIndex: number + }> + } }, requestId: string ): Promise<TableDefinition> { - // Phase 1 (no lock): load the workflow and resolve the pickable output plus - // its execution-order index. This depends only on the workflow graph (which - // is stable), so it runs OFF the advisory-lock critical section — holding the - // lock during this DB load would make concurrent adders on the same table - // time out waiting (the Mothership fan-out this fix targets). Phase 2 - // re-validates that the group still maps to the same workflow under the lock. + // Phase 1 (no lock): validate the authorized workflow metadata against the + // group's current workflow. Phase 2 re-validates the same binding under the + // table lock before applying the mutation. const preTable = await getTableById(data.tableId) if (!preTable || (data.workspaceId && preTable.workspaceId !== data.workspaceId)) { throw new OrchestrationError('not_found', 'Table not found') @@ -675,38 +643,16 @@ export async function addWorkflowGroupOutput( throw new OrchestrationError('not_found', `Workflow group "${data.groupId}" not found`) } const workflowId = preGroup.workflowId - - const [ - { loadWorkflowFromNormalizedTables }, - { flattenWorkflowOutputs, getBlockExecutionOrder }, - { columnTypeForLeaf, deriveOutputColumnName }, - ] = await Promise.all([ - import('@/lib/workflows/persistence/utils'), - import('@/lib/workflows/blocks/flatten-outputs'), - import('@/lib/table/column-naming'), - ]) - const normalized = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalized) { - throw new OrchestrationError('not_found', `Workflow ${workflowId} not found`) + if (data.resolvedOutput.workflowId !== workflowId) { + throw new OrchestrationError('not_found', 'Workflow not found') } - const blocks = Object.values(normalized.blocks ?? {}).map((b) => ({ - id: b.id, - type: b.type, - name: b.name, - triggerMode: (b as { triggerMode?: boolean }).triggerMode, - subBlocks: b.subBlocks as Record<string, unknown> | undefined, - })) - const flattened = flattenWorkflowOutputs(blocks, normalized.edges ?? []) - const match = flattened.find((f) => f.blockId === data.blockId && f.path === data.path) - if (!match) { - throw new OrchestrationError( - 'validation', - `Output ${data.blockId}::${data.path} is not a valid pickable output on workflow ${workflowId}` - ) - } - const newColumnType = columnTypeForLeaf(match.leafType) - const distances = getBlockExecutionOrder(blocks, normalized.edges ?? []) - const flatIndex = new Map(flattened.map((f, i) => [`${f.blockId}::${f.path}`, i])) + const newColumnType = data.resolvedOutput.columnType + const resolvedOrder = new Map( + data.resolvedOutput.order.map((output) => [ + `${output.blockId}::${output.path}`, + [output.executionDistance, output.discoveryIndex] as const, + ]) + ) // Phase 2 (locked): re-read fresh, validate against the current schema, and // write. The critical section holds no I/O — just the in-memory splice + the @@ -776,10 +722,10 @@ export async function addWorkflowGroupOutput( // — regardless of whether they were added at create time or one-by-one. const groupColIdsBefore = new Set(group.outputs.map((o) => o.columnName)) const orderKey = (o: { blockId: string; path: string }) => { - const d = distances[o.blockId] - const dist = d === undefined || d < 0 ? Number.POSITIVE_INFINITY : d - const idx = flatIndex.get(`${o.blockId}::${o.path}`) ?? Number.POSITIVE_INFINITY - return [dist, idx] as const + return ( + resolvedOrder.get(`${o.blockId}::${o.path}`) ?? + ([Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY] as const) + ) } const allGroupOutputs = [...group.outputs, newOutput].sort((a, b) => { const [da, ia] = orderKey(a) diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts index aa2c88c5d31..842306fa89a 100644 --- a/apps/sim/lib/uploads/upload-session/service.test.ts +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import type { Principal } from '@sim/auth/principal' +import type { Principal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { sha256Hex } from '@sim/security/hash' import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { eq, inArray, isNull } from 'drizzle-orm' @@ -74,6 +74,21 @@ import { const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' const FINAL_KEY = `workspace/${WORKSPACE_ID}/final-file.bin` +const executorPrincipal: WorkflowExecutionDelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: WORKSPACE_ID, + delegationId: 'delegation-1', + audience: 'sim:tables', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, +} describe('upload sessions', () => { beforeEach(() => { @@ -353,6 +368,89 @@ describe('upload sessions', () => { ).rejects.toMatchObject({ code: 'not_found' }) }) + it('binds table-import uploads to the canonical executor workflow execution', async () => { + const row = uploadRow({ + purpose: 'table_import', + storageContext: 'table-import', + finalKey: `table-import/${WORKSPACE_ID}/upload-1/people.csv`, + fileName: 'people.csv', + contentType: 'text/csv', + }) + dbChainMockFns.returning.mockResolvedValueOnce([row]) + + await createUploadSession({ + id: row.id, + workspaceId: WORKSPACE_ID, + userId: 'user-1', + principal: executorPrincipal, + purpose: 'table_import', + fileName: 'people.csv', + contentType: 'text/csv', + fileSize: 4, + localOrigin: 'http://localhost:3000', + }) + + expect(dbChainMockFns.values.mock.calls[0][0].metadata.authBinding).toEqual({ + version: 1, + workspaceId: WORKSPACE_ID, + principal: { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + audience: 'sim:tables', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + }) + + it('accepts refreshed executor tokens only for the same immutable upload binding', () => { + const session = sessionRecord({ + purpose: 'table_import', + storageContext: 'table-import', + metadata: { + authBinding: createUploadSessionAuthBinding(executorPrincipal, WORKSPACE_ID, { + executorDelegationAudience: 'sim:tables', + }), + }, + }) + + expect(() => + assertUploadSessionAuthBinding(session, { + ...executorPrincipal, + delegationId: 'refreshed-token-jti', + }) + ).not.toThrow() + expect(() => + assertUploadSessionAuthBinding(session, { + ...executorPrincipal, + delegationId: 'other-execution-token', + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-2', + }, + }) + ).toThrow('Upload session not found') + expect(() => + assertUploadSessionAuthBinding(session, { + ...executorPrincipal, + workspaceId: 'different-workspace', + }) + ).toThrow('Upload session not found') + }) + + it('does not admit executor delegation outside the explicit Table upload policy', () => { + expect(() => createUploadSessionAuthBinding(executorPrincipal, WORKSPACE_ID)).toThrow( + 'Delegated principal cannot create this upload' + ) + expect(() => + createUploadSessionAuthBinding(executorPrincipal, WORKSPACE_ID, { + executorDelegationAudience: 'sim:workspace-files', + }) + ).toThrow('Delegated principal cannot create this upload') + }) + it('fails closed for legacy table-import sessions without a binding', () => { const legacyImport = sessionRecord({ purpose: 'table_import', diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index a24b99e6a4f..237c671e3cf 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -1,4 +1,4 @@ -import type { Principal } from '@sim/auth/principal' +import type { Principal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { db, dbFor } from '@sim/db' import { uploadSession } from '@sim/db/schema' import { safeCompare } from '@sim/security/compare' @@ -106,6 +106,14 @@ export interface UploadSessionAuthBinding { | { kind: 'session'; userId: string; sessionId: string } | { kind: 'personal_api_key'; userId: string; keyId: string } | { kind: 'workspace_api_key'; workspaceId: string; keyId: string } + | { + kind: 'delegated' + serviceId: 'executor' + subjectUserId: string + audience: string + workflowId: string + executionId?: string + } } export interface CreatedUploadSession extends UploadSessionRecord { @@ -122,6 +130,30 @@ export class UploadSessionError extends OrchestrationError { } } +function isExecutorWorkflowExecutionPrincipal( + principal: Principal +): principal is WorkflowExecutionDelegatedPrincipal { + if ( + principal.kind !== 'delegated' || + principal.serviceId !== 'executor' || + !('delegationContext' in principal) + ) { + return false + } + const context = principal.delegationContext + return ( + typeof context === 'object' && + context !== null && + 'kind' in context && + context.kind === 'workflow_execution' && + 'workflowId' in context && + typeof context.workflowId === 'string' && + (!('executionId' in context) || + context.executionId === undefined || + typeof context.executionId === 'string') + ) +} + interface CreateUploadSessionBaseParams { id?: string userId: string @@ -170,7 +202,9 @@ export async function createUploadSession( metadata.authBinding = createUploadSessionAuthBinding(params.principal, workspaceId) } else if (params.purpose === 'table_import' && params.principal) { if (!workspaceId) throw new Error('table_import upload is missing workspaceId') - metadata.authBinding = createUploadSessionAuthBinding(params.principal, workspaceId) + metadata.authBinding = createUploadSessionAuthBinding(params.principal, workspaceId, { + executorDelegationAudience: 'sim:tables', + }) } const { storageContext, finalKey } = resolveUploadStorage(params, id) const method: UploadTransferMethod = @@ -369,7 +403,8 @@ export async function getPrincipalKnowledgeDocumentUploadSession(params: { export function createUploadSessionAuthBinding( principal: Principal, - workspaceId: string + workspaceId: string, + options: { executorDelegationAudience?: string } = {} ): UploadSessionAuthBinding { switch (principal.kind) { case 'session': @@ -397,8 +432,30 @@ export function createUploadSessionAuthBinding( workspaceId, principal: { kind: principal.kind, workspaceId, keyId: principal.keyId }, } - case 'delegated': - throw new UploadSessionError('forbidden', 'Delegated principals cannot create uploads') + case 'delegated': { + if ( + options.executorDelegationAudience === undefined || + !isExecutorWorkflowExecutionPrincipal(principal) || + principal.audience !== options.executorDelegationAudience || + principal.workspaceId !== workspaceId + ) { + throw new UploadSessionError('forbidden', 'Delegated principal cannot create this upload') + } + return { + version: 1, + workspaceId, + principal: { + kind: principal.kind, + serviceId: principal.serviceId, + subjectUserId: principal.subjectUserId, + audience: principal.audience, + workflowId: principal.delegationContext.workflowId, + ...(principal.delegationContext.executionId + ? { executionId: principal.delegationContext.executionId } + : {}), + }, + } + } } } @@ -427,9 +484,16 @@ export function assertUploadSessionAuthBinding( ? principal.kind === 'personal_api_key' && bound.userId === principal.userId && bound.keyId === principal.keyId - : principal.kind === 'workspace_api_key' && - bound.workspaceId === principal.workspaceId && - bound.keyId === principal.keyId) + : bound.kind === 'workspace_api_key' + ? principal.kind === 'workspace_api_key' && + bound.workspaceId === principal.workspaceId && + bound.keyId === principal.keyId + : isExecutorWorkflowExecutionPrincipal(principal) && + principal.workspaceId === session.workspaceId && + principal.subjectUserId === bound.subjectUserId && + principal.audience === bound.audience && + principal.delegationContext.workflowId === bound.workflowId && + principal.delegationContext.executionId === bound.executionId) if (!matches) throw uploadNotFound() } @@ -1190,6 +1254,15 @@ function isUploadSessionAuthBinding(value: unknown): value is UploadSessionAuthB if (principal.kind === 'personal_api_key') { return typeof principal.userId === 'string' && typeof principal.keyId === 'string' } + if (principal.kind === 'delegated') { + return ( + principal.serviceId === 'executor' && + typeof principal.subjectUserId === 'string' && + typeof principal.audience === 'string' && + typeof principal.workflowId === 'string' && + (principal.executionId === undefined || typeof principal.executionId === 'string') + ) + } return ( principal.kind === 'workspace_api_key' && typeof principal.workspaceId === 'string' && diff --git a/apps/sim/lib/workflows/application/resolve-workflow-outputs.test.ts b/apps/sim/lib/workflows/application/resolve-workflow-outputs.test.ts new file mode 100644 index 00000000000..6ccf06f983a --- /dev/null +++ b/apps/sim/lib/workflows/application/resolve-workflow-outputs.test.ts @@ -0,0 +1,122 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const mocks = vi.hoisted(() => ({ + flatten: vi.fn(), + load: vi.fn(), + order: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveContext, +})) + +vi.mock('@/lib/workflows/blocks/flatten-outputs', () => ({ + flattenWorkflowOutputs: mocks.flatten, + getBlockExecutionOrder: mocks.order, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadWorkflowFromNormalizedTables: mocks.load, +})) + +import { resolveWorkflowOutputs } from '@/lib/workflows/application/resolve-workflow-outputs' + +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), +} + +describe('resolveWorkflowOutputs', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveContext.mockResolvedValue({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + workflow: { id: 'workflow-1' }, + }) + mocks.load.mockResolvedValue({ + blocks: { block1: { id: 'block-1', type: 'agent', name: 'Agent', subBlocks: {} } }, + edges: [], + }) + mocks.flatten.mockReturnValue([ + { + blockId: 'block-1', + blockName: 'Agent', + blockType: 'agent', + path: 'content', + leafType: 'string', + }, + ]) + mocks.order.mockReturnValue({ 'block-1': 1 }) + }) + + it('binds the canonical workflow load to the trusted Copilot workspace', async () => { + await expect( + resolveWorkflowOutputs.execute({ + principal, + input: { workflowId: 'workflow-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toMatchObject({ + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-1', path: 'content' }], + executionOrderByBlockId: { 'block-1': 1 }, + }) + + expect(mocks.resolveContext).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + }) + expect(mocks.load).toHaveBeenCalledWith('workflow-1') + }) + + it('conceals cross-workspace workflow ids before loading workflow state', async () => { + mocks.resolveContext.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Workflow not found') + ) + + await expect( + resolveWorkflowOutputs.execute({ + principal, + input: { workflowId: 'workflow-other', assertedWorkspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Workflow not found' }) + expect(mocks.load).not.toHaveBeenCalled() + }) + + it('rejects expired delegated scope before loading workflow state', async () => { + await expect( + resolveWorkflowOutputs.execute({ + principal: { ...principal, expiresAt: new Date(0) }, + input: { workflowId: 'workflow-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.load).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/resolve-workflow-outputs.ts b/apps/sim/lib/workflows/application/resolve-workflow-outputs.ts new file mode 100644 index 00000000000..209d26107f6 --- /dev/null +++ b/apps/sim/lib/workflows/application/resolve-workflow-outputs.ts @@ -0,0 +1,57 @@ +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { + type ActiveWorkflowApplicationContext, + resolveActiveWorkflowApplicationContext, +} from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { + type FlattenedBlockOutput, + flattenWorkflowOutputs, + getBlockExecutionOrder, +} from '@/lib/workflows/blocks/flatten-outputs' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' + +export interface ResolveWorkflowOutputsInput { + workflowId: string + assertedWorkspaceId: string +} + +export interface ResolveWorkflowOutputsResult { + workflowId: string + outputs: FlattenedBlockOutput[] | null + executionOrderByBlockId: Record<string, number> +} + +/** Loads output metadata after a top-level application command has authorized this workflow context. */ +export async function loadResolvedWorkflowOutputs( + context: ActiveWorkflowApplicationContext +): Promise<ResolveWorkflowOutputsResult> { + const normalized = await loadWorkflowFromNormalizedTables(context.workflowId) + if (!normalized) { + return { workflowId: context.workflowId, outputs: null, executionOrderByBlockId: {} } + } + const blocks = Object.values(normalized.blocks ?? {}).map((block) => ({ + id: block.id, + type: block.type, + name: block.name, + triggerMode: (block as { triggerMode?: boolean }).triggerMode, + subBlocks: block.subBlocks as Record<string, unknown> | undefined, + })) + return { + workflowId: context.workflowId, + outputs: flattenWorkflowOutputs(blocks, normalized.edges ?? []), + executionOrderByBlockId: getBlockExecutionOrder(blocks, normalized.edges ?? []), + } +} + +export const resolveWorkflowOutputs = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.read, + resolveContext: ({ input }: { input: ResolveWorkflowOutputsInput }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: input.assertedWorkspaceId, + }), + async execute({ context }): Promise<ResolveWorkflowOutputsResult> { + return loadResolvedWorkflowOutputs(context) + }, +}) From bc542748d05d29d2a0e068319548a2cf540a5eae Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sun, 9 Aug 2026 23:23:54 -0700 Subject: [PATCH 120/159] fix(workflows): migrate Copilot application boundary (#6455) * fix(workflows): migrate Copilot application boundary * fix(workflows): finish delegated application migration * fix(workflows): encode VFS folder aliases * fix(workflows): close application composition gaps * fix(workflows): preserve VFS validation errors * fix(workflows): complete application boundary migration * test(workflows): format canonical binding coverage * fix(workflows): scope executor metadata reads * fix(workflows): bind executor metadata targets --- apps/sim/app/api/chat/manage/[id]/route.ts | 7 +- .../v2/workflows/[id]/deploy/route.test.ts | 20 +- .../app/api/v2/workflows/[id]/deploy/route.ts | 14 - .../v2/workflows/[id]/execute/route.test.ts | 59 +- .../api/v2/workflows/[id]/execute/route.ts | 19 +- .../v2/workflows/[id]/rollback/route.test.ts | 20 +- .../api/v2/workflows/[id]/rollback/route.ts | 16 - .../app/api/workflows/[id]/deploy/route.ts | 379 ++---- .../api/workflows/[id]/deployed/route.test.ts | 271 ++-- .../app/api/workflows/[id]/deployed/route.ts | 138 +- .../deployments/[version]/revert/route.ts | 103 +- .../[id]/deployments/[version]/route.test.ts | 125 ++ .../[id]/deployments/[version]/route.ts | 157 +-- .../api/workflows/[id]/deployments/route.ts | 72 +- .../[id]/execute/route.async.test.ts | 2 +- .../app/api/workflows/[id]/execute/route.ts | 2 +- apps/sim/app/api/workflows/[id]/route.test.ts | 1036 ++------------- apps/sim/app/api/workflows/[id]/route.ts | 452 ++----- .../app/api/workflows/[id]/status/route.ts | 65 +- apps/sim/app/api/workflows/utils.ts | 46 - .../workflow/workflow-handler.test.ts | 28 +- .../handlers/workflow/workflow-handler.ts | 31 +- .../lib/api-key/application/create-api-key.ts | 55 + .../sim/lib/api-key/application/operations.ts | 13 + apps/sim/lib/api-key/orchestration/index.ts | 49 +- apps/sim/lib/api/server/routes/index.ts | 1 + .../lib/api/server/routes/v2-json-route.ts | 54 +- .../application/execute-api-key-use-case.ts | 13 + .../execute-workflow-use-case.test.ts | 87 +- .../application/execute-workflow-use-case.ts | 27 + .../lib/copilot/generated/tool-catalog-v1.ts | 14 +- .../lib/copilot/generated/tool-schemas-v1.ts | 10 + .../request/tools/workflow-context.test.ts | 6 +- .../copilot/request/tools/workflow-context.ts | 159 +-- .../handlers/deployment/custom-block.test.ts | 8 +- .../tools/handlers/deployment/custom-block.ts | 7 +- .../tools/handlers/deployment/deploy.test.ts | 90 +- .../tools/handlers/deployment/deploy.ts | 498 ++----- .../tools/handlers/deployment/manage.test.ts | 144 +- .../tools/handlers/deployment/manage.ts | 397 ++---- .../tools/handlers/deployment/state-refs.ts | 82 +- .../copilot/tools/handlers/vfs-mutate.test.ts | 568 +++++--- .../lib/copilot/tools/handlers/vfs-mutate.ts | 1079 ++++----------- .../tools/handlers/workflow/mutations.test.ts | 1168 ++--------------- .../tools/handlers/workflow/mutations.ts | 915 ++----------- .../tools/handlers/workflow/queries.test.ts | 108 +- .../tools/handlers/workflow/queries.ts | 395 ++---- apps/sim/lib/copilot/vfs/path-utils.ts | 53 +- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 2 +- .../knowledge/application/knowledge-vfs.ts | 105 ++ .../lib/knowledge/application/operations.ts | 16 + apps/sim/lib/mcp/application/operations.ts | 48 + apps/sim/lib/mcp/application/use-cases.ts | 23 +- .../application/workflow-deployments.test.ts | 181 +++ .../mcp/application/workflow-deployments.ts | 431 ++++++ .../orchestration/workflow-mcp-lifecycle.ts | 226 ++-- apps/sim/lib/posthog/server.ts | 47 +- apps/sim/lib/realtime/notify.ts | 60 + apps/sim/lib/table/application/operations.ts | 16 + apps/sim/lib/table/application/table-vfs.ts | 91 ++ apps/sim/lib/table/service.ts | 23 +- apps/sim/lib/vfs/limits.ts | 61 + apps/sim/lib/vfs/path.ts | 57 + apps/sim/lib/workflows/api/index.ts | 7 +- .../lib/workflows/api/route-policies.test.ts | 64 +- apps/sim/lib/workflows/api/route-policies.ts | 59 + .../workflows/application/authorization.ts | 15 +- .../workflows/application/chat-deployments.ts | 258 ++++ .../lib/workflows/application/context.test.ts | 61 +- .../workflows/application/create-workflow.ts | 38 +- .../workflows/application/delete-workflow.ts | 5 + .../lib/workflows/application/deployments.ts | 144 +- .../application/duplicate-workflow.ts | 51 + .../application/list-workflow-versions.ts | 33 +- .../application/move-workflows-bulk.test.ts | 154 +++ .../application/move-workflows-bulk.ts | 166 +++ .../lib/workflows/application/operations.ts | 151 ++- .../workflows/application/principal-scope.ts | 8 +- .../read-workflow-copilot-metadata.test.ts | 160 +++ .../read-workflow-copilot-metadata.ts | 294 +++++ .../application/read-workflow-definition.ts | 56 + .../read-workflow-deployment-overview.test.ts | 190 +++ .../read-workflow-deployment-overview.ts | 130 ++ .../read-workflow-state-references.ts | 72 + .../application/read-workflow-version.ts | 13 +- .../run-workflow-from-copilot.test.ts | 243 ++++ .../application/run-workflow-from-copilot.ts | 358 +++++ .../update-workflow-content.test.ts | 141 ++ .../application/update-workflow-content.ts | 395 ++++++ .../update-workflow-deployment-settings.ts | 77 ++ .../workflows/application/update-workflow.ts | 288 +++- .../application/workflow-crud.test.ts | 132 +- .../application/workflow-deployments.test.ts | 105 +- .../application/workflow-vfs.test.ts | 279 ++++ .../lib/workflows/application/workflow-vfs.ts | 851 ++++++++++++ .../lib/workflows/deployment-outbox.test.ts | 53 +- apps/sim/lib/workflows/deployment-outbox.ts | 5 +- apps/sim/lib/workflows/deployment-status.ts | 35 + apps/sim/lib/workflows/execution-admission.ts | 132 ++ .../orchestration/chat-deploy.test.ts | 2 +- .../workflows/orchestration/chat-deploy.ts | 112 +- .../workflows/orchestration/deploy.test.ts | 98 ++ .../sim/lib/workflows/orchestration/deploy.ts | 132 +- .../orchestration/workflow-lifecycle.ts | 40 +- apps/sim/lib/workflows/persistence/utils.ts | 19 +- .../workspace-files/application/operations.ts | 21 + .../application/workspace-file-vfs.ts | 486 +++++++ apps/sim/providers/utils.test.ts | 141 ++ apps/sim/providers/utils.ts | 23 +- packages/audit/src/types.ts | 1 + 110 files changed, 10070 insertions(+), 6677 deletions(-) create mode 100644 apps/sim/app/api/workflows/[id]/deployments/[version]/route.test.ts create mode 100644 apps/sim/lib/api-key/application/create-api-key.ts create mode 100644 apps/sim/lib/api-key/application/operations.ts create mode 100644 apps/sim/lib/copilot/application/execute-api-key-use-case.ts create mode 100644 apps/sim/lib/knowledge/application/knowledge-vfs.ts create mode 100644 apps/sim/lib/mcp/application/workflow-deployments.test.ts create mode 100644 apps/sim/lib/mcp/application/workflow-deployments.ts create mode 100644 apps/sim/lib/table/application/table-vfs.ts create mode 100644 apps/sim/lib/vfs/limits.ts create mode 100644 apps/sim/lib/vfs/path.ts create mode 100644 apps/sim/lib/workflows/application/chat-deployments.ts create mode 100644 apps/sim/lib/workflows/application/duplicate-workflow.ts create mode 100644 apps/sim/lib/workflows/application/move-workflows-bulk.test.ts create mode 100644 apps/sim/lib/workflows/application/move-workflows-bulk.ts create mode 100644 apps/sim/lib/workflows/application/read-workflow-copilot-metadata.test.ts create mode 100644 apps/sim/lib/workflows/application/read-workflow-copilot-metadata.ts create mode 100644 apps/sim/lib/workflows/application/read-workflow-definition.ts create mode 100644 apps/sim/lib/workflows/application/read-workflow-deployment-overview.test.ts create mode 100644 apps/sim/lib/workflows/application/read-workflow-deployment-overview.ts create mode 100644 apps/sim/lib/workflows/application/read-workflow-state-references.ts create mode 100644 apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts create mode 100644 apps/sim/lib/workflows/application/run-workflow-from-copilot.ts create mode 100644 apps/sim/lib/workflows/application/update-workflow-content.test.ts create mode 100644 apps/sim/lib/workflows/application/update-workflow-content.ts create mode 100644 apps/sim/lib/workflows/application/update-workflow-deployment-settings.ts create mode 100644 apps/sim/lib/workflows/application/workflow-vfs.test.ts create mode 100644 apps/sim/lib/workflows/application/workflow-vfs.ts create mode 100644 apps/sim/lib/workflows/deployment-status.ts create mode 100644 apps/sim/lib/workflows/execution-admission.ts create mode 100644 apps/sim/lib/workspace-files/application/workspace-file-vfs.ts diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index af90c4fedeb..8088df80d29 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -12,17 +12,14 @@ import { isDev } from '@/lib/core/config/env-flags' import { encryptSecret } from '@/lib/core/security/encryption' import { getEmailDomain } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' import { getWorkflowDeploymentSummary, performChatUndeploy, performFullDeploy, } from '@/lib/workflows/orchestration' import { checkChatAccess } from '@/app/api/chat/utils' -import { - checkNeedsRedeployment, - createErrorResponse, - createSuccessResponse, -} from '@/app/api/workflows/utils' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' import { ChatDeployAuthNotAllowedError, validateChatDeployAuth, diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts index 049527d5fda..a00bc304b68 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts @@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/lib/api/server/routes', () => ({ + createInternalSessionOrExecutorAuth: vi.fn(() => ({ kind: 'internal-workflow' })), + createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-workflow' })), defineV2JsonRoute: mocks.defineRoute, v2ApiKeyAuth: { kind: 'v2-api-key' }, v2RateLimits: { publicApi: { kind: 'public-api' } }, @@ -78,21 +80,9 @@ describe('/api/v2/workflows/[id]/deploy route definitions', () => { expect(v2DeployWorkflowContract.response.schema.parse(body)).toEqual(body) }) - it('keeps product analytics on the v2 adapter', async () => { - const result = { workflowId: 'workflow-1', workspaceId: 'workspace-1' } - await Reflect.get( - POST, - 'onSuccess' - )({ - principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, - result, - }) - expect(mocks.capture).toHaveBeenCalledWith( - 'user-1', - 'workflow_deployed', - { workflow_id: 'workflow-1', workspace_id: 'workspace-1' }, - expect.objectContaining({ groups: { workspace: 'workspace-1' } }) - ) + it('defers deploy analytics to durable activation', () => { + expect(Reflect.get(POST, 'onSuccess')).toBeUndefined() + expect(mocks.capture).not.toHaveBeenCalled() }) it('keeps undeploy on the authorized operation and declared response schema', () => { diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts index 4827ea5e8e0..d4a3051c075 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -43,20 +43,6 @@ export const POST = defineV2JsonRoute({ latestDeploymentAttempt: result.latestDeploymentAttempt ?? null, }, }), - onSuccess: ({ principal, result }) => { - if (principal.kind !== 'personal_api_key') { - throw new Error('Admin deployment unexpectedly admitted a workspace API key') - } - captureServerEvent( - principal.userId, - 'workflow_deployed', - { workflow_id: result.workflowId, workspace_id: result.workspaceId }, - { - groups: { workspace: result.workspaceId }, - setOnce: { first_workflow_deployed_at: new Date().toISOString() }, - } - ) - }, }) export const DELETE = defineV2JsonRoute({ diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts index f5de09391d9..5cce4b1bad5 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts @@ -236,7 +236,22 @@ describe('POST /api/v2/workflows/[id]/execute', () => { rateLimitSubscription: null, keyType: 'workspace', }) - dbChainMockFns.limit.mockResolvedValue([applicationContext]) + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + workflowId: workflowRecord.id, + workflow: workflowRecord, + workspaceId: workflowRecord.workspaceId, + }, + ]) + .mockResolvedValueOnce([ + { + id: applicationContext.workspaceId, + organizationId: applicationContext.workspaceOrganizationId, + allowPersonalApiKeys: applicationContext.allowPersonalApiKeys, + billedAccountUserId: applicationContext.billedAccountUserId, + }, + ]) mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord }) mockClaimExecutionId.mockImplementation(async (executionId: string) => ({ key: `workflow-execution-id:${executionId}`, @@ -420,9 +435,23 @@ describe('POST /api/v2/workflows/[id]/execute', () => { rateLimitSubscription: null, keyType: 'personal', }) - dbChainMockFns.limit.mockResolvedValueOnce([ - { ...applicationContext, allowPersonalApiKeys: false }, - ]) + dbChainMockFns.limit.mockReset() + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + workflowId: workflowRecord.id, + workflow: workflowRecord, + workspaceId: workflowRecord.workspaceId, + }, + ]) + .mockResolvedValueOnce([ + { + id: applicationContext.workspaceId, + organizationId: applicationContext.workspaceOrganizationId, + allowPersonalApiKeys: false, + billedAccountUserId: applicationContext.billedAccountUserId, + }, + ]) const res = await callExecute({ input: {} }) @@ -487,12 +516,16 @@ describe('POST /api/v2/workflows/[id]/execute', () => { }) it('runs the anonymous public path sync but refuses async', async () => { + dbChainMockFns.limit.mockReset() dbChainMockFns.limit.mockResolvedValueOnce([ { isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, ]) const okRes = await callPublicExecute({ input: {} }) expect(okRes.status).toBe(200) + expect(mockCheckPreAuthRate.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.select.mock.invocationCallOrder[0] + ) expect(mockAuthenticateV2ApiKey).not.toHaveBeenCalled() expect(mockCheckOperationRate).not.toHaveBeenCalled() expect(mockPreprocessExecution).toHaveBeenCalledWith( @@ -506,7 +539,24 @@ describe('POST /api/v2/workflows/[id]/execute', () => { expect(asyncRes.status).toBe(400) }) + it('rejects anonymous abuse before looking up the workflow', async () => { + mockCheckPreAuthRate.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date('2026-08-08T05:00:00Z'), + retryAfterMs: 10_000, + }) + + const response = await callPublicExecute({ input: {} }) + + expect(response.status).toBe(429) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(mockValidatePublicApiAllowed).not.toHaveBeenCalled() + expect(mockAuthenticateV2ApiKey).not.toHaveBeenCalled() + }) + it('401s non-public workflows without a key', async () => { + dbChainMockFns.limit.mockReset() dbChainMockFns.limit.mockResolvedValueOnce([ { isPublicApi: false, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, ]) @@ -532,6 +582,7 @@ describe('POST /api/v2/workflows/[id]/execute', () => { }) it('returns a safe error when canonical workflow lookup fails', async () => { + dbChainMockFns.limit.mockReset() dbChainMockFns.limit.mockRejectedValueOnce(new Error('database connection details')) const response = await callExecute({ input: {} }) 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 5c7a78cad99..6b7b120aaab 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -11,7 +11,7 @@ import { } from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' import { - admitV2Request, + admitOptionalV2Request, V2RouteInfrastructureError, v2ApiKeyAuth, v2RateLimits, @@ -112,14 +112,15 @@ export const POST = withRouteHandler( let isPublicApiAccess = false let apiKeyPrincipal: V2ApiKeyPrincipal | undefined - if (req.headers.has('x-api-key')) { - const admission = await admitV2Request( - req, - workflowOperations.execute, - v2ApiKeyAuth, - v2RateLimits.publicApi - ) - if (!admission.success) return admission.response + const admission = await admitOptionalV2Request( + req, + workflowOperations.execute, + v2ApiKeyAuth, + v2RateLimits.publicApi + ) + if (!admission.success) return admission.response + + if (admission.auth) { apiKeyPrincipal = admission.auth.principal userId = admission.auth.rolloutUserId } else { diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts index 34d98dd6f1e..41996501de0 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts @@ -5,16 +5,16 @@ import { describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition), - capture: vi.fn(), })) vi.mock('@/lib/api/server/routes', () => ({ + createInternalSessionOrExecutorAuth: vi.fn(() => ({ kind: 'internal-workflow' })), + createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-workflow' })), defineV2JsonRoute: mocks.defineRoute, v2ApiKeyAuth: { kind: 'v2-api-key' }, v2RateLimits: { publicApi: { kind: 'public-api' } }, v2OrchestrationErrorPolicy: { kind: 'orchestration-errors' }, })) -vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows' import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' @@ -75,19 +75,7 @@ describe('/api/v2/workflows/[id]/rollback route definition', () => { expect(v2RollbackWorkflowContract.response.schema.parse(body)).toEqual(body) }) - it('keeps activation analytics on the v2 adapter', async () => { - await Reflect.get( - POST, - 'onSuccess' - )({ - principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, - result: { workflowId: 'workflow-1', workspaceId: 'workspace-1', version: 1 }, - }) - expect(mocks.capture).toHaveBeenCalledWith( - 'user-1', - 'deployment_version_activated', - { workflow_id: 'workflow-1', workspace_id: 'workspace-1', version: 1 }, - { groups: { workspace: 'workspace-1' } } - ) + it('defers activation analytics to durable activation', () => { + expect(Reflect.get(POST, 'onSuccess')).toBeUndefined() }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts index 42c1ef2e58e..ae0e1a3519f 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -1,7 +1,6 @@ import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { generateRequestId } from '@/lib/core/utils/request' -import { captureServerEvent } from '@/lib/posthog/server' import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { activateWorkflowVersion } from '@/lib/workflows/application/deployments' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -40,19 +39,4 @@ export const POST = defineV2JsonRoute({ latestDeploymentAttempt: result.latestDeploymentAttempt ?? null, }, }), - onSuccess: ({ principal, result }) => { - if (principal.kind !== 'personal_api_key') { - throw new Error('Admin activation unexpectedly admitted a workspace API key') - } - captureServerEvent( - principal.userId, - 'deployment_version_activated', - { - workflow_id: result.workflowId, - workspace_id: result.workspaceId, - version: result.version, - }, - { groups: { workspace: result.workspaceId } } - ) - }, }) diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 30960639858..35da4ac6eef 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -1,275 +1,132 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db, workflow } from '@sim/db' -import { createLogger } from '@sim/logger' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' -import { updatePublicApiContract } from '@/lib/api/contracts/deployments' -import { parseRequest } from '@/lib/api/server' -import { InternalUnauthenticatedError, internalSessionAuth } from '@/lib/api/server/routes' -import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { + deployWorkflowContract, + getDeploymentInfoContract, + undeployWorkflowContract, + updatePublicApiContract, +} from '@/lib/api/contracts/deployments' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/application/deployments' -import { getWorkflowDeploymentSummary } from '@/lib/workflows/orchestration' -import { validateWorkflowPermissions } from '@/lib/workflows/utils' -import { - checkNeedsRedeployment, - createErrorResponse, - createSuccessResponse, -} from '@/app/api/workflows/utils' +import { createInternalWorkflowErrorPolicy } from '@/lib/workflows/api' import { - PublicApiNotAllowedError, - validatePublicApiAllowed, -} from '@/ee/access-control/utils/permission-check' - -const logger = createLogger('WorkflowDeployAPI') + deployWorkflow, + readWorkflowDeploymentStatus, + undeployWorkflow, +} from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { updateWorkflowPublicApi } from '@/lib/workflows/application/update-workflow-deployment-settings' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 120 -export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const { id } = await params - - try { - const { error, workflow: workflowData } = await validateWorkflowPermissions( - id, - requestId, - 'read' - ) - if (error) { - return createErrorResponse(error.message, error.status) - } - - /** - * A workflow is deployed only when an active version snapshot exists — - * the same definition POST and the v1 routes use. The legacy - * `workflow.isDeployed` flag is deliberately not consulted: when it - * disagrees with the version table the workflow cannot actually serve - * traffic, so reporting it as live would be untruthful. - */ - const deploymentSummary = await getWorkflowDeploymentSummary(id) - const isDeployed = deploymentSummary.activeDeployment !== null - - if (!isDeployed) { - logger.info(`[${requestId}] Workflow is not deployed: ${id}`) - return createSuccessResponse({ - isDeployed: false, - deployedAt: null, - apiKey: null, - needsRedeployment: false, - isPublicApi: workflowData.isPublicApi ?? false, - activeDeployment: deploymentSummary.activeDeployment, - latestDeploymentAttempt: deploymentSummary.latestDeploymentAttempt, - warnings: deploymentSummary.warnings, - }) - } - - const attemptStatus = deploymentSummary.latestDeploymentAttempt?.status - const needsRedeployment = - attemptStatus === 'preparing' || attemptStatus === 'activating' - ? false - : await checkNeedsRedeployment(id) - - logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`) - - const responseApiKeyInfo = workflowData.workspaceId - ? 'Workspace API keys' - : 'Personal API keys' - - return createSuccessResponse({ - apiKey: responseApiKeyInfo, - isDeployed, - deployedAt: deploymentSummary.activeDeployment?.deployedAt ?? workflowData.deployedAt, - needsRedeployment, - isPublicApi: workflowData.isPublicApi ?? false, - activeDeployment: deploymentSummary.activeDeployment, - latestDeploymentAttempt: deploymentSummary.latestDeploymentAttempt, - warnings: deploymentSummary.warnings, - }) - } catch (error: unknown) { - logger.error(`[${requestId}] Error fetching deployment info: ${id}`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return createErrorResponse('Failed to fetch deployment information', 500) - } - } -) - -export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const { id } = await params - - try { - const principal = await internalSessionAuth.authenticate() - const result = await deployWorkflow.execute({ - principal, - input: { workflowId: id, requestId }, - request, - }) - - const isDeployed = Boolean(result.activeDeployment) - const attemptActivated = result.latestDeploymentAttempt?.status === 'active' - logger.info( - `[${requestId}] Workflow deployment ${attemptActivated ? 'activated' : 'accepted for preparation'}: ${id}` - ) - - captureServerEvent( - principal.userId, - 'workflow_deployed', - { workflow_id: result.workflowId, workspace_id: result.workspaceId }, - { - groups: { workspace: result.workspaceId }, - setOnce: { first_workflow_deployed_at: new Date().toISOString() }, - } - ) - - return createSuccessResponse({ - apiKey: 'Workspace API keys', - isDeployed, - deployedAt: result.deployedAt, - warnings: result.warnings, - activeDeployment: result.activeDeployment, - latestDeploymentAttempt: result.latestDeploymentAttempt, - }) - } catch (error: unknown) { - if (error instanceof InternalUnauthenticatedError) { - return createErrorResponse(error.message, 401) - } - const orchestrationError = asOrchestrationError(error) - if (orchestrationError) { - return createErrorResponse( - orchestrationError.message, - statusForOrchestrationError(orchestrationError.code) - ) - } - logger.error(`[${requestId}] Error deploying workflow: ${id}`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return createErrorResponse('Failed to deploy workflow', 500) - } - } -) - -export const PATCH = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - - try { - const parsed = await parseRequest(updatePublicApiContract, request, context, { - validationErrorResponse: () => - createErrorResponse('Invalid request body: isPublicApi must be a boolean', 400), - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { isPublicApi } = parsed.data.body - - const { - error, - session, - workflow: workflowData, - } = await validateWorkflowPermissions(id, requestId, 'admin') - if (error) { - return createErrorResponse(error.message, error.status) - } - await assertWorkflowMutable(id) - - if (isPublicApi) { - try { - await validatePublicApiAllowed(session?.user?.id, workflowData?.workspaceId ?? undefined) - } catch (err) { - if (err instanceof PublicApiNotAllowedError) { - return createErrorResponse('Public API access is disabled', 403) - } - throw err - } - } - - await db.update(workflow).set({ isPublicApi }).where(eq(workflow.id, id)) - - logger.info(`[${requestId}] Updated isPublicApi for workflow ${id} to ${isPublicApi}`) - - const wsId = workflowData?.workspaceId - - recordAudit({ - workspaceId: wsId ?? null, - actorId: session!.user.id, - action: AuditAction.WORKFLOW_PUBLIC_API_TOGGLED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: id, - resourceName: workflowData?.name ?? undefined, - description: `${isPublicApi ? 'Enabled' : 'Disabled'} public API for workflow "${workflowData?.name ?? id}"`, - metadata: { isPublicApi }, - request, - }) - - captureServerEvent( - session!.user.id, - 'workflow_public_api_toggled', - { workflow_id: id, workspace_id: wsId ?? '', is_public: isPublicApi }, - wsId ? { groups: { workspace: wsId } } : undefined - ) - - return createSuccessResponse({ isPublicApi }) - } catch (error: unknown) { - if (error instanceof WorkflowLockedError) { - return createErrorResponse(error.message, error.status) - } - logger.error(`[${requestId}] Error updating deployment settings`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return createErrorResponse('Failed to update deployment settings', 500) - } - } -) - -export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const { id } = await params - - try { - const principal = await internalSessionAuth.authenticate() - const result = await undeployWorkflow.execute({ - principal, - input: { workflowId: id, requestId }, - request, - }) - captureServerEvent( - principal.userId, - 'workflow_undeployed', - { workflow_id: result.workflowId, workspace_id: result.workspaceId }, - { groups: { workspace: result.workspaceId } } - ) - - return createSuccessResponse({ +const NO_INTERNAL_RATE_LIMIT = internalRateLimits.none({ + reason: + 'Authenticated workspace UI deployment operations retain their existing admission policy.', +}) + +export const GET = defineInternalJsonRoute({ + contract: getDeploymentInfoContract, + operation: workflowOperations.read, + useCase: readWorkflowDeploymentStatus, + auth: internalSessionAuth, + rateLimit: NO_INTERNAL_RATE_LIMIT, + errorPolicy: createInternalWorkflowErrorPolicy('Failed to fetch deployment information'), + mapInput: ({ params }) => ({ workflowId: params.id }), + present: (result) => { + if (!result.isDeployed) { + return { isDeployed: false, deployedAt: null, apiKey: null, + needsRedeployment: false, + isPublicApi: result.workflow.isPublicApi ?? false, + activeDeployment: result.activeDeployment, + latestDeploymentAttempt: result.latestDeploymentAttempt, warnings: result.warnings, - }) - } catch (error: unknown) { - if (error instanceof InternalUnauthenticatedError) { - return createErrorResponse(error.message, 401) } - const orchestrationError = asOrchestrationError(error) - if (orchestrationError) { - return createErrorResponse( - orchestrationError.message, - statusForOrchestrationError(orchestrationError.code) - ) - } - logger.error(`[${requestId}] Error undeploying workflow: ${id}`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return createErrorResponse('Failed to undeploy workflow', 500) } - } -) + return { + apiKey: result.workflow.workspaceId ? 'Workspace API keys' : 'Personal API keys', + isDeployed: true, + deployedAt: result.activeDeployment?.deployedAt ?? result.workflow.deployedAt?.toISOString(), + needsRedeployment: result.needsRedeployment, + isPublicApi: result.workflow.isPublicApi ?? false, + activeDeployment: result.activeDeployment, + latestDeploymentAttempt: result.latestDeploymentAttempt, + warnings: result.warnings, + } + }, +}) + +export const POST = defineInternalJsonRoute({ + contract: deployWorkflowContract, + operation: workflowOperations.deploy, + useCase: deployWorkflow, + auth: internalSessionAuth, + rateLimit: NO_INTERNAL_RATE_LIMIT, + errorPolicy: createInternalWorkflowErrorPolicy('Failed to deploy workflow'), + mapInput: ({ params }) => ({ workflowId: params.id, requestId: generateRequestId() }), + present: (result) => ({ + apiKey: 'Workspace API keys', + isDeployed: Boolean(result.activeDeployment), + deployedAt: result.deployedAt?.toISOString(), + warnings: result.warnings, + activeDeployment: result.activeDeployment, + latestDeploymentAttempt: result.latestDeploymentAttempt, + }), +}) + +export const PATCH = defineInternalJsonRoute({ + contract: updatePublicApiContract, + operation: workflowOperations.updatePublicApi, + useCase: updateWorkflowPublicApi, + auth: internalSessionAuth, + rateLimit: NO_INTERNAL_RATE_LIMIT, + errorPolicy: createInternalWorkflowErrorPolicy('Failed to update deployment settings'), + mapInput: ({ params, body }) => ({ + workflowId: params.id, + isPublicApi: body.isPublicApi, + }), + present: (result) => ({ isPublicApi: result.isPublicApi }), + onSuccess: ({ principal, result }) => { + captureServerEvent( + principal.userId, + 'workflow_public_api_toggled', + { + workflow_id: result.workflowId, + workspace_id: result.workspaceId, + is_public: result.isPublicApi, + }, + { groups: { workspace: result.workspaceId } } + ) + }, +}) + +export const DELETE = defineInternalJsonRoute({ + contract: undeployWorkflowContract, + operation: workflowOperations.undeploy, + useCase: undeployWorkflow, + auth: internalSessionAuth, + rateLimit: NO_INTERNAL_RATE_LIMIT, + errorPolicy: createInternalWorkflowErrorPolicy('Failed to undeploy workflow'), + mapInput: ({ params }) => ({ workflowId: params.id, requestId: generateRequestId() }), + present: (result) => ({ + isDeployed: false, + deployedAt: null, + apiKey: null, + warnings: result.warnings, + }), + onSuccess: ({ principal, result }) => { + captureServerEvent( + principal.userId, + 'workflow_undeployed', + { workflow_id: result.workflowId, workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } + ) + }, +}) diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.test.ts b/apps/sim/app/api/workflows/[id]/deployed/route.test.ts index a8854c4afdf..374b99edaa5 100644 --- a/apps/sim/app/api/workflows/[id]/deployed/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/deployed/route.test.ts @@ -1,39 +1,47 @@ /** - * Tests for the workflow deployed-state API route. - * Covers internal-JWT authorization (acting user required + workspace read - * permission) and the unchanged session path. - * * @vitest-environment node */ - -import { - workflowAuthzMockFns, - workflowsPersistenceUtilsMock, - workflowsPersistenceUtilsMockFns, - workflowsUtilsMock, - workflowsUtilsMockFns, -} from '@sim/testing' +import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockVerifyInternalToken } = vi.hoisted(() => ({ - mockVerifyInternalToken: vi.fn(), +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const { + InvalidDelegationTokenError, + mockBindExecutorDelegation, + mockReadWorkflowDefinition, + mockVerifyDelegationToken, +} = vi.hoisted(() => ({ + InvalidDelegationTokenError: class InvalidDelegationTokenError extends Error {}, + mockBindExecutorDelegation: vi.fn(), + mockReadWorkflowDefinition: vi.fn(), + mockVerifyDelegationToken: vi.fn(), })) vi.mock('@/lib/auth/internal', () => ({ - verifyInternalToken: mockVerifyInternalToken, + InvalidInternalDelegationTokenError: InvalidDelegationTokenError, + verifyInternalDelegationToken: mockVerifyDelegationToken, })) -vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) - -vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) +vi.mock('@/lib/auth/internal-delegation', () => ({ + bindInternalExecutorDelegation: mockBindExecutorDelegation, + InvalidInternalDelegationBindingError: class InvalidInternalDelegationBindingError extends Error {}, +})) -import { GET } from './route' +vi.mock('@/lib/workflows/application/read-workflow-definition', () => { + const operation = { + id: 'workflows.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot', 'executor'], + } as const + return { + readWorkflowDefinition: { operation, execute: mockReadWorkflowDefinition }, + } +}) -const mockAuthorizeWorkflowByWorkspacePermission = - workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission -const mockLoadDeployedWorkflowState = workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState -const mockValidateWorkflowPermissions = workflowsUtilsMockFns.mockValidateWorkflowPermissions +import { GET } from '@/app/api/workflows/[id]/deployed/route' const DEPLOYED_STATE = { blocks: { 'block-1': { id: 'block-1', type: 'starter' } }, @@ -43,160 +51,119 @@ const DEPLOYED_STATE = { variables: {}, } -function createRequest(options?: { bearerToken?: string }) { - const headers: Record<string, string> = {} - if (options?.bearerToken) { - headers.Authorization = `Bearer ${options.bearerToken}` - } - return new NextRequest('http://localhost:3000/api/workflows/workflow-123/deployed', { headers }) +const SESSION = { + user: { id: 'user-123' }, + session: { id: 'session-123' }, +} + +const EXECUTOR_PRINCIPAL = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'user-123', + workspaceId: 'workspace-456', + delegationId: 'delegation-123', + audience: 'sim:workflows', + issuedAt: new Date('2026-08-08T00:00:00.000Z'), + expiresAt: new Date('2999-08-08T00:00:00.000Z'), + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: 'origin-workflow', + executionId: 'origin-run', + }, +} + +function createRequest(bearerToken?: string) { + return new NextRequest('http://localhost:3000/api/workflows/workflow-123/deployed', { + headers: bearerToken ? { Authorization: `Bearer ${bearerToken}` } : undefined, + }) } const routeParams = () => ({ params: Promise.resolve({ id: 'workflow-123' }) }) +function readResult(state: typeof DEPLOYED_STATE | null = DEPLOYED_STATE) { + return { + workflow: { id: 'workflow-123' }, + workspaceId: 'workspace-456', + state, + } +} + describe('GET /api/workflows/[id]/deployed', () => { beforeEach(() => { vi.clearAllMocks() - mockVerifyInternalToken.mockResolvedValue({ valid: false }) - mockLoadDeployedWorkflowState.mockResolvedValue(DEPLOYED_STATE) + authMockFns.mockGetSession.mockResolvedValue(SESSION) + mockReadWorkflowDefinition.mockResolvedValue(readResult()) + mockVerifyDelegationToken.mockResolvedValue({ + subjectUserId: 'user-123', + workflowId: 'origin-workflow', + executionId: 'origin-run', + }) + mockBindExecutorDelegation.mockResolvedValue(EXECUTOR_PRINCIPAL) }) - describe('internal JWT path', () => { - it('returns 200 when the token carries a user with read permission', async () => { - mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' }) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: { id: 'workflow-123', workspaceId: 'workspace-456' }, - workspacePermission: 'read', - }) - - const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.deployedState).toEqual(DEPLOYED_STATE) - expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ - workflowId: 'workflow-123', - userId: 'user-123', - action: 'read', - }) - expect(mockValidateWorkflowPermissions).not.toHaveBeenCalled() - }) + it('passes the authenticated session principal through the application use case', async () => { + const response = await GET(createRequest(), routeParams()) - it('returns 403 when the acting user lacks read permission', async () => { - mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' }) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: false, - status: 403, - message: 'Unauthorized: Access denied to read this workflow', - workflow: { id: 'workflow-123', workspaceId: 'workspace-456' }, - workspacePermission: null, + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ deployedState: DEPLOYED_STATE }) + expect(mockReadWorkflowDefinition).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'user-123', sessionId: 'session-123' }, + input: { workflowId: 'workflow-123', state: 'deployed' }, }) + ) + }) - const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) - - expect(response.status).toBe(403) - const data = await response.json() - expect(data.error).toBe('Unauthorized: Access denied to read this workflow') - expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled() - }) - - it('returns 403 when the token carries no acting user (fail closed)', async () => { - mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: undefined }) - - const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) - - expect(response.status).toBe(403) - const data = await response.json() - expect(data.error).toBe('Forbidden') - expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() - expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled() - }) + it('accepts only the canonically bound executor principal for Bearer requests', async () => { + const response = await GET(createRequest('signed-token'), routeParams()) - it('returns 404 when the workflow does not exist', async () => { - mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' }) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: false, - status: 404, - message: 'Workflow not found', - workflow: null, - workspacePermission: null, + expect(response.status).toBe(200) + expect(mockBindExecutorDelegation).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'origin-workflow', executionId: 'origin-run' }), + { audience: 'sim:workflows', resourceScope: undefined } + ) + expect(mockReadWorkflowDefinition).toHaveBeenCalledWith( + expect.objectContaining({ + principal: EXECUTOR_PRINCIPAL, + input: { workflowId: 'workflow-123', state: 'deployed' }, }) - - const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) - - expect(response.status).toBe(404) - const data = await response.json() - expect(data.error).toBe('Workflow not found') - expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled() - }) + ) + expect(authMockFns.mockGetSession).not.toHaveBeenCalled() }) - describe('session path', () => { - it('returns 200 when session permissions validate', async () => { - mockValidateWorkflowPermissions.mockResolvedValue({ - error: null, - session: { user: { id: 'user-123' } }, - workflow: { id: 'workflow-123' }, - }) - - const response = await GET(createRequest(), routeParams()) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.deployedState).toEqual(DEPLOYED_STATE) - expect(mockValidateWorkflowPermissions).toHaveBeenCalledWith( - 'workflow-123', - expect.any(String), - 'read' - ) - expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() - }) - - it('propagates validateWorkflowPermissions errors unchanged', async () => { - mockValidateWorkflowPermissions.mockResolvedValue({ - error: { message: 'Unauthorized', status: 401 }, - session: null, - workflow: null, - }) + it('fails closed when a Bearer delegation cannot be verified', async () => { + mockVerifyDelegationToken.mockRejectedValue(new InvalidDelegationTokenError()) - const response = await GET(createRequest(), routeParams()) + const response = await GET(createRequest('invalid-token'), routeParams()) - expect(response.status).toBe(401) - const data = await response.json() - expect(data.error).toBe('Unauthorized') - }) + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: 'Authentication required' }) + expect(authMockFns.mockGetSession).not.toHaveBeenCalled() + expect(mockReadWorkflowDefinition).not.toHaveBeenCalled() + }) - it('falls back to session validation when the bearer token is not a valid internal token', async () => { - mockVerifyInternalToken.mockResolvedValue({ valid: false }) - mockValidateWorkflowPermissions.mockResolvedValue({ - error: { message: 'Unauthorized', status: 401 }, - session: null, - workflow: null, - }) + it('projects application authorization failures without loading state in the route', async () => { + mockReadWorkflowDefinition.mockRejectedValue( + new OrchestrationError('forbidden', 'Delegated workflow access is no longer valid') + ) - const response = await GET(createRequest({ bearerToken: 'not-internal' }), routeParams()) + const response = await GET(createRequest('signed-token'), routeParams()) - expect(response.status).toBe(401) - expect(mockValidateWorkflowPermissions).toHaveBeenCalled() - expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: 'Delegated workflow access is no longer valid', }) }) - it('returns null deployedState when loading the snapshot fails', async () => { - mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' }) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: { id: 'workflow-123', workspaceId: 'workspace-456' }, - workspacePermission: 'admin', - }) - mockLoadDeployedWorkflowState.mockRejectedValue(new Error('no active deployment')) + it('preserves null deployed state and disables caching', async () => { + mockReadWorkflowDefinition.mockResolvedValue(readResult(null)) - const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) + const response = await GET(createRequest(), routeParams()) expect(response.status).toBe(200) - const data = await response.json() - expect(data.deployedState).toBeNull() + await expect(response.json()).resolves.toEqual({ deployedState: null }) + expect(response.headers.get('cache-control')).toBe( + 'no-store, no-cache, must-revalidate, max-age=0' + ) }) }) diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.ts b/apps/sim/app/api/workflows/[id]/deployed/route.ts index 60e8feaf7ec..6a9ad7d1bc4 100644 --- a/apps/sim/app/api/workflows/[id]/deployed/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployed/route.ts @@ -1,108 +1,46 @@ import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' -import type { NextRequest, NextResponse } from 'next/server' -import { getDeployedWorkflowStateContract } from '@/lib/api/contracts/deployments' -import { parseRequest } from '@/lib/api/server' -import { verifyInternalToken } from '@/lib/auth/internal' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils' -import { validateWorkflowPermissions } from '@/lib/workflows/utils' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { + deployedWorkflowStateSchema, + getDeployedWorkflowStateContract, +} from '@/lib/api/contracts/deployments' +import { + defineInternalJsonRoute, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalWorkflowSessionOrExecutorAuth } from '@/lib/workflows/api' +import { readWorkflowDefinition } from '@/lib/workflows/application/read-workflow-definition' const logger = createLogger('WorkflowDeployedStateAPI') export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -function addNoCacheHeaders(response: NextResponse): NextResponse { - response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') - return response -} - -/** - * GET /api/workflows/[id]/deployed - * Returns the active deployed state snapshot for a workflow. - * - * Internal (server-to-server) calls must carry the acting user in the internal - * JWT payload (`generateInternalToken(userId)` — the executor's - * `buildAuthHeaders(ctx.userId)` always embeds it) and are authorized as that - * user with the same workspace-read semantics as the sibling - * `/api/workflows/[id]` route. Internal calls without a user id are rejected - * (fail closed). Session calls are authorized via - * `validateWorkflowPermissions` as before. - */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const parsed = await parseRequest(getDeployedWorkflowStateContract, request, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - - try { - const authHeader = request.headers.get('authorization') - let isInternalCall = false - let internalCallUserId: string | undefined - - if (authHeader?.startsWith('Bearer ')) { - const token = authHeader.split(' ')[1] - const verification = await verifyInternalToken(token) - isInternalCall = verification.valid - internalCallUserId = verification.userId - } - - if (isInternalCall) { - if (!internalCallUserId) { - logger.warn(`[${requestId}] Internal call without acting user denied for workflow ${id}`) - return addNoCacheHeaders(createErrorResponse('Forbidden', 403)) - } - - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId: id, - userId: internalCallUserId, - action: 'read', +export const GET = defineInternalJsonRoute({ + contract: getDeployedWorkflowStateContract, + auth: internalWorkflowSessionOrExecutorAuth, + operation: readWorkflowDefinition.operation, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal workflow read behavior', + }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ workflowId: params.id, state: 'deployed' as const }), + useCase: readWorkflowDefinition, + present: ({ state }) => ({ + deployedState: state + ? deployedWorkflowStateSchema.parse({ + blocks: state.blocks, + edges: state.edges, + loops: state.loops, + parallels: state.parallels, + variables: 'variables' in state ? (state.variables ?? {}) : {}, }) - if (!authorization.workflow) { - logger.warn(`[${requestId}] Workflow ${id} not found for internal call`) - return addNoCacheHeaders(createErrorResponse('Workflow not found', 404)) - } - if (!authorization.allowed) { - logger.warn( - `[${requestId}] Internal call user ${internalCallUserId} denied read access to workflow ${id}` - ) - return addNoCacheHeaders( - createErrorResponse(authorization.message || 'Access denied', authorization.status) - ) - } - } else { - const { error } = await validateWorkflowPermissions(id, requestId, 'read') - if (error) { - const response = createErrorResponse(error.message, error.status) - return addNoCacheHeaders(response) - } - } - - let deployedState = null - try { - const data = await loadDeployedWorkflowState(id) - deployedState = { - blocks: data.blocks, - edges: data.edges, - loops: data.loops, - parallels: data.parallels, - variables: data.variables, - } - } catch (error) { - logger.warn(`[${requestId}] Failed to load deployed state for workflow ${id}`, { error }) - deployedState = null - } - - const response = createSuccessResponse({ deployedState }) - return addNoCacheHeaders(response) - } catch (error: any) { - logger.error(`[${requestId}] Error fetching deployed state: ${id}`, error) - const response = createErrorResponse(error.message || 'Failed to fetch deployed state', 500) - return addNoCacheHeaders(response) - } - } -) + : null, + }), + onSuccess: ({ input, result }) => { + if (!result.state) logger.warn('Workflow has no active deployed state', input) + }, + responseHeaders: () => ({ + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + }), +}) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts index 1b2746f525f..7b03ec104b1 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -1,70 +1,41 @@ -import { createLogger } from '@sim/logger' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' -import type { NextRequest } from 'next/server' -import { workflowDeploymentVersionParamSchema } from '@/lib/api/contracts/workflows' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performRevertToVersion } from '@/lib/workflows/orchestration' -import { validateWorkflowPermissions } from '@/lib/workflows/utils' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' - -const logger = createLogger('RevertToDeploymentVersionAPI') +import { revertToDeploymentVersionContract } from '@/lib/api/contracts/deployments' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { captureServerEvent } from '@/lib/posthog/server' +import { createInternalWorkflowErrorPolicy } from '@/lib/workflows/api' +import { revertWorkflowVersion } from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -export const POST = withRouteHandler( - async ( - request: NextRequest, - { params }: { params: Promise<{ id: string; version: string }> } - ) => { - const requestId = generateRequestId() - const { id, version } = await params - - try { - const { - error, - session, - workflow: workflowRecord, - } = await validateWorkflowPermissions(id, requestId, 'admin') - if (error) { - return createErrorResponse(error.message, error.status) - } - await assertWorkflowMutable(id) - - const versionValidation = workflowDeploymentVersionParamSchema.safeParse(version) - if (!versionValidation.success) { - return createErrorResponse('Invalid version', 400) - } - - const result = await performRevertToVersion({ - workflowId: id, - version: versionValidation.data, - userId: session!.user.id, - workflow: (workflowRecord ?? {}) as Record<string, unknown>, - request, - actorName: session!.user.name ?? undefined, - actorEmail: session!.user.email ?? undefined, - }) - - if (!result.success) { - return createErrorResponse( - result.error || 'Failed to revert', - result.errorCode === 'not_found' ? 404 : 500 - ) - } - - return createSuccessResponse({ - message: 'Reverted to deployment version', - lastSaved: result.lastSaved, - }) - } catch (error: any) { - if (error instanceof WorkflowLockedError) { - return createErrorResponse(error.message, error.status) - } - - logger.error('Error reverting to deployment version', error) - return createErrorResponse(error.message || 'Failed to revert', 500) - } - } -) +export const POST = defineInternalJsonRoute({ + contract: revertToDeploymentVersionContract, + operation: workflowOperations.revertVersion, + useCase: revertWorkflowVersion, + auth: internalSessionAuth, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated workspace UI version reverts retain their existing admission policy.', + }), + errorPolicy: createInternalWorkflowErrorPolicy('Failed to revert'), + mapInput: ({ params }) => ({ workflowId: params.id, version: params.version }), + present: (result) => ({ + message: 'Reverted to deployment version', + lastSaved: result.lastSaved, + }), + onSuccess: ({ principal, result }) => { + captureServerEvent( + principal.userId, + 'workflow_deployment_reverted', + { + workflow_id: result.workflowId, + workspace_id: result.workspaceId, + version: String(result.version), + }, + { groups: { workspace: result.workspaceId } } + ) + }, +}) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.test.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.test.ts new file mode 100644 index 00000000000..7dece65c9bc --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.test.ts @@ -0,0 +1,125 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + activate: vi.fn(), + parseRequest: vi.fn(), + read: vi.fn(), + session: vi.fn(), + update: vi.fn(), +})) + +vi.mock('@/lib/api/server', () => ({ + getValidationErrorMessage: vi.fn(), + parseRequest: mocks.parseRequest, +})) + +vi.mock('@/lib/api/server/routes', () => ({ + defineInternalJsonRoute: vi.fn(() => vi.fn()), + InternalUnauthenticatedError: class InternalUnauthenticatedError extends Error {}, + internalRateLimits: { none: vi.fn(() => ({ kind: 'none' })) }, + internalSessionAuth: { authenticate: mocks.session }, +})) + +vi.mock('@/lib/workflows/api', () => ({ + createInternalWorkflowErrorPolicy: vi.fn(() => ({ + project: vi.fn(), + unhandled: vi.fn(), + })), +})) + +vi.mock('@/lib/core/utils/with-route-handler', () => ({ + withRouteHandler: (handler: unknown) => handler, +})) + +vi.mock('@/lib/workflows/application/deployments', () => ({ + activateWorkflowVersion: { execute: mocks.activate }, + updateWorkflowVersion: { execute: mocks.update }, +})) + +vi.mock('@/lib/workflows/application/read-workflow-version', () => ({ + readWorkflowVersion: { execute: mocks.read }, +})) + +import { PATCH } from '@/app/api/workflows/[id]/deployments/[version]/route' + +describe('workflow deployment version PATCH', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.session.mockResolvedValue({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + mocks.activate.mockResolvedValue({ + deployedAt: new Date('2026-01-01T00:00:00Z'), + warnings: undefined, + activeDeployment: null, + latestDeploymentAttempt: null, + name: 'Release 2', + description: 'Production', + }) + mocks.update.mockResolvedValue({ name: 'Release 2', description: 'Production' }) + }) + + it('sends activation and optional metadata through one application command', async () => { + mocks.parseRequest.mockResolvedValue({ + success: true, + data: { + params: { id: 'workflow-1', version: 2 }, + body: { isActive: true, name: 'Release 2', description: 'Production' }, + }, + }) + + const response = await PATCH( + createMockRequest( + 'PATCH', + undefined, + {}, + 'http://localhost/api/workflows/workflow-1/deployments/2' + ), + { params: Promise.resolve({ id: 'workflow-1', version: '2' }) } + ) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + success: true, + name: 'Release 2', + description: 'Production', + }) + expect(mocks.activate).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + workflowId: 'workflow-1', + version: 2, + name: 'Release 2', + description: 'Production', + }), + }) + ) + expect(mocks.update).not.toHaveBeenCalled() + }) + + it('keeps metadata-only edits on the existing update-version operation', async () => { + mocks.parseRequest.mockResolvedValue({ + success: true, + data: { + params: { id: 'workflow-1', version: 2 }, + body: { isActive: false, name: 'Release 2' }, + }, + }) + + const response = await PATCH( + createMockRequest( + 'PATCH', + undefined, + {}, + 'http://localhost/api/workflows/workflow-1/deployments/2' + ), + { params: Promise.resolve({ id: 'workflow-1', version: '2' }) } + ) + + expect(response.status).toBe(200) + expect(mocks.update).toHaveBeenCalledOnce() + expect(mocks.activate).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index 42516bd676a..e19432df48d 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -1,18 +1,26 @@ -import { db, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { updateDeploymentVersionMetadataContract } from '@/lib/api/contracts/deployments' +import { + getDeploymentVersionStateContract, + updateDeploymentVersionMetadataContract, +} from '@/lib/api/contracts/deployments' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { InternalUnauthenticatedError, internalSessionAuth } from '@/lib/api/server/routes' +import { + defineInternalJsonRoute, + InternalUnauthenticatedError, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { activateWorkflowVersion } from '@/lib/workflows/application/deployments' +import { createInternalWorkflowErrorPolicy } from '@/lib/workflows/api' +import { + activateWorkflowVersion, + updateWorkflowVersion, +} from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' import { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version' -import { updateDeploymentVersionMetadata } from '@/lib/workflows/persistence/utils' -import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('WorkflowDeploymentVersionAPI') @@ -21,48 +29,18 @@ export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 120 -export const GET = withRouteHandler( - async ( - request: NextRequest, - { params }: { params: Promise<{ id: string; version: string }> } - ) => { - const requestId = generateRequestId() - const { id, version } = await params - - try { - const principal = await internalSessionAuth.authenticate() - - const versionNum = Number(version) - if (!Number.isFinite(versionNum)) { - return createErrorResponse('Invalid version', 400) - } - - const { version: row } = await readWorkflowVersion.execute({ - principal, - input: { workflowId: id, version: versionNum }, - request, - }) - - return createSuccessResponse({ deployedState: row.state }) - } catch (error: unknown) { - if (error instanceof InternalUnauthenticatedError) { - return createErrorResponse(error.message, 401) - } - const orchestrationError = asOrchestrationError(error) - if (orchestrationError) { - return createErrorResponse( - orchestrationError.message, - statusForOrchestrationError(orchestrationError.code) - ) - } - logger.error( - `[${requestId}] Error fetching deployment version ${version} for workflow ${id}`, - { error } - ) - return createErrorResponse('Failed to fetch deployment version', 500) - } - } -) +export const GET = defineInternalJsonRoute({ + contract: getDeploymentVersionStateContract, + operation: workflowOperations.readVersion, + useCase: readWorkflowVersion, + auth: internalSessionAuth, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated workspace UI version reads retain their existing admission policy.', + }), + errorPolicy: createInternalWorkflowErrorPolicy('Failed to fetch deployment version'), + mapInput: ({ params }) => ({ workflowId: params.id, version: params.version }), + present: ({ version }) => ({ deployedState: version.state }), +}) export const PATCH = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string; version: string }> }) => { @@ -85,84 +63,41 @@ export const PATCH = withRouteHandler( if (isActive) { const activateResult = await activateWorkflowVersion.execute({ principal, - input: { workflowId: id, version: versionNum, transition: 'activate', requestId }, + input: { + workflowId: id, + version: versionNum, + transition: 'activate', + requestId, + name, + description, + }, request, }) - let updatedName: string | null | undefined - let updatedDescription: string | null | undefined if (name !== undefined || description !== undefined) { - const activationUpdateData: { name?: string; description?: string | null } = {} - if (name !== undefined) { - activationUpdateData.name = name - } - if (description !== undefined) { - activationUpdateData.description = description - } - - const [updated] = await db - .update(workflowDeploymentVersion) - .set(activationUpdateData) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.version, versionNum) - ) - ) - .returning({ - name: workflowDeploymentVersion.name, - description: workflowDeploymentVersion.description, - }) - - if (updated) { - updatedName = updated.name - updatedDescription = updated.description - logger.info( - `[${requestId}] Updated deployment version ${version} metadata during activation`, - { name: activationUpdateData.name, description: activationUpdateData.description } - ) - } + logger.info( + `[${requestId}] Updated deployment version ${version} metadata during activation`, + { name, description } + ) } - captureServerEvent( - principal.userId, - 'deployment_version_activated', - { - workflow_id: activateResult.workflowId, - workspace_id: activateResult.workspaceId, - version: versionNum, - }, - { groups: { workspace: activateResult.workspaceId } } - ) - return createSuccessResponse({ success: true, deployedAt: activateResult.deployedAt ?? null, warnings: activateResult.warnings, activeDeployment: activateResult.activeDeployment ?? null, latestDeploymentAttempt: activateResult.latestDeploymentAttempt ?? null, - ...(updatedName !== undefined && { name: updatedName }), - ...(updatedDescription !== undefined && { description: updatedDescription }), + ...(name !== undefined && { name: activateResult.name ?? null }), + ...(description !== undefined && { description: activateResult.description ?? null }), }) } - const { error } = await validateWorkflowPermissions(id, requestId, 'write') - if (error) { - return createErrorResponse(error.message, error.status) - } - - // Handle name/description updates (shared with the update_deployment_version copilot tool) - const updated = await updateDeploymentVersionMetadata({ - workflowId: id, - version: versionNum, - name, - description, + const updated = await updateWorkflowVersion.execute({ + principal, + input: { workflowId: id, version: versionNum, name, description }, + request, }) - if (!updated) { - return createErrorResponse('Deployment version not found', 404) - } - logger.info(`[${requestId}] Updated deployment version ${version} for workflow ${id}`, { name, description, diff --git a/apps/sim/app/api/workflows/[id]/deployments/route.ts b/apps/sim/app/api/workflows/[id]/deployments/route.ts index f958f5b5de3..feeb094bba9 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/route.ts @@ -1,53 +1,31 @@ -import { createLogger } from '@sim/logger' -import type { NextRequest } from 'next/server' import { listDeploymentVersionsContract } from '@/lib/api/contracts/deployments' -import { parseRequest } from '@/lib/api/server' -import { InternalUnauthenticatedError, internalSessionAuth } from '@/lib/api/server/routes' -import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { createInternalWorkflowErrorPolicy } from '@/lib/workflows/api' import { listWorkflowVersions } from '@/lib/workflows/application/list-workflow-versions' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' - -const logger = createLogger('WorkflowDeploymentsListAPI') +import { workflowOperations } from '@/lib/workflows/application/operations' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - - try { - const principal = await internalSessionAuth.authenticate() - const parsed = await parseRequest(listDeploymentVersionsContract, request, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - - const { versions: rows } = await listWorkflowVersions.execute({ - principal, - input: { workflowId: id }, - request, - }) - const versions = rows.map(({ deployedByName, ...version }) => ({ - ...version, - deployedBy: deployedByName, - })) - - return createSuccessResponse({ versions }) - } catch (error: unknown) { - if (error instanceof InternalUnauthenticatedError) { - return createErrorResponse(error.message, 401) - } - const orchestrationError = asOrchestrationError(error) - if (orchestrationError) { - return createErrorResponse( - orchestrationError.message, - statusForOrchestrationError(orchestrationError.code) - ) - } - logger.error(`[${requestId}] Error listing workflow deployments`, { error }) - return createErrorResponse('Failed to list deployments', 500) - } - } -) +export const GET = defineInternalJsonRoute({ + contract: listDeploymentVersionsContract, + operation: workflowOperations.listVersions, + useCase: listWorkflowVersions, + auth: internalSessionAuth, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated workspace UI version lists retain their existing admission policy.', + }), + errorPolicy: createInternalWorkflowErrorPolicy('Failed to list deployments'), + mapInput: ({ params }) => ({ workflowId: params.id }), + present: ({ versions }) => ({ + versions: versions.map(({ deployedByName, ...version }) => ({ + ...version, + createdAt: version.createdAt.toISOString(), + deployedBy: deployedByName, + })), + }), +}) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index cc61e399dd8..0db40b5ee81 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -121,7 +121,7 @@ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock) -vi.mock('@/app/api/workflows/utils', () => ({ +vi.mock('@/lib/workflows/deployment-status', () => ({ checkNeedsRedeployment: mockCheckNeedsRedeployment, })) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 839d8f5f595..2943bb76423 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -98,6 +98,7 @@ import { hydrateUserFilesWithBase64, } from '@/lib/uploads/utils/user-file-base64.server' import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations' +import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' import { enqueueWorkflowExecution } from '@/lib/workflows/executor/enqueue-execution' import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' @@ -139,7 +140,6 @@ import { } from '@/lib/workflows/streaming/streaming' import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils' import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' -import { checkNeedsRedeployment } from '@/app/api/workflows/utils' import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' import { PublicApiNotAllowedError, diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index 9b9be85824c..e65dbfecf03 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -1,943 +1,171 @@ /** - * Integration tests for workflow by ID API route - * Tests the new centralized permissions system - * * @vitest-environment node */ - -import { - auditMock, - dbChainMockFns, - hybridAuthMockFns, - resetDbChainMock, - telemetryMock, - workflowAuthzMockFns, - workflowsOrchestrationMock, - workflowsOrchestrationMockFns, - workflowsPersistenceUtilsMock, - workflowsPersistenceUtilsMockFns, - workflowsUtilsMock, - workflowsUtilsMockFns, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { getWorkflowResponseDataSchema } from '@/lib/api/contracts/workflows' - -const mockLoadWorkflowFromNormalizedTables = - workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables -const mockGetWorkflowById = workflowsUtilsMockFns.mockGetWorkflowById -const mockAuthorizeWorkflowByWorkspacePermission = - workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission -const mockPerformDeleteWorkflow = workflowsOrchestrationMockFns.mockPerformDeleteWorkflow -const mockPerformUpdateWorkflow = workflowsOrchestrationMockFns.mockPerformUpdateWorkflow - -/** - * Helper to set mock auth state consistently across getSession and hybrid auth. - */ -function mockGetSession(session: { user: { id: string } } | null) { - if (session) { - hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({ - success: true, - userId: session.user.id, - }) - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: session.user.id, - }) - } else { - hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({ success: false }) - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) - } +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + auth: vi.fn(), + capture: vi.fn(), + defineRoute: vi.fn((definition) => definition), + deleteWorkflow: vi.fn(), + parseRequest: vi.fn(), + readWorkflow: vi.fn(), + updatePolicy: vi.fn(), + updateWorkflow: vi.fn(), +})) + +vi.mock('@/lib/api/server', () => ({ parseRequest: mocks.parseRequest })) + +vi.mock('@/lib/api/server/routes', () => ({ + defineInternalJsonRoute: mocks.defineRoute, + InternalUnauthenticatedError: class InternalUnauthenticatedError extends Error {}, + internalPlainOrchestrationErrorPolicy: { kind: 'plain-orchestration' }, + internalRateLimits: { none: vi.fn(() => ({ kind: 'none' })) }, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +vi.mock('@/lib/workflows/api', () => ({ + internalWorkflowSessionOrExecutorAuth: { authenticate: mocks.auth }, +})) + +vi.mock('@/lib/workflows/application/read-workflow-definition', () => ({ + readWorkflowDefinition: { + operation: { id: 'workflows.read' }, + execute: mocks.readWorkflow, + }, +})) + +vi.mock('@/lib/workflows/application/delete-workflow', () => ({ + deleteWorkflow: { + operation: { id: 'workflows.delete' }, + execute: mocks.deleteWorkflow, + }, +})) + +vi.mock('@/lib/workflows/application/update-workflow', () => ({ + updateWorkflow: { + operation: { id: 'workflows.update' }, + execute: mocks.updateWorkflow, + }, + updateWorkflowPolicy: { + operation: { id: 'workflows.policy.update' }, + execute: mocks.updatePolicy, + }, +})) + +import { DELETE, GET, PUT } from '@/app/api/workflows/[id]/route' + +const sessionPrincipal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', } -vi.mock('@/lib/core/telemetry', () => telemetryMock) - -vi.mock('@sim/audit', () => auditMock) - -vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) - -vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) - -vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) - -import { DELETE, GET, PUT } from './route' - -describe('Workflow By ID API Route', () => { - afterAll(() => { - resetDbChainMock() - }) - +describe('/api/workflows/[id] application adapters', () => { beforeEach(() => { vi.clearAllMocks() - resetDbChainMock() - - vi.stubGlobal('crypto', { - randomUUID: vi.fn().mockReturnValue('mock-request-id-12345678'), - }) - - mockLoadWorkflowFromNormalizedTables.mockResolvedValue(null) - mockPerformUpdateWorkflow.mockImplementation(async (params) => ({ - success: true, - workflow: { - id: params.workflowId, - name: params.name ?? params.currentName, - description: params.description ?? null, - workspaceId: params.workspaceId, - folderId: params.folderId ?? params.currentFolderId ?? null, - sortOrder: params.sortOrder ?? null, - locked: params.locked ?? null, - forkSyncExcluded: params.forkSyncExcluded ?? null, - createdAt: new Date(), - updatedAt: new Date(), - archivedAt: null, - }, - })) - }) - - describe('GET /api/workflows/[id]', () => { - it('should return 401 when user is not authenticated', async () => { - mockGetSession(null) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await GET(req, { params }) - - expect(response.status).toBe(401) - const data = await response.json() - expect(data.error).toBe('Unauthorized') - }) - - it('should return 404 when workflow does not exist', async () => { - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(null) - - const req = new NextRequest('http://localhost:3000/api/workflows/nonexistent') - const params = Promise.resolve({ id: 'nonexistent' }) - - const response = await GET(req, { params }) - - expect(response.status).toBe(404) - const data = await response.json() - expect(data.error).toBe('Workflow not found') - }) - - it.concurrent('should allow access when user has admin workspace permission', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - const mockNormalizedData = { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - isFromNormalizedTables: true, - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'admin', - }) - - mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await GET(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.data.id).toBe('workflow-123') - }) - - it('omits null workflow description from state metadata so response validates', async () => { - const mockWorkflow = { - id: 'workflow-null-description', - userId: 'user-123', - name: 'No Description Workflow', - description: null, - workspaceId: 'workspace-456', - folderId: null, - sortOrder: 0, - color: '#3972F6', - lastSynced: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - isDeployed: false, - deployedAt: null, - isPublicApi: false, - locked: false, - runCount: 0, - lastRunAt: null, - archivedAt: null, - variables: {}, - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'admin', - }) - mockLoadWorkflowFromNormalizedTables.mockResolvedValue({ - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-null-description') - const params = Promise.resolve({ id: 'workflow-null-description' }) - - const response = await GET(req, { params }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.data.state.metadata).toEqual({ name: 'No Description Workflow' }) - expect(getWorkflowResponseDataSchema.safeParse(data.data).success).toBe(true) - }) - - it.concurrent('should allow access when user has workspace permissions', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'other-user', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - const mockNormalizedData = { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - isFromNormalizedTables: true, - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'read', - }) - - mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await GET(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.data.id).toBe('workflow-123') + mocks.auth.mockResolvedValue(sessionPrincipal) + mocks.updateWorkflow.mockResolvedValue({ + workflow: { id: 'workflow-1', name: 'Renamed', locked: false, forkSyncExcluded: false }, + workspaceId: 'workspace-1', + changes: ['name'], }) - - it('should deny access when user has no workspace permissions', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'other-user', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: false, - status: 403, - message: 'Unauthorized: Access denied to read this workflow', - workflow: mockWorkflow, - workspacePermission: null, - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await GET(req, { params }) - - expect(response.status).toBe(403) - const data = await response.json() - expect(data.error).toBe('Unauthorized: Access denied to read this workflow') - }) - - it.concurrent('should use normalized tables when available', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - const mockNormalizedData = { - blocks: { 'block-1': { id: 'block-1', type: 'starter' } }, - edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2' }], - loops: {}, - parallels: {}, - isFromNormalizedTables: true, - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'admin', - }) - - mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await GET(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.data.state.blocks).toEqual(mockNormalizedData.blocks) - expect(data.data.state.edges).toEqual(mockNormalizedData.edges) + mocks.updatePolicy.mockResolvedValue({ + workflow: { id: 'workflow-1', name: 'Workflow', locked: true, forkSyncExcluded: false }, + workspaceId: 'workspace-1', + changes: ['locked'], }) }) - describe('DELETE /api/workflows/[id]', () => { - it('should allow admin to delete workflow', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'admin', - }) - - mockPerformDeleteWorkflow.mockResolvedValue({ success: true }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'DELETE', - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await DELETE(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.success).toBe(true) - expect(mockPerformDeleteWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ - workflowId: 'workflow-123', - userId: 'user-123', - }) - ) + it('binds GET and DELETE directly to fixed application use cases', () => { + expect(GET).toMatchObject({ + operation: { id: 'workflows.read' }, + useCase: { operation: { id: 'workflows.read' } }, }) - - it('should allow admin to delete workspace workflow', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'other-user', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'admin', - }) - - mockPerformDeleteWorkflow.mockResolvedValue({ success: true }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'DELETE', - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await DELETE(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.success).toBe(true) + expect(Reflect.get(GET, 'mapInput')({ params: { id: 'workflow-1' } })).toEqual({ + workflowId: 'workflow-1', + state: 'draft', }) - it('should prevent deletion of the last workflow in workspace', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'admin', - }) - - mockPerformDeleteWorkflow.mockResolvedValue({ - success: false, - error: 'Cannot delete the only workflow in the workspace', - errorCode: 'validation', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'DELETE', - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await DELETE(req, { params }) - - expect(response.status).toBe(400) - const data = await response.json() - expect(data.error).toBe('Cannot delete the only workflow in the workspace') + expect(DELETE).toMatchObject({ + operation: { id: 'workflows.delete' }, + useCase: { operation: { id: 'workflows.delete' } }, }) - - it('should allow user with write permission to delete workflow', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'other-user', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - - mockPerformDeleteWorkflow.mockResolvedValue({ success: true }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'DELETE', - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await DELETE(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.success).toBe(true) - expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith( - expect.objectContaining({ workflowId: 'workflow-123', action: 'write' }) - ) - }) - - it.concurrent('should deny deletion for read-only users', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'other-user', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: false, - status: 403, - message: 'Unauthorized: Access denied to write this workflow', - workflow: mockWorkflow, - workspacePermission: 'read', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'DELETE', - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await DELETE(req, { params }) - - expect(response.status).toBe(403) - const data = await response.json() - expect(data.error).toBe('Unauthorized: Access denied to write this workflow') + expect(Reflect.get(DELETE, 'mapInput')({ params: { id: 'workflow-1' } })).toEqual({ + workflowId: 'workflow-1', }) }) - describe('PUT /api/workflows/[id]', () => { - it('should allow user with write permission to update workflow', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - const updateData = { name: 'Updated Workflow' } - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify(updateData), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.workflow.name).toBe('Updated Workflow') + it('keeps human delete analytics surface-specific and no-op aware', async () => { + const onSuccess = Reflect.get(DELETE, 'onSuccess') + await onSuccess({ + principal: sessionPrincipal, + result: { archived: false, workflowId: 'workflow-1', workspaceId: 'workspace-1' }, }) + expect(mocks.capture).not.toHaveBeenCalled() - it('should allow users with write permission to update workflow', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'other-user', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - const updateData = { name: 'Updated Workflow' } - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify(updateData), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.workflow.name).toBe('Updated Workflow') + await onSuccess({ + principal: sessionPrincipal, + result: { archived: true, workflowId: 'workflow-1', workspaceId: 'workspace-1' }, }) + expect(mocks.capture).toHaveBeenCalledOnce() + }) - it('should deny update for users with only read permission', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'other-user', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - const updateData = { name: 'Updated Workflow' } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: false, - status: 403, - message: 'Unauthorized: Access denied to write this workflow', - workflow: mockWorkflow, - workspacePermission: 'read', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify(updateData), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(403) - const data = await response.json() - expect(data.error).toBe('Unauthorized: Access denied to write this workflow') - }) - - it.concurrent('should validate request data', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - - const invalidData = { name: '' } - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify(invalidData), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(400) - const data = await response.json() - expect(data.error).toBe('Validation error') - }) - - it('should reject rename when duplicate name exists in same folder', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Original Name', - folderId: 'folder-1', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - mockPerformUpdateWorkflow.mockResolvedValueOnce({ - success: false, - error: 'A workflow named "Duplicate Name" already exists in this folder', - errorCode: 'conflict', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify({ name: 'Duplicate Name' }), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(409) - const data = await response.json() - expect(data.error).toBe('A workflow named "Duplicate Name" already exists in this folder') - }) - - it('should reject rename when duplicate name exists at root level', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Original Name', - folderId: null, - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - mockPerformUpdateWorkflow.mockResolvedValueOnce({ - success: false, - error: 'A workflow named "Duplicate Name" already exists in this folder', - errorCode: 'conflict', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify({ name: 'Duplicate Name' }), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(409) - const data = await response.json() - expect(data.error).toBe('A workflow named "Duplicate Name" already exists in this folder') - }) - - it('should allow rename when no duplicate exists in same folder', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Original Name', - folderId: 'folder-1', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify({ name: 'Unique Name' }), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.workflow.name).toBe('Unique Name') - }) - - it('should allow same name in different folders', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'My Workflow', - folderId: 'folder-1', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify({ folderId: 'folder-2' }), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.workflow.folderId).toBe('folder-2') + it('selects one fixed update command without route-owned resource work', async () => { + mocks.parseRequest.mockResolvedValue({ + success: true, + data: { params: { id: 'workflow-1' }, body: { name: 'Renamed' } }, }) - it('should reject moving to a folder where same name already exists', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'My Workflow', - folderId: 'folder-1', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - mockPerformUpdateWorkflow.mockResolvedValueOnce({ - success: false, - error: 'A workflow named "My Workflow" already exists in this folder', - errorCode: 'conflict', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify({ folderId: 'folder-2' }), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(409) - const data = await response.json() - expect(data.error).toBe('A workflow named "My Workflow" already exists in this folder') + const response = await PUT(createMockRequest('PUT', { name: 'Renamed' }), { + params: Promise.resolve({ id: 'workflow-1' }), }) - it('should skip duplicate check when only updating non-name/non-folder fields', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify({ description: 'Updated description' }), + expect(response.status).toBe(200) + expect(mocks.updateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', name: 'Renamed' }, }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) + ) + expect(mocks.updatePolicy).not.toHaveBeenCalled() + }) - expect(response.status).toBe(200) - expect(dbChainMockFns.select).not.toHaveBeenCalled() + it('uses the dedicated policy command and emits only human product analytics', async () => { + mocks.parseRequest.mockResolvedValue({ + success: true, + data: { params: { id: 'workflow-1' }, body: { locked: true } }, }) - it('should deny forkSyncExcluded update for non-admin users', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - forkSyncExcluded: false, - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify({ forkSyncExcluded: true }), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(403) - const data = await response.json() - expect(data.error).toBe('Admin access required to exclude workflows from sync') - expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + const response = await PUT(createMockRequest('PUT', { locked: true }), { + params: Promise.resolve({ id: 'workflow-1' }), }) - it('should allow admin to toggle forkSyncExcluded and carry it on the response', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - forkSyncExcluded: false, - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'admin', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify({ forkSyncExcluded: true }), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) + expect(response.status).toBe(200) + expect(mocks.updatePolicy).toHaveBeenCalledOnce() + expect(mocks.updateWorkflow).not.toHaveBeenCalled() + expect(mocks.capture).toHaveBeenCalledWith( + 'user-1', + 'workflow_lock_toggled', + expect.objectContaining({ workflow_id: 'workflow-1', locked: true }), + expect.any(Object) + ) + }) - expect(response.status).toBe(200) - const data = await response.json() - expect(data.workflow.forkSyncExcluded).toBe(true) - expect(mockPerformUpdateWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ - workflowId: 'workflow-123', - forkSyncExcluded: true, - currentForkSyncExcluded: false, - }) - ) + it('projects unknown update failures safely', async () => { + mocks.parseRequest.mockResolvedValue({ + success: true, + data: { params: { id: 'workflow-1' }, body: { name: 'Renamed' } }, }) + mocks.updateWorkflow.mockRejectedValueOnce(new Error('postgres password=secret')) - it('should skip the mutability check for an exclusion-only update (locked workflow stays togglable)', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - locked: true, - forkSyncExcluded: false, - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'admin', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify({ forkSyncExcluded: true }), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(200) - expect(workflowAuthzMockFns.mockAssertWorkflowMutable).not.toHaveBeenCalled() + const response = await PUT(createMockRequest('PUT', { name: 'Renamed' }), { + params: Promise.resolve({ id: 'workflow-1' }), }) - }) - - describe('Error handling', () => { - it.concurrent('should handle database errors gracefully', async () => { - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockRejectedValue(new Error('Database connection timeout')) - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await GET(req, { params }) - - expect(response.status).toBe(500) - const data = await response.json() - expect(data.error).toBe('Internal server error') - }) + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ error: 'Internal server error' }) }) }) diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index ec30ce24829..0cbb072b5a1 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -1,359 +1,157 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' import { - assertFolderMutable, - assertWorkflowMutable, - authorizeWorkflowByWorkspacePermission, - FolderLockedError, - WorkflowLockedError, -} from '@sim/platform-authz/workflow' -import { type NextRequest, NextResponse } from 'next/server' -import { updateWorkflowContract } from '@/lib/api/contracts/workflows' + deleteWorkflowContract, + getWorkflowResponseDataSchema, + getWorkflowStateContract, + updateWorkflowContract, +} from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' -import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' +import { + defineInternalJsonRoute, + InternalUnauthenticatedError, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' -import { loadWorkflowReadSnapshot } from '@/lib/workflows/queries' -import { getWorkflowById } from '@/lib/workflows/utils' +import { internalWorkflowSessionOrExecutorAuth } from '@/lib/workflows/api' +import { deleteWorkflow } from '@/lib/workflows/application/delete-workflow' +import { readWorkflowDefinition } from '@/lib/workflows/application/read-workflow-definition' +import { updateWorkflow, updateWorkflowPolicy } from '@/lib/workflows/application/update-workflow' const logger = createLogger('WorkflowByIdAPI') -/** - * GET /api/workflows/[id] - * Fetch a single workflow by ID - * Uses hybrid approach: try normalized tables first, fallback to JSON blob - */ -export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const startTime = Date.now() - const { id: workflowId } = await params - - try { - const auth = await checkHybridAuth(request, { requireWorkflowId: false }) - if (!auth.success) { - logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const isInternalCall = auth.authType === AuthType.INTERNAL_JWT - const userId = auth.userId || null - - let workflowData = await getWorkflowById(workflowId) - - if (!workflowData) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflowData.workspaceId) { - return NextResponse.json( - { error: 'API key is not authorized for this workspace' }, - { status: 403 } - ) - } - - if (isInternalCall && !userId) { - // Internal system calls (e.g. workflow-in-workflow executor) may not carry a userId. - // These are already authenticated via internal JWT; allow read access. - logger.info(`[${requestId}] Internal API call for workflow ${workflowId}`) - } else if (!userId) { - logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } else { - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'read', - }) - if (!authorization.workflow) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - workflowData = authorization.workflow - if (!authorization.allowed) { - logger.warn(`[${requestId}] User ${userId} denied access to workflow ${workflowId}`) - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status } - ) - } - } - - const snapshot = await loadWorkflowReadSnapshot(workflowId) - const responseWorkflowData = snapshot.workflowRecord ?? workflowData - - // Stamp `workflowId` from the path param on each variable so the - // global client-side variables store can filter by workflow without - // requiring persisted variables to carry a redundant `workflowId`. - // The persisted blob may or may not include `workflowId` depending on - // when the variable was last written; the path param is authoritative. - const persistedVariables = - (responseWorkflowData.variables as Record<string, Record<string, unknown>>) || {} - const stampedVariables: Record<string, Record<string, unknown>> = {} - for (const [variableId, variable] of Object.entries(persistedVariables)) { - if (variable && typeof variable === 'object') { - stampedVariables[variableId] = { ...variable, workflowId } - } - } - const workflowStateMetadata = { - name: responseWorkflowData.name, - ...(typeof responseWorkflowData.description === 'string' - ? { description: responseWorkflowData.description } - : {}), - } - - if (snapshot.normalizedData) { - const finalWorkflowData = { - ...responseWorkflowData, - state: { - blocks: snapshot.normalizedData.blocks, - edges: snapshot.normalizedData.edges, - loops: snapshot.normalizedData.loops, - parallels: snapshot.normalizedData.parallels, - lastSaved: Date.now(), - isDeployed: responseWorkflowData.isDeployed || false, - deployedAt: responseWorkflowData.deployedAt, - metadata: workflowStateMetadata, - }, - variables: stampedVariables, - } - - logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`) - const elapsed = Date.now() - startTime - logger.info(`[${requestId}] Successfully fetched workflow ${workflowId} in ${elapsed}ms`) - - return NextResponse.json({ data: finalWorkflowData }, { status: 200 }) +const workflowInternalRateLimit = internalRateLimits.none({ + reason: 'Preserve existing internal workflow CRUD behavior', +}) + +export const GET = defineInternalJsonRoute({ + contract: getWorkflowStateContract, + auth: internalWorkflowSessionOrExecutorAuth, + operation: readWorkflowDefinition.operation, + rateLimit: workflowInternalRateLimit, + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ workflowId: params.id, state: 'draft' as const }), + useCase: readWorkflowDefinition, + present: ({ workflow: workflowData, state }) => { + const persistedVariables = + (workflowData.variables as Record<string, Record<string, unknown>>) || {} + const stampedVariables: Record<string, Record<string, unknown>> = {} + for (const [variableId, variable] of Object.entries(persistedVariables)) { + if (variable && typeof variable === 'object') { + stampedVariables[variableId] = { ...variable, workflowId: workflowData.id } } - - const emptyWorkflowData = { - ...responseWorkflowData, + } + const workflowStateMetadata = { + name: workflowData.name, + ...(typeof workflowData.description === 'string' + ? { description: workflowData.description } + : {}), + } + return { + data: getWorkflowResponseDataSchema.parse({ + ...workflowData, state: { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, + blocks: state?.blocks ?? {}, + edges: state?.edges ?? [], + loops: state?.loops ?? {}, + parallels: state?.parallels ?? {}, lastSaved: Date.now(), - isDeployed: responseWorkflowData.isDeployed || false, - deployedAt: responseWorkflowData.deployedAt, + isDeployed: workflowData.isDeployed || false, + deployedAt: workflowData.deployedAt, metadata: workflowStateMetadata, }, variables: stampedVariables, - } - - return NextResponse.json({ data: emptyWorkflowData }, { status: 200 }) - } catch (error: any) { - const elapsed = Date.now() - startTime - logger.error(`[${requestId}] Error fetching workflow ${workflowId} after ${elapsed}ms`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) - -/** - * DELETE /api/workflows/[id] - * Delete a workflow by ID - */ -export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const startTime = Date.now() - const { id: workflowId } = await params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized deletion attempt for workflow ${workflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = auth.userId - - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'write', - }) - const workflowData = authorization.workflow || (await getWorkflowById(workflowId)) - - if (!workflowData) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found for deletion`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - const canDelete = authorization.allowed - - if (!canDelete) { - logger.warn( - `[${requestId}] User ${userId} denied permission to delete workflow ${workflowId}` - ) - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status || 403 } - ) - } - - await assertWorkflowMutable(workflowId) - - const result = await performDeleteWorkflow({ - workflowId, - userId, - requestId, - }) - - if (!result.success) { - const status = - result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 - return NextResponse.json({ error: result.error }, { status }) - } - - captureServerEvent( - userId, - 'workflow_deleted', - { workflow_id: workflowId, workspace_id: workflowData.workspaceId ?? '' }, - workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined - ) - - const elapsed = Date.now() - startTime - logger.info(`[${requestId}] Successfully archived workflow ${workflowId} in ${elapsed}ms`) - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error: any) { - if (error instanceof WorkflowLockedError) { - return NextResponse.json({ error: error.message }, { status: error.status }) - } - - const elapsed = Date.now() - startTime - logger.error(`[${requestId}] Error deleting workflow ${workflowId} after ${elapsed}ms`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + }), } - } -) + }, + onSuccess: ({ result }) => { + logger.info('Successfully fetched workflow', { workflowId: result.workflow.id }) + }, +}) + +export const DELETE = defineInternalJsonRoute({ + contract: deleteWorkflowContract, + auth: internalWorkflowSessionOrExecutorAuth, + operation: deleteWorkflow.operation, + rateLimit: workflowInternalRateLimit, + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ workflowId: params.id }), + useCase: deleteWorkflow, + present: () => ({ success: true as const }), + onSuccess: ({ principal, result }) => { + if (principal.kind !== 'session' || !result.archived) return + captureServerEvent( + principal.userId, + 'workflow_deleted', + { workflow_id: result.workflowId, workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } + ) + }, +}) -/** - * PUT /api/workflows/[id] - * Update workflow metadata (name, description, folderId) - */ export const PUT = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const startTime = Date.now() - const { id: workflowId } = await context.params - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized update attempt for workflow ${workflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = auth.userId - - const parsed = await parseRequest(updateWorkflowContract, request, context) - if (!parsed.success) return parsed.response - const updates = parsed.data.body - - // Fetch the workflow to check ownership/access - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'write', + const rawParams = await context.params + const principal = await internalWorkflowSessionOrExecutorAuth.authenticate(request, rawParams) + const parsed = await parseRequest(updateWorkflowContract, request, { + params: Promise.resolve(rawParams), }) - const workflowData = authorization.workflow || (await getWorkflowById(workflowId)) - - if (!workflowData) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found for update`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - const canUpdate = authorization.allowed - - if (!canUpdate) { - logger.warn( - `[${requestId}] User ${userId} denied permission to update workflow ${workflowId}` - ) - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status || 403 } - ) - } + if (!parsed.success) return parsed.response - if (updates.locked !== undefined && authorization.workspacePermission !== 'admin') { - logger.warn( - `[${requestId}] User ${userId} denied permission to lock workflow ${workflowId}` - ) - return NextResponse.json( - { error: 'Admin access required to lock workflows' }, - { status: 403 } + const input = { workflowId: parsed.data.params.id, ...parsed.data.body } + const isPolicyUpdate = input.locked !== undefined || input.forkSyncExcluded !== undefined + const result = isPolicyUpdate + ? await updateWorkflowPolicy.execute({ principal, input, request }) + : await updateWorkflow.execute({ principal, input, request }) + + if (principal.kind === 'session' && result.changes.includes('locked')) { + captureServerEvent( + principal.userId, + 'workflow_lock_toggled', + { + workflow_id: result.workflow.id, + workspace_id: result.workspaceId, + locked: result.workflow.locked === true, + }, + { groups: { workspace: result.workspaceId } } ) } - - if (updates.forkSyncExcluded !== undefined && authorization.workspacePermission !== 'admin') { - logger.warn( - `[${requestId}] User ${userId} denied permission to change sync exclusion for workflow ${workflowId}` - ) - return NextResponse.json( - { error: 'Admin access required to exclude workflows from sync' }, - { status: 403 } + if (principal.kind === 'session' && result.changes.includes('forkSyncExcluded')) { + captureServerEvent( + principal.userId, + 'workflow_fork_sync_exclusion_toggled', + { + workflow_id: result.workflow.id, + workspace_id: result.workspaceId, + fork_sync_excluded: result.workflow.forkSyncExcluded === true, + }, + { groups: { workspace: result.workspaceId } } ) } - // Policy flags (lock, sync exclusion) don't modify content, so a locked workflow - // may still have them toggled; everything else requires mutability. - const hasNonPolicyUpdate = Object.keys(updates).some( - (key) => key !== 'locked' && key !== 'forkSyncExcluded' - ) - if (hasNonPolicyUpdate) { - await assertWorkflowMutable(workflowId) - } - if (updates.folderId !== undefined) { - await assertFolderMutable(updates.folderId) - } - - if (!workflowData.workspaceId) { - logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - - const result = await performUpdateWorkflow({ - workflowId, - userId, - workspaceId: workflowData.workspaceId, - currentName: workflowData.name, - currentFolderId: workflowData.folderId, - currentLocked: workflowData.locked, - currentForkSyncExcluded: workflowData.forkSyncExcluded, - ...updates, - requestId, + logger.info('Successfully updated workflow', { + workflowId: result.workflow.id, + changes: result.changes, }) - - if (!result.success || !result.workflow) { - const status = - result.errorCode === 'not_found' - ? 404 - : result.errorCode === 'conflict' - ? 409 - : result.errorCode === 'validation' - ? 400 - : 500 - return NextResponse.json({ error: result.error }, { status }) + return NextResponse.json({ workflow: result.workflow }) + } catch (error) { + if (error instanceof InternalUnauthenticatedError) { + return NextResponse.json({ error: error.message }, { status: 401 }) } - - const elapsed = Date.now() - startTime - logger.info(`[${requestId}] Successfully updated workflow ${workflowId} in ${elapsed}ms`, { - updates, - }) - - return NextResponse.json({ workflow: result.workflow }, { status: 200 }) - } catch (error: any) { - if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { - return NextResponse.json({ error: error.message }, { status: error.status }) + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + return NextResponse.json( + { error: orchestrationError.message }, + { status: statusForOrchestrationError(orchestrationError.code) } + ) } - - const elapsed = Date.now() - startTime - logger.error(`[${requestId}] Error updating workflow ${workflowId} after ${elapsed}ms`, error) + logger.error('Failed to update workflow', { error: getErrorMessage(error) }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/sim/app/api/workflows/[id]/status/route.ts b/apps/sim/app/api/workflows/[id]/status/route.ts index 4c1d56357d2..4c7a1860906 100644 --- a/apps/sim/app/api/workflows/[id]/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/status/route.ts @@ -1,45 +1,24 @@ -import { createLogger } from '@sim/logger' -import type { NextRequest } from 'next/server' import { getWorkflowStatusContract } from '@/lib/api/contracts/workflows' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { validateWorkflowAccess } from '@/app/api/workflows/middleware' -import { - checkNeedsRedeployment, - createErrorResponse, - createSuccessResponse, -} from '@/app/api/workflows/utils' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { createInternalWorkflowErrorPolicy, internalWorkflowReadAuth } from '@/lib/workflows/api' +import { readWorkflowDeploymentStatus } from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' -const logger = createLogger('WorkflowStatusAPI') - -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const parsed = await parseRequest(getWorkflowStatusContract, request, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - - try { - const validation = await validateWorkflowAccess(request, id, false) - if (validation.error) { - logger.warn(`[${requestId}] Workflow access validation failed: ${validation.error.message}`) - return createErrorResponse(validation.error.message, validation.error.status) - } - - const needsRedeployment = validation.workflow.isDeployed - ? await checkNeedsRedeployment(id) - : false - - return createSuccessResponse({ - isDeployed: validation.workflow.isDeployed, - deployedAt: validation.workflow.deployedAt, - isPublished: validation.workflow.isPublished, - needsRedeployment, - }) - } catch (error) { - logger.error(`[${requestId}] Error getting status for workflow: ${id}`, error) - return createErrorResponse('Failed to get status', 500) - } - } -) +export const GET = defineInternalJsonRoute({ + contract: getWorkflowStatusContract, + auth: internalWorkflowReadAuth, + operation: workflowOperations.read, + rateLimit: internalRateLimits.none({ + reason: 'Workflow status retains its existing authenticated admission policy.', + }), + errorPolicy: createInternalWorkflowErrorPolicy('Failed to get status'), + mapInput: ({ params }) => ({ workflowId: params.id }), + useCase: readWorkflowDeploymentStatus, + present: (result) => ({ + isDeployed: result.isDeployed, + deployedAt: result.activeDeployment?.deployedAt + ? new Date(result.activeDeployment.deployedAt) + : result.workflow.deployedAt, + needsRedeployment: result.needsRedeployment, + }), +}) diff --git a/apps/sim/app/api/workflows/utils.ts b/apps/sim/app/api/workflows/utils.ts index d966621a67c..a6646d39505 100644 --- a/apps/sim/app/api/workflows/utils.ts +++ b/apps/sim/app/api/workflows/utils.ts @@ -1,11 +1,6 @@ -import { db, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, desc, eq, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' -import { hasWorkflowChanged } from '@/lib/workflows/comparison' -import { loadWorkflowDeploymentSnapshot } from '@/lib/workflows/persistence/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowUtils') @@ -23,47 +18,6 @@ export function createSuccessResponse(data: any) { return NextResponse.json(data) } -/** - * Checks whether a deployed workflow has changes that require redeployment. - * Compares the current persisted state (from normalized tables) against the - * active deployment version state. - * - * This is the single source of truth for redeployment detection — used by - * both the /deploy and /status endpoints to ensure consistent results. - */ -/** - * Pure redeployment-change comparison shared by checkNeedsRedeployment and the - * VFS deployment serializer so both surfaces agree. Returns false when either - * side is missing. - */ -export function computeNeedsRedeployment( - currentSnapshot: WorkflowState | null | undefined, - activeState: WorkflowState | null | undefined -): boolean { - if (!activeState || !currentSnapshot) return false - return hasWorkflowChanged(currentSnapshot, activeState) -} - -export async function checkNeedsRedeployment(workflowId: string): Promise<boolean> { - return db.transaction(async (tx) => { - await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`) - const [active] = await tx - .select({ state: workflowDeploymentVersion.state }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .orderBy(desc(workflowDeploymentVersion.createdAt)) - .limit(1) - - const currentState = await loadWorkflowDeploymentSnapshot(workflowId, tx) - return computeNeedsRedeployment(currentState, (active?.state as WorkflowState) ?? null) - }) -} - /** * Verifies user's workspace permissions using the permissions table * @param userId User ID to check diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index ea7dfcc04d3..31cfe2bc7c8 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -43,6 +43,7 @@ const { mockSetExecutionDeadlineAt, mockSetTraceLargeValueAccess, mockDispose, + mockBuildExecutorDelegationHeaders, executorOptions, loggingSessionArgs, } = vi.hoisted(() => ({ @@ -62,6 +63,7 @@ const { mockSetExecutionDeadlineAt: vi.fn(), mockSetTraceLargeValueAccess: vi.fn(), mockDispose: vi.fn(), + mockBuildExecutorDelegationHeaders: vi.fn(), executorOptions: [] as Array<Record<string, any>>, loggingSessionArgs: [] as Array<any[]>, })) @@ -182,7 +184,7 @@ vi.mock('@/lib/auth/internal', () => ({ })) vi.mock('@/executor/utils/http', () => ({ - buildAuthHeaders: vi.fn().mockResolvedValue({ 'Content-Type': 'application/json' }), + buildExecutorDelegationHeaders: mockBuildExecutorDelegationHeaders, buildAPIUrl: vi.fn((path: string) => new URL(path, 'http://localhost:3000')), extractAPIErrorMessage: vi.fn(async (response: Response) => { const defaultMessage = `API request failed with status ${response.status}` @@ -226,6 +228,7 @@ describe('WorkflowBlockHandler', () => { mockContext = { workflowId: 'parent-workflow-id', + userId: 'user-1', blockStates: new Map(), blockLogs: [], metadata: { duration: 0 }, @@ -250,6 +253,10 @@ describe('WorkflowBlockHandler', () => { mockSafeStart.mockResolvedValue(true) mockAdmitCustomBlockChildExecution.mockResolvedValue(undefined) mockBuildTraceSpans.mockReturnValue({ traceSpans: [], totalDuration: 0 }) + mockBuildExecutorDelegationHeaders.mockResolvedValue({ + 'Content-Type': 'application/json', + Authorization: 'Bearer executor-token', + }) // Setup default fetch mock mockFetch.mockResolvedValue({ @@ -342,7 +349,11 @@ describe('WorkflowBlockHandler', () => { const inputs = { workflowId: 'child-workflow-id' } it('should fail a cross-workspace child in the draft loader path', async () => { - const ctx = { ...mockContext, workspaceId: 'workspace-parent' } + const ctx = { + ...mockContext, + workspaceId: 'workspace-parent', + executionId: 'parent-execution-id', + } mockFetch.mockResolvedValueOnce({ ok: true, @@ -361,6 +372,11 @@ describe('WorkflowBlockHandler', () => { ) expect(mockCreateSnapshot).not.toHaveBeenCalled() expect(mockExecutorExecute).not.toHaveBeenCalled() + expect(mockBuildExecutorDelegationHeaders).toHaveBeenCalledWith({ + subjectUserId: 'user-1', + workflowId: 'parent-workflow-id', + executionId: 'parent-execution-id', + }) }) it('should fail a cross-workspace child in the deployed loader path', async () => { @@ -554,6 +570,10 @@ describe('WorkflowBlockHandler', () => { await handler.execute(ctx, customBlock, {}) + expect(mockBuildExecutorDelegationHeaders).toHaveBeenCalledWith({ + subjectUserId: 'owner-9', + workflowId: 'source-workflow-id', + }) expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ actorUserId: 'owner-9', workspaceId: 'workspace-source', @@ -991,7 +1011,7 @@ describe('WorkflowBlockHandler', () => { text: () => Promise.resolve(''), }) - const result = await (handler as any).loadChildWorkflow(workflowId) + const result = await (handler as any).loadChildWorkflow(workflowId, {}) expect(result).toBeNull() }) @@ -1010,7 +1030,7 @@ describe('WorkflowBlockHandler', () => { }), }) - await expect((handler as any).loadChildWorkflow(workflowId)).rejects.toThrow( + await expect((handler as any).loadChildWorkflow(workflowId, {})).rejects.toThrow( 'Child workflow invalid-workflow has invalid state' ) }) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index c3cd00b84d6..ab10a3f1aa7 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -42,7 +42,7 @@ import { type StreamingExecution, } from '@/executor/types' import { hasExecutionResult } from '@/executor/utils/errors' -import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http' +import { buildAPIUrl, buildExecutorDelegationHeaders } from '@/executor/utils/http' import { getIterationContext } from '@/executor/utils/iteration-context' import { parseJSON } from '@/executor/utils/json' import { lazyCleanupInputMapping } from '@/executor/utils/lazy-cleanup' @@ -281,12 +281,21 @@ export class WorkflowBlockHandler implements BlockHandler { /** Settled in `finally` once the child is fully done — see `trackChildRun`. */ let settleChildRun: (() => void) | undefined try { + if (!loadUserId) { + throw new Error('Workflow child loading requires a human execution subject') + } + const workflowReadHeaders = await buildExecutorDelegationHeaders({ + subjectUserId: loadUserId, + workflowId: isCustomBlock ? workflowId : ctx.workflowId, + ...(!isCustomBlock && ctx.executionId ? { executionId: ctx.executionId } : {}), + }) + // A custom block runs the source's latest deployment; if the source has been // undeployed there's nothing to run. `BoundarySafeError` marks the message as // safe to cross the invocation boundary verbatim (it names no source // internals), so the catch forwards it instead of the generic failure. if (isCustomBlock) { - const deployed = await this.checkChildDeployment(workflowId, loadUserId) + const deployed = await this.checkChildDeployment(workflowId, workflowReadHeaders) if (!deployed) { throw new BoundarySafeError({ errorType: 'not_deployed', @@ -296,7 +305,7 @@ export class WorkflowBlockHandler implements BlockHandler { } if (useDeployed && !isCustomBlock) { - const hasActiveDeployment = await this.checkChildDeployment(workflowId, loadUserId) + const hasActiveDeployment = await this.checkChildDeployment(workflowId, workflowReadHeaders) if (!hasActiveDeployment) { throw new Error( `Child workflow is not deployed. Please deploy the workflow before invoking it.` @@ -305,8 +314,8 @@ export class WorkflowBlockHandler implements BlockHandler { } const childWorkflow = useDeployed - ? await this.loadChildWorkflowDeployed(workflowId, loadUserId) - : await this.loadChildWorkflow(workflowId, ctx.userId) + ? await this.loadChildWorkflowDeployed(workflowId, workflowReadHeaders) + : await this.loadChildWorkflow(workflowId, workflowReadHeaders) if (!childWorkflow) { throw new Error(`Child workflow ${workflowId} not found`) @@ -950,8 +959,7 @@ export class WorkflowBlockHandler implements BlockHandler { } } - private async loadChildWorkflow(workflowId: string, userId?: string) { - const headers = await buildAuthHeaders(userId) + private async loadChildWorkflow(workflowId: string, headers: Record<string, string>) { const url = buildAPIUrl(`/api/workflows/${workflowId}`) const response = await fetch(url.toString(), { headers }) @@ -1012,9 +1020,11 @@ export class WorkflowBlockHandler implements BlockHandler { } } - private async checkChildDeployment(workflowId: string, userId?: string): Promise<boolean> { + private async checkChildDeployment( + workflowId: string, + headers: Record<string, string> + ): Promise<boolean> { try { - const headers = await buildAuthHeaders(userId) const url = buildAPIUrl(`/api/workflows/${workflowId}/deployed`) const response = await fetch(url.toString(), { @@ -1035,8 +1045,7 @@ export class WorkflowBlockHandler implements BlockHandler { } } - private async loadChildWorkflowDeployed(workflowId: string, userId?: string) { - const headers = await buildAuthHeaders(userId) + private async loadChildWorkflowDeployed(workflowId: string, headers: Record<string, string>) { const deployedUrl = buildAPIUrl(`/api/workflows/${workflowId}/deployed`) const deployedRes = await fetch(deployedUrl.toString(), { diff --git a/apps/sim/lib/api-key/application/create-api-key.ts b/apps/sim/lib/api-key/application/create-api-key.ts new file mode 100644 index 00000000000..cfd659b1174 --- /dev/null +++ b/apps/sim/lib/api-key/application/create-api-key.ts @@ -0,0 +1,55 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { apiKeyOperations } from '@/lib/api-key/application/operations' +import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface CreateCopilotWorkspaceApiKeyInput { + workspaceId: string + name: string +} + +export const createCopilotWorkspaceApiKey = defineAuthorizedWorkspaceUseCase({ + operation: apiKeyOperations.createFromCopilot, + resolveContext: async ({ input }: { input: CreateCopilotWorkspaceApiKeyInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: { + delegation: { + audience: 'sim:api-keys', + isWithinScope: (principal, context) => principal.workspaceId === context.workspaceId, + }, + }, + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await performCreateWorkspaceApiKey({ + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + name: input.name, + source: 'copilot', + projectLegacyAudit: false, + captureAnalytics: false, + }) + if (!result.success || !result.key) { + if (result.errorCode === 'conflict') { + throw new OrchestrationError('conflict', result.error ?? 'API key name already exists') + } + throw new Error('Failed to create workspace API key') + } + return { key: result.key, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.API_KEY_CREATED, + resourceType: AuditResourceType.API_KEY, + resourceId: result.key.id, + resourceName: result.key.name, + description: `Created API key "${result.key.name}"`, + metadata: { keyName: result.key.name, keyType: 'workspace', source: 'copilot' }, + }), +}) diff --git a/apps/sim/lib/api-key/application/operations.ts b/apps/sim/lib/api-key/application/operations.ts new file mode 100644 index 00000000000..7494ae377ef --- /dev/null +++ b/apps/sim/lib/api-key/application/operations.ts @@ -0,0 +1,13 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +export const apiKeyOperations = { + createFromCopilot: defineWorkspaceOperation({ + id: 'api_keys.copilot.create', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), +} as const + +export type ApiKeyOperation = (typeof apiKeyOperations)[keyof typeof apiKeyOperations] diff --git a/apps/sim/lib/api-key/orchestration/index.ts b/apps/sim/lib/api-key/orchestration/index.ts index 934766fa4e4..f285bf0fe3f 100644 --- a/apps/sim/lib/api-key/orchestration/index.ts +++ b/apps/sim/lib/api-key/orchestration/index.ts @@ -15,6 +15,8 @@ export interface PerformCreateWorkspaceApiKeyParams { source?: string actorName?: string | null actorEmail?: string | null + projectLegacyAudit?: boolean + captureAnalytics?: boolean } export interface PerformCreateWorkspaceApiKeyResult { @@ -39,12 +41,14 @@ export async function performCreateWorkspaceApiKey( name: params.name, }) - try { - PlatformEvents.apiKeyGenerated({ - userId: params.userId, - keyName: params.name, - }) - } catch {} + if (params.captureAnalytics !== false) { + try { + PlatformEvents.apiKeyGenerated({ + userId: params.userId, + keyName: params.name, + }) + } catch {} + } logger.info('Created workspace API key', { workspaceId: params.workspaceId, @@ -52,22 +56,23 @@ export async function performCreateWorkspaceApiKey( name: params.name, }) - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.API_KEY_CREATED, - resourceType: AuditResourceType.API_KEY, - resourceId: key.id, - resourceName: params.name, - description: `Created API key "${params.name}"`, - metadata: { - keyName: params.name, - keyType: 'workspace', - source: params.source ?? 'settings', - }, - }) + if (params.projectLegacyAudit !== false) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.API_KEY_CREATED, + resourceType: AuditResourceType.API_KEY, + resourceId: key.id, + resourceName: params.name, + description: `Created API key "${params.name}"`, + metadata: { + keyName: params.name, + keyType: 'workspace', + source: params.source ?? 'settings', + }, + }) return { success: true, key } } catch (error) { diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts index f75fe366a79..03951db9c67 100644 --- a/apps/sim/lib/api/server/routes/index.ts +++ b/apps/sim/lib/api/server/routes/index.ts @@ -16,6 +16,7 @@ export { export { defineV2BinaryRoute } from '@/lib/api/server/routes/v2-binary-route' export { defineV2BodyLifecycleRoute } from '@/lib/api/server/routes/v2-body-lifecycle-route' export { + admitOptionalV2Request, admitV2Request, defineV2JsonRoute, type V2ErrorPolicy, 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 283a1c6d64b..c750be3c1a2 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -110,27 +110,26 @@ export const v2OrchestrationErrorPolicy = { }, } satisfies V2ErrorPolicy -export async function admitV2Request( - request: NextRequest, - operation: ApplicationOperation, - authPolicy: typeof v2ApiKeyAuth, - rateLimitPolicy: V2RateLimitPolicy -): Promise< - { success: true; auth: V2ApiKeyAuthContext } | { success: false; response: NextResponse } -> { +async function enforceV2PreAuthIpLimit(request: NextRequest): Promise<NextResponse | null> { const ip = getClientIp(request) const abuseLimit = await rateLimiter.checkRateLimitDirect( `v2:preauth:ip:${ip}`, V2_PREAUTH_IP_LIMIT, { failClosed: true } ) - if (!abuseLimit.allowed) { - return { - success: false, - response: v2RateLimitError({ ...abuseLimit, limit: V2_PREAUTH_IP_LIMIT.maxTokens }), - } - } + return abuseLimit.allowed + ? null + : v2RateLimitError({ ...abuseLimit, limit: V2_PREAUTH_IP_LIMIT.maxTokens }) +} +async function admitAuthenticatedV2Request( + request: NextRequest, + operation: ApplicationOperation, + authPolicy: typeof v2ApiKeyAuth, + rateLimitPolicy: V2RateLimitPolicy +): Promise< + { success: true; auth: V2ApiKeyAuthContext } | { success: false; response: NextResponse } +> { let auth: V2ApiKeyAuthContext try { auth = await authPolicy.authenticate(request) @@ -153,6 +152,33 @@ export async function admitV2Request( return limited ? { success: false, response: limited } : { success: true, auth } } +export async function admitV2Request( + request: NextRequest, + operation: ApplicationOperation, + authPolicy: typeof v2ApiKeyAuth, + rateLimitPolicy: V2RateLimitPolicy +): Promise< + { success: true; auth: V2ApiKeyAuthContext } | { success: false; response: NextResponse } +> { + const preAuthResponse = await enforceV2PreAuthIpLimit(request) + if (preAuthResponse) return { success: false, response: preAuthResponse } + return admitAuthenticatedV2Request(request, operation, authPolicy, rateLimitPolicy) +} + +export async function admitOptionalV2Request( + request: NextRequest, + operation: ApplicationOperation, + authPolicy: typeof v2ApiKeyAuth, + rateLimitPolicy: V2RateLimitPolicy +): Promise< + { success: true; auth?: V2ApiKeyAuthContext } | { success: false; response: NextResponse } +> { + const preAuthResponse = await enforceV2PreAuthIpLimit(request) + if (preAuthResponse) return { success: false, response: preAuthResponse } + if (!request.headers.has('x-api-key')) return { success: true } + return admitAuthenticatedV2Request(request, operation, authPolicy, rateLimitPolicy) +} + interface V2JsonRouteOptions<C extends JsonApiRouteContract, O extends ApplicationOperation, I, R> extends JsonRouteDefinition<C, O, I, R> { auth: typeof v2ApiKeyAuth diff --git a/apps/sim/lib/copilot/application/execute-api-key-use-case.ts b/apps/sim/lib/copilot/application/execute-api-key-use-case.ts new file mode 100644 index 00000000000..d1ec9402336 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-api-key-use-case.ts @@ -0,0 +1,13 @@ +import { apiKeyOperations } from '@/lib/api-key/application/operations' +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' + +export const executeCopilotApiKeyUseCase = createCopilotApplicationAdapter({ + domain: 'API key', + delegation: { + audience: 'sim:api-keys', + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, + operations: apiKeyOperations, +}) diff --git a/apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts b/apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts index f453b071688..7905253ac0d 100644 --- a/apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts +++ b/apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts @@ -9,10 +9,16 @@ vi.mock('@/lib/workflows/application/resolve-workflow-outputs', () => ({ resolveWorkflowOutputs: { execute: mocks.execute }, })) -import { executeCopilotResolveWorkflowOutputs } from '@/lib/copilot/application/execute-workflow-use-case' +import { + executeCopilotResolveWorkflowOutputs, + executeCopilotWorkflowUseCase, + messageForCopilotWorkflowError, +} from '@/lib/copilot/application/execute-workflow-use-case' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { workflowOperations } from '@/lib/workflows/application/operations' const trustedContext = { - userId: 'user-1', + userId: 'trusted-user', workspaceId: 'workspace-1', chatId: 'chat-1', executionId: 'execution-1', @@ -20,7 +26,7 @@ const trustedContext = { copilotToolExecution: true, } as const -describe('executeCopilotResolveWorkflowOutputs', () => { +describe('Copilot Workflow application adapter', () => { afterEach(() => { vi.clearAllMocks() vi.useRealTimers() @@ -46,7 +52,7 @@ describe('executeCopilotResolveWorkflowOutputs', () => { principal: { kind: 'delegated', serviceId: 'copilot', - subjectUserId: 'user-1', + subjectUserId: 'trusted-user', workspaceId: 'workspace-1', delegationId: 'copilot-tool:tool-call-1', audience: 'sim:workflows', @@ -58,7 +64,69 @@ describe('executeCopilotResolveWorkflowOutputs', () => { }) }) - it('rejects untrusted context before Workflow application execution', () => { + it('derives identity only from trusted adapter context', async () => { + const execute = vi.fn().mockResolvedValue({ ok: true }) + const useCase = { operation: workflowOperations.update, execute } + + await expect( + executeCopilotWorkflowUseCase(trustedContext, useCase, { + workflowId: 'workflow-1', + userId: 'forged-user', + }) + ).resolves.toEqual({ ok: true }) + + expect(execute).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + subjectUserId: 'trusted-user', + workspaceId: 'workspace-1', + audience: 'sim:workflows', + resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, + }), + input: { workflowId: 'workflow-1', userId: 'forged-user' }, + }) + }) + + it('supports workspace-scoped operations without inventing workflow scope', async () => { + const execute = vi.fn().mockResolvedValue({ ok: true }) + await executeCopilotWorkflowUseCase( + trustedContext, + { operation: workflowOperations.create, execute }, + { workspaceId: 'workspace-1', name: 'New workflow' } + ) + + expect(execute).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, + }), + input: { workspaceId: 'workspace-1', name: 'New workflow' }, + }) + }) + + it('rejects forged contexts and unregistered operations before execution', () => { + const execute = vi.fn() + expect(() => + executeCopilotWorkflowUseCase( + { ...trustedContext, copilotToolExecution: false }, + { operation: workflowOperations.read, execute }, + { workflowId: 'workflow-1' } + ) + ).toThrow('trusted Copilot execution context') + + expect(() => + executeCopilotWorkflowUseCase( + trustedContext, + { + operation: { ...workflowOperations.read, id: 'workflows.unregistered' }, + execute, + }, + { workflowId: 'workflow-1' } + ) + ).toThrow('Unregistered Copilot workflow operation') + expect(execute).not.toHaveBeenCalled() + }) + + it('rejects untrusted context before fixed Workflow application execution', () => { expect(() => executeCopilotResolveWorkflowOutputs( { ...trustedContext, copilotToolExecution: false }, @@ -67,4 +135,13 @@ describe('executeCopilotResolveWorkflowOutputs', () => { ).toThrow('trusted Copilot execution context') expect(mocks.execute).not.toHaveBeenCalled() }) + + it('presents typed application errors and conceals unknown causes', () => { + expect( + messageForCopilotWorkflowError(new OrchestrationError('forbidden', 'Access denied')) + ).toBe('Access denied') + expect(messageForCopilotWorkflowError(new Error('database password'))).toBe( + 'Workflow operation failed' + ) + }) }) diff --git a/apps/sim/lib/copilot/application/execute-workflow-use-case.ts b/apps/sim/lib/copilot/application/execute-workflow-use-case.ts index a91acba9560..706e68bb9e8 100644 --- a/apps/sim/lib/copilot/application/execute-workflow-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-workflow-use-case.ts @@ -1,10 +1,14 @@ +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' import { COPILOT_APPLICATION_DELEGATION_TTL_MS, type CopilotExecutionContext, createCopilotApplicationPrincipal, requireTrustedCopilotExecutionContext, } from '@/lib/copilot/auth/application-delegation' +import type { OperationUseCase } from '@/lib/core/application' import { workflowDelegationPolicy } from '@/lib/workflows/application/authorization' +import { type WorkflowOperation, workflowOperations } from '@/lib/workflows/application/operations' import { type ResolveWorkflowOutputsInput, type ResolveWorkflowOutputsResult, @@ -20,6 +24,21 @@ const workflowDelegation = { `copilot-tool:${context.toolCallId}`, } as const +const executeWorkflowUseCase = createCopilotApplicationAdapter<WorkflowOperation>({ + domain: 'workflow', + delegation: workflowDelegation, + operations: workflowOperations, +}) + +/** Enters a registered workflow use case with identity derived only from trusted tool context. */ +export function executeCopilotWorkflowUseCase<O extends WorkflowOperation, I, R>( + context: CopilotWorkflowDelegationContext | undefined, + useCase: OperationUseCase<O, I, R>, + input: I +): Promise<R> { + return executeWorkflowUseCase(context, useCase, input) +} + /** Resolves workflow output metadata through one fixed authorized Workflow command. */ export function executeCopilotResolveWorkflowOutputs( context: CopilotWorkflowDelegationContext | undefined, @@ -33,3 +52,11 @@ export function executeCopilotResolveWorkflowOutputs( input, }) } + +/** Projects actionable application errors without exposing infrastructure details to the model. */ +export function messageForCopilotWorkflowError( + error: unknown, + fallback = 'Workflow operation failed' +): string { + return messageForCopilotApplicationError(error, fallback) +} diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index e67b7fd1761..8573ac186b9 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -1248,14 +1248,16 @@ export const Cp: ToolCatalogEntry = { properties: { destination: { type: 'string', + maxLength: 4096, description: 'Target path under workflows/. An existing folder (or a path ending in "/") duplicates sources into it keeping their names; otherwise the last segment names the copy and the preceding segments are the target folder (created automatically when missing).', }, sources: { type: 'array', + maxItems: 100, description: 'Canonical workflow VFS paths to duplicate, e.g. ["workflows/My%20Workflow"]. Copy paths verbatim from glob/grep/read output.', - items: { type: 'string' }, + items: { type: 'string', maxLength: 4096 }, }, toolTitle: { type: 'string', @@ -3714,9 +3716,10 @@ export const Mkdir: ToolCatalogEntry = { properties: { paths: { type: 'array', + maxItems: 100, description: 'Canonical folder VFS paths to create, e.g. ["files/Reports/2026"]. Missing parent segments are created automatically.', - items: { type: 'string' }, + items: { type: 'string', maxLength: 4096 }, }, toolTitle: { type: 'string', @@ -3739,14 +3742,16 @@ export const Mv: ToolCatalogEntry = { properties: { destination: { type: 'string', + maxLength: 4096, description: 'Target path. A path ending in "/" (or naming an existing folder) moves sources into it keeping their names — always use the trailing "/" form when targeting a folder. Otherwise the last segment is the new name and the preceding segments are the target folder (created automatically when missing).', }, sources: { type: 'array', + maxItems: 100, description: 'Canonical VFS paths to move or rename, e.g. ["files/draft.md"]. All sources must share one category. Copy paths verbatim from glob/grep/read output.', - items: { type: 'string' }, + items: { type: 'string', maxLength: 4096 }, }, toolTitle: { type: 'string', @@ -4179,9 +4184,10 @@ export const Rm: ToolCatalogEntry = { properties: { paths: { type: 'array', + maxItems: 100, description: 'Canonical VFS paths to delete, e.g. ["files/Reports/draft.md"]. Copy paths verbatim from glob/grep/read output. Paths from different categories may be mixed in one call.', - items: { type: 'string' }, + items: { type: 'string', maxLength: 4096 }, }, toolTitle: { type: 'string', diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 81188db33ab..7c470624386 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1112,15 +1112,18 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = { properties: { destination: { type: 'string', + maxLength: 4096, description: 'Target path under workflows/. An existing folder (or a path ending in "/") duplicates sources into it keeping their names; otherwise the last segment names the copy and the preceding segments are the target folder (created automatically when missing).', }, sources: { type: 'array', + maxItems: 100, description: 'Canonical workflow VFS paths to duplicate, e.g. ["workflows/My%20Workflow"]. Copy paths verbatim from glob/grep/read output.', items: { type: 'string', + maxLength: 4096, }, }, toolTitle: { @@ -3593,10 +3596,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = { properties: { paths: { type: 'array', + maxItems: 100, description: 'Canonical folder VFS paths to create, e.g. ["files/Reports/2026"]. Missing parent segments are created automatically.', items: { type: 'string', + maxLength: 4096, }, }, toolTitle: { @@ -3615,15 +3620,18 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = { properties: { destination: { type: 'string', + maxLength: 4096, description: 'Target path. A path ending in "/" (or naming an existing folder) moves sources into it keeping their names — always use the trailing "/" form when targeting a folder. Otherwise the last segment is the new name and the preceding segments are the target folder (created automatically when missing).', }, sources: { type: 'array', + maxItems: 100, description: 'Canonical VFS paths to move or rename, e.g. ["files/draft.md"]. All sources must share one category. Copy paths verbatim from glob/grep/read output.', items: { type: 'string', + maxLength: 4096, }, }, toolTitle: { @@ -4059,10 +4067,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = { properties: { paths: { type: 'array', + maxItems: 100, description: 'Canonical VFS paths to delete, e.g. ["files/Reports/draft.md"]. Copy paths verbatim from glob/grep/read output. Paths from different categories may be mixed in one call.', items: { type: 'string', + maxLength: 4096, }, }, toolTitle: { diff --git a/apps/sim/lib/copilot/request/tools/workflow-context.test.ts b/apps/sim/lib/copilot/request/tools/workflow-context.test.ts index abcd14d5ef9..379c8c267fd 100644 --- a/apps/sim/lib/copilot/request/tools/workflow-context.test.ts +++ b/apps/sim/lib/copilot/request/tools/workflow-context.test.ts @@ -26,13 +26,13 @@ vi.mock('@/lib/billing/calculations/usage-reservation', () => ({ }, })) +import { applyCreateWorkflowOutputToContext } from '@/lib/copilot/request/tools/workflow-context' +import type { ExecutionContext } from '@/lib/copilot/request/types' import { - applyCreateWorkflowOutputToContext, prepareWorkflowExecutionAdmission, resolveWorkflowExecutionBillingAttribution, WorkflowExecutionAdmissionError, -} from '@/lib/copilot/request/tools/workflow-context' -import type { ExecutionContext } from '@/lib/copilot/request/types' +} from '@/lib/workflows/execution-admission' const billingAttribution: BillingAttributionSnapshot = { actorUserId: 'user-1', diff --git a/apps/sim/lib/copilot/request/tools/workflow-context.ts b/apps/sim/lib/copilot/request/tools/workflow-context.ts index 0e8fa1523d6..cf49411b723 100644 --- a/apps/sim/lib/copilot/request/tools/workflow-context.ts +++ b/apps/sim/lib/copilot/request/tools/workflow-context.ts @@ -1,43 +1,21 @@ import { isRecordLike } from '@sim/utils/object' -import { - reserveExecutionSlot, - UsageReservationUnavailableError, -} from '@/lib/billing/calculations/usage-reservation' -import { - type BillingAttributionSnapshot, - checkAttributedUsageLimits, - resolveBillingAttribution, -} from '@/lib/billing/core/billing-attribution' import type { ExecutionContext } from '@/lib/copilot/request/types' -import { - getReservationDenialDescriptor, - type ReservationDenialReason, -} from '@/lib/core/admission/transient-failure' -import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' function getCreateWorkflowOutput( output: unknown ): { workflowId?: string; workspaceId?: string } | undefined { - if (!isRecordLike(output)) { - return undefined - } + if (!isRecordLike(output)) return undefined const workflowId = typeof output.workflowId === 'string' ? output.workflowId : undefined const workspaceId = typeof output.workspaceId === 'string' ? output.workspaceId : undefined - if (!workflowId && !workspaceId) { - return undefined - } - + if (!workflowId && !workspaceId) return undefined return { ...(workflowId ? { workflowId } : {}), ...(workspaceId ? { workspaceId } : {}), } } -/** - * Adopts a workflow returned by create_workflow only when it belongs to the - * ambient workspace and the Copilot lifecycle is not already workflow-rooted. - */ +/** Adopts a same-workspace workflow created by the current unrooted Copilot lifecycle. */ export function applyCreateWorkflowOutputToContext( output: unknown, context: ExecutionContext @@ -51,136 +29,5 @@ export function applyCreateWorkflowOutputToContext( ) { return } - context.workflowId = createdWorkflow.workflowId } - -/** - * Selects billing for one hosted workflow execution. Same-workspace work - * keeps the root snapshot; cross-workspace work gets a fresh child snapshot - * without mutating or implicitly replacing the root lifecycle attribution. - */ -export async function resolveWorkflowExecutionBillingAttribution( - context: ExecutionContext, - targetWorkspaceId: string -): Promise<BillingAttributionSnapshot | undefined> { - const rootAttribution = context.billingAttribution - if (!rootAttribution) { - return undefined - } - - if (rootAttribution.workspaceId === targetWorkspaceId) { - return rootAttribution - } - - const childAttribution = await resolveBillingAttribution({ - actorUserId: context.userId, - workspaceId: targetWorkspaceId, - }) - if ( - childAttribution.actorUserId !== context.userId || - childAttribution.workspaceId !== targetWorkspaceId - ) { - throw new Error('Resolved workflow billing attribution does not match its actor and workspace') - } - - return childAttribution -} - -export interface WorkflowExecutionAdmission { - billingAttribution: BillingAttributionSnapshot | undefined - targetReservation: boolean -} - -type ReservationDenialDescriptor = ReturnType<typeof getReservationDenialDescriptor> - -export class WorkflowExecutionAdmissionError extends Error { - readonly code: ReservationDenialDescriptor['code'] - readonly statusCode: ReservationDenialDescriptor['statusCode'] - readonly retryable: ReservationDenialDescriptor['retryable'] - - constructor(message: string, descriptor: ReservationDenialDescriptor) { - super(message) - this.name = 'WorkflowExecutionAdmissionError' - this.code = descriptor.code - this.statusCode = descriptor.statusCode - this.retryable = descriptor.retryable - } -} - -const TARGET_RESERVATION_DENIAL_MESSAGE = { - payer_concurrency: 'Target workspace execution concurrency is currently exhausted', - payer_headroom: 'Target workspace payer usage headroom is currently exhausted', - member_headroom: 'Target workspace member usage headroom is currently exhausted', -} as const satisfies Record<ReservationDenialReason, string> - -/** - * Admits one direct Copilot workflow execution. Same-workspace runs reuse the - * root lifecycle admission without another usage read or reservation. - * Cross-workspace runs use their separately frozen target snapshot and perform - * exactly one attributed usage check followed by one atomic reservation. - */ -export async function prepareWorkflowExecutionAdmission( - context: ExecutionContext, - targetWorkspaceId: string, - childExecutionId: string -): Promise<WorkflowExecutionAdmission> { - const billingAttribution = await resolveWorkflowExecutionBillingAttribution( - context, - targetWorkspaceId - ) - const rootAttribution = context.billingAttribution - const isCrossWorkspace = - rootAttribution !== undefined && rootAttribution.workspaceId !== targetWorkspaceId - - if (!billingAttribution || !isCrossWorkspace) { - return { billingAttribution, targetReservation: false } - } - - const usage = await checkAttributedUsageLimits(billingAttribution) - if (usage.isExceeded) { - const descriptor = getReservationDenialDescriptor( - usage.scope === 'member' ? 'member_headroom' : 'payer_headroom' - ) - throw new WorkflowExecutionAdmissionError( - usage.message ?? 'Target workspace usage limit exceeded', - descriptor - ) - } - if (isHosted && isBillingEnabled && !usage.payerUsage) { - throw new UsageReservationUnavailableError( - 'Target workspace usage admission is temporarily unavailable. Please retry.' - ) - } - - const payerUsage = usage.payerUsage ?? { currentUsage: 0, limit: 0 } - const reservation = await reserveExecutionSlot({ - billingEntity: billingAttribution.billingEntity, - executionId: childExecutionId, - plan: billingAttribution.payerSubscription?.plan, - enterpriseConcurrencyLimit: billingAttribution.payerSubscription?.enterpriseConcurrencyLimit, - currentUsage: payerUsage.currentUsage, - limit: payerUsage.limit, - ...(billingAttribution.organizationId && - usage.memberUsage?.limit !== null && - usage.memberUsage?.limit !== undefined - ? { - member: { - organizationId: billingAttribution.organizationId, - actorUserId: billingAttribution.actorUserId, - currentUsage: usage.memberUsage.currentUsage, - limit: usage.memberUsage.limit, - }, - } - : {}), - }) - if (!reservation.reserved) { - const descriptor = getReservationDenialDescriptor(reservation.reason) - throw new WorkflowExecutionAdmissionError( - TARGET_RESERVATION_DENIAL_MESSAGE[reservation.reason], - descriptor - ) - } - - return { billingAttribution, targetReservation: true } -} diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts index 916149e9b04..e37f623a234 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts @@ -51,8 +51,12 @@ vi.mock('@/lib/billing', () => ({ isOrganizationOnEnterprisePlan: isOrganizationOnEnterprisePlanMock, })) -vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ - resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock, +vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({ + resolveCopilotWorkspaceFileReference: resolveWorkspaceFileReferenceMock, + executeCopilotFileUseCase: vi.fn( + async (_context, _useCase, input: { fileId: string; maxBytes: number }) => + readWorkspaceFileContentMock(input) + ), })) vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ readWorkspaceFileContent: { 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 ca51f6bafcd..1e948a911f6 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { isAllowedCustomBlockIconUrl } from '@/lib/api/contracts/custom-blocks' @@ -29,6 +30,7 @@ import type { DeployCustomBlockParams } from '../param-types' const MAX_ICON_BYTES = 5 * 1024 * 1024 const MAX_INPUT_ENTRIES = 50 const MAX_OUTPUT_ENTRIES = 50 +const logger = createLogger('CopilotCustomBlockDeployment') /** * Resolve the agent-supplied icon reference to a publicly servable URL. A VFS @@ -139,7 +141,7 @@ export async function executeDeployCustomBlock( } catch (error) { const message = toError(error).message if (message.includes('not found')) { - return { success: false, error: message } + return { success: false, error: 'Workflow not found' } } return { success: false, @@ -301,6 +303,7 @@ export async function executeDeployCustomBlock( if (error instanceof CustomBlockValidationError) { return { success: false, error: error.message } } - return { success: false, error: toError(error).message } + logger.error('Custom block deployment failed', { error }) + return { success: false, error: 'Custom block deployment failed due to a system error' } } } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts index 3f67f333488..01727a9c9d0 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { resetDbChainMock } from '@sim/testing' +import { getErrorMessage } from '@sim/utils/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -11,6 +12,8 @@ const { mockPerformDeleteWorkflowMcpTool, mockPerformFullDeploy, mockPerformFullUndeploy, + mockExecuteCopilotMcpServerUseCase, + mockExecuteCopilotWorkflowUseCase, } = vi.hoisted(() => ({ mockCheckChatAccess: vi.fn(), mockEnsureWorkflowAccess: vi.fn(), @@ -18,6 +21,18 @@ const { mockPerformDeleteWorkflowMcpTool: vi.fn(), mockPerformFullDeploy: vi.fn(), mockPerformFullUndeploy: vi.fn(), + mockExecuteCopilotMcpServerUseCase: vi.fn(), + mockExecuteCopilotWorkflowUseCase: vi.fn(), +})) + +vi.mock('@/lib/copilot/application/execute-mcp-server-use-case', () => ({ + executeCopilotMcpServerUseCase: mockExecuteCopilotMcpServerUseCase, +})) + +vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ + executeCopilotWorkflowUseCase: mockExecuteCopilotWorkflowUseCase, + messageForCopilotWorkflowError: (error: unknown, fallback: string) => + getErrorMessage(error, fallback), })) vi.mock('@/lib/workflows/orchestration', () => ({ @@ -74,7 +89,7 @@ describe('deployment handlers', () => { }) it('undeploys the API without approval context when permission gating is disabled', async () => { - mockPerformFullUndeploy.mockResolvedValue({ success: true }) + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ success: true }) const result = await executeDeployApi( { workflowId: 'workflow-1', action: 'undeploy' }, @@ -86,14 +101,15 @@ describe('deployment handlers', () => { ) expect(result.success).toBe(true) - expect(mockPerformFullUndeploy).toHaveBeenCalledWith({ - workflowId: 'workflow-1', - userId: 'user-1', - }) + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.objectContaining({ operation: expect.objectContaining({ id: 'workflows.undeploy' }) }), + expect.objectContaining({ workflowId: 'workflow-1' }) + ) }) it('uses the execution and deployment intent for semantic retry idempotency', async () => { - mockPerformFullDeploy.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ success: true, activeDeployment: null, latestDeploymentAttempt: { status: 'preparing' }, @@ -114,7 +130,9 @@ describe('deployment handlers', () => { } ) - expect(mockPerformFullDeploy).toHaveBeenCalledWith( + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ operation: expect.objectContaining({ id: 'workflows.deploy' }) }), expect.objectContaining({ idempotencyKey: 'copilot:execution-1:operation:deploy_api', }) @@ -122,7 +140,7 @@ describe('deployment handlers', () => { }) it('does not report an admitted deployment as successful before its version is active', async () => { - mockPerformFullDeploy.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ success: true, version: 12, deploymentVersionId: 'version-12', @@ -148,7 +166,9 @@ describe('deployment handlers', () => { success: false, error: expect.stringContaining('not active'), }) - expect(mockPerformFullDeploy).toHaveBeenCalledWith( + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ operation: expect.objectContaining({ id: 'workflows.deploy' }) }), expect.objectContaining({ idempotencyKey: 'copilot:execution-1:operation:deploy_api', }) @@ -156,7 +176,7 @@ describe('deployment handlers', () => { }) it('reports success only when the version admitted by this call is active', async () => { - mockPerformFullDeploy.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ success: true, version: 12, deploymentVersionId: 'version-12', @@ -191,7 +211,7 @@ describe('deployment handlers', () => { }) it('rejects a replay whose active deployment attempt became historical', async () => { - mockPerformFullDeploy.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ success: true, activeDeployment: null, latestDeploymentAttempt: { status: 'active', isCurrent: false }, @@ -219,7 +239,7 @@ describe('deployment handlers', () => { }) it('does not report a historical active attempt as a successful redeploy', async () => { - mockPerformFullDeploy.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ success: true, activeDeployment: null, latestDeploymentAttempt: { status: 'active', isCurrent: false }, @@ -246,8 +266,8 @@ describe('deployment handlers', () => { }) it('undeploys chat without approval context when permission gating is disabled', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([ - { + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ + deployment: { id: 'chat-1', identifier: 'production-helper', title: 'Production Helper', @@ -259,9 +279,7 @@ describe('deployment handlers', () => { includeToolCalls: false, customizations: null, }, - ]) - mockCheckChatAccess.mockResolvedValue({ hasAccess: true, workspaceId: 'workspace-1' }) - mockPerformChatUndeploy.mockResolvedValue({ success: true }) + }) const result = await executeDeployChat( { workflowId: 'workflow-1', action: 'undeploy' }, @@ -273,18 +291,21 @@ describe('deployment handlers', () => { ) expect(result.success).toBe(true) - expect(mockPerformChatUndeploy).toHaveBeenCalledWith({ - chatId: 'chat-1', - userId: 'user-1', - workspaceId: 'workspace-1', - }) + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.objectContaining({ + operation: expect.objectContaining({ id: 'workflows.chat.undeploy' }), + }), + expect.objectContaining({ workflowId: 'workflow-1' }) + ) }) it('undeploys MCP without approval context when permission gating is disabled', async () => { - dbChainMockFns.limit - .mockResolvedValueOnce([{ id: 'server-1', name: 'Production MCP' }]) - .mockResolvedValueOnce([{ id: 'tool-1' }]) - mockPerformDeleteWorkflowMcpTool.mockResolvedValue({ success: true }) + mockExecuteCopilotMcpServerUseCase.mockResolvedValue({ + server: { id: 'server-1', name: 'Production MCP' }, + tool: { id: 'tool-1', toolName: 'run_workflow' }, + workflow: { id: 'workflow-1' }, + }) const result = await executeDeployMcp( { workflowId: 'workflow-1', serverId: 'server-1', action: 'undeploy' }, @@ -296,11 +317,14 @@ describe('deployment handlers', () => { ) expect(result.success).toBe(true) - expect(mockPerformDeleteWorkflowMcpTool).toHaveBeenCalledWith({ - serverId: 'server-1', - toolId: 'tool-1', - workspaceId: 'workspace-1', - userId: 'user-1', - }) + expect(mockExecuteCopilotMcpServerUseCase).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.objectContaining({ + operation: expect.objectContaining({ + id: 'mcp_servers.workflow_deployments.undeploy_tool', + }), + }), + { serverId: 'server-1', workflowId: 'workflow-1' } + ) }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index 3c7aeb53435..5fd15db45d7 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -1,32 +1,20 @@ -import { db } from '@sim/db' -import { chat, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' -import { toError } from '@sim/utils/errors' -import { and, eq, isNull } from 'drizzle-orm' +import { executeCopilotMcpServerUseCase } from '@/lib/copilot/application/execute-mcp-server-use-case' +import { + executeCopilotWorkflowUseCase, + messageForCopilotWorkflowError, +} from '@/lib/copilot/application/execute-workflow-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' import { - performCreateWorkflowMcpTool, - performDeleteWorkflowMcpTool, - performUpdateWorkflowMcpTool, -} from '@/lib/mcp/orchestration' -import { getDeployedWorkflowInputFormat } from '@/lib/mcp/workflow-mcp-sync' -import { - applyDescriptionOverrides, - generateToolInputSchema, - sanitizeToolName, -} from '@/lib/mcp/workflow-tool-schema' + deployWorkflowMcpTool, + undeployWorkflowMcpTool, +} from '@/lib/mcp/application/workflow-deployments' import { - performChatDeploy, - performChatUndeploy, - performFullDeploy, - performFullUndeploy, -} from '@/lib/workflows/orchestration' -import { checkChatAccess, checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils' -import { - ChatDeployAuthNotAllowedError, - validateChatDeployAuth, -} from '@/ee/access-control/utils/permission-check' -import { ensureWorkflowAccess } from '../access' + deployWorkflowChat, + undeployWorkflowChat, +} from '@/lib/workflows/application/chat-deployments' +import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/application/deployments' import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../param-types' import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError } from './context' @@ -100,7 +88,7 @@ function buildWorkflowApiExamples(baseUrl: string, apiEndpoint: string) { /** Returns an error until this call's admitted version is the active production version. */ function getUnconfirmedDeploymentError( - result: Awaited<ReturnType<typeof performFullDeploy>>, + result: Awaited<ReturnType<typeof deployWorkflow.execute>>, action: string ): string | null { const attempt = result.latestDeploymentAttempt @@ -162,14 +150,12 @@ export async function executeDeployApi( return { success: false, error: 'workflowId is required' } } const action = params.action === 'undeploy' ? 'undeploy' : 'deploy' - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'admin' - ) - if (action === 'undeploy') { - const result = await performFullUndeploy({ workflowId, userId: context.userId }) + const result = await executeCopilotWorkflowUseCase(context, undeployWorkflow, { + workflowId, + assertedWorkspaceId: context.workspaceId, + requestId: generateRequestId(), + }) if (!result.success) { return { success: false, error: result.error || 'Failed to undeploy workflow' } } @@ -219,11 +205,12 @@ export async function executeDeployApi( } } - const result = await performFullDeploy({ + const result = await executeCopilotWorkflowUseCase(context, deployWorkflow, { workflowId, - userId: context.userId, - versionDescription, - versionName, + assertedWorkspaceId: context.workspaceId, + description: versionDescription, + name: versionName, + requestId: generateRequestId(), idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_api'), }) if (!result.success) { @@ -277,7 +264,10 @@ export async function executeDeployApi( }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to update API deployment'), + } } } @@ -293,33 +283,14 @@ export async function executeDeployChat( const action = params.action === 'undeploy' ? 'undeploy' : 'deploy' if (action === 'undeploy') { + const { deployment } = await executeCopilotWorkflowUseCase(context, undeployWorkflowChat, { + workflowId, + assertedWorkspaceId: context.workspaceId, + }) const baseUrl = getBaseUrl() const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint) const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint) - const existing = await db - .select() - .from(chat) - .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) - .limit(1) - if (!existing.length) { - return { success: false, error: 'No active chat deployment found for this workflow' } - } - const { hasAccess, workspaceId: chatWorkspaceId } = await checkChatAccess( - existing[0].id, - context.userId - ) - if (!hasAccess) { - return { success: false, error: 'Unauthorized chat access' } - } - const undeployResult = await performChatUndeploy({ - chatId: existing[0].id, - userId: context.userId, - workspaceId: chatWorkspaceId, - }) - if (!undeployResult.success) { - return { success: false, error: undeployResult.error || 'Failed to undeploy chat' } - } return { success: true, output: { @@ -338,25 +309,25 @@ export async function executeDeployChat( }, chat: { isDeployed: false, - identifier: existing[0].identifier, - title: existing[0].title, + identifier: deployment.identifier, + title: deployment.title, }, }, deploymentConfig: { api: apiConfig, chat: { - identifier: existing[0].identifier, - title: existing[0].title, - description: existing[0].description || '', - authType: existing[0].authType, - allowedEmails: (existing[0].allowedEmails as string[]) || [], + identifier: deployment.identifier, + title: deployment.title, + description: deployment.description || '', + authType: deployment.authType, + allowedEmails: (deployment.allowedEmails as string[]) || [], outputConfigs: - (existing[0].outputConfigs as Array<{ blockId: string; path: string }>) || [], - includeThinking: existing[0].includeThinking ?? false, - includeToolCalls: existing[0].includeToolCalls ?? false, + (deployment.outputConfigs as Array<{ blockId: string; path: string }>) || [], + includeThinking: deployment.includeThinking ?? false, + includeToolCalls: deployment.includeToolCalls ?? false, welcomeMessage: - (existing[0].customizations as { welcomeMessage?: string } | null) - ?.welcomeMessage || 'Hi there! How can I help you today?', + (deployment.customizations as { welcomeMessage?: string } | null)?.welcomeMessage || + 'Hi there! How can I help you today?', }, }, examples: { @@ -368,26 +339,6 @@ export async function executeDeployChat( } } - const { hasAccess, workflow: workflowRecord } = await checkWorkflowAccessForChatCreation( - workflowId, - context.userId - ) - if (!hasAccess || !workflowRecord) { - return { success: false, error: 'Workflow not found or access denied' } - } - - const [existingDeployment] = await db - .select() - .from(chat) - .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) - .limit(1) - - const identifier = String(params.identifier || existingDeployment?.identifier || '').trim() - const title = String(params.title || existingDeployment?.title || '').trim() - if (!identifier || !title) { - return { success: false, error: 'Chat identifier and title are required' } - } - const versionDescription = params.versionDescription?.trim() if (!versionDescription) { return { @@ -406,102 +357,29 @@ export async function executeDeployChat( } } - const identifierPattern = /^[a-z0-9-]+$/ - if (!identifierPattern.test(identifier)) { - return { - success: false, - error: 'Identifier can only contain lowercase letters, numbers, and hyphens', - } - } - - const existingIdentifier = await db - .select() - .from(chat) - .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) - .limit(1) - if (existingIdentifier.length > 0 && existingIdentifier[0].id !== existingDeployment?.id) { - return { success: false, error: 'Identifier already in use' } - } - - const existingCustomizations = - (existingDeployment?.customizations as - | { primaryColor?: string; welcomeMessage?: string; imageUrl?: string } - | undefined) || {} - const resolvedDescription = String(params.description || existingDeployment?.description || '') - const resolvedAuthType = (params.authType || existingDeployment?.authType || 'public') as - | 'public' - | 'password' - | 'email' - | 'sso' - const resolvedAllowedEmails = - params.allowedEmails || (existingDeployment?.allowedEmails as string[]) || [] - const resolvedOutputConfigs = (params.outputConfigs || - existingDeployment?.outputConfigs || - []) as Array<{ - blockId: string - path: string - }> - const resolvedIncludeThinking = - typeof params.includeThinking === 'boolean' - ? params.includeThinking - : (existingDeployment?.includeThinking ?? false) - const resolvedIncludeToolCalls = - typeof params.includeToolCalls === 'boolean' - ? params.includeToolCalls - : (existingDeployment?.includeToolCalls ?? false) - const welcomeMessage = - typeof params.welcomeMessage === 'string' - ? params.welcomeMessage - : params.customizations?.welcomeMessage || existingCustomizations.welcomeMessage - const imageUrl = - params.customizations?.imageUrl || - params.customizations?.iconUrl || - existingCustomizations.imageUrl - - // Enforce the permission group's chat auth-mode allow-list, but only when the - // mode actually changes (or on a first deploy) so an existing grandfathered - // mode can be re-saved. - if (workflowRecord.workspaceId && resolvedAuthType !== existingDeployment?.authType) { - try { - await validateChatDeployAuth(context.userId, workflowRecord.workspaceId, resolvedAuthType) - } catch (error) { - if (error instanceof ChatDeployAuthNotAllowedError) { - return { success: false, error: error.message } - } - throw error - } - } - - const result = await performChatDeploy({ + const result = await executeCopilotWorkflowUseCase(context, deployWorkflowChat, { workflowId, - userId: context.userId, - identifier, - title, - description: resolvedDescription, + assertedWorkspaceId: context.workspaceId, + identifier: params.identifier, + title: params.title, + description: params.description, versionDescription, versionName, customizations: { - primaryColor: - params.customizations?.primaryColor || - existingCustomizations.primaryColor || - 'var(--brand-hover)', - welcomeMessage: welcomeMessage || 'Hi there! How can I help you today?', - ...(imageUrl ? { imageUrl } : {}), + primaryColor: params.customizations?.primaryColor, + welcomeMessage: params.welcomeMessage ?? params.customizations?.welcomeMessage, + imageUrl: params.customizations?.imageUrl ?? params.customizations?.iconUrl, }, - authType: resolvedAuthType, + authType: params.authType, password: params.password, - allowedEmails: resolvedAllowedEmails, - outputConfigs: resolvedOutputConfigs, - includeThinking: resolvedIncludeThinking, - includeToolCalls: resolvedIncludeToolCalls, - workspaceId: workflowRecord.workspaceId, + allowedEmails: params.allowedEmails, + outputConfigs: params.outputConfigs, + includeThinking: params.includeThinking, + includeToolCalls: params.includeToolCalls, + requestId: generateRequestId(), idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_chat'), }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to deploy chat' } - } - const baseUrl = getBaseUrl() const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint) @@ -514,7 +392,7 @@ export async function executeDeployChat( action: 'deploy', isDeployed: true, isChatDeployed: true, - identifier, + identifier: result.identifier, chatUrl: result.chatUrl, apiEndpoint, baseUrl, @@ -530,31 +408,26 @@ export async function executeDeployChat( }, chat: { isDeployed: true, - identifier, + identifier: result.identifier, chatUrl: result.chatUrl, - title, - description: resolvedDescription, - authType: resolvedAuthType, + title: result.title, + description: result.description, + authType: result.authType, }, }, deploymentConfig: { api: apiConfig, chat: { - identifier, + identifier: result.identifier, chatUrl: result.chatUrl, - title, - description: resolvedDescription, - authType: resolvedAuthType, - allowedEmails: resolvedAllowedEmails, - outputConfigs: resolvedOutputConfigs, - includeThinking: resolvedIncludeThinking, - includeToolCalls: resolvedIncludeToolCalls, - welcomeMessage: welcomeMessage || 'Hi there! How can I help you today?', - primaryColor: - params.customizations?.primaryColor || - existingCustomizations.primaryColor || - 'var(--brand-hover)', - ...(imageUrl ? { imageUrl } : {}), + title: result.title, + description: result.description, + authType: result.authType, + allowedEmails: result.allowedEmails, + outputConfigs: result.outputConfigs, + includeThinking: result.includeThinking, + includeToolCalls: result.includeToolCalls, + ...result.customizations, }, }, examples: { @@ -568,7 +441,10 @@ export async function executeDeployChat( }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to update chat deployment'), + } } } @@ -582,16 +458,6 @@ export async function executeDeployMcp( return { success: false, error: 'workflowId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'admin' - ) - const workspaceId = workflowRecord.workspaceId - if (!workspaceId) { - return { success: false, error: 'workspaceId is required' } - } - const serverId = params.serverId if (!serverId) { return { @@ -599,58 +465,17 @@ export async function executeDeployMcp( error: 'serverId is required. Use list_workspace_mcp_servers to get available servers.', } } - const [serverRecord] = await db - .select({ - id: workflowMcpServer.id, - name: workflowMcpServer.name, - }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) - ) - .limit(1) - if (!serverRecord) { - return { success: false, error: 'MCP server not found in this workspace' } - } - - // Handle undeploy action — remove workflow from MCP server if (params.action === 'undeploy') { - const [existingTool] = await db - .select({ id: workflowMcpTool.id }) - .from(workflowMcpTool) - .where( - and( - eq(workflowMcpTool.serverId, serverId), - eq(workflowMcpTool.workflowId, workflowId), - isNull(workflowMcpTool.archivedAt) - ) - ) - .limit(1) - - if (!existingTool) { - return { success: false, error: 'Workflow is not deployed to this MCP server' } - } - - const deleteResult = await performDeleteWorkflowMcpTool({ + const result = await executeCopilotMcpServerUseCase(context, undeployWorkflowMcpTool, { serverId, - toolId: existingTool.id, - workspaceId, - userId: context.userId, + workflowId, }) - if (!deleteResult.success) { - return { success: false, error: deleteResult.error || 'Failed to undeploy MCP tool' } - } - return { success: true, output: { workflowId, serverId, - serverName: serverRecord.name, + serverName: result.server.name, action: 'undeploy', removed: true, deploymentType: 'mcp', @@ -658,135 +483,27 @@ export async function executeDeployMcp( mcp: { isDeployed: false, serverId, - serverName: serverRecord.name, + serverName: result.server.name, }, }, }, } } - if (!workflowRecord.isDeployed) { - return { - success: false, - error: 'Workflow must be deployed before adding as an MCP tool. Use deploy_api first.', - } - } - - const existingTool = await db - .select() - .from(workflowMcpTool) - .where( - and( - eq(workflowMcpTool.serverId, serverId), - eq(workflowMcpTool.workflowId, workflowId), - isNull(workflowMcpTool.archivedAt) - ) - ) - .limit(1) - - const toolName = sanitizeToolName( - params.toolName || workflowRecord.name || `workflow_${workflowId}` - ) - const toolDescription = - params.toolDescription?.trim() || `Execute ${workflowRecord.name} workflow` - /** - * Parameter names/types come from the workflow's deployed input trigger; this tool only sets - * per-parameter descriptions, sent as sparse overrides. The materialized schema is echoed in the - * response for the model's reference. - */ - const inputFormat = await getDeployedWorkflowInputFormat(workflowId) - const parameterDescriptionOverrides = Object.fromEntries( - (params.parameterDescriptions ?? []) - .filter((entry) => entry && typeof entry.name === 'string' && entry.name.trim() !== '') - .map((entry) => [entry.name.trim(), (entry.description ?? '').trim()]) - .filter(([, description]) => description !== '') - ) - const parameterSchema = applyDescriptionOverrides( - generateToolInputSchema(inputFormat), - parameterDescriptionOverrides - ) - const baseUrl = getBaseUrl() - const mcpServerUrl = `${baseUrl}/api/mcp/serve/${serverId}` - const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) - const clientExamples = buildMcpClientExamples(serverRecord.name, mcpServerUrl) - - if (existingTool.length > 0) { - const toolId = existingTool[0].id - const updateResult = await performUpdateWorkflowMcpTool({ - serverId, - toolId, - workspaceId, - userId: context.userId, - toolName, - toolDescription, - parameterDescriptionOverrides, - }) - if (!updateResult.success || !updateResult.tool) { - return { success: false, error: updateResult.error || 'Failed to update MCP tool' } - } - - return { - success: true, - output: { - toolId, - toolName, - toolDescription, - updated: true, - mcpServerUrl, - baseUrl, - serverId, - serverName: serverRecord.name, - deploymentType: 'mcp', - apiEndpoint, - deploymentStatus: { - api: { - isDeployed: true, - endpoint: apiEndpoint, - }, - mcp: { - isDeployed: true, - serverId, - serverName: serverRecord.name, - toolId, - toolName, - updated: true, - }, - }, - deploymentConfig: { - mcp: { - serverId, - serverName: serverRecord.name, - serverUrl: mcpServerUrl, - toolId, - toolName, - toolDescription, - parameterSchema, - authentication: { - type: 'api_key', - header: 'X-API-Key: YOUR_API_KEY', - }, - }, - }, - examples: { - mcp: clientExamples, - }, - }, - } - } - - const createResult = await performCreateWorkflowMcpTool({ + const result = await executeCopilotMcpServerUseCase(context, deployWorkflowMcpTool, { serverId, - workspaceId, - userId: context.userId, workflowId, - toolName, - toolDescription, - parameterDescriptionOverrides, + toolName: params.toolName, + toolDescription: params.toolDescription, + parameterDescriptions: params.parameterDescriptions, }) - if (!createResult.success || !createResult.tool) { - return { success: false, error: createResult.error || 'Failed to deploy MCP tool' } - } - const toolId = createResult.tool.id + const baseUrl = getBaseUrl() + const mcpServerUrl = `${baseUrl}/api/mcp/serve/${serverId}` + const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) + const clientExamples = buildMcpClientExamples(result.server.name, mcpServerUrl) + const toolId = result.tool.id + const toolName = result.tool.toolName + const toolDescription = result.tool.toolDescription return { success: true, @@ -794,11 +511,11 @@ export async function executeDeployMcp( toolId, toolName, toolDescription, - updated: false, + updated: result.updated, mcpServerUrl, baseUrl, serverId, - serverName: serverRecord.name, + serverName: result.server.name, deploymentType: 'mcp', apiEndpoint, deploymentStatus: { @@ -809,21 +526,21 @@ export async function executeDeployMcp( mcp: { isDeployed: true, serverId, - serverName: serverRecord.name, + serverName: result.server.name, toolId, toolName, - updated: false, + updated: result.updated, }, }, deploymentConfig: { mcp: { serverId, - serverName: serverRecord.name, + serverName: result.server.name, serverUrl: mcpServerUrl, toolId, toolName, toolDescription, - parameterSchema, + parameterSchema: result.parameterSchema, authentication: { type: 'api_key', header: 'X-API-Key: YOUR_API_KEY', @@ -836,7 +553,10 @@ export async function executeDeployMcp( }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to update MCP deployment'), + } } } @@ -865,13 +585,12 @@ export async function executeRedeploy( 'versionName is required. Provide a short human-readable label for this deployment version.', } } - await ensureWorkflowAccess(workflowId, context.userId, 'admin') - - const result = await performFullDeploy({ + const result = await executeCopilotWorkflowUseCase(context, deployWorkflow, { workflowId, - userId: context.userId, - versionDescription, - versionName, + assertedWorkspaceId: context.workspaceId, + description: versionDescription, + name: versionName, + requestId: generateRequestId(), idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_api'), }) if (!result.success) { @@ -924,6 +643,9 @@ export async function executeRedeploy( }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to redeploy workflow'), + } } } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts index 55ec5455a9e..f5e5081d156 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts @@ -4,18 +4,25 @@ import { auditMock, - queueTableRows, resetDbChainMock, - schemaMock, workflowsOrchestrationMock, workflowsOrchestrationMockFns, } from '@sim/testing' +import { getErrorMessage } from '@sim/utils/errors' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionContext } from '@/lib/copilot/request/types' -const { ensureWorkflowAccessMock, checkNeedsRedeploymentMock } = vi.hoisted(() => ({ - ensureWorkflowAccessMock: vi.fn(), - checkNeedsRedeploymentMock: vi.fn(), +const { ensureWorkflowAccessMock, checkNeedsRedeploymentMock, mockExecuteCopilotWorkflowUseCase } = + vi.hoisted(() => ({ + ensureWorkflowAccessMock: vi.fn(), + checkNeedsRedeploymentMock: vi.fn(), + mockExecuteCopilotWorkflowUseCase: vi.fn(), + })) + +vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ + executeCopilotWorkflowUseCase: mockExecuteCopilotWorkflowUseCase, + messageForCopilotWorkflowError: (error: unknown, fallback: string) => + getErrorMessage(error, fallback), })) const performRevertToVersionMock = workflowsOrchestrationMockFns.mockPerformRevertToVersion @@ -58,6 +65,7 @@ vi.mock('../access', () => ({ vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) vi.mock('./state-refs', () => ({ + parseWorkflowRef: (value: number | string) => (value === 'live' ? 'active' : value), resolveWorkflowStateRef: resolveWorkflowStateRefMock, })) @@ -65,7 +73,7 @@ vi.mock('@/lib/workflows/comparison', () => ({ generateWorkflowDiffSummary: generateWorkflowDiffSummaryMock, })) -vi.mock('@/app/api/workflows/utils', () => ({ +vi.mock('@/lib/workflows/deployment-status', () => ({ checkNeedsRedeployment: checkNeedsRedeploymentMock, })) @@ -95,20 +103,20 @@ describe('executeLoadDeployment', () => { }) it('loads a version into the draft via performRevertToVersion', async () => { - performRevertToVersionMock.mockResolvedValue({ success: true, lastSaved: 12345 }) + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ lastSaved: 12345 }) const result = await executeLoadDeployment({ workflowId: 'wf-1', version: 7 }, { userId: 'user-1', workflowId: 'wf-1', } as ExecutionContext) - expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin') - expect(performRevertToVersionMock).toHaveBeenCalledWith({ - workflowId: 'wf-1', - version: 7, - userId: 'user-1', - workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' }, - }) + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.objectContaining({ + operation: expect.objectContaining({ id: 'workflows.versions.revert' }), + }), + expect.objectContaining({ workflowId: 'wf-1', version: 7 }) + ) expect(result).toEqual({ success: true, output: { @@ -120,14 +128,16 @@ describe('executeLoadDeployment', () => { }) it('maps "live" to the active version', async () => { - performRevertToVersionMock.mockResolvedValue({ success: true, lastSaved: 1 }) + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ lastSaved: 1 }) await executeLoadDeployment({ workflowId: 'wf-1', version: 'live' }, { userId: 'user-1', workflowId: 'wf-1', } as ExecutionContext) - expect(performRevertToVersionMock).toHaveBeenCalledWith( + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), expect.objectContaining({ version: 'active' }) ) }) @@ -143,10 +153,7 @@ describe('executeLoadDeployment', () => { }) it('returns shared helper failures directly', async () => { - performRevertToVersionMock.mockResolvedValue({ - success: false, - error: 'Deployment version not found', - }) + mockExecuteCopilotWorkflowUseCase.mockRejectedValue(new Error('Deployment version not found')) const result = await executeLoadDeployment({ workflowId: 'wf-1', version: 7 }, { userId: 'user-1', @@ -166,7 +173,7 @@ describe('executePromoteToLive', () => { }) it('promotes a version via performActivateVersion', async () => { - performActivateVersionMock.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ success: true, deployedAt: new Date('2026-05-30T00:00:00.000Z'), activeDeployment: { @@ -195,13 +202,17 @@ describe('executePromoteToLive', () => { toolCallId: 'call-1', } as ExecutionContext) - expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin') - expect(performActivateVersionMock).toHaveBeenCalledWith({ - workflowId: 'wf-1', - version: 3, - userId: 'user-1', - idempotencyKey: 'copilot:execution-1:operation:promote_to_live', - }) + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.objectContaining({ + operation: expect.objectContaining({ id: 'workflows.versions.activate' }), + }), + expect.objectContaining({ + workflowId: 'wf-1', + version: 3, + idempotencyKey: 'copilot:execution-1:operation:promote_to_live', + }) + ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ workflowId: 'wf-1', @@ -213,7 +224,7 @@ describe('executePromoteToLive', () => { }) it('does not report a historical active operation as a successful promotion', async () => { - performActivateVersionMock.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ success: true, activeDeployment: null, latestDeploymentAttempt: { @@ -263,7 +274,7 @@ describe('executeGetDeploymentLog', () => { }) it('returns versions from the shared listWorkflowVersions helper', async () => { - listWorkflowVersionsMock.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ versions: [ { id: 'v2', @@ -293,7 +304,13 @@ describe('executeGetDeploymentLog', () => { workflowId: 'wf-1', } as ExecutionContext) - expect(listWorkflowVersionsMock).toHaveBeenCalledWith('wf-1') + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.objectContaining({ + operation: expect.objectContaining({ id: 'workflows.versions.list' }), + }), + expect.objectContaining({ workflowId: 'wf-1' }) + ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ workflowId: 'wf-1', @@ -312,9 +329,12 @@ describe('executeDiffWorkflows', () => { }) it('diffs ref2 against ref1 and returns the structured summary', async () => { - resolveWorkflowStateRefMock - .mockResolvedValueOnce({ state: { base: true }, ref: '1', version: 1, isActive: false }) - .mockResolvedValueOnce({ state: { target: true }, ref: 'live', version: 2, isActive: true }) + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ + references: [ + { state: { base: true }, ref: '1', version: 1, isActive: false }, + { state: { target: true }, ref: 'live', version: 2, isActive: true }, + ], + }) const summary = { addedBlocks: [], @@ -340,8 +360,13 @@ describe('executeDiffWorkflows', () => { workflowId: 'wf-1', } as ExecutionContext) - expect(resolveWorkflowStateRefMock).toHaveBeenCalledWith('wf-1', 1, 'user-1') - expect(resolveWorkflowStateRefMock).toHaveBeenCalledWith('wf-1', 'live', 'user-1') + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.objectContaining({ + operation: expect.objectContaining({ id: 'workflows.versions.compare_references' }), + }), + expect.objectContaining({ workflowId: 'wf-1', references: [1, 'active'] }) + ) // ref1 = base/previous, ref2 = target/current. expect(generateWorkflowDiffSummaryMock).toHaveBeenCalledWith({ target: true }, { base: true }) expect(result.success).toBe(true) @@ -366,11 +391,18 @@ describe('executeCheckDeploymentStatus', () => { activeDeployment: null, latestDeploymentAttempt: null, warnings: [], + chatDeployment: null, + mcpTools: [], + mcpToolsTruncated: false, }) }) it('uses the shared redeployment freshness helper for deployed APIs', async () => { - getWorkflowDeploymentSummaryMock.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ + workflow: { id: 'wf-1', workspaceId: 'ws-1', deployedAt: new Date('2026-05-28') }, + workspaceId: 'ws-1', + isDeployed: true, + needsRedeployment: true, activeDeployment: { deploymentVersionId: 'dv-1', version: 1, @@ -378,16 +410,22 @@ describe('executeCheckDeploymentStatus', () => { }, latestDeploymentAttempt: null, warnings: [], + chatDeployment: null, + mcpTools: [], + mcpToolsTruncated: false, }) - queueTableRows(schemaMock.workflow, [{ deployedAt: new Date('2026-05-28') }]) - checkNeedsRedeploymentMock.mockResolvedValueOnce(true) - const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, { userId: 'user-1', workflowId: 'wf-1', } as ExecutionContext) - expect(checkNeedsRedeploymentMock).toHaveBeenCalledWith('wf-1') + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.objectContaining({ + operation: expect.objectContaining({ id: 'workflows.deployment_overview.read' }), + }), + expect.objectContaining({ workflowId: 'wf-1' }) + ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ isDeployed: true, @@ -399,7 +437,18 @@ describe('executeCheckDeploymentStatus', () => { }) it('does not check redeployment freshness for undeployed APIs', async () => { - queueTableRows(schemaMock.workflow, [{ deployedAt: null }]) + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ + workflow: { id: 'wf-1', workspaceId: 'ws-1', deployedAt: null }, + workspaceId: 'ws-1', + isDeployed: false, + needsRedeployment: false, + activeDeployment: null, + latestDeploymentAttempt: null, + warnings: [], + chatDeployment: null, + mcpTools: [], + mcpToolsTruncated: false, + }) const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, { userId: 'user-1', @@ -418,7 +467,11 @@ describe('executeCheckDeploymentStatus', () => { }) it('separates a historical active attempt from the current undeployed state', async () => { - getWorkflowDeploymentSummaryMock.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ + workflow: { id: 'wf-1', workspaceId: 'ws-1', deployedAt: null }, + workspaceId: 'ws-1', + isDeployed: false, + needsRedeployment: false, activeDeployment: null, latestDeploymentAttempt: { id: 'op-historical', @@ -433,9 +486,10 @@ describe('executeCheckDeploymentStatus', () => { error: null, }, warnings: ['The latest successful deployment attempt is historical.'], + chatDeployment: null, + mcpTools: [], + mcpToolsTruncated: false, }) - queueTableRows(schemaMock.workflow, [{ deployedAt: null }]) - const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, { userId: 'user-1', workflowId: 'wf-1', diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index c2e13b77b14..0ec758912f6 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -1,25 +1,26 @@ -import { db } from '@sim/db' -import { chat, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' -import { toError } from '@sim/utils/errors' -import { and, eq, inArray, isNull } from 'drizzle-orm' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { executeCopilotMcpServerUseCase } from '@/lib/copilot/application/execute-mcp-server-use-case' import { - performCreateWorkflowMcpServer, - performDeleteWorkflowMcpServer, - performUpdateWorkflowMcpServer, -} from '@/lib/mcp/orchestration' -import { generateWorkflowDiffSummary } from '@/lib/workflows/comparison' + executeCopilotWorkflowUseCase, + messageForCopilotWorkflowError, +} from '@/lib/copilot/application/execute-workflow-use-case' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { generateRequestId } from '@/lib/core/utils/request' import { - getWorkflowDeploymentSummary, - performActivateVersion, - performRevertToVersion, -} from '@/lib/workflows/orchestration' + createWorkflowMcpDeploymentServer, + deleteWorkflowMcpDeploymentServer, + listWorkflowMcpDeployments, + updateWorkflowMcpDeploymentServer, +} from '@/lib/mcp/application/workflow-deployments' import { - listWorkflowVersions, - updateDeploymentVersionMetadata, -} from '@/lib/workflows/persistence/utils' -import { checkNeedsRedeployment } from '@/app/api/workflows/utils' -import { ensureWorkflowAccess, ensureWorkspaceAccess } from '../access' + activateWorkflowVersion, + revertWorkflowVersion, + updateWorkflowVersion, +} from '@/lib/workflows/application/deployments' +import { listWorkflowVersions } from '@/lib/workflows/application/list-workflow-versions' +import { readWorkflowDeploymentOverview } from '@/lib/workflows/application/read-workflow-deployment-overview' +import { readWorkflowStateReferences } from '@/lib/workflows/application/read-workflow-state-references' +import { generateWorkflowDiffSummary } from '@/lib/workflows/comparison' import type { CheckDeploymentStatusParams, CreateWorkspaceMcpServerParams, @@ -33,7 +34,7 @@ import type { UpdateWorkspaceMcpServerParams, } from '../param-types' import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError } from './context' -import { resolveWorkflowStateRef } from './state-refs' +import { parseWorkflowRef } from './state-refs' export async function executeCheckDeploymentStatus( params: CheckDeploymentStatusParams, @@ -44,77 +45,58 @@ export async function executeCheckDeploymentStatus( if (!workflowId) { return { success: false, error: 'workflowId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) - const workspaceId = workflowRecord.workspaceId - - const [apiDeploy, chatDeploy, deploymentSummary] = await Promise.all([ - db - .select({ deployedAt: workflow.deployedAt }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1), - db - .select({ - id: chat.id, - identifier: chat.identifier, - title: chat.title, - description: chat.description, - authType: chat.authType, - allowedEmails: chat.allowedEmails, - outputConfigs: chat.outputConfigs, - includeThinking: chat.includeThinking, - includeToolCalls: chat.includeToolCalls, - password: chat.password, - customizations: chat.customizations, - }) - .from(chat) - .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) - .limit(1), - getWorkflowDeploymentSummary(workflowId), - ]) + const deployment = await executeCopilotWorkflowUseCase( + context, + readWorkflowDeploymentOverview, + { + workflowId, + assertedWorkspaceId: context.workspaceId, + } + ) + const workflowRecord = deployment.workflow /** * Deployed means an active version snapshot exists; the legacy * `workflow.isDeployed` flag is not consulted so this can never * contradict the attached `activeDeployment` summary. */ - const isApiDeployed = deploymentSummary.activeDeployment !== null - const needsRedeployment = isApiDeployed ? await checkNeedsRedeployment(workflowId) : false - const currentDeploymentAttempt = deploymentSummary.latestDeploymentAttempt?.isCurrent - ? deploymentSummary.latestDeploymentAttempt + const isApiDeployed = deployment.isDeployed + const currentDeploymentAttempt = deployment.latestDeploymentAttempt?.isCurrent + ? deployment.latestDeploymentAttempt : null const apiDetails = { isDeployed: isApiDeployed, - deployedAt: apiDeploy[0]?.deployedAt || null, + deployedAt: workflowRecord.deployedAt || null, endpoint: isApiDeployed ? `/api/workflows/${workflowId}/execute` : null, apiKey: workflowRecord.workspaceId ? 'Workspace API keys' : 'Personal API keys', - needsRedeployment, - activeDeployment: deploymentSummary.activeDeployment, - latestDeploymentAttempt: deploymentSummary.latestDeploymentAttempt, + needsRedeployment: deployment.needsRedeployment, + activeDeployment: deployment.activeDeployment, + latestDeploymentAttempt: deployment.latestDeploymentAttempt, currentDeploymentAttempt, - warnings: deploymentSummary.warnings ?? [], + warnings: deployment.warnings ?? [], } - const isChatDeployed = !!chatDeploy[0] + const chatDeploy = deployment.chatDeployment + const isChatDeployed = chatDeploy !== null const chatCustomizations = - (chatDeploy[0]?.customizations as + (chatDeploy?.customizations as | { welcomeMessage?: string; primaryColor?: string } | undefined) || {} const chatDetails = { isDeployed: isChatDeployed, - chatId: chatDeploy[0]?.id || null, - identifier: chatDeploy[0]?.identifier || null, - chatUrl: isChatDeployed ? `/chat/${chatDeploy[0]?.identifier}` : null, - title: chatDeploy[0]?.title || null, - description: chatDeploy[0]?.description || null, - authType: chatDeploy[0]?.authType || null, - allowedEmails: chatDeploy[0]?.allowedEmails || null, - outputConfigs: chatDeploy[0]?.outputConfigs || null, - includeThinking: chatDeploy[0]?.includeThinking ?? false, - includeToolCalls: chatDeploy[0]?.includeToolCalls ?? false, + chatId: chatDeploy?.id || null, + identifier: chatDeploy?.identifier || null, + chatUrl: isChatDeployed ? `/chat/${chatDeploy?.identifier}` : null, + title: chatDeploy?.title || null, + description: chatDeploy?.description || null, + authType: chatDeploy?.authType || null, + allowedEmails: chatDeploy?.allowedEmails || null, + outputConfigs: chatDeploy?.outputConfigs || null, + includeThinking: chatDeploy?.includeThinking ?? false, + includeToolCalls: chatDeploy?.includeToolCalls ?? false, welcomeMessage: chatCustomizations.welcomeMessage || null, primaryColor: chatCustomizations.primaryColor || null, - hasPassword: Boolean(chatDeploy[0]?.password), + hasPassword: Boolean(chatDeploy?.password), } const mcpDetails: { @@ -127,25 +109,15 @@ export async function executeCheckDeploymentStatus( parameterSchema: unknown toolId: string }> - } = { isDeployed: false, servers: [] } - if (workspaceId) { - const servers = await db - .select({ - serverId: workflowMcpServer.id, - serverName: workflowMcpServer.name, - toolName: workflowMcpTool.toolName, - toolDescription: workflowMcpTool.toolDescription, - parameterSchema: workflowMcpTool.parameterSchema, - toolId: workflowMcpTool.id, - }) - .from(workflowMcpTool) - .innerJoin(workflowMcpServer, eq(workflowMcpTool.serverId, workflowMcpServer.id)) - .where(eq(workflowMcpTool.workflowId, workflowId)) - - if (servers.length > 0) { - mcpDetails.isDeployed = true - mcpDetails.servers = servers - } + truncated: boolean + } = { + isDeployed: false, + servers: [], + truncated: deployment.mcpToolsTruncated, + } + if (deployment.mcpTools.length > 0) { + mcpDetails.isDeployed = true + mcpDetails.servers = deployment.mcpTools } const isDeployed = apiDetails.isDeployed || chatDetails.isDeployed || mcpDetails.isDeployed @@ -154,7 +126,10 @@ export async function executeCheckDeploymentStatus( output: { isDeployed, api: apiDetails, chat: chatDetails, mcp: mcpDetails }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to check deployment status'), + } } } @@ -163,61 +138,23 @@ export async function executeListWorkspaceMcpServers( context: ExecutionContext ): Promise<ToolCallResult> { try { - let workspaceId = params.workspaceId || context.workspaceId - const workflowId = context.workflowId - - if (!workspaceId && workflowId) { - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) - workspaceId = workflowRecord.workspaceId ?? undefined - } - + const workspaceId = params.workspaceId || context.workspaceId if (!workspaceId) { return { success: false, error: 'workspaceId is required' } } - await ensureWorkspaceAccess(workspaceId, context.userId, 'read') - - const servers = await db - .select({ - id: workflowMcpServer.id, - name: workflowMcpServer.name, - description: workflowMcpServer.description, - }) - .from(workflowMcpServer) - .where( - and(eq(workflowMcpServer.workspaceId, workspaceId), isNull(workflowMcpServer.deletedAt)) - ) - - const serverIds = servers.map((server) => server.id) - const tools = - serverIds.length > 0 - ? await db - .select({ - serverId: workflowMcpTool.serverId, - toolName: workflowMcpTool.toolName, - }) - .from(workflowMcpTool) - .where( - and(inArray(workflowMcpTool.serverId, serverIds), isNull(workflowMcpTool.archivedAt)) - ) - : [] - - const toolNamesByServer: Record<string, string[]> = {} - for (const tool of tools) { - if (!toolNamesByServer[tool.serverId]) { - toolNamesByServer[tool.serverId] = [] - } - toolNamesByServer[tool.serverId].push(tool.toolName) + const result = await executeCopilotMcpServerUseCase(context, listWorkflowMcpDeployments, { + workspaceId, + }) + return { + success: true, + output: { + servers: result.servers, + count: result.servers.length, + truncated: result.truncated, + }, } - - const serversWithToolNames = servers.map((server) => ({ - ...server, - toolCount: toolNamesByServer[server.id]?.length || 0, - toolNames: toolNamesByServer[server.id] || [], - })) - - return { success: true, output: { servers: serversWithToolNames, count: servers.length } } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotApplicationError(error) } } } @@ -226,43 +163,31 @@ export async function executeCreateWorkspaceMcpServer( context: ExecutionContext ): Promise<ToolCallResult> { try { - let workspaceId = params.workspaceId || context.workspaceId - const workflowId = context.workflowId - - if (!workspaceId && workflowId) { - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) - workspaceId = workflowRecord.workspaceId ?? undefined - } - + const workspaceId = params.workspaceId || context.workspaceId if (!workspaceId) { return { success: false, error: 'workspaceId is required' } } - await ensureWorkspaceAccess(workspaceId, context.userId, 'admin') const name = params.name?.trim() if (!name) { return { success: false, error: 'name is required' } } - const result = await performCreateWorkflowMcpServer({ - workspaceId, - userId: context.userId, - name, - description: params.description, - isPublic: params.isPublic, - workflowIds: params.workflowIds, - }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to create MCP server' } - } + const result = await executeCopilotMcpServerUseCase( + context, + createWorkflowMcpDeploymentServer, + { + workspaceId, + name, + description: params.description, + isPublic: params.isPublic, + workflowIds: params.workflowIds, + } + ) - return { success: true, output: { server: result.server, addedTools: result.addedTools || [] } } + return { success: true, output: { server: result.server, addedTools: result.addedTools } } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotApplicationError(error) } } } @@ -293,34 +218,14 @@ export async function executeUpdateWorkspaceMcpServer( return { success: false, error: 'At least one of name, description, or isPublic is required' } } - const [existing] = await db - .select({ - id: workflowMcpServer.id, - workspaceId: workflowMcpServer.workspaceId, - }) - .from(workflowMcpServer) - .where(eq(workflowMcpServer.id, serverId)) - .limit(1) - - if (!existing) { - return { success: false, error: 'MCP server not found' } - } - - await ensureWorkspaceAccess(existing.workspaceId, context.userId, 'write') - - const result = await performUpdateWorkflowMcpServer({ + await executeCopilotMcpServerUseCase(context, updateWorkflowMcpDeploymentServer, { serverId, - workspaceId: existing.workspaceId, - userId: context.userId, ...updates, }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to update MCP server' } - } return { success: true, output: { serverId, ...updates } } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotApplicationError(error) } } } @@ -334,34 +239,17 @@ export async function executeDeleteWorkspaceMcpServer( return { success: false, error: 'serverId is required' } } - const [existing] = await db - .select({ - id: workflowMcpServer.id, - name: workflowMcpServer.name, - workspaceId: workflowMcpServer.workspaceId, - }) - .from(workflowMcpServer) - .where(and(eq(workflowMcpServer.id, serverId), isNull(workflowMcpServer.deletedAt))) - .limit(1) - - if (!existing) { - return { success: false, error: 'MCP server not found' } - } - - await ensureWorkspaceAccess(existing.workspaceId, context.userId, 'admin') - - const result = await performDeleteWorkflowMcpServer({ - serverId, - workspaceId: existing.workspaceId, - userId: context.userId, - }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to delete MCP server' } - } + const result = await executeCopilotMcpServerUseCase( + context, + deleteWorkflowMcpDeploymentServer, + { + serverId, + } + ) - return { success: true, output: { serverId, name: existing.name, deleted: true } } + return { success: true, output: { serverId, name: result.server.name, deleted: true } } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotApplicationError(error) } } } @@ -374,9 +262,10 @@ export async function executeGetDeploymentLog( if (!workflowId) { return { success: false, error: 'workflowId is required' } } - await ensureWorkflowAccess(workflowId, context.userId) - - const { versions: rows } = await listWorkflowVersions(workflowId) + const { versions: rows } = await executeCopilotWorkflowUseCase(context, listWorkflowVersions, { + workflowId, + assertedWorkspaceId: context.workspaceId, + }) const versions = rows.map((r) => ({ id: r.id, @@ -391,7 +280,10 @@ export async function executeGetDeploymentLog( return { success: true, output: { workflowId, count: versions.length, versions } } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to list deployment versions'), + } } } @@ -424,11 +316,16 @@ export async function executeDiffWorkflows( return { success: false, error: 'ref1 and ref2 are required' } } - // resolveWorkflowStateRef enforces read access on the workflow. - const [side1, side2] = await Promise.all([ - resolveWorkflowStateRef(workflowId, params.ref1, context.userId), - resolveWorkflowStateRef(workflowId, params.ref2, context.userId), - ]) + const { references } = await executeCopilotWorkflowUseCase( + context, + readWorkflowStateReferences, + { + workflowId, + assertedWorkspaceId: context.workspaceId, + references: [parseWorkflowRef(params.ref1), parseWorkflowRef(params.ref2)], + } + ) + const [side1, side2] = references // ref1 = base/previous, ref2 = target/current: added = present in ref2 only. const summary = generateWorkflowDiffSummary(side2.state, side1.state) @@ -454,7 +351,10 @@ export async function executeDiffWorkflows( }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to compare workflow versions'), + } } } @@ -496,22 +396,12 @@ export async function executeLoadDeployment( return { success: false, error: target.error } } - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'admin' - ) - const result = await performRevertToVersion({ + const result = await executeCopilotWorkflowUseCase(context, revertWorkflowVersion, { workflowId, + assertedWorkspaceId: context.workspaceId, version: target.version, - userId: context.userId, - workflow: workflowRecord as Record<string, unknown>, }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to load deployment' } - } - const label = target.version === 'active' ? 'the live deployment' : `version ${target.version}` return { success: true, @@ -522,7 +412,10 @@ export async function executeLoadDeployment( }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to load deployment'), + } } } @@ -553,15 +446,12 @@ export async function executePromoteToLive( } } - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'admin' - ) - const result = await performActivateVersion({ + const result = await executeCopilotWorkflowUseCase(context, activateWorkflowVersion, { workflowId, + assertedWorkspaceId: context.workspaceId, version, - userId: context.userId, + transition: 'activate', + requestId: generateRequestId(), idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'promote_to_live'), }) @@ -598,7 +488,10 @@ export async function executePromoteToLive( }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to promote deployment version'), + } } } @@ -629,23 +522,21 @@ export async function executeUpdateDeploymentVersion( return { success: false, error: 'Provide a name and/or description to update' } } - await ensureWorkflowAccess(workflowId, context.userId, 'write') - - const updated = await updateDeploymentVersionMetadata({ + const updated = await executeCopilotWorkflowUseCase(context, updateWorkflowVersion, { workflowId, + assertedWorkspaceId: context.workspaceId, version, ...(name !== undefined ? { name: name || null } : {}), ...(description !== undefined ? { description: description || null } : {}), }) - if (!updated) { - return { success: false, error: `Deployment version ${version} not found` } - } - return { success: true, output: { workflowId, version, name: updated.name, description: updated.description }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to update deployment version'), + } } } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts b/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts index 8dde36ba6f0..84774331fd9 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts @@ -1,22 +1,12 @@ -import { db } from '@sim/db' -import { workflowDeploymentVersion } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' -import { loadWorkflowDeploymentSnapshot } from '@/lib/workflows/persistence/utils' -import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { ensureWorkflowAccess } from '../access' +import type { + ResolvedWorkflowStateReference, + WorkflowStateReference, +} from '@/lib/workflows/application/read-workflow-state-references' /** Canonical workflow-state selector: a deployment version number, the live * (active) deployment, or the current draft. */ -export type WorkflowRef = number | 'live' | 'draft' - -export interface ResolvedWorkflowRef { - state: WorkflowState - /** Human-readable ref label: "live", "draft", or the version number as a string. */ - ref: string - version?: number - isActive?: boolean - createdAt?: string -} +export type WorkflowRef = WorkflowStateReference +export type ResolvedWorkflowRef = ResolvedWorkflowStateReference /** * Parse a raw ref param into a canonical WorkflowRef. @@ -33,63 +23,3 @@ export function parseWorkflowRef(raw: unknown): WorkflowRef { } throw new Error(`Invalid ref "${String(raw)}": expected a version number, "live", or "draft"`) } - -/** - * Resolve a (workflowId, ref) pair to a WorkflowState for diffing. Raw stored - * snapshots are used for version/live (matching checkNeedsRedeployment's baseline), - * and loadWorkflowDeploymentSnapshot is used for draft. Requires read access. - */ -export async function resolveWorkflowStateRef( - workflowId: string, - rawRef: unknown, - userId: string -): Promise<ResolvedWorkflowRef> { - const ref = parseWorkflowRef(rawRef) - await ensureWorkflowAccess(workflowId, userId, 'read') - - if (ref === 'draft') { - const state = await loadWorkflowDeploymentSnapshot(workflowId) - if (!state) { - throw new Error(`Workflow ${workflowId} has no draft state`) - } - return { state, ref: 'draft' } - } - - const whereClause = - ref === 'live' - ? and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.isActive, true) - ) - : and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.version, ref) - ) - - const [row] = await db - .select({ - version: workflowDeploymentVersion.version, - state: workflowDeploymentVersion.state, - isActive: workflowDeploymentVersion.isActive, - createdAt: workflowDeploymentVersion.createdAt, - }) - .from(workflowDeploymentVersion) - .where(whereClause) - .limit(1) - - if (!row?.state) { - throw new Error( - ref === 'live' - ? `Workflow ${workflowId} has no active deployment` - : `Deployment version ${ref} not found for workflow ${workflowId}` - ) - } - - return { - state: row.state as WorkflowState, - ref: ref === 'live' ? 'live' : String(ref), - version: row.version, - isActive: row.isActive, - createdAt: row.createdAt?.toISOString(), - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index 4dd79f4fffc..6d2b9e98eb6 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -1,14 +1,12 @@ /** * @vitest-environment node */ -import { - dbChainMock, - queueTableRows, - resetDbChainMock, - schemaMock, - workflowAuthzMockFns, -} from '@sim/testing' +import { dbChainMock, resetDbChainMock, schemaMock, workflowAuthzMockFns } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { tableOperations } from '@/lib/table/application/operations' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { fileOperations } from '@/lib/workspace-files/application/operations' const mocks = vi.hoisted(() => ({ ensureWorkspaceAccess: vi.fn(), @@ -27,8 +25,10 @@ const mocks = vi.hoisted(() => ({ performUpdateWorkspaceFileFolder: vi.fn(), performCreateFolder: vi.fn(), performUpdateFolder: vi.fn(), - performUpdateWorkflow: vi.fn(), - duplicateWorkflow: vi.fn(), + moveWorkflowVfs: vi.fn(), + copyWorkflowVfs: vi.fn(), + createWorkflowVfsFolders: vi.fn(), + deleteWorkflowVfs: vi.fn(), listFolders: vi.fn(), verifyFolderWorkspace: vi.fn(), listTables: vi.fn(), @@ -37,6 +37,13 @@ const mocks = vi.hoisted(() => ({ updateKnowledgeBase: vi.fn(), deleteKnowledgeBase: vi.fn(), knowledgeBaseDeleted: vi.fn(), + createFileVfsFolders: vi.fn(), + relocateFileVfsItems: vi.fn(), + deleteFileVfsItems: vi.fn(), + renameTableVfs: vi.fn(), + deleteTableVfs: vi.fn(), + renameKnowledgeVfs: vi.fn(), + deleteKnowledgeVfs: vi.fn(), })) vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) @@ -78,41 +85,28 @@ vi.mock('@/lib/copilot/tools/server/files/file-folder-application', () => ({ vi.mock('@/lib/workspace-files/application/move-workspace-file-items', () => ({ moveWorkspaceFileItemsOperation: { - operation: { id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow' }, + operation: fileOperations.move, execute: mocks.moveWorkspaceFileItems, }, })) -vi.mock('@/lib/workspace-files/application/operations', () => ({ - fileOperations: { - move: { id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow' }, - rename: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' }, - delete: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, - updateFolder: { - id: 'files.folders.update', - minimumRole: 'write', - workspaceApiKey: 'allow', - }, - }, -})) - vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ updateWorkspaceFileFolderOperation: { - operation: { id: 'files.folders.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + operation: fileOperations.updateFolder, execute: mocks.updateWorkspaceFileFolder, }, })) vi.mock('@/lib/workspace-files/application/delete-workspace-file', () => ({ deleteWorkspaceFileOperation: { - operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + operation: fileOperations.delete, execute: mocks.deleteWorkspaceFile, }, })) vi.mock('@/lib/workspace-files/application/archive-workspace-file-items', () => ({ archiveWorkspaceFileItemsOperation: { - operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + operation: fileOperations.delete, execute: mocks.deleteWorkspaceFile, }, })) @@ -121,30 +115,65 @@ vi.mock('@/lib/workspace-files/orchestration', () => ({})) vi.mock('@/lib/workspace-files/application/rename-workspace-file', () => ({ renameWorkspaceFile: { - operation: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' }, + operation: fileOperations.rename, execute: mocks.renameWorkspaceFile, }, })) -vi.mock('@/lib/folders/orchestration', () => ({ - createFolder: mocks.performCreateFolder, - deleteFolder: vi.fn(), - updateFolder: mocks.performUpdateFolder, +vi.mock('@/lib/workflows/application/workflow-vfs', () => ({ + moveWorkflowVfsItems: { + operation: workflowOperations.moveVfsItems, + execute: mocks.moveWorkflowVfs, + }, + copyWorkflowVfsItems: { + operation: workflowOperations.copyVfsItems, + execute: mocks.copyWorkflowVfs, + }, + createWorkflowVfsFolders: { + operation: workflowOperations.createVfsFolders, + execute: mocks.createWorkflowVfsFolders, + }, + deleteWorkflowVfsItems: { + operation: workflowOperations.deleteVfsItems, + execute: mocks.deleteWorkflowVfs, + }, })) -vi.mock('@/lib/workflows/orchestration', () => ({ - performCreateFolder: mocks.performCreateFolder, - performUpdateFolder: mocks.performUpdateFolder, - performUpdateWorkflow: mocks.performUpdateWorkflow, +vi.mock('@/lib/workspace-files/application/workspace-file-vfs', () => ({ + createWorkspaceFileVfsFolders: { + operation: fileOperations.createVfsFolders, + execute: mocks.createFileVfsFolders, + }, + relocateWorkspaceFileVfsItems: { + operation: fileOperations.relocateVfsItems, + execute: mocks.relocateFileVfsItems, + }, + deleteWorkspaceFileVfsItems: { + operation: fileOperations.deleteVfsItems, + execute: mocks.deleteFileVfsItems, + }, })) -vi.mock('@/lib/workflows/persistence/duplicate', () => ({ - duplicateWorkflow: mocks.duplicateWorkflow, +vi.mock('@/lib/table/application/table-vfs', () => ({ + renameTableByVfsPath: { + operation: tableOperations.renameByVfsPath, + execute: mocks.renameTableVfs, + }, + deleteTableByVfsPath: { + operation: tableOperations.deleteByVfsPath, + execute: mocks.deleteTableVfs, + }, })) -vi.mock('@/lib/workflows/utils', () => ({ - listFolders: mocks.listFolders, - verifyFolderWorkspace: mocks.verifyFolderWorkspace, +vi.mock('@/lib/knowledge/application/knowledge-vfs', () => ({ + renameKnowledgeBaseByVfsPath: { + operation: knowledgeOperations.renameByVfsPath, + execute: mocks.renameKnowledgeVfs, + }, + deleteKnowledgeBaseByVfsPath: { + operation: knowledgeOperations.deleteByVfsPath, + execute: mocks.deleteKnowledgeVfs, + }, })) vi.mock('@/lib/table/service', () => ({ @@ -154,15 +183,15 @@ vi.mock('@/lib/table/service', () => ({ vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ listKnowledgeBases: { - operation: { id: 'knowledge.list' }, + operation: knowledgeOperations.list, execute: mocks.listKnowledgeBases, }, updateKnowledgeBaseOperation: { - operation: { id: 'knowledge.update' }, + operation: knowledgeOperations.update, execute: mocks.updateKnowledgeBase, }, deleteKnowledgeBaseOperation: { - operation: { id: 'knowledge.delete' }, + operation: knowledgeOperations.delete, execute: mocks.deleteKnowledgeBase, }, })) @@ -192,6 +221,10 @@ describe('vfs mv/cp', () => { workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) mocks.verifyFolderWorkspace.mockResolvedValue(true) mocks.listFolders.mockResolvedValue([]) + mocks.moveWorkflowVfs.mockResolvedValue({ outcomes: [] }) + mocks.copyWorkflowVfs.mockResolvedValue({ outcomes: [] }) + mocks.createWorkflowVfsFolders.mockResolvedValue({ outcomes: [] }) + mocks.deleteWorkflowVfs.mockResolvedValue({ outcomes: [] }) mocks.getWorkspaceFileByName.mockResolvedValue(null) mocks.resolveWorkspaceFileReference.mockImplementation(async ({ reference }) => { const segments = reference.split('/').slice(1) @@ -216,6 +249,27 @@ describe('vfs mv/cp', () => { mocks.renameWorkspaceFile.mockResolvedValue({ file: { id: 'file-1', name: 'renamed.md' }, }) + mocks.createFileVfsFolders.mockResolvedValue({ outcomes: [] }) + mocks.relocateFileVfsItems.mockResolvedValue({ outcomes: [] }) + mocks.deleteFileVfsItems.mockResolvedValue({ outcomes: [] }) + mocks.renameTableVfs.mockResolvedValue({ + id: 'tbl-1', + name: 'Customers', + previousName: 'Leads', + workspaceId: 'ws-1', + }) + mocks.renameKnowledgeVfs.mockResolvedValue({ + id: 'kb-1', + name: 'Product Docs', + previousName: 'Docs', + workspaceId: 'ws-1', + }) + mocks.deleteKnowledgeVfs.mockResolvedValue({ + id: 'kb-1', + name: 'Docs', + workspaceId: 'ws-1', + deleted: true, + }) }) afterAll(() => { @@ -270,13 +324,15 @@ describe('vfs mv/cp', () => { describe('files', () => { it('routes a same-folder rename through the delegated file use case', async () => { - mocks.getWorkspaceFileByName.mockResolvedValue({ - id: 'file-1', - name: 'draft.md', - folderId: null, - }) - mocks.renameWorkspaceFile.mockResolvedValue({ - file: { id: 'file-1', name: 'final.md' }, + mocks.relocateFileVfsItems.mockResolvedValue({ + outcomes: [ + { + source: 'files/draft.md', + targetSegments: ['final.md'], + resourceType: 'file', + resourceId: 'file-1', + }, + ], }) const result = await executeVfsMv( @@ -284,18 +340,17 @@ describe('vfs mv/cp', () => { context ) - expect(mocks.renameWorkspaceFile).toHaveBeenCalledWith({ + expect(mocks.relocateFileVfsItems).toHaveBeenCalledWith({ principal: expect.objectContaining({ kind: 'delegated', subjectUserId: 'user-1', workspaceId: 'ws-1', delegationId: 'copilot-tool:tool-call-1', - resourceScope: expect.objectContaining({ fileId: 'file-1' }), }), input: { - fileId: 'file-1', - assertedWorkspaceId: 'ws-1', - name: 'final.md', + workspaceId: 'ws-1', + sources: [{ source: 'files/draft.md', segments: ['draft.md'] }], + destination: { segments: ['final.md'], trailingSlash: false }, }, }) expect(mocks.moveWorkspaceFileItems).not.toHaveBeenCalled() @@ -306,9 +361,15 @@ describe('vfs mv/cp', () => { }) it('moves and renames a file in one call, auto-creating destination folders', async () => { - mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'draft.md' }) - mocks.renameWorkspaceFile.mockResolvedValue({ - file: { id: 'file-1', name: 'final.md' }, + mocks.relocateFileVfsItems.mockResolvedValue({ + outcomes: [ + { + source: 'files/draft.md', + targetSegments: ['Reports', '2026', 'final.md'], + resourceType: 'file', + resourceId: 'file-1', + }, + ], }) const result = await executeVfsMv( @@ -316,16 +377,11 @@ describe('vfs mv/cp', () => { context ) - expect(mocks.getWorkspaceFileByName).toHaveBeenCalledWith('ws-1', 'draft.md', { - folderId: null, - }) - expect(mocks.ensureCopilotFileFolderPath).toHaveBeenCalledWith(context, 'ws-1', [ - 'Reports', - '2026', - ]) - expect(mocks.moveWorkspaceFileItems).toHaveBeenCalledWith( + expect(mocks.relocateFileVfsItems).toHaveBeenCalledWith( expect.objectContaining({ - input: expect.objectContaining({ targetFolderId: 'ensured-folder' }), + input: expect.objectContaining({ + destination: { segments: ['Reports', '2026', 'final.md'], trailingSlash: false }, + }), }) ) expect(result.success).toBe(true) @@ -335,25 +391,48 @@ describe('vfs mv/cp', () => { }) it('moves into an existing folder keeping the name without creating anything', async () => { - mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue('folder-images') - mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'a.png' }) + mocks.relocateFileVfsItems.mockResolvedValue({ + outcomes: [ + { + source: 'files/a.png', + targetSegments: ['Images', 'a.png'], + resourceType: 'file', + resourceId: 'file-1', + }, + ], + }) const result = await executeVfsMv( { sources: ['files/a.png'], destination: 'files/Images' }, context ) - expect(mocks.moveWorkspaceFileItems).toHaveBeenCalledWith( + expect(mocks.relocateFileVfsItems).toHaveBeenCalledWith( expect.objectContaining({ - input: expect.objectContaining({ targetFolderId: 'folder-images' }), + input: expect.objectContaining({ + destination: { segments: ['Images'], trailingSlash: false }, + }), }) ) - expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ to: 'files/Images/a.png' }] }) }) it('requires a folder destination for multiple sources', async () => { + mocks.relocateFileVfsItems.mockResolvedValue({ + outcomes: [ + { + source: 'files/a.png', + resourceType: 'file', + error: 'Destination must be a folder when moving multiple sources', + }, + { + source: 'files/b.png', + resourceType: 'file', + error: 'Destination must be a folder when moving multiple sources', + }, + ], + }) const result = await executeVfsMv( { sources: ['files/a.png', 'files/b.png'], destination: 'files/Images/c.png' }, context @@ -363,8 +442,15 @@ describe('vfs mv/cp', () => { }) it('resolves sources at their exact path only — no cross-folder name fallback', async () => { - mocks.getWorkspaceFileByName.mockResolvedValue(null) - mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) + mocks.relocateFileVfsItems.mockResolvedValue({ + outcomes: [ + { + source: 'files/report.pdf', + resourceType: 'file', + error: 'Not found at files/report.pdf', + }, + ], + }) const result = await executeVfsMv( { sources: ['files/report.pdf'], destination: 'files/Archive/' }, @@ -373,8 +459,7 @@ describe('vfs mv/cp', () => { expect(result.success).toBe(false) expect(result.error).toContain('Not found') - expect(mocks.moveWorkspaceFileItems).not.toHaveBeenCalled() - expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() + expect(mocks.relocateFileVfsItems).toHaveBeenCalledOnce() }) it('rejects copying workspace files — cp is workflows-only', async () => { @@ -391,21 +476,26 @@ describe('vfs mv/cp', () => { }) it('moves and renames a file folder via the shared folder operation', async () => { - mocks.findWorkspaceFileFolderIdByPath - .mockResolvedValueOnce(null) // destination is not an existing folder - .mockResolvedValueOnce('folder-src') // source resolves as folder + mocks.relocateFileVfsItems.mockResolvedValue({ + outcomes: [ + { + source: 'files/Reports', + targetSegments: ['Archive', 'Reports 2025'], + resourceType: 'folder', + resourceId: 'folder-src', + }, + ], + }) const result = await executeVfsMv( { sources: ['files/Reports'], destination: 'files/Archive/Reports 2025' }, context ) - expect(mocks.updateWorkspaceFileFolder).toHaveBeenCalledWith( + expect(mocks.relocateFileVfsItems).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ - folderId: 'folder-src', - name: 'Reports 2025', - parentId: 'ensured-folder', + destination: { segments: ['Archive', 'Reports 2025'], trailingSlash: false }, }), }) ) @@ -414,48 +504,97 @@ describe('vfs mv/cp', () => { }) describe('workflows', () => { - it('renames a workflow at root', async () => { - queueTableRows(schemaMock.workflow, [{ id: 'wf-1', name: 'Old Name', folderId: null }]) - mocks.performUpdateWorkflow.mockResolvedValue({ success: true }) + it('routes an encoded rename through one bounded workflow VFS command', async () => { + mocks.moveWorkflowVfs.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/Old%20Name', + targetSegments: ['New Name'], + resourceType: 'workflow', + resourceId: 'wf-1', + }, + ], + }) const result = await executeVfsMv( { sources: ['workflows/Old%20Name'], destination: 'workflows/New Name' }, context ) - expect(workflowAuthzMockFns.mockAssertWorkflowMutable).toHaveBeenCalledWith('wf-1') - expect(mocks.performUpdateWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ workflowId: 'wf-1', name: 'New Name', folderId: null }) + expect(mocks.moveWorkflowVfs).toHaveBeenCalledWith( + expect.objectContaining({ + principal: expect.objectContaining({ + serviceId: 'copilot', + workspaceId: 'ws-1', + }), + input: { + workspaceId: 'ws-1', + sources: [{ source: 'workflows/Old%20Name', segments: ['Old Name'] }], + destination: { segments: ['New Name'], trailingSlash: false }, + }, + }) ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ to: 'workflows/New%20Name' }] }) }) - it('moves a workflow into an existing folder keeping its name', async () => { - mocks.listFolders.mockResolvedValue([ - { folderId: 'fold-1', folderName: 'Archive', parentId: null }, - ]) - queueTableRows(schemaMock.workflow, [{ id: 'wf-1', name: 'My Workflow', folderId: null }]) - mocks.performUpdateWorkflow.mockResolvedValue({ success: true }) + it('passes a multi-source move to the application once', async () => { + mocks.moveWorkflowVfs.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/One', + targetSegments: ['Archive', 'One'], + resourceType: 'workflow', + resourceId: 'wf-1', + }, + { + source: 'workflows/Two', + targetSegments: ['Archive', 'Two'], + resourceType: 'workflow', + resourceId: 'wf-2', + }, + ], + }) const result = await executeVfsMv( - { sources: ['workflows/My%20Workflow'], destination: 'workflows/Archive' }, + { sources: ['workflows/One', 'workflows/Two'], destination: 'workflows/Archive/' }, context ) - expect(workflowAuthzMockFns.mockAssertFolderMutable).toHaveBeenCalledWith('fold-1') - expect(mocks.performUpdateWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ workflowId: 'wf-1', name: undefined, folderId: 'fold-1' }) - ) + expect(mocks.moveWorkflowVfs).toHaveBeenCalledOnce() expect(result.success).toBe(true) }) - it('surfaces locked-workflow rejections per item', async () => { - queueTableRows(schemaMock.workflow, [{ id: 'wf-1', name: 'Locked One', folderId: null }]) - workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue( - new Error('Workflow is locked') + it('preserves safe workflow application validation errors', async () => { + mocks.moveWorkflowVfs.mockRejectedValueOnce( + new OrchestrationError( + 'validation', + 'With multiple sources the destination must be a folder' + ) ) + const result = await executeVfsMv( + { sources: ['workflows/One', 'workflows/Two'], destination: 'workflows/Renamed' }, + context + ) + + expect(result).toEqual({ + success: false, + error: 'With multiple sources the destination must be a folder', + }) + }) + + it('surfaces locked-workflow rejections per item', async () => { + mocks.moveWorkflowVfs.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/Locked%20One', + resourceType: 'workflow', + error: 'Workflow is locked', + }, + ], + }) + const result = await executeVfsMv( { sources: ['workflows/Locked%20One'], destination: 'workflows/Renamed' }, context @@ -466,8 +605,16 @@ describe('vfs mv/cp', () => { }) it('duplicates a workflow with cp (locked source allowed)', async () => { - queueTableRows(schemaMock.workflow, [{ id: 'wf-1', name: 'Template', folderId: null }]) - mocks.duplicateWorkflow.mockResolvedValue({ id: 'wf-2', name: 'My Copy' }) + mocks.copyWorkflowVfs.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/Template', + targetSegments: ['My Copy'], + resourceType: 'workflow', + resourceId: 'wf-2', + }, + ], + }) const result = await executeVfsCp( { sources: ['workflows/Template'], destination: 'workflows/My Copy' }, @@ -475,22 +622,21 @@ describe('vfs mv/cp', () => { ) expect(workflowAuthzMockFns.mockAssertWorkflowMutable).not.toHaveBeenCalled() - expect(mocks.duplicateWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ - sourceWorkflowId: 'wf-1', - workspaceId: 'ws-1', - folderId: null, - name: 'My Copy', - }) - ) + expect(mocks.copyWorkflowVfs).toHaveBeenCalledOnce() expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ to: 'workflows/My%20Copy', id: 'wf-2' }] }) }) it('rejects copying workflow folders', async () => { - mocks.listFolders.mockResolvedValue([ - { folderId: 'fold-1', folderName: 'Projects', parentId: null }, - ]) + mocks.copyWorkflowVfs.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/Projects', + resourceType: 'folder', + error: 'Workflow folders cannot be copied.', + }, + ], + }) const result = await executeVfsCp( { sources: ['workflows/Projects'], destination: 'workflows/Projects Copy' }, context @@ -500,54 +646,130 @@ describe('vfs mv/cp', () => { }) it('moves and renames a workflow folder', async () => { - mocks.listFolders.mockResolvedValue([ - { folderId: 'fold-1', folderName: 'Q1', parentId: null }, - { folderId: 'fold-2', folderName: 'Archive', parentId: null }, - ]) - mocks.performUpdateFolder.mockResolvedValue({ success: true }) + mocks.moveWorkflowVfs.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/Q1', + targetSegments: ['Archive', 'Q1 2026'], + resourceType: 'folder', + resourceId: 'fold-1', + }, + ], + }) const result = await executeVfsMv( { sources: ['workflows/Q1'], destination: 'workflows/Archive/Q1 2026' }, context ) - expect(mocks.performUpdateFolder).toHaveBeenCalledWith( - expect.objectContaining({ folderId: 'fold-1', name: 'Q1 2026', parentId: 'fold-2' }) + expect(mocks.moveWorkflowVfs).toHaveBeenCalledOnce() + expect(result.success).toBe(true) + }) + + it('does not expose workflow application infrastructure errors', async () => { + mocks.moveWorkflowVfs.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/Old%20Name', + resourceType: 'workflow', + error: 'Workflow mutation failed', + }, + ], + }) + + const result = await executeVfsMv( + { sources: ['workflows/Old%20Name'], destination: 'workflows/New Name' }, + context ) + + expect(result).toMatchObject({ + success: false, + error: 'Workflow mutation failed', + output: { results: [expect.objectContaining({ error: 'Workflow mutation failed' })] }, + }) + }) + + it('deletes an encoded workflow alias through the workflow application operation', async () => { + mocks.deleteWorkflowVfs.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/Old%20Name', + resourceType: 'workflow', + resourceId: 'wf-1', + }, + ], + }) + + const result = await executeVfsRm({ paths: ['workflows/Old%20Name'] }, context) + expect(result.success).toBe(true) + expect(mocks.deleteWorkflowVfs).toHaveBeenCalledWith( + expect.objectContaining({ + principal: expect.objectContaining({ + serviceId: 'copilot', + workspaceId: 'ws-1', + }), + input: { + workspaceId: 'ws-1', + paths: [{ source: 'workflows/Old%20Name', segments: ['Old Name'] }], + }, + }) + ) }) }) describe('mkdir', () => { it('creates a nested file folder chain', async () => { + mocks.createFileVfsFolders.mockResolvedValue({ + outcomes: [ + { + source: 'files/Reports/2026', + targetSegments: ['Reports', '2026'], + resourceType: 'folder', + resourceId: 'folder-2026', + }, + ], + }) const result = await executeVfsMkdir({ paths: ['files/Reports/2026'] }, context) - expect(mocks.ensureCopilotFileFolderPath).toHaveBeenCalledWith(context, 'ws-1', [ - 'Reports', - '2026', - ]) + expect(mocks.createFileVfsFolders).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + workspaceId: 'ws-1', + paths: [{ source: 'files/Reports/2026', segments: ['Reports', '2026'] }], + }, + }) + ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ from: 'files/Reports/2026', to: 'files/Reports/2026', kind: 'file_folder' }], }) }) - it('creates a workflow folder via performCreateFolder', async () => { - mocks.listFolders.mockResolvedValue([]) - mocks.performCreateFolder.mockResolvedValue({ success: true, folder: { id: 'fold-new' } }) - - const result = await executeVfsMkdir({ paths: ['workflows/Archive'] }, context) - - expect(mocks.performCreateFolder).toHaveBeenCalledWith({ - resourceType: 'workflow', - workspaceId: 'ws-1', - userId: 'user-1', - name: 'Archive', - parentId: undefined, + it('creates a workflow folder through the workflow application operation', async () => { + mocks.createWorkflowVfsFolders.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/Project Plans', + targetSegments: ['Project Plans'], + resourceType: 'folder', + resourceId: 'fold-new', + }, + ], }) + const result = await executeVfsMkdir({ paths: ['workflows/Project Plans'] }, context) + + expect(mocks.createWorkflowVfsFolders).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + workspaceId: 'ws-1', + paths: [{ source: 'workflows/Project Plans', segments: ['Project Plans'] }], + }, + }) + ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ - results: [{ to: 'workflows/Archive', kind: 'workflow_folder', id: 'fold-new' }], + results: [{ to: 'workflows/Project%20Plans', kind: 'workflow_folder', id: 'fold-new' }], }) }) @@ -561,28 +783,36 @@ describe('vfs mv/cp', () => { }) it('rejects creation inside a locked workflow folder', async () => { - mocks.listFolders.mockResolvedValue([]) - workflowAuthzMockFns.mockAssertFolderMutable.mockRejectedValue(new Error('Folder is locked')) + mocks.createWorkflowVfsFolders.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/Locked/Sub', + resourceType: 'folder', + error: 'Folder is locked', + }, + ], + }) const result = await executeVfsMkdir({ paths: ['workflows/Locked/Sub'] }, context) expect(result.success).toBe(false) expect(result.error).toContain('locked') - expect(mocks.performCreateFolder).not.toHaveBeenCalled() + expect(mocks.createWorkflowVfsFolders).toHaveBeenCalledOnce() }) }) describe('tables and knowledge bases (flat namespaces)', () => { it('renames a table', async () => { - mocks.listTables.mockResolvedValue([{ id: 'tbl-1', name: 'Leads' }]) - mocks.renameTable.mockResolvedValue({ id: 'tbl-1', name: 'Customers' }) - const result = await executeVfsMv( { sources: ['tables/Leads'], destination: 'tables/Customers' }, context ) - expect(mocks.renameTable).toHaveBeenCalledWith('tbl-1', 'Customers', expect.any(String)) + expect(mocks.renameTableVfs).toHaveBeenCalledWith( + expect.objectContaining({ + input: { workspaceId: 'ws-1', sourceName: 'Leads', newName: 'Customers' }, + }) + ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ to: 'tables/Customers', kind: 'table' }] }) }) @@ -607,20 +837,12 @@ describe('vfs mv/cp', () => { }) it('renames a knowledge base through trusted application operations', async () => { - mocks.listKnowledgeBases.mockResolvedValue({ - knowledgeBases: [{ knowledgeBase: { id: 'kb-1', name: 'Docs' }, folderPath: '/' }], - }) - mocks.updateKnowledgeBase.mockResolvedValue({ - knowledgeBase: { id: 'kb-1', name: 'Product Docs' }, - folderPath: '/', - }) - const result = await executeVfsMv( { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/Product Docs' }, context ) - expect(mocks.updateKnowledgeBase).toHaveBeenCalledWith( + expect(mocks.renameKnowledgeVfs).toHaveBeenCalledWith( expect.objectContaining({ principal: expect.objectContaining({ kind: 'delegated', @@ -629,10 +851,9 @@ describe('vfs mv/cp', () => { delegationId: 'tool-call-1', }), input: { - knowledgeBaseId: 'kb-1', - assertedWorkspaceId: 'ws-1', - name: 'Product Docs', - source: 'agent', + workspaceId: 'ws-1', + sourceName: 'Docs', + newName: 'Product Docs', }, }) ) @@ -640,7 +861,7 @@ describe('vfs mv/cp', () => { }) it('propagates knowledge application infrastructure failures', async () => { - mocks.listKnowledgeBases.mockRejectedValueOnce(new Error('knowledge database unavailable')) + mocks.renameKnowledgeVfs.mockRejectedValueOnce(new Error('knowledge database unavailable')) await expect( executeVfsMv( @@ -651,10 +872,7 @@ describe('vfs mv/cp', () => { }) it('preserves an actionable knowledge rename conflict', async () => { - mocks.listKnowledgeBases.mockResolvedValue({ - knowledgeBases: [{ knowledgeBase: { id: 'kb-1', name: 'Docs' }, folderPath: '/' }], - }) - mocks.updateKnowledgeBase.mockRejectedValue( + mocks.renameKnowledgeVfs.mockRejectedValue( new OrchestrationError('conflict', 'A knowledge base named Product Docs already exists') ) @@ -679,35 +897,25 @@ describe('vfs mv/cp', () => { }) it('deletes a knowledge base through the trusted application operation', async () => { - mocks.listKnowledgeBases.mockResolvedValue({ - knowledgeBases: [{ knowledgeBase: { id: 'kb-1', name: 'Docs' }, folderPath: '/' }], - }) - mocks.deleteKnowledgeBase.mockResolvedValue({ id: 'kb-1', name: 'Docs' }) - const result = await executeVfsRm({ paths: ['knowledgebases/Docs'] }, context) expect(result).toMatchObject({ success: true, output: { results: [{ from: 'knowledgebases/Docs', id: 'kb-1' }] }, }) - expect(mocks.deleteKnowledgeBase).toHaveBeenCalledWith( + expect(mocks.deleteKnowledgeVfs).toHaveBeenCalledWith( expect.objectContaining({ principal: expect.objectContaining({ delegationId: 'tool-call-1' }), input: { - knowledgeBaseId: 'kb-1', - assertedWorkspaceId: 'ws-1', - source: 'agent', + workspaceId: 'ws-1', + sourceName: 'Docs', }, }) ) - expect(mocks.knowledgeBaseDeleted).toHaveBeenCalledWith({ knowledgeBaseId: 'kb-1' }) }) it('preserves an actionable knowledge delete failure', async () => { - mocks.listKnowledgeBases.mockResolvedValue({ - knowledgeBases: [{ knowledgeBase: { id: 'kb-1', name: 'Docs' }, folderPath: '/' }], - }) - mocks.deleteKnowledgeBase.mockRejectedValue( + mocks.deleteKnowledgeVfs.mockRejectedValue( new OrchestrationError('not_found', 'Knowledge base no longer exists') ) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index 961545fb299..6516fe35f15 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -1,53 +1,41 @@ -import { db, workflow as workflowTable } from '@sim/db' import { createLogger } from '@sim/logger' -import { assertFolderMutable, assertWorkflowMutable } from '@sim/platform-authz/workflow' -import { toError } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' -import { - executeCopilotFileUseCase, - resolveCopilotWorkspaceFileReference, -} from '@/lib/copilot/application/execute-file-use-case' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import { executeCopilotKnowledgeUseCase, messageForCopilotKnowledgeError, - resolveCopilotKnowledgePrincipal, } from '@/lib/copilot/application/execute-knowledge-use-case' -import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { ensureWorkflowAccess, ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' +import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' import { - ensureCopilotFileFolderPath, - requireCopilotWorkspace, -} from '@/lib/copilot/tools/server/files/file-folder-application' + executeCopilotWorkflowUseCase, + messageForCopilotWorkflowError, +} from '@/lib/copilot/application/execute-workflow-use-case' +import { messageForCopilotTableError } from '@/lib/copilot/auth/table-delegation' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/files/file-folder-application' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' -import { - buildVfsFolderPathMap, - canonicalWorkflowVfsDir, - decodeVfsPathSegments, - encodeVfsPathSegments, -} from '@/lib/copilot/vfs/path-utils' +import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' -import { generateRequestId } from '@/lib/core/utils/request' -import { createFolder, deleteFolder, updateFolder } from '@/lib/folders/orchestration' import { - deleteKnowledgeBaseOperation, - listKnowledgeBases, - updateKnowledgeBaseOperation, -} from '@/lib/knowledge/application/knowledge-bases' -import { performDeleteTable, performRenameTable } from '@/lib/table/orchestration' -import { listTables } from '@/lib/table/service' -import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' -import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' -import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' -import { archiveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/archive-workspace-file-items' -import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' -import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' -import { fileOperations } from '@/lib/workspace-files/application/operations' -import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' -import { updateWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' + deleteKnowledgeBaseByVfsPath, + renameKnowledgeBaseByVfsPath, +} from '@/lib/knowledge/application/knowledge-vfs' +import { captureServerEvent } from '@/lib/posthog/server' +import { deleteTableByVfsPath, renameTableByVfsPath } from '@/lib/table/application/table-vfs' +import { VfsPathLimitError, validateVfsPathBatch } from '@/lib/vfs/limits' +import { + copyWorkflowVfsItems, + createWorkflowVfsFolders, + deleteWorkflowVfsItems, + moveWorkflowVfsItems, + type WorkflowVfsOutcome, +} from '@/lib/workflows/application/workflow-vfs' +import { + createWorkspaceFileVfsFolders, + deleteWorkspaceFileVfsItems, + relocateWorkspaceFileVfsItems, + type WorkspaceFileVfsOutcome, +} from '@/lib/workspace-files/application/workspace-file-vfs' const logger = createLogger('VfsMutateTools') @@ -98,6 +86,18 @@ function messageForKnowledgeVfsError(error: unknown, forbiddenMessage: string): return classified.code === 'forbidden' ? forbiddenMessage : messageForCopilotKnowledgeError(error) } +function messageForExpectedWorkflowVfsError(error: unknown, fallback: string): string { + const classified = asOrchestrationError(error) + if (!classified || classified.code === 'internal') throw error + return messageForCopilotWorkflowError(error, fallback) +} + +function messageForExpectedTableVfsError(error: unknown): string { + const classified = asOrchestrationError(error) + if (!classified || classified.code === 'internal') throw error + return messageForCopilotTableError(error) +} + /** Top-level VFS segment of a raw (possibly encoded) path. */ function topLevelSegment(path: string): string { return path.trim().replace(/^\/+/, '').split('/')[0] ?? '' @@ -175,12 +175,47 @@ export async function executeVfsMkdir( if (paths.length === 0) { return { success: false, error: 'paths is required (an array of folder VFS paths)' } } + validateVfsPathBatch(paths) const workspaceId = requireCopilotWorkspace(context) - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertMutationNotAborted(context) - let ensureWorkflowFolder: ((segments: string[]) => Promise<string | null>) | undefined + const filePaths = paths.filter((path) => topLevelSegment(path) === 'files') + const fileOutcomes = new Map<string, VfsMutateOutcome>() + if (filePaths.length > 0) { + const result = await executeCopilotFileUseCase(context, createWorkspaceFileVfsFolders, { + workspaceId, + paths: filePaths.map((path) => ({ + source: path, + segments: decodeVfsPathSegments(path).slice(1), + })), + }) + for (const outcome of result.outcomes) { + fileOutcomes.set(outcome.source, presentFileVfsOutcome(outcome)) + } + } + + const workflowPaths = paths.filter((path) => topLevelSegment(path) === 'workflows') + const workflowOutcomes = new Map<string, VfsMutateOutcome>() + if (workflowPaths.length > 0) { + try { + const result = await executeCopilotWorkflowUseCase(context, createWorkflowVfsFolders, { + workspaceId, + paths: workflowPaths.map((path) => ({ + source: path, + segments: decodeVfsPathSegments(path).slice(1), + })), + }) + for (const outcome of result.outcomes) { + workflowOutcomes.set(outcome.source, presentWorkflowVfsOutcome(outcome)) + } + } catch (error) { + const message = messageForExpectedWorkflowVfsError(error, 'Workflow folder creation failed') + for (const path of workflowPaths) { + workflowOutcomes.set(path, { from: path, kind: 'workflow_folder', error: message }) + } + } + } const outcomes: VfsMutateOutcome[] = [] for (const path of paths) { @@ -203,43 +238,37 @@ export async function executeVfsMkdir( } try { assertMutationNotAborted(context) - let folderId: string | null if (top === 'files') { - folderId = await ensureCopilotFileFolderPath(context, workspaceId, segments) + outcomes.push( + fileOutcomes.get(path) ?? { + from: path, + kind: 'file_folder', + error: 'File folder creation failed', + } + ) } else { - ensureWorkflowFolder ??= makeWorkflowFolderEnsurer( - workspaceId, - context.userId, - await loadWorkflowFolderIndex(workspaceId) + outcomes.push( + workflowOutcomes.get(path) ?? { + from: path, + kind: 'workflow_folder', + error: 'Workflow folder creation failed', + } ) - folderId = await ensureWorkflowFolder(segments) } - outcomes.push({ - from: path, - to: `${top}/${encodeVfsPathSegments(segments)}`, - kind, - id: folderId ?? undefined, - }) } catch (error) { - outcomes.push({ - from: path, - kind, - error: - top === 'files' - ? messageForCopilotFileError(error, 'File folder creation failed') - : toError(error).message, - }) + const classified = asOrchestrationError(error) + if (!classified || classified.code === 'internal') throw error + outcomes.push({ from: path, kind, error: classified.message }) } } return buildResult('mkdir', outcomes) } catch (error) { - return { - success: false, - error: context.abortSignal?.aborted - ? 'Request aborted before the mutation could be applied.' - : 'Mutation failed', + if (context.abortSignal?.aborted) { + return { success: false, error: 'Request aborted before the mutation could be applied.' } } + if (error instanceof VfsPathLimitError) return { success: false, error: error.message } + throw error } } @@ -257,18 +286,14 @@ async function executeVfsMutate( if (!destination) { return { success: false, error: 'destination is required' } } + validateVfsPathBatch([...sources, destination]) const workspaceId = requireCopilotWorkspace(context) - if (topLevelSegment(sources[0]) === 'knowledgebases') { - resolveCopilotKnowledgePrincipal(context) - } - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertMutationNotAborted(context) const classified = classifyCategory(sources[0]) if ('error' in classified) return { success: false, error: classified.error } const { category } = classified - for (const source of sources.slice(1)) { const other = classifyCategory(source) if ('error' in other) return { success: false, error: other.error } @@ -300,96 +325,11 @@ async function executeVfsMutate( if (error instanceof KnowledgeVfsInfrastructureError) { throw error.infrastructureCause } - return { - success: false, - error: context.abortSignal?.aborted - ? 'Request aborted before the mutation could be applied.' - : 'Mutation failed', + if (context.abortSignal?.aborted) { + return { success: false, error: 'Request aborted before the mutation could be applied.' } } - } -} - -interface DestinationPlan { - /** True when sources move INTO the destination folder keeping their names. */ - dirMode: boolean - /** Decoded display-name segments of the destination folder. */ - folderSegments: string[] - /** New leaf name; set only when `dirMode` is false. */ - leafName?: string - /** - * Resolve the destination folder id, creating missing folders on first call. - * Deferred and memoized so nothing is created until a source is confirmed - * valid — a fully-failed mv/cp must not leave folders behind. - */ - ensureFolderId: () => Promise<string | null> -} - -/** - * Shared destination interpretation for every category with folders: an - * existing folder (or a trailing "/") means move/copy INTO it keeping names; - * otherwise the last segment is the new name and the preceding segments are - * the target folder. Folder creation is deferred to `ensureFolderId`. - */ -async function planDestination(args: { - destination: string - sourceCount: number - lookupFolder: (segments: string[]) => Promise<string | null> - ensureFolderPath: (segments: string[]) => Promise<string | null> -}): Promise<DestinationPlan | { error: string }> { - const rest = decodeVfsPathSegments(args.destination).slice(1) - const plan = ( - dirMode: boolean, - folderSegments: string[], - leafName?: string, - knownFolderId?: string | null - ): DestinationPlan => { - let memo: Promise<string | null> | undefined - return { - dirMode, - folderSegments, - leafName, - ensureFolderId: () => - (memo ??= - knownFolderId !== undefined - ? Promise.resolve(knownFolderId) - : folderSegments.length > 0 - ? args.ensureFolderPath(folderSegments) - : Promise.resolve(null)), - } - } - - if (rest.length === 0) return plan(true, [], undefined, null) - if (hasTrailingSlash(args.destination)) return plan(true, rest) - const existing = await args.lookupFolder(rest) - if (existing) return plan(true, rest, undefined, existing) - if (args.sourceCount > 1) { - return { - error: `With multiple sources the destination must be a folder. "${args.destination}" does not exist — end it with "/" to create it.`, - } - } - return plan(false, rest.slice(0, -1), rest.at(-1) as string) -} - -/** - * Resolve a `files/...` source to the file at EXACTLY that path (folder- - * anchored). Deliberately not the lenient read-side resolver — on a - * destructive path a bare-name fallback could match a file in a different - * folder than the one named. - */ -async function resolveFileAtExactPath( - workspaceId: string, - segments: string[], - context: ExecutionContext -): Promise<WorkspaceFileRecord | null> { - try { - return await resolveCopilotWorkspaceFileReference(context, fileOperations.move, { - workspaceId, - reference: `files/${encodeVfsPathSegments(segments)}`, - }) - } catch (error) { - const classified = asOrchestrationError(error) - if (classified?.code !== 'not_found') throw error - return null + if (error instanceof VfsPathLimitError) return { success: false, error: error.message } + throw error } } @@ -406,228 +346,43 @@ async function mutateWorkspaceFiles( error: 'Workspace files cannot be copied — cp only duplicates workflows.', } } - const dest = await planDestination({ - destination, - sourceCount: sources.length, - lookupFolder: (segments) => findWorkspaceFileFolderIdByPath(workspaceId, segments), - ensureFolderPath: (segments) => ensureCopilotFileFolderPath(context, workspaceId, segments), + assertMutationNotAborted(context) + const result = await executeCopilotFileUseCase(context, relocateWorkspaceFileVfsItems, { + workspaceId, + sources: sources.map((source) => ({ + source, + segments: decodeVfsPathSegments(source).slice(1), + })), + destination: { + segments: decodeVfsPathSegments(destination).slice(1), + trailingSlash: hasTrailingSlash(destination), + }, }) - if ('error' in dest) return { success: false, error: dest.error } - - // Resolve every source read-only before mutating anything, so a fully - // invalid call cannot create destination folders as a side effect. - type SourceRef = - | { source: string; file: WorkspaceFileRecord } - | { source: string; folderId: string } - | { source: string; error: string } - const refs: SourceRef[] = [] - for (const source of sources) { - const segments = decodeVfsPathSegments(source).slice(1) - if (segments.length === 0) { - refs.push({ source, error: 'Source must name a file or folder under files/' }) - continue - } - const file = await resolveFileAtExactPath(workspaceId, segments, context) - if (file) { - refs.push({ source, file }) - continue - } - const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) - if (folderId) refs.push({ source, folderId }) - else refs.push({ source, error: `Not found: ${source}` }) - } - - const outcomes: VfsMutateOutcome[] = [] - for (const ref of refs) { - if ('error' in ref) { - outcomes.push({ from: ref.source, kind: 'file', error: ref.error }) - continue - } - - if ('file' in ref) { - assertMutationNotAborted(context) - const targetName = dest.dirMode ? ref.file.name : (dest.leafName as string) - const targetFolderId = await dest.ensureFolderId() - if (targetFolderId === ref.file.folderId) { - try { - const result = await executeCopilotFileUseCase( - context, - renameWorkspaceFile, - { - fileId: ref.file.id, - assertedWorkspaceId: workspaceId, - name: targetName, - }, - { fileId: ref.file.id } - ) - outcomes.push({ - from: ref.source, - to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.file.name])}`, - kind: 'file', - id: ref.file.id, - }) - } catch (error) { - outcomes.push({ - from: ref.source, - kind: 'file', - error: messageForCopilotFileError(error), - }) - } - continue - } - try { - await executeCopilotFileUseCase( - context, - moveWorkspaceFileItemsOperation, - { workspaceId, fileIds: [ref.file.id], targetFolderId }, - { fileId: ref.file.id } - ) - let finalName = ref.file.name - if (targetName !== ref.file.name) { - const renamed = await executeCopilotFileUseCase( - context, - renameWorkspaceFile, - { - fileId: ref.file.id, - assertedWorkspaceId: workspaceId, - name: targetName, - }, - { fileId: ref.file.id } - ) - finalName = renamed.file.name - } - outcomes.push({ - from: ref.source, - to: `files/${encodeVfsPathSegments([...dest.folderSegments, finalName])}`, - kind: 'file', - id: ref.file.id, - }) - } catch (error) { - outcomes.push({ - from: ref.source, - kind: 'file', - error: messageForCopilotFileError(error, 'Failed to move file'), - }) - } - continue - } - - assertMutationNotAborted(context) - const targetFolderId = await dest.ensureFolderId() - if (targetFolderId === ref.folderId) { - outcomes.push({ - from: ref.source, - kind: 'file_folder', - error: 'Cannot move a folder into itself', - }) - continue - } - try { - const result = await executeCopilotFileUseCase(context, updateWorkspaceFileFolderOperation, { - workspaceId, - folderId: ref.folderId, - name: dest.dirMode ? undefined : dest.leafName, - parentId: targetFolderId, - }) - outcomes.push({ - from: ref.source, - to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.folder.name])}`, - kind: 'file_folder', - id: ref.folderId, - }) - } catch (error) { - outcomes.push({ - from: ref.source, - kind: 'file_folder', - error: messageForCopilotFileError(error, 'Failed to move folder'), - }) - } - } - - return buildResult(verb, outcomes) -} - -interface WorkflowFolderIndex { - folderPathById: Map<string, string> - folderIdByPath: Map<string, string> -} - -async function loadWorkflowFolderIndex(workspaceId: string): Promise<WorkflowFolderIndex> { - const folderPathById = buildVfsFolderPathMap(await listFolders(workspaceId)) - const folderIdByPath = new Map<string, string>() - for (const [id, path] of folderPathById.entries()) folderIdByPath.set(path, id) - return { folderPathById, folderIdByPath } + return buildResult(verb, result.outcomes.map(presentFileVfsOutcome)) } -/** - * mkdir -p for workflow folders: resolves each segment against the index, - * creating missing ones (locked parents rejected) and keeping the index maps - * current so later paths in the same call see the new folders. - */ -function makeWorkflowFolderEnsurer( - workspaceId: string, - userId: string, - index: WorkflowFolderIndex -): (segments: string[]) => Promise<string | null> { - return async (segments) => { - let parentId: string | null = null - let pathSoFar = '' - for (const segment of segments) { - pathSoFar = pathSoFar - ? `${pathSoFar}/${encodeVfsPathSegments([segment])}` - : encodeVfsPathSegments([segment]) - const existing = index.folderIdByPath.get(pathSoFar) - if (existing) { - parentId = existing - continue - } - await assertFolderMutable(parentId) - const created = await createFolder({ - resourceType: 'workflow', - workspaceId, - userId, - name: segment, - parentId: parentId ?? undefined, - }) - if (!created.success || !created.folder) { - throw new Error(created.error || `Failed to create workflow folder "${segment}"`) - } - index.folderIdByPath.set(pathSoFar, created.folder.id) - index.folderPathById.set(created.folder.id, pathSoFar) - parentId = created.folder.id - } - return parentId +function presentFileVfsOutcome(outcome: WorkspaceFileVfsOutcome): VfsMutateOutcome { + return { + from: outcome.source, + ...(outcome.targetSegments + ? { to: `files/${encodeVfsPathSegments(outcome.targetSegments)}` } + : {}), + kind: outcome.resourceType === 'file' ? 'file' : 'file_folder', + id: outcome.resourceId, + error: outcome.error, } } -interface WorkflowRow { - id: string - name: string - folderId: string | null -} - -/** - * Every workflow in the workspace keyed by its canonical VFS directory, so a - * path resolves without a query per path. Shared by mv/cp and rm, which ask the - * same question of a workflows/ path: is this a workflow or a folder? - */ -async function loadWorkflowsByVfsPath( - workspaceId: string, - folderPathById: Map<string, string> -): Promise<Map<string, WorkflowRow>> { - const rows = await db - .select({ id: workflowTable.id, name: workflowTable.name, folderId: workflowTable.folderId }) - .from(workflowTable) - .where(eq(workflowTable.workspaceId, workspaceId)) - const byPath = new Map<string, WorkflowRow>() - for (const row of rows) { - const dir = canonicalWorkflowVfsDir({ - name: row.name, - folderPath: row.folderId ? folderPathById.get(row.folderId) : null, - }) - if (!byPath.has(dir)) byPath.set(dir, row) +function presentWorkflowVfsOutcome(outcome: WorkflowVfsOutcome): VfsMutateOutcome { + return { + from: outcome.source, + ...(outcome.targetSegments + ? { to: `workflows/${encodeVfsPathSegments(outcome.targetSegments)}` } + : {}), + kind: outcome.resourceType === 'workflow' ? 'workflow' : 'workflow_folder', + id: outcome.resourceId, + error: outcome.error, } - return byPath } async function mutateWorkflows( @@ -637,170 +392,31 @@ async function mutateWorkflows( context: ExecutionContext, workspaceId: string ): Promise<ToolCallResult> { - const index = await loadWorkflowFolderIndex(workspaceId) - const { folderPathById, folderIdByPath } = index - - const workflowByPath = await loadWorkflowsByVfsPath(workspaceId, folderPathById) - - const ensureWorkflowFolderPath = makeWorkflowFolderEnsurer(workspaceId, context.userId, index) - - const dest = await planDestination({ - destination, - sourceCount: sources.length, - lookupFolder: async (segments) => folderIdByPath.get(encodeVfsPathSegments(segments)) ?? null, - ensureFolderPath: ensureWorkflowFolderPath, - }) - if ('error' in dest) return { success: false, error: dest.error } - if (!dest.dirMode && (dest.leafName as string).length > 200) { - return { success: false, error: 'Workflow name must be 200 characters or less' } - } - - // Resolve every source against the in-memory maps before mutating anything. - type SourceRef = - | { source: string; workflow: WorkflowRow } - | { source: string; folderId: string } - | { source: string; error: string } - const refs: SourceRef[] = [] - for (const source of sources) { - const segments = decodeVfsPathSegments(source).slice(1) - if (segments.length === 0) { - refs.push({ source, error: 'Source must name a workflow or folder under workflows/' }) - continue - } - const encoded = encodeVfsPathSegments(segments) - const wf = workflowByPath.get(`workflows/${encoded}`) - if (wf) { - refs.push({ source, workflow: wf }) - continue - } - const folderId = folderIdByPath.get(encoded) - if (folderId) refs.push({ source, folderId }) - else refs.push({ source, error: `Not found: ${source}` }) + assertMutationNotAborted(context) + const input = { + workspaceId, + sources: sources.map((source) => ({ + source, + segments: decodeVfsPathSegments(source).slice(1), + })), + destination: { + segments: decodeVfsPathSegments(destination).slice(1), + trailingSlash: hasTrailingSlash(destination), + }, } - - const outcomes: VfsMutateOutcome[] = [] - for (const ref of refs) { - if ('error' in ref) { - outcomes.push({ from: ref.source, kind: 'workflow', error: ref.error }) - continue - } - - if ('workflow' in ref) { - const wf = ref.workflow - const targetName = dest.dirMode ? wf.name : (dest.leafName as string) - try { - assertMutationNotAborted(context) - if (verb === 'cp') { - const targetFolderId = await dest.ensureFolderId() - const duplicated = await duplicateWorkflow({ - sourceWorkflowId: wf.id, - userId: context.userId, - workspaceId, - folderId: targetFolderId, - name: targetName, - requestId: generateRequestId(), - }) - outcomes.push({ - from: ref.source, - to: `workflows/${encodeVfsPathSegments([...dest.folderSegments, duplicated.name])}`, - kind: 'workflow', - id: duplicated.id, - }) - } else { - await ensureWorkflowAccess(wf.id, context.userId, 'write') - await assertWorkflowMutable(wf.id) - const targetFolderId = await dest.ensureFolderId() - await assertFolderMutable(targetFolderId) - if (targetFolderId && !(await verifyFolderWorkspace(targetFolderId, workspaceId))) { - outcomes.push({ - from: ref.source, - kind: 'workflow', - error: 'Destination folder not found', - }) - continue - } - const result = await performUpdateWorkflow({ - workflowId: wf.id, - userId: context.userId, - workspaceId, - currentName: wf.name, - currentFolderId: wf.folderId, - name: dest.dirMode ? undefined : targetName, - folderId: targetFolderId, - }) - outcomes.push( - result.success - ? { - from: ref.source, - to: `workflows/${encodeVfsPathSegments([...dest.folderSegments, targetName])}`, - kind: 'workflow', - id: wf.id, - } - : { - from: ref.source, - kind: 'workflow', - error: result.error || 'Failed to move workflow', - } - ) - } - } catch (error) { - outcomes.push({ from: ref.source, kind: 'workflow', error: toError(error).message }) - } - continue - } - - if (verb === 'cp') { - outcomes.push({ - from: ref.source, - kind: 'workflow_folder', - error: 'Workflow folders cannot be copied.', - }) - continue - } - try { - assertMutationNotAborted(context) - await assertFolderMutable(ref.folderId) - const targetFolderId = await dest.ensureFolderId() - if (targetFolderId === ref.folderId) { - outcomes.push({ - from: ref.source, - kind: 'workflow_folder', - error: 'Cannot move a folder into itself', - }) - continue - } - await assertFolderMutable(targetFolderId) - const result = await updateFolder({ - resourceType: 'workflow', - folderId: ref.folderId, - workspaceId, - userId: context.userId, - name: dest.dirMode ? undefined : dest.leafName, - parentId: targetFolderId, - }) - const finalLeaf = dest.dirMode - ? (decodeVfsPathSegments(ref.source).slice(1).at(-1) ?? '') - : (dest.leafName as string) - outcomes.push( - result.success - ? { - from: ref.source, - to: `workflows/${encodeVfsPathSegments([...dest.folderSegments, finalLeaf])}`, - kind: 'workflow_folder', - id: ref.folderId, - } - : { - from: ref.source, - kind: 'workflow_folder', - error: result.error || 'Failed to move folder', - } - ) - } catch (error) { - outcomes.push({ from: ref.source, kind: 'workflow_folder', error: toError(error).message }) + try { + const result = + verb === 'cp' + ? await executeCopilotWorkflowUseCase(context, copyWorkflowVfsItems, input) + : await executeCopilotWorkflowUseCase(context, moveWorkflowVfsItems, input) + return buildResult(verb, result.outcomes.map(presentWorkflowVfsOutcome)) + } catch (error) { + if (context.abortSignal?.aborted) throw error + return { + success: false, + error: messageForExpectedWorkflowVfsError(error, 'Workflow mutation failed'), } } - - return buildResult(verb, outcomes) } async function renameFlatResource( @@ -832,76 +448,58 @@ async function renameFlatResource( const sourceName = sourceSegments[0] const newName = destSegments[0] - const canonicalSource = normalizeVfsSegment(sourceName) if (category === 'tables') { - const tables = await listTables(workspaceId) - const match = tables.find((t) => normalizeVfsSegment(t.name) === canonicalSource) - if (!match) { - return { success: false, error: `Table not found at ${sources[0]}` } - } - assertMutationNotAborted(context) - const renameOutcome = await performRenameTable({ - table: match, - newName, - userId: context.userId, - requestId: generateRequestId(), - }) - if (!renameOutcome.success) { - return { success: false, error: renameOutcome.error ?? 'Failed to rename table' } + try { + const renamed = await executeCopilotTableUseCase( + context, + renameTableByVfsPath, + { workspaceId, sourceName, newName }, + {} + ) + return buildResult(verb, [ + { + from: sources[0], + to: `tables/${normalizeVfsSegment(renamed.name)}`, + kind, + id: renamed.id, + }, + ]) + } catch (error) { + return { success: false, error: messageForExpectedTableVfsError(error) } } - return buildResult(verb, [ - { - from: sources[0], - to: `tables/${normalizeVfsSegment(newName)}`, - kind, - id: match.id, - }, - ]) } if (newName.toLowerCase() === 'connectors') { return { success: false, error: '"knowledgebases/connectors" is a reserved path.' } } - let knowledgeBases: Awaited<ReturnType<typeof listKnowledgeBases.execute>>['knowledgeBases'] try { - const result = await executeCopilotKnowledgeUseCase(context, listKnowledgeBases, { + const renamed = await executeCopilotKnowledgeUseCase(context, renameKnowledgeBaseByVfsPath, { workspaceId, + sourceName, + newName, }) - knowledgeBases = result.knowledgeBases - } catch (error) { - return { - success: false, - error: messageForKnowledgeVfsError(error, 'Write access required to rename knowledge bases'), - } - } - const match = knowledgeBases - .map(({ knowledgeBase }) => knowledgeBase) - .find((kb) => normalizeVfsSegment(kb.name) === canonicalSource) - if (!match) { - return { success: false, error: `Knowledge base not found at ${sources[0]}` } - } - assertMutationNotAborted(context) - try { - await executeCopilotKnowledgeUseCase(context, updateKnowledgeBaseOperation, { - knowledgeBaseId: match.id, - assertedWorkspaceId: workspaceId, - name: newName, - source: 'agent', + logger.info('Renamed knowledge base via mv', { + knowledgeBaseId: renamed.id, + workspaceId, }) + return buildResult(verb, [ + { + from: sources[0], + to: `knowledgebases/${normalizeVfsSegment(renamed.name)}`, + kind, + id: renamed.id, + }, + ]) } catch (error) { return { success: false, error: messageForKnowledgeVfsError( error, - `Write access required to rename knowledge base "${match.name}"` + `Write access required to rename knowledge base "${sourceName}"` ), } } - logger.info('Renamed knowledge base via mv', { knowledgeBaseId: match.id, workspaceId }) - return buildResult(verb, [ - { from: sources[0], to: `knowledgebases/${normalizeVfsSegment(newName)}`, kind, id: match.id }, - ]) } /** @@ -922,17 +520,47 @@ export async function executeVfsRm( if (paths.length === 0) { return { success: false, error: 'paths is required (an array of VFS paths to delete)' } } + validateVfsPathBatch(paths) const workspaceId = requireCopilotWorkspace(context) - if (paths.some((path) => topLevelSegment(path) === 'knowledgebases')) { - resolveCopilotKnowledgePrincipal(context) - } - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertMutationNotAborted(context) - // Loaded at most once, and only when a workflows/ path in this call needs it. - let workflowIndex: Promise<WorkflowRemoveIndex> | undefined - const getWorkflowIndex = () => (workflowIndex ??= loadWorkflowRemoveIndex(workspaceId)) + const filePaths = paths.filter((path) => topLevelSegment(path) === 'files') + const fileOutcomes = new Map<string, VfsMutateOutcome>() + if (filePaths.length > 0) { + const result = await executeCopilotFileUseCase(context, deleteWorkspaceFileVfsItems, { + workspaceId, + paths: filePaths.map((path) => ({ + source: path, + segments: decodeVfsPathSegments(path).slice(1), + })), + }) + for (const outcome of result.outcomes) { + fileOutcomes.set(outcome.source, presentFileVfsOutcome(outcome)) + } + } + + const workflowPaths = paths.filter((path) => topLevelSegment(path) === 'workflows') + const workflowOutcomes = new Map<string, VfsMutateOutcome>() + if (workflowPaths.length > 0) { + try { + const result = await executeCopilotWorkflowUseCase(context, deleteWorkflowVfsItems, { + workspaceId, + paths: workflowPaths.map((path) => ({ + source: path, + segments: decodeVfsPathSegments(path).slice(1), + })), + }) + for (const outcome of result.outcomes) { + workflowOutcomes.set(outcome.source, presentWorkflowVfsOutcome(outcome)) + } + } catch (error) { + const message = messageForExpectedWorkflowVfsError(error, 'Workflow deletion failed') + for (const path of workflowPaths) { + workflowOutcomes.set(path, { from: path, kind: 'workflow', error: message }) + } + } + } const outcomes: VfsMutateOutcome[] = [] for (const path of paths) { @@ -943,19 +571,32 @@ export async function executeVfsRm( } try { assertMutationNotAborted(context) - outcomes.push( - await removeOne(classified.category, path, context, workspaceId, getWorkflowIndex) - ) + if (classified.category === 'workflows') { + outcomes.push( + workflowOutcomes.get(path) ?? { + from: path, + kind: 'workflow', + error: 'Workflow deletion failed', + } + ) + } else if (classified.category === 'files') { + outcomes.push( + fileOutcomes.get(path) ?? { from: path, kind: 'file', error: 'File deletion failed' } + ) + } else { + outcomes.push(await removeOne(classified.category, path, context, workspaceId)) + } } catch (error) { if (error instanceof KnowledgeVfsInfrastructureError) throw error - outcomes.push({ - from: path, - kind: defaultKindFor(path), - error: - classified.category === 'files' - ? messageForCopilotFileError(error, 'File deletion failed') - : toError(error).message, - }) + if (classified.category === 'workflows') { + outcomes.push({ + from: path, + kind: defaultKindFor(path), + error: messageForExpectedWorkflowVfsError(error, 'Workflow deletion failed'), + }) + continue + } + throw error } } @@ -964,12 +605,11 @@ export async function executeVfsRm( if (error instanceof KnowledgeVfsInfrastructureError) { throw error.infrastructureCause } - return { - success: false, - error: context.abortSignal?.aborted - ? 'Request aborted before the mutation could be applied.' - : 'Delete failed', + if (context.abortSignal?.aborted) { + return { success: false, error: 'Request aborted before the mutation could be applied.' } } + if (error instanceof VfsPathLimitError) return { success: false, error: error.message } + throw error } } @@ -988,17 +628,12 @@ function defaultKindFor(path: string): VfsMutateOutcome['kind'] { } function removeOne( - category: MutateCategory, + category: Exclude<MutateCategory, 'workflows' | 'files'>, path: string, context: ExecutionContext, - workspaceId: string, - getWorkflowIndex: () => Promise<WorkflowRemoveIndex> + workspaceId: string ): Promise<VfsMutateOutcome> { switch (category) { - case 'files': - return removeWorkspaceFilePath(path, context, workspaceId) - case 'workflows': - return removeWorkflowPath(path, context, workspaceId, getWorkflowIndex) case 'tables': return removeTablePath(path, context, workspaceId) case 'knowledgebases': @@ -1006,140 +641,11 @@ function removeOne( } } -/** - * A files/ path is either a leaf file or a folder, and the two cannot collide, - * so resolving the file first and falling back to the folder is unambiguous. - * Both go through performDeleteWorkspaceFileItems — deleting a folder archives - * the files and subfolders inside it. - */ -async function removeWorkspaceFilePath( - path: string, - context: ExecutionContext, - workspaceId: string -): Promise<VfsMutateOutcome> { - let file: WorkspaceFileRecord | undefined - try { - file = await resolveCopilotWorkspaceFileReference(context, fileOperations.delete, { - workspaceId, - reference: path, - }) - } catch (error) { - const classified = asOrchestrationError(error) - if (classified?.code !== 'not_found') throw error - } - if (file) { - await executeCopilotFileUseCase( - context, - deleteWorkspaceFileOperation, - { fileId: file.id, assertedWorkspaceId: workspaceId }, - { fileId: file.id } - ) - logger.info('Deleted workspace file via rm', { fileId: file.id, workspaceId }) - return { from: path, kind: 'file', id: file.id } - } - - const segments = decodeVfsPathSegments(path).slice(1) - if (segments.length === 0) { - return { from: path, kind: 'file', error: 'Path must name a file or folder under files/' } - } - const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) - if (!folderId) return { from: path, kind: 'file', error: `Not found: ${path}` } - - try { - const result = await executeCopilotFileUseCase(context, archiveWorkspaceFileItemsOperation, { - workspaceId, - folderIds: [folderId], - }) - logger.info('Deleted file folder via rm', { folderId, workspaceId }) - return { from: path, kind: 'file_folder', id: folderId } - } catch (error) { - return { - from: path, - kind: 'file_folder', - id: folderId, - error: messageForCopilotFileError(error, 'Failed to delete'), - } - } -} - -interface WorkflowRemoveIndex { - workflowByPath: Map<string, WorkflowRow> - folderIdByPath: Map<string, string> -} - -async function loadWorkflowRemoveIndex(workspaceId: string): Promise<WorkflowRemoveIndex> { - const { folderPathById, folderIdByPath } = await loadWorkflowFolderIndex(workspaceId) - return { - workflowByPath: await loadWorkflowsByVfsPath(workspaceId, folderPathById), - folderIdByPath, - } -} - -/** - * Workflow first, then folder — the same resolution order mv uses. The lock - * assertions are what make a locked workflow (or one inside a locked folder) - * fail here rather than silently archiving. - */ -async function removeWorkflowPath( - path: string, - context: ExecutionContext, - workspaceId: string, - getWorkflowIndex: () => Promise<WorkflowRemoveIndex> -): Promise<VfsMutateOutcome> { - const segments = decodeVfsPathSegments(path).slice(1) - if (segments.length === 0) { - return { - from: path, - kind: 'workflow', - error: 'Path must name a workflow or folder under workflows/', - } - } - const encoded = encodeVfsPathSegments(segments) - const { workflowByPath, folderIdByPath } = await getWorkflowIndex() - - const workflow = workflowByPath.get(`workflows/${encoded}`) - if (workflow) { - await assertWorkflowMutable(workflow.id) - const result = await performDeleteWorkflow({ workflowId: workflow.id, userId: context.userId }) - if (!result.success) { - return { - from: path, - kind: 'workflow', - id: workflow.id, - error: result.error || 'Failed to delete workflow', - } - } - logger.info('Deleted workflow via rm', { workflowId: workflow.id, workspaceId }) - return { from: path, kind: 'workflow', id: workflow.id } - } - - const folderId = folderIdByPath.get(encoded) - if (!folderId) return { from: path, kind: 'workflow', error: `Not found: ${path}` } - - await assertFolderMutable(folderId) - const result = await deleteFolder({ - resourceType: 'workflow', - folderId, - workspaceId, - userId: context.userId, - }) - if (!result.success) { - return { - from: path, - kind: 'workflow_folder', - id: folderId, - error: result.error || 'Failed to delete folder', - } - } - logger.info('Deleted workflow folder via rm', { folderId, workspaceId }) - return { from: path, kind: 'workflow_folder', id: folderId } -} - /** Resolves a flat tables/{name} or knowledgebases/{name} path to its single segment. */ -function flatResourceName(path: string, category: 'tables' | 'knowledgebases'): string | null { +function flatResourceName(path: string): string | null { const segments = decodeVfsPathSegments(path).slice(1) if (segments.length !== 1) return null - return normalizeVfsSegment(segments[0]) + return segments[0] } async function removeTablePath( @@ -1147,29 +653,32 @@ async function removeTablePath( context: ExecutionContext, workspaceId: string ): Promise<VfsMutateOutcome> { - const canonical = flatResourceName(path, 'tables') - if (!canonical) { + const sourceName = flatResourceName(path) + if (!sourceName) { return { from: path, kind: 'table', error: 'tables/ is a flat namespace — rm takes a single name, e.g. rm(["tables/Leads"]).', } } - const match = (await listTables(workspaceId)).find( - (table) => normalizeVfsSegment(table.name) === canonical - ) - if (!match) return { from: path, kind: 'table', error: `Table not found at ${path}` } - - const outcome = await performDeleteTable({ - table: match, - userId: context.userId, - requestId: generateRequestId(), - }) - if (!outcome.success) { - return { from: path, kind: 'table', error: outcome.error ?? 'Failed to archive table' } + try { + const deleted = await executeCopilotTableUseCase( + context, + deleteTableByVfsPath, + { workspaceId, sourceName }, + {} + ) + captureServerEvent( + context.userId, + 'table_deleted', + { table_id: deleted.id, workspace_id: deleted.workspaceId }, + { groups: { workspace: deleted.workspaceId } } + ) + logger.info('Archived table via rm', { tableId: deleted.id, workspaceId }) + return { from: path, kind: 'table', id: deleted.id } + } catch (error) { + return { from: path, kind: 'table', error: messageForExpectedTableVfsError(error) } } - logger.info('Archived table via rm', { tableId: match.id, workspaceId }) - return { from: path, kind: 'table', id: match.id } } async function removeKnowledgeBasePath( @@ -1177,8 +686,8 @@ async function removeKnowledgeBasePath( context: ExecutionContext, workspaceId: string ): Promise<VfsMutateOutcome> { - const canonical = flatResourceName(path, 'knowledgebases') - if (!canonical) { + const sourceName = flatResourceName(path) + if (!sourceName) { return { from: path, kind: 'knowledge_base', @@ -1186,50 +695,32 @@ async function removeKnowledgeBasePath( 'knowledgebases/ is a flat namespace — rm takes a single name, e.g. rm(["knowledgebases/support-docs"]).', } } - if (canonical === normalizeVfsSegment('connectors')) { + if (sourceName.toLowerCase() === 'connectors') { return { from: path, kind: 'knowledge_base', error: '"knowledgebases/connectors" is a reserved path, not a knowledge base.', } } - let knowledgeBases: Awaited<ReturnType<typeof listKnowledgeBases.execute>>['knowledgeBases'] try { - const result = await executeCopilotKnowledgeUseCase(context, listKnowledgeBases, { + const deleted = await executeCopilotKnowledgeUseCase(context, deleteKnowledgeBaseByVfsPath, { workspaceId, + sourceName, }) - knowledgeBases = result.knowledgeBases - } catch (error) { - return { - from: path, - kind: 'knowledge_base', - error: messageForKnowledgeVfsError(error, 'Write access required to delete knowledge bases'), - } - } - const match = knowledgeBases - .map(({ knowledgeBase }) => knowledgeBase) - .find((kb) => normalizeVfsSegment(kb.name) === canonical) - if (!match) - return { from: path, kind: 'knowledge_base', error: `Knowledge base not found at ${path}` } - - try { - await executeCopilotKnowledgeUseCase(context, deleteKnowledgeBaseOperation, { - knowledgeBaseId: match.id, - assertedWorkspaceId: workspaceId, - source: 'agent', + PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: deleted.id }) + logger.info('Deleted knowledge base via rm', { + knowledgeBaseId: deleted.id, + workspaceId, }) + return { from: path, kind: 'knowledge_base', id: deleted.id } } catch (error) { return { from: path, kind: 'knowledge_base', - id: match.id, error: messageForKnowledgeVfsError( error, - `Write access required to delete knowledge base "${match.name}"` + `Write access required to delete knowledge base "${sourceName}"` ), } } - PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: match.id }) - logger.info('Deleted knowledge base via rm', { knowledgeBaseId: match.id, workspaceId }) - return { from: path, kind: 'knowledge_base', id: match.id } } diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts index fa9dc2465e7..49939478d95 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts @@ -1,1146 +1,260 @@ /** * @vitest-environment node */ -import { - dbChainMock, - requestUtilsMockFns, - resetEnvMock, - schemaMock, - setEnv, - workflowAuthzMockFns, -} from '@sim/testing' -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' - -beforeAll(() => { - setEnv({ INTERNAL_API_SECRET: 'secret', SOCKET_SERVER_URL: 'http://socket.test' }) - requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('request-1') -}) - -afterAll(() => { - resetEnvMock() - requestUtilsMockFns.mockGenerateRequestId.mockReset() -}) - -import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionContext } from '@/lib/copilot/request/types' -import { - ANONYMOUS_SECRET_TRACE_REPLACEMENT, - ResolvedSecretTraceRegistry, -} from '@/executor/utils/resolved-secret-trace-registry' - -const { - ensureWorkflowAccessMock, - ensureWorkspaceAccessMock, - setWorkflowVariablesMock, - recordAuditMock, - performCreateWorkflowMock, - executeWorkflowMock, - getExecutionStateForWorkflowMock, - getLatestExecutionStateWithExecutionIdMock, - loadWorkflowFromNormalizedTablesMock, - resolveBillingAttributionMock, - resolveTriggerRunOptionsMock, - checkAttributedUsageLimitsMock, - reserveExecutionSlotMock, - releaseExecutionSlotMock, - decryptSecretMock, -} = vi.hoisted(() => ({ - ensureWorkflowAccessMock: vi.fn(), - ensureWorkspaceAccessMock: vi.fn(), - setWorkflowVariablesMock: vi.fn(), - recordAuditMock: vi.fn(), - performCreateWorkflowMock: vi.fn(), - executeWorkflowMock: vi.fn(), - getExecutionStateForWorkflowMock: vi.fn(), - getLatestExecutionStateWithExecutionIdMock: vi.fn(), - loadWorkflowFromNormalizedTablesMock: vi.fn(), - resolveBillingAttributionMock: vi.fn(), - resolveTriggerRunOptionsMock: vi.fn(), - checkAttributedUsageLimitsMock: vi.fn(), - reserveExecutionSlotMock: vi.fn(), - releaseExecutionSlotMock: vi.fn(), - decryptSecretMock: vi.fn(), -})) - -vi.mock('@sim/audit', () => ({ - AuditAction: { WORKFLOW_VARIABLES_UPDATED: 'WORKFLOW_VARIABLES_UPDATED' }, - AuditResourceType: { WORKFLOW: 'WORKFLOW' }, - recordAudit: recordAuditMock, -})) -vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) - -vi.mock('@/lib/api-key/orchestration', () => ({ - performCreateWorkspaceApiKey: vi.fn(), -})) - -vi.mock('@/lib/billing/core/billing-attribution', () => ({ - checkAttributedUsageLimits: checkAttributedUsageLimitsMock, - resolveBillingAttribution: resolveBillingAttributionMock, -})) - -vi.mock('@/lib/billing/calculations/usage-reservation', () => ({ - releaseExecutionSlot: releaseExecutionSlotMock, - reserveExecutionSlot: reserveExecutionSlotMock, - UsageReservationUnavailableError: class UsageReservationUnavailableError extends Error {}, -})) - -vi.mock('@/lib/core/security/encryption', () => ({ - decryptSecret: decryptSecretMock, - encryptSecret: vi.fn(), -})) - -vi.mock('@/lib/workflows/executor/execute-workflow', () => ({ - executeWorkflow: executeWorkflowMock, +const { mocks } = vi.hoisted(() => ({ + mocks: { + apiKey: vi.fn(), + defaultWorkspace: vi.fn(), + executeWorkflowUseCase: vi.fn(), + hasExecutionResult: vi.fn(), + }, })) -vi.mock('@/lib/workflows/executor/execution-state', () => ({ - getExecutionStateForWorkflow: getExecutionStateForWorkflowMock, - getLatestExecutionStateWithExecutionId: getLatestExecutionStateWithExecutionIdMock, +vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ + executeCopilotWorkflowUseCase: mocks.executeWorkflowUseCase, + messageForCopilotWorkflowError: (_error: unknown, fallback = 'Workflow operation failed') => + fallback, })) -vi.mock('@/lib/workflows/orchestration', () => ({ - performCreateFolder: vi.fn(), - performCreateWorkflow: performCreateWorkflowMock, - performDeleteFolder: vi.fn(), - performDeleteWorkflow: vi.fn(), - performUpdateFolder: vi.fn(), - performUpdateWorkflow: vi.fn(), +vi.mock('@/lib/copilot/application/execute-api-key-use-case', () => ({ + executeCopilotApiKeyUseCase: mocks.apiKey, })) -vi.mock('@/lib/workflows/persistence/utils', () => ({ - loadWorkflowFromNormalizedTables: loadWorkflowFromNormalizedTablesMock, - saveWorkflowToNormalizedTables: vi.fn(), +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + getDefaultWorkspaceId: mocks.defaultWorkspace, })) vi.mock('@/lib/workflows/sanitization/json-sanitizer', () => ({ sanitizeForCopilot: vi.fn((state) => state), })) -vi.mock('@/lib/workflows/triggers/run-options', () => ({ - resolveTriggerRunOptions: resolveTriggerRunOptionsMock, - validateTriggerInput: vi.fn(), -})) - -vi.mock('@/lib/workflows/utils', () => ({ - listFolders: vi.fn(), - setWorkflowVariables: setWorkflowVariablesMock, - verifyFolderWorkspace: vi.fn(), -})) - vi.mock('@/executor/utils/errors', () => ({ - hasExecutionResult: vi.fn(() => false), + hasExecutionResult: mocks.hasExecutionResult, })) -vi.mock('../access', () => ({ - ensureWorkflowAccess: ensureWorkflowAccessMock, - ensureWorkspaceAccess: ensureWorkspaceAccessMock, - getDefaultWorkspaceId: vi.fn(), +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { apiKeyGenerated: vi.fn() }, })) -import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' -import { applyCreateWorkflowOutputToContext } from '@/lib/copilot/request/tools/workflow-context' -import { performUpdateWorkflow } from '@/lib/workflows/orchestration' -import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' import { executeCreateWorkflow, + executeGenerateApiKey, executeMoveWorkflow, executeRunBlock, executeRunFromBlock, executeRunWorkflow, executeRunWorkflowUntilBlock, executeSetGlobalWorkflowVariables, -} from './mutations' +} from '@/lib/copilot/tools/handlers/workflow/mutations' -const performUpdateWorkflowMock = vi.mocked(performUpdateWorkflow) -const listFoldersMock = vi.mocked(listFolders) -const verifyFolderWorkspaceMock = vi.mocked(verifyFolderWorkspace) -const billingAttribution: BillingAttributionSnapshot = { - actorUserId: 'user-1', - workspaceId: 'workspace-1', - organizationId: null, - billedAccountUserId: 'owner-1', - billingEntity: { type: 'user', id: 'owner-1' }, - billingPeriod: { - start: '2026-07-01T00:00:00.000Z', - end: '2026-08-01T00:00:00.000Z', - }, - payerSubscription: null, -} -const childBillingAttribution: BillingAttributionSnapshot = Object.freeze({ - actorUserId: 'user-1', - workspaceId: 'workspace-2', - organizationId: 'organization-2', - billedAccountUserId: 'owner-2', - billingEntity: { type: 'organization', id: 'organization-2' }, - billingPeriod: { - start: '2026-07-01T00:00:00.000Z', - end: '2026-08-01T00:00:00.000Z', - }, - payerSubscription: null, -}) -const executionContext: ExecutionContext = { +const context = { userId: 'user-1', - workflowId: 'workflow-1', workspaceId: 'workspace-1', - billingAttribution, -} - -describe('executeSetGlobalWorkflowVariables', () => { - beforeEach(() => { - vi.clearAllMocks() - global.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) as typeof fetch - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'workflow-1', - variables: {}, - }, - }) - setWorkflowVariablesMock.mockResolvedValue(undefined) - }) - - it('persists variable changes and notifies clients that workflow state changed', async () => { - const result = await executeSetGlobalWorkflowVariables( - { - workflowId: 'workflow-1', - operations: [{ operation: 'add', name: 'threshold', type: 'number', value: '5' }], - }, - { userId: 'user-1' } as any - ) - - expect(result.success).toBe(true) - const [, variables] = setWorkflowVariablesMock.mock.calls[0] - expect(Object.values(variables)).toEqual([ - expect.objectContaining({ - workflowId: 'workflow-1', - name: 'threshold', - type: 'number', - value: 5, - }), - ]) - expect(global.fetch).toHaveBeenCalledWith('http://socket.test/api/workflow-updated', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'secret', - }, - body: JSON.stringify({ workflowId: 'workflow-1' }), - }) - expect(recordAuditMock).toHaveBeenCalled() - }) -}) - -describe('lock enforcement', () => { - beforeEach(() => { - vi.clearAllMocks() - global.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) as typeof fetch - workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) - workflowAuthzMockFns.mockAssertFolderMutable.mockResolvedValue(undefined) - }) - - it('does not persist variable changes when the workflow is locked', async () => { - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { id: 'workflow-1', variables: {} }, - }) - workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValueOnce( - new Error('Workflow is locked') - ) - - const result = await executeSetGlobalWorkflowVariables( - { - workflowId: 'workflow-1', - operations: [{ operation: 'add', name: 'threshold', type: 'number', value: '5' }], - }, - { userId: 'user-1' } as any - ) - - expect(result.success).toBe(false) - expect(result.error).toBe('Workflow is locked') - expect(setWorkflowVariablesMock).not.toHaveBeenCalled() - }) - - it('does not move a workflow into a locked target folder', async () => { - ensureWorkflowAccessMock.mockResolvedValue({ - workspaceId: 'workspace-1', - workflow: { id: 'workflow-1', name: 'WF', folderId: null }, - }) - verifyFolderWorkspaceMock.mockResolvedValue(true) - workflowAuthzMockFns.mockAssertFolderMutable.mockRejectedValueOnce( - new Error('Folder is locked') - ) - - const result = await executeMoveWorkflow( - { workflowIds: ['workflow-1'], folderId: 'locked-folder' }, - { userId: 'user-1' } as any - ) - - expect(result.success).toBe(false) - expect(result.error).toBe('Folder is locked') - expect(performUpdateWorkflowMock).not.toHaveBeenCalled() - }) -}) + workflowId: 'workflow-1', + toolCallId: 'tool-call-1', + billingAttribution: { workspaceId: 'workspace-1' }, +} as ExecutionContext -describe('executeCreateWorkflow billing attribution', () => { +describe('workflow mutation Copilot adapters', () => { beforeEach(() => { vi.clearAllMocks() - ensureWorkspaceAccessMock.mockResolvedValue(undefined) - workflowAuthzMockFns.mockAssertFolderMutable.mockResolvedValue(undefined) - loadWorkflowFromNormalizedTablesMock.mockResolvedValue({ - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - }) - resolveTriggerRunOptionsMock.mockReturnValue([ - { - triggerBlockId: 'trigger-1', - blockName: 'Start', - mockPayload: { source: 'copilot' }, - }, - ]) - executeWorkflowMock.mockResolvedValue({ - success: true, - output: {}, - logs: [], - metadata: { executionId: 'new-execution-1' }, - }) - checkAttributedUsageLimitsMock.mockResolvedValue({ - isExceeded: false, - payerUsage: { currentUsage: 1, limit: 10 }, - }) - reserveExecutionSlotMock.mockResolvedValue({ reserved: true, created: true }) - decryptSecretMock.mockResolvedValue({ decrypted: 'secret-value' }) - listFoldersMock.mockResolvedValue([]) + mocks.defaultWorkspace.mockResolvedValue('workspace-1') + mocks.hasExecutionResult.mockReturnValue(false) }) - it('ignores legacy description input instead of persisting it', async () => { - performCreateWorkflowMock.mockResolvedValue({ - success: true, - workflow: { - id: 'created-workflow', - name: 'Created Workflow', - workspaceId: 'workspace-1', - folderId: null, - }, - }) - const legacyParams = { - name: 'Created Workflow', - workspaceId: 'workspace-1', - description: 'PRIVATE WORKFLOW DESCRIPTION', - } as Parameters<typeof executeCreateWorkflow>[0] - - const result = await executeCreateWorkflow(legacyParams, executionContext) - - expect(result.success).toBe(true) - expect(performCreateWorkflowMock).toHaveBeenCalledWith({ - userId: 'user-1', - workspaceId: 'workspace-1', - name: 'Created Workflow', - folderId: null, - }) - }) - - it('canonicalizes a workflow-folder VFS path and resolves its internal ID', async () => { - listFoldersMock.mockResolvedValue([ - { folderId: 'folder-dream', folderName: 'Dream', parentId: null }, - { - folderId: 'folder-launch-plans', - folderName: 'Launch Plans', - parentId: 'folder-dream', - }, - ]) - performCreateWorkflowMock.mockResolvedValue({ - success: true, + it('maps encoded folder aliases into one create application command', async () => { + mocks.executeWorkflowUseCase.mockResolvedValue({ workflow: { - id: 'created-workflow', - name: 'Created Workflow', + id: 'workflow-new', + name: 'New Workflow', workspaceId: 'workspace-1', - folderId: 'folder-launch-plans', + folderId: 'folder-1', }, + normalizedState: { blocks: {}, edges: [], loops: {}, parallels: {} }, }) const result = await executeCreateWorkflow( - { - name: 'Created Workflow', - workspaceId: 'workspace-1', - folderPath: 'workflows/Dream/Launch%20Plans', - }, - executionContext + { name: ' New Workflow ', folderPath: 'workflows/Launch%20Plans' }, + context ) expect(result.success).toBe(true) - expect(performCreateWorkflowMock).toHaveBeenCalledWith({ - userId: 'user-1', - workspaceId: 'workspace-1', - name: 'Created Workflow', - folderId: 'folder-launch-plans', - }) - expect(workflowAuthzMockFns.mockAssertFolderMutable).toHaveBeenCalledWith('folder-launch-plans') - }) - - it('fails clearly when a workflow-folder VFS path does not exist', async () => { - listFoldersMock.mockResolvedValue([ - { folderId: 'folder-existing', folderName: 'Existing', parentId: null }, - ]) - - const result = await executeCreateWorkflow( + expect(mocks.executeWorkflowUseCase).toHaveBeenCalledWith( + context, + expect.objectContaining({ operation: expect.objectContaining({ id: 'workflows.create' }) }), { - name: 'Created Workflow', workspaceId: 'workspace-1', - folderPath: 'workflows/Dream', - }, - executionContext + name: 'New Workflow', + folderPath: '/Launch%20Plans', + } ) - - expect(result).toEqual({ - success: false, - error: 'Folder not found at workflows/Dream', - }) - expect(performCreateWorkflowMock).not.toHaveBeenCalled() }) - it('rejects canonically ambiguous workflow-folder VFS paths', async () => { - listFoldersMock.mockResolvedValue([ - { folderId: 'folder-cafe-nfc', folderName: 'Caf\u00e9', parentId: null }, - { folderId: 'folder-cafe-nfd', folderName: 'Cafe\u0301', parentId: null }, - ]) + it('calls the compound variable command once', async () => { + mocks.executeWorkflowUseCase.mockResolvedValue({ updated: 2 }) + const operations = [ + { operation: 'add' as const, name: 'threshold', type: 'number', value: '5' }, + ] - const result = await executeCreateWorkflow( - { - name: 'Created Workflow', - workspaceId: 'workspace-1', - folderPath: 'workflows/Caf%C3%A9', - }, - executionContext - ) - - expect(result).toEqual({ - success: false, - error: - 'Folder path is ambiguous after canonicalization: workflows/Caf%C3%A9. Rename one of the conflicting folders and retry.', - }) - expect(performCreateWorkflowMock).not.toHaveBeenCalled() - expect(workflowAuthzMockFns.mockAssertFolderMutable).not.toHaveBeenCalled() - }) - - it('keeps same-workspace creation and subsequent execution on the immutable payer', async () => { - const context: ExecutionContext = { ...executionContext, workflowId: '' } - performCreateWorkflowMock.mockResolvedValue({ - success: true, - workflow: { - id: 'created-workflow', - name: 'Created Workflow', - workspaceId: 'workspace-1', - folderId: null, - }, - }) - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'created-workflow', - userId: 'owner-1', - workspaceId: 'workspace-1', - variables: {}, - }, - }) - - const createResult = await executeCreateWorkflow( - { name: 'Created Workflow', workspaceId: 'workspace-1' }, - context - ) - - expect(createResult.success).toBe(true) - applyCreateWorkflowOutputToContext(createResult.output, context) - expect(context).toMatchObject({ - userId: 'user-1', - workflowId: 'created-workflow', - workspaceId: 'workspace-1', - billingAttribution, - }) - expect(context.billingAttribution).toBe(billingAttribution) - expect(performCreateWorkflowMock).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'user-1', workspaceId: 'workspace-1' }) - ) - - const runResult = await executeRunWorkflow({ useMockPayload: true }, context) - - expect(runResult.success).toBe(true) - expect(executeWorkflowMock.mock.calls[0]?.[3]).toBe('user-1') - expect(executeWorkflowMock.mock.calls[0]?.[4]).toEqual( - expect.objectContaining({ billingAttribution }) - ) - expect(checkAttributedUsageLimitsMock).not.toHaveBeenCalled() - expect(reserveExecutionSlotMock).not.toHaveBeenCalled() - expect(resolveBillingAttributionMock).not.toHaveBeenCalled() - }) - - it('keeps cross-workspace creation scoped while allowing explicit subsequent execution', async () => { - const context: ExecutionContext = { ...executionContext, workflowId: '' } - performCreateWorkflowMock.mockResolvedValue({ - success: true, - workflow: { - id: 'created-workflow', - name: 'Other Workspace Workflow', - workspaceId: 'workspace-2', - folderId: null, - }, - }) - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'created-workflow', - userId: 'owner-2', - workspaceId: 'workspace-2', - variables: {}, - }, - }) - resolveBillingAttributionMock.mockResolvedValue(childBillingAttribution) - - const createResult = await executeCreateWorkflow( - { name: 'Other Workspace Workflow', workspaceId: 'workspace-2' }, - context - ) - - expect(createResult.success).toBe(true) - applyCreateWorkflowOutputToContext(createResult.output, context) - expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith('workspace-2', 'user-1', 'write') - expect(performCreateWorkflowMock).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'user-1', workspaceId: 'workspace-2' }) - ) - expect(context).toMatchObject({ - userId: 'user-1', - workflowId: '', - workspaceId: 'workspace-1', - billingAttribution, - }) - expect(context.billingAttribution).toBe(billingAttribution) - const createOutput = createResult.output as { workflowId: string; workspaceId: string } - expect(createOutput).toEqual( - expect.objectContaining({ workflowId: 'created-workflow', workspaceId: 'workspace-2' }) - ) - - const runResult = await executeRunWorkflow( - { workflowId: createOutput.workflowId, useMockPayload: true }, + const result = await executeSetGlobalWorkflowVariables( + { workflowId: 'workflow-1', operations }, context ) - expect(runResult.success).toBe(true) - expect(resolveBillingAttributionMock).toHaveBeenCalledOnce() - expect(resolveBillingAttributionMock).toHaveBeenCalledWith({ - actorUserId: 'user-1', - workspaceId: 'workspace-2', - }) - expect(executeWorkflowMock.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ id: 'created-workflow', workspaceId: 'workspace-2' }) - ) - expect(executeWorkflowMock.mock.calls[0]?.[3]).toBe('user-1') - expect(executeWorkflowMock.mock.calls[0]?.[4]).toEqual( - expect.objectContaining({ billingAttribution: childBillingAttribution }) - ) - expect(checkAttributedUsageLimitsMock).toHaveBeenCalledOnce() - expect(checkAttributedUsageLimitsMock).toHaveBeenCalledWith(childBillingAttribution) - expect(reserveExecutionSlotMock).toHaveBeenCalledOnce() - expect(reserveExecutionSlotMock).toHaveBeenCalledWith( + expect(result).toEqual({ success: true, output: { updated: 2 } }) + expect(mocks.executeWorkflowUseCase).toHaveBeenCalledOnce() + expect(mocks.executeWorkflowUseCase).toHaveBeenCalledWith( + context, expect.objectContaining({ - billingEntity: childBillingAttribution.billingEntity, - executionId: executeWorkflowMock.mock.calls[0]?.[5], - }) - ) - expect(context.billingAttribution).toBe(billingAttribution) - }) -}) - -describe('Copilot workflow execution billing attribution', () => { - const sourceSnapshot = { - blockStates: {}, - executedBlocks: [], - blockLogs: [], - decisions: {}, - completedLoops: [], - activeExecutionPath: [], - } - - beforeEach(() => { - vi.clearAllMocks() - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'workflow-1', - userId: 'owner-1', - workspaceId: 'workspace-1', - variables: {}, - }, - }) - loadWorkflowFromNormalizedTablesMock.mockResolvedValue({ - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - }) - resolveTriggerRunOptionsMock.mockReturnValue([ - { - triggerBlockId: 'trigger-1', - blockName: 'Start', - mockPayload: { source: 'copilot' }, - }, - ]) - getExecutionStateForWorkflowMock.mockResolvedValue(sourceSnapshot) - executeWorkflowMock.mockResolvedValue({ - success: true, - output: {}, - logs: [], - metadata: { executionId: 'new-execution-1' }, - }) - checkAttributedUsageLimitsMock.mockResolvedValue({ - isExceeded: false, - payerUsage: { currentUsage: 1, limit: 10 }, - }) - reserveExecutionSlotMock.mockResolvedValue({ reserved: true, created: true }) - decryptSecretMock.mockResolvedValue({ decrypted: 'secret-value' }) - }) - - async function expectBillingAttributionForwarded( - run: () => Promise<{ success: boolean }> - ): Promise<void> { - const result = await run() - - expect(result.success).toBe(true) - expect(executeWorkflowMock).toHaveBeenCalledTimes(1) - expect(executeWorkflowMock.mock.calls[0]?.[4]).toEqual( - expect.objectContaining({ billingAttribution }) - ) - expect(checkAttributedUsageLimitsMock).not.toHaveBeenCalled() - expect(reserveExecutionSlotMock).not.toHaveBeenCalled() - } - - it('passes immutable attribution when running a workflow', async () => { - await expectBillingAttributionForwarded(() => - executeRunWorkflow({ workflowId: 'workflow-1', useMockPayload: true }, executionContext) - ) - }) - - it('passes only input-crossing parent provenance to the child execution', async () => { - const registry = new ResolvedSecretTraceRegistry( - [ - { - name: 'INPUT_SECRET', - plaintext: 'input-secret', - encryptedValue: 'input-ciphertext', - }, - { - name: 'UNRELATED_SECRET', - plaintext: 'unrelated-secret', - encryptedValue: 'unrelated-ciphertext', - }, - ], - { userId: 'user-1', workspaceId: 'workspace-1' } - ) - registry.recordResolved('INPUT_SECRET', 'input-secret') - registry.recordResolved('UNRELATED_SECRET', 'unrelated-secret') - resolveTriggerRunOptionsMock.mockReturnValueOnce([ + operation: expect.objectContaining({ id: 'workflows.variables.apply_operations' }), + }), { - triggerBlockId: 'trigger-1', - blockName: 'Start', - mockPayload: { value: 'input-secret' }, - }, - ]) - executeWorkflowMock.mockResolvedValueOnce({ - success: true, - output: { ok: true }, - logs: [], - metadata: { executionId: 'new-execution-1' }, - executionState: { - resolvedSecretTraceProvenance: { - version: 1, - complete: true, - entries: [], - scope: { userId: 'user-1', workspaceId: 'workspace-1' }, - }, - }, - }) - - const result = await executeRunWorkflow( - { workflowId: 'workflow-1', useMockPayload: true }, - { ...executionContext, resolvedSecretTraceRegistry: registry } - ) - - expect(result.success).toBe(true) - expect(executeWorkflowMock.mock.calls[0]?.[2]).toEqual({ value: 'input-secret' }) - expect(executeWorkflowMock.mock.calls[0]?.[4]).toEqual( - expect.objectContaining({ - trustedInitialResolvedSecretTraceProvenance: { - version: 1, - complete: true, - entries: [{ name: 'INPUT_SECRET', encryptedValue: 'input-ciphertext' }], - scope: { userId: 'user-1', workspaceId: 'workspace-1' }, - }, - }) + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + operations, + } ) - expect(JSON.stringify(result)).not.toContain('input-ciphertext') - expect(JSON.stringify(result)).not.toContain('unrelated-ciphertext') }) - it('imports child provenance without returning private metadata to the model', async () => { - const registry = new ResolvedSecretTraceRegistry([], { - userId: 'user-1', - workspaceId: 'workspace-1', - }) - const context: ExecutionContext = { - ...executionContext, - resolvedSecretTraceRegistry: registry, - } - executeWorkflowMock.mockResolvedValueOnce({ + it('projects one run command result without exposing binary payloads', async () => { + mocks.executeWorkflowUseCase.mockResolvedValue({ success: true, - output: { value: 'secret-value' }, + output: { file: { base64: 'secret-bytes', name: 'report.pdf' } }, logs: [], - metadata: { executionId: 'new-execution-1' }, - executionState: { - resolvedSecretTraceProvenance: { - version: 1, - complete: true, - entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], - scope: { userId: 'user-1', workspaceId: 'workspace-1' }, - }, - }, + metadata: { executionId: 'execution-1' }, }) const result = await executeRunWorkflow( - { workflowId: 'workflow-1', useMockPayload: true }, + { workflowId: 'workflow-1', workflow_input: { query: 'hello' } }, context ) expect(result).toMatchObject({ success: true, - output: { output: { value: 'secret-value' } }, - }) - expect(registry.getActiveMatches()).toEqual([ - { plaintext: 'secret-value', replacement: '{{API_KEY}}' }, - ]) - expect(JSON.stringify(result)).not.toContain('__resolvedSecretTraceProvenance') - expect(JSON.stringify(result)).not.toContain('encrypted-secret') - }) - - it('keeps unrelated tool-result projection available while child provenance is pending', async () => { - const registry = new ResolvedSecretTraceRegistry([], { - userId: 'user-1', - workspaceId: 'workspace-1', - }) - const context: ExecutionContext = { - ...executionContext, - resolvedSecretTraceRegistry: registry, - } - let resolveExecution!: (value: unknown) => void - let markExecutionStarted!: () => void - const executionStarted = new Promise<void>((resolve) => { - markExecutionStarted = resolve - }) - executeWorkflowMock.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveExecution = resolve - markExecutionStarted() - }) - ) - - const execution = executeRunWorkflow( - { workflowId: 'workflow-1', useMockPayload: true }, - context - ) - await executionStarted - - expect(registry.isComplete()).toBe(false) - expect( - projectToolResultForCopilot({ success: true, output: { value: 'secret-value' } }, registry) - ).toMatchObject({ output: { value: 'secret-value' } }) - - resolveExecution({ - success: true, - output: { value: 'secret-value' }, - logs: [], - metadata: { executionId: 'new-execution-1' }, - executionState: { - resolvedSecretTraceProvenance: { - version: 1, - complete: true, - entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], - scope: { userId: 'user-1', workspaceId: 'workspace-1' }, - }, - }, - }) - - await expect(execution).resolves.toMatchObject({ success: true }) - expect(registry.isComplete()).toBe(true) - expect( - projectToolResultForCopilot({ success: true, output: { value: 'secret-value' } }, registry) - ).toMatchObject({ output: { value: '{{API_KEY}}' } }) - }) - - it('marks provenance incomplete when child execution returns no trusted state', async () => { - const registry = new ResolvedSecretTraceRegistry() - const context: ExecutionContext = { - ...executionContext, - resolvedSecretTraceRegistry: registry, - } - - const result = await executeRunWorkflow( - { workflowId: 'workflow-1', useMockPayload: true }, - context - ) - - expect(result.success).toBe(true) - expect(registry.isComplete()).toBe(false) - }) - - it('filters and anonymizes cross-workspace child provenance to values that cross back', async () => { - const registry = new ResolvedSecretTraceRegistry([], { - userId: 'user-1', - workspaceId: 'workspace-1', - }) - const context: ExecutionContext = { - ...executionContext, - resolvedSecretTraceRegistry: registry, - } - ensureWorkflowAccessMock.mockResolvedValueOnce({ - workflow: { - id: 'workflow-2', - userId: 'owner-2', - workspaceId: 'workspace-2', - variables: {}, - }, - }) - resolveBillingAttributionMock.mockResolvedValueOnce(childBillingAttribution) - decryptSecretMock.mockImplementation(async (encryptedValue: string) => ({ - decrypted: encryptedValue === 'used-ciphertext' ? 'used-secret' : 'workspace-only-secret', - })) - executeWorkflowMock.mockResolvedValueOnce({ - success: true, - output: { value: 'used-secret' }, - logs: [], - metadata: { executionId: 'child-execution' }, - executionState: { - resolvedSecretTraceProvenance: { - version: 1, - complete: true, - entries: [ - { name: 'USED', encryptedValue: 'used-ciphertext' }, - { name: 'WORKSPACE_ONLY', encryptedValue: 'workspace-only-ciphertext' }, - ], - scope: { userId: 'user-1', workspaceId: 'workspace-2' }, - }, + output: { + executionId: 'execution-1', + output: { file: { name: 'report.pdf' } }, }, }) - - const result = await executeRunWorkflow( - { workflowId: 'workflow-2', useMockPayload: true }, - context - ) - - expect(result).toMatchObject({ - success: true, - output: { output: { value: 'used-secret' } }, - }) - expect(registry.getActiveMatches()).toEqual([ - { plaintext: 'used-secret', replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT }, - ]) - expect(JSON.stringify(result)).not.toContain('workspace-only-ciphertext') - expect(JSON.stringify(result)).not.toContain('__resolvedSecretTraceProvenance') - }) - - it('passes immutable attribution when running until a block', async () => { - await expectBillingAttributionForwarded(() => - executeRunWorkflowUntilBlock( - { - workflowId: 'workflow-1', - stopAfterBlockId: 'agent-1', - useMockPayload: true, - }, - executionContext - ) - ) - }) - - it('passes immutable attribution when running from a block', async () => { - await expectBillingAttributionForwarded(() => - executeRunFromBlock( - { - workflowId: 'workflow-1', - startBlockId: 'agent-1', - executionId: 'source-execution-1', - }, - executionContext - ) - ) - }) - - it('passes immutable attribution when running one block', async () => { - await expectBillingAttributionForwarded(() => - executeRunBlock( - { - workflowId: 'workflow-1', - blockId: 'agent-1', - executionId: 'source-execution-1', - }, - executionContext - ) + expect(mocks.executeWorkflowUseCase).toHaveBeenCalledWith( + context, + expect.objectContaining({ + operation: expect.objectContaining({ id: 'workflows.copilot.run' }), + }), + expect.objectContaining({ + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + workflowInput: { query: 'hello' }, + hasWorkflowInput: true, + lifecycle: expect.objectContaining({ billingAttribution: context.billingAttribution }), + }) ) }) it.each([ { - mode: 'a workflow', - run: (context: ExecutionContext) => - executeRunWorkflow({ workflowId: 'workflow-2', useMockPayload: true }, context), - }, - { - mode: 'until a block', - run: (context: ExecutionContext) => + label: 'until', + operationId: 'workflows.copilot.run_until', + run: () => executeRunWorkflowUntilBlock( - { - workflowId: 'workflow-2', - stopAfterBlockId: 'agent-1', - useMockPayload: true, - }, + { workflowId: 'workflow-1', stopAfterBlockId: 'agent-1', useMockPayload: true }, context ), + input: expect.objectContaining({ stopAfterBlockId: 'agent-1' }), }, { - mode: 'from a block', - run: (context: ExecutionContext) => + label: 'from block', + operationId: 'workflows.copilot.run_from_block', + run: () => executeRunFromBlock( { - workflowId: 'workflow-2', + workflowId: 'workflow-1', startBlockId: 'agent-1', - executionId: 'source-execution-1', + executionId: 'source-1', }, context ), + input: expect.objectContaining({ blockId: 'agent-1', sourceExecutionId: 'source-1' }), }, { - mode: 'one block', - run: (context: ExecutionContext) => + label: 'one block', + operationId: 'workflows.copilot.run_block', + run: () => executeRunBlock( - { - workflowId: 'workflow-2', - blockId: 'agent-1', - executionId: 'source-execution-1', - }, + { workflowId: 'workflow-1', blockId: 'agent-1', executionId: 'source-1' }, context ), + input: expect.objectContaining({ blockId: 'agent-1', sourceExecutionId: 'source-1' }), }, - ])('resolves a child snapshot when running $mode cross-workspace', async ({ run }) => { - const context: ExecutionContext = { - ...executionContext, - workflowId: 'workflow-2', - workspaceId: 'workspace-2', - } - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'workflow-2', - userId: 'owner-2', - workspaceId: 'workspace-2', - variables: {}, - }, - }) - resolveBillingAttributionMock.mockResolvedValue(childBillingAttribution) - - const result = await run(context) - - expect(result.success).toBe(true) - expect(resolveBillingAttributionMock).toHaveBeenCalledOnce() - expect(resolveBillingAttributionMock).toHaveBeenCalledWith({ - actorUserId: 'user-1', - workspaceId: 'workspace-2', - }) - expect(executeWorkflowMock.mock.calls[0]?.[4]).toEqual( - expect.objectContaining({ billingAttribution: childBillingAttribution }) - ) - expect(checkAttributedUsageLimitsMock).toHaveBeenCalledOnce() - expect(checkAttributedUsageLimitsMock).toHaveBeenCalledWith(childBillingAttribution) - expect(reserveExecutionSlotMock).toHaveBeenCalledOnce() - expect(reserveExecutionSlotMock).toHaveBeenCalledWith( - expect.objectContaining({ - executionId: executeWorkflowMock.mock.calls[0]?.[5], - }) - ) - expect(context.billingAttribution).toBe(billingAttribution) - }) - - it('blocks a cross-workspace run before execution when target usage is exhausted', async () => { - const context: ExecutionContext = { ...executionContext, workspaceId: 'workspace-2' } - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'workflow-2', - userId: 'owner-2', - workspaceId: 'workspace-2', - variables: {}, - }, - }) - resolveBillingAttributionMock.mockResolvedValue(childBillingAttribution) - checkAttributedUsageLimitsMock.mockResolvedValue({ - isExceeded: true, - scope: 'member', - message: 'Member limit reached', - payerUsage: { currentUsage: 1, limit: 10 }, - memberUsage: { currentUsage: 2, limit: 2 }, + ])('uses one fixed $label application command', async ({ operationId, run, input }) => { + mocks.executeWorkflowUseCase.mockResolvedValue({ + success: true, + output: {}, + logs: [], + metadata: { executionId: 'execution-1' }, }) - const result = await executeRunWorkflow( - { workflowId: 'workflow-2', useMockPayload: true }, - context - ) - - expect(result).toEqual({ success: false, error: 'Member limit reached' }) - expect(checkAttributedUsageLimitsMock).toHaveBeenCalledOnce() - expect(reserveExecutionSlotMock).not.toHaveBeenCalled() - expect(executeWorkflowMock).not.toHaveBeenCalled() - }) + await run() - it('blocks a cross-workspace run when its atomic target reservation is full', async () => { - const context: ExecutionContext = { ...executionContext, workspaceId: 'workspace-2' } - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'workflow-2', - userId: 'owner-2', - workspaceId: 'workspace-2', - variables: {}, - }, - }) - resolveBillingAttributionMock.mockResolvedValue(childBillingAttribution) - reserveExecutionSlotMock.mockResolvedValue({ - reserved: false, - reason: 'payer_concurrency', - }) - - const result = await executeRunWorkflow( - { workflowId: 'workflow-2', useMockPayload: true }, - context + expect(mocks.executeWorkflowUseCase).toHaveBeenCalledOnce() + expect(mocks.executeWorkflowUseCase).toHaveBeenCalledWith( + context, + expect.objectContaining({ operation: expect.objectContaining({ id: operationId }) }), + input ) - - expect(result.success).toBe(false) - expect(result.error).toContain('concurrency') - expect(checkAttributedUsageLimitsMock).toHaveBeenCalledOnce() - expect(reserveExecutionSlotMock).toHaveBeenCalledOnce() - expect(executeWorkflowMock).not.toHaveBeenCalled() }) - it('releases the child reservation when direct target execution throws', async () => { - const context: ExecutionContext = { ...executionContext, workspaceId: 'workspace-2' } - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'workflow-2', - userId: 'owner-2', - workspaceId: 'workspace-2', - variables: {}, - }, + it('passes a bounded move batch to one bulk command', async () => { + mocks.executeWorkflowUseCase.mockResolvedValue({ + moved: [{ workflowId: 'workflow-1' }], + failed: [{ workflowId: 'workflow-2', error: 'Workflow is locked' }], + folderId: 'folder-1', }) - resolveBillingAttributionMock.mockResolvedValue(childBillingAttribution) - executeWorkflowMock.mockRejectedValue(new Error('direct execution failed')) - const result = await executeRunWorkflow( - { workflowId: 'workflow-2', useMockPayload: true }, + const result = await executeMoveWorkflow( + { workflowIds: ['workflow-1', 'workflow-2'], folderId: 'folder-1' }, context ) - const childExecutionId = executeWorkflowMock.mock.calls[0]?.[5] - expect(result).toEqual({ success: false, error: 'direct execution failed' }) - expect(reserveExecutionSlotMock).toHaveBeenCalledWith( - expect.objectContaining({ executionId: childExecutionId }) + expect(result.success).toBe(true) + expect(mocks.executeWorkflowUseCase).toHaveBeenCalledWith( + context, + expect.objectContaining({ + operation: expect.objectContaining({ id: 'workflows.bulk.move' }), + }), + { + workspaceId: 'workspace-1', + workflowIds: ['workflow-1', 'workflow-2'], + folderId: 'folder-1', + } ) - expect(releaseExecutionSlotMock).toHaveBeenCalledOnce() - expect(releaseExecutionSlotMock).toHaveBeenCalledWith(childExecutionId) }) - it('leaves pause release to durable pause persistence', async () => { - const context: ExecutionContext = { ...executionContext, workspaceId: 'workspace-2' } - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'workflow-2', - userId: 'owner-2', - workspaceId: 'workspace-2', - variables: {}, - }, - }) - resolveBillingAttributionMock.mockResolvedValue(childBillingAttribution) - executeWorkflowMock.mockResolvedValue({ - success: true, - status: 'paused', - output: {}, - logs: [], - metadata: { executionId: 'child-execution' }, + it('uses the fixed API-key application command', async () => { + mocks.apiKey.mockResolvedValue({ + key: { id: 'key-1', name: 'Copilot key', key: 'secret-key' }, }) - const result = await executeRunWorkflow( - { workflowId: 'workflow-2', useMockPayload: true }, - context - ) + const result = await executeGenerateApiKey({ name: ' Copilot key ' }, context) expect(result.success).toBe(true) - expect(releaseExecutionSlotMock).not.toHaveBeenCalled() - }) -}) - -describe('executeRunFromBlock', () => { - beforeEach(() => { - vi.clearAllMocks() - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'workflow-1', - userId: 'owner-1', - workspaceId: 'workspace-1', - variables: {}, - }, - }) - executeWorkflowMock.mockResolvedValue({ - success: true, - output: {}, - logs: [], - metadata: { executionId: 'new-execution-1' }, - }) + expect(mocks.apiKey).toHaveBeenCalledWith( + context, + expect.objectContaining({ + operation: expect.objectContaining({ id: 'api_keys.copilot.create' }), + }), + { workspaceId: 'workspace-1', name: 'Copilot key' } + ) }) - it('passes source execution lineage for stored run-from-block snapshots', async () => { - const sourceSnapshot = { - blockStates: { - upstream: { - output: { - __simLargeValueRef: true, - version: 1, - id: 'lv_ABCDEFGHIJKL', - kind: 'object', - size: 10, - key: 'execution/workspace-1/workflow-1/source-execution-1/large-value-lv_ABCDEFGHIJKL.json', - executionId: 'source-execution-1', - }, - }, - }, - executedBlocks: [], - blockLogs: [], - decisions: {}, - completedLoops: [], - activeExecutionPath: [], - } - getExecutionStateForWorkflowMock.mockResolvedValue(sourceSnapshot) + it('logs the full unknown run failure but returns a generic model-visible error', async () => { + mocks.executeWorkflowUseCase.mockRejectedValueOnce(new Error('postgres password=secret')) - const result = await executeRunFromBlock( - { - workflowId: 'workflow-1', - startBlockId: 'agent-1', - executionId: 'source-execution-1', - }, - { userId: 'user-1' } as any - ) + const result = await executeRunWorkflow({ workflowId: 'workflow-1' }, context) - expect(result.success).toBe(true) - expect(executeWorkflowMock).toHaveBeenCalledWith( - expect.any(Object), - 'request-1', - undefined, - 'user-1', - expect.objectContaining({ - runFromBlock: { - startBlockId: 'agent-1', - sourceSnapshot, - sourceExecutionId: 'source-execution-1', - }, - }), - expect.any(String) - ) + expect(result).toEqual({ success: false, error: 'Workflow execution failed' }) }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 260bf73fbd2..0769954cad9 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -1,49 +1,31 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db, workflow as workflowTable } from '@sim/db' import { createLogger } from '@sim/logger' -import { assertFolderMutable, assertWorkflowMutable } from '@sim/platform-authz/workflow' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' -import { eq } from 'drizzle-orm' -import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration' -import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' -import { prepareWorkflowExecutionAdmission } from '@/lib/copilot/request/tools/workflow-context' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { - buildVfsFolderPathMap, - decodeVfsPathSegments, - encodeVfsPathSegments, -} from '@/lib/copilot/vfs/path-utils' -import { env } from '@/lib/core/config/env' -import { generateRequestId } from '@/lib/core/utils/request' -import { getSocketServerUrl } from '@/lib/core/utils/urls' +import { createCopilotWorkspaceApiKey } from '@/lib/api-key/application/create-api-key' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { executeCopilotApiKeyUseCase } from '@/lib/copilot/application/execute-api-key-use-case' import { - type ExecuteWorkflowOptions, - executeWorkflow, - type WorkflowInfo, -} from '@/lib/workflows/executor/execute-workflow' + executeCopilotWorkflowUseCase, + messageForCopilotWorkflowError, +} from '@/lib/copilot/application/execute-workflow-use-case' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { PlatformEvents } from '@/lib/core/telemetry' +import { createWorkflow } from '@/lib/workflows/application/create-workflow' +import { moveWorkflowsBulk } from '@/lib/workflows/application/move-workflows-bulk' import { - getExecutionInputForWorkflow, - getExecutionStateForWorkflow, - getLatestExecutionStateWithExecutionId, -} from '@/lib/workflows/executor/execution-state' -import { performCreateWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' + runBlockFromCopilot, + runFromBlockFromCopilot, + runWorkflowFromCopilot, + runWorkflowUntilBlockFromCopilot, +} from '@/lib/workflows/application/run-workflow-from-copilot' +import { updateWorkflow } from '@/lib/workflows/application/update-workflow' import { - loadDeployedWorkflowState, - loadWorkflowFromNormalizedTables, - saveWorkflowToNormalizedTables, -} from '@/lib/workflows/persistence/utils' + applyWorkflowVariableOperations, + setWorkflowBlockEnabled, +} from '@/lib/workflows/application/update-workflow-content' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' -import { - resolveTriggerRunOptions, - validateTriggerInput, -} from '@/lib/workflows/triggers/run-options' -import { listFolders, setWorkflowVariables, verifyFolderWorkspace } from '@/lib/workflows/utils' -import type { SerializableExecutionState } from '@/executor/execution/types' import { hasExecutionResult } from '@/executor/utils/errors' -import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' -import { ensureWorkflowAccess, ensureWorkspaceAccess, getDefaultWorkspaceId } from '../access' +import type { WorkflowState } from '@/stores/workflows/workflow/types' +import { getDefaultWorkspaceId } from '../access' function stripBinaryFields(value: unknown): unknown { if (value === null || value === undefined) return value @@ -80,108 +62,18 @@ function buildExecutionOutput( } } -async function executeCopilotWorkflowTarget(params: { - workflow: WorkflowInfo - input: unknown - context: ExecutionContext - options: Omit<ExecuteWorkflowOptions, 'billingAttribution'> -}) { - const childExecutionId = generateId() - if (!params.workflow.workspaceId) { - throw new Error(`Workflow ${params.workflow.id} has no workspaceId`) - } - const admission = await prepareWorkflowExecutionAdmission( - params.context, - params.workflow.workspaceId, - childExecutionId - ) - const trustedInitialResolvedSecretTraceProvenance = - params.context.resolvedSecretTraceRegistry?.exportProvenanceForValue(params.input) - const completePendingActivation = - params.context.resolvedSecretTraceRegistry?.beginPendingActivation() - - try { - const result = await executeWorkflow( - params.workflow, - generateRequestId(), - params.input, - params.context.userId, - { - ...params.options, - billingAttribution: admission.billingAttribution, - ...(trustedInitialResolvedSecretTraceProvenance - ? { trustedInitialResolvedSecretTraceProvenance } - : {}), - }, - childExecutionId - ) - if (params.context.resolvedSecretTraceRegistry) { - await params.context.resolvedSecretTraceRegistry.importCrossingProvenance( - result.executionState?.resolvedSecretTraceProvenance, - { output: result.output, logs: result.logs, error: result.error }, - { trusted: true } - ) - } - return result - } catch (error) { - if (params.context.resolvedSecretTraceRegistry) { - const executionResult = hasExecutionResult(error) ? error.executionResult : undefined - await params.context.resolvedSecretTraceRegistry.importCrossingProvenance( - executionResult?.executionState?.resolvedSecretTraceProvenance, - { - output: executionResult?.output, - logs: executionResult?.logs, - error: executionResult?.error, - thrownMessage: toError(error).message, - }, - { trusted: true } - ) - } - if (admission.targetReservation) { - await releaseExecutionSlot(childExecutionId) - } - throw error - } finally { - completePendingActivation?.() - } -} - function buildExecutionError(error: unknown): ToolCallResult { - const message = toError(error).message if (hasExecutionResult(error)) { return buildExecutionOutput({ ...error.executionResult, success: false, - error: error.executionResult.error || message, + error: error.executionResult.error || 'Workflow execution failed', }) } - return { success: false, error: message } -} - -async function resolveRunFromBlockSnapshot( - workflowId: string, - executionId?: string -): Promise< - | { - executionId: string - snapshot: SerializableExecutionState - } - | undefined -> { - const sourceExecution = executionId - ? { - executionId, - state: await getExecutionStateForWorkflow(executionId, workflowId), - } - : await getLatestExecutionStateWithExecutionId(workflowId) - - if (!sourceExecution?.state) { - return undefined - } - + logger.error('Copilot workflow execution command failed', { error }) return { - executionId: sourceExecution.executionId, - snapshot: sourceExecution.state, + success: false, + error: messageForCopilotWorkflowError(error, 'Workflow execution failed'), } } @@ -201,174 +93,16 @@ function resolveRunTriggerBlockId(params: { triggerBlockId?: unknown }): string : undefined } -interface PreparedTriggerRun { - triggerBlockId: string - input: unknown +function resolveInputFromExecutionId(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined } -/** - * Resolves which trigger a copilot run targets and validates the input against - * it. There are no fallbacks: an invalid trigger id, an ambiguous workflow, or - * input that doesn't match the trigger's schema returns an error string so the - * agent fixes it and retries. The resolved triggerBlockId is returned so the - * caller pins the executed entry to the validated one. - */ -async function resolveValidatedTriggerRun( - workflowId: string, - useDraftState: boolean, - params: { - triggerBlockId?: unknown - workflow_input?: unknown - input?: unknown - useMockPayload?: unknown - inputFromExecutionId?: unknown - } -): Promise<PreparedTriggerRun | { error: string }> { - const state = useDraftState - ? await loadWorkflowFromNormalizedTables(workflowId) - : await loadDeployedWorkflowState(workflowId) - - if (!state?.blocks) { - return { - error: `Workflow ${workflowId} has no ${useDraftState ? 'saved draft' : 'deployed'} state to run.`, - } - } - - const merged = mergeSubblockStateWithValues(state.blocks) - const options = resolveTriggerRunOptions(merged, state.edges) - - if (options.length === 0) { - return { - error: - 'No runnable trigger found. Add a Start/API/Input/Chat trigger or an external (webhook/integration) trigger before running.', - } - } - - const listTriggers = () => - options.map((option) => `${option.triggerBlockId} (${option.blockName})`).join(', ') - - const requestedId = resolveRunTriggerBlockId(params) - let option = options[0] - if (requestedId) { - const match = options.find((o) => o.triggerBlockId === requestedId) - if (!match) { - return { - error: `triggerBlockId "${requestedId}" is not a runnable trigger in this workflow. Valid triggers: ${listTriggers()}. Call get_workflow_run_options to inspect them.`, - } - } - option = match - } else if (options.length > 1) { - return { - error: `This workflow has multiple triggers — pass triggerBlockId to choose one: ${listTriggers()}. Call get_workflow_run_options for each trigger's input shape.`, - } - } - - const providedInput = resolveRunWorkflowInput(params) - const hasProvidedInput = providedInput !== undefined - const useMock = params.useMockPayload === true - const fromExecutionId = - typeof params.inputFromExecutionId === 'string' && params.inputFromExecutionId.trim().length > 0 - ? params.inputFromExecutionId.trim() - : undefined - - const sourceCount = (hasProvidedInput ? 1 : 0) + (useMock ? 1 : 0) + (fromExecutionId ? 1 : 0) - if (sourceCount > 1) { - return { - error: - 'Provide only one input source: workflow_input, useMockPayload: true, or inputFromExecutionId.', - } - } - - // Mock payload is generated to match the trigger, so it bypasses validation. - if (useMock) { - return { triggerBlockId: option.triggerBlockId, input: option.mockPayload } - } - - let inputToValidate = providedInput - if (fromExecutionId) { - const past = await getExecutionInputForWorkflow(fromExecutionId, workflowId) - if (!past.found) { - return { - error: `No execution "${fromExecutionId}" found for this workflow to reuse input from.`, - } - } - if (past.input === undefined) { - return { error: `Execution "${fromExecutionId}" has no recorded input to reuse.` } - } - inputToValidate = past.input - } - - const validation = validateTriggerInput(option, inputToValidate) - if (!validation.ok) { - return { error: validation.error || 'workflow_input is invalid for the target trigger.' } - } - - return { triggerBlockId: option.triggerBlockId, input: inputToValidate } -} - -function isBlockProtected(blockId: string, blocksById: Record<string, BlockState>): boolean { - const block = blocksById[blockId] - if (!block) return false - if (block.locked) return true - - const visited = new Set<string>() - let parentId = block.data?.parentId - while (parentId && !visited.has(parentId)) { - visited.add(parentId) - if (blocksById[parentId]?.locked) return true - parentId = blocksById[parentId]?.data?.parentId - } - - return false -} - -function hasDisabledAncestor(blockId: string, blocksById: Record<string, BlockState>): boolean { - const visited = new Set<string>() - let parentId = blocksById[blockId]?.data?.parentId - - while (parentId && !visited.has(parentId)) { - visited.add(parentId) - const parent = blocksById[parentId] - if (!parent) return false - if (parent.enabled === false) return true - parentId = parent.data?.parentId - } - - return false -} - -function findDescendants(containerId: string, blocksById: Record<string, BlockState>): string[] { - const descendants: string[] = [] - const stack = [containerId] - const visited = new Set<string>() - - while (stack.length > 0) { - const current = stack.pop()! - if (visited.has(current)) continue - visited.add(current) - - for (const [blockId, block] of Object.entries(blocksById)) { - if (block.data?.parentId === current) { - descendants.push(blockId) - stack.push(blockId) - } - } +function copilotRunLifecycle(context: ExecutionContext) { + return { + billingAttribution: context.billingAttribution, + resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry, + abortSignal: context.abortSignal, } - - return descendants -} - -function notifyWorkflowUpdated(workflowId: string): void { - fetch(`${getSocketServerUrl()}/api/workflow-updated`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': env.INTERNAL_API_SECRET, - }, - body: JSON.stringify({ workflowId }), - }).catch((error) => { - logger.warn('Failed to notify socket server of workflow update', { workflowId, error }) - }) } import type { @@ -411,57 +145,30 @@ export async function executeCreateWorkflow( const workspaceId = params?.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - const folderPath = typeof params?.folderPath === 'string' ? params.folderPath.trim() : '' - let folderId = + const folderId = typeof params?.folderId === 'string' && params.folderId.trim() ? params.folderId.trim() : null + let canonicalFolderPath: string | undefined if (folderPath) { const relativePath = workflowFolderRelativePath(folderPath) - if (!relativePath) { - folderId = null - } else { - const target = resolveFolderIdByPath(folderPath, await loadFolderPathIndex(workspaceId)) - if ('error' in target) return { success: false, error: target.error } - folderId = target.folderId - } + canonicalFolderPath = relativePath + ? `/${encodeVfsPathSegments(decodeVfsPathSegments(relativePath))}` + : '/' } - await assertFolderMutable(folderId) assertWorkflowMutationNotAborted(context) - const result = await performCreateWorkflow({ - userId: context.userId, + const result = await executeCopilotWorkflowUseCase(context, createWorkflow, { workspaceId, name, - folderId, + ...(canonicalFolderPath !== undefined ? { folderPath: canonicalFolderPath } : { folderId }), }) - if (!result.success || !result.workflow) { - return { success: false, error: result.error || 'Failed to create workflow' } - } - - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.workflowCreated({ - workflowId: result.workflow.id, - name: result.workflow.name, - workspaceId, - folderId: folderId ?? undefined, - }) - } catch (_e) { - // Telemetry is best-effort - } - - const normalized = await loadWorkflowFromNormalizedTables(result.workflow.id) - let copilotSanitizedWorkflowState: unknown - if (normalized) { - copilotSanitizedWorkflowState = sanitizeForCopilot({ - blocks: normalized.blocks || {}, - edges: normalized.edges || [], - loops: normalized.loops || {}, - parallels: normalized.parallels || {}, - } as WorkflowState) - } + const copilotSanitizedWorkflowState = sanitizeForCopilot({ + blocks: result.normalizedState.blocks || {}, + edges: result.normalizedState.edges || [], + loops: result.normalizedState.loops || {}, + parallels: result.normalizedState.parallels || {}, + } as WorkflowState) return { success: true, @@ -474,7 +181,10 @@ export async function executeCreateWorkflow( }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to create workflow'), + } } } @@ -488,33 +198,18 @@ export async function executeRunWorkflow( return { success: false, error: 'workflowId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) - const useDraftState = !params.useDeployedState - - const prepared = await resolveValidatedTriggerRun(workflowId, useDraftState, params) - if ('error' in prepared) { - return { success: false, error: prepared.error } - } - const result = await executeCopilotWorkflowTarget({ - workflow: { - id: workflowRecord.id, - userId: workflowRecord.userId, - workspaceId: workflowRecord.workspaceId, - variables: workflowRecord.variables || {}, - }, - input: prepared.input, - context, - options: { - enabled: true, - useDraftState, - workflowTriggerType: 'copilot', - triggerBlockId: prepared.triggerBlockId, - }, + const workflowInput = resolveRunWorkflowInput(params) + const result = await executeCopilotWorkflowUseCase(context, runWorkflowFromCopilot, { + workflowId, + assertedWorkspaceId: context.workspaceId, + useDraftState, + triggerBlockId: resolveRunTriggerBlockId(params), + workflowInput, + hasWorkflowInput: workflowInput !== undefined, + useMockPayload: params.useMockPayload === true, + inputFromExecutionId: resolveInputFromExecutionId(params.inputFromExecutionId), + lifecycle: copilotRunLifecycle(context), }) return buildExecutionOutput(result) @@ -535,113 +230,17 @@ export async function executeSetGlobalWorkflowVariables( const operations: VariableOperation[] = Array.isArray(params.operations) ? params.operations : [] - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) - await assertWorkflowMutable(workflowId) - - interface WorkflowVariable { - id: string - workflowId?: string - name: string - type: string - value?: unknown - } - const currentVarsRecord = (workflowRecord.variables as Record<string, unknown>) || {} - const byName: Record<string, WorkflowVariable> = {} - Object.values(currentVarsRecord).forEach((v) => { - if (v && typeof v === 'object' && 'id' in v && 'name' in v) { - const variable = v as WorkflowVariable - byName[String(variable.name)] = variable - } - }) - - for (const op of operations) { - const key = String(op?.name || '') - if (!key) continue - const nextType = op?.type || byName[key]?.type || 'plain' - const coerceValue = (value: unknown, type: string): unknown => { - if (value === undefined) return value - if (type === 'number') { - const n = Number(value) - return Number.isNaN(n) ? value : n - } - if (type === 'boolean') { - const v = String(value).trim().toLowerCase() - if (v === 'true') return true - if (v === 'false') return false - return value - } - if (type === 'array' || type === 'object') { - try { - const parsed = JSON.parse(String(value)) - if (type === 'array' && Array.isArray(parsed)) return parsed - if (type === 'object' && parsed && typeof parsed === 'object' && !Array.isArray(parsed)) - return parsed - } catch (error) { - logger.warn('Failed to parse JSON value for variable coercion', { - error: toError(error).message, - }) - } - return value - } - return value - } - - if (op.operation === 'delete') { - delete byName[key] - continue - } - const typedValue = coerceValue(op.value, nextType) - if (op.operation === 'add') { - byName[key] = { - id: generateId(), - workflowId, - name: key, - type: nextType, - value: typedValue, - } - continue - } - if (op.operation === 'edit') { - if (!byName[key]) { - byName[key] = { - id: generateId(), - workflowId, - name: key, - type: nextType, - value: typedValue, - } - } else { - byName[key] = { - ...byName[key], - type: nextType, - value: typedValue, - } - } - } - } - - const nextVarsRecord = Object.fromEntries(Object.values(byName).map((v) => [String(v.id), v])) assertWorkflowMutationNotAborted(context) - await setWorkflowVariables(workflowId, nextVarsRecord) - notifyWorkflowUpdated(workflowId) - - recordAudit({ - actorId: context.userId, - action: AuditAction.WORKFLOW_VARIABLES_UPDATED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - description: `Updated workflow variables`, - metadata: { operationCount: operations.length, source: 'copilot' }, + const result = await executeCopilotWorkflowUseCase(context, applyWorkflowVariableOperations, { + workflowId, + assertedWorkspaceId: context.workspaceId, + operations, }) - return { success: true, output: { updated: Object.values(byName).length } } + return { success: true, output: { updated: result.updated } } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotWorkflowError(error) } } } @@ -662,27 +261,19 @@ export async function executeRenameWorkflow( return { success: false, error: 'Workflow name must be 200 characters or less' } } - const current = await ensureWorkflowAccess(workflowId, context.userId, 'write') - await assertWorkflowMutable(workflowId) assertWorkflowMutationNotAborted(context) - if (!current.workspaceId) { - return { success: false, error: 'Workflow workspace is required' } - } - const result = await performUpdateWorkflow({ + await executeCopilotWorkflowUseCase(context, updateWorkflow, { workflowId, - userId: context.userId, - workspaceId: current.workspaceId, - currentName: current.workflow.name, - currentFolderId: current.workflow.folderId, + assertedWorkspaceId: context.workspaceId, name, }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to rename workflow' } - } return { success: true, output: { workflowId, name } } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to rename workflow'), + } } } @@ -695,53 +286,23 @@ export async function executeMoveWorkflow( if (!workflowIds || workflowIds.length === 0) { return { success: false, error: 'workflowIds is required' } } + if (!context.workspaceId) { + return { success: false, error: 'Workspace context is required' } + } - const folderId = params.folderId || null - const moved: string[] = [] - const failed: string[] = [] - - await assertFolderMutable(folderId) + assertWorkflowMutationNotAborted(context) + const result = await executeCopilotWorkflowUseCase(context, moveWorkflowsBulk, { + workspaceId: context.workspaceId, + workflowIds, + folderId: params.folderId || null, + }) - for (const workflowId of workflowIds) { - try { - const { workspaceId, workflow } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) - if (!workspaceId) { - failed.push(workflowId) - continue - } - if (folderId) { - if (!workspaceId || !(await verifyFolderWorkspace(folderId, workspaceId))) { - failed.push(workflowId) - continue - } - } - await assertWorkflowMutable(workflowId) - assertWorkflowMutationNotAborted(context) - const result = await performUpdateWorkflow({ - workflowId, - userId: context.userId, - workspaceId, - currentName: workflow.name, - currentFolderId: workflow.folderId, - folderId, - }) - if (!result.success) { - failed.push(workflowId) - continue - } - moved.push(workflowId) - } catch { - failed.push(workflowId) - } + return { + success: result.moved.length > 0, + output: { moved: result.moved, failed: result.failed, folderId: result.folderId }, } - - return { success: moved.length > 0, output: { moved, failed, folderId } } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotWorkflowError(error) } } } @@ -758,34 +319,19 @@ export async function executeRunWorkflowUntilBlock( return { success: false, error: 'stopAfterBlockId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) - const useDraftState = !params.useDeployedState - - const prepared = await resolveValidatedTriggerRun(workflowId, useDraftState, params) - if ('error' in prepared) { - return { success: false, error: prepared.error } - } - const result = await executeCopilotWorkflowTarget({ - workflow: { - id: workflowRecord.id, - userId: workflowRecord.userId, - workspaceId: workflowRecord.workspaceId, - variables: workflowRecord.variables || {}, - }, - input: prepared.input, - context, - options: { - enabled: true, - useDraftState, - stopAfterBlockId: params.stopAfterBlockId, - workflowTriggerType: 'copilot', - triggerBlockId: prepared.triggerBlockId, - }, + const workflowInput = resolveRunWorkflowInput(params) + const result = await executeCopilotWorkflowUseCase(context, runWorkflowUntilBlockFromCopilot, { + workflowId, + assertedWorkspaceId: context.workspaceId, + useDraftState, + triggerBlockId: resolveRunTriggerBlockId(params), + workflowInput, + hasWorkflowInput: workflowInput !== undefined, + useMockPayload: params.useMockPayload === true, + inputFromExecutionId: resolveInputFromExecutionId(params.inputFromExecutionId), + stopAfterBlockId: params.stopAfterBlockId, + lifecycle: copilotRunLifecycle(context), }) return buildExecutionOutput(result, { stoppedAfterBlockId: params.stopAfterBlockId }) @@ -809,17 +355,16 @@ export async function executeGenerateApiKey( const workspaceId = params.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, 'admin') assertWorkflowMutationNotAborted(context) - const result = await performCreateWorkspaceApiKey({ + const result = await executeCopilotApiKeyUseCase(context, createCopilotWorkspaceApiKey, { workspaceId, - userId: context.userId, name, - source: 'copilot', }) - if (!result.success || !result.key) { - return { success: false, error: result.error || 'Failed to generate API key' } + try { + PlatformEvents.apiKeyGenerated({ userId: context.userId, keyName: result.key.name }) + } catch (error) { + logger.warn('Failed to capture Copilot API key analytics', { error }) } return { @@ -833,7 +378,11 @@ export async function executeGenerateApiKey( }, } } catch (error) { - return { success: false, error: toError(error).message } + logger.error('Copilot API key creation failed', { error }) + return { + success: false, + error: messageForCopilotApplicationError(error, 'Failed to create API key'), + } } } @@ -850,43 +399,15 @@ export async function executeRunFromBlock( return { success: false, error: 'startBlockId is required' } } - const sourceSnapshot = await resolveRunFromBlockSnapshot(workflowId, params.executionId) - - if (!sourceSnapshot) { - return { - success: false, - error: params.executionId - ? `No execution state found for execution ${params.executionId}. Run the full workflow first.` - : `No execution state found for workflow ${workflowId}. Run the full workflow first to create a snapshot.`, - } - } - - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) const useDraftState = !params.useDeployedState - - const result = await executeCopilotWorkflowTarget({ - workflow: { - id: workflowRecord.id, - userId: workflowRecord.userId, - workspaceId: workflowRecord.workspaceId, - variables: workflowRecord.variables || {}, - }, - input: resolveRunWorkflowInput(params), - context, - options: { - enabled: true, - useDraftState, - workflowTriggerType: 'copilot', - runFromBlock: { - startBlockId: params.startBlockId, - sourceSnapshot: sourceSnapshot.snapshot, - sourceExecutionId: sourceSnapshot.executionId, - }, - }, + const result = await executeCopilotWorkflowUseCase(context, runFromBlockFromCopilot, { + workflowId, + assertedWorkspaceId: context.workspaceId, + useDraftState, + blockId: params.startBlockId, + workflowInput: resolveRunWorkflowInput(params), + sourceExecutionId: params.executionId, + lifecycle: copilotRunLifecycle(context), }) return buildExecutionOutput(result, { startBlockId: params.startBlockId }) @@ -911,119 +432,33 @@ export async function executeSetBlockEnabled( return { success: false, error: 'enabled must be a boolean' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) - await assertWorkflowMutable(workflowId) - assertWorkflowMutationNotAborted(context) - - const normalized = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalized) { - return { success: false, error: `Workflow ${workflowId} has no normalized state` } - } - - const currentState: WorkflowState = { - blocks: normalized.blocks as Record<string, BlockState>, - edges: normalized.edges || [], - loops: normalized.loops || {}, - parallels: normalized.parallels || {}, - lastSaved: Date.now(), - } - - const currentBlocks = currentState.blocks - const targetBlock = currentBlocks[params.blockId] - if (!targetBlock) { - return { - success: false, - error: `Block ${params.blockId} not found in workflow ${workflowId}`, - } - } - if (isBlockProtected(params.blockId, currentBlocks)) { - return { - success: false, - error: `Block ${params.blockId} is locked or inside a locked container and cannot be updated`, - } - } - if (targetBlock.enabled === params.enabled) { - return { - success: true, - output: { - workflowId, - workflowName: workflowRecord.name, - blockId: params.blockId, - enabled: params.enabled, - affectedBlockIds: [params.blockId], - workflowState: currentState, - copilotSanitizedWorkflowState: sanitizeForCopilot(currentState), - message: `Block ${params.blockId} is already ${params.enabled ? 'enabled' : 'disabled'}`, - }, - } - } - if (params.enabled && hasDisabledAncestor(params.blockId, currentBlocks)) { - return { - success: false, - error: `Cannot enable block ${params.blockId} while one of its parent containers is disabled. Enable the parent first.`, - } - } - - const affectedBlockIds = new Set<string>([params.blockId]) - if (targetBlock.type === 'loop' || targetBlock.type === 'parallel') { - for (const descendantId of findDescendants(params.blockId, currentBlocks)) { - if (!isBlockProtected(descendantId, currentBlocks)) { - affectedBlockIds.add(descendantId) - } - } - } - - const nextBlocks: Record<string, BlockState> = { ...currentBlocks } - for (const blockId of affectedBlockIds) { - nextBlocks[blockId] = { - ...nextBlocks[blockId], - enabled: params.enabled, - } - } - - const nextState: WorkflowState = { - ...currentState, - blocks: nextBlocks, - lastSaved: Date.now(), - } - assertWorkflowMutationNotAborted(context) - const saveResult = await saveWorkflowToNormalizedTables(workflowId, nextState) - if (!saveResult.success) { - return { - success: false, - error: saveResult.error || `Failed to persist enabled state for block ${params.blockId}`, - } - } - - await db - .update(workflowTable) - .set({ - lastSynced: new Date(), - updatedAt: new Date(), - }) - .where(eq(workflowTable.id, workflowId)) - - notifyWorkflowUpdated(workflowId) + const result = await executeCopilotWorkflowUseCase(context, setWorkflowBlockEnabled, { + workflowId, + assertedWorkspaceId: context.workspaceId, + blockId: params.blockId, + enabled: params.enabled, + }) return { success: true, output: { workflowId, - workflowName: workflowRecord.name, + workflowName: result.workflowName, blockId: params.blockId, enabled: params.enabled, - affectedBlockIds: Array.from(affectedBlockIds), - workflowState: nextState, - copilotSanitizedWorkflowState: sanitizeForCopilot(nextState), + affectedBlockIds: result.affectedBlockIds, + workflowState: result.state, + copilotSanitizedWorkflowState: sanitizeForCopilot(result.state), + ...(!result.changed + ? { + message: `Block ${params.blockId} is already ${params.enabled ? 'enabled' : 'disabled'}`, + } + : {}), }, } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotWorkflowError(error) } } } @@ -1038,47 +473,6 @@ function workflowFolderRelativePath(rawPath: string): string { return trimmed.startsWith('workflows/') ? trimmed.slice('workflows/'.length) : trimmed } -type FolderPathIndex = Map<string, string | null> - -/** - * Load an index from each canonical encoded VFS path to its folder id. A null - * value records that multiple folder ids collapse to the same canonical path, - * so callers can reject the ambiguous path instead of silently choosing one. - */ -async function loadFolderPathIndex(workspaceId: string): Promise<FolderPathIndex> { - const byPath: FolderPathIndex = new Map() - for (const [folderId, encodedPath] of buildVfsFolderPathMap( - await listFolders(workspaceId) - ).entries()) { - if (!byPath.has(encodedPath)) { - byPath.set(encodedPath, folderId) - } else if (byPath.get(encodedPath) !== folderId) { - byPath.set(encodedPath, null) - } - } - return byPath -} - -function resolveFolderIdByPath( - rawPath: string, - byPath: FolderPathIndex, - label = 'Folder' -): { folderId: string } | { error: string } { - const relative = workflowFolderRelativePath(rawPath) - if (!relative) return { error: `${label} not found at ${rawPath}` } - - const canonicalPath = encodeVfsPathSegments(decodeVfsPathSegments(relative)) - if (!byPath.has(canonicalPath)) return { error: `${label} not found at ${rawPath}` } - - const folderId = byPath.get(canonicalPath) - if (!folderId) { - return { - error: `${label} path is ambiguous after canonicalization: ${rawPath}. Rename one of the conflicting folders and retry.`, - } - } - return { folderId } -} - export async function executeRunBlock( params: RunBlockParams, context: ExecutionContext @@ -1092,44 +486,15 @@ export async function executeRunBlock( return { success: false, error: 'blockId is required' } } - const sourceSnapshot = await resolveRunFromBlockSnapshot(workflowId, params.executionId) - - if (!sourceSnapshot) { - return { - success: false, - error: params.executionId - ? `No execution state found for execution ${params.executionId}. Run the full workflow first.` - : `No execution state found for workflow ${workflowId}. Run the full workflow first to create a snapshot.`, - } - } - - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) const useDraftState = !params.useDeployedState - - const result = await executeCopilotWorkflowTarget({ - workflow: { - id: workflowRecord.id, - userId: workflowRecord.userId, - workspaceId: workflowRecord.workspaceId, - variables: workflowRecord.variables || {}, - }, - input: resolveRunWorkflowInput(params), - context, - options: { - enabled: true, - useDraftState, - workflowTriggerType: 'copilot', - runFromBlock: { - startBlockId: params.blockId, - sourceSnapshot: sourceSnapshot.snapshot, - sourceExecutionId: sourceSnapshot.executionId, - }, - stopAfterBlockId: params.blockId, - }, + const result = await executeCopilotWorkflowUseCase(context, runBlockFromCopilot, { + workflowId, + assertedWorkspaceId: context.workspaceId, + useDraftState, + blockId: params.blockId, + workflowInput: resolveRunWorkflowInput(params), + sourceExecutionId: params.executionId, + lifecycle: copilotRunLifecycle(context), }) return buildExecutionOutput(result, { blockId: params.blockId }) diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts index 86fe5c9e63a..f8f33c8289e 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts @@ -1,91 +1,26 @@ -import { - workflowsPersistenceUtilsMock, - workflowsPersistenceUtilsMockFns, - workflowsUtilsMock, - workflowsUtilsMockFns, -} from '@sim/testing' +import { getErrorMessage } from '@sim/utils/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionContext } from '@/lib/copilot/request/types' -const { - ensureWorkflowAccessMock, - getEffectiveBlockOutputPathsMock, - hasTriggerCapabilityMock, - getBlockMock, -} = vi.hoisted(() => ({ - ensureWorkflowAccessMock: vi.fn(), - getEffectiveBlockOutputPathsMock: vi.fn(), - hasTriggerCapabilityMock: vi.fn(), - getBlockMock: vi.fn(), +const { executeWorkflowUseCaseMock } = vi.hoisted(() => ({ + executeWorkflowUseCaseMock: vi.fn(), })) -const loadWorkflowFromNormalizedTablesMock = - workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables -const getWorkflowByIdMock = workflowsUtilsMockFns.mockGetWorkflowById - -vi.mock('../access', () => ({ - ensureWorkflowAccess: ensureWorkflowAccessMock, - ensureWorkspaceAccess: vi.fn(), - getDefaultWorkspaceId: vi.fn(), -})) - -vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) - -vi.mock('@/lib/workflows/blocks/block-outputs', () => ({ - getEffectiveBlockOutputPaths: getEffectiveBlockOutputPathsMock, -})) - -vi.mock('@/lib/workflows/triggers/trigger-utils', () => ({ - hasTriggerCapability: hasTriggerCapabilityMock, +vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ + executeCopilotWorkflowUseCase: executeWorkflowUseCaseMock, + messageForCopilotWorkflowError: (error: unknown) => + getErrorMessage(error, 'Workflow operation failed'), })) -vi.mock('@/blocks/registry', () => ({ - getBlock: getBlockMock, -})) - -vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) - import { executeGetBlockOutputs } from './queries' describe('executeGetBlockOutputs', () => { beforeEach(() => { vi.clearAllMocks() - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { id: 'wf-1', userId: 'user-1', workspaceId: 'ws-1' }, - }) - getWorkflowByIdMock.mockResolvedValue({ variables: {} }) - getBlockMock.mockReturnValue({ category: 'core' }) - hasTriggerCapabilityMock.mockReturnValue(false) - getEffectiveBlockOutputPathsMock.mockReturnValue(['content']) }) it('returns display outputs and block-relative outputs for chat deployment', async () => { - loadWorkflowFromNormalizedTablesMock.mockResolvedValue({ - blocks: { - 'agent-1': { - type: 'agent', - name: 'Support Agent', - subBlocks: {}, - }, - 'loop-1': { - type: 'loop', - name: 'Items Loop', - }, - }, - loops: { - 'loop-1': { - loopType: 'forEach', - }, - }, - parallels: {}, - }) - - const result = await executeGetBlockOutputs({ blockIds: ['agent-1', 'loop-1'] }, { - workflowId: 'wf-1', - userId: 'user-1', - } as any) - - expect(result.success).toBe(true) - expect(result.output).toEqual({ + const applicationResult = { blocks: [ { blockId: 'agent-1', @@ -109,6 +44,29 @@ describe('executeGetBlockOutputs', () => { }, ], variables: [], - }) + } + executeWorkflowUseCaseMock.mockResolvedValue(applicationResult) + + const result = await executeGetBlockOutputs({ blockIds: ['agent-1', 'loop-1'] }, { + workflowId: 'wf-1', + workspaceId: 'ws-1', + userId: 'user-1', + toolCallId: 'tool-1', + copilotToolExecution: true, + } as ExecutionContext) + + expect(result.success).toBe(true) + expect(result.output).toEqual(applicationResult) + expect(executeWorkflowUseCaseMock).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'wf-1', workspaceId: 'ws-1' }), + expect.objectContaining({ + operation: expect.objectContaining({ id: 'workflows.copilot.block_outputs.read' }), + }), + { + workflowId: 'wf-1', + assertedWorkspaceId: 'ws-1', + blockIds: ['agent-1', 'loop-1'], + } + ) }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts index fb250666e14..8861dbc98d6 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts @@ -1,27 +1,25 @@ -import { toError } from '@sim/utils/errors' -import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' +import { createLogger } from '@sim/logger' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { executeCopilotCustomToolUseCase } from '@/lib/copilot/application/execute-custom-tool-use-case' import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' +import { executeCopilotMcpServerUseCase } from '@/lib/copilot/application/execute-mcp-server-use-case' +import { + executeCopilotWorkflowUseCase, + messageForCopilotWorkflowError, +} from '@/lib/copilot/application/execute-workflow-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { formatNormalizedWorkflowForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' -import { mcpService } from '@/lib/mcp/service' -import { getEffectiveBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs' -import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator' -import { getBlockReferenceTags } from '@/lib/workflows/blocks/block-reference-tags' -import { listCustomTools } from '@/lib/workflows/custom-tools/operations' +import { listAvailableCustomToolsUseCase } from '@/lib/custom-tools/application/use-cases' +import { discoverMcpToolsUseCase } from '@/lib/mcp/application/use-cases' import { - loadDeployedWorkflowState, - loadWorkflowFromNormalizedTables, - NoActiveDeploymentError, -} from '@/lib/workflows/persistence/utils' -import { resolveTriggerRunOptions, toPublicRunOption } from '@/lib/workflows/triggers/run-options' -import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' -import { getWorkflowById } from '@/lib/workflows/utils' + readCopilotWorkflowBlockOutputs, + readCopilotWorkflowRunOptions, + readCopilotWorkflowUpstreamReferences, +} from '@/lib/workflows/application/read-workflow-copilot-metadata' +import { readWorkflowDefinition } from '@/lib/workflows/application/read-workflow-definition' import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' import { listUserWorkspaces } from '@/lib/workspaces/utils' -import { getBlock } from '@/blocks/registry' -import { normalizeName } from '@/executor/constants' import type { Loop, Parallel } from '@/stores/workflows/workflow/types' -import { ensureWorkflowAccess } from '../access' import type { GetBlockOutputsParams, GetBlockUpstreamReferencesParams, @@ -30,6 +28,8 @@ import type { GetWorkflowRunOptionsParams, } from '../param-types' +const logger = createLogger('WorkflowQueries') + export async function executeListUserWorkspaces( context: ExecutionContext ): Promise<ToolCallResult> { @@ -38,7 +38,11 @@ export async function executeListUserWorkspaces( return { success: true, output: { workspaces } } } catch (error) { - return { success: false, error: toError(error).message } + logger.error('Failed to list user workspaces for Copilot', { error }) + return { + success: false, + error: messageForCopilotApplicationError(error, 'Failed to list workspaces'), + } } } @@ -52,15 +56,11 @@ export async function executeGetWorkflowRunOptions( return { success: false, error: 'workflowId is required' } } - await ensureWorkflowAccess(workflowId, context.userId) - - const normalized = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalized) { - return { success: false, error: `Workflow ${workflowId} has no saved state` } - } - - const merged = mergeSubblockStateWithValues(normalized.blocks) - const options = resolveTriggerRunOptions(merged, normalized.edges) + const { options } = await executeCopilotWorkflowUseCase( + context, + readCopilotWorkflowRunOptions, + { workflowId, assertedWorkspaceId: context.workspaceId } + ) if (options.length === 0) { return { @@ -89,12 +89,11 @@ export async function executeGetWorkflowRunOptions( } const triggers = options.map((option) => { - const pub = toPublicRunOption(option) const callExample = - pub.inputKind === 'none' - ? { triggerBlockId: pub.triggerBlockId } - : { triggerBlockId: pub.triggerBlockId, workflow_input: pub.mockPayload } - return { ...pub, guidance: guidanceFor(pub.inputKind), callExample } + option.inputKind === 'none' + ? { triggerBlockId: option.triggerBlockId } + : { triggerBlockId: option.triggerBlockId, workflow_input: option.mockPayload } + return { ...option, guidance: guidanceFor(option.inputKind), callExample } }) const defaultOption = options.find((option) => option.isDefault) @@ -113,7 +112,7 @@ export async function executeGetWorkflowRunOptions( }, } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotWorkflowError(error) } } } @@ -131,12 +130,12 @@ export async function executeGetWorkflowData( return { success: false, error: 'data_type is required' } } - const { workflow: workflowRecord, workspaceId } = await ensureWorkflowAccess( - workflowId, - context.userId - ) - if (dataType === 'global_variables') { + const { workflow: workflowRecord } = await executeCopilotWorkflowUseCase( + context, + readWorkflowDefinition, + { workflowId, assertedWorkspaceId: context.workspaceId, state: 'draft' } + ) const variablesRecord = (workflowRecord.variables as Record<string, unknown>) || {} const variables = Object.values(variablesRecord).map((v) => { const variable = v as Record<string, unknown> | null @@ -149,14 +148,18 @@ export async function executeGetWorkflowData( return { success: true, output: { variables } } } + const workspaceId = context.workspaceId if (dataType === 'custom_tools') { if (!workspaceId) { return { success: false, error: 'workspaceId is required' } } - const toolsRows = await listCustomTools({ - userId: context.userId, - workspaceId, - }) + const { tools: toolsRows } = await executeCopilotCustomToolUseCase( + context, + listAvailableCustomToolsUseCase, + { + workspaceId, + } + ) const customToolsData = toolsRows.map((tool) => { const schema = tool.schema as Record<string, unknown> | null @@ -177,7 +180,10 @@ export async function executeGetWorkflowData( if (!workspaceId) { return { success: false, error: 'workspaceId is required' } } - const tools = await mcpService.discoverTools(context.userId, workspaceId, false) + const { tools } = await executeCopilotMcpServerUseCase(context, discoverMcpToolsUseCase, { + workspaceId, + refresh: false, + }) const mcpTools = tools.map((tool) => ({ name: String(tool.name || ''), serverId: String(tool.serverId || ''), @@ -210,7 +216,7 @@ export async function executeGetWorkflowData( return { success: false, error: `Unknown data_type: ${dataType}` } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotWorkflowError(error) } } } @@ -223,79 +229,14 @@ export async function executeGetBlockOutputs( if (!workflowId) { return { success: false, error: 'workflowId is required' } } - await ensureWorkflowAccess(workflowId, context.userId) - - const normalized = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalized) { - return { success: false, error: 'Workflow has no normalized data' } - } - - const blocks = normalized.blocks || {} - const loops = normalized.loops || {} - const parallels = normalized.parallels || {} - const blockIds = - Array.isArray(params.blockIds) && params.blockIds.length > 0 - ? params.blockIds - : Object.keys(blocks) - - const results: Array<{ - blockId: string - blockName: string - blockType: string - outputs: string[] - relativeOutputs?: string[] - insideSubflowOutputs?: string[] - outsideSubflowOutputs?: string[] - relativeInsideSubflowOutputs?: string[] - relativeOutsideSubflowOutputs?: string[] - triggerMode?: boolean - }> = [] - - for (const blockId of blockIds) { - const block = blocks[blockId] - if (!block?.type) continue - const blockName = block.name || block.type - - if (block.type === 'loop' || block.type === 'parallel') { - const insidePaths = getSubflowInsidePaths(block.type, blockId, loops, parallels) - results.push({ - blockId, - blockName, - blockType: block.type, - outputs: [], - relativeOutputs: [], - insideSubflowOutputs: formatOutputsForDisplay(insidePaths, blockName), - outsideSubflowOutputs: formatOutputsForDisplay(['results'], blockName), - relativeInsideSubflowOutputs: insidePaths, - relativeOutsideSubflowOutputs: ['results'], - triggerMode: block.triggerMode, - }) - continue - } - - const blockConfig = getBlock(block.type) - const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false - const triggerMode = Boolean(block.triggerMode && isTriggerCapable) - const outputs = getEffectiveBlockOutputPaths(block.type, block.subBlocks, { - triggerMode, - preferToolOutputs: !triggerMode, - }) - results.push({ - blockId, - blockName, - blockType: block.type, - outputs: formatOutputsForDisplay(outputs, blockName), - relativeOutputs: outputs, - triggerMode: block.triggerMode, - }) - } - - const variables = await getWorkflowVariablesForTool(workflowId) - - const payload = { blocks: results, variables } + const payload = await executeCopilotWorkflowUseCase(context, readCopilotWorkflowBlockOutputs, { + workflowId, + assertedWorkspaceId: context.workspaceId, + blockIds: params.blockIds, + }) return { success: true, output: payload } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotWorkflowError(error) } } } @@ -311,192 +252,17 @@ export async function executeGetBlockUpstreamReferences( if (!Array.isArray(params.blockIds) || params.blockIds.length === 0) { return { success: false, error: 'blockIds array is required' } } - await ensureWorkflowAccess(workflowId, context.userId) - - const normalized = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalized) { - return { success: false, error: 'Workflow has no normalized data' } - } - - const blocks = normalized.blocks || {} - const edges = normalized.edges || [] - const loops = normalized.loops || {} - const parallels = normalized.parallels || {} - - const graphEdges = edges.map((edge) => ({ source: edge.source, target: edge.target })) - const variableOutputs = await getWorkflowVariablesForTool(workflowId) - - interface AccessibleBlockEntry { - blockId: string - blockName: string - blockType: string - outputs: string[] - triggerMode?: boolean - accessContext?: 'inside' | 'outside' - } - - interface UpstreamReferenceResult { - blockId: string - blockName: string - blockType: string - accessibleBlocks: AccessibleBlockEntry[] - insideSubflows: Array<{ blockId: string; blockName: string; blockType: string }> - variables: Array<{ id: string; name: string; type: string; tag: string }> - } - - const results: UpstreamReferenceResult[] = [] - - for (const blockId of params.blockIds) { - const targetBlock = blocks[blockId] - if (!targetBlock) continue - - const insideSubflows: Array<{ blockId: string; blockName: string; blockType: string }> = [] - const containingLoopIds = new Set<string>() - const containingParallelIds = new Set<string>() - - Object.values(loops).forEach((loop) => { - if (loop?.nodes?.includes(blockId)) { - containingLoopIds.add(loop.id) - const loopBlock = blocks[loop.id] - if (loopBlock) { - insideSubflows.push({ - blockId: loop.id, - blockName: loopBlock.name || loopBlock.type, - blockType: 'loop', - }) - } - } - }) - - Object.values(parallels).forEach((parallel) => { - if (parallel?.nodes?.includes(blockId)) { - containingParallelIds.add(parallel.id) - const parallelBlock = blocks[parallel.id] - if (parallelBlock) { - insideSubflows.push({ - blockId: parallel.id, - blockName: parallelBlock.name || parallelBlock.type, - blockType: 'parallel', - }) - } - } - }) - - const ancestorIds = BlockPathCalculator.findAllPathNodes(graphEdges, blockId) - const accessibleIds = new Set<string>(ancestorIds) - accessibleIds.add(blockId) - - containingLoopIds.forEach((loopId) => accessibleIds.add(loopId)) - - containingParallelIds.forEach((parallelId) => accessibleIds.add(parallelId)) - - const accessibleBlocks: AccessibleBlockEntry[] = [] - - for (const accessibleBlockId of accessibleIds) { - const block = blocks[accessibleBlockId] - if (!block?.type) continue - const canSelfReference = block.type === 'approval' || block.type === 'human_in_the_loop' - if (accessibleBlockId === blockId && !canSelfReference) continue - - const blockName = block.name || block.type - let accessContext: 'inside' | 'outside' | undefined - - let formattedOutputs: string[] - if (block.type === 'loop' || block.type === 'parallel') { - const isInside = - (block.type === 'loop' && containingLoopIds.has(accessibleBlockId)) || - (block.type === 'parallel' && containingParallelIds.has(accessibleBlockId)) - accessContext = isInside ? 'inside' : 'outside' - const outputPaths = isInside - ? getSubflowInsidePaths(block.type, accessibleBlockId, loops, parallels) - : ['results'] - formattedOutputs = formatOutputsForDisplay(outputPaths, blockName) - } else { - formattedOutputs = getBlockReferenceTags({ - block: { - id: accessibleBlockId, - type: block.type, - name: block.name, - triggerMode: block.triggerMode, - subBlocks: block.subBlocks, - }, - currentBlockId: blockId, - }) - } - const entry: AccessibleBlockEntry = { - blockId: accessibleBlockId, - blockName, - blockType: block.type, - outputs: formattedOutputs, - ...(block.triggerMode ? { triggerMode: true } : {}), - ...(accessContext ? { accessContext } : {}), - } - accessibleBlocks.push(entry) - } - - results.push({ - blockId, - blockName: targetBlock.name || targetBlock.type, - blockType: targetBlock.type, - accessibleBlocks, - insideSubflows, - variables: variableOutputs, - }) - } - - const payload = { results } + const payload = await executeCopilotWorkflowUseCase( + context, + readCopilotWorkflowUpstreamReferences, + { workflowId, assertedWorkspaceId: context.workspaceId, blockIds: params.blockIds } + ) return { success: true, output: payload } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotWorkflowError(error) } } } -async function getWorkflowVariablesForTool( - workflowId: string -): Promise<Array<{ id: string; name: string; type: string; tag: string }>> { - const workflowRecord = await getWorkflowById(workflowId) - - const variablesRecord = (workflowRecord?.variables as Record<string, unknown>) || {} - return Object.values(variablesRecord) - .filter((v): v is Record<string, unknown> => { - if (!v || typeof v !== 'object') return false - const variable = v as Record<string, unknown> - return !!variable.name && String(variable.name).trim() !== '' - }) - .map((v) => ({ - id: String(v.id || ''), - name: String(v.name || ''), - type: String(v.type || 'plain'), - tag: `variable.${normalizeName(String(v.name || ''))}`, - })) -} - -function getSubflowInsidePaths( - blockType: 'loop' | 'parallel', - blockId: string, - loops: Record<string, Loop>, - parallels: Record<string, Parallel> -): string[] { - const paths = ['index'] - if (blockType === 'loop') { - const loopType = loops[blockId]?.loopType || 'for' - if (loopType === 'forEach') { - paths.push('currentItem', 'items') - } - } else { - const parallelType = parallels[blockId]?.parallelType || 'count' - if (parallelType === 'collection') { - paths.push('currentItem', 'items') - } - } - return paths -} - -function formatOutputsForDisplay(paths: string[], blockName: string): string[] { - const normalizedName = normalizeName(blockName) - return paths.map((path) => `${normalizedName}.${path}`) -} - export async function executeGetDeployedWorkflowState( params: GetDeployedWorkflowStateParams, context: ExecutionContext @@ -507,10 +273,12 @@ export async function executeGetDeployedWorkflowState( return { success: false, error: 'workflowId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) - - try { - const deployedState = await loadDeployedWorkflowState(workflowId) + const { workflow: workflowRecord, state: deployedState } = await executeCopilotWorkflowUseCase( + context, + readWorkflowDefinition, + { workflowId, assertedWorkspaceId: context.workspaceId, state: 'deployed' } + ) + if (deployedState) { const formatted = formatNormalizedWorkflowForCopilot({ blocks: deployedState.blocks, edges: deployedState.edges, @@ -524,25 +292,22 @@ export async function executeGetDeployedWorkflowState( workflowId, workflowName: workflowRecord.name || '', isDeployed: true, - deploymentVersionId: deployedState.deploymentVersionId, + deploymentVersionId: + 'deploymentVersionId' in deployedState ? deployedState.deploymentVersionId : undefined, deployedState: formatted, }, } - } catch (error) { - if (!(error instanceof NoActiveDeploymentError)) { - return { success: false, error: toError(error).message } - } - return { - success: true, - output: { - workflowId, - workflowName: workflowRecord.name || '', - isDeployed: false, - message: 'Workflow has not been deployed yet.', - }, - } + } + return { + success: true, + output: { + workflowId, + workflowName: workflowRecord.name || '', + isDeployed: false, + message: 'Workflow has not been deployed yet.', + }, } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotWorkflowError(error) } } } diff --git a/apps/sim/lib/copilot/vfs/path-utils.ts b/apps/sim/lib/copilot/vfs/path-utils.ts index daeb5c0a631..490c0bf0bfb 100644 --- a/apps/sim/lib/copilot/vfs/path-utils.ts +++ b/apps/sim/lib/copilot/vfs/path-utils.ts @@ -1,37 +1,18 @@ -const CONTROL_CHARS = /[\x00-\x1f\x7f]/g -const WHITESPACE = /\s+/g - -export class VfsPathError extends Error { - constructor(message: string) { - super(message) - this.name = 'VfsPathError' - } -} - -function normalizeDisplaySegment(segment: string): string { - return segment.normalize('NFC').trim().replace(CONTROL_CHARS, '').replace(WHITESPACE, ' ') -} +import { + canonicalizeVfsPath as canonicalizeNeutralVfsPath, + decodeVfsPathSegments as decodeNeutralVfsPathSegments, + decodeVfsSegment as decodeNeutralVfsSegment, + decodeVfsSegmentSafe as decodeNeutralVfsSegmentSafe, + encodeVfsPathSegments as encodeNeutralVfsPathSegments, + encodeVfsSegment as encodeNeutralVfsSegment, +} from '@/lib/vfs/path' export function encodeVfsSegment(segment: string): string { - const normalized = normalizeDisplaySegment(segment) - if (!normalized || normalized === '.' || normalized === '..') { - throw new VfsPathError('VFS path segment cannot be empty or a dot segment') - } - return encodeURIComponent(normalized) + return encodeNeutralVfsSegment(segment) } export function decodeVfsSegment(segment: string): string { - try { - const decoded = decodeURIComponent(segment) - const normalized = normalizeDisplaySegment(decoded) - if (!normalized || normalized === '.' || normalized === '..') { - throw new VfsPathError('VFS path segment cannot be empty or a dot segment') - } - return normalized - } catch (error) { - if (error instanceof VfsPathError) throw error - throw new VfsPathError(`Invalid encoded VFS path segment: ${segment}`) - } + return decodeNeutralVfsSegment(segment) } /** @@ -39,25 +20,19 @@ export function decodeVfsSegment(segment: string): string { * it is not valid encoding (e.g. a literal "%" that was never encoded). */ export function decodeVfsSegmentSafe(segment: string): string { - try { - return decodeVfsSegment(segment) - } catch { - return segment - } + return decodeNeutralVfsSegmentSafe(segment) } export function encodeVfsPathSegments(segments: string[]): string { - return segments.map(encodeVfsSegment).join('/') + return encodeNeutralVfsPathSegments(segments) } export function decodeVfsPathSegments(path: string): string[] { - const trimmed = path.trim().replace(/^\/+|\/+$/g, '') - if (!trimmed) return [] - return trimmed.split('/').map(decodeVfsSegment) + return decodeNeutralVfsPathSegments(path) } export function canonicalizeVfsPath(path: string): string { - return encodeVfsPathSegments(decodeVfsPathSegments(path)) + return canonicalizeNeutralVfsPath(path) } export function canonicalWorkspaceFilePath(parts: { diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 60f9f033c6e..95784c2e315 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -126,6 +126,7 @@ import { findWorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/worksp import type { WorkspaceFileSecretProvenanceEnvelope } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' +import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { getSkillById } from '@/lib/workflows/skills/operations' @@ -139,7 +140,6 @@ import { getWorkspaceWithOwner, hasWorkspaceAdminAccess, } from '@/lib/workspaces/permissions/utils' -import { checkNeedsRedeployment } from '@/app/api/workflows/utils' import { buildCustomBlockConfig, isCustomBlockType } from '@/blocks/custom/build-config' import { BLOCK_REGISTRY } from '@/blocks/registry-maps' import type { BlockConfig, BlockIcon } from '@/blocks/types' diff --git a/apps/sim/lib/knowledge/application/knowledge-vfs.ts b/apps/sim/lib/knowledge/application/knowledge-vfs.ts new file mode 100644 index 00000000000..da538dae88e --- /dev/null +++ b/apps/sim/lib/knowledge/application/knowledge-vfs.ts @@ -0,0 +1,105 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { + type KnowledgeWorkspaceContext, + resolveKnowledgeWorkspaceContext, +} from '@/lib/knowledge/application/contexts' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + deleteKnowledgeBase, + getWorkspaceKnowledgeBases, + updateKnowledgeBase, +} from '@/lib/knowledge/service' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' + +interface KnowledgeVfsReferenceInput { + workspaceId: string + sourceName: string +} + +export interface RenameKnowledgeBaseByVfsPathInput extends KnowledgeVfsReferenceInput { + newName: string +} + +export type DeleteKnowledgeBaseByVfsPathInput = KnowledgeVfsReferenceInput + +async function resolveKnowledgeBaseByVfsName( + context: KnowledgeWorkspaceContext, + sourceName: string +): Promise<KnowledgeBaseWithCounts> { + const rows = await getWorkspaceKnowledgeBases(context.workspaceId, 'active', { + search: sourceName, + }) + const matches = rows.filter((row) => row.name === sourceName) + if (matches.length > 1) { + throw new OrchestrationError( + 'conflict', + `Knowledge base path is ambiguous: knowledgebases/${sourceName}` + ) + } + const knowledgeBase = matches[0] + if (!knowledgeBase) { + throw new OrchestrationError( + 'not_found', + `Knowledge base not found at knowledgebases/${sourceName}` + ) + } + return knowledgeBase +} + +export const renameKnowledgeBaseByVfsPath = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.renameByVfsPath, + resolveContext: ({ input }: { input: RenameKnowledgeBaseByVfsPathInput }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ input, context }) { + const knowledgeBase = await resolveKnowledgeBaseByVfsName(context, input.sourceName) + const updated = await updateKnowledgeBase( + knowledgeBase.id, + { name: input.newName }, + generateRequestId(), + { assertedWorkspaceId: context.workspaceId } + ) + return { + id: updated.id, + name: updated.name, + previousName: knowledgeBase.name, + workspaceId: context.workspaceId, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: result.id, + resourceName: result.name, + description: `Renamed knowledge base to "${result.name}"`, + metadata: { source: 'copilot_vfs', previousName: result.previousName, updatedFields: ['name'] }, + }), +}) + +export const deleteKnowledgeBaseByVfsPath = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.deleteByVfsPath, + resolveContext: ({ input }: { input: DeleteKnowledgeBaseByVfsPathInput }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ input, context }) { + const knowledgeBase = await resolveKnowledgeBaseByVfsName(context, input.sourceName) + await deleteKnowledgeBase(knowledgeBase.id, generateRequestId(), { + assertedWorkspaceId: context.workspaceId, + }) + return { + id: knowledgeBase.id, + name: knowledgeBase.name, + workspaceId: context.workspaceId, + deleted: true as const, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: result.id, + resourceName: result.name, + description: `Deleted knowledge base "${result.name}"`, + metadata: { source: 'copilot_vfs', knowledgeBaseName: result.name }, + }), +}) diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index 4bacaa7a01b..c0181d2f8e9 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -4,6 +4,10 @@ const ALL_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], delegatedServices: ['copilot'], } as const +const COPILOT_PRINCIPAL_POLICY = { + principalKinds: ['delegated'], + delegatedServices: ['copilot'], +} as const const ALL_PRINCIPAL_WITH_EXECUTOR_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], @@ -67,6 +71,18 @@ export const knowledgeOperations = { workspaceApiKey: 'allow', ...ALL_PRINCIPAL_POLICY, }), + renameByVfsPath: defineWorkspaceOperation({ + id: 'knowledge.vfs.rename', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_PRINCIPAL_POLICY, + }), + deleteByVfsPath: defineWorkspaceOperation({ + id: 'knowledge.vfs.delete', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_PRINCIPAL_POLICY, + }), search: defineWorkspaceOperation({ id: 'knowledge.search', minimumRole: 'read', diff --git a/apps/sim/lib/mcp/application/operations.ts b/apps/sim/lib/mcp/application/operations.ts index e615dcfe77a..4e89347a41a 100644 --- a/apps/sim/lib/mcp/application/operations.ts +++ b/apps/sim/lib/mcp/application/operations.ts @@ -12,6 +12,54 @@ export const mcpServerOperations = { workspaceApiKey: 'allow', ...ALL_PRINCIPAL_POLICY, }), + discoverTools: defineWorkspaceOperation({ + id: 'mcp_servers.tools.discover', + minimumRole: 'read', + workspaceApiKey: 'allow', + ...ALL_PRINCIPAL_POLICY, + }), + listWorkflowDeployments: defineWorkspaceOperation({ + id: 'mcp_servers.workflow_deployments.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + createWorkflowDeploymentServer: defineWorkspaceOperation({ + id: 'mcp_servers.workflow_deployments.create_server', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + updateWorkflowDeploymentServer: defineWorkspaceOperation({ + id: 'mcp_servers.workflow_deployments.update_server', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + deleteWorkflowDeploymentServer: defineWorkspaceOperation({ + id: 'mcp_servers.workflow_deployments.delete_server', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + deployWorkflowTool: defineWorkspaceOperation({ + id: 'mcp_servers.workflow_deployments.deploy_tool', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + undeployWorkflowTool: defineWorkspaceOperation({ + id: 'mcp_servers.workflow_deployments.undeploy_tool', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), read: defineWorkspaceOperation({ id: 'mcp_servers.read', minimumRole: 'read', diff --git a/apps/sim/lib/mcp/application/use-cases.ts b/apps/sim/lib/mcp/application/use-cases.ts index 9137d97cbb0..b656ea30cf5 100644 --- a/apps/sim/lib/mcp/application/use-cases.ts +++ b/apps/sim/lib/mcp/application/use-cases.ts @@ -1,5 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { requirePrincipalSubjectUserId, resolvePrincipalAttribution } from '@sim/auth/principal' import { getPostgresErrorCode } from '@sim/utils/errors' import type { ListSortOrder } from '@/lib/api/list-query' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' @@ -20,6 +20,7 @@ import { type McpServerRow, type McpServerSortBy, } from '@/lib/mcp/queries' +import { mcpService } from '@/lib/mcp/service' import type { McpAuthType } from '@/lib/mcp/types' import { generateMcpServerId } from '@/lib/mcp/utils' import { loadActiveWorkspaceContext } from '@/lib/uploads/contexts/workspace' @@ -94,6 +95,26 @@ export const listMcpServersUseCase = defineAuthorizedWorkspaceUseCase({ }, }) +export interface DiscoverMcpToolsInput { + workspaceId: string + refresh?: boolean +} + +export const discoverMcpToolsUseCase = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.discoverTools, + resolveContext: ({ input }: { input: DiscoverMcpToolsInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ principal, input, context }) { + const tools = await mcpService.discoverTools( + requirePrincipalSubjectUserId(principal), + context.workspaceId, + input.refresh ?? false + ) + return { tools } + }, +}) + export interface GetMcpServerInput { workspaceId: string serverId: string diff --git a/apps/sim/lib/mcp/application/workflow-deployments.test.ts b/apps/sim/lib/mcp/application/workflow-deployments.test.ts new file mode 100644 index 00000000000..4957911976c --- /dev/null +++ b/apps/sim/lib/mcp/application/workflow-deployments.test.ts @@ -0,0 +1,181 @@ +/** + * @vitest-environment node + */ +import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks } = vi.hoisted(() => ({ + mocks: { + audit: vi.fn(), + loadWorkspace: vi.fn(), + permission: vi.fn(), + publish: vi.fn(), + updateServer: vi.fn(), + }, +})) + +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + MCP_SERVER_ADDED: 'mcp_server.added', + MCP_SERVER_UPDATED: 'mcp_server.updated', + MCP_SERVER_REMOVED: 'mcp_server.removed', + }, + AuditResourceType: { MCP_SERVER: 'mcp_server' }, + recordAudit: mocks.audit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.permission, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateWorkflowMcpServer: vi.fn(), + performCreateWorkflowMcpTool: vi.fn(), + performDeleteWorkflowMcpServer: vi.fn(), + performDeleteWorkflowMcpTool: vi.fn(), + performUpdateWorkflowMcpServer: mocks.updateServer, + performUpdateWorkflowMcpTool: vi.fn(), +})) + +vi.mock('@/lib/mcp/pubsub', () => ({ + mcpPubSub: { publishWorkflowToolsChanged: mocks.publish }, +})) + +vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ + getDeployedWorkflowInputFormat: vi.fn(), +})) + +vi.mock('@/lib/mcp/workflow-tool-schema', () => ({ + applyDescriptionOverrides: vi.fn(), + generateToolInputSchema: vi.fn(), + sanitizeToolName: vi.fn((name: string) => name), +})) + +import { updateWorkflowMcpDeploymentServer } from '@/lib/mcp/application/workflow-deployments' + +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:mcp-servers', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), +} + +const server = { + id: 'server-1', + workspaceId: 'workspace-1', + name: 'Production MCP', + description: null, + isPublic: false, + deletedAt: null, +} + +describe('workflow MCP deployment application commands', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.loadWorkspace.mockImplementation(async (workspaceId: string) => ({ + workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + })) + mocks.permission.mockResolvedValue('write') + mocks.updateServer.mockResolvedValue({ + success: true, + server: { ...server, name: 'Renamed MCP' }, + updatedFields: ['name'], + }) + }) + + it('derives workspace authorization canonically from the server id', async () => { + queueTableRows(schemaMock.workflowMcpServer, [{ ...server, workspaceId: 'workspace-2' }]) + + await expect( + updateWorkflowMcpDeploymentServer.execute({ + principal, + input: { serverId: server.id, name: 'Renamed MCP' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadWorkspace).toHaveBeenCalledWith('workspace-2') + expect(mocks.updateServer).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('rechecks the delegated subject permission before mutation', async () => { + queueTableRows(schemaMock.workflowMcpServer, [server]) + mocks.permission.mockResolvedValueOnce(null) + + await expect( + updateWorkflowMcpDeploymentServer.execute({ + principal, + input: { serverId: server.id, name: 'Renamed MCP' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.updateServer).not.toHaveBeenCalled() + }) + + it('owns mutation attribution and semantic audit', async () => { + queueTableRows(schemaMock.workflowMcpServer, [server]) + + const result = await updateWorkflowMcpDeploymentServer.execute({ + principal, + input: { serverId: server.id, name: 'Renamed MCP' }, + }) + + expect(result.server.name).toBe('Renamed MCP') + expect(mocks.updateServer).toHaveBeenCalledWith( + expect.objectContaining({ + serverId: server.id, + workspaceId: server.workspaceId, + userId: principal.subjectUserId, + projectLegacyAudit: false, + publishEffects: false, + }) + ) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'mcp_server.updated', + resourceId: server.id, + metadata: expect.objectContaining({ + operation: 'mcp_servers.workflow_deployments.update_server', + }), + }) + ) + }) + + it('fails fast with a generic application error for an internal lower-layer result', async () => { + queueTableRows(schemaMock.workflowMcpServer, [server]) + mocks.updateServer.mockResolvedValueOnce({ + success: false, + error: 'postgres password=secret', + errorCode: 'internal', + }) + + await expect( + updateWorkflowMcpDeploymentServer.execute({ + principal, + input: { serverId: server.id, name: 'Renamed MCP' }, + }) + ).rejects.toThrow('Failed to update workflow MCP server') + + expect(mocks.audit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/mcp/application/workflow-deployments.ts b/apps/sim/lib/mcp/application/workflow-deployments.ts new file mode 100644 index 00000000000..b0543432e17 --- /dev/null +++ b/apps/sim/lib/mcp/application/workflow-deployments.ts @@ -0,0 +1,431 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { db, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db' +import { and, asc, eq, inArray, isNull } from 'drizzle-orm' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { mcpServerDelegationPolicy } from '@/lib/mcp/application/authorization' +import { mcpServerOperations } from '@/lib/mcp/application/operations' +import { + performCreateWorkflowMcpServer, + performCreateWorkflowMcpTool, + performDeleteWorkflowMcpServer, + performDeleteWorkflowMcpTool, + performUpdateWorkflowMcpServer, + performUpdateWorkflowMcpTool, +} from '@/lib/mcp/orchestration' +import { mcpPubSub } from '@/lib/mcp/pubsub' +import { getDeployedWorkflowInputFormat } from '@/lib/mcp/workflow-mcp-sync' +import { + applyDescriptionOverrides, + generateToolInputSchema, + sanitizeToolName, +} from '@/lib/mcp/workflow-tool-schema' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +const MAX_LISTED_WORKFLOW_MCP_SERVERS = 100 +const MAX_LISTED_WORKFLOW_MCP_TOOLS = 2000 +const MAX_MCP_PARAMETER_DESCRIPTION_OVERRIDES = 100 +const authorizationOptions = { delegation: mcpServerDelegationPolicy } + +async function resolveWorkspaceContext(workspaceId: string) { + const context = await loadActiveWorkspaceApplicationContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} + +async function resolveServerContext(serverId: string) { + const [server] = await db + .select() + .from(workflowMcpServer) + .where(and(eq(workflowMcpServer.id, serverId), isNull(workflowMcpServer.deletedAt))) + .limit(1) + if (!server) throw new OrchestrationError('not_found', 'MCP server not found') + const workspace = await resolveWorkspaceContext(server.workspaceId) + return { ...workspace, server } +} + +async function resolveWorkflowToolContext(serverId: string, workflowId: string) { + const context = await resolveServerContext(serverId) + const [workflowRecord] = await db + .select() + .from(workflow) + .where( + and( + eq(workflow.id, workflowId), + eq(workflow.workspaceId, context.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .limit(1) + if (!workflowRecord) throw new OrchestrationError('not_found', 'Workflow not found') + return { ...context, workflow: workflowRecord } +} + +function throwWorkflowMcpFailure( + result: { + error?: string + errorCode?: 'not_found' | 'validation' | 'forbidden' | 'conflict' | 'internal' + }, + fallback: string +): never { + if (!result.errorCode || result.errorCode === 'internal') throw new Error(fallback) + throw new OrchestrationError(result.errorCode, result.error ?? fallback) +} + +function attribution( + principal: Parameters<typeof resolvePrincipalAttribution>[0], + billedAccountUserId: string +) { + return resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: billedAccountUserId, + }).attributedUserId +} + +export interface ListWorkflowMcpDeploymentsInput { + workspaceId: string +} + +export const listWorkflowMcpDeployments = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.listWorkflowDeployments, + resolveContext: ({ input }: { input: ListWorkflowMcpDeploymentsInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ context }) { + const rows = await db + .select({ + id: workflowMcpServer.id, + name: workflowMcpServer.name, + description: workflowMcpServer.description, + }) + .from(workflowMcpServer) + .where( + and( + eq(workflowMcpServer.workspaceId, context.workspaceId), + isNull(workflowMcpServer.deletedAt) + ) + ) + .orderBy(asc(workflowMcpServer.id)) + .limit(MAX_LISTED_WORKFLOW_MCP_SERVERS + 1) + const truncated = rows.length > MAX_LISTED_WORKFLOW_MCP_SERVERS + const servers = rows.slice(0, MAX_LISTED_WORKFLOW_MCP_SERVERS) + const serverIds = servers.map((server) => server.id) + const tools = + serverIds.length === 0 + ? [] + : await db + .select({ serverId: workflowMcpTool.serverId, toolName: workflowMcpTool.toolName }) + .from(workflowMcpTool) + .where( + and(inArray(workflowMcpTool.serverId, serverIds), isNull(workflowMcpTool.archivedAt)) + ) + .orderBy(asc(workflowMcpTool.serverId), asc(workflowMcpTool.toolName)) + .limit(MAX_LISTED_WORKFLOW_MCP_TOOLS + 1) + const toolsTruncated = tools.length > MAX_LISTED_WORKFLOW_MCP_TOOLS + const names = new Map<string, string[]>() + for (const tool of tools.slice(0, MAX_LISTED_WORKFLOW_MCP_TOOLS)) { + const existing = names.get(tool.serverId) ?? [] + existing.push(tool.toolName) + names.set(tool.serverId, existing) + } + return { + servers: servers.map((server) => ({ + ...server, + toolCount: names.get(server.id)?.length ?? 0, + toolNames: names.get(server.id) ?? [], + })), + truncated: truncated || toolsTruncated, + } + }, +}) + +export interface CreateWorkflowMcpDeploymentServerInput { + workspaceId: string + name: string + description?: string + isPublic?: boolean + workflowIds?: string[] +} + +export const createWorkflowMcpDeploymentServer = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.createWorkflowDeploymentServer, + resolveContext: ({ input }: { input: CreateWorkflowMcpDeploymentServerInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ principal, input, context }) { + const result = await performCreateWorkflowMcpServer({ + ...input, + workspaceId: context.workspaceId, + userId: attribution(principal, context.billedAccountUserId), + projectLegacyAudit: false, + publishEffects: false, + }) + if (!result.success || !result.server) { + throwWorkflowMcpFailure(result, 'Failed to create workflow MCP server') + } + return { server: result.server, addedTools: result.addedTools ?? [] } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.MCP_SERVER_ADDED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.server.id, + resourceName: result.server.name, + description: `Published workflow MCP server "${result.server.name}" with ${result.addedTools.length} tool(s)`, + metadata: { + serverName: result.server.name, + isPublic: result.server.isPublic, + toolCount: result.addedTools.length, + toolNames: result.addedTools.map((tool) => tool.toolName), + workflowIds: result.addedTools.map((tool) => tool.workflowId), + }, + }), + afterSuccess: ({ context, result }) => + result.addedTools.length > 0 + ? mcpPubSub?.publishWorkflowToolsChanged({ + serverId: result.server.id, + workspaceId: context.workspaceId, + }) + : undefined, +}) + +export interface UpdateWorkflowMcpDeploymentServerInput { + serverId: string + name?: string + description?: string | null + isPublic?: boolean +} + +export const updateWorkflowMcpDeploymentServer = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.updateWorkflowDeploymentServer, + resolveContext: ({ input }: { input: UpdateWorkflowMcpDeploymentServerInput }) => + resolveServerContext(input.serverId), + authorizationOptions, + async execute({ principal, input, context }) { + const result = await performUpdateWorkflowMcpServer({ + ...input, + workspaceId: context.workspaceId, + userId: attribution(principal, context.billedAccountUserId), + projectLegacyAudit: false, + publishEffects: false, + }) + if (!result.success || !result.server) { + throwWorkflowMcpFailure(result, 'Failed to update workflow MCP server') + } + return { server: result.server, updatedFields: result.updatedFields ?? [] } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.server.id, + resourceName: result.server.name, + description: `Updated workflow MCP server "${result.server.name}"`, + metadata: { + serverName: result.server.name, + isPublic: result.server.isPublic, + updatedFields: result.updatedFields, + }, + }), +}) + +export interface DeleteWorkflowMcpDeploymentServerInput { + serverId: string +} + +export const deleteWorkflowMcpDeploymentServer = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.deleteWorkflowDeploymentServer, + resolveContext: ({ input }: { input: DeleteWorkflowMcpDeploymentServerInput }) => + resolveServerContext(input.serverId), + authorizationOptions, + async execute({ principal, input, context }) { + const result = await performDeleteWorkflowMcpServer({ + serverId: input.serverId, + workspaceId: context.workspaceId, + userId: attribution(principal, context.billedAccountUserId), + projectLegacyAudit: false, + publishEffects: false, + }) + if (!result.success || !result.server) { + throwWorkflowMcpFailure(result, 'Failed to delete workflow MCP server') + } + return { server: result.server } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.MCP_SERVER_REMOVED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.server.id, + resourceName: result.server.name, + description: `Unpublished workflow MCP server "${result.server.name}"`, + metadata: { serverName: result.server.name }, + }), + afterSuccess: ({ context, result }) => + mcpPubSub?.publishWorkflowToolsChanged({ + serverId: result.server.id, + workspaceId: context.workspaceId, + }), +}) + +export interface DeployWorkflowMcpToolInput { + serverId: string + workflowId: string + toolName?: string + toolDescription?: string + parameterDescriptions?: Array<{ name?: string; description?: string }> +} + +export const deployWorkflowMcpTool = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.deployWorkflowTool, + resolveContext: ({ input }: { input: DeployWorkflowMcpToolInput }) => + resolveWorkflowToolContext(input.serverId, input.workflowId), + authorizationOptions, + async execute({ principal, input, context }) { + if (!context.workflow.isDeployed) { + throw new OrchestrationError( + 'validation', + 'Workflow must be deployed before adding as an MCP tool. Use deploy_api first.' + ) + } + if ( + input.parameterDescriptions && + input.parameterDescriptions.length > MAX_MCP_PARAMETER_DESCRIPTION_OVERRIDES + ) { + throw new OrchestrationError( + 'validation', + `MCP tools cannot override more than ${MAX_MCP_PARAMETER_DESCRIPTION_OVERRIDES} parameter descriptions` + ) + } + const [existing] = await db + .select() + .from(workflowMcpTool) + .where( + and( + eq(workflowMcpTool.serverId, context.server.id), + eq(workflowMcpTool.workflowId, context.workflow.id), + isNull(workflowMcpTool.archivedAt) + ) + ) + .limit(1) + const toolName = sanitizeToolName( + input.toolName || context.workflow.name || `workflow_${context.workflow.id}` + ) + const toolDescription = + input.toolDescription?.trim() || `Execute ${context.workflow.name} workflow` + const parameterDescriptionOverrides = Object.fromEntries( + (input.parameterDescriptions ?? []) + .filter((entry) => typeof entry.name === 'string' && entry.name.trim().length > 0) + .map((entry) => [entry.name?.trim() ?? '', entry.description?.trim() ?? '']) + .filter(([, description]) => description.length > 0) + ) + const parameterSchema = applyDescriptionOverrides( + generateToolInputSchema(await getDeployedWorkflowInputFormat(context.workflow.id)), + parameterDescriptionOverrides + ) + const userId = attribution(principal, context.billedAccountUserId) + const result = existing + ? await performUpdateWorkflowMcpTool({ + serverId: context.server.id, + toolId: existing.id, + workspaceId: context.workspaceId, + userId, + toolName, + toolDescription, + parameterDescriptionOverrides, + projectLegacyAudit: false, + publishEffects: false, + }) + : await performCreateWorkflowMcpTool({ + serverId: context.server.id, + workspaceId: context.workspaceId, + userId, + workflowId: context.workflow.id, + toolName, + toolDescription, + parameterDescriptionOverrides, + projectLegacyAudit: false, + publishEffects: false, + }) + if (!result.success || !result.tool) { + throwWorkflowMcpFailure(result, 'Failed to deploy workflow MCP tool') + } + return { + tool: result.tool, + server: context.server, + workflow: context.workflow, + updated: Boolean(existing), + parameterSchema, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.server.id, + resourceName: result.server.name, + description: `${result.updated ? 'Updated' : 'Added'} tool "${result.tool.toolName}" on MCP server`, + metadata: { + toolId: result.tool.id, + toolName: result.tool.toolName, + workflowId: result.workflow.id, + }, + }), + afterSuccess: ({ context, result }) => + mcpPubSub?.publishWorkflowToolsChanged({ + serverId: result.server.id, + workspaceId: context.workspaceId, + }), +}) + +export interface UndeployWorkflowMcpToolInput { + serverId: string + workflowId: string +} + +export const undeployWorkflowMcpTool = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.undeployWorkflowTool, + resolveContext: ({ input }: { input: UndeployWorkflowMcpToolInput }) => + resolveWorkflowToolContext(input.serverId, input.workflowId), + authorizationOptions, + async execute({ principal, context }) { + const [tool] = await db + .select() + .from(workflowMcpTool) + .where( + and( + eq(workflowMcpTool.serverId, context.server.id), + eq(workflowMcpTool.workflowId, context.workflow.id), + isNull(workflowMcpTool.archivedAt) + ) + ) + .limit(1) + if (!tool) { + throw new OrchestrationError('not_found', 'Workflow is not deployed to this MCP server') + } + const result = await performDeleteWorkflowMcpTool({ + serverId: context.server.id, + toolId: tool.id, + workspaceId: context.workspaceId, + userId: attribution(principal, context.billedAccountUserId), + projectLegacyAudit: false, + publishEffects: false, + }) + if (!result.success || !result.tool) { + throwWorkflowMcpFailure(result, 'Failed to undeploy workflow MCP tool') + } + return { tool: result.tool, server: context.server, workflow: context.workflow } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.server.id, + resourceName: result.server.name, + description: `Removed tool "${result.tool.toolName}" from MCP server`, + metadata: { + toolId: result.tool.id, + toolName: result.tool.toolName, + workflowId: result.workflow.id, + }, + }), + afterSuccess: ({ context, result }) => + mcpPubSub?.publishWorkflowToolsChanged({ + serverId: result.server.id, + workspaceId: context.workspaceId, + }), +}) diff --git a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts index df8e03908db..a10d236b473 100644 --- a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts @@ -58,6 +58,8 @@ class WorkflowMcpExpectedError extends Error { interface ActorMetadata { actorName?: string | null actorEmail?: string | null + projectLegacyAudit?: boolean + publishEffects?: boolean } export interface PerformCreateWorkflowMcpServerParams extends ActorMetadata { @@ -497,28 +499,29 @@ export async function performCreateWorkflowMcpServer( return { server: createdServer, addedTools: insertedTools, serverId: newServerId } }) - if (addedTools.length > 0) { + if (addedTools.length > 0 && params.publishEffects !== false) { mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId: params.workspaceId }) } - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.MCP_SERVER_ADDED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: name, - description: `Published workflow MCP server "${name}" with ${addedTools.length} tool(s)`, - metadata: { - serverName: name, - isPublic: params.isPublic ?? false, - toolCount: addedTools.length, - toolNames: addedTools.map((tool) => tool.toolName), - workflowIds: addedTools.map((tool) => tool.workflowId), - }, - }) + if (params.projectLegacyAudit !== false) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.MCP_SERVER_ADDED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + resourceName: name, + description: `Published workflow MCP server "${name}" with ${addedTools.length} tool(s)`, + metadata: { + serverName: name, + isPublic: params.isPublic ?? false, + toolCount: addedTools.length, + toolNames: addedTools.map((tool) => tool.toolName), + workflowIds: addedTools.map((tool) => tool.workflowId), + }, + }) return { success: true, server, addedTools } } catch (error) { @@ -565,22 +568,23 @@ export async function performUpdateWorkflowMcpServer( return { success: false, error: 'Server not found', errorCode: 'not_found' } } - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: params.serverId, - resourceName: server.name, - description: `Updated workflow MCP server "${server.name}"`, - metadata: { - serverName: server.name, - isPublic: server.isPublic, - updatedFields, - }, - }) + if (params.projectLegacyAudit !== false) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: params.serverId, + resourceName: server.name, + description: `Updated workflow MCP server "${server.name}"`, + metadata: { + serverName: server.name, + isPublic: server.isPublic, + updatedFields, + }, + }) return { success: true, server, updatedFields } } catch (error) { @@ -613,23 +617,25 @@ export async function performDeleteWorkflowMcpServer( return { success: false, error: 'Server not found', errorCode: 'not_found' } } - mcpPubSub?.publishWorkflowToolsChanged({ - serverId: params.serverId, - workspaceId: params.workspaceId, - }) + if (params.publishEffects !== false) + mcpPubSub?.publishWorkflowToolsChanged({ + serverId: params.serverId, + workspaceId: params.workspaceId, + }) - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.MCP_SERVER_REMOVED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: params.serverId, - resourceName: server.name, - description: `Unpublished workflow MCP server "${server.name}"`, - metadata: { serverName: server.name }, - }) + if (params.projectLegacyAudit !== false) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.MCP_SERVER_REMOVED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: params.serverId, + resourceName: server.name, + description: `Unpublished workflow MCP server "${server.name}"`, + metadata: { serverName: server.name }, + }) return { success: true, server } } catch (error) { @@ -836,28 +842,30 @@ export async function performCreateWorkflowMcpTool( return { success: false, error: 'Failed to add tool', errorCode: 'internal' } } - mcpPubSub?.publishWorkflowToolsChanged({ - serverId: params.serverId, - workspaceId: params.workspaceId, - }) + if (params.publishEffects !== false) + mcpPubSub?.publishWorkflowToolsChanged({ + serverId: params.serverId, + workspaceId: params.workspaceId, + }) - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: params.serverId, - description: `Added tool "${toolName}" to MCP server`, - metadata: { - toolId, - toolName, - toolDescription, - workflowId: params.workflowId, - workflowName: workflowRecord.name, - }, - }) + if (params.projectLegacyAudit !== false) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: params.serverId, + description: `Added tool "${toolName}" to MCP server`, + metadata: { + toolId, + toolName, + toolDescription, + workflowId: params.workflowId, + workflowName: workflowRecord.name, + }, + }) return { success: true, tool } } catch (error) { @@ -1045,27 +1053,29 @@ export async function performUpdateWorkflowMcpTool( if (!tool) return { success: false, error: 'Tool not found', errorCode: 'not_found' } - mcpPubSub?.publishWorkflowToolsChanged({ - serverId: params.serverId, - workspaceId: params.workspaceId, - }) + if (params.publishEffects !== false) + mcpPubSub?.publishWorkflowToolsChanged({ + serverId: params.serverId, + workspaceId: params.workspaceId, + }) - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: params.serverId, - description: `Updated tool "${tool.toolName}" in MCP server`, - metadata: { - toolId: params.toolId, - toolName: tool.toolName, - workflowId: tool.workflowId, - updatedFields, - }, - }) + if (params.projectLegacyAudit !== false) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: params.serverId, + description: `Updated tool "${tool.toolName}" in MCP server`, + metadata: { + toolId: params.toolId, + toolName: tool.toolName, + workflowId: tool.workflowId, + updatedFields, + }, + }) return { success: true, tool } } catch (error) { @@ -1117,22 +1127,24 @@ export async function performDeleteWorkflowMcpTool( if (!tool) return { success: false, error: 'Tool not found', errorCode: 'not_found' } - mcpPubSub?.publishWorkflowToolsChanged({ - serverId: params.serverId, - workspaceId: params.workspaceId, - }) + if (params.publishEffects !== false) + mcpPubSub?.publishWorkflowToolsChanged({ + serverId: params.serverId, + workspaceId: params.workspaceId, + }) - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: params.serverId, - description: `Removed tool "${tool.toolName}" from MCP server`, - metadata: { toolId: params.toolId, toolName: tool.toolName, workflowId: tool.workflowId }, - }) + if (params.projectLegacyAudit !== false) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: params.serverId, + description: `Removed tool "${tool.toolName}" from MCP server`, + metadata: { toolId: params.toolId, toolName: tool.toolName, workflowId: tool.workflowId }, + }) return { success: true, tool } } catch (error) { diff --git a/apps/sim/lib/posthog/server.ts b/apps/sim/lib/posthog/server.ts index c81349e2e79..18274456081 100644 --- a/apps/sim/lib/posthog/server.ts +++ b/apps/sim/lib/posthog/server.ts @@ -36,6 +36,8 @@ function getClient(): PostHog | null { type PersonProperties = Record<string, string | number | boolean> interface CaptureOptions { + /** Stable event identity used by PostHog to collapse retried server captures. */ + insertId?: string /** * Associate this event with workspace-level group analytics. * Pass `{ workspace: workspaceId }`. @@ -53,6 +55,22 @@ interface CaptureOptions { setOnce?: PersonProperties } +function buildCaptureProperties<E extends PostHogEventName>( + properties: PostHogEventMap[E], + options?: CaptureOptions +): Record<string, unknown> { + const contextRequestId = getRequestContext()?.requestId + const props = properties as Record<string, unknown> + return { + ...properties, + ...(contextRequestId && !('request_id' in props) ? { request_id: contextRequestId } : {}), + ...(options?.insertId ? { $insert_id: options.insertId } : {}), + ...(options?.groups ? { $groups: options.groups } : {}), + ...(options?.set ? { $set: options.set } : {}), + ...(options?.setOnce ? { $set_once: options.setOnce } : {}), + } +} + /** * Capture a server-side PostHog event. Fire-and-forget — never throws. * @@ -71,20 +89,31 @@ export function captureServerEvent<E extends PostHogEventName>( const client = getClient() if (!client) return - const contextRequestId = getRequestContext()?.requestId - const props = properties as Record<string, unknown> client.capture({ distinctId, event, - properties: { - ...properties, - ...(contextRequestId && !('request_id' in props) ? { request_id: contextRequestId } : {}), - ...(options?.groups ? { $groups: options.groups } : {}), - ...(options?.set ? { $set: options.set } : {}), - ...(options?.setOnce ? { $set_once: options.setOnce } : {}), - }, + properties: buildCaptureProperties(properties, options), }) } catch (error) { logger.warn('Failed to capture PostHog server event', { event, error }) } } + +/** Captures and flushes one outbox event before its durable checkpoint advances. */ +export async function deliverOutboxServerEvent<E extends PostHogEventName>( + distinctId: string, + event: E, + properties: PostHogEventMap[E], + options?: CaptureOptions +): Promise<'delivered' | 'skipped'> { + const client = getClient() + if (!client) return 'skipped' + + client.capture({ + distinctId, + event, + properties: buildCaptureProperties(properties, options), + }) + await client.flush() + return 'delivered' +} diff --git a/apps/sim/lib/realtime/notify.ts b/apps/sim/lib/realtime/notify.ts index 58a6c46f4f7..f1d9846919b 100644 --- a/apps/sim/lib/realtime/notify.ts +++ b/apps/sim/lib/realtime/notify.ts @@ -84,6 +84,66 @@ export async function notifyWorkspaceTablesChanged(workspaceId: string): Promise } } +/** Best-effort fan-out that invalidates open editors for one durably changed workflow. */ +export async function notifyWorkflowUpdated(workflowId: string): Promise<void> { + try { + const response = await fetch(`${getSocketServerUrl()}/api/workflow-updated`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, + body: JSON.stringify({ workflowId }), + signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS), + }) + if (!response.ok) { + logger.warn('workflow-updated notify failed', { workflowId, status: response.status }) + } + } catch (error) { + logger.warn('workflow-updated notify error', { + workflowId, + error: getErrorMessage(error), + }) + } +} + +/** Best-effort fan-out that removes one durably archived workflow from open clients. */ +export async function notifyWorkflowDeleted(workflowId: string): Promise<void> { + try { + const response = await fetch(`${getSocketServerUrl()}/api/workflow-deleted`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, + body: JSON.stringify({ workflowId }), + signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS), + }) + if (!response.ok) { + logger.warn('workflow-deleted notify failed', { workflowId, status: response.status }) + } + } catch (error) { + logger.warn('workflow-deleted notify error', { + workflowId, + error: getErrorMessage(error), + }) + } +} + +/** Best-effort fan-out that replaces an open editor after a deployment is loaded into draft. */ +export async function notifyWorkflowReverted(workflowId: string, timestamp: number): Promise<void> { + try { + const response = await fetch(`${getSocketServerUrl()}/api/workflow-reverted`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, + body: JSON.stringify({ workflowId, timestamp }), + signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS), + }) + if (!response.ok) { + logger.warn('workflow-reverted notify failed', { workflowId, status: response.status }) + } + } catch (error) { + logger.warn('workflow-reverted notify error', { + workflowId, + error: getErrorMessage(error), + }) + } +} + /** * Folder resource types whose list is kept live by a workspace invalidation room: a folder mutation * (create/rename/move/delete/restore) for one of these must fan out the same list-changed signal as a diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index 9d0f6009346..dd590edd4f7 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -4,6 +4,10 @@ const ALL_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], delegatedServices: ['copilot'], } as const +const COPILOT_PRINCIPAL_POLICY = { + principalKinds: ['delegated'], + delegatedServices: ['copilot'], +} as const const ALL_TABLE_TOOL_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], @@ -76,6 +80,18 @@ export const tableOperations = { create: writeOperation('tables.create'), update: writeOperation('tables.update'), delete: writeOperation('tables.delete'), + renameByVfsPath: defineWorkspaceOperation({ + id: 'tables.vfs.rename', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_PRINCIPAL_POLICY, + }), + deleteByVfsPath: defineWorkspaceOperation({ + id: 'tables.vfs.delete', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_PRINCIPAL_POLICY, + }), listFolders: readOperation('tables.folders.list'), createFolder: writeOperation('tables.folders.create'), updateFolder: writeOperation('tables.folders.update'), diff --git a/apps/sim/lib/table/application/table-vfs.ts b/apps/sim/lib/table/application/table-vfs.ts new file mode 100644 index 00000000000..cba5c91866c --- /dev/null +++ b/apps/sim/lib/table/application/table-vfs.ts @@ -0,0 +1,91 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { notifyWorkspaceTablesChanged } from '@/lib/realtime/notify' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { resolveTableWorkspaceContext } from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { deleteTable, findActiveTablesByExactName, renameTable } from '@/lib/table/service' +import type { TableDefinition } from '@/lib/table/types' + +interface TableVfsReferenceInput { + workspaceId: string + sourceName: string +} + +export interface RenameTableByVfsPathInput extends TableVfsReferenceInput { + newName: string +} + +export type DeleteTableByVfsPathInput = TableVfsReferenceInput + +async function resolveTableByVfsName( + workspaceId: string, + sourceName: string +): Promise<TableDefinition> { + const matches = await findActiveTablesByExactName(workspaceId, sourceName) + if (matches.length > 1) { + throw new OrchestrationError('conflict', `Table path is ambiguous: tables/${sourceName}`) + } + const table = matches[0] + if (!table) throw new OrchestrationError('not_found', `Table not found at tables/${sourceName}`) + return table +} + +export const renameTableByVfsPath = defineAuthorizedTableUseCase({ + operation: tableOperations.renameByVfsPath, + resolveContext: ({ input }: { input: RenameTableByVfsPathInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ input, context }) { + const table = await resolveTableByVfsName(context.workspaceId, input.sourceName) + const renamed = await renameTable(table.id, input.newName, generateRequestId(), { + expectedWorkspaceId: context.workspaceId, + skipNotify: true, + }) + return { + id: renamed.id, + name: renamed.name, + previousName: table.name, + workspaceId: context.workspaceId, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.id, + resourceName: result.name, + description: `Renamed table to "${result.name}"`, + metadata: { op: 'rename', previousName: result.previousName, source: 'copilot_vfs' }, + }), + afterSuccess: ({ context }) => notifyWorkspaceTablesChanged(context.workspaceId), +}) + +export const deleteTableByVfsPath = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteByVfsPath, + resolveContext: ({ input }: { input: DeleteTableByVfsPathInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ input, context }) { + const table = await resolveTableByVfsName(context.workspaceId, input.sourceName) + const { archived } = await deleteTable(table.id, generateRequestId(), { + expectedWorkspaceId: context.workspaceId, + skipNotify: true, + }) + if (!archived) + throw new OrchestrationError('not_found', `Table not found at tables/${input.sourceName}`) + return { + id: table.id, + name: archived.name, + workspaceId: context.workspaceId, + deleted: true as const, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: result.id, + resourceName: result.name, + description: `Archived table "${result.name}"`, + metadata: { source: 'copilot_vfs' }, + }), + afterSuccess: ({ context }) => notifyWorkspaceTablesChanged(context.workspaceId), +}) diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index e30a4ecb83e..7cb7e35c760 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -338,6 +338,25 @@ export async function listTables( return hydrateTableRows(tables) } +/** Loads at most two active exact-name matches so callers can fail on corrupt ambiguity. */ +export async function findActiveTablesByExactName( + workspaceId: string, + name: string +): Promise<TableDefinition[]> { + const rows = await db + .select(TABLE_ROW_SELECT) + .from(userTableDefinitions) + .where( + and( + eq(userTableDefinitions.workspaceId, workspaceId), + eq(userTableDefinitions.name, name), + isNull(userTableDefinitions.archivedAt) + ) + ) + .limit(2) + return hydrateTableRows(rows) +} + /** * Attaches each table's latest job fields and its order-corrected schema. The * `rowCount` subtracts rows a pending delete has already claimed, so a table @@ -785,7 +804,7 @@ export async function renameTable( tableId: string, newName: string, requestId: string, - options?: { expectedWorkspaceId?: string } + options?: { expectedWorkspaceId?: string; skipNotify?: boolean } ): Promise<{ id: string; name: string }> { const nameValidation = validateTableName(newName) if (!nameValidation.valid) { @@ -819,7 +838,7 @@ export async function renameTable( logger.info(`[${requestId}] Renamed table ${tableId} to "${newName}"`) // Live tables list: a rename changes the list result, so everyone viewing refetches. - if (workspaceId) await notifyWorkspaceTablesChanged(workspaceId) + if (workspaceId && !options?.skipNotify) await notifyWorkspaceTablesChanged(workspaceId) return { id: tableId, name: newName } } catch (error: unknown) { diff --git a/apps/sim/lib/vfs/limits.ts b/apps/sim/lib/vfs/limits.ts new file mode 100644 index 00000000000..931531274b3 --- /dev/null +++ b/apps/sim/lib/vfs/limits.ts @@ -0,0 +1,61 @@ +export const MAX_VFS_PATH_ITEMS = 100 +export const MAX_VFS_PATH_LENGTH = 4096 +export const MAX_VFS_TOTAL_PATH_BYTES = 64 * 1024 +export const MAX_VFS_PATH_SEGMENTS = 64 +export const MAX_VFS_SEGMENT_LENGTH = 255 + +export class VfsPathLimitError extends Error { + constructor(message: string) { + super(message) + this.name = 'VfsPathLimitError' + } +} + +function byteLength(value: string): number { + return new TextEncoder().encode(value).length +} + +export function validateVfsPathSegments(segments: readonly string[]): void { + if (segments.length > MAX_VFS_PATH_SEGMENTS) { + throw new VfsPathLimitError(`VFS paths cannot exceed ${MAX_VFS_PATH_SEGMENTS} segments`) + } + for (const segment of segments) { + if (segment.length === 0 || byteLength(segment) > MAX_VFS_SEGMENT_LENGTH) { + throw new VfsPathLimitError( + `VFS path segments must be between 1 and ${MAX_VFS_SEGMENT_LENGTH} bytes` + ) + } + } +} + +export function validateVfsPathBatch(paths: readonly string[]): void { + if (paths.length > MAX_VFS_PATH_ITEMS) { + throw new VfsPathLimitError(`VFS commands cannot exceed ${MAX_VFS_PATH_ITEMS} paths`) + } + let totalBytes = 0 + for (const path of paths) { + const pathBytes = byteLength(path) + totalBytes += pathBytes + if (pathBytes > MAX_VFS_PATH_LENGTH) { + throw new VfsPathLimitError(`VFS paths cannot exceed ${MAX_VFS_PATH_LENGTH} bytes`) + } + const segments = path + .trim() + .replace(/^\/+|\/+$/g, '') + .split('/') + .filter(Boolean) + .map((segment) => { + try { + return decodeURIComponent(segment) + } catch { + return segment + } + }) + validateVfsPathSegments(segments) + } + if (totalBytes > MAX_VFS_TOTAL_PATH_BYTES) { + throw new VfsPathLimitError( + `VFS command paths cannot exceed ${MAX_VFS_TOTAL_PATH_BYTES} total bytes` + ) + } +} diff --git a/apps/sim/lib/vfs/path.ts b/apps/sim/lib/vfs/path.ts new file mode 100644 index 00000000000..2b8848b2ed3 --- /dev/null +++ b/apps/sim/lib/vfs/path.ts @@ -0,0 +1,57 @@ +const CONTROL_CHARS = /[\x00-\x1f\x7f]/g +const WHITESPACE = /\s+/g + +export class VfsPathError extends Error { + constructor(message: string) { + super(message) + this.name = 'VfsPathError' + } +} + +function normalizeDisplaySegment(segment: string): string { + return segment.normalize('NFC').trim().replace(CONTROL_CHARS, '').replace(WHITESPACE, ' ') +} + +export function encodeVfsSegment(segment: string): string { + const normalized = normalizeDisplaySegment(segment) + if (!normalized || normalized === '.' || normalized === '..') { + throw new VfsPathError('VFS path segment cannot be empty or a dot segment') + } + return encodeURIComponent(normalized) +} + +export function decodeVfsSegment(segment: string): string { + try { + const decoded = decodeURIComponent(segment) + const normalized = normalizeDisplaySegment(decoded) + if (!normalized || normalized === '.' || normalized === '..') { + throw new VfsPathError('VFS path segment cannot be empty or a dot segment') + } + return normalized + } catch (error) { + if (error instanceof VfsPathError) throw error + throw new VfsPathError(`Invalid encoded VFS path segment: ${segment}`) + } +} + +export function decodeVfsSegmentSafe(segment: string): string { + try { + return decodeVfsSegment(segment) + } catch { + return segment + } +} + +export function encodeVfsPathSegments(segments: string[]): string { + return segments.map(encodeVfsSegment).join('/') +} + +export function decodeVfsPathSegments(path: string): string[] { + const trimmed = path.trim().replace(/^\/+|\/+$/g, '') + if (!trimmed) return [] + return trimmed.split('/').map(decodeVfsSegment) +} + +export function canonicalizeVfsPath(path: string): string { + return encodeVfsPathSegments(decodeVfsPathSegments(path)) +} diff --git a/apps/sim/lib/workflows/api/index.ts b/apps/sim/lib/workflows/api/index.ts index ad2a025d322..2d0371da36a 100644 --- a/apps/sim/lib/workflows/api/index.ts +++ b/apps/sim/lib/workflows/api/index.ts @@ -1 +1,6 @@ -export { v2WorkflowErrorPolicies } from '@/lib/workflows/api/route-policies' +export { + createInternalWorkflowErrorPolicy, + internalWorkflowReadAuth, + internalWorkflowSessionOrExecutorAuth, + v2WorkflowErrorPolicies, +} from '@/lib/workflows/api/route-policies' diff --git a/apps/sim/lib/workflows/api/route-policies.test.ts b/apps/sim/lib/workflows/api/route-policies.test.ts index 0955c8082a0..3ca683dd509 100644 --- a/apps/sim/lib/workflows/api/route-policies.test.ts +++ b/apps/sim/lib/workflows/api/route-policies.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { DelegatedWorkspaceAuthorizationError, InsufficientWorkspacePermissionsError, @@ -10,7 +11,21 @@ import { WorkspaceApiKeyAuthorizationError, } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { v2WorkflowErrorPolicies } from '@/lib/workflows/api/route-policies' + +const mocks = vi.hoisted(() => ({ + authenticateApiKey: vi.fn(), + updateLastUsed: vi.fn(), +})) + +vi.mock('@/lib/api-key/service', () => ({ + authenticateApiKeyFromHeader: mocks.authenticateApiKey, + updateApiKeyLastUsed: mocks.updateLastUsed, +})) + +import { + internalWorkflowReadAuth, + v2WorkflowErrorPolicies, +} from '@/lib/workflows/api/route-policies' describe('v2 workflow error policies', () => { it.each([ @@ -58,3 +73,48 @@ describe('v2 workflow error policies', () => { }) }) }) + +describe('internal workflow read auth', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('constructs a workspace principal only from the verified API-key result', async () => { + mocks.authenticateApiKey.mockResolvedValue({ + success: true, + keyId: 'key-1', + keyType: 'workspace', + userId: 'forged-route-user', + workspaceId: 'workspace-1', + }) + + const principal = await internalWorkflowReadAuth.authenticate( + new NextRequest('http://localhost/api/workflows/forged/status', { + headers: { 'x-api-key': 'secret-key' }, + }), + { id: 'forged-workflow' } + ) + + expect(principal).toEqual({ + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }) + expect(mocks.updateLastUsed).toHaveBeenCalledWith('key-1') + }) + + it('fails closed when API-key verification does not return a principal identity', async () => { + mocks.authenticateApiKey.mockResolvedValue({ success: false, error: 'Invalid API key' }) + + await expect( + internalWorkflowReadAuth.authenticate( + new NextRequest('http://localhost/api/workflows/workflow-1/status', { + headers: { 'x-api-key': 'invalid' }, + }), + { id: 'workflow-1' } + ) + ).rejects.toMatchObject({ name: 'InternalUnauthenticatedError' }) + + expect(mocks.updateLastUsed).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/api/route-policies.ts b/apps/sim/lib/workflows/api/route-policies.ts index 9f17e4708d5..bb5e1ec6930 100644 --- a/apps/sim/lib/workflows/api/route-policies.ts +++ b/apps/sim/lib/workflows/api/route-policies.ts @@ -1,8 +1,17 @@ +import type { Principal } from '@sim/auth/principal' import { + createInternalSessionOrExecutorAuth, createV2ResourceConcealmentPolicy, + type InternalAuthPolicy, + type InternalErrorPolicy, + InternalUnauthenticatedError, + internalErrorResponse, type V2ErrorPolicy, v2OrchestrationErrorPolicy, } from '@/lib/api/server/routes' +import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { WORKFLOW_DELEGATION_AUDIENCE } from '@/lib/workflows/application/authorization' import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error' import { v2CaughtOrchestrationError, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' @@ -23,3 +32,53 @@ export const v2WorkflowErrorPolicies = { notFoundMessage: 'Run not found', }), } as const + +export const internalWorkflowSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({ + audience: WORKFLOW_DELEGATION_AUDIENCE, +}) + +export const internalWorkflowReadAuth: InternalAuthPolicy<Principal> = { + async authenticate(request, params) { + const rawApiKey = request.headers.get('x-api-key') + if (!rawApiKey) { + return internalWorkflowSessionOrExecutorAuth.authenticate(request, params) + } + + const result = await authenticateApiKeyFromHeader(rawApiKey) + if (!result.success || !result.keyId || !result.keyType) { + throw new InternalUnauthenticatedError('Unauthorized') + } + await updateApiKeyLastUsed(result.keyId) + + if (result.keyType === 'workspace') { + if (!result.workspaceId) throw new Error('Workspace API key is missing its workspace scope') + return { kind: 'workspace_api_key', workspaceId: result.workspaceId, keyId: result.keyId } + } + if (!result.userId) throw new Error('Personal API key is missing its credential owner') + return { kind: 'personal_api_key', userId: result.userId, keyId: result.keyId } + }, +} + +function legacyWorkflowErrorCode(message: string): string { + return message.toUpperCase().replace(/\s+/g, '_') +} + +export function createInternalWorkflowErrorPolicy(fallback: string): InternalErrorPolicy { + if (!fallback.trim()) throw new Error('Internal workflow error fallback is required') + return { + project(error) { + const classified = asOrchestrationError(error) + if (!classified) return null + return internalErrorResponse(statusForOrchestrationError(classified.code), { + error: classified.message, + code: legacyWorkflowErrorCode(classified.message), + }) + }, + unhandled() { + return internalErrorResponse(500, { + error: fallback, + code: legacyWorkflowErrorCode(fallback), + }) + }, + } +} diff --git a/apps/sim/lib/workflows/application/authorization.ts b/apps/sim/lib/workflows/application/authorization.ts index f8dffb5ba3e..9fc44c899e3 100644 --- a/apps/sim/lib/workflows/application/authorization.ts +++ b/apps/sim/lib/workflows/application/authorization.ts @@ -18,6 +18,19 @@ export const workflowDelegationPolicy: WorkspaceDelegationPolicy<WorkflowAuthori principal: Extract<Principal, { kind: 'delegated' }>, context: WorkflowAuthorizationContext ) { - return principal.workspaceId === context.workspaceId + if (principal.workspaceId !== context.workspaceId) return false + if (principal.serviceId === 'copilot') return true + if (principal.serviceId !== 'executor') return false + const delegationContext = (principal as { delegationContext?: unknown }).delegationContext + return ( + typeof delegationContext === 'object' && + delegationContext !== null && + 'kind' in delegationContext && + delegationContext.kind === 'workflow_execution' && + 'workflowId' in delegationContext && + typeof delegationContext.workflowId === 'string' && + delegationContext.workflowId.length > 0 && + context.workflowId === delegationContext.workflowId + ) }, } diff --git a/apps/sim/lib/workflows/application/chat-deployments.ts b/apps/sim/lib/workflows/application/chat-deployments.ts new file mode 100644 index 00000000000..5c6a653681e --- /dev/null +++ b/apps/sim/lib/workflows/application/chat-deployments.ts @@ -0,0 +1,258 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { + type Principal, + requirePrincipalSubjectUserId, + resolvePrincipalAttribution, + toPrincipalActor, +} from '@sim/auth/principal' +import { chat, db } from '@sim/db' +import { and, eq, isNull } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { performChatDeploy, performChatUndeploy } from '@/lib/workflows/orchestration' +import { + ChatDeployAuthNotAllowedError, + validateChatDeployAuth, +} from '@/ee/access-control/utils/permission-check' + +type ChatAuthType = 'public' | 'password' | 'email' | 'sso' +type ChatOutputConfig = { blockId: string; path: string } +type ChatCustomizations = { + primaryColor?: string + welcomeMessage?: string + imageUrl?: string +} + +export interface DeployWorkflowChatInput { + workflowId: string + assertedWorkspaceId?: string + identifier?: string + title?: string + description?: string + versionDescription: string + versionName: string + customizations?: ChatCustomizations + authType?: ChatAuthType + password?: string | null + allowedEmails?: string[] + outputConfigs?: unknown[] + includeThinking?: boolean + includeToolCalls?: boolean + requestId: string + idempotencyKey?: string +} + +export interface UndeployWorkflowChatInput { + workflowId: string + assertedWorkspaceId?: string +} + +function parseChatOutputConfigs(value: unknown[] | undefined): ChatOutputConfig[] | undefined { + if (value === undefined) return undefined + if ( + !value.every( + (entry): entry is ChatOutputConfig => + typeof entry === 'object' && + entry !== null && + 'blockId' in entry && + typeof entry.blockId === 'string' && + entry.blockId.length > 0 && + 'path' in entry && + typeof entry.path === 'string' + ) + ) { + throw new OrchestrationError('validation', 'Invalid chat output configuration') + } + return value +} + +function resolveWorkflowContext<I extends { workflowId: string; assertedWorkspaceId?: string }>({ + principal, + input, +}: { + principal: Principal + input: I +}) { + return resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }) +} + +export const deployWorkflowChat = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.deployChat, + resolveContext: resolveWorkflowContext<DeployWorkflowChatInput>, + async execute({ principal, input, context }) { + const [existingDeployment] = await db + .select() + .from(chat) + .where(and(eq(chat.workflowId, context.workflowId), isNull(chat.archivedAt))) + .limit(1) + + const identifier = (input.identifier || existingDeployment?.identifier || '').trim() + const title = (input.title || existingDeployment?.title || '').trim() + if (!identifier || !title) { + throw new OrchestrationError('validation', 'Chat identifier and title are required') + } + if (!/^[a-z0-9-]+$/.test(identifier)) { + throw new OrchestrationError( + 'validation', + 'Identifier can only contain lowercase letters, numbers, and hyphens' + ) + } + + const [identifierOwner] = await db + .select({ id: chat.id }) + .from(chat) + .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) + .limit(1) + if (identifierOwner && identifierOwner.id !== existingDeployment?.id) { + throw new OrchestrationError('conflict', 'Identifier already in use') + } + + const existingCustomizations = + (existingDeployment?.customizations as ChatCustomizations | null) ?? {} + const description = input.description ?? existingDeployment?.description ?? '' + const authType = input.authType ?? (existingDeployment?.authType as ChatAuthType) ?? 'public' + const allowedEmails = + input.allowedEmails ?? (existingDeployment?.allowedEmails as string[] | null) ?? [] + const outputConfigs = + parseChatOutputConfigs(input.outputConfigs) ?? + (existingDeployment?.outputConfigs as ChatOutputConfig[] | null) ?? + [] + const includeThinking = input.includeThinking ?? existingDeployment?.includeThinking ?? false + const includeToolCalls = input.includeToolCalls ?? existingDeployment?.includeToolCalls ?? false + const customizations = { + primaryColor: + input.customizations?.primaryColor ?? + existingCustomizations.primaryColor ?? + 'var(--brand-hover)', + welcomeMessage: + input.customizations?.welcomeMessage ?? + existingCustomizations.welcomeMessage ?? + 'Hi there! How can I help you today?', + ...((input.customizations?.imageUrl ?? existingCustomizations.imageUrl) + ? { imageUrl: input.customizations?.imageUrl ?? existingCustomizations.imageUrl } + : {}), + } + + const subjectUserId = requirePrincipalSubjectUserId(principal) + if (authType !== existingDeployment?.authType) { + try { + await validateChatDeployAuth(subjectUserId, context.workspaceId, authType) + } catch (error) { + if (error instanceof ChatDeployAuthNotAllowedError) { + throw new OrchestrationError('forbidden', error.message) + } + throw error + } + } + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await performChatDeploy({ + workflowId: context.workflowId, + userId: attribution.attributedUserId, + actorId: attribution.attributedUserId, + actor: toPrincipalActor(principal), + identifier, + title, + description, + versionDescription: input.versionDescription, + versionName: input.versionName, + customizations, + authType, + password: input.password, + allowedEmails, + outputConfigs, + includeThinking, + includeToolCalls, + workspaceId: context.workspaceId, + requestId: input.requestId, + idempotencyKey: input.idempotencyKey, + projectLegacyAudit: false, + ...(principal.kind === 'delegated' + ? { captureDeploymentAnalytics: false as const, captureLegacyTelemetry: false } + : {}), + }) + if (!result.success || !result.chatId || !result.chatUrl) { + throw new OrchestrationError('validation', result.error ?? 'Failed to deploy chat') + } + return { + ...result, + chatId: result.chatId, + chatUrl: result.chatUrl, + workflowId: context.workflowId, + identifier, + title, + description, + authType, + allowedEmails, + outputConfigs, + includeThinking, + includeToolCalls, + customizations, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.CHAT_DEPLOYED, + resourceType: AuditResourceType.CHAT, + resourceId: result.chatId, + resourceName: result.title, + description: `Deployed chat "${result.title}"`, + metadata: { + workflowId: result.workflowId, + identifier: result.identifier, + authType: result.authType, + chatUrl: result.chatUrl, + isUpdate: result.isUpdate, + hasOutputConfigs: result.outputConfigs.length > 0, + hasCustomizations: Object.keys(result.customizations).length > 0, + }, + }), +}) + +export const undeployWorkflowChat = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.undeployChat, + resolveContext: resolveWorkflowContext<UndeployWorkflowChatInput>, + async execute({ principal, context }) { + const [deployment] = await db + .select() + .from(chat) + .where(and(eq(chat.workflowId, context.workflowId), isNull(chat.archivedAt))) + .limit(1) + if (!deployment) { + throw new OrchestrationError('not_found', 'No active chat deployment found for this workflow') + } + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await performChatUndeploy({ + chatId: deployment.id, + userId: attribution.attributedUserId, + workspaceId: context.workspaceId, + projectLegacyAudit: false, + }) + if (!result.success) { + throw new OrchestrationError('not_found', result.error ?? 'Failed to undeploy chat') + } + return { workflowId: context.workflowId, deployment } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.CHAT_DELETED, + resourceType: AuditResourceType.CHAT, + resourceId: result.deployment.id, + resourceName: result.deployment.title || result.deployment.id, + description: `Deleted chat deployment "${result.deployment.title || result.deployment.id}"`, + metadata: { + workflowId: result.workflowId, + identifier: result.deployment.identifier || undefined, + authType: result.deployment.authType || undefined, + }, + }), +}) diff --git a/apps/sim/lib/workflows/application/context.test.ts b/apps/sim/lib/workflows/application/context.test.ts index 5c8f59b0e0c..d30b4afa24a 100644 --- a/apps/sim/lib/workflows/application/context.test.ts +++ b/apps/sim/lib/workflows/application/context.test.ts @@ -1,18 +1,23 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ loadWorkspace: vi.fn() })) +const mocks = vi.hoisted(() => ({ + getJob: vi.fn(), + getJobQueue: vi.fn(), + loadWorkspace: vi.fn(), +})) -vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: vi.fn() })) +vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: mocks.getJobQueue })) vi.mock('@/lib/workspaces/application/workspace-context', () => ({ loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, })) import { resolveActiveWorkflowApplicationContext, + resolveActiveWorkflowRunApplicationContext, resolveActiveWorkspaceApplicationContext, } from '@/lib/workflows/application/context' @@ -24,11 +29,18 @@ const workspace = { } const workflow = { id: 'workflow-1', workspaceId: 'workspace-1', archivedAt: null } +function queueCanonicalBindings(input: { log?: string; paused?: string; resumed?: string }): void { + queueTableRows(schemaMock.workflowExecutionLogs, input.log ? [{ workflowId: input.log }] : []) + queueTableRows(schemaMock.pausedExecutions, input.paused ? [{ workflowId: input.paused }] : []) + queueTableRows(schemaMock.resumeQueue, input.resumed ? [{ workflowId: input.resumed }] : []) +} + describe('workflow application contexts', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.getJobQueue.mockResolvedValue({ getJob: mocks.getJob }) }) it('uses the canonical loader for workspace-scoped operations', async () => { @@ -86,4 +98,47 @@ describe('workflow application contexts', () => { resolveActiveWorkflowApplicationContext({ workflowId: 'workflow-1' }) ).rejects.toBe(failure) }) + + it('fails hard when durable stores disagree about the canonical workflow binding', async () => { + queueCanonicalBindings({ log: 'workflow-1', paused: 'workflow-2' }) + + await expect(resolveActiveWorkflowRunApplicationContext({ runId: 'run-1' })).rejects.toThrow( + 'Run run-1 has conflicting canonical workflow bindings' + ) + expect(mocks.getJobQueue).not.toHaveBeenCalled() + }) + + it('conceals a caller-asserted workflow that conflicts with the canonical binding', async () => { + queueCanonicalBindings({ log: 'workflow-1' }) + + await expect( + resolveActiveWorkflowRunApplicationContext({ + runId: 'run-1', + assertedWorkflowId: 'workflow-forged', + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Run not found' }) + }) + + it('accepts matching durable bindings and resolves the active canonical workflow', async () => { + queueCanonicalBindings({ log: 'workflow-1', paused: 'workflow-1', resumed: 'workflow-1' }) + queueTableRows(schemaMock.workflow, [ + { + workflowId: 'workflow-1', + workflow: { id: 'workflow-1', name: 'Canonical workflow' }, + workspaceId: 'workspace-1', + }, + ]) + + await expect( + resolveActiveWorkflowRunApplicationContext({ + runId: 'run-1', + assertedWorkflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + }) + ).resolves.toMatchObject({ + runId: 'run-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + }) + }) }) diff --git a/apps/sim/lib/workflows/application/create-workflow.ts b/apps/sim/lib/workflows/application/create-workflow.ts index eeac83f1b40..f1123885e2e 100644 --- a/apps/sim/lib/workflows/application/create-workflow.ts +++ b/apps/sim/lib/workflows/application/create-workflow.ts @@ -3,6 +3,9 @@ import { resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { PlatformEvents } from '@/lib/core/telemetry' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { notifyWorkflowUpdated } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -12,6 +15,7 @@ import { workflowFolderPathForId, } from '@/lib/workflows/application/workflow-folders' import { performCreateWorkflowTransition } from '@/lib/workflows/orchestration' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' const logger = createLogger('CreateWorkflow') @@ -20,6 +24,7 @@ export interface CreateWorkflowInput { name: string description?: string | null folderPath?: string + folderId?: string | null } export const createWorkflow = defineAuthorizedWorkflowUseCase({ @@ -27,7 +32,19 @@ export const createWorkflow = defineAuthorizedWorkflowUseCase({ resolveContext: ({ input }: { input: CreateWorkflowInput }) => resolveActiveWorkspaceApplicationContext(input.workspaceId), async execute({ principal, input, context }) { - const resolution = await resolveWorkflowFolderPath(context.workspaceId, input.folderPath ?? '/') + if (input.folderPath !== undefined && input.folderId !== undefined) { + throw new OrchestrationError('validation', 'Provide either folderPath or folderId, not both') + } + const resolution = + input.folderId === undefined + ? await resolveWorkflowFolderPath(context.workspaceId, input.folderPath ?? '/') + : { + folderId: input.folderId, + index: await loadActiveFolderPathIndex(context.workspaceId, 'workflow'), + } + if (resolution.folderId && !resolution.index.pathById.has(resolution.folderId)) { + throw new OrchestrationError('not_found', 'Folder not found') + } try { await assertFolderMutable(resolution.folderId) } catch (error) { @@ -49,6 +66,8 @@ export const createWorkflow = defineAuthorizedWorkflowUseCase({ }) requireWorkflowTransition(transition, 'Failed to create workflow') if (!transition.workflow) throw new Error('Successful workflow create returned no workflow') + const normalizedState = await loadWorkflowFromNormalizedTables(transition.workflow.id) + if (!normalizedState) throw new Error('Successful workflow create returned no workflow state') logger.info('Created workflow', { workspaceId: context.workspaceId, @@ -58,6 +77,7 @@ export const createWorkflow = defineAuthorizedWorkflowUseCase({ return { workflow: transition.workflow, folderPath: workflowFolderPathForId(resolution.index, transition.workflow.folderId), + normalizedState, } }, projectAudit: ({ result }) => ({ @@ -74,4 +94,20 @@ export const createWorkflow = defineAuthorizedWorkflowUseCase({ sortOrder: result.workflow.sortOrder, }, }), + async afterSuccess({ result }) { + await notifyWorkflowUpdated(result.workflow.id) + try { + PlatformEvents.workflowCreated({ + workflowId: result.workflow.id, + name: result.workflow.name, + workspaceId: result.workflow.workspaceId, + folderId: result.workflow.folderId ?? undefined, + }) + } catch (error) { + logger.warn('Failed to capture workflow created telemetry', { + workflowId: result.workflow.id, + error, + }) + } + }, }) diff --git a/apps/sim/lib/workflows/application/delete-workflow.ts b/apps/sim/lib/workflows/application/delete-workflow.ts index f2a41746cc4..cfa01b44119 100644 --- a/apps/sim/lib/workflows/application/delete-workflow.ts +++ b/apps/sim/lib/workflows/application/delete-workflow.ts @@ -3,6 +3,7 @@ import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal import { createLogger } from '@sim/logger' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyWorkflowDeleted } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -39,6 +40,7 @@ export const deleteWorkflow = defineAuthorizedWorkflowUseCase({ userId: resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }).attributedUserId, + notifySocket: false, }) requireWorkflowTransition(transition, 'Failed to delete workflow') if (!transition.workflow) throw new Error('Successful workflow delete returned no workflow') @@ -52,6 +54,7 @@ export const deleteWorkflow = defineAuthorizedWorkflowUseCase({ return { workflowId: context.workflowId, workflowName: transition.workflow.name, + workspaceId: context.workspaceId, archived: transition.archived === true, } }, @@ -66,4 +69,6 @@ export const deleteWorkflow = defineAuthorizedWorkflowUseCase({ metadata: { archived: true }, } : [], + afterSuccess: ({ context, result }) => + result.archived ? notifyWorkflowDeleted(context.workflowId) : undefined, }) diff --git a/apps/sim/lib/workflows/application/deployments.ts b/apps/sim/lib/workflows/application/deployments.ts index bffda5ba2b7..4e57d017821 100644 --- a/apps/sim/lib/workflows/application/deployments.ts +++ b/apps/sim/lib/workflows/application/deployments.ts @@ -1,19 +1,33 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { resolvePrincipalAttribution, toPrincipalActor } from '@sim/auth/principal' +import { + type Principal, + requirePrincipalSubjectUserId, + resolvePrincipalAttribution, + toPrincipalActor, +} from '@sim/auth/principal' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { notifyWorkflowReverted } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' import { + getWorkflowDeploymentSummary, performActivateVersion, performFullDeploy, performFullUndeploy, + performRevertToVersion, } from '@/lib/workflows/orchestration' -import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' +import { + findPreviousDeploymentVersion, + updateDeploymentVersionMetadata, +} from '@/lib/workflows/persistence/utils' export interface DeployWorkflowInput { workflowId: string + assertedWorkspaceId?: string name?: string description?: string requestId: string @@ -22,15 +36,51 @@ export interface DeployWorkflowInput { export interface UndeployWorkflowInput { workflowId: string + assertedWorkspaceId?: string requestId: string } export interface ActivateWorkflowVersionInput { workflowId: string + assertedWorkspaceId?: string version?: number transition: 'activate' | 'rollback' requestId: string idempotencyKey?: string + name?: string | null + description?: string | null +} + +export interface ReadWorkflowDeploymentStatusInput { + workflowId: string + assertedWorkspaceId?: string +} + +export interface RevertWorkflowVersionInput { + workflowId: string + assertedWorkspaceId?: string + version: number | 'active' +} + +export interface UpdateWorkflowVersionInput { + workflowId: string + assertedWorkspaceId?: string + version: number + name?: string | null + description?: string | null +} + +function resolveWorkflowContext<I extends { workflowId: string; assertedWorkspaceId?: string }>({ + principal, + input, +}: { + principal: Principal + input: I +}) { + return resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }) } function throwDeploymentFailure( @@ -56,8 +106,7 @@ async function requireMutableWorkflow(workflowId: string): Promise<void> { export const deployWorkflow = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.deploy, - resolveContext: ({ input }: { input: DeployWorkflowInput }) => - resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), + resolveContext: resolveWorkflowContext<DeployWorkflowInput>, async execute({ principal, input, context }) { await requireMutableWorkflow(context.workflowId) const attribution = resolvePrincipalAttribution(principal, { @@ -68,7 +117,7 @@ export const deployWorkflow = defineAuthorizedWorkflowUseCase({ userId: attribution.attributedUserId, actorId: attribution.attributedUserId, actor: toPrincipalActor(principal), - captureAnalytics: false, + ...(principal.kind === 'delegated' ? { captureAnalytics: false as const } : {}), versionName: input.name, versionDescription: input.description, requestId: input.requestId, @@ -85,8 +134,7 @@ export const deployWorkflow = defineAuthorizedWorkflowUseCase({ export const undeployWorkflow = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.undeploy, - resolveContext: ({ input }: { input: UndeployWorkflowInput }) => - resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), + resolveContext: resolveWorkflowContext<UndeployWorkflowInput>, async execute({ principal, input, context }) { if (!context.workflow.isDeployed) { throw new OrchestrationError('validation', 'Workflow is not deployed') @@ -121,8 +169,7 @@ export const undeployWorkflow = defineAuthorizedWorkflowUseCase({ export const activateWorkflowVersion = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.activateVersion, - resolveContext: ({ input }: { input: ActivateWorkflowVersionInput }) => - resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), + resolveContext: resolveWorkflowContext<ActivateWorkflowVersionInput>, async execute({ principal, input, context }) { if (input.transition === 'rollback' && !context.workflow.isDeployed) { throw new OrchestrationError('validation', 'Workflow is not deployed') @@ -155,9 +202,11 @@ export const activateWorkflowVersion = defineAuthorizedWorkflowUseCase({ userId: attribution.attributedUserId, actorId: attribution.attributedUserId, actor: toPrincipalActor(principal), - captureAnalytics: false, + ...(principal.kind === 'delegated' ? { captureAnalytics: false as const } : {}), requestId: input.requestId, idempotencyKey: input.idempotencyKey, + name: input.name, + description: input.description, }) if (!result.success) throwDeploymentFailure(result, 'Failed to activate workflow version') return { @@ -168,3 +217,78 @@ export const activateWorkflowVersion = defineAuthorizedWorkflowUseCase({ } }, }) + +export const readWorkflowDeploymentStatus = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.read, + resolveContext: resolveWorkflowContext<ReadWorkflowDeploymentStatusInput>, + async execute({ context }) { + const deploymentSummary = await getWorkflowDeploymentSummary(context.workflowId) + const isDeployed = deploymentSummary.activeDeployment !== null + const attemptStatus = deploymentSummary.latestDeploymentAttempt?.status + const needsRedeployment = + isDeployed && attemptStatus !== 'preparing' && attemptStatus !== 'activating' + ? await checkNeedsRedeployment(context.workflowId) + : false + return { + workflow: context.workflow, + workspaceId: context.workspaceId, + isDeployed, + needsRedeployment, + ...deploymentSummary, + } + }, +}) + +export const revertWorkflowVersion = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.revertVersion, + resolveContext: resolveWorkflowContext<RevertWorkflowVersionInput>, + async execute({ principal, input, context }) { + const userId = requirePrincipalSubjectUserId(principal) + await requireMutableWorkflow(context.workflowId) + const result = await performRevertToVersion({ + workflowId: context.workflowId, + version: input.version, + userId, + actorId: userId, + workflow: context.workflow, + captureAnalytics: false, + projectLegacyAudit: false, + notifyRealtime: false, + }) + if (!result.success) throwDeploymentFailure(result, 'Failed to revert workflow version') + if (result.lastSaved === undefined) { + throw new Error('Successful workflow version revert returned no save timestamp') + } + return { + workflowId: context.workflowId, + workflowName: context.workflow.name, + workspaceId: context.workspaceId, + version: input.version, + lastSaved: result.lastSaved, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.WORKFLOW_DEPLOYMENT_REVERTED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.workflowId, + resourceName: result.workflowName, + description: `Reverted workflow to deployment version ${String(result.version)}`, + metadata: { targetVersion: String(result.version) }, + }), + afterSuccess: ({ result }) => notifyWorkflowReverted(result.workflowId, result.lastSaved), +}) + +export const updateWorkflowVersion = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.updateVersion, + resolveContext: resolveWorkflowContext<UpdateWorkflowVersionInput>, + async execute({ input, context }) { + const updated = await updateDeploymentVersionMetadata({ + workflowId: context.workflowId, + version: input.version, + name: input.name, + description: input.description, + }) + if (!updated) throw new OrchestrationError('not_found', 'Deployment version not found') + return { workflowId: context.workflowId, version: input.version, ...updated } + }, +}) diff --git a/apps/sim/lib/workflows/application/duplicate-workflow.ts b/apps/sim/lib/workflows/application/duplicate-workflow.ts new file mode 100644 index 00000000000..fd6f01cf884 --- /dev/null +++ b/apps/sim/lib/workflows/application/duplicate-workflow.ts @@ -0,0 +1,51 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { db } from '@sim/db' +import { generateRequestId } from '@/lib/core/utils/request' +import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { duplicateWorkflow as duplicateWorkflowRecord } from '@/lib/workflows/persistence/duplicate' + +export interface DuplicateWorkflowInput { + sourceWorkflowId: string + assertedWorkspaceId?: string + folderId: string | null + name: string +} + +export const duplicateWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.duplicate, + resolveContext: ({ principal, input }: { principal: Principal; input: DuplicateWorkflowInput }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.sourceWorkflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + return db.transaction((tx) => + duplicateWorkflowRecord({ + sourceWorkflowId: context.workflowId, + userId: attribution.attributedUserId, + workspaceId: context.workspaceId, + folderId: input.folderId, + name: input.name, + requestId: generateRequestId(), + tx, + }) + ) + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.WORKFLOW_DUPLICATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.id, + resourceName: result.name, + description: `Duplicated workflow "${context.workflow.name}" as "${result.name}"`, + metadata: { sourceWorkflowId: context.workflowId, workspaceId: context.workspaceId }, + }), + afterSuccess: ({ result }) => notifyWorkflowUpdated(result.id), +}) diff --git a/apps/sim/lib/workflows/application/list-workflow-versions.ts b/apps/sim/lib/workflows/application/list-workflow-versions.ts index 86748c0c090..9c65a7ca53a 100644 --- a/apps/sim/lib/workflows/application/list-workflow-versions.ts +++ b/apps/sim/lib/workflows/application/list-workflow-versions.ts @@ -1,12 +1,16 @@ import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { isDeploymentOperationStatus } from '@/lib/workflows/deployment-lifecycle' import { listWorkflowVersions as listStoredWorkflowVersions } from '@/lib/workflows/persistence/utils' const logger = createLogger('ListWorkflowVersions') +const MAX_WORKFLOW_VERSION_PAGE_SIZE = 100 +const MAX_UNPAGINATED_WORKFLOW_VERSIONS = 1000 export interface ListWorkflowVersionsInput { workflowId: string @@ -29,12 +33,35 @@ export const listWorkflowVersions = defineAuthorizedWorkflowUseCase({ assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), }), async execute({ principal, input, context }) { + if ( + input.limit !== undefined && + (!Number.isInteger(input.limit) || + input.limit < 1 || + input.limit > MAX_WORKFLOW_VERSION_PAGE_SIZE) + ) { + throw new OrchestrationError( + 'validation', + `Workflow version page size must be between 1 and ${MAX_WORKFLOW_VERSION_PAGE_SIZE}` + ) + } + const resultLimit = input.limit ?? MAX_UNPAGINATED_WORKFLOW_VERSIONS const { versions } = await listStoredWorkflowVersions(context.workflowId, { - limit: input.limit === undefined ? undefined : input.limit + 1, + limit: resultLimit + 1, afterVersion: input.afterVersion, }) - const hasMore = input.limit !== undefined && versions.length > input.limit - const page = input.limit === undefined ? versions : versions.slice(0, input.limit) + if (input.limit === undefined && versions.length > MAX_UNPAGINATED_WORKFLOW_VERSIONS) { + throw new Error( + `Workflow version list exceeds the ${MAX_UNPAGINATED_WORKFLOW_VERSIONS} row limit` + ) + } + const hasMore = input.limit !== undefined && versions.length > resultLimit + const page = versions.slice(0, resultLimit).map((version) => { + const latestOperationStatus = version.latestOperationStatus + if (latestOperationStatus !== null && !isDeploymentOperationStatus(latestOperationStatus)) { + throw new Error('Deployment version contains an invalid operation status') + } + return { ...version, latestOperationStatus } + }) logger.info('Listed workflow versions', { workspaceId: context.workspaceId, workflowId: context.workflowId, diff --git a/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts b/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts new file mode 100644 index 00000000000..d2a83f8eb61 --- /dev/null +++ b/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts @@ -0,0 +1,154 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { FolderLockedError, WorkflowLockedError, mocks } = vi.hoisted(() => { + class WorkflowLockedError extends Error {} + class FolderLockedError extends Error {} + return { + WorkflowLockedError, + FolderLockedError, + mocks: { + assertFolderMutable: vi.fn(), + assertWorkflowMutable: vi.fn(), + audit: vi.fn(), + notify: vi.fn(), + permission: vi.fn(), + resolveContext: vi.fn(), + updateWorkflow: vi.fn(), + }, + } +}) + +vi.mock('@sim/audit', () => ({ + AuditAction: { WORKFLOW_UPDATED: 'workflow.updated' }, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.audit, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mocks.assertFolderMutable, + assertWorkflowMutable: mocks.assertWorkflowMutable, + FolderLockedError, + WorkflowLockedError, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => actual === required, + resolveEffectiveWorkspacePermission: mocks.permission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.resolveContext, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + updateWorkflowRecord: mocks.updateWorkflow, +})) + +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) + +import { moveWorkflowsBulk } from '@/lib/workflows/application/move-workflows-bulk' + +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), +} + +describe('moveWorkflowsBulk', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.resolveContext.mockResolvedValue(context) + mocks.permission.mockResolvedValue('write') + mocks.assertFolderMutable.mockResolvedValue(undefined) + mocks.assertWorkflowMutable.mockResolvedValue(undefined) + }) + + it('returns bounded best-effort outcomes and audits only authoritative moves', async () => { + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'One', folderId: null }, + { id: 'workflow-2', name: 'Two', folderId: null }, + ]) + dbChainMockFns.for + .mockResolvedValueOnce([{ id: 'workflow-1', name: 'One', folderId: null }]) + .mockResolvedValueOnce([{ id: 'workflow-2', name: 'Two', folderId: null }]) + mocks.updateWorkflow + .mockResolvedValueOnce({ + success: true, + workflow: { id: 'workflow-1', name: 'One', folderId: 'folder-1' }, + }) + .mockResolvedValueOnce({ success: false, error: 'Workflow is locked', errorCode: 'locked' }) + + const result = await moveWorkflowsBulk.execute({ + principal, + input: { + workspaceId: 'workspace-1', + workflowIds: ['workflow-1', 'workflow-2', 'workflow-1'], + folderId: 'folder-1', + }, + }) + + expect(result).toMatchObject({ + moved: ['workflow-1'], + failed: ['workflow-2'], + folderId: 'folder-1', + }) + expect(mocks.audit).toHaveBeenCalledOnce() + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'workflow.updated', + resourceId: 'workflow-1', + metadata: expect.objectContaining({ operation: 'workflows.bulk.move' }), + }) + ) + expect(mocks.notify).toHaveBeenCalledWith('workflow-1') + expect(mocks.notify).not.toHaveBeenCalledWith('workflow-2') + }) + + it('conceals cross-workspace workflow IDs as failed items', async () => { + queueTableRows(schemaMock.workflow, []) + + await expect( + moveWorkflowsBulk.execute({ + principal, + input: { + workspaceId: 'workspace-1', + workflowIds: ['workflow-from-workspace-2'], + folderId: null, + }, + }) + ).resolves.toMatchObject({ + moved: [], + failed: ['workflow-from-workspace-2'], + }) + + expect(mocks.updateWorkflow).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('rejects a non-Copilot principal before canonical workspace loading', async () => { + await expect( + moveWorkflowsBulk.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'workspace-1', workflowIds: ['workflow-1'], folderId: null }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveContext).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/move-workflows-bulk.ts b/apps/sim/lib/workflows/application/move-workflows-bulk.ts new file mode 100644 index 00000000000..62c6acc0e5c --- /dev/null +++ b/apps/sim/lib/workflows/application/move-workflows-bulk.ts @@ -0,0 +1,166 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { + assertFolderMutable, + assertWorkflowMutable, + FolderLockedError, + WorkflowLockedError, +} from '@sim/platform-authz/workflow' +import { and, eq, inArray, isNull } from 'drizzle-orm' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { requireWorkflowTransition } from '@/lib/workflows/application/transition-result' +import { updateWorkflowRecord } from '@/lib/workflows/orchestration' + +const MAX_BULK_WORKFLOW_MOVES = 100 + +export interface MoveWorkflowsBulkInput { + workspaceId: string + workflowIds: string[] + folderId: string | null +} + +interface MovedWorkflow { + id: string + name: string + previousFolderId: string | null +} + +export interface MoveWorkflowsBulkResult { + moved: string[] + failed: string[] + folderId: string | null + changes: MovedWorkflow[] +} + +function normalizeWorkflowIds(workflowIds: readonly string[]): string[] { + const normalized = [...new Set(workflowIds.filter((id) => id.length > 0))] + if (normalized.length === 0) { + throw new OrchestrationError('validation', 'workflowIds is required') + } + if (normalized.length > MAX_BULK_WORKFLOW_MOVES) { + throw new OrchestrationError( + 'validation', + `Workflow moves cannot exceed ${MAX_BULK_WORKFLOW_MOVES} items` + ) + } + return normalized +} + +function requireMutable(workflowId: string, folderId: string | null): Promise<void> { + return Promise.all([assertWorkflowMutable(workflowId), assertFolderMutable(folderId)]) + .then(() => undefined) + .catch((error: unknown) => { + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + }) +} + +export const moveWorkflowsBulk = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.moveBulk, + resolveContext: ({ input }: { input: MoveWorkflowsBulkInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }): Promise<MoveWorkflowsBulkResult> { + const workflowIds = normalizeWorkflowIds(input.workflowIds) + const rows = await db + .select({ + id: workflow.id, + name: workflow.name, + folderId: workflow.folderId, + }) + .from(workflow) + .where( + and( + inArray(workflow.id, workflowIds), + eq(workflow.workspaceId, context.workspaceId), + isNull(workflow.archivedAt) + ) + ) + const byId = new Map(rows.map((row) => [row.id, row])) + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const moved: string[] = [] + const failed: string[] = [] + const changes: MovedWorkflow[] = [] + + for (const workflowId of workflowIds) { + const indexed = byId.get(workflowId) + if (!indexed) { + failed.push(workflowId) + continue + } + + try { + await requireMutable(workflowId, input.folderId) + const changed = await db.transaction(async (tx) => { + const [current] = await tx + .select({ + id: workflow.id, + name: workflow.name, + folderId: workflow.folderId, + }) + .from(workflow) + .where( + and( + eq(workflow.id, workflowId), + eq(workflow.workspaceId, context.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .limit(1) + .for('update') + if (!current) throw new OrchestrationError('not_found', 'Workflow not found') + + const transition = await updateWorkflowRecord({ + workflowId, + userId: attribution.attributedUserId, + workspaceId: context.workspaceId, + currentName: current.name, + currentFolderId: current.folderId, + folderId: input.folderId, + tx, + }) + requireWorkflowTransition(transition, 'Failed to move workflow') + return current + }) + moved.push(workflowId) + changes.push({ + id: workflowId, + name: changed.name, + previousFolderId: changed.folderId, + }) + } catch (error) { + const classified = asOrchestrationError(error) + if (!classified || classified.code === 'internal') throw error + failed.push(workflowId) + } + } + + return { moved, failed, folderId: input.folderId, changes } + }, + projectAudit: ({ result }) => + result.changes.map((change) => ({ + action: AuditAction.WORKFLOW_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: change.id, + resourceName: change.name, + description: `Moved workflow "${change.name}"`, + metadata: { + previousFolderId: change.previousFolderId, + folderId: result.folderId, + }, + })), + afterSuccess: async ({ result }) => { + for (const workflowId of result.moved) { + await notifyWorkflowUpdated(workflowId) + } + }, +}) diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index 8150e81a21d..5cfae2226f7 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -5,11 +5,21 @@ const ALL_WORKFLOW_PRINCIPAL_POLICY = { delegatedServices: ['copilot'], } as const +const WORKFLOW_READ_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot', 'executor'], +} as const + const HUMAN_WORKFLOW_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'delegated'], delegatedServices: ['copilot'], } as const +const COPILOT_WORKFLOW_PRINCIPAL_POLICY = { + principalKinds: ['delegated'], + delegatedServices: ['copilot'], +} as const + export const workflowOperations = { list: defineWorkspaceOperation({ id: 'workflows.list', @@ -21,7 +31,31 @@ export const workflowOperations = { id: 'workflows.read', minimumRole: 'read', workspaceApiKey: 'allow', - ...ALL_WORKFLOW_PRINCIPAL_POLICY, + ...WORKFLOW_READ_PRINCIPAL_POLICY, + }), + readDeploymentOverview: defineWorkspaceOperation({ + id: 'workflows.deployment_overview.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + readCopilotRunOptions: defineWorkspaceOperation({ + id: 'workflows.copilot.run_options.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + readCopilotBlockOutputs: defineWorkspaceOperation({ + id: 'workflows.copilot.block_outputs.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + readCopilotUpstreamReferences: defineWorkspaceOperation({ + id: 'workflows.copilot.upstream_references.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), create: defineWorkspaceOperation({ id: 'workflows.create', @@ -35,6 +69,85 @@ export const workflowOperations = { workspaceApiKey: 'allow', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + updatePolicy: defineWorkspaceOperation({ + id: 'workflows.policy.update', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + applyVariableOperations: defineWorkspaceOperation({ + id: 'workflows.variables.apply_operations', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + setBlockEnabled: defineWorkspaceOperation({ + id: 'workflows.blocks.set_enabled', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + moveBulk: defineWorkspaceOperation({ + id: 'workflows.bulk.move', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + createVfsFolders: defineWorkspaceOperation({ + id: 'workflows.vfs.folders.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + moveVfsItems: defineWorkspaceOperation({ + id: 'workflows.vfs.move', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + copyVfsItems: defineWorkspaceOperation({ + id: 'workflows.vfs.copy', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + deleteVfsItems: defineWorkspaceOperation({ + id: 'workflows.vfs.delete', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + duplicate: defineWorkspaceOperation({ + id: 'workflows.duplicate', + minimumRole: 'write', + workspaceApiKey: 'allow', + ...ALL_WORKFLOW_PRINCIPAL_POLICY, + }), + runFromCopilot: defineWorkspaceOperation({ + id: 'workflows.copilot.run', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + runUntilFromCopilot: defineWorkspaceOperation({ + id: 'workflows.copilot.run_until', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + runFromBlockFromCopilot: defineWorkspaceOperation({ + id: 'workflows.copilot.run_from_block', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + runBlockFromCopilot: defineWorkspaceOperation({ + id: 'workflows.copilot.run_block', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), delete: defineWorkspaceOperation({ id: 'workflows.delete', minimumRole: 'write', @@ -77,12 +190,42 @@ export const workflowOperations = { workspaceApiKey: 'deny', ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), + deployChat: defineWorkspaceOperation({ + id: 'workflows.chat.deploy', + minimumRole: 'admin', + workspaceApiKey: 'deny', + ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, + }), + undeployChat: defineWorkspaceOperation({ + id: 'workflows.chat.undeploy', + minimumRole: 'admin', + workspaceApiKey: 'deny', + ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, + }), + updatePublicApi: defineWorkspaceOperation({ + id: 'workflows.public_api.update', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), activateVersion: defineWorkspaceOperation({ id: 'workflows.versions.activate', minimumRole: 'admin', workspaceApiKey: 'deny', ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), + revertVersion: defineWorkspaceOperation({ + id: 'workflows.versions.revert', + minimumRole: 'admin', + workspaceApiKey: 'deny', + ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, + }), + updateVersion: defineWorkspaceOperation({ + id: 'workflows.versions.update', + minimumRole: 'write', + workspaceApiKey: 'allow', + ...ALL_WORKFLOW_PRINCIPAL_POLICY, + }), listVersions: defineWorkspaceOperation({ id: 'workflows.versions.list', minimumRole: 'read', @@ -95,6 +238,12 @@ export const workflowOperations = { workspaceApiKey: 'allow', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + compareReferences: defineWorkspaceOperation({ + id: 'workflows.versions.compare_references', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), export: defineWorkspaceOperation({ id: 'workflows.export', minimumRole: 'read', diff --git a/apps/sim/lib/workflows/application/principal-scope.ts b/apps/sim/lib/workflows/application/principal-scope.ts index d5e70577ee9..807bd095e39 100644 --- a/apps/sim/lib/workflows/application/principal-scope.ts +++ b/apps/sim/lib/workflows/application/principal-scope.ts @@ -4,8 +4,8 @@ export function assertedWorkflowWorkspaceId( principal: Principal, assertedWorkspaceId?: string ): string | undefined { - return ( - assertedWorkspaceId ?? - (principal.kind === 'workspace_api_key' ? principal.workspaceId : undefined) - ) + if (principal.kind === 'workspace_api_key' || principal.kind === 'delegated') { + return principal.workspaceId + } + return assertedWorkspaceId } diff --git a/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.test.ts b/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.test.ts new file mode 100644 index 00000000000..3510e4ddb4d --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.test.ts @@ -0,0 +1,160 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + loadDraft: vi.fn(), + getBlock: vi.fn(), + outputPaths: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveContext, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadWorkflowFromNormalizedTables: mocks.loadDraft, +})) + +vi.mock('@/blocks/registry', () => ({ getBlock: mocks.getBlock })) + +vi.mock('@/lib/workflows/blocks/block-outputs', () => ({ + getEffectiveBlockOutputPaths: mocks.outputPaths, +})) + +vi.mock('@/lib/workflows/blocks/block-path-calculator', () => ({ + BlockPathCalculator: { findAllPathNodes: vi.fn().mockReturnValue([]) }, +})) + +vi.mock('@/lib/workflows/blocks/block-reference-tags', () => ({ + getBlockReferenceTags: vi.fn().mockReturnValue([]), +})) + +vi.mock('@/lib/workflows/triggers/run-options', () => ({ + resolveTriggerRunOptions: vi.fn().mockReturnValue([]), + toPublicRunOption: vi.fn((value) => value), +})) + +vi.mock('@/lib/workflows/triggers/trigger-utils', () => ({ + hasTriggerCapability: vi.fn().mockReturnValue(false), +})) + +import { readCopilotWorkflowBlockOutputs } from '@/lib/workflows/application/read-workflow-copilot-metadata' + +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-08-01T00:00:00Z'), + expiresAt: new Date('2999-08-01T00:00:00Z'), +} + +describe('Copilot workflow metadata application queries', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue({ + workflowId: 'workflow-1', + workflow: { + id: 'workflow-1', + workspaceId: 'workspace-1', + variables: { + variable1: { id: 'variable-1', name: 'Customer Name', type: 'plain' }, + }, + }, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.resolvePermission.mockResolvedValue('read') + mocks.loadDraft.mockResolvedValue({ + blocks: { + 'agent-1': { type: 'agent', name: 'Support Agent', subBlocks: {} }, + }, + edges: [], + loops: {}, + parallels: {}, + }) + mocks.getBlock.mockReturnValue({ category: 'core' }) + mocks.outputPaths.mockReturnValue(['content']) + }) + + it('owns canonical loading and block output computation', async () => { + const result = await readCopilotWorkflowBlockOutputs.execute({ + principal, + input: { + workflowId: 'workflow-1', + assertedWorkspaceId: 'forged-workspace', + blockIds: ['agent-1'], + }, + }) + + expect(mocks.resolveContext).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + }) + expect(result).toEqual({ + blocks: [ + { + blockId: 'agent-1', + blockName: 'Support Agent', + blockType: 'agent', + outputs: ['supportagent.content'], + relativeOutputs: ['content'], + triggerMode: undefined, + }, + ], + variables: [ + { + id: 'variable-1', + name: 'Customer Name', + type: 'plain', + tag: 'variable.customername', + }, + ], + }) + }) + + it('rechecks current permission before loading workflow state', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + await expect( + readCopilotWorkflowBlockOutputs.execute({ + principal, + input: { workflowId: 'workflow-1', blockIds: ['agent-1'] }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadDraft).not.toHaveBeenCalled() + }) + + it('rejects oversized block selections before loading workflow state', async () => { + await expect( + readCopilotWorkflowBlockOutputs.execute({ + principal, + input: { + workflowId: 'workflow-1', + blockIds: Array.from({ length: 101 }, (_, index) => `block-${index}`), + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.loadDraft).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.ts b/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.ts new file mode 100644 index 00000000000..ef6c2af4151 --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.ts @@ -0,0 +1,294 @@ +import type { Principal } from '@sim/auth/principal' +import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' +import type { Loop, Parallel } from '@sim/workflow-types/workflow' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { getEffectiveBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs' +import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator' +import { getBlockReferenceTags } from '@/lib/workflows/blocks/block-reference-tags' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { resolveTriggerRunOptions, toPublicRunOption } from '@/lib/workflows/triggers/run-options' +import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' +import { getBlock } from '@/blocks/registry' +import { normalizeName } from '@/executor/constants' + +const MAX_COPILOT_BLOCK_IDS = 100 + +interface CopilotWorkflowQueryInput { + workflowId: string + assertedWorkspaceId?: string +} + +interface WorkflowVariableReference { + id: string + name: string + type: string + tag: string +} + +interface AccessibleBlockEntry { + blockId: string + blockName: string + blockType: string + outputs: string[] + triggerMode?: boolean + accessContext?: 'inside' | 'outside' +} + +function resolveWorkflowContext<I extends CopilotWorkflowQueryInput>({ + principal, + input, +}: { + principal: Principal + input: I +}) { + return resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }) +} + +async function loadDraftWorkflow(workflowId: string) { + const state = await loadWorkflowFromNormalizedTables(workflowId) + if (!state) throw new OrchestrationError('not_found', 'Workflow has no saved state') + return state +} + +function workflowVariables(value: unknown): WorkflowVariableReference[] { + const variablesRecord = (value as Record<string, unknown>) || {} + return Object.values(variablesRecord) + .filter((variable): variable is Record<string, unknown> => { + if (!variable || typeof variable !== 'object') return false + const record = variable as Record<string, unknown> + return Boolean(record.name && String(record.name).trim()) + }) + .map((variable) => ({ + id: String(variable.id || ''), + name: String(variable.name || ''), + type: String(variable.type || 'plain'), + tag: `variable.${normalizeName(String(variable.name || ''))}`, + })) +} + +function subflowInsidePaths( + blockType: 'loop' | 'parallel', + blockId: string, + loops: Record<string, Loop>, + parallels: Record<string, Parallel> +): string[] { + const paths = ['index'] + if (blockType === 'loop') { + if ((loops[blockId]?.loopType || 'for') === 'forEach') paths.push('currentItem', 'items') + } else if ((parallels[blockId]?.parallelType || 'count') === 'collection') { + paths.push('currentItem', 'items') + } + return paths +} + +function displayOutputs(paths: string[], blockName: string): string[] { + const normalizedName = normalizeName(blockName) + return paths.map((path) => `${normalizedName}.${path}`) +} + +function assertBlockIdBound(blockIds: string[]): void { + if (blockIds.length > MAX_COPILOT_BLOCK_IDS) { + throw new OrchestrationError( + 'validation', + `blockIds cannot contain more than ${MAX_COPILOT_BLOCK_IDS} entries` + ) + } +} + +export interface ReadCopilotWorkflowRunOptionsInput extends CopilotWorkflowQueryInput {} + +export const readCopilotWorkflowRunOptions = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.readCopilotRunOptions, + resolveContext: resolveWorkflowContext<ReadCopilotWorkflowRunOptionsInput>, + async execute({ context }) { + const state = await loadDraftWorkflow(context.workflowId) + const merged = mergeSubblockStateWithValues(state.blocks) + const options = resolveTriggerRunOptions(merged, state.edges) + return { + options: options.map((option) => toPublicRunOption(option)), + } + }, +}) + +export interface ReadCopilotWorkflowBlockOutputsInput extends CopilotWorkflowQueryInput { + blockIds?: string[] +} + +export const readCopilotWorkflowBlockOutputs = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.readCopilotBlockOutputs, + resolveContext: resolveWorkflowContext<ReadCopilotWorkflowBlockOutputsInput>, + async execute({ input, context }) { + if (input.blockIds) assertBlockIdBound(input.blockIds) + const state = await loadDraftWorkflow(context.workflowId) + const blocks = state.blocks || {} + const loops = (state.loops || {}) as Record<string, Loop> + const parallels = (state.parallels || {}) as Record<string, Parallel> + const blockIds = input.blockIds?.length ? input.blockIds : Object.keys(blocks) + assertBlockIdBound(blockIds) + + const results = [] + for (const blockId of blockIds) { + const block = blocks[blockId] + if (!block?.type) continue + const blockName = block.name || block.type + if (block.type === 'loop' || block.type === 'parallel') { + const insidePaths = subflowInsidePaths(block.type, blockId, loops, parallels) + results.push({ + blockId, + blockName, + blockType: block.type, + outputs: [], + relativeOutputs: [], + insideSubflowOutputs: displayOutputs(insidePaths, blockName), + outsideSubflowOutputs: displayOutputs(['results'], blockName), + relativeInsideSubflowOutputs: insidePaths, + relativeOutsideSubflowOutputs: ['results'], + triggerMode: block.triggerMode, + }) + continue + } + + const blockConfig = getBlock(block.type) + const triggerMode = Boolean( + block.triggerMode && blockConfig && hasTriggerCapability(blockConfig) + ) + const outputs = getEffectiveBlockOutputPaths(block.type, block.subBlocks, { + triggerMode, + preferToolOutputs: !triggerMode, + }) + results.push({ + blockId, + blockName, + blockType: block.type, + outputs: displayOutputs(outputs, blockName), + relativeOutputs: outputs, + triggerMode: block.triggerMode, + }) + } + + return { blocks: results, variables: workflowVariables(context.workflow.variables) } + }, +}) + +export interface ReadCopilotWorkflowUpstreamReferencesInput extends CopilotWorkflowQueryInput { + blockIds: string[] +} + +export const readCopilotWorkflowUpstreamReferences = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.readCopilotUpstreamReferences, + resolveContext: resolveWorkflowContext<ReadCopilotWorkflowUpstreamReferencesInput>, + async execute({ input, context }) { + assertBlockIdBound(input.blockIds) + const state = await loadDraftWorkflow(context.workflowId) + const blocks = state.blocks || {} + const loops = (state.loops || {}) as Record<string, Loop> + const parallels = (state.parallels || {}) as Record<string, Parallel> + const graphEdges = (state.edges || []).map((edge) => ({ + source: edge.source, + target: edge.target, + })) + const variables = workflowVariables(context.workflow.variables) + const results = [] + + for (const blockId of input.blockIds) { + const targetBlock = blocks[blockId] + if (!targetBlock) continue + + const insideSubflows: Array<{ blockId: string; blockName: string; blockType: string }> = [] + const containingLoopIds = new Set<string>() + const containingParallelIds = new Set<string>() + + for (const loop of Object.values(loops)) { + if (!loop?.nodes?.includes(blockId)) continue + containingLoopIds.add(loop.id) + const loopBlock = blocks[loop.id] + if (loopBlock) { + insideSubflows.push({ + blockId: loop.id, + blockName: loopBlock.name || loopBlock.type, + blockType: 'loop', + }) + } + } + + for (const parallel of Object.values(parallels)) { + if (!parallel?.nodes?.includes(blockId)) continue + containingParallelIds.add(parallel.id) + const parallelBlock = blocks[parallel.id] + if (parallelBlock) { + insideSubflows.push({ + blockId: parallel.id, + blockName: parallelBlock.name || parallelBlock.type, + blockType: 'parallel', + }) + } + } + + const accessibleIds = new Set(BlockPathCalculator.findAllPathNodes(graphEdges, blockId)) + accessibleIds.add(blockId) + for (const loopId of containingLoopIds) accessibleIds.add(loopId) + for (const parallelId of containingParallelIds) accessibleIds.add(parallelId) + + const accessibleBlocks: AccessibleBlockEntry[] = [] + for (const accessibleBlockId of accessibleIds) { + const block = blocks[accessibleBlockId] + if (!block?.type) continue + const canSelfReference = block.type === 'approval' || block.type === 'human_in_the_loop' + if (accessibleBlockId === blockId && !canSelfReference) continue + + const blockName = block.name || block.type + let accessContext: 'inside' | 'outside' | undefined + let outputs: string[] + if (block.type === 'loop' || block.type === 'parallel') { + const isInside = + (block.type === 'loop' && containingLoopIds.has(accessibleBlockId)) || + (block.type === 'parallel' && containingParallelIds.has(accessibleBlockId)) + accessContext = isInside ? 'inside' : 'outside' + outputs = displayOutputs( + isInside + ? subflowInsidePaths(block.type, accessibleBlockId, loops, parallels) + : ['results'], + blockName + ) + } else { + outputs = getBlockReferenceTags({ + block: { + id: accessibleBlockId, + type: block.type, + name: block.name, + triggerMode: block.triggerMode, + subBlocks: block.subBlocks, + }, + currentBlockId: blockId, + }) + } + accessibleBlocks.push({ + blockId: accessibleBlockId, + blockName, + blockType: block.type, + outputs, + ...(block.triggerMode ? { triggerMode: true } : {}), + ...(accessContext ? { accessContext } : {}), + }) + } + + results.push({ + blockId, + blockName: targetBlock.name || targetBlock.type, + blockType: targetBlock.type, + accessibleBlocks, + insideSubflows, + variables, + }) + } + + return { results } + }, +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-definition.ts b/apps/sim/lib/workflows/application/read-workflow-definition.ts new file mode 100644 index 00000000000..0969b0ad563 --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-definition.ts @@ -0,0 +1,56 @@ +import type { Principal } from '@sim/auth/principal' +import type { NormalizedWorkflowData } from '@sim/workflow-persistence/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { + type DeployedWorkflowData, + loadDeployedWorkflowState, + loadWorkflowFromNormalizedTables, + NoActiveDeploymentError, +} from '@/lib/workflows/persistence/utils' + +export interface ReadWorkflowDefinitionInput { + workflowId: string + assertedWorkspaceId?: string + state: 'draft' | 'deployed' +} + +export interface ReadWorkflowDefinitionResult { + workflow: Awaited<ReturnType<typeof resolveActiveWorkflowApplicationContext>>['workflow'] + workspaceId: string + state: NormalizedWorkflowData | DeployedWorkflowData | null +} + +async function loadDefinition(input: ReadWorkflowDefinitionInput, workspaceId: string) { + if (input.state === 'draft') return loadWorkflowFromNormalizedTables(input.workflowId) + try { + return await loadDeployedWorkflowState(input.workflowId, workspaceId) + } catch (error) { + if (error instanceof NoActiveDeploymentError) return null + throw error + } +} + +export const readWorkflowDefinition = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.read, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: ReadWorkflowDefinitionInput + }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ input, context }): Promise<ReadWorkflowDefinitionResult> { + return { + workflow: context.workflow, + workspaceId: context.workspaceId, + state: await loadDefinition(input, context.workspaceId), + } + }, +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-deployment-overview.test.ts b/apps/sim/lib/workflows/application/read-workflow-deployment-overview.test.ts new file mode 100644 index 00000000000..6861fdf160d --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-deployment-overview.test.ts @@ -0,0 +1,190 @@ +/** + * @vitest-environment node + */ +import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks } = vi.hoisted(() => ({ + mocks: { + deploymentSummary: vi.fn(), + loadWorkspace: vi.fn(), + permission: vi.fn(), + redeployment: vi.fn(), + }, +})) + +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.permission, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@/lib/workflows/deployment-status', () => ({ + checkNeedsRedeployment: mocks.redeployment, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + getWorkflowDeploymentSummary: mocks.deploymentSummary, +})) + +import { + MAX_WORKFLOW_MCP_STATUS_SCHEMA_BYTES, + MAX_WORKFLOW_MCP_STATUS_TOOLS, + MAX_WORKFLOW_MCP_STATUS_TOTAL_SCHEMA_BYTES, + readWorkflowDeploymentOverview, +} from '@/lib/workflows/application/read-workflow-deployment-overview' + +const workflowRecord = { + id: 'workflow-1', + workspaceId: 'workspace-1', + name: 'Workflow', + archivedAt: null, +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), +} + +describe('readWorkflowDeploymentOverview', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.permission.mockResolvedValue('read') + mocks.deploymentSummary.mockResolvedValue({ + activeDeployment: null, + latestDeploymentAttempt: null, + warnings: [], + }) + mocks.redeployment.mockResolvedValue(false) + }) + + it('caps workflow MCP status rows and reports truncation', async () => { + queueTableRows(schemaMock.workflow, [ + { + workflowId: workflowRecord.id, + workflow: workflowRecord, + workspaceId: workflowRecord.workspaceId, + }, + ]) + queueTableRows(schemaMock.chat, []) + queueTableRows( + schemaMock.workflowMcpTool, + Array.from({ length: MAX_WORKFLOW_MCP_STATUS_TOOLS + 1 }, (_, index) => ({ + serverId: `server-${index}`, + serverName: `Server ${index}`, + toolName: `tool_${index}`, + toolDescription: null, + parameterSchema: {}, + parameterSchemaBytes: 2, + toolId: `tool-${index}`, + })) + ) + + const result = await readWorkflowDeploymentOverview.execute({ + principal, + input: { workflowId: workflowRecord.id }, + }) + + expect(result.mcpTools).toHaveLength(MAX_WORKFLOW_MCP_STATUS_TOOLS) + expect(result.mcpToolsTruncated).toBe(true) + }) + + it('truncates schema materialization at individual and aggregate byte budgets', async () => { + queueTableRows(schemaMock.workflow, [ + { + workflowId: workflowRecord.id, + workflow: workflowRecord, + workspaceId: workflowRecord.workspaceId, + }, + ]) + queueTableRows(schemaMock.chat, []) + const aggregateRows = Array.from( + { + length: MAX_WORKFLOW_MCP_STATUS_TOTAL_SCHEMA_BYTES / MAX_WORKFLOW_MCP_STATUS_SCHEMA_BYTES, + }, + (_, index) => ({ + serverId: 'server-1', + serverName: 'Server 1', + toolName: `within-budget-${index}`, + toolDescription: null, + parameterSchema: { type: 'object' }, + parameterSchemaBytes: MAX_WORKFLOW_MCP_STATUS_SCHEMA_BYTES, + toolId: `tool-${index + 2}`, + }) + ) + queueTableRows(schemaMock.workflowMcpTool, [ + { + serverId: 'server-1', + serverName: 'Server 1', + toolName: 'oversized', + toolDescription: null, + parameterSchema: null, + parameterSchemaBytes: MAX_WORKFLOW_MCP_STATUS_SCHEMA_BYTES + 1, + toolId: 'tool-1', + }, + ...aggregateRows, + { + serverId: 'server-1', + serverName: 'Server 1', + toolName: 'past-budget', + toolDescription: null, + parameterSchema: { type: 'object' }, + parameterSchemaBytes: 1, + toolId: 'tool-last', + }, + ]) + + const result = await readWorkflowDeploymentOverview.execute({ + principal, + input: { workflowId: workflowRecord.id }, + }) + + expect(result.mcpTools).toHaveLength(1 + aggregateRows.length) + expect(result.mcpTools[0].parameterSchema).toEqual({ + truncated: true, + bytes: MAX_WORKFLOW_MCP_STATUS_SCHEMA_BYTES + 1, + }) + expect(result.mcpToolsTruncated).toBe(true) + }) + + it('rejects a cross-workspace assertion before protected status loads', async () => { + queueTableRows(schemaMock.workflow, [ + { + workflowId: workflowRecord.id, + workflow: workflowRecord, + workspaceId: workflowRecord.workspaceId, + }, + ]) + + await expect( + readWorkflowDeploymentOverview.execute({ + principal: { ...principal, workspaceId: 'workspace-2' }, + input: { workflowId: workflowRecord.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.deploymentSummary).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-deployment-overview.ts b/apps/sim/lib/workflows/application/read-workflow-deployment-overview.ts new file mode 100644 index 00000000000..832d741adc1 --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-deployment-overview.ts @@ -0,0 +1,130 @@ +import type { Principal } from '@sim/auth/principal' +import { chat, db, workflowMcpServer, workflowMcpTool } from '@sim/db' +import { and, asc, eq, isNull, sql } from 'drizzle-orm' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' +import { getWorkflowDeploymentSummary } from '@/lib/workflows/orchestration' + +export const MAX_WORKFLOW_MCP_STATUS_TOOLS = 100 +export const MAX_WORKFLOW_MCP_STATUS_SCHEMA_BYTES = 64 * 1024 +export const MAX_WORKFLOW_MCP_STATUS_TOTAL_SCHEMA_BYTES = 1024 * 1024 + +export interface ReadWorkflowDeploymentOverviewInput { + workflowId: string + assertedWorkspaceId?: string +} + +function resolveWorkflowContext({ + principal, + input, +}: { + principal: Principal + input: ReadWorkflowDeploymentOverviewInput +}) { + return resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }) +} + +export const readWorkflowDeploymentOverview = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.readDeploymentOverview, + resolveContext: resolveWorkflowContext, + async execute({ context }) { + const [deploymentSummary, chatDeploy, mcpRows] = await Promise.all([ + getWorkflowDeploymentSummary(context.workflowId), + db + .select({ + id: chat.id, + identifier: chat.identifier, + title: chat.title, + description: chat.description, + authType: chat.authType, + allowedEmails: chat.allowedEmails, + outputConfigs: chat.outputConfigs, + includeThinking: chat.includeThinking, + includeToolCalls: chat.includeToolCalls, + password: chat.password, + customizations: chat.customizations, + }) + .from(chat) + .where(and(eq(chat.workflowId, context.workflowId), isNull(chat.archivedAt))) + .limit(1), + db + .select({ + serverId: workflowMcpServer.id, + serverName: workflowMcpServer.name, + toolName: workflowMcpTool.toolName, + toolDescription: workflowMcpTool.toolDescription, + parameterSchema: sql<unknown>`CASE + WHEN COALESCE(octet_length(${workflowMcpTool.parameterSchema}::text), 0) + <= ${MAX_WORKFLOW_MCP_STATUS_SCHEMA_BYTES} + THEN ${workflowMcpTool.parameterSchema} + ELSE NULL + END`, + parameterSchemaBytes: + sql<number>`COALESCE(octet_length(${workflowMcpTool.parameterSchema}::text), 0)`.mapWith( + Number + ), + toolId: workflowMcpTool.id, + }) + .from(workflowMcpTool) + .innerJoin(workflowMcpServer, eq(workflowMcpTool.serverId, workflowMcpServer.id)) + .where( + and( + eq(workflowMcpTool.workflowId, context.workflowId), + isNull(workflowMcpTool.archivedAt), + isNull(workflowMcpServer.deletedAt) + ) + ) + .orderBy(asc(workflowMcpServer.id), asc(workflowMcpTool.toolName)) + .limit(MAX_WORKFLOW_MCP_STATUS_TOOLS + 1), + ]) + + const isDeployed = deploymentSummary.activeDeployment !== null + const attemptStatus = deploymentSummary.latestDeploymentAttempt?.status + const needsRedeployment = + isDeployed && attemptStatus !== 'preparing' && attemptStatus !== 'activating' + ? await checkNeedsRedeployment(context.workflowId) + : false + let schemaBytes = 0 + let mcpToolsTruncated = mcpRows.length > MAX_WORKFLOW_MCP_STATUS_TOOLS + const mcpTools = [] + for (const row of mcpRows.slice(0, MAX_WORKFLOW_MCP_STATUS_TOOLS)) { + if (!Number.isFinite(row.parameterSchemaBytes) || row.parameterSchemaBytes < 0) { + throw new Error('Workflow MCP status query returned an invalid schema byte count') + } + const schemaOversized = row.parameterSchemaBytes > MAX_WORKFLOW_MCP_STATUS_SCHEMA_BYTES + if ( + !schemaOversized && + schemaBytes + row.parameterSchemaBytes > MAX_WORKFLOW_MCP_STATUS_TOTAL_SCHEMA_BYTES + ) { + mcpToolsTruncated = true + break + } + if (!schemaOversized) schemaBytes += row.parameterSchemaBytes + if (schemaOversized) mcpToolsTruncated = true + const { parameterSchemaBytes: _parameterSchemaBytes, ...tool } = row + mcpTools.push({ + ...tool, + parameterSchema: schemaOversized + ? { truncated: true, bytes: row.parameterSchemaBytes } + : row.parameterSchema, + }) + } + + return { + workflow: context.workflow, + workspaceId: context.workspaceId, + isDeployed, + needsRedeployment, + ...deploymentSummary, + chatDeployment: chatDeploy[0] ?? null, + mcpTools, + mcpToolsTruncated, + } + }, +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-state-references.ts b/apps/sim/lib/workflows/application/read-workflow-state-references.ts new file mode 100644 index 00000000000..6a33cf01f98 --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-state-references.ts @@ -0,0 +1,72 @@ +import type { Principal } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { + getWorkflowDeploymentVersion, + loadWorkflowFromNormalizedTables, +} from '@/lib/workflows/persistence/utils' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +export type WorkflowStateReference = number | 'live' | 'draft' + +export interface ResolvedWorkflowStateReference { + state: WorkflowState + ref: string + version?: number + isActive?: boolean + createdAt?: string +} + +export interface ReadWorkflowStateReferencesInput { + workflowId: string + assertedWorkspaceId?: string + references: [WorkflowStateReference, WorkflowStateReference] +} + +async function loadReference( + workflowId: string, + reference: WorkflowStateReference +): Promise<ResolvedWorkflowStateReference> { + if (reference === 'draft') { + const state = await loadWorkflowFromNormalizedTables(workflowId) + if (!state) throw new OrchestrationError('not_found', 'Workflow has no draft state') + return { state: state as WorkflowState, ref: 'draft' } + } + + const row = await getWorkflowDeploymentVersion( + workflowId, + reference === 'live' ? 'active' : reference + ) + if (!row?.state) throw new OrchestrationError('not_found', 'Deployment version not found') + return { + state: row.state as WorkflowState, + ref: reference === 'live' ? 'live' : String(reference), + version: row.version, + isActive: row.isActive, + createdAt: row.createdAt?.toISOString(), + } +} + +export const readWorkflowStateReferences = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.compareReferences, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: ReadWorkflowStateReferencesInput + }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ input, context }) { + const [first, second] = await Promise.all( + input.references.map((reference) => loadReference(context.workflowId, reference)) + ) + return { references: [first, second] as const } + }, +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-version.ts b/apps/sim/lib/workflows/application/read-workflow-version.ts index bec3ac131af..143aae63cdd 100644 --- a/apps/sim/lib/workflows/application/read-workflow-version.ts +++ b/apps/sim/lib/workflows/application/read-workflow-version.ts @@ -6,13 +6,18 @@ import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/applica import { workflowOperations } from '@/lib/workflows/application/operations' import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('ReadWorkflowVersion') +function isWorkflowState(value: unknown): value is WorkflowState { + return typeof value === 'object' && value !== null +} + export interface ReadWorkflowVersionInput { workflowId: string assertedWorkspaceId?: string - version: number + version: number | 'active' } export const readWorkflowVersion = defineAuthorizedWorkflowUseCase({ @@ -33,12 +38,16 @@ export const readWorkflowVersion = defineAuthorizedWorkflowUseCase({ if (!version?.state) { throw new OrchestrationError('not_found', 'Deployment version not found') } + const state = version.state + if (!isWorkflowState(state)) { + throw new Error('Deployment version contains invalid workflow state') + } logger.info('Read workflow version', { workspaceId: context.workspaceId, workflowId: context.workflowId, version: input.version, principalKind: principal.kind, }) - return { version } + return { version: { ...version, state } } }, }) diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts new file mode 100644 index 00000000000..18755a4f70f --- /dev/null +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts @@ -0,0 +1,243 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks } = vi.hoisted(() => ({ + mocks: { + admission: vi.fn(), + executeWorkflow: vi.fn(), + latestState: vi.fn(), + loadDeployed: vi.fn(), + loadDraft: vi.fn(), + permission: vi.fn(), + resolveContext: vi.fn(), + resolveOptions: vi.fn(), + sourceState: vi.fn(), + validateInput: vi.fn(), + }, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.permission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveContext, +})) + +vi.mock('@/lib/workflows/execution-admission', () => ({ + prepareWorkflowExecutionAdmission: mocks.admission, +})) + +vi.mock('@/lib/workflows/executor/execution-state', () => ({ + getExecutionInputForWorkflow: vi.fn(), + getExecutionStateForWorkflow: mocks.sourceState, + getLatestExecutionStateWithExecutionId: mocks.latestState, +})) + +vi.mock('@/lib/workflows/executor/execute-workflow', () => ({ + executeWorkflow: mocks.executeWorkflow, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadDeployedWorkflowState: mocks.loadDeployed, + loadWorkflowFromNormalizedTables: mocks.loadDraft, + NoActiveDeploymentError: class NoActiveDeploymentError extends Error {}, +})) + +vi.mock('@/lib/workflows/triggers/run-options', () => ({ + resolveTriggerRunOptions: mocks.resolveOptions, + validateTriggerInput: mocks.validateInput, +})) + +vi.mock('@sim/workflow-persistence/subblocks', () => ({ + mergeSubblockStateWithValues: vi.fn((blocks) => blocks), +})) + +vi.mock('@sim/utils/id', () => ({ generateId: vi.fn(() => 'child-execution-1') })) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: vi.fn(() => 'request-1') })) + +import { + runFromBlockFromCopilot, + runWorkflowFromCopilot, +} from '@/lib/workflows/application/run-workflow-from-copilot' + +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), +} + +const context = { + workflowId: 'workflow-1', + workflow: { + id: 'workflow-1', + userId: 'owner-1', + workspaceId: 'workspace-1', + variables: {}, + }, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const lifecycle = {} + +describe('Copilot workflow run application commands', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(context) + mocks.permission.mockResolvedValue('write') + mocks.loadDraft.mockResolvedValue({ blocks: { trigger: {} }, edges: [] }) + mocks.resolveOptions.mockReturnValue([ + { triggerBlockId: 'trigger', blockName: 'Start', mockPayload: { source: 'mock' } }, + ]) + mocks.validateInput.mockReturnValue({ ok: true }) + mocks.admission.mockResolvedValue({ billingAttribution: undefined, targetReservation: false }) + mocks.executeWorkflow.mockResolvedValue({ success: true, output: { ok: true }, logs: [] }) + }) + + it('owns canonical authorization, trigger selection, admission, and execution', async () => { + const result = await runWorkflowFromCopilot.execute({ + principal, + input: { + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + useDraftState: true, + lifecycle, + hasWorkflowInput: false, + useMockPayload: true, + }, + }) + + expect(result).toMatchObject({ success: true, output: { ok: true } }) + expect(mocks.resolveContext).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + }) + expect(mocks.permission).toHaveBeenCalledBefore(mocks.loadDraft) + expect(mocks.admission).toHaveBeenCalledWith( + { userId: 'user-1', billingAttribution: undefined }, + 'workspace-1', + 'child-execution-1' + ) + expect(mocks.executeWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ id: 'workflow-1' }), + 'request-1', + { source: 'mock' }, + 'user-1', + expect.objectContaining({ + useDraftState: true, + workflowTriggerType: 'copilot', + triggerBlockId: 'trigger', + }), + 'child-execution-1' + ) + }) + + it('rechecks current permission before loading execution state', async () => { + mocks.permission.mockResolvedValueOnce(null) + + await expect( + runWorkflowFromCopilot.execute({ + principal, + input: { + workflowId: 'workflow-1', + useDraftState: true, + lifecycle, + hasWorkflowInput: false, + useMockPayload: true, + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadDraft).not.toHaveBeenCalled() + expect(mocks.executeWorkflow).not.toHaveBeenCalled() + }) + + it('fails before execution when the selected durable definition is absent', async () => { + mocks.loadDraft.mockResolvedValueOnce(null) + + await expect( + runWorkflowFromCopilot.execute({ + principal, + input: { + workflowId: 'workflow-1', + useDraftState: true, + lifecycle, + hasWorkflowInput: false, + useMockPayload: true, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.admission).not.toHaveBeenCalled() + }) + + it('owns canonical source snapshot lineage for run-from-block', async () => { + const snapshot = { + blockStates: {}, + executedBlocks: [], + blockLogs: [], + decisions: {}, + completedLoops: [], + activeExecutionPath: [], + } + mocks.sourceState.mockResolvedValueOnce(snapshot) + + await runFromBlockFromCopilot.execute({ + principal, + input: { + workflowId: 'workflow-1', + useDraftState: true, + lifecycle, + blockId: 'agent-1', + sourceExecutionId: 'source-execution-1', + }, + }) + + expect(mocks.executeWorkflow).toHaveBeenCalledWith( + expect.any(Object), + 'request-1', + undefined, + 'user-1', + expect.objectContaining({ + runFromBlock: { + startBlockId: 'agent-1', + sourceSnapshot: snapshot, + sourceExecutionId: 'source-execution-1', + }, + }), + 'child-execution-1' + ) + }) + + it('propagates unexpected execution infrastructure failures', async () => { + mocks.executeWorkflow.mockRejectedValueOnce(new Error('database unavailable')) + + await expect( + runWorkflowFromCopilot.execute({ + principal, + input: { + workflowId: 'workflow-1', + useDraftState: true, + lifecycle, + hasWorkflowInput: false, + useMockPayload: true, + }, + }) + ).rejects.toThrow('database unavailable') + }) +}) diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts new file mode 100644 index 00000000000..568b9a31c66 --- /dev/null +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts @@ -0,0 +1,358 @@ +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' +import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { prepareWorkflowExecutionAdmission } from '@/lib/workflows/execution-admission' +import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' +import { + getExecutionInputForWorkflow, + getExecutionStateForWorkflow, + getLatestExecutionStateWithExecutionId, +} from '@/lib/workflows/executor/execution-state' +import { + loadDeployedWorkflowState, + loadWorkflowFromNormalizedTables, + NoActiveDeploymentError, +} from '@/lib/workflows/persistence/utils' +import { + resolveTriggerRunOptions, + validateTriggerInput, +} from '@/lib/workflows/triggers/run-options' +import type { SerializableExecutionState } from '@/executor/execution/types' +import type { ExecutionResult } from '@/executor/types' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +export interface CopilotWorkflowRunLifecycle { + billingAttribution?: BillingAttributionSnapshot + resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry + abortSignal?: AbortSignal +} + +interface BaseCopilotRunInput { + workflowId: string + assertedWorkspaceId?: string + useDraftState: boolean + lifecycle: CopilotWorkflowRunLifecycle +} + +interface TriggerCopilotRunInput extends BaseCopilotRunInput { + triggerBlockId?: string + workflowInput?: unknown + hasWorkflowInput: boolean + useMockPayload: boolean + inputFromExecutionId?: string +} + +export interface RunWorkflowFromCopilotInput extends TriggerCopilotRunInput {} + +export interface RunWorkflowUntilBlockFromCopilotInput extends TriggerCopilotRunInput { + stopAfterBlockId: string +} + +interface SnapshotCopilotRunInput extends BaseCopilotRunInput { + blockId: string + workflowInput?: unknown + sourceExecutionId?: string +} + +export interface RunFromBlockFromCopilotInput extends SnapshotCopilotRunInput {} +export interface RunBlockFromCopilotInput extends SnapshotCopilotRunInput {} + +function resolveContext<I extends BaseCopilotRunInput>({ + principal, + input, +}: { + principal: Principal + input: I +}) { + return resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }) +} + +async function loadDefinition(input: BaseCopilotRunInput, workspaceId: string) { + if (input.useDraftState) return loadWorkflowFromNormalizedTables(input.workflowId) + try { + return await loadDeployedWorkflowState(input.workflowId, workspaceId) + } catch (error) { + if (error instanceof NoActiveDeploymentError) return null + throw error + } +} + +async function resolveTriggerExecution(params: { + input: TriggerCopilotRunInput + workspaceId: string +}): Promise<{ triggerBlockId: string; input: unknown }> { + const state = await loadDefinition(params.input, params.workspaceId) + if (!state?.blocks) { + throw new OrchestrationError( + 'validation', + `Workflow ${params.input.workflowId} has no ${params.input.useDraftState ? 'saved draft' : 'deployed'} state to run.` + ) + } + const merged = mergeSubblockStateWithValues(state.blocks) + const options = resolveTriggerRunOptions(merged, state.edges) + if (options.length === 0) { + throw new OrchestrationError( + 'validation', + 'No runnable trigger found. Add a Start/API/Input/Chat trigger or an external (webhook/integration) trigger before running.' + ) + } + const listTriggers = () => + options.map((option) => `${option.triggerBlockId} (${option.blockName})`).join(', ') + let option = options[0] + if (params.input.triggerBlockId) { + const selected = options.find( + (candidate) => candidate.triggerBlockId === params.input.triggerBlockId + ) + if (!selected) { + throw new OrchestrationError( + 'validation', + `triggerBlockId "${params.input.triggerBlockId}" is not a runnable trigger in this workflow. Valid triggers: ${listTriggers()}. Call get_workflow_run_options to inspect them.` + ) + } + option = selected + } else if (options.length > 1) { + throw new OrchestrationError( + 'validation', + `This workflow has multiple triggers — pass triggerBlockId to choose one: ${listTriggers()}. Call get_workflow_run_options for each trigger's input shape.` + ) + } + + const sourceCount = + (params.input.hasWorkflowInput ? 1 : 0) + + (params.input.useMockPayload ? 1 : 0) + + (params.input.inputFromExecutionId ? 1 : 0) + if (sourceCount > 1) { + throw new OrchestrationError( + 'validation', + 'Provide only one input source: workflow_input, useMockPayload: true, or inputFromExecutionId.' + ) + } + if (params.input.useMockPayload) { + return { triggerBlockId: option.triggerBlockId, input: option.mockPayload } + } + + let executionInput = params.input.workflowInput + if (params.input.inputFromExecutionId) { + const source = await getExecutionInputForWorkflow( + params.input.inputFromExecutionId, + params.input.workflowId + ) + if (!source.found) { + throw new OrchestrationError( + 'not_found', + `No execution "${params.input.inputFromExecutionId}" found for this workflow to reuse input from.` + ) + } + if (source.input === undefined) { + throw new OrchestrationError( + 'validation', + `Execution "${params.input.inputFromExecutionId}" has no recorded input to reuse.` + ) + } + executionInput = source.input + } + const validation = validateTriggerInput(option, executionInput) + if (!validation.ok) { + throw new OrchestrationError( + 'validation', + validation.error || 'workflow_input is invalid for the target trigger.' + ) + } + return { triggerBlockId: option.triggerBlockId, input: executionInput } +} + +async function resolveSourceSnapshot(input: SnapshotCopilotRunInput): Promise<{ + executionId: string + snapshot: SerializableExecutionState +}> { + if (input.sourceExecutionId) { + const snapshot = await getExecutionStateForWorkflow(input.sourceExecutionId, input.workflowId) + if (snapshot) return { executionId: input.sourceExecutionId, snapshot } + throw new OrchestrationError( + 'not_found', + `No execution state found for execution ${input.sourceExecutionId}. Run the full workflow first.` + ) + } + const latest = await getLatestExecutionStateWithExecutionId(input.workflowId) + if (latest?.state) return { executionId: latest.executionId, snapshot: latest.state } + throw new OrchestrationError( + 'not_found', + `No execution state found for workflow ${input.workflowId}. Run the full workflow first to create a snapshot.` + ) +} + +async function executeCopilotRun(params: { + principal: Principal + input: BaseCopilotRunInput + context: Awaited<ReturnType<typeof resolveActiveWorkflowApplicationContext>> + executionInput: unknown + triggerBlockId?: string + stopAfterBlockId?: string + runFromBlock?: { + startBlockId: string + sourceSnapshot: SerializableExecutionState + sourceExecutionId: string + } +}): Promise<ExecutionResult> { + const actorUserId = requirePrincipalSubjectUserId(params.principal) + const childExecutionId = generateId() + const admission = await prepareWorkflowExecutionAdmission( + { + userId: actorUserId, + billingAttribution: params.input.lifecycle.billingAttribution, + }, + params.context.workspaceId, + childExecutionId + ) + const registry = params.input.lifecycle.resolvedSecretTraceRegistry + const trustedInitialResolvedSecretTraceProvenance = registry?.exportProvenanceForValue( + params.executionInput + ) + const completePendingActivation = registry?.beginPendingActivation() + try { + const result = await executeWorkflow( + { + id: params.context.workflowId, + userId: params.context.workflow.userId, + workspaceId: params.context.workspaceId, + variables: params.context.workflow.variables || {}, + }, + generateRequestId(), + params.executionInput, + actorUserId, + { + enabled: true, + useDraftState: params.input.useDraftState, + workflowTriggerType: 'copilot', + triggerBlockId: params.triggerBlockId, + stopAfterBlockId: params.stopAfterBlockId, + runFromBlock: params.runFromBlock, + abortSignal: params.input.lifecycle.abortSignal, + billingAttribution: admission.billingAttribution, + ...(trustedInitialResolvedSecretTraceProvenance + ? { trustedInitialResolvedSecretTraceProvenance } + : {}), + }, + childExecutionId + ) + if (registry) { + await registry.importCrossingProvenance( + result.executionState?.resolvedSecretTraceProvenance, + { output: result.output, logs: result.logs, error: result.error }, + { trusted: true } + ) + } + return result + } catch (error) { + if (registry) { + const executionResult = + typeof error === 'object' && + error !== null && + 'executionResult' in error && + typeof error.executionResult === 'object' + ? (error.executionResult as ExecutionResult) + : undefined + await registry.importCrossingProvenance( + executionResult?.executionState?.resolvedSecretTraceProvenance, + { + output: executionResult?.output, + logs: executionResult?.logs, + error: executionResult?.error, + thrownMessage: toError(error).message, + }, + { trusted: true } + ) + } + if (admission.targetReservation) await releaseExecutionSlot(childExecutionId) + throw error + } finally { + completePendingActivation?.() + } +} + +function defineTriggerRunUseCase<I extends TriggerCopilotRunInput & { stopAfterBlockId?: string }>( + operation: + | typeof workflowOperations.runFromCopilot + | typeof workflowOperations.runUntilFromCopilot +) { + return defineAuthorizedWorkflowUseCase({ + operation, + resolveContext: resolveContext<I>, + async execute({ principal, input, context }) { + const prepared = await resolveTriggerExecution({ input, workspaceId: context.workspaceId }) + return executeCopilotRun({ + principal, + input, + context, + executionInput: prepared.input, + triggerBlockId: prepared.triggerBlockId, + stopAfterBlockId: input.stopAfterBlockId, + }) + }, + }) +} + +export const runWorkflowFromCopilot = defineTriggerRunUseCase<RunWorkflowFromCopilotInput>( + workflowOperations.runFromCopilot +) + +export const runWorkflowUntilBlockFromCopilot = + defineTriggerRunUseCase<RunWorkflowUntilBlockFromCopilotInput>( + workflowOperations.runUntilFromCopilot + ) + +function defineSnapshotRunUseCase<I extends SnapshotCopilotRunInput>( + operation: + | typeof workflowOperations.runFromBlockFromCopilot + | typeof workflowOperations.runBlockFromCopilot, + stopAtStartBlock: boolean +) { + return defineAuthorizedWorkflowUseCase({ + operation, + resolveContext: resolveContext<I>, + async execute({ principal, input, context }) { + const state = await loadDefinition(input, context.workspaceId) + if (!state?.blocks) { + throw new OrchestrationError( + 'validation', + `Workflow ${input.workflowId} has no ${input.useDraftState ? 'saved draft' : 'deployed'} state to run.` + ) + } + const source = await resolveSourceSnapshot(input) + return executeCopilotRun({ + principal, + input, + context, + executionInput: input.workflowInput, + runFromBlock: { + startBlockId: input.blockId, + sourceSnapshot: source.snapshot, + sourceExecutionId: source.executionId, + }, + stopAfterBlockId: stopAtStartBlock ? input.blockId : undefined, + }) + }, + }) +} + +export const runFromBlockFromCopilot = defineSnapshotRunUseCase<RunFromBlockFromCopilotInput>( + workflowOperations.runFromBlockFromCopilot, + false +) + +export const runBlockFromCopilot = defineSnapshotRunUseCase<RunBlockFromCopilotInput>( + workflowOperations.runBlockFromCopilot, + true +) diff --git a/apps/sim/lib/workflows/application/update-workflow-content.test.ts b/apps/sim/lib/workflows/application/update-workflow-content.test.ts new file mode 100644 index 00000000000..6379ad2e3f7 --- /dev/null +++ b/apps/sim/lib/workflows/application/update-workflow-content.test.ts @@ -0,0 +1,141 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + recordAudit: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + notify: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated' }, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveContext, +})) + +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) + +import { applyWorkflowVariableOperations } from '@/lib/workflows/application/update-workflow-content' + +const context = { + workflowId: 'workflow-1', + workflow: { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), +} + +describe('applyWorkflowVariableOperations', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('write') + dbChainMockFns.for.mockResolvedValue([{ variables: {} }]) + dbChainMockFns.returning.mockResolvedValue([{ id: 'workflow-1' }]) + }) + + it('transforms the row locked in the write transaction and projects effects afterward', async () => { + dbChainMockFns.for.mockResolvedValueOnce([ + { + variables: { + concurrent: { + id: 'concurrent', + workflowId: 'workflow-1', + name: 'preserved', + type: 'plain', + value: 'newer write', + }, + }, + }, + ]) + + await expect( + applyWorkflowVariableOperations.execute({ + principal, + input: { + workflowId: 'workflow-1', + operations: [{ operation: 'add', name: 'threshold', type: 'number', value: '5' }], + }, + }) + ).resolves.toMatchObject({ updated: 2, changed: true }) + + expect(dbChainMockFns.for).toHaveBeenCalledWith('update') + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + concurrent: expect.objectContaining({ value: 'newer write' }), + }), + }) + ) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'workflow.variables_updated', + resourceId: 'workflow-1', + metadata: expect.objectContaining({ + operation: 'workflows.variables.apply_operations', + operationCount: 1, + source: 'copilot', + }), + }) + ) + expect(mocks.notify).toHaveBeenCalledWith('workflow-1') + expect(dbChainMockFns.returning).toHaveBeenCalledBefore(mocks.notify) + }) + + it('does not write, audit, or notify an authoritative no-op', async () => { + await expect( + applyWorkflowVariableOperations.execute({ + principal, + input: { + workflowId: 'workflow-1', + operations: [{ operation: 'delete', name: 'missing' }], + }, + }) + ).resolves.toEqual({ updated: 0, changed: false }) + + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) + + it('rejects a non-Copilot principal before canonical loading', async () => { + await expect( + applyWorkflowVariableOperations.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workflowId: 'workflow-1', operations: [] }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveContext).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/update-workflow-content.ts b/apps/sim/lib/workflows/application/update-workflow-content.ts new file mode 100644 index 00000000000..9d60b59d277 --- /dev/null +++ b/apps/sim/lib/workflows/application/update-workflow-content.ts @@ -0,0 +1,395 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' +import { and, eq, isNull } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { + loadWorkflowFromNormalizedTables, + saveWorkflowToNormalizedTables, +} from '@/lib/workflows/persistence/utils' + +const logger = createLogger('UpdateWorkflowContent') +const MAX_WORKFLOW_VARIABLE_OPERATIONS = 100 + +interface WorkflowContentInput { + workflowId: string + assertedWorkspaceId?: string +} + +async function requireMutableWorkflow(workflowId: string): Promise<void> { + try { + await assertWorkflowMutable(workflowId) + } catch (error) { + if (error instanceof WorkflowLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + } +} + +function resolveWorkflowContentContext<I extends WorkflowContentInput>({ + principal, + input, +}: { + principal: Principal + input: I +}) { + return resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }) +} + +interface WorkflowVariable { + id: string + workflowId?: string + name: string + type: string + value?: unknown +} + +export interface WorkflowVariableOperation { + name: string + operation: 'add' | 'edit' | 'delete' + value?: unknown + type?: string +} + +export interface ApplyWorkflowVariableOperationsInput extends WorkflowContentInput { + operations: WorkflowVariableOperation[] +} + +function coerceWorkflowVariableValue(value: unknown, type: string): unknown { + if (value === undefined) return value + if (type === 'number') { + const number = Number(value) + return Number.isNaN(number) ? value : number + } + if (type === 'boolean') { + const normalized = String(value).trim().toLowerCase() + if (normalized === 'true') return true + if (normalized === 'false') return false + return value + } + if (type !== 'array' && type !== 'object') return value + + try { + const parsed: unknown = JSON.parse(String(value)) + if (type === 'array' && Array.isArray(parsed)) return parsed + if (type === 'object' && parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed + } + } catch (error) { + logger.warn('Failed to parse JSON value for workflow variable coercion', { + error: getErrorMessage(error), + }) + } + return value +} + +function applyVariableOperations( + workflowId: string, + currentVariables: unknown, + operations: readonly WorkflowVariableOperation[] +): { variables: Record<string, WorkflowVariable>; changed: boolean } { + const current = + currentVariables && typeof currentVariables === 'object' && !Array.isArray(currentVariables) + ? (currentVariables as Record<string, unknown>) + : {} + const byName = new Map<string, WorkflowVariable>() + for (const value of Object.values(current)) { + if ( + value && + typeof value === 'object' && + 'id' in value && + typeof value.id === 'string' && + 'name' in value && + typeof value.name === 'string' + ) { + byName.set(value.name, { + ...value, + id: value.id, + name: value.name, + type: 'type' in value && typeof value.type === 'string' ? value.type : 'plain', + }) + } + } + + let changed = false + for (const operation of operations) { + const name = String(operation.name || '') + if (!name) continue + const existing = byName.get(name) + if (operation.operation === 'delete') { + changed = byName.delete(name) || changed + continue + } + + const type = operation.type || existing?.type || 'plain' + const value = coerceWorkflowVariableValue(operation.value, type) + if (operation.operation === 'add' || !existing) { + byName.set(name, { id: generateId(), workflowId, name, type, value }) + } else { + byName.set(name, { ...existing, type, value }) + } + changed = true + } + + return { + variables: Object.fromEntries([...byName.values()].map((variable) => [variable.id, variable])), + changed, + } +} + +export const applyWorkflowVariableOperations = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.applyVariableOperations, + resolveContext: resolveWorkflowContentContext<ApplyWorkflowVariableOperationsInput>, + async execute({ input, context }) { + if (input.operations.length > MAX_WORKFLOW_VARIABLE_OPERATIONS) { + throw new OrchestrationError( + 'validation', + `Workflow variable updates cannot exceed ${MAX_WORKFLOW_VARIABLE_OPERATIONS} operations` + ) + } + await requireMutableWorkflow(context.workflowId) + + return db.transaction(async (tx) => { + const [current] = await tx + .select({ variables: workflow.variables }) + .from(workflow) + .where( + and( + eq(workflow.id, context.workflowId), + eq(workflow.workspaceId, context.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .limit(1) + .for('update') + if (!current) throw new OrchestrationError('not_found', 'Workflow not found') + + const transformed = applyVariableOperations( + context.workflowId, + current.variables, + input.operations + ) + if (!transformed.changed) { + return { updated: Object.keys(transformed.variables).length, changed: false } + } + + const [updated] = await tx + .update(workflow) + .set({ variables: transformed.variables, updatedAt: new Date() }) + .where( + and( + eq(workflow.id, context.workflowId), + eq(workflow.workspaceId, context.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .returning({ id: workflow.id }) + if (!updated) throw new OrchestrationError('not_found', 'Workflow not found') + return { updated: Object.keys(transformed.variables).length, changed: true } + }) + }, + projectAudit: ({ input, context, result }) => + result.changed + ? { + action: AuditAction.WORKFLOW_VARIABLES_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: context.workflowId, + resourceName: context.workflow.name, + description: 'Updated workflow variables', + metadata: { operationCount: input.operations.length, source: 'copilot' }, + } + : [], + afterSuccess: ({ context, result }) => + result.changed ? notifyWorkflowUpdated(context.workflowId) : undefined, +}) + +function isBlockProtected(blockId: string, blocksById: Record<string, BlockState>): boolean { + const block = blocksById[blockId] + if (!block) return false + if (block.locked) return true + + const visited = new Set<string>() + let parentId = block.data?.parentId + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + if (blocksById[parentId]?.locked) return true + parentId = blocksById[parentId]?.data?.parentId + } + return false +} + +function hasDisabledAncestor(blockId: string, blocksById: Record<string, BlockState>): boolean { + const visited = new Set<string>() + let parentId = blocksById[blockId]?.data?.parentId + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + const parent = blocksById[parentId] + if (!parent) return false + if (parent.enabled === false) return true + parentId = parent.data?.parentId + } + return false +} + +function findDescendants(containerId: string, blocksById: Record<string, BlockState>): string[] { + const descendants: string[] = [] + const stack = [containerId] + const visited = new Set<string>() + while (stack.length > 0) { + const current = stack.pop()! + if (visited.has(current)) continue + visited.add(current) + for (const [blockId, block] of Object.entries(blocksById)) { + if (block.data?.parentId === current) { + descendants.push(blockId) + stack.push(blockId) + } + } + } + return descendants +} + +export interface SetWorkflowBlockEnabledInput extends WorkflowContentInput { + blockId: string + enabled: boolean +} + +export const setWorkflowBlockEnabled = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.setBlockEnabled, + resolveContext: resolveWorkflowContentContext<SetWorkflowBlockEnabledInput>, + async execute({ input, context }) { + await requireMutableWorkflow(context.workflowId) + return db.transaction(async (tx) => { + const [active] = await tx + .select({ id: workflow.id, name: workflow.name }) + .from(workflow) + .where( + and( + eq(workflow.id, context.workflowId), + eq(workflow.workspaceId, context.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .limit(1) + .for('update') + if (!active) throw new OrchestrationError('not_found', 'Workflow not found') + + const normalized = await loadWorkflowFromNormalizedTables(context.workflowId, tx) + if (!normalized) { + throw new OrchestrationError( + 'validation', + `Workflow ${context.workflowId} has no normalized state` + ) + } + const currentState: WorkflowState = { + blocks: normalized.blocks as Record<string, BlockState>, + edges: normalized.edges || [], + loops: normalized.loops || {}, + parallels: normalized.parallels || {}, + lastSaved: Date.now(), + } + const targetBlock = currentState.blocks[input.blockId] + if (!targetBlock) { + throw new OrchestrationError( + 'not_found', + `Block ${input.blockId} not found in workflow ${context.workflowId}` + ) + } + if (isBlockProtected(input.blockId, currentState.blocks)) { + throw new OrchestrationError( + 'locked', + `Block ${input.blockId} is locked or inside a locked container and cannot be updated` + ) + } + if (input.enabled && hasDisabledAncestor(input.blockId, currentState.blocks)) { + throw new OrchestrationError( + 'validation', + `Cannot enable block ${input.blockId} while one of its parent containers is disabled. Enable the parent first.` + ) + } + + const affectedBlockIds = new Set<string>([input.blockId]) + if (targetBlock.type === 'loop' || targetBlock.type === 'parallel') { + for (const descendantId of findDescendants(input.blockId, currentState.blocks)) { + if (!isBlockProtected(descendantId, currentState.blocks)) { + affectedBlockIds.add(descendantId) + } + } + } + if (targetBlock.enabled === input.enabled) { + return { + changed: false, + workflowName: active.name, + affectedBlockIds: [input.blockId], + state: currentState, + } + } + + const nextBlocks = { ...currentState.blocks } + for (const blockId of affectedBlockIds) { + nextBlocks[blockId] = { ...nextBlocks[blockId], enabled: input.enabled } + } + const nextState: WorkflowState = { + ...currentState, + blocks: nextBlocks, + lastSaved: Date.now(), + } + const saveResult = await saveWorkflowToNormalizedTables(context.workflowId, nextState, tx) + if (!saveResult.success) { + throw new Error(saveResult.error || 'Failed to save workflow state') + } + const [updated] = await tx + .update(workflow) + .set({ lastSynced: new Date(), updatedAt: new Date() }) + .where( + and( + eq(workflow.id, context.workflowId), + eq(workflow.workspaceId, context.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .returning({ id: workflow.id }) + if (!updated) throw new OrchestrationError('not_found', 'Workflow not found') + return { + changed: true, + workflowName: active.name, + affectedBlockIds: [...affectedBlockIds], + state: nextState, + } + }) + }, + projectAudit: ({ input, context, result }) => + result.changed + ? { + action: AuditAction.WORKFLOW_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: context.workflowId, + resourceName: result.workflowName, + description: `${input.enabled ? 'Enabled' : 'Disabled'} workflow block "${input.blockId}"`, + metadata: { + op: 'set_block_enabled', + blockId: input.blockId, + enabled: input.enabled, + affectedBlockIds: result.affectedBlockIds, + source: 'copilot', + }, + } + : [], + afterSuccess: ({ context, result }) => + result.changed ? notifyWorkflowUpdated(context.workflowId) : undefined, +}) diff --git a/apps/sim/lib/workflows/application/update-workflow-deployment-settings.ts b/apps/sim/lib/workflows/application/update-workflow-deployment-settings.ts new file mode 100644 index 00000000000..8fb3038dcb7 --- /dev/null +++ b/apps/sim/lib/workflows/application/update-workflow-deployment-settings.ts @@ -0,0 +1,77 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import { db, workflow } from '@sim/db' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { eq } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { + PublicApiNotAllowedError, + validatePublicApiAllowed, +} from '@/ee/access-control/utils/permission-check' + +export interface UpdateWorkflowPublicApiInput { + workflowId: string + assertedWorkspaceId?: string + isPublicApi: boolean +} + +export const updateWorkflowPublicApi = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.updatePublicApi, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: UpdateWorkflowPublicApiInput + }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, input, context }) { + if (principal.kind !== 'session') { + throw new Error('Workflow public API settings require a session principal') + } + try { + await assertWorkflowMutable(context.workflowId) + if (input.isPublicApi) { + await validatePublicApiAllowed(principal.userId, context.workspaceId) + } + } catch (error) { + if (error instanceof WorkflowLockedError) { + throw new OrchestrationError('locked', error.message) + } + if (error instanceof PublicApiNotAllowedError) { + throw new OrchestrationError('forbidden', 'Public API access is disabled') + } + throw error + } + + const [updated] = await db + .update(workflow) + .set({ isPublicApi: input.isPublicApi }) + .where(eq(workflow.id, context.workflowId)) + .returning({ id: workflow.id }) + if (!updated) throw new OrchestrationError('not_found', 'Workflow not found') + return { + workflowId: context.workflowId, + workflowName: context.workflow.name, + workspaceId: context.workspaceId, + isPublicApi: input.isPublicApi, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.WORKFLOW_PUBLIC_API_TOGGLED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.workflowId, + resourceName: result.workflowName, + description: `${result.isPublicApi ? 'Enabled' : 'Disabled'} public API for workflow "${result.workflowName}"`, + metadata: { isPublicApi: result.isPublicApi }, + }), + afterSuccess: ({ result }) => notifyWorkflowUpdated(result.workflowId), +}) diff --git a/apps/sim/lib/workflows/application/update-workflow.ts b/apps/sim/lib/workflows/application/update-workflow.ts index 2760ff20c62..d697250a93f 100644 --- a/apps/sim/lib/workflows/application/update-workflow.ts +++ b/apps/sim/lib/workflows/application/update-workflow.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { @@ -6,11 +7,16 @@ import { FolderLockedError, WorkflowLockedError, } from '@sim/platform-authz/workflow' +import type { WorkspaceUseCaseAuditEntry } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { notifyWorkflowUpdated } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' -import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { + type ActiveWorkflowApplicationContext, + resolveActiveWorkflowApplicationContext, +} from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' import { requireWorkflowTransition } from '@/lib/workflows/application/transition-result' @@ -28,66 +34,238 @@ export interface UpdateWorkflowInput { name?: string description?: string | null folderPath?: string + folderId?: string | null + sortOrder?: number } -export const updateWorkflow = defineAuthorizedWorkflowUseCase({ - operation: workflowOperations.update, - resolveContext: ({ principal, input }: { principal: Principal; input: UpdateWorkflowInput }) => - resolveActiveWorkflowApplicationContext({ - workflowId: input.workflowId, - assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), - }), - async execute({ principal, input, context }) { - const resolution = - input.folderPath === undefined +export interface UpdateWorkflowPolicyInput extends UpdateWorkflowInput { + locked?: boolean + forkSyncExcluded?: boolean +} + +export type AppliedWorkflowUpdate = + | 'name' + | 'description' + | 'folder' + | 'sortOrder' + | 'locked' + | 'forkSyncExcluded' + +interface WorkflowUpdateResult { + workflow: NonNullable<Awaited<ReturnType<typeof updateWorkflowRecord>>['workflow']> + workspaceId: string + folderPath: string + changes: AppliedWorkflowUpdate[] + deployment: { + isDeployed: boolean + deployedAt: Date | null + runCount: number + lastRunAt: Date | null + } +} + +function resolveWorkflowUpdateContext({ + principal, + input, +}: { + principal: Principal + input: UpdateWorkflowInput +}) { + return resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }) +} + +async function requireMutableWorkflowUpdate( + context: ActiveWorkflowApplicationContext, + input: UpdateWorkflowPolicyInput, + targetFolderId: string | null | undefined +): Promise<void> { + const hasContentUpdate = + input.name !== undefined || + input.description !== undefined || + input.folderPath !== undefined || + input.folderId !== undefined || + input.sortOrder !== undefined + try { + if (hasContentUpdate) await assertWorkflowMutable(context.workflowId) + if (targetFolderId !== undefined) await assertFolderMutable(targetFolderId) + } catch (error) { + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + } +} + +function changedFields( + input: UpdateWorkflowPolicyInput, + context: ActiveWorkflowApplicationContext, + updated: NonNullable<Awaited<ReturnType<typeof updateWorkflowRecord>>['workflow']> +): AppliedWorkflowUpdate[] { + const changes: AppliedWorkflowUpdate[] = [] + if (input.name !== undefined && updated.name !== context.workflow.name) changes.push('name') + if ( + input.description !== undefined && + updated.description !== (context.workflow.description ?? null) + ) { + changes.push('description') + } + if ( + (input.folderPath !== undefined || input.folderId !== undefined) && + updated.folderId !== (context.workflow.folderId ?? null) + ) { + changes.push('folder') + } + if (input.sortOrder !== undefined && updated.sortOrder !== context.workflow.sortOrder) { + changes.push('sortOrder') + } + if (input.locked !== undefined && updated.locked !== context.workflow.locked) { + changes.push('locked') + } + if ( + input.forkSyncExcluded !== undefined && + updated.forkSyncExcluded !== context.workflow.forkSyncExcluded + ) { + changes.push('forkSyncExcluded') + } + return changes +} + +async function executeWorkflowUpdate(args: { + principal: Principal + input: UpdateWorkflowPolicyInput + context: ActiveWorkflowApplicationContext +}): Promise<WorkflowUpdateResult> { + const { principal, input, context } = args + if (input.folderPath !== undefined && input.folderId !== undefined) { + throw new OrchestrationError('validation', 'Provide either folderPath or folderId, not both') + } + const resolution = + input.folderId !== undefined + ? { + folderId: input.folderId, + index: await loadActiveFolderPathIndex(context.workspaceId, 'workflow'), + } + : input.folderPath === undefined ? undefined : await resolveWorkflowFolderPath(context.workspaceId, input.folderPath) + if (resolution?.folderId && !resolution.index.pathById.has(resolution.folderId)) { + throw new OrchestrationError('not_found', 'Folder not found') + } + await requireMutableWorkflowUpdate(context, input, resolution?.folderId) - try { - await assertWorkflowMutable(context.workflowId) - if (resolution) await assertFolderMutable(resolution.folderId) - } catch (error) { - if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { - throw new OrchestrationError('locked', error.message) - } - throw error - } + const transition = await updateWorkflowRecord({ + workflowId: context.workflowId, + userId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + workspaceId: context.workspaceId, + currentName: context.workflow.name, + currentFolderId: context.workflow.folderId, + currentLocked: context.workflow.locked, + currentForkSyncExcluded: context.workflow.forkSyncExcluded, + name: input.name, + description: input.description, + folderId: resolution?.folderId, + sortOrder: input.sortOrder, + locked: input.locked, + forkSyncExcluded: input.forkSyncExcluded, + }) + requireWorkflowTransition(transition, 'Failed to update workflow') + if (!transition.workflow) throw new Error('Successful workflow update returned no workflow') - const transition = await updateWorkflowRecord({ - workflowId: context.workflowId, - userId: resolvePrincipalAttribution(principal, { - workspaceBillingOwnerUserId: context.billedAccountUserId, - }).attributedUserId, - workspaceId: context.workspaceId, - currentName: context.workflow.name, - currentFolderId: context.workflow.folderId, - name: input.name, - description: input.description, - folderId: resolution?.folderId, - }) - requireWorkflowTransition(transition, 'Failed to update workflow') - if (!transition.workflow) throw new Error('Successful workflow update returned no workflow') + const folderIndex = + resolution?.index ?? + (await loadActiveFolderPathIndex(context.workspaceId, 'workflow', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + })) + const changes = changedFields(input, context, transition.workflow) + logger.info('Updated workflow', { + workspaceId: context.workspaceId, + workflowId: context.workflowId, + principalKind: principal.kind, + changes, + }) + return { + workflow: transition.workflow, + workspaceId: context.workspaceId, + folderPath: workflowFolderPathForId(folderIndex, transition.workflow.folderId), + changes, + deployment: { + isDeployed: context.workflow.isDeployed, + deployedAt: context.workflow.deployedAt, + runCount: context.workflow.runCount, + lastRunAt: context.workflow.lastRunAt, + }, + } +} - const folderIndex = - resolution?.index ?? - (await loadActiveFolderPathIndex(context.workspaceId, 'workflow', undefined, { - maxRows: MAX_FOLDERS_PER_WORKSPACE, - })) - logger.info('Updated workflow', { - workspaceId: context.workspaceId, - workflowId: context.workflowId, - principalKind: principal.kind, +function projectWorkflowUpdateAudit(args: { + input: UpdateWorkflowPolicyInput + context: ActiveWorkflowApplicationContext + result: WorkflowUpdateResult +}): WorkspaceUseCaseAuditEntry[] { + const { input, context, result } = args + const entries: WorkspaceUseCaseAuditEntry[] = [] + const metadataChanges = result.changes.filter( + (field) => field !== 'locked' && field !== 'forkSyncExcluded' + ) + if (metadataChanges.length > 0) { + entries.push({ + action: AuditAction.WORKFLOW_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: context.workflowId, + resourceName: result.workflow.name, + description: `Updated workflow "${result.workflow.name}"`, + metadata: { updatedFields: metadataChanges }, }) - return { - workflow: transition.workflow, - workspaceId: context.workspaceId, - folderPath: workflowFolderPathForId(folderIndex, transition.workflow.folderId), - deployment: { - isDeployed: context.workflow.isDeployed, - deployedAt: context.workflow.deployedAt, - runCount: context.workflow.runCount, - lastRunAt: context.workflow.lastRunAt, - }, - } - }, + } + if (result.changes.includes('locked')) { + entries.push({ + action: input.locked ? AuditAction.WORKFLOW_LOCKED : AuditAction.WORKFLOW_UNLOCKED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: context.workflowId, + resourceName: result.workflow.name, + description: `${input.locked ? 'Locked' : 'Unlocked'} workflow "${result.workflow.name}"`, + metadata: { locked: input.locked }, + }) + } + if (result.changes.includes('forkSyncExcluded')) { + entries.push({ + action: input.forkSyncExcluded + ? AuditAction.WORKFLOW_FORK_SYNC_EXCLUDED + : AuditAction.WORKFLOW_FORK_SYNC_INCLUDED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: context.workflowId, + resourceName: result.workflow.name, + description: `${input.forkSyncExcluded ? 'Excluded' : 'Included'} workflow "${result.workflow.name}" ${input.forkSyncExcluded ? 'from' : 'in'} fork sync`, + metadata: { forkSyncExcluded: input.forkSyncExcluded }, + }) + } + return entries +} + +function notifyAfterWorkflowUpdate(args: { + context: ActiveWorkflowApplicationContext + result: WorkflowUpdateResult +}) { + return args.result.changes.length > 0 ? notifyWorkflowUpdated(args.context.workflowId) : undefined +} + +export const updateWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.update, + resolveContext: resolveWorkflowUpdateContext, + execute: executeWorkflowUpdate, + projectAudit: projectWorkflowUpdateAudit, + afterSuccess: notifyAfterWorkflowUpdate, +}) + +export const updateWorkflowPolicy = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.updatePolicy, + resolveContext: resolveWorkflowUpdateContext, + execute: executeWorkflowUpdate, + projectAudit: projectWorkflowUpdateAudit, + afterSuccess: notifyAfterWorkflowUpdate, }) diff --git a/apps/sim/lib/workflows/application/workflow-crud.test.ts b/apps/sim/lib/workflows/application/workflow-crud.test.ts index 64930d7bce7..162b2e149d9 100644 --- a/apps/sim/lib/workflows/application/workflow-crud.test.ts +++ b/apps/sim/lib/workflows/application/workflow-crud.test.ts @@ -20,6 +20,9 @@ const mocks = vi.hoisted(() => ({ loadFolderIndex: vi.fn(), listVersions: vi.fn(), readVersion: vi.fn(), + loadNormalized: vi.fn(), + notifyWorkflowUpdated: vi.fn(), + workflowCreated: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -81,6 +84,15 @@ vi.mock('@/lib/workflows/input-format', () => ({ vi.mock('@/lib/workflows/persistence/utils', () => ({ listWorkflowVersions: mocks.listVersions, getWorkflowDeploymentVersion: mocks.readVersion, + loadWorkflowFromNormalizedTables: mocks.loadNormalized, +})) + +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkflowUpdated: mocks.notifyWorkflowUpdated, +})) + +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { workflowCreated: mocks.workflowCreated }, })) import { createWorkflow } from '@/lib/workflows/application/create-workflow' @@ -88,6 +100,7 @@ import { deleteWorkflow } from '@/lib/workflows/application/delete-workflow' import { listWorkflowVersions } from '@/lib/workflows/application/list-workflow-versions' import { readWorkflow } from '@/lib/workflows/application/read-workflow' import { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version' +import { updateWorkflow } from '@/lib/workflows/application/update-workflow' const WORKSPACE_ID = 'workspace-1' const WORKFLOW_ID = 'workflow-1' @@ -129,6 +142,21 @@ const workspacePrincipal = { workspaceId: WORKSPACE_ID, keyId: 'workspace-key-1', } +const executorPrincipal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'user-1', + workspaceId: WORKSPACE_ID, + delegationId: 'executor-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-08-01T00:00:00Z'), + expiresAt: new Date('2999-08-01T00:00:00Z'), + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: WORKFLOW_ID, + executionId: 'origin-run', + }, +} describe('authorized workflow CRUD and version reads', () => { beforeEach(() => { @@ -154,11 +182,22 @@ describe('authorized workflow CRUD and version reads', () => { }, }) mocks.loadSnapshot.mockResolvedValue({ workflowRecord, normalizedData: { blocks: {} } }) + mocks.loadNormalized.mockResolvedValue({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + isFromNormalizedTables: true, + }) mocks.deleteRecord.mockResolvedValue({ success: true, archived: true, workflow: { id: WORKFLOW_ID, name: workflowRecord.name, workspaceId: WORKSPACE_ID }, }) + mocks.updateRecord.mockResolvedValue({ + success: true, + workflow: workflowRecord, + }) mocks.listVersions.mockResolvedValue({ versions: [] }) mocks.readVersion.mockResolvedValue({ id: 'version-1', @@ -194,6 +233,10 @@ describe('authorized workflow CRUD and version reads', () => { }), }) ) + expect(mocks.notifyWorkflowUpdated).toHaveBeenCalledWith(WORKFLOW_ID) + expect(mocks.workflowCreated).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: WORKFLOW_ID, workspaceId: WORKSPACE_ID }) + ) }) it('uses the billing owner only for the workspace key legacy user column', async () => { @@ -249,6 +292,81 @@ describe('authorized workflow CRUD and version reads', () => { expect(mocks.loadSnapshot).not.toHaveBeenCalled() }) + it('rejects executor workflow mutations before canonical resource loading', async () => { + const executor = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'user-1', + workspaceId: WORKSPACE_ID, + delegationId: 'delegation-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-08-01T00:00:00Z'), + expiresAt: new Date('2999-01-01T00:00:00Z'), + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: WORKFLOW_ID, + executionId: 'execution-1', + }, + } + + await expect( + updateWorkflow.execute({ + principal: executor, + input: { workflowId: WORKFLOW_ID, name: 'Forged target' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled() + expect(mocks.updateRecord).not.toHaveBeenCalled() + }) + + it('allows executor reads only after canonical same-workspace binding and permission recheck', async () => { + await readWorkflow.execute({ + principal: executorPrincipal, + input: { workflowId: WORKFLOW_ID }, + }) + + expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({ + workflowId: WORKFLOW_ID, + assertedWorkspaceId: WORKSPACE_ID, + }) + expect(mocks.resolvePermission).toHaveBeenCalledWith('user-1', WORKSPACE_ID, null, undefined, { + forUpdate: undefined, + }) + expect(mocks.loadSnapshot).toHaveBeenCalledWith(WORKFLOW_ID) + }) + + it('rejects executor reads whose canonical target is outside the signed origin workspace', async () => { + mocks.resolveWorkflowContext.mockResolvedValueOnce({ + ...workflowContext, + workspaceId: 'workspace-other', + workflow: { ...workflowRecord, workspaceId: 'workspace-other' }, + }) + + await expect( + readWorkflow.execute({ + principal: executorPrincipal, + input: { workflowId: WORKFLOW_ID }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadSnapshot).not.toHaveBeenCalled() + }) + + it('rechecks current permission for every workflow mutation', async () => { + mocks.resolvePermission.mockResolvedValueOnce('write').mockResolvedValueOnce('read') + + await updateWorkflow.execute({ + principal: personalPrincipal, + input: { workflowId: WORKFLOW_ID, name: 'First update' }, + }) + await expect( + updateWorkflow.execute({ + principal: personalPrincipal, + input: { workflowId: WORKFLOW_ID, name: 'Second update' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.updateRecord).toHaveBeenCalledTimes(1) + }) + it('does not audit an authoritative delete no-op', async () => { mocks.deleteRecord.mockResolvedValue({ success: true, @@ -264,7 +382,7 @@ describe('authorized workflow CRUD and version reads', () => { expect(mocks.recordAudit).not.toHaveBeenCalled() }) - it('supports bounded v2 and unbounded internal version listing', async () => { + it('bounds both paginated and legacy unpaginated version listing', async () => { await listWorkflowVersions.execute({ principal: workspacePrincipal, input: { workflowId: WORKFLOW_ID, limit: 50 }, @@ -281,9 +399,19 @@ describe('authorized workflow CRUD and version reads', () => { }) ).resolves.toEqual({ versions: [], hasMore: false }) expect(mocks.listVersions).toHaveBeenLastCalledWith(WORKFLOW_ID, { - limit: undefined, + limit: 1001, afterVersion: undefined, }) + + mocks.listVersions.mockResolvedValue({ + versions: Array.from({ length: 1001 }, (_, index) => ({ id: `version-${index}` })), + }) + await expect( + listWorkflowVersions.execute({ + principal: workspacePrincipal, + input: { workflowId: WORKFLOW_ID }, + }) + ).rejects.toThrow('Workflow version list exceeds the 1000 row limit') }) it('reads one version only after canonical workflow authorization', async () => { diff --git a/apps/sim/lib/workflows/application/workflow-deployments.test.ts b/apps/sim/lib/workflows/application/workflow-deployments.test.ts index 3863d2f1a40..add3175f73f 100644 --- a/apps/sim/lib/workflows/application/workflow-deployments.test.ts +++ b/apps/sim/lib/workflows/application/workflow-deployments.test.ts @@ -14,6 +14,8 @@ const { MockWorkflowLockedError, mocks } = vi.hoisted(() => { audit: vi.fn(), deploy: vi.fn(), findPrevious: vi.fn(), + notifyReverted: vi.fn(), + revert: vi.fn(), resolveContext: vi.fn(), resolvePermission: vi.fn(), undeploy: vi.fn(), @@ -22,7 +24,10 @@ const { MockWorkflowLockedError, mocks } = vi.hoisted(() => { }) vi.mock('@sim/audit', () => ({ - AuditAction: { WORKFLOW_UNDEPLOYED: 'workflow.undeployed' }, + AuditAction: { + WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted', + WORKFLOW_UNDEPLOYED: 'workflow.undeployed', + }, AuditResourceType: { WORKFLOW: 'workflow' }, recordAudit: mocks.audit, })) @@ -50,15 +55,26 @@ vi.mock('@/lib/workflows/orchestration', () => ({ performActivateVersion: mocks.activate, performFullDeploy: mocks.deploy, performFullUndeploy: mocks.undeploy, + performRevertToVersion: mocks.revert, })) vi.mock('@/lib/workflows/persistence/utils', () => ({ findPreviousDeploymentVersion: mocks.findPrevious, + updateDeploymentVersionMetadata: vi.fn(), +})) + +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkflowReverted: mocks.notifyReverted, +})) + +vi.mock('@/lib/workflows/deployment-status', () => ({ + checkNeedsRedeployment: vi.fn(), })) import { activateWorkflowVersion, deployWorkflow, + revertWorkflowVersion, undeployWorkflow, } from '@/lib/workflows/application/deployments' @@ -124,6 +140,7 @@ describe('workflow deployment application use cases', () => { warnings: [], }) mocks.findPrevious.mockResolvedValue({ ok: true, version: 3 }) + mocks.revert.mockResolvedValue({ success: true, lastSaved: 12345 }) }) it.each(adminPrincipals)( @@ -145,7 +162,7 @@ describe('workflow deployment application use cases', () => { workflowId: 'workflow-1', userId: actorUserId, actorId: actorUserId, - captureAnalytics: false, + ...(principal.kind === 'delegated' ? { captureAnalytics: false } : {}), versionName: 'Version 4', versionDescription: 'Production release', requestId: 'request-1', @@ -172,6 +189,32 @@ describe('workflow deployment application use cases', () => { expect(mocks.deploy).not.toHaveBeenCalled() }) + it('rejects executor deployment transitions before canonical lookup', async () => { + await expect( + deployWorkflow.execute({ + principal: { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'executor-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-08-08T00:00:00Z'), + expiresAt: new Date('2999-08-08T00:00:00Z'), + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }, + input: { workflowId: 'workflow-1', requestId: 'request-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveContext).not.toHaveBeenCalled() + expect(mocks.deploy).not.toHaveBeenCalled() + }) + it('requires current admin permission before deployment', async () => { mocks.resolvePermission.mockResolvedValueOnce('write') @@ -211,7 +254,35 @@ describe('workflow deployment application use cases', () => { ) }) - it('activates an explicit version with analytics disabled in orchestration', async () => { + it('projects revert audit and notification exactly once outside legacy orchestration', async () => { + await revertWorkflowVersion.execute({ + principal: adminPrincipals[2].principal, + input: { workflowId: 'workflow-1', version: 3 }, + }) + + expect(mocks.revert).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-1', + version: 3, + userId: 'delegated-user', + captureAnalytics: false, + projectLegacyAudit: false, + notifyRealtime: false, + }) + ) + expect(mocks.audit).toHaveBeenCalledOnce() + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'workflow.deployment_reverted', + resourceId: 'workflow-1', + metadata: expect.objectContaining({ targetVersion: '3' }), + }) + ) + expect(mocks.notifyReverted).toHaveBeenCalledOnce() + expect(mocks.notifyReverted).toHaveBeenCalledWith('workflow-1', 12345) + }) + + it('keeps human activation analytics enabled for durable post-activation capture', async () => { await activateWorkflowVersion.execute({ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, input: { @@ -230,17 +301,41 @@ describe('workflow deployment application use cases', () => { version: 2, userId: 'user-1', actorId: 'user-1', - captureAnalytics: false, requestId: 'request-3', idempotencyKey: 'activation-1', }) ) }) + it('forwards optional version metadata through the activation command', async () => { + await activateWorkflowVersion.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workflowId: 'workflow-1', + version: 2, + transition: 'activate', + requestId: 'request-metadata', + name: 'Release 2', + description: 'Production', + }, + }) + + expect(mocks.activate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Release 2', + description: 'Production', + }) + ) + }) + it('resolves the previous active version for an implicit rollback', async () => { const result = await activateWorkflowVersion.execute({ principal: { kind: 'personal_api_key', userId: 'key-user', keyId: 'personal-key' }, - input: { workflowId: 'workflow-1', transition: 'rollback', requestId: 'request-4' }, + input: { + workflowId: 'workflow-1', + transition: 'rollback', + requestId: 'request-4', + }, }) expect(mocks.findPrevious).toHaveBeenCalledWith('workflow-1') diff --git a/apps/sim/lib/workflows/application/workflow-vfs.test.ts b/apps/sim/lib/workflows/application/workflow-vfs.test.ts new file mode 100644 index 00000000000..62af1155e8a --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-vfs.test.ts @@ -0,0 +1,279 @@ +/** + * @vitest-environment node + */ +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { FolderLockedError, WorkflowLockedError, mocks } = vi.hoisted(() => { + class WorkflowLockedError extends Error {} + class FolderLockedError extends Error {} + return { + WorkflowLockedError, + FolderLockedError, + mocks: { + assertFolderMutable: vi.fn(), + assertWorkflowMutable: vi.fn(), + audit: vi.fn(), + createFolder: vi.fn(), + deleteFolder: vi.fn(), + deleteWorkflow: vi.fn(), + duplicateWorkflow: vi.fn(), + loadFolderIndex: vi.fn(), + logError: vi.fn(), + notifyFolder: vi.fn(), + notifyWorkflow: vi.fn(), + permission: vi.fn(), + relocateFolder: vi.fn(), + resolveContext: vi.fn(), + updateWorkflow: vi.fn(), + }, + } +}) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + FOLDER_CREATED: 'folder.created', + FOLDER_DELETED: 'folder.deleted', + FOLDER_MOVED: 'folder.moved', + WORKFLOW_DELETED: 'workflow.deleted', + WORKFLOW_DUPLICATED: 'workflow.duplicated', + WORKFLOW_UPDATED: 'workflow.updated', + }, + AuditResourceType: { FOLDER: 'folder', WORKFLOW: 'workflow' }, + recordAudit: mocks.audit, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ + error: mocks.logError, + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mocks.assertFolderMutable, + assertWorkflowMutable: mocks.assertWorkflowMutable, + FolderLockedError, + WorkflowLockedError, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.permission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.resolveContext, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mocks.loadFolderIndex, +})) + +vi.mock('@/lib/folders/orchestration', () => ({ + createFolderAtPath: mocks.createFolder, + deleteFolderByPath: mocks.deleteFolder, + relocateFolderByPath: mocks.relocateFolder, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + deleteWorkflowRecord: mocks.deleteWorkflow, + updateWorkflowRecord: mocks.updateWorkflow, +})) + +vi.mock('@/lib/workflows/persistence/duplicate', () => ({ + duplicateWorkflow: mocks.duplicateWorkflow, +})) + +vi.mock('@/lib/realtime/notify', () => ({ + notifyFolderResourceChanged: mocks.notifyFolder, + notifyWorkflowUpdated: mocks.notifyWorkflow, +})) + +import { + createWorkflowVfsFolders, + moveWorkflowVfsItems, +} from '@/lib/workflows/application/workflow-vfs' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), +} +const emptyIndex = { + rowById: new Map(), + pathById: new Map(), + idByPath: new Map(), +} + +describe('workflow VFS application commands', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.resolveContext.mockResolvedValue(workspaceContext) + mocks.permission.mockResolvedValue('write') + mocks.assertFolderMutable.mockResolvedValue(undefined) + mocks.assertWorkflowMutable.mockResolvedValue(undefined) + mocks.loadFolderIndex.mockResolvedValue(emptyIndex) + }) + + it('rejects a forged cross-workspace delegation before loading the protected VFS index', async () => { + await expect( + moveWorkflowVfsItems.execute({ + principal: { ...principal, workspaceId: 'workspace-2' }, + input: { + workspaceId: 'workspace-1', + sources: [{ source: 'workflows/One', segments: ['One'] }], + destination: { segments: ['Archive'], trailingSlash: true }, + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadFolderIndex).not.toHaveBeenCalled() + }) + + it('rechecks current permission before canonical index loading', async () => { + mocks.permission.mockResolvedValueOnce(null) + + await expect( + moveWorkflowVfsItems.execute({ + principal, + input: { + workspaceId: 'workspace-1', + sources: [{ source: 'workflows/One', segments: ['One'] }], + destination: { segments: [], trailingSlash: false }, + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadFolderIndex).not.toHaveBeenCalled() + }) + + it('keeps partial failures bounded while auditing and notifying only durable successes', async () => { + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'One', folderId: null }, + { id: 'workflow-2', name: 'Two', folderId: null }, + ]) + queueTableRows(schemaMock.workflow, [{ id: 'workflow-1', name: 'One', folderId: null }]) + queueTableRows(schemaMock.workflow, [{ id: 'workflow-2', name: 'Two', folderId: null }]) + mocks.updateWorkflow + .mockResolvedValueOnce({ + success: true, + workflow: { id: 'workflow-1', name: 'One', folderId: null }, + }) + .mockResolvedValueOnce({ success: false, error: 'Workflow is locked', errorCode: 'locked' }) + + const result = await moveWorkflowVfsItems.execute({ + principal, + input: { + workspaceId: 'workspace-1', + sources: [ + { source: 'workflows/One', segments: ['One'] }, + { source: 'workflows/Two', segments: ['Two'] }, + ], + destination: { segments: [], trailingSlash: true }, + }, + }) + + expect(mocks.logError).not.toHaveBeenCalled() + expect(result.outcomes).toEqual([ + expect.objectContaining({ source: 'workflows/One', resourceId: 'workflow-1' }), + expect.objectContaining({ source: 'workflows/Two', error: 'Workflow is locked' }), + ]) + expect(mocks.audit).toHaveBeenCalledOnce() + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'workflow.updated', + resourceId: 'workflow-1', + metadata: expect.objectContaining({ operation: 'workflows.vfs.move' }), + }) + ) + expect(mocks.notifyWorkflow).toHaveBeenCalledWith('workflow-1') + expect(mocks.notifyWorkflow).not.toHaveBeenCalledWith('workflow-2') + }) + + it('propagates an unexpected mutation failure without projecting a partial outcome', async () => { + queueTableRows(schemaMock.workflow, [{ id: 'workflow-1', name: 'One', folderId: null }]) + queueTableRows(schemaMock.workflow, [{ id: 'workflow-1', name: 'One', folderId: null }]) + mocks.updateWorkflow.mockRejectedValueOnce(new Error('postgres password=secret')) + + await expect( + moveWorkflowVfsItems.execute({ + principal, + input: { + workspaceId: 'workspace-1', + sources: [{ source: 'workflows/One', segments: ['One'] }], + destination: { segments: [], trailingSlash: true }, + }, + }) + ).rejects.toThrow('postgres password=secret') + + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.notifyWorkflow).not.toHaveBeenCalled() + expect(mocks.notifyFolder).not.toHaveBeenCalled() + }) + + it('owns mkdir path planning and audits only the folder it creates', async () => { + const created = { + id: 'folder-1', + name: 'Project Plans', + parentId: null, + } + const createdIndex = { + rowById: new Map([[created.id, created]]), + pathById: new Map([[created.id, '/Project%20Plans']]), + idByPath: new Map([['/Project%20Plans', created.id]]), + } + mocks.loadFolderIndex.mockResolvedValueOnce(emptyIndex).mockResolvedValueOnce(createdIndex) + mocks.createFolder.mockResolvedValue({ + success: true, + folder: created, + path: '/Project%20Plans', + }) + + const result = await createWorkflowVfsFolders.execute({ + principal, + input: { + workspaceId: 'workspace-1', + paths: [{ source: 'workflows/Project Plans', segments: ['Project Plans'] }], + }, + }) + + expect(result.outcomes).toEqual([ + expect.objectContaining({ resourceId: 'folder-1', targetSegments: ['Project Plans'] }), + ]) + expect(mocks.createFolder).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/Project%20Plans', + effects: false, + throwInfrastructure: true, + }) + ) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'folder.created', + resourceId: 'folder-1', + metadata: expect.objectContaining({ operation: 'workflows.vfs.folders.create' }), + }) + ) + expect(mocks.notifyFolder).toHaveBeenCalledWith('workflow', 'workspace-1') + }) +}) diff --git a/apps/sim/lib/workflows/application/workflow-vfs.ts b/apps/sim/lib/workflows/application/workflow-vfs.ts new file mode 100644 index 00000000000..deb32724d46 --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-vfs.ts @@ -0,0 +1,851 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { + assertFolderMutable, + assertWorkflowMutable, + FolderLockedError, + WorkflowLockedError, +} from '@sim/platform-authz/workflow' +import { and, eq, isNull } from 'drizzle-orm' +import { + asOrchestrationError, + OrchestrationError, + type OrchestrationErrorCode, +} from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { + createFolderAtPath, + deleteFolderByPath, + relocateFolderByPath, +} from '@/lib/folders/orchestration' +import { + buildFolderPath, + FolderPathError, + type FolderPathIndex, + parseFolderPath, +} from '@/lib/folders/paths' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { + notifyFolderResourceChanged, + notifyWorkflowDeleted, + notifyWorkflowUpdated, +} from '@/lib/realtime/notify' +import { VfsPathLimitError, validateVfsPathSegments } from '@/lib/vfs/limits' +import { encodeVfsPathSegments } from '@/lib/vfs/path' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { requireWorkflowTransition } from '@/lib/workflows/application/transition-result' +import { deleteWorkflowRecord, updateWorkflowRecord } from '@/lib/workflows/orchestration' +import { duplicateWorkflow as duplicateWorkflowRecord } from '@/lib/workflows/persistence/duplicate' +import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +const MAX_WORKFLOW_VFS_ITEMS = 100 +const MAX_WORKFLOW_VFS_INDEX_ROWS = 10_000 +const MAX_WORKFLOW_NAME_LENGTH = 200 + +export interface WorkflowVfsPathReference { + source: string + segments: string[] +} + +export interface WorkflowVfsDestination { + segments: string[] + trailingSlash: boolean +} + +export interface WorkflowVfsOutcome { + source: string + targetSegments?: string[] + resourceType: 'workflow' | 'folder' + resourceId?: string + error?: string +} + +export interface CreateWorkflowVfsFoldersInput { + workspaceId: string + paths: WorkflowVfsPathReference[] +} + +export interface TransferWorkflowVfsItemsInput { + workspaceId: string + sources: WorkflowVfsPathReference[] + destination: WorkflowVfsDestination +} + +export interface DeleteWorkflowVfsItemsInput { + workspaceId: string + paths: WorkflowVfsPathReference[] +} + +interface WorkflowVfsRow { + id: string + name: string + folderId: string | null +} + +interface CreatedFolderChange { + id: string + name: string + path: string +} + +interface MovedWorkflowChange { + id: string + name: string + previousFolderId: string | null + folderId: string | null +} + +interface MovedFolderChange { + id: string + name: string + sourcePath: string + destinationPath: string +} + +interface DuplicatedWorkflowChange { + id: string + name: string + sourceWorkflowId: string +} + +interface DeletedWorkflowChange { + id: string + name: string +} + +interface DeletedFolderChange { + id: string + name: string + path: string + workflows: number + folders: number +} + +interface WorkflowVfsIndexState { + folderIndex: FolderPathIndex + workflows: WorkflowVfsRow[] + createdFolders: CreatedFolderChange[] +} + +interface ResolvedWorkflowSource { + source: string + workflow?: WorkflowVfsRow + folderId?: string + error?: string +} + +interface DestinationPlan { + dirMode: boolean + folderSegments: string[] + leafName?: string + ensureFolderId(): Promise<string | null> +} + +function canonicalSegmentsKey(segments: readonly string[]): string { + return encodeVfsPathSegments([...segments]) +} + +function normalizeReferences( + references: readonly WorkflowVfsPathReference[] +): WorkflowVfsPathReference[] { + if (references.length > MAX_WORKFLOW_VFS_ITEMS) { + throw new OrchestrationError( + 'validation', + `Workflow VFS commands cannot exceed ${MAX_WORKFLOW_VFS_ITEMS} items` + ) + } + const byPath = new Map<string, WorkflowVfsPathReference>() + for (const reference of references) { + try { + validateVfsPathSegments(reference.segments) + } catch (error) { + if (error instanceof VfsPathLimitError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + const key = canonicalSegmentsKey(reference.segments) + if (!byPath.has(key)) byPath.set(key, reference) + } + if (byPath.size === 0) throw new OrchestrationError('validation', 'At least one path is required') + return [...byPath.values()] +} + +function validateDestination(destination: WorkflowVfsDestination): void { + try { + validateVfsPathSegments(destination.segments) + } catch (error) { + if (error instanceof VfsPathLimitError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } +} + +async function loadWorkflowVfsIndex( + context: ActiveWorkspaceApplicationContext +): Promise<WorkflowVfsIndexState> { + const [folderIndex, workflows] = await Promise.all([ + loadActiveFolderPathIndex(context.workspaceId, 'workflow', db, { + maxRows: MAX_WORKFLOW_VFS_INDEX_ROWS, + }), + db + .select({ id: workflow.id, name: workflow.name, folderId: workflow.folderId }) + .from(workflow) + .where(and(eq(workflow.workspaceId, context.workspaceId), isNull(workflow.archivedAt))) + .limit(MAX_WORKFLOW_VFS_INDEX_ROWS + 1), + ]) + if (workflows.length > MAX_WORKFLOW_VFS_INDEX_ROWS) { + throw new Error(`Workflow VFS index exceeds the ${MAX_WORKFLOW_VFS_INDEX_ROWS} row limit`) + } + return { folderIndex, workflows, createdFolders: [] } +} + +function folderSegmentsForId(index: FolderPathIndex, folderId: string | null): string[] { + if (!folderId) return [] + const path = index.pathById.get(folderId) + if (!path) throw new Error('Workflow references an inactive or missing folder') + return parseFolderPath(path) +} + +function resolveWorkflowSources( + state: WorkflowVfsIndexState, + references: readonly WorkflowVfsPathReference[] +): ResolvedWorkflowSource[] { + const workflowsByPath = new Map<string, WorkflowVfsRow>() + for (const row of state.workflows) { + const path = canonicalSegmentsKey([ + ...folderSegmentsForId(state.folderIndex, row.folderId), + row.name, + ]) + if (!workflowsByPath.has(path)) workflowsByPath.set(path, row) + } + const foldersByPath = new Map<string, string>() + for (const [folderId, path] of state.folderIndex.pathById) { + foldersByPath.set(canonicalSegmentsKey(parseFolderPath(path)), folderId) + } + + return references.map((reference) => { + if (reference.segments.length === 0) { + return { + source: reference.source, + error: 'Source must name a workflow or folder under workflows/', + } + } + const key = canonicalSegmentsKey(reference.segments) + const workflowRow = workflowsByPath.get(key) + if (workflowRow) return { source: reference.source, workflow: workflowRow } + const folderId = foldersByPath.get(key) + if (folderId) return { source: reference.source, folderId } + return { source: reference.source, error: `Not found: ${reference.source}` } + }) +} + +function throwFolderFailure(result: { error?: string; errorCode?: OrchestrationErrorCode }): never { + throw new OrchestrationError( + result.errorCode ?? 'internal', + result.errorCode === 'internal' + ? 'Workflow folder mutation failed' + : (result.error ?? 'Folder mutation failed') + ) +} + +async function reloadFolderIndex(state: WorkflowVfsIndexState, workspaceId: string): Promise<void> { + state.folderIndex = await loadActiveFolderPathIndex(workspaceId, 'workflow', db, { + maxRows: MAX_WORKFLOW_VFS_INDEX_ROWS, + }) +} + +async function ensureWorkflowFolderPath( + state: WorkflowVfsIndexState, + context: ActiveWorkspaceApplicationContext, + userId: string, + segments: readonly string[] +): Promise<string | null> { + let folderId: string | null = null + for (let position = 0; position < segments.length; position += 1) { + const path = buildFolderPath(segments.slice(0, position + 1)) + const existing = state.folderIndex.idByPath.get(path) + if (existing) { + folderId = existing + continue + } + + const result = await createFolderAtPath({ + resourceType: 'workflow', + workspaceId: context.workspaceId, + userId, + path, + effects: false, + throwInfrastructure: true, + maxFolderRows: MAX_WORKFLOW_VFS_INDEX_ROWS, + }) + if (!result.success || !result.folder) { + if (result.errorCode === 'conflict') { + await reloadFolderIndex(state, context.workspaceId) + const concurrentlyCreated = state.folderIndex.idByPath.get(path) + if (concurrentlyCreated) { + folderId = concurrentlyCreated + continue + } + } + throwFolderFailure(result) + } + + state.createdFolders.push({ id: result.folder.id, name: result.folder.name, path }) + await reloadFolderIndex(state, context.workspaceId) + folderId = result.folder.id + } + return folderId +} + +function planDestination( + input: TransferWorkflowVfsItemsInput, + state: WorkflowVfsIndexState, + context: ActiveWorkspaceApplicationContext, + userId: string, + sourceCount: number +): DestinationPlan { + const segments = input.destination.segments + const plan = ( + dirMode: boolean, + folderSegments: string[], + leafName?: string, + knownFolderId?: string | null + ): DestinationPlan => { + let memo: Promise<string | null> | undefined + return { + dirMode, + folderSegments, + leafName, + ensureFolderId: () => + (memo ??= + knownFolderId !== undefined + ? Promise.resolve(knownFolderId) + : folderSegments.length === 0 + ? Promise.resolve(null) + : ensureWorkflowFolderPath(state, context, userId, folderSegments)), + } + } + + if (segments.length === 0) return plan(true, [], undefined, null) + if (input.destination.trailingSlash) return plan(true, segments) + const existingFolderId = state.folderIndex.idByPath.get(buildFolderPath(segments)) + if (existingFolderId) return plan(true, segments, undefined, existingFolderId) + if (sourceCount > 1) { + throw new OrchestrationError( + 'validation', + `With multiple sources the destination must be a folder. "workflows/${canonicalSegmentsKey(segments)}" does not exist — end it with "/" to create it.` + ) + } + return plan(false, segments.slice(0, -1), segments.at(-1)) +} + +function expectedOutcomeMessage(error: unknown): string { + const classified = asOrchestrationError(error) + if (classified && classified.code !== 'internal') return classified.message + if ( + error instanceof WorkflowLockedError || + error instanceof FolderLockedError || + error instanceof FolderPathError + ) { + return error.message + } + throw error +} + +async function moveWorkflowRow(params: { + row: WorkflowVfsRow + targetName?: string + targetFolderId: string | null + context: ActiveWorkspaceApplicationContext + userId: string +}): Promise<MovedWorkflowChange> { + try { + await Promise.all([ + assertWorkflowMutable(params.row.id), + assertFolderMutable(params.targetFolderId), + ]) + } catch (error) { + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + } + + return db.transaction(async (tx) => { + const [current] = await tx + .select({ id: workflow.id, name: workflow.name, folderId: workflow.folderId }) + .from(workflow) + .where( + and( + eq(workflow.id, params.row.id), + eq(workflow.workspaceId, params.context.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .limit(1) + .for('update') + if (!current) throw new OrchestrationError('not_found', 'Workflow not found') + + const transition = await updateWorkflowRecord({ + workflowId: current.id, + userId: params.userId, + workspaceId: params.context.workspaceId, + currentName: current.name, + currentFolderId: current.folderId, + name: params.targetName, + folderId: params.targetFolderId, + tx, + }) + requireWorkflowTransition(transition, 'Workflow mutation failed') + if (!transition.workflow) throw new Error('Successful workflow move returned no workflow') + return { + id: transition.workflow.id, + name: transition.workflow.name, + previousFolderId: current.folderId, + folderId: transition.workflow.folderId, + } + }) +} + +function createdFolderAuditEntries(createdFolders: readonly CreatedFolderChange[]) { + return createdFolders.map((folder) => ({ + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + description: `Created workflow folder "${folder.path}"`, + metadata: { path: folder.path, folderResourceType: 'workflow' }, + })) +} + +export const createWorkflowVfsFolders = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.createVfsFolders, + resolveContext: ({ input }: { input: CreateWorkflowVfsFoldersInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }) { + const paths = normalizeReferences(input.paths) + const state = await loadWorkflowVfsIndex(context) + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes: WorkflowVfsOutcome[] = [] + + for (const path of paths) { + if (path.segments.length === 0) { + outcomes.push({ + source: path.source, + resourceType: 'folder', + error: 'Path must include at least one folder segment', + }) + continue + } + try { + const folderId = await ensureWorkflowFolderPath(state, context, userId, path.segments) + outcomes.push({ + source: path.source, + targetSegments: path.segments, + resourceType: 'folder', + resourceId: folderId ?? undefined, + }) + } catch (error) { + outcomes.push({ + source: path.source, + resourceType: 'folder', + error: expectedOutcomeMessage(error), + }) + } + } + return { outcomes, createdFolders: state.createdFolders } + }, + projectAudit: ({ result }) => createdFolderAuditEntries(result.createdFolders), + afterSuccess: ({ context, result }) => + result.createdFolders.length > 0 + ? notifyFolderResourceChanged('workflow', context.workspaceId) + : undefined, +}) + +export const moveWorkflowVfsItems = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.moveVfsItems, + resolveContext: ({ input }: { input: TransferWorkflowVfsItemsInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }) { + const sources = normalizeReferences(input.sources) + validateDestination(input.destination) + const state = await loadWorkflowVfsIndex(context) + const refs = resolveWorkflowSources(state, sources) + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const destination = planDestination(input, state, context, userId, sources.length) + if (!destination.dirMode && (destination.leafName?.length ?? 0) > MAX_WORKFLOW_NAME_LENGTH) { + throw new OrchestrationError( + 'validation', + `Workflow name must be ${MAX_WORKFLOW_NAME_LENGTH} characters or less` + ) + } + const outcomes: WorkflowVfsOutcome[] = [] + const movedWorkflows: MovedWorkflowChange[] = [] + const movedFolders: MovedFolderChange[] = [] + + for (const ref of refs) { + if (ref.error) { + outcomes.push({ source: ref.source, resourceType: 'workflow', error: ref.error }) + continue + } + if (ref.workflow) { + const targetName = destination.dirMode + ? ref.workflow.name + : (destination.leafName as string) + try { + const targetFolderId = await destination.ensureFolderId() + const change = await moveWorkflowRow({ + row: ref.workflow, + targetName: destination.dirMode ? undefined : targetName, + targetFolderId, + context, + userId, + }) + movedWorkflows.push(change) + outcomes.push({ + source: ref.source, + targetSegments: [...destination.folderSegments, change.name], + resourceType: 'workflow', + resourceId: change.id, + }) + } catch (error) { + outcomes.push({ + source: ref.source, + resourceType: 'workflow', + error: expectedOutcomeMessage(error), + }) + } + continue + } + + const folderId = ref.folderId as string + try { + const targetFolderId = await destination.ensureFolderId() + if (targetFolderId === folderId) { + outcomes.push({ + source: ref.source, + resourceType: 'folder', + error: 'Cannot move a folder into itself', + }) + continue + } + const sourcePath = state.folderIndex.pathById.get(folderId) + const sourceRow = state.folderIndex.rowById.get(folderId) + if (!sourcePath || !sourceRow) throw new Error('Workflow folder path index is incomplete') + const finalLeaf = destination.dirMode + ? (sources.find((source) => source.source === ref.source)?.segments.at(-1) ?? '') + : (destination.leafName as string) + const destinationPath = buildFolderPath([...destination.folderSegments, finalLeaf]) + const result = await relocateFolderByPath({ + resourceType: 'workflow', + workspaceId: context.workspaceId, + userId, + path: sourcePath, + destinationPath, + effects: false, + throwInfrastructure: true, + maxFolderRows: MAX_WORKFLOW_VFS_INDEX_ROWS, + }) + if (!result.success || !result.folder) throwFolderFailure(result) + movedFolders.push({ + id: result.folder.id, + name: result.folder.name, + sourcePath, + destinationPath, + }) + outcomes.push({ + source: ref.source, + targetSegments: [...destination.folderSegments, finalLeaf], + resourceType: 'folder', + resourceId: result.folder.id, + }) + } catch (error) { + outcomes.push({ + source: ref.source, + resourceType: 'folder', + error: expectedOutcomeMessage(error), + }) + } + } + + return { outcomes, createdFolders: state.createdFolders, movedWorkflows, movedFolders } + }, + projectAudit: ({ result }) => [ + ...createdFolderAuditEntries(result.createdFolders), + ...result.movedWorkflows.map((change) => ({ + action: AuditAction.WORKFLOW_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: change.id, + resourceName: change.name, + description: `Moved workflow "${change.name}"`, + metadata: { + previousFolderId: change.previousFolderId, + folderId: change.folderId, + }, + })), + ...result.movedFolders.map((change) => ({ + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: change.id, + resourceName: change.name, + description: `Moved workflow folder to "${change.destinationPath}"`, + metadata: { + sourcePath: change.sourcePath, + destinationPath: change.destinationPath, + folderResourceType: 'workflow', + }, + })), + ], + afterSuccess: async ({ context, result }) => { + for (const change of result.movedWorkflows) { + await notifyWorkflowUpdated(change.id) + } + if (result.createdFolders.length > 0 || result.movedFolders.length > 0) { + await notifyFolderResourceChanged('workflow', context.workspaceId) + } + }, +}) + +export const copyWorkflowVfsItems = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.copyVfsItems, + resolveContext: ({ input }: { input: TransferWorkflowVfsItemsInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }) { + const sources = normalizeReferences(input.sources) + validateDestination(input.destination) + const state = await loadWorkflowVfsIndex(context) + const refs = resolveWorkflowSources(state, sources) + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const destination = planDestination(input, state, context, userId, sources.length) + if (!destination.dirMode && (destination.leafName?.length ?? 0) > MAX_WORKFLOW_NAME_LENGTH) { + throw new OrchestrationError( + 'validation', + `Workflow name must be ${MAX_WORKFLOW_NAME_LENGTH} characters or less` + ) + } + const outcomes: WorkflowVfsOutcome[] = [] + const duplicatedWorkflows: DuplicatedWorkflowChange[] = [] + + for (const ref of refs) { + if (ref.error) { + outcomes.push({ source: ref.source, resourceType: 'workflow', error: ref.error }) + continue + } + if (!ref.workflow) { + outcomes.push({ + source: ref.source, + resourceType: 'folder', + error: 'Workflow folders cannot be copied.', + }) + continue + } + + try { + const targetFolderId = await destination.ensureFolderId() + const targetName = destination.dirMode + ? ref.workflow.name + : (destination.leafName as string) + const duplicated = await db.transaction(async (tx) => { + const [source] = await tx + .select({ id: workflow.id }) + .from(workflow) + .where( + and( + eq(workflow.id, ref.workflow?.id as string), + eq(workflow.workspaceId, context.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .limit(1) + .for('update') + if (!source) throw new OrchestrationError('not_found', 'Workflow not found') + return duplicateWorkflowRecord({ + sourceWorkflowId: source.id, + userId, + workspaceId: context.workspaceId, + folderId: targetFolderId, + name: targetName, + requestId: generateRequestId(), + tx, + }) + }) + duplicatedWorkflows.push({ + id: duplicated.id, + name: duplicated.name, + sourceWorkflowId: ref.workflow.id, + }) + outcomes.push({ + source: ref.source, + targetSegments: [...destination.folderSegments, duplicated.name], + resourceType: 'workflow', + resourceId: duplicated.id, + }) + } catch (error) { + outcomes.push({ + source: ref.source, + resourceType: 'workflow', + error: expectedOutcomeMessage(error), + }) + } + } + + return { outcomes, createdFolders: state.createdFolders, duplicatedWorkflows } + }, + projectAudit: ({ context, result }) => [ + ...createdFolderAuditEntries(result.createdFolders), + ...result.duplicatedWorkflows.map((change) => ({ + action: AuditAction.WORKFLOW_DUPLICATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: change.id, + resourceName: change.name, + description: `Duplicated workflow as "${change.name}"`, + metadata: { + sourceWorkflowId: change.sourceWorkflowId, + workspaceId: context.workspaceId, + }, + })), + ], + afterSuccess: async ({ context, result }) => { + for (const change of result.duplicatedWorkflows) { + await notifyWorkflowUpdated(change.id) + } + if (result.createdFolders.length > 0) { + await notifyFolderResourceChanged('workflow', context.workspaceId) + } + }, +}) + +export const deleteWorkflowVfsItems = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.deleteVfsItems, + resolveContext: ({ input }: { input: DeleteWorkflowVfsItemsInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }) { + const paths = normalizeReferences(input.paths) + const state = await loadWorkflowVfsIndex(context) + const refs = resolveWorkflowSources(state, paths) + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes: WorkflowVfsOutcome[] = [] + const deletedWorkflows: DeletedWorkflowChange[] = [] + const deletedFolders: DeletedFolderChange[] = [] + + for (const ref of refs) { + if (ref.error) { + outcomes.push({ source: ref.source, resourceType: 'workflow', error: ref.error }) + continue + } + if (ref.workflow) { + try { + await assertWorkflowMutable(ref.workflow.id) + const result = await deleteWorkflowRecord({ + workflowId: ref.workflow.id, + userId, + notifySocket: false, + }) + requireWorkflowTransition(result, 'Workflow deletion failed') + if (!result.workflow || !result.archived) { + throw new OrchestrationError('validation', 'Workflow is already deleted') + } + deletedWorkflows.push({ id: result.workflow.id, name: result.workflow.name }) + outcomes.push({ + source: ref.source, + resourceType: 'workflow', + resourceId: result.workflow.id, + }) + } catch (error) { + outcomes.push({ + source: ref.source, + resourceType: 'workflow', + error: expectedOutcomeMessage(error), + }) + } + continue + } + + const folderId = ref.folderId as string + const path = state.folderIndex.pathById.get(folderId) + if (!path) throw new Error('Workflow folder path index is incomplete') + try { + const result = await deleteFolderByPath({ + resourceType: 'workflow', + workspaceId: context.workspaceId, + userId, + path, + recursive: true, + effects: false, + throwInfrastructure: true, + maxFolderRows: MAX_WORKFLOW_VFS_INDEX_ROWS, + }) + if (!result.success || !result.folderId || !result.folderName || !result.deletedItems) { + throwFolderFailure(result) + } + deletedFolders.push({ + id: result.folderId, + name: result.folderName, + path, + workflows: result.deletedItems.workflows ?? 0, + folders: result.deletedItems.folders, + }) + outcomes.push({ + source: ref.source, + resourceType: 'folder', + resourceId: result.folderId, + }) + } catch (error) { + outcomes.push({ + source: ref.source, + resourceType: 'folder', + error: expectedOutcomeMessage(error), + }) + } + } + + return { outcomes, deletedWorkflows, deletedFolders } + }, + projectAudit: ({ result }) => [ + ...result.deletedWorkflows.map((change) => ({ + action: AuditAction.WORKFLOW_DELETED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: change.id, + resourceName: change.name, + description: `Archived workflow "${change.name}"`, + metadata: { archived: true }, + })), + ...result.deletedFolders.map((change) => ({ + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: change.id, + resourceName: change.name, + description: `Deleted workflow folder "${change.path}"`, + metadata: { + folderResourceType: 'workflow', + path: change.path, + affected: { + workflows: change.workflows, + subfolders: Math.max(change.folders - 1, 0), + }, + }, + })), + ], + afterSuccess: async ({ context, result }) => { + for (const workflow of result.deletedWorkflows) { + await notifyWorkflowDeleted(workflow.id) + } + if (result.deletedFolders.length > 0) { + await notifyFolderResourceChanged('workflow', context.workspaceId) + } + }, +}) diff --git a/apps/sim/lib/workflows/deployment-outbox.test.ts b/apps/sim/lib/workflows/deployment-outbox.test.ts index b08db0ce3e9..eee9c74f65a 100644 --- a/apps/sim/lib/workflows/deployment-outbox.test.ts +++ b/apps/sim/lib/workflows/deployment-outbox.test.ts @@ -75,7 +75,7 @@ vi.mock('@/lib/mcp/server-locks', () => ({ })) vi.mock('@/lib/posthog/server', () => ({ - captureServerEvent: mockCaptureServerEvent, + deliverOutboxServerEvent: mockCaptureServerEvent, })) vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ @@ -212,6 +212,7 @@ describe('versioned deployment preparation outbox', () => { mockSyncMcpToolsForWorkflow.mockResolvedValue([{ serverId: 'mcp-server-1' }]) mockSetWorkflowMcpTransactionLockTimeout.mockResolvedValue(undefined) mockEmitWorkflowDeployedEvent.mockResolvedValue(undefined) + mockCaptureServerEvent.mockResolvedValue('delivered') mockMarkDeploymentOperationFailed.mockResolvedValue({ success: true, operation: operation({ status: 'failed' }), @@ -303,6 +304,7 @@ describe('versioned deployment preparation outbox', () => { 'workflow_deployed', { workflow_id: 'workflow-1', workspace_id: 'workspace-1' }, expect.objectContaining({ + insertId: 'event-1', groups: { workspace: 'workspace-1' }, setOnce: expect.objectContaining({ first_workflow_deployed_at: expect.any(String) }), }) @@ -311,6 +313,28 @@ describe('versioned deployment preparation outbox', () => { expect(mockRecordAudit.mock.invocationCallOrder[0]).toBeGreaterThan( mockActivateDeploymentOperation.mock.invocationCallOrder[0] ) + expect(mockCaptureServerEvent.mock.invocationCallOrder[0]).toBeGreaterThan( + mockActivateDeploymentOperation.mock.invocationCallOrder[0] + ) + + mockGetDeploymentOperation.mockResolvedValue(active) + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + ]) + await handler()( + { + ...payload(), + checkpoints: { + inactiveCleanupCompleted: true, + auditEmitted: true, + analyticsCaptured: true, + socketNotified: true, + workspaceEventEmitted: true, + }, + }, + context() + ) + expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1) }) it('ignores a superseded generation without preparing side effects', async () => { @@ -324,6 +348,33 @@ describe('versioned deployment preparation outbox', () => { expect(mockActivateDeploymentOperation).not.toHaveBeenCalled() }) + it('does not checkpoint analytics until durable PostHog delivery resolves', async () => { + const active = operation({ status: 'active', completedAt: NOW }) + mockGetDeploymentOperation.mockResolvedValue(active) + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + ]) + const deliveryFailure = new Error('PostHog flush failed') + mockCaptureServerEvent.mockRejectedValueOnce(deliveryFailure) + const outboxContext = context() + + await expect( + handler()( + { + ...payload(), + checkpoints: { inactiveCleanupCompleted: true, auditEmitted: true }, + }, + outboxContext + ) + ).rejects.toBe(deliveryFailure) + + expect(outboxContext.checkpointPayload).not.toHaveBeenCalledWith( + expect.objectContaining({ + checkpoints: expect.objectContaining({ analyticsCaptured: true }), + }) + ) + }) + it('honors an aborted signal before starting any side effect', async () => { const controller = new AbortController() controller.abort() diff --git a/apps/sim/lib/workflows/deployment-outbox.ts b/apps/sim/lib/workflows/deployment-outbox.ts index a22152e4421..eff2d1db51c 100644 --- a/apps/sim/lib/workflows/deployment-outbox.ts +++ b/apps/sim/lib/workflows/deployment-outbox.ts @@ -22,7 +22,7 @@ import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow, } from '@/lib/mcp/workflow-mcp-sync' -import { captureServerEvent } from '@/lib/posthog/server' +import { deliverOutboxServerEvent } from '@/lib/posthog/server' import { cleanupWebhooksForWorkflow, prepareStableTriggerWebhooksForDeploy, @@ -647,7 +647,7 @@ async function emitPostActivationSideEffects(params: { if (params.payload.captureAnalytics !== false) { const workspaceId = (params.workflow.workspaceId as string) || '' const isVersionActivation = params.operation.action === 'activate' - captureServerEvent( + await deliverOutboxServerEvent( params.payload.userId, isVersionActivation ? 'deployment_version_activated' : 'workflow_deployed', { @@ -656,6 +656,7 @@ async function emitPostActivationSideEffects(params: { ...(isVersionActivation ? { version: params.payload.version } : {}), }, { + insertId: params.context.eventId, groups: workspaceId ? { workspace: workspaceId } : undefined, ...(isVersionActivation ? {} diff --git a/apps/sim/lib/workflows/deployment-status.ts b/apps/sim/lib/workflows/deployment-status.ts new file mode 100644 index 00000000000..3498506a5f0 --- /dev/null +++ b/apps/sim/lib/workflows/deployment-status.ts @@ -0,0 +1,35 @@ +import { db, workflowDeploymentVersion } from '@sim/db' +import { and, desc, eq, sql } from 'drizzle-orm' +import { hasWorkflowChanged } from '@/lib/workflows/comparison' +import { loadWorkflowDeploymentSnapshot } from '@/lib/workflows/persistence/utils' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +/** Compares the current durable draft with the active deployment snapshot. */ +export function computeNeedsRedeployment( + currentSnapshot: WorkflowState | null | undefined, + activeState: WorkflowState | null | undefined +): boolean { + if (!activeState || !currentSnapshot) return false + return hasWorkflowChanged(currentSnapshot, activeState) +} + +/** Reads both sides at repeatable-read isolation so the comparison is coherent. */ +export async function checkNeedsRedeployment(workflowId: string): Promise<boolean> { + return db.transaction(async (tx) => { + await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`) + const [active] = await tx + .select({ state: workflowDeploymentVersion.state }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .orderBy(desc(workflowDeploymentVersion.createdAt)) + .limit(1) + + const currentState = await loadWorkflowDeploymentSnapshot(workflowId, tx) + return computeNeedsRedeployment(currentState, (active?.state as WorkflowState) ?? null) + }) +} diff --git a/apps/sim/lib/workflows/execution-admission.ts b/apps/sim/lib/workflows/execution-admission.ts new file mode 100644 index 00000000000..601d7c70e8f --- /dev/null +++ b/apps/sim/lib/workflows/execution-admission.ts @@ -0,0 +1,132 @@ +import { + reserveExecutionSlot, + UsageReservationUnavailableError, +} from '@/lib/billing/calculations/usage-reservation' +import { + type BillingAttributionSnapshot, + checkAttributedUsageLimits, + resolveBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import { + getReservationDenialDescriptor, + type ReservationDenialReason, +} from '@/lib/core/admission/transient-failure' +import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' + +export interface WorkflowExecutionActorContext { + userId: string + billingAttribution?: BillingAttributionSnapshot +} + +export async function resolveWorkflowExecutionBillingAttribution( + context: WorkflowExecutionActorContext, + targetWorkspaceId: string +): Promise<BillingAttributionSnapshot | undefined> { + const rootAttribution = context.billingAttribution + if (!rootAttribution) return undefined + if (rootAttribution.workspaceId === targetWorkspaceId) return rootAttribution + + const childAttribution = await resolveBillingAttribution({ + actorUserId: context.userId, + workspaceId: targetWorkspaceId, + }) + if ( + childAttribution.actorUserId !== context.userId || + childAttribution.workspaceId !== targetWorkspaceId + ) { + throw new Error('Resolved workflow billing attribution does not match its actor and workspace') + } + return childAttribution +} + +export interface WorkflowExecutionAdmission { + billingAttribution: BillingAttributionSnapshot | undefined + targetReservation: boolean +} + +type ReservationDenialDescriptor = ReturnType<typeof getReservationDenialDescriptor> + +export class WorkflowExecutionAdmissionError extends Error { + readonly code: ReservationDenialDescriptor['code'] + readonly statusCode: ReservationDenialDescriptor['statusCode'] + readonly retryable: ReservationDenialDescriptor['retryable'] + + constructor(message: string, descriptor: ReservationDenialDescriptor) { + super(message) + this.name = 'WorkflowExecutionAdmissionError' + this.code = descriptor.code + this.statusCode = descriptor.statusCode + this.retryable = descriptor.retryable + } +} + +const TARGET_RESERVATION_DENIAL_MESSAGE = { + payer_concurrency: 'Target workspace execution concurrency is currently exhausted', + payer_headroom: 'Target workspace payer usage headroom is currently exhausted', + member_headroom: 'Target workspace member usage headroom is currently exhausted', +} as const satisfies Record<ReservationDenialReason, string> + +export async function prepareWorkflowExecutionAdmission( + context: WorkflowExecutionActorContext, + targetWorkspaceId: string, + childExecutionId: string +): Promise<WorkflowExecutionAdmission> { + const billingAttribution = await resolveWorkflowExecutionBillingAttribution( + context, + targetWorkspaceId + ) + const rootAttribution = context.billingAttribution + const isCrossWorkspace = + rootAttribution !== undefined && rootAttribution.workspaceId !== targetWorkspaceId + + if (!billingAttribution || !isCrossWorkspace) { + return { billingAttribution, targetReservation: false } + } + + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + const descriptor = getReservationDenialDescriptor( + usage.scope === 'member' ? 'member_headroom' : 'payer_headroom' + ) + throw new WorkflowExecutionAdmissionError( + usage.message ?? 'Target workspace usage limit exceeded', + descriptor + ) + } + if (isHosted && isBillingEnabled && !usage.payerUsage) { + throw new UsageReservationUnavailableError( + 'Target workspace usage admission is temporarily unavailable. Please retry.' + ) + } + + const payerUsage = usage.payerUsage ?? { currentUsage: 0, limit: 0 } + const reservation = await reserveExecutionSlot({ + billingEntity: billingAttribution.billingEntity, + executionId: childExecutionId, + plan: billingAttribution.payerSubscription?.plan, + enterpriseConcurrencyLimit: billingAttribution.payerSubscription?.enterpriseConcurrencyLimit, + currentUsage: payerUsage.currentUsage, + limit: payerUsage.limit, + ...(billingAttribution.organizationId && + usage.memberUsage?.limit !== null && + usage.memberUsage?.limit !== undefined + ? { + member: { + organizationId: billingAttribution.organizationId, + actorUserId: billingAttribution.actorUserId, + currentUsage: usage.memberUsage.currentUsage, + limit: usage.memberUsage.limit, + }, + } + : {}), + }) + if (!reservation.reserved) { + const descriptor = getReservationDenialDescriptor(reservation.reason) + throw new WorkflowExecutionAdmissionError( + TARGET_RESERVATION_DENIAL_MESSAGE[reservation.reason], + descriptor + ) + } + + return { billingAttribution, targetReservation: true } +} diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts index 9ee006b26be..098305930de 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts @@ -16,7 +16,7 @@ vi.mock('@/lib/workflows/orchestration/deploy', () => ({ performFullDeploy: mockPerformFullDeploy, })) -vi.mock('@/app/api/workflows/utils', () => ({ +vi.mock('@/lib/workflows/deployment-status', () => ({ checkNeedsRedeployment: mockCheckNeedsRedeployment, })) diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index cdfffe62237..6a28925145b 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import type { PrincipalActor } from '@sim/auth/principal' import { db } from '@sim/db' import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -7,11 +8,11 @@ import { and, eq, isNull } from 'drizzle-orm' import { chatDeploymentPasswordSchema } from '@/lib/api/contracts/chats' import { encryptSecret } from '@/lib/core/security/encryption' import { getBaseUrl } from '@/lib/core/utils/urls' +import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' import { getWorkflowDeploymentSummary, performFullDeploy, } from '@/lib/workflows/orchestration/deploy' -import { checkNeedsRedeployment } from '@/app/api/workflows/utils' const logger = createLogger('ChatDeployOrchestration') @@ -37,6 +38,12 @@ export interface ChatDeployPayload { workspaceId?: string | null /** Stable identity for the underlying workflow deployment operation. */ idempotencyKey?: string + actorId?: string + actor?: PrincipalActor + requestId?: string + captureDeploymentAnalytics?: false + projectLegacyAudit?: boolean + captureLegacyTelemetry?: boolean } export interface PerformChatDeployResult { @@ -45,6 +52,7 @@ export interface PerformChatDeployResult { chatUrl?: string deployedAt?: Date | null version?: number + isUpdate?: boolean error?: string } @@ -114,9 +122,13 @@ export async function performChatDeploy( deployResult = await performFullDeploy({ workflowId, userId, + actorId: params.actorId, + actor: params.actor, + requestId: params.requestId, versionDescription: params.versionDescription, versionName: params.versionName, idempotencyKey: params.idempotencyKey, + captureAnalytics: params.captureDeploymentAnalytics, }) if (!deployResult.success) { return { success: false, error: deployResult.error || 'Failed to deploy workflow' } @@ -230,40 +242,42 @@ export async function performChatDeploy( logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`) - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.chatDeployed({ - chatId, - workflowId, - authType, - hasOutputConfigs: outputConfigs.length > 0, - }) - } catch (_e) { - // Telemetry is best-effort + if (params.captureLegacyTelemetry !== false) { + try { + const { PlatformEvents } = await import('@/lib/core/telemetry') + PlatformEvents.chatDeployed({ + chatId, + workflowId, + authType, + hasOutputConfigs: outputConfigs.length > 0, + }) + } catch (_e) {} } - recordAudit({ - workspaceId: params.workspaceId || null, - actorId: userId, - action: AuditAction.CHAT_DEPLOYED, - resourceType: AuditResourceType.CHAT, - resourceId: chatId, - resourceName: title, - description: `Deployed chat "${title}"`, - metadata: { - workflowId, - identifier, - authType, - chatUrl, - isUpdate: !!existingDeployment, - hasOutputConfigs: outputConfigs.length > 0, - hasCustomizations: !!( - params.customizations?.primaryColor || - params.customizations?.welcomeMessage || - params.customizations?.imageUrl - ), - }, - }) + if (params.projectLegacyAudit !== false) { + recordAudit({ + workspaceId: params.workspaceId || null, + actorId: userId, + action: AuditAction.CHAT_DEPLOYED, + resourceType: AuditResourceType.CHAT, + resourceId: chatId, + resourceName: title, + description: `Deployed chat "${title}"`, + metadata: { + workflowId, + identifier, + authType, + chatUrl, + isUpdate: !!existingDeployment, + hasOutputConfigs: outputConfigs.length > 0, + hasCustomizations: !!( + params.customizations?.primaryColor || + params.customizations?.welcomeMessage || + params.customizations?.imageUrl + ), + }, + }) + } return { success: true, @@ -271,6 +285,7 @@ export async function performChatDeploy( chatUrl, deployedAt: deployResult?.deployedAt ?? toDeployedAtDate(deploymentSummary), version: deployResult?.version ?? deploymentSummary.activeDeployment?.version, + isUpdate: Boolean(existingDeployment), } } @@ -284,6 +299,7 @@ export interface PerformChatUndeployParams { chatId: string userId: string workspaceId?: string | null + projectLegacyAudit?: boolean } export interface PerformChatUndeployResult { @@ -320,20 +336,22 @@ export async function performChatUndeploy( logger.info(`Chat "${chatId}" deleted successfully`) - recordAudit({ - workspaceId: workspaceId || null, - actorId: userId, - action: AuditAction.CHAT_DELETED, - resourceType: AuditResourceType.CHAT, - resourceId: chatId, - resourceName: chatRecord.title || chatId, - description: `Deleted chat deployment "${chatRecord.title || chatId}"`, - metadata: { - workflowId: chatRecord.workflowId || undefined, - identifier: chatRecord.identifier || undefined, - authType: chatRecord.authType || undefined, - }, - }) + if (params.projectLegacyAudit !== false) { + recordAudit({ + workspaceId: workspaceId || null, + actorId: userId, + action: AuditAction.CHAT_DELETED, + resourceType: AuditResourceType.CHAT, + resourceId: chatId, + resourceName: chatRecord.title || chatId, + description: `Deleted chat deployment "${chatRecord.title || chatId}"`, + metadata: { + workflowId: chatRecord.workflowId || undefined, + identifier: chatRecord.identifier || undefined, + authType: chatRecord.authType || undefined, + }, + }) + } return { success: true } } diff --git a/apps/sim/lib/workflows/orchestration/deploy.test.ts b/apps/sim/lib/workflows/orchestration/deploy.test.ts index e619944702b..262a45c30d7 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.test.ts @@ -25,6 +25,7 @@ const { mockProcessWorkflowDeploymentOutboxEvent, mockNotifySocketDeploymentChanged, mockLoadWorkflowDeploymentSnapshot, + mockUpdateDeploymentVersionMetadata, mockTx, } = vi.hoisted(() => ({ mockSaveWorkflowToNormalizedTables: vi.fn(), @@ -40,6 +41,7 @@ const { mockProcessWorkflowDeploymentOutboxEvent: vi.fn(), mockNotifySocketDeploymentChanged: vi.fn(), mockLoadWorkflowDeploymentSnapshot: vi.fn(), + mockUpdateDeploymentVersionMetadata: vi.fn(), /** * Sentinel transaction handle the mocked prepare functions hand to the real * onPrepareTransaction callback, which only forwards it into the (mocked) @@ -88,6 +90,7 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({ loadWorkflowDeploymentSnapshot: mockLoadWorkflowDeploymentSnapshot, saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, undeployWorkflow: vi.fn(), + updateDeploymentVersionMetadata: mockUpdateDeploymentVersionMetadata, })) vi.mock('@/lib/webhooks/deploy', () => ({ @@ -248,6 +251,7 @@ describe('performFullDeploy workspace event emission', () => { }) mockValidateWorkflowSchedules.mockReturnValue({ isValid: true }) mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true }) + mockUpdateDeploymentVersionMetadata.mockResolvedValue({ name: null, description: null }) mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-default') mockPrepareWorkflowDeployment.mockImplementation(async (input) => { await input.onPrepareTransaction?.(mockTx, operation) @@ -672,6 +676,100 @@ describe('performActivateVersion workspace event emission', () => { expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled() }) + it('commits optional metadata inside activation admission before enqueueing work', async () => { + mockUpdateDeploymentVersionMetadata.mockResolvedValue({ + name: 'Release 2', + description: 'Ready for production', + }) + + const result = await performActivateVersion({ + workflowId: 'workflow-1', + version: 2, + userId: 'user-1', + name: 'Release 2', + description: 'Ready for production', + }) + + expect(result).toMatchObject({ + success: true, + name: 'Release 2', + description: 'Ready for production', + }) + expect(mockUpdateDeploymentVersionMetadata).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + version: 2, + name: 'Release 2', + description: 'Ready for production', + tx: mockTx, + }) + expect(mockUpdateDeploymentVersionMetadata).toHaveBeenCalledBefore( + mockEnqueueWorkflowDeploymentPreparation + ) + }) + + it('does not enqueue activation when transactional metadata persistence fails', async () => { + mockUpdateDeploymentVersionMetadata.mockRejectedValueOnce(new Error('metadata write failed')) + + const result = await performActivateVersion({ + workflowId: 'workflow-1', + version: 2, + userId: 'user-1', + name: 'Release 2', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'internal' }) + expect(mockEnqueueWorkflowDeploymentPreparation).not.toHaveBeenCalled() + }) + + it('reports post-admission activation failure while preserving admitted metadata', async () => { + const failedAt = new Date('2026-07-14T08:01:00.000Z') + mockUpdateDeploymentVersionMetadata.mockResolvedValue({ + name: 'Release 2', + description: null, + }) + mockGetWorkflowDeploymentStatus.mockResolvedValue({ + activeDeployment: null, + latestOperation: { + id: 'operation-activate-default', + workflowId: 'workflow-1', + deploymentVersionId: 'dv-2', + version: 2, + previousActiveVersionId: 'dv-1', + action: 'activate', + protocolVersion: 2, + generation: 4, + status: 'failed', + componentReadiness: {}, + errorCode: 'webhook_path_conflict', + errorMessage: 'Webhook path is already in use', + idempotencyKey: 'request-activate-default', + requestHash: 'hash', + actorId: 'user-1', + completedAt: failedAt, + createdAt: failedAt, + updatedAt: failedAt, + }, + }) + + const result = await performActivateVersion({ + workflowId: 'workflow-1', + version: 2, + userId: 'user-1', + name: 'Release 2', + }) + + expect(result).toMatchObject({ + success: false, + error: 'Webhook path is already in use', + errorCode: 'conflict', + name: 'Release 2', + }) + expect(mockUpdateDeploymentVersionMetadata).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Release 2', tx: mockTx }) + ) + expect(mockEnqueueWorkflowDeploymentPreparation).toHaveBeenCalledOnce() + }) + it('keeps the current version active while version activation prepares', async () => { const now = new Date('2026-07-14T08:00:00.000Z') const operation = { diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 1476840245d..5584a52412f 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -41,6 +41,7 @@ import { loadWorkflowDeploymentSnapshot, saveWorkflowToNormalizedTables, undeployWorkflow, + updateDeploymentVersionMetadata, } from '@/lib/workflows/persistence/utils' import { validateWorkflowSchedules } from '@/lib/workflows/schedules' import { emitWorkflowUndeployedEvent } from '@/lib/workspace-events/emitter' @@ -595,6 +596,10 @@ export interface PerformActivateVersionParams { workflowId: string version: number userId: string + /** Metadata committed atomically with activation admission. */ + name?: string | null + /** Metadata committed atomically with activation admission. */ + description?: string | null /** Stable identity for one logical activation operation. */ idempotencyKey?: string /** Correlation ID for logging and outbox tracing. */ @@ -613,6 +618,8 @@ export interface PerformActivateVersionResult { error?: string errorCode?: OrchestrationErrorCode warnings?: string[] + name?: string | null + description?: string | null } export interface PerformRevertToVersionParams { @@ -625,6 +632,9 @@ export interface PerformRevertToVersionParams { actorId?: string actorName?: string actorEmail?: string + captureAnalytics?: false + projectLegacyAudit?: boolean + notifyRealtime?: boolean } export interface PerformRevertToVersionResult { @@ -637,6 +647,10 @@ export interface PerformRevertToVersionResult { /** * Admits an existing version through the v2 prepare/activate protocol. Callers * that can replay a logical operation must provide a stable `idempotencyKey`. + * Optional metadata is committed in the same transaction as a new activation + * attempt. A metadata failure rolls back admission; a later preparation failure + * is returned as a failure even though the already-admitted attempt and its + * metadata remain durable and retryable through the deployment outbox. */ export async function performActivateVersion( params: PerformActivateVersionParams @@ -654,6 +668,8 @@ export async function performActivateVersion( id: workflowDeploymentVersion.id, state: workflowDeploymentVersion.state, isActive: workflowDeploymentVersion.isActive, + name: workflowDeploymentVersion.name, + description: workflowDeploymentVersion.description, }) .from(workflowDeploymentVersion) .where( @@ -669,6 +685,15 @@ export async function performActivateVersion( } if (versionRow.isActive) { + const metadata = await updateDeploymentVersionMetadata({ + workflowId, + version, + name: params.name, + description: params.description, + }) + if (!metadata) { + return { success: false, error: 'Deployment version not found', errorCode: 'not_found' } + } const [workflowDeployment] = await db .select({ deployedAt: workflowTable.deployedAt }) .from(workflowTable) @@ -683,6 +708,7 @@ export async function performActivateVersion( activeDeployment: stableResult.activeDeployment, latestDeploymentAttempt: stableResult.latestDeploymentAttempt, warnings: stableResult.warnings, + ...metadata, } } @@ -721,6 +747,8 @@ export async function performActivateVersion( actorId, actor: params.actor, captureAnalytics: params.captureAnalytics, + name: params.name, + description: params.description, requestId, idempotencyKey, }) @@ -746,6 +774,8 @@ async function performStableVersionActivation(params: { actorId: string actor?: PrincipalActor captureAnalytics?: false + name?: string | null + description?: string | null requestId: string idempotencyKey: string }): Promise<PerformActivateVersionResult> { @@ -755,8 +785,11 @@ async function performStableVersionActivation(params: { deploymentVersionId: params.deploymentVersionId, version: params.version, userId: params.userId, + name: params.name, + description: params.description, }) let outboxEventId: string | undefined + let metadata: { name: string | null; description: string | null } | undefined const prepared = await prepareWorkflowVersionActivation({ workflowId: params.workflowId, deploymentVersionId: params.deploymentVersionId, @@ -768,6 +801,15 @@ async function performStableVersionActivation(params: { if (!operation.deploymentVersionId || operation.version === null) { throw new Error('Prepared activation operation is missing its target version') } + metadata = + (await updateDeploymentVersionMetadata({ + workflowId: operation.workflowId, + version: operation.version, + name: params.name, + description: params.description, + tx, + })) ?? undefined + if (!metadata) throw new Error('Deployment version disappeared during activation admission') outboxEventId = await enqueueWorkflowDeploymentPreparation(tx, { protocolVersion: operation.protocolVersion, operationId: operation.id, @@ -792,10 +834,19 @@ async function performStableVersionActivation(params: { } } + metadata ??= + (await updateDeploymentVersionMetadata({ + workflowId: params.workflowId, + version: params.version, + })) ?? undefined + if (!metadata) { + return { success: false, error: 'Deployment version not found', errorCode: 'not_found' } + } + const processResult = await processStableDeploymentPreparationNow(outboxEventId, params.requestId) const status = await getWorkflowDeploymentStatus(params.workflowId) const inlineFailure = buildInlinePreparationFailure(prepared.operation.id, status) - if (inlineFailure) return inlineFailure + if (inlineFailure) return { ...inlineFailure, ...metadata } const result = buildStableDeploymentResult(status, processResult) return { success: result.success, @@ -803,6 +854,7 @@ async function performStableVersionActivation(params: { activeDeployment: result.activeDeployment, latestDeploymentAttempt: result.latestDeploymentAttempt, warnings: result.warnings, + ...metadata, } } @@ -947,46 +999,52 @@ export async function performRevertToVersion( } } - try { - await fetch(`${getSocketServerUrl()}/api/workflow-reverted`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': env.INTERNAL_API_SECRET, - }, - body: JSON.stringify({ workflowId, timestamp: lastSaved }), - }) - } catch (error) { - logger.error('Error sending workflow reverted event to socket server', error) + if (params.notifyRealtime !== false) { + try { + await fetch(`${getSocketServerUrl()}/api/workflow-reverted`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': env.INTERNAL_API_SECRET, + }, + body: JSON.stringify({ workflowId, timestamp: lastSaved }), + }) + } catch (error) { + logger.error('Error sending workflow reverted event to socket server', error) + } } const workspaceId = (workflow.workspaceId as string) || '' - captureServerEvent( - userId, - 'workflow_deployment_reverted', - { - workflow_id: workflowId, - workspace_id: workspaceId, - version: versionLabel, - }, - workspaceId ? { groups: { workspace: workspaceId } } : undefined - ) + if (params.captureAnalytics !== false) { + captureServerEvent( + userId, + 'workflow_deployment_reverted', + { + workflow_id: workflowId, + workspace_id: workspaceId, + version: versionLabel, + }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + } - recordAudit({ - workspaceId: workspaceId || null, - actorId, - actorName: params.actorName, - actorEmail: params.actorEmail, - action: AuditAction.WORKFLOW_DEPLOYMENT_REVERTED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - resourceName: (workflow.name as string) || undefined, - description: `Reverted workflow to deployment version ${versionLabel}`, - metadata: { - targetVersion: versionLabel, - }, - request: params.request, - }) + if (params.projectLegacyAudit !== false) { + recordAudit({ + workspaceId: workspaceId || null, + actorId, + actorName: params.actorName, + actorEmail: params.actorEmail, + action: AuditAction.WORKFLOW_DEPLOYMENT_REVERTED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + resourceName: (workflow.name as string) || undefined, + description: `Reverted workflow to deployment version ${versionLabel}`, + metadata: { + targetVersion: versionLabel, + }, + request: params.request, + }) + } return { success: true, diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index e8c5d0d1ea3..18210c81e8a 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -8,6 +8,7 @@ import { generateId } from '@sim/utils/id' import { and, eq, isNull, min, ne } from 'drizzle-orm' import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' +import type { DbOrTx } from '@/lib/db/types' import { captureServerEvent } from '@/lib/posthog/server' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { archiveWorkflow, restoreWorkflow } from '@/lib/workflows/lifecycle' @@ -63,6 +64,7 @@ export interface PerformUpdateWorkflowParams { locked?: boolean forkSyncExcluded?: boolean requestId?: string + tx?: DbOrTx } export interface PerformUpdateWorkflowResult { @@ -92,6 +94,8 @@ export interface PerformDeleteWorkflowParams { skipLastWorkflowGuard?: boolean /** Override the actor ID used in audit logs. Defaults to `userId`. */ actorId?: string + /** Legacy lifecycle notification; application commands project their own semantic event. */ + notifySocket?: boolean } export interface PerformDeleteWorkflowResult { @@ -169,7 +173,9 @@ async function workflowNameExistsInFolder(params: { name: string folderId?: string | null excludeWorkflowId?: string + tx?: DbOrTx }): Promise<boolean> { + const executor = params.tx ?? db const conditions = [ eq(workflow.workspaceId, params.workspaceId), isNull(workflow.archivedAt), @@ -186,7 +192,7 @@ async function workflowNameExistsInFolder(params: { conditions.push(isNull(workflow.folderId)) } - const [duplicateWorkflow] = await db + const [duplicateWorkflow] = await executor .select({ id: workflow.id }) .from(workflow) .where(and(...conditions)) @@ -194,6 +200,27 @@ async function workflowNameExistsInFolder(params: { return Boolean(duplicateWorkflow) } +async function isWorkflowFolderInWorkspace( + folderId: string | null | undefined, + workspaceId: string, + executor: DbOrTx = db +): Promise<boolean> { + if (!folderId) return true + const [row] = await executor + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + eq(folderTable.id, folderId), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) + ) + ) + .limit(1) + return Boolean(row) +} + export async function performCreateWorkflowTransition( params: PerformCreateWorkflowParams ): Promise<PerformCreateWorkflowResult> { @@ -304,6 +331,7 @@ export async function performCreateWorkflow( export async function updateWorkflowRecord( params: PerformUpdateWorkflowParams ): Promise<PerformUpdateWorkflowResult> { + const executor = params.tx ?? db const requestId = params.requestId ?? generateRequestId() const targetName = params.name ?? params.currentName const targetFolderId = @@ -311,7 +339,7 @@ export async function updateWorkflowRecord( if ( params.folderId !== undefined && - !(await isFolderInWorkspace(targetFolderId, params.workspaceId)) + !(await isWorkflowFolderInWorkspace(targetFolderId, params.workspaceId, executor)) ) { return { success: false, error: 'Target folder not found', errorCode: 'validation' } } @@ -322,6 +350,7 @@ export async function updateWorkflowRecord( name: targetName, folderId: targetFolderId, excludeWorkflowId: params.workflowId, + tx: executor, }) if (duplicate) { return { @@ -340,7 +369,7 @@ export async function updateWorkflowRecord( if (params.locked !== undefined) updateData.locked = params.locked if (params.forkSyncExcluded !== undefined) updateData.forkSyncExcluded = params.forkSyncExcluded - const [updatedWorkflow] = await db + const [updatedWorkflow] = await executor .update(workflow) .set(updateData) .where( @@ -478,7 +507,10 @@ export async function deleteWorkflowRecord( } } - const archiveResult = await archiveWorkflow(workflowId, { requestId }) + const archiveResult = await archiveWorkflow(workflowId, { + requestId, + notifySocket: params.notifySocket, + }) if (!archiveResult.workflow) { return { success: false, error: 'Workflow not found', errorCode: 'not_found' } } diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index fff063f8ab3..60aac83b1bd 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -618,13 +618,15 @@ export async function updateDeploymentVersionMetadata(params: { version: number name?: string | null description?: string | null + tx?: DbOrTx }): Promise<{ name: string | null; description: string | null } | null> { + const executor = params.tx ?? db const updateData: { name?: string | null; description?: string | null } = {} if (params.name !== undefined) updateData.name = params.name if (params.description !== undefined) updateData.description = params.description if (Object.keys(updateData).length === 0) { - const [row] = await db + const [row] = await executor .select({ name: workflowDeploymentVersion.name, description: workflowDeploymentVersion.description, @@ -640,7 +642,7 @@ export async function updateDeploymentVersionMetadata(params: { return row ?? null } - const [updated] = await db + const [updated] = await executor .update(workflowDeploymentVersion) .set(updateData) .where( @@ -912,7 +914,7 @@ export async function findPreviousDeploymentVersion( */ export async function getWorkflowDeploymentVersion( workflowId: string, - version: number + version: number | 'active' ): Promise<{ id: string version: number @@ -922,6 +924,10 @@ export async function getWorkflowDeploymentVersion( createdAt: Date state: unknown } | null> { + const versionPredicate = + version === 'active' + ? eq(workflowDeploymentVersion.isActive, true) + : eq(workflowDeploymentVersion.version, version) const [row] = await db .select({ id: workflowDeploymentVersion.id, @@ -933,12 +939,7 @@ export async function getWorkflowDeploymentVersion( state: workflowDeploymentVersion.state, }) .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.version, version) - ) - ) + .where(and(eq(workflowDeploymentVersion.workflowId, workflowId), versionPredicate)) .limit(1) return row ?? null diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index dba3e7da931..4ffa34a5eb3 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -74,6 +74,27 @@ export const fileOperations = { workspaceApiKey: 'allow', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), + createVfsFolders: defineWorkspaceOperation({ + id: 'files.vfs.folders.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + relocateVfsItems: defineWorkspaceOperation({ + id: 'files.vfs.relocate', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + deleteVfsItems: defineWorkspaceOperation({ + id: 'files.vfs.delete', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), delete: defineWorkspaceOperation({ id: 'files.delete', minimumRole: 'write', diff --git a/apps/sim/lib/workspace-files/application/workspace-file-vfs.ts b/apps/sim/lib/workspace-files/application/workspace-file-vfs.ts new file mode 100644 index 00000000000..6df8c570b94 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/workspace-file-vfs.ts @@ -0,0 +1,486 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' +import { buildFolderPath, FolderPathError } from '@/lib/folders/paths' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + bulkArchiveWorkspaceFileItems, + createWorkspaceFileFolderAtPath, + findWorkspaceFileFolderIdByPath, + getWorkspaceFileByName, + loadWorkspaceFileOperationContext, + moveRenameWorkspaceFile, + relocateWorkspaceFileFolderByPath, + WorkspaceFileFolderConflictError, + WorkspaceFileMoveConflictError, +} from '@/lib/uploads/contexts/workspace' +import { VfsPathLimitError, validateVfsPathSegments } from '@/lib/vfs/limits' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +const MAX_FILE_VFS_ITEMS = 100 + +export interface WorkspaceFileVfsPathReference { + source: string + segments: string[] +} + +export interface WorkspaceFileVfsDestination { + segments: string[] + trailingSlash: boolean +} + +export interface WorkspaceFileVfsOutcome { + source: string + targetSegments?: string[] + resourceType: 'file' | 'folder' + resourceId?: string + error?: string +} + +interface CreatedFolder { + id: string + name: string + path: string +} + +interface RelocatedFile { + id: string + name: string + moved: boolean + renamed: boolean +} + +interface RelocatedFolder { + id: string + name: string + sourcePath: string + destinationPath: string +} + +function normalizeReferences( + references: readonly WorkspaceFileVfsPathReference[] +): WorkspaceFileVfsPathReference[] { + if (references.length > MAX_FILE_VFS_ITEMS) { + throw new OrchestrationError( + 'validation', + `File VFS commands cannot exceed ${MAX_FILE_VFS_ITEMS} items` + ) + } + const unique = new Map<string, WorkspaceFileVfsPathReference>() + for (const reference of references) { + try { + validateVfsPathSegments(reference.segments) + } catch (error) { + if (error instanceof VfsPathLimitError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + const key = buildFolderPath(reference.segments) + if (!unique.has(key)) unique.set(key, reference) + } + if (unique.size === 0) throw new OrchestrationError('validation', 'At least one path is required') + return [...unique.values()] +} + +function expectedOutcomeMessage(error: unknown): string { + const classified = asOrchestrationError(error) + if (classified && classified.code !== 'internal') return classified.message + if ( + error instanceof WorkspaceFileFolderConflictError || + error instanceof WorkspaceFileMoveConflictError || + error instanceof FolderPathError + ) { + return error.message + } + throw error +} + +async function ensureFolderPath(params: { + workspaceId: string + userId: string + segments: readonly string[] + createdFolders: CreatedFolder[] +}): Promise<string | null> { + let folderId: string | null = null + for (let index = 0; index < params.segments.length; index += 1) { + const segments = params.segments.slice(0, index + 1) + const existing = await findWorkspaceFileFolderIdByPath(params.workspaceId, [...segments]) + if (existing) { + folderId = existing + continue + } + const path = buildFolderPath(segments) + try { + const created = await createWorkspaceFileFolderAtPath({ + workspaceId: params.workspaceId, + userId: params.userId, + path, + }) + folderId = created.folder.id + params.createdFolders.push({ + id: created.folder.id, + name: created.folder.name, + path: created.path, + }) + } catch (error) { + if (error instanceof WorkspaceFileFolderConflictError) { + const concurrentlyCreated = await findWorkspaceFileFolderIdByPath(params.workspaceId, [ + ...segments, + ]) + if (concurrentlyCreated) { + folderId = concurrentlyCreated + continue + } + } + throw error + } + } + return folderId +} + +async function resolveSource( + workspaceId: string, + reference: WorkspaceFileVfsPathReference +): Promise< + | { source: string; file: NonNullable<Awaited<ReturnType<typeof getWorkspaceFileByName>>> } + | { source: string; folderId: string } + | { source: string; error: string } +> { + if (reference.segments.length === 0) { + return { source: reference.source, error: 'Source must name a file or folder under files/' } + } + const parentSegments = reference.segments.slice(0, -1) + const folderId = + parentSegments.length === 0 + ? null + : await findWorkspaceFileFolderIdByPath(workspaceId, parentSegments) + if (parentSegments.length === 0 || folderId) { + const file = await getWorkspaceFileByName(workspaceId, reference.segments.at(-1) as string, { + folderId, + }) + if (file) return { source: reference.source, file } + } + const sourceFolderId = await findWorkspaceFileFolderIdByPath(workspaceId, reference.segments) + return sourceFolderId + ? { source: reference.source, folderId: sourceFolderId } + : { source: reference.source, error: `Not found: ${reference.source}` } +} + +function createdFolderAudits(folders: readonly CreatedFolder[]) { + return folders.map((folder) => ({ + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + description: `Created file folder "${folder.path}"`, + metadata: { path: folder.path, folderResourceType: 'file' }, + })) +} + +export interface CreateWorkspaceFileVfsFoldersInput { + workspaceId: string + paths: WorkspaceFileVfsPathReference[] +} + +export const createWorkspaceFileVfsFolders = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.createVfsFolders, + resolveContext: ({ input }: { input: CreateWorkspaceFileVfsFoldersInput }) => + loadWorkspaceFileOperationContext(input.workspaceId).then((context) => { + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }), + async execute({ principal, input, context }) { + const paths = normalizeReferences(input.paths) + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const createdFolders: CreatedFolder[] = [] + const outcomes: WorkspaceFileVfsOutcome[] = [] + for (const path of paths) { + if (path.segments.length === 0) { + outcomes.push({ + source: path.source, + resourceType: 'folder', + error: 'Path must include at least one folder segment', + }) + continue + } + try { + const folderId = await ensureFolderPath({ + workspaceId: context.workspaceId, + userId, + segments: path.segments, + createdFolders, + }) + outcomes.push({ + source: path.source, + targetSegments: path.segments, + resourceType: 'folder', + resourceId: folderId ?? undefined, + }) + } catch (error) { + outcomes.push({ + source: path.source, + resourceType: 'folder', + error: expectedOutcomeMessage(error), + }) + } + } + return { outcomes, createdFolders } + }, + projectAudit: ({ result }) => createdFolderAudits(result.createdFolders), + afterSuccess: ({ context, result }) => + result.createdFolders.length > 0 ? notifyWorkspaceFilesChanged(context.workspaceId) : undefined, +}) + +export interface RelocateWorkspaceFileVfsItemsInput { + workspaceId: string + sources: WorkspaceFileVfsPathReference[] + destination: WorkspaceFileVfsDestination +} + +export const relocateWorkspaceFileVfsItems = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.relocateVfsItems, + resolveContext: ({ input }: { input: RelocateWorkspaceFileVfsItemsInput }) => + loadWorkspaceFileOperationContext(input.workspaceId).then((context) => { + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }), + async execute({ principal, input, context }) { + const sources = normalizeReferences(input.sources) + try { + validateVfsPathSegments(input.destination.segments) + } catch (error) { + if (error instanceof VfsPathLimitError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + const references = [] + for (const source of sources) { + references.push(await resolveSource(context.workspaceId, source)) + } + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const destinationPath = buildFolderPath(input.destination.segments) + const existingDestination = await findWorkspaceFileFolderIdByPath( + context.workspaceId, + input.destination.segments + ) + const dirMode = + input.destination.segments.length === 0 || + input.destination.trailingSlash || + existingDestination !== null + if (!dirMode && sources.length > 1) { + throw new OrchestrationError( + 'validation', + `With multiple sources the destination must be a folder. "${destinationPath}" does not exist — end it with "/" to create it.` + ) + } + const folderSegments = dirMode + ? input.destination.segments + : input.destination.segments.slice(0, -1) + const leafName = dirMode ? undefined : input.destination.segments.at(-1) + const createdFolders: CreatedFolder[] = [] + let targetFolderPromise: Promise<string | null> | undefined + const targetFolderId = () => + (targetFolderPromise ??= ensureFolderPath({ + workspaceId: context.workspaceId, + userId, + segments: folderSegments, + createdFolders, + })) + const outcomes: WorkspaceFileVfsOutcome[] = [] + const relocatedFiles: RelocatedFile[] = [] + const relocatedFolders: RelocatedFolder[] = [] + + for (const reference of references) { + if ('error' in reference) { + outcomes.push({ source: reference.source, resourceType: 'file', error: reference.error }) + continue + } + try { + const targetId = await targetFolderId() + if ('file' in reference) { + const name = leafName ?? reference.file.name + const result = await moveRenameWorkspaceFile({ + workspaceId: context.workspaceId, + fileId: reference.file.id, + targetFolderId: targetId, + newName: name, + }) + relocatedFiles.push({ + id: result.file.id, + name: result.file.name, + moved: result.moved, + renamed: result.renamed, + }) + outcomes.push({ + source: reference.source, + targetSegments: [...folderSegments, result.file.name], + resourceType: 'file', + resourceId: result.file.id, + }) + continue + } + if (targetId === reference.folderId) { + outcomes.push({ + source: reference.source, + resourceType: 'folder', + error: 'Cannot move a folder into itself', + }) + continue + } + const sourcePath = buildFolderPath( + sources.find((source) => source.source === reference.source)?.segments ?? [] + ) + const name = + leafName ?? sources.find((source) => source.source === reference.source)?.segments.at(-1) + if (!name) throw new OrchestrationError('validation', 'Folder name is required') + const nextPath = buildFolderPath([...folderSegments, name]) + const result = await relocateWorkspaceFileFolderByPath({ + workspaceId: context.workspaceId, + path: sourcePath, + destinationPath: nextPath, + }) + relocatedFolders.push({ + id: result.folder.id, + name: result.folder.name, + sourcePath, + destinationPath: result.path, + }) + outcomes.push({ + source: reference.source, + targetSegments: [...folderSegments, result.folder.name], + resourceType: 'folder', + resourceId: result.folder.id, + }) + } catch (error) { + outcomes.push({ + source: reference.source, + resourceType: 'file' in reference ? 'file' : 'folder', + error: expectedOutcomeMessage(error), + }) + } + } + return { outcomes, createdFolders, relocatedFiles, relocatedFolders } + }, + projectAudit: ({ result }) => [ + ...createdFolderAudits(result.createdFolders), + ...result.relocatedFiles + .filter((file) => file.moved || file.renamed) + .map((file) => ({ + action: file.moved ? AuditAction.FILE_MOVED : AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.name, + description: `Relocated file "${file.name}"`, + metadata: { moved: file.moved, renamed: file.renamed }, + })), + ...result.relocatedFolders.map((folder) => ({ + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + description: `Moved file folder to "${folder.destinationPath}"`, + metadata: { sourcePath: folder.sourcePath, destinationPath: folder.destinationPath }, + })), + ], + afterSuccess: ({ context, result }) => + result.createdFolders.length > 0 || + result.relocatedFiles.some((file) => file.moved || file.renamed) || + result.relocatedFolders.length > 0 + ? notifyWorkspaceFilesChanged(context.workspaceId) + : undefined, +}) + +export interface DeleteWorkspaceFileVfsItemsInput { + workspaceId: string + paths: WorkspaceFileVfsPathReference[] +} + +export const deleteWorkspaceFileVfsItems = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.deleteVfsItems, + resolveContext: ({ input }: { input: DeleteWorkspaceFileVfsItemsInput }) => + loadWorkspaceFileOperationContext(input.workspaceId).then((context) => { + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }), + async execute({ input, context }) { + const paths = normalizeReferences(input.paths) + const references = [] + for (const path of paths) { + references.push(await resolveSource(context.workspaceId, path)) + } + const outcomes: WorkspaceFileVfsOutcome[] = [] + const deletedFiles: Array<{ id: string; name: string }> = [] + const deletedFolders: Array<{ id: string; path: string }> = [] + for (const reference of references) { + if ('error' in reference) { + outcomes.push({ source: reference.source, resourceType: 'file', error: reference.error }) + continue + } + try { + if ('file' in reference) { + const archived = await bulkArchiveWorkspaceFileItems({ + workspaceId: context.workspaceId, + fileIds: [reference.file.id], + }) + if (!archived.fileIds.includes(reference.file.id)) { + throw new OrchestrationError('not_found', 'File not found') + } + deletedFiles.push({ id: reference.file.id, name: reference.file.name }) + outcomes.push({ + source: reference.source, + resourceType: 'file', + resourceId: reference.file.id, + }) + continue + } + const archived = await bulkArchiveWorkspaceFileItems({ + workspaceId: context.workspaceId, + folderIds: [reference.folderId], + }) + if (!archived.folderIds.includes(reference.folderId)) { + throw new OrchestrationError('not_found', 'Folder not found') + } + deletedFolders.push({ id: reference.folderId, path: reference.source }) + outcomes.push({ + source: reference.source, + resourceType: 'folder', + resourceId: reference.folderId, + }) + } catch (error) { + outcomes.push({ + source: reference.source, + resourceType: 'file' in reference ? 'file' : 'folder', + error: expectedOutcomeMessage(error), + }) + } + } + return { outcomes, deletedFiles, deletedFolders } + }, + projectAudit: ({ result }) => [ + ...result.deletedFiles.map((file) => ({ + action: AuditAction.FILE_DELETED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.name, + description: `Archived file "${file.name}"`, + })), + ...result.deletedFolders.map((folder) => ({ + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + description: `Archived file folder "${folder.path}"`, + metadata: { path: folder.path }, + })), + ], + afterSuccess: ({ context, result }) => + result.deletedFiles.length > 0 || result.deletedFolders.length > 0 + ? notifyWorkspaceFilesChanged(context.workspaceId) + : undefined, +}) diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index c550b19dbcf..e5ade86d4ac 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -1,5 +1,16 @@ import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const workflowMetadataMocks = vi.hoisted(() => ({ + buildAPIUrl: vi.fn((path: string) => new URL(path, 'https://sim.local')), + buildExecutorDelegationHeaders: vi.fn(), +})) + +vi.mock('@/executor/utils/http', () => ({ + buildAPIUrl: workflowMetadataMocks.buildAPIUrl, + buildExecutorDelegationHeaders: workflowMetadataMocks.buildExecutorDelegationHeaders, +})) + import { calculateCost, describeModelLevel, @@ -1841,6 +1852,136 @@ describe('prepareToolExecution invoker identity hand-off', () => { }) }) +describe('workflow executor metadata delegation', () => { + const workflowBlock = { + type: 'workflow', + name: 'Workflow', + description: 'Execute a workflow', + inputs: {}, + subBlocks: [], + tools: { access: ['workflow_executor'] }, + } + const workflowTool = { + id: 'workflow_executor', + name: 'Workflow Executor', + description: 'Execute another workflow', + params: { + workflowId: { + type: 'string' as const, + required: true, + visibility: 'user-only' as const, + }, + }, + } + + beforeEach(() => { + vi.clearAllMocks() + workflowMetadataMocks.buildExecutorDelegationHeaders.mockResolvedValue({ + 'Content-Type': 'application/json', + Authorization: 'Bearer delegated-token', + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('binds cross-workflow metadata reads to the target without attaching the parent run', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ data: { name: 'Child Workflow', description: 'Child description' } }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await transformBlockTool( + { type: 'workflow', params: { workflowId: 'child-workflow' } }, + { + getAllBlocks: () => [workflowBlock], + getTool: () => workflowTool, + enrichmentContext: { + workflowId: 'parent-workflow', + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'user-1', + }, + } + ) + + expect(workflowMetadataMocks.buildExecutorDelegationHeaders).toHaveBeenCalledWith({ + subjectUserId: 'user-1', + workflowId: 'child-workflow', + }) + expect(fetchMock).toHaveBeenCalledWith('https://sim.local/api/workflows/child-workflow', { + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer delegated-token', + }, + }) + expect(result).toMatchObject({ + id: 'workflow_executor_child-workflow', + name: 'Child Workflow', + description: 'Child description', + }) + }) + + it('includes the run binding when the metadata target is the executing workflow', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: { name: 'Current Workflow', description: null } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) + + await transformBlockTool( + { type: 'workflow', params: { workflowId: 'current-workflow' } }, + { + getAllBlocks: () => [workflowBlock], + getTool: () => workflowTool, + enrichmentContext: { + workflowId: 'current-workflow', + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'user-1', + }, + } + ) + + expect(workflowMetadataMocks.buildExecutorDelegationHeaders).toHaveBeenCalledWith({ + subjectUserId: 'user-1', + workflowId: 'current-workflow', + executionId: 'execution-1', + }) + }) + + it('does not issue an actorless fallback token without a trusted execution subject', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const result = await transformBlockTool( + { type: 'workflow', params: { workflowId: 'child-workflow' } }, + { + getAllBlocks: () => [workflowBlock], + getTool: () => workflowTool, + } + ) + + expect(workflowMetadataMocks.buildExecutorDelegationHeaders).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + expect(result).toMatchObject({ + id: 'workflow_executor_child-workflow', + name: 'Workflow Executor', + description: 'Execute another workflow', + }) + }) +}) + /** * The agent block's tuning-level fields accept variable and environment references, so any * message that echoes a caller-supplied level can otherwise carry whatever that reference diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 7d805394ea8..22e34f528ac 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -77,12 +77,22 @@ function isDefaultWorkflowDescription( * Fetches workflow metadata (name and description) from the API */ async function fetchWorkflowMetadata( - workflowId: string + workflowId: string, + executionContext: WorkflowToolExecutionContext | undefined ): Promise<{ name: string; description: string | null } | null> { try { - const { buildAuthHeaders, buildAPIUrl } = await import('@/executor/utils/http') - - const headers = await buildAuthHeaders() + if (!executionContext?.userId) { + throw new Error('Workflow metadata enrichment requires a trusted execution subject') + } + const { buildAPIUrl, buildExecutorDelegationHeaders } = await import('@/executor/utils/http') + + const headers = await buildExecutorDelegationHeaders({ + subjectUserId: executionContext.userId, + workflowId, + ...(executionContext.workflowId === workflowId && executionContext.executionId + ? { executionId: executionContext.executionId } + : {}), + }) const url = buildAPIUrl(`/api/workflows/${workflowId}`) const response = await fetch(url.toString(), { headers }) @@ -787,7 +797,10 @@ export async function transformBlockTool( if (toolId === 'workflow_executor' && resolvedResourceParams.workflowId) { uniqueToolId = `${toolConfig.id}_${resolvedResourceParams.workflowId}` - const workflowMetadata = await fetchWorkflowMetadata(resolvedResourceParams.workflowId) + const workflowMetadata = await fetchWorkflowMetadata( + resolvedResourceParams.workflowId, + enrichmentContext + ) if (workflowMetadata) { toolName = workflowMetadata.name || toolConfig.name if ( diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index cb94f7fa435..f33e8269094 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -185,6 +185,7 @@ export const AuditAction = { // Workflows WORKFLOW_CREATED: 'workflow.created', + WORKFLOW_UPDATED: 'workflow.updated', WORKFLOW_DELETED: 'workflow.deleted', WORKFLOW_RESTORED: 'workflow.restored', WORKFLOW_DEPLOYED: 'workflow.deployed', From b25e7ba0c3775846f094d78cc0b9989763be0086 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <siddharthganesan@gmail.com> Date: Sat, 8 Aug 2026 17:08:18 -0700 Subject: [PATCH 121/159] fix(copilot): recover tool arguments lost when a call is checkpointed mid-generation Tool arguments reach Sim two ways: whole on a frame's `arguments`, or in pieces as `args_delta` chunks that accumulate into `streamingArgs`. Only the first populated `params`, so a call checkpointed before any frame carried `arguments` executed with `{}` and failed its own schema on every required property. The file subagent's `workspace_file` calls arrive exactly that way, which left the agent retrying and then routing around the tool entirely. - executor: hydrate `params` from the streamed deltas before dispatch, covering both normal dispatch and the never-dispatched resume path. - handlers: record the subagent channel at registration rather than only on a finalized frame, so the workspace_file -> edit_content intent handoff can find its intent instead of reporting "No workspace_file context found". - preview adapter: pass the frame's tool call id into file delegation, which derives its audit id from it. Without it every preview threw and no file content streamed at all. - run: a checkpointed call with no recorded result now reports a failed result instead of throwing, which ended the whole turn and cost the user the entire response. Each fix has a regression test verified to fail without it. --- .../request/go/file-preview-adapter.ts | 10 +- .../sim/lib/copilot/request/go/stream.test.ts | 8 +- .../copilot/request/handlers/handlers.test.ts | 103 ++++++++++++++++++ apps/sim/lib/copilot/request/handlers/tool.ts | 5 + .../lib/copilot/request/lifecycle/run.test.ts | 62 +++++++++++ apps/sim/lib/copilot/request/lifecycle/run.ts | 22 +++- .../sim/lib/copilot/request/tools/executor.ts | 29 +++++ 7 files changed, 234 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts index feaa0822416..a950cb90d8c 100644 --- a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts +++ b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts @@ -376,7 +376,11 @@ export async function processFilePreviewStreamEvent(input: { if (toolCallId && parsedArgs) { const { operation, title, contentType, edit } = parsedArgs const target = await resolvePreviewTarget({ - context: execContext, + /* File delegation derives its audit id from the tool call + (`copilot-tool:<toolCallId>`), and that id lives on the frame rather + than the turn-scoped context. Passing the turn context alone made + every preview throw, so the file stopped streaming entirely. */ + context: { ...execContext, toolCallId }, workspaceId: execContext.workspaceId, target: parsedArgs.target, }) @@ -405,7 +409,7 @@ export async function processFilePreviewStreamEvent(input: { (operation === 'append' || operation === 'patch') ) { previewBase = await loadWorkspaceFileTextForPreview( - execContext, + { ...execContext, toolCallId }, execContext.workspaceId, fileId ) @@ -480,7 +484,7 @@ export async function processFilePreviewStreamEvent(input: { (intent.operation === 'append' || intent.operation === 'patch') ) { previewBase = await loadWorkspaceFileTextForPreview( - execContext, + { ...execContext, toolCallId: streamEvent.payload.toolCallId }, execContext.workspaceId, result.fileId ) diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 17b47aa79b5..1a2d114d304 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -38,7 +38,13 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ }) ?? null, })) vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ - listAllWorkspaceFiles: { execute: listAllWorkspaceFilesMock }, + /* `executeCopilotFileUseCase` reads `useCase.operation.id` to check the + operation is registered before running it, so a use-case mock without an + `operation` throws before `execute` is ever reached. */ + listAllWorkspaceFiles: { + execute: listAllWorkspaceFilesMock, + operation: { id: 'files.list' }, + }, })) vi.mock('@/lib/copilot/tools/server/files/file-preview', async () => { diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index d8adfb16857..ebeaad2015d 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -886,6 +886,109 @@ describe('sse-handlers tool lifecycle', () => { ) }) + /** + * A call can be checkpointed while every frame it received is still + * `generating`. The subagent channel must already be on the tool call by + * then: the workspace_file -> edit_content intent handoff scopes on it, and + * recording it only on a finalized frame left edit_content reporting + * "No workspace_file context found" for a write that had in fact succeeded. + */ + /** + * Arguments reach Sim either whole on a frame or in `args_delta` pieces. A + * call checkpointed before any frame carries `arguments` used to execute with + * `{}` — its own schema then rejected every required property — even though + * the full argument JSON had already arrived as deltas. + */ + it('executes with arguments recovered from args_delta frames', async () => { + executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) + context.toolCalls.set('parent-1', { + id: 'parent-1', + name: 'file', + status: 'pending', + startTime: Date.now(), + }) + + const call = { + toolCallId: 'sub-tool-delta', + toolName: 'workspace_file', + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + } + const scope = { lane: 'subagent', parentToolCallId: 'parent-1', agentId: 'file' } as const + + // Registered while still generating, with no arguments on the frame. + await subAgentHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + scope, + payload: { ...call, status: 'generating' }, + } as StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + for (const argumentsDelta of ['{"operation":"update",', '"title":"Set contents"}']) { + await subAgentHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + scope, + payload: { + toolCallId: 'sub-tool-delta', + toolName: 'workspace_file', + phase: 'args_delta', + argumentsDelta, + }, + } as unknown as StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + } + + await executeToolAndReport('sub-tool-delta', context, execContext, { + interactive: false, + timeout: 1000, + }) + + expect(executeTool).toHaveBeenCalledWith( + 'workspace_file', + { operation: 'update', title: 'Set contents' }, + expect.any(Object) + ) + }) + + it('records the subagent channel from a generating frame, before any final frame', async () => { + context.toolCalls.set('parent-1', { + id: 'parent-1', + name: 'file', + status: 'pending', + startTime: Date.now(), + }) + + await subAgentHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + scope: { lane: 'subagent', parentToolCallId: 'parent-1', agentId: 'file' }, + payload: { + toolCallId: 'sub-tool-partial', + toolName: 'workspace_file', + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + status: 'generating', + arguments: { operation: 'update' }, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + expect(context.toolCalls.get('sub-tool-partial')?.parentToolCallId).toBe('parent-1') + }) + it('updates stored params when a subagent generating event is followed by the final tool call', async () => { executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) context.toolCalls.set('parent-1', { diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index 9606e8e5261..eab680ef9b6 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -583,6 +583,11 @@ function registerSubagentToolCall( status: 'pending', agentId, params: args, + /* The invoking subagent's channel, recorded here rather than only on a + finalized frame: a call checkpointed while every frame is still partial + would otherwise execute with no channel, and the workspace_file -> + edit_content intent handoff scopes on exactly this id. */ + parentToolCallId, startTime: Date.now(), } applyToolDisplay(toolCall) diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index aaa92662750..092b5a97039 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -1634,6 +1634,68 @@ describe('runCopilotLifecycle', () => { } }) + /** + * Go blocks on a result for every checkpointed call. Throwing here used to end + * the whole turn, so one unrecorded result cost the user the entire response — + * and the only thing preventing it was a partial frame happening to register + * the call. Report the failure as that tool's result instead, so the model + * sees one failed call and can route around it. + */ + it('reports a failed result instead of ending the turn when a checkpointed tool has none', async () => { + const billingAttribution = { + actorUserId: 'user-1', + workspaceId: 'ws-1', + billedAccountUserId: 'owner-1', + organizationId: 'org-1', + billingEntity: { type: 'organization' as const, id: 'org-1' }, + billingPeriod: { + start: '2026-07-01T00:00:00.000Z', + end: '2026-08-01T00:00:00.000Z', + }, + payerSubscription: null, + } + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise<void> => { + // Registered but never resolved — no `result` ever recorded. + context.toolCalls.set('tool-1', { + id: 'tool-1', + name: 'workspace_file', + status: MothershipStreamV1ToolOutcome.error, + }) + context.awaitingAsyncContinuation = { + checkpointId: 'ckpt-1', + pendingToolCallIds: ['tool-1'], + } + } + ) + mockRunStreamLoop.mockResolvedValueOnce(undefined) + + await expect( + runCopilotLifecycle( + { message: 'hello', messageId: 'message-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'execution-1', + runId: 'run-1', + simRequestId: 'request-1', + billingAttribution, + } + ) + ).resolves.toBeDefined() + + // The turn continued: a second leg ran, carrying the failed result back. + expect(mockRunStreamLoop).toHaveBeenCalledTimes(2) + const resumeBody = JSON.parse((mockRunStreamLoop.mock.calls[1]?.[1].body as string) ?? '{}') + const sent = JSON.stringify(resumeBody) + expect(sent).toContain('tool-1') + }) + it('fails closed instead of sending a secret-bearing tool name on resume', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: 'unsafe-tool', encryptedValue: 'ciphertext' }, diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index a92eb175a55..513c592a2c3 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -1780,7 +1780,27 @@ async function runCheckpointLoop( toolStatus: tool?.status, hasPendingPromise: context.pendingToolPromises.has(toolCallId), }) - throw new Error(`Cannot resume: missing result for pending tool call ${toolCallId}`) + /** + * Go is blocked on a result for every checkpointed call, so throwing + * here ends the turn outright and the user loses the whole response. + * Report the failure as that tool's result instead: the model sees one + * failed call and can retry or route around it, which is how every + * other tool failure already behaves. Reached only when a call was + * checkpointed without Sim ever recording a result for it. + */ + const failedName = tool?.name ?? '' + results.push({ + callId: toolCallId, + name: failedName, + data: getToolCallTerminalData({ + id: toolCallId, + name: failedName, + status: MothershipStreamV1ToolOutcome.error, + error: `Tool call ${toolCallId} produced no result before resume`, + }), + success: false, + }) + continue } const name = tool.name || '' if (!isResolvedSecretModelContentUnchanged(name, execContext.resolvedSecretTraceRegistry)) { diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index ccaf638aaa9..1427e7a9e41 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -499,6 +499,33 @@ function terminalCompletionFromToolCall(toolCall: ToolCallState): AsyncToolCompl }) } +/** + * Recovers a tool call's arguments from the raw argument stream. + * + * Arguments reach Sim two ways: whole, on a tool frame's `arguments`, or in + * pieces, as `argumentsDelta` chunks that `handleToolArgsDelta` concatenates + * into `streamingArgs`. Only the first populates `params`. When a call is + * checkpointed before any frame carries `arguments` — which is how the file + * subagent's `workspace_file` calls arrive — `params` stays undefined and the + * tool executes with `{}`, failing its own schema on every required property. + * The arguments were never lost, only unparsed, so recover them here rather + * than dispatching a call known to be incomplete. + */ +function hydrateParamsFromStreamedArgs(toolCall: ToolCallState): void { + if (toolCall.params !== undefined) return + const streamed = toolCall.streamingArgs?.trim() + if (!streamed) return + try { + const parsed = JSON.parse(streamed) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + toolCall.params = parsed as Record<string, unknown> + } + } catch { + // A truncated stream is not recoverable; leave params undefined so the + // tool's own validation reports the failure. + } +} + export async function executeToolAndReport( toolCallId: string, context: StreamingContext, @@ -512,6 +539,8 @@ export async function executeToolAndReport( message: 'Tool call not found', }) + hydrateParamsFromStreamedArgs(toolCall) + const argsPayload = toolCall.params ? (() => { try { From 2af85228c0fbec71cbbdcddffbf89597451466d6 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Thu, 13 Aug 2026 11:15:00 -0700 Subject: [PATCH 122/159] feat(credentials): add v2 OAuth connection APIs --- 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 | 353 +++++++++++++++++- apps/docs/openapi-v2-tables.json | 2 +- apps/docs/openapi-v2-workflows.json | 2 +- .../api/auth/oauth2/authorize/route.test.ts | 85 ++++- .../app/api/auth/oauth2/authorize/route.ts | 180 +++++---- .../v2/credential-connections/route.test.ts | 150 ++++++++ .../api/v2/credential-connections/route.ts | 32 ++ .../api/v2/credential-providers/route.test.ts | 124 ++++++ .../app/api/v2/credential-providers/route.ts | 27 ++ .../oauth/credential-connected/page.test.tsx | 36 ++ .../app/oauth/credential-connected/page.tsx | 50 +++ .../lib/api/contracts/oauth-connections.ts | 46 ++- .../v2/__tests__/list-pagination.test.ts | 1 + apps/sim/lib/api/contracts/v2/credentials.ts | 129 +++++++ .../lib/api/contracts/v2/openapi/resources.ts | 77 +++- apps/sim/lib/core/application/forbidden.ts | 4 + .../application/connection-target.test.ts | 150 ++++++++ .../application/connection-target.ts | 92 +++++ .../create-credential-connection.test.ts | 128 +++++++ .../create-credential-connection.ts | 53 +++ .../launch-credential-connection.test.ts | 93 +++++ .../launch-credential-connection.ts | 52 +++ .../list-credential-providers.test.ts | 68 ++++ .../application/list-credential-providers.ts | 29 ++ .../lib/credentials/application/operations.ts | 18 + .../application/provider-catalog.test.ts | 176 +++++++++ .../application/provider-catalog.ts | 113 ++++++ apps/sim/lib/credentials/connect-draft.ts | 83 ++-- scripts/check-api-validation-contracts.ts | 4 +- scripts/openapi/documents.test.ts | 4 +- 34 files changed, 2244 insertions(+), 125 deletions(-) create mode 100644 apps/sim/app/api/v2/credential-connections/route.test.ts create mode 100644 apps/sim/app/api/v2/credential-connections/route.ts create mode 100644 apps/sim/app/api/v2/credential-providers/route.test.ts create mode 100644 apps/sim/app/api/v2/credential-providers/route.ts create mode 100644 apps/sim/app/oauth/credential-connected/page.test.tsx create mode 100644 apps/sim/app/oauth/credential-connected/page.tsx create mode 100644 apps/sim/lib/credentials/application/connection-target.test.ts create mode 100644 apps/sim/lib/credentials/application/connection-target.ts create mode 100644 apps/sim/lib/credentials/application/create-credential-connection.test.ts create mode 100644 apps/sim/lib/credentials/application/create-credential-connection.ts create mode 100644 apps/sim/lib/credentials/application/launch-credential-connection.test.ts create mode 100644 apps/sim/lib/credentials/application/launch-credential-connection.ts create mode 100644 apps/sim/lib/credentials/application/list-credential-providers.test.ts create mode 100644 apps/sim/lib/credentials/application/list-credential-providers.ts create mode 100644 apps/sim/lib/credentials/application/provider-catalog.test.ts create mode 100644 apps/sim/lib/credentials/application/provider-catalog.ts diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 5b513c1c03d..eada35ce0ed 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -477,7 +477,7 @@ "description": "Human-readable explanation of the 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." + "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- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\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"], diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 3e605aa00e1..6a29b4542a5 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -2245,7 +2245,7 @@ "description": "Human-readable explanation of the 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." + "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- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\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"], diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index e0d93148561..d7e63995376 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -2206,7 +2206,7 @@ "description": "Human-readable explanation of the 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." + "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- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\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"], diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index eaf97c33c96..89b62afab4c 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -596,7 +596,7 @@ "description": "Human-readable explanation of the 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." + "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- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\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"], diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 6b7fe8bd62b..69ae6f3206e 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -39,7 +39,7 @@ }, { "name": "Credentials", - "description": "List OAuth and service-account connections without secret material." + "description": "Discover OAuth providers, connect or reconnect accounts, and list connections without secret material." }, { "name": "Secrets", @@ -1705,6 +1705,140 @@ } } }, + "/api/v2/credential-providers": { + "get": { + "operationId": "listCredentialProviders", + "summary": "List Credential Providers", + "description": "List catalogued OAuth services and whether each is available to the caller in this workspace and deployment. Authorization options contain the exact provider IDs accepted by the connection endpoint. The bounded set is returned in one page; `nextCursor` is always null.", + "tags": ["Credentials"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace used to evaluate OAuth availability and integration policy.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workspace used to evaluate OAuth availability and integration policy." + } + } + ], + "responses": { + "200": { + "description": "OAuth provider catalog with caller-specific availability.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListCredentialProvidersResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/credential-connections": { + "post": { + "operationId": "createCredentialConnection", + "summary": "Create Credential Connection", + "description": "Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL in a browser, sign in as the personal API-key owner, complete provider authorization, then refresh the credentials list. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], + "requestBody": { + "required": true, + "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCredentialConnectionBody" + } + } + } + }, + "responses": { + "200": { + "description": "A short-lived browser authorization URL.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCredentialConnectionResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/secrets": { "get": { "operationId": "listSecrets", @@ -2264,7 +2398,7 @@ "description": "Human-readable explanation of the 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." + "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- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\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"], @@ -4075,6 +4209,221 @@ } ] }, + "V2CredentialProvider": { + "type": "object", + "properties": { + "serviceId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stable OAuth service identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "OAuth service display name." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "OAuth service description." + }, + "providerFamily": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Owning provider family identifier." + }, + "available": { + "type": "boolean", + "description": "Whether this caller can start the OAuth flow in the current deployment." + }, + "supportsReconnect": { + "type": "boolean", + "description": "Whether existing credentials for this service can be reconnected." + }, + "authorizationOptions": { + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "object", + "properties": { + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact OAuth provider identifier accepted by the connection endpoint." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable authorization-server label." + } + }, + "required": ["providerId", "label"], + "additionalProperties": false + }, + "description": "Authorization servers available for this OAuth service." + } + }, + "required": [ + "serviceId", + "name", + "description", + "providerFamily", + "available", + "supportsReconnect", + "authorizationOptions" + ], + "additionalProperties": false, + "title": "Credential Provider", + "description": "An OAuth service that may be connected to a workspace." + }, + "ListCredentialProvidersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2CredentialProvider" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "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": "List credential providers response", + "description": "OAuth providers and their authorization-server options.", + "examples": [ + { + "data": [ + { + "serviceId": "salesforce", + "name": "Salesforce", + "description": "Connect to Salesforce CRM data and operations.", + "providerFamily": "salesforce", + "available": true, + "supportsReconnect": true, + "authorizationOptions": [ + { + "providerId": "salesforce", + "label": "Production" + }, + { + "providerId": "salesforce-sandbox", + "label": "Sandbox" + } + ] + } + ], + "nextCursor": null + } + ] + }, + "V2CredentialConnectionAuthorization": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri", + "description": "Short-lived Sim browser URL that starts the OAuth authorization flow." + }, + "expiresAt": { + "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": "ISO 8601 timestamp when the connection link expires." + } + }, + "required": ["authorizationUrl", "expiresAt"], + "additionalProperties": false, + "title": "Credential Connection Authorization", + "description": "A short-lived browser entrypoint for an OAuth connection flow." + }, + "CreateCredentialConnectionResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CredentialConnectionAuthorization" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create credential connection response", + "description": "Short-lived Sim browser entrypoint and its expiry.", + "examples": [ + { + "data": { + "authorizationUrl": "https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123", + "expiresAt": "2026-06-20T14:17:11.000Z" + } + } + ] + }, + "CreateCredentialConnectionBody": { + "anyOf": [ + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace that will own the credential." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact provider ID returned by the credential-provider catalog." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name shown for the new credential in Sim." + } + }, + "required": ["workspaceId", "providerId", "displayName"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace expected to own the credential." + }, + "credentialId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Existing OAuth credential to reconnect in place." + } + }, + "required": ["workspaceId", "credentialId"], + "additionalProperties": false + } + ], + "title": "Create credential connection body", + "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved." + }, "V2Secret": { "type": "object", "properties": { diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index a4aff379376..c33fe610f69 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -3976,7 +3976,7 @@ "description": "Human-readable explanation of the 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." + "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- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\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"], diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index c93a2b3352e..19a0b5f6fcd 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -2292,7 +2292,7 @@ "description": "Human-readable explanation of the 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." + "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- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\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"], diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index 54f49a5e29f..938e0f3310a 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -4,8 +4,10 @@ import { createMockRequest, dbChainMockFns, + queueTableRows, resetDbChainMock, resetEnvMock, + schemaMock, setEnv, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -15,11 +17,13 @@ const { mockOAuth2LinkAccount, mockCheckWorkspaceAccess, mockGetCredentialActorContext, + mockLaunchCredentialConnection, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockOAuth2LinkAccount: vi.fn(), mockCheckWorkspaceAccess: vi.fn(), mockGetCredentialActorContext: vi.fn(), + mockLaunchCredentialConnection: vi.fn(), })) vi.mock('@/lib/auth/auth', () => ({ @@ -35,6 +39,13 @@ vi.mock('@/lib/credentials/access', () => ({ getCredentialActorContext: mockGetCredentialActorContext, })) +vi.mock('@/lib/credentials/application/launch-credential-connection', () => ({ + launchCredentialConnection: { + operation: { id: 'credentials.connections.launch' }, + execute: mockLaunchCredentialConnection, + }, +})) + vi.mock('@/lib/oauth/utils', () => ({ getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]), // Real implementation: a credential id matches its service's OAuth id, an @@ -99,7 +110,16 @@ describe('OAuth2 authorize route', () => { GOOGLE_CLIENT_ID: 'google-client', GOOGLE_CLIENT_SECRET: 'google-secret', }) - mockGetSession.mockResolvedValue({ user: { id: USER_ID } }) + mockGetSession.mockResolvedValue({ + user: { id: USER_ID }, + session: { id: 'session-1' }, + }) + queueTableRows(schemaMock.user, [{ name: 'Test User' }]) + dbChainMockFns.onConflictDoUpdate.mockImplementation(() => ({ + returning: vi + .fn() + .mockResolvedValue([{ id: 'draft-1', expiresAt: new Date('2026-08-12T20:15:00.000Z') }]), + })) mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, @@ -114,6 +134,69 @@ describe('OAuth2 authorize route', () => { }) }) + describe('draft-bound connection', () => { + it('resolves the exact user-bound draft before starting OAuth', async () => { + mockLaunchCredentialConnection.mockResolvedValue({ + draft: { + id: 'draft-1', + userId: USER_ID, + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: "Test User's Gmail", + description: null, + credentialId: null, + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + createdAt: new Date('2026-08-12T20:00:00.000Z'), + }, + }) + const request = authorizeRequest({ draftId: 'draft-1' }) + + const response = await GET(request) + + expect(response.headers.get('location')).toBe(LINK_URL) + expect(mockLaunchCredentialConnection).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: USER_ID, sessionId: 'session-1' }, + input: { draftId: 'draft-1' }, + request, + }) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + expect(dbChainMockFns.values).not.toHaveBeenCalled() + expect(mockOAuth2LinkAccount).toHaveBeenCalledWith( + expect.objectContaining({ + body: { + providerId: 'google-email', + callbackURL: `${BASE_URL}/oauth/credential-connected?result=connected`, + errorCallbackURL: `${BASE_URL}/oauth/credential-connected?result=failed`, + }, + }) + ) + }) + + it('hands custom providers to their authenticated browser flow', async () => { + setEnv({ TRELLO_API_KEY: 'trello-key' }) + mockLaunchCredentialConnection.mockResolvedValue({ + draft: { + id: 'draft-1', + userId: USER_ID, + workspaceId: WORKSPACE_ID, + providerId: 'trello', + displayName: "Test User's Trello", + description: null, + credentialId: null, + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + createdAt: new Date('2026-08-12T20:00:00.000Z'), + }, + }) + + const response = await GET(authorizeRequest({ draftId: 'draft-1' })) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/api/auth/trello/authorize?returnUrl=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected%3Fresult%3Dconnected` + ) + expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() + }) + }) + describe('plain connect (no credentialId)', () => { it('creates a draft with credentialId null and redirects to the provider', async () => { const response = await GET( diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 063de2ca015..49d8d8c7fcf 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -4,9 +4,11 @@ import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections' import { parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth/auth' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getCredentialActorContext } from '@/lib/credentials/access' +import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' import { createConnectDraft } from '@/lib/credentials/connect-draft' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -30,92 +32,136 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(authorizeOAuth2Contract, request, {}) if (!parsed.success) return parsed.response - const { - providerId, - workspaceId, - callbackURL: requestedCallback, - credentialId, - } = parsed.data.query + const { draftId } = parsed.data.query + let { providerId, workspaceId, callbackURL: requestedCallback, credentialId } = parsed.data.query - const callbackURL = requestedCallback?.startsWith(`${baseUrl}/`) - ? requestedCallback - : `${baseUrl}/workspace` - - try { - const access = await checkWorkspaceAccess(workspaceId, userId) - if (!access.canWrite) { - logger.warn('Workspace write access denied for OAuth2 authorize', { - userId, - workspaceId, - providerId, + let fromConnectionDraft = false + if (draftId) { + try { + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') + const { draft } = await launchCredentialConnection.execute({ + principal: { + kind: 'session', + userId, + sessionId, + }, + input: { draftId }, + request, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) + providerId = draft.providerId + workspaceId = draft.workspaceId + credentialId = draft.credentialId ?? undefined + fromConnectionDraft = true + } catch (error) { + if (!(error instanceof OrchestrationError)) throw error + logger.warn('Rejected OAuth connection draft', { userId, draftId, code: error.code }) + return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_invalid`) } + } + if (!providerId || !workspaceId) { + throw new Error('Validated OAuth authorization request is missing its target') + } + + const connectionCompleteUrl = new URL('/oauth/credential-connected', baseUrl) + connectionCompleteUrl.searchParams.set('result', 'connected') + const callbackURL = fromConnectionDraft + ? connectionCompleteUrl.toString() + : requestedCallback?.startsWith(`${baseUrl}/`) + ? requestedCallback + : `${baseUrl}/workspace` + + try { let reconnectDisplayName: string | undefined - if (credentialId) { - // Trello and Shopify authorize through their own custom flows that bypass - // this endpoint, so a reconnect draft written here would linger unconsumed - // and could later be picked up by their token-store callbacks, silently - // rebinding the credential. Mirror the copilot tool and reject reconnect. - if (providerId === 'trello' || providerId === 'shopify') { - logger.warn('Reconnect not supported for custom-flow provider', { + if (!fromConnectionDraft) { + const access = await checkWorkspaceAccess(workspaceId, userId) + if (!access.canWrite) { + logger.warn('Workspace write access denied for OAuth2 authorize', { userId, workspaceId, providerId, - credentialId, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_reconnect_unsupported`) + return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) } - // Reconnect: the OAuth callback will rebind this credential to the fresh - // account, so require the same credential-admin access as the draft POST - // route — workspace write alone must not be enough to swap someone's tokens. - const actor = await getCredentialActorContext(credentialId, userId, { - workspaceAccess: access, - }) - if ( - !actor.credential || - actor.credential.workspaceId !== workspaceId || - actor.credential.type !== 'oauth' || - !actor.isAdmin - ) { - logger.warn('Credential admin access denied for OAuth2 reconnect', { - userId, - workspaceId, - providerId, - credentialId, - }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) - } - if (actor.credential.providerId !== providerId) { - logger.warn('Provider mismatch for OAuth2 reconnect', { - userId, - workspaceId, - providerId, - credentialId, - credentialProviderId: actor.credential.providerId, + if (credentialId) { + // Trello and Shopify authorize through their own custom flows that bypass + // this endpoint, so a reconnect draft written here would linger unconsumed + // and could later be picked up by their token-store callbacks, silently + // rebinding the credential. Mirror the copilot tool and reject reconnect. + if (providerId === 'trello' || providerId === 'shopify') { + logger.warn('Reconnect not supported for custom-flow provider', { + userId, + workspaceId, + providerId, + credentialId, + }) + return NextResponse.redirect( + `${baseUrl}/workspace?error=credential_reconnect_unsupported` + ) + } + + // Reconnect: the OAuth callback will rebind this credential to the fresh + // account, so require the same credential-admin access as the draft POST + // route — workspace write alone must not be enough to swap someone's tokens. + const actor = await getCredentialActorContext(credentialId, userId, { + workspaceAccess: access, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) + if ( + !actor.credential || + actor.credential.workspaceId !== workspaceId || + actor.credential.type !== 'oauth' || + !actor.isAdmin + ) { + logger.warn('Credential admin access denied for OAuth2 reconnect', { + userId, + workspaceId, + providerId, + credentialId, + }) + return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) + } + if (actor.credential.providerId !== providerId) { + logger.warn('Provider mismatch for OAuth2 reconnect', { + userId, + workspaceId, + providerId, + credentialId, + credentialProviderId: actor.credential.providerId, + }) + return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) + } + reconnectDisplayName = actor.credential.displayName } - reconnectDisplayName = actor.credential.displayName } requireConfiguredOAuthClient(providerId) - // Create the draft before initiating the link so it is guaranteed to exist - // (and freshly clocked) when the OAuth callback's `account.create.after` - // hook runs. If this throws, we never start the OAuth flow. - await createConnectDraft({ - userId, - workspaceId, - providerId, - credentialId, - displayName: reconnectDisplayName, - }) + if (!draftId) { + await createConnectDraft({ + userId, + workspaceId, + providerId, + credentialId, + displayName: reconnectDisplayName, + }) + } + + if (providerId === 'trello' || providerId === 'instagram' || providerId === 'shopify') { + const authorizeUrl = new URL(`/api/auth/${providerId}/authorize`, baseUrl) + authorizeUrl.searchParams.set('returnUrl', callbackURL) + return NextResponse.redirect(authorizeUrl) + } const linkResponse = await auth.api.oAuth2LinkAccount({ - body: { providerId, callbackURL }, + body: { + providerId, + callbackURL, + ...(fromConnectionDraft + ? { errorCallbackURL: `${baseUrl}/oauth/credential-connected?result=failed` } + : {}), + }, headers: request.headers, asResponse: true, }) diff --git a/apps/sim/app/api/v2/credential-connections/route.test.ts b/apps/sim/app/api/v2/credential-connections/route.test.ts new file mode 100644 index 00000000000..569eb61dd72 --- /dev/null +++ b/apps/sim/app/api/v2/credential-connections/route.test.ts @@ -0,0 +1,150 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { WorkspaceApiKeyScopeAuthorizationError } from '@/lib/core/application' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +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) +vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ + createCredentialConnection: { + operation: { id: 'credentials.connections.create' }, + execute: mocks.execute, + }, +})) + +import { POST } from '@/app/api/v2/credential-connections/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +describe('POST /api/v2/credential-connections', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + }) + }) + + it('rejects requests that provide both connection targets', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credential-connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + credentialId: 'credential-1', + }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('requires a display name for a new connection', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credential-connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, providerId: 'google-email' }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('rejects a display name when reconnecting an existing credential', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credential-connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + displayName: 'Renamed Gmail', + }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('returns the short-lived browser URL', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/credential-connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }), + }) + const response = await POST(request) + + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }, + request, + }) + expect(await response.json()).toEqual({ + data: { + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: '2026-08-12T20:15:00.000Z', + }, + }) + }) + + it('conceals inaccessible workspaces as not found', async () => { + mocks.execute.mockRejectedValueOnce(new WorkspaceApiKeyScopeAuthorizationError()) + + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credential-connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }), + }) + ) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Workspace not found' }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/credential-connections/route.ts b/apps/sim/app/api/v2/credential-connections/route.ts new file mode 100644 index 00000000000..7fef7f6ce2d --- /dev/null +++ b/apps/sim/app/api/v2/credential-connections/route.ts @@ -0,0 +1,32 @@ +import { v2CreateCredentialConnectionContract } from '@/lib/api/contracts/v2/credentials' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' +import { credentialOperations } from '@/lib/credentials/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const credentialConnectionErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) + +export const POST = defineV2JsonRoute({ + contract: v2CreateCredentialConnectionContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.createConnection, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialConnectionErrorPolicy, + mapInput: ({ body }) => body, + useCase: createCredentialConnection, + present: ({ authorizationUrl, expiresAt }) => ({ + data: { + authorizationUrl, + expiresAt: expiresAt.toISOString(), + }, + }), +}) diff --git a/apps/sim/app/api/v2/credential-providers/route.test.ts b/apps/sim/app/api/v2/credential-providers/route.test.ts new file mode 100644 index 00000000000..27de3904a9a --- /dev/null +++ b/apps/sim/app/api/v2/credential-providers/route.test.ts @@ -0,0 +1,124 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { WorkspaceApiKeyScopeAuthorizationError } from '@/lib/core/application' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +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) +vi.mock('@/lib/credentials/application/list-credential-providers', () => ({ + listCredentialProviders: { + operation: { id: 'credentials.providers.list' }, + execute: mocks.execute, + }, +})) + +import { GET } from '@/app/api/v2/credential-providers/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +describe('GET /api/v2/credential-providers', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ + providers: [ + { + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], + }, + ], + }) + }) + + it('returns the full provider catalog in one page', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/credential-providers?workspaceId=${WORKSPACE_ID}` + ) + const response = await GET(request) + + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID }, + request, + }) + expect(await response.json()).toEqual({ + data: [ + { + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], + }, + ], + nextCursor: null, + }) + }) + + it('rejects query parameters it does not implement', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credential-providers?workspaceId=${WORKSPACE_ID}&limit=1` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('conceals a workspace-key scope mismatch as not found', async () => { + mocks.execute.mockRejectedValueOnce(new WorkspaceApiKeyScopeAuthorizationError()) + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credential-providers?workspaceId=${WORKSPACE_ID}` + ) + ) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Workspace not found' }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/credential-providers/route.ts b/apps/sim/app/api/v2/credential-providers/route.ts new file mode 100644 index 00000000000..4fbe9de2f4f --- /dev/null +++ b/apps/sim/app/api/v2/credential-providers/route.ts @@ -0,0 +1,27 @@ +import { v2ListCredentialProvidersContract } from '@/lib/api/contracts/v2/credentials' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { listCredentialProviders } from '@/lib/credentials/application/list-credential-providers' +import { credentialOperations } from '@/lib/credentials/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const credentialProviderErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) + +export const GET = defineV2JsonRoute({ + contract: v2ListCredentialProvidersContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.listProviders, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialProviderErrorPolicy, + mapInput: ({ query }) => query, + useCase: listCredentialProviders, + present: ({ providers }) => ({ data: providers, nextCursor: null }), +}) diff --git a/apps/sim/app/oauth/credential-connected/page.test.tsx b/apps/sim/app/oauth/credential-connected/page.test.tsx new file mode 100644 index 00000000000..2a137ea39b7 --- /dev/null +++ b/apps/sim/app/oauth/credential-connected/page.test.tsx @@ -0,0 +1,36 @@ +/** + * @vitest-environment node + */ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import CredentialConnectedPage from '@/app/oauth/credential-connected/page' + +describe('CredentialConnectedPage', () => { + it('confirms a successful connection', async () => { + const page = await CredentialConnectedPage({ + searchParams: Promise.resolve({ result: 'connected' }), + }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Credential connected') + expect(markup).toContain('The credential is ready to use.') + }) + + it('does not claim success when the provider returns an error', async () => { + const page = await CredentialConnectedPage({ + searchParams: Promise.resolve({ result: 'connected', error: 'access_denied' }), + }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Connection failed') + expect(markup).not.toContain('Credential connected') + }) + + it('does not claim success without an explicit success result', async () => { + const page = await CredentialConnectedPage({ searchParams: Promise.resolve({}) }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Connection failed') + expect(markup).not.toContain('Credential connected') + }) +}) diff --git a/apps/sim/app/oauth/credential-connected/page.tsx b/apps/sim/app/oauth/credential-connected/page.tsx new file mode 100644 index 00000000000..c0583aede98 --- /dev/null +++ b/apps/sim/app/oauth/credential-connected/page.tsx @@ -0,0 +1,50 @@ +import { ChipLink } from '@sim/emcn' +import { CircleAlert, CircleCheck } from '@sim/emcn/icons' +import type { Metadata } from 'next' +import { LogoShell } from '@/app/(landing)/components' + +export const metadata: Metadata = { + title: 'Credential connected', + robots: { index: false, follow: false }, +} + +interface CredentialConnectedPageProps { + searchParams: Promise<Record<string, string | string[] | undefined>> +} + +export default async function CredentialConnectedPage({ + searchParams, +}: CredentialConnectedPageProps) { + const params = await searchParams + const result = typeof params.result === 'string' ? params.result : undefined + const error = Array.isArray(params.error) ? params.error[0] : params.error + const connected = result === 'connected' && !error + + return ( + <LogoShell center> + <div className='flex w-full max-w-[410px] flex-col items-center gap-3 text-center'> + <div + className='mb-2 flex size-12 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-2)] shadow-card' + aria-hidden + > + {connected ? ( + <CircleCheck className='size-6 text-[var(--text-success)]' /> + ) : ( + <CircleAlert className='size-6 text-[var(--text-error)]' /> + )} + </div> + <h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'> + {connected ? 'Credential connected' : 'Connection failed'} + </h1> + <p className='text-pretty text-[var(--text-muted)] text-lg'> + {connected + ? 'The credential is ready to use. You can close this tab and return to the app that started the connection.' + : 'The credential could not be connected. Return to the app that started the connection and try again.'} + </p> + <ChipLink variant='primary' href='/workspace' className='mt-3'> + Open Sim + </ChipLink> + </div> + </LogoShell> + ) +} diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index c9c4951efd2..3e184df791c 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -262,12 +262,46 @@ export const instagramCallbackContract = defineRouteContract({ response: { mode: 'redirect' }, }) -export const authorizeOAuth2QuerySchema = z.object({ - providerId: z.string().min(1, 'providerId is required'), - workspaceId: workspaceIdSchema, - callbackURL: z.string().min(1).optional(), - credentialId: z.string().min(1).optional(), -}) +export const authorizeOAuth2QuerySchema = z + .object({ + draftId: z + .string() + .min(1, 'draftId is required') + .max(255, 'draftId must be at most 255 characters') + .optional(), + providerId: z.string().min(1, 'providerId is required').optional(), + workspaceId: workspaceIdSchema.optional(), + callbackURL: z.string().min(1).optional(), + credentialId: z.string().min(1).optional(), + }) + .superRefine((data, ctx) => { + if (data.draftId) { + for (const field of ['providerId', 'workspaceId', 'callbackURL', 'credentialId'] as const) { + if (data[field] !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [field], + message: `${field} cannot be combined with draftId`, + }) + } + } + return + } + if (!data.providerId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['providerId'], + message: 'providerId is required', + }) + } + if (!data.workspaceId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['workspaceId'], + message: 'workspaceId is required', + }) + } + }) export const authorizeOAuth2Contract = defineRouteContract({ method: 'GET', 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 b6034e3af3b..4ce9499de71 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 @@ -83,6 +83,7 @@ const PAGED_LISTS = [ * - A table's saved views and its dispatchable groups are capped per table. */ const FULL_SET_LISTS = [ + 'GET /api/v2/credential-providers', 'GET /api/v2/files/folders', 'GET /api/v2/knowledge/[id]/tags', 'GET /api/v2/knowledge/folders', diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 6694a626a3c..60d537b2754 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -4,6 +4,7 @@ import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, + v2DataResponse, v2PaginationFields, v2SearchSchema, v2SortFields, @@ -44,6 +45,43 @@ export const v2CredentialSchema = z }) export type V2Credential = z.output<typeof v2CredentialSchema> +export const v2CredentialProviderAuthorizationOptionSchema = z.object({ + providerId: z + .string() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact OAuth provider identifier accepted by the connection endpoint.'), + label: z.string().min(1).max(255).describe('Human-readable authorization-server label.'), +}) +export type V2CredentialProviderAuthorizationOption = z.output< + typeof v2CredentialProviderAuthorizationOptionSchema +> + +export const v2CredentialProviderSchema = z + .object({ + serviceId: z.string().min(1).max(255).describe('Stable OAuth service identifier.'), + name: z.string().min(1).max(255).describe('OAuth service display name.'), + description: z.string().min(1).max(1000).describe('OAuth service description.'), + providerFamily: z.string().min(1).max(255).describe('Owning provider family identifier.'), + available: z + .boolean() + .describe('Whether this caller can start the OAuth flow in the current deployment.'), + supportsReconnect: z + .boolean() + .describe('Whether existing credentials for this service can be reconnected.'), + authorizationOptions: z + .array(v2CredentialProviderAuthorizationOptionSchema) + .min(1) + .max(10) + .describe('Authorization servers available for this OAuth service.'), + }) + .meta({ + id: 'V2CredentialProvider', + title: 'Credential Provider', + description: 'An OAuth service that may be connected to a workspace.', + }) +export type V2CredentialProvider = z.output<typeof v2CredentialProviderSchema> + /** A credential's natural name field is `displayName`, so that is what `search` matches. */ export const v2CredentialSortFields = ['displayName', 'createdAt', 'updatedAt'] as const export type V2CredentialSortBy = (typeof v2CredentialSortFields)[number] @@ -80,3 +118,94 @@ export const v2ListCredentialsContract = defineRouteContract({ schema: v2CursorListResponse(v2CredentialSchema), }, }) + +export const v2ListCredentialProvidersQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe( + 'Workspace used to evaluate OAuth availability and integration policy.' + ), + }) + .strict() +export type V2ListCredentialProvidersQuery = z.output<typeof v2ListCredentialProvidersQuerySchema> + +export const v2ListCredentialProvidersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/credential-providers', + query: v2ListCredentialProvidersQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2CredentialProviderSchema, { paged: false }), + }, +}) + +const v2CreateCredentialConnectionByProviderSchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that will own the credential.'), + providerId: z + .string({ error: 'providerId is required' }) + .trim() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact provider ID returned by the credential-provider catalog.'), + displayName: z + .string({ error: 'displayName is required' }) + .trim() + .min(1, 'displayName cannot be empty') + .max(255, 'displayName must be at most 255 characters') + .describe('Name shown for the new credential in Sim.'), + }) + .strict() + +const v2CreateCredentialConnectionByCredentialSchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace expected to own the credential.'), + credentialId: z + .string({ error: 'credentialId is required' }) + .trim() + .min(1, 'credentialId cannot be empty') + .max(255, 'credentialId must be at most 255 characters') + .describe('Existing OAuth credential to reconnect in place.'), + }) + .strict() + +export const v2CreateCredentialConnectionBodySchema = z.union([ + v2CreateCredentialConnectionByProviderSchema, + v2CreateCredentialConnectionByCredentialSchema, +]) +export type V2CreateCredentialConnectionBody = z.output< + typeof v2CreateCredentialConnectionBodySchema +> + +export const v2CredentialConnectionAuthorizationSchema = z + .object({ + authorizationUrl: z + .string() + .url('authorizationUrl must be an absolute URL') + .describe('Short-lived Sim browser URL that starts the OAuth authorization flow.'), + expiresAt: v2TimestampSchema.describe('ISO 8601 timestamp when the connection link expires.'), + }) + .meta({ + id: 'V2CredentialConnectionAuthorization', + title: 'Credential Connection Authorization', + description: 'A short-lived browser entrypoint for an OAuth connection flow.', + }) +export type V2CredentialConnectionAuthorization = z.output< + typeof v2CredentialConnectionAuthorizationSchema +> + +export const v2CreateCredentialConnectionResponseSchema = v2DataResponse( + v2CredentialConnectionAuthorizationSchema +) +export type V2CreateCredentialConnectionResponse = z.output< + typeof v2CreateCredentialConnectionResponseSchema +> + +export const v2CreateCredentialConnectionContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/credential-connections', + body: v2CreateCredentialConnectionBodySchema, + response: { + mode: 'json', + schema: v2CreateCredentialConnectionResponseSchema, + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index a87fa3f3294..377a1cb7442 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -1,4 +1,8 @@ -import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' +import { + v2CreateCredentialConnectionContract, + v2ListCredentialProvidersContract, + v2ListCredentialsContract, +} from '@/lib/api/contracts/v2/credentials' import { v2CreateCustomToolContract, v2DeleteCustomToolContract, @@ -166,6 +170,24 @@ const CREDENTIAL_EXAMPLE = { updatedAt: '2026-06-20T14:02:11.000Z', } as const +const CREDENTIAL_PROVIDER_EXAMPLE = { + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect to Salesforce CRM data and operations.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], +} as const + +const CREDENTIAL_CONNECTION_EXAMPLE = { + authorizationUrl: 'https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123', + expiresAt: '2026-06-20T14:17:11.000Z', +} as const + const SECRET_EXAMPLE = { name: 'STRIPE_API_KEY', scope: 'workspace', @@ -804,6 +826,56 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2ListCredentialProvidersContract, + resourceOperation('Credentials', { + operationId: 'listCredentialProviders', + summary: 'List Credential Providers', + description: `List catalogued OAuth services and whether each is available to the caller in this workspace and deployment. Authorization options contain the exact provider IDs accepted by the connection endpoint. ${FULL_SET_LIST}`, + errors: RESOURCE_ERRORS, + success: { description: 'OAuth provider catalog with caller-specific availability.' }, + }), + { + query: documentedSchema( + v2ListCredentialProvidersContract.query, + 'ListCredentialProvidersQuery', + 'List credential providers query', + 'Workspace used to evaluate provider availability.' + ), + response: documentedSchema( + v2ListCredentialProvidersContract.response.schema, + 'ListCredentialProvidersResponse', + 'List credential providers response', + 'OAuth providers and their authorization-server options.', + [{ data: [CREDENTIAL_PROVIDER_EXAMPLE], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2CreateCredentialConnectionContract, + resourceOperation('Credentials', { + operationId: 'createCredentialConnection', + summary: 'Create Credential Connection', + description: `Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL in a browser, sign in as the personal API-key owner, complete provider authorization, then refresh the credentials list. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'A short-lived browser authorization URL.' }, + }), + { + body: documentedSchema( + v2CreateCredentialConnectionContract.body, + 'CreateCredentialConnectionBody', + 'Create credential connection body', + 'For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved.' + ), + response: documentedSchema( + v2CreateCredentialConnectionContract.response.schema, + 'CreateCredentialConnectionResponse', + 'Create credential connection response', + 'Short-lived Sim browser entrypoint and its expiry.', + [{ data: CREDENTIAL_CONNECTION_EXAMPLE }] + ), + } + ), defineOpenApiRoute( v2ListSecretsContract, resourceOperation('Secrets', { @@ -953,7 +1025,8 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ }, { name: 'Credentials', - description: 'List OAuth and service-account connections without secret material.', + description: + 'Discover OAuth providers, connect or reconnect accounts, and list connections without secret material.', }, { name: 'Secrets', diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts index 5ac8b5c55cc..8d9ed60e7c5 100644 --- a/apps/sim/lib/core/application/forbidden.ts +++ b/apps/sim/lib/core/application/forbidden.ts @@ -48,6 +48,8 @@ export const FORBIDDEN_DETAIL_CODES = [ 'WORKSPACE_RESOURCE_LIMIT_REACHED', /** The workspace's organization does not permit public sharing. */ 'PUBLIC_SHARING_NOT_ALLOWED', + /** The caller can write in the workspace but cannot administer this credential. */ + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', /** The MCP server URL is outside the allowed domains or resolves internally. */ 'MCP_SERVER_URL_NOT_ALLOWED', ] as const @@ -83,6 +85,8 @@ export const FORBIDDEN_DETAIL_CODE_DESCRIPTIONS: Record<ForbiddenDetailCode, str '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.', PUBLIC_SHARING_NOT_ALLOWED: "The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.", + CREDENTIAL_ADMIN_ACCESS_REQUIRED: + 'The caller can write in the workspace but cannot administer this credential.', MCP_SERVER_URL_NOT_ALLOWED: 'The supplied MCP server URL is outside the allowed domains or resolves to an internal address.', } diff --git a/apps/sim/lib/credentials/application/connection-target.test.ts b/apps/sim/lib/credentials/application/connection-target.test.ts new file mode 100644 index 00000000000..ce3a6805483 --- /dev/null +++ b/apps/sim/lib/credentials/application/connection-target.test.ts @@ -0,0 +1,150 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + listCatalog: vi.fn(), + getWorkspaceCredential: vi.fn(), + getCredentialActorContext: vi.fn(), +})) + +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, + requireAvailableCredentialProvider: ( + catalog: Array<{ + available: boolean + authorizationOptions: Array<{ providerId: string }> + }>, + providerId: string + ) => { + const provider = catalog.find((entry) => + entry.authorizationOptions.some((option) => option.providerId === providerId) + ) + if (!provider) throw Object.assign(new Error('Unknown OAuth provider'), { code: 'validation' }) + if (!provider.available) + throw Object.assign(new Error('OAuth provider is unavailable'), { code: 'conflict' }) + return provider + }, +})) + +vi.mock('@/lib/credentials/queries', () => ({ + getWorkspaceCredential: mocks.getWorkspaceCredential, +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getCredentialActorContext, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + credentialProviderMatchesService: ( + credentialProviderId: string, + service: { providerId: string; additionalProviderIds?: readonly string[] } + ) => + credentialProviderId === service.providerId || + (service.additionalProviderIds?.includes(credentialProviderId) ?? false), +})) + +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' + +const principal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const salesforceProvider = { + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], +} +const credential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth', + providerId: 'salesforce-sandbox', + displayName: 'Sandbox CRM', +} + +describe('resolveCredentialConnectionTarget', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.listCatalog.mockResolvedValue([salesforceProvider]) + mocks.getWorkspaceCredential.mockResolvedValue(credential) + mocks.getCredentialActorContext.mockResolvedValue({ credential, isAdmin: true }) + }) + + it('accepts an exact authorization option for a new connection', async () => { + const result = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: 'salesforce-sandbox', + }) + + expect(result).toEqual({ + provider: salesforceProvider, + providerId: 'salesforce-sandbox', + }) + expect(mocks.getWorkspaceCredential).not.toHaveBeenCalled() + }) + + it('loads reconnect credentials through the asserted workspace and requires admin access', async () => { + mocks.getCredentialActorContext.mockResolvedValue({ credential, isAdmin: false }) + + await expect( + resolveCredentialConnectionTarget({ + principal, + context, + credentialId: 'credential-1', + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + + expect(mocks.getWorkspaceCredential).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + credentialId: 'credential-1', + }) + expect(mocks.getCredentialActorContext).toHaveBeenCalledWith('credential-1', 'user-1') + }) + + it('preserves the credential authorization-server ID on reconnect', async () => { + const result = await resolveCredentialConnectionTarget({ + principal, + context, + credentialId: 'credential-1', + }) + + expect(result).toEqual({ + provider: salesforceProvider, + providerId: 'salesforce-sandbox', + credentialId: 'credential-1', + displayName: 'Sandbox CRM', + }) + }) + + it('rejects providers whose custom flow cannot reconnect', async () => { + mocks.listCatalog.mockResolvedValue([{ ...salesforceProvider, supportsReconnect: false }]) + + await expect( + resolveCredentialConnectionTarget({ + principal, + context, + credentialId: 'credential-1', + }) + ).rejects.toMatchObject({ code: 'conflict' }) + }) +}) diff --git a/apps/sim/lib/credentials/application/connection-target.ts b/apps/sim/lib/credentials/application/connection-target.ts new file mode 100644 index 00000000000..6365988099e --- /dev/null +++ b/apps/sim/lib/credentials/application/connection-target.ts @@ -0,0 +1,92 @@ +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { + type CredentialProviderCatalogEntry, + listCredentialProviderCatalog, + requireAvailableCredentialProvider, +} from '@/lib/credentials/application/provider-catalog' +import { getWorkspaceCredential } from '@/lib/credentials/queries' +import { credentialProviderMatchesService } from '@/lib/oauth/utils' +import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface ResolvedCredentialConnectionTarget { + provider: CredentialProviderCatalogEntry + providerId: string + credentialId?: string + displayName?: string +} + +export async function resolveCredentialConnectionTarget(params: { + principal: Principal + context: ActiveWorkspaceApplicationContext + providerId?: string + credentialId?: string +}): Promise<ResolvedCredentialConnectionTarget> { + const { principal, context, providerId, credentialId } = params + if (Boolean(providerId) === Boolean(credentialId)) { + throw new Error('Credential connection requires exactly one target identifier') + } + + const catalog = await listCredentialProviderCatalog(principal, context) + if (providerId) { + return { + provider: requireAvailableCredentialProvider(catalog, providerId), + providerId, + } + } + + if (!credentialId) throw new Error('Credential reconnect target is missing its credential ID') + const userId = requirePrincipalSubjectUserId(principal) + const targetCredentialId = credentialId + const credential = await getWorkspaceCredential({ + workspaceId: context.workspaceId, + credentialId: targetCredentialId, + }) + if (!credential) throw new OrchestrationError('not_found', 'Credential not found') + if (credential.type !== 'oauth' || !credential.providerId) { + throw new OrchestrationError('validation', 'Only OAuth credentials can be reconnected') + } + const credentialProviderId = credential.providerId + + const actor = await getCredentialActorContext(targetCredentialId, userId) + if (!actor.credential || actor.credential.workspaceId !== context.workspaceId) { + throw new OrchestrationError('not_found', 'Credential not found') + } + if (!actor.isAdmin) { + throw new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Admin access on the credential is required to reconnect it' + ) + } + + const provider = catalog.find((entry) => + credentialProviderMatchesService(credentialProviderId, { + providerId: entry.authorizationOptions[0].providerId, + additionalProviderIds: entry.authorizationOptions.slice(1).map((option) => option.providerId), + }) + ) + if (!provider) { + throw new OrchestrationError('validation', `Unknown OAuth provider: ${credentialProviderId}`) + } + if (!provider.available) { + throw new OrchestrationError( + 'conflict', + `OAuth provider is unavailable: ${credentialProviderId}` + ) + } + if (!provider.supportsReconnect) { + throw new OrchestrationError( + 'conflict', + `OAuth provider does not support reconnecting credentials: ${credentialProviderId}` + ) + } + + return { + provider, + providerId: credentialProviderId, + credentialId: credential.id, + displayName: credential.displayName, + } +} diff --git a/apps/sim/lib/credentials/application/create-credential-connection.test.ts b/apps/sim/lib/credentials/application/create-credential-connection.test.ts new file mode 100644 index 00000000000..b934755f0e5 --- /dev/null +++ b/apps/sim/lib/credentials/application/create-credential-connection.test.ts @@ -0,0 +1,128 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + resolveTarget: vi.fn(), + createDraft: vi.fn(), + getBaseUrl: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/credentials/application/connection-target', () => ({ + resolveCredentialConnectionTarget: mocks.resolveTarget, +})) + +vi.mock('@/lib/credentials/connect-draft', () => ({ + createConnectDraft: mocks.createDraft, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: mocks.getBaseUrl, +})) + +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} + +describe('createCredentialConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'gmail' }, + providerId: 'google-email', + }) + mocks.createDraft.mockResolvedValue({ + id: 'draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + }) + mocks.getBaseUrl.mockReturnValue('https://sim.ai') + }) + + it('rejects workspace keys before canonical workspace loading', async () => { + await expect( + createCredentialConnection.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) + + it('creates a user-bound draft and returns only its browser entrypoint', async () => { + const result = await createCredentialConnection.execute({ + principal: personalPrincipal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + }, + }) + + expect(mocks.createDraft).toHaveBeenCalledWith({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: undefined, + displayName: 'Work Gmail', + }) + expect(result).toEqual({ + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + }) + }) + + it("preserves an existing credential's name on reconnect", async () => { + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'gmail' }, + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Existing Gmail', + }) + + await createCredentialConnection.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1', credentialId: 'credential-1' }, + }) + + expect(mocks.createDraft).toHaveBeenCalledWith({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Existing Gmail', + }) + }) +}) diff --git a/apps/sim/lib/credentials/application/create-credential-connection.ts b/apps/sim/lib/credentials/application/create-credential-connection.ts new file mode 100644 index 00000000000..513e2b21c85 --- /dev/null +++ b/apps/sim/lib/credentials/application/create-credential-connection.ts @@ -0,0 +1,53 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export type CreateCredentialConnectionInput = { + workspaceId: string +} & ( + | { providerId: string; displayName: string; credentialId?: never } + | { credentialId: string; providerId?: never; displayName?: never } +) + +export interface CreateCredentialConnectionResult { + authorizationUrl: string + expiresAt: Date +} + +export const createCredentialConnection = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.createConnection, + resolveContext: async ({ input }: { input: CreateCredentialConnectionInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + execute: async ({ principal, input, context }): Promise<CreateCredentialConnectionResult> => { + const target = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: input.providerId, + credentialId: input.credentialId, + }) + const displayName = input.providerId ? input.displayName : target.displayName + if (!displayName) throw new Error('Resolved credential connection target has no display name') + + const draft = await createConnectDraft({ + userId: principal.userId, + workspaceId: context.workspaceId, + providerId: target.providerId, + credentialId: target.credentialId, + displayName, + }) + const authorizationUrl = new URL('/api/auth/oauth2/authorize', getBaseUrl()) + authorizationUrl.searchParams.set('draftId', draft.id) + return { + authorizationUrl: authorizationUrl.toString(), + expiresAt: draft.expiresAt, + } + }, +}) diff --git a/apps/sim/lib/credentials/application/launch-credential-connection.test.ts b/apps/sim/lib/credentials/application/launch-credential-connection.test.ts new file mode 100644 index 00000000000..d983cdf3d53 --- /dev/null +++ b/apps/sim/lib/credentials/application/launch-credential-connection.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getActiveDraft: vi.fn(), + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + resolveTarget: vi.fn(), +})) + +vi.mock('@/lib/credentials/connect-draft', () => ({ + getActiveConnectDraft: mocks.getActiveDraft, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/credentials/application/connection-target', () => ({ + resolveCredentialConnectionTarget: mocks.resolveTarget, +})) + +import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' + +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const draft = { + id: 'draft-1', + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: "User's Gmail", + description: null, + credentialId: null, + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + createdAt: new Date('2026-08-12T20:00:00.000Z'), +} +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +describe('launchCredentialConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getActiveDraft.mockResolvedValue(draft) + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'gmail' }, + providerId: 'google-email', + }) + }) + + it('loads the exact draft for the signed-in user and reauthorizes its target', async () => { + const result = await launchCredentialConnection.execute({ + principal, + input: { draftId: 'draft-1' }, + }) + + expect(mocks.getActiveDraft).toHaveBeenCalledWith('draft-1', 'user-1') + expect(mocks.resolveTarget).toHaveBeenCalledWith({ + principal, + context: { ...workspaceContext, draft }, + providerId: 'google-email', + credentialId: undefined, + }) + expect(result).toEqual({ draft }) + }) + + it('rejects an invalid or expired draft before loading a workspace', async () => { + mocks.getActiveDraft.mockResolvedValue(null) + + await expect( + launchCredentialConnection.execute({ principal, input: { draftId: 'draft-missing' } }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/launch-credential-connection.ts b/apps/sim/lib/credentials/application/launch-credential-connection.ts new file mode 100644 index 00000000000..1a19cafecee --- /dev/null +++ b/apps/sim/lib/credentials/application/launch-credential-connection.ts @@ -0,0 +1,52 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { type ConnectDraft, getActiveConnectDraft } from '@/lib/credentials/connect-draft' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' + +export interface LaunchCredentialConnectionInput { + draftId: string +} + +interface LaunchCredentialConnectionContext extends ActiveWorkspaceApplicationContext { + draft: ConnectDraft +} + +export interface LaunchCredentialConnectionResult { + draft: ConnectDraft +} + +export const launchCredentialConnection = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.launchConnection, + resolveContext: async ({ + principal, + input, + }: { + principal: { kind: 'session'; userId: string; sessionId: string } + input: LaunchCredentialConnectionInput + }): Promise<LaunchCredentialConnectionContext> => { + const draft = await getActiveConnectDraft(input.draftId, principal.userId) + if (!draft) + throw new OrchestrationError('not_found', 'OAuth connection link is invalid or expired') + const workspace = await loadActiveWorkspaceApplicationContext(draft.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return { ...workspace, draft } + }, + authorizationOptions: {}, + execute: async ({ principal, context }): Promise<LaunchCredentialConnectionResult> => { + const target = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: context.draft.credentialId ? undefined : context.draft.providerId, + credentialId: context.draft.credentialId ?? undefined, + }) + if (target.providerId !== context.draft.providerId) { + throw new OrchestrationError('conflict', 'OAuth connection provider no longer matches') + } + return { draft: context.draft } + }, +}) diff --git a/apps/sim/lib/credentials/application/list-credential-providers.test.ts b/apps/sim/lib/credentials/application/list-credential-providers.test.ts new file mode 100644 index 00000000000..c7d11d848d9 --- /dev/null +++ b/apps/sim/lib/credentials/application/list-credential-providers.test.ts @@ -0,0 +1,68 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + listCatalog: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, +})) + +import { listCredentialProviders } from '@/lib/credentials/application/list-credential-providers' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +describe('listCredentialProviders', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.listCatalog.mockResolvedValue([]) + }) + + it('rejects unsupported principals before canonical workspace loading', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect( + listCredentialProviders.execute({ principal, input: { workspaceId: 'workspace-1' } }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) + + it('allows workspace keys to inspect deployment availability', async () => { + const principal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', + } + + await listCredentialProviders.execute({ principal, input: { workspaceId: 'workspace-1' } }) + + expect(mocks.listCatalog).toHaveBeenCalledWith(principal, workspaceContext) + }) +}) diff --git a/apps/sim/lib/credentials/application/list-credential-providers.ts b/apps/sim/lib/credentials/application/list-credential-providers.ts new file mode 100644 index 00000000000..b24d45ea811 --- /dev/null +++ b/apps/sim/lib/credentials/application/list-credential-providers.ts @@ -0,0 +1,29 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + type CredentialProviderCatalogEntry, + listCredentialProviderCatalog, +} from '@/lib/credentials/application/provider-catalog' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface ListCredentialProvidersInput { + workspaceId: string +} + +export interface ListCredentialProvidersResult { + providers: CredentialProviderCatalogEntry[] +} + +export const listCredentialProviders = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.listProviders, + resolveContext: async ({ input }: { input: ListCredentialProvidersInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + execute: async ({ principal, context }): Promise<ListCredentialProvidersResult> => ({ + providers: await listCredentialProviderCatalog(principal, context), + }), +}) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 4a3dcde7c11..7432c1c7430 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -1,10 +1,28 @@ import { defineWorkspaceOperation } from '@/lib/core/application' export const credentialOperations = { + listProviders: defineWorkspaceOperation({ + id: 'credentials.providers.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['personal_api_key', 'workspace_api_key'], + }), listConnections: defineWorkspaceOperation({ id: 'credentials.connections.list', minimumRole: 'read', workspaceApiKey: 'allow', principalKinds: ['personal_api_key', 'workspace_api_key'], }), + createConnection: defineWorkspaceOperation({ + id: 'credentials.connections.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['personal_api_key'], + }), + launchConnection: defineWorkspaceOperation({ + id: 'credentials.connections.launch', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), } as const diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts new file mode 100644 index 00000000000..51aece09f81 --- /dev/null +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -0,0 +1,176 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getBlockVisibility: vi.fn(), + getAllowedIntegrationsFromEnv: vi.fn(), + getUserPermissionConfig: vi.fn(), + createVisibility: vi.fn(), + getAllOAuthServices: vi.fn(), + getServiceConfigByServiceId: vi.fn(), +})) + +vi.mock('@/lib/core/config/block-visibility', () => ({ + getBlockVisibility: mocks.getBlockVisibility, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + getAllowedIntegrationsFromEnv: mocks.getAllowedIntegrationsFromEnv, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, +})) + +vi.mock('@/lib/permission-groups/integration-allowlist', () => ({ + intersectIntegrationAllowlists: ( + permissionGroup: readonly string[] | null, + deployment: readonly string[] | null + ) => { + if (!permissionGroup) return deployment + if (!deployment) return permissionGroup + return permissionGroup.filter((type) => deployment.includes(type)) + }, +})) + +vi.mock('@/lib/integrations/credential-visibility.server', () => ({ + createIntegrationCredentialVisibility: mocks.createVisibility, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getAllOAuthServices: mocks.getAllOAuthServices, + getServiceConfigByServiceId: mocks.getServiceConfigByServiceId, +})) + +import { listCredentialProviderCatalog } from '@/lib/credentials/application/provider-catalog' + +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', +} +const services = [ + { + serviceId: 'salesforce', + providerId: 'salesforce', + additionalProviderIds: ['salesforce-sandbox'], + name: 'Salesforce', + description: 'Connect Salesforce.', + baseProvider: 'salesforce', + authType: 'oauth' as const, + }, + { + serviceId: 'trello', + providerId: 'trello', + name: 'Trello', + description: 'Connect Trello.', + baseProvider: 'trello', + authType: 'oauth' as const, + }, + { + serviceId: 'service-account-only', + providerId: 'service-account-only', + name: 'Service account', + description: 'Not OAuth.', + baseProvider: 'test', + authType: 'service_account' as const, + }, +] + +describe('listCredentialProviderCatalog', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getAllOAuthServices.mockReturnValue(services) + mocks.getAllowedIntegrationsFromEnv.mockReturnValue(['salesforce']) + mocks.getUserPermissionConfig.mockResolvedValue({ + allowedIntegrations: ['salesforce', 'trello'], + }) + mocks.getBlockVisibility.mockResolvedValue({ + revealed: new Set(), + disabled: new Set(), + previewTagged: new Set(), + }) + mocks.createVisibility.mockReturnValue({ + isOAuthServiceVisible: (service: { serviceId: string }) => service.serviceId === 'salesforce', + }) + mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { + if (serviceId === 'salesforce') { + return { + providerIdLabels: { + salesforce: 'Production', + 'salesforce-sandbox': 'Sandbox', + }, + } + } + if (serviceId === 'trello') return {} + return null + }) + }) + + it('projects OAuth services, authorization options, and reconnect capability', async () => { + const catalog = await listCredentialProviderCatalog(personalPrincipal, context) + + expect(catalog).toEqual([ + { + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], + }, + { + serviceId: 'trello', + name: 'Trello', + description: 'Connect Trello.', + providerFamily: 'trello', + available: false, + supportsReconnect: false, + authorizationOptions: [{ providerId: 'trello', label: 'Trello' }], + }, + ]) + expect(mocks.createVisibility).toHaveBeenCalledWith( + expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) }) + ) + }) + + it('does not borrow a human permission group for workspace API keys', async () => { + await listCredentialProviderCatalog( + { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + context + ) + + expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() + expect(mocks.createVisibility).toHaveBeenCalledWith( + expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) }) + ) + }) + + it('fails fast when a multi-server provider lacks complete labels', async () => { + mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { + if (serviceId === 'salesforce') { + return { providerIdLabels: { salesforce: 'Production' } } + } + if (serviceId === 'trello') return {} + return null + }) + + await expect(listCredentialProviderCatalog(personalPrincipal, context)).rejects.toThrow( + 'OAuth provider salesforce-sandbox is missing its authorization option label' + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts new file mode 100644 index 00000000000..760cda016d0 --- /dev/null +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -0,0 +1,113 @@ +import type { Principal } from '@sim/auth/principal' +import { getBlockVisibility } from '@/lib/core/config/block-visibility' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' +import { getAllOAuthServices, getServiceConfigByServiceId } from '@/lib/oauth/utils' +import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' + +export interface CredentialProviderAuthorizationOption { + providerId: string + label: string +} + +export interface CredentialProviderCatalogEntry { + serviceId: string + name: string + description: string + providerFamily: string + available: boolean + supportsReconnect: boolean + authorizationOptions: CredentialProviderAuthorizationOption[] +} + +interface CredentialProviderCatalogContext { + workspaceId: string + workspaceOrganizationId: string | null +} + +function principalUserId(principal: Principal): string | undefined { + if (principal.kind === 'session' || principal.kind === 'personal_api_key') { + return principal.userId + } + if (principal.kind === 'delegated') return principal.subjectUserId + return undefined +} + +async function allowedIntegrationTypes( + principal: Principal, + workspaceId: string +): Promise<ReadonlySet<string> | null> { + const userId = principalUserId(principal) + const permissionConfig = userId ? await getUserPermissionConfig(userId, workspaceId) : null + const integrations = intersectIntegrationAllowlists( + permissionConfig?.allowedIntegrations ?? null, + getAllowedIntegrationsFromEnv() + ) + return integrations ? new Set(integrations.map((type) => type.toLowerCase())) : null +} + +export async function listCredentialProviderCatalog( + principal: Principal, + context: CredentialProviderCatalogContext +): Promise<CredentialProviderCatalogEntry[]> { + const userId = principalUserId(principal) + const [allowedIntegrations, blockVisibility] = await Promise.all([ + allowedIntegrationTypes(principal, context.workspaceId), + getBlockVisibility({ + ...(userId ? { userId } : {}), + ...(context.workspaceOrganizationId ? { orgId: context.workspaceOrganizationId } : {}), + }), + ]) + const oauthServices = getAllOAuthServices().filter((service) => service.authType === 'oauth') + const visibility = createIntegrationCredentialVisibility({ + allowedIntegrationTypes: allowedIntegrations, + blockVisibility, + oauthServices, + }) + + return oauthServices.map((service) => { + const config = getServiceConfigByServiceId(service.serviceId) + if (!config) { + throw new Error(`OAuth service ${service.serviceId} is missing its canonical configuration`) + } + const providerIds = [service.providerId, ...(service.additionalProviderIds ?? [])] + if (providerIds.length > 1 && !config.providerIdLabels) { + throw new Error(`OAuth service ${service.serviceId} is missing provider option labels`) + } + const authorizationOptions = providerIds.map((providerId) => { + const label = providerIds.length === 1 ? service.name : config.providerIdLabels?.[providerId] + if (!label) { + throw new Error(`OAuth provider ${providerId} is missing its authorization option label`) + } + return { providerId, label } + }) + + return { + serviceId: service.serviceId, + name: service.name, + description: service.description, + providerFamily: service.baseProvider, + available: visibility.isOAuthServiceVisible(service), + supportsReconnect: !['trello', 'shopify'].includes(service.providerId), + authorizationOptions, + } + }) +} + +export function requireAvailableCredentialProvider( + catalog: readonly CredentialProviderCatalogEntry[], + providerId: string +): CredentialProviderCatalogEntry { + const provider = catalog.find((entry) => + entry.authorizationOptions.some((option) => option.providerId === providerId) + ) + if (!provider) { + throw new OrchestrationError('validation', `Unknown OAuth provider: ${providerId}`) + } + if (!provider.available) { + throw new OrchestrationError('conflict', `OAuth provider is unavailable: ${providerId}`) + } + return provider +} diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 2e72f796526..0c455f68dff 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -2,13 +2,20 @@ import { db } from '@sim/db' import { credential, pendingCredentialDraft, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, eq, lt } from 'drizzle-orm' +import { and, eq, gt, lt } from 'drizzle-orm' import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' import { credentialProviderMatchesService, getAllOAuthServices } from '@/lib/oauth/utils' const logger = createLogger('OAuthConnectDraft') const DRAFT_TTL_MS = 15 * 60 * 1000 +export type ConnectDraft = typeof pendingCredentialDraft.$inferSelect + +export interface CreatedConnectDraft { + id: string + expiresAt: Date +} + /** * Creates the pending credential draft at OAuth click time so custom and * generic OAuth callbacks can materialize the connected workspace credential. @@ -21,7 +28,7 @@ export async function createConnectDraft(params: { credentialId?: string /** Reconnect only: the credential's actual name, so audit records stay accurate. */ displayName?: string -}): Promise<void> { +}): Promise<CreatedConnectDraft> { const { userId, workspaceId, providerId, credentialId } = params let displayName = params.displayName @@ -32,42 +39,21 @@ export async function createConnectDraft(params: { const service = getAllOAuthServices().find((s) => credentialProviderMatchesService(providerId, s) ) - const serviceName = service?.name ?? providerId + if (!service) throw new Error(`Cannot create OAuth draft for unknown provider ${providerId}`) + const serviceName = service.name - let userName: string | null = null - try { - const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) - userName = row?.name ?? null - } catch (error) { - // Cosmetic only — fall back to the "My {Service}" default - logger.warn('User name lookup failed for connect draft display name', { - userId, - workspaceId, - providerId, - error, - }) - } + const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) + if (!row) throw new Error(`Cannot create OAuth draft for missing user ${userId}`) + const userName = row.name // Auto-number against existing workspace credentials so repeat connects for // the same provider stay distinguishable — same behavior as the connect - // modal, which computes this client-side. Best effort: on failure the name - // simply skips deduplication. - let takenNames: ReadonlySet<string> = new Set<string>() - try { - const rows = await db - .select({ displayName: credential.displayName }) - .from(credential) - .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth'))) - takenNames = new Set(rows.map((row) => row.displayName.toLowerCase())) - } catch (error) { - // Cosmetic only — proceed without collision numbering - logger.warn('Credential name lookup failed for connect draft deduplication', { - userId, - workspaceId, - providerId, - error, - }) - } + // modal, which computes this client-side. + const rows = await db + .select({ displayName: credential.displayName }) + .from(credential) + .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth'))) + const takenNames = new Set(rows.map((credentialRow) => credentialRow.displayName.toLowerCase())) displayName = defaultCredentialDisplayName(userName, serviceName, takenNames) } @@ -79,10 +65,11 @@ export async function createConnectDraft(params: { .where( and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) ) - await db + const id = generateId() + const [draft] = await db .insert(pendingCredentialDraft) .values({ - id: generateId(), + id, userId, workspaceId, providerId, @@ -100,8 +87,11 @@ export async function createConnectDraft(params: { // credentialId must be written on BOTH paths: a plain connect that reuses a // stale reconnect draft row would otherwise silently rebind the old // credential instead of creating a new one. - set: { displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, + set: { id, displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, }) + .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) + + if (!draft) throw new Error('OAuth connect draft insert returned no row') logger.info('Created OAuth connect credential draft', { userId, @@ -109,4 +99,23 @@ export async function createConnectDraft(params: { providerId, credentialId: credentialId ?? null, }) + return draft +} + +export async function getActiveConnectDraft( + draftId: string, + userId: string +): Promise<ConnectDraft | null> { + const [draft] = await db + .select() + .from(pendingCredentialDraft) + .where( + and( + eq(pendingCredentialDraft.id, draftId), + eq(pendingCredentialDraft.userId, userId), + gt(pendingCredentialDraft.expiresAt, new Date()) + ) + ) + .limit(1) + return draft ?? null } diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 58393603987..5a40d039ee0 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1105, - zodRoutes: 1105, + totalRoutes: 1107, + zodRoutes: 1107, nonZodRoutes: 0, } as const diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 21ebac6ba87..191fb41000a 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -37,7 +37,7 @@ const EXPECTED_OPERATION_COUNTS = new Map<string, number>([ ['apps/docs/openapi-v2-tables.json', 44], ['apps/docs/openapi-v2-knowledge.json', 21], ['apps/docs/openapi-v2-billing.json', 2], - ['apps/docs/openapi-v2-resources.json', 22], + ['apps/docs/openapi-v2-resources.json', 24], ]) function getOperation(spec: JsonObject, path: string, method: string): JsonObject { @@ -169,7 +169,7 @@ describe('generated OpenAPI documents', () => { }) } } - expect(totalOperations).toBe(135) + expect(totalOperations).toBe(137) }) it('documents mixed workflow execution and resume responses', () => { From f0767d3af14cc824c8cd3b9c7db500ccb6d75083 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Thu, 13 Aug 2026 11:28:29 -0700 Subject: [PATCH 123/159] fix(credentials): preserve active OAuth connection links --- .../sim/lib/credentials/connect-draft.test.ts | 46 +++++++++++++++++++ apps/sim/lib/credentials/connect-draft.ts | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/credentials/connect-draft.test.ts diff --git a/apps/sim/lib/credentials/connect-draft.test.ts b/apps/sim/lib/credentials/connect-draft.test.ts new file mode 100644 index 00000000000..a230dec4cc8 --- /dev/null +++ b/apps/sim/lib/credentials/connect-draft.test.ts @@ -0,0 +1,46 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGenerateId } = vi.hoisted(() => ({ + mockGenerateId: vi.fn(), +})) + +vi.mock('@sim/utils/id', () => ({ generateId: mockGenerateId })) + +import { createConnectDraft } from '@/lib/credentials/connect-draft' + +describe('createConnectDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGenerateId.mockReturnValue('new-draft-id') + }) + + it('preserves the active draft ID when refreshing the same connection intent', async () => { + const expiresAt = new Date('2026-08-13T20:15:00.000Z') + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'active-draft-id', expiresAt }]) + + const result = await createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + }) + + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ id: 'new-draft-id' }) + ) + const conflict = dbChainMockFns.onConflictDoUpdate.mock.calls[0]?.[0] as + | { set?: Record<string, unknown> } + | undefined + expect(conflict?.set).not.toHaveProperty('id') + expect(conflict?.set).toMatchObject({ + displayName: 'Work Gmail', + credentialId: null, + }) + expect(result).toEqual({ id: 'active-draft-id', expiresAt }) + }) +}) diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 0c455f68dff..027f6ae26a2 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -87,7 +87,7 @@ export async function createConnectDraft(params: { // credentialId must be written on BOTH paths: a plain connect that reuses a // stale reconnect draft row would otherwise silently rebind the old // credential instead of creating a new one. - set: { id, displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, + set: { displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, }) .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) From e4b09dca7f047a76ede2f84719d5bbeefd6b6361 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Thu, 13 Aug 2026 11:37:24 -0700 Subject: [PATCH 124/159] fix(credentials): bind OAuth links to connection intent --- .../api/auth/oauth2/authorize/route.test.ts | 17 +++++------ .../v2/credential-connections/route.test.ts | 30 +++++++++++++++++++ .../sim/lib/credentials/connect-draft.test.ts | 28 +++++++++++++---- apps/sim/lib/credentials/connect-draft.ts | 21 +++++++++---- 4 files changed, 74 insertions(+), 22 deletions(-) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index 938e0f3310a..c9bb3e8e9dd 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -214,9 +214,7 @@ describe('OAuth2 authorize route', () => { }) ) expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - set: expect.objectContaining({ credentialId: null }), - }) + expect.objectContaining({ setWhere: expect.anything() }) ) }) @@ -232,11 +230,12 @@ describe('OAuth2 authorize route', () => { ) }) - it('nulls out credentialId in the upsert set so a stale reconnect draft cannot leak into a plain connect', async () => { + it('does not overwrite a reconnect intent when refreshing a plain connect', async () => { await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - const [{ set }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0] - expect(set).toHaveProperty('credentialId', null) + const [{ set, setWhere }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0] + expect(set).not.toHaveProperty('credentialId') + expect(setWhere).toBeDefined() }) it('rejects an OAuth client that is not configured for the deployment', async () => { @@ -283,7 +282,7 @@ describe('OAuth2 authorize route', () => { }) describe('reconnect (credentialId present)', () => { - it('creates a reconnect draft carrying credentialId in values and upsert set', async () => { + it('creates a reconnect draft and guards conflict refreshes by intent', async () => { mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) const response = await GET( @@ -304,9 +303,7 @@ describe('OAuth2 authorize route', () => { expect.objectContaining({ credentialId: CREDENTIAL_ID }) ) expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - set: expect.objectContaining({ credentialId: CREDENTIAL_ID }), - }) + expect.objectContaining({ setWhere: expect.anything() }) ) }) diff --git a/apps/sim/app/api/v2/credential-connections/route.test.ts b/apps/sim/app/api/v2/credential-connections/route.test.ts index 569eb61dd72..3c69d229b94 100644 --- a/apps/sim/app/api/v2/credential-connections/route.test.ts +++ b/apps/sim/app/api/v2/credential-connections/route.test.ts @@ -12,6 +12,7 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { WorkspaceApiKeyScopeAuthorizationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' const mocks = vi.hoisted(() => ({ execute: vi.fn() })) @@ -147,4 +148,33 @@ describe('POST /api/v2/credential-connections', () => { error: { code: 'NOT_FOUND', message: 'Workspace not found' }, }) }) + + it('returns a conflict when another intent already owns the active provider draft', async () => { + mocks.execute.mockRejectedValueOnce( + new OrchestrationError( + 'conflict', + 'A different OAuth connection flow is already active for this provider' + ) + ) + + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credential-connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }), + }) + ) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: { + code: 'CONFLICT', + message: 'A different OAuth connection flow is already active for this provider', + }, + }) + }) }) diff --git a/apps/sim/lib/credentials/connect-draft.test.ts b/apps/sim/lib/credentials/connect-draft.test.ts index a230dec4cc8..4394d86067b 100644 --- a/apps/sim/lib/credentials/connect-draft.test.ts +++ b/apps/sim/lib/credentials/connect-draft.test.ts @@ -19,7 +19,7 @@ describe('createConnectDraft', () => { mockGenerateId.mockReturnValue('new-draft-id') }) - it('preserves the active draft ID when refreshing the same connection intent', async () => { + it('refreshes the expiry without changing an active connection intent', async () => { const expiresAt = new Date('2026-08-13T20:15:00.000Z') dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'active-draft-id', expiresAt }]) @@ -34,13 +34,29 @@ describe('createConnectDraft', () => { expect.objectContaining({ id: 'new-draft-id' }) ) const conflict = dbChainMockFns.onConflictDoUpdate.mock.calls[0]?.[0] as - | { set?: Record<string, unknown> } + | { set?: Record<string, unknown>; setWhere?: unknown } | undefined expect(conflict?.set).not.toHaveProperty('id') - expect(conflict?.set).toMatchObject({ - displayName: 'Work Gmail', - credentialId: null, - }) + expect(conflict?.set).not.toHaveProperty('displayName') + expect(conflict?.set).not.toHaveProperty('credentialId') + expect(conflict?.setWhere).toBeDefined() expect(result).toEqual({ id: 'active-draft-id', expiresAt }) }) + + it('fails fast when an active draft has a different connection intent', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Existing Gmail', + }) + ).rejects.toMatchObject({ + code: 'conflict', + message: 'A different OAuth connection flow is already active for this provider', + }) + }) }) diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 027f6ae26a2..23a7e4ca34a 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -2,7 +2,8 @@ import { db } from '@sim/db' import { credential, pendingCredentialDraft, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, eq, gt, lt } from 'drizzle-orm' +import { and, eq, gt, isNull, lt } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' import { credentialProviderMatchesService, getAllOAuthServices } from '@/lib/oauth/utils' @@ -84,14 +85,22 @@ export async function createConnectDraft(params: { pendingCredentialDraft.providerId, pendingCredentialDraft.workspaceId, ], - // credentialId must be written on BOTH paths: a plain connect that reuses a - // stale reconnect draft row would otherwise silently rebind the old - // credential instead of creating a new one. - set: { displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, + set: { expiresAt, createdAt: now }, + setWhere: and( + eq(pendingCredentialDraft.displayName, displayName), + credentialId + ? eq(pendingCredentialDraft.credentialId, credentialId) + : isNull(pendingCredentialDraft.credentialId) + ), }) .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) - if (!draft) throw new Error('OAuth connect draft insert returned no row') + if (!draft) { + throw new OrchestrationError( + 'conflict', + 'A different OAuth connection flow is already active for this provider' + ) + } logger.info('Created OAuth connect credential draft', { userId, From e14963685475f86759cc94fa50edf6eb430a68b3 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Thu, 13 Aug 2026 13:32:38 -0700 Subject: [PATCH 125/159] feat(credentials): complete v2 credential lifecycle --- apps/docs/openapi-v2-resources.json | 722 ++++++++++++++++-- .../v2/credential-connections/route.test.ts | 180 ----- .../api/v2/credential-providers/route.test.ts | 124 --- .../credentials/[credentialId]/route.test.ts | 77 ++ .../v2/credentials/[credentialId]/route.ts | 30 + .../v2/credentials/connections/route.test.ts | 93 +++ .../connections}/route.ts | 0 .../v2/credentials/providers/route.test.ts | 114 +++ .../providers}/route.ts | 0 apps/sim/app/api/v2/credentials/route.test.ts | 128 +++- apps/sim/app/api/v2/credentials/route.ts | 50 +- .../v2/__tests__/list-pagination.test.ts | 2 +- apps/sim/lib/api/contracts/v2/credentials.ts | 295 ++++++- .../lib/api/contracts/v2/openapi/resources.ts | 132 +++- .../__tests__/webhook-deactivation.test.ts | 39 +- .../application/connection-target.test.ts | 3 +- .../application/connection-target.ts | 22 +- .../lib/credentials/application/operations.ts | 12 + .../credentials/application/presentation.ts | 26 + .../application/provider-catalog.test.ts | 73 +- .../application/provider-catalog.ts | 259 ++++++- .../application/service-account.test.ts | 209 +++++ .../application/service-account.ts | 211 +++++ apps/sim/lib/credentials/deletion.ts | 27 + .../orchestration/credential-create.ts | 109 ++- .../lib/credentials/orchestration/index.ts | 3 + .../credential-visibility.server.ts | 23 +- scripts/check-api-validation-contracts.ts | 4 +- scripts/openapi/documents.test.ts | 4 +- 29 files changed, 2444 insertions(+), 527 deletions(-) delete mode 100644 apps/sim/app/api/v2/credential-connections/route.test.ts delete mode 100644 apps/sim/app/api/v2/credential-providers/route.test.ts create mode 100644 apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts create mode 100644 apps/sim/app/api/v2/credentials/[credentialId]/route.ts create mode 100644 apps/sim/app/api/v2/credentials/connections/route.test.ts rename apps/sim/app/api/v2/{credential-connections => credentials/connections}/route.ts (100%) create mode 100644 apps/sim/app/api/v2/credentials/providers/route.test.ts rename apps/sim/app/api/v2/{credential-providers => credentials/providers}/route.ts (100%) create mode 100644 apps/sim/lib/credentials/application/presentation.ts create mode 100644 apps/sim/lib/credentials/application/service-account.test.ts create mode 100644 apps/sim/lib/credentials/application/service-account.ts diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 69ae6f3206e..9706338488b 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -39,7 +39,7 @@ }, { "name": "Credentials", - "description": "Discover OAuth providers, connect or reconnect accounts, and list connections without secret material." + "description": "Discover providers, create service-account credentials, connect or reconnect OAuth accounts, disconnect credentials, and list connections without secret material." }, { "name": "Secrets", @@ -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 not exposed.", + "description": "List OAuth and service-account connections visible to the caller. Secret material is never returned.", "tags": ["Credentials"], "parameters": [ { @@ -1703,30 +1703,118 @@ "$ref": "#/components/responses/ServiceUnavailable" } } + }, + "post": { + "operationId": "createServiceAccountCredential", + "summary": "Create Service-Account Credential", + "description": "Verify and store one service-account credential. Use provider discovery to select a service-account provider and submit its required fields. Secret fields are write-only and are never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], + "requestBody": { + "required": true, + "description": "Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceAccountCredentialRequest" + } + } + } + }, + "responses": { + "200": { + "description": "An existing credential matched the verified source.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceAccountCredentialResponse" + } + } + } + }, + "201": { + "description": "The service-account credential was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceAccountCredentialResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } } }, - "/api/v2/credential-providers": { + "/api/v2/credentials/providers": { "get": { "operationId": "listCredentialProviders", "summary": "List Credential Providers", - "description": "List catalogued OAuth services and whether each is available to the caller in this workspace and deployment. Authorization options contain the exact provider IDs accepted by the connection endpoint. The bounded set is returned in one page; `nextCursor` is always null.", + "description": "List catalogued OAuth and service-account connection methods and whether each is available to the caller in this workspace and deployment. OAuth authorization options contain the exact provider IDs accepted by the browser connection endpoint; service-account methods list the exact create-body fields and mark secret fields write-only. The bounded set is returned in one page; `nextCursor` is always null.", "tags": ["Credentials"], "parameters": [ { "name": "workspaceId", "in": "query", "required": true, - "description": "Workspace used to evaluate OAuth availability and integration policy.", + "description": "Workspace used to evaluate credential-provider availability and integration policy.", "schema": { "type": "string", "minLength": 1, - "description": "Workspace used to evaluate OAuth availability and integration policy." + "description": "Workspace used to evaluate credential-provider availability and integration policy." } } ], "responses": { "200": { - "description": "OAuth provider catalog with caller-specific availability.", + "description": "Credential provider catalog with caller-specific availability.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1770,7 +1858,7 @@ } } }, - "/api/v2/credential-connections": { + "/api/v2/credentials/connections": { "post": { "operationId": "createCredentialConnection", "summary": "Create Credential Connection", @@ -1839,6 +1927,83 @@ } } }, + "/api/v2/credentials/{credentialId}": { + "delete": { + "operationId": "deleteCredential", + "summary": "Disconnect Credential", + "description": "Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], + "parameters": [ + { + "name": "credentialId", + "in": "path", + "required": true, + "description": "Credential to disconnect.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential to disconnect." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace expected to own the credential.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workspace expected to own the credential." + } + } + ], + "responses": { + "200": { + "description": "The credential was disconnected.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteCredentialResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/secrets": { "get": { "operationId": "listSecrets", @@ -4210,78 +4375,245 @@ ] }, "V2CredentialProvider": { - "type": "object", - "properties": { - "serviceId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Stable OAuth service identifier." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "OAuth service display name." - }, - "description": { - "type": "string", - "minLength": 1, - "maxLength": 1000, - "description": "OAuth service description." - }, - "providerFamily": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Owning provider family identifier." - }, - "available": { - "type": "boolean", - "description": "Whether this caller can start the OAuth flow in the current deployment." - }, - "supportsReconnect": { - "type": "boolean", - "description": "Whether existing credentials for this service can be reconnected." - }, - "authorizationOptions": { - "minItems": 1, - "maxItems": 10, - "type": "array", - "items": { - "type": "object", - "properties": { - "providerId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Exact OAuth provider identifier accepted by the connection endpoint." + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "oauth", + "description": "Browser-based OAuth connection method." + }, + "serviceId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stable credential-provider identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential provider display name." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Credential provider description." + }, + "providerFamily": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Owning provider family identifier." + }, + "available": { + "type": "boolean", + "description": "Whether this caller can connect the provider in the current deployment." + }, + "supportsReconnect": { + "type": "boolean", + "description": "Whether existing credentials for this service can be reconnected." + }, + "authorizationOptions": { + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "object", + "properties": { + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact OAuth provider identifier accepted by the connection endpoint." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable authorization-server label." + } + }, + "required": ["providerId", "label"], + "additionalProperties": false }, - "label": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Human-readable authorization-server label." - } + "description": "Authorization servers available for this OAuth service." + } + }, + "required": [ + "type", + "serviceId", + "name", + "description", + "providerFamily", + "available", + "supportsReconnect", + "authorizationOptions" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "service_account", + "description": "Direct service-account credential method." + }, + "serviceId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stable credential-provider identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential provider display name." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Credential provider description." + }, + "providerFamily": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Owning provider family identifier." + }, + "available": { + "type": "boolean", + "description": "Whether this caller can connect the provider in the current deployment." }, - "required": ["providerId", "label"], - "additionalProperties": false + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact service-account provider ID accepted by credential creation." + }, + "docsUrl": { + "type": "string", + "format": "uri", + "description": "Setup guide for the provider." + }, + "helpText": { + "description": "Provider-specific setup guidance.", + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "requiresClientGeneratedCredentialId": { + "type": "boolean", + "description": "Whether the caller must generate and submit the credential ID before setup." + }, + "fields": { + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact create-body field name." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable field label." + }, + "placeholder": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Suggested input placeholder." + }, + "required": { + "type": "boolean", + "description": "Whether the field is required for the selected flow." + }, + "secret": { + "type": "boolean", + "description": "Whether the submitted field is write-only secret material." + }, + "multiline": { + "type": "boolean", + "description": "Whether the field is intended for multi-line input." + }, + "requiredForAuthMethods": { + "description": "Authentication methods for which this field is required.", + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "options": { + "description": "Fixed values accepted by a selector field.", + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Submitted option value." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable option label." + } + }, + "required": ["value", "label"], + "additionalProperties": false + } + }, + "hint": { + "description": "Provider-specific setup guidance.", + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + }, + "required": ["id", "label", "placeholder", "required", "secret", "multiline"], + "additionalProperties": false + }, + "description": "Create-body fields accepted by this provider. Secret fields are write-only." + } }, - "description": "Authorization servers available for this OAuth service." + "required": [ + "type", + "serviceId", + "name", + "description", + "providerFamily", + "available", + "providerId", + "docsUrl", + "requiresClientGeneratedCredentialId", + "fields" + ], + "additionalProperties": false } - }, - "required": [ - "serviceId", - "name", - "description", - "providerFamily", - "available", - "supportsReconnect", - "authorizationOptions" ], - "additionalProperties": false, "title": "Credential Provider", - "description": "An OAuth service that may be connected to a workspace." + "description": "An OAuth or service-account connection method available to a workspace." }, "ListCredentialProvidersResponse": { "type": "object", @@ -4308,11 +4640,12 @@ "required": ["data", "nextCursor"], "additionalProperties": false, "title": "List credential providers response", - "description": "OAuth providers and their authorization-server options.", + "description": "OAuth and service-account connection methods.", "examples": [ { "data": [ { + "type": "oauth", "serviceId": "salesforce", "name": "Salesforce", "description": "Connect to Salesforce CRM data and operations.", @@ -4329,12 +4662,209 @@ "label": "Sandbox" } ] + }, + { + "type": "service_account", + "serviceId": "zoom-service-account", + "providerId": "zoom-service-account", + "name": "Zoom server-to-server app", + "description": "Connect Zoom with a server-to-server app.", + "providerFamily": "zoom", + "available": true, + "docsUrl": "https://docs.sim.ai/integrations/zoom-service-account", + "requiresClientGeneratedCredentialId": false, + "fields": [ + { + "id": "clientId", + "label": "Client ID", + "placeholder": "Paste the client ID", + "required": true, + "secret": false, + "multiline": false + }, + { + "id": "clientSecret", + "label": "Client secret", + "placeholder": "Paste the client secret", + "required": true, + "secret": true, + "multiline": false + }, + { + "id": "orgId", + "label": "Account ID", + "placeholder": "Paste the account ID", + "required": true, + "secret": false, + "multiline": false + } + ] } ], "nextCursor": null } ] }, + "CreateServiceAccountCredentialResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Credential" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create service-account credential response", + "description": "Verified credential metadata without secret material.", + "examples": [ + { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom service account", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "CreateServiceAccountCredentialRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace that will own the credential." + }, + "type": { + "type": "string", + "const": "service_account", + "description": "Service-account credential discriminator." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact service-account provider ID returned by provider discovery." + }, + "displayName": { + "description": "Optional name; providers may derive one from the verified account identity.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "description": "Optional credential description.", + "type": "string", + "maxLength": 500 + }, + "id": { + "description": "Required only when provider discovery requests a client-generated ID.", + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "serviceAccountJson": { + "description": "Write-only Google service-account JSON key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "apiToken": { + "description": "Write-only provider API token.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "domain": { + "description": "Provider account domain.", + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "signingSecret": { + "description": "Write-only webhook signing secret.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "botToken": { + "description": "Write-only bot token.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "clientId": { + "description": "OAuth client identifier.", + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "clientSecret": { + "description": "Write-only OAuth client secret.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "orgId": { + "description": "Provider organization ID.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "dataCenter": { + "description": "Provider data center.", + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "authMethod": { + "description": "Provider authentication method.", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "privateKey": { + "description": "Write-only PEM private key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "username": { + "description": "Provider run-as username.", + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "required": ["workspaceId", "type", "providerId"], + "additionalProperties": false, + "title": "Create service-account credential request", + "description": "Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "type": "service_account", + "providerId": "zoom-service-account", + "displayName": "Zoom automation", + "clientId": "YOUR_CLIENT_ID", + "clientSecret": "YOUR_CLIENT_SECRET", + "orgId": "YOUR_ACCOUNT_ID" + } + ] + }, "V2CredentialConnectionAuthorization": { "type": "object", "properties": { @@ -4390,7 +4920,7 @@ "type": "string", "minLength": 1, "maxLength": 255, - "description": "Exact provider ID returned by the credential-provider catalog." + "description": "Exact OAuth provider ID returned by credential-provider discovery." }, "displayName": { "type": "string", @@ -4424,6 +4954,46 @@ "title": "Create credential connection body", "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved." }, + "V2CredentialDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Disconnected credential identifier." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the credential was disconnected." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete credential data", + "description": "Credential disconnection acknowledgement." + }, + "DeleteCredentialResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CredentialDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Disconnect credential response", + "description": "Acknowledgement that the credential was disconnected.", + "examples": [ + { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "deleted": true + } + } + ] + }, "V2Secret": { "type": "object", "properties": { diff --git a/apps/sim/app/api/v2/credential-connections/route.test.ts b/apps/sim/app/api/v2/credential-connections/route.test.ts deleted file mode 100644 index 3c69d229b94..00000000000 --- a/apps/sim/app/api/v2/credential-connections/route.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * @vitest-environment node - */ -import { - V2_OPERATION_RATE_LIMIT_ALLOWED, - V2_PREAUTH_RATE_LIMIT_ALLOWED, - v2ApiKeyAuthModuleMock, - v2GateModuleMock, - v2RateLimiterModuleMock, - v2RouteMocks, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { WorkspaceApiKeyScopeAuthorizationError } from '@/lib/core/application' -import { OrchestrationError } from '@/lib/core/orchestration/types' - -const mocks = vi.hoisted(() => ({ execute: vi.fn() })) - -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) -vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ - createCredentialConnection: { - operation: { id: 'credentials.connections.create' }, - execute: mocks.execute, - }, -})) - -import { POST } from '@/app/api/v2/credential-connections/route' - -const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' -const auth = { - principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, - rolloutUserId: 'user-1', - rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, - rateLimitSubscription: null, - keyType: 'personal' as const, -} - -describe('POST /api/v2/credential-connections', () => { - beforeEach(() => { - vi.clearAllMocks() - v2RouteMocks.authenticate.mockResolvedValue(auth) - v2RouteMocks.gate.mockResolvedValue(null) - v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) - v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.execute.mockResolvedValue({ - authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', - expiresAt: new Date('2026-08-12T20:15:00.000Z'), - }) - }) - - it('rejects requests that provide both connection targets', async () => { - const response = await POST( - new NextRequest('http://localhost:3000/api/v2/credential-connections', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - providerId: 'google-email', - displayName: 'Work Gmail', - credentialId: 'credential-1', - }), - }) - ) - - expect(response.status).toBe(400) - expect(mocks.execute).not.toHaveBeenCalled() - }) - - it('requires a display name for a new connection', async () => { - const response = await POST( - new NextRequest('http://localhost:3000/api/v2/credential-connections', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ workspaceId: WORKSPACE_ID, providerId: 'google-email' }), - }) - ) - - expect(response.status).toBe(400) - expect(mocks.execute).not.toHaveBeenCalled() - }) - - it('rejects a display name when reconnecting an existing credential', async () => { - const response = await POST( - new NextRequest('http://localhost:3000/api/v2/credential-connections', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - credentialId: 'credential-1', - displayName: 'Renamed Gmail', - }), - }) - ) - - expect(response.status).toBe(400) - expect(mocks.execute).not.toHaveBeenCalled() - }) - - it('returns the short-lived browser URL', async () => { - const request = new NextRequest('http://localhost:3000/api/v2/credential-connections', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - providerId: 'google-email', - displayName: 'Work Gmail', - }), - }) - const response = await POST(request) - - expect(response.status).toBe(200) - expect(mocks.execute).toHaveBeenCalledWith({ - principal: auth.principal, - input: { - workspaceId: WORKSPACE_ID, - providerId: 'google-email', - displayName: 'Work Gmail', - }, - request, - }) - expect(await response.json()).toEqual({ - data: { - authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', - expiresAt: '2026-08-12T20:15:00.000Z', - }, - }) - }) - - it('conceals inaccessible workspaces as not found', async () => { - mocks.execute.mockRejectedValueOnce(new WorkspaceApiKeyScopeAuthorizationError()) - - const response = await POST( - new NextRequest('http://localhost:3000/api/v2/credential-connections', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - providerId: 'google-email', - displayName: 'Work Gmail', - }), - }) - ) - - expect(response.status).toBe(404) - expect(await response.json()).toEqual({ - error: { code: 'NOT_FOUND', message: 'Workspace not found' }, - }) - }) - - it('returns a conflict when another intent already owns the active provider draft', async () => { - mocks.execute.mockRejectedValueOnce( - new OrchestrationError( - 'conflict', - 'A different OAuth connection flow is already active for this provider' - ) - ) - - const response = await POST( - new NextRequest('http://localhost:3000/api/v2/credential-connections', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - providerId: 'google-email', - displayName: 'Work Gmail', - }), - }) - ) - - expect(response.status).toBe(409) - expect(await response.json()).toEqual({ - error: { - code: 'CONFLICT', - message: 'A different OAuth connection flow is already active for this provider', - }, - }) - }) -}) diff --git a/apps/sim/app/api/v2/credential-providers/route.test.ts b/apps/sim/app/api/v2/credential-providers/route.test.ts deleted file mode 100644 index 27de3904a9a..00000000000 --- a/apps/sim/app/api/v2/credential-providers/route.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * @vitest-environment node - */ -import { - V2_OPERATION_RATE_LIMIT_ALLOWED, - V2_PREAUTH_RATE_LIMIT_ALLOWED, - v2ApiKeyAuthModuleMock, - v2GateModuleMock, - v2RateLimiterModuleMock, - v2RouteMocks, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { WorkspaceApiKeyScopeAuthorizationError } from '@/lib/core/application' - -const mocks = vi.hoisted(() => ({ execute: vi.fn() })) - -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) -vi.mock('@/lib/credentials/application/list-credential-providers', () => ({ - listCredentialProviders: { - operation: { id: 'credentials.providers.list' }, - execute: mocks.execute, - }, -})) - -import { GET } from '@/app/api/v2/credential-providers/route' - -const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' -const auth = { - principal: { - kind: 'workspace_api_key' as const, - workspaceId: WORKSPACE_ID, - keyId: 'key-1', - }, - rolloutUserId: 'billing-owner-1', - rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, - rateLimitSubscription: null, - keyType: 'workspace' as const, -} - -describe('GET /api/v2/credential-providers', () => { - beforeEach(() => { - vi.clearAllMocks() - v2RouteMocks.authenticate.mockResolvedValue(auth) - v2RouteMocks.gate.mockResolvedValue(null) - v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) - v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.execute.mockResolvedValue({ - providers: [ - { - serviceId: 'salesforce', - name: 'Salesforce', - description: 'Connect Salesforce.', - providerFamily: 'salesforce', - available: true, - supportsReconnect: true, - authorizationOptions: [ - { providerId: 'salesforce', label: 'Production' }, - { providerId: 'salesforce-sandbox', label: 'Sandbox' }, - ], - }, - ], - }) - }) - - it('returns the full provider catalog in one page', async () => { - const request = new NextRequest( - `http://localhost:3000/api/v2/credential-providers?workspaceId=${WORKSPACE_ID}` - ) - const response = await GET(request) - - expect(response.status).toBe(200) - expect(mocks.execute).toHaveBeenCalledWith({ - principal: auth.principal, - input: { workspaceId: WORKSPACE_ID }, - request, - }) - expect(await response.json()).toEqual({ - data: [ - { - serviceId: 'salesforce', - name: 'Salesforce', - description: 'Connect Salesforce.', - providerFamily: 'salesforce', - available: true, - supportsReconnect: true, - authorizationOptions: [ - { providerId: 'salesforce', label: 'Production' }, - { providerId: 'salesforce-sandbox', label: 'Sandbox' }, - ], - }, - ], - nextCursor: null, - }) - }) - - it('rejects query parameters it does not implement', async () => { - const response = await GET( - new NextRequest( - `http://localhost:3000/api/v2/credential-providers?workspaceId=${WORKSPACE_ID}&limit=1` - ) - ) - - expect(response.status).toBe(400) - expect(mocks.execute).not.toHaveBeenCalled() - }) - - it('conceals a workspace-key scope mismatch as not found', async () => { - mocks.execute.mockRejectedValueOnce(new WorkspaceApiKeyScopeAuthorizationError()) - - const response = await GET( - new NextRequest( - `http://localhost:3000/api/v2/credential-providers?workspaceId=${WORKSPACE_ID}` - ) - ) - - expect(response.status).toBe(404) - expect(await response.json()).toEqual({ - error: { code: 'NOT_FOUND', message: 'Workspace not found' }, - }) - }) -}) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts new file mode 100644 index 00000000000..38362bef21f --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts @@ -0,0 +1,77 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +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) +vi.mock('@/lib/credentials/application/service-account', () => ({ + deleteCredentialUseCase: { + operation: { id: 'credentials.delete' }, + execute: mocks.execute, + }, +})) + +import { DELETE } from '@/app/api/v2/credentials/[credentialId]/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +describe('DELETE /api/v2/credentials/[credentialId]', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ credential: { id: 'credential-1' } }) + }) + + it('disconnects a credential through the application operation', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/credentials/credential-1?workspaceId=${WORKSPACE_ID}`, + { method: 'DELETE' } + ) + const response = await DELETE(request, { + params: Promise.resolve({ credentialId: 'credential-1' }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: 'credential-1', deleted: true } }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID, credentialId: 'credential-1' }, + request, + }) + }) + + it('requires the asserted workspace scope', async () => { + const response = await DELETE( + new NextRequest('http://localhost:3000/api/v2/credentials/credential-1', { + method: 'DELETE', + }), + { params: Promise.resolve({ credentialId: 'credential-1' }) } + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/route.ts b/apps/sim/app/api/v2/credentials/[credentialId]/route.ts new file mode 100644 index 00000000000..0baea423259 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[credentialId]/route.ts @@ -0,0 +1,30 @@ +import { v2DeleteCredentialContract } from '@/lib/api/contracts/v2/credentials' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { deleteCredentialUseCase } from '@/lib/credentials/application/service-account' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const credentialErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Credential not found', +}) + +export const DELETE = defineV2JsonRoute({ + contract: v2DeleteCredentialContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.delete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialErrorPolicy, + mapInput: ({ params, query }) => ({ + workspaceId: query.workspaceId, + credentialId: params.credentialId, + }), + useCase: deleteCredentialUseCase, + present: ({ credential }) => ({ data: { id: credential.id, deleted: true as const } }), +}) diff --git a/apps/sim/app/api/v2/credentials/connections/route.test.ts b/apps/sim/app/api/v2/credentials/connections/route.test.ts new file mode 100644 index 00000000000..1946b7fb81c --- /dev/null +++ b/apps/sim/app/api/v2/credentials/connections/route.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +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) +vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ + createCredentialConnection: { + operation: { id: 'credentials.connections.create' }, + execute: mocks.execute, + }, +})) + +import { POST } from '@/app/api/v2/credentials/connections/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +describe('POST /api/v2/credentials/connections', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + }) + }) + + it('creates a browser entrypoint for a named OAuth credential', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/credentials/connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }), + }) + const response = await POST(request) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: '2026-08-12T20:15:00.000Z', + }, + }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }, + request, + }) + }) + + it('requires a display name for new OAuth connections', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credentials/connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, providerId: 'google-email' }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credential-connections/route.ts b/apps/sim/app/api/v2/credentials/connections/route.ts similarity index 100% rename from apps/sim/app/api/v2/credential-connections/route.ts rename to apps/sim/app/api/v2/credentials/connections/route.ts diff --git a/apps/sim/app/api/v2/credentials/providers/route.test.ts b/apps/sim/app/api/v2/credentials/providers/route.test.ts new file mode 100644 index 00000000000..bec5f7126c8 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/providers/route.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +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) +vi.mock('@/lib/credentials/application/list-credential-providers', () => ({ + listCredentialProviders: { + operation: { id: 'credentials.providers.list' }, + execute: mocks.execute, + }, +})) + +import { GET } from '@/app/api/v2/credentials/providers/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const providers = [ + { + type: 'oauth' as const, + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], + }, + { + type: 'service_account' as const, + serviceId: 'salesforce-service-account', + providerId: 'salesforce-service-account', + name: 'Salesforce integration user app', + description: 'Connect Salesforce with an integration user app.', + providerFamily: 'salesforce', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/salesforce-service-account', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'clientSecret', + label: 'Consumer secret', + placeholder: 'Paste the consumer secret', + required: false, + secret: true, + multiline: false, + requiredForAuthMethods: ['client_credentials'], + }, + ], + }, +] + +describe('GET /api/v2/credentials/providers', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ providers }) + }) + + it('returns OAuth and service-account connection methods', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/credentials/providers?workspaceId=${WORKSPACE_ID}` + ) + const response = await GET(request) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: providers, nextCursor: null }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID }, + request, + }) + }) + + it('rejects unsupported pagination instead of ignoring it', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credentials/providers?workspaceId=${WORKSPACE_ID}&limit=1` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credential-providers/route.ts b/apps/sim/app/api/v2/credentials/providers/route.ts similarity index 100% rename from apps/sim/app/api/v2/credential-providers/route.ts rename to apps/sim/app/api/v2/credentials/providers/route.ts diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index 465d2cbd6be..3857fc31409 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -13,7 +13,8 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - execute: vi.fn(), + list: vi.fn(), + create: vi.fn(), })) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) @@ -23,13 +24,20 @@ vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) vi.mock('@/lib/credentials/application/list-workspace-credentials', () => ({ listWorkspaceCredentials: { operation: { id: 'credentials.connections.list' }, - execute: mocks.execute, + execute: mocks.list, + }, +})) + +vi.mock('@/lib/credentials/application/service-account', () => ({ + createServiceAccountCredentialUseCase: { + operation: { id: 'credentials.service_accounts.create' }, + execute: mocks.create, }, })) 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' +import { GET, POST } from '@/app/api/v2/credentials/route' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' const auth = { @@ -67,7 +75,7 @@ describe('GET /api/v2/credentials', () => { v2RouteMocks.gate.mockResolvedValue(null) v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.execute.mockResolvedValue({ + mocks.list.mockResolvedValue({ credentials: [credential], nextCursorKeys: null, sortBy: 'createdAt', @@ -81,7 +89,7 @@ describe('GET /api/v2/credentials', () => { expect(response.status).toBe(400) expect(v2RouteMocks.authenticate).toHaveBeenCalled() expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) - expect(mocks.execute).not.toHaveBeenCalled() + expect(mocks.list).not.toHaveBeenCalled() }) it('calls the application operation with the workspace principal', async () => { @@ -91,7 +99,7 @@ describe('GET /api/v2/credentials', () => { const response = await GET(request) expect(response.status).toBe(200) - expect(mocks.execute).toHaveBeenCalledWith({ + expect(mocks.list).toHaveBeenCalledWith({ principal: auth.principal, input: { workspaceId: WORKSPACE_ID, @@ -114,7 +122,7 @@ describe('GET /api/v2/credentials', () => { * 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({ + mocks.list.mockResolvedValue({ credentials: [credential], nextCursorKeys: ['2026-01-01T00:00:00.000Z', 'credential-1'], sortBy: 'createdAt', @@ -129,7 +137,7 @@ describe('GET /api/v2/credentials', () => { const { nextCursor } = await minted.json() expect(nextCursor).toEqual(expect.any(String)) - mocks.execute.mockClear() + mocks.list.mockClear() const replayed = await GET( new NextRequest( `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&search=slack&cursor=${encodeURIComponent(nextCursor)}` @@ -138,11 +146,11 @@ describe('GET /api/v2/credentials', () => { expect(replayed.status).toBe(400) expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) - expect(mocks.execute).not.toHaveBeenCalled() + expect(mocks.list).not.toHaveBeenCalled() }) it('resumes a cursor replayed under the filters it was minted with', async () => { - mocks.execute.mockResolvedValue({ + mocks.list.mockResolvedValue({ credentials: [credential], nextCursorKeys: ['2026-01-01T00:00:00.000Z', 'credential-1'], sortBy: 'createdAt', @@ -156,7 +164,7 @@ describe('GET /api/v2/credentials', () => { ) const { nextCursor } = await minted.json() - mocks.execute.mockClear() + mocks.list.mockClear() const resumed = await GET( new NextRequest( `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&search=zoom&cursor=${encodeURIComponent(nextCursor)}` @@ -164,7 +172,7 @@ describe('GET /api/v2/credentials', () => { ) expect(resumed.status).toBe(200) - expect(mocks.execute).toHaveBeenCalledWith({ + expect(mocks.list).toHaveBeenCalledWith({ principal: auth.principal, input: expect.objectContaining({ search: 'zoom', @@ -202,7 +210,7 @@ describe('GET /api/v2/credentials', () => { }) it('hides repository errors that may contain secret details', async () => { - mocks.execute.mockRejectedValueOnce(new Error('encryptedServiceAccountKey failed')) + mocks.list.mockRejectedValueOnce(new Error('encryptedServiceAccountKey failed')) const response = await GET( new NextRequest(`http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}`) @@ -214,3 +222,97 @@ describe('GET /api/v2/credentials', () => { }) }) }) + +describe('POST /api/v2/credentials', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue({ + ...auth, + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + keyType: 'personal', + }) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.create.mockResolvedValue({ + credential: { ...credential, encryptedServiceAccountKey: 'must-not-leak' }, + created: true, + hasServiceAccountKey: true, + role: 'admin', + auditMetadata: {}, + }) + }) + + it('creates a verified service-account credential without returning secrets', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Zoom account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }), + }) + const response = await POST(request) + const body = await response.json() + + expect(response.status).toBe(201) + expect(body.data).toMatchObject({ + id: 'credential-1', + type: 'service_account', + displayName: 'Zoom account', + providerId: 'zoom-service-account', + hasServiceAccountKey: true, + role: 'admin', + }) + expect(JSON.stringify(body)).not.toContain('client-secret') + expect(JSON.stringify(body)).not.toContain('must-not-leak') + expect(mocks.create).toHaveBeenCalledWith({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Zoom account', + description: undefined, + id: undefined, + serviceAccountJson: undefined, + apiToken: undefined, + domain: undefined, + signingSecret: undefined, + botToken: undefined, + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + dataCenter: undefined, + authMethod: undefined, + privateKey: undefined, + username: undefined, + }, + request, + }) + }) + + it('rejects an unknown service-account provider before the use case', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'made-up-service-account', + serviceAccountJson: '{}', + }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.create).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index bccfffccbe2..207efb65ec8 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -1,39 +1,26 @@ -import type { V2Credential } from '@/lib/api/contracts/v2/credentials' -import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' +import { + v2CreateServiceAccountCredentialContract, + v2ListCredentialsContract, +} from '@/lib/api/contracts/v2/credentials' import { cursorScopeKey } from '@/lib/api/cursor-binding' import { + createV2ResourceConcealmentPolicy, defineV2JsonRoute, v2ApiKeyAuth, - v2OrchestrationErrorPolicy, v2RateLimits, } from '@/lib/api/server/routes' import { listWorkspaceCredentials } from '@/lib/credentials/application/list-workspace-credentials' import { credentialOperations } from '@/lib/credentials/application/operations' -import type { VisibleWorkspaceCredential } from '@/lib/credentials/queries' +import { toV2Credential } from '@/lib/credentials/application/presentation' +import { createServiceAccountCredentialUseCase } from '@/lib/credentials/application/service-account' 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(), - } -} +const credentialWorkspaceErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) /** Every param that changes which credentials, in which order, this list returns. */ function credentialCursorFilters(query: { @@ -56,7 +43,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: credentialOperations.listConnections, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: credentialWorkspaceErrorPolicy, mapInput: ({ query }) => ({ ...query, cursorKeys: readSortedCursor( @@ -77,3 +64,18 @@ export const GET = defineV2JsonRoute({ ), }), }) + +/** POST /api/v2/credentials — Create and verify a service-account credential. */ +export const POST = defineV2JsonRoute({ + contract: v2CreateServiceAccountCredentialContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.createServiceAccount, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialWorkspaceErrorPolicy, + mapInput: ({ body }) => body, + useCase: createServiceAccountCredentialUseCase, + present: ({ credential, hasServiceAccountKey, role }) => ({ + data: toV2Credential({ ...credential, hasServiceAccountKey, role }), + }), + statusForResult: ({ created }) => (created ? 201 : 200), +}) 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 4ce9499de71..9dd1e8aee32 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 @@ -83,7 +83,7 @@ const PAGED_LISTS = [ * - A table's saved views and its dispatchable groups are capped per table. */ const FULL_SET_LISTS = [ - 'GET /api/v2/credential-providers', + 'GET /api/v2/credentials/providers', 'GET /api/v2/files/folders', 'GET /api/v2/knowledge/[id]/tags', 'GET /api/v2/knowledge/folders', diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 60d537b2754..c7a3ab5c7fd 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.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, nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, @@ -10,6 +10,11 @@ import { v2SortFields, v2TimestampSchema, } from '@/lib/api/contracts/v2/shared' +import { + getServiceAccountRequiredFields, + SERVICE_ACCOUNT_REQUIRED_FIELDS, +} from '@/lib/credentials/service-account-fields' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' /** Public credentials are authenticated connections, never raw environment secrets. */ export const v2CredentialTypeSchema = z @@ -45,27 +50,69 @@ export const v2CredentialSchema = z }) export type V2Credential = z.output<typeof v2CredentialSchema> -export const v2CredentialProviderAuthorizationOptionSchema = z.object({ - providerId: z - .string() - .min(1, 'providerId cannot be empty') - .max(255, 'providerId must be at most 255 characters') - .describe('Exact OAuth provider identifier accepted by the connection endpoint.'), - label: z.string().min(1).max(255).describe('Human-readable authorization-server label.'), -}) +export const v2CredentialProviderAuthorizationOptionSchema = z + .object({ + providerId: z + .string() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact OAuth provider identifier accepted by the connection endpoint.'), + label: z + .string() + .min(1, 'label cannot be empty') + .max(255, 'label must be at most 255 characters') + .describe('Human-readable authorization-server label.'), + }) + .strict() export type V2CredentialProviderAuthorizationOption = z.output< typeof v2CredentialProviderAuthorizationOptionSchema > -export const v2CredentialProviderSchema = z +export const v2CredentialProviderFieldOptionSchema = z .object({ - serviceId: z.string().min(1).max(255).describe('Stable OAuth service identifier.'), - name: z.string().min(1).max(255).describe('OAuth service display name.'), - description: z.string().min(1).max(1000).describe('OAuth service description.'), - providerFamily: z.string().min(1).max(255).describe('Owning provider family identifier.'), - available: z - .boolean() - .describe('Whether this caller can start the OAuth flow in the current deployment.'), + value: z.string().min(1).max(255).describe('Submitted option value.'), + label: z.string().min(1).max(255).describe('Human-readable option label.'), + }) + .strict() + +export const v2CredentialProviderFieldSchema = z + .object({ + id: z.string().min(1).max(255).describe('Exact create-body field name.'), + label: z.string().min(1).max(255).describe('Human-readable field label.'), + placeholder: z.string().min(1).max(1000).describe('Suggested input placeholder.'), + required: z.boolean().describe('Whether the field is required for the selected flow.'), + secret: z.boolean().describe('Whether the submitted field is write-only secret material.'), + multiline: z.boolean().describe('Whether the field is intended for multi-line input.'), + requiredForAuthMethods: z + .array(z.string().min(1).max(64)) + .min(1) + .max(10) + .optional() + .describe('Authentication methods for which this field is required.'), + options: z + .array(v2CredentialProviderFieldOptionSchema) + .min(1) + .max(20) + .optional() + .describe('Fixed values accepted by a selector field.'), + hint: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'), + }) + .strict() + +const v2CredentialProviderBaseShape = { + serviceId: z.string().min(1).max(255).describe('Stable credential-provider identifier.'), + name: z.string().min(1).max(255).describe('Credential provider display name.'), + description: z.string().min(1).max(1000).describe('Credential provider description.'), + providerFamily: z.string().min(1).max(255).describe('Owning provider family identifier.'), + available: z + .boolean() + .describe('Whether this caller can connect the provider in the current deployment.'), +} as const + +export const v2OAuthCredentialProviderSchema = z + .object({ + type: z.literal('oauth').describe('Browser-based OAuth connection method.'), + ...v2CredentialProviderBaseShape, supportsReconnect: z .boolean() .describe('Whether existing credentials for this service can be reconnected.'), @@ -75,10 +122,39 @@ export const v2CredentialProviderSchema = z .max(10) .describe('Authorization servers available for this OAuth service.'), }) + .strict() + +export const v2ServiceAccountCredentialProviderSchema = z + .object({ + type: z.literal('service_account').describe('Direct service-account credential method.'), + ...v2CredentialProviderBaseShape, + providerId: z + .string() + .min(1) + .max(255) + .describe('Exact service-account provider ID accepted by credential creation.'), + docsUrl: z.string().url().describe('Setup guide for the provider.'), + helpText: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'), + requiresClientGeneratedCredentialId: z + .boolean() + .describe('Whether the caller must generate and submit the credential ID before setup.'), + fields: z + .array(v2CredentialProviderFieldSchema) + .min(1) + .max(20) + .describe('Create-body fields accepted by this provider. Secret fields are write-only.'), + }) + .strict() + +export const v2CredentialProviderSchema = z + .discriminatedUnion('type', [ + v2OAuthCredentialProviderSchema, + v2ServiceAccountCredentialProviderSchema, + ]) .meta({ id: 'V2CredentialProvider', title: 'Credential Provider', - description: 'An OAuth service that may be connected to a workspace.', + description: 'An OAuth or service-account connection method available to a workspace.', }) export type V2CredentialProvider = z.output<typeof v2CredentialProviderSchema> @@ -104,11 +180,6 @@ export const v2ListCredentialsQuerySchema = z .strict() export type V2ListCredentialsQuery = z.output<typeof v2ListCredentialsQuerySchema> -/** - * Lists OAuth and service-account connections, keyset-paginated over the active - * sort. Credential mutations are intentionally absent. Nothing capped the - * per-workspace set before pagination, so the response grew without bound. - */ export const v2ListCredentialsContract = defineRouteContract({ method: 'GET', path: '/api/v2/credentials', @@ -122,7 +193,7 @@ export const v2ListCredentialsContract = defineRouteContract({ export const v2ListCredentialProvidersQuerySchema = z .object({ workspaceId: workspaceIdSchema.describe( - 'Workspace used to evaluate OAuth availability and integration policy.' + 'Workspace used to evaluate credential-provider availability and integration policy.' ), }) .strict() @@ -130,7 +201,7 @@ export type V2ListCredentialProvidersQuery = z.output<typeof v2ListCredentialPro export const v2ListCredentialProvidersContract = defineRouteContract({ method: 'GET', - path: '/api/v2/credential-providers', + path: '/api/v2/credentials/providers', query: v2ListCredentialProvidersQuerySchema, response: { mode: 'json', @@ -146,7 +217,7 @@ const v2CreateCredentialConnectionByProviderSchema = z .trim() .min(1, 'providerId cannot be empty') .max(255, 'providerId must be at most 255 characters') - .describe('Exact provider ID returned by the credential-provider catalog.'), + .describe('Exact OAuth provider ID returned by credential-provider discovery.'), displayName: z .string({ error: 'displayName is required' }) .trim() @@ -202,10 +273,180 @@ export type V2CreateCredentialConnectionResponse = z.output< export const v2CreateCredentialConnectionContract = defineRouteContract({ method: 'POST', - path: '/api/v2/credential-connections', + path: '/api/v2/credentials/connections', + query: noInputSchema, body: v2CreateCredentialConnectionBodySchema, response: { mode: 'json', schema: v2CreateCredentialConnectionResponseSchema, }, }) + +const v2ServiceAccountSecretFieldsShape = { + serviceAccountJson: z + .string() + .min(1) + .max(65_536) + .optional() + .describe('Write-only Google service-account JSON key.') + .meta({ writeOnly: true }), + apiToken: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only provider API token.') + .meta({ writeOnly: true }), + domain: z.string().trim().min(1).max(2048).optional().describe('Provider account domain.'), + signingSecret: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only webhook signing secret.') + .meta({ writeOnly: true }), + botToken: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only bot token.') + .meta({ writeOnly: true }), + clientId: z.string().trim().min(1).max(512).optional().describe('OAuth client identifier.'), + clientSecret: z + .string() + .trim() + .min(1) + .max(1024) + .optional() + .describe('Write-only OAuth client secret.') + .meta({ writeOnly: true }), + orgId: z.string().trim().min(1).max(255).optional().describe('Provider organization ID.'), + dataCenter: z.string().trim().min(1).max(32).optional().describe('Provider data center.'), + authMethod: z + .string() + .trim() + .min(1) + .max(64) + .optional() + .describe('Provider authentication method.'), + privateKey: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only PEM private key.') + .meta({ writeOnly: true }), + username: z.string().trim().min(1).max(255).optional().describe('Provider run-as username.'), +} as const + +export const v2CreateServiceAccountCredentialBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that will own the credential.'), + type: z.literal('service_account').describe('Service-account credential discriminator.'), + providerId: z + .string({ error: 'providerId is required' }) + .trim() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact service-account provider ID returned by provider discovery.'), + displayName: z + .string() + .trim() + .min(1, 'displayName cannot be empty') + .max(255, 'displayName must be at most 255 characters') + .optional() + .describe('Optional name; providers may derive one from the verified account identity.'), + description: z + .string() + .trim() + .max(500, 'description must be at most 500 characters') + .optional() + .describe('Optional credential description.'), + id: z + .string() + .uuid('id must be a valid UUID') + .optional() + .describe('Required only when provider discovery requests a client-generated ID.'), + ...v2ServiceAccountSecretFieldsShape, + }) + .strict() + .superRefine((body, ctx) => { + if (!Object.hasOwn(SERVICE_ACCOUNT_REQUIRED_FIELDS, body.providerId)) { + ctx.addIssue({ + code: 'custom', + path: ['providerId'], + message: `Unknown service-account provider: ${body.providerId}`, + }) + return + } + if (body.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID && !body.id) { + ctx.addIssue({ + code: 'custom', + path: ['id'], + message: `id is required for ${SLACK_CUSTOM_BOT_PROVIDER_ID} credentials`, + }) + } + for (const field of getServiceAccountRequiredFields(body.providerId)) { + if (!body[field]) { + ctx.addIssue({ + code: 'custom', + path: [field], + message: `${field} is required for ${body.providerId} credentials`, + }) + } + } + }) +export type V2CreateServiceAccountCredentialBody = z.input< + typeof v2CreateServiceAccountCredentialBodySchema +> + +export const v2CreateServiceAccountCredentialContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/credentials', + query: noInputSchema, + body: v2CreateServiceAccountCredentialBodySchema, + response: { + mode: 'json', + status: [200, 201], + schema: v2DataResponse(v2CredentialSchema), + }, +}) + +export const v2CredentialParamsSchema = z + .object({ + credentialId: nonEmptyIdSchema.max(255).describe('Credential to disconnect.'), + }) + .strict() + +export const v2DeleteCredentialQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace expected to own the credential.'), + }) + .strict() + +export const v2CredentialDeleteDataSchema = z + .object({ + id: nonEmptyIdSchema.describe('Disconnected credential identifier.'), + deleted: z.literal(true).describe('Whether the credential was disconnected.'), + }) + .meta({ + id: 'V2CredentialDeleteData', + title: 'Delete credential data', + description: 'Credential disconnection acknowledgement.', + }) + +export const v2DeleteCredentialContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/credentials/[credentialId]', + params: v2CredentialParamsSchema, + query: v2DeleteCredentialQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 377a1cb7442..7a420995f46 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -1,5 +1,7 @@ import { v2CreateCredentialConnectionContract, + v2CreateServiceAccountCredentialContract, + v2DeleteCredentialContract, v2ListCredentialProvidersContract, v2ListCredentialsContract, } from '@/lib/api/contracts/v2/credentials' @@ -171,6 +173,7 @@ const CREDENTIAL_EXAMPLE = { } as const const CREDENTIAL_PROVIDER_EXAMPLE = { + type: 'oauth', serviceId: 'salesforce', name: 'Salesforce', description: 'Connect to Salesforce CRM data and operations.', @@ -183,6 +186,44 @@ const CREDENTIAL_PROVIDER_EXAMPLE = { ], } as const +const SERVICE_ACCOUNT_PROVIDER_EXAMPLE = { + type: 'service_account', + serviceId: 'zoom-service-account', + providerId: 'zoom-service-account', + name: 'Zoom server-to-server app', + description: 'Connect Zoom with a server-to-server app.', + providerFamily: 'zoom', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/zoom-service-account', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Paste the client ID', + required: true, + secret: false, + multiline: false, + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Paste the client secret', + required: true, + secret: true, + multiline: false, + }, + { + id: 'orgId', + label: 'Account ID', + placeholder: 'Paste the account ID', + required: true, + secret: false, + multiline: false, + }, + ], +} as const + const CREDENTIAL_CONNECTION_EXAMPLE = { authorizationUrl: 'https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123', expiresAt: '2026-06-20T14:17:11.000Z', @@ -806,7 +847,7 @@ const declaredRoutes = [ 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 not exposed.', + 'List OAuth and service-account connections visible to the caller. Secret material is never returned.', errors: RESOURCE_ERRORS, success: { description: 'Credentials visible to the caller.' }, }), @@ -831,9 +872,9 @@ const declaredRoutes = [ resourceOperation('Credentials', { operationId: 'listCredentialProviders', summary: 'List Credential Providers', - description: `List catalogued OAuth services and whether each is available to the caller in this workspace and deployment. Authorization options contain the exact provider IDs accepted by the connection endpoint. ${FULL_SET_LIST}`, + description: `List catalogued OAuth and service-account connection methods and whether each is available to the caller in this workspace and deployment. OAuth authorization options contain the exact provider IDs accepted by the browser connection endpoint; service-account methods list the exact create-body fields and mark secret fields write-only. ${FULL_SET_LIST}`, errors: RESOURCE_ERRORS, - success: { description: 'OAuth provider catalog with caller-specific availability.' }, + success: { description: 'Credential provider catalog with caller-specific availability.' }, }), { query: documentedSchema( @@ -846,8 +887,55 @@ const declaredRoutes = [ v2ListCredentialProvidersContract.response.schema, 'ListCredentialProvidersResponse', 'List credential providers response', - 'OAuth providers and their authorization-server options.', - [{ data: [CREDENTIAL_PROVIDER_EXAMPLE], nextCursor: null }] + 'OAuth and service-account connection methods.', + [ + { + data: [CREDENTIAL_PROVIDER_EXAMPLE, SERVICE_ACCOUNT_PROVIDER_EXAMPLE], + nextCursor: null, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2CreateServiceAccountCredentialContract, + resourceOperation('Credentials', { + operationId: 'createServiceAccountCredential', + summary: 'Create Service-Account Credential', + description: `Verify and store one service-account credential. Use provider discovery to select a service-account provider and submit its required fields. Secret fields are write-only and are never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { + byStatus: { + 200: { description: 'An existing credential matched the verified source.' }, + 201: { description: 'The service-account credential was created.' }, + }, + }, + }), + { + query: v2CreateServiceAccountCredentialContract.query, + body: documentedSchema( + v2CreateServiceAccountCredentialContract.body, + 'CreateServiceAccountCredentialRequest', + 'Create service-account credential request', + 'Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.', + [ + { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Zoom automation', + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + orgId: 'YOUR_ACCOUNT_ID', + }, + ] + ), + response: documentedSchema( + v2CreateServiceAccountCredentialContract.response.schema, + 'CreateServiceAccountCredentialResponse', + 'Create service-account credential response', + 'Verified credential metadata without secret material.', + [{ data: CREDENTIAL_EXAMPLE }] ), } ), @@ -861,6 +949,7 @@ const declaredRoutes = [ success: { description: 'A short-lived browser authorization URL.' }, }), { + query: v2CreateCredentialConnectionContract.query, body: documentedSchema( v2CreateCredentialConnectionContract.body, 'CreateCredentialConnectionBody', @@ -876,6 +965,37 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2DeleteCredentialContract, + resourceOperation('Credentials', { + operationId: 'deleteCredential', + summary: 'Disconnect Credential', + description: `Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'The credential was disconnected.' }, + }), + { + params: documentedSchema( + v2DeleteCredentialContract.params, + 'DeleteCredentialParams', + 'Disconnect credential path parameters', + 'Credential selected for disconnection.' + ), + query: documentedSchema( + v2DeleteCredentialContract.query, + 'DeleteCredentialQuery', + 'Disconnect credential query', + 'Workspace expected to own the credential.' + ), + response: documentedSchema( + v2DeleteCredentialContract.response.schema, + 'DeleteCredentialResponse', + 'Disconnect credential response', + 'Acknowledgement that the credential was disconnected.', + [{ data: { id: CREDENTIAL_EXAMPLE.id, deleted: true } }] + ), + } + ), defineOpenApiRoute( v2ListSecretsContract, resourceOperation('Secrets', { @@ -1026,7 +1146,7 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ { name: 'Credentials', description: - 'Discover OAuth providers, connect or reconnect accounts, and list connections without secret material.', + 'Discover providers, create service-account credentials, connect or reconnect OAuth accounts, disconnect credentials, and list connections without secret material.', }, { name: 'Secrets', diff --git a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts index 917e87d666a..cdb6849481f 100644 --- a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts +++ b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts @@ -15,7 +15,7 @@ vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@sim/db/schema', () => schemaMock) vi.mock('drizzle-orm', () => drizzleOrmMock) -import { clearCredentialRefs } from '@/lib/credentials/deletion' +import { clearCredentialRefs, deleteConnectionCredential } from '@/lib/credentials/deletion' describe('credential-bound webhook deactivation', () => { beforeEach(() => { @@ -39,3 +39,40 @@ describe('credential-bound webhook deactivation', () => { expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.webhook.provider, 'slack') }) }) + +describe('deleteConnectionCredential', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() + }) + + it('deletes exactly one credential within its canonical workspace scope', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }]) + + await deleteConnectionCredential({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + reason: 'user_delete', + }) + + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.credential) + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.id, 'credential-1') + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.workspaceId, 'workspace-1') + }) + + it('fails fast if the authorized credential disappears before deletion commits', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + deleteConnectionCredential({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + reason: 'user_delete', + }) + ).rejects.toThrow('Credential disappeared during deletion') + }) +}) diff --git a/apps/sim/lib/credentials/application/connection-target.test.ts b/apps/sim/lib/credentials/application/connection-target.test.ts index ce3a6805483..143a068961e 100644 --- a/apps/sim/lib/credentials/application/connection-target.test.ts +++ b/apps/sim/lib/credentials/application/connection-target.test.ts @@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/credentials/application/provider-catalog', () => ({ listCredentialProviderCatalog: mocks.listCatalog, - requireAvailableCredentialProvider: ( + requireAvailableOAuthCredentialProvider: ( catalog: Array<{ available: boolean authorizationOptions: Array<{ providerId: string }> @@ -59,6 +59,7 @@ const context = { billedAccountUserId: 'billing-owner-1', } const salesforceProvider = { + type: 'oauth' as const, serviceId: 'salesforce', name: 'Salesforce', description: 'Connect Salesforce.', diff --git a/apps/sim/lib/credentials/application/connection-target.ts b/apps/sim/lib/credentials/application/connection-target.ts index 6365988099e..8783d526463 100644 --- a/apps/sim/lib/credentials/application/connection-target.ts +++ b/apps/sim/lib/credentials/application/connection-target.ts @@ -3,16 +3,16 @@ import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getCredentialActorContext } from '@/lib/credentials/access' import { - type CredentialProviderCatalogEntry, listCredentialProviderCatalog, - requireAvailableCredentialProvider, + type OAuthCredentialProviderCatalogEntry, + requireAvailableOAuthCredentialProvider, } from '@/lib/credentials/application/provider-catalog' import { getWorkspaceCredential } from '@/lib/credentials/queries' import { credentialProviderMatchesService } from '@/lib/oauth/utils' import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' export interface ResolvedCredentialConnectionTarget { - provider: CredentialProviderCatalogEntry + provider: OAuthCredentialProviderCatalogEntry providerId: string credentialId?: string displayName?: string @@ -32,7 +32,7 @@ export async function resolveCredentialConnectionTarget(params: { const catalog = await listCredentialProviderCatalog(principal, context) if (providerId) { return { - provider: requireAvailableCredentialProvider(catalog, providerId), + provider: requireAvailableOAuthCredentialProvider(catalog, providerId), providerId, } } @@ -61,11 +61,15 @@ export async function resolveCredentialConnectionTarget(params: { ) } - const provider = catalog.find((entry) => - credentialProviderMatchesService(credentialProviderId, { - providerId: entry.authorizationOptions[0].providerId, - additionalProviderIds: entry.authorizationOptions.slice(1).map((option) => option.providerId), - }) + const provider = catalog.find( + (entry): entry is OAuthCredentialProviderCatalogEntry => + entry.type === 'oauth' && + credentialProviderMatchesService(credentialProviderId, { + providerId: entry.authorizationOptions[0].providerId, + additionalProviderIds: entry.authorizationOptions + .slice(1) + .map((option) => option.providerId), + }) ) if (!provider) { throw new OrchestrationError('validation', `Unknown OAuth provider: ${credentialProviderId}`) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 7432c1c7430..4dd53eb7dd5 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -19,6 +19,18 @@ export const credentialOperations = { workspaceApiKey: 'deny', principalKinds: ['personal_api_key'], }), + createServiceAccount: defineWorkspaceOperation({ + id: 'credentials.service_accounts.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['personal_api_key'], + }), + delete: defineWorkspaceOperation({ + id: 'credentials.delete', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['personal_api_key'], + }), launchConnection: defineWorkspaceOperation({ id: 'credentials.connections.launch', minimumRole: 'write', diff --git a/apps/sim/lib/credentials/application/presentation.ts b/apps/sim/lib/credentials/application/presentation.ts new file mode 100644 index 00000000000..efbf73c0d07 --- /dev/null +++ b/apps/sim/lib/credentials/application/presentation.ts @@ -0,0 +1,26 @@ +import type { V2Credential } from '@/lib/api/contracts/v2/credentials' +import type { CredentialRow, VisibleWorkspaceCredential } from '@/lib/credentials/queries' + +type PublicCredentialSource = + | VisibleWorkspaceCredential + | (CredentialRow & { hasServiceAccountKey: boolean; role: 'admin' | 'member' }) + +/** Serializes connection metadata field by field so encrypted columns can never reach the wire. */ +export function toV2Credential(row: PublicCredentialSource): 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/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts index 51aece09f81..08a452dd5c2 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -44,7 +44,11 @@ vi.mock('@/lib/oauth/utils', () => ({ getServiceConfigByServiceId: mocks.getServiceConfigByServiceId, })) -import { listCredentialProviderCatalog } from '@/lib/credentials/application/provider-catalog' +import { + listCredentialProviderCatalog, + requireAvailableServiceAccountCredentialProvider, + type ServiceAccountCredentialProviderCatalogEntry, +} from '@/lib/credentials/application/provider-catalog' const personalPrincipal = { kind: 'personal_api_key' as const, @@ -74,11 +78,12 @@ const services = [ authType: 'oauth' as const, }, { - serviceId: 'service-account-only', - providerId: 'service-account-only', - name: 'Service account', - description: 'Not OAuth.', - baseProvider: 'test', + serviceId: 'claude-platform', + providerId: 'claude-platform-service-account', + serviceAccountProviderId: 'claude-platform-service-account', + name: 'Claude Platform', + description: 'Run Claude Platform Managed Agents from your workflows.', + baseProvider: 'claude-platform', authType: 'service_account' as const, }, ] @@ -98,6 +103,8 @@ describe('listCredentialProviderCatalog', () => { }) mocks.createVisibility.mockReturnValue({ isOAuthServiceVisible: (service: { serviceId: string }) => service.serviceId === 'salesforce', + isCredentialVisible: ({ providerId }: { providerId: string }) => + providerId === 'claude-platform-service-account', }) mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { if (serviceId === 'salesforce') { @@ -118,6 +125,7 @@ describe('listCredentialProviderCatalog', () => { expect(catalog).toEqual([ { + type: 'oauth', serviceId: 'salesforce', name: 'Salesforce', description: 'Connect Salesforce.', @@ -130,6 +138,7 @@ describe('listCredentialProviderCatalog', () => { ], }, { + type: 'oauth', serviceId: 'trello', name: 'Trello', description: 'Connect Trello.', @@ -138,6 +147,28 @@ describe('listCredentialProviderCatalog', () => { supportsReconnect: false, authorizationOptions: [{ providerId: 'trello', label: 'Trello' }], }, + { + type: 'service_account', + serviceId: 'claude-platform-service-account', + providerId: 'claude-platform-service-account', + name: 'Claude Platform API key', + description: 'Connect Claude Platform with a API key.', + providerFamily: 'claude-platform', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/managed-agent', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'apiToken', + label: 'API key', + placeholder: 'sk-ant-...', + required: true, + secret: true, + multiline: false, + hint: 'Claude Platform API keys usually start with sk-ant-.', + }, + ], + }, ]) expect(mocks.createVisibility).toHaveBeenCalledWith( expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) }) @@ -174,3 +205,33 @@ describe('listCredentialProviderCatalog', () => { ) }) }) + +describe('requireAvailableServiceAccountCredentialProvider', () => { + const provider: ServiceAccountCredentialProviderCatalogEntry = { + type: 'service_account', + serviceId: 'zoom-service-account', + providerId: 'zoom-service-account', + name: 'Zoom server-to-server app', + description: 'Connect Zoom with a server-to-server app.', + providerFamily: 'zoom', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/zoom-service-account', + requiresClientGeneratedCredentialId: false, + fields: [], + } + + it('returns an available service-account provider', () => { + expect(requireAvailableServiceAccountCredentialProvider([provider], provider.providerId)).toBe( + provider + ) + }) + + it('rejects a service-account provider hidden by workspace policy', () => { + expect(() => + requireAvailableServiceAccountCredentialProvider( + [{ ...provider, available: false }], + provider.providerId + ) + ).toThrow('Service-account provider is unavailable: zoom-service-account') + }) +}) diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index 760cda016d0..90e8d49d500 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -2,7 +2,21 @@ import type { Principal } from '@sim/auth/principal' import { getBlockVisibility } from '@/lib/core/config/block-visibility' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS, + type ClientCredentialAccountField, +} from '@/lib/credentials/client-credential-accounts/descriptors' +import { + TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, + type TokenServiceAccountField, +} from '@/lib/credentials/token-service-accounts/descriptors' import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' +import { + ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + type OAuthServiceMetadata, + SLACK_CUSTOM_BOT_PROVIDER_ID, +} from '@/lib/oauth/types' import { getAllOAuthServices, getServiceConfigByServiceId } from '@/lib/oauth/utils' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' @@ -12,21 +26,195 @@ export interface CredentialProviderAuthorizationOption { label: string } -export interface CredentialProviderCatalogEntry { +export interface CredentialProviderFieldOption { + value: string + label: string +} + +export interface CredentialProviderField { + id: string + label: string + placeholder: string + required: boolean + secret: boolean + multiline: boolean + requiredForAuthMethods?: string[] + options?: CredentialProviderFieldOption[] + hint?: string +} + +interface CredentialProviderCatalogBase { + type: 'oauth' | 'service_account' serviceId: string name: string description: string providerFamily: string available: boolean +} + +export interface OAuthCredentialProviderCatalogEntry extends CredentialProviderCatalogBase { + type: 'oauth' supportsReconnect: boolean authorizationOptions: CredentialProviderAuthorizationOption[] } +export interface ServiceAccountCredentialProviderCatalogEntry + extends CredentialProviderCatalogBase { + type: 'service_account' + providerId: string + docsUrl: string + helpText?: string + requiresClientGeneratedCredentialId: boolean + fields: CredentialProviderField[] +} + +export type CredentialProviderCatalogEntry = + | OAuthCredentialProviderCatalogEntry + | ServiceAccountCredentialProviderCatalogEntry + interface CredentialProviderCatalogContext { workspaceId: string workspaceOrganizationId: string | null } +interface ServiceAccountDescriptor { + name: string + description: string + docsUrl: string + helpText?: string + requiresClientGeneratedCredentialId?: boolean + fields: CredentialProviderField[] +} + +const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account' +const ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL = + 'https://docs.sim.ai/integrations/atlassian-service-account' + +function providerField( + field: TokenServiceAccountField | ClientCredentialAccountField +): CredentialProviderField { + return { + id: field.id, + label: field.label, + placeholder: field.placeholder, + required: !('optional' in field && field.optional), + secret: field.secret, + multiline: 'multiline' in field && field.multiline === true, + ...('requiredForAuthMethods' in field && field.requiredForAuthMethods + ? { requiredForAuthMethods: [...field.requiredForAuthMethods] } + : {}), + ...('options' in field && field.options ? { options: [...field.options] } : {}), + ...('hint' in field && field.hint + ? { hint: field.hint } + : 'hintMessage' in field && field.hintMessage + ? { hint: field.hintMessage } + : {}), + } +} + +function getServiceAccountDescriptor(providerId: string): ServiceAccountDescriptor { + if (providerId === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID) { + return { + name: 'Google service account', + description: 'Connect Google APIs with a service-account JSON key.', + docsUrl: GOOGLE_SERVICE_ACCOUNT_DOCS_URL, + fields: [ + { + id: 'serviceAccountJson', + label: 'JSON key', + placeholder: 'Paste the service-account JSON key', + required: true, + secret: true, + multiline: true, + }, + ], + } + } + if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) { + return { + name: 'Atlassian service account', + description: 'Connect Jira and Confluence with an Atlassian API token.', + docsUrl: ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL, + fields: [ + { + id: 'apiToken', + label: 'API token', + placeholder: 'Paste the API token', + required: true, + secret: true, + multiline: false, + }, + { + id: 'domain', + label: 'Site domain', + placeholder: 'your-team.atlassian.net', + required: true, + secret: false, + multiline: false, + }, + ], + } + } + if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) { + return { + name: 'Slack custom bot', + description: 'Connect a reusable Slack app with its signing secret and bot token.', + docsUrl: 'https://docs.sim.ai/integrations/slack', + requiresClientGeneratedCredentialId: true, + fields: [ + { + id: 'signingSecret', + label: 'Signing secret', + placeholder: 'Paste the signing secret', + required: true, + secret: true, + multiline: false, + }, + { + id: 'botToken', + label: 'Bot token', + placeholder: 'xoxb-...', + required: true, + secret: true, + multiline: false, + }, + ], + } + } + + const tokenDescriptor = Object.hasOwn(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, providerId) + ? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[ + providerId as keyof typeof TOKEN_SERVICE_ACCOUNT_DESCRIPTORS + ] + : undefined + if (tokenDescriptor) { + return { + name: `${tokenDescriptor.serviceLabel} ${tokenDescriptor.connectNoun}`, + description: `Connect ${tokenDescriptor.serviceLabel} with a ${tokenDescriptor.tokenNoun}.`, + docsUrl: tokenDescriptor.docsUrl, + helpText: tokenDescriptor.helpText, + fields: tokenDescriptor.fields.map(providerField), + } + } + + const clientDescriptor = Object.hasOwn(CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS, providerId) + ? CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS[ + providerId as keyof typeof CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS + ] + : undefined + if (clientDescriptor) { + return { + name: `${clientDescriptor.serviceLabel} ${clientDescriptor.connectNoun}`, + description: `Connect ${clientDescriptor.serviceLabel} with a ${clientDescriptor.connectNoun}.`, + docsUrl: clientDescriptor.docsUrl, + helpText: clientDescriptor.helpText, + fields: clientDescriptor.fields.map(providerField), + } + } + + throw new Error(`Service-account provider ${providerId} is missing its canonical descriptor`) +} + function principalUserId(principal: Principal): string | undefined { if (principal.kind === 'session' || principal.kind === 'personal_api_key') { return principal.userId @@ -60,14 +248,15 @@ export async function listCredentialProviderCatalog( ...(context.workspaceOrganizationId ? { orgId: context.workspaceOrganizationId } : {}), }), ]) - const oauthServices = getAllOAuthServices().filter((service) => service.authType === 'oauth') + const services = getAllOAuthServices() + const oauthServices = services.filter((service) => service.authType === 'oauth') const visibility = createIntegrationCredentialVisibility({ allowedIntegrationTypes: allowedIntegrations, blockVisibility, - oauthServices, + oauthServices: services, }) - return oauthServices.map((service) => { + const oauthEntries: OAuthCredentialProviderCatalogEntry[] = oauthServices.map((service) => { const config = getServiceConfigByServiceId(service.serviceId) if (!config) { throw new Error(`OAuth service ${service.serviceId} is missing its canonical configuration`) @@ -85,6 +274,7 @@ export async function listCredentialProviderCatalog( }) return { + type: 'oauth', serviceId: service.serviceId, name: service.name, description: service.description, @@ -94,14 +284,47 @@ export async function listCredentialProviderCatalog( authorizationOptions, } }) + + const serviceAccountOwners = new Map<string, OAuthServiceMetadata>() + for (const service of services) { + const serviceAccountProviderId = + service.serviceAccountProviderId ?? + (service.authType === 'service_account' ? service.providerId : undefined) + if (serviceAccountProviderId && !serviceAccountOwners.has(serviceAccountProviderId)) { + serviceAccountOwners.set(serviceAccountProviderId, service) + } + } + + const serviceAccountEntries: ServiceAccountCredentialProviderCatalogEntry[] = [ + ...serviceAccountOwners, + ].map(([providerId, owner]) => { + const descriptor = getServiceAccountDescriptor(providerId) + return { + type: 'service_account', + serviceId: providerId, + providerId, + name: descriptor.name, + description: descriptor.description, + providerFamily: owner.baseProvider, + available: visibility.isCredentialVisible({ providerId, type: 'service_account' }), + docsUrl: descriptor.docsUrl, + ...(descriptor.helpText ? { helpText: descriptor.helpText } : {}), + requiresClientGeneratedCredentialId: descriptor.requiresClientGeneratedCredentialId === true, + fields: descriptor.fields, + } + }) + + return [...oauthEntries, ...serviceAccountEntries] } -export function requireAvailableCredentialProvider( +export function requireAvailableOAuthCredentialProvider( catalog: readonly CredentialProviderCatalogEntry[], providerId: string -): CredentialProviderCatalogEntry { - const provider = catalog.find((entry) => - entry.authorizationOptions.some((option) => option.providerId === providerId) +): OAuthCredentialProviderCatalogEntry { + const provider = catalog.find( + (entry): entry is OAuthCredentialProviderCatalogEntry => + entry.type === 'oauth' && + entry.authorizationOptions.some((option) => option.providerId === providerId) ) if (!provider) { throw new OrchestrationError('validation', `Unknown OAuth provider: ${providerId}`) @@ -111,3 +334,23 @@ export function requireAvailableCredentialProvider( } return provider } + +export function requireAvailableServiceAccountCredentialProvider( + catalog: readonly CredentialProviderCatalogEntry[], + providerId: string +): ServiceAccountCredentialProviderCatalogEntry { + const provider = catalog.find( + (entry): entry is ServiceAccountCredentialProviderCatalogEntry => + entry.type === 'service_account' && entry.providerId === providerId + ) + if (!provider) { + throw new OrchestrationError('validation', `Unknown service-account provider: ${providerId}`) + } + if (!provider.available) { + throw new OrchestrationError( + 'conflict', + `Service-account provider is unavailable: ${providerId}` + ) + } + return provider +} diff --git a/apps/sim/lib/credentials/application/service-account.test.ts b/apps/sim/lib/credentials/application/service-account.test.ts new file mode 100644 index 00000000000..37a384a6752 --- /dev/null +++ b/apps/sim/lib/credentials/application/service-account.test.ts @@ -0,0 +1,209 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + create: vi.fn(), + listCatalog: vi.fn(), + requireProvider: vi.fn(), + getCredential: vi.fn(), + getActor: vi.fn(), + delete: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/orchestration', () => ({ + createServiceAccountCredential: mocks.create, + deleteConnectionCredential: mocks.delete, +})) +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, + requireAvailableServiceAccountCredentialProvider: mocks.requireProvider, +})) +vi.mock('@/lib/credentials/queries', () => ({ + getWorkspaceCredential: mocks.getCredential, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { + createServiceAccountCredentialUseCase, + deleteCredentialUseCase, +} from '@/lib/credentials/application/service-account' + +const WORKSPACE_ID = 'workspace-1' +const workspace = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const credential = { + id: 'credential-1', + workspaceId: WORKSPACE_ID, + type: 'service_account' as const, + displayName: 'Zoom account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted', + createdBy: 'user-1', + createdAt: new Date('2026-08-12T20:00:00.000Z'), + updatedAt: new Date('2026-08-12T20:00:00.000Z'), +} + +describe('credential service-account application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.getCredential.mockResolvedValue(credential) + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + mocks.create.mockResolvedValue({ + success: true, + credential, + created: true, + auditMetadata: { tenantId: 'tenant-1' }, + }) + mocks.listCatalog.mockResolvedValue([{ providerId: 'zoom-service-account' }]) + mocks.requireProvider.mockReturnValue({ + type: 'service_account', + providerId: 'zoom-service-account', + available: true, + }) + }) + + it('rejects workspace keys before canonical loading on create', async () => { + await expect( + createServiceAccountCredentialUseCase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('creates through the verified service-account primitive', async () => { + const result = await createServiceAccountCredentialUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }, + }) + + expect(result).toMatchObject({ + credential, + created: true, + hasServiceAccountKey: true, + role: 'admin', + }) + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + providerId: 'zoom-service-account', + }) + ) + }) + + it('rejects service-account providers hidden by workspace policy', async () => { + mocks.requireProvider.mockImplementation(() => { + throw new OrchestrationError( + 'conflict', + 'Service-account provider is unavailable: zoom-service-account' + ) + }) + + await expect( + createServiceAccountCredentialUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('requires credential admin access before disconnecting', async () => { + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'member' }, + hasWorkspaceAccess: true, + isAdmin: false, + }) + + await expect( + deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.delete).not.toHaveBeenCalled() + }) + + it('disconnects an administered credential', async () => { + const result = await deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + + expect(result).toEqual({ credential }) + expect(mocks.delete).toHaveBeenCalledWith({ + credentialId: credential.id, + workspaceId: WORKSPACE_ID, + reason: 'user_delete', + }) + }) +}) diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts new file mode 100644 index 00000000000..ac7e766cc47 --- /dev/null +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -0,0 +1,211 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { HttpError } from '@/lib/core/utils/http-error' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + listCredentialProviderCatalog, + requireAvailableServiceAccountCredentialProvider, +} from '@/lib/credentials/application/provider-catalog' +import { + type CreateServiceAccountCredentialParams, + createServiceAccountCredential, + deleteConnectionCredential, +} from '@/lib/credentials/orchestration' +import { type CredentialRow, getWorkspaceCredential } from '@/lib/credentials/queries' +import { captureServerEvent } from '@/lib/posthog/server' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export type CreateServiceAccountInput = Omit< + CreateServiceAccountCredentialParams, + 'userId' | 'request' +> + +export interface CreateServiceAccountResult { + credential: CredentialRow + created: boolean + hasServiceAccountKey: boolean + role: 'admin' | 'member' + auditMetadata: Record<string, unknown> +} + +class CredentialProviderUnavailableError extends HttpError { + readonly statusCode = 503 + + constructor() { + super('Credential provider is temporarily unavailable') + this.name = 'CredentialProviderUnavailableError' + } +} + +function principalUserId(principal: Extract<Principal, { kind: 'personal_api_key' }>): string { + return principal.userId +} + +export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.createServiceAccount, + resolveContext: async ({ input }: { input: CreateServiceAccountInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + async execute({ principal, input, context, request }): Promise<CreateServiceAccountResult> { + const catalog = await listCredentialProviderCatalog(principal, context) + requireAvailableServiceAccountCredentialProvider(catalog, input.providerId) + const result = await createServiceAccountCredential({ + ...input, + workspaceId: context.workspaceId, + userId: principalUserId(principal), + request, + }) + if (!result.success) { + if (result.providerUnavailable) throw new CredentialProviderUnavailableError() + switch (result.errorCode) { + case 'validation': + case 'not_found': + case 'conflict': + throw new OrchestrationError(result.errorCode, result.error ?? 'Credential create failed') + case 'forbidden': + throw new ForbiddenOperationError( + 'INSUFFICIENT_WORKSPACE_ROLE', + result.error ?? 'Write permission required' + ) + default: + throw new Error('Failed to create service-account credential') + } + } + if (!result.credential) { + throw new Error('Credential creation succeeded without a credential') + } + const actor = await getCredentialActorContext(result.credential.id, principalUserId(principal)) + if (!actor.credential || (!actor.member && !actor.isAdmin)) { + throw new Error('Created credential is not visible to its creator') + } + return { + credential: result.credential, + created: result.created === true, + hasServiceAccountKey: Boolean(result.credential.encryptedServiceAccountKey), + role: actor.isAdmin ? 'admin' : 'member', + auditMetadata: result.auditMetadata ?? {}, + } + }, + projectAudit: ({ result }) => + result.created + ? { + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Created service_account credential "${result.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: result.credential.type, + providerId: result.credential.providerId, + }, + } + : [], + afterSuccess: ({ principal, context, result }) => { + if (!result.created) return + captureServerEvent( + principalUserId(principal), + 'credential_connected', + { + credential_type: 'service_account', + provider_id: result.credential.providerId ?? 'service_account', + workspace_id: context.workspaceId, + }, + { + groups: { workspace: context.workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + }, +}) + +interface CredentialApplicationContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string + credential: CredentialRow +} + +export interface DeleteCredentialInput { + workspaceId: string + credentialId: string +} + +export interface DeleteCredentialResult { + credential: CredentialRow +} + +async function resolveCredentialContext( + input: DeleteCredentialInput +): Promise<CredentialApplicationContext> { + const workspace = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Credential not found') + const credential = await getWorkspaceCredential({ + workspaceId: workspace.workspaceId, + credentialId: input.credentialId, + }) + if (!credential || !['oauth', 'service_account'].includes(credential.type)) { + throw new OrchestrationError('not_found', 'Credential not found') + } + return { ...workspace, credential } +} + +export const deleteCredentialUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.delete, + resolveContext: async ({ input }: { input: DeleteCredentialInput }) => + resolveCredentialContext(input), + authorizationOptions: {}, + async execute({ principal, input, context }): Promise<DeleteCredentialResult> { + const userId = principalUserId(principal) + const actor = await getCredentialActorContext(context.credential.id, userId) + if (!actor.credential || !actor.hasWorkspaceAccess) { + throw new OrchestrationError('not_found', 'Credential not found') + } + if (!actor.isAdmin) { + throw new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Credential admin permission required' + ) + } + + await deleteConnectionCredential({ + credentialId: input.credentialId, + workspaceId: context.workspaceId, + reason: 'user_delete', + }) + return { credential: context.credential } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Deleted ${result.credential.type} credential "${result.credential.displayName}" (user_delete)`, + metadata: { + reason: 'user_delete', + credentialType: result.credential.type, + providerId: result.credential.providerId, + accountId: result.credential.accountId, + }, + }), + afterSuccess: ({ principal, context, result }) => { + captureServerEvent( + principalUserId(principal), + 'credential_deleted', + { + credential_type: result.credential.type as 'oauth' | 'service_account', + provider_id: result.credential.providerId ?? result.credential.id, + workspace_id: context.workspaceId, + }, + { groups: { workspace: context.workspaceId } } + ) + }, +}) diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts index 618e51b0d1a..e42081e57fc 100644 --- a/apps/sim/lib/credentials/deletion.ts +++ b/apps/sim/lib/credentials/deletion.ts @@ -23,6 +23,12 @@ interface DeleteCredentialParams { request?: NextRequest } +export interface DeleteConnectionCredentialParams { + credentialId: string + workspaceId: string + reason: CredentialDeleteReason +} + /** * Clears all stored references to the credential, deletes the row, and * records an audit entry. Idempotent when the row no longer exists. @@ -71,6 +77,27 @@ export async function deleteCredential(params: DeleteCredentialParams): Promise< logger.info('Deleted credential', { credentialId, workspaceId: row.workspaceId, reason }) } +/** Clears references and deletes one connection without surface audit attribution. */ +export async function deleteConnectionCredential( + params: DeleteConnectionCredentialParams +): Promise<void> { + const { credentialId, workspaceId } = params + await clearCredentialRefs(credentialId, workspaceId) + const deleted = await db + .delete(schema.credential) + .where( + and(eq(schema.credential.id, credentialId), eq(schema.credential.workspaceId, workspaceId)) + ) + .returning({ id: schema.credential.id }) + if (deleted.length !== 1) throw new Error('Credential disappeared during deletion') + + logger.info('Deleted credential', { + credentialId, + workspaceId, + reason: params.reason, + }) +} + /** * Clears stored references to a credential across mutable workspace state * (editor blocks, copilot checkpoints, knowledge connectors) and frozen diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index 7a09f8a4f16..785f3320c7f 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -5,9 +5,9 @@ import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' import { normalizeCredentialEnvKey } from '@/lib/api/contracts/credentials' import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' import { getCredentialActorContext } from '@/lib/credentials/access' import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment' @@ -81,7 +81,7 @@ export interface PerformCreateCredentialParams { * secrets exist, so the id must be known up front. */ id?: string - request?: NextRequest + request?: OrchestrationRequestContext } export interface PerformCreateCredentialResult { @@ -95,6 +95,8 @@ export interface PerformCreateCredentialResult { credential?: CredentialRow /** False when an existing credential matched the source and was returned instead. */ created?: boolean + /** Verified provider identity metadata for the application audit projection. */ + auditMetadata?: Record<string, unknown> } interface ExistingCredentialSourceParams { @@ -190,14 +192,17 @@ function failure( return { success: false, error, errorCode, ...extra } } -export async function performCreateCredential( - params: PerformCreateCredentialParams +async function createCredentialRecord( + params: PerformCreateCredentialParams, + options: { authorizeWorkspace: boolean } ): Promise<PerformCreateCredentialResult> { const { workspaceId, type, userId } = params try { - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) - if (!workspaceAccess.canWrite) { + const workspaceAccess = options.authorizeWorkspace + ? await checkWorkspaceAccess(workspaceId, userId) + : undefined + if (workspaceAccess && !workspaceAccess.canWrite) { return failure('Write permission required', 'forbidden') } @@ -332,7 +337,7 @@ export async function performCreateCredential( } const access = await getCredentialActorContext(existingCredential.id, userId, { - workspaceAccess, + ...(workspaceAccess ? { workspaceAccess } : {}), }) if (!access.member && !access.isAdmin) { @@ -484,37 +489,7 @@ export async function performCreateCredential( .where(eq(credential.id, credentialId)) .limit(1) - captureServerEvent( - userId, - 'credential_connected', - { credential_type: type, provider_id: resolvedProviderId ?? type, workspace_id: workspaceId }, - { - groups: { workspace: workspaceId }, - setOnce: { first_credential_connected_at: new Date().toISOString() }, - } - ) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_CREATED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - resourceName: resolvedDisplayName, - description: `Created ${type} credential "${resolvedDisplayName}"`, - metadata: { - // Provider metadata spreads first so this path's own keys stay - // authoritative and can never be shadowed, matching the update path. - ...extraAuditMetadata, - credentialType: type, - providerId: resolvedProviderId, - }, - request: params.request, - }) - - return { success: true, credential: created, created: true } + return { success: true, credential: created, created: true, auditMetadata: extraAuditMetadata } } catch (error: unknown) { if (error instanceof AtlassianValidationError) { logger.warn(`Atlassian credential rejected: ${error.code}`, { @@ -570,6 +545,64 @@ export async function performCreateCredential( } } +export type CreateServiceAccountCredentialParams = Omit< + PerformCreateCredentialParams, + 'type' | 'actorName' | 'actorEmail' +> & { providerId: string } + +/** Creates and verifies one service-account credential without surface side effects. */ +export function createServiceAccountCredential( + params: CreateServiceAccountCredentialParams +): Promise<PerformCreateCredentialResult> { + return createCredentialRecord( + { ...params, type: 'service_account' }, + { authorizeWorkspace: false } + ) +} + +/** Preserves the legacy internal surface's analytics and audit behavior. */ +export async function performCreateCredential( + params: PerformCreateCredentialParams +): Promise<PerformCreateCredentialResult> { + const result = await createCredentialRecord(params, { authorizeWorkspace: true }) + if (!result.success || !result.created) return result + if (!result.credential) throw new Error('Credential creation succeeded without a credential') + + captureServerEvent( + params.userId, + 'credential_connected', + { + credential_type: result.credential.type, + provider_id: result.credential.providerId ?? result.credential.type, + workspace_id: result.credential.workspaceId, + }, + { + groups: { workspace: result.credential.workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + + recordAudit({ + workspaceId: result.credential.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Created ${result.credential.type} credential "${result.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: result.credential.type, + providerId: result.credential.providerId, + }, + request: params.request, + }) + + return result +} + /** * Provider error codes that mean the upstream service could not be reached, * rather than that the caller's secret was rejected. Each provider family names diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index ff0a3bd283f..bab184fc3a4 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -32,7 +32,10 @@ import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialOrchestration') +export { deleteConnectionCredential } from '@/lib/credentials/deletion' export { + type CreateServiceAccountCredentialParams, + createServiceAccountCredential, isProviderOutageCode, type PerformCreateCredentialParams, type PerformCreateCredentialResult, diff --git a/apps/sim/lib/integrations/credential-visibility.server.ts b/apps/sim/lib/integrations/credential-visibility.server.ts index 8bb9720552d..1837aa67571 100644 --- a/apps/sim/lib/integrations/credential-visibility.server.ts +++ b/apps/sim/lib/integrations/credential-visibility.server.ts @@ -62,16 +62,21 @@ export function createIntegrationCredentialVisibility({ else ownersByProviderId.set(providerId, [service]) } - for (const service of oauthOwners) { - addOwner(oauthOwnersByProviderId, service.providerId, service) - // A second authorization server for the same service (`salesforce-sandbox`) - // issues ordinary OAuth credentials, so they own visibility exactly like - // the primary provider's do. - for (const extraProviderId of service.additionalProviderIds ?? []) { - addOwner(oauthOwnersByProviderId, extraProviderId, service) + for (const service of oauthServices) { + if (service.authType === 'oauth') { + addOwner(oauthOwnersByProviderId, service.providerId, service) + // A second authorization server for the same service (`salesforce-sandbox`) + // issues ordinary OAuth credentials, so they own visibility exactly like + // the primary provider's do. + for (const extraProviderId of service.additionalProviderIds ?? []) { + addOwner(oauthOwnersByProviderId, extraProviderId, service) + } } - if (service.serviceAccountProviderId) { - addOwner(serviceAccountOwnersByProviderId, service.serviceAccountProviderId, service) + const serviceAccountProviderId = + service.serviceAccountProviderId ?? + (service.authType === 'service_account' ? service.providerId : undefined) + if (serviceAccountProviderId) { + addOwner(serviceAccountOwnersByProviderId, serviceAccountProviderId, service) } } diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 5a40d039ee0..6665ac74a79 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1107, - zodRoutes: 1107, + totalRoutes: 1108, + zodRoutes: 1108, nonZodRoutes: 0, } as const diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 191fb41000a..2fb9c71e064 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -37,7 +37,7 @@ const EXPECTED_OPERATION_COUNTS = new Map<string, number>([ ['apps/docs/openapi-v2-tables.json', 44], ['apps/docs/openapi-v2-knowledge.json', 21], ['apps/docs/openapi-v2-billing.json', 2], - ['apps/docs/openapi-v2-resources.json', 24], + ['apps/docs/openapi-v2-resources.json', 26], ]) function getOperation(spec: JsonObject, path: string, method: string): JsonObject { @@ -169,7 +169,7 @@ describe('generated OpenAPI documents', () => { }) } } - expect(totalOperations).toBe(137) + expect(totalOperations).toBe(139) }) it('documents mixed workflow execution and resume responses', () => { From 7fcf26fca5be1d0c6c7c519af90e0aad4d5fd8f8 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Thu, 13 Aug 2026 13:41:06 -0700 Subject: [PATCH 126/159] fix(credentials): make disconnect idempotent --- .../__tests__/webhook-deactivation.test.ts | 7 ++-- .../application/service-account.test.ts | 14 +++++++- .../application/service-account.ts | 35 +++++++++++-------- apps/sim/lib/credentials/deletion.ts | 17 +++++---- 4 files changed, 47 insertions(+), 26 deletions(-) diff --git a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts index cdb6849481f..2fc567d9205 100644 --- a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts +++ b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts @@ -53,18 +53,19 @@ describe('deleteConnectionCredential', () => { it('deletes exactly one credential within its canonical workspace scope', async () => { dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }]) - await deleteConnectionCredential({ + const deleted = await deleteConnectionCredential({ credentialId: 'credential-1', workspaceId: 'workspace-1', reason: 'user_delete', }) + expect(deleted).toBe(true) expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.credential) expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.id, 'credential-1') expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.workspaceId, 'workspace-1') }) - it('fails fast if the authorized credential disappears before deletion commits', async () => { + it('returns an idempotent no-op if a concurrent disconnect wins the delete', async () => { dbChainMockFns.returning.mockResolvedValueOnce([]) await expect( @@ -73,6 +74,6 @@ describe('deleteConnectionCredential', () => { workspaceId: 'workspace-1', reason: 'user_delete', }) - ).rejects.toThrow('Credential disappeared during deletion') + ).resolves.toBe(false) }) }) diff --git a/apps/sim/lib/credentials/application/service-account.test.ts b/apps/sim/lib/credentials/application/service-account.test.ts index 37a384a6752..9da01986a6d 100644 --- a/apps/sim/lib/credentials/application/service-account.test.ts +++ b/apps/sim/lib/credentials/application/service-account.test.ts @@ -92,6 +92,7 @@ describe('credential service-account application operations', () => { auditMetadata: { tenantId: 'tenant-1' }, }) mocks.listCatalog.mockResolvedValue([{ providerId: 'zoom-service-account' }]) + mocks.delete.mockResolvedValue(true) mocks.requireProvider.mockReturnValue({ type: 'service_account', providerId: 'zoom-service-account', @@ -199,11 +200,22 @@ describe('credential service-account application operations', () => { input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, }) - expect(result).toEqual({ credential }) + expect(result).toEqual({ credential, deleted: true }) expect(mocks.delete).toHaveBeenCalledWith({ credentialId: credential.id, workspaceId: WORKSPACE_ID, reason: 'user_delete', }) }) + + it('treats a concurrent disconnect as an idempotent success', async () => { + mocks.delete.mockResolvedValue(false) + + const result = await deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + + expect(result).toEqual({ credential, deleted: false }) + }) }) diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts index ac7e766cc47..5954a910b2e 100644 --- a/apps/sim/lib/credentials/application/service-account.ts +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -141,6 +141,7 @@ export interface DeleteCredentialInput { export interface DeleteCredentialResult { credential: CredentialRow + deleted: boolean } async function resolveCredentialContext( @@ -176,27 +177,31 @@ export const deleteCredentialUseCase = defineAuthorizedWorkspaceUseCase({ ) } - await deleteConnectionCredential({ + const deleted = await deleteConnectionCredential({ credentialId: input.credentialId, workspaceId: context.workspaceId, reason: 'user_delete', }) - return { credential: context.credential } + return { credential: context.credential, deleted } }, - projectAudit: ({ result }) => ({ - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: result.credential.id, - resourceName: result.credential.displayName, - description: `Deleted ${result.credential.type} credential "${result.credential.displayName}" (user_delete)`, - metadata: { - reason: 'user_delete', - credentialType: result.credential.type, - providerId: result.credential.providerId, - accountId: result.credential.accountId, - }, - }), + projectAudit: ({ result }) => + result.deleted + ? { + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Deleted ${result.credential.type} credential "${result.credential.displayName}" (user_delete)`, + metadata: { + reason: 'user_delete', + credentialType: result.credential.type, + providerId: result.credential.providerId, + accountId: result.credential.accountId, + }, + } + : [], afterSuccess: ({ principal, context, result }) => { + if (!result.deleted) return captureServerEvent( principalUserId(principal), 'credential_deleted', diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts index e42081e57fc..f16902ddf04 100644 --- a/apps/sim/lib/credentials/deletion.ts +++ b/apps/sim/lib/credentials/deletion.ts @@ -80,7 +80,7 @@ export async function deleteCredential(params: DeleteCredentialParams): Promise< /** Clears references and deletes one connection without surface audit attribution. */ export async function deleteConnectionCredential( params: DeleteConnectionCredentialParams -): Promise<void> { +): Promise<boolean> { const { credentialId, workspaceId } = params await clearCredentialRefs(credentialId, workspaceId) const deleted = await db @@ -89,13 +89,16 @@ export async function deleteConnectionCredential( and(eq(schema.credential.id, credentialId), eq(schema.credential.workspaceId, workspaceId)) ) .returning({ id: schema.credential.id }) - if (deleted.length !== 1) throw new Error('Credential disappeared during deletion') + if (deleted.length > 1) throw new Error('Credential deletion affected multiple rows') - logger.info('Deleted credential', { - credentialId, - workspaceId, - reason: params.reason, - }) + if (deleted.length === 1) { + logger.info('Deleted credential', { + credentialId, + workspaceId, + reason: params.reason, + }) + } + return deleted.length === 1 } /** From cf680e6550bcb0d510591ef204e07e11e59fe45f Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Thu, 13 Aug 2026 13:49:24 -0700 Subject: [PATCH 127/159] fix(credentials): stabilize oauth draft retries --- .../create-credential-connection.test.ts | 2 ++ .../create-credential-connection.ts | 1 + .../sim/lib/credentials/connect-draft.test.ts | 23 ++++++++++++++++++- apps/sim/lib/credentials/connect-draft.ts | 15 +++++++----- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/credentials/application/create-credential-connection.test.ts b/apps/sim/lib/credentials/application/create-credential-connection.test.ts index b934755f0e5..544edd2130b 100644 --- a/apps/sim/lib/credentials/application/create-credential-connection.test.ts +++ b/apps/sim/lib/credentials/application/create-credential-connection.test.ts @@ -97,6 +97,7 @@ describe('createCredentialConnection', () => { providerId: 'google-email', credentialId: undefined, displayName: 'Work Gmail', + displayNameDefinesIntent: true, }) expect(result).toEqual({ authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', @@ -123,6 +124,7 @@ describe('createCredentialConnection', () => { providerId: 'google-email', credentialId: 'credential-1', displayName: 'Existing Gmail', + displayNameDefinesIntent: false, }) }) }) diff --git a/apps/sim/lib/credentials/application/create-credential-connection.ts b/apps/sim/lib/credentials/application/create-credential-connection.ts index 513e2b21c85..4aaae28a61c 100644 --- a/apps/sim/lib/credentials/application/create-credential-connection.ts +++ b/apps/sim/lib/credentials/application/create-credential-connection.ts @@ -42,6 +42,7 @@ export const createCredentialConnection = defineAuthorizedWorkspaceUseCase({ providerId: target.providerId, credentialId: target.credentialId, displayName, + displayNameDefinesIntent: input.providerId !== undefined, }) const authorizationUrl = new URL('/api/auth/oauth2/authorize', getBaseUrl()) authorizationUrl.searchParams.set('draftId', draft.id) diff --git a/apps/sim/lib/credentials/connect-draft.test.ts b/apps/sim/lib/credentials/connect-draft.test.ts index 4394d86067b..539a7cac8c5 100644 --- a/apps/sim/lib/credentials/connect-draft.test.ts +++ b/apps/sim/lib/credentials/connect-draft.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGenerateId } = vi.hoisted(() => ({ @@ -28,6 +28,7 @@ describe('createConnectDraft', () => { workspaceId: 'workspace-1', providerId: 'google-email', displayName: 'Work Gmail', + displayNameDefinesIntent: true, }) expect(dbChainMockFns.values).toHaveBeenCalledWith( @@ -43,6 +44,26 @@ describe('createConnectDraft', () => { expect(result).toEqual({ id: 'active-draft-id', expiresAt }) }) + it('refreshes a reconnect target when its mutable display name changes', async () => { + const expiresAt = new Date('2026-08-13T20:15:00.000Z') + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'active-draft-id', expiresAt }]) + + await expect( + createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Renamed Gmail', + }) + ).resolves.toEqual({ id: 'active-draft-id', expiresAt }) + + expect(drizzleOrmMock.eq).not.toHaveBeenCalledWith( + schemaMock.pendingCredentialDraft.displayName, + 'Renamed Gmail' + ) + }) + it('fails fast when an active draft has a different connection intent', async () => { dbChainMockFns.returning.mockResolvedValueOnce([]) diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 23a7e4ca34a..6c2565e3d55 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -29,6 +29,8 @@ export async function createConnectDraft(params: { credentialId?: string /** Reconnect only: the credential's actual name, so audit records stay accurate. */ displayName?: string + /** Whether an explicitly requested name distinguishes this new-connection intent. */ + displayNameDefinesIntent?: boolean }): Promise<CreatedConnectDraft> { const { userId, workspaceId, providerId, credentialId } = params @@ -67,6 +69,12 @@ export async function createConnectDraft(params: { and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) ) const id = generateId() + const sameTarget = credentialId + ? eq(pendingCredentialDraft.credentialId, credentialId) + : isNull(pendingCredentialDraft.credentialId) + const sameIntent = params.displayNameDefinesIntent + ? and(sameTarget, eq(pendingCredentialDraft.displayName, displayName)) + : sameTarget const [draft] = await db .insert(pendingCredentialDraft) .values({ @@ -86,12 +94,7 @@ export async function createConnectDraft(params: { pendingCredentialDraft.workspaceId, ], set: { expiresAt, createdAt: now }, - setWhere: and( - eq(pendingCredentialDraft.displayName, displayName), - credentialId - ? eq(pendingCredentialDraft.credentialId, credentialId) - : isNull(pendingCredentialDraft.credentialId) - ), + setWhere: sameIntent, }) .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) From b8d4a95829d277ca021cfe05e146d0d6a5007463 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 10:53:48 -0700 Subject: [PATCH 128/159] fix(credentials): bind oauth callbacks to drafts --- .../app/api/auth/instagram/authorize/route.ts | 21 ++++- .../api/auth/oauth2/authorize/route.test.ts | 27 ++++-- .../app/api/auth/oauth2/authorize/route.ts | 32 +++---- .../auth/oauth2/callback/instagram/route.ts | 7 ++ .../api/auth/oauth2/shopify/store/route.ts | 3 + .../app/api/auth/shopify/authorize/route.ts | 20 ++++- .../app/api/auth/trello/authorize/route.ts | 17 +++- apps/sim/app/api/auth/trello/store/route.ts | 4 + .../lib/api/contracts/oauth-connections.ts | 14 +-- apps/sim/lib/auth/auth.ts | 13 ++- .../lib/credentials/draft-processor.test.ts | 86 +++++++++++++++++++ apps/sim/lib/credentials/draft-processor.ts | 37 +++++--- apps/sim/lib/credentials/oauth-draft-state.ts | 1 + 13 files changed, 237 insertions(+), 45 deletions(-) create mode 100644 apps/sim/lib/credentials/draft-processor.test.ts create mode 100644 apps/sim/lib/credentials/oauth-draft-state.ts diff --git a/apps/sim/app/api/auth/instagram/authorize/route.ts b/apps/sim/app/api/auth/instagram/authorize/route.ts index b33a0c0c510..7d2c0270796 100644 --- a/apps/sim/app/api/auth/instagram/authorize/route.ts +++ b/apps/sim/app/api/auth/instagram/authorize/route.ts @@ -18,6 +18,7 @@ export const dynamic = 'force-dynamic' const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state' const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url' +const INSTAGRAM_CREDENTIAL_DRAFT_COOKIE = 'instagram_credential_draft_id' const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' const INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10 @@ -34,18 +35,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(authorizeInstagramContract, request, {}) if (!parsed.success) return parsed.response - const { returnUrl, workspaceId } = parsed.data.query + const { returnUrl, workspaceId, draftId } = parsed.data.query + let credentialDraftId = draftId if (workspaceId) { const access = await checkWorkspaceAccess(workspaceId, session.user.id) if (!access.canWrite) { return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 }) } - await createConnectDraft({ + const draft = await createConnectDraft({ userId: session.user.id, workspaceId, providerId: 'instagram', }) + credentialDraftId = draft.id } const baseUrl = getBaseUrl() @@ -68,6 +71,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, path: INSTAGRAM_STATE_COOKIE_PATH, }) + if (credentialDraftId) { + response.cookies.set(INSTAGRAM_CREDENTIAL_DRAFT_COOKIE, credentialDraftId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) + } else { + response.cookies.delete({ + name: INSTAGRAM_CREDENTIAL_DRAFT_COOKIE, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) + } if (returnUrl && isSameOrigin(returnUrl)) { response.cookies.set(INSTAGRAM_RETURN_URL_COOKIE, returnUrl, { diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index c9bb3e8e9dd..a39d6f8f0a2 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -165,7 +165,7 @@ describe('OAuth2 authorize route', () => { expect.objectContaining({ body: { providerId: 'google-email', - callbackURL: `${BASE_URL}/oauth/credential-connected?result=connected`, + callbackURL: `${BASE_URL}/oauth/credential-connected?result=connected&credentialDraftId=draft-1`, errorCallbackURL: `${BASE_URL}/oauth/credential-connected?result=failed`, }, }) @@ -191,7 +191,7 @@ describe('OAuth2 authorize route', () => { const response = await GET(authorizeRequest({ draftId: 'draft-1' })) expect(response.headers.get('location')).toBe( - `${BASE_URL}/api/auth/trello/authorize?returnUrl=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected%3Fresult%3Dconnected` + `${BASE_URL}/api/auth/trello/authorize?returnUrl=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected%3Fresult%3Dconnected&draftId=draft-1` ) expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() }) @@ -216,6 +216,13 @@ describe('OAuth2 authorize route', () => { expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( expect.objectContaining({ setWhere: expect.anything() }) ) + expect(mockOAuth2LinkAccount).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + callbackURL: `${BASE_URL}/workspace?credentialDraftId=draft-1`, + }), + }) + ) }) it('numbers the draft display name when the default collides with an existing credential', async () => { @@ -325,18 +332,26 @@ describe('OAuth2 authorize route', () => { ) }) - it('rejects reconnect for custom-flow providers (trello/shopify) and writes no draft', async () => { + it('binds custom-provider reconnects to the exact draft', async () => { + setEnv({ + TRELLO_API_KEY: 'trello-key', + SHOPIFY_CLIENT_ID: 'shopify-client', + SHOPIFY_CLIENT_SECRET: 'shopify-secret', + }) for (const providerId of ['trello', 'shopify']) { + mockGetCredentialActorContext.mockResolvedValue( + oauthCredentialActor({ credential: { providerId } }) + ) const response = await GET( authorizeRequest({ providerId, workspaceId: WORKSPACE_ID, credentialId: CREDENTIAL_ID }) ) expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_reconnect_unsupported` + `${BASE_URL}/api/auth/${providerId}/authorize?returnUrl=https%3A%2F%2Fsim.test%2Fworkspace&draftId=draft-1` ) } - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - expect(dbChainMockFns.values).not.toHaveBeenCalled() + expect(mockGetCredentialActorContext).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.values).toHaveBeenCalledTimes(2) expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 49d8d8c7fcf..98543d53099 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -10,6 +10,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getCredentialActorContext } from '@/lib/credentials/access' import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/oauth-draft-state' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('OAuth2Authorize') @@ -36,6 +37,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { let { providerId, workspaceId, callbackURL: requestedCallback, credentialId } = parsed.data.query let fromConnectionDraft = false + let connectionDraftId: string | undefined if (draftId) { try { const sessionId = session.session?.id @@ -52,6 +54,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { providerId = draft.providerId workspaceId = draft.workspaceId credentialId = draft.credentialId ?? undefined + connectionDraftId = draft.id fromConnectionDraft = true } catch (error) { if (!(error instanceof OrchestrationError)) throw error @@ -86,22 +89,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } if (credentialId) { - // Trello and Shopify authorize through their own custom flows that bypass - // this endpoint, so a reconnect draft written here would linger unconsumed - // and could later be picked up by their token-store callbacks, silently - // rebinding the credential. Mirror the copilot tool and reject reconnect. - if (providerId === 'trello' || providerId === 'shopify') { - logger.warn('Reconnect not supported for custom-flow provider', { - userId, - workspaceId, - providerId, - credentialId, - }) - return NextResponse.redirect( - `${baseUrl}/workspace?error=credential_reconnect_unsupported` - ) - } - // Reconnect: the OAuth callback will rebind this credential to the fresh // account, so require the same credential-admin access as the draft POST // route — workspace write alone must not be enough to swap someone's tokens. @@ -139,25 +126,34 @@ export const GET = withRouteHandler(async (request: NextRequest) => { requireConfiguredOAuthClient(providerId) if (!draftId) { - await createConnectDraft({ + const draft = await createConnectDraft({ userId, workspaceId, providerId, credentialId, displayName: reconnectDisplayName, }) + connectionDraftId = draft.id + } + + if (!connectionDraftId) { + throw new Error('OAuth authorization is missing its credential draft id') } if (providerId === 'trello' || providerId === 'instagram' || providerId === 'shopify') { const authorizeUrl = new URL(`/api/auth/${providerId}/authorize`, baseUrl) authorizeUrl.searchParams.set('returnUrl', callbackURL) + authorizeUrl.searchParams.set('draftId', connectionDraftId) return NextResponse.redirect(authorizeUrl) } + const stateCallbackUrl = new URL(callbackURL) + stateCallbackUrl.searchParams.set(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, connectionDraftId) + const linkResponse = await auth.api.oAuth2LinkAccount({ body: { providerId, - callbackURL, + callbackURL: stateCallbackUrl.toString(), ...(fromConnectionDraft ? { errorCallbackURL: `${baseUrl}/oauth/credential-connected?result=failed` } : {}), diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts index 4aea1372f83..b5dfa269d49 100644 --- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -33,11 +33,16 @@ export const dynamic = 'force-dynamic' const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state' const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url' +const INSTAGRAM_CREDENTIAL_DRAFT_COOKIE = 'instagram_credential_draft_id' const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' function clearOAuthCookies(response: NextResponse) { response.cookies.delete({ name: INSTAGRAM_STATE_COOKIE, path: INSTAGRAM_STATE_COOKIE_PATH }) response.cookies.delete({ name: INSTAGRAM_RETURN_URL_COOKIE, path: INSTAGRAM_STATE_COOKIE_PATH }) + response.cookies.delete({ + name: INSTAGRAM_CREDENTIAL_DRAFT_COOKIE, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) return response } @@ -54,6 +59,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { code, state, error, error_reason, error_description } = parsed.data.query + const draftId = request.cookies.get(INSTAGRAM_CREDENTIAL_DRAFT_COOKIE)?.value if (error) { logger.warn('Instagram OAuth denied by user', { @@ -296,6 +302,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (persisted) { try { await processCredentialDraft({ + draftId, userId: session.user.id, providerId: 'instagram', accountId: persisted.id, diff --git a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts index 182989f917a..6ebe44de6c6 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -41,6 +41,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_missing_data`) } const { accessToken, shopDomain, scope, returnUrl } = parsedCookies.data + const draftId = request.cookies.get('shopify_credential_draft_id')?.value if (!shopifyShopDomainSchema.safeParse(shopDomain).success) { logger.error('Invalid shop domain format in cookie', { shopDomain }) @@ -121,6 +122,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (persisted) { try { await processCredentialDraft({ + draftId, userId: session.user.id, providerId: 'shopify', accountId: persisted.id, @@ -139,6 +141,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { response.cookies.delete('shopify_pending_shop') response.cookies.delete('shopify_pending_scope') response.cookies.delete('shopify_return_url') + response.cookies.delete('shopify_credential_draft_id') return response } catch (error) { diff --git a/apps/sim/app/api/auth/shopify/authorize/route.ts b/apps/sim/app/api/auth/shopify/authorize/route.ts index d2d3a5401e4..63475a0cf76 100644 --- a/apps/sim/app/api/auth/shopify/authorize/route.ts +++ b/apps/sim/app/api/auth/shopify/authorize/route.ts @@ -32,13 +32,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const query = shopifyAuthorizeQuerySchema.parse({ shop: request.nextUrl.searchParams.get('shop') || undefined, returnUrl: request.nextUrl.searchParams.get('returnUrl') || undefined, + draftId: request.nextUrl.searchParams.get('draftId') || undefined, }) - const { shop: shopDomain, returnUrl } = query + const { shop: shopDomain, returnUrl, draftId } = query if (!shopDomain) { const safeReturnUrl = returnUrl && isSameOrigin(returnUrl) ? encodeURIComponent(returnUrl) : '' const returnUrlJsLiteral = JSON.stringify(safeReturnUrl) + const draftIdJsLiteral = JSON.stringify(draftId ?? '') return new NextResponse( `<!DOCTYPE html> <html> @@ -127,6 +129,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { <script> const returnUrl = ${returnUrlJsLiteral}; + const draftId = ${draftIdJsLiteral}; function handleSubmit(e) { e.preventDefault(); let shop = document.getElementById('shop').value.trim().toLowerCase(); @@ -141,6 +144,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (returnUrl) { url += '&returnUrl=' + returnUrl; } + if (draftId) { + url += '&draftId=' + encodeURIComponent(draftId); + } window.location.href = url; } </script> @@ -205,6 +211,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { path: '/', }) + if (draftId) { + response.cookies.set('shopify_credential_draft_id', draftId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 10, + path: '/', + }) + } else { + response.cookies.delete('shopify_credential_draft_id') + } + if (returnUrl && isSameOrigin(returnUrl)) { response.cookies.set('shopify_return_url', returnUrl, { httpOnly: true, diff --git a/apps/sim/app/api/auth/trello/authorize/route.ts b/apps/sim/app/api/auth/trello/authorize/route.ts index b69c6caed2e..c24cd32d61d 100644 --- a/apps/sim/app/api/auth/trello/authorize/route.ts +++ b/apps/sim/app/api/auth/trello/authorize/route.ts @@ -16,6 +16,7 @@ export const dynamic = 'force-dynamic' const TRELLO_STATE_COOKIE = 'trello_oauth_state' const TRELLO_RETURN_URL_COOKIE = 'trello_return_url' +const TRELLO_CREDENTIAL_DRAFT_COOKIE = 'trello_credential_draft_id' const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello' const TRELLO_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10 @@ -28,7 +29,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(authorizeTrelloContract, request, {}) if (!parsed.success) return parsed.response - const { returnUrl: requestedReturnUrl } = parsed.data.query + const { returnUrl: requestedReturnUrl, draftId } = parsed.data.query const apiKey = env.TRELLO_API_KEY @@ -60,6 +61,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS, path: TRELLO_STATE_COOKIE_PATH, }) + if (draftId) { + response.cookies.set(TRELLO_CREDENTIAL_DRAFT_COOKIE, draftId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS, + path: TRELLO_STATE_COOKIE_PATH, + }) + } else { + response.cookies.delete({ + name: TRELLO_CREDENTIAL_DRAFT_COOKIE, + path: TRELLO_STATE_COOKIE_PATH, + }) + } if (requestedReturnUrl && isSameOrigin(requestedReturnUrl)) { response.cookies.set(TRELLO_RETURN_URL_COOKIE, requestedReturnUrl, { httpOnly: true, diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index 12233c934a4..aa99cc2cb98 100644 --- a/apps/sim/app/api/auth/trello/store/route.ts +++ b/apps/sim/app/api/auth/trello/store/route.ts @@ -18,11 +18,13 @@ export const dynamic = 'force-dynamic' const TRELLO_STATE_COOKIE = 'trello_oauth_state' const TRELLO_RETURN_URL_COOKIE = 'trello_return_url' +const TRELLO_CREDENTIAL_DRAFT_COOKIE = 'trello_credential_draft_id' const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello' function clearStateCookie(response: NextResponse) { response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: TRELLO_STATE_COOKIE_PATH }) response.cookies.delete({ name: TRELLO_RETURN_URL_COOKIE, path: TRELLO_STATE_COOKIE_PATH }) + response.cookies.delete({ name: TRELLO_CREDENTIAL_DRAFT_COOKIE, path: TRELLO_STATE_COOKIE_PATH }) return response } @@ -37,6 +39,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(storeTrelloTokenContract, request, {}) if (!parsed.success) return parsed.response const { token, state } = parsed.data.body + const draftId = request.cookies.get(TRELLO_CREDENTIAL_DRAFT_COOKIE)?.value const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value if (!cookieState || cookieState !== state) { @@ -138,6 +141,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (persisted) { try { await processCredentialDraft({ + draftId, userId: session.user.id, providerId: 'trello', accountId: persisted.id, diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index 3e184df791c..74f7afea2c9 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -47,12 +47,18 @@ export const trelloTokenBodySchema = z.object({ state: z.string().min(1, 'state is required'), }) +const oauthCredentialDraftIdSchema = z + .string() + .min(1, 'draftId is required') + .max(255, 'draftId must be at most 255 characters') + export const trelloAuthorizeQuerySchema = z.object({ returnUrl: z .string() .min(1, 'Return URL cannot be empty') .max(2048, 'Return URL is too long') .optional(), + draftId: oauthCredentialDraftIdSchema.optional(), }) const trelloCallbackQuerySchema = z @@ -129,6 +135,7 @@ export const oauthTokenPostContract = defineRouteContract({ export const shopifyAuthorizeQuerySchema = z.object({ shop: z.string().optional(), returnUrl: z.string().optional(), + draftId: oauthCredentialDraftIdSchema.optional(), }) export const shopifyCallbackQuerySchema = z.object({ @@ -218,6 +225,7 @@ export const instagramAuthorizeQuerySchema = z.object({ .max(MAX_OAUTH_RETURN_URL_LENGTH, 'Return URL is too long') .optional(), workspaceId: workspaceIdSchema.optional(), + draftId: oauthCredentialDraftIdSchema.optional(), }) export const authorizeInstagramContract = defineRouteContract({ @@ -264,11 +272,7 @@ export const instagramCallbackContract = defineRouteContract({ export const authorizeOAuth2QuerySchema = z .object({ - draftId: z - .string() - .min(1, 'draftId is required') - .max(255, 'draftId must be at most 255 characters') - .optional(), + draftId: oauthCredentialDraftIdSchema.optional(), providerId: z.string().min(1, 'providerId is required').optional(), workspaceId: workspaceIdSchema.optional(), callbackURL: z.string().min(1).optional(), diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index cdcbcd32903..fe4c0089a3c 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -7,7 +7,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type BetterAuthOptions, betterAuth, type User } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' -import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api' +import { APIError, createAuthMiddleware, getOAuthState, getSessionFromCtx } from 'better-auth/api' import { nextCookies } from 'better-auth/next-js' import { admin, @@ -97,6 +97,7 @@ import { import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/oauth-draft-state' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' @@ -524,7 +525,17 @@ export const auth = betterAuth({ } try { + const oauthState = await getOAuthState() + const rawCallbackUrl = oauthState?.callbackURL + if (rawCallbackUrl !== undefined && typeof rawCallbackUrl !== 'string') { + throw new Error('OAuth state callback URL must be a string') + } + const draftId = rawCallbackUrl + ? (new URL(rawCallbackUrl).searchParams.get(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM) ?? + undefined) + : undefined await processCredentialDraft({ + draftId, userId: account.userId, providerId: account.providerId, accountId: account.id, diff --git a/apps/sim/lib/credentials/draft-processor.test.ts b/apps/sim/lib/credentials/draft-processor.test.ts new file mode 100644 index 00000000000..6ed91fcdb73 --- /dev/null +++ b/apps/sim/lib/credentials/draft-processor.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { + dbChainMockFns, + drizzleOrmMock, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockHandleCreateCredentialFromDraft, mockHandleReconnectCredential } = vi.hoisted(() => ({ + mockHandleCreateCredentialFromDraft: vi.fn(), + mockHandleReconnectCredential: vi.fn(), +})) + +vi.mock('@/lib/credentials/draft-hooks', () => ({ + handleCreateCredentialFromDraft: mockHandleCreateCredentialFromDraft, + handleReconnectCredential: mockHandleReconnectCredential, +})) + +import { processCredentialDraft } from '@/lib/credentials/draft-processor' + +function credentialDraft(id: string, workspaceId: string) { + return { + id, + userId: 'user-1', + workspaceId, + providerId: 'google-email', + displayName: 'Work Gmail', + description: null, + credentialId: null, + expiresAt: new Date('2026-08-14T18:15:00.000Z'), + createdAt: new Date('2026-08-14T18:00:00.000Z'), + } +} + +describe('processCredentialDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('processes only the exact draft bound to the OAuth state', async () => { + const draft = credentialDraft('draft-2', 'workspace-2') + queueTableRows(schemaMock.pendingCredentialDraft, [draft]) + + await processCredentialDraft({ + draftId: 'draft-2', + userId: 'user-1', + providerId: 'google-email', + accountId: 'account-1', + }) + + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.pendingCredentialDraft.id, 'draft-2') + expect(mockHandleCreateCredentialFromDraft).toHaveBeenCalledWith({ + draft, + accountId: 'account-1', + providerId: 'google-email', + userId: 'user-1', + now: expect.any(Date), + }) + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.pendingCredentialDraft) + }) + + it('fails closed when a legacy callback has multiple active drafts', async () => { + queueTableRows(schemaMock.pendingCredentialDraft, [ + credentialDraft('draft-1', 'workspace-1'), + credentialDraft('draft-2', 'workspace-2'), + ]) + + await expect( + processCredentialDraft({ + userId: 'user-1', + providerId: 'google-email', + accountId: 'account-1', + }) + ).rejects.toThrow( + 'Cannot process an ambiguous OAuth credential draft for user user-1 and provider google-email' + ) + + expect(mockHandleCreateCredentialFromDraft).not.toHaveBeenCalled() + expect(mockHandleReconnectCredential).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/draft-processor.ts b/apps/sim/lib/credentials/draft-processor.ts index b7b9f5cd931..b7e418db28f 100644 --- a/apps/sim/lib/credentials/draft-processor.ts +++ b/apps/sim/lib/credentials/draft-processor.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, sql } from 'drizzle-orm' +import { and, desc, eq, sql } from 'drizzle-orm' import { handleCreateCredentialFromDraft, handleReconnectCredential, @@ -10,30 +10,45 @@ import { const logger = createLogger('CredentialDraftProcessor') interface ProcessCredentialDraftParams { + draftId?: string userId: string providerId: string accountId: string } /** - * Looks up a pending credential draft for the given user/provider and processes it. + * Looks up a pending credential draft and processes it. + * Draft-backed OAuth launches pass the exact id. Legacy callers without one are + * accepted only when the user/provider pair has a single active draft. * Creates a new credential or reconnects an existing one depending on the draft state. * Used by Better Auth's `account.create.after` hook and custom OAuth flows (Shopify, Trello). */ export async function processCredentialDraft(params: ProcessCredentialDraftParams): Promise<void> { - const { userId, providerId, accountId } = params + const { draftId, userId, providerId, accountId } = params - const [draft] = await db + const predicates = [ + eq(schema.pendingCredentialDraft.userId, userId), + eq(schema.pendingCredentialDraft.providerId, providerId), + sql`${schema.pendingCredentialDraft.expiresAt} > NOW()`, + ] + if (draftId) { + predicates.push(eq(schema.pendingCredentialDraft.id, draftId)) + } + + const drafts = await db .select() .from(schema.pendingCredentialDraft) - .where( - and( - eq(schema.pendingCredentialDraft.userId, userId), - eq(schema.pendingCredentialDraft.providerId, providerId), - sql`${schema.pendingCredentialDraft.expiresAt} > NOW()` - ) + .where(and(...predicates)) + .orderBy(desc(schema.pendingCredentialDraft.createdAt)) + .limit(draftId ? 1 : 2) + + if (!draftId && drafts.length > 1) { + throw new Error( + `Cannot process an ambiguous OAuth credential draft for user ${userId} and provider ${providerId}` ) - .limit(1) + } + + const [draft] = drafts if (!draft) return diff --git a/apps/sim/lib/credentials/oauth-draft-state.ts b/apps/sim/lib/credentials/oauth-draft-state.ts new file mode 100644 index 00000000000..3dd068a1bed --- /dev/null +++ b/apps/sim/lib/credentials/oauth-draft-state.ts @@ -0,0 +1 @@ +export const OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM = 'credentialDraftId' From 75d3327cbfd8d2d1ae4fd30564762e1287e73c2a Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 11:08:22 -0700 Subject: [PATCH 129/159] fix(credentials): fail closed on oauth completion --- .../auth/oauth2/callback/instagram/route.ts | 19 ++++++++----------- .../api/auth/oauth2/shopify/store/route.ts | 19 ++++++++----------- apps/sim/app/api/auth/trello/store/route.ts | 19 ++++++++----------- apps/sim/lib/auth/auth.ts | 6 ++++-- apps/sim/lib/credentials/draft-hooks.ts | 14 ++++---------- .../lib/credentials/draft-processor.test.ts | 19 +++++++++++++++++++ apps/sim/lib/credentials/draft-processor.ts | 9 ++++++++- 7 files changed, 59 insertions(+), 46 deletions(-) diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts index b5dfa269d49..19284a950fc 100644 --- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -299,18 +299,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ), })) - if (persisted) { - try { - await processCredentialDraft({ - draftId, - userId: session.user.id, - providerId: 'instagram', - accountId: persisted.id, - }) - } catch (draftError) { - logger.error('Failed to process credential draft for Instagram', { error: draftError }) - } + if (!persisted) { + throw new Error(`Instagram OAuth account ${igUserId} was not persisted`) } + await processCredentialDraft({ + draftId, + userId: session.user.id, + providerId: 'instagram', + accountId: persisted.id, + }) const returnUrlCookie = request.cookies.get(INSTAGRAM_RETURN_URL_COOKIE)?.value const redirectUrl = diff --git a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts index 6ebe44de6c6..ca58ab7c8f4 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -119,18 +119,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ), })) - if (persisted) { - try { - await processCredentialDraft({ - draftId, - userId: session.user.id, - providerId: 'shopify', - accountId: persisted.id, - }) - } catch (error) { - logger.error('Failed to process credential draft for Shopify', { error }) - } + if (!persisted) { + throw new Error(`Shopify OAuth account ${stableAccountId} was not persisted`) } + await processCredentialDraft({ + draftId, + userId: session.user.id, + providerId: 'shopify', + accountId: persisted.id, + }) const redirectUrl = returnUrl && isSameOrigin(returnUrl) ? returnUrl : `${baseUrl}/workspace` const finalUrl = new URL(redirectUrl) diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index aa99cc2cb98..22d9a04aefa 100644 --- a/apps/sim/app/api/auth/trello/store/route.ts +++ b/apps/sim/app/api/auth/trello/store/route.ts @@ -138,18 +138,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ), })) - if (persisted) { - try { - await processCredentialDraft({ - draftId, - userId: session.user.id, - providerId: 'trello', - accountId: persisted.id, - }) - } catch (error) { - logger.error('Failed to process credential draft for Trello', { error }) - } + if (!persisted) { + throw new Error(`Trello OAuth account ${trelloUser.id} was not persisted`) } + await processCredentialDraft({ + draftId, + userId: session.user.id, + providerId: 'trello', + accountId: persisted.id, + }) return clearStateCookie(NextResponse.json({ success: true })) } catch (error) { diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index fe4c0089a3c..dc4ba62ca70 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -524,18 +524,19 @@ export const auth = betterAuth({ } } + let credentialDraftId: string | undefined try { const oauthState = await getOAuthState() const rawCallbackUrl = oauthState?.callbackURL if (rawCallbackUrl !== undefined && typeof rawCallbackUrl !== 'string') { throw new Error('OAuth state callback URL must be a string') } - const draftId = rawCallbackUrl + credentialDraftId = rawCallbackUrl ? (new URL(rawCallbackUrl).searchParams.get(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM) ?? undefined) : undefined await processCredentialDraft({ - draftId, + draftId: credentialDraftId, userId: account.userId, providerId: account.providerId, accountId: account.id, @@ -546,6 +547,7 @@ export const auth = betterAuth({ providerId: account.providerId, error, }) + if (credentialDraftId) throw error } try { diff --git a/apps/sim/lib/credentials/draft-hooks.ts b/apps/sim/lib/credentials/draft-hooks.ts index e467f56609e..c1aed407dca 100644 --- a/apps/sim/lib/credentials/draft-hooks.ts +++ b/apps/sim/lib/credentials/draft-hooks.ts @@ -121,10 +121,7 @@ export async function handleReconnectCredential(params: { .limit(1) if (!existingCredential) { - logger.warn('Credential not found for reconnect, skipping', { - credentialId: draft.credentialId, - }) - return + throw new Error(`Cannot reconnect missing credential ${draft.credentialId}`) } const oldAccountId = existingCredential.accountId @@ -144,12 +141,9 @@ export async function handleReconnectCredential(params: { .limit(1) if (conflicting) { - logger.warn('New account already used by another credential, skipping reconnect', { - credentialId: draft.credentialId, - newAccountId, - conflictingCredentialId: conflicting.id, - }) - return + throw new Error( + `Cannot reconnect credential ${draft.credentialId}: account ${newAccountId} is already used by credential ${conflicting.id}` + ) } } diff --git a/apps/sim/lib/credentials/draft-processor.test.ts b/apps/sim/lib/credentials/draft-processor.test.ts index 6ed91fcdb73..9eb88bc1953 100644 --- a/apps/sim/lib/credentials/draft-processor.test.ts +++ b/apps/sim/lib/credentials/draft-processor.test.ts @@ -83,4 +83,23 @@ describe('processCredentialDraft', () => { expect(mockHandleCreateCredentialFromDraft).not.toHaveBeenCalled() expect(mockHandleReconnectCredential).not.toHaveBeenCalled() }) + + it('fails when an exact draft is missing or expired', async () => { + queueTableRows(schemaMock.pendingCredentialDraft, []) + + await expect( + processCredentialDraft({ + draftId: 'draft-missing', + userId: 'user-1', + providerId: 'google-email', + accountId: 'account-1', + }) + ).rejects.toThrow( + 'Cannot process missing or expired OAuth credential draft draft-missing for user user-1' + ) + + expect(mockHandleCreateCredentialFromDraft).not.toHaveBeenCalled() + expect(mockHandleReconnectCredential).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/credentials/draft-processor.ts b/apps/sim/lib/credentials/draft-processor.ts index b7e418db28f..fc3637845f0 100644 --- a/apps/sim/lib/credentials/draft-processor.ts +++ b/apps/sim/lib/credentials/draft-processor.ts @@ -50,7 +50,14 @@ export async function processCredentialDraft(params: ProcessCredentialDraftParam const [draft] = drafts - if (!draft) return + if (!draft) { + if (draftId) { + throw new Error( + `Cannot process missing or expired OAuth credential draft ${draftId} for user ${userId}` + ) + } + return + } const now = new Date() From c9a51aee395bffe51558cb25d27ade3011def217 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 11:33:36 -0700 Subject: [PATCH 130/159] fix(credentials): bind shopify completion to oauth state --- .../oauth2/callback/shopify/route.test.ts | 97 +++++++++++++++ .../api/auth/oauth2/callback/shopify/route.ts | 76 ++++++------ .../api/auth/oauth2/shopify/store/route.ts | 91 ++------------ .../app/api/auth/shopify/authorize/route.ts | 43 ++----- apps/sim/lib/oauth/shopify-state.test.ts | 80 ++++++++++++ apps/sim/lib/oauth/shopify-state.ts | 102 ++++++++++++++++ apps/sim/lib/oauth/shopify.ts | 114 ++++++++++++++++++ 7 files changed, 453 insertions(+), 150 deletions(-) create mode 100644 apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts create mode 100644 apps/sim/lib/oauth/shopify-state.test.ts create mode 100644 apps/sim/lib/oauth/shopify-state.ts create mode 100644 apps/sim/lib/oauth/shopify.ts diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts new file mode 100644 index 00000000000..664a50bc714 --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node + */ +import { hmacSha256Hex } from '@sim/security/hmac' +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCompleteShopifyOAuthConnection, mockGetSession, mockRequireConfiguredOAuthClient } = + vi.hoisted(() => ({ + mockCompleteShopifyOAuthConnection: vi.fn(), + mockGetSession: vi.fn(), + mockRequireConfiguredOAuthClient: vi.fn(), + })) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/core/config/env-capabilities.server', () => ({ + requireConfiguredOAuthClient: mockRequireConfiguredOAuthClient, +})) +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' })) +vi.mock('@/lib/oauth/shopify', () => ({ + completeShopifyOAuthConnection: mockCompleteShopifyOAuthConnection, +})) + +import { createShopifyOAuthState } from '@/lib/oauth/shopify-state' +import { GET } from '@/app/api/auth/oauth2/callback/shopify/route' + +const CLIENT_SECRET = 'shopify-client-secret' +const SHOP_DOMAIN = 'example.myshopify.com' + +function callbackRequest(state: string) { + const searchParams = new URLSearchParams({ + code: 'authorization-code', + shop: SHOP_DOMAIN, + state, + }) + const message = [...searchParams.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join('&') + searchParams.set('hmac', hmacSha256Hex(message, CLIENT_SECRET)) + + return createMockRequest( + 'GET', + undefined, + { + cookie: + 'shopify_credential_draft_id=draft-from-shared-cookie; shopify_return_url=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected%3Fresult%3Dconnected', + }, + `https://sim.test/api/auth/oauth2/callback/shopify?${searchParams.toString()}` + ) +} + +describe('Shopify OAuth callback', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockRequireConfiguredOAuthClient.mockReturnValue({ + values: { + SHOPIFY_CLIENT_ID: 'shopify-client-id', + SHOPIFY_CLIENT_SECRET: CLIENT_SECRET, + }, + }) + mockCompleteShopifyOAuthConnection.mockResolvedValue(undefined) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ access_token: 'shopify-token', scope: 'read_products' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) + }) + + it('completes the credential draft carried by signed state instead of a shared cookie', async () => { + const state = createShopifyOAuthState({ + userId: 'user-1', + shopDomain: SHOP_DOMAIN, + draftId: 'draft-from-state', + clientSecret: CLIENT_SECRET, + }) + + const response = await GET(callbackRequest(state)) + + expect(mockCompleteShopifyOAuthConnection).toHaveBeenCalledWith({ + accessToken: 'shopify-token', + shopDomain: SHOP_DOMAIN, + scope: 'read_products', + userId: 'user-1', + draftId: 'draft-from-state', + signal: expect.any(AbortSignal), + }) + expect(response.headers.get('location')).toBe( + 'https://sim.test/oauth/credential-connected?result=connected&shopify_connected=true' + ) + }) +}) diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts index 2292a76a9a6..bf2907856d2 100644 --- a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts @@ -10,12 +10,26 @@ import { getSession } from '@/lib/auth' import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' import { getBaseUrl } from '@/lib/core/utils/urls' +import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { completeShopifyOAuthConnection } from '@/lib/oauth/shopify' +import { parseShopifyOAuthState } from '@/lib/oauth/shopify-state' const logger = createLogger('ShopifyCallback') export const dynamic = 'force-dynamic' +function clearShopifyOAuthCookies(response: NextResponse): NextResponse { + response.cookies.delete('shopify_oauth_state') + response.cookies.delete('shopify_shop_domain') + response.cookies.delete('shopify_credential_draft_id') + response.cookies.delete('shopify_pending_token') + response.cookies.delete('shopify_pending_shop') + response.cookies.delete('shopify_pending_scope') + response.cookies.delete('shopify_return_url') + return response +} + /** * Validates the HMAC signature from Shopify to ensure the request is authentic * @see https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens @@ -59,9 +73,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { shop: searchParams.get('shop') || undefined, }) - const storedState = request.cookies.get('shopify_oauth_state')?.value - const storedShop = request.cookies.get('shopify_shop_domain')?.value - const { values: { SHOPIFY_CLIENT_ID: clientId, SHOPIFY_CLIENT_SECRET: clientSecret }, } = requireConfiguredOAuthClient('shopify') @@ -71,8 +82,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_hmac_invalid`) } - if (!state || state !== storedState) { - logger.error('State mismatch in Shopify OAuth callback') + if (!state) { + logger.error('Missing state in Shopify OAuth callback') return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_state_mismatch`) } @@ -81,7 +92,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_code`) } - const shopDomain = shop || storedShop + const shopDomain = shop if (!shopDomain) { logger.error('No shop domain available') return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_shop`) @@ -92,6 +103,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_shop`) } + const { draftId } = parseShopifyOAuthState({ + state, + userId: session.user.id, + shopDomain, + clientSecret, + }) + const tokenResponse = await fetch(`https://${shopDomain}/admin/oauth/access_token`, { method: 'POST', headers: { @@ -127,44 +145,30 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_token`) } - const storeUrl = new URL(`${baseUrl}/api/auth/oauth2/shopify/store`) - - const response = NextResponse.redirect(storeUrl) - - response.cookies.set('shopify_pending_token', accessToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', - }) - - response.cookies.set('shopify_pending_shop', shopDomain, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', - }) - - response.cookies.set('shopify_pending_scope', scope || '', { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', + await completeShopifyOAuthConnection({ + accessToken, + shopDomain, + scope, + userId: session.user.id, + draftId, + signal: request.signal, }) - response.cookies.delete('shopify_oauth_state') - response.cookies.delete('shopify_shop_domain') + const returnUrlCookie = request.cookies.get('shopify_return_url')?.value + const redirectUrl = + returnUrlCookie && isSameOrigin(returnUrlCookie) ? returnUrlCookie : `${baseUrl}/workspace` + const finalUrl = new URL(redirectUrl) + finalUrl.searchParams.set('shopify_connected', 'true') - return response + return clearShopifyOAuthCookies(NextResponse.redirect(finalUrl)) } catch (error) { logger.error('Error in Shopify OAuth callback:', error) const errorCode = error instanceof EnvCapabilityConfigurationError && error.capabilityId === 'oauth' ? 'shopify_config_error' : 'shopify_callback_error' - return NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`) + return clearShopifyOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`) + ) } }) diff --git a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts index ca58ab7c8f4..d3c84d68883 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -1,7 +1,4 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { shopifyShopDomainSchema, @@ -11,9 +8,7 @@ import { getSession } from '@/lib/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { processCredentialDraft } from '@/lib/credentials/draft-processor' -import { safeAccountInsert } from '@/lib/oauth/credential-service' -import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' +import { completeShopifyOAuthConnection } from '@/lib/oauth/shopify' const logger = createLogger('ShopifyStore') @@ -48,85 +43,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_domain`) } - const shopResponse = await fetch( - `https://${shopDomain}/admin/api/${SHOPIFY_API_VERSION}/shop.json`, - { - headers: { - 'X-Shopify-Access-Token': accessToken, - 'Content-Type': 'application/json', - }, - } - ) - - if (!shopResponse.ok) { - const errorText = await shopResponse.text() - logger.error('Invalid Shopify token', { - status: shopResponse.status, - error: errorText, - }) - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_token`) - } - - const shopData = await shopResponse.json() - const shopInfo = shopData.shop - const stableAccountId = shopInfo.id?.toString() || shopDomain - - const existing = await db.query.account.findFirst({ - where: and( - eq(account.userId, session.user.id), - eq(account.providerId, 'shopify'), - eq(account.accountId, stableAccountId) - ), - }) - - const now = new Date() - - const accountData = { - accessToken: accessToken, - accountId: stableAccountId, - scope: scope || '', - updatedAt: now, - idToken: shopDomain, - } - - if (existing) { - await db.update(account).set(accountData).where(eq(account.id, existing.id)) - logger.info('Updated existing Shopify account', { accountId: existing.id }) - } else { - await safeAccountInsert( - { - id: `shopify_${session.user.id}_${Date.now()}`, - userId: session.user.id, - providerId: 'shopify', - accountId: accountData.accountId, - accessToken: accountData.accessToken, - scope: accountData.scope, - idToken: accountData.idToken, - createdAt: now, - updatedAt: now, - }, - { provider: 'Shopify', identifier: shopDomain } - ) - } - - const persisted = - existing ?? - (await db.query.account.findFirst({ - where: and( - eq(account.userId, session.user.id), - eq(account.providerId, 'shopify'), - eq(account.accountId, stableAccountId) - ), - })) - - if (!persisted) { - throw new Error(`Shopify OAuth account ${stableAccountId} was not persisted`) - } - await processCredentialDraft({ - draftId, + await completeShopifyOAuthConnection({ + accessToken, + shopDomain, + scope, userId: session.user.id, - providerId: 'shopify', - accountId: persisted.id, + draftId, + signal: request.signal, }) const redirectUrl = returnUrl && isSameOrigin(returnUrl) ? returnUrl : `${baseUrl}/workspace` diff --git a/apps/sim/app/api/auth/shopify/authorize/route.ts b/apps/sim/app/api/auth/shopify/authorize/route.ts index 63475a0cf76..9ef6909e581 100644 --- a/apps/sim/app/api/auth/shopify/authorize/route.ts +++ b/apps/sim/app/api/auth/shopify/authorize/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { shopifyAuthorizeQuerySchema, @@ -10,6 +9,7 @@ import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createShopifyOAuthState } from '@/lib/oauth/shopify-state' import { getScopesForService } from '@/lib/oauth/utils' const logger = createLogger('ShopifyAuthorize') @@ -26,7 +26,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const { - values: { SHOPIFY_CLIENT_ID: clientId }, + values: { SHOPIFY_CLIENT_ID: clientId, SHOPIFY_CLIENT_SECRET: clientSecret }, } = requireConfiguredOAuthClient('shopify') const query = shopifyAuthorizeQuerySchema.parse({ @@ -175,7 +175,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const baseUrl = getBaseUrl() const redirectUri = `${baseUrl}/api/auth/oauth2/callback/shopify` - const state = generateId() + const state = createShopifyOAuthState({ + userId: session.user.id, + shopDomain: cleanShop, + draftId, + clientSecret, + }) const oauthUrl = `https://${cleanShop}/admin/oauth/authorize?` + @@ -195,33 +200,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const response = NextResponse.redirect(oauthUrl) - response.cookies.set('shopify_oauth_state', state, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) - - response.cookies.set('shopify_shop_domain', cleanShop, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) - - if (draftId) { - response.cookies.set('shopify_credential_draft_id', draftId, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) - } else { - response.cookies.delete('shopify_credential_draft_id') - } + response.cookies.delete('shopify_oauth_state') + response.cookies.delete('shopify_shop_domain') + response.cookies.delete('shopify_credential_draft_id') if (returnUrl && isSameOrigin(returnUrl)) { response.cookies.set('shopify_return_url', returnUrl, { @@ -231,6 +212,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { maxAge: 60 * 10, path: '/', }) + } else { + response.cookies.delete('shopify_return_url') } return response diff --git a/apps/sim/lib/oauth/shopify-state.test.ts b/apps/sim/lib/oauth/shopify-state.test.ts new file mode 100644 index 00000000000..28a3a15595f --- /dev/null +++ b/apps/sim/lib/oauth/shopify-state.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createShopifyOAuthState, parseShopifyOAuthState } from '@/lib/oauth/shopify-state' + +const CLIENT_SECRET = 'shopify-client-secret' +const USER_ID = 'user-1' +const SHOP_DOMAIN = 'example.myshopify.com' + +function parse( + state: string, + overrides: { userId?: string; shopDomain?: string; now?: Date } = {} +) { + return parseShopifyOAuthState({ + state, + userId: overrides.userId ?? USER_ID, + shopDomain: overrides.shopDomain ?? SHOP_DOMAIN, + clientSecret: CLIENT_SECRET, + now: overrides.now, + }) +} + +describe('Shopify OAuth state', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps overlapping connection drafts bound to their own state', () => { + const first = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + draftId: 'draft-1', + clientSecret: CLIENT_SECRET, + }) + const second = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + draftId: 'draft-2', + clientSecret: CLIENT_SECRET, + }) + + expect(parse(first)).toEqual({ draftId: 'draft-1' }) + expect(parse(second)).toEqual({ draftId: 'draft-2' }) + }) + + it('rejects tampered, cross-user, and cross-shop state', () => { + const state = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + draftId: 'draft-1', + clientSecret: CLIENT_SECRET, + }) + const [payload, signature] = state.split('.') + + expect(() => parse(`${payload}x.${signature}`)).toThrow( + 'Shopify OAuth state signature is invalid' + ) + expect(() => parse(state, { userId: 'user-2' })).toThrow( + 'Shopify OAuth state belongs to a different user' + ) + expect(() => parse(state, { shopDomain: 'other.myshopify.com' })).toThrow( + 'Shopify OAuth state belongs to a different shop' + ) + }) + + it('rejects expired state', () => { + const issuedAt = new Date('2026-08-14T18:00:00.000Z') + vi.spyOn(Date, 'now').mockReturnValue(issuedAt.getTime()) + const state = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + clientSecret: CLIENT_SECRET, + }) + + expect(() => parse(state, { now: new Date(issuedAt.getTime() + 10 * 60 * 1000 + 1) })).toThrow( + 'Shopify OAuth state is expired' + ) + }) +}) diff --git a/apps/sim/lib/oauth/shopify-state.ts b/apps/sim/lib/oauth/shopify-state.ts new file mode 100644 index 00000000000..23d5f29d761 --- /dev/null +++ b/apps/sim/lib/oauth/shopify-state.ts @@ -0,0 +1,102 @@ +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' +import { generateId } from '@sim/utils/id' + +const SHOPIFY_OAUTH_STATE_VERSION = 1 +const SHOPIFY_OAUTH_STATE_TTL_MS = 10 * 60 * 1000 + +interface ShopifyOAuthStatePayload { + v: typeof SHOPIFY_OAUTH_STATE_VERSION + nonce: string + userId: string + shopDomain: string + draftId?: string + issuedAt: number +} + +interface CreateShopifyOAuthStateParams { + userId: string + shopDomain: string + draftId?: string + clientSecret: string +} + +interface ParseShopifyOAuthStateParams { + state: string + userId: string + shopDomain: string + clientSecret: string + now?: Date +} + +function isShopifyOAuthStatePayload(value: unknown): value is ShopifyOAuthStatePayload { + if (!value || typeof value !== 'object') return false + const payload = value as Record<string, unknown> + return ( + payload.v === SHOPIFY_OAUTH_STATE_VERSION && + typeof payload.nonce === 'string' && + payload.nonce.length > 0 && + typeof payload.userId === 'string' && + payload.userId.length > 0 && + typeof payload.shopDomain === 'string' && + payload.shopDomain.length > 0 && + (payload.draftId === undefined || + (typeof payload.draftId === 'string' && payload.draftId.length > 0)) && + typeof payload.issuedAt === 'number' && + Number.isSafeInteger(payload.issuedAt) + ) +} + +/** Creates a signed, user-bound Shopify state token carrying the exact credential draft. */ +export function createShopifyOAuthState(params: CreateShopifyOAuthStateParams): string { + const payload: ShopifyOAuthStatePayload = { + v: SHOPIFY_OAUTH_STATE_VERSION, + nonce: generateId(), + userId: params.userId, + shopDomain: params.shopDomain, + ...(params.draftId ? { draftId: params.draftId } : {}), + issuedAt: Date.now(), + } + const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') + const signature = hmacSha256Hex(encoded, params.clientSecret) + return `${encoded}.${signature}` +} + +/** Verifies Shopify state integrity, expiry, user ownership, and shop binding. */ +export function parseShopifyOAuthState(params: ParseShopifyOAuthStateParams): { + draftId?: string +} { + const [encoded, signature, extra] = params.state.split('.') + if (!encoded || !signature || extra !== undefined) { + throw new Error('Shopify OAuth state is malformed') + } + + const expectedSignature = hmacSha256Hex(encoded, params.clientSecret) + if (!safeCompare(signature, expectedSignature)) { + throw new Error('Shopify OAuth state signature is invalid') + } + + let decoded: unknown + try { + decoded = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) + } catch { + throw new Error('Shopify OAuth state payload is invalid') + } + if (!isShopifyOAuthStatePayload(decoded)) { + throw new Error('Shopify OAuth state payload is invalid') + } + + if (decoded.userId !== params.userId) { + throw new Error('Shopify OAuth state belongs to a different user') + } + if (decoded.shopDomain !== params.shopDomain) { + throw new Error('Shopify OAuth state belongs to a different shop') + } + + const now = params.now?.getTime() ?? Date.now() + if (decoded.issuedAt > now || now - decoded.issuedAt > SHOPIFY_OAUTH_STATE_TTL_MS) { + throw new Error('Shopify OAuth state is expired') + } + + return decoded.draftId ? { draftId: decoded.draftId } : {} +} diff --git a/apps/sim/lib/oauth/shopify.ts b/apps/sim/lib/oauth/shopify.ts new file mode 100644 index 00000000000..29a7299b874 --- /dev/null +++ b/apps/sim/lib/oauth/shopify.ts @@ -0,0 +1,114 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { safeAccountInsert } from '@/lib/oauth/credential-service' +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' + +const logger = createLogger('ShopifyOAuth') + +interface CompleteShopifyOAuthConnectionParams { + accessToken: string + shopDomain: string + scope?: string + userId: string + draftId?: string + signal?: AbortSignal +} + +function getShopifyAccountId(value: unknown): string { + if (!value || typeof value !== 'object') { + throw new Error('Shopify shop response must be an object') + } + const shop = (value as { shop?: unknown }).shop + if (!shop || typeof shop !== 'object') { + throw new Error('Shopify shop response is missing shop data') + } + const id = (shop as { id?: unknown }).id + if ((typeof id !== 'string' && typeof id !== 'number') || String(id).length === 0) { + throw new Error('Shopify shop response is missing its account id') + } + return String(id) +} + +/** Persists a verified Shopify account and completes its exact credential draft. */ +export async function completeShopifyOAuthConnection( + params: CompleteShopifyOAuthConnectionParams +): Promise<void> { + const shopResponse = await fetch( + `https://${params.shopDomain}/admin/api/${SHOPIFY_API_VERSION}/shop.json`, + { + headers: { + 'X-Shopify-Access-Token': params.accessToken, + 'Content-Type': 'application/json', + }, + signal: params.signal, + } + ) + + if (!shopResponse.ok) { + const errorText = await shopResponse.text() + throw new Error(`Shopify token validation failed (${shopResponse.status}): ${errorText}`) + } + + const stableAccountId = getShopifyAccountId(await shopResponse.json()) + const existing = await db.query.account.findFirst({ + where: and( + eq(account.userId, params.userId), + eq(account.providerId, 'shopify'), + eq(account.accountId, stableAccountId) + ), + }) + + const now = new Date() + const accountData = { + accessToken: params.accessToken, + accountId: stableAccountId, + scope: params.scope ?? '', + updatedAt: now, + idToken: params.shopDomain, + } + + if (existing) { + await db.update(account).set(accountData).where(eq(account.id, existing.id)) + logger.info('Updated existing Shopify account', { accountId: existing.id }) + } else { + await safeAccountInsert( + { + id: generateId(), + userId: params.userId, + providerId: 'shopify', + accountId: accountData.accountId, + accessToken: accountData.accessToken, + scope: accountData.scope, + idToken: accountData.idToken, + createdAt: now, + updatedAt: now, + }, + { provider: 'Shopify', identifier: params.shopDomain } + ) + } + + const persisted = + existing ?? + (await db.query.account.findFirst({ + where: and( + eq(account.userId, params.userId), + eq(account.providerId, 'shopify'), + eq(account.accountId, stableAccountId) + ), + })) + + if (!persisted) { + throw new Error(`Shopify OAuth account ${stableAccountId} was not persisted`) + } + + await processCredentialDraft({ + draftId: params.draftId, + userId: params.userId, + providerId: 'shopify', + accountId: persisted.id, + }) +} From ebfa60fc47396b7596e6e3d57e85aa6a74b83565 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 11:35:45 -0700 Subject: [PATCH 131/159] revert(chat): remove Sim Chat and mothership changes Reverts this branch's Sim Chat surface and its mothership-view edits. d4bdb87d04 ("feat(cli): add interactive Sim chat") could not be reverted: it is a 175-file commit that also introduced the ExecutionContext refactor and the v2 workspaces API, which 33 files under lib/copilot now depend on. Reverting it produced 42 content conflicts, 24 of them in the refactor rather than in chat code. Its chat contribution is removed forward instead. Reverted: b25e7ba0c3 fix(copilot): recover tool arguments lost when checkpointed d4c74648b0 feat(cli): add resumable async Sim Chat 3f0c1fcce0 feat(cli): add saved chat commands de263204fd feat(cli): refine chat and desktop updates -- mothership-view only; desktop updater, terminal themes, and bridge are kept Removed forward: the 17 CLI chat modules, /api/v2/chat, /api/v2/chats, the v2 chat contracts, and lib/copilot/headless. lib/copilot/chat/turn-persistence.ts is kept because the web chat's post.ts imports it. Left alone: upstream Copilot application-boundary work (#6450-#6462), the sim-chat billing source, and the v2 workspaces API. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNFjchn6dcM7xevh34PKHE --- apps/docs/openapi-core.json | 277 +- apps/sim/app/api/v2/chat/route.test.ts | 1865 ---------- apps/sim/app/api/v2/chat/route.ts | 755 ---- .../api/v2/chat/runs/[runId]/route.test.ts | 157 - .../sim/app/api/v2/chat/runs/[runId]/route.ts | 32 - apps/sim/app/api/v2/chat/runs/route.test.ts | 139 - apps/sim/app/api/v2/chat/runs/route.ts | 47 - .../app/api/v2/chats/[chatId]/route.test.ts | 372 -- apps/sim/app/api/v2/chats/[chatId]/route.ts | 158 - apps/sim/app/api/v2/chats/route.test.ts | 225 -- apps/sim/app/api/v2/chats/route.ts | 139 - .../terminal-session/terminal-session.tsx | 43 +- .../resource-content/resource-content.tsx | 28 +- .../lib/api/contracts/v2/chat-runs.test.ts | 81 - apps/sim/lib/api/contracts/v2/chat-runs.ts | 89 - apps/sim/lib/api/contracts/v2/chat.test.ts | 158 - apps/sim/lib/api/contracts/v2/chat.ts | 216 -- apps/sim/lib/api/contracts/v2/chats.test.ts | 101 - apps/sim/lib/api/contracts/v2/chats.ts | 108 - apps/sim/lib/api/list-query.test.ts | 8 - apps/sim/lib/api/list-query.ts | 10 - .../lib/copilot/chat/api/run-presenters.ts | 13 - .../copilot/chat/api/run-route-policy.test.ts | 51 - .../lib/copilot/chat/api/run-route-policy.ts | 40 - .../lib/copilot/chat/application/errors.ts | 6 - .../copilot/chat/application/operations.ts | 18 - .../lib/copilot/chat/application/runs.test.ts | 420 --- apps/sim/lib/copilot/chat/application/runs.ts | 212 -- .../lib/copilot/chat/public-activity.test.ts | 380 -- apps/sim/lib/copilot/chat/public-activity.ts | 606 ---- apps/sim/lib/copilot/chat/public-runs.test.ts | 123 - apps/sim/lib/copilot/chat/public-runs.ts | 138 - .../lib/copilot/headless/attachments.test.ts | 239 -- apps/sim/lib/copilot/headless/attachments.ts | 181 - .../headless/continuation-token.test.ts | 111 - .../copilot/headless/continuation-token.ts | 140 - .../copilot/headless/workspace-chat.test.ts | 544 --- .../lib/copilot/headless/workspace-chat.ts | 262 -- .../request/go/file-preview-adapter.ts | 10 +- .../sim/lib/copilot/request/go/stream.test.ts | 8 +- .../copilot/request/handlers/handlers.test.ts | 103 - apps/sim/lib/copilot/request/handlers/tool.ts | 5 - .../lib/copilot/request/lifecycle/run.test.ts | 63 - apps/sim/lib/copilot/request/lifecycle/run.ts | 33 +- .../sim/lib/copilot/request/tools/executor.ts | 29 - packages/sim-cli/README.md | 146 +- .../protocol/chat-attachment-tag.test.ts | 64 - .../protocol/chat-attachments.test.ts | 110 - .../src/commands/protocol/chat-attachments.ts | 393 -- .../commands/protocol/chat-markdown.test.ts | 99 - .../src/commands/protocol/chat-markdown.ts | 244 -- .../commands/protocol/chat-mentions.test.ts | 121 - .../src/commands/protocol/chat-paste.test.ts | 78 - .../protocol/chat-path-extraction.test.ts | 77 - .../commands/protocol/chat-structured.test.ts | 352 -- .../src/commands/protocol/chat-structured.ts | 748 ---- .../protocol/chat-suggestions.test.ts | 179 - .../src/commands/protocol/chat-suggestions.ts | 216 -- .../commands/protocol/chat-terminal.test.ts | 2150 ----------- .../src/commands/protocol/chat-terminal.ts | 2690 -------------- .../src/commands/protocol/chat-wrap.test.ts | 81 - .../src/commands/protocol/chat.test.ts | 3192 ----------------- .../sim-cli/src/commands/protocol/chat.ts | 1975 ---------- .../sim-cli/src/commands/protocol/index.ts | 3 - packages/sim-cli/src/contract/commands.ts | 55 - packages/sim-cli/src/contract/types.ts | 2 - packages/sim-cli/src/generated/v2-api.ts | 256 -- packages/sim-cli/src/http/client.test.ts | 3 - packages/sim-cli/src/index.ts | 5 +- packages/sim-cli/src/runtime/build.test.ts | 128 - packages/sim-cli/src/runtime/execute.ts | 9 +- scripts/check-api-validation-contracts.ts | 4 +- 72 files changed, 49 insertions(+), 22074 deletions(-) delete mode 100644 apps/sim/app/api/v2/chat/route.test.ts delete mode 100644 apps/sim/app/api/v2/chat/route.ts delete mode 100644 apps/sim/app/api/v2/chat/runs/[runId]/route.test.ts delete mode 100644 apps/sim/app/api/v2/chat/runs/[runId]/route.ts delete mode 100644 apps/sim/app/api/v2/chat/runs/route.test.ts delete mode 100644 apps/sim/app/api/v2/chat/runs/route.ts delete mode 100644 apps/sim/app/api/v2/chats/[chatId]/route.test.ts delete mode 100644 apps/sim/app/api/v2/chats/[chatId]/route.ts delete mode 100644 apps/sim/app/api/v2/chats/route.test.ts delete mode 100644 apps/sim/app/api/v2/chats/route.ts delete mode 100644 apps/sim/lib/api/contracts/v2/chat-runs.test.ts delete mode 100644 apps/sim/lib/api/contracts/v2/chat-runs.ts delete mode 100644 apps/sim/lib/api/contracts/v2/chat.test.ts delete mode 100644 apps/sim/lib/api/contracts/v2/chat.ts delete mode 100644 apps/sim/lib/api/contracts/v2/chats.test.ts delete mode 100644 apps/sim/lib/api/contracts/v2/chats.ts delete mode 100644 apps/sim/lib/copilot/chat/api/run-presenters.ts delete mode 100644 apps/sim/lib/copilot/chat/api/run-route-policy.test.ts delete mode 100644 apps/sim/lib/copilot/chat/api/run-route-policy.ts delete mode 100644 apps/sim/lib/copilot/chat/application/errors.ts delete mode 100644 apps/sim/lib/copilot/chat/application/operations.ts delete mode 100644 apps/sim/lib/copilot/chat/application/runs.test.ts delete mode 100644 apps/sim/lib/copilot/chat/application/runs.ts delete mode 100644 apps/sim/lib/copilot/chat/public-activity.test.ts delete mode 100644 apps/sim/lib/copilot/chat/public-activity.ts delete mode 100644 apps/sim/lib/copilot/chat/public-runs.test.ts delete mode 100644 apps/sim/lib/copilot/chat/public-runs.ts delete mode 100644 apps/sim/lib/copilot/headless/attachments.test.ts delete mode 100644 apps/sim/lib/copilot/headless/attachments.ts delete mode 100644 apps/sim/lib/copilot/headless/continuation-token.test.ts delete mode 100644 apps/sim/lib/copilot/headless/continuation-token.ts delete mode 100644 apps/sim/lib/copilot/headless/workspace-chat.test.ts delete mode 100644 apps/sim/lib/copilot/headless/workspace-chat.ts delete mode 100644 packages/sim-cli/src/commands/protocol/chat-attachment-tag.test.ts delete mode 100644 packages/sim-cli/src/commands/protocol/chat-attachments.test.ts delete mode 100644 packages/sim-cli/src/commands/protocol/chat-attachments.ts delete mode 100644 packages/sim-cli/src/commands/protocol/chat-markdown.test.ts delete mode 100644 packages/sim-cli/src/commands/protocol/chat-markdown.ts delete mode 100644 packages/sim-cli/src/commands/protocol/chat-mentions.test.ts delete mode 100644 packages/sim-cli/src/commands/protocol/chat-paste.test.ts delete mode 100644 packages/sim-cli/src/commands/protocol/chat-path-extraction.test.ts delete mode 100644 packages/sim-cli/src/commands/protocol/chat-structured.test.ts delete mode 100644 packages/sim-cli/src/commands/protocol/chat-structured.ts delete mode 100644 packages/sim-cli/src/commands/protocol/chat-suggestions.test.ts delete mode 100644 packages/sim-cli/src/commands/protocol/chat-suggestions.ts delete mode 100644 packages/sim-cli/src/commands/protocol/chat-terminal.test.ts delete mode 100644 packages/sim-cli/src/commands/protocol/chat-terminal.ts delete mode 100644 packages/sim-cli/src/commands/protocol/chat-wrap.test.ts delete mode 100644 packages/sim-cli/src/commands/protocol/chat.test.ts delete mode 100644 packages/sim-cli/src/commands/protocol/chat.ts diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index 8f9def2750a..dfc07313e3d 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -1434,7 +1434,7 @@ "post": { "operationId": "chat", "summary": "Ask Sim Chat", - "description": "Chat with the Mothership agent for a workspace. Personal API keys use their owner's current workspace permission, integrations, credentials, environment context, and memory, and their conversations are synchronized with the Sim Home chat history. Shared workspace keys retain normal workspace capabilities but do not inherit a human owner's personal integrations, secrets, environment, memory, or private chat history. Set `readOnly` to select the subtractive, secretless workspace-query policy. Omit `continuationToken` for a one-shot or first interactive turn, then send the latest opaque token returned by the stream to continue the same conversation. Tokens are bound to the workspace, authorization principal, credential type, and read-only mode, and expire on a rolling 24-hour window; this chat POST never accepts a raw chat ID. Set `async: true` with a personal API key to keep an accepted persisted turn running after disconnect; its first accepted `session` event includes a durable `runId` that can be polled through the chat-run endpoints. `runId` is omitted for ordinary synchronous turns. Set `persistChat: false` to suppress persistence of a newly created chat; that mode cannot be asynchronous. The response is a Server-Sent Events stream. `text` events contain incremental assistant output and `complete` contains the authoritative final result. Comment frames are heartbeats and `data: [DONE]` closes a successful stream. The caller's Sim API key selects and authorizes the local workspace but is never forwarded to Mothership. Workspace keys use the workspace billing account as their system actor while local tool authorization remains bound to the key owner; personal keys use their owner. Inline attachments are base64-only: paths and URLs are not accepted or resolved.", + "description": "Chat with the Mothership agent for a workspace. Personal API keys use their owner's current workspace permission, integrations, credentials, environment context, and memory, and their conversations are synchronized with the Sim Home chat history. Shared workspace keys retain normal workspace capabilities but do not inherit a human owner's personal integrations, secrets, environment, memory, or private chat history. Set `readOnly` to select the subtractive, secretless workspace-query policy. Omit `continuationToken` for a one-shot or first interactive turn, then send the latest opaque token returned by the stream to continue the same conversation. Tokens are bound to the workspace, authorization principal, credential type, and read-only mode, and expire on a rolling 24-hour window; this chat POST never accepts a raw chat ID. The response is a Server-Sent Events stream. `text` events contain incremental assistant output and `complete` contains the authoritative final result. Comment frames are heartbeats and `data: [DONE]` closes a successful stream. The caller's Sim API key selects and authorizes the local workspace but is never forwarded to Mothership. Workspace keys use the workspace billing account as their system actor while local tool authorization remains bound to the key owner; personal keys use their owner. Inline attachments are base64-only: paths and URLs are not accepted or resolved.", "tags": ["Chat"], "security": [ { @@ -1472,16 +1472,6 @@ "default": false, "description": "Use the secretless, read-only workspace-query policy. The default keeps normal workspace capabilities; shared workspace credentials still exclude personal integrations, secrets, environment, and persistent memory." }, - "async": { - "type": "boolean", - "default": false, - "description": "Keep an accepted persisted turn running after the caller disconnects and return its durable run ID in the session event. Requires a personal API key and `persistChat: true`; poll the chat-run endpoints for progress." - }, - "persistChat": { - "type": "boolean", - "default": true, - "description": "Allow a new conversation to be persisted in the workspace chat list. Setting this to false suppresses new-chat persistence and cannot be combined with `async: true`; an existing persisted chat remains persisted when continued." - }, "attachments": { "type": "array", "maxItems": 5, @@ -1647,7 +1637,6 @@ "example": { "workspaceId": "ws_abc123", "prompt": "Summarize the attached notes and compare them with this workspace.", - "async": true, "attachments": [ { "name": "notes.md", @@ -1679,7 +1668,7 @@ "content": { "text/event-stream": { "schema": { "type": "string" }, - "example": "data: {\"type\":\"session\",\"continuationToken\":\"sim-v2-chat-v1.opaque.refreshed\",\"requestId\":\"req_123\",\"chatId\":\"80a47295-040e-46f9-9ea8-ad78eff3bcab\",\"runId\":\"4bfa6f89-b746-43be-8246-bf1c69b58593\"}\n\ndata: {\"type\":\"text\",\"delta\":\"Two workflows...\"}\n\ndata: {\"type\":\"complete\",\"data\":{\"content\":\"Two workflows...\",\"continuationToken\":\"sim-v2-chat-v1.opaque.refreshed\",\"usage\":{\"prompt\":120,\"completion\":18,\"total\":138}}}\n\ndata: [DONE]\n\n" + "example": "data: {\"type\":\"session\",\"continuationToken\":\"sim-v2-chat-v1.opaque.refreshed\",\"requestId\":\"req_123\",\"chatId\":\"80a47295-040e-46f9-9ea8-ad78eff3bcab\"}\n\ndata: {\"type\":\"text\",\"delta\":\"Two workflows...\"}\n\ndata: {\"type\":\"complete\",\"data\":{\"content\":\"Two workflows...\",\"continuationToken\":\"sim-v2-chat-v1.opaque.refreshed\",\"usage\":{\"prompt\":120,\"completion\":18,\"total\":138}}}\n\ndata: [DONE]\n\n" } } }, @@ -1716,186 +1705,6 @@ } } }, - "/api/v2/chat/runs": { - "get": { - "operationId": "listChatRuns", - "summary": "List Sim Chat Runs", - "description": "List a bounded page of the authenticated user's root Mothership runs for one workspace, newest first. This private history surface requires a personal API key. It returns durable run state and safe chat metadata only; stream IDs, continuation tokens, model reasoning, tool payloads, and errors are never exposed. Pass `nextCursor` back as `cursor` to load another page.", - "tags": ["Chat"], - "security": [ - { - "apiKey": [] - } - ], - "parameters": [ - { - "name": "workspaceId", - "in": "query", - "required": true, - "schema": { "type": "string" }, - "description": "Workspace whose owned Sim Chat runs should be listed." - }, - { - "name": "status", - "in": "query", - "required": false, - "schema": { - "type": "string", - "enum": [ - "active", - "paused_waiting_for_tool", - "resuming", - "complete", - "error", - "cancelled" - ] - }, - "description": "Return only runs with this durable status." - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 30 }, - "description": "Maximum runs to return." - }, - { - "name": "cursor", - "in": "query", - "required": false, - "schema": { "type": "string", "minLength": 1 }, - "description": "Opaque cursor returned by the previous page." - } - ], - "responses": { - "200": { - "description": "A bounded page of safe chat run summaries.", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["data", "nextCursor"], - "properties": { - "data": { - "type": "array", - "items": { "$ref": "#/components/schemas/V2ChatRunSummary" } - }, - "nextCursor": { "type": ["string", "null"] } - } - }, - "example": { - "data": [ - { - "runId": "4bfa6f89-b746-43be-8246-bf1c69b58593", - "chatId": "80a47295-040e-46f9-9ea8-ad78eff3bcab", - "chatTitle": "Review release workflow", - "status": "complete", - "startedAt": "2026-08-08T18:29:00.000Z", - "completedAt": "2026-08-08T18:30:00.000Z" - } - ], - "nextCursor": null - } - } - } - }, - "400": { - "$ref": "#/components/responses/V2BadRequest" - }, - "401": { - "$ref": "#/components/responses/V2Unauthorized" - }, - "403": { - "$ref": "#/components/responses/V2Forbidden" - }, - "429": { - "$ref": "#/components/responses/V2RateLimited" - } - } - } - }, - "/api/v2/chat/runs/{runId}": { - "get": { - "operationId": "getChatRun", - "summary": "Get Sim Chat Run", - "description": "Poll one owned root Mothership run. In addition to durable status and chat metadata, the response contains accumulated root-assistant text and chronological display-safe activity updates when complete replay is available. A terminal run falls back to its persisted assistant response after replay expires. Raw argument/result objects, model reasoning, upstream errors, stream IDs, and continuation tokens are never returned; activity labels may summarize the same user-visible target or operation shown in Sim Home. Runs outside the user, workspace, live Mothership chat, or root-run scope all return the same 404.", - "tags": ["Chat"], - "security": [ - { - "apiKey": [] - } - ], - "parameters": [ - { - "name": "runId", - "in": "path", - "required": true, - "schema": { "type": "string", "format": "uuid" }, - "description": "Run ID returned by Ask Sim Chat or List Sim Chat Runs." - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "schema": { "type": "string" }, - "description": "Workspace the run and its chat must belong to." - } - ], - "responses": { - "200": { - "description": "A safe snapshot of the chat run.", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["data"], - "properties": { - "data": { "$ref": "#/components/schemas/V2ChatRunDetail" } - } - }, - "example": { - "data": { - "runId": "4bfa6f89-b746-43be-8246-bf1c69b58593", - "chatId": "80a47295-040e-46f9-9ea8-ad78eff3bcab", - "chatTitle": "Review release workflow", - "status": "active", - "startedAt": "2026-08-08T18:29:00.000Z", - "completedAt": null, - "response": "I reviewed the release workflow.", - "activities": [ - { - "kind": "tool", - "id": "tool-1", - "label": "Read workflow", - "state": "complete" - } - ] - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/V2BadRequest" - }, - "401": { - "$ref": "#/components/responses/V2Unauthorized" - }, - "403": { - "$ref": "#/components/responses/V2Forbidden" - }, - "404": { - "$ref": "#/components/responses/V2NotFound" - }, - "429": { - "$ref": "#/components/responses/V2RateLimited" - }, - "503": { - "$ref": "#/components/responses/V2ServiceUnavailable" - } - } - } - }, "/api/v2/billing/status": { "get": { "operationId": "getBillingStatus", @@ -2929,88 +2738,6 @@ } } }, - "V2ChatRunSummary": { - "type": "object", - "required": ["runId", "chatId", "chatTitle", "status", "startedAt", "completedAt"], - "properties": { - "runId": { "type": "string", "format": "uuid" }, - "chatId": { "type": "string", "format": "uuid" }, - "chatTitle": { "type": ["string", "null"] }, - "status": { - "type": "string", - "enum": [ - "active", - "paused_waiting_for_tool", - "resuming", - "complete", - "error", - "cancelled" - ] - }, - "startedAt": { "type": "string", "format": "date-time" }, - "completedAt": { "type": ["string", "null"], "format": "date-time" } - } - }, - "V2ChatRunActivity": { - "oneOf": [ - { - "type": "object", - "required": ["kind", "id", "label", "state"], - "properties": { - "kind": { "type": "string", "enum": ["subagent", "tool"] }, - "id": { "type": "string" }, - "parentId": { "type": "string" }, - "label": { "type": "string" }, - "state": { "type": "string", "enum": ["running", "complete", "error"] } - } - }, - { - "type": "object", - "required": ["kind", "parentId", "delta"], - "properties": { - "kind": { "type": "string", "const": "narration" }, - "parentId": { "type": "string" }, - "delta": { "type": "string" } - } - } - ] - }, - "V2ChatRunDetail": { - "type": "object", - "required": [ - "runId", - "chatId", - "chatTitle", - "status", - "startedAt", - "completedAt", - "response", - "activities" - ], - "properties": { - "runId": { "type": "string", "format": "uuid" }, - "chatId": { "type": "string", "format": "uuid" }, - "chatTitle": { "type": ["string", "null"] }, - "status": { - "type": "string", - "enum": [ - "active", - "paused_waiting_for_tool", - "resuming", - "complete", - "error", - "cancelled" - ] - }, - "startedAt": { "type": "string", "format": "date-time" }, - "completedAt": { "type": ["string", "null"], "format": "date-time" }, - "response": { "type": "string" }, - "activities": { - "type": "array", - "items": { "$ref": "#/components/schemas/V2ChatRunActivity" } - } - } - }, "V2Error": { "type": "object", "required": ["error"], diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts deleted file mode 100644 index b53ded5b476..00000000000 --- a/apps/sim/app/api/v2/chat/route.test.ts +++ /dev/null @@ -1,1865 +0,0 @@ -/** - * @vitest-environment node - */ -import { createMockRequest } from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockAcquirePendingChatStream, - mockCheckAttributedUsageLimits, - mockCheckRateLimit, - mockClearFilePreviewSessions, - mockCleanupAbortMarker, - mockCreateRunSegment, - mockEnv, - mockEnvFlags, - mockFinalizeStream, - mockFireTitleGeneration, - mockGenerateId, - mockGetAccessibleCopilotChatContinuationMetadata, - mockIssueV2ChatContinuationToken, - mockPersistCopilotUserMessage, - mockPrepareV2ChatAttachments, - mockPublishStatusChanged, - mockPublisherClose, - mockPublisherFlush, - mockPublisherPublish, - mockRegisterActiveStream, - mockReleasePendingChatStream, - mockResetBuffer, - mockResolveOrCreateChat, - mockRequestExplicitStreamAbort, - mockResolveBillingAttribution, - mockResolveSystemBillingAttribution, - mockResolveWorkspaceAccess, - mockRunWorkspaceChat, - mockScheduleBufferCleanup, - mockScheduleFilePreviewSessionCleanup, - mockStartAbortPoller, - mockStreamWriter, - mockTurnOnComplete, - mockTurnOnError, - mockUnregisterActiveStream, - mockVerifyV2ChatContinuationToken, - mockV2ApiGateError, -} = vi.hoisted(() => ({ - mockAcquirePendingChatStream: vi.fn(), - mockCheckAttributedUsageLimits: vi.fn(), - mockCheckRateLimit: vi.fn(), - mockClearFilePreviewSessions: vi.fn(), - mockCleanupAbortMarker: vi.fn(), - mockCreateRunSegment: vi.fn(), - mockEnv: { COPILOT_API_KEY: 'deployment-mothership-key' as string | undefined }, - mockEnvFlags: { isAuthDisabled: false }, - mockFinalizeStream: vi.fn(), - mockFireTitleGeneration: vi.fn(), - mockGenerateId: vi.fn(), - mockGetAccessibleCopilotChatContinuationMetadata: vi.fn(), - mockIssueV2ChatContinuationToken: vi.fn(), - mockPersistCopilotUserMessage: vi.fn(), - mockPrepareV2ChatAttachments: vi.fn(), - mockPublishStatusChanged: vi.fn(), - mockPublisherClose: vi.fn(), - mockPublisherFlush: vi.fn(), - mockPublisherPublish: vi.fn(), - mockRegisterActiveStream: vi.fn(), - mockReleasePendingChatStream: vi.fn(), - mockResetBuffer: vi.fn(), - mockResolveOrCreateChat: vi.fn(), - mockRequestExplicitStreamAbort: vi.fn(), - mockResolveBillingAttribution: vi.fn(), - mockResolveSystemBillingAttribution: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockRunWorkspaceChat: vi.fn(), - mockScheduleBufferCleanup: vi.fn(), - mockScheduleFilePreviewSessionCleanup: vi.fn(), - mockStartAbortPoller: vi.fn(), - mockStreamWriter: vi.fn(), - mockTurnOnComplete: vi.fn(), - mockTurnOnError: vi.fn(), - mockUnregisterActiveStream: vi.fn(), - mockVerifyV2ChatContinuationToken: vi.fn(), - mockV2ApiGateError: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: mockV2ApiGateError, -})) - -vi.mock('@/lib/billing/core/billing-attribution', () => ({ - checkAttributedUsageLimits: mockCheckAttributedUsageLimits, - resolveBillingAttribution: mockResolveBillingAttribution, - resolveSystemBillingAttribution: mockResolveSystemBillingAttribution, -})) - -vi.mock('@/lib/copilot/async-runs/repository', () => ({ - createRunSegment: mockCreateRunSegment, -})) - -vi.mock('@/lib/copilot/chat/lifecycle', () => ({ - getAccessibleCopilotChatContinuationMetadata: mockGetAccessibleCopilotChatContinuationMetadata, - resolveOrCreateChat: mockResolveOrCreateChat, -})) - -vi.mock('@/lib/copilot/chat/turn-persistence', () => ({ - buildCopilotTurnOnComplete: () => mockTurnOnComplete, - buildCopilotTurnOnError: () => mockTurnOnError, - persistCopilotUserMessage: mockPersistCopilotUserMessage, -})) - -vi.mock('@/lib/copilot/chat-status', () => ({ - chatPubSub: { publishStatusChanged: mockPublishStatusChanged }, -})) - -vi.mock('@/lib/copilot/headless/workspace-chat', () => ({ - runWorkspaceChat: mockRunWorkspaceChat, - publicChatUsageLimitMessage: (content: string) => { - const match = /^<usage_upgrade>(.+)<\/usage_upgrade>$/.exec(content) - if (!match) return null - return (JSON.parse(match[1]) as { message: string }).message - }, - toPublicChatResult: ( - result: { content: string; usage?: { prompt: number; completion: number } }, - continuationToken: string - ) => ({ - content: result.content, - continuationToken, - usage: result.usage - ? { - prompt: result.usage.prompt, - completion: result.usage.completion, - total: result.usage.prompt + result.usage.completion, - } - : {}, - }), -})) - -vi.mock('@/lib/copilot/headless/attachments', () => ({ - prepareV2ChatAttachments: mockPrepareV2ChatAttachments, -})) - -vi.mock('@/lib/copilot/headless/continuation-token', () => ({ - issueV2ChatContinuationToken: mockIssueV2ChatContinuationToken, - verifyV2ChatContinuationToken: mockVerifyV2ChatContinuationToken, -})) - -vi.mock('@/lib/copilot/request/session/explicit-abort', () => ({ - requestExplicitStreamAbort: mockRequestExplicitStreamAbort, -})) - -vi.mock('@/lib/copilot/request/lifecycle/finalize', () => ({ - finalizeStream: mockFinalizeStream, -})) - -vi.mock('@/lib/copilot/request/lifecycle/start', () => ({ - fireTitleGeneration: mockFireTitleGeneration, -})) - -vi.mock('@/lib/copilot/request/session', () => ({ - AbortReason: { UserStop: 'user_stop:abortActiveStream' }, - StreamWriter: mockStreamWriter, - acquirePendingChatStream: mockAcquirePendingChatStream, - clearFilePreviewSessions: mockClearFilePreviewSessions, - cleanupAbortMarker: mockCleanupAbortMarker, - encodeSSEComment: (comment: string) => new TextEncoder().encode(`: ${comment}\n\n`), - encodeSSEEnvelope: (value: unknown) => - new TextEncoder().encode(`data: ${JSON.stringify(value)}\n\n`), - registerActiveStream: mockRegisterActiveStream, - releasePendingChatStream: mockReleasePendingChatStream, - resetBuffer: mockResetBuffer, - scheduleBufferCleanup: mockScheduleBufferCleanup, - scheduleFilePreviewSessionCleanup: mockScheduleFilePreviewSessionCleanup, - SSE_RESPONSE_HEADERS: { 'Content-Type': 'text/event-stream' }, - startAbortPoller: mockStartAbortPoller, - unregisterActiveStream: mockUnregisterActiveStream, -})) - -vi.mock('@/lib/core/config/env', () => ({ env: mockEnv })) -vi.mock('@/lib/core/config/env-flags', () => mockEnvFlags) -vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) - -vi.mock('@/executor/utils/resolved-secret-trace-registry', () => ({ - ResolvedSecretTraceRegistry: class MockResolvedSecretTraceRegistry { - getModelEgressSnapshot() { - return { complete: true } - } - }, -})) - -vi.mock('@sim/utils/id', () => ({ generateId: mockGenerateId })) - -import { MAX_V2_CHAT_BODY_BYTES } from '@/lib/api/contracts/v2/chat' -import { POST } from '@/app/api/v2/chat/route' - -const RATE_LIMIT = { - allowed: true, - userId: 'key-owner-1', - keyType: 'personal' as const, - limit: 100, - remaining: 99, - resetAt: new Date('2026-08-05T12:00:00.000Z'), -} - -const personalAttribution = { - actorUserId: 'key-owner-1', - workspaceId: 'workspace-1', - billedAccountUserId: 'payer-1', - organizationId: null, - billingEntity: { type: 'user' as const, id: 'payer-1' }, - billingPeriod: { - start: '2026-08-01T00:00:00.000Z', - end: '2026-09-01T00:00:00.000Z', - }, - payerSubscription: null, -} - -const systemAttribution = { - ...personalAttribution, - actorUserId: 'workspace-billed-account', -} - -function callChat(body: Record<string, unknown>, headers: Record<string, string> = {}) { - return POST( - createMockRequest( - 'POST', - body, - { 'Content-Type': 'application/json', 'x-api-key': 'caller-platform-key', ...headers }, - 'http://localhost:3000/api/v2/chat' - ) - ) -} - -function parseSse(stream: string): Record<string, unknown>[] { - return stream - .split('\n') - .filter((line) => line.startsWith('data: ') && line !== 'data: [DONE]') - .map((line) => JSON.parse(line.slice('data: '.length)) as Record<string, unknown>) -} - -describe('POST /api/v2/chat', () => { - beforeEach(() => { - vi.clearAllMocks() - mockEnv.COPILOT_API_KEY = 'deployment-mothership-key' - mockEnvFlags.isAuthDisabled = false - mockGenerateId - .mockReset() - .mockReturnValueOnce('message-1') - .mockReturnValueOnce('execution-1') - .mockReturnValueOnce('run-1') - .mockReturnValue('generated-extra') - mockResolveOrCreateChat.mockResolvedValue({ - chatId: 'chat-1', - chat: { id: 'chat-1', type: 'mothership', title: null }, - conversationHistory: [], - isNew: true, - }) - mockStreamWriter.mockImplementation(function MockStreamWriter() { - return { - close: mockPublisherClose, - flush: mockPublisherFlush, - publish: mockPublisherPublish, - sawComplete: false, - } - }) - mockIssueV2ChatContinuationToken.mockReturnValue('continuation-new') - mockGetAccessibleCopilotChatContinuationMetadata.mockResolvedValue(null) - mockVerifyV2ChatContinuationToken.mockReturnValue({ valid: false }) - mockPrepareV2ChatAttachments.mockReturnValue({ success: true, attachments: [] }) - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) - mockV2ApiGateError.mockResolvedValue(null) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockResolveBillingAttribution.mockResolvedValue(personalAttribution) - mockResolveSystemBillingAttribution.mockResolvedValue(systemAttribution) - mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false }) - mockAcquirePendingChatStream.mockResolvedValue(true) - mockClearFilePreviewSessions.mockResolvedValue(undefined) - mockCleanupAbortMarker.mockResolvedValue(undefined) - mockCreateRunSegment.mockResolvedValue({ id: 'run-1' }) - mockFinalizeStream.mockResolvedValue(undefined) - mockPersistCopilotUserMessage.mockResolvedValue(undefined) - mockPublisherClose.mockResolvedValue(undefined) - mockPublisherFlush.mockResolvedValue(undefined) - mockReleasePendingChatStream.mockResolvedValue(undefined) - mockResetBuffer.mockResolvedValue(undefined) - mockRequestExplicitStreamAbort.mockResolvedValue(undefined) - mockScheduleBufferCleanup.mockResolvedValue(undefined) - mockScheduleFilePreviewSessionCleanup.mockResolvedValue(undefined) - mockStartAbortPoller.mockReturnValue(0) - mockRunWorkspaceChat.mockImplementation(async (input) => { - input.onInitialStreamAccepted?.() - await input.onEvent?.({ - type: 'text', - payload: { channel: 'assistant', text: 'Hello from Sim' }, - }) - return { - success: true, - content: 'Hello from Sim', - contentBlocks: [], - toolCalls: [], - usage: { prompt: 8, completion: 3 }, - } - }) - }) - - /** - * The one-off CLI turn (`sim chat ask`) is a command, not a conversation the - * workspace accumulates, so it must leave nothing for the chat list or - * `sim chats list` to surface — matching the Mothership block, which mints - * its own conversation id and never writes a chat row. The turn still gets a - * chat id and a continuation token, so the conversation remains continuable. - */ - it('creates no chat row when the caller opts out of persistence', async () => { - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: 'What is here?', - persistChat: false, - }) - const stream = await response.text() - - expect(response.status).toBe(200) - expect(mockResolveOrCreateChat).not.toHaveBeenCalled() - expect(mockPersistCopilotUserMessage).not.toHaveBeenCalled() - expect(stream).toContain('"type":"complete"') - // No Sim-side row, so the token must not claim Sim persistence. - expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( - expect.not.objectContaining({ persistence: 'sim' }) - ) - }) - - it('still persists the chat when the caller does not opt out', async () => { - await callChat({ workspaceId: 'workspace-1', prompt: 'What is here?' }) - expect(mockResolveOrCreateChat).toHaveBeenCalled() - }) - - it('requires persisted chat storage for asynchronous execution', async () => { - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: 'What is here?', - async: true, - persistChat: false, - }) - - expect(response.status).toBe(400) - expect(await response.json()).toEqual({ - error: { - code: 'BAD_REQUEST', - message: 'Asynchronous chat requires persistChat to be true', - }, - }) - expect(mockResolveOrCreateChat).not.toHaveBeenCalled() - expect(mockRunWorkspaceChat).not.toHaveBeenCalled() - }) - - it('streams a personal-key chat and bills its authenticated actor', async () => { - const response = await callChat({ workspaceId: 'workspace-1', prompt: 'What is here?' }) - const stream = await response.text() - - expect(response.status).toBe(200) - expect(response.headers.get('content-type')).toContain('text/event-stream') - expect(response.headers.get('x-ratelimit-remaining')).toBe('99') - expect(stream).toContain('"type":"session"') - expect(stream).toContain('"continuationToken":"continuation-new"') - expect(stream).toContain('"chatId":"chat-1"') - expect(stream).not.toContain('"runId":"run-1"') - expect(stream).toContain('"delta":"Hello from Sim"') - expect(stream).toContain('"type":"complete"') - expect(stream).toContain('data: [DONE]') - - expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ - actorUserId: 'key-owner-1', - workspaceId: 'workspace-1', - }) - expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled() - expect(mockRunWorkspaceChat).toHaveBeenCalledWith( - expect.objectContaining({ - authorizationUserId: 'key-owner-1', - actorUserId: 'key-owner-1', - workspaceId: 'workspace-1', - billingAttribution: personalAttribution, - readOnly: false, - }) - ) - expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( - expect.objectContaining({ - credentialType: 'personal', - readOnly: false, - persistence: 'sim', - }) - ) - expect(mockRunWorkspaceChat.mock.calls[0][0]).not.toHaveProperty('apiKey') - expect(mockAcquirePendingChatStream).toHaveBeenCalledWith('chat-1', 'message-1') - expect(mockRegisterActiveStream).toHaveBeenCalledWith( - 'message-1', - expect.any(AbortController), - expect.any(AbortController) - ) - expect(mockStartAbortPoller).toHaveBeenCalledWith('message-1', expect.any(AbortController), { - requestId: 'request-1', - chatId: 'chat-1', - userStopController: expect.any(AbortController), - }) - expect(mockUnregisterActiveStream).toHaveBeenCalledWith('message-1') - expect(mockReleasePendingChatStream).toHaveBeenCalledWith('chat-1', 'message-1') - expect(mockCleanupAbortMarker).toHaveBeenCalledWith('message-1') - expect(mockResolveOrCreateChat).toHaveBeenCalledWith({ - userId: 'key-owner-1', - workspaceId: 'workspace-1', - model: 'claude-opus-4-8', - type: 'mothership', - }) - expect(mockPublishStatusChanged).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - chatId: 'chat-1', - type: 'created', - }) - expect(mockCreateRunSegment).toHaveBeenCalledWith({ - id: 'run-1', - executionId: 'execution-1', - chatId: 'chat-1', - userId: 'key-owner-1', - workspaceId: 'workspace-1', - streamId: 'message-1', - model: null, - requestContext: { requestId: 'request-1', source: 'v2_chat' }, - }) - expect(mockResetBuffer).toHaveBeenCalledWith('message-1') - expect(mockClearFilePreviewSessions).toHaveBeenCalledWith('message-1') - expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith({ - chatId: 'chat-1', - userMessageId: 'message-1', - message: 'What is here?', - contexts: undefined, - workspaceId: 'workspace-1', - notifyWorkspaceStatus: true, - }) - expect(mockPublisherPublish).toHaveBeenCalledWith({ - type: 'session', - payload: { kind: 'chat', chatId: 'chat-1' }, - }) - expect(mockPublisherPublish).toHaveBeenCalledWith({ - type: 'text', - payload: { channel: 'assistant', text: 'Hello from Sim' }, - }) - expect(mockFinalizeStream).toHaveBeenCalledWith( - expect.objectContaining({ success: true, content: 'Hello from Sim' }), - expect.any(Object), - 'run-1', - 'success', - 'request-1' - ) - expect(mockFireTitleGeneration).toHaveBeenCalledWith( - expect.objectContaining({ - chatId: 'chat-1', - isNewChat: true, - message: 'What is here?', - workspaceId: 'workspace-1', - }) - ) - /** - * Title generation projects its input against the secret-trace registry and - * fails closed when none is supplied, so omitting this silently skips every - * title on this route — the failure is a missing log line, not an error. - * The registry must also report complete, or the projection is still unsafe. - */ - const titleParams = mockFireTitleGeneration.mock.calls[0]![0] as { - resolvedSecretTraceRegistry?: { getModelEgressSnapshot(): { complete: boolean } } - } - expect(titleParams.resolvedSecretTraceRegistry).toBeDefined() - expect(titleParams.resolvedSecretTraceRegistry!.getModelEgressSnapshot().complete).toBe(true) - expect(mockPublisherClose).toHaveBeenCalledTimes(1) - expect(mockScheduleBufferCleanup).toHaveBeenCalledWith('message-1') - expect(mockScheduleFilePreviewSessionCleanup).toHaveBeenCalledWith('message-1') - }) - - it('passes validated resource and slash contexts to workspace chat', async () => { - const contexts = [ - { kind: 'workflow', workflowId: 'workflow-1', label: 'Release' }, - { kind: 'skill', skillId: 'skill-1', label: 'review' }, - { kind: 'mcp', serverId: 'mcp-1', label: 'Docs' }, - ] - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: 'Use @Release and /review with /Docs', - contexts, - }) - await response.text() - - expect(response.status).toBe(200) - expect(mockRunWorkspaceChat).toHaveBeenCalledWith(expect.objectContaining({ contexts })) - }) - - it('rejects malformed or unsupported public context variants', async () => { - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: 'Use this', - contexts: [{ kind: 'folder', folderId: 'folder-1', label: 'Private folder' }], - }) - - expect(response.status).toBe(400) - expect(mockRunWorkspaceChat).not.toHaveBeenCalled() - }) - - it('fails with a retryable conflict before exposing a session when the chat lease is busy', async () => { - mockAcquirePendingChatStream.mockResolvedValueOnce(false) - - const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) - - expect(response.status).toBe(409) - expect(await response.json()).toEqual({ - error: { - code: 'CONFLICT', - message: 'A response is already in progress for this chat', - }, - }) - expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() - expect(mockRegisterActiveStream).not.toHaveBeenCalled() - expect(mockRunWorkspaceChat).not.toHaveBeenCalled() - expect(mockReleasePendingChatStream).not.toHaveBeenCalled() - }) - - it('does not issue a session token or start Mothership before the chat lease is acquired', async () => { - let acquire!: (value: boolean) => void - mockAcquirePendingChatStream.mockReturnValueOnce( - new Promise<boolean>((resolve) => { - acquire = resolve - }) - ) - - const pendingResponse = callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) - await new Promise((resolve) => setImmediate(resolve)) - - expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() - expect(mockRunWorkspaceChat).not.toHaveBeenCalled() - - acquire(true) - const response = await pendingResponse - const stream = await response.text() - expect(stream).toContain('"type":"session"') - expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1) - }) - - it('does not expose the continuation token until Go accepts the initial stream', async () => { - let accept!: () => void - let settle!: () => void - mockFireTitleGeneration.mockImplementationOnce( - ({ publisher }: { publisher: { publish: (event: unknown) => void } }) => { - publisher.publish({ - type: 'session', - payload: { kind: 'title', title: 'Release investigation' }, - }) - } - ) - mockRunWorkspaceChat.mockImplementationOnce( - (input) => - new Promise((resolve) => { - accept = () => input.onInitialStreamAccepted?.() - settle = () => - resolve({ - success: true, - content: 'Done', - contentBlocks: [], - toolCalls: [], - }) - }) - ) - - const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) - const reader = response.body!.getReader() - let firstReadSettled = false - const firstRead = reader.read().then((result) => { - firstReadSettled = true - return result - }) - await new Promise((resolve) => setImmediate(resolve)) - expect(firstReadSettled).toBe(false) - - accept() - const first = await firstRead - const acceptedSession = new TextDecoder().decode(first.value) - expect(acceptedSession).toContain('"type":"session"') - expect(acceptedSession).toContain('"continuationToken":"continuation-new"') - expect(acceptedSession).toContain('"title":"Release investigation"') - expect(mockPublisherPublish).toHaveBeenCalledWith({ - type: 'session', - payload: { kind: 'title', title: 'Release investigation' }, - }) - - settle() - while (!(await reader.read()).done) { - // Drain the completion so route cleanup can release its lease. - } - await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) - }) - - it('projects a title generated after session acceptance onto the public stream', async () => { - let publishTitle!: (event: unknown) => void - mockFireTitleGeneration.mockImplementationOnce( - ({ publisher }: { publisher: { publish: (event: unknown) => void } }) => { - publishTitle = publisher.publish - } - ) - mockRunWorkspaceChat.mockImplementationOnce(async (input) => { - input.onInitialStreamAccepted?.() - publishTitle({ - type: 'session', - payload: { kind: 'title', title: 'Deployment failure' }, - }) - return { - success: true, - content: 'Done', - contentBlocks: [], - toolCalls: [], - } - }) - - const response = await callChat({ workspaceId: 'workspace-1', prompt: 'What failed?' }) - const events = parseSse(await response.text()) - - expect(events).toContainEqual({ - type: 'session', - chatId: 'chat-1', - title: 'Deployment failure', - }) - }) - - it('does not hold a synchronous Go leg on run creation but waits before finalizing it', async () => { - let resolveRunSegment!: () => void - let resolveChat!: () => void - mockCreateRunSegment.mockReturnValueOnce( - new Promise((resolve) => { - resolveRunSegment = () => resolve({ id: 'run-1' }) - }) - ) - mockRunWorkspaceChat.mockImplementationOnce( - (input) => - new Promise((resolve) => { - input.onInitialStreamAccepted?.() - resolveChat = () => - resolve({ - success: true, - content: 'Done', - contentBlocks: [], - toolCalls: [], - }) - }) - ) - - const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Continue' }) - await vi.waitFor(() => expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1)) - - resolveChat() - await new Promise((resolve) => setImmediate(resolve)) - expect(mockFinalizeStream).not.toHaveBeenCalled() - - resolveRunSegment() - expect(await response.text()).toContain('"type":"complete"') - expect(mockFinalizeStream).toHaveBeenCalledTimes(1) - }) - - it('creates an asynchronous run durably before starting Go or exposing its session', async () => { - let resolveRunSegment!: () => void - mockCreateRunSegment.mockReturnValueOnce( - new Promise((resolve) => { - resolveRunSegment = () => resolve({ id: 'run-1' }) - }) - ) - - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: 'Continue', - async: true, - }) - const reader = response.body!.getReader() - let firstReadSettled = false - const firstRead = reader.read().then((result) => { - firstReadSettled = true - return result - }) - await new Promise((resolve) => setImmediate(resolve)) - - expect(mockRunWorkspaceChat).not.toHaveBeenCalled() - expect(firstReadSettled).toBe(false) - - resolveRunSegment() - const first = await firstRead - expect(new TextDecoder().decode(first.value)).toContain('"runId":"run-1"') - while (!(await reader.read()).done) { - // Drain the completion so route cleanup can release its lease. - } - expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1) - expect(mockFinalizeStream).toHaveBeenCalledTimes(1) - }) - - it('keeps a synchronous synced turn working when run creation fails', async () => { - mockCreateRunSegment.mockRejectedValueOnce(new Error('run table unavailable')) - - const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Continue' }) - const stream = await response.text() - - expect(response.status).toBe(200) - expect(stream).toContain('"type":"complete"') - expect(stream).not.toContain('"runId":"run-1"') - expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1) - expect(mockFinalizeStream).toHaveBeenCalledTimes(1) - }) - - it('fails before acceptance when durable run creation fails', async () => { - mockCreateRunSegment.mockRejectedValueOnce(new Error('run table unavailable')) - - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: 'Continue', - async: true, - }) - const stream = await response.text() - - expect(response.status).toBe(200) - expect(stream).not.toContain('"type":"session"') - expect(stream).toContain('"code":"INTERNAL_ERROR"') - expect(mockRunWorkspaceChat).not.toHaveBeenCalled() - expect(mockFinalizeStream).not.toHaveBeenCalled() - }) - - it('surfaces a pre-acceptance failure without exposing a continuation token', async () => { - mockRunWorkspaceChat.mockResolvedValueOnce({ - success: false, - content: '', - contentBlocks: [], - toolCalls: [], - error: 'workspace setup failed', - }) - - const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) - const stream = await response.text() - - expect(stream).not.toContain('"type":"session"') - expect(stream).toContain('"code":"INTERNAL_ERROR"') - }) - - it('enables the subtractive query policy only when explicitly requested', async () => { - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: 'Only inspect this workspace', - readOnly: true, - }) - await response.text() - - expect(response.status).toBe(200) - expect(mockRunWorkspaceChat).toHaveBeenCalledWith(expect.objectContaining({ readOnly: true })) - expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( - expect.objectContaining({ credentialType: 'personal', readOnly: true }) - ) - }) - - it('continues a legacy Go-only chat without exposing or partially persisting it', async () => { - mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ - valid: true, - chatId: 'private-chat-id', - }) - mockIssueV2ChatContinuationToken.mockReturnValueOnce('continuation-refreshed') - mockGenerateId.mockReset().mockReturnValue('message-followup') - - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: 'Tell me more', - continuationToken: 'continuation-old', - }) - const stream = await response.text() - - expect(response.status).toBe(200) - expect(mockVerifyV2ChatContinuationToken).toHaveBeenCalledWith('continuation-old', { - workspaceId: 'workspace-1', - authorizationUserId: 'key-owner-1', - credentialType: 'personal', - readOnly: false, - }) - expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith({ - chatId: 'private-chat-id', - workspaceId: 'workspace-1', - authorizationUserId: 'key-owner-1', - credentialType: 'personal', - readOnly: false, - }) - expect(mockRunWorkspaceChat).toHaveBeenCalledWith( - expect.objectContaining({ - chatId: 'private-chat-id', - messageId: 'message-followup', - }) - ) - expect(stream).toContain('"continuationToken":"continuation-refreshed"') - expect(stream).not.toContain('private-chat-id') - expect(mockGetAccessibleCopilotChatContinuationMetadata).toHaveBeenCalledWith( - 'private-chat-id', - 'key-owner-1' - ) - expect(mockStreamWriter).not.toHaveBeenCalled() - expect(mockPersistCopilotUserMessage).not.toHaveBeenCalled() - expect(mockPublishStatusChanged).not.toHaveBeenCalled() - }) - - it('rejects asynchronous continuation of a legacy Go-only chat', async () => { - mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ - valid: true, - chatId: 'private-chat-id', - }) - - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: 'Tell me more', - continuationToken: 'continuation-old', - async: true, - }) - - expect(response.status).toBe(400) - expect(await response.json()).toEqual({ - error: { - code: 'BAD_REQUEST', - message: 'Asynchronous chat requires a persisted chat', - }, - }) - expect(mockResolveBillingAttribution).not.toHaveBeenCalled() - expect(mockCreateRunSegment).not.toHaveBeenCalled() - expect(mockRunWorkspaceChat).not.toHaveBeenCalled() - }) - - it('continues an existing persisted personal chat with UI replay enabled', async () => { - mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ - valid: true, - chatId: 'shared-chat-1', - }) - mockIssueV2ChatContinuationToken.mockReturnValueOnce('continuation-refreshed') - mockGetAccessibleCopilotChatContinuationMetadata.mockResolvedValueOnce({ - id: 'shared-chat-1', - userId: 'key-owner-1', - workflowId: null, - workspaceId: 'workspace-1', - type: 'mothership', - title: 'Existing chat', - hasMessages: true, - mcpServerIds: ['mcp-history'], - }) - mockGenerateId - .mockReset() - .mockReturnValueOnce('message-followup') - .mockReturnValueOnce('execution-followup') - .mockReturnValueOnce('run-followup') - - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: 'Continue', - continuationToken: 'continuation-old', - contexts: [{ kind: 'mcp', serverId: 'mcp-current', label: 'Current' }], - }) - const stream = await response.text() - - expect(response.status).toBe(200) - expect(stream).toContain('"chatId":"shared-chat-1"') - expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith( - expect.objectContaining({ - chatId: 'shared-chat-1', - userMessageId: 'message-followup', - message: 'Continue', - contexts: [{ kind: 'mcp', serverId: 'mcp-current', label: 'Current' }], - }) - ) - expect(mockRunWorkspaceChat).toHaveBeenCalledWith( - expect.objectContaining({ - mcpServerIds: ['mcp-history'], - contexts: [{ kind: 'mcp', serverId: 'mcp-current', label: 'Current' }], - }) - ) - expect(mockCreateRunSegment).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'run-followup', - executionId: 'execution-followup', - chatId: 'shared-chat-1', - }) - ) - expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( - expect.objectContaining({ chatId: 'shared-chat-1', persistence: 'sim' }) - ) - }) - - it.each([ - ['missing or deleted', null], - [ - 'the wrong type', - { - id: 'synced-chat-1', - userId: 'key-owner-1', - workflowId: 'workflow-1', - workspaceId: 'workspace-1', - type: 'copilot', - title: 'Workflow chat', - hasMessages: true, - }, - ], - [ - 'from another workspace', - { - id: 'synced-chat-1', - userId: 'key-owner-1', - workflowId: null, - workspaceId: 'workspace-2', - type: 'mothership', - title: 'Other workspace', - hasMessages: true, - }, - ], - ])('rejects an explicitly Sim-persisted continuation when its row is %s', async (_case, chat) => { - mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ - valid: true, - chatId: 'synced-chat-1', - persistence: 'sim', - }) - mockGetAccessibleCopilotChatContinuationMetadata.mockResolvedValueOnce(chat) - - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: 'Continue', - continuationToken: 'continuation-sim', - }) - - expect(response.status).toBe(404) - expect(await response.json()).toEqual({ - error: { code: 'NOT_FOUND', message: 'Chat not found' }, - }) - expect(mockResolveBillingAttribution).not.toHaveBeenCalled() - expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() - expect(mockRunWorkspaceChat).not.toHaveBeenCalled() - }) - - it('fails closed before billing or Mothership for an invalid continuation token', async () => { - mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ valid: false }) - - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: 'steal history', - continuationToken: 'tampered-or-cross-owner-token', - }) - - expect(response.status).toBe(400) - expect(await response.json()).toEqual({ - error: { code: 'BAD_REQUEST', message: 'Invalid or expired continuation token' }, - }) - expect(mockResolveBillingAttribution).not.toHaveBeenCalled() - expect(mockRunWorkspaceChat).not.toHaveBeenCalled() - }) - - it('validates inline attachments and forwards only the server-mapped Mothership shape', async () => { - const publicAttachment = { - name: 'notes.txt', - mediaType: 'text/plain', - data: 'aGk=', - } - const mothershipAttachment = { - type: 'document', - filename: 'notes.txt', - source: { type: 'base64', media_type: 'text/plain', data: 'aGk=' }, - } - mockPrepareV2ChatAttachments.mockReturnValueOnce({ - success: true, - attachments: [mothershipAttachment], - }) - - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: 'Read this', - attachments: [publicAttachment], - }) - await response.text() - - expect(response.status).toBe(200) - expect(mockPrepareV2ChatAttachments).toHaveBeenCalledWith([publicAttachment]) - expect(mockRunWorkspaceChat).toHaveBeenCalledWith( - expect.objectContaining({ fileAttachments: [mothershipAttachment] }) - ) - }) - - it('normalizes an attachment-only turn to a neutral upstream prompt', async () => { - mockPrepareV2ChatAttachments.mockReturnValueOnce({ - success: true, - attachments: [ - { - type: 'document', - filename: 'notes.txt', - source: { type: 'base64', media_type: 'text/plain', data: 'aGk=' }, - }, - ], - }) - - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: ' ', - attachments: [{ name: 'notes.txt', mediaType: 'text/plain', data: 'aGk=' }], - }) - await response.text() - - expect(response.status).toBe(200) - expect(mockRunWorkspaceChat).toHaveBeenCalledWith( - expect.objectContaining({ prompt: 'Please inspect the attached file(s).' }) - ) - expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Please inspect the attached file(s).' }) - ) - }) - - it('returns a typed HTTP error before billing when attachment validation fails', async () => { - mockPrepareV2ChatAttachments.mockReturnValueOnce({ - success: false, - error: { - code: 'UNSUPPORTED_MEDIA_TYPE', - message: 'Attachment "clip.mp4" has unsupported media type video/mp4', - }, - }) - - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: 'Watch this', - attachments: [{ name: 'clip.mp4', mediaType: 'video/mp4', data: 'AAAA' }], - }) - - expect(response.status).toBe(415) - expect((await response.json()).error.code).toBe('UNSUPPORTED_MEDIA_TYPE') - expect(mockResolveBillingAttribution).not.toHaveBeenCalled() - expect(mockRunWorkspaceChat).not.toHaveBeenCalled() - }) - - it('returns the v2 payload-too-large envelope for an oversized raw body', async () => { - const response = await callChat( - { workspaceId: 'workspace-1', prompt: 'hello' }, - { 'Content-Length': String(MAX_V2_CHAT_BODY_BYTES + 1) } - ) - - expect(response.status).toBe(413) - expect(await response.json()).toEqual({ - error: { - code: 'PAYLOAD_TOO_LARGE', - message: `Request body exceeds the ${MAX_V2_CHAT_BODY_BYTES}-byte limit`, - }, - }) - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockResolveBillingAttribution).not.toHaveBeenCalled() - expect(mockRunWorkspaceChat).not.toHaveBeenCalled() - }) - - it('forwards Mothership text events as deltas without prefix guessing', async () => { - mockRunWorkspaceChat.mockImplementationOnce(async (input) => { - input.onInitialStreamAccepted?.() - await input.onEvent?.({ - type: 'text', - payload: { channel: 'assistant', text: 'a' }, - }) - // This delta starts with all prior output. Treating events as possibly - // cumulative would incorrectly emit only "bc" here. - await input.onEvent?.({ - type: 'text', - payload: { channel: 'assistant', text: 'abc' }, - }) - return { - success: true, - content: 'aabc', - contentBlocks: [], - toolCalls: [], - } - }) - - const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) - const stream = await response.text() - - expect(stream).toContain('"delta":"a"') - expect(stream).toContain('"delta":"abc"') - expect(stream).not.toContain('"delta":"bc"') - }) - - it('projects scoped assistant narration without merging it into the public answer', async () => { - mockRunWorkspaceChat.mockImplementationOnce(async (input) => { - input.onInitialStreamAccepted?.() - await input.onEvent?.({ - type: 'span', - scope: { - lane: 'subagent', - agentId: 'research', - parentToolCallId: 'private-dispatch', - spanId: 'private-span', - parentSpanId: 'main', - }, - payload: { kind: 'subagent', event: 'start', agent: 'research' }, - }) - await input.onEvent?.({ - type: 'text', - scope: { - lane: 'subagent', - agentId: 'research', - parentToolCallId: 'private-dispatch', - spanId: 'private-span', - parentSpanId: 'main', - }, - payload: { channel: 'assistant', text: 'Scoped progress.' }, - }) - await input.onEvent?.({ - type: 'span', - scope: { - lane: 'subagent', - agentId: 'research', - parentToolCallId: 'private-dispatch', - spanId: 'private-span', - parentSpanId: 'main', - }, - payload: { kind: 'subagent', event: 'end', agent: 'research' }, - }) - await input.onEvent?.({ - type: 'text', - payload: { channel: 'assistant', text: 'public final delta' }, - }) - return { - success: true, - content: 'public final delta', - contentBlocks: [], - toolCalls: [], - } - }) - - const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) - const stream = await response.text() - const events = parseSse(stream) - const activities = events.filter((event) => event.type === 'activity') - const answerText = events.filter((event) => event.type === 'text') - - expect(answerText).toEqual([{ type: 'text', delta: 'public final delta' }]) - expect(activities).toEqual([ - { - type: 'activity', - data: { - kind: 'subagent', - id: 'agent-1', - label: 'Research Agent', - state: 'running', - }, - }, - { - type: 'activity', - data: { kind: 'narration', parentId: 'agent-1', delta: 'Scoped progress.' }, - }, - { - type: 'activity', - data: { - kind: 'subagent', - id: 'agent-1', - label: 'Research Agent', - state: 'complete', - }, - }, - ]) - expect(stream).not.toContain('private-dispatch') - expect(stream).not.toContain('private-span') - }) - - it('projects a display-safe nested activity tree without private stream data', async () => { - mockRunWorkspaceChat.mockImplementationOnce(async (input) => { - input.onInitialStreamAccepted?.() - await input.onEvent?.({ - type: 'text', - payload: { channel: 'thinking', text: 'Inspecting the workspace' }, - }) - await input.onEvent?.({ - type: 'tool', - payload: { - phase: 'call', - toolCallId: 'private-tool-id', - toolName: 'read', - arguments: { secret: 'never-forward-me' }, - executor: 'sim', - mode: 'async', - }, - }) - await input.onEvent?.({ - type: 'tool', - payload: { - phase: 'call', - toolCallId: 'hidden-tool-id', - toolName: 'private_hidden_tool', - arguments: { secret: 'hidden-call-secret' }, - executor: 'sim', - mode: 'async', - ui: { hidden: true }, - }, - }) - await input.onEvent?.({ - type: 'tool', - payload: { - phase: 'result', - toolCallId: 'hidden-tool-id', - toolName: 'private_hidden_tool', - output: { secret: 'hidden-result-secret' }, - success: true, - executor: 'sim', - mode: 'async', - }, - }) - await input.onEvent?.({ - type: 'tool', - scope: { - lane: 'subagent', - agentId: 'research', - parentToolCallId: 'private-tool-id', - spanId: 'private-research-span', - parentSpanId: 'main', - }, - payload: { - phase: 'call', - toolCallId: 'scoped-tool-id', - toolName: 'private_scoped_tool', - arguments: { secret: 'scoped-secret' }, - executor: 'sim', - mode: 'async', - }, - }) - await input.onEvent?.({ - type: 'tool', - scope: { - lane: 'subagent', - agentId: 'research', - parentToolCallId: 'private-tool-id', - spanId: 'private-research-span', - parentSpanId: 'main', - }, - payload: { - phase: 'result', - toolCallId: 'scoped-tool-id', - toolName: 'private_scoped_tool', - output: { secret: 'scoped-result-secret' }, - success: true, - executor: 'sim', - mode: 'async', - }, - }) - await input.onEvent?.({ - type: 'span', - scope: { - lane: 'subagent', - agentId: 'research', - parentToolCallId: 'private-tool-id', - spanId: 'private-research-span', - parentSpanId: 'main', - }, - payload: { kind: 'subagent', event: 'start', agent: 'research' }, - }) - await input.onEvent?.({ - type: 'text', - scope: { - lane: 'subagent', - agentId: 'research', - parentToolCallId: 'private-tool-id', - spanId: 'private-research-span', - parentSpanId: 'main', - }, - payload: { channel: 'thinking', text: 'private subagent reasoning' }, - }) - await input.onEvent?.({ - type: 'span', - scope: { - lane: 'subagent', - agentId: 'research', - parentToolCallId: 'private-tool-id', - spanId: 'private-research-span', - parentSpanId: 'main', - }, - payload: { kind: 'subagent', event: 'end', agent: 'research' }, - }) - await input.onEvent?.({ - type: 'tool', - payload: { - phase: 'result', - toolCallId: 'private-tool-id', - toolName: 'read', - output: { secret: 'never-forward-me' }, - success: true, - executor: 'sim', - mode: 'async', - }, - }) - return { - success: true, - content: 'Done', - contentBlocks: [], - toolCalls: [], - } - }) - - const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) - const stream = await response.text() - - expect(stream).toContain('"type":"complete"') - expect(stream).toContain('Done') - expect(stream).toContain('"type":"activity"') - expect(stream).toContain('"label":"Reading file"') - expect(stream).toContain('"label":"Read file"') - expect(stream).toContain('"label":"Research Agent"') - expect(stream).toContain('"label":"Private Scoped Tool"') - expect(stream).toContain('"parentId":"agent-1"') - expect(stream).toContain('"state":"running"') - expect(stream).toContain('"state":"complete"') - expect(stream.match(/"type":"activity"/g)).toHaveLength(6) - expect(stream).not.toContain('Inspecting the workspace') - expect(stream).not.toContain('private-tool-id') - expect(stream).not.toContain('private_hidden_tool') - expect(stream).not.toContain('private_scoped_tool') - expect(stream).not.toContain('private-research-span') - expect(stream).not.toContain('never-forward-me') - expect(stream).not.toContain('scoped-secret') - expect(stream).not.toContain('scoped-result-secret') - expect(stream).not.toContain('private subagent reasoning') - }) - - it('authorizes a workspace key as its creator but executes and bills as the system actor', async () => { - mockGenerateId - .mockReset() - .mockReturnValueOnce('chat-1') - .mockReturnValueOnce('message-1') - .mockReturnValue('generated-extra') - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT, - keyType: 'workspace', - workspaceId: 'workspace-1', - }) - - const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Summarize it' }) - const stream = await response.text() - - expect(response.status).toBe(200) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'key-owner-1', keyType: 'workspace' }), - 'key-owner-1', - 'workspace-1', - 'read' - ) - expect(mockResolveSystemBillingAttribution).toHaveBeenCalledWith('workspace-1') - expect(mockResolveBillingAttribution).not.toHaveBeenCalled() - expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( - expect.objectContaining({ credentialType: 'workspace', readOnly: false }) - ) - expect(mockRunWorkspaceChat).toHaveBeenCalledWith( - expect.objectContaining({ - authorizationUserId: 'key-owner-1', - actorUserId: 'workspace-billed-account', - billingAttribution: systemAttribution, - sharedWorkspaceCredential: true, - }) - ) - expect(stream).not.toContain('"chatId":"chat-1"') - expect(mockResolveOrCreateChat).not.toHaveBeenCalled() - expect(mockStreamWriter).not.toHaveBeenCalled() - expect(mockPersistCopilotUserMessage).not.toHaveBeenCalled() - expect(mockPublishStatusChanged).not.toHaveBeenCalled() - }) - - it('requires a personal API key for asynchronous execution', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT, - keyType: 'workspace', - workspaceId: 'workspace-1', - }) - - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: 'Summarize it', - async: true, - }) - - expect(response.status).toBe(403) - expect(await response.json()).toEqual({ - error: { - code: 'FORBIDDEN', - message: 'Asynchronous chat requires a personal API key', - }, - }) - expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled() - expect(mockResolveOrCreateChat).not.toHaveBeenCalled() - expect(mockRunWorkspaceChat).not.toHaveBeenCalled() - }) - - it('routes a workspace-key abort by its owner while preserving the billing actor body', async () => { - mockGenerateId - .mockReset() - .mockReturnValueOnce('chat-1') - .mockReturnValueOnce('message-1') - .mockReturnValue('generated-extra') - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT, - keyType: 'workspace', - workspaceId: 'workspace-1', - }) - mockRunWorkspaceChat.mockResolvedValueOnce({ - success: false, - content: '', - contentBlocks: [], - toolCalls: [], - error: 'upstream failed', - }) - - const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Summarize it' }) - await response.text() - - expect(mockRunWorkspaceChat).toHaveBeenCalledWith( - expect.objectContaining({ - authorizationUserId: 'key-owner-1', - actorUserId: 'workspace-billed-account', - billingAttribution: systemAttribution, - }) - ) - expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith({ - streamId: 'message-1', - userId: 'workspace-billed-account', - routingUserId: 'key-owner-1', - chatId: 'chat-1', - workspaceId: 'workspace-1', - }) - }) - - it('supports the auth-disabled self-host principal while keeping upstream auth server-owned', async () => { - const anonymousAttribution = { - ...personalAttribution, - actorUserId: 'anonymous', - } - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT, - userId: 'anonymous', - keyType: 'personal', - }) - mockEnvFlags.isAuthDisabled = true - mockResolveBillingAttribution.mockResolvedValue(anonymousAttribution) - - const response = await callChat({ workspaceId: 'workspace-1', prompt: 'What is here?' }) - const stream = await response.text() - - expect(response.status).toBe(200) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'anonymous', keyType: undefined }), - 'anonymous', - 'workspace-1', - 'read' - ) - expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ - actorUserId: 'anonymous', - workspaceId: 'workspace-1', - }) - expect(mockRunWorkspaceChat).toHaveBeenCalledWith( - expect.objectContaining({ - authorizationUserId: 'anonymous', - actorUserId: 'anonymous', - billingAttribution: anonymousAttribution, - }) - ) - expect(stream).toContain('"chatId":"chat-1"') - expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith( - expect.objectContaining({ chatId: 'chat-1', message: 'What is here?' }) - ) - }) - - it('returns 402 before opening a stream or calling Mothership when usage is exhausted', async () => { - mockCheckAttributedUsageLimits.mockResolvedValue({ - isExceeded: true, - message: 'Organization usage limit exceeded', - }) - - const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) - - expect(response.status).toBe(402) - expect(await response.json()).toEqual({ - error: { code: 'USAGE_LIMIT_EXCEEDED', message: 'Organization usage limit exceeded' }, - }) - expect(mockRunWorkspaceChat).not.toHaveBeenCalled() - }) - - it('surfaces a raced or self-hosted upstream 402 as a structured stream error', async () => { - const upgrade = - '<usage_upgrade>{"reason":"usage_limit","action":"increase_limit","message":"Ask an org admin."}</usage_upgrade>' - mockRunWorkspaceChat.mockImplementationOnce(async (input) => { - await input.onEvent?.({ - type: 'text', - payload: { channel: 'assistant', text: upgrade }, - }) - return { - success: true, - content: upgrade, - contentBlocks: [], - toolCalls: [], - } - }) - - const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) - const stream = await response.text() - - expect(response.status).toBe(200) - expect(stream).toContain('"code":"USAGE_LIMIT_EXCEEDED"') - expect(stream).toContain('Ask an org admin.') - expect(stream).not.toContain('<usage_upgrade>') - expect(stream).not.toContain('"type":"complete"') - }) - - it('rejects a cross-workspace key before resolving a payer', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'API key is not authorized for this workspace', - }) - - const response = await callChat({ workspaceId: 'workspace-2', prompt: 'hello' }) - - expect(response.status).toBe(403) - expect(mockResolveBillingAttribution).not.toHaveBeenCalled() - expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled() - expect(mockRunWorkspaceChat).not.toHaveBeenCalled() - }) - - it('returns a clear 503 when the deployment has no Mothership key', async () => { - mockEnv.COPILOT_API_KEY = undefined - - const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) - - expect(response.status).toBe(503) - expect(await response.json()).toEqual({ - error: { - code: 'SERVICE_UNAVAILABLE', - message: 'Sim Chat is not configured on this deployment', - }, - }) - expect(mockResolveBillingAttribution).not.toHaveBeenCalled() - }) - - it('does not leak an upstream failure body and explicitly stops detached generation', async () => { - mockRunWorkspaceChat.mockResolvedValueOnce({ - success: false, - content: '', - contentBlocks: [], - toolCalls: [], - error: 'upstream secret response body', - errors: ['provider internal detail'], - }) - - const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) - const stream = await response.text() - - expect(stream).toContain('"code":"INTERNAL_ERROR"') - expect(stream).toContain('"message":"Chat request failed"') - expect(stream).not.toContain('upstream secret response body') - expect(stream).not.toContain('provider internal detail') - expect(mockRequestExplicitStreamAbort).toHaveBeenCalledTimes(1) - expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith({ - streamId: 'message-1', - userId: 'key-owner-1', - routingUserId: 'key-owner-1', - chatId: 'chat-1', - workspaceId: 'workspace-1', - }) - }) - - it('rejects caller-controlled identity, model, and provider fields', async () => { - for (const forbidden of [ - { userId: 'forged-user' }, - { model: 'caller-model' }, - { provider: 'caller-provider' }, - { chatId: 'raw-private-chat-id' }, - { conversationId: 'raw-private-chat-id' }, - ]) { - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: 'hello', - ...forbidden, - }) - expect(response.status).toBe(400) - } - expect(mockResolveBillingAttribution).not.toHaveBeenCalled() - expect(mockRunWorkspaceChat).not.toHaveBeenCalled() - }) - - it('returns the shared v2 auth error before parsing the body', async () => { - mockCheckRateLimit.mockResolvedValue({ - allowed: false, - limit: 0, - remaining: 0, - resetAt: new Date(), - error: 'Invalid API key', - }) - - const response = await callChat({}) - - expect(response.status).toBe(401) - expect((await response.json()).error.code).toBe('UNAUTHORIZED') - expect(mockV2ApiGateError).not.toHaveBeenCalled() - }) - - it('stops local work, marks Go once, and retains the lease until the lifecycle settles', async () => { - const teardownOrder: string[] = [] - let settle!: () => void - let lifecycleSignal: AbortSignal | undefined - let userStopSignal: AbortSignal | undefined - mockRequestExplicitStreamAbort.mockImplementationOnce(async () => { - teardownOrder.push('go-abort') - }) - mockReleasePendingChatStream.mockImplementationOnce(async () => { - teardownOrder.push('release') - }) - mockRunWorkspaceChat.mockImplementationOnce( - (input) => - new Promise((resolve) => { - lifecycleSignal = input.abortSignal - userStopSignal = input.userStopSignal - input.onInitialStreamAccepted?.() - settle = () => - resolve({ - success: false, - cancelled: true, - content: '', - contentBlocks: [], - toolCalls: [], - }) - }) - ) - const request = new NextRequest('http://localhost:3000/api/v2/chat', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'caller-platform-key', - }, - body: JSON.stringify({ workspaceId: 'workspace-1', prompt: 'keep going' }), - }) - - const response = await POST(request) - const reader = response.body!.getReader() - await reader.read() - await reader.cancel('test_disconnect') - - await vi.waitFor(() => expect(mockRequestExplicitStreamAbort).toHaveBeenCalledTimes(1)) - expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith({ - streamId: 'message-1', - userId: 'key-owner-1', - routingUserId: 'key-owner-1', - chatId: 'chat-1', - workspaceId: 'workspace-1', - }) - expect(lifecycleSignal?.aborted).toBe(false) - expect(userStopSignal?.aborted).toBe(true) - expect(userStopSignal?.reason).toBe('user_stop:abortActiveStream') - expect(mockReleasePendingChatStream).not.toHaveBeenCalled() - - settle() - await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) - expect(teardownOrder).toEqual(['go-abort', 'release']) - expect(mockUnregisterActiveStream).toHaveBeenCalledTimes(1) - expect(mockCleanupAbortMarker).toHaveBeenCalledWith('message-1') - }) - - it('keeps an accepted asynchronous turn running after its reader disconnects', async () => { - let settle!: () => void - let lifecycleSignal: AbortSignal | undefined - let userStopSignal: AbortSignal | undefined - mockRunWorkspaceChat.mockImplementationOnce( - (input) => - new Promise((resolve) => { - lifecycleSignal = input.abortSignal - userStopSignal = input.userStopSignal - input.onInitialStreamAccepted?.() - settle = () => - resolve({ - success: true, - content: 'Finished in the background', - contentBlocks: [], - toolCalls: [], - }) - }) - ) - - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: 'keep going', - async: true, - }) - const reader = response.body!.getReader() - const acceptedSession = new TextDecoder().decode((await reader.read()).value) - expect(acceptedSession).toContain('"runId":"run-1"') - - await reader.cancel('async_receipt_received') - await new Promise((resolve) => setImmediate(resolve)) - - expect(mockRequestExplicitStreamAbort).not.toHaveBeenCalled() - expect(lifecycleSignal?.aborted).toBe(false) - expect(userStopSignal?.aborted).toBe(false) - expect(mockReleasePendingChatStream).not.toHaveBeenCalled() - - settle() - await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) - expect(mockFinalizeStream).toHaveBeenCalledWith( - expect.objectContaining({ success: true, content: 'Finished in the background' }), - expect.any(Object), - 'run-1', - 'success', - 'request-1' - ) - }) - - it('does not classify an accepted asynchronous turn as cancelled when its request aborts', async () => { - const requestAbortController = new AbortController() - let settle!: () => void - let userStopSignal: AbortSignal | undefined - mockRunWorkspaceChat.mockImplementationOnce( - (input) => - new Promise((resolve) => { - userStopSignal = input.userStopSignal - input.onInitialStreamAccepted?.() - settle = () => - resolve({ - success: true, - content: 'Finished after request disconnect', - contentBlocks: [], - toolCalls: [], - }) - }) - ) - const request = new NextRequest('http://localhost:3000/api/v2/chat', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'caller-platform-key', - }, - body: JSON.stringify({ - workspaceId: 'workspace-1', - prompt: 'keep going', - async: true, - }), - signal: requestAbortController.signal, - }) - - const response = await POST(request) - const reader = response.body!.getReader() - expect(new TextDecoder().decode((await reader.read()).value)).toContain('"runId":"run-1"') - - requestAbortController.abort() - await new Promise((resolve) => setImmediate(resolve)) - expect(mockRequestExplicitStreamAbort).not.toHaveBeenCalled() - expect(userStopSignal?.aborted).toBe(false) - - settle() - while (!(await reader.read()).done) { - // Drain the completion so route cleanup can release its lease. - } - await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) - expect(mockFinalizeStream).toHaveBeenCalledWith( - expect.objectContaining({ success: true, content: 'Finished after request disconnect' }), - expect.any(Object), - 'run-1', - 'success', - 'request-1' - ) - }) - - it('still stops an asynchronous turn when the reader disconnects before acceptance', async () => { - let settle!: () => void - let userStopSignal: AbortSignal | undefined - mockRunWorkspaceChat.mockImplementationOnce( - (input) => - new Promise((resolve) => { - userStopSignal = input.userStopSignal - settle = () => - resolve({ - success: false, - cancelled: true, - content: '', - contentBlocks: [], - toolCalls: [], - }) - }) - ) - - const response = await callChat({ - workspaceId: 'workspace-1', - prompt: 'keep going', - async: true, - }) - await vi.waitFor(() => expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1)) - await response.body!.cancel('pre_accept_disconnect') - - await vi.waitFor(() => expect(mockRequestExplicitStreamAbort).toHaveBeenCalledTimes(1)) - expect(userStopSignal?.aborted).toBe(true) - expect(userStopSignal?.reason).toBe('user_stop:abortActiveStream') - - settle() - await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) - expect(mockFinalizeStream).toHaveBeenCalledWith( - expect.objectContaining({ cancelled: true }), - expect.any(Object), - 'run-1', - 'cancelled', - 'request-1' - ) - }) - - it('does not start a lifecycle when Stop wins before workspace chat begins', async () => { - mockRegisterActiveStream.mockImplementationOnce( - ( - _streamId: string, - _lifecycleController: AbortController, - userStopController: AbortController - ) => userStopController.abort('user_stop:abortActiveStream') - ) - - const response = await callChat({ workspaceId: 'workspace-1', prompt: 'keep going' }) - expect(await response.text()).toBe('') - - expect(mockRunWorkspaceChat).not.toHaveBeenCalled() - expect(mockUnregisterActiveStream).toHaveBeenCalledWith('message-1') - expect(mockReleasePendingChatStream).toHaveBeenCalledWith('chat-1', 'message-1') - expect(mockCleanupAbortMarker).toHaveBeenCalledWith('message-1') - }) - - it('still stops local work and retains the lease when the Go abort marker fails', async () => { - let settle!: () => void - let lifecycleSignal: AbortSignal | undefined - let userStopSignal: AbortSignal | undefined - mockRequestExplicitStreamAbort.mockRejectedValueOnce(new Error('marker unavailable')) - mockRunWorkspaceChat.mockImplementationOnce( - (input) => - new Promise((resolve) => { - lifecycleSignal = input.abortSignal - userStopSignal = input.userStopSignal - input.onInitialStreamAccepted?.() - settle = () => - resolve({ - success: true, - content: 'settled naturally', - contentBlocks: [], - toolCalls: [], - }) - }) - ) - - const request = new NextRequest('http://localhost:3000/api/v2/chat', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'caller-platform-key', - }, - body: JSON.stringify({ workspaceId: 'workspace-1', prompt: 'keep going' }), - }) - const response = await POST(request) - const reader = response.body!.getReader() - await reader.read() - await reader.cancel('test_disconnect') - - await vi.waitFor(() => expect(mockRequestExplicitStreamAbort).toHaveBeenCalledTimes(1)) - expect(lifecycleSignal?.aborted).toBe(false) - expect(userStopSignal?.aborted).toBe(true) - expect(userStopSignal?.reason).toBe('user_stop:abortActiveStream') - expect(mockReleasePendingChatStream).not.toHaveBeenCalled() - - settle() - await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) - }) -}) diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts deleted file mode 100644 index e4671df3fba..00000000000 --- a/apps/sim/app/api/v2/chat/route.ts +++ /dev/null @@ -1,755 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' -import { MAX_V2_CHAT_BODY_BYTES, v2ChatContract } from '@/lib/api/contracts/v2/chat' -import { parseRequest } from '@/lib/api/server' -import { - checkAttributedUsageLimits, - resolveBillingAttribution, - resolveSystemBillingAttribution, -} from '@/lib/billing/core/billing-attribution' -import { createRunSegment } from '@/lib/copilot/async-runs/repository' -import { - getAccessibleCopilotChatContinuationMetadata, - resolveOrCreateChat, -} from '@/lib/copilot/chat/lifecycle' -import { ChatActivityProjector, type V2ChatActivity } from '@/lib/copilot/chat/public-activity' -import { - buildCopilotTurnOnComplete, - buildCopilotTurnOnError, - persistCopilotUserMessage, -} from '@/lib/copilot/chat/turn-persistence' -import { chatPubSub } from '@/lib/copilot/chat-status' -import { - MothershipStreamV1EventType, - MothershipStreamV1SessionKind, - MothershipStreamV1TextChannel, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { RequestTraceV1Outcome } from '@/lib/copilot/generated/request-trace-v1' -import { prepareV2ChatAttachments } from '@/lib/copilot/headless/attachments' -import { - issueV2ChatContinuationToken, - verifyV2ChatContinuationToken, -} from '@/lib/copilot/headless/continuation-token' -import { - publicChatUsageLimitMessage, - runWorkspaceChat, - toPublicChatResult, -} from '@/lib/copilot/headless/workspace-chat' -import { finalizeStream } from '@/lib/copilot/request/lifecycle/finalize' -import { fireTitleGeneration } from '@/lib/copilot/request/lifecycle/start' -import { - AbortReason, - acquirePendingChatStream, - cleanupAbortMarker, - clearFilePreviewSessions, - encodeSSEComment, - encodeSSEEnvelope, - registerActiveStream, - releasePendingChatStream, - resetBuffer, - SSE_RESPONSE_HEADERS, - StreamWriter, - scheduleBufferCleanup, - scheduleFilePreviewSessionCleanup, - startAbortPoller, - unregisterActiveStream, -} from '@/lib/copilot/request/session' -import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' -import type { OrchestratorResult } from '@/lib/copilot/request/types' -import { env } from '@/lib/core/config/env' -import { isAuthDisabled } from '@/lib/core/config/env-flags' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - rateLimitHeaders, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' - -export const maxDuration = 3600 -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' - -const logger = createLogger('V2ChatAPI') -const encoder = new TextEncoder() -const HEARTBEAT_INTERVAL_MS = 15_000 -const ATTACHMENT_ONLY_PROMPT = 'Please inspect the attached file(s).' -const V2_CHAT_TITLE_MODEL = 'claude-opus-4-8' - -/** - * An empty, complete secret-trace registry for title generation. - * - * `projectResolvedSecretModelContent` fails closed on a missing registry, so - * without one every title on this route is skipped. The empty registry is the - * accurate claim rather than a bypass: the title is generated from - * `effectivePrompt`, which is the request body's own `prompt` verbatim — this - * route runs no workflow and resolves no secrets into it, so there is nothing - * for the matcher to redact. Shared because it is immutable and the matcher - * cache is keyed on the instance. - * - * If this route ever resolves secrets into the prompt, thread that execution's - * real registry through here instead. - */ -const V2_CHAT_TITLE_SECRET_REGISTRY = new ResolvedSecretTraceRegistry([]) - -interface SyncedChat { - chat: { title?: string | null } | null - isNewChat: boolean - mcpServerIds: string[] -} - -function isAbortError(error: unknown): boolean { - return error instanceof Error && error.name === 'AbortError' -} - -/** POST /api/v2/chat — normal workspace chat with opaque continuation over SSE. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - let acquiredChatId: string | undefined - let acquiredStreamId: string | undefined - let streamOwnsLock = false - - try { - const rateLimit = await checkRateLimit(request, 'copilot-chat') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const authenticatedUserId = rateLimit.userId! - const gate = await v2ApiGateError(authenticatedUserId) - if (gate) return gate - - const parsed = await parseRequest( - v2ChatContract, - request, - {}, - { - maxBodyBytes: MAX_V2_CHAT_BODY_BYTES, - validationErrorResponse: v2ValidationError, - invalidJsonResponse: () => - v2Error('BAD_REQUEST', 'Request body must be valid JSON', { - headers: rateLimitHeaders(rateLimit), - }), - } - ) - if (!parsed.success) { - return parsed.response.status === 413 - ? v2Error( - 'PAYLOAD_TOO_LARGE', - `Request body exceeds the ${MAX_V2_CHAT_BODY_BYTES}-byte limit`, - { headers: rateLimitHeaders(rateLimit) } - ) - : parsed.response - } - - const { - workspaceId, - prompt, - continuationToken, - readOnly, - async: asyncRequested, - attachments, - contexts, - persistChat, - } = parsed.data.body - const credentialType = rateLimit.keyType === 'workspace' ? 'workspace' : 'personal' - const effectivePrompt = prompt.trim() ? prompt : ATTACHMENT_ONLY_PROMPT - // DISABLE_AUTH produces an anonymous pseudo-personal principal. It is not - // an API key, so the workspace's personal-key toggle must not reject it. - // Real personal keys retain the normal toggle on every hosted path. - const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit - const access = await resolveWorkspaceAccess( - accessPrincipal, - authenticatedUserId, - workspaceId, - 'read' - ) - if (access) return v2WorkspaceAccessError(access) - - // Sim API keys authenticate this public boundary only. Every Sim -> Go - // request uses the deployment-owned key so hosted and self-hosted billing - // semantics cannot be changed by a caller-controlled credential. - if (!env.COPILOT_API_KEY?.trim()) { - return v2Error('SERVICE_UNAVAILABLE', 'Sim Chat is not configured on this deployment', { - headers: rateLimitHeaders(rateLimit), - }) - } - - if (asyncRequested && rateLimit.keyType !== 'personal') { - return v2Error('FORBIDDEN', 'Asynchronous chat requires a personal API key', { - headers: rateLimitHeaders(rateLimit), - }) - } - if (asyncRequested && !persistChat) { - return v2Error('BAD_REQUEST', 'Asynchronous chat requires persistChat to be true', { - headers: rateLimitHeaders(rateLimit), - }) - } - - const continuation = continuationToken - ? await verifyV2ChatContinuationToken(continuationToken, { - workspaceId, - authorizationUserId: authenticatedUserId, - credentialType, - readOnly, - }) - : null - if (continuation && !continuation.valid) { - return v2Error('BAD_REQUEST', 'Invalid or expired continuation token', { - headers: rateLimitHeaders(rateLimit), - }) - } - - const shouldSyncChat = rateLimit.keyType === 'personal' - let continuedSyncedChat: SyncedChat | null = null - if (continuation?.valid) { - if (continuation.persistence === 'sim' && !shouldSyncChat) { - return v2Error('NOT_FOUND', 'Chat not found', { - headers: rateLimitHeaders(rateLimit), - }) - } - if (shouldSyncChat) { - const existing = await getAccessibleCopilotChatContinuationMetadata( - continuation.chatId, - authenticatedUserId - ) - const matchesPersistedChat = - existing?.type === 'mothership' && existing.workspaceId === workspaceId - /** - * Tokens issued before Sim-side persistence can point at a Go-only - * chat. A deleted/missing row follows the same path: keep the valid - * continuation working, but do not create a partial UI transcript - * without its earlier turns. - */ - if (matchesPersistedChat && existing) { - continuedSyncedChat = { - chat: { title: existing.title }, - isNewChat: !existing.hasMessages, - mcpServerIds: existing.mcpServerIds, - } - } else if (continuation.persistence === 'sim') { - return v2Error('NOT_FOUND', 'Chat not found', { - headers: rateLimitHeaders(rateLimit), - }) - } - } - } - if (asyncRequested && continuation?.valid && !continuedSyncedChat) { - return v2Error('BAD_REQUEST', 'Asynchronous chat requires a persisted chat', { - headers: rateLimitHeaders(rateLimit), - }) - } - - const preparedAttachments = prepareV2ChatAttachments(attachments) - if (!preparedAttachments.success) { - return v2Error(preparedAttachments.error.code, preparedAttachments.error.message, { - headers: rateLimitHeaders(rateLimit), - }) - } - - /** - * Match public workflow execution: a personal key identifies its human - * actor; a shared workspace key uses the atomically resolved system actor - * and payer. Authorization above always remains bound to the key owner. - */ - const billingAttribution = - rateLimit.keyType === 'workspace' - ? await resolveSystemBillingAttribution(workspaceId) - : await resolveBillingAttribution({ actorUserId: authenticatedUserId, workspaceId }) - const actorUserId = billingAttribution.actorUserId - - const usage = await checkAttributedUsageLimits(billingAttribution) - if (usage.isExceeded) { - return v2Error( - 'USAGE_LIMIT_EXCEEDED', - usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.', - { headers: rateLimitHeaders(rateLimit) } - ) - } - - let syncedChat = continuedSyncedChat - let chatId: string - - if (continuation?.valid) { - chatId = continuation.chatId - } else if (shouldSyncChat && persistChat) { - /* `persistChat: false` gates chat *creation* only. It deliberately does - not reach the continuation branch above: detaching a chat that is - already persisted would silently drop the rest of its transcript. */ - const created = await resolveOrCreateChat({ - userId: authenticatedUserId, - workspaceId, - model: V2_CHAT_TITLE_MODEL, - type: 'mothership', - }) - if (!created.chat || !created.chatId) { - throw new Error('Failed to create persisted v2 chat') - } - syncedChat = { - chat: created.chat, - isNewChat: created.conversationHistory.length === 0, - mcpServerIds: [], - } - chatId = created.chatId - chatPubSub?.publishStatusChanged({ workspaceId, chatId, type: 'created' }) - } else { - chatId = generateId() - } - - const messageId = generateId() - const executionId = syncedChat ? generateId() : undefined - const runId = syncedChat ? generateId() : undefined - const replayPublisher = syncedChat - ? new StreamWriter({ streamId: messageId, chatId, requestId }) - : null - const onTurnComplete = syncedChat - ? buildCopilotTurnOnComplete({ - chatId, - userMessageId: messageId, - requestId, - workspaceId, - notifyWorkspaceStatus: true, - }) - : undefined - const onTurnError = syncedChat - ? buildCopilotTurnOnError({ - chatId, - userMessageId: messageId, - requestId, - workspaceId, - notifyWorkspaceStatus: true, - }) - : undefined - const lifecycleAbortController = new AbortController() - const userStopController = new AbortController() - const chatStreamLockAcquired = await acquirePendingChatStream(chatId, messageId) - if (!chatStreamLockAcquired) { - return v2Error('CONFLICT', 'A response is already in progress for this chat', { - headers: rateLimitHeaders(rateLimit), - }) - } - acquiredChatId = chatId - acquiredStreamId = messageId - if (request.signal.aborted) { - await releasePendingChatStream(chatId, messageId) - acquiredChatId = undefined - acquiredStreamId = undefined - return v2Error('CLIENT_CLOSED_REQUEST', 'Chat request cancelled', { - headers: rateLimitHeaders(rateLimit), - }) - } - - const refreshedContinuationToken = await issueV2ChatContinuationToken({ - chatId, - workspaceId, - authorizationUserId: authenticatedUserId, - credentialType, - readOnly, - ...(syncedChat ? { persistence: 'sim' as const } : {}), - }) - if (request.signal.aborted) { - await releasePendingChatStream(chatId, messageId) - acquiredChatId = undefined - acquiredStreamId = undefined - return v2Error('CLIENT_CLOSED_REQUEST', 'Chat request cancelled', { - headers: rateLimitHeaders(rateLimit), - }) - } - let cancelled = false - let publicStreamOpen = false - let lifecycleStarted = false - let abortRequested = false - let allowExplicitAbort = true - let sessionAccepted = false - let explicitAbortRequest: Promise<void> | undefined - const acceptedAsyncTurnIsDetached = () => asyncRequested && sessionAccepted - const requestAbortStopsLifecycle = () => - request.signal.aborted && !acceptedAsyncTurnIsDetached() - - const requestExplicitAbortOnce = () => { - if (!lifecycleStarted || !allowExplicitAbort) return undefined - if (!explicitAbortRequest) { - explicitAbortRequest = requestExplicitStreamAbort({ - streamId: messageId, - // Go scopes the live stream to its execution/billing actor, while Sim - // must choose the upstream environment from the API-key owner. Keeping - // those identities separate prevents an actor override from rerouting - // Stop without breaking Go's owner-scoped abort marker. - userId: actorUserId, - routingUserId: authenticatedUserId, - chatId, - workspaceId, - }).catch((error) => { - logger.warn(`[${requestId}] Failed to send explicit abort for v2 chat`, { - error: toError(error).message, - }) - }) - } - return explicitAbortRequest - } - - /** - * A normal disconnect is an explicit stop request. Once an asynchronous - * caller has received its durable session receipt, however, disconnect is - * passive and the route keeps draining the Go leg into persisted state. - * In either case the route owns the chat lease until lifecycle settlement. - */ - const abortLifecycle = () => { - if (acceptedAsyncTurnIsDetached()) return - abortRequested = true - requestExplicitAbortOnce() - if (allowExplicitAbort && !userStopController.signal.aborted) { - userStopController.abort(AbortReason.UserStop) - } - } - const onRequestAbort = () => abortLifecycle() - - if (request.signal.aborted) onRequestAbort() - else request.signal.addEventListener('abort', onRequestAbort, { once: true }) - - let heartbeatId: ReturnType<typeof setInterval> | undefined - const stream = new ReadableStream<Uint8Array>({ - start(controller) { - publicStreamOpen = true - registerActiveStream(messageId, lifecycleAbortController, userStopController) - const abortPoller = startAbortPoller(messageId, lifecycleAbortController, { - requestId, - chatId, - userStopController, - }) - const send = (data: unknown): boolean => { - if (cancelled || !publicStreamOpen) return false - controller.enqueue(encodeSSEEnvelope(data)) - return true - } - const activityProjector = new ChatActivityProjector() - const sendActivities = (activities: V2ChatActivity[]) => { - for (const activity of activities) send({ type: 'activity', data: activity }) - } - - let pendingTitle = syncedChat?.chat?.title?.trim() || undefined - let publishedTitle: string | undefined - let replayFinalized = false - let runSegmentPromise: Promise<unknown> | undefined - const publishTitle = (title: string) => { - const next = title.trim() - if (!next) return - pendingTitle = next - if (!sessionAccepted || next === publishedTitle) return - if ( - send({ - type: 'session', - chatId, - ...(asyncRequested && runId ? { runId } : {}), - title: next, - }) - ) { - publishedTitle = next - } - } - const sendSession = () => { - if (sessionAccepted) return - const sent = send({ - type: 'session', - continuationToken: refreshedContinuationToken, - requestId, - ...(syncedChat ? { chatId } : {}), - ...(asyncRequested && runId ? { runId } : {}), - ...(pendingTitle ? { title: pendingTitle } : {}), - }) - if (!sent) return - sessionAccepted = true - if (pendingTitle) publishedTitle = pendingTitle - } - heartbeatId = setInterval(() => { - if (!cancelled && publicStreamOpen) { - controller.enqueue(encodeSSEComment(`heartbeat ${new Date().toISOString()}`)) - } - }, HEARTBEAT_INTERVAL_MS) - - void (async () => { - try { - if (lifecycleAbortController.signal.aborted || userStopController.signal.aborted) { - return - } - - if (replayPublisher && syncedChat && executionId && runId) { - await Promise.all([resetBuffer(messageId), clearFilePreviewSessions(messageId)]) - const createRunSegmentPromise = createRunSegment({ - id: runId, - executionId, - chatId, - userId: authenticatedUserId, - workspaceId, - streamId: messageId, - model: null, - requestContext: { requestId, source: 'v2_chat' }, - }) - runSegmentPromise = asyncRequested - ? createRunSegmentPromise - : createRunSegmentPromise.catch((error) => { - logger.warn(`[${requestId}] Failed to create v2 chat run segment`, { - error: getErrorMessage(error), - }) - }) - if (asyncRequested) await runSegmentPromise - replayPublisher.publish({ - type: MothershipStreamV1EventType.session, - payload: { kind: MothershipStreamV1SessionKind.chat, chatId }, - }) - await replayPublisher.flush() - await persistCopilotUserMessage({ - chatId, - userMessageId: messageId, - message: effectivePrompt, - contexts, - workspaceId, - notifyWorkspaceStatus: true, - }) - fireTitleGeneration({ - chatId, - currentChat: syncedChat.chat, - isNewChat: syncedChat.isNewChat, - userId: authenticatedUserId, - message: effectivePrompt, - titleModel: V2_CHAT_TITLE_MODEL, - workspaceId, - billingAttribution, - requestId, - resolvedSecretTraceRegistry: V2_CHAT_TITLE_SECRET_REGISTRY, - publisher: { - publish(event) { - replayPublisher.publish(event) - if ( - event.type === MothershipStreamV1EventType.session && - event.payload.kind === MothershipStreamV1SessionKind.title - ) { - publishTitle(event.payload.title) - } - }, - }, - }) - } - - lifecycleStarted = true - if (abortRequested) requestExplicitAbortOnce() - const result = await runWorkspaceChat({ - prompt: effectivePrompt, - authorizationUserId: authenticatedUserId, - actorUserId, - workspaceId, - chatId, - messageId, - requestId, - executionId, - runId, - billingAttribution, - readOnly, - sharedWorkspaceCredential: credentialType === 'workspace', - fileAttachments: preparedAttachments.attachments, - contexts, - mcpServerIds: syncedChat?.mcpServerIds, - abortSignal: lifecycleAbortController.signal, - userStopSignal: userStopController.signal, - onInitialStreamAccepted: sendSession, - onEvent: async (event) => { - replayPublisher?.publish(event) - sendActivities(activityProjector.project(event)) - if ( - event.type === MothershipStreamV1EventType.text && - event.payload.channel === MothershipStreamV1TextChannel.assistant && - !event.scope && - event.payload.text - ) { - const text = event.payload.text - if (!publicChatUsageLimitMessage(text)) { - send({ type: 'text', delta: text }) - } - } - }, - onComplete: onTurnComplete, - onError: onTurnError, - }) - - if (replayPublisher && runId) { - await runSegmentPromise - const replayOutcome = result.success - ? RequestTraceV1Outcome.success - : result.cancelled || - lifecycleAbortController.signal.aborted || - userStopController.signal.aborted || - requestAbortStopsLifecycle() - ? RequestTraceV1Outcome.cancelled - : RequestTraceV1Outcome.error - await finalizeStream(result, replayPublisher, runId, replayOutcome, requestId) - replayFinalized = true - } - - const upstreamUsageLimit = publicChatUsageLimitMessage(result.content) - if (upstreamUsageLimit) { - allowExplicitAbort = false - sendActivities(activityProjector.finish('error')) - send({ - type: 'error', - error: { - code: 'USAGE_LIMIT_EXCEEDED', - message: upstreamUsageLimit, - }, - }) - return - } - - if (!sessionAccepted) { - throw new Error('Mothership did not acknowledge the initial chat stream') - } - if ( - lifecycleAbortController.signal.aborted || - userStopController.signal.aborted || - requestAbortStopsLifecycle() || - result.cancelled - ) { - requestExplicitAbortOnce() - sendActivities(activityProjector.finish('error')) - send({ - type: 'error', - error: { code: 'CLIENT_CLOSED_REQUEST', message: 'Chat request cancelled' }, - }) - return - } - - if (!result.success) { - requestExplicitAbortOnce() - logger.error(`[${requestId}] V2 chat failed`, { - workspaceId, - error: result.error, - errors: result.errors, - }) - sendActivities(activityProjector.finish('error')) - send({ - type: 'error', - error: { - code: 'INTERNAL_ERROR', - message: 'Chat request failed', - }, - }) - return - } - - allowExplicitAbort = false - - sendActivities(activityProjector.finish('complete')) - send({ - type: 'complete', - data: toPublicChatResult(result, refreshedContinuationToken), - }) - if (!cancelled) controller.enqueue(encoder.encode('data: [DONE]\n\n')) - publicStreamOpen = false - } catch (error) { - const aborted = - lifecycleAbortController.signal.aborted || - userStopController.signal.aborted || - requestAbortStopsLifecycle() || - isAbortError(error) - const terminalResult: OrchestratorResult = { - success: false, - cancelled: aborted, - content: '', - contentBlocks: [], - toolCalls: [], - error: toError(error).message, - } - if (!replayFinalized) { - if (aborted) { - await onTurnComplete?.(terminalResult) - } else { - await onTurnError?.(toError(error), terminalResult) - } - if (replayPublisher && runId) { - try { - await runSegmentPromise - await finalizeStream( - terminalResult, - replayPublisher, - runId, - aborted ? RequestTraceV1Outcome.cancelled : RequestTraceV1Outcome.error, - requestId - ) - replayFinalized = true - } catch (finalizeError) { - logger.warn(`[${requestId}] Failed to finalize v2 replay stream`, { - error: getErrorMessage(finalizeError), - }) - } - } - } - if (!aborted) { - logger.error(`[${requestId}] V2 chat error`, { - workspaceId, - error: getErrorMessage(error, 'Unknown error'), - }) - } - requestExplicitAbortOnce() - sendActivities(activityProjector.finish('error')) - send({ - type: 'error', - error: { - code: aborted ? 'CLIENT_CLOSED_REQUEST' : 'INTERNAL_ERROR', - message: aborted ? 'Chat request cancelled' : 'Chat request failed', - }, - }) - } finally { - publicStreamOpen = false - allowExplicitAbort = false - if (heartbeatId) clearInterval(heartbeatId) - request.signal.removeEventListener('abort', onRequestAbort) - await explicitAbortRequest - clearInterval(abortPoller) - unregisterActiveStream(messageId) - await releasePendingChatStream(chatId, messageId) - await cleanupAbortMarker(messageId) - if (replayPublisher) { - try { - await replayPublisher.close() - } catch (error) { - logger.warn(`[${requestId}] Failed to flush v2 replay stream`, { - error: getErrorMessage(error), - }) - } - await scheduleBufferCleanup(messageId) - await scheduleFilePreviewSessionCleanup(messageId) - } - if (!cancelled) controller.close() - } - })() - }, - cancel(reason) { - cancelled = true - publicStreamOpen = false - if (heartbeatId) clearInterval(heartbeatId) - abortLifecycle() - }, - }) - streamOwnsLock = true - - return new Response(stream, { - headers: { - ...SSE_RESPONSE_HEADERS, - 'Cache-Control': 'private, no-store, no-transform', - ...rateLimitHeaders(rateLimit), - }, - }) - } catch (error) { - if (!streamOwnsLock && acquiredChatId && acquiredStreamId) { - await releasePendingChatStream(acquiredChatId, acquiredStreamId) - } - logger.error(`[${requestId}] Failed to start v2 chat`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/chat/runs/[runId]/route.test.ts b/apps/sim/app/api/v2/chat/runs/[runId]/route.test.ts deleted file mode 100644 index c06c1512ad3..00000000000 --- a/apps/sim/app/api/v2/chat/runs/[runId]/route.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - checkPreauth: vi.fn(), - checkOperationRate: vi.fn(), - gate: vi.fn(), - readRun: vi.fn(), -})) - -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -vi.mock('@/lib/core/rate-limiter', () => ({ - getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), - RateLimiter: class RateLimiter { - checkRateLimitDirect = mocks.checkPreauth - checkRateLimitDirectOrThrow = mocks.checkOperationRate - }, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) - -vi.mock('@/lib/copilot/chat/application/runs', () => ({ - readChatRun: { - operation: { id: 'chat.runs.read' }, - execute: mocks.readRun, - }, -})) - -import { ChatRunProgressUnavailableError } from '@/lib/copilot/chat/application/errors' -import { InsufficientWorkspacePermissionsError } from '@/lib/core/application' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { GET } from '@/app/api/v2/chat/runs/[runId]/route' - -const RUN_ID = '4bfa6f89-b746-43be-8246-bf1c69b58593' -const CHAT_ID = '80a47295-040e-46f9-9ea8-ad78eff3bcab' -const auth = { - principal: { - kind: 'personal_api_key' as const, - userId: 'user-1', - keyId: 'key-1', - }, - rolloutUserId: 'user-1', - rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, - rateLimitSubscription: null, - keyType: 'personal' as const, -} -const run = { - runId: RUN_ID, - chatId: CHAT_ID, - chatTitle: 'Release plan', - streamId: 'stream-1', - status: 'active' as const, - startedAt: new Date('2026-08-08T12:00:00.000Z'), - completedAt: null, -} -const context = () => ({ params: Promise.resolve({ runId: RUN_ID }) }) - -function callDetail() { - return GET( - new NextRequest(`http://localhost:3000/api/v2/chat/runs/${RUN_ID}?workspaceId=workspace-1`), - context() - ) -} - -describe('GET /api/v2/chat/runs/[runId]', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.gate.mockResolvedValue(null) - mocks.checkPreauth.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-08-08T13:00:00.000Z'), - }) - mocks.checkOperationRate.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-08-08T13:00:00.000Z'), - }) - mocks.readRun.mockResolvedValue({ - run, - status: 'active', - completedAt: null, - response: 'Working', - activities: [{ kind: 'tool', id: 'tool-1', label: 'Reading file', state: 'running' }], - }) - }) - - it('projects the authorized application result through the public contract', async () => { - const request = new NextRequest( - `http://localhost:3000/api/v2/chat/runs/${RUN_ID}?workspaceId=workspace-1` - ) - const response = await GET(request, context()) - - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ - data: { - runId: RUN_ID, - chatId: CHAT_ID, - chatTitle: 'Release plan', - status: 'active', - startedAt: '2026-08-08T12:00:00.000Z', - completedAt: null, - response: 'Working', - activities: [{ kind: 'tool', id: 'tool-1', label: 'Reading file', state: 'running' }], - }, - }) - expect(mocks.readRun).toHaveBeenCalledWith({ - principal: auth.principal, - input: { runId: RUN_ID, workspaceId: 'workspace-1' }, - request, - }) - }) - - it.each([ - new InsufficientWorkspacePermissionsError(), - new OrchestrationError('not_found', 'Workspace not found'), - new OrchestrationError('not_found', 'Chat run not found'), - ])('uniformly conceals inaccessible scoped runs', async (error) => { - mocks.readRun.mockRejectedValue(error) - - const response = await callDetail() - - expect(response.status).toBe(404) - expect(await response.json()).toEqual({ - error: { code: 'NOT_FOUND', message: 'Chat run not found' }, - }) - }) - - it('returns a retryable 503 for temporarily unavailable progress', async () => { - mocks.readRun.mockRejectedValue(new ChatRunProgressUnavailableError()) - - const response = await callDetail() - - expect(response.status).toBe(503) - expect((await response.json()).error.code).toBe('SERVICE_UNAVAILABLE') - }) - - it('does not disguise unexpected infrastructure failures as absence', async () => { - mocks.readRun.mockRejectedValue(new Error('database unavailable')) - - const response = await callDetail() - - expect(response.status).toBe(500) - expect(await response.json()).toEqual({ - error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, - }) - }) -}) diff --git a/apps/sim/app/api/v2/chat/runs/[runId]/route.ts b/apps/sim/app/api/v2/chat/runs/[runId]/route.ts deleted file mode 100644 index 864fd284298..00000000000 --- a/apps/sim/app/api/v2/chat/runs/[runId]/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { v2GetChatRunContract } from '@/lib/api/contracts/v2/chat-runs' -import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' -import { toPublicChatRunSummary } from '@/lib/copilot/chat/api/run-presenters' -import { v2ChatRunErrorPolicies } from '@/lib/copilot/chat/api/run-route-policy' -import { chatOperations } from '@/lib/copilot/chat/application/operations' -import { readChatRun } from '@/lib/copilot/chat/application/runs' - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -/** GET /api/v2/chat/runs/[runId] — safe pollable run status and progress. */ -export const GET = defineV2JsonRoute({ - contract: v2GetChatRunContract, - auth: v2ApiKeyAuth, - operation: chatOperations.readRun, - rateLimit: v2RateLimits.publicApi, - errorPolicy: v2ChatRunErrorPolicies.detail, - mapInput: ({ params, query }) => ({ - runId: params.runId, - workspaceId: query.workspaceId, - }), - useCase: readChatRun, - present: ({ run, status, completedAt, response, activities }) => ({ - data: { - ...toPublicChatRunSummary(run), - status, - completedAt: completedAt?.toISOString() ?? null, - response, - activities, - }, - }), -}) diff --git a/apps/sim/app/api/v2/chat/runs/route.test.ts b/apps/sim/app/api/v2/chat/runs/route.test.ts deleted file mode 100644 index 5682ba00ba1..00000000000 --- a/apps/sim/app/api/v2/chat/runs/route.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - checkPreauth: vi.fn(), - checkOperationRate: vi.fn(), - gate: vi.fn(), - listRuns: vi.fn(), -})) - -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mocks.authenticate, - V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, -})) - -vi.mock('@/lib/core/rate-limiter', () => ({ - getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), - RateLimiter: class RateLimiter { - checkRateLimitDirect = mocks.checkPreauth - checkRateLimitDirectOrThrow = mocks.checkOperationRate - }, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) - -vi.mock('@/lib/copilot/chat/application/runs', () => ({ - listChatRuns: { - operation: { id: 'chat.runs.list' }, - execute: mocks.listRuns, - }, -})) - -import { PrincipalKindAuthorizationError } from '@/lib/core/application' -import { GET } from '@/app/api/v2/chat/runs/route' - -const RUN_ID = '4bfa6f89-b746-43be-8246-bf1c69b58593' -const CHAT_ID = '80a47295-040e-46f9-9ea8-ad78eff3bcab' -const auth = { - principal: { - kind: 'personal_api_key' as const, - userId: 'user-1', - keyId: 'key-1', - }, - rolloutUserId: 'user-1', - rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, - rateLimitSubscription: null, - keyType: 'personal' as const, -} -const run = { - runId: RUN_ID, - chatId: CHAT_ID, - chatTitle: 'Release plan', - streamId: 'stream-1', - status: 'complete' as const, - startedAt: new Date('2026-08-08T12:00:00.000Z'), - completedAt: new Date('2026-08-08T12:01:00.000Z'), -} - -function callList(query = 'workspaceId=workspace-1') { - return GET(new NextRequest(`http://localhost:3000/api/v2/chat/runs?${query}`)) -} - -describe('GET /api/v2/chat/runs', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.authenticate.mockResolvedValue(auth) - mocks.gate.mockResolvedValue(null) - mocks.checkPreauth.mockResolvedValue({ - allowed: true, - remaining: 599, - resetAt: new Date('2026-08-08T13:00:00.000Z'), - }) - mocks.checkOperationRate.mockResolvedValue({ - allowed: true, - remaining: 99, - resetAt: new Date('2026-08-08T13:00:00.000Z'), - }) - mocks.listRuns.mockResolvedValue({ rows: [], hasMore: false }) - }) - - it('routes validated list input through the semantic application operation', async () => { - mocks.listRuns.mockResolvedValue({ rows: [run], hasMore: true }) - const request = new NextRequest( - 'http://localhost:3000/api/v2/chat/runs?workspaceId=workspace-1&status=complete&limit=1' - ) - - const response = await GET(request) - const body = await response.json() - - expect(response.status).toBe(200) - expect(body.data).toEqual([ - { - runId: RUN_ID, - chatId: CHAT_ID, - chatTitle: 'Release plan', - status: 'complete', - startedAt: '2026-08-08T12:00:00.000Z', - completedAt: '2026-08-08T12:01:00.000Z', - }, - ]) - expect(body.nextCursor).toEqual(expect.any(String)) - expect(mocks.listRuns).toHaveBeenCalledWith({ - principal: auth.principal, - input: { - workspaceId: 'workspace-1', - status: 'complete', - limit: 1, - cursorKeys: undefined, - }, - request, - }) - expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2) - }) - - it('rejects malformed cursors before application execution', async () => { - const response = await callList('workspaceId=workspace-1&cursor=not-a-cursor') - - expect(response.status).toBe(400) - expect((await response.json()).error.message).toMatch(/cursor does not match/i) - expect(mocks.listRuns).not.toHaveBeenCalled() - }) - - it('renders the personal-key-only operation failure consistently', async () => { - mocks.listRuns.mockRejectedValue( - new PrincipalKindAuthorizationError('workspace_api_key', 'chat.runs.list') - ) - - const response = await callList() - - expect(response.status).toBe(403) - expect(await response.json()).toEqual({ - error: { code: 'FORBIDDEN', message: 'Chat runs require a personal API key' }, - }) - }) -}) diff --git a/apps/sim/app/api/v2/chat/runs/route.ts b/apps/sim/app/api/v2/chat/runs/route.ts deleted file mode 100644 index d58e894b44f..00000000000 --- a/apps/sim/app/api/v2/chat/runs/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { v2ListChatRunsContract } from '@/lib/api/contracts/v2/chat-runs' -import { INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' -import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' -import { toPublicChatRunSummary } from '@/lib/copilot/chat/api/run-presenters' -import { v2ChatRunErrorPolicies } from '@/lib/copilot/chat/api/run-route-policy' -import { chatOperations } from '@/lib/copilot/chat/application/operations' -import { listChatRuns } from '@/lib/copilot/chat/application/runs' -import { encodePublicChatRunCursor, PUBLIC_CHAT_RUN_SORT } from '@/lib/copilot/chat/public-runs' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { decodeSortedCursor, encodeSortedCursor } from '@/app/api/v2/lib/response' - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -function decodeCursor(cursor: string | undefined) { - const decoded = decodeSortedCursor(cursor, PUBLIC_CHAT_RUN_SORT) - if (decoded.status === 'invalid') { - throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) - } - return decoded.status === 'ok' ? decoded.keys : undefined -} - -/** GET /api/v2/chat/runs — list owned root Mothership chat runs. */ -export const GET = defineV2JsonRoute({ - contract: v2ListChatRunsContract, - auth: v2ApiKeyAuth, - operation: chatOperations.listRuns, - rateLimit: v2RateLimits.publicApi, - errorPolicy: v2ChatRunErrorPolicies.default, - mapInput: ({ query }) => ({ - workspaceId: query.workspaceId, - status: query.status, - limit: query.limit, - cursorKeys: decodeCursor(query.cursor), - }), - useCase: listChatRuns, - present: ({ rows, hasMore }) => { - const last = rows.at(-1) - return { - data: rows.map(toPublicChatRunSummary), - nextCursor: - hasMore && last - ? encodeSortedCursor(PUBLIC_CHAT_RUN_SORT, encodePublicChatRunCursor(last)) - : null, - } - }, -}) diff --git a/apps/sim/app/api/v2/chats/[chatId]/route.test.ts b/apps/sim/app/api/v2/chats/[chatId]/route.test.ts deleted file mode 100644 index 997739fa9a0..00000000000 --- a/apps/sim/app/api/v2/chats/[chatId]/route.test.ts +++ /dev/null @@ -1,372 +0,0 @@ -/** - * @vitest-environment node - */ -import { dbChainMockFns, flattenMockConditions, resetDbChainMock, schemaMock } from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckRateLimit, - mockEnvFlags, - mockGetAccessibleCopilotChatWithMessages, - mockIssueV2ChatContinuationToken, - mockPublishStatusChanged, - mockCaptureServerEvent, - mockReconcileChatStreamMarkers, - mockResolveWorkspaceAccess, - mockV2ApiGateError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockEnvFlags: { isAuthDisabled: false }, - mockGetAccessibleCopilotChatWithMessages: vi.fn(), - mockIssueV2ChatContinuationToken: vi.fn(), - mockPublishStatusChanged: vi.fn(), - mockCaptureServerEvent: vi.fn(), - mockReconcileChatStreamMarkers: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockV2ApiGateError: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: mockV2ApiGateError, -})) - -vi.mock('@/lib/copilot/chat/lifecycle', () => ({ - getAccessibleCopilotChatWithMessages: mockGetAccessibleCopilotChatWithMessages, -})) - -vi.mock('@/lib/copilot/chat/stream-liveness', () => ({ - reconcileChatStreamMarkers: mockReconcileChatStreamMarkers, -})) - -vi.mock('@/lib/copilot/headless/continuation-token', () => ({ - issueV2ChatContinuationToken: mockIssueV2ChatContinuationToken, -})) - -vi.mock('@/lib/copilot/chat-status', () => ({ - chatPubSub: { publishStatusChanged: mockPublishStatusChanged }, -})) - -vi.mock('@/lib/posthog/server', () => ({ - captureServerEvent: mockCaptureServerEvent, -})) - -vi.mock('@/lib/core/config/env-flags', () => mockEnvFlags) - -import { GET, PATCH } from '@/app/api/v2/chats/[chatId]/route' - -const RATE_LIMIT = { - allowed: true, - userId: 'user-1', - keyType: 'personal' as const, - limit: 100, - remaining: 99, - resetAt: new Date('2026-08-07T13:00:00.000Z'), -} - -function buildChat(overrides: Record<string, unknown> = {}) { - return { - id: 'chat-1', - userId: 'user-1', - workflowId: null, - workspaceId: 'workspace-1', - type: 'mothership', - title: 'Release plan', - conversationId: 'stream-stale', - resources: null, - createdAt: new Date('2026-08-07T11:00:00.000Z'), - updatedAt: new Date('2026-08-07T12:00:00.000Z'), - messages: [], - ...overrides, - } -} - -function callDetail(query = 'workspaceId=workspace-1') { - return GET(new NextRequest(`http://localhost:3000/api/v2/chats/chat-1?${query}`), { - params: Promise.resolve({ chatId: 'chat-1' }), - }) -} - -function callRename(body: Record<string, unknown>) { - return PATCH( - new NextRequest('http://localhost:3000/api/v2/chats/chat-1', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - { params: Promise.resolve({ chatId: 'chat-1' }) } - ) -} - -describe('GET /api/v2/chats/[chatId]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockEnvFlags.isAuthDisabled = false - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) - mockV2ApiGateError.mockResolvedValue(null) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetAccessibleCopilotChatWithMessages.mockResolvedValue(buildChat()) - mockReconcileChatStreamMarkers.mockResolvedValue( - new Map([['chat-1', { chatId: 'chat-1', streamId: null, status: 'inactive' }]]) - ) - mockIssueV2ChatContinuationToken.mockResolvedValue('continuation-token') - }) - - it('rejects workspace keys before loading private chat history', async () => { - mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) - - const response = await callDetail() - - expect(response.status).toBe(403) - expect(await response.json()).toEqual({ - error: { - code: 'FORBIDDEN', - message: 'Chat history requires a personal API key', - }, - }) - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockGetAccessibleCopilotChatWithMessages).not.toHaveBeenCalled() - }) - - it('returns the workspace-access failure without loading the chat', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - - const response = await callDetail() - - expect(response.status).toBe(403) - expect(await response.json()).toEqual({ - error: { code: 'FORBIDDEN', message: 'Access denied' }, - }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - RATE_LIMIT, - 'user-1', - 'workspace-1', - 'read' - ) - expect(mockGetAccessibleCopilotChatWithMessages).not.toHaveBeenCalled() - }) - - it('treats the auth-disabled principal like a session principal for workspace access', async () => { - mockEnvFlags.isAuthDisabled = true - - const response = await callDetail() - - expect(response.status).toBe(200) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.objectContaining({ keyType: undefined }), - 'user-1', - 'workspace-1', - 'read' - ) - }) - - it.each([ - ['an inaccessible chat', null], - ['a workflow-scoped chat', buildChat({ type: 'copilot' })], - ['a chat from another workspace', buildChat({ workspaceId: 'workspace-2' })], - ])('masks %s as the same not-found response', async (_case, chat) => { - mockGetAccessibleCopilotChatWithMessages.mockResolvedValue(chat) - - const response = await callDetail() - - expect(response.status).toBe(404) - expect(await response.json()).toEqual({ - error: { code: 'NOT_FOUND', message: 'Chat not found' }, - }) - expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() - expect(mockReconcileChatStreamMarkers).not.toHaveBeenCalled() - }) - - it('projects display-safe messages and reports the reconciled active marker', async () => { - mockGetAccessibleCopilotChatWithMessages.mockResolvedValue( - buildChat({ - messages: [ - { - id: 'message-user', - role: 'user', - content: 'Ship it', - timestamp: '2026-08-07T11:30:00.000Z', - contexts: [{ kind: 'workflow', label: 'Release', workflowId: 'workflow-1' }], - }, - { - id: 'message-assistant', - role: 'assistant', - content: 'Done', - timestamp: '2026-08-07T11:31:00.000Z', - requestId: 'request-private', - contentBlocks: [{ type: 'text', content: 'Done' }], - }, - { - id: 'message-system', - role: 'system', - content: 'private instructions', - timestamp: '2026-08-07T11:29:00.000Z', - }, - null, - ], - }) - ) - mockReconcileChatStreamMarkers.mockResolvedValueOnce( - new Map([['chat-1', { chatId: 'chat-1', streamId: 'stream-live', status: 'active' }]]) - ) - - const response = await callDetail('workspaceId=workspace-1&readOnly=true') - const body = await response.json() - - expect(response.status).toBe(200) - expect(body.data).toEqual({ - id: 'chat-1', - title: 'Release plan', - active: true, - continuationToken: 'continuation-token', - messages: [ - { - id: 'message-user', - role: 'user', - content: 'Ship it', - timestamp: '2026-08-07T11:30:00.000Z', - }, - { - id: 'message-assistant', - role: 'assistant', - content: 'Done', - timestamp: '2026-08-07T11:31:00.000Z', - }, - ], - }) - expect(mockReconcileChatStreamMarkers).toHaveBeenCalledWith( - [{ chatId: 'chat-1', streamId: 'stream-stale' }], - { repairVerifiedStaleMarkers: true } - ) - }) - - it.each([ - ['true', true], - ['false', false], - ])('binds readOnly=%s into the minted continuation token', async (raw, expected) => { - const response = await callDetail(`workspaceId=workspace-1&readOnly=${raw}`) - - expect(response.status).toBe(200) - expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith({ - chatId: 'chat-1', - workspaceId: 'workspace-1', - authorizationUserId: 'user-1', - credentialType: 'personal', - readOnly: expected, - persistence: 'sim', - }) - }) -}) - -describe('PATCH /api/v2/chats/[chatId]', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - mockEnvFlags.isAuthDisabled = false - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) - mockV2ApiGateError.mockResolvedValue(null) - mockResolveWorkspaceAccess.mockResolvedValue(null) - }) - - it('renames an owned chat and notifies the synchronized Home list', async () => { - dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'chat-1', workspaceId: 'workspace-1' }]) - - const response = await callRename({ - workspaceId: 'workspace-1', - title: 'Incident investigation', - }) - - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ - data: { id: 'chat-1', title: 'Incident investigation' }, - }) - expect(dbChainMockFns.set).toHaveBeenCalledWith({ - title: 'Incident investigation', - updatedAt: expect.any(Date), - lastSeenAt: expect.any(Date), - }) - const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) - expect(conditions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'eq', - left: schemaMock.copilotChats.id, - right: 'chat-1', - }), - expect.objectContaining({ - type: 'eq', - left: schemaMock.copilotChats.userId, - right: 'user-1', - }), - expect.objectContaining({ - type: 'eq', - left: schemaMock.copilotChats.workspaceId, - right: 'workspace-1', - }), - expect.objectContaining({ - type: 'eq', - left: schemaMock.copilotChats.type, - right: 'mothership', - }), - expect.objectContaining({ - type: 'isNull', - column: schemaMock.copilotChats.deletedAt, - }), - ]) - ) - expect(mockPublishStatusChanged).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - chatId: 'chat-1', - type: 'renamed', - }) - expect(mockCaptureServerEvent).toHaveBeenCalledWith( - 'user-1', - 'task_renamed', - { workspace_id: 'workspace-1' }, - { groups: { workspace: 'workspace-1' } } - ) - }) - - it('rejects workspace keys before touching private chat data', async () => { - mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) - - const response = await callRename({ workspaceId: 'workspace-1', title: 'Private title' }) - - expect(response.status).toBe(403) - expect(dbChainMockFns.update).not.toHaveBeenCalled() - }) - - it('returns the workspace-access failure before updating the chat', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - - const response = await callRename({ workspaceId: 'workspace-1', title: 'Private title' }) - - expect(response.status).toBe(403) - expect(dbChainMockFns.update).not.toHaveBeenCalled() - }) - - it('masks missing, deleted, foreign, and non-mothership chats as not found', async () => { - dbChainMockFns.returning.mockResolvedValueOnce([]) - - const response = await callRename({ workspaceId: 'workspace-1', title: 'Private title' }) - - expect(response.status).toBe(404) - expect(await response.json()).toEqual({ - error: { code: 'NOT_FOUND', message: 'Chat not found' }, - }) - expect(mockPublishStatusChanged).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/v2/chats/[chatId]/route.ts b/apps/sim/app/api/v2/chats/[chatId]/route.ts deleted file mode 100644 index 16b09ac2bf8..00000000000 --- a/apps/sim/app/api/v2/chats/[chatId]/route.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { db } from '@sim/db' -import { copilotChats } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { and, eq, isNull } from 'drizzle-orm' -import type { NextRequest } from 'next/server' -import { v2GetChatContract, v2RenameChatContract } from '@/lib/api/contracts/v2/chats' -import { parseRequest } from '@/lib/api/server' -import { getAccessibleCopilotChatWithMessages } from '@/lib/copilot/chat/lifecycle' -import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' -import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' -import { chatPubSub } from '@/lib/copilot/chat-status' -import { issueV2ChatContinuationToken } from '@/lib/copilot/headless/continuation-token' -import { isAuthDisabled } from '@/lib/core/config/env-flags' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2ChatDetailAPI') -type ChatRouteContext = { params: Promise<{ chatId: string }> } - -/** GET /api/v2/chats/[chatId] — open one owned chat and mint a fresh resume token. */ -export const GET = withRouteHandler(async (request: NextRequest, context: ChatRouteContext) => { - try { - const rateLimit = await checkRateLimit(request, 'copilot-chat') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - if (rateLimit.keyType === 'workspace') { - return v2Error('FORBIDDEN', 'Chat history requires a personal API key') - } - - const parsed = await parseRequest(v2GetChatContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { chatId } = parsed.data.params - const { workspaceId, readOnly } = parsed.data.query - - const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit - const access = await resolveWorkspaceAccess(accessPrincipal, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const chat = await getAccessibleCopilotChatWithMessages(chatId, userId) - if (!chat || chat.type !== 'mothership' || chat.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Chat not found') - } - - const streamMarkers = await reconcileChatStreamMarkers( - [{ chatId: chat.id, streamId: chat.conversationId }], - { repairVerifiedStaleMarkers: true } - ) - const active = Boolean(streamMarkers.get(chat.id)?.streamId) - const continuationToken = await issueV2ChatContinuationToken({ - chatId: chat.id, - workspaceId, - authorizationUserId: userId, - credentialType: 'personal', - readOnly, - persistence: 'sim', - }) - const messages = (Array.isArray(chat.messages) ? chat.messages : []) - .filter((message): message is Record<string, unknown> => Boolean(message)) - .map(normalizeMessage) - .filter((message) => message.role === 'user' || message.role === 'assistant') - .map(({ id, role, content, timestamp }) => ({ id, role, content, timestamp })) - - return v2Data( - { - id: chat.id, - title: chat.title, - messages, - continuationToken, - active, - }, - { rateLimit } - ) - } catch (error) { - logger.error('Failed to open v2 chat', { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) - -/** PATCH /api/v2/chats/[chatId] — rename one owned workspace chat. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: ChatRouteContext) => { - try { - const rateLimit = await checkRateLimit(request, 'copilot-chat') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - if (rateLimit.keyType === 'workspace') { - return v2Error('FORBIDDEN', 'Renaming chats requires a personal API key') - } - - const parsed = await parseRequest(v2RenameChatContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { chatId } = parsed.data.params - const { workspaceId, title } = parsed.data.body - - const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit - const access = await resolveWorkspaceAccess(accessPrincipal, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const now = new Date() - const [updated] = await db - .update(copilotChats) - .set({ title, updatedAt: now, lastSeenAt: now }) - .where( - and( - eq(copilotChats.id, chatId), - eq(copilotChats.userId, userId), - eq(copilotChats.workspaceId, workspaceId), - eq(copilotChats.type, 'mothership'), - isNull(copilotChats.deletedAt) - ) - ) - .returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId }) - - if (!updated) return v2Error('NOT_FOUND', 'Chat not found') - - if (updated.workspaceId) { - chatPubSub?.publishStatusChanged({ - workspaceId: updated.workspaceId, - chatId: updated.id, - type: 'renamed', - }) - captureServerEvent( - userId, - 'task_renamed', - { workspace_id: updated.workspaceId }, - { groups: { workspace: updated.workspaceId } } - ) - } - - return v2Data({ id: updated.id, title }, { rateLimit }) - } catch (error) { - logger.error('Failed to rename v2 chat', { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/chats/route.test.ts b/apps/sim/app/api/v2/chats/route.test.ts deleted file mode 100644 index b59eed21878..00000000000 --- a/apps/sim/app/api/v2/chats/route.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * @vitest-environment node - */ -import { - dbChainMockFns, - flattenMockConditions, - queueTableRows, - resetDbChainMock, - schemaMock, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckRateLimit, - mockEnvFlags, - mockReconcileChatStreamMarkers, - mockResolveWorkspaceAccess, - mockV2ApiGateError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockEnvFlags: { isAuthDisabled: false }, - mockReconcileChatStreamMarkers: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockV2ApiGateError: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: mockV2ApiGateError, -})) - -vi.mock('@/lib/copilot/chat/stream-liveness', () => ({ - reconcileChatStreamMarkers: mockReconcileChatStreamMarkers, -})) - -vi.mock('@/lib/core/config/env-flags', () => mockEnvFlags) - -import { GET } from '@/app/api/v2/chats/route' - -const RATE_LIMIT = { - allowed: true, - userId: 'user-1', - keyType: 'personal' as const, - limit: 100, - remaining: 99, - resetAt: new Date('2026-08-07T13:00:00.000Z'), -} - -function buildChat(overrides: Record<string, unknown> = {}) { - return { - id: 'chat-1', - title: 'Release plan', - updatedAt: new Date('2026-08-07T12:00:00.000Z'), - pinned: true, - activeStreamId: 'stream-stale', - ...overrides, - } -} - -function callList(query = 'workspaceId=workspace-1') { - return GET(new NextRequest(`http://localhost:3000/api/v2/chats?${query}`)) -} - -describe('GET /api/v2/chats', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - mockEnvFlags.isAuthDisabled = false - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) - mockV2ApiGateError.mockResolvedValue(null) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockReconcileChatStreamMarkers.mockImplementation( - async (candidates: Array<{ chatId: string; streamId: string | null }>) => - new Map( - candidates.map((candidate) => [ - candidate.chatId, - { - chatId: candidate.chatId, - streamId: candidate.streamId, - status: candidate.streamId ? 'active' : 'inactive', - }, - ]) - ) - ) - }) - - it('rejects workspace keys before reading private chat history', async () => { - mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) - - const response = await callList() - - expect(response.status).toBe(403) - expect(await response.json()).toEqual({ - error: { - code: 'FORBIDDEN', - message: 'Chat history requires a personal API key', - }, - }) - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(dbChainMockFns.select).not.toHaveBeenCalled() - }) - - it('returns the workspace-access failure without querying chats', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - - const response = await callList() - - expect(response.status).toBe(403) - expect(await response.json()).toEqual({ - error: { code: 'FORBIDDEN', message: 'Access denied' }, - }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - RATE_LIMIT, - 'user-1', - 'workspace-1', - 'read' - ) - expect(dbChainMockFns.select).not.toHaveBeenCalled() - }) - - it('treats the auth-disabled principal like a session principal for workspace access', async () => { - mockEnvFlags.isAuthDisabled = true - queueTableRows(schemaMock.copilotChats, []) - - const response = await callList() - - expect(response.status).toBe(200) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.objectContaining({ keyType: undefined }), - 'user-1', - 'workspace-1', - 'read' - ) - }) - - it('bounds the SQL page, maps summaries, and derives active state from the live marker', async () => { - queueTableRows(schemaMock.copilotChats, [ - buildChat(), - buildChat({ - id: 'chat-2', - title: null, - updatedAt: new Date('2026-08-06T12:00:00.000Z'), - pinned: false, - activeStreamId: 'stream-live', - }), - buildChat({ id: 'chat-3' }), - ]) - mockReconcileChatStreamMarkers.mockResolvedValueOnce( - new Map([ - ['chat-1', { chatId: 'chat-1', streamId: null, status: 'inactive' }], - ['chat-2', { chatId: 'chat-2', streamId: 'stream-live', status: 'active' }], - ]) - ) - - const response = await callList('workspaceId=workspace-1&limit=2') - const body = await response.json() - - expect(response.status).toBe(200) - expect(body.data).toEqual([ - { - id: 'chat-1', - title: 'Release plan', - updatedAt: '2026-08-07T12:00:00.000Z', - pinned: true, - active: false, - }, - { - id: 'chat-2', - title: null, - updatedAt: '2026-08-06T12:00:00.000Z', - pinned: false, - active: true, - }, - ]) - expect(body.nextCursor).toEqual(expect.any(String)) - expect(dbChainMockFns.limit).toHaveBeenCalledWith(3) - expect(mockReconcileChatStreamMarkers).toHaveBeenCalledWith( - [ - { chatId: 'chat-1', streamId: 'stream-stale' }, - { chatId: 'chat-2', streamId: 'stream-live' }, - ], - { repairVerifiedStaleMarkers: true } - ) - }) - - it('replays its opaque cursor as a keyset bound', async () => { - queueTableRows(schemaMock.copilotChats, [buildChat(), buildChat({ id: 'chat-2' })]) - const first = await callList('workspaceId=workspace-1&limit=1') - const { nextCursor } = await first.json() - - queueTableRows(schemaMock.copilotChats, [ - buildChat({ - id: 'chat-2', - title: 'Older chat', - updatedAt: new Date('2026-08-06T12:00:00.000Z'), - pinned: false, - activeStreamId: null, - }), - ]) - const second = await callList( - `workspaceId=workspace-1&limit=1&cursor=${encodeURIComponent(nextCursor)}` - ) - - expect(second.status).toBe(200) - const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) - expect(conditions.some((condition) => condition?.type === 'or')).toBe(true) - }) - - it('rejects a malformed cursor instead of restarting at the first page', async () => { - const response = await callList('workspaceId=workspace-1&cursor=not-a-cursor') - - expect(response.status).toBe(400) - expect((await response.json()).error.message).toMatch(/cursor does not match/i) - expect(dbChainMockFns.select).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/v2/chats/route.ts b/apps/sim/app/api/v2/chats/route.ts deleted file mode 100644 index f713db70161..00000000000 --- a/apps/sim/app/api/v2/chats/route.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { db } from '@sim/db' -import { copilotChats } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { and, eq, isNull, sql } from 'drizzle-orm' -import type { NextRequest } from 'next/server' -import { type V2ChatSummary, v2ListChatsContract } from '@/lib/api/contracts/v2/chats' -import { - encodeKeyset, - keysetAfter, - keysetColumns, - listOrderBy, - numberKey, - searchFilter, - timestampKey, - uuidKey, -} from '@/lib/api/list-query' -import { parseRequest } from '@/lib/api/server' -import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' -import { isAuthDisabled } from '@/lib/core/config/env-flags' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - decodeSortedCursor, - encodeSortedCursor, - v2CursorList, - v2CursorSortError, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2ChatsAPI') -const CHAT_SORT = 'pinned:desc,updatedAt:desc' - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -type ChatRow = { - id: string - title: string | null - updatedAt: Date - pinned: boolean - activeStreamId: string | null -} - -const pinnedRank = sql<number>`case when ${copilotChats.pinned} then 1 else 0 end` -const CHAT_KEYS = [ - numberKey<ChatRow>(pinnedRank, (row) => (row.pinned ? 1 : 0)), - timestampKey<ChatRow>(copilotChats.updatedAt, (row) => row.updatedAt), - uuidKey<ChatRow>(copilotChats.id, (row) => row.id), -] - -/** GET /api/v2/chats — bounded personal chat history for the terminal picker. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'copilot-chat') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - // A workspace key can be held by people other than its creator. Its - // creator's UI chats are private and must never become shared-key data. - if (rateLimit.keyType === 'workspace') { - return v2Error('FORBIDDEN', 'Chat history requires a personal API key') - } - - const parsed = await parseRequest( - v2ListChatsContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, search, limit, cursor } = parsed.data.query - - const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit - const access = await resolveWorkspaceAccess(accessPrincipal, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const decoded = decodeSortedCursor(cursor, CHAT_SORT) - if (decoded.status === 'invalid') return v2CursorSortError() - const resumeAfter = - decoded.status === 'ok' ? keysetAfter(CHAT_KEYS, decoded.keys, 'desc') : undefined - if (resumeAfter === null) return v2CursorSortError() - - const rows = await db - .select({ - id: copilotChats.id, - title: copilotChats.title, - updatedAt: copilotChats.updatedAt, - pinned: copilotChats.pinned, - activeStreamId: copilotChats.conversationId, - }) - .from(copilotChats) - .where( - and( - eq(copilotChats.userId, userId), - eq(copilotChats.workspaceId, workspaceId), - eq(copilotChats.type, 'mothership'), - isNull(copilotChats.deletedAt), - searchFilter(copilotChats.title, search), - resumeAfter - ) - ) - .orderBy(...listOrderBy(keysetColumns(CHAT_KEYS), 'desc')) - .limit(limit + 1) - - const page = rows.slice(0, limit) - const streamMarkers = await reconcileChatStreamMarkers( - page.map((chat) => ({ chatId: chat.id, streamId: chat.activeStreamId })), - { repairVerifiedStaleMarkers: true } - ) - const data: V2ChatSummary[] = page.map((chat) => ({ - id: chat.id, - title: chat.title, - updatedAt: chat.updatedAt.toISOString(), - pinned: chat.pinned, - active: Boolean(streamMarkers.get(chat.id)?.streamId), - })) - - const last = page.at(-1) - const nextCursor = - rows.length > limit && last - ? encodeSortedCursor(CHAT_SORT, encodeKeyset(CHAT_KEYS, last)) - : null - - return v2CursorList(data, nextCursor, { rateLimit }) - } catch (error) { - logger.error('Failed to list v2 chats', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx index 7a4647f8bfd..3dde6afcb0f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx @@ -11,11 +11,15 @@ import { useState, } from 'react' import { + type DesktopAppearanceTheme, type DesktopZoomAction, type DesktopZoomPercent, resolveDesktopZoom, + TERMINAL_DARK_THEME, + TERMINAL_LIGHT_THEME, type TerminalAppearanceTheme, type TerminalShortcutCommand, + type TerminalThemePalette, type TerminalThemeProfile, } from '@sim/desktop-bridge' import { @@ -41,8 +45,7 @@ import { getDesktopBridge } from '@/lib/desktop' import { loadDesktopTerminalAppearance, loadDesktopTerminalThemeProfiles, - refreshSelectedTerminalProfile, - resolveTerminalThemePalette, + resolveDesktopAppearanceTheme, withSelectedProfile, } from '@/lib/desktop/appearance' import { trackPanelFocus } from '@/lib/desktop/panel-focus' @@ -311,7 +314,15 @@ const TerminalView = memo(function TerminalView({ defaultZoom: DesktopZoomPercent }) { const { resolvedTheme } = useTheme() - const terminalTheme = resolveTerminalThemePalette(appearanceTheme, resolvedTheme) + const profileTheme = typeof appearanceTheme === 'string' ? undefined : appearanceTheme + const builtInTheme: DesktopAppearanceTheme = + typeof appearanceTheme === 'string' ? appearanceTheme : 'app' + const colorScheme = resolveDesktopAppearanceTheme(builtInTheme, resolvedTheme) + const terminalTheme: TerminalThemePalette = profileTheme + ? profileTheme.palette + : colorScheme === 'dark' + ? TERMINAL_DARK_THEME + : TERMINAL_LIGHT_THEME const hostRef = useRef<HTMLDivElement>(null) const terminalRef = useRef<Terminal | null>(null) const fitRef = useRef<FitAddon | null>(null) @@ -745,20 +756,26 @@ export function TerminalSession({ visible, scopeId }: TerminalSessionProps) { ) useEffect(() => { - if (!visible) return let active = true - void Promise.all([loadDesktopTerminalAppearance(), loadDesktopTerminalThemeProfiles()]).then( - ([nextAppearance, nextProfiles]) => { - if (!active) return - setProfiles(nextProfiles) - setAppearanceTheme(refreshSelectedTerminalProfile(nextProfiles, nextAppearance.theme)) - setDefaultZoom(nextAppearance.defaultZoom) - } - ) + void loadDesktopTerminalAppearance().then((next) => { + if (!active) return + setAppearanceTheme(next.theme) + setDefaultZoom(next.defaultZoom) + }) return () => { active = false } - }, [visible]) + }, []) + + useEffect(() => { + let active = true + void loadDesktopTerminalThemeProfiles().then((next) => { + if (active) setProfiles(next) + }) + return () => { + active = false + } + }, []) useEffect(() => { let active = true diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 7faf2135da8..5c7684320dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -1,6 +1,6 @@ 'use client' -import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react' import { Button, PlayOutline, Skeleton, Tooltip, toast } from '@sim/emcn' import { Download, @@ -24,7 +24,6 @@ import { reportManualRunToolStop, } from '@/lib/copilot/tools/client/run-tool-execution' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' -import { prefersInPlaceNavigation } from '@/lib/desktop' import { triggerFileDownload } from '@/lib/uploads/client/download' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { @@ -74,25 +73,6 @@ const LOADING_SKELETON = ( </div> ) -/** - * Opens an internal app link the way the host expects: a new browser tab on the - * web, and the current view in the desktop app, whose shell would otherwise turn - * the same-origin `window.open` into a second Sim window. - */ -function useOpenInternalLink() { - const router = useRouter() - return useCallback( - (href: string) => { - if (prefersInPlaceNavigation()) { - router.push(href) - return - } - window.open(href, '_blank') - }, - [router] - ) -} - interface ResourceContentProps { workspaceId: string desktopScopeId: string @@ -370,7 +350,6 @@ interface EmbeddedWorkflowActionsProps { } export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWorkflowActionsProps) { - const openInternalLink = useOpenInternalLink() const { navigateToSettings } = useSettingsNavigation() const { data: session } = useSession() const hostContext = useWorkspaceHostContext() @@ -425,7 +404,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor } const handleOpenWorkflow = () => { - openInternalLink(`/workspace/${workspaceId}/w/${workflowId}`) + window.open(`/workspace/${workspaceId}/w/${workflowId}`, '_blank') } return ( @@ -748,7 +727,6 @@ interface EmbeddedFolderProps { } function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) { - const openInternalLink = useOpenInternalLink() const { data: folderList, isPending: isFoldersPending } = useFolders(workspaceId) const { data: workflowList = [] } = useWorkflows(workspaceId) @@ -782,7 +760,7 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) { <button key={w.id} type='button' - onClick={() => openInternalLink(`/workspace/${workspaceId}/w/${w.id}`)} + onClick={() => window.open(`/workspace/${workspaceId}/w/${w.id}`, '_blank')} className='flex items-center gap-2 rounded-[6px] px-3 py-2 text-left transition-colors hover:bg-[var(--surface-4)]' > <WorkflowIcon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' /> diff --git a/apps/sim/lib/api/contracts/v2/chat-runs.test.ts b/apps/sim/lib/api/contracts/v2/chat-runs.test.ts deleted file mode 100644 index ff9cc3f27bf..00000000000 --- a/apps/sim/lib/api/contracts/v2/chat-runs.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - v2ChatRunDetailSchema, - v2ChatRunParamsSchema, - v2GetChatRunQuerySchema, - v2ListChatRunsQuerySchema, -} from '@/lib/api/contracts/v2/chat-runs' - -describe('v2ListChatRunsQuerySchema', () => { - it('defaults and clamps its bounded page size', () => { - expect(v2ListChatRunsQuerySchema.parse({ workspaceId: 'workspace-1' }).limit).toBe(30) - expect(v2ListChatRunsQuerySchema.parse({ workspaceId: 'workspace-1', limit: '0' }).limit).toBe( - 1 - ) - expect( - v2ListChatRunsQuerySchema.parse({ workspaceId: 'workspace-1', limit: '999' }).limit - ).toBe(100) - }) - - it('accepts only durable run statuses and a non-empty cursor', () => { - expect( - v2ListChatRunsQuerySchema.parse({ workspaceId: 'workspace-1', status: 'resuming' }).status - ).toBe('resuming') - expect( - v2ListChatRunsQuerySchema.safeParse({ workspaceId: 'workspace-1', status: 'running' }).success - ).toBe(false) - expect( - v2ListChatRunsQuerySchema.safeParse({ workspaceId: 'workspace-1', cursor: '' }).success - ).toBe(false) - }) -}) - -describe('v2GetChatRunQuerySchema', () => { - it('rejects unknown query fields', () => { - expect( - v2GetChatRunQuerySchema.safeParse({ workspaceId: 'workspace-1', continuationToken: 'secret' }) - .success - ).toBe(false) - }) - - it('rejects a malformed run UUID before it reaches Postgres', () => { - expect(v2ChatRunParamsSchema.safeParse({ runId: 'not-a-uuid' }).success).toBe(false) - }) -}) - -describe('v2ChatRunDetailSchema', () => { - it('strips private replay fields from the public projection', () => { - const parsed = v2ChatRunDetailSchema.parse({ - runId: '4bfa6f89-b746-43be-8246-bf1c69b58593', - chatId: '80a47295-040e-46f9-9ea8-ad78eff3bcab', - chatTitle: 'Release plan', - status: 'complete', - startedAt: '2026-08-08T12:00:00.000Z', - completedAt: '2026-08-08T12:01:00.000Z', - response: 'Done', - activities: [ - { - kind: 'tool', - id: 'tool-1', - label: 'Read file', - state: 'complete', - arguments: { secret: 'private' }, - result: 'private', - }, - ], - continuationToken: 'private', - error: 'private', - }) - - expect(parsed).toEqual({ - runId: '4bfa6f89-b746-43be-8246-bf1c69b58593', - chatId: '80a47295-040e-46f9-9ea8-ad78eff3bcab', - chatTitle: 'Release plan', - status: 'complete', - startedAt: '2026-08-08T12:00:00.000Z', - completedAt: '2026-08-08T12:01:00.000Z', - response: 'Done', - activities: [{ kind: 'tool', id: 'tool-1', label: 'Read file', state: 'complete' }], - }) - }) -}) diff --git a/apps/sim/lib/api/contracts/v2/chat-runs.ts b/apps/sim/lib/api/contracts/v2/chat-runs.ts deleted file mode 100644 index 1eaf364b0a4..00000000000 --- a/apps/sim/lib/api/contracts/v2/chat-runs.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { z } from 'zod' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' -import { defineRouteContract } from '@/lib/api/contracts/types' -import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' - -export const v2ChatRunStatusSchema = z.enum([ - 'active', - 'paused_waiting_for_tool', - 'resuming', - 'complete', - 'error', - 'cancelled', -]) - -export type V2ChatRunStatus = z.output<typeof v2ChatRunStatusSchema> - -/** Safe, durable metadata for one root Mothership chat run. */ -export const v2ChatRunSummarySchema = z.object({ - runId: z.string().uuid(), - chatId: z.string().uuid(), - chatTitle: z.string().nullable(), - status: v2ChatRunStatusSchema, - startedAt: z.string().datetime(), - completedAt: z.string().datetime().nullable(), -}) - -export type V2ChatRunSummary = z.output<typeof v2ChatRunSummarySchema> - -const v2ChatRunActivityStateSchema = z.enum(['running', 'complete', 'error']) - -export const v2ChatRunActivitySchema = z.discriminatedUnion('kind', [ - z.object({ - kind: z.enum(['subagent', 'tool']), - id: z.string().min(1), - parentId: z.string().min(1).optional(), - label: z.string(), - state: v2ChatRunActivityStateSchema, - }), - z.object({ - kind: z.literal('narration'), - parentId: z.string().min(1), - delta: z.string(), - }), -]) - -export type V2ChatRunActivity = z.output<typeof v2ChatRunActivitySchema> - -export const v2ChatRunDetailSchema = v2ChatRunSummarySchema.extend({ - /** Accumulated root-assistant response; empty until public text is available. */ - response: z.string(), - /** Chronological, display-safe activity updates projected from replay. */ - activities: z.array(v2ChatRunActivitySchema), -}) - -export type V2ChatRunDetail = z.output<typeof v2ChatRunDetailSchema> - -export const v2ListChatRunsQuerySchema = z - .object({ - workspaceId: workspaceIdSchema, - status: v2ChatRunStatusSchema.optional(), - limit: z.coerce - .number() - .optional() - .default(30) - .transform((value) => Math.min(Math.max(1, Math.trunc(value)), 100)), - cursor: z.string().min(1).optional(), - }) - .strict() - -export type V2ListChatRunsQuery = z.output<typeof v2ListChatRunsQuerySchema> - -export const v2ChatRunParamsSchema = z.object({ runId: z.string().uuid() }).strict() - -export const v2GetChatRunQuerySchema = z.object({ workspaceId: workspaceIdSchema }).strict() - -export const v2ListChatRunsContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/chat/runs', - query: v2ListChatRunsQuerySchema, - response: { mode: 'json', schema: v2CursorListResponse(v2ChatRunSummarySchema) }, -}) - -export const v2GetChatRunContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/chat/runs/[runId]', - params: v2ChatRunParamsSchema, - query: v2GetChatRunQuerySchema, - response: { mode: 'json', schema: v2DataResponse(v2ChatRunDetailSchema) }, -}) diff --git a/apps/sim/lib/api/contracts/v2/chat.test.ts b/apps/sim/lib/api/contracts/v2/chat.test.ts deleted file mode 100644 index 81dbedc13b9..00000000000 --- a/apps/sim/lib/api/contracts/v2/chat.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - MAX_V2_CHAT_ATTACHMENTS, - MAX_V2_CHAT_CONTEXTS, - MAX_V2_CHAT_PROMPT_LENGTH, - v2ChatBodySchema, -} from '@/lib/api/contracts/v2/chat' - -describe('v2ChatBodySchema', () => { - it('enforces the prompt limit in UTF-8 bytes', () => { - const overLimit = 'é'.repeat(MAX_V2_CHAT_PROMPT_LENGTH / 2 + 1) - - const result = v2ChatBodySchema.safeParse({ - workspaceId: 'workspace-1', - prompt: overLimit, - }) - - expect(result.success).toBe(false) - if (!result.success) { - expect(result.error.issues[0]?.message).toBe('Prompt cannot exceed 10 MiB') - } - }) - - it('accepts an opaque continuation token and inline base64 attachment', () => { - expect( - v2ChatBodySchema.parse({ - workspaceId: 'workspace-1', - prompt: 'Read this', - continuationToken: 'opaque-token', - attachments: [{ name: 'Notes.MD', mediaType: 'TEXT/MARKDOWN', data: 'aGk=' }], - }) - ).toEqual({ - workspaceId: 'workspace-1', - prompt: 'Read this', - continuationToken: 'opaque-token', - readOnly: false, - async: false, - persistChat: true, - attachments: [{ name: 'Notes.MD', mediaType: 'text/markdown', data: 'aGk=' }], - }) - }) - - it('accepts explicit asynchronous execution', () => { - expect( - v2ChatBodySchema.parse({ - workspaceId: 'workspace-1', - prompt: 'Run this in the background', - async: true, - }).async - ).toBe(true) - }) - - it('accepts only the identity-bearing contexts supported by public resource lists', () => { - const contexts = [ - { kind: 'workflow', workflowId: 'workflow-1', label: 'Release' }, - { kind: 'table', tableId: 'table-1', label: 'Leads' }, - { kind: 'file', fileId: 'file-1', label: 'Brief.md' }, - { kind: 'knowledge', knowledgeId: 'kb-1', label: 'Handbook' }, - { kind: 'logs', executionId: 'execution-1', label: 'Release log' }, - { kind: 'skill', skillId: 'skill-1', label: 'review' }, - { kind: 'mcp', serverId: 'mcp-1', label: 'Docs' }, - ] - - expect( - v2ChatBodySchema.parse({ workspaceId: 'workspace-1', prompt: 'Use these', contexts }).contexts - ).toEqual(contexts) - expect( - v2ChatBodySchema.safeParse({ - workspaceId: 'workspace-1', - prompt: 'Use this', - contexts: [{ kind: 'folder', folderId: 'folder-1', label: 'Folder' }], - }).success - ).toBe(false) - expect( - v2ChatBodySchema.safeParse({ - workspaceId: 'workspace-1', - prompt: 'Use these', - contexts: Array.from({ length: MAX_V2_CHAT_CONTEXTS + 1 }, (_, index) => ({ - kind: 'skill', - skillId: `skill-${index}`, - label: `skill-${index}`, - })), - }).success - ).toBe(false) - }) - - it('allows an attachment-only turn but still rejects an entirely empty turn', () => { - expect( - v2ChatBodySchema.safeParse({ - workspaceId: 'workspace-1', - prompt: ' ', - attachments: [{ name: 'image.png', mediaType: 'image/png', data: 'AAAA' }], - }).success - ).toBe(true) - expect(v2ChatBodySchema.safeParse({ workspaceId: 'workspace-1', prompt: ' ' }).success).toBe( - false - ) - }) - - it('accepts only file basenames and a bounded attachment count', () => { - for (const name of ['/tmp/secret.txt', '../secret.txt', 'folder\\secret.txt', 'bad\0.txt']) { - expect( - v2ChatBodySchema.safeParse({ - workspaceId: 'workspace-1', - prompt: 'Read this', - attachments: [{ name, mediaType: 'text/plain', data: 'aGk=' }], - }).success - ).toBe(false) - } - - expect( - v2ChatBodySchema.safeParse({ - workspaceId: 'workspace-1', - prompt: 'Read these', - attachments: Array.from({ length: MAX_V2_CHAT_ATTACHMENTS + 1 }, (_, index) => ({ - name: `${index}.txt`, - mediaType: 'text/plain', - data: 'aGk=', - })), - }).success - ).toBe(false) - }) - - it('continues to reject raw caller-controlled chat ids and attachment URLs or paths', () => { - for (const extra of [ - { chatId: 'raw-chat-id' }, - { conversationId: 'raw-chat-id' }, - { - attachments: [ - { - name: 'notes.txt', - mediaType: 'text/plain', - data: 'aGk=', - path: '/tmp/notes.txt', - }, - ], - }, - { - attachments: [ - { - name: 'notes.txt', - mediaType: 'text/plain', - data: 'aGk=', - url: 'https://example.com/notes.txt', - }, - ], - }, - ]) { - expect( - v2ChatBodySchema.safeParse({ - workspaceId: 'workspace-1', - prompt: 'hello', - ...extra, - }).success - ).toBe(false) - } - }) -}) diff --git a/apps/sim/lib/api/contracts/v2/chat.ts b/apps/sim/lib/api/contracts/v2/chat.ts deleted file mode 100644 index 14657ec7007..00000000000 --- a/apps/sim/lib/api/contracts/v2/chat.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { z } from 'zod' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' -import { defineRouteContract } from '@/lib/api/contracts/types' - -/** Bounds both non-interactive output and persistent interactive CLI chat. */ -export const MAX_V2_CHAT_PROMPT_LENGTH = 10 * 1024 * 1024 -export const MAX_V2_CHAT_ATTACHMENTS = 5 -export const MAX_V2_CHAT_ATTACHMENT_BYTES = 5 * 1024 * 1024 -export const MAX_V2_CHAT_TEXT_ATTACHMENT_BYTES = 200 * 1024 -export const MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES = 10 * 1024 * 1024 -export const MAX_V2_CHAT_ATTACHMENT_NAME_LENGTH = 255 -export const MAX_V2_CHAT_CONTINUATION_TOKEN_LENGTH = 4096 -export const MAX_V2_CHAT_CONTEXTS = 50 -export const MAX_V2_CHAT_CONTEXT_LABEL_LENGTH = 255 -export const MAX_V2_CHAT_CONTEXT_ID_LENGTH = 255 -/** Prevent small compressed inputs from expanding into unbounded image allocations. */ -export const MAX_V2_CHAT_IMAGE_DIMENSION = 8192 -/** Caps one 4-byte decoded image surface at roughly 64 MiB before resize overhead. */ -export const MAX_V2_CHAT_IMAGE_PIXELS = 16_000_000 -/** Caps all decoded image surfaces in one request at roughly 128 MiB. */ -export const MAX_V2_CHAT_IMAGES_TOTAL_PIXELS = 32_000_000 - -const MAX_V2_CHAT_ATTACHMENT_BASE64_LENGTH = Math.ceil(MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES / 3) * 4 -const MAX_V2_CHAT_JSON_OVERHEAD_BYTES = 64 * 1024 - -/** - * A prompt byte may occupy six transport bytes as a JSON `\u00XX` escape; - * attachment base64 is already ASCII. This cap is deliberately a transport - * bound, while the decoded prompt/file limits are enforced below and at the - * route's attachment-validation boundary. - */ -export const MAX_V2_CHAT_BODY_BYTES = - MAX_V2_CHAT_PROMPT_LENGTH * 6 + - MAX_V2_CHAT_ATTACHMENT_BASE64_LENGTH + - MAX_V2_CHAT_JSON_OVERHEAD_BYTES - -export const V2_CHAT_IMAGE_MEDIA_TYPES = [ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/webp', -] as const - -export const V2_CHAT_TEXT_MEDIA_TYPES = [ - 'text/plain', - 'text/markdown', - 'text/csv', - 'text/tab-separated-values', - 'text/html', - 'text/css', - 'text/javascript', - 'text/typescript', - 'text/xml', - 'text/yaml', - 'application/json', - 'application/jsonl', - 'application/x-ndjson', - 'application/xml', - 'application/yaml', - 'application/x-yaml', - 'application/toml', -] as const - -export const V2_CHAT_DOCUMENT_MEDIA_TYPES = ['application/pdf'] as const - -const v2ChatContextIdSchema = z.string().trim().min(1).max(MAX_V2_CHAT_CONTEXT_ID_LENGTH) -const v2ChatContextLabelSchema = z.string().trim().min(1).max(MAX_V2_CHAT_CONTEXT_LABEL_LENGTH) - -/** - * Identity-bearing tags supported by the public CLI surface. The home client - * uses the same context kinds; this deliberately exposes only resources whose - * stable ids are already available from public v2 list endpoints. - */ -export const v2ChatContextSchema = z.discriminatedUnion('kind', [ - z - .object({ - kind: z.literal('workflow'), - workflowId: v2ChatContextIdSchema, - label: v2ChatContextLabelSchema, - }) - .strict(), - z - .object({ - kind: z.literal('table'), - tableId: v2ChatContextIdSchema, - label: v2ChatContextLabelSchema, - }) - .strict(), - z - .object({ - kind: z.literal('file'), - fileId: v2ChatContextIdSchema, - label: v2ChatContextLabelSchema, - }) - .strict(), - z - .object({ - kind: z.literal('knowledge'), - knowledgeId: v2ChatContextIdSchema, - label: v2ChatContextLabelSchema, - }) - .strict(), - z - .object({ - kind: z.literal('logs'), - executionId: v2ChatContextIdSchema, - label: v2ChatContextLabelSchema, - }) - .strict(), - z - .object({ - kind: z.literal('skill'), - skillId: v2ChatContextIdSchema, - label: v2ChatContextLabelSchema, - }) - .strict(), - z - .object({ - kind: z.literal('mcp'), - serverId: v2ChatContextIdSchema, - label: v2ChatContextLabelSchema, - }) - .strict(), -]) - -export type V2ChatContext = z.output<typeof v2ChatContextSchema> - -const textEncoder = new TextEncoder() - -const v2ChatAttachmentSchema = z - .object({ - // Basenames only: local paths belong to the CLI process and must never - // cross the API boundary. - name: z - .string() - .trim() - .min(1, 'Attachment name is required') - .max(MAX_V2_CHAT_ATTACHMENT_NAME_LENGTH) - .refine( - (value) => value !== '.' && value !== '..' && !/[\\/\u0000-\u001f\u007f]/.test(value), - 'Attachment name must be a file basename' - ), - mediaType: z - .string() - .trim() - .toLowerCase() - .max(127) - .regex( - /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/, - 'Attachment mediaType must be a MIME type without parameters' - ), - // Semantic validation performs strict canonical-base64 decoding, byte - // sniffing, and the type-specific decoded limits after workspace auth. - data: z.string().min(4).max(MAX_V2_CHAT_ATTACHMENT_BASE64_LENGTH), - }) - .strict() - -export type V2ChatAttachment = z.output<typeof v2ChatAttachmentSchema> - -export const v2ChatBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - prompt: z - .string() - .max(MAX_V2_CHAT_PROMPT_LENGTH, 'Prompt cannot exceed 10 MiB') - .refine( - (value) => textEncoder.encode(value).byteLength <= MAX_V2_CHAT_PROMPT_LENGTH, - 'Prompt cannot exceed 10 MiB' - ), - continuationToken: z.string().min(1).max(MAX_V2_CHAT_CONTINUATION_TOKEN_LENGTH).optional(), - /** Normal Mothership is the default; this explicitly selects its read-only projection. */ - readOnly: z.boolean().optional().default(false), - /** - * Keep an accepted persisted turn running after the caller disconnects. - * The route requires a personal API key and `persistChat: true` so it can - * return a durable run id that another client can follow. - */ - async: z.boolean().optional().default(false), - /** - * Whether this turn may create a chat that shows up in the workspace's chat - * list. `false` matches the Mothership block: the turn still gets a chat id - * and stays continuable through its token, but leaves no row behind for the - * UI or `sim chats list` to surface. Only suppresses *creating* a chat — a - * continuation token for an already-persisted chat keeps persisting. - */ - persistChat: z.boolean().optional().default(true), - attachments: z.array(v2ChatAttachmentSchema).max(MAX_V2_CHAT_ATTACHMENTS).optional(), - contexts: z.array(v2ChatContextSchema).max(MAX_V2_CHAT_CONTEXTS).optional(), - }) - .strict() - .superRefine((value, context) => { - if (!value.prompt.trim() && !value.attachments?.length) { - context.addIssue({ - code: 'custom', - path: ['prompt'], - message: 'Prompt or at least one attachment is required', - }) - } - }) -export type V2ChatBody = z.input<typeof v2ChatBodySchema> - -/** - * A normal workspace Mothership turn. Omit `continuationToken` for a one-shot - * or the first interactive turn; pass the latest server-issued token to - * continue the same private conversation. `readOnly` explicitly selects the - * secretless query projection. `async: true` keeps an accepted persisted turn - * running after disconnect. `persistChat: false` runs the turn without leaving - * a chat behind in the workspace's chat list. Successful responses are SSE so - * proxies stay alive during long agent turns and callers can cancel the run. - */ -export const v2ChatContract = defineRouteContract({ - method: 'POST', - path: '/api/v2/chat', - body: v2ChatBodySchema, - response: { mode: 'stream' }, -}) diff --git a/apps/sim/lib/api/contracts/v2/chats.test.ts b/apps/sim/lib/api/contracts/v2/chats.test.ts deleted file mode 100644 index e648db545a1..00000000000 --- a/apps/sim/lib/api/contracts/v2/chats.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - v2ChatDetailSchema, - v2GetChatQuerySchema, - v2ListChatsQuerySchema, - v2RenameChatBodySchema, -} from '@/lib/api/contracts/v2/chats' - -describe('v2ListChatsQuerySchema', () => { - it('defaults to a bounded page and clamps caller-provided limits', () => { - expect(v2ListChatsQuerySchema.parse({ workspaceId: 'workspace-1' }).limit).toBe(30) - expect(v2ListChatsQuerySchema.parse({ workspaceId: 'workspace-1', limit: '0' }).limit).toBe(1) - expect(v2ListChatsQuerySchema.parse({ workspaceId: 'workspace-1', limit: '500' }).limit).toBe( - 100 - ) - expect(v2ListChatsQuerySchema.parse({ workspaceId: 'workspace-1', limit: '2.9' }).limit).toBe(2) - }) - - it('rejects empty search and cursor values', () => { - expect( - v2ListChatsQuerySchema.safeParse({ workspaceId: 'workspace-1', search: '' }).success - ).toBe(false) - expect( - v2ListChatsQuerySchema.safeParse({ workspaceId: 'workspace-1', cursor: '' }).success - ).toBe(false) - }) -}) - -describe('v2GetChatQuerySchema', () => { - it('parses text booleans without treating "false" as truthy', () => { - expect(v2GetChatQuerySchema.parse({ workspaceId: 'workspace-1' }).readOnly).toBe(false) - expect( - v2GetChatQuerySchema.parse({ workspaceId: 'workspace-1', readOnly: false }).readOnly - ).toBe(false) - expect( - v2GetChatQuerySchema.parse({ workspaceId: 'workspace-1', readOnly: true }).readOnly - ).toBe(true) - expect( - v2GetChatQuerySchema.parse({ workspaceId: 'workspace-1', readOnly: 'false' }).readOnly - ).toBe(false) - expect( - v2GetChatQuerySchema.parse({ workspaceId: 'workspace-1', readOnly: 'true' }).readOnly - ).toBe(true) - }) -}) - -describe('v2ChatDetailSchema', () => { - it('accepts only the display-safe transcript projection', () => { - const detail = { - id: 'chat-1', - title: 'Release plan', - active: false, - continuationToken: 'opaque-token', - messages: [ - { - id: 'message-1', - role: 'assistant', - content: 'Ready', - timestamp: '2026-08-07T12:00:00.000Z', - contentBlocks: [{ type: 'tool', result: 'private' }], - }, - ], - } - - expect(v2ChatDetailSchema.parse(detail)).toEqual({ - ...detail, - messages: [ - { - id: 'message-1', - role: 'assistant', - content: 'Ready', - timestamp: '2026-08-07T12:00:00.000Z', - }, - ], - }) - }) -}) - -describe('v2RenameChatBodySchema', () => { - it('trims a bounded title and rejects empty or unknown input', () => { - expect( - v2RenameChatBodySchema.parse({ workspaceId: 'workspace-1', title: ' Release plan ' }) - ).toEqual({ workspaceId: 'workspace-1', title: 'Release plan' }) - expect( - v2RenameChatBodySchema.safeParse({ workspaceId: 'workspace-1', title: ' ' }).success - ).toBe(false) - expect( - v2RenameChatBodySchema.safeParse({ - workspaceId: 'workspace-1', - title: 'Release plan', - extra: true, - }).success - ).toBe(false) - expect( - v2RenameChatBodySchema.safeParse({ - workspaceId: 'workspace-1', - title: 'x'.repeat(201), - }).success - ).toBe(false) - }) -}) diff --git a/apps/sim/lib/api/contracts/v2/chats.ts b/apps/sim/lib/api/contracts/v2/chats.ts deleted file mode 100644 index 4a27771a919..00000000000 --- a/apps/sim/lib/api/contracts/v2/chats.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { z } from 'zod' -import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' -import { defineRouteContract } from '@/lib/api/contracts/types' -import { v2CursorListResponse, v2DataResponse, v2SearchSchema } from '@/lib/api/contracts/v2/shared' - -/** A bounded, display-safe chat summary for the public CLI history picker. */ -export const v2ChatSummarySchema = z.object({ - id: z.string().min(1), - title: z.string().nullable(), - updatedAt: z.string().datetime(), - pinned: z.boolean(), - /** True while another client owns the chat's single active response stream. */ - active: z.boolean(), -}) - -export type V2ChatSummary = z.output<typeof v2ChatSummarySchema> - -/** The intentionally small transcript shape needed to repaint a terminal chat. */ -export const v2ChatMessageSchema = z.object({ - id: z.string().min(1), - role: z.enum(['user', 'assistant']), - content: z.string(), - timestamp: z.string().datetime(), -}) - -export type V2ChatMessage = z.output<typeof v2ChatMessageSchema> - -export const v2ChatDetailSchema = z.object({ - id: z.string().min(1), - title: z.string().nullable(), - messages: z.array(v2ChatMessageSchema), - continuationToken: z.string().min(1), - active: z.boolean(), -}) - -export type V2ChatDetail = z.output<typeof v2ChatDetailSchema> - -export const v2RenameChatBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - title: z - .string() - .trim() - .min(1, 'Chat title is required') - .max(200, 'Chat title must be at most 200 characters'), - }) - .strict() - -export type V2RenameChatBody = z.input<typeof v2RenameChatBodySchema> - -export const v2RenamedChatSchema = z.object({ - id: z.string().min(1), - title: z.string().min(1).max(200), -}) - -export type V2RenamedChat = z.output<typeof v2RenamedChatSchema> - -/** - * Recent chats use their Home ordering (pinned first, then most recently - * updated) with a fixed keyset cursor. The modest default keeps `/chats` - * cheap even for workspaces with years of chat history. - */ -export const v2ListChatsQuerySchema = z - .object({ - workspaceId: workspaceIdSchema, - search: v2SearchSchema, - limit: z.coerce - .number() - .optional() - .default(30) - .transform((value) => Math.min(Math.max(1, Math.trunc(value)), 100)), - cursor: z.string().min(1).optional(), - }) - .strict() - -export type V2ListChatsQuery = z.output<typeof v2ListChatsQuerySchema> - -export const v2ChatParamsSchema = z.object({ chatId: z.string().min(1) }).strict() - -export const v2GetChatQuerySchema = z - .object({ - workspaceId: workspaceIdSchema, - readOnly: booleanQueryFlagSchema.optional().default(false), - }) - .strict() - -export const v2ListChatsContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/chats', - query: v2ListChatsQuerySchema, - response: { mode: 'json', schema: v2CursorListResponse(v2ChatSummarySchema) }, -}) - -export const v2GetChatContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/chats/[chatId]', - params: v2ChatParamsSchema, - query: v2GetChatQuerySchema, - response: { mode: 'json', schema: v2DataResponse(v2ChatDetailSchema) }, -}) - -export const v2RenameChatContract = defineRouteContract({ - method: 'PATCH', - path: '/api/v2/chats/[chatId]', - params: v2ChatParamsSchema, - body: v2RenameChatBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2RenamedChatSchema) }, -}) diff --git a/apps/sim/lib/api/list-query.test.ts b/apps/sim/lib/api/list-query.test.ts index 1351651d87b..0974e8673d0 100644 --- a/apps/sim/lib/api/list-query.test.ts +++ b/apps/sim/lib/api/list-query.test.ts @@ -21,7 +21,6 @@ import { searchFilter, textKey, timestampKey, - uuidKey, } from '@/lib/api/list-query' const thing = pgTable('thing', { @@ -140,13 +139,6 @@ describe('cursor key value validation', () => { expect(sizeKey.bind(Number.POSITIVE_INFINITY)).toBeNull() expect(sizeKey.bind(12)).not.toBeNull() }) - - it('rejects a malformed UUID before it reaches a UUID column comparison', () => { - const id = uuidKey<Row>(thing.id, (row) => row.id) - - expect(id.bind('not-a-uuid')).toBeNull() - expect(id.bind('4bfa6f89-b746-43be-8246-bf1c69b58593')).not.toBeNull() - }) }) describe('encodeKeyset / keysetColumns', () => { diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts index b105bcd68b9..480d94819c6 100644 --- a/apps/sim/lib/api/list-query.ts +++ b/apps/sim/lib/api/list-query.ts @@ -1,4 +1,3 @@ -import { isValidUuid } from '@sim/utils/id' import { and, asc, @@ -85,15 +84,6 @@ export function textKey<Row>(column: Column, read: (row: Row) => string): Keyset } } -/** A UUID key whose caller-controlled cursor value is validated before SQL binding. */ -export function uuidKey<Row>(column: Column, read: (row: Row) => string): KeysetKey<Row> { - return { - expr: column, - encode: read, - bind: (value) => (typeof value === 'string' && isValidUuid(value) ? sql`${value}` : null), - } -} - /** A numeric key — sizes, counts, manual positions. */ export function numberKey<Row>(column: SQLWrapper, read: (row: Row) => number): KeysetKey<Row> { return { diff --git a/apps/sim/lib/copilot/chat/api/run-presenters.ts b/apps/sim/lib/copilot/chat/api/run-presenters.ts deleted file mode 100644 index 54b86534bb1..00000000000 --- a/apps/sim/lib/copilot/chat/api/run-presenters.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { V2ChatRunSummary } from '@/lib/api/contracts/v2/chat-runs' -import type { PublicChatRunRow } from '@/lib/copilot/chat/public-runs' - -export function toPublicChatRunSummary(row: PublicChatRunRow): V2ChatRunSummary { - return { - runId: row.runId, - chatId: row.chatId, - chatTitle: row.chatTitle, - status: row.status, - startedAt: row.startedAt.toISOString(), - completedAt: row.completedAt?.toISOString() ?? null, - } -} diff --git a/apps/sim/lib/copilot/chat/api/run-route-policy.test.ts b/apps/sim/lib/copilot/chat/api/run-route-policy.test.ts deleted file mode 100644 index 5e696525fa0..00000000000 --- a/apps/sim/lib/copilot/chat/api/run-route-policy.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { v2ChatRunErrorPolicies } from '@/lib/copilot/chat/api/run-route-policy' -import { ChatRunProgressUnavailableError } from '@/lib/copilot/chat/application/errors' -import { - InsufficientWorkspacePermissionsError, - PersonalApiKeysDisabledError, - PrincipalKindAuthorizationError, -} from '@/lib/core/application' -import { OrchestrationError } from '@/lib/core/orchestration/types' - -describe('v2 chat run error policies', () => { - it('keeps the personal-key-only failure explicit', async () => { - const response = v2ChatRunErrorPolicies.default.render( - new PrincipalKindAuthorizationError('workspace_api_key', 'chat.runs.list') - ) - - expect(response?.status).toBe(403) - expect(await response?.json()).toEqual({ - error: { code: 'FORBIDDEN', message: 'Chat runs require a personal API key' }, - }) - }) - - it('conceals detail authorization and scoped misses as the same absence', async () => { - for (const error of [ - new InsufficientWorkspacePermissionsError(), - new OrchestrationError('not_found', 'Workspace not found'), - new OrchestrationError('not_found', 'Chat run not found'), - ]) { - const response = v2ChatRunErrorPolicies.detail.render(error) - expect(response?.status).toBe(404) - expect(await response?.json()).toEqual({ - error: { code: 'NOT_FOUND', message: 'Chat run not found' }, - }) - } - }) - - it('preserves workspace personal-key policy failures', async () => { - const response = v2ChatRunErrorPolicies.detail.render(new PersonalApiKeysDisabledError()) - expect(response?.status).toBe(403) - }) - - it('maps transient replay unavailability without masking infrastructure failures', async () => { - expect( - v2ChatRunErrorPolicies.detail.render(new ChatRunProgressUnavailableError())?.status - ).toBe(503) - expect(v2ChatRunErrorPolicies.detail.render(new Error('redis unavailable'))).toBeNull() - }) -}) diff --git a/apps/sim/lib/copilot/chat/api/run-route-policy.ts b/apps/sim/lib/copilot/chat/api/run-route-policy.ts deleted file mode 100644 index 9b4750f2b1e..00000000000 --- a/apps/sim/lib/copilot/chat/api/run-route-policy.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { V2ErrorPolicy } from '@/lib/api/server/routes' -import { ChatRunProgressUnavailableError } from '@/lib/copilot/chat/application/errors' -import { - InsufficientWorkspacePermissionsError, - PersonalApiKeysDisabledError, - PrincipalKindAuthorizationError, -} from '@/lib/core/application' -import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' - -const defaultPolicy = { - render(error) { - if (error instanceof PrincipalKindAuthorizationError) { - return v2Error('FORBIDDEN', 'Chat runs require a personal API key') - } - if (error instanceof ChatRunProgressUnavailableError) { - return v2Error('SERVICE_UNAVAILABLE', error.message) - } - return v2CaughtOrchestrationError(error) - }, -} satisfies V2ErrorPolicy - -export const v2ChatRunErrorPolicies = { - default: defaultPolicy, - detail: { - render(error) { - if ( - error instanceof PrincipalKindAuthorizationError || - error instanceof PersonalApiKeysDisabledError || - error instanceof ChatRunProgressUnavailableError - ) { - return defaultPolicy.render(error) - } - if (error instanceof InsufficientWorkspacePermissionsError) { - return v2Error('NOT_FOUND', 'Chat run not found') - } - const response = v2CaughtOrchestrationError(error) - return response?.status === 404 ? v2Error('NOT_FOUND', 'Chat run not found') : response - }, - } satisfies V2ErrorPolicy, -} as const diff --git a/apps/sim/lib/copilot/chat/application/errors.ts b/apps/sim/lib/copilot/chat/application/errors.ts deleted file mode 100644 index 1e48beec978..00000000000 --- a/apps/sim/lib/copilot/chat/application/errors.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class ChatRunProgressUnavailableError extends Error { - constructor() { - super('Chat run progress is temporarily unavailable') - this.name = 'ChatRunProgressUnavailableError' - } -} diff --git a/apps/sim/lib/copilot/chat/application/operations.ts b/apps/sim/lib/copilot/chat/application/operations.ts deleted file mode 100644 index 6f030410b51..00000000000 --- a/apps/sim/lib/copilot/chat/application/operations.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineWorkspaceOperation } from '@/lib/core/application' - -const PERSONAL_API_KEY_PRINCIPALS = ['personal_api_key'] as const - -export const chatOperations = { - listRuns: defineWorkspaceOperation({ - id: 'chat.runs.list', - minimumRole: 'read', - workspaceApiKey: 'deny', - principalKinds: PERSONAL_API_KEY_PRINCIPALS, - }), - readRun: defineWorkspaceOperation({ - id: 'chat.runs.read', - minimumRole: 'read', - workspaceApiKey: 'deny', - principalKinds: PERSONAL_API_KEY_PRINCIPALS, - }), -} as const diff --git a/apps/sim/lib/copilot/chat/application/runs.test.ts b/apps/sim/lib/copilot/chat/application/runs.test.ts deleted file mode 100644 index 1e9f92e95cf..00000000000 --- a/apps/sim/lib/copilot/chat/application/runs.test.ts +++ /dev/null @@ -1,420 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - loadWorkspace: vi.fn(), - resolvePermission: vi.fn(), - listRuns: vi.fn(), - getRun: vi.fn(), - getPersistedResponse: vi.fn(), - readEvents: vi.fn(), - updateRunStatus: vi.fn(), - recordAudit: vi.fn(), - envFlags: { isAuthDisabled: false }, -})) - -vi.mock('@/lib/core/config/env-flags', () => mocks.envFlags) - -vi.mock('@/lib/workspaces/application/workspace-context', () => ({ - loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, -})) - -vi.mock('@sim/platform-authz/workspace', () => ({ - permissionSatisfies: (permission: string | null, required: string) => - permission === 'admin' || permission === 'write' || permission === required, - resolveEffectiveWorkspacePermission: mocks.resolvePermission, -})) - -vi.mock('@/lib/copilot/chat/public-runs', () => ({ - listPublicChatRuns: mocks.listRuns, - getPublicChatRun: mocks.getRun, - getPersistedPublicChatRunResponse: mocks.getPersistedResponse, -})) - -vi.mock('@/lib/copilot/request/session', () => ({ - readEvents: mocks.readEvents, - eventToStreamEvent: (event: { type: string; payload: unknown; scope?: unknown }) => ({ - type: event.type, - payload: event.payload, - ...(event.scope ? { scope: event.scope } : {}), - }), -})) - -vi.mock('@/lib/copilot/async-runs/repository', () => ({ - updateRunStatus: mocks.updateRunStatus, -})) - -vi.mock('@/lib/copilot/headless/workspace-chat', () => ({ - publicChatUsageLimitMessage: (content: string) => - /^\s*<usage_upgrade>[\s\S]+<\/usage_upgrade>\s*$/.test(content) ? 'Usage limit exceeded' : null, -})) - -vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit })) - -import { ChatRunProgressUnavailableError } from '@/lib/copilot/chat/application/errors' -import { chatOperations } from '@/lib/copilot/chat/application/operations' -import { listChatRuns, readChatRun } from '@/lib/copilot/chat/application/runs' - -const workspaceContext = { - workspaceId: 'workspace-1', - workspaceOrganizationId: null, - allowPersonalApiKeys: true, - billedAccountUserId: 'billing-owner-1', -} -const personalPrincipal = { - kind: 'personal_api_key' as const, - userId: 'user-1', - keyId: 'key-1', -} -const run = { - runId: '4bfa6f89-b746-43be-8246-bf1c69b58593', - chatId: '80a47295-040e-46f9-9ea8-ad78eff3bcab', - chatTitle: 'Release plan', - streamId: 'stream-1', - status: 'complete' as const, - startedAt: new Date('2026-08-08T12:00:00.000Z'), - completedAt: new Date('2026-08-08T12:01:00.000Z'), -} - -function envelope(seq: number, type: string, payload: Record<string, unknown>) { - return { - v: 1, - seq, - ts: `2026-08-08T12:00:0${seq}.000Z`, - stream: { streamId: 'stream-1' }, - type, - payload, - } -} - -describe('chat run application operations', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.envFlags.isAuthDisabled = false - mocks.loadWorkspace.mockResolvedValue(workspaceContext) - mocks.resolvePermission.mockResolvedValue('read') - mocks.listRuns.mockResolvedValue({ status: 'ok', rows: [] }) - mocks.getRun.mockResolvedValue(run) - mocks.getPersistedResponse.mockResolvedValue(null) - mocks.readEvents.mockResolvedValue([ - envelope(1, 'session', { kind: 'chat', chatId: run.chatId }), - envelope(2, 'text', { channel: 'assistant', text: 'Done' }), - envelope(3, 'complete', { status: 'complete' }), - ]) - mocks.updateRunStatus.mockResolvedValue(null) - }) - - it('defines read-only personal-key operations', () => { - for (const operation of [chatOperations.listRuns, chatOperations.readRun]) { - expect(operation).toMatchObject({ - minimumRole: 'read', - workspaceApiKey: 'deny', - principalKinds: ['personal_api_key'], - }) - } - }) - - it('rejects workspace keys before canonical workspace or run loading', async () => { - await expect( - readChatRun.execute({ - principal: { - kind: 'workspace_api_key', - workspaceId: 'workspace-1', - keyId: 'key-1', - }, - input: { runId: run.runId, workspaceId: 'workspace-1' }, - }) - ).rejects.toMatchObject({ code: 'forbidden' }) - - expect(mocks.loadWorkspace).not.toHaveBeenCalled() - expect(mocks.getRun).not.toHaveBeenCalled() - }) - - it('enforces personal-key workspace policy before protected run reads', async () => { - mocks.loadWorkspace.mockResolvedValue({ ...workspaceContext, allowPersonalApiKeys: false }) - - await expect( - listChatRuns.execute({ - principal: personalPrincipal, - input: { workspaceId: 'workspace-1', limit: 30 }, - }) - ).rejects.toMatchObject({ code: 'forbidden' }) - await expect( - readChatRun.execute({ - principal: personalPrincipal, - input: { runId: run.runId, workspaceId: 'workspace-1' }, - }) - ).rejects.toMatchObject({ code: 'forbidden' }) - - expect(mocks.listRuns).not.toHaveBeenCalled() - expect(mocks.getRun).not.toHaveBeenCalled() - }) - - it('does not treat the auth-disabled self-host principal as a real personal key', async () => { - mocks.envFlags.isAuthDisabled = true - mocks.loadWorkspace.mockResolvedValue({ ...workspaceContext, allowPersonalApiKeys: false }) - - await listChatRuns.execute({ - principal: { ...personalPrincipal, keyId: 'auth-disabled' }, - input: { workspaceId: 'workspace-1', limit: 30 }, - }) - - expect(mocks.resolvePermission).toHaveBeenCalled() - expect(mocks.listRuns).toHaveBeenCalled() - }) - - it('requires current personal-key permission before protected run reads', async () => { - mocks.resolvePermission.mockResolvedValue(null) - - await expect( - listChatRuns.execute({ - principal: personalPrincipal, - input: { workspaceId: 'workspace-1', limit: 30 }, - }) - ).rejects.toMatchObject({ code: 'forbidden' }) - await expect( - readChatRun.execute({ - principal: personalPrincipal, - input: { runId: run.runId, workspaceId: 'workspace-1' }, - }) - ).rejects.toMatchObject({ code: 'forbidden' }) - - expect(mocks.listRuns).not.toHaveBeenCalled() - expect(mocks.getRun).not.toHaveBeenCalled() - }) - - it('authorizes the personal key before listing its owned runs', async () => { - mocks.listRuns.mockResolvedValue({ status: 'ok', rows: [run, { ...run }] }) - - const result = await listChatRuns.execute({ - principal: personalPrincipal, - input: { workspaceId: 'workspace-1', status: 'complete', limit: 1 }, - }) - - expect(mocks.resolvePermission).toHaveBeenCalled() - expect(mocks.listRuns).toHaveBeenCalledWith({ - userId: 'user-1', - workspaceId: 'workspace-1', - status: 'complete', - limit: 1, - cursorKeys: undefined, - }) - expect(result).toEqual({ rows: [run], hasMore: true }) - }) - - it('keeps malformed keyset cursors out of successful application results', async () => { - mocks.listRuns.mockResolvedValue({ status: 'invalid_cursor' }) - - await expect( - listChatRuns.execute({ - principal: personalPrincipal, - input: { workspaceId: 'workspace-1', limit: 30, cursorKeys: ['bad'] }, - }) - ).rejects.toMatchObject({ code: 'validation' }) - }) - - it('masks every scoped run miss as chat-run absence', async () => { - mocks.getRun.mockResolvedValue(null) - - await expect( - readChatRun.execute({ - principal: personalPrincipal, - input: { runId: run.runId, workspaceId: 'workspace-1' }, - }) - ).rejects.toMatchObject({ code: 'not_found', message: 'Chat run not found' }) - }) - - it('returns safe accumulated text and repairs stale terminal status from replay', async () => { - mocks.getRun.mockResolvedValue({ ...run, status: 'active', completedAt: null }) - - const result = await readChatRun.execute({ - principal: personalPrincipal, - input: { runId: run.runId, workspaceId: 'workspace-1' }, - }) - - expect(result).toMatchObject({ - status: 'complete', - completedAt: new Date('2026-08-08T12:00:03.000Z'), - response: 'Done', - activities: [], - }) - expect(mocks.updateRunStatus).toHaveBeenCalledWith(run.runId, 'complete', { - completedAt: new Date('2026-08-08T12:00:03.000Z'), - }) - }) - - it('projects replay into safe root text and opaque activities', async () => { - mocks.readEvents.mockResolvedValue([ - envelope(1, 'session', { kind: 'chat', chatId: run.chatId }), - envelope(2, 'text', { channel: 'assistant', text: 'Done' }), - envelope(3, 'text', { channel: 'thinking', text: 'private chain of thought' }), - envelope(4, 'tool', { - toolCallId: 'private-tool-id', - toolName: 'read', - phase: 'call', - arguments: { path: 'files/private.txt', secret: 'private-argument' }, - executor: 'go', - mode: 'sync', - }), - envelope(5, 'tool', { - toolCallId: 'private-tool-id', - toolName: 'read', - phase: 'result', - status: 'success', - success: true, - output: { secret: 'private-result' }, - }), - envelope(6, 'error', { code: 'PRIVATE', message: 'private-error' }), - envelope(7, 'complete', { status: 'complete', reason: 'private-reason' }), - ]) - - const result = await readChatRun.execute({ - principal: personalPrincipal, - input: { runId: run.runId, workspaceId: 'workspace-1' }, - }) - const progress = { response: result.response, activities: result.activities } - const serialized = JSON.stringify(progress) - - expect(progress).toEqual({ - response: 'Done', - activities: [ - { kind: 'tool', id: 'tool-1', label: 'Reading private.txt', state: 'running' }, - { kind: 'tool', id: 'tool-1', label: 'Read private.txt', state: 'complete' }, - ], - }) - for (const privateValue of [ - 'private-tool-id', - 'files/private.txt', - 'private-argument', - 'private-result', - 'private-error', - 'private-reason', - 'private chain of thought', - ]) { - expect(serialized).not.toContain(privateValue) - } - }) - - it('uses persisted assistant prose after a terminal replay expires', async () => { - mocks.readEvents.mockResolvedValue([]) - mocks.getPersistedResponse.mockResolvedValue('Stored answer') - - await expect( - readChatRun.execute({ - principal: personalPrincipal, - input: { runId: run.runId, workspaceId: 'workspace-1' }, - }) - ).resolves.toMatchObject({ response: 'Stored answer', activities: [] }) - }) - - it('uses persisted assistant prose when terminal replay has no root text', async () => { - mocks.getPersistedResponse.mockResolvedValue('Stored answer') - mocks.readEvents.mockResolvedValue([ - envelope(1, 'session', { kind: 'chat', chatId: run.chatId }), - envelope(2, 'complete', { status: 'complete' }), - ]) - - await expect( - readChatRun.execute({ - principal: personalPrincipal, - input: { runId: run.runId, workspaceId: 'workspace-1' }, - }) - ).resolves.toMatchObject({ response: 'Stored answer' }) - }) - - it('falls back to persisted text when terminal replay has a sequence gap', async () => { - mocks.getPersistedResponse.mockResolvedValue('Complete stored answer') - mocks.readEvents.mockResolvedValue([ - envelope(1, 'text', { channel: 'assistant', text: 'Truncated ' }), - envelope(3, 'complete', { status: 'complete' }), - ]) - - await expect( - readChatRun.execute({ - principal: personalPrincipal, - input: { runId: run.runId, workspaceId: 'workspace-1' }, - }) - ).resolves.toMatchObject({ - status: 'complete', - response: 'Complete stored answer', - activities: [], - }) - }) - - it('uses replay completion metadata when stale durable terminal state disagrees', async () => { - mocks.getRun.mockResolvedValue({ - ...run, - status: 'error', - completedAt: new Date('2026-08-08T11:59:00.000Z'), - }) - - await expect( - readChatRun.execute({ - principal: personalPrincipal, - input: { runId: run.runId, workspaceId: 'workspace-1' }, - }) - ).resolves.toMatchObject({ - status: 'complete', - completedAt: new Date('2026-08-08T12:00:03.000Z'), - response: 'Done', - }) - expect(mocks.updateRunStatus).toHaveBeenCalledWith(run.runId, 'complete', { - completedAt: new Date('2026-08-08T12:00:03.000Z'), - }) - }) - - it('reports missing or gapped active replay as transient', async () => { - mocks.getRun.mockResolvedValue({ ...run, status: 'active', completedAt: null }) - mocks.readEvents.mockResolvedValue([]) - - await expect( - readChatRun.execute({ - principal: personalPrincipal, - input: { runId: run.runId, workspaceId: 'workspace-1' }, - }) - ).rejects.toBeInstanceOf(ChatRunProgressUnavailableError) - }) - - it('does not regress an active run when its replay is gapped', async () => { - mocks.getRun.mockResolvedValue({ ...run, status: 'active', completedAt: null }) - mocks.readEvents.mockResolvedValue([ - envelope(1, 'text', { channel: 'assistant', text: 'Partial' }), - envelope(3, 'text', { channel: 'assistant', text: ' answer' }), - ]) - - await expect( - readChatRun.execute({ - principal: personalPrincipal, - input: { runId: run.runId, workspaceId: 'workspace-1' }, - }) - ).rejects.toBeInstanceOf(ChatRunProgressUnavailableError) - expect(mocks.getPersistedResponse).not.toHaveBeenCalled() - }) - - it('reports replay-store failures as transient only while a run is active', async () => { - mocks.getRun.mockResolvedValue({ ...run, status: 'active', completedAt: null }) - mocks.readEvents.mockRejectedValue(new Error('redis unavailable')) - - await expect( - readChatRun.execute({ - principal: personalPrincipal, - input: { runId: run.runId, workspaceId: 'workspace-1' }, - }) - ).rejects.toBeInstanceOf(ChatRunProgressUnavailableError) - expect(mocks.getPersistedResponse).not.toHaveBeenCalled() - }) - - it('propagates unexpected run-store failures unchanged', async () => { - const failure = new Error('database unavailable') - mocks.getRun.mockRejectedValueOnce(failure) - - await expect( - readChatRun.execute({ - principal: personalPrincipal, - input: { runId: run.runId, workspaceId: 'workspace-1' }, - }) - ).rejects.toBe(failure) - }) -}) diff --git a/apps/sim/lib/copilot/chat/application/runs.ts b/apps/sim/lib/copilot/chat/application/runs.ts deleted file mode 100644 index 1fbc4c4de7e..00000000000 --- a/apps/sim/lib/copilot/chat/application/runs.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' -import { updateRunStatus } from '@/lib/copilot/async-runs/repository' -import { ChatRunProgressUnavailableError } from '@/lib/copilot/chat/application/errors' -import { chatOperations } from '@/lib/copilot/chat/application/operations' -import { ChatActivityProjector, type V2ChatActivity } from '@/lib/copilot/chat/public-activity' -import { - getPersistedPublicChatRunResponse, - getPublicChatRun, - listPublicChatRuns, - type PublicChatRunRow, -} from '@/lib/copilot/chat/public-runs' -import { - MothershipStreamV1CompletionStatus, - MothershipStreamV1EventType, - MothershipStreamV1TextChannel, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { publicChatUsageLimitMessage } from '@/lib/copilot/headless/workspace-chat' -import { eventToStreamEvent, readEvents } from '@/lib/copilot/request/session' -import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' -import { isAuthDisabled } from '@/lib/core/config/env-flags' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' - -const logger = createLogger('CopilotChatRunsApplication') -const TERMINAL_RUN_STATUSES = new Set<PublicChatRunRow['status']>([ - 'complete', - 'error', - 'cancelled', -]) - -async function loadWorkspaceContext(workspaceId: string) { - const context = await loadActiveWorkspaceApplicationContext(workspaceId) - if (!context) throw new OrchestrationError('not_found', 'Workspace not found') - /** - * DISABLE_AUTH's synthetic principal is not a personal API key. Preserve the - * self-host policy while still requiring its current workspace permission. - */ - return isAuthDisabled ? { ...context, allowPersonalApiKeys: true } : context -} - -export interface ListChatRunsInput { - workspaceId: string - status?: PublicChatRunRow['status'] - limit: number - cursorKeys?: CursorKey[] -} - -export interface ListChatRunsResult { - rows: PublicChatRunRow[] - hasMore: boolean -} - -export const listChatRuns = defineAuthorizedWorkspaceUseCase({ - operation: chatOperations.listRuns, - resolveContext: ({ input }: { input: ListChatRunsInput }) => - loadWorkspaceContext(input.workspaceId), - authorizationOptions: {}, - execute: async ({ principal, input, context }): Promise<ListChatRunsResult> => { - const result = await listPublicChatRuns({ - userId: principal.userId, - workspaceId: context.workspaceId, - status: input.status, - limit: input.limit, - cursorKeys: input.cursorKeys, - }) - if (result.status === 'invalid_cursor') { - throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) - } - return { - rows: result.rows.slice(0, input.limit), - hasMore: result.rows.length > input.limit, - } - }, -}) - -interface PublicRunSnapshot { - response: string - activities: V2ChatActivity[] - replayStatus?: PublicChatRunRow['status'] - replayCompletedAt?: Date -} - -async function persistedFallback(run: PublicChatRunRow): Promise<string> { - const response = (await getPersistedPublicChatRunResponse(run.chatId, run.streamId)) ?? '' - return publicChatUsageLimitMessage(response) ? '' : response -} - -async function buildPublicRunSnapshot(run: PublicChatRunRow): Promise<PublicRunSnapshot | null> { - let envelopes - try { - envelopes = await readEvents(run.streamId, '0') - } catch (error) { - logger.warn('Failed to read chat run replay; using safe fallback', { - runId: run.runId, - error: getErrorMessage(error, 'Unknown error'), - }) - return TERMINAL_RUN_STATUSES.has(run.status) - ? { response: await persistedFallback(run), activities: [] } - : null - } - - if (envelopes.length === 0 || envelopes.some((envelope, index) => envelope.seq !== index + 1)) { - return TERMINAL_RUN_STATUSES.has(run.status) - ? { response: await persistedFallback(run), activities: [] } - : null - } - - const projector = new ChatActivityProjector() - const activities: V2ChatActivity[] = [] - const rootText: string[] = [] - let completionStatus: 'complete' | 'error' | undefined - let replayStatus: PublicChatRunRow['status'] | undefined - let replayCompletedAt: Date | undefined - - for (const envelope of envelopes) { - const event = eventToStreamEvent(envelope) - activities.push(...projector.project(event)) - - if ( - event.type === MothershipStreamV1EventType.text && - event.payload.channel === MothershipStreamV1TextChannel.assistant && - !event.scope && - event.payload.text && - !publicChatUsageLimitMessage(event.payload.text) - ) { - rootText.push(event.payload.text) - } - - if (event.type === MothershipStreamV1EventType.complete && !event.scope) { - replayStatus = - event.payload.status === MothershipStreamV1CompletionStatus.complete - ? 'complete' - : event.payload.status === MothershipStreamV1CompletionStatus.cancelled - ? 'cancelled' - : 'error' - replayCompletedAt = new Date(envelope.ts) - completionStatus = replayStatus === 'complete' ? 'complete' : 'error' - } - } - - if (!completionStatus && TERMINAL_RUN_STATUSES.has(run.status)) { - completionStatus = run.status === 'complete' ? 'complete' : 'error' - } - if (completionStatus) activities.push(...projector.finish(completionStatus)) - - const accumulated = rootText.join('') - const response = - accumulated.length === 0 && (replayStatus || TERMINAL_RUN_STATUSES.has(run.status)) - ? await persistedFallback(run) - : accumulated - return { - response: publicChatUsageLimitMessage(response) ? '' : response, - activities, - ...(replayStatus ? { replayStatus, replayCompletedAt } : {}), - } -} - -export interface ReadChatRunInput { - runId: string - workspaceId: string -} - -export interface ReadChatRunResult { - run: PublicChatRunRow - status: PublicChatRunRow['status'] - completedAt: Date | null - response: string - activities: V2ChatActivity[] -} - -export const readChatRun = defineAuthorizedWorkspaceUseCase({ - operation: chatOperations.readRun, - resolveContext: ({ input }: { input: ReadChatRunInput }) => - loadWorkspaceContext(input.workspaceId), - authorizationOptions: {}, - execute: async ({ principal, input, context }): Promise<ReadChatRunResult> => { - const run = await getPublicChatRun({ - runId: input.runId, - userId: principal.userId, - workspaceId: context.workspaceId, - }) - if (!run) throw new OrchestrationError('not_found', 'Chat run not found') - - const snapshot = await buildPublicRunSnapshot(run) - if (!snapshot) throw new ChatRunProgressUnavailableError() - - const status = snapshot.replayStatus ?? run.status - const completedAt = snapshot.replayCompletedAt ?? run.completedAt - if (snapshot.replayStatus && (run.status !== snapshot.replayStatus || !run.completedAt)) { - try { - await updateRunStatus(run.runId, snapshot.replayStatus, { - completedAt: snapshot.replayCompletedAt ?? new Date(), - }) - } catch (error) { - logger.warn('Failed to reconcile chat run status from terminal replay', { - runId: run.runId, - error: getErrorMessage(error, 'Unknown error'), - }) - } - } - - return { - run, - status, - completedAt, - response: snapshot.response, - activities: snapshot.activities, - } - }, -}) diff --git a/apps/sim/lib/copilot/chat/public-activity.test.ts b/apps/sim/lib/copilot/chat/public-activity.test.ts deleted file mode 100644 index 506d44a5e54..00000000000 --- a/apps/sim/lib/copilot/chat/public-activity.test.ts +++ /dev/null @@ -1,380 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it, vi } from 'vitest' -import { ChatActivityProjector } from '@/lib/copilot/chat/public-activity' -import type { MothershipStreamV1StreamScope } from '@/lib/copilot/generated/mothership-stream-v1' -import type { StreamEvent } from '@/lib/copilot/request/types' - -vi.mock('@/lib/copilot/tools/client/read-block', () => ({ - getReadTargetBlock: vi.fn((path: string | undefined) => - path?.startsWith('components/') ? { name: 'Gmail' } : undefined - ), -})) - -const call = (over: Record<string, unknown> = {}) => ({ - toolCallId: 'private-call-id', - toolName: 'read', - phase: 'call', - arguments: { secret: 'never-forward-me' }, - executor: 'go', - mode: 'sync', - ...over, -}) - -const result = (over: Record<string, unknown> = {}) => - call({ - phase: 'result', - success: true, - output: { secret: 'never-forward-me' }, - arguments: undefined, - ...over, - }) - -const tool = (payload: Record<string, unknown>, scope?: MothershipStreamV1StreamScope) => - ({ type: 'tool', payload, ...(scope ? { scope } : {}) }) as StreamEvent - -const span = ( - event: 'start' | 'end', - scope: MothershipStreamV1StreamScope, - over: Record<string, unknown> = {} -) => - ({ - type: 'span', - scope, - payload: { kind: 'subagent', event, agent: scope.agentId, ...over }, - }) as StreamEvent - -const text = ( - channel: 'assistant' | 'thinking', - value: string, - scope?: MothershipStreamV1StreamScope -) => - ({ - type: 'text', - payload: { channel, text: value }, - ...(scope ? { scope } : {}), - }) as StreamEvent - -const researchScope: MothershipStreamV1StreamScope = { - lane: 'subagent', - agentId: 'research', - parentToolCallId: 'private-dispatch-id', - spanId: 'private-research-span', - parentSpanId: 'main', -} - -describe('ChatActivityProjector', () => { - it('correlates a visible root call and result without exposing their raw payload', () => { - const projector = new ChatActivityProjector() - - const [running] = projector.project(tool(call())) - const [complete] = projector.project(tool(result())) - - expect(running).toEqual({ - kind: 'tool', - id: 'tool-1', - label: 'Reading file', - state: 'running', - }) - expect(complete).toEqual({ ...running, label: 'Read file', state: 'complete' }) - expect(JSON.stringify([running, complete])).not.toContain('private-call-id') - expect(JSON.stringify([running, complete])).not.toContain('never-forward-me') - }) - - it.each([ - ['workflows/forceful-arm/state.json', 'forceful-arm'], - ['components/blocks/gmail_v2.json', 'Gmail'], - ['components/integrations/gmail/send.json', 'Gmail'], - ])('uses the web read label for %s without forwarding arguments', (path, target) => { - const projector = new ChatActivityProjector() - const activities = [ - ...projector.project(tool(call({ arguments: { path, secret: 'never-forward-me' } }))), - ...projector.project(tool(result())), - ] - - expect(activities).toEqual([ - { kind: 'tool', id: 'tool-1', label: `Reading ${target}`, state: 'running' }, - { kind: 'tool', id: 'tool-1', label: `Read ${target}`, state: 'complete' }, - ]) - expect(JSON.stringify(activities)).not.toContain(path) - expect(JSON.stringify(activities)).not.toContain('never-forward-me') - }) - - it('maps failed and skipped terminal outcomes', () => { - const failed = new ChatActivityProjector() - failed.project(tool(call())) - expect(failed.project(tool(result({ success: false, error: 'private failure' })))).toEqual([ - expect.objectContaining({ label: 'Reading file', state: 'error' }), - ]) - - expect( - new ChatActivityProjector().project(tool(call({ status: 'skipped', success: false }))) - ).toEqual([expect.objectContaining({ label: 'Reading file', state: 'complete' })]) - - for (const status of ['cancelled', 'rejected']) { - const projector = new ChatActivityProjector() - projector.project(tool(call())) - expect(projector.project(tool(result({ status, success: true })))[0]).toMatchObject({ - state: 'error', - }) - } - }) - - it('waits for an authoritative call and holds an early result', () => { - const generating = new ChatActivityProjector() - expect(generating.project(tool(call({ partial: true, status: 'generating' })))).toEqual([]) - expect(generating.project(tool(call({ partial: false, status: 'executing' })))).toEqual([ - { - kind: 'tool', - id: 'tool-1', - label: 'Reading file', - state: 'running', - }, - ]) - - const reordered = new ChatActivityProjector() - expect(reordered.project(tool(result()))).toEqual([]) - expect(reordered.project(tool(call()))).toEqual([ - { - kind: 'tool', - id: 'tool-1', - label: 'Read file', - state: 'complete', - }, - ]) - }) - - it('suppresses hidden, internal, and internal-result calls without id gaps', () => { - const projector = new ChatActivityProjector() - - for (const payload of [ - call({ toolCallId: 'hidden', ui: { hidden: true } }), - call({ toolCallId: 'internal', ui: { internal: true } }), - call({ toolCallId: 'legacy', toolName: 'load_skill' }), - call({ - toolCallId: 'tool-result-read', - arguments: { path: 'internal/tool-results/private' }, - }), - ]) { - expect(projector.project(tool(payload))).toEqual([]) - } - - expect(projector.project(tool(call({ toolCallId: 'visible' })))[0]).toMatchObject({ - id: 'tool-1', - }) - }) - - it('provisions root and nested subagent lanes from dispatch calls before span start', () => { - const root = new ChatActivityProjector() - expect( - root.project(tool(call({ toolCallId: 'workflow-dispatch', toolName: 'workflow' }))) - ).toEqual([ - { - kind: 'subagent', - id: 'agent-1', - label: 'Workflow Agent', - state: 'running', - }, - ]) - const workflowScope = { - lane: 'subagent' as const, - agentId: 'workflow', - spanId: 'workflow-span', - parentSpanId: 'main', - parentToolCallId: 'workflow-dispatch', - } - expect(root.project(span('start', workflowScope))).toEqual([]) - - const nested = new ChatActivityProjector() - nested.project(span('start', researchScope)) - expect( - nested.project( - tool(call({ toolCallId: 'deploy-dispatch', toolName: 'deploy' }), researchScope) - ) - ).toEqual([ - { - kind: 'subagent', - id: 'agent-2', - parentId: 'agent-1', - label: 'Deploy Agent', - state: 'running', - }, - ]) - expect( - nested.project( - span('start', { - lane: 'subagent', - agentId: 'deploy', - spanId: 'deploy-span', - parentSpanId: researchScope.spanId, - parentToolCallId: 'deploy-dispatch', - }) - ) - ).toEqual([]) - }) - - it('projects subagent lifecycle, scoped tools, and narration as an opaque tree', () => { - const projector = new ChatActivityProjector() - const activities = [ - ...projector.project(span('start', researchScope)), - ...projector.project(tool(call({ toolCallId: 'private-child-tool' }), researchScope)), - ...projector.project(text('assistant', 'I found the answer.', researchScope)), - ...projector.project(text('thinking', 'private chain of thought', researchScope)), - // Sim/client tool results are synthesized without their original scope. - ...projector.project(tool(result({ toolCallId: 'private-child-tool' }))), - ...projector.project(span('end', researchScope)), - ] - - expect(activities).toEqual([ - { - kind: 'subagent', - id: 'agent-1', - label: 'Research Agent', - state: 'running', - }, - { - kind: 'tool', - id: 'tool-1', - parentId: 'agent-1', - label: 'Reading file', - state: 'running', - }, - { kind: 'narration', parentId: 'agent-1', delta: 'I found the answer.' }, - { - kind: 'tool', - id: 'tool-1', - parentId: 'agent-1', - label: 'Read file', - state: 'complete', - }, - { - kind: 'subagent', - id: 'agent-1', - label: 'Research Agent', - state: 'complete', - }, - ]) - const serialized = JSON.stringify(activities) - for (const privateValue of [ - 'private-child-tool', - 'private-dispatch-id', - 'private-research-span', - 'never-forward-me', - 'private chain of thought', - ]) { - expect(serialized).not.toContain(privateValue) - } - }) - - it('nests subagents by opaque span parent ids and keeps parallel same-name runs distinct', () => { - const projector = new ChatActivityProjector() - const parent = { ...researchScope, spanId: 'parent', parentToolCallId: 'parent-call' } - const child = { - ...researchScope, - spanId: 'child', - parentSpanId: 'parent', - parentToolCallId: 'child-call', - } - const sibling = { - ...researchScope, - spanId: 'sibling', - parentToolCallId: 'sibling-call', - } - - expect(projector.project(span('start', parent))).toEqual([ - expect.objectContaining({ id: 'agent-1', label: 'Research Agent' }), - ]) - expect(projector.project(span('start', child))).toEqual([ - expect.objectContaining({ id: 'agent-2', parentId: 'agent-1' }), - ]) - expect(projector.project(span('start', sibling))).toEqual([ - expect.objectContaining({ id: 'agent-3', label: 'Research Agent' }), - ]) - }) - - it('reconciles a pre-start lane to the authoritative agent without changing its id', () => { - const projector = new ChatActivityProjector() - const provisional = { ...researchScope, agentId: 'superagent' } - - expect(projector.project(text('assistant', 'Starting.', provisional))).toEqual([ - expect.objectContaining({ kind: 'subagent', id: 'agent-1', label: 'Superagent' }), - { kind: 'narration', parentId: 'agent-1', delta: 'Starting.' }, - ]) - expect(projector.project(span('start', provisional, { agent: 'file' }))).toEqual([ - expect.objectContaining({ kind: 'subagent', id: 'agent-1', label: 'File Agent' }), - ]) - }) - - it('keeps pending span ends open and exposes terminal errors without their details', () => { - const projector = new ChatActivityProjector() - projector.project(span('start', researchScope)) - - expect(projector.project(span('end', researchScope, { data: { pending: true } }))).toEqual([]) - const terminal = projector.project( - span('end', researchScope, { data: { error: 'private backend failure' } }) - ) - expect(terminal).toEqual([ - expect.objectContaining({ id: 'agent-1', state: 'error', label: 'Research Agent' }), - ]) - expect(JSON.stringify(terminal)).not.toContain('private backend failure') - }) - - it('settles open tools and agents, using past tense only on success', () => { - const successful = new ChatActivityProjector() - successful.project(span('start', researchScope)) - successful.project(tool(call(), researchScope)) - expect(successful.finish('complete')).toEqual([ - expect.objectContaining({ kind: 'tool', label: 'Read file', state: 'complete' }), - expect.objectContaining({ kind: 'subagent', state: 'complete' }), - ]) - expect(successful.finish('complete')).toEqual([]) - - const failed = new ChatActivityProjector() - failed.project(span('start', researchScope)) - failed.project(tool(call(), researchScope)) - expect(failed.finish('error')).toEqual([ - expect.objectContaining({ kind: 'tool', label: 'Reading file', state: 'error' }), - expect.objectContaining({ kind: 'subagent', state: 'error' }), - ]) - }) - - it('absorbs a workspace_file dispatch into its matching file subagent', () => { - const projector = new ChatActivityProjector() - const workspaceCall = call({ toolCallId: 'workspace-dispatch', toolName: 'workspace_file' }) - const fileScope = { - lane: 'subagent' as const, - agentId: 'file', - spanId: 'file-span', - parentSpanId: 'main', - parentToolCallId: 'workspace-dispatch', - } - - expect(projector.project(tool(workspaceCall))).toEqual([]) - expect( - projector.project( - tool(result({ toolCallId: 'workspace-dispatch', toolName: 'workspace_file' })) - ) - ).toEqual([]) - expect(projector.project(span('start', fileScope))).toEqual([ - { - kind: 'subagent', - id: 'agent-1', - label: 'File Agent', - state: 'running', - }, - ]) - expect(projector.project(tool(call({ toolCallId: 'visible-root' })))[0]).toMatchObject({ - id: 'tool-1', - }) - }) - - it('drops argument deltas, synthetic preview frames, and malformed events', () => { - const projector = new ChatActivityProjector() - - expect(projector.project(tool(call({ phase: 'args_delta' })))).toEqual([]) - expect(projector.project(tool(call({ phase: undefined })))).toEqual([]) - expect(projector.project(tool(call({ toolCallId: '' })))).toEqual([]) - expect(projector.project(tool(call({ toolName: undefined })))).toEqual([]) - }) -}) diff --git a/apps/sim/lib/copilot/chat/public-activity.ts b/apps/sim/lib/copilot/chat/public-activity.ts deleted file mode 100644 index 46e91e7c2b1..00000000000 --- a/apps/sim/lib/copilot/chat/public-activity.ts +++ /dev/null @@ -1,606 +0,0 @@ -import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome' -import { - MothershipStreamV1EventType, - MothershipStreamV1SpanLifecycleEvent, - MothershipStreamV1SpanPayloadKind, - type MothershipStreamV1StreamScope, - MothershipStreamV1TextChannel, - MothershipStreamV1ToolOutcome, - MothershipStreamV1ToolPhase, - MothershipStreamV1ToolStatus, -} from '@/lib/copilot/generated/mothership-stream-v1' -import type { StreamEvent } from '@/lib/copilot/request/types' -import { getToolEntry } from '@/lib/copilot/tool-executor/router' -import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' -import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' -import { getSubagentDisplayTitle } from '@/lib/copilot/tools/subagent-display' -import { getToolDisplayTitle, getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display' - -type ActivityState = 'running' | 'complete' | 'error' - -/** A display-safe node in the public v2 chat activity tree. */ -export interface V2ChatNodeActivity { - kind: 'subagent' | 'tool' - id: string - parentId?: string - label: string - state: ActivityState -} - -/** Display-safe assistant narration authored inside a subagent lane. */ -export interface V2ChatNarrationActivity { - kind: 'narration' - parentId: string - delta: string -} - -export type V2ChatActivity = V2ChatNodeActivity | V2ChatNarrationActivity - -interface ToolEventPayload { - toolCallId?: unknown - toolName?: unknown - arguments?: unknown - output?: unknown - partial?: unknown - phase?: unknown - status?: unknown - success?: unknown - ui?: { hidden?: unknown; internal?: unknown } | null -} - -interface ToolProjection { - id?: string - label?: string - parentId?: string - state?: ActivityState - status?: string - visibility: 'pending' | 'visible' | 'hidden' - pendingState?: ActivityState - pendingStatus?: string -} - -interface AgentProjection { - id: string - label: string - parentId?: string - state: ActivityState - emitted: boolean -} - -interface ProjectedToolState { - state: ActivityState - status: string -} - -interface DeferredWorkspaceFile { - call: ToolEventPayload - result?: ToolEventPayload -} - -const ERROR_STATUSES = new Set<string>([ - MothershipStreamV1ToolStatus.error, - MothershipStreamV1ToolStatus.cancelled, - MothershipStreamV1ToolStatus.rejected, -]) -const MAIN_SPAN = 'main' -const WORKSPACE_FILE_TOOL = 'workspace_file' -const FILE_SUBAGENT = 'file' - -/** - * Request-local projection of the private Mothership stream onto the public - * activity tree. Raw span/tool ids, argument/result objects, errors, and - * thinking never cross this boundary. Labels may summarize the same - * user-visible target or operation shown in Sim Home. - */ -export class ChatActivityProjector { - private readonly calls = new Map<string, ToolProjection>() - private readonly agentsByKey = new Map<string, AgentProjection>() - private readonly agents: AgentProjection[] = [] - private deferredWorkspaceFile?: DeferredWorkspaceFile - private nextToolId = 1 - private nextAgentId = 1 - - project(event: StreamEvent): V2ChatActivity[] { - const activities: V2ChatActivity[] = [] - - if (this.captureDeferredWorkspaceFileResult(event)) return activities - - const absorbsWorkspaceFile = this.absorbsDeferredWorkspaceFile(event) - if (this.deferredWorkspaceFile && !absorbsWorkspaceFile && this.breaksDeferral(event)) { - activities.push(...this.flushDeferredWorkspaceFile()) - } - if (absorbsWorkspaceFile) this.hideDeferredWorkspaceFile() - - if (this.deferWorkspaceFileCall(event)) return activities - - switch (event.type) { - case MothershipStreamV1EventType.span: - activities.push(...this.projectSpan(event.payload, event.scope)) - break - case MothershipStreamV1EventType.text: - activities.push(...this.projectText(event.payload, event.scope)) - break - case MothershipStreamV1EventType.tool: - activities.push(...this.projectTool(event.payload, event.scope)) - break - } - - return activities - } - - /** Settle every public row before the route sends its terminal envelope. */ - finish(outcome: 'complete' | 'error'): V2ChatActivity[] { - const activities: V2ChatActivity[] = [] - - if (this.deferredWorkspaceFile) { - const deferred = this.flushDeferredWorkspaceFile() - const last = deferred.at(-1) - // A deferred call was never visible. If it already completed, expose only - // its terminal snapshot; otherwise the normal settlement below closes it. - if (last?.kind === 'tool' && last.state !== 'running') activities.push(last) - } - - for (const projection of this.calls.values()) { - if ( - projection.visibility !== 'visible' || - !projection.id || - !projection.label || - projection.state !== 'running' - ) { - continue - } - activities.push( - this.toolActivity(projection, { - state: outcome === 'complete' ? 'complete' : 'error', - status: - outcome === 'complete' - ? MothershipStreamV1ToolOutcome.success - : MothershipStreamV1ToolOutcome.error, - }) - ) - } - - // Children close before their parents, matching the visible activity tree. - for (const agent of [...this.agents].reverse()) { - if (!agent.emitted || agent.state !== 'running') continue - agent.state = outcome - activities.push(this.agentActivity(agent)) - } - - return activities - } - - private projectSpan(payload: unknown, scope?: MothershipStreamV1StreamScope): V2ChatActivity[] { - const span = record(payload) - if (span?.kind !== MothershipStreamV1SpanPayloadKind.subagent) return [] - if ( - span.event !== MothershipStreamV1SpanLifecycleEvent.start && - span.event !== MothershipStreamV1SpanLifecycleEvent.end - ) { - return [] - } - - const data = record(span.data) - const triggerToolCallId = - stringValue(scope?.parentToolCallId) ?? - stringValue(data?.tool_call_id) ?? - stringValue(data?.toolCallId) - const authoritativeAgent = stringValue(span.agent) - const resolved = this.ensureAgent(scope, authoritativeAgent, triggerToolCallId, false) - if (!resolved) return [] - const { agent, changed } = resolved - - if (span.event === MothershipStreamV1SpanLifecycleEvent.start) { - const stateChanged = agent.state !== 'running' - agent.state = 'running' - if (!agent.emitted || changed || stateChanged) { - agent.emitted = true - return [this.agentActivity(agent)] - } - return [] - } - - // A checkpoint pause is resumable, not a completed subagent run. - if (data?.pending === true) return [] - agent.state = stringValue(data?.error) ? 'error' : 'complete' - agent.emitted = true - return [this.agentActivity(agent)] - } - - private projectText(payload: unknown, scope?: MothershipStreamV1StreamScope): V2ChatActivity[] { - const text = record(payload) - if ( - !scope || - text?.channel !== MothershipStreamV1TextChannel.assistant || - typeof text.text !== 'string' || - !text.text - ) { - return [] - } - - const resolved = this.ensureAgent(scope, undefined, undefined, true) - if (!resolved) return [] - return [ - ...resolved.activities, - { kind: 'narration', parentId: resolved.agent.id, delta: text.text }, - ] - } - - private projectTool(payload: unknown, scope?: MothershipStreamV1StreamScope): V2ChatActivity[] { - if (!payload || typeof payload !== 'object') return [] - const tool = payload as ToolEventPayload - if (tool.phase === MothershipStreamV1ToolPhase.args_delta) return [] - if ( - tool.phase !== MothershipStreamV1ToolPhase.call && - tool.phase !== MothershipStreamV1ToolPhase.result - ) { - return [] - } - - const callId = stringValue(tool.toolCallId) - const toolName = stringValue(tool.toolName) - if (!callId || !toolName) return [] - - const catalog = getToolEntry(toolName) - if (catalog?.route === 'subagent') { - this.calls.set(callId, { visibility: 'hidden' }) - if ( - tool.phase !== MothershipStreamV1ToolPhase.call || - tool.partial === true || - tool.status === MothershipStreamV1ToolStatus.generating - ) { - return [] - } - return this.projectSubagentDispatch(callId, catalog.subagentId ?? toolName, scope) - } - - const existing = this.calls.get(callId) - if (existing?.visibility === 'hidden') return [] - - if (this.isHidden(toolName, tool)) { - this.calls.set(callId, { visibility: 'hidden' }) - return [] - } - - const activities: V2ChatActivity[] = [] - let parentId = existing?.parentId - if (scope) { - const resolved = this.ensureAgent(scope, undefined, undefined, true) - if (!resolved) { - this.calls.set(callId, { visibility: 'hidden' }) - return [] - } - activities.push(...resolved.activities) - parentId = resolved.agent.id - } - - if (tool.phase === MothershipStreamV1ToolPhase.result) { - const projectedState = toolState(tool) - if (!existing || existing.visibility !== 'visible' || !existing.label) { - this.calls.set(callId, { - label: existing?.label, - parentId, - visibility: 'pending', - pendingState: projectedState.state, - pendingStatus: projectedState.status, - }) - return activities - } - existing.parentId ??= parentId - existing.pendingState = projectedState.state - existing.pendingStatus = projectedState.status - activities.push(this.toolActivity(existing, projectedState)) - return activities - } - - const projection = existing ?? { visibility: 'pending' as const } - const toolArguments = record(tool.arguments) - const resolvedReadTargetName = - toolName === 'read' ? getReadTargetBlock(stringValue(toolArguments?.path))?.name : undefined - projection.label = getToolDisplayTitle(toolName, toolArguments, resolvedReadTargetName) - projection.parentId ??= parentId - - // Generating calls can later resolve to a hidden/internal tool. Wait for - // the authoritative call so the terminal never paints an orphan row. - if (tool.partial === true || tool.status === MothershipStreamV1ToolStatus.generating) { - this.calls.set(callId, projection) - return activities - } - - projection.visibility = 'visible' - projection.id ??= this.publicToolId() - this.calls.set(callId, projection) - const projectedState = toolState(tool) - activities.push( - this.toolActivity(projection, { - state: projection.pendingState ?? projectedState.state, - status: projection.pendingStatus ?? projectedState.status, - }) - ) - return activities - } - - private ensureAgent( - scope: MothershipStreamV1StreamScope | undefined, - authoritativeAgent?: string, - triggerToolCallId?: string, - emit = true - ): - | { - agent: AgentProjection - activities: V2ChatActivity[] - changed: boolean - } - | undefined { - if (!scope || scope.lane !== 'subagent') return undefined - const spanId = stringValue(scope.spanId) - const triggerId = triggerToolCallId ?? stringValue(scope.parentToolCallId) - const spanKey = spanId ? `span:${spanId}` : undefined - const callKey = triggerId ? `call:${triggerId}` : undefined - if (!spanKey && !callKey) return undefined - - let agent = - (spanKey ? this.agentsByKey.get(spanKey) : undefined) ?? - (callKey ? this.agentsByKey.get(callKey) : undefined) - if (!agent) { - agent = { - id: this.publicAgentId(), - label: getSubagentDisplayTitle(authoritativeAgent ?? scope.agentId ?? ''), - parentId: this.parentAgentId(scope, spanId), - state: 'running', - emitted: false, - } - this.agents.push(agent) - } - if (spanKey) this.agentsByKey.set(spanKey, agent) - if (callKey) this.agentsByKey.set(callKey, agent) - - let changed = false - if (authoritativeAgent) { - const label = getSubagentDisplayTitle(authoritativeAgent) - if (label !== agent.label) { - agent.label = label - changed = true - } - } - const parentId = this.parentAgentId(scope, spanId) - if (parentId && parentId !== agent.parentId) { - agent.parentId = parentId - changed = true - } - - const activities: V2ChatActivity[] = [] - if (emit && (!agent.emitted || changed)) { - agent.emitted = true - activities.push(this.agentActivity(agent)) - } - return { agent, activities, changed } - } - - private projectSubagentDispatch( - callId: string, - agentId: string, - scope?: MothershipStreamV1StreamScope - ): V2ChatActivity[] { - const activities: V2ChatActivity[] = [] - let parentId: string | undefined - if (scope) { - const parent = this.ensureAgent(scope, undefined, undefined, true) - if (parent) { - activities.push(...parent.activities) - parentId = parent.agent.id - } - } - - const key = `call:${callId}` - let agent = this.agentsByKey.get(key) - const label = getSubagentDisplayTitle(agentId) - if (!agent) { - agent = { - id: this.publicAgentId(), - label, - ...(parentId ? { parentId } : {}), - state: 'running', - emitted: false, - } - this.agentsByKey.set(key, agent) - this.agents.push(agent) - } - const changed = agent.label !== label || (!!parentId && agent.parentId !== parentId) - agent.label = label - agent.parentId ??= parentId - agent.state = 'running' - if (!agent.emitted || changed) { - agent.emitted = true - activities.push(this.agentActivity(agent)) - } - return activities - } - - private parentAgentId( - scope: MothershipStreamV1StreamScope, - ownSpanId?: string - ): string | undefined { - const parentSpanId = stringValue(scope.parentSpanId) - if (!parentSpanId || parentSpanId === MAIN_SPAN || parentSpanId === ownSpanId) return undefined - const key = `span:${parentSpanId}` - let parent = this.agentsByKey.get(key) - if (!parent) { - parent = { - id: this.publicAgentId(), - label: getSubagentDisplayTitle(''), - state: 'running', - emitted: false, - } - this.agentsByKey.set(key, parent) - this.agents.push(parent) - } - return parent.id - } - - private isHidden(toolName: string, tool: ToolEventPayload): boolean { - const catalog = getToolEntry(toolName) - return ( - tool.ui?.hidden === true || - tool.ui?.internal === true || - catalog?.hidden === true || - catalog?.internal === true || - isToolHiddenInUi(toolName) || - (toolName === 'read' && - stringValue(record(tool.arguments)?.path)?.startsWith('internal/tool-results/') === true) - ) - } - - private deferWorkspaceFileCall(event: StreamEvent): boolean { - if (event.type !== MothershipStreamV1EventType.tool || event.scope) return false - const tool = event.payload as ToolEventPayload - if ( - tool.phase !== MothershipStreamV1ToolPhase.call || - tool.toolName !== WORKSPACE_FILE_TOOL || - tool.partial === true || - tool.status === MothershipStreamV1ToolStatus.generating || - this.isHidden(WORKSPACE_FILE_TOOL, tool) - ) { - return false - } - this.deferredWorkspaceFile = { call: tool } - return true - } - - private captureDeferredWorkspaceFileResult(event: StreamEvent): boolean { - const deferred = this.deferredWorkspaceFile - if (!deferred || event.type !== MothershipStreamV1EventType.tool) return false - const tool = event.payload as ToolEventPayload - if ( - tool.phase !== MothershipStreamV1ToolPhase.result || - tool.toolName !== WORKSPACE_FILE_TOOL || - tool.toolCallId !== deferred.call.toolCallId - ) { - return false - } - deferred.result = tool - return true - } - - private absorbsDeferredWorkspaceFile(event: StreamEvent): boolean { - const deferred = this.deferredWorkspaceFile - if ( - !deferred || - event.type !== MothershipStreamV1EventType.span || - event.payload.kind !== MothershipStreamV1SpanPayloadKind.subagent || - event.payload.event !== MothershipStreamV1SpanLifecycleEvent.start - ) { - return false - } - const data = record(event.payload.data) - const agent = stringValue(event.payload.agent) ?? stringValue(event.scope?.agentId) - const triggerId = - stringValue(event.scope?.parentToolCallId) ?? - stringValue(data?.tool_call_id) ?? - stringValue(data?.toolCallId) - return agent === FILE_SUBAGENT && triggerId === deferred.call.toolCallId - } - - private hideDeferredWorkspaceFile(): void { - const deferred = this.deferredWorkspaceFile - if (!deferred) return - const callId = stringValue(deferred.call.toolCallId) - if (callId) this.calls.set(callId, { visibility: 'hidden' }) - this.deferredWorkspaceFile = undefined - } - - private flushDeferredWorkspaceFile(): V2ChatActivity[] { - const deferred = this.deferredWorkspaceFile - if (!deferred) return [] - this.deferredWorkspaceFile = undefined - return [ - ...this.projectTool(deferred.call), - ...(deferred.result ? this.projectTool(deferred.result) : []), - ] - } - - private breaksDeferral(event: StreamEvent): boolean { - if (event.type === MothershipStreamV1EventType.tool) { - const tool = event.payload as ToolEventPayload - return tool.phase !== MothershipStreamV1ToolPhase.args_delta - } - if (event.type === MothershipStreamV1EventType.text) { - return ( - event.payload.channel === MothershipStreamV1TextChannel.assistant && !!event.payload.text - ) - } - if (event.type === MothershipStreamV1EventType.span) { - return event.payload.kind === MothershipStreamV1SpanPayloadKind.subagent - } - return ( - event.type === MothershipStreamV1EventType.error || - event.type === MothershipStreamV1EventType.complete - ) - } - - private publicToolId(): string { - return `tool-${this.nextToolId++}` - } - - private publicAgentId(): string { - return `agent-${this.nextAgentId++}` - } - - private agentActivity(agent: AgentProjection): V2ChatNodeActivity { - return { - kind: 'subagent', - id: agent.id, - ...(agent.parentId ? { parentId: agent.parentId } : {}), - label: agent.label, - state: agent.state, - } - } - - private toolActivity( - projection: ToolProjection, - projectedState: ProjectedToolState - ): V2ChatNodeActivity { - projection.state = projectedState.state - projection.status = projectedState.status - return { - kind: 'tool', - id: projection.id!, - ...(projection.parentId ? { parentId: projection.parentId } : {}), - label: getToolStatusDisplayTitle(projection.label!, projectedState.status), - state: projectedState.state, - } - } -} - -function record(value: unknown): Record<string, unknown> | undefined { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record<string, unknown>) - : undefined -} - -function stringValue(value: unknown): string | undefined { - return typeof value === 'string' && value ? value : undefined -} - -function toolState(tool: ToolEventPayload): ProjectedToolState { - if (tool.phase === MothershipStreamV1ToolPhase.result) { - const outcome = resolveStreamToolOutcome({ - output: tool.output, - ...(typeof tool.status === 'string' ? { status: tool.status } : {}), - ...(typeof tool.success === 'boolean' ? { success: tool.success } : {}), - }) - return { - state: - outcome === MothershipStreamV1ToolOutcome.success || - outcome === MothershipStreamV1ToolOutcome.skipped - ? 'complete' - : 'error', - status: outcome, - } - } - const status = typeof tool.status === 'string' ? tool.status : 'running' - if (tool.status === MothershipStreamV1ToolStatus.success) return { state: 'complete', status } - if (tool.status === MothershipStreamV1ToolStatus.skipped) return { state: 'complete', status } - if (ERROR_STATUSES.has(status)) return { state: 'error', status } - return { state: 'running', status } -} diff --git a/apps/sim/lib/copilot/chat/public-runs.test.ts b/apps/sim/lib/copilot/chat/public-runs.test.ts deleted file mode 100644 index 5bb65d0dca5..00000000000 --- a/apps/sim/lib/copilot/chat/public-runs.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * @vitest-environment node - */ -import { - dbChainMockFns, - flattenMockConditions, - queueTableRows, - resetDbChainMock, - schemaMock, -} from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - getPersistedPublicChatRunResponse, - getPublicChatRun, - listPublicChatRuns, -} from '@/lib/copilot/chat/public-runs' - -function assertOwnedRootMothershipScope(where: unknown, extraRight?: string) { - const conditions = flattenMockConditions(where) - const equalities = conditions.filter((condition) => condition.type === 'eq') - const nullChecks = conditions.filter((condition) => condition.type === 'isNull') - - // Both the run and joined chat are independently pinned to the caller. - expect(equalities.filter((condition) => condition.right === 'user-1')).toHaveLength(2) - expect(equalities.filter((condition) => condition.right === 'workspace-1')).toHaveLength(2) - expect(equalities).toContainEqual(expect.objectContaining({ left: 'type', right: 'mothership' })) - expect(nullChecks).toContainEqual(expect.objectContaining({ column: 'parentRunId' })) - expect(nullChecks).toContainEqual(expect.objectContaining({ column: 'deletedAt' })) - if (extraRight) { - expect(equalities).toContainEqual(expect.objectContaining({ right: extraRight })) - } - - expect(dbChainMockFns.innerJoin).toHaveBeenCalledWith(schemaMock.copilotChats, expect.anything()) -} - -describe('public chat run repository', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - }) - - it('scopes list queries to owned root runs in live Mothership chats', async () => { - queueTableRows(schemaMock.copilotRuns, []) - - await listPublicChatRuns({ - userId: 'user-1', - workspaceId: 'workspace-1', - status: 'active', - limit: 30, - }) - - assertOwnedRootMothershipScope(dbChainMockFns.where.mock.calls.at(-1)?.[0], 'active') - expect(dbChainMockFns.limit).toHaveBeenCalledWith(31) - }) - - it('uses the same masked scope for run detail lookups', async () => { - queueTableRows(schemaMock.copilotRuns, []) - - expect( - await getPublicChatRun({ - runId: 'run-private', - userId: 'user-1', - workspaceId: 'workspace-1', - }) - ).toBeNull() - - assertOwnedRootMothershipScope(dbChainMockFns.where.mock.calls.at(-1)?.[0], 'run-private') - expect(dbChainMockFns.limit).toHaveBeenCalledWith(1) - }) - - it('rejects a cursor with a malformed UUID before querying Postgres', async () => { - await expect( - listPublicChatRuns({ - userId: 'user-1', - workspaceId: 'workspace-1', - limit: 30, - cursorKeys: ['2026-08-08T12:00:00.000Z', 'not-a-uuid'], - }) - ).resolves.toEqual({ status: 'invalid_cursor' }) - - expect(dbChainMockFns.select).not.toHaveBeenCalled() - }) - - it('returns only assistant prose from persisted stream messages', async () => { - queueTableRows(schemaMock.copilotMessages, [ - { - content: { - id: 'assistant-1', - role: 'assistant', - content: 'Stored answer', - timestamp: '2026-08-08T12:00:00.000Z', - contentBlocks: [ - { - type: 'tool', - toolCall: { params: { secret: 'private' }, result: { output: 'private' } }, - }, - ], - }, - }, - ]) - - await expect(getPersistedPublicChatRunResponse('chat-1', 'stream-1')).resolves.toBe( - 'Stored answer' - ) - const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) - expect(conditions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ type: 'eq', left: 'chatId', right: 'chat-1' }), - expect.objectContaining({ type: 'eq', left: 'streamId', right: 'stream-1' }), - expect.objectContaining({ type: 'eq', left: 'role', right: 'assistant' }), - expect.objectContaining({ type: 'isNull', column: 'deletedAt' }), - ]) - ) - }) - - it('refuses a malformed persisted content value instead of forwarding it', async () => { - queueTableRows(schemaMock.copilotMessages, [ - { content: { content: { secret: 'private' }, toolResult: 'private' } }, - ]) - - await expect(getPersistedPublicChatRunResponse('chat-1', 'stream-1')).resolves.toBeNull() - }) -}) diff --git a/apps/sim/lib/copilot/chat/public-runs.ts b/apps/sim/lib/copilot/chat/public-runs.ts deleted file mode 100644 index 3b150661d3e..00000000000 --- a/apps/sim/lib/copilot/chat/public-runs.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { db } from '@sim/db' -import { type CopilotRunStatus, copilotChats, copilotMessages, copilotRuns } from '@sim/db/schema' -import { and, desc, eq, isNull, type SQL } from 'drizzle-orm' -import { - type CursorKey, - encodeKeyset, - keysetAfter, - keysetColumns, - listOrderBy, - timestampKey, - uuidKey, -} from '@/lib/api/list-query' - -export const PUBLIC_CHAT_RUN_SORT = 'startedAt:desc' - -export interface PublicChatRunRow { - runId: string - chatId: string - chatTitle: string | null - streamId: string - status: CopilotRunStatus - startedAt: Date - completedAt: Date | null -} - -const PUBLIC_CHAT_RUN_KEYS = [ - timestampKey<PublicChatRunRow>(copilotRuns.startedAt, (row) => row.startedAt), - uuidKey<PublicChatRunRow>(copilotRuns.id, (row) => row.runId), -] - -const publicChatRunSelection = { - runId: copilotRuns.id, - chatId: copilotRuns.chatId, - chatTitle: copilotChats.title, - streamId: copilotRuns.streamId, - status: copilotRuns.status, - startedAt: copilotRuns.startedAt, - completedAt: copilotRuns.completedAt, -} as const - -function ownedRootMothershipRunWhere(input: { - userId: string - workspaceId: string - runId?: string - status?: CopilotRunStatus - resumeAfter?: SQL -}) { - return and( - eq(copilotRuns.userId, input.userId), - eq(copilotRuns.workspaceId, input.workspaceId), - isNull(copilotRuns.parentRunId), - eq(copilotChats.userId, input.userId), - eq(copilotChats.workspaceId, input.workspaceId), - eq(copilotChats.type, 'mothership'), - isNull(copilotChats.deletedAt), - input.runId ? eq(copilotRuns.id, input.runId) : undefined, - input.status ? eq(copilotRuns.status, input.status) : undefined, - input.resumeAfter - ) -} - -export type ListPublicChatRunsResult = - | { status: 'ok'; rows: PublicChatRunRow[] } - | { status: 'invalid_cursor' } - -/** Lists only user-owned root runs from live Mothership chats. */ -export async function listPublicChatRuns(input: { - userId: string - workspaceId: string - status?: CopilotRunStatus - limit: number - cursorKeys?: CursorKey[] -}): Promise<ListPublicChatRunsResult> { - const resumeAfter = input.cursorKeys - ? keysetAfter(PUBLIC_CHAT_RUN_KEYS, input.cursorKeys, 'desc') - : undefined - if (resumeAfter === null) return { status: 'invalid_cursor' } - - const rows = await db - .select(publicChatRunSelection) - .from(copilotRuns) - .innerJoin(copilotChats, eq(copilotChats.id, copilotRuns.chatId)) - .where(ownedRootMothershipRunWhere({ ...input, resumeAfter })) - .orderBy(...listOrderBy(keysetColumns(PUBLIC_CHAT_RUN_KEYS), 'desc')) - .limit(input.limit + 1) - - return { status: 'ok', rows } -} - -export function encodePublicChatRunCursor(row: PublicChatRunRow): CursorKey[] { - return encodeKeyset(PUBLIC_CHAT_RUN_KEYS, row) -} - -/** - * Loads one public run while masking every ownership, scope, type, deletion, - * and parent-run mismatch behind the same absence result. - */ -export async function getPublicChatRun(input: { - runId: string - userId: string - workspaceId: string -}): Promise<PublicChatRunRow | null> { - const [run] = await db - .select(publicChatRunSelection) - .from(copilotRuns) - .innerJoin(copilotChats, eq(copilotChats.id, copilotRuns.chatId)) - .where(ownedRootMothershipRunWhere(input)) - .limit(1) - - return run ?? null -} - -/** - * Reads only the root assistant prose persisted for this stream. Tool blocks - * stay inside the JSON message and never cross the public boundary. - */ -export async function getPersistedPublicChatRunResponse( - chatId: string, - streamId: string -): Promise<string | null> { - const [row] = await db - .select({ content: copilotMessages.content }) - .from(copilotMessages) - .where( - and( - eq(copilotMessages.chatId, chatId), - eq(copilotMessages.streamId, streamId), - eq(copilotMessages.role, 'assistant'), - isNull(copilotMessages.deletedAt) - ) - ) - .orderBy(desc(copilotMessages.seq), desc(copilotMessages.createdAt), desc(copilotMessages.id)) - .limit(1) - - if (!row?.content || typeof row.content !== 'object' || Array.isArray(row.content)) return null - const response = (row.content as Record<string, unknown>).content - return typeof response === 'string' ? response : null -} diff --git a/apps/sim/lib/copilot/headless/attachments.test.ts b/apps/sim/lib/copilot/headless/attachments.test.ts deleted file mode 100644 index 173b63d6656..00000000000 --- a/apps/sim/lib/copilot/headless/attachments.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - MAX_V2_CHAT_ATTACHMENT_BYTES, - MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES, - MAX_V2_CHAT_IMAGE_DIMENSION, - MAX_V2_CHAT_IMAGE_PIXELS, - MAX_V2_CHAT_IMAGES_TOTAL_PIXELS, - MAX_V2_CHAT_TEXT_ATTACHMENT_BYTES, -} from '@/lib/api/contracts/v2/chat' -import { prepareV2ChatAttachments } from '@/lib/copilot/headless/attachments' - -function attachment(name: string, mediaType: string, bytes: Buffer) { - return { name, mediaType, data: bytes.toString('base64') } -} - -function pngHeader(width: number, height: number): Buffer { - const buffer = Buffer.alloc(24) - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buffer) - buffer.writeUInt32BE(13, 8) - buffer.write('IHDR', 12, 'ascii') - buffer.writeUInt32BE(width, 16) - buffer.writeUInt32BE(height, 20) - return buffer -} - -function gifHeader(width: number, height: number): Buffer { - const buffer = Buffer.alloc(10) - buffer.write('GIF89a', 0, 'ascii') - buffer.writeUInt16LE(width, 6) - buffer.writeUInt16LE(height, 8) - return buffer -} - -describe('prepareV2ChatAttachments', () => { - it('maps byte-sniffed images, PDFs, and UTF-8 text to Mothership attachments', () => { - const png = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+X2b6WQAAAABJRU5ErkJggg==', - 'base64' - ) - const result = prepareV2ChatAttachments([ - attachment('screenshot.png', 'image/png', png), - attachment('report.pdf', 'application/pdf', Buffer.from('%PDF-1.7\nexample')), - attachment('notes.md', 'text/markdown', Buffer.from('# Notes\n', 'utf8')), - ]) - - expect(result).toEqual({ - success: true, - attachments: [ - { - type: 'image', - filename: 'screenshot.png', - source: { type: 'base64', media_type: 'image/png', data: png.toString('base64') }, - }, - { - type: 'document', - filename: 'report.pdf', - source: { - type: 'base64', - media_type: 'application/pdf', - data: Buffer.from('%PDF-1.7\nexample').toString('base64'), - }, - }, - { - type: 'document', - filename: 'notes.md', - source: { - type: 'base64', - media_type: 'text/markdown', - data: Buffer.from('# Notes\n', 'utf8').toString('base64'), - }, - }, - ], - }) - }) - - it('preserves each supported raster image format', () => { - const images = [ - { - name: 'photo.jpg', - mediaType: 'image/jpeg', - data: '/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AJUAB//Z', - }, - { - name: 'animation.gif', - mediaType: 'image/gif', - data: 'R0lGODlhAQABAIAAAExpcQAAACH5BAUAAAAALAAAAAABAAEAAAICRAEAOw==', - }, - { - name: 'image.webp', - mediaType: 'image/webp', - data: 'UklGRkAAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAAAAFZQOCAYAAAAMAEAnQEqAQABAAFAJiWkAANwAP79NmgA', - }, - ] - - for (const image of images) { - expect( - prepareV2ChatAttachments([ - attachment(image.name, image.mediaType, Buffer.from(image.data, 'base64')), - ]) - ).toMatchObject({ - success: true, - attachments: [{ type: 'image', source: { media_type: image.mediaType } }], - }) - } - }) - - it('rejects non-canonical base64 before forwarding it', () => { - expect( - prepareV2ChatAttachments([{ name: 'notes.txt', mediaType: 'text/plain', data: 'YQ= ' }]) - ).toEqual({ - success: false, - error: { - code: 'BAD_REQUEST', - message: 'Attachment "notes.txt" data must be canonical base64', - }, - }) - }) - - it('rejects unsupported types and declared image types that do not match the bytes', () => { - expect( - prepareV2ChatAttachments([ - attachment('archive.zip', 'application/zip', Buffer.from('PK\x03\x04')), - ]) - ).toMatchObject({ success: false, error: { code: 'UNSUPPORTED_MEDIA_TYPE' } }) - - expect( - prepareV2ChatAttachments([ - attachment('fake.png', 'image/png', Buffer.from('<script>alert(1)</script>')), - ]) - ).toMatchObject({ success: false, error: { code: 'UNSUPPORTED_MEDIA_TYPE' } }) - }) - - it('rejects malformed images even when their magic bytes match the declared type', () => { - expect( - prepareV2ChatAttachments([ - attachment( - 'truncated.png', - 'image/png', - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - ), - ]) - ).toEqual({ - success: false, - error: { - code: 'UNSUPPORTED_MEDIA_TYPE', - message: 'Attachment "truncated.png" is not a readable image', - }, - }) - }) - - it('rejects compressed images with an oversized axis before forwarding them', () => { - expect( - prepareV2ChatAttachments([ - attachment('wide.png', 'image/png', pngHeader(MAX_V2_CHAT_IMAGE_DIMENSION + 1, 1)), - ]) - ).toMatchObject({ success: false, error: { code: 'PAYLOAD_TOO_LARGE' } }) - - expect( - prepareV2ChatAttachments([ - attachment('tall.gif', 'image/gif', gifHeader(1, MAX_V2_CHAT_IMAGE_DIMENSION + 1)), - ]) - ).toMatchObject({ success: false, error: { code: 'PAYLOAD_TOO_LARGE' } }) - }) - - it('rejects compressed images over the total decoded-pixel limit', () => { - const width = 5000 - const height = Math.floor(MAX_V2_CHAT_IMAGE_PIXELS / width) + 1 - expect(width).toBeLessThanOrEqual(MAX_V2_CHAT_IMAGE_DIMENSION) - expect(height).toBeLessThanOrEqual(MAX_V2_CHAT_IMAGE_DIMENSION) - - expect( - prepareV2ChatAttachments([ - attachment('too-many-pixels.png', 'image/png', pngHeader(width, height)), - ]) - ).toMatchObject({ success: false, error: { code: 'PAYLOAD_TOO_LARGE' } }) - }) - - it('enforces an aggregate decoded-pixel limit across images', () => { - const width = 4000 - const height = 4000 - const pixelsPerImage = width * height - expect(pixelsPerImage).toBe(MAX_V2_CHAT_IMAGE_PIXELS) - expect(pixelsPerImage * 2).toBe(MAX_V2_CHAT_IMAGES_TOTAL_PIXELS) - - expect( - prepareV2ChatAttachments([ - attachment('one.png', 'image/png', pngHeader(width, height)), - attachment('two.gif', 'image/gif', gifHeader(width, height)), - attachment('three.png', 'image/png', pngHeader(1, 1)), - ]) - ).toEqual({ - success: false, - error: { - code: 'PAYLOAD_TOO_LARGE', - message: `Images exceed the ${MAX_V2_CHAT_IMAGES_TOTAL_PIXELS}-pixel aggregate limit`, - }, - }) - }) - - it('enforces text and binary per-file byte limits', () => { - expect( - prepareV2ChatAttachments([ - attachment( - 'large.txt', - 'text/plain', - Buffer.alloc(MAX_V2_CHAT_TEXT_ATTACHMENT_BYTES + 1, 0x61) - ), - ]) - ).toMatchObject({ success: false, error: { code: 'PAYLOAD_TOO_LARGE' } }) - - const oversizedPng = Buffer.alloc(MAX_V2_CHAT_ATTACHMENT_BYTES + 1) - pngHeader(1, 1).copy(oversizedPng) - expect( - prepareV2ChatAttachments([attachment('large.png', 'image/png', oversizedPng)]) - ).toMatchObject({ success: false, error: { code: 'PAYLOAD_TOO_LARGE' } }) - }) - - it('enforces the decoded aggregate byte limit across attachments', () => { - const imageBytes = Buffer.alloc(4 * 1024 * 1024) - pngHeader(1, 1).copy(imageBytes) - - expect(imageBytes.byteLength * 3).toBeGreaterThan(MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES) - expect( - prepareV2ChatAttachments([ - attachment('one.png', 'image/png', imageBytes), - attachment('two.png', 'image/png', imageBytes), - attachment('three.png', 'image/png', imageBytes), - ]) - ).toMatchObject({ success: false, error: { code: 'PAYLOAD_TOO_LARGE' } }) - }) - - it('rejects binary data mislabeled as text', () => { - expect( - prepareV2ChatAttachments([ - attachment('binary.txt', 'text/plain', Buffer.from([0xff, 0xfe, 0xfd])), - ]) - ).toMatchObject({ success: false, error: { code: 'UNSUPPORTED_MEDIA_TYPE' } }) - }) -}) diff --git a/apps/sim/lib/copilot/headless/attachments.ts b/apps/sim/lib/copilot/headless/attachments.ts deleted file mode 100644 index 7cbd863c31d..00000000000 --- a/apps/sim/lib/copilot/headless/attachments.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { imageSize } from 'image-size' -import { isCanonicalBase64 } from '@/lib/api/contracts/primitives' -import { - MAX_V2_CHAT_ATTACHMENT_BYTES, - MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES, - MAX_V2_CHAT_IMAGE_DIMENSION, - MAX_V2_CHAT_IMAGE_PIXELS, - MAX_V2_CHAT_IMAGES_TOTAL_PIXELS, - MAX_V2_CHAT_TEXT_ATTACHMENT_BYTES, - V2_CHAT_DOCUMENT_MEDIA_TYPES, - V2_CHAT_IMAGE_MEDIA_TYPES, - V2_CHAT_TEXT_MEDIA_TYPES, - type V2ChatAttachment, -} from '@/lib/api/contracts/v2/chat' -import { sniffImageContentType } from '@/lib/uploads/utils/validation' - -export interface MothershipInlineFileAttachment { - type: 'image' | 'document' - filename: string - source: { - type: 'base64' - media_type: string - data: string - } -} - -type AttachmentValidationErrorCode = 'BAD_REQUEST' | 'PAYLOAD_TOO_LARGE' | 'UNSUPPORTED_MEDIA_TYPE' - -export type PreparedV2ChatAttachments = - | { success: true; attachments: MothershipInlineFileAttachment[] } - | { - success: false - error: { code: AttachmentValidationErrorCode; message: string } - } - -type AttachmentValidationFailure = Extract<PreparedV2ChatAttachments, { success: false }> - -const IMAGE_MEDIA_TYPES = new Set<string>(V2_CHAT_IMAGE_MEDIA_TYPES) -const DOCUMENT_MEDIA_TYPES = new Set<string>(V2_CHAT_DOCUMENT_MEDIA_TYPES) -const TEXT_MEDIA_TYPES = new Set<string>(V2_CHAT_TEXT_MEDIA_TYPES) -const utf8Decoder = new TextDecoder('utf-8', { fatal: true }) - -function decodeCanonicalBase64(data: string): Buffer | null { - return data.length > 0 && isCanonicalBase64(data) ? Buffer.from(data, 'base64') : null -} - -function isPdf(buffer: Buffer): boolean { - // Match the existing workspace VFS behavior: PDFs may have a BOM or leading - // whitespace, but the signature must appear near the beginning. - return buffer.subarray(0, 1024).toString('latin1').includes('%PDF') -} - -function invalidAttachment(message: string): AttachmentValidationFailure { - return { success: false, error: { code: 'BAD_REQUEST', message } } -} - -function unsupportedAttachment(message: string): AttachmentValidationFailure { - return { success: false, error: { code: 'UNSUPPORTED_MEDIA_TYPE', message } } -} - -function oversizedAttachment(message: string): AttachmentValidationFailure { - return { success: false, error: { code: 'PAYLOAD_TOO_LARGE', message } } -} - -function validateImageDimensions( - name: string, - buffer: Buffer -): { success: true; pixels: number } | AttachmentValidationFailure { - let dimensions: ReturnType<typeof imageSize> - try { - dimensions = imageSize(buffer) - } catch { - return unsupportedAttachment(`Attachment "${name}" is not a readable image`) - } - - const { width, height } = dimensions - if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) { - return unsupportedAttachment(`Attachment "${name}" has invalid image dimensions`) - } - - if ( - width > MAX_V2_CHAT_IMAGE_DIMENSION || - height > MAX_V2_CHAT_IMAGE_DIMENSION || - width > MAX_V2_CHAT_IMAGE_PIXELS / height - ) { - return oversizedAttachment( - `Attachment "${name}" dimensions ${width}x${height} exceed the ${MAX_V2_CHAT_IMAGE_DIMENSION}-pixel axis or ${MAX_V2_CHAT_IMAGE_PIXELS}-pixel image limit` - ) - } - - return { success: true, pixels: width * height } -} - -/** - * Validates the public inline-file boundary and maps it to Mothership's - * existing base64 attachment contract. No path or URL is accepted or resolved. - */ -export function prepareV2ChatAttachments( - input: V2ChatAttachment[] | undefined -): PreparedV2ChatAttachments { - if (!input?.length) return { success: true, attachments: [] } - - const prepared: MothershipInlineFileAttachment[] = [] - let totalBytes = 0 - let totalImagePixels = 0 - - for (const attachment of input) { - const isImage = IMAGE_MEDIA_TYPES.has(attachment.mediaType) - const isPdfDocument = DOCUMENT_MEDIA_TYPES.has(attachment.mediaType) - const isTextDocument = TEXT_MEDIA_TYPES.has(attachment.mediaType) - - if (!isImage && !isPdfDocument && !isTextDocument) { - return unsupportedAttachment( - `Attachment "${attachment.name}" has unsupported media type ${attachment.mediaType}` - ) - } - - const decoded = decodeCanonicalBase64(attachment.data) - if (!decoded) { - return invalidAttachment(`Attachment "${attachment.name}" data must be canonical base64`) - } - - const perFileLimit = isTextDocument - ? MAX_V2_CHAT_TEXT_ATTACHMENT_BYTES - : MAX_V2_CHAT_ATTACHMENT_BYTES - if (decoded.byteLength > perFileLimit) { - return oversizedAttachment( - `Attachment "${attachment.name}" exceeds the ${perFileLimit}-byte limit for ${attachment.mediaType}` - ) - } - - totalBytes += decoded.byteLength - if (totalBytes > MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES) { - return oversizedAttachment( - `Attachments exceed the ${MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES}-byte aggregate limit` - ) - } - - if (isImage) { - const sniffedMediaType = sniffImageContentType(decoded) - if (sniffedMediaType !== attachment.mediaType) { - return unsupportedAttachment( - `Attachment "${attachment.name}" bytes do not match ${attachment.mediaType}` - ) - } - const dimensions = validateImageDimensions(attachment.name, decoded) - if (!dimensions.success) return dimensions - totalImagePixels += dimensions.pixels - if (totalImagePixels > MAX_V2_CHAT_IMAGES_TOTAL_PIXELS) { - return oversizedAttachment( - `Images exceed the ${MAX_V2_CHAT_IMAGES_TOTAL_PIXELS}-pixel aggregate limit` - ) - } - } else if (isPdfDocument) { - if (!isPdf(decoded)) { - return unsupportedAttachment(`Attachment "${attachment.name}" is not a valid PDF`) - } - } else { - try { - const text = utf8Decoder.decode(decoded) - if (text.includes('\0')) { - return unsupportedAttachment(`Attachment "${attachment.name}" is not UTF-8 text`) - } - } catch { - return unsupportedAttachment(`Attachment "${attachment.name}" is not UTF-8 text`) - } - } - - prepared.push({ - type: isImage ? 'image' : 'document', - filename: attachment.name, - source: { - type: 'base64', - media_type: attachment.mediaType, - data: attachment.data, - }, - }) - } - - return { success: true, attachments: prepared } -} diff --git a/apps/sim/lib/copilot/headless/continuation-token.test.ts b/apps/sim/lib/copilot/headless/continuation-token.test.ts deleted file mode 100644 index 299a6b67656..00000000000 --- a/apps/sim/lib/copilot/headless/continuation-token.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockEnv } = vi.hoisted(() => ({ - mockEnv: { BETTER_AUTH_SECRET: 'test-v2-chat-secret-that-is-at-least-32-characters' }, -})) - -vi.mock('@/lib/core/config/env', () => ({ env: mockEnv })) - -import { - issueV2ChatContinuationToken, - V2_CHAT_CONTINUATION_TTL_SECONDS, - verifyV2ChatContinuationToken, -} from './continuation-token' - -const NOW = 1_800_000_000 -const binding = { - workspaceId: 'workspace-1', - authorizationUserId: 'key-owner-1', - credentialType: 'personal' as const, - readOnly: false, -} - -describe('v2 chat continuation tokens', () => { - beforeEach(() => { - mockEnv.BETTER_AUTH_SECRET = 'test-v2-chat-secret-that-is-at-least-32-characters' - }) - - it('round-trips the private chat id only for its bound principal and workspace', async () => { - const token = await issueV2ChatContinuationToken({ - ...binding, - chatId: 'chat-private-1', - now: NOW, - }) - - await expect(verifyV2ChatContinuationToken(token, binding, NOW + 1)).resolves.toEqual({ - valid: true, - chatId: 'chat-private-1', - }) - await expect( - verifyV2ChatContinuationToken(token, { ...binding, workspaceId: 'workspace-2' }, NOW + 1) - ).resolves.toEqual({ valid: false }) - await expect( - verifyV2ChatContinuationToken( - token, - { ...binding, authorizationUserId: 'other-user' }, - NOW + 1 - ) - ).resolves.toEqual({ valid: false }) - await expect( - verifyV2ChatContinuationToken(token, { ...binding, readOnly: true }, NOW + 1) - ).resolves.toEqual({ valid: false }) - await expect( - verifyV2ChatContinuationToken(token, { ...binding, credentialType: 'workspace' }, NOW + 1) - ).resolves.toEqual({ valid: false }) - }) - - it('authenticates the optional Sim persistence claim without changing legacy tokens', async () => { - const syncedToken = await issueV2ChatContinuationToken({ - ...binding, - chatId: 'chat-synced-1', - persistence: 'sim', - now: NOW, - }) - const legacyToken = await issueV2ChatContinuationToken({ - ...binding, - chatId: 'chat-legacy-1', - now: NOW, - }) - - await expect(verifyV2ChatContinuationToken(syncedToken, binding, NOW + 1)).resolves.toEqual({ - valid: true, - chatId: 'chat-synced-1', - persistence: 'sim', - }) - await expect(verifyV2ChatContinuationToken(legacyToken, binding, NOW + 1)).resolves.toEqual({ - valid: true, - chatId: 'chat-legacy-1', - }) - }) - - it('rejects tampering and expiry', async () => { - const token = await issueV2ChatContinuationToken({ - ...binding, - chatId: 'chat-private-1', - now: NOW, - }) - const tampered = `${token.slice(0, -1)}${token.endsWith('a') ? 'b' : 'a'}` - - await expect(verifyV2ChatContinuationToken(tampered, binding, NOW + 1)).resolves.toEqual({ - valid: false, - }) - await expect( - verifyV2ChatContinuationToken(token, binding, NOW + V2_CHAT_CONTINUATION_TTL_SECONDS) - ).resolves.toEqual({ valid: false }) - }) - - it('encrypts the claims with a fresh nonce so decoding token segments cannot reveal the chat id', async () => { - const chatId = 'chat-private-1' - const token = await issueV2ChatContinuationToken({ ...binding, chatId, now: NOW }) - const nextToken = await issueV2ChatContinuationToken({ ...binding, chatId, now: NOW }) - const [prefix, ...encodedSegments] = token.split('.') - - expect(prefix).toBe('sim-v2-chat-v1') - expect(encodedSegments).toHaveLength(1) - expect(nextToken).not.toBe(token) - expect(token).not.toContain(chatId) - for (const segment of encodedSegments) { - expect(Buffer.from(segment, 'base64url').toString('utf8')).not.toContain(chatId) - } - }) -}) diff --git a/apps/sim/lib/copilot/headless/continuation-token.ts b/apps/sim/lib/copilot/headless/continuation-token.ts deleted file mode 100644 index 46e646cbe0b..00000000000 --- a/apps/sim/lib/copilot/headless/continuation-token.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { createHmac } from 'node:crypto' -import { decrypt, encrypt } from '@sim/security/encryption' -import { env } from '@/lib/core/config/env' - -const TOKEN_PREFIX = 'sim-v2-chat-v1' -const TOKEN_MAX_LENGTH = 4096 - -/** Interactive CLI sessions may refresh this rolling expiry on every turn. */ -export const V2_CHAT_CONTINUATION_TTL_SECONDS = 24 * 60 * 60 - -interface ContinuationClaims { - version: 1 - chatId: string - workspaceId: string - authorizationUserId: string - credentialType: 'personal' | 'workspace' - readOnly: boolean - /** Present only when the chat is backed by Sim's persisted chat tables. */ - persistence?: 'sim' - issuedAt: number - expiresAt: number -} - -export interface ContinuationBinding { - workspaceId: string - authorizationUserId: string - credentialType: 'personal' | 'workspace' - readOnly: boolean -} - -export interface IssueContinuationTokenInput extends ContinuationBinding { - chatId: string - persistence?: 'sim' - /** Unix seconds; exposed only to keep expiry behavior deterministic in tests. */ - now?: number -} - -export type VerifiedContinuationToken = - | { valid: true; chatId: string; persistence?: 'sim' } - | { valid: false } - -function encryptionKey(): Buffer { - // Derive a dedicated 256-bit key instead of using BETTER_AUTH_SECRET - // directly. The purpose string prevents ciphertexts from another feature - // backed by the same deployment secret from being valid here. - return createHmac('sha256', env.BETTER_AUTH_SECRET) - .update(`${TOKEN_PREFIX}:aes-256-gcm-encryption-key`, 'utf8') - .digest() -} - -function decodeCanonicalBase64Url(segment: string): string | null { - if (!segment || !/^[A-Za-z0-9_-]+$/.test(segment)) return null - const decoded = Buffer.from(segment, 'base64url') - return decoded.toString('base64url') === segment ? decoded.toString('utf8') : null -} - -function isContinuationClaims(value: unknown): value is ContinuationClaims { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false - const claims = value as Partial<ContinuationClaims> - return ( - claims.version === 1 && - typeof claims.chatId === 'string' && - claims.chatId.length > 0 && - claims.chatId.length <= 255 && - typeof claims.workspaceId === 'string' && - claims.workspaceId.length > 0 && - claims.workspaceId.length <= 255 && - typeof claims.authorizationUserId === 'string' && - claims.authorizationUserId.length > 0 && - claims.authorizationUserId.length <= 255 && - (claims.credentialType === 'personal' || claims.credentialType === 'workspace') && - typeof claims.readOnly === 'boolean' && - (claims.persistence === undefined || claims.persistence === 'sim') && - Number.isSafeInteger(claims.issuedAt) && - Number.isSafeInteger(claims.expiresAt) && - (claims.expiresAt as number) > (claims.issuedAt as number) - ) -} - -/** Issues an opaque, authenticated handle for one private Mothership chat. */ -export async function issueV2ChatContinuationToken( - input: IssueContinuationTokenInput -): Promise<string> { - const issuedAt = input.now ?? Math.floor(Date.now() / 1000) - const claims: ContinuationClaims = { - version: 1, - chatId: input.chatId, - workspaceId: input.workspaceId, - authorizationUserId: input.authorizationUserId, - credentialType: input.credentialType, - readOnly: input.readOnly, - ...(input.persistence ? { persistence: input.persistence } : {}), - issuedAt, - expiresAt: issuedAt + V2_CHAT_CONTINUATION_TTL_SECONDS, - } - - const { encrypted } = await encrypt(JSON.stringify(claims), encryptionKey()) - return `${TOKEN_PREFIX}.${Buffer.from(encrypted, 'utf8').toString('base64url')}` -} - -/** - * Authenticates/decrypts the handle, then verifies expiry and the request's - * ownership tuple. Every failure is intentionally indistinguishable to callers. - */ -export async function verifyV2ChatContinuationToken( - token: string, - binding: ContinuationBinding, - now: number = Math.floor(Date.now() / 1000) -): Promise<VerifiedContinuationToken> { - if (!token || token.length > TOKEN_MAX_LENGTH) return { valid: false } - - const [prefix, encodedCiphertext, ...extra] = token.split('.') - if (prefix !== TOKEN_PREFIX || !encodedCiphertext || extra.length > 0) { - return { valid: false } - } - - try { - const ciphertext = decodeCanonicalBase64Url(encodedCiphertext) - if (!ciphertext) return { valid: false } - const { decrypted } = await decrypt(ciphertext, encryptionKey()) - const parsed = JSON.parse(decrypted) as unknown - if (!isContinuationClaims(parsed)) return { valid: false } - if (parsed.expiresAt <= now || parsed.issuedAt > now + 60) return { valid: false } - if ( - parsed.workspaceId !== binding.workspaceId || - parsed.authorizationUserId !== binding.authorizationUserId || - parsed.credentialType !== binding.credentialType || - parsed.readOnly !== binding.readOnly - ) { - return { valid: false } - } - return { - valid: true, - chatId: parsed.chatId, - ...(parsed.persistence ? { persistence: parsed.persistence } : {}), - } - } catch { - return { valid: false } - } -} diff --git a/apps/sim/lib/copilot/headless/workspace-chat.test.ts b/apps/sim/lib/copilot/headless/workspace-chat.test.ts deleted file mode 100644 index 154efb7da25..00000000000 --- a/apps/sim/lib/copilot/headless/workspace-chat.test.ts +++ /dev/null @@ -1,544 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockAssertActiveWorkspaceAccess, - mockBuildIntegrationToolSchemas, - mockBuildTaggedMcpToolSchemas, - mockComputeWorkspaceEntitlements, - mockCreateCopilotEnvironmentContext, - mockGenerateWorkspaceSnapshot, - mockPrepareCopilotEnvironmentContext, - mockProcessContextsServer, - mockRunHeadlessCopilotLifecycle, -} = vi.hoisted(() => ({ - mockAssertActiveWorkspaceAccess: vi.fn(), - mockBuildIntegrationToolSchemas: vi.fn(), - mockBuildTaggedMcpToolSchemas: vi.fn(), - mockComputeWorkspaceEntitlements: vi.fn(), - mockCreateCopilotEnvironmentContext: vi.fn(), - mockGenerateWorkspaceSnapshot: vi.fn(), - mockPrepareCopilotEnvironmentContext: vi.fn(), - mockProcessContextsServer: vi.fn(), - mockRunHeadlessCopilotLifecycle: vi.fn(), -})) - -vi.mock('@/lib/copilot/chat/payload', () => ({ - buildIntegrationToolSchemas: mockBuildIntegrationToolSchemas, -})) - -vi.mock('@/lib/copilot/chat/process-contents', () => ({ - processContextsServer: mockProcessContextsServer, -})) - -vi.mock('@/lib/copilot/mcp-tools', () => ({ - buildTaggedMcpToolSchemas: mockBuildTaggedMcpToolSchemas, -})) - -vi.mock('@/lib/copilot/chat/workspace-context', () => ({ - generateWorkspaceSnapshot: mockGenerateWorkspaceSnapshot, -})) - -vi.mock('@/lib/copilot/entitlements', () => ({ - computeWorkspaceEntitlements: mockComputeWorkspaceEntitlements, -})) - -vi.mock('@/lib/copilot/environment-context', () => ({ - createCopilotEnvironmentContext: mockCreateCopilotEnvironmentContext, - prepareCopilotEnvironmentContext: mockPrepareCopilotEnvironmentContext, -})) - -vi.mock('@/lib/copilot/request/lifecycle/headless', () => ({ - runHeadlessCopilotLifecycle: mockRunHeadlessCopilotLifecycle, -})) - -vi.mock('@/lib/core/config/env-flags', () => ({ - isDocSandboxEnabled: false, - isHosted: false, -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - assertActiveWorkspaceAccess: mockAssertActiveWorkspaceAccess, -})) - -import { publicChatUsageLimitMessage, runWorkspaceChat, toPublicChatResult } from './workspace-chat' - -const billingAttribution = { - actorUserId: 'billing-actor', - workspaceId: 'workspace-1', - organizationId: 'organization-1', - billedAccountUserId: 'billed-account-1', - billingEntity: { type: 'organization' as const, id: 'organization-1' }, - billingPeriod: { - start: '2026-08-01T00:00:00.000Z', - end: '2026-09-01T00:00:00.000Z', - }, - payerSubscription: null, -} - -describe('runWorkspaceChat', () => { - beforeEach(() => { - vi.clearAllMocks() - mockAssertActiveWorkspaceAccess.mockResolvedValue({ permission: 'admin' }) - mockGenerateWorkspaceSnapshot.mockResolvedValue({ - markdown: 'workspace markdown', - snapshot: { workspace: { id: 'workspace-1', name: 'Acme', ownerId: 'owner-1' } }, - }) - mockComputeWorkspaceEntitlements.mockResolvedValue(['custom-blocks']) - mockCreateCopilotEnvironmentContext.mockResolvedValue({ - resolvedSecretTraceRegistry: { kind: 'empty-registry' }, - }) - mockPrepareCopilotEnvironmentContext.mockResolvedValue({ - resolvedSecretTraceRegistry: { kind: 'full-registry' }, - }) - mockBuildIntegrationToolSchemas.mockResolvedValue([ - { name: 'slack_send', description: 'Send Slack message', input_schema: {} }, - ]) - mockBuildTaggedMcpToolSchemas.mockResolvedValue([]) - mockProcessContextsServer.mockResolvedValue([]) - mockRunHeadlessCopilotLifecycle.mockResolvedValue({ - success: true, - content: 'answer', - contentBlocks: [], - toolCalls: [], - usage: { prompt: 12, completion: 3 }, - }) - }) - - it('uses normal Mothership permissions, integrations, memory, and secrets by default', async () => { - const userStopController = new AbortController() - const onComplete = vi.fn() - const onError = vi.fn() - await runWorkspaceChat({ - prompt: 'Fix the workflow', - authorizationUserId: 'key-owner-1', - actorUserId: 'billing-actor', - workspaceId: 'workspace-1', - chatId: 'chat-1', - messageId: 'message-1', - requestId: 'request-1', - executionId: 'execution-1', - runId: 'run-1', - billingAttribution, - userStopSignal: userStopController.signal, - onComplete, - onError, - }) - - expect(mockAssertActiveWorkspaceAccess).toHaveBeenCalledWith('workspace-1', 'key-owner-1') - expect(mockGenerateWorkspaceSnapshot).toHaveBeenCalledWith('workspace-1', 'key-owner-1', { - workspaceAccess: { permission: 'admin' }, - secretless: false, - }) - expect(mockPrepareCopilotEnvironmentContext).toHaveBeenCalledWith('key-owner-1', 'workspace-1') - expect(mockCreateCopilotEnvironmentContext).not.toHaveBeenCalled() - expect(mockBuildIntegrationToolSchemas).toHaveBeenCalledWith( - 'key-owner-1', - 'message-1', - { schemaSurface: 'copilot' }, - 'workspace-1' - ) - - const [payload, options] = mockRunHeadlessCopilotLifecycle.mock.calls[0] - expect(payload).toMatchObject({ - message: 'Fix the workflow', - userId: 'billing-actor', - userPermission: 'admin', - integrationTools: [ - { name: 'slack_send', description: 'Send Slack message', input_schema: {} }, - ], - }) - expect(payload).not.toHaveProperty('queryOnly') - expect(payload).not.toHaveProperty('disableUserMemory') - expect(options).toMatchObject({ - userId: 'billing-actor', - authorizationUserId: 'key-owner-1', - executionId: 'execution-1', - runId: 'run-1', - autoCreateRunIdentity: false, - userPermission: 'admin', - secretActorUserId: 'key-owner-1', - environmentContext: { resolvedSecretTraceRegistry: { kind: 'full-registry' } }, - billingAttribution, - userStopSignal: userStopController.signal, - onComplete, - onError, - }) - expect(options).not.toHaveProperty('secretMountPolicy') - }) - - it('uses the workspace-chat route with a read-only, secretless server policy', async () => { - await runWorkspaceChat({ - prompt: 'What is deployed?', - authorizationUserId: 'key-owner-1', - actorUserId: 'billing-actor', - workspaceId: 'workspace-1', - chatId: 'chat-1', - messageId: 'message-1', - requestId: 'request-1', - billingAttribution, - readOnly: true, - }) - - expect(mockCreateCopilotEnvironmentContext).toHaveBeenCalledWith('key-owner-1', 'workspace-1', { - personalEncrypted: {}, - workspaceEncrypted: {}, - personalDecrypted: {}, - workspaceDecrypted: {}, - personalOwners: {}, - conflicts: [], - decryptionFailures: [], - }) - expect(mockGenerateWorkspaceSnapshot).toHaveBeenCalledWith('workspace-1', 'key-owner-1', { - workspaceAccess: { permission: 'admin' }, - secretless: true, - }) - expect(mockComputeWorkspaceEntitlements).toHaveBeenCalledWith('workspace-1', 'key-owner-1') - - const [payload, options] = mockRunHeadlessCopilotLifecycle.mock.calls[0] - expect(payload).toEqual({ - message: 'What is deployed?', - userId: 'billing-actor', - workspaceId: 'workspace-1', - chatId: 'chat-1', - messageId: 'message-1', - mode: 'agent', - queryOnly: true, - disableUserMemory: true, - workspaceContext: 'workspace markdown', - vfs: { workspace: { id: 'workspace-1', name: 'Acme', ownerId: 'owner-1' } }, - userPermission: 'read', - entitlements: ['custom-blocks'], - isHosted: false, - }) - expect(payload).not.toHaveProperty('model') - expect(payload).not.toHaveProperty('provider') - expect(payload).not.toHaveProperty('integrationTools') - expect(payload).not.toHaveProperty('mothershipTools') - - expect(options).toMatchObject({ - userId: 'billing-actor', - authorizationUserId: 'key-owner-1', - workspaceId: 'workspace-1', - chatId: 'chat-1', - autoCreateRunIdentity: false, - simRequestId: 'request-1', - goRoute: '/api/mothership/v2-chat', - resumeRoute: '/api/tools/v2-chat/resume', - autoExecuteTools: true, - interactive: false, - billingAttribution, - userPermission: 'read', - secretActorUserId: null, - secretMountPolicy: { secretScope: 'selected', mountedSecrets: [] }, - environmentContext: { resolvedSecretTraceRegistry: { kind: 'empty-registry' } }, - }) - }) - - it('resolves structured tags and exposes only explicitly tagged MCP tools', async () => { - const contexts = [ - { kind: 'workflow' as const, workflowId: 'workflow-1', label: 'Release' }, - { kind: 'skill' as const, skillId: 'skill-1', label: 'review' }, - { kind: 'mcp' as const, serverId: 'mcp-1', label: 'Docs' }, - ] - mockProcessContextsServer.mockResolvedValueOnce([ - { type: 'workflow', content: '', path: 'workflows/release', tag: '@Release' }, - { type: 'skill', content: 'Review carefully', tag: '/review' }, - ]) - mockBuildTaggedMcpToolSchemas.mockResolvedValueOnce([ - { name: 'mcp_docs_search', description: 'Search docs', input_schema: {} }, - ]) - - await runWorkspaceChat({ - prompt: 'Use @Release and /review with /Docs', - authorizationUserId: 'key-owner-1', - actorUserId: 'billing-actor', - workspaceId: 'workspace-1', - chatId: 'chat-1', - messageId: 'message-1', - requestId: 'request-1', - billingAttribution, - contexts, - }) - - expect(mockProcessContextsServer).toHaveBeenCalledWith( - contexts, - 'key-owner-1', - 'Use @Release and /review with /Docs', - 'workspace-1', - 'chat-1' - ) - expect(mockBuildTaggedMcpToolSchemas).toHaveBeenCalledWith('key-owner-1', 'workspace-1', [ - 'mcp-1', - ]) - expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).toEqual( - expect.objectContaining({ - context: [ - { type: 'workflow', content: '', path: 'workflows/release', tag: '@Release' }, - { type: 'skill', content: 'Review carefully', tag: '/review' }, - ], - mothershipTools: [ - { name: 'mcp_docs_search', description: 'Search docs', input_schema: {} }, - ], - }) - ) - }) - - it('unions inherited MCP ids with this turn while expanding only explicit contexts', async () => { - const contexts = [ - { kind: 'skill' as const, skillId: 'skill-1', label: 'review' }, - { kind: 'mcp' as const, serverId: 'mcp-current', label: 'Current' }, - ] - - await runWorkspaceChat({ - prompt: 'Continue with /review and /Current', - authorizationUserId: 'key-owner-1', - actorUserId: 'billing-actor', - workspaceId: 'workspace-1', - chatId: 'chat-1', - messageId: 'message-1', - requestId: 'request-1', - billingAttribution, - contexts, - mcpServerIds: ['mcp-history', 'mcp-current'], - }) - - expect(mockProcessContextsServer).toHaveBeenCalledWith( - contexts, - 'key-owner-1', - 'Continue with /review and /Current', - 'workspace-1', - 'chat-1' - ) - expect(mockBuildTaggedMcpToolSchemas).toHaveBeenCalledWith('key-owner-1', 'workspace-1', [ - 'mcp-history', - 'mcp-current', - ]) - }) - - it('drops MCP contexts and tools from secretless requests', async () => { - const workflow = { - kind: 'workflow' as const, - workflowId: 'workflow-1', - label: 'Release', - } - await runWorkspaceChat({ - prompt: 'Inspect @Release with /Docs', - authorizationUserId: 'key-owner-1', - actorUserId: 'billing-actor', - workspaceId: 'workspace-1', - chatId: 'chat-1', - messageId: 'message-1', - requestId: 'request-1', - billingAttribution, - readOnly: true, - contexts: [workflow, { kind: 'mcp', serverId: 'mcp-1', label: 'Docs' }], - mcpServerIds: ['mcp-history'], - }) - - expect(mockProcessContextsServer).toHaveBeenCalledWith( - [workflow], - 'key-owner-1', - 'Inspect @Release with /Docs', - 'workspace-1', - 'chat-1' - ) - expect(mockBuildTaggedMcpToolSchemas).not.toHaveBeenCalled() - expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).not.toHaveProperty('mothershipTools') - }) - - it('keeps shared workspace credentials out of personal environment, integrations, and memory', async () => { - await runWorkspaceChat({ - prompt: 'Fix the workflow', - authorizationUserId: 'key-owner-1', - actorUserId: 'billing-actor', - workspaceId: 'workspace-1', - chatId: 'chat-1', - messageId: 'message-1', - requestId: 'request-1', - billingAttribution, - sharedWorkspaceCredential: true, - }) - - expect(mockPrepareCopilotEnvironmentContext).not.toHaveBeenCalled() - expect(mockCreateCopilotEnvironmentContext).toHaveBeenCalledWith('key-owner-1', 'workspace-1', { - personalEncrypted: {}, - workspaceEncrypted: {}, - personalDecrypted: {}, - workspaceDecrypted: {}, - personalOwners: {}, - conflicts: [], - decryptionFailures: [], - }) - expect(mockBuildIntegrationToolSchemas).not.toHaveBeenCalled() - expect(mockGenerateWorkspaceSnapshot).toHaveBeenCalledWith('workspace-1', 'key-owner-1', { - workspaceAccess: { permission: 'admin' }, - secretless: true, - }) - - const [payload, options] = mockRunHeadlessCopilotLifecycle.mock.calls[0] - expect(payload).toMatchObject({ - userId: 'billing-actor', - userPermission: 'admin', - disableUserMemory: true, - }) - expect(payload).not.toHaveProperty('queryOnly') - expect(payload).not.toHaveProperty('integrationTools') - expect(options).toMatchObject({ - userId: 'billing-actor', - authorizationUserId: 'key-owner-1', - secretActorUserId: null, - secretMountPolicy: { secretScope: 'selected', mountedSecrets: [] }, - environmentContext: { resolvedSecretTraceRegistry: { kind: 'empty-registry' } }, - }) - }) - - it('authorizes before reading workspace context or resolving runtime state', async () => { - let resolveAccess: ((value: { permission: string }) => void) | undefined - mockAssertActiveWorkspaceAccess.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveAccess = resolve - }) - ) - - const pending = runWorkspaceChat({ - prompt: 'Fix the workflow', - authorizationUserId: 'key-owner-1', - actorUserId: 'billing-actor', - workspaceId: 'workspace-1', - chatId: 'chat-1', - messageId: 'message-1', - requestId: 'request-1', - billingAttribution, - }) - - expect(mockGenerateWorkspaceSnapshot).not.toHaveBeenCalled() - expect(mockComputeWorkspaceEntitlements).not.toHaveBeenCalled() - expect(mockPrepareCopilotEnvironmentContext).not.toHaveBeenCalled() - expect(mockBuildIntegrationToolSchemas).not.toHaveBeenCalled() - - resolveAccess?.({ permission: 'admin' }) - await pending - }) - - it('does not start the Go leg when cancellation wins during workspace preparation', async () => { - const abortController = new AbortController() - let resolveSnapshot!: (value: { - markdown: string - snapshot: { workspace: { id: string; name: string; ownerId: string } } - }) => void - mockGenerateWorkspaceSnapshot.mockReturnValueOnce( - new Promise((resolve) => { - resolveSnapshot = resolve - }) - ) - - const pending = runWorkspaceChat({ - prompt: 'Fix the workflow', - authorizationUserId: 'key-owner-1', - actorUserId: 'billing-actor', - workspaceId: 'workspace-1', - chatId: 'chat-1', - messageId: 'message-1', - requestId: 'request-1', - billingAttribution, - abortSignal: abortController.signal, - }) - await vi.waitFor(() => expect(mockGenerateWorkspaceSnapshot).toHaveBeenCalledTimes(1)) - - abortController.abort('test cancellation') - resolveSnapshot({ - markdown: 'workspace markdown', - snapshot: { workspace: { id: 'workspace-1', name: 'Acme', ownerId: 'owner-1' } }, - }) - - await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) - expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() - }) - - it('fails rather than asking without a workspace snapshot', async () => { - mockGenerateWorkspaceSnapshot.mockResolvedValueOnce(null) - - await expect( - runWorkspaceChat({ - prompt: 'hello', - authorizationUserId: 'key-owner-1', - actorUserId: 'billing-actor', - workspaceId: 'workspace-1', - chatId: 'chat-1', - messageId: 'message-1', - requestId: 'request-1', - billingAttribution, - readOnly: true, - }) - ).rejects.toThrow('Workspace context is unavailable') - expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() - }) - - it('passes validated inline attachments without exposing storage paths or URLs', async () => { - const fileAttachments = [ - { - type: 'document' as const, - filename: 'notes.txt', - source: { - type: 'base64' as const, - media_type: 'text/plain', - data: 'aGk=', - }, - }, - ] - - await runWorkspaceChat({ - prompt: 'Read this', - authorizationUserId: 'key-owner-1', - actorUserId: 'billing-actor', - workspaceId: 'workspace-1', - chatId: 'chat-1', - messageId: 'message-1', - requestId: 'request-1', - billingAttribution, - readOnly: true, - fileAttachments, - }) - - expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).toEqual( - expect.objectContaining({ fileAttachments }) - ) - }) -}) - -describe('toPublicChatResult', () => { - it('exposes only final content, opaque continuation token, and token usage', () => { - expect( - toPublicChatResult( - { - success: true, - content: 'answer', - contentBlocks: [{ type: 'thinking', content: 'private', timestamp: 1 }], - toolCalls: [{ id: 'tool-1', name: 'read', status: 'success' }], - usage: { prompt: 12, completion: 3 }, - cost: { input: 1, output: 2, total: 3 }, - }, - 'continuation-token-1' - ) - ).toEqual({ - content: 'answer', - continuationToken: 'continuation-token-1', - usage: { prompt: 12, completion: 3, total: 15 }, - }) - }) -}) - -describe('publicChatUsageLimitMessage', () => { - it('turns the interactive upgrade tag back into a public error message', () => { - expect( - publicChatUsageLimitMessage( - '<usage_upgrade>{"reason":"usage_limit","action":"increase_limit","message":"Ask an org admin."}</usage_upgrade>' - ) - ).toBe('Ask an org admin.') - expect(publicChatUsageLimitMessage('<usage_upgrade>bad json</usage_upgrade>')).toBe( - 'Usage limit exceeded' - ) - expect(publicChatUsageLimitMessage('ordinary answer')).toBeNull() - }) -}) diff --git a/apps/sim/lib/copilot/headless/workspace-chat.ts b/apps/sim/lib/copilot/headless/workspace-chat.ts deleted file mode 100644 index ba1f3cd9f43..00000000000 --- a/apps/sim/lib/copilot/headless/workspace-chat.ts +++ /dev/null @@ -1,262 +0,0 @@ -import type { V2ChatContext } from '@/lib/api/contracts/v2/chat' -import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' -import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' -import { processContextsServer } from '@/lib/copilot/chat/process-contents' -import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context' -import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' -import { - createCopilotEnvironmentContext, - prepareCopilotEnvironmentContext, -} from '@/lib/copilot/environment-context' -import type { MothershipInlineFileAttachment } from '@/lib/copilot/headless/attachments' -import { buildTaggedMcpToolSchemas } from '@/lib/copilot/mcp-tools' -import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' -import type { OrchestratorResult, StreamEvent } from '@/lib/copilot/request/types' -import { isDocSandboxEnabled, isHosted } from '@/lib/core/config/env-flags' -import type { EnvironmentResolutionSnapshot } from '@/lib/environment/utils' -import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' - -const EMPTY_ENVIRONMENT: EnvironmentResolutionSnapshot = { - personalEncrypted: {}, - workspaceEncrypted: {}, - personalDecrypted: {}, - workspaceDecrypted: {}, - personalOwners: {}, - conflicts: [], - decryptionFailures: [], -} - -function throwIfWorkspaceChatAborted( - input: Pick<WorkspaceChatInput, 'abortSignal' | 'userStopSignal'> -) { - if (!input.abortSignal?.aborted && !input.userStopSignal?.aborted) return - const error = new Error('Chat request cancelled') - error.name = 'AbortError' - throw error -} - -export interface WorkspaceChatInput { - prompt: string - authorizationUserId: string - actorUserId: string - workspaceId: string - chatId: string - messageId: string - requestId: string - executionId?: string - runId?: string - billingAttribution: BillingAttributionSnapshot - /** Explicit safety mode. Normal Mothership capabilities are the default. */ - readOnly?: boolean - /** Shared workspace credentials never inherit their creator's personal runtime state. */ - sharedWorkspaceCredential?: boolean - fileAttachments?: MothershipInlineFileAttachment[] - /** Identity-bearing `@` resources and `/` skill/MCP tags for this turn. */ - contexts?: V2ChatContext[] - /** MCP servers explicitly tagged on earlier persisted turns. */ - mcpServerIds?: string[] - abortSignal?: AbortSignal - /** Stops local Sim work without cancelling the active Go stream transport. */ - userStopSignal?: AbortSignal - /** Signals that Go accepted and early-persisted the initial turn. */ - onInitialStreamAccepted?: () => void - onEvent?: (event: StreamEvent) => void | Promise<void> - onComplete?: (result: OrchestratorResult) => void | Promise<void> - onError?: (error: Error, result?: OrchestratorResult) => void | Promise<void> -} - -/** - * Runs the public CLI workspace-chat surface with the normal Mothership - * capability set, or its explicit query-only projection. - * - * The caller has already authenticated and authorized the requested workspace. - * `authorizationUserId` remains the principal whose current membership governs - * workspace access. `actorUserId` is deliberately separate: personal keys use - * that same principal while workspace keys use the workspace billing account as - * the system actor. Local tool execution is projected back onto the - * authorization principal while billing remains frozen to `actorUserId`. - * - * Query-only is opt-in and gets an empty secret catalog plus the subtractive Go - * tool policy. Personal credentials in normal mode mirror workspace Mothership. - * Shared workspace credentials remain fully workspace-authorized but cannot - * inherit their creator's personal environment, integrations, or memory. - */ -export async function runWorkspaceChat(input: WorkspaceChatInput): Promise<OrchestratorResult> { - throwIfWorkspaceChatAborted(input) - const readOnly = input.readOnly === true - const secretless = readOnly || input.sharedWorkspaceCredential === true - // MCP execution depends on user-held credentials, which read-only and shared - // workspace credentials deliberately cannot inherit. - const contexts = (input.contexts ?? []).filter((context) => !secretless || context.kind !== 'mcp') - const mcpServerIds = secretless - ? [] - : Array.from( - new Set([ - ...(input.mcpServerIds ?? []), - ...contexts.flatMap((context) => (context.kind === 'mcp' ? [context.serverId] : [])), - ]) - ) - - /** - * Keep this authorization barrier ahead of every workspace/context read. The - * route also checks access, but this helper must fail closed on its own. - */ - const workspaceAccess = await assertActiveWorkspaceAccess( - input.workspaceId, - input.authorizationUserId - ) - throwIfWorkspaceChatAborted(input) - const [ - workspaceSnapshot, - entitlements, - environmentContext, - integrationTools, - agentContexts, - mothershipTools, - ] = await Promise.all([ - generateWorkspaceSnapshot(input.workspaceId, input.authorizationUserId, { - workspaceAccess, - secretless, - }), - computeWorkspaceEntitlements(input.workspaceId, input.authorizationUserId), - secretless - ? createCopilotEnvironmentContext( - input.authorizationUserId, - input.workspaceId, - EMPTY_ENVIRONMENT - ) - : prepareCopilotEnvironmentContext(input.authorizationUserId, input.workspaceId), - secretless - ? Promise.resolve([]) - : buildIntegrationToolSchemas( - input.authorizationUserId, - input.messageId, - { schemaSurface: 'copilot' }, - input.workspaceId - ), - processContextsServer( - contexts, - input.authorizationUserId, - input.prompt, - input.workspaceId, - input.chatId - ), - secretless - ? Promise.resolve([]) - : buildTaggedMcpToolSchemas(input.authorizationUserId, input.workspaceId, mcpServerIds), - ]) - throwIfWorkspaceChatAborted(input) - - if (!workspaceSnapshot) { - throw new Error('Workspace context is unavailable') - } - const userPermission = readOnly ? 'read' : workspaceAccess.permission - if (!userPermission) { - // `assertActiveWorkspaceAccess` should make this unreachable, but fail - // closed if its access/permission invariants ever drift apart. - throw new Error('Workspace permission is unavailable') - } - - const requestPayload: Record<string, unknown> = { - message: input.prompt, - userId: input.actorUserId, - workspaceId: input.workspaceId, - chatId: input.chatId, - messageId: input.messageId, - mode: 'agent', - ...(readOnly ? { queryOnly: true } : {}), - ...(secretless ? { disableUserMemory: true } : {}), - ...(input.fileAttachments?.length ? { fileAttachments: input.fileAttachments } : {}), - ...(agentContexts.length ? { context: agentContexts } : {}), - workspaceContext: workspaceSnapshot.markdown, - vfs: workspaceSnapshot.snapshot, - userPermission, - ...(entitlements.length > 0 ? { entitlements } : {}), - ...(integrationTools.length > 0 ? { integrationTools } : {}), - ...(mothershipTools.length > 0 ? { mothershipTools } : {}), - ...(isDocSandboxEnabled ? { docCompiler: 'python' } : {}), - isHosted, - } - - return runHeadlessCopilotLifecycle(requestPayload, { - userId: input.actorUserId, - authorizationUserId: input.authorizationUserId, - workspaceId: input.workspaceId, - chatId: input.chatId, - executionId: input.executionId, - runId: input.runId, - // This wrapper owns Sim run creation. Synced calls arrive with route-created - // ids; Go-only/workspace-key chats intentionally have no Sim parent row. - autoCreateRunIdentity: false, - simRequestId: input.requestId, - // This policy-aware route intentionally fails closed against an older Go - // task that would ignore queryOnly/disableUserMemory during a mixed deploy. - goRoute: '/api/mothership/v2-chat', - resumeRoute: '/api/tools/v2-chat/resume', - autoExecuteTools: true, - interactive: false, - abortSignal: input.abortSignal, - userStopSignal: input.userStopSignal, - billingAttribution: input.billingAttribution, - userPermission, - ...(secretless - ? { - secretActorUserId: null, - secretMountPolicy: { secretScope: 'selected' as const, mountedSecrets: [] }, - } - : { secretActorUserId: input.authorizationUserId }), - environmentContext, - ...(input.onInitialStreamAccepted - ? { onInitialStreamAccepted: input.onInitialStreamAccepted } - : {}), - onEvent: input.onEvent, - onComplete: input.onComplete, - onError: input.onError, - }) -} - -export interface PublicChatResult { - content: string - continuationToken: string - usage: { - prompt?: number - completion?: number - total?: number - } -} - -/** - * The lifecycle turns an upstream 402 into the UI's synthetic usage tag so an - * interactive browser can render an upgrade card. A public stream has no such - * renderer; recover the message and expose it as a normal v2 stream error. - */ -export function publicChatUsageLimitMessage(content: string): string | null { - const match = /^\s*<usage_upgrade>([\s\S]+)<\/usage_upgrade>\s*$/.exec(content) - if (!match) return null - try { - const payload = JSON.parse(match[1]) as { message?: unknown } - return typeof payload.message === 'string' && payload.message.trim() - ? payload.message - : 'Usage limit exceeded' - } catch { - return 'Usage limit exceeded' - } -} - -/** Projects the internal result onto the intentionally small public surface. */ -export function toPublicChatResult( - result: OrchestratorResult, - continuationToken: string -): PublicChatResult { - return { - content: result.content, - continuationToken, - usage: result.usage - ? { - prompt: result.usage.prompt, - completion: result.usage.completion, - total: result.usage.prompt + result.usage.completion, - } - : {}, - } -} diff --git a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts index a950cb90d8c..feaa0822416 100644 --- a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts +++ b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts @@ -376,11 +376,7 @@ export async function processFilePreviewStreamEvent(input: { if (toolCallId && parsedArgs) { const { operation, title, contentType, edit } = parsedArgs const target = await resolvePreviewTarget({ - /* File delegation derives its audit id from the tool call - (`copilot-tool:<toolCallId>`), and that id lives on the frame rather - than the turn-scoped context. Passing the turn context alone made - every preview throw, so the file stopped streaming entirely. */ - context: { ...execContext, toolCallId }, + context: execContext, workspaceId: execContext.workspaceId, target: parsedArgs.target, }) @@ -409,7 +405,7 @@ export async function processFilePreviewStreamEvent(input: { (operation === 'append' || operation === 'patch') ) { previewBase = await loadWorkspaceFileTextForPreview( - { ...execContext, toolCallId }, + execContext, execContext.workspaceId, fileId ) @@ -484,7 +480,7 @@ export async function processFilePreviewStreamEvent(input: { (intent.operation === 'append' || intent.operation === 'patch') ) { previewBase = await loadWorkspaceFileTextForPreview( - { ...execContext, toolCallId: streamEvent.payload.toolCallId }, + execContext, execContext.workspaceId, result.fileId ) diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 1a2d114d304..17b47aa79b5 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -38,13 +38,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ }) ?? null, })) vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ - /* `executeCopilotFileUseCase` reads `useCase.operation.id` to check the - operation is registered before running it, so a use-case mock without an - `operation` throws before `execute` is ever reached. */ - listAllWorkspaceFiles: { - execute: listAllWorkspaceFilesMock, - operation: { id: 'files.list' }, - }, + listAllWorkspaceFiles: { execute: listAllWorkspaceFilesMock }, })) vi.mock('@/lib/copilot/tools/server/files/file-preview', async () => { diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index ebeaad2015d..d8adfb16857 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -886,109 +886,6 @@ describe('sse-handlers tool lifecycle', () => { ) }) - /** - * A call can be checkpointed while every frame it received is still - * `generating`. The subagent channel must already be on the tool call by - * then: the workspace_file -> edit_content intent handoff scopes on it, and - * recording it only on a finalized frame left edit_content reporting - * "No workspace_file context found" for a write that had in fact succeeded. - */ - /** - * Arguments reach Sim either whole on a frame or in `args_delta` pieces. A - * call checkpointed before any frame carries `arguments` used to execute with - * `{}` — its own schema then rejected every required property — even though - * the full argument JSON had already arrived as deltas. - */ - it('executes with arguments recovered from args_delta frames', async () => { - executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) - context.toolCalls.set('parent-1', { - id: 'parent-1', - name: 'file', - status: 'pending', - startTime: Date.now(), - }) - - const call = { - toolCallId: 'sub-tool-delta', - toolName: 'workspace_file', - executor: MothershipStreamV1ToolExecutor.sim, - mode: MothershipStreamV1ToolMode.async, - phase: MothershipStreamV1ToolPhase.call, - } - const scope = { lane: 'subagent', parentToolCallId: 'parent-1', agentId: 'file' } as const - - // Registered while still generating, with no arguments on the frame. - await subAgentHandlers.tool( - { - type: MothershipStreamV1EventType.tool, - scope, - payload: { ...call, status: 'generating' }, - } as StreamEvent, - context, - execContext, - { interactive: false, timeout: 1000 } - ) - - for (const argumentsDelta of ['{"operation":"update",', '"title":"Set contents"}']) { - await subAgentHandlers.tool( - { - type: MothershipStreamV1EventType.tool, - scope, - payload: { - toolCallId: 'sub-tool-delta', - toolName: 'workspace_file', - phase: 'args_delta', - argumentsDelta, - }, - } as unknown as StreamEvent, - context, - execContext, - { interactive: false, timeout: 1000 } - ) - } - - await executeToolAndReport('sub-tool-delta', context, execContext, { - interactive: false, - timeout: 1000, - }) - - expect(executeTool).toHaveBeenCalledWith( - 'workspace_file', - { operation: 'update', title: 'Set contents' }, - expect.any(Object) - ) - }) - - it('records the subagent channel from a generating frame, before any final frame', async () => { - context.toolCalls.set('parent-1', { - id: 'parent-1', - name: 'file', - status: 'pending', - startTime: Date.now(), - }) - - await subAgentHandlers.tool( - { - type: MothershipStreamV1EventType.tool, - scope: { lane: 'subagent', parentToolCallId: 'parent-1', agentId: 'file' }, - payload: { - toolCallId: 'sub-tool-partial', - toolName: 'workspace_file', - executor: MothershipStreamV1ToolExecutor.sim, - mode: MothershipStreamV1ToolMode.async, - phase: MothershipStreamV1ToolPhase.call, - status: 'generating', - arguments: { operation: 'update' }, - }, - } satisfies StreamEvent, - context, - execContext, - { interactive: false, timeout: 1000 } - ) - - expect(context.toolCalls.get('sub-tool-partial')?.parentToolCallId).toBe('parent-1') - }) - it('updates stored params when a subagent generating event is followed by the final tool call', async () => { executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) context.toolCalls.set('parent-1', { diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index eab680ef9b6..9606e8e5261 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -583,11 +583,6 @@ function registerSubagentToolCall( status: 'pending', agentId, params: args, - /* The invoking subagent's channel, recorded here rather than only on a - finalized frame: a call checkpointed while every frame is still partial - would otherwise execute with no channel, and the workspace_file -> - edit_content intent handoff scopes on exactly this id. */ - parentToolCallId, startTime: Date.now(), } applyToolDisplay(toolCall) diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 092b5a97039..c05fa7a76f5 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -1634,68 +1634,6 @@ describe('runCopilotLifecycle', () => { } }) - /** - * Go blocks on a result for every checkpointed call. Throwing here used to end - * the whole turn, so one unrecorded result cost the user the entire response — - * and the only thing preventing it was a partial frame happening to register - * the call. Report the failure as that tool's result instead, so the model - * sees one failed call and can route around it. - */ - it('reports a failed result instead of ending the turn when a checkpointed tool has none', async () => { - const billingAttribution = { - actorUserId: 'user-1', - workspaceId: 'ws-1', - billedAccountUserId: 'owner-1', - organizationId: 'org-1', - billingEntity: { type: 'organization' as const, id: 'org-1' }, - billingPeriod: { - start: '2026-07-01T00:00:00.000Z', - end: '2026-08-01T00:00:00.000Z', - }, - payerSubscription: null, - } - mockRunStreamLoop.mockImplementationOnce( - async ( - _fetchUrl: string, - _fetchOptions: RequestInit, - context: StreamingContext - ): Promise<void> => { - // Registered but never resolved — no `result` ever recorded. - context.toolCalls.set('tool-1', { - id: 'tool-1', - name: 'workspace_file', - status: MothershipStreamV1ToolOutcome.error, - }) - context.awaitingAsyncContinuation = { - checkpointId: 'ckpt-1', - pendingToolCallIds: ['tool-1'], - } - } - ) - mockRunStreamLoop.mockResolvedValueOnce(undefined) - - await expect( - runCopilotLifecycle( - { message: 'hello', messageId: 'message-1' }, - { - userId: 'user-1', - workspaceId: 'ws-1', - chatId: 'chat-1', - executionId: 'execution-1', - runId: 'run-1', - simRequestId: 'request-1', - billingAttribution, - } - ) - ).resolves.toBeDefined() - - // The turn continued: a second leg ran, carrying the failed result back. - expect(mockRunStreamLoop).toHaveBeenCalledTimes(2) - const resumeBody = JSON.parse((mockRunStreamLoop.mock.calls[1]?.[1].body as string) ?? '{}') - const sent = JSON.stringify(resumeBody) - expect(sent).toContain('tool-1') - }) - it('fails closed instead of sending a secret-bearing tool name on resume', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: 'unsafe-tool', encryptedValue: 'ciphertext' }, @@ -1903,7 +1841,6 @@ describe('runCopilotLifecycle', () => { ) expect(fetchUrls[1]).toBe('http://mothership.test/api/tools/resume') - expect(mockUpdateRunStatus).toHaveBeenCalledWith('run-1', 'resuming') expect(requestBodies[1]).toEqual( expect.objectContaining({ checkpointId: 'ckpt-1', diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 513c592a2c3..60fcf011885 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -1490,17 +1490,6 @@ async function runCheckpointLoop( break } - if (isResume && options.runId) { - try { - await updateRunStatus(options.runId, 'resuming') - } catch (error) { - logger.warn('Failed to mark run as resuming', { - runId: options.runId, - error: toError(error).message, - }) - } - } - const loopOptions = { ...options, onEvent: async (event: StreamEvent) => { @@ -1780,27 +1769,7 @@ async function runCheckpointLoop( toolStatus: tool?.status, hasPendingPromise: context.pendingToolPromises.has(toolCallId), }) - /** - * Go is blocked on a result for every checkpointed call, so throwing - * here ends the turn outright and the user loses the whole response. - * Report the failure as that tool's result instead: the model sees one - * failed call and can retry or route around it, which is how every - * other tool failure already behaves. Reached only when a call was - * checkpointed without Sim ever recording a result for it. - */ - const failedName = tool?.name ?? '' - results.push({ - callId: toolCallId, - name: failedName, - data: getToolCallTerminalData({ - id: toolCallId, - name: failedName, - status: MothershipStreamV1ToolOutcome.error, - error: `Tool call ${toolCallId} produced no result before resume`, - }), - success: false, - }) - continue + throw new Error(`Cannot resume: missing result for pending tool call ${toolCallId}`) } const name = tool.name || '' if (!isResolvedSecretModelContentUnchanged(name, execContext.resolvedSecretTraceRegistry)) { diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index 1427e7a9e41..ccaf638aaa9 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -499,33 +499,6 @@ function terminalCompletionFromToolCall(toolCall: ToolCallState): AsyncToolCompl }) } -/** - * Recovers a tool call's arguments from the raw argument stream. - * - * Arguments reach Sim two ways: whole, on a tool frame's `arguments`, or in - * pieces, as `argumentsDelta` chunks that `handleToolArgsDelta` concatenates - * into `streamingArgs`. Only the first populates `params`. When a call is - * checkpointed before any frame carries `arguments` — which is how the file - * subagent's `workspace_file` calls arrive — `params` stays undefined and the - * tool executes with `{}`, failing its own schema on every required property. - * The arguments were never lost, only unparsed, so recover them here rather - * than dispatching a call known to be incomplete. - */ -function hydrateParamsFromStreamedArgs(toolCall: ToolCallState): void { - if (toolCall.params !== undefined) return - const streamed = toolCall.streamingArgs?.trim() - if (!streamed) return - try { - const parsed = JSON.parse(streamed) - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - toolCall.params = parsed as Record<string, unknown> - } - } catch { - // A truncated stream is not recoverable; leave params undefined so the - // tool's own validation reports the failure. - } -} - export async function executeToolAndReport( toolCallId: string, context: StreamingContext, @@ -539,8 +512,6 @@ export async function executeToolAndReport( message: 'Tool call not found', }) - hydrateParamsFromStreamedArgs(toolCall) - const argsPayload = toolCall.params ? (() => { try { diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 06e028d64e5..6d9e981717e 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -18,7 +18,7 @@ npm install --global @simai/cli@dev # dev ## Profiles Profiles work like the AWS CLI: one identity and one set of defaults per named -profile, selected with `-p`, `--profile`, or `SIM_PROFILE`. This is what lets you keep +profile, selected with `-P`, `--profile`, or `SIM_PROFILE`. This is what lets you keep production and a local dev stack side by side without re-authenticating. Non-secret settings live in `~/.sim/config`: @@ -108,24 +108,14 @@ Settings → API keys. ## Commands -Plural resource names are canonical, but most plural top-level resource groups -also accept their singular form: for example, `sim table list`, +Plural resource names are canonical, but every plural top-level resource group +also accepts its singular form: for example, `sim table list`, `sim file get`, and `sim workflow get` are equivalent to their plural -spellings. Chats deliberately keep separate names: `sim chat` is the terminal -conversation, while `sim chats` manages saved chat resources. +spellings. `knowledge` also accepts the shorter `kb` alias. ```bash -sim chat [prompt...] [-f <path>...] [--read-only] -sim chat ask [prompt...] [-f <path>...] [--read-only] [--chat <chatId>] [--async] -sim chat follow <runId> -sim chat runs list [--status <status>] [--limit <n>] -sim chat runs get <runId> -sim chats list [--search <text>] [--limit <n>] -sim chats get <chatId> [--read-only] -sim chats rename <chatId> --title <title> - sim workflows ls [path] [--search <text>] [--limit <n>] sim workflows list [--folder <path>] [--deployed-only] [--limit <n>] sim workflows get <id> @@ -192,8 +182,6 @@ sim billing logs [--period 7d] [--source sim-chat] [--limit <n>] [--all-workspac The `sim-chat` billing source combines Copilot and workspace chat usage. Organization audit logs require a personal API key. Commands with `--all-workspaces` otherwise default to the workspace in the active profile. -Saved chat commands also require a personal API key and use that active -workspace unless `--workspace` overrides it. `workflows runs get` is the lightweight status and polling resource. `--workflow` names the parent resource, while the run ID remains positional. @@ -203,132 +191,6 @@ concise; add `--trace` for the expanded recursive trace with span inputs, outputs, errors, timing, and cost. JSON and YAML retain the complete structured response. -### Ask Sim Chat - -`sim chat` opens a terminal conversation about the workspace saved by `sim -login`. It streams answers with a compact working indicator, keeps the -conversation across turns, and provides input history. In a real TTY, the -transcript and current activity stay in the upper viewport while the -free-form `❯` composer remains pinned at the bottom. Use the global -`--workspace` flag to target another workspace the active key can access. -Structured questions use a separate compact panel: Up/Down moves, Enter selects, -Space toggles multi-select items, typing supplies a custom answer, and Esc returns -to the ordinary composer. Suggested follow-up metadata is omitted. - -The composer stays editable while Sim is working. Press Enter with a follow-up -to queue it and immediately steer the active turn (the TUI performs the web -chat's queue-then-send-now handoff in one step); additional submitted prompts -remain FIFO. Press Up on an empty composer to recall the newest queued prompt. -Shift+Enter, Option/Meta+Enter, or a trailing `\` followed by Enter inserts a -newline instead of submitting. - -Type `@` at the start of a token to tag a workspace workflow, table, file, or -knowledge base. The latest 50 execution logs appear after those primary -resources instead of expanding an unbounded logs tree. Past chats never enter -the `@` list; use `/chats` to open their searchable picker. Type `/` to invoke a -workspace skill or an enabled MCP server; read-only chat omits MCP servers, -and CLI control commands remain in that menu at the start of the composer. -These are structured tags, not decorative prompt text: Sim receives the -selected resource id, and a tagged MCP server remains enabled for later turns -in the same terminal conversation. - -```bash -sim chat -sim chat "Start by explaining this workspace" -sim chat --file screenshot.png "What is failing here?" -sim chat --read-only "Summarize this workspace without changing it" -``` - -Inside the chat, type or drop local paths to attach up to five images, PDFs, or -UTF-8 text files. Paths are removed from the submitted prompt and the files are -sent inline; a path-only turn sends just the attachments. Press Ctrl+V (or -Cmd+V on macOS) to add a clipboard image or file without replacing draft text. -`/chats` loads the chat history and opens a searchable picker. Selecting one -restores its transcript and continues it with a fresh opaque token. The header -shows the active chat title and keeps the `/chats` switch hint visible; a new -chat's generated title appears there as soon as the server publishes it. -`/rename <title>` retitles the active synced chat in both the terminal and Sim -Home. `/new` clears the visible transcript and starts a new conversation, -`/help` lists commands, and `/exit` or Ctrl+D exits. Ctrl+C clears idle input or -cancels the active generation and returns to the prompt. - -Chats sent with the personal API key issued by `sim login` use the same history -as Sim Home, so a CLI conversation appears in the web UI and a web conversation -can be resumed in the terminal. Shared workspace keys intentionally do not -expose their creator's private chat history. Profiles created by an older login -flow may still contain a workspace-scoped key; run `sim login` again for that -profile to replace it with a personal key and enable synchronized history and -`/chats`. - -Chat uses the full Mothership toolset by default. Add `--read-only` in either -interactive or one-shot mode when the conversation must be restricted to -workspace-reading tools. - -`sim chat ask` is the non-interactive form. It never opens a prompt. With a -personal API key, each ask creates or continues a saved chat, so the returned -chat ID can be passed back with `--chat` and the conversation also appears in -Sim Home. Without `--async`, table and text output keep the completed, -terminal-safe answer as the only stdout payload, so it composes cleanly with -shell tools; an interactive terminal shows the chat ID on stderr. JSON and YAML -return both `content` and `chatId`. Bare `sim chat` requires a real terminal; -pipelines and redirected output must use `chat ask`. - -```bash -sim chat ask "Which workflows handle support tickets?" -sim chat ask --chat <chatId> "Continue this conversation" -cat incident.txt | sim chat ask "Which workflow is most likely involved?" -sim chat ask < question.txt -sim chat ask --file report.pdf "Summarize this in workspace context" -``` - -Pass `--chat <chatId>` to append one turn to an existing inactive -chat, print its answer, and exit. The chat must belong to the active workspace, -and synchronized history requires a personal API key. - -```bash -chat_id=$(sim --output json chat ask "Inspect this workspace" | jq -r .chatId) -sim chat ask --chat "$chat_id" "Now inspect the deployed workflows" -``` - -Add `--async` when the shell should regain control as soon as the run is -accepted. The receipt contains the `runId`, `chatId`, and initial `active` -status in the profile's normal output format. Async asks are saved chats, so -they stay synchronized with Sim Home. `chat runs list` shows recent requests, -and `chat runs get --output json` or `--output yaml` includes one run's -accumulated response and activity. - -Use `chat follow` to observe a run until it reaches a terminal state. In human -output it streams only response text that has appeared since the last poll; -when stderr is a terminal, concise status and tool activity appear there. -JSON and YAML wait and emit one final snapshot instead. Ctrl+C detaches the -observer without cancelling the server-side run. A run ending in `error` or -`cancelled` still emits its safe partial/final result and exits nonzero. - -```bash -run_id=$(sim --output json chat ask --async "Audit this workspace" | jq -r .runId) -sim chat follow "$run_id" -sim chat runs get "$run_id" -``` - -When both a positional prompt and stdin are present, the positional prompt comes -first and the piped content follows on the next line. Combined input is limited -to 10 MiB of UTF-8 text. -Files are sent inline by basename only: local paths never cross the API -boundary. Images and PDFs are limited to 5 MiB each, text files to 200 KiB, and -all attachments in a turn to 10 MiB total. - -On an auth-disabled self-hosted Sim deployment, configure the endpoint and -workspace without logging in locally: - -```bash -sim configure --set-endpoint http://localhost:3000 --set-workspace ws_local -sim chat ask "What is in this workspace?" -``` - -That deployment must enable `V2_API=true` and set `COPILOT_API_KEY` server-side. -A CLI API key, when one is present, authenticates only the public Sim request -and is never reused as the deployment's Mothership key. - `sim logs get` keeps the default human output concise. Use JSON or YAML to inspect its complete `executionData` and recursive `traceSpans` tree: diff --git a/packages/sim-cli/src/commands/protocol/chat-attachment-tag.test.ts b/packages/sim-cli/src/commands/protocol/chat-attachment-tag.test.ts deleted file mode 100644 index ee98e5cc066..00000000000 --- a/packages/sim-cli/src/commands/protocol/chat-attachment-tag.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { PassThrough } from 'node:stream' -import { describe, expect, it } from 'vitest' -import { ReadlineChatTerminal } from './chat-terminal.js' - -const ESC = String.fromCharCode(27) -const TAG = `${ESC}[38;2;51;196;130m` - -function harness() { - const input = new PassThrough() as PassThrough & { isTTY: boolean; setRawMode?: () => void } - const output = new PassThrough() as PassThrough & { - isTTY: boolean - columns: number - rows: number - } - input.isTTY = true - input.setRawMode = () => {} - output.isTTY = true - output.columns = 80 - output.rows = 20 - output.on('data', () => {}) - const terminal = new ReadlineChatTerminal(input as never, output as never) - const probe = terminal as never as { - draft: string - buildPanel(rows: number): { lines: string[] } - } - return { - input, - terminal, - draft: () => probe.draft, - row: () => probe.buildPanel(20).lines.join('\n'), - } -} - -describe('pasted image tag', () => { - it('inserts a numbered tag at the cursor and highlights it', () => { - const { input, terminal, draft, row } = harness() - void terminal.read('> ') - input.write('look at') - terminal.noteAttachment() - expect(draft()).toBe('look at [Image #1] ') - expect(row()).toContain(`${TAG}[Image #1]`) - terminal.close() - }) - - it('numbers successive attachments', () => { - const { terminal, draft } = harness() - void terminal.read('> ') - terminal.noteAttachment() - terminal.noteAttachment() - expect(draft()).toBe('[Image #1] [Image #2] ') - terminal.close() - }) - - it('stops highlighting once the tag is deleted', () => { - const { input, terminal, row } = harness() - void terminal.read('> ') - terminal.noteAttachment() - expect(row()).toContain(TAG) - const BACKSPACE = String.fromCharCode(127) - for (let i = 0; i < 12; i++) input.write(BACKSPACE) - expect(row()).not.toContain(TAG) - terminal.close() - }) -}) diff --git a/packages/sim-cli/src/commands/protocol/chat-attachments.test.ts b/packages/sim-cli/src/commands/protocol/chat-attachments.test.ts deleted file mode 100644 index 642b20a8498..00000000000 --- a/packages/sim-cli/src/commands/protocol/chat-attachments.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - combineChatAttachments, - loadChatAttachment, - loadChatAttachments, -} from './chat-attachments.js' - -const temporaryDirectories: string[] = [] - -function pngBytes(size: number): Buffer { - const bytes = Buffer.alloc(size) - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(bytes) - return bytes -} - -async function fixture(name: string, value: Uint8Array | string): Promise<string> { - const directory = await mkdtemp(join(tmpdir(), 'sim-cli-chat-test-')) - temporaryDirectories.push(directory) - const path = join(directory, name) - await writeFile(path, value) - return path -} - -afterEach(async () => { - for (const path of temporaryDirectories.splice(0)) await rm(path, { recursive: true }) -}) - -describe('chat attachments', () => { - it('infers media types from bytes and sends only the basename', async () => { - const png = await fixture( - 'renamed.dat', - Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - ) - const markdown = await fixture('notes.md', '# hello') - - await expect(loadChatAttachment(png)).resolves.toEqual({ - name: 'renamed.dat', - mediaType: 'image/png', - data: 'iVBORw0KGgo=', - }) - await expect(loadChatAttachment(markdown)).resolves.toEqual({ - name: 'notes.md', - mediaType: 'text/markdown', - data: 'IyBoZWxsbw==', - }) - }) - - it('rejects binary and oversized text locally', async () => { - const binary = await fixture('payload.bin', Uint8Array.from([0xff, 0x00, 0xfe])) - const large = await fixture('large.txt', 'x'.repeat(200 * 1024 + 1)) - const tooLargeForAnyType = await fixture('huge.png', Buffer.alloc(5 * 1024 * 1024 + 1)) - - await expect(loadChatAttachment(binary)).rejects.toThrow(/Unsupported attachment/) - await expect(loadChatAttachment(large)).rejects.toThrow(/200 KiB/) - await expect(loadChatAttachment(tooLargeForAnyType)).rejects.toThrow(/5 MiB/) - }) - - it('enforces count and aggregate limits', async () => { - const small = { name: 'a.txt', mediaType: 'text/plain', data: 'eA==' } - expect(() => - combineChatAttachments( - [], - Array.from({ length: 6 }, () => small) - ) - ).toThrow(/at most 5/) - - const fiveMiB = Buffer.alloc(5 * 1024 * 1024).toString('base64') - expect(() => - combineChatAttachments( - [], - [ - { name: 'a.png', mediaType: 'image/png', data: fiveMiB }, - { name: 'b.png', mediaType: 'image/png', data: fiveMiB }, - { name: 'c.txt', mediaType: 'text/plain', data: 'eA==' }, - ] - ) - ).toThrow(/aggregate limit/) - }) - - it('loads multiple attachments and rejects missing paths', async () => { - const one = await fixture('one.txt', 'one') - const two = await fixture('two.json', '{}') - await expect(loadChatAttachments([one, two])).resolves.toHaveLength(2) - await expect(loadChatAttachment(join(tmpdir(), 'definitely-missing-sim-file'))).rejects.toThrow( - /Could not read attachment/ - ) - }) - - it('rejects too many paths before attempting to open any of them', async () => { - const missing = join(tmpdir(), 'definitely-missing-sim-file') - - await expect(loadChatAttachments(Array.from({ length: 6 }, () => missing))).rejects.toThrow( - /at most 5/ - ) - }) - - it('stops loading as soon as the aggregate byte limit is exceeded', async () => { - const first = await fixture('first.png', pngBytes(5 * 1024 * 1024)) - const second = await fixture('second.png', pngBytes(5 * 1024 * 1024)) - const third = await fixture('third.png', pngBytes(8)) - const missing = join(tmpdir(), 'missing-after-aggregate-limit.png') - - await expect(loadChatAttachments([first, second, third, missing])).rejects.toThrow( - /aggregate limit/ - ) - }) -}) diff --git a/packages/sim-cli/src/commands/protocol/chat-attachments.ts b/packages/sim-cli/src/commands/protocol/chat-attachments.ts deleted file mode 100644 index 0697bac11c1..00000000000 --- a/packages/sim-cli/src/commands/protocol/chat-attachments.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { execFile } from 'node:child_process' -import { type FileHandle, mkdtemp, open, rmdir, stat, unlink } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { basename, extname, join } from 'node:path' -import { promisify } from 'node:util' -import { SimApiError } from '../../http/client.js' - -export interface ChatAttachment { - name: string - mediaType: string - data: string -} - -export const MAX_CHAT_ATTACHMENTS = 5 -export const MAX_CHAT_ATTACHMENT_BYTES = 5 * 1024 * 1024 -export const MAX_CHAT_TEXT_ATTACHMENT_BYTES = 200 * 1024 -export const MAX_CHAT_ATTACHMENTS_TOTAL_BYTES = 10 * 1024 * 1024 - -const execFileAsync = promisify(execFile) -const utf8Decoder = new TextDecoder('utf-8', { fatal: true }) - -const TEXT_MEDIA_TYPES_BY_EXTENSION: Record<string, string> = { - '.css': 'text/css', - '.csv': 'text/csv', - '.htm': 'text/html', - '.html': 'text/html', - '.js': 'text/javascript', - '.json': 'application/json', - '.jsonl': 'application/jsonl', - '.jsx': 'text/javascript', - '.log': 'text/plain', - '.markdown': 'text/markdown', - '.md': 'text/markdown', - '.mjs': 'text/javascript', - '.ndjson': 'application/x-ndjson', - '.toml': 'application/toml', - '.ts': 'text/typescript', - '.tsv': 'text/tab-separated-values', - '.tsx': 'text/typescript', - '.txt': 'text/plain', - '.xml': 'application/xml', - '.yaml': 'application/yaml', - '.yml': 'application/yaml', -} - -function attachmentError(message: string): SimApiError { - return new SimApiError(message, 0) -} - -function sniffImageMediaType(bytes: Uint8Array): string | null { - if ( - bytes.length >= 8 && - bytes[0] === 0x89 && - bytes[1] === 0x50 && - bytes[2] === 0x4e && - bytes[3] === 0x47 && - bytes[4] === 0x0d && - bytes[5] === 0x0a && - bytes[6] === 0x1a && - bytes[7] === 0x0a - ) { - return 'image/png' - } - if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { - return 'image/jpeg' - } - if (bytes.length >= 6) { - const signature = Buffer.from(bytes.subarray(0, 6)).toString('ascii') - if (signature === 'GIF87a' || signature === 'GIF89a') return 'image/gif' - } - if ( - bytes.length >= 12 && - Buffer.from(bytes.subarray(0, 4)).toString('ascii') === 'RIFF' && - Buffer.from(bytes.subarray(8, 12)).toString('ascii') === 'WEBP' - ) { - return 'image/webp' - } - return null -} - -function isPdf(bytes: Uint8Array): boolean { - return Buffer.from(bytes.subarray(0, 1024)).toString('latin1').includes('%PDF') -} - -function assertAttachmentName(name: string): void { - if ( - !name || - name === '.' || - name === '..' || - name.length > 255 || - /[\\/\u0000-\u001f\u007f]/.test(name) - ) { - throw attachmentError(`Attachment name ${JSON.stringify(name)} must be a safe file basename.`) - } -} - -function textMediaType(path: string): string { - return TEXT_MEDIA_TYPES_BY_EXTENSION[extname(path).toLowerCase()] ?? 'text/plain' -} - -function inspectAttachment(path: string, bytes: Uint8Array): { mediaType: string; limit: number } { - const imageMediaType = sniffImageMediaType(bytes) - if (imageMediaType) return { mediaType: imageMediaType, limit: MAX_CHAT_ATTACHMENT_BYTES } - if (isPdf(bytes)) return { mediaType: 'application/pdf', limit: MAX_CHAT_ATTACHMENT_BYTES } - - try { - const value = utf8Decoder.decode(bytes) - if (value.includes('\0')) throw new Error('NUL byte') - } catch { - throw attachmentError( - `Unsupported attachment ${JSON.stringify(basename(path))}. Use PNG, JPEG, GIF, WebP, PDF, or UTF-8 text.` - ) - } - return { mediaType: textMediaType(path), limit: MAX_CHAT_TEXT_ATTACHMENT_BYTES } -} - -async function readBounded(handle: FileHandle, limit: number): Promise<Buffer> { - const bytes = Buffer.allocUnsafe(limit + 1) - let offset = 0 - while (offset < bytes.byteLength) { - const result = await handle.read(bytes, offset, bytes.byteLength - offset, offset) - if (result.bytesRead === 0) break - offset += result.bytesRead - } - return bytes.subarray(0, offset) -} - -/** Reads and validates one local file without ever putting its path on the wire. */ -export async function loadChatAttachment(path: string): Promise<ChatAttachment> { - let handle: FileHandle - try { - handle = await open(path, 'r') - } catch { - throw attachmentError(`Could not read attachment ${JSON.stringify(path)}.`) - } - - const name = basename(path) - try { - const info = await handle.stat() - if (!info.isFile()) throw attachmentError(`Attachment ${JSON.stringify(path)} is not a file.`) - // Metadata rejects obvious mistakes without allocating for them. The read - // itself is independently capped because a file can grow after fstat. - if (info.size > MAX_CHAT_ATTACHMENT_BYTES) { - throw attachmentError(`Attachment ${JSON.stringify(name)} exceeds the 5 MiB limit.`) - } - - assertAttachmentName(name) - const bytes = await readBounded(handle, MAX_CHAT_ATTACHMENT_BYTES) - if (bytes.byteLength > MAX_CHAT_ATTACHMENT_BYTES) { - throw attachmentError(`Attachment ${JSON.stringify(name)} exceeds the 5 MiB limit.`) - } - if (bytes.byteLength === 0) { - throw attachmentError(`Attachment ${JSON.stringify(name)} is empty.`) - } - const { mediaType, limit } = inspectAttachment(path, bytes) - if (bytes.byteLength > limit) { - const label = limit === MAX_CHAT_TEXT_ATTACHMENT_BYTES ? '200 KiB' : '5 MiB' - throw attachmentError(`Attachment ${JSON.stringify(name)} exceeds the ${label} limit.`) - } - - return { name, mediaType, data: bytes.toString('base64') } - } catch (error) { - if (error instanceof SimApiError) throw error - throw attachmentError(`Could not read attachment ${JSON.stringify(path)}.`) - } finally { - await handle.close().catch(() => {}) - } -} - -export function decodedAttachmentBytes(attachment: ChatAttachment): number { - return Buffer.from(attachment.data, 'base64').byteLength -} - -/** Enforces count and aggregate limits whenever pending attachments are combined. */ -export function combineChatAttachments( - current: ChatAttachment[], - additions: ChatAttachment[] -): ChatAttachment[] { - const combined = [...current, ...additions] - if (combined.length > MAX_CHAT_ATTACHMENTS) { - throw attachmentError(`Sim Chat accepts at most ${MAX_CHAT_ATTACHMENTS} attachments per turn.`) - } - const bytes = combined.reduce((total, item) => total + decodedAttachmentBytes(item), 0) - if (bytes > MAX_CHAT_ATTACHMENTS_TOTAL_BYTES) { - throw attachmentError('Sim Chat attachments exceed the 10 MiB aggregate limit.') - } - return combined -} - -export async function loadChatAttachments(paths: string[]): Promise<ChatAttachment[]> { - if (paths.length > MAX_CHAT_ATTACHMENTS) { - throw attachmentError(`Sim Chat accepts at most ${MAX_CHAT_ATTACHMENTS} attachments per turn.`) - } - - const attachments: ChatAttachment[] = [] - let totalBytes = 0 - for (const path of paths) { - const attachment = await loadChatAttachment(path) - totalBytes += decodedAttachmentBytes(attachment) - if (totalBytes > MAX_CHAT_ATTACHMENTS_TOTAL_BYTES) { - throw attachmentError('Sim Chat attachments exceed the 10 MiB aggregate limit.') - } - attachments.push(attachment) - } - return attachments -} - -/** A token from a submitted line, with its span in the original input. */ -interface AttachmentToken { - value: string - start: number - end: number -} - -/** - * Splits an input line into shell-style tokens, honoring single quotes, double - * quotes and backslash escapes, and keeping each token's span so callers can - * rewrite the original text in place. - */ -function tokenizeAttachmentInput(input: string): AttachmentToken[] { - const tokens: AttachmentToken[] = [] - let value = '' - let quote: 'single' | 'double' | null = null - let escaped = false - let start = -1 - - const push = (end: number) => { - if (value) tokens.push({ value, start, end }) - value = '' - start = -1 - } - - for (let index = 0; index < input.length; index++) { - const character = input[index] as string - if (start < 0 && !/\s/.test(character)) start = index - if (escaped) { - value += character - escaped = false - continue - } - if (character === '\\' && quote !== 'single') { - escaped = true - continue - } - if (character === "'" && quote !== 'double') { - quote = quote === 'single' ? null : 'single' - continue - } - if (character === '"' && quote !== 'single') { - quote = quote === 'double' ? null : 'double' - continue - } - if (/\s/.test(character) && quote === null) { - push(index) - continue - } - value += character - } - - if (escaped) value += '\\' - if (quote !== null) throw attachmentError('Unclosed quote in attachment path.') - push(input.length) - return tokens -} - -/** - * Writes the clipboard image to a path given as argv[1]. - * - * Deliberately performs no size check: AppleScript cannot take `length of` raw - * data ("Can't make length of «data PNGf…»"), so a guard here throws for every - * image and the whole read fails. `loadChatAttachment` caps the size on fstat - * and again on read, which is where the limit belongs anyway. - */ -/** Returns the POSIX path of a file copied in Finder, which carries no text flavor. */ -const APPLE_SCRIPT_FILE = [ - 'on run', - 'return POSIX path of (the clipboard as «class furl»)', - 'end run', -] - -const APPLE_SCRIPT = [ - 'on run argv', - 'set outputPath to item 1 of argv', - 'try', - 'set imageData to the clipboard as «class PNGf»', - 'set outputFile to open for access POSIX file outputPath with write permission', - 'set eof outputFile to 0', - 'write imageData to outputFile', - 'close access outputFile', - 'on error', - 'try', - 'close access POSIX file outputPath', - 'end try', - 'error number -1700', - 'end try', - 'end run', -] - -/** Best-effort macOS clipboard image extraction, used by the paste keystroke. */ -export async function readClipboardAttachment(): Promise<ChatAttachment | null> { - if (process.platform !== 'darwin') return null - return (await readClipboardImage()) ?? (await readClipboardFile()) -} - -async function readClipboardImage(): Promise<ChatAttachment | null> { - const directory = await mkdtemp(join(tmpdir(), 'sim-chat-clipboard-')) - const path = join(directory, 'clipboard.png') - try { - const args = APPLE_SCRIPT.flatMap((line) => ['-e', line]) - args.push(path) - await execFileAsync('osascript', args, { timeout: 5_000 }) - return await loadChatAttachment(path) - } catch { - return null - } finally { - await unlink(path).catch(() => {}) - await rmdir(directory).catch(() => {}) - } -} - -/** - * Reads a file copied in Finder. - * - * The `furl` coercion is lenient — plain clipboard text comes back as a path - * that was never on disk — so the result is only trusted once it stats as a - * real file. - */ -async function readClipboardFile(): Promise<ChatAttachment | null> { - try { - const args = APPLE_SCRIPT_FILE.flatMap((line) => ['-e', line]) - const { stdout } = await execFileAsync('osascript', args, { timeout: 5_000 }) - const path = stdout.trim() - if (!path || !(await stat(path)).isFile()) return null - return await loadChatAttachment(path) - } catch { - return null - } -} - -/** True when every parsed path names an existing regular file. */ -async function isFile(path: string): Promise<boolean> { - try { - return (await stat(path)).isFile() - } catch { - return false - } -} - -/** Paths found inside a message, and the message with each replaced by a tag. */ -export interface ExtractedAttachments { - paths: string[] - text: string -} - -/** - * Pulls existing file paths out of a message, wherever they appear. - * - * A token only counts when it resolves to a real file, so prose that merely - * looks path-like — a snippet, a URL fragment — stays literal text. Each match - * is swapped for a `[File #N]` tag so the reader can see what was attached and - * delete it to detach. - */ -export async function extractAttachmentPaths(input: string): Promise<ExtractedAttachments | null> { - /* A path pasted whole may contain unescaped spaces, which tokenizing would - split apart, so the entire line gets the first look. */ - const whole = input.trim() - if (whole.includes('/') && (await isFile(whole))) return { paths: [whole], text: '[File #1]' } - - let tokens: AttachmentToken[] - try { - tokens = tokenizeAttachmentInput(input) - } catch { - return null - } - - const matches: Array<{ token: AttachmentToken; path: string }> = [] - for (const token of tokens) { - if (matches.length >= MAX_CHAT_ATTACHMENTS) break - if (!token.value.includes('/') && !token.value.includes('\\')) continue - if (await isFile(token.value)) matches.push({ token, path: token.value }) - } - if (matches.length === 0) return null - - let text = '' - let cursor = 0 - for (const [index, match] of matches.entries()) { - text += `${input.slice(cursor, match.token.start)}[File #${index + 1}]` - cursor = match.token.end - } - text += input.slice(cursor) - - return { paths: matches.map((match) => match.path), text: text.trim() } -} diff --git a/packages/sim-cli/src/commands/protocol/chat-markdown.test.ts b/packages/sim-cli/src/commands/protocol/chat-markdown.test.ts deleted file mode 100644 index c6a8f533451..00000000000 --- a/packages/sim-cli/src/commands/protocol/chat-markdown.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { ChatMarkdownStream } from './chat-markdown.js' - -const ESC = String.fromCharCode(27) - -describe('ChatMarkdownStream', () => { - it('styles headings, emphasis, lists, quotes, inline code, and fences across chunks', () => { - const stream = new ChatMarkdownStream(true) - const output = [ - stream.push('## Work'), - stream.push('space\n- **default'), - stream.push('-agent** with `code`\n> note\n```ts\nconst x = 1\n```'), - stream.finish(), - ].join('') - - expect(output).toContain(`${ESC}[1mWorkspace`) - expect(output).toContain(`${ESC}[2m•${ESC}[0m `) - expect(output).toContain('default-agent') - expect(output).not.toContain('**') - expect(output).not.toContain('`code`') - expect(output).toContain(`${ESC}[2m│${ESC}[0m note`) - expect(output).toContain(`${ESC}[2m┌─ ts${ESC}[0m`) - expect(output).toContain(`${ESC}[2mconst x = 1${ESC}[0m`) - expect(output).toContain(`${ESC}[2m└─${ESC}[0m`) - }) - - it('renders workspace summaries without exposing Markdown or styling identifier underscores', () => { - const stream = new ChatMarkdownStream(true) - const output = [ - stream.push("Here's what's in your workspace:\n\n**Workflows (3)**\n- forceful-arm\n"), - stream.push( - '- Table: cobalt_cloud\n- File: Mothership_Capability_Overview.pptx\n- **default-agent**' - ), - stream.finish(), - ].join('') - - expect(output).toContain(`${ESC}[1mWorkflows (3)${ESC}[0m`) - expect(output).toContain('cobalt_cloud') - expect(output).toContain('Mothership_Capability_Overview.pptx') - expect(output).toContain('default-agent') - expect(output).not.toContain('**') - expect(output).not.toContain(`${ESC}[3mcloud`) - expect(output).not.toContain(`${ESC}[3mCapability`) - }) - - it('renders Markdown links as visible labels without terminal hyperlinks or destinations', () => { - const stream = new ChatMarkdownStream(true) - expect(stream.push('[Sim](https://sim.ai/work')).toBe('') - expect(stream.push('space)')).toBe('Sim') - - const misleading = new ChatMarkdownStream(true) - const misleadingOutput = misleading.push('[notexample.com](https://example.com/)') - expect(misleadingOutput).toBe('notexample.com') - - const unsafe = new ChatMarkdownStream(true) - const unsafeOutput = unsafe.push('[bad](javascript:alert(1))') - expect(unsafeOutput).toBe('bad') - - const userInfo = new ChatMarkdownStream(true) - const userInfoOutput = userInfo.push('[login](https://trusted.example@evil.example/)') - expect(userInfoOutput).toBe('login') - expect(`${misleadingOutput}${unsafeOutput}${userInfoOutput}`).not.toContain(`${ESC}]8;;`) - }) - - it('never prefixes streamed list items with an undefined renderer value', () => { - const stream = new ChatMarkdownStream(true) - const output = `${stream.push('- ')}${stream.flushInline()}default-agent${stream.finish()}` - - expect(output).toContain('default-agent') - expect(output).not.toContain('undefined') - }) - - it('bounds incomplete link candidates and does not hide multiline prose', () => { - const longLabel = `[${'x'.repeat(300)}` - const labelStream = new ChatMarkdownStream(true) - expect(labelStream.push(longLabel)).toBe(longLabel) - - const longDestination = `[label](https://example.com/${'x'.repeat(2_100)}` - const destinationStream = new ChatMarkdownStream(true) - expect(destinationStream.push(longDestination)).toBe(longDestination) - - const multiline = new ChatMarkdownStream(true) - expect(multiline.push('[not a link\nnext line')).toBe('[not a link\nnext line') - }) - - it('sanitizes model controls before applying renderer-owned terminal styling', () => { - const stream = new ChatMarkdownStream(true) - const output = stream.push(`**safe${ESC}]0;owned\u0007**`) - expect(output).toContain('safe') - expect(output).not.toContain('owned') - expect(output).not.toContain(`${ESC}]0;`) - }) - - it('is a sanitized byte-preserving stream when terminal styling is disabled', () => { - const stream = new ChatMarkdownStream(false) - expect(stream.push('**plain**\n')).toBe('**plain**\n') - expect(stream.finish()).toBe('') - }) -}) diff --git a/packages/sim-cli/src/commands/protocol/chat-markdown.ts b/packages/sim-cli/src/commands/protocol/chat-markdown.ts deleted file mode 100644 index 1070f9ad7fa..00000000000 --- a/packages/sim-cli/src/commands/protocol/chat-markdown.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { sanitize } from '../../output/render.js' - -const ESC = String.fromCharCode(27) -const RESET = `${ESC}[0m` -const BOLD = `${ESC}[1m` -const DIM = `${ESC}[2m` -const ITALIC = `${ESC}[3m` -const CYAN = `${ESC}[36m` -const MAX_LINK_LABEL_LENGTH = 256 -const MAX_LINK_DESTINATION_LENGTH = 2_048 - -interface InlineStyle { - bold: boolean - italic: boolean - code: boolean -} - -/** - * A deliberately small streaming Markdown renderer for interactive chat. - * - * It does not parse HTML and it never accepts terminal escapes from the model: - * input is sanitized first, then the renderer adds its own fixed SGR - * sequences. Markdown links intentionally render as their visible label only; - * terminal hyperlink support varies and hidden destinations are surprising in - * a CLI transcript. Unlike a whole-document Markdown parser, this keeps ordinary prose - * streaming as soon as it arrives. Only a possible Markdown link is buffered - * until its closing `)` makes the URL safe to validate. - */ -export class ChatMarkdownStream { - private readonly style: InlineStyle = { bold: false, italic: false, code: false } - private pending = '' - private atLineStart = true - private inFence = false - - constructor(private readonly enabled: boolean) {} - - push(fragment: string): string { - const safe = sanitize(fragment) - if (!this.enabled) return safe - this.pending += safe - return this.drain(false) - } - - /** Flushes an inline prefix before a trusted structured tag is written. */ - flushInline(): string { - if (!this.enabled || !this.pending) return '' - return this.drain(true) - } - - finish(): string { - if (!this.enabled) return '' - const rendered = this.drain(true) - return rendered + (this.hasStyle() ? this.resetStyles() : '') - } - - private drain(final: boolean): string { - let output = '' - - while (this.pending) { - if (this.atLineStart) { - const prefix = this.consumeLinePrefix(final) - if (prefix === null) break - output += prefix - if (!this.pending) break - } - - if (this.pending.startsWith('\n')) { - this.pending = this.pending.slice(1) - output += this.resetStyles() - output += '\n' - this.atLineStart = true - continue - } - - if (this.inFence) { - const newline = this.pending.indexOf('\n') - const amount = newline === -1 ? (final ? this.pending.length : 0) : newline - if (amount === 0) break - output += `${DIM}${this.pending.slice(0, amount)}${RESET}` - this.pending = this.pending.slice(amount) - continue - } - - const link = this.tryMarkdownLink(final) - if (link.kind === 'wait') break - if (link.kind === 'rendered') { - output += link.value - continue - } - - if (this.pending.startsWith('`')) { - this.pending = this.pending.slice(1) - this.style.code = !this.style.code - output += this.applyStyles() - continue - } - // Workspace identifiers and file names commonly contain underscores, so - // only asterisks act as emphasis delimiters in this compact renderer. - if (!this.style.code && this.pending.startsWith('**')) { - this.pending = this.pending.slice(2) - this.style.bold = !this.style.bold - output += this.applyStyles() - continue - } - if (!this.style.code && this.pending.startsWith('*')) { - if (!final && this.pending.length === 1) break - this.pending = this.pending.slice(1) - this.style.italic = !this.style.italic - output += this.applyStyles() - continue - } - - // A trailing marker may be the first half of a delimiter in the next SSE - // chunk. Hold it for one beat instead of briefly printing raw Markdown. - if (!final && this.pending.length === 1 && /[[\]*`]/u.test(this.pending)) break - - output += this.pending[0] - this.pending = this.pending.slice(1) - } - - return output - } - - private consumeLinePrefix(final: boolean): string | null { - const newline = this.pending.indexOf('\n') - const candidate = newline === -1 ? this.pending : this.pending.slice(0, newline) - if (!final && newline === -1 && candidate.length < 4 && /^[#>*+\-\d. `]*$/u.test(candidate)) { - return null - } - - const fence = candidate.match(/^\s*```\s*([^\s`]*)\s*$/u) - if (fence) { - this.pending = this.pending.slice(candidate.length) - this.inFence = !this.inFence - this.atLineStart = false - return this.inFence ? `${DIM}┌─${fence[1] ? ` ${fence[1]}` : ''}${RESET}` : `${DIM}└─${RESET}` - } - - if (this.inFence) { - this.atLineStart = false - return '' - } - - const heading = candidate.match(/^\s{0,3}#{1,6}\s+/u) - if (heading) { - this.pending = this.pending.slice(heading[0].length) - this.style.bold = true - this.atLineStart = false - return BOLD - } - - const bullet = candidate.match(/^(\s{0,8})[-+*]\s+/u) - if (bullet) { - this.pending = this.pending.slice(bullet[0].length) - this.atLineStart = false - return `${bullet[1]}${DIM}•${RESET} ${this.applyStyles(false)}` - } - - const quote = candidate.match(/^(\s{0,3})>\s?/u) - if (quote) { - this.pending = this.pending.slice(quote[0].length) - this.atLineStart = false - return `${quote[1]}${DIM}│${RESET} ` - } - - const rule = candidate.match(/^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/u) - if (rule) { - this.pending = this.pending.slice(candidate.length) - this.atLineStart = false - return `${DIM}${'─'.repeat(24)}${RESET}` - } - - this.atLineStart = false - return '' - } - - private tryMarkdownLink( - final: boolean - ): { kind: 'none' } | { kind: 'wait' } | { kind: 'rendered'; value: string } { - if (!this.pending.startsWith('[') || this.style.code || this.inFence) return { kind: 'none' } - - const labelEnd = this.pending.indexOf('](') - if (labelEnd === -1) { - const couldStillBeLink = - !final && !this.pending.includes('\n') && this.pending.length <= MAX_LINK_LABEL_LENGTH + 2 - return couldStillBeLink ? { kind: 'wait' } : { kind: 'none' } - } - const label = this.pending.slice(1, labelEnd) - if (!label || label.includes('\n') || label.length > MAX_LINK_LABEL_LENGTH) { - return { kind: 'none' } - } - - let depth = 1 - let escaped = false - let end = labelEnd + 2 - for (; end < this.pending.length; end += 1) { - if (end - labelEnd - 2 > MAX_LINK_DESTINATION_LENGTH) return { kind: 'none' } - const character = this.pending[end] - if (escaped) { - escaped = false - continue - } - if (character === '\\') { - escaped = true - continue - } - if (character === '(') depth += 1 - if (character === ')') { - depth -= 1 - if (depth === 0) break - } - if (character === '\n') return { kind: 'none' } - } - if (end >= this.pending.length) { - const destinationLength = this.pending.length - labelEnd - 2 - return !final && destinationLength <= MAX_LINK_DESTINATION_LENGTH - ? { kind: 'wait' } - : { kind: 'none' } - } - - this.pending = this.pending.slice(end + 1) - return { kind: 'rendered', value: label } - } - - private hasStyle(): boolean { - return this.style.bold || this.style.italic || this.style.code - } - - private resetStyles(): string { - const hadStyle = this.hasStyle() - this.style.bold = false - this.style.italic = false - this.style.code = false - return hadStyle ? RESET : '' - } - - private applyStyles(reset = true): string { - let output = reset ? RESET : '' - if (this.style.bold) output += BOLD - if (this.style.italic) output += ITALIC - if (this.style.code) output += `${CYAN}${DIM}` - return output - } -} diff --git a/packages/sim-cli/src/commands/protocol/chat-mentions.test.ts b/packages/sim-cli/src/commands/protocol/chat-mentions.test.ts deleted file mode 100644 index 8e857695ab7..00000000000 --- a/packages/sim-cli/src/commands/protocol/chat-mentions.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { PassThrough } from 'node:stream' -import { describe, expect, it } from 'vitest' -import { ReadlineChatTerminal } from './chat-terminal.js' - -const ESC = String.fromCharCode(27) -const MENTION = `${ESC}[38;2;51;196;130m` -const BODY_TEXT = `${ESC}[38;2;242;242;242m` -const BACKSPACE = String.fromCharCode(127) - -function harness() { - const input = new PassThrough() as PassThrough & { isTTY: boolean; setRawMode?: () => void } - const output = new PassThrough() as PassThrough & { - isTTY: boolean - columns: number - rows: number - } - input.isTTY = true - input.setRawMode = () => {} - output.isTTY = true - output.columns = 80 - output.rows = 20 - const terminal = new ReadlineChatTerminal(input as never, output as never) - terminal.setSuggestionCandidates({ - resources: [ - { - id: 'w1', - value: 'code-review', - displayText: 'code-review', - tag: 'workflow', - context: { - kind: 'workflow', - workflowId: 'w1', - label: 'code-review', - }, - }, - { - id: 'w2', - value: 'release notes', - displayText: 'release notes', - tag: 'workflow', - context: { - kind: 'workflow', - workflowId: 'w2', - label: 'release notes', - }, - }, - ], - slash: [ - { - id: 's1', - value: 'review', - displayText: '/review', - tag: 'skill', - context: { kind: 'skill', skillId: 's1', label: 'review' }, - }, - ], - }) - const probe = terminal as never as { buildPanel(rows: number): { lines: string[] } } - return { - input, - terminal, - draftRow: () => probe.buildPanel(20).lines.find((line) => line.includes('>')) ?? '', - } -} - -describe('mention highlighting', () => { - it('lights a mention that resolves to a candidate', () => { - const { input, terminal, draftRow } = harness() - void terminal.read('> ') - input.write('run @code\tnow') - expect(draftRow()).toContain(`${MENTION}@code-review${BODY_TEXT}`) - terminal.close() - }) - - it('goes plain once the mention is half-deleted', () => { - const { input, terminal, draftRow } = harness() - void terminal.read('> ') - input.write('run @code\t') - expect(draftRow()).toContain(MENTION) - for (let i = 0; i < 3; i++) input.write(BACKSPACE) - expect(draftRow()).not.toContain(MENTION) - terminal.close() - }) - - it('does not light an unknown mention or an email address', () => { - const { input, terminal, draftRow } = harness() - void terminal.read('> ') - input.write('ping @nobody and me@example.com') - expect(draftRow()).not.toContain(MENTION) - terminal.close() - }) - - it('lights the client-style literal mention containing a space', () => { - const { input, terminal, draftRow } = harness() - void terminal.read('> ') - input.write('draft @release\tplease') - expect(draftRow()).toContain(`${MENTION}@release notes${BODY_TEXT}`) - terminal.close() - }) - - it('lights a typed exact slash skill once it resolves', () => { - const { input, terminal, draftRow } = harness() - void terminal.read('> ') - input.write('use /review now') - expect(draftRow()).toContain(`${MENTION}/review${BODY_TEXT}`) - terminal.close() - }) - - it('closes the style at a row break so it cannot leak', () => { - const { input, terminal } = harness() - void terminal.read('> ') - input.write(`${'x'.repeat(75)} @code\ttail`) - const probe = terminal as never as { buildPanel(rows: number): { lines: string[] } } - for (const line of probe.buildPanel(20).lines) { - const opens = line.split(MENTION).length - 1 - const closes = line.split(`${ESC}[0m`).length - 1 - expect(closes).toBeGreaterThanOrEqual(opens) - } - terminal.close() - }) -}) diff --git a/packages/sim-cli/src/commands/protocol/chat-paste.test.ts b/packages/sim-cli/src/commands/protocol/chat-paste.test.ts deleted file mode 100644 index a1e993f8f42..00000000000 --- a/packages/sim-cli/src/commands/protocol/chat-paste.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { PassThrough } from 'node:stream' -import { describe, expect, it } from 'vitest' -import { ReadlineChatTerminal } from './chat-terminal.js' - -const ESC = String.fromCharCode(27) -const PASTE_START = `${ESC}[200~` -const PASTE_END = `${ESC}[201~` - -function harness() { - const input = new PassThrough() as PassThrough & { isTTY: boolean; setRawMode?: () => void } - const output = new PassThrough() as PassThrough & { - isTTY: boolean - columns: number - rows: number - } - input.isTTY = true - input.setRawMode = () => {} - output.isTTY = true - output.columns = 80 - output.rows = 20 - const terminal = new ReadlineChatTerminal(input as never, output as never) - return { input, terminal, draft: () => (terminal as never as { draft: string }).draft } -} - -describe('bracketed paste', () => { - it('inserts a short single-line paste literally', () => { - const { input, terminal, draft } = harness() - void terminal.read('> ') - input.write(`${PASTE_START}hello world${PASTE_END}`) - expect(draft()).toBe('hello world') - terminal.close() - }) - - it('collapses a multi-line paste to a placeholder and expands it on submit', async () => { - const { input, terminal, draft } = harness() - const result = terminal.read('> ') - const body = 'line one\nline two\nline three\nline four' - input.write(`${PASTE_START}${body}${PASTE_END}`) - expect(draft()).toBe('[Pasted text #1 +3 lines]') - input.write('\r') - await expect(result).resolves.toEqual({ - kind: 'line', - value: body, - display: '[Pasted text #1 +3 lines]', - pastes: new Map([[1, body]]), - }) - terminal.close() - }) - - it('collapses a long single-line paste', () => { - const { input, terminal, draft } = harness() - void terminal.read('> ') - input.write(`${PASTE_START}${'x'.repeat(900)}${PASTE_END}`) - expect(draft()).toBe('[Pasted text #1]') - terminal.close() - }) - - it('drops a stashed body when its placeholder is deleted', async () => { - const { input, terminal, draft } = harness() - const result = terminal.read('> ') - input.write(`${PASTE_START}a\nb\nc\nd${PASTE_END}`) - const BACKSPACE = String.fromCharCode(127) - let guard = 200 - while (draft().length > 0 && guard-- > 0) input.write(BACKSPACE) - input.write('plain') - input.write('\r') - await expect(result).resolves.toEqual({ kind: 'line', value: 'plain' }) - terminal.close() - }) - - it('routes an empty paste to the clipboard, for macOS cmd+v of an image', async () => { - const { input, terminal } = harness() - const result = terminal.read('> ') - input.write(`${PASTE_START}${PASTE_END}`) - await expect(result).resolves.toEqual({ kind: 'clipboard', value: '' }) - terminal.close() - }) -}) diff --git a/packages/sim-cli/src/commands/protocol/chat-path-extraction.test.ts b/packages/sim-cli/src/commands/protocol/chat-path-extraction.test.ts deleted file mode 100644 index 839559f705e..00000000000 --- a/packages/sim-cli/src/commands/protocol/chat-path-extraction.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { extractAttachmentPaths } from './chat-attachments.js' - -let dir: string -let file: string -let spaced: string - -beforeAll(() => { - dir = mkdtempSync(join(tmpdir(), 'sim-extract-')) - file = join(dir, 'report.pdf') - spaced = join(dir, 'my report.pdf') - writeFileSync(file, 'x') - writeFileSync(spaced, 'x') -}) -afterAll(() => rmSync(dir, { recursive: true, force: true })) - -describe('extractAttachmentPaths', () => { - it('pulls a path out of surrounding prose and leaves a tag', async () => { - const result = await extractAttachmentPaths(`summarize ${file} for me`) - expect(result).toEqual({ paths: [file], text: 'summarize [File #1] for me' }) - }) - - it('handles a path at the start or end of the line', async () => { - expect((await extractAttachmentPaths(`${file} what is this`))?.text).toBe( - '[File #1] what is this' - ) - expect((await extractAttachmentPaths(`look at ${file}`))?.text).toBe('look at [File #1]') - }) - - it('numbers multiple attachments in order', async () => { - const result = await extractAttachmentPaths(`diff ${file} against ${file}`) - expect(result?.text).toBe('diff [File #1] against [File #2]') - expect(result?.paths).toHaveLength(2) - }) - - it('understands quoted and escaped paths with spaces', async () => { - expect((await extractAttachmentPaths(`read "${spaced}" please`))?.paths).toEqual([spaced]) - const escaped = spaced.replace(/ /gu, '\\ ') - expect((await extractAttachmentPaths(`read ${escaped} please`))?.paths).toEqual([spaced]) - }) - - it('leaves path-like prose alone when the file does not exist', async () => { - expect(await extractAttachmentPaths('check /nope/missing.png please')).toBeNull() - expect(await extractAttachmentPaths('see src/does-not-exist.ts line 4')).toBeNull() - }) - - it('attaches a relative path that resolves against the working directory', async () => { - expect((await extractAttachmentPaths('read src/index.ts'))?.paths).toEqual(['src/index.ts']) - }) - - it('returns null for a message with no paths', async () => { - expect(await extractAttachmentPaths('hello there')).toBeNull() - }) - - it('takes a whole line that is one unescaped path with spaces', async () => { - expect((await extractAttachmentPaths(` ${spaced} `))?.paths).toEqual([spaced]) - }) - - it('stops at the per-turn attachment limit and leaves the rest as text', async () => { - const line = Array.from({ length: 6 }, () => file).join(' ') - const result = await extractAttachmentPaths(line) - expect(result?.paths).toHaveLength(5) - expect(result?.text).toBe(`[File #1] [File #2] [File #3] [File #4] [File #5] ${file}`) - }) - - it('keeps the line breaks in a multi-line message', async () => { - const result = await extractAttachmentPaths(`first line\nsummarize ${file}\nlast line`) - expect(result?.text).toBe('first line\nsummarize [File #1]\nlast line') - }) - - it('does not throw on an unclosed quote', async () => { - expect(await extractAttachmentPaths(`read "${file}`)).toBeNull() - }) -}) diff --git a/packages/sim-cli/src/commands/protocol/chat-structured.test.ts b/packages/sim-cli/src/commands/protocol/chat-structured.test.ts deleted file mode 100644 index 1c95e3f826e..00000000000 --- a/packages/sim-cli/src/commands/protocol/chat-structured.test.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - ChatStructuredParser, - parseChatStructured, - renderChatStructured, -} from './chat-structured.js' - -const ESC = String.fromCharCode(27) - -function parseChunks(chunks: string[]) { - const parser = new ChatStructuredParser() - return [...chunks.flatMap((chunk) => parser.push(chunk)), ...parser.finish()] -} - -describe('ChatStructuredParser', () => { - it('parses every official tag when wrappers are split across chunks', () => { - const content = [ - 'Answer ', - '<thinking>private</thinking>', - '<options>{"1":{"title":"Next","description":"Continue"}}</options>', - '<question>{"type":"single_select","prompt":"Choose","options":[{"id":"a","label":"A"}]}</question>', - '<credential>{"type":"link","provider":"Slack","value":"https://sim.ai/connect?id=1"}</credential>', - '<workspace_resource>{"type":"workflow","id":"wf_1","title":"Daily sync"}</workspace_resource>', - '<usage_upgrade>{"reason":"quota","action":"upgrade_plan","message":"Upgrade now"}</usage_upgrade>', - '<mothership-error>{"message":"Unavailable","code":"MODEL_DOWN"}</mothership-error>', - ].join('') - - const segments = parseChunks([...content]) - - expect(segments.map((segment) => segment.kind).filter((kind) => kind !== 'text')).toEqual([ - 'thinking', - 'options', - 'question', - 'credential', - 'workspace_resource', - 'usage_upgrade', - 'mothership-error', - ]) - }) - - it('does not treat a closing marker inside a JSON string as the tag boundary', () => { - const segments = parseChunks([ - '<opt', - 'ions>{"1":{"title":"Show </options> literally","description":"escaped \\\"quote\\\""}}</opt', - 'ions>', - ]) - - expect(segments).toEqual([ - { - kind: 'options', - choices: [ - { - value: 'Show </options> literally', - label: 'Show </options> literally', - description: 'escaped "quote"', - }, - ], - }, - ]) - }) - - it('preserves valid-looking structured examples inside inline and fenced code', () => { - const inline = '`<options>{"1":{"title":"A","description":"B"}}</options>`' - const fenced = - '```json\n<question>{"type":"single_select","prompt":"P","options":[{"id":"a","label":"A"}]}</question>\n```' - const content = `${inline}\n${fenced}` - - expect(renderChatStructured(parseChunks([...content])).text).toBe(content) - }) - - it('strips malformed options and preserves unknown tags as sanitized text', () => { - const content = `before <options>{bad${ESC}[2A</options> <future>${ESC}]0;pwned\u0007ok</future>` - - const result = renderChatStructured(parseChatStructured(content)) - - expect(result.text).toContain('before<future>') - expect(result.text).toContain('<future>ok</future>') - expect(result.text).not.toContain('interactive response') - expect(result.text).not.toContain(ESC) - }) - - it('holds incomplete wrappers until finish and then preserves them', () => { - const parser = new ChatStructuredParser() - - expect(parser.push('answer <quest')).toEqual([{ kind: 'text', text: 'answer ' }]) - expect(parser.push('ion>{"type":"single_select"')).toEqual([]) - expect(parser.finish()).toEqual([ - { kind: 'text', text: 'Sim Chat requested an interactive response.' }, - ]) - }) - - it('drops an unclosed thinking wrapper when the stream finishes', () => { - const parser = new ChatStructuredParser() - - expect(parser.push('answer <thinking>still reasoning about')).toEqual([ - { kind: 'text', text: 'answer ' }, - ]) - expect(parser.finish()).toEqual([]) - }) - - it('recovers useful prompts from invalid but parseable question payloads', () => { - const segments = parseChatStructured( - '<question>[{"type":"single_select","prompt":"Which\\nservice?","options":[]},{"prompt":"Deploy where?"}]</question>' - ) - - expect(segments).toEqual([{ kind: 'text', text: 'Which service?\n\nDeploy where?' }]) - }) - - it('recovers a prompt from an otherwise complete question missing its closing tag', () => { - const parser = new ChatStructuredParser() - - expect( - parser.push( - '<question>{"type":"single_select","prompt":"Continue?","options":[{"id":"yes","label":"Yes"}]}' - ) - ).toEqual([]) - expect(parser.finish()).toEqual([{ kind: 'text', text: 'Continue?' }]) - }) - - it('rejects question payloads beyond the interaction bounds and bounds prompt recovery', () => { - const fourQuestions = Array.from({ length: 4 }, (_, index) => ({ - type: 'single_select', - prompt: `Question ${index + 1}`, - options: [{ id: 'yes', label: 'Yes' }], - })) - const tooManyOptions = { - type: 'single_select', - prompt: 'Pick one', - options: Array.from({ length: 21 }, (_, index) => ({ - id: `option-${index}`, - label: `Option ${index}`, - })), - } - - expect(parseChatStructured(`<question>${JSON.stringify(fourQuestions)}</question>`)).toEqual([ - { kind: 'text', text: 'Question 1\n\nQuestion 2\n\nQuestion 3' }, - ]) - expect(parseChatStructured(`<question>${JSON.stringify(tooManyOptions)}</question>`)).toEqual([ - { kind: 'text', text: 'Pick one' }, - ]) - }) - - it('accepts question values at their limits and rejects overlong prompt, id, and label fields', () => { - const boundedQuestion = { - type: 'multi_select', - prompt: 'p'.repeat(1024), - options: Array.from({ length: 20 }, (_, index) => ({ - id: `${index}-${'i'.repeat(157)}`, - label: 'l'.repeat(160), - })), - } - const atLimits = parseChatStructured( - `<question>${JSON.stringify([boundedQuestion, boundedQuestion, boundedQuestion])}</question>` - ) - - expect(atLimits).toHaveLength(1) - expect(atLimits[0]?.kind).toBe('question') - if (atLimits[0]?.kind !== 'question') throw new Error('Expected a question segment') - expect(atLimits[0].questions).toHaveLength(3) - expect(atLimits[0].questions[0]?.options).toHaveLength(20) - - for (const invalid of [ - { ...boundedQuestion, prompt: 'p'.repeat(1025) }, - { ...boundedQuestion, options: [{ id: 'i'.repeat(161), label: 'Valid' }] }, - { ...boundedQuestion, options: [{ id: 'valid', label: 'l'.repeat(161) }] }, - ]) { - const segments = parseChatStructured(`<question>${JSON.stringify(invalid)}</question>`) - expect(segments.some((segment) => segment.kind === 'question')).toBe(false) - } - }) - - it('strips an incomplete options wrapper at end of stream', () => { - const parser = new ChatStructuredParser() - - expect(parser.push('answer <options>{"1":{"title":"Next"')).toEqual([ - { kind: 'text', text: 'answer ' }, - ]) - expect(parser.finish()).toEqual([{ kind: 'options', choices: [] }]) - }) - - it('keeps a CRLF stable when its bytes arrive in separate string fragments', () => { - expect(renderChatStructured(parseChunks(['first\r', '\nsecond'])).text).toBe('first\nsecond') - }) - - it('strips options even when their decoded values contain controls', () => { - const result = renderChatStructured( - '<options>{"1":{"title":"Safe\\u001b[2A title","description":"D"}}</options>' - ) - - expect(result).toMatchObject({ text: '', interactions: [] }) - }) - - it('sanitizes directly supplied segments as a defense-in-depth boundary', () => { - const result = renderChatStructured([ - { kind: 'text', text: `safe${ESC}[2A text` }, - { - kind: 'options', - choices: [{ value: `next${ESC}c`, label: `Next${ESC}]0;x\u0007`, description: 'D' }], - }, - ]) - - expect(result).toMatchObject({ text: 'safe text', interactions: [] }) - }) - - it('flattens interactive prompts, labels, and descriptions onto terminal-safe lines', () => { - const result = renderChatStructured( - [ - '<options>{"1":{"title":"Inspect\\nlogs","description":"Find\\t recent\\nerrors"}}</options>', - '<question>{"type":"single_select","prompt":"Which\\nservice?","options":[{"id":"a\\nb","label":"API\\nworker"}]}</question>', - ].join(''), - { printMode: false } - ) - - expect(result.interactions).toEqual([ - { - kind: 'question', - questions: [ - { - type: 'single_select', - prompt: 'Which service?', - options: [{ id: 'a b', label: 'API worker' }], - }, - ], - }, - ]) - }) -}) - -describe('renderChatStructured', () => { - it('strips options while preserving question interactions and print text', () => { - const result = renderChatStructured( - [ - '<options>{"1":{"title":"Inspect logs","description":"Find errors"}}</options>', - '<question>[{"type":"multi_select","prompt":"Pick services","options":[{"id":"api","label":"API"},{"id":"other","label":"Something else"}]}]</question>', - ].join('\n') - ) - - expect(result.text).toBe('Pick services') - expect(result.interactions).toEqual([ - { - kind: 'question', - questions: [ - { - type: 'multi_select', - prompt: 'Pick services', - options: [{ id: 'api', label: 'API' }], - }, - ], - }, - ]) - }) - - it('strips options without producing an interaction outside print mode', () => { - const result = renderChatStructured( - 'before<options>{"1":{"title":"Next","description":"Continue"}}</options>after', - { printMode: false } - ) - - expect(result.text).toBe('beforeafter') - expect(result.interactions).toEqual([]) - }) - - it('removes whitespace surrounding hidden options at the end of print output', () => { - const options = '<options>{"1":{"title":"Next","description":"Continue"}}</options>' - - expect(renderChatStructured(`Answer\n\n${options}\n\n`).text).toBe('Answer') - expect(renderChatStructured(`before \n${options}\n after`).text).toBe('beforeafter') - expect(renderChatStructured('Answer\n\n<options>{"1":{"title":"Next"').text).toBe('Answer') - }) - - it('renders workspace resources as names without terminal links or URL suffixes', () => { - const result = renderChatStructured( - '<workspace_resource>{"type":"workflow","id":"wf /1","title":"My workflow"}</workspace_resource>' - ) - - expect(result.text).toBe('My workflow') - expect(result.text).not.toContain(ESC) - expect(result.text).not.toContain('https://') - }) - - it('shows a path-only file title without resolving or appending its VFS path', () => { - const result = renderChatStructured( - '<workspace_resource>{"type":"file","path":"files/Reports/Q4%20Report.csv","title":"Q4"}</workspace_resource>' - ) - - expect(result.text).toBe('Q4') - }) - - it('rejects unsafe credential protocols and control-bearing links', () => { - const unsafeProtocol = renderChatStructured( - '<credential>{"type":"link","provider":"Slack","value":"javascript:alert(1)"}</credential>' - ) - const controlBearing = renderChatStructured( - '<credential>{"type":"link","provider":"Slack","value":"https://safe.test/\\u001b]8;;https://evil.test"}</credential>' - ) - - expect(unsafeProtocol.text).toBe('Open Sim to connect Slack.') - expect(unsafeProtocol.text).not.toContain(ESC) - expect(controlBearing.text).toBe('Open Sim to complete the requested credential action.') - expect(controlBearing.text).not.toContain(ESC) - }) - - it('renders credential links as a plain action without exposing the destination', () => { - const content = - '<credential>{"type":"link","provider":"Slack","value":"https://sim.example.evil.test/connect"}</credential>' - const result = renderChatStructured(content) - - expect(result.text).toBe('Open Sim to connect Slack.') - expect(result.text).not.toContain('sim.example.evil.test') - expect(result.text).not.toContain(ESC) - }) - - it('sanitizes workspace titles before rendering a plain resource name', () => { - const result = renderChatStructured( - '<workspace_resource>{"type":"table","id":"table_1","title":"Orders\\u001b]0;owned\\u0007 safe"}</workspace_resource>' - ) - - expect(result.text).toBe('Orders safe') - expect(result.text).not.toContain(ESC) - }) - - it('never renders credential secret values', () => { - const result = renderChatStructured( - '<credential>{"type":"sim_key","provider":"Sim","value":"secret-value"}</credential>' - ) - - expect(result.text).toBe('Open Sim to configure a Sim API key.') - expect(result.text).not.toContain('secret-value') - }) - - it.each([ - ['env_key', 'Open Sim to configure Slack environment credentials.'], - ['oauth_key', 'Open Sim to connect Slack with OAuth.'], - ['credential_id', 'Open Sim to select Slack credentials.'], - ])('renders %s as a safe action without its value', (type, expected) => { - const result = renderChatStructured( - `<credential>{"type":"${type}","provider":"Slack","value":"never-print-me"}</credential>` - ) - - expect(result.text).toBe(expected) - expect(result.text).not.toContain('never-print-me') - }) - - it('hides thinking and safely renders usage and mothership errors', () => { - const result = renderChatStructured( - 'Answer<thinking>secret reasoning</thinking><usage_upgrade>{"reason":"quota","action":"increase_limit","message":"Increase limit"}</usage_upgrade><mothership-error>{"message":"Retry later","code":"BUSY","provider":"x"}</mothership-error>' - ) - - expect(result.text).toBe('Answer\n\nUsage limit reached: Increase limit\n\nRetry later (BUSY)') - expect(result.text).not.toContain('secret reasoning') - }) -}) diff --git a/packages/sim-cli/src/commands/protocol/chat-structured.ts b/packages/sim-cli/src/commands/protocol/chat-structured.ts deleted file mode 100644 index 999de64e7d1..00000000000 --- a/packages/sim-cli/src/commands/protocol/chat-structured.ts +++ /dev/null @@ -1,748 +0,0 @@ -import { sanitize } from '../../output/render.js' - -export const OFFICIAL_CHAT_TAG_NAMES = [ - 'thinking', - 'options', - 'question', - 'credential', - 'workspace_resource', - 'usage_upgrade', - 'mothership-error', -] as const - -export type OfficialChatTagName = (typeof OFFICIAL_CHAT_TAG_NAMES)[number] - -export interface ChatChoice { - value: string - label: string - description: string -} - -export interface ChatQuestionOption { - id: string - label: string -} - -export interface ChatQuestion { - type: 'single_select' | 'multi_select' - prompt: string - options: ChatQuestionOption[] -} - -export interface ChatOptionsInteraction { - kind: 'options' - choices: ChatChoice[] -} - -export interface ChatQuestionInteraction { - kind: 'question' - questions: ChatQuestion[] -} - -export type ChatInteraction = ChatOptionsInteraction | ChatQuestionInteraction - -export type ChatCredentialType = - | 'env_key' - | 'oauth_key' - | 'sim_key' - | 'credential_id' - | 'link' - | 'secret_input' - | 'folder_access' - | 'browser_takeover' - | 'terminal_handoff' - | 'service_account' - -export interface ChatCredential { - type: ChatCredentialType - provider?: string - value?: string - name?: string - scope?: 'personal' | 'workspace' - credentialId?: string -} - -export interface ChatWorkspaceResource { - type: 'workflow' | 'table' | 'file' - id?: string - path?: string - title?: string -} - -export interface ChatUsageUpgrade { - reason: string - action: 'upgrade_plan' | 'increase_limit' - message: string -} - -export interface ChatMothershipError { - message: string - code?: string - provider?: string -} - -export type ChatStructuredSegment = - | { kind: 'text'; text: string } - | { kind: 'thinking'; content: string } - | { kind: 'options'; choices: ChatChoice[] } - | { kind: 'question'; questions: ChatQuestion[] } - | { kind: 'credential'; credential: ChatCredential } - | { kind: 'workspace_resource'; resource: ChatWorkspaceResource } - | { kind: 'usage_upgrade'; upgrade: ChatUsageUpgrade } - | { kind: 'mothership-error'; error: ChatMothershipError } - -export interface ChatStructuredRenderOptions { - printMode?: boolean -} - -export interface ChatStructuredRenderResult { - text: string - interactions: ChatInteraction[] - /** - * The parts `text` was joined from, each tagged block or inline. - * - * Exposed so an incremental renderer can reuse this classification instead of - * re-deriving it per segment kind — two copies of that rule drift apart and - * nothing catches it, since only one of them is exercised by the one-shot path. - */ - parts: readonly RenderPart[] -} - -interface OpeningTagMatch { - index: number - name: OfficialChatTagName -} - -export interface RenderPart { - block: boolean - value: string -} - -type JsonRecord = Record<string, unknown> - -const OPENING_TAGS = OFFICIAL_CHAT_TAG_NAMES.map((name) => ({ - marker: `<${name}>`, - name, -})) - -const QUESTION_TYPES = new Set(['single_select', 'multi_select']) -const MAX_QUESTIONS = 3 -const MAX_QUESTION_OPTIONS = 20 -const MAX_QUESTION_PROMPT_LENGTH = 1024 -const MAX_QUESTION_OPTION_FIELD_LENGTH = 160 -const CREDENTIAL_TYPES = new Set<ChatCredentialType>([ - 'env_key', - 'oauth_key', - 'sim_key', - 'credential_id', - 'link', - 'secret_input', - 'folder_access', - 'browser_takeover', - 'terminal_handoff', - 'service_account', -]) -const QUESTION_CATCH_ALL_LABELS = new Set([ - 'other', - 'others', - 'something else', - 'none of the above', - 'none of these', -]) -const TERMINAL_CONTROL_PATTERN = /[\u0000-\u001f\u007f-\u009f]/u - -/** Sanitizes one server-owned fragment before it enters parser state. */ -function sanitizeServerString(value: string): string { - return sanitize(value).replace(/\r/g, '') -} - -function oneLine(value: string): string { - return sanitizeServerString(value) - .replace(/[\n\t]+/g, ' ') - .replace(/\s+/g, ' ') - .trim() -} - -function isRecord(value: unknown): value is JsonRecord { - return value !== null && typeof value === 'object' && !Array.isArray(value) -} - -function cleanOptionalString(value: unknown): string | undefined { - return typeof value === 'string' ? sanitizeServerString(value) : undefined -} - -function parseJson(body: string): unknown | undefined { - try { - return JSON.parse(body) as unknown - } catch { - return undefined - } -} - -function parseChoices(value: unknown): ChatChoice[] | null { - if (value === null || typeof value !== 'object') return null - - const choices: ChatChoice[] = [] - for (const item of Object.values(value)) { - if (!isRecord(item) || typeof item.title !== 'string' || typeof item.description !== 'string') { - return null - } - const title = oneLine(item.title) - choices.push({ - value: title, - label: title, - description: oneLine(item.description), - }) - } - return choices -} - -function parseQuestion(value: unknown): ChatQuestion | null { - if (!isRecord(value) || !QUESTION_TYPES.has(String(value.type))) return null - if (typeof value.prompt !== 'string') return null - - const prompt = oneLine(value.prompt) - if ( - !prompt || - prompt.length > MAX_QUESTION_PROMPT_LENGTH || - !Array.isArray(value.options) || - value.options.length === 0 || - value.options.length > MAX_QUESTION_OPTIONS - ) { - return null - } - - const options: ChatQuestionOption[] = [] - for (const option of value.options) { - if (!isRecord(option) || typeof option.id !== 'string' || typeof option.label !== 'string') { - return null - } - const id = oneLine(option.id) - const label = oneLine(option.label) - if ( - !id || - !label || - id.length > MAX_QUESTION_OPTION_FIELD_LENGTH || - label.length > MAX_QUESTION_OPTION_FIELD_LENGTH - ) { - return null - } - if (QUESTION_CATCH_ALL_LABELS.has(label.trim().toLowerCase())) continue - options.push({ id, label }) - } - if (options.length === 0) return null - - return { - type: value.type as ChatQuestion['type'], - prompt, - options, - } -} - -function parseQuestions(value: unknown): ChatQuestion[] | null { - const values = Array.isArray(value) ? value : [value] - if (values.length === 0 || values.length > MAX_QUESTIONS) return null - - const questions: ChatQuestion[] = [] - for (const candidate of values) { - const question = parseQuestion(candidate) - if (!question) return null - questions.push(question) - } - return questions -} - -function recoverQuestionPrompts(body: string): string | null { - const payload = parseJson(body) - if (payload === undefined) return null - - const values = (Array.isArray(payload) ? payload : [payload]).slice(0, MAX_QUESTIONS) - const prompts: string[] = [] - for (const value of values) { - if (!isRecord(value) || typeof value.prompt !== 'string') continue - const prompt = oneLine(value.prompt) - if (prompt && prompt.length <= MAX_QUESTION_PROMPT_LENGTH) prompts.push(prompt) - } - return prompts.length > 0 ? prompts.join('\n\n') : null -} - -function parseCredential(value: unknown): ChatCredential | null { - if (!isRecord(value) || typeof value.type !== 'string') return null - if (!CREDENTIAL_TYPES.has(value.type as ChatCredentialType)) return null - if (value.provider !== undefined && typeof value.provider !== 'string') return null - - const type = value.type as ChatCredentialType - const provider = cleanOptionalString(value.provider) - - if (type === 'secret_input') { - if (typeof value.name !== 'string' || !sanitizeServerString(value.name).trim()) return null - if (value.scope !== undefined && value.scope !== 'personal' && value.scope !== 'workspace') { - return null - } - return { - type, - provider, - name: sanitizeServerString(value.name), - scope: value.scope as ChatCredential['scope'], - } - } - - if (type === 'folder_access' || type === 'browser_takeover' || type === 'terminal_handoff') { - if (value.name !== undefined && typeof value.name !== 'string') return null - return { type, provider, name: cleanOptionalString(value.name) } - } - - if (type === 'service_account') { - if (!provider?.trim()) return null - if ( - value.credentialId !== undefined && - (typeof value.credentialId !== 'string' || !sanitizeServerString(value.credentialId).trim()) - ) { - return null - } - return { - type, - provider, - credentialId: cleanOptionalString(value.credentialId), - } - } - - if (type === 'sim_key') return { type, provider } - if (typeof value.value !== 'string') return null - if (type === 'link' && TERMINAL_CONTROL_PATTERN.test(value.value)) return null - return { type, provider, value: sanitizeServerString(value.value) } -} - -function cleanLinkIdentifier(value: unknown): string | undefined { - if (typeof value !== 'string' || TERMINAL_CONTROL_PATTERN.test(value)) return undefined - const cleaned = sanitizeServerString(value).trim() - return cleaned || undefined -} - -function parseWorkspaceResource(value: unknown): ChatWorkspaceResource | null { - if ( - !isRecord(value) || - (value.type !== 'workflow' && value.type !== 'table' && value.type !== 'file') - ) { - return null - } - if (value.id !== undefined && typeof value.id !== 'string') return null - if (value.path !== undefined && typeof value.path !== 'string') return null - if (value.title !== undefined && typeof value.title !== 'string') return null - - const id = cleanLinkIdentifier(value.id) - const path = cleanLinkIdentifier(value.path) - if ((value.type === 'workflow' || value.type === 'table') && !id) return null - if (value.type === 'file' && !id && !path) return null - - return { - type: value.type, - id, - path, - title: cleanOptionalString(value.title), - } -} - -function parseUsageUpgrade(value: unknown): ChatUsageUpgrade | null { - if (!isRecord(value)) return null - if (typeof value.reason !== 'string' || typeof value.message !== 'string') return null - if (value.action !== 'upgrade_plan' && value.action !== 'increase_limit') return null - return { - reason: sanitizeServerString(value.reason), - action: value.action, - message: sanitizeServerString(value.message), - } -} - -function parseMothershipError(value: unknown): ChatMothershipError | null { - if (!isRecord(value) || typeof value.message !== 'string') return null - if (value.code !== undefined && typeof value.code !== 'string') return null - if (value.provider !== undefined && typeof value.provider !== 'string') return null - return { - message: sanitizeServerString(value.message), - code: cleanOptionalString(value.code), - provider: cleanOptionalString(value.provider), - } -} - -function parseTag(name: OfficialChatTagName, body: string): ChatStructuredSegment | null { - if (name === 'thinking') { - return body.trim() ? { kind: 'thinking', content: sanitizeServerString(body) } : null - } - - const payload = parseJson(body) - if (payload === undefined) return null - - if (name === 'options') { - const choices = parseChoices(payload) - return choices ? { kind: 'options', choices } : null - } - if (name === 'question') { - const questions = parseQuestions(payload) - return questions ? { kind: 'question', questions } : null - } - if (name === 'credential') { - const credential = parseCredential(payload) - return credential ? { kind: 'credential', credential } : null - } - if (name === 'workspace_resource') { - const resource = parseWorkspaceResource(payload) - return resource ? { kind: 'workspace_resource', resource } : null - } - if (name === 'usage_upgrade') { - const upgrade = parseUsageUpgrade(payload) - return upgrade ? { kind: 'usage_upgrade', upgrade } : null - } - - const error = parseMothershipError(payload) - return error ? { kind: 'mothership-error', error } : null -} - -function invalidTagFallback( - name: OfficialChatTagName, - body?: string -): ChatStructuredSegment | null { - if (name === 'thinking') return null - if (name === 'credential') { - return { kind: 'text', text: 'Open Sim to complete the requested credential action.' } - } - // Keep an internal empty marker so renderers can discard whitespace that was - // emitted before a malformed or incomplete suggestions wrapper. The marker - // itself still renders as nothing and never becomes an interaction. - if (name === 'options') return { kind: 'options', choices: [] } - if (name === 'question') { - return { - kind: 'text', - text: - (body === undefined ? null : recoverQuestionPrompts(body)) ?? - 'Sim Chat requested an interactive response.', - } - } - if (name === 'workspace_resource') { - return { kind: 'text', text: 'Sim Chat referenced a workspace resource.' } - } - if (name === 'usage_upgrade') return { kind: 'text', text: 'Usage limit reached.' } - return { kind: 'text', text: 'Sim Chat reported an error.' } -} - -function nextMarkdownDelimiter(value: string, initialDelimiter: number): number { - let delimiter = initialDelimiter - let index = 0 - while (index < value.length) { - if (value[index] !== '`') { - index += 1 - continue - } - let end = index + 1 - while (end < value.length && value[end] === '`') end += 1 - const runLength = end - index - if (delimiter === 0) delimiter = runLength - else if (runLength >= delimiter) delimiter = 0 - index = end - } - return delimiter -} - -function findOpeningTag(value: string, initialDelimiter: number): OpeningTagMatch | null { - let delimiter = initialDelimiter - let index = 0 - while (index < value.length) { - if (value[index] === '`') { - let end = index + 1 - while (end < value.length && value[end] === '`') end += 1 - const runLength = end - index - if (delimiter === 0) delimiter = runLength - else if (runLength >= delimiter) delimiter = 0 - index = end - continue - } - - if (delimiter === 0 && value[index] === '<') { - for (const opening of OPENING_TAGS) { - if (value.startsWith(opening.marker, index)) return { index, name: opening.name } - } - } - index += 1 - } - return null -} - -function findClosingTag(value: string, start: number, name: OfficialChatTagName): number { - const closing = `</${name}>` - if (name === 'thinking') return value.indexOf(closing, start) - - let inString = false - let escaped = false - for (let index = start; index < value.length; index += 1) { - const character = value[index] - if (inString) { - if (escaped) escaped = false - else if (character === '\\') escaped = true - else if (character === '"') inString = false - continue - } - if (character === '"') { - inString = true - continue - } - if (value.startsWith(closing, index)) return index - } - return -1 -} - -function trailingBacktickRun(value: string): number { - let index = value.length - while (index > 0 && value[index - 1] === '`') index -= 1 - return value.length - index -} - -function partialOpeningSuffix(value: string): number { - let longest = 0 - for (const { marker } of OPENING_TAGS) { - const limit = Math.min(marker.length - 1, value.length) - for (let length = limit; length > longest; length -= 1) { - if (value.endsWith(marker.slice(0, length))) { - longest = length - break - } - } - } - return longest -} - -function appendText(segments: ChatStructuredSegment[], text: string): void { - if (!text) return - const previous = segments[segments.length - 1] - if (previous?.kind === 'text') previous.text += text - else segments.push({ kind: 'text', text }) -} - -/** - * Incrementally parses structured Sim Chat tags while retaining possible openers - * and incomplete wrappers across arbitrary transport chunk boundaries. - */ -export class ChatStructuredParser { - private buffer = '' - private markdownDelimiter = 0 - private finished = false - - push(fragment: string): ChatStructuredSegment[] { - if (this.finished) throw new Error('Cannot push to a finished chat parser.') - this.buffer += sanitizeServerString(fragment) - return this.drain(false) - } - - finish(): ChatStructuredSegment[] { - if (this.finished) return [] - this.finished = true - return this.drain(true) - } - - private consumeText(length: number, segments: ChatStructuredSegment[]): void { - const text = this.buffer.slice(0, length) - this.buffer = this.buffer.slice(length) - this.markdownDelimiter = nextMarkdownDelimiter(text, this.markdownDelimiter) - appendText(segments, text) - } - - private drain(final: boolean): ChatStructuredSegment[] { - const segments: ChatStructuredSegment[] = [] - - while (this.buffer) { - const opening = findOpeningTag(this.buffer, this.markdownDelimiter) - if (!opening) { - const retained = final - ? 0 - : Math.max(partialOpeningSuffix(this.buffer), trailingBacktickRun(this.buffer)) - const consumable = this.buffer.length - retained - if (consumable > 0) this.consumeText(consumable, segments) - break - } - - if (opening.index > 0) { - this.consumeText(opening.index, segments) - continue - } - - const openingMarker = `<${opening.name}>` - const closingMarker = `</${opening.name}>` - const closingIndex = findClosingTag(this.buffer, openingMarker.length, opening.name) - if (closingIndex === -1) { - if (final) { - if (opening.name === 'thinking') { - // Thinking is intentionally hidden from terminal output. If the stream - // ends before the wrapper closes, fail closed instead of exposing its - // potentially private contents as ordinary text. - this.buffer = '' - } else { - const body = this.buffer.slice(openingMarker.length) - this.buffer = '' - const fallback = invalidTagFallback(opening.name, body) - if (fallback) segments.push(fallback) - } - } - break - } - - const end = closingIndex + closingMarker.length - const body = this.buffer.slice(openingMarker.length, closingIndex) - const parsed = parseTag(opening.name, body) - if (!parsed) { - this.buffer = this.buffer.slice(end) - const fallback = invalidTagFallback(opening.name, body) - if (fallback) segments.push(fallback) - continue - } - - this.buffer = this.buffer.slice(end) - segments.push(parsed) - } - - return segments - } -} - -/** Parses a completed Sim Chat response into sanitized structured segments. */ -export function parseChatStructured(content: string): ChatStructuredSegment[] { - const parser = new ChatStructuredParser() - return [...parser.push(content), ...parser.finish()] -} - -function resourceLabel(resource: ChatWorkspaceResource): string { - const title = oneLine(resource.title ?? '') - if (title) return title - if (resource.type === 'file') return oneLine(resource.path ?? resource.id ?? 'File') || 'File' - return resource.type === 'workflow' ? 'Workflow' : 'Table' -} - -function renderResource(resource: ChatWorkspaceResource): string { - return resourceLabel(resource) -} - -function renderCredential(credential: ChatCredential): string { - const provider = oneLine(credential.provider ?? '') || 'account' - const name = oneLine(credential.name ?? '') - - if (credential.type === 'link' && credential.value) { - return `Open Sim to connect ${provider}.` - } - if (credential.type === 'service_account') { - return `Open Sim to connect ${provider} with a service account.` - } - if (credential.type === 'secret_input') { - return `Open Sim to provide ${name || 'the requested secret'}.` - } - if (credential.type === 'folder_access') { - return `Open Sim Desktop to grant access to ${name || 'the requested folder'}.` - } - if (credential.type === 'browser_takeover') { - return `Open Sim Desktop to continue ${name || 'the browser task'}.` - } - if (credential.type === 'terminal_handoff') { - return `Open Sim Desktop to continue ${name || 'the terminal task'}.` - } - if (credential.type === 'env_key') { - return `Open Sim to configure ${provider} environment credentials.` - } - if (credential.type === 'oauth_key') return `Open Sim to connect ${provider} with OAuth.` - if (credential.type === 'credential_id') return `Open Sim to select ${provider} credentials.` - if (credential.type === 'sim_key') return 'Open Sim to configure a Sim API key.' - return `Open Sim to configure ${provider} credentials.` -} - -function renderQuestions(questions: ChatQuestion[]): string { - return questions.map((question) => oneLine(question.prompt)).join('\n\n') -} - -function addPart(parts: RenderPart[], value: string, block: boolean): void { - if (value) parts.push({ block, value: sanitizeServerString(value) }) -} - -function trimRenderedEnd(parts: RenderPart[]): void { - while (parts.length > 0) { - const last = parts[parts.length - 1] - last.value = last.value.trimEnd() - if (last.value) return - parts.pop() - } -} - -function joinRenderParts(parts: RenderPart[]): string { - let output = '' - let previous: RenderPart | undefined - for (const part of parts) { - if (output && (part.block || previous?.block)) { - const trailing = output.match(/\n*$/u)?.[0].length ?? 0 - const leading = part.value.match(/^\n*/u)?.[0].length ?? 0 - output += '\n'.repeat(Math.max(0, 2 - trailing - leading)) - } - output += part.value - previous = part - } - return output -} - -/** - * Renders structured chat as deterministic terminal-safe text. - */ -export function renderChatStructured( - input: string | readonly ChatStructuredSegment[], - options: ChatStructuredRenderOptions = {} -): ChatStructuredRenderResult { - const segments = typeof input === 'string' ? parseChatStructured(input) : input - const printMode = options.printMode !== false - const parts: RenderPart[] = [] - const interactions: ChatInteraction[] = [] - let strippedOptions = false - - for (const segment of segments) { - if (segment.kind === 'text') { - const text = strippedOptions ? segment.text.replace(/^\s+/u, '') : segment.text - if (strippedOptions && !text) continue - strippedOptions = false - addPart(parts, text, false) - continue - } - if (segment.kind === 'thinking') continue - if (segment.kind === 'workspace_resource') { - addPart(parts, renderResource(segment.resource), false) - continue - } - if (segment.kind === 'options') { - // Follow-up suggestions are browser UI metadata, not answer text. The - // terminal composer stays ordinary free-form input, so omit them fully. - trimRenderedEnd(parts) - strippedOptions = true - continue - } - strippedOptions = false - if (segment.kind === 'question') { - const questions = segment.questions.map((question) => ({ - type: question.type, - prompt: oneLine(question.prompt), - options: question.options.map((option) => ({ - id: oneLine(option.id), - label: oneLine(option.label), - })), - })) - interactions.push({ kind: 'question', questions }) - if (printMode) addPart(parts, renderQuestions(questions), true) - continue - } - if (segment.kind === 'credential') { - addPart(parts, renderCredential(segment.credential), true) - continue - } - if (segment.kind === 'usage_upgrade') { - addPart(parts, `Usage limit reached: ${oneLine(segment.upgrade.message)}`, true) - continue - } - addPart( - parts, - `${oneLine(segment.error.message)}${segment.error.code ? ` (${oneLine(segment.error.code)})` : ''}`, - true - ) - } - - return { text: joinRenderParts(parts), interactions, parts } -} diff --git a/packages/sim-cli/src/commands/protocol/chat-suggestions.test.ts b/packages/sim-cli/src/commands/protocol/chat-suggestions.test.ts deleted file mode 100644 index f271ff071dd..00000000000 --- a/packages/sim-cli/src/commands/protocol/chat-suggestions.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - applySuggestion, - type ChatContext, - contextSpans, - extractCompletionToken, - formatMention, - presentContexts, - rankSuggestions, - resolveSlashContexts, - SLASH_COMMANDS, - type SuggestionItem, - suggestionWindow, -} from './chat-suggestions.js' - -const item = (value: string, description?: string): SuggestionItem => ({ - id: value, - value, - displayText: value, - description, -}) - -describe('slash commands', () => { - it('offers chat switching and renaming without requiring arguments to open the menu', () => { - expect(SLASH_COMMANDS).toEqual( - expect.arrayContaining([ - expect.objectContaining({ value: '/chats', displayText: '/chats' }), - expect.objectContaining({ value: '/rename', displayText: '/rename <title>' }), - ]) - ) - }) -}) - -describe('extractCompletionToken', () => { - it('opens a slash context at the start of any token', () => { - expect(extractCompletionToken('/att', 4)).toMatchObject({ trigger: '/', query: 'att' }) - expect(extractCompletionToken('hi /att', 7)).toMatchObject({ - trigger: '/', - query: 'att', - startPos: 3, - }) - }) - - it('closes the slash context once an argument is typed', () => { - expect(extractCompletionToken('/attach ', 8)).toBeNull() - }) - - it('opens a mention at the start or after whitespace', () => { - expect(extractCompletionToken('@rev', 4)).toMatchObject({ - trigger: '@', - query: 'rev', - startPos: 0, - }) - expect(extractCompletionToken('use @rev', 8)).toMatchObject({ - trigger: '@', - query: 'rev', - startPos: 4, - }) - }) - - it('closes a mention once a slash is typed, matching the client editor', () => { - expect(extractCompletionToken('@logs/incident', 14)).toBeNull() - }) - - it('does not treat an email address as a mention', () => { - expect(extractCompletionToken('mail foo@bar.com', 16)).toBeNull() - }) - - it('reads from the cursor, not the end of the draft', () => { - expect(extractCompletionToken('@rev trailing', 4)).toMatchObject({ query: 'rev' }) - }) - - it('returns null for a bare draft', () => { - expect(extractCompletionToken('hello world', 11)).toBeNull() - }) -}) - -describe('rankSuggestions', () => { - const candidates = [ - item('attach'), - item('clear'), - item('help'), - item('paste-image'), - item('chat'), - ] - - it('returns everything for an empty query', () => { - expect(rankSuggestions('', candidates)).toHaveLength(5) - }) - - it('preserves source order while filtering by substring', () => { - const filtered = rankSuggestions('c', [item('clear'), item('c'), item('chat')]) - expect(filtered.map((entry) => entry.value)).toEqual(['clear', 'c', 'chat']) - }) - - it('matches substrings but not fuzzy subsequences', () => { - expect(rankSuggestions('image', candidates)[0]?.value).toBe('paste-image') - expect(rankSuggestions('pti', candidates)).toEqual([]) - }) - - it('does not search descriptions', () => { - expect( - rankSuggestions('clipboard', [item('paste-image', 'attach from the clipboard')]) - ).toEqual([]) - }) - - it('drops non-matches', () => { - expect(rankSuggestions('zzz', candidates)).toEqual([]) - }) -}) - -describe('suggestionWindow', () => { - it('shows everything when the list fits', () => { - expect(suggestionWindow(3, 0, 5)).toEqual({ start: 0, end: 3 }) - }) - - it('centres the window on the selection', () => { - expect(suggestionWindow(20, 10, 5)).toEqual({ start: 8, end: 13 }) - }) - - it('clamps at both ends', () => { - expect(suggestionWindow(20, 0, 5)).toEqual({ start: 0, end: 5 }) - expect(suggestionWindow(20, 19, 5)).toEqual({ start: 15, end: 20 }) - }) -}) - -describe('applySuggestion', () => { - it('replaces the trigger token and leaves a trailing space', () => { - const token = extractCompletionToken('/att', 4) - expect(token).not.toBeNull() - expect(applySuggestion('/att', token!, '/attach')).toEqual({ draft: '/attach ', cursor: 8 }) - }) - - it('preserves text after the cursor without doubling the separator', () => { - const token = extractCompletionToken('use @rev and go', 8) - expect(applySuggestion('use @rev and go', token!, '@reviewer')).toEqual({ - draft: 'use @reviewer and go', - cursor: 13, - }) - }) -}) - -describe('formatMention', () => { - it('matches the client literal insertion for single and multiword labels', () => { - expect(formatMention('reviewer')).toBe('@reviewer') - expect(formatMention('code reviewer')).toBe('@code reviewer') - }) -}) - -describe('structured tag contexts', () => { - const workflow: ChatContext = { - kind: 'workflow', - workflowId: 'workflow-1', - label: 'Release notes', - } - const skill: ChatContext = { kind: 'skill', skillId: 'skill-1', label: 'review' } - const mcp: ChatContext = { kind: 'mcp', serverId: 'mcp-1', label: 'review' } - - it('finds literal multiword resource and slash spans', () => { - expect(contextSpans('use @Release notes with /review', [workflow, skill])).toEqual([ - { start: 4, end: 18 }, - { start: 24, end: 31 }, - ]) - }) - - it('drops a selected context when its exact token is gone', () => { - expect(presentContexts('use @Release notes', [workflow])).toEqual([workflow]) - expect(presentContexts('use @Release note', [workflow])).toEqual([]) - }) - - it('auto-resolves typed slash tags with skill precedence over a same-name MCP', () => { - const candidates: SuggestionItem[] = [ - { id: 'skill', value: 'review', displayText: '/review', context: skill }, - { id: 'mcp', value: 'review', displayText: '/review', context: mcp }, - ] - expect(resolveSlashContexts('please /REVIEW this', candidates)).toEqual([skill]) - expect(resolveSlashContexts('path/to/review', candidates)).toEqual([]) - }) -}) diff --git a/packages/sim-cli/src/commands/protocol/chat-suggestions.ts b/packages/sim-cli/src/commands/protocol/chat-suggestions.ts deleted file mode 100644 index 856b87e44be..00000000000 --- a/packages/sim-cli/src/commands/protocol/chat-suggestions.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Composer autocomplete: trigger detection, ranking and windowing. - * - * Pure and ANSI-free so it can be unit tested without a terminal; the caller - * owns painting. Tags stay plain draft text while their selected identities - * travel beside the draft. Submit keeps an identity only while its exact tag - * remains, so a half-deleted tag degrades to literal text instead of a dangling - * resource reference. - */ - -import type { ChatBody } from '../../generated/v2-api.js' - -export type ChatContext = NonNullable<ChatBody['contexts']>[number] - -/** One row in the suggestion list. */ -export interface SuggestionItem { - /** Stable across refreshes — selection is tracked by id, never by index. */ - id: string - /** What the user picks, and what gets written into the draft. */ - value: string - displayText: string - description?: string - tag?: string - /** Exact identity sent beside the prompt when this tag remains present. */ - context?: ChatContext -} - -export interface ChatSuggestionCandidates { - resources: SuggestionItem[] - slash: SuggestionItem[] -} - -export interface CompletionToken { - /** Includes the trigger character. */ - token: string - /** Index of the trigger character within the draft. */ - startPos: number - /** Text after the trigger, i.e. what to filter on. */ - query: string - trigger: '/' | '@' -} - -const MENTION_QUERY_BOUNDARY = /[\s.,;:!?(){}[\]"'`/\\<>]/u -const SLASH_QUERY_BOUNDARY = /[\s.,;:!?(){}[\]"'`\\<>]/u -const TAG_END_BOUNDARY = /^[\s.,;:!?(){}[\]"'`/\\<>]/u - -/** - * Walk backwards from the cursor to find an open completion context. - * - * A trigger only counts at the start of the draft or after whitespace, so an - * email address in prose cannot open a mention. Returns null when the cursor is - * not inside a completion. - */ -export function extractCompletionToken(text: string, cursor: number): CompletionToken | null { - const before = text.slice(0, Math.max(0, Math.min(cursor, text.length))) - for (let index = before.length - 1; index >= 0; index -= 1) { - const trigger = before[index] - if (trigger !== '@' && trigger !== '/') continue - if (index > 0 && !/\s/u.test(before[index - 1] as string)) continue - - const query = before.slice(index + 1) - const boundary = trigger === '@' ? MENTION_QUERY_BOUNDARY : SLASH_QUERY_BOUNDARY - if (boundary.test(query)) return null - return { token: before.slice(index), startPos: index, query, trigger } - } - return null -} - -/** - * Filter like the home composer: a case-insensitive name substring while - * preserving source order. A leading trigger on local CLI commands is ignored. - */ -export function rankSuggestions(query: string, candidates: SuggestionItem[]): SuggestionItem[] { - const needle = query.trim().toLowerCase() - if (!needle) return [...candidates] - return candidates.filter((item) => - item.value.replace(/^[/@]/u, '').toLowerCase().includes(needle) - ) -} - -/** - * Visible slice for a list taller than the panel, centred on the selection so - * the cursor sits mid-list rather than only scrolling at the edges. - */ -export function suggestionWindow( - total: number, - selected: number, - maxVisible: number -): { start: number; end: number } { - if (total <= maxVisible) return { start: 0, end: total } - const start = Math.max(0, Math.min(selected - Math.floor(maxVisible / 2), total - maxVisible)) - return { start, end: start + maxVisible } -} - -/** The home composer inserts literal multiword labels and carries identity separately. */ -export function formatMention(value: string): string { - return `@${value}` -} - -/** Splice an accepted suggestion over the trigger token. */ -export function applySuggestion( - draft: string, - token: CompletionToken, - replacement: string -): { draft: string; cursor: number } { - const head = draft.slice(0, token.startPos) - const tail = draft.slice(token.startPos + token.token.length) - /* Only add the separating space when the draft does not already have one. */ - const inserted = /^\s/.test(tail) ? replacement : `${replacement} ` - return { draft: `${head}${inserted}${tail}`, cursor: head.length + inserted.length } -} - -export function contextToken(context: ChatContext): string { - return `${context.kind === 'skill' || context.kind === 'mcp' ? '/' : '@'}${context.label}` -} - -function hasContextToken(text: string, context: ChatContext): boolean { - const token = contextToken(context).toLowerCase() - const haystack = text.toLowerCase() - let start = haystack.indexOf(token) - while (start >= 0) { - const before = start === 0 ? '' : text[start - 1] - const after = text[start + token.length] - if ((!before || /\s/u.test(before)) && (!after || TAG_END_BOUNDARY.test(after))) return true - start = haystack.indexOf(token, start + 1) - } - return false -} - -/** Keeps selected identities only while their exact visible tag remains. */ -export function presentContexts(text: string, contexts: ChatContext[]): ChatContext[] { - return contexts.filter((context) => hasContextToken(text, context)) -} - -/** - * Mirrors the client's typed/pasted `/name` auto-registration. Candidate order - * is significant: skills precede MCP servers, so a same-name skill wins. - */ -export function resolveSlashContexts(text: string, candidates: SuggestionItem[]): ChatContext[] { - const contexts: ChatContext[] = [] - const seenLabels = new Set<string>() - for (const candidate of candidates) { - const context = candidate.context - if (!context || (context.kind !== 'skill' && context.kind !== 'mcp')) continue - const label = context.label.toLowerCase() - if (seenLabels.has(label)) continue - seenLabels.add(label) - if (hasContextToken(text, context)) contexts.push(context) - } - return contexts -} - -/** Exact context-backed tags to paint as chips, longest first for overlaps. */ -export function contextSpans( - text: string, - contexts: ChatContext[] -): Array<{ start: number; end: number }> { - const tokens = [...new Set(contexts.map(contextToken))].sort( - (left, right) => right.length - left.length - ) - const ranges: Array<{ start: number; end: number }> = [] - const lower = text.toLowerCase() - for (const token of tokens) { - const needle = token.toLowerCase() - let start = lower.indexOf(needle) - while (start >= 0) { - const before = start === 0 ? '' : text[start - 1] - const after = text[start + token.length] - const overlaps = ranges.some( - (range) => start < range.end && start + token.length > range.start - ) - if ( - (!before || /\s/u.test(before)) && - (!after || TAG_END_BOUNDARY.test(after)) && - !overlaps - ) { - ranges.push({ start, end: start + token.length }) - } - start = lower.indexOf(needle, start + 1) - } - } - return ranges.sort((left, right) => left.start - right.start) -} - -/** Composer slash commands, the source for the `/` menu. */ -export const SLASH_COMMANDS: SuggestionItem[] = [ - { - id: 'new', - value: '/new', - displayText: '/new', - description: 'start a new chat', - tag: 'command', - }, - { - id: 'chats', - value: '/chats', - displayText: '/chats', - description: 'view and switch chats', - tag: 'command', - }, - { - id: 'rename', - value: '/rename', - displayText: '/rename <title>', - description: 'rename the active chat', - tag: 'command', - }, - { id: 'help', value: '/help', displayText: '/help', description: 'show help', tag: 'command' }, - { - id: 'exit', - value: '/exit', - displayText: '/exit', - description: 'leave Sim Chat (alias: /quit)', - tag: 'command', - }, -] diff --git a/packages/sim-cli/src/commands/protocol/chat-terminal.test.ts b/packages/sim-cli/src/commands/protocol/chat-terminal.test.ts deleted file mode 100644 index c32b4372232..00000000000 --- a/packages/sim-cli/src/commands/protocol/chat-terminal.test.ts +++ /dev/null @@ -1,2150 +0,0 @@ -import { PassThrough } from 'node:stream' -import { Terminal as HeadlessTerminal } from '@xterm/headless' -import { describe, expect, it, vi } from 'vitest' -import { ReadlineChatTerminal } from './chat-terminal.js' - -interface TTYInput extends PassThrough { - isTTY: boolean - isRaw: boolean - setRawMode: ReturnType<typeof vi.fn<(mode: boolean) => void>> -} - -interface TTYOutput extends PassThrough { - isTTY: boolean - columns: number - rows: number -} - -function terminalStreams( - columns = 80, - rows = 24 -): { - input: TTYInput - output: TTYOutput - chunks: string[] -} { - const input = new PassThrough() as TTYInput - input.isTTY = true - input.isRaw = false - input.setRawMode = vi.fn((mode: boolean) => { - input.isRaw = mode - }) - - const output = new PassThrough() as TTYOutput - output.isTTY = true - output.columns = columns - output.rows = rows - const chunks: string[] = [] - output.on('data', (chunk) => chunks.push(String(chunk))) - return { input, output, chunks } -} - -function key(input: TTYInput, character: string, value: Record<string, unknown>): void { - input.emit('keypress', character, value) -} - -function mirrorToHeadless( - output: TTYOutput, - columns: number, - rows: number -): { terminal: HeadlessTerminal; flush: () => Promise<void> } { - const terminal = new HeadlessTerminal({ cols: columns, rows, allowProposedApi: true }) - let writes = Promise.resolve() - output.on('data', (chunk) => { - writes = writes.then( - () => new Promise<void>((resolve) => terminal.write(String(chunk), resolve)) - ) - }) - return { terminal, flush: () => writes } -} - -function paintedPayloads(frame: string): string[] { - const starts = [...frame.matchAll(/\u001b\[\d+;1H\u001b\[2K/gu)] - return starts.map((start, index) => { - const contentStart = (start.index ?? 0) + start[0].length - const nextPaint = starts[index + 1]?.index ?? frame.length - const remainder = frame.slice(contentStart, nextPaint) - const nextControl = remainder.search(/\u001b\[\d+;\d+H|\u001b\[\?25[hl]|\u001b\[\?2026l/u) - return remainder.slice(0, nextControl < 0 ? remainder.length : nextControl) - }) -} - -function payloadDisplayWidth(value: string): number { - const plain = value.replace(/\u001b\[[0-9;:]*m/gu, '') - const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) - let width = 0 - for (const { segment } of segmenter.segment(plain)) { - if (/^\p{Mark}+$/u.test(segment)) continue - const codePoint = segment.codePointAt(0) ?? 0 - const wide = - segment.includes('\u200d') || - /\p{Extended_Pictographic}/u.test(segment) || - (codePoint >= 0x1100 && - (codePoint <= 0x115f || - (codePoint >= 0x2e80 && codePoint <= 0xa4cf) || - (codePoint >= 0xac00 && codePoint <= 0xd7a3) || - (codePoint >= 0xf900 && codePoint <= 0xfaff) || - (codePoint >= 0xff00 && codePoint <= 0xff60) || - (codePoint >= 0x1f300 && codePoint <= 0x1faff))) - width += wide ? 2 : 1 - } - return width -} - -function plainTerminalText(value: string): string { - return value.replace(/\u001b\[[0-9;:]*m/gu, '') -} - -function visibleTerminalLines(terminal: HeadlessTerminal, rows: number): string[] { - return Array.from( - { length: rows }, - (_, row) => - terminal.buffer.active - .getLine(row) - ?.translateToString(true) - .replace(/\u00a0/gu, ' ') - .trimEnd() ?? '' - ) -} - -function expectUserPanelRow(terminal: HeadlessTerminal, row: number, columns: number): void { - const line = terminal.buffer.active.getLine(row) - expect(line).toBeDefined() - expect(line?.getCell(0)?.isBgDefault()).toBe(true) - for (let column = 1; column < columns - 2; column += 1) { - const cell = line?.getCell(column) - expect(cell?.isBgRGB()).toBe(true) - expect(cell?.getBgColor()).toBe(0x3a3c46) - } - expect(line?.getCell(columns - 2)?.isBgDefault()).toBe(true) - expect(line?.getCell(columns - 1)?.isBgDefault()).toBe(true) -} - -describe('ReadlineChatTerminal', () => { - it('opens with the active chat and switch hint, then reflows for narrow terminals', () => { - const { input, output, chunks } = terminalStreams(80, 16) - const terminal = new ReadlineChatTerminal(input, output) - - terminal.welcome({ chatTitle: 'New chat\u001b]0;owned\u0007' }) - - const wideFrame = chunks.at(-1) ?? '' - expect(wideFrame).toContain('\u001b[97m') - expect(wideFrame).toContain('⠤⠶⠶⠮⣤⣽⠤⠴⡋ ⢘⡦⠤⢤⡏ ⠹⣄⣀⣀⡴⠋⠉⢹⢺⡄') - expect(wideFrame).toContain(' ⠈⠓⠤⢄⣹⡤⠤⢜ ⠳⣄⣀⣀⡞ ⢙⣦⣖⠾⠋') - expect(wideFrame).not.toContain('▐██▄███████████▌') - expect(wideFrame).not.toContain('\u001b[38;2;128;47;222m') - expect(wideFrame).toContain('\u001b[1mSim Chat\u001b[0m') - expect(wideFrame).toContain('╭') - expect(wideFrame).toContain('╰') - expect(wideFrame).toContain('chat: New chat') - expect(wideFrame).not.toContain('workspace') - expect(wideFrame).not.toContain('owned') - const welcomeRows = paintedPayloads(wideFrame).map(plainTerminalText) - expect(welcomeRows.findIndex((row) => row.includes('profile:'))).toBe( - welcomeRows.findIndex((row) => row.includes('Sim Chat')) + 1 - ) - - terminal.setChatTitle('Release investigation') - expect(chunks.at(-1) ?? '').toContain('chat: Release investigation') - - output.columns = 30 - output.emit('resize') - const narrowFrame = chunks.at(-1) ?? '' - expect(narrowFrame).toContain(' ⠈⠉⠉⣝⣀⣀⣚⣀⡔⠒⠛⠓⠊⠉⠉') - expect(narrowFrame).not.toContain('▐██▄███████████▌') - expect(narrowFrame).toContain('Sim Chat') - expect(narrowFrame).toContain('chat Release investigation') - expect(narrowFrame).not.toContain('ws_local') - expect(narrowFrame).not.toContain('╭') - terminal.close() - }) - - it('pins a balanced padded composer to the bottom of an alternate-screen viewport', async () => { - const columns = 80 - const rows = 14 - const { input, output, chunks } = terminalStreams(columns, rows) - const screen = mirrorToHeadless(output, columns, rows) - const terminal = new ReadlineChatTerminal(input, output) - const result = terminal.read('❯ ') - - input.write('hello') - await screen.flush() - - const buffer = screen.terminal.buffer.active - const lines = visibleTerminalLines(screen.terminal, rows) - expect(lines[rows - 3]).toBe(' ❯ hello') - expect(lines[rows - 1]).toBe('') - for (const row of [rows - 4, rows - 3, rows - 2]) { - expectUserPanelRow(screen.terminal, row, columns) - } - expect( - buffer - .getLine(rows - 3) - ?.getCell(0) - ?.getChars() - ).toBe(' ') - expect( - buffer - .getLine(rows - 3) - ?.getCell(1) - ?.getChars() - ).toBe('❯') - expect( - buffer - .getLine(rows - 3) - ?.getCell(1) - ?.getFgColor() - ).toBe(0xa0a0a0) - expect( - buffer - .getLine(rows - 3) - ?.getCell(3) - ?.getFgColor() - ).toBe(0xf2f2f2) - expect( - buffer - .getLine(rows - 3) - ?.getCell(columns - 3) - ?.getChars() - ).toBe(' ') - expect( - buffer - .getLine(rows - 1) - ?.getCell(0) - ?.isBgDefault() - ).toBe(true) - expect(buffer.cursorY).toBe(rows - 3) - expect(buffer.cursorX).toBe(8) - - input.write('\r') - - await expect(result).resolves.toEqual({ kind: 'line', value: 'hello' }) - const rendered = chunks.join('') - expect(rendered).toContain('\u001b[?1049h') - - terminal.close() - await screen.flush() - expect(chunks.join('')).toContain('\u001b[?1049l') - expect(input.setRawMode).toHaveBeenNthCalledWith(1, true) - expect(input.setRawMode).toHaveBeenLastCalledWith(false) - expect(input.isPaused()).toBe(true) - screen.terminal.dispose() - }) - - it('submits an exact slash command with one Enter', async () => { - const { input, output } = terminalStreams(80, 14) - const terminal = new ReadlineChatTerminal(input, output) - const result = terminal.read('❯ ') - - input.write('/exit\r') - - await expect(result).resolves.toEqual({ kind: 'line', value: '/exit' }) - terminal.close() - }) - - it('keeps the composer background continuous across a highlighted mention', async () => { - const columns = 50 - const rows = 12 - const { input, output } = terminalStreams(columns, rows) - const screen = mirrorToHeadless(output, columns, rows) - const terminal = new ReadlineChatTerminal(input, output) - terminal.setSuggestionCandidates({ - resources: [ - { - id: 'workflow:workflow-1', - value: 'Release', - displayText: 'Release', - context: { - kind: 'workflow', - workflowId: 'workflow-1', - label: 'Release', - }, - }, - ], - slash: [], - }) - void terminal.read('❯ ') - - input.write('@rel\tthen') - await screen.flush() - - const composerRow = visibleTerminalLines(screen.terminal, rows).findIndex((line) => - line?.startsWith(' ❯ @Release then') - ) - expect(composerRow).toBeGreaterThanOrEqual(0) - expectUserPanelRow(screen.terminal, composerRow, columns) - expect(screen.terminal.buffer.active.getLine(composerRow)?.getCell(3)?.isFgRGB()).toBe(true) - expect(screen.terminal.buffer.active.getLine(composerRow)?.getCell(12)?.getFgColor()).toBe( - 0xf2f2f2 - ) - expect(screen.terminal.buffer.active.getLine(composerRow)?.getCell(12)?.isFgRGB()).toBe(true) - - terminal.close() - await screen.flush() - screen.terminal.dispose() - }) - - it('submits the exact resource identity selected from @ with literal client text', async () => { - const { input, output } = terminalStreams(80, 14) - const terminal = new ReadlineChatTerminal(input, output) - terminal.setSuggestionCandidates({ - resources: [ - { - id: 'workflow:workflow-1', - value: 'Release notes', - displayText: 'Release notes', - context: { - kind: 'workflow', - workflowId: 'workflow-1', - label: 'Release notes', - }, - }, - ], - slash: [], - }) - const result = terminal.read('❯ ') - - input.write('@rel\t\r') - - await expect(result).resolves.toEqual({ - kind: 'line', - value: '@Release notes ', - contexts: [ - { - kind: 'workflow', - workflowId: 'workflow-1', - label: 'Release notes', - }, - ], - }) - terminal.close() - }) - - it('sanitizes server-provided suggestion text before rendering or submitting it', async () => { - const { input, output, chunks } = terminalStreams(80, 14) - const terminal = new ReadlineChatTerminal(input, output) - const injected = '\u001b]2;suggestion-owned\u0007' - terminal.setSuggestionCandidates({ - resources: [ - { - id: 'workflow:workflow-1', - value: `Release${injected}\nnotes`, - displayText: `Release${injected}\nnotes`, - description: `workflow${injected}`, - context: { - kind: 'workflow', - workflowId: 'workflow-1', - label: `Release${injected}\nnotes`, - }, - }, - ], - slash: [], - }) - const result = terminal.read('❯ ') - - input.write('@rel\t\r') - - await expect(result).resolves.toEqual({ - kind: 'line', - value: '@Release notes ', - contexts: [ - { - kind: 'workflow', - workflowId: 'workflow-1', - label: 'Release notes', - }, - ], - }) - expect(chunks.join('')).not.toContain('suggestion-owned') - terminal.close() - }) - - it('clips long suggestion labels before the description column', () => { - const { input, output } = terminalStreams(50, 14) - const terminal = new ReadlineChatTerminal(input, output) - terminal.setSuggestionCandidates({ - resources: [ - { - id: 'logs:execution-1', - value: 'x'.repeat(80), - displayText: 'x'.repeat(80), - description: 'log', - tag: 'logs', - context: { - kind: 'logs', - executionId: 'execution-1', - label: 'x'.repeat(80), - }, - }, - ], - slash: [], - }) - void terminal.read('❯ ') - input.write('@') - - const probe = terminal as never as { buildPanel(rows: number): { lines: string[] } } - const row = probe - .buildPanel(14) - .lines.find((line) => line.replace(/\u001b\[[0-9;:]*m/gu, '').includes('log')) - expect(row?.replace(/\u001b\[[0-9;:]*m/gu, '')).toMatch(/… {2}log/u) - terminal.close() - }) - - it('shows recent logs at the top level after the other @ resources', async () => { - const { input, output } = terminalStreams(80, 14) - const terminal = new ReadlineChatTerminal(input, output) - terminal.setSuggestionCandidates({ - resources: [ - { - id: 'workflow:workflow-1', - value: 'Release workflow', - displayText: 'Release workflow', - tag: 'workflow', - context: { - kind: 'workflow', - workflowId: 'workflow-1', - label: 'Release workflow', - }, - }, - { - id: 'logs:execution-1', - value: 'Incident run', - displayText: 'Incident run', - description: 'log', - tag: 'logs', - context: { - kind: 'logs', - executionId: 'execution-1', - label: 'Incident run', - }, - }, - ], - slash: [], - }) - const result = terminal.read('❯ ') - const probe = terminal as never as { buildPanel(rows: number): { lines: string[] } } - - input.write('@') - const bare = probe.buildPanel(14).lines.map((line) => line.replace(/\u001b\[[0-9;:]*m/gu, '')) - const workflowRow = bare.findIndex((line) => line.includes('Release workflow')) - const logRow = bare.findIndex((line) => line.includes('Incident run')) - expect(workflowRow).toBeGreaterThanOrEqual(0) - expect(logRow).toBeGreaterThan(workflowRow) - expect(bare.some((line) => line.includes('logs/'))).toBe(false) - - key(input, '', { name: 'down', sequence: '\u001b[B' }) - input.write('\t\r') - await expect(result).resolves.toMatchObject({ - kind: 'line', - value: '@Incident run ', - contexts: [ - { - kind: 'logs', - executionId: 'execution-1', - label: 'Incident run', - }, - ], - }) - terminal.close() - }) - - it('reopens @ suggestions after the trigger is removed and retyped', () => { - const { input, output } = terminalStreams(80, 14) - const terminal = new ReadlineChatTerminal(input, output) - terminal.setSuggestionCandidates({ - resources: [ - { - id: 'workflow:workflow-1', - value: 'Release workflow', - displayText: 'Release workflow', - context: { - kind: 'workflow', - workflowId: 'workflow-1', - label: 'Release workflow', - }, - }, - ], - slash: [], - }) - void terminal.read('❯ ') - const probe = terminal as never as { buildPanel(rows: number): { lines: string[] } } - const hasReleaseSuggestion = () => - probe - .buildPanel(14) - .lines.map(plainTerminalText) - .some((line) => line.includes('Release workflow')) - - input.write('@') - expect(hasReleaseSuggestion()).toBe(true) - - key(input, '', { name: 'escape', sequence: '\u001b' }) - expect(hasReleaseSuggestion()).toBe(false) - key(input, '\u007f', { name: 'backspace', sequence: '\u007f' }) - input.write('@') - - expect(hasReleaseSuggestion()).toBe(true) - terminal.close() - }) - - it('renders suggestions above the status and bottom-pinned composer', async () => { - const columns = 80 - const rows = 14 - const { input, output } = terminalStreams(columns, rows) - const screen = mirrorToHeadless(output, columns, rows) - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - - const probe = terminal as never as { - buildPanel(rows: number): { lines: string[]; cursor?: { row: number } } - } - const closedPanel = probe.buildPanel(rows) - const closedCursorRow = rows - closedPanel.lines.length + (closedPanel.cursor?.row ?? 0) - - input.write('/') - const panel = probe.buildPanel(rows) - const lines = panel.lines.map((line) => line.replace(/\u001b\[[0-9;:]*m/gu, '')) - const suggestion = lines.findIndex((line) => line.includes('/help')) - const thinking = lines.findIndex((line) => line.includes('Thinking…')) - const composer = lines.findIndex((line) => line.startsWith(' ❯')) - const openCursorRow = rows - panel.lines.length + (panel.cursor?.row ?? 0) - - expect(suggestion).toBeGreaterThanOrEqual(0) - expect(thinking).toBeGreaterThan(suggestion) - expect(thinking).toBe(composer - 3) - expect(lines[composer - 2]).toBe('') - expect(lines[composer - 1]).toBe(' ') - expect(lines[composer + 1]).toBe(' ') - expect(composer + 1).toBe(lines.length - 2) - expect(panel.cursor?.row).toBe(composer) - expect(openCursorRow).toBe(closedCursorRow) - expect(lines.at(-1)).toContain('enter to steer · esc to interrupt') - - await screen.flush() - const renderedLines = visibleTerminalLines(screen.terminal, rows) - const renderedThinking = renderedLines.findIndex((line) => line?.includes('Thinking…')) - const renderedComposer = renderedLines.findIndex((line) => line?.startsWith(' ❯ /')) - expect(renderedThinking).toBeGreaterThanOrEqual(0) - expect(renderedComposer).toBe(renderedThinking + 3) - expectUserPanelRow(screen.terminal, renderedComposer - 1, columns) - expectUserPanelRow(screen.terminal, renderedComposer, columns) - expectUserPanelRow(screen.terminal, renderedComposer + 1, columns) - expect(screen.terminal.buffer.active.cursorY).toBe(renderedComposer) - - activity.stop() - terminal.close() - await screen.flush() - screen.terminal.dispose() - }) - - it('keeps autocomplete inactive when the terminal is too short to show an option', async () => { - const { input, output } = terminalStreams(80, 4) - const terminal = new ReadlineChatTerminal(input, output) - const result = terminal.read('❯ ') - - input.write('/r\r') - - await expect(result).resolves.toEqual({ kind: 'line', value: '/r' }) - terminal.close() - }) - - it('filters a single-choice menu above a fixed bottom search composer', async () => { - const { input, output } = terminalStreams(80, 14) - const terminal = new ReadlineChatTerminal(input, output) - const selected = terminal.select({ - prompt: 'Choose a chat', - options: [ - { id: 'new', label: 'New chat', description: 'start blank' }, - { id: 'release', label: 'Release investigation', description: 'pinned' }, - { id: 'deploy', label: 'Deployment failure', description: 'updated yesterday' }, - ], - }) - const probe = terminal as never as { - buildPanel(rows: number): { lines: string[]; cursor?: { row: number } } - } - const initial = probe.buildPanel(14) - const initialCursorRow = 14 - initial.lines.length + (initial.cursor?.row ?? 0) - - input.write('deploy') - const filtered = probe.buildPanel(14) - const lines = filtered.lines.map((line) => line.replace(/\u001b\[[0-9;:]*m/gu, '')) - const filteredCursorRow = 14 - filtered.lines.length + (filtered.cursor?.row ?? 0) - - expect(lines.some((line) => line.includes('Deployment failure'))).toBe(true) - expect(lines.some((line) => line.includes('Release investigation'))).toBe(false) - const searchRow = lines.indexOf(' Search › deploy') - expect(lines.findIndex((line) => line.includes('Deployment failure'))).toBeLessThan(searchRow) - expect(lines[searchRow - 1]).toBe(' ') - expect(lines[searchRow + 1]).toBe(' ') - expect(lines.some((line) => line.startsWith('─'))).toBe(false) - expect(filteredCursorRow).toBe(initialCursorRow) - - input.write('\r') - await expect(selected).resolves.toEqual({ kind: 'selected', id: 'deploy' }) - terminal.close() - }) - - it('keeps chat options beyond the first hundred searchable', async () => { - const { input, output } = terminalStreams(80, 14) - const terminal = new ReadlineChatTerminal(input, output) - const selected = terminal.select({ - prompt: 'Choose a chat', - options: Array.from({ length: 150 }, (_, index) => ({ - id: `chat-${index + 1}`, - label: index === 149 ? 'Needle investigation' : `Chat ${index + 1}`, - })), - }) - - input.write('needle\r') - - await expect(selected).resolves.toEqual({ kind: 'selected', id: 'chat-150' }) - terminal.close() - }) - - it('clears prior transcript content without rebuilding the terminal viewport', () => { - const { input, output } = terminalStreams(80, 14) - const terminal = new ReadlineChatTerminal(input, output) - terminal.userMessage('Old question') - terminal.write('Old answer\n') - - terminal.clearTranscript() - - expect((terminal as never as { transcript: string }).transcript).toBe('') - terminal.userMessage('New question') - expect((terminal as never as { transcript: string }).transcript).toContain('New question') - expect((terminal as never as { transcript: string }).transcript).not.toContain('Old question') - terminal.close() - }) - - it('opens / after whitespace and carries a selected skill identity', async () => { - const { input, output } = terminalStreams(80, 14) - const terminal = new ReadlineChatTerminal(input, output) - terminal.setSuggestionCandidates({ - resources: [], - slash: [ - { - id: 'skill:skill-1', - value: 'review', - displayText: '/review', - tag: 'skill', - context: { kind: 'skill', skillId: 'skill-1', label: 'review' }, - }, - ], - }) - const result = terminal.read('❯ ') - - input.write('please /rev\tthis\r') - - await expect(result).resolves.toEqual({ - kind: 'line', - value: 'please /review this', - contexts: [{ kind: 'skill', skillId: 'skill-1', label: 'review' }], - }) - terminal.close() - }) - - it('resets autocomplete selection to the first match when the token query changes', async () => { - const { input, output } = terminalStreams(80, 14) - const terminal = new ReadlineChatTerminal(input, output) - terminal.setSuggestionCandidates({ - resources: ['Apple', 'Apricot', 'Banana'].map((label, index) => ({ - id: `workflow:${index}`, - value: label, - displayText: label, - context: { - kind: 'workflow' as const, - workflowId: `workflow-${index}`, - label, - }, - })), - slash: [], - }) - const result = terminal.read('❯ ') - - input.write('@') - key(input, '', { name: 'down', sequence: '\u001b[B' }) - input.write('a\t\r') - - await expect(result).resolves.toMatchObject({ - kind: 'line', - value: '@Apple ', - contexts: [ - { - kind: 'workflow', - workflowId: 'workflow-0', - label: 'Apple', - }, - ], - }) - terminal.close() - }) - - it('preserves the highlighted autocomplete item when async candidates arrive', async () => { - const { input, output } = terminalStreams(80, 14) - const terminal = new ReadlineChatTerminal(input, output) - const tables = ['Customers', 'Orders'].map((label, index) => ({ - id: `table:${index}`, - value: label, - displayText: label, - context: { - kind: 'table' as const, - tableId: `table-${index}`, - label, - }, - })) - terminal.setSuggestionCandidates({ resources: tables, slash: [] }) - const result = terminal.read('❯ ') - - input.write('@') - key(input, '', { name: 'down', sequence: '\u001b[B' }) - terminal.setSuggestionCandidates({ - resources: [ - { - id: 'workflow:0', - value: 'Billing', - displayText: 'Billing', - context: { - kind: 'workflow', - workflowId: 'workflow-0', - label: 'Billing', - }, - }, - ...tables, - ], - slash: [], - }) - input.write('\t\r') - - await expect(result).resolves.toEqual({ - kind: 'line', - value: '@Orders ', - contexts: [ - { - kind: 'table', - tableId: 'table-1', - label: 'Orders', - }, - ], - }) - terminal.close() - }) - - it('auto-resolves a manually typed slash tag with skill precedence', async () => { - const { input, output } = terminalStreams(80, 14) - const terminal = new ReadlineChatTerminal(input, output) - terminal.setSuggestionCandidates({ - resources: [], - slash: [ - { - id: 'skill:skill-1', - value: 'review', - displayText: '/review', - tag: 'skill', - context: { kind: 'skill', skillId: 'skill-1', label: 'review' }, - }, - { - id: 'mcp:mcp-1', - value: 'review', - displayText: '/review', - tag: 'mcp', - context: { kind: 'mcp', serverId: 'mcp-1', label: 'review' }, - }, - ], - }) - const result = terminal.read('❯ ') - - input.write('/REVIEW this\r') - - await expect(result).resolves.toEqual({ - kind: 'line', - value: '/REVIEW this', - contexts: [{ kind: 'skill', skillId: 'skill-1', label: 'review' }], - }) - terminal.close() - }) - - it('preserves selected context identity through a queued priority preload', async () => { - const { input, output } = terminalStreams(80, 14) - const terminal = new ReadlineChatTerminal(input, output) - const contexts = [{ kind: 'workflow' as const, workflowId: 'workflow-1', label: 'Release' }] - - expect(terminal.preload('@Release', { queued: true, contexts })).toBe(true) - const result = terminal.read('❯ ') - input.write('\r') - - await expect(result).resolves.toEqual({ - kind: 'line', - value: '@Release', - queued: true, - display: '@Release', - contexts, - }) - terminal.close() - }) - - it('leaves a caller-owned flowing input flowing after close', () => { - const { input, output } = terminalStreams(80, 14) - input.resume() - expect(input.readableFlowing).toBe(true) - - const terminal = new ReadlineChatTerminal(input, output) - void terminal.read('❯ ') - terminal.close() - - expect(input.isPaused()).toBe(false) - }) - - it('does not change caller-owned raw mode when closed before opening the viewport', () => { - const { input, output } = terminalStreams(80, 14) - input.isRaw = true - - const terminal = new ReadlineChatTerminal(input, output) - terminal.close() - - expect(input.setRawMode).not.toHaveBeenCalled() - }) - - it('commits sent prompts with the same balanced panel as the composer', async () => { - const columns = 80 - const rows = 14 - const { input, output } = terminalStreams(columns, rows) - const screen = mirrorToHeadless(output, columns, rows) - const terminal = new ReadlineChatTerminal(input, output) - const result = terminal.read('❯ ') - - input.write('first line\\\rsecond line\r') - await expect(result).resolves.toEqual({ kind: 'line', value: 'first line\nsecond line' }) - await screen.flush() - - const buffer = screen.terminal.buffer.active - const lines = visibleTerminalLines(screen.terminal, rows) - const firstRow = lines.indexOf(' ❯ first line') - const secondRow = lines.indexOf(' second line') - expect(firstRow).toBeGreaterThan(0) - expect(secondRow).toBe(firstRow + 1) - for (const row of [firstRow - 1, firstRow, secondRow, secondRow + 1]) { - expectUserPanelRow(screen.terminal, row, columns) - } - expect(buffer.getLine(firstRow)?.getCell(0)?.getChars()).toBe(' ') - expect(buffer.getLine(firstRow)?.getCell(1)?.getChars()).toBe('❯') - expect(buffer.getLine(firstRow)?.getCell(1)?.getFgColor()).toBe(0xa0a0a0) - expect( - buffer - .getLine(firstRow) - ?.getCell(columns - 3) - ?.getChars() - ).toBe(' ') - expect( - buffer - .getLine(secondRow + 2) - ?.getCell(0) - ?.isBgDefault() - ).toBe(true) - - terminal.close() - await screen.flush() - screen.terminal.dispose() - }) - - it('wraps committed user-card words within the shaded content width', async () => { - const columns = 21 - const rows = 14 - const { input, output } = terminalStreams(columns, rows) - const screen = mirrorToHeadless(output, columns, rows) - const terminal = new ReadlineChatTerminal(input, output) - const result = terminal.read('❯ ') - - input.write('abcdef 1234567890\r') - await expect(result).resolves.toEqual({ kind: 'line', value: 'abcdef 1234567890' }) - await screen.flush() - - const lines = visibleTerminalLines(screen.terminal, rows) - const firstRow = lines.indexOf(' ❯ abcdef') - expect(firstRow).toBeGreaterThan(0) - expect(lines[firstRow + 1]).toBe(' 1234567890') - expectUserPanelRow(screen.terminal, firstRow, columns) - expectUserPanelRow(screen.terminal, firstRow + 1, columns) - - terminal.close() - await screen.flush() - screen.terminal.dispose() - }) - - it('renders padded user-turn cells while keeping the composer in the physical bottom rows', async () => { - const columns = 67 - const rows = 12 - const { input, output } = terminalStreams(columns, rows) - const screen = mirrorToHeadless(output, columns, rows) - const terminal = new ReadlineChatTerminal(input, output) - terminal.welcome({ chatTitle: 'New chat' }) - const submitted = terminal.read('❯ ') - - input.write('whats in my workspace\r') - await expect(submitted).resolves.toEqual({ - kind: 'line', - value: 'whats in my workspace', - }) - const activity = terminal.activity('Thinking…') - activity.clear() - terminal.write('Here is your workspace.') - activity.complete() - void terminal.read('❯ ') - await screen.flush() - - const buffer = screen.terminal.buffer.active - const lines = visibleTerminalLines(screen.terminal, rows) - expect(lines).toContain(' ❯ whats in my workspace') - expect(lines).toContain('● Here is your workspace.') - expect(lines).toContain('✻ Worked for 1s') - expect(lines[rows - 3]).toBe(' ❯') - expect(lines[rows - 2]).toBe('') - expect(lines[11]).toBe(' ? for shortcuts') - expectUserPanelRow(screen.terminal, rows - 4, columns) - expectUserPanelRow(screen.terminal, rows - 3, columns) - expectUserPanelRow(screen.terminal, rows - 2, columns) - - const userRowIndex = lines.indexOf(' ❯ whats in my workspace') - const assistantRowIndex = lines.indexOf('● Here is your workspace.') - expectUserPanelRow(screen.terminal, userRowIndex - 1, columns) - expectUserPanelRow(screen.terminal, userRowIndex, columns) - expectUserPanelRow(screen.terminal, userRowIndex + 1, columns) - expect( - buffer - .getLine(userRowIndex + 2) - ?.getCell(0) - ?.isBgDefault() - ).toBe(true) - expect(buffer.getLine(assistantRowIndex)?.getCell(0)?.getChars()).toBe('●') - expect(buffer.getLine(assistantRowIndex)?.getCell(0)?.isBgDefault()).toBe(true) - expect(buffer.cursorY).toBe(rows - 3) - expect(buffer.baseY).toBe(0) - - terminal.close() - await screen.flush() - screen.terminal.dispose() - }) - - it('expands user and composer panels across wide terminal viewports', async () => { - const columns = 240 - const rows = 14 - const { input, output } = terminalStreams(columns, rows) - const screen = mirrorToHeadless(output, columns, rows) - const terminal = new ReadlineChatTerminal(input, output) - const submitted = terminal.read('❯ ') - - input.write('wide terminal\r') - await expect(submitted).resolves.toEqual({ kind: 'line', value: 'wide terminal' }) - void terminal.read('❯ ') - await screen.flush() - - const buffer = screen.terminal.buffer.active - expectUserPanelRow(screen.terminal, 0, columns) - expectUserPanelRow(screen.terminal, 1, columns) - expectUserPanelRow(screen.terminal, 2, columns) - expectUserPanelRow(screen.terminal, rows - 4, columns) - expectUserPanelRow(screen.terminal, rows - 3, columns) - expectUserPanelRow(screen.terminal, rows - 2, columns) - expect( - buffer - .getLine(0) - ?.getCell(columns - 3) - ?.getChars() - ).toBe(' ') - expect( - buffer - .getLine(rows - 1) - ?.getCell(0) - ?.isBgDefault() - ).toBe(true) - - terminal.close() - await screen.flush() - screen.terminal.dispose() - }) - - it('buffers and removes leading whitespace so assistant text shares the prefix row', async () => { - const { input, output, chunks } = terminalStreams(30, 10) - const screen = mirrorToHeadless(output, 30, 10) - const terminal = new ReadlineChatTerminal(input, output) - void terminal.read('❯ ') - terminal.userMessage('question') - const chunksBeforeWhitespace = chunks.length - - terminal.write('\u001b[1m') - terminal.write('\n ') - expect(chunks).toHaveLength(chunksBeforeWhitespace) - - terminal.write('answer\u001b[0m') - await screen.flush() - const answerFrame = chunks.at(-1) ?? '' - expect(answerFrame).toContain('● \u001b[1manswer\u001b[0m') - expect(answerFrame).not.toContain('● \u001b[0m') - const visibleLines = Array.from({ length: 10 }, (_, row) => - screen.terminal.buffer.active.getLine(row)?.translateToString(true).trimEnd() - ).filter(Boolean) - expect(visibleLines).toContain('● answer') - expect(visibleLines).not.toContain('●') - terminal.close() - await screen.flush() - screen.terminal.dispose() - }) - - it('keeps explicit and soft-wrapped assistant rows in a hanging gutter', async () => { - const columns = 16 - const rows = 14 - const { input, output } = terminalStreams(columns, rows) - const screen = mirrorToHeadless(output, columns, rows) - const terminal = new ReadlineChatTerminal(input, output) - void terminal.read('❯ ') - terminal.userMessage('question') - const activity = terminal.activity('Thinking…') - activity.clear() - - terminal.write('alpha beta gamma delta\n') - terminal.write('\u001b[1mHeading\u001b[0m\n') - terminal.write('\u001b[2m•\u001b[0m nested item') - - await screen.flush() - const visibleLines = Array.from({ length: rows }, (_, row) => - screen.terminal.buffer.active.getLine(row)?.translateToString(true).trimEnd() - ) - expect(visibleLines).toContain('● alpha beta') - expect(visibleLines).toContain(' gamma delta') - expect(visibleLines).toContain(' Heading') - expect(visibleLines).toContain(' • nested item') - expect(visibleLines).not.toContain('Heading') - expect(visibleLines).not.toContain('• nested item') - - activity.stop() - terminal.close() - await screen.flush() - screen.terminal.dispose() - }) - - it('starts an assistant turn for attachment-only requests without a text prompt', () => { - const { input, output, chunks } = terminalStreams(30, 10) - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - - activity.clear() - terminal.write('I inspected the attachment.') - - expect(chunks.at(-1)).toContain('● I inspected the attachment.') - activity.stop() - terminal.close() - }) - - it('coordinates streaming transcript writes without moving the busy composer from the bottom', async () => { - const columns = 50 - const rows = 12 - const { input, output, chunks } = terminalStreams(columns, rows) - const screen = mirrorToHeadless(output, columns, rows) - const terminal = new ReadlineChatTerminal(input, output) - const submitted = terminal.read('❯ ') - input.write('question\r') - await submitted - const activity = terminal.activity('Thinking…') - activity.clear() - - terminal.write('Hello ') - terminal.write('\u001b[1mworld\u001b[0m') - await screen.flush() - - const latestFrame = chunks.at(-1) ?? '' - const rendered = chunks.join('') - expect(latestFrame).toContain('Hello \u001b[1mworld\u001b[0m') - expect(rendered).toContain('esc to interrupt') - expect(latestFrame).not.toContain('\u001b[2J') - expect(latestFrame).not.toContain('\n') - expectUserPanelRow(screen.terminal, rows - 4, columns) - expectUserPanelRow(screen.terminal, rows - 3, columns) - expectUserPanelRow(screen.terminal, rows - 2, columns) - expect(visibleTerminalLines(screen.terminal, rows)[rows - 3]).toBe(' ❯') - expect(screen.terminal.buffer.active.cursorY).toBe(rows - 3) - - activity.stop() - terminal.close() - await screen.flush() - screen.terminal.dispose() - }) - - it('keeps the busy composer editable and drains steering prompts in FIFO order', async () => { - const { input, output, chunks } = terminalStreams(60, 14) - const terminal = new ReadlineChatTerminal(input, output) - const initial = terminal.read('❯ ') - input.write('original request\r') - await initial - - const interruptions: string[] = [] - terminal.onInterrupt((reason) => interruptions.push(reason)) - const activity = terminal.activity('Thinking…') - - input.write('first steer') - await new Promise((resolve) => setImmediate(resolve)) - expect(plainTerminalText(chunks.at(-1) ?? '')).toContain(' ❯ first steer') - expect(chunks.at(-1)).toContain('\u001b[?25h') - - input.write('\rsecond steer\r') - await new Promise((resolve) => setImmediate(resolve)) - expect(interruptions).toEqual(['submit', 'submit']) - expect(chunks.at(-1)).toContain('2 queued · enter to steer · esc to interrupt') - - activity.stop() - await expect(terminal.read('❯ ')).resolves.toEqual({ - kind: 'line', - value: 'first steer', - queued: true, - display: 'first steer', - }) - await expect(terminal.read('❯ ')).resolves.toEqual({ - kind: 'line', - value: 'second steer', - queued: true, - display: 'second steer', - }) - terminal.close() - }) - - it('treats blank busy Enter as a no-op', async () => { - const { input, output, chunks } = terminalStreams(60, 12) - const terminal = new ReadlineChatTerminal(input, output) - const interruptions: string[] = [] - terminal.onInterrupt((reason) => interruptions.push(reason)) - const activity = terminal.activity('Thinking…') - - key(input, '\r', { name: 'return', sequence: '\r' }) - - expect(interruptions).toEqual([]) - expect((terminal as never as { queued: unknown[] }).queued).toHaveLength(0) - expect(chunks.at(-1)).not.toContain('queued') - activity.stop() - terminal.close() - }) - - it('reports busy submissions without duplicating chat command or path semantics', async () => { - const { input, output } = terminalStreams(60, 12) - const terminal = new ReadlineChatTerminal(input, output) - const interruptions: string[] = [] - terminal.onInterrupt((reason) => interruptions.push(reason)) - const activity = terminal.activity('Thinking…') - - input.write('/help \r/private/tmp/report.txt\r') - await new Promise((resolve) => setImmediate(resolve)) - - expect(interruptions).toEqual(['submit', 'submit']) - activity.stop() - await expect(terminal.read('❯ ')).resolves.toMatchObject({ value: '/help ', queued: true }) - await expect(terminal.read('❯ ')).resolves.toMatchObject({ - value: '/private/tmp/report.txt', - queued: true, - }) - terminal.close() - }) - - it('prioritizes an explicit preload without losing queued turns or the live draft', async () => { - const { input, output } = terminalStreams(60, 12) - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - - input.write('/private/tmp/report.txt\rinspect it\runfinished') - await new Promise((resolve) => setImmediate(resolve)) - activity.stop() - - await expect(terminal.read('❯ ')).resolves.toMatchObject({ - value: '/private/tmp/report.txt', - queued: true, - }) - expect(terminal.preload('/attach "/private/tmp/report.txt"')).toBe(true) - const confirmation = terminal.read('❯ ') - key(input, '\r', { name: 'return', sequence: '\r' }) - await expect(confirmation).resolves.toEqual({ - kind: 'line', - value: '/attach "/private/tmp/report.txt"', - }) - - await expect(terminal.read('❯ ')).resolves.toMatchObject({ - value: 'inspect it', - queued: true, - }) - const restoredDraft = terminal.read('❯ ') - key(input, '\r', { name: 'return', sequence: '\r' }) - await expect(restoredDraft).resolves.toEqual({ kind: 'line', value: 'unfinished' }) - terminal.close() - }) - - it('consumes a preload submitted while clipboard work is between reads', async () => { - const { input, output } = terminalStreams(60, 12) - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - input.write('live draft') - activity.stop() - - expect(terminal.preload('/attach "/private/tmp/report.txt"')).toBe(true) - const clipboard = terminal.read('❯ ') - key(input, '\u0016', { ctrl: true, name: 'v', sequence: '\u0016' }) - await expect(clipboard).resolves.toEqual({ - kind: 'clipboard', - value: '/attach "/private/tmp/report.txt"', - }) - - // Clipboard inspection is asynchronous in chat.ts. Enter can arrive before - // it asks the terminal for another input, and must consume this preload. - key(input, '\r', { name: 'return', sequence: '\r' }) - await expect(terminal.read('❯ ')).resolves.toMatchObject({ - kind: 'line', - value: '/attach "/private/tmp/report.txt"', - queued: true, - }) - - const restoredDraft = terminal.read('❯ ') - key(input, '\r', { name: 'return', sequence: '\r' }) - await expect(restoredDraft).resolves.toEqual({ kind: 'line', value: 'live draft' }) - terminal.close() - }) - - it('preserves a large pasted draft while a priority preload is submitted', async () => { - const { input, output } = terminalStreams(60, 12) - const terminal = new ReadlineChatTerminal(input, output) - const pasted = 'p'.repeat(900) - const activity = terminal.activity('Thinking…') - input.write('before ') - key(input, '', { name: 'paste-start', sequence: '\u001b[200~' }) - key(input, pasted, { sequence: pasted }) - key(input, '', { name: 'paste-end', sequence: '\u001b[201~' }) - activity.stop() - - expect(terminal.preload('/attach "/private/tmp/report.txt"')).toBe(true) - const confirmation = terminal.read('❯ ') - key(input, '\r', { name: 'return', sequence: '\r' }) - await expect(confirmation).resolves.toMatchObject({ - kind: 'line', - value: '/attach "/private/tmp/report.txt"', - }) - - const restoredDraft = terminal.read('❯ ') - key(input, '\r', { name: 'return', sequence: '\r' }) - await expect(restoredDraft).resolves.toMatchObject({ - kind: 'line', - value: `before ${pasted}`, - }) - terminal.close() - }) - - it('retains queued paste bodies across later input and a priority retry', async () => { - const { input, output } = terminalStreams(60, 12) - const terminal = new ReadlineChatTerminal(input, output) - const pasted = 'q'.repeat(900) - const activity = terminal.activity('Thinking…') - key(input, '', { name: 'paste-start', sequence: '\u001b[200~' }) - key(input, pasted, { sequence: pasted }) - key(input, '', { name: 'paste-end', sequence: '\u001b[201~' }) - key(input, '\r', { name: 'return', sequence: '\r' }) - activity.stop() - - const queued = await terminal.read('❯ ') - expect(queued).toMatchObject({ kind: 'line', value: pasted, queued: true }) - if (queued.kind !== 'line' || !queued.display) throw new Error('Expected queued pasted line') - - const laterActivity = terminal.activity('Thinking…') - input.write('later\r') - laterActivity.stop() - expect(terminal.preload(queued.display, { queued: true, pastes: queued.pastes })).toBe(true) - - const retry = terminal.read('❯ ') - key(input, '\r', { name: 'return', sequence: '\r' }) - await expect(retry).resolves.toMatchObject({ kind: 'line', value: pasted, queued: true }) - terminal.close() - }) - - it('keeps a deferred retry ahead of queued turns without duplicating its transcript row', async () => { - const { input, output } = terminalStreams(60, 12) - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - input.write('retry me\r') - activity.stop() - - await expect(terminal.read('❯ ')).resolves.toMatchObject({ value: 'retry me', queued: true }) - const laterActivity = terminal.activity('Thinking…') - input.write('later\r') - laterActivity.stop() - expect(terminal.preload('retry me', { queued: true })).toBe(true) - - const clipboard = terminal.read('❯ ') - key(input, '\u0016', { ctrl: true, name: 'v', sequence: '\u0016' }) - await expect(clipboard).resolves.toMatchObject({ kind: 'clipboard' }) - - // Enter can land before clipboard inspection asks for the next input. The - // retry remains the priority item even though another turn is queued. - key(input, '\r', { name: 'return', sequence: '\r' }) - await expect(terminal.read('❯ ')).resolves.toMatchObject({ - value: 'retry me', - queued: true, - }) - await expect(terminal.read('❯ ')).resolves.toMatchObject({ value: 'later', queued: true }) - - const transcript = (terminal as never as { transcript: string }).transcript - expect(transcript.match(/retry me/gu)).toHaveLength(1) - terminal.close() - }) - - it('retries a normally submitted prompt without duplicating its transcript row', async () => { - const { input, output } = terminalStreams(60, 12) - const terminal = new ReadlineChatTerminal(input, output) - const firstAttempt = terminal.read('❯ ') - input.write('retry me\r') - await expect(firstAttempt).resolves.toEqual({ kind: 'line', value: 'retry me' }) - - const activity = terminal.activity('Thinking…') - activity.stop() - expect(terminal.preload('retry me', { queued: true })).toBe(true) - const retry = terminal.read('❯ ') - input.write('\r') - - await expect(retry).resolves.toMatchObject({ - kind: 'line', - value: 'retry me', - queued: true, - }) - const transcript = (terminal as never as { transcript: string }).transcript - expect(transcript.match(/retry me/gu)).toHaveLength(1) - terminal.close() - }) - - it('keeps an unchanged committed retry deduplicated after queue recall', async () => { - const { input, output } = terminalStreams(60, 12) - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - input.write('retry me\r') - activity.stop() - - await expect(terminal.read('❯ ')).resolves.toMatchObject({ value: 'retry me', queued: true }) - expect(terminal.preload('retry me', { queued: true })).toBe(true) - key(input, '\r', { name: 'return', sequence: '\r' }) - key(input, '', { name: 'up', sequence: '\u001b[A' }) - key(input, '\r', { name: 'return', sequence: '\r' }) - - await expect(terminal.read('❯ ')).resolves.toMatchObject({ value: 'retry me', queued: true }) - const transcript = (terminal as never as { transcript: string }).transcript - expect(transcript.match(/retry me/gu)).toHaveLength(1) - terminal.close() - }) - - it('keeps clipboard draft edits terminal-owned while a turn is active', async () => { - const { input, output, chunks } = terminalStreams(60, 12) - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - - input.write('draft') - key(input, '\u0016', { ctrl: true, name: 'v', sequence: '\u0016' }) - for (let index = 0; index < 5; index += 1) { - key(input, '\u007f', { name: 'backspace', sequence: '\u007f' }) - } - expect(chunks.at(-1)).not.toContain('queued') - activity.stop() - - await expect(terminal.read('❯ ')).resolves.toEqual({ kind: 'clipboard', value: 'draft' }) - const empty = terminal.read('❯ ') - key(input, '\r', { name: 'return', sequence: '\r' }) - await expect(empty).resolves.toEqual({ kind: 'line', value: '' }) - terminal.close() - }) - - it('dismisses busy suggestions before Escape interrupts generation', () => { - const { input, output } = terminalStreams(60, 12) - const terminal = new ReadlineChatTerminal(input, output) - const interruptions: string[] = [] - terminal.onInterrupt((reason) => interruptions.push(reason)) - const activity = terminal.activity('Thinking…') - input.write('/he') - - key(input, '', { name: 'escape', sequence: '\u001b' }) - expect(interruptions).toEqual([]) - expect((terminal as never as { draft: string }).draft).toBe('/he') - - key(input, '', { name: 'escape', sequence: '\u001b' }) - expect(interruptions).toEqual(['manual']) - activity.stop() - terminal.close() - }) - - it('recalls the newest queued steering prompt with Up', async () => { - const { input, output, chunks } = terminalStreams(60, 12) - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - - input.write('first\rsecond\r') - await new Promise((resolve) => setImmediate(resolve)) - key(input, '', { name: 'up', sequence: '\u001b[A' }) - - expect(plainTerminalText(chunks.at(-1) ?? '')).toContain(' ❯ second') - expect(chunks.at(-1)).toContain('1 queued · enter to steer · esc to interrupt') - activity.stop() - terminal.close() - }) - - it('reinserts a recalled prompt ahead of controls that arrived after it', async () => { - const { input, output } = terminalStreams(60, 12) - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - - input.write('first\rsecond\r') - await new Promise((resolve) => setImmediate(resolve)) - key(input, '\u0016', { ctrl: true, name: 'v', sequence: '\u0016' }) - key(input, '', { name: 'up', sequence: '\u001b[A' }) - input.write(' edited\r') - activity.stop() - - await expect(terminal.read('❯ ')).resolves.toMatchObject({ value: 'first', queued: true }) - await expect(terminal.read('❯ ')).resolves.toMatchObject({ - value: 'second edited', - queued: true, - }) - await expect(terminal.read('❯ ')).resolves.toEqual({ kind: 'clipboard', value: '' }) - terminal.close() - }) - - it('preserves a mid-stream draft across a structured question', async () => { - const { input, output } = terminalStreams(60, 14) - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - input.write('unfinished follow-up') - activity.stop() - - const answer = terminal.askQuestion({ - prompt: 'Which service?', - multi: false, - options: [{ id: 'api', label: 'API' }], - }) - key(input, '\r', { name: 'return', sequence: '\r' }) - await expect(answer).resolves.toEqual({ kind: 'answer', values: ['API'] }) - - const followUp = terminal.read('❯ ') - key(input, '\r', { name: 'return', sequence: '\r' }) - await expect(followUp).resolves.toEqual({ kind: 'line', value: 'unfinished follow-up' }) - terminal.close() - }) - - it('paints only visible transcript rows without terminal scrolling during repeated redraws', () => { - const { input, output, chunks } = terminalStreams(16, 10) - const terminal = new ReadlineChatTerminal(input, output) - void terminal.read('❯ ') - - terminal.write(`${Array.from({ length: 100 }, (_, index) => `line-${index}`).join('\n')}\n`) - const transcriptFrame = chunks.at(-1) ?? '' - const transcriptPaints = [...transcriptFrame.matchAll(/\u001b\[(\d+);1H\u001b\[2K/gu)] - expect(transcriptPaints.length).toBeLessThanOrEqual(output.rows) - expect(transcriptFrame).not.toContain('line-0') - expect(transcriptFrame).toContain('line-99') - expect(transcriptFrame).not.toContain('\n') - expect(transcriptFrame).not.toContain('\r') - expect(transcriptFrame).not.toMatch(/\u001b\[\d+;\d+r/u) - - for (let redraw = 0; redraw < 10; redraw += 1) output.emit('resize') - for (const frame of chunks.slice(-10)) { - const paintedRows = [...frame.matchAll(/\u001b\[(\d+);1H\u001b\[2K/gu)] - expect(paintedRows).toHaveLength(0) - expect(frame).not.toMatch(/\u001b\[\d+;\d+r/u) - expect(frame).not.toContain('\n') - expect(frame).not.toContain('\r') - expect(frame).not.toContain('line-99') - expect(frame).not.toContain('\u001b[2J') - } - - terminal.close() - }) - - it('owns transcript scrollback while keeping the composer fixed and sticky', async () => { - const { input, output, chunks } = terminalStreams(32, 10) - const terminal = new ReadlineChatTerminal(input, output) - const submitted = terminal.read('❯ ') - - terminal.write(`${Array.from({ length: 20 }, (_, index) => `line-${index}`).join('\n')}\n`) - expect(chunks.at(-1)).toContain('line-19') - - key(input, '', { name: 'pageup', sequence: '\u001b[5~' }) - const historyFrame = chunks.at(-1) ?? '' - expect(historyFrame).toContain('line-11') - expect(historyFrame).not.toContain('line-19') - expect(historyFrame).toContain('\u001b[8;4H\u001b[?25h') - - terminal.write('line-20\nline-21\n') - const anchoredFrame = chunks.at(-1) ?? '' - expect(anchoredFrame).not.toContain('line-20') - expect(anchoredFrame).not.toContain('line-21') - expect(anchoredFrame).not.toMatch(/\u001b\[\d+;1H\u001b\[2K/u) - - key(input, '', { ctrl: true, name: 'home', sequence: '\u001b[1;5H' }) - expect(chunks.at(-1)).toContain('line-0') - key(input, '', { ctrl: true, name: 'end', sequence: '\u001b[1;5F' }) - expect(chunks.at(-1)).toContain('line-21') - - key(input, '', { name: 'pageup', sequence: '\u001b[5~' }) - output.rows = 12 - output.emit('resize') - const resizedFrame = chunks.at(-1) ?? '' - expect(resizedFrame).not.toContain('line-21') - expect(resizedFrame).not.toContain('\n') - expect(resizedFrame).not.toContain('\r') - - input.write('new question\r') - await expect(submitted).resolves.toEqual({ kind: 'line', value: 'new question' }) - const submittedFrame = chunks.at(-1) ?? '' - expect(submittedFrame).toContain('new question') - expect(submittedFrame).toContain('\u001b[10;1H\u001b[2K') - expect(plainTerminalText(submittedFrame)).toContain(' ❯ ') - expect(submittedFrame).not.toContain('\n') - expect(submittedFrame).not.toContain('\r') - terminal.close() - }) - - it('wraps ANSI-styled wide graphemes into bounded absolute rows', () => { - const { input, output, chunks } = terminalStreams(10, 8) - const terminal = new ReadlineChatTerminal(input, output) - void terminal.read('❯ ') - - terminal.write('\u001b[31m12345678界Z\u001b[0m') - - const latestFrame = chunks.at(-1) ?? '' - expect(latestFrame).toContain('\u001b[31m12345678\u001b[0m') - expect(latestFrame).toContain('\u001b[31m界Z\u001b[0m') - expect(latestFrame).not.toContain('\n') - expect(latestFrame).not.toMatch(/\u001b\[\d+;\d+r/u) - terminal.close() - }) - - it('reflows streamed prose at word boundaries instead of splitting ordinary words', () => { - const { input, output, chunks } = terminalStreams(21, 8) - const terminal = new ReadlineChatTerminal(input, output) - void terminal.read('❯ ') - - terminal.write('happy to build somet') - expect(chunks.at(-1)).toContain('happy to build somet') - - terminal.write('hing') - const reflowedFrame = chunks.at(-1) ?? '' - expect(reflowedFrame).toContain('\u001b[1;1H\u001b[2Khappy to build \u001b[0m') - expect(reflowedFrame).toContain('\u001b[2;1H\u001b[2Ksomething\u001b[0m') - expect(reflowedFrame).not.toContain('somet\u001b[0m') - expect(reflowedFrame).not.toContain('\u001b[2;1H\u001b[2Khing') - terminal.close() - }) - - it('reopens the user panel on word-wrapped rows without leaking into assistant output', async () => { - const columns = 21 - const rows = 8 - const { input, output } = terminalStreams(columns, rows) - const screen = mirrorToHeadless(output, columns, rows) - const terminal = new ReadlineChatTerminal(input, output) - void terminal.read('❯ ') - - terminal.userMessage('happy to build something') - await screen.flush() - - const userLines = visibleTerminalLines(screen.terminal, rows) - const firstRow = userLines.indexOf(' ❯ happy to build') - const continuationRow = userLines.indexOf(' something') - expect(firstRow).toBeGreaterThanOrEqual(0) - expect(continuationRow).toBe(firstRow + 1) - expectUserPanelRow(screen.terminal, firstRow, columns) - expectUserPanelRow(screen.terminal, continuationRow, columns) - - terminal.write('assistant') - await screen.flush() - const assistantRow = visibleTerminalLines(screen.terminal, rows).indexOf('● assistant') - expect(assistantRow).toBeGreaterThanOrEqual(0) - expect(screen.terminal.buffer.active.getLine(assistantRow)?.getCell(0)?.isBgDefault()).toBe( - true - ) - - terminal.close() - await screen.flush() - screen.terminal.dispose() - }) - - it('supports multiline input, grapheme deletion, and history recall', async () => { - const { input, output } = terminalStreams() - const terminal = new ReadlineChatTerminal(input, output) - - const multiline = terminal.read('❯ ') - input.write('hello\\\rworld\r') - await expect(multiline).resolves.toEqual({ kind: 'line', value: 'hello\nworld' }) - - const edited = terminal.read('❯ ') - input.write('A😀B') - key(input, '', { name: 'left', sequence: '\u001b[D' }) - key(input, '', { name: 'backspace', sequence: '\u007f' }) - key(input, '\r', { name: 'return', sequence: '\r' }) - await expect(edited).resolves.toEqual({ kind: 'line', value: 'AB' }) - - const recalled = terminal.read('❯ ') - key(input, '', { name: 'up', sequence: '\u001b[A' }) - key(input, '\r', { name: 'return', sequence: '\r' }) - await expect(recalled).resolves.toEqual({ kind: 'line', value: 'AB' }) - terminal.close() - }) - - it('preserves the live draft cursor when a streamed turn settles', async () => { - const { input, output } = terminalStreams() - const terminal = new ReadlineChatTerminal(input, output) - const initial = terminal.read('❯ ') - input.write('original\r') - await initial - - const activity = terminal.activity('Thinking…') - input.write('abcdef') - key(input, '', { name: 'left', sequence: '\u001b[D' }) - key(input, '', { name: 'left', sequence: '\u001b[D' }) - activity.stop() - - const followUp = terminal.read('❯ ') - input.write('X\r') - await expect(followUp).resolves.toEqual({ kind: 'line', value: 'abcdXef' }) - terminal.close() - }) - - it('returns eof after close even when deferred input remains queued', async () => { - const { input, output } = terminalStreams() - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - input.write('stale\r') - await new Promise((resolve) => setImmediate(resolve)) - - terminal.close() - - await expect(terminal.read('❯ ')).resolves.toEqual({ kind: 'eof' }) - activity.stop() - }) - - it('redraws the balanced composer across narrow terminal resizes', async () => { - const { input, output } = terminalStreams(12, 10) - const screen = mirrorToHeadless(output, 12, 10) - const terminal = new ReadlineChatTerminal(input, output) - void terminal.read('❯ ') - await screen.flush() - - expectUserPanelRow(screen.terminal, 6, 12) - expectUserPanelRow(screen.terminal, 7, 12) - expectUserPanelRow(screen.terminal, 8, 12) - expect(screen.terminal.buffer.active.cursorY).toBe(7) - - screen.terminal.resize(40, 16) - output.columns = 40 - output.rows = 16 - output.emit('resize') - await screen.flush() - - expectUserPanelRow(screen.terminal, 12, 40) - expectUserPanelRow(screen.terminal, 13, 40) - expectUserPanelRow(screen.terminal, 14, 40) - expect(visibleTerminalLines(screen.terminal, 16)[13]).toBe(' ❯') - expect(screen.terminal.buffer.active.cursorY).toBe(13) - - terminal.close() - await screen.flush() - screen.terminal.dispose() - }) - - it('never paints beyond the physical terminal during extreme row resizes', () => { - const { input, output, chunks } = terminalStreams(20, 14) - const terminal = new ReadlineChatTerminal(input, output) - void terminal.read('❯ ') - terminal.write('one\ntwo\nthree\nfour') - - for (const rows of [2, 1, 20]) { - output.rows = rows - output.emit('resize') - const frame = chunks.at(-1) ?? '' - const cursorPositions = [...frame.matchAll(/\u001b\[(\d+);(\d+)H/gu)] - expect(cursorPositions.length).toBeGreaterThan(0) - for (const position of cursorPositions) { - expect(Number(position[1])).toBeLessThanOrEqual(rows) - expect(Number(position[2])).toBeLessThanOrEqual(output.columns) - } - expect(frame).not.toContain('\n') - expect(frame).not.toContain('\r') - } - - terminal.close() - }) - - it('respects the physical column count and leaves a no-wrap safety column', () => { - const { input, output, chunks } = terminalStreams(14, 8) - const terminal = new ReadlineChatTerminal(input, output) - void terminal.read('❯ ') - terminal.write('alpha beta 界界 gamma') - - for (const columns of [2, 1, 20]) { - output.columns = columns - output.emit('resize') - const frame = chunks.at(-1) ?? '' - const cursorPositions = [...frame.matchAll(/\u001b\[(\d+);(\d+)H/gu)] - expect(cursorPositions.length).toBeGreaterThan(0) - for (const position of cursorPositions) { - expect(Number(position[1])).toBeLessThanOrEqual(output.rows) - expect(Number(position[2])).toBeLessThanOrEqual(columns) - } - for (const payload of paintedPayloads(frame)) { - expect(payloadDisplayWidth(payload)).toBeLessThanOrEqual(Math.max(0, columns - 1)) - } - expect(frame).not.toContain('\n') - expect(frame).not.toContain('\r') - } - - terminal.close() - }) - - it('keeps the meaningful composer and question row focused at one terminal row', async () => { - const { input, output, chunks } = terminalStreams(40, 1) - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - - const busyFrame = chunks.at(-1) ?? '' - expect(busyFrame).toContain('❯ ') - expect(busyFrame).not.toContain('esc to interrupt') - expect(paintedPayloads(busyFrame)).toHaveLength(1) - activity.stop() - - const answer = terminal.askQuestion({ - prompt: 'Which service?', - multi: false, - options: [ - { id: 'api', label: 'API' }, - { id: 'worker', label: 'Worker' }, - ], - }) - const questionFrame = chunks.at(-1) ?? '' - expect(questionFrame).toContain('❯ 1. API') - expect(questionFrame).not.toContain('navigate') - key(input, '\r', { name: 'return', sequence: '\r' }) - await expect(answer).resolves.toEqual({ kind: 'answer', values: ['API'] }) - terminal.close() - }) - - it('clips the balanced composer around its input on tiny terminal heights', async () => { - const columns = 40 - const { input, output } = terminalStreams(columns, 6) - const screen = mirrorToHeadless(output, columns, 6) - const terminal = new ReadlineChatTerminal(input, output) - void terminal.read('❯ ') - const expectedCursorRows = new Map([ - [5, 2], - [4, 1], - [3, 1], - [2, 0], - [1, 0], - ]) - - for (const rows of [5, 4, 3, 2, 1]) { - screen.terminal.resize(columns, rows) - output.rows = rows - output.emit('resize') - await screen.flush() - - const cursorRow = expectedCursorRows.get(rows) - if (cursorRow === undefined) throw new Error(`Missing cursor expectation for ${rows} rows`) - expect(screen.terminal.buffer.active.cursorY).toBe(cursorRow) - expect(screen.terminal.buffer.active.cursorX).toBe(3) - expect(visibleTerminalLines(screen.terminal, rows)[cursorRow]).toBe(' ❯') - expectUserPanelRow(screen.terminal, cursorRow, columns) - if (cursorRow > 0) expectUserPanelRow(screen.terminal, cursorRow - 1, columns) - if (cursorRow + 1 < rows) expectUserPanelRow(screen.terminal, cursorRow + 1, columns) - if (rows >= 4) { - expect( - screen.terminal.buffer.active - .getLine(rows - 1) - ?.getCell(0) - ?.isBgDefault() - ).toBe(true) - } - } - - terminal.close() - await screen.flush() - screen.terminal.dispose() - }) - - it('returns Ctrl+V with the current draft and implements clear-aware Ctrl+C', async () => { - const { input, output } = terminalStreams() - const terminal = new ReadlineChatTerminal(input, output) - - const clipboard = terminal.read('❯ ') - input.write('explain this') - key(input, '\u0016', { ctrl: true, name: 'v', sequence: '\u0016' }) - await expect(clipboard).resolves.toEqual({ kind: 'clipboard', value: 'explain this' }) - - const withDraft = terminal.read('❯ ') - input.write('discard me') - key(input, '\u0003', { ctrl: true, name: 'c', sequence: '\u0003' }) - await expect(withDraft).resolves.toEqual({ kind: 'interrupt', empty: false }) - - const empty = terminal.read('❯ ') - key(input, '\u0003', { ctrl: true, name: 'c', sequence: '\u0003' }) - await expect(empty).resolves.toEqual({ kind: 'interrupt', empty: true }) - terminal.close() - }) - - it('renders questions in the bottom panel with focus, custom answers, and cancellation', async () => { - const { input, output, chunks } = terminalStreams() - const terminal = new ReadlineChatTerminal(input, output) - const question = { - prompt: 'Which service?', - multi: false, - options: [ - { id: 'api', label: 'API' }, - { id: 'worker', label: 'Worker' }, - ], - } - - const selected = terminal.askQuestion(question) - key(input, '', { name: 'down', sequence: '\u001b[B' }) - expect(chunks.at(-1)).toContain('❯ 2. Worker') - key(input, '\r', { name: 'return', sequence: '\r' }) - await expect(selected).resolves.toEqual({ kind: 'answer', values: ['Worker'] }) - - const custom = terminal.askQuestion(question) - input.write('my service\r') - await expect(custom).resolves.toEqual({ kind: 'answer', values: ['my service'] }) - - const cancelled = terminal.askQuestion(question) - key(input, '', { name: 'escape', sequence: '\u001b' }) - await expect(cancelled).resolves.toEqual({ kind: 'cancel' }) - expect(chunks.join('')).not.toContain('Choose an option:') - expect(chunks.join('')).not.toContain('Selected:') - terminal.close() - }) - - it('keeps multi-select state in place and submits it explicitly', async () => { - const { input, output, chunks } = terminalStreams() - const terminal = new ReadlineChatTerminal(input, output) - const result = terminal.askQuestion({ - prompt: 'Which services?', - multi: true, - options: [ - { id: 'api', label: 'API' }, - { id: 'worker', label: 'Worker' }, - ], - }) - - key(input, ' ', { name: 'space', sequence: ' ' }) - key(input, '', { name: 'down', sequence: '\u001b[B' }) - key(input, ' ', { name: 'space', sequence: ' ' }) - key(input, '', { name: 'down', sequence: '\u001b[B' }) - key(input, '', { name: 'down', sequence: '\u001b[B' }) - expect(chunks.at(-1)).toContain('❯ \u001b[2mSubmit answers') - key(input, '\r', { name: 'return', sequence: '\r' }) - - await expect(result).resolves.toEqual({ kind: 'answer', values: ['API', 'Worker'] }) - expect(chunks.join('')).toContain('[✓] API') - expect(chunks.join('')).toContain('[✓] Worker') - expect(chunks.join('')).not.toContain('Selected:') - terminal.close() - }) - - it('keeps completed tool rows in the transcript after transient activity clears', () => { - const { input, output, chunks } = terminalStreams() - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - activity.thinking('Inspecting\nworkflows…') - activity.event({ - kind: 'tool', - id: 'tool-1', - label: 'Read\nworkspace', - state: 'running', - }) - activity.event({ kind: 'tool', id: 'tool-1', label: 'Read workspace', state: 'complete' }) - activity.event({ - kind: 'subagent', - id: 'agent-1', - label: 'Research\u001b]0;owned\u0007 agent', - state: 'running', - }) - activity.event({ - kind: 'narration', - parentId: 'agent-1', - delta: 'Found the relevant workflow', - }) - activity.event({ - kind: 'subagent', - id: 'agent-1', - label: 'Research\u001b]0;owned\u0007 agent', - state: 'error', - }) - activity.clear() - activity.stop() - - const rendered = chunks.join('') - expect(rendered).toContain('\u001b[32m●\u001b[0m Read workspace') - expect(rendered).toContain('\u001b[31m●\u001b[0m Research agent') - expect(rendered).toContain(' \u001b[2mFound the relevant workflow\u001b[0m') - expect(rendered).not.toContain('Research agent \u001b[2mfailed') - expect(rendered).not.toContain(' \u001b[32m●\u001b[0m Read workspace') - expect(rendered).not.toContain(' \u001b[31m●\u001b[0m Research agent') - expect(rendered).not.toContain('owned') - expect(rendered).not.toContain('✗') - terminal.close() - }) - - it('renders nested lanes in wire order with verbatim adjacent narration and structural seams', () => { - const { input, output } = terminalStreams() - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - activity.event({ - kind: 'subagent', - id: 'agent-root', - label: 'Workflow Agent', - state: 'running', - }) - activity.event({ kind: 'narration', parentId: 'agent-root', delta: 'First ' }) - activity.event({ kind: 'narration', parentId: 'agent-root', delta: 'step\n\ncontinues' }) - activity.event({ - kind: 'tool', - id: 'tool-read', - parentId: 'agent-root', - label: 'Read file', - state: 'complete', - }) - activity.event({ kind: 'narration', parentId: 'agent-root', delta: 'After tool' }) - activity.event({ - kind: 'subagent', - id: 'agent-child', - parentId: 'agent-root', - label: 'Deploy Agent', - state: 'running', - }) - activity.event({ kind: 'narration', parentId: 'agent-child', delta: 'Shipping now' }) - - const probe = terminal as never as { activityEventsDisplay(): string } - expect(plainTerminalText(probe.activityEventsDisplay()).split('\n')).toEqual([ - '● Workflow Agent', - ' First step', - ' ', - ' continues', - ' ● Read file', - ' After tool', - ' ● Deploy Agent', - ' Shipping now', - ]) - - activity.stop() - terminal.close() - }) - - it('keeps parallel same-name subagents separate by id', () => { - const { input, output } = terminalStreams() - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - for (const [id, narration] of [ - ['agent-a', 'First lane'], - ['agent-b', 'Second lane'], - ] as const) { - activity.event({ kind: 'subagent', id, label: 'Research Agent', state: 'running' }) - activity.event({ kind: 'narration', parentId: id, delta: narration }) - activity.event({ kind: 'subagent', id, label: 'Research Agent', state: 'complete' }) - } - - const probe = terminal as never as { activityEventsDisplay(): string } - expect(plainTerminalText(probe.activityEventsDisplay()).split('\n')).toEqual([ - '● Research Agent', - ' First lane', - '● Research Agent', - ' Second lane', - ]) - - activity.stop() - terminal.close() - }) - - it('commits only whole settled roots and prunes closed empty subagent groups', () => { - const { input, output } = terminalStreams() - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - activity.event({ - kind: 'subagent', - id: 'agent-root', - label: 'Build Agent', - state: 'complete', - }) - activity.event({ - kind: 'tool', - id: 'tool-child', - parentId: 'agent-root', - label: 'Editing workflow', - state: 'running', - }) - activity.event({ - kind: 'subagent', - id: 'agent-empty', - label: 'Empty Agent', - state: 'complete', - }) - - const probe = terminal as never as { - activityEventsDisplay(): string - transcript: string - } - activity.clear() - expect(plainTerminalText(probe.transcript)).toBe('') - expect(plainTerminalText(probe.activityEventsDisplay())).toContain('Build Agent') - expect(plainTerminalText(probe.activityEventsDisplay())).not.toContain('Empty Agent') - - activity.event({ - kind: 'tool', - id: 'tool-child', - parentId: 'agent-root', - label: 'Edited workflow', - state: 'complete', - }) - activity.clear() - expect(plainTerminalText(probe.transcript).trim().split('\n')).toEqual([ - '● Build Agent', - ' ● Edited workflow', - ]) - expect(probe.activityEventsDisplay()).toBe('') - - activity.stop() - terminal.close() - }) - - it('pins the UI thinking label while tool activity remains in the transcript tail', () => { - const { input, output } = terminalStreams() - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - activity.thinking('Planning next step') - activity.event({ kind: 'tool', id: 'tool-1', label: 'Reading file', state: 'running' }) - - const probe = terminal as never as { - activityEventsDisplay(): string - activityStatusLine(): string - buildPanel(rows: number): { lines: string[] } - } - const events = probe - .activityEventsDisplay() - .split('\n') - .map((line) => line.replace(/\u001b\[[0-9;:]*m/gu, '')) - const status = probe.activityStatusLine().replace(/\u001b\[[0-9;:]*m/gu, '') - const panel = probe.buildPanel(24).lines.map((line) => line.replace(/\u001b\[[0-9;:]*m/gu, '')) - const statusRow = panel.findIndex((line) => line.includes('Thinking…')) - const composerRow = panel.findIndex((line) => line.startsWith(' ❯')) - - expect(events).toEqual(['● Reading file…']) - expect(status).toMatch(/^[·•●] Thinking…$/u) - expect(status).not.toContain('Planning next step') - expect(statusRow).toBeGreaterThanOrEqual(0) - expect(statusRow).toBe(composerRow - 3) - expect(panel[composerRow - 2]).toBe('') - expect(panel[composerRow - 1]).toBe(' ') - expect(panel[composerRow + 1]).toBe(' ') - - activity.clear() - expect(probe.activityStatusLine().replace(/\u001b\[[0-9;:]*m/gu, '')).toMatch( - /^[·•●] Thinking…$/u - ) - activity.stop() - terminal.close() - }) - - it('commits one settled work duration without showing a live time counter', () => { - const { input, output } = terminalStreams() - const now = vi.spyOn(Date, 'now').mockReturnValue(10_000) - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - const probe = terminal as never as { - activityStatusLine(): string - transcript: string - } - - expect(probe.activityStatusLine()).not.toContain('Worked for') - expect(probe.activityStatusLine()).not.toContain('1m') - terminal.write('Done') - now.mockReturnValue(75_000) - activity.complete() - activity.complete() - - const transcript = probe.transcript.replace(/\u001b\[[0-9;:]*m/gu, '') - expect(transcript.match(/✻ Worked for 1m 5s/gu)).toHaveLength(1) - expect(probe.activityStatusLine()).toBe('') - - terminal.close() - now.mockRestore() - }) - - it('preserves every completed row when a turn exceeds the live activity window', () => { - const { input, output } = terminalStreams() - const terminal = new ReadlineChatTerminal(input, output) - const activity = terminal.activity('Thinking…') - const labels = Array.from({ length: 30 }, (_, index) => `Tool ${index}`) - - for (const [index, label] of labels.entries()) { - activity.event({ kind: 'tool', id: `tool-${index}`, label, state: 'complete' }) - } - activity.stop() - - const transcript = ( - terminal as never as { - transcript: string - } - ).transcript - .replace(/\u001b\[[0-9;:]*m/gu, '') - .trim() - .split('\n') - expect(transcript).toEqual(labels.map((label) => `● ${label}`)) - - terminal.close() - }) - - it('ignores stale activity handles and settles an empty successful turn once', () => { - const { input, output } = terminalStreams() - const now = vi.spyOn(Date, 'now').mockReturnValue(10_000) - const terminal = new ReadlineChatTerminal(input, output) - const stale = terminal.activity('Thinking…') - const current = terminal.activity('Thinking…') - const probe = terminal as never as { - activityEventsDisplay(): string - activityStatusLine(): string - transcript: string - } - - stale.update('Stale') - stale.event({ kind: 'tool', id: 'stale-tool', label: 'Stale tool', state: 'complete' }) - stale.complete() - expect(probe.activityStatusLine()).toContain('Thinking…') - expect(probe.activityEventsDisplay()).not.toContain('Stale tool') - - now.mockReturnValue(12_000) - current.complete() - current.complete() - expect( - probe.transcript.replace(/\u001b\[[0-9;:]*m/gu, '').match(/Worked for 2s/gu) - ).toHaveLength(1) - - terminal.close() - now.mockRestore() - }) - - it('queues rapid non-TTY lines and leaves non-interactive output free of screen controls', async () => { - const input = new PassThrough() - const output = new PassThrough() - const chunks: string[] = [] - output.on('data', (chunk) => chunks.push(String(chunk))) - const terminal = new ReadlineChatTerminal(input, output) - - input.write('first\nsecond\n') - await new Promise((resolve) => setImmediate(resolve)) - - await expect(terminal.read('> ')).resolves.toEqual({ - kind: 'line', - value: 'first', - queued: true, - display: 'first', - }) - await expect(terminal.read('> ')).resolves.toEqual({ - kind: 'line', - value: 'second', - queued: true, - display: 'second', - }) - terminal.write('plain output') - expect(chunks.join('')).toBe('plain output') - expect(chunks.join('')).not.toContain('\u001b[') - terminal.close() - }) -}) diff --git a/packages/sim-cli/src/commands/protocol/chat-terminal.ts b/packages/sim-cli/src/commands/protocol/chat-terminal.ts deleted file mode 100644 index fe465c7661d..00000000000 --- a/packages/sim-cli/src/commands/protocol/chat-terminal.ts +++ /dev/null @@ -1,2690 +0,0 @@ -import { emitKeypressEvents, type Key } from 'node:readline' -import type { Readable, Writable } from 'node:stream' -import { safeOneLine, sanitize } from '../../output/render.js' -import { - artPad, - displayWidth, - firstGrapheme, - graphemes, - graphemeWidth, - lineEnd, - lineStart, - nextGraphemeIndex, - previousGraphemeIndex, - tailToWidth, - truncateDisplay, -} from '../../output/terminal-text.js' - -/** Which tag `noteAttachment` writes into the composer. */ -export type ChatAttachmentKind = 'Image' | 'File' - -export type ChatTerminalInput = - | { - kind: 'line' - value: string - queued?: boolean - display?: string - /** Large-paste bodies retained only so a failed queued turn can be retried losslessly. */ - pastes?: ReadonlyMap<number, string> - /** Identity-bearing `@` and `/` tags present in this submitted line. */ - contexts?: ChatContext[] - } - | { kind: 'clipboard'; value: string } - | { kind: 'selection'; values: string[] } - | { kind: 'interrupt'; empty?: boolean } - | { kind: 'eof' } - -type ChatActivityState = 'running' | 'complete' | 'error' - -export type ChatActivityUpdate = - | { - kind: 'tool' | 'subagent' - id: string - label: string - state: ChatActivityState - /** Opaque public id of the subagent lane that owns this row. */ - parentId?: string - } - | { - kind: 'narration' - /** Opaque public id of the subagent lane that owns this text. */ - parentId: string - delta: string - } - -export interface ChatActivity { - update(message: string): void - thinking(delta: string): void - event(update: ChatActivityUpdate): void - clear(): void - complete(): void - stop(): void -} - -export interface ChatTerminalQuestion { - prompt: string - multi: boolean - options: Array<{ id: string; label: string }> -} - -export interface ChatTerminalSelect { - prompt: string - options: Array<{ id: string; label: string; description?: string }> -} - -export interface ChatTerminalWelcome { - chatTitle: string - profile?: string - workspaceName?: string -} - -export type ChatTerminalQuestionResult = - | { kind: 'answer'; values: string[] } - | { kind: 'cancel' } - | { kind: 'eof' } - -export type ChatTerminalSelectResult = - | { kind: 'selected'; id: string } - | { kind: 'cancel' } - | { kind: 'eof' } - -export type ChatTerminalInterruptReason = 'manual' | 'submit' -export type ChatTerminalInterruptListener = ( - reason: ChatTerminalInterruptReason, - input?: ChatTerminalInput -) => void - -export interface ChatTerminal { - welcome(context: ChatTerminalWelcome): void - /** Updates the active conversation title after resume or server-side title generation. */ - setChatTitle(title: string): void - /** Fills in the workspace name once the lookup resolves. */ - setWorkspaceName(name: string): void - /** Inserts an `[Image #N]` or `[File #N]` tag at the cursor for a just-attached file. */ - noteAttachment(kind?: ChatAttachmentKind): void - /** Supplies the home-composer `@` resource and `/` skill/MCP pools. */ - setSuggestionCandidates?(candidates: ChatSuggestionCandidates): void - /** Clears the visible conversation while preserving the active terminal session. */ - clearTranscript(): void - userMessage(message: string): void - read(prompt: string): Promise<ChatTerminalInput> - /** Whether deferred input, a control, or a priority preload is waiting to be consumed. */ - hasQueuedInput(): boolean - /** Temporarily stages text ahead of queued turns without discarding the live draft. */ - preload( - value: string, - options?: { - queued?: boolean - pastes?: ReadonlyMap<number, string> - contexts?: ChatContext[] - } - ): boolean - status(message: string): void - /** Writes trusted, already-rendered assistant output into the coordinated transcript viewport. */ - write(content: string): void - activity(message: string): ChatActivity - askQuestion(question: ChatTerminalQuestion): Promise<ChatTerminalQuestionResult> - /** Opens a searchable, single-choice menu above the bottom-pinned search composer. */ - select(menu: ChatTerminalSelect): Promise<ChatTerminalSelectResult> - onInterrupt(listener: ChatTerminalInterruptListener): () => void - close(): void -} - -interface TerminalInput extends Readable { - isTTY?: boolean - isRaw?: boolean - setRawMode?: (mode: boolean) => void -} - -interface TerminalOutput extends Writable { - isTTY?: boolean - columns?: number - rows?: number -} - -interface CursorPoint { - index: number - row: number - column: number -} - -interface DraftLayout { - rows: string[] - points: CursorPoint[] - cursor: CursorPoint -} - -interface DraftLayoutOptions { - continuationPrefix?: string - normalTextStyle?: string -} - -interface RenderPanel { - lines: string[] - focusRow?: number - centerFocus?: boolean - cursor?: { row: number; column: number } -} - -interface QuestionState { - question: ChatTerminalQuestion - active: number - selected: Set<number> - previousDraft: string - previousCursor: number - previousContexts: ChatContext[] - resolve: (result: ChatTerminalQuestionResult) => void -} - -interface SelectState { - menu: ChatTerminalSelect - active: number - previousDraft: string - previousCursor: number - previousContexts: ChatContext[] - resolve: (result: ChatTerminalSelectResult) => void -} - -interface QueuedTerminalInput { - input: ChatTerminalInput - /** Composer text that has not already been committed to the transcript. */ - display?: string -} - -interface PreloadState { - initialDraft: string - previousDraft: string - previousCursor: number - previousPastes: Map<number, string> - previousContexts: ChatContext[] - queued: boolean -} - -interface RecalledQueueState { - index: number - initialDraft: string - /** Undefined when the original queue row was already committed. */ - commitDisplay?: string -} - -type ChatActivityStatusUpdate = Exclude<ChatActivityUpdate, { kind: 'narration' }> - -type ActivityTreeChild = { kind: 'node'; id: string } | { kind: 'narration'; content: string } - -interface ActivityTreeNode extends ChatActivityStatusUpdate { - children: ActivityTreeChild[] -} - -import { - applySuggestion, - type ChatContext, - type ChatSuggestionCandidates, - type CompletionToken, - contextSpans, - extractCompletionToken, - formatMention, - presentContexts, - rankSuggestions, - resolveSlashContexts, - SLASH_COMMANDS, - type SuggestionItem, - suggestionWindow, -} from './chat-suggestions.js' - -const ESC = '\u001b' -const RESET = `${ESC}[0m` -const DIM = `${ESC}[2m` -const HIDE_CURSOR = `${ESC}[?25l` -const SHOW_CURSOR = `${ESC}[?25h` -const ENTER_ALTERNATE_SCREEN = `${ESC}[?1049h` -const EXIT_ALTERNATE_SCREEN = `${ESC}[?1049l` -const ENABLE_BRACKETED_PASTE = `${ESC}[?2004h` -const DISABLE_BRACKETED_PASTE = `${ESC}[?2004l` -const BEGIN_SYNCHRONIZED_OUTPUT = `${ESC}[?2026h` -const END_SYNCHRONIZED_OUTPUT = `${ESC}[?2026l` -const CLEAR_SCREEN = `${ESC}[2J` -const RESET_SCROLL_REGION = `${ESC}[r` -const BOLD = `${ESC}[1m` -const BRIGHT_WHITE = `${ESC}[97m` -/** Sim green — marks a mention that currently resolves to a candidate. */ -const MENTION_TEXT = `${ESC}[38;2;51;196;130m` -const USER_MESSAGE_BACKGROUND = `${ESC}[48;2;58;60;70m` -const USER_MESSAGE_TEXT = `${ESC}[38;2;242;242;242m` -const USER_MESSAGE_POINTER = `${ESC}[38;2;160;160;160m` -const USER_PANEL_OUTER_MARGIN = ' ' -const USER_TURN_PREFIX = `${USER_PANEL_OUTER_MARGIN}❯ ` -const ASSISTANT_TURN_PREFIX = '● ' -const DEFAULT_CHAT_TITLE = 'New chat' -const CONTINUATION_PREFIX = ' ' -const MAX_TRANSCRIPT_CHARACTERS = 256 * 1024 -const MAX_HISTORY_ENTRIES = 500 -const MAX_DRAFT_CHARACTERS = 10 * 1024 * 1024 -/** Above this, or across multiple lines, a paste collapses to a placeholder. */ -const PASTE_PLACEHOLDER_CHARACTERS = 800 -const PASTE_PLACEHOLDER_LINES = 3 -const PASTED_TEXT_REF = /\[Pasted text #(\d+)(?: \+\d+ lines)?\]/g -const BLIMP_ART = [ - ' ⣀⣀⣀', - ' ⡇ ⢳⡀⣀⣀⣀⠤⢤⣤⣤⣤⠤⠤⠤⣀⣀⣀', - ' ⢻⣀⡴⠂⠉⢹⠤⠤⣜⠁ ⢘⡦⠤⠤⡞⠉⠉⠙⠻⡖⠢⢄⡀', - '⠤⠶⠶⠮⣤⣽⠤⠴⡋ ⢘⡦⠤⢤⡏ ⠹⣄⣀⣀⡴⠋⠉⢹⢺⡄', - ' ⠈⠓⠤⢄⣹⡤⠤⢜ ⠳⣄⣀⣀⡞ ⢙⣦⣖⠾⠋', - ' ⠈⠉⠉⣝⣀⣀⣚⣀⡔⠒⠛⠓⠊⠉⠉', -] as const - -/** Frames and cadence for the airship sliding in from the left on first paint. */ -const WELCOME_FLY_IN_FRAMES = 20 -const WELCOME_FLY_IN_INTERVAL_MS = 22 -/** Columns the detail box needs beside the art before it is worth drawing. */ -const WELCOME_MIN_BOX_COLUMNS = 26 -/** Blank columns between the detail box and the airship. */ -const WELCOME_GUTTER = 2 - -function formatActivityDuration(elapsedMs: number): string { - let seconds = Math.max(1, Math.round(Math.max(0, elapsedMs) / 1000)) - const hours = Math.floor(seconds / 3600) - seconds %= 3600 - const minutes = Math.floor(seconds / 60) - seconds %= 60 - return [hours ? `${hours}h` : '', minutes ? `${minutes}m` : '', seconds ? `${seconds}s` : ''] - .filter(Boolean) - .join(' ') -} - -/** Shared row treatment for the editable composer and its committed user turn. */ -function userPanelRow(content = ''): string { - return `${USER_PANEL_OUTER_MARGIN}${USER_MESSAGE_BACKGROUND}${content}${RESET}` -} - -/** A fullscreen terminal chat with a durable transcript and a bottom-pinned composer. */ -export class ReadlineChatTerminal implements ChatTerminal { - private pending: ((input: ChatTerminalInput) => void) | null = null - private readonly queued: QueuedTerminalInput[] = [] - private recalledQueue: RecalledQueueState | null = null - private readonly interruptListeners = new Set<ChatTerminalInterruptListener>() - private readonly history: string[] = [] - private historyIndex = 0 - private historyDraft = '' - private preferredColumn: number | null = null - private prompt = '❯ ' - private draft = '' - private cursor = 0 - private preloadState: PreloadState | null = null - private composerVisible = false - private busy = false - private questionState: QuestionState | null = null - private selectState: SelectState | null = null - private welcomeVisible = false - private welcomeProfile: string | null = null - private welcomeChatTitle = DEFAULT_CHAT_TITLE - private welcomeWorkspaceName: string | null = null - private suggestionIndex = 0 - private suggestionQueryKey: string | null = null - private suggestionDismissed: string | null = null - private resourceCandidates: SuggestionItem[] = [] - private slashCandidates: SuggestionItem[] = [] - private selectedContexts: ChatContext[] = [] - private readonly nextAttachmentNumber = new Map<ChatAttachmentKind, number>() - private pasting = false - private pasteBuffer = '' - private pastedText = new Map<number, string>() - private nextPasteId = 1 - private transcriptEpoch = 0 - private wrapCache: { - width: number - epoch: number - consumed: number - rows: string[] - state: WrapState - } | null = null - private welcomeRevealFrame = WELCOME_FLY_IN_FRAMES - private welcomeTimer: ReturnType<typeof setInterval> | null = null - private transcript = '' - private assistantPrefixPending = false - private assistantPrefixBuffer = '' - private assistantTurnActive = false - private assistantContinuationPending = false - private transcriptScrollTopRow: number | null = null - private viewportActive = false - private renderedScreen: string[] | null = null - private renderedColumns = 0 - private renderedRows = 0 - private restoredRawMode = false - private readonly inputWasRaw: boolean - private readonly inputWasFlowing: boolean - private ended = false - private closed = false - private activityActive = false - private activityThinking = '' - private activityStartedAt = 0 - private activityGeneration = 0 - private readonly activityNodes = new Map<string, ActivityTreeNode>() - private readonly activityRoots: string[] = [] - private readonly committedActivityRoots = new Set<string>() - private activityFrame = 0 - private activityTimer: ReturnType<typeof setInterval> | null = null - - constructor( - private readonly input: Readable = process.stdin, - private readonly output: Writable = process.stdout - ) { - this.inputWasFlowing = input.readableFlowing === true - this.inputWasRaw = Boolean((input as TerminalInput).isRaw) - emitKeypressEvents(input) - input.on('keypress', this.handleKeypress) - input.once('end', this.handleInputEnd) - output.on('resize', this.handleResize) - } - - welcome(context: ChatTerminalWelcome): void { - if (!this.isInteractiveTTY() || this.closed) return - this.welcomeVisible = true - this.welcomeProfile = context.profile ? safeOneLine(context.profile).slice(0, 80) : null - this.welcomeChatTitle = safeOneLine(context.chatTitle).slice(0, 160) || DEFAULT_CHAT_TITLE - this.welcomeWorkspaceName = context.workspaceName - ? safeOneLine(context.workspaceName).slice(0, 80) - : null - this.startWelcomeFlyIn() - this.ensureViewport() - this.renderScreen() - } - - userMessage(message: string): void { - if (!message.trim()) return - this.commitUserLine(message) - this.renderScreen() - } - - clearTranscript(): void { - this.stopActivity() - this.transcript = '' - this.transcriptEpoch += 1 - this.wrapCache = null - this.assistantPrefixPending = false - this.assistantPrefixBuffer = '' - this.assistantTurnActive = false - this.assistantContinuationPending = false - this.transcriptScrollTopRow = null - this.history.length = 0 - this.historyIndex = 0 - this.historyDraft = '' - this.renderScreen() - } - - read(prompt: string): Promise<ChatTerminalInput> { - if (this.closed) return Promise.resolve({ kind: 'eof' }) - const queued = this.preloadState ? undefined : this.queued.shift() - if (queued) { - if (this.recalledQueue && this.recalledQueue.index > 0) { - this.recalledQueue.index-- - } - const { input } = queued - if (input.kind === 'line') { - for (const [id, body] of input.pastes ?? []) this.pastedText.set(id, body) - if (queued.display?.trim()) this.commitUserLine(queued.display) - } - this.renderScreen() - return Promise.resolve(input) - } - if (this.ended) return Promise.resolve({ kind: 'eof' }) - if (this.pending || this.questionState || this.selectState) { - throw new Error('Chat terminal already has a pending read') - } - - this.prompt = sanitize(prompt) - .replace(/[\n\r\t]+/gu, ' ') - .slice(0, 80) - this.draft = sanitize(this.draft).slice(0, MAX_DRAFT_CHARACTERS) - this.cursor = Math.min(this.cursor, this.draft.length) - this.preferredColumn = null - this.historyIndex = this.history.length - this.historyDraft = this.draft - this.composerVisible = true - this.busy = false - this.ensureViewport() - - if (!this.isInteractiveTTY()) this.output.write(this.prompt) - this.renderScreen() - return new Promise((resolve) => { - this.pending = resolve - this.renderScreen() - }) - } - - hasQueuedInput(): boolean { - return this.preloadState !== null || this.queued.length > 0 - } - - preload( - value: string, - options: { - queued?: boolean - pastes?: ReadonlyMap<number, string> - contexts?: ChatContext[] - } = {} - ): boolean { - if ( - this.closed || - this.ended || - this.pending || - this.busy || - this.questionState || - this.selectState || - this.preloadState - ) { - return false - } - const next = sanitize(value).slice(0, MAX_DRAFT_CHARACTERS) - if (!next) return false - - this.preloadState = { - initialDraft: next, - previousDraft: this.draft, - previousCursor: this.cursor, - previousPastes: this.pastesFor(this.draft), - previousContexts: this.selectedContexts, - queued: options.queued === true, - } - for (const [id, body] of options.pastes ?? []) this.pastedText.set(id, body) - this.draft = next - this.cursor = next.length - this.selectedContexts = [...(options.contexts ?? [])] - this.preferredColumn = null - this.composerVisible = true - this.renderScreen() - return true - } - - status(message: string): void { - const safe = sanitize(message) - if (!this.isInteractiveTTY()) { - this.output.write(safe) - if (!safe.endsWith('\n')) this.output.write('\n') - return - } - - this.ensureViewport() - this.assistantTurnActive = false - this.assistantContinuationPending = false - this.appendTranscript(safe) - if (!safe.endsWith('\n')) this.appendTranscript('\n') - this.renderScreen() - } - - write(content: string): void { - if (!content) return - if (!this.isInteractiveTTY()) { - this.output.write(content) - return - } - - this.ensureViewport() - let rendered = content.replace(/\r/gu, '') - if (this.assistantPrefixPending) { - this.assistantPrefixBuffer += rendered - const prefixed = prefixAssistantTurn(this.assistantPrefixBuffer) - if (prefixed === null) return - rendered = prefixed - this.assistantPrefixPending = false - this.assistantPrefixBuffer = '' - this.assistantTurnActive = true - this.assistantContinuationPending = rendered.endsWith('\n') - } else if (this.assistantTurnActive) { - rendered = indentAssistantFragment(rendered, this.assistantContinuationPending) - this.assistantContinuationPending = rendered.endsWith('\n') - } - this.appendTranscript(rendered) - this.renderScreen() - } - - activity(message: string): ChatActivity { - this.stopActivity() - this.assistantPrefixPending = true - this.assistantPrefixBuffer = '' - this.assistantTurnActive = false - this.assistantContinuationPending = false - this.activityActive = true - this.activityThinking = safeOneLine(message) || 'Thinking…' - this.activityStartedAt = Date.now() - const generation = ++this.activityGeneration - this.activityNodes.clear() - this.activityRoots.length = 0 - this.committedActivityRoots.clear() - this.activityFrame = 0 - this.busy = true - this.composerVisible = true - this.ensureViewport() - this.renderScreen() - - if (this.isInteractiveTTY()) { - this.activityTimer = setInterval(() => { - this.activityFrame += 1 - this.renderScreen() - }, 90) - this.activityTimer.unref() - } - - let stopped = false - const isCurrent = () => - !stopped && this.activityActive && generation === this.activityGeneration - const finish = (completed: boolean) => { - if (!isCurrent()) return - stopped = true - this.stopActivity(completed) - } - return { - update: (next) => { - if (!isCurrent()) return - this.activityThinking = safeOneLine(next) || this.activityThinking - this.renderScreen() - }, - thinking: (_delta) => { - if (!isCurrent()) return - // Match the web client: raw reasoning is not rendered; the stable - // turn-level label remains visible in the tail instead. - }, - event: (update) => { - if (!isCurrent()) return - this.recordActivityEvent(update) - this.renderScreen() - }, - clear: () => { - if (!isCurrent()) return - this.commitActivityEvents(false) - this.renderScreen() - }, - complete: () => finish(true), - stop: () => finish(false), - } - } - - askQuestion(question: ChatTerminalQuestion): Promise<ChatTerminalQuestionResult> { - if (this.pending || this.questionState || this.selectState) { - throw new Error('Chat terminal already has a pending read') - } - if (this.ended || this.closed) return Promise.resolve({ kind: 'eof' }) - - const safeQuestion: ChatTerminalQuestion = { - prompt: safeOneLine(question.prompt).slice(0, 500), - multi: question.multi, - options: question.options.slice(0, 20).map((option) => ({ - id: safeOneLine(option.id).slice(0, 160), - label: safeOneLine(option.label).slice(0, 160), - })), - } - const previousDraft = this.draft - const previousCursor = this.cursor - const previousContexts = this.selectedContexts - this.draft = '' - this.cursor = 0 - this.selectedContexts = [] - this.preferredColumn = null - this.composerVisible = true - this.busy = false - this.ensureViewport() - - return new Promise((resolve) => { - this.questionState = { - question: safeQuestion, - active: 0, - selected: new Set(), - previousDraft, - previousCursor, - previousContexts, - resolve, - } - this.renderScreen() - }) - } - - select(menu: ChatTerminalSelect): Promise<ChatTerminalSelectResult> { - if (this.pending || this.questionState || this.selectState) { - throw new Error('Chat terminal already has a pending read') - } - if (this.ended || this.closed) return Promise.resolve({ kind: 'eof' }) - - const safeMenu: ChatTerminalSelect = { - prompt: safeOneLine(menu.prompt).slice(0, 500), - options: menu.options.map((option) => ({ - id: safeOneLine(option.id).slice(0, 160), - label: safeOneLine(option.label).slice(0, 255), - ...(option.description - ? { description: safeOneLine(option.description).slice(0, 255) } - : {}), - })), - } - const previousDraft = this.draft - const previousCursor = this.cursor - const previousContexts = this.selectedContexts - this.draft = '' - this.cursor = 0 - this.selectedContexts = [] - this.preferredColumn = null - this.composerVisible = true - this.busy = false - this.ensureViewport() - - return new Promise((resolve) => { - this.selectState = { - menu: safeMenu, - active: 0, - previousDraft, - previousCursor, - previousContexts, - resolve, - } - this.renderScreen() - }) - } - - onInterrupt(listener: ChatTerminalInterruptListener): () => void { - this.interruptListeners.add(listener) - return () => this.interruptListeners.delete(listener) - } - - close(): void { - if (this.closed) return - this.stopActivity() - this.stopWelcomeFlyIn() - this.closed = true - - const pending = this.pending - this.pending = null - pending?.({ kind: 'eof' }) - const question = this.questionState - this.questionState = null - question?.resolve({ kind: 'eof' }) - const select = this.selectState - this.selectState = null - select?.resolve({ kind: 'eof' }) - - this.input.removeListener('keypress', this.handleKeypress) - this.input.removeListener('end', this.handleInputEnd) - this.output.removeListener('resize', this.handleResize) - - if (this.viewportActive) { - this.output.write( - `${BEGIN_SYNCHRONIZED_OUTPUT}${RESET}${RESET_SCROLL_REGION}${DISABLE_BRACKETED_PASTE}${SHOW_CURSOR}${EXIT_ALTERNATE_SCREEN}${END_SYNCHRONIZED_OUTPUT}` - ) - this.viewportActive = false - } - this.restoreInputMode() - } - - private readonly handleResize = (): void => { - this.renderScreen() - } - - private readonly handleInputEnd = (): void => { - this.ended = true - const pending = this.pending - this.pending = null - pending?.({ kind: 'eof' }) - const question = this.questionState - this.questionState = null - question?.resolve({ kind: 'eof' }) - const select = this.selectState - this.selectState = null - select?.resolve({ kind: 'eof' }) - this.renderScreen() - } - - private readonly handleKeypress = (character: string, key: Key | undefined): void => { - if (this.closed) return - if (key?.name === 'paste-start') { - this.pasting = true - this.pasteBuffer = '' - return - } - if (key?.name === 'paste-end') { - const pasted = this.pasteBuffer - this.pasting = false - this.pasteBuffer = '' - this.commitPaste(pasted) - return - } - if (this.pasting) { - if (character) this.pasteBuffer += character - return - } - - if (this.selectState) { - this.handleSelectKey(character, key) - return - } - - if (this.handleTranscriptNavigationKey(key)) return - - if (key?.ctrl && key.name === 'v') { - this.resolveClipboard() - return - } - if (this.questionState) { - this.handleQuestionKey(character, key) - return - } - - this.handleEditorKey(character, key) - } - - private handleEditorKey(character: string, key: Key | undefined): void { - if (!this.isComposerEditable() && this.isInteractiveTTY()) return - if (key?.ctrl && key.name === 'c') { - if (!this.pending && this.busy) { - for (const listener of this.interruptListeners) listener('manual') - return - } - const wasEmpty = this.draft.length === 0 - this.draft = '' - this.cursor = 0 - this.preferredColumn = null - this.resolveInput({ kind: 'interrupt', empty: wasEmpty }) - return - } - if (key?.ctrl && key.name === 'd' && this.draft.length === 0) { - this.resolveInput({ kind: 'eof' }) - return - } - const open = this.openSuggestions() - if (open) { - if (key?.name === 'up' || (key?.ctrl && key.name === 'p')) { - this.moveSuggestion(open.items.length, -1) - return - } - if (key?.name === 'down' || (key?.ctrl && key.name === 'n')) { - this.moveSuggestion(open.items.length, 1) - return - } - if (key?.name === 'escape') { - this.suggestionDismissed = this.draft - this.renderScreen() - return - } - if (key?.name === 'tab' || isEnter(key)) { - const chosen = open.items[Math.min(this.suggestionIndex, open.items.length - 1)] - const submitExactSlash = - isEnter(key) && - chosen?.tag === 'command' && - open.token.trigger === '/' && - chosen.value === open.token.token - if (!submitExactSlash) { - this.acceptSuggestion(open) - return - } - } - } - if (key?.name === 'escape') { - if (!this.pending && this.busy) { - for (const listener of this.interruptListeners) listener('manual') - return - } - const wasEmpty = this.draft.length === 0 - this.draft = '' - this.cursor = 0 - this.resolveInput({ kind: 'interrupt', empty: wasEmpty }) - return - } - - if (isEnter(key)) { - const beforeCursor = this.draft.slice(0, this.cursor) - if (key?.shift || key?.meta || beforeCursor.endsWith('\\')) { - if (beforeCursor.endsWith('\\')) { - this.draft = `${beforeCursor.slice(0, -1)}\n${this.draft.slice(this.cursor)}` - this.cursor = beforeCursor.length - } else { - this.insertText('\n') - } - this.renderScreen() - return - } - this.submitDraft() - return - } - if (key?.name === 'backspace') { - this.deleteBackward() - return - } - if (key?.name === 'delete') { - this.deleteForward() - return - } - if (key?.name === 'left' || (key?.ctrl && key.name === 'b')) { - this.cursor = previousGraphemeIndex(this.draft, this.cursor) - this.preferredColumn = null - this.renderScreen() - return - } - if (key?.name === 'right' || (key?.ctrl && key.name === 'f')) { - this.cursor = nextGraphemeIndex(this.draft, this.cursor) - this.preferredColumn = null - this.renderScreen() - return - } - if (key?.name === 'up' || (key?.ctrl && key.name === 'p')) { - if (this.draft.length === 0 && this.recallQueuedDraft()) return - this.moveVertically(-1) - return - } - if (key?.name === 'down' || (key?.ctrl && key.name === 'n')) { - this.moveVertically(1) - return - } - if (key?.name === 'home' || (key?.ctrl && key.name === 'a')) { - this.cursor = lineStart(this.draft, this.cursor) - this.preferredColumn = null - this.renderScreen() - return - } - if (key?.name === 'end' || (key?.ctrl && key.name === 'e')) { - this.cursor = lineEnd(this.draft, this.cursor) - this.preferredColumn = null - this.renderScreen() - return - } - if (key?.ctrl && key.name === 'u') { - this.draft = this.draft.slice(this.cursor) - this.cursor = 0 - this.preferredColumn = null - this.renderScreen() - return - } - if (key?.ctrl && key.name === 'k') { - this.draft = this.draft.slice(0, this.cursor) - this.preferredColumn = null - this.renderScreen() - return - } - if (key?.ctrl && key.name === 'w') { - const before = this.draft.slice(0, this.cursor) - const start = before.search(/\S+\s*$/u) - if (start >= 0) { - this.draft = `${before.slice(0, start)}${this.draft.slice(this.cursor)}` - this.cursor = start - } - this.preferredColumn = null - this.renderScreen() - return - } - - const printable = printableText(character, key) - if (printable) { - this.insertText(printable) - this.renderScreen() - } - } - - private handleQuestionKey(character: string, key: Key | undefined): void { - const state = this.questionState - if (!state) return - const otherIndex = state.question.options.length - const submitIndex = state.question.multi ? otherIndex + 1 : otherIndex - const choiceCount = submitIndex + 1 - - if (key?.name === 'escape' || (key?.ctrl && key.name === 'c')) { - this.finishQuestion({ kind: 'cancel' }) - return - } - if (key?.name === 'up' || (key?.ctrl && key.name === 'p')) { - state.active = (state.active - 1 + choiceCount) % choiceCount - this.renderScreen() - return - } - if (key?.name === 'down' || key?.name === 'tab' || (key?.ctrl && key.name === 'n')) { - state.active = (state.active + 1) % choiceCount - this.renderScreen() - return - } - if (state.question.multi && key?.name === 'space' && state.active < otherIndex) { - this.toggleQuestionSelection(state.active) - return - } - if (/^[1-9]$/u.test(character) && this.draft.length === 0) { - const index = Number(character) - 1 - if (index < state.question.options.length) { - state.active = index - this.renderScreen() - return - } - } - if (isEnter(key)) { - if (state.active < otherIndex) { - if (state.question.multi) this.toggleQuestionSelection(state.active) - else { - const selected = state.question.options[state.active] - if (selected) this.finishQuestion({ kind: 'answer', values: [selected.label] }) - } - return - } - - const custom = safeOneLine(this.draft) - if (state.active === otherIndex && custom) { - const values = state.question.multi ? [...this.selectedQuestionLabels(), custom] : [custom] - this.finishQuestion({ kind: 'answer', values: [...new Set(values)] }) - return - } - if (state.question.multi && state.active === submitIndex) { - const values = this.selectedQuestionLabels() - if (custom) values.push(custom) - if (values.length > 0) { - this.finishQuestion({ kind: 'answer', values: [...new Set(values)] }) - } - } - return - } - - if (state.active === otherIndex) { - if (key?.name === 'backspace') { - this.deleteBackward() - return - } - if (key?.name === 'delete') { - this.deleteForward() - return - } - if (key?.name === 'left') { - this.cursor = previousGraphemeIndex(this.draft, this.cursor) - this.renderScreen() - return - } - if (key?.name === 'right') { - this.cursor = nextGraphemeIndex(this.draft, this.cursor) - this.renderScreen() - return - } - } - - const printable = printableText(character, key) - if (printable) { - state.active = otherIndex - this.insertText(printable) - this.renderScreen() - } - } - - private handleSelectKey(character: string, key: Key | undefined): void { - const state = this.selectState - if (!state) return - const options = this.filteredSelectOptions() - - if (key?.name === 'escape' || (key?.ctrl && key.name === 'c')) { - this.finishSelect({ kind: 'cancel' }) - return - } - if (key?.name === 'up' || (key?.ctrl && key.name === 'p')) { - if (options.length > 0) state.active = (state.active - 1 + options.length) % options.length - this.renderScreen() - return - } - if (key?.name === 'down' || key?.name === 'tab' || (key?.ctrl && key.name === 'n')) { - if (options.length > 0) state.active = (state.active + 1) % options.length - this.renderScreen() - return - } - if (isEnter(key)) { - if (this.selectOptionCapacity() <= 0) return - const selected = options[Math.min(state.active, options.length - 1)] - if (selected) this.finishSelect({ kind: 'selected', id: selected.id }) - return - } - if (key?.name === 'backspace') { - state.active = 0 - this.deleteBackward() - return - } - if (key?.name === 'delete') { - state.active = 0 - this.deleteForward() - return - } - if (key?.name === 'left' || (key?.ctrl && key.name === 'b')) { - this.cursor = previousGraphemeIndex(this.draft, this.cursor) - this.renderScreen() - return - } - if (key?.name === 'right' || (key?.ctrl && key.name === 'f')) { - this.cursor = nextGraphemeIndex(this.draft, this.cursor) - this.renderScreen() - return - } - if (key?.name === 'home' || (key?.ctrl && key.name === 'a')) { - this.cursor = 0 - this.renderScreen() - return - } - if (key?.name === 'end' || (key?.ctrl && key.name === 'e')) { - this.cursor = this.draft.length - this.renderScreen() - return - } - if (key?.ctrl && key.name === 'u') { - this.draft = this.draft.slice(this.cursor) - this.cursor = 0 - state.active = 0 - this.renderScreen() - return - } - if (key?.ctrl && key.name === 'k') { - this.draft = this.draft.slice(0, this.cursor) - state.active = 0 - this.renderScreen() - return - } - if (key?.ctrl && key.name === 'w') { - const before = this.draft.slice(0, this.cursor) - const start = before.search(/\S+\s*$/u) - if (start >= 0) { - this.draft = `${before.slice(0, start)}${this.draft.slice(this.cursor)}` - this.cursor = start - } - state.active = 0 - this.renderScreen() - return - } - - const printable = printableText(character, key) - if (printable) { - this.insertText(printable) - state.active = 0 - this.renderScreen() - } - } - - /** - * Recomputed each render rather than tracked on every draft mutation, so the - * menu can never disagree with the text it is completing. - */ - private openSuggestions(): { - token: CompletionToken - items: SuggestionItem[] - pool: SuggestionItem[] - } | null { - if ( - !this.isComposerEditable() || - this.questionState || - this.selectState || - this.terminalRows() < 5 - ) { - this.suggestionIndex = 0 - this.suggestionQueryKey = null - return null - } - if (this.suggestionDismissed !== null) { - if (this.suggestionDismissed === this.draft) return null - this.suggestionDismissed = null - } - const token = extractCompletionToken(this.draft, this.cursor) - if (!token) { - this.suggestionIndex = 0 - this.suggestionQueryKey = null - return null - } - const queryKey = `${token.startPos}:${token.trigger}:${token.query}` - if (queryKey !== this.suggestionQueryKey) { - this.suggestionIndex = 0 - this.suggestionQueryKey = queryKey - } - const commandPosition = this.draft.slice(0, token.startPos).trim().length === 0 - const pool = - token.trigger === '/' - ? [...(commandPosition ? SLASH_COMMANDS : []), ...this.slashCandidates] - : this.resourceCandidates - if (!pool.length) return null - const items = rankSuggestions(token.query, pool) - return items.length ? { token, items, pool } : null - } - - setSuggestionCandidates(candidates: ChatSuggestionCandidates): void { - const open = this.openSuggestions() - const selectedId = open?.items[Math.min(this.suggestionIndex, open.items.length - 1)]?.id - this.resourceCandidates = candidates.resources - .map(sanitizeSuggestionItem) - .filter((item): item is SuggestionItem => item !== null) - this.slashCandidates = candidates.slash - .map(sanitizeSuggestionItem) - .filter((item): item is SuggestionItem => item !== null) - if (selectedId) { - const refreshed = this.openSuggestions() - const refreshedIndex = refreshed?.items.findIndex((item) => item.id === selectedId) ?? -1 - this.suggestionIndex = refreshedIndex >= 0 ? refreshedIndex : 0 - } - if (!this.closed && this.isComposerEditable()) this.renderScreen() - } - - private moveSuggestion(total: number, delta: number): void { - this.suggestionIndex = (this.suggestionIndex + delta + total) % total - this.renderScreen() - } - - private acceptSuggestion(open: { token: CompletionToken; items: SuggestionItem[] }): void { - const chosen = open.items[Math.min(this.suggestionIndex, open.items.length - 1)] - if (!chosen) return - const replacement = - open.token.trigger === '@' - ? formatMention(chosen.value) - : chosen.tag === 'command' - ? chosen.value - : `/${chosen.value}` - const next = applySuggestion(this.draft, open.token, replacement) - this.draft = next.draft - this.cursor = next.cursor - if ( - chosen.context && - !this.selectedContexts.some((context) => context.label === chosen.context?.label) - ) { - this.selectedContexts.push(chosen.context) - } - this.suggestionIndex = 0 - this.suggestionDismissed = this.draft - this.renderScreen() - } - - /** - * Re-derived every render rather than stored, so a mention the user - * half-deletes simply stops lighting up instead of leaving stale state. - */ - private liveMentionSpans(): Array<{ start: number; end: number }> { - const selected = presentContexts(this.draft, this.selectedContexts) - const occupied = new Set(selected.map((context) => context.label.toLowerCase())) - const typedSlash = resolveSlashContexts(this.draft, this.slashCandidates).filter( - (context) => !occupied.has(context.label.toLowerCase()) - ) - return [ - ...contextSpans(this.draft, [...selected, ...typedSlash]), - ...attachmentSpans(this.draft), - ].sort((left, right) => left.start - right.start) - } - - private suggestionRows(width: number, rows: number): string[] { - const open = this.openSuggestions() - if (!open) return [] - const maxVisible = Math.max(1, Math.min(5, rows - 6)) - const selected = Math.min(this.suggestionIndex, open.items.length - 1) - const { start, end } = suggestionWindow(open.items.length, selected, maxVisible) - /* Width comes from the whole pool, not the filtered slice, so the column - does not jump while the user narrows the list. */ - const labelWidth = Math.min( - Math.floor(width * 0.4), - Math.max(...open.pool.map((entry) => displayWidth(entry.displayText))) + 2 - ) - return open.items.slice(start, end).map((entry) => { - const active = entry.id === open.items[selected]?.id - const label = truncateDisplay(entry.displayText, Math.max(1, labelWidth - 2)) - const padding = ' '.repeat(Math.max(2, labelWidth - displayWidth(label))) - const line = truncateDisplay(` ${label}${padding}${entry.description ?? ''}`, width) - return active ? `${BRIGHT_WHITE}${line}${RESET}` : `${DIM}${line}${RESET}` - }) - } - - /** - * Turns one bracketed paste into a single edit. - * - * An empty paste is macOS Cmd+V of an image — the terminal sends the markers - * with nothing between them — so it routes to the same clipboard path as - * ctrl+v rather than being discarded. - */ - private commitPaste(text: string): void { - if (!this.isComposerEditable()) return - const normalized = text.replace(/\r\n?/gu, '\n') - if (!normalized) { - if (this.selectState) return - this.resolveClipboard() - return - } - if (this.selectState) { - this.insertText(normalized.replace(/\s+/gu, ' ')) - this.selectState.active = 0 - this.renderScreen() - return - } - const lines = normalized.split('\n').length - 1 - if (normalized.length > PASTE_PLACEHOLDER_CHARACTERS || lines >= PASTE_PLACEHOLDER_LINES) { - const id = this.nextPasteId++ - this.pastedText.set(id, normalized) - this.insertText(lines ? `[Pasted text #${id} +${lines} lines]` : `[Pasted text #${id}]`) - } else { - this.insertText(normalized) - } - this.renderScreen() - } - - /** Splices stashed paste bodies back in, and drops any the user deleted. */ - private pastesFor(value: string): Map<number, string> { - const pastes = new Map<number, string>() - for (const match of value.matchAll(PASTED_TEXT_REF)) { - const id = Number(match[1]) - const body = this.pastedText.get(id) - if (body !== undefined) pastes.set(id, body) - } - return pastes - } - - private expandPastes(value: string): string { - const referenced = new Set<number>() - const expanded = value.replace(PASTED_TEXT_REF, (match, id: string) => { - const body = this.pastedText.get(Number(id)) - if (body === undefined) return match - referenced.add(Number(id)) - return body - }) - for (const id of this.pastedText.keys()) if (!referenced.has(id)) this.pastedText.delete(id) - return expanded - } - - private submitDraft(): void { - /* The placeholder is what the user sees and recalls; only the wire value - carries the expanded body, so a large paste never floods the transcript. */ - const display = this.draft - const preload = this.preloadState - const deferred = !this.pending - if (deferred && !display.trim()) { - this.draft = '' - this.cursor = 0 - this.preferredColumn = null - this.recalledQueue = null - this.renderScreen() - return - } - const pastes = this.pastesFor(display) - const value = this.expandPastes(display) - const selected = presentContexts(value, this.selectedContexts) - const occupied = new Set(selected.map((context) => context.label.toLowerCase())) - const contexts = [ - ...selected, - ...resolveSlashContexts(value, this.slashCandidates).filter( - (context) => !occupied.has(context.label.toLowerCase()) - ), - ] - this.transcriptScrollTopRow = null - if (display.trim()) { - if (this.history.at(-1) !== display) this.history.push(display) - if (this.history.length > MAX_HISTORY_ENTRIES) this.history.shift() - // A queued retry was already committed when it first left the queue. - // Repaint it only if the user edited the staged retry. - const unchangedCommittedRecall = - this.recalledQueue?.commitDisplay === undefined && - display === this.recalledQueue?.initialDraft - if ( - !deferred && - !(preload?.queued && display === preload.initialDraft) && - !unchangedCommittedRecall - ) { - this.commitUserLine(display) - } - } - this.draft = '' - this.cursor = 0 - this.selectedContexts = [] - this.preferredColumn = null - this.historyIndex = this.history.length - const input: ChatTerminalInput = { - kind: 'line', - value, - ...(display !== value ? { display } : {}), - ...(pastes.size ? { pastes } : {}), - ...(contexts.length ? { contexts } : {}), - } - this.resolveInput(input, display) - if (deferred && this.busy && display.trim()) { - for (const listener of this.interruptListeners) listener('submit', input) - } - } - - private resolveClipboard(): void { - if (!this.isComposerEditable()) return - this.resolveInput({ kind: 'clipboard', value: this.draft }) - } - - private resolveInput(value: ChatTerminalInput, display?: string): void { - const pending = this.pending - this.pending = null - let resolved = value - const preload = value.kind === 'clipboard' ? null : this.preloadState - if (preload && value.kind !== 'clipboard') { - this.preloadState = null - if (value.kind === 'line' && preload.queued) { - resolved = { - ...value, - queued: true, - ...(display === undefined ? {} : { display }), - } - } - this.draft = preload.previousDraft - this.cursor = preload.previousCursor - this.selectedContexts = preload.previousContexts - for (const [id, body] of preload.previousPastes) this.pastedText.set(id, body) - } - const recalled = !preload && resolved.kind === 'line' ? this.recalledQueue : null - if (!preload && resolved.kind !== 'clipboard') this.recalledQueue = null - - if (pending) { - pending(resolved) - } else { - if (resolved.kind === 'line') { - resolved = { - ...resolved, - queued: true, - ...(display === undefined ? {} : { display }), - } - } - const entry = { - input: resolved, - ...(!( - (preload?.queued && display === preload.initialDraft) || - (recalled?.commitDisplay === undefined && display === recalled?.initialDraft) - ) - ? { display } - : {}), - } - if (preload) { - this.queued.unshift(entry) - if (this.recalledQueue) this.recalledQueue.index++ - } else if (recalled) { - this.queued.splice(Math.min(recalled.index, this.queued.length), 0, entry) - } else { - this.queued.push(entry) - } - } - this.renderScreen() - } - - private finishQuestion(result: ChatTerminalQuestionResult): void { - const state = this.questionState - if (!state) return - this.questionState = null - this.draft = state.previousDraft - this.cursor = state.previousCursor - this.selectedContexts = state.previousContexts - if (result.kind === 'answer') this.commitUserLine(result.values.join(', ')) - this.renderScreen() - state.resolve(result) - } - - private finishSelect(result: ChatTerminalSelectResult): void { - const state = this.selectState - if (!state) return - this.selectState = null - this.draft = state.previousDraft - this.cursor = state.previousCursor - this.selectedContexts = state.previousContexts - this.preferredColumn = null - this.renderScreen() - state.resolve(result) - } - - private filteredSelectOptions(): ChatTerminalSelect['options'] { - const state = this.selectState - if (!state) return [] - const query = safeOneLine(this.draft).trim().toLocaleLowerCase() - if (!query) return state.menu.options - return state.menu.options.filter((option) => - `${option.label}\n${option.description ?? ''}`.toLocaleLowerCase().includes(query) - ) - } - - private selectOptionCapacity(): number { - return Math.max(0, Math.min(8, this.terminalRows() - 5)) - } - - private selectedQuestionLabels(): string[] { - const state = this.questionState - if (!state) return [] - return [...state.selected] - .sort((left, right) => left - right) - .map((index) => state.question.options[index]?.label) - .filter((label): label is string => Boolean(label)) - } - - private toggleQuestionSelection(index: number): void { - const state = this.questionState - if (!state) return - if (state.selected.has(index)) state.selected.delete(index) - else state.selected.add(index) - this.renderScreen() - } - - private isComposerEditable(): boolean { - return Boolean(this.composerVisible && !this.questionState && !this.closed && !this.ended) - } - - /** Recalls the newest deferred line without disturbing earlier FIFO entries. */ - private recallQueuedDraft(): boolean { - for (let index = this.queued.length - 1; index >= 0; index -= 1) { - const queued = this.queued[index] - if (queued.input.kind !== 'line' || queued.input.display === undefined) continue - this.queued.splice(index, 1) - this.recalledQueue = { - index, - initialDraft: queued.input.display, - ...(queued.display === undefined ? {} : { commitDisplay: queued.display }), - } - for (const [id, body] of queued.input.pastes ?? []) this.pastedText.set(id, body) - this.selectedContexts = [...(queued.input.contexts ?? [])] - this.draft = queued.input.display - this.cursor = this.draft.length - this.preferredColumn = null - this.historyIndex = this.history.length - this.renderScreen() - return true - } - return false - } - - private insertText(value: string): void { - const safe = sanitize(value).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '') - if (!safe) return - const room = MAX_DRAFT_CHARACTERS - this.draft.length - if (room <= 0) return - const inserted = safe.slice(0, room) - this.draft = `${this.draft.slice(0, this.cursor)}${inserted}${this.draft.slice(this.cursor)}` - this.cursor += inserted.length - this.preferredColumn = null - this.historyIndex = this.history.length - } - - private deleteBackward(): void { - if (this.cursor === 0) return - const previous = previousGraphemeIndex(this.draft, this.cursor) - this.draft = `${this.draft.slice(0, previous)}${this.draft.slice(this.cursor)}` - this.cursor = previous - this.preferredColumn = null - this.renderScreen() - } - - private deleteForward(): void { - if (this.cursor >= this.draft.length) return - const next = nextGraphemeIndex(this.draft, this.cursor) - this.draft = `${this.draft.slice(0, this.cursor)}${this.draft.slice(next)}` - this.preferredColumn = null - this.renderScreen() - } - - private moveVertically(direction: -1 | 1): void { - const layout = this.composerDraftLayout() - const targetRow = layout.cursor.row + direction - if (targetRow < 0 || targetRow >= layout.rows.length) { - this.navigateHistory(direction) - return - } - - const desiredColumn = this.preferredColumn ?? layout.cursor.column - this.preferredColumn = desiredColumn - const candidates = layout.points.filter((point) => point.row === targetRow) - const best = candidates.reduce<CursorPoint | null>((current, candidate) => { - if (!current) return candidate - return Math.abs(candidate.column - desiredColumn) < Math.abs(current.column - desiredColumn) - ? candidate - : current - }, null) - if (best) this.cursor = best.index - this.renderScreen() - } - - private navigateHistory(direction: -1 | 1): void { - if (this.history.length === 0) return - if (direction < 0) { - if (this.historyIndex === this.history.length) this.historyDraft = this.draft - if (this.historyIndex === 0) return - this.historyIndex -= 1 - this.draft = this.history[this.historyIndex] ?? '' - } else { - if (this.historyIndex >= this.history.length) return - this.historyIndex += 1 - this.draft = - this.historyIndex === this.history.length - ? this.historyDraft - : (this.history[this.historyIndex] ?? '') - } - this.cursor = this.draft.length - this.preferredColumn = null - this.renderScreen() - } - - private handleTranscriptNavigationKey(key: Key | undefined): boolean { - if (key?.name === 'pageup') { - this.scrollTranscript(-1) - return true - } - if (key?.name === 'pagedown') { - this.scrollTranscript(1) - return true - } - if (key?.ctrl && key.name === 'home') { - this.jumpTranscript('oldest') - return true - } - if (key?.ctrl && key.name === 'end') { - this.jumpTranscript('latest') - return true - } - return false - } - - private scrollTranscript(direction: -1 | 1): void { - const metrics = this.transcriptViewportMetrics() - if (metrics.capacity <= 0 || metrics.maxTop <= 0) { - this.transcriptScrollTopRow = null - return - } - - const page = Math.max(1, metrics.capacity - 1) - const currentTop = this.transcriptScrollTopRow ?? metrics.maxTop - const nextTop = Math.max(0, Math.min(metrics.maxTop, currentTop + direction * page)) - const nextScrollTop = nextTop >= metrics.maxTop ? null : nextTop - if (nextScrollTop === this.transcriptScrollTopRow) return - this.transcriptScrollTopRow = nextScrollTop - this.renderScreen() - } - - private jumpTranscript(destination: 'oldest' | 'latest'): void { - if (destination === 'latest') { - if (this.transcriptScrollTopRow === null) return - this.transcriptScrollTopRow = null - this.renderScreen() - return - } - - const metrics = this.transcriptViewportMetrics() - if (metrics.capacity <= 0 || metrics.maxTop <= 0 || this.transcriptScrollTopRow === 0) return - this.transcriptScrollTopRow = 0 - this.renderScreen() - } - - private transcriptViewportMetrics(): { capacity: number; maxTop: number } { - const rows = this.terminalRows() - const panel = this.buildPanel(rows) - const capacity = Math.max(0, rows - Math.min(rows, panel.lines.length)) - const totalRows = this.wrappedBody(this.panelWidth()).length - return { capacity, maxTop: Math.max(0, totalRows - capacity) } - } - - private ensureViewport(): void { - if (!this.isInteractiveTTY() || this.viewportActive || this.closed) return - const input = this.input as TerminalInput - if (input.setRawMode && !input.isRaw) input.setRawMode(true) - input.resume() - this.viewportActive = true - const rows = this.terminalRows() - const columns = this.terminalColumns() - this.output.write( - `${BEGIN_SYNCHRONIZED_OUTPUT}${ENTER_ALTERNATE_SCREEN}${ENABLE_BRACKETED_PASTE}${HIDE_CURSOR}${CLEAR_SCREEN}${ESC}[H${END_SYNCHRONIZED_OUTPUT}` - ) - this.renderedScreen = Array<string>(rows).fill('') - this.renderedColumns = columns - this.renderedRows = rows - } - - private restoreInputMode(): void { - if (this.restoredRawMode) return - this.restoredRawMode = true - const input = this.input as TerminalInput - if (input.setRawMode && input.isRaw !== this.inputWasRaw) input.setRawMode(this.inputWasRaw) - if (!this.inputWasFlowing) input.pause() - } - - private isInteractiveTTY(): boolean { - return Boolean((this.input as TerminalInput).isTTY && (this.output as TerminalOutput).isTTY) - } - - private terminalColumns(): number { - return Math.max(1, (this.output as TerminalOutput).columns ?? 80) - } - - private terminalRows(): number { - return Math.max(1, (this.output as TerminalOutput).rows ?? 24) - } - - private panelWidth(): number { - return Math.max(0, this.terminalColumns() - 1) - } - - private userPanelDraftLayout( - prompt: string, - highlights: Array<{ start: number; end: number }> = [] - ): DraftLayout { - return layoutDraft( - `${USER_MESSAGE_POINTER}${prompt}${USER_MESSAGE_TEXT}`, - this.draft, - Math.max(1, this.panelWidth() - 3), - this.cursor, - highlights, - { - continuationPrefix: CONTINUATION_PREFIX, - normalTextStyle: USER_MESSAGE_TEXT, - } - ) - } - - private composerDraftLayout(): DraftLayout { - return this.userPanelDraftLayout(this.prompt, this.liveMentionSpans()) - } - - private renderScreen(): void { - if (!this.viewportActive || this.closed) return - const rows = this.terminalRows() - const width = this.panelWidth() - const panel = this.buildPanel(rows) - const panelCapacity = Math.min(rows, panel.lines.length) - const panelFocusRow = panel.focusRow ?? panel.cursor?.row - const panelFirst = - panelFocusRow !== undefined - ? Math.max( - 0, - Math.min( - panel.centerFocus - ? panelFocusRow - Math.floor((panelCapacity - 1) / 2) - : panelFocusRow - panelCapacity + 1, - Math.max(0, panel.lines.length - panelCapacity) - ) - ) - : Math.max(0, panel.lines.length - panelCapacity) - const panelLines = panel.lines - .slice(panelFirst, panelFirst + panelCapacity) - .map((line) => layoutAnsiRows(line, width)[0] ?? '') - const panelTop = rows - panelLines.length + 1 - const transcriptCapacity = Math.max(0, panelTop - 1) - const allTranscriptRows = this.wrappedBody(width) - const maxTranscriptTop = Math.max(0, allTranscriptRows.length - transcriptCapacity) - if (this.transcriptScrollTopRow !== null) { - const clamped = Math.max(0, Math.min(this.transcriptScrollTopRow, maxTranscriptTop)) - this.transcriptScrollTopRow = clamped >= maxTranscriptTop ? null : clamped - } - const transcriptTop = this.transcriptScrollTopRow ?? maxTranscriptTop - const transcriptRows = transcriptCapacity - ? allTranscriptRows.slice(transcriptTop, transcriptTop + transcriptCapacity) - : [] - const screen = Array<string>(rows).fill('') - for (const [index, line] of transcriptRows.entries()) screen[index] = line - for (const [index, line] of panelLines.entries()) screen[panelTop + index - 1] = line - const columns = this.terminalColumns() - const fullRepaint = - !this.renderedScreen || this.renderedColumns !== columns || this.renderedRows !== rows - - let frame = `${BEGIN_SYNCHRONIZED_OUTPUT}${HIDE_CURSOR}${RESET}${RESET_SCROLL_REGION}` - if (fullRepaint) frame += CLEAR_SCREEN - for (const [index, line] of screen.entries()) { - if ((!fullRepaint && line === this.renderedScreen?.[index]) || (fullRepaint && !line)) - continue - frame += `${cursorTo(index + 1, 1)}${ESC}[2K${line}${RESET}` - } - - if (panel.cursor && this.isComposerEditable()) { - const clippedPanelCursorRow = Math.max(0, panel.cursor.row - panelFirst) - frame += `${cursorTo( - Math.min(rows, panelTop + clippedPanelCursorRow), - Math.min(columns, panel.cursor.column) - )}${SHOW_CURSOR}` - } else if (panel.cursor && this.questionState) { - const clippedPanelCursorRow = Math.max(0, panel.cursor.row - panelFirst) - frame += `${cursorTo( - Math.min(rows, panelTop + clippedPanelCursorRow), - Math.min(columns, panel.cursor.column) - )}${SHOW_CURSOR}` - } else { - frame += HIDE_CURSOR - } - frame += END_SYNCHRONIZED_OUTPUT - this.renderedScreen = screen - this.renderedColumns = columns - this.renderedRows = rows - this.output.write(frame) - } - - private buildPanel(rows: number): RenderPanel { - if (this.selectState) return this.buildSelectPanel(rows) - if (this.questionState) return this.buildQuestionPanel(rows) - if (!this.composerVisible) return { lines: [] } - - const layout = this.composerDraftLayout() - const topMargin = rows >= 13 ? [''] : [] - const maxInputRows = Math.max(1, Math.min(6, Math.floor(rows / 3))) - const firstVisible = Math.max( - 0, - Math.min(layout.cursor.row - maxInputRows + 1, layout.rows.length - maxInputRows) - ) - const visibleRows = layout.rows.slice(firstVisible, firstVisible + maxInputRows) - const queuedTurns = this.queued.filter( - ({ input }) => input.kind === 'line' && input.value.trim() - ).length - const queued = queuedTurns > 0 ? `${queuedTurns} queued · ` : '' - const footer = this.busy - ? ` ${queued}enter to steer · esc to interrupt` - : this.pending && !this.draft - ? ' ? for shortcuts' - : '' - const activityStatus = this.activityStatusLine() - const activityRows = activityStatus ? [activityStatus] : [] - const suggestionRows = this.suggestionRows(this.panelWidth(), rows) - /* Keep the suggestion menu visually separate from the activity line. The - composer's shaded top row already separates activity from input. */ - const suggestionGap = suggestionRows.length ? [''] : [] - const activityGap = activityRows.length ? [''] : [] - const composerCursor = { - row: - topMargin.length + - suggestionRows.length + - suggestionGap.length + - activityRows.length + - activityGap.length + - 1 + - layout.cursor.row - - firstVisible, - column: Math.min(this.panelWidth() + 1, layout.cursor.column + 2), - } - return { - lines: [ - ...topMargin, - ...suggestionRows, - ...suggestionGap, - ...activityRows, - ...activityGap, - userPanelRow(), - ...visibleRows.map((line) => userPanelRow(line)), - userPanelRow(), - `${DIM}${footer}${RESET}`, - ], - focusRow: composerCursor.row, - centerFocus: true, - cursor: this.isComposerEditable() ? composerCursor : undefined, - } - } - - private buildSelectPanel(rows: number): RenderPanel { - const state = this.selectState - if (!state) return { lines: [] } - - const width = this.panelWidth() - const options = this.filteredSelectOptions() - const capacity = Math.max(0, Math.min(8, rows - 5)) - state.active = Math.max(0, Math.min(state.active, options.length - 1)) - const { start, end } = suggestionWindow(options.length, state.active, capacity) - const visible = options.slice(start, end) - const labelWidth = Math.min( - Math.floor(width * 0.55), - Math.max(0, ...visible.map((option) => displayWidth(option.label))) + 3 - ) - const optionRows = visible.map((option) => { - const active = option.id === options[state.active]?.id - const pointer = active ? '❯' : ' ' - const label = truncateDisplay(option.label, Math.max(1, labelWidth - 3)) - const padding = ' '.repeat(Math.max(2, labelWidth - displayWidth(label) - 1)) - const line = truncateDisplay( - `${pointer} ${label}${padding}${option.description ?? ''}`, - width - ) - return active ? `${BRIGHT_WHITE}${line}${RESET}` : `${DIM}${line}${RESET}` - }) - if (capacity > 0 && optionRows.length === 0) { - optionRows.push(`${DIM} No matching chats${RESET}`) - } - - const layout = this.userPanelDraftLayout('Search › ') - const searchRow = - layout.rows[layout.cursor.row] ?? `${USER_MESSAGE_POINTER}Search › ${USER_MESSAGE_TEXT}` - const header = rows >= 5 ? [`${BOLD}? ${state.menu.prompt}${RESET}`] : [] - const cursor = { - row: header.length + optionRows.length + 1, - column: Math.min(width + 1, layout.cursor.column + 2), - } - return { - lines: [ - ...header, - ...optionRows, - userPanelRow(), - userPanelRow(searchRow), - userPanelRow(), - `${DIM} ↑/↓ navigate · enter open · esc cancel${RESET}`, - ], - focusRow: cursor.row, - cursor, - } - } - - private buildQuestionPanel(rows: number): RenderPanel { - const state = this.questionState - if (!state) return { lines: [] } - const otherIndex = state.question.options.length - const choices: Array<{ line: string; cursorColumn?: number }> = state.question.options.map( - (option, index) => { - const active = state.active === index - const pointer = active ? '❯' : ' ' - const marker = state.question.multi - ? `[${state.selected.has(index) ? '✓' : ' '}]` - : `${index + 1}.` - return { - line: truncateDisplay(`${pointer} ${marker} ${option.label}`, this.panelWidth()), - } - } - ) - - const otherActive = state.active === otherIndex - const otherLead = `${otherActive ? '❯' : ' '} Other › ` - const otherRoom = Math.max(1, this.panelWidth() - displayWidth(otherLead)) - const otherValue = this.draft - ? tailToWidth(this.draft.replace(/\n/gu, ' '), otherRoom) - : `${DIM}Type something…${RESET}` - choices.push({ - line: `${otherLead}${otherValue}`, - cursorColumn: otherActive - ? Math.min( - this.panelWidth() + 1, - this.draft ? displayWidth(`${otherLead}${otherValue}`) + 1 : displayWidth(otherLead) + 1 - ) - : undefined, - }) - if (state.question.multi) { - choices.push({ - line: `${state.active === otherIndex + 1 ? '❯' : ' '} ${DIM}Submit answers${RESET}`, - }) - } - - const maxChoices = Math.max(1, Math.min(choices.length, Math.floor(rows / 2))) - const firstVisible = Math.max( - 0, - Math.min(state.active - maxChoices + 1, choices.length - maxChoices) - ) - const visibleChoices = choices.slice(firstVisible, firstVisible + maxChoices) - const footer = state.question.multi - ? '↑/↓ navigate · Space select · Enter submit · Esc cancel' - : '↑/↓ navigate · Enter select · Esc cancel' - const lines = [ - `${ESC}[1m${truncateDisplay(`? ${state.question.prompt}`, this.panelWidth())}${RESET}`, - ...visibleChoices.map((choice) => choice.line), - `${DIM}${truncateDisplay(footer, this.panelWidth())}${RESET}`, - ] - const activeChoice = choices[state.active] - const focusRow = 1 + state.active - firstVisible - return { - lines, - focusRow, - cursor: - activeChoice?.cursorColumn && state.active >= firstVisible - ? { row: focusRow, column: activeChoice.cursorColumn } - : undefined, - } - } - - private appendTranscript(value: string): void { - this.transcript += value - if (this.transcript.length <= MAX_TRANSCRIPT_CHARACTERS) return - - const preferredCut = this.transcript.length - MAX_TRANSCRIPT_CHARACTERS - const nextLine = this.transcript.indexOf('\n', preferredCut) - if (nextLine >= 0) { - this.transcriptEpoch += 1 - this.transcript = this.transcript.slice(nextLine + 1) - return - } - if (this.transcript.length > MAX_TRANSCRIPT_CHARACTERS * 2) { - this.transcriptEpoch += 1 - this.transcript = `…${sanitize(this.transcript.slice(-MAX_TRANSCRIPT_CHARACTERS))}` - } - } - - /** - * Wrapped rows for the whole viewport body, reusing the rows already computed - * for the immutable part of the transcript. - * - * Everything up to the transcript's last newline can never change, so it is - * wrapped once and kept; only the partial final line is re-wrapped per token. - * That turns an O(transcript) cost per streamed chunk into O(one line). - */ - private wrappedBody(width: number): string[] { - const welcome = this.welcomeVisible ? this.renderWelcome() : '' - const activity = this.activityEventsDisplay() - const text = this.transcript - const boundary = text.lastIndexOf('\n') + 1 - - let cache = this.wrapCache - if ( - !cache || - cache.width !== width || - cache.epoch !== this.transcriptEpoch || - cache.consumed > boundary - ) { - cache = { - width, - epoch: this.transcriptEpoch, - consumed: 0, - rows: [], - state: { sgr: '', userBackground: false }, - } - } - if (cache.consumed < boundary) { - const state: WrapState = { ...cache.state } - const added = layoutAnsiRows(text.slice(cache.consumed, boundary), width, state) - cache = { - width, - epoch: this.transcriptEpoch, - consumed: boundary, - rows: cache.rows.concat(added), - state, - } - } - this.wrapCache = cache - - /* The welcome block always ends with a reset and a blank line, so it cannot - leak style into the transcript and is wrapped independently. */ - const rows = welcome ? layoutAnsiRows(welcome, width) : [] - rows.push(...cache.rows) - const tail = text.slice(cache.consumed) - if (tail) rows.push(...layoutAnsiRows(tail, width, { ...cache.state })) - if (activity) rows.push(...layoutAnsiRows(activity, width)) - return rows - } - - private commitUserLine(value: string): void { - if (!this.isInteractiveTTY()) return - this.transcriptScrollTopRow = null - this.assistantPrefixPending = true - this.assistantPrefixBuffer = '' - this.assistantTurnActive = false - this.assistantContinuationPending = false - if (this.transcript && !this.transcript.endsWith('\n')) this.appendTranscript('\n') - if (this.transcript && !this.transcript.endsWith('\n\n')) this.appendTranscript('\n') - - this.appendTranscript(`${userPanelRow()}\n`) - const lines = sanitize(value).replace(/\t/gu, CONTINUATION_PREFIX).split('\n') - for (const [index, line] of lines.entries()) { - const pointer = - index === 0 - ? `${USER_MESSAGE_POINTER}❯ ${USER_MESSAGE_TEXT}` - : `${USER_MESSAGE_TEXT}${CONTINUATION_PREFIX}` - this.appendTranscript(`${userPanelRow(`${pointer}${line}`)}\n`) - } - this.appendTranscript(`${userPanelRow()}\n`) - this.appendTranscript('\n') - } - - private renderWelcome(): string { - const width = this.panelWidth() - const chat = `chat ${this.welcomeChatTitle}` - const artWidth = Math.max(...BLIMP_ART.map(displayWidth)) - const progress = this.welcomeRevealFrame / WELCOME_FLY_IN_FRAMES - const eased = progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2 - const trailing = Math.max(0, artWidth - Math.round(artWidth * eased)) - const art = BLIMP_ART.map((line) => `${line}${artPad(line, artWidth)}`.slice(trailing)) - const lead = ' '.repeat(trailing) - - const boxColumns = width - artWidth - WELCOME_GUTTER - if (boxColumns >= WELCOME_MIN_BOX_COLUMNS) { - const rows = this.welcomeDetailBox(boxColumns) - const gutter = ' '.repeat(WELCOME_GUTTER) - const lines: string[] = [] - /* Centre the shorter column against the taller one. Top-aligning leaves - the airship and the box visibly out of register whenever they differ - in height, which they usually do. */ - const height = Math.max(art.length, rows.length) - const artTop = Math.round((height - art.length) / 2) - const boxTop = Math.round((height - rows.length) / 2) - for (let index = 0; index < height; index++) { - const line = art[index - artTop] - const column = - line === undefined ? ' '.repeat(artWidth) : `${BRIGHT_WHITE}${line}${RESET}${lead}` - lines.push(`${column}${gutter}${rows[index - boxTop] ?? ''}`.trimEnd()) - } - return `${lines.join('\n')}\n\n` - } - - if (width >= artWidth) { - const title = `${BOLD}${truncateDisplay('Sim Chat', width)}${RESET}` - const scope = `${DIM}${truncateDisplay(chat, width)}${RESET}` - const rendered = art - .map((line) => `${BRIGHT_WHITE}${truncateDisplay(line.trimEnd(), width)}${RESET}`) - .join('\n') - return `${rendered}\n${title}\n${scope}\n\n` - } - - return `${BOLD}${truncateDisplay('Sim Chat', width)}${RESET}\n${DIM}${truncateDisplay( - chat, - width - )}${RESET}\n\n` - } - - noteAttachment(kind: ChatAttachmentKind = 'Image'): void { - const number = this.nextAttachmentNumber.get(kind) ?? 1 - this.nextAttachmentNumber.set(kind, number + 1) - const token = `[${kind} #${number}]` - const before = this.draft.slice(0, this.cursor) - const separator = !before || /\s$/u.test(before) ? '' : ' ' - this.insertText(`${separator}${token} `) - this.renderScreen() - } - - setWorkspaceName(name: string): void { - const next = safeOneLine(name).slice(0, 80) - if (!next || next === this.welcomeWorkspaceName) return - this.welcomeWorkspaceName = next - if (!this.closed) this.renderScreen() - } - - setChatTitle(title: string): void { - const next = safeOneLine(title).slice(0, 160) - if (!next || next === this.welcomeChatTitle) return - this.welcomeChatTitle = next - if (this.welcomeVisible && !this.closed) this.renderScreen() - } - - /** Rounded detail box drawn to the right of the art, with aligned labels. */ - private welcomeDetailBox(columns: number): string[] { - const details: Array<[string, string]> = [ - ['profile', this.welcomeProfile ?? 'default'], - ...(this.welcomeWorkspaceName - ? ([['workspace', this.welcomeWorkspaceName]] as Array<[string, string]>) - : []), - ['chat', this.welcomeChatTitle], - ] - const labelWidth = Math.max(...details.map(([label]) => label.length)) + 2 - const content = [ - { text: 'Sim Chat', style: BOLD }, - ...details.map(([label, value]) => ({ - text: `${`${label}:`.padEnd(labelWidth)}${value}`, - style: DIM, - })), - ] - const widest = Math.max(...content.map((entry) => displayWidth(entry.text))) - const inner = Math.max(1, Math.min(columns - 4, widest)) - const rule = '\u2500'.repeat(inner + 2) - const rows = [`${DIM}\u256d${rule}\u256e${RESET}`] - for (const { text, style } of content) { - const clipped = truncateDisplay(text, inner) - const padding = ' '.repeat(Math.max(0, inner - displayWidth(clipped))) - const painted = style ? `${style}${clipped}${RESET}` : clipped - rows.push(`${DIM}\u2502${RESET} ${painted}${padding} ${DIM}\u2502${RESET}`) - } - rows.push(`${DIM}\u2570${rule}\u256f${RESET}`) - return rows - } - - /** - * Slides the airship in from the left edge, repainting on a timer. Skipped for - * non-interactive output and under CI/test runners, where a partially drawn - * frame would make the header nondeterministic. - */ - private startWelcomeFlyIn(): void { - this.stopWelcomeFlyIn() - if (!this.isInteractiveTTY()) return - if (process.env.CI || process.env.VITEST) return - this.welcomeRevealFrame = 0 - this.welcomeTimer = setInterval(() => { - this.welcomeRevealFrame += 1 - if (this.welcomeRevealFrame >= WELCOME_FLY_IN_FRAMES) this.stopWelcomeFlyIn() - this.renderScreen() - }, WELCOME_FLY_IN_INTERVAL_MS) - this.welcomeTimer.unref() - } - - private stopWelcomeFlyIn(): void { - if (this.welcomeTimer) clearInterval(this.welcomeTimer) - this.welcomeTimer = null - this.welcomeRevealFrame = WELCOME_FLY_IN_FRAMES - } - - private activityEventsDisplay(): string { - if (!this.activityActive) return '' - const lines: string[] = [] - for (const id of this.activityRoots) { - if (this.committedActivityRoots.has(id)) continue - const node = this.activityNodes.get(id) - if (node) lines.push(...this.activityNodeLines(node, true)) - } - return lines.join('\n') - } - - private activityStatusLine(): string { - if (!this.activityActive) return '' - const pulseFrames = ['·', '•', '●', '•'] - const pulse = pulseFrames[this.activityFrame % pulseFrames.length] - const label = tailToWidth( - safeOneLine(this.activityThinking) || 'Thinking…', - Math.max(1, this.panelWidth() - 2) - ) - return `${DIM}${ESC}[3m${pulse} ${label}${RESET}` - } - - private activityEventLine(event: ChatActivityStatusUpdate, live: boolean, depth = 0): string { - const icon = - event.state === 'complete' - ? `${ESC}[32m●${RESET}` - : event.state === 'error' - ? `${ESC}[31m●${RESET}` - : `${DIM}●${RESET}` - const indent = CONTINUATION_PREFIX.repeat(depth) - const label = live - ? truncateDisplay(event.label, Math.max(1, this.panelWidth() - displayWidth(indent) - 8)) - : event.label - // A subagent's public label is its stable lane header. Its dot carries the - // state, while tool labels may use the familiar live/error suffixes. - const suffix = event.kind === 'tool' && live && event.state === 'running' ? '…' : '' - const failed = event.kind === 'tool' && event.state === 'error' ? ` ${DIM}failed${RESET}` : '' - return `${indent}${icon} ${label}${suffix}${failed}` - } - - private recordActivityEvent(update: ChatActivityUpdate): void { - if (update.kind === 'narration') { - const parentId = safeOneLine(update.parentId).slice(0, 160) - const parent = this.activityNodes.get(parentId) - if (!parent || parent.kind !== 'subagent') return - const delta = update.delta.replace(/\r/gu, '') - if (!delta) return - const last = parent.children[parent.children.length - 1] - if (last?.kind === 'narration') last.content += delta - else parent.children.push({ kind: 'narration', content: delta }) - return - } - - const id = safeOneLine(update.id).slice(0, 160) - const label = safeOneLine(update.label).slice(0, 160) - if (!id || !label) return - const parentId = update.parentId ? safeOneLine(update.parentId).slice(0, 160) : undefined - const safeParentId = parentId && parentId !== id ? parentId : undefined - const existing = this.activityNodes.get(id) - const node: ActivityTreeNode = { - kind: update.kind, - id, - label, - state: update.state, - ...(safeParentId ? { parentId: safeParentId } : {}), - children: existing?.children ?? [], - } - this.activityNodes.set(id, node) - if (!existing || existing.parentId !== node.parentId) this.attachActivityNode(node) - - if (node.kind === 'subagent') { - for (const child of this.activityNodes.values()) { - if (child.parentId === node.id) this.attachActivityNode(child) - } - } - } - - private commitActivityEvents(includeRunning: boolean): void { - if (!this.isInteractiveTTY()) return - for (const id of this.activityRoots) { - if (this.committedActivityRoots.has(id)) continue - const node = this.activityNodes.get(id) - if (!node || (!includeRunning && !this.activityNodeSettled(node))) continue - this.commitActivityRoot(node) - } - } - - private commitActivityRoot(node: ActivityTreeNode): void { - if (!this.isInteractiveTTY() || this.committedActivityRoots.has(node.id)) return - this.committedActivityRoots.add(node.id) - const lines = this.activityNodeLines(node, false) - if (lines.length === 0) return - if (this.transcript && !this.transcript.endsWith('\n')) this.appendTranscript('\n') - this.appendTranscript(`${lines.join('\n')}\n`) - } - - private attachActivityNode(node: ActivityTreeNode): void { - const rootIndex = this.activityRoots.indexOf(node.id) - if (rootIndex >= 0) this.activityRoots.splice(rootIndex, 1) - for (const candidate of this.activityNodes.values()) { - if (candidate.kind !== 'subagent') continue - candidate.children = candidate.children.filter( - (child) => child.kind !== 'node' || child.id !== node.id - ) - } - - if (node.parentId) { - const parent = this.activityNodes.get(node.parentId) - if (parent?.kind === 'subagent') parent.children.push({ kind: 'node', id: node.id }) - return - } - this.activityRoots.push(node.id) - } - - private activityNodeSettled(node: ActivityTreeNode, seen = new Set<string>()): boolean { - if (node.state === 'running' || seen.has(node.id)) return false - seen.add(node.id) - for (const child of node.children) { - if (child.kind !== 'node') continue - const nested = this.activityNodes.get(child.id) - if (nested && !this.activityNodeSettled(nested, seen)) return false - } - return true - } - - private activityNodeLines( - node: ActivityTreeNode, - live: boolean, - depth = 0, - seen = new Set<string>() - ): string[] { - if (seen.has(node.id)) return [] - seen.add(node.id) - - const children: string[] = [] - for (const child of node.children) { - if (child.kind === 'node') { - const nested = this.activityNodes.get(child.id) - if (nested) children.push(...this.activityNodeLines(nested, live, depth + 1, seen)) - continue - } - if (!child.content.trim()) continue - const indent = CONTINUATION_PREFIX.repeat(depth + 1) - // Only trim to decide whether the lane has visible work. The original - // text (including leading/trailing blank lines) is the ordered stream. - for (const line of child.content.split('\n')) { - children.push(`${indent}${DIM}${line}${RESET}`) - } - } - - // Match the web lane projection: a closed lane with no visible work leaves - // no orphan header, while an open empty lane still explains what is running. - if (node.kind === 'subagent' && node.state !== 'running' && children.length === 0) return [] - return [this.activityEventLine(node, live, depth), ...children] - } - - private stopActivity(completed = false): void { - if (this.activityTimer) clearInterval(this.activityTimer) - this.activityTimer = null - if (this.activityActive) { - this.commitActivityEvents(true) - if (completed) { - if (this.transcript && !this.transcript.endsWith('\n')) this.appendTranscript('\n') - if (this.transcript && !this.transcript.endsWith('\n\n')) this.appendTranscript('\n') - this.appendTranscript( - `${DIM}✻ Worked for ${formatActivityDuration(Date.now() - this.activityStartedAt)}${RESET}\n` - ) - } - } - this.activityActive = false - this.activityThinking = '' - this.activityStartedAt = 0 - this.activityNodes.clear() - this.activityRoots.length = 0 - this.committedActivityRoots.clear() - this.assistantTurnActive = false - this.assistantContinuationPending = false - this.busy = false - this.renderScreen() - } -} - -function prefixAssistantTurn(value: string): string | null { - let offset = 0 - let leadingSgr = '' - while (offset < value.length) { - if (value[offset] === ESC) { - const sgr = value.slice(offset).match(/^\u001b\[[0-9;:]*m/u)?.[0] - if (sgr) { - leadingSgr += sgr - offset += sgr.length - continue - } - } - - const part = firstGrapheme(value.slice(offset)) - if (!part) break - if (!/^\p{White_Space}+$/u.test(part)) { - return `${ASSISTANT_TURN_PREFIX}${indentAssistantFragment( - `${leadingSgr}${value.slice(offset)}`, - false - )}` - } - offset += part.length - } - return null -} - -/** Materializes the assistant gutter on explicit line breaks across streamed chunks. */ -function indentAssistantFragment(value: string, continuationPending: boolean): string { - const prefixed = continuationPending ? `${CONTINUATION_PREFIX}${value}` : value - return prefixed.replace(/\n(?=.)/gu, `\n${CONTINUATION_PREFIX}`) -} - -interface WrapState { - sgr: string - userBackground: boolean -} - -/** - * `carry` resumes the state a previous call ended in, and receives the state - * this call ends in — the two things that survive a row break. Threading them - * explicitly is what makes it safe to wrap a transcript in pieces. - */ -function layoutAnsiRows(value: string, width: number, carry?: WrapState): string[] { - if (!value || width <= 0) return [] - - type LayoutToken = - | { kind: 'sgr'; value: string } - | { kind: 'grapheme'; value: string; width: number } - - const rows: string[] = [] - /* Resumed styling must reopen on the first row, exactly as finishRow() - reopens it on every subsequent row. */ - let row = carry?.userBackground ? `${USER_PANEL_OUTER_MARGIN}${carry.sgr}` : (carry?.sgr ?? '') - let column = carry?.userBackground ? displayWidth(USER_PANEL_OUTER_MARGIN) : 0 - let activeSgr = carry?.sgr ?? '' - let userBackgroundActive = carry?.userBackground ?? false - let hangingIndent = 0 - let logicalLinePrefix = '' - let logicalLinePrefixRejected = false - let pendingWord: LayoutToken[] = [] - let pendingWordWidth = 0 - - const contentWidth = (): number => (userBackgroundActive && width > 2 ? width - 2 : width) - - const fillUserMessageRow = (): void => { - if (!userBackgroundActive) return - const target = width > 1 ? width - 1 : width - if (column >= target) return - row += ' '.repeat(target - column) - column = target - } - - const finishRow = (continueLogicalLine = true): void => { - fillUserMessageRow() - rows.push(row) - const outerMargin = userBackgroundActive ? displayWidth(USER_PANEL_OUTER_MARGIN) : 0 - const continuationIndent = continueLogicalLine - ? Math.min(hangingIndent, Math.max(0, contentWidth() - 1)) - : 0 - if (continuationIndent > 0) { - const indent = ' '.repeat(Math.max(0, continuationIndent - outerMargin)) - row = userBackgroundActive - ? `${USER_PANEL_OUTER_MARGIN}${activeSgr}${indent}` - : `${indent}${activeSgr}` - } else { - row = userBackgroundActive ? `${USER_PANEL_OUTER_MARGIN}${activeSgr}` : activeSgr - } - column = Math.max(continuationIndent, outerMargin) - if (!continueLogicalLine) { - hangingIndent = 0 - logicalLinePrefix = '' - logicalLinePrefixRejected = false - } - } - - const observeLogicalLinePrefix = (segment: string, segmentWidth: number): void => { - if (logicalLinePrefixRejected) return - if (segmentWidth !== 1) { - logicalLinePrefixRejected = true - return - } - - // Activity trees can be nested more deeply than the assistant's two-column - // gutter. Preserve every explicit leading space on soft wraps so a nested - // tool or narration row never jumps back toward its parent. - if (segment === ' ' && /^ *$/u.test(logicalLinePrefix)) { - logicalLinePrefix += segment - hangingIndent = displayWidth(logicalLinePrefix) - return - } - - const candidate = `${logicalLinePrefix}${segment}` - const knownPrefix = [ASSISTANT_TURN_PREFIX, USER_TURN_PREFIX].find((prefix) => - prefix.startsWith(candidate) - ) - if (knownPrefix) { - logicalLinePrefix = candidate - if (candidate === knownPrefix) hangingIndent = displayWidth(knownPrefix) - return - } - logicalLinePrefixRejected = true - } - - const appendVisible = (segment: string): void => { - if (segment === '\t') { - const spaces = Math.max(1, 8 - (column % 8)) - for (let index = 0; index < spaces; index += 1) appendVisible(' ') - return - } - if (/[\u0000-\u001f\u007f-\u009f]/u.test(segment)) return - - const segmentWidth = graphemeWidth(segment) - observeLogicalLinePrefix(segment, segmentWidth) - const availableWidth = contentWidth() - if (segmentWidth > availableWidth) { - if (column > 0) finishRow() - row += '…' - column = 1 - return - } - if (column > 0 && column + segmentWidth > availableWidth) finishRow() - row += segment - column += segmentWidth - } - - const appendToken = (token: LayoutToken): void => { - if (token.kind === 'sgr') { - if (userBackgroundActive && token.value === RESET) fillUserMessageRow() - row += token.value - activeSgr = updateActiveSgr(activeSgr, token.value) - if (token.value === USER_MESSAGE_BACKGROUND) userBackgroundActive = true - else if (token.value === RESET) userBackgroundActive = false - return - } - appendVisible(token.value) - } - - const flushWord = (): void => { - if (pendingWord.length === 0) return - - const availableWidth = contentWidth() - const outerMargin = userBackgroundActive ? displayWidth(USER_PANEL_OUTER_MARGIN) : 0 - const continuationIndent = Math.min(hangingIndent, Math.max(0, availableWidth - 1)) - const freshColumn = Math.max(continuationIndent, outerMargin) - - /** - * Matches Ink's default wrap behavior: ordinary words move intact when they fit on a fresh - * row, while overlong tokens hard-wrap through the remaining space. Styling tokens flush with - * their word so absolute continuation rows can safely reopen the active SGR state. - */ - if ( - pendingWordWidth > 0 && - freshColumn + pendingWordWidth <= availableWidth && - column > freshColumn && - column + pendingWordWidth > availableWidth - ) { - finishRow() - } - - for (const token of pendingWord) appendToken(token) - pendingWord = [] - pendingWordWidth = 0 - } - - const bufferWordToken = (token: LayoutToken): void => { - pendingWord.push(token) - if (token.kind === 'grapheme') pendingWordWidth += token.width - } - - let offset = 0 - while (offset < value.length) { - if (value[offset] === ESC) { - const match = value.slice(offset).match(/^\u001b\[[0-9;:]*m/u) - if (match) { - const sequence = match[0] - bufferWordToken({ kind: 'sgr', value: sequence }) - offset += sequence.length - continue - } - offset += 1 - continue - } - if (value[offset] === '\n') { - flushWord() - finishRow(false) - offset += 1 - continue - } - - const nextControl = [value.indexOf(ESC, offset), value.indexOf('\n', offset)] - .filter((index) => index >= 0) - .reduce((closest, index) => Math.min(closest, index), value.length) - const text = value.slice(offset, nextControl) - for (const part of graphemes(text)) { - const breakableWhitespace = - part.segment !== '\u00a0' && - part.segment !== '\u202f' && - /^\p{White_Space}+$/u.test(part.segment) - if (breakableWhitespace) { - flushWord() - appendVisible(part.segment) - } else { - bufferWordToken({ - kind: 'grapheme', - value: part.segment, - width: graphemeWidth(part.segment), - }) - } - } - offset = nextControl - } - - flushWord() - fillUserMessageRow() - rows.push(row) - if (value.endsWith('\n')) rows.pop() - if (carry) { - carry.sgr = activeSgr - carry.userBackground = userBackgroundActive - } - return rows -} - -function updateActiveSgr(active: string, sequence: string): string { - const rawParameters = sequence.slice(2, -1) - const parameters = rawParameters ? rawParameters.split(';') : ['0'] - let lastReset = -1 - for (let index = 0; index < parameters.length; index += 1) { - const parameter = parameters[index] ?? '' - const code = Number(parameter.split(':', 1)[0]) - if (code === 0) lastReset = index - if ((code === 38 || code === 48 || code === 58) && !parameter.includes(':')) { - const mode = Number(parameters[index + 1]) - if (mode === 2) index += 4 - else if (mode === 5) index += 2 - } - } - if (lastReset < 0) return `${active}${sequence}` - - const remaining = parameters.slice(lastReset + 1) - return remaining.length > 0 ? `${ESC}[${remaining.join(';')}m` : '' -} - -function cursorTo(row: number, column: number): string { - return `${ESC}[${Math.max(1, row)};${Math.max(1, column)}H` -} - -/** - * Spans of `[Image #N]` and `[File #N]` tags, so an attachment reads as a tag - * rather than loose text. Derived per render like context spans, so deleting the - * tag stops the highlight with no bookkeeping. - */ -const ATTACHMENT_TOKEN = /\[(?:Image|File) #\d+\]/gu - -function attachmentSpans(text: string): Array<{ start: number; end: number }> { - return [...text.matchAll(ATTACHMENT_TOKEN)].map((match) => ({ - start: match.index ?? 0, - end: (match.index ?? 0) + match[0].length, - })) -} - -function isEnter(key: Key | undefined): boolean { - return key?.name === 'return' || key?.name === 'enter' -} - -function printableText(character: string, key: Key | undefined): string { - if (!character || key?.ctrl || key?.meta) return '' - if (key?.name === 'return' || key?.name === 'enter' || key?.name === 'tab') return '' - return sanitize(character).replace(/[\u0000-\u001f\u007f]/gu, '') -} - -/** Normalizes server-provided menu text before it can enter the terminal draft or renderer. */ -function sanitizeSuggestionItem(item: SuggestionItem): SuggestionItem | null { - const value = safeOneLine(item.value).slice(0, 255) - const displayText = safeOneLine(item.displayText).slice(0, 255) - if (!value || !displayText) return null - - const description = item.description ? safeOneLine(item.description).slice(0, 500) : undefined - const sanitized = { - ...item, - value, - displayText, - ...(description ? { description } : {}), - } - if (!item.context) return sanitized - - const contextLabel = safeOneLine(item.context.label).slice(0, 255) - if (!contextLabel) return null - return { ...sanitized, context: { ...item.context, label: contextLabel } } -} - -function layoutDraft( - prompt: string, - draft: string, - width: number, - cursor: number, - highlights: Array<{ start: number; end: number }> = [], - options: DraftLayoutOptions = {} -): DraftLayout { - const continuationPrefix = options.continuationPrefix ?? CONTINUATION_PREFIX - const normalTextStyle = options.normalTextStyle ?? RESET - const rows = [prompt] - const points: CursorPoint[] = [{ index: 0, row: 0, column: displayWidth(prompt) }] - let row = 0 - let column = displayWidth(prompt) - let styled = false - - const setPoint = (index: number): void => { - const previous = points.at(-1) - if (previous?.index === index) { - previous.row = row - previous.column = column - } else { - points.push({ index, row, column }) - } - } - - for (const part of graphemes(draft)) { - setPoint(part.index) - const end = part.index + part.segment.length - if (part.segment === '\n') { - if (styled) rows[row] += normalTextStyle - row += 1 - column = displayWidth(continuationPrefix) - rows.push( - styled - ? `${continuationPrefix}${MENTION_TEXT}` - : `${continuationPrefix}${options.normalTextStyle ?? ''}` - ) - setPoint(end) - continue - } - - const segmentWidth = displayWidth(part.segment) - if (column + segmentWidth > width && column > displayWidth(continuationPrefix)) { - if (styled) rows[row] += normalTextStyle - row += 1 - column = displayWidth(continuationPrefix) - rows.push( - styled - ? `${continuationPrefix}${MENTION_TEXT}` - : `${continuationPrefix}${options.normalTextStyle ?? ''}` - ) - setPoint(part.index) - } - /* ANSI has zero display width, so styling here cannot disturb the wrap or - cursor arithmetic above. Runs are coalesced rather than wrapping every - grapheme, and closed/reopened around a row break so no style leaks. */ - const lit = highlights.some((span) => part.index >= span.start && part.index < span.end) - if (lit && !styled) { - rows[row] += MENTION_TEXT - styled = true - } else if (!lit && styled) { - rows[row] += normalTextStyle - styled = false - } - rows[row] += part.segment - column += segmentWidth - setPoint(end) - } - - if (styled) rows[row] += normalTextStyle - - const fallback = points.at(-1) ?? { index: 0, row: 0, column: displayWidth(prompt) } - const cursorPoint = points.find((point) => point.index === cursor) ?? fallback - return { rows, points, cursor: cursorPoint } -} diff --git a/packages/sim-cli/src/commands/protocol/chat-wrap.test.ts b/packages/sim-cli/src/commands/protocol/chat-wrap.test.ts deleted file mode 100644 index 92de4c1af5d..00000000000 --- a/packages/sim-cli/src/commands/protocol/chat-wrap.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { PassThrough } from 'node:stream' -import { describe, expect, it } from 'vitest' -import { ReadlineChatTerminal } from './chat-terminal.js' - -const ESC = String.fromCharCode(27) - -function harness(columns = 60) { - const input = new PassThrough() as PassThrough & { isTTY: boolean; setRawMode?: () => void } - const output = new PassThrough() as PassThrough & { - isTTY: boolean - columns: number - rows: number - } - input.isTTY = true - input.setRawMode = () => {} - output.isTTY = true - output.columns = columns - output.rows = 30 - output.on('data', () => {}) - const terminal = new ReadlineChatTerminal(input as never, output as never) - return { - terminal, - probe: terminal as never as { - wrappedBody(width: number): string[] - panelWidth(): number - wrapCache: unknown - }, - } -} - -/** - * The cached path must be byte-identical to wrapping the concatenated body in - * one pass — otherwise a streamed frame would differ from a repainted one. - */ -describe('incremental transcript wrapping', () => { - const cases: Array<[string, string[]]> = [ - ['plain lines', ['hello world\n', 'second line\n']], - ['partial final line', ['complete\n', 'partial without newline']], - ['long wrapping line', [`${'word '.repeat(40)}\n`]], - ['styled text', [`${ESC}[1mbold${ESC}[0m plain\n`, `${ESC}[31mred\n`, `still red${ESC}[0m\n`]], - ['blank lines', ['a\n', '\n', '\n', 'b\n']], - ['token by token', ['no newline yet', ' more', ' and more', '\n', 'next\n']], - ['unicode', ['héllo wörld ☃\n', '日本語のテキスト\n']], - ] - - for (const [name, chunks] of cases) { - it(`matches a single-pass wrap: ${name}`, () => { - const { terminal, probe } = harness() - const oneShot = harness() - for (const chunk of chunks) { - terminal.write(chunk) - oneShot.terminal.write(chunk) - oneShot.probe.wrapCache = null - const width = probe.panelWidth() - expect(probe.wrappedBody(width)).toEqual(oneShot.probe.wrappedBody(width)) - } - terminal.close() - oneShot.terminal.close() - }) - } - - it('rebuilds when the width changes', () => { - const { terminal, probe } = harness() - terminal.write(`${'alpha beta '.repeat(20)}\n`) - const narrow = probe.wrappedBody(40) - const wide = probe.wrappedBody(100) - expect(narrow).not.toEqual(wide) - expect(probe.wrappedBody(40)).toEqual(narrow) - terminal.close() - }) - - it('stays correct after the transcript is trimmed from the front', () => { - const { terminal, probe } = harness() - for (let i = 0; i < 400; i++) terminal.write(`line ${i} ${'x'.repeat(200)}\n`) - const width = probe.panelWidth() - const cached = probe.wrappedBody(width) - probe.wrapCache = null - expect(cached).toEqual(probe.wrappedBody(width)) - terminal.close() - }) -}) diff --git a/packages/sim-cli/src/commands/protocol/chat.test.ts b/packages/sim-cli/src/commands/protocol/chat.test.ts deleted file mode 100644 index 3524973ace1..00000000000 --- a/packages/sim-cli/src/commands/protocol/chat.test.ts +++ /dev/null @@ -1,3192 +0,0 @@ -import { Command } from 'commander' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SimApiError } from '../../http/client.js' -import { - type ChatDependencies, - chatCommand, - composeChatPrompt, - readChatResponse, - readChatTurn, -} from './chat.js' -import type { ChatAttachment } from './chat-attachments.js' -import type { ChatContext, ChatSuggestionCandidates } from './chat-suggestions.js' -import type { - ChatActivity, - ChatActivityUpdate, - ChatTerminal, - ChatTerminalInput, - ChatTerminalInterruptListener, - ChatTerminalInterruptReason, - ChatTerminalQuestion, - ChatTerminalQuestionResult, - ChatTerminalSelect, - ChatTerminalSelectResult, - ChatTerminalWelcome, -} from './chat-terminal.js' -import { attachProtocolCommands } from './index.js' - -const mocks = vi.hoisted(() => ({ - output: 'table' as 'table' | 'text' | 'json' | 'yaml', - request: vi.fn(), - requestRaw: vi.fn(), - requireWorkspace: vi.fn(() => 'ws_local'), - selectedProfile: vi.fn(), -})) - -vi.mock('../../context.js', () => ({ - clientFrom: (command: Command) => { - mocks.selectedProfile(command.optsWithGlobals().profile) - return { - client: mocks, - profile: { endpoint: 'https://sim.example', name: 'default', output: mocks.output }, - } - }, -})) - -beforeEach(() => { - mocks.request.mockReset().mockResolvedValue({ data: [], nextCursor: null }) - mocks.requestRaw.mockReset() - mocks.requireWorkspace.mockClear() - mocks.selectedProfile.mockReset() - mocks.output = 'table' -}) - -function sse(chunks: string[]): Response { - const body = new ReadableStream<Uint8Array>({ - start(controller) { - for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)) - controller.close() - }, - }) - return new Response(body, { headers: { 'content-type': 'text/event-stream' } }) -} - -function completed( - content: string, - token = 'continuation-1', - deltas: string[] = [], - chatId: string | null = null -): Response { - return sse([ - `event: session\ndata: ${JSON.stringify({ - type: 'session', - continuationToken: token, - requestId: 'req_1', - ...(chatId ? { chatId } : {}), - })}\n\n`, - ...deltas.map((delta) => `event: text\ndata: ${JSON.stringify({ type: 'text', delta })}\n\n`), - `event: complete\ndata: ${JSON.stringify({ - type: 'complete', - data: { content, continuationToken: token }, - })}\n\n`, - 'data: [DONE]\n\n', - ]) -} - -function openSse(chunk: string): { response: Response; cancel: ReturnType<typeof vi.fn> } { - const cancel = vi.fn() - const body = new ReadableStream<Uint8Array>({ - start(controller) { - controller.enqueue(new TextEncoder().encode(chunk)) - }, - cancel, - }) - return { - response: new Response(body, { headers: { 'content-type': 'text/event-stream' } }), - cancel, - } -} - -function program( - readInput: () => Promise<string>, - writeOutput = vi.fn(), - overrides: Partial<ChatDependencies> = {} -): Command { - const root = new Command('sim') - root.option('-p, --profile <name>') - root.addCommand( - chatCommand({ - readInput, - writeOutput, - isInteractive: () => false, - ...overrides, - }) - ) - const overrideExit = (command: Command) => { - command.exitOverride() - command.commands.forEach(overrideExit) - } - overrideExit(root) - return root -} - -class FakeTerminal implements ChatTerminal { - readonly welcomes: string[] = [] - readonly workspaceNames: string[] = [] - attachmentNotes = 0 - readonly chatTitles: string[] = [] - readonly userMessages: string[] = [] - readonly statuses: string[] = [] - readonly thinking: string[] = [] - readonly activities: ChatActivityUpdate[] = [] - readonly questions: ChatTerminalQuestion[] = [] - readonly selections: ChatTerminalSelect[] = [] - readonly reads: Array<{ prompt: string; initialValue: string }> = [] - readonly preloads: Array<{ - value: string - queued: boolean - pastes?: ReadonlyMap<number, string> - contexts?: ChatContext[] - }> = [] - readonly writes: string[] = [] - readonly suggestionUpdates: ChatSuggestionCandidates[] = [] - suggestionCandidates: ChatSuggestionCandidates | null = null - clearedTranscripts = 0 - readonly listeners = new Set<ChatTerminalInterruptListener>() - closed = false - private stagedPreload = '' - - constructor( - readonly inputs: ChatTerminalInput[], - readonly questionResults: ChatTerminalQuestionResult[] = [], - readonly selectionResults: ChatTerminalSelectResult[] = [] - ) {} - - welcome({ chatTitle }: ChatTerminalWelcome): void { - this.welcomes.push(chatTitle) - } - - setChatTitle(title: string): void { - this.chatTitles.push(title) - } - - setWorkspaceName(name: string): void { - this.workspaceNames.push(name) - } - - noteAttachment(): void { - this.attachmentNotes += 1 - } - - userMessage(message: string): void { - this.userMessages.push(message) - } - - clearTranscript(): void { - this.clearedTranscripts += 1 - } - - read(prompt: string): Promise<ChatTerminalInput> { - this.reads.push({ prompt, initialValue: this.stagedPreload }) - this.stagedPreload = '' - return Promise.resolve(this.inputs.shift() ?? { kind: 'eof' }) - } - - hasQueuedInput(): boolean { - return ( - Boolean(this.stagedPreload) || - this.inputs.some((input) => input.kind === 'line' && input.queued === true) - ) - } - - preload( - value: string, - options: { - queued?: boolean - pastes?: ReadonlyMap<number, string> - contexts?: ChatContext[] - } = {} - ): boolean { - this.preloads.push({ - value, - queued: options.queued === true, - ...(options.pastes ? { pastes: options.pastes } : {}), - ...(options.contexts ? { contexts: options.contexts } : {}), - }) - this.stagedPreload = value - return true - } - - setSuggestionCandidates(candidates: ChatSuggestionCandidates): void { - this.suggestionCandidates = candidates - this.suggestionUpdates.push(candidates) - } - - status(message: string): void { - this.statuses.push(message) - } - - write(content: string): void { - this.writes.push(content) - } - - activity(_message: string): ChatActivity { - return { - update: () => {}, - thinking: (delta) => this.thinking.push(delta), - event: (update) => this.activities.push(update), - clear: () => {}, - complete: () => {}, - stop: () => {}, - } - } - - askQuestion(question: ChatTerminalQuestion): Promise<ChatTerminalQuestionResult> { - this.questions.push(question) - return Promise.resolve(this.questionResults.shift() ?? { kind: 'cancel' }) - } - - select(menu: ChatTerminalSelect): Promise<ChatTerminalSelectResult> { - this.selections.push(menu) - return Promise.resolve(this.selectionResults.shift() ?? { kind: 'cancel' }) - } - - onInterrupt(listener: ChatTerminalInterruptListener): () => void { - this.listeners.add(listener) - return () => this.listeners.delete(listener) - } - - interrupt(reason: ChatTerminalInterruptReason = 'manual', input?: ChatTerminalInput): void { - const submitted = - input ?? - (reason === 'submit' ? this.inputs.find((entry) => entry.kind === 'line') : undefined) - for (const listener of this.listeners) listener(reason, submitted) - } - - close(): void { - this.closed = true - } -} - -describe('chat ask', () => { - it('posts to the selected workspace and prints only the completed answer', async () => { - const wire = [ - ': keepalive\n\n', - `event: session\ndata: ${JSON.stringify({ - type: 'session', - continuationToken: 'opaque-token', - requestId: 'req_1', - })}\n\n`, - `event: text\ndata: ${JSON.stringify({ type: 'text', delta: 'Hello ' })}\n\n`, - `event: text\ndata: ${JSON.stringify({ type: 'text', delta: 'world' })}\n\n`, - `event: complete\ndata: ${JSON.stringify({ - type: 'complete', - data: { content: 'Hello world', continuationToken: 'opaque-token' }, - })}\n\n`, - 'data: [DONE]\n\n', - ].join('') - mocks.requestRaw.mockResolvedValue( - sse([wire.slice(0, 41), wire.slice(41, 137), wire.slice(137)]) - ) - const writeOutput = vi.fn() - - await program(async () => '', writeOutput).parseAsync([ - 'node', - 'sim', - 'chat', - 'ask', - 'What', - 'is', - 'here?', - ]) - - expect(mocks.requireWorkspace).toHaveBeenCalledWith(undefined, { auth: 'optional' }) - expect(mocks.requestRaw).toHaveBeenCalledWith('/api/v2/chat', { - method: 'POST', - headers: { accept: 'text/event-stream' }, - body: { workspaceId: 'ws_local', prompt: 'What is here?' }, - signal: expect.any(AbortSignal), - auth: 'optional', - }) - expect(writeOutput).toHaveBeenCalledOnce() - expect(writeOutput).toHaveBeenCalledWith('Hello world') - }) - - it('shows the reusable saved chat ID on stderr without changing answer stdout', async () => { - mocks.requestRaw.mockResolvedValue(completed('Saved answer', 'token', [], 'chat-1')) - const writeOutput = vi.fn() - const writeProgress = vi.fn() - - await program(async () => '', writeOutput, { - showProgress: () => true, - writeProgress, - }).parseAsync(['node', 'sim', 'chat', 'ask', 'Save this']) - - expect(writeOutput).toHaveBeenCalledWith('Saved answer') - expect(writeProgress).toHaveBeenCalledWith('chat: chat-1\n') - }) - - it.each([ - ['json', '{"content":"Saved answer","chatId":"chat-1"}'], - ['yaml', 'content: Saved answer\nchatId: chat-1'], - ] as const)('returns content and reusable chat ID in %s output', async (format, expected) => { - mocks.requestRaw.mockResolvedValue(completed('Saved answer', 'token', [], 'chat-1')) - mocks.output = format - const writeOutput = vi.fn() - const writeProgress = vi.fn() - const logged: string[] = [] - const log = vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) - - try { - await program(async () => '', writeOutput, { - showProgress: () => true, - writeProgress, - }).parseAsync(['node', 'sim', 'chat', 'ask', 'Save this']) - } finally { - log.mockRestore() - } - - expect(writeOutput).not.toHaveBeenCalled() - expect(writeProgress).not.toHaveBeenCalled() - expect(logged).toHaveLength(1) - expect(format === 'json' ? JSON.stringify(JSON.parse(logged[0])) : logged[0]).toBe(expected) - }) - - it('keeps workspace-key one-shot answers usable when no saved chat ID is available', async () => { - mocks.requestRaw.mockResolvedValue(completed('Unsynced answer', 'token', [], null)) - const writeOutput = vi.fn() - const writeProgress = vi.fn() - - await program(async () => '', writeOutput, { - showProgress: () => true, - writeProgress, - }).parseAsync(['node', 'sim', 'chat', 'ask', 'Read this']) - - expect(writeOutput).toHaveBeenCalledWith('Unsynced answer') - expect(writeProgress).toHaveBeenCalledWith( - 'chat: not saved (a personal API key is required for resumable history)\n' - ) - }) - - it('resumes an existing chat by ID for one print-mode turn', async () => { - mocks.request.mockResolvedValueOnce({ - data: { - id: 'chat-1', - title: 'Existing chat', - messages: [], - continuationToken: 'resume-token', - active: false, - }, - }) - mocks.requestRaw.mockResolvedValue(completed('Continued answer', 'next-token')) - const writeOutput = vi.fn() - - await program(async () => '', writeOutput).parseAsync([ - 'node', - 'sim', - 'chat', - 'ask', - '--chat', - 'chat-1', - 'Continue here', - ]) - - expect(mocks.request).toHaveBeenCalledWith('/api/v2/chats/chat-1', { - query: { workspaceId: 'ws_local' }, - auth: 'optional', - }) - expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: 'Continue here', - continuationToken: 'resume-token', - }) - expect(writeOutput).toHaveBeenCalledWith('Continued answer') - }) - - it('binds a resumed print-mode token to read-only mode', async () => { - mocks.request.mockResolvedValueOnce({ - data: { - id: 'chat-1', - title: 'Existing chat', - messages: [], - continuationToken: 'read-only-token', - active: false, - }, - }) - mocks.requestRaw.mockResolvedValue(completed('Read-only answer')) - - await program(async () => '').parseAsync([ - 'node', - 'sim', - 'chat', - 'ask', - '--read-only', - '--chat', - 'chat-1', - 'Continue safely', - ]) - - expect(mocks.request).toHaveBeenCalledWith('/api/v2/chats/chat-1', { - query: { workspaceId: 'ws_local', readOnly: true }, - auth: 'optional', - }) - expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: 'Continue safely', - readOnly: true, - continuationToken: 'read-only-token', - }) - }) - - it('keeps --chat scoped to one-shot asks', async () => { - const root = program(async () => '', vi.fn(), { isInteractive: () => true }) - const chat = root.commands.find((command) => command.name() === 'chat') - const ask = chat?.commands.find((command) => command.name() === 'ask') - - expect(chat?.helpInformation()).not.toContain('--chat') - expect(ask?.helpInformation()).toContain('--chat <chatId>') - - expect(mocks.request).not.toHaveBeenCalled() - expect(mocks.requestRaw).not.toHaveBeenCalled() - }) - - it('does not race a print-mode turn into a chat active elsewhere', async () => { - mocks.request.mockResolvedValueOnce({ - data: { - id: 'chat-1', - title: 'Existing chat', - messages: [], - continuationToken: 'resume-token', - active: true, - }, - }) - - await expect( - program(async () => '').parseAsync([ - 'node', - 'sim', - 'chat', - 'ask', - '--chat', - 'chat-1', - 'Continue here', - ]) - ).rejects.toThrow('currently active in another client') - - expect(mocks.requestRaw).not.toHaveBeenCalled() - }) - - it('surfaces a conflict if the chat becomes active after lookup', async () => { - mocks.request.mockResolvedValueOnce({ - data: { - id: 'chat-1', - title: 'Existing chat', - messages: [], - continuationToken: 'resume-token', - active: false, - }, - }) - mocks.requestRaw.mockRejectedValueOnce( - new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') - ) - const writeOutput = vi.fn() - - await expect( - program(async () => '', writeOutput).parseAsync([ - 'node', - 'sim', - 'chat', - 'ask', - '--chat', - 'chat-1', - 'Continue here', - ]) - ).rejects.toThrow('A response is already in progress for this chat') - - expect(mocks.requestRaw).toHaveBeenCalledOnce() - expect(writeOutput).not.toHaveBeenCalled() - }) - - it('surfaces an inaccessible chat without starting a new one', async () => { - mocks.request.mockRejectedValueOnce(new SimApiError('Chat not found', 404, 'NOT_FOUND')) - - await expect( - program(async () => '').parseAsync([ - 'node', - 'sim', - 'chat', - 'ask', - '--chat', - 'missing-chat', - 'Continue here', - ]) - ).rejects.toThrow('Chat not found') - - expect(mocks.requestRaw).not.toHaveBeenCalled() - }) - - it('accepts the global profile shorthand after chat ask', async () => { - mocks.requestRaw.mockResolvedValue(completed('answer')) - - await program(async () => '').parseAsync([ - 'node', - 'sim', - 'chat', - 'ask', - '-p', - 'dev', - 'question', - ]) - - expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: 'question', - }) - expect(mocks.selectedProfile).toHaveBeenCalledWith('dev') - }) - - it('opts into query-only chat only when --read-only is passed', async () => { - mocks.requestRaw.mockResolvedValue(completed('answer')) - - await program(async () => '').parseAsync([ - 'node', - 'sim', - 'chat', - 'ask', - '--read-only', - 'question', - ]) - - expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: 'question', - readOnly: true, - }) - }) - - it('combines positional and piped input in Claude Code order', async () => { - mocks.requestRaw.mockResolvedValue(completed('answer')) - - await program(async () => 'piped context\n').parseAsync([ - 'node', - 'sim', - 'chat', - 'ask', - 'Explain', - 'this', - ]) - - expect(mocks.requestRaw.mock.calls[0][1].body.prompt).toBe('Explain this\npiped context\n') - }) - - it('accepts piped input without a positional prompt', async () => { - mocks.requestRaw.mockResolvedValue(completed('answer')) - - await program(async () => 'question from stdin\n').parseAsync(['node', 'sim', 'chat', 'ask']) - - expect(mocks.requestRaw.mock.calls[0][1].body.prompt).toBe('question from stdin\n') - }) - - it('accepts attachment-only turns and never sends local paths', async () => { - const attachment: ChatAttachment = { - name: 'notes.md', - mediaType: 'text/markdown', - data: 'IyBub3Rlcw==', - } - const loadAttachments = vi.fn(async (paths: string[]) => (paths.length ? [attachment] : [])) - mocks.requestRaw.mockResolvedValue(completed('Inspected')) - - await program(async () => '', vi.fn(), { loadAttachments }).parseAsync([ - 'node', - 'sim', - 'chat', - 'ask', - '--file', - '/private/local/notes.md', - ]) - - expect(loadAttachments).toHaveBeenCalledWith(['/private/local/notes.md']) - expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: '', - attachments: [attachment], - }) - expect(JSON.stringify(mocks.requestRaw.mock.calls[0][1].body)).not.toContain('/private/local') - }) - - it('requires a prompt, attachment, or stdin', async () => { - await expect( - program(async () => '').parseAsync(['node', 'sim', 'chat', 'ask']) - ).rejects.toThrow(/Provide a prompt, attach a file, or pipe input/) - expect(mocks.requestRaw).not.toHaveBeenCalled() - }) - - it('caps the combined prompt by UTF-8 bytes', async () => { - const justOverTenMebibytes = 'é'.repeat(5 * 1024 * 1024 + 1) - - const result = program(async () => justOverTenMebibytes).parseAsync([ - 'node', - 'sim', - 'chat', - 'ask', - ]) - - await expect(result).rejects.toMatchObject({ - message: 'Chat input exceeds the 10 MiB limit.', - status: 0, - }) - expect(mocks.requestRaw).not.toHaveBeenCalled() - }) - - it('fails clearly instead of blocking when bare chat has no interactive terminal', async () => { - await expect( - program(async () => '').parseAsync(['node', 'sim', 'chat', 'question']) - ).rejects.toThrow(/Use sim chat ask/) - expect(mocks.requestRaw).not.toHaveBeenCalled() - }) - - it('never constructs a terminal prompt for chat ask', async () => { - mocks.requestRaw.mockResolvedValue(completed('answer')) - const createTerminal = vi.fn(() => { - throw new Error('must not prompt') - }) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal, - }).parseAsync(['node', 'sim', 'chat', 'ask', 'question']) - - expect(createTerminal).not.toHaveBeenCalled() - }) - - it('sanitizes final plain text and strips suggested follow-up options', async () => { - const terminalEscape = String.fromCharCode(27) - mocks.requestRaw.mockResolvedValue( - completed( - `Safe${terminalEscape}]0;owned\u0007 text<options>{"1":{"title":"Next${terminalEscape}[2A","description":"Continue"}}</options>` - ) - ) - const writeOutput = vi.fn() - - await program(async () => '', writeOutput).parseAsync([ - 'node', - 'sim', - 'chat', - 'ask', - 'question', - ]) - - expect(writeOutput).toHaveBeenCalledWith('Safe text') - expect(writeOutput.mock.calls[0][0]).not.toContain(terminalEscape) - }) - - it('trims whitespace owned by hidden options in one-shot output', async () => { - const options = '<options>{"1":{"title":"Next","description":"Continue"}}</options>' - mocks.requestRaw.mockResolvedValue(completed(`Answer\n\n${options}\n\n`)) - const writeOutput = vi.fn() - - await program(async () => '', writeOutput).parseAsync([ - 'node', - 'sim', - 'chat', - 'ask', - 'question', - ]) - - expect(writeOutput).toHaveBeenCalledOnce() - expect(writeOutput).toHaveBeenCalledWith('Answer') - }) - - it('renders a path-only file resource as its plain title without another API request', async () => { - mocks.requestRaw.mockResolvedValue( - completed( - '<workspace_resource>{"type":"file","path":"files/Reports/Q4%20Report.csv","title":"Q4 report"}</workspace_resource>' - ) - ) - const writeOutput = vi.fn() - - await program(async () => '', writeOutput).parseAsync([ - 'node', - 'sim', - 'chat', - 'ask', - 'find file', - ]) - - expect(mocks.request).not.toHaveBeenCalled() - expect(writeOutput).toHaveBeenCalledWith('Q4 report') - }) - - it('omits a trailing standalone workspace link in one-shot output', async () => { - const resource = - '<workspace_resource>{"type":"workflow","id":"wf-forceful","title":"forceful-arm"}</workspace_resource>' - mocks.requestRaw.mockResolvedValue(completed(`Summary.\n\n${resource}`)) - const writeOutput = vi.fn() - - await program(async () => '', writeOutput).parseAsync([ - 'node', - 'sim', - 'chat', - 'ask', - 'inspect forceful-arm', - ]) - - expect(writeOutput).toHaveBeenCalledWith('Summary.') - }) - - it('does not print a partial answer when the stream fails', async () => { - mocks.requestRaw.mockResolvedValue( - sse([ - 'event: text\ndata: {"type":"text","delta":"partial"}\n\n', - 'event: error\ndata: {"type":"error","error":{"code":"FAILED","message":"No answer"}}\n\n', - ]) - ) - const writeOutput = vi.fn() - - await expect( - program(async () => '', writeOutput).parseAsync(['node', 'sim', 'chat', 'ask', 'question']) - ).rejects.toThrow('No answer') - expect(writeOutput).not.toHaveBeenCalled() - }) - - it('keeps thinking and activity events silent in one-shot output', async () => { - mocks.requestRaw.mockResolvedValue( - sse([ - 'event: thinking\ndata: {"type":"thinking","delta":"Checking the workspace"}\n\n', - 'event: activity\ndata: {"type":"activity","data":{"kind":"subagent","id":"agent-1","label":"Build Agent","state":"running"}}\n\n', - 'event: activity\ndata: {"type":"activity","data":{"kind":"narration","parentId":"agent-1","delta":"Inspecting files"}}\n\n', - 'event: activity\ndata: {"type":"activity","data":{"kind":"tool","id":"tool-1","label":"Read workflows","state":"running"}}\n\n', - 'event: text\ndata: {"type":"text","delta":"Answer"}\n\n', - 'event: complete\ndata: {"type":"complete","data":{"content":"Answer","continuationToken":"token-1"}}\n\n', - ]) - ) - const writeOutput = vi.fn() - await program(async () => '', writeOutput).parseAsync([ - 'node', - 'sim', - 'chat', - 'ask', - 'question', - ]) - - expect(writeOutput).toHaveBeenCalledWith('Answer') - }) - - it('starts an asynchronous run, closes the accepted stream, and prints a normal receipt', async () => { - const accepted = openSse( - 'event: session\ndata: {"type":"session","runId":"run-1","chatId":"chat-1"}\n\n' - ) - mocks.requestRaw.mockResolvedValue(accepted.response) - mocks.output = 'json' - const logged: string[] = [] - const log = vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) - - try { - await program(async () => '').parseAsync([ - 'node', - 'sim', - 'chat', - 'ask', - '--async', - 'inspect workspace', - ]) - } finally { - log.mockRestore() - } - - expect(mocks.requestRaw).toHaveBeenCalledWith('/api/v2/chat', { - method: 'POST', - headers: { accept: 'text/event-stream' }, - body: { - workspaceId: 'ws_local', - prompt: 'inspect workspace', - persistChat: true, - async: true, - }, - signal: expect.any(AbortSignal), - auth: 'optional', - }) - expect(accepted.cancel).toHaveBeenCalledOnce() - expect(JSON.parse(logged.join('\n'))).toEqual({ - runId: 'run-1', - chatId: 'chat-1', - status: 'active', - }) - }) -}) - -describe('chat follow', () => { - const snapshot = ( - status: string, - response: string, - activities: Array<Record<string, unknown>> = [] - ) => ({ - data: { - runId: 'run-1', - chatId: 'chat-1', - chatTitle: 'Workspace audit', - status, - startedAt: '2026-08-08T12:00:00.000Z', - completedAt: status === 'complete' ? '2026-08-08T12:00:02.000Z' : null, - response, - activities, - }, - }) - - it('prints only newly accumulated response text and useful progress', async () => { - mocks.request - .mockResolvedValueOnce( - snapshot('active', 'Hel', [ - { kind: 'tool', id: 'tool-1', label: 'Read file', state: 'running' }, - ]) - ) - .mockResolvedValueOnce( - snapshot('active', 'Hello', [ - { kind: 'tool', id: 'tool-1', label: 'Read file', state: 'running' }, - ]) - ) - .mockResolvedValueOnce( - snapshot('complete', 'Hello world', [ - { kind: 'tool', id: 'tool-1', label: 'Read file', state: 'running' }, - { kind: 'tool', id: 'tool-1', label: 'Read file', state: 'complete' }, - ]) - ) - const terminalWrites: string[] = [] - const writeStream = vi.fn((content: string) => terminalWrites.push(content)) - const writeProgress = vi.fn((content: string) => terminalWrites.push(content)) - const pollDelay = vi.fn(async () => {}) - - await program(async () => '', vi.fn(), { - writeStream, - writeProgress, - showProgress: () => true, - pollDelay, - }).parseAsync(['node', 'sim', 'chat', 'follow', 'run-1']) - - expect(mocks.request).toHaveBeenCalledTimes(3) - for (const [path, options] of mocks.request.mock.calls) { - expect(path).toBe('/api/v2/chat/runs/run-1') - expect(options).toMatchObject({ query: { workspaceId: 'ws_local' }, auth: 'optional' }) - expect(options.signal).toBeInstanceOf(AbortSignal) - } - expect(pollDelay).toHaveBeenCalledTimes(2) - expect(writeStream.mock.calls.map(([value]) => value)).toEqual(['Hel', 'lo', ' world', '\n']) - expect(writeProgress.mock.calls.map(([value]) => value)).toEqual([ - 'status: active\n', - '● Read file\n', - 'status: complete\n', - '✓ Read file\n', - ]) - expect(terminalWrites).toEqual([ - 'status: active\n', - '● Read file\n', - 'Hel', - 'lo', - ' world', - '\n', - 'status: complete\n', - '✓ Read file\n', - ]) - }) - - it('retries transient status failures without regressing accumulated output', async () => { - mocks.request - .mockRejectedValueOnce(new SimApiError('Progress unavailable', 503, 'SERVICE_UNAVAILABLE')) - .mockResolvedValueOnce(snapshot('complete', 'Recovered answer')) - const writeStream = vi.fn() - const pollDelay = vi.fn(async () => {}) - - await program(async () => '', vi.fn(), { - writeStream, - showProgress: () => false, - pollDelay, - }).parseAsync(['node', 'sim', 'chat', 'follow', 'run-1']) - - expect(mocks.request).toHaveBeenCalledTimes(2) - expect(pollDelay).toHaveBeenCalledOnce() - expect(writeStream.mock.calls.map(([value]) => value)).toEqual(['Recovered answer', '\n']) - }) - - it('emits one safe final snapshot for JSON output', async () => { - const terminalEscape = `${String.fromCharCode(27)}]0;owned\u0007` - mocks.request.mockResolvedValueOnce(snapshot('complete', `${terminalEscape}Answer`)) - mocks.output = 'json' - const writeStream = vi.fn() - const writeProgress = vi.fn() - const logged: string[] = [] - const log = vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) - - try { - await program(async () => '', vi.fn(), { - writeStream, - writeProgress, - showProgress: () => true, - }).parseAsync(['node', 'sim', 'chat', 'follow', 'run-1']) - } finally { - log.mockRestore() - } - - expect(writeStream).not.toHaveBeenCalled() - expect(writeProgress).not.toHaveBeenCalled() - expect(logged).toHaveLength(1) - expect(JSON.parse(logged[0])).toMatchObject({ - runId: 'run-1', - chatId: 'chat-1', - status: 'complete', - response: `${terminalEscape}Answer`, - }) - }) - - it('streams through the normal structured renderer and omits a trailing resource', async () => { - const options = '<options>{"1":{"title":"Next","description":"Continue"}}</options>' - const resource = - '<workspace_resource>{"type":"workflow","id":"wf-1","title":"forceful-arm"}</workspace_resource>' - mocks.request - .mockResolvedValueOnce(snapshot('active', 'Answer\n\n<op')) - .mockResolvedValueOnce(snapshot('complete', `Answer\n\n${options}\n\n${resource}`)) - const writeStream = vi.fn() - - await program(async () => '', vi.fn(), { - writeStream, - showProgress: () => false, - pollDelay: async () => {}, - }).parseAsync(['node', 'sim', 'chat', 'follow', 'run-1']) - - expect(writeStream.mock.calls.map(([value]) => value)).toEqual(['Answer', '\n']) - expect(writeStream.mock.calls.flat().join('')).not.toContain('<options>') - expect(writeStream.mock.calls.flat().join('')).not.toContain('forceful-arm') - }) - - it('prints the safe partial answer before failing an errored run', async () => { - mocks.request.mockResolvedValueOnce(snapshot('error', 'Partial answer')) - const writeStream = vi.fn() - - await expect( - program(async () => '', vi.fn(), { - writeStream, - showProgress: () => false, - }).parseAsync(['node', 'sim', 'chat', 'follow', 'run-1']) - ).rejects.toMatchObject({ - message: 'Sim Chat run ended with status "error".', - code: 'CHAT_RUN_FAILED', - }) - - expect(writeStream.mock.calls.map(([value]) => value)).toEqual(['Partial answer', '\n']) - }) - - it('emits one final JSON snapshot before failing a cancelled run', async () => { - mocks.request.mockResolvedValueOnce(snapshot('cancelled', 'Stopped safely')) - mocks.output = 'json' - const logged: string[] = [] - const log = vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) - - try { - await expect( - program(async () => '', vi.fn(), { - showProgress: () => false, - }).parseAsync(['node', 'sim', 'chat', 'follow', 'run-1']) - ).rejects.toMatchObject({ code: 'CHAT_RUN_FAILED' }) - } finally { - log.mockRestore() - } - - expect(logged).toHaveLength(1) - expect(JSON.parse(logged[0])).toMatchObject({ - runId: 'run-1', - status: 'cancelled', - response: 'Stopped safely', - }) - }) - - it('detaches on Ctrl+C without cancelling the run', async () => { - mocks.request.mockResolvedValueOnce(snapshot('active', 'partial')) - let interrupt: (() => void) | undefined - const writeStream = vi.fn() - const pollDelay = vi.fn(async () => { - interrupt?.() - }) - - await program(async () => '', vi.fn(), { - writeStream, - showProgress: () => false, - pollDelay, - onInterrupt: (listener) => { - interrupt = listener - return () => { - interrupt = undefined - } - }, - }).parseAsync(['node', 'sim', 'chat', 'follow', 'run-1']) - - expect(mocks.request).toHaveBeenCalledTimes(1) - expect(mocks.request.mock.calls[0][0]).toBe('/api/v2/chat/runs/run-1') - expect(writeStream.mock.calls.map(([value]) => value)).toEqual(['partial', '\n']) - }) - - it('rejects extra positional arguments after the run ID', async () => { - await expect( - program(async () => '').parseAsync(['node', 'sim', 'chat', 'follow', 'run-1', 'unexpected']) - ).rejects.toThrow(/too many arguments/i) - - expect(mocks.request).not.toHaveBeenCalled() - }) -}) - -describe('chat command composition', () => { - it('merges the manual chat protocol into an existing generated chat group', () => { - const root = new Command('sim') - const generatedChat = new Command('chat') - const runs = new Command('runs').addCommand(new Command('get')) - generatedChat.addCommand(runs) - root.addCommand(generatedChat) - - attachProtocolCommands(root) - - expect(root.commands.filter((command) => command.name() === 'chat')).toEqual([generatedChat]) - expect(generatedChat.commands.map((command) => command.name()).sort()).toEqual([ - 'ask', - 'follow', - 'runs', - ]) - }) -}) - -describe('interactive chat', () => { - it('shows the resolved workspace name in the terminal header', async () => { - mocks.request.mockImplementation((path: string) => { - if (path === '/api/v2/workspaces/ws_local') { - return Promise.resolve({ data: { name: 'Product Operations' } }) - } - return Promise.resolve({ data: [], nextCursor: null }) - }) - const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat']) - - await vi.waitFor(() => expect(terminal.workspaceNames).toContain('Product Operations')) - }) - - it('renders Markdown in the fullscreen TUI when TERM is dumb', async () => { - const originalTerm = process.env.TERM - const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY') - Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }) - process.env.TERM = 'dumb' - - try { - const content = '**Workflows (3)**\n- cobalt_cloud' - mocks.requestRaw.mockResolvedValue(completed(content, 'token-1', [content])) - const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'list workspace']) - - const esc = String.fromCharCode(27) - const rendered = terminal.writes.join('') - expect(rendered).toContain(`${esc}[1mWorkflows (3)`) - expect(rendered).toContain(`${esc}[2m•${esc}[0m`) - expect(rendered).not.toContain('**') - expect(rendered).toContain('cobalt_cloud') - } finally { - if (originalIsTTY) { - Object.defineProperty(process.stdout, 'isTTY', originalIsTTY) - } else { - Reflect.deleteProperty(process.stdout, 'isTTY') - } - if (originalTerm === undefined) Reflect.deleteProperty(process.env, 'TERM') - else process.env.TERM = originalTerm - } - }) - - it('aborts every background suggestion request when the terminal session closes', async () => { - const signals: AbortSignal[] = [] - mocks.request.mockImplementation( - (_path: string, options: { signal?: AbortSignal } = {}) => - new Promise((_resolve, reject) => { - if (!options.signal) return - signals.push(options.signal) - options.signal.addEventListener('abort', () => reject(new Error('aborted')), { - once: true, - }) - }) - ) - const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat']) - - expect(signals).toHaveLength(7) - expect(signals.every((signal) => signal === signals[0])).toBe(true) - expect(signals[0]?.aborted).toBe(true) - expect(terminal.closed).toBe(true) - }) - - it('loads workspace resources under @ and skills plus enabled MCP servers under /', async () => { - mocks.request.mockImplementation((path: string) => { - if (path === '/api/v2/workflows') { - return Promise.resolve({ data: [{ id: 'wf-1', name: 'Release' }], nextCursor: null }) - } - if (path === '/api/v2/tables') { - return Promise.resolve({ data: [{ id: 'table-1', name: 'Leads' }], nextCursor: null }) - } - if (path === '/api/v2/files') { - return Promise.resolve({ data: [{ id: 'file-1', name: 'Brief.md' }], nextCursor: null }) - } - if (path === '/api/v2/knowledge') { - return Promise.resolve({ data: [{ id: 'kb-1', name: 'Handbook' }], nextCursor: null }) - } - if (path === '/api/v2/logs') { - return Promise.resolve({ - data: Array.from({ length: 55 }, (_, index) => ({ - id: `log-row-${index + 1}`, - runId: `execution-${index + 1}`, - workflowId: 'wf-1', - startedAt: '2026-08-07T12:00:00.000Z', - })), - nextCursor: 'more-logs', - }) - } - if (path === '/api/v2/skills') { - return Promise.resolve({ - data: [{ id: 'skill-1', name: 'review', description: 'Review the work' }], - nextCursor: null, - }) - } - if (path === '/api/v2/mcp-servers') { - return Promise.resolve({ - data: [ - { id: 'mcp-1', name: 'Docs', enabled: true }, - { id: 'mcp-2', name: 'Disabled', enabled: false }, - ], - nextCursor: null, - }) - } - return Promise.resolve({ data: [], nextCursor: null }) - }) - const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat']) - await vi.waitFor(() => { - expect(terminal.suggestionCandidates?.resources).toHaveLength(54) - expect(terminal.suggestionCandidates?.slash).toHaveLength(2) - }) - - const resources = terminal.suggestionCandidates?.resources ?? [] - expect(resources.slice(0, 4).map((item) => item.context?.kind)).toEqual([ - 'workflow', - 'table', - 'file', - 'knowledge', - ]) - expect(resources.slice(4)).toHaveLength(50) - expect(resources.slice(4).every((item) => item.context?.kind === 'logs')).toBe(true) - expect(resources.at(-1)?.context).toMatchObject({ - kind: 'logs', - executionId: 'execution-50', - label: expect.stringContaining('Release'), - }) - expect(terminal.suggestionCandidates?.slash.map((item) => item.context?.kind)).toEqual([ - 'skill', - 'mcp', - ]) - expect(terminal.suggestionCandidates?.slash.map((item) => item.displayText)).toEqual([ - '/review', - '/Docs', - ]) - expect( - terminal.suggestionUpdates.some( - (update) => - update.resources.length + update.slash.length > 0 && update.resources.length < 54 - ) - ).toBe(true) - const logRequests = mocks.request.mock.calls.filter(([path]) => path === '/api/v2/logs') - expect(logRequests).toHaveLength(1) - expect(logRequests[0]?.[1]).toMatchObject({ - query: { - workspaceId: 'ws_local', - details: 'basic', - order: 'desc', - limit: 50, - }, - }) - }) - - it('publishes skills but does not fetch or suggest MCP servers in read-only chat', async () => { - mocks.request.mockImplementation((path: string) => { - if (path === '/api/v2/skills') { - return Promise.resolve({ - data: [{ id: 'skill-1', name: 'review', description: 'Review the work' }], - nextCursor: null, - }) - } - if (path === '/api/v2/mcp-servers') { - return Promise.resolve({ - data: [{ id: 'mcp-1', name: 'Docs', enabled: true }], - nextCursor: null, - }) - } - return Promise.resolve({ data: [], nextCursor: null }) - }) - const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', '--read-only']) - await vi.waitFor(() => expect(terminal.suggestionCandidates?.slash).toHaveLength(1)) - - expect(terminal.suggestionCandidates?.slash[0]?.context?.kind).toBe('skill') - expect(mocks.request.mock.calls.some(([path]) => path === '/api/v2/mcp-servers')).toBe(false) - }) - - it('publishes each suggestion family without waiting for a slower list', async () => { - let resolveWorkflows: - | ((page: { data: Array<{ id: string; name: string }>; nextCursor: null }) => void) - | undefined - const workflows = new Promise<{ data: Array<{ id: string; name: string }>; nextCursor: null }>( - (resolve) => { - resolveWorkflows = resolve - } - ) - mocks.request.mockImplementation((path: string) => { - if (path === '/api/v2/workflows') return workflows - if (path === '/api/v2/files') { - return Promise.resolve({ data: [{ id: 'file-1', name: 'Ready.md' }], nextCursor: null }) - } - return Promise.resolve({ data: [], nextCursor: null }) - }) - const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat']) - await vi.waitFor(() => - expect(terminal.suggestionCandidates?.resources.map((item) => item.displayText)).toContain( - 'Ready.md' - ) - ) - expect(terminal.suggestionCandidates?.resources.map((item) => item.displayText)).not.toContain( - 'Later workflow' - ) - - resolveWorkflows?.({ data: [{ id: 'workflow-1', name: 'Later workflow' }], nextCursor: null }) - await vi.waitFor(() => - expect(terminal.suggestionCandidates?.resources.map((item) => item.displayText)).toContain( - 'Later workflow' - ) - ) - }) - - it('sends selected resource and slash identities beside the prompt', async () => { - const contexts: ChatContext[] = [ - { kind: 'workflow', workflowId: 'workflow-1', label: 'Release' }, - { kind: 'skill', skillId: 'skill-1', label: 'review' }, - { kind: 'mcp', serverId: 'mcp-1', label: 'Docs' }, - ] - const terminal = new FakeTerminal([ - { - kind: 'line', - value: 'Use @Release with /review and /Docs', - contexts, - }, - { kind: 'line', value: '/exit' }, - ]) - mocks.requestRaw.mockResolvedValueOnce(completed('Done', 'token-1')) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat']) - - expect(mocks.requestRaw.mock.calls[0][1].body).toMatchObject({ - prompt: 'Use @Release with /review and /Docs', - contexts, - }) - }) - - it('lists every chat page and refreshes an active choice before sending', async () => { - let detailRequests = 0 - mocks.request.mockImplementation((path: string, options?: { query?: unknown }) => { - if (path === '/api/v2/chats') { - const cursor = (options?.query as { cursor?: string | null } | undefined)?.cursor - if (cursor === 'older-chats') { - return Promise.resolve({ - data: [ - { - id: 'chat-older', - title: 'Older investigation', - updatedAt: '2026-07-01T12:00:00.000Z', - pinned: false, - active: false, - }, - ], - nextCursor: null, - }) - } - return Promise.resolve({ - data: [ - { - id: 'chat-2', - title: 'Release investigation', - updatedAt: '2026-08-07T12:00:00.000Z', - pinned: true, - active: true, - }, - ], - nextCursor: 'older-chats', - }) - } - if (path === '/api/v2/chats/chat-2') { - detailRequests += 1 - const active = detailRequests === 1 - return Promise.resolve({ - data: { - id: 'chat-2', - title: 'Release investigation', - messages: [ - { - id: 'message-1', - role: 'user', - content: 'What failed?', - timestamp: '2026-08-07T11:59:00.000Z', - }, - { - id: 'message-2', - role: 'assistant', - content: active - ? 'The **release** is still running.' - : 'The **release** finished.\n\n<workspace_resource>{"type":"workflow","id":"wf-forceful","title":"forceful-arm"}</workspace_resource>', - timestamp: '2026-08-07T12:00:00.000Z', - }, - ], - continuationToken: active ? 'resume-token' : 'refreshed-token', - active, - }, - }) - } - return Promise.resolve({ data: [], nextCursor: null, options }) - }) - mocks.requestRaw.mockResolvedValueOnce(completed('Continuing', 'next-token')) - const terminal = new FakeTerminal( - [ - { kind: 'line', value: '/chats' }, - { kind: 'line', value: 'Continue here' }, - { kind: 'line', value: '/exit' }, - ], - [], - [{ kind: 'selected', id: 'chat-2' }] - ) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - formatMarkdown: () => false, - }).parseAsync(['node', 'sim', 'chat']) - - expect(terminal.selections).toHaveLength(1) - expect(terminal.selections[0]?.options).toEqual([ - { - id: 'sim-cli:new-chat', - label: 'New chat', - description: 'start a blank conversation', - }, - expect.objectContaining({ - id: 'chat-2', - label: 'Release investigation', - description: expect.stringContaining('pinned'), - }), - expect.objectContaining({ - id: 'chat-older', - label: 'Older investigation', - }), - ]) - expect(detailRequests).toBe(2) - expect(terminal.clearedTranscripts).toBe(2) - expect(terminal.statuses).toContain( - 'Opened Release investigation. This chat is currently active elsewhere.' - ) - expect(terminal.statuses).toContain('Resumed Release investigation.') - expect(terminal.chatTitles).toContain('Release investigation') - expect(terminal.userMessages).toContain('What failed?') - expect(terminal.userMessages).toContain('Continue here') - expect(terminal.writes.join('')).toContain('The **release** finished.\n') - expect(terminal.writes.join('')).not.toContain('forceful-arm') - expect(mocks.requestRaw.mock.calls[0][1].body).toMatchObject({ - workspaceId: 'ws_local', - prompt: 'Continue here', - continuationToken: 'refreshed-token', - }) - const listRequests = mocks.request.mock.calls.filter(([path]) => path === '/api/v2/chats') - expect(listRequests).toHaveLength(2) - expect(listRequests[0]?.[1]).toMatchObject({ - query: { workspaceId: 'ws_local', limit: 100, cursor: null }, - }) - expect(listRequests[1]?.[1]).toMatchObject({ - query: { workspaceId: 'ws_local', limit: 100, cursor: 'older-chats' }, - }) - }) - - it('refreshes a resumed chat before retrying after a send races with remote activity', async () => { - let detailRequests = 0 - mocks.request.mockImplementation((path: string) => { - if (path === '/api/v2/chats') { - return Promise.resolve({ - data: [ - { - id: 'chat-race', - title: 'Race investigation', - updatedAt: '2026-08-07T12:00:00.000Z', - pinned: false, - active: false, - }, - ], - nextCursor: null, - }) - } - if (path === '/api/v2/chats/chat-race') { - detailRequests += 1 - return Promise.resolve({ - data: { - id: 'chat-race', - title: 'Race investigation', - messages: [ - { - id: `message-${detailRequests}`, - role: 'assistant', - content: detailRequests === 1 ? 'Ready.' : 'The remote response finished.', - timestamp: '2026-08-07T12:00:00.000Z', - }, - ], - continuationToken: detailRequests === 1 ? 'initial-token' : 'refreshed-token', - active: false, - }, - }) - } - return Promise.resolve({ data: [], nextCursor: null }) - }) - mocks.requestRaw - .mockRejectedValueOnce( - new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') - ) - .mockResolvedValueOnce(completed('Retried', 'next-token')) - const terminal = new FakeTerminal( - [ - { kind: 'line', value: '/chats' }, - { kind: 'line', value: 'Retry this turn' }, - { kind: 'line', value: 'Retry this turn' }, - { kind: 'line', value: '/exit' }, - ], - [], - [{ kind: 'selected', id: 'chat-race' }] - ) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat']) - - expect(detailRequests).toBe(2) - expect(terminal.clearedTranscripts).toBe(2) - expect(terminal.preloads).toContainEqual({ value: 'Retry this turn', queued: true }) - expect(terminal.statuses).toContain( - 'Previous response is still settling. Press Enter to retry.' - ) - expect(terminal.writes.join('')).toContain('The remote response finished.\n') - expect(mocks.requestRaw).toHaveBeenCalledTimes(2) - expect(mocks.requestRaw.mock.calls[1][1].body).toMatchObject({ - workspaceId: 'ws_local', - prompt: 'Retry this turn', - continuationToken: 'refreshed-token', - }) - }) - - it('repaints and restores the exact turn while a resumed chat remains active elsewhere', async () => { - const attachment: ChatAttachment = { - name: 'notes.txt', - mediaType: 'text/plain', - data: 'bm90ZXM=', - } - const pasted = 'p'.repeat(900) - const display = 'Retry @Release [Pasted text #1]' - const prompt = `Retry @Release ${pasted}` - const pastes = new Map([[1, pasted]]) - const contexts: ChatContext[] = [ - { kind: 'workflow', workflowId: 'workflow-1', label: 'Release' }, - ] - let detailRequests = 0 - mocks.request.mockImplementation((path: string) => { - if (path === '/api/v2/chats') { - return Promise.resolve({ - data: [ - { - id: 'chat-active', - title: 'Active investigation', - updatedAt: '2026-08-07T12:00:00.000Z', - pinned: false, - active: true, - }, - ], - nextCursor: null, - }) - } - if (path === '/api/v2/chats/chat-active') { - detailRequests += 1 - const active = detailRequests < 3 - return Promise.resolve({ - data: { - id: 'chat-active', - title: 'Active investigation', - messages: [ - { - id: 'message-1', - role: 'assistant', - content: active ? 'Still working.' : 'Finished now.', - timestamp: '2026-08-07T12:00:00.000Z', - }, - ], - continuationToken: `resume-token-${detailRequests}`, - active, - }, - }) - } - return Promise.resolve({ data: [], nextCursor: null }) - }) - mocks.requestRaw.mockResolvedValueOnce(completed('Retried', 'next-token')) - const terminal = new FakeTerminal( - [ - { kind: 'clipboard', value: '' }, - { kind: 'line', value: '/chats' }, - { kind: 'line', value: prompt, display, pastes, contexts }, - { kind: 'line', value: prompt, display, pastes, contexts }, - { kind: 'line', value: '/exit' }, - ], - [], - [{ kind: 'selected', id: 'chat-active' }] - ) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - clipboardAttachment: async () => attachment, - extractAttachmentPaths: async () => null, - }).parseAsync(['node', 'sim', 'chat']) - - expect(detailRequests).toBe(3) - expect(terminal.statuses).toContain( - 'Refreshed Active investigation. This chat remains active elsewhere.' - ) - expect(terminal.preloads).toContainEqual({ - value: display, - queued: true, - pastes, - contexts, - }) - expect(terminal.userMessages).toContain(display) - expect(mocks.requestRaw).toHaveBeenCalledTimes(1) - expect(mocks.requestRaw.mock.calls[0][1].body).toMatchObject({ - workspaceId: 'ws_local', - prompt, - continuationToken: 'resume-token-3', - attachments: [attachment], - contexts, - }) - }) - - it('visibly resets the transcript and continuation identity with /new', async () => { - mocks.requestRaw - .mockResolvedValueOnce( - sse([ - 'event: session\ndata: {"type":"session","chatId":"chat-1","continuationToken":"token-1"}\n\n', - 'event: complete\ndata: {"type":"complete","data":{"content":"First","continuationToken":"token-1"}}\n\n', - ]) - ) - .mockResolvedValueOnce(completed('Second', 'token-2')) - const terminal = new FakeTerminal([ - { kind: 'line', value: '/new' }, - { kind: 'line', value: 'Fresh question' }, - { kind: 'line', value: '/exit' }, - ]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'Original question']) - - expect(terminal.clearedTranscripts).toBe(1) - expect(terminal.statuses).toContain('Started a new conversation.') - expect(terminal.chatTitles).toContain('New chat') - expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: 'Fresh question', - }) - }) - - it('updates the welcome header when the server generates a chat title', async () => { - mocks.requestRaw.mockResolvedValueOnce( - sse([ - 'event: session\ndata: {"type":"session","chatId":"chat-1","continuationToken":"token-1"}\n\n', - 'event: session\ndata: {"type":"session","title":"Release investigation"}\n\n', - 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"token-1"}}\n\n', - ]) - ) - const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'Investigate the release']) - - expect(terminal.welcomes).toEqual(['New chat']) - expect(terminal.chatTitles).toContain('Release investigation') - }) - - it('renames the active synced chat and updates the terminal header', async () => { - mocks.requestRaw.mockResolvedValueOnce( - sse([ - 'event: session\ndata: {"type":"session","chatId":"chat-1","continuationToken":"token-1"}\n\n', - 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"token-1"}}\n\n', - ]) - ) - mocks.request.mockImplementation((path: string) => { - if (path === '/api/v2/chats/chat-1') { - return Promise.resolve({ - data: { id: 'chat-1', title: 'Incident investigation' }, - }) - } - return Promise.resolve({ data: [], nextCursor: null }) - }) - const terminal = new FakeTerminal([ - { kind: 'line', value: '/rename Incident investigation' }, - { kind: 'line', value: '/exit' }, - ]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'Investigate the incident']) - - const renameRequest = mocks.request.mock.calls.find( - ([path, options]) => path === '/api/v2/chats/chat-1' && options?.method === 'PATCH' - ) - expect(renameRequest?.[1]).toEqual({ - method: 'PATCH', - body: { workspaceId: 'ws_local', title: 'Incident investigation' }, - auth: 'optional', - }) - expect(terminal.chatTitles).toContain('Incident investigation') - expect(terminal.statuses).toContain('Renamed chat to Incident investigation.') - }) - - it('requires a synced chat before renaming', async () => { - const terminal = new FakeTerminal([ - { kind: 'line', value: '/rename Draft title' }, - { kind: 'line', value: '/exit' }, - ]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat']) - - expect(terminal.statuses).toContain('Send a message before renaming this chat.') - expect(mocks.request.mock.calls.some(([, options]) => options?.method === 'PATCH')).toBe(false) - }) - - it('validates rename titles locally', async () => { - const terminal = new FakeTerminal([ - { kind: 'line', value: '/rename' }, - { kind: 'line', value: `/rename ${'x'.repeat(201)}` }, - { kind: 'line', value: '/exit' }, - ]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat']) - - expect(terminal.statuses).toContain('Usage: /rename <title>') - expect(terminal.statuses).toContain('Error: Chat title cannot exceed 200 characters.') - expect(mocks.request.mock.calls.some(([, options]) => options?.method === 'PATCH')).toBe(false) - }) - - it('keeps the current title when rename fails', async () => { - mocks.requestRaw.mockResolvedValueOnce( - sse([ - 'event: session\ndata: {"type":"session","chatId":"chat-1","title":"Current title","continuationToken":"token-1"}\n\n', - 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"token-1"}}\n\n', - ]) - ) - mocks.request.mockImplementation((path: string) => { - if (path === '/api/v2/chats/chat-1') return Promise.reject(new Error('Rename failed')) - return Promise.resolve({ data: [], nextCursor: null }) - }) - const terminal = new FakeTerminal([ - { kind: 'line', value: '/rename New title' }, - { kind: 'line', value: '/exit' }, - ]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'Start']) - - expect(terminal.chatTitles).toEqual(['Current title']) - expect(terminal.statuses).toContain('Error: Rename failed') - }) - - it('sends only MCP contexts explicitly tagged on each turn', async () => { - const mcp: ChatContext = { kind: 'mcp', serverId: 'mcp-1', label: 'Docs' } - const terminal = new FakeTerminal([ - { kind: 'line', value: '/Docs search', contexts: [mcp] }, - { kind: 'line', value: 'Search again' }, - { kind: 'line', value: '/exit' }, - ]) - mocks.requestRaw - .mockResolvedValueOnce(completed('First', 'token-1')) - .mockResolvedValueOnce(completed('Second', 'token-2')) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat']) - - expect(mocks.requestRaw.mock.calls[0][1].body.contexts).toEqual([mcp]) - expect(mocks.requestRaw.mock.calls[1][1].body.contexts).toBeUndefined() - }) - - it('quietly clears on Ctrl+C and exits on a second empty Ctrl+C', async () => { - const terminal = new FakeTerminal([ - { kind: 'interrupt', empty: true }, - { kind: 'interrupt', empty: true }, - ]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat']) - - expect(terminal.statuses).toEqual([]) - expect(terminal.welcomes).toEqual(['New chat']) - expect(mocks.requestRaw).not.toHaveBeenCalled() - expect(terminal.closed).toBe(true) - }) - - it('strips suggested follow-ups and keeps the next composer message free-form', async () => { - const options = - '<options>{"1":{"title":"First","description":"A"},"2":{"title":"Second","description":"B"}}</options>' - mocks.requestRaw - .mockResolvedValueOnce( - completed(options, 'token-1', [options.slice(0, 31), options.slice(31)]) - ) - .mockResolvedValueOnce(completed('Done', 'token-2', ['Do', 'ne'])) - const terminal = new FakeTerminal([ - { kind: 'line', value: 'A different request' }, - { kind: 'line', value: '/exit' }, - ]) - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - formatMarkdown: () => false, - }).parseAsync(['node', 'sim', 'chat', 'start']) - - expect(mocks.requestRaw).toHaveBeenCalledTimes(2) - expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: 'A different request', - continuationToken: 'token-1', - }) - expect(terminal.writes.join('')).toBe('Done\n') - expect(terminal.statuses.join('\n')).not.toContain('Suggested follow-ups') - expect(terminal.statuses.join('\n')).not.toContain('First') - expect(terminal.reads[0]).toEqual({ prompt: '❯ ', initialValue: '' }) - expect(terminal.userMessages).toEqual(['start']) - expect(terminal.closed).toBe(true) - }) - - it.each([ - ['plain trailing whitespace', 'Answer\n\n'], - [ - 'whitespace before hidden options', - 'Answer\n\n<options>{"1":{"title":"Next","description":"Continue"}}</options>\n\n', - ], - ])('hands %s to the next composer with exactly one newline', async (_name, content) => { - mocks.requestRaw.mockResolvedValue(completed(content, 'token-1', [content])) - const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'start']) - - expect(terminal.writes.join('')).toBe('Answer\n') - expect(terminal.reads).toEqual([{ prompt: '❯ ', initialValue: '' }]) - }) - - it('renders tagged resource bullets as plain names without links or undefined prefixes', async () => { - const content = [ - 'Workflows\n', - '- <workspace_resource>{"type":"workflow","id":"wf-1","title":"default-agent"}</workspace_resource>\n', - '- <workspace_resource>{"type":"workflow","id":"wf-2","title":"forceful-arm"}</workspace_resource>', - ].join('') - mocks.requestRaw.mockResolvedValue(completed(content, 'token-1', [content])) - const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - formatMarkdown: () => true, - }).parseAsync(['node', 'sim', 'chat', 'list resources']) - - const rendered = terminal.writes.join('') - expect(rendered).toContain('default-agent') - expect(rendered).toContain('forceful-arm') - expect(rendered).not.toContain('undefined') - expect(rendered).not.toContain('https://') - expect(rendered).not.toContain(`${String.fromCharCode(27)}]8;;`) - }) - - it('omits a trailing standalone workspace link that has no terminal action', async () => { - const resource = - '<workspace_resource>{"type":"workflow","id":"wf-forceful","title":"forceful-arm"}</workspace_resource>' - const content = [ - 'Three blocks, mostly a stub:\n\n', - '- Start — manual trigger.\n', - '- Router 1 — always routes hi.\n', - '- Agent 1 — replies to hi.\n\n', - resource, - ].join('') - mocks.requestRaw.mockResolvedValue( - completed(content, 'token-1', [ - content.slice(0, content.indexOf('<workspace_resource>') + 12), - content.slice(content.indexOf('<workspace_resource>') + 12, -8), - content.slice(-8), - ]) - ) - const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - formatMarkdown: () => false, - }).parseAsync(['node', 'sim', 'chat', 'inspect forceful-arm']) - - const rendered = terminal.writes.join('') - expect(rendered).toContain('Three blocks, mostly a stub:') - expect(rendered).toContain('- Agent 1 — replies to hi.') - expect(rendered).not.toContain('forceful-arm') - expect(rendered.endsWith('\n')).toBe(true) - }) - - it('restores a deferred workspace link when a later chunk continues the answer', async () => { - const resource = - '<workspace_resource>{"type":"workflow","id":"wf-forceful","title":"forceful-arm"}</workspace_resource>' - const first = `Summary.\n\n${resource}` - const content = `${first}\nThen continue.` - mocks.requestRaw.mockResolvedValue(completed(content, 'token-1', [first, '\nThen continue.'])) - const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - formatMarkdown: () => false, - }).parseAsync(['node', 'sim', 'chat', 'inspect forceful-arm']) - - expect(terminal.writes.join('')).toBe('Summary.\n\nforceful-arm\nThen continue.\n') - }) - - it('uses the dedicated question panel and sends its answer with the continuation token', async () => { - const question = - '<question>{"type":"single_select","prompt":"Which service should I inspect?","options":[{"id":"api","label":"API"},{"id":"worker","label":"Worker"}]}</question>' - mocks.requestRaw - .mockResolvedValueOnce(completed(question, 'token-1')) - .mockResolvedValueOnce(completed('Done', 'token-2')) - const terminal = new FakeTerminal( - [{ kind: 'line', value: '/exit' }], - [{ kind: 'answer', values: ['Worker'] }] - ) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'start']) - - expect(terminal.questions).toEqual([ - { - prompt: 'Which service should I inspect?', - multi: false, - options: [ - { id: 'api', label: 'API' }, - { id: 'worker', label: 'Worker' }, - ], - }, - ]) - expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: 'Which service should I inspect? — Worker', - continuationToken: 'token-1', - }) - }) - - it('runs queued local commands before presenting a retained structured question', async () => { - const question = - '<question>{"type":"single_select","prompt":"Proceed?","options":[{"id":"yes","label":"Yes"}]}</question>' - mocks.requestRaw - .mockResolvedValueOnce(completed(question, 'token-1')) - .mockResolvedValueOnce(completed('Done', 'token-2')) - const terminal = new FakeTerminal( - [ - { kind: 'line', value: '/help', queued: true, display: '/help' }, - { kind: 'line', value: '/exit' }, - ], - [{ kind: 'answer', values: ['Yes'] }] - ) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'start']) - - expect(terminal.statuses.join('\n')).toContain('Commands:') - expect(terminal.questions).toHaveLength(1) - expect(mocks.requestRaw.mock.calls.map(([, options]) => options.body.prompt)).toEqual([ - 'start', - 'Proceed? — Yes', - ]) - }) - - it('attaches a queued path without answering a retained question', async () => { - const question = - '<question>{"type":"single_select","prompt":"Proceed?","options":[{"id":"yes","label":"Yes"}]}</question>' - const attachment: ChatAttachment = { - name: 'report.txt', - mediaType: 'text/plain', - data: 'cmVwb3J0', - } - mocks.requestRaw - .mockResolvedValueOnce(completed(question, 'token-1')) - .mockResolvedValueOnce(completed('Done', 'token-2')) - const terminal = new FakeTerminal( - [ - { - kind: 'line', - value: '/private/tmp/report.txt', - queued: true, - display: '/private/tmp/report.txt', - }, - { kind: 'line', value: '/exit' }, - ], - [{ kind: 'answer', values: ['Yes'] }] - ) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - extractAttachmentPaths: async (value: string) => - value === '/private/tmp/report.txt' - ? { paths: ['/private/tmp/report.txt'], text: '[File #1]' } - : null, - loadAttachments: async () => [attachment], - }).parseAsync(['node', 'sim', 'chat', 'start']) - - expect(terminal.questions).toHaveLength(1) - expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: 'Proceed? — Yes', - continuationToken: 'token-1', - }) - }) - - it('honors a queued exit before opening a structured question', async () => { - const question = - '<question>{"type":"single_select","prompt":"Proceed?","options":[{"id":"yes","label":"Yes"}]}</question>' - mocks.requestRaw.mockResolvedValueOnce(completed(question, 'token-1')) - const terminal = new FakeTerminal([ - { kind: 'line', value: '/exit', queued: true, display: '/exit' }, - ]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'start']) - - expect(terminal.questions).toEqual([]) - expect(mocks.requestRaw).toHaveBeenCalledTimes(1) - }) - - it('submits question arrays and multi-selects in the Mothership answer format', async () => { - const questions = - '<question>[{"type":"single_select","prompt":"Environment?","options":[{"id":"dev","label":"Dev"},{"id":"prod","label":"Prod"}]},{"type":"multi_select","prompt":"Services?","options":[{"id":"api","label":"API"},{"id":"worker","label":"Worker"}]}]</question>' - mocks.requestRaw - .mockResolvedValueOnce(completed(questions, 'token-1')) - .mockResolvedValueOnce(completed('Done', 'token-2')) - const terminal = new FakeTerminal( - [{ kind: 'line', value: '/exit' }], - [ - { kind: 'answer', values: ['Prod'] }, - { kind: 'answer', values: ['API', 'custom service'] }, - ] - ) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'start']) - - expect(terminal.statuses).toEqual(['Question 1 of 2', 'Question 2 of 2']) - expect(mocks.requestRaw.mock.calls[1][1].body.prompt).toBe( - 'Environment? — Prod\nServices? — API, custom service' - ) - }) - - it('never interprets a model-authored question answer as a local slash command', async () => { - const question = - '<question>{"type":"single_select","prompt":"Proceed?","options":[{"id":"bad","label":"/attach /secret"}]}</question>' - mocks.requestRaw - .mockResolvedValueOnce(completed(question, 'token-1')) - .mockResolvedValueOnce(completed('Done', 'token-2')) - const terminal = new FakeTerminal( - [{ kind: 'line', value: '/exit' }], - [{ kind: 'answer', values: ['/attach /secret'] }] - ) - const loadAttachments = vi.fn(async () => []) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - loadAttachments, - }).parseAsync(['node', 'sim', 'chat', 'start']) - - expect(loadAttachments).toHaveBeenCalledOnce() - expect(loadAttachments).toHaveBeenCalledWith([]) - expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: 'Proceed? — /attach /secret', - continuationToken: 'token-1', - }) - }) - - it('submits arbitrary composer text unchanged after stripped options', async () => { - const options = '<options>{"1":{"title":"Inspect logs","description":"Find errors"}}</options>' - mocks.requestRaw - .mockResolvedValueOnce(completed(options, 'token-1')) - .mockResolvedValueOnce(completed('Done', 'token-2')) - const terminal = new FakeTerminal([ - { kind: 'line', value: 'Ask a completely different question' }, - { kind: 'line', value: '/exit' }, - ]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'start']) - - expect(terminal.reads[0].prompt).toBe('❯ ') - expect(mocks.requestRaw.mock.calls[1][1].body.prompt).toBe( - 'Ask a completely different question' - ) - }) - - it('attaches a pasted path inline and sends the surrounding text', async () => { - const attachment: ChatAttachment = { - name: 'report.txt', - mediaType: 'text/plain', - data: 'cmVwb3J0', - } - const absolutePath = '/private/tmp/report.txt' - const terminal = new FakeTerminal([ - { kind: 'line', value: `Inspect ${absolutePath} closely` }, - { kind: 'line', value: '/exit' }, - ]) - mocks.requestRaw.mockResolvedValue(completed('Done')) - const extractAttachmentPaths = vi.fn(async (value: string) => - value.includes(absolutePath) - ? { paths: [absolutePath], text: value.replace(absolutePath, '[File #1]') } - : null - ) - const loadAttachments = vi.fn(async (paths: string[]) => (paths.length ? [attachment] : [])) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - extractAttachmentPaths, - loadAttachments, - }).parseAsync(['node', 'sim', 'chat']) - - expect(loadAttachments).toHaveBeenCalledWith([absolutePath]) - expect(terminal.preloads).toEqual([]) - expect(terminal.statuses.some((status) => status.startsWith('Unknown command:'))).toBe(false) - expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: 'Inspect [File #1] closely', - attachments: [attachment], - }) - }) - - it('never reads a path out of a slash command', async () => { - const absolutePath = '/private/tmp/private.txt' - const terminal = new FakeTerminal([ - { kind: 'line', value: `/rename ${absolutePath}` }, - { kind: 'line', value: '/exit' }, - ]) - const extractAttachmentPaths = vi.fn(async () => ({ - paths: [absolutePath], - text: '[File #1]', - })) - const loadAttachments = vi.fn(async () => []) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - extractAttachmentPaths, - loadAttachments, - }).parseAsync(['node', 'sim', 'chat']) - - expect(extractAttachmentPaths).not.toHaveBeenCalled() - expect(loadAttachments).not.toHaveBeenCalledWith([absolutePath]) - expect(mocks.requestRaw).not.toHaveBeenCalled() - }) - - it('preserves draft text when Ctrl+V attaches a clipboard image', async () => { - const attachment: ChatAttachment = { - name: 'clipboard.png', - mediaType: 'image/png', - data: 'iVBORw0KGgo=', - } - const terminal = new FakeTerminal([ - { kind: 'clipboard', value: 'explain this' }, - { kind: 'line', value: 'explain this' }, - { kind: 'line', value: '/exit' }, - ]) - mocks.requestRaw.mockResolvedValue(completed('Done')) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - clipboardAttachment: async () => attachment, - extractAttachmentPaths: async () => null, - }).parseAsync(['node', 'sim', 'chat']) - - expect(terminal.reads[1].initialValue).toBe('') - expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: 'explain this', - attachments: [attachment], - }) - }) - - it('aborts an active HTTP turn on Ctrl+C and returns to the prompt', async () => { - const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) - let requestSignal: AbortSignal | undefined - mocks.requestRaw.mockImplementation( - (_path: string, options: { signal: AbortSignal }) => - new Promise((_resolve, reject) => { - requestSignal = options.signal - options.signal.addEventListener('abort', () => reject(new Error('aborted')), { - once: true, - }) - queueMicrotask(() => terminal.interrupt()) - }) - ) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'long request']) - - expect(requestSignal?.aborted).toBe(true) - expect(terminal.statuses).toContain('Generation cancelled.') - expect(terminal.reads.at(-1)?.prompt).toBe('❯ ') - }) - - it('steers an active turn with the early continuation token and no attachment replay', async () => { - const attachment: ChatAttachment = { - name: 'notes.txt', - mediaType: 'text/plain', - data: 'bm90ZXM=', - } - const terminal = new FakeTerminal([ - { - kind: 'line', - value: 'change direction', - queued: true, - display: 'change direction', - }, - { kind: 'line', value: '/exit' }, - ]) - const order: string[] = [] - const interrupt = vi.spyOn(terminal, 'interrupt') - let firstRequestSignal: AbortSignal | undefined - - mocks.requestRaw - .mockImplementationOnce((_path: string, options: { signal: AbortSignal }) => { - firstRequestSignal = options.signal - return Promise.resolve( - new Response( - new ReadableStream<Uint8Array>({ - start(controller) { - order.push('session') - controller.enqueue( - new TextEncoder().encode( - 'event: session\ndata: {"type":"session","continuationToken":"token-before-complete"}\n\n' - ) - ) - options.signal.addEventListener( - 'abort', - () => { - order.push('abort') - controller.error(new Error('aborted')) - }, - { once: true } - ) - setImmediate(() => { - order.push('submit') - terminal.interrupt('submit') - }) - }, - }) - ) - ) - }) - .mockImplementationOnce(async () => { - order.push('follow-up') - return completed('Redirected', 'token-2') - }) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - loadAttachments: async () => [attachment], - }).parseAsync(['node', 'sim', 'chat', '--file', '/local/notes.txt', 'inspect']) - - expect(mocks.requestRaw).toHaveBeenCalledTimes(2) - expect(interrupt).toHaveBeenCalledTimes(1) - expect(interrupt).toHaveBeenCalledWith('submit') - expect(firstRequestSignal?.aborted).toBe(true) - expect(order).toEqual(['session', 'submit', 'abort', 'follow-up']) - expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: 'change direction', - continuationToken: 'token-before-complete', - }) - expect(terminal.statuses).not.toContain('Generation cancelled.') - expect(terminal.preloads).toEqual([]) - }) - - it('steers the active turn with a queued line carrying a file path', async () => { - const pathInput = { - kind: 'line' as const, - value: 'report.txt', - queued: true, - display: 'report.txt', - } - const terminal = new FakeTerminal([pathInput, { kind: 'line', value: '/exit' }]) - let requestSignal: AbortSignal | undefined - const extractAttachmentPaths = vi.fn(async (value: string) => - value === 'report.txt' ? { paths: ['report.txt'], text: '[File #1]' } : null - ) - mocks.requestRaw.mockImplementationOnce( - async (_path: string, options: { signal: AbortSignal }) => { - requestSignal = options.signal - terminal.interrupt('submit', pathInput) - await new Promise((resolve) => setImmediate(resolve)) - return completed('Finished normally', 'token-1') - } - ) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - extractAttachmentPaths, - }).parseAsync(['node', 'sim', 'chat', 'original']) - - expect(requestSignal?.aborted).toBe(true) - expect(terminal.preloads).toEqual([{ value: 'report.txt', queued: true }]) - }) - - it('queues /chats without interrupting the active stream', async () => { - const chatsInput = { - kind: 'line' as const, - value: '/chats', - queued: true, - display: '/chats', - } - const terminal = new FakeTerminal( - [chatsInput, { kind: 'line', value: '/exit' }], - [], - [{ kind: 'cancel' }] - ) - let requestSignal: AbortSignal | undefined - mocks.requestRaw.mockImplementationOnce( - async (_path: string, options: { signal: AbortSignal }) => { - requestSignal = options.signal - terminal.interrupt('submit', chatsInput) - await new Promise((resolve) => setImmediate(resolve)) - return completed('Finished normally', 'token-1') - } - ) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'original']) - - expect(requestSignal?.aborted).toBe(false) - expect(terminal.selections).toHaveLength(1) - expect(mocks.requestRaw).toHaveBeenCalledTimes(1) - const listRequest = mocks.request.mock.calls.find(([path]) => path === '/api/v2/chats') - expect(listRequest?.[1]).toMatchObject({ - query: { workspaceId: 'ws_local', limit: 100, cursor: null }, - }) - expect(listRequest?.[1]?.query).not.toHaveProperty('search') - }) - - it('waits for the first session token before interrupting a fast queued steer', async () => { - const attachment: ChatAttachment = { - name: 'notes.txt', - mediaType: 'text/plain', - data: 'bm90ZXM=', - } - const terminal = new FakeTerminal([ - { - kind: 'line', - value: 'change direction', - queued: true, - display: 'change direction', - }, - { kind: 'line', value: '/exit' }, - ]) - let abortedBeforeSession = false - - mocks.requestRaw - .mockImplementationOnce( - (_path: string, options: { signal: AbortSignal }) => - new Promise((resolve) => { - queueMicrotask(() => { - terminal.interrupt('submit') - abortedBeforeSession = options.signal.aborted - resolve( - new Response( - new ReadableStream<Uint8Array>({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"session","continuationToken":"first-token"}\n\n' - ) - ) - options.signal.addEventListener( - 'abort', - () => controller.error(new Error('aborted')), - { once: true } - ) - }, - }) - ) - ) - }) - }) - ) - .mockResolvedValueOnce(completed('Redirected', 'token-2')) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - loadAttachments: async () => [attachment], - }).parseAsync(['node', 'sim', 'chat', '--file', '/local/notes.txt', 'inspect']) - - expect(abortedBeforeSession).toBe(false) - expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: 'change direction', - continuationToken: 'first-token', - }) - expect(terminal.statuses).not.toContain('Generation cancelled.') - }) - - it('keeps the original attachments when setup fails before a session is accepted', async () => { - const attachment: ChatAttachment = { - name: 'notes.txt', - mediaType: 'text/plain', - data: 'bm90ZXM=', - } - const followUp = { - kind: 'line' as const, - value: 'retry with context', - queued: true, - display: 'retry with context', - } - const terminal = new FakeTerminal([followUp, { kind: 'line', value: '/exit' }]) - mocks.requestRaw - .mockImplementationOnce(() => - Promise.resolve( - new Response( - new ReadableStream<Uint8Array>({ - start(controller) { - terminal.interrupt('submit', followUp) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"error","error":{"code":"INTERNAL_ERROR","message":"Chat request failed"}}\n\n' - ) - ) - controller.close() - }, - }) - ) - ) - ) - .mockResolvedValueOnce(completed('Retried', 'token-2')) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - loadAttachments: async () => [attachment], - }).parseAsync(['node', 'sim', 'chat', '--file', '/local/notes.txt', 'inspect']) - - expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: 'retry with context', - attachments: [attachment], - }) - expect(terminal.statuses).toContain('Error: Chat request failed (INTERNAL_ERROR)') - }) - - it('does not replay attachments after an accepted turn fails', async () => { - const attachment: ChatAttachment = { - name: 'notes.txt', - mediaType: 'text/plain', - data: 'bm90ZXM=', - } - const terminal = new FakeTerminal([ - { kind: 'line', value: 'continue without replaying it' }, - { kind: 'line', value: '/exit' }, - ]) - mocks.requestRaw - .mockResolvedValueOnce( - sse([ - 'data: {"type":"session","continuationToken":"token-1"}\n\n', - 'data: {"type":"error","error":{"code":"INTERNAL_ERROR","message":"Chat request failed"}}\n\n', - ]) - ) - .mockResolvedValueOnce(completed('Continued', 'token-2')) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - loadAttachments: async () => [attachment], - }).parseAsync(['node', 'sim', 'chat', '--file', '/local/notes.txt', 'inspect']) - - expect(mocks.requestRaw.mock.calls[0][1].body.attachments).toEqual([attachment]) - expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: 'continue without replaying it', - continuationToken: 'token-1', - }) - expect(terminal.preloads).toEqual([]) - }) - - it('drains already-submitted turns before presenting an earlier turn question', async () => { - const question = - '<question>{"type":"single_select","prompt":"Pause for this?","options":[{"id":"yes","label":"Yes"}]}</question>' - const firstQueued = { - kind: 'line' as const, - value: 'first queued', - queued: true, - display: 'first queued', - } - const terminal = new FakeTerminal([ - firstQueued, - { kind: 'line', value: 'second queued', queued: true, display: 'second queued' }, - { kind: 'line', value: '/exit' }, - ]) - - mocks.requestRaw - .mockImplementationOnce((_path: string, options: { signal: AbortSignal }) => - Promise.resolve( - new Response( - new ReadableStream<Uint8Array>({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"session","continuationToken":"token-1"}\n\n' - ) - ) - options.signal.addEventListener( - 'abort', - () => controller.error(new Error('aborted')), - { once: true } - ) - setImmediate(() => terminal.interrupt('submit', firstQueued)) - }, - }) - ) - ) - ) - .mockResolvedValueOnce(completed(question, 'token-2')) - .mockResolvedValueOnce(completed('Done', 'token-3')) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'original']) - - expect(mocks.requestRaw.mock.calls.map(([, options]) => options.body.prompt)).toEqual([ - 'original', - 'first queued', - 'second queued', - ]) - expect(terminal.questions).toEqual([]) - }) - - it('does not move queued prompts into another conversation', async () => { - const terminal = new FakeTerminal([ - { kind: 'line', value: 'first' }, - { kind: 'line', value: '/new', queued: true, display: '/new' }, - { kind: 'line', value: 'second', queued: true, display: 'second' }, - { kind: 'line', value: '/chats', queued: true, display: '/chats' }, - { kind: 'line', value: 'third', queued: true, display: 'third' }, - { kind: 'line', value: '/exit' }, - ]) - mocks.requestRaw - .mockResolvedValueOnce(completed('First', 'token-1')) - .mockResolvedValueOnce(completed('Second', 'token-2')) - .mockResolvedValueOnce(completed('Third', 'token-3')) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat']) - - expect(mocks.requestRaw.mock.calls.map(([, options]) => options.body)).toEqual([ - { workspaceId: 'ws_local', prompt: 'first' }, - { workspaceId: 'ws_local', prompt: 'second', continuationToken: 'token-1' }, - { workspaceId: 'ws_local', prompt: 'third', continuationToken: 'token-2' }, - ]) - expect(terminal.statuses).toEqual([ - 'Finish queued prompts before changing conversations.', - 'Finish queued prompts before changing conversations.', - ]) - expect(mocks.request.mock.calls.some(([path]) => path === '/api/v2/chats')).toBe(false) - }) - - it('restores a queued head ahead of later input when the handoff lease is still busy', async () => { - const terminal = new FakeTerminal([ - { kind: 'line', value: 'retry me', queued: true, display: 'retry me' }, - { kind: 'line', value: 'retry me' }, - { kind: 'line', value: '/exit' }, - ]) - mocks.requestRaw - .mockRejectedValueOnce( - new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') - ) - .mockResolvedValueOnce(completed('Retried', 'token-2')) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat']) - - expect(terminal.preloads).toContainEqual({ value: 'retry me', queued: true }) - expect(terminal.statuses).toContain( - 'Previous response is still settling. Press Enter to retry.' - ) - expect(mocks.requestRaw).toHaveBeenCalledTimes(2) - expect(mocks.requestRaw.mock.calls[1][1].body.prompt).toBe('retry me') - }) - - it('restores a normally submitted prompt after a pre-session conflict', async () => { - const terminal = new FakeTerminal([ - { kind: 'line', value: 'retry me' }, - { kind: 'line', value: 'retry me' }, - { kind: 'line', value: '/exit' }, - ]) - mocks.requestRaw - .mockRejectedValueOnce( - new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') - ) - .mockResolvedValueOnce(completed('Retried', 'token-2')) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat']) - - expect(terminal.preloads).toContainEqual({ value: 'retry me', queued: true }) - expect(terminal.statuses).toContain( - 'Previous response is still settling. Press Enter to retry.' - ) - expect(mocks.requestRaw).toHaveBeenCalledTimes(2) - }) - - it('automatically retries one queued continuation conflict', async () => { - const contexts: ChatContext[] = [ - { kind: 'workflow', workflowId: 'workflow-1', label: 'Release' }, - ] - const terminal = new FakeTerminal([ - { - kind: 'line', - value: 'retry @Release', - queued: true, - display: 'retry @Release', - contexts, - }, - { kind: 'line', value: '/exit' }, - ]) - mocks.requestRaw - .mockResolvedValueOnce(completed('Original', 'token-1')) - .mockRejectedValueOnce( - new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') - ) - .mockResolvedValueOnce(completed('Retried', 'token-2')) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'original']) - - expect(mocks.requestRaw.mock.calls.map(([, options]) => options.body.prompt)).toEqual([ - 'original', - 'retry @Release', - 'retry @Release', - ]) - expect(mocks.requestRaw.mock.calls[2][1].body).toMatchObject({ - continuationToken: 'token-1', - contexts, - }) - expect(terminal.preloads).toEqual([]) - expect(terminal.statuses).toContain('Previous response is still settling. Retrying…') - expect(terminal.statuses).not.toContain( - 'Previous response is still settling. Press Enter to retry.' - ) - }) - - it('bounds queued continuation conflict retries and restores the exact tagged input', async () => { - const contexts: ChatContext[] = [{ kind: 'skill', skillId: 'skill-1', label: 'review' }] - const terminal = new FakeTerminal([ - { - kind: 'line', - value: '/review this', - queued: true, - display: '/review this', - contexts, - }, - { kind: 'line', value: '/exit' }, - ]) - mocks.requestRaw - .mockResolvedValueOnce(completed('Original', 'token-1')) - .mockRejectedValueOnce( - new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') - ) - .mockRejectedValueOnce( - new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') - ) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'original']) - - expect(mocks.requestRaw).toHaveBeenCalledTimes(3) - expect(terminal.preloads).toContainEqual({ - value: '/review this', - queued: true, - contexts, - }) - expect(terminal.statuses).toContain( - 'Previous response is still settling. Press Enter to retry.' - ) - }) - - it('carries queued large-paste bodies into a conflict retry', async () => { - const pasted = 'p'.repeat(900) - const pastes = new Map([[1, pasted]]) - const terminal = new FakeTerminal([ - { - kind: 'line', - value: pasted, - queued: true, - display: '[Pasted text #1]', - pastes, - }, - { kind: 'line', value: pasted, queued: true, display: '[Pasted text #1]', pastes }, - { kind: 'line', value: '/exit' }, - ]) - mocks.requestRaw - .mockRejectedValueOnce( - new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') - ) - .mockResolvedValueOnce(completed('Retried', 'token-2')) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat']) - - expect(terminal.preloads[0]).toMatchObject({ - value: '[Pasted text #1]', - queued: true, - pastes, - }) - expect(mocks.requestRaw.mock.calls[1][1].body.prompt).toBe(pasted) - }) - - it('restores pending attachments after Ctrl+C so a retry can send them', async () => { - const attachment: ChatAttachment = { - name: 'notes.txt', - mediaType: 'text/plain', - data: 'bm90ZXM=', - } - const terminal = new FakeTerminal([ - { kind: 'line', value: 'retry' }, - { kind: 'line', value: '/exit' }, - ]) - mocks.requestRaw - .mockImplementationOnce( - (_path: string, options: { signal: AbortSignal }) => - new Promise((_resolve, reject) => { - options.signal.addEventListener('abort', () => reject(new Error('aborted')), { - once: true, - }) - queueMicrotask(() => terminal.interrupt()) - }) - ) - .mockResolvedValueOnce(completed('Retried')) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - loadAttachments: async () => [attachment], - }).parseAsync(['node', 'sim', 'chat', '--file', '/local/notes.txt', 'inspect']) - - expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: 'retry', - attachments: [attachment], - }) - }) - - it('reports a failed turn and restores its attachments for the next prompt', async () => { - const attachment: ChatAttachment = { - name: 'notes.txt', - mediaType: 'text/plain', - data: 'bm90ZXM=', - } - const terminal = new FakeTerminal([ - { kind: 'line', value: 'retry' }, - { kind: 'line', value: '/exit' }, - ]) - mocks.requestRaw - .mockRejectedValueOnce(new SimApiError('Temporarily\nunavailable', 503, 'UNAVAILABLE')) - .mockResolvedValueOnce(completed('Retried')) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - loadAttachments: async () => [attachment], - }).parseAsync(['node', 'sim', 'chat', '--file', '/local/notes.txt', 'inspect']) - - expect(terminal.statuses).toContain('Error: Temporarily unavailable (UNAVAILABLE)') - expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: 'retry', - attachments: [attachment], - }) - }) - - it('parses an authoritative completion suffix omitted from text deltas', async () => { - const options = '<options>{"1":{"title":"Continue","description":"Go"}}</options>' - mocks.requestRaw - .mockResolvedValueOnce(completed(`Hello${options}`, 'token-1', ['Hello'])) - .mockResolvedValueOnce(completed('Done', 'token-2')) - const terminal = new FakeTerminal([ - { kind: 'line', value: 'Continue' }, - { kind: 'line', value: '/exit' }, - ]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'start']) - - expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ - workspaceId: 'ws_local', - prompt: 'Continue', - continuationToken: 'token-1', - }) - }) - - it('sanitizes streamed plain deltas before stdout', async () => { - const terminalEscape = String.fromCharCode(27) - mocks.requestRaw.mockResolvedValue( - completed(`Safe${terminalEscape}]0;owned\u0007 answer`, 'token', [ - `Safe${terminalEscape}]0;`, - 'owned\u0007 answer', - ]) - ) - const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'question']) - - expect(terminal.writes.join('')).toBe('Safeowned answer\n') - expect(terminal.writes.join('')).not.toContain(terminalEscape) - }) - - it('forwards sanitized thinking and ordered activity transitions to the terminal', async () => { - const terminalEscape = String.fromCharCode(27) - mocks.requestRaw.mockResolvedValue( - sse([ - `event: thinking\ndata: ${JSON.stringify({ - type: 'thinking', - delta: `Inspect${terminalEscape}]0;owned\u0007 workspace`, - })}\n\n`, - `event: activity\ndata: ${JSON.stringify({ - type: 'activity', - data: { - kind: 'subagent', - id: 'agent-1', - label: 'Research\nagent', - state: 'running', - }, - })}\n\n`, - `event: activity\ndata: ${JSON.stringify({ - type: 'activity', - data: { - kind: 'subagent', - id: 'agent-1', - label: 'Research agent', - state: 'complete', - }, - })}\n\n`, - 'event: text\ndata: {"type":"text","delta":"Done"}\n\n', - 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"token"}}\n\n', - ]) - ) - const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - }).parseAsync(['node', 'sim', 'chat', 'question']) - - expect(terminal.thinking).toEqual(['Inspect workspace']) - expect(terminal.activities).toEqual([ - { - kind: 'subagent', - id: 'agent-1', - label: 'Research agent', - state: 'running', - }, - { - kind: 'subagent', - id: 'agent-1', - label: 'Research agent', - state: 'complete', - }, - ]) - }) - - it('forwards nested subagent narration and tool seams without mixing them into the answer', async () => { - const terminalEscape = String.fromCharCode(27) - mocks.requestRaw.mockResolvedValue( - sse([ - `event: activity\ndata: ${JSON.stringify({ - type: 'activity', - data: { - kind: 'subagent', - id: 'agent-1', - label: 'Build Agent', - state: 'running', - }, - })}\n\n`, - `event: activity\ndata: ${JSON.stringify({ - type: 'activity', - data: { - kind: 'narration', - parentId: 'agent-1', - delta: `Inspect${terminalEscape}]0;owned\u0007ing `, - }, - })}\n\n`, - `event: activity\ndata: ${JSON.stringify({ - type: 'activity', - data: { kind: 'narration', parentId: 'agent-1', delta: 'workspace' }, - })}\n\n`, - `event: activity\ndata: ${JSON.stringify({ - type: 'activity', - data: { - kind: 'tool', - id: 'tool-1', - parentId: 'agent-1', - label: 'Read file', - state: 'complete', - }, - })}\n\n`, - `event: activity\ndata: ${JSON.stringify({ - type: 'activity', - data: { kind: 'narration', parentId: 'agent-1', delta: 'After tool' }, - })}\n\n`, - `event: activity\ndata: ${JSON.stringify({ - type: 'activity', - data: { - kind: 'subagent', - id: 'agent-1', - label: 'Build Agent', - state: 'complete', - }, - })}\n\n`, - 'event: text\ndata: {"type":"text","delta":"Final answer"}\n\n', - 'event: complete\ndata: {"type":"complete","data":{"content":"Final answer","continuationToken":"token"}}\n\n', - ]) - ) - const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) - - await program(async () => '', vi.fn(), { - isInteractive: () => true, - createTerminal: () => terminal, - formatMarkdown: () => false, - }).parseAsync(['node', 'sim', 'chat', 'question']) - - expect(terminal.activities).toEqual([ - { - kind: 'subagent', - id: 'agent-1', - label: 'Build Agent', - state: 'running', - }, - { kind: 'narration', parentId: 'agent-1', delta: 'Inspecting ' }, - { kind: 'narration', parentId: 'agent-1', delta: 'workspace' }, - { - kind: 'tool', - id: 'tool-1', - parentId: 'agent-1', - label: 'Read file', - state: 'complete', - }, - { kind: 'narration', parentId: 'agent-1', delta: 'After tool' }, - { - kind: 'subagent', - id: 'agent-1', - label: 'Build Agent', - state: 'complete', - }, - ]) - expect(terminal.writes.join('')).toBe('Final answer\n') - expect(terminal.writes.join('')).not.toContain('Inspecting') - }) -}) - -describe('chat SSE reader', () => { - it('delivers thinking, activity, and text callbacks in wire order', async () => { - const callbacks: string[] = [] - const response = sse([ - 'event: thinking\ndata: {"type":"thinking","delta":"Planning"}\n\n', - 'event: activity\ndata: {"type":"activity","data":{"kind":"tool","id":"tool-1","label":"Read\\nworkflow","state":"running"}}\n\n', - 'event: activity\ndata: {"type":"activity","data":{"kind":"tool","id":"tool-1","label":"Read workflow","state":"complete"}}\n\n', - 'event: text\ndata: {"type":"text","delta":"Answer"}\n\n', - 'event: complete\ndata: {"type":"complete","data":{"content":"Answer","continuationToken":"token"}}\n\n', - ]) - - const result = await readChatTurn(response, { - onThinking: (delta) => { - callbacks.push(`thinking:${delta}`) - }, - onActivity: (activity) => { - callbacks.push( - activity.kind === 'narration' - ? `${activity.kind}:${activity.parentId}:${activity.delta}` - : `${activity.kind}:${activity.label}:${activity.state}` - ) - }, - onDelta: (delta) => { - callbacks.push(`text:${delta}`) - }, - }) - - expect(callbacks).toEqual([ - 'thinking:Planning', - 'tool:Read workflow:running', - 'tool:Read workflow:complete', - 'text:Answer', - ]) - expect(result.content).toBe('Answer') - }) - - it('parses parented narration and nested tools without adding scoped text to content', async () => { - const terminalEscape = String.fromCharCode(27) - const activities: ChatActivityUpdate[] = [] - const response = sse([ - `event: activity\ndata: ${JSON.stringify({ - type: 'activity', - data: { - kind: 'subagent', - id: 'agent-1', - label: 'Build\nAgent', - state: 'running', - }, - })}\n\n`, - `event: activity\ndata: ${JSON.stringify({ - type: 'activity', - data: { - kind: 'narration', - parentId: 'agent-1', - delta: `Line one\n\nLine${terminalEscape}]0;owned\u0007 two`, - }, - })}\n\n`, - `event: activity\ndata: ${JSON.stringify({ - type: 'activity', - data: { - kind: 'tool', - id: 'tool-1', - parentId: 'agent-1', - label: 'Read file', - state: 'complete', - }, - })}\n\n`, - 'event: text\ndata: {"type":"text","delta":"Answer"}\n\n', - 'event: complete\ndata: {"type":"complete","data":{"content":"Answer","continuationToken":"token"}}\n\n', - ]) - - const result = await readChatTurn(response, { - onActivity: (activity) => { - activities.push(activity) - }, - }) - - expect(activities).toEqual([ - { - kind: 'subagent', - id: 'agent-1', - label: 'Build Agent', - state: 'running', - }, - { - kind: 'narration', - parentId: 'agent-1', - delta: 'Line one\n\nLine two', - }, - { - kind: 'tool', - id: 'tool-1', - parentId: 'agent-1', - label: 'Read file', - state: 'complete', - }, - ]) - expect(result).toEqual({ - content: 'Answer', - streamedContent: 'Answer', - continuationToken: 'token', - }) - }) - - it('falls back to text deltas and uses the completion continuation token', async () => { - const response = sse([ - 'event: session\ndata: {"type":"session","continuationToken":"session-token"}\n\n', - 'event: text\r\ndata: {"type":"text","delta":"one"}\r\n\r\n', - 'event: text\ndata: {"type":"text","delta":" two"}\n\n', - 'event: complete\ndata: {"type":"complete","data":{"continuationToken":"complete-token"}}\n\n', - 'data: [DONE]\n\n', - ]) - - await expect(readChatTurn(response)).resolves.toEqual({ - content: 'one two', - streamedContent: 'one two', - continuationToken: 'complete-token', - }) - }) - - it('exposes the session continuation token before completion', async () => { - const tokens: string[] = [] - const response = sse([ - 'event: session\ndata: {"type":"session","continuationToken":"session-token"}\n\n', - 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"complete-token"}}\n\n', - ]) - - await readChatTurn(response, { - onContinuationToken: (token) => { - tokens.push(token) - }, - }) - - expect(tokens).toEqual(['session-token']) - }) - - it('exposes the shared chat id from the session event', async () => { - const chatIds: string[] = [] - const response = sse([ - 'event: session\ndata: {"type":"session","chatId":"chat-1","continuationToken":"session-token"}\n\n', - 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"complete-token"}}\n\n', - ]) - - await readChatTurn(response, { - onChatId: (chatId) => { - chatIds.push(chatId) - }, - }) - - expect(chatIds).toEqual(['chat-1']) - }) - - it('exposes a sanitized generated title from session events', async () => { - const titles: string[] = [] - const response = sse([ - 'event: session\ndata: {"type":"session","title":"Release\\u001b]0;owned\\u0007 investigation"}\n\n', - 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"complete-token"}}\n\n', - ]) - - await readChatTurn(response, { - onTitle: (title) => { - titles.push(title) - }, - }) - - expect(titles).toEqual(['Release investigation']) - }) - - it('turns a streamed error into a sanitized structured CLI error', async () => { - const terminalEscape = String.fromCharCode(27) - const response = sse([ - `event: error\ndata: ${JSON.stringify({ - type: 'error', - error: { - code: `CHAT${terminalEscape}[2A_FAILED`, - message: `Model${terminalEscape}]0;x\u0007 unavailable`, - }, - })}\n\n`, - ]) - - const result = readChatResponse(response) - await expect(result).rejects.toBeInstanceOf(SimApiError) - await expect(result).rejects.toMatchObject({ - message: 'Model unavailable', - code: 'CHAT_FAILED', - }) - }) - - it('rejects malformed and incomplete streams', async () => { - await expect(readChatResponse(sse(['data: not-json\n\n']))).rejects.toThrow( - /malformed streaming data/ - ) - await expect( - readChatResponse(sse(['data: {"type":"text","delta":"partial"}\n\ndata: [DONE]\n\n'])) - ).rejects.toThrow(/ended before completing/) - }) - - it.each([ - [ - 'an error event', - 'event: error\ndata: {"type":"error","error":{"code":"FAILED","message":"No answer"}}\n\n', - ], - ['malformed data', 'data: not-json\n\n'], - ])('cancels the response body after %s', async (_name, wire) => { - const { response, cancel } = openSse(wire) - - await expect(readChatResponse(response)).rejects.toBeInstanceOf(SimApiError) - expect(cancel).toHaveBeenCalledOnce() - }) -}) - -describe('composeChatPrompt', () => { - it('does not add a separator when only one source is present', () => { - expect(composeChatPrompt(['hello'], '')).toBe('hello') - expect(composeChatPrompt([], 'hello\n')).toBe('hello\n') - }) -}) diff --git a/packages/sim-cli/src/commands/protocol/chat.ts b/packages/sim-cli/src/commands/protocol/chat.ts deleted file mode 100644 index 2443865c059..00000000000 --- a/packages/sim-cli/src/commands/protocol/chat.ts +++ /dev/null @@ -1,1975 +0,0 @@ -import { setTimeout as delay } from 'node:timers/promises' -import { Command } from 'commander' -import type { OutputFormat } from '../../config/index.js' -import { clientFrom } from '../../context.js' -import type { - ChatBody, - GetChatResponse, - GetChatRunResponse, - GetWorkspaceResponse, - ListChatsResponse, - ListFilesResponse, - ListKnowledgeBasesResponse, - ListLogsResponse, - ListMcpServersResponse, - ListSkillsResponse, - ListTablesResponse, - ListWorkflowsResponse, - RenameChatBody, - RenameChatResponse, -} from '../../generated/v2-api.js' -import { V2_OPERATIONS } from '../../generated/v2-api.js' -import { requestAllPages, resolvePath, SimApiError, type SimClient } from '../../http/client.js' -import { safeOneLine, sanitize } from '../../output/render.js' -import { - type ChatAttachment, - combineChatAttachments, - type ExtractedAttachments, - extractAttachmentPaths, - loadChatAttachments, - readClipboardAttachment, -} from './chat-attachments.js' -import { ChatMarkdownStream } from './chat-markdown.js' -import { - type ChatQuestion, - ChatStructuredParser, - type ChatStructuredSegment, - parseChatStructured, - type RenderPart, - renderChatStructured, -} from './chat-structured.js' -import type { ChatContext, ChatSuggestionCandidates, SuggestionItem } from './chat-suggestions.js' -import { - type ChatActivityUpdate, - type ChatTerminal, - type ChatTerminalInput, - type ChatTerminalSelectResult, - ReadlineChatTerminal, -} from './chat-terminal.js' -import { printProtocolResult } from './result.js' - -export interface ChatDependencies { - readInput: (maxBytes: number) => Promise<string> - writeOutput: (content: string) => void - isInteractive: () => boolean - createTerminal: () => ChatTerminal - loadAttachments: (paths: string[]) => Promise<ChatAttachment[]> - clipboardAttachment: () => Promise<ChatAttachment | null> - extractAttachmentPaths: (input: string) => Promise<ExtractedAttachments | null> - formatMarkdown: () => boolean - writeStream: (content: string) => void - writeProgress: (content: string) => void - showProgress: () => boolean - pollDelay: (milliseconds: number, signal: AbortSignal) => Promise<void> - onInterrupt: (listener: () => void) => () => void -} - -interface ChatEvent { - type?: unknown - delta?: unknown - data?: unknown - error?: unknown - continuationToken?: unknown - chatId?: unknown - runId?: unknown - title?: unknown -} - -type ChatSummary = ListChatsResponse['data'][number] -type ChatHistoryMessage = GetChatResponse['data']['messages'][number] - -export interface ChatTurn { - content: string - streamedContent: string - continuationToken: string | null -} - -export interface ReadChatTurnOptions { - onDelta?: (delta: string) => void | Promise<void> - onThinking?: (delta: string) => void | Promise<void> - onActivity?: (activity: ChatActivityUpdate) => void | Promise<void> - /** The opaque token arrives after turn acceptance and before assistant output. */ - onContinuationToken?: (token: string) => void | Promise<void> - /** The shared chat identity arrives with the session event when available. */ - onChatId?: (chatId: string) => void | Promise<void> - /** The persisted chat title may arrive with either session acceptance or title generation. */ - onTitle?: (title: string) => void | Promise<void> -} - -interface ChatAcceptance { - runId: string - chatId: string -} - -interface ChatRunSnapshot { - runId: string - chatId: string - chatTitle?: string | null - status: GetChatRunResponse['data']['status'] - startedAt?: string | null - completedAt?: string | null - response: string - activities: ChatActivityUpdate[] -} - -interface ParsedChatRunSnapshot { - /** Sanitized and shape-checked values used by the human renderer. */ - snapshot: ChatRunSnapshot - /** Exact API data used by JSON/YAML, which preserve wire values by convention. */ - raw: Record<string, unknown> -} - -const MAX_CHAT_PROMPT_BYTES = 10 * 1024 * 1024 -const MAX_LOG_SUGGESTIONS = 50 -const CHAT_RUN_POLL_MS = 3_000 -const CHAT_RUN_STATUSES = new Set<GetChatRunResponse['data']['status']>([ - ...V2_OPERATIONS.listChatRuns.query.status.values, -]) -const TERMINAL_CHAT_RUN_STATUSES = new Set<GetChatRunResponse['data']['status']>([ - 'complete', - 'error', - 'cancelled', -]) -const FAILED_CHAT_RUN_STATUSES = new Set<GetChatRunResponse['data']['status']>([ - 'error', - 'cancelled', -]) - -function inputTooLarge(): SimApiError { - return new SimApiError('Chat input exceeds the 10 MiB limit.', 0) -} - -function utf8Bytes(value: string): number { - return Buffer.byteLength(value, 'utf8') -} - -/** Reads stdin only when the command is part of a pipe or redirection. */ -async function readPipedInput(maxBytes: number): Promise<string> { - if (process.stdin.isTTY) return '' - - process.stdin.setEncoding('utf8') - let input = '' - let inputBytes = 0 - for await (const chunk of process.stdin) { - inputBytes += utf8Bytes(chunk) - if (inputBytes > maxBytes) throw inputTooLarge() - input += chunk - } - return input -} - -/** Writes one completed answer, preserving its contents and adding a shell-friendly newline. */ -function writeCompletedAnswer(content: string): void { - if (!content) return - process.stdout.write(content) - if (!content.endsWith('\n')) process.stdout.write('\n') -} - -/** - * Matches Claude Code's print-mode input ordering: command-line prompt first, - * then piped context separated by one newline. - */ -export function composeChatPrompt(promptParts: string[], pipedInput: string): string { - return [promptParts.join(' '), pipedInput].filter(Boolean).join('\n') -} - -async function* linesOf(body: ReadableStream<Uint8Array>): AsyncGenerator<string> { - const reader = body.getReader() - const decoder = new TextDecoder() - let buffered = '' - let reachedEnd = false - - try { - while (true) { - const { done, value } = await reader.read() - if (done) { - reachedEnd = true - break - } - buffered += decoder.decode(value, { stream: true }) - - let newline = buffered.indexOf('\n') - while (newline !== -1) { - const raw = buffered.slice(0, newline) - buffered = buffered.slice(newline + 1) - yield raw.endsWith('\r') ? raw.slice(0, -1) : raw - newline = buffered.indexOf('\n') - } - } - - buffered += decoder.decode() - if (buffered) yield buffered.endsWith('\r') ? buffered.slice(0, -1) : buffered - } finally { - if (!reachedEnd) await reader.cancel().catch(() => {}) - reader.releaseLock() - } -} - -function dataFromEvent(lines: string[]): string | null { - const data: string[] = [] - for (const line of lines) { - if (!line || line.startsWith(':')) continue - const separator = line.indexOf(':') - const field = separator === -1 ? line : line.slice(0, separator) - if (field !== 'data') continue - - const raw = separator === -1 ? '' : line.slice(separator + 1) - data.push(raw.startsWith(' ') ? raw.slice(1) : raw) - } - return data.length > 0 ? data.join('\n') : null -} - -function streamError(event: ChatEvent): SimApiError { - const detail = event.error - if (!detail || typeof detail !== 'object') { - return new SimApiError('Sim Chat failed.', 0) - } - - const error = detail as { code?: unknown; message?: unknown } - return new SimApiError( - typeof error.message === 'string' ? sanitize(error.message) : 'Sim Chat failed.', - 0, - typeof error.code === 'string' ? sanitize(error.code) : null - ) -} - -function eventString(event: ChatEvent, field: 'runId' | 'chatId'): string | null { - const direct = event[field] - if (typeof direct === 'string' && direct) return direct - if (!event.data || typeof event.data !== 'object') return null - const nested = (event.data as Record<string, unknown>)[field] - return typeof nested === 'string' && nested ? nested : null -} - -/** Reads only the accepted session for a detached chat run, then closes the HTTP reader. */ -export async function readChatAcceptance(response: Response): Promise<ChatAcceptance> { - if (!response.body) throw new SimApiError('Sim Chat returned an empty response.', 0) - - let eventLines: string[] = [] - let runId: string | null = null - let chatId: string | null = null - - const consume = (): ChatAcceptance | null => { - const raw = dataFromEvent(eventLines) - eventLines = [] - if (raw === null || raw === '[DONE]') return null - - let parsed: ChatEvent - try { - parsed = JSON.parse(raw) as ChatEvent - } catch { - throw new SimApiError('Sim Chat returned malformed streaming data.', 0) - } - - if (parsed.type === 'error') throw streamError(parsed) - if (parsed.type !== 'session') return null - runId = eventString(parsed, 'runId') ?? runId - chatId = eventString(parsed, 'chatId') ?? chatId - return runId && chatId ? { runId, chatId } : null - } - - try { - for await (const line of linesOf(response.body)) { - if (line !== '') { - eventLines.push(line) - continue - } - const accepted = consume() - if (accepted) return accepted - } - if (eventLines.length > 0) { - const accepted = consume() - if (accepted) return accepted - } - } catch (error) { - if (error instanceof SimApiError) throw error - const message = error instanceof Error ? error.message : String(error) - throw new SimApiError(`Sim Chat stream failed: ${sanitize(message)}`, 0) - } - - throw new SimApiError('Sim Chat ended before accepting the asynchronous run.', 0) -} - -function tokenFrom(value: unknown): string | null { - if (!value || typeof value !== 'object') return null - const token = (value as { continuationToken?: unknown }).continuationToken - return typeof token === 'string' && token ? token : null -} - -function activityFrom(value: unknown): ChatActivityUpdate | null { - if (!value || typeof value !== 'object') return null - const data = value as Record<string, unknown> - if (data.kind === 'narration') { - if (typeof data.parentId !== 'string' || typeof data.delta !== 'string') return null - const parentId = safeOneLine(data.parentId).slice(0, 160) - const delta = sanitize(data.delta) - return parentId && delta ? { kind: 'narration', parentId, delta } : null - } - if (data.kind !== 'tool' && data.kind !== 'subagent') return null - if (data.state !== 'running' && data.state !== 'complete' && data.state !== 'error') return null - if (typeof data.id !== 'string' || typeof data.label !== 'string') return null - - const id = safeOneLine(data.id).slice(0, 160) - const label = safeOneLine(data.label).slice(0, 160) - const parentId = typeof data.parentId === 'string' ? safeOneLine(data.parentId).slice(0, 160) : '' - return id && label - ? { - kind: data.kind, - id, - label, - state: data.state, - ...(parentId && parentId !== id ? { parentId } : {}), - } - : null -} - -function optionalSnapshotString(value: unknown, field: string): string | null | undefined { - if (value === undefined) return undefined - if (value === null) return null - if (typeof value !== 'string') { - throw new SimApiError(`Sim Chat returned an invalid ${field}.`, 0) - } - return sanitize(value) -} - -function parseChatRunSnapshot(value: unknown, expectedRunId: string): ParsedChatRunSnapshot { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new SimApiError('Sim Chat returned an invalid run status.', 0) - } - const envelope = value as Record<string, unknown> - const raw = - envelope.data && typeof envelope.data === 'object' && !Array.isArray(envelope.data) - ? (envelope.data as Record<string, unknown>) - : envelope - const runId = optionalSnapshotString(raw.runId, 'run ID') - const chatId = optionalSnapshotString(raw.chatId, 'chat ID') - const status = optionalSnapshotString(raw.status, 'run status') - if (!runId || !chatId || !status) { - throw new SimApiError('Sim Chat returned an incomplete run status.', 0) - } - if (runId !== expectedRunId) { - throw new SimApiError('Sim Chat returned status for a different run.', 0) - } - if (!CHAT_RUN_STATUSES.has(status as GetChatRunResponse['data']['status'])) { - throw new SimApiError(`Sim Chat returned unknown run status "${safeOneLine(status)}".`, 0) - } - if (raw.response !== undefined && raw.response !== null && typeof raw.response !== 'string') { - throw new SimApiError('Sim Chat returned an invalid run response.', 0) - } - if (raw.activities !== undefined && !Array.isArray(raw.activities)) { - throw new SimApiError('Sim Chat returned invalid run activity.', 0) - } - - return { - raw, - snapshot: { - runId, - chatId, - status: status as GetChatRunResponse['data']['status'], - // The structured stream parser owns human-output sanitization. Retaining - // the exact cumulative string here also makes prefix checks meaningful. - response: typeof raw.response === 'string' ? raw.response : '', - activities: Array.isArray(raw.activities) - ? raw.activities.flatMap((activity) => { - const parsed = activityFrom(activity) - return parsed ? [parsed] : [] - }) - : [], - ...(raw.chatTitle !== undefined - ? { chatTitle: optionalSnapshotString(raw.chatTitle, 'chat title') } - : {}), - ...(raw.startedAt !== undefined - ? { startedAt: optionalSnapshotString(raw.startedAt, 'start time') } - : {}), - ...(raw.completedAt !== undefined - ? { completedAt: optionalSnapshotString(raw.completedAt, 'completion time') } - : {}), - }, - } -} - -/** Reads one public chat turn, optionally forwarding raw text deltas to a safe renderer. */ -export async function readChatTurn( - response: Response, - options: ReadChatTurnOptions = {} -): Promise<ChatTurn> { - if (!response.body) throw new SimApiError('Sim Chat returned an empty response.', 0) - - let deltas = '' - let completedContent: string | null = null - let continuationToken: string | null = null - let sawComplete = false - let eventLines: string[] = [] - - const consume = async (): Promise<void> => { - const raw = dataFromEvent(eventLines) - eventLines = [] - if (raw === null || raw === '[DONE]') return - - let parsed: ChatEvent - try { - parsed = JSON.parse(raw) as ChatEvent - } catch { - throw new SimApiError('Sim Chat returned malformed streaming data.', 0) - } - - if (parsed.type === 'session') { - if (typeof parsed.chatId === 'string' && parsed.chatId) { - await options.onChatId?.(parsed.chatId) - } - if (typeof parsed.title === 'string') { - const title = safeOneLine(parsed.title).slice(0, 160) - if (title) await options.onTitle?.(title) - } - const token = tokenFrom(parsed) - if (token) { - continuationToken = token - await options.onContinuationToken?.(token) - } - return - } - if (parsed.type === 'text' && typeof parsed.delta === 'string') { - deltas += parsed.delta - await options.onDelta?.(parsed.delta) - return - } - if (parsed.type === 'thinking' && typeof parsed.delta === 'string') { - await options.onThinking?.(sanitize(parsed.delta)) - return - } - if (parsed.type === 'activity') { - const activity = activityFrom(parsed.data) - if (activity) await options.onActivity?.(activity) - return - } - if (parsed.type === 'error') throw streamError(parsed) - if (parsed.type !== 'complete') return - - sawComplete = true - if (parsed.data && typeof parsed.data === 'object') { - const content = (parsed.data as { content?: unknown }).content - if (typeof content === 'string') completedContent = content - continuationToken = tokenFrom(parsed.data) ?? continuationToken - } - } - - try { - for await (const line of linesOf(response.body)) { - if (line === '') await consume() - else eventLines.push(line) - } - if (eventLines.length > 0) await consume() - } catch (error) { - if (error instanceof SimApiError) throw error - const message = error instanceof Error ? error.message : String(error) - throw new SimApiError(`Sim Chat stream failed: ${sanitize(message)}`, 0) - } - - if (!sawComplete) throw new SimApiError('Sim Chat ended before completing.', 0) - return { - content: completedContent ?? deltas, - streamedContent: deltas, - continuationToken, - } -} - -/** Buffers the public chat SSE protocol and returns only the final assistant answer. */ -export async function readChatResponse(response: Response): Promise<string> { - return (await readChatTurn(response)).content -} - -function requestChat(client: SimClient, body: ChatBody, signal: AbortSignal): Promise<Response> { - return client.requestRaw(V2_OPERATIONS.chat.path, { - method: 'POST', - headers: { accept: 'text/event-stream' }, - body, - signal, - auth: 'optional', - }) -} - -function isMachineOutput(format: OutputFormat): boolean { - return format === 'json' || format === 'yaml' -} - -function renderRunProgress( - snapshot: ChatRunSnapshot, - previousStatus: string | undefined, - seenActivityUpdates: Set<string>, - write: (content: string) => void -): void { - if (snapshot.status !== previousStatus) write(`status: ${safeOneLine(snapshot.status)}\n`) - - snapshot.activities.forEach((activity, index) => { - if (activity.kind === 'narration') { - const narration = safeOneLine(activity.delta) - if (!narration) return - const key = `${index}:narration:${activity.parentId}:${narration}` - if (seenActivityUpdates.has(key)) return - seenActivityUpdates.add(key) - write(` ${narration}\n`) - return - } - - // The API returns a cumulative, chronological activity snapshot. Include - // the stable position and full public transition in the identity so a - // running -> complete pair is emitted once each rather than replayed on - // every poll. - const key = `${index}:${activity.kind}:${activity.id}:${activity.state}:${activity.label}` - if (seenActivityUpdates.has(key)) return - seenActivityUpdates.add(key) - const marker = activity.state === 'running' ? '●' : activity.state === 'complete' ? '✓' : '✗' - write(`${marker} ${safeOneLine(activity.label)}\n`) - }) -} - -async function followChatRun( - client: SimClient, - workspaceId: string, - runId: string, - format: OutputFormat, - dependencies: ChatDependencies -): Promise<void> { - const controller = new AbortController() - let interrupted = false - const stopListening = dependencies.onInterrupt(() => { - interrupted = true - controller.abort() - }) - const machineOutput = isMachineOutput(format) - const progressEnabled = !machineOutput && dependencies.showProgress() - const seenActivityUpdates = new Set<string>() - const responseParser = machineOutput ? null : new ChatStructuredParser() - const responseSegments: ChatStructuredSegment[] = [] - let observedResponse = '' - let emittedResponse = '' - let renderedProgressStatus: string | undefined - let finalSnapshot: ChatRunSnapshot | undefined - let finalRawSnapshot: Record<string, unknown> | undefined - - try { - while (!interrupted) { - let result: GetChatRunResponse - try { - result = await client.request<GetChatRunResponse>( - resolvePath(V2_OPERATIONS.getChatRun.path, { runId }), - { - query: { workspaceId }, - signal: controller.signal, - auth: 'optional', - } - ) - } catch (error) { - if (interrupted || controller.signal.aborted) break - if (error instanceof SimApiError && (error.status === 429 || error.status >= 500)) { - try { - await dependencies.pollDelay(CHAT_RUN_POLL_MS, controller.signal) - } catch (delayError) { - if (interrupted || controller.signal.aborted) break - throw delayError - } - continue - } - throw error - } - - const parsed = parseChatRunSnapshot(result, runId) - const { snapshot } = parsed - finalSnapshot = snapshot - finalRawSnapshot = parsed.raw - - let displayDelta = '' - if (!machineOutput) { - if (!snapshot.response.startsWith(observedResponse)) { - throw new SimApiError('Sim Chat returned a non-monotonic run response.', 0) - } - const responseDelta = snapshot.response.slice(observedResponse.length) - observedResponse = snapshot.response - if (responseDelta) responseSegments.push(...responseParser!.push(responseDelta)) - const terminal = TERMINAL_CHAT_RUN_STATUSES.has(snapshot.status) - if (terminal) responseSegments.push(...responseParser!.finish()) - - const rendered = sanitize( - renderChatStructured( - withoutTrailingStandaloneResource(responseSegments), - renderContext(false) - ).text - ) - // A future structured tag may own the whitespace immediately before - // it. Hold that tiny unstable suffix until more content or completion - // makes its purpose known, while still streaming all substantive text. - const stableRendered = terminal ? rendered : rendered.trimEnd() - if (!stableRendered.startsWith(emittedResponse)) { - throw new SimApiError('Sim Chat returned non-monotonic rendered output.', 0) - } - displayDelta = stableRendered.slice(emittedResponse.length) - // Progress owns complete lines. Once response prose starts, defer any - // later progress until the response has received its final newline so - // stdout and stderr cannot concatenate on a shared terminal. - if (progressEnabled && !emittedResponse) { - renderRunProgress( - snapshot, - renderedProgressStatus, - seenActivityUpdates, - dependencies.writeProgress - ) - renderedProgressStatus = snapshot.status - } - if (displayDelta) dependencies.writeStream(displayDelta) - emittedResponse = stableRendered - } - - if (TERMINAL_CHAT_RUN_STATUSES.has(snapshot.status)) break - try { - await dependencies.pollDelay(CHAT_RUN_POLL_MS, controller.signal) - } catch (error) { - if (interrupted || controller.signal.aborted) break - throw error - } - } - } finally { - stopListening() - } - - if (interrupted) { - if (!machineOutput && emittedResponse && !emittedResponse.endsWith('\n')) { - dependencies.writeStream('\n') - } - return - } - if (!finalSnapshot) return - if (machineOutput) { - printProtocolResult(format, finalRawSnapshot ?? { ...finalSnapshot }) - } else { - if (emittedResponse && !emittedResponse.endsWith('\n')) dependencies.writeStream('\n') - if (progressEnabled && emittedResponse) { - renderRunProgress( - finalSnapshot, - renderedProgressStatus, - seenActivityUpdates, - dependencies.writeProgress - ) - } - } - if (FAILED_CHAT_RUN_STATUSES.has(finalSnapshot.status)) { - throw new SimApiError( - `Sim Chat run ended with status "${safeOneLine(finalSnapshot.status)}".`, - 0, - 'CHAT_RUN_FAILED' - ) - } -} - -function renderContext(interactive: boolean) { - return { printMode: !interactive } -} - -async function runOneShot( - client: SimClient, - workspaceId: string, - prompt: string, - attachments: ChatAttachment[], - readOnly: boolean, - dependencies: ChatDependencies, - output: OutputFormat, - continuationToken?: string, - asyncMode = false -): Promise<void> { - const controller = new AbortController() - const cancel = () => controller.abort() - process.once('SIGINT', cancel) - - try { - const response = await requestChat( - client, - { - workspaceId, - prompt, - // The server's default persists a normal one-shot chat. Keep the new - // field off the blocking wire for compatibility with older strict v2 - // servers; detached runs must opt in explicitly to both behaviors. - ...(asyncMode ? { async: true, persistChat: true } : {}), - ...(readOnly ? { readOnly: true } : {}), - ...(continuationToken ? { continuationToken } : {}), - ...(attachments.length ? { attachments } : {}), - }, - controller.signal - ) - if (asyncMode) { - const accepted = await readChatAcceptance(response) - printProtocolResult(output, { ...accepted, status: 'active' }) - return - } - let chatId: string | null = null - const result = await readChatTurn(response, { - onChatId: (acceptedChatId) => { - chatId = acceptedChatId - }, - }) - const segments = withoutTrailingStandaloneResource(parseChatStructured(result.content)) - const rendered = renderChatStructured(segments, renderContext(false)) - // Print mode deliberately has no ANSI/OSC of its own, so a final defense at - // the stdout boundary is safe and preserves shell composability. - const content = sanitize(rendered.text) - if (isMachineOutput(output)) { - // Machine formats preserve the server's exact completed content. JSON - // and YAML encode control bytes safely and are the lossless API surface. - printProtocolResult(output, { content: result.content, chatId }) - return - } - dependencies.writeOutput(content) - if (dependencies.showProgress()) { - dependencies.writeProgress( - chatId - ? `chat: ${safeOneLine(chatId)}\n` - : 'chat: not saved (a personal API key is required for resumable history)\n' - ) - } - } catch (error) { - if (controller.signal.aborted) throw new SimApiError('Sim Chat cancelled.', 0) - throw error - } finally { - process.removeListener('SIGINT', cancel) - } -} - -type UserTurnResult = - | { - kind: 'turn' - prompt: string - attachments: ChatAttachment[] - queued: boolean - display?: string - pastes?: ReadonlyMap<number, string> - contexts?: ChatContext[] - } - | { kind: 'new'; attachments: ChatAttachment[] } - | { kind: 'chats'; attachments: ChatAttachment[] } - | { kind: 'rename'; title: string; attachments: ChatAttachment[] } - | { kind: 'idle'; attachments: ChatAttachment[] } - | { kind: 'exit' } - -function explainInteractiveCommands(terminal: ChatTerminal): void { - terminal.status( - [ - 'Commands:', - ' ctrl+v attach the clipboard image or file (or cmd+v on macOS)', - ' <file path> drop or type a path to attach the file', - ' /new start a new chat', - ' /chats view and switch chats', - ' /rename <title> rename the active chat', - ' /help show this help', - ' /exit leave Sim Chat (alias: /quit)', - ].join('\n') - ) -} - -function attachmentStatus(attachments: ChatAttachment[]): string { - const names = attachments.map((attachment) => attachment.name).join(', ') - return `Attached for the next turn (${attachments.length}/${5}): ${names}` -} - -async function addPaths( - current: ChatAttachment[], - paths: string[], - terminal: ChatTerminal, - dependencies: ChatDependencies -): Promise<ChatAttachment[]> { - try { - const additions = await dependencies.loadAttachments(paths) - const combined = combineChatAttachments(current, additions) - terminal.status(attachmentStatus(combined)) - return combined - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - terminal.status(`Error: ${message}`) - return current - } -} - -async function addClipboardAttachment( - current: ChatAttachment[], - terminal: ChatTerminal, - dependencies: ChatDependencies -): Promise<ChatAttachment[]> { - const pasted = await dependencies.clipboardAttachment() - /* Paste feedback is the `[Image #N]` tag in the composer, not a transcript - line: the tag says what was attached and disappears when it is deleted. */ - if (!pasted) return current - try { - const combined = combineChatAttachments(current, [pasted]) - terminal.noteAttachment(pasted.mediaType.startsWith('image/') ? 'Image' : 'File') - return combined - } catch { - return current - } -} - -async function readUserTurn( - terminal: ChatTerminal, - initialAttachments: ChatAttachment[], - dependencies: ChatDependencies, - queuedOnly = false -): Promise<UserTurnResult> { - let attachments = initialAttachments - let lastEmptyInterrupt = 0 - - while (true) { - if (queuedOnly && !terminal.hasQueuedInput()) return { kind: 'idle', attachments } - const input = await terminal.read('❯ ') - if (input.kind === 'eof') return { kind: 'exit' } - if (input.kind === 'interrupt') { - const now = Date.now() - if (input.empty && now - lastEmptyInterrupt < 1_200) return { kind: 'exit' } - lastEmptyInterrupt = input.empty ? now : 0 - continue - } - if (input.kind === 'clipboard') { - attachments = await addClipboardAttachment(attachments, terminal, dependencies) - continue - } - if (input.kind === 'selection') continue - - let trimmed = input.value.trim() - if (trimmed === '/exit' || trimmed === '/quit') return { kind: 'exit' } - if (trimmed === '/help') { - explainInteractiveCommands(terminal) - continue - } - if (trimmed === '/new') return { kind: 'new', attachments } - if (trimmed === '/chats') { - return { kind: 'chats', attachments } - } - if (trimmed.startsWith('/chats ')) { - terminal.status('Usage: /chats (search inside the chat list).') - continue - } - if (trimmed === '/rename' || trimmed.startsWith('/rename ')) { - const title = safeOneLine(trimmed.slice('/rename'.length).trim()) - if (!title) { - terminal.status('Usage: /rename <title>') - continue - } - if (title.length > 200) { - terminal.status('Error: Chat title cannot exceed 200 characters.') - continue - } - return { kind: 'rename', title, attachments } - } - let prompt = input.value - if (trimmed && !trimmed.startsWith('/')) { - const extracted = await dependencies.extractAttachmentPaths(input.value) - if (extracted) { - try { - attachments = await addPaths(attachments, extracted.paths, terminal, dependencies) - prompt = extracted.text - trimmed = prompt.trim() - } catch (error) { - terminal.status(`Error: ${error instanceof Error ? error.message : String(error)}`) - continue - } - } - } - if (trimmed.startsWith('/')) { - const taggedSlash = input.contexts?.some( - (context) => context.kind === 'skill' || context.kind === 'mcp' - ) - if (!taggedSlash) { - terminal.status(`Unknown command: ${trimmed.split(/\s/, 1)[0]}. Use /help.`) - continue - } - } - if (!trimmed && attachments.length === 0) continue - if (utf8Bytes(prompt) > MAX_CHAT_PROMPT_BYTES) { - terminal.status('Error: Chat input exceeds the 10 MiB limit.') - continue - } - return { - kind: 'turn', - prompt, - attachments, - queued: input.queued === true, - ...(input.display === undefined ? {} : { display: input.display }), - ...(input.pastes === undefined ? {} : { pastes: input.pastes }), - ...(input.contexts?.length ? { contexts: input.contexts } : {}), - } - } -} - -type QuestionAnswers = { kind: 'answer'; value: string } | { kind: 'cancel' } | { kind: 'exit' } - -async function answerQuestions( - terminal: ChatTerminal, - questions: ChatQuestion[] -): Promise<QuestionAnswers> { - const answers: string[] = [] - for (const [index, question] of questions.entries()) { - if (questions.length > 1) terminal.status(`Question ${index + 1} of ${questions.length}`) - const result = await terminal.askQuestion({ - prompt: question.prompt, - multi: question.type === 'multi_select', - options: question.options, - }) - if (result.kind === 'eof') return { kind: 'exit' } - if (result.kind === 'cancel') return { kind: 'cancel' } - answers.push(`${safeOneLine(question.prompt)} — ${result.values.map(safeOneLine).join(', ')}`) - } - return { kind: 'answer', value: answers.join('\n') } -} - -function isChatTurnInput(input: Extract<ChatTerminalInput, { kind: 'line' }>): boolean { - const trimmed = input.value.trim() - if (!trimmed) return false - return ( - !trimmed.startsWith('/') || - input.contexts?.some((context) => context.kind === 'skill' || context.kind === 'mcp') === true - ) -} - -function logSuggestionLabel( - log: ListLogsResponse['data'][number], - workflowNames: ReadonlyMap<string, string> -): string { - const workflow = - log.workflow?.name || - (log.workflowId ? workflowNames.get(log.workflowId) : undefined) || - log.workflowId || - 'Unknown workflow' - const started = new Date(log.startedAt) - const time = Number.isNaN(started.getTime()) ? log.startedAt : started.toLocaleString() - return `${workflow} · ${time}`.slice(0, 255) -} - -/** - * Builds the same two pools as the home composer from existing public lists: - * workspace resources under `@`, then skills and enabled MCP servers under `/`. - * Each request fails independently so one unavailable resource family does not - * disable the rest of the composer. - */ -function loadSuggestionCandidates( - client: SimClient, - workspaceId: string, - readOnly: boolean, - signal: AbortSignal, - publish: (candidates: ChatSuggestionCandidates) => void -): void { - const query = { workspaceId } - const resourceGroups = { - workflows: [] as SuggestionItem[], - tables: [] as SuggestionItem[], - files: [] as SuggestionItem[], - knowledge: [] as SuggestionItem[], - logs: [] as SuggestionItem[], - } - const slashGroups = { - skills: [] as SuggestionItem[], - mcp: [] as SuggestionItem[], - } - let workflowsForLogs: ListWorkflowsResponse['data'] = [] - let loadedLogs: ListLogsResponse['data'] | null = null - const publishCurrent = () => { - publish({ - resources: [ - ...resourceGroups.workflows, - ...resourceGroups.tables, - ...resourceGroups.files, - ...resourceGroups.knowledge, - ...resourceGroups.logs, - ], - slash: [...slashGroups.skills, ...slashGroups.mcp], - }) - } - const publishLogs = () => { - if (!loadedLogs) return - const workflowNames = new Map(workflowsForLogs.map((workflow) => [workflow.id, workflow.name])) - resourceGroups.logs = loadedLogs.slice(0, MAX_LOG_SUGGESTIONS).map((log) => { - const label = logSuggestionLabel(log, workflowNames) - return { - id: `logs:${log.runId}`, - value: label, - displayText: label, - description: 'log', - tag: 'logs', - context: { kind: 'logs' as const, executionId: log.runId, label }, - } - }) - publishCurrent() - } - - const workflowsRequest = requestAllPages<ListWorkflowsResponse['data'][number]>( - client, - V2_OPERATIONS.listWorkflows.path, - { - query, - pageSize: 50, - signal, - auth: 'optional', - } - ).catch(() => []) - void workflowsRequest.then((workflows) => { - workflowsForLogs = workflows - resourceGroups.workflows = workflows.map((workflow) => ({ - id: `workflow:${workflow.id}`, - value: workflow.name, - displayText: workflow.name, - description: 'workflow', - tag: 'workflow', - context: { - kind: 'workflow' as const, - workflowId: workflow.id, - label: workflow.name, - }, - })) - publishCurrent() - publishLogs() - }) - - void requestAllPages<ListTablesResponse['data'][number]>(client, V2_OPERATIONS.listTables.path, { - query, - pageSize: 100, - signal, - auth: 'optional', - }) - .catch(() => []) - .then((tables) => { - resourceGroups.tables = tables.map((table) => ({ - id: `table:${table.id}`, - value: table.name, - displayText: table.name, - description: 'table', - tag: 'table', - context: { kind: 'table' as const, tableId: table.id, label: table.name }, - })) - publishCurrent() - }) - - void requestAllPages<ListFilesResponse['data'][number]>(client, V2_OPERATIONS.listFiles.path, { - query, - pageSize: 100, - signal, - auth: 'optional', - }) - .catch(() => []) - .then((files) => { - resourceGroups.files = files.map((file) => ({ - id: `file:${file.id}`, - value: file.name, - displayText: file.name, - description: 'file', - tag: 'file', - context: { kind: 'file' as const, fileId: file.id, label: file.name }, - })) - publishCurrent() - }) - - void client - .request<ListKnowledgeBasesResponse>(V2_OPERATIONS.listKnowledgeBases.path, { - query, - signal, - auth: 'optional', - }) - .then((page) => page.data) - .catch(() => []) - .then((knowledge) => { - resourceGroups.knowledge = knowledge.map((base) => ({ - id: `knowledge:${base.id}`, - value: base.name, - displayText: base.name, - description: 'knowledge base', - tag: 'knowledge', - context: { kind: 'knowledge' as const, knowledgeId: base.id, label: base.name }, - })) - publishCurrent() - }) - - const logsRequest = client - .request<ListLogsResponse>(V2_OPERATIONS.listLogs.path, { - query: { workspaceId, details: 'basic', order: 'desc', limit: MAX_LOG_SUGGESTIONS }, - signal, - auth: 'optional', - }) - .then((page) => page.data) - .catch(() => []) - void logsRequest.then((logs) => { - loadedLogs = logs - publishLogs() - }) - - void client - .request<ListSkillsResponse>(V2_OPERATIONS.listSkills.path, { query, signal, auth: 'optional' }) - .catch(() => null) - .then((skills) => { - slashGroups.skills = (skills?.data ?? []).map((skill) => ({ - id: `skill:${skill.id}`, - value: skill.name, - displayText: `/${skill.name}`, - description: skill.description, - tag: 'skill', - context: { kind: 'skill' as const, skillId: skill.id, label: skill.name }, - })) - publishCurrent() - }) - - if (!readOnly) { - void client - .request<ListMcpServersResponse>(V2_OPERATIONS.listMcpServers.path, { - query, - signal, - auth: 'optional', - }) - .catch(() => null) - .then((servers) => { - slashGroups.mcp = (servers?.data ?? []) - .filter((server) => server.enabled !== false) - .map((server) => ({ - id: `mcp:${server.id}`, - value: server.name, - displayText: `/${server.name}`, - description: server.description ?? 'MCP server', - tag: 'mcp', - context: { kind: 'mcp' as const, serverId: server.id, label: server.name }, - })) - publishCurrent() - }) - } -} - -const NEW_CHAT_SELECTION_ID = 'sim-cli:new-chat' -const NEW_CHAT_TITLE = 'New chat' - -function chatMenuDescription(chat: ChatSummary, currentChatId?: string): string { - const labels: string[] = [] - if (chat.id === currentChatId) labels.push('current') - if (chat.pinned) labels.push('pinned') - if (chat.active) labels.push('active') - const updated = new Date(chat.updatedAt) - labels.push( - Number.isNaN(updated.getTime()) - ? `updated ${safeOneLine(chat.updatedAt)}` - : updated.toLocaleString() - ) - return labels.join(' · ') -} - -async function selectChat( - client: SimClient, - terminal: ChatTerminal, - workspaceId: string, - currentChatId?: string -): Promise<ChatTerminalSelectResult> { - const chats = await requestAllPages<ChatSummary>(client, V2_OPERATIONS.listChats.path, { - query: { workspaceId }, - pageSize: 100, - auth: 'optional', - }) - return terminal.select({ - prompt: 'Choose a chat', - options: [ - { - id: NEW_CHAT_SELECTION_ID, - label: NEW_CHAT_TITLE, - description: 'start a blank conversation', - }, - ...chats.map((chat) => ({ - id: chat.id, - label: chat.title?.trim() || 'Untitled chat', - description: chatMenuDescription(chat, currentChatId), - })), - ], - }) -} - -async function loadChat( - client: SimClient, - workspaceId: string, - chatId: string, - readOnly: boolean -): Promise<GetChatResponse['data']> { - const response = await client.request<GetChatResponse>( - resolvePath(V2_OPERATIONS.getChat.path, { chatId }), - { - query: { workspaceId, ...(readOnly ? { readOnly: true } : {}) }, - auth: 'optional', - } - ) - return response.data -} - -async function renameChat( - client: SimClient, - workspaceId: string, - chatId: string, - title: string -): Promise<string> { - const body: RenameChatBody = { workspaceId, title } - const response = await client.request<RenameChatResponse>( - resolvePath(V2_OPERATIONS.renameChat.path, { chatId }), - { method: 'PATCH', body, auth: 'optional' } - ) - return response.data.title -} - -function renderStoredAssistantMessage(content: string, formatMarkdown: boolean): string { - const rendered = renderChatStructured( - withoutTrailingStandaloneResource(parseChatStructured(content)), - renderContext(false) - ) - const markdown = new ChatMarkdownStream(formatMarkdown) - return `${markdown.push(rendered.text)}${markdown.finish()}` -} - -/** Removes a terminal-dead resource pointer that the web UI renders as a clickable panel link. */ -function withoutTrailingStandaloneResource( - segments: readonly ChatStructuredSegment[] -): ChatStructuredSegment[] { - let index = segments.length - 1 - let foundResource = false - - while (index >= 0) { - const segment = segments[index] - if (segment.kind === 'workspace_resource') { - foundResource = true - index -= 1 - continue - } - if (segment.kind === 'thinking' || segment.kind === 'options') { - index -= 1 - continue - } - if (segment.kind === 'text' && !segment.text.trim()) { - index -= 1 - continue - } - break - } - - const boundary = segments[index] - if ( - !foundResource || - boundary?.kind !== 'text' || - !boundary.text.trim() || - !/\n[^\S\n]*$/u.test(boundary.text) - ) { - return [...segments] - } - - return [ - ...segments.slice(0, index), - { ...boundary, text: boundary.text.trimEnd() }, - ...segments.slice(index + 1).filter((segment) => { - if (segment.kind === 'workspace_resource') return false - return segment.kind !== 'text' || Boolean(segment.text.trim()) - }), - ] -} - -function showChatHistory( - terminal: ChatTerminal, - title: string | null, - messages: ChatHistoryMessage[], - formatMarkdown: boolean, - status: 'resumed' | 'active' | 'still-active' -): void { - terminal.clearTranscript() - const name = safeOneLine(title ?? '') || 'Untitled chat' - terminal.setChatTitle(name) - const message = - status === 'active' - ? `Opened ${name}. This chat is currently active elsewhere.` - : status === 'still-active' - ? `Refreshed ${name}. This chat remains active elsewhere.` - : `Resumed ${name}.` - terminal.status(message) - for (const message of messages) { - if (message.role === 'user') { - terminal.userMessage(message.content) - continue - } - const rendered = renderStoredAssistantMessage(message.content, formatMarkdown) - if (!rendered) continue - terminal.write(rendered) - if (!rendered.endsWith('\n')) terminal.write('\n') - } -} - -/** - * Best-effort workspace name lookup, unawaited so the header paints - * immediately; a failure just leaves the row out. - */ -async function resolveWorkspaceName( - client: SimClient, - workspaceId: string -): Promise<string | null> { - try { - const response = await client.request<GetWorkspaceResponse>( - resolvePath(V2_OPERATIONS.getWorkspace.path, { workspaceId }), - { auth: 'optional' } - ) - return response.data.name || null - } catch { - return null - } -} - -async function runInteractive( - client: SimClient, - workspaceId: string, - initialPrompt: string, - initialAttachments: ChatAttachment[], - readOnly: boolean, - dependencies: ChatDependencies, - profileName?: string -): Promise<void> { - const terminal = dependencies.createTerminal() - const suggestionController = new AbortController() - let continuationToken: string | undefined - let currentChatId: string | undefined - let resumedChatActive = false - let pendingAttachments = initialAttachments - let nextPrompt: string | null = initialPrompt || (initialAttachments.length ? '' : null) - let nextPromptQueued = false - let nextPromptDisplay: string | undefined - let nextPromptPastes: ReadonlyMap<number, string> | undefined - let nextPromptContexts: ChatContext[] = [] - let nextPromptConflictRetries = 0 - let pendingQuestions: ChatQuestion[] = [] - - const startNewConversation = () => { - pendingQuestions = [] - continuationToken = undefined - currentChatId = undefined - resumedChatActive = false - terminal.clearTranscript() - terminal.setChatTitle(NEW_CHAT_TITLE) - terminal.status('Started a new conversation.') - } - - try { - terminal.welcome({ chatTitle: NEW_CHAT_TITLE, profile: profileName }) - void resolveWorkspaceName(client, workspaceId).then((name) => { - if (name) terminal.setWorkspaceName(name) - }) - loadSuggestionCandidates( - client, - workspaceId, - readOnly, - suggestionController.signal, - (candidates) => { - terminal.setSuggestionCandidates?.(candidates) - } - ) - if (initialPrompt.trim()) terminal.userMessage(initialPrompt) - while (true) { - if (nextPrompt === null) { - const input = await readUserTurn( - terminal, - pendingAttachments, - dependencies, - pendingQuestions.length > 0 - ) - if (input.kind === 'exit') return - pendingAttachments = input.attachments - if (input.kind === 'idle') { - const questions = pendingQuestions - pendingQuestions = [] - const questionAnswers = await answerQuestions(terminal, questions) - if (questionAnswers.kind === 'exit') return - if (questionAnswers.kind === 'cancel') continue - nextPrompt = questionAnswers.value - nextPromptQueued = false - nextPromptDisplay = undefined - nextPromptPastes = undefined - nextPromptContexts = [] - nextPromptConflictRetries = 0 - continue - } - if ((input.kind === 'new' || input.kind === 'chats') && terminal.hasQueuedInput()) { - terminal.status('Finish queued prompts before changing conversations.') - continue - } - if (input.kind === 'new') { - startNewConversation() - continue - } - if (input.kind === 'chats') { - pendingQuestions = [] - let selection: ChatTerminalSelectResult - try { - selection = await selectChat(client, terminal, workspaceId, currentChatId) - } catch (error) { - terminal.status( - `Error: ${safeOneLine(error instanceof Error ? error.message : String(error))}` - ) - continue - } - if (selection.kind === 'eof') return - if (selection.kind === 'cancel') continue - if (selection.id === NEW_CHAT_SELECTION_ID) { - startNewConversation() - continue - } - try { - const chat = await loadChat(client, workspaceId, selection.id, readOnly) - if (!chat.continuationToken) { - throw new SimApiError('Sim Chat did not return a continuation token.', 0) - } - continuationToken = chat.continuationToken - currentChatId = chat.id - resumedChatActive = chat.active - showChatHistory( - terminal, - chat.title, - chat.messages, - dependencies.formatMarkdown(), - chat.active ? 'active' : 'resumed' - ) - } catch (error) { - terminal.status( - `Error: ${safeOneLine(error instanceof Error ? error.message : String(error))}` - ) - } - continue - } - if (input.kind === 'rename') { - if (!currentChatId) { - terminal.status('Send a message before renaming this chat.') - continue - } - try { - const title = await renameChat(client, workspaceId, currentChatId, input.title) - terminal.setChatTitle(title) - terminal.status(`Renamed chat to ${title}.`) - } catch (error) { - terminal.status( - `Error: ${safeOneLine(error instanceof Error ? error.message : String(error))}` - ) - } - continue - } - pendingQuestions = [] - nextPrompt = input.prompt - nextPromptQueued = input.queued - nextPromptDisplay = input.display - nextPromptPastes = input.pastes - nextPromptContexts = input.contexts ?? [] - nextPromptConflictRetries = 0 - } - - if (resumedChatActive && currentChatId) { - const retryDisplay = nextPromptDisplay ?? nextPrompt - const restorePrompt = (): boolean => - terminal.preload(retryDisplay, { - queued: true, - pastes: nextPromptPastes, - ...(nextPromptContexts.length ? { contexts: nextPromptContexts } : {}), - }) - try { - const chat = await loadChat(client, workspaceId, currentChatId, readOnly) - continuationToken = chat.continuationToken - currentChatId = chat.id - resumedChatActive = chat.active - showChatHistory( - terminal, - chat.title, - chat.messages, - dependencies.formatMarkdown(), - chat.active ? 'still-active' : 'resumed' - ) - if (!chat.active) { - if (retryDisplay.trim()) terminal.userMessage(retryDisplay) - } else { - if (!restorePrompt()) { - terminal.status('The pending prompt could not be restored. Please enter it again.') - } - nextPrompt = null - nextPromptQueued = false - nextPromptDisplay = undefined - nextPromptPastes = undefined - nextPromptContexts = [] - nextPromptConflictRetries = 0 - continue - } - } catch (error) { - const restored = restorePrompt() - const message = safeOneLine(error instanceof Error ? error.message : String(error)) - terminal.status( - restored - ? `Error: ${message}. Press Enter to retry.` - : `Error: ${message}. Please enter the prompt again.` - ) - nextPrompt = null - nextPromptQueued = false - nextPromptDisplay = undefined - nextPromptPastes = undefined - nextPromptContexts = [] - nextPromptConflictRetries = 0 - continue - } - } - - const sentPrompt = nextPrompt - const sentPromptQueued = nextPromptQueued - const sentPromptDisplay = nextPromptDisplay - const sentPromptPastes = nextPromptPastes - const sentContexts = nextPromptContexts - const sentConflictRetries = nextPromptConflictRetries - const sentAttachments = pendingAttachments - pendingAttachments = [] - const controller = new AbortController() - let sessionReady = false - let submitRequested = false - let submitChecks = Promise.resolve() - const stopListening = terminal.onInterrupt((reason, input) => { - if (reason === 'manual') { - if (!controller.signal.aborted) controller.abort(reason) - return - } - if (input?.kind !== 'line') return - submitChecks = submitChecks.then(async () => { - if (!isChatTurnInput(input)) return - if (!submitRequested) { - submitRequested = true - if (sessionReady && !controller.signal.aborted) controller.abort(reason) - } - }) - }) - const activity = terminal.activity('Thinking…') - const parser = new ChatStructuredParser() - const markdownEnabled = dependencies.formatMarkdown() - const markdown = new ChatMarkdownStream(markdownEnabled) - const narrationMarkdown = new Map<string, ChatMarkdownStream>() - const questions: ChatQuestion[] = [] - let wroteOutput = false - let pendingWhitespace = '' - let previousWasBlock = false - let outputFinalized = false - let strippedOptions = false - let deferredTrailingResourceParts: RenderPart[] | null = null - - const writePart = (value: string, block: boolean) => { - if (!value) return - let separator = '' - if (wroteOutput && (block || previousWasBlock)) { - const trailingNewlines = pendingWhitespace.match(/\n*$/u)?.[0].length ?? 0 - const leadingNewlines = value.match(/^\n*/u)?.[0].length ?? 0 - separator = '\n'.repeat(Math.max(0, 2 - trailingNewlines - leadingNewlines)) - } - const output = `${pendingWhitespace}${separator}${value}` - const trailing = output.match(/\s+$/u)?.[0] ?? '' - const ready = trailing ? output.slice(0, -trailing.length) : output - if (ready) { - terminal.write(ready) - wroteOutput = true - } - pendingWhitespace = trailing - previousWasBlock = block - } - - const flushDeferredTrailingResource = () => { - if (!deferredTrailingResourceParts) return - for (const part of deferredTrailingResourceParts) writePart(part.value, part.block) - deferredTrailingResourceParts = null - } - - const finishOutput = () => { - if (outputFinalized) return - outputFinalized = true - if (deferredTrailingResourceParts) { - if (wroteOutput) { - deferredTrailingResourceParts = null - pendingWhitespace = '' - } else { - flushDeferredTrailingResource() - } - } - writePart(markdown.finish(), false) - pendingWhitespace = '' - if (wroteOutput) terminal.write('\n') - } - - const finishNarration = (parentId: string) => { - const stream = narrationMarkdown.get(parentId) - if (!stream) return - const delta = stream.finish() - if (delta) activity.event({ kind: 'narration', parentId, delta }) - narrationMarkdown.delete(parentId) - } - - const finishNarrations = () => { - for (const parentId of [...narrationMarkdown.keys()]) finishNarration(parentId) - } - - const renderActivity = (update: ChatActivityUpdate) => { - if (update.kind === 'narration') { - let stream = narrationMarkdown.get(update.parentId) - if (!stream) { - stream = new ChatMarkdownStream(markdownEnabled) - narrationMarkdown.set(update.parentId, stream) - } - const delta = stream.push(update.delta) - if (delta) activity.event({ ...update, delta }) - return - } - - if (update.parentId) finishNarration(update.parentId) - if (update.kind === 'subagent' && update.state !== 'running') finishNarration(update.id) - activity.event(update) - } - - const renderSegments = async (segments: Parameters<typeof renderChatStructured>[0]) => { - const list = typeof segments === 'string' ? parseChatStructured(segments) : [...segments] - for (const segment of list) { - let displaySegment = segment - if (segment.kind === 'options') { - // Suggestions are hidden terminal metadata. Any whitespace the - // model emitted immediately before them belongs to that hidden UI, - // so do not leak it into the transcript or the next composer. - if (!deferredTrailingResourceParts) pendingWhitespace = '' - strippedOptions = true - } else if (segment.kind === 'text' && strippedOptions) { - const text = segment.text.replace(/^\s+/u, '') - if (!text) continue - displaySegment = { ...segment, text } - strippedOptions = false - } else if (segment.kind !== 'thinking') { - strippedOptions = false - } - const rendered = renderChatStructured([displaySegment], renderContext(true)) - if (displaySegment.kind === 'text') { - const value = markdown.push(rendered.text) - if (deferredTrailingResourceParts && !rendered.text.trim()) { - if (value) deferredTrailingResourceParts.push({ value, block: false }) - continue - } - flushDeferredTrailingResource() - writePart(value, false) - } else { - const inline = markdown.flushInline() - if (deferredTrailingResourceParts && !inline.trim()) { - if (inline) deferredTrailingResourceParts.push({ value: inline, block: false }) - } else { - flushDeferredTrailingResource() - writePart(inline, false) - } - /* Reuse the renderer's own block classification rather than - re-deriving it by segment kind, so the streaming and one-shot - paths cannot disagree about spacing. */ - if (displaySegment.kind === 'workspace_resource') { - if (deferredTrailingResourceParts) { - deferredTrailingResourceParts.push(...rendered.parts) - } else if (wroteOutput && pendingWhitespace.includes('\n')) { - deferredTrailingResourceParts = [...rendered.parts] - } else { - for (const part of rendered.parts) writePart(part.value, part.block) - } - } else { - if ( - deferredTrailingResourceParts && - (rendered.parts.length > 0 || rendered.interactions.length > 0) - ) { - flushDeferredTrailingResource() - } - for (const part of rendered.parts) writePart(part.value, part.block) - } - } - for (const interaction of rendered.interactions) { - if (interaction.kind === 'question') questions.push(...interaction.questions) - } - } - } - - try { - const response = await requestChat( - client, - { - workspaceId, - prompt: nextPrompt, - ...(readOnly ? { readOnly: true } : {}), - ...(continuationToken ? { continuationToken } : {}), - ...(sentAttachments.length ? { attachments: sentAttachments } : {}), - ...(sentContexts.length ? { contexts: sentContexts } : {}), - }, - controller.signal - ) - const result = await readChatTurn(response, { - onDelta: (delta) => { - finishNarrations() - activity.clear() - return renderSegments(parser.push(delta)) - }, - onThinking: (delta) => activity.thinking(delta), - onActivity: renderActivity, - onContinuationToken: (token) => { - continuationToken = token - sessionReady = true - if (submitRequested && !controller.signal.aborted) controller.abort('submit') - }, - onChatId: (chatId) => { - currentChatId = chatId - }, - onTitle: (title) => terminal.setChatTitle(title), - }) - if (result.streamedContent) { - // The completion is authoritative. Upstream normally mirrors every - // byte as a delta, but a proxy can omit the last buffered suffix; feed - // that suffix through the same parser before finalizing its state. - if ( - result.content.length > result.streamedContent.length && - result.content.startsWith(result.streamedContent) - ) { - await renderSegments(parser.push(result.content.slice(result.streamedContent.length))) - } - await renderSegments(parser.finish()) - } else { - finishNarrations() - activity.clear() - await renderSegments(result.content) - } - if (!result.continuationToken) { - throw new SimApiError('Sim Chat did not return a continuation token.', 0) - } - finishNarrations() - finishOutput() - activity.complete() - continuationToken = result.continuationToken - nextPromptQueued = false - nextPromptDisplay = undefined - nextPromptPastes = undefined - nextPromptContexts = [] - nextPromptConflictRetries = 0 - await submitChecks - if (submitRequested || questions.length === 0) { - pendingQuestions = [] - nextPrompt = null - } else if (terminal.hasQueuedInput()) { - pendingQuestions = questions - nextPrompt = null - } else { - const questionAnswers = await answerQuestions(terminal, questions) - if (questionAnswers.kind === 'exit') return - nextPrompt = questionAnswers.kind === 'answer' ? questionAnswers.value : null - } - } catch (error) { - await submitChecks - const queuedSubmit = controller.signal.aborted && controller.signal.reason === 'submit' - if (!queuedSubmit && !sessionReady) { - pendingAttachments = combineChatAttachments(sentAttachments, pendingAttachments) - } - finishNarrations() - finishOutput() - activity.stop() - if (controller.signal.aborted) { - if (!queuedSubmit) terminal.status('Generation cancelled.') - } else { - const message = error instanceof Error ? error.message : String(error) - const code = - error instanceof SimApiError && error.code ? ` (${safeOneLine(error.code)})` : '' - const conflict = - error instanceof SimApiError && error.status === 409 && error.code === 'CONFLICT' - if (conflict && currentChatId) resumedChatActive = true - if ( - conflict && - !sessionReady && - sentPromptQueued && - continuationToken && - sentConflictRetries < 1 - ) { - nextPrompt = sentPrompt - nextPromptQueued = true - nextPromptDisplay = sentPromptDisplay - nextPromptPastes = sentPromptPastes - nextPromptContexts = sentContexts - nextPromptConflictRetries = sentConflictRetries + 1 - terminal.status('Previous response is still settling. Retrying…') - continue - } - const restored = - !sessionReady && - (sentPromptQueued || conflict) && - terminal.preload(sentPromptDisplay ?? sentPrompt, { - queued: true, - pastes: sentPromptPastes, - ...(sentContexts.length ? { contexts: sentContexts } : {}), - }) - if (conflict && restored) { - terminal.status('Previous response is still settling. Press Enter to retry.') - } else { - terminal.status(`Error: ${safeOneLine(message)}${code}`) - } - } - nextPrompt = null - nextPromptQueued = false - nextPromptDisplay = undefined - nextPromptPastes = undefined - nextPromptContexts = [] - nextPromptConflictRetries = 0 - } finally { - activity.stop() - stopListening() - } - } - } finally { - suggestionController.abort() - terminal.close() - } -} - -function collectFile(value: string, previous: string[] = []): string[] { - return [...previous, value] -} - -interface ChatCommandOptions { - async?: boolean - chat?: string - file?: string[] - readOnly?: boolean -} - -function addChatInputOptions(command: Command): Command { - return command - .option('-f, --file <path>', 'Attach a local file (repeatable)', collectFile, []) - .option('--read-only', 'Restrict Sim Chat to read-only workspace tools') -} - -/** Creates one-shot and interactive workspace chat. */ -export function chatCommand( - overrides: Partial<ChatDependencies> = {}, - target = new Command('chat') -): Command { - const dependencies: ChatDependencies = { - readInput: readPipedInput, - writeOutput: writeCompletedAnswer, - isInteractive: () => - Boolean(process.stdin.isTTY && process.stdout.isTTY && process.stderr.isTTY), - createTerminal: () => new ReadlineChatTerminal(), - loadAttachments: loadChatAttachments, - clipboardAttachment: readClipboardAttachment, - extractAttachmentPaths, - // The fullscreen chat already requires a TTY and uses ANSI throughout. A - // propagated TERM=dumb value must not leave model Markdown visible inside - // an otherwise fully rendered TUI. - formatMarkdown: () => Boolean(process.stdout.isTTY), - writeStream: (content) => process.stdout.write(content), - writeProgress: (content) => process.stderr.write(content), - showProgress: () => Boolean(process.stderr.isTTY), - pollDelay: (milliseconds, signal) => delay(milliseconds, undefined, { signal }), - onInterrupt: (listener) => { - process.once('SIGINT', listener) - return () => process.removeListener('SIGINT', listener) - }, - ...overrides, - } - - const run = async (promptParts: string[], oneShot: boolean, command: Command): Promise<void> => { - const options = command.optsWithGlobals() as ChatCommandOptions - const positionalPrompt = promptParts.join(' ') - const positionalBytes = utf8Bytes(positionalPrompt) - if (positionalBytes > MAX_CHAT_PROMPT_BYTES) throw inputTooLarge() - - const chatId = options.chat?.trim() - if (options.chat !== undefined && !chatId) { - throw new SimApiError('Chat ID must not be empty.', 0) - } - - const interactive = !oneShot && dependencies.isInteractive() - if (!oneShot && !interactive) { - throw new SimApiError( - 'Interactive Sim Chat requires a terminal. Use sim chat ask for pipelines or redirected output.', - 0 - ) - } - const separatorBytes = positionalPrompt ? 1 : 0 - const pipedInput = interactive - ? '' - : await dependencies.readInput(MAX_CHAT_PROMPT_BYTES - positionalBytes - separatorBytes) - const prompt = composeChatPrompt(promptParts, pipedInput) - if (utf8Bytes(prompt) > MAX_CHAT_PROMPT_BYTES) throw inputTooLarge() - - const attachments = await dependencies.loadAttachments(options.file ?? []) - if (!interactive && !prompt.trim() && attachments.length === 0) { - throw new SimApiError('Provide a prompt, attach a file, or pipe input to sim chat ask.', 0) - } - - const { client, profile } = clientFrom(command) - const workspaceId = client.requireWorkspace(undefined, { auth: 'optional' }) - if (interactive) { - await runInteractive( - client, - workspaceId, - prompt, - attachments, - options.readOnly === true, - dependencies, - profile.name - ) - return - } - - let continuationToken: string | undefined - if (chatId) { - const chat = await loadChat(client, workspaceId, chatId, options.readOnly === true) - if (!chat.continuationToken) { - throw new SimApiError( - 'Sim Chat did not return a continuation token for the selected chat.', - 0 - ) - } - if (chat.active) { - throw new SimApiError( - 'The selected chat is currently active in another client. Wait for it to finish before resuming it.', - 409, - 'CONFLICT' - ) - } - continuationToken = chat.continuationToken - } - await runOneShot( - client, - workspaceId, - prompt, - attachments, - options.readOnly === true, - dependencies, - profile.output, - continuationToken, - options.async === true - ) - } - - const chat = addChatInputOptions(target) - .description('Ask Sim Chat about the active workspace') - .argument('[prompt...]', 'Question to ask') - .action((promptParts: string[], _options: ChatCommandOptions, command: Command) => - run(promptParts, false, command) - ) - - const ask = addChatInputOptions(new Command('ask')) - .description('Ask once, save the chat, print the response, and exit') - .argument('[prompt...]', 'Question to ask') - .option('--chat <chatId>', 'Continue an existing chat by ID') - .option('--async', 'Start the chat run and return immediately') - .action((promptParts: string[], _options: ChatCommandOptions, command: Command) => - run(promptParts, true, command) - ) - - const follow = new Command('follow') - .description('Follow a chat run until it finishes') - .argument('<runId>', 'Chat run ID returned by chat ask --async') - .allowExcessArguments(false) - .action(async (runId: string, _options: unknown, command: Command) => { - const normalizedRunId = runId.trim() - if (!normalizedRunId) throw new SimApiError('Run ID must not be empty.', 0) - const { client, profile } = clientFrom(command) - await followChatRun( - client, - client.requireWorkspace(undefined, { auth: 'optional' }), - normalizedRunId, - profile.output, - dependencies - ) - }) - - chat.addCommand(ask) - chat.addCommand(follow) - return chat -} diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index c8cce8531b1..65bbf53cb0e 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -1,5 +1,4 @@ import { Command } from 'commander' -import { chatCommand } from './chat.js' import { attachFileGet } from './files-get.js' import { attachFileUpload } from './files-upload.js' import { attachKnowledgeDocumentUpload } from './knowledge-document-upload.js' @@ -16,8 +15,6 @@ function group(program: Command, name: string): Command { /** Attaches commands whose multi-request or binary protocols cannot be generated. */ export function attachProtocolCommands(program: Command): void { - chatCommand({}, group(program, 'chat')) - const files = group(program, 'files') attachFileUpload(files) attachFileGet(files) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index ae0c648107b..a85ea550bfa 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -292,61 +292,6 @@ export const CLI_CONTRACT: CliContract = { updateCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, // ─── Output columns for list commands ───────────────────────────────────── - listChats: { - flags: { search: { describe: 'Filter chats by title' } }, - columns: [ - { header: 'id' }, - { header: 'title' }, - { header: 'updated', path: 'updatedAt', format: 'timestamp' }, - { header: 'pinned', format: 'bool' }, - { header: 'active', format: 'bool' }, - ], - }, - getChat: { - describe: 'Show chat metadata and message count', - flags: { - readOnly: { - boolean: true, - describe: 'Bind the returned continuation token to read-only mode', - }, - }, - fields: [ - { header: 'id' }, - { header: 'title' }, - { header: 'messages', format: 'count' }, - { header: 'active', format: 'bool' }, - ], - }, - listChatRuns: { - describe: 'List recent Sim Chat runs', - auth: 'optional', - flags: { status: { describe: 'Filter by durable run status' } }, - columns: [ - { header: 'started', path: 'startedAt', format: 'timestamp' }, - { header: 'status' }, - { header: 'title', path: 'chatTitle' }, - { header: 'chat', path: 'chatId' }, - { header: 'run', path: 'runId' }, - ], - }, - getChatRun: { - describe: 'Show chat run status (response and activity are included in JSON or YAML output)', - auth: 'optional', - fields: [ - { header: 'run', path: 'runId' }, - { header: 'chat', path: 'chatId' }, - { header: 'title', path: 'chatTitle' }, - { header: 'status' }, - { header: 'started', path: 'startedAt', format: 'timestamp' }, - { header: 'completed', path: 'completedAt', format: 'timestamp' }, - ], - }, - renameChat: { - command: 'chats rename', - describe: 'Rename a chat', - flags: { title: { describe: 'New chat title' } }, - fields: [{ header: 'id' }, { header: 'title' }], - }, listTables: { flags: { folderPath: FOLDER_PATH_FLAG }, columns: [ diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index a75bc584377..d7df31c341e 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -135,8 +135,6 @@ export interface CommandSpec { variants?: readonly CommandVariantSpec[] /** One-line help. Falls back to the OpenAPI summary for the operation. */ describe?: string - /** Allow an auth-disabled self-hosted route to run without a locally stored API key. */ - auth?: 'required' | 'optional' /** Per-field flag overrides, keyed by the contract's field name. */ flags?: Record<string, FlagSpec> /** Friendly mutually-exclusive flags for an otherwise opaque union body. */ diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 424c6ddfc72..bc1d6435de1 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -352,61 +352,6 @@ export type CancelWorkflowRunResponse = { } } -/** `POST /api/v2/chat` */ -export type ChatBody = { - workspaceId: string - prompt: string - continuationToken?: string - readOnly?: boolean - async?: boolean - persistChat?: boolean - attachments?: Array<{ - name: string - mediaType: string - data: string - }> - contexts?: Array< - | { - kind: 'workflow' - workflowId: string - label: string - } - | { - kind: 'table' - tableId: string - label: string - } - | { - kind: 'file' - fileId: string - label: string - } - | { - kind: 'knowledge' - knowledgeId: string - label: string - } - | { - kind: 'logs' - executionId: string - label: string - } - | { - kind: 'skill' - skillId: string - label: string - } - | { - kind: 'mcp' - serverId: string - label: string - } - > -} - -/** Non-JSON response (`stream`). */ -export type ChatResponse = never - /** `POST /api/v2/files/uploads/[uploadId]/complete` */ export type CompleteFileUploadParams = { uploadId: string @@ -1982,66 +1927,6 @@ export type GetBillingStatusResponse = { } } -/** `GET /api/v2/chats/[chatId]` */ -export type GetChatParams = { - chatId: string -} - -export type GetChatQuery = { - workspaceId: string - readOnly?: boolean -} - -export type GetChatResponse = { - data: { - id: string - title: string | null - messages: Array<{ - id: string - role: 'user' | 'assistant' - content: string - timestamp: string - }> - continuationToken: string - active: boolean - } -} - -/** `GET /api/v2/chat/runs/[runId]` */ -export type GetChatRunParams = { - runId: string -} - -export type GetChatRunQuery = { - workspaceId: string -} - -export type GetChatRunResponse = { - data: { - runId: string - chatId: string - chatTitle: string | null - status: 'active' | 'paused_waiting_for_tool' | 'resuming' | 'complete' | 'error' | 'cancelled' - startedAt: string - completedAt: string | null - response: string - activities: Array< - | { - kind: 'subagent' | 'tool' - id: string - parentId?: string - label: string - state: 'running' | 'complete' | 'error' - } - | { - kind: 'narration' - parentId: string - delta: string - } - > - } -} - /** `GET /api/v2/custom-tools/[id]` */ export type GetCustomToolParams = { id: string @@ -2691,45 +2576,6 @@ export type ListBillingLogsResponse = { nextCursor: string | null } -/** `GET /api/v2/chat/runs` */ -export type ListChatRunsQuery = { - workspaceId: string - status?: 'active' | 'paused_waiting_for_tool' | 'resuming' | 'complete' | 'error' | 'cancelled' - limit?: number - cursor?: string -} - -export type ListChatRunsResponse = { - data: Array<{ - runId: string - chatId: string - chatTitle: string | null - status: 'active' | 'paused_waiting_for_tool' | 'resuming' | 'complete' | 'error' | 'cancelled' - startedAt: string - completedAt: string | null - }> - nextCursor: string | null -} - -/** `GET /api/v2/chats` */ -export type ListChatsQuery = { - workspaceId: string - search?: string - limit?: number - cursor?: string -} - -export type ListChatsResponse = { - data: Array<{ - id: string - title: string | null - updatedAt: string - pinned: boolean - active: boolean - }> - nextCursor: string | null -} - /** `GET /api/v2/credentials` */ export type ListCredentialsQuery = { workspaceId: string @@ -3504,23 +3350,6 @@ export type RelocateWorkflowFolderResponse = { } } -/** `PATCH /api/v2/chats/[chatId]` */ -export type RenameChatParams = { - chatId: string -} - -export type RenameChatBody = { - workspaceId: string - title: string -} - -export type RenameChatResponse = { - data: { - id: string - title: string - } -} - /** `PATCH /api/v2/files/[fileId]` */ export type RenameFileParams = { fileId: string @@ -4485,23 +4314,6 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Cancel a run', }, - chat: { - method: 'POST', - path: '/api/v2/chat', - pathParams: [] as const, - responseMode: 'stream', - summary: 'Ask Sim Chat', - body: { - workspaceId: { kind: 'string', required: true }, - prompt: { kind: 'string', required: true }, - continuationToken: { kind: 'string' }, - readOnly: { kind: 'boolean', default: false }, - async: { kind: 'boolean', default: false }, - persistChat: { kind: 'boolean', default: true }, - attachments: { kind: 'array' }, - contexts: { kind: 'array' }, - }, - }, completeFileUpload: { method: 'POST', path: '/api/v2/files/uploads/[uploadId]/complete', @@ -5072,27 +4884,6 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string' }, }, }, - getChat: { - method: 'GET', - path: '/api/v2/chats/[chatId]', - pathParams: ['chatId'] as const, - responseMode: 'json', - summary: 'Open Sim Chat', - query: { - workspaceId: { kind: 'string', required: true }, - readOnly: { kind: 'boolean' }, - }, - }, - getChatRun: { - method: 'GET', - path: '/api/v2/chat/runs/[runId]', - pathParams: ['runId'] as const, - responseMode: 'json', - summary: 'Get Sim Chat Run', - query: { - workspaceId: { kind: 'string', required: true }, - }, - }, getCustomTool: { method: 'GET', path: '/api/v2/custom-tools/[id]', @@ -5309,42 +5100,6 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, - listChatRuns: { - method: 'GET', - path: '/api/v2/chat/runs', - pathParams: [] as const, - responseMode: 'json', - summary: 'List Sim Chat Runs', - query: { - workspaceId: { kind: 'string', required: true }, - status: { - kind: 'enum', - values: [ - 'active', - 'paused_waiting_for_tool', - 'resuming', - 'complete', - 'error', - 'cancelled', - ] as const, - }, - limit: { kind: 'number', default: 30 }, - cursor: { kind: 'string' }, - }, - }, - listChats: { - method: 'GET', - path: '/api/v2/chats', - pathParams: [] as const, - responseMode: 'json', - summary: 'List Sim Chats', - query: { - workspaceId: { kind: 'string', required: true }, - search: { kind: 'string' }, - limit: { kind: 'number', default: 30 }, - cursor: { kind: 'string' }, - }, - }, listCredentials: { method: 'GET', path: '/api/v2/credentials', @@ -5791,17 +5546,6 @@ export const V2_OPERATIONS = { destinationPath: { kind: 'string', required: true }, }, }, - renameChat: { - method: 'PATCH', - path: '/api/v2/chats/[chatId]', - pathParams: ['chatId'] as const, - responseMode: 'json', - summary: 'Rename Sim Chat', - body: { - workspaceId: { kind: 'string', required: true }, - title: { kind: 'string', required: true }, - }, - }, renameFile: { method: 'PATCH', path: '/api/v2/files/[fileId]', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index d966aea9d1a..0679db8b4dd 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -252,9 +252,6 @@ describe('generated operation table', () => { 'getLog', 'getBillingStatus', 'listBillingLogs', - 'listChats', - 'getChat', - 'renameChat', 'listWorkflowRuns', 'getWorkflowRun', 'resumeWorkflow', diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 1687fbc4a8a..2405e62bf52 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -32,7 +32,7 @@ program .name('sim') .description('Talk to the Sim API from your terminal') .version(readPackageVersion()) - .option('-p, --profile <name>', 'Profile to use (env: SIM_PROFILE)') + .option('-P, --profile <name>', 'Profile to use (env: SIM_PROFILE)') .option('--endpoint <url>', 'Sim deployment to talk to (env: SIM_ENDPOINT)') .option('-w, --workspace <id>', 'Workspace to target (env: SIM_WORKSPACE)') .addOption( @@ -55,12 +55,11 @@ program.addHelpText( 'after', ` Profiles work like the AWS CLI: settings live in ~/.sim/config, keys in -~/.sim/credentials (0600). Select one with -p, --profile, or SIM_PROFILE. +~/.sim/credentials (0600). Select one with -P, --profile, or SIM_PROFILE. Examples: $ sim login Authorize the default profile $ sim login --profile dev --endpoint http://localhost:3000 - $ sim chat ask "Which workflows handle support tickets?" $ sim workflows list $ sim logs list --level error --limit 20 $ sim --output json tables get tbl_123 Override output for one command diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index bb0f3f169cf..575f8112053 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -108,91 +108,6 @@ describe('commands parsed through commander', () => { expect(program().commands.some((command) => command.name() === 'folders')).toBe(false) }) - it('exposes saved chats through the generated resource commands', async () => { - expect( - commandAt('chats') - .commands.map((command) => command.name()) - .sort() - ).toEqual(['get', 'list', 'rename']) - - const listHelp = commandAt('chats', 'list').helpInformation() - expect(listHelp).toContain('--search <value>') - expect(listHelp).toContain('Filter chats by title') - expect(listHelp).toContain('--limit <n>') - - const [listPath, listOptions] = await run([ - 'chats', - 'list', - '--search', - 'incident', - '--limit', - '5', - ]) - expect(listPath).toBe('/api/v2/chats') - expect(listOptions.query).toMatchObject({ - workspaceId: 'ws_local', - search: 'incident', - limit: 5, - }) - - const getHelp = commandAt('chats', 'get').helpInformation() - expect(getHelp).toContain('Bind the returned continuation token to read-only mode') - const [getPath, getOptions] = await run(['chats', 'get', 'chat_1', '--read-only']) - expect(getPath).toBe('/api/v2/chats/chat_1') - expect(getOptions.query).toEqual({ workspaceId: 'ws_local', readOnly: true }) - - const renameHelp = commandAt('chats', 'rename').helpInformation() - expect(renameHelp).toContain('--title <value>') - expect(renameHelp).toContain('New chat title') - const [renamePath, renameOptions] = await run([ - 'chats', - 'rename', - 'chat_1', - '--title', - 'Incident review', - ]) - expect(renamePath).toBe('/api/v2/chats/chat_1') - expect(renameOptions).toMatchObject({ - method: 'PATCH', - body: { workspaceId: 'ws_local', title: 'Incident review' }, - }) - }) - - it('exposes pollable chat runs under the manual chat command group', async () => { - expect( - commandAt('chat', 'runs') - .commands.map((command) => command.name()) - .sort() - ).toEqual(['get', 'list']) - - const listHelp = commandAt('chat', 'runs', 'list').helpInformation() - expect(listHelp).toContain('--status <value>') - expect(listHelp).toContain('--limit <n>') - const [listPath, listOptions] = await run([ - 'chat', - 'runs', - 'list', - '--status', - 'active', - '--limit', - '5', - ]) - expect(listPath).toBe('/api/v2/chat/runs') - expect(listOptions.query).toMatchObject({ - workspaceId: 'ws_local', - status: 'active', - limit: 5, - }) - expect(listOptions.auth).toBe('optional') - - const get = commandAt('chat', 'runs', 'get') - expect(get.description()).toContain('response and activity') - const [getPath, getOptions] = await run(['chat', 'runs', 'get', 'run_1']) - expect(getPath).toBe('/api/v2/chat/runs/run_1') - expect(getOptions.query).toEqual({ workspaceId: 'ws_local' }) - expect(getOptions.auth).toBe('optional') - }) - it('describes generated resource and sub-resource groups', () => { expect(commandAt('tables').description()).toBe('Manage tables') expect(commandAt('tables', 'rows').description()).toBe('Manage table rows') @@ -864,32 +779,6 @@ describe('single-resource rendering', () => { expect(JSON.parse(printed[0])).toEqual({ row: { id: 'r1' }, operation: 'inserted' }) }) - it('keeps chat implementation details out of human output', async () => { - const chat = { - id: 'chat_1', - title: 'Incident review', - messages: [ - { id: 'message_1', role: 'user', content: 'Private prompt', timestamp: '2026-08-04' }, - { - id: 'message_2', - role: 'assistant', - content: 'Private answer', - timestamp: '2026-08-04', - }, - ], - continuationToken: 'opaque-token', - active: false, - } - - const human = await lines(['chats', 'get', 'chat_1'], chat, 'text') - expect(human).toEqual(['id\tchat_1', 'title\tIncident review', 'messages\t2', 'active\tno']) - expect(human.join('\n')).not.toContain('opaque-token') - expect(human.join('\n')).not.toContain('Private prompt') - - const machine = await lines(['chats', 'get', 'chat_1'], chat, 'json') - expect(JSON.parse(machine[0])).toEqual(chat) - }) - it('keeps sensitive run detail opt-in for human log output', async () => { const log = { runId: 'run_1', @@ -986,23 +875,6 @@ describe('contract-selected list rendering', () => { expect(printed).toEqual(['0.91\tpolicy.md\t2\tRefunds are available for 30 days.']) }) - it('formats saved chats like other resource lists', async () => { - const printed = await lines( - ['chats', 'list'], - [ - { - id: 'chat_1', - title: 'Incident review', - updatedAt: '2026-08-04T12:34:56.789Z', - pinned: false, - active: true, - }, - ] - ) - - expect(printed).toEqual(['chat_1\tIncident review\t2026-08-04 12:34:56\tno\tyes']) - }) - it('renders row matches as rows', async () => { const printed = await lines(['tables', 'rows', 'find', 'tbl_1', '--q', 'alice'], { matches: [{ ordinal: 3, rowId: 'row_1', column: 'email' }], diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index afe62fa1725..73d6adbe6c6 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -54,7 +54,6 @@ export async function executeOperation( } const { client, profile } = clientFrom(host) - const auth = commandSpec.auth const hasWorkspaceField = Boolean( (operationSpec.query && PROFILE_INJECTED_FIELD in operationSpec.query) || (operationSpec.body && PROFILE_INJECTED_FIELD in operationSpec.body) @@ -64,11 +63,7 @@ export async function executeOperation( operation, positional, requestFlags, - hasWorkspaceField && !omitsWorkspace - ? auth - ? client.requireWorkspace(undefined, { auth }) - : client.requireWorkspace() - : profile.workspaceId + hasWorkspaceField && !omitsWorkspace ? client.requireWorkspace() : profile.workspaceId ) const paging = cursorSlot(operationSpec) @@ -92,7 +87,6 @@ export async function executeOperation( paging === 'body' ? { ...(request.body ?? {}), ...pageLimit, ...(cursor ? { cursor } : {}) } : request.body, - ...(auth ? { auth } : {}), }) rows.push(...page.data) cursor = page.nextCursor @@ -106,7 +100,6 @@ export async function executeOperation( method: operationSpec.method, query: request.query, body: request.body, - ...(auth ? { auth } : {}), }) renderResult(operation, profile.output, result?.data ?? result, commandSpec, { expandedTrace: requestFlags.trace === true, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 0d4c07bd18f..c2f29ace6ae 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1094, - zodRoutes: 1094, + totalRoutes: 1089, + zodRoutes: 1089, nonZodRoutes: 0, } as const From 036bfa04e4e83521be8092ca654e41b2217d29c4 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 11:51:00 -0700 Subject: [PATCH 132/159] fix(credentials): align custom oauth reconnects --- .../auth/instagram/authorize/route.test.ts | 93 +++++++++++++++++++ .../app/api/auth/instagram/authorize/route.ts | 2 +- .../application/provider-catalog.test.ts | 2 +- .../application/provider-catalog.ts | 2 +- 4 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 apps/sim/app/api/auth/instagram/authorize/route.test.ts diff --git a/apps/sim/app/api/auth/instagram/authorize/route.test.ts b/apps/sim/app/api/auth/instagram/authorize/route.test.ts new file mode 100644 index 00000000000..7c5eb98a75a --- /dev/null +++ b/apps/sim/app/api/auth/instagram/authorize/route.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + checkWorkspaceAccess: vi.fn(), + createConnectDraft: vi.fn(), + getSession: vi.fn(), + requireConfiguredOAuthClient: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mocks.getSession, +})) + +vi.mock('@/lib/core/config/env-capabilities.server', () => ({ + requireConfiguredOAuthClient: mocks.requireConfiguredOAuthClient, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://sim.test', +})) + +vi.mock('@/lib/credentials/connect-draft', () => ({ + createConnectDraft: mocks.createConnectDraft, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getCanonicalScopesForProvider: () => ['instagram_business_basic'], +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mocks.checkWorkspaceAccess, +})) + +import { GET } from '@/app/api/auth/instagram/authorize/route' + +describe('Instagram authorize route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.requireConfiguredOAuthClient.mockReturnValue({ + values: { INSTAGRAM_CLIENT_ID: 'instagram-client' }, + }) + mocks.checkWorkspaceAccess.mockResolvedValue({ canWrite: true }) + mocks.createConnectDraft.mockResolvedValue({ id: 'draft-created' }) + }) + + it('preserves an exact credential draft when workspaceId is also supplied', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1&draftId=draft-exact' + ) + + const response = await GET(request) + + expect(response.status).toBe(307) + expect(response.headers.get('set-cookie')).toContain( + 'instagram_credential_draft_id=draft-exact' + ) + expect(mocks.checkWorkspaceAccess).not.toHaveBeenCalled() + expect(mocks.createConnectDraft).not.toHaveBeenCalled() + }) + + it('creates a credential draft for a legacy workspace-only launch', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1' + ) + + const response = await GET(request) + + expect(response.status).toBe(307) + expect(response.headers.get('set-cookie')).toContain( + 'instagram_credential_draft_id=draft-created' + ) + expect(mocks.checkWorkspaceAccess).toHaveBeenCalledWith('workspace-1', 'user-1') + expect(mocks.createConnectDraft).toHaveBeenCalledWith({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'instagram', + }) + }) +}) diff --git a/apps/sim/app/api/auth/instagram/authorize/route.ts b/apps/sim/app/api/auth/instagram/authorize/route.ts index 7d2c0270796..84b0154e706 100644 --- a/apps/sim/app/api/auth/instagram/authorize/route.ts +++ b/apps/sim/app/api/auth/instagram/authorize/route.ts @@ -38,7 +38,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { returnUrl, workspaceId, draftId } = parsed.data.query let credentialDraftId = draftId - if (workspaceId) { + if (workspaceId && !draftId) { const access = await checkWorkspaceAccess(workspaceId, session.user.id) if (!access.canWrite) { return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 }) diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts index 08a452dd5c2..f485a80465d 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -144,7 +144,7 @@ describe('listCredentialProviderCatalog', () => { description: 'Connect Trello.', providerFamily: 'trello', available: false, - supportsReconnect: false, + supportsReconnect: true, authorizationOptions: [{ providerId: 'trello', label: 'Trello' }], }, { diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index 90e8d49d500..7dcdc507dbf 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -280,7 +280,7 @@ export async function listCredentialProviderCatalog( description: service.description, providerFamily: service.baseProvider, available: visibility.isOAuthServiceVisible(service), - supportsReconnect: !['trello', 'shopify'].includes(service.providerId), + supportsReconnect: true, authorizationOptions, } }) From 981f7577a49d13dc752319624c454d93c89d8230 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 13:18:15 -0700 Subject: [PATCH 133/159] fix(credentials): centralize application authorization --- apps/sim/app/api/auth/accounts/route.ts | 82 +-- .../api/auth/oauth/connections/route.test.ts | 6 +- .../app/api/auth/oauth/connections/route.ts | 156 +---- .../api/auth/oauth/disconnect/route.test.ts | 6 +- .../app/api/auth/oauth/disconnect/route.ts | 148 +---- .../api/auth/oauth2/authorize/route.test.ts | 555 +++++------------- .../app/api/auth/oauth2/authorize/route.ts | 91 +-- .../app/api/credentials/[id]/members/route.ts | 452 ++------------ apps/sim/app/api/credentials/[id]/route.ts | 236 ++------ apps/sim/app/api/credentials/draft/route.ts | 114 +--- .../app/api/credentials/memberships/route.ts | 163 ++--- apps/sim/app/api/credentials/route.test.ts | 57 ++ apps/sim/app/api/credentials/route.ts | 328 ++--------- apps/sim/lib/api/contracts/credentials.ts | 42 +- .../server/routes/internal-json-route.test.ts | 58 ++ .../api/server/routes/internal-json-route.ts | 15 +- .../execute-credential-use-case.ts | 14 + .../manage-application-use-cases.test.ts | 52 ++ .../handlers/management/manage-credential.ts | 132 ++--- .../lib/copilot/tools/handlers/oauth.test.ts | 352 +++-------- apps/sim/lib/copilot/tools/handlers/oauth.ts | 200 +------ .../sim/lib/credentials/api/route-policies.ts | 23 + .../credentials/application/authorization.ts | 12 + .../authorized-credential-use-case.test.ts | 105 ++++ .../authorized-credential-use-case.ts | 26 +- .../application/authorized-user-use-case.ts | 73 +++ .../application/connection-target.ts | 13 +- .../create-credential-connection.test.ts | 5 +- .../create-credential-connection.ts | 27 +- .../application/credential-context.ts | 32 + .../application/credential-crud.ts | 239 ++++++++ .../application/credential-members.ts | 148 +++++ .../delete-many-credentials.test.ts | 124 ++++ .../application/delete-many-credentials.ts | 114 ++++ .../list-credential-providers.test.ts | 8 +- .../application/list-credential-providers.ts | 3 +- .../list-workspace-credentials.test.ts | 14 +- .../credentials/application/oauth-accounts.ts | 98 ++++ .../application/operations.test.ts | 9 +- .../lib/credentials/application/operations.ts | 142 ++++- .../prepare-credential-connection.test.ts | 118 ++++ .../prepare-credential-connection.ts | 109 ++++ .../credentials/application/presentation.ts | 29 + .../application/save-credential-draft.test.ts | 109 ++++ .../application/save-credential-draft.ts | 67 +++ .../application/service-account.ts | 88 ++- apps/sim/lib/credentials/connect-draft.ts | 16 +- apps/sim/lib/credentials/members.ts | 265 +++++++++ apps/sim/lib/credentials/oauth-accounts.ts | 135 +++++ .../orchestration/credential-create.ts | 2 +- .../lib/credentials/orchestration/index.ts | 366 ++++++------ apps/sim/lib/credentials/queries.ts | 6 + 52 files changed, 3118 insertions(+), 2666 deletions(-) create mode 100644 apps/sim/lib/copilot/application/execute-credential-use-case.ts create mode 100644 apps/sim/lib/credentials/api/route-policies.ts create mode 100644 apps/sim/lib/credentials/application/authorization.ts create mode 100644 apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts create mode 100644 apps/sim/lib/credentials/application/authorized-user-use-case.ts create mode 100644 apps/sim/lib/credentials/application/credential-context.ts create mode 100644 apps/sim/lib/credentials/application/credential-crud.ts create mode 100644 apps/sim/lib/credentials/application/credential-members.ts create mode 100644 apps/sim/lib/credentials/application/delete-many-credentials.test.ts create mode 100644 apps/sim/lib/credentials/application/delete-many-credentials.ts create mode 100644 apps/sim/lib/credentials/application/oauth-accounts.ts create mode 100644 apps/sim/lib/credentials/application/prepare-credential-connection.test.ts create mode 100644 apps/sim/lib/credentials/application/prepare-credential-connection.ts create mode 100644 apps/sim/lib/credentials/application/save-credential-draft.test.ts create mode 100644 apps/sim/lib/credentials/application/save-credential-draft.ts create mode 100644 apps/sim/lib/credentials/members.ts create mode 100644 apps/sim/lib/credentials/oauth-accounts.ts diff --git a/apps/sim/app/api/auth/accounts/route.ts b/apps/sim/app/api/auth/accounts/route.ts index 016384aa9c7..fd95740bf08 100644 --- a/apps/sim/app/api/auth/accounts/route.ts +++ b/apps/sim/app/api/auth/accounts/route.ts @@ -1,61 +1,23 @@ -import { db } from '@sim/db' -import { account, credential } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, desc, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { connectedAccountsQuerySchema } from '@/lib/api/contracts/oauth-connections' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('AuthAccountsAPI') - -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(request.url) - const { provider } = connectedAccountsQuerySchema.parse({ - provider: searchParams.get('provider') || undefined, - }) - - const whereConditions = [eq(account.userId, session.user.id)] - - if (provider) { - whereConditions.push(eq(account.providerId, provider)) - } - - const accounts = await db - .select({ - id: account.id, - accountId: account.accountId, - providerId: account.providerId, - credentialDisplayName: credential.displayName, - }) - .from(account) - .leftJoin(credential, eq(credential.accountId, account.id)) - .where(and(...whereConditions)) - .orderBy(desc(account.updatedAt)) - - const seen = new Map<string, (typeof accounts)[number]>() - for (const acc of accounts) { - if (!seen.has(acc.id)) { - seen.set(acc.id, acc) - } - } - - const accountsWithDisplayName = Array.from(seen.values()).map((acc) => ({ - id: acc.id, - accountId: acc.accountId, - providerId: acc.providerId, - displayName: acc.credentialDisplayName || acc.accountId || acc.providerId, - })) - - return NextResponse.json({ accounts: accountsWithDisplayName }) - } catch (error) { - logger.error('Failed to fetch accounts', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +import { listConnectedAccountsContract } from '@/lib/api/contracts/oauth-connections' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { listConnectedAccountsUseCase } from '@/lib/credentials/application/oauth-accounts' +import { credentialUserOperations } from '@/lib/credentials/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: listConnectedAccountsContract, + auth: internalSessionAuth, + operation: credentialUserOperations.listConnectedAccounts, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ query }) => query, + useCase: listConnectedAccountsUseCase, }) diff --git a/apps/sim/app/api/auth/oauth/connections/route.test.ts b/apps/sim/app/api/auth/oauth/connections/route.test.ts index 593079aa20c..80db8ab7a39 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.test.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.test.ts @@ -49,6 +49,7 @@ describe('OAuth Connections API Route', () => { it('should return connections successfully', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const mockAccounts = [ @@ -105,12 +106,13 @@ describe('OAuth Connections API Route', () => { const data = await response.json() expect(response.status).toBe(401) - expect(data.error).toBe('User not authenticated') + expect(data.error).toBe('Unauthorized') }) it('should handle user with no connections', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) dbChainMockFns.where.mockResolvedValueOnce([]) @@ -128,6 +130,7 @@ describe('OAuth Connections API Route', () => { it('should handle database error', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error')) @@ -144,6 +147,7 @@ describe('OAuth Connections API Route', () => { it('should decode ID token for display name', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const mockAccounts = [ diff --git a/apps/sim/app/api/auth/oauth/connections/route.ts b/apps/sim/app/api/auth/oauth/connections/route.ts index 9af427f9c17..92813d0638c 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.ts @@ -1,139 +1,19 @@ -import { account, db, user } from '@sim/db' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { decodeJwt } from 'jose' -import { type NextRequest, NextResponse } from 'next/server' -import type { OAuthConnection } from '@/lib/api/contracts/oauth-connections' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { OAuthProvider } from '@/lib/oauth' -import { parseProvider } from '@/lib/oauth' - -const logger = createLogger('OAuthConnectionsAPI') - -interface GoogleIdToken { - email?: string - sub?: string - name?: string -} - -/** - * Get all OAuth connections for the current user - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - // Get the session - const session = await getSession() - - // Check if the user is authenticated - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - - // Get all accounts for this user - const accounts = await db.select().from(account).where(eq(account.userId, session.user.id)) - - // Get the user's email for fallback - const userRecord = await db - .select({ email: user.email }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) - - const userEmail = userRecord.length > 0 ? userRecord[0]?.email : null - - // Process accounts to determine connections - const connections: OAuthConnection[] = [] - - for (const acc of accounts) { - const { baseProvider, featureType } = parseProvider(acc.providerId as OAuthProvider) - const scopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : [] - - if (baseProvider) { - // Try multiple methods to get a user-friendly display name - let displayName = '' - - // Method 1: Try to extract email from ID token (works for Google, etc.) - if (acc.idToken) { - try { - const decoded = decodeJwt<GoogleIdToken>(acc.idToken) - if (decoded.email) { - displayName = decoded.email - } else if (decoded.name) { - displayName = decoded.name - } - } catch (_error) { - logger.warn(`[${requestId}] Error decoding ID token`, { - accountId: acc.id, - }) - } - } - - // Method 2: For GitHub, the accountId might be the username - if (!displayName && baseProvider === 'github') { - displayName = `${acc.accountId} (GitHub)` - } - - // Method 3: Use the user's email from our database - if (!displayName && userEmail) { - displayName = userEmail - } - - // Fallback: Use accountId with provider type as context - if (!displayName) { - displayName = `${acc.accountId} (${baseProvider})` - } - - // Create a unique connection key that includes the full provider ID - const connectionKey = acc.providerId - - // Find existing connection for this specific provider ID - const existingConnection = connections.find((conn) => conn.provider === connectionKey) - - const accountSummary = { - id: acc.id, - name: displayName, - } - - if (existingConnection) { - // Add account to existing connection - existingConnection.accounts = existingConnection.accounts || [] - existingConnection.accounts.push(accountSummary) - - existingConnection.scopes = Array.from( - new Set([...(existingConnection.scopes || []), ...scopes]) - ) - - const existingTimestamp = existingConnection.lastConnected - ? new Date(existingConnection.lastConnected).getTime() - : 0 - const candidateTimestamp = acc.updatedAt.getTime() - - if (candidateTimestamp > existingTimestamp) { - existingConnection.lastConnected = acc.updatedAt.toISOString() - } - } else { - // Create new connection - connections.push({ - provider: connectionKey, - baseProvider, - featureType, - isConnected: true, - scopes, - lastConnected: acc.updatedAt.toISOString(), - accounts: [accountSummary], - }) - } - } - } - - return NextResponse.json({ connections }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching OAuth connections`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +import { listOAuthConnectionsContract } from '@/lib/api/contracts/oauth-connections' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' +import { listOAuthConnectionsUseCase } from '@/lib/credentials/application/oauth-accounts' +import { credentialUserOperations } from '@/lib/credentials/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: listOAuthConnectionsContract, + auth: internalSessionAuth, + operation: credentialUserOperations.listOAuthConnections, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + mapInput: () => ({}), + useCase: listOAuthConnectionsUseCase, }) diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts index 757ea76c9df..e1dd3aa2eec 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts @@ -26,6 +26,7 @@ describe('OAuth Disconnect API Route', () => { it('should disconnect provider successfully', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const req = createMockRequest('POST', { @@ -42,6 +43,7 @@ describe('OAuth Disconnect API Route', () => { it('should disconnect specific provider ID successfully', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const req = createMockRequest('POST', { @@ -67,12 +69,13 @@ describe('OAuth Disconnect API Route', () => { const data = await response.json() expect(response.status).toBe(401) - expect(data.error).toBe('User not authenticated') + expect(data.error).toBe('Unauthorized') }) it('should handle missing provider', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const req = createMockRequest('POST', {}) @@ -87,6 +90,7 @@ describe('OAuth Disconnect API Route', () => { it('should handle database error', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error')) diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.ts b/apps/sim/app/api/auth/oauth/disconnect/route.ts index c3c145e60e7..d53f89128b7 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.ts @@ -1,132 +1,26 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { account, credential } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, inArray, like, or } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { disconnectOAuthContract } from '@/lib/api/contracts/oauth-connections' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteCredential } from '@/lib/credentials/deletion' -import { providerIdsForService } from '@/lib/oauth/utils' -import { captureServerEvent } from '@/lib/posthog/server' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { disconnectOAuthUseCase } from '@/lib/credentials/application/oauth-accounts' +import { credentialUserOperations } from '@/lib/credentials/application/operations' export const dynamic = 'force-dynamic' -const logger = createLogger('OAuthDisconnectAPI') - -/** - * Disconnect an OAuth provider for the current user - */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const session = await getSession() - - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated disconnect request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - - const parsed = await parseRequest( - disconnectOAuthContract, - request, - {}, - { - validationErrorResponse: (error) => { - logger.warn(`[${requestId}] Invalid disconnect request`, { errors: error.issues }) - return NextResponse.json( - { error: getValidationErrorMessage(error, 'Validation failed') }, - { status: 400 } - ) - }, - } - ) - if (!parsed.success) return parsed.response - - const { provider, providerId, accountId } = parsed.data.body - - logger.info(`[${requestId}] Processing OAuth disconnect request`, { - provider, - hasProviderId: !!providerId, - }) - - // Delete credentials before their accounts so deleteCredential can clear - // stored references first. Otherwise FK CASCADE would orphan them silently. - const accountFilter = accountId - ? and(eq(account.userId, session.user.id), eq(account.id, accountId)) - : providerId - ? and(eq(account.userId, session.user.id), eq(account.providerId, providerId)) - : and( - eq(account.userId, session.user.id), - or( - // The prefix sweep already caught `{base}-{feature}` ids by - // accident; an alternate authorization server shares that shape, - // so name it explicitly rather than relying on the accident. - inArray(account.providerId, providerIdsForService(provider)), - like(account.providerId, `${provider}-%`) - ) - ) - - const targetAccounts = await db.select({ id: account.id }).from(account).where(accountFilter) - - const targetAccountIds = targetAccounts.map((a) => a.id) - - if (targetAccountIds.length > 0) { - const credentialsToDelete = await db - .select({ - id: credential.id, - workspaceId: credential.workspaceId, - providerId: credential.providerId, - }) - .from(credential) - .where(inArray(credential.accountId, targetAccountIds)) - - for (const cred of credentialsToDelete) { - await deleteCredential({ - credentialId: cred.id, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - reason: 'oauth_disconnect', - request, - }) - - captureServerEvent( - session.user.id, - 'credential_deleted', - { - credential_type: 'oauth', - provider_id: cred.providerId ?? providerId ?? provider, - workspace_id: cred.workspaceId, - }, - { groups: { workspace: cred.workspaceId } } - ) - } - - await db.delete(account).where(inArray(account.id, targetAccountIds)) - } - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.OAUTH_DISCONNECTED, - resourceType: AuditResourceType.OAUTH, - resourceId: providerId ?? provider, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: provider, - description: `Disconnected OAuth provider: ${provider}`, - metadata: { provider, providerId }, - request, - }) - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error disconnecting OAuth provider`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const POST = defineInternalJsonRoute({ + contract: disconnectOAuthContract, + auth: internalSessionAuth, + operation: credentialUserOperations.disconnectOAuth, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ body }) => body, + useCase: disconnectOAuthUseCase, + present: () => ({ success: true as const }), }) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index a39d6f8f0a2..c4a275a840d 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -1,455 +1,210 @@ /** * @vitest-environment node */ -import { - createMockRequest, - dbChainMockFns, - queueTableRows, - resetDbChainMock, - resetEnvMock, - schemaMock, - setEnv, -} from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockGetSession, - mockOAuth2LinkAccount, - mockCheckWorkspaceAccess, - mockGetCredentialActorContext, - mockLaunchCredentialConnection, -} = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockOAuth2LinkAccount: vi.fn(), - mockCheckWorkspaceAccess: vi.fn(), - mockGetCredentialActorContext: vi.fn(), - mockLaunchCredentialConnection: vi.fn(), +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + linkAccount: vi.fn(), + getBaseUrl: vi.fn(), + requireClient: vi.fn(), + createConnection: vi.fn(), + launchConnection: vi.fn(), })) vi.mock('@/lib/auth/auth', () => ({ - auth: { api: { oAuth2LinkAccount: mockOAuth2LinkAccount } }, - getSession: mockGetSession, + getSession: mocks.getSession, + auth: { api: { oAuth2LinkAccount: mocks.linkAccount } }, })) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - checkWorkspaceAccess: mockCheckWorkspaceAccess, +vi.mock('@/lib/core/utils/urls', () => ({ + SITE_URL: 'https://www.sim.ai', + getBaseUrl: mocks.getBaseUrl, })) - -vi.mock('@/lib/credentials/access', () => ({ - getCredentialActorContext: mockGetCredentialActorContext, +vi.mock('@/lib/core/config/env-capabilities.server', () => ({ + requireConfiguredOAuthClient: mocks.requireClient, + wireServerFallback: () => ({ + configured: false, + providerIds: [], + providers: [], + execute: vi.fn(), + }), +})) +vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ + createCredentialConnection: { + operation: { id: 'credentials.connections.create' }, + execute: mocks.createConnection, + }, })) - vi.mock('@/lib/credentials/application/launch-credential-connection', () => ({ launchCredentialConnection: { operation: { id: 'credentials.connections.launch' }, - execute: mockLaunchCredentialConnection, + execute: mocks.launchConnection, }, })) -vi.mock('@/lib/oauth/utils', () => ({ - getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]), - // Real implementation: a credential id matches its service's OAuth id, an - // alternate authorization server, or the family's service-account id. - credentialProviderMatchesService: ( - credentialProviderId: string, - service: { - providerId: string - serviceAccountProviderId?: string - additionalProviderIds?: readonly string[] - } - ) => - service.providerId === credentialProviderId || - service.serviceAccountProviderId === credentialProviderId || - (service.additionalProviderIds?.includes(credentialProviderId) ?? false), -})) - import { GET } from '@/app/api/auth/oauth2/authorize/route' const BASE_URL = 'https://sim.test' -const WORKSPACE_ID = 'ws-1' -const USER_ID = 'user-1' -const CREDENTIAL_ID = 'cred-1' -const LINK_URL = 'https://provider.example/authorize?state=abc' +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' -function authorizeRequest(query: Record<string, string>) { - const url = new URL(`${BASE_URL}/api/auth/oauth2/authorize`) - for (const [key, value] of Object.entries(query)) { - url.searchParams.set(key, value) - } +function request(query: Record<string, string>) { + const url = new URL('/api/auth/oauth2/authorize', BASE_URL) + for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value) return createMockRequest('GET', undefined, {}, url.toString()) } -function oauthCredentialActor(overrides: Record<string, unknown> = {}) { - return { - credential: { - id: CREDENTIAL_ID, - workspaceId: WORKSPACE_ID, - type: 'oauth', - providerId: 'google-email', - displayName: 'Work Gmail', - ...((overrides.credential as Record<string, unknown>) ?? {}), - }, - member: null, - hasWorkspaceAccess: true, - canWriteWorkspace: true, - isAdmin: true, - ...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')), - } +function linkResponse(url = 'https://provider.example/authorize') { + return new Response(JSON.stringify({ url }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) } describe('OAuth2 authorize route', () => { - afterAll(() => { - resetEnvMock() - }) - beforeEach(() => { vi.clearAllMocks() - resetDbChainMock() - setEnv({ - NEXT_PUBLIC_APP_URL: BASE_URL, - GOOGLE_CLIENT_ID: 'google-client', - GOOGLE_CLIENT_SECRET: 'google-secret', - }) - mockGetSession.mockResolvedValue({ - user: { id: USER_ID }, + mocks.getBaseUrl.mockReturnValue(BASE_URL) + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, session: { id: 'session-1' }, }) - queueTableRows(schemaMock.user, [{ name: 'Test User' }]) - dbChainMockFns.onConflictDoUpdate.mockImplementation(() => ({ - returning: vi - .fn() - .mockResolvedValue([{ id: 'draft-1', expiresAt: new Date('2026-08-12T20:15:00.000Z') }]), - })) - mockCheckWorkspaceAccess.mockResolvedValue({ - hasAccess: true, - canWrite: true, - canAdmin: false, - workspace: { id: WORKSPACE_ID }, - }) - mockOAuth2LinkAccount.mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ url: LINK_URL }), - headers: { getSetCookie: () => ['better-auth.state=xyz; Path=/'] }, - }) + mocks.createConnection.mockResolvedValue({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + draftId: 'draft-1', + expiresAt: new Date('2026-08-14T12:00:00.000Z'), + authorizationUrl: `${BASE_URL}/api/auth/oauth2/authorize?draftId=draft-1`, + }) + mocks.launchConnection.mockResolvedValue({ + draft: { + id: 'draft-1', + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: null, + }, + }) + mocks.linkAccount.mockResolvedValue(linkResponse()) }) - describe('draft-bound connection', () => { - it('resolves the exact user-bound draft before starting OAuth', async () => { - mockLaunchCredentialConnection.mockResolvedValue({ - draft: { - id: 'draft-1', - userId: USER_ID, - workspaceId: WORKSPACE_ID, - providerId: 'google-email', - displayName: "Test User's Gmail", - description: null, - credentialId: null, - expiresAt: new Date('2026-08-12T20:15:00.000Z'), - createdAt: new Date('2026-08-12T20:00:00.000Z'), - }, - }) - const request = authorizeRequest({ draftId: 'draft-1' }) - - const response = await GET(request) + it('creates a canonical application draft for a legacy connect URL', async () => { + const response = await GET(request({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - expect(response.headers.get('location')).toBe(LINK_URL) - expect(mockLaunchCredentialConnection).toHaveBeenCalledWith({ - principal: { kind: 'session', userId: USER_ID, sessionId: 'session-1' }, - input: { draftId: 'draft-1' }, - request, + expect(response.headers.get('location')).toBe('https://provider.example/authorize') + expect(mocks.createConnection).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: WORKSPACE_ID, providerId: 'google-email' }, }) - expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).toHaveBeenCalledWith( - expect.objectContaining({ - body: { - providerId: 'google-email', - callbackURL: `${BASE_URL}/oauth/credential-connected?result=connected&credentialDraftId=draft-1`, - errorCallbackURL: `${BASE_URL}/oauth/credential-connected?result=failed`, - }, - }) - ) - }) - - it('hands custom providers to their authenticated browser flow', async () => { - setEnv({ TRELLO_API_KEY: 'trello-key' }) - mockLaunchCredentialConnection.mockResolvedValue({ - draft: { - id: 'draft-1', - userId: USER_ID, - workspaceId: WORKSPACE_ID, - providerId: 'trello', - displayName: "Test User's Trello", - description: null, - credentialId: null, - expiresAt: new Date('2026-08-12T20:15:00.000Z'), - createdAt: new Date('2026-08-12T20:00:00.000Z'), - }, - }) - - const response = await GET(authorizeRequest({ draftId: 'draft-1' })) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/api/auth/trello/authorize?returnUrl=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected%3Fresult%3Dconnected&draftId=draft-1` - ) - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) - }) - - describe('plain connect (no credentialId)', () => { - it('creates a draft with credentialId null and redirects to the provider', async () => { - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) - - expect(response.headers.get('location')).toBe(LINK_URL) - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ - userId: USER_ID, - workspaceId: WORKSPACE_ID, + ) + expect(mocks.linkAccount).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ providerId: 'google-email', - credentialId: null, - }) - ) - expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ setWhere: expect.anything() }) - ) - expect(mockOAuth2LinkAccount).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ - callbackURL: `${BASE_URL}/workspace?credentialDraftId=draft-1`, - }), - }) - ) - }) - - it('numbers the draft display name when the default collides with an existing credential', async () => { - dbChainMockFns.where - .mockImplementationOnce(() => Promise.resolve([{ name: 'Justin' }])) - .mockImplementationOnce(() => Promise.resolve([{ displayName: "Justin's Gmail" }])) - - await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ displayName: "Justin's Gmail 2" }) - ) - }) - - it('does not overwrite a reconnect intent when refreshing a plain connect', async () => { - await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - - const [{ set, setWhere }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0] - expect(set).not.toHaveProperty('credentialId') - expect(setWhere).toBeDefined() - }) - - it('rejects an OAuth client that is not configured for the deployment', async () => { - setEnv({ GOOGLE_CLIENT_ID: undefined, GOOGLE_CLIENT_SECRET: undefined }) - - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) - - expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) - - it('redirects to login when unauthenticated', async () => { - mockGetSession.mockResolvedValue(null) - - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) - - expect(response.headers.get('location')).toContain('/login') - expect(dbChainMockFns.values).not.toHaveBeenCalled() - }) - - it('rejects without workspace write access', async () => { - mockCheckWorkspaceAccess.mockResolvedValue({ - hasAccess: true, - canWrite: false, - canAdmin: false, - workspace: { id: WORKSPACE_ID }, + callbackURL: expect.stringContaining('credentialDraftId=draft-1'), + }), }) - - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=workspace_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) + ) }) - describe('reconnect (credentialId present)', () => { - it('creates a reconnect draft and guards conflict refreshes by intent', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) - - expect(response.headers.get('location')).toBe(LINK_URL) - expect(mockGetCredentialActorContext).toHaveBeenCalledWith( - CREDENTIAL_ID, - USER_ID, - expect.objectContaining({ workspaceAccess: expect.anything() }) - ) - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ credentialId: CREDENTIAL_ID }) - ) - expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ setWhere: expect.anything() }) - ) - }) - - it("uses the credential's actual display name for the reconnect draft (audit accuracy)", async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { displayName: 'Renamed By User' } }) - ) + it('launches an exact draft without creating another one', async () => { + const response = await GET(request({ draftId: 'draft-1' })) - await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) + expect(response.headers.get('location')).toBe('https://provider.example/authorize') + expect(mocks.launchConnection).toHaveBeenCalledWith( + expect.objectContaining({ input: { draftId: 'draft-1' } }) + ) + expect(mocks.createConnection).not.toHaveBeenCalled() + }) - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ displayName: 'Renamed By User' }) - ) + it('passes reconnect provider assertions through the application use case', async () => { + mocks.createConnection.mockResolvedValue({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + draftId: 'draft-1', + expiresAt: new Date(), + authorizationUrl: '', }) - it('binds custom-provider reconnects to the exact draft', async () => { - setEnv({ - TRELLO_API_KEY: 'trello-key', - SHOPIFY_CLIENT_ID: 'shopify-client', - SHOPIFY_CLIENT_SECRET: 'shopify-secret', + await GET( + request({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', }) - for (const providerId of ['trello', 'shopify']) { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { providerId } }) - ) - const response = await GET( - authorizeRequest({ providerId, workspaceId: WORKSPACE_ID, credentialId: CREDENTIAL_ID }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/api/auth/${providerId}/authorize?returnUrl=https%3A%2F%2Fsim.test%2Fworkspace&draftId=draft-1` - ) - } - expect(mockGetCredentialActorContext).toHaveBeenCalledTimes(2) - expect(dbChainMockFns.values).toHaveBeenCalledTimes(2) - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) - - it('rejects when the caller is not a credential admin and writes no draft', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false })) - - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) - - it('rejects when the credential belongs to a different workspace', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { workspaceId: 'ws-other' } }) - ) + ) - const response = await GET( - authorizeRequest({ - providerId: 'google-email', + expect(mocks.createConnection).toHaveBeenCalledWith( + expect.objectContaining({ + input: { workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - }) - - it('rejects when the credential does not exist', async () => { - mockGetCredentialActorContext.mockResolvedValue({ - credential: null, - member: null, - hasWorkspaceAccess: false, - canWriteWorkspace: false, - isAdmin: false, + credentialId: 'credential-1', + assertedProviderId: 'google-email', + }, }) + ) + }) - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: 'cred-missing', - }) - ) + it('maps a provider mismatch without exposing the credential', async () => { + mocks.createConnection.mockRejectedValue( + new CredentialConnectionProviderMismatchError('google-email', 'slack') + ) - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - }) + const response = await GET( + request({ + providerId: 'slack', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + }) + ) - it('rejects a non-oauth credential', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { type: 'env_workspace' } }) - ) + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=credential_provider_mismatch` + ) + }) - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) + it('maps credential and workspace authorization failures separately', async () => { + mocks.createConnection.mockRejectedValueOnce( + new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Credential admin permission required' + ) + ) + const credentialResponse = await GET( + request({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + }) + ) + mocks.createConnection.mockRejectedValueOnce( + new OrchestrationError('forbidden', 'Write permission required') + ) + const workspaceResponse = await GET( + request({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) + ) + + expect(credentialResponse.headers.get('location')).toContain('credential_access_denied') + expect(workspaceResponse.headers.get('location')).toContain('workspace_access_denied') + }) - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() + it('routes custom providers through the exact application draft', async () => { + mocks.createConnection.mockResolvedValue({ + providerId: 'trello', + workspaceId: WORKSPACE_ID, + draftId: 'draft-1', + expiresAt: new Date(), + authorizationUrl: '', }) - it('rejects when the query providerId does not match the credential provider', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) + const response = await GET(request({ providerId: 'trello', workspaceId: WORKSPACE_ID })) + const location = new URL(response.headers.get('location') ?? '') - const response = await GET( - authorizeRequest({ - providerId: 'slack', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_provider_mismatch` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) + expect(location.pathname).toBe('/api/auth/trello/authorize') + expect(location.searchParams.get('draftId')).toBe('draft-1') }) }) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 98543d53099..61ce6823600 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -3,15 +3,15 @@ import { type NextRequest, NextResponse } from 'next/server' import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections' import { parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth/auth' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredentialActorContext } from '@/lib/credentials/access' +import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' -import { createConnectDraft } from '@/lib/credentials/connect-draft' import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/oauth-draft-state' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('OAuth2Authorize') @@ -30,6 +30,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(loginUrl.toString()) } const userId = session.user.id + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') + const principal = { kind: 'session' as const, userId, sessionId } const parsed = await parseRequest(authorizeOAuth2Contract, request, {}) if (!parsed.success) return parsed.response @@ -40,14 +43,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { let connectionDraftId: string | undefined if (draftId) { try { - const sessionId = session.session?.id - if (!sessionId) throw new Error('Authenticated session is missing its session ID') const { draft } = await launchCredentialConnection.execute({ - principal: { - kind: 'session', - userId, - sessionId, - }, + principal, input: { draftId }, request, }) @@ -76,66 +73,40 @@ export const GET = withRouteHandler(async (request: NextRequest) => { : `${baseUrl}/workspace` try { - let reconnectDisplayName: string | undefined if (!fromConnectionDraft) { - const access = await checkWorkspaceAccess(workspaceId, userId) - if (!access.canWrite) { - logger.warn('Workspace write access denied for OAuth2 authorize', { - userId, - workspaceId, - providerId, + try { + const connection = await createCredentialConnection.execute({ + principal, + input: credentialId + ? { workspaceId, credentialId, assertedProviderId: providerId } + : { workspaceId, providerId }, + request, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) - } - - if (credentialId) { - // Reconnect: the OAuth callback will rebind this credential to the fresh - // account, so require the same credential-admin access as the draft POST - // route — workspace write alone must not be enough to swap someone's tokens. - const actor = await getCredentialActorContext(credentialId, userId, { - workspaceAccess: access, - }) - if ( - !actor.credential || - actor.credential.workspaceId !== workspaceId || - actor.credential.type !== 'oauth' || - !actor.isAdmin - ) { - logger.warn('Credential admin access denied for OAuth2 reconnect', { - userId, - workspaceId, - providerId, - credentialId, - }) + providerId = connection.providerId + workspaceId = connection.workspaceId + credentialId = connection.credentialId + connectionDraftId = connection.draftId + } catch (error) { + if (error instanceof CredentialConnectionProviderMismatchError) { + return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) + } + if (credentialId && error instanceof ForbiddenOperationError) { return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) } - if (actor.credential.providerId !== providerId) { - logger.warn('Provider mismatch for OAuth2 reconnect', { - userId, - workspaceId, - providerId, - credentialId, - credentialProviderId: actor.credential.providerId, - }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) + if (error instanceof OrchestrationError && error.code === 'not_found') { + return NextResponse.redirect( + `${baseUrl}/workspace?error=${credentialId ? 'credential_access_denied' : 'workspace_access_denied'}` + ) } - reconnectDisplayName = actor.credential.displayName + if (error instanceof OrchestrationError && error.code === 'forbidden') { + return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) + } + throw error } } requireConfiguredOAuthClient(providerId) - if (!draftId) { - const draft = await createConnectDraft({ - userId, - workspaceId, - providerId, - credentialId, - displayName: reconnectDisplayName, - }) - connectionDraftId = draft.id - } - if (!connectionDraftId) { throw new Error('OAuth authorization is missing its credential draft id') } diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts index 72132ee56d0..2e47b6b4354 100644 --- a/apps/sim/app/api/credentials/[id]/members/route.ts +++ b/apps/sim/app/api/credentials/[id]/members/route.ts @@ -1,406 +1,64 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { credential, credentialMember, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { + listWorkspaceCredentialMembersContract, + removeWorkspaceCredentialMemberContract, upsertWorkspaceCredentialMemberContract, - type WorkspaceCredentialMember, } from '@/lib/api/contracts/credentials' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deriveCredentialAdmin, isSharedCredentialType } from '@/lib/credentials/access' -import { captureServerEvent } from '@/lib/posthog/server' import { - getUserEntityPermissions, - getUsersWithPermissions, -} from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('CredentialMembersAPI') - -interface RouteContext { - params: Promise<{ id: string }> -} - -async function requireCredentialAdmin(credentialId: string, userId: string) { - const [cred] = await db - .select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type }) - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - - if (!cred) return null - - const perm = await getUserEntityPermissions(userId, 'workspace', cred.workspaceId) - if (perm === null) return null - - const [membership] = await db - .select({ role: credentialMember.role, status: credentialMember.status }) - .from(credentialMember) - .where( - and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId)) - ) - .limit(1) - - const isAdmin = deriveCredentialAdmin({ - credentialType: cred.type, - memberRole: membership?.status === 'active' ? membership.role : null, - workspaceCanAdmin: perm === 'admin', - }) - - if (!isAdmin) { - return null - } - return { credentialType: cred.type, workspaceId: cred.workspaceId } -} - -export const GET = withRouteHandler(async (_request: NextRequest, context: RouteContext) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: credentialId } = await context.params - - const [cred] = await db - .select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type }) - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - - if (!cred) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const callerPerm = await getUserEntityPermissions( - session.user.id, - 'workspace', - cred.workspaceId - ) - if (callerPerm === null) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const explicitMembers = await db - .select({ - id: credentialMember.id, - userId: credentialMember.userId, - role: credentialMember.role, - status: credentialMember.status, - joinedAt: credentialMember.joinedAt, - userName: user.name, - userEmail: user.email, - }) - .from(credentialMember) - .innerJoin(user, eq(credentialMember.userId, user.id)) - .where(eq(credentialMember.credentialId, credentialId)) - - const byUser = new Map<string, WorkspaceCredentialMember>( - explicitMembers.map((m) => [ - m.userId, - { - id: m.id, - userId: m.userId, - role: m.role, - status: m.status, - joinedAt: m.joinedAt ? m.joinedAt.toISOString() : null, - userName: m.userName, - userEmail: m.userEmail, - roleSource: 'explicit' as const, - }, - ]) - ) - - if (isSharedCredentialType(cred.type)) { - const workspaceMembers = await getUsersWithPermissions(cred.workspaceId) - for (const wsMember of workspaceMembers) { - if (wsMember.permissionType !== 'admin') continue - const existing = byUser.get(wsMember.userId) - if (existing) { - existing.role = 'admin' - existing.status = 'active' - existing.roleSource = 'workspace-admin' - } else { - byUser.set(wsMember.userId, { - id: `workspace-admin-${wsMember.userId}`, - userId: wsMember.userId, - role: 'admin', - status: 'active', - joinedAt: null, - userName: wsMember.name, - userEmail: wsMember.email, - roleSource: 'workspace-admin', - }) - } - } - } - - const members = Array.from(byUser.values()) - - return NextResponse.json({ members }) - } catch (error) { - logger.error('Failed to fetch credential members', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { + listCredentialMembersUseCase, + removeCredentialMemberUseCase, + upsertCredentialMemberUseCase, +} from '@/lib/credentials/application/credential-members' +import { credentialOperations } from '@/lib/credentials/application/operations' + +const rateLimit = internalRateLimits.none({ reason: 'Preserve existing internal behavior' }) + +export const GET = defineInternalJsonRoute({ + contract: listWorkspaceCredentialMembersContract, + auth: internalSessionAuth, + operation: credentialOperations.listMembers, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params }) => ({ credentialId: params.id }), + useCase: listCredentialMembersUseCase, + present: ({ members }) => ({ + members: members.map((member) => ({ + ...member, + joinedAt: member.joinedAt?.toISOString() ?? null, + })), + }), }) -export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: credentialId } = await context.params - - const admin = await requireCredentialAdmin(credentialId, session.user.id) - if (!admin) { - logger.warn('Credential member share denied', { - credentialId, - actorId: session.user.id, - reason: 'not-admin', - }) - return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) - } - if (!isSharedCredentialType(admin.credentialType)) { - logger.warn('Credential member share denied', { - credentialId, - actorId: session.user.id, - reason: 'env_personal-cannot-be-shared', - }) - return NextResponse.json({ error: 'Personal secrets cannot be shared' }, { status: 400 }) - } - - const parsed = await parseRequest(upsertWorkspaceCredentialMemberContract, request, context) - if (!parsed.success) return parsed.response - - const { userId, role } = parsed.data.body - - const targetWorkspacePerm = await getUserEntityPermissions( - userId, - 'workspace', - admin.workspaceId - ) - if (targetWorkspacePerm === 'admin' && role !== 'admin') { - return NextResponse.json( - { error: 'Workspace admins are automatically credential admins and cannot be demoted' }, - { status: 400 } - ) - } - - const now = new Date() - - const [existing] = await db - .select({ id: credentialMember.id, status: credentialMember.status }) - .from(credentialMember) - .where( - and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId)) - ) - .limit(1) - - if (existing) { - const result = await db.transaction(async (tx) => { - const [current] = await tx - .select({ role: credentialMember.role, status: credentialMember.status }) - .from(credentialMember) - .where(eq(credentialMember.id, existing.id)) - .limit(1) - .for('update') - if ( - !isSharedCredentialType(admin.credentialType) && - current?.role === 'admin' && - current?.status === 'active' && - role !== 'admin' - ) { - const activeAdmins = await tx - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.role, 'admin'), - eq(credentialMember.status, 'active') - ) - ) - .for('update') - if (activeAdmins.length <= 1) return { ok: false as const } - } - await tx - .update(credentialMember) - .set({ role, status: 'active', updatedAt: now }) - .where(eq(credentialMember.id, existing.id)) - return { ok: true as const, fromRole: current?.role } - }) - if (!result.ok) { - return NextResponse.json({ error: 'Cannot demote the last admin' }, { status: 400 }) - } - - recordAudit({ - workspaceId: admin.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_MEMBER_ROLE_CHANGED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - description: `Changed credential member role to "${role}"`, - metadata: { targetUserId: userId, fromRole: result.fromRole, toRole: role }, - request, - }) - - return NextResponse.json({ success: true }) - } - - await db.insert(credentialMember).values({ - id: generateId(), - credentialId, - userId, - role, - status: 'active', - joinedAt: now, - invitedBy: session.user.id, - createdAt: now, - updatedAt: now, - }) - - captureServerEvent(session.user.id, 'credential_shared', { - credential_type: admin.credentialType, - role, - workspace_id: admin.workspaceId, - }) - - recordAudit({ - workspaceId: admin.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_MEMBER_ADDED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - description: `Shared credential with member as "${role}"`, - metadata: { targetUserId: userId, role }, - request, - }) - - return NextResponse.json({ success: true }, { status: 201 }) - } catch (error) { - logger.error('Failed to add credential member', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const POST = defineInternalJsonRoute({ + contract: upsertWorkspaceCredentialMemberContract, + auth: internalSessionAuth, + operation: credentialOperations.upsertMember, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params, body }) => ({ credentialId: params.id, ...body }), + useCase: upsertCredentialMemberUseCase, + present: () => ({ success: true as const }), + statusForResult: ({ created }) => (created ? 201 : 200), }) -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: credentialId } = await context.params - const targetUserId = new URL(request.url).searchParams.get('userId') - if (!targetUserId) { - return NextResponse.json({ error: 'userId query parameter required' }, { status: 400 }) - } - - const admin = await requireCredentialAdmin(credentialId, session.user.id) - if (!admin) { - logger.warn('Credential member removal denied', { - credentialId, - actorId: session.user.id, - reason: 'not-admin', - }) - return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) - } - - const [target] = await db - .select({ - id: credentialMember.id, - role: credentialMember.role, - }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.userId, targetUserId), - eq(credentialMember.status, 'active') - ) - ) - .limit(1) - - if (!target) { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }) - } - - if (isSharedCredentialType(admin.credentialType)) { - const targetWorkspacePerm = await getUserEntityPermissions( - targetUserId, - 'workspace', - admin.workspaceId - ) - if (targetWorkspacePerm === 'admin') { - return NextResponse.json( - { error: 'Workspace admins are automatically credential admins and cannot be removed' }, - { status: 400 } - ) - } - } - - const revoked = await db.transaction(async (tx) => { - if (!isSharedCredentialType(admin.credentialType) && target.role === 'admin') { - const activeAdmins = await tx - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.role, 'admin'), - eq(credentialMember.status, 'active') - ) - ) - .for('update') - - if (activeAdmins.length <= 1) { - return false - } - } - - await tx - .update(credentialMember) - .set({ status: 'revoked', updatedAt: new Date() }) - .where(eq(credentialMember.id, target.id)) - - return true - }) - - if (!revoked) { - return NextResponse.json({ error: 'Cannot remove the last admin' }, { status: 400 }) - } - - captureServerEvent(session.user.id, 'credential_unshared', { - credential_type: admin.credentialType, - workspace_id: admin.workspaceId, - }) - - recordAudit({ - workspaceId: admin.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_MEMBER_REMOVED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - description: 'Removed credential member', - metadata: { targetUserId }, - request, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Failed to remove credential member', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const DELETE = defineInternalJsonRoute({ + contract: removeWorkspaceCredentialMemberContract, + auth: internalSessionAuth, + operation: credentialOperations.removeMember, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params, query }) => ({ credentialId: params.id, userId: query.userId }), + useCase: removeCredentialMemberUseCase, + present: () => ({ success: true as const }), }) diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 3ff1de37444..d99ad8382c4 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -1,177 +1,63 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { updateWorkspaceCredentialContract } from '@/lib/api/contracts/credentials' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - type CredentialActorContext, - canUseCredential, - getCredentialActorContext, -} from '@/lib/credentials/access' + deleteWorkspaceCredentialContract, + getWorkspaceCredentialContract, + updateWorkspaceCredentialContract, +} from '@/lib/api/contracts/credentials' import { - isProviderOutageCode, - performDeleteCredential, - performUpdateCredential, -} from '@/lib/credentials/orchestration' - -const logger = createLogger('CredentialByIdAPI') - -function formatCredentialResponse(access: CredentialActorContext) { - const cred = access.credential - if (!cred) return null - - return { - id: cred.id, - workspaceId: cred.workspaceId, - type: cred.type, - displayName: cred.displayName, - description: cred.description, - providerId: cred.providerId, - accountId: cred.accountId, - envKey: cred.envKey, - envOwnerUserId: cred.envOwnerUserId, - createdBy: cred.createdBy, - createdAt: cred.createdAt, - updatedAt: cred.updatedAt, - role: access.isAdmin ? 'admin' : (access.member?.role ?? null), - status: access.member?.status ?? (access.isAdmin ? 'active' : null), - } -} - -export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - - try { - const access = await getCredentialActorContext(id, session.user.id) - if (!access.credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - if (!canUseCredential(access)) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - return NextResponse.json({ credential: formatCredentialResponse(access) }, { status: 200 }) - } catch (error) { - logger.error('Failed to fetch credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) - -export const PUT = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const parsed = await parseRequest(updateWorkspaceCredentialContract, request, context, { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const body = parsed.data.body - - const result = await performUpdateCredential({ - credentialId: id, - userId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - displayName: body.displayName, - description: body.description, - serviceAccountJson: body.serviceAccountJson, - signingSecret: body.signingSecret, - botToken: body.botToken, - apiToken: body.apiToken, - domain: body.domain, - clientId: body.clientId, - clientSecret: body.clientSecret, - certificateId: body.certificateId, - orgId: body.orgId, - dataCenter: body.dataCenter, - authMethod: body.authMethod, - privateKey: body.privateKey, - username: body.username, - request, - }) - if (!result.success) { - const status = - result.errorCode === 'not_found' - ? 404 - : result.errorCode === 'forbidden' - ? 403 - : result.errorCode === 'conflict' - ? 409 - : // A provider outage during reconnect is infra, not a bad - // request — mirror the create route and runtime token route. - // Every provider family names its own outage code, so this - // asks the shared predicate rather than matching one literal. - isProviderOutageCode(result.providerErrorCode) - ? 502 - : result.errorCode === 'validation' - ? 400 - : 500 - return NextResponse.json( - { - error: result.error, - ...(result.providerErrorCode ? { code: result.providerErrorCode } : {}), - }, - { status } - ) - } - - const access = await getCredentialActorContext(id, session.user.id) - return NextResponse.json({ credential: formatCredentialResponse(access) }, { status: 200 }) - } catch (error) { - logger.error('Failed to update credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) - -export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - - try { - const result = await performDeleteCredential({ - credentialId: id, - userId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - request, - }) - if (!result.success) { - const status = - result.errorCode === 'not_found' - ? 404 - : result.errorCode === 'forbidden' - ? 403 - : result.errorCode === 'validation' - ? 400 - : 500 - return NextResponse.json({ error: result.error }, { status }) - } - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error('Failed to delete credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { + getWorkspaceCredentialUseCase, + updateWorkspaceCredentialUseCase, +} from '@/lib/credentials/application/credential-crud' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { toWorkspaceCredential } from '@/lib/credentials/application/presentation' +import { deleteCredentialUseCase } from '@/lib/credentials/application/service-account' + +const rateLimit = internalRateLimits.none({ reason: 'Preserve existing internal behavior' }) + +export const GET = defineInternalJsonRoute({ + contract: getWorkspaceCredentialContract, + auth: internalSessionAuth, + operation: credentialOperations.read, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params }) => ({ credentialId: params.id }), + useCase: getWorkspaceCredentialUseCase, + present: ({ credential, access }) => ({ + credential: toWorkspaceCredential(credential, access), + }), +}) + +export const PUT = defineInternalJsonRoute({ + contract: updateWorkspaceCredentialContract, + auth: internalSessionAuth, + operation: credentialOperations.update, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params, body }) => ({ credentialId: params.id, ...body }), + useCase: updateWorkspaceCredentialUseCase, + present: ({ credential, access }) => ({ + credential: toWorkspaceCredential(credential, access), + }), +}) + +export const DELETE = defineInternalJsonRoute({ + contract: deleteWorkspaceCredentialContract, + auth: internalSessionAuth, + operation: credentialOperations.delete, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params }) => ({ credentialId: params.id }), + useCase: deleteCredentialUseCase, + present: () => ({ success: true as const }), +}) diff --git a/apps/sim/app/api/credentials/draft/route.ts b/apps/sim/app/api/credentials/draft/route.ts index 2e693609438..545101f591c 100644 --- a/apps/sim/app/api/credentials/draft/route.ts +++ b/apps/sim/app/api/credentials/draft/route.ts @@ -1,95 +1,23 @@ -import { db } from '@sim/db' -import { pendingCredentialDraft } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq, lt } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { createCredentialDraftContract } from '@/lib/api/contracts/credentials' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredentialActorContext } from '@/lib/credentials/access' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('CredentialDraftAPI') - -const DRAFT_TTL_MS = 15 * 60 * 1000 - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(createCredentialDraftContract, request, {}) - if (!parsed.success) return parsed.response - - const { workspaceId, providerId, displayName, description, credentialId } = parsed.data.body - const userId = session.user.id - - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) - if (!workspaceAccess.canWrite) { - return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) - } - - if (credentialId) { - const access = await getCredentialActorContext(credentialId, userId, { workspaceAccess }) - if (!access.credential || access.credential.workspaceId !== workspaceId || !access.isAdmin) { - return NextResponse.json( - { error: 'Admin access required on the target credential' }, - { status: 403 } - ) - } - } - - const now = new Date() - - await db - .delete(pendingCredentialDraft) - .where( - and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) - ) - - await db - .insert(pendingCredentialDraft) - .values({ - id: generateId(), - userId, - workspaceId, - providerId, - displayName, - description: description || null, - credentialId: credentialId || null, - expiresAt: new Date(now.getTime() + DRAFT_TTL_MS), - createdAt: now, - }) - .onConflictDoUpdate({ - target: [ - pendingCredentialDraft.userId, - pendingCredentialDraft.providerId, - pendingCredentialDraft.workspaceId, - ], - set: { - displayName, - description: description || null, - credentialId: credentialId || null, - expiresAt: new Date(now.getTime() + DRAFT_TTL_MS), - createdAt: now, - }, - }) - - logger.info('Credential draft saved', { - userId, - workspaceId, - providerId, - displayName, - credentialId: credentialId || null, - }) - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error('Failed to save credential draft', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { saveCredentialDraft } from '@/lib/credentials/application/save-credential-draft' + +export const POST = defineInternalJsonRoute({ + contract: createCredentialDraftContract, + auth: internalSessionAuth, + operation: credentialOperations.saveDraft, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ body }) => body, + useCase: saveCredentialDraft, }) diff --git a/apps/sim/app/api/credentials/memberships/route.ts b/apps/sim/app/api/credentials/memberships/route.ts index 7e855d2caca..6ee05aa1de6 100644 --- a/apps/sim/app/api/credentials/memberships/route.ts +++ b/apps/sim/app/api/credentials/memberships/route.ts @@ -1,121 +1,48 @@ -import { db } from '@sim/db' -import { credential, credentialMember } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { leaveCredentialQuerySchema } from '@/lib/api/contracts/credentials' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('CredentialMembershipsAPI') - -export const GET = withRouteHandler(async () => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const memberships = await db - .select({ - membershipId: credentialMember.id, - credentialId: credential.id, - workspaceId: credential.workspaceId, - type: credential.type, - displayName: credential.displayName, - providerId: credential.providerId, - role: credentialMember.role, - status: credentialMember.status, - joinedAt: credentialMember.joinedAt, - }) - .from(credentialMember) - .innerJoin(credential, eq(credentialMember.credentialId, credential.id)) - .where(eq(credentialMember.userId, session.user.id)) - - return NextResponse.json({ memberships }, { status: 200 }) - } catch (error) { - logger.error('Failed to list credential memberships', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +import { + leaveCredentialMembershipContract, + listCredentialMembershipsContract, +} from '@/lib/api/contracts/credentials' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { + leaveCredentialMembershipUseCase, + listCredentialMembershipsUseCase, +} from '@/lib/credentials/application/credential-members' +import { credentialUserOperations } from '@/lib/credentials/application/operations' + +const rateLimit = internalRateLimits.none({ reason: 'Preserve existing internal behavior' }) + +export const GET = defineInternalJsonRoute({ + contract: listCredentialMembershipsContract, + auth: internalSessionAuth, + operation: credentialUserOperations.listMemberships, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: () => ({}), + useCase: listCredentialMembershipsUseCase, + present: ({ memberships }) => ({ + memberships: memberships.map((membership) => ({ + ...membership, + joinedAt: membership.joinedAt?.toISOString() ?? null, + })), + }), }) -export const DELETE = withRouteHandler(async (request: NextRequest) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const parseResult = leaveCredentialQuerySchema.safeParse({ - credentialId: new URL(request.url).searchParams.get('credentialId'), - }) - if (!parseResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(parseResult.error) }, - { status: 400 } - ) - } - - const { credentialId } = parseResult.data - const [membership] = await db - .select() - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.userId, session.user.id) - ) - ) - .limit(1) - - if (!membership) { - return NextResponse.json({ error: 'Membership not found' }, { status: 404 }) - } - - if (membership.status !== 'active') { - return NextResponse.json({ success: true }, { status: 200 }) - } - - const revoked = await db.transaction(async (tx) => { - if (membership.role === 'admin') { - const activeAdmins = await tx - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.role, 'admin'), - eq(credentialMember.status, 'active') - ) - ) - - if (activeAdmins.length <= 1) { - return false - } - } - - await tx - .update(credentialMember) - .set({ - status: 'revoked', - updatedAt: new Date(), - }) - .where(eq(credentialMember.id, membership.id)) - - return true - }) - - if (!revoked) { - return NextResponse.json( - { error: 'Cannot leave credential as the last active admin' }, - { status: 400 } - ) - } - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error('Failed to leave credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const DELETE = defineInternalJsonRoute({ + contract: leaveCredentialMembershipContract, + auth: internalSessionAuth, + operation: credentialUserOperations.leaveMembership, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ query }) => query, + useCase: leaveCredentialMembershipUseCase, }) diff --git a/apps/sim/app/api/credentials/route.test.ts b/apps/sim/app/api/credentials/route.test.ts index 3a105b71e57..c9580a98073 100644 --- a/apps/sim/app/api/credentials/route.test.ts +++ b/apps/sim/app/api/credentials/route.test.ts @@ -18,11 +18,17 @@ import { TokenServiceAccountValidationError } from '@/lib/credentials/token-serv const { mockCheckWorkspaceAccess, + mockGetCredentialActorContext, mockGetCredentialCreationWorkspaceContext, + mockLoadWorkspace, + mockResolveWorkspacePermission, mockVerifyAndBuildServiceAccountSecret, } = vi.hoisted(() => ({ mockCheckWorkspaceAccess: vi.fn(), + mockGetCredentialActorContext: vi.fn(), mockGetCredentialCreationWorkspaceContext: vi.fn(), + mockLoadWorkspace: vi.fn(), + mockResolveWorkspacePermission: vi.fn(), mockVerifyAndBuildServiceAccountSecret: vi.fn(), })) @@ -33,6 +39,24 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ checkWorkspaceAccess: mockCheckWorkspaceAccess, })) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mockLoadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mockResolveWorkspacePermission, +})) + +vi.mock('@/lib/credentials/access', () => ({ + canUseCredential: (access: { member: unknown; isAdmin: boolean; hasWorkspaceAccess: boolean }) => + access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin), + getCredentialActorContext: mockGetCredentialActorContext, + isSharedCredentialType: (type: string) => type !== 'env_personal', + SHARED_CREDENTIAL_TYPES: ['oauth', 'env_workspace', 'service_account'], +})) + vi.mock('@/lib/credentials/environment', () => ({ getCredentialCreationWorkspaceContext: mockGetCredentialCreationWorkspaceContext, })) @@ -57,6 +81,12 @@ vi.mock('@/lib/credentials/service-account-secret', () => ({ import { GET, POST } from '@/app/api/credentials/route' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const WORKSPACE_CONTEXT = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', +} describe('GET /api/credentials', () => { beforeEach(() => { @@ -64,7 +94,10 @@ describe('GET /api/credentials', () => { resetDbChainMock() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1', name: 'Test User', email: 'test@example.com' }, + session: { id: 'session-1' }, }) + mockLoadWorkspace.mockResolvedValue(WORKSPACE_CONTEXT) + mockResolveWorkspacePermission.mockResolvedValue('read') mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, @@ -120,7 +153,10 @@ describe('POST /api/credentials', () => { resetDbChainMock() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1', name: 'Test User', email: 'test@example.com' }, + session: { id: 'session-1' }, }) + mockLoadWorkspace.mockResolvedValue(WORKSPACE_CONTEXT) + mockResolveWorkspacePermission.mockResolvedValue('write') mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, @@ -132,6 +168,27 @@ describe('POST /api/credentials', () => { memberUserIds: ['user-1'], canWrite: true, }) + mockGetCredentialActorContext.mockResolvedValue({ + credential: { + id: 'credential-1', + workspaceId: WORKSPACE_ID, + type: 'service_account', + displayName: 'Service account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted-blob', + createdBy: 'user-1', + createdAt: new Date('2026-08-11T00:00:00.000Z'), + updatedAt: new Date('2026-08-11T00:00:00.000Z'), + }, + member: { role: 'admin', status: 'active' }, + hasWorkspaceAccess: true, + canWriteWorkspace: true, + isAdmin: true, + }) }) describe('client-credential service accounts', () => { diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 69ec1fb54e2..a653ff0a7d1 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -1,291 +1,51 @@ -import { db } from '@sim/db' -import { credential } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { createWorkspaceCredentialContract, - credentialsListGetQuerySchema, + listWorkspaceCredentialsContract, } from '@/lib/api/contracts/credentials' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { - performCreateCredential, - statusForCredentialOrchestrationError, -} from '@/lib/credentials/orchestration/credential-create' -import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('CredentialsAPI') - -/** - * Thrown by the inner duplicate guard inside the create transaction when a - * concurrent request slipped a row in between the outer existence check and - * our INSERT. The catch maps this to a 409 with a typed `code` so the UI can - * map to a friendly message. - */ -class DuplicateCredentialError extends Error { - constructor() { - super('duplicate_display_name') - this.name = 'DuplicateCredentialError' - } -} - -interface ExistingCredentialSourceParams { - workspaceId: string - type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' - accountId?: string | null - envKey?: string | null - envOwnerUserId?: string | null - displayName?: string | null - providerId?: string | null -} - -type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0] - -async function findExistingCredentialBySourceWith( - exec: DbOrTx, - params: ExistingCredentialSourceParams -) { - const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params - - if (type === 'oauth' && accountId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'oauth'), - eq(credential.accountId, accountId) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'env_workspace' && envKey) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_workspace'), - eq(credential.envKey, envKey) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'env_personal' && envKey && envOwnerUserId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_personal'), - eq(credential.envKey, envKey), - eq(credential.envOwnerUserId, envOwnerUserId) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'service_account' && displayName && providerId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'service_account'), - eq(credential.providerId, providerId), - eq(credential.displayName, displayName) - ) - ) - .limit(1) - return row ?? null - } - - return null -} - -/** - * `return await` is load-bearing, not redundant. Next 16.3.0's Turbopack - * optimizer models a bare `return <asyncCall>()` tail call as returning the - * promise object, then propagates that always-truthy fact through the caller's - * `await`. It concludes `if (existingCredential)` is always taken and — because - * every branch inside that block returns — deletes the entire create path from - * the emitted bundle, so a first-time create throws on `existingCredential.id`. - * Awaiting here makes the optimizer model the resolved value instead. - */ -async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) { - return await findExistingCredentialBySourceWith(db, params) -} - -async function findExistingCredentialBySourceTx( - tx: Parameters<Parameters<typeof db.transaction>[0]>[0], - params: ExistingCredentialSourceParams -) { - return await findExistingCredentialBySourceWith(tx, params) -} - -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const { searchParams } = new URL(request.url) - const rawWorkspaceId = searchParams.get('workspaceId') - const rawType = searchParams.get('type') - const rawProviderId = searchParams.get('providerId') - const rawCredentialId = searchParams.get('credentialId') - const parseResult = credentialsListGetQuerySchema.safeParse({ - workspaceId: rawWorkspaceId?.trim(), - type: rawType?.trim() || undefined, - providerId: rawProviderId?.trim() || undefined, - credentialId: rawCredentialId?.trim() || undefined, - }) - - if (!parseResult.success) { - logger.warn(`[${requestId}] Invalid credential list request`, { - workspaceId: rawWorkspaceId, - type: rawType, - providerId: rawProviderId, - errors: parseResult.error.issues, - }) - return NextResponse.json( - { error: getValidationErrorMessage(parseResult.error) }, - { status: 400 } - ) - } - - const { workspaceId, type, providerId, credentialId: lookupCredentialId } = parseResult.data - const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id) - - if (!workspaceAccess.hasAccess) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - if (lookupCredentialId) { - let [row] = await db - .select({ - id: credential.id, - displayName: credential.displayName, - type: credential.type, - providerId: credential.providerId, - }) - .from(credential) - .where(and(eq(credential.id, lookupCredentialId), eq(credential.workspaceId, workspaceId))) - .limit(1) - - if (!row) { - ;[row] = await db - .select({ - id: credential.id, - displayName: credential.displayName, - type: credential.type, - providerId: credential.providerId, - }) - .from(credential) - .where( - and( - eq(credential.accountId, lookupCredentialId), - eq(credential.workspaceId, workspaceId) - ) - ) - .limit(1) - } - - return NextResponse.json({ credential: row ?? null }) - } - - if (!type || type === 'oauth') { - await syncWorkspaceOAuthCredentialsForUser({ workspaceId, userId: session.user.id }) - } - - const visible = await listVisibleWorkspaceCredentials({ - workspaceId, - userId: session.user.id, - workspaceAccess, - types: type ? [type] : undefined, - providerId, - }) - const credentials = visible.data.map(({ hasServiceAccountKey: _hasKey, ...rest }) => rest) - - return NextResponse.json({ credentials }) - } catch (error) { - logger.error(`[${requestId}] Failed to list credentials`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { + createWorkspaceCredential, + listInternalCredentials, +} from '@/lib/credentials/application/credential-crud' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { toWorkspaceCredential } from '@/lib/credentials/application/presentation' + +export const GET = defineInternalJsonRoute({ + contract: listWorkspaceCredentialsContract, + auth: internalSessionAuth, + operation: credentialOperations.listInternal, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ query }) => query, + useCase: listInternalCredentials, + present: ({ credentials, credential }) => ({ + credentials: credentials.map((row) => toWorkspaceCredential(row)), + ...(credential !== undefined + ? { credential: credential ? toWorkspaceCredential(credential) : null } + : {}), + }), }) -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest( - createWorkspaceCredentialContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const result = await performCreateCredential({ - ...parsed.data.body, - userId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - request, - }) - - if (!result.success) { - logger.warn(`[${requestId}] Credential create rejected`, { - errorCode: result.errorCode, - providerErrorCode: result.providerErrorCode, - }) - const status = statusForCredentialOrchestrationError(result.errorCode, { - providerUnavailable: result.providerUnavailable, - }) - return NextResponse.json( - result.providerErrorCode - ? { code: result.providerErrorCode, error: result.error } - : { error: result.error }, - { status } - ) - } - - if (!result.credential) { - throw new Error('Credential creation succeeded without a credential') - } - - const responseBody = createWorkspaceCredentialContract.response.schema.parse({ - credential: { - ...result.credential, - createdAt: result.credential.createdAt.toISOString(), - updatedAt: result.credential.updatedAt.toISOString(), - }, - }) - - // An existing credential matched the source: an idempotent replay, not a create. - return NextResponse.json(responseBody, { status: result.created ? 201 : 200 }) +export const POST = defineInternalJsonRoute({ + contract: createWorkspaceCredentialContract, + auth: internalSessionAuth, + operation: credentialOperations.create, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ body }) => body, + useCase: createWorkspaceCredential, + present: ({ credential, role, status }) => ({ + credential: { ...toWorkspaceCredential({ ...credential, role }), status }, + }), + statusForResult: ({ created }) => (created ? 201 : 200), }) diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index e3c00abec1f..25a4189a61e 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -46,19 +46,13 @@ export const credentialsListQuerySchema = z.object({ workspaceId: z.string().uuid('Workspace ID must be a valid UUID'), type: workspaceCredentialTypeSchema.optional(), providerId: z.string().optional(), + credentialId: z.string().optional(), }) export const credentialIdParamsSchema = z.object({ id: z.string().min(1), }) -export const credentialsListGetQuerySchema = z.object({ - workspaceId: z.string().uuid('Workspace ID must be a valid UUID'), - type: workspaceCredentialTypeSchema.optional(), - providerId: z.string().optional(), - credentialId: z.string().optional(), -}) - export const serviceAccountJsonSchema = z .string() .min(1, 'Service account JSON key is required') @@ -255,6 +249,18 @@ export const leaveCredentialQuerySchema = z.object({ credentialId: z.string().min(1), }) +export const credentialMembershipSchema = z.object({ + membershipId: z.string(), + credentialId: z.string(), + workspaceId: z.string(), + type: workspaceCredentialTypeSchema, + displayName: z.string(), + providerId: z.string().nullable(), + role: workspaceCredentialRoleSchema, + status: workspaceCredentialMemberStatusSchema, + joinedAt: z.string().nullable(), +}) + export const workspaceCredentialMemberSchema = z.object({ id: z.string(), userId: z.string(), @@ -321,6 +327,7 @@ export const listWorkspaceCredentialsContract = defineRouteContract({ mode: 'json', schema: z.object({ credentials: z.array(workspaceCredentialSchema), + credential: workspaceCredentialSchema.nullable().optional(), }), }, }) @@ -379,6 +386,7 @@ export const createWorkspaceCredentialContract = defineRouteContract({ body: createCredentialBodySchema, response: { mode: 'json', + status: [200, 201], schema: z.object({ credential: workspaceCredentialSchema, }), @@ -417,6 +425,7 @@ export const upsertWorkspaceCredentialMemberContract = defineRouteContract({ body: upsertWorkspaceCredentialMemberBodySchema, response: { mode: 'json', + status: [200, 201], schema: z.object({ success: z.literal(true), member: workspaceCredentialMemberSchema.optional(), @@ -436,3 +445,22 @@ export const removeWorkspaceCredentialMemberContract = defineRouteContract({ }), }, }) + +export const listCredentialMembershipsContract = defineRouteContract({ + method: 'GET', + path: '/api/credentials/memberships', + response: { + mode: 'json', + schema: z.object({ memberships: z.array(credentialMembershipSchema) }), + }, +}) + +export const leaveCredentialMembershipContract = defineRouteContract({ + method: 'DELETE', + path: '/api/credentials/memberships', + query: leaveCredentialQuerySchema, + response: { + mode: 'json', + schema: z.object({ success: z.literal(true) }), + }, +}) diff --git a/apps/sim/lib/api/server/routes/internal-json-route.test.ts b/apps/sim/lib/api/server/routes/internal-json-route.test.ts index 53c0e054622..7f10e36b703 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.test.ts @@ -383,4 +383,62 @@ describe('defineInternalJsonRoute', () => { __privateMetadata: { value: 'ok' }, }) }) + + it('selects a declared success status from the application result', async () => { + const replayableContract = defineRouteContract({ + method: 'POST', + path: '/api/test/internal-json-route', + response: { + mode: 'json', + schema: z.object({ value: z.string() }), + status: [200, 201], + }, + }) + const handler = defineInternalJsonRoute({ + contract: replayableContract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 'created', created: true } + }, + }, + present: ({ value }) => ({ value }), + statusForResult: ({ created }) => (created ? 201 : 200), + }) + + const response = await handler( + new NextRequest('http://localhost/api/test/internal-json-route', { method: 'POST' }) + ) + + expect(response.status).toBe(201) + await expect(response.json()).resolves.toEqual({ value: 'created' }) + }) + + it('fails closed when the application selects an undeclared success status', async () => { + const handler = defineInternalJsonRoute({ + contract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 'ok' } + }, + }, + statusForResult: () => 201, + }) + + const response = await handler(new NextRequest('http://localhost/api/test/internal-json-route')) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ error: 'Internal server error' }) + }) }) 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 f9102d57217..8e362c35f66 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -230,6 +230,7 @@ type InternalJsonRouteOptions< params: Record<string, string | string[] | undefined> }): void | Promise<void> onSuccess?(args: { principal: P; input: NoInfer<I>; result: NoInfer<R> }): void | Promise<void> + statusForResult?(result: NoInfer<R>): number responseHeaders?(args: { principal: P; input: NoInfer<I>; result: NoInfer<R> }): HeadersInit finalizeResponse?(args: { request: NextRequest @@ -287,12 +288,6 @@ export function defineInternalJsonRoute< options.operation, options.useCase.operation ) - if (successStatuses.length !== 1) { - throw new Error( - `${options.contract.method} ${options.contract.path} internal JSON route requires one success status` - ) - } - const wrapped = withRouteHandler<JsonRouteContext | undefined>( async (request, context) => { if (!methodMatchesContract(request.method, options.contract.method)) { @@ -344,6 +339,12 @@ export function defineInternalJsonRoute< throw new Error('Internal JSON route response mode changed after initialization') } const validatedBody = responseSchema.schema.parse(body) as ContractJsonResponse<C> + const responseStatus = options.statusForResult?.(result) ?? successStatus + if (!successStatuses.includes(responseStatus)) { + throw new Error( + `Internal JSON route produced undeclared success status ${responseStatus}; expected ${successStatuses.join(', ')}` + ) + } const headers = options.responseHeaders?.({ principal, input, result }) const finalization = options.finalizeResponse ? await options.finalizeResponse({ @@ -357,7 +358,7 @@ export function defineInternalJsonRoute< return NextResponse.json( appendFinalizedBodyFields(validatedBody, finalization?.bodyFields), { - status: successStatus, + status: responseStatus, headers: appendFinalizedHeaders(headers, finalization?.headers), } ) diff --git a/apps/sim/lib/copilot/application/execute-credential-use-case.ts b/apps/sim/lib/copilot/application/execute-credential-use-case.ts new file mode 100644 index 00000000000..cbebe99402a --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-credential-use-case.ts @@ -0,0 +1,14 @@ +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import { CREDENTIAL_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' + +export const executeCopilotCredentialUseCase = createCopilotApplicationAdapter({ + domain: 'credential', + delegation: { + audience: CREDENTIAL_DELEGATION_AUDIENCE, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, + operations: credentialOperations, +}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts index e7ea463273b..9b9ebb41da5 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts @@ -8,6 +8,7 @@ const { mocks, useCases } = vi.hoisted(() => ({ custom: vi.fn(), mcp: vi.fn(), skill: vi.fn(), + credential: vi.fn(), capture: vi.fn(), }, useCases: { @@ -23,6 +24,8 @@ const { mocks, useCases } = vi.hoisted(() => ({ deleteSkill: { operation: { id: 'skills.delete' } }, listSkill: { operation: { id: 'skills.list_available' } }, updateSkill: { operation: { id: 'skills.update' } }, + updateCredential: { operation: { id: 'credentials.update' } }, + deleteManyCredentials: { operation: { id: 'credentials.delete_many' } }, }, })) @@ -35,6 +38,9 @@ vi.mock('@/lib/copilot/application/execute-mcp-server-use-case', () => ({ vi.mock('@/lib/copilot/application/execute-skill-use-case', () => ({ executeCopilotSkillUseCase: mocks.skill, })) +vi.mock('@/lib/copilot/application/execute-credential-use-case', () => ({ + executeCopilotCredentialUseCase: mocks.credential, +})) vi.mock('@/lib/custom-tools/application/use-cases', () => ({ deleteAvailableCustomToolUseCase: useCases.deleteCustom, listAvailableCustomToolsUseCase: useCases.listCustom, @@ -53,9 +59,16 @@ vi.mock('@/lib/skills/application/use-cases', () => ({ listAvailableSkillsUseCase: useCases.listSkill, updateSkillUseCase: useCases.updateSkill, })) +vi.mock('@/lib/credentials/application/credential-crud', () => ({ + updateWorkspaceCredentialUseCase: useCases.updateCredential, +})) +vi.mock('@/lib/credentials/application/delete-many-credentials', () => ({ + deleteManyCredentialsUseCase: useCases.deleteManyCredentials, +})) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeManageCredential } from '@/lib/copilot/tools/handlers/management/manage-credential' import { executeManageCustomTool } from '@/lib/copilot/tools/handlers/management/manage-custom-tool' import { executeManageMcpTool } from '@/lib/copilot/tools/handlers/management/manage-mcp-tool' import { executeManageSkill } from '@/lib/copilot/tools/handlers/management/manage-skill' @@ -157,4 +170,43 @@ describe('Copilot management application boundaries', () => { } ) }) + + it('renames credentials through the shared credential use case', async () => { + mocks.credential.mockResolvedValue({ + credential: { id: 'credential-1', displayName: 'Renamed' }, + previousDisplayName: 'Original', + }) + + const result = await executeManageCredential( + { operation: 'rename', credentialId: 'credential-1', displayName: 'Renamed' }, + context + ) + + expect(result).toMatchObject({ + success: true, + output: { previousDisplayName: 'Original', displayName: 'Renamed' }, + }) + expect(mocks.credential).toHaveBeenCalledWith(context, useCases.updateCredential, { + credentialId: 'credential-1', + displayName: 'Renamed', + }) + }) + + it('keeps best-effort batch deletion inside one semantic application command', async () => { + mocks.credential.mockResolvedValue({ deleted: ['credential-1'], failed: ['credential-2'] }) + + const result = await executeManageCredential( + { operation: 'delete', credentialIds: ['credential-1', 'credential-2'] }, + context + ) + + expect(result).toMatchObject({ + success: true, + output: { deleted: ['credential-1'], failed: ['credential-2'] }, + }) + expect(mocks.credential).toHaveBeenCalledWith(context, useCases.deleteManyCredentials, { + workspaceId: 'workspace-1', + credentialIds: ['credential-1', 'credential-2'], + }) + }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts index fb307feb7a4..07dc5d0bc44 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts @@ -1,84 +1,82 @@ -import { toError } from '@sim/utils/errors' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { executeCopilotCredentialUseCase } from '@/lib/copilot/application/execute-credential-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration' +import { updateWorkspaceCredentialUseCase } from '@/lib/credentials/application/credential-crud' +import { deleteManyCredentialsUseCase } from '@/lib/credentials/application/delete-many-credentials' -export function executeManageCredential( +export async function executeManageCredential( rawParams: Record<string, unknown>, context: ExecutionContext ): Promise<ToolCallResult> { - const params = rawParams as { - operation: string - credentialId?: string - credentialIds?: string[] - displayName?: string + const operation = typeof rawParams.operation === 'string' ? rawParams.operation : '' + const credentialId = + typeof rawParams.credentialId === 'string' ? rawParams.credentialId : undefined + const displayName = typeof rawParams.displayName === 'string' ? rawParams.displayName : undefined + const rawCredentialIds = rawParams.credentialIds + if ( + rawCredentialIds !== undefined && + (!Array.isArray(rawCredentialIds) || rawCredentialIds.some((id) => typeof id !== 'string')) + ) { + return { success: false, error: 'credentialIds must be an array of strings' } } - const { operation, displayName } = params - return (async () => { - try { - if (!context?.userId) { - return { success: false, error: 'Authentication required' } - } - - switch (operation) { - case 'rename': { - const credentialId = params.credentialId - if (!credentialId) return { success: false, error: 'credentialId is required for rename' } - if (!displayName) return { success: false, error: 'displayName is required for rename' } + const credentialIds = rawCredentialIds as string[] | undefined + const workspaceId = context.workspaceId + if (!workspaceId) return { success: false, error: 'workspaceId is required' } - const result = await performUpdateCredential({ + try { + switch (operation) { + case 'rename': { + if (!credentialId) { + return { success: false, error: 'credentialId is required for rename' } + } + if (!displayName) { + return { success: false, error: 'displayName is required for rename' } + } + const result = await executeCopilotCredentialUseCase( + context, + updateWorkspaceCredentialUseCase, + { credentialId, - userId: context.userId, displayName, - allowedTypes: ['oauth'], - }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to rename credential' } - } - return { - success: true, - output: { - credentialId, - previousDisplayName: result.previousDisplayName, - displayName, - }, } + ) + return { + success: true, + output: { + credentialId: result.credential.id, + previousDisplayName: result.previousDisplayName, + displayName: result.credential.displayName, + }, } - case 'delete': { - const ids: string[] = - params.credentialIds ?? (params.credentialId ? [params.credentialId] : []) - if (ids.length === 0) - return { success: false, error: 'credentialId or credentialIds is required for delete' } - - const deleted: string[] = [] - const failed: string[] = [] - - for (const id of ids) { - const result = await performDeleteCredential({ - credentialId: id, - userId: context.userId, - allowedTypes: ['oauth'], - reason: 'copilot_delete', - }) - if (!result.success) { - failed.push(id) - continue - } - deleted.push(id) - } - - return { - success: deleted.length > 0, - output: { deleted, failed }, - } - } - default: + } + case 'delete': { + const ids = credentialIds ?? (credentialId ? [credentialId] : []) + if (ids.length === 0) { return { success: false, - error: `Unknown operation: ${operation}. Use "rename" or "delete".`, + error: 'credentialId or credentialIds is required for delete', } + } + const result = await executeCopilotCredentialUseCase( + context, + deleteManyCredentialsUseCase, + { workspaceId, credentialIds: ids } + ) + return { + success: result.deleted.length > 0, + output: { deleted: result.deleted, failed: result.failed }, + } } - } catch (error) { - return { success: false, error: toError(error).message } + default: + return { + success: false, + error: `Unknown operation: ${operation}. Use "rename" or "delete".`, + } } - })() + } catch (error) { + return { + success: false, + error: messageForCopilotApplicationError(error, 'Failed to manage credential'), + } + } } diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts index 77ff5a9922d..f3926a3ed11 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts @@ -2,324 +2,114 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' -const { - mockEnsureWorkspaceAccess, - mockGetCredentialActorContext, - mockIsOAuthServiceDeploymentAvailable, - mockGetUserPermissionConfig, -} = vi.hoisted(() => ({ - mockEnsureWorkspaceAccess: vi.fn(), - mockGetCredentialActorContext: vi.fn(), - mockIsOAuthServiceDeploymentAvailable: vi.fn(() => true), - mockGetUserPermissionConfig: vi.fn(), +const mocks = vi.hoisted(() => ({ + execute: vi.fn(), + getBaseUrl: vi.fn(), })) -vi.mock('@/lib/copilot/tools/handlers/access', () => ({ - ensureWorkspaceAccess: mockEnsureWorkspaceAccess, +const useCases = vi.hoisted(() => ({ + prepare: { operation: { id: 'credentials.connections.prepare' } }, })) -vi.mock('@/lib/credentials/access', () => ({ - getCredentialActorContext: mockGetCredentialActorContext, +vi.mock('@/lib/copilot/application/execute-credential-use-case', () => ({ + executeCopilotCredentialUseCase: mocks.execute, })) - -vi.mock('@/lib/integrations/availability.server', () => ({ - isOAuthServiceDeploymentAvailable: mockIsOAuthServiceDeploymentAvailable, -})) - -vi.mock('@/lib/core/config/env-flags', () => ({ - getAllowedIntegrationsFromEnv: vi.fn(() => null), -})) - -vi.mock('@/ee/access-control/utils/permission-check', () => ({ - getUserPermissionConfig: mockGetUserPermissionConfig, -})) - -vi.mock('@/lib/oauth/utils', () => ({ - getAllOAuthServices: vi.fn(() => [ - { serviceId: 'gmail', providerId: 'google-email', name: 'Gmail', authType: 'oauth' }, - { serviceId: 'slack', providerId: 'slack', name: 'Slack', authType: 'oauth' }, - { serviceId: 'trello', providerId: 'trello', name: 'Trello', authType: 'oauth' }, - { serviceId: 'shopify', providerId: 'shopify', name: 'Shopify', authType: 'oauth' }, - { - serviceId: 'claude-platform', - providerId: 'claude-platform', - name: 'Claude Platform', - authType: 'service_account', - }, - ]), +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: mocks.getBaseUrl })) +vi.mock('@/lib/credentials/application/prepare-credential-connection', () => ({ + prepareCredentialConnection: useCases.prepare, })) import type { ExecutionContext } from '@/lib/copilot/request/types' import { executeOAuthGetAuthLink } from '@/lib/copilot/tools/handlers/oauth' -const BASE_URL = 'https://sim.test' -const WORKSPACE_ID = 'ws-1' -const USER_ID = 'user-1' -const CREDENTIAL_ID = 'cred-1' - -const context = { - workspaceId: WORKSPACE_ID, - userId: USER_ID, +const context: ExecutionContext = { + userId: 'user-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', chatId: 'chat-1', -} as unknown as ExecutionContext - -const WORKSPACE_ACCESS = { - exists: true, - hasAccess: true, - canWrite: true, - canAdmin: false, - workspace: { id: WORKSPACE_ID }, -} - -function oauthCredentialActor(overrides: Record<string, unknown> = {}) { - return { - credential: { - id: CREDENTIAL_ID, - workspaceId: WORKSPACE_ID, - type: 'oauth', - providerId: 'google-email', - ...((overrides.credential as Record<string, unknown>) ?? {}), - }, - member: null, - hasWorkspaceAccess: true, - canWriteWorkspace: true, - isAdmin: true, - ...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')), - } + toolCallId: 'call-1', + copilotToolExecution: true, + userPermission: 'write', } describe('executeOAuthGetAuthLink', () => { beforeEach(() => { vi.clearAllMocks() - process.env.NEXT_PUBLIC_APP_URL = BASE_URL - mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS) - mockIsOAuthServiceDeploymentAvailable.mockReturnValue(true) - mockGetUserPermissionConfig.mockResolvedValue(null) - }) - - describe('connect (no credentialId)', () => { - it('returns an authorize URL without a credentialId param', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context) - - expect(result.success).toBe(true) - const url = new URL((result.output as { oauth_url: string }).oauth_url) - expect(url.pathname).toBe('/api/auth/oauth2/authorize') - expect(url.searchParams.get('providerId')).toBe('google-email') - expect(url.searchParams.get('credentialId')).toBeNull() - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) - - it('rejects a provider whose OAuth client is not configured', async () => { - mockIsOAuthServiceDeploymentAvailable.mockReturnValue(false) - - const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('not configured for this deployment') - }) - - it('rejects a provider disallowed for the workspace member', async () => { - mockGetUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) - - const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('not allowed for this workspace member') - }) - - it('does not treat service-account-only metadata as OAuth', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'Claude Platform' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found') + mocks.getBaseUrl.mockReturnValue('https://sim.test') + mocks.execute.mockResolvedValue({ + serviceName: 'Gmail', + providerId: 'google-email', + workspaceId: 'workspace-1', }) }) - describe('reconnect (credentialId passed)', () => { - it('returns an authorize URL carrying the credentialId and a reconnect message', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) + it('uses the credential application adapter for a new connection', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'gmail' }, context) - expect(result.success).toBe(true) - const output = result.output as { oauth_url: string; message: string } - const url = new URL(output.oauth_url) - expect(url.searchParams.get('credentialId')).toBe(CREDENTIAL_ID) - expect(output.message).toContain('Reconnect') - expect(output.message).toContain(CREDENTIAL_ID) + expect(result.success).toBe(true) + expect(mocks.execute).toHaveBeenCalledWith(context, useCases.prepare, { + workspaceId: 'workspace-1', + providerName: 'gmail', + credentialId: undefined, }) + const url = new URL((result.output as { oauth_url: string }).oauth_url) + expect(url.pathname).toBe('/api/auth/oauth2/authorize') + expect(url.searchParams.get('providerId')).toBe('google-email') + expect(url.searchParams.get('workspaceId')).toBe('workspace-1') + expect(url.searchParams.has('credentialId')).toBe(false) + }) - it('reuses the already-resolved workspace access for the credential lookup', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(mockGetCredentialActorContext).toHaveBeenCalledWith(CREDENTIAL_ID, USER_ID, { - workspaceAccess: WORKSPACE_ACCESS, - }) - }) - - it('fails with an agent-visible error for a nonexistent credential', async () => { - mockGetCredentialActorContext.mockResolvedValue({ - credential: null, - member: null, - hasWorkspaceAccess: false, - canWriteWorkspace: false, - isAdmin: false, - }) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: 'cred-hallucinated' }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found in this workspace') - }) - - it('fails when the credential belongs to another workspace', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { workspaceId: 'ws-other' } }) - ) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found in this workspace') - }) - - it('fails when the credential is not an OAuth credential', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { type: 'env_workspace' } }) - ) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not an OAuth credential') - }) - - it('fails naming the actual provider when providerName does not match the credential', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const result = await executeOAuthGetAuthLink( - { providerName: 'slack', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('google-email') + it('preserves the canonical credential ID for reconnect', async () => { + mocks.execute.mockResolvedValue({ + serviceName: 'Gmail', + providerId: 'google-email', + workspaceId: 'workspace-1', + credentialId: 'credential-1', }) - it('fails when the caller is not a credential admin', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false })) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('Admin access') - }) + const result = await executeOAuthGetAuthLink( + { providerName: 'gmail', credentialId: 'credential-1' }, + context + ) - it('rejects reconnect for Trello and directs the user to the integrations page', async () => { - const result = await executeOAuthGetAuthLink( - { providerName: 'trello', credentialId: CREDENTIAL_ID }, - context - ) + const output = result.output as { oauth_url: string; message: string } + expect(new URL(output.oauth_url).searchParams.get('credentialId')).toBe('credential-1') + expect(output.message).toContain('re-authorizes credential credential-1 in place') + }) - expect(result.success).toBe(false) - expect(result.error).toContain('integrations page') - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) + it('returns application validation errors without exposing infrastructure failures', async () => { + mocks.execute.mockRejectedValue(new OrchestrationError('not_found', 'Provider not found')) - it('rejects reconnect for Shopify and directs the user to the integrations page', async () => { - const result = await executeOAuthGetAuthLink( - { providerName: 'shopify', credentialId: CREDENTIAL_ID }, - context - ) + const result = await executeOAuthGetAuthLink({ providerName: 'missing' }, context) - expect(result.success).toBe(false) - expect(result.error).toContain('integrations page') - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) + expect(result.success).toBe(false) + expect(result.error).toBe('Provider not found') }) -}) -describe('executeOAuthGetAuthLink service account rejection', () => { - beforeEach(() => { - vi.clearAllMocks() - process.env.NEXT_PUBLIC_APP_URL = BASE_URL - mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS) - mockIsOAuthServiceDeploymentAvailable.mockReturnValue(true) - mockGetUserPermissionConfig.mockResolvedValue(null) + it('fails fast without trusted workspace context', async () => { + const result = await executeOAuthGetAuthLink( + { providerName: 'gmail' }, + { ...context, workspaceId: undefined } + ) + + expect(result).toEqual({ success: false, error: 'workspaceId is required' }) + expect(mocks.execute).not.toHaveBeenCalled() }) - /** - * Regression: a user asked for a "new custom bot", the agent correctly - * resolved that to `slack-custom-bot` and passed it here, and the fuzzy - * substring pass matched it to the Slack OAuth service — `slack-custom-bot` - * contains `slack`. The tool returned a personal-OAuth authorize URL and - * reported success, so the user connected their own account instead of a - * shared bot. Failing loudly is the point: a wrong link that looks right is - * worse than an error the agent can recover from. - */ - it('rejects a service account id with a coherent recovery message, not a workspace link', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'slack-custom-bot' }, context) + it('rejects service-account providers before OAuth resolution', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'slack custom bot' }, context) expect(result.success).toBe(false) - expect(result.error).toContain('service account') - expect(result.error).toContain('service_account credential tag') - const output = result.output as { setup_url?: string; oauth_url?: string; message: string } - // The rejection must not fall into the generic catch, which would attach a - // contradicting workspace oauth_url and a "connect manually" message — the - // agent would then surface a workspace link instead of the tag. - expect(output.setup_url).toBeUndefined() - expect(output.oauth_url).toBeUndefined() - expect(output.message).toContain('service_account credential tag') - expect(output.message).not.toContain('Connect manually') + expect(result.error).toContain('service account, not an OAuth provider') + expect(mocks.execute).not.toHaveBeenCalled() }) - it.each([ - 'notion-service-account', - 'salesforce-service-account', - 'google-service-account', - 'atlassian-service-account', - 'SLACK-CUSTOM-BOT', - // Readable forms must be normalized (spaces/underscores → hyphens) so they - // are caught too, not passed to the fuzzy OAuth resolver. - 'slack custom bot', - 'google service account', - 'notion_service_account', - ])('rejects %s', async (providerName) => { - const result = await executeOAuthGetAuthLink({ providerName }, context) - expect(result.success).toBe(false) - expect(result.error).toContain('service_account credential tag') - }) + it('does not confuse integrations that also offer service accounts', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'slack' }, context) - it('still resolves ordinary OAuth providers for integrations that also offer a service account', async () => { - // `slack` and `notion` must keep working — the guard keys off the id being - // a service-account id, not off the integration having a service-account flow. - for (const providerName of ['slack', 'google-email']) { - const result = await executeOAuthGetAuthLink({ providerName }, context) - expect(result.success).toBe(true) - expect((result.output as { oauth_url: string }).oauth_url).toContain( - '/api/auth/oauth2/authorize' - ) - } + expect(result.success).toBe(true) + expect(mocks.execute).toHaveBeenCalledOnce() }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index 5549efacd10..eb24cb86ab6 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -1,16 +1,9 @@ -import { toError } from '@sim/utils/errors' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { executeCopilotCredentialUseCase } from '@/lib/copilot/application/execute-credential-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' -import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' -import { getCredentialActorContext } from '@/lib/credentials/access' +import { prepareCredentialConnection } from '@/lib/credentials/application/prepare-credential-connection' import { isServiceAccountProviderId } from '@/lib/credentials/service-account-provider-ids' -import { isOAuthServiceAllowedByIntegrationTypes } from '@/lib/integrations/availability' -import { isOAuthServiceDeploymentAvailable } from '@/lib/integrations/availability.server' -import { getAllOAuthServices } from '@/lib/oauth/utils' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' -import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' export async function executeOAuthGetAuthLink( rawParams: Record<string, unknown>, @@ -38,33 +31,26 @@ export async function executeOAuthGetAuthLink( `value instead (e.g. "slack") — it opens the service account setup form in chat.` return { success: false, error: message, output: { message } } } + const workspaceId = context.workspaceId + if (!workspaceId) return { success: false, error: 'workspaceId is required' } try { - if (!context.workspaceId || !context.userId) { - throw new Error('workspaceId and userId are required to generate an OAuth link') - } - const workspaceAccess = await ensureWorkspaceAccess( - context.workspaceId, - context.userId, - 'write' - ) - const permissionConfig = await getUserPermissionConfig(context.userId, context.workspaceId) - const configuredAllowedIntegrations = intersectIntegrationAllowlists( - permissionConfig?.allowedIntegrations ?? null, - getAllowedIntegrationsFromEnv() - ) - const allowedIntegrationTypes = configuredAllowedIntegrations - ? new Set(configuredAllowedIntegrations.map((type) => type.toLowerCase())) - : null - const result = await generateOAuthLink( - context.workspaceId, - context.workflowId, - context.chatId, + const result = await executeCopilotCredentialUseCase(context, prepareCredentialConnection, { + workspaceId, providerName, - baseUrl, - allowedIntegrationTypes, - credentialId ? { credentialId, userId: context.userId, workspaceAccess } : undefined - ) + credentialId, + }) + const callbackURL = context.workflowId + ? `${baseUrl}/workspace/${workspaceId}/w/${context.workflowId}` + : context.chatId + ? `${baseUrl}/workspace/${workspaceId}/chat/${context.chatId}` + : `${baseUrl}/workspace/${workspaceId}` + const authorizeUrl = new URL(`${baseUrl}/api/auth/oauth2/authorize`) + authorizeUrl.searchParams.set('providerId', result.providerId) + authorizeUrl.searchParams.set('workspaceId', workspaceId) + authorizeUrl.searchParams.set('callbackURL', callbackURL) + if (result.credentialId) authorizeUrl.searchParams.set('credentialId', result.credentialId) + const action = credentialId ? 'reconnect' : 'connect' return { success: true, @@ -72,23 +58,24 @@ export async function executeOAuthGetAuthLink( message: credentialId ? `Reconnect authorization URL generated for ${result.serviceName}. Completing it re-authorizes credential ${credentialId} in place — its id stays the same.` : `Authorization URL generated for ${result.serviceName}.`, - oauth_url: result.url, - instructions: `Open this URL in your browser to ${action} ${result.serviceName}: ${result.url}`, + oauth_url: authorizeUrl.toString(), + instructions: `Open this URL in your browser to ${action} ${result.serviceName}: ${authorizeUrl.toString()}`, provider: result.serviceName, providerId: result.providerId, }, } } catch (err) { + const message = messageForCopilotApplicationError(err) const workspaceUrl = context.workspaceId ? `${baseUrl}/workspace/${context.workspaceId}` : `${baseUrl}/workspace` return { success: false, - error: toError(err).message, + error: message, output: { message: `Could not generate a direct OAuth link for ${providerName}. Connect manually from the workspace.`, oauth_url: workspaceUrl, - error: toError(err).message, + error: message, }, } } @@ -109,140 +96,3 @@ export async function executeOAuthRequestAccess( }, } } - -/** - * Resolves a human-friendly provider name to a providerId and returns a - * browser-initiated authorize URL the user opens to connect the service. - * - * Steps: resolve provider → return the Sim `/api/auth/oauth2/authorize` URL. - * That endpoint (not this server-side handler) creates the credential draft and - * calls Better Auth, so the draft's TTL starts at click and the signed `state` - * cookie is planted in the user's browser and the OAuth callback's state check - * passes. - * - * When `reconnect` is set, the URL carries the existing credential id so the - * authorize endpoint creates a reconnect draft and the OAuth callback rebinds - * the credential in place instead of creating a new one. Validation happens - * here too (not just at click time) so a bad id fails in the tool result where - * the agent can see it, rather than as a silent browser redirect. - */ -async function generateOAuthLink( - workspaceId: string | undefined, - workflowId: string | undefined, - chatId: string | undefined, - providerName: string, - baseUrl: string, - allowedIntegrationTypes: ReadonlySet<string> | null, - reconnect?: { credentialId: string; userId: string; workspaceAccess: WorkspaceAccess } -): Promise<{ url: string; providerId: string; serviceName: string }> { - if (!workspaceId) { - throw new Error('workspaceId is required to generate an OAuth link') - } - - const allServices = getAllOAuthServices().filter((service) => service.authType === 'oauth') - const normalizedInput = providerName.toLowerCase().trim() - - const matched = - allServices.find((s) => s.providerId === normalizedInput) || - allServices.find((s) => s.name.toLowerCase() === normalizedInput) || - allServices.find( - (s) => - s.name.toLowerCase().includes(normalizedInput) || - normalizedInput.includes(s.name.toLowerCase()) - ) || - allServices.find( - (s) => s.providerId.includes(normalizedInput) || normalizedInput.includes(s.providerId) - ) - - if (!matched) { - const available = allServices.map((s) => s.name).join(', ') - throw new Error(`Provider "${providerName}" not found. Available providers: ${available}`) - } - - const { providerId, name: serviceName } = matched - if (!isOAuthServiceAllowedByIntegrationTypes(matched.serviceId, allowedIntegrationTypes)) { - throw new Error(`${serviceName} is not allowed for this workspace member`) - } - if (!isOAuthServiceDeploymentAvailable(providerId)) { - throw new Error(`${serviceName} OAuth is not configured for this deployment`) - } - - if (reconnect) { - if (providerId === 'trello' || providerId === 'shopify') { - throw new Error( - `Reconnect is not supported for ${serviceName} from chat. Ask the user to open the ` + - `integrations page and press Reconnect on the credential there.` - ) - } - const actor = await getCredentialActorContext(reconnect.credentialId, reconnect.userId, { - workspaceAccess: reconnect.workspaceAccess, - }) - if (!actor.credential || actor.credential.workspaceId !== workspaceId) { - throw new Error( - `Credential "${reconnect.credentialId}" was not found in this workspace. Read ` + - `environment/credentials.json for valid credential ids.` - ) - } - if (actor.credential.type !== 'oauth') { - throw new Error( - `Credential "${reconnect.credentialId}" is not an OAuth credential and cannot be reconnected.` - ) - } - if (actor.credential.providerId !== providerId) { - throw new Error( - `Credential "${reconnect.credentialId}" belongs to provider "${actor.credential.providerId}", ` + - `not "${providerId}". Pass the matching providerName.` - ) - } - if (!actor.isAdmin) { - throw new Error('Admin access on the credential is required to reconnect it.') - } - } - - const callbackURL = - workflowId && workspaceId - ? `${baseUrl}/workspace/${workspaceId}/w/${workflowId}` - : chatId && workspaceId - ? `${baseUrl}/workspace/${workspaceId}/chat/${chatId}` - : `${baseUrl}/workspace/${workspaceId}` - - if (providerId === 'trello') { - const authorizeUrl = new URL(`${baseUrl}/api/auth/trello/authorize`) - authorizeUrl.searchParams.set('returnUrl', callbackURL) - return { url: authorizeUrl.toString(), providerId, serviceName } - } - if (providerId === 'instagram') { - const authorizeUrl = new URL(`${baseUrl}/api/auth/instagram/authorize`) - authorizeUrl.searchParams.set('returnUrl', callbackURL) - authorizeUrl.searchParams.set('workspaceId', workspaceId) - return { url: authorizeUrl.toString(), providerId, serviceName } - } - if (providerId === 'shopify') { - const returnUrl = encodeURIComponent(callbackURL) - return { - url: `${baseUrl}/api/auth/shopify/authorize?returnUrl=${returnUrl}`, - providerId, - serviceName, - } - } - - // Hand back a browser-initiated authorize URL rather than calling - // oAuth2LinkAccount here. Generating the link server-side would set Better - // Auth's signed `state` cookie on this server-to-server response instead of the - // user's browser, so the OAuth callback would fail with `state_mismatch`. The - // authorize endpoint runs the link inside the user's browser, planting the - // cookie correctly while keeping the callback's state check enabled. - // - // The pending credential draft is created by that authorize endpoint at click - // time (not here), so the draft's TTL starts when the user actually initiates - // the connect and reliably outlives the OAuth round-trip. - const authorizeUrl = new URL(`${baseUrl}/api/auth/oauth2/authorize`) - authorizeUrl.searchParams.set('providerId', providerId) - authorizeUrl.searchParams.set('workspaceId', workspaceId) - authorizeUrl.searchParams.set('callbackURL', callbackURL) - if (reconnect) { - authorizeUrl.searchParams.set('credentialId', reconnect.credentialId) - } - - return { url: authorizeUrl.toString(), providerId, serviceName } -} diff --git a/apps/sim/lib/credentials/api/route-policies.ts b/apps/sim/lib/credentials/api/route-policies.ts new file mode 100644 index 00000000000..2ffb63ae2f2 --- /dev/null +++ b/apps/sim/lib/credentials/api/route-policies.ts @@ -0,0 +1,23 @@ +import { + extendInternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, +} from '@/lib/api/server/routes' +import { getValidationErrorMessage, validationErrorResponse } from '@/lib/api/server/validation' +import { CredentialProviderOperationError } from '@/lib/credentials/application/credential-crud' + +export const credentialValidationParseOptions = { + validationErrorResponse: (error: Parameters<typeof getValidationErrorMessage>[0]) => + validationErrorResponse(error, getValidationErrorMessage(error)), +} as const + +export const internalCredentialErrorPolicy = extendInternalErrorPolicy( + internalOrchestrationErrorPolicy, + (error) => { + if (!(error instanceof CredentialProviderOperationError)) return null + return internalErrorResponse(error.providerUnavailable ? 502 : 400, { + error: error.message, + code: error.providerErrorCode, + }) + } +) diff --git a/apps/sim/lib/credentials/application/authorization.ts b/apps/sim/lib/credentials/application/authorization.ts new file mode 100644 index 00000000000..6b81be90d82 --- /dev/null +++ b/apps/sim/lib/credentials/application/authorization.ts @@ -0,0 +1,12 @@ +import type { WorkspaceDelegationPolicy } from '@/lib/core/application' + +export const CREDENTIAL_DELEGATION_AUDIENCE = 'sim:credentials' + +export const credentialDelegationPolicy = { + audience: CREDENTIAL_DELEGATION_AUDIENCE, + isWithinScope: () => true, +} as const satisfies WorkspaceDelegationPolicy<{ + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean +}> diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts new file mode 100644 index 00000000000..dbfe9f99157 --- /dev/null +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineWorkspaceOperation } from '@/lib/core/application' +import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { defineCredentialOperation } from '@/lib/credentials/application/operations' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + getActor: vi.fn(), + execute: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) + +const memberOperation = defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.test_member', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'member' +) +const adminOperation = defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.test_admin', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'admin' +) +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const credential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth' as const, +} +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + credential, +} + +function createUseCase(operation: typeof memberOperation | typeof adminOperation) { + return defineAuthorizedCredentialUseCase({ + operation, + resolveContext: async () => ({ ...context }), + execute: mocks.execute, + }) +} + +describe('defineAuthorizedCredentialUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.execute.mockResolvedValue({ ok: true }) + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'member', status: 'active' }, + hasWorkspaceAccess: true, + isAdmin: false, + }) + }) + + it('allows an active credential member for member-level reads', async () => { + await expect( + createUseCase(memberOperation).execute({ principal, input: undefined }) + ).resolves.toEqual({ ok: true }) + expect(mocks.execute).toHaveBeenCalledOnce() + }) + + it('requires credential admin independently of workspace read access', async () => { + await expect( + createUseCase(adminOperation).execute({ principal, input: undefined }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('authorizes the workspace before resolving credential membership', async () => { + await createUseCase(memberOperation).execute({ principal, input: undefined }) + + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.getActor.mock.invocationCallOrder[0] + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts index 2fe52fb957e..635fc9eb621 100644 --- a/apps/sim/lib/credentials/application/authorized-credential-use-case.ts +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts @@ -6,16 +6,28 @@ import { type WorkspaceAuthorizationContext, } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialActorContext } from '@/lib/credentials/access' import { getCredentialActorContext } from '@/lib/credentials/access' -import type { CredentialAdminOperation } from '@/lib/credentials/application/operations' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import type { CredentialOperation } from '@/lib/credentials/application/operations' import type { CredentialRow } from '@/lib/credentials/queries' export interface CredentialAuthorizationContext extends WorkspaceAuthorizationContext { credential: CredentialRow + credentialAccess?: CredentialActorContext +} + +export function requireCredentialAccess( + context: CredentialAuthorizationContext +): CredentialActorContext { + if (!context.credentialAccess) { + throw new Error('Credential use case executed without resource authorization') + } + return context.credentialAccess } type AuthorizedCredentialUseCaseDefinition< - O extends CredentialAdminOperation, + O extends CredentialOperation, I, C extends CredentialAuthorizationContext, R, @@ -25,14 +37,14 @@ type AuthorizedCredentialUseCaseDefinition< > export function defineAuthorizedCredentialUseCase< - const O extends CredentialAdminOperation, + const O extends CredentialOperation, I, C extends CredentialAuthorizationContext, R, >(definition: AuthorizedCredentialUseCaseDefinition<O, I, C, R>) { return defineAuthorizedWorkspaceUseCase({ ...definition, - authorizationOptions: {}, + authorizationOptions: { delegation: credentialDelegationPolicy }, async authorizeResource({ principal, context }) { const actor = await getCredentialActorContext( context.credential.id, @@ -45,7 +57,13 @@ export function defineAuthorizedCredentialUseCase< ) { throw new OrchestrationError('not_found', 'Credential not found') } + context.credentialAccess = actor switch (definition.operation.minimumCredentialRole) { + case 'member': + if (!actor.member && !actor.isAdmin) { + throw new OrchestrationError('forbidden', 'Credential access required') + } + return case 'admin': if (!actor.isAdmin) { throw new ForbiddenOperationError( diff --git a/apps/sim/lib/credentials/application/authorized-user-use-case.ts b/apps/sim/lib/credentials/application/authorized-user-use-case.ts new file mode 100644 index 00000000000..729bcbddf57 --- /dev/null +++ b/apps/sim/lib/credentials/application/authorized-user-use-case.ts @@ -0,0 +1,73 @@ +import { type AuditActionType, type AuditResourceTypeValue, recordAudit } from '@sim/audit' +import { resolvePrincipalAuditAttribution, type SessionPrincipal } from '@sim/auth/principal' +import type { OperationUseCase } from '@/lib/core/application' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialUserOperation } from '@/lib/credentials/application/operations' + +export interface CredentialUserAuditEntry { + workspaceId: string | null + action: AuditActionType + resourceType: AuditResourceTypeValue + resourceId?: string + resourceName?: string + description?: string + metadata?: Record<string, unknown> +} + +interface CredentialUserUseCaseDefinition<O extends CredentialUserOperation, I, R> { + operation: O + execute(args: { + principal: SessionPrincipal + input: I + request?: OrchestrationRequestContext + }): Promise<R> + projectAudit?(args: { + principal: SessionPrincipal + input: I + result: R + }): CredentialUserAuditEntry | CredentialUserAuditEntry[] + afterSuccess?(args: { principal: SessionPrincipal; input: I; result: R }): void | Promise<void> +} + +/** Defines a current-user credential operation that cannot borrow workspace identity. */ +export function defineAuthorizedCredentialUserUseCase< + const O extends CredentialUserOperation, + I, + R, +>(definition: CredentialUserUseCaseDefinition<O, I, R>): OperationUseCase<O, I, R> { + return { + operation: definition.operation, + async execute({ principal, input, request }) { + if (principal.kind !== 'session') { + throw new OrchestrationError('forbidden', 'Session authentication required') + } + const result = await definition.execute({ principal, input, request }) + const projected = definition.projectAudit?.({ principal, input, result }) + if (projected) { + const attribution = resolvePrincipalAuditAttribution(principal) + const entries = Array.isArray(projected) ? projected : [projected] + for (const entry of entries) { + recordAudit({ + workspaceId: entry.workspaceId, + actorId: attribution.actorId, + actorName: attribution.actorName, + action: entry.action, + resourceType: entry.resourceType, + resourceId: entry.resourceId, + resourceName: entry.resourceName, + description: entry.description, + metadata: { + ...entry.metadata, + operation: definition.operation.id, + actor: attribution.actor, + }, + request, + }) + } + } + await definition.afterSuccess?.({ principal, input, result }) + return result + }, + } +} diff --git a/apps/sim/lib/credentials/application/connection-target.ts b/apps/sim/lib/credentials/application/connection-target.ts index 8783d526463..a7cb618ceab 100644 --- a/apps/sim/lib/credentials/application/connection-target.ts +++ b/apps/sim/lib/credentials/application/connection-target.ts @@ -18,13 +18,21 @@ export interface ResolvedCredentialConnectionTarget { displayName?: string } +export class CredentialConnectionProviderMismatchError extends OrchestrationError { + constructor() { + super('validation', 'Credential provider does not match the requested OAuth provider') + this.name = 'CredentialConnectionProviderMismatchError' + } +} + export async function resolveCredentialConnectionTarget(params: { principal: Principal context: ActiveWorkspaceApplicationContext providerId?: string credentialId?: string + assertedProviderId?: string }): Promise<ResolvedCredentialConnectionTarget> { - const { principal, context, providerId, credentialId } = params + const { principal, context, providerId, credentialId, assertedProviderId } = params if (Boolean(providerId) === Boolean(credentialId)) { throw new Error('Credential connection requires exactly one target identifier') } @@ -49,6 +57,9 @@ export async function resolveCredentialConnectionTarget(params: { throw new OrchestrationError('validation', 'Only OAuth credentials can be reconnected') } const credentialProviderId = credential.providerId + if (assertedProviderId && assertedProviderId !== credentialProviderId) { + throw new CredentialConnectionProviderMismatchError() + } const actor = await getCredentialActorContext(targetCredentialId, userId) if (!actor.credential || actor.credential.workspaceId !== context.workspaceId) { diff --git a/apps/sim/lib/credentials/application/create-credential-connection.test.ts b/apps/sim/lib/credentials/application/create-credential-connection.test.ts index 544edd2130b..9c3eabbbed8 100644 --- a/apps/sim/lib/credentials/application/create-credential-connection.test.ts +++ b/apps/sim/lib/credentials/application/create-credential-connection.test.ts @@ -81,7 +81,7 @@ describe('createCredentialConnection', () => { expect(mocks.loadWorkspace).not.toHaveBeenCalled() }) - it('creates a user-bound draft and returns only its browser entrypoint', async () => { + it('creates a user-bound draft and returns its canonical connection context', async () => { const result = await createCredentialConnection.execute({ principal: personalPrincipal, input: { @@ -101,7 +101,10 @@ describe('createCredentialConnection', () => { }) expect(result).toEqual({ authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + draftId: 'draft-1', expiresAt: new Date('2026-08-12T20:15:00.000Z'), + providerId: 'google-email', + workspaceId: 'workspace-1', }) }) diff --git a/apps/sim/lib/credentials/application/create-credential-connection.ts b/apps/sim/lib/credentials/application/create-credential-connection.ts index 4aaae28a61c..7a0d8491168 100644 --- a/apps/sim/lib/credentials/application/create-credential-connection.ts +++ b/apps/sim/lib/credentials/application/create-credential-connection.ts @@ -1,6 +1,8 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getBaseUrl } from '@/lib/core/utils/urls' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' import { credentialOperations } from '@/lib/credentials/application/operations' import { createConnectDraft } from '@/lib/credentials/connect-draft' @@ -9,13 +11,22 @@ import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/applicat export type CreateCredentialConnectionInput = { workspaceId: string } & ( - | { providerId: string; displayName: string; credentialId?: never } - | { credentialId: string; providerId?: never; displayName?: never } + | { providerId: string; displayName?: string; credentialId?: never } + | { + credentialId: string + assertedProviderId?: string + providerId?: never + displayName?: never + } ) export interface CreateCredentialConnectionResult { authorizationUrl: string + draftId: string expiresAt: Date + providerId: string + workspaceId: string + credentialId?: string } export const createCredentialConnection = defineAuthorizedWorkspaceUseCase({ @@ -25,30 +36,34 @@ export const createCredentialConnection = defineAuthorizedWorkspaceUseCase({ if (!context) throw new OrchestrationError('not_found', 'Workspace not found') return context }, - authorizationOptions: {}, + authorizationOptions: { delegation: credentialDelegationPolicy }, execute: async ({ principal, input, context }): Promise<CreateCredentialConnectionResult> => { const target = await resolveCredentialConnectionTarget({ principal, context, providerId: input.providerId, credentialId: input.credentialId, + assertedProviderId: 'assertedProviderId' in input ? input.assertedProviderId : undefined, }) const displayName = input.providerId ? input.displayName : target.displayName - if (!displayName) throw new Error('Resolved credential connection target has no display name') const draft = await createConnectDraft({ - userId: principal.userId, + userId: requirePrincipalSubjectUserId(principal), workspaceId: context.workspaceId, providerId: target.providerId, credentialId: target.credentialId, displayName, - displayNameDefinesIntent: input.providerId !== undefined, + displayNameDefinesIntent: input.providerId !== undefined && displayName !== undefined, }) const authorizationUrl = new URL('/api/auth/oauth2/authorize', getBaseUrl()) authorizationUrl.searchParams.set('draftId', draft.id) return { authorizationUrl: authorizationUrl.toString(), + draftId: draft.id, expiresAt: draft.expiresAt, + providerId: target.providerId, + workspaceId: context.workspaceId, + ...(target.credentialId ? { credentialId: target.credentialId } : {}), } }, }) diff --git a/apps/sim/lib/credentials/application/credential-context.ts b/apps/sim/lib/credentials/application/credential-context.ts new file mode 100644 index 00000000000..d5fbf242545 --- /dev/null +++ b/apps/sim/lib/credentials/application/credential-context.ts @@ -0,0 +1,32 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialAuthorizationContext } from '@/lib/credentials/application/authorized-credential-use-case' +import { getCredentialById, getWorkspaceCredential } from '@/lib/credentials/queries' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface ResolveCredentialApplicationContextInput { + credentialId: string + assertedWorkspaceId?: string +} + +/** Loads a credential canonically and verifies any asserted workspace scope. */ +export async function resolveCredentialApplicationContext( + input: ResolveCredentialApplicationContextInput +): Promise<CredentialAuthorizationContext> { + const assertedWorkspace = input.assertedWorkspaceId + ? await loadActiveWorkspaceApplicationContext(input.assertedWorkspaceId) + : null + if (input.assertedWorkspaceId && !assertedWorkspace) { + throw new OrchestrationError('not_found', 'Credential not found') + } + const credential = assertedWorkspace + ? await getWorkspaceCredential({ + workspaceId: assertedWorkspace.workspaceId, + credentialId: input.credentialId, + }) + : await getCredentialById(input.credentialId) + if (!credential) throw new OrchestrationError('not_found', 'Credential not found') + const workspace = + assertedWorkspace ?? (await loadActiveWorkspaceApplicationContext(credential.workspaceId)) + if (!workspace) throw new OrchestrationError('not_found', 'Credential not found') + return { ...workspace, credential } +} diff --git a/apps/sim/lib/credentials/application/credential-crud.ts b/apps/sim/lib/credentials/application/credential-crud.ts new file mode 100644 index 00000000000..a78460c291a --- /dev/null +++ b/apps/sim/lib/credentials/application/credential-crud.ts @@ -0,0 +1,239 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access' +import { + defineAuthorizedCredentialUseCase, + requireCredentialAccess, +} from '@/lib/credentials/application/authorized-credential-use-case' +import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' +import { + createCredentialRecord, + isProviderOutageCode, + type PerformCreateCredentialParams, + type PerformCredentialResult, + type PerformUpdateCredentialParams, + updateCredentialRecord, +} from '@/lib/credentials/orchestration' +import { + type CredentialRow, + listVisibleWorkspaceCredentials, + type VisibleWorkspaceCredential, +} from '@/lib/credentials/queries' +import { captureServerEvent } from '@/lib/posthog/server' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +export class CredentialProviderOperationError extends OrchestrationError { + constructor( + message: string, + readonly providerErrorCode: string, + readonly providerUnavailable: boolean + ) { + super('validation', message) + this.name = 'CredentialProviderOperationError' + } +} + +function throwCredentialMutationFailure(result: { + success: boolean + error?: string + errorCode?: PerformCredentialResult['errorCode'] + providerErrorCode?: string + providerUnavailable?: boolean +}): never { + if (result.providerErrorCode) { + throw new CredentialProviderOperationError( + result.error ?? result.providerErrorCode, + result.providerErrorCode, + result.providerUnavailable === true || isProviderOutageCode(result.providerErrorCode) + ) + } + switch (result.errorCode) { + case 'validation': + case 'not_found': + case 'conflict': + throw new OrchestrationError(result.errorCode, result.error ?? 'Credential mutation failed') + case 'forbidden': + throw new OrchestrationError('forbidden', result.error ?? 'Credential mutation forbidden') + default: + throw new Error(result.error ?? 'Credential mutation failed') + } +} + +export interface ListInternalCredentialsInput { + workspaceId: string + type?: CredentialRow['type'] + providerId?: string + credentialId?: string +} + +export interface ListInternalCredentialsResult { + credentials: VisibleWorkspaceCredential[] + credential: VisibleWorkspaceCredential | null | undefined +} + +export const listInternalCredentials = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.listInternal, + resolveContext: async ({ input }: { input: ListInternalCredentialsInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + async execute({ principal, input, context }): Promise<ListInternalCredentialsResult> { + const userId = requirePrincipalSubjectUserId(principal) + if (!input.type || input.type === 'oauth') { + await syncWorkspaceOAuthCredentialsForUser({ workspaceId: context.workspaceId, userId }) + } + const workspaceAccess = await checkWorkspaceAccess(context.workspaceId, userId) + const page = await listVisibleWorkspaceCredentials({ + workspaceId: context.workspaceId, + userId, + workspaceAccess, + types: input.type ? [input.type] : undefined, + providerId: input.providerId, + }) + const lookup = input.credentialId + ? (page.data.find( + (candidate) => + candidate.id === input.credentialId || candidate.accountId === input.credentialId + ) ?? null) + : undefined + return { credentials: input.credentialId ? [] : page.data, credential: lookup } + }, +}) + +export type CreateWorkspaceCredentialInput = Omit< + PerformCreateCredentialParams, + 'userId' | 'actorName' | 'actorEmail' | 'request' +> + +export interface CreateWorkspaceCredentialResult { + credential: CredentialRow + created: boolean + role: 'admin' | 'member' + status: 'active' | 'pending' | 'revoked' + auditMetadata: Record<string, unknown> +} + +export const createWorkspaceCredential = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.create, + resolveContext: async ({ input }: { input: CreateWorkspaceCredentialInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + async execute({ principal, input }): Promise<CreateWorkspaceCredentialResult> { + const userId = requirePrincipalSubjectUserId(principal) + const result = await createCredentialRecord({ ...input, userId }, { authorizeWorkspace: false }) + if (!result.success) throwCredentialMutationFailure(result) + if (!result.credential) throw new Error('Credential creation succeeded without a credential') + const access = await getCredentialActorContext(result.credential.id, userId) + if (!access.credential || !canUseCredential(access)) { + throw new Error('Created credential is not visible to its creator') + } + const role = access.isAdmin ? 'admin' : access.member?.role + const status = access.member?.status ?? (access.isAdmin ? 'active' : undefined) + if (!role || !status) throw new Error('Created credential has no active actor membership') + return { + credential: access.credential, + created: result.created === true, + role, + status, + auditMetadata: result.auditMetadata ?? {}, + } + }, + projectAudit: ({ result }) => + result.created + ? { + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Created ${result.credential.type} credential "${result.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: result.credential.type, + providerId: result.credential.providerId, + }, + } + : [], + afterSuccess: ({ principal, context, result }) => { + if (!result.created) return + captureServerEvent( + requirePrincipalSubjectUserId(principal), + 'credential_connected', + { + credential_type: result.credential.type, + provider_id: result.credential.providerId ?? result.credential.type, + workspace_id: context.workspaceId, + }, + { + groups: { workspace: context.workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + }, +}) + +export interface GetWorkspaceCredentialInput { + credentialId: string +} + +export const getWorkspaceCredentialUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.read, + resolveContext: ({ input }: { input: GetWorkspaceCredentialInput }) => + resolveCredentialApplicationContext(input), + async execute({ context }) { + return { credential: context.credential, access: requireCredentialAccess(context) } + }, +}) + +export type UpdateWorkspaceCredentialInput = Omit< + PerformUpdateCredentialParams, + 'userId' | 'actorName' | 'actorEmail' | 'allowedTypes' | 'reason' | 'request' +> + +export const updateWorkspaceCredentialUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.update, + resolveContext: ({ input }: { input: UpdateWorkspaceCredentialInput }) => + resolveCredentialApplicationContext(input), + async execute({ principal, input, context }) { + if (principal.kind === 'delegated' && context.credential.type !== 'oauth') { + throw new OrchestrationError('validation', 'Copilot can update only oauth credentials') + } + const result = await updateCredentialRecord({ ...input, credential: context.credential }) + if (!result.success) throwCredentialMutationFailure(result) + const access = await getCredentialActorContext( + context.credential.id, + requirePrincipalSubjectUserId(principal) + ) + if (!access.credential || !access.isAdmin) { + throw new Error('Updated credential is no longer visible to its administrator') + } + return { + credential: access.credential, + access, + previousDisplayName: context.credential.displayName, + updatedFields: result.updatedFields ?? [], + auditMetadata: result.auditMetadata ?? {}, + } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.CREDENTIAL_UPDATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credential.id, + resourceName: context.credential.displayName, + description: `Updated ${context.credential.type} credential "${context.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: context.credential.type, + updatedFields: result.updatedFields, + }, + }), +}) diff --git a/apps/sim/lib/credentials/application/credential-members.ts b/apps/sim/lib/credentials/application/credential-members.ts new file mode 100644 index 00000000000..57ecb2d8796 --- /dev/null +++ b/apps/sim/lib/credentials/application/credential-members.ts @@ -0,0 +1,148 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId, type SessionPrincipal } from '@sim/auth/principal' +import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' +import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' +import { + credentialOperations, + credentialUserOperations, +} from '@/lib/credentials/application/operations' +import { + leaveCredentialMembership, + listCredentialMembers, + listCredentialMembershipsForUser, + removeCredentialMember, + upsertCredentialMember, +} from '@/lib/credentials/members' +import { captureServerEvent } from '@/lib/posthog/server' + +interface CredentialMemberResourceInput { + credentialId: string +} + +function resolveSessionCredentialContext( + _principal: SessionPrincipal, + input: CredentialMemberResourceInput +) { + return resolveCredentialApplicationContext(input) +} + +export const listCredentialMembersUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.listMembers, + resolveContext: ({ + principal, + input, + }: { + principal: SessionPrincipal + input: CredentialMemberResourceInput + }) => resolveSessionCredentialContext(principal, input), + async execute({ context }) { + return { members: await listCredentialMembers(context.credential) } + }, +}) + +export interface UpsertCredentialMemberInput extends CredentialMemberResourceInput { + userId: string + role: 'admin' | 'member' +} + +export const upsertCredentialMemberUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.upsertMember, + resolveContext: ({ + principal, + input, + }: { + principal: SessionPrincipal + input: UpsertCredentialMemberInput + }) => resolveSessionCredentialContext(principal, input), + async execute({ principal, input, context }) { + const result = await upsertCredentialMember({ + credential: context.credential, + actorUserId: requirePrincipalSubjectUserId(principal), + targetUserId: input.userId, + role: input.role, + }) + return { ...result, targetUserId: input.userId, role: input.role } + }, + projectAudit: ({ context, result }) => ({ + action: result.created + ? AuditAction.CREDENTIAL_MEMBER_ADDED + : AuditAction.CREDENTIAL_MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credential.id, + description: result.created + ? `Shared credential with member as "${result.role}"` + : `Changed credential member role to "${result.role}"`, + metadata: { + targetUserId: result.targetUserId, + ...(result.created + ? { role: result.role } + : { fromRole: result.previousRole, toRole: result.role }), + }, + }), + afterSuccess: ({ principal, context, result }) => { + if (!result.created) return + captureServerEvent(requirePrincipalSubjectUserId(principal), 'credential_shared', { + credential_type: context.credential.type, + role: result.role, + workspace_id: context.workspaceId, + }) + }, +}) + +export interface RemoveCredentialMemberInput extends CredentialMemberResourceInput { + userId: string +} + +export const removeCredentialMemberUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.removeMember, + resolveContext: ({ + principal, + input, + }: { + principal: SessionPrincipal + input: RemoveCredentialMemberInput + }) => resolveSessionCredentialContext(principal, input), + async execute({ input, context }) { + await removeCredentialMember({ credential: context.credential, targetUserId: input.userId }) + return { success: true as const, targetUserId: input.userId } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.CREDENTIAL_MEMBER_REMOVED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credential.id, + description: 'Removed credential member', + metadata: { targetUserId: result.targetUserId }, + }), + afterSuccess: ({ principal, context }) => { + captureServerEvent(requirePrincipalSubjectUserId(principal), 'credential_unshared', { + credential_type: context.credential.type, + workspace_id: context.workspaceId, + }) + }, +}) + +export const listCredentialMembershipsUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.listMemberships, + async execute({ principal }) { + return { memberships: await listCredentialMembershipsForUser(principal.userId) } + }, +}) + +export interface LeaveCredentialMembershipInput { + credentialId: string +} + +export const leaveCredentialMembershipUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.leaveMembership, + async execute({ + principal, + input, + }: { + principal: SessionPrincipal + input: LeaveCredentialMembershipInput + }) { + await leaveCredentialMembership({ userId: principal.userId, credentialId: input.credentialId }) + return { success: true as const } + }, +}) diff --git a/apps/sim/lib/credentials/application/delete-many-credentials.test.ts b/apps/sim/lib/credentials/application/delete-many-credentials.test.ts new file mode 100644 index 00000000000..c3154f33db1 --- /dev/null +++ b/apps/sim/lib/credentials/application/delete-many-credentials.test.ts @@ -0,0 +1,124 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getActor: vi.fn(), + deleteCredential: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) +vi.mock('@/lib/credentials/orchestration', () => ({ + deleteCredentialRecord: mocks.deleteCredential, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { deleteManyCredentialsUseCase } from '@/lib/credentials/application/delete-many-credentials' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:credentials', + issuedAt: new Date('2026-08-14T12:00:00.000Z'), + expiresAt: new Date('2030-08-14T12:05:00.000Z'), +} + +function oauthCredential(id: string, workspaceId = 'workspace-1') { + return { + id, + workspaceId, + type: 'oauth' as const, + displayName: `OAuth ${id}`, + description: null, + providerId: 'google-email', + accountId: `account-${id}`, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: null, + createdBy: 'user-1', + createdAt: new Date('2026-08-14T12:00:00.000Z'), + updatedAt: new Date('2026-08-14T12:00:00.000Z'), + } +} + +describe('deleteManyCredentialsUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('read') + mocks.deleteCredential.mockResolvedValue(true) + }) + + it('deletes only OAuth credentials administered in the delegated workspace', async () => { + const allowed = oauthCredential('credential-1') + mocks.getActor + .mockResolvedValueOnce({ + credential: allowed, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + .mockResolvedValueOnce({ + credential: oauthCredential('credential-2', 'workspace-2'), + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + const result = await deleteManyCredentialsUseCase.execute({ + principal, + input: { + workspaceId: 'workspace-1', + credentialIds: ['credential-1', 'credential-2'], + }, + }) + + expect(result).toEqual({ + deleted: ['credential-1'], + failed: ['credential-2'], + deletedCredentials: [allowed], + }) + expect(mocks.deleteCredential).toHaveBeenCalledOnce() + expect(mocks.deleteCredential).toHaveBeenCalledWith({ + credential: allowed, + reason: 'copilot_delete', + }) + }) + + it('rejects duplicate IDs before loading any credential', async () => { + await expect( + deleteManyCredentialsUseCase.execute({ + principal, + input: { + workspaceId: 'workspace-1', + credentialIds: ['credential-1', 'credential-1'], + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.getActor).not.toHaveBeenCalled() + expect(mocks.deleteCredential).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/delete-many-credentials.ts b/apps/sim/lib/credentials/application/delete-many-credentials.ts new file mode 100644 index 00000000000..993f1795ef2 --- /dev/null +++ b/apps/sim/lib/credentials/application/delete-many-credentials.ts @@ -0,0 +1,114 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { deleteCredentialRecord } from '@/lib/credentials/orchestration' +import type { CredentialRow } from '@/lib/credentials/queries' +import { captureServerEvent } from '@/lib/posthog/server' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +const logger = createLogger('DeleteManyCredentialsApplication') +const MAX_CREDENTIAL_DELETE_BATCH = 20 + +export interface DeleteManyCredentialsInput { + workspaceId: string + credentialIds: string[] +} + +export interface DeleteManyCredentialsResult { + deleted: string[] + failed: string[] + deletedCredentials: CredentialRow[] +} + +export const deleteManyCredentialsUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.deleteMany, + resolveContext: async ({ input }: { input: DeleteManyCredentialsInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: { delegation: credentialDelegationPolicy }, + async execute({ principal, input, context }): Promise<DeleteManyCredentialsResult> { + if (input.credentialIds.length === 0) { + throw new OrchestrationError('validation', 'At least one credential ID is required') + } + if (input.credentialIds.length > MAX_CREDENTIAL_DELETE_BATCH) { + throw new OrchestrationError( + 'validation', + `At most ${MAX_CREDENTIAL_DELETE_BATCH} credentials can be deleted at once` + ) + } + if (new Set(input.credentialIds).size !== input.credentialIds.length) { + throw new OrchestrationError('validation', 'Credential IDs must be unique') + } + + const userId = requirePrincipalSubjectUserId(principal) + const deleted: string[] = [] + const failed: string[] = [] + const deletedCredentials: CredentialRow[] = [] + + for (const credentialId of input.credentialIds) { + try { + const access = await getCredentialActorContext(credentialId, userId) + if ( + !access.credential || + access.credential.workspaceId !== context.workspaceId || + !access.hasWorkspaceAccess || + !access.isAdmin || + access.credential.type !== 'oauth' + ) { + failed.push(credentialId) + continue + } + const didDelete = await deleteCredentialRecord({ + credential: access.credential, + reason: 'copilot_delete', + }) + if (!didDelete) { + failed.push(credentialId) + continue + } + deleted.push(credentialId) + deletedCredentials.push(access.credential) + } catch (error) { + logger.error('Failed to delete credential in Copilot batch', { credentialId, error }) + failed.push(credentialId) + } + } + + return { deleted, failed, deletedCredentials } + }, + projectAudit: ({ result }) => + result.deletedCredentials.map((credential) => ({ + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credential.id, + resourceName: credential.displayName, + description: `Deleted oauth credential "${credential.displayName}" (copilot_delete)`, + metadata: { + reason: 'copilot_delete', + credentialType: credential.type, + providerId: credential.providerId, + accountId: credential.accountId, + }, + })), + afterSuccess: ({ principal, context, result }) => { + for (const credential of result.deletedCredentials) { + captureServerEvent( + requirePrincipalSubjectUserId(principal), + 'credential_deleted', + { + credential_type: 'oauth', + provider_id: credential.providerId ?? credential.id, + workspace_id: context.workspaceId, + }, + { groups: { workspace: context.workspaceId } } + ) + } + }, +}) diff --git a/apps/sim/lib/credentials/application/list-credential-providers.test.ts b/apps/sim/lib/credentials/application/list-credential-providers.test.ts index c7d11d848d9..a6dbde7cc43 100644 --- a/apps/sim/lib/credentials/application/list-credential-providers.test.ts +++ b/apps/sim/lib/credentials/application/list-credential-providers.test.ts @@ -41,17 +41,15 @@ describe('listCredentialProviders', () => { mocks.listCatalog.mockResolvedValue([]) }) - it('rejects unsupported principals before canonical workspace loading', async () => { + it('allows sessions to inspect deployment availability', async () => { const principal: SessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1', } - await expect( - listCredentialProviders.execute({ principal, input: { workspaceId: 'workspace-1' } }) - ).rejects.toMatchObject({ code: 'forbidden' }) - expect(mocks.loadWorkspace).not.toHaveBeenCalled() + await listCredentialProviders.execute({ principal, input: { workspaceId: 'workspace-1' } }) + expect(mocks.listCatalog).toHaveBeenCalledWith(principal, workspaceContext) }) it('allows workspace keys to inspect deployment availability', async () => { diff --git a/apps/sim/lib/credentials/application/list-credential-providers.ts b/apps/sim/lib/credentials/application/list-credential-providers.ts index b24d45ea811..d61c7209340 100644 --- a/apps/sim/lib/credentials/application/list-credential-providers.ts +++ b/apps/sim/lib/credentials/application/list-credential-providers.ts @@ -1,5 +1,6 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' import { credentialOperations } from '@/lib/credentials/application/operations' import { type CredentialProviderCatalogEntry, @@ -22,7 +23,7 @@ export const listCredentialProviders = defineAuthorizedWorkspaceUseCase({ if (!context) throw new OrchestrationError('not_found', 'Workspace not found') return context }, - authorizationOptions: {}, + authorizationOptions: { delegation: credentialDelegationPolicy }, execute: async ({ principal, context }): Promise<ListCredentialProvidersResult> => ({ providers: await listCredentialProviderCatalog(principal, context), }), diff --git a/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts b/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts index f0dc64e48e3..c22e024cd61 100644 --- a/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts +++ b/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts @@ -54,21 +54,21 @@ describe('listWorkspaceCredentials', () => { mocks.loadWorkspace.mockResolvedValue(workspaceContext) mocks.resolvePermission.mockResolvedValue('read') mocks.checkWorkspaceAccess.mockResolvedValue({ hasAccess: true, canAdmin: false }) - mocks.listVisible.mockResolvedValue([]) - mocks.listForWorkspacePrincipal.mockResolvedValue([]) + mocks.listVisible.mockResolvedValue({ data: [], nextCursorKeys: null }) + mocks.listForWorkspacePrincipal.mockResolvedValue({ data: [], nextCursorKeys: null }) }) - it('rejects unsupported principals before canonical workspace loading', async () => { + it('preserves per-credential visibility for sessions', async () => { const principal: SessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1', } - await expect(listWorkspaceCredentials.execute({ principal, input })).rejects.toMatchObject({ - code: 'forbidden', - }) - expect(mocks.loadWorkspace).not.toHaveBeenCalled() + await listWorkspaceCredentials.execute({ principal, input }) + expect(mocks.listVisible).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', types: ['oauth', 'service_account'] }) + ) }) it('lists shared connections for a workspace key without creator identity', async () => { diff --git a/apps/sim/lib/credentials/application/oauth-accounts.ts b/apps/sim/lib/credentials/application/oauth-accounts.ts new file mode 100644 index 00000000000..3c649416f06 --- /dev/null +++ b/apps/sim/lib/credentials/application/oauth-accounts.ts @@ -0,0 +1,98 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { SessionPrincipal } from '@sim/auth/principal' +import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' +import { credentialUserOperations } from '@/lib/credentials/application/operations' +import { + disconnectOAuthAccounts, + listConnectedAccountsForUser, + listOAuthConnectionsForUser, +} from '@/lib/credentials/oauth-accounts' +import { captureServerEvent } from '@/lib/posthog/server' + +export const listOAuthConnectionsUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.listOAuthConnections, + async execute({ principal }) { + return { connections: await listOAuthConnectionsForUser(principal.userId) } + }, +}) + +export interface ListConnectedAccountsInput { + provider?: string +} + +export const listConnectedAccountsUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.listConnectedAccounts, + async execute({ + principal, + input, + }: { + principal: SessionPrincipal + input: ListConnectedAccountsInput + }) { + return { + accounts: await listConnectedAccountsForUser({ + userId: principal.userId, + provider: input.provider, + }), + } + }, +}) + +export interface DisconnectOAuthInput { + provider: string + providerId?: string + accountId?: string +} + +export const disconnectOAuthUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.disconnectOAuth, + async execute({ + principal, + input, + }: { + principal: SessionPrincipal + input: DisconnectOAuthInput + }) { + const result = await disconnectOAuthAccounts({ userId: principal.userId, ...input }) + return { ...result, ...input, success: true as const } + }, + projectAudit: ({ result }) => [ + ...result.credentials.map((credential) => ({ + workspaceId: credential.workspaceId, + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credential.id, + resourceName: credential.displayName, + description: `Deleted oauth credential "${credential.displayName}" (oauth_disconnect)`, + metadata: { + reason: 'oauth_disconnect', + credentialType: credential.type, + providerId: credential.providerId, + accountId: credential.accountId, + }, + })), + { + workspaceId: null, + action: AuditAction.OAUTH_DISCONNECTED, + resourceType: AuditResourceType.OAUTH, + resourceId: result.providerId ?? result.provider, + resourceName: result.provider, + description: `Disconnected OAuth provider: ${result.provider}`, + metadata: { provider: result.provider, providerId: result.providerId }, + }, + ], + afterSuccess: ({ principal, result }) => { + for (const credential of result.credentials) { + captureServerEvent( + principal.userId, + 'credential_deleted', + { + credential_type: 'oauth', + provider_id: credential.providerId ?? result.providerId ?? result.provider, + workspace_id: credential.workspaceId, + }, + { groups: { workspace: credential.workspaceId } } + ) + } + }, +}) diff --git a/apps/sim/lib/credentials/application/operations.test.ts b/apps/sim/lib/credentials/application/operations.test.ts index 2fd4091fb7b..26c363580f9 100644 --- a/apps/sim/lib/credentials/application/operations.test.ts +++ b/apps/sim/lib/credentials/application/operations.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' import { defineWorkspaceOperation } from '@/lib/core/application' import { credentialOperations, - defineCredentialAdminOperation, + defineCredentialOperation, } from '@/lib/credentials/application/operations' describe('credential operations', () => { @@ -15,7 +15,8 @@ describe('credential operations', () => { minimumRole: 'read', minimumCredentialRole: 'admin', workspaceApiKey: 'deny', - principalKinds: ['personal_api_key'], + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], }) expect(Object.isFrozen(credentialOperations.delete)).toBe(true) }) @@ -28,8 +29,8 @@ describe('credential operations', () => { principalKinds: ['workspace_api_key'], }) - expect(() => defineCredentialAdminOperation(workspaceKeyOperation)).toThrow( - 'Credential admin operation credentials.test_admin requires a human principal' + expect(() => defineCredentialOperation(workspaceKeyOperation, 'admin')).toThrow( + 'Credential operation credentials.test_admin requires a user-bearing principal' ) }) }) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index ec5b7181635..fbd1f8a5631 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -1,51 +1,143 @@ +import type { ApplicationOperation } from '@/lib/core/application' import { defineWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' -export type CredentialAdminOperation<O extends WorkspaceOperation = WorkspaceOperation> = O & { - readonly minimumCredentialRole: 'admin' +export type CredentialRole = 'member' | 'admin' + +export type CredentialOperation<O extends WorkspaceOperation = WorkspaceOperation> = O & { + readonly minimumCredentialRole: CredentialRole } -/** Adds credential-admin policy to a workspace-scoped operation. */ -export function defineCredentialAdminOperation<const O extends WorkspaceOperation>( - operation: O -): CredentialAdminOperation<O> { +/** Adds credential-resource policy to a workspace-scoped operation. */ +export function defineCredentialOperation< + const O extends WorkspaceOperation, + const R extends CredentialRole, +>( + operation: O, + minimumCredentialRole: R +): CredentialOperation<O> & { + readonly minimumCredentialRole: R +} { if (operation.principalKinds.includes('workspace_api_key')) { - throw new Error(`Credential admin operation ${operation.id} requires a human principal`) + throw new Error(`Credential operation ${operation.id} requires a user-bearing principal`) } - return Object.freeze({ ...operation, minimumCredentialRole: 'admin' as const }) + return Object.freeze({ ...operation, minimumCredentialRole }) } +const HUMAN_AND_COPILOT_PRINCIPALS = { + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const + export const credentialOperations = { + listInternal: defineWorkspaceOperation({ + id: 'credentials.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), listProviders: defineWorkspaceOperation({ id: 'credentials.providers.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ['personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), listConnections: defineWorkspaceOperation({ id: 'credentials.connections.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ['personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), createConnection: defineWorkspaceOperation({ id: 'credentials.connections.create', minimumRole: 'write', workspaceApiKey: 'deny', - principalKinds: ['personal_api_key'], + principalKinds: ['session', 'personal_api_key'], + }), + prepareConnection: defineWorkspaceOperation({ + id: 'credentials.connections.prepare', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], }), createServiceAccount: defineWorkspaceOperation({ id: 'credentials.service_accounts.create', minimumRole: 'write', workspaceApiKey: 'deny', - principalKinds: ['personal_api_key'], + principalKinds: ['session', 'personal_api_key'], + }), + read: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'member' + ), + create: defineWorkspaceOperation({ + id: 'credentials.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], }), - delete: defineCredentialAdminOperation( + update: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.update', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPALS, + }), + 'admin' + ), + delete: defineCredentialOperation( defineWorkspaceOperation({ id: 'credentials.delete', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: ['personal_api_key'], - }) + ...HUMAN_AND_COPILOT_PRINCIPALS, + }), + 'admin' + ), + deleteMany: defineWorkspaceOperation({ + id: 'credentials.delete_many', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + saveDraft: defineWorkspaceOperation({ + id: 'credentials.drafts.save', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + listMembers: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.members.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'member' + ), + upsertMember: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.members.upsert', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'admin' + ), + removeMember: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.members.remove', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'admin' ), launchConnection: defineWorkspaceOperation({ id: 'credentials.connections.launch', @@ -54,3 +146,23 @@ export const credentialOperations = { principalKinds: ['session'], }), } as const + +export interface CredentialUserOperation<Id extends string = string> + extends ApplicationOperation<Id> { + readonly principalKinds: readonly ['session'] +} + +function defineCredentialUserOperation<const Id extends string>( + id: Id +): CredentialUserOperation<Id> { + if (!id.trim()) throw new Error('Credential user operation ID must not be empty') + return Object.freeze({ id, principalKinds: Object.freeze(['session'] as const) }) +} + +export const credentialUserOperations = { + listMemberships: defineCredentialUserOperation('credentials.memberships.list'), + leaveMembership: defineCredentialUserOperation('credentials.memberships.leave'), + listOAuthConnections: defineCredentialUserOperation('credentials.oauth_connections.list'), + listConnectedAccounts: defineCredentialUserOperation('credentials.accounts.list'), + disconnectOAuth: defineCredentialUserOperation('credentials.oauth_connections.disconnect'), +} as const diff --git a/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts b/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts new file mode 100644 index 00000000000..4d2e8f8ed27 --- /dev/null +++ b/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts @@ -0,0 +1,118 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + listCatalog: vi.fn(), + resolveTarget: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, +})) +vi.mock('@/lib/credentials/application/connection-target', () => ({ + resolveCredentialConnectionTarget: mocks.resolveTarget, +})) + +import { prepareCredentialConnection } from '@/lib/credentials/application/prepare-credential-connection' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:credentials', + issuedAt: new Date('2026-08-14T12:00:00.000Z'), + expiresAt: new Date('2030-08-14T12:05:00.000Z'), +} +const gmailProvider = { + type: 'oauth' as const, + serviceId: 'gmail', + name: 'Gmail', + description: 'Gmail OAuth', + providerFamily: 'google', + available: true, + supportsReconnect: true, + authorizationOptions: [{ providerId: 'google-email', label: 'Gmail' }], +} + +describe('prepareCredentialConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.listCatalog.mockResolvedValue([gmailProvider]) + }) + + it('resolves a provider inside delegated workspace policy', async () => { + const result = await prepareCredentialConnection.execute({ + principal, + input: { workspaceId: 'workspace-1', providerName: 'gmail' }, + }) + + expect(result).toEqual({ providerId: 'google-email', serviceName: 'Gmail' }) + }) + + it('uses the credential target as the reconnect authority', async () => { + mocks.resolveTarget.mockResolvedValue({ + providerId: 'google-email', + credentialId: 'credential-1', + }) + + const result = await prepareCredentialConnection.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerName: 'gmail', + credentialId: 'credential-1', + }, + }) + + expect(result).toEqual({ + providerId: 'google-email', + serviceName: 'Gmail', + credentialId: 'credential-1', + }) + expect(mocks.resolveTarget).toHaveBeenCalledWith({ + principal, + context: workspace, + credentialId: 'credential-1', + }) + }) + + it('rejects a reconnect whose requested provider does not match the credential', async () => { + mocks.resolveTarget.mockResolvedValue({ + providerId: 'slack', + credentialId: 'credential-1', + }) + + await expect( + prepareCredentialConnection.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerName: 'gmail', + credentialId: 'credential-1', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) +}) diff --git a/apps/sim/lib/credentials/application/prepare-credential-connection.ts b/apps/sim/lib/credentials/application/prepare-credential-connection.ts new file mode 100644 index 00000000000..cedd08e89fd --- /dev/null +++ b/apps/sim/lib/credentials/application/prepare-credential-connection.ts @@ -0,0 +1,109 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + listCredentialProviderCatalog, + type OAuthCredentialProviderCatalogEntry, +} from '@/lib/credentials/application/provider-catalog' +import { credentialProviderMatchesService } from '@/lib/oauth/utils' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface PrepareCredentialConnectionInput { + workspaceId: string + providerName: string + credentialId?: string +} + +export interface PrepareCredentialConnectionResult { + providerId: string + serviceName: string + credentialId?: string +} + +function resolveRequestedProvider( + providers: readonly OAuthCredentialProviderCatalogEntry[], + providerName: string +): OAuthCredentialProviderCatalogEntry { + const requested = providerName.toLowerCase().trim() + if (!requested) throw new OrchestrationError('validation', 'OAuth provider is required') + + const provider = + providers.find((entry) => + entry.authorizationOptions.some((option) => option.providerId.toLowerCase() === requested) + ) ?? + providers.find( + (entry) => + entry.serviceId.toLowerCase() === requested || entry.name.toLowerCase() === requested + ) ?? + providers.find( + (entry) => + entry.name.toLowerCase().includes(requested) || + requested.includes(entry.name.toLowerCase()) || + entry.authorizationOptions.some( + (option) => + option.providerId.toLowerCase().includes(requested) || + requested.includes(option.providerId.toLowerCase()) + ) + ) + + if (!provider) + throw new OrchestrationError('validation', `OAuth provider not found: ${providerName}`) + if (!provider.available) { + throw new OrchestrationError('conflict', `${provider.name} is not available in this workspace`) + } + return provider +} + +export const prepareCredentialConnection = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.prepareConnection, + resolveContext: async ({ input }: { input: PrepareCredentialConnectionInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: { delegation: credentialDelegationPolicy }, + execute: async ({ principal, input, context }): Promise<PrepareCredentialConnectionResult> => { + const providers = (await listCredentialProviderCatalog(principal, context)).filter( + (entry): entry is OAuthCredentialProviderCatalogEntry => entry.type === 'oauth' + ) + const requestedProvider = resolveRequestedProvider(providers, input.providerName) + const requestedProviderId = requestedProvider.authorizationOptions[0]?.providerId + if (!requestedProviderId) { + throw new Error(`OAuth provider ${requestedProvider.serviceId} has no authorization option`) + } + + if (!input.credentialId) { + return { + providerId: requestedProviderId, + serviceName: requestedProvider.name, + } + } + + const target = await resolveCredentialConnectionTarget({ + principal, + context, + credentialId: input.credentialId, + }) + if ( + !credentialProviderMatchesService(target.providerId, { + providerId: requestedProviderId, + additionalProviderIds: requestedProvider.authorizationOptions + .slice(1) + .map((option) => option.providerId), + }) + ) { + throw new OrchestrationError( + 'validation', + `Credential belongs to provider ${target.providerId}, not ${requestedProviderId}` + ) + } + + return { + providerId: target.providerId, + serviceName: requestedProvider.name, + credentialId: target.credentialId, + } + }, +}) diff --git a/apps/sim/lib/credentials/application/presentation.ts b/apps/sim/lib/credentials/application/presentation.ts index efbf73c0d07..3ff11684906 100644 --- a/apps/sim/lib/credentials/application/presentation.ts +++ b/apps/sim/lib/credentials/application/presentation.ts @@ -1,4 +1,6 @@ +import type { WorkspaceCredential } from '@/lib/api/contracts/credentials' import type { V2Credential } from '@/lib/api/contracts/v2/credentials' +import type { CredentialActorContext } from '@/lib/credentials/access' import type { CredentialRow, VisibleWorkspaceCredential } from '@/lib/credentials/queries' type PublicCredentialSource = @@ -24,3 +26,30 @@ export function toV2Credential(row: PublicCredentialSource): V2Credential { updatedAt: row.updatedAt.toISOString(), } } + +/** Serializes credential metadata for the internal workspace surface. */ +export function toWorkspaceCredential( + row: CredentialRow | VisibleWorkspaceCredential, + access?: CredentialActorContext +): WorkspaceCredential { + const role = access?.isAdmin + ? 'admin' + : (access?.member?.role ?? ('role' in row ? row.role : undefined)) + const status = access?.member?.status ?? (access?.isAdmin ? 'active' : undefined) + return { + id: row.id, + workspaceId: row.workspaceId, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + envKey: row.envKey, + envOwnerUserId: row.envOwnerUserId, + createdBy: row.createdBy, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + ...(role ? { role } : {}), + ...(status ? { status } : {}), + } +} diff --git a/apps/sim/lib/credentials/application/save-credential-draft.test.ts b/apps/sim/lib/credentials/application/save-credential-draft.test.ts new file mode 100644 index 00000000000..abdd2da0d16 --- /dev/null +++ b/apps/sim/lib/credentials/application/save-credential-draft.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getActor: vi.fn(), + createDraft: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) +vi.mock('@/lib/credentials/connect-draft', () => ({ + createConnectDraft: mocks.createDraft, +})) + +import { saveCredentialDraft } from '@/lib/credentials/application/save-credential-draft' + +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const credential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth' as const, +} + +describe('saveCredentialDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + mocks.createDraft.mockResolvedValue({ id: 'draft-1' }) + }) + + it('authorizes workspace access before resolving reconnect credential access', async () => { + await saveCredentialDraft.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + credentialId: 'credential-1', + }, + }) + + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.getActor.mock.invocationCallOrder[0] + ) + expect(mocks.createDraft).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + workspaceId: 'workspace-1', + credentialId: 'credential-1', + replaceExistingIntent: true, + }) + ) + }) + + it('rejects a reconnect outside the asserted workspace', async () => { + mocks.getActor.mockResolvedValue({ + credential: { ...credential, workspaceId: 'workspace-2' }, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + await expect( + saveCredentialDraft.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + credentialId: 'credential-1', + }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.createDraft).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/save-credential-draft.ts b/apps/sim/lib/credentials/application/save-credential-draft.ts new file mode 100644 index 00000000000..6fc002c83c7 --- /dev/null +++ b/apps/sim/lib/credentials/application/save-credential-draft.ts @@ -0,0 +1,67 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase, ForbiddenOperationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { type CredentialActorContext, getCredentialActorContext } from '@/lib/credentials/access' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface SaveCredentialDraftInput { + workspaceId: string + providerId: string + displayName: string + description?: string + credentialId?: string +} + +interface SaveCredentialDraftContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string + credentialAccess?: CredentialActorContext +} + +export const saveCredentialDraft = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.saveDraft, + async resolveContext({ + input, + }: { + input: SaveCredentialDraftInput + }): Promise<SaveCredentialDraftContext> { + const workspace = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return workspace + }, + authorizationOptions: {}, + async authorizeResource({ principal, input, context }) { + if (!input.credentialId) return + context.credentialAccess = await getCredentialActorContext( + input.credentialId, + requirePrincipalSubjectUserId(principal) + ) + if ( + !context.credentialAccess?.credential || + context.credentialAccess.credential.workspaceId !== context.workspaceId || + !context.credentialAccess.isAdmin + ) { + throw new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Admin access required on the target credential' + ) + } + }, + async execute({ principal, input }) { + await createConnectDraft({ + userId: requirePrincipalSubjectUserId(principal), + workspaceId: input.workspaceId, + providerId: input.providerId, + displayName: input.displayName, + description: input.description, + credentialId: input.credentialId, + displayNameDefinesIntent: true, + replaceExistingIntent: true, + }) + return { success: true as const } + }, +}) diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts index d72c1911864..c99ac568058 100644 --- a/apps/sim/lib/credentials/application/service-account.ts +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -1,14 +1,12 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import type { Principal } from '@sim/auth/principal' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { HttpError } from '@/lib/core/utils/http-error' import { getCredentialActorContext } from '@/lib/credentials/access' -import { - type CredentialAuthorizationContext, - defineAuthorizedCredentialUseCase, -} from '@/lib/credentials/application/authorized-credential-use-case' +import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' import { credentialOperations } from '@/lib/credentials/application/operations' import { listCredentialProviderCatalog, @@ -18,8 +16,9 @@ import { type CreateServiceAccountCredentialParams, createServiceAccountCredential, deleteConnectionCredential, + deleteCredentialRecord, } from '@/lib/credentials/orchestration' -import { type CredentialRow, getWorkspaceCredential } from '@/lib/credentials/queries' +import type { CredentialRow } from '@/lib/credentials/queries' import { captureServerEvent } from '@/lib/posthog/server' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' @@ -45,10 +44,6 @@ class CredentialProviderUnavailableError extends HttpError { } } -function principalUserId(principal: Extract<Principal, { kind: 'personal_api_key' }>): string { - return principal.userId -} - export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUseCase({ operation: credentialOperations.createServiceAccount, resolveContext: async ({ input }: { input: CreateServiceAccountInput }) => { @@ -63,7 +58,7 @@ export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUs const result = await createServiceAccountCredential({ ...input, workspaceId: context.workspaceId, - userId: principalUserId(principal), + userId: requirePrincipalSubjectUserId(principal), request, }) if (!result.success) { @@ -85,7 +80,10 @@ export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUs if (!result.credential) { throw new Error('Credential creation succeeded without a credential') } - const actor = await getCredentialActorContext(result.credential.id, principalUserId(principal)) + const actor = await getCredentialActorContext( + result.credential.id, + requirePrincipalSubjectUserId(principal) + ) if (!actor.credential || (!actor.member && !actor.isAdmin)) { throw new Error('Created credential is not visible to its creator') } @@ -115,7 +113,7 @@ export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUs afterSuccess: ({ principal, context, result }) => { if (!result.created) return captureServerEvent( - principalUserId(principal), + requirePrincipalSubjectUserId(principal), 'credential_connected', { credential_type: 'service_account', @@ -130,12 +128,8 @@ export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUs }, }) -interface CredentialApplicationContext extends CredentialAuthorizationContext { - billedAccountUserId: string -} - export interface DeleteCredentialInput { - workspaceId: string + workspaceId?: string credentialId: string } @@ -144,43 +138,47 @@ export interface DeleteCredentialResult { deleted: boolean } -async function resolveCredentialContext( - input: DeleteCredentialInput -): Promise<CredentialApplicationContext> { - const workspace = await loadActiveWorkspaceApplicationContext(input.workspaceId) - if (!workspace) throw new OrchestrationError('not_found', 'Credential not found') - const credential = await getWorkspaceCredential({ - workspaceId: workspace.workspaceId, - credentialId: input.credentialId, - }) - if (!credential || !['oauth', 'service_account'].includes(credential.type)) { - throw new OrchestrationError('not_found', 'Credential not found') - } - return { ...workspace, credential } -} - export const deleteCredentialUseCase = defineAuthorizedCredentialUseCase({ operation: credentialOperations.delete, - resolveContext: async ({ input }: { input: DeleteCredentialInput }) => - resolveCredentialContext(input), - async execute({ input, context }): Promise<DeleteCredentialResult> { - const deleted = await deleteConnectionCredential({ + resolveContext: ({ input }: { input: DeleteCredentialInput }) => + resolveCredentialApplicationContext({ credentialId: input.credentialId, - workspaceId: context.workspaceId, - reason: 'user_delete', - }) + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, context }): Promise<DeleteCredentialResult> { + const allowedTypes = + principal.kind === 'session' + ? ['oauth', 'env_workspace', 'env_personal', 'service_account'] + : principal.kind === 'delegated' + ? ['oauth'] + : ['oauth', 'service_account'] + if (!allowedTypes.includes(context.credential.type)) { + throw new OrchestrationError( + 'validation', + `Only ${allowedTypes.join(', ')} credentials can be managed by this caller` + ) + } + const reason = principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete' + const deleted = + context.credential.type === 'oauth' || context.credential.type === 'service_account' + ? await deleteConnectionCredential({ + credentialId: context.credential.id, + workspaceId: context.workspaceId, + reason, + }) + : await deleteCredentialRecord({ credential: context.credential, reason }) return { credential: context.credential, deleted } }, - projectAudit: ({ result }) => + projectAudit: ({ principal, result }) => result.deleted ? { action: AuditAction.CREDENTIAL_DELETED, resourceType: AuditResourceType.CREDENTIAL, resourceId: result.credential.id, resourceName: result.credential.displayName, - description: `Deleted ${result.credential.type} credential "${result.credential.displayName}" (user_delete)`, + description: `Deleted ${result.credential.type} credential "${result.credential.displayName}" (${principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete'})`, metadata: { - reason: 'user_delete', + reason: principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete', credentialType: result.credential.type, providerId: result.credential.providerId, accountId: result.credential.accountId, @@ -190,10 +188,10 @@ export const deleteCredentialUseCase = defineAuthorizedCredentialUseCase({ afterSuccess: ({ principal, context, result }) => { if (!result.deleted) return captureServerEvent( - principalUserId(principal), + requirePrincipalSubjectUserId(principal), 'credential_deleted', { - credential_type: result.credential.type as 'oauth' | 'service_account', + credential_type: result.credential.type, provider_id: result.credential.providerId ?? result.credential.id, workspace_id: context.workspaceId, }, diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 6c2565e3d55..b41e2693412 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -29,8 +29,11 @@ export async function createConnectDraft(params: { credentialId?: string /** Reconnect only: the credential's actual name, so audit records stay accurate. */ displayName?: string + description?: string /** Whether an explicitly requested name distinguishes this new-connection intent. */ displayNameDefinesIntent?: boolean + /** Replaces a pending intent for the same user, provider, and workspace. */ + replaceExistingIntent?: boolean }): Promise<CreatedConnectDraft> { const { userId, workspaceId, providerId, credentialId } = params @@ -83,6 +86,7 @@ export async function createConnectDraft(params: { workspaceId, providerId, displayName, + description: params.description?.trim() || null, credentialId: credentialId ?? null, expiresAt, createdAt: now, @@ -93,8 +97,16 @@ export async function createConnectDraft(params: { pendingCredentialDraft.providerId, pendingCredentialDraft.workspaceId, ], - set: { expiresAt, createdAt: now }, - setWhere: sameIntent, + set: params.replaceExistingIntent + ? { + displayName, + description: params.description?.trim() || null, + credentialId: credentialId ?? null, + expiresAt, + createdAt: now, + } + : { expiresAt, createdAt: now }, + ...(params.replaceExistingIntent ? {} : { setWhere: sameIntent }), }) .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) diff --git a/apps/sim/lib/credentials/members.ts b/apps/sim/lib/credentials/members.ts new file mode 100644 index 00000000000..3e1ed1985a6 --- /dev/null +++ b/apps/sim/lib/credentials/members.ts @@ -0,0 +1,265 @@ +import { db } from '@sim/db' +import { credential, credentialMember, user } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { isSharedCredentialType } from '@/lib/credentials/access' +import type { CredentialRow } from '@/lib/credentials/queries' +import { + getUserEntityPermissions, + getUsersWithPermissions, +} from '@/lib/workspaces/permissions/utils' + +export interface CredentialMemberView { + id: string + userId: string + role: 'admin' | 'member' + status: 'active' | 'pending' | 'revoked' + joinedAt: Date | null + userName: string | null + userEmail: string | null + roleSource: 'explicit' | 'workspace-admin' +} + +export async function listCredentialMembers( + credential: CredentialRow +): Promise<CredentialMemberView[]> { + const explicitMembers = await db + .select({ + id: credentialMember.id, + userId: credentialMember.userId, + role: credentialMember.role, + status: credentialMember.status, + joinedAt: credentialMember.joinedAt, + userName: user.name, + userEmail: user.email, + }) + .from(credentialMember) + .innerJoin(user, eq(credentialMember.userId, user.id)) + .where(eq(credentialMember.credentialId, credential.id)) + + const byUser = new Map<string, CredentialMemberView>( + explicitMembers.map((member) => [member.userId, { ...member, roleSource: 'explicit' as const }]) + ) + + if (isSharedCredentialType(credential.type)) { + const workspaceMembers = await getUsersWithPermissions(credential.workspaceId) + for (const workspaceMember of workspaceMembers) { + if (workspaceMember.permissionType !== 'admin') continue + const existing = byUser.get(workspaceMember.userId) + if (existing) { + existing.role = 'admin' + existing.status = 'active' + existing.roleSource = 'workspace-admin' + } else { + byUser.set(workspaceMember.userId, { + id: `workspace-admin-${workspaceMember.userId}`, + userId: workspaceMember.userId, + role: 'admin', + status: 'active', + joinedAt: null, + userName: workspaceMember.name, + userEmail: workspaceMember.email, + roleSource: 'workspace-admin', + }) + } + } + } + + return Array.from(byUser.values()) +} + +export interface UpsertCredentialMemberParams { + credential: CredentialRow + actorUserId: string + targetUserId: string + role: 'admin' | 'member' +} + +export interface UpsertCredentialMemberResult { + created: boolean + previousRole?: 'admin' | 'member' +} + +export async function upsertCredentialMember( + params: UpsertCredentialMemberParams +): Promise<UpsertCredentialMemberResult> { + if (!isSharedCredentialType(params.credential.type)) { + throw new OrchestrationError('validation', 'Personal secrets cannot be shared') + } + const targetWorkspacePermission = await getUserEntityPermissions( + params.targetUserId, + 'workspace', + params.credential.workspaceId + ) + if (targetWorkspacePermission === null) { + throw new OrchestrationError( + 'validation', + 'Target user must belong to the credential workspace' + ) + } + if (targetWorkspacePermission === 'admin' && params.role !== 'admin') { + throw new OrchestrationError( + 'validation', + 'Workspace admins are automatically credential admins and cannot be demoted' + ) + } + + const [existing] = await db + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credential.id), + eq(credentialMember.userId, params.targetUserId) + ) + ) + .limit(1) + const now = new Date() + if (existing) { + const previousRole = await db.transaction(async (tx) => { + const [current] = await tx + .select({ role: credentialMember.role }) + .from(credentialMember) + .where(eq(credentialMember.id, existing.id)) + .limit(1) + .for('update') + if (!current) throw new Error('Credential membership disappeared during update') + await tx + .update(credentialMember) + .set({ role: params.role, status: 'active', updatedAt: now }) + .where(eq(credentialMember.id, existing.id)) + return current.role + }) + return { created: false, previousRole } + } + + await db.insert(credentialMember).values({ + id: generateId(), + credentialId: params.credential.id, + userId: params.targetUserId, + role: params.role, + status: 'active', + joinedAt: now, + invitedBy: params.actorUserId, + createdAt: now, + updatedAt: now, + }) + return { created: true } +} + +export async function removeCredentialMember(params: { + credential: CredentialRow + targetUserId: string +}): Promise<void> { + const [target] = await db + .select({ id: credentialMember.id, role: credentialMember.role }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credential.id), + eq(credentialMember.userId, params.targetUserId), + eq(credentialMember.status, 'active') + ) + ) + .limit(1) + if (!target) throw new OrchestrationError('not_found', 'Member not found') + + if (isSharedCredentialType(params.credential.type)) { + const targetWorkspacePermission = await getUserEntityPermissions( + params.targetUserId, + 'workspace', + params.credential.workspaceId + ) + if (targetWorkspacePermission === 'admin') { + throw new OrchestrationError( + 'validation', + 'Workspace admins are automatically credential admins and cannot be removed' + ) + } + } + + const revoked = await db.transaction(async (tx) => { + if (!isSharedCredentialType(params.credential.type) && target.role === 'admin') { + const activeAdmins = await tx + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credential.id), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active') + ) + ) + .for('update') + if (activeAdmins.length <= 1) return false + } + await tx + .update(credentialMember) + .set({ status: 'revoked', updatedAt: new Date() }) + .where(eq(credentialMember.id, target.id)) + return true + }) + if (!revoked) throw new OrchestrationError('validation', 'Cannot remove the last admin') +} + +export async function listCredentialMembershipsForUser(userId: string) { + return db + .select({ + membershipId: credentialMember.id, + credentialId: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + displayName: credential.displayName, + providerId: credential.providerId, + role: credentialMember.role, + status: credentialMember.status, + joinedAt: credentialMember.joinedAt, + }) + .from(credentialMember) + .innerJoin(credential, eq(credentialMember.credentialId, credential.id)) + .where(eq(credentialMember.userId, userId)) +} + +export async function leaveCredentialMembership(params: { + userId: string + credentialId: string +}): Promise<void> { + const [membership] = await db + .select() + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credentialId), + eq(credentialMember.userId, params.userId) + ) + ) + .limit(1) + if (!membership) throw new OrchestrationError('not_found', 'Membership not found') + if (membership.status !== 'active') return + + const revoked = await db.transaction(async (tx) => { + if (membership.role === 'admin') { + const activeAdmins = await tx + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credentialId), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active') + ) + ) + .for('update') + if (activeAdmins.length <= 1) return false + } + await tx + .update(credentialMember) + .set({ status: 'revoked', updatedAt: new Date() }) + .where(eq(credentialMember.id, membership.id)) + return true + }) + if (!revoked) { + throw new OrchestrationError('validation', 'Cannot leave credential as the last active admin') + } +} diff --git a/apps/sim/lib/credentials/oauth-accounts.ts b/apps/sim/lib/credentials/oauth-accounts.ts new file mode 100644 index 00000000000..8322b1f551d --- /dev/null +++ b/apps/sim/lib/credentials/oauth-accounts.ts @@ -0,0 +1,135 @@ +import { db } from '@sim/db' +import { account, credential, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, desc, eq, inArray, like, or } from 'drizzle-orm' +import { decodeJwt } from 'jose' +import type { OAuthConnection } from '@/lib/api/contracts/oauth-connections' +import { deleteCredentialRecord } from '@/lib/credentials/orchestration' +import type { OAuthProvider } from '@/lib/oauth' +import { parseProvider } from '@/lib/oauth' +import { providerIdsForService } from '@/lib/oauth/utils' + +const logger = createLogger('CredentialOAuthAccounts') + +interface GoogleIdToken { + email?: string + name?: string +} + +export async function listOAuthConnectionsForUser(userId: string): Promise<OAuthConnection[]> { + const [accounts, userRecord] = await Promise.all([ + db.select().from(account).where(eq(account.userId, userId)), + db.select({ email: user.email }).from(user).where(eq(user.id, userId)).limit(1), + ]) + const userEmail = userRecord[0]?.email ?? null + const connections: OAuthConnection[] = [] + + for (const accountRow of accounts) { + const { baseProvider, featureType } = parseProvider(accountRow.providerId as OAuthProvider) + if (!baseProvider) continue + const scopes = accountRow.scope?.split(/\s+/).filter(Boolean) ?? [] + let displayName = '' + if (accountRow.idToken) { + try { + const decoded = decodeJwt<GoogleIdToken>(accountRow.idToken) + displayName = decoded.email || decoded.name || '' + } catch (error) { + logger.warn('Failed to decode OAuth account ID token', { accountId: accountRow.id, error }) + } + } + if (!displayName && baseProvider === 'github') { + displayName = `${accountRow.accountId} (GitHub)` + } + displayName ||= userEmail || `${accountRow.accountId} (${baseProvider})` + + const existing = connections.find((connection) => connection.provider === accountRow.providerId) + if (existing) { + existing.accounts.push({ id: accountRow.id, name: displayName }) + existing.scopes = Array.from(new Set([...existing.scopes, ...scopes])) + if (accountRow.updatedAt.getTime() > new Date(existing.lastConnected).getTime()) { + existing.lastConnected = accountRow.updatedAt.toISOString() + } + continue + } + connections.push({ + provider: accountRow.providerId, + baseProvider, + featureType, + isConnected: true, + scopes, + lastConnected: accountRow.updatedAt.toISOString(), + accounts: [{ id: accountRow.id, name: displayName }], + }) + } + + return connections +} + +export async function listConnectedAccountsForUser(params: { userId: string; provider?: string }) { + const whereConditions = [eq(account.userId, params.userId)] + if (params.provider) whereConditions.push(eq(account.providerId, params.provider)) + const rows = await db + .select({ + id: account.id, + accountId: account.accountId, + providerId: account.providerId, + credentialDisplayName: credential.displayName, + }) + .from(account) + .leftJoin(credential, eq(credential.accountId, account.id)) + .where(and(...whereConditions)) + .orderBy(desc(account.updatedAt)) + + const seen = new Map<string, (typeof rows)[number]>() + for (const row of rows) { + if (!seen.has(row.id)) seen.set(row.id, row) + } + return Array.from(seen.values()).map((row) => ({ + id: row.id, + accountId: row.accountId, + providerId: row.providerId, + displayName: row.credentialDisplayName || row.accountId || row.providerId, + })) +} + +export interface DisconnectOAuthAccountsParams { + userId: string + provider: string + providerId?: string + accountId?: string +} + +export async function disconnectOAuthAccounts(params: DisconnectOAuthAccountsParams) { + const accountFilter = params.accountId + ? and(eq(account.userId, params.userId), eq(account.id, params.accountId)) + : params.providerId + ? and(eq(account.userId, params.userId), eq(account.providerId, params.providerId)) + : and( + eq(account.userId, params.userId), + or( + inArray(account.providerId, providerIdsForService(params.provider)), + like(account.providerId, `${params.provider}-%`) + ) + ) + const targetAccounts = await db.select({ id: account.id }).from(account).where(accountFilter) + const targetAccountIds = targetAccounts.map((row) => row.id) + if (targetAccountIds.length === 0) return { credentials: [] } + + const credentialRows = await db + .select() + .from(credential) + .where(inArray(credential.accountId, targetAccountIds)) + const deletedCredentials: typeof credentialRows = [] + for (const credentialRow of credentialRows) { + if (credentialRow.type !== 'oauth') { + throw new Error(`OAuth account ${credentialRow.accountId} owns a non-OAuth credential`) + } + const deleted = await deleteCredentialRecord({ + credential: credentialRow, + reason: 'oauth_disconnect', + }) + if (deleted) deletedCredentials.push(credentialRow) + } + await db.delete(account).where(inArray(account.id, targetAccountIds)) + return { credentials: deletedCredentials } +} diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index ecda5d7d249..8a54565ed33 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -193,7 +193,7 @@ function failure( return { success: false, error, errorCode, ...extra } } -async function createCredentialRecord( +export async function createCredentialRecord( params: PerformCreateCredentialParams, options: { authorizeWorkspace: boolean } ): Promise<PerformCreateCredentialResult> { diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index 31afb8701d0..a403463467d 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -12,7 +12,7 @@ import { getClientCredentialAccountDescriptor, isClientCredentialAccountProviderId, } from '@/lib/credentials/client-credential-accounts/descriptors' -import { type CredentialDeleteReason, deleteCredential } from '@/lib/credentials/deletion' +import { type CredentialDeleteReason, deleteConnectionCredential } from '@/lib/credentials/deletion' import { slackCustomBotDisplayName } from '@/lib/credentials/display-name' import { deleteWorkspaceEnvCredentials, @@ -31,10 +31,12 @@ import { import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialOrchestration') +type CredentialRow = typeof credential.$inferSelect export { deleteConnectionCredential } from '@/lib/credentials/deletion' export { type CreateServiceAccountCredentialParams, + createCredentialRecord, createServiceAccountCredential, isProviderOutageCode, type PerformCreateCredentialParams, @@ -162,38 +164,26 @@ export interface PerformCredentialResult { workspaceId?: string updatedFields?: string[] previousDisplayName?: string + auditMetadata?: Record<string, unknown> } -export async function performUpdateCredential( - params: PerformUpdateCredentialParams +export type UpdateCredentialRecordParams = Omit< + PerformUpdateCredentialParams, + 'userId' | 'actorName' | 'actorEmail' | 'allowedTypes' | 'reason' | 'request' +> & { credential: CredentialRow } + +/** Updates one already-authorized credential without surface authorization or audit. */ +export async function updateCredentialRecord( + params: UpdateCredentialRecordParams ): Promise<PerformCredentialResult> { try { - const access = await getCredentialActorContext(params.credentialId, params.userId) - if (!access.credential) { - return { success: false, error: 'Credential not found', errorCode: 'not_found' } - } - if (!access.hasWorkspaceAccess || !access.isAdmin) { - return { - success: false, - error: 'Credential admin permission required', - errorCode: 'forbidden', - } - } - if (params.allowedTypes && !params.allowedTypes.includes(access.credential.type)) { - return { - success: false, - error: `Only ${params.allowedTypes.join(', ')} credentials can be managed with this tool.`, - errorCode: 'validation', - } - } - const updates: Record<string, unknown> = {} if (params.description !== undefined) { updates.description = params.description ?? null } if ( params.displayName !== undefined && - (access.credential.type === 'oauth' || access.credential.type === 'service_account') + (params.credential.type === 'oauth' || params.credential.type === 'service_account') ) { updates.displayName = params.displayName } @@ -218,8 +208,8 @@ export async function performUpdateCredential( params.username !== undefined let rotatedSlackBotUserId: string | undefined let rotatedAuditMetadata: Record<string, string> | undefined - if (hasRotationSecret && access.credential.type === 'service_account') { - const providerId = access.credential.providerId ?? '' + if (hasRotationSecret && params.credential.type === 'service_account') { + const providerId = params.credential.providerId ?? '' // A reconnect rebuilds the secret blob from the submitted fields only, and // the modal never prefills (secrets are never echoed back). For an actual @@ -249,7 +239,7 @@ export async function performUpdateCredential( // One read + decrypt at most, and only for the providers that can use it. const storedBlob = needsStoredDataCenter || needsStoredAuthMethod || needsStoredUsername || needsStoredIdentity - ? await readStoredSecretBlob(access.credential.id) + ? await readStoredSecretBlob(params.credential.id) : null try { @@ -280,7 +270,7 @@ export async function performUpdateCredential( const previousIdentity = deriveStoredDisplayName(storedBlob) if ( previousIdentity !== undefined && - previousIdentity === access.credential.displayName && + previousIdentity === params.credential.displayName && secret.displayName && secret.displayName !== previousIdentity ) { @@ -314,7 +304,7 @@ export async function performUpdateCredential( } if (Object.keys(updates).length === 0) { - if (access.credential.type === 'oauth' || access.credential.type === 'service_account') { + if (params.credential.type === 'oauth' || params.credential.type === 'service_account') { return { success: false, error: 'No updatable fields provided.', errorCode: 'validation' } } return { @@ -343,31 +333,12 @@ export async function performUpdateCredential( } const updatedFields = auditUpdatedFields(updates) - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_UPDATED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: params.credentialId, - resourceName: access.credential.displayName, - description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`, - // Provider metadata first: the orchestration's own keys stay authoritative - // and can never be shadowed by a builder's audit payload. - metadata: { - ...rotatedAuditMetadata, - credentialType: access.credential.type, - updatedFields, - }, - request: params.request, - }) - return { success: true, - workspaceId: access.credential.workspaceId, + workspaceId: params.credential.workspaceId, updatedFields, - previousDisplayName: access.credential.displayName, + previousDisplayName: params.credential.displayName, + auditMetadata: rotatedAuditMetadata, } } catch (error) { if (error instanceof Error && error.message.includes('unique')) { @@ -382,6 +353,137 @@ export async function performUpdateCredential( } } +/** Preserves the legacy callers while application adapters migrate to the manager above. */ +export async function performUpdateCredential( + params: PerformUpdateCredentialParams +): Promise<PerformCredentialResult> { + const access = await getCredentialActorContext(params.credentialId, params.userId) + if (!access.credential) { + return { success: false, error: 'Credential not found', errorCode: 'not_found' } + } + if (!access.hasWorkspaceAccess || !access.isAdmin) { + return { + success: false, + error: 'Credential admin permission required', + errorCode: 'forbidden', + } + } + if (params.allowedTypes && !params.allowedTypes.includes(access.credential.type)) { + return { + success: false, + error: `Only ${params.allowedTypes.join(', ')} credentials can be managed with this tool.`, + errorCode: 'validation', + } + } + + const result = await updateCredentialRecord({ ...params, credential: access.credential }) + if (!result.success) return result + + recordAudit({ + workspaceId: access.credential.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_UPDATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: params.credentialId, + resourceName: access.credential.displayName, + description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: access.credential.type, + updatedFields: result.updatedFields, + }, + request: params.request, + }) + + return result +} + +export interface DeleteCredentialRecordParams { + credential: CredentialRow + reason: CredentialDeleteReason +} + +/** Deletes one already-authorized credential and its backing secret source. */ +export async function deleteCredentialRecord( + params: DeleteCredentialRecordParams +): Promise<boolean> { + const { credential: credentialRow } = params + + if (credentialRow.type === 'env_personal') { + if (!credentialRow.envKey || !credentialRow.envOwnerUserId) { + throw new Error('Personal environment credential is missing its source identity') + } + const [personalRow] = await db + .select({ variables: environment.variables }) + .from(environment) + .where(eq(environment.userId, credentialRow.envOwnerUserId)) + .limit(1) + const current = { ...((personalRow?.variables as Record<string, string> | null) ?? {}) } + delete current[credentialRow.envKey] + await db + .insert(environment) + .values({ + id: credentialRow.envOwnerUserId, + userId: credentialRow.envOwnerUserId, + variables: current, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [environment.userId], + set: { variables: current, updatedAt: new Date() }, + }) + await syncPersonalEnvCredentialsForUser({ + userId: credentialRow.envOwnerUserId, + envKeys: Object.keys(current), + }) + return true + } + + if (credentialRow.type === 'env_workspace') { + if (!credentialRow.envKey) { + throw new Error('Workspace environment credential is missing its source identity') + } + const [workspaceRow] = await db + .select({ + id: workspaceEnvironment.id, + createdAt: workspaceEnvironment.createdAt, + variables: workspaceEnvironment.variables, + }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, credentialRow.workspaceId)) + .limit(1) + const current = { ...((workspaceRow?.variables as Record<string, string> | null) ?? {}) } + delete current[credentialRow.envKey] + await db + .insert(workspaceEnvironment) + .values({ + id: workspaceRow?.id ?? generateId(), + workspaceId: credentialRow.workspaceId, + variables: current, + createdAt: workspaceRow?.createdAt ?? new Date(), + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [workspaceEnvironment.workspaceId], + set: { variables: current, updatedAt: new Date() }, + }) + await deleteWorkspaceEnvCredentials({ + workspaceId: credentialRow.workspaceId, + removedKeys: [credentialRow.envKey], + }) + return true + } + + return deleteConnectionCredential({ + credentialId: credentialRow.id, + workspaceId: credentialRow.workspaceId, + reason: params.reason, + }) +} + +/** Preserves the legacy callers while application adapters migrate to the manager above. */ export async function performDeleteCredential( params: CredentialActorParams ): Promise<PerformCredentialResult> { @@ -405,149 +507,47 @@ export async function performDeleteCredential( } } - if (access.credential.type === 'env_personal' && access.credential.envKey) { - const ownerUserId = access.credential.envOwnerUserId - if (!ownerUserId) { - return { success: false, error: 'Invalid personal secret owner', errorCode: 'validation' } - } - - const [personalRow] = await db - .select({ variables: environment.variables }) - .from(environment) - .where(eq(environment.userId, ownerUserId)) - .limit(1) - - const current = ((personalRow?.variables as Record<string, string> | null) ?? {}) as Record< - string, - string - > - if (access.credential.envKey in current) delete current[access.credential.envKey] - - await db - .insert(environment) - .values({ id: ownerUserId, userId: ownerUserId, variables: current, updatedAt: new Date() }) - .onConflictDoUpdate({ - target: [environment.userId], - set: { variables: current, updatedAt: new Date() }, - }) - - await syncPersonalEnvCredentialsForUser({ - userId: ownerUserId, - envKeys: Object.keys(current), - }) - - captureServerEvent( - params.userId, - 'credential_deleted', - { - credential_type: 'env_personal', - provider_id: access.credential.envKey, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: params.credentialId, - resourceName: access.credential.displayName, - description: `Deleted personal env credential "${access.credential.envKey}"`, - metadata: { credentialType: 'env_personal', envKey: access.credential.envKey }, - request: params.request, - }) - - return { success: true, workspaceId: access.credential.workspaceId } - } - - if (access.credential.type === 'env_workspace' && access.credential.envKey) { - const [workspaceRow] = await db - .select({ - id: workspaceEnvironment.id, - createdAt: workspaceEnvironment.createdAt, - variables: workspaceEnvironment.variables, - }) - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId)) - .limit(1) - - const current = ((workspaceRow?.variables as Record<string, string> | null) ?? {}) as Record< - string, - string - > - if (access.credential.envKey in current) delete current[access.credential.envKey] - - await db - .insert(workspaceEnvironment) - .values({ - id: workspaceRow?.id || generateId(), - workspaceId: access.credential.workspaceId, - variables: current, - createdAt: workspaceRow?.createdAt || new Date(), - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [workspaceEnvironment.workspaceId], - set: { variables: current, updatedAt: new Date() }, - }) - - await deleteWorkspaceEnvCredentials({ - workspaceId: access.credential.workspaceId, - removedKeys: [access.credential.envKey], - }) - - captureServerEvent( - params.userId, - 'credential_deleted', - { - credential_type: 'env_workspace', - provider_id: access.credential.envKey, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: params.credentialId, - resourceName: access.credential.displayName, - description: `Deleted workspace env credential "${access.credential.envKey}"`, - metadata: { credentialType: 'env_workspace', envKey: access.credential.envKey }, - request: params.request, - }) - - return { success: true, workspaceId: access.credential.workspaceId } - } - - await deleteCredential({ - credentialId: params.credentialId, - actorId: params.userId, - actorName: params.actorName, - actorEmail: params.actorEmail, - reason: params.reason ?? 'user_delete', - request: params.request, - }) + const reason = params.reason ?? 'user_delete' + await deleteCredentialRecord({ credential: access.credential, reason }) captureServerEvent( params.userId, 'credential_deleted', { - credential_type: access.credential.type as 'oauth' | 'service_account', - provider_id: access.credential.providerId ?? params.credentialId, + credential_type: access.credential.type, + provider_id: + access.credential.providerId ?? access.credential.envKey ?? params.credentialId, workspace_id: access.credential.workspaceId, }, { groups: { workspace: access.credential.workspaceId } } ) + const envDescription = + access.credential.type === 'env_personal' + ? `Deleted personal env credential "${access.credential.envKey}"` + : access.credential.type === 'env_workspace' + ? `Deleted workspace env credential "${access.credential.envKey}"` + : `Deleted ${access.credential.type} credential "${access.credential.displayName}" (${reason})` + recordAudit({ + workspaceId: access.credential.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: params.credentialId, + resourceName: access.credential.displayName, + description: envDescription, + metadata: { + reason, + credentialType: access.credential.type, + providerId: access.credential.providerId, + accountId: access.credential.accountId, + envKey: access.credential.envKey, + }, + request: params.request, + }) + return { success: true, workspaceId: access.credential.workspaceId } } catch (error) { logger.error('Failed to delete credential', { error }) diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index bacb9d2cda7..abf1b3b6a0f 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -280,3 +280,9 @@ export async function getWorkspaceCredential(params: { .limit(1) return row ?? null } + +/** Canonical credential lookup used before its workspace scope is known. */ +export async function getCredentialById(credentialId: string): Promise<CredentialRow | null> { + const [row] = await db.select().from(credential).where(eq(credential.id, credentialId)).limit(1) + return row ?? null +} From 96ef2bd1d3561e2680662f9a23b89c0d463ae6d5 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 14:39:24 -0700 Subject: [PATCH 134/159] fix(credentials): keep OAuth draft intent immutable --- apps/docs/openapi-v2-resources.json | 5 ++ .../auth/instagram/authorize/route.test.ts | 49 ++++++++++++------- .../app/api/auth/instagram/authorize/route.ts | 35 ++++++++----- .../app/api/auth/oauth2/authorize/route.ts | 2 +- apps/sim/lib/auth/auth.ts | 6 ++- .../application/save-credential-draft.test.ts | 2 +- .../application/save-credential-draft.ts | 1 - apps/sim/lib/credentials/connect-draft.ts | 14 +----- apps/sim/lib/credentials/draft-processor.ts | 2 + apps/sim/lib/credentials/oauth-draft-state.ts | 1 - 10 files changed, 71 insertions(+), 46 deletions(-) delete mode 100644 apps/sim/lib/credentials/oauth-draft-state.ts diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 84d41b1cf71..d36683d8fb0 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -1821,6 +1821,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace used to evaluate credential-provider availability and integration policy." } } @@ -1967,6 +1968,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace expected to own the credential." } } @@ -4760,6 +4762,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that will own the credential." }, "type": { @@ -4940,6 +4943,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that will own the credential." }, "providerId": { @@ -4964,6 +4968,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace expected to own the credential." }, "credentialId": { diff --git a/apps/sim/app/api/auth/instagram/authorize/route.test.ts b/apps/sim/app/api/auth/instagram/authorize/route.test.ts index 7c5eb98a75a..125a05686ed 100644 --- a/apps/sim/app/api/auth/instagram/authorize/route.test.ts +++ b/apps/sim/app/api/auth/instagram/authorize/route.test.ts @@ -5,8 +5,7 @@ import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - checkWorkspaceAccess: vi.fn(), - createConnectDraft: vi.fn(), + createCredentialConnection: vi.fn(), getSession: vi.fn(), requireConfiguredOAuthClient: vi.fn(), })) @@ -23,18 +22,15 @@ vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test', })) -vi.mock('@/lib/credentials/connect-draft', () => ({ - createConnectDraft: mocks.createConnectDraft, +vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ + createCredentialConnection: { execute: mocks.createCredentialConnection }, })) vi.mock('@/lib/oauth/utils', () => ({ getCanonicalScopesForProvider: () => ['instagram_business_basic'], })) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - checkWorkspaceAccess: mocks.checkWorkspaceAccess, -})) - +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET } from '@/app/api/auth/instagram/authorize/route' describe('Instagram authorize route', () => { @@ -47,8 +43,7 @@ describe('Instagram authorize route', () => { mocks.requireConfiguredOAuthClient.mockReturnValue({ values: { INSTAGRAM_CLIENT_ID: 'instagram-client' }, }) - mocks.checkWorkspaceAccess.mockResolvedValue({ canWrite: true }) - mocks.createConnectDraft.mockResolvedValue({ id: 'draft-created' }) + mocks.createCredentialConnection.mockResolvedValue({ draftId: 'draft-created' }) }) it('preserves an exact credential draft when workspaceId is also supplied', async () => { @@ -65,8 +60,7 @@ describe('Instagram authorize route', () => { expect(response.headers.get('set-cookie')).toContain( 'instagram_credential_draft_id=draft-exact' ) - expect(mocks.checkWorkspaceAccess).not.toHaveBeenCalled() - expect(mocks.createConnectDraft).not.toHaveBeenCalled() + expect(mocks.createCredentialConnection).not.toHaveBeenCalled() }) it('creates a credential draft for a legacy workspace-only launch', async () => { @@ -83,11 +77,32 @@ describe('Instagram authorize route', () => { expect(response.headers.get('set-cookie')).toContain( 'instagram_credential_draft_id=draft-created' ) - expect(mocks.checkWorkspaceAccess).toHaveBeenCalledWith('workspace-1', 'user-1') - expect(mocks.createConnectDraft).toHaveBeenCalledWith({ - userId: 'user-1', - workspaceId: 'workspace-1', - providerId: 'instagram', + expect(mocks.createCredentialConnection).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'workspace-1', providerId: 'instagram' }, + request, + }) + }) + + it('returns a conflict when a different connection intent is already active', async () => { + mocks.createCredentialConnection.mockRejectedValue( + new OrchestrationError( + 'conflict', + 'A different OAuth connection flow is already active for this provider' + ) + ) + const request = createMockRequest( + 'GET', + undefined, + {}, + 'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1' + ) + + const response = await GET(request) + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toEqual({ + error: 'A different OAuth connection flow is already active for this provider', }) }) }) diff --git a/apps/sim/app/api/auth/instagram/authorize/route.ts b/apps/sim/app/api/auth/instagram/authorize/route.ts index 84b0154e706..78bc81deb48 100644 --- a/apps/sim/app/api/auth/instagram/authorize/route.ts +++ b/apps/sim/app/api/auth/instagram/authorize/route.ts @@ -5,12 +5,12 @@ import { authorizeInstagramContract } from '@/lib/api/contracts/oauth-connection import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InstagramAuthorize') @@ -28,6 +28,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') const { values: { INSTAGRAM_CLIENT_ID: clientId }, @@ -39,16 +41,27 @@ export const GET = withRouteHandler(async (request: NextRequest) => { let credentialDraftId = draftId if (workspaceId && !draftId) { - const access = await checkWorkspaceAccess(workspaceId, session.user.id) - if (!access.canWrite) { - return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 }) + try { + const connection = await createCredentialConnection.execute({ + principal: { kind: 'session', userId: session.user.id, sessionId }, + input: { workspaceId, providerId: 'instagram' }, + request, + }) + credentialDraftId = connection.draftId + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code === 'conflict') { + logger.warn('Rejected conflicting Instagram OAuth connection intent', { + userId: session.user.id, + workspaceId, + }) + return NextResponse.json({ error: classified.message }, { status: 409 }) + } + if (classified?.code === 'forbidden' || classified?.code === 'not_found') { + return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 }) + } + throw error } - const draft = await createConnectDraft({ - userId: session.user.id, - workspaceId, - providerId: 'instagram', - }) - credentialDraftId = draft.id } const baseUrl = getBaseUrl() diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 61ce6823600..7526b1b67e8 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -11,7 +11,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' -import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/oauth-draft-state' +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-processor' const logger = createLogger('OAuth2Authorize') diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index dc4ba62ca70..b06bd531e0a 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -96,8 +96,10 @@ import { } from '@/lib/core/config/env-flags' import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' -import { processCredentialDraft } from '@/lib/credentials/draft-processor' -import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/oauth-draft-state' +import { + OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, + processCredentialDraft, +} from '@/lib/credentials/draft-processor' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' diff --git a/apps/sim/lib/credentials/application/save-credential-draft.test.ts b/apps/sim/lib/credentials/application/save-credential-draft.test.ts index abdd2da0d16..c7e463e10c9 100644 --- a/apps/sim/lib/credentials/application/save-credential-draft.test.ts +++ b/apps/sim/lib/credentials/application/save-credential-draft.test.ts @@ -77,7 +77,7 @@ describe('saveCredentialDraft', () => { userId: 'user-1', workspaceId: 'workspace-1', credentialId: 'credential-1', - replaceExistingIntent: true, + displayNameDefinesIntent: true, }) ) }) diff --git a/apps/sim/lib/credentials/application/save-credential-draft.ts b/apps/sim/lib/credentials/application/save-credential-draft.ts index 6fc002c83c7..8b27db621eb 100644 --- a/apps/sim/lib/credentials/application/save-credential-draft.ts +++ b/apps/sim/lib/credentials/application/save-credential-draft.ts @@ -60,7 +60,6 @@ export const saveCredentialDraft = defineAuthorizedWorkspaceUseCase({ description: input.description, credentialId: input.credentialId, displayNameDefinesIntent: true, - replaceExistingIntent: true, }) return { success: true as const } }, diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index b41e2693412..9ecd5efe825 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -32,8 +32,6 @@ export async function createConnectDraft(params: { description?: string /** Whether an explicitly requested name distinguishes this new-connection intent. */ displayNameDefinesIntent?: boolean - /** Replaces a pending intent for the same user, provider, and workspace. */ - replaceExistingIntent?: boolean }): Promise<CreatedConnectDraft> { const { userId, workspaceId, providerId, credentialId } = params @@ -97,16 +95,8 @@ export async function createConnectDraft(params: { pendingCredentialDraft.providerId, pendingCredentialDraft.workspaceId, ], - set: params.replaceExistingIntent - ? { - displayName, - description: params.description?.trim() || null, - credentialId: credentialId ?? null, - expiresAt, - createdAt: now, - } - : { expiresAt, createdAt: now }, - ...(params.replaceExistingIntent ? {} : { setWhere: sameIntent }), + set: { expiresAt, createdAt: now }, + setWhere: sameIntent, }) .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) diff --git a/apps/sim/lib/credentials/draft-processor.ts b/apps/sim/lib/credentials/draft-processor.ts index fc3637845f0..32b46d686a2 100644 --- a/apps/sim/lib/credentials/draft-processor.ts +++ b/apps/sim/lib/credentials/draft-processor.ts @@ -9,6 +9,8 @@ import { const logger = createLogger('CredentialDraftProcessor') +export const OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM = 'credentialDraftId' + interface ProcessCredentialDraftParams { draftId?: string userId: string diff --git a/apps/sim/lib/credentials/oauth-draft-state.ts b/apps/sim/lib/credentials/oauth-draft-state.ts deleted file mode 100644 index 3dd068a1bed..00000000000 --- a/apps/sim/lib/credentials/oauth-draft-state.ts +++ /dev/null @@ -1 +0,0 @@ -export const OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM = 'credentialDraftId' From 90fb920f04b4fcaf6d28cc67ccf1b0c12e423b7a Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 14:45:19 -0700 Subject: [PATCH 135/159] chore(cli): regenerate v2 API for the staging sweep Additive only, both picked up automatically by the derived command surface: - cancelWorkflowRun `reason` gains already_cancelled / already_completed / already_failed (#6702) - getFile gains `scope` (active | archived), so `sim files describe` grows a --scope flag defaulting to active Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNFjchn6dcM7xevh34PKHE --- packages/sim-cli/src/generated/v2-api.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index d5ac524c307..249c53ea1c7 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -468,6 +468,9 @@ type CancelWorkflowRunResponseRef0 = { pausedCancelled: boolean reason?: | 'recorded' + | 'already_cancelled' + | 'already_completed' + | 'already_failed' | 'redis_unavailable' | 'redis_write_failed' | 'paused_event_publish_failed' @@ -2451,6 +2454,7 @@ export type GetFileParams = { export type GetFileQuery = { workspaceId: string + scope?: 'active' | 'archived' } type GetFileResponseRef0 = { @@ -6367,6 +6371,7 @@ export const V2_OPERATIONS = { summary: 'Get File Metadata', query: { workspaceId: { kind: 'string', required: true }, + scope: { kind: 'enum', values: ['active', 'archived'] as const, default: 'active' }, }, }, getFileShare: { From 147b1613cd014ae8d7f30b0c447ea5a934710f78 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 14:52:23 -0700 Subject: [PATCH 136/159] fix(credentials): allow renamed reconnect targets --- .../lib/credentials/application/save-credential-draft.test.ts | 2 +- apps/sim/lib/credentials/application/save-credential-draft.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/credentials/application/save-credential-draft.test.ts b/apps/sim/lib/credentials/application/save-credential-draft.test.ts index c7e463e10c9..a3c6c1f81c4 100644 --- a/apps/sim/lib/credentials/application/save-credential-draft.test.ts +++ b/apps/sim/lib/credentials/application/save-credential-draft.test.ts @@ -77,7 +77,7 @@ describe('saveCredentialDraft', () => { userId: 'user-1', workspaceId: 'workspace-1', credentialId: 'credential-1', - displayNameDefinesIntent: true, + displayNameDefinesIntent: false, }) ) }) diff --git a/apps/sim/lib/credentials/application/save-credential-draft.ts b/apps/sim/lib/credentials/application/save-credential-draft.ts index 8b27db621eb..a058f1190f4 100644 --- a/apps/sim/lib/credentials/application/save-credential-draft.ts +++ b/apps/sim/lib/credentials/application/save-credential-draft.ts @@ -59,7 +59,7 @@ export const saveCredentialDraft = defineAuthorizedWorkspaceUseCase({ displayName: input.displayName, description: input.description, credentialId: input.credentialId, - displayNameDefinesIntent: true, + displayNameDefinesIntent: input.credentialId === undefined, }) return { success: true as const } }, From 7b7429977d10560657ff51fa1a25e28ec7532955 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 15:05:15 -0700 Subject: [PATCH 137/159] fix(credentials): close OAuth draft edge cases --- .../auth/instagram/authorize/route.test.ts | 1 + .../app/api/auth/instagram/authorize/route.ts | 8 ++-- .../app/api/auth/trello/authorize/route.ts | 8 ++-- apps/sim/lib/auth/auth.ts | 21 +++++---- apps/sim/lib/credentials/connect-draft.ts | 4 +- apps/sim/lib/credentials/draft-constants.ts | 2 + apps/sim/lib/credentials/draft-hooks.test.ts | 45 +++++++++++++++++++ apps/sim/lib/credentials/draft-hooks.ts | 15 ++++--- .../lib/credentials/draft-processor.test.ts | 22 ++++++++- apps/sim/lib/credentials/draft-processor.ts | 9 ++++ apps/sim/lib/oauth/shopify-state.test.ts | 8 +++- apps/sim/lib/oauth/shopify-state.ts | 4 +- 12 files changed, 118 insertions(+), 29 deletions(-) create mode 100644 apps/sim/lib/credentials/draft-constants.ts create mode 100644 apps/sim/lib/credentials/draft-hooks.test.ts diff --git a/apps/sim/app/api/auth/instagram/authorize/route.test.ts b/apps/sim/app/api/auth/instagram/authorize/route.test.ts index 125a05686ed..66cb5506c97 100644 --- a/apps/sim/app/api/auth/instagram/authorize/route.test.ts +++ b/apps/sim/app/api/auth/instagram/authorize/route.test.ts @@ -77,6 +77,7 @@ describe('Instagram authorize route', () => { expect(response.headers.get('set-cookie')).toContain( 'instagram_credential_draft_id=draft-created' ) + expect(response.headers.get('set-cookie')).toContain('Max-Age=900') expect(mocks.createCredentialConnection).toHaveBeenCalledWith({ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, input: { workspaceId: 'workspace-1', providerId: 'instagram' }, diff --git a/apps/sim/app/api/auth/instagram/authorize/route.ts b/apps/sim/app/api/auth/instagram/authorize/route.ts index 78bc81deb48..17f21e99e66 100644 --- a/apps/sim/app/api/auth/instagram/authorize/route.ts +++ b/apps/sim/app/api/auth/instagram/authorize/route.ts @@ -10,6 +10,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' +import { CREDENTIAL_DRAFT_TTL_SECONDS } from '@/lib/credentials/draft-constants' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' const logger = createLogger('InstagramAuthorize') @@ -20,7 +21,6 @@ const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state' const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url' const INSTAGRAM_CREDENTIAL_DRAFT_COOKIE = 'instagram_credential_draft_id' const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' -const INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10 export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -81,7 +81,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: INSTAGRAM_STATE_COOKIE_PATH, }) if (credentialDraftId) { @@ -89,7 +89,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: INSTAGRAM_STATE_COOKIE_PATH, }) } else { @@ -104,7 +104,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: INSTAGRAM_STATE_COOKIE_PATH, }) } diff --git a/apps/sim/app/api/auth/trello/authorize/route.ts b/apps/sim/app/api/auth/trello/authorize/route.ts index c24cd32d61d..f98aaf5aab8 100644 --- a/apps/sim/app/api/auth/trello/authorize/route.ts +++ b/apps/sim/app/api/auth/trello/authorize/route.ts @@ -8,6 +8,7 @@ import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { CREDENTIAL_DRAFT_TTL_SECONDS } from '@/lib/credentials/draft-constants' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' const logger = createLogger('TrelloAuthorize') @@ -18,7 +19,6 @@ const TRELLO_STATE_COOKIE = 'trello_oauth_state' const TRELLO_RETURN_URL_COOKIE = 'trello_return_url' const TRELLO_CREDENTIAL_DRAFT_COOKIE = 'trello_credential_draft_id' const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello' -const TRELLO_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10 export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -58,7 +58,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: TRELLO_STATE_COOKIE_PATH, }) if (draftId) { @@ -66,7 +66,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: TRELLO_STATE_COOKIE_PATH, }) } else { @@ -80,7 +80,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: TRELLO_STATE_COOKIE_PATH, }) } else { diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index b06bd531e0a..43fbdf2a980 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -97,7 +97,7 @@ import { import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' import { - OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, + parseCredentialDraftIdFromCallbackUrl, processCredentialDraft, } from '@/lib/credentials/draft-processor' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -529,14 +529,17 @@ export const auth = betterAuth({ let credentialDraftId: string | undefined try { const oauthState = await getOAuthState() - const rawCallbackUrl = oauthState?.callbackURL - if (rawCallbackUrl !== undefined && typeof rawCallbackUrl !== 'string') { - throw new Error('OAuth state callback URL must be a string') - } - credentialDraftId = rawCallbackUrl - ? (new URL(rawCallbackUrl).searchParams.get(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM) ?? - undefined) - : undefined + credentialDraftId = parseCredentialDraftIdFromCallbackUrl(oauthState?.callbackURL) + } catch (error) { + logger.error('[account.create.after] Failed to read OAuth credential draft state', { + userId: account.userId, + providerId: account.providerId, + error, + }) + throw error + } + + try { await processCredentialDraft({ draftId: credentialDraftId, userId: account.userId, diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 9ecd5efe825..bda705b4744 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -5,10 +5,10 @@ import { generateId } from '@sim/utils/id' import { and, eq, gt, isNull, lt } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' +import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' import { credentialProviderMatchesService, getAllOAuthServices } from '@/lib/oauth/utils' const logger = createLogger('OAuthConnectDraft') -const DRAFT_TTL_MS = 15 * 60 * 1000 export type ConnectDraft = typeof pendingCredentialDraft.$inferSelect @@ -63,7 +63,7 @@ export async function createConnectDraft(params: { } const now = new Date() - const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS) + const expiresAt = new Date(now.getTime() + CREDENTIAL_DRAFT_TTL_MS) await db .delete(pendingCredentialDraft) .where( diff --git a/apps/sim/lib/credentials/draft-constants.ts b/apps/sim/lib/credentials/draft-constants.ts new file mode 100644 index 00000000000..ff7525a35f9 --- /dev/null +++ b/apps/sim/lib/credentials/draft-constants.ts @@ -0,0 +1,2 @@ +export const CREDENTIAL_DRAFT_TTL_MS = 15 * 60 * 1000 +export const CREDENTIAL_DRAFT_TTL_SECONDS = CREDENTIAL_DRAFT_TTL_MS / 1000 diff --git a/apps/sim/lib/credentials/draft-hooks.test.ts b/apps/sim/lib/credentials/draft-hooks.test.ts new file mode 100644 index 00000000000..bf2e88a15a9 --- /dev/null +++ b/apps/sim/lib/credentials/draft-hooks.test.ts @@ -0,0 +1,45 @@ +/** + * @vitest-environment node + */ +import { auditMock, auditMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + clearDeadFlag: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/oauth/terminal-errors', () => ({ clearDeadFlag: mocks.clearDeadFlag })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) + +import { handleReconnectCredential } from '@/lib/credentials/draft-hooks' + +describe('handleReconnectCredential', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('audits a reconnect with the credential current name instead of draft presentation', async () => { + queueTableRows(schemaMock.credential, [ + { id: 'credential-1', accountId: null, displayName: 'Renamed Gmail' }, + ]) + queueTableRows(schemaMock.credential, []) + + await handleReconnectCredential({ + draft: { credentialId: 'credential-1' }, + newAccountId: 'account-new', + workspaceId: 'workspace-1', + userId: 'user-1', + now: new Date('2026-08-14T18:00:00.000Z'), + }) + + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'credential-1', + resourceName: 'Renamed Gmail', + description: 'Reconnected OAuth credential "Renamed Gmail" to a new account', + }) + ) + }) +}) diff --git a/apps/sim/lib/credentials/draft-hooks.ts b/apps/sim/lib/credentials/draft-hooks.ts index c1aed407dca..704a22c25fb 100644 --- a/apps/sim/lib/credentials/draft-hooks.ts +++ b/apps/sim/lib/credentials/draft-hooks.ts @@ -105,7 +105,7 @@ export async function handleCreateCredentialFromDraft(params: { * the dead flag. Callers treat that timestamp as proof the reconnect landed. */ export async function handleReconnectCredential(params: { - draft: { credentialId: string | null; workspaceId: string; displayName: string } + draft: { credentialId: string | null } newAccountId: string workspaceId: string userId: string @@ -115,7 +115,11 @@ export async function handleReconnectCredential(params: { if (!draft.credentialId) return const [existingCredential] = await db - .select({ id: schema.credential.id, accountId: schema.credential.accountId }) + .select({ + id: schema.credential.id, + accountId: schema.credential.accountId, + displayName: schema.credential.displayName, + }) .from(schema.credential) .where(eq(schema.credential.id, draft.credentialId)) .limit(1) @@ -125,6 +129,7 @@ export async function handleReconnectCredential(params: { } const oldAccountId = existingCredential.accountId + const displayName = existingCredential.displayName const accountChanged = oldAccountId !== newAccountId if (accountChanged) { @@ -171,10 +176,10 @@ export async function handleReconnectCredential(params: { action: AuditAction.CREDENTIAL_RECONNECTED, resourceType: AuditResourceType.CREDENTIAL, resourceId: draft.credentialId, - resourceName: draft.displayName, + resourceName: displayName, description: accountChanged - ? `Reconnected OAuth credential "${draft.displayName}" to a new account` - : `Reconnected OAuth credential "${draft.displayName}"`, + ? `Reconnected OAuth credential "${displayName}" to a new account` + : `Reconnected OAuth credential "${displayName}"`, metadata: { oldAccountId, newAccountId }, }) diff --git a/apps/sim/lib/credentials/draft-processor.test.ts b/apps/sim/lib/credentials/draft-processor.test.ts index 9eb88bc1953..ad7b55e162b 100644 --- a/apps/sim/lib/credentials/draft-processor.test.ts +++ b/apps/sim/lib/credentials/draft-processor.test.ts @@ -20,7 +20,10 @@ vi.mock('@/lib/credentials/draft-hooks', () => ({ handleReconnectCredential: mockHandleReconnectCredential, })) -import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { + parseCredentialDraftIdFromCallbackUrl, + processCredentialDraft, +} from '@/lib/credentials/draft-processor' function credentialDraft(id: string, workspaceId: string) { return { @@ -103,3 +106,20 @@ describe('processCredentialDraft', () => { expect(dbChainMockFns.delete).not.toHaveBeenCalled() }) }) + +describe('parseCredentialDraftIdFromCallbackUrl', () => { + it('extracts the exact draft id from a valid callback URL', () => { + expect( + parseCredentialDraftIdFromCallbackUrl( + 'https://sim.test/oauth/credential-connected?credentialDraftId=draft-1' + ) + ).toBe('draft-1') + }) + + it('fails closed for malformed or non-string callback state', () => { + expect(() => parseCredentialDraftIdFromCallbackUrl({})).toThrow( + 'OAuth state callback URL must be a string' + ) + expect(() => parseCredentialDraftIdFromCallbackUrl('not a URL')).toThrow() + }) +}) diff --git a/apps/sim/lib/credentials/draft-processor.ts b/apps/sim/lib/credentials/draft-processor.ts index 32b46d686a2..e136e482900 100644 --- a/apps/sim/lib/credentials/draft-processor.ts +++ b/apps/sim/lib/credentials/draft-processor.ts @@ -11,6 +11,15 @@ const logger = createLogger('CredentialDraftProcessor') export const OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM = 'credentialDraftId' +/** Extracts a draft binding from Better Auth state and rejects malformed callback state. */ +export function parseCredentialDraftIdFromCallbackUrl(callbackUrl: unknown): string | undefined { + if (callbackUrl === undefined) return undefined + if (typeof callbackUrl !== 'string') { + throw new Error('OAuth state callback URL must be a string') + } + return new URL(callbackUrl).searchParams.get(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM) ?? undefined +} + interface ProcessCredentialDraftParams { draftId?: string userId: string diff --git a/apps/sim/lib/oauth/shopify-state.test.ts b/apps/sim/lib/oauth/shopify-state.test.ts index 28a3a15595f..61de958536d 100644 --- a/apps/sim/lib/oauth/shopify-state.test.ts +++ b/apps/sim/lib/oauth/shopify-state.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { afterEach, describe, expect, it, vi } from 'vitest' +import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' import { createShopifyOAuthState, parseShopifyOAuthState } from '@/lib/oauth/shopify-state' const CLIENT_SECRET = 'shopify-client-secret' @@ -73,8 +74,11 @@ describe('Shopify OAuth state', () => { clientSecret: CLIENT_SECRET, }) - expect(() => parse(state, { now: new Date(issuedAt.getTime() + 10 * 60 * 1000 + 1) })).toThrow( - 'Shopify OAuth state is expired' + expect(parse(state, { now: new Date(issuedAt.getTime() + CREDENTIAL_DRAFT_TTL_MS) })).toEqual( + {} ) + expect(() => + parse(state, { now: new Date(issuedAt.getTime() + CREDENTIAL_DRAFT_TTL_MS + 1) }) + ).toThrow('Shopify OAuth state is expired') }) }) diff --git a/apps/sim/lib/oauth/shopify-state.ts b/apps/sim/lib/oauth/shopify-state.ts index 23d5f29d761..3c10b1d5778 100644 --- a/apps/sim/lib/oauth/shopify-state.ts +++ b/apps/sim/lib/oauth/shopify-state.ts @@ -1,9 +1,9 @@ import { safeCompare } from '@sim/security/compare' import { hmacSha256Hex } from '@sim/security/hmac' import { generateId } from '@sim/utils/id' +import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' const SHOPIFY_OAUTH_STATE_VERSION = 1 -const SHOPIFY_OAUTH_STATE_TTL_MS = 10 * 60 * 1000 interface ShopifyOAuthStatePayload { v: typeof SHOPIFY_OAUTH_STATE_VERSION @@ -94,7 +94,7 @@ export function parseShopifyOAuthState(params: ParseShopifyOAuthStateParams): { } const now = params.now?.getTime() ?? Date.now() - if (decoded.issuedAt > now || now - decoded.issuedAt > SHOPIFY_OAUTH_STATE_TTL_MS) { + if (decoded.issuedAt > now || now - decoded.issuedAt > CREDENTIAL_DRAFT_TTL_MS) { throw new Error('Shopify OAuth state is expired') } From 52f7d278421000966abf4379701c2da52cf836e2 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 15:09:01 -0700 Subject: [PATCH 138/159] fix(ci): green the repo audits after the staging merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check:import-specifiers — 79 violations, all packages/sim-cli. That package is "moduleResolution": "nodenext" while the rest of the repo is "bundler": Node's ESM resolver takes the specifier literally, so `./ini.js` is required for a file that is `./ini.ts` on disk. Following the audit's advice to drop the extension would break the CLI at runtime. The checker now reads each workspace's tsconfig and, for a NodeNext package, resolves a `.js` specifier back to its source instead of flagging it — still catching genuinely missing files. check:utils — device-flow.ts polled with `new Promise(setTimeout)`. It cannot import sleep() from @sim/utils: that package is private, so a published @simai/cli would resolve it in the monorepo and fail from npm. Added a local helpers.ts and allowlisted it, matching the existing packages/cli entry. check:tool-registry-boundary — knowledge/page.tsx measured +43 against a +42 allowance. tools/registry.ts is not a gateway on any route, so the boundary the audit exists to protect is intact; the growth is this branch's own v2 work. Re-recorded per the script's own instruction. helm — restored staging's networkpolicy_test.yaml. An earlier integration merge had dropped its trailing newline, which was the only helm delta against staging and was tripping the chart-version-bump check. Not addressed: the Security audit step reports high advisories (brace-expansion, undici, Socket.IO, OpenTelemetry, fast-uri). Every one is transitive and present in staging's lockfile too; the step is continue-on-error and did not fail the job. bun audit reads a live advisory feed, so staging's green run predates them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNFjchn6dcM7xevh34PKHE --- helm/sim/tests/networkpolicy_test.yaml | 1 + packages/sim-cli/src/auth/device-flow.ts | 3 +- packages/sim-cli/src/helpers.ts | 12 + scripts/check-import-specifiers.ts | 25 +- ...check-tool-registry-boundary.baseline.json | 324 +++++++++--------- scripts/check-utils-enforcement.ts | 3 + 6 files changed, 202 insertions(+), 166 deletions(-) create mode 100644 packages/sim-cli/src/helpers.ts diff --git a/helm/sim/tests/networkpolicy_test.yaml b/helm/sim/tests/networkpolicy_test.yaml index 9f062ed3a25..817b829b3b1 100644 --- a/helm/sim/tests/networkpolicy_test.yaml +++ b/helm/sim/tests/networkpolicy_test.yaml @@ -212,3 +212,4 @@ tests: except: - "169.254.169.254/32" - "169.254.170.2/32" + diff --git a/packages/sim-cli/src/auth/device-flow.ts b/packages/sim-cli/src/auth/device-flow.ts index 31fb0a5b5d7..97c78c2d060 100644 --- a/packages/sim-cli/src/auth/device-flow.ts +++ b/packages/sim-cli/src/auth/device-flow.ts @@ -1,4 +1,5 @@ import { createHash, randomBytes, randomInt } from 'node:crypto' +import { sleep } from '../helpers.js' import { SimApiError } from '../http/client.js' /** @@ -167,7 +168,7 @@ export async function pollForKey( } } - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) + await sleep(POLL_INTERVAL_MS) } throw new SimApiError('Timed out waiting for browser approval.', 0) diff --git a/packages/sim-cli/src/helpers.ts b/packages/sim-cli/src/helpers.ts new file mode 100644 index 00000000000..66bc434a349 --- /dev/null +++ b/packages/sim-cli/src/helpers.ts @@ -0,0 +1,12 @@ +/** + * Local copies of the shared helpers. + * + * `@sim/utils` is a private workspace package, so a published `@simai/cli` + * cannot depend on it — importing it would resolve in the monorepo and fail for + * anyone installing from npm. + */ + +/** Resolves after `ms` milliseconds. */ +export function sleep(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/scripts/check-import-specifiers.ts b/scripts/check-import-specifiers.ts index b8ad37a3b51..3a9f5ad146b 100644 --- a/scripts/check-import-specifiers.ts +++ b/scripts/check-import-specifiers.ts @@ -128,6 +128,13 @@ interface PathRule { interface Workspace { dir: string paths: PathRule[] + /** + * Node's ESM resolver takes the specifier literally, so a NodeNext package + * must write `./foo.js` for a file that is `./foo.ts` on disk. Dropping the + * extension there — what this audit advises everywhere else — breaks the + * package at runtime, so the `.js` is mapped back to its source instead. + */ + nodeNext: boolean } const workspaces: Workspace[] = [] @@ -144,7 +151,11 @@ for (const group of ['apps', 'packages']) { if (!isFile(tsconfig)) continue try { const raw = readFileSync(tsconfig, 'utf8').replace(/^\s*\/\/.*$/gm, '') - const paths = JSON.parse(raw)?.compilerOptions?.paths ?? {} + const compilerOptions = JSON.parse(raw)?.compilerOptions ?? {} + const paths = compilerOptions.paths ?? {} + const nodeNext = /^node(next|16)$/i.test( + compilerOptions.moduleResolution ?? compilerOptions.module ?? '' + ) const entries: PathRule[] = Object.entries<string[]>(paths).map(([pattern, targets]) => { const [prefix, suffix = ''] = pattern.split('*') return { @@ -156,7 +167,7 @@ for (const group of ['apps', 'packages']) { }) // Longest prefix wins, matching TypeScript's own precedence. entries.sort((a, b) => b.prefix.length - a.prefix.length) - workspaces.push({ dir, paths: entries }) + workspaces.push({ dir, paths: entries, nodeNext }) } catch { /* unparseable tsconfig — skip rather than fail the whole run */ } @@ -230,7 +241,15 @@ function resolveSpecifier(spec: string, importer: string): Outcome | null { if (spec.startsWith('.')) { const base = resolve(dirname(importer), spec) if (isGeneratedPath(base)) return null - return probe(base) ? { ok: true } : { ok: false, reason: 'no file at that path' } + if (probe(base)) return { ok: true } + // A NodeNext package points at the emitted `.js`; check its source instead + // of demanding the extension be dropped. + if (workspaceFor(importer)?.nodeNext && /\.(js|mjs|cjs)$/.test(spec)) { + const source = base.replace(/\.(js|mjs|cjs)$/, '') + if (isGeneratedPath(source)) return null + if (probe(source)) return { ok: true } + } + return { ok: false, reason: 'no file at that path' } } // tsconfig `paths` first — it legitimately overrides a package's exports map. diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index 5e8ebeb9d3b..e0f743686e4 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -10,49 +10,49 @@ "gateways": {} }, "app/workspace/[workspaceId]/chat/[chatId]/page.tsx": { - "modules": 2890, + "modules": 2916, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1328, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 971, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 836, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 833, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1329, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 982, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 847, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 844, "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 301, - "apps/sim/lib/auth/index.ts": 297, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 295 + "apps/sim/blocks/registry.ts": 305, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 295, + "apps/sim/lib/auth/index.ts": 205 } }, "app/workspace/[workspaceId]/files/[fileId]/page.tsx": { - "modules": 1909, + "modules": 1922, "gateways": { "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 328, - "apps/sim/lib/auth/index.ts": 300, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 275, + "apps/sim/blocks/registry.ts": 332, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 276, + "apps/sim/lib/auth/index.ts": 212, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 143, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 129, "apps/sim/lib/api/contracts/index.ts": 107, - "apps/sim/lib/webhooks/providers/index.ts": 99 + "apps/sim/lib/webhooks/providers/index.ts": 100 } }, "app/workspace/[workspaceId]/files/[fileId]/view/page.tsx": { - "modules": 57, + "modules": 59, "gateways": { - "apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx": 56, - "apps/sim/hooks/queries/workspace-files.ts": 53 + "apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx": 58, + "apps/sim/hooks/queries/workspace-files.ts": 55 } }, "app/workspace/[workspaceId]/files/page.tsx": { - "modules": 1909, + "modules": 1922, "gateways": { "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 328, - "apps/sim/lib/auth/index.ts": 300, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 277, + "apps/sim/blocks/registry.ts": 332, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 278, + "apps/sim/lib/auth/index.ts": 212, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 143, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 129, "apps/sim/lib/api/contracts/index.ts": 107, - "apps/sim/lib/webhooks/providers/index.ts": 99 + "apps/sim/lib/webhooks/providers/index.ts": 100 } }, "app/workspace/[workspaceId]/home/layout.tsx": { @@ -60,120 +60,120 @@ "gateways": {} }, "app/workspace/[workspaceId]/home/page.tsx": { - "modules": 2890, + "modules": 2916, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1328, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 971, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 836, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 833, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1329, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 982, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 847, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 844, "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 301, - "apps/sim/lib/auth/index.ts": 297, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 295 + "apps/sim/blocks/registry.ts": 305, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 295, + "apps/sim/lib/auth/index.ts": 205 } }, "app/workspace/[workspaceId]/integrations/[block]/page.tsx": { - "modules": 1268, + "modules": 1279, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1243, - "apps/sim/blocks/registry.ts": 925, + "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1254, + "apps/sim/blocks/registry.ts": 931, "apps/sim/triggers/index.ts": 482, "apps/sim/lib/api/contracts/index.ts": 128, - "apps/sim/stores/workflows/registry/store.ts": 82, + "apps/sim/stores/workflows/registry/store.ts": 84, + "apps/sim/hooks/queries/deployments.ts": 61, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/hooks/queries/deployments.ts": 59, - "apps/sim/lib/workflows/comparison/compare.ts": 56 + "apps/sim/lib/workflows/comparison/compare.ts": 58 } }, "app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx": { - "modules": 1254, + "modules": 1262, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1253, + "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1261, "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 331, + "apps/sim/blocks/registry.ts": 335, "apps/sim/lib/api/contracts/index.ts": 134, - "apps/sim/stores/workflows/registry/store.ts": 62, + "apps/sim/stores/workflows/registry/store.ts": 64, + "apps/sim/hooks/queries/deployments.ts": 61, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/hooks/queries/deployments.ts": 59, - "apps/sim/lib/workflows/comparison/compare.ts": 56 + "apps/sim/lib/workflows/comparison/compare.ts": 58 } }, "app/workspace/[workspaceId]/integrations/page.tsx": { - "modules": 1253, + "modules": 1264, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 979, - "apps/sim/blocks/registry.ts": 926, + "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 985, + "apps/sim/blocks/registry.ts": 932, "apps/sim/triggers/index.ts": 482, "apps/sim/lib/api/contracts/index.ts": 130, - "apps/sim/stores/workflows/registry/store.ts": 83, + "apps/sim/stores/workflows/registry/store.ts": 85, + "apps/sim/hooks/queries/deployments.ts": 61, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/hooks/queries/deployments.ts": 59, - "apps/sim/lib/workflows/comparison/compare.ts": 56 + "apps/sim/lib/workflows/comparison/compare.ts": 58 } }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx": { - "modules": 1460, + "modules": 1481, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1183, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1199, "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 322, - "apps/sim/blocks/registry-maps.ts": 319, + "apps/sim/blocks/registry.ts": 326, + "apps/sim/blocks/registry-maps.ts": 323, "apps/sim/lib/api/contracts/index.ts": 119, + "apps/sim/connectors/registry.ts": 62, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/connectors/registry.ts": 53, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 52 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 55 } }, "app/workspace/[workspaceId]/knowledge/[id]/page.tsx": { - "modules": 1461, + "modules": 1482, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1184, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1200, "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 322, - "apps/sim/blocks/registry-maps.ts": 319, + "apps/sim/blocks/registry.ts": 326, + "apps/sim/blocks/registry-maps.ts": 323, "apps/sim/lib/api/contracts/index.ts": 119, + "apps/sim/connectors/registry.ts": 62, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/connectors/registry.ts": 53, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 52 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 55 } }, "app/workspace/[workspaceId]/knowledge/page.tsx": { - "modules": 2091, + "modules": 2134, "gateways": { "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 317, - "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 249, - "apps/sim/lib/knowledge/application/knowledge-bases.ts": 198, - "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 168, - "apps/sim/lib/auth/index.ts": 158, - "apps/sim/lib/knowledge/orchestration/index.ts": 121, - "apps/sim/lib/knowledge/orchestration/connectors.ts": 116 + "apps/sim/blocks/registry.ts": 321, + "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 272, + "apps/sim/lib/knowledge/application/knowledge-bases.ts": 218, + "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 169, + "apps/sim/lib/auth/index.ts": 159, + "apps/sim/lib/knowledge/orchestration/index.ts": 141, + "apps/sim/lib/knowledge/orchestration/connectors.ts": 136 } }, "app/workspace/[workspaceId]/layout.tsx": { - "modules": 1957, + "modules": 1970, "gateways": { "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 316, - "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 255, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 247, - "apps/sim/lib/auth/index.ts": 180, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 151, + "apps/sim/blocks/registry.ts": 320, + "apps/sim/lib/auth/index.ts": 275, + "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 270, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 262, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 165, "apps/sim/lib/api/contracts/index.ts": 109, - "apps/sim/lib/webhooks/providers/index.ts": 99 + "apps/sim/lib/webhooks/providers/index.ts": 100 } }, "app/workspace/[workspaceId]/logs/page.tsx": { - "modules": 1696, + "modules": 1707, "gateways": { - "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1421, + "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1427, "apps/sim/triggers/registry.ts": 481, "apps/sim/app/workspace/[workspaceId]/logs/components/index.ts": 418, "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts": 366, "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 322, - "apps/sim/blocks/registry.ts": 318, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 286, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 256 + "apps/sim/blocks/registry.ts": 322, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 285, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 255 } }, "app/workspace/[workspaceId]/page.tsx": { @@ -185,16 +185,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/[section]/page.tsx": { - "modules": 1977, + "modules": 1990, "gateways": { "apps/sim/triggers/registry.ts": 446, - "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 409, - "apps/sim/blocks/registry.ts": 320, - "apps/sim/lib/auth/index.ts": 282, + "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 412, + "apps/sim/blocks/registry.ts": 324, + "apps/sim/lib/auth/index.ts": 284, "apps/sim/lib/api/contracts/index.ts": 106, - "apps/sim/lib/webhooks/providers/index.ts": 99, + "apps/sim/lib/webhooks/providers/index.ts": 100, "apps/sim/lib/api/contracts/tools/index.ts": 59, - "apps/sim/lib/workflows/lifecycle.ts": 48 + "apps/sim/lib/workflows/lifecycle.ts": 49 } }, "app/workspace/[workspaceId]/settings/billing/credit-usage/layout.tsx": { @@ -202,15 +202,15 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx": { - "modules": 1573, + "modules": 1583, "gateways": { - "apps/sim/lib/auth/index.ts": 1445, + "apps/sim/lib/auth/index.ts": 1454, "apps/sim/triggers/index.ts": 447, - "apps/sim/blocks/registry.ts": 330, - "apps/sim/blocks/registry-maps.ts": 327, + "apps/sim/blocks/registry.ts": 334, + "apps/sim/blocks/registry-maps.ts": 331, "apps/sim/lib/api/contracts/index.ts": 122, - "apps/sim/lib/webhooks/providers/index.ts": 99, - "apps/sim/stores/workflows/registry/store.ts": 71, + "apps/sim/lib/webhooks/providers/index.ts": 100, + "apps/sim/stores/workflows/registry/store.ts": 72, "apps/sim/lib/api/contracts/tools/index.ts": 60 } }, @@ -223,115 +223,115 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx": { - "modules": 1283, + "modules": 1294, "gateways": { - "apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx": 1282, - "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 993, - "apps/sim/components/permissions/index.ts": 980, - "apps/sim/components/permissions/add-people-modal.tsx": 971, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 969, + "apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx": 1293, + "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 999, + "apps/sim/components/permissions/index.ts": 986, + "apps/sim/components/permissions/add-people-modal.tsx": 977, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 975, "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 333, - "apps/sim/blocks/registry-maps.ts": 330 + "apps/sim/blocks/registry.ts": 337, + "apps/sim/blocks/registry-maps.ts": 334 } }, "app/workspace/[workspaceId]/skills/[skillId]/page.tsx": { - "modules": 1374, + "modules": 1382, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1373, + "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1381, "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 332, - "apps/sim/blocks/registry-maps.ts": 330, + "apps/sim/blocks/registry.ts": 336, + "apps/sim/blocks/registry-maps.ts": 334, "apps/sim/lib/api/contracts/index.ts": 126, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 89, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 86, + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 86, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 83, "apps/sim/lib/api/contracts/tools/index.ts": 60 } }, "app/workspace/[workspaceId]/skills/new/page.tsx": { - "modules": 1372, + "modules": 1380, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1371, + "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1379, "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 332, - "apps/sim/blocks/registry-maps.ts": 330, + "apps/sim/blocks/registry.ts": 336, + "apps/sim/blocks/registry-maps.ts": 334, "apps/sim/lib/api/contracts/index.ts": 126, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 89, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 86, + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 86, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 83, "apps/sim/lib/api/contracts/tools/index.ts": 60 } }, "app/workspace/[workspaceId]/skills/page.tsx": { - "modules": 1236, + "modules": 1247, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 962, - "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 950, - "apps/sim/blocks/registry.ts": 938, - "apps/sim/blocks/registry-maps.ts": 936, + "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 968, + "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 956, + "apps/sim/blocks/registry.ts": 944, + "apps/sim/blocks/registry-maps.ts": 942, "apps/sim/triggers/index.ts": 482, "apps/sim/lib/api/contracts/index.ts": 135, - "apps/sim/stores/workflows/registry/store.ts": 84, - "apps/sim/hooks/queries/deployments.ts": 60 + "apps/sim/stores/workflows/registry/store.ts": 86, + "apps/sim/hooks/queries/deployments.ts": 62 } }, "app/workspace/[workspaceId]/tables/[tableId]/page.tsx": { - "modules": 2186, + "modules": 2200, "gateways": { - "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 574, + "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 578, "apps/sim/triggers/registry.ts": 446, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 328, - "apps/sim/lib/auth/index.ts": 302, - "apps/sim/blocks/registry.ts": 301, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 286, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 259, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 230 + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 332, + "apps/sim/blocks/registry.ts": 305, + "apps/sim/lib/auth/index.ts": 304, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 290, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 262, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 233 } }, "app/workspace/[workspaceId]/tables/page.tsx": { - "modules": 1767, + "modules": 1779, "gateways": { "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 327, - "apps/sim/lib/auth/index.ts": 296, - "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 121, + "apps/sim/blocks/registry.ts": 331, + "apps/sim/lib/auth/index.ts": 295, + "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 122, "apps/sim/lib/api/contracts/index.ts": 111, - "apps/sim/lib/webhooks/providers/index.ts": 99, + "apps/sim/lib/webhooks/providers/index.ts": 100, "apps/sim/lib/api/contracts/tools/index.ts": 60, "apps/sim/app/workspace/[workspaceId]/components/index.ts": 50 } }, "app/workspace/[workspaceId]/upgrade/page.tsx": { - "modules": 263, + "modules": 267, "gateways": { - "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 256, - "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 210, + "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 260, + "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 213, "apps/sim/lib/billing/client/upgrade.ts": 205, "apps/sim/hooks/queries/organization.ts": 201, - "apps/sim/hooks/queries/workspace.ts": 192, - "apps/sim/lib/api/contracts/index.ts": 190, + "apps/sim/hooks/queries/workspace.ts": 193, + "apps/sim/lib/api/contracts/index.ts": 191, "apps/sim/lib/api/contracts/tools/index.ts": 61, "apps/sim/lib/api/contracts/v1/index.ts": 38 } }, "app/workspace/[workspaceId]/w/[workflowId]/layout.tsx": { - "modules": 2145, + "modules": 2157, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2144, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 540, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2156, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 541, "apps/sim/triggers/registry.ts": 481, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 458, - "apps/sim/blocks/registry.ts": 318, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 459, + "apps/sim/blocks/registry.ts": 322, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 285, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 141, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 134 } }, "app/workspace/[workspaceId]/w/[workflowId]/page.tsx": { - "modules": 2172, + "modules": 2184, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2171, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2183, "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 318, + "apps/sim/blocks/registry.ts": 322, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 305, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 267, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 224, @@ -340,42 +340,42 @@ } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 2145, + "modules": 2157, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 904, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 907, "apps/sim/triggers/registry.ts": 481, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 458, - "apps/sim/blocks/registry.ts": 318, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 459, + "apps/sim/blocks/registry.ts": 322, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 285, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 141, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 134, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 133 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 129 } }, "app/workspace/layout.tsx": { - "modules": 1194, + "modules": 1202, "gateways": { - "apps/sim/app/workspace/providers/socket-provider.tsx": 1184, + "apps/sim/app/workspace/providers/socket-provider.tsx": 1192, "apps/sim/triggers/registry.ts": 481, - "apps/sim/blocks/registry.ts": 333, - "apps/sim/blocks/registry-maps.ts": 330, + "apps/sim/blocks/registry.ts": 337, + "apps/sim/blocks/registry-maps.ts": 334, "apps/sim/lib/api/contracts/index.ts": 139, - "apps/sim/stores/workflows/registry/store.ts": 63, - "apps/sim/hooks/queries/deployments.ts": 60, + "apps/sim/stores/workflows/registry/store.ts": 65, + "apps/sim/hooks/queries/deployments.ts": 62, "apps/sim/lib/api/contracts/tools/index.ts": 60 } }, "app/workspace/page.tsx": { - "modules": 1188, + "modules": 1199, "gateways": { - "apps/sim/lib/auth/stale-session-recovery.ts": 959, + "apps/sim/lib/auth/stale-session-recovery.ts": 968, "apps/sim/triggers/index.ts": 482, - "apps/sim/blocks/registry.ts": 333, - "apps/sim/blocks/registry-maps.ts": 330, - "apps/sim/lib/api/contracts/index.ts": 138, - "apps/sim/stores/workflows/registry/store.ts": 62, + "apps/sim/blocks/registry.ts": 337, + "apps/sim/blocks/registry-maps.ts": 334, + "apps/sim/lib/api/contracts/index.ts": 137, + "apps/sim/stores/workflows/registry/store.ts": 64, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/hooks/queries/deployments.ts": 56 + "apps/sim/hooks/queries/deployments.ts": 58 } } } diff --git a/scripts/check-utils-enforcement.ts b/scripts/check-utils-enforcement.ts index 22565e4c781..857bab94901 100644 --- a/scripts/check-utils-enforcement.ts +++ b/scripts/check-utils-enforcement.ts @@ -29,6 +29,9 @@ const ALLOWLISTED_FILES = new Set([ 'packages/utils/src/id.test.ts', 'packages/utils/src/object.test.ts', 'packages/utils/src/retry.test.ts', + // Published standalone CLIs: `@sim/utils` is private, so they carry local + // copies rather than a dependency that only resolves inside the monorepo. + 'packages/sim-cli/src/helpers.ts', 'packages/cli/src/index.ts', 'packages/ts-sdk/src/index.ts', // CJS bundle — cannot use ES module imports From c2ccac2b7ef088057dd72cba7ae956d6ea2a2dfd Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 15:12:30 -0700 Subject: [PATCH 139/159] fix(cli): read share fields from the v2 share object The file-share columns pointed at a `sharing` wrapper that v2 does not return. The share travels under `share` on file metadata (null when unshared) and as the unwrapped body on the share endpoints, and its flag is `isActive`, not `enabled`. Every one of those columns was therefore rendering an em-dash on `files describe`, `files share get`, and `files share set`. A missing field path renders blank instead of failing, so nothing caught this. Added a rendering test over both surfaces; it fails if a path stops resolving. `hasPassword` is surfaced on the two share commands while they are being fixed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNFjchn6dcM7xevh34PKHE --- packages/sim-cli/src/contract/commands.ts | 30 ++++++++++------- packages/sim-cli/src/runtime/build.test.ts | 38 ++++++++++++++++++++++ 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 106e6bf7d1c..62404efdadf 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -469,10 +469,12 @@ export const CLI_CONTRACT: CliContract = { { header: 'uploaded by', path: 'uploadedByEmail' }, { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, { header: 'updated', path: 'updatedAt', format: 'timestamp' }, - { header: 'shared', path: 'sharing.enabled', format: 'bool' }, - { header: 'share URL', path: 'sharing.url' }, - { header: 'share auth', path: 'sharing.authType' }, - { header: 'allowed emails', path: 'sharing.allowedEmails', format: 'count' }, + // v2 returns the share under `share` (null when unshared), and its flag + // is `isActive`. + { header: 'shared', path: 'share.isActive', format: 'bool' }, + { header: 'share URL', path: 'share.url' }, + { header: 'share auth', path: 'share.authType' }, + { header: 'allowed emails', path: 'share.allowedEmails', format: 'count' }, ], }, moveFileItems: { @@ -500,14 +502,17 @@ export const CLI_CONTRACT: CliContract = { encoding: { choices: ['utf-8', 'base64'], describe: 'Content encoding' }, }, }, + // Both share commands return the share itself as `data`, which the runtime + // unwraps, so these fields sit at the top level rather than under a wrapper. getFileShare: { command: 'files share get', describe: 'Show a file’s share settings', fields: [ - { header: 'shared', path: 'sharing.enabled', format: 'bool' }, - { header: 'URL', path: 'sharing.url' }, - { header: 'auth', path: 'sharing.authType' }, - { header: 'allowed emails', path: 'sharing.allowedEmails', format: 'count' }, + { header: 'shared', path: 'isActive', format: 'bool' }, + { header: 'URL', path: 'url' }, + { header: 'auth', path: 'authType' }, + { header: 'password set', path: 'hasPassword', format: 'bool' }, + { header: 'allowed emails', path: 'allowedEmails', format: 'count' }, ], }, // v2 folds share and unshare into one PATCH; `--is-active false` disables it, @@ -519,10 +524,11 @@ export const CLI_CONTRACT: CliContract = { allowedEmails: { list: true }, }, fields: [ - { header: 'shared', path: 'sharing.enabled', format: 'bool' }, - { header: 'URL', path: 'sharing.url' }, - { header: 'auth', path: 'sharing.authType' }, - { header: 'allowed emails', path: 'sharing.allowedEmails', format: 'count' }, + { header: 'shared', path: 'isActive', format: 'bool' }, + { header: 'URL', path: 'url' }, + { header: 'auth', path: 'authType' }, + { header: 'password set', path: 'hasPassword', format: 'bool' }, + { header: 'allowed emails', path: 'allowedEmails', format: 'count' }, ], }, diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 981df197f6b..92378570d62 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -945,6 +945,44 @@ describe('contract-selected list rendering', () => { expect(secrets[0]).toContain('STRIPE_API_KEY') expect(secrets[0]).toContain('workspace') }) + + /** + * A field path that misses renders as an em-dash rather than failing, so a + * renamed response key is invisible until someone reads the output. v2 nests + * the share under `share` and calls the flag `isActive`; the CLI briefly read + * a `sharing` wrapper and silently showed nothing for all four columns. + */ + it('reads share fields from the v2 share object, not a sharing wrapper', async () => { + const described = ( + await lines(['files', 'describe', 'file_1'], { + id: 'file_1', + name: 'notes.txt', + uploadedByEmail: 'ada@example.com', + share: { + isActive: true, + url: 'https://sim.ai/s/tok_1', + authType: 'email', + hasPassword: false, + allowedEmails: ['ada@example.com'], + }, + }) + ).join('\n') + expect(described).toContain('https://sim.ai/s/tok_1') + expect(described).toContain('email') + expect(described).toContain('ada@example.com') + + const share = ( + await lines(['files', 'share', 'get', 'file_1'], { + isActive: true, + url: 'https://sim.ai/s/tok_2', + authType: 'sso', + hasPassword: true, + allowedEmails: ['ada@example.com', 'grace@example.com'], + }) + ).join('\n') + expect(share).toContain('https://sim.ai/s/tok_2') + expect(share).toContain('sso') + }) }) describe('pagination slot', () => { From ae46594e5b899794988a235a019859a63427ab8f Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 15:23:58 -0700 Subject: [PATCH 140/159] fix(credentials): fail closed without breaking auth --- .../api/auth/shopify/authorize/route.test.ts | 60 +++++++++++++++++++ .../app/api/auth/shopify/authorize/route.ts | 3 +- apps/sim/lib/auth/auth.ts | 44 +++++++------- .../lib/credentials/draft-processor.test.ts | 27 +++++++++ apps/sim/lib/credentials/draft-processor.ts | 23 +++++++ 5 files changed, 134 insertions(+), 23 deletions(-) create mode 100644 apps/sim/app/api/auth/shopify/authorize/route.test.ts diff --git a/apps/sim/app/api/auth/shopify/authorize/route.test.ts b/apps/sim/app/api/auth/shopify/authorize/route.test.ts new file mode 100644 index 00000000000..8829fb01422 --- /dev/null +++ b/apps/sim/app/api/auth/shopify/authorize/route.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + requireConfiguredOAuthClient: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mocks.getSession, +})) + +vi.mock('@/lib/core/config/env-capabilities.server', () => ({ + requireConfiguredOAuthClient: mocks.requireConfiguredOAuthClient, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://sim.test', +})) + +vi.mock('@/lib/oauth/shopify-state', () => ({ + createShopifyOAuthState: () => 'signed-state', +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getScopesForService: () => ['read_products'], +})) + +import { GET } from '@/app/api/auth/shopify/authorize/route' + +describe('Shopify authorize route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' } }) + mocks.requireConfiguredOAuthClient.mockReturnValue({ + values: { + SHOPIFY_CLIENT_ID: 'shopify-client', + SHOPIFY_CLIENT_SECRET: 'shopify-secret', + }, + }) + }) + + it('keeps the post-connect return URL for the full credential draft lifetime', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'https://sim.test/api/auth/shopify/authorize?shop=test-store.myshopify.com&returnUrl=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected&draftId=draft-1' + ) + + const response = await GET(request) + + expect(response.status).toBe(307) + expect(response.headers.get('set-cookie')).toContain('shopify_return_url=') + expect(response.headers.get('set-cookie')).toContain('Max-Age=900') + }) +}) diff --git a/apps/sim/app/api/auth/shopify/authorize/route.ts b/apps/sim/app/api/auth/shopify/authorize/route.ts index 9ef6909e581..e49efe20c51 100644 --- a/apps/sim/app/api/auth/shopify/authorize/route.ts +++ b/apps/sim/app/api/auth/shopify/authorize/route.ts @@ -9,6 +9,7 @@ import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { CREDENTIAL_DRAFT_TTL_SECONDS } from '@/lib/credentials/draft-constants' import { createShopifyOAuthState } from '@/lib/oauth/shopify-state' import { getScopesForService } from '@/lib/oauth/utils' @@ -209,7 +210,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: 60 * 10, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: '/', }) } else { diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 43fbdf2a980..7906d93707d 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -97,7 +97,7 @@ import { import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' import { - parseCredentialDraftIdFromCallbackUrl, + loadOAuthCredentialDraftBinding, processCredentialDraft, } from '@/lib/credentials/draft-processor' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -526,33 +526,33 @@ export const auth = betterAuth({ } } - let credentialDraftId: string | undefined - try { - const oauthState = await getOAuthState() - credentialDraftId = parseCredentialDraftIdFromCallbackUrl(oauthState?.callbackURL) - } catch (error) { + const credentialDraftBinding = await loadOAuthCredentialDraftBinding(() => + getOAuthState() + ) + if (credentialDraftBinding.status === 'unavailable') { logger.error('[account.create.after] Failed to read OAuth credential draft state', { userId: account.userId, providerId: account.providerId, - error, + error: credentialDraftBinding.error, }) - throw error } - try { - await processCredentialDraft({ - draftId: credentialDraftId, - userId: account.userId, - providerId: account.providerId, - accountId: account.id, - }) - } catch (error) { - logger.error('[account.create.after] Failed to process credential draft', { - userId: account.userId, - providerId: account.providerId, - error, - }) - if (credentialDraftId) throw error + if (credentialDraftBinding.status === 'available') { + try { + await processCredentialDraft({ + draftId: credentialDraftBinding.draftId, + userId: account.userId, + providerId: account.providerId, + accountId: account.id, + }) + } catch (error) { + logger.error('[account.create.after] Failed to process credential draft', { + userId: account.userId, + providerId: account.providerId, + error, + }) + if (credentialDraftBinding.draftId) throw error + } } try { diff --git a/apps/sim/lib/credentials/draft-processor.test.ts b/apps/sim/lib/credentials/draft-processor.test.ts index ad7b55e162b..2cf5c13014d 100644 --- a/apps/sim/lib/credentials/draft-processor.test.ts +++ b/apps/sim/lib/credentials/draft-processor.test.ts @@ -21,6 +21,7 @@ vi.mock('@/lib/credentials/draft-hooks', () => ({ })) import { + loadOAuthCredentialDraftBinding, parseCredentialDraftIdFromCallbackUrl, processCredentialDraft, } from '@/lib/credentials/draft-processor' @@ -123,3 +124,29 @@ describe('parseCredentialDraftIdFromCallbackUrl', () => { expect(() => parseCredentialDraftIdFromCallbackUrl('not a URL')).toThrow() }) }) + +describe('loadOAuthCredentialDraftBinding', () => { + it('returns the exact draft id when OAuth state is readable', async () => { + await expect( + loadOAuthCredentialDraftBinding(async () => ({ + callbackURL: 'https://sim.test/oauth/credential-connected?credentialDraftId=draft-exact', + })) + ).resolves.toEqual({ status: 'available', draftId: 'draft-exact' }) + }) + + it('marks unreadable OAuth state unavailable instead of permitting legacy draft fallback', async () => { + const stateError = new Error('OAuth state is unavailable') + + await expect( + loadOAuthCredentialDraftBinding(async () => { + throw stateError + }) + ).resolves.toEqual({ status: 'unavailable', error: stateError }) + }) + + it('marks malformed callback state unavailable without throwing from the account hook', async () => { + const binding = await loadOAuthCredentialDraftBinding(async () => ({ callbackURL: null })) + + expect(binding.status).toBe('unavailable') + }) +}) diff --git a/apps/sim/lib/credentials/draft-processor.ts b/apps/sim/lib/credentials/draft-processor.ts index e136e482900..0b60ccac106 100644 --- a/apps/sim/lib/credentials/draft-processor.ts +++ b/apps/sim/lib/credentials/draft-processor.ts @@ -11,6 +11,14 @@ const logger = createLogger('CredentialDraftProcessor') export const OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM = 'credentialDraftId' +interface OAuthStateWithCallbackUrl { + callbackURL?: unknown +} + +type OAuthCredentialDraftBinding = + | { status: 'available'; draftId?: string } + | { status: 'unavailable'; error: unknown } + /** Extracts a draft binding from Better Auth state and rejects malformed callback state. */ export function parseCredentialDraftIdFromCallbackUrl(callbackUrl: unknown): string | undefined { if (callbackUrl === undefined) return undefined @@ -20,6 +28,21 @@ export function parseCredentialDraftIdFromCallbackUrl(callbackUrl: unknown): str return new URL(callbackUrl).searchParams.get(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM) ?? undefined } +/** Reads an exact draft binding without falling back when OAuth state is unavailable. */ +export async function loadOAuthCredentialDraftBinding( + loadOAuthState: () => Promise<OAuthStateWithCallbackUrl | null | undefined> +): Promise<OAuthCredentialDraftBinding> { + try { + const oauthState = await loadOAuthState() + return { + status: 'available', + draftId: parseCredentialDraftIdFromCallbackUrl(oauthState?.callbackURL), + } + } catch (error) { + return { status: 'unavailable', error } + } +} + interface ProcessCredentialDraftParams { draftId?: string userId: string From 7d17fc922a0f837bf5e8e09a7b1a5109ab8876ba Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 16:30:41 -0700 Subject: [PATCH 141/159] improvement(cli): bundle and publish as @sim/cli --- .github/workflows/publish-sim-cli.yml | 23 +++++++++-- bun.lock | 38 +++++++++---------- packages/sim-cli/README.md | 6 +-- packages/sim-cli/THIRD_PARTY_LICENSES | 30 +++++++++++++++ packages/sim-cli/package.json | 15 ++++---- packages/sim-cli/src/auth/device-flow.test.ts | 2 +- packages/sim-cli/src/auth/device-flow.ts | 4 +- packages/sim-cli/src/commands/auth.test.ts | 2 +- packages/sim-cli/src/commands/auth.ts | 10 ++--- packages/sim-cli/src/commands/configure.ts | 11 ++---- .../src/commands/protocol/files-get.test.ts | 8 ++-- .../src/commands/protocol/files-get.ts | 8 ++-- .../commands/protocol/files-upload.test.ts | 6 +-- .../src/commands/protocol/files-upload.ts | 15 +++----- .../sim-cli/src/commands/protocol/index.ts | 10 ++--- .../knowledge-document-upload.test.ts | 6 +-- .../protocol/knowledge-document-upload.ts | 12 +++--- .../protocol/resource-directory.test.ts | 6 +-- .../commands/protocol/resource-directory.ts | 12 +++--- .../sim-cli/src/commands/protocol/result.ts | 4 +- .../commands/protocol/tables-import.test.ts | 6 +-- .../src/commands/protocol/tables-import.ts | 16 ++++---- packages/sim-cli/src/config/index.ts | 4 +- packages/sim-cli/src/config/ini.test.ts | 2 +- packages/sim-cli/src/config/profile.test.ts | 4 +- packages/sim-cli/src/config/profile.ts | 4 +- packages/sim-cli/src/context.ts | 4 +- packages/sim-cli/src/contract/commands.ts | 2 +- packages/sim-cli/src/contract/types.ts | 2 +- packages/sim-cli/src/helpers.ts | 2 +- packages/sim-cli/src/http/client.test.ts | 6 +-- packages/sim-cli/src/http/client.ts | 2 +- packages/sim-cli/src/index.ts | 14 +++---- packages/sim-cli/src/output/render.test.ts | 2 +- packages/sim-cli/src/output/render.ts | 4 +- packages/sim-cli/src/output/trace.ts | 4 +- packages/sim-cli/src/runtime/build.test.ts | 4 +- packages/sim-cli/src/runtime/build.ts | 16 ++++---- packages/sim-cli/src/runtime/derive.ts | 2 +- packages/sim-cli/src/runtime/execute.ts | 18 ++++----- packages/sim-cli/src/runtime/options.ts | 8 ++-- packages/sim-cli/src/runtime/request.test.ts | 6 +-- packages/sim-cli/src/runtime/request.ts | 10 ++--- packages/sim-cli/src/runtime/result.ts | 10 ++--- packages/sim-cli/src/runtime/types.ts | 4 +- packages/sim-cli/src/transfer/local-file.ts | 2 +- .../sim-cli/src/transfer/upload-session.ts | 2 +- packages/sim-cli/tsconfig.build.json | 9 ----- packages/sim-cli/tsconfig.json | 9 +---- scripts/check-import-specifiers.ts | 25 ++---------- 50 files changed, 216 insertions(+), 215 deletions(-) create mode 100644 packages/sim-cli/THIRD_PARTY_LICENSES delete mode 100644 packages/sim-cli/tsconfig.build.json diff --git a/.github/workflows/publish-sim-cli.yml b/.github/workflows/publish-sim-cli.yml index 48f7b0f557f..bf658a9c09f 100644 --- a/.github/workflows/publish-sim-cli.yml +++ b/.github/workflows/publish-sim-cli.yml @@ -26,6 +26,11 @@ jobs: with: bun-version: 1.3.14 + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: '20' + - name: Cache Bun dependencies uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: @@ -40,8 +45,10 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile --ignore-scripts - - name: Verify initial package exists - run: bun pm view @simai/cli@preview name + - name: Verify npm authentication + env: + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + run: bun pm whoami - name: Run tests working-directory: packages/sim-cli @@ -98,6 +105,16 @@ jobs: echo "tag=$TAG" } >> "$GITHUB_OUTPUT" + - name: Smoke-test packed Node bundle + working-directory: packages/sim-cli + run: | + set -euo pipefail + SMOKE_DIR="$(mktemp -d "$RUNNER_TEMP/sim-cli-smoke.XXXXXX")" + PACKAGE_PATH="$SMOKE_DIR/sim-cli.tgz" + bun pm pack --ignore-scripts --filename "$PACKAGE_PATH" --quiet + tar -xzf "$PACKAGE_PATH" -C "$SMOKE_DIR" + "$SMOKE_DIR/package/dist/index.js" --version + - name: Publish to npm working-directory: packages/sim-cli env: @@ -109,4 +126,4 @@ jobs: env: VERSION: ${{ steps.release.outputs.version }} NPM_TAG: ${{ steps.release.outputs.tag }} - run: echo "Published @simai/cli@$VERSION with the '$NPM_TAG' tag." + run: echo "Published @sim/cli@$VERSION with the '$NPM_TAG' tag." diff --git a/bun.lock b/bun.lock index d8a5e1d1ebf..83a087efddb 100644 --- a/bun.lock +++ b/bun.lock @@ -587,21 +587,19 @@ }, }, "packages/sim-cli": { - "name": "@simai/cli", + "name": "@sim/cli", "version": "0.1.0", "bin": { "sim": "dist/index.js", }, - "dependencies": { - "chalk": "5.6.2", - "commander": "^11.1.0", - "js-yaml": "4.3.0", - }, "devDependencies": { "@sim/tsconfig": "workspace:*", "@types/js-yaml": "4.0.9", "@types/node": "24.2.1", "@xterm/headless": "6.0.0", + "chalk": "5.6.2", + "commander": "^11.1.0", + "js-yaml": "4.3.0", "typescript": "^7.0.2", "vitest": "^3.2.4", }, @@ -1806,6 +1804,8 @@ "@sim/browser-protocol": ["@sim/browser-protocol@workspace:packages/browser-protocol"], + "@sim/cli": ["@sim/cli@workspace:packages/sim-cli"], + "@sim/db": ["@sim/db@workspace:packages/db"], "@sim/desktop": ["@sim/desktop@workspace:apps/desktop"], @@ -1842,8 +1842,6 @@ "@sim/workflow-types": ["@sim/workflow-types@workspace:packages/workflow-types"], - "@simai/cli": ["@simai/cli@workspace:packages/sim-cli"], - "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], "@smithy/config-resolver": ["@smithy/config-resolver@4.6.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-NJF/Xc69G68BzZMKMEpWkCY9HjZJzTWztTW4VxBC2SodX+H60xw+NGckNhkgg4uMRHrpDkhWeBeigM3YJmv1FQ=="], @@ -4900,7 +4898,7 @@ "@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], - "@simai/cli/vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="], + "@sim/cli/vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="], "@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="], @@ -5476,27 +5474,27 @@ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], - "@simai/cli/vitest/@vitest/expect": ["@vitest/expect@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w=="], + "@sim/cli/vitest/@vitest/expect": ["@vitest/expect@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w=="], - "@simai/cli/vitest/@vitest/mocker": ["@vitest/mocker@3.2.7", "", { "dependencies": { "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA=="], + "@sim/cli/vitest/@vitest/mocker": ["@vitest/mocker@3.2.7", "", { "dependencies": { "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA=="], - "@simai/cli/vitest/@vitest/pretty-format": ["@vitest/pretty-format@3.2.7", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA=="], + "@sim/cli/vitest/@vitest/pretty-format": ["@vitest/pretty-format@3.2.7", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA=="], - "@simai/cli/vitest/@vitest/runner": ["@vitest/runner@3.2.7", "", { "dependencies": { "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA=="], + "@sim/cli/vitest/@vitest/runner": ["@vitest/runner@3.2.7", "", { "dependencies": { "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA=="], - "@simai/cli/vitest/@vitest/snapshot": ["@vitest/snapshot@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g=="], + "@sim/cli/vitest/@vitest/snapshot": ["@vitest/snapshot@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g=="], - "@simai/cli/vitest/@vitest/spy": ["@vitest/spy@3.2.7", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ=="], + "@sim/cli/vitest/@vitest/spy": ["@vitest/spy@3.2.7", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ=="], - "@simai/cli/vitest/@vitest/utils": ["@vitest/utils@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw=="], + "@sim/cli/vitest/@vitest/utils": ["@vitest/utils@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw=="], - "@simai/cli/vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "@sim/cli/vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], - "@simai/cli/vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + "@sim/cli/vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - "@simai/cli/vitest/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + "@sim/cli/vitest/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], - "@simai/cli/vitest/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + "@sim/cli/vitest/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], "@tailwindcss/postcss/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 5a17d6b5aae..eec482d12cb 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -3,7 +3,7 @@ Talk to the [Sim](https://sim.ai) API from your terminal. ```bash -npm install --global @simai/cli +npm install --global @sim/cli sim login sim workflows list ``` @@ -11,8 +11,8 @@ sim workflows list Prerelease channels track the corresponding Sim environments: ```bash -npm install --global @simai/cli@preview # staging -npm install --global @simai/cli@dev # dev +npm install --global @sim/cli@preview # staging +npm install --global @sim/cli@dev # dev ``` ## Profiles diff --git a/packages/sim-cli/THIRD_PARTY_LICENSES b/packages/sim-cli/THIRD_PARTY_LICENSES new file mode 100644 index 00000000000..f8ff0105dc1 --- /dev/null +++ b/packages/sim-cli/THIRD_PARTY_LICENSES @@ -0,0 +1,30 @@ +The Sim CLI bundle includes the following third-party software. + +chalk +Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com) + +commander +Copyright (c) 2011 TJ Holowaychuk <tj@vision-media.ca> + +js-yaml +Copyright (C) 2011-2015 by Vitaly Puzrin + +Each dependency above is licensed under the MIT License: + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json index 432f0befb83..545ab48bcee 100644 --- a/packages/sim-cli/package.json +++ b/packages/sim-cli/package.json @@ -1,5 +1,5 @@ { - "name": "@simai/cli", + "name": "@sim/cli", "version": "0.1.0", "description": "Sim CLI - talk to the Sim API from your terminal", "type": "module", @@ -8,7 +8,7 @@ }, "scripts": { "prebuild": "bun run clean", - "build": "tsc --project tsconfig.build.json", + "build": "bun build src/index.ts --target=node --format=esm --packages=bundle --reject-unresolved --outfile=dist/index.js", "clean": "bun -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"", "type-check": "tsc --noEmit", "lint": "biome check --write --unsafe .", @@ -19,7 +19,8 @@ "prepublishOnly": "bun run build" }, "files": [ - "dist" + "dist", + "THIRD_PARTY_LICENSES" ], "keywords": [ "sim", @@ -45,16 +46,14 @@ "engines": { "node": ">=20" }, - "dependencies": { - "chalk": "5.6.2", - "commander": "^11.1.0", - "js-yaml": "4.3.0" - }, "devDependencies": { "@sim/tsconfig": "workspace:*", "@types/js-yaml": "4.0.9", "@types/node": "24.2.1", "@xterm/headless": "6.0.0", + "chalk": "5.6.2", + "commander": "^11.1.0", + "js-yaml": "4.3.0", "typescript": "^7.0.2", "vitest": "^3.2.4" } diff --git a/packages/sim-cli/src/auth/device-flow.test.ts b/packages/sim-cli/src/auth/device-flow.test.ts index 80df1109946..4b2747c53ce 100644 --- a/packages/sim-cli/src/auth/device-flow.test.ts +++ b/packages/sim-cli/src/auth/device-flow.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { buildApprovalUrl, createAuthRequest, pollForKey } from './device-flow.js' +import { buildApprovalUrl, createAuthRequest, pollForKey } from './device-flow' const ENDPOINT = 'https://sim.test' diff --git a/packages/sim-cli/src/auth/device-flow.ts b/packages/sim-cli/src/auth/device-flow.ts index 97c78c2d060..198f3610c30 100644 --- a/packages/sim-cli/src/auth/device-flow.ts +++ b/packages/sim-cli/src/auth/device-flow.ts @@ -1,6 +1,6 @@ import { createHash, randomBytes, randomInt } from 'node:crypto' -import { sleep } from '../helpers.js' -import { SimApiError } from '../http/client.js' +import { sleep } from '../helpers' +import { SimApiError } from '../http/client' /** * The terminal half of the CLI key handoff. diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts index 59f41e578d4..a3022c75459 100644 --- a/packages/sim-cli/src/commands/auth.test.ts +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { profilesCommand } from './auth.js' +import { profilesCommand } from './auth' describe('profiles command', () => { it('accepts the singular profile alias', () => { diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index e54b58db17e..3e365a626cd 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -6,7 +6,7 @@ import { type CliAuthScope, createAuthRequest, pollForKey, -} from '../auth/device-flow.js' +} from '../auth/device-flow' import { credentialsPath, deleteProfile, @@ -14,10 +14,10 @@ import { readCredentialsProfile, writeConfigProfile, writeCredentialsProfile, -} from '../config/index.js' -import { profileFrom } from '../context.js' -import { SimApiError } from '../http/client.js' -import { printRecord } from '../output/render.js' +} from '../config/index' +import { profileFrom } from '../context' +import { SimApiError } from '../http/client' +import { printRecord } from '../output/render' /** * Best-effort browser launch. Failure is not an error: the URL is always printed diff --git a/packages/sim-cli/src/commands/configure.ts b/packages/sim-cli/src/commands/configure.ts index af804495396..88879206b55 100644 --- a/packages/sim-cli/src/commands/configure.ts +++ b/packages/sim-cli/src/commands/configure.ts @@ -1,13 +1,8 @@ import chalk from 'chalk' import { Command } from 'commander' -import { - configPath, - OUTPUT_FORMATS, - readConfigProfile, - writeConfigProfile, -} from '../config/index.js' -import { profileFrom } from '../context.js' -import { SimApiError } from '../http/client.js' +import { configPath, OUTPUT_FORMATS, readConfigProfile, writeConfigProfile } from '../config/index' +import { profileFrom } from '../context' +import { SimApiError } from '../http/client' /** * Non-secret profile settings. Credentials are deliberately not settable here — diff --git a/packages/sim-cli/src/commands/protocol/files-get.test.ts b/packages/sim-cli/src/commands/protocol/files-get.test.ts index 6eb1f6ad3c1..4cef35e449e 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.test.ts @@ -3,16 +3,16 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { buildGeneratedCommands } from '../../runtime/build.js' -import { isTerminalSafeContentType, streamToFile } from './files-get.js' -import { attachProtocolCommands } from './index.js' +import { buildGeneratedCommands } from '../../runtime/build' +import { isTerminalSafeContentType, streamToFile } from './files-get' +import { attachProtocolCommands } from './index' const { output, requestRaw } = vi.hoisted(() => ({ output: { format: 'json' }, requestRaw: vi.fn(), })) -vi.mock('../../context.js', () => ({ +vi.mock('../../context', () => ({ clientFrom: () => ({ client: { requestRaw, requireWorkspace: () => 'ws_local' }, profile: { diff --git a/packages/sim-cli/src/commands/protocol/files-get.ts b/packages/sim-cli/src/commands/protocol/files-get.ts index cca059c6353..9477463570f 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -1,10 +1,10 @@ import { once } from 'node:events' import { createWriteStream, type WriteStream } from 'node:fs' import type { Command } from 'commander' -import { clientFrom } from '../../context.js' -import { V2_OPERATIONS } from '../../generated/v2-api.js' -import { resolvePath, SimApiError } from '../../http/client.js' -import { printProtocolResult } from './result.js' +import { clientFrom } from '../../context' +import { V2_OPERATIONS } from '../../generated/v2-api' +import { resolvePath, SimApiError } from '../../http/client' +import { printProtocolResult } from './result' /** Streams a fetch body to disk while honoring write-stream backpressure. */ export async function streamToFile( diff --git a/packages/sim-cli/src/commands/protocol/files-upload.test.ts b/packages/sim-cli/src/commands/protocol/files-upload.test.ts index 5b023be631f..c56ef989fd8 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.test.ts @@ -3,14 +3,14 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { buildGeneratedCommands } from '../../runtime/build.js' -import { attachProtocolCommands } from './index.js' +import { buildGeneratedCommands } from '../../runtime/build' +import { attachProtocolCommands } from './index' const { mockRequest } = vi.hoisted(() => ({ mockRequest: vi.fn(), })) -vi.mock('../../context.js', () => ({ +vi.mock('../../context', () => ({ clientFrom: () => ({ client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, profile: { diff --git a/packages/sim-cli/src/commands/protocol/files-upload.ts b/packages/sim-cli/src/commands/protocol/files-upload.ts index ca0d928e59d..99dfe3c9418 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -1,13 +1,10 @@ import type { Command } from 'commander' -import { clientFrom } from '../../context.js' -import type { - CompleteFileUploadResponse, - CreateFileUploadResponse, -} from '../../generated/v2-api.js' -import { V2_OPERATIONS } from '../../generated/v2-api.js' -import { contentTypeFor, localFile } from '../../transfer/local-file.js' -import { finishUploadSession } from '../../transfer/upload-session.js' -import { printProtocolResult } from './result.js' +import { clientFrom } from '../../context' +import type { CompleteFileUploadResponse, CreateFileUploadResponse } from '../../generated/v2-api' +import { V2_OPERATIONS } from '../../generated/v2-api' +import { contentTypeFor, localFile } from '../../transfer/local-file' +import { finishUploadSession } from '../../transfer/upload-session' +import { printProtocolResult } from './result' export function attachFileUpload(files: Command): void { files diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index 65bbf53cb0e..8be3088630c 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -1,9 +1,9 @@ import { Command } from 'commander' -import { attachFileGet } from './files-get.js' -import { attachFileUpload } from './files-upload.js' -import { attachKnowledgeDocumentUpload } from './knowledge-document-upload.js' -import { attachResourceDirectoryCommands } from './resource-directory.js' -import { attachTableImport } from './tables-import.js' +import { attachFileGet } from './files-get' +import { attachFileUpload } from './files-upload' +import { attachKnowledgeDocumentUpload } from './knowledge-document-upload' +import { attachResourceDirectoryCommands } from './resource-directory' +import { attachTableImport } from './tables-import' function group(program: Command, name: string): Command { const existing = program.commands.find((command) => command.name() === name) diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts index 74eb2bdea35..c7baf48d5bf 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts @@ -3,14 +3,14 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { buildGeneratedCommands } from '../../runtime/build.js' -import { attachProtocolCommands } from './index.js' +import { buildGeneratedCommands } from '../../runtime/build' +import { attachProtocolCommands } from './index' const { mockRequest } = vi.hoisted(() => ({ mockRequest: vi.fn(), })) -vi.mock('../../context.js', () => ({ +vi.mock('../../context', () => ({ clientFrom: () => ({ client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, profile: { diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts index 1a459930628..f268a0e5a25 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts @@ -1,13 +1,13 @@ import type { Command } from 'commander' -import { clientFrom } from '../../context.js' +import { clientFrom } from '../../context' import type { CompleteKnowledgeDocumentUploadResponse, CreateKnowledgeDocumentUploadResponse, -} from '../../generated/v2-api.js' -import { SimApiError } from '../../http/client.js' -import { contentTypeFor, localFile } from '../../transfer/local-file.js' -import { finishUploadSession } from '../../transfer/upload-session.js' -import { printProtocolResult } from './result.js' +} from '../../generated/v2-api' +import { SimApiError } from '../../http/client' +import { contentTypeFor, localFile } from '../../transfer/local-file' +import { finishUploadSession } from '../../transfer/upload-session' +import { printProtocolResult } from './result' interface KnowledgeDocumentUploadOptions { name?: string diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts index 950512efc0b..1ef6d4b1d12 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts @@ -1,14 +1,14 @@ import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { buildGeneratedCommands } from '../../runtime/build.js' -import { attachProtocolCommands } from './index.js' +import { buildGeneratedCommands } from '../../runtime/build' +import { attachProtocolCommands } from './index' const { mockRequest, output } = vi.hoisted(() => ({ mockRequest: vi.fn(), output: { format: 'json' }, })) -vi.mock('../../context.js', () => ({ +vi.mock('../../context', () => ({ clientFrom: () => ({ client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, profile: { diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.ts b/packages/sim-cli/src/commands/protocol/resource-directory.ts index c2e9d64f8bc..fa0ca8eb07a 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.ts @@ -1,5 +1,5 @@ import { type Command, Option } from 'commander' -import { clientFrom } from '../../context.js' +import { clientFrom } from '../../context' import { type ListFileFoldersResponse, type ListFilesResponse, @@ -11,11 +11,11 @@ import { type ListWorkflowsResponse, V2_OPERATIONS, type V2OperationName, -} from '../../generated/v2-api.js' -import { requestAllPages, SimApiError, type SimClient, type V2Page } from '../../http/client.js' -import { type Column, printList, text, timestamp } from '../../output/render.js' -import { DEFAULT_LIMIT } from '../../runtime/options.js' -import { renderResult } from '../../runtime/result.js' +} from '../../generated/v2-api' +import { requestAllPages, SimApiError, type SimClient, type V2Page } from '../../http/client' +import { type Column, printList, text, timestamp } from '../../output/render' +import { DEFAULT_LIMIT } from '../../runtime/options' +import { renderResult } from '../../runtime/result' type FolderListOperation = | 'listFileFolders' diff --git a/packages/sim-cli/src/commands/protocol/result.ts b/packages/sim-cli/src/commands/protocol/result.ts index 304f852b394..35e84b03fe6 100644 --- a/packages/sim-cli/src/commands/protocol/result.ts +++ b/packages/sim-cli/src/commands/protocol/result.ts @@ -1,5 +1,5 @@ -import type { OutputFormat } from '../../config/index.js' -import { printRecord, text } from '../../output/render.js' +import type { OutputFormat } from '../../config/index' +import { printRecord, text } from '../../output/render' export function printProtocolResult(format: OutputFormat, result: Record<string, unknown>): void { const fields = Object.entries(result).map<[string, string]>(([key, value]) => [key, text(value)]) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.test.ts b/packages/sim-cli/src/commands/protocol/tables-import.test.ts index 0a04990ce1e..b14cee1a0dd 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.test.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.test.ts @@ -1,14 +1,14 @@ import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { buildGeneratedCommands } from '../../runtime/build.js' -import { attachProtocolCommands } from './index.js' +import { buildGeneratedCommands } from '../../runtime/build' +import { attachProtocolCommands } from './index' const { mockRequest, output } = vi.hoisted(() => ({ mockRequest: vi.fn(), output: { format: 'json' }, })) -vi.mock('../../context.js', () => ({ +vi.mock('../../context', () => ({ clientFrom: () => ({ client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, profile: { diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts index fd6d4581b84..d60bbcc6288 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -1,18 +1,18 @@ import { setTimeout as sleep } from 'node:timers/promises' import chalk from 'chalk' import { type Command, Option } from 'commander' -import { clientFrom } from '../../context.js' +import { clientFrom } from '../../context' import type { CompleteTableImportResponse, CreateTableImportResponse, GetTableImportResponse, -} from '../../generated/v2-api.js' -import { V2_OPERATIONS } from '../../generated/v2-api.js' -import { SimApiError, type SimClient } from '../../http/client.js' -import { coerce, type FieldSpec } from '../../runtime/request.js' -import { contentTypeFor, localFile } from '../../transfer/local-file.js' -import { finishUploadSession } from '../../transfer/upload-session.js' -import { printProtocolResult } from './result.js' +} from '../../generated/v2-api' +import { V2_OPERATIONS } from '../../generated/v2-api' +import { SimApiError, type SimClient } from '../../http/client' +import { coerce, type FieldSpec } from '../../runtime/request' +import { contentTypeFor, localFile } from '../../transfer/local-file' +import { finishUploadSession } from '../../transfer/upload-session' +import { printProtocolResult } from './result' type TableImport = GetTableImportResponse['data'] diff --git a/packages/sim-cli/src/config/index.ts b/packages/sim-cli/src/config/index.ts index 50ead0e790d..79b5751a0b0 100644 --- a/packages/sim-cli/src/config/index.ts +++ b/packages/sim-cli/src/config/index.ts @@ -1,4 +1,4 @@ -export { configDir, configPath, credentialsPath } from './paths.js' +export { configDir, configPath, credentialsPath } from './paths' export { DEFAULT_ENDPOINT, DEFAULT_PROFILE, @@ -15,4 +15,4 @@ export { type SettingSource, writeConfigProfile, writeCredentialsProfile, -} from './profile.js' +} from './profile' diff --git a/packages/sim-cli/src/config/ini.test.ts b/packages/sim-cli/src/config/ini.test.ts index ba3a93fb84c..d26ba8bd594 100644 --- a/packages/sim-cli/src/config/ini.test.ts +++ b/packages/sim-cli/src/config/ini.test.ts @@ -6,7 +6,7 @@ import { removeSection, serializeIni, setSectionValues, -} from './ini.js' +} from './ini' const SAMPLE = `# top-level note [default] diff --git a/packages/sim-cli/src/config/profile.test.ts b/packages/sim-cli/src/config/profile.test.ts index fee767e474f..225af15842b 100644 --- a/packages/sim-cli/src/config/profile.test.ts +++ b/packages/sim-cli/src/config/profile.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { configPath, credentialsPath } from './paths.js' +import { configPath, credentialsPath } from './paths' import { deleteProfile, listProfiles, @@ -10,7 +10,7 @@ import { resolveProfile, writeConfigProfile, writeCredentialsProfile, -} from './profile.js' +} from './profile' let dir: string const ENV_KEYS = ['SIM_PROFILE', 'SIM_ENDPOINT', 'SIM_API_KEY', 'SIM_WORKSPACE', 'SIM_OUTPUT'] diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts index 9826b79daa1..c770cc2aae9 100644 --- a/packages/sim-cli/src/config/profile.ts +++ b/packages/sim-cli/src/config/profile.ts @@ -8,8 +8,8 @@ import { removeSection, serializeIni, setSectionValues, -} from './ini.js' -import { configPath, credentialsPath } from './paths.js' +} from './ini' +import { configPath, credentialsPath } from './paths' export const DEFAULT_PROFILE = 'default' export const DEFAULT_ENDPOINT = 'https://sim.ai' diff --git a/packages/sim-cli/src/context.ts b/packages/sim-cli/src/context.ts index 1880f366eac..61cc307a2b4 100644 --- a/packages/sim-cli/src/context.ts +++ b/packages/sim-cli/src/context.ts @@ -4,8 +4,8 @@ import { type ProfileOverrides, type ResolvedProfile, resolveProfile, -} from './config/index.js' -import { SimClient } from './http/client.js' +} from './config/index' +import { SimClient } from './http/client' /** Global flags, shared by every subcommand. */ export interface GlobalOptions { diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 62404efdadf..f7ba4e0af9e 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -1,4 +1,4 @@ -import type { CliContract, ColumnSpec, CommandVariantSpec } from './types.js' +import type { CliContract, ColumnSpec, CommandVariantSpec } from './types' const TABLE_NAME_HELP = 'Identifier: letters, numbers, and underscores; cannot start with a number' const TABLE_FILTER_HELP = diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index d7df31c341e..736c247ef24 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -1,4 +1,4 @@ -import type { V2OperationName } from '../generated/v2-api.js' +import type { V2OperationName } from '../generated/v2-api' /** * The CLI contract: how the terminal surface maps onto the v2 API. diff --git a/packages/sim-cli/src/helpers.ts b/packages/sim-cli/src/helpers.ts index 66bc434a349..9cfd149591b 100644 --- a/packages/sim-cli/src/helpers.ts +++ b/packages/sim-cli/src/helpers.ts @@ -1,7 +1,7 @@ /** * Local copies of the shared helpers. * - * `@sim/utils` is a private workspace package, so a published `@simai/cli` + * `@sim/utils` is a private workspace package, so a published `@sim/cli` * cannot depend on it — importing it would resolve in the monorepo and fail for * anyone installing from npm. */ diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 0679db8b4dd..39b5177e58e 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -1,13 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { CLI_CONTRACT } from '../contract/commands.js' -import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' +import { CLI_CONTRACT } from '../contract/commands' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' import { formatApiErrorDetails, requestAllPages, resolvePath, SimApiError, SimClient, -} from './client.js' +} from './client' afterEach(() => { vi.unstubAllGlobals() diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index ccecd13ecdf..dbebfecc7d5 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -1,4 +1,4 @@ -import type { ResolvedProfile } from '../config/index.js' +import type { ResolvedProfile } from '../config/index' /** * A failure the CLI can explain. Anything thrown as a `SimApiError` is printed diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 2405e62bf52..b801a8c1e75 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -3,13 +3,13 @@ import { readFileSync } from 'node:fs' import chalk from 'chalk' import { Command, Option } from 'commander' -import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth.js' -import { configureCommand } from './commands/configure.js' -import { attachProtocolCommands } from './commands/protocol/index.js' -import { OUTPUT_FORMATS, ProfileConfigError } from './config/index.js' -import { formatApiErrorDetails, SimApiError } from './http/client.js' -import { sanitize } from './output/render.js' -import { buildGeneratedCommands } from './runtime/build.js' +import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth' +import { configureCommand } from './commands/configure' +import { attachProtocolCommands } from './commands/protocol/index' +import { OUTPUT_FORMATS, ProfileConfigError } from './config/index' +import { formatApiErrorDetails, SimApiError } from './http/client' +import { sanitize } from './output/render' +import { buildGeneratedCommands } from './runtime/build' const program = new Command() diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts index 41b7ac06f56..f3dbaa8fbf4 100644 --- a/packages/sim-cli/src/output/render.test.ts +++ b/packages/sim-cli/src/output/render.test.ts @@ -11,7 +11,7 @@ import { text, timestamp, visibleWidth, -} from './render.js' +} from './render' const ESC = String.fromCharCode(27) const BEL = String.fromCharCode(7) diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index 46cc97008d9..a6b3fbe7b38 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -1,7 +1,7 @@ import chalk from 'chalk' import { dump } from 'js-yaml' -import type { OutputFormat } from '../config/index.js' -import { displayWidth } from './terminal-text.js' +import type { OutputFormat } from '../config/index' +import { displayWidth } from './terminal-text' export interface Column<T> { header: string diff --git a/packages/sim-cli/src/output/trace.ts b/packages/sim-cli/src/output/trace.ts index 3e4380519fd..553da8a863a 100644 --- a/packages/sim-cli/src/output/trace.ts +++ b/packages/sim-cli/src/output/trace.ts @@ -1,6 +1,6 @@ import chalk from 'chalk' -import type { OutputFormat } from '../config/index.js' -import { duration, sanitize } from './render.js' +import type { OutputFormat } from '../config/index' +import { duration, sanitize } from './render' type TraceSpan = Record<string, unknown> diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 92378570d62..8a75ff76a08 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -1,6 +1,6 @@ import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { buildGeneratedCommands } from './build.js' +import { buildGeneratedCommands } from './build' /** * Drives commands through commander's own parsing rather than calling @@ -19,7 +19,7 @@ const { mockRequest, output, profileState } = vi.hoisted(() => ({ profileState: { workspaceId: 'ws_local' as string | null }, })) -vi.mock('../context.js', () => ({ +vi.mock('../context', () => ({ clientFrom: () => ({ client: { request: mockRequest, diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 8acb4a84fc0..5753fb09cd7 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -1,12 +1,12 @@ import { Command } from 'commander' -import { CLI_CONTRACT } from '../contract/commands.js' -import type { CommandSpec, CommandVariantSpec } from '../contract/types.js' -import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' -import { deriveCommandPath } from './derive.js' -import { executeOperation } from './execute.js' -import { addOperationOptions } from './options.js' -import { flagNameFor, isProfileWorkspacePath, PROFILE_INJECTED_FIELD } from './request.js' -import type { OperationSpec } from './types.js' +import { CLI_CONTRACT } from '../contract/commands' +import type { CommandSpec, CommandVariantSpec } from '../contract/types' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' +import { deriveCommandPath } from './derive' +import { executeOperation } from './execute' +import { addOperationOptions } from './options' +import { flagNameFor, isProfileWorkspacePath, PROFILE_INJECTED_FIELD } from './request' +import type { OperationSpec } from './types' const GROUP_ALIASES: Readonly<Record<string, string>> = { 'audit-logs': 'audit-log', diff --git a/packages/sim-cli/src/runtime/derive.ts b/packages/sim-cli/src/runtime/derive.ts index f91aac678e1..eb0d17a94e8 100644 --- a/packages/sim-cli/src/runtime/derive.ts +++ b/packages/sim-cli/src/runtime/derive.ts @@ -1,4 +1,4 @@ -import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' /** * Trailing path segments that read as verbs rather than sub-resources, so diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 73d6adbe6c6..5b3ebd83e70 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -1,18 +1,18 @@ import type { Command } from 'commander' -import { clientFrom } from '../context.js' -import type { CommandSpec } from '../contract/types.js' -import type { V2OperationName } from '../generated/v2-api.js' -import { SimApiError, type V2Page } from '../http/client.js' -import { camel } from './derive.js' -import { DEFAULT_LIMIT } from './options.js' +import { clientFrom } from '../context' +import type { CommandSpec } from '../contract/types' +import type { V2OperationName } from '../generated/v2-api' +import { SimApiError, type V2Page } from '../http/client' +import { camel } from './derive' +import { DEFAULT_LIMIT } from './options' import { buildRequest, flagNameFor, isProfileWorkspacePath, PROFILE_INJECTED_FIELD, -} from './request.js' -import { renderPage, renderResult } from './result.js' -import type { OperationSpec } from './types.js' +} from './request' +import { renderPage, renderResult } from './result' +import type { OperationSpec } from './types' function cursorSlot(operationSpec: OperationSpec): 'query' | 'body' | null { if (operationSpec.query && 'cursor' in operationSpec.query) return 'query' diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index 5ca5757e034..9c2a1576ffc 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -1,6 +1,6 @@ import { type Command, Option } from 'commander' -import type { CommandSpec } from '../contract/types.js' -import type { V2OperationName } from '../generated/v2-api.js' +import type { CommandSpec } from '../contract/types' +import type { V2OperationName } from '../generated/v2-api' import { type FieldSpec, flagNameFor, @@ -8,8 +8,8 @@ import { PROFILE_INJECTED_FIELD, pathFlagNameFor, takesJson, -} from './request.js' -import type { OperationSpec } from './types.js' +} from './request' +import type { OperationSpec } from './types' export const DEFAULT_LIMIT = 100 diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 771f7f42708..dc8295e70d6 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -2,9 +2,9 @@ import { rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { SimApiError } from '../http/client.js' -import { deriveCommandPath } from './derive.js' -import { buildRequest, coerce, type FieldSpec } from './request.js' +import { SimApiError } from '../http/client' +import { deriveCommandPath } from './derive' +import { buildRequest, coerce, type FieldSpec } from './request' const WORKSPACE = 'ws_local' diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 430348170b4..848acdadb1e 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -1,9 +1,9 @@ import { existsSync, readFileSync, readSync } from 'node:fs' -import { CLI_CONTRACT } from '../contract/commands.js' -import type { CommandSpec, FlagSpec } from '../contract/types.js' -import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' -import { type QueryValue, SimApiError } from '../http/client.js' -import { camel, kebab } from './derive.js' +import { CLI_CONTRACT } from '../contract/commands' +import type { CommandSpec, FlagSpec } from '../contract/types' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' +import { type QueryValue, SimApiError } from '../http/client' +import { camel, kebab } from './derive' /** One request field, as the generator describes it. */ export interface FieldSpec { diff --git a/packages/sim-cli/src/runtime/result.ts b/packages/sim-cli/src/runtime/result.ts index fba5d5dc790..e803f45934d 100644 --- a/packages/sim-cli/src/runtime/result.ts +++ b/packages/sim-cli/src/runtime/result.ts @@ -1,6 +1,6 @@ -import type { OutputFormat } from '../config/index.js' -import type { ColumnSpec, CommandSpec } from '../contract/types.js' -import type { V2OperationName } from '../generated/v2-api.js' +import type { OutputFormat } from '../config/index' +import type { ColumnSpec, CommandSpec } from '../contract/types' +import type { V2OperationName } from '../generated/v2-api' import { bool, bytes, @@ -12,8 +12,8 @@ import { sanitize, text, timestamp, -} from '../output/render.js' -import { printTraceSpans } from '../output/trace.js' +} from '../output/render' +import { printTraceSpans } from '../output/trace' interface RenderResultOptions { expandedTrace?: boolean diff --git a/packages/sim-cli/src/runtime/types.ts b/packages/sim-cli/src/runtime/types.ts index 2d352c73a3f..ab9892aec8c 100644 --- a/packages/sim-cli/src/runtime/types.ts +++ b/packages/sim-cli/src/runtime/types.ts @@ -1,5 +1,5 @@ -import type { RequestOptions } from '../http/client.js' -import type { FieldSpec } from './request.js' +import type { RequestOptions } from '../http/client' +import type { FieldSpec } from './request' export interface OperationSpec { method: NonNullable<RequestOptions['method']> diff --git a/packages/sim-cli/src/transfer/local-file.ts b/packages/sim-cli/src/transfer/local-file.ts index e2647dbec24..dfbd1d35e46 100644 --- a/packages/sim-cli/src/transfer/local-file.ts +++ b/packages/sim-cli/src/transfer/local-file.ts @@ -1,6 +1,6 @@ import { stat } from 'node:fs/promises' import { basename } from 'node:path' -import { SimApiError } from '../http/client.js' +import { SimApiError } from '../http/client' const CONTENT_TYPES: Record<string, string> = { css: 'text/css', diff --git a/packages/sim-cli/src/transfer/upload-session.ts b/packages/sim-cli/src/transfer/upload-session.ts index c97d02bd2bd..cc8e7d0566d 100644 --- a/packages/sim-cli/src/transfer/upload-session.ts +++ b/packages/sim-cli/src/transfer/upload-session.ts @@ -1,5 +1,5 @@ import { openAsBlob } from 'node:fs' -import { SimApiError, type SimClient } from '../http/client.js' +import { SimApiError, type SimClient } from '../http/client' interface UploadPartUrl { partNumber: number diff --git a/packages/sim-cli/tsconfig.build.json b/packages/sim-cli/tsconfig.build.json deleted file mode 100644 index 302f241b4d3..00000000000 --- a/packages/sim-cli/tsconfig.build.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "declaration": false, - "declarationMap": false, - "sourceMap": false - }, - "exclude": ["node_modules", "dist", "src/**/*.test.ts"] -} diff --git a/packages/sim-cli/tsconfig.json b/packages/sim-cli/tsconfig.json index 69711cab009..98522576add 100644 --- a/packages/sim-cli/tsconfig.json +++ b/packages/sim-cli/tsconfig.json @@ -1,12 +1,5 @@ { - "extends": "@sim/tsconfig/library-build.json", - "compilerOptions": { - "target": "ES2022", - "module": "nodenext", - "moduleResolution": "nodenext", - "outDir": "./dist", - "rootDir": "./src" - }, + "extends": "@sim/tsconfig/base.json", "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } diff --git a/scripts/check-import-specifiers.ts b/scripts/check-import-specifiers.ts index 3a9f5ad146b..b8ad37a3b51 100644 --- a/scripts/check-import-specifiers.ts +++ b/scripts/check-import-specifiers.ts @@ -128,13 +128,6 @@ interface PathRule { interface Workspace { dir: string paths: PathRule[] - /** - * Node's ESM resolver takes the specifier literally, so a NodeNext package - * must write `./foo.js` for a file that is `./foo.ts` on disk. Dropping the - * extension there — what this audit advises everywhere else — breaks the - * package at runtime, so the `.js` is mapped back to its source instead. - */ - nodeNext: boolean } const workspaces: Workspace[] = [] @@ -151,11 +144,7 @@ for (const group of ['apps', 'packages']) { if (!isFile(tsconfig)) continue try { const raw = readFileSync(tsconfig, 'utf8').replace(/^\s*\/\/.*$/gm, '') - const compilerOptions = JSON.parse(raw)?.compilerOptions ?? {} - const paths = compilerOptions.paths ?? {} - const nodeNext = /^node(next|16)$/i.test( - compilerOptions.moduleResolution ?? compilerOptions.module ?? '' - ) + const paths = JSON.parse(raw)?.compilerOptions?.paths ?? {} const entries: PathRule[] = Object.entries<string[]>(paths).map(([pattern, targets]) => { const [prefix, suffix = ''] = pattern.split('*') return { @@ -167,7 +156,7 @@ for (const group of ['apps', 'packages']) { }) // Longest prefix wins, matching TypeScript's own precedence. entries.sort((a, b) => b.prefix.length - a.prefix.length) - workspaces.push({ dir, paths: entries, nodeNext }) + workspaces.push({ dir, paths: entries }) } catch { /* unparseable tsconfig — skip rather than fail the whole run */ } @@ -241,15 +230,7 @@ function resolveSpecifier(spec: string, importer: string): Outcome | null { if (spec.startsWith('.')) { const base = resolve(dirname(importer), spec) if (isGeneratedPath(base)) return null - if (probe(base)) return { ok: true } - // A NodeNext package points at the emitted `.js`; check its source instead - // of demanding the extension be dropped. - if (workspaceFor(importer)?.nodeNext && /\.(js|mjs|cjs)$/.test(spec)) { - const source = base.replace(/\.(js|mjs|cjs)$/, '') - if (isGeneratedPath(source)) return null - if (probe(source)) return { ok: true } - } - return { ok: false, reason: 'no file at that path' } + return probe(base) ? { ok: true } : { ok: false, reason: 'no file at that path' } } // tsconfig `paths` first — it legitimately overrides a package's exports map. From b41318329ea5d6dd75386503ed4b5c1e3b231d7e Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 17:13:05 -0700 Subject: [PATCH 142/159] fix(credentials): preserve migrated route behavior --- .../migrate-application-operation/SKILL.md | 41 +++++ .../commands/migrate-application-operation.md | 41 +++++ .../commands/migrate-application-operation.md | 41 +++++ .../api/auth/oauth2/authorize/route.test.ts | 25 +++ .../app/api/auth/oauth2/authorize/route.ts | 66 ++++---- .../oauth2/callback/shopify/route.test.ts | 48 +++++- .../api/auth/oauth2/callback/shopify/route.ts | 9 +- .../api/auth/shopify/authorize/route.test.ts | 28 +++- .../app/api/auth/shopify/authorize/route.ts | 24 ++- .../credentials/[id]/members/route.test.ts | 154 ++++++++++++++++++ .../app/api/credentials/[id]/members/route.ts | 9 +- apps/sim/app/api/credentials/route.test.ts | 51 +++++- apps/sim/app/api/credentials/route.ts | 10 +- .../microsoft-excel/microsoft-excel.ts | 2 +- apps/sim/hooks/queries/credentials.ts | 3 +- .../utils/fetch-workspace-credentials.ts | 17 +- apps/sim/hooks/use-oauth-return.ts | 5 +- apps/sim/lib/api/contracts/credentials.ts | 37 ++++- .../api/contracts/oauth-connections.test.ts | 12 ++ .../lib/api/contracts/oauth-connections.ts | 9 +- .../sim/lib/credentials/api/route-policies.ts | 31 ++++ .../application/authorized-user-use-case.ts | 79 ++++++--- .../application/credential-crud.ts | 27 +-- .../application/credential-members.ts | 4 +- .../application/oauth-accounts.test.ts | 84 ++++++++++ .../credentials/application/oauth-accounts.ts | 86 +++++++--- .../lib/credentials/application/operations.ts | 15 +- .../application/service-account.test.ts | 48 ++++++ .../application/service-account.ts | 43 +++-- apps/sim/lib/credentials/oauth-accounts.ts | 35 +++- apps/sim/lib/credentials/queries.ts | 40 +++++ apps/sim/lib/oauth/shopify-state.test.ts | 12 +- apps/sim/lib/oauth/shopify-state.ts | 11 +- findings.txt | 19 +++ scripts/check-api-validation-contracts.ts | 4 +- 35 files changed, 983 insertions(+), 187 deletions(-) create mode 100644 apps/sim/app/api/credentials/[id]/members/route.test.ts create mode 100644 apps/sim/lib/credentials/application/oauth-accounts.test.ts create mode 100644 findings.txt diff --git a/.agents/skills/migrate-application-operation/SKILL.md b/.agents/skills/migrate-application-operation/SKILL.md index e7f0039e84b..6caa5ff7c57 100644 --- a/.agents/skills/migrate-application-operation/SKILL.md +++ b/.agents/skills/migrate-application-operation/SKILL.md @@ -78,6 +78,43 @@ Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent oper Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. +## Freeze observable behavior before editing + +Treat the legacy route or tool as an ordered program, not merely a bag of business logic. Before moving code, write a compact baseline for every in-scope entry point and add focused characterization tests for behavior not already pinned down. + +Capture all of these when they apply: + +- Accepted inputs, including trimming, blank omission, duplicate query keys, aliases, defaults, and bounds. +- Authentication and authorization order, minimum roles, resource membership, concealment, and exact error/status mapping. +- Exact success bodies, optional fields, status codes, redirects, cookies, headers, and binary or stream behavior. +- Mutation ordering, transaction boundaries, idempotency, no-ops, and observable state after each possible partial failure. +- Audit, notification, analytics, and billing timing plus exact semantic dimensions and attribution. +- Browser or protocol state ownership, concurrency isolation, expiry, callback ordering, and cleanup behavior. +- Every value newly crossing into HTML, JavaScript, SQL, URLs, logs, provider payloads, or another encoding context. + +Compare the old statement order with the proposed application lifecycle explicitly: + +```text +legacy parse/normalize + -> legacy authorization checks + -> branch-specific canonical lookup + -> mutation(s) + -> per-step side effects + -> response or redirect catch +``` + +Moving those steps under a wrapper may change behavior even when each individual call is reused. In particular: + +- `projectAudit` and `afterSuccess` run only after `execute` returns. They cannot describe earlier committed mutations when a later step throws. Make the compound mutation atomic or define explicit partial-result/failure projection semantics before migrating it. +- Operation metadata is executable policy. Adding a resource role to a workspace-only legacy read is an authorization change, not an architectural cleanup. +- A shared error policy does not automatically preserve route-local concealment, subclass ordering, browser redirects, or branch-specific messages. +- A shared contract does not automatically preserve manual `URLSearchParams` normalization or exact legacy response unions. +- A shared use case may own domain behavior while separate surface presenters still preserve different wire shapes. +- Per-flow identity is insufficient when another part of the flow remains in browser-global state such as one cookie. +- Passing a newly supported parameter through old rendering code creates a new security boundary even when the renderer itself is unchanged. + +Fail fast if the baseline cannot be established from code, tests, or an explicit product decision. Do not infer that behavior is unimportant because it was previously implicit. + ## Keep the layers distinct Use these responsibilities: @@ -270,6 +307,10 @@ Add focused tests for every migrated surface and principal kind allowed by the o - Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. - Copilot or tools: trusted context, exact registered operation membership, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. - Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. +- Compatibility characterization: legacy normalization, exact response/redirect/cookie behavior, concealment, error subclass precedence, and branch-specific output. +- Failure sequencing: inject a failure after each independently committing step and assert persisted state plus audit, analytics, and notification effects. +- Concurrency: overlap stateful browser or provider flows and prove each callback consumes only its own state and return destination. +- Rendering boundaries: exercise hostile values for every newly connected input that reaches HTML, inline JavaScript, URLs, logs, or provider requests. Run at minimum: diff --git a/.claude/commands/migrate-application-operation.md b/.claude/commands/migrate-application-operation.md index bc61333c358..d8048553e78 100644 --- a/.claude/commands/migrate-application-operation.md +++ b/.claude/commands/migrate-application-operation.md @@ -77,6 +77,43 @@ Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent oper Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. +## Freeze observable behavior before editing + +Treat the legacy route or tool as an ordered program, not merely a bag of business logic. Before moving code, write a compact baseline for every in-scope entry point and add focused characterization tests for behavior not already pinned down. + +Capture all of these when they apply: + +- Accepted inputs, including trimming, blank omission, duplicate query keys, aliases, defaults, and bounds. +- Authentication and authorization order, minimum roles, resource membership, concealment, and exact error/status mapping. +- Exact success bodies, optional fields, status codes, redirects, cookies, headers, and binary or stream behavior. +- Mutation ordering, transaction boundaries, idempotency, no-ops, and observable state after each possible partial failure. +- Audit, notification, analytics, and billing timing plus exact semantic dimensions and attribution. +- Browser or protocol state ownership, concurrency isolation, expiry, callback ordering, and cleanup behavior. +- Every value newly crossing into HTML, JavaScript, SQL, URLs, logs, provider payloads, or another encoding context. + +Compare the old statement order with the proposed application lifecycle explicitly: + +```text +legacy parse/normalize + -> legacy authorization checks + -> branch-specific canonical lookup + -> mutation(s) + -> per-step side effects + -> response or redirect catch +``` + +Moving those steps under a wrapper may change behavior even when each individual call is reused. In particular: + +- `projectAudit` and `afterSuccess` run only after `execute` returns. They cannot describe earlier committed mutations when a later step throws. Make the compound mutation atomic or define explicit partial-result/failure projection semantics before migrating it. +- Operation metadata is executable policy. Adding a resource role to a workspace-only legacy read is an authorization change, not an architectural cleanup. +- A shared error policy does not automatically preserve route-local concealment, subclass ordering, browser redirects, or branch-specific messages. +- A shared contract does not automatically preserve manual `URLSearchParams` normalization or exact legacy response unions. +- A shared use case may own domain behavior while separate surface presenters still preserve different wire shapes. +- Per-flow identity is insufficient when another part of the flow remains in browser-global state such as one cookie. +- Passing a newly supported parameter through old rendering code creates a new security boundary even when the renderer itself is unchanged. + +Fail fast if the baseline cannot be established from code, tests, or an explicit product decision. Do not infer that behavior is unimportant because it was previously implicit. + ## Keep the layers distinct Use these responsibilities: @@ -269,6 +306,10 @@ Add focused tests for every migrated surface and principal kind allowed by the o - Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. - Copilot or tools: trusted context, exact registered operation membership, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. - Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. +- Compatibility characterization: legacy normalization, exact response/redirect/cookie behavior, concealment, error subclass precedence, and branch-specific output. +- Failure sequencing: inject a failure after each independently committing step and assert persisted state plus audit, analytics, and notification effects. +- Concurrency: overlap stateful browser or provider flows and prove each callback consumes only its own state and return destination. +- Rendering boundaries: exercise hostile values for every newly connected input that reaches HTML, inline JavaScript, URLs, logs, or provider requests. Run at minimum: diff --git a/.cursor/commands/migrate-application-operation.md b/.cursor/commands/migrate-application-operation.md index 9fac674ca6f..0742f452523 100644 --- a/.cursor/commands/migrate-application-operation.md +++ b/.cursor/commands/migrate-application-operation.md @@ -73,6 +73,43 @@ Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent oper Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. +## Freeze observable behavior before editing + +Treat the legacy route or tool as an ordered program, not merely a bag of business logic. Before moving code, write a compact baseline for every in-scope entry point and add focused characterization tests for behavior not already pinned down. + +Capture all of these when they apply: + +- Accepted inputs, including trimming, blank omission, duplicate query keys, aliases, defaults, and bounds. +- Authentication and authorization order, minimum roles, resource membership, concealment, and exact error/status mapping. +- Exact success bodies, optional fields, status codes, redirects, cookies, headers, and binary or stream behavior. +- Mutation ordering, transaction boundaries, idempotency, no-ops, and observable state after each possible partial failure. +- Audit, notification, analytics, and billing timing plus exact semantic dimensions and attribution. +- Browser or protocol state ownership, concurrency isolation, expiry, callback ordering, and cleanup behavior. +- Every value newly crossing into HTML, JavaScript, SQL, URLs, logs, provider payloads, or another encoding context. + +Compare the old statement order with the proposed application lifecycle explicitly: + +```text +legacy parse/normalize + -> legacy authorization checks + -> branch-specific canonical lookup + -> mutation(s) + -> per-step side effects + -> response or redirect catch +``` + +Moving those steps under a wrapper may change behavior even when each individual call is reused. In particular: + +- `projectAudit` and `afterSuccess` run only after `execute` returns. They cannot describe earlier committed mutations when a later step throws. Make the compound mutation atomic or define explicit partial-result/failure projection semantics before migrating it. +- Operation metadata is executable policy. Adding a resource role to a workspace-only legacy read is an authorization change, not an architectural cleanup. +- A shared error policy does not automatically preserve route-local concealment, subclass ordering, browser redirects, or branch-specific messages. +- A shared contract does not automatically preserve manual `URLSearchParams` normalization or exact legacy response unions. +- A shared use case may own domain behavior while separate surface presenters still preserve different wire shapes. +- Per-flow identity is insufficient when another part of the flow remains in browser-global state such as one cookie. +- Passing a newly supported parameter through old rendering code creates a new security boundary even when the renderer itself is unchanged. + +Fail fast if the baseline cannot be established from code, tests, or an explicit product decision. Do not infer that behavior is unimportant because it was previously implicit. + ## Keep the layers distinct Use these responsibilities: @@ -265,6 +302,10 @@ Add focused tests for every migrated surface and principal kind allowed by the o - Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. - Copilot or tools: trusted context, exact registered operation membership, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. - Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. +- Compatibility characterization: legacy normalization, exact response/redirect/cookie behavior, concealment, error subclass precedence, and branch-specific output. +- Failure sequencing: inject a failure after each independently committing step and assert persisted state plus audit, analytics, and notification effects. +- Concurrency: overlap stateful browser or provider flows and prove each callback consumes only its own state and return destination. +- Rendering boundaries: exercise hostile values for every newly connected input that reaches HTML, inline JavaScript, URLs, logs, or provider requests. Run at minimum: diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index c4a275a840d..4b4c07ebe22 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -4,6 +4,7 @@ import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { InsufficientWorkspacePermissionsError } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' @@ -192,6 +193,30 @@ describe('OAuth2 authorize route', () => { expect(workspaceResponse.headers.get('location')).toContain('workspace_access_denied') }) + it('keeps a reconnect workspace-role denial classified as workspace access', async () => { + mocks.createConnection.mockRejectedValue(new InsufficientWorkspacePermissionsError()) + + const response = await GET( + request({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + }) + ) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=workspace_access_denied` + ) + }) + + it('redirects a draft launch infrastructure failure through the browser error contract', async () => { + mocks.launchConnection.mockRejectedValue(new Error('Database unavailable')) + + const response = await GET(request({ draftId: 'draft-1' })) + + expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`) + }) + it('routes custom providers through the exact application draft', async () => { mocks.createConnection.mockResolvedValue({ providerId: 'trello', diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 7526b1b67e8..414ff227c2d 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -39,40 +39,40 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { draftId } = parsed.data.query let { providerId, workspaceId, callbackURL: requestedCallback, credentialId } = parsed.data.query - let fromConnectionDraft = false - let connectionDraftId: string | undefined - if (draftId) { - try { - const { draft } = await launchCredentialConnection.execute({ - principal, - input: { draftId }, - request, - }) - providerId = draft.providerId - workspaceId = draft.workspaceId - credentialId = draft.credentialId ?? undefined - connectionDraftId = draft.id - fromConnectionDraft = true - } catch (error) { - if (!(error instanceof OrchestrationError)) throw error - logger.warn('Rejected OAuth connection draft', { userId, draftId, code: error.code }) - return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_invalid`) + try { + let fromConnectionDraft = false + let connectionDraftId: string | undefined + if (draftId) { + try { + const { draft } = await launchCredentialConnection.execute({ + principal, + input: { draftId }, + request, + }) + providerId = draft.providerId + workspaceId = draft.workspaceId + credentialId = draft.credentialId ?? undefined + connectionDraftId = draft.id + fromConnectionDraft = true + } catch (error) { + if (!(error instanceof OrchestrationError)) throw error + logger.warn('Rejected OAuth connection draft', { userId, draftId, code: error.code }) + return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_invalid`) + } } - } - if (!providerId || !workspaceId) { - throw new Error('Validated OAuth authorization request is missing its target') - } + if (!providerId || !workspaceId) { + throw new Error('Validated OAuth authorization request is missing its target') + } - const connectionCompleteUrl = new URL('/oauth/credential-connected', baseUrl) - connectionCompleteUrl.searchParams.set('result', 'connected') - const callbackURL = fromConnectionDraft - ? connectionCompleteUrl.toString() - : requestedCallback?.startsWith(`${baseUrl}/`) - ? requestedCallback - : `${baseUrl}/workspace` + const connectionCompleteUrl = new URL('/oauth/credential-connected', baseUrl) + connectionCompleteUrl.searchParams.set('result', 'connected') + const callbackURL = fromConnectionDraft + ? connectionCompleteUrl.toString() + : requestedCallback?.startsWith(`${baseUrl}/`) + ? requestedCallback + : `${baseUrl}/workspace` - try { if (!fromConnectionDraft) { try { const connection = await createCredentialConnection.execute({ @@ -90,7 +90,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (error instanceof CredentialConnectionProviderMismatchError) { return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) } - if (credentialId && error instanceof ForbiddenOperationError) { + if ( + credentialId && + error instanceof ForbiddenOperationError && + error.detailCode === 'CREDENTIAL_ADMIN_ACCESS_REQUIRED' + ) { return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) } if (error instanceof OrchestrationError && error.code === 'not_found') { diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts index 664a50bc714..e9aa534852a 100644 --- a/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts @@ -63,11 +63,13 @@ describe('Shopify OAuth callback', () => { mockCompleteShopifyOAuthConnection.mockResolvedValue(undefined) vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue( - new Response(JSON.stringify({ access_token: 'shopify-token', scope: 'read_products' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) + vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ access_token: 'shopify-token', scope: 'read_products' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) ) ) }) @@ -77,6 +79,7 @@ describe('Shopify OAuth callback', () => { userId: 'user-1', shopDomain: SHOP_DOMAIN, draftId: 'draft-from-state', + returnUrl: 'https://sim.test/oauth/credential-connected?result=connected', clientSecret: CLIENT_SECRET, }) @@ -94,4 +97,39 @@ describe('Shopify OAuth callback', () => { 'https://sim.test/oauth/credential-connected?result=connected&shopify_connected=true' ) }) + + it('keeps overlapping flows bound to their own return destinations', async () => { + const firstState = createShopifyOAuthState({ + userId: 'user-1', + shopDomain: SHOP_DOMAIN, + draftId: 'draft-first', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=first', + clientSecret: CLIENT_SECRET, + }) + const secondState = createShopifyOAuthState({ + userId: 'user-1', + shopDomain: SHOP_DOMAIN, + draftId: 'draft-second', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=second', + clientSecret: CLIENT_SECRET, + }) + + const firstResponse = await GET(callbackRequest(firstState)) + const secondResponse = await GET(callbackRequest(secondState)) + + expect(firstResponse.headers.get('location')).toBe( + 'https://sim.test/oauth/credential-connected?flow=first&shopify_connected=true' + ) + expect(secondResponse.headers.get('location')).toBe( + 'https://sim.test/oauth/credential-connected?flow=second&shopify_connected=true' + ) + expect(mockCompleteShopifyOAuthConnection).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ draftId: 'draft-first' }) + ) + expect(mockCompleteShopifyOAuthConnection).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ draftId: 'draft-second' }) + ) + }) }) diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts index bf2907856d2..8447e56d48d 100644 --- a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts @@ -103,7 +103,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_shop`) } - const { draftId } = parseShopifyOAuthState({ + const { draftId, returnUrl } = parseShopifyOAuthState({ state, userId: session.user.id, shopDomain, @@ -154,9 +154,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { signal: request.signal, }) - const returnUrlCookie = request.cookies.get('shopify_return_url')?.value - const redirectUrl = - returnUrlCookie && isSameOrigin(returnUrlCookie) ? returnUrlCookie : `${baseUrl}/workspace` + if (returnUrl && !isSameOrigin(returnUrl)) { + throw new Error('Shopify OAuth state contains an invalid return URL') + } + const redirectUrl = returnUrl ?? `${baseUrl}/workspace` const finalUrl = new URL(redirectUrl) finalUrl.searchParams.set('shopify_connected', 'true') diff --git a/apps/sim/app/api/auth/shopify/authorize/route.test.ts b/apps/sim/app/api/auth/shopify/authorize/route.test.ts index 8829fb01422..2e4ea50a32a 100644 --- a/apps/sim/app/api/auth/shopify/authorize/route.test.ts +++ b/apps/sim/app/api/auth/shopify/authorize/route.test.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ getSession: vi.fn(), requireConfiguredOAuthClient: vi.fn(), + createShopifyOAuthState: vi.fn(), })) vi.mock('@/lib/auth', () => ({ @@ -22,7 +23,7 @@ vi.mock('@/lib/core/utils/urls', () => ({ })) vi.mock('@/lib/oauth/shopify-state', () => ({ - createShopifyOAuthState: () => 'signed-state', + createShopifyOAuthState: mocks.createShopifyOAuthState, })) vi.mock('@/lib/oauth/utils', () => ({ @@ -41,9 +42,10 @@ describe('Shopify authorize route', () => { SHOPIFY_CLIENT_SECRET: 'shopify-secret', }, }) + mocks.createShopifyOAuthState.mockReturnValue('signed-state') }) - it('keeps the post-connect return URL for the full credential draft lifetime', async () => { + it('binds the post-connect return URL to the signed flow state', async () => { const request = createMockRequest( 'GET', undefined, @@ -54,7 +56,25 @@ describe('Shopify authorize route', () => { const response = await GET(request) expect(response.status).toBe(307) - expect(response.headers.get('set-cookie')).toContain('shopify_return_url=') - expect(response.headers.get('set-cookie')).toContain('Max-Age=900') + expect(mocks.createShopifyOAuthState).toHaveBeenCalledWith({ + userId: 'user-1', + shopDomain: 'test-store.myshopify.com', + draftId: 'draft-1', + returnUrl: 'https://sim.test/oauth/credential-connected', + clientSecret: 'shopify-secret', + }) + expect(response.headers.get('set-cookie')).toContain('shopify_return_url=;') + }) + + it('escapes a user-controlled draft id before embedding it in inline script', async () => { + const url = new URL('https://sim.test/api/auth/shopify/authorize') + url.searchParams.set('draftId', '</script><script>document.title=location.origin</script>') + + const response = await GET(createMockRequest('GET', undefined, {}, url.toString())) + const html = await response.text() + + expect(response.status).toBe(200) + expect(html.match(/<script>/g)).toHaveLength(1) + expect(html).toContain('\\u003c/script>\\u003cscript>document.title=location.origin') }) }) diff --git a/apps/sim/app/api/auth/shopify/authorize/route.ts b/apps/sim/app/api/auth/shopify/authorize/route.ts index e49efe20c51..9d9d92675cb 100644 --- a/apps/sim/app/api/auth/shopify/authorize/route.ts +++ b/apps/sim/app/api/auth/shopify/authorize/route.ts @@ -9,7 +9,6 @@ import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { CREDENTIAL_DRAFT_TTL_SECONDS } from '@/lib/credentials/draft-constants' import { createShopifyOAuthState } from '@/lib/oauth/shopify-state' import { getScopesForService } from '@/lib/oauth/utils' @@ -19,6 +18,11 @@ export const dynamic = 'force-dynamic' const SHOPIFY_SCOPES = getScopesForService('shopify').join(',') +/** Serializes user-controlled text without allowing it to terminate an inline script element. */ +function serializeInlineScriptString(value: string): string { + return JSON.stringify(value).replaceAll('<', '\\u003c') +} + export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() @@ -40,8 +44,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!shopDomain) { const safeReturnUrl = returnUrl && isSameOrigin(returnUrl) ? encodeURIComponent(returnUrl) : '' - const returnUrlJsLiteral = JSON.stringify(safeReturnUrl) - const draftIdJsLiteral = JSON.stringify(draftId ?? '') + const returnUrlJsLiteral = serializeInlineScriptString(safeReturnUrl) + const draftIdJsLiteral = serializeInlineScriptString(draftId ?? '') return new NextResponse( `<!DOCTYPE html> <html> @@ -175,11 +179,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const baseUrl = getBaseUrl() const redirectUri = `${baseUrl}/api/auth/oauth2/callback/shopify` + const safeReturnUrl = returnUrl && isSameOrigin(returnUrl) ? returnUrl : undefined const state = createShopifyOAuthState({ userId: session.user.id, shopDomain: cleanShop, draftId, + returnUrl: safeReturnUrl, clientSecret, }) @@ -205,17 +211,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { response.cookies.delete('shopify_shop_domain') response.cookies.delete('shopify_credential_draft_id') - if (returnUrl && isSameOrigin(returnUrl)) { - response.cookies.set('shopify_return_url', returnUrl, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, - path: '/', - }) - } else { - response.cookies.delete('shopify_return_url') - } + response.cookies.delete('shopify_return_url') return response } catch (error) { diff --git a/apps/sim/app/api/credentials/[id]/members/route.test.ts b/apps/sim/app/api/credentials/[id]/members/route.test.ts new file mode 100644 index 00000000000..c7e1d01fb5b --- /dev/null +++ b/apps/sim/app/api/credentials/[id]/members/route.test.ts @@ -0,0 +1,154 @@ +/** + * @vitest-environment node + */ +import { credential } from '@sim/db/schema' +import { + auditMock, + authMockFns, + createMockRequest, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + listMembers: vi.fn(), + removeMember: vi.fn(), + upsertMember: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@/lib/credentials/members', () => ({ + leaveCredentialMembership: vi.fn(), + listCredentialMembers: mocks.listMembers, + listCredentialMembershipsForUser: vi.fn(), + removeCredentialMember: mocks.removeMember, + upsertCredentialMember: mocks.upsertMember, +})) + +import { DELETE, GET, POST } from '@/app/api/credentials/[id]/members/route' + +const CREDENTIAL_ID = 'credential-1' +const WORKSPACE_ID = 'workspace-1' +const routeContext = { params: Promise.resolve({ id: CREDENTIAL_ID }) } +const credentialRow = { + id: CREDENTIAL_ID, + workspaceId: WORKSPACE_ID, + type: 'oauth' as const, + displayName: 'Google account', + description: null, + providerId: 'google-email', + accountId: 'account-1', + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: null, + createdBy: 'user-1', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), +} + +describe('/api/credentials/[id]/members compatibility', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', + }) + mocks.resolvePermission.mockResolvedValue('read') + mocks.listMembers.mockResolvedValue([ + { + id: 'member-1', + userId: 'user-2', + role: 'member', + status: 'active', + joinedAt: new Date('2026-08-02T00:00:00.000Z'), + userName: 'Member', + userEmail: 'member@example.com', + }, + ]) + }) + + it('allows any workspace reader to list the credential roster', async () => { + queueTableRows(credential, [credentialRow]) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + `http://localhost/api/credentials/${CREDENTIAL_ID}/members` + ), + routeContext + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + members: [ + expect.objectContaining({ + id: 'member-1', + joinedAt: '2026-08-02T00:00:00.000Z', + }), + ], + }) + expect(mocks.listMembers).toHaveBeenCalledWith(credentialRow) + }) + + it('conceals an existing credential outside the caller workspace as not found', async () => { + queueTableRows(credential, [credentialRow]) + mocks.resolvePermission.mockResolvedValue(null) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + `http://localhost/api/credentials/${CREDENTIAL_ID}/members` + ), + routeContext + ) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Not found' }) + expect(mocks.listMembers).not.toHaveBeenCalled() + }) + + it('keeps nonexistent POST and DELETE targets behind the uniform admin denial', async () => { + queueTableRows(credential, []) + const postResponse = await POST( + createMockRequest('POST', { userId: 'user-2', role: 'member' }), + routeContext + ) + queueTableRows(credential, []) + const deleteResponse = await DELETE( + createMockRequest( + 'DELETE', + undefined, + {}, + `http://localhost/api/credentials/${CREDENTIAL_ID}/members?userId=user-2` + ), + routeContext + ) + + expect(postResponse.status).toBe(403) + expect(await postResponse.json()).toEqual({ error: 'Admin access required' }) + expect(deleteResponse.status).toBe(403) + expect(await deleteResponse.json()).toEqual({ error: 'Admin access required' }) + }) +}) diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts index 2e47b6b4354..07e15694921 100644 --- a/apps/sim/app/api/credentials/[id]/members/route.ts +++ b/apps/sim/app/api/credentials/[id]/members/route.ts @@ -10,7 +10,8 @@ import { } from '@/lib/api/server/routes' import { credentialValidationParseOptions, - internalCredentialErrorPolicy, + internalCredentialMemberListErrorPolicy, + internalCredentialMemberMutationErrorPolicy, } from '@/lib/credentials/api/route-policies' import { listCredentialMembersUseCase, @@ -26,7 +27,7 @@ export const GET = defineInternalJsonRoute({ auth: internalSessionAuth, operation: credentialOperations.listMembers, rateLimit, - errorPolicy: internalCredentialErrorPolicy, + errorPolicy: internalCredentialMemberListErrorPolicy, parseOptions: credentialValidationParseOptions, mapInput: ({ params }) => ({ credentialId: params.id }), useCase: listCredentialMembersUseCase, @@ -43,7 +44,7 @@ export const POST = defineInternalJsonRoute({ auth: internalSessionAuth, operation: credentialOperations.upsertMember, rateLimit, - errorPolicy: internalCredentialErrorPolicy, + errorPolicy: internalCredentialMemberMutationErrorPolicy, parseOptions: credentialValidationParseOptions, mapInput: ({ params, body }) => ({ credentialId: params.id, ...body }), useCase: upsertCredentialMemberUseCase, @@ -56,7 +57,7 @@ export const DELETE = defineInternalJsonRoute({ auth: internalSessionAuth, operation: credentialOperations.removeMember, rateLimit, - errorPolicy: internalCredentialErrorPolicy, + errorPolicy: internalCredentialMemberMutationErrorPolicy, parseOptions: credentialValidationParseOptions, mapInput: ({ params, query }) => ({ credentialId: params.id, userId: query.userId }), useCase: removeCredentialMemberUseCase, diff --git a/apps/sim/app/api/credentials/route.test.ts b/apps/sim/app/api/credentials/route.test.ts index c9580a98073..f39f6042c6e 100644 --- a/apps/sim/app/api/credentials/route.test.ts +++ b/apps/sim/app/api/credentials/route.test.ts @@ -22,6 +22,7 @@ const { mockGetCredentialCreationWorkspaceContext, mockLoadWorkspace, mockResolveWorkspacePermission, + mockSyncWorkspaceOAuthCredentials, mockVerifyAndBuildServiceAccountSecret, } = vi.hoisted(() => ({ mockCheckWorkspaceAccess: vi.fn(), @@ -29,6 +30,7 @@ const { mockGetCredentialCreationWorkspaceContext: vi.fn(), mockLoadWorkspace: vi.fn(), mockResolveWorkspacePermission: vi.fn(), + mockSyncWorkspaceOAuthCredentials: vi.fn(), mockVerifyAndBuildServiceAccountSecret: vi.fn(), })) @@ -62,7 +64,7 @@ vi.mock('@/lib/credentials/environment', () => ({ })) vi.mock('@/lib/credentials/oauth', () => ({ - syncWorkspaceOAuthCredentialsForUser: vi.fn(), + syncWorkspaceOAuthCredentialsForUser: mockSyncWorkspaceOAuthCredentials, })) vi.mock('@/lib/oauth', () => ({ @@ -145,6 +147,53 @@ describe('GET /api/credentials', () => { }), ]) }) + + it('normalizes padded, blank, and duplicate legacy query values', async () => { + queueTableRows(credential, []) + const url = new URL('http://localhost:3000/api/credentials') + url.searchParams.append('workspaceId', ` ${WORKSPACE_ID} `) + url.searchParams.append('workspaceId', 'not-the-selected-value') + url.searchParams.set('type', '') + url.searchParams.set('providerId', '') + url.searchParams.set('credentialId', ' ') + + const response = await GET(createMockRequest('GET', undefined, {}, url.toString())) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ credentials: [] }) + expect(mockLoadWorkspace).toHaveBeenCalledWith(WORKSPACE_ID) + }) + + it('uses the legacy workspace-scoped id/account lookup without sync, filters, or shape drift', async () => { + queueTableRows(credential, []) + queueTableRows(credential, [ + { + id: 'credential-1', + displayName: 'Google account', + type: 'oauth', + providerId: 'google-email', + }, + ]) + const url = new URL('http://localhost:3000/api/credentials') + url.searchParams.set('workspaceId', WORKSPACE_ID) + url.searchParams.set('credentialId', ' account-1 ') + url.searchParams.set('type', 'env_workspace') + url.searchParams.set('providerId', 'different-provider') + + const response = await GET(createMockRequest('GET', undefined, {}, url.toString())) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + credential: { + id: 'credential-1', + displayName: 'Google account', + type: 'oauth', + providerId: 'google-email', + }, + }) + expect(mockSyncWorkspaceOAuthCredentials).not.toHaveBeenCalled() + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + }) }) describe('POST /api/credentials', () => { diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index a653ff0a7d1..ea3203d398f 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -27,12 +27,10 @@ export const GET = defineInternalJsonRoute({ parseOptions: credentialValidationParseOptions, mapInput: ({ query }) => query, useCase: listInternalCredentials, - present: ({ credentials, credential }) => ({ - credentials: credentials.map((row) => toWorkspaceCredential(row)), - ...(credential !== undefined - ? { credential: credential ? toWorkspaceCredential(credential) : null } - : {}), - }), + present: (result) => + result.mode === 'lookup' + ? { credential: result.credential } + : { credentials: result.credentials.map((row) => toWorkspaceCredential(row)) }, }) export const POST = defineInternalJsonRoute({ diff --git a/apps/sim/connectors/microsoft-excel/microsoft-excel.ts b/apps/sim/connectors/microsoft-excel/microsoft-excel.ts index fc93d0537f4..80b321cdb16 100644 --- a/apps/sim/connectors/microsoft-excel/microsoft-excel.ts +++ b/apps/sim/connectors/microsoft-excel/microsoft-excel.ts @@ -274,7 +274,7 @@ async function fetchWorksheets(accessToken: string, basePath: string): Promise<W worksheets.push(...(data.value ?? [])) const next = data['@odata.nextLink'] - url = next && next.startsWith(GRAPH_API_BASE) ? next : undefined + url = next?.startsWith(GRAPH_API_BASE) ? next : undefined } return worksheets diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index ca094a48c24..8d931e4e20e 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -23,6 +23,7 @@ import { environmentKeys } from '@/hooks/queries/environment' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' import { fetchWorkspaceCredentialList, + requireWorkspaceCredentialListResponse, WORKSPACE_CREDENTIAL_LIST_STALE_TIME, } from '@/hooks/queries/utils/fetch-workspace-credentials' @@ -74,7 +75,7 @@ export function useWorkspaceCredentials(params: { }, signal, }) - return data.credentials ?? [] + return requireWorkspaceCredentialListResponse(data) }, enabled: Boolean(workspaceId) && enabled, staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME, diff --git a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts index bf1dccfe9d3..aae9ce81307 100644 --- a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts +++ b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts @@ -1,8 +1,21 @@ import { requestJson } from '@/lib/api/client/request' -import { listWorkspaceCredentialsContract, type WorkspaceCredential } from '@/lib/api/contracts' +import { + type ContractJsonResponse, + listWorkspaceCredentialsContract, + type WorkspaceCredential, +} from '@/lib/api/contracts' export const WORKSPACE_CREDENTIAL_LIST_STALE_TIME = 60 * 1000 +export function requireWorkspaceCredentialListResponse( + data: ContractJsonResponse<typeof listWorkspaceCredentialsContract> +): WorkspaceCredential[] { + if (!('credentials' in data)) { + throw new Error('Workspace credential list returned a lookup response') + } + return data.credentials +} + /** * Fetches the workspace credential list. * @@ -18,5 +31,5 @@ export async function fetchWorkspaceCredentialList( query: { workspaceId }, signal, }) - return data.credentials ?? [] + return requireWorkspaceCredentialListResponse(data) } diff --git a/apps/sim/hooks/use-oauth-return.ts b/apps/sim/hooks/use-oauth-return.ts index 5b2092c44cb..d49fd84f7ca 100644 --- a/apps/sim/hooks/use-oauth-return.ts +++ b/apps/sim/hooks/use-oauth-return.ts @@ -25,6 +25,7 @@ import { import { getDesktopBridge } from '@/lib/desktop' import { oauthConnectionsKeys } from '@/hooks/queries/oauth/oauth-connections' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' +import { requireWorkspaceCredentialListResponse } from '@/hooks/queries/utils/fetch-workspace-credentials' const OAUTH_CREDENTIAL_UPDATED_EVENT = 'oauth-credentials-updated' const SETTINGS_RETURN_URL_KEY = 'settings-return-url' @@ -39,7 +40,7 @@ async function resolveOAuthMessage(ctx: OAuthReturnContext): Promise<string> { const data = await requestJson(listWorkspaceCredentialsContract, { query: { workspaceId: ctx.workspaceId, type: 'oauth' }, }) - const oauthCredentials = data.credentials ?? [] + const oauthCredentials = requireWorkspaceCredentialListResponse(data) const forProvider = oauthCredentials.filter((c) => c.providerId === ctx.providerId) if (forProvider.length > ctx.preCount) { @@ -97,7 +98,7 @@ async function verifyOAuthChatAttempt(queryClient: QueryClient, attemptId: strin requestJson(listWorkspaceCredentialsContract, { query: { workspaceId: attempt.workspaceId, type: 'oauth' }, signal, - }).then((data) => data.credentials ?? []), + }).then(requireWorkspaceCredentialListResponse), staleTime: 0, }) diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index 25a4189a61e..370a0cbfc69 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -42,11 +42,24 @@ export type WorkspaceCredentialRole = z.output<typeof workspaceCredentialRoleSch export type WorkspaceCredentialMemberStatus = z.output<typeof workspaceCredentialMemberStatusSchema> export type WorkspaceCredential = z.output<typeof workspaceCredentialSchema> +const firstQueryStringSchema = z + .union([z.string(), z.array(z.string()).min(1)]) + .transform((value) => (Array.isArray(value) ? value[0] : value)) + +function trimmedOptionalQueryString<T extends z.ZodType<string, string>>(schema: T) { + return firstQueryStringSchema + .transform((value) => value.trim() || undefined) + .pipe(schema.optional()) + .optional() +} + export const credentialsListQuerySchema = z.object({ - workspaceId: z.string().uuid('Workspace ID must be a valid UUID'), - type: workspaceCredentialTypeSchema.optional(), - providerId: z.string().optional(), - credentialId: z.string().optional(), + workspaceId: firstQueryStringSchema + .transform((value) => value.trim()) + .pipe(z.string().uuid('Workspace ID must be a valid UUID')), + type: trimmedOptionalQueryString(workspaceCredentialTypeSchema), + providerId: trimmedOptionalQueryString(z.string()), + credentialId: trimmedOptionalQueryString(z.string()), }) export const credentialIdParamsSchema = z.object({ @@ -307,6 +320,14 @@ export const oauthCredentialSchema = z.object({ scopes: z.array(z.string()).optional(), }) +export const workspaceCredentialLookupSchema = workspaceCredentialSchema.pick({ + id: true, + displayName: true, + type: true, + providerId: true, +}) +export type WorkspaceCredentialLookup = z.output<typeof workspaceCredentialLookupSchema> + export const oauthCredentialsQuerySchema = z .object({ provider: z.string().nullish(), @@ -325,10 +346,10 @@ export const listWorkspaceCredentialsContract = defineRouteContract({ query: credentialsListQuerySchema, response: { mode: 'json', - schema: z.object({ - credentials: z.array(workspaceCredentialSchema), - credential: workspaceCredentialSchema.nullable().optional(), - }), + schema: z.union([ + z.object({ credentials: z.array(workspaceCredentialSchema) }), + z.object({ credential: workspaceCredentialLookupSchema.nullable() }), + ]), }, }) diff --git a/apps/sim/lib/api/contracts/oauth-connections.test.ts b/apps/sim/lib/api/contracts/oauth-connections.test.ts index db44ebfb81f..8e607b307fc 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.test.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.test.ts @@ -3,11 +3,23 @@ */ import { describe, expect, it } from 'vitest' import { + connectedAccountsQuerySchema, instagramAuthorizeQuerySchema, instagramCallbackQuerySchema, trelloAuthorizeQuerySchema, } from '@/lib/api/contracts/oauth-connections' +describe('Connected account query contracts', () => { + it('preserves first-value and blank-provider normalization', () => { + expect(connectedAccountsQuerySchema.parse({ provider: '' })).toEqual({ + provider: undefined, + }) + expect(connectedAccountsQuerySchema.parse({ provider: ['google', 'slack'] })).toEqual({ + provider: 'google', + }) + }) +}) + describe('Instagram OAuth query contracts', () => { it('accepts bounded authorize and callback values', () => { expect( diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index 74f7afea2c9..26031a1e475 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -30,8 +30,15 @@ export const disconnectOAuthBodySchema = z.object({ accountId: z.string().optional(), }) +const firstQueryStringSchema = z + .union([z.string(), z.array(z.string()).min(1)]) + .transform((value) => (Array.isArray(value) ? value[0] : value)) + export const connectedAccountsQuerySchema = z.object({ - provider: z.string().min(1).optional(), + provider: firstQueryStringSchema + .transform((value) => value || undefined) + .pipe(z.string().min(1).optional()) + .optional(), }) export const connectedAccountSchema = z.object({ diff --git a/apps/sim/lib/credentials/api/route-policies.ts b/apps/sim/lib/credentials/api/route-policies.ts index 2ffb63ae2f2..8e97f55c2ce 100644 --- a/apps/sim/lib/credentials/api/route-policies.ts +++ b/apps/sim/lib/credentials/api/route-policies.ts @@ -4,6 +4,9 @@ import { internalOrchestrationErrorPolicy, } from '@/lib/api/server/routes' import { getValidationErrorMessage, validationErrorResponse } from '@/lib/api/server/validation' +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { CredentialProviderOperationError } from '@/lib/credentials/application/credential-crud' export const credentialValidationParseOptions = { @@ -21,3 +24,31 @@ export const internalCredentialErrorPolicy = extendInternalErrorPolicy( }) } ) + +export const internalCredentialMemberListErrorPolicy = extendInternalErrorPolicy( + internalCredentialErrorPolicy, + (error) => { + if ( + error instanceof NoWorkspaceAccessError || + (error instanceof OrchestrationError && error.code === 'not_found') + ) { + return internalErrorResponse(404, { error: 'Not found' }) + } + return null + } +) + +export const internalCredentialMemberMutationErrorPolicy = extendInternalErrorPolicy( + internalCredentialErrorPolicy, + (error) => { + if ( + error instanceof NoWorkspaceAccessError || + (error instanceof ForbiddenOperationError && + error.detailCode === 'CREDENTIAL_ADMIN_ACCESS_REQUIRED') || + (error instanceof OrchestrationError && error.code === 'not_found') + ) { + return internalErrorResponse(403, { error: 'Admin access required' }) + } + return null + } +) diff --git a/apps/sim/lib/credentials/application/authorized-user-use-case.ts b/apps/sim/lib/credentials/application/authorized-user-use-case.ts index 729bcbddf57..95fd6e08712 100644 --- a/apps/sim/lib/credentials/application/authorized-user-use-case.ts +++ b/apps/sim/lib/credentials/application/authorized-user-use-case.ts @@ -27,7 +27,42 @@ interface CredentialUserUseCaseDefinition<O extends CredentialUserOperation, I, input: I result: R }): CredentialUserAuditEntry | CredentialUserAuditEntry[] + projectErrorAudit?(args: { + principal: SessionPrincipal + input: I + error: unknown + }): CredentialUserAuditEntry | CredentialUserAuditEntry[] | undefined afterSuccess?(args: { principal: SessionPrincipal; input: I; result: R }): void | Promise<void> + afterError?(args: { principal: SessionPrincipal; input: I; error: unknown }): void | Promise<void> +} + +function recordCredentialUserAudit( + principal: SessionPrincipal, + operation: CredentialUserOperation, + projected: CredentialUserAuditEntry | CredentialUserAuditEntry[] | undefined, + request?: OrchestrationRequestContext +): void { + if (!projected) return + const attribution = resolvePrincipalAuditAttribution(principal) + const entries = Array.isArray(projected) ? projected : [projected] + for (const entry of entries) { + recordAudit({ + workspaceId: entry.workspaceId, + actorId: attribution.actorId, + actorName: attribution.actorName, + action: entry.action, + resourceType: entry.resourceType, + resourceId: entry.resourceId, + resourceName: entry.resourceName, + description: entry.description, + metadata: { + ...entry.metadata, + operation: operation.id, + actor: attribution.actor, + }, + request, + }) + } } /** Defines a current-user credential operation that cannot borrow workspace identity. */ @@ -42,32 +77,26 @@ export function defineAuthorizedCredentialUserUseCase< if (principal.kind !== 'session') { throw new OrchestrationError('forbidden', 'Session authentication required') } - const result = await definition.execute({ principal, input, request }) - const projected = definition.projectAudit?.({ principal, input, result }) - if (projected) { - const attribution = resolvePrincipalAuditAttribution(principal) - const entries = Array.isArray(projected) ? projected : [projected] - for (const entry of entries) { - recordAudit({ - workspaceId: entry.workspaceId, - actorId: attribution.actorId, - actorName: attribution.actorName, - action: entry.action, - resourceType: entry.resourceType, - resourceId: entry.resourceId, - resourceName: entry.resourceName, - description: entry.description, - metadata: { - ...entry.metadata, - operation: definition.operation.id, - actor: attribution.actor, - }, - request, - }) - } + try { + const result = await definition.execute({ principal, input, request }) + recordCredentialUserAudit( + principal, + definition.operation, + definition.projectAudit?.({ principal, input, result }), + request + ) + await definition.afterSuccess?.({ principal, input, result }) + return result + } catch (error) { + recordCredentialUserAudit( + principal, + definition.operation, + definition.projectErrorAudit?.({ principal, input, error }), + request + ) + await definition.afterError?.({ principal, input, error }) + throw error } - await definition.afterSuccess?.({ principal, input, result }) - return result }, } } diff --git a/apps/sim/lib/credentials/application/credential-crud.ts b/apps/sim/lib/credentials/application/credential-crud.ts index a78460c291a..cd1d065d1e2 100644 --- a/apps/sim/lib/credentials/application/credential-crud.ts +++ b/apps/sim/lib/credentials/application/credential-crud.ts @@ -20,8 +20,10 @@ import { } from '@/lib/credentials/orchestration' import { type CredentialRow, + findWorkspaceCredentialLookup, listVisibleWorkspaceCredentials, type VisibleWorkspaceCredential, + type WorkspaceCredentialLookup, } from '@/lib/credentials/queries' import { captureServerEvent } from '@/lib/posthog/server' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' @@ -71,10 +73,9 @@ export interface ListInternalCredentialsInput { credentialId?: string } -export interface ListInternalCredentialsResult { - credentials: VisibleWorkspaceCredential[] - credential: VisibleWorkspaceCredential | null | undefined -} +export type ListInternalCredentialsResult = + | { mode: 'list'; credentials: VisibleWorkspaceCredential[] } + | { mode: 'lookup'; credential: WorkspaceCredentialLookup | null } export const listInternalCredentials = defineAuthorizedWorkspaceUseCase({ operation: credentialOperations.listInternal, @@ -85,6 +86,16 @@ export const listInternalCredentials = defineAuthorizedWorkspaceUseCase({ }, authorizationOptions: {}, async execute({ principal, input, context }): Promise<ListInternalCredentialsResult> { + if (input.credentialId) { + return { + mode: 'lookup', + credential: await findWorkspaceCredentialLookup({ + workspaceId: context.workspaceId, + credentialId: input.credentialId, + }), + } + } + const userId = requirePrincipalSubjectUserId(principal) if (!input.type || input.type === 'oauth') { await syncWorkspaceOAuthCredentialsForUser({ workspaceId: context.workspaceId, userId }) @@ -97,13 +108,7 @@ export const listInternalCredentials = defineAuthorizedWorkspaceUseCase({ types: input.type ? [input.type] : undefined, providerId: input.providerId, }) - const lookup = input.credentialId - ? (page.data.find( - (candidate) => - candidate.id === input.credentialId || candidate.accountId === input.credentialId - ) ?? null) - : undefined - return { credentials: input.credentialId ? [] : page.data, credential: lookup } + return { mode: 'list', credentials: page.data } }, }) diff --git a/apps/sim/lib/credentials/application/credential-members.ts b/apps/sim/lib/credentials/application/credential-members.ts index 57ecb2d8796..561ae6a130d 100644 --- a/apps/sim/lib/credentials/application/credential-members.ts +++ b/apps/sim/lib/credentials/application/credential-members.ts @@ -1,5 +1,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { requirePrincipalSubjectUserId, type SessionPrincipal } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' @@ -27,7 +28,7 @@ function resolveSessionCredentialContext( return resolveCredentialApplicationContext(input) } -export const listCredentialMembersUseCase = defineAuthorizedCredentialUseCase({ +export const listCredentialMembersUseCase = defineAuthorizedWorkspaceUseCase({ operation: credentialOperations.listMembers, resolveContext: ({ principal, @@ -36,6 +37,7 @@ export const listCredentialMembersUseCase = defineAuthorizedCredentialUseCase({ principal: SessionPrincipal input: CredentialMemberResourceInput }) => resolveSessionCredentialContext(principal, input), + authorizationOptions: {}, async execute({ context }) { return { members: await listCredentialMembers(context.credential) } }, diff --git a/apps/sim/lib/credentials/application/oauth-accounts.test.ts b/apps/sim/lib/credentials/application/oauth-accounts.test.ts new file mode 100644 index 00000000000..bf77fae5662 --- /dev/null +++ b/apps/sim/lib/credentials/application/oauth-accounts.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ +import { account, credential } from '@sim/db/schema' +import { auditMock, auditMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + deleteCredential: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/credentials/orchestration', () => ({ + deleteCredentialRecord: mocks.deleteCredential, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { disconnectOAuthUseCase } from '@/lib/credentials/application/oauth-accounts' + +const firstCredential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth' as const, + displayName: 'First Google account', + description: null, + providerId: 'google-email', + accountId: 'account-1', + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: null, + createdBy: 'user-1', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), +} + +describe('OAuth account application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('audits and captures committed deletions before rethrowing a later failure', async () => { + const secondCredential = { + ...firstCredential, + id: 'credential-2', + displayName: 'Second Google account', + accountId: 'account-2', + } + queueTableRows(account, [{ id: 'account-1' }, { id: 'account-2' }]) + queueTableRows(credential, [firstCredential, secondCredential]) + mocks.deleteCredential + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(new Error('Second credential delete failed')) + + await expect( + disconnectOAuthUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { provider: 'google' }, + }) + ).rejects.toMatchObject({ + name: 'OAuthDisconnectPartialFailureError', + credentials: [firstCredential], + }) + + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledTimes(1) + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'credential.deleted', + resourceId: firstCredential.id, + metadata: expect.objectContaining({ reason: 'oauth_disconnect' }), + }) + ) + expect(mocks.capture).toHaveBeenCalledWith( + 'user-1', + 'credential_deleted', + expect.objectContaining({ + provider_id: 'google-email', + workspace_id: 'workspace-1', + }), + { groups: { workspace: 'workspace-1' } } + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/oauth-accounts.ts b/apps/sim/lib/credentials/application/oauth-accounts.ts index 3c649416f06..ff144a47524 100644 --- a/apps/sim/lib/credentials/application/oauth-accounts.ts +++ b/apps/sim/lib/credentials/application/oauth-accounts.ts @@ -6,6 +6,7 @@ import { disconnectOAuthAccounts, listConnectedAccountsForUser, listOAuthConnectionsForUser, + OAuthDisconnectPartialFailureError, } from '@/lib/credentials/oauth-accounts' import { captureServerEvent } from '@/lib/posthog/server' @@ -44,6 +45,45 @@ export interface DisconnectOAuthInput { accountId?: string } +function projectDeletedCredentialAudit( + credentials: OAuthDisconnectPartialFailureError['credentials'] +) { + return credentials.map((credential) => ({ + workspaceId: credential.workspaceId, + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credential.id, + resourceName: credential.displayName, + description: `Deleted oauth credential "${credential.displayName}" (oauth_disconnect)`, + metadata: { + reason: 'oauth_disconnect', + credentialType: credential.type, + providerId: credential.providerId, + accountId: credential.accountId, + }, + })) +} + +function captureDeletedCredentialEvents( + userId: string, + credentials: OAuthDisconnectPartialFailureError['credentials'], + provider: string, + providerId?: string +): void { + for (const credential of credentials) { + captureServerEvent( + userId, + 'credential_deleted', + { + credential_type: 'oauth', + provider_id: credential.providerId ?? providerId ?? provider, + workspace_id: credential.workspaceId, + }, + { groups: { workspace: credential.workspaceId } } + ) + } +} + export const disconnectOAuthUseCase = defineAuthorizedCredentialUserUseCase({ operation: credentialUserOperations.disconnectOAuth, async execute({ @@ -57,20 +97,7 @@ export const disconnectOAuthUseCase = defineAuthorizedCredentialUserUseCase({ return { ...result, ...input, success: true as const } }, projectAudit: ({ result }) => [ - ...result.credentials.map((credential) => ({ - workspaceId: credential.workspaceId, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credential.id, - resourceName: credential.displayName, - description: `Deleted oauth credential "${credential.displayName}" (oauth_disconnect)`, - metadata: { - reason: 'oauth_disconnect', - credentialType: credential.type, - providerId: credential.providerId, - accountId: credential.accountId, - }, - })), + ...projectDeletedCredentialAudit(result.credentials), { workspaceId: null, action: AuditAction.OAUTH_DISCONNECTED, @@ -81,18 +108,25 @@ export const disconnectOAuthUseCase = defineAuthorizedCredentialUserUseCase({ metadata: { provider: result.provider, providerId: result.providerId }, }, ], + projectErrorAudit: ({ error }) => + error instanceof OAuthDisconnectPartialFailureError + ? projectDeletedCredentialAudit(error.credentials) + : undefined, afterSuccess: ({ principal, result }) => { - for (const credential of result.credentials) { - captureServerEvent( - principal.userId, - 'credential_deleted', - { - credential_type: 'oauth', - provider_id: credential.providerId ?? result.providerId ?? result.provider, - workspace_id: credential.workspaceId, - }, - { groups: { workspace: credential.workspaceId } } - ) - } + captureDeletedCredentialEvents( + principal.userId, + result.credentials, + result.provider, + result.providerId + ) + }, + afterError: ({ principal, input, error }) => { + if (!(error instanceof OAuthDisconnectPartialFailureError)) return + captureDeletedCredentialEvents( + principal.userId, + error.credentials, + input.provider, + input.providerId + ) }, }) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index fbd1f8a5631..bb3e2015187 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -112,15 +112,12 @@ export const credentialOperations = { workspaceApiKey: 'deny', principalKinds: ['session'], }), - listMembers: defineCredentialOperation( - defineWorkspaceOperation({ - id: 'credentials.members.list', - minimumRole: 'read', - workspaceApiKey: 'deny', - principalKinds: ['session'], - }), - 'member' - ), + listMembers: defineWorkspaceOperation({ + id: 'credentials.members.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), upsertMember: defineCredentialOperation( defineWorkspaceOperation({ id: 'credentials.members.upsert', diff --git a/apps/sim/lib/credentials/application/service-account.test.ts b/apps/sim/lib/credentials/application/service-account.test.ts index f4b00811eda..6dc2ece8096 100644 --- a/apps/sim/lib/credentials/application/service-account.test.ts +++ b/apps/sim/lib/credentials/application/service-account.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { auditMock, auditMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -13,9 +14,11 @@ const mocks = vi.hoisted(() => ({ getCredential: vi.fn(), getActor: vi.fn(), delete: vi.fn(), + deleteRecord: vi.fn(), capture: vi.fn(), })) +vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/workspaces/application/workspace-context', () => ({ loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, })) @@ -27,6 +30,7 @@ vi.mock('@sim/platform-authz/workspace', () => ({ vi.mock('@/lib/credentials/orchestration', () => ({ createServiceAccountCredential: mocks.create, deleteConnectionCredential: mocks.delete, + deleteCredentialRecord: mocks.deleteRecord, })) vi.mock('@/lib/credentials/application/provider-catalog', () => ({ listCredentialProviderCatalog: mocks.listCatalog, @@ -93,6 +97,7 @@ describe('credential service-account application operations', () => { }) mocks.listCatalog.mockResolvedValue([{ providerId: 'zoom-service-account' }]) mocks.delete.mockResolvedValue(true) + mocks.deleteRecord.mockResolvedValue(true) mocks.requireProvider.mockReturnValue({ type: 'service_account', providerId: 'zoom-service-account', @@ -275,4 +280,47 @@ describe('credential service-account application operations', () => { expect(result).toEqual({ credential, deleted: false }) }) + + it.each([ + ['env_personal', 'personal'], + ['env_workspace', 'workspace'], + ] as const)('preserves %s deletion audit and analytics dimensions', async (type, label) => { + const envCredential = { + ...credential, + type, + displayName: 'MY_API_KEY', + providerId: null, + envKey: 'MY_API_KEY', + envOwnerUserId: type === 'env_personal' ? 'user-1' : null, + encryptedServiceAccountKey: null, + } + mocks.getCredential.mockResolvedValue(envCredential) + mocks.getActor.mockResolvedValue({ + credential: envCredential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + await deleteCredentialUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: WORKSPACE_ID, credentialId: envCredential.id }, + }) + + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + description: `Deleted ${label} env credential "MY_API_KEY"`, + metadata: expect.objectContaining({ + credentialType: type, + envKey: 'MY_API_KEY', + }), + }) + ) + expect(mocks.capture).toHaveBeenCalledWith( + 'user-1', + 'credential_deleted', + expect.objectContaining({ provider_id: 'MY_API_KEY' }), + expect.anything() + ) + }) }) diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts index c99ac568058..078cb50f04d 100644 --- a/apps/sim/lib/credentials/application/service-account.ts +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -169,22 +169,30 @@ export const deleteCredentialUseCase = defineAuthorizedCredentialUseCase({ : await deleteCredentialRecord({ credential: context.credential, reason }) return { credential: context.credential, deleted } }, - projectAudit: ({ principal, result }) => - result.deleted - ? { - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: result.credential.id, - resourceName: result.credential.displayName, - description: `Deleted ${result.credential.type} credential "${result.credential.displayName}" (${principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete'})`, - metadata: { - reason: principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete', - credentialType: result.credential.type, - providerId: result.credential.providerId, - accountId: result.credential.accountId, - }, - } - : [], + projectAudit: ({ principal, result }) => { + if (!result.deleted) return [] + const reason = principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete' + const description = + result.credential.type === 'env_personal' + ? `Deleted personal env credential "${result.credential.envKey}"` + : result.credential.type === 'env_workspace' + ? `Deleted workspace env credential "${result.credential.envKey}"` + : `Deleted ${result.credential.type} credential "${result.credential.displayName}" (${reason})` + return { + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description, + metadata: { + reason, + credentialType: result.credential.type, + providerId: result.credential.providerId, + accountId: result.credential.accountId, + envKey: result.credential.envKey, + }, + } + }, afterSuccess: ({ principal, context, result }) => { if (!result.deleted) return captureServerEvent( @@ -192,7 +200,8 @@ export const deleteCredentialUseCase = defineAuthorizedCredentialUseCase({ 'credential_deleted', { credential_type: result.credential.type, - provider_id: result.credential.providerId ?? result.credential.id, + provider_id: + result.credential.providerId ?? result.credential.envKey ?? result.credential.id, workspace_id: context.workspaceId, }, { groups: { workspace: context.workspaceId } } diff --git a/apps/sim/lib/credentials/oauth-accounts.ts b/apps/sim/lib/credentials/oauth-accounts.ts index 8322b1f551d..705d926d5bc 100644 --- a/apps/sim/lib/credentials/oauth-accounts.ts +++ b/apps/sim/lib/credentials/oauth-accounts.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { account, credential, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' import { and, desc, eq, inArray, like, or } from 'drizzle-orm' import { decodeJwt } from 'jose' import type { OAuthConnection } from '@/lib/api/contracts/oauth-connections' @@ -99,6 +100,17 @@ export interface DisconnectOAuthAccountsParams { accountId?: string } +export class OAuthDisconnectPartialFailureError extends Error { + constructor( + readonly credentials: Array<typeof credential.$inferSelect>, + cause: unknown + ) { + const error = toError(cause) + super(error.message, { cause: error }) + this.name = 'OAuthDisconnectPartialFailureError' + } +} + export async function disconnectOAuthAccounts(params: DisconnectOAuthAccountsParams) { const accountFilter = params.accountId ? and(eq(account.userId, params.userId), eq(account.id, params.accountId)) @@ -120,16 +132,21 @@ export async function disconnectOAuthAccounts(params: DisconnectOAuthAccountsPar .from(credential) .where(inArray(credential.accountId, targetAccountIds)) const deletedCredentials: typeof credentialRows = [] - for (const credentialRow of credentialRows) { - if (credentialRow.type !== 'oauth') { - throw new Error(`OAuth account ${credentialRow.accountId} owns a non-OAuth credential`) + try { + for (const credentialRow of credentialRows) { + if (credentialRow.type !== 'oauth') { + throw new Error(`OAuth account ${credentialRow.accountId} owns a non-OAuth credential`) + } + const deleted = await deleteCredentialRecord({ + credential: credentialRow, + reason: 'oauth_disconnect', + }) + if (deleted) deletedCredentials.push(credentialRow) } - const deleted = await deleteCredentialRecord({ - credential: credentialRow, - reason: 'oauth_disconnect', - }) - if (deleted) deletedCredentials.push(credentialRow) + await db.delete(account).where(inArray(account.id, targetAccountIds)) + } catch (error) { + if (deletedCredentials.length === 0) throw error + throw new OAuthDisconnectPartialFailureError(deletedCredentials, error) } - await db.delete(account).where(inArray(account.id, targetAccountIds)) return { credentials: deletedCredentials } } diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index b1fdbda821e..fe96e7a28d1 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -42,6 +42,13 @@ export interface VisibleWorkspaceCredential { role: 'admin' | 'member' } +export interface WorkspaceCredentialLookup { + id: string + displayName: string + type: CredentialRow['type'] + providerId: string | null +} + const credentialIdKey = textKey<VisibleWorkspaceCredential>(credential.id, (row) => row.id) /** @@ -280,6 +287,39 @@ export async function getWorkspaceCredential(params: { return row ?? null } +/** Preserves the internal route's legacy id-first, account-id-second lookup semantics. */ +export async function findWorkspaceCredentialLookup(params: { + workspaceId: string + credentialId: string +}): Promise<WorkspaceCredentialLookup | null> { + const projection = { + id: credential.id, + displayName: credential.displayName, + type: credential.type, + providerId: credential.providerId, + } + const [byId] = await db + .select(projection) + .from(credential) + .where( + and(eq(credential.id, params.credentialId), eq(credential.workspaceId, params.workspaceId)) + ) + .limit(1) + if (byId) return byId + + const [byAccountId] = await db + .select(projection) + .from(credential) + .where( + and( + eq(credential.accountId, params.credentialId), + eq(credential.workspaceId, params.workspaceId) + ) + ) + .limit(1) + return byAccountId ?? null +} + /** Canonical credential lookup used before its workspace scope is known. */ export async function getCredentialById(credentialId: string): Promise<CredentialRow | null> { const [row] = await db.select().from(credential).where(eq(credential.id, credentialId)).limit(1) diff --git a/apps/sim/lib/oauth/shopify-state.test.ts b/apps/sim/lib/oauth/shopify-state.test.ts index 61de958536d..058bfca32dc 100644 --- a/apps/sim/lib/oauth/shopify-state.test.ts +++ b/apps/sim/lib/oauth/shopify-state.test.ts @@ -32,17 +32,25 @@ describe('Shopify OAuth state', () => { userId: USER_ID, shopDomain: SHOP_DOMAIN, draftId: 'draft-1', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=first', clientSecret: CLIENT_SECRET, }) const second = createShopifyOAuthState({ userId: USER_ID, shopDomain: SHOP_DOMAIN, draftId: 'draft-2', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=second', clientSecret: CLIENT_SECRET, }) - expect(parse(first)).toEqual({ draftId: 'draft-1' }) - expect(parse(second)).toEqual({ draftId: 'draft-2' }) + expect(parse(first)).toEqual({ + draftId: 'draft-1', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=first', + }) + expect(parse(second)).toEqual({ + draftId: 'draft-2', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=second', + }) }) it('rejects tampered, cross-user, and cross-shop state', () => { diff --git a/apps/sim/lib/oauth/shopify-state.ts b/apps/sim/lib/oauth/shopify-state.ts index 3c10b1d5778..75c7f4b5771 100644 --- a/apps/sim/lib/oauth/shopify-state.ts +++ b/apps/sim/lib/oauth/shopify-state.ts @@ -11,6 +11,7 @@ interface ShopifyOAuthStatePayload { userId: string shopDomain: string draftId?: string + returnUrl?: string issuedAt: number } @@ -18,6 +19,7 @@ interface CreateShopifyOAuthStateParams { userId: string shopDomain: string draftId?: string + returnUrl?: string clientSecret: string } @@ -42,6 +44,8 @@ function isShopifyOAuthStatePayload(value: unknown): value is ShopifyOAuthStateP payload.shopDomain.length > 0 && (payload.draftId === undefined || (typeof payload.draftId === 'string' && payload.draftId.length > 0)) && + (payload.returnUrl === undefined || + (typeof payload.returnUrl === 'string' && payload.returnUrl.length > 0)) && typeof payload.issuedAt === 'number' && Number.isSafeInteger(payload.issuedAt) ) @@ -55,6 +59,7 @@ export function createShopifyOAuthState(params: CreateShopifyOAuthStateParams): userId: params.userId, shopDomain: params.shopDomain, ...(params.draftId ? { draftId: params.draftId } : {}), + ...(params.returnUrl ? { returnUrl: params.returnUrl } : {}), issuedAt: Date.now(), } const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') @@ -65,6 +70,7 @@ export function createShopifyOAuthState(params: CreateShopifyOAuthStateParams): /** Verifies Shopify state integrity, expiry, user ownership, and shop binding. */ export function parseShopifyOAuthState(params: ParseShopifyOAuthStateParams): { draftId?: string + returnUrl?: string } { const [encoded, signature, extra] = params.state.split('.') if (!encoded || !signature || extra !== undefined) { @@ -98,5 +104,8 @@ export function parseShopifyOAuthState(params: ParseShopifyOAuthStateParams): { throw new Error('Shopify OAuth state is expired') } - return decoded.draftId ? { draftId: decoded.draftId } : {} + return { + ...(decoded.draftId ? { draftId: decoded.draftId } : {}), + ...(decoded.returnUrl ? { returnUrl: decoded.returnUrl } : {}), + } } diff --git a/findings.txt b/findings.txt new file mode 100644 index 00000000000..92450d24ace --- /dev/null +++ b/findings.txt @@ -0,0 +1,19 @@ +# Behavior change (resolved) + +- [HIGH][RESOLVED] `apps/sim/app/api/auth/shopify/authorize/route.ts:44` introduced an authenticated reflected-XSS path. Inline script values now escape `<` as a Unicode escape, with a regression test using a closing-script payload. + +- [HIGH][RESOLVED] `apps/sim/app/api/credentials/[id]/members/route.ts:24` changed roster authorization and concealment. Listing is workspace-read authorized again, inaccessible credentials are concealed as `404 Not found`, and missing POST/DELETE targets retain the uniform `403 Admin access required` response. + +- [HIGH][RESOLVED] OAuth disconnect deferred audit and analytics until every destructive step finished. A typed partial-failure now carries committed deletions through the application boundary, which records their audit and PostHog effects before rethrowing the original failure. + +- [MEDIUM][RESOLVED] Shopify return destinations were stored in one browser-wide cookie. Each return URL now travels in its own signed, user/shop-bound state token, and overlapping callbacks are tested independently. + +- [MEDIUM][RESOLVED] Reconnects mapped every forbidden operation to credential denial. Only `CREDENTIAL_ADMIN_ACCESS_REQUIRED` now maps to `credential_access_denied`; workspace-role failures map to `workspace_access_denied`. + +- [MEDIUM][RESOLVED] Draft-backed OAuth launch ran outside the browser redirect error boundary. Launch and target resolution now run inside it, so unknown failures redirect to `/workspace?error=oauth_link_failed`. + +- [MEDIUM][RESOLVED] Credential lookup was folded into filtered listing. The application use case now has a dedicated workspace-authorized, ID-first/account-ID-second lookup branch that skips sync and filters and returns exactly `{ credential }`. + +- [MEDIUM][RESOLVED] Environment deletion lost its per-type audit and analytics projection. Personal/workspace descriptions, `envKey` metadata, and the PostHog provider dimension are restored within the shared use case. + +- [LOW][RESOLVED] Credentials and connected-account queries lost legacy normalization. Their shared contracts now restore trimming/blank handling where previously supported and first-value-wins behavior for duplicate query keys. diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 9cad9e0e847..93851eb59f3 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1110, - zodRoutes: 1110, + totalRoutes: 1111, + zodRoutes: 1111, nonZodRoutes: 0, } as const From dd85f55cee535d081f5557b0723f436cdc30d954 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 17:52:18 -0700 Subject: [PATCH 143/159] fix(cli): confirm before overwriting login profile --- packages/sim-cli/src/commands/auth.test.ts | 132 +++++++++++++++++++- packages/sim-cli/src/commands/auth.ts | 138 +++++++++++++-------- 2 files changed, 218 insertions(+), 52 deletions(-) diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts index a3022c75459..11e2f8b9a08 100644 --- a/packages/sim-cli/src/commands/auth.test.ts +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -1,5 +1,133 @@ -import { describe, expect, it } from 'vitest' -import { profilesCommand } from './auth' +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + buildApprovalUrl: vi.fn(() => 'https://sim.ai/cli/auth?code=ABCD'), + createAuthRequest: vi.fn(() => ({ pairing: 'ABCD', verifier: 'verifier' })), + createInterface: vi.fn(), + listProfiles: vi.fn<() => string[]>(() => []), + pollForKey: vi.fn(async () => ({ + apiKey: 'sim-key', + scope: 'platform' as const, + workspaceBound: false, + workspaceId: 'ws_1', + })), + writeConfigProfile: vi.fn(), + writeCredentialsProfile: vi.fn(), +})) + +vi.mock('node:readline/promises', () => ({ createInterface: mocks.createInterface })) +vi.mock('../auth/device-flow', () => ({ + buildApprovalUrl: mocks.buildApprovalUrl, + createAuthRequest: mocks.createAuthRequest, + pollForKey: mocks.pollForKey, +})) +vi.mock('../config/index', () => ({ + credentialsPath: () => '/tmp/sim-credentials', + deleteProfile: vi.fn(), + listProfiles: mocks.listProfiles, + readCredentialsProfile: vi.fn(() => ({})), + writeConfigProfile: mocks.writeConfigProfile, + writeCredentialsProfile: mocks.writeCredentialsProfile, +})) +vi.mock('../context', () => ({ + profileFrom: () => ({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: null, + workspaceId: null, + output: 'table', + sources: { + endpoint: 'default', + apiKey: 'unset', + workspaceId: 'unset', + output: 'default', + }, + }), +})) + +import { loginCommand, profilesCommand } from './auth' + +const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') + +function setInteractive(value: boolean): void { + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value }) +} + +async function login(...args: string[]): Promise<void> { + const root = new Command('sim').exitOverride() + root.addCommand(loginCommand()) + await root.parseAsync(['node', 'sim', 'login', '--no-browser', ...args]) +} + +describe('login command', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.listProfiles.mockReturnValue([]) + mocks.createInterface.mockReturnValue({ + question: vi.fn(async () => 'yes'), + close: vi.fn(), + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + if (originalIsTTY) Object.defineProperty(process.stdin, 'isTTY', originalIsTTY) + else Reflect.deleteProperty(process.stdin, 'isTTY') + }) + + it('does not prompt when the profile is new', async () => { + setInteractive(false) + await login() + + expect(mocks.createInterface).not.toHaveBeenCalled() + expect(mocks.createAuthRequest).toHaveBeenCalledOnce() + }) + + it('requires --yes before overwriting non-interactively', async () => { + setInteractive(false) + mocks.listProfiles.mockReturnValue(['default']) + + await expect(login()).rejects.toThrow( + 'Profile "default" already exists. Re-run with --yes to overwrite it.' + ) + expect(mocks.createAuthRequest).not.toHaveBeenCalled() + + await login('--yes') + expect(mocks.createAuthRequest).toHaveBeenCalledOnce() + }) + + it('continues only when an interactive overwrite is confirmed', async () => { + setInteractive(true) + mocks.listProfiles.mockReturnValue(['default']) + const question = vi.fn(async () => 'yes') + const close = vi.fn() + mocks.createInterface.mockReturnValue({ question, close }) + + await login() + + expect(question).toHaveBeenCalledWith( + 'Profile "default" already exists. Replace its API key and login defaults? (y/N) ' + ) + expect(close).toHaveBeenCalledOnce() + expect(mocks.createAuthRequest).toHaveBeenCalledOnce() + }) + + it('leaves the profile unchanged when confirmation is declined', async () => { + setInteractive(true) + mocks.listProfiles.mockReturnValue(['default']) + mocks.createInterface.mockReturnValue({ + question: vi.fn(async () => 'no'), + close: vi.fn(), + }) + + await login() + + expect(mocks.createAuthRequest).not.toHaveBeenCalled() + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + }) +}) describe('profiles command', () => { it('accepts the singular profile alias', () => { diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index 3e365a626cd..fa19ed0d789 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process' +import { createInterface } from 'node:readline/promises' import chalk from 'chalk' import { Command } from 'commander' import { @@ -50,71 +51,108 @@ function maskKey(key: string): string { return key.length <= 10 ? '•'.repeat(key.length) : `${key.slice(0, 6)}…${key.slice(-4)}` } +async function confirmProfileOverwrite(profileName: string): Promise<boolean> { + if (!process.stdin.isTTY) { + throw new SimApiError( + `Profile "${profileName}" already exists. Re-run with --yes to overwrite it.`, + 0 + ) + } + + const prompt = createInterface({ input: process.stdin, output: process.stderr }) + try { + const answer = await prompt.question( + `Profile "${profileName}" already exists. Replace its API key and login defaults? (y/N) ` + ) + return answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes' + } finally { + prompt.close() + } +} + export function loginCommand(): Command { return new Command('login') .description('Authorize this terminal and store an API key for the profile') .option('--scope <scope>', 'Key space to mint from: platform or copilot', 'platform') .option('--no-browser', 'Print the URL instead of opening a browser') - .action(async (options: { scope: string; browser: boolean }, command: Command) => { - const profile = profileFrom(command) + .option('-y, --yes', 'Overwrite an existing profile without prompting') + .action( + async (options: { scope: string; browser: boolean; yes?: boolean }, command: Command) => { + const profile = profileFrom(command) - if (options.scope !== 'platform' && options.scope !== 'copilot') { - throw new SimApiError(`Unknown scope "${options.scope}". Use platform or copilot.`, 0) - } - const scope = options.scope as CliAuthScope + if (options.scope !== 'platform' && options.scope !== 'copilot') { + throw new SimApiError(`Unknown scope "${options.scope}". Use platform or copilot.`, 0) + } + const scope = options.scope as CliAuthScope + + if (listProfiles().includes(profile.name) && !options.yes) { + const confirmed = await confirmProfileOverwrite(profile.name) + if (!confirmed) { + console.log(chalk.dim('Login cancelled; the existing profile was not changed.')) + return + } + } - const auth = createAuthRequest() - const url = buildApprovalUrl(profile.endpoint, auth, scope, profile.workspaceId ?? undefined) + const auth = createAuthRequest() + const url = buildApprovalUrl( + profile.endpoint, + auth, + scope, + profile.workspaceId ?? undefined + ) - console.log( - `Signing in to ${chalk.bold(profile.endpoint)} as profile ${chalk.bold(profile.name)}` - ) - console.log(`\nPairing code: ${chalk.bold(auth.pairing)}`) - console.log(chalk.dim('Confirm this code matches what the browser shows before approving.\n')) - console.log(url) - - if (options.browser) openBrowser(url) - console.log(chalk.dim('\nWaiting for approval…')) - - const key = await pollForKey(profile.endpoint, auth) - - if (key.scope !== scope) { - // The approval, not the request, decides the scope. Storing a copilot - // key where a platform key belongs would fail every later call with an - // unexplained 401, so refuse now with the reason. - throw new SimApiError( - `Server issued a ${key.scope} key but this profile needs a ${scope} key. Update the Sim deployment, or run: sim login --scope ${key.scope}`, - 0 + console.log( + `Signing in to ${chalk.bold(profile.endpoint)} as profile ${chalk.bold(profile.name)}` ) - } + console.log(`\nPairing code: ${chalk.bold(auth.pairing)}`) + console.log( + chalk.dim('Confirm this code matches what the browser shows before approving.\n') + ) + console.log(url) - writeCredentialsProfile(profile.name, key.apiKey) + if (options.browser) openBrowser(url) + console.log(chalk.dim('\nWaiting for approval…')) - // The workspace picked in the browser becomes the profile's default, - // whether or not the key is scoped to it. The user chose it by name — - // making them look up its id afterwards would waste the one moment the - // answer was already on screen. - const settings: Record<string, string> = { endpoint: profile.endpoint } - if (key.workspaceId) settings.workspace = key.workspaceId - writeConfigProfile(profile.name, settings) + const key = await pollForKey(profile.endpoint, auth) - console.log(chalk.green(`\n✓ Logged in. Key stored in ${credentialsPath()}`)) - if (key.workspaceBound && key.workspaceId) { - console.log(chalk.dim(` Workspace-scoped key — it can only reach ${key.workspaceId}.`)) - } else if (key.workspaceId) { - console.log( - chalk.dim( - ` Personal key, defaulting to ${key.workspaceId}. Override per command with --workspace.` + if (key.scope !== scope) { + // The approval, not the request, decides the scope. Storing a copilot + // key where a platform key belongs would fail every later call with an + // unexplained 401, so refuse now with the reason. + throw new SimApiError( + `Server issued a ${key.scope} key but this profile needs a ${scope} key. Update the Sim deployment, or run: sim login --scope ${key.scope}`, + 0 ) - ) - } else if (!profile.workspaceId) { - console.log( - chalk.dim( - ' Personal key with no default workspace. Set one with: sim configure --set-workspace <id>' + } + + writeCredentialsProfile(profile.name, key.apiKey) + + // The workspace picked in the browser becomes the profile's default, + // whether or not the key is scoped to it. The user chose it by name — + // making them look up its id afterwards would waste the one moment the + // answer was already on screen. + const settings: Record<string, string> = { endpoint: profile.endpoint } + if (key.workspaceId) settings.workspace = key.workspaceId + writeConfigProfile(profile.name, settings) + + console.log(chalk.green(`\n✓ Logged in. Key stored in ${credentialsPath()}`)) + if (key.workspaceBound && key.workspaceId) { + console.log(chalk.dim(` Workspace-scoped key — it can only reach ${key.workspaceId}.`)) + } else if (key.workspaceId) { + console.log( + chalk.dim( + ` Personal key, defaulting to ${key.workspaceId}. Override per command with --workspace.` + ) ) - ) + } else if (!profile.workspaceId) { + console.log( + chalk.dim( + ' Personal key with no default workspace. Set one with: sim configure --set-workspace <id>' + ) + ) + } } - }) + ) } export function logoutCommand(): Command { From 918968e1cf56022e7239519c4f7cdf2d98cdfa98 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 18:26:22 -0700 Subject: [PATCH 144/159] fix(cli): adapt service-account credential fields --- .../sim-cli/src/commands/credentials.test.ts | 160 ++++++++++++++++++ packages/sim-cli/src/commands/credentials.ts | 148 +++++++++++++++- packages/sim-cli/src/contract/commands.ts | 1 + 3 files changed, 308 insertions(+), 1 deletion(-) diff --git a/packages/sim-cli/src/commands/credentials.test.ts b/packages/sim-cli/src/commands/credentials.test.ts index 023b80849e6..842e8022f58 100644 --- a/packages/sim-cli/src/commands/credentials.test.ts +++ b/packages/sim-cli/src/commands/credentials.test.ts @@ -30,6 +30,16 @@ function program(): Command { return root } +function commandAt(...names: string[]): Command { + let current = program() + for (const name of names) { + const next = current.commands.find((command) => command.name() === name) + if (!next) throw new Error(`Missing command ${names.join(' ')}`) + current = next + } + return current +} + describe('credential connection commands', () => { beforeEach(() => { vi.restoreAllMocks() @@ -44,6 +54,156 @@ describe('credential connection commands', () => { vi.spyOn(console, 'log').mockImplementation(() => {}) }) + it('discovers and validates a service-account provider before creating it', async () => { + mockRequest + .mockReset() + .mockResolvedValueOnce({ + data: [ + { + type: 'service_account', + serviceId: 'zoom-service-account', + providerId: 'zoom-service-account', + name: 'Zoom server-to-server app', + description: 'Connect Zoom.', + providerFamily: 'zoom', + available: true, + docsUrl: 'https://docs.sim.ai/zoom', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Client ID', + required: true, + secret: false, + multiline: false, + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Client secret', + required: true, + secret: true, + multiline: false, + }, + { + id: 'orgId', + label: 'Account ID', + placeholder: 'Account ID', + required: true, + secret: false, + multiline: false, + }, + ], + }, + ], + nextCursor: null, + }) + .mockResolvedValueOnce({ + data: { + id: 'cred_123', + type: 'service_account', + displayName: 'Production Zoom', + description: null, + providerId: 'zoom-service-account', + accountId: null, + hasServiceAccountKey: true, + role: 'admin', + createdAt: '2026-08-12T20:15:00.000Z', + updatedAt: '2026-08-12T20:15:00.000Z', + }, + }) + + await program().parseAsync([ + 'node', + 'sim', + 'credentials', + 'create', + 'zoom-service-account', + '--name', + 'Production Zoom', + '--credentials', + '{"clientId":"client","clientSecret":"secret","orgId":"account"}', + ]) + + expect(mockRequest).toHaveBeenNthCalledWith(1, '/api/v2/credentials/providers', { + method: 'GET', + query: { workspaceId: 'ws_local' }, + }) + expect(mockRequest).toHaveBeenNthCalledWith(2, '/api/v2/credentials', { + method: 'POST', + body: { + workspaceId: 'ws_local', + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Production Zoom', + clientId: 'client', + clientSecret: 'secret', + orgId: 'account', + }, + }) + }) + + it('exposes one provider-shaped credential object instead of every provider secret', () => { + const help = commandAt('credentials', 'create').helpInformation() + + expect(help).toContain('<providerId>') + expect(help).toContain('--credentials <json|@file>') + expect(help).not.toContain('--type') + expect(help).not.toContain('--client-secret') + expect(help).not.toContain('--service-account-json') + }) + + it('rejects missing and unsupported provider fields before creation', async () => { + mockRequest.mockReset().mockResolvedValue({ + data: [ + { + type: 'service_account', + providerId: 'zoom-service-account', + available: true, + requiresClientGeneratedCredentialId: false, + fields: [ + { id: 'clientId', required: true }, + { id: 'clientSecret', required: true }, + { id: 'orgId', required: true }, + ], + }, + ], + nextCursor: null, + }) + + await expect( + program().parseAsync([ + 'node', + 'sim', + 'credentials', + 'create', + 'zoom-service-account', + '--name', + 'Production Zoom', + '--credentials', + '{"clientId":"client","clientSecret":"secret"}', + ]) + ).rejects.toThrow('missing required fields for zoom-service-account: orgId') + expect(mockRequest).toHaveBeenCalledTimes(1) + + mockRequest.mockClear() + await expect( + program().parseAsync([ + 'node', + 'sim', + 'credentials', + 'create', + 'zoom-service-account', + '--name', + 'Production Zoom', + '--credentials', + '{"clientId":"client","clientSecret":"secret","orgId":"account","extra":"no"}', + ]) + ).rejects.toThrow('unsupported field "extra" for zoom-service-account') + expect(mockRequest).toHaveBeenCalledTimes(1) + }) + it('creates and prints a new-provider connection link', async () => { await program().parseAsync([ 'node', diff --git a/packages/sim-cli/src/commands/credentials.ts b/packages/sim-cli/src/commands/credentials.ts index febf540790e..39b7684f978 100644 --- a/packages/sim-cli/src/commands/credentials.ts +++ b/packages/sim-cli/src/commands/credentials.ts @@ -1,7 +1,14 @@ import type { Command } from 'commander' import { clientFrom } from '../context' import type { CommandSpec } from '../contract/types' -import { type CreateCredentialConnectionResponse, V2_OPERATIONS } from '../generated/v2-api' +import { + type CreateCredentialConnectionResponse, + type CreateServiceAccountCredentialResponse, + type ListCredentialProvidersResponse, + V2_OPERATIONS, +} from '../generated/v2-api' +import { SimApiError } from '../http/client' +import { coerce } from '../runtime/request' import { renderResult } from '../runtime/result' const CONNECTION_RESULT: CommandSpec = { @@ -11,7 +18,129 @@ const CONNECTION_RESULT: CommandSpec = { ], } +const SERVICE_ACCOUNT_RESULT: CommandSpec = { + fields: [ + { header: 'id' }, + { header: 'name', path: 'displayName' }, + { header: 'provider', path: 'providerId' }, + { header: 'role' }, + { header: 'created', path: 'createdAt', format: 'timestamp' }, + ], +} + type ConnectionBody = { providerId: string; displayName: string } | { credentialId: string } +type CredentialProvider = ListCredentialProvidersResponse['data'][number] +type ServiceAccountProvider = Extract<CredentialProvider, { type: 'service_account' }> + +interface CreateServiceAccountOptions { + credentials: string + description?: string + id?: string + name: string +} + +function serviceAccountProvider( + providers: CredentialProvider[], + providerId: string +): ServiceAccountProvider { + const provider = providers.find( + (candidate): candidate is ServiceAccountProvider => + candidate.type === 'service_account' && candidate.providerId === providerId + ) + if (!provider) { + throw new SimApiError(`Unknown service-account provider "${providerId}".`, 0) + } + if (!provider.available) { + throw new SimApiError(`Service-account provider "${providerId}" is not available.`, 0) + } + return provider +} + +function credentialValues(provider: ServiceAccountProvider, raw: string): Record<string, string> { + const parsed = coerce(raw, { kind: 'object' }, { json: true }, 'credentials') + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new SimApiError('--credentials must be a JSON object', 0) + } + + const values = parsed as Record<string, unknown> + const fields = new Map(provider.fields.map((field) => [field.id, field])) + for (const [id, value] of Object.entries(values)) { + const field = fields.get(id) + if (!field) { + throw new SimApiError( + `--credentials contains unsupported field "${id}" for ${provider.providerId}.`, + 0 + ) + } + if (typeof value !== 'string' || value.trim().length === 0) { + throw new SimApiError(`--credentials.${id} must be a non-empty string.`, 0) + } + if (field.options && !field.options.some((option) => option.value === value)) { + throw new SimApiError( + `--credentials.${id} must be one of: ${field.options.map((option) => option.value).join(', ')}.`, + 0 + ) + } + } + + const authMethod = typeof values.authMethod === 'string' ? values.authMethod : undefined + const missing = provider.fields + .filter( + (field) => + field.required || + (authMethod !== undefined && field.requiredForAuthMethods?.includes(authMethod)) + ) + .filter((field) => values[field.id] === undefined) + .map((field) => field.id) + if (missing.length > 0) { + throw new SimApiError( + `--credentials is missing required fields for ${provider.providerId}: ${missing.join(', ')}.`, + 0 + ) + } + + return values as Record<string, string> +} + +async function createServiceAccount( + command: Command, + providerId: string, + options: CreateServiceAccountOptions +): Promise<void> { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const discovery = V2_OPERATIONS.listCredentialProviders + const catalog = await client.request<ListCredentialProvidersResponse>(discovery.path, { + method: discovery.method, + query: { workspaceId }, + }) + const provider = serviceAccountProvider(catalog.data, providerId) + if (provider.requiresClientGeneratedCredentialId && !options.id) { + throw new SimApiError(`--id is required for ${providerId}.`, 0) + } + + const credentials = credentialValues(provider, options.credentials) + const operation = V2_OPERATIONS.createServiceAccountCredential + const response = await client.request<CreateServiceAccountCredentialResponse>(operation.path, { + method: operation.method, + body: { + workspaceId, + type: 'service_account', + providerId, + displayName: options.name, + ...(options.description ? { description: options.description } : {}), + ...(options.id ? { id: options.id } : {}), + ...credentials, + }, + }) + + renderResult( + 'createServiceAccountCredential', + profile.output, + response.data, + SERVICE_ACCOUNT_RESULT + ) +} async function createConnectionLink(command: Command, body: ConnectionBody): Promise<void> { const { client, profile } = clientFrom(command) @@ -32,6 +161,23 @@ export function attachCredentialCommands(program: Command): void { const credentials = program.commands.find((command) => command.name() === 'credentials') if (!credentials) throw new Error('The generated credentials command group is missing') + credentials + .command('create <providerId>') + .description('Create a service-account credential using its discovered provider schema') + .requiredOption('--name <displayName>', 'Name shown for the credential in Sim') + .requiredOption( + '--credentials <json|@file>', + 'Provider credentials as JSON (or @path / @- to read a file or stdin)' + ) + .option('--description <description>', 'Optional credential description') + .option( + '--id <credentialId>', + 'Client-generated credential ID when provider discovery requires it' + ) + .action((providerId: string, options: CreateServiceAccountOptions, command: Command) => + createServiceAccount(command, providerId, options) + ) + credentials .command('connect <providerId>') .description('Create a short-lived link for connecting an OAuth provider') diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 8a096d0e2ba..bb1be22821e 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -56,6 +56,7 @@ function moveResource(command: string, resource: string): CommandVariantSpec { */ export const CLI_CONTRACT: CliContract = { createCredentialConnection: { hidden: true }, + createServiceAccountCredential: { hidden: true }, getBillingStatus: { command: 'billing status', allWorkspaces: true, From d7ff17a7a705a1bc9d84ac898112966be1e59f24 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 19:22:41 -0700 Subject: [PATCH 145/159] feat(cli): prompt for secret values --- packages/sim-cli/src/commands/secrets.test.ts | 118 ++++++++++++++++++ packages/sim-cli/src/commands/secrets.ts | 67 ++++++++++ packages/sim-cli/src/contract/commands.ts | 5 +- packages/sim-cli/src/index.ts | 2 + packages/sim-cli/src/runtime/build.test.ts | 18 +-- .../sim-cli/src/terminal/secret-input.test.ts | 89 +++++++++++++ packages/sim-cli/src/terminal/secret-input.ts | 81 ++++++++++++ 7 files changed, 360 insertions(+), 20 deletions(-) create mode 100644 packages/sim-cli/src/commands/secrets.test.ts create mode 100644 packages/sim-cli/src/commands/secrets.ts create mode 100644 packages/sim-cli/src/terminal/secret-input.test.ts create mode 100644 packages/sim-cli/src/terminal/secret-input.ts diff --git a/packages/sim-cli/src/commands/secrets.test.ts b/packages/sim-cli/src/commands/secrets.test.ts new file mode 100644 index 00000000000..b2b2a5d6cf6 --- /dev/null +++ b/packages/sim-cli/src/commands/secrets.test.ts @@ -0,0 +1,118 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../runtime/build' +import { attachSecretCommands } from './secrets' + +const { mockPromptSecret, mockRequest } = vi.hoisted(() => ({ + mockPromptSecret: vi.fn(async () => 'prompted-secret'), + mockRequest: vi.fn(), +})) + +vi.mock('../context', () => ({ + clientFrom: () => ({ + client: { + request: mockRequest, + requireWorkspace: () => 'ws_local', + }, + profile: { + workspaceId: 'ws_local', + output: 'json', + name: 'default', + apiKey: 'key', + }, + }), +})) +vi.mock('../terminal/secret-input', () => ({ promptSecret: mockPromptSecret })) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachSecretCommands(root) + return root +} + +describe('secrets set', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPromptSecret.mockResolvedValue('prompted-secret') + mockRequest.mockResolvedValue({ + data: { + name: 'STRIPE_API_KEY', + scope: 'workspace', + role: 'admin', + createdAt: '2026-08-12T20:15:00.000Z', + updatedAt: '2026-08-12T20:15:00.000Z', + }, + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('prompts when no value flag is supplied', async () => { + await program().parseAsync([ + 'node', + 'sim', + 'secrets', + 'set', + 'STRIPE_API_KEY', + '--scope', + 'workspace', + ]) + + expect(mockPromptSecret).toHaveBeenCalledOnce() + expect(mockRequest).toHaveBeenCalledWith('/api/v2/secrets/STRIPE_API_KEY', { + method: 'PUT', + body: { + workspaceId: 'ws_local', + scope: 'workspace', + value: 'prompted-secret', + }, + }) + }) + + it('accepts --value directly without prompting', async () => { + await program().parseAsync([ + 'node', + 'sim', + 'secrets', + 'set', + 'STRIPE_API_KEY', + '--scope', + 'personal', + '--value', + 'direct-secret', + ]) + + expect(mockPromptSecret).not.toHaveBeenCalled() + expect(mockRequest).toHaveBeenCalledWith('/api/v2/secrets/STRIPE_API_KEY', { + method: 'PUT', + body: { + workspaceId: 'ws_local', + scope: 'personal', + value: 'direct-secret', + }, + }) + }) + + it('keeps --value optional in help and rejects an empty direct value', async () => { + const secrets = program().commands.find((command) => command.name() === 'secrets') + const set = secrets?.commands.find((command) => command.name() === 'set') + if (!set) throw new Error('Missing secrets set command') + expect(set.helpInformation()).toContain('--value <value>') + expect(set.helpInformation()).not.toContain('Set value (required)') + + await expect( + program().parseAsync([ + 'node', + 'sim', + 'secrets', + 'set', + 'STRIPE_API_KEY', + '--scope', + 'workspace', + '--value', + '', + ]) + ).rejects.toThrow('Secret value cannot be empty.') + expect(mockRequest).not.toHaveBeenCalled() + }) +}) diff --git a/packages/sim-cli/src/commands/secrets.ts b/packages/sim-cli/src/commands/secrets.ts new file mode 100644 index 00000000000..922e348c132 --- /dev/null +++ b/packages/sim-cli/src/commands/secrets.ts @@ -0,0 +1,67 @@ +import { type Command, Option } from 'commander' +import { clientFrom } from '../context' +import type { CommandSpec } from '../contract/types' +import { type SetSecretResponse, V2_OPERATIONS } from '../generated/v2-api' +import { resolvePath, SimApiError } from '../http/client' +import { renderResult } from '../runtime/result' +import { promptSecret } from '../terminal/secret-input' + +const MAX_SECRET_LENGTH = 65_536 +const SECRET_SCOPES = ['workspace', 'personal'] as const + +const SECRET_RESULT: CommandSpec = { + fields: [ + { header: 'name' }, + { header: 'scope' }, + { header: 'role' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], +} + +interface SetSecretOptions { + scope: (typeof SECRET_SCOPES)[number] + value?: string +} + +function validateSecretValue(value: string): string { + if (value.length === 0) throw new SimApiError('Secret value cannot be empty.', 0) + if (value.length > MAX_SECRET_LENGTH) { + throw new SimApiError(`Secret value cannot exceed ${MAX_SECRET_LENGTH} characters.`, 0) + } + return value +} + +async function setSecret(name: string, options: SetSecretOptions, command: Command): Promise<void> { + const value = validateSecretValue(options.value ?? (await promptSecret())) + const { client, profile } = clientFrom(command) + const operation = V2_OPERATIONS.setSecret + const response = await client.request<SetSecretResponse>(resolvePath(operation.path, { name }), { + method: operation.method, + body: { + workspaceId: client.requireWorkspace(), + scope: options.scope, + value, + }, + }) + + renderResult('setSecret', profile.output, response.data, SECRET_RESULT) +} + +/** Adds interactive secret entry while preserving an explicit value flag for scripts. */ +export function attachSecretCommands(program: Command): void { + const secrets = program.commands.find((command) => command.name() === 'secrets') + if (!secrets) throw new Error('The generated secrets command group is missing') + + secrets + .command('set <name>') + .description('Create or replace a named secret') + .addOption( + new Option('--scope <scope>', 'Secret ownership scope') + .choices([...SECRET_SCOPES]) + .makeOptionMandatory() + ) + .option('--value <value>', 'Secret value; visible to shell history when supplied directly') + .action((name: string, options: SetSecretOptions, command: Command) => + setSecret(name, options, command) + ) +} diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index bb1be22821e..46c7c65e108 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -120,10 +120,7 @@ export const CLI_CONTRACT: CliContract = { command: 'workflows undeploy', describe: 'Take a workflow out of deployment', }, - setSecret: { - command: 'secrets set', - describe: 'Create or replace a named secret', - }, + setSecret: { hidden: true }, // ─── Destructive single-resource operations ─────────────────────────────── deleteTable: { confirm: 'This deletes the table and all of its rows.' }, diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index eae823591f5..4e26c52ed1c 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -7,6 +7,7 @@ import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './c import { configureCommand } from './commands/configure' import { attachCredentialCommands } from './commands/credentials' import { attachProtocolCommands } from './commands/protocol/index' +import { attachSecretCommands } from './commands/secrets' import { OUTPUT_FORMATS, ProfileConfigError } from './config/index' import { formatApiErrorDetails, SimApiError } from './http/client' import { sanitize } from './output/render' @@ -52,6 +53,7 @@ for (const command of buildGeneratedCommands()) { attachCredentialCommands(program) attachProtocolCommands(program) +attachSecretCommands(program) program.addHelpText( 'after', diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 869bb66ae25..de9d2a939bb 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -443,22 +443,8 @@ describe('commands parsed through commander', () => { expect(help).not.toContain('--no-recursive') }) - it('exposes named secrets separately from connected credentials', async () => { - const [path, options] = await run([ - 'secret', - 'set', - 'ZOHO_API_KEY', - '--scope', - 'workspace', - '--value', - 'test-secret', - ]) - expect(path).toBe('/api/v2/secrets/ZOHO_API_KEY') - expect(options.body).toEqual({ - workspaceId: 'ws_local', - scope: 'workspace', - value: 'test-secret', - }) + it('exposes named secrets separately from connected credentials', () => { + expect(commandAt('secrets', 'list').name()).toBe('list') expect(commandAt('credentials', 'list').name()).toBe('list') }) diff --git a/packages/sim-cli/src/terminal/secret-input.test.ts b/packages/sim-cli/src/terminal/secret-input.test.ts new file mode 100644 index 00000000000..c7068dd6745 --- /dev/null +++ b/packages/sim-cli/src/terminal/secret-input.test.ts @@ -0,0 +1,89 @@ +import { EventEmitter } from 'node:events' +import type { ReadStream } from 'node:tty' +import { describe, expect, it } from 'vitest' +import { promptSecret } from './secret-input' + +class FakeInput extends EventEmitter { + isTTY = true + isRaw = false + paused = true + readonly rawStates: boolean[] = [] + + isPaused(): boolean { + return this.paused + } + + setRawMode(value: boolean): this { + this.isRaw = value + this.rawStates.push(value) + return this + } + + resume(): this { + this.paused = false + return this + } + + pause(): this { + this.paused = true + return this + } +} + +class FakeOutput { + value = '' + + write(value: string): boolean { + this.value += value + return true + } +} + +describe('promptSecret', () => { + it('masks input and restores the terminal before returning it', async () => { + const input = new FakeInput() + const output = new FakeOutput() + const result = promptSecret(input as unknown as ReadStream, output) + + input.emit('keypress', 'hunter2', { name: 'h' }) + input.emit('keypress', '\r', { name: 'return' }) + + await expect(result).resolves.toBe('hunter2') + expect(output.value).toBe('Secret value: *******\n') + expect(input.rawStates).toEqual([true, false]) + expect(input.paused).toBe(true) + }) + + it('handles backspace without revealing the value', async () => { + const input = new FakeInput() + const output = new FakeOutput() + const result = promptSecret(input as unknown as ReadStream, output) + + input.emit('keypress', 'ab', { name: 'a' }) + input.emit('keypress', '', { name: 'backspace' }) + input.emit('keypress', 'c', { name: 'c' }) + input.emit('keypress', '\r', { name: 'return' }) + + await expect(result).resolves.toBe('ac') + expect(output.value).toBe('Secret value: **\b \b*\n') + }) + + it('requires --value when no interactive terminal is available', () => { + const input = new FakeInput() + input.isTTY = false + + expect(() => promptSecret(input as unknown as ReadStream, new FakeOutput())).toThrow( + 'Interactive secret input requires a terminal. Pass --value instead.' + ) + }) + + it('restores the terminal when input is cancelled', async () => { + const input = new FakeInput() + const result = promptSecret(input as unknown as ReadStream, new FakeOutput()) + + input.emit('keypress', '\u0003', { ctrl: true, name: 'c' }) + + await expect(result).rejects.toThrow('Secret input cancelled.') + expect(input.rawStates).toEqual([true, false]) + }) +}) diff --git a/packages/sim-cli/src/terminal/secret-input.ts b/packages/sim-cli/src/terminal/secret-input.ts new file mode 100644 index 00000000000..e8b365767d7 --- /dev/null +++ b/packages/sim-cli/src/terminal/secret-input.ts @@ -0,0 +1,81 @@ +import { emitKeypressEvents, type Key } from 'node:readline' +import type { ReadStream } from 'node:tty' +import { SimApiError } from '../http/client' + +const MAX_SECRET_LENGTH = 65_536 + +interface SecretOutput { + write(value: string): unknown +} + +/** Reads a secret from a TTY while rendering one mask character per entered character. */ +export function promptSecret( + input: ReadStream = process.stdin, + output: SecretOutput = process.stderr +): Promise<string> { + if (!input.isTTY) { + throw new SimApiError('Interactive secret input requires a terminal. Pass --value instead.', 0) + } + + const wasPaused = input.isPaused() + const wasRaw = input.isRaw + let value = '' + let settled = false + + output.write('Secret value: ') + emitKeypressEvents(input) + input.setRawMode(true) + input.resume() + + return new Promise<string>((resolve, reject) => { + const cleanup = () => { + input.removeListener('keypress', onKeypress) + input.setRawMode(wasRaw) + if (wasPaused) input.pause() + } + + const finish = (complete: () => void) => { + if (settled) return + settled = true + output.write('\n') + try { + cleanup() + complete() + } catch (error) { + reject(error) + } + } + + const fail = (message: string) => finish(() => reject(new SimApiError(message, 0))) + + function onKeypress(text: string, key: Key): void { + if (key.ctrl && (key.name === 'c' || key.name === 'd')) { + fail('Secret input cancelled.') + return + } + if (key.name === 'return' || key.name === 'enter') { + if (value.length === 0) fail('Secret value cannot be empty.') + else finish(() => resolve(value)) + return + } + if (key.name === 'backspace') { + const characters = Array.from(value) + if (characters.length > 0) { + characters.pop() + value = characters.join('') + output.write('\b \b') + } + return + } + if (!text || key.ctrl || key.meta || key.name === 'escape') return + if (value.length + text.length > MAX_SECRET_LENGTH) { + fail(`Secret value cannot exceed ${MAX_SECRET_LENGTH} characters.`) + return + } + value += text + output.write('*'.repeat(Array.from(text).length)) + } + + input.on('keypress', onKeypress) + }) +} From 2dca8ba1df5f963c81746c19c2bd40aa74bedf1c Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 23:10:40 -0700 Subject: [PATCH 146/159] fix(cli): harden login and credentialless chat --- .../app/api/knowledge/search/utils.test.ts | 25 ++-- apps/sim/app/api/knowledge/utils.test.ts | 24 ++-- .../routes/v2-resource-concealment.test.ts | 98 -------------- .../server/routes/v2-resource-concealment.ts | 35 ----- apps/sim/lib/copilot/chat/lifecycle.ts | 68 +++++++++- .../sim/lib/copilot/chat/persisted-message.ts | 30 +++++ apps/sim/lib/copilot/chat/post.ts | 39 +----- .../sim/lib/copilot/tool-executor/executor.ts | 84 ++++++++++++ .../tools/handlers/deployment/manage.ts | 6 +- .../management/manage-custom-tool.test.ts | 125 ++++++++--------- .../handlers/management/manage-custom-tool.ts | 51 ++++--- .../management/manage-mcp-tool.test.ts | 53 +++----- apps/sim/lib/copilot/tools/handlers/vfs.ts | 12 ++ .../workflow/edit-workflow/index.test.ts | 11 +- .../server/workflow/edit-workflow/index.ts | 8 +- .../copilot/tools/shared/workflow-utils.ts | 23 +++- apps/sim/lib/logs/fetch-log-detail.ts | 58 +++++++- .../credentials/credential-extractor.ts | 50 ++++++- apps/sim/lib/workflows/execution-admission.ts | 5 +- .../application/share-workspace-file.test.ts | 126 ------------------ packages/sim-cli/src/commands/auth.test.ts | 81 ++++++++--- packages/sim-cli/src/commands/auth.ts | 8 +- scripts/check-source-text.ts | 4 +- 23 files changed, 542 insertions(+), 482 deletions(-) delete mode 100644 apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts delete mode 100644 apps/sim/lib/api/server/routes/v2-resource-concealment.ts delete mode 100644 apps/sim/lib/workspace-files/application/share-workspace-file.test.ts diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 3ab31695330..c5ae91e4c30 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -666,23 +666,17 @@ describe('Knowledge Search Utils', () => { it('should throw error when no API configuration provided', async () => { const { env } = await import('@/lib/core/config/env') Object.keys(env).forEach((key) => delete (env as any)[key]) - // The env object lazily reads process.env, so a developer's local .env - // keys survive the deletion above — stub the direct key empty and fail - // the hosted rotation fallback for hermeticity on any machine. - vi.stubEnv('OPENAI_API_KEY', '') - const apiKeysModule = await import('@/lib/core/config/api-keys') - const rotationSpy = vi.spyOn(apiKeysModule, 'getRotatingApiKey').mockImplementation(() => { - throw new Error('No rotation keys configured') + Object.assign(env, { + OPENAI_API_KEY: undefined, + OPENAI_API_KEY_1: undefined, + OPENAI_API_KEY_2: undefined, + OPENAI_API_KEY_3: undefined, + OPENROUTER_API_KEY: undefined, }) - try { - await expect(generateSearchEmbedding('test query')).rejects.toThrow( - 'OPENAI_API_KEY is not configured' - ) - } finally { - rotationSpy.mockRestore() - vi.unstubAllEnvs() - } + await expect(generateSearchEmbedding('test query')).rejects.toThrow( + 'OPENAI_API_KEY is not configured' + ) }) it('should handle Azure OpenAI API errors properly', async () => { @@ -713,6 +707,7 @@ describe('Knowledge Search Utils', () => { Object.keys(env).forEach((key) => delete (env as any)[key]) Object.assign(env, { OPENAI_API_KEY: 'test-openai-key', + OPENROUTER_API_KEY: undefined, }) mockNextFetchResponse({ diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index df3dc0b9c40..de84bc92aa7 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -363,23 +363,17 @@ describe('Knowledge Utils', () => { it('should throw error when no API configuration provided', async () => { const { env } = await import('@/lib/core/config/env') Object.keys(env).forEach((key) => delete (env as any)[key]) - // The env object lazily reads process.env, so a developer's local .env - // keys survive the deletion above — stub the direct key empty and fail - // the hosted rotation fallback for hermeticity on any machine. - vi.stubEnv('OPENAI_API_KEY', '') - const apiKeysModule = await import('@/lib/core/config/api-keys') - const rotationSpy = vi.spyOn(apiKeysModule, 'getRotatingApiKey').mockImplementation(() => { - throw new Error('No rotation keys configured') + Object.assign(env, { + OPENAI_API_KEY: undefined, + OPENAI_API_KEY_1: undefined, + OPENAI_API_KEY_2: undefined, + OPENAI_API_KEY_3: undefined, + OPENROUTER_API_KEY: undefined, }) - try { - await expect(generateEmbeddings(['test text'])).rejects.toThrow( - 'OPENAI_API_KEY is not configured' - ) - } finally { - rotationSpy.mockRestore() - vi.unstubAllEnvs() - } + await expect(generateEmbeddings(['test text'])).rejects.toThrow( + 'OPENAI_API_KEY is not configured' + ) }) }) }) diff --git a/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts b/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts deleted file mode 100644 index 8224d495182..00000000000 --- a/apps/sim/lib/api/server/routes/v2-resource-concealment.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import type { V2ErrorPolicy } from '@/lib/api/server/routes' -import { - DelegatedWorkspaceAuthorizationError, - InsufficientWorkspacePermissionsError, - PersonalApiKeysDisabledError, - PrincipalKindAuthorizationError, - WorkspaceApiKeyAuthorizationError, -} from '@/lib/core/application' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' -import { v2TableErrorPolicies } from '@/lib/table/api/route-policies' -import { v2WorkflowErrorPolicies } from '@/lib/workflows/api/route-policies' -import { v2FileErrorPolicies } from '@/lib/workspace-files/api/route-policies' - -const policies: Array<{ - domain: string - policy: V2ErrorPolicy - notFoundMessage: string -}> = [ - { - domain: 'file', - policy: v2FileErrorPolicies.concealResourceAuthorization, - notFoundMessage: 'File not found', - }, - { - domain: 'workflow', - policy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, - notFoundMessage: 'Workflow not found', - }, - { - domain: 'workflow run', - policy: v2WorkflowErrorPolicies.concealRunAuthorization, - notFoundMessage: 'Run not found', - }, - { - domain: 'table', - policy: v2TableErrorPolicies.concealTableAuthorization, - notFoundMessage: 'Table not found', - }, - { - domain: 'table import', - policy: v2TableErrorPolicies.concealImportAuthorization, - notFoundMessage: 'Table import not found', - }, - { - domain: 'table export', - policy: v2TableErrorPolicies.concealExportAuthorization, - notFoundMessage: 'Table export not found', - }, - { - domain: 'knowledge base', - policy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization, - notFoundMessage: 'Knowledge base not found', - }, -] - -const resourceAuthorizationErrors = [ - new InsufficientWorkspacePermissionsError(), - new WorkspaceApiKeyAuthorizationError(), - new DelegatedWorkspaceAuthorizationError(), - new PrincipalKindAuthorizationError('workspace_api_key', 'resources.read'), -] - -describe.each(policies)('$domain resource concealment', ({ policy, notFoundMessage }) => { - it.each(resourceAuthorizationErrors)( - 'conceals typed resource authorization: %s', - async (error) => { - const response = policy.render(error) - expect(response?.status).toBe(404) - await expect(response?.json()).resolves.toEqual({ - error: { code: 'NOT_FOUND', message: notFoundMessage }, - }) - } - ) - - it('preserves workspace personal-key policy denial as forbidden', async () => { - const response = policy.render(new PersonalApiKeysDisabledError()) - expect(response?.status).toBe(403) - await expect(response?.json()).resolves.toEqual({ - error: { - code: 'FORBIDDEN', - message: 'Personal API keys are not allowed for this workspace', - }, - }) - }) - - it('preserves unrelated forbidden business failures', async () => { - const response = policy.render(new OrchestrationError('forbidden', 'Business rule denied')) - expect(response?.status).toBe(403) - await expect(response?.json()).resolves.toEqual({ - error: { code: 'FORBIDDEN', message: 'Business rule denied' }, - }) - }) -}) diff --git a/apps/sim/lib/api/server/routes/v2-resource-concealment.ts b/apps/sim/lib/api/server/routes/v2-resource-concealment.ts deleted file mode 100644 index 6df54519f19..00000000000 --- a/apps/sim/lib/api/server/routes/v2-resource-concealment.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { V2ErrorPolicy } from '@/lib/api/server/routes/v2-json-route' -import { - DelegatedWorkspaceAuthorizationError, - InsufficientWorkspacePermissionsError, - PrincipalKindAuthorizationError, - WorkspaceApiKeyAuthorizationError, -} from '@/lib/core/application' -import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' - -type V2ErrorRenderer = V2ErrorPolicy['render'] - -function isResourceAuthorizationError(error: unknown): boolean { - return ( - error instanceof DelegatedWorkspaceAuthorizationError || - error instanceof InsufficientWorkspacePermissionsError || - error instanceof PrincipalKindAuthorizationError || - error instanceof WorkspaceApiKeyAuthorizationError - ) -} - -/** Conceals only typed resource-authorization failures without hiding workspace policy denials. */ -export function createV2ResourceConcealmentPolicy(options: { - notFoundMessage: string - render?: V2ErrorRenderer -}): V2ErrorPolicy { - const render = options.render ?? v2CaughtOrchestrationError - return { - render(error) { - if (isResourceAuthorizationError(error)) { - return v2Error('NOT_FOUND', options.notFoundMessage) - } - return render(error) - }, - } -} diff --git a/apps/sim/lib/copilot/chat/lifecycle.ts b/apps/sim/lib/copilot/chat/lifecycle.ts index 69b577a31e9..381a227ed75 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.ts @@ -6,7 +6,11 @@ import { getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' import { and, asc, eq, isNull, sql } from 'drizzle-orm' -import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message' +import { + collectChatMcpServerIds, + type PersistedMessage, + stripToolResultOutput, +} from '@/lib/copilot/chat/persisted-message' import { assertActiveWorkspaceAccess, checkWorkspaceAccess, @@ -35,6 +39,11 @@ const copilotChatAuthColumns = { type: copilotChats.type, } as const +const copilotChatContinuationColumns = { + ...copilotChatAuthColumns, + title: copilotChats.title, +} as const + /** * Column set for chat-detail callers that need chat metadata. The conversation * transcript is no longer selected from `copilot_chats.messages` (JSONB) — @@ -103,6 +112,12 @@ type CopilotChatAuthRow = Pick< 'id' | 'userId' | 'workflowId' | 'workspaceId' | 'type' > +export type CopilotChatContinuationMetadata = CopilotChatAuthRow & { + title: string | null + hasMessages: boolean + mcpServerIds: string[] +} + export type CopilotChatDetailRow = Pick< typeof copilotChats.$inferSelect, | 'id' @@ -181,6 +196,57 @@ export async function getAccessibleCopilotChatAuth( return authorizeCopilotChatRow(chat, chatId, userId) } +/** + * Loads only the authorized metadata needed to continue a persisted chat. The + * one-row existence probe preserves first-turn title behavior, while the MCP + * query projects only user-message context arrays. Assistant/tool content is + * never loaded or normalized. + */ +export async function getAccessibleCopilotChatContinuationMetadata( + chatId: string, + userId: string +): Promise<CopilotChatContinuationMetadata | null> { + const [chat] = await db + .select(copilotChatContinuationColumns) + .from(copilotChats) + .where(ownedLiveChatWhere(chatId, userId)) + .limit(1) + + const authorized = await authorizeCopilotChatRow(chat, chatId, userId) + if (!authorized) return null + + const [message] = await db + .select({ id: copilotMessages.id }) + .from(copilotMessages) + .where(and(eq(copilotMessages.chatId, chatId), isNull(copilotMessages.deletedAt))) + .limit(1) + + if (!message) return { ...authorized, hasMessages: false, mcpServerIds: [] } + + const contextRows = await db + .select({ contexts: sql<unknown>`${copilotMessages.content} -> 'contexts'` }) + .from(copilotMessages) + .where( + and( + eq(copilotMessages.chatId, chatId), + eq(copilotMessages.role, 'user'), + isNull(copilotMessages.deletedAt), + sql`${copilotMessages.content} ? 'contexts'` + ) + ) + .orderBy( + sql`${copilotMessages.seq} asc nulls last`, + asc(copilotMessages.createdAt), + asc(copilotMessages.id) + ) + + return { + ...authorized, + hasMessages: true, + mcpServerIds: collectChatMcpServerIds(contextRows), + } +} + /** * Load a copilot chat row for the legacy chat detail endpoint, including the * transcript plus `model` and `config`. Drops `previewYaml` diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index 54b66e90c95..b0efff5600f 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -127,6 +127,36 @@ export interface PersistedMessage { contexts?: PersistedMessageContext[] } +/** + * Collect the append-only MCP enablement carried by explicitly tagged user + * message contexts. Only ids move between turns: inherited contexts are not + * re-expanded into the prompt or persisted again as chips on later messages. + */ +export function collectChatMcpServerIds( + conversationHistory: readonly unknown[], + currentContexts?: unknown +): string[] { + const serverIds = new Set<string>() + + const collect = (contexts: unknown) => { + if (!Array.isArray(contexts)) return + for (const context of contexts) { + if (!context || typeof context !== 'object') continue + const { kind, serverId } = context as { kind?: unknown; serverId?: unknown } + if (kind === 'mcp' && typeof serverId === 'string' && serverId) { + serverIds.add(serverId) + } + } + } + + for (const message of conversationHistory) { + collect((message as { contexts?: unknown } | null)?.contexts) + } + collect(currentContexts) + + return Array.from(serverIds) +} + /** * Drop persisted tool outputs, keeping `success` and `error`. The one narrow * UI-state exception is a browser takeover's user-authored instruction, which diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 0bde82a6c45..187d3d8c9aa 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -16,6 +16,7 @@ import { buildCopilotRequestPayload } from '@/lib/copilot/chat/payload' import { buildPersistedAssistantMessage, buildPersistedUserMessage, + collectChatMcpServerIds, withStoppedContentBlock, } from '@/lib/copilot/chat/persisted-message' import { @@ -416,44 +417,6 @@ function normalizeContexts(contexts: UnifiedChatRequest['contexts']) { }) } -/** - * An MCP server tagged with `/name` stays enabled for the rest of the chat, not - * just the turn it was tagged on. Persisted user messages already carry their - * `mcp` contexts, so the transcript is the source of truth — enablement survives - * reloads and reopened chats with no extra state to keep in sync. There is - * deliberately no off switch: history is append-only. - * - * Only the ids travel forward, not the contexts themselves. The tools ride the - * tool array on every turn, so the model always sees their names and schemas; - * re-expanding the prompt listing each turn would just duplicate that. Keeping - * inherited servers out of the persisted contexts also keeps the `/name` chips - * on a sent message showing only what the user actually typed that turn. - */ -function collectChatMcpServerIds( - conversationHistory: unknown[], - currentContexts: UnifiedChatRequest['contexts'] -): string[] { - const serverIds = new Set<string>() - - const collect = (contexts: unknown) => { - if (!Array.isArray(contexts)) return - for (const ctx of contexts) { - if (!ctx || typeof ctx !== 'object') continue - const { kind, serverId } = ctx as { kind?: unknown; serverId?: unknown } - if (kind === 'mcp' && typeof serverId === 'string' && serverId) { - serverIds.add(serverId) - } - } - } - - for (const message of conversationHistory) { - collect((message as { contexts?: unknown } | null)?.contexts) - } - collect(currentContexts) - - return Array.from(serverIds) -} - async function resolveAgentContexts(params: { contexts?: UnifiedChatRequest['contexts'] resourceAttachments?: UnifiedChatRequest['resourceAttachments'] diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index dc2489efe61..cee96483ecf 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -3,6 +3,7 @@ import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/wo import { toError } from '@sim/utils/errors' import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' +import { isCustomTool, isMcpTool } from '@/executor/constants' import { executeTool as executeAppTool } from '@/tools' import { getToolEntry, isClientExecuted, isKnownTool, isSimExecuted } from './router' import type { ToolExecutionContext, ToolExecutionResult, ToolHandler } from './types' @@ -11,6 +12,24 @@ const logger = createLogger('ToolExecutor') const FUNCTION_EXECUTE_TOOL_ID = 'function_execute' const DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS = 10 const MILLISECONDS_PER_SECOND = 1000 +const QUERY_ONLY_TOOL_IDS = new Set([ + 'grep', + 'glob', + 'read', + 'get_block_outputs', + 'get_block_upstream_references', + 'get_deployed_workflow_state', + 'search_knowledge_base', + 'query_user_table', + 'get_platform_actions', +]) +const CREDENTIALLESS_DENIED_TOOL_IDS = new Set([ + 'generate_api_key', + 'list_user_workspaces', + 'manage_credential', + 'oauth_get_auth_link', + 'oauth_request_access', +]) const handlerRegistry = new Map<string, ToolHandler>() @@ -37,6 +56,48 @@ export async function executeTool( params: Record<string, unknown>, context: ToolExecutionContext ): Promise<ToolExecutionResult> { + if ( + context.workspaceId && + isKnownTool(toolId) && + hasWorkspaceScopeMismatch(params, context.workspaceId) + ) { + return { + success: false, + error: 'Tool denied: requested workspace does not match the current workspace.', + } + } + + if (context.queryOnly && !QUERY_ONLY_TOOL_IDS.has(toolId)) { + return { + success: false, + error: `Tool denied: ${toolId} is not available in query-only mode.`, + } + } + + if ( + context.secretActorUserId === null && + (CREDENTIALLESS_DENIED_TOOL_IDS.has(toolId) || + isMcpTool(toolId) || + (!isKnownTool(toolId) && !isCustomTool(toolId))) + ) { + return { + success: false, + error: `Tool denied: ${toolId} is not available without credential access.`, + } + } + + if ( + context.secretActorUserId === null && + toolId === 'set_environment_variables' && + params.scope === 'personal' + ) { + return { + success: false, + error: + 'Tool denied: personal environment variables are not available without credential access.', + } + } + const requiredPermission = getToolEntry(toolId)?.requiredPermission if ( requiredPermission && @@ -62,7 +123,9 @@ export async function executeTool( (isSimExecuted(toolId) || (isClientExecuted(toolId) && hasHandler(toolId))) if (!canUseRegisteredHandler) { const appParams = buildAppToolParams(normalizedParams, context) + const signal = context.abortSignal ?? context.userStopSignal const options = { + ...(signal ? { signal } : {}), ...(context.resolvedSecretTraceRegistry ? { resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry } : {}), @@ -108,6 +171,27 @@ export async function executeTool( } } +function hasWorkspaceScopeMismatch(params: Record<string, unknown>, workspaceId: string): boolean { + const payload = + typeof params.payload === 'object' && params.payload !== null + ? (params.payload as Record<string, unknown>) + : undefined + const suppliedContext = + typeof params._context === 'object' && params._context !== null + ? (params._context as Record<string, unknown>) + : undefined + const candidates = [ + params.workspaceId, + params.workspace_id, + payload?.workspaceId, + payload?.workspace_id, + suppliedContext?.workspaceId, + suppliedContext?.workspace_id, + ] + + return candidates.some((candidate) => typeof candidate === 'string' && candidate !== workspaceId) +} + function normalizeToolParams( toolId: string, params: Record<string, unknown>, diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index 0ec758912f6..8906621ada9 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -5,6 +5,7 @@ import { messageForCopilotWorkflowError, } from '@/lib/copilot/application/execute-workflow-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { projectWorkflowStateForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' import { generateRequestId } from '@/lib/core/utils/request' import { createWorkflowMcpDeploymentServer, @@ -326,9 +327,12 @@ export async function executeDiffWorkflows( } ) const [side1, side2] = references + const projection = { secretless: context.secretActorUserId === null } + const state1 = projectWorkflowStateForCopilot(side1.state, projection) + const state2 = projectWorkflowStateForCopilot(side2.state, projection) // ref1 = base/previous, ref2 = target/current: added = present in ref2 only. - const summary = generateWorkflowDiffSummary(side2.state, side1.state) + const summary = generateWorkflowDiffSummary(state2, state1) const diff = { ...summary, modifiedBlocks: summary.modifiedBlocks.map((block) => ({ diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.test.ts index ee5625247d4..ba18bc1c0fb 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.test.ts @@ -4,50 +4,32 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - deleteCustomTool, - deleteWorkspaceCustomTool, - getCustomToolById, - getWorkspaceCustomTool, - listCustomTools, - listWorkspaceCustomTools, - updateWorkspaceCustomTool, - upsertCustomTools, -} = vi.hoisted(() => ({ - deleteCustomTool: vi.fn(), - deleteWorkspaceCustomTool: vi.fn(), - getCustomToolById: vi.fn(), - getWorkspaceCustomTool: vi.fn(), - listCustomTools: vi.fn(), - listWorkspaceCustomTools: vi.fn(), - updateWorkspaceCustomTool: vi.fn(), - upsertCustomTools: vi.fn(), -})) - -vi.mock('@sim/audit', () => ({ - AuditAction: { - CUSTOM_TOOL_CREATED: 'created', - CUSTOM_TOOL_UPDATED: 'updated', - CUSTOM_TOOL_DELETED: 'deleted', +const mocks = vi.hoisted(() => ({ + executeCopilotCustomToolUseCase: vi.fn(), + useCases: { + deleteAvailable: { operation: { id: 'custom_tools.delete_available' } }, + deleteWorkspace: { operation: { id: 'custom_tools.delete' } }, + listAvailable: { operation: { id: 'custom_tools.list_available' } }, + listWorkspace: { operation: { id: 'custom_tools.list' } }, + saveWorkspace: { operation: { id: 'custom_tools.save' } }, + updateAvailable: { operation: { id: 'custom_tools.update_available' } }, + updateWorkspace: { operation: { id: 'custom_tools.update' } }, }, - AuditResourceType: { CUSTOM_TOOL: 'custom_tool' }, - recordAudit: vi.fn(), })) -vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) -vi.mock('@/lib/copilot/tools/permissions', () => ({ - copilotToolCanWrite: vi.fn(() => true), - copilotWriteDeniedMessage: vi.fn(), + +vi.mock('@/lib/copilot/application/execute-custom-tool-use-case', () => ({ + executeCopilotCustomToolUseCase: mocks.executeCopilotCustomToolUseCase, })) -vi.mock('@/lib/workflows/custom-tools/operations', () => ({ - deleteCustomTool, - deleteWorkspaceCustomTool, - getCustomToolById, - getWorkspaceCustomTool, - listCustomTools, - listWorkspaceCustomTools, - updateWorkspaceCustomTool, - upsertCustomTools, +vi.mock('@/lib/custom-tools/application/use-cases', () => ({ + deleteAvailableCustomToolUseCase: mocks.useCases.deleteAvailable, + deleteWorkspaceCustomToolUseCase: mocks.useCases.deleteWorkspace, + listAvailableCustomToolsUseCase: mocks.useCases.listAvailable, + listWorkspaceCustomToolsUseCase: mocks.useCases.listWorkspace, + saveWorkspaceCustomToolUseCase: mocks.useCases.saveWorkspace, + updateAvailableCustomToolUseCase: mocks.useCases.updateAvailable, + updateWorkspaceCustomToolUseCase: mocks.useCases.updateWorkspace, })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) import { executeManageCustomTool } from './manage-custom-tool' @@ -57,31 +39,43 @@ const CREDENTIALLESS_CONTEXT = { workspaceId: 'ws-1', userPermission: 'admin', secretActorUserId: null, + toolCallId: 'tool-call-1', + copilotToolExecution: true, } describe('manage_custom_tool credentialless workspace scope', () => { beforeEach(() => vi.clearAllMocks()) - it('lists workspace tools without including legacy personal tools', async () => { - listWorkspaceCustomTools.mockResolvedValue([{ id: 'tool-1', title: 'Shared tool' }]) + it('lists only workspace tools through the authorized application use case', async () => { + const tools = [{ id: 'tool-1', title: 'Shared tool' }] + mocks.executeCopilotCustomToolUseCase.mockResolvedValue({ tools }) const result = await executeManageCustomTool({ operation: 'list' }, CREDENTIALLESS_CONTEXT) - expect(result.success).toBe(true) - expect(listWorkspaceCustomTools).toHaveBeenCalledWith({ workspaceId: 'ws-1' }) - expect(listCustomTools).not.toHaveBeenCalled() + expect(result).toMatchObject({ + success: true, + output: { tools, count: 1 }, + }) + expect(mocks.executeCopilotCustomToolUseCase).toHaveBeenCalledWith( + CREDENTIALLESS_CONTEXT, + mocks.useCases.listWorkspace, + { workspaceId: 'ws-1', limit: 100 } + ) + expect(mocks.executeCopilotCustomToolUseCase).not.toHaveBeenCalledWith( + expect.anything(), + mocks.useCases.listAvailable, + expect.anything() + ) }) - it('edits and deletes through workspace-scoped operations', async () => { - const existing = { + it('edits and deletes through workspace-scoped application use cases', async () => { + const tool = { id: 'tool-1', title: 'Shared tool', schema: { type: 'function', function: { name: 'shared_tool', parameters: {} } }, code: 'return 1', } - getWorkspaceCustomTool.mockResolvedValue(existing) - updateWorkspaceCustomTool.mockResolvedValue(existing) - deleteWorkspaceCustomTool.mockResolvedValue(true) + mocks.executeCopilotCustomToolUseCase.mockResolvedValue({ tool }) const edit = await executeManageCustomTool( { operation: 'edit', toolId: 'tool-1', code: 'return 2' }, @@ -94,18 +88,27 @@ describe('manage_custom_tool credentialless workspace scope', () => { expect(edit.success).toBe(true) expect(remove.success).toBe(true) - expect(getWorkspaceCustomTool).toHaveBeenCalledWith({ - toolId: 'tool-1', - workspaceId: 'ws-1', - }) - expect(updateWorkspaceCustomTool).toHaveBeenCalledWith( + expect(mocks.executeCopilotCustomToolUseCase).toHaveBeenNthCalledWith( + 1, + CREDENTIALLESS_CONTEXT, + mocks.useCases.updateWorkspace, expect.objectContaining({ toolId: 'tool-1', workspaceId: 'ws-1', code: 'return 2' }) ) - expect(deleteWorkspaceCustomTool).toHaveBeenCalledWith({ - toolId: 'tool-1', - workspaceId: 'ws-1', - }) - expect(getCustomToolById).not.toHaveBeenCalled() - expect(deleteCustomTool).not.toHaveBeenCalled() + expect(mocks.executeCopilotCustomToolUseCase).toHaveBeenNthCalledWith( + 2, + CREDENTIALLESS_CONTEXT, + mocks.useCases.deleteWorkspace, + { toolId: 'tool-1', workspaceId: 'ws-1', source: 'tool_input' } + ) + expect(mocks.executeCopilotCustomToolUseCase).not.toHaveBeenCalledWith( + expect.anything(), + mocks.useCases.updateAvailable, + expect.anything() + ) + expect(mocks.executeCopilotCustomToolUseCase).not.toHaveBeenCalledWith( + expect.anything(), + mocks.useCases.deleteAvailable, + expect.anything() + ) }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts index a0c5cbb9089..9a615bb3d59 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts @@ -5,9 +5,12 @@ import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/typ import { asOrchestrationError } from '@/lib/core/orchestration/types' import { deleteAvailableCustomToolUseCase, + deleteWorkspaceCustomToolUseCase, listAvailableCustomToolsUseCase, + listWorkspaceCustomToolsUseCase, saveWorkspaceCustomToolUseCase, updateAvailableCustomToolUseCase, + updateWorkspaceCustomToolUseCase, } from '@/lib/custom-tools/application/use-cases' import { captureServerEvent } from '@/lib/posthog/server' @@ -48,6 +51,7 @@ export async function executeManageCustomTool( * caught it. Matches manage_mcp_tool and manage_skill. */ const workspaceId = context.workspaceId + const secretless = context.secretActorUserId === null if (!operation) { return { success: false, error: "Missing required 'operation' argument" } @@ -56,11 +60,14 @@ export async function executeManageCustomTool( try { if (operation === 'list') { if (!workspaceId) return { success: false, error: 'workspaceId is required' } - const { tools: toolsForUser } = await executeCopilotCustomToolUseCase( - context, - listAvailableCustomToolsUseCase, - { workspaceId } - ) + const { tools: toolsForUser } = secretless + ? await executeCopilotCustomToolUseCase(context, listWorkspaceCustomToolsUseCase, { + workspaceId, + limit: 100, + }) + : await executeCopilotCustomToolUseCase(context, listAvailableCustomToolsUseCase, { + workspaceId, + }) return { success: true, @@ -144,18 +151,17 @@ export async function executeManageCustomTool( } } - const { tool } = await executeCopilotCustomToolUseCase( - context, - updateAvailableCustomToolUseCase, - { - workspaceId, - toolId: params.toolId, - title: params.title || params.schema?.function?.name, - schema: params.schema, - code: params.code, - source: 'tool_input', - } - ) + const input = { + workspaceId, + toolId: params.toolId, + title: params.title || params.schema?.function?.name, + schema: params.schema, + code: params.code, + source: 'tool_input' as const, + } + const { tool } = secretless + ? await executeCopilotCustomToolUseCase(context, updateWorkspaceCustomToolUseCase, input) + : await executeCopilotCustomToolUseCase(context, updateAvailableCustomToolUseCase, input) captureServerEvent( context.userId, 'custom_tool_saved', @@ -191,11 +197,16 @@ export async function executeManageCustomTool( for (const toolId of toolIds) { try { - await executeCopilotCustomToolUseCase(context, deleteAvailableCustomToolUseCase, { + const input = { toolId, workspaceId, - source: 'tool_input', - }) + source: 'tool_input' as const, + } + if (secretless) { + await executeCopilotCustomToolUseCase(context, deleteWorkspaceCustomToolUseCase, input) + } else { + await executeCopilotCustomToolUseCase(context, deleteAvailableCustomToolUseCase, input) + } deleted.push(toolId) } catch (error) { const classified = asOrchestrationError(error) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.test.ts index cf175bfd8c4..e310f28a4b3 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.test.ts @@ -4,34 +4,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { select, from, where } = vi.hoisted(() => { - const where = vi.fn() - const from = vi.fn(() => ({ where })) - const select = vi.fn(() => ({ from })) - return { select, from, where } -}) - -vi.mock('@sim/db', () => ({ db: { select } })) -vi.mock('@sim/db/schema', () => ({ - mcpServers: { - workspaceId: 'workspaceId', - deletedAt: 'deletedAt', - }, -})) -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => conditions), - eq: vi.fn((left: unknown, right: unknown) => [left, right]), - isNull: vi.fn((value: unknown) => [value, null]), +const mocks = vi.hoisted(() => ({ + executeCopilotMcpServerUseCase: vi.fn(), + listMcpServersUseCase: { operation: { id: 'mcp_servers.list' } }, })) -vi.mock('@/lib/copilot/tools/permissions', () => ({ - copilotToolCanWrite: vi.fn(() => true), - copilotWriteDeniedMessage: vi.fn(), + +vi.mock('@/lib/copilot/application/execute-mcp-server-use-case', () => ({ + executeCopilotMcpServerUseCase: mocks.executeCopilotMcpServerUseCase, })) -vi.mock('@/lib/mcp/orchestration', () => ({ - performCreateMcpServer: vi.fn(), - performDeleteMcpServer: vi.fn(), - performUpdateMcpServer: vi.fn(), +vi.mock('@/lib/mcp/application/use-cases', () => ({ + deleteMcpServerUseCase: { operation: { id: 'mcp_servers.delete' } }, + listMcpServersUseCase: mocks.listMcpServersUseCase, + reconfigureMcpServerUseCase: { operation: { id: 'mcp_servers.reconfigure' } }, + registerMcpServerUseCase: { operation: { id: 'mcp_servers.register' } }, })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) import { executeManageMcpTool } from './manage-mcp-tool' @@ -54,14 +41,12 @@ const CONTEXT = { describe('manage_mcp_tool list projection', () => { beforeEach(() => { vi.clearAllMocks() - where.mockResolvedValue([SERVER]) + mocks.executeCopilotMcpServerUseCase.mockResolvedValue({ servers: [SERVER] }) }) it('omits raw URLs from secretless workspace chat', async () => { - const result = await executeManageMcpTool( - { operation: 'list' }, - { ...CONTEXT, secretActorUserId: null } - ) + const context = { ...CONTEXT, secretActorUserId: null } + const result = await executeManageMcpTool({ operation: 'list' }, context) expect(result.success).toBe(true) expect(result.output).toMatchObject({ @@ -76,13 +61,17 @@ describe('manage_mcp_tool list projection', () => { ], }) expect(JSON.stringify(result.output)).not.toContain('sentinel') + expect(mocks.executeCopilotMcpServerUseCase).toHaveBeenCalledWith( + context, + mocks.listMcpServersUseCase, + { workspaceId: 'workspace-1' } + ) }) it('keeps URLs for normal user-backed chat', async () => { const result = await executeManageMcpTool({ operation: 'list' }, CONTEXT) expect(result.output).toMatchObject({ servers: [{ url: SERVER.url }] }) - expect(select).toHaveBeenCalledOnce() - expect(from).toHaveBeenCalledOnce() + expect(mocks.executeCopilotMcpServerUseCase).toHaveBeenCalledOnce() }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index dfa61ea3881..abfc2442872 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -43,6 +43,7 @@ async function getGatedVFS(context: ExecutionContext) { secretMountPolicy: context.secretMountPolicy, filePrincipal, knowledgePrincipal, + ...(context.secretActorUserId === null ? { secretless: true } : {}), }) ) } @@ -291,6 +292,17 @@ export async function executeVfsRead( return { success: false, error: 'No workspace context available' } } + if ( + context.queryOnly && + /\/(?:compiled|compiled-check|extract|render)\/?$/.test(path.trim().replace(/^\/+/, '')) + ) { + return { + success: false, + error: + 'read is query-only: document compilation, extraction, and rendering paths are not available; read the file content or metadata instead', + } + } + try { const parseOptionalNumber = (value: unknown): number | undefined => { if (typeof value === 'number' && Number.isFinite(value)) return value diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.test.ts index f3b1bc2d217..1f008e5c29f 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.test.ts @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { - ensureWorkflowAccessMock, + authorizeWorkflowByWorkspacePermissionMock, applyOperationsToWorkflowStateMock, saveWorkflowToNormalizedTablesMock, assertWorkflowMutableMock, @@ -18,7 +18,7 @@ const { const dbSetMock = vi.fn(() => ({ where: dbWhereMock })) const dbUpdateMock = vi.fn(() => ({ set: dbSetMock })) return { - ensureWorkflowAccessMock: vi.fn(), + authorizeWorkflowByWorkspacePermissionMock: vi.fn(), applyOperationsToWorkflowStateMock: vi.fn(), saveWorkflowToNormalizedTablesMock: vi.fn(), assertWorkflowMutableMock: vi.fn(), @@ -36,6 +36,7 @@ vi.mock('@sim/db/schema', () => ({ vi.mock('drizzle-orm', () => ({ eq: vi.fn((left, right) => [left, right]) })) vi.mock('@sim/platform-authz/workflow', () => ({ assertWorkflowMutable: assertWorkflowMutableMock, + authorizeWorkflowByWorkspacePermission: authorizeWorkflowByWorkspacePermissionMock, })) vi.mock('@/lib/billing/core/subscription', () => ({ hasWorkspaceSandboxAccess: vi.fn(async () => true), @@ -46,9 +47,6 @@ vi.mock('@/lib/copilot/block-visibility', () => ({ vi.mock('@/lib/copilot/sim-sandbox-projection', () => ({ operationsReferenceSimSandbox: vi.fn(() => false), })) -vi.mock('@/lib/copilot/tools/handlers/access', () => ({ - ensureWorkflowAccess: ensureWorkflowAccessMock, -})) vi.mock('@/lib/core/config/env', () => ({ env: { INTERNAL_API_SECRET: 'internal-secret' } })) vi.mock('@/lib/core/utils/urls', () => ({ getSocketServerUrl: () => 'http://socket.test' })) vi.mock('@/lib/execution/remote-sandbox/workspace-sandboxes', () => ({ @@ -136,7 +134,8 @@ describe('editWorkflowServerTool secretless projection', () => { beforeEach(() => { vi.clearAllMocks() global.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) as typeof fetch - ensureWorkflowAccessMock.mockResolvedValue({ + authorizeWorkflowByWorkspacePermissionMock.mockResolvedValue({ + allowed: true, workflow: { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, }) assertWorkflowMutableMock.mockResolvedValue(undefined) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts index b7ecfeed71a..663e05db75e 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts @@ -16,6 +16,7 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { projectWorkflowStateForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' import { env } from '@/lib/core/config/env' import { getSocketServerUrl } from '@/lib/core/utils/urls' import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/workspace-sandboxes' @@ -404,11 +405,16 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown> const sanitizationWarnings = validation.warnings.length > 0 ? validation.warnings : undefined + const outputWorkflowState = projectWorkflowStateForCopilot( + { ...finalWorkflowState, blocks: layoutedBlocks }, + { secretless: context.secretActorUserId === null } + ) + return { success: true, workflowId, workflowName: workflowName ?? 'Workflow', - workflowState: { ...finalWorkflowState, blocks: layoutedBlocks }, + workflowState: outputWorkflowState, workflowLint, ...(workflowLintMessage && { workflowLintMessage }), ...(inputErrors && { diff --git a/apps/sim/lib/copilot/tools/shared/workflow-utils.ts b/apps/sim/lib/copilot/tools/shared/workflow-utils.ts index c82e0f60bdf..ea211a4546a 100644 --- a/apps/sim/lib/copilot/tools/shared/workflow-utils.ts +++ b/apps/sim/lib/copilot/tools/shared/workflow-utils.ts @@ -1,3 +1,4 @@ +import { sanitizeWorkflowForSharing } from '@/lib/workflows/credentials/credential-extractor' import { type CopilotSanitizationOptions, sanitizeForCopilot, @@ -10,9 +11,24 @@ type CopilotWorkflowState = { parallels?: Record<string, any> } +type CopilotWorkflowProjectionOptions = CopilotSanitizationOptions & { secretless?: boolean } + +export function projectWorkflowStateForCopilot<T extends CopilotWorkflowState>( + state: T, + options?: CopilotWorkflowProjectionOptions +): T { + return options?.secretless + ? (sanitizeWorkflowForSharing(state, { + preserveEnvVars: false, + preserveWorkspaceReferences: true, + redactOpaqueCredentialInputs: true, + }) as T) + : state +} + export function formatWorkflowStateForCopilot( state: CopilotWorkflowState, - options?: CopilotSanitizationOptions + options?: CopilotWorkflowProjectionOptions ): string { const workflowState = { blocks: state.blocks || {}, @@ -20,13 +36,14 @@ export function formatWorkflowStateForCopilot( loops: state.loops || {}, parallels: state.parallels || {}, } - const sanitized = sanitizeForCopilot(workflowState, options) + const credentialSafeState = projectWorkflowStateForCopilot(workflowState, options) + const sanitized = sanitizeForCopilot(credentialSafeState as typeof workflowState, options) return JSON.stringify(sanitized, null, 2) } export function formatNormalizedWorkflowForCopilot( normalized: CopilotWorkflowState | null | undefined, - options?: CopilotSanitizationOptions + options?: CopilotWorkflowProjectionOptions ): string | null { if (!normalized) return null return formatWorkflowStateForCopilot(normalized, options) diff --git a/apps/sim/lib/logs/fetch-log-detail.ts b/apps/sim/lib/logs/fetch-log-detail.ts index 64931f50fda..5c7f5b75824 100644 --- a/apps/sim/lib/logs/fetch-log-detail.ts +++ b/apps/sim/lib/logs/fetch-log-detail.ts @@ -1,5 +1,12 @@ import { db } from '@sim/db' -import { jobExecutionLogs, usageLog } from '@sim/db/schema' +import { + jobExecutionLogs, + pausedExecutions, + usageLog, + workflow, + workflowDeploymentVersion, + workflowExecutionLogs, +} from '@sim/db/schema' import { and, eq, type SQL } from 'drizzle-orm' import type { CostLedger } from '@/lib/api/contracts/logs' import { @@ -9,7 +16,7 @@ import { pickLatestStartedMarker, } from '@/lib/logs/execution/progress-markers' import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store' -import { getPublicWorkflowLog } from '@/lib/logs/public-queries' +import { workflowExecutionOriginSql } from '@/lib/logs/execution-origin' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' type LookupColumn = 'id' | 'executionId' @@ -91,7 +98,52 @@ export async function fetchLogDetail({ const access = await checkWorkspaceAccess(workspaceId, userId) if (!access.hasAccess) return null - const log = await getPublicWorkflowLog({ column: lookupColumn, value: lookupValue }, workspaceId) + const workflowMatch: SQL = + lookupColumn === 'id' + ? eq(workflowExecutionLogs.id, lookupValue) + : eq(workflowExecutionLogs.executionId, lookupValue) + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + executionId: workflowExecutionLogs.executionId, + deploymentVersionId: workflowExecutionLogs.deploymentVersionId, + level: workflowExecutionLogs.level, + status: workflowExecutionLogs.status, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + costTotal: workflowExecutionLogs.costTotal, + files: workflowExecutionLogs.files, + createdAt: workflowExecutionLogs.createdAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + deploymentVersion: workflowDeploymentVersion.version, + deploymentVersionName: workflowDeploymentVersion.name, + pausedStatus: pausedExecutions.status, + pausedTotalPauseCount: pausedExecutions.totalPauseCount, + pausedResumedCount: pausedExecutions.resumedCount, + executionOrigin: workflowExecutionOriginSql().as('execution_origin'), + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .leftJoin( + workflowDeploymentVersion, + eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) + ) + .leftJoin(pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)) + .where(and(workflowMatch, eq(workflowExecutionLogs.workspaceId, workspaceId))) + .limit(1) + + const log = rows[0] if (log) { const workflowSummary = log.workflowId diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.ts b/apps/sim/lib/workflows/credentials/credential-extractor.ts index 4683bda7a6c..ec198547253 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.ts @@ -1,4 +1,5 @@ import { isPlainRecord } from '@sim/utils/object' +import { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/credentials/constants' import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer' import { WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES } from '@/lib/workflows/search-replace/resources/registry' import { setValueAtPath } from '@/lib/workflows/search-replace/value-walker' @@ -134,6 +135,8 @@ interface SanitizedWorkflowState { interface WorkflowSanitizationOptions { preserveEnvVars?: boolean + /** Retain IDs for resources in the same workspace while still removing credentials. */ + preserveWorkspaceReferences?: boolean /** * Withhold values whose interior cannot be projected safely once the payload leaves the * workspace — whole `table` values (see {@link OPAQUE_CREDENTIAL_BEARING_TYPES}) and every @@ -157,6 +160,25 @@ interface WorkflowSanitizationOptions { redactOpaqueCredentialInputs?: boolean } +function isCredentialKey(key: string): boolean { + const normalized = key.replace(/[_-]/g, '').replace(/\d+$/, '').toLowerCase() + return ( + normalized === 'auth' || + normalized === 'authorization' || + normalized.endsWith('credential') || + normalized.endsWith('credentialid') || + normalized.endsWith('apikey') || + normalized.endsWith('accesstoken') || + normalized.endsWith('refreshtoken') || + normalized.endsWith('idtoken') || + normalized.endsWith('authtoken') || + normalized.endsWith('bottoken') || + normalized.endsWith('bearertoken') || + normalized.endsWith('secret') || + normalized.endsWith('password') + ) +} + type CredentialSanitizationConfig = Pick< SubBlockConfig, 'id' | 'type' | 'password' | 'canonicalParamId' @@ -227,9 +249,10 @@ function sanitizeConfiguredSubBlockValue( return options.preserveEnvVars && isEnvironmentVariableReference(value) ? value : null } if ( - WORKSPACE_SPECIFIC_TYPES.has(config.type) || - WORKSPACE_SPECIFIC_FIELDS.has(config.id) || - (config.canonicalParamId != null && WORKSPACE_SPECIFIC_FIELDS.has(config.canonicalParamId)) + !options.preserveWorkspaceReferences && + (WORKSPACE_SPECIFIC_TYPES.has(config.type) || + WORKSPACE_SPECIFIC_FIELDS.has(config.id) || + (config.canonicalParamId != null && WORKSPACE_SPECIFIC_FIELDS.has(config.canonicalParamId))) ) { return null } @@ -260,6 +283,11 @@ export function sanitizeWorkflowForSharing( removeMalformedSubBlocks(block) const blockConfig = getBlock(block.type) + const registeredSubBlockIds = new Set<string>() + for (const config of blockConfig?.subBlocks ?? []) { + registeredSubBlockIds.add(config.id) + if (config.canonicalParamId) registeredSubBlockIds.add(config.canonicalParamId) + } // Process subBlocks with config if (blockConfig) { @@ -287,8 +315,20 @@ export function sanitizeWorkflowForSharing( } } + if ( + subBlock && + (CREDENTIAL_SUBBLOCK_IDS.has(key) || + (!registeredSubBlockIds.has(key) && isCredentialKey(key))) + ) { + subBlock.value = null + } + // Clear workspace-specific fields by key name - if (WORKSPACE_SPECIFIC_FIELDS.has(key) && subBlock) { + if ( + !options.preserveWorkspaceReferences && + WORKSPACE_SPECIFIC_FIELDS.has(key) && + subBlock + ) { subBlock.value = null } }) @@ -302,7 +342,7 @@ export function sanitizeWorkflowForSharing( block.data![key] = null } // Clear workspace-specific data - if (WORKSPACE_SPECIFIC_FIELDS.has(key)) { + if (!options.preserveWorkspaceReferences && WORKSPACE_SPECIFIC_FIELDS.has(key)) { block.data![key] = null } }) diff --git a/apps/sim/lib/workflows/execution-admission.ts b/apps/sim/lib/workflows/execution-admission.ts index 601d7c70e8f..0209db29e83 100644 --- a/apps/sim/lib/workflows/execution-admission.ts +++ b/apps/sim/lib/workflows/execution-admission.ts @@ -26,12 +26,13 @@ export async function resolveWorkflowExecutionBillingAttribution( if (!rootAttribution) return undefined if (rootAttribution.workspaceId === targetWorkspaceId) return rootAttribution + const billingActorUserId = rootAttribution.actorUserId const childAttribution = await resolveBillingAttribution({ - actorUserId: context.userId, + actorUserId: billingActorUserId, workspaceId: targetWorkspaceId, }) if ( - childAttribution.actorUserId !== context.userId || + childAttribution.actorUserId !== billingActorUserId || childAttribution.workspaceId !== targetWorkspaceId ) { throw new Error('Resolved workflow billing attribution does not match its actor and workspace') diff --git a/apps/sim/lib/workspace-files/application/share-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/share-workspace-file.test.ts deleted file mode 100644 index 9e526270a9b..00000000000 --- a/apps/sim/lib/workspace-files/application/share-workspace-file.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - getFile: vi.fn(), - getShare: vi.fn(), - loadContext: vi.fn(), - recordAudit: vi.fn(), - resolvePermission: vi.fn(), - upsertShare: vi.fn(), -})) - -vi.mock('@sim/audit', () => ({ - AuditAction: { FILE_SHARED: 'FILE_SHARED', FILE_SHARE_DISABLED: 'FILE_SHARE_DISABLED' }, - AuditResourceType: { FILE: 'FILE' }, - recordAudit: mocks.recordAudit, -})) - -vi.mock('@sim/auth/principal', () => ({ - resolvePrincipalAttribution: () => ({ attributedUserId: 'user-1' }), - resolvePrincipalAuditAttribution: () => ({ - actorId: 'user-1', - actorName: null, - actor: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - }), -})) - -vi.mock('@sim/platform-authz/workspace', () => ({ - permissionSatisfies: () => true, - resolveEffectiveWorkspacePermission: mocks.resolvePermission, -})) - -vi.mock('@/lib/public-shares/share-manager', () => ({ - getShareForResource: mocks.getShare, - ShareValidationError: class ShareValidationError extends Error {}, - upsertFileShare: mocks.upsertShare, -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - getWorkspaceFile: mocks.getFile, - loadActiveWorkspaceFileContext: mocks.loadContext, -})) - -vi.mock('@/ee/access-control/utils/permission-check', () => ({ - PublicFileSharingNotAllowedError: class PublicFileSharingNotAllowedError extends Error {}, - validatePublicFileSharing: vi.fn(), -})) - -import { unshareWorkspaceFile } from '@/lib/workspace-files/application/share-workspace-file' - -const context = { - fileId: 'file-1', - workspaceId: 'workspace-1', - workspaceOrganizationId: 'organization-1', - allowPersonalApiKeys: true, - billedAccountUserId: 'billing-owner-1', -} - -const file = { - id: 'file-1', - workspaceId: 'workspace-1', - name: 'report.pdf', -} - -const activeShare = { - id: 'share-1', - resourceType: 'file', - resourceId: 'file-1', - token: 'token-1', - url: 'https://sim.ai/f/token-1', - isActive: true, - authType: 'public', - hasPassword: false, - allowedEmails: [], -} - -describe('unshareWorkspaceFile application service', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.loadContext.mockResolvedValue(context) - mocks.getFile.mockResolvedValue(file) - mocks.resolvePermission.mockResolvedValue('admin') - }) - - it('disables an active share and records the transition', async () => { - const disabledShare = { ...activeShare, isActive: false } - mocks.getShare.mockResolvedValue(activeShare) - mocks.upsertShare.mockResolvedValue(disabledShare) - - await expect( - unshareWorkspaceFile.execute({ - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, - }) - ).resolves.toEqual({ share: disabledShare, changed: true }) - - expect(mocks.upsertShare).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - fileId: 'file-1', - userId: 'user-1', - isActive: false, - }) - expect(mocks.recordAudit).toHaveBeenCalledWith( - expect.objectContaining({ - action: 'FILE_SHARE_DISABLED', - resourceId: 'file-1', - }) - ) - }) - - it('is idempotent without creating an inactive share or audit entry', async () => { - mocks.getShare.mockResolvedValue(null) - - await expect( - unshareWorkspaceFile.execute({ - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, - }) - ).resolves.toEqual({ share: null, changed: false }) - - expect(mocks.upsertShare).not.toHaveBeenCalled() - expect(mocks.recordAudit).not.toHaveBeenCalled() - }) -}) diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts index ffeb5c88666..0f11ff981c1 100644 --- a/packages/sim-cli/src/commands/auth.test.ts +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -11,7 +11,20 @@ const mocks = vi.hoisted(() => ({ apiKey: 'sim-key', scope: 'platform' as const, workspaceBound: false, - workspaceId: 'ws_1', + workspaceId: 'ws_1' as string | undefined, + })), + profileFrom: vi.fn(() => ({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: null, + workspaceId: null as string | null, + output: 'table', + sources: { + endpoint: 'default', + apiKey: 'unset', + workspaceId: 'unset', + output: 'default', + }, })), writeConfigProfile: vi.fn(), writeCredentialsProfile: vi.fn(), @@ -31,21 +44,7 @@ vi.mock('../config/index', () => ({ writeConfigProfile: mocks.writeConfigProfile, writeCredentialsProfile: mocks.writeCredentialsProfile, })) -vi.mock('../context', () => ({ - profileFrom: () => ({ - name: 'default', - endpoint: 'https://sim.ai', - apiKey: null, - workspaceId: null, - output: 'table', - sources: { - endpoint: 'default', - apiKey: 'unset', - workspaceId: 'unset', - output: 'default', - }, - }), -})) +vi.mock('../context', () => ({ profileFrom: mocks.profileFrom })) import { loginCommand, profilesCommand } from './auth' @@ -66,6 +65,25 @@ describe('login command', () => { vi.clearAllMocks() mocks.listProfiles.mockReturnValue([]) mocks.readCredentialsProfile.mockReturnValue({}) + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: null, + workspaceId: null, + output: 'table', + sources: { + endpoint: 'default', + apiKey: 'unset', + workspaceId: 'unset', + output: 'default', + }, + }) + mocks.pollForKey.mockResolvedValue({ + apiKey: 'sim-key', + scope: 'platform', + workspaceBound: false, + workspaceId: 'ws_1', + }) mocks.createInterface.mockReturnValue({ question: vi.fn(async () => 'yes'), close: vi.fn(), @@ -140,6 +158,37 @@ describe('login command', () => { expect(mocks.createInterface).not.toHaveBeenCalled() expect(mocks.createAuthRequest).toHaveBeenCalledOnce() }) + + it('clears a stale workspace default when none is selected during login', async () => { + setInteractive(false) + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: null, + workspaceId: 'ws_old', + output: 'table', + sources: { + endpoint: 'default', + apiKey: 'unset', + workspaceId: 'config', + output: 'default', + }, + }) + mocks.pollForKey.mockResolvedValue({ + apiKey: 'sim-key', + scope: 'platform', + workspaceBound: false, + workspaceId: undefined, + }) + + await login() + + expect(mocks.writeConfigProfile).toHaveBeenCalledWith('default', { + endpoint: 'https://sim.ai', + workspace: null, + }) + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('no default workspace')) + }) }) describe('profiles command', () => { diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index d73d7e5721f..2f9c3fb3130 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -131,8 +131,10 @@ export function loginCommand(): Command { // whether or not the key is scoped to it. The user chose it by name — // making them look up its id afterwards would waste the one moment the // answer was already on screen. - const settings: Record<string, string> = { endpoint: profile.endpoint } - if (key.workspaceId) settings.workspace = key.workspaceId + const settings: Record<string, string | null> = { + endpoint: profile.endpoint, + workspace: key.workspaceId ?? null, + } writeConfigProfile(profile.name, settings) console.log(chalk.green(`\n✓ Logged in. Key stored in ${credentialsPath()}`)) @@ -144,7 +146,7 @@ export function loginCommand(): Command { ` Personal key, defaulting to ${key.workspaceId}. Override per command with --workspace.` ) ) - } else if (!profile.workspaceId) { + } else { console.log( chalk.dim( ' Personal key with no default workspace. Set one with: sim configure --set-workspace <id>' diff --git a/scripts/check-source-text.ts b/scripts/check-source-text.ts index 1052604eb32..c406242cc8d 100644 --- a/scripts/check-source-text.ts +++ b/scripts/check-source-text.ts @@ -56,7 +56,9 @@ const files = listed.stdout const offenders: string[] = [] for (const file of files) { - const bytes = await Bun.file(path.join(ROOT, file)).bytes() + const source = Bun.file(path.join(ROOT, file)) + if (!(await source.exists())) continue + const bytes = await source.bytes() if (bytes.includes(0)) offenders.push(file) } From 4414c5d31e5acb6b0190b768bbc2d6cfbddb4442 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 23:35:35 -0700 Subject: [PATCH 147/159] fix(cli): skip existing package releases --- .github/workflows/publish-sim-cli.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/publish-sim-cli.yml b/.github/workflows/publish-sim-cli.yml index bf658a9c09f..ca4ff6370bc 100644 --- a/.github/workflows/publish-sim-cli.yml +++ b/.github/workflows/publish-sim-cli.yml @@ -115,7 +115,20 @@ jobs: tar -xzf "$PACKAGE_PATH" -C "$SMOKE_DIR" "$SMOKE_DIR/package/dist/index.js" --version + - name: Check if version already exists + id: version_check + working-directory: packages/sim-cli + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + if bun pm view "@sim/cli@$VERSION" version > /dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + - name: Publish to npm + if: steps.version_check.outputs.exists == 'false' working-directory: packages/sim-cli env: NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -123,7 +136,14 @@ jobs: run: bun publish --access public --tag "$NPM_TAG" --no-save - name: Summarize release + if: steps.version_check.outputs.exists == 'false' env: VERSION: ${{ steps.release.outputs.version }} NPM_TAG: ${{ steps.release.outputs.tag }} run: echo "Published @sim/cli@$VERSION with the '$NPM_TAG' tag." + + - name: Summarize skipped release + if: steps.version_check.outputs.exists == 'true' + env: + VERSION: ${{ steps.release.outputs.version }} + run: echo "Skipped @sim/cli@$VERSION because that version is already published." From a369eab34c5a93aeaaf0325a7c200d8d8e7965c8 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 23:45:02 -0700 Subject: [PATCH 148/159] fix(cli): avoid exposing API key fragments --- packages/sim-cli/src/commands/auth.test.ts | 40 ++++++++++++++++++++-- packages/sim-cli/src/commands/auth.ts | 11 ++---- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts index 0f11ff981c1..03cd76489f1 100644 --- a/packages/sim-cli/src/commands/auth.test.ts +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -16,7 +16,7 @@ const mocks = vi.hoisted(() => ({ profileFrom: vi.fn(() => ({ name: 'default', endpoint: 'https://sim.ai', - apiKey: null, + apiKey: null as string | null, workspaceId: null as string | null, output: 'table', sources: { @@ -46,7 +46,7 @@ vi.mock('../config/index', () => ({ })) vi.mock('../context', () => ({ profileFrom: mocks.profileFrom })) -import { loginCommand, profilesCommand } from './auth' +import { loginCommand, profilesCommand, whoamiCommand } from './auth' const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') @@ -60,6 +60,12 @@ async function login(...args: string[]): Promise<void> { await root.parseAsync(['node', 'sim', 'login', '--no-browser', ...args]) } +async function whoami(...args: string[]): Promise<void> { + const root = new Command('sim').exitOverride() + root.addCommand(whoamiCommand()) + await root.parseAsync(['node', 'sim', 'whoami', ...args]) +} + describe('login command', () => { beforeEach(() => { vi.clearAllMocks() @@ -196,3 +202,33 @@ describe('profiles command', () => { expect(profilesCommand().alias()).toBe('profile') }) }) + +describe('whoami command', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('reports authentication without exposing any part of the API key', async () => { + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: 'sim_super_secret_value', + workspaceId: 'ws_1', + output: 'text', + sources: { + endpoint: 'default', + apiKey: 'credentials', + workspaceId: 'config', + output: 'flag', + }, + }) + + await whoami() + + const output = vi.mocked(console.log).mock.calls.flat().join('\n') + expect(output).toContain('API key\tconfigured (credentials)') + expect(output).not.toContain('sim_super_secret_value') + expect(output).not.toContain('secret') + }) +}) diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index 2f9c3fb3130..1ee1977065f 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -47,10 +47,6 @@ function openBrowser(url: string): void { } catch {} } -function maskKey(key: string): string { - return key.length <= 10 ? '•'.repeat(key.length) : `${key.slice(0, 6)}…${key.slice(-4)}` -} - async function confirmProfileOverwrite(profileName: string): Promise<boolean> { if (!process.stdin.isTTY) { throw new SimApiError( @@ -193,6 +189,7 @@ export function whoamiCommand(): Command { .action((_options: unknown, command: Command) => { const profile = profileFrom(command) const { sources } = profile + const authenticated = sources.apiKey !== 'unset' const annotate = (value: string, source: string) => source === 'unset' ? chalk.dim('not set') : `${value} ${chalk.dim(`(${source})`)}` @@ -204,9 +201,7 @@ export function whoamiCommand(): Command { ['Endpoint', annotate(profile.endpoint, sources.endpoint)], [ 'API key', - profile.apiKey - ? annotate(maskKey(profile.apiKey), sources.apiKey) - : chalk.yellow('not logged in'), + authenticated ? annotate('configured', sources.apiKey) : chalk.yellow('not logged in'), ], ['Workspace', annotate(profile.workspaceId ?? '', sources.workspaceId)], ['Output', annotate(profile.output, sources.output)], @@ -216,7 +211,7 @@ export function whoamiCommand(): Command { endpoint: profile.endpoint, workspaceId: profile.workspaceId, output: profile.output, - authenticated: Boolean(profile.apiKey), + authenticated, sources, } ) From f4e898c246ac7c4b9259e6f09a426641dac57122 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Fri, 14 Aug 2026 23:50:44 -0700 Subject: [PATCH 149/159] fix(cli): cancel failed download streams --- .../src/commands/protocol/files-get.test.ts | 26 +++++++++++++++++ .../src/commands/protocol/files-get.ts | 28 +++---------------- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/packages/sim-cli/src/commands/protocol/files-get.test.ts b/packages/sim-cli/src/commands/protocol/files-get.test.ts index 4cef35e449e..431788af71a 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.test.ts @@ -1,6 +1,7 @@ import { createWriteStream, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { Writable } from 'node:stream' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { buildGeneratedCommands } from '../../runtime/build' @@ -85,6 +86,31 @@ describe('streamToFile', () => { ).rejects.toThrow(/Could not write/) } ) + + it('cancels the response body and waits for the pump when writing fails', async () => { + const cancelled = vi.fn() + const body = new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue(new TextEncoder().encode('first chunk')) + }, + cancel: cancelled, + }) + const target = join(dir, 'out.txt') + const destination = Object.assign( + new Writable({ + write(_chunk, _encoding, callback) { + const error = Object.assign(new Error('disk full'), { code: 'ENOSPC' }) + callback(error) + }, + }), + { path: target } + ) + + await expect(streamToFile(body, destination)).rejects.toThrow( + `Could not write ${target}: disk full` + ) + expect(cancelled).toHaveBeenCalledOnce() + }) }) describe('isTerminalSafeContentType', () => { diff --git a/packages/sim-cli/src/commands/protocol/files-get.ts b/packages/sim-cli/src/commands/protocol/files-get.ts index 9477463570f..6b3642a47f3 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -1,5 +1,7 @@ import { once } from 'node:events' import { createWriteStream, type WriteStream } from 'node:fs' +import { Readable, type Writable } from 'node:stream' +import { pipeline } from 'node:stream/promises' import type { Command } from 'commander' import { clientFrom } from '../../context' import { V2_OPERATIONS } from '../../generated/v2-api' @@ -9,33 +11,11 @@ import { printProtocolResult } from './result' /** Streams a fetch body to disk while honoring write-stream backpressure. */ export async function streamToFile( body: ReadableStream<Uint8Array>, - file: WriteStream + file: Writable & Pick<WriteStream, 'path'> ): Promise<void> { - const failed = new Promise<never>((_resolve, reject) => { - file.once('error', reject) - }) - - const pump = (async () => { - const reader = body.getReader() - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - if (!file.write(value)) await once(file, 'drain') - } - } finally { - reader.releaseLock() - } - - await new Promise<void>((resolve, reject) => { - file.end((error?: Error | null) => (error ? reject(error) : resolve())) - }) - })() - try { - await Promise.race([pump, failed]) + await pipeline(Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]), file) } catch (error) { - file.destroy() const code = (error as NodeJS.ErrnoException).code if (code === 'EEXIST') { throw new SimApiError( From f00b86fa51104c5e57852e3c9de58389f93a9ac8 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 15 Aug 2026 00:00:11 -0700 Subject: [PATCH 150/159] fix(cli): sanitize authentication metadata --- packages/sim-cli/src/commands/auth.ts | 35 ++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index 1ee1977065f..56010d62560 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -13,6 +13,7 @@ import { deleteProfile, listProfiles, readCredentialsProfile, + type SettingSource, writeConfigProfile, writeCredentialsProfile, } from '../config/index' @@ -47,6 +48,25 @@ function openBrowser(url: string): void { } catch {} } +function presentAuthentication(source: SettingSource): { + authenticated: boolean + source: SettingSource +} { + switch (source) { + case 'flag': + return { authenticated: true, source: 'flag' } + case 'env': + return { authenticated: true, source: 'env' } + case 'credentials': + return { authenticated: true, source: 'credentials' } + case 'unset': + return { authenticated: false, source: 'unset' } + case 'config': + case 'default': + throw new SimApiError(`Unexpected API key source "${source}".`, 0) + } +} + async function confirmProfileOverwrite(profileName: string): Promise<boolean> { if (!process.stdin.isTTY) { throw new SimApiError( @@ -189,7 +209,7 @@ export function whoamiCommand(): Command { .action((_options: unknown, command: Command) => { const profile = profileFrom(command) const { sources } = profile - const authenticated = sources.apiKey !== 'unset' + const authentication = presentAuthentication(sources.apiKey) const annotate = (value: string, source: string) => source === 'unset' ? chalk.dim('not set') : `${value} ${chalk.dim(`(${source})`)}` @@ -201,7 +221,9 @@ export function whoamiCommand(): Command { ['Endpoint', annotate(profile.endpoint, sources.endpoint)], [ 'API key', - authenticated ? annotate('configured', sources.apiKey) : chalk.yellow('not logged in'), + authentication.authenticated + ? annotate('configured', authentication.source) + : chalk.yellow('not logged in'), ], ['Workspace', annotate(profile.workspaceId ?? '', sources.workspaceId)], ['Output', annotate(profile.output, sources.output)], @@ -211,8 +233,13 @@ export function whoamiCommand(): Command { endpoint: profile.endpoint, workspaceId: profile.workspaceId, output: profile.output, - authenticated, - sources, + authenticated: authentication.authenticated, + sources: { + endpoint: sources.endpoint, + apiKey: authentication.source, + workspaceId: sources.workspaceId, + output: sources.output, + }, } ) }) From b3f150c8be520872f4f4b075b7208c43ba2b5106 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 15 Aug 2026 00:13:36 -0700 Subject: [PATCH 151/159] fix(cli): rename authentication output metadata --- packages/sim-cli/src/commands/auth.test.ts | 26 ++++++++++++++++++++++ packages/sim-cli/src/commands/auth.ts | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts index 03cd76489f1..a75fe6174ee 100644 --- a/packages/sim-cli/src/commands/auth.test.ts +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -231,4 +231,30 @@ describe('whoami command', () => { expect(output).not.toContain('sim_super_secret_value') expect(output).not.toContain('secret') }) + + it('uses non-secret-shaped authentication metadata in machine output', async () => { + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: 'sim_super_secret_value', + workspaceId: 'ws_1', + output: 'json', + sources: { + endpoint: 'default', + apiKey: 'credentials', + workspaceId: 'config', + output: 'flag', + }, + }) + + await whoami() + + const output = String(vi.mocked(console.log).mock.calls[0][0]) + expect(JSON.parse(output)).toMatchObject({ + authenticated: true, + sources: { authentication: 'credentials' }, + }) + expect(output).not.toContain('apiKey') + expect(output).not.toContain('sim_super_secret_value') + }) }) diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index 56010d62560..242683ca644 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -236,7 +236,7 @@ export function whoamiCommand(): Command { authenticated: authentication.authenticated, sources: { endpoint: sources.endpoint, - apiKey: authentication.source, + authentication: authentication.source, workspaceId: sources.workspaceId, output: sources.output, }, From 2b01b00ddce186cbc2d1607b14cce1129d4e1c63 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 15 Aug 2026 00:23:29 -0700 Subject: [PATCH 152/159] fix(cli): publish downloads atomically --- .../src/commands/protocol/files-get.test.ts | 48 ++++++++++++++++- .../src/commands/protocol/files-get.ts | 53 ++++++++++++++----- 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/packages/sim-cli/src/commands/protocol/files-get.test.ts b/packages/sim-cli/src/commands/protocol/files-get.test.ts index 431788af71a..2e90cceaf7b 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.test.ts @@ -1,11 +1,18 @@ -import { createWriteStream, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { + createWriteStream, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Writable } from 'node:stream' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { buildGeneratedCommands } from '../../runtime/build' -import { isTerminalSafeContentType, streamToFile } from './files-get' +import { isTerminalSafeContentType, saveToFile, streamToFile } from './files-get' import { attachProtocolCommands } from './index' const { output, requestRaw } = vi.hoisted(() => ({ @@ -49,6 +56,15 @@ function bodyOf(chunks: string[]): ReadableStream<Uint8Array> { }) } +function failingBody(): ReadableStream<Uint8Array> { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('partial')) + controller.error(new Error('connection lost')) + }, + }) +} + function program(): Command { const root = new Command('sim').exitOverride() for (const group of buildGeneratedCommands()) root.addCommand(group) @@ -113,6 +129,34 @@ describe('streamToFile', () => { }) }) +describe('saveToFile', () => { + it('preserves the original destination when a forced download fails', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'precious') + + await expect(saveToFile(failingBody(), target, true)).rejects.toThrow(/connection lost/) + + expect(readFileSync(target, 'utf8')).toBe('precious') + }) + + it('leaves no partial destination when a new download fails', async () => { + const target = join(dir, 'out.txt') + + await expect(saveToFile(failingBody(), target, false)).rejects.toThrow(/connection lost/) + + expect(existsSync(target)).toBe(false) + }) + + it('publishes a completed forced download over the original', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'old') + + await saveToFile(bodyOf(['new']), target, true) + + expect(readFileSync(target, 'utf8')).toBe('new') + }) +}) + describe('isTerminalSafeContentType', () => { it('accepts text formats and rejects binary or unknown formats', () => { expect(isTerminalSafeContentType('text/markdown; charset=utf-8')).toBe(true) diff --git a/packages/sim-cli/src/commands/protocol/files-get.ts b/packages/sim-cli/src/commands/protocol/files-get.ts index 6b3642a47f3..618517bf578 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -1,5 +1,7 @@ import { once } from 'node:events' import { createWriteStream, type WriteStream } from 'node:fs' +import { link, mkdtemp, rename, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' import { Readable, type Writable } from 'node:stream' import { pipeline } from 'node:stream/promises' import type { Command } from 'commander' @@ -8,22 +10,50 @@ import { V2_OPERATIONS } from '../../generated/v2-api' import { resolvePath, SimApiError } from '../../http/client' import { printProtocolResult } from './result' +function writeFailure(path: WriteStream['path'], error: unknown): SimApiError { + const code = (error as NodeJS.ErrnoException).code + if (code === 'EEXIST') { + return new SimApiError( + `${path} already exists. Pass --force to overwrite it, or choose another output path.`, + 0 + ) + } + return new SimApiError(`Could not write ${path}: ${(error as Error).message}`, 0) +} + /** Streams a fetch body to disk while honoring write-stream backpressure. */ export async function streamToFile( body: ReadableStream<Uint8Array>, - file: Writable & Pick<WriteStream, 'path'> + file: Writable & Pick<WriteStream, 'path'>, + reportedPath: WriteStream['path'] = file.path ): Promise<void> { try { await pipeline(Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]), file) } catch (error) { - const code = (error as NodeJS.ErrnoException).code - if (code === 'EEXIST') { - throw new SimApiError( - `${file.path} already exists. Pass --force to overwrite it, or choose another output path.`, - 0 - ) - } - throw new SimApiError(`Could not write ${file.path}: ${(error as Error).message}`, 0) + throw writeFailure(reportedPath, error) + } +} + +/** Stages a complete download beside its destination before publishing it. */ +export async function saveToFile( + body: ReadableStream<Uint8Array>, + target: string, + force: boolean +): Promise<void> { + let temporaryDirectory: string | null = null + + try { + temporaryDirectory = await mkdtemp(join(dirname(target), '.sim-download-')) + const temporaryPath = join(temporaryDirectory, 'payload') + await streamToFile(body, createWriteStream(temporaryPath, { flags: 'wx' }), target) + + if (force) await rename(temporaryPath, target) + else await link(temporaryPath, target) + } catch (error) { + if (error instanceof SimApiError) throw error + throw writeFailure(target, error) + } finally { + if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true }) } } @@ -111,10 +141,7 @@ export function attachFileGet(files: Command): void { const target = options.outputFile - await streamToFile( - response.body, - createWriteStream(target, { flags: options.force ? 'w' : 'wx' }) - ) + await saveToFile(response.body, target, Boolean(options.force)) printProtocolResult(profile.output, { id: fileId, path: target, From 90c21fd700b3810828e2935135f168e8492b252b Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 15 Aug 2026 00:34:03 -0700 Subject: [PATCH 153/159] fix(cli): harden download destinations and labels --- .../src/commands/protocol/files-get.test.ts | 26 ++++++ .../src/commands/protocol/files-get.ts | 93 ++++++++++++++++--- packages/sim-cli/src/output/render.test.ts | 9 ++ packages/sim-cli/src/output/render.ts | 8 +- 4 files changed, 118 insertions(+), 18 deletions(-) diff --git a/packages/sim-cli/src/commands/protocol/files-get.test.ts b/packages/sim-cli/src/commands/protocol/files-get.test.ts index 2e90cceaf7b..6427a100986 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.test.ts @@ -1,9 +1,11 @@ import { createWriteStream, existsSync, + lstatSync, mkdtempSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' @@ -147,6 +149,17 @@ describe('saveToFile', () => { expect(existsSync(target)).toBe(false) }) + it('preserves an existing destination without --force', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'precious') + + await expect(saveToFile(bodyOf(['new']), target, false)).rejects.toThrow( + /already exists.*--force/s + ) + + expect(readFileSync(target, 'utf8')).toBe('precious') + }) + it('publishes a completed forced download over the original', async () => { const target = join(dir, 'out.txt') writeFileSync(target, 'old') @@ -155,6 +168,19 @@ describe('saveToFile', () => { expect(readFileSync(target, 'utf8')).toBe('new') }) + + it('preserves a forced symlink destination and replaces its target', async () => { + const target = join(dir, 'target.txt') + const link = join(dir, 'link.txt') + writeFileSync(target, 'old') + symlinkSync(target, link) + + await saveToFile(bodyOf(['new']), link, true) + + expect(readFileSync(target, 'utf8')).toBe('new') + expect(readFileSync(link, 'utf8')).toBe('new') + expect(lstatSync(link).isSymbolicLink()).toBe(true) + }) }) describe('isTerminalSafeContentType', () => { diff --git a/packages/sim-cli/src/commands/protocol/files-get.ts b/packages/sim-cli/src/commands/protocol/files-get.ts index 618517bf578..68b16883bc2 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -1,6 +1,6 @@ import { once } from 'node:events' import { createWriteStream, type WriteStream } from 'node:fs' -import { link, mkdtemp, rename, rm } from 'node:fs/promises' +import { lstat, mkdtemp, open, realpath, rename, rm } from 'node:fs/promises' import { dirname, join } from 'node:path' import { Readable, type Writable } from 'node:stream' import { pipeline } from 'node:stream/promises' @@ -21,6 +21,33 @@ function writeFailure(path: WriteStream['path'], error: unknown): SimApiError { return new SimApiError(`Could not write ${path}: ${(error as Error).message}`, 0) } +async function forcedPublicationTarget(target: string): Promise<string> { + let metadata + try { + metadata = await lstat(target) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return target + throw error + } + + return metadata.isSymbolicLink() ? realpath(target) : target +} + +function normalizedWriteFailure(target: string, error: unknown): SimApiError { + return error instanceof SimApiError ? error : writeFailure(target, error) +} + +function combinedCleanupFailure( + failure: SimApiError, + temporaryPath: string, + cleanupError: unknown +): SimApiError { + return new SimApiError( + `${failure.message} Cleanup also failed for ${temporaryPath}: ${(cleanupError as Error).message}`, + 0 + ) +} + /** Streams a fetch body to disk while honoring write-stream backpressure. */ export async function streamToFile( body: ReadableStream<Uint8Array>, @@ -34,27 +61,63 @@ export async function streamToFile( } } -/** Stages a complete download beside its destination before publishing it. */ -export async function saveToFile( - body: ReadableStream<Uint8Array>, - target: string, - force: boolean -): Promise<void> { +async function saveNewFile(body: ReadableStream<Uint8Array>, target: string): Promise<void> { + let created = false + + try { + const file = await open(target, 'wx') + created = true + await streamToFile(body, file.createWriteStream(), target) + } catch (error) { + const failure = normalizedWriteFailure(target, error) + if (!created) throw failure + + try { + await rm(target, { force: true }) + } catch (cleanupError) { + throw combinedCleanupFailure(failure, target, cleanupError) + } + throw failure + } +} + +async function saveForcedFile(body: ReadableStream<Uint8Array>, target: string): Promise<void> { let temporaryDirectory: string | null = null + let failure: SimApiError | null = null try { - temporaryDirectory = await mkdtemp(join(dirname(target), '.sim-download-')) + const publicationTarget = await forcedPublicationTarget(target) + temporaryDirectory = await mkdtemp(join(dirname(publicationTarget), '.sim-download-')) const temporaryPath = join(temporaryDirectory, 'payload') await streamToFile(body, createWriteStream(temporaryPath, { flags: 'wx' }), target) - - if (force) await rename(temporaryPath, target) - else await link(temporaryPath, target) + await rename(temporaryPath, publicationTarget) } catch (error) { - if (error instanceof SimApiError) throw error - throw writeFailure(target, error) - } finally { - if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true }) + failure = normalizedWriteFailure(target, error) + } + + if (temporaryDirectory) { + try { + await rm(temporaryDirectory, { recursive: true, force: true }) + } catch (cleanupError) { + if (failure) throw combinedCleanupFailure(failure, temporaryDirectory, cleanupError) + throw new SimApiError( + `Saved ${target}, but could not remove temporary directory ${temporaryDirectory}: ${(cleanupError as Error).message}`, + 0 + ) + } } + + if (failure) throw failure +} + +/** Saves without overwriting by default; forced writes publish only after the body is complete. */ +export async function saveToFile( + body: ReadableStream<Uint8Array>, + target: string, + force: boolean +): Promise<void> { + if (force) return saveForcedFile(body, target) + return saveNewFile(body, target) } /** Streams a fetch body to stdout without closing the process-wide stream. */ diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts index f3dbaa8fbf4..a72caa2946b 100644 --- a/packages/sim-cli/src/output/render.test.ts +++ b/packages/sim-cli/src/output/render.test.ts @@ -179,6 +179,15 @@ describe('printRecord', () => { expect(logged[0]).toContain('abc') expect(logged[1]).toContain('alpha') }) + + it.each(['text', 'table'] as const)('sanitizes API-controlled labels in %s output', (format) => { + printRecord(format, [[`${ESC}]0;pwned${BEL}safe\nlabel`, 'value']], {}) + + expect(logged.join('\n')).not.toContain(ESC) + expect(logged.join('\n')).not.toContain(BEL) + expect(logged).toHaveLength(1) + expect(logged[0]).toContain('safe label') + }) }) describe('formatters', () => { diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index a6b3fbe7b38..3c748b79bca 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -291,15 +291,17 @@ export function printRecord(format: OutputFormat, fields: Array<[string, string] return } + const safeFields = fields.map<[string, string]>(([label, value]) => [safeOneLine(label), value]) + if (format === 'text') { - for (const [label, value] of fields) { + for (const [label, value] of safeFields) { console.log(`${label}\t${oneLine(stripAnsi(value))}`) } return } - const width = Math.max(...fields.map(([label]) => label.length)) - for (const [label, value] of fields) { + const width = Math.max(...safeFields.map(([label]) => visibleWidth(label))) + for (const [label, value] of safeFields) { console.log(`${chalk.dim(pad(`${label}:`, width + 1))} ${oneLine(value)}`) } } From ab22e0d6f47e3cf10f7c8fa77ce5918fb41def62 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 15 Aug 2026 00:40:23 -0700 Subject: [PATCH 154/159] fix(cli): support dangling download symlinks --- .../src/commands/protocol/files-get.test.ts | 12 +++++++ .../src/commands/protocol/files-get.ts | 32 +++++++++++++------ 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/packages/sim-cli/src/commands/protocol/files-get.test.ts b/packages/sim-cli/src/commands/protocol/files-get.test.ts index 6427a100986..fe948283df8 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.test.ts @@ -181,6 +181,18 @@ describe('saveToFile', () => { expect(readFileSync(link, 'utf8')).toBe('new') expect(lstatSync(link).isSymbolicLink()).toBe(true) }) + + it('preserves a dangling forced symlink and creates its target', async () => { + const target = join(dir, 'missing.txt') + const link = join(dir, 'link.txt') + symlinkSync('missing.txt', link) + + await saveToFile(bodyOf(['new']), link, true) + + expect(readFileSync(target, 'utf8')).toBe('new') + expect(readFileSync(link, 'utf8')).toBe('new') + expect(lstatSync(link).isSymbolicLink()).toBe(true) + }) }) describe('isTerminalSafeContentType', () => { diff --git a/packages/sim-cli/src/commands/protocol/files-get.ts b/packages/sim-cli/src/commands/protocol/files-get.ts index 68b16883bc2..1d76009f95a 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -1,7 +1,7 @@ import { once } from 'node:events' import { createWriteStream, type WriteStream } from 'node:fs' -import { lstat, mkdtemp, open, realpath, rename, rm } from 'node:fs/promises' -import { dirname, join } from 'node:path' +import { lstat, mkdtemp, open, readlink, rename, rm } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' import { Readable, type Writable } from 'node:stream' import { pipeline } from 'node:stream/promises' import type { Command } from 'commander' @@ -22,15 +22,27 @@ function writeFailure(path: WriteStream['path'], error: unknown): SimApiError { } async function forcedPublicationTarget(target: string): Promise<string> { - let metadata - try { - metadata = await lstat(target) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return target - throw error - } + let candidate = target + const visited = new Set<string>() + + while (true) { + const absoluteCandidate = resolve(candidate) + if (visited.has(absoluteCandidate)) { + throw Object.assign(new Error(`Symbolic link loop at ${target}`), { code: 'ELOOP' }) + } + visited.add(absoluteCandidate) - return metadata.isSymbolicLink() ? realpath(target) : target + let metadata + try { + metadata = await lstat(candidate) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return candidate + throw error + } + + if (!metadata.isSymbolicLink()) return candidate + candidate = resolve(dirname(candidate), await readlink(candidate)) + } } function normalizedWriteFailure(target: string, error: unknown): SimApiError { From 18fc3775b401cf79e2259ba3e4dbbf046943903c Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 15 Aug 2026 00:48:05 -0700 Subject: [PATCH 155/159] fix(cli): keep new downloads atomic --- .../src/commands/protocol/files-get.ts | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/sim-cli/src/commands/protocol/files-get.ts b/packages/sim-cli/src/commands/protocol/files-get.ts index 1d76009f95a..ae325c09816 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -1,6 +1,6 @@ import { once } from 'node:events' import { createWriteStream, type WriteStream } from 'node:fs' -import { lstat, mkdtemp, open, readlink, rename, rm } from 'node:fs/promises' +import { link, lstat, mkdtemp, readlink, rename, rm } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { Readable, type Writable } from 'node:stream' import { pipeline } from 'node:stream/promises' @@ -60,6 +60,15 @@ function combinedCleanupFailure( ) } +function unsupportedAtomicPublish(target: string, error: unknown): SimApiError | null { + const code = (error as NodeJS.ErrnoException).code + if (!['ENOSYS', 'ENOTSUP', 'EOPNOTSUPP', 'EPERM'].includes(code ?? '')) return null + return new SimApiError( + `Could not publish ${target} without overwrite protection because this filesystem does not support atomic hard links. Re-run with --force to publish the completed download with an atomic rename.`, + 0 + ) +} + /** Streams a fetch body to disk while honoring write-stream backpressure. */ export async function streamToFile( body: ReadableStream<Uint8Array>, @@ -73,36 +82,28 @@ export async function streamToFile( } } -async function saveNewFile(body: ReadableStream<Uint8Array>, target: string): Promise<void> { - let created = false - - try { - const file = await open(target, 'wx') - created = true - await streamToFile(body, file.createWriteStream(), target) - } catch (error) { - const failure = normalizedWriteFailure(target, error) - if (!created) throw failure - - try { - await rm(target, { force: true }) - } catch (cleanupError) { - throw combinedCleanupFailure(failure, target, cleanupError) - } - throw failure - } -} - -async function saveForcedFile(body: ReadableStream<Uint8Array>, target: string): Promise<void> { +async function saveStagedFile( + body: ReadableStream<Uint8Array>, + target: string, + force: boolean +): Promise<void> { let temporaryDirectory: string | null = null let failure: SimApiError | null = null try { - const publicationTarget = await forcedPublicationTarget(target) + const publicationTarget = force ? await forcedPublicationTarget(target) : target temporaryDirectory = await mkdtemp(join(dirname(publicationTarget), '.sim-download-')) const temporaryPath = join(temporaryDirectory, 'payload') await streamToFile(body, createWriteStream(temporaryPath, { flags: 'wx' }), target) - await rename(temporaryPath, publicationTarget) + if (force) { + await rename(temporaryPath, publicationTarget) + } else { + try { + await link(temporaryPath, publicationTarget) + } catch (error) { + throw unsupportedAtomicPublish(target, error) ?? error + } + } } catch (error) { failure = normalizedWriteFailure(target, error) } @@ -122,14 +123,13 @@ async function saveForcedFile(body: ReadableStream<Uint8Array>, target: string): if (failure) throw failure } -/** Saves without overwriting by default; forced writes publish only after the body is complete. */ +/** Publishes a complete staged body atomically, with overwrite requiring explicit force. */ export async function saveToFile( body: ReadableStream<Uint8Array>, target: string, force: boolean ): Promise<void> { - if (force) return saveForcedFile(body, target) - return saveNewFile(body, target) + return saveStagedFile(body, target, force) } /** Streams a fetch body to stdout without closing the process-wide stream. */ From 8027afc93e66929c4135cf076e543aad05cd5d0d Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 15 Aug 2026 01:14:30 -0700 Subject: [PATCH 156/159] refactor(cli): remove unrelated Copilot changes --- apps/docs/openapi-core.json | 757 +----------------- apps/sim/app/api/knowledge/utils.test.ts | 34 +- apps/sim/app/api/knowledge/utils.ts | 37 +- .../app/workspace/[workspaceId]/home/types.ts | 22 +- .../lib/copilot/async-runs/repository.test.ts | 22 - apps/sim/lib/copilot/async-runs/repository.ts | 5 +- apps/sim/lib/copilot/chat/lifecycle.test.ts | 54 -- apps/sim/lib/copilot/chat/lifecycle.ts | 68 +- .../sim/lib/copilot/chat/persisted-message.ts | 30 - apps/sim/lib/copilot/chat/post.ts | 39 +- .../request/context/request-context.ts | 1 - apps/sim/lib/copilot/request/go/stream.ts | 4 - .../copilot/request/handlers/handlers.test.ts | 113 +-- .../lib/copilot/request/lifecycle/headless.ts | 9 +- .../lifecycle/resume-leg-context.test.ts | 1 - .../copilot/request/lifecycle/start.test.ts | 134 +--- .../lib/copilot/request/lifecycle/start.ts | 24 +- .../copilot/request/session/abort-reason.ts | 2 - .../lib/copilot/request/session/abort.test.ts | 65 +- apps/sim/lib/copilot/request/session/abort.ts | 116 +-- .../request/session/explicit-abort.test.ts | 24 +- .../copilot/request/session/explicit-abort.ts | 6 +- .../copilot/request/tools/executor.test.ts | 95 +-- .../sim/lib/copilot/request/tools/executor.ts | 320 +++----- .../copilot/request/tools/permission.test.ts | 38 - .../lib/copilot/request/tools/permission.ts | 6 +- .../request/tools/workflow-context.test.ts | 16 - apps/sim/lib/copilot/request/types.ts | 14 - .../copilot/tool-executor/executor.test.ts | 205 ----- .../sim/lib/copilot/tool-executor/executor.ts | 84 -- apps/sim/lib/copilot/tool-executor/types.ts | 4 - .../lib/copilot/tools/client/store-utils.ts | 84 +- .../lib/copilot/tools/handlers/access.test.ts | 72 -- apps/sim/lib/copilot/tools/handlers/access.ts | 21 +- .../handlers/deployment/custom-block.test.ts | 2 +- .../tools/handlers/deployment/custom-block.ts | 2 +- .../tools/handlers/deployment/manage.test.ts | 71 -- .../tools/handlers/deployment/manage.ts | 6 +- .../tools/handlers/function-execute.test.ts | 20 - .../management/manage-custom-tool.test.ts | 114 --- .../handlers/management/manage-custom-tool.ts | 51 +- .../management/manage-mcp-tool.test.ts | 77 -- .../handlers/management/manage-mcp-tool.ts | 2 +- .../tools/handlers/materialize-file.ts | 2 +- .../lib/copilot/tools/handlers/vfs.test.ts | 43 - apps/sim/lib/copilot/tools/handlers/vfs.ts | 12 - .../tools/registry/server-tool-adapter.ts | 2 - .../sim/lib/copilot/tools/server/base-tool.ts | 2 - .../server/docs/search-documentation.test.ts | 18 +- .../tools/server/docs/search-documentation.ts | 12 +- .../tools/server/table/user-table.test.ts | 25 +- .../user/set-environment-variables.test.ts | 6 +- .../server/user/set-environment-variables.ts | 13 +- .../workflow/edit-workflow/index.test.ts | 185 ----- .../server/workflow/edit-workflow/index.ts | 8 +- .../tools/server/workflow/query-logs.ts | 2 +- .../tools/shared/workflow-utils.test.ts | 41 - .../copilot/tools/shared/workflow-utils.ts | 23 +- .../sim/lib/copilot/tools/subagent-display.ts | 29 - .../lib/copilot/tools/tool-display.test.ts | 15 - apps/sim/lib/copilot/tools/tool-display.ts | 66 +- apps/sim/lib/copilot/vfs/serializers.test.ts | 15 - apps/sim/lib/copilot/vfs/serializers.ts | 2 +- .../lib/workflows/credentials/constants.ts | 8 - .../credentials/credential-extractor.ts | 50 +- apps/sim/lib/workflows/execution-admission.ts | 5 +- apps/sim/lib/workflows/persistence/utils.ts | 9 +- 67 files changed, 433 insertions(+), 3031 deletions(-) delete mode 100644 apps/sim/lib/copilot/tools/handlers/access.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.test.ts delete mode 100644 apps/sim/lib/copilot/tools/shared/workflow-utils.test.ts delete mode 100644 apps/sim/lib/copilot/tools/subagent-display.ts delete mode 100644 apps/sim/lib/workflows/credentials/constants.ts diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index dfc07313e3d..b7020ae27f9 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -1,8 +1,8 @@ { "openapi": "3.1.0", "info": { - "title": "Sim API — Execution, Chat & Usage", - "description": "Run workflows, chat with a workspace, poll and cancel executions, resume Human-in-the-Loop pauses, and check usage limits.", + "title": "Sim API — Execution & Usage", + "description": "Run workflows, poll and cancel executions, resume Human-in-the-Loop pauses, and check usage limits.", "version": "1.0.0", "contact": { "name": "Sim Support", @@ -36,14 +36,6 @@ { "name": "Billing", "description": "Inspect billing status and credit-denominated ledger events" - }, - { - "name": "Chat", - "description": "Chat with a workspace through Mothership" - }, - { - "name": "Workspaces", - "description": "Resolve workspace metadata available to the authenticated credential" } ], "security": [ @@ -1026,685 +1018,6 @@ "parameters": [] } }, - "/api/v2/workspaces/{workspaceId}": { - "get": { - "operationId": "getWorkspace", - "summary": "Get Workspace", - "description": "Resolve a workspace ID to the display metadata available to the authenticated credential. The credential must have read access to the workspace.", - "tags": ["Workspaces"], - "security": [ - { - "apiKey": [] - } - ], - "parameters": [ - { - "name": "workspaceId", - "in": "path", - "required": true, - "schema": { - "type": "string", - "minLength": 1 - }, - "description": "Workspace to resolve." - } - ], - "responses": { - "200": { - "description": "The workspace's display metadata.", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["data"], - "properties": { - "data": { - "type": "object", - "required": [ - "id", - "name", - "color", - "logoUrl", - "mode", - "memberCount", - "createdAt", - "updatedAt" - ], - "properties": { - "id": { "type": "string", "minLength": 1 }, - "name": { "type": "string" }, - "color": { "type": "string" }, - "logoUrl": { "type": ["string", "null"] }, - "mode": { - "type": "string", - "enum": ["personal", "organization", "grandfathered_shared"] - }, - "memberCount": { "type": "integer", "minimum": 0 }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } - } - } - } - }, - "example": { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Product Operations", - "color": "#7C3AED", - "logoUrl": null, - "mode": "organization", - "memberCount": 12, - "createdAt": "2026-08-07T18:00:00.000Z", - "updatedAt": "2026-08-07T18:30:00.000Z" - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/V2BadRequest" - }, - "401": { - "$ref": "#/components/responses/V2Unauthorized" - }, - "403": { - "$ref": "#/components/responses/V2Forbidden" - }, - "404": { - "$ref": "#/components/responses/V2NotFound" - }, - "429": { - "$ref": "#/components/responses/V2RateLimited" - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - } - } - } - }, - "/api/v2/chats": { - "get": { - "operationId": "listChats", - "summary": "List Sim Chats", - "description": "List a bounded page of the authenticated user's active workspace chats in the same pinned-first, recently-updated order used by the Sim Home UI. This personal history surface requires a personal API key; shared workspace keys cannot read their creator's private chats. Pass `nextCursor` back as `cursor` to load another page.", - "tags": ["Chat"], - "security": [ - { - "apiKey": [] - } - ], - "parameters": [ - { - "name": "workspaceId", - "in": "query", - "required": true, - "schema": { "type": "string" }, - "description": "Workspace whose chats should be listed." - }, - { - "name": "search", - "in": "query", - "required": false, - "schema": { "type": "string", "maxLength": 200 }, - "description": "Case-insensitive title substring." - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 30 }, - "description": "Maximum chats to return." - }, - { - "name": "cursor", - "in": "query", - "required": false, - "schema": { "type": "string" }, - "description": "Opaque cursor returned by the previous page." - } - ], - "responses": { - "200": { - "description": "A bounded page of chat summaries.", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["data", "nextCursor"], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": ["id", "title", "updatedAt", "pinned", "active"], - "properties": { - "id": { "type": "string" }, - "title": { "type": ["string", "null"] }, - "updatedAt": { "type": "string", "format": "date-time" }, - "pinned": { "type": "boolean" }, - "active": { "type": "boolean" } - } - } - }, - "nextCursor": { "type": ["string", "null"] } - } - }, - "example": { - "data": [ - { - "id": "80a47295-040e-46f9-9ea8-ad78eff3bcab", - "title": "Review release workflow", - "updatedAt": "2026-08-07T18:30:00.000Z", - "pinned": true, - "active": false - } - ], - "nextCursor": null - } - } - } - }, - "400": { - "$ref": "#/components/responses/V2BadRequest" - }, - "401": { - "$ref": "#/components/responses/V2Unauthorized" - }, - "403": { - "$ref": "#/components/responses/V2Forbidden" - }, - "429": { - "$ref": "#/components/responses/V2RateLimited" - } - } - } - }, - "/api/v2/chats/{chatId}": { - "get": { - "operationId": "getChat", - "summary": "Open Sim Chat", - "description": "Load one owned workspace chat as a display-safe user/assistant transcript and mint a fresh opaque continuation token for the requested safety mode. Internal tool payloads, stream IDs, resources, and replay metadata are not exposed. The subsequent chat POST still accepts only the continuation token, never this resource ID.", - "tags": ["Chat"], - "security": [ - { - "apiKey": [] - } - ], - "parameters": [ - { - "name": "chatId", - "in": "path", - "required": true, - "schema": { "type": "string" }, - "description": "Chat resource ID returned by List Sim Chats." - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "schema": { "type": "string" }, - "description": "Workspace the chat must belong to." - }, - { - "name": "readOnly", - "in": "query", - "required": false, - "schema": { "type": "boolean", "default": false }, - "description": "Mint a continuation token for the secretless read-only chat mode." - } - ], - "responses": { - "200": { - "description": "The chat transcript and a fresh continuation token.", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["data"], - "properties": { - "data": { - "type": "object", - "required": ["id", "title", "messages", "continuationToken", "active"], - "properties": { - "id": { "type": "string" }, - "title": { "type": ["string", "null"] }, - "messages": { - "type": "array", - "items": { - "type": "object", - "required": ["id", "role", "content", "timestamp"], - "properties": { - "id": { "type": "string" }, - "role": { "type": "string", "enum": ["user", "assistant"] }, - "content": { "type": "string" }, - "timestamp": { "type": "string", "format": "date-time" } - } - } - }, - "continuationToken": { "type": "string" }, - "active": { "type": "boolean" } - } - } - } - }, - "example": { - "data": { - "id": "80a47295-040e-46f9-9ea8-ad78eff3bcab", - "title": "Review release workflow", - "messages": [ - { - "id": "msg_1", - "role": "user", - "content": "Review the release workflow", - "timestamp": "2026-08-07T18:29:00.000Z" - }, - { - "id": "msg_2", - "role": "assistant", - "content": "The workflow is ready to release.", - "timestamp": "2026-08-07T18:30:00.000Z" - } - ], - "continuationToken": "sim-v2-chat-v1.opaque.refreshed", - "active": false - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/V2BadRequest" - }, - "401": { - "$ref": "#/components/responses/V2Unauthorized" - }, - "403": { - "$ref": "#/components/responses/V2Forbidden" - }, - "404": { - "$ref": "#/components/responses/V2NotFound" - }, - "429": { - "$ref": "#/components/responses/V2RateLimited" - } - } - }, - "patch": { - "operationId": "renameChat", - "summary": "Rename Sim Chat", - "description": "Rename an owned Sim Chat and synchronize the new title with the Sim Home chat list. This private history operation requires a personal API key; shared workspace keys cannot rename a creator's chats.", - "tags": ["Chat"], - "security": [ - { - "apiKey": [] - } - ], - "parameters": [ - { - "name": "chatId", - "in": "path", - "required": true, - "schema": { "type": "string" }, - "description": "Chat resource ID returned by List Sim Chats." - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": false, - "required": ["workspaceId", "title"], - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "Workspace the chat must belong to." - }, - "title": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "New chat title. Leading and trailing whitespace is removed." - } - } - }, - "example": { - "workspaceId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "title": "Incident investigation" - } - } - } - }, - "responses": { - "200": { - "description": "The renamed chat.", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["data"], - "properties": { - "data": { - "type": "object", - "required": ["id", "title"], - "properties": { - "id": { "type": "string" }, - "title": { "type": "string", "minLength": 1, "maxLength": 200 } - } - } - } - }, - "example": { - "data": { - "id": "80a47295-040e-46f9-9ea8-ad78eff3bcab", - "title": "Incident investigation" - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/V2BadRequest" - }, - "401": { - "$ref": "#/components/responses/V2Unauthorized" - }, - "403": { - "$ref": "#/components/responses/V2Forbidden" - }, - "404": { - "$ref": "#/components/responses/V2NotFound" - }, - "429": { - "$ref": "#/components/responses/V2RateLimited" - } - } - } - }, - "/api/v2/chat": { - "post": { - "operationId": "chat", - "summary": "Ask Sim Chat", - "description": "Chat with the Mothership agent for a workspace. Personal API keys use their owner's current workspace permission, integrations, credentials, environment context, and memory, and their conversations are synchronized with the Sim Home chat history. Shared workspace keys retain normal workspace capabilities but do not inherit a human owner's personal integrations, secrets, environment, memory, or private chat history. Set `readOnly` to select the subtractive, secretless workspace-query policy. Omit `continuationToken` for a one-shot or first interactive turn, then send the latest opaque token returned by the stream to continue the same conversation. Tokens are bound to the workspace, authorization principal, credential type, and read-only mode, and expire on a rolling 24-hour window; this chat POST never accepts a raw chat ID. The response is a Server-Sent Events stream. `text` events contain incremental assistant output and `complete` contains the authoritative final result. Comment frames are heartbeats and `data: [DONE]` closes a successful stream. The caller's Sim API key selects and authorizes the local workspace but is never forwarded to Mothership. Workspace keys use the workspace billing account as their system actor while local tool authorization remains bound to the key owner; personal keys use their owner. Inline attachments are base64-only: paths and URLs are not accepted or resolved.", - "tags": ["Chat"], - "security": [ - { - "apiKey": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": false, - "required": ["workspaceId", "prompt"], - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "description": "Workspace Sim Chat should operate in." - }, - "prompt": { - "type": "string", - "maxLength": 10485760, - "x-maxUtf8Bytes": 10485760, - "description": "The instruction or question for Sim Chat. UTF-8 input is limited to 10 MiB. It may be empty or whitespace only when at least one attachment is present; the server supplies a neutral inspect-the-attachments instruction in that case." - }, - "continuationToken": { - "type": "string", - "minLength": 1, - "maxLength": 4096, - "description": "Latest opaque continuation token returned by a prior `session` or `complete` event. Never send a raw chat or conversation ID." - }, - "readOnly": { - "type": "boolean", - "default": false, - "description": "Use the secretless, read-only workspace-query policy. The default keeps normal workspace capabilities; shared workspace credentials still exclude personal integrations, secrets, environment, and persistent memory." - }, - "attachments": { - "type": "array", - "maxItems": 5, - "description": "Optional inline attachments, accepted on initial and continuation turns. Decoded aggregate size is limited to 10 MiB. Images and PDFs are limited to 5 MiB each; UTF-8 text is limited to 200 KiB each. Each image may be at most 8192 pixels on either axis and 16,000,000 total pixels; all images in one request may total at most 32,000,000 decoded pixels.", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["name", "mediaType", "data"], - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "File basename only. Directory separators and control characters are rejected." - }, - "mediaType": { - "type": "string", - "enum": [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "application/pdf", - "text/plain", - "text/markdown", - "text/csv", - "text/tab-separated-values", - "text/html", - "text/css", - "text/javascript", - "text/typescript", - "text/xml", - "text/yaml", - "application/json", - "application/jsonl", - "application/x-ndjson", - "application/xml", - "application/yaml", - "application/x-yaml", - "application/toml" - ], - "description": "Declared MIME type. Image and PDF bytes are sniffed; text must decode as UTF-8." - }, - "data": { - "type": "string", - "minLength": 4, - "maxLength": 13981016, - "contentEncoding": "base64", - "description": "Canonical standard base64 bytes. Data URLs and base64url are not accepted." - } - } - } - }, - "contexts": { - "type": "array", - "maxItems": 50, - "description": "Optional identity-bearing workspace resources, skills, and MCP servers to inject for this turn. Resource kinds correspond to `@` tags; `skill` and `mcp` correspond to `/` tags. MCP contexts are ignored for read-only requests and shared workspace API keys.", - "items": { - "oneOf": [ - { - "type": "object", - "additionalProperties": false, - "required": ["kind", "workflowId", "label"], - "properties": { - "kind": { "type": "string", "const": "workflow" }, - "workflowId": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "label": { "type": "string", "minLength": 1, "maxLength": 255 } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": ["kind", "tableId", "label"], - "properties": { - "kind": { "type": "string", "const": "table" }, - "tableId": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "label": { "type": "string", "minLength": 1, "maxLength": 255 } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": ["kind", "fileId", "label"], - "properties": { - "kind": { "type": "string", "const": "file" }, - "fileId": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "label": { "type": "string", "minLength": 1, "maxLength": 255 } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": ["kind", "knowledgeId", "label"], - "properties": { - "kind": { "type": "string", "const": "knowledge" }, - "knowledgeId": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "label": { "type": "string", "minLength": 1, "maxLength": 255 } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": ["kind", "executionId", "label"], - "properties": { - "kind": { "type": "string", "const": "logs" }, - "executionId": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "label": { "type": "string", "minLength": 1, "maxLength": 255 } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": ["kind", "skillId", "label"], - "properties": { - "kind": { "type": "string", "const": "skill" }, - "skillId": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "label": { "type": "string", "minLength": 1, "maxLength": 255 } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": ["kind", "serverId", "label"], - "properties": { - "kind": { "type": "string", "const": "mcp" }, - "serverId": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "label": { "type": "string", "minLength": 1, "maxLength": 255 } - } - } - ] - } - } - } - }, - "example": { - "workspaceId": "ws_abc123", - "prompt": "Summarize the attached notes and compare them with this workspace.", - "attachments": [ - { - "name": "notes.md", - "mediaType": "text/markdown", - "data": "IyBOb3Rlcwo=" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "A Sim Chat SSE stream.", - "headers": { - "X-RateLimit-Limit": { - "description": "API request bucket capacity.", - "schema": { "type": "integer" } - }, - "X-RateLimit-Remaining": { - "description": "Requests remaining in the current bucket.", - "schema": { "type": "integer" } - }, - "X-RateLimit-Reset": { - "description": "When the current API request bucket resets.", - "schema": { "type": "string", "format": "date-time" } - } - }, - "content": { - "text/event-stream": { - "schema": { "type": "string" }, - "example": "data: {\"type\":\"session\",\"continuationToken\":\"sim-v2-chat-v1.opaque.refreshed\",\"requestId\":\"req_123\",\"chatId\":\"80a47295-040e-46f9-9ea8-ad78eff3bcab\"}\n\ndata: {\"type\":\"text\",\"delta\":\"Two workflows...\"}\n\ndata: {\"type\":\"complete\",\"data\":{\"content\":\"Two workflows...\",\"continuationToken\":\"sim-v2-chat-v1.opaque.refreshed\",\"usage\":{\"prompt\":120,\"completion\":18,\"total\":138}}}\n\ndata: [DONE]\n\n" - } - } - }, - "400": { - "$ref": "#/components/responses/V2BadRequest" - }, - "401": { - "$ref": "#/components/responses/V2Unauthorized" - }, - "402": { - "$ref": "#/components/responses/V2UsageLimitExceeded" - }, - "403": { - "$ref": "#/components/responses/V2Forbidden" - }, - "404": { - "$ref": "#/components/responses/V2NotFound" - }, - "409": { - "$ref": "#/components/responses/V2Conflict" - }, - "413": { - "$ref": "#/components/responses/V2PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/V2UnsupportedMediaType" - }, - "429": { - "$ref": "#/components/responses/V2RateLimited" - }, - "503": { - "$ref": "#/components/responses/V2ServiceUnavailable" - } - } - } - }, "/api/v2/billing/status": { "get": { "operationId": "getBillingStatus", @@ -1927,7 +1240,7 @@ "source", "workspaceId", "workflow", - "runId", + "executionId", "creditCost" ], "properties": { @@ -1974,7 +1287,7 @@ } ] }, - "runId": { + "executionId": { "type": ["string", "null"] }, "creditCost": { @@ -1998,7 +1311,7 @@ "source": "sim-chat", "workspaceId": "ws_1", "workflow": null, - "runId": null, + "executionId": null, "creditCost": 12 } ], @@ -2935,56 +2248,6 @@ } } }, - "V2NotFound": { - "description": "The requested resource does not exist or is not visible to the credential.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "V2Conflict": { - "description": "The chat already has a response in progress. Retry after that response finishes.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "V2UsageLimitExceeded": { - "description": "The resolved workspace payer or organization member has reached a usage limit.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "V2PayloadTooLarge": { - "description": "The request body or decoded attachment limits were exceeded.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "V2UnsupportedMediaType": { - "description": "An attachment media type or its decoded bytes are unsupported.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, "V2RateLimited": { "description": "Rate limit exceeded; retry after the window resets.", "content": { @@ -2994,16 +2257,6 @@ } } } - }, - "V2ServiceUnavailable": { - "description": "Sim Chat is not configured or temporarily unavailable.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } } } } diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index de84bc92aa7..d7d0ea2999d 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -234,16 +234,6 @@ describe('Knowledge Utils', () => { expect(result.hasAccess).toBe(false) expect('notFound' in result && result.notFound).toBe(true) }) - - it('treats a knowledge base outside the trusted workspace as not found', async () => { - queueTableRows(schemaMock.knowledgeBase, [ - { id: 'kb1', userId: 'user1', workspaceId: 'workspace-2' }, - ]) - - const result = await checkKnowledgeBaseAccess('kb1', 'user1', 'workspace-1') - - expect(result).toEqual({ hasAccess: false, notFound: true }) - }) }) describe('checkDocumentAccess', () => { @@ -363,17 +353,23 @@ describe('Knowledge Utils', () => { it('should throw error when no API configuration provided', async () => { const { env } = await import('@/lib/core/config/env') Object.keys(env).forEach((key) => delete (env as any)[key]) - Object.assign(env, { - OPENAI_API_KEY: undefined, - OPENAI_API_KEY_1: undefined, - OPENAI_API_KEY_2: undefined, - OPENAI_API_KEY_3: undefined, - OPENROUTER_API_KEY: undefined, + // The env object lazily reads process.env, so a developer's local .env + // keys survive the deletion above — stub the direct key empty and fail + // the hosted rotation fallback for hermeticity on any machine. + vi.stubEnv('OPENAI_API_KEY', '') + const apiKeysModule = await import('@/lib/core/config/api-keys') + const rotationSpy = vi.spyOn(apiKeysModule, 'getRotatingApiKey').mockImplementation(() => { + throw new Error('No rotation keys configured') }) - await expect(generateEmbeddings(['test text'])).rejects.toThrow( - 'OPENAI_API_KEY is not configured' - ) + try { + await expect(generateEmbeddings(['test text'])).rejects.toThrow( + 'OPENAI_API_KEY is not configured' + ) + } finally { + rotationSpy.mockRestore() + vi.unstubAllEnvs() + } }) }) }) diff --git a/apps/sim/app/api/knowledge/utils.ts b/apps/sim/app/api/knowledge/utils.ts index 11fac039123..e92dc49f419 100644 --- a/apps/sim/app/api/knowledge/utils.ts +++ b/apps/sim/app/api/knowledge/utils.ts @@ -163,8 +163,7 @@ export type ChunkAccessCheck = ChunkAccessResult | ChunkAccessDenied async function resolveKnowledgeBaseAccess( knowledgeBaseId: string, userId: string, - requireWrite: boolean, - workspaceId?: string + requireWrite: boolean ): Promise<KnowledgeBaseAccessCheck> { const kb = await db .select({ @@ -184,10 +183,6 @@ async function resolveKnowledgeBaseAccess( const kbData = kb[0] - if (workspaceId && kbData.workspaceId !== workspaceId) { - return { hasAccess: false, notFound: true } - } - if (kbData.workspaceId) { // Workspace KB: use workspace permissions only const userPermission = await getUserEntityPermissions(userId, 'workspace', kbData.workspaceId) @@ -210,10 +205,9 @@ async function resolveKnowledgeBaseAccess( */ export async function checkKnowledgeBaseAccess( knowledgeBaseId: string, - userId: string, - workspaceId?: string + userId: string ): Promise<KnowledgeBaseAccessCheck> { - return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, false, workspaceId) + return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, false) } /** @@ -225,10 +219,9 @@ export async function checkKnowledgeBaseAccess( */ export async function checkKnowledgeBaseWriteAccess( knowledgeBaseId: string, - userId: string, - workspaceId?: string + userId: string ): Promise<KnowledgeBaseAccessCheck> { - return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, true, workspaceId) + return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, true) } /** @@ -239,15 +232,9 @@ async function resolveDocumentAccess( knowledgeBaseId: string, documentId: string, userId: string, - requireWrite: boolean, - workspaceId?: string + requireWrite: boolean ): Promise<DocumentAccessCheck> { - const kbAccess = await resolveKnowledgeBaseAccess( - knowledgeBaseId, - userId, - requireWrite, - workspaceId - ) + const kbAccess = await resolveKnowledgeBaseAccess(knowledgeBaseId, userId, requireWrite) if (!kbAccess.hasAccess) { return { @@ -275,10 +262,9 @@ async function resolveDocumentAccess( export async function checkDocumentAccess( knowledgeBaseId: string, documentId: string, - userId: string, - workspaceId?: string + userId: string ): Promise<DocumentAccessCheck> { - return resolveDocumentAccess(knowledgeBaseId, documentId, userId, false, workspaceId) + return resolveDocumentAccess(knowledgeBaseId, documentId, userId, false) } /** @@ -288,10 +274,9 @@ export async function checkDocumentAccess( export async function checkDocumentWriteAccess( knowledgeBaseId: string, documentId: string, - userId: string, - workspaceId?: string + userId: string ): Promise<DocumentAccessCheck> { - return resolveDocumentAccess(knowledgeBaseId, documentId, userId, true, workspaceId) + return resolveDocumentAccess(knowledgeBaseId, documentId, userId, true) } /** diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 869e455653f..e6d21c27765 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -9,7 +9,6 @@ export type { MothershipResourceType, WorkspaceResourceRef, } from '@/lib/copilot/resources/types' -export { SUBAGENT_LABELS } from '@/lib/copilot/tools/subagent-display' /** Union of all valid context kind strings, derived from {@link ChatContext}. */ export type ChatContextKind = ChatContext['kind'] @@ -178,3 +177,24 @@ export interface ChatMessage { contexts?: ChatMessageContext[] requestId?: string } + +export const SUBAGENT_LABELS: Record<string, string> = { + workflow: 'Workflow Agent', + debug: 'Debug Agent', + deploy: 'Deploy Agent', + auth: 'Auth Agent', + research: 'Research Agent', + knowledge: 'Knowledge Agent', + table: 'Table Agent', + custom_tool: 'Custom Tool Agent', + scout: 'Scout Agent', + search: 'Search Agent', + superagent: 'Superagent', + run: 'Run Agent', + agent: 'Tools Agent', + // `job` retained as a backward-compat alias so historical transcripts still render a label. + job: 'Job Agent', + file: 'File Agent', + media: 'Media Agent', + browser: 'Browser Agent', +} as const diff --git a/apps/sim/lib/copilot/async-runs/repository.test.ts b/apps/sim/lib/copilot/async-runs/repository.test.ts index 1d5563b03a3..fcd9c01a4e7 100644 --- a/apps/sim/lib/copilot/async-runs/repository.test.ts +++ b/apps/sim/lib/copilot/async-runs/repository.test.ts @@ -11,7 +11,6 @@ import { completeAsyncToolCall, detachAsyncToolCall, getClaimedWorkflowExecutionId, - markAsyncToolRunning, recordToolPermissionDecision, releaseWorkflowToolExecutionClaim, replaceTerminalAsyncToolCallResult, @@ -133,27 +132,6 @@ describe('async tool repository single-row semantics', () => { ) }) - it('marks a Sim tool running only while its durable row is still live', async () => { - dbChainMockFns.returning.mockResolvedValueOnce([ - { - toolCallId: 'sim-tool', - status: 'running', - claimedBy: 'sim-stream', - }, - ]) - - await markAsyncToolRunning('sim-tool', 'sim-stream') - - const predicate = dbChainMockFns.where.mock.calls.at(-1)?.[0] - expect(predicate).toEqual({ - type: 'and', - conditions: [ - expect.objectContaining({ type: 'eq', right: 'sim-tool' }), - expect.objectContaining({ type: 'inArray', values: ['pending', 'running'] }), - ], - }) - }) - it('atomically binds an eligible workflow tool to one execution', async () => { dbChainMockFns.returning.mockResolvedValueOnce([ { diff --git a/apps/sim/lib/copilot/async-runs/repository.ts b/apps/sim/lib/copilot/async-runs/repository.ts index ae6e30c1854..58efd23875b 100644 --- a/apps/sim/lib/copilot/async-runs/repository.ts +++ b/apps/sim/lib/copilot/async-runs/repository.ts @@ -329,10 +329,7 @@ async function markAsyncToolStatus( } export async function markAsyncToolRunning(toolCallId: string, claimedBy: string) { - return markAsyncToolStatus(toolCallId, ASYNC_TOOL_STATUS.running, { claimedBy }, [ - ASYNC_TOOL_STATUS.pending, - ASYNC_TOOL_STATUS.running, - ]) + return markAsyncToolStatus(toolCallId, 'running', { claimedBy }) } export function getClaimedWorkflowExecutionId(claimedBy: string | null | undefined) { diff --git a/apps/sim/lib/copilot/chat/lifecycle.test.ts b/apps/sim/lib/copilot/chat/lifecycle.test.ts index fc4fa740fe9..46e5c63dc31 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.test.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.test.ts @@ -21,7 +21,6 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ import { getAccessibleCopilotChat, - getAccessibleCopilotChatContinuationMetadata, getAccessibleCopilotChatWithMessages, resolveOrCreateChat, } from '@/lib/copilot/chat/lifecycle' @@ -107,59 +106,6 @@ describe('lifecycle copilot chat reads (cutover to copilot_messages)', () => { expect(result?.messages).toEqual([]) }) - it('loads continuation metadata with a one-row probe and contexts-only MCP projection', async () => { - const continuationRow = { - id: chatRow.id, - userId: chatRow.userId, - workflowId: chatRow.workflowId, - workspaceId: chatRow.workspaceId, - type: chatRow.type, - title: chatRow.title, - } - dbChainMockFns.limit - .mockResolvedValueOnce([continuationRow]) - .mockResolvedValueOnce([{ id: 'message-1' }]) - dbChainMockFns.orderBy.mockResolvedValueOnce([ - { - contexts: [ - { kind: 'mcp', serverId: 'mcp-docs', label: 'Docs' }, - { kind: 'skill', skillId: 'skill-review', label: 'Review' }, - ], - }, - { contexts: [{ kind: 'mcp', serverId: 'mcp-docs', label: 'Docs again' }] }, - { contexts: [{ kind: 'mcp', serverId: 'mcp-issues', label: 'Issues' }] }, - ]) - - const result = await getAccessibleCopilotChatContinuationMetadata(CHAT_ID, USER_ID) - - expect(result).toEqual({ - ...continuationRow, - hasMessages: true, - mcpServerIds: ['mcp-docs', 'mcp-issues'], - }) - expect(dbChainMockFns.limit).toHaveBeenCalledTimes(2) - expect(dbChainMockFns.orderBy).toHaveBeenCalledTimes(1) - const contextsProjection = dbChainMockFns.select.mock.calls[2]?.[0] as Record<string, unknown> - expect(Object.keys(contextsProjection)).toEqual(['contexts']) - }) - - it('skips the MCP projection for an empty persisted chat', async () => { - const continuationRow = { - id: chatRow.id, - userId: chatRow.userId, - workflowId: chatRow.workflowId, - workspaceId: chatRow.workspaceId, - type: chatRow.type, - title: chatRow.title, - } - dbChainMockFns.limit.mockResolvedValueOnce([continuationRow]).mockResolvedValueOnce([]) - - const result = await getAccessibleCopilotChatContinuationMetadata(CHAT_ID, USER_ID) - - expect(result).toEqual({ ...continuationRow, hasMessages: false, mcpServerIds: [] }) - expect(dbChainMockFns.orderBy).not.toHaveBeenCalled() - }) - it('returns null and does NOT query messages when the chat is not found', async () => { dbChainMockFns.limit.mockResolvedValueOnce([]) diff --git a/apps/sim/lib/copilot/chat/lifecycle.ts b/apps/sim/lib/copilot/chat/lifecycle.ts index 381a227ed75..69b577a31e9 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.ts @@ -6,11 +6,7 @@ import { getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' import { and, asc, eq, isNull, sql } from 'drizzle-orm' -import { - collectChatMcpServerIds, - type PersistedMessage, - stripToolResultOutput, -} from '@/lib/copilot/chat/persisted-message' +import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message' import { assertActiveWorkspaceAccess, checkWorkspaceAccess, @@ -39,11 +35,6 @@ const copilotChatAuthColumns = { type: copilotChats.type, } as const -const copilotChatContinuationColumns = { - ...copilotChatAuthColumns, - title: copilotChats.title, -} as const - /** * Column set for chat-detail callers that need chat metadata. The conversation * transcript is no longer selected from `copilot_chats.messages` (JSONB) — @@ -112,12 +103,6 @@ type CopilotChatAuthRow = Pick< 'id' | 'userId' | 'workflowId' | 'workspaceId' | 'type' > -export type CopilotChatContinuationMetadata = CopilotChatAuthRow & { - title: string | null - hasMessages: boolean - mcpServerIds: string[] -} - export type CopilotChatDetailRow = Pick< typeof copilotChats.$inferSelect, | 'id' @@ -196,57 +181,6 @@ export async function getAccessibleCopilotChatAuth( return authorizeCopilotChatRow(chat, chatId, userId) } -/** - * Loads only the authorized metadata needed to continue a persisted chat. The - * one-row existence probe preserves first-turn title behavior, while the MCP - * query projects only user-message context arrays. Assistant/tool content is - * never loaded or normalized. - */ -export async function getAccessibleCopilotChatContinuationMetadata( - chatId: string, - userId: string -): Promise<CopilotChatContinuationMetadata | null> { - const [chat] = await db - .select(copilotChatContinuationColumns) - .from(copilotChats) - .where(ownedLiveChatWhere(chatId, userId)) - .limit(1) - - const authorized = await authorizeCopilotChatRow(chat, chatId, userId) - if (!authorized) return null - - const [message] = await db - .select({ id: copilotMessages.id }) - .from(copilotMessages) - .where(and(eq(copilotMessages.chatId, chatId), isNull(copilotMessages.deletedAt))) - .limit(1) - - if (!message) return { ...authorized, hasMessages: false, mcpServerIds: [] } - - const contextRows = await db - .select({ contexts: sql<unknown>`${copilotMessages.content} -> 'contexts'` }) - .from(copilotMessages) - .where( - and( - eq(copilotMessages.chatId, chatId), - eq(copilotMessages.role, 'user'), - isNull(copilotMessages.deletedAt), - sql`${copilotMessages.content} ? 'contexts'` - ) - ) - .orderBy( - sql`${copilotMessages.seq} asc nulls last`, - asc(copilotMessages.createdAt), - asc(copilotMessages.id) - ) - - return { - ...authorized, - hasMessages: true, - mcpServerIds: collectChatMcpServerIds(contextRows), - } -} - /** * Load a copilot chat row for the legacy chat detail endpoint, including the * transcript plus `model` and `config`. Drops `previewYaml` diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index b0efff5600f..54b66e90c95 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -127,36 +127,6 @@ export interface PersistedMessage { contexts?: PersistedMessageContext[] } -/** - * Collect the append-only MCP enablement carried by explicitly tagged user - * message contexts. Only ids move between turns: inherited contexts are not - * re-expanded into the prompt or persisted again as chips on later messages. - */ -export function collectChatMcpServerIds( - conversationHistory: readonly unknown[], - currentContexts?: unknown -): string[] { - const serverIds = new Set<string>() - - const collect = (contexts: unknown) => { - if (!Array.isArray(contexts)) return - for (const context of contexts) { - if (!context || typeof context !== 'object') continue - const { kind, serverId } = context as { kind?: unknown; serverId?: unknown } - if (kind === 'mcp' && typeof serverId === 'string' && serverId) { - serverIds.add(serverId) - } - } - } - - for (const message of conversationHistory) { - collect((message as { contexts?: unknown } | null)?.contexts) - } - collect(currentContexts) - - return Array.from(serverIds) -} - /** * Drop persisted tool outputs, keeping `success` and `error`. The one narrow * UI-state exception is a browser takeover's user-authored instruction, which diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 187d3d8c9aa..0bde82a6c45 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -16,7 +16,6 @@ import { buildCopilotRequestPayload } from '@/lib/copilot/chat/payload' import { buildPersistedAssistantMessage, buildPersistedUserMessage, - collectChatMcpServerIds, withStoppedContentBlock, } from '@/lib/copilot/chat/persisted-message' import { @@ -417,6 +416,44 @@ function normalizeContexts(contexts: UnifiedChatRequest['contexts']) { }) } +/** + * An MCP server tagged with `/name` stays enabled for the rest of the chat, not + * just the turn it was tagged on. Persisted user messages already carry their + * `mcp` contexts, so the transcript is the source of truth — enablement survives + * reloads and reopened chats with no extra state to keep in sync. There is + * deliberately no off switch: history is append-only. + * + * Only the ids travel forward, not the contexts themselves. The tools ride the + * tool array on every turn, so the model always sees their names and schemas; + * re-expanding the prompt listing each turn would just duplicate that. Keeping + * inherited servers out of the persisted contexts also keeps the `/name` chips + * on a sent message showing only what the user actually typed that turn. + */ +function collectChatMcpServerIds( + conversationHistory: unknown[], + currentContexts: UnifiedChatRequest['contexts'] +): string[] { + const serverIds = new Set<string>() + + const collect = (contexts: unknown) => { + if (!Array.isArray(contexts)) return + for (const ctx of contexts) { + if (!ctx || typeof ctx !== 'object') continue + const { kind, serverId } = ctx as { kind?: unknown; serverId?: unknown } + if (kind === 'mcp' && typeof serverId === 'string' && serverId) { + serverIds.add(serverId) + } + } + } + + for (const message of conversationHistory) { + collect((message as { contexts?: unknown } | null)?.contexts) + } + collect(currentContexts) + + return Array.from(serverIds) +} + async function resolveAgentContexts(params: { contexts?: UnifiedChatRequest['contexts'] resourceAttachments?: UnifiedChatRequest['resourceAttachments'] diff --git a/apps/sim/lib/copilot/request/context/request-context.ts b/apps/sim/lib/copilot/request/context/request-context.ts index ceefb46bfb1..1fd556a76bf 100644 --- a/apps/sim/lib/copilot/request/context/request-context.ts +++ b/apps/sim/lib/copilot/request/context/request-context.ts @@ -17,7 +17,6 @@ export function createStreamingContext(overrides?: Partial<StreamingContext>): S contentBlocks: [], toolCalls: new Map(), pendingToolPromises: new Map(), - inFlightToolExecutions: new Map(), currentThinkingBlock: null, subagentThinkingBlocks: new Map(), isInThinkingBlock: false, diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts index 699182b2df4..3377e6bd6b2 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -133,8 +133,6 @@ export interface StreamLoopOptions extends OrchestratorOptions { * Called when the Go backend's trace ID (go_trace_id) is first received via SSE. */ onGoTraceId?: (goTraceId: string) => void - /** Called once the upstream accepted this leg and exposed an SSE body. */ - onAccepted?: () => void otelContext?: Context } @@ -210,8 +208,6 @@ export async function runStreamLoop( throw new CopilotBackendError('Copilot backend response missing body') } - options.onAccepted?.() - context.trace.endSpan(fetchSpan) const bodySpan = context.trace.startSpan(`SSE Body → ${pathname}`, 'sim.http.stream_body', { diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index de513401ee5..9bcf12d9d7c 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -4,7 +4,6 @@ import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { TOOL_WATCHDOG_DEFAULT_MS } from '@/lib/copilot/constants' import { TraceCollector } from '@/lib/copilot/request/trace' const { isSimExecuted, executeTool, ensureHandlersRegistered, toolRequiresApproval } = vi.hoisted( @@ -16,13 +15,11 @@ const { isSimExecuted, executeTool, ensureHandlersRegistered, toolRequiresApprov }) ) -const { upsertAsyncToolCall, getAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall } = - vi.hoisted(() => ({ - upsertAsyncToolCall: vi.fn(), - getAsyncToolCall: vi.fn(), - markAsyncToolRunning: vi.fn(), - completeAsyncToolCall: vi.fn(), - })) +const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall } = vi.hoisted(() => ({ + upsertAsyncToolCall: vi.fn(), + markAsyncToolRunning: vi.fn(), + completeAsyncToolCall: vi.fn(), +})) const { waitForClientToolCompletion, waitForToolCompletion, waitForWorkflowToolCompletion } = vi.hoisted(() => ({ @@ -50,7 +47,7 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({ getLatestRunForStream: vi.fn(), getRunSegment: vi.fn(), createRunCheckpoint: vi.fn(), - getAsyncToolCall, + getAsyncToolCall: vi.fn(), markAsyncToolStatus: vi.fn(), listAsyncToolCallsForRun: vi.fn(), getAsyncToolCalls: vi.fn(), @@ -89,7 +86,6 @@ import { sseHandlers, subAgentHandlers, } from '@/lib/copilot/request/handlers' -import { cancelToolCallAndReport, executeToolAndReport } from '@/lib/copilot/request/tools/executor' import type { ExecutionContext, StreamEvent, StreamingContext } from '@/lib/copilot/request/types' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -101,7 +97,6 @@ describe('sse-handlers tool lifecycle', () => { vi.clearAllMocks() isSimExecuted.mockReturnValue(true) upsertAsyncToolCall.mockResolvedValue(null) - getAsyncToolCall.mockResolvedValue(null) markAsyncToolRunning.mockResolvedValue(null) completeAsyncToolCall.mockResolvedValue(null) waitForToolCompletion.mockResolvedValue(null) @@ -1324,102 +1319,6 @@ describe('sse-handlers tool lifecycle', () => { expect(updated?.error).toBe('Request aborted during tool execution') }) - it('creates and terminalizes the durable row when Stop wins before normal persistence', async () => { - context.runId = 'run-stop' - context.toolCalls.set('tool-stop', { - id: 'tool-stop', - name: ReadTool.id, - params: { path: 'WORKSPACE.md' }, - status: 'executing', - }) - - await cancelToolCallAndReport('tool-stop', context) - - expect(upsertAsyncToolCall).toHaveBeenCalledWith({ - runId: 'run-stop', - toolCallId: 'tool-stop', - toolName: ReadTool.id, - args: { path: 'WORKSPACE.md' }, - }) - expect(completeAsyncToolCall).toHaveBeenCalledWith({ - toolCallId: 'tool-stop', - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Stopped by user', - }) - expect(context.toolCalls.get('tool-stop')).toEqual( - expect.objectContaining({ - status: MothershipStreamV1ToolOutcome.cancelled, - result: { success: false }, - error: 'Stopped by user', - endTime: expect.any(Number), - }) - ) - }) - - it('does not execute after a durable cancellation wins the running transition', async () => { - context.runId = 'run-stop' - context.toolCalls.set('tool-stop', { - id: 'tool-stop', - name: ReadTool.id, - params: { path: 'WORKSPACE.md' }, - status: 'pending', - }) - getAsyncToolCall.mockResolvedValueOnce({ - toolCallId: 'tool-stop', - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Stopped by user', - }) - - const completion = await executeToolAndReport('tool-stop', context, execContext) - - expect(executeTool).not.toHaveBeenCalled() - expect(completion.status).toBe(MothershipStreamV1ToolOutcome.cancelled) - expect(context.toolCalls.get('tool-stop')).toEqual( - expect.objectContaining({ - status: MothershipStreamV1ToolOutcome.cancelled, - error: 'Stopped by user', - }) - ) - }) - - it('keeps a watchdog-timed-out raw handler tracked until it actually settles', async () => { - vi.useFakeTimers() - try { - let settleRawExecution!: (value: { success: boolean; output: { ok: boolean } }) => void - executeTool.mockReturnValueOnce( - new Promise((resolve) => { - settleRawExecution = resolve - }) - ) - markAsyncToolRunning.mockResolvedValueOnce({ - toolCallId: 'tool-timeout', - status: MothershipStreamV1AsyncToolRecordStatus.running, - }) - context.toolCalls.set('tool-timeout', { - id: 'tool-timeout', - name: ReadTool.id, - params: { path: 'WORKSPACE.md' }, - status: 'pending', - }) - - const reported = executeToolAndReport('tool-timeout', context, execContext) - await vi.waitFor(() => expect(context.inFlightToolExecutions?.has('tool-timeout')).toBe(true)) - - await vi.advanceTimersByTimeAsync(TOOL_WATCHDOG_DEFAULT_MS) - await reported - expect(context.inFlightToolExecutions?.has('tool-timeout')).toBe(true) - - settleRawExecution({ success: true, output: { ok: true } }) - await vi.waitFor(() => - expect(context.inFlightToolExecutions?.has('tool-timeout')).toBe(false) - ) - } finally { - vi.useRealTimers() - } - }) - it('does not replace an in-flight pending promise on duplicate tool_call', async () => { let resolveTool: ((value: { success: boolean; output: { ok: boolean } }) => void) | undefined executeTool.mockImplementationOnce( diff --git a/apps/sim/lib/copilot/request/lifecycle/headless.ts b/apps/sim/lib/copilot/request/lifecycle/headless.ts index 28a0f1b4f0c..0e5172280a9 100644 --- a/apps/sim/lib/copilot/request/lifecycle/headless.ts +++ b/apps/sim/lib/copilot/request/lifecycle/headless.ts @@ -55,15 +55,14 @@ export async function runHeadlessCopilotLifecycle( }) outcome = result.success ? RequestTraceV1Outcome.success - : options.userStopSignal?.aborted || options.abortSignal?.aborted || result.cancelled + : options.abortSignal?.aborted || result.cancelled ? RequestTraceV1Outcome.cancelled : RequestTraceV1Outcome.error return result } catch (error) { - outcome = - options.userStopSignal?.aborted || options.abortSignal?.aborted - ? RequestTraceV1Outcome.cancelled - : RequestTraceV1Outcome.error + outcome = options.abortSignal?.aborted + ? RequestTraceV1Outcome.cancelled + : RequestTraceV1Outcome.error throw error } finally { trace.endSpan( diff --git a/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts b/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts index a29590fd441..68fb457f076 100644 --- a/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts @@ -40,7 +40,6 @@ describe('resume leg context isolate/merge contract', () => { expect(leg.contentBlocks).toBe(base.contentBlocks) expect(leg.toolCalls).toBe(base.toolCalls) expect(leg.pendingToolPromises).toBe(base.pendingToolPromises) - expect(leg.inFlightToolExecutions).toBe(base.inFlightToolExecutions) expect(leg.subAgentContent).toBe(base.subAgentContent) }) diff --git a/apps/sim/lib/copilot/request/lifecycle/start.test.ts b/apps/sim/lib/copilot/request/lifecycle/start.test.ts index 00ace0f8d61..80f6bc94896 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/start.test.ts @@ -5,14 +5,7 @@ import { propagation, trace } from '@opentelemetry/api' import { W3CTraceContextPropagator } from '@opentelemetry/core' import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base' -import { - dbChainMockFns, - flattenMockConditions, - resetDbChainMock, - resetEnvFlagsMock, - schemaMock, - setEnvFlags, -} from '@sim/testing' +import { resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MothershipStreamV1CompletionStatus, @@ -33,8 +26,6 @@ const { cleanupAbortMarker, hasAbortMarker, releasePendingChatStream, - registerActiveStream, - startAbortPoller, fetchGo, } = vi.hoisted(() => ({ runCopilotLifecycle: vi.fn(), @@ -49,8 +40,6 @@ const { cleanupAbortMarker: vi.fn(), hasAbortMarker: vi.fn(), releasePendingChatStream: vi.fn(), - registerActiveStream: vi.fn(), - startAbortPoller: vi.fn().mockReturnValue(setInterval(() => {}, 999999)), fetchGo: vi.fn(), })) @@ -88,9 +77,9 @@ vi.mock('@/lib/copilot/request/session', () => ({ cleanupAbortMarker, hasAbortMarker, releasePendingChatStream, - registerActiveStream, + registerActiveStream: vi.fn(), unregisterActiveStream: vi.fn(), - startAbortPoller, + startAbortPoller: vi.fn().mockReturnValue(setInterval(() => {}, 999999)), isExplicitStopReason: vi.fn().mockReturnValue(false), SSE_RESPONSE_HEADERS: {}, StreamWriter: vi.fn().mockImplementation( @@ -138,7 +127,7 @@ vi.mock('@/lib/copilot/server/agent-url', () => ({ getMothershipSourceEnvHeaders: vi.fn().mockReturnValue({}), })) -import { createSSEStream, fireTitleGeneration, requestChatTitle } from './start' +import { createSSEStream, requestChatTitle } from './start' async function drainStream(stream: ReadableStream) { const reader = stream.getReader() @@ -301,48 +290,6 @@ describe('createSSEStream terminal error handling', () => { ) }) - it('registers and forwards distinct transport and explicit-stop signals', async () => { - runCopilotLifecycle.mockResolvedValue({ - success: true, - content: 'OK', - contentBlocks: [], - toolCalls: [], - }) - - const stream = createSSEStream({ - requestPayload: { message: 'hello' }, - userId: 'user-1', - streamId: 'stream-signals', - executionId: 'exec-signals', - runId: 'run-signals', - currentChat: null, - isNewChat: false, - message: 'hello', - titleModel: 'gpt-5.4', - requestId: 'req-signals', - orchestrateOptions: {}, - }) - - const [, transportController, userStopController] = registerActiveStream.mock.calls[0] - expect(transportController).toBeInstanceOf(AbortController) - expect(userStopController).toBeInstanceOf(AbortController) - expect(userStopController).not.toBe(transportController) - - await drainStream(stream) - - expect(startAbortPoller).toHaveBeenCalledWith( - 'stream-signals', - transportController, - expect.objectContaining({ userStopController }) - ) - expect(runCopilotLifecycle.mock.calls[0]?.[1]).toEqual( - expect.objectContaining({ - abortSignal: transportController.signal, - userStopSignal: userStopController.signal, - }) - ) - }) - it('passes an OTel context into the streaming lifecycle', async () => { let lifecycleTraceparent = '' runCopilotLifecycle.mockImplementation(async (_payload, options) => { @@ -477,76 +424,3 @@ describe('requestChatTitle billing protocol', () => { expect(headers['x-sim-billing-request-id']).toBeUndefined() }) }) - -describe('fireTitleGeneration rename ordering', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - setEnvFlags({ isHosted: true }) - setEnvFlags({ isCopilotBillingAttributionV1Enabled: true }) - fetchGo.mockResolvedValue( - new Response(JSON.stringify({ title: 'Generated title' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) - }) - - it('does not overwrite or publish over a title renamed while generation was running', async () => { - dbChainMockFns.returning.mockResolvedValueOnce([]) - const publish = vi.fn() - const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry() - - fireTitleGeneration({ - chatId: 'chat-1', - currentChat: null, - isNewChat: true, - userId: 'user-1', - message: 'Investigate the incident', - titleModel: 'claude-opus-4.8', - workspaceId: 'workspace-1', - billingAttribution: BILLING_ATTRIBUTION, - requestId: 'request-1', - publisher: { publish }, - resolvedSecretTraceRegistry, - }) - - await vi.waitFor(() => expect(dbChainMockFns.returning).toHaveBeenCalledTimes(1)) - await new Promise((resolve) => setImmediate(resolve)) - expect(dbChainMockFns.set).toHaveBeenCalledWith({ title: 'Generated title' }) - expect( - flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).some( - (condition) => - condition.type === 'isNull' && condition.column === schemaMock.copilotChats.title - ) - ).toBe(true) - expect(publish).not.toHaveBeenCalled() - }) - - it('publishes the generated title when the null-title update wins', async () => { - dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'chat-1' }]) - const publish = vi.fn() - const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry() - - fireTitleGeneration({ - chatId: 'chat-1', - currentChat: null, - isNewChat: true, - userId: 'user-1', - message: 'Investigate the incident', - titleModel: 'claude-opus-4.8', - workspaceId: 'workspace-1', - billingAttribution: BILLING_ATTRIBUTION, - requestId: 'request-1', - publisher: { publish }, - resolvedSecretTraceRegistry, - }) - - await vi.waitFor(() => - expect(publish).toHaveBeenCalledWith({ - type: 'session', - payload: { kind: 'title', title: 'Generated title' }, - }) - ) - }) -}) diff --git a/apps/sim/lib/copilot/request/lifecycle/start.ts b/apps/sim/lib/copilot/request/lifecycle/start.ts index 838f6b9bff3..5a6ed9f0bb2 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.ts +++ b/apps/sim/lib/copilot/request/lifecycle/start.ts @@ -3,7 +3,7 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { and, eq, isNull } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { assertBillingAttributionSnapshot, type BillingAttributionSnapshot, @@ -58,7 +58,7 @@ export { SSE_RESPONSE_HEADERS } const logger = createLogger('CopilotChatStreaming') -export type CurrentChatSummary = { +type CurrentChatSummary = { title?: string | null } | null @@ -118,8 +118,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS }) const abortController = new AbortController() - const userStopController = new AbortController() - registerActiveStream(streamId, abortController, userStopController) + registerActiveStream(streamId, abortController) const publisher = new StreamWriter({ streamId, chatId, requestId }) @@ -224,7 +223,6 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS const abortPoller = startAbortPoller(streamId, abortController, { requestId, chatId, - userStopController, }) publisher.startKeepalive() @@ -262,14 +260,10 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS simRequestId: requestId, otelContext, abortSignal: abortController.signal, - userStopSignal: userStopController.signal, onEvent: async (event) => { await publisher.publish(event) }, onAbortObserved: (reason) => { - if (isExplicitStopReason(reason) && !userStopController.signal.aborted) { - userStopController.abort(reason) - } if (!abortController.signal.aborted) { abortController.abort(reason) } @@ -428,8 +422,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS // Title generation (fire-and-forget side effect) -/** Starts the shared chat-title side effect without delaying the response stream. */ -export function fireTitleGeneration(params: { +function fireTitleGeneration(params: { chatId?: string currentChat: CurrentChatSummary isNewChat: boolean @@ -440,7 +433,7 @@ export function fireTitleGeneration(params: { workspaceId?: string billingAttribution?: BillingAttributionSnapshot requestId: string - publisher: Pick<StreamWriter, 'publish'> + publisher: StreamWriter otelContext?: Context }): void { const { @@ -470,12 +463,7 @@ export function fireTitleGeneration(params: { }) .then(async (title) => { if (!title) return - const [updated] = await db - .update(copilotChats) - .set({ title }) - .where(and(eq(copilotChats.id, chatId), isNull(copilotChats.title))) - .returning({ id: copilotChats.id }) - if (!updated) return + await db.update(copilotChats).set({ title }).where(eq(copilotChats.id, chatId)) await publisher.publish({ type: MothershipStreamV1EventType.session, payload: { kind: MothershipStreamV1SessionKind.title, title }, diff --git a/apps/sim/lib/copilot/request/session/abort-reason.ts b/apps/sim/lib/copilot/request/session/abort-reason.ts index 791b92b711e..8a6b281e2c0 100644 --- a/apps/sim/lib/copilot/request/session/abort-reason.ts +++ b/apps/sim/lib/copilot/request/session/abort-reason.ts @@ -30,8 +30,6 @@ export const AbortReason = { MarkerObservedAtBodyClose: 'redis_abort_marker:body_close', /** Internal timeout on the outbound explicit-abort fetch to Go. */ ExplicitAbortFetchTimeout: 'timeout:go_explicit_abort_fetch', - /** This handler no longer owns the per-chat lease and must stop writing. */ - LockOwnershipLost: 'chat_stream_lock:ownership_lost', } as const export type AbortReasonValue = (typeof AbortReason)[keyof typeof AbortReason] diff --git a/apps/sim/lib/copilot/request/session/abort.test.ts b/apps/sim/lib/copilot/request/session/abort.test.ts index 788eef3e833..2404b12dc5c 100644 --- a/apps/sim/lib/copilot/request/session/abort.test.ts +++ b/apps/sim/lib/copilot/request/session/abort.test.ts @@ -22,38 +22,12 @@ vi.mock('@/lib/copilot/request/otel', () => ({ })) import { - abortActiveStream, acquirePendingChatStream, getChatStreamLockOwners, - registerActiveStream, releasePendingChatStream, startAbortPoller, } from '@/lib/copilot/request/session/abort' -describe('active stream cancellation', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('fires both the transport and explicit-stop controllers', async () => { - const transportController = new AbortController() - const userStopController = new AbortController() - - registerActiveStream('stream-stop', transportController, userStopController) - - await expect(abortActiveStream('stream-stop')).resolves.toBe(true) - expect(mockWriteAbortMarker).toHaveBeenCalledWith('stream-stop') - expect(transportController.signal).toMatchObject({ - aborted: true, - reason: 'user_stop:abortActiveStream', - }) - expect(userStopController.signal).toMatchObject({ - aborted: true, - reason: 'user_stop:abortActiveStream', - }) - }) -}) - describe('startAbortPoller heartbeat', () => { beforeEach(() => { vi.clearAllMocks() @@ -130,7 +104,6 @@ describe('startAbortPoller heartbeat', () => { it('aborts the controller before clearing the marker so the marker is never observable as cleared while the signal is still unaborted', async () => { const controller = new AbortController() - const userStopController = new AbortController() const streamId = 'stream-order-1' let signalAbortedWhenMarkerCleared: boolean | null = null @@ -139,7 +112,7 @@ describe('startAbortPoller heartbeat', () => { }) mockHasAbortMarker.mockResolvedValueOnce(true) - const interval = startAbortPoller(streamId, controller, { userStopController }) + const interval = startAbortPoller(streamId, controller, {}) try { await vi.advanceTimersByTimeAsync(300) @@ -147,10 +120,6 @@ describe('startAbortPoller heartbeat', () => { expect(mockClearAbortMarker).toHaveBeenCalledWith(streamId) expect(signalAbortedWhenMarkerCleared).toBe(true) expect(controller.signal.aborted).toBe(true) - expect(userStopController.signal).toMatchObject({ - aborted: true, - reason: 'redis_abort_marker:poller', - }) } finally { clearInterval(interval) } @@ -174,22 +143,18 @@ describe('startAbortPoller heartbeat', () => { } }) - it('aborts the stale lifecycle and stops heartbeating after ownership is lost', async () => { + it('stops heartbeating after ownership is lost', async () => { const controller = new AbortController() - const userStopController = new AbortController() const streamId = 'stream-lost' const chatId = 'chat-lost' redisConfigMockFns.mockExtendLock.mockResolvedValueOnce(false) - const interval = startAbortPoller(streamId, controller, { chatId, userStopController }) + const interval = startAbortPoller(streamId, controller, { chatId }) try { await vi.advanceTimersByTimeAsync(21_000) expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) - expect(controller.signal.aborted).toBe(true) - expect(controller.signal.reason).toBe('chat_stream_lock:ownership_lost') - expect(userStopController.signal.reason).toBe('chat_stream_lock:ownership_lost') await vi.advanceTimersByTimeAsync(60_000) expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) @@ -197,30 +162,6 @@ describe('startAbortPoller heartbeat', () => { clearInterval(interval) } }) - - it('does not overlap heartbeat extensions when Redis is slow', async () => { - const controller = new AbortController() - let resolveExtend!: (owned: boolean) => void - redisConfigMockFns.mockExtendLock.mockReturnValueOnce( - new Promise<boolean>((resolve) => { - resolveExtend = resolve - }) - ) - - const interval = startAbortPoller('stream-slow', controller, { chatId: 'chat-slow' }) - try { - await vi.advanceTimersByTimeAsync(21_000) - expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(5_000) - expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) - - resolveExtend(true) - await vi.advanceTimersByTimeAsync(1) - } finally { - clearInterval(interval) - } - }) }) describe('getChatStreamLockOwners', () => { diff --git a/apps/sim/lib/copilot/request/session/abort.ts b/apps/sim/lib/copilot/request/session/abort.ts index 5483ddaf7ad..b081044f8eb 100644 --- a/apps/sim/lib/copilot/request/session/abort.ts +++ b/apps/sim/lib/copilot/request/session/abort.ts @@ -11,12 +11,7 @@ import { clearAbortMarker, hasAbortMarker, writeAbortMarker } from './buffer' const logger = createLogger('SessionAbort') -interface ActiveStreamEntry { - abortController: AbortController - userStopController: AbortController -} - -const activeStreams = new Map<string, ActiveStreamEntry>() +const activeStreams = new Map<string, AbortController>() const pendingChatStreams = new Map< string, { promise: Promise<void>; resolve: () => void; streamId: string } @@ -65,12 +60,8 @@ function getChatStreamLockKey(chatId: string): string { return `copilot:chat-stream-lock:${chatId}` } -export function registerActiveStream( - streamId: string, - abortController: AbortController, - userStopController: AbortController -): void { - activeStreams.set(streamId, { abortController, userStopController }) +export function registerActiveStream(streamId: string, controller: AbortController): void { + activeStreams.set(streamId, controller) } export function unregisterActiveStream(streamId: string): void { @@ -294,13 +285,12 @@ export async function abortActiveStream(streamId: string): Promise<boolean> { async (span) => { await writeAbortMarker(streamId) span.setAttribute(TraceAttr.CopilotAbortMarkerWritten, true) - const entry = activeStreams.get(streamId) - if (!entry) { + const controller = activeStreams.get(streamId) + if (!controller) { span.setAttribute(TraceAttr.CopilotAbortControllerFired, false) return false } - entry.userStopController.abort(AbortReason.UserStop) - entry.abortController.abort(AbortReason.UserStop) + controller.abort(AbortReason.UserStop) activeStreams.delete(streamId) span.setAttribute(TraceAttr.CopilotAbortControllerFired, true) return true @@ -336,17 +326,11 @@ const pollingStreams = new Set<string>() export function startAbortPoller( streamId: string, abortController: AbortController, - options?: { - pollMs?: number - requestId?: string - chatId?: string - userStopController?: AbortController - } + options?: { pollMs?: number; requestId?: string; chatId?: string } ): ReturnType<typeof setInterval> { const pollMs = options?.pollMs ?? DEFAULT_ABORT_POLL_MS const requestId = options?.requestId const chatId = options?.chatId - const userStopController = options?.userStopController let lastHeartbeatAt = Date.now() let heartbeatOwnershipLost = false @@ -357,60 +341,46 @@ export function startAbortPoller( void (async () => { try { - try { - const shouldAbort = await hasAbortMarker(streamId) - if (shouldAbort && !abortController.signal.aborted) { - userStopController?.abort(AbortReason.RedisPoller) - abortController.abort(AbortReason.RedisPoller) - await clearAbortMarker(streamId) - } - } catch (error) { - logger.warn('Failed to poll stream abort marker', { + const shouldAbort = await hasAbortMarker(streamId) + if (shouldAbort && !abortController.signal.aborted) { + abortController.abort(AbortReason.RedisPoller) + await clearAbortMarker(streamId) + } + } catch (error) { + logger.warn('Failed to poll stream abort marker', { + streamId, + ...(requestId ? { requestId } : {}), + error: toError(error).message, + }) + } finally { + pollingStreams.delete(streamId) + } + + if (!chatId || heartbeatOwnershipLost) return + if (Date.now() - lastHeartbeatAt < CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS) return + + try { + const owned = await extendLock( + getChatStreamLockKey(chatId), + streamId, + CHAT_STREAM_LOCK_TTL_SECONDS + ) + lastHeartbeatAt = Date.now() + if (!owned) { + heartbeatOwnershipLost = true + logger.warn('Lost ownership of chat stream lock — stopping heartbeat', { + chatId, streamId, ...(requestId ? { requestId } : {}), - error: toError(error).message, }) } - - if ( - chatId && - !heartbeatOwnershipLost && - Date.now() - lastHeartbeatAt >= CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS - ) { - try { - const owned = await extendLock( - getChatStreamLockKey(chatId), - streamId, - CHAT_STREAM_LOCK_TTL_SECONDS - ) - lastHeartbeatAt = Date.now() - if (!owned) { - heartbeatOwnershipLost = true - if (!userStopController?.signal.aborted) { - userStopController?.abort(AbortReason.LockOwnershipLost) - } - if (!abortController.signal.aborted) { - abortController.abort(AbortReason.LockOwnershipLost) - } - logger.warn('Lost ownership of chat stream lock — aborting stale stream', { - chatId, - streamId, - ...(requestId ? { requestId } : {}), - }) - } - } catch (error) { - logger.warn('Failed to extend chat stream lock TTL', { - chatId, - streamId, - ...(requestId ? { requestId } : {}), - error: toError(error).message, - }) - } - } - } finally { - // Cover both marker polling and the (potentially slower) lock EVAL so - // the 250ms timer cannot overlap heartbeats for one stream. - pollingStreams.delete(streamId) + } catch (error) { + logger.warn('Failed to extend chat stream lock TTL', { + chatId, + streamId, + ...(requestId ? { requestId } : {}), + error: toError(error).message, + }) } })() }, pollMs) diff --git a/apps/sim/lib/copilot/request/session/explicit-abort.test.ts b/apps/sim/lib/copilot/request/session/explicit-abort.test.ts index bd01932fab2..5cfcd9efadf 100644 --- a/apps/sim/lib/copilot/request/session/explicit-abort.test.ts +++ b/apps/sim/lib/copilot/request/session/explicit-abort.test.ts @@ -10,9 +10,8 @@ beforeAll(() => { afterAll(resetEnvMock) -const { mockFetchGo, mockGetMothershipBaseURL } = vi.hoisted(() => ({ +const { mockFetchGo } = vi.hoisted(() => ({ mockFetchGo: vi.fn(), - mockGetMothershipBaseURL: vi.fn().mockResolvedValue('https://copilot.test'), })) vi.mock('@/lib/copilot/request/go/fetch', () => ({ @@ -20,7 +19,7 @@ vi.mock('@/lib/copilot/request/go/fetch', () => ({ })) vi.mock('@/lib/copilot/server/agent-url', () => ({ - getMothershipBaseURL: mockGetMothershipBaseURL, + getMothershipBaseURL: vi.fn().mockResolvedValue('https://copilot.test'), getMothershipSourceEnvHeaders: vi.fn().mockReturnValue({ 'X-Sim-Source-Env': 'test' }), })) @@ -49,23 +48,4 @@ describe('requestExplicitStreamAbort', () => { }) ) }) - - it('routes separately from the execution owner stamped into the abort body', async () => { - await requestExplicitStreamAbort({ - streamId: 'stream-1', - userId: 'workspace-billing-actor', - routingUserId: 'workspace-key-owner', - workspaceId: 'workspace-1', - }) - - expect(mockGetMothershipBaseURL).toHaveBeenCalledWith({ - userId: 'workspace-key-owner', - }) - const request = mockFetchGo.mock.calls[0]?.[1] as RequestInit - expect(JSON.parse(String(request.body))).toEqual({ - messageId: 'stream-1', - userId: 'workspace-billing-actor', - workspaceId: 'workspace-1', - }) - }) }) diff --git a/apps/sim/lib/copilot/request/session/explicit-abort.ts b/apps/sim/lib/copilot/request/session/explicit-abort.ts index b6021e1053b..37fe00f1343 100644 --- a/apps/sim/lib/copilot/request/session/explicit-abort.ts +++ b/apps/sim/lib/copilot/request/session/explicit-abort.ts @@ -13,10 +13,7 @@ export const DEFAULT_EXPLICIT_ABORT_TIMEOUT_MS = 3000 export async function requestExplicitStreamAbort(params: { streamId: string - /** Authenticated execution/billing owner stamped into the Go request body. */ userId: string - /** Sim principal whose environment override selects the Mothership URL. */ - routingUserId?: string chatId?: string workspaceId?: string timeoutMs?: number @@ -25,7 +22,6 @@ export async function requestExplicitStreamAbort(params: { const { streamId, userId, - routingUserId, chatId, workspaceId, timeoutMs = DEFAULT_EXPLICIT_ABORT_TIMEOUT_MS, @@ -48,7 +44,7 @@ export async function requestExplicitStreamAbort(params: { ) try { - const mothershipBaseURL = await getMothershipBaseURL({ userId: routingUserId ?? userId }) + const mothershipBaseURL = await getMothershipBaseURL({ userId }) const response = await fetchGo(`${mothershipBaseURL}/api/streams/explicit-abort`, { method: 'POST', headers, diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts index b9b1f5f5135..06c86e224fa 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -1,15 +1,12 @@ import '@sim/testing/mocks/executor' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' const { executeTool, completeAsyncToolCall, - getAsyncToolCall, markAsyncToolRunning, upsertAsyncToolCall, - publishToolConfirmation, onEvent, recordSimToolMetric, setAttribute, @@ -19,10 +16,8 @@ const { return { executeTool: vi.fn(), completeAsyncToolCall: vi.fn(), - getAsyncToolCall: vi.fn().mockResolvedValue(null), markAsyncToolRunning: vi.fn(), upsertAsyncToolCall: vi.fn(), - publishToolConfirmation: vi.fn(), onEvent: vi.fn(), recordSimToolMetric: vi.fn(), setAttribute, @@ -40,13 +35,12 @@ vi.mock('@/lib/copilot/tool-executor', () => ({ vi.mock('@/lib/copilot/async-runs/repository', () => ({ completeAsyncToolCall, - getAsyncToolCall, markAsyncToolRunning, upsertAsyncToolCall, })) vi.mock('@/lib/copilot/persistence/tool-confirm', () => ({ - publishToolConfirmation, + publishToolConfirmation: vi.fn(), })) vi.mock('@/lib/copilot/request/metrics', () => ({ @@ -83,7 +77,6 @@ import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothershi import { createStreamingContext } from '@/lib/copilot/request/context/request-context' import { buildToolExecutionContext, - cancelToolCallAndReport, executeToolAndReport, pendingToolWaitBudgetMs, toolWatchdogTimeoutMs, @@ -409,89 +402,3 @@ describe('executeToolAndReport metrics', () => { } ) }) - -describe('buildToolExecutionContext authorization and stop signals', () => { - it('projects the authorization principal while retaining the immutable billing actor', () => { - const billingAttribution: BillingAttributionSnapshot = { - actorUserId: 'workspace-billed-account', - workspaceId: 'workspace-1', - organizationId: null, - billedAccountUserId: 'workspace-billed-account', - billingEntity: { type: 'user', id: 'workspace-billed-account' }, - billingPeriod: { start: '2026-07-01', end: '2026-08-01' }, - payerSubscription: null, - } - const executionContext: ExecutionContext = { - userId: 'workspace-billed-account', - authorizationUserId: 'workspace-key-owner', - workflowId: '', - workspaceId: 'workspace-1', - billingAttribution, - } - - const toolContext = buildToolExecutionContext({ id: 'call-1' }, executionContext) - expect(toolContext).toMatchObject({ - userId: 'workspace-key-owner', - workspaceId: 'workspace-1', - toolCallId: 'call-1', - }) - expect(toolContext.billingAttribution).toBe(billingAttribution) - expect(toolContext).not.toHaveProperty('authorizationUserId') - expect(toolContext).not.toHaveProperty('billingActorUserId') - expect(executionContext.userId).toBe('workspace-billed-account') - expect(executionContext).not.toHaveProperty('billingActorUserId') - }) - - it('preserves the explicit user-stop signal in the per-tool context', () => { - const userStopController = new AbortController() - const executionContext: ExecutionContext = { - userId: 'user-1', - workflowId: 'workflow-1', - userStopSignal: userStopController.signal, - } - - const toolContext = buildToolExecutionContext({ id: 'call-1' }, executionContext) - - expect(toolContext.userStopSignal).toBe(userStopController.signal) - }) -}) - -describe('cancelToolCallAndReport', () => { - beforeEach(() => { - vi.clearAllMocks() - upsertAsyncToolCall.mockResolvedValue({ toolCallId: 'tool-stop' }) - }) - - it('publishes cancellation only when its durable terminal transition wins', async () => { - const losingContext = createStreamingContext({ runId: 'run-1' }) - losingContext.toolCalls.set('tool-lost-race', { - id: 'tool-lost-race', - name: 'read', - status: 'executing', - }) - completeAsyncToolCall.mockResolvedValueOnce(null) - - await cancelToolCallAndReport('tool-lost-race', losingContext) - expect(publishToolConfirmation).not.toHaveBeenCalled() - - const winningContext = createStreamingContext({ runId: 'run-1' }) - winningContext.toolCalls.set('tool-won-race', { - id: 'tool-won-race', - name: 'read', - status: 'executing', - }) - completeAsyncToolCall.mockResolvedValueOnce({ - toolCallId: 'tool-won-race', - status: 'cancelled', - }) - - await cancelToolCallAndReport('tool-won-race', winningContext) - expect(publishToolConfirmation).toHaveBeenCalledOnce() - expect(publishToolConfirmation).toHaveBeenCalledWith( - expect.objectContaining({ - toolCallId: 'tool-won-race', - status: MothershipStreamV1ToolOutcome.cancelled, - }) - ) - }) -}) diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index c783dca615b..693a8c325d2 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -5,10 +5,8 @@ import type { AsyncCompletionEnvelope, AsyncCompletionSignal, } from '@/lib/copilot/async-runs/lifecycle' -import { isTerminalAsyncStatus } from '@/lib/copilot/async-runs/lifecycle' import { completeAsyncToolCall, - getAsyncToolCall, markAsyncToolRunning, upsertAsyncToolCall, } from '@/lib/copilot/async-runs/repository' @@ -79,7 +77,6 @@ import { type ToolCallState, } from '@/lib/copilot/request/types' import { ensureHandlersRegistered, executeTool } from '@/lib/copilot/tool-executor' -import type { ToolExecutionContext } from '@/lib/copilot/tool-executor/types' import { isMcpTool } from '@/executor/constants' export { waitForToolCompletion } from '@/lib/copilot/request/tools/client' @@ -200,11 +197,7 @@ function abortRequested( options?: OrchestratorOptions ): boolean { return Boolean( - options?.userStopSignal?.aborted || - execContext.userStopSignal?.aborted || - options?.abortSignal?.aborted || - execContext.abortSignal?.aborted || - context.wasAborted + options?.abortSignal?.aborted || execContext.abortSignal?.aborted || context.wasAborted ) } @@ -285,13 +278,9 @@ class ToolExecutionTimeoutError extends Error { export function buildToolExecutionContext( toolCall: Pick<ToolCallState, 'id' | 'parentToolCallId' | 'params'>, execContext: ExecutionContext -): ToolExecutionContext { - const { authorizationUserId, ...toolContext } = execContext +): ExecutionContext { return { - ...toolContext, - ...(authorizationUserId && authorizationUserId !== execContext.userId - ? { userId: authorizationUserId } - : {}), + ...execContext, toolCallId: toolCall.id, resolvedSecretTraceRegistry: execContext.resolvedSecretTraceRegistry?.forkForInputPaths([]), ...(toolCall.parentToolCallId ? { parentToolCallId: toolCall.parentToolCallId } : {}), @@ -303,31 +292,12 @@ export function buildToolExecutionContext( * resolves nor rejects within the tool's watchdog cap, throw a timeout error * so the standard failure path (persist failed row, publish terminal * confirmation, resume Go with an error result) runs and the chat never - * wedges behind a hung await. The losing promise's result is ignored, but the - * raw execution remains tracked so an explicit Stop keeps the chat lease until - * any still-mutating handler has actually unwound. + * wedges behind a hung await. The losing promise keeps running detached; its + * eventual settlement is ignored. */ -async function executeToolWithWatchdog( - toolCall: ToolCallState, - context: StreamingContext, - toolContext: ToolExecutionContext -) { +async function executeToolWithWatchdog(toolCall: ToolCallState, toolContext: ExecutionContext) { const timeoutMs = toolWatchdogTimeoutMs(toolCall.name) const execution = executeTool(toolCall.name, toolCall.params || {}, toolContext) - const inFlightToolExecutions = (context.inFlightToolExecutions ??= new Map()) - inFlightToolExecutions.set(toolCall.id, execution) - void execution.then( - () => { - if (inFlightToolExecutions.get(toolCall.id) === execution) { - inFlightToolExecutions.delete(toolCall.id) - } - }, - () => { - if (inFlightToolExecutions.get(toolCall.id) === execution) { - inFlightToolExecutions.delete(toolCall.id) - } - } - ) let timer: ReturnType<typeof setTimeout> | undefined try { return await Promise.race([ @@ -347,77 +317,6 @@ async function executeToolWithWatchdog( } } -/** - * Durably terminalizes a Sim-owned tool call when its turn is stopped. - * - * The upsert closes the narrow race where cancellation wins before the normal - * executor creates its row. `markAsyncToolRunning` is terminal-safe, so a late - * executor cannot resurrect this cancellation back to `running`. - */ -export async function cancelToolCallAndReport( - toolCallId: string, - context: StreamingContext, - message = 'Stopped by user' -): Promise<void> { - const toolCall = context.toolCalls.get(toolCallId) - if (!toolCall) return - - const alreadyCancelled = toolCall.status === MothershipStreamV1ToolOutcome.cancelled - if ( - !alreadyCancelled && - (toolCall.endTime !== undefined || isTerminalToolCallStatus(toolCall.status)) - ) { - return - } - - if (!alreadyCancelled) { - setTerminalToolCallState(toolCall, { - status: MothershipStreamV1ToolOutcome.cancelled, - error: message, - }) - } - markToolResultSeen(toolCallId) - - if (context.runId) { - await upsertAsyncToolCall({ - runId: context.runId, - toolCallId, - toolName: toolCall.name, - args: toolCall.params, - }).catch((err) => { - logger.warn('Failed to persist async tool row before cancellation', { - toolCallId, - error: toError(err).message, - }) - }) - } - - const persisted = await completeAsyncToolCall({ - toolCallId, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: message, - }).catch((err) => { - logger.warn('Failed to persist async tool cancellation', { - toolCallId, - error: toError(err).message, - }) - return null - }) - - // Only the winner of the pending/running -> cancelled transition publishes. - // A null row means another terminal outcome already won and must not be - // overwritten by a late stop notification. - if (persisted) { - publishTerminalToolConfirmation({ - toolCallId, - status: MothershipStreamV1ToolOutcome.cancelled, - message, - data: { cancelled: true }, - }) - } -} - /** * Last-resort settlement for a tool whose promise never settled (a hang the * per-tool watchdog could not see, e.g. in post-processing or persistence). @@ -588,9 +487,6 @@ async function executeToolAndReportInner( }) } if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) { - if (toolCall.status === MothershipStreamV1ToolOutcome.cancelled) { - await cancelToolCallAndReport(toolCall.id, context, requireToolCallError(toolCall)) - } return terminalCompletionFromToolCall(toolCall) } @@ -602,9 +498,26 @@ async function executeToolAndReportInner( } if (abortRequested(context, execContext, options)) { - const message = 'Request aborted before tool execution' - await cancelToolCallAndReport(toolCall.id, context, message) - return cancelledCompletion(message) + markToolCallCancelled('Request aborted before tool execution') + markToolResultSeen(toolCall.id) + await completeAsyncToolCall({ + toolCallId: toolCall.id, + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: 'Request aborted before tool execution', + }).catch((err) => { + logger.warn('Failed to persist async tool status', { + toolCallId: toolCall.id, + error: toError(err).message, + }) + }) + publishTerminalToolConfirmation({ + toolCallId: toolCall.id, + status: MothershipStreamV1ToolOutcome.cancelled, + message: 'Request aborted before tool execution', + data: { cancelled: true }, + }) + return cancelledCompletion('Request aborted before tool execution') } toolCall.status = 'executing' @@ -619,53 +532,15 @@ async function executeToolAndReportInner( error: toError(err).message, }) }) - const runningToolCall = await markAsyncToolRunning(toolCall.id, 'sim-stream').catch((err) => { + await markAsyncToolRunning(toolCall.id, 'sim-stream').catch((err) => { logger.warn('Failed to mark async tool running', { toolCallId: toolCall.id, error: toError(err).message, }) - return null }) - if (!runningToolCall) { - const durableToolCall = await getAsyncToolCall(toolCall.id).catch((err) => { - logger.warn('Failed to inspect async tool state after running transition lost', { - toolCallId: toolCall.id, - error: toError(err).message, - }) - return null - }) - if (durableToolCall && isTerminalAsyncStatus(durableToolCall.status)) { - const terminalStatus = - durableToolCall.status === MothershipStreamV1AsyncToolRecordStatus.completed - ? MothershipStreamV1ToolOutcome.success - : durableToolCall.status === MothershipStreamV1AsyncToolRecordStatus.cancelled - ? MothershipStreamV1ToolOutcome.cancelled - : MothershipStreamV1ToolOutcome.error - setTerminalToolCallState(toolCall, { - status: terminalStatus, - ...(durableToolCall.result !== null && durableToolCall.result !== undefined - ? { output: durableToolCall.result } - : {}), - ...(terminalStatus === MothershipStreamV1ToolOutcome.success - ? {} - : { error: durableToolCall.error || 'Tool execution was already terminalized' }), - }) - markToolResultSeen(toolCall.id) - return terminalCompletionFromToolCall(toolCall) - } - } - - const persistedToolCall = context.toolCalls.get(toolCall.id) ?? toolCall - if (persistedToolCall.endTime || isTerminalToolCallStatus(persistedToolCall.status)) { - if (persistedToolCall.status === MothershipStreamV1ToolOutcome.cancelled) { - await cancelToolCallAndReport( - persistedToolCall.id, - context, - requireToolCallError(persistedToolCall) - ) - } - return terminalCompletionFromToolCall(persistedToolCall) + if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) { + return terminalCompletionFromToolCall(toolCall) } const argsPreview = toolCall.params ? JSON.stringify(toolCall.params).slice(0, 200) : undefined @@ -674,7 +549,6 @@ async function executeToolAndReportInner( toolName: toolCall.name, argsPreview, abortSignalAborted: execContext.abortSignal?.aborted ?? false, - userStopSignalAborted: execContext.userStopSignal?.aborted ?? false, }) const endToolSpan = ( @@ -689,13 +563,6 @@ async function executeToolAndReportInner( if (options?.abortSignal?.aborted) { abortDetail.optionsAbortReason = String(options.abortSignal.reason ?? 'unknown') } - if (execContext.userStopSignal?.aborted) { - abortDetail.userStopSignalAborted = true - abortDetail.userStopReason = String(execContext.userStopSignal.reason ?? 'unknown') - } - if (options?.userStopSignal?.aborted) { - abortDetail.optionsUserStopReason = String(options.userStopSignal.reason ?? 'unknown') - } if (context.wasAborted) { abortDetail.wasAborted = true } @@ -734,31 +601,40 @@ async function executeToolAndReportInner( try { ensureHandlersRegistered() - let result = await executeToolWithWatchdog(toolCall, context, toolExecutionContext) - const currentToolCall = context.toolCalls.get(toolCall.id) ?? toolCall - if (currentToolCall.endTime || isTerminalToolCallStatus(currentToolCall.status)) { - if (currentToolCall.status === MothershipStreamV1ToolOutcome.cancelled) { - await cancelToolCallAndReport( - currentToolCall.id, - context, - requireToolCallError(currentToolCall) - ) - } + let result = await executeToolWithWatchdog(toolCall, toolExecutionContext) + if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) { endToolSpanFromTerminalState() - return terminalCompletionFromToolCall(currentToolCall) + return terminalCompletionFromToolCall(toolCall) } if (abortRequested(context, execContext, options)) { const copilotResult = inspectToolResultForCopilot( result, toolExecutionContext.resolvedSecretTraceRegistry ).result - const message = 'Request aborted during tool execution' - await cancelToolCallAndReport(toolCall.id, context, message) + markToolCallCancelled('Request aborted during tool execution') + markToolResultSeen(toolCall.id) + await completeAsyncToolCall({ + toolCallId: toolCall.id, + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: 'Request aborted during tool execution', + }).catch((err) => { + logger.warn('Failed to persist async tool status', { + toolCallId: toolCall.id, + error: toError(err).message, + }) + }) + publishTerminalToolConfirmation({ + toolCallId: toolCall.id, + status: MothershipStreamV1ToolOutcome.cancelled, + message: 'Request aborted during tool execution', + data: { cancelled: true }, + }) endToolSpan('cancelled', { cancelReason: 'abort_during_execution', error: copilotResult.success === false ? copilotResult.error : undefined, }) - return cancelledCompletion(message) + return cancelledCompletion('Request aborted during tool execution') } result = await maybeWriteOutputToFile( toolCall.name, @@ -767,10 +643,27 @@ async function executeToolAndReportInner( toolExecutionContext ) if (abortRequested(context, execContext, options)) { - const message = 'Request aborted during tool post-processing' - await cancelToolCallAndReport(toolCall.id, context, message) + markToolCallCancelled('Request aborted during tool post-processing') + markToolResultSeen(toolCall.id) + await completeAsyncToolCall({ + toolCallId: toolCall.id, + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: 'Request aborted during tool post-processing', + }).catch((err) => { + logger.warn('Failed to persist async tool status', { + toolCallId: toolCall.id, + error: toError(err).message, + }) + }) + publishTerminalToolConfirmation({ + toolCallId: toolCall.id, + status: MothershipStreamV1ToolOutcome.cancelled, + message: 'Request aborted during tool post-processing', + data: { cancelled: true }, + }) endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_file' }) - return cancelledCompletion(message) + return cancelledCompletion('Request aborted during tool post-processing') } result = await maybeWriteOutputToTable( toolCall.name, @@ -779,10 +672,27 @@ async function executeToolAndReportInner( toolExecutionContext ) if (abortRequested(context, execContext, options)) { - const message = 'Request aborted during tool post-processing' - await cancelToolCallAndReport(toolCall.id, context, message) + markToolCallCancelled('Request aborted during tool post-processing') + markToolResultSeen(toolCall.id) + await completeAsyncToolCall({ + toolCallId: toolCall.id, + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: 'Request aborted during tool post-processing', + }).catch((err) => { + logger.warn('Failed to persist async tool status', { + toolCallId: toolCall.id, + error: toError(err).message, + }) + }) + publishTerminalToolConfirmation({ + toolCallId: toolCall.id, + status: MothershipStreamV1ToolOutcome.cancelled, + message: 'Request aborted during tool post-processing', + data: { cancelled: true }, + }) endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_table' }) - return cancelledCompletion(message) + return cancelledCompletion('Request aborted during tool post-processing') } result = await maybeWriteReadCsvToTable( toolCall.name, @@ -791,10 +701,27 @@ async function executeToolAndReportInner( toolExecutionContext ) if (abortRequested(context, execContext, options)) { - const message = 'Request aborted during tool post-processing' - await cancelToolCallAndReport(toolCall.id, context, message) + markToolCallCancelled('Request aborted during tool post-processing') + markToolResultSeen(toolCall.id) + await completeAsyncToolCall({ + toolCallId: toolCall.id, + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: 'Request aborted during tool post-processing', + }).catch((err) => { + logger.warn('Failed to persist async tool status', { + toolCallId: toolCall.id, + error: toError(err).message, + }) + }) + publishTerminalToolConfirmation({ + toolCallId: toolCall.id, + status: MothershipStreamV1ToolOutcome.cancelled, + message: 'Request aborted during tool post-processing', + data: { cancelled: true }, + }) endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_csv' }) - return cancelledCompletion(message) + return cancelledCompletion('Request aborted during tool post-processing') } const projection = inspectToolResultForCopilot( result, @@ -935,13 +862,30 @@ async function executeToolAndReportInner( mergeToolRegistry(projection.safe) const safeThrownMessage = copilotError.error || 'Tool failed' if (abortRequested(context, execContext, options)) { - const message = 'Request aborted during tool execution' - await cancelToolCallAndReport(toolCall.id, context, message) + markToolCallCancelled('Request aborted during tool execution') + markToolResultSeen(toolCall.id) + await completeAsyncToolCall({ + toolCallId: toolCall.id, + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: 'Request aborted during tool execution', + }).catch((err) => { + logger.warn('Failed to persist async tool status', { + toolCallId: toolCall.id, + error: toError(err).message, + }) + }) + publishTerminalToolConfirmation({ + toolCallId: toolCall.id, + status: MothershipStreamV1ToolOutcome.cancelled, + message: 'Request aborted during tool execution', + data: { cancelled: true }, + }) endToolSpan('cancelled', { cancelReason: 'abort_during_execution_catch', error: safeThrownMessage, }) - return cancelledCompletion(message) + return cancelledCompletion('Request aborted during tool execution') } setTerminalToolCallState(toolCall, { status: MothershipStreamV1ToolOutcome.error, diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts index 23af59650ee..5c75b645730 100644 --- a/apps/sim/lib/copilot/request/tools/permission.test.ts +++ b/apps/sim/lib/copilot/request/tools/permission.test.ts @@ -375,44 +375,6 @@ describe('runGatedToolExecution', () => { expect(signal.status).toBe('error') }) - it('lets an explicit user stop cancel a permission wait independently of transport', async () => { - const context = makeContext() - const toolCall = makeToolCall() - const transportController = new AbortController() - const userStopController = new AbortController() - let permissionSignal: AbortSignal | undefined - waitForToolPermissionDecision.mockImplementationOnce( - (_toolCallId: string, _timeoutMs: number, signal?: AbortSignal) => { - permissionSignal = signal - return new Promise((resolve) => { - signal?.addEventListener('abort', () => resolve(null), { once: true }) - }) - } - ) - - const pending = runGatedToolExecution( - toolCall, - toolCall.id, - toolCall.name, - toolCall.params, - MothershipStreamV1ToolExecutor.client, - context, - { - abortSignal: transportController.signal, - userStopSignal: userStopController.signal, - }, - vi.fn() as () => Promise<never> - ) - - await vi.waitFor(() => expect(permissionSignal).toBeDefined()) - userStopController.abort('stop') - await pending - - expect(permissionSignal?.aborted).toBe(true) - expect(transportController.signal.aborted).toBe(false) - expect(toolCall.status).toBe('cancelled') - }) - it('refuses to run a gated tool whose row is hidden, rather than hanging the turn', async () => { const context = makeContext() const toolCall = makeToolCall() diff --git a/apps/sim/lib/copilot/request/tools/permission.ts b/apps/sim/lib/copilot/request/tools/permission.ts index 9001eb38725..08e751dc204 100644 --- a/apps/sim/lib/copilot/request/tools/permission.ts +++ b/apps/sim/lib/copilot/request/tools/permission.ts @@ -240,14 +240,10 @@ export function runGatedToolExecution( return { status: MothershipStreamV1ToolOutcome.success, message: output.message } } - const stopSignal = - options.abortSignal && options.userStopSignal - ? AbortSignal.any([options.abortSignal, options.userStopSignal]) - : (options.userStopSignal ?? options.abortSignal) const decision = await waitForToolPermissionDecision( toolCallId, PERMISSION_WAIT_TIMEOUT_MS, - stopSignal + options.abortSignal ) if (!decision) { diff --git a/apps/sim/lib/copilot/request/tools/workflow-context.test.ts b/apps/sim/lib/copilot/request/tools/workflow-context.test.ts index 12c14871640..379c8c267fd 100644 --- a/apps/sim/lib/copilot/request/tools/workflow-context.test.ts +++ b/apps/sim/lib/copilot/request/tools/workflow-context.test.ts @@ -189,22 +189,6 @@ describe('create_workflow execution context', () => { expect(Object.isFrozen(attribution)).toBe(true) expect(context.billingAttribution).toBe(billingAttribution) }) - - it('uses the retained billing actor after a tool context projects its authorization user', async () => { - const context = { - ...createContext(), - userId: 'workspace-key-owner', - } - resolveBillingAttributionMock.mockResolvedValue(childBillingAttribution) - - const attribution = await resolveWorkflowExecutionBillingAttribution(context, 'workspace-2') - - expect(resolveBillingAttributionMock).toHaveBeenCalledWith({ - actorUserId: 'user-1', - workspaceId: 'workspace-2', - }) - expect(attribution).toBe(childBillingAttribution) - }) }) describe('prepareWorkflowExecutionAdmission', () => { diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 1391290f966..ed76e5cd505 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -137,12 +137,6 @@ export interface StreamingContext { contentBlocks: ContentBlock[] toolCalls: Map<string, ToolCallState> pendingToolPromises: Map<string, Promise<AsyncCompletionSignal>> - /** - * Raw handler executions beneath the timeout wrapper. Stop waits for these - * too, so a watchdog timeout cannot detach a still-mutating stopped tool from - * the chat lease. - */ - inFlightToolExecutions?: Map<string, Promise<unknown>> awaitingAsyncContinuation?: ResumeContinuation currentThinkingBlock: ContentBlock | null /** @@ -222,8 +216,6 @@ export interface OrchestratorOptions { onComplete?: (result: OrchestratorResult) => void | Promise<void> onError?: (error: Error, result?: OrchestratorResult) => void | Promise<void> abortSignal?: AbortSignal - /** Fires only on explicit user stop, never on passive transport disconnect. */ - userStopSignal?: AbortSignal onAbortObserved?: (reason: string) => void interactive?: boolean } @@ -253,11 +245,5 @@ export interface ToolCallSummary { } export interface ExecutionContext extends ToolExecutionContext { - /** - * Turn-scoped authorization principal. It is projected onto `userId` before - * tool dispatch and never enters the generic tool context; billing remains - * frozen in `billingAttribution.actorUserId`. - */ - authorizationUserId?: string messageId?: string } diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index f2f50b71762..11672c1e8ed 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -89,188 +89,6 @@ describe('copilot tool executor fallback', () => { expect(handler).toHaveBeenCalledOnce() }) - it('rejects a top-level workspaceId outside the trusted workspace before dispatch', async () => { - isKnownTool.mockReturnValue(true) - isSimExecuted.mockReturnValue(true) - isClientExecuted.mockReturnValue(false) - const handler = vi.fn().mockResolvedValue({ success: true }) - registerHandler('manage_workspace_resource', handler) - - await expect( - executeTool( - 'manage_workspace_resource', - { workspaceId: 'ws-2' }, - { userId: 'user-1', workflowId: '', workspaceId: 'ws-1' } - ) - ).resolves.toEqual({ - success: false, - error: 'Tool denied: requested workspace does not match the current workspace.', - }) - expect(handler).not.toHaveBeenCalled() - }) - - it('rejects a nested payload workspaceId outside the trusted workspace', async () => { - isKnownTool.mockReturnValue(true) - isSimExecuted.mockReturnValue(true) - isClientExecuted.mockReturnValue(false) - const handler = vi.fn().mockResolvedValue({ success: true }) - registerHandler('manage_workspace_resource', handler) - - await expect( - executeTool( - 'manage_workspace_resource', - { payload: { workspaceId: 'ws-2' } }, - { userId: 'user-1', workflowId: '', workspaceId: 'ws-1' } - ) - ).resolves.toEqual({ - success: false, - error: 'Tool denied: requested workspace does not match the current workspace.', - }) - expect(handler).not.toHaveBeenCalled() - }) - - it('preserves workspaceId parameters owned by dynamic integrations', async () => { - isKnownTool.mockReturnValue(false) - isSimExecuted.mockReturnValue(false) - isClientExecuted.mockReturnValue(false) - executeAppTool.mockResolvedValue({ success: true, output: { ok: true } }) - - await expect( - executeTool( - 'external_integration_action', - { workspaceId: 'external-service-workspace' }, - { userId: 'user-1', workflowId: '', workspaceId: 'ws-1' } - ) - ).resolves.toEqual({ success: true, output: { ok: true } }) - expect(executeAppTool).toHaveBeenCalledWith( - 'external_integration_action', - expect.objectContaining({ - workspaceId: 'external-service-workspace', - _context: expect.objectContaining({ workspaceId: 'ws-1' }), - }) - ) - }) - - it('allows explicit workspaceIds that match the trusted workspace', async () => { - isKnownTool.mockReturnValue(true) - isSimExecuted.mockReturnValue(true) - isClientExecuted.mockReturnValue(false) - const handler = vi.fn().mockResolvedValue({ success: true }) - registerHandler('manage_workspace_resource', handler) - const context = { userId: 'user-1', workflowId: '', workspaceId: 'ws-1' } - - await expect( - executeTool( - 'manage_workspace_resource', - { workspaceId: 'ws-1', payload: { workspace_id: 'ws-1' } }, - context - ) - ).resolves.toEqual({ success: true }) - expect(handler).toHaveBeenCalledWith( - { workspaceId: 'ws-1', payload: { workspace_id: 'ws-1' } }, - context - ) - }) - - it('fails closed to the reviewed local tool set in query-only mode', async () => { - isKnownTool.mockReturnValue(true) - isSimExecuted.mockReturnValue(true) - isClientExecuted.mockReturnValue(false) - const readHandler = vi.fn().mockResolvedValue({ success: true, output: 'ok' }) - const mutationHandler = vi.fn().mockResolvedValue({ success: true }) - registerHandler('read', readHandler) - registerHandler('create_workflow', mutationHandler) - const context = { userId: 'user-1', workflowId: '', queryOnly: true } - - await expect(executeTool('read', { path: 'WORKSPACE.md' }, context)).resolves.toEqual({ - success: true, - output: 'ok', - }) - await expect(executeTool('create_workflow', { name: 'Nope' }, context)).resolves.toEqual({ - success: false, - error: 'Tool denied: create_workflow is not available in query-only mode.', - }) - expect(readHandler).toHaveBeenCalledOnce() - expect(mutationHandler).not.toHaveBeenCalled() - }) - - it('denies private credential controls and dynamic integrations when credentialless', async () => { - isKnownTool.mockImplementation((toolId: string) => toolId !== 'gmail_read') - - for (const toolId of [ - 'generate_api_key', - 'list_user_workspaces', - 'manage_credential', - 'oauth_get_auth_link', - 'oauth_request_access', - 'gmail_read', - ]) { - await expect( - executeTool(toolId, {}, { userId: 'user-1', workflowId: '', secretActorUserId: null }) - ).resolves.toEqual({ - success: false, - error: `Tool denied: ${toolId} is not available without credential access.`, - }) - } - expect(executeAppTool).not.toHaveBeenCalled() - }) - - it('keeps workspace environment writes and workflow runs in credentialless mode', async () => { - isKnownTool.mockReturnValue(true) - isSimExecuted.mockReturnValue(true) - isClientExecuted.mockReturnValue(false) - const envHandler = vi.fn().mockResolvedValue({ success: true }) - const runHandler = vi.fn().mockResolvedValue({ success: true, output: { ran: true } }) - registerHandler('set_environment_variables', envHandler) - registerHandler('run_workflow', runHandler) - const context = { - userId: 'user-1', - workflowId: 'workflow-1', - workspaceId: 'ws-1', - secretActorUserId: null, - } - - await expect( - executeTool('set_environment_variables', { scope: 'personal', variables: [] }, context) - ).resolves.toEqual({ - success: false, - error: - 'Tool denied: personal environment variables are not available without credential access.', - }) - await expect( - executeTool('set_environment_variables', { scope: 'workspace', variables: [] }, context) - ).resolves.toEqual({ success: true }) - await expect(executeTool('run_workflow', {}, context)).resolves.toEqual({ - success: true, - output: { ran: true }, - }) - expect(envHandler).toHaveBeenCalledOnce() - expect(runHandler).toHaveBeenCalledOnce() - }) - - it('keeps workspace custom tools but denies MCP execution in credentialless mode', async () => { - isKnownTool.mockReturnValue(false) - isSimExecuted.mockReturnValue(false) - isClientExecuted.mockReturnValue(false) - executeAppTool.mockResolvedValue({ success: true, output: { ok: true } }) - const context = { - userId: 'user-1', - workflowId: 'workflow-1', - workspaceId: 'ws-1', - secretActorUserId: null, - } - - await expect(executeTool('custom_tool-1', {}, context)).resolves.toEqual({ - success: true, - output: { ok: true }, - }) - await expect(executeTool('mcp-server-1-search', {}, context)).resolves.toEqual({ - success: false, - error: 'Tool denied: mcp-server-1-search is not available without credential access.', - }) - expect(executeAppTool).toHaveBeenCalledOnce() - }) - it('projects resolved secrets before logging registered handler failures', async () => { const secret = 'mounted-secret-value' const registry = new ResolvedSecretTraceRegistry([ @@ -331,29 +149,6 @@ describe('copilot tool executor fallback', () => { expect(result).toEqual({ success: true, output: { emails: [] } }) }) - it('forwards the active cancellation signal to dynamic app tools', async () => { - isKnownTool.mockReturnValue(false) - isSimExecuted.mockReturnValue(false) - executeAppTool.mockResolvedValue({ success: true, output: {} }) - const controller = new AbortController() - - await executeTool( - 'gmail_read', - {}, - { - userId: 'user-1', - workflowId: 'workflow-1', - abortSignal: controller.signal, - } - ) - - expect(executeAppTool).toHaveBeenCalledWith( - 'gmail_read', - expect.any(Object), - expect.objectContaining({ signal: controller.signal }) - ) - }) - it('threads billing attribution into _context for dynamic tools (MCP)', async () => { isKnownTool.mockReturnValue(false) isSimExecuted.mockReturnValue(false) diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index cee96483ecf..dc2489efe61 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -3,7 +3,6 @@ import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/wo import { toError } from '@sim/utils/errors' import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' -import { isCustomTool, isMcpTool } from '@/executor/constants' import { executeTool as executeAppTool } from '@/tools' import { getToolEntry, isClientExecuted, isKnownTool, isSimExecuted } from './router' import type { ToolExecutionContext, ToolExecutionResult, ToolHandler } from './types' @@ -12,24 +11,6 @@ const logger = createLogger('ToolExecutor') const FUNCTION_EXECUTE_TOOL_ID = 'function_execute' const DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS = 10 const MILLISECONDS_PER_SECOND = 1000 -const QUERY_ONLY_TOOL_IDS = new Set([ - 'grep', - 'glob', - 'read', - 'get_block_outputs', - 'get_block_upstream_references', - 'get_deployed_workflow_state', - 'search_knowledge_base', - 'query_user_table', - 'get_platform_actions', -]) -const CREDENTIALLESS_DENIED_TOOL_IDS = new Set([ - 'generate_api_key', - 'list_user_workspaces', - 'manage_credential', - 'oauth_get_auth_link', - 'oauth_request_access', -]) const handlerRegistry = new Map<string, ToolHandler>() @@ -56,48 +37,6 @@ export async function executeTool( params: Record<string, unknown>, context: ToolExecutionContext ): Promise<ToolExecutionResult> { - if ( - context.workspaceId && - isKnownTool(toolId) && - hasWorkspaceScopeMismatch(params, context.workspaceId) - ) { - return { - success: false, - error: 'Tool denied: requested workspace does not match the current workspace.', - } - } - - if (context.queryOnly && !QUERY_ONLY_TOOL_IDS.has(toolId)) { - return { - success: false, - error: `Tool denied: ${toolId} is not available in query-only mode.`, - } - } - - if ( - context.secretActorUserId === null && - (CREDENTIALLESS_DENIED_TOOL_IDS.has(toolId) || - isMcpTool(toolId) || - (!isKnownTool(toolId) && !isCustomTool(toolId))) - ) { - return { - success: false, - error: `Tool denied: ${toolId} is not available without credential access.`, - } - } - - if ( - context.secretActorUserId === null && - toolId === 'set_environment_variables' && - params.scope === 'personal' - ) { - return { - success: false, - error: - 'Tool denied: personal environment variables are not available without credential access.', - } - } - const requiredPermission = getToolEntry(toolId)?.requiredPermission if ( requiredPermission && @@ -123,9 +62,7 @@ export async function executeTool( (isSimExecuted(toolId) || (isClientExecuted(toolId) && hasHandler(toolId))) if (!canUseRegisteredHandler) { const appParams = buildAppToolParams(normalizedParams, context) - const signal = context.abortSignal ?? context.userStopSignal const options = { - ...(signal ? { signal } : {}), ...(context.resolvedSecretTraceRegistry ? { resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry } : {}), @@ -171,27 +108,6 @@ export async function executeTool( } } -function hasWorkspaceScopeMismatch(params: Record<string, unknown>, workspaceId: string): boolean { - const payload = - typeof params.payload === 'object' && params.payload !== null - ? (params.payload as Record<string, unknown>) - : undefined - const suppliedContext = - typeof params._context === 'object' && params._context !== null - ? (params._context as Record<string, unknown>) - : undefined - const candidates = [ - params.workspaceId, - params.workspace_id, - payload?.workspaceId, - payload?.workspace_id, - suppliedContext?.workspaceId, - suppliedContext?.workspace_id, - ] - - return candidates.some((candidate) => typeof candidate === 'string' && candidate !== workspaceId) -} - function normalizeToolParams( toolId: string, params: Record<string, unknown>, diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index 8cc38af972a..17c233e2550 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -17,8 +17,6 @@ export interface ToolExecutionContext { copilotToolExecution?: boolean /** Server-owned base image selected from the fixed Go route for this turn. */ sandboxProfile?: 'mothership' - /** Trusted server policy: workspace inspection only, with every write sink disabled. */ - queryOnly?: boolean requestMode?: string currentAgentId?: string /** @@ -29,8 +27,6 @@ export interface ToolExecutionContext { */ parentToolCallId?: string abortSignal?: AbortSignal - /** Fires only on explicit user stop, never on passive transport disconnect. */ - userStopSignal?: AbortSignal userTimezone?: string userPermission?: string secretMountPolicy?: SecretMountPolicy diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index 6f32827bcc4..343c9e2712d 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -2,14 +2,12 @@ import type { ComponentType } from 'react' import { Loader } from '@sim/emcn' import { FileText } from '@sim/emcn/icons' import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' +import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state' -import { - describeReadTarget, - humanizeDisplayIdentifier, - humanizeToolName, -} from '@/lib/copilot/tools/tool-display' +import { humanizeDisplayIdentifier, humanizeToolName } from '@/lib/copilot/tools/tool-display' +import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils' /** Respond tools are internal handoff tools shown with a friendly generic label. */ const HIDDEN_TOOL_SUFFIX = '_respond' @@ -47,8 +45,7 @@ function specialToolDisplay( } if (toolName === ReadTool.id) { - const path = readStringParam(params, 'path') - const target = describeReadTarget(path, getReadTargetBlock(path)?.name) + const target = describeReadTarget(readStringParam(params, 'path')) return { text: formatReadingLabel(target, state), icon: FileText, @@ -86,6 +83,79 @@ function formatReadingLabel(target: string | undefined, state: ClientToolCallSta } } +function describeReadTarget(path: string | undefined): string | undefined { + if (!path) return undefined + + const block = getReadTargetBlock(path) + if (block) return block.name + + const segments = path + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean) + .map(decodeVfsSegmentSafe) + + if (segments.length === 0) return undefined + + const resourceType = VFS_DIR_TO_RESOURCE[segments[0]] + if (!resourceType) { + return humanizeDisplayIdentifier(stripExtension(segments[segments.length - 1]), 'sentence') + } + + if (resourceType === 'file') { + return describeFileReadTarget(segments) + } + + if (resourceType === 'workflow') { + return stripExtension(getLeafResourceSegment(segments)) + } + + const resourceName = segments[1] || segments[segments.length - 1] + return stripExtension(resourceName) +} + +// A workspace file is addressed as a directory of facets in the VFS +// (files/{...path}/{name}/{facet}). `content` is the default facet — reading a +// file means reading its content — so it carries no qualifier, matching a bare +// `files/{...path}/{name}` read. The remaining facets are genuinely distinct, so +// they keep a descriptive label. +const FILE_FACET_LABELS: Record<string, string> = { + content: '', + 'meta.json': 'metadata for', + style: 'style details for', + 'compiled-check': 'the final file check for', +} + +function describeFileReadTarget(segments: string[]): string { + const lastSegment = segments[segments.length - 1] || '' + const facetLabel = FILE_FACET_LABELS[lastSegment] + // Treat the suffix as a facet only when a real file name precedes it; otherwise + // the leaf is the file itself (e.g. a file literally named "content"). + if (facetLabel !== undefined && segments.length > 2) { + const fileName = segments[segments.length - 2] + return facetLabel ? `${facetLabel} ${fileName}` : fileName + } + // Show just the file name, not the folder path — these are glanceable status + // lines, and the other resource types already render the leaf only. + return lastSegment +} + +function getLeafResourceSegment(segments: string[]): string { + const lastSegment = segments[segments.length - 1] || '' + if (hasFileExtension(lastSegment) && segments.length > 1) { + return segments[segments.length - 2] || lastSegment + } + return lastSegment +} + +function hasFileExtension(value: string): boolean { + return /\.[^/.]+$/.test(value) +} + +function stripExtension(value: string): string { + return value.replace(/\.[^/.]+$/, '') +} + function humanizedFallback( toolName: string, state: ClientToolCallState diff --git a/apps/sim/lib/copilot/tools/handlers/access.test.ts b/apps/sim/lib/copilot/tools/handlers/access.test.ts deleted file mode 100644 index 47a38664729..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/access.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * @vitest-environment node - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { authorizeWorkflow, checkWorkspaceAccess } = vi.hoisted(() => ({ - authorizeWorkflow: vi.fn(), - checkWorkspaceAccess: vi.fn(), -})) - -vi.mock('@sim/platform-authz/workflow', () => ({ - authorizeWorkflowByWorkspacePermission: authorizeWorkflow, -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - checkWorkspaceAccess, -})) - -vi.mock('@/lib/workspaces/utils', () => ({ - listAccessibleWorkspaceRowsForUser: vi.fn(), -})) - -import { ensureWorkflowAccess, ensureWorkspaceAccess } from './access' - -describe('Copilot access scope', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('allows a workflow in the trusted workspace', async () => { - const workflow = { id: 'wf-1', workspaceId: 'ws-1' } - authorizeWorkflow.mockResolvedValue({ allowed: true, workflow }) - - await expect( - ensureWorkflowAccess('wf-1', { userId: 'user-1', workspaceId: 'ws-1' }) - ).resolves.toEqual({ workflow, workspaceId: 'ws-1' }) - }) - - it('hides a workflow outside the trusted workspace', async () => { - authorizeWorkflow.mockResolvedValue({ - allowed: true, - workflow: { id: 'wf-2', workspaceId: 'ws-2' }, - }) - - await expect( - ensureWorkflowAccess('wf-2', { userId: 'user-1', workspaceId: 'ws-1' }) - ).rejects.toThrow('Workflow wf-2 not found') - }) - - it('rejects a workspace outside the trusted scope before its membership lookup', async () => { - await expect( - ensureWorkspaceAccess('ws-2', { userId: 'user-1', workspaceId: 'ws-1' }) - ).rejects.toThrow('Workspace ws-2 not found') - expect(checkWorkspaceAccess).not.toHaveBeenCalled() - }) - - it('preserves normal permission checks inside the trusted workspace', async () => { - const access = { - exists: true, - hasAccess: true, - canWrite: true, - canAdmin: false, - } - checkWorkspaceAccess.mockResolvedValue(access) - - await expect( - ensureWorkspaceAccess('ws-1', { userId: 'user-1', workspaceId: 'ws-1' }, 'write') - ).resolves.toBe(access) - expect(checkWorkspaceAccess).toHaveBeenCalledWith('ws-1', 'user-1') - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/access.ts b/apps/sim/lib/copilot/tools/handlers/access.ts index 9ea9fbc4b4d..2f5d592269e 100644 --- a/apps/sim/lib/copilot/tools/handlers/access.ts +++ b/apps/sim/lib/copilot/tools/handlers/access.ts @@ -5,14 +5,9 @@ import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' type WorkflowRecord = NonNullable<Awaited<ReturnType<typeof getWorkflowById>>> -export interface CopilotAccessContext { - userId: string - workspaceId?: string -} - export async function ensureWorkflowAccess( workflowId: string, - context: CopilotAccessContext, + userId: string, action: 'read' | 'write' | 'admin' = 'read' ): Promise<{ workflow: WorkflowRecord @@ -20,7 +15,7 @@ export async function ensureWorkflowAccess( }> { const result = await authorizeWorkflowByWorkspacePermission({ workflowId, - userId: context.userId, + userId, action, }) @@ -32,10 +27,6 @@ export async function ensureWorkflowAccess( throw new Error(result.message || 'Unauthorized workflow access') } - if (context.workspaceId && result.workflow.workspaceId !== context.workspaceId) { - throw new Error(`Workflow ${workflowId} not found`) - } - return { workflow: result.workflow, workspaceId: result.workflow.workspaceId } } @@ -54,14 +45,10 @@ export async function getDefaultWorkspaceId(userId: string): Promise<string> { export async function ensureWorkspaceAccess( workspaceId: string, - context: CopilotAccessContext, + userId: string, level: 'read' | 'write' | 'admin' = 'read' ): Promise<WorkspaceAccess> { - if (context.workspaceId && workspaceId !== context.workspaceId) { - throw new Error(`Workspace ${workspaceId} not found`) - } - - const access = await checkWorkspaceAccess(workspaceId, context.userId) + const access = await checkWorkspaceAccess(workspaceId, userId) if (!access.exists || !access.hasAccess) { throw new Error(`Workspace ${workspaceId} not found`) } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts index b25f585b7f1..e37f623a234 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts @@ -134,7 +134,7 @@ describe('executeDeployCustomBlock', () => { context ) - expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', context, 'admin') + expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin') expect(publishCustomBlockMock).toHaveBeenCalledWith({ organizationId: 'org-1', workspaceId: 'ws-1', 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 0e860d8efd6..a7ddfb72721 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -136,7 +136,7 @@ export async function executeDeployCustomBlock( let workflowRecord: Awaited<ReturnType<typeof ensureWorkflowAccess>>['workflow'] try { - workflowRecord = (await ensureWorkflowAccess(workflowId, context, 'admin')).workflow + workflowRecord = (await ensureWorkflowAccess(workflowId, context.userId, 'admin')).workflow } catch (error) { const message = toError(error).message if (message.includes('not found')) { diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts index 69ba5f0f454..f5e5081d156 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts @@ -377,77 +377,6 @@ describe('executeDiffWorkflows', () => { diff: { hasChanges: false }, }) }) - - it('removes credentials before diffing in secretless mode', async () => { - const state = (apiKey: string) => ({ - blocks: { - request: { - id: 'request', - type: 'unknown-integration', - subBlocks: { - apiKey: { id: 'apiKey', type: 'short-input', value: apiKey }, - path: { id: 'path', type: 'short-input', value: '/users' }, - }, - }, - }, - edges: [], - loops: {}, - parallels: {}, - }) - mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ - references: [ - { state: state('SENTINEL_OLD_SECRET'), ref: '1', version: 1, isActive: false }, - { state: state('SENTINEL_NEW_SECRET'), ref: '2', version: 2, isActive: false }, - ], - }) - generateWorkflowDiffSummaryMock.mockReturnValue({ - addedBlocks: [], - removedBlocks: [], - modifiedBlocks: [], - edgeChanges: { added: 0, removed: 0, addedDetails: [], removedDetails: [] }, - loopChanges: { added: 0, removed: 0, modified: 0 }, - parallelChanges: { added: 0, removed: 0, modified: 0 }, - variableChanges: { - added: 0, - removed: 0, - modified: 0, - addedNames: [], - removedNames: [], - modifiedNames: [], - }, - hasChanges: false, - }) - - await executeDiffWorkflows({ workflowId: 'wf-1', ref1: 1, ref2: 2 }, { - userId: 'key-creator', - secretActorUserId: null, - workflowId: 'wf-1', - } as ExecutionContext) - - expect(generateWorkflowDiffSummaryMock).toHaveBeenCalledWith( - expect.objectContaining({ - blocks: expect.objectContaining({ - request: expect.objectContaining({ - subBlocks: expect.objectContaining({ - apiKey: expect.objectContaining({ value: null }), - path: expect.objectContaining({ value: '/users' }), - }), - }), - }), - }), - expect.objectContaining({ - blocks: expect.objectContaining({ - request: expect.objectContaining({ - subBlocks: expect.objectContaining({ - apiKey: expect.objectContaining({ value: null }), - path: expect.objectContaining({ value: '/users' }), - }), - }), - }), - }) - ) - expect(JSON.stringify(generateWorkflowDiffSummaryMock.mock.calls)).not.toContain('SENTINEL_') - }) }) describe('executeCheckDeploymentStatus', () => { diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index 8906621ada9..0ec758912f6 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -5,7 +5,6 @@ import { messageForCopilotWorkflowError, } from '@/lib/copilot/application/execute-workflow-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { projectWorkflowStateForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' import { generateRequestId } from '@/lib/core/utils/request' import { createWorkflowMcpDeploymentServer, @@ -327,12 +326,9 @@ export async function executeDiffWorkflows( } ) const [side1, side2] = references - const projection = { secretless: context.secretActorUserId === null } - const state1 = projectWorkflowStateForCopilot(side1.state, projection) - const state2 = projectWorkflowStateForCopilot(side2.state, projection) // ref1 = base/previous, ref2 = target/current: added = present in ref2 only. - const summary = generateWorkflowDiffSummary(state2, state1) + const summary = generateWorkflowDiffSummary(side2.state, side1.state) const diff = { ...summary, modifiedBlocks: summary.modifiedBlocks.map((block) => ({ diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts index c31941a6823..7501d3355ba 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -393,26 +393,6 @@ describe('executeFunctionExecute trace-secret provenance', () => { expect(mockExecuteTool).not.toHaveBeenCalled() }) - it('forwards cancellation to the nested function executor', async () => { - const controller = new AbortController() - - await executeFunctionExecute( - { code: 'return 1' }, - { - userId: 'u1', - workflowId: '', - workspaceId: 'ws_1', - abortSignal: controller.signal, - } - ) - - expect(mockExecuteTool).toHaveBeenCalledWith( - 'function_execute', - expect.any(Object), - expect.objectContaining({ signal: controller.signal }) - ) - }) - it('returns the raw runtime result when provenance import fails', async () => { mockMaterializeCopilotCodeSecrets.mockResolvedValue({ envVars: { API_KEY: 'secret-value' }, diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.test.ts deleted file mode 100644 index ba18bc1c0fb..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * @vitest-environment node - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - executeCopilotCustomToolUseCase: vi.fn(), - useCases: { - deleteAvailable: { operation: { id: 'custom_tools.delete_available' } }, - deleteWorkspace: { operation: { id: 'custom_tools.delete' } }, - listAvailable: { operation: { id: 'custom_tools.list_available' } }, - listWorkspace: { operation: { id: 'custom_tools.list' } }, - saveWorkspace: { operation: { id: 'custom_tools.save' } }, - updateAvailable: { operation: { id: 'custom_tools.update_available' } }, - updateWorkspace: { operation: { id: 'custom_tools.update' } }, - }, -})) - -vi.mock('@/lib/copilot/application/execute-custom-tool-use-case', () => ({ - executeCopilotCustomToolUseCase: mocks.executeCopilotCustomToolUseCase, -})) -vi.mock('@/lib/custom-tools/application/use-cases', () => ({ - deleteAvailableCustomToolUseCase: mocks.useCases.deleteAvailable, - deleteWorkspaceCustomToolUseCase: mocks.useCases.deleteWorkspace, - listAvailableCustomToolsUseCase: mocks.useCases.listAvailable, - listWorkspaceCustomToolsUseCase: mocks.useCases.listWorkspace, - saveWorkspaceCustomToolUseCase: mocks.useCases.saveWorkspace, - updateAvailableCustomToolUseCase: mocks.useCases.updateAvailable, - updateWorkspaceCustomToolUseCase: mocks.useCases.updateWorkspace, -})) -vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) - -import { executeManageCustomTool } from './manage-custom-tool' - -const CREDENTIALLESS_CONTEXT = { - userId: 'key-owner', - workflowId: '', - workspaceId: 'ws-1', - userPermission: 'admin', - secretActorUserId: null, - toolCallId: 'tool-call-1', - copilotToolExecution: true, -} - -describe('manage_custom_tool credentialless workspace scope', () => { - beforeEach(() => vi.clearAllMocks()) - - it('lists only workspace tools through the authorized application use case', async () => { - const tools = [{ id: 'tool-1', title: 'Shared tool' }] - mocks.executeCopilotCustomToolUseCase.mockResolvedValue({ tools }) - - const result = await executeManageCustomTool({ operation: 'list' }, CREDENTIALLESS_CONTEXT) - - expect(result).toMatchObject({ - success: true, - output: { tools, count: 1 }, - }) - expect(mocks.executeCopilotCustomToolUseCase).toHaveBeenCalledWith( - CREDENTIALLESS_CONTEXT, - mocks.useCases.listWorkspace, - { workspaceId: 'ws-1', limit: 100 } - ) - expect(mocks.executeCopilotCustomToolUseCase).not.toHaveBeenCalledWith( - expect.anything(), - mocks.useCases.listAvailable, - expect.anything() - ) - }) - - it('edits and deletes through workspace-scoped application use cases', async () => { - const tool = { - id: 'tool-1', - title: 'Shared tool', - schema: { type: 'function', function: { name: 'shared_tool', parameters: {} } }, - code: 'return 1', - } - mocks.executeCopilotCustomToolUseCase.mockResolvedValue({ tool }) - - const edit = await executeManageCustomTool( - { operation: 'edit', toolId: 'tool-1', code: 'return 2' }, - CREDENTIALLESS_CONTEXT - ) - const remove = await executeManageCustomTool( - { operation: 'delete', toolId: 'tool-1' }, - CREDENTIALLESS_CONTEXT - ) - - expect(edit.success).toBe(true) - expect(remove.success).toBe(true) - expect(mocks.executeCopilotCustomToolUseCase).toHaveBeenNthCalledWith( - 1, - CREDENTIALLESS_CONTEXT, - mocks.useCases.updateWorkspace, - expect.objectContaining({ toolId: 'tool-1', workspaceId: 'ws-1', code: 'return 2' }) - ) - expect(mocks.executeCopilotCustomToolUseCase).toHaveBeenNthCalledWith( - 2, - CREDENTIALLESS_CONTEXT, - mocks.useCases.deleteWorkspace, - { toolId: 'tool-1', workspaceId: 'ws-1', source: 'tool_input' } - ) - expect(mocks.executeCopilotCustomToolUseCase).not.toHaveBeenCalledWith( - expect.anything(), - mocks.useCases.updateAvailable, - expect.anything() - ) - expect(mocks.executeCopilotCustomToolUseCase).not.toHaveBeenCalledWith( - expect.anything(), - mocks.useCases.deleteAvailable, - expect.anything() - ) - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts index 9a615bb3d59..a0c5cbb9089 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts @@ -5,12 +5,9 @@ import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/typ import { asOrchestrationError } from '@/lib/core/orchestration/types' import { deleteAvailableCustomToolUseCase, - deleteWorkspaceCustomToolUseCase, listAvailableCustomToolsUseCase, - listWorkspaceCustomToolsUseCase, saveWorkspaceCustomToolUseCase, updateAvailableCustomToolUseCase, - updateWorkspaceCustomToolUseCase, } from '@/lib/custom-tools/application/use-cases' import { captureServerEvent } from '@/lib/posthog/server' @@ -51,7 +48,6 @@ export async function executeManageCustomTool( * caught it. Matches manage_mcp_tool and manage_skill. */ const workspaceId = context.workspaceId - const secretless = context.secretActorUserId === null if (!operation) { return { success: false, error: "Missing required 'operation' argument" } @@ -60,14 +56,11 @@ export async function executeManageCustomTool( try { if (operation === 'list') { if (!workspaceId) return { success: false, error: 'workspaceId is required' } - const { tools: toolsForUser } = secretless - ? await executeCopilotCustomToolUseCase(context, listWorkspaceCustomToolsUseCase, { - workspaceId, - limit: 100, - }) - : await executeCopilotCustomToolUseCase(context, listAvailableCustomToolsUseCase, { - workspaceId, - }) + const { tools: toolsForUser } = await executeCopilotCustomToolUseCase( + context, + listAvailableCustomToolsUseCase, + { workspaceId } + ) return { success: true, @@ -151,17 +144,18 @@ export async function executeManageCustomTool( } } - const input = { - workspaceId, - toolId: params.toolId, - title: params.title || params.schema?.function?.name, - schema: params.schema, - code: params.code, - source: 'tool_input' as const, - } - const { tool } = secretless - ? await executeCopilotCustomToolUseCase(context, updateWorkspaceCustomToolUseCase, input) - : await executeCopilotCustomToolUseCase(context, updateAvailableCustomToolUseCase, input) + const { tool } = await executeCopilotCustomToolUseCase( + context, + updateAvailableCustomToolUseCase, + { + workspaceId, + toolId: params.toolId, + title: params.title || params.schema?.function?.name, + schema: params.schema, + code: params.code, + source: 'tool_input', + } + ) captureServerEvent( context.userId, 'custom_tool_saved', @@ -197,16 +191,11 @@ export async function executeManageCustomTool( for (const toolId of toolIds) { try { - const input = { + await executeCopilotCustomToolUseCase(context, deleteAvailableCustomToolUseCase, { toolId, workspaceId, - source: 'tool_input' as const, - } - if (secretless) { - await executeCopilotCustomToolUseCase(context, deleteWorkspaceCustomToolUseCase, input) - } else { - await executeCopilotCustomToolUseCase(context, deleteAvailableCustomToolUseCase, input) - } + source: 'tool_input', + }) deleted.push(toolId) } catch (error) { const classified = asOrchestrationError(error) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.test.ts deleted file mode 100644 index e310f28a4b3..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @vitest-environment node - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - executeCopilotMcpServerUseCase: vi.fn(), - listMcpServersUseCase: { operation: { id: 'mcp_servers.list' } }, -})) - -vi.mock('@/lib/copilot/application/execute-mcp-server-use-case', () => ({ - executeCopilotMcpServerUseCase: mocks.executeCopilotMcpServerUseCase, -})) -vi.mock('@/lib/mcp/application/use-cases', () => ({ - deleteMcpServerUseCase: { operation: { id: 'mcp_servers.delete' } }, - listMcpServersUseCase: mocks.listMcpServersUseCase, - reconfigureMcpServerUseCase: { operation: { id: 'mcp_servers.reconfigure' } }, - registerMcpServerUseCase: { operation: { id: 'mcp_servers.register' } }, -})) -vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) - -import { executeManageMcpTool } from './manage-mcp-tool' - -const SERVER = { - id: 'server-1', - name: 'Private MCP', - url: 'https://user:secret@example.com/mcp?token=sentinel', - transport: 'streamable-http', - enabled: true, - connectionStatus: 'connected', -} - -const CONTEXT = { - userId: 'user-1', - workflowId: '', - workspaceId: 'workspace-1', - userPermission: 'admin', -} - -describe('manage_mcp_tool list projection', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.executeCopilotMcpServerUseCase.mockResolvedValue({ servers: [SERVER] }) - }) - - it('omits raw URLs from secretless workspace chat', async () => { - const context = { ...CONTEXT, secretActorUserId: null } - const result = await executeManageMcpTool({ operation: 'list' }, context) - - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - servers: [ - { - id: 'server-1', - name: 'Private MCP', - transport: 'streamable-http', - enabled: true, - connectionStatus: 'connected', - }, - ], - }) - expect(JSON.stringify(result.output)).not.toContain('sentinel') - expect(mocks.executeCopilotMcpServerUseCase).toHaveBeenCalledWith( - context, - mocks.listMcpServersUseCase, - { workspaceId: 'workspace-1' } - ) - }) - - it('keeps URLs for normal user-backed chat', async () => { - const result = await executeManageMcpTool({ operation: 'list' }, CONTEXT) - - expect(result.output).toMatchObject({ servers: [{ url: SERVER.url }] }) - expect(mocks.executeCopilotMcpServerUseCase).toHaveBeenCalledOnce() - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts index 71497d436a1..5158176f27c 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts @@ -60,7 +60,7 @@ export async function executeManageMcpTool( servers: servers.map((s) => ({ id: s.id, name: s.name, - ...(context.secretActorUserId === null ? {} : { url: s.url }), + url: s.url, transport: s.transport, enabled: s.enabled, connectionStatus: s.connectionStatus, diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index 0593da388b3..d6b5e2dece6 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -575,7 +575,7 @@ export async function executeMaterializeFile( try { if (operation === 'import') { - await ensureWorkspaceAccess(context.workspaceId, context, 'write') + await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write') } else { await admitCreateWorkspaceFile(principal, context.workspaceId) } diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts index f129bb0d329..5f89535f76a 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -507,49 +507,6 @@ describe('vfs handlers oversize policy', () => { expect(result.success).toBe(false) expect(result.error).toContain('cannot be shared safely') }) - - it.each(['compiled', 'compiled-check', 'extract', 'render'])( - 'rejects /%s document execution paths in query-only mode', - async (suffix) => { - const vfs = makeVfs() - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead( - { path: `files/reports/brief.pdf/${suffix}` }, - { ...GREP_CTX, queryOnly: true } - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('query-only') - expect(vfs.readFileContent).not.toHaveBeenCalled() - expect(getOrMaterializeVFS).not.toHaveBeenCalled() - } - ) - - it('requests a secretless VFS for credentialless execution contexts', async () => { - const vfs = makeVfs() - getOrMaterializeVFS.mockResolvedValue(vfs) - - await executeVfsGlob({ pattern: 'workflows/**' }, { ...GREP_CTX, secretActorUserId: null }) - - expect(getOrMaterializeVFS).toHaveBeenCalledWith( - 'ws-1', - 'user-1', - expect.objectContaining({ - secretless: true, - filePrincipal: expect.objectContaining({ - kind: 'delegated', - workspaceId: 'ws-1', - subjectUserId: 'user-1', - }), - knowledgePrincipal: expect.objectContaining({ - kind: 'delegated', - workspaceId: 'ws-1', - subjectUserId: 'user-1', - }), - }) - ) - }) }) describe('vfs grep workspace-file routing', () => { diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index abfc2442872..dfa61ea3881 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -43,7 +43,6 @@ async function getGatedVFS(context: ExecutionContext) { secretMountPolicy: context.secretMountPolicy, filePrincipal, knowledgePrincipal, - ...(context.secretActorUserId === null ? { secretless: true } : {}), }) ) } @@ -292,17 +291,6 @@ export async function executeVfsRead( return { success: false, error: 'No workspace context available' } } - if ( - context.queryOnly && - /\/(?:compiled|compiled-check|extract|render)\/?$/.test(path.trim().replace(/^\/+/, '')) - ) { - return { - success: false, - error: - 'read is query-only: document compilation, extraction, and rendering paths are not available; read the file content or metadata instead', - } - } - try { const parseOptionalNumber = (value: unknown): number | undefined => { if (typeof value === 'number' && Number.isFinite(value)) return value diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts index f48a1123625..b853e6ae492 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts @@ -24,13 +24,11 @@ export function createServerToolHandler(toolId: string): ToolHandler { copilotToolExecution: context.copilotToolExecution, billingAttribution: context.billingAttribution, userPermission: context.userPermission ?? undefined, - secretActorUserId: context.secretActorUserId, chatId: context.chatId, messageId: context.messageId, parentToolCallId: context.parentToolCallId, abortSignal: context.abortSignal, resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry, - userStopSignal: context.userStopSignal, }) const rec = isRecordLike(result) ? (result as Record<string, unknown>) : null diff --git a/apps/sim/lib/copilot/tools/server/base-tool.ts b/apps/sim/lib/copilot/tools/server/base-tool.ts index 87c8c54a3ea..2af97ba7490 100644 --- a/apps/sim/lib/copilot/tools/server/base-tool.ts +++ b/apps/sim/lib/copilot/tools/server/base-tool.ts @@ -12,8 +12,6 @@ export interface ServerToolContext { copilotToolExecution?: boolean billingAttribution?: BillingAttributionSnapshot userPermission?: string - /** Undefined uses the execution actor; null explicitly disables raw secret access. */ - secretActorUserId?: string | null chatId?: string messageId?: string /** diff --git a/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts index 2eddae4121f..14693f75913 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts @@ -15,25 +15,9 @@ vi.mock('@/lib/knowledge/embeddings', () => ({ generateSearchEmbedding: mockGenerateSearchEmbedding, })) -import { - normalizeDocsTopK, - searchDocumentationServerTool, -} from '@/lib/copilot/tools/server/docs/search-documentation' +import { searchDocumentationServerTool } from '@/lib/copilot/tools/server/docs/search-documentation' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' -describe('documentation search result limit', () => { - it.each([ - { input: undefined, expected: 10 }, - { input: 0, expected: 10 }, - { input: -1, expected: 10 }, - { input: 1.5, expected: 10 }, - { input: 12, expected: 12 }, - { input: 10_000, expected: 50 }, - ])('normalizes $input to $expected', ({ input, expected }) => { - expect(normalizeDocsTopK(input)).toBe(expected) - }) -}) - describe('documentation search model boundary', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts b/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts index 7dab6911aa6..ad14c3937a6 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts @@ -13,24 +13,14 @@ interface DocsSearchParams { } const DEFAULT_DOCS_SIMILARITY_THRESHOLD = 0.3 -const DEFAULT_DOCS_TOP_K = 10 -const MAX_DOCS_TOP_K = 50 - -export function normalizeDocsTopK(value: unknown): number { - return typeof value === 'number' && Number.isInteger(value) && value >= 1 - ? Math.min(value, MAX_DOCS_TOP_K) - : DEFAULT_DOCS_TOP_K -} export const searchDocumentationServerTool: BaseServerTool<DocsSearchParams, any> = { name: SearchDocumentation.id, async execute(params: DocsSearchParams): Promise<any> { const logger = createLogger('SearchDocumentationServerTool') - const { query, threshold } = params + const { query, topK = 10, threshold } = params if (!query || typeof query !== 'string') throw new Error('query is required') - const topK = normalizeDocsTopK(params.topK) - logger.info('Executing docs search', { queryLength: query.length, topK }) const similarityThreshold = threshold ?? DEFAULT_DOCS_SIMILARITY_THRESHOLD diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index a69f8719254..83c263815a2 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -16,11 +16,6 @@ const { mockBatchInsertRows, mockReplaceTableRows, mockAddWorkflowGroup, - mockUpdateWorkflowGroup, - mockRunWorkflowColumn, - mockCancelWorkflowGroupRuns, - mockLoadWorkflowFromNormalizedTables, - mockFlattenWorkflowOutputs, mockCreateTable, mockDeleteTable, mockGetWorkspaceTableLimits, @@ -49,11 +44,6 @@ const { mockBatchInsertRows: vi.fn(), mockReplaceTableRows: vi.fn(), mockAddWorkflowGroup: vi.fn(), - mockUpdateWorkflowGroup: vi.fn(), - mockRunWorkflowColumn: vi.fn(), - mockCancelWorkflowGroupRuns: vi.fn(), - mockLoadWorkflowFromNormalizedTables: vi.fn(), - mockFlattenWorkflowOutputs: vi.fn(), mockCreateTable: vi.fn(), mockDeleteTable: vi.fn(), mockGetWorkspaceTableLimits: vi.fn(), @@ -198,20 +188,7 @@ vi.mock('@/lib/table/workflow-groups/service', () => ({ addWorkflowGroupOutput: vi.fn(), deleteWorkflowGroup: vi.fn(), deleteWorkflowGroupOutput: vi.fn(), - updateWorkflowGroup: mockUpdateWorkflowGroup, -})) - -vi.mock('@/lib/table/workflow-columns', () => ({ - cancelWorkflowGroupRuns: mockCancelWorkflowGroupRuns, - runWorkflowColumn: mockRunWorkflowColumn, -})) - -vi.mock('@/lib/workflows/persistence/utils', () => ({ - loadWorkflowFromNormalizedTables: mockLoadWorkflowFromNormalizedTables, -})) - -vi.mock('@/lib/workflows/blocks/flatten-outputs', () => ({ - flattenWorkflowOutputs: mockFlattenWorkflowOutputs, + updateWorkflowGroup: vi.fn(), })) vi.mock('@/lib/table/columns/service', () => ({ diff --git a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts index 50a7c86e59a..e6b159a3da4 100644 --- a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts +++ b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts @@ -50,11 +50,7 @@ describe('setEnvironmentVariablesServerTool', () => { } ) - expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith( - 'ws-1', - { userId: 'user-1', workspaceId: 'ws-1' }, - 'write' - ) + expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith('ws-1', 'user-1', 'write') expect(upsertWorkspaceEnvVarsMock).toHaveBeenCalledWith('ws-1', { API_KEY: 'secret' }, 'user-1') expect(upsertPersonalEnvVarsMock).not.toHaveBeenCalled() expect(result.scope).toBe('workspace') diff --git a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts index 357a814c040..daa8c19fe80 100644 --- a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts +++ b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts @@ -57,23 +57,24 @@ function normalizeVariables( async function resolveWorkspaceId( params: SetEnvironmentVariablesParams, - context: ServerToolContext + context: ServerToolContext | undefined, + userId: string ): Promise<string> { if (params.workflowId) { - const { workflow } = await ensureWorkflowAccess(params.workflowId, context, 'write') + const { workflow } = await ensureWorkflowAccess(params.workflowId, userId, 'write') if (!workflow.workspaceId) { throw new Error(`Workflow ${params.workflowId} is not associated with a workspace`) } return workflow.workspaceId } - const workspaceId = context.workspaceId ?? params.workspaceId + const workspaceId = params.workspaceId ?? context?.workspaceId if (workspaceId) { - await ensureWorkspaceAccess(workspaceId, context, 'write') + await ensureWorkspaceAccess(workspaceId, userId, 'write') return workspaceId } - return getDefaultWorkspaceId(context.userId) + return getDefaultWorkspaceId(userId) } export const setEnvironmentVariablesServerTool: BaseServerTool< @@ -107,7 +108,7 @@ export const setEnvironmentVariablesServerTool: BaseServerTool< let resolvedWorkspaceId: string | undefined if (scope === 'workspace') { - resolvedWorkspaceId = await resolveWorkspaceId(params, context) + resolvedWorkspaceId = await resolveWorkspaceId(params, context, authenticatedUserId) workspaceUpdated = await upsertWorkspaceEnvVars( resolvedWorkspaceId, validatedVariables, diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.test.ts deleted file mode 100644 index 1f008e5c29f..00000000000 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * @vitest-environment node - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - authorizeWorkflowByWorkspacePermissionMock, - applyOperationsToWorkflowStateMock, - saveWorkflowToNormalizedTablesMock, - assertWorkflowMutableMock, - validateWorkflowStateMock, - dbUpdateMock, - dbSetMock, - dbWhereMock, -} = vi.hoisted(() => { - const dbWhereMock = vi.fn() - const dbSetMock = vi.fn(() => ({ where: dbWhereMock })) - const dbUpdateMock = vi.fn(() => ({ set: dbSetMock })) - return { - authorizeWorkflowByWorkspacePermissionMock: vi.fn(), - applyOperationsToWorkflowStateMock: vi.fn(), - saveWorkflowToNormalizedTablesMock: vi.fn(), - assertWorkflowMutableMock: vi.fn(), - validateWorkflowStateMock: vi.fn(), - dbUpdateMock, - dbSetMock, - dbWhereMock, - } -}) - -vi.mock('@sim/db', () => ({ db: { update: dbUpdateMock } })) -vi.mock('@sim/db/schema', () => ({ - workflow: { id: 'id', lastSynced: 'lastSynced', updatedAt: 'updatedAt' }, -})) -vi.mock('drizzle-orm', () => ({ eq: vi.fn((left, right) => [left, right]) })) -vi.mock('@sim/platform-authz/workflow', () => ({ - assertWorkflowMutable: assertWorkflowMutableMock, - authorizeWorkflowByWorkspacePermission: authorizeWorkflowByWorkspacePermissionMock, -})) -vi.mock('@/lib/billing/core/subscription', () => ({ - hasWorkspaceSandboxAccess: vi.fn(async () => true), -})) -vi.mock('@/lib/copilot/block-visibility', () => ({ - getBlockVisibilityForCopilot: vi.fn(async () => null), -})) -vi.mock('@/lib/copilot/sim-sandbox-projection', () => ({ - operationsReferenceSimSandbox: vi.fn(() => false), -})) -vi.mock('@/lib/core/config/env', () => ({ env: { INTERNAL_API_SECRET: 'internal-secret' } })) -vi.mock('@/lib/core/utils/urls', () => ({ getSocketServerUrl: () => 'http://socket.test' })) -vi.mock('@/lib/execution/remote-sandbox/workspace-sandboxes', () => ({ - MAX_PLAN_REQUIRED: 'Upgrade required', -})) -vi.mock('@/lib/workflows/autolayout', () => ({ - applyTargetedLayout: vi.fn((blocks) => blocks), - getTargetedLayoutImpact: vi.fn(() => ({ - layoutBlockIds: [], - resizedBlockIds: [], - shiftSourceBlockIds: [], - })), - transferBlockHeights: vi.fn(), -})) -vi.mock('@/lib/workflows/persistence/custom-tools-persistence', () => ({ - extractAndPersistCustomTools: vi.fn(async () => ({ saved: 0, errors: [] })), -})) -vi.mock('@/lib/workflows/persistence/utils', () => ({ - loadWorkflowFromNormalizedTables: vi.fn(), - saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, -})) -vi.mock('@/lib/workflows/sanitization/validation', () => ({ - validateWorkflowState: validateWorkflowStateMock, -})) -vi.mock('@/blocks/visibility/server-context', () => ({ - withBlockVisibility: vi.fn(async (_visibility, execute) => execute()), -})) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ - getUserPermissionConfig: vi.fn(async () => null), -})) -vi.mock('@/stores/workflows/workflow/utils', () => ({ - generateLoopBlocks: vi.fn(() => ({})), - generateParallelBlocks: vi.fn(() => ({})), -})) -vi.mock('@/stores/workflows/workflow/validation', () => ({ normalizeWorkflowState: vi.fn() })) -vi.mock('./engine', () => ({ - applyOperationsToWorkflowState: applyOperationsToWorkflowStateMock, -})) -vi.mock('./lint', () => ({ - collectWorkflowFieldIssues: vi.fn(() => []), - formatWorkflowLintMessage: vi.fn(() => ''), - hasWorkflowLintIssues: vi.fn(() => false), - lintEditedWorkflowState: vi.fn(() => ({ - sources: [], - sinks: [], - orphanBlocks: [], - emptyOutgoingPorts: [], - invalidBranchPorts: [], - invalidConnectionTargets: [], - })), -})) -vi.mock('./validation', () => ({ - collectUnresolvedAgentToolReferences: vi.fn(async () => []), - collectUnresolvedReferences: vi.fn(async () => []), - preValidateCredentialInputs: vi.fn(async (operations) => ({ - filteredOperations: operations, - errors: [], - })), - UNRESOLVABLE_AT_LINT_NOTE: 'unresolvable', -})) - -vi.unmock('@/blocks/registry') - -import { editWorkflowServerTool } from './index' - -const workflowState = { - blocks: { - request: { - id: 'request', - type: 'unknown-integration', - name: 'Request', - enabled: true, - subBlocks: { - apiKey: { id: 'apiKey', type: 'short-input', value: 'SENTINEL_API_KEY' }, - path: { id: 'path', type: 'short-input', value: '/users' }, - }, - }, - }, - edges: [], - loops: {}, - parallels: {}, -} - -describe('editWorkflowServerTool secretless projection', () => { - beforeEach(() => { - vi.clearAllMocks() - global.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) as typeof fetch - authorizeWorkflowByWorkspacePermissionMock.mockResolvedValue({ - allowed: true, - workflow: { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, - }) - assertWorkflowMutableMock.mockResolvedValue(undefined) - applyOperationsToWorkflowStateMock.mockImplementation((state) => ({ - state, - validationErrors: [], - skippedItems: [], - })) - validateWorkflowStateMock.mockImplementation((state) => ({ - valid: true, - errors: [], - warnings: [], - sanitizedState: state, - })) - saveWorkflowToNormalizedTablesMock.mockResolvedValue({ success: true }) - dbWhereMock.mockResolvedValue(undefined) - }) - - async function execute(secretActorUserId?: string | null) { - return editWorkflowServerTool.execute( - { - workflowId: 'workflow-1', - currentUserWorkflow: JSON.stringify(workflowState), - operations: [{ operation_type: 'edit', block_id: 'request', params: {} }], - }, - { - userId: 'key-creator', - workspaceId: 'workspace-1', - secretActorUserId, - } - ) as Promise<Record<string, any>> - } - - it('redacts credentials from the returned state in secretless mode', async () => { - const result = await execute(null) - - expect(result.workflowState.blocks.request.subBlocks.apiKey.value).toBeNull() - expect(result.workflowState.blocks.request.subBlocks.path.value).toBe('/users') - expect(JSON.stringify(result)).not.toContain('SENTINEL_API_KEY') - }) - - it('preserves the existing returned state for a user-backed chat', async () => { - const result = await execute('user-1') - - expect(result.workflowState.blocks.request.subBlocks.apiKey.value).toBe('SENTINEL_API_KEY') - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts index 663e05db75e..b7ecfeed71a 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts @@ -16,7 +16,6 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { projectWorkflowStateForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' import { env } from '@/lib/core/config/env' import { getSocketServerUrl } from '@/lib/core/utils/urls' import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/workspace-sandboxes' @@ -405,16 +404,11 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown> const sanitizationWarnings = validation.warnings.length > 0 ? validation.warnings : undefined - const outputWorkflowState = projectWorkflowStateForCopilot( - { ...finalWorkflowState, blocks: layoutedBlocks }, - { secretless: context.secretActorUserId === null } - ) - return { success: true, workflowId, workflowName: workflowName ?? 'Workflow', - workflowState: outputWorkflowState, + workflowState: { ...finalWorkflowState, blocks: layoutedBlocks }, workflowLint, ...(workflowLintMessage && { workflowLintMessage }), ...(inputErrors && { diff --git a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts index 804d86eccf4..9ab073f7dd8 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts @@ -69,7 +69,7 @@ const queryLogsArgsSchema = z.discriminatedUnion('view', [ type QueryLogsArgs = z.infer<typeof queryLogsArgsSchema> function resolveWorkspaceId(args: QueryLogsArgs, context?: ServerToolContext): string { - const workspaceId = context?.workspaceId ?? args.workspaceId + const workspaceId = args.workspaceId ?? context?.workspaceId if (!workspaceId) { throw new Error('workspaceId is required') } diff --git a/apps/sim/lib/copilot/tools/shared/workflow-utils.test.ts b/apps/sim/lib/copilot/tools/shared/workflow-utils.test.ts deleted file mode 100644 index ed1a15f8c7e..00000000000 --- a/apps/sim/lib/copilot/tools/shared/workflow-utils.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @vitest-environment node - */ - -import { describe, expect, it, vi } from 'vitest' -import { formatNormalizedWorkflowForCopilot } from './workflow-utils' - -vi.unmock('@/blocks/registry') - -describe('formatNormalizedWorkflowForCopilot', () => { - it('redacts credentials from secretless deployed-state projections', () => { - const formatted = formatNormalizedWorkflowForCopilot( - { - blocks: { - slack: { - id: 'slack', - type: 'slack', - name: 'Slack', - enabled: true, - subBlocks: { - credential: { id: 'credential', type: 'oauth-input', value: 'cred-private' }, - manualCredential: { - id: 'manualCredential', - type: 'short-input', - value: 'cred-private-advanced', - }, - message: { id: 'message', type: 'long-input', value: 'hello' }, - }, - }, - }, - edges: [], - loops: {}, - parallels: {}, - }, - { secretless: true } - ) - - expect(formatted).not.toContain('cred-private') - expect(formatted).toContain('hello') - }) -}) diff --git a/apps/sim/lib/copilot/tools/shared/workflow-utils.ts b/apps/sim/lib/copilot/tools/shared/workflow-utils.ts index ea211a4546a..c82e0f60bdf 100644 --- a/apps/sim/lib/copilot/tools/shared/workflow-utils.ts +++ b/apps/sim/lib/copilot/tools/shared/workflow-utils.ts @@ -1,4 +1,3 @@ -import { sanitizeWorkflowForSharing } from '@/lib/workflows/credentials/credential-extractor' import { type CopilotSanitizationOptions, sanitizeForCopilot, @@ -11,24 +10,9 @@ type CopilotWorkflowState = { parallels?: Record<string, any> } -type CopilotWorkflowProjectionOptions = CopilotSanitizationOptions & { secretless?: boolean } - -export function projectWorkflowStateForCopilot<T extends CopilotWorkflowState>( - state: T, - options?: CopilotWorkflowProjectionOptions -): T { - return options?.secretless - ? (sanitizeWorkflowForSharing(state, { - preserveEnvVars: false, - preserveWorkspaceReferences: true, - redactOpaqueCredentialInputs: true, - }) as T) - : state -} - export function formatWorkflowStateForCopilot( state: CopilotWorkflowState, - options?: CopilotWorkflowProjectionOptions + options?: CopilotSanitizationOptions ): string { const workflowState = { blocks: state.blocks || {}, @@ -36,14 +20,13 @@ export function formatWorkflowStateForCopilot( loops: state.loops || {}, parallels: state.parallels || {}, } - const credentialSafeState = projectWorkflowStateForCopilot(workflowState, options) - const sanitized = sanitizeForCopilot(credentialSafeState as typeof workflowState, options) + const sanitized = sanitizeForCopilot(workflowState, options) return JSON.stringify(sanitized, null, 2) } export function formatNormalizedWorkflowForCopilot( normalized: CopilotWorkflowState | null | undefined, - options?: CopilotWorkflowProjectionOptions + options?: CopilotSanitizationOptions ): string | null { if (!normalized) return null return formatWorkflowStateForCopilot(normalized, options) diff --git a/apps/sim/lib/copilot/tools/subagent-display.ts b/apps/sim/lib/copilot/tools/subagent-display.ts deleted file mode 100644 index d074f6878fb..00000000000 --- a/apps/sim/lib/copilot/tools/subagent-display.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { humanizeToolName } from '@/lib/copilot/tools/tool-display' - -/** Canonical user-facing labels for Mothership subagent lanes. */ -export const SUBAGENT_LABELS: Readonly<Record<string, string>> = { - workflow: 'Workflow Agent', - debug: 'Debug Agent', - deploy: 'Deploy Agent', - auth: 'Auth Agent', - research: 'Research Agent', - knowledge: 'Knowledge Agent', - table: 'Table Agent', - custom_tool: 'Custom Tool Agent', - scout: 'Scout Agent', - search: 'Search Agent', - superagent: 'Superagent', - run: 'Run Agent', - agent: 'Tools Agent', - scheduled_task: 'Scheduled Task Agent', - /** Backward-compatible label for historical transcripts. */ - job: 'Job Agent', - file: 'File Agent', - media: 'Media Agent', - browser: 'Browser Agent', -} as const - -/** Resolves a server-owned subagent id without exposing raw identifier casing. */ -export function getSubagentDisplayTitle(agentId: string): string { - return SUBAGENT_LABELS[agentId] ?? humanizeToolName(agentId || 'subagent') -} diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index fdb5f9830ea..027ce68d915 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -71,21 +71,6 @@ describe('humanizeToolName', () => { }) describe('getToolDisplayTitle natural-language coverage', () => { - it('uses the same glanceable target names as the web read row', () => { - expect(getToolDisplayTitle('read', { path: 'workflows/Folder/forceful-arm/state.json' })).toBe( - 'Reading forceful-arm' - ) - expect(getToolDisplayTitle('read', { path: 'files/Reports/Q4%20Report.pdf/content' })).toBe( - 'Reading Q4 Report.pdf' - ) - expect(getToolDisplayTitle('read', { path: 'components/blocks/gmail_v2.json' }, 'Gmail')).toBe( - 'Reading Gmail' - ) - expect( - getToolDisplayTitle('read', { path: 'components/integrations/gmail/send.json' }, 'Gmail') - ).toBe('Reading Gmail') - }) - it('gives gerund titles to tools that previously fell through to humanize', () => { expect(getToolDisplayTitle('deploy_api')).toBe('Deploying API') expect(getToolDisplayTitle('list_workspace_mcp_servers')).toBe('Listing MCP servers') diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index b788ffd82af..c62db5ce326 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -1,7 +1,5 @@ import { isRecordLike } from '@sim/utils/object' import { stripVersionSuffix } from '@sim/utils/string' -import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' -import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils' /** * Single source of truth for copilot tool-call display titles. @@ -433,62 +431,6 @@ function workspaceFileTitle(args: ToolArgs): string { return `${verb} ${title}` } -const READ_FILE_FACET_LABELS: Record<string, string> = { - content: '', - 'meta.json': 'metadata for', - style: 'style details for', - 'compiled-check': 'the final file check for', -} - -function stripReadTargetExtension(value: string): string { - return value.replace(/\.[^/.]+$/, '') -} - -function readResourceLeaf(segments: string[]): string { - const lastSegment = segments.at(-1) ?? '' - if (/\.[^/.]+$/.test(lastSegment) && segments.length > 1) { - return segments.at(-2) ?? lastSegment - } - return lastSegment -} - -function describeFileReadTarget(segments: string[]): string { - const lastSegment = segments.at(-1) ?? '' - const facetLabel = READ_FILE_FACET_LABELS[lastSegment] - if (facetLabel !== undefined && segments.length > 2) { - const fileName = segments.at(-2) ?? '' - return facetLabel ? `${facetLabel} ${fileName}` : fileName - } - return lastSegment -} - -/** Resolves the glanceable VFS target shared by web and public chat tool rows. */ -export function describeReadTarget( - path: string | undefined, - resolvedBlockName?: string -): string | undefined { - if (!path) return undefined - if (resolvedBlockName) return resolvedBlockName - - const segments = path - .split('/') - .map((segment) => segment.trim()) - .filter(Boolean) - .map(decodeVfsSegmentSafe) - if (segments.length === 0) return undefined - - const resourceType = VFS_DIR_TO_RESOURCE[segments[0]] - if (!resourceType) { - return humanizeDisplayIdentifier(stripReadTargetExtension(segments.at(-1) ?? ''), 'sentence') - } - if (resourceType === 'file') return describeFileReadTarget(segments) - if (resourceType === 'workflow') { - return stripReadTargetExtension(readResourceLeaf(segments)) - } - - return stripReadTargetExtension(segments[1] ?? segments.at(-1) ?? '') -} - /** Static fallback titles for tools without an argument-aware title. */ const TOOL_TITLES: Record<string, string> = { // Gateway rows brand from the streamed toolId as soon as it resolves; this @@ -717,11 +659,7 @@ function terminalTitle(args: ToolArgs): string { * cases come first, then the static map, then a humanized fallback. This never * returns an empty string. */ -export function getToolDisplayTitle( - name: string, - args?: Record<string, unknown>, - resolvedReadTargetName?: string -): string { +export function getToolDisplayTitle(name: string, args?: Record<string, unknown>): string { const mcpToolMatch = name.match(/^mcp-[^-]+-(.+)$/) if (mcpToolMatch?.[1]) { return humanizeToolName(mcpToolMatch[1]) @@ -1002,8 +940,6 @@ export function getToolDisplayTitle( if (isWorkflowArtifactPath(stringArg(args, 'path'), 'lint.json')) { return 'Validating workflow state' } - const target = describeReadTarget(stringArg(args, 'path'), resolvedReadTargetName) - if (target) return `Reading ${target}` break } case 'workspace_file': diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index 3544b5ef5fd..5e86b445895 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -18,7 +18,6 @@ import { serializeFileMeta, serializeIntegrationSchema, serializeKBMeta, - serializeMcpServer, serializeSandbox, serializeSandboxCatalog, serializeTableMeta, @@ -67,20 +66,6 @@ describe('VFS metadata serializers', () => { expect(deployment).toEqual({ api: { isDeployed: false } }) }) - it('omits an MCP URL when the caller projects a secretless server', () => { - const server = JSON.parse( - serializeMcpServer({ - id: 'mcp-1', - name: 'Private MCP', - transport: 'sse', - enabled: true, - connectionStatus: 'connected', - }) - ) - - expect(server).not.toHaveProperty('url') - }) - it('includes the authoritative file update timestamp', () => { const metadata = JSON.parse( serializeFileMeta({ diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index f4924f01f90..0fc76e89926 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -972,7 +972,7 @@ export function serializeCustomTool(tool: { export function serializeMcpServer(server: { id: string name: string - url?: string | null + url: string | null transport: string | null enabled: boolean connectionStatus: string | null diff --git a/apps/sim/lib/workflows/credentials/constants.ts b/apps/sim/lib/workflows/credentials/constants.ts deleted file mode 100644 index df9cac1f370..00000000000 --- a/apps/sim/lib/workflows/credentials/constants.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** Legacy and current subblock IDs that persist credential references. */ -export const CREDENTIAL_SUBBLOCK_IDS = new Set([ - 'credential', - 'manualCredential', - 'triggerCredentials', - 'customBotCredential', - 'manualBotCredential', -]) diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.ts b/apps/sim/lib/workflows/credentials/credential-extractor.ts index ec198547253..4683bda7a6c 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.ts @@ -1,5 +1,4 @@ import { isPlainRecord } from '@sim/utils/object' -import { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/credentials/constants' import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer' import { WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES } from '@/lib/workflows/search-replace/resources/registry' import { setValueAtPath } from '@/lib/workflows/search-replace/value-walker' @@ -135,8 +134,6 @@ interface SanitizedWorkflowState { interface WorkflowSanitizationOptions { preserveEnvVars?: boolean - /** Retain IDs for resources in the same workspace while still removing credentials. */ - preserveWorkspaceReferences?: boolean /** * Withhold values whose interior cannot be projected safely once the payload leaves the * workspace — whole `table` values (see {@link OPAQUE_CREDENTIAL_BEARING_TYPES}) and every @@ -160,25 +157,6 @@ interface WorkflowSanitizationOptions { redactOpaqueCredentialInputs?: boolean } -function isCredentialKey(key: string): boolean { - const normalized = key.replace(/[_-]/g, '').replace(/\d+$/, '').toLowerCase() - return ( - normalized === 'auth' || - normalized === 'authorization' || - normalized.endsWith('credential') || - normalized.endsWith('credentialid') || - normalized.endsWith('apikey') || - normalized.endsWith('accesstoken') || - normalized.endsWith('refreshtoken') || - normalized.endsWith('idtoken') || - normalized.endsWith('authtoken') || - normalized.endsWith('bottoken') || - normalized.endsWith('bearertoken') || - normalized.endsWith('secret') || - normalized.endsWith('password') - ) -} - type CredentialSanitizationConfig = Pick< SubBlockConfig, 'id' | 'type' | 'password' | 'canonicalParamId' @@ -249,10 +227,9 @@ function sanitizeConfiguredSubBlockValue( return options.preserveEnvVars && isEnvironmentVariableReference(value) ? value : null } if ( - !options.preserveWorkspaceReferences && - (WORKSPACE_SPECIFIC_TYPES.has(config.type) || - WORKSPACE_SPECIFIC_FIELDS.has(config.id) || - (config.canonicalParamId != null && WORKSPACE_SPECIFIC_FIELDS.has(config.canonicalParamId))) + WORKSPACE_SPECIFIC_TYPES.has(config.type) || + WORKSPACE_SPECIFIC_FIELDS.has(config.id) || + (config.canonicalParamId != null && WORKSPACE_SPECIFIC_FIELDS.has(config.canonicalParamId)) ) { return null } @@ -283,11 +260,6 @@ export function sanitizeWorkflowForSharing( removeMalformedSubBlocks(block) const blockConfig = getBlock(block.type) - const registeredSubBlockIds = new Set<string>() - for (const config of blockConfig?.subBlocks ?? []) { - registeredSubBlockIds.add(config.id) - if (config.canonicalParamId) registeredSubBlockIds.add(config.canonicalParamId) - } // Process subBlocks with config if (blockConfig) { @@ -315,20 +287,8 @@ export function sanitizeWorkflowForSharing( } } - if ( - subBlock && - (CREDENTIAL_SUBBLOCK_IDS.has(key) || - (!registeredSubBlockIds.has(key) && isCredentialKey(key))) - ) { - subBlock.value = null - } - // Clear workspace-specific fields by key name - if ( - !options.preserveWorkspaceReferences && - WORKSPACE_SPECIFIC_FIELDS.has(key) && - subBlock - ) { + if (WORKSPACE_SPECIFIC_FIELDS.has(key) && subBlock) { subBlock.value = null } }) @@ -342,7 +302,7 @@ export function sanitizeWorkflowForSharing( block.data![key] = null } // Clear workspace-specific data - if (!options.preserveWorkspaceReferences && WORKSPACE_SPECIFIC_FIELDS.has(key)) { + if (WORKSPACE_SPECIFIC_FIELDS.has(key)) { block.data![key] = null } }) diff --git a/apps/sim/lib/workflows/execution-admission.ts b/apps/sim/lib/workflows/execution-admission.ts index 0209db29e83..601d7c70e8f 100644 --- a/apps/sim/lib/workflows/execution-admission.ts +++ b/apps/sim/lib/workflows/execution-admission.ts @@ -26,13 +26,12 @@ export async function resolveWorkflowExecutionBillingAttribution( if (!rootAttribution) return undefined if (rootAttribution.workspaceId === targetWorkspaceId) return rootAttribution - const billingActorUserId = rootAttribution.actorUserId const childAttribution = await resolveBillingAttribution({ - actorUserId: billingActorUserId, + actorUserId: context.userId, workspaceId: targetWorkspaceId, }) if ( - childAttribution.actorUserId !== billingActorUserId || + childAttribution.actorUserId !== context.userId || childAttribution.workspaceId !== targetWorkspaceId ) { throw new Error('Resolved workflow billing attribution does not match its actor and workspace') diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index b760b3793b3..c9313ef325d 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -24,7 +24,6 @@ import { LRUCache } from 'lru-cache' import type { Edge } from 'reactflow' import { releaseWebhookPathClaims } from '@/lib/webhooks/path-claims' import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' -import { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/credentials/constants' import { isDynamicHandleSubblock } from '@/lib/workflows/dynamic-handle-topology' import { backfillCanonicalModes, @@ -397,7 +396,13 @@ export function migrateAgentBlocksToMessagesFormat( ) } -export { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/credentials/constants' +export const CREDENTIAL_SUBBLOCK_IDS = new Set([ + 'credential', + 'manualCredential', + 'triggerCredentials', + 'customBotCredential', + 'manualBotCredential', +]) async function migrateCredentialIds( blocks: Record<string, BlockState>, From 582d3dd3c91ad9d314e2aeab96a8cf2d314d2048 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 15 Aug 2026 01:30:43 -0700 Subject: [PATCH 157/159] fix(cli): publish as sim package --- .github/workflows/publish-sim-cli.yml | 6 +- .github/workflows/test-build.yml | 2 +- apps/sim/package.json | 2 +- bun.lock | 212 ++++++++------------------ packages/sim-cli/README.md | 6 +- packages/sim-cli/package.json | 4 +- packages/sim-cli/src/helpers.ts | 2 +- 7 files changed, 77 insertions(+), 157 deletions(-) diff --git a/.github/workflows/publish-sim-cli.yml b/.github/workflows/publish-sim-cli.yml index ca4ff6370bc..29d6d590a8f 100644 --- a/.github/workflows/publish-sim-cli.yml +++ b/.github/workflows/publish-sim-cli.yml @@ -121,7 +121,7 @@ jobs: env: VERSION: ${{ steps.release.outputs.version }} run: | - if bun pm view "@sim/cli@$VERSION" version > /dev/null 2>&1; then + if bun pm view "sim@$VERSION" version > /dev/null 2>&1; then echo "exists=true" >> "$GITHUB_OUTPUT" else echo "exists=false" >> "$GITHUB_OUTPUT" @@ -140,10 +140,10 @@ jobs: env: VERSION: ${{ steps.release.outputs.version }} NPM_TAG: ${{ steps.release.outputs.tag }} - run: echo "Published @sim/cli@$VERSION with the '$NPM_TAG' tag." + run: echo "Published sim@$VERSION with the '$NPM_TAG' tag." - name: Summarize skipped release if: steps.version_check.outputs.exists == 'true' env: VERSION: ${{ steps.release.outputs.version }} - run: echo "Skipped @sim/cli@$VERSION because that version is already published." + run: echo "Skipped sim@$VERSION because that version is already published." diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 09cdf7dbb48..80a0ec62352 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -264,4 +264,4 @@ jobs: AWS_REGION: 'us-west-2' ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only TURBO_CACHE_DIR: .turbo - run: bunx turbo run build --filter=sim + run: bunx turbo run build --filter=@sim/app diff --git a/apps/sim/package.json b/apps/sim/package.json index 846442f7c1a..5ae6a5509f2 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -1,5 +1,5 @@ { - "name": "sim", + "name": "@sim/app", "version": "0.1.0", "private": true, "license": "Apache-2.0", diff --git a/bun.lock b/bun.lock index 83a087efddb..f5bfb0b253f 100644 --- a/bun.lock +++ b/bun.lock @@ -135,7 +135,7 @@ }, }, "apps/sim": { - "name": "sim", + "name": "@sim/app", "version": "0.1.0", "dependencies": { "@1password/sdk": "0.3.1", @@ -587,8 +587,8 @@ }, }, "packages/sim-cli": { - "name": "@sim/cli", - "version": "0.1.0", + "name": "sim", + "version": "2.0.0", "bin": { "sim": "dist/index.js", }, @@ -1370,8 +1370,6 @@ "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.100", "", { "os": "win32", "cpu": "x64" }, "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA=="], - "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], "@next/env": ["@next/env@16.2.12", "", {}, "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg=="], @@ -1724,55 +1722,7 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.4", "", { "os": "android", "cpu": "arm" }, "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.4", "", { "os": "android", "cpu": "arm64" }, "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA=="], - - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ=="], - - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ=="], - - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g=="], - - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA=="], - - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ=="], - - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w=="], - - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w=="], - - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ=="], - - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA=="], - - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA=="], - - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw=="], - - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA=="], - - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ=="], - - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.4", "", { "os": "none", "cpu": "arm64" }, "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw=="], "@s2-dev/streamstore": ["@s2-dev/streamstore@0.22.10", "", { "dependencies": { "@protobuf-ts/runtime": "^2.11.1", "debug": "^4.4.3" } }, "sha512-dtm+oFHVE8szINwOUoNQdx9xpGSJOrcAEvsxspPFvomjYKGnmhIRmU4OX8o6kxcPoiK76S1tPeU0smjZdmOngA=="], @@ -1798,14 +1748,14 @@ "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], + "@sim/app": ["@sim/app@workspace:apps/sim"], + "@sim/audit": ["@sim/audit@workspace:packages/audit"], "@sim/auth": ["@sim/auth@workspace:packages/auth"], "@sim/browser-protocol": ["@sim/browser-protocol@workspace:packages/browser-protocol"], - "@sim/cli": ["@sim/cli@workspace:packages/sim-cli"], - "@sim/db": ["@sim/db@workspace:packages/db"], "@sim/desktop": ["@sim/desktop@workspace:apps/desktop"], @@ -1932,7 +1882,7 @@ "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -2502,8 +2452,6 @@ "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], - "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], - "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], @@ -2520,7 +2468,7 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -2532,13 +2480,11 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], - "cheerio": ["cheerio@1.1.2", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.0.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.12.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg=="], "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], - "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -2754,8 +2700,6 @@ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], - "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], - "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], @@ -3314,7 +3258,7 @@ "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], @@ -3456,8 +3400,6 @@ "lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="], - "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], - "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], "lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="], @@ -3482,7 +3424,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], @@ -3820,8 +3762,6 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], - "pdf-lib": ["pdf-lib@1.17.1", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "pako": "^1.0.11", "tslib": "^1.11.1" } }, "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw=="], "pdfjs-dist": ["pdfjs-dist@5.4.296", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.80" } }, "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q=="], @@ -4020,7 +3960,7 @@ "readdir-glob": ["readdir-glob@3.0.0", "", { "dependencies": { "minimatch": "^10.2.2" } }, "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw=="], - "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], @@ -4112,8 +4052,6 @@ "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], - "rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="], - "rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="], "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -4204,7 +4142,7 @@ "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "sim": ["sim@workspace:apps/sim"], + "sim": ["sim@workspace:packages/sim-cli"], "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], @@ -4302,8 +4240,6 @@ "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], - "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], - "stripe": ["stripe@18.5.0", "", { "dependencies": { "qs": "^6.11.0" }, "peerDependencies": { "@types/node": ">=12.x.x" }, "optionalPeers": ["@types/node"] }, "sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA=="], "strnum": ["strnum@2.4.0", "", { "dependencies": { "anynum": "^1.0.0" } }, "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg=="], @@ -4342,7 +4278,7 @@ "tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="], - "tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], @@ -4386,12 +4322,8 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], - "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], - "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], - "tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="], "tldts-core": ["tldts-core@7.4.3", "", {}, "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw=="], @@ -4524,8 +4456,6 @@ "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], - "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], - "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], @@ -4632,6 +4562,8 @@ "@a2a-js/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@apidevtools/json-schema-ref-parser/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -4664,6 +4596,8 @@ "@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@better-auth/core/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@better-auth/core/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], "@better-auth/sso/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -4702,8 +4636,6 @@ "@earendil-works/pi-tui/marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], - "@electric-sql/client/@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw=="], - "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -4724,6 +4656,8 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@fumadocs/tailwind/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + "@google-cloud/storage/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -4878,7 +4812,7 @@ "@react-email/components/@react-email/render": ["@react-email/render@2.0.6", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog=="], - "@react-email/markdown/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "@react-email/tailwind/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], "@reactflow/background/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], @@ -4898,14 +4832,14 @@ "@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], - "@sim/cli/vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="], - "@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="], "@socket.io/redis-adapter/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "@tailwindcss/node/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], @@ -4920,6 +4854,10 @@ "@tailwindcss/postcss/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "@tailwindcss/postcss/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + + "@tiptap/markdown/marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + "@trigger.dev/core/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw=="], "@trigger.dev/core/@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], @@ -4982,7 +4920,7 @@ "@types/ws/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -5004,6 +4942,8 @@ "app-builder-lib/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "app-builder-lib/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "app-builder-lib/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], @@ -5020,7 +4960,7 @@ "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], "c12/confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], @@ -5064,8 +5004,12 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "dmg-builder/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "docs/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], + "docs/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], + "docx/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "docx/nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], @@ -5084,6 +5028,8 @@ "echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], + "effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "electron/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -5092,6 +5038,8 @@ "electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + "electron-updater/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "electron-updater/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], @@ -5128,12 +5076,18 @@ "fumadocs-core/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + "fumadocs-mdx/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "fumadocs-mdx/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "fumadocs-mdx/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], "fumadocs-openapi/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], "fumadocs-openapi/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "fumadocs-openapi/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "fumadocs-openapi/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], "fumadocs-openapi/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], @@ -5226,8 +5180,6 @@ "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - "nuqs/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], - "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -5262,13 +5214,11 @@ "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - "react-email/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "react-email/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], "react-email/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "react-email/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "react-email/tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], "react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], @@ -5288,10 +5238,6 @@ "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], - "sim/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], - - "sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], - "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], @@ -5304,6 +5250,8 @@ "stream-browserify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "streamdown/marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + "streamdown/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -5316,12 +5264,18 @@ "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], - "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "svix/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + "tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "tailwindcss/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], + "teeny-request/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], "teeny-request/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], @@ -5348,10 +5302,6 @@ "vite/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], - "vite-node/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], - - "vite-node/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], - "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -5474,28 +5424,6 @@ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], - "@sim/cli/vitest/@vitest/expect": ["@vitest/expect@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w=="], - - "@sim/cli/vitest/@vitest/mocker": ["@vitest/mocker@3.2.7", "", { "dependencies": { "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA=="], - - "@sim/cli/vitest/@vitest/pretty-format": ["@vitest/pretty-format@3.2.7", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA=="], - - "@sim/cli/vitest/@vitest/runner": ["@vitest/runner@3.2.7", "", { "dependencies": { "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA=="], - - "@sim/cli/vitest/@vitest/snapshot": ["@vitest/snapshot@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g=="], - - "@sim/cli/vitest/@vitest/spy": ["@vitest/spy@3.2.7", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ=="], - - "@sim/cli/vitest/@vitest/utils": ["@vitest/utils@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw=="], - - "@sim/cli/vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], - - "@sim/cli/vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - - "@sim/cli/vitest/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], - - "@sim/cli/vitest/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], - "@tailwindcss/postcss/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA=="], @@ -5558,8 +5486,6 @@ "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -5646,6 +5572,10 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "fumadocs-mdx/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + + "fumadocs-openapi/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "gcp-metadata/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "giget/nypm/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], @@ -5738,21 +5668,17 @@ "protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "react-email/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "sim/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "sim/tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "sim/tailwindcss/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "sim/tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], + "tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "tailwindcss/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "teeny-request/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], @@ -5802,14 +5728,8 @@ "rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "sim/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - - "sim/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - - "sim/tailwindcss/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "@trigger.dev/core/socket.io/engine.io/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - - "sim/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], } } diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index eec482d12cb..80a05314968 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -3,7 +3,7 @@ Talk to the [Sim](https://sim.ai) API from your terminal. ```bash -npm install --global @sim/cli +npm install --global sim sim login sim workflows list ``` @@ -11,8 +11,8 @@ sim workflows list Prerelease channels track the corresponding Sim environments: ```bash -npm install --global @sim/cli@preview # staging -npm install --global @sim/cli@dev # dev +npm install --global sim@preview # staging +npm install --global sim@dev # dev ``` ## Profiles diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json index 545ab48bcee..d738c306918 100644 --- a/packages/sim-cli/package.json +++ b/packages/sim-cli/package.json @@ -1,6 +1,6 @@ { - "name": "@sim/cli", - "version": "0.1.0", + "name": "sim", + "version": "2.0.0", "description": "Sim CLI - talk to the Sim API from your terminal", "type": "module", "bin": { diff --git a/packages/sim-cli/src/helpers.ts b/packages/sim-cli/src/helpers.ts index 9cfd149591b..6b5f77fe373 100644 --- a/packages/sim-cli/src/helpers.ts +++ b/packages/sim-cli/src/helpers.ts @@ -1,7 +1,7 @@ /** * Local copies of the shared helpers. * - * `@sim/utils` is a private workspace package, so a published `@sim/cli` + * `@sim/utils` is a private workspace package, so the published `sim` package * cannot depend on it — importing it would resolve in the monorepo and fail for * anyone installing from npm. */ From d252c863dd6c6b3933803945094fbd786c7ed51e Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 15 Aug 2026 01:37:44 -0700 Subject: [PATCH 158/159] fix(cli): use staging npm tag --- .github/workflows/publish-sim-cli.yml | 2 +- packages/sim-cli/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-sim-cli.yml b/.github/workflows/publish-sim-cli.yml index 29d6d590a8f..f36e514ec69 100644 --- a/.github/workflows/publish-sim-cli.yml +++ b/.github/workflows/publish-sim-cli.yml @@ -81,7 +81,7 @@ jobs: ;; staging) VERSION="${BASE_VERSION}-preview.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" - TAG="preview" + TAG="staging" ;; main) VERSION="$BASE_VERSION" diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 80a05314968..979c2b77142 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -11,7 +11,7 @@ sim workflows list Prerelease channels track the corresponding Sim environments: ```bash -npm install --global sim@preview # staging +npm install --global sim@staging # staging npm install --global sim@dev # dev ``` From 7b84b0288b856bf149ec00d36482ab18213cbc09 Mon Sep 17 00:00:00 2001 From: Theodore Li <theo@sim.ai> Date: Sat, 15 Aug 2026 01:43:36 -0700 Subject: [PATCH 159/159] fix(cli): align vitest lock resolution --- bun.lock | 4 +++- packages/sim-cli/package.json | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index f5bfb0b253f..78d53a9db71 100644 --- a/bun.lock +++ b/bun.lock @@ -601,7 +601,7 @@ "commander": "^11.1.0", "js-yaml": "4.3.0", "typescript": "^7.0.2", - "vitest": "^3.2.4", + "vitest": "^4.1.0", }, }, "packages/terminal-protocol": { @@ -5238,6 +5238,8 @@ "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "sim/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json index d738c306918..432ee0f3fb3 100644 --- a/packages/sim-cli/package.json +++ b/packages/sim-cli/package.json @@ -55,6 +55,6 @@ "commander": "^11.1.0", "js-yaml": "4.3.0", "typescript": "^7.0.2", - "vitest": "^3.2.4" + "vitest": "^4.1.0" } }